From 2765b50eefc164438935ee8075dd5af9b71b6cfa Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 1 Jan 2026 12:16:27 +0100 Subject: [PATCH 01/16] spec: scripts management --- .../checklists/requirements.md | 34 ++++++ specs/013-scripts-management/spec.md | 112 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 specs/013-scripts-management/checklists/requirements.md create mode 100644 specs/013-scripts-management/spec.md diff --git a/specs/013-scripts-management/checklists/requirements.md b/specs/013-scripts-management/checklists/requirements.md new file mode 100644 index 0000000..89849c9 --- /dev/null +++ b/specs/013-scripts-management/checklists/requirements.md @@ -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. diff --git a/specs/013-scripts-management/spec.md b/specs/013-scripts-management/spec.md new file mode 100644 index 0000000..b8446df --- /dev/null +++ b/specs/013-scripts-management/spec.md @@ -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)* + + + +### 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)* + + + +### 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 policy’s 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)* + + + +### 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. -- 2.45.2 From 52d6d016cdd6fbdde91cd5131e7d53bfb8093355 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 1 Jan 2026 12:50:37 +0100 Subject: [PATCH 02/16] feat: scripts policy normalization --- app/Providers/AppServiceProvider.php | 2 + .../Intune/ScriptsPolicyNormalizer.php | 90 +++++++++++++++++++ specs/013-scripts-management/plan.md | 42 +++++++++ specs/013-scripts-management/tasks.md | 20 +++++ .../ScriptPoliciesNormalizedDisplayTest.php | 51 +++++++++++ tests/Unit/ScriptsPolicyNormalizerTest.php | 55 ++++++++++++ 6 files changed, 260 insertions(+) create mode 100644 app/Services/Intune/ScriptsPolicyNormalizer.php create mode 100644 specs/013-scripts-management/plan.md create mode 100644 specs/013-scripts-management/tasks.md create mode 100644 tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php create mode 100644 tests/Unit/ScriptsPolicyNormalizerTest.php diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index dbf8384..f6421d8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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, diff --git a/app/Services/Intune/ScriptsPolicyNormalizer.php b/app/Services/Intune/ScriptsPolicyNormalizer.php new file mode 100644 index 0000000..1593604 --- /dev/null +++ b/app/Services/Intune/ScriptsPolicyNormalizer.php @@ -0,0 +1,90 @@ +>, settings_table?: array, warnings: array} + */ + 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'); + + $settings = []; + + $settings[] = ['key' => 'Type', 'value' => $policyType]; + + if (is_string($displayName) && $displayName !== '') { + $settings[] = ['key' => 'Display name', 'value' => $displayName]; + } + + if (is_string($description) && $description !== '') { + $settings[] = ['key' => 'Description', 'value' => $description]; + } + + // Script content and large blobs should not dominate normalized output. + // Keep only safe summary fields if present. + $contentKeys = [ + 'scriptContent', + 'scriptContentBase64', + 'detectionScriptContent', + 'remediationScriptContent', + ]; + + foreach ($contentKeys as $key) { + $value = Arr::get($snapshot, $key); + + if (is_string($value) && $value !== '') { + $settings[] = ['key' => $key, 'value' => sprintf('[content: %d chars]', strlen($value))]; + } + } + + $schedule = Arr::get($snapshot, 'runSchedule'); + if (is_array($schedule) && $schedule !== []) { + $settings[] = ['key' => 'Run schedule', 'value' => Arr::except($schedule, ['@odata.type'])]; + } + + $frequency = Arr::get($snapshot, 'runFrequency'); + if (is_string($frequency) && $frequency !== '') { + $settings[] = ['key' => 'Run frequency', 'value' => $frequency]; + } + + $roleScopeTagIds = Arr::get($snapshot, 'roleScopeTagIds'); + if (is_array($roleScopeTagIds) && $roleScopeTagIds !== []) { + $settings[] = ['key' => 'Scope tag IDs', 'value' => array_values($roleScopeTagIds)]; + } + + return [ + 'status' => 'ok', + 'settings' => $settings, + 'warnings' => [], + ]; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenForDiff($normalized['settings'] ?? []); + } +} diff --git a/specs/013-scripts-management/plan.md b/specs/013-scripts-management/plan.md new file mode 100644 index 0000000..57970df --- /dev/null +++ b/specs/013-scripts-management/plan.md @@ -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. diff --git a/specs/013-scripts-management/tasks.md b/specs/013-scripts-management/tasks.md new file mode 100644 index 0000000..81d7bd2 --- /dev/null +++ b/specs/013-scripts-management/tasks.md @@ -0,0 +1,20 @@ +# 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 +- [ ] T001 Verify `config/graph_contracts.php` entries for `deviceManagementScript`, `deviceShellScript`, `deviceHealthScript` (resource, type_family, assignment payload key). + +## Phase 2: UI Normalization +- [ ] T002 Add a `ScriptsPolicyNormalizer` (or equivalent) to produce readable normalized settings for the three script policy types. +- [ ] T003 Register the normalizer in `AppServiceProvider`. + +## Phase 3: Tests + Verification +- [ ] T004 Add tests for normalized output (shape + stability) for each script policy type. +- [ ] T005 Add Filament render tests for “Normalized settings” tab for each script policy type. +- [ ] T006 Run targeted tests. +- [ ] T007 Run Pint (`./vendor/bin/pint --dirty`). + +## Open TODOs (Follow-up) +- None yet. diff --git a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php new file mode 100644 index 0000000..8f7e3cf --- /dev/null +++ b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php @@ -0,0 +1,51 @@ +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', + ]); + + PolicyVersion::factory()->create([ + 'policy_id' => $policy->id, + 'tenant_id' => $tenant->id, + 'policy_type' => $policyType, + 'snapshot' => [ + '@odata.type' => $odataType, + 'displayName' => 'Script policy', + 'description' => 'desc', + 'scriptContent' => str_repeat('X', 20), + ], + ]); + + $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('index')) + ->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'], +]); diff --git a/tests/Unit/ScriptsPolicyNormalizerTest.php b/tests/Unit/ScriptsPolicyNormalizerTest.php new file mode 100644 index 0000000..4c88ecc --- /dev/null +++ b/tests/Unit/ScriptsPolicyNormalizerTest.php @@ -0,0 +1,55 @@ + '#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(collect($result['settings'])->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(); +}); + +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(); +}); -- 2.45.2 From 854ce80df7627dc7643ed58408c52fe62914a7a6 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 1 Jan 2026 12:59:23 +0100 Subject: [PATCH 03/16] fix: normalize scripts settings blocks --- .../Intune/ScriptsPolicyNormalizer.php | 26 ++++++++++++------- .../policy-settings-standard.blade.php | 8 ++++-- tests/Unit/ScriptsPolicyNormalizerTest.php | 5 +++- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/app/Services/Intune/ScriptsPolicyNormalizer.php b/app/Services/Intune/ScriptsPolicyNormalizer.php index 1593604..945e520 100644 --- a/app/Services/Intune/ScriptsPolicyNormalizer.php +++ b/app/Services/Intune/ScriptsPolicyNormalizer.php @@ -27,16 +27,16 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor $displayName = Arr::get($snapshot, 'displayName') ?? Arr::get($snapshot, 'name'); $description = Arr::get($snapshot, 'description'); - $settings = []; + $entries = []; - $settings[] = ['key' => 'Type', 'value' => $policyType]; + $entries[] = ['key' => 'Type', 'value' => $policyType]; if (is_string($displayName) && $displayName !== '') { - $settings[] = ['key' => 'Display name', 'value' => $displayName]; + $entries[] = ['key' => 'Display name', 'value' => $displayName]; } if (is_string($description) && $description !== '') { - $settings[] = ['key' => 'Description', 'value' => $description]; + $entries[] = ['key' => 'Description', 'value' => $description]; } // Script content and large blobs should not dominate normalized output. @@ -52,28 +52,34 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor $value = Arr::get($snapshot, $key); if (is_string($value) && $value !== '') { - $settings[] = ['key' => $key, 'value' => sprintf('[content: %d chars]', strlen($value))]; + $entries[] = ['key' => $key, 'value' => sprintf('[content: %d chars]', strlen($value))]; } } $schedule = Arr::get($snapshot, 'runSchedule'); if (is_array($schedule) && $schedule !== []) { - $settings[] = ['key' => 'Run schedule', 'value' => Arr::except($schedule, ['@odata.type'])]; + $entries[] = ['key' => 'Run schedule', 'value' => Arr::except($schedule, ['@odata.type'])]; } $frequency = Arr::get($snapshot, 'runFrequency'); if (is_string($frequency) && $frequency !== '') { - $settings[] = ['key' => 'Run frequency', 'value' => $frequency]; + $entries[] = ['key' => 'Run frequency', 'value' => $frequency]; } $roleScopeTagIds = Arr::get($snapshot, 'roleScopeTagIds'); if (is_array($roleScopeTagIds) && $roleScopeTagIds !== []) { - $settings[] = ['key' => 'Scope tag IDs', 'value' => array_values($roleScopeTagIds)]; + $entries[] = ['key' => 'Scope tag IDs', 'value' => array_values($roleScopeTagIds)]; } return [ 'status' => 'ok', - 'settings' => $settings, + 'settings' => [ + [ + 'type' => 'keyValue', + 'title' => 'Script settings', + 'entries' => $entries, + ], + ], 'warnings' => [], ]; } @@ -85,6 +91,6 @@ public function flattenForDiff(?array $snapshot, string $policyType, ?string $pl { $normalized = $this->normalize($snapshot, $policyType, $platform); - return $this->defaultNormalizer->flattenForDiff($normalized['settings'] ?? []); + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); } } diff --git a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php index 9fb9398..9788fc4 100644 --- a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php +++ b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php @@ -65,7 +65,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') - @elseif($block['type'] === 'keyValue') + @elseif($blockType === 'keyValue') toBe('ok'); expect($result['settings'])->toBeArray()->not->toBeEmpty(); - expect(collect($result['settings'])->pluck('key')->all())->toContain('Display name'); + 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 () { @@ -36,6 +37,7 @@ 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 () { @@ -52,4 +54,5 @@ expect($result['status'])->toBe('ok'); expect($result['settings'])->toBeArray()->not->toBeEmpty(); + expect($result['settings'][0]['type'])->toBe('keyValue'); }); -- 2.45.2 From c749e22beefa498a391a39c7d576302b34ffb6b8 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 1 Jan 2026 13:01:35 +0100 Subject: [PATCH 04/16] fix: render array values in policy settings --- .../entries/policy-settings-standard.blade.php | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php index 9788fc4..a534d59 100644 --- a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php +++ b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php @@ -99,8 +99,15 @@ {{ $row['value'] }} @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 - {{ Str::limit($row['value'] ?? 'N/A', 200) }} + {{ Str::limit((string) $value, 200) }} @endif @@ -127,8 +134,15 @@ {{ $entry['key'] }}
+ @php + $value = $entry['value'] ?? 'N/A'; + + if (is_array($value) || is_object($value)) { + $value = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + @endphp - {{ Str::limit($entry['value'] ?? 'N/A', 200) }} + {{ Str::limit((string) $value, 200) }}
-- 2.45.2 From 058724c3593495c2609f375849c9a88aad8ed372 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 1 Jan 2026 13:07:06 +0100 Subject: [PATCH 05/16] fix: avoid assignment expand fallback --- app/Services/Graph/AssignmentFetcher.php | 28 ++++++++----- tests/Unit/AssignmentFetcherTest.php | 50 ++++++++++++++---------- 2 files changed, 49 insertions(+), 29 deletions(-) diff --git a/app/Services/Graph/AssignmentFetcher.php b/app/Services/Graph/AssignmentFetcher.php index 6bd2139..29aa0b5 100644 --- a/app/Services/Graph/AssignmentFetcher.php +++ b/app/Services/Graph/AssignmentFetcher.php @@ -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'] ?? []; diff --git a/tests/Unit/AssignmentFetcherTest.php b/tests/Unit/AssignmentFetcherTest.php index ce174ee..b634752 100644 --- a/tests/Unit/AssignmentFetcherTest.php +++ b/tests/Unit/AssignmentFetcherTest.php @@ -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' => []] ); -- 2.45.2 From 17bfc2f17e545245ccc643fd1d9d03c875c8c771 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 1 Jan 2026 15:16:48 +0100 Subject: [PATCH 06/16] feat: scripts normalized settings and safe script content view --- app/Filament/Resources/PolicyResource.php | 1 + .../Resources/PolicyVersionResource.php | 1 + .../Intune/ScriptsPolicyNormalizer.php | 184 +++++++++++++++-- composer.json | 1 + composer.lock | 193 +++++++++++++++++- config/tenantpilot.php | 5 + .../policy-settings-standard.blade.php | 96 ++++++++- specs/013-scripts-management/tasks.md | 19 +- .../ScriptPoliciesNormalizedDisplayTest.php | 16 +- tests/Unit/ScriptsPolicyNormalizerTest.php | 110 ++++++++++ 10 files changed, 597 insertions(+), 29 deletions(-) diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index bea7788..bbfac49 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -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); diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 4bab649..91347ef 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -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; }) diff --git a/app/Services/Intune/ScriptsPolicyNormalizer.php b/app/Services/Intune/ScriptsPolicyNormalizer.php index 945e520..68bf7e5 100644 --- a/app/Services/Intune/ScriptsPolicyNormalizer.php +++ b/app/Services/Intune/ScriptsPolicyNormalizer.php @@ -39,22 +39,7 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor $entries[] = ['key' => 'Description', 'value' => $description]; } - // Script content and large blobs should not dominate normalized output. - // Keep only safe summary fields if present. - $contentKeys = [ - 'scriptContent', - 'scriptContentBase64', - 'detectionScriptContent', - 'remediationScriptContent', - ]; - - foreach ($contentKeys as $key) { - $value = Arr::get($snapshot, $key); - - if (is_string($value) && $value !== '') { - $entries[] = ['key' => $key, 'value' => sprintf('[content: %d chars]', strlen($value))]; - } - } + $entries = array_merge($entries, $this->contentEntries($snapshot)); $schedule = Arr::get($snapshot, 'runSchedule'); if (is_array($schedule) && $schedule !== []) { @@ -84,6 +69,173 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor ]; } + /** + * @return array + */ + 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 + */ + 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 */ diff --git a/composer.json b/composer.json index ccdbb83..b681b4b 100644 --- a/composer.json +++ b/composer.json @@ -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" diff --git a/composer.lock b/composer.lock index 33f7c64..4a56c47 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 2e8c4b0..17efa20 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -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), + ], ]; diff --git a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php index a534d59..a3c9c9d 100644 --- a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php +++ b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php @@ -7,6 +7,7 @@ $warnings = $state['warnings'] ?? []; $settings = $state['settings'] ?? []; $settingsTable = $state['settings_table'] ?? null; + $policyType = $state['policy_type'] ?? null; @endphp
@@ -137,13 +138,102 @@ @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 - - {{ Str::limit((string) $value, 200) }} - + @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 + +
+
+ + Show + Hide + + + + {{ number_format(Str::length($code)) }} chars + +
+ +
+ @if (is_string($highlightedHtml) && $highlightedHtml !== '') + + +
{!! $highlightedHtml !!}
+ @else +
{{ $code }}
+ @endif +
+
+ @else + + {{ Str::limit((string) $value, 200) }} + + @endif
@endforeach diff --git a/specs/013-scripts-management/tasks.md b/specs/013-scripts-management/tasks.md index 81d7bd2..4201d38 100644 --- a/specs/013-scripts-management/tasks.md +++ b/specs/013-scripts-management/tasks.md @@ -4,17 +4,22 @@ # Tasks: Scripts Management (013) **Input**: [spec.md](./spec.md), [plan.md](./plan.md) ## Phase 1: Contracts Review -- [ ] T001 Verify `config/graph_contracts.php` entries for `deviceManagementScript`, `deviceShellScript`, `deviceHealthScript` (resource, type_family, assignment payload key). +- [x] T001 Verify `config/graph_contracts.php` entries for `deviceManagementScript`, `deviceShellScript`, `deviceHealthScript` (resource, type_family, assignment payload key). ## Phase 2: UI Normalization -- [ ] T002 Add a `ScriptsPolicyNormalizer` (or equivalent) to produce readable normalized settings for the three script policy types. -- [ ] T003 Register the normalizer in `AppServiceProvider`. +- [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 -- [ ] T004 Add tests for normalized output (shape + stability) for each script policy type. -- [ ] T005 Add Filament render tests for “Normalized settings” tab for each script policy type. -- [ ] T006 Run targeted tests. -- [ ] T007 Run Pint (`./vendor/bin/pint --dirty`). +- [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). ## Open TODOs (Follow-up) - None yet. diff --git a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php index 8f7e3cf..fdd05f7 100644 --- a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php +++ b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php @@ -26,7 +26,16 @@ 'external_id' => 'policy-1', ]); - PolicyVersion::factory()->create([ + 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, @@ -34,13 +43,16 @@ '@odata.type' => $odataType, 'displayName' => 'Script policy', 'description' => 'desc', - 'scriptContent' => str_repeat('X', 20), + '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'); diff --git a/tests/Unit/ScriptsPolicyNormalizerTest.php b/tests/Unit/ScriptsPolicyNormalizerTest.php index 8310d82..61a63a7 100644 --- a/tests/Unit/ScriptsPolicyNormalizerTest.php +++ b/tests/Unit/ScriptsPolicyNormalizerTest.php @@ -56,3 +56,113 @@ 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); +}); -- 2.45.2 From 840e4686f98698db0fc85517946377009c144547 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 1 Jan 2026 21:25:41 +0100 Subject: [PATCH 07/16] feat: highlight script content in normalized diff --- .../Resources/PolicyVersionResource.php | 5 +- .../entries/normalized-diff.blade.php | 123 +++++++++++++++++- specs/013-scripts-management/tasks.md | 1 + .../ScriptPoliciesNormalizedDisplayTest.php | 61 +++++++++ 4 files changed, 186 insertions(+), 4 deletions(-) diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 91347ef..2b01621 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -115,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)') diff --git a/resources/views/filament/infolists/entries/normalized-diff.blade.php b/resources/views/filament/infolists/entries/normalized-diff.blade.php index c57ebd1..6b5e516 100644 --- a/resources/views/filament/infolists/entries/normalized-diff.blade.php +++ b/resources/views/filament/infolists/entries/normalized-diff.blade.php @@ -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,59 @@ 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; + } + }; @endphp
@@ -103,6 +157,10 @@ $to = $value['to']; $fromText = $stringify($from); $toText = $stringify($to); + + $isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name); + $fromHighlight = $isScriptContent ? $highlight($policyType, (string) $fromText) : null; + $toHighlight = $isScriptContent ? $highlight($policyType, (string) $toText) : null; @endphp
@@ -115,7 +173,25 @@ View -
{{ $fromText }}
+ @if (is_string($fromHighlight) && $fromHighlight !== '') + + +
{!! $fromHighlight !!}
+ @else +
{{ $fromText }}
+ @endif @else
{{ $fromText }}
@@ -128,7 +204,25 @@ View -
{{ $toText }}
+ @if (is_string($toHighlight) && $toHighlight !== '') + + +
{!! $toHighlight !!}
+ @else +
{{ $toText }}
+ @endif @else
{{ $toText }}
@@ -149,7 +243,30 @@ View -
{{ $text }}
+ @php + $isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name); + $highlighted = $isScriptContent ? $highlight($policyType, (string) $text) : null; + @endphp + + @if (is_string($highlighted) && $highlighted !== '') + + +
{!! $highlighted !!}
+ @else +
{{ $text }}
+ @endif @else
{{ $text }}
diff --git a/specs/013-scripts-management/tasks.md b/specs/013-scripts-management/tasks.md index 4201d38..6ebabbd 100644 --- a/specs/013-scripts-management/tasks.md +++ b/specs/013-scripts-management/tasks.md @@ -20,6 +20,7 @@ ## 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). ## Open TODOs (Follow-up) - None yet. diff --git a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php index fdd05f7..092ec9e 100644 --- a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php +++ b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php @@ -61,3 +61,64 @@ ['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() + ->assertSee('torchlight', false); + + $originalEnv !== false + ? putenv("INTUNE_TENANT_ID={$originalEnv}") + : putenv('INTUNE_TENANT_ID'); +}); -- 2.45.2 From 2c1ee814eee09b3228af16c171f5fdb6a8f4b1c4 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 1 Jan 2026 21:27:03 +0100 Subject: [PATCH 08/16] refactor: dedupe torchlight dark mode styles --- .../entries/normalized-diff.blade.php | 48 ++++--------------- .../policy-settings-standard.blade.php | 16 ++----- .../torchlight-dark-overrides.blade.php | 13 +++++ 3 files changed, 25 insertions(+), 52 deletions(-) create mode 100644 resources/views/filament/partials/torchlight-dark-overrides.blade.php diff --git a/resources/views/filament/infolists/entries/normalized-diff.blade.php b/resources/views/filament/infolists/entries/normalized-diff.blade.php index 6b5e516..4d9a154 100644 --- a/resources/views/filament/infolists/entries/normalized-diff.blade.php +++ b/resources/views/filament/infolists/entries/normalized-diff.blade.php @@ -174,19 +174,9 @@ View @if (is_string($fromHighlight) && $fromHighlight !== '') - + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce
{!! $fromHighlight !!}
@else @@ -205,19 +195,9 @@ View @if (is_string($toHighlight) && $toHighlight !== '') - + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce
{!! $toHighlight !!}
@else @@ -249,19 +229,9 @@ @endphp @if (is_string($highlighted) && $highlighted !== '') - + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce
{!! $highlighted !!}
@else diff --git a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php index a3c9c9d..aabb323 100644 --- a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php +++ b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php @@ -209,19 +209,9 @@
@if (is_string($highlightedHtml) && $highlightedHtml !== '') - + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce
{!! $highlightedHtml !!}
@else diff --git a/resources/views/filament/partials/torchlight-dark-overrides.blade.php b/resources/views/filament/partials/torchlight-dark-overrides.blade.php new file mode 100644 index 0000000..e93868a --- /dev/null +++ b/resources/views/filament/partials/torchlight-dark-overrides.blade.php @@ -0,0 +1,13 @@ + -- 2.45.2 From f165bd059e82ffc2c748a6b54b97b30b1c4053ee Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 1 Jan 2026 21:33:19 +0100 Subject: [PATCH 09/16] feat: highlight changed lines in script diffs --- .../entries/normalized-diff.blade.php | 127 +++++++++++++++--- .../ScriptPoliciesNormalizedDisplayTest.php | 5 +- 2 files changed, 115 insertions(+), 17 deletions(-) diff --git a/resources/views/filament/infolists/entries/normalized-diff.blade.php b/resources/views/filament/infolists/entries/normalized-diff.blade.php index 4d9a154..844df9d 100644 --- a/resources/views/filament/infolists/entries/normalized-diff.blade.php +++ b/resources/views/filament/infolists/entries/normalized-diff.blade.php @@ -104,6 +104,94 @@ 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
@@ -159,8 +247,7 @@ $toText = $stringify($to); $isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name); - $fromHighlight = $isScriptContent ? $highlight($policyType, (string) $fromText) : null; - $toHighlight = $isScriptContent ? $highlight($policyType, (string) $toText) : null; + $ops = $isScriptContent ? $scriptLineDiff((string) $fromText, (string) $toText) : []; @endphp
@@ -168,17 +255,21 @@
From - @if ($isExpandable($from)) + @if ($isScriptContent || $isExpandable($from))
View - @if (is_string($fromHighlight) && $fromHighlight !== '') - @once - @include('filament.partials.torchlight-dark-overrides') - @endonce - -
{!! $fromHighlight !!}
+ @if ($isScriptContent) +
+@foreach ($ops as $op)
+@if ($op['type'] === 'equal')
+{{ $op['line'] }}
+@elseif ($op['type'] === 'delete')
+- {{ $op['line'] }}
+@endif
+@endforeach
+                                                                
@else
{{ $fromText }}
@endif @@ -189,17 +280,21 @@
To - @if ($isExpandable($to)) + @if ($isScriptContent || $isExpandable($to))
View - @if (is_string($toHighlight) && $toHighlight !== '') - @once - @include('filament.partials.torchlight-dark-overrides') - @endonce - -
{!! $toHighlight !!}
+ @if ($isScriptContent) +
+@foreach ($ops as $op)
+@if ($op['type'] === 'equal')
+{{ $op['line'] }}
+@elseif ($op['type'] === 'insert')
++ {{ $op['line'] }}
+@endif
+@endforeach
+                                                                
@else
{{ $toText }}
@endif diff --git a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php index 092ec9e..f2f9f78 100644 --- a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php +++ b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php @@ -116,7 +116,10 @@ $this->get($url.'?tab=diff') ->assertSuccessful() - ->assertSee('torchlight', false); + ->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}") -- 2.45.2 From a6b1a8913b32cbe6745862ea640ecfd1ba2a046b Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 1 Jan 2026 21:38:15 +0100 Subject: [PATCH 10/16] refactor: group replace pairs in script diffs --- .../entries/normalized-diff.blade.php | 99 +++++++++++-------- 1 file changed, 57 insertions(+), 42 deletions(-) diff --git a/resources/views/filament/infolists/entries/normalized-diff.blade.php b/resources/views/filament/infolists/entries/normalized-diff.blade.php index 844df9d..aceaaa5 100644 --- a/resources/views/filament/infolists/entries/normalized-diff.blade.php +++ b/resources/views/filament/infolists/entries/normalized-diff.blade.php @@ -253,56 +253,71 @@
{{ (string) $name }}
-
- From - @if ($isScriptContent || $isExpandable($from)) + + @if ($isScriptContent) +
+ Diff
View - @if ($isScriptContent) -
-@foreach ($ops as $op)
-@if ($op['type'] === 'equal')
+
+                                                            
+ @php + $count = count($ops); + @endphp + + @for ($i = 0; $i < $count; $i++) + @php + $op = $ops[$i]; + $next = $ops[$i + 1] ?? null; + @endphp + + @if ($op['type'] === 'equal') {{ $op['line'] }} -@elseif ($op['type'] === 'delete') + @elseif ($op['type'] === 'delete' && is_array($next) && ($next['type'] ?? null) === 'insert') - {{ $op['line'] }} -@endif -@endforeach -
- @else -
{{ $fromText }}
- @endif -
- @else -
{{ $fromText }}
- @endif -
-
- To - @if ($isScriptContent || $isExpandable($to)) -
- - View - - @if ($isScriptContent) -
-@foreach ($ops as $op)
-@if ($op['type'] === 'equal')
-{{ $op['line'] }}
-@elseif ($op['type'] === 'insert')
++ {{ $next['line'] }}
+                                                                        @php
+                                                                            $i++;
+                                                                        @endphp
+                                                                    @elseif ($op['type'] === 'delete')
+- {{ $op['line'] }}
+                                                                    @elseif ($op['type'] === 'insert')
 + {{ $op['line'] }}
