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 14773434..bf4b4ad5 100644 --- a/config/notur.php +++ b/config/notur.php @@ -7,7 +7,7 @@ | Notur Version |-------------------------------------------------------------------------- */ - 'version' => '1.2.3', + 'version' => '1.2.4', /* |-------------------------------------------------------------------------- @@ -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..024b99ca --- /dev/null +++ b/src/Console/Commands/DevPullCommand.php @@ -0,0 +1,432 @@ +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 = $this->resolveClient(); + + // 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; + } + + // 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; + } + + // 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); + $this->deleteDirectory($tmpDir); + 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 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); + $url = self::GITHUB_API_BASE . "/repos/{$repo}/commits/{$encodedRef}"; + + 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); + } + $tmpDest = $tmpPreserve . '/' . $dir; + if (!rename($dirPath, $tmpDest)) { + throw new \RuntimeException( + "Failed to preserve directory: {$dirPath} to {$tmpDest}. " . + "Check permissions and ensure the directory is not locked." + ); + } + } + } + + // 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); + } + if (!rename($tmpSource, $destPath)) { + throw new \RuntimeException( + "Failed to restore directory: {$tmpSource} to {$destPath}. " . + "Check permissions and ensure the directory is not locked." + ); + } + } + } + + // 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'; + } + if (file_exists($cwd . '/package-lock.json')) { + return 'npm'; + } + + return 'npm'; + } + + 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, diff --git a/tests/Integration/Console/DevPullCommandTest.php b/tests/Integration/Console/DevPullCommandTest.php new file mode 100644 index 00000000..cbf1ec6b --- /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' => 'abcd1234567890abcdef1234567890abcdef1234', + '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 abcd1234 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); + } +} 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); + } +} 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