From 80749a62a2abdc3b2f16ce3484f47c727f364de8 Mon Sep 17 00:00:00 2001 From: sak0a Date: Sat, 7 Feb 2026 08:39:43 +0100 Subject: [PATCH 01/16] Add GitHub repository configuration and register DevPullCommand - Introduced 'repository' configuration in notur.php for GitHub source code. - Registered DevPullCommand in NoturServiceProvider for enhanced development workflow. --- config/notur.php | 11 + src/Console/Commands/DevPullCommand.php | 395 ++++++++++++++++++++++++ src/NoturServiceProvider.php | 2 + 3 files changed, 408 insertions(+) create mode 100644 src/Console/Commands/DevPullCommand.php diff --git a/config/notur.php b/config/notur.php index 14773434..91681ca1 100644 --- a/config/notur.php +++ b/config/notur.php @@ -30,6 +30,17 @@ */ 'require_signatures' => false, + /* + |-------------------------------------------------------------------------- + | GitHub Repository + |-------------------------------------------------------------------------- + | + | The GitHub owner/repo for the Notur framework source code. + | Used by notur:dev:pull to download unreleased commits. + | + */ + 'repository' => 'sak0a/notur', + /* |-------------------------------------------------------------------------- | Registry URL diff --git a/src/Console/Commands/DevPullCommand.php b/src/Console/Commands/DevPullCommand.php new file mode 100644 index 00000000..29e79614 --- /dev/null +++ b/src/Console/Commands/DevPullCommand.php @@ -0,0 +1,395 @@ +argument('branch'); + $commit = $this->argument('commit'); + $ref = $commit ?? $branch; + $isDryRun = (bool) $this->option('dry-run'); + $noRebuild = (bool) $this->option('no-rebuild'); + + $repo = config('notur.repository', self::DEFAULT_REPO); + $noturRoot = base_path('vendor/notur/notur'); + + $client = new Client([ + 'timeout' => 30, + 'connect_timeout' => 10, + 'headers' => [ + 'Accept' => 'application/vnd.github.v3+json', + 'User-Agent' => 'Notur-DevPull/1.0', + ], + ]); + + // Step 1: Fetch commit info + $this->info("Fetching commit info for '{$ref}' from {$repo}..."); + + try { + $commitInfo = $this->fetchCommitInfo($client, $repo, $ref); + } catch (\Throwable $e) { + $this->error("Failed to fetch commit info: {$e->getMessage()}"); + return 1; + } + + $sha = $commitInfo['sha']; + $shortSha = substr($sha, 0, 8); + $message = $commitInfo['commit']['message'] ?? 'No message'; + $author = $commitInfo['commit']['author']['name'] ?? 'Unknown'; + $date = $commitInfo['commit']['author']['date'] ?? 'Unknown'; + $firstLine = strtok($message, "\n"); + + $this->newLine(); + $this->info(" Branch: {$branch}"); + $this->info(" Commit: {$shortSha}"); + $this->info(" Author: {$author}"); + $this->info(" Date: {$date}"); + $this->info(" Message: {$firstLine}"); + $this->newLine(); + + // Step 2: Warn and confirm + $this->warn('This will replace files in vendor/notur/notur/ which is normally managed by Composer.'); + $this->warn('Running "composer update notur/notur" later will overwrite these changes.'); + + if ($isDryRun) { + $this->newLine(); + $this->info("[DRY RUN] Would download and extract commit {$shortSha} to {$noturRoot}"); + if (!$noRebuild) { + $this->info('[DRY RUN] Would rebuild frontend bridge'); + $this->info('[DRY RUN] Would copy bridge.js and tailwind.css to public/notur/'); + } + return 0; + } + + if (!$this->confirm("Pull commit {$shortSha} into vendor/notur/notur?")) { + $this->info('Aborted.'); + return 0; + } + + // Step 3: Download the zip archive + $tmpZip = sys_get_temp_dir() . '/notur-dev-pull-' . uniqid() . '.zip'; + $this->info("Downloading commit {$shortSha}..."); + + try { + $this->downloadArchive($client, $repo, $sha, $tmpZip); + } catch (\Throwable $e) { + $this->error("Download failed: {$e->getMessage()}"); + @unlink($tmpZip); + return 1; + } + + $this->info('Download complete.'); + + // Step 4: Extract to temp directory + $tmpDir = sys_get_temp_dir() . '/notur-dev-pull-extract-' . uniqid(); + + try { + $innerDir = $this->extractArchive($tmpZip, $tmpDir); + } catch (\Throwable $e) { + $this->error("Extraction failed: {$e->getMessage()}"); + @unlink($tmpZip); + return 1; + } + + @unlink($tmpZip); + + // Step 5: Replace vendor files + $this->info('Replacing vendor/notur/notur/...'); + + try { + $this->replaceVendorFiles($noturRoot, $innerDir); + } catch (\Throwable $e) { + $this->error("Failed to replace files: {$e->getMessage()}"); + $this->deleteDirectory($tmpDir); + return 1; + } + + $this->deleteDirectory($tmpDir); + $this->info('Files replaced successfully.'); + + // Step 6: Rebuild frontend + if (!$noRebuild) { + $this->newLine(); + $this->info('Rebuilding frontend...'); + + $packageManager = $this->resolvePackageManager($noturRoot); + + $result = $this->runProcess("{$packageManager} install", $noturRoot); + if ($result !== 0) { + $this->warn('Dependency install failed. You may need to run it manually.'); + } + + $result = $this->runProcess("{$packageManager} run build:bridge", $noturRoot); + if ($result !== 0) { + $this->warn('Bridge build failed. You may need to run it manually.'); + } + + $result = $this->runProcess("{$packageManager} run build:tailwind", $noturRoot); + if ($result !== 0) { + $this->warn('Tailwind build failed. You may need to run it manually.'); + } + + // Step 7: Copy built assets to public + $bridgeSource = $noturRoot . '/bridge/dist/bridge.js'; + $bridgeTarget = ExtensionPath::bridgeJs(); + + if (file_exists($bridgeSource) && !is_link($bridgeTarget)) { + $bridgeDir = dirname($bridgeTarget); + if (!is_dir($bridgeDir)) { + mkdir($bridgeDir, 0755, true); + } + copy($bridgeSource, $bridgeTarget); + $this->info('Copied bridge.js to public/notur/'); + } elseif (is_link($bridgeTarget)) { + $this->info('bridge.js is symlinked (dev mode) — skipping copy.'); + } + + $tailwindSource = $noturRoot . '/bridge/dist/tailwind.css'; + $tailwindTarget = ExtensionPath::tailwindCss(); + + if (file_exists($tailwindSource) && !is_link($tailwindTarget)) { + $tailwindDir = dirname($tailwindTarget); + if (!is_dir($tailwindDir)) { + mkdir($tailwindDir, 0755, true); + } + copy($tailwindSource, $tailwindTarget); + $this->info('Copied tailwind.css to public/notur/'); + } elseif (is_link($tailwindTarget)) { + $this->info('tailwind.css is symlinked (dev mode) — skipping copy.'); + } + } + + // Step 8: Clear caches + $this->call('cache:clear'); + $this->call('view:clear'); + + $this->newLine(); + $this->info("Notur framework updated to commit {$shortSha} ({$firstLine})"); + $this->info("Run 'composer update notur/notur' to revert to the published release."); + + return 0; + } + + private function fetchCommitInfo(Client $client, string $repo, string $ref): array + { + $url = self::GITHUB_API_BASE . "/repos/{$repo}/commits/{$ref}"; + + try { + $response = $client->get($url); + } catch (GuzzleException $e) { + throw new \RuntimeException( + "GitHub API request failed: {$e->getMessage()}", + (int) $e->getCode(), + $e, + ); + } + + $body = json_decode($response->getBody()->getContents(), true); + if (!is_array($body) || !isset($body['sha'])) { + throw new \RuntimeException('Unexpected response from GitHub API'); + } + + return $body; + } + + private function downloadArchive(Client $client, string $repo, string $sha, string $targetPath): void + { + $url = self::GITHUB_API_BASE . "/repos/{$repo}/zipball/{$sha}"; + + try { + $client->get($url, [ + 'sink' => $targetPath, + 'timeout' => 120, + 'connect_timeout' => 10, + ]); + } catch (GuzzleException $e) { + throw new \RuntimeException( + "Failed to download archive: {$e->getMessage()}", + (int) $e->getCode(), + $e, + ); + } + } + + private function extractArchive(string $zipPath, string $targetDir): string + { + if (!is_dir($targetDir)) { + mkdir($targetDir, 0755, true); + } + + $zip = new \ZipArchive(); + $result = $zip->open($zipPath); + + if ($result !== true) { + throw new \RuntimeException("Failed to open zip archive (error code: {$result})"); + } + + $zip->extractTo($targetDir); + $zip->close(); + + // GitHub zipball contains a single top-level directory: {owner}-{repo}-{shortsha}/ + $entries = array_values(array_filter( + scandir($targetDir), + fn ($e) => $e !== '.' && $e !== '..' && is_dir($targetDir . '/' . $e), + )); + + if (count($entries) !== 1) { + throw new \RuntimeException('Unexpected archive structure: expected exactly one top-level directory'); + } + + return $targetDir . '/' . $entries[0]; + } + + private function replaceVendorFiles(string $noturRoot, string $sourcePath): void + { + // Preserve vendor/ and node_modules/ if they exist (heavy install artifacts) + $preserveDirs = ['vendor', 'node_modules']; + $tmpPreserve = sys_get_temp_dir() . '/notur-preserve-' . uniqid(); + + foreach ($preserveDirs as $dir) { + $dirPath = $noturRoot . '/' . $dir; + if (is_dir($dirPath)) { + if (!is_dir($tmpPreserve)) { + mkdir($tmpPreserve, 0755, true); + } + rename($dirPath, $tmpPreserve . '/' . $dir); + } + } + + // Delete old vendor/notur/notur/ contents + if (is_dir($noturRoot)) { + $this->deleteDirectory($noturRoot); + } + + // Copy new files + $this->copyDirectory($sourcePath, $noturRoot); + + // Restore preserved directories + foreach ($preserveDirs as $dir) { + $tmpSource = $tmpPreserve . '/' . $dir; + if (is_dir($tmpSource)) { + $destPath = $noturRoot . '/' . $dir; + if (is_dir($destPath)) { + $this->deleteDirectory($destPath); + } + rename($tmpSource, $destPath); + } + } + + // Clean up preserve temp + if (is_dir($tmpPreserve)) { + $this->deleteDirectory($tmpPreserve); + } + } + + private function resolvePackageManager(string $cwd): string + { + if (file_exists($cwd . '/bun.lockb') || file_exists($cwd . '/bun.lock')) { + return 'bun'; + } + if (file_exists($cwd . '/pnpm-lock.yaml')) { + return 'pnpm'; + } + if (file_exists($cwd . '/yarn.lock')) { + return 'yarn'; + } + + return 'bun'; + } + + private function runProcess(string $command, string $cwd): int + { + $process = proc_open( + $command, + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + $cwd, + ); + + if (!is_resource($process)) { + return 1; + } + + $output = stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + + fclose($pipes[1]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + if ($output) { + $this->line($output); + } + if ($errors && $exitCode !== 0) { + $this->error($errors); + } + + return $exitCode; + } + + private function copyDirectory(string $source, string $dest): void + { + if (!is_dir($dest)) { + mkdir($dest, 0755, true); + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($source, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST, + ); + + foreach ($iterator as $item) { + $target = $dest . '/' . $iterator->getSubPathname(); + if ($item->isDir()) { + if (!is_dir($target)) { + mkdir($target, 0755, true); + } + } else { + copy($item->getPathname(), $target); + } + } + } + + private function deleteDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $item) { + if ($item->isDir()) { + rmdir($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + + rmdir($dir); + } +} diff --git a/src/NoturServiceProvider.php b/src/NoturServiceProvider.php index c70df3d6..54b3450b 100644 --- a/src/NoturServiceProvider.php +++ b/src/NoturServiceProvider.php @@ -10,6 +10,7 @@ use Notur\Features\FeatureRegistry; use Notur\Console\Commands\BuildCommand; use Notur\Console\Commands\DevCommand; +use Notur\Console\Commands\DevPullCommand; use Notur\Console\Commands\DisableCommand; use Notur\Console\Commands\EnableCommand; use Notur\Console\Commands\ExportCommand; @@ -92,6 +93,7 @@ public function boot(): void ListCommand::class, UpdateCommand::class, DevCommand::class, + DevPullCommand::class, BuildCommand::class, ExportCommand::class, KeygenCommand::class, From cf01adb5a75dda71dfbf8c2a654ba53234eeb715 Mon Sep 17 00:00:00 2001 From: sak0a Date: Sat, 7 Feb 2026 17:44:09 +0100 Subject: [PATCH 02/16] Update version to 1.2.4 and add `notur:dev:pull` command for GitHub integration - Bumped framework version to 1.2.4 in configuration. - Introduced `notur:dev:pull` command to facilitate pulling updates from GitHub, including options for specific branches and dry-run functionality. - Added `repository` configuration key for GitHub source management. - Updated documentation to reflect new command and configuration changes. --- CLAUDE.md | 2 +- config/notur.php | 2 +- website/docs/admin/guide.md | 44 +++++++++++++++++++++++++++++ website/docs/reference/changelog.md | 16 +++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index af4cc80f..6272f103 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ npm run test:frontend # Jest tests **Database tables:** `notur_extensions`, `notur_migrations`, `notur_settings`, `notur_activity_logs` (migrations in `database/migrations/`). -**Artisan commands** (16 total in `src/Console/Commands/`): `notur:install`, `notur:remove`, `notur:enable`, `notur:disable`, `notur:list`, `notur:update`, `notur:dev`, `notur:build`, `notur:export`, `notur:registry:sync`, `notur:uninstall`, `notur:new`, `notur:validate`, `notur:status`, `notur:keygen`, `notur:registry:status`. +**Artisan commands** (17 total in `src/Console/Commands/`): `notur:install`, `notur:remove`, `notur:enable`, `notur:disable`, `notur:list`, `notur:update`, `notur:dev`, `notur:dev:pull`, `notur:build`, `notur:export`, `notur:registry:sync`, `notur:uninstall`, `notur:new`, `notur:validate`, `notur:status`, `notur:keygen`, `notur:registry:status`. ### Frontend Bridge (`bridge/src/`) diff --git a/config/notur.php b/config/notur.php index 91681ca1..bf4b4ad5 100644 --- a/config/notur.php +++ b/config/notur.php @@ -7,7 +7,7 @@ | Notur Version |-------------------------------------------------------------------------- */ - 'version' => '1.2.3', + 'version' => '1.2.4', /* |-------------------------------------------------------------------------- diff --git a/website/docs/admin/guide.md b/website/docs/admin/guide.md index cc828edf..9bb3ce89 100644 --- a/website/docs/admin/guide.md +++ b/website/docs/admin/guide.md @@ -230,6 +230,42 @@ php artisan notur:dev /path/to/my-extension --watch php artisan notur:dev /path/to/my-extension --watch --watch-bridge ``` +### Pulling Framework Updates from GitHub + +For developers working on the Notur framework itself, `notur:dev:pull` lets you quickly test unreleased commits without waiting for a new Composer release. It downloads the specified branch or commit from GitHub, replaces the files in `vendor/notur/notur/`, and rebuilds the frontend bridge automatically. + +```bash +# Pull the latest commit from master (default) +php artisan notur:dev:pull + +# Pull from a specific branch +php artisan notur:dev:pull develop + +# Pull a specific commit +php artisan notur:dev:pull master abc1234f + +# Preview what would happen without making changes +php artisan notur:dev:pull --dry-run + +# Pull without rebuilding the frontend bridge +php artisan notur:dev:pull --no-rebuild +``` + +The command will: +1. Fetch commit info from GitHub (SHA, author, date, message) +2. Ask for confirmation before proceeding +3. Download the zip archive of the exact commit +4. Replace files in `vendor/notur/notur/` (preserving `vendor/` and `node_modules/`) +5. Rebuild the bridge runtime and Tailwind CSS (unless `--no-rebuild`) +6. Copy `bridge.js` and `tailwind.css` to `public/notur/` +7. Clear caches + +::: warning +This modifies `vendor/notur/notur/` which is normally managed by Composer. Running `composer update notur/notur` will overwrite these changes and revert to the published release. +::: + +The GitHub repository is configured via the `notur.repository` config key (defaults to `sak0a/notur`). See [Configuration](#configuration) below. + ### Scaffolding New Extensions ```bash @@ -360,6 +396,14 @@ The directory where extensions are stored, relative to the panel root. Change th When `true`, only extensions with valid Ed25519 signatures can be installed. Set to `true` for production environments where you want to ensure extension integrity. Set to `false` for development or trusted environments. +### `repository` + +```php +'repository' => 'sak0a/notur', +``` + +The GitHub `owner/repo` for the Notur framework source code. Used by `notur:dev:pull` to download unreleased commits. Change this if you are working with a fork of the framework. + ### `registry_url` ```php diff --git a/website/docs/reference/changelog.md b/website/docs/reference/changelog.md index f4103ce6..e8168b72 100644 --- a/website/docs/reference/changelog.md +++ b/website/docs/reference/changelog.md @@ -2,6 +2,22 @@ All notable changes to the Notur Extension Library are documented here. +## [1.2.4] - 2026-02-07 + +### Added +- **`notur:dev:pull` command** -- Pull the latest Notur framework code directly from GitHub for development. Downloads a specific branch (default `master`) or commit via the GitHub API, replaces `vendor/notur/notur/`, and automatically rebuilds the frontend bridge. Supports `--no-rebuild` and `--dry-run` flags. +- **`repository` config key** -- New `notur.repository` setting in `config/notur.php` (defaults to `sak0a/notur`). Used by `notur:dev:pull` to resolve the GitHub repository. + +### Changed +- **Framework version bump to 1.2.4** -- Artisan command count increased from 16 to 17. +- **SDK unchanged** -- SDK package version remains unchanged for this release. + +## [1.2.3] - 2026-02-07 + +### Changed +- **Framework version bump to 1.2.3** -- Documentation and framework version references updated for the extension framework release. +- **SDK unchanged** -- SDK package version remains unchanged for this release. + ## [1.2.2] - 2026-02-07 ### Changed From 07a5b54fe66886c82bf05327372c1513c7b0ffbf Mon Sep 17 00:00:00 2001 From: saka Date: Sat, 7 Feb 2026 18:04:02 +0100 Subject: [PATCH 03/16] Fix package manager detection for npm --- src/Console/Commands/DevPullCommand.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Console/Commands/DevPullCommand.php b/src/Console/Commands/DevPullCommand.php index 29e79614..370585d8 100644 --- a/src/Console/Commands/DevPullCommand.php +++ b/src/Console/Commands/DevPullCommand.php @@ -313,8 +313,11 @@ private function resolvePackageManager(string $cwd): string if (file_exists($cwd . '/yarn.lock')) { return 'yarn'; } + if (file_exists($cwd . '/package-lock.json')) { + return 'npm'; + } - return 'bun'; + return 'npm'; } private function runProcess(string $command, string $cwd): int From 2dc875a04b9420c15fc6b1a3661e20bcf9883ad2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:07:03 +0000 Subject: [PATCH 04/16] Initial plan From 561ddc95587f4dc7626a7dafeb85e13a96e812b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:07:23 +0000 Subject: [PATCH 05/16] Initial plan From 4ff91e440a32807b62fbc42ad7741614e0879b80 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:08:00 +0000 Subject: [PATCH 06/16] Initial plan From 225282ae5b47162f01342d92ff0e4364413b118e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:08:43 +0000 Subject: [PATCH 07/16] Initial plan From c95e10f1d7b69f6743e9a6636120fee39717fa91 Mon Sep 17 00:00:00 2001 From: saka Date: Sat, 7 Feb 2026 18:09:01 +0100 Subject: [PATCH 08/16] Update src/Console/Commands/DevPullCommand.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Console/Commands/DevPullCommand.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Console/Commands/DevPullCommand.php b/src/Console/Commands/DevPullCommand.php index 370585d8..8f130979 100644 --- a/src/Console/Commands/DevPullCommand.php +++ b/src/Console/Commands/DevPullCommand.php @@ -33,6 +33,15 @@ public function handle(): int $repo = config('notur.repository', self::DEFAULT_REPO); $noturRoot = base_path('vendor/notur/notur'); + if (! is_dir($noturRoot)) { + $this->error("Notur installation not found at path: {$noturRoot}. Make sure Notur is installed (e.g. via Composer) before running this command."); + return 1; + } + + if (! is_writable($noturRoot)) { + $this->error("The Notur installation directory is not writable: {$noturRoot}. Please adjust filesystem permissions and try again."); + return 1; + } $client = new Client([ 'timeout' => 30, 'connect_timeout' => 10, From 6dfbe8f3384187d4195ef7c7225be26b7a1518d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:12:37 +0000 Subject: [PATCH 09/16] Add error checking for rename() calls in DevPullCommand Co-authored-by: sak0a <24781653+sak0a@users.noreply.github.com> --- src/Console/Commands/DevPullCommand.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Console/Commands/DevPullCommand.php b/src/Console/Commands/DevPullCommand.php index 370585d8..2907a793 100644 --- a/src/Console/Commands/DevPullCommand.php +++ b/src/Console/Commands/DevPullCommand.php @@ -272,7 +272,12 @@ private function replaceVendorFiles(string $noturRoot, string $sourcePath): void if (!is_dir($tmpPreserve)) { mkdir($tmpPreserve, 0755, true); } - rename($dirPath, $tmpPreserve . '/' . $dir); + if (!rename($dirPath, $tmpPreserve . '/' . $dir)) { + throw new \RuntimeException( + "Failed to preserve directory: {$dirPath}. " . + "Check permissions and ensure the directory is not locked." + ); + } } } @@ -292,7 +297,12 @@ private function replaceVendorFiles(string $noturRoot, string $sourcePath): void if (is_dir($destPath)) { $this->deleteDirectory($destPath); } - rename($tmpSource, $destPath); + if (!rename($tmpSource, $destPath)) { + throw new \RuntimeException( + "Failed to restore directory: {$tmpSource} to {$destPath}. " . + "Check permissions and ensure the directory is not locked." + ); + } } } From c60079dcd74a5f621a020fa68caea0adf867b92b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:12:55 +0000 Subject: [PATCH 10/16] Add integration tests for DevPullCommand with mocked GitHub API Co-authored-by: sak0a <24781653+sak0a@users.noreply.github.com> --- src/Console/Commands/DevPullCommand.php | 10 +- .../Console/DevPullCommandTest.php | 274 ++++++++++++++++++ 2 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/Console/DevPullCommandTest.php diff --git a/src/Console/Commands/DevPullCommand.php b/src/Console/Commands/DevPullCommand.php index 370585d8..1c67a84a 100644 --- a/src/Console/Commands/DevPullCommand.php +++ b/src/Console/Commands/DevPullCommand.php @@ -22,6 +22,14 @@ class DevPullCommand extends Command protected $description = 'Pull the latest Notur framework code from GitHub for development'; + private ?Client $client = null; + + public function __construct(?Client $client = null) + { + parent::__construct(); + $this->client = $client; + } + public function handle(): int { $branch = $this->argument('branch'); @@ -33,7 +41,7 @@ public function handle(): int $repo = config('notur.repository', self::DEFAULT_REPO); $noturRoot = base_path('vendor/notur/notur'); - $client = new Client([ + $client = $this->client ?? new Client([ 'timeout' => 30, 'connect_timeout' => 10, 'headers' => [ diff --git a/tests/Integration/Console/DevPullCommandTest.php b/tests/Integration/Console/DevPullCommandTest.php new file mode 100644 index 00000000..db16cd42 --- /dev/null +++ b/tests/Integration/Console/DevPullCommandTest.php @@ -0,0 +1,274 @@ +set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + $app['config']->set('notur.repository', 'sak0a/notur'); + } + + protected function setUp(): void + { + parent::setUp(); + $this->loadMigrationsFrom(__DIR__ . '/../../../database/migrations'); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function test_dry_run_shows_what_would_be_done_without_making_changes(): void + { + // Mock the GitHub API response for commit info + $mockClient = Mockery::mock(Client::class); + + $mockResponse = new Response(200, [], json_encode([ + 'sha' => 'abc123def456abc123def456abc123def456abc1', + 'commit' => [ + 'message' => 'Test commit message', + 'author' => [ + 'name' => 'Test Author', + 'date' => '2024-01-15T10:30:00Z', + ], + ], + ])); + + $mockClient->shouldReceive('get') + ->once() + ->with('https://api.github.com/repos/sak0a/notur/commits/master') + ->andReturn($mockResponse); + + // Bind the mock client to the service container + $this->app->bind(Client::class, function () use ($mockClient) { + return $mockClient; + }); + + $this->artisan('notur:dev:pull', ['--dry-run' => true]) + ->expectsOutput('[DRY RUN] Would download and extract commit abc123de to ' . base_path('vendor/notur/notur')) + ->expectsOutput('[DRY RUN] Would rebuild frontend bridge') + ->expectsOutput('[DRY RUN] Would copy bridge.js and tailwind.css to public/notur/') + ->assertExitCode(0); + } + + public function test_dry_run_with_no_rebuild_option(): void + { + $mockClient = Mockery::mock(Client::class); + + $mockResponse = new Response(200, [], json_encode([ + 'sha' => 'abc123def456abc123def456abc123def456abc1', + 'commit' => [ + 'message' => 'Test commit message', + 'author' => [ + 'name' => 'Test Author', + 'date' => '2024-01-15T10:30:00Z', + ], + ], + ])); + + $mockClient->shouldReceive('get') + ->once() + ->with('https://api.github.com/repos/sak0a/notur/commits/master') + ->andReturn($mockResponse); + + $this->app->bind(Client::class, function () use ($mockClient) { + return $mockClient; + }); + + $this->artisan('notur:dev:pull', ['--dry-run' => true, '--no-rebuild' => true]) + ->expectsOutput('[DRY RUN] Would download and extract commit abc123de to ' . base_path('vendor/notur/notur')) + ->doesntExpectOutput('[DRY RUN] Would rebuild frontend bridge') + ->assertExitCode(0); + } + + public function test_dry_run_with_specific_commit(): void + { + $mockClient = Mockery::mock(Client::class); + + $mockResponse = new Response(200, [], json_encode([ + 'sha' => 'specific123commit456specific123commit456spe', + 'commit' => [ + 'message' => 'Specific commit message', + 'author' => [ + 'name' => 'Test Author', + 'date' => '2024-01-15T10:30:00Z', + ], + ], + ])); + + $mockClient->shouldReceive('get') + ->once() + ->with('https://api.github.com/repos/sak0a/notur/commits/specific123') + ->andReturn($mockResponse); + + $this->app->bind(Client::class, function () use ($mockClient) { + return $mockClient; + }); + + $this->artisan('notur:dev:pull', ['commit' => 'specific123', '--dry-run' => true]) + ->expectsOutput('[DRY RUN] Would download and extract commit specific to ' . base_path('vendor/notur/notur')) + ->assertExitCode(0); + } + + public function test_handles_invalid_ref_error(): void + { + $mockClient = Mockery::mock(Client::class); + + $request = new Request('GET', 'https://api.github.com/repos/sak0a/notur/commits/invalid-ref'); + $exception = new RequestException( + 'Not Found', + $request, + new Response(404, [], '{"message":"Not Found"}') + ); + + $mockClient->shouldReceive('get') + ->once() + ->with('https://api.github.com/repos/sak0a/notur/commits/invalid-ref') + ->andThrow($exception); + + $this->app->bind(Client::class, function () use ($mockClient) { + return $mockClient; + }); + + $this->artisan('notur:dev:pull', ['branch' => 'invalid-ref', '--dry-run' => true]) + ->expectsOutputToContain('Failed to fetch commit info') + ->assertExitCode(1); + } + + public function test_handles_network_error_on_commit_fetch(): void + { + $mockClient = Mockery::mock(Client::class); + + $request = new Request('GET', 'https://api.github.com/repos/sak0a/notur/commits/master'); + $exception = new RequestException( + 'Connection timeout', + $request + ); + + $mockClient->shouldReceive('get') + ->once() + ->with('https://api.github.com/repos/sak0a/notur/commits/master') + ->andThrow($exception); + + $this->app->bind(Client::class, function () use ($mockClient) { + return $mockClient; + }); + + $this->artisan('notur:dev:pull', ['--dry-run' => true]) + ->expectsOutputToContain('Failed to fetch commit info') + ->assertExitCode(1); + } + + public function test_handles_malformed_api_response(): void + { + $mockClient = Mockery::mock(Client::class); + + // Response missing 'sha' field + $mockResponse = new Response(200, [], json_encode([ + 'commit' => [ + 'message' => 'Test commit message', + ], + ])); + + $mockClient->shouldReceive('get') + ->once() + ->with('https://api.github.com/repos/sak0a/notur/commits/master') + ->andReturn($mockResponse); + + $this->app->bind(Client::class, function () use ($mockClient) { + return $mockClient; + }); + + $this->artisan('notur:dev:pull', ['--dry-run' => true]) + ->expectsOutputToContain('Failed to fetch commit info') + ->assertExitCode(1); + } + + public function test_displays_commit_information(): void + { + $mockClient = Mockery::mock(Client::class); + + $mockResponse = new Response(200, [], json_encode([ + 'sha' => 'abc123def456abc123def456abc123def456abc1', + 'commit' => [ + 'message' => "Add new feature\n\nDetailed description here", + 'author' => [ + 'name' => 'Jane Developer', + 'date' => '2024-01-15T10:30:00Z', + ], + ], + ])); + + $mockClient->shouldReceive('get') + ->once() + ->with('https://api.github.com/repos/sak0a/notur/commits/develop') + ->andReturn($mockResponse); + + $this->app->bind(Client::class, function () use ($mockClient) { + return $mockClient; + }); + + $this->artisan('notur:dev:pull', ['branch' => 'develop', '--dry-run' => true]) + ->expectsOutput(' Branch: develop') + ->expectsOutput(' Commit: abc123de') + ->expectsOutput(' Author: Jane Developer') + ->expectsOutput(' Date: 2024-01-15T10:30:00Z') + ->expectsOutput(' Message: Add new feature') + ->assertExitCode(0); + } + + public function test_uses_custom_repository_from_config(): void + { + config(['notur.repository' => 'custom/repo']); + + $mockClient = Mockery::mock(Client::class); + + $mockResponse = new Response(200, [], json_encode([ + 'sha' => 'abc123def456abc123def456abc123def456abc1', + 'commit' => [ + 'message' => 'Test commit', + 'author' => [ + 'name' => 'Test Author', + 'date' => '2024-01-15T10:30:00Z', + ], + ], + ])); + + // Should use custom/repo instead of default + $mockClient->shouldReceive('get') + ->once() + ->with('https://api.github.com/repos/custom/repo/commits/master') + ->andReturn($mockResponse); + + $this->app->bind(Client::class, function () use ($mockClient) { + return $mockClient; + }); + + $this->artisan('notur:dev:pull', ['--dry-run' => true]) + ->assertExitCode(0); + } +} From e05c8f92a6dee3c5d630e649de4beea2272bdc87 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:13:18 +0000 Subject: [PATCH 11/16] Improve error messages to include both source and destination paths Co-authored-by: sak0a <24781653+sak0a@users.noreply.github.com> --- src/Console/Commands/DevPullCommand.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Console/Commands/DevPullCommand.php b/src/Console/Commands/DevPullCommand.php index 2907a793..6b676263 100644 --- a/src/Console/Commands/DevPullCommand.php +++ b/src/Console/Commands/DevPullCommand.php @@ -272,9 +272,10 @@ private function replaceVendorFiles(string $noturRoot, string $sourcePath): void if (!is_dir($tmpPreserve)) { mkdir($tmpPreserve, 0755, true); } - if (!rename($dirPath, $tmpPreserve . '/' . $dir)) { + $tmpDest = $tmpPreserve . '/' . $dir; + if (!rename($dirPath, $tmpDest)) { throw new \RuntimeException( - "Failed to preserve directory: {$dirPath}. " . + "Failed to preserve directory from {$dirPath} to {$tmpDest}. " . "Check permissions and ensure the directory is not locked." ); } From 69580c2bd23d738e14cc622426afc04bc01f593d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:13:44 +0000 Subject: [PATCH 12/16] Make error messages consistent between preserve and restore operations Co-authored-by: sak0a <24781653+sak0a@users.noreply.github.com> --- src/Console/Commands/DevPullCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Console/Commands/DevPullCommand.php b/src/Console/Commands/DevPullCommand.php index 6b676263..84e1620b 100644 --- a/src/Console/Commands/DevPullCommand.php +++ b/src/Console/Commands/DevPullCommand.php @@ -275,7 +275,7 @@ private function replaceVendorFiles(string $noturRoot, string $sourcePath): void $tmpDest = $tmpPreserve . '/' . $dir; if (!rename($dirPath, $tmpDest)) { throw new \RuntimeException( - "Failed to preserve directory from {$dirPath} to {$tmpDest}. " . + "Failed to preserve directory: {$dirPath} to {$tmpDest}. " . "Check permissions and ensure the directory is not locked." ); } From 3ccdccb0be4b8868925a15a079f7bbfaf7c864a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:13:45 +0000 Subject: [PATCH 13/16] Fix test expectation for specific commit SHA format Co-authored-by: sak0a <24781653+sak0a@users.noreply.github.com> --- tests/Integration/Console/DevPullCommandTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Integration/Console/DevPullCommandTest.php b/tests/Integration/Console/DevPullCommandTest.php index db16cd42..cbf1ec6b 100644 --- a/tests/Integration/Console/DevPullCommandTest.php +++ b/tests/Integration/Console/DevPullCommandTest.php @@ -110,7 +110,7 @@ public function test_dry_run_with_specific_commit(): void $mockClient = Mockery::mock(Client::class); $mockResponse = new Response(200, [], json_encode([ - 'sha' => 'specific123commit456specific123commit456spe', + 'sha' => 'abcd1234567890abcdef1234567890abcdef1234', 'commit' => [ 'message' => 'Specific commit message', 'author' => [ @@ -130,7 +130,7 @@ public function test_dry_run_with_specific_commit(): void }); $this->artisan('notur:dev:pull', ['commit' => 'specific123', '--dry-run' => true]) - ->expectsOutput('[DRY RUN] Would download and extract commit specific to ' . base_path('vendor/notur/notur')) + ->expectsOutput('[DRY RUN] Would download and extract commit abcd1234 to ' . base_path('vendor/notur/notur')) ->assertExitCode(0); } From 1aa052f7429fedd8e9a2ea31dacbce6466bdbf72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:13:45 +0000 Subject: [PATCH 14/16] URL-encode branch names in DevPullCommand to handle slashes Co-authored-by: sak0a <24781653+sak0a@users.noreply.github.com> --- src/Console/Commands/DevPullCommand.php | 3 +- tests/Unit/Console/DevPullCommandTest.php | 195 ++++++++++++++++++++++ 2 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Console/DevPullCommandTest.php diff --git a/src/Console/Commands/DevPullCommand.php b/src/Console/Commands/DevPullCommand.php index 370585d8..887a2ebe 100644 --- a/src/Console/Commands/DevPullCommand.php +++ b/src/Console/Commands/DevPullCommand.php @@ -192,7 +192,8 @@ public function handle(): int private function fetchCommitInfo(Client $client, string $repo, string $ref): array { - $url = self::GITHUB_API_BASE . "/repos/{$repo}/commits/{$ref}"; + $encodedRef = rawurlencode($ref); + $url = self::GITHUB_API_BASE . "/repos/{$repo}/commits/{$encodedRef}"; try { $response = $client->get($url); diff --git a/tests/Unit/Console/DevPullCommandTest.php b/tests/Unit/Console/DevPullCommandTest.php new file mode 100644 index 00000000..9696b2b6 --- /dev/null +++ b/tests/Unit/Console/DevPullCommandTest.php @@ -0,0 +1,195 @@ +set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + $app['config']->set('notur.repository', 'sak0a/notur'); + } + + public function test_url_encodes_branch_names_with_slashes(): void + { + // Create a mock handler to capture HTTP requests + $container = []; + $history = Middleware::history($container); + + $mock = new MockHandler([ + new Response(200, [], json_encode([ + 'sha' => 'abc123def456', + 'commit' => [ + 'message' => 'Test commit', + 'author' => [ + 'name' => 'Test Author', + 'date' => '2026-02-07T12:00:00Z', + ], + ], + ])), + ]); + + $handlerStack = HandlerStack::create($mock); + $handlerStack->push($history); + + // Test with a branch name containing slashes + $client = new Client(['handler' => $handlerStack]); + + $command = new DevPullCommand(); + $reflection = new \ReflectionClass($command); + $method = $reflection->getMethod('fetchCommitInfo'); + $method->setAccessible(true); + + $method->invoke($command, $client, 'sak0a/notur', 'feature/my-branch'); + + // Verify the URL was properly encoded + $this->assertCount(1, $container); + $request = $container[0]['request']; + $uri = (string) $request->getUri(); + + // The branch name should be URL-encoded: feature/my-branch -> feature%2Fmy-branch + $this->assertStringContainsString('/commits/feature%2Fmy-branch', $uri); + $this->assertStringNotContainsString('/commits/feature/my-branch', $uri); + } + + public function test_url_encodes_branch_names_with_special_characters(): void + { + // Create a mock handler + $container = []; + $history = Middleware::history($container); + + $mock = new MockHandler([ + new Response(200, [], json_encode([ + 'sha' => 'xyz789abc', + 'commit' => [ + 'message' => 'Another test', + 'author' => [ + 'name' => 'Test Author', + 'date' => '2026-02-07T12:00:00Z', + ], + ], + ])), + ]); + + $handlerStack = HandlerStack::create($mock); + $handlerStack->push($history); + + $client = new Client(['handler' => $handlerStack]); + + $command = new DevPullCommand(); + $reflection = new \ReflectionClass($command); + $method = $reflection->getMethod('fetchCommitInfo'); + $method->setAccessible(true); + + // Test with a branch name containing spaces and special characters + $method->invoke($command, $client, 'sak0a/notur', 'feature/my branch-v2'); + + $this->assertCount(1, $container); + $request = $container[0]['request']; + $uri = (string) $request->getUri(); + + // The branch name should be URL-encoded + $this->assertStringContainsString('/commits/feature%2Fmy%20branch-v2', $uri); + } + + public function test_handles_simple_branch_names_correctly(): void + { + // Create a mock handler + $container = []; + $history = Middleware::history($container); + + $mock = new MockHandler([ + new Response(200, [], json_encode([ + 'sha' => '123abc456', + 'commit' => [ + 'message' => 'Simple test', + 'author' => [ + 'name' => 'Test Author', + 'date' => '2026-02-07T12:00:00Z', + ], + ], + ])), + ]); + + $handlerStack = HandlerStack::create($mock); + $handlerStack->push($history); + + $client = new Client(['handler' => $handlerStack]); + + $command = new DevPullCommand(); + $reflection = new \ReflectionClass($command); + $method = $reflection->getMethod('fetchCommitInfo'); + $method->setAccessible(true); + + // Test with a simple branch name without special characters + $method->invoke($command, $client, 'sak0a/notur', 'main'); + + $this->assertCount(1, $container); + $request = $container[0]['request']; + $uri = (string) $request->getUri(); + + // Simple branch names should still work + $this->assertStringContainsString('/commits/main', $uri); + } + + public function test_handles_commit_sha(): void + { + // Create a mock handler + $container = []; + $history = Middleware::history($container); + + $mock = new MockHandler([ + new Response(200, [], json_encode([ + 'sha' => 'abc123def456789', + 'commit' => [ + 'message' => 'SHA test', + 'author' => [ + 'name' => 'Test Author', + 'date' => '2026-02-07T12:00:00Z', + ], + ], + ])), + ]); + + $handlerStack = HandlerStack::create($mock); + $handlerStack->push($history); + + $client = new Client(['handler' => $handlerStack]); + + $command = new DevPullCommand(); + $reflection = new \ReflectionClass($command); + $method = $reflection->getMethod('fetchCommitInfo'); + $method->setAccessible(true); + + // Test with a commit SHA (no special characters) + $method->invoke($command, $client, 'sak0a/notur', 'abc123def456789'); + + $this->assertCount(1, $container); + $request = $container[0]['request']; + $uri = (string) $request->getUri(); + + // Commit SHAs should work as expected + $this->assertStringContainsString('/commits/abc123def456789', $uri); + } +} From d03cfebf417b98e1dafc1f36ee034fd367770cc5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:19:42 +0000 Subject: [PATCH 15/16] Add cleanup of temp directory on extraction failure Co-authored-by: sak0a <24781653+sak0a@users.noreply.github.com> --- src/Console/Commands/DevPullCommand.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Console/Commands/DevPullCommand.php b/src/Console/Commands/DevPullCommand.php index 370585d8..a33ff402 100644 --- a/src/Console/Commands/DevPullCommand.php +++ b/src/Console/Commands/DevPullCommand.php @@ -108,6 +108,7 @@ public function handle(): int } catch (\Throwable $e) { $this->error("Extraction failed: {$e->getMessage()}"); @unlink($tmpZip); + $this->deleteDirectory($tmpDir); return 1; } From 63d6ef4cfb09307c958dca6e73fd9f0351af62b0 Mon Sep 17 00:00:00 2001 From: sak0a Date: Sat, 7 Feb 2026 18:28:47 +0100 Subject: [PATCH 16/16] Refactor DevPullCommand to improve client resolution and error handling - Removed the constructor dependency for the HTTP client and added a private method `resolveClient()` to handle client instantiation. - Moved validation for the Notur installation directory to occur before making changes, ensuring better error handling for directory existence and writability. --- src/Console/Commands/DevPullCommand.php | 54 +++++++++++++------------ 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/src/Console/Commands/DevPullCommand.php b/src/Console/Commands/DevPullCommand.php index dd0fbf87..024b99ca 100644 --- a/src/Console/Commands/DevPullCommand.php +++ b/src/Console/Commands/DevPullCommand.php @@ -22,14 +22,6 @@ class DevPullCommand extends Command protected $description = 'Pull the latest Notur framework code from GitHub for development'; - private ?Client $client = null; - - public function __construct(?Client $client = null) - { - parent::__construct(); - $this->client = $client; - } - public function handle(): int { $branch = $this->argument('branch'); @@ -41,23 +33,7 @@ public function handle(): int $repo = config('notur.repository', self::DEFAULT_REPO); $noturRoot = base_path('vendor/notur/notur'); - if (! is_dir($noturRoot)) { - $this->error("Notur installation not found at path: {$noturRoot}. Make sure Notur is installed (e.g. via Composer) before running this command."); - return 1; - } - - if (! is_writable($noturRoot)) { - $this->error("The Notur installation directory is not writable: {$noturRoot}. Please adjust filesystem permissions and try again."); - return 1; - } - $client = $this->client ?? new Client([ - 'timeout' => 30, - 'connect_timeout' => 10, - 'headers' => [ - 'Accept' => 'application/vnd.github.v3+json', - 'User-Agent' => 'Notur-DevPull/1.0', - ], - ]); + $client = $this->resolveClient(); // Step 1: Fetch commit info $this->info("Fetching commit info for '{$ref}' from {$repo}..."); @@ -98,6 +74,17 @@ public function handle(): int return 0; } + // Validate vendor directory exists and is writable before making changes + if (!is_dir($noturRoot)) { + $this->error("Notur installation not found at path: {$noturRoot}. Make sure Notur is installed (e.g. via Composer) before running this command."); + return 1; + } + + if (!is_writable($noturRoot)) { + $this->error("The Notur installation directory is not writable: {$noturRoot}. Please adjust filesystem permissions and try again."); + return 1; + } + if (!$this->confirm("Pull commit {$shortSha} into vendor/notur/notur?")) { $this->info('Aborted.'); return 0; @@ -208,6 +195,23 @@ public function handle(): int return 0; } + private function resolveClient(): Client + { + // Allow injecting a mock client via the Laravel container (used in tests) + if (app()->bound(Client::class)) { + return app(Client::class); + } + + return new Client([ + 'timeout' => 30, + 'connect_timeout' => 10, + 'headers' => [ + 'Accept' => 'application/vnd.github.v3+json', + 'User-Agent' => 'Notur-DevPull/1.0', + ], + ]); + } + private function fetchCommitInfo(Client $client, string $repo, string $ref): array { $encodedRef = rawurlencode($ref);