013-scripts-management #19

Merged
ahmido merged 17 commits from 013-scripts-management into dev 2026-01-01 22:02:30 +00:00
18 changed files with 1773 additions and 60 deletions

View File

@ -623,6 +623,7 @@ private static function normalizedPolicyState(Policy $record): array
$normalized['context'] = 'policy';
$normalized['record_id'] = (string) $record->getKey();
$normalized['policy_type'] = $record->policy_type;
$request->attributes->set($cacheKey, $normalized);

View File

@ -87,6 +87,7 @@ public static function infolist(Schema $schema): Schema
$normalized['context'] = 'version';
$normalized['record_id'] = (string) $record->getKey();
$normalized['policy_type'] = $record->policy_type;
return $normalized;
})
@ -114,7 +115,10 @@ public static function infolist(Schema $schema): Schema
: [];
$to = $normalizer->flattenForDiff($record->snapshot ?? [], $record->policy_type ?? '', $record->platform);
return $diff->compare($from, $to);
$result = $diff->compare($from, $to);
$result['policy_type'] = $record->policy_type;
return $result;
}),
Infolists\Components\ViewEntry::make('diff_json')
->label('Raw diff (advanced)')

View File

@ -9,6 +9,7 @@
use App\Services\Intune\CompliancePolicyNormalizer;
use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
use App\Services\Intune\GroupPolicyConfigurationNormalizer;
use App\Services\Intune\ScriptsPolicyNormalizer;
use App\Services\Intune\SettingsCatalogPolicyNormalizer;
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
@ -42,6 +43,7 @@ public function register(): void
CompliancePolicyNormalizer::class,
DeviceConfigurationPolicyNormalizer::class,
GroupPolicyConfigurationNormalizer::class,
ScriptsPolicyNormalizer::class,
SettingsCatalogPolicyNormalizer::class,
WindowsFeatureUpdateProfileNormalizer::class,
WindowsQualityUpdateProfileNormalizer::class,

View File