-@endif
-@endforeach
-                                                                
- @else -
{{ $toText }}
- @endif + @endif + @endfor +
- @else -
{{ $toText }}
- @endif -
+
+ @else +
+ From + @if ($isExpandable($from)) +
+ + View + +
{{ $fromText }}
+
+ @else +
{{ $fromText }}
+ @endif +
+
+ To + @if ($isExpandable($to)) +
+ + View + +
{{ $toText }}
+
+ @else +
{{ $toText }}
+ @endif +
+ @endif
@else @php -- 2.45.2 From 75a87a77aeb498fe2794a434f6fe387359d76bc6 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 1 Jan 2026 21:47:32 +0100 Subject: [PATCH 11/16] fix: prevent script diff indentation and add scroll --- .../entries/normalized-diff.blade.php | 54 ++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/resources/views/filament/infolists/entries/normalized-diff.blade.php b/resources/views/filament/infolists/entries/normalized-diff.blade.php index aceaaa5..6daeb07 100644 --- a/resources/views/filament/infolists/entries/normalized-diff.blade.php +++ b/resources/views/filament/infolists/entries/normalized-diff.blade.php @@ -262,32 +262,38 @@ View -
- @php - $count = count($ops); - @endphp +
@php
+$count = count($ops);
 
-                                                                @for ($i = 0; $i < $count; $i++)
-                                                                    @php
-                                                                        $op = $ops[$i];
-                                                                        $next = $ops[$i + 1] ?? null;
-                                                                    @endphp
+for ($i = 0; $i < $count; $i++) {
+    $op = $ops[$i];
+    $next = $ops[$i + 1] ?? null;
+    $type = $op['type'] ?? null;
+    $line = (string) ($op['line'] ?? '');
 
-                                                                    @if ($op['type'] === 'equal')
-{{ $op['line'] }}
-                                                                    @elseif ($op['type'] === 'delete' && is_array($next) && ($next['type'] ?? null) === 'insert')
-- {{ $op['line'] }}
-+ {{ $next['line'] }}
-                                                                        @php
-                                                                            $i++;
-                                                                        @endphp
-                                                                    @elseif ($op['type'] === 'delete')
-- {{ $op['line'] }}
-                                                                    @elseif ($op['type'] === 'insert')
-+ {{ $op['line'] }}
-                                                                    @endif
-                                                                @endfor
-                                                            
+ if ($type === 'equal') { + echo e($line)."\n"; + continue; + } + + if ($type === 'delete' && is_array($next) && ($next['type'] ?? null) === 'insert') { + echo '- '.e($line)."\n"; + echo '+ '.e((string) ($next['line'] ?? ''))."\n"; + $i++; + continue; + } + + if ($type === 'delete') { + echo '- '.e($line)."\n"; + continue; + } + + if ($type === 'insert') { + echo '+ '.e($line)."\n"; + continue; + } +} +@endphp
@else -- 2.45.2 From 1ff4b2c8ffca941c254606e2e55fc1edfe8a3d7e Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 1 Jan 2026 21:50:57 +0100 Subject: [PATCH 12/16] feat: side-by-side script diff with before/after tabs --- .../entries/normalized-diff.blade.php | 132 +++++++++++++++--- 1 file changed, 109 insertions(+), 23 deletions(-) diff --git a/resources/views/filament/infolists/entries/normalized-diff.blade.php b/resources/views/filament/infolists/entries/normalized-diff.blade.php index 6daeb07..6ba7040 100644 --- a/resources/views/filament/infolists/entries/normalized-diff.blade.php +++ b/resources/views/filament/infolists/entries/normalized-diff.blade.php @@ -256,44 +256,130 @@ @if ($isScriptContent)
- Diff + Script
View -
@php
-$count = count($ops);
+                                                            
+
+ + Diff + + + Vorher + + + Nachher + +
-for ($i = 0; $i < $count; $i++) { - $op = $ops[$i]; - $next = $ops[$i + 1] ?? null; - $type = $op['type'] ?? null; - $line = (string) ($op['line'] ?? ''); +
+ @php + $rows = []; + $count = count($ops); - if ($type === 'equal') { - echo e($line)."\n"; + 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 + +
+
+
Alt
+
@php
+foreach ($rows as $row) {
+    $left = $row['left'];
+    $leftType = $left['type'];
+    $leftLine = (string) ($left['line'] ?? '');
+
+    if ($leftType === 'equal') {
+        echo e($leftLine)."\n";
         continue;
     }
 
-    if ($type === 'delete' && is_array($next) && ($next['type'] ?? null) === 'insert') {
-        echo '- '.e($line)."\n";
-        echo '+ '.e((string) ($next['line'] ?? ''))."\n";
-        $i++;
+    if ($leftType === 'delete') {
+        echo '- '.e($leftLine)."\n";
         continue;
     }
 
-    if ($type === 'delete') {
-        echo '- '.e($line)."\n";
-        continue;
-    }
-
-    if ($type === 'insert') {
-        echo '+ '.e($line)."\n";
-        continue;
-    }
+    echo "\n";
 }
 @endphp
+
+ +
+
Neu
+
@php
+foreach ($rows as $row) {
+    $right = $row['right'];
+    $rightType = $right['type'];
+    $rightLine = (string) ($right['line'] ?? '');
+
+    if ($rightType === 'equal') {
+        echo e($rightLine)."\n";
+        continue;
+    }
+
+    if ($rightType === 'insert') {
+        echo '+ '.e($rightLine)."\n";
+        continue;
+    }
+
+    echo "\n";
+}
+@endphp
+
+
+
+ +
+
Vorher
+
{{ (string) $fromText }}
+
+ +
+
Nachher
+
{{ (string) $toText }}
+
+
@else -- 2.45.2 From 49a4a9f15ff3b3a643c5d4037ddee70f2f85dff5 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 1 Jan 2026 22:04:17 +0100 Subject: [PATCH 13/16] fix: english labels for diff UI --- .../infolists/entries/normalized-diff.blade.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/resources/views/filament/infolists/entries/normalized-diff.blade.php b/resources/views/filament/infolists/entries/normalized-diff.blade.php index 6ba7040..b3533dd 100644 --- a/resources/views/filament/infolists/entries/normalized-diff.blade.php +++ b/resources/views/filament/infolists/entries/normalized-diff.blade.php @@ -268,10 +268,10 @@ Diff - Vorher + Before - Nachher + After
@@ -323,7 +323,7 @@
-
Alt
+
Old
@php
 foreach ($rows as $row) {
     $left = $row['left'];
@@ -346,7 +346,7 @@
                                                                         
-
Neu
+
New
@php
 foreach ($rows as $row) {
     $right = $row['right'];
@@ -371,12 +371,12 @@
                                                                 
-
Vorher
+
Before
{{ (string) $fromText }}
-
Nachher
+
After
{{ (string) $toText }}
-- 2.45.2 From 2f3788372a1d688087d7c60a16f5127bf2f72241 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 1 Jan 2026 22:44:33 +0100 Subject: [PATCH 14/16] feat: torchlight in script diff tabs --- .../entries/normalized-diff.blade.php | 108 +++++++++++++++++- specs/013-scripts-management/tasks.md | 1 + 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/resources/views/filament/infolists/entries/normalized-diff.blade.php b/resources/views/filament/infolists/entries/normalized-diff.blade.php index b3533dd..80f8361 100644 --- a/resources/views/filament/infolists/entries/normalized-diff.blade.php +++ b/resources/views/filament/infolists/entries/normalized-diff.blade.php @@ -105,6 +105,39 @@ } }; + $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, + ); + + if (! preg_match('/^
]*>(.*)<\\/code><\\/pre>$/s', $html, $matches)) {
+                return null;
+            }
+
+            $inner = $matches[1] ?? '';
+
+            return trim((string) preg_replace('//', '', $inner));
+        } catch (\Throwable $e) {
+            return null;
+        }
+    };
+
     $splitLines = static function (string $text): array {
         $text = str_replace(["\r\n", "\r"], "\n", $text);
 
@@ -248,6 +281,7 @@
 
                                                 $isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name);
                                                 $ops = $isScriptContent ? $scriptLineDiff((string) $fromText, (string) $toText) : [];
