Output:
Chunk ID: 248360
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1334
Output:
<?php

namespace App\Livewire\Admin\Developers;

use App\Enums\WebhookEventType;
use App\Enums\WebhookSubscriptionStatus;
use App\Models\OauthToken;
use App\Models\Store;
use App\Models\WebhookSubscription;
use App\Services\WebhookService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Component;

class Index extends Component
{
    use AuthorizesRequests;

    #[Locked]
    public int $storeId;

    public string $newTokenName = '';

    public ?string $generatedToken = null;

    public string $webhookEventType = 'order.created';

    public string $webhookUrl = '';

    public ?int $editingWebhookId = null;

    /**
     * @var list<string>
     */
    public array $tokenAbilities = [
        'read-products',
        'write-products',
        'read-orders',
        'write-orders',
        'read-customers',
        'write-customers',
        'read-analytics',
    ];

    public function mount(): void
    {
        $store = $this->store();

        $this->authorize('update', $store);
        $this->storeId = $store->getKey();
    }

    public function generateToken(WebhookService $webhooks): void
    {
        $this->authorize('update', $this->scopedStore());

        $this->validate([
            'newTokenName' => ['required', 'string', 'max:255'],
        ], [], [
            'newTokenName' => 'token name',
        ]);

        $result = $webhooks->createApiToken($this->scopedStore(), $this->newTokenName, $this->tokenAbilities);

        $this->generatedToken = $result['plain_text'];
        $this->newTokenName = '';
        $this->modal('generate-token')->close();
        $this->dispatch('toast', type: 'success', message: __('API token generated'));
    }

    public function revokeToken(int $tokenId): void
    {
        $this->authorize('update', $this->scopedStore());

        $this->token($tokenId)->delete();

        session()->flash('status', __('API token revoked'));
        $this->dispatch('toast', type: 'success', message: __('API token revoked'));
    }

    public function openWebhookModal(?int $webhookId = null): void
    {
        $this->editingWebhookId = $webhookId;

        if ($webhookId !== null) {
            $webhook = $this->webhook($webhookId);
            $this->webhookEventType = $webhook->event_type->value;
            $this->webhookUrl = $webhook->target_url;
        } else {
            $this->webhookEventType = WebhookEventType::OrderCreated->value;
            $this->webhookUrl = '';
        }

        $this->modal('webhook-form')->show();
    }

    public function saveWebhook(WebhookService $webhooks): void
    {
        $this->authorize('update', $this->scopedStore());

        $this->validate([
            'webhookEventType' => ['required', Rule::enum(WebhookEventType::class)],
            'webhookUrl' => ['required', 'url', 'max:2048'],
        ], [], [
            'webhookEventType' => 'event type',
            'webhookUrl' => 'endpoint URL',
        ]);

        $webhook = $this->editingWebhookId !== null
            ? $this->webhook($this->editingWebhookId)
            : new WebhookSubscription([
                'store_id' => $this->storeId,
                'signing_secret_encrypted' => $webhooks->createSigningSecret(),
            ]);

        $webhook->forceFill([
            'event_type' => $this->webhookEventType,
            'target_url' => $this->webhookUrl,
            'status' => WebhookSubscriptionStatus::Active,
        ])->save();

        $this->editingWebhookId = null;
        $this->webhookUrl = '';
        $this->modal('webhook-form')->close();
        $this->dispatch('toast', type: 'success', message: __('Webhook saved'));
    }

    public function deleteWebhook(int $webhookId): void
    {
        $this->authorize('update', $this->scopedStore());

        $this->webhook($webhookId)->delete();
        $this->dispatch('toast', type: 'success', message: __('Webhook deleted'));
    }

    /**
     * @return Collection<int, OauthToken>
     */
    public function tokens(): Collection
    {
        return OauthToken::query()
            ->with('installation.app')
            ->whereHas('installation', function ($query): void {
                $query->withoutGlobalScopes()->where('store_id', $this->storeId);
            })
            ->latest('created_at')
            ->get();
    }

    /**
     * @return Collection<int, WebhookSubscription>
     */
    public function webhooks(): Collection
    {
        return WebhookSubscription::withoutGlobalScopes()
            ->withCount([
                'deliveries',
                'deliveries as failed_deliveries_count' => fn ($query) => $query->where('status', 'failed'),
            ])
            ->where('store_id', $this->storeId)
            ->orderBy('event_type')
            ->get();
    }

    public function render()
    {
        return view('livewire.admin.developers.index', [
            'tokens' => $this->tokens(),
            'webhooks' => $this->webhooks(),
            'eventTypes' => WebhookEventType::selectable(),
        ])->layout('layouts.app', [
            'title' => __('Developers'),
        ]);
    }