@ -39,7 +39,7 @@ public function fetch(
$primaryException = null;
$assignments = [];
$primarySucceeded = false;
$lastSuccessfulAssignments = null;
// Try primary endpoint(s)
$listPathTemplates = [];
@ -65,7 +65,12 @@ public function fetch(
$context,
$throwOnFailure
);
$primarySucceeded = true;
if ($assignments === null) {
continue;
}
$lastSuccessfulAssignments = $assignments;
if (! empty($assignments)) {
Log::debug('Fetched assignments via primary endpoint', [
@ -77,20 +82,25 @@ public function fetch(
return $assignments;
}
if ($policyType !== 'appProtectionPolicy') {
// Empty is a valid outcome (policy not assigned). Do not attempt fallback.
return [];
}
} catch (GraphException $e) {
$primaryException = $primaryException ?? $e;
}
}
if ($primarySucceeded && $policyType === 'appProtectionPolicy') {
if ($lastSuccessfulAssignments !== null && $policyType === 'appProtectionPolicy') {
Log::debug('Assignments fetched via primary endpoint(s)', [
'tenant_id' => $tenantId,
'policy_type' => $policyType,
'policy_id' => $policyId,
'count' => count($assignments),
'count' => count($lastSuccessfulAssignments),
]);
return $assignments;
return $lastSuccessfulAssignments;
}
// Try fallback with $expand
@ -215,15 +225,15 @@ private function fetchPrimary(
array $options,
array $context,
bool $throwOnFailure
): array {
): ?array {
if (! is_string($listPathTemplate) || $listPathTemplate === '') {
return [];
return null;
}
$path = $this->resolvePath($listPathTemplate, $policyId);
if ($path === null) {
return [];
return null;
}
$response = $this->graphClient->request('GET', $path, $options);
@ -239,7 +249,7 @@ private function fetchPrimary(
);
}
return [];
return null;
}
return $response->data['value'] ?? [];

View File

@ -0,0 +1,248 @@
<?php
namespace App\Services\Intune;
use Illuminate\Support\Arr;
class ScriptsPolicyNormalizer implements PolicyTypeNormalizer
{
public function __construct(private readonly DefaultPolicyNormalizer $defaultNormalizer) {}
public function supports(string $policyType): bool
{
return in_array($policyType, [
'deviceManagementScript',
'deviceShellScript',
'deviceHealthScript',
], true);
}
/**
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
*/
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = is_array($snapshot) ? $snapshot : [];
$displayName = Arr::get($snapshot, 'displayName') ?? Arr::get($snapshot, 'name');
$description = Arr::get($snapshot, 'description');
$entries = [];
$entries[] = ['key' => 'Type', 'value' => $policyType];
if (is_string($displayName) && $displayName !== '') {
$entries[] = ['key' => 'Display name', 'value' => $displayName];
}
if (is_string($description) && $description !== '') {
$entries[] = ['key' => 'Description', 'value' => $description];
}
$entries = array_merge($entries, $this->contentEntries($snapshot));
$schedule = Arr::get($snapshot, 'runSchedule');
if (is_array($schedule) && $schedule !== []) {
$entries[] = ['key' => 'Run schedule', 'value' => Arr::except($schedule, ['@odata.type'])];
}
$frequency = Arr::get($snapshot, 'runFrequency');
if (is_string($frequency) && $frequency !== '') {
$entries[] = ['key' => 'Run frequency', 'value' => $frequency];
}
$roleScopeTagIds = Arr::get($snapshot, 'roleScopeTagIds');
if (is_array($roleScopeTagIds) && $roleScopeTagIds !== []) {
$entries[] = ['key' => 'Scope tag IDs', 'value' => array_values($roleScopeTagIds)];
}
return [
'status' => 'ok',
'settings' => [
[
'type' => 'keyValue',
'title' => 'Script settings',
'entries' => $entries,
],
],
'warnings' => [],
];
}
/**
* @return array<int, array{key: string, value: mixed}>
*/
private function contentEntries(array $snapshot): array
{
$showContent = (bool) config('tenantpilot.display.show_script_content', false);
$maxChars = (int) config('tenantpilot.display.max_script_content_chars', 5000);
if ($maxChars <= 0) {
$maxChars = 5000;
}
if (! $showContent) {
return $this->contentSummaryEntries($snapshot);
}
$entries = [];
$scriptContent = Arr::get($snapshot, 'scriptContent');
if (is_string($scriptContent) && $scriptContent !== '') {
$decoded = $this->decodeIfBase64Text($scriptContent);
if (is_string($decoded) && $decoded !== '') {
$scriptContent = $decoded;
}
}
if (! is_string($scriptContent) || $scriptContent === '') {
$scriptContentBase64 = Arr::get($snapshot, 'scriptContentBase64');
if (is_string($scriptContentBase64) && $scriptContentBase64 !== '') {
$decoded = base64_decode($this->stripWhitespace($scriptContentBase64), true);
if (is_string($decoded) && $decoded !== '') {
$scriptContent = $this->normalizeDecodedText($decoded);
}
}
}
if (is_string($scriptContent) && $scriptContent !== '') {
$entries[] = ['key' => 'scriptContent', 'value' => $this->limitContent($scriptContent, $maxChars)];
}
foreach (['detectionScriptContent', 'remediationScriptContent'] as $key) {
$value = Arr::get($snapshot, $key);
if (! is_string($value) || $value === '') {
continue;
}
$decoded = $this->decodeIfBase64Text($value);
if (is_string($decoded) && $decoded !== '') {
$value = $decoded;
}
$entries[] = ['key' => $key, 'value' => $this->limitContent($value, $maxChars)];
}
return $entries;
}
private function decodeIfBase64Text(string $candidate): ?string
{
$trimmed = $this->stripWhitespace($candidate);
if ($trimmed === '' || strlen($trimmed) < 16) {
return null;
}
if (strlen($trimmed) % 4 !== 0) {
return null;
}
if (! preg_match('/^[A-Za-z0-9+\/=]+$/', $trimmed)) {
return null;
}
$decoded = base64_decode($trimmed, true);
if (! is_string($decoded) || $decoded === '') {
return null;
}
$decoded = $this->normalizeDecodedText($decoded);
if ($decoded === '') {
return null;
}
if (! $this->looksLikeText($decoded)) {
return null;
}
return $decoded;
}
private function stripWhitespace(string $value): string
{
return preg_replace('/\s+/', '', $value) ?? '';
}
private function normalizeDecodedText(string $decoded): string
{
if (str_starts_with($decoded, "\xFF\xFE")) {
$decoded = mb_convert_encoding(substr($decoded, 2), 'UTF-8', 'UTF-16LE');
} elseif (str_starts_with($decoded, "\xFE\xFF")) {
$decoded = mb_convert_encoding(substr($decoded, 2), 'UTF-8', 'UTF-16BE');
} elseif (str_contains($decoded, "\x00")) {
$decoded = mb_convert_encoding($decoded, 'UTF-8', 'UTF-16LE');
}
if (str_starts_with($decoded, "\xEF\xBB\xBF")) {
$decoded = substr($decoded, 3);
}
return $decoded;
}
private function looksLikeText(string $decoded): bool
{
$length = strlen($decoded);
if ($length === 0) {
return false;
}
$nonPrintable = preg_match_all('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $decoded) ?: 0;
if ($nonPrintable > (int) max(1, $length * 0.05)) {
return false;
}
// Scripts should typically contain some whitespace or line breaks.
if ($length >= 24 && ! preg_match('/\s/', $decoded)) {
return false;
}
return true;
}
/**
* @return array<int, array{key: string, value: mixed}>
*/
private function contentSummaryEntries(array $snapshot): array
{
// Script content and large blobs should not dominate normalized output.
// Keep only safe summary fields if present.
$contentKeys = [
'scriptContent',
'scriptContentBase64',
'detectionScriptContent',
'remediationScriptContent',
];
$entries = [];
foreach ($contentKeys as $key) {
$value = Arr::get($snapshot, $key);
if (is_string($value) && $value !== '') {
$entries[] = ['key' => $key, 'value' => sprintf('[content: %d chars]', strlen($value))];
}
}
return $entries;
}
private function limitContent(string $content, int $maxChars): string
{
if (mb_strlen($content) <= $maxChars) {
return $content;
}
return mb_substr($content, 0, $maxChars).'…';
}
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
$normalized = $this->normalize($snapshot, $policyType, $platform);
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
}
}

View File

@ -8,6 +8,7 @@
"require": {
"php": "^8.2",
"filament/filament": "^4.0",
"lara-zeus/torch-filament": "^2.0",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1",
"pepperfm/filament-json": "^4"

193
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "c4f08fd9fc4b86cc13b75332dd6e1b7a",
"content-hash": "20819254265bddd0aa70006919cb735f",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@ -2082,6 +2082,87 @@
},
"time": "2025-11-13T14:57:49+00:00"
},
{
"name": "lara-zeus/torch-filament",
"version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/lara-zeus/torch-filament.git",
"reference": "71dbe8df4a558a80308781ba20c5922943b33009"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lara-zeus/torch-filament/zipball/71dbe8df4a558a80308781ba20c5922943b33009",
"reference": "71dbe8df4a558a80308781ba20c5922943b33009",
"shasum": ""
},
"require": {
"filament/filament": "^4.0",
"php": "^8.1",
"spatie/laravel-package-tools": "^1.16",
"torchlight/engine": "^0.1.0"
},
"require-dev": {
"larastan/larastan": "^2.0",
"laravel/pint": "^1.0",
"nunomaduro/collision": "^7.0",
"nunomaduro/phpinsights": "^2.8",
"orchestra/testbench": "^8.0",
"phpstan/extension-installer": "^1.1"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"LaraZeus\\TorchFilament\\TorchFilamentServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"LaraZeus\\TorchFilament\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Lara Zeus",
"email": "info@larazeus.com"
}
],
"description": "Infolist component to highlight code using Torchlight Engine",
"homepage": "https://larazeus.com/torch-filament",
"keywords": [
"code",
"design",
"engine",
"filamentphp",
"highlight",
"input",
"lara-zeus",
"laravel",
"torchlight",
"ui"
],
"support": {
"issues": "https://github.com/lara-zeus/torch-filament/issues",
"source": "https://github.com/lara-zeus/torch-filament"
},
"funding": [
{
"url": "https://www.buymeacoffee.com/larazeus",
"type": "custom"
},
{
"url": "https://github.com/atmonshi",
"type": "github"
}
],
"time": "2025-06-11T19:32:10+00:00"
},
{
"name": "laravel/framework",
"version": "v12.42.0",
@ -4265,6 +4346,60 @@
},
"time": "2025-02-26T00:08:40+00:00"
},
{
"name": "phiki/phiki",
"version": "v1.1.6",
"source": {
"type": "git",
"url": "https://github.com/phikiphp/phiki.git",
"reference": "3174d8cb309bdccc32b7a33500379de76148256b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phikiphp/phiki/zipball/3174d8cb309bdccc32b7a33500379de76148256b",
"reference": "3174d8cb309bdccc32b7a33500379de76148256b",
"shasum": ""
},
"require": {
"league/commonmark": "^2.5.3",
"php": "^8.2"
},
"require-dev": {
"illuminate/support": "^11.30",
"laravel/pint": "^1.18.1",
"pestphp/pest": "^3.5.1",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.0",
"symfony/var-dumper": "^7.1.6"
},
"bin": [
"bin/phiki"
],
"type": "library",
"autoload": {
"psr-4": {
"Phiki\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ryan Chandler",
"email": "support@ryangjchandler.co.uk",
"homepage": "https://ryangjchandler.co.uk",
"role": "Developer"
}
],
"description": "Syntax highlighting using TextMate grammars in PHP.",
"support": {
"issues": "https://github.com/phikiphp/phiki/issues",
"source": "https://github.com/phikiphp/phiki/tree/v1.1.6"
},
"time": "2025-06-06T20:18:29+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.4",
@ -8110,6 +8245,62 @@
},
"time": "2024-12-21T16:25:41+00:00"
},
{
"name": "torchlight/engine",
"version": "v0.1.0",
"source": {
"type": "git",
"url": "https://github.com/torchlight-api/engine.git",
"reference": "8d12f611efb0b22406ec0744abb453ddd2f1fe9d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/torchlight-api/engine/zipball/8d12f611efb0b22406ec0744abb453ddd2f1fe9d",
"reference": "8d12f611efb0b22406ec0744abb453ddd2f1fe9d",
"shasum": ""
},
"require": {
"league/commonmark": "^2.5.3",
"phiki/phiki": "^1.1.4",
"php": "^8.2"
},
"require-dev": {
"ext-dom": "*",
"ext-libxml": "*",
"laravel/pint": "^1.13",
"pestphp/pest": "^2"
},
"type": "library",
"autoload": {
"psr-4": {
"Torchlight\\Engine\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Aaron Francis",
"email": "aaron@hammerstone.dev"
},
{
"name": "John Koster",
"email": "john@stillat.com"
}
],
"description": "The PHP-based Torchlight code annotation and rendering engine.",
"keywords": [
"Code highlighting",
"syntax highlighting"
],
"support": {
"issues": "https://github.com/torchlight-api/engine/issues",
"source": "https://github.com/torchlight-api/engine/tree/v0.1.0"
},
"time": "2025-04-02T01:47:48+00:00"
},
{
"name": "ueberdosis/tiptap-php",
"version": "2.0.0",

View File

@ -218,4 +218,9 @@
'chunk_size' => (int) env('TENANTPILOT_BULK_CHUNK_SIZE', 10),
'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3),
],
'display' => [
'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false),
'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000),
],
];