+                                                $useTorchlight = $isScriptContent && class_exists(\Torchlight\Engine\Engine::class);
                                             @endphp
                                             
@@ -330,13 +364,32 @@ $leftType = $left['type']; $leftLine = (string) ($left['line'] ?? ''); + $leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null; + $leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine); + if ($leftType === 'equal') { - echo e($leftLine)."\n"; + if ($useTorchlight) { + @endphp + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + @php + } + + echo $leftRendered."\n"; continue; } if ($leftType === 'delete') { - echo '- '.e($leftLine)."\n"; + if ($useTorchlight) { + @endphp + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + @php + } + + echo '- '.$leftRendered."\n"; continue; } @@ -353,13 +406,32 @@ $rightType = $right['type']; $rightLine = (string) ($right['line'] ?? ''); + $rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null; + $rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine); + if ($rightType === 'equal') { - echo e($rightLine)."\n"; + if ($useTorchlight) { + @endphp + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + @php + } + + echo $rightRendered."\n"; continue; } if ($rightType === 'insert') { - echo '+ '.e($rightLine)."\n"; + if ($useTorchlight) { + @endphp + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + @php + } + + echo '+ '.$rightRendered."\n"; continue; } @@ -372,12 +444,36 @@
Before
-
{{ (string) $fromText }}
+ @php + $highlightedBefore = $useTorchlight ? $highlight($policyType, (string) $fromText) : null; + @endphp + + @if (is_string($highlightedBefore) && $highlightedBefore !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlightedBefore !!}
+ @else +
{{ (string) $fromText }}
+ @endif
After
-
{{ (string) $toText }}
+ @php + $highlightedAfter = $useTorchlight ? $highlight($policyType, (string) $toText) : null; + @endphp + + @if (is_string($highlightedAfter) && $highlightedAfter !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlightedAfter !!}
+ @else +
{{ (string) $toText }}
+ @endif
diff --git a/specs/013-scripts-management/tasks.md b/specs/013-scripts-management/tasks.md index 6ebabbd..332d7c8 100644 --- a/specs/013-scripts-management/tasks.md +++ b/specs/013-scripts-management/tasks.md @@ -21,6 +21,7 @@ ## Phase 4: Script Content Display (Safe) - [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. ## Open TODOs (Follow-up) - None yet. -- 2.45.2 From 72acc8db423096e3d4f422251dc54f0045db5e13 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 1 Jan 2026 22:54:23 +0100 Subject: [PATCH 15/16] feat: fullscreen script diff with scroll sync --- .../entries/normalized-diff.blade.php | 285 +++++++++++++++--- specs/013-scripts-management/tasks.md | 1 + .../ScriptPoliciesNormalizedDisplayTest.php | 1 + 3 files changed, 241 insertions(+), 46 deletions(-) diff --git a/resources/views/filament/infolists/entries/normalized-diff.blade.php b/resources/views/filament/infolists/entries/normalized-diff.blade.php index 80f8361..a8d45a8 100644 --- a/resources/views/filament/infolists/entries/normalized-diff.blade.php +++ b/resources/views/filament/infolists/entries/normalized-diff.blade.php @@ -282,6 +282,51 @@ $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
@@ -291,7 +336,7 @@ @if ($isScriptContent)
Script -
+
View @@ -307,54 +352,13 @@ After + + + ⤢ Fullscreen +
- @php - $rows = []; - $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 -
Old
@@ -476,6 +480,195 @@ @endif
+ +
+
+
+
+
Script diff
+
+ + Close + +
+
+ +
+
+
+ + Diff + + + Before + + + After + +
+ +
+
+
+
Old
+
@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')
+            @endonce
+            @php
+        }
+
+        echo $leftRendered."\n";
+        continue;
+    }
+
+    if ($leftType === 'delete') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+            @endonce
+            @php
+        }
+
+        echo '- '.$leftRendered."\n";
+        continue;
+    }
+
+    echo "\n";
+}
+@endphp
+
+ +
+
New
+
@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')
+            @endonce
+            @php
+        }
+
+        echo $rightRendered."\n";
+        continue;
+    }
+
+    if ($rightType === 'insert') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+            @endonce
+            @php
+        }
+
+        echo '+ '.$rightRendered."\n";
+        continue;
+    }
+
+    echo "\n";
+}
+@endphp
+
+
+
+ +
+
Before
+ @php + $highlightedBeforeFullscreen = $useTorchlight ? $highlight($policyType, (string) $fromText) : null; + @endphp + + @if (is_string($highlightedBeforeFullscreen) && $highlightedBeforeFullscreen !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlightedBeforeFullscreen !!}
+ @else +
{{ (string) $fromText }}
+ @endif +
+ +
+
After
+ @php + $highlightedAfterFullscreen = $useTorchlight ? $highlight($policyType, (string) $toText) : null; + @endphp + + @if (is_string($highlightedAfterFullscreen) && $highlightedAfterFullscreen !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlightedAfterFullscreen !!}
+ @else +
{{ (string) $toText }}
+ @endif +
+
+
+
+
@else diff --git a/specs/013-scripts-management/tasks.md b/specs/013-scripts-management/tasks.md index 332d7c8..92d99ee 100644 --- a/specs/013-scripts-management/tasks.md +++ b/specs/013-scripts-management/tasks.md @@ -22,6 +22,7 @@ ## Phase 4: Script Content Display (Safe) - [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. diff --git a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php index f2f9f78..9db3154 100644 --- a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php +++ b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php @@ -116,6 +116,7 @@ $this->get($url.'?tab=diff') ->assertSuccessful() + ->assertSeeText('Fullscreen') ->assertSeeText("- Write-Host 'one'") ->assertSeeText("+ Write-Host 'two'") ->assertSee('bg-danger-50', false) -- 2.45.2 From 280e2372a9b352642f39b7a2fb6e0db0258ab8ce Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 1 Jan 2026 23:00:23 +0100 Subject: [PATCH 16/16] fix: torchlight dark mode in script diff --- .../entries/normalized-diff.blade.php | 64 +++++++++++++++---- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/resources/views/filament/infolists/entries/normalized-diff.blade.php b/resources/views/filament/infolists/entries/normalized-diff.blade.php index a8d45a8..a7623b8 100644 --- a/resources/views/filament/infolists/entries/normalized-diff.blade.php +++ b/resources/views/filament/infolists/entries/normalized-diff.blade.php @@ -126,13 +126,13 @@ withWrapper: false, ); - if (! preg_match('/^
]*>(.*)<\\/code><\\/pre>$/s', $html, $matches)) {
+            $html = (string) preg_replace('//', '', $html);
+
+            if (! preg_match('/]*>.*?<\\/code>/s', $html, $matches)) {
                 return null;
             }
 