    private function token(int $tokenId): OauthToken
    {
        return OauthToken::query()


Input:
*** Begin Patch
*** Update File: app/Services/ThemeArchiveInstaller.php
@@
 namespace App\Services;

+use App\Enums\ThemeStatus;
+use App\Models\Store;
+use App\Models\Theme;
+use App\Models\ThemeFile;
+use App\Models\ThemeSettings;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Validation\ValidationException;
+use ZipArchive;
+
 class ThemeArchiveInstaller
 {
     /**
-     * Create a new class instance.
+     * @var list<string>
      */
-    public function __construct()
+    private const REQUIRED_PATHS = [
+        'layouts/storefront.blade.php',
+        'sections/hero.blade.php',
+        'sections/featured-products.blade.php',
+    ];
+
+    public function __construct(private ThemeSettingsService $settings)
     {
-        //
+    }
+
+    public function install(Store $store, UploadedFile $archive, ?string $name = null): Theme
+    {
+        [$files, $manifest] = $this->readArchive($archive);
+
+        $this->validateStructure($files);
+
+        $themeName = trim((string) ($name ?: data_get($manifest, 'name', '')));
+
+        if ($themeName === '') {
+            throw ValidationException::withMessages([
+                'name' => __('The theme archive manifest must include a name.'),
+            ]);
+        }
+
+        $version = trim((string) data_get($manifest, 'version', '1.0.0'));
+        $settings = data_get($manifest, 'settings_json', data_get($manifest, 'settings'));
+
+        return DB::transaction(function () use ($files, $settings, $store, $themeName, $version): Theme {
+            $theme = Theme::withoutGlobalScopes()->create([
+                'store_id' => $store->getKey(),
+                'name' => $themeName,
+                'version' => $version !== '' ? $version : null,
+                'status' => ThemeStatus::Draft,
+                'published_at' => null,
+            ]);
+
+            foreach ($files as $path => $contents) {
+                $storageKey = "themes/{$theme->getKey()}/{$path}";
+
+                Storage::disk('local')->put($storageKey, $contents);
+
+                ThemeFile::withoutGlobalScopes()->create([
+                    'theme_id' => $theme->getKey(),
+                    'path' => $path,
+                    'storage_key' => $storageKey,
+                    'sha256' => hash('sha256', $contents),
+                    'byte_size' => strlen($contents),
+                ]);
+            }
+
+            ThemeSettings::withoutGlobalScopes()->create([
+                'theme_id' => $theme->getKey(),
+                'settings_json' => is_array($settings) && ! array_is_list($settings)
+                    ? $settings
+                    : $this->settings->defaultsForStore($store),
+                'updated_at' => now(),
+            ]);
+
+            return $theme->load('settings')->loadCount('files');
+        });
+    }
+
+    /**
+     * @return list<string>
+     */
+    public static function requiredPaths(): array
+    {
+        return self::REQUIRED_PATHS;
+    }
+
+    /**
+     * @return array{0: array<string, string>, 1: array<string, mixed>}
+     */
+    private function readArchive(UploadedFile $archive): array
+    {
+        $zip = new ZipArchive;
+
+        if ($zip->open($archive->getRealPath()) !== true) {
+            throw ValidationException::withMessages([
+                'file' => __('The theme archive could not be opened.'),
+            ]);
+        }
+
+        $files = [];
+
+        try {
+            for ($index = 0; $index < $zip->numFiles; $index++) {
+                $name = $zip->getNameIndex($index);
+
+                if (! is_string($name)) {
+                    continue;
+                }
+
+                $path = $this->normalizePath($name);
+
+                if ($path === null) {
+                    continue;
+                }
+
+                $contents = $zip->getFromIndex($index);
+
+                if (! is_string($contents)) {
+                    throw ValidationException::withMessages([
+                        'file' => __('The theme archive contains an unreadable file.'),
+                    ]);
+                }
+
+                $files[$path] = $contents;
+            }
+        } finally {
+            $zip->close();
+        }
+
+        $files = $this->stripRootDirectory($files);
+        $manifestPath = array_key_exists('theme.json', $files)
+            ? 'theme.json'
+            : (array_key_exists('manifest.json', $files) ? 'manifest.json' : null);
+
+        if ($manifestPath === null) {
+            throw ValidationException::withMessages([
+                'file' => __('The theme archive must contain a theme.json manifest.'),
+            ]);
+        }
+
+        $manifest = json_decode($files[$manifestPath], true);
+
+        if (! is_array($manifest) || array_is_list($manifest)) {
+            throw ValidationException::withMessages([
+                'file' => __('The theme archive manifest must be a valid JSON object.'),
+            ]);
+        }
+
+        return [$files, $manifest];
+    }
+
+    private function normalizePath(string $name): ?string
+    {
+        $path = str_replace('\\', '/', $name);
+
+        if ($path === '' || str_ends_with($path, '/')) {
+            return null;
+        }
+
+        if (str_starts_with($path, '/') || str_starts_with($path, '__MACOSX/')) {
+            throw ValidationException::withMessages([
+                'file' => __('The theme archive contains an invalid file path.'),
+            ]);
+        }
+
+        $segments = explode('/', $path);
+
+        foreach ($segments as $segment) {
+            if ($segment === '' || $segment === '.' || $segment === '..') {
+                throw ValidationException::withMessages([
+                    'file' => __('The theme archive contains an invalid file path.'),
+                ]);
+            }
+        }
+
+        if (end($segments) === '.DS_Store') {
+            return null;
+        }
+
+        if (strlen($path) > 255) {
+            throw ValidationException::withMessages([
+                'file' => __('The theme archive contains a file path that is too long.'),
+            ]);
+        }
+
+        return $path;
+    }
+
+    /**
+     * @param  array<string, string>  $files
+     * @return array<string, string>
+     */
+    private function stripRootDirectory(array $files): array
+    {
+        if ($files === []) {
+            return $files;
+        }
+
+        $paths = array_keys($files);
+
+        if (! collect($paths)->every(fn (string $path): bool => str_contains($path, '/'))) {
+            return $files;
+        }
+
+        $root = explode('/', $paths[0], 2)[0];
+
+        if (! collect($paths)->every(fn (string $path): bool => str_starts_with($path, "{$root}/"))) {
+            return $files;
+        }
+
+        return collect($files)
+            ->mapWithKeys(fn (string $contents, string $path): array => [substr($path, strlen($root) + 1) => $contents])
+            ->all();
+    }
+
+    /**
+     * @param  array<string, string>  $files
+     */
+    private function validateStructure(array $files): void
+    {
+        $missing = array_values(array_diff(self::REQUIRED_PATHS, array_keys($files)));
+
+        if ($missing !== []) {
+            throw ValidationException::withMessages([
+                'file' => __('The theme archive is missing required file: :path', ['path' => $missing[0]]),
+            ]);
+        }
     }
 }
*** End Patch


Success. Updated the following files:
M app/Services/ThemeArchiveInstaller.php

@@ -4,2 +4,13 @@

+use App\Enums\ThemeStatus;
+use App\Models\Store;
+use App\Models\Theme;
+use App\Models\ThemeFile;
+use App\Models\ThemeSettings;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Validation\ValidationException;
+use ZipArchive;
+
 class ThemeArchiveInstaller
@@ -7,7 +18,215 @@
     /**
-     * Create a new class instance.
+     * @var list<string>
      */
-    public function __construct()
+    private const REQUIRED_PATHS = [
+        'layouts/storefront.blade.php',
+        'sections/hero.blade.php',
+        'sections/featured-products.blade.php',
+    ];
+
+    public function __construct(private ThemeSettingsService $settings)
+    {
+    }
+
+    public function install(Store $store, UploadedFile $archive, ?string $name = null): Theme
     {
-        //
+        [$files, $manifest] = $this->readArchive($archive);
+
+        $this->validateStructure($files);
+
+        $themeName = trim((string) ($name ?: data_get($manifest, 'name', '')));
+
+        if ($themeName === '') {
+            throw ValidationException::withMessages([
+                'name' => __('The theme archive manifest must include a name.'),
+            ]);
+        }
+
+        $version = trim((string) data_get($manifest, 'version', '1.0.0'));
+        $settings = data_get($manifest, 'settings_json', data_get($manifest, 'settings'));
+
+        return DB::transaction(function () use ($files, $settings, $store, $themeName, $version): Theme {
+            $theme = Theme::withoutGlobalScopes()->create([
+                'store_id' => $store->getKey(),
+                'name' => $themeName,
+                'version' => $version !== '' ? $version : null,
+                'status' => ThemeStatus::Draft,
+                'published_at' => null,
+            ]);
+
+            foreach ($files as $path => $contents) {
+                $storageKey = "themes/{$theme->getKey()}/{$path}";
+
+                Storage::disk('local')->put($storageKey, $contents);
+
+                ThemeFile::withoutGlobalScopes()->create([
+                    'theme_id' => $theme->getKey(),
+                    'path' => $path,
+                    'storage_key' => $storageKey,
+                    'sha256' => hash('sha256', $contents),
+                    'byte_size' => strlen($contents),
+                ]);
+            }
+
+            ThemeSettings::withoutGlobalScopes()->create([
+                'theme_id' => $theme->getKey(),
+                'settings_json' => is_array($settings) && ! array_is_list($settings)
+                    ? $settings
+                    : $this->settings->defaultsForStore($store),
+                'updated_at' => now(),
+            ]);
+
+            return $theme->load('settings')->loadCount('files');
+        });
+    }
+
+    /**
+     * @return list<string>
+     */
+    public static function requiredPaths(): array
+    {
+        return self::REQUIRED_PATHS;
+    }
+
+    /**
+     * @return array{0: array<string, string>, 1: array<string, mixed>}
+     */
+    private function readArchive(UploadedFile $archive): array
+    {
+        $zip = new ZipArchive;
+
+        if ($zip->open($archive->getRealPath()) !== true) {
+            throw ValidationException::withMessages([
+                'file' => __('The theme archive could not be opened.'),
+            ]);
+        }
+
+        $files = [];
+
+        try {
+            for ($index = 0; $index < $zip->numFiles; $index++) {
+                $name = $zip->getNameIndex($index);
+
+                if (! is_string($name)) {
+                    continue;
+                }
+
+                $path = $this->normalizePath($name);
+
+                if ($path === null) {
+                    continue;
+                }
+
+                $contents = $zip->getFromIndex($index);
+
+                if (! is_string($contents)) {
+                    throw ValidationException::withMessages([
+                        'file' => __('The theme archive contains an unreadable file.'),
+                    ]);
+                }
+
+                $files[$path] = $contents;
+            }
+        } finally {
+            $zip->close();
+        }
+
+        $files = $this->stripRootDirectory($files);
+        $manifestPath = array_key_exists('theme.json', $files)
+            ? 'theme.json'
+            : (array_key_exists('manifest.json', $files) ? 'manifest.json' : null);
+
+        if ($manifestPath === null) {
+            throw ValidationException::withMessages([
+                'file' => __('The theme archive must contain a theme.json manifest.'),
+            ]);
+        }
+
+        $manifest = json_decode($files[$manifestPath], true);
+
+        if (! is_array($manifest) || array_is_list($manifest)) {
+            throw ValidationException::withMessages([
+                'file' => __('The theme archive manifest must be a valid JSON object.'),
+            ]);
+        }
+
+        return [$files, $manifest];
+    }
+
+    private function normalizePath(string $name): ?string
+    {
+        $path = str_replace('\\', '/', $name);
+
+        if ($path === '' || str_ends_with($path, '/')) {
+            return null;
+        }
+
+        if (str_starts_with($path, '/') || str_starts_with($path, '__MACOSX/')) {
+            throw ValidationException::withMessages([
+                'file' => __('The theme archive contains an invalid file path.'),
+            ]);
+        }
+
+        $segments = explode('/', $path);
+
+        foreach ($segments as $segment) {
+            if ($segment === '' || $segment === '.' || $segment === '..') {
+                throw ValidationException::withMessages([
+                    'file' => __('The theme archive contains an invalid file path.'),
+                ]);
+            }
+        }
+
+        if (end($segments) === '.DS_Store') {
+            return null;
+        }
+
+        if (strlen($path) > 255) {
+            throw ValidationException::withMessages([
+                'file' => __('The theme archive contains a file path that is too long.'),
+            ]);
+        }
+
+        return $path;
+    }
+
+    /**
+     * @param  array<string, string>  $files
+     * @return array<string, string>
+     */
+    private function stripRootDirectory(array $files): array
+    {
+        if ($files === []) {
+            return $files;
+        }
+
+        $paths = array_keys($files);
+
+        if (! collect($paths)->every(fn (string $path): bool => str_contains($path, '/'))) {
+            return $files;
+        }
+
+        $root = explode('/', $paths[0], 2)[0];
+
+        if (! collect($paths)->every(fn (string $path): bool => str_starts_with($path, "{$root}/"))) {
+            return $files;
+        }
+
+        return collect($files)
+            ->mapWithKeys(fn (string $contents, string $path): array => [substr($path, strlen($root) + 1) => $contents])
+            ->all();
+    }
+
+    /**
+     * @param  array<string, string>  $files
+     */
+    private function validateStructure(array $files): void
+    {
+        $missing = array_values(array_diff(self::REQUIRED_PATHS, array_keys($files)));
+
+        if ($missing !== []) {
+            throw ValidationException::withMessages([
+                'file' => __('The theme archive is missing required file: :path', ['path' => $missing[0]]),
+            ]);
+        }
     }


Output: {"output":"Success. Updated the following files:\nM app/Services/ThemeArchiveInstaller.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: app/Http/Controllers/Api/Admin/V1/ThemeController.php
@@
 namespace App\Http\Controllers\Api\Admin\V1;

+use App\Enums\ThemeStatus;
 use App\Http\Controllers\Controller;
+use App\Http\Resources\Admin\V1\ThemeResource;
+use App\Models\Store;
+use App\Models\Theme;
+use App\Services\ThemeArchiveInstaller;
+use App\Services\ThemeSettingsService;
+use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\ValidationException;

 class ThemeController extends Controller
 {
-    //
+    public function store(Request $request, Store $store, ThemeArchiveInstaller $installer): JsonResponse
+    {
+        $this->authorizeStore($request, $store);
+
+        $validated = $request->validate([
+            'file' => ['required', 'file', 'mimes:zip', 'max:51200'],
+            'name' => ['sometimes', 'nullable', 'string', 'max:255'],
+        ]);
+
+        $archive = $request->file('file');
+
+        abort_unless($archive instanceof UploadedFile, 422);
+
+        $theme = $installer->install($store, $archive, $validated['name'] ?? null);
+
+        return ThemeResource::make($theme)
+            ->response()
+            ->setStatusCode(201);
+    }
+
+    public function publish(Request $request, Store $store, Theme $theme, ThemeSettingsService $settings): ThemeResource
+    {
+        $this->authorizeStore($request, $store);
+        $this->abortUnlessThemeBelongsToStore($theme, $store);
+        $this->validatePublishable($theme);
+
+        DB::transaction(function () use ($store, $theme): void {
+            Theme::withoutGlobalScopes()
+                ->where('store_id', $store->getKey())
+                ->update([
+                    'status' => ThemeStatus::Draft,
+                    'published_at' => null,
+                ]);
+
+            $theme->forceFill([
+                'status' => ThemeStatus::Published,
+                'published_at' => now(),
+            ])->save();
+        });
+
+        $settings->forget($store);
+
+        return ThemeResource::make($theme->refresh()->load('settings')->loadCount('files'));
+    }
+
+    private function authorizeStore(Request $request, Store $store): void
+    {
+        if (! $request->attributes->has('admin_api_oauth_token')) {
+            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
+        }
+
+        app()->instance('current_store', $store);
+    }
+
+    private function abortUnlessThemeBelongsToStore(Theme $theme, Store $store): void
+    {
+        abort_unless((int) $theme->store_id === $store->getKey(), 404);
+    }
+
+    private function validatePublishable(Theme $theme): void
+    {
+        $paths = $theme->files()
+            ->withoutGlobalScopes()
+            ->pluck('path')
+            ->all();
+        $missing = array_values(array_diff(ThemeArchiveInstaller::requiredPaths(), $paths));
+
+        if ($missing !== []) {
+            throw ValidationException::withMessages([
+                'theme' => __('The theme is missing required file: :path', ['path' => $missing[0]]),
+            ]);
+        }
+    }
 }
*** End Patch


Success. Updated the following files:
M app/Http/Controllers/Api/Admin/V1/ThemeController.php

@@ -4,4 +4,14 @@

+use App\Enums\ThemeStatus;
 use App\Http\Controllers\Controller;
+use App\Http\Resources\Admin\V1\ThemeResource;
+use App\Models\Store;
+use App\Models\Theme;
+use App\Services\ThemeArchiveInstaller;
+use App\Services\ThemeSettingsService;
+use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\ValidationException;

@@ -9,3 +19,75 @@
 {
-    //
+    public function store(Request $request, Store $store, ThemeArchiveInstaller $installer): JsonResponse
+    {
+        $this->authorizeStore($request, $store);
+
+        $validated = $request->validate([
+            'file' => ['required', 'file', 'mimes:zip', 'max:51200'],
+            'name' => ['sometimes', 'nullable', 'string', 'max:255'],
+        ]);
+
+        $archive = $request->file('file');
+
+        abort_unless($archive instanceof UploadedFile, 422);
+
+        $theme = $installer->install($store, $archive, $validated['name'] ?? null);
+
+        return ThemeResource::make($theme)
+            ->response()
+            ->setStatusCode(201);
+    }
+
+    public function publish(Request $request, Store $store, Theme $theme, ThemeSettingsService $settings): ThemeResource
+    {
+        $this->authorizeStore($request, $store);
+        $this->abortUnlessThemeBelongsToStore($theme, $store);
+        $this->validatePublishable($theme);
+
+        DB::transaction(function () use ($store, $theme): void {
+            Theme::withoutGlobalScopes()
+                ->where('store_id', $store->getKey())
+                ->update([
+                    'status' => ThemeStatus::Draft,
+                    'published_at' => null,
+                ]);
+
+            $theme->forceFill([
+                'status' => ThemeStatus::Published,
+                'published_at' => now(),
+            ])->save();
+        });
+
+        $settings->forget($store);
+
+        return ThemeResource::make($theme->refresh()->load('settings')->loadCount('files'));
+    }
+
+    private function authorizeStore(Request $request, Store $store): void
+    {
+        if (! $request->attributes->has('admin_api_oauth_token')) {
+            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
+        }
+
+        app()->instance('current_store', $store);
+    }
+
+    private function abortUnlessThemeBelongsToStore(Theme $theme, Store $store): void
+    {
+        abort_unless((int) $theme->store_id === $store->getKey(), 404);
+    }
+
+    private function validatePublishable(Theme $theme): void
+    {
+        $paths = $theme->files()
+            ->withoutGlobalScopes()
+            ->pluck('path')
+            ->all();
+        $missing = array_values(array_diff(ThemeArchiveInstaller::requiredPaths(), $paths));
+
+        if ($missing !== []) {
+            throw ValidationException::withMessages([
+                'theme' => __('The theme is missing required file: :path', ['path' => $missing[0]]),
+            ]);
+        }
+    }
 }


Output: {"output":"Success. Updated the following files:\nM app/Http/Controllers/Api/Admin/V1/ThemeController.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php
@@
 namespace App\Http\Controllers\Api\Admin\V1;

 use App\Http\Controllers\Controller;
+use App\Http\Resources\Admin\V1\ThemeResource;
+use App\Models\Store;
+use App\Models\Theme;
+use App\Models\ThemeSettings;
+use App\Services\ThemeSettingsService;
 use Illuminate\Http\Request;
+use Illuminate\Validation\ValidationException;

 class ThemeSettingsController extends Controller
 {
-    //
+    public function update(Request $request, Store $store, Theme $theme, ThemeSettingsService $settings): ThemeResource
+    {
+        $this->authorizeStore($request, $store);
+        $this->abortUnlessThemeBelongsToStore($theme, $store);
+
+        $validated = $request->validate([
+            'settings_json' => ['required', 'array'],
+        ]);
+
+        if (array_is_list($validated['settings_json'])) {
+            throw ValidationException::withMessages([
+                'settings_json' => __('The settings json field must be an object.'),
+            ]);
+        }
+
+        ThemeSettings::withoutGlobalScopes()->updateOrCreate(
+            ['theme_id' => $theme->getKey()],
+            [
+                'settings_json' => $validated['settings_json'],
+                'updated_at' => now(),
+            ],
+        );
+
+        $settings->forget($store);
+
+        return ThemeResource::make($theme->refresh()->load('settings')->loadCount('files'));
+    }
+
+    private function authorizeStore(Request $request, Store $store): void
+    {
+        if (! $request->attributes->has('admin_api_oauth_token')) {
+            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
+        }
+
+        app()->instance('current_store', $store);
+    }
+
+    private function abortUnlessThemeBelongsToStore(Theme $theme, Store $store): void
+    {
+        abort_unless((int) $theme->store_id === $store->getKey(), 404);
+    }
 }
*** End Patch


Success. Updated the following files:
M app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php

@@ -5,3 +5,9 @@
 use App\Http\Controllers\Controller;
+use App\Http\Resources\Admin\V1\ThemeResource;
+use App\Models\Store;
+use App\Models\Theme;
+use App\Models\ThemeSettings;
+use App\Services\ThemeSettingsService;
 use Illuminate\Http\Request;
+use Illuminate\Validation\ValidationException;

@@ -9,3 +15,43 @@
 {
-    //
+    public function update(Request $request, Store $store, Theme $theme, ThemeSettingsService $settings): ThemeResource
+    {
+        $this->authorizeStore($request, $store);
+        $this->abortUnlessThemeBelongsToStore($theme, $store);
+
+        $validated = $request->validate([
+            'settings_json' => ['required', 'array'],
+        ]);
+
+        if (array_is_list($validated['settings_json'])) {
+            throw ValidationException::withMessages([
+                'settings_json' => __('The settings json field must be an object.'),
+            ]);
+        }
+
+        ThemeSettings::withoutGlobalScopes()->updateOrCreate(
+            ['theme_id' => $theme->getKey()],
+            [
+                'settings_json' => $validated['settings_json'],
+                'updated_at' => now(),
+            ],
+        );
+
+        $settings->forget($store);
+
+        return ThemeResource::make($theme->refresh()->load('settings')->loadCount('files'));
+    }
+
+    private function authorizeStore(Request $request, Store $store): void
+    {
+        if (! $request->attributes->has('admin_api_oauth_token')) {
+            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
+        }
+
+        app()->instance('current_store', $store);
+    }
+
+    private function abortUnlessThemeBelongsToStore(Theme $theme, Store $store): void
+    {
+        abort_unless((int) $theme->store_id === $store->getKey(), 404);
+    }
 }


Output: {"output":"Success. Updated the following files:\nM app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: app/Http/Resources/Admin/V1/ThemeResource.php
@@
     public function toArray(Request $request): array
     {
-        return parent::toArray($request);
+        return [
+            'id' => $this->id,
+            'store_id' => $this->store_id,
+            'name' => $this->name,
+            'version' => $this->version,
+            'status' => $this->status?->value,
+            'published_at' => $this->published_at?->toIso8601String(),
+            'created_at' => $this->created_at?->toIso8601String(),
+            'updated_at' => $this->updated_at?->toIso8601String(),
+            'files_count' => $this->whenCounted('files'),
+            'settings_json' => $this->whenLoaded('settings', fn (): array => $this->settings?->settings_json ?? []),
+        ];
     }
 }
*** End Patch


Success. Updated the following files:
M app/Http/Resources/Admin/V1/ThemeResource.php

@@ -16,3 +16,14 @@
     {
-        return parent::toArray($request);
+        return [
+            'id' => $this->id,
+            'store_id' => $this->store_id,
+            'name' => $this->name,
+            'version' => $this->version,
+            'status' => $this->status?->value,
+            'published_at' => $this->published_at?->toIso8601String(),
+            'created_at' => $this->created_at?->toIso8601String(),
+            'updated_at' => $this->updated_at?->toIso8601String(),
+            'files_count' => $this->whenCounted('files'),
+            'settings_json' => $this->whenLoaded('settings', fn (): array => $this->settings?->settings_json ?? []),
+        ];
     }


Output: {"output":"Success. Updated the following files:\nM app/Http/Resources/Admin/V1/ThemeResource.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: routes/api.php
@@
 use App\Http\Controllers\Api\Admin\V1\ShippingRateController as AdminShippingRateController;
 use App\Http\Controllers\Api\Admin\V1\ShippingZoneController as AdminShippingZoneController;
 use App\Http\Controllers\Api\Admin\V1\TaxSettingsController as AdminTaxSettingsController;
+use App\Http\Controllers\Api\Admin\V1\ThemeController as AdminThemeController;
+use App\Http\Controllers\Api\Admin\V1\ThemeSettingsController as AdminThemeSettingsController;
@@
         Route::middleware('admin.api:write-settings')->group(function (): void {
             Route::post('search/reindex', [AdminSearchIndexController::class, 'reindex'])->name('search.reindex');
             Route::post('shipping/zones', [AdminShippingZoneController::class, 'store'])->name('shipping.zones.store');
             Route::put('shipping/zones/{shippingZone}', [AdminShippingZoneController::class, 'update'])->name('shipping.zones.update');
             Route::post('shipping/zones/{shippingZone}/rates', [AdminShippingRateController::class, 'store'])->name('shipping.zones.rates.store');
             Route::put('tax/settings', [AdminTaxSettingsController::class, 'update'])->name('tax.settings.update');
         });

+        Route::middleware('admin.api:write-themes')->group(function (): void {
+            Route::post('themes', [AdminThemeController::class, 'store'])->name('themes.store');
+            Route::post('themes/{theme}/publish', [AdminThemeController::class, 'publish'])->name('themes.publish');
+            Route::put('themes/{theme}/settings', [AdminThemeSettingsController::class, 'update'])->name('themes.settings.update');
+        });
+
         Route::middleware('admin.api:read-analytics')->group(function (): void {
             Route::get('analytics/summary', [AdminAnalyticsSummaryController::class, 'show'])->name('analytics.summary');
         });
*** End Patch


Success. Updated the following files:
M routes/api.php

@@ -15,2 +15,4 @@
 use App\Http\Controllers\Api\Admin\V1\TaxSettingsController as AdminTaxSettingsController;
+use App\Http\Controllers\Api\Admin\V1\ThemeController as AdminThemeController;
+use App\Http\Controllers\Api\Admin\V1\ThemeSettingsController as AdminThemeSettingsController;
 use App\Http\Controllers\Api\Apps\V1\DeferredEndpointController as DeferredAppEndpointController;
@@ -116,2 +118,8 @@

+        Route::middleware('admin.api:write-themes')->group(function (): void {
+            Route::post('themes', [AdminThemeController::class, 'store'])->name('themes.store');
+            Route::post('themes/{theme}/publish', [AdminThemeController::class, 'publish'])->name('themes.publish');
+            Route::put('themes/{theme}/settings', [AdminThemeSettingsController::class, 'update'])->name('themes.settings.update');
+        });
+
         Route::middleware('admin.api:read-analytics')->group(function (): void {


Output: {"output":"Success. Updated the following files:\nM routes/api.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: tests/Feature/Api/AdminThemeApiTest.php
@@
 <?php

-test('example', function () {
-    $response = $this->get('/');
+use App\Enums\ThemeStatus;
+use App\Models\Store;
+use App\Models\Theme;
+use App\Models\ThemeFile;
+use App\Models\ThemeSettings;
+use App\Models\User;
+use App\Services\WebhookService;
+use Database\Seeders\DatabaseSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Storage;
+
+uses(RefreshDatabase::class);
+
+beforeEach(function (): void {
+    $this->withoutVite();
+    $this->seed(DatabaseSeeder::class);
+});
+
+function adminThemeApiStore(): Store
+{
+    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+}
+
+function adminThemeApiUser(): User
+{
+    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
+}
+
+/**
+ * @param  list<string>  $abilities
+ * @return array{token: \App\Models\OauthToken, plain_text: string}
+ */
+function adminThemeApiToken(Store $store, array $abilities): array
+{
+    return app(WebhookService::class)->createApiToken($store, 'Theme integration', $abilities);
+}
+
+/**
+ * @param  array<string, string>  $overrides
+ */
+function adminThemeApiArchiveUpload(array $overrides = []): UploadedFile
+{
+    $path = tempnam(sys_get_temp_dir(), 'theme-api-');
+    $zip = new ZipArchive;
+
+    $zip->open($path, ZipArchive::OVERWRITE);
+
+    foreach (array_merge([
+        'theme.json' => json_encode([
+            'name' => 'API Dawn',
+            'version' => '2.0.0',
+            'settings_json' => [
+                'home' => [
+                    'hero' => [
+                        'heading' => 'Archive Hero',
+                    ],
+                ],
+            ],
+        ], JSON_THROW_ON_ERROR),
+        'layouts/storefront.blade.php' => '<x-layouts.storefront>{{ $slot }}</x-layouts.storefront>',
+        'sections/hero.blade.php' => '
API Hero
',
+        'sections/featured-products.blade.php' => '
API Products
',
+    ], $overrides) as $file => $contents) {
+        $zip->addFromString("api-theme/{$file}", $contents);
+    }
+
+    $zip->close();
+
+    return new UploadedFile($path, 'theme.zip', 'application/zip', null, true);
+}
+
+function adminThemeApiDraftTheme(Store $store): Theme
+{
+    $theme = Theme::factory()->create([
+        'store_id' => $store->getKey(),
+        'name' => 'API Draft Theme',
+    ]);
+
+    foreach ([
+        'layouts/storefront.blade.php',
+        'sections/hero.blade.php',
+        'sections/featured-products.blade.php',
+    ] as $path) {
+        ThemeFile::factory()->create([
+            'theme_id' => $theme->getKey(),
+            'path' => $path,
+        ]);
+    }
+
+    ThemeSettings::factory()->create([
+        'theme_id' => $theme->getKey(),
+    ]);
+
+    return $theme;
+}
+
+test('admin theme api installs uploaded archives', function (): void {
+    $store = adminThemeApiStore();
+    $user = adminThemeApiUser();
+
+    $response = $this->actingAs($user)
+        ->post("/api/admin/v1/stores/{$store->getKey()}/themes", [
+            'name' => 'Uploaded API Theme',
+            'file' => adminThemeApiArchiveUpload(),
+        ], ['Accept' => 'application/json'])
+        ->assertCreated()
+        ->assertJsonPath('data.name', 'Uploaded API Theme')
+        ->assertJsonPath('data.version', '2.0.0')
+        ->assertJsonPath('data.status', 'draft')
+        ->assertJsonPath('data.files_count', 4)
+        ->assertJsonPath('data.settings_json.home.hero.heading', 'Archive Hero');
+
+    $theme = Theme::withoutGlobalScopes()->findOrFail($response->json('data.id'));
+    $hero = $theme->files()->withoutGlobalScopes()->where('path', 'sections/hero.blade.php')->firstOrFail();
+
+    expect($theme->status)->toBe(ThemeStatus::Draft)
+        ->and(Storage::disk('local')->get($hero->storage_key))->toBe('
API Hero
');
+});
+
+test('admin theme api updates settings and publishes one active theme', function (): void {
+    $store = adminThemeApiStore();
+    $published = Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->where('status', ThemeStatus::Published)->firstOrFail();
+    $draft = adminThemeApiDraftTheme($store);
+
+    $this->actingAs(adminThemeApiUser())
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/settings", [
+            'settings_json' => [
+                'home' => [
+                    'hero' => [
+                        'heading' => 'API Saved Hero',
+                    ],
+                ],
+            ],
+        ])
+        ->assertOk()
+        ->assertJsonPath('data.settings_json.home.hero.heading', 'API Saved Hero');
+
+    $this->actingAs(adminThemeApiUser())
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish")
+        ->assertOk()
+        ->assertJsonPath('data.status', 'published');
+
+    expect(ThemeSettings::withoutGlobalScopes()->where('theme_id', $draft->getKey())->first()?->settings_json['home']['hero']['heading'])->toBe('API Saved Hero')
+        ->and($draft->refresh()->status)->toBe(ThemeStatus::Published)
+        ->and($published->refresh()->status)->toBe(ThemeStatus::Draft)
+        ->and(Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->where('status', ThemeStatus::Published)->count())->toBe(1);
+});
+
+test('admin theme api enforces token abilities and store scope', function (): void {
+    $store = adminThemeApiStore();
+    $otherStore = Store::factory()->create();
+    $draft = adminThemeApiDraftTheme($store);
+    $readToken = adminThemeApiToken($store, ['read-settings']);
+    $writeToken = adminThemeApiToken($store, ['write-themes']);
+    $otherStoreToken = adminThemeApiToken($otherStore, ['write-themes']);
+
+    $this->withToken($readToken['plain_text'])
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish")
+        ->assertForbidden();
+
+    $this->withToken($otherStoreToken['plain_text'])
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish")
+        ->assertForbidden();
+
+    $this->withToken($writeToken['plain_text'])
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/settings", [
+            'settings_json' => [
+                'announcement' => [
+                    'enabled' => false,
+                ],
+            ],
+        ])
+        ->assertOk()
+        ->assertJsonPath('data.settings_json.announcement.enabled', false);
+});
+
+test('admin theme api validates archives settings and publishable files', function (): void {
+    $store = adminThemeApiStore();
+    $draft = Theme::factory()->create([
+        'store_id' => $store->getKey(),
+        'name' => 'Incomplete API Theme',
+    ]);
+
+    $this->actingAs(adminThemeApiUser())
+        ->post("/api/admin/v1/stores/{$store->getKey()}/themes", [
+            'file' => adminThemeApiArchiveUpload([
+                'sections/hero.blade.php' => '',
+            ]),
+        ], ['Accept' => 'application/json'])
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['file']);
+
+    $this->actingAs(adminThemeApiUser())
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/settings", [
+            'settings_json' => ['invalid-list-value'],
+        ])
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['settings_json']);

-    $response->assertStatus(200);
+    $this->actingAs(adminThemeApiUser())
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish")
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['theme']);
 });
*** End Patch


Success. Updated the following files:
M tests/Feature/Api/AdminThemeApiTest.php

@@ -2,6 +2,206 @@

-test('example', function () {
-    $response = $this->get('/');
+use App\Enums\ThemeStatus;
+use App\Models\Store;
+use App\Models\Theme;
+use App\Models\ThemeFile;
+use App\Models\ThemeSettings;
+use App\Models\User;
+use App\Services\WebhookService;
+use Database\Seeders\DatabaseSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Storage;
+
+uses(RefreshDatabase::class);
+
+beforeEach(function (): void {
+    $this->withoutVite();
+    $this->seed(DatabaseSeeder::class);
+});
+
+function adminThemeApiStore(): Store
+{
+    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+}
+
+function adminThemeApiUser(): User
+{
+    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
+}
+
+/**
+ * @param  list<string>  $abilities
+ * @return array{token: \App\Models\OauthToken, plain_text: string}
+ */
+function adminThemeApiToken(Store $store, array $abilities): array
+{
+    return app(WebhookService::class)->createApiToken($store, 'Theme integration', $abilities);
+}
+
+/**
+ * @param  array<string, string>  $overrides
+ */
+function adminThemeApiArchiveUpload(array $overrides = []): UploadedFile
+{
+    $path = tempnam(sys_get_temp_dir(), 'theme-api-');
+    $zip = new ZipArchive;
+
+    $zip->open($path, ZipArchive::OVERWRITE);
+
+    foreach (array_merge([
+        'theme.json' => json_encode([
+            'name' => 'API Dawn',
+            'version' => '2.0.0',
+            'settings_json' => [
+                'home' => [
+                    'hero' => [
+                        'heading' => 'Archive Hero',
+                    ],
+                ],
+            ],
+        ], JSON_THROW_ON_ERROR),
+        'layouts/storefront.blade.php' => '<x-layouts.storefront>{{ $slot }}</x-layouts.storefront>',
+        'sections/hero.blade.php' => '
API Hero
',
+        'sections/featured-products.blade.php' => '
API Products
',
+    ], $overrides) as $file => $contents) {
+        $zip->addFromString("api-theme/{$file}", $contents);
+    }
+
+    $zip->close();
+
+    return new UploadedFile($path, 'theme.zip', 'application/zip', null, true);
+}
+
+function adminThemeApiDraftTheme(Store $store): Theme
+{
+    $theme = Theme::factory()->create([
+        'store_id' => $store->getKey(),
+        'name' => 'API Draft Theme',
+    ]);
+
+    foreach ([
+        'layouts/storefront.blade.php',
+        'sections/hero.blade.php',
+        'sections/featured-products.blade.php',
+    ] as $path) {
+        ThemeFile::factory()->create([
+            'theme_id' => $theme->getKey(),
+            'path' => $path,
+        ]);
+    }
+
+    ThemeSettings::factory()->create([
+        'theme_id' => $theme->getKey(),
+    ]);
+
+    return $theme;
+}
+
+test('admin theme api installs uploaded archives', function (): void {
+    $store = adminThemeApiStore();
+    $user = adminThemeApiUser();
+
+    $response = $this->actingAs($user)
+        ->post("/api/admin/v1/stores/{$store->getKey()}/themes", [
+            'name' => 'Uploaded API Theme',
+            'file' => adminThemeApiArchiveUpload(),
+        ], ['Accept' => 'application/json'])
+        ->assertCreated()
+        ->assertJsonPath('data.name', 'Uploaded API Theme')
+        ->assertJsonPath('data.version', '2.0.0')
+        ->assertJsonPath('data.status', 'draft')
+        ->assertJsonPath('data.files_count', 4)
+        ->assertJsonPath('data.settings_json.home.hero.heading', 'Archive Hero');
+
+    $theme = Theme::withoutGlobalScopes()->findOrFail($response->json('data.id'));
+    $hero = $theme->files()->withoutGlobalScopes()->where('path', 'sections/hero.blade.php')->firstOrFail();
+
+    expect($theme->status)->toBe(ThemeStatus::Draft)
+        ->and(Storage::disk('local')->get($hero->storage_key))->toBe('
API Hero
');
+});
+
+test('admin theme api updates settings and publishes one active theme', function (): void {
+    $store = adminThemeApiStore();
+    $published = Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->where('status', ThemeStatus::Published)->firstOrFail();
+    $draft = adminThemeApiDraftTheme($store);
+
+    $this->actingAs(adminThemeApiUser())
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/settings", [
+            'settings_json' => [
+                'home' => [
+                    'hero' => [
+                        'heading' => 'API Saved Hero',
+                    ],
+                ],
+            ],
+        ])
+        ->assertOk()
+        ->assertJsonPath('data.settings_json.home.hero.heading', 'API Saved Hero');
+
+    $this->actingAs(adminThemeApiUser())
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish")
+        ->assertOk()
+        ->assertJsonPath('data.status', 'published');

-    $response->assertStatus(200);
+    expect(ThemeSettings::withoutGlobalScopes()->where('theme_id', $draft->getKey())->first()?->settings_json['home']['hero']['heading'])->toBe('API Saved Hero')
+        ->and($draft->refresh()->status)->toBe(ThemeStatus::Published)
+        ->and($published->refresh()->status)->toBe(ThemeStatus::Draft)
+        ->and(Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->where('status', ThemeStatus::Published)->count())->toBe(1);
+});
+
+test('admin theme api enforces token abilities and store scope', function (): void {
+    $store = adminThemeApiStore();
+    $otherStore = Store::factory()->create();
+    $draft = adminThemeApiDraftTheme($store);
+    $readToken = adminThemeApiToken($store, ['read-settings']);
+    $writeToken = adminThemeApiToken($store, ['write-themes']);
+    $otherStoreToken = adminThemeApiToken($otherStore, ['write-themes']);
+
+    $this->withToken($readToken['plain_text'])
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish")
+        ->assertForbidden();
+
+    $this->withToken($otherStoreToken['plain_text'])
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish")
+        ->assertForbidden();
+
+    $this->withToken($writeToken['plain_text'])
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/settings", [
+            'settings_json' => [
+                'announcement' => [
+                    'enabled' => false,
+                ],
+            ],
+        ])
+        ->assertOk()
+        ->assertJsonPath('data.settings_json.announcement.enabled', false);
+});
+
+test('admin theme api validates archives settings and publishable files', function (): void {
+    $store = adminThemeApiStore();
+    $draft = Theme::factory()->create([
+        'store_id' => $store->getKey(),
+        'name' => 'Incomplete API Theme',
+    ]);
+
+    $this->actingAs(adminThemeApiUser())
+        ->post("/api/admin/v1/stores/{$store->getKey()}/themes", [
+            'file' => adminThemeApiArchiveUpload([
+                'sections/hero.blade.php' => '',
+            ]),
+        ], ['Accept' => 'application/json'])
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['file']);
+
+    $this->actingAs(adminThemeApiUser())
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/settings", [
+            'settings_json' => ['invalid-list-value'],
+        ])
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['settings_json']);
+
+    $this->actingAs(adminThemeApiUser())
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/themes/{$draft->getKey()}/publish")
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['theme']);
 });