View File

@ -1,6 +1,7 @@
@php
$diff = $getState() ?? ['summary' => [], 'added' => [], 'removed' => [], 'changed' => []];
$summary = $diff['summary'] ?? [];
$policyType = $diff['policy_type'] ?? null;
$groupByBlock = static function (array $items): array {
$groups = [];
@ -50,6 +51,180 @@
return is_string($value) && strlen($value) > 160;
};
$isScriptKey = static function (mixed $name): bool {
return in_array((string) $name, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true);
};
$canHighlightScripts = static function (?string $policyType): bool {
return (bool) config('tenantpilot.display.show_script_content', false)
&& in_array($policyType, ['deviceManagementScript', 'deviceShellScript', 'deviceHealthScript'], true);
};
$selectGrammar = static function (?string $policyType, string $code): string {
if ($policyType === 'deviceShellScript') {
$firstLine = strtok($code, "\n") ?: '';
$shebang = trim($firstLine);
if (str_starts_with($shebang, '#!')) {
if (str_contains($shebang, 'zsh')) {
return 'zsh';
}
if (str_contains($shebang, 'bash')) {
return 'bash';
}
return 'sh';
}
return 'sh';
}
return 'powershell';
};
$highlight = static function (?string $policyType, string $code, string $fallbackClass = '') use ($selectGrammar): ?string {
if (! class_exists(\Torchlight\Engine\Engine::class)) {
return null;
}
try {
return (new \Torchlight\Engine\Engine())->codeToHtml(
code: $code,
grammar: $selectGrammar($policyType, $code),
theme: [
'light' => 'github-light',
'dark' => 'github-dark',
],
withGutter: false,
withWrapper: true,
);
} catch (\Throwable $e) {
return null;
}
};
$highlightInline = static function (?string $policyType, string $code) use ($selectGrammar): ?string {
if (! class_exists(\Torchlight\Engine\Engine::class)) {
return null;
}
if ($code === '') {
return '';
}
try {
$html = (new \Torchlight\Engine\Engine())->codeToHtml(
code: $code,
grammar: $selectGrammar($policyType, $code),
theme: [
'light' => 'github-light',
'dark' => 'github-dark',
],
withGutter: false,
withWrapper: false,
);
$html = (string) preg_replace('/<!--\s*Syntax highlighted by[^>]*-->/', '', $html);
if (! preg_match('/<code\b[^>]*>.*?<\\/code>/s', $html, $matches)) {
return null;
}
return trim((string) ($matches[0] ?? ''));
} catch (\Throwable $e) {
return null;
}
};
$splitLines = static function (string $text): array {
$text = str_replace(["\r\n", "\r"], "\n", $text);
return $text === '' ? [] : explode("\n", $text);
};
$myersLineDiff = static function (array $a, array $b): array {
$n = count($a);
$m = count($b);
$max = $n + $m;
$v = [1 => 0];
$trace = [];
for ($d = 0; $d <= $max; $d++) {
$trace[$d] = $v;
for ($k = -$d; $k <= $d; $k += 2) {
$kPlus = $v[$k + 1] ?? 0;
$kMinus = $v[$k - 1] ?? 0;
if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) {
$x = $kPlus;
} else {
$x = $kMinus + 1;
}
$y = $x - $k;
while ($x < $n && $y < $m && $a[$x] === $b[$y]) {
$x++;
$y++;
}
$v[$k] = $x;
if ($x >= $n && $y >= $m) {
break 2;
}
}
}
$ops = [];
$x = $n;
$y = $m;
for ($d = count($trace) - 1; $d >= 0; $d--) {
$v = $trace[$d];
$k = $x - $y;
$kPlus = $v[$k + 1] ?? 0;
$kMinus = $v[$k - 1] ?? 0;
if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) {
$prevK = $k + 1;
} else {
$prevK = $k - 1;
}
$prevX = $v[$prevK] ?? 0;
$prevY = $prevX - $prevK;
while ($x > $prevX && $y > $prevY) {
$ops[] = ['type' => 'equal', 'line' => $a[$x - 1]];
$x--;
$y--;
}
if ($d === 0) {
break;
}
if ($x === $prevX) {
$ops[] = ['type' => 'insert', 'line' => $b[$y - 1] ?? ''];
$y--;
} else {
$ops[] = ['type' => 'delete', 'line' => $a[$x - 1] ?? ''];
$x--;
}
}
return array_reverse($ops);
};
$scriptLineDiff = static function (string $fromText, string $toText) use ($splitLines, $myersLineDiff): array {
return $myersLineDiff($splitLines($fromText), $splitLines($toText));
};
@endphp
<div class="space-y-4">
@ -103,11 +278,440 @@
$to = $value['to'];
$fromText = $stringify($from);
$toText = $stringify($to);
$isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name);
$ops = $isScriptContent ? $scriptLineDiff((string) $fromText, (string) $toText) : [];
$useTorchlight = $isScriptContent && class_exists(\Torchlight\Engine\Engine::class);
$rows = [];
if ($isScriptContent) {
$count = count($ops);
for ($i = 0; $i < $count; $i++) {
$op = $ops[$i];
$next = $ops[$i + 1] ?? null;
$type = $op['type'] ?? null;
$line = (string) ($op['line'] ?? '');
if ($type === 'equal') {
$rows[] = [
'left' => ['type' => 'equal', 'line' => $line],
'right' => ['type' => 'equal', 'line' => $line],
];
continue;
}
if ($type === 'delete' && is_array($next) && ($next['type'] ?? null) === 'insert') {
$rows[] = [
'left' => ['type' => 'delete', 'line' => $line],
'right' => ['type' => 'insert', 'line' => (string) ($next['line'] ?? '')],
];
$i++;
continue;
}
if ($type === 'delete') {
$rows[] = [
'left' => ['type' => 'delete', 'line' => $line],
'right' => ['type' => 'blank', 'line' => ''],
];
continue;
}
if ($type === 'insert') {
$rows[] = [
'left' => ['type' => 'blank', 'line' => ''],
'right' => ['type' => 'insert', 'line' => $line],
];
continue;
}
}
}
@endphp
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ (string) $name }}
</div>
@if ($isScriptContent)
<div class="text-sm text-gray-600 dark:text-gray-300 sm:col-span-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Script</span>
<details class="mt-1" x-data="{ fullscreenOpen: false }">
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
View
</summary>
<div x-data="{ tab: 'diff' }" class="mt-2 space-y-3">
<div class="flex flex-wrap items-center gap-2">
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'diff'" x-bind:class="tab === 'diff' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
Diff
</x-filament::button>
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'before'" x-bind:class="tab === 'before' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
Before
</x-filament::button>
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'after'" x-bind:class="tab === 'after' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
After
</x-filament::button>
<x-filament::button size="xs" color="gray" type="button" x-on:click="fullscreenOpen = true">
Fullscreen
</x-filament::button>
</div>
<div x-show="tab === 'diff'" x-cloak>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Old</div>
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@php
foreach ($rows as $row) {
$left = $row['left'];
$leftType = $left['type'];
$leftLine = (string) ($left['line'] ?? '');
$leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
$leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
if ($leftType === 'equal') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="tp-script-diff-line">'.$leftRendered."</span>\n";
continue;
}
if ($leftType === 'delete') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="block tp-script-diff-line bg-danger-50 text-danger-700 dark:bg-danger-950/40 dark:text-danger-200">- '.$leftRendered."</span>\n";
continue;
}
echo "\n";
}
@endphp</pre>
</div>
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">New</div>
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@php
foreach ($rows as $row) {
$right = $row['right'];
$rightType = $right['type'];
$rightLine = (string) ($right['line'] ?? '');
$rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
$rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
if ($rightType === 'equal') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="tp-script-diff-line">'.$rightRendered."</span>\n";
continue;
}
if ($rightType === 'insert') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="block tp-script-diff-line bg-success-50 text-success-700 dark:bg-success-950/40 dark:text-success-200">+ '.$rightRendered."</span>\n";
continue;
}
echo "\n";
}
@endphp</pre>
</div>
</div>
</div>
<div x-show="tab === 'before'" x-cloak>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Before</div>
@php
$highlightedBefore = $useTorchlight ? $highlight($policyType, (string) $fromText) : null;
@endphp
@if (is_string($highlightedBefore) && $highlightedBefore !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="mt-1 max-h-96 overflow-auto">{!! $highlightedBefore !!}</div>
@else
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $fromText }}</pre>
@endif
</div>
<div x-show="tab === 'after'" x-cloak>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">After</div>
@php
$highlightedAfter = $useTorchlight ? $highlight($policyType, (string) $toText) : null;
@endphp
@if (is_string($highlightedAfter) && $highlightedAfter !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="mt-1 max-h-96 overflow-auto">{!! $highlightedAfter !!}</div>
@else
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $toText }}</pre>
@endif
</div>
</div>
<div
x-show="fullscreenOpen"
x-cloak
x-on:keydown.escape.window="fullscreenOpen = false"
class="fixed inset-0 z-50"
>
<div class="absolute inset-0 bg-gray-950/50"></div>
<div class="relative flex h-full w-full flex-col bg-white dark:bg-gray-900">
<div class="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-white/10">
<div class="text-sm font-medium text-gray-900 dark:text-white">Script diff</div>
<div class="flex items-center gap-2">
<x-filament::button size="sm" color="gray" type="button" x-on:click="fullscreenOpen = false">
Close
</x-filament::button>
</div>
</div>
<div class="flex-1 overflow-hidden p-4">
<div
x-data="{
tab: 'diff',
syncing: false,
syncHorizontal: true,
sync(from, to) {
if (this.syncing) return;
this.syncing = true;
to.scrollTop = from.scrollTop;
const bothHorizontal = this.syncHorizontal
&& from.scrollWidth > from.clientWidth
&& to.scrollWidth > to.clientWidth;
if (bothHorizontal) {
to.scrollLeft = from.scrollLeft;
}
requestAnimationFrame(() => { this.syncing = false; });
},
}"
x-init="$nextTick(() => {
const left = $refs.left;
const right = $refs.right;
if (!left || !right) return;
left.addEventListener('scroll', () => sync(left, right), { passive: true });
right.addEventListener('scroll', () => sync(right, left), { passive: true });
})"
class="h-full space-y-3"
>
<div class="flex flex-wrap items-center gap-2">
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'diff'" x-bind:class="tab === 'diff' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
Diff
</x-filament::button>
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'before'" x-bind:class="tab === 'before' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
Before
</x-filament::button>
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'after'" x-bind:class="tab === 'after' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
After
</x-filament::button>
</div>
<div x-show="tab === 'diff'" x-cloak class="h-[calc(100%-3rem)]">
<div class="grid h-full grid-cols-1 gap-4 lg:grid-cols-2">
<div class="flex h-full flex-col">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Old</div>
<pre x-ref="left" class="mt-2 flex-1 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@php
foreach ($rows as $row) {
$left = $row['left'];
$leftType = $left['type'];
$leftLine = (string) ($left['line'] ?? '');
$leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
$leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
if ($leftType === 'equal') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="tp-script-diff-line">'.$leftRendered."</span>\n";
continue;
}
if ($leftType === 'delete') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="block tp-script-diff-line bg-danger-50 text-danger-700 dark:bg-danger-950/40 dark:text-danger-200">- '.$leftRendered."</span>\n";
continue;
}
echo "\n";
}
@endphp</pre>
</div>
<div class="flex h-full flex-col">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">New</div>
<pre x-ref="right" class="mt-2 flex-1 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@php
foreach ($rows as $row) {
$right = $row['right'];
$rightType = $right['type'];
$rightLine = (string) ($right['line'] ?? '');
$rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
$rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
if ($rightType === 'equal') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="tp-script-diff-line">'.$rightRendered."</span>\n";
continue;
}
if ($rightType === 'insert') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="block tp-script-diff-line bg-success-50 text-success-700 dark:bg-success-950/40 dark:text-success-200">+ '.$rightRendered."</span>\n";
continue;
}
echo "\n";
}
@endphp</pre>
</div>
</div>
</div>
<div x-show="tab === 'before'" x-cloak class="h-[calc(100%-3rem)]">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Before</div>
@php
$highlightedBeforeFullscreen = $useTorchlight ? $highlight($policyType, (string) $fromText) : null;
@endphp
@if (is_string($highlightedBeforeFullscreen) && $highlightedBeforeFullscreen !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="mt-2 h-full overflow-auto">{!! $highlightedBeforeFullscreen !!}</div>
@else
<pre class="mt-2 h-full overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $fromText }}</pre>
@endif
</div>
<div x-show="tab === 'after'" x-cloak class="h-[calc(100%-3rem)]">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">After</div>
@php
$highlightedAfterFullscreen = $useTorchlight ? $highlight($policyType, (string) $toText) : null;
@endphp
@if (is_string($highlightedAfterFullscreen) && $highlightedAfterFullscreen !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="mt-2 h-full overflow-auto">{!! $highlightedAfterFullscreen !!}</div>
@else
<pre class="mt-2 h-full overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $toText }}</pre>
@endif
</div>
</div>
</div>
</div>
</div>
</details>
</div>
@else
<div class="text-sm text-gray-600 dark:text-gray-300">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">From</span>
@if ($isExpandable($from))
@ -134,6 +738,7 @@
<div class="mt-1">{{ $toText }}</div>
@endif
</div>
@endif
</div>
@else
@php
@ -149,7 +754,20 @@
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
View
</summary>
@php
$isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name);
$highlighted = $isScriptContent ? $highlight($policyType, (string) $text) : null;
@endphp
@if (is_string($highlighted) && $highlighted !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="mt-2 overflow-x-auto">{!! $highlighted !!}</div>
@else
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $text }}</pre>
@endif
</details>
@else
<div class="break-words">{{ $text }}</div>