-            $inner = $matches[1] ?? '';
-
-            return trim((string) preg_replace('//', '', $inner));
+            return trim((string) ($matches[0] ?? ''));
         } catch (\Throwable $e) {
             return null;
         }
@@ -376,11 +376,16 @@
             @endphp
             @once
                 @include('filament.partials.torchlight-dark-overrides')
+                
             @endonce
             @php
         }
 
-        echo $leftRendered."\n";
+        echo ''.$leftRendered."\n";
         continue;
     }
 
@@ -389,11 +394,16 @@
             @endphp
             @once
                 @include('filament.partials.torchlight-dark-overrides')
+                
             @endonce
             @php
         }
 
-        echo '- '.$leftRendered."\n";
+        echo '- '.$leftRendered."\n";
         continue;
     }
 
@@ -418,11 +428,16 @@
             @endphp
             @once
                 @include('filament.partials.torchlight-dark-overrides')
+                
             @endonce
             @php
         }
 
-        echo $rightRendered."\n";
+        echo ''.$rightRendered."\n";
         continue;
     }
 
@@ -431,11 +446,16 @@
             @endphp
             @once
                 @include('filament.partials.torchlight-dark-overrides')
+                
             @endonce
             @php
         }
 
-        echo '+ '.$rightRendered."\n";
+        echo '+ '.$rightRendered."\n";
         continue;
     }
 
@@ -562,11 +582,16 @@ class="h-full space-y-3"
             @endphp
             @once
                 @include('filament.partials.torchlight-dark-overrides')
+                
             @endonce
             @php
         }
 
-        echo $leftRendered."\n";
+        echo ''.$leftRendered."\n";
         continue;
     }
 
@@ -575,11 +600,16 @@ class="h-full space-y-3"
             @endphp
             @once
                 @include('filament.partials.torchlight-dark-overrides')
+                
             @endonce
             @php
         }
 
-        echo '- '.$leftRendered."\n";
+        echo '- '.$leftRendered."\n";
         continue;
     }
 
@@ -604,11 +634,16 @@ class="h-full space-y-3"
             @endphp
             @once
                 @include('filament.partials.torchlight-dark-overrides')
+                
             @endonce
             @php
         }
 
-        echo $rightRendered."\n";
+        echo ''.$rightRendered."\n";
         continue;
     }
 
@@ -617,11 +652,16 @@ class="h-full space-y-3"
             @endphp
             @once
                 @include('filament.partials.torchlight-dark-overrides')
+                
             @endonce
             @php
         }
 
-        echo '+ '.$rightRendered."\n";
+        echo '+ '.$rightRendered."\n";
         continue;
     }
 
-- 
2.45.2