Output: {"output":"Success. Updated the following files:\nM tests/Feature/Api/AdminThemeApiTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: tests/Feature/Api/AdminThemeApiTest.php
@@
- * @param  array<string, string>  $overrides
+ * @param  array<string, string|null>  $overrides
@@
-    foreach (array_merge([
+    foreach (array_merge([
         'theme.json' => json_encode([
             'name' => 'API Dawn',
             'version' => '2.0.0',
@@
         'layouts/storefront.blade.php' => '<x-layouts.storefront>{{ $slot }}</x-layouts.storefront>',
         'sections/hero.blade.php' => '
API Hero
',
         'sections/featured-products.blade.php' => '
API Products
',
     ], $overrides) as $file => $contents) {
+        if ($contents === null) {
+            continue;
+        }
+
         $zip->addFromString("api-theme/{$file}", $contents);
     }
@@
         ->post("/api/admin/v1/stores/{$store->getKey()}/themes", [
             'file' => adminThemeApiArchiveUpload([
-                'sections/hero.blade.php' => '',
+                'sections/hero.blade.php' => null,
             ]),
         ], ['Accept' => 'application/json'])
*** End Patch


@@ -41,3 +41,3 @@
 /**
- * @param  array<string, string>  $overrides
+ * @param  array<string, string|null>  $overrides
  */
@@ -66,2 +66,6 @@
     ], $overrides) as $file => $contents) {
+        if ($contents === null) {
+            continue;
+        }
+
         $zip->addFromString("api-theme/{$file}", $contents);
@@ -189,3 +193,3 @@
             'file' => adminThemeApiArchiveUpload([
-                'sections/hero.blade.php' => '',
+                'sections/hero.blade.php' => null,
             ]),


Output: {"output":"Success. Updated the following files:\nM tests/Feature/Api/AdminThemeApiTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


The API implementation is in place now. I’m running the new focused test file first; if it exposes edge cases in ZIP validation or resource serialization, I’ll tighten those before formatting and broader API checks.


php artisan test --compact tests/Feature/Api/AdminThemeApiTest.php


Output:
Chunk ID: 6f91fd
Wall time: 1.7684 seconds
Process exited with code 0
Original token count: 17
Output:

  ....

  Tests:    4 passed (29 assertions)
  Duration: 1.56s


php artisan test --compact tests/Feature/Api/AdminThemeApiTest.php

  ....

  Tests:    4 passed (29 assertions)
  Duration: 1.56s


The new theme API tests pass on the first run: archive install, settings update, publishing, token ability checks, and validation all exercised. I’m doing a small hardening pass on manifest values before running the broader API suite.


Input:
*** Begin Patch
*** Update File: app/Services/ThemeArchiveInstaller.php
@@
         if ($themeName === '') {
             throw ValidationException::withMessages([
                 'name' => __('The theme archive manifest must include a name.'),
             ]);
         }

         $version = trim((string) data_get($manifest, 'version', '1.0.0'));
         $settings = data_get($manifest, 'settings_json', data_get($manifest, 'settings'));

+        if (mb_strlen($themeName) > 255) {
+            throw ValidationException::withMessages([
+                'name' => __('The theme name may not be greater than 255 characters.'),
+            ]);
+        }
+
+        if (mb_strlen($version) > 255) {
+            throw ValidationException::withMessages([
+                'file' => __('The theme archive manifest version may not be greater than 255 characters.'),
+            ]);
+        }
+
         return DB::transaction(function () use ($files, $settings, $store, $themeName, $version): Theme {
@@
         $zip = new ZipArchive;
+        $realPath = $archive->getRealPath();

-        if ($zip->open($archive->getRealPath()) !== true) {
+        if (! is_string($realPath) || $zip->open($realPath) !== true) {
             throw ValidationException::withMessages([
                 'file' => __('The theme archive could not be opened.'),
             ]);
*** End Patch


@@ -47,2 +47,14 @@

+        if (mb_strlen($themeName) > 255) {
+            throw ValidationException::withMessages([
+                'name' => __('The theme name may not be greater than 255 characters.'),
+            ]);
+        }
+
+        if (mb_strlen($version) > 255) {
+            throw ValidationException::withMessages([
+                'file' => __('The theme archive manifest version may not be greater than 255 characters.'),
+            ]);
+        }
+
         return DB::transaction(function () use ($files, $settings, $store, $themeName, $version): Theme {
@@ -96,4 +108,5 @@
         $zip = new ZipArchive;
+        $realPath = $archive->getRealPath();

-        if ($zip->open($archive->getRealPath()) !== true) {
+        if (! is_string($realPath) || $zip->open($realPath) !== true) {
             throw ValidationException::withMessages([


Output: {"output":"Success. Updated the following files:\nM app/Services/ThemeArchiveInstaller.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


sed -n '1,200p' resources/views/livewire/admin/developers/index.blade.php


sed -n '1,200p' resources/views/livewire/admin/developers/index.blade.php

            <flux:heading size="xl">Developers</flux:heading>
            <flux:text class="mt-1">API access and outbound webhook subscriptions.</flux:text>

    @if (session('status'))
        <flux:callout color="green" icon="check-circle">
            {{ session('status') }}
        </flux:callout>
    @endif

    @if ($generatedToken)
        <flux:callout color="amber" icon="key">

Copy this token now. It will not be shown again.

                {{ $generatedToken }}

        </flux:callout>
    @endif

                <flux:heading size="lg">API tokens</flux:heading>
                <flux:text class="mt-1">Personal access tokens for store integrations.</flux:text>

            <flux:modal.trigger name="generate-token">
                <flux:button icon="plus" variant="primary">Generate new token</flux:button>
            </flux:modal.trigger>

                        Name
                        App
                        Last used
                        Created
                        Actions

                    @forelse ($tokens as $token)

getKey() }}">
                            {{ $token->name ?? 'API token' }}
                            {{ $token->installation->app->name }}
                            {{ $token->last_used_at?->diffForHumans() ?? 'Never' }}
                            {{ $token->created_at?->toFormattedDateString() ?? 'Unknown' }}

                                <flux:button wire:click="revokeToken({{ $token->getKey() }})" wire:confirm="Revoke this token?" size="sm" variant="danger">
                                    Revoke
                                </flux:button>

                    @empty

                            No API tokens created.

                    @endforelse

    <flux:separator />

                <flux:heading size="lg">Webhooks</flux:heading>
                <flux:text class="mt-1">Send real-time event notifications to external endpoints.</flux:text>

            <flux:button wire:click="openWebhookModal" icon="plus" variant="primary">
                Add webhook
            </flux:button>

                        Event type
                        URL
                        Status
                        Actions

                    @forelse ($webhooks as $webhook)

getKey() }}">
                            {{ $webhook->event_type->value }}
                            {{ $webhook->target_url }}

                                <flux:badge :color="$webhook->status->badgeColor()">{{ $webhook->status->label() }}</flux:badge>

                                    <flux:button wire:click="openWebhookModal({{ $webhook->getKey() }})" size="sm" variant="filled">Edit</flux:button>
                                    <flux:button wire:click="deleteWebhook({{ $webhook->getKey() }})" wire:confirm="Delete this webhook?" size="sm" variant="danger">Delete</flux:button>

                    @empty

                            No webhook subscriptions created.

                    @endforelse

    <flux:modal name="generate-token" class="md:w-[32rem]">
        <form wire:submit="generateToken" class="space-y-6">
            <flux:heading size="lg">Generate API token</flux:heading>

            <flux:input wire:model="newTokenName" label="Token name" placeholder="My integration" />
            <flux:error name="newTokenName" />

                <flux:modal.close>
                    <flux:button variant="filled">Cancel</flux:button>
                </flux:modal.close>
                <flux:button type="submit" variant="primary">Generate</flux:button>

        </form>
    </flux:modal>

    <flux:modal name="webhook-form" class="md:w-[34rem]">
        <form wire:submit="saveWebhook" class="space-y-6">
            <flux:heading size="lg">{{ $editingWebhookId ? 'Edit webhook' : 'Add webhook' }}</flux:heading>

            <flux:select wire:model="webhookEventType" label="Event type">
                @foreach ($eventTypes as $eventType)
                    <flux:select.option wire:key="event-type-{{ $eventType->value }}" value="{{ $eventType->value }}">
                        {{ $eventType->value }}
                    </flux:select.option>
                @endforeach
            </flux:select>
            <flux:error name="webhookEventType" />

            <flux:input type="url" wire:model="webhookUrl" label="Endpoint URL" placeholder="https://example.com/webhooks" />
            <flux:error name="webhookUrl" />

                <flux:modal.close>
                    <flux:button variant="filled">Cancel</flux:button>
                </flux:modal.close>
                <flux:button type="submit" variant="primary">Save</flux:button>

        </form>
    </flux:modal>


Output:
Chunk ID: ee2f63
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1963
Output:

            <flux:heading size="xl">Developers</flux:heading>
            <flux:text class="mt-1">API access and outbound webhook subscriptions.</flux:text>

    @if (session('status'))
        <flux:callout color="green" icon="check-circle">
            {{ session('status') }}
        </flux:callout>
    @endif

    @if ($generatedToken)
        <flux:callout color="amber" icon="key">

Copy this token now. It will not be shown again.

                {{ $generatedToken }}

        </flux:callout>
    @endif

                <flux:heading size="lg">API tokens</flux:heading>
                <flux:text class="mt-1">Personal access tokens for store integrations.</flux:text>

            <flux:modal.trigger name="generate-token">
                <flux:button icon="plus" variant="primary">Generate new token</flux:button>
            </flux:modal.trigger>

                        Name
                        App
                        Last used
                        Created
                        Actions

                    @forelse ($tokens as $token)

getKey() }}">
                            {{ $token->name ?? 'API token' }}
                            {{ $token->installation->app->name }}
                            {{ $token->last_used_at?->diffForHumans() ?? 'Never' }}
                            {{ $token->created_at?->toFormattedDateString() ?? 'Unknown' }}

                                <flux:button wire:click="revokeToken({{ $token->getKey() }})" wire:confirm="Revoke this token?" size="sm" variant="danger">
                                    Revoke
                                </flux:button>

                    @empty

                            No API tokens created.

                    @endforelse

    <flux:separator />

                <flux:heading size="lg">Webhooks</flux:heading>
                <flux:text class="mt-1">Send real-time event notifications to external endpoints.</flux:text>

            <flux:button wire:click="openWebhookModal" icon="plus" variant="primary">
                Add webhook
            </flux:button>

                        Event type
                        URL
                        Status
                        Actions

                    @forelse ($webhooks as $webhook)

getKey() }}">
                            {{ $webhook->event_type->value }}
                            {{ $webhook->target_url }}

                                <flux:badge :color="$webhook->status->badgeColor()">{{ $webhook->status->label() }}</flux:badge>

                                    <flux:button wire:click="openWebhookModal({{ $webhook->getKey() }})" size="sm" variant="filled">Edit</flux:button>
                                    <flux:button wire:click="deleteWebhook({{ $webhook->getKey() }})" wire:confirm="Delete this webhook?" size="sm" variant="danger">Delete</flux:button>

                    @empty

                            No webhook subscriptions created.

                    @endforelse

    <flux:modal name="generate-token" class="md:w-[32rem]">
        <form wire:submit="generateToken" class="space-y-6">
            <flux:heading size="lg">Generate API token</flux:heading>

            <flux:input wire:model="newTokenName" label="Token name" placeholder="My integration" />
            <flux:error name="newTokenName" />

                <flux:modal.close>
                    <flux:button variant="filled">Cancel</flux:button>
                </flux:modal.close>
                <flux:button type="submit" variant="primary">Generate</flux:button>

        </form>
    </flux:modal>

    <flux:modal name="webhook-form" class="md:w-[34rem]">
        <form wire:submit="saveWebhook" class="space-y-6">
            <flux:heading size="lg">{{ $editingWebhookId ? 'Edit webhook' : 'Add webhook' }}</flux:heading>

            <flux:select wire:model="webhookEventType" label="Event type">
                @foreach ($eventTypes as $eventType)
                    <flux:select.option wire:key="event-type-{{ $eventType->value }}" value="{{ $eventType->value }}">
                        {{ $eventType->value }}
                    </flux:select.option>
                @endforeach
            </flux:select>
            <flux:error name="webhookEventType" />

            <flux:input type="url" wire:model="webhookUrl" label="Endpoint URL" placeholder="https://example.com/webhooks" />
            <flux:error name="webhookUrl" />

                <flux:modal.close>
                    <flux:button variant="filled">Cancel</flux:button>
                </flux:modal.close>
                <flux:button type="submit" variant="primary">Save</flux:button>

        </form>
    </flux:modal>


sed -n '1,240p' tests/Feature/Admin/AppsDevelopersTest.php


sed -n '1,240p' tests/Feature/Admin/AppsDevelopersTest.php

<?php

use App\Enums\StoreUserRole;
use App\Enums\WebhookEventType;
use App\Livewire\Admin\Developers\Index as DevelopersIndex;
use App\Models\OauthToken;
use App\Models\Store;
use App\Models\User;
use App\Models\WebhookSubscription;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function appsDevelopersStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

function appsDevelopersUser(?StoreUserRole $role = null): User
{
    $store = appsDevelopersStore();
    $user = User::factory()->create([
        'email_verified_at' => now(),
    ]);

    DB::table('store_users')->insert([
        'store_id' => $store->getKey(),
        'user_id' => $user->getKey(),
        'role' => ($role ?? StoreUserRole::Owner)->value,
        'created_at' => now(),
    ]);

    return $user;
}

test('apps route renders installed apps for owners and admins', function (): void {
    $this->actingAs(appsDevelopersUser(StoreUserRole::Admin))
        ->get('/admin/apps')
        ->assertSuccessful()
        ->assertSee('Apps')
        ->assertSee('Inventory Sync');
});

test('developer route rejects staff users', function (): void {
    $this->actingAs(appsDevelopersUser(StoreUserRole::Staff))
        ->get('/admin/developers')
        ->assertForbidden();
});

test('developers component generates tokens and manages webhooks', function (): void {
    $store = appsDevelopersStore();
    app()->instance('current_store', $store);
    $user = appsDevelopersUser(StoreUserRole::Owner);

    $initialTokenCount = OauthToken::query()
        ->whereHas('installation', fn ($query) => $query->withoutGlobalScopes()->where('store_id', $store->getKey()))
        ->count();

    Livewire::actingAs($user)
        ->test(DevelopersIndex::class)
        ->assertSee('API tokens')
        ->set('newTokenName', 'CI Pipeline')
        ->call('generateToken')
        ->assertHasNoErrors()
        ->assertSet('generatedToken', fn (?string $token): bool => str_starts_with((string) $token, 'shop_'))
        ->call('openWebhookModal')
        ->set('webhookEventType', WebhookEventType::OrderCreated->value)
        ->set('webhookUrl', 'https://example.com/webhooks/orders')
        ->call('saveWebhook')
        ->assertHasNoErrors()
        ->assertSee('https://example.com/webhooks/orders');

    expect(OauthToken::query()
        ->whereHas('installation', fn ($query) => $query->withoutGlobalScopes()->where('store_id', $store->getKey()))
        ->count())->toBe($initialTokenCount + 1)
        ->and(WebhookSubscription::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where('target_url', 'https://example.com/webhooks/orders')
            ->exists())->toBeTrue();
});


Output:
Chunk ID: 28925c
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 742
Output:
<?php

use App\Enums\StoreUserRole;
use App\Enums\WebhookEventType;
use App\Livewire\Admin\Developers\Index as DevelopersIndex;
use App\Models\OauthToken;
use App\Models\Store;
use App\Models\User;
use App\Models\WebhookSubscription;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function appsDevelopersStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

function appsDevelopersUser(?StoreUserRole $role = null): User
{
    $store = appsDevelopersStore();
    $user = User::factory()->create([
        'email_verified_at' => now(),
    ]);

    DB::table('store_users')->insert([
        'store_id' => $store->getKey(),
        'user_id' => $user->getKey(),
        'role' => ($role ?? StoreUserRole::Owner)->value,
        'created_at' => now(),
    ]);

    return $user;
}

test('apps route renders installed apps for owners and admins', function (): void {
    $this->actingAs(appsDevelopersUser(StoreUserRole::Admin))
        ->get('/admin/apps')
        ->assertSuccessful()
        ->assertSee('Apps')
        ->assertSee('Inventory Sync');
});

test('developer route rejects staff users', function (): void {
    $this->actingAs(appsDevelopersUser(StoreUserRole::Staff))
        ->get('/admin/developers')
        ->assertForbidden();
});

test('developers component generates tokens and manages webhooks', function (): void {
    $store = appsDevelopersStore();
    app()->instance('current_store', $store);
    $user = appsDevelopersUser(StoreUserRole::Owner);

    $initialTokenCount = OauthToken::query()
        ->whereHas('installation', fn ($query) => $query->withoutGlobalScopes()->where('store_id', $store->getKey()))
        ->count();

    Livewire::actingAs($user)
        ->test(DevelopersIndex::class)
        ->assertSee('API tokens')
        ->set('newTokenName', 'CI Pipeline')
        ->call('generateToken')
        ->assertHasNoErrors()
        ->assertSet('generatedToken', fn (?string $token): bool => str_starts_with((string) $token, 'shop_'))
        ->call('openWebhookModal')
        ->set('webhookEventType', WebhookEventType::OrderCreated->value)
        ->set('webhookUrl', 'https://example.com/webhooks/orders')
        ->call('saveWebhook')
        ->assertHasNoErrors()
        ->assertSee('https://example.com/webhooks/orders');

    expect(OauthToken::query()
        ->whereHas('installation', fn ($query) => $query->withoutGlobalScopes()->where('store_id', $store->getKey()))
        ->count())->toBe($initialTokenCount + 1)
        ->and(WebhookSubscription::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where('target_url', 'https://example.com/webhooks/orders')
            ->exists())->toBeTrue();
});


Output:
Chunk ID: c08880
Wall time: 0.4087 seconds
Process exited with code 0
Original token count: 44
Output:
{"result":"fixed","files":[{"path":"app\/Services\/ThemeArchiveInstaller.php","fixers":["unary_operator_spaces","not_operator_with_successor_space","single_line_empty_body"]}]}


/bin/zsh
-lc
vendor/bin/pint --dirty --format agent


sed -n '1,280p' app/Services/ThemeArchiveInstaller.php


sed -n '1,240p' app/Http/Controllers/Api/Admin/V1/ThemeController.php


sed -n '1,220p' app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php


sed -n '1,260p' tests/Feature/Api/AdminThemeApiTest.php


sed -n '1,240p' app/Http/Controllers/Api/Admin/V1/ThemeController.php

<?php

namespace App\Http\Controllers\Api\Admin\V1;

use App\Enums\ThemeStatus;
use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\ThemeResource;
use App\Models\Store;
use App\Models\Theme;
use App\Services\ThemeArchiveInstaller;
use App\Services\ThemeSettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

class ThemeController extends Controller
{
    public function store(Request $request, Store $store, ThemeArchiveInstaller $installer): JsonResponse
    {
        $this->authorizeStore($request, $store);

        $validated = $request->validate([
            'file' => ['required', 'file', 'mimes:zip', 'max:51200'],
            'name' => ['sometimes', 'nullable', 'string', 'max:255'],
        ]);

        $archive = $request->file('file');

        abort_unless($archive instanceof UploadedFile, 422);

        $theme = $installer->install($store, $archive, $validated['name'] ?? null);

        return ThemeResource::make($theme)
            ->response()
            ->setStatusCode(201);
    }

    public function publish(Request $request, Store $store, Theme $theme, ThemeSettingsService $settings): ThemeResource
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessThemeBelongsToStore($theme, $store);
        $this->validatePublishable($theme);

        DB::transaction(function () use ($store, $theme): void {
            Theme::withoutGlobalScopes()
                ->where('store_id', $store->getKey())
                ->update([
                    'status' => ThemeStatus::Draft,
                    'published_at' => null,
                ]);

            $theme->forceFill([
                'status' => ThemeStatus::Published,
                'published_at' => now(),
            ])->save();
        });

        $settings->forget($store);

        return ThemeResource::make($theme->refresh()->load('settings')->loadCount('files'));
    }

    private function authorizeStore(Request $request, Store $store): void
    {
        if (! $request->attributes->has('admin_api_oauth_token')) {
            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
        }

        app()->instance('current_store', $store);
    }

    private function abortUnlessThemeBelongsToStore(Theme $theme, Store $store): void
    {
        abort_unless((int) $theme->store_id === $store->getKey(), 404);
    }

    private function validatePublishable(Theme $theme): void
    {
        $paths = $theme->files()
            ->withoutGlobalScopes()
            ->pluck('path')
            ->all();
        $missing = array_values(array_diff(ThemeArchiveInstaller::requiredPaths(), $paths));

        if ($missing !== []) {
            throw ValidationException::withMessages([
                'theme' => __('The theme is missing required file: :path', ['path' => $missing[0]]),
            ]);
        }
    }
}


sed -n '1,280p' app/Services/ThemeArchiveInstaller.php

<?php

namespace App\Services;

use App\Enums\ThemeStatus;
use App\Models\Store;
use App\Models\Theme;
use App\Models\ThemeFile;
use App\Models\ThemeSettings;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use ZipArchive;

class ThemeArchiveInstaller
{
    /**
     * @var list<string>
     */
    private const REQUIRED_PATHS = [
        'layouts/storefront.blade.php',
        'sections/hero.blade.php',
        'sections/featured-products.blade.php',
    ];

    public function __construct(private ThemeSettingsService $settings) {}

    public function install(Store $store, UploadedFile $archive, ?string $name = null): Theme
    {
        [$files, $manifest] = $this->readArchive($archive);

        $this->validateStructure($files);

        $themeName = trim((string) ($name ?: data_get($manifest, 'name', '')));

        if ($themeName === '') {
            throw ValidationException::withMessages([
                'name' => __('The theme archive manifest must include a name.'),
            ]);
        }

        $version = trim((string) data_get($manifest, 'version', '1.0.0'));
        $settings = data_get($manifest, 'settings_json', data_get($manifest, 'settings'));

        if (mb_strlen($themeName) > 255) {
            throw ValidationException::withMessages([
                'name' => __('The theme name may not be greater than 255 characters.'),
            ]);
        }

        if (mb_strlen($version) > 255) {
            throw ValidationException::withMessages([
                'file' => __('The theme archive manifest version may not be greater than 255 characters.'),
            ]);
        }

        return DB::transaction(function () use ($files, $settings, $store, $themeName, $version): Theme {
            $theme = Theme::withoutGlobalScopes()->create([
                'store_id' => $store->getKey(),
                'name' => $themeName,
                'version' => $version !== '' ? $version : null,
                'status' => ThemeStatus::Draft,
                'published_at' => null,
            ]);

            foreach ($files as $path => $contents) {
                $storageKey = "themes/{$theme->getKey()}/{$path}";

                Storage::disk('local')->put($storageKey, $contents);

                ThemeFile::withoutGlobalScopes()->create([
                    'theme_id' => $theme->getKey(),
                    'path' => $path,
                    'storage_key' => $storageKey,
                    'sha256' => hash('sha256', $contents),
                    'byte_size' => strlen($contents),
                ]);
            }

            ThemeSettings::withoutGlobalScopes()->create([
                'theme_id' => $theme->getKey(),
                'settings_json' => is_array($settings) && ! array_is_list($settings)
                    ? $settings
                    : $this->settings->defaultsForStore($store),
                'updated_at' => now(),
            ]);

            return $theme->load('settings')->loadCount('files');
        });
    }

    /**
     * @return list<string>
     */
    public static function requiredPaths(): array
    {
        return self::REQUIRED_PATHS;
    }

    /**
     * @return array{0: array<string, string>, 1: array<string, mixed>}
     */
    private function readArchive(UploadedFile $archive): array
    {
        $zip = new ZipArchive;
        $realPath = $archive->getRealPath();

        if (! is_string($realPath) || $zip->open($realPath) !== true) {
            throw ValidationException::withMessages([
                'file' => __('The theme archive could not be opened.'),
            ]);
        }

        $files = [];

        try {
            for ($index = 0; $index < $zip->numFiles; $index++) {
                $name = $zip->getNameIndex($index);

                if (! is_string($name)) {
                    continue;
                }

                $path = $this->normalizePath($name);

                if ($path === null) {
                    continue;
                }

                $contents = $zip->getFromIndex($index);

                if (! is_string($contents)) {
                    throw ValidationException::withMessages([
                        'file' => __('The theme archive contains an unreadable file.'),
                    ]);
                }

                $files[$path] = $contents;
            }
        } finally {
            $zip->close();
        }

        $files = $this->stripRootDirectory($files);
        $manifestPath = array_key_exists('theme.json', $files)
            ? 'theme.json'
            : (array_key_exists('manifest.json', $files) ? 'manifest.json' : null);

        if ($manifestPath === null) {
            throw ValidationException::withMessages([
                'file' => __('The theme archive must contain a theme.json manifest.'),
            ]);
        }

        $manifest = json_decode($files[$manifestPath], true);

        if (! is_array($manifest) || array_is_list($manifest)) {
            throw ValidationException::withMessages([
                'file' => __('The theme archive manifest must be a valid JSON object.'),
            ]);
        }

        return [$files, $manifest];
    }

    private function normalizePath(string $name): ?string
    {
        $path = str_replace('\\', '/', $name);

        if ($path === '' || str_ends_with($path, '/')) {
            return null;
        }

        if (str_starts_with($path, '/') || str_starts_with($path, '__MACOSX/')) {
            throw ValidationException::withMessages([
                'file' => __('The theme archive contains an invalid file path.'),
            ]);
        }

        $segments = explode('/', $path);

        foreach ($segments as $segment) {
            if ($segment === '' || $segment === '.' || $segment === '..') {
                throw ValidationException::withMessages([
                    'file' => __('The theme archive contains an invalid file path.'),
                ]);
            }
        }

        if (end($segments) === '.DS_Store') {
            return null;
        }

        if (strlen($path) > 255) {
            throw ValidationException::withMessages([
                'file' => __('The theme archive contains a file path that is too long.'),
            ]);
        }

        return $path;
    }

    /**
     * @param  array<string, string>  $files
     * @return array<string, string>
     */
    private function stripRootDirectory(array $files): array
    {
        if ($files === []) {
            return $files;
        }

        $paths = array_keys($files);

        if (! collect($paths)->every(fn (string $path): bool => str_contains($path, '/'))) {
            return $files;
        }

        $root = explode('/', $paths[0], 2)[0];

        if (! collect($paths)->every(fn (string $path): bool => str_starts_with($path, "{$root}/"))) {
            return $files;
        }

        return collect($files)
            ->mapWithKeys(fn (string $contents, string $path): array => [substr($path, strlen($root) + 1) => $contents])
            ->all();
    }

    /**
     * @param  array<string, string>  $files
     */
    private function validateStructure(array $files): void
    {
        $missing = array_values(array_diff(self::REQUIRED_PATHS, array_keys($files)));

        if ($missing !== []) {
            throw ValidationException::withMessages([
                'file' => __('The theme archive is missing required file: :path', ['path' => $missing[0]]),
            ]);
        }
    }
}


Output:
Chunk ID: 2c6208
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1907
Output:
<?php

namespace App\Services;

use App\Enums\ThemeStatus;
use App\Models\Store;
use App\Models\Theme;
use App\Models\ThemeFile;
use App\Models\ThemeSettings;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use ZipArchive;

class ThemeArchiveInstaller
{
    /**
     * @var list<string>
     */
    private const REQUIRED_PATHS = [
        'layouts/storefront.blade.php',
        'sections/hero.blade.php',
        'sections/featured-products.blade.php',
    ];

    public function __construct(private ThemeSettingsService $settings) {}

    public function install(Store $store, UploadedFile $archive, ?string $name = null): Theme
    {
        [$files, $manifest] = $this->readArchive($archive);

        $this->validateStructure($files);

        $themeName = trim((string) ($name ?: data_get($manifest, 'name', '')));

        if ($themeName === '') {
            throw ValidationException::withMessages([
                'name' => __('The theme archive manifest must include a name.'),
            ]);
        }

        $version = trim((string) data_get($manifest, 'version', '1.0.0'));
        $settings = data_get($manifest, 'settings_json', data_get($manifest, 'settings'));

        if (mb_strlen($themeName) > 255) {
            throw ValidationException::withMessages([
                'name' => __('The theme name may not be greater than 255 characters.'),
            ]);
        }

        if (mb_strlen($version) > 255) {
            throw ValidationException::withMessages([
                'file' => __('The theme archive manifest version may not be greater than 255 characters.'),
            ]);
        }

        return DB::transaction(function () use ($files, $settings, $store, $themeName, $version): Theme {
            $theme = Theme::withoutGlobalScopes()->create([
                'store_id' => $store->getKey(),
                'name' => $themeName,
                'version' => $version !== '' ? $version : null,
                'status' => ThemeStatus::Draft,
                'published_at' => null,
            ]);

            foreach ($files as $path => $contents) {
                $storageKey = "themes/{$theme->getKey()}/{$path}";

                Storage::disk('local')->put($storageKey, $contents);

                ThemeFile::withoutGlobalScopes()->create([
                    'theme_id' => $theme->getKey(),
                    'path' => $path,
                    'storage_key' => $storageKey,
                    'sha256' => hash('sha256', $contents),
                    'byte_size' => strlen($contents),
                ]);
            }

            ThemeSettings::withoutGlobalScopes()->create([
                'theme_id' => $theme->getKey(),
                'settings_json' => is_array($settings) && ! array_is_list($settings)
                    ? $settings
                    : $this->settings->defaultsForStore($store),
                'updated_at' => now(),
            ]);

            return $theme->load('settings')->loadCount('files');
        });
    }

    /**
     * @return list<string>
     */
    public static function requiredPaths(): array
    {
        return self::REQUIRED_PATHS;
    }

    /**
     * @return array{0: array<string, string>, 1: array<string, mixed>}
     */
    private function readArchive(UploadedFile $archive): array
    {
        $zip = new ZipArchive;
        $realPath = $archive->getRealPath();

        if (! is_string($realPath) || $zip->open($realPath) !== true) {
            throw ValidationException::withMessages([
                'file' => __('The theme archive could not be opened.'),
            ]);
        }

        $files = [];

        try {
            for ($index = 0; $index < $zip->numFiles; $index++) {
                $name = $zip->getNameIndex($index);

                if (! is_string($name)) {
                    continue;
                }

                $path = $this->normalizePath($name);

                if ($path === null) {
                    continue;
                }

                $contents = $zip->getFromIndex($index);

                if (! is_string($contents)) {
                    throw ValidationException::withMessages([
                        'file' => __('The theme archive contains an unreadable file.'),
                    ]);
                }

                $files[$path] = $contents;
            }
        } finally {
            $zip->close();
        }

        $files = $this->stripRootDirectory($files);
        $manifestPath = array_key_exists('theme.json', $files)
            ? 'theme.json'
            : (array_key_exists('manifest.json', $files) ? 'manifest.json' : null);

        if ($manifestPath === null) {
            throw ValidationException::withMessages([
                'file' => __('The theme archive must contain a theme.json manifest.'),
            ]);
        }

        $manifest = json_decode($files[$manifestPath], true);

        if (! is_array($manifest) || array_is_list($manifest)) {
            throw ValidationException::withMessages([
                'file' => __('The theme archive manifest must be a valid JSON object.'),
            ]);
        }

        return [$files, $manifest];
    }

    private function normalizePath(string $name): ?string
    {
        $path = str_replace('\\', '/', $name);

        if ($path === '' || str_ends_with($path, '/')) {
            return null;
        }

        if (str_starts_with($path, '/') || str_starts_with($path, '__MACOSX/')) {
            throw ValidationException::withMessages([
                'file' => __('The theme archive contains an invalid file path.'),
            ]);
        }

        $segments = explode('/', $path);

        foreach ($segments as $segment) {
            if ($segment === '' || $segment === '.' || $segment === '..') {
                throw ValidationException::withMessages([
                    'file' => __('The theme archive contains an invalid file path.'),
                ]);
            }
        }

        if (end($segments) === '.DS_Store') {
            return null;
        }

        if (strlen($path) > 255) {
            throw ValidationException::withMessages([
                'file' => __('The theme archive contains a file path that is too long.'),
            ]);
        }

        return $path;
    }

    /**
     * @param  array<string, string>  $files
     * @return array<string, string>
     */
    private function stripRootDirectory(array $files): array
    {
        if ($files === []) {
            return $files;
        }

        $paths = array_keys($files);

        if (! collect($paths)->every(fn (string $path): bool => str_contains($path, '/'))) {
            return $files;
        }

        $root = explode('/', $paths[0], 2)[0];

        if (! collect($paths)->every(fn (string $path): bool => str_starts_with($path, "{$root}/"))) {
            return $files;
        }

        return collect($files)
            ->mapWithKeys(fn (string $contents, string $path): array => [substr($path, strlen($root) + 1) => $contents])
            ->all();
    }

    /**
     * @param  array<string, string>  $files
     */
    private function validateStructure(array $files): void
    {
        $missing = array_values(array_diff(self::REQUIRED_PATHS, array_keys($files)));

        if ($missing !== []) {
            throw ValidationException::withMessages([
                'file' => __('The theme archive is missing required file: :path', ['path' => $missing[0]]),
            ]);
        }
    }
}


Output:
Chunk ID: 8a9350
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 760
Output:
<?php

namespace App\Http\Controllers\Api\Admin\V1;

use App\Enums\ThemeStatus;
use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\ThemeResource;
use App\Models\Store;
use App\Models\Theme;
use App\Services\ThemeArchiveInstaller;
use App\Services\ThemeSettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

class ThemeController extends Controller
{
    public function store(Request $request, Store $store, ThemeArchiveInstaller $installer): JsonResponse
    {
        $this->authorizeStore($request, $store);

        $validated = $request->validate([
            'file' => ['required', 'file', 'mimes:zip', 'max:51200'],
            'name' => ['sometimes', 'nullable', 'string', 'max:255'],
        ]);

        $archive = $request->file('file');

        abort_unless($archive instanceof UploadedFile, 422);

        $theme = $installer->install($store, $archive, $validated['name'] ?? null);

        return ThemeResource::make($theme)
            ->response()
            ->setStatusCode(201);
    }

    public function publish(Request $request, Store $store, Theme $theme, ThemeSettingsService $settings): ThemeResource
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessThemeBelongsToStore($theme, $store);
        $this->validatePublishable($theme);

        DB::transaction(function () use ($store, $theme): void {
            Theme::withoutGlobalScopes()
                ->where('store_id', $store->getKey())
                ->update([
                    'status' => ThemeStatus::Draft,
                    'published_at' => null,
                ]);

            $theme->forceFill([
                'status' => ThemeStatus::Published,
                'published_at' => now(),
            ])->save();
        });

        $settings->forget($store);

        return ThemeResource::make($theme->refresh()->load('settings')->loadCount('files'));
    }

    private function authorizeStore(Request $request, Store $store): void
    {
        if (! $request->attributes->has('admin_api_oauth_token')) {
            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
        }

        app()->instance('current_store', $store);
    }

    private function abortUnlessThemeBelongsToStore(Theme $theme, Store $store): void
    {
        abort_unless((int) $theme->store_id === $store->getKey(), 404);
    }

    private function validatePublishable(Theme $theme): void
    {
        $paths = $theme->files()
            ->withoutGlobalScopes()
            ->pluck('path')
            ->all();
        $missing = array_values(array_diff(ThemeArchiveInstaller::requiredPaths(), $paths));

        if ($missing !== []) {
            throw ValidationException::withMessages([
                'theme' => __('The theme is missing required file: :path', ['path' => $missing[0]]),
            ]);
        }
    }
}


sed -n '1,220p' app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php

<?php

namespace App\Http\Controllers\Api\Admin\V1;

use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\ThemeResource;
use App\Models\Store;
use App\Models\Theme;
use App\Models\ThemeSettings;
use App\Services\ThemeSettingsService;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;

class ThemeSettingsController extends Controller
{
    public function update(Request $request, Store $store, Theme $theme, ThemeSettingsService $settings): ThemeResource
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessThemeBelongsToStore($theme, $store);

        $validated = $request->validate([
            'settings_json' => ['required', 'array'],
        ]);

        if (array_is_list($validated['settings_json'])) {
            throw ValidationException::withMessages([
                'settings_json' => __('The settings json field must be an object.'),
            ]);
        }

        ThemeSettings::withoutGlobalScopes()->updateOrCreate(
            ['theme_id' => $theme->getKey()],
            [
                'settings_json' => $validated['settings_json'],
                'updated_at' => now(),
            ],
        );

        $settings->forget($store);

        return ThemeResource::make($theme->refresh()->load('settings')->loadCount('files'));
    }

    private function authorizeStore(Request $request, Store $store): void
    {
        if (! $request->attributes->has('admin_api_oauth_token')) {
            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
        }

        app()->instance('current_store', $store);
    }

    private function abortUnlessThemeBelongsToStore(Theme $theme, Store $store): void
    {
        abort_unless((int) $theme->store_id === $store->getKey(), 404);
    }
}


Output:
Chunk ID: dc6364
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 456
Output:
<?php

namespace App\Http\Controllers\Api\Admin\V1;

use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\ThemeResource;
use App\Models\Store;
use App\Models\Theme;
use App\Models\ThemeSettings;
use App\Services\ThemeSettingsService;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;

class ThemeSettingsController extends Controller
{
    public function update(Request $request, Store $store, Theme $theme, ThemeSettingsService $settings): ThemeResource
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessThemeBelongsToStore($theme, $store);

        $validated = $request->validate([
            'settings_json' => ['required', 'array'],
        ]);

        if (array_is_list($validated['settings_json'])) {
            throw ValidationException::withMessages([
                'settings_json' => __('The settings json field must be an object.'),
            ]);
        }

        ThemeSettings::withoutGlobalScopes()->updateOrCreate(
            ['theme_id' => $theme->getKey()],
            [
                'settings_json' => $validated['settings_json'],
                'updated_at' => now(),
            ],
        );

        $settings->forget($store);

        return ThemeResource::make($theme->refresh()->load('settings')->loadCount('files'));
    }

    private function authorizeStore(Request $request, Store $store): void
    {
        if (! $request->attributes->has('admin_api_oauth_token')) {
            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
        }

        app()->instance('current_store', $store);
    }

    private function abortUnlessThemeBelongsToStore(Theme $theme, Store $store): void
    {
        abort_unless((int) $theme->store_id === $store->getKey(), 404);
    }
}