View File

@ -7,6 +7,7 @@
$warnings = $state['warnings'] ?? [];
$settings = $state['settings'] ?? [];
$settingsTable = $state['settings_table'] ?? null;
$policyType = $state['policy_type'] ?? null;
@endphp
<div class="space-y-4">
@ -65,7 +66,11 @@
{{-- Settings Blocks (for OMA Settings, Key/Value pairs, etc.) --}}
@foreach($settings as $block)
@if($block['type'] === 'table')
@php
$blockType = is_array($block) ? ($block['type'] ?? null) : null;
@endphp
@if($blockType === 'table')
<x-filament::section
:heading="$block['title'] ?? 'Settings'"
collapsible
@ -95,8 +100,15 @@
{{ $row['value'] }}
</span>
@else
@php
$value = $row['value'] ?? 'N/A';
if (is_array($value) || is_object($value)) {
$value = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
@endphp
<span class="text-sm text-gray-900 dark:text-white break-words">
{{ Str::limit($row['value'] ?? 'N/A', 200) }}
{{ Str::limit((string) $value, 200) }}
</span>
@endif
</dd>
@ -105,7 +117,7 @@
</div>
</x-filament::section>
@elseif($block['type'] === 'keyValue')
@elseif($blockType === 'keyValue')
<x-filament::section
:heading="$block['title'] ?? 'Settings'"
collapsible
@ -123,9 +135,95 @@
{{ $entry['key'] }}
</dt>
<dd class="mt-1 sm:mt-0 sm:col-span-2">
<span class="text-sm text-gray-900 dark:text-white break-words">
{{ Str::limit($entry['value'] ?? 'N/A', 200) }}
@php
$value = $entry['value'] ?? 'N/A';
$isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true)
&& (bool) config('tenantpilot.display.show_script_content', false);
if (is_array($value) || is_object($value)) {
$value = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
@endphp
@if($isScriptContent)
@php
$code = (string) $value;
$firstLine = strtok($code, "\n") ?: '';
$grammar = 'powershell';
if ($policyType === 'deviceShellScript') {
$shebang = trim($firstLine);
if (str_starts_with($shebang, '#!')) {
if (str_contains($shebang, 'zsh')) {
$grammar = 'zsh';
} elseif (str_contains($shebang, 'bash')) {
$grammar = 'bash';
} else {
$grammar = 'sh';
}
} else {
$grammar = 'sh';
}
} elseif ($policyType === 'deviceManagementScript' || $policyType === 'deviceHealthScript') {
$grammar = 'powershell';
}
$highlightedHtml = null;
if (class_exists(\Torchlight\Engine\Engine::class)) {
try {
$highlightedHtml = (new \Torchlight\Engine\Engine())->codeToHtml(
code: $code,
grammar: $grammar,
theme: [
'light' => 'github-light',
'dark' => 'github-dark',
],
withGutter: false,
withWrapper: true,
);
} catch (\Throwable $e) {
$highlightedHtml = null;
}
}
@endphp
<div x-data="{ open: false }" class="space-y-2">
<div class="flex items-center gap-2">
<x-filament::button
size="xs"
color="gray"
type="button"
x-on:click="open = !open"
>
<span x-show="!open" x-cloak>Show</span>
<span x-show="open" x-cloak>Hide</span>
</x-filament::button>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ number_format(Str::length($code)) }} chars
</span>
</div>
<div x-show="open" x-cloak>
@if (is_string($highlightedHtml) && $highlightedHtml !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="overflow-x-auto">{!! $highlightedHtml !!}</div>
@else
<pre class="text-xs font-mono text-gray-900 dark:text-white whitespace-pre-wrap break-words">{{ $code }}</pre>
@endif
</div>
</div>
@else
<span class="text-sm text-gray-900 dark:text-white break-words">
{{ Str::limit((string) $value, 200) }}
</span>
@endif
</dd>
</div>
@endforeach

View File

@ -0,0 +1,13 @@
<style>
html.dark code.torchlight {
background-color: var(--phiki-dark-background-color) !important;
}
html.dark .phiki,
html.dark .phiki span {
color: var(--phiki-dark-color) !important;
font-style: var(--phiki-dark-font-style) !important;
font-weight: var(--phiki-dark-font-weight) !important;
text-decoration: var(--phiki-dark-text-decoration) !important;
}
</style>

View File

@ -0,0 +1,34 @@
# Specification Quality Checklist: Scripts Management
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-01-01
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Assumptions: Supported script policy types are already discoverable in the product, and restore/assignments follow existing system patterns.

View File

@ -0,0 +1,42 @@
# Plan: Scripts Management (013)
**Branch**: `013-scripts-management`
**Date**: 2026-01-01
**Input**: [spec.md](./spec.md)
## Goal
Provide end-to-end support for script policies (PowerShell scripts, macOS shell scripts, and proactive remediations) with readable normalized settings and safe restore behavior including assignments.
## Scope
### In scope
- Script policy types:
- `deviceManagementScript`
- `deviceShellScript`
- `deviceHealthScript`
- Readable “Normalized settings” output for the above types.
- Restore apply safety is preserved (type mismatch fails; preview vs execute follows existing system behavior).
- Assignment restore is supported (using existing assignment restore mechanisms and contract metadata).
### Out of scope
- Adding new UI flows or pages.
- Introducing new external services or background infrastructure.
- Changing how authentication/authorization works.
## Approach
1. Confirm contract entries exist and are correct for the three script policy types (resource, type families, assignment paths/payload keys).
2. Add a policy normalizer that supports the three script policy types and outputs a stable, readable structure.
3. Register the normalizer in the application normalizer tag.
4. Add tests:
- Normalized output shape/stability for each type.
- Filament “Normalized settings” tab renders without errors for a version of each type.
5. Run targeted tests and Pint.
## Risks & Mitigations
- Scripts may contain large content blobs: normalized view must be readable and avoid overwhelming output (truncate or summarize where needed).
- Platform-specific fields vary: normalizer must handle missing keys safely and remain stable.
## Success Criteria
- Normalized settings views are readable and stable for all three script policy types.
- Restore execution remains safe and assignment behavior is unchanged/regression-free.
- Tests cover the new normalizer behavior and basic UI render.

View File

@ -0,0 +1,112 @@
# Feature Specification: Scripts Management
**Feature Branch**: `013-scripts-management`
**Created**: 2026-01-01
**Status**: Draft
**Input**: User description: "Add end-to-end support for management scripts (Windows PowerShell scripts, macOS shell scripts, and proactive remediations) including readable normalized settings, backup snapshots, and safe restore with assignments."
## User Scenarios & Testing *(mandatory)*
<!--
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
you should still have a viable MVP (Minimum Viable Product) that delivers value.
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
Think of each story as a standalone slice of functionality that can be:
- Developed independently
- Tested independently
- Deployed independently
- Demonstrated to users independently
-->
### User Story 1 - Restore a script safely (Priority: P1)
As an admin, I want to restore a script policy from a saved snapshot so I can recover from accidental or unwanted changes.
**Why this priority**: Restoring known-good configuration is the core safety value of the product.
**Independent Test**: Can be fully tested by restoring one script policy into a tenant where the script is missing or changed, and verifying the script and its assignments match the snapshot.
**Acceptance Scenarios**:
1. **Given** a saved script snapshot and a target tenant where the script does not exist, **When** I run restore for that item, **Then** the system creates a new script policy from the snapshot and reports success.
2. **Given** a saved script snapshot and a target tenant where the script exists with differences, **When** I run restore for that item, **Then** the system updates the existing script policy to match the snapshot and reports success.
3. **Given** a saved script snapshot with assignments, **When** I run restore, **Then** the system applies the assignments using the snapshot data and reports assignment outcomes.
---
### User Story 2 - Readable script configuration (Priority: P2)
As an admin, I want to view a readable, normalized representation of a script policy so I can understand what it does and compare versions reliably.
**Why this priority**: If admins cannot quickly understand changes, version history and restore become risky and slow.
**Independent Test**: Can be tested by opening a script policy version page and confirming that normalized settings display key fields consistently across versions.
**Acceptance Scenarios**:
1. **Given** a script policy version, **When** I open the policy version details, **Then** I see a normalized settings view that is stable (same input yields same output ordering/shape).
2. **Given** two versions of the same script policy with changes, **When** I view their normalized settings, **Then** the differences are visible without reading raw JSON.
---
### User Story 3 - Reliable backup capture (Priority: P3)
As an admin, I want backups/version snapshots of script policies to be captured reliably so I can restore later with confidence.
**Why this priority**: Restore is only as good as the snapshot quality.
**Independent Test**: Can be tested by capturing a snapshot of each script policy type and validating it contains the expected configuration fields for that policy.
**Acceptance Scenarios**:
1. **Given** an existing script policy, **When** I capture a snapshot/backup, **Then** the saved snapshot contains the complete configuration needed to restore the script policy.
---
[Add more user stories as needed, each with an assigned priority]
### Edge Cases
- Restoring a snapshot whose policy type does not match the target item (type mismatch) must fail clearly without making changes.
- Restoring when the snapshot contains fields that are not accepted by the target environment must result in a clear failure reason and no partial silent data loss.
- Assignments referencing groups or foundations that cannot be mapped must be reported as manual-required for those assignments.
- Script policies with very large or complex configuration should still render a readable normalized settings view (with safe truncation if needed).
## Requirements *(mandatory)*
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right functional requirements.
-->
### Functional Requirements
- **FR-001**: System MUST support listing and viewing script policies for the supported script policy types.
- **FR-002**: System MUST allow capturing a snapshot of a script policy that is sufficient to restore the policy later.
- **FR-003**: System MUST allow restoring a script policy from a snapshot in a safe manner (create when missing; update when present).
- **FR-004**: System MUST support restoring assignments for script policies using the assignments saved with the snapshot.
- **FR-005**: System MUST present a readable normalized settings view for script policies and script policy versions.
- **FR-006**: System MUST prevent execution of restore if the snapshot policy type does not match the restore item type.
- **FR-007**: System MUST record an audit trail for restore preview and restore execution attempts.
### Key Entities *(include if feature involves data)*
- **Script Policy**: A configuration object representing a management script (platform-specific variants), identified by a stable external identifier and a display name.
- **Script Policy Snapshot**: An immutable capture of a script policys configuration at a point in time, used for diffing and restore.
- **Script Assignment**: A target association that applies a script policy to a defined scope (e.g., groups/filters), stored with the snapshot and restored with mapping when needed.
## Success Criteria *(mandatory)*
<!--
ACTION REQUIRED: Define measurable success criteria.
These must be technology-agnostic and measurable.
-->
### Measurable Outcomes
- **SC-001**: An admin can complete a restore preview for a single script policy in under 1 minute.
- **SC-002**: In a test tenant, restoring a script policy results in the target script policy and assignments matching the snapshot for 100% of supported script policy types.
- **SC-003**: Normalized settings for a script policy are readable and stable: repeated views of the same snapshot produce identical normalized output.
- **SC-004**: Restore failures provide a clear reason (actionable message) in 100% of failure cases.

View File

@ -0,0 +1,28 @@
# Tasks: Scripts Management (013)
**Branch**: `013-scripts-management` | **Date**: 2026-01-01
**Input**: [spec.md](./spec.md), [plan.md](./plan.md)
## Phase 1: Contracts Review
- [x] T001 Verify `config/graph_contracts.php` entries for `deviceManagementScript`, `deviceShellScript`, `deviceHealthScript` (resource, type_family, assignment payload key).
## Phase 2: UI Normalization
- [x] T002 Add a `ScriptsPolicyNormalizer` (or equivalent) to produce readable normalized settings for the three script policy types.
- [x] T003 Register the normalizer in `AppServiceProvider`.
## Phase 3: Tests + Verification
- [x] T004 Add tests for normalized output (shape + stability) for each script policy type.
- [x] T005 Add Filament render tests for “Normalized settings” tab for each script policy type.
- [x] T006 Run targeted tests.
- [x] T007 Run Pint (`./vendor/bin/pint --dirty`).
## Phase 4: Script Content Display (Safe)
- [x] T008 Add opt-in display + base64 decoding for `scriptContent` in normalized settings.
- [x] T009 Highlight script content with Torch (shebang-based shell + PowerShell default).
- [x] T010 Hide script content behind a Show/Hide button (collapsed by default).
- [x] T011 Highlight script content in Normalized Diff view (From/To).
- [x] T012 Enable Torchlight highlighting in Diff + Before/After views.
- [x] T013 Add “Fullscreen” overlay for script diffs (scroll sync).
## Open TODOs (Follow-up)
- None yet.

View File

@ -0,0 +1,128 @@
<?php
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('renders policy version normalized settings for script policies', function (string $policyType, string $odataType) {
$originalEnv = getenv('INTUNE_TENANT_ID');
putenv('INTUNE_TENANT_ID=');
$this->actingAs(User::factory()->create());
$tenant = Tenant::factory()->create();
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent();
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'policy_type' => $policyType,
'platform' => 'all',
'display_name' => 'Script policy',
'external_id' => 'policy-1',
]);
config([
'tenantpilot.display.show_script_content' => true,
]);
$scriptContent = str_repeat('X', 20);
if ($policyType === 'deviceShellScript') {
$scriptContent = "#!/bin/zsh\n".str_repeat('X', 20);
}
$version = PolicyVersion::factory()->create([
'policy_id' => $policy->id,
'tenant_id' => $tenant->id,
'policy_type' => $policyType,
'snapshot' => [
'@odata.type' => $odataType,
'displayName' => 'Script policy',
'description' => 'desc',
'scriptContent' => $scriptContent,
],
]);
$this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('index'))
->assertSuccessful();
$this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version]).'?tab=normalized-settings')
->assertSuccessful();
$originalEnv !== false
? putenv("INTUNE_TENANT_ID={$originalEnv}")
: putenv('INTUNE_TENANT_ID');
})->with([
['deviceManagementScript', '#microsoft.graph.deviceManagementScript'],
['deviceShellScript', '#microsoft.graph.deviceShellScript'],
['deviceHealthScript', '#microsoft.graph.deviceHealthScript'],
]);
it('renders diff tab with highlighted script content for script policies', function () {
$originalEnv = getenv('INTUNE_TENANT_ID');
putenv('INTUNE_TENANT_ID=');
$this->actingAs(User::factory()->create());
config([
'tenantpilot.display.show_script_content' => true,
'tenantpilot.display.max_script_content_chars' => 5000,
]);
$tenant = Tenant::factory()->create();
putenv('INTUNE_TENANT_ID='.$tenant->tenant_id);
$tenant->makeCurrent();
$policy = \App\Models\Policy::factory()->create([
'tenant_id' => $tenant->getKey(),
'policy_type' => 'deviceManagementScript',
'platform' => 'windows',
]);
$scriptOne = "# test\n".str_repeat("Write-Host 'one'\n", 40);
$scriptTwo = "# test\n".str_repeat("Write-Host 'two'\n", 40);
$v1 = \App\Models\PolicyVersion::factory()->create([
'policy_id' => $policy->getKey(),
'tenant_id' => $tenant->getKey(),
'version_number' => 1,
'policy_type' => 'deviceManagementScript',
'platform' => 'windows',
'snapshot' => [
'@odata.type' => '#microsoft.graph.deviceManagementScript',
'displayName' => 'My script',
'scriptContent' => base64_encode($scriptOne),
],
]);
$v2 = \App\Models\PolicyVersion::factory()->create([
'policy_id' => $policy->getKey(),
'tenant_id' => $tenant->getKey(),
'version_number' => 2,
'policy_type' => 'deviceManagementScript',
'platform' => 'windows',
'snapshot' => [
'@odata.type' => '#microsoft.graph.deviceManagementScript',
'displayName' => 'My script',
'scriptContent' => base64_encode($scriptTwo),
],
]);
$url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2]);
$this->get($url.'?tab=diff')
->assertSuccessful()
->assertSeeText('Fullscreen')
->assertSeeText("- Write-Host 'one'")
->assertSeeText("+ Write-Host 'two'")
->assertSee('bg-danger-50', false)
->assertSee('bg-success-50', false);
$originalEnv !== false
? putenv("INTUNE_TENANT_ID={$originalEnv}")
: putenv('INTUNE_TENANT_ID');
});

View File

@ -75,15 +75,11 @@
expect($result)->toBe($assignments);
});
test('fallback on empty response', function () {
test('does not use fallback when primary succeeds with empty assignments', function () {
$tenantId = 'tenant-123';
$policyId = 'policy-456';
$policyType = 'settingsCatalogPolicy';
$assignments = [
['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']],
];
// Primary returns empty
$primaryResponse = new GraphResponse(
success: true,
data: ['value' => []]
@ -97,7 +93,34 @@
])
->andReturn($primaryResponse);
// Fallback returns assignments
$result = $this->fetcher->fetch($policyType, $tenantId, $policyId);
expect($result)->toBe([]);
});
test('uses fallback when primary endpoint fails', function () {
$tenantId = 'tenant-123';
$policyId = 'policy-456';
$policyType = 'settingsCatalogPolicy';
$assignments = [
['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']],
];
$primaryFailure = new GraphResponse(
success: false,
data: [],
status: 400,
errors: [['message' => 'Bad Request']]
);
$this->graphClient
->shouldReceive('request')
->once()
->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", [
'tenant' => $tenantId,
])
->andReturn($primaryFailure);
$fallbackResponse = new GraphResponse(
success: true,
data: ['value' => [['id' => $policyId, 'assignments' => $assignments]]]
@ -152,18 +175,6 @@
->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", Mockery::any())
->andReturn($primaryResponse);
// Fallback returns empty
$fallbackResponse = new GraphResponse(
success: true,
data: ['value' => []]
);
$this->graphClient
->shouldReceive('request')
->once()
->with('GET', 'deviceManagement/configurationPolicies', Mockery::any())
->andReturn($fallbackResponse);
$result = $this->fetcher->fetch($policyType, $tenantId, $policyId);
expect($result)->toBe([]);
@ -174,9 +185,8 @@
$policyId = 'policy-456';
$policyType = 'settingsCatalogPolicy';
// Primary returns empty
$primaryResponse = new GraphResponse(
success: true,
success: false,
data: ['value' => []]
);

View File

@ -0,0 +1,168 @@
<?php
use App\Services\Intune\PolicyNormalizer;
use Tests\TestCase;
uses(TestCase::class);
it('normalizes deviceManagementScript into readable settings', function () {
$normalizer = app(PolicyNormalizer::class);
$snapshot = [
'@odata.type' => '#microsoft.graph.deviceManagementScript',
'displayName' => 'My PS script',
'description' => 'Does a thing',
'scriptContent' => str_repeat('A', 10),
'runFrequency' => 'weekly',
];
$result = $normalizer->normalize($snapshot, 'deviceManagementScript', 'windows');
expect($result['status'])->toBe('ok');
expect($result['settings'])->toBeArray()->not->toBeEmpty();
expect($result['settings'][0]['type'])->toBe('keyValue');
expect(collect($result['settings'][0]['entries'])->pluck('key')->all())->toContain('Display name');
});
it('normalizes deviceShellScript into readable settings', function () {
$normalizer = app(PolicyNormalizer::class);
$snapshot = [
'@odata.type' => '#microsoft.graph.deviceShellScript',
'displayName' => 'My macOS shell script',
'scriptContent' => str_repeat('B', 5),
];
$result = $normalizer->normalize($snapshot, 'deviceShellScript', 'macOS');
expect($result['status'])->toBe('ok');
expect($result['settings'])->toBeArray()->not->toBeEmpty();
expect($result['settings'][0]['type'])->toBe('keyValue');
});
it('normalizes deviceHealthScript into readable settings', function () {
$normalizer = app(PolicyNormalizer::class);
$snapshot = [
'@odata.type' => '#microsoft.graph.deviceHealthScript',
'displayName' => 'My remediation',
'detectionScriptContent' => str_repeat('C', 3),
'remediationScriptContent' => str_repeat('D', 4),
];
$result = $normalizer->normalize($snapshot, 'deviceHealthScript', 'windows');
expect($result['status'])->toBe('ok');
expect($result['settings'])->toBeArray()->not->toBeEmpty();
expect($result['settings'][0]['type'])->toBe('keyValue');
});
it('summarizes script content by default', function () {
config([
'tenantpilot.display.show_script_content' => false,
]);
$normalizer = app(PolicyNormalizer::class);
$snapshot = [
'@odata.type' => '#microsoft.graph.deviceManagementScript',
'displayName' => 'My PS script',
'scriptContent' => 'ABC',
];
$result = $normalizer->normalize($snapshot, 'deviceManagementScript', 'windows');
$entries = collect($result['settings'][0]['entries']);
expect($entries->firstWhere('key', 'scriptContent')['value'])->toBe('[content: 3 chars]');
});
it('shows script content when enabled', function () {
config([
'tenantpilot.display.show_script_content' => true,
'tenantpilot.display.max_script_content_chars' => 100,
]);
$normalizer = app(PolicyNormalizer::class);
$snapshot = [
'@odata.type' => '#microsoft.graph.deviceManagementScript',
'displayName' => 'My PS script',
'scriptContent' => "line1\nline2",
];
$result = $normalizer->normalize($snapshot, 'deviceManagementScript', 'windows');
$entries = collect($result['settings'][0]['entries']);
expect($entries->firstWhere('key', 'scriptContent')['value'])->toBe("line1\nline2");
});
it('decodes scriptContentBase64 when enabled and scriptContent is missing', function () {
config([
'tenantpilot.display.show_script_content' => true,
'tenantpilot.display.max_script_content_chars' => 50,
]);
$normalizer = app(PolicyNormalizer::class);
$snapshot = [
'@odata.type' => '#microsoft.graph.deviceShellScript',
'displayName' => 'My macOS shell script',
'scriptContentBase64' => base64_encode('echo hello'),
];
$result = $normalizer->normalize($snapshot, 'deviceShellScript', 'macOS');
$entries = collect($result['settings'][0]['entries']);
expect($entries->firstWhere('key', 'scriptContent')['value'])->toBe('echo hello');
});
it('decodes base64-looking scriptContent when enabled', function () {
config([
'tenantpilot.display.show_script_content' => true,
'tenantpilot.display.max_script_content_chars' => 5000,
]);
$normalizer = app(PolicyNormalizer::class);
$plain = "# hello\nWrite-Host \"hi\"";
$snapshot = [
'@odata.type' => '#microsoft.graph.deviceManagementScript',
'displayName' => 'My PS script',
'scriptContent' => base64_encode($plain),
];
$result = $normalizer->normalize($snapshot, 'deviceManagementScript', 'windows');
$entries = collect($result['settings'][0]['entries']);
expect($entries->firstWhere('key', 'scriptContent')['value'])->toBe($plain);
});
it('decodes base64-looking detection/remediation script content when enabled', function () {
config([
'tenantpilot.display.show_script_content' => true,
'tenantpilot.display.max_script_content_chars' => 5000,
]);
$normalizer = app(PolicyNormalizer::class);
$detection = "# detection\nWrite-Host \"detect\"";
$remediation = "# remediation\nWrite-Host \"fix\"";
$snapshot = [
'@odata.type' => '#microsoft.graph.deviceHealthScript',
'displayName' => 'My remediation',
'detectionScriptContent' => base64_encode($detection),
'remediationScriptContent' => base64_encode($remediation),
];
$result = $normalizer->normalize($snapshot, 'deviceHealthScript', 'windows');
$entries = collect($result['settings'][0]['entries']);
expect($entries->firstWhere('key', 'detectionScriptContent')['value'])->toBe($detection);
expect($entries->firstWhere('key', 'remediationScriptContent')['value'])->toBe($remediation);
});