From 3b49eff4b4e45ddcc64b099b2ab9cbbf276eb937 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Fri, 3 Apr 2026 20:07:16 -0400 Subject: [PATCH 1/8] Initial macOS support --- app/Agents/ClaudeCode.php | 13 +- app/Data/OsType.php | 35 ++++ app/Data/ProjectConfig.php | 24 ++- app/Pipelines/Steps/BootVm.php | 79 ++++++++- app/Pipelines/Steps/CreateSshTunnels.php | 3 +- app/Pipelines/Steps/InstallHostShims.php | 13 +- app/Pipelines/Steps/SetupClaudeCode.php | 20 +-- app/Support/MacOsProvisioningPipeline.php | 160 ++++++++++++++++++ app/Support/SshExecutor.php | 7 + tests/Feature/Dto/ProjectConfigTest.php | 42 ++++- tests/Unit/Data/OsTypeTest.php | 38 +++++ tests/Unit/Dto/ProjectConfigTest.php | 82 +++++++-- .../Support/MacOsProvisioningPipelineTest.php | 86 ++++++++++ 13 files changed, 552 insertions(+), 50 deletions(-) create mode 100644 app/Data/OsType.php create mode 100644 app/Support/MacOsProvisioningPipeline.php create mode 100644 tests/Unit/Data/OsTypeTest.php create mode 100644 tests/Unit/Support/MacOsProvisioningPipelineTest.php diff --git a/app/Agents/ClaudeCode.php b/app/Agents/ClaudeCode.php index cc2fd71..f7d47d7 100644 --- a/app/Agents/ClaudeCode.php +++ b/app/Agents/ClaudeCode.php @@ -25,24 +25,24 @@ class ClaudeCode 'TZ', 'VISUAL', ]; - + public function __construct( protected SshExecutor $ssh, protected AuthManager $auth, ) { } - + public function __invoke(SessionContext $context): void { $env = $this->env($context); $model = $context->project_config->model; $flags = '--dangerously-skip-permissions'; - + if ($model) { $flags .= ' --model '.escapeshellarg($model); } - + foreach ($context->claude_flags as $flag) { $flags .= ' '.escapeshellarg($flag); } @@ -80,9 +80,10 @@ protected function env(SessionContext $context): string $env[] = 'CLAUDE_CODE_SSE_PORT='.escapeshellarg((string) $context->ide->port); $env[] = 'ENABLE_IDE_INTEGRATION=true'; } - + if ($context->proxy_socket_path !== null) { - $env[] = 'CLAVE_PROXY_SOCKET=/home/admin/.clave/proxy.sock'; + $home = $context->project_config->homeDir(); + $env[] = "CLAVE_PROXY_SOCKET={$home}/.clave/proxy.sock"; } return $env ? implode(' ', $env).' ' : ''; diff --git a/app/Data/OsType.php b/app/Data/OsType.php new file mode 100644 index 0000000..096a742 --- /dev/null +++ b/app/Data/OsType.php @@ -0,0 +1,35 @@ + "/home/{$user}", + self::MacOS => "/Users/{$user}", + }; + } + + public function base64DecodeFlag(): string + { + return match ($this) { + self::Linux => '-d', + self::MacOS => '-D', + }; + } +} diff --git a/app/Data/ProjectConfig.php b/app/Data/ProjectConfig.php index e75b2a0..614b25a 100644 --- a/app/Data/ProjectConfig.php +++ b/app/Data/ProjectConfig.php @@ -2,11 +2,14 @@ namespace App\Data; +use App\Support\MacOsProvisioningPipeline; use App\Support\ProvisioningPipeline; use Illuminate\Filesystem\Filesystem; class ProjectConfig { + public readonly OsType $os_type; + public static function fromProjectDir(string $project_dir, Filesystem $fs): static { $path = $project_dir.'/.clave.json'; @@ -29,6 +32,7 @@ public static function fromProjectDir(string $project_dir, Filesystem $fs): stat memory: isset($data['memory']) ? (int) $data['memory'] : null, model: $data['model'] ?? null, shims: $data['shims'] ?? [], + user: $data['user'] ?? null, ); } @@ -40,7 +44,21 @@ public function __construct( public readonly ?int $memory = null, public readonly ?string $model = null, public readonly array $shims = [], + public readonly ?string $user = null, ) { + $this->os_type = $base_image !== null + ? OsType::fromImage($base_image) + : OsType::Linux; + } + + public function effectiveUser(): string + { + return $this->user ?? config('clave.ssh.user'); + } + + public function homeDir(): string + { + return $this->os_type->homeDir($this->effectiveUser()); } public function hasCustomizations(): bool @@ -50,8 +68,12 @@ public function hasCustomizations(): bool public function baseVmName(): string { + $pipeline_class = $this->os_type === OsType::MacOS + ? MacOsProvisioningPipeline::class + : ProvisioningPipeline::class; + $hash = substr(md5(json_encode([ - 'script' => ProvisioningPipeline::toScript($this->provision), + 'script' => $pipeline_class::toScript($this->provision), 'base_image' => $this->base_image, ])), 0, 8); diff --git a/app/Pipelines/Steps/BootVm.php b/app/Pipelines/Steps/BootVm.php index ff0b93b..c820d48 100644 --- a/app/Pipelines/Steps/BootVm.php +++ b/app/Pipelines/Steps/BootVm.php @@ -2,6 +2,7 @@ namespace App\Pipelines\Steps; +use App\Data\OsType; use App\Data\SessionContext; use App\Support\SshExecutor; use App\Support\TartManager; @@ -23,6 +24,10 @@ public function handle(SessionContext $context, Closure $next): mixed { $mount_path = $context->clone_path ?? $context->project_dir; + if ($context->project_config->user !== null) { + $this->ssh->setUser($context->project_config->effectiveUser()); + } + $boot_label = $context->resumed ? 'Resuming' : 'Booting'; $this->checklist("{$boot_label} VM '{$context->vm_name}'...") ->run(fn() => $this->tart->runBackground($context->vm_name, [$mount_path])); @@ -52,14 +57,31 @@ public function handle(SessionContext $context, Closure $next): mixed protected function cleanupResumedVm(SessionContext $context): void { - $this->ssh->run('pkill -u admin -x node 2>/dev/null; true'); - - $project_dir = escapeshellarg($context->project_dir); - $this->ssh->run("sudo umount {$project_dir} 2>/dev/null; true"); - $this->ssh->run('sudo umount /srv/project 2>/dev/null; true'); + $user = $context->project_config->effectiveUser(); + $this->ssh->run("pkill -u {$user} -x node 2>/dev/null; true"); + + if ($context->project_config->os_type === OsType::MacOS) { + $project_dir = escapeshellarg($context->project_dir); + $this->ssh->run("rm -f /srv/project {$project_dir} 2>/dev/null; true"); + } else { + $project_dir = escapeshellarg($context->project_dir); + $this->ssh->run("sudo umount {$project_dir} 2>/dev/null; true"); + $this->ssh->run('sudo umount /srv/project 2>/dev/null; true'); + } } protected function mountSharedDirectories(SessionContext $context): void + { + $mount_path = $context->clone_path ?? $context->project_dir; + + if ($context->project_config->os_type === OsType::MacOS) { + $this->mountMacOsSharedDirectories($context, $mount_path); + } else { + $this->mountLinuxSharedDirectories($context); + } + } + + protected function mountLinuxSharedDirectories(SessionContext $context): void { $mount_timeout = 30; $start = time(); @@ -82,7 +104,6 @@ protected function mountSharedDirectories(SessionContext $context): void } $this->ssh->run("mountpoint -q {$mount_point}"); - $this->ssh->run("sudo mkdir -p {$project_dir}"); $this->ssh->run("sudo mount --bind {$mount_point} {$project_dir}"); @@ -116,10 +137,56 @@ protected function mountSharedDirectories(SessionContext $context): void ); } + protected function mountMacOsSharedDirectories(SessionContext $context, string $mount_path): void + { + $mount_timeout = 30; + $start = time(); + + $label = basename($mount_path); + $virtiofs_path = "/Volumes/My Shared Files/{$label}"; + $mount_point = '/srv/project'; + $project_dir = $context->project_dir; + $user = $context->project_config->effectiveUser(); + $parent_dir = escapeshellarg(dirname($project_dir)); + + while (time() - $start < $mount_timeout) { + try { + $this->ssh->run('test -d '.escapeshellarg($virtiofs_path)); + $this->ssh->run("sudo rm -rf {$mount_point} && sudo ln -sf ".escapeshellarg($virtiofs_path)." {$mount_point}"); + $this->ssh->run("sudo mkdir -p {$parent_dir} && sudo chown {$user} {$parent_dir}"); + $this->ssh->run('sudo rm -rf '.escapeshellarg($project_dir)." && sudo ln -sf {$mount_point} ".escapeshellarg($project_dir)); + + return; + } catch (Throwable) { + sleep(2); + } + } + + $diag = ''; + + try { + $result = $this->ssh->run(implode("\n", [ + 'echo "=== VirtioFS mounts ==="', + 'ls -la "/Volumes/My Shared Files/" 2>&1 || echo "(not mounted)"', + 'echo "=== current mounts ==="', + 'mount 2>&1', + 'true', + ])); + $diag = $result->output(); + } catch (Throwable) { + } + + throw new RuntimeException( + "Timed out after {$mount_timeout}s waiting for VirtioFS mount at '{$virtiofs_path}' on VM '{$context->vm_name}'" + .($diag ? "\nDiagnostics:\n{$diag}" : '') + ); + } + protected function isMounted(string $path): bool { try { $this->ssh->run("mountpoint -q {$path}"); + return true; } catch (Throwable) { return false; diff --git a/app/Pipelines/Steps/CreateSshTunnels.php b/app/Pipelines/Steps/CreateSshTunnels.php index abeceee..34dc1eb 100644 --- a/app/Pipelines/Steps/CreateSshTunnels.php +++ b/app/Pipelines/Steps/CreateSshTunnels.php @@ -19,8 +19,9 @@ public function handle(SessionContext $context, Closure $next): mixed return $next($context); } + $home = $context->project_config->homeDir(); $socket_forward = $context->proxy_socket_path !== null - ? '/home/admin/.clave/proxy.sock:'.$context->proxy_socket_path + ? "{$home}/.clave/proxy.sock:{$context->proxy_socket_path}" : null; $this->checklist('Creating MCP tunnels...') diff --git a/app/Pipelines/Steps/InstallHostShims.php b/app/Pipelines/Steps/InstallHostShims.php index d8f2a9c..daaf3b1 100644 --- a/app/Pipelines/Steps/InstallHostShims.php +++ b/app/Pipelines/Steps/InstallHostShims.php @@ -49,15 +49,20 @@ public function handle(SessionContext $context, Closure $next): mixed $context->proxy_socket_path = $socket_path; }); - $symlinks = implode(' ', + $home = $context->project_config->homeDir(); + $shims_dir = "{$home}/.clave/shims"; + + $symlinks = implode( + ' ', array_map( - fn($shim) => '&& ln -sf /usr/local/bin/clave-exec /home/admin/.clave/shims/'.escapeshellarg($shim), + fn($shim) => "&& ln -sf /usr/local/bin/clave-exec {$shims_dir}/".escapeshellarg($shim), $shims, - )); + ) + ); $this->checklist('Installing host shims...') ->run(fn() => $this->ssh->run( - "mkdir -p /home/admin/.clave/shims {$symlinks}", + "mkdir -p {$shims_dir} {$symlinks}", )); return $next($context); diff --git a/app/Pipelines/Steps/SetupClaudeCode.php b/app/Pipelines/Steps/SetupClaudeCode.php index c2e8bf3..9b478ac 100644 --- a/app/Pipelines/Steps/SetupClaudeCode.php +++ b/app/Pipelines/Steps/SetupClaudeCode.php @@ -3,17 +3,16 @@ namespace App\Pipelines\Steps; use App\Data\SessionContext; -use function App\home_path; use App\Support\AuthManager; use App\Support\SshExecutor; use Closure; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Arr; use Throwable; +use function App\home_path; class SetupClaudeCode extends Step { - public function __construct( protected SshExecutor $ssh, protected AuthManager $auth, @@ -82,9 +81,11 @@ protected function writeConfigFiles(SessionContext $context, array $config, arra $claude_json_encoded = base64_encode(json_encode($claude_json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); $settings_json_encoded = base64_encode(json_encode($settings_json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + $b64 = $context->project_config->os_type->base64DecodeFlag(); + $command = 'mkdir -p ~/.claude' - ." && echo {$claude_json_encoded} | base64 -d > ~/.claude.json" - ." && echo {$settings_json_encoded} | base64 -d > ~/.claude/settings.json"; + ." && echo {$claude_json_encoded} | base64 {$b64} > ~/.claude.json" + ." && echo {$settings_json_encoded} | base64 {$b64} > ~/.claude/settings.json"; if ($auth !== null && $auth['type'] === 'oauth') { $credentials_encoded = base64_encode(json_encode([ @@ -96,14 +97,14 @@ protected function writeConfigFiles(SessionContext $context, array $config, arra ], ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - $command .= " && echo {$credentials_encoded} | base64 -d > ~/.claude/.credentials.json"; + $command .= " && echo {$credentials_encoded} | base64 {$b64} > ~/.claude/.credentials.json"; } if (! empty($md)) { $md_encoded = base64_encode($md); - $command .= " && echo {$md_encoded} | base64 -d > ~/.claude/CLAUDE.md"; + $command .= " && echo {$md_encoded} | base64 {$b64} > ~/.claude/CLAUDE.md"; } - + if ($context->ide) { $lock = json_encode(array_filter([ 'ideName' => $context->ide->ide_name, @@ -113,9 +114,9 @@ protected function writeConfigFiles(SessionContext $context, array $config, arra ], fn($v) => $v !== null), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); $lock_encoded = base64_encode($lock); $port = $context->ide->port; - $command .= " && mkdir -p ~/.claude/ide && echo {$lock_encoded} | base64 -d > ~/.claude/ide/{$port}.lock"; + $command .= " && mkdir -p ~/.claude/ide && echo {$lock_encoded} | base64 {$b64} > ~/.claude/ide/{$port}.lock"; } - + $this->ssh->run($command); } @@ -142,5 +143,4 @@ protected function extractMcpPorts(array $config): array return array_unique($ports); } - } diff --git a/app/Support/MacOsProvisioningPipeline.php b/app/Support/MacOsProvisioningPipeline.php new file mode 100644 index 0000000..8fb9db0 --- /dev/null +++ b/app/Support/MacOsProvisioningPipeline.php @@ -0,0 +1,160 @@ + [ + 'label' => 'Installing pkgx and base dependencies', + 'commands' => [ + 'curl -fsSL https://pkgx.sh | bash', + "export PATH=\"{$home}/.local/bin:\$PATH\"", + 'pkgx install git unzip jq socat', + ], + ], + 'php' => [ + 'label' => 'Installing PHP', + 'commands' => [ + 'pkgx install php@8.3', + "echo 'export PATH=\"{$home}/.local/bin:\$PATH\"' >> {$home}/.bash_profile", + "echo 'export PATH=\"{$home}/.local/bin:\$PATH\"' >> {$home}/.zshrc || true", + ], + ], + 'composer' => [ + 'label' => 'Installing Composer', + 'commands' => [ + "export PATH=\"{$home}/.local/bin:\$PATH\"", + 'curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer', + 'composer --version', + ], + ], + 'nginx' => [ + 'label' => 'Installing nginx', + 'commands' => [ + 'pkgx install nginx', + ], + ], + 'mysql' => [ + 'label' => 'Installing MySQL client', + 'commands' => [ + 'pkgx install mysql-client', + ], + ], + 'node' => [ + 'label' => 'Installing Node.js', + 'commands' => [ + 'pkgx install node@22', + ], + ], + 'claudeCode' => [ + 'label' => 'Installing Claude Code', + 'commands' => [ + <<> {$home}/.bash_profile", + "sudo -H -u {$user} bash -l -c \"claude --version\"", + ], + ], + 'claveProxy' => [ + 'label' => 'Installing clave-exec shim runner', + 'commands' => [ + << /dev/null << 'SCRIPT' + #!/usr/bin/env bash + set -euo pipefail + + SOCKET="\${CLAVE_PROXY_SOCKET:-{$home}/.clave/proxy.sock}" + CMD="\$(basename "\$0")" + + if [[ "\$CMD" == "clave-exec" ]]; then + CMD="\$1" + shift + fi + + ARGS_JSON=\$([ \$# -gt 0 ] && printf '%s\n' "\$@" | jq -R . | jq -s . || echo '[]') + PAYLOAD=\$(jq -cn --arg cmd "\$CMD" --arg cwd "\$(pwd)" --argjson args "\$ARGS_JSON" \ + '{cmd: \$cmd, args: \$args, cwd: \$cwd}') + + RESPONSE=\$(printf '%s\n' "\$PAYLOAD" | socat - "UNIX-CONNECT:\${SOCKET}") + + { + read -r STDOUT_B64 + read -r STDERR_B64 + read -r EXIT_CODE + } < <(printf '%s' "\$RESPONSE" | jq -r '(.stdout // "" | @base64), (.stderr // "" | @base64), (.exit_code // 1 | tostring)') + + printf '%s' "\$STDOUT_B64" | base64 -D + printf '%s' "\$STDERR_B64" | base64 -D >&2 + exit "\${EXIT_CODE:-1}" + SCRIPT + BASH, + 'sudo chmod +x /usr/local/bin/clave-exec', + "sudo mkdir -p {$home}/.clave/shims", + "sudo chown -R {$user}:{$user} {$home}/.clave", + "echo '{$home}/.clave/shims' | sudo tee /etc/paths.d/clave-shims", + "echo 'export PATH=\"{$home}/.clave/shims:\$PATH\"' >> {$home}/.zshrc || true", + ], + ], + 'git' => [ + 'label' => 'Configuring git', + 'commands' => [ + 'git config --global user.name "Clave"', + 'git config --global user.email "noreply@clave.run"', + ], + ], + 'projectDirectories' => [ + 'label' => 'Creating project directories', + 'commands' => [ + 'sudo mkdir -p /srv/project', + "sudo chown -R {$user}:{$user} /srv/project", + ], + ], + ]; + } + + public static function hash(array $extra_commands = []): string + { + return substr(md5(static::toScript($extra_commands)), 0, 8); + } + + public static function toScript(array $extra_commands = []): string + { + $lines = ['#!/usr/bin/env bash', 'set -euo pipefail', '']; + + foreach (static::steps() as $step) { + $lines[] = "echo '==> {$step['label']}...'"; + foreach ($step['commands'] as $command) { + $lines[] = $command; + } + $lines[] = ''; + } + + if ($extra_commands) { + $lines[] = "echo '==> Running project provisioning...'"; + foreach ($extra_commands as $command) { + $lines[] = $command; + } + $lines[] = ''; + } + + return implode("\n", $lines)."\n"; + } +} diff --git a/app/Support/SshExecutor.php b/app/Support/SshExecutor.php index 4d31c34..17d8bd0 100644 --- a/app/Support/SshExecutor.php +++ b/app/Support/SshExecutor.php @@ -42,6 +42,13 @@ public function setHost(string $host): self return $this; } + public function setUser(string $user): self + { + $this->user = $user; + + return $this; + } + public function usePassword(string $password): self { $this->password = $password; diff --git a/tests/Feature/Dto/ProjectConfigTest.php b/tests/Feature/Dto/ProjectConfigTest.php index 7a83988..e56aed6 100644 --- a/tests/Feature/Dto/ProjectConfigTest.php +++ b/tests/Feature/Dto/ProjectConfigTest.php @@ -1,21 +1,22 @@ baseVmName(); - + expect($name)->toStartWith(config('clave.base_vm').'-') ->and(strlen($name))->toBe(strlen(config('clave.base_vm')) + 9); // dash + 8 hex chars }); test('baseVmName includes hash when customized', function() { $config = new ProjectConfig(base_image: 'ghcr.io/custom/image:latest'); - + $name = $config->baseVmName(); - + expect($name)->toStartWith(config('clave.base_vm').'-') ->and(strlen($name))->toBe(strlen(config('clave.base_vm')) + 9); }); @@ -23,20 +24,47 @@ test('baseVmName is deterministic for same config', function() { $config_a = new ProjectConfig(base_image: 'ghcr.io/custom/image:latest', provision: ['cmd1']); $config_b = new ProjectConfig(base_image: 'ghcr.io/custom/image:latest', provision: ['cmd1']); - + expect($config_a->baseVmName())->toBe($config_b->baseVmName()); }); test('baseVmName differs for different configs', function() { $config_a = new ProjectConfig(base_image: 'ghcr.io/custom/image:v1'); $config_b = new ProjectConfig(base_image: 'ghcr.io/custom/image:v2'); - + expect($config_a->baseVmName())->not->toBe($config_b->baseVmName()); }); test('baseVmName differs for different provision commands', function() { $config_a = new ProjectConfig(provision: ['cmd1']); $config_b = new ProjectConfig(provision: ['cmd2']); - + expect($config_a->baseVmName())->not->toBe($config_b->baseVmName()); }); + +test('effectiveUser falls back to config when user is null', function() { + $config = new ProjectConfig(); + + expect($config->effectiveUser())->toBe(config('clave.ssh.user')); +}); + +test('homeDir returns Linux path by default using config user', function() { + $config = new ProjectConfig(); + + $user = config('clave.ssh.user'); + expect($config->homeDir())->toBe("/home/{$user}"); +}); + +test('baseVmName differs between Linux and macOS images', function() { + $linux = new ProjectConfig(base_image: 'ghcr.io/cirruslabs/ubuntu:latest'); + $macos = new ProjectConfig(base_image: 'ghcr.io/cirruslabs/macos-sequoia-base:latest'); + + expect($linux->baseVmName())->not->toBe($macos->baseVmName()); +}); + +test('baseVmName for macOS image uses macOS provisioning script', function() { + $config = new ProjectConfig(base_image: 'ghcr.io/cirruslabs/macos-sequoia-base:latest'); + + expect($config->os_type)->toBe(OsType::MacOS) + ->and($config->baseVmName())->toStartWith(config('clave.base_vm').'-'); +}); diff --git a/tests/Unit/Data/OsTypeTest.php b/tests/Unit/Data/OsTypeTest.php new file mode 100644 index 0000000..1caaf9e --- /dev/null +++ b/tests/Unit/Data/OsTypeTest.php @@ -0,0 +1,38 @@ +toBe(OsType::Linux) + ->and(OsType::fromImage('ghcr.io/custom/debian:12'))->toBe(OsType::Linux) + ->and(OsType::fromImage('my-linux-image:v1'))->toBe(OsType::Linux); +}); + +test('fromImage returns MacOs for macOS images', function() { + expect(OsType::fromImage('ghcr.io/cirruslabs/macos-sequoia-base:latest'))->toBe(OsType::MacOS) + ->and(OsType::fromImage('ghcr.io/cirruslabs/macos-ventura-base:latest'))->toBe(OsType::MacOS) + ->and(OsType::fromImage('ghcr.io/cirruslabs/macos-sonoma-base:latest'))->toBe(OsType::MacOS) + ->and(OsType::fromImage('my-darwin-image:v1'))->toBe(OsType::MacOS); +}); + +test('fromImage is case-insensitive', function() { + expect(OsType::fromImage('ghcr.io/org/MacOS-Sequoia:latest'))->toBe(OsType::MacOS); +}); + +test('homeDir returns correct path for Linux', function() { + expect(OsType::Linux->homeDir('admin'))->toBe('/home/admin') + ->and(OsType::Linux->homeDir('chris'))->toBe('/home/chris'); +}); + +test('homeDir returns correct path for macOS', function() { + expect(OsType::MacOS->homeDir('admin'))->toBe('/Users/admin') + ->and(OsType::MacOS->homeDir('chris'))->toBe('/Users/chris'); +}); + +test('base64DecodeFlag returns -d for Linux', function() { + expect(OsType::Linux->base64DecodeFlag())->toBe('-d'); +}); + +test('base64DecodeFlag returns -D for macOS', function() { + expect(OsType::MacOS->base64DecodeFlag())->toBe('-D'); +}); diff --git a/tests/Unit/Dto/ProjectConfigTest.php b/tests/Unit/Dto/ProjectConfigTest.php index 439dfe4..eb00a4a 100644 --- a/tests/Unit/Dto/ProjectConfigTest.php +++ b/tests/Unit/Dto/ProjectConfigTest.php @@ -1,11 +1,12 @@ base_image)->toBeNull() ->and($config->provision)->toBe([]) ->and($config->hasCustomizations())->toBeFalse(); @@ -13,22 +14,22 @@ test('hasCustomizations returns true when base_image is set', function() { $config = new ProjectConfig(base_image: 'ghcr.io/custom/image:latest'); - + expect($config->hasCustomizations())->toBeTrue(); }); test('hasCustomizations returns true when provision is set', function() { $config = new ProjectConfig(provision: ['sudo apt-get install -y postgresql']); - + expect($config->hasCustomizations())->toBeTrue(); }); test('fromProjectDir returns defaults when no .clave.json exists', function() { $fs = Mockery::mock(Filesystem::class); $fs->shouldReceive('exists')->with('/path/to/project/.clave.json')->andReturn(false); - + $config = ProjectConfig::fromProjectDir('/path/to/project', $fs); - + expect($config->base_image)->toBeNull() ->and($config->provision)->toBe([]) ->and($config->hasCustomizations())->toBeFalse(); @@ -40,9 +41,9 @@ $fs->shouldReceive('get')->with('/path/to/project/.clave.json')->andReturn(json_encode([ 'base_image' => 'ghcr.io/custom/image:latest', ])); - + $config = ProjectConfig::fromProjectDir('/path/to/project', $fs); - + expect($config->base_image)->toBe('ghcr.io/custom/image:latest') ->and($config->provision)->toBe([]); }); @@ -56,9 +57,9 @@ 'sudo systemctl enable postgresql', ], ])); - + $config = ProjectConfig::fromProjectDir('/path/to/project', $fs); - + expect($config->base_image)->toBeNull() ->and($config->provision)->toBe([ 'sudo apt-get install -y postgresql', @@ -75,9 +76,9 @@ 'cpus' => 8, 'memory' => 16384, ])); - + $config = ProjectConfig::fromProjectDir('/path/to/project', $fs); - + expect($config->base_image)->toBe('ghcr.io/custom/image:latest') ->and($config->provision)->toBe(['sudo apt-get install -y redis-server']) ->and($config->cpus)->toBe(8) @@ -92,9 +93,9 @@ 'cpus' => 2, 'memory' => 4096, ])); - + $config = ProjectConfig::fromProjectDir('/path/to/project', $fs); - + expect($config->cpus)->toBe(2) ->and($config->memory)->toBe(4096); }); @@ -103,9 +104,60 @@ $fs = Mockery::mock(Filesystem::class); $fs->shouldReceive('exists')->with('/path/to/project/.clave.json')->andReturn(true); $fs->shouldReceive('get')->with('/path/to/project/.clave.json')->andReturn('not valid json'); - + $config = ProjectConfig::fromProjectDir('/path/to/project', $fs); - + expect($config->base_image)->toBeNull() ->and($config->provision)->toBe([]); }); + +test('default os_type is Linux', function() { + $config = new ProjectConfig(); + + expect($config->os_type)->toBe(OsType::Linux); +}); + +test('os_type is MacOs when base_image is a macOS image', function() { + $config = new ProjectConfig(base_image: 'ghcr.io/cirruslabs/macos-sequoia-base:latest'); + + expect($config->os_type)->toBe(OsType::MacOS); +}); + +test('os_type remains Linux for non-macOS images', function() { + $config = new ProjectConfig(base_image: 'ghcr.io/cirruslabs/ubuntu:latest'); + + expect($config->os_type)->toBe(OsType::Linux); +}); + +test('user defaults to null', function() { + $config = new ProjectConfig(); + + expect($config->user)->toBeNull(); +}); + +test('fromProjectDir parses user field', function() { + $fs = Mockery::mock(Filesystem::class); + $fs->shouldReceive('exists')->with('/path/to/project/.clave.json')->andReturn(true); + $fs->shouldReceive('get')->with('/path/to/project/.clave.json')->andReturn(json_encode([ + 'user' => 'chris', + ])); + + $config = ProjectConfig::fromProjectDir('/path/to/project', $fs); + + expect($config->user)->toBe('chris'); +}); + +test('effectiveUser returns the configured user when set', function() { + $config = new ProjectConfig(user: 'chris'); + + expect($config->effectiveUser())->toBe('chris'); +}); + +test('homeDir returns macOS path for macOS image with custom user', function() { + $config = new ProjectConfig( + base_image: 'ghcr.io/cirruslabs/macos-sequoia-base:latest', + user: 'chris', + ); + + expect($config->homeDir())->toBe('/Users/chris'); +}); diff --git a/tests/Unit/Support/MacOsProvisioningPipelineTest.php b/tests/Unit/Support/MacOsProvisioningPipelineTest.php new file mode 100644 index 0000000..4b2f951 --- /dev/null +++ b/tests/Unit/Support/MacOsProvisioningPipelineTest.php @@ -0,0 +1,86 @@ +toStartWith("#!/usr/bin/env bash\n") + ->and($script)->toContain('set -euo pipefail') + ->and($script)->toContain("echo '==> Installing pkgx and base dependencies...'"); +}); + +test('toScript uses pkgx and not apt-get or brew', function() { + $script = MacOsProvisioningPipeline::toScript(); + + expect($script)->toContain('pkgx') + ->and($script)->not->toContain('apt-get') + ->and($script)->not->toContain('brew '); +}); + +test('toScript does not include fstab or virtiofs mount configuration', function() { + $script = MacOsProvisioningPipeline::toScript(); + + expect($script)->not->toContain('/etc/fstab') + ->and($script)->not->toContain('virtiofsMounts') + ->and($script)->not->toContain('com.apple.virtio-fs.automount'); +}); + +test('toScript uses /etc/paths.d for PATH setup', function() { + $script = MacOsProvisioningPipeline::toScript(); + + expect($script)->toContain('/etc/paths.d/clave-shims'); +}); + +test('toScript includes extra commands when provided', function() { + $extra = [ + 'pkgx install postgresql', + ]; + + $script = MacOsProvisioningPipeline::toScript($extra); + + expect($script)->toContain("echo '==> Running project provisioning...'") + ->and($script)->toContain('pkgx install postgresql'); +}); + +test('toScript does not include project provisioning section without extra commands', function() { + $script = MacOsProvisioningPipeline::toScript(); + + expect($script)->not->toContain('Running project provisioning'); +}); + +test('toScript appends extra commands after standard steps', function() { + $extra = ['echo "custom step"']; + + $script = MacOsProvisioningPipeline::toScript($extra); + + $standard_pos = strpos($script, 'Creating project directories'); + $custom_pos = strpos($script, 'Running project provisioning'); + + expect($standard_pos)->toBeLessThan($custom_pos); +}); + +test('hash returns 8-char hex string', function() { + $hash = MacOsProvisioningPipeline::hash(); + + expect($hash)->toMatch('/^[0-9a-f]{8}$/'); +}); + +test('hash is deterministic for same input', function() { + expect(MacOsProvisioningPipeline::hash())->toBe(MacOsProvisioningPipeline::hash()) + ->and(MacOsProvisioningPipeline::hash(['cmd1']))->toBe(MacOsProvisioningPipeline::hash(['cmd1'])); +}); + +test('hash changes when extra commands differ', function() { + $default = MacOsProvisioningPipeline::hash(); + $with_extra = MacOsProvisioningPipeline::hash(['pkgx install postgresql']); + + expect($default)->not->toBe($with_extra); +}); + +test('hash differs from Linux provisioning hash', function() { + $linux_hash = \App\Support\ProvisioningPipeline::hash(); + $macos_hash = MacOsProvisioningPipeline::hash(); + + expect($linux_hash)->not->toBe($macos_hash); +}); From a3bcf37f0db57d2e632ba092fc547e35b636456f Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Fri, 3 Apr 2026 21:51:03 -0400 Subject: [PATCH 2/8] Basic mac support --- app/Commands/ProvisionCommand.php | 73 +++++++++++++++++-- app/Data/ProjectConfig.php | 2 +- app/Pipelines/Steps/BootVm.php | 16 ++-- app/Pipelines/Steps/EnsureVmExists.php | 4 + app/Support/MacOsProvisioningPipeline.php | 35 ++++----- app/Support/ProvisioningPipeline.php | 38 ++++------ .../Feature/Commands/ProvisionCommandTest.php | 9 ++- 7 files changed, 113 insertions(+), 64 deletions(-) diff --git a/app/Commands/ProvisionCommand.php b/app/Commands/ProvisionCommand.php index 797c989..db4e6c7 100644 --- a/app/Commands/ProvisionCommand.php +++ b/app/Commands/ProvisionCommand.php @@ -2,12 +2,15 @@ namespace App\Commands; +use App\Data\OsType; +use App\Support\MacOsProvisioningPipeline; use App\Support\ProvisioningPipeline; use App\Support\SshExecutor; use App\Support\TartManager; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Str; use LaravelZero\Framework\Commands\Command; +use RuntimeException; use Throwable; use function App\checklist; use function Laravel\Prompts\note; @@ -18,6 +21,7 @@ class ProvisionCommand extends Command {--force : Re-provision even if base image exists} {--image= : OCI image to pull} {--base-vm= : Name for the provisioned base VM} + {--user= : SSH username for the VM} {--provision= : JSON array of extra provisioning commands}'; protected $description = 'Provision the base VM image for Clave sessions'; @@ -40,6 +44,7 @@ public function handle( $tmp_id = Str::random(12); $tmp_name = "clave-tmp-{$tmp_id}"; $script_dir = null; + $os_type = OsType::Linux; $this->trap([SIGINT, SIGTERM], function() use ($checklist, $tart, $tmp_name) { $checklist->item('Cleaning up...') @@ -58,7 +63,7 @@ public function handle( ->run(fn() => $tart->clone($image, $tmp_name)); $checklist->item('Configuring VM...') - ->run(function() use ($tart, $ssh, $fs, $tmp_name, $tmp_id, &$script_dir) { + ->run(function() use ($tart, $ssh, $fs, $image, $tmp_name, $tmp_id, &$script_dir, &$os_type) { $tart->set( name: $tmp_name, cpus: config('clave.vm.cpus'), @@ -66,15 +71,22 @@ public function handle( display: config('clave.vm.display') ); + $user = $this->option('user') ?? config('clave.ssh.user'); + $ssh->setUser($user); $ssh->usePassword(config('clave.ssh.password')); $extra_provision = $this->option('provision') ? json_decode($this->option('provision'), true) ?? [] : []; + $os_type = OsType::fromImage($image); + $pipeline_class = $os_type === OsType::MacOS + ? MacOsProvisioningPipeline::class + : ProvisioningPipeline::class; + $script_dir = sys_get_temp_dir().'/clave-provision-'.$tmp_id; $fs->ensureDirectoryExists($script_dir, 0700); - $fs->put("{$script_dir}/provision.sh", ProvisioningPipeline::toScript($extra_provision)); + $fs->put("{$script_dir}/provision.sh", $pipeline_class::toScript($extra_provision, $user)); }); $checklist->item('Booting VM...') @@ -84,9 +96,15 @@ public function handle( ->run(fn() => $tart->waitForReady($tmp_name, $ssh, 120)); $checklist->item('Provisioning (may take a while)...') - ->run(function() use ($ssh) { - $ssh->run('sudo mkdir -p /mnt/provision && sudo mount -t virtiofs com.apple.virtio-fs.automount /mnt/provision'); - $ssh->run('sudo bash /mnt/provision/provision.sh', 600); + ->run(function() use ($ssh, &$script_dir, &$os_type) { + if ($os_type === OsType::MacOS) { + $script_path = '/Volumes/My Shared Files/provision.sh'; + $this->waitForPath($ssh, $script_path); + $ssh->run("bash '{$script_path}'", 600); + } else { + $ssh->run('sudo mkdir -p /mnt/provision && sudo mount -t virtiofs com.apple.virtio-fs.automount /mnt/provision'); + $ssh->run('sudo bash /mnt/provision/provision.sh', 600); + } }); $checklist->item('Stopping VM...') @@ -105,11 +123,52 @@ public function handle( throw $e; } finally { - if ($script_dir) { - $fs->deleteDirectory($script_dir); + try { + if ($script_dir) { + $fs->deleteDirectory($script_dir); + } + } catch (Throwable) { + // Temp dir cleanup is best-effort — macOS may create + // permission-restricted entries like .Trashes } } return self::SUCCESS; } + + protected function waitForPath(SshExecutor $ssh, string $path, int $timeout = 30): void + { + $escaped = escapeshellarg($path); + $start = time(); + + while (time() - $start < $timeout) { + try { + $ssh->run("test -f {$escaped}"); + + return; + } catch (Throwable) { + sleep(2); + } + } + + $diag = ''; + try { + $result = $ssh->run(implode("\n", [ + 'echo "=== /Volumes ==="', + 'ls -la /Volumes/ 2>&1', + 'echo "=== /Volumes/My Shared Files ==="', + 'ls -la "/Volumes/My Shared Files/" 2>&1 || echo "(not found)"', + 'echo "=== mount ==="', + 'mount 2>&1', + 'true', + ])); + $diag = $result->output(); + } catch (Throwable) { + } + + throw new RuntimeException( + "Timed out after {$timeout}s waiting for '{$path}' to appear in VM" + .($diag ? "\nDiagnostics:\n{$diag}" : '') + ); + } } diff --git a/app/Data/ProjectConfig.php b/app/Data/ProjectConfig.php index 614b25a..42477e8 100644 --- a/app/Data/ProjectConfig.php +++ b/app/Data/ProjectConfig.php @@ -73,7 +73,7 @@ public function baseVmName(): string : ProvisioningPipeline::class; $hash = substr(md5(json_encode([ - 'script' => $pipeline_class::toScript($this->provision), + 'script' => $pipeline_class::toScript($this->provision, $this->effectiveUser()), 'base_image' => $this->base_image, ])), 0, 8); diff --git a/app/Pipelines/Steps/BootVm.php b/app/Pipelines/Steps/BootVm.php index c820d48..2fdaebb 100644 --- a/app/Pipelines/Steps/BootVm.php +++ b/app/Pipelines/Steps/BootVm.php @@ -61,8 +61,9 @@ protected function cleanupResumedVm(SessionContext $context): void $this->ssh->run("pkill -u {$user} -x node 2>/dev/null; true"); if ($context->project_config->os_type === OsType::MacOS) { + $home = $context->project_config->homeDir(); $project_dir = escapeshellarg($context->project_dir); - $this->ssh->run("rm -f /srv/project {$project_dir} 2>/dev/null; true"); + $this->ssh->run("rm -f {$home}/project {$project_dir} 2>/dev/null; true"); } else { $project_dir = escapeshellarg($context->project_dir); $this->ssh->run("sudo umount {$project_dir} 2>/dev/null; true"); @@ -142,19 +143,18 @@ protected function mountMacOsSharedDirectories(SessionContext $context, string $ $mount_timeout = 30; $start = time(); - $label = basename($mount_path); - $virtiofs_path = "/Volumes/My Shared Files/{$label}"; - $mount_point = '/srv/project'; + $virtiofs_path = '/Volumes/My Shared Files'; + $home = $context->project_config->homeDir(); + $mount_point = "{$home}/project"; $project_dir = $context->project_dir; - $user = $context->project_config->effectiveUser(); $parent_dir = escapeshellarg(dirname($project_dir)); while (time() - $start < $mount_timeout) { try { $this->ssh->run('test -d '.escapeshellarg($virtiofs_path)); - $this->ssh->run("sudo rm -rf {$mount_point} && sudo ln -sf ".escapeshellarg($virtiofs_path)." {$mount_point}"); - $this->ssh->run("sudo mkdir -p {$parent_dir} && sudo chown {$user} {$parent_dir}"); - $this->ssh->run('sudo rm -rf '.escapeshellarg($project_dir)." && sudo ln -sf {$mount_point} ".escapeshellarg($project_dir)); + $this->ssh->run("rm -rf {$mount_point} && ln -sf ".escapeshellarg($virtiofs_path)." {$mount_point}"); + $this->ssh->run("mkdir -p {$parent_dir}"); + $this->ssh->run('rm -rf '.escapeshellarg($project_dir)." && ln -sf {$mount_point} ".escapeshellarg($project_dir)); return; } catch (Throwable) { diff --git a/app/Pipelines/Steps/EnsureVmExists.php b/app/Pipelines/Steps/EnsureVmExists.php index 43fd7b7..c3f5da7 100644 --- a/app/Pipelines/Steps/EnsureVmExists.php +++ b/app/Pipelines/Steps/EnsureVmExists.php @@ -32,6 +32,10 @@ public function handle(SessionContext $context, Closure $next): mixed $args['--image'] = $context->project_config->base_image; } + if ($context->project_config->user) { + $args['--user'] = $context->project_config->user; + } + if ($context->project_config->provision) { $args['--provision'] = json_encode($context->project_config->provision); } diff --git a/app/Support/MacOsProvisioningPipeline.php b/app/Support/MacOsProvisioningPipeline.php index 8fb9db0..f2d0d81 100644 --- a/app/Support/MacOsProvisioningPipeline.php +++ b/app/Support/MacOsProvisioningPipeline.php @@ -4,9 +4,8 @@ class MacOsProvisioningPipeline { - public static function steps(): array + public static function steps(string $user = 'admin'): array { - $user = 'admin'; $home = "/Users/{$user}"; return [ @@ -14,14 +13,14 @@ public static function steps(): array 'label' => 'Installing pkgx and base dependencies', 'commands' => [ 'curl -fsSL https://pkgx.sh | bash', - "export PATH=\"{$home}/.local/bin:\$PATH\"", - 'pkgx install git unzip jq socat', + "export PATH=\"/usr/local/bin:{$home}/.local/bin:\$PATH\"", + 'pkgm install git unzip jq socat', ], ], 'php' => [ 'label' => 'Installing PHP', 'commands' => [ - 'pkgx install php@8.3', + 'pkgm install php@8.3', "echo 'export PATH=\"{$home}/.local/bin:\$PATH\"' >> {$home}/.bash_profile", "echo 'export PATH=\"{$home}/.local/bin:\$PATH\"' >> {$home}/.zshrc || true", ], @@ -30,26 +29,26 @@ public static function steps(): array 'label' => 'Installing Composer', 'commands' => [ "export PATH=\"{$home}/.local/bin:\$PATH\"", - 'curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer', + "curl -sS https://getcomposer.org/installer | php -- --install-dir={$home}/.local/bin --filename=composer", 'composer --version', ], ], 'nginx' => [ 'label' => 'Installing nginx', 'commands' => [ - 'pkgx install nginx', + 'pkgm install nginx', ], ], 'mysql' => [ 'label' => 'Installing MySQL client', 'commands' => [ - 'pkgx install mysql-client', + 'pkgm install mysql.com', ], ], 'node' => [ 'label' => 'Installing Node.js', 'commands' => [ - 'pkgx install node@22', + 'pkgm install node@22', ], ], 'claudeCode' => [ @@ -58,7 +57,7 @@ public static function steps(): array <<> {$home}/.bash_profile", - "sudo -H -u {$user} bash -l -c \"claude --version\"", + "bash -l -c 'claude --version'", ], ], 'claveProxy' => [ @@ -107,8 +106,7 @@ public static function steps(): array SCRIPT BASH, 'sudo chmod +x /usr/local/bin/clave-exec', - "sudo mkdir -p {$home}/.clave/shims", - "sudo chown -R {$user}:{$user} {$home}/.clave", + "mkdir -p {$home}/.clave/shims", "echo '{$home}/.clave/shims' | sudo tee /etc/paths.d/clave-shims", "echo 'export PATH=\"{$home}/.clave/shims:\$PATH\"' >> {$home}/.zshrc || true", ], @@ -123,23 +121,22 @@ public static function steps(): array 'projectDirectories' => [ 'label' => 'Creating project directories', 'commands' => [ - 'sudo mkdir -p /srv/project', - "sudo chown -R {$user}:{$user} /srv/project", + "mkdir -p {$home}/project", ], ], ]; } - public static function hash(array $extra_commands = []): string + public static function hash(array $extra_commands = [], string $user = 'admin'): string { - return substr(md5(static::toScript($extra_commands)), 0, 8); + return substr(md5(static::toScript($extra_commands, $user)), 0, 8); } - public static function toScript(array $extra_commands = []): string + public static function toScript(array $extra_commands = [], string $user = 'admin'): string { $lines = ['#!/usr/bin/env bash', 'set -euo pipefail', '']; - foreach (static::steps() as $step) { + foreach (static::steps($user) as $step) { $lines[] = "echo '==> {$step['label']}...'"; foreach ($step['commands'] as $command) { $lines[] = $command; diff --git a/app/Support/ProvisioningPipeline.php b/app/Support/ProvisioningPipeline.php index 34df7d9..fd58f9e 100644 --- a/app/Support/ProvisioningPipeline.php +++ b/app/Support/ProvisioningPipeline.php @@ -4,7 +4,7 @@ class ProvisioningPipeline { - public static function steps(): array + protected static function steps(): array { return [ 'baseSystem' => [ @@ -50,24 +50,12 @@ public static function steps(): array 'sudo apt-get install -y nodejs', ], ], - // 'phpstorm' => [ - // 'label' => 'Installing PhpStorm remote dev backend', - // 'commands' => [ - // <<<'BASH' - // PHPSTORM_URL=$(curl -fsSL 'https://data.services.jetbrains.com/products?code=PS&release.type=release&_p=1&_n=1' \ - // | jq -r '.[0].releases[0].downloads.linuxARM64.link') - // curl -fsSL "$PHPSTORM_URL" | sudo tar xz -C /opt - // sudo mv /opt/PhpStorm-* /opt/phpstorm - // sudo -H -u admin /opt/phpstorm/bin/remote-dev-server.sh registerBackendLocationForGateway - // BASH, - // ], - // ], 'claudeCode' => [ 'label' => 'Installing Claude Code', 'commands' => [ <<<'BASH' for attempt in 1 2 3; do - if sudo -H -u admin bash -c "curl -fsSL https://claude.ai/install.sh | bash"; then + if sudo -H -u CLAVE_USER bash -c "curl -fsSL https://claude.ai/install.sh | bash"; then break elif [ "$attempt" -lt 3 ]; then echo "Install attempt $attempt failed, retrying in 30s..." @@ -78,8 +66,8 @@ public static function steps(): array fi done BASH, - 'echo \'export PATH="$HOME/.local/bin:$PATH"\' >> /home/admin/.bashrc', - 'sudo -H -u admin bash -l -c "claude --version"', + 'echo \'export PATH="$HOME/.local/bin:$PATH"\' >> CLAVE_HOME/.bashrc', + 'sudo -H -u CLAVE_USER bash -l -c "claude --version"', ], ], 'claveProxy' => [ @@ -90,7 +78,7 @@ public static function steps(): array #!/usr/bin/env bash set -euo pipefail - SOCKET="${CLAVE_PROXY_SOCKET:-/home/admin/.clave/proxy.sock}" + SOCKET="${CLAVE_PROXY_SOCKET:-CLAVE_HOME/.clave/proxy.sock}" CMD="$(basename "$0")" if [[ "$CMD" == "clave-exec" ]]; then @@ -116,9 +104,9 @@ public static function steps(): array SCRIPT BASH, 'sudo chmod +x /usr/local/bin/clave-exec', - 'sudo mkdir -p /home/admin/.clave/shims', - 'sudo chown -R admin:admin /home/admin/.clave', - 'echo \'export PATH="/home/admin/.clave/shims:$PATH"\' | sudo tee /etc/profile.d/clave-shims.sh', + 'sudo mkdir -p CLAVE_HOME/.clave/shims', + 'sudo chown -R CLAVE_USER:CLAVE_USER CLAVE_HOME/.clave', + 'echo \'export PATH="CLAVE_HOME/.clave/shims:$PATH"\' | sudo tee /etc/profile.d/clave-shims.sh', ], ], 'git' => [ @@ -132,7 +120,7 @@ public static function steps(): array 'label' => 'Creating project directories', 'commands' => [ 'sudo mkdir -p /srv/project', - 'sudo chown -R admin:admin /srv/project', + 'sudo chown -R CLAVE_USER:CLAVE_USER /srv/project', ], ], 'virtiofsMounts' => [ @@ -144,19 +132,19 @@ public static function steps(): array ]; } - public static function hash(array $extra_commands = []): string + public static function hash(array $extra_commands = [], string $user = 'admin'): string { - return substr(md5(static::toScript($extra_commands)), 0, 8); + return substr(md5(static::toScript($extra_commands, $user)), 0, 8); } - public static function toScript(array $extra_commands = []): string + public static function toScript(array $extra_commands = [], string $user = 'admin'): string { $lines = ['#!/usr/bin/env bash', 'set -euo pipefail', '']; foreach (static::steps() as $step) { $lines[] = "echo '==> {$step['label']}...'"; foreach ($step['commands'] as $command) { - $lines[] = $command; + $lines[] = str_replace(['CLAVE_USER', 'CLAVE_HOME'], [$user, "/home/{$user}"], $command); } $lines[] = ''; } diff --git a/tests/Feature/Commands/ProvisionCommandTest.php b/tests/Feature/Commands/ProvisionCommandTest.php index bcfcdf9..9c88489 100644 --- a/tests/Feature/Commands/ProvisionCommandTest.php +++ b/tests/Feature/Commands/ProvisionCommandTest.php @@ -15,6 +15,7 @@ function fakeProvisionProcesses(array $vm_list): void beforeEach(function() { $mock_ssh = Mockery::mock(SshExecutor::class); + $mock_ssh->shouldReceive('setUser')->andReturnSelf(); $mock_ssh->shouldReceive('usePassword')->andReturnSelf(); $mock_ssh->shouldReceive('setHost')->andReturnSelf(); $mock_ssh->shouldReceive('test')->andReturn(true); @@ -29,12 +30,12 @@ function fakeProvisionProcesses(array $vm_list): void ['Name' => 'clave-base-old11111'], ['Name' => 'clave-session-xyz'], ]); - + $this->artisan('provision', [ '--base-vm' => 'clave-base-abc12345', '--force' => true, ]); - + Process::assertNotRan(fn(PendingProcess $p) => $p->command === ['tart', 'stop', 'clave-base']); Process::assertNotRan(fn(PendingProcess $p) => $p->command === ['tart', 'delete', 'clave-base']); Process::assertNotRan(fn(PendingProcess $p) => $p->command === ['tart', 'stop', 'clave-base-old11111']); @@ -47,11 +48,11 @@ function fakeProvisionProcesses(array $vm_list): void fakeProvisionProcesses([ ['Name' => 'clave-base-abc12345'], ]); - + $this->artisan('provision', [ '--base-vm' => 'clave-base-abc12345', '--force' => true, ]); - + Process::assertRan(fn(PendingProcess $p) => $p->command === ['tart', 'delete', 'clave-base-abc12345']); }); From 60178a4b3b4148a67d624a6fbc3ff3a844fe977d Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Fri, 3 Apr 2026 23:18:58 -0400 Subject: [PATCH 3/8] Refactor macOS virtiofs mounting to mount parent directory directly instead of using symlinks and add validation to prevent mounting home or root directories --- app/Pipelines/Steps/BootVm.php | 37 +++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/app/Pipelines/Steps/BootVm.php b/app/Pipelines/Steps/BootVm.php index 2fdaebb..e139ecf 100644 --- a/app/Pipelines/Steps/BootVm.php +++ b/app/Pipelines/Steps/BootVm.php @@ -9,6 +9,7 @@ use Closure; use RuntimeException; use Throwable; +use function App\home_path; class BootVm extends Step { @@ -24,6 +25,14 @@ public function handle(SessionContext $context, Closure $next): mixed { $mount_path = $context->clone_path ?? $context->project_dir; + if ($context->project_config->os_type === OsType::MacOS) { + $mount_path = dirname($mount_path); + + if ($mount_path === home_path() || $mount_path === '/') { + $context->abort('Cannot mount your home directory or root directory. Move your project into a subdirectory first.'); + } + } + if ($context->project_config->user !== null) { $this->ssh->setUser($context->project_config->effectiveUser()); } @@ -61,9 +70,10 @@ protected function cleanupResumedVm(SessionContext $context): void $this->ssh->run("pkill -u {$user} -x node 2>/dev/null; true"); if ($context->project_config->os_type === OsType::MacOS) { - $home = $context->project_config->homeDir(); + $mount_path = $context->clone_path ?? $context->project_dir; + $parent_dir = escapeshellarg(dirname($mount_path)); $project_dir = escapeshellarg($context->project_dir); - $this->ssh->run("rm -f {$home}/project {$project_dir} 2>/dev/null; true"); + $this->ssh->run("umount {$parent_dir} 2>/dev/null; rm -rf {$project_dir} 2>/dev/null; true"); } else { $project_dir = escapeshellarg($context->project_dir); $this->ssh->run("sudo umount {$project_dir} 2>/dev/null; true"); @@ -144,17 +154,20 @@ protected function mountMacOsSharedDirectories(SessionContext $context, string $ $start = time(); $virtiofs_path = '/Volumes/My Shared Files'; - $home = $context->project_config->homeDir(); - $mount_point = "{$home}/project"; + $parent_dir = dirname($mount_path); $project_dir = $context->project_dir; - $parent_dir = escapeshellarg(dirname($project_dir)); while (time() - $start < $mount_timeout) { try { $this->ssh->run('test -d '.escapeshellarg($virtiofs_path)); - $this->ssh->run("rm -rf {$mount_point} && ln -sf ".escapeshellarg($virtiofs_path)." {$mount_point}"); - $this->ssh->run("mkdir -p {$parent_dir}"); - $this->ssh->run('rm -rf '.escapeshellarg($project_dir)." && ln -sf {$mount_point} ".escapeshellarg($project_dir)); + $this->ssh->run('sudo umount '.escapeshellarg($virtiofs_path)); + $this->ssh->run('mkdir -p '.escapeshellarg($parent_dir)); + $this->ssh->run('mount_virtiofs com.apple.virtio-fs.automount '.escapeshellarg($parent_dir)); + + if ($context->clone_path) { + $this->ssh->run('mkdir -p '.escapeshellarg(dirname($project_dir))); + $this->ssh->run('rm -rf '.escapeshellarg($project_dir).' && ln -sf '.escapeshellarg($context->clone_path).' '.escapeshellarg($project_dir)); + } return; } catch (Throwable) { @@ -167,9 +180,9 @@ protected function mountMacOsSharedDirectories(SessionContext $context, string $ try { $result = $this->ssh->run(implode("\n", [ 'echo "=== VirtioFS mounts ==="', - 'ls -la "/Volumes/My Shared Files/" 2>&1 || echo "(not mounted)"', - 'echo "=== current mounts ==="', - 'mount 2>&1', + 'mount 2>&1 | grep virtiofs || echo "(no virtiofs mounts)"', + 'echo "=== /Volumes ==="', + 'ls -la "/Volumes/" 2>&1', 'true', ])); $diag = $result->output(); @@ -177,7 +190,7 @@ protected function mountMacOsSharedDirectories(SessionContext $context, string $ } throw new RuntimeException( - "Timed out after {$mount_timeout}s waiting for VirtioFS mount at '{$virtiofs_path}' on VM '{$context->vm_name}'" + "Timed out after {$mount_timeout}s waiting for VirtioFS mount on VM '{$context->vm_name}'" .($diag ? "\nDiagnostics:\n{$diag}" : '') ); } From eb2e168c6ceae165a12735de4e0f6651ac39efa9 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Fri, 3 Apr 2026 23:30:33 -0400 Subject: [PATCH 4/8] Improve macOS virtiofs mount validation and error messaging --- app/Pipelines/Steps/BootVm.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/Pipelines/Steps/BootVm.php b/app/Pipelines/Steps/BootVm.php index e139ecf..2b7689b 100644 --- a/app/Pipelines/Steps/BootVm.php +++ b/app/Pipelines/Steps/BootVm.php @@ -4,12 +4,12 @@ use App\Data\OsType; use App\Data\SessionContext; +use function App\home_path; use App\Support\SshExecutor; use App\Support\TartManager; use Closure; use RuntimeException; use Throwable; -use function App\home_path; class BootVm extends Step { @@ -29,7 +29,10 @@ public function handle(SessionContext $context, Closure $next): mixed $mount_path = dirname($mount_path); if ($mount_path === home_path() || $mount_path === '/') { - $context->abort('Cannot mount your home directory or root directory. Move your project into a subdirectory first.'); + $context->abort( + "macOS VMs require the project to be at least two levels below the home directory.\n" + ."Project is directly inside '{$mount_path}'." + ); } } @@ -163,7 +166,8 @@ protected function mountMacOsSharedDirectories(SessionContext $context, string $ $this->ssh->run('sudo umount '.escapeshellarg($virtiofs_path)); $this->ssh->run('mkdir -p '.escapeshellarg($parent_dir)); $this->ssh->run('mount_virtiofs com.apple.virtio-fs.automount '.escapeshellarg($parent_dir)); - + $this->ssh->run('test -d '.escapeshellarg($mount_path)); + if ($context->clone_path) { $this->ssh->run('mkdir -p '.escapeshellarg(dirname($project_dir))); $this->ssh->run('rm -rf '.escapeshellarg($project_dir).' && ln -sf '.escapeshellarg($context->clone_path).' '.escapeshellarg($project_dir)); From f013b7e9f89866fd1dc6681a2bf00d30ece35a18 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Sat, 4 Apr 2026 01:33:28 -0400 Subject: [PATCH 5/8] Add session persistence --- app/Data/SessionContext.php | 2 + app/Logging/PostRunLogger.php | 42 +++++++++++++ app/Pipelines/Steps/BootVm.php | 61 +++++++++++++++---- app/Pipelines/Steps/ResolveVm.php | 20 ++++-- app/Prompts/ChecklistItem.php | 37 +++++------ app/Providers/AppServiceProvider.php | 6 ++ app/Support/SessionTeardown.php | 22 ++++++- app/Support/TartManager.php | 9 ++- .../Feature/Pipelines/Steps/ResolveVmTest.php | 2 +- tests/Feature/Services/TartManagerTest.php | 8 +-- 10 files changed, 165 insertions(+), 44 deletions(-) create mode 100644 app/Logging/PostRunLogger.php diff --git a/app/Data/SessionContext.php b/app/Data/SessionContext.php index ee96acb..7b3465a 100644 --- a/app/Data/SessionContext.php +++ b/app/Data/SessionContext.php @@ -32,6 +32,8 @@ class SessionContext public ?string $proxy_socket_path = null; public mixed $proxy_daemon_process = null; + + public mixed $vm_process = null; public bool $resumed = false; diff --git a/app/Logging/PostRunLogger.php b/app/Logging/PostRunLogger.php new file mode 100644 index 0000000..91116d8 --- /dev/null +++ b/app/Logging/PostRunLogger.php @@ -0,0 +1,42 @@ +logs[] = [$level, $message]; + } + + public function dump(): void + { + if ([] === $this->logs) { + return; + } + + heading('Session Logs'); + + foreach ($this->logs as $log) { + [$level, $message] = $log; + $line = "[{$level}] {$message}"; + + match ($level) { + LogLevel::EMERGENCY, LogLevel::ALERT, LogLevel::CRITICAL, LogLevel::ERROR => error($line), + LogLevel::WARNING => warning($line), + default => info($line), + }; + } + + $this->logs = []; + } +} diff --git a/app/Pipelines/Steps/BootVm.php b/app/Pipelines/Steps/BootVm.php index 2b7689b..daac379 100644 --- a/app/Pipelines/Steps/BootVm.php +++ b/app/Pipelines/Steps/BootVm.php @@ -4,12 +4,12 @@ use App\Data\OsType; use App\Data\SessionContext; -use function App\home_path; use App\Support\SshExecutor; use App\Support\TartManager; use Closure; use RuntimeException; use Throwable; +use function App\home_path; class BootVm extends Step { @@ -40,14 +40,51 @@ public function handle(SessionContext $context, Closure $next): mixed $this->ssh->setUser($context->project_config->effectiveUser()); } + try { + $this->bootVm($context, $mount_path); + } catch (RuntimeException $e) { + if (! $context->resumed) { + throw $e; + } + + $this->checklist('Resume failed, stopping VM and retrying...') + ->run(function() use ($context) { + $this->tart->stop($context->vm_name); + $context->vm_process = null; + $context->vm_ip = null; + $context->resumed = false; + }); + + $this->bootVm($context, $mount_path); + } + + if ($context->resumed) { + $this->checklist('Cleaning up stale processes...') + ->run(fn() => $this->cleanupResumedVm($context)); + } + + $this->checklist('Mounting shared directories...') + ->run(fn() => $this->mountSharedDirectories($context)); + + return $next($context); + } + + protected function bootVm(SessionContext $context, string $mount_path): void + { $boot_label = $context->resumed ? 'Resuming' : 'Booting'; $this->checklist("{$boot_label} VM '{$context->vm_name}'...") - ->run(fn() => $this->tart->runBackground($context->vm_name, [$mount_path])); + ->run(function() use ($context, $mount_path) { + $context->vm_process = $this->tart->runBackground($context->vm_name, [$mount_path]); + }); $this->ssh->usePassword(config('clave.ssh.password')); $this->checklist('Waiting for VM IP address...') ->run(function() use ($context) { + if ($context->vm_process && ! $context->vm_process->running()) { + $result = $context->vm_process->wait(); + throw new RuntimeException('tart run exited early: '.trim($result->errorOutput() ?: $result->output() ?: 'no output')); + } $ip = $this->tart->ip($context->vm_name, $this->timeout); $context->vm_ip = $ip; $this->ssh->setHost($ip); @@ -55,16 +92,6 @@ public function handle(SessionContext $context, Closure $next): mixed $this->checklist('Waiting for SSH...') ->run(fn() => $this->waitForSsh($context)); - - if ($context->resumed) { - $this->checklist('Cleaning up stale processes...') - ->run(fn() => $this->cleanupResumedVm($context)); - } - - $this->checklist('Mounting shared directories...') - ->run(fn() => $this->mountSharedDirectories($context)); - - return $next($context); } protected function cleanupResumedVm(SessionContext $context): void @@ -167,7 +194,7 @@ protected function mountMacOsSharedDirectories(SessionContext $context, string $ $this->ssh->run('mkdir -p '.escapeshellarg($parent_dir)); $this->ssh->run('mount_virtiofs com.apple.virtio-fs.automount '.escapeshellarg($parent_dir)); $this->ssh->run('test -d '.escapeshellarg($mount_path)); - + if ($context->clone_path) { $this->ssh->run('mkdir -p '.escapeshellarg(dirname($project_dir))); $this->ssh->run('rm -rf '.escapeshellarg($project_dir).' && ln -sf '.escapeshellarg($context->clone_path).' '.escapeshellarg($project_dir)); @@ -215,6 +242,14 @@ protected function waitForSsh(SessionContext $context): void $start = time(); while (time() - $start < $this->timeout) { + if ($context->vm_process && ! $context->vm_process->running()) { + $result = $context->vm_process->wait(); + $output = trim($result->errorOutput() ?: $result->output() ?: 'no output'); + throw new RuntimeException( + "tart run process exited during SSH wait for VM '{$context->vm_name}': {$output}" + ); + } + if ($this->ssh->test()) { return; } diff --git a/app/Pipelines/Steps/ResolveVm.php b/app/Pipelines/Steps/ResolveVm.php index b92996c..65c3334 100644 --- a/app/Pipelines/Steps/ResolveVm.php +++ b/app/Pipelines/Steps/ResolveVm.php @@ -3,8 +3,8 @@ namespace App\Pipelines\Steps; use App\Data\SessionContext; -use function App\user_config_path; use App\Support\TartManager; +use function App\user_config_path; use Closure; use Illuminate\Filesystem\Filesystem; @@ -89,10 +89,20 @@ protected function handleRunning(SessionContext $context, string $base_vm): void protected function handleStopped(SessionContext $context, string $vm_name, string $base_vm): void { - $this->checklist("Removing stopped VM '{$vm_name}'...") - ->run(fn() => $this->tart->delete($vm_name)); - - $this->cloneFresh($context, $vm_name, $base_vm); + $metadata = $this->readMetadata($vm_name); + + if (($metadata['base_vm'] ?? null) !== $base_vm) { + $this->checklist('Base VM changed, replacing stopped VM...') + ->run(fn() => $this->tart->delete($vm_name)); + + $this->cloneFresh($context, $vm_name, $base_vm); + return; + } + + $this->checklist("Booting stopped VM '{$vm_name}'...") + ->run(fn() => null); + + $context->vm_name = $vm_name; } protected function cloneFresh(SessionContext $context, string $vm_name, string $base_vm): void diff --git a/app/Prompts/ChecklistItem.php b/app/Prompts/ChecklistItem.php index 1885ae5..dc9bfb1 100644 --- a/app/Prompts/ChecklistItem.php +++ b/app/Prompts/ChecklistItem.php @@ -3,6 +3,7 @@ namespace App\Prompts; use Laravel\Prompts\Prompt; +use Throwable; class ChecklistItem extends Prompt { @@ -11,33 +12,31 @@ class ChecklistItem extends Prompt public function __construct( protected string $title, protected ?string $item = null, - protected bool $complete = false, ) { } - public function item(string $item, bool $complete = false): static + public function item(string $item): static { - return new static($this->title, $item, $complete); + return new static($this->title, $item); } public function run(callable $callback): mixed { $this->render(); - $result = $callback(); - - $this->complete(); - - return $result; - } - - public function complete(): static - { - $this->complete = true; + try { + $result = $callback(); + } catch (Throwable $exception) { + $this->state = 'error'; + $this->render(); + + throw $exception; + } + $this->state = 'complete'; $this->render(); - return $this; + return $result; } public function display(): void @@ -58,7 +57,7 @@ public function prompt(): bool public function value(): bool { - return $this->complete; + return $this->state === 'complete'; } protected function getRenderer(): callable @@ -74,8 +73,12 @@ protected function getRenderer(): callable } if ($this->item) { - $bullet = $item->complete ? '■' : '□'; - $output .= $this->yellow(" {$bullet} {$item->item}"); + $bullet = match ($this->state) { + 'complete' => $this->yellow('■'), + 'error' => $this->red('▣'), + default => $this->yellow('□'), + }; + $output .= ' '.$bullet.' '.$this->yellow($item->item); $output .= PHP_EOL; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8797541..d02edbd 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,9 @@ namespace App\Providers; +use App\Logging\PostRunLogger; use App\Pipelines\SessionSetup; +use Psr\Log\LoggerInterface; use function App\user_config_path; use App\Pipelines\Steps\CheckClaudeAuthentication; use App\Pipelines\Steps\DetectRecipe; @@ -48,5 +50,9 @@ public function register(): void $this->app->singleton(DetectRecipe::class); $this->app->singleton(EnsureVmExists::class); $this->app->singleton(CheckClaudeAuthentication::class); + + $this->app->singleton(PostRunLogger::class); + $this->app->instance(LoggerInterface::class, $this->app->make(PostRunLogger::class)); + $this->app->instance('log', $this->app->make(PostRunLogger::class)); } } diff --git a/app/Support/SessionTeardown.php b/app/Support/SessionTeardown.php index 7e4dbbe..1a6d6be 100644 --- a/app/Support/SessionTeardown.php +++ b/app/Support/SessionTeardown.php @@ -2,11 +2,12 @@ namespace App\Support; +use App\Logging\PostRunLogger; +use function App\checklist; use App\Data\OnExit; use App\Data\SessionContext; -use App\Prompts\ChecklistItem; -use function App\checklist; use function App\heading; +use App\Prompts\ChecklistItem; use function Laravel\Prompts\select; use function Laravel\Prompts\warning; @@ -38,6 +39,8 @@ public function __invoke(SessionContext $context): void if ($context->upgrade_version_available) { warning("A new version of Clave is available (v{$context->upgrade_version_available}). Update with:\n\n clave update"); } + + app(PostRunLogger::class)->dump(); } protected function checklist(string $item): ChecklistItem @@ -80,6 +83,21 @@ protected function suspendOrDeleteVm(SessionContext $context): void } else { $this->checklist('Suspending VM...') ->run(fn() => $this->tart->suspend($context->vm_name)); + + $this->checklist('Waiting for VM process to exit...') + ->run(function() use ($context) { + if (! $context->vm_process) { + return; + } + + $start = time(); + while ($context->vm_process->running() && time() - $start < 10) { + usleep(250_000); + } + if ($context->vm_process->running()) { + $context->vm_process->stop(); + } + }); } } diff --git a/app/Support/TartManager.php b/app/Support/TartManager.php index e6f6b21..e75fb10 100644 --- a/app/Support/TartManager.php +++ b/app/Support/TartManager.php @@ -25,7 +25,7 @@ public function runBackground(string $name, array $dirs = [], bool $no_graphics $args[] = $path; } - return Process::start($args); + return Process::forever()->start($args); } public function suspend(string $name): mixed @@ -84,7 +84,12 @@ public function list(): array { $result = Process::run(['tart', 'list', '--format', 'json'])->throw(); - return json_decode($result->output(), true) ?? []; + $vms = json_decode($result->output(), true) ?? []; + + return array_map( + fn(array $vm) => array_change_key_case($vm, CASE_LOWER), + $vms, + ); } public function randomizeMac(string $name): mixed diff --git a/tests/Feature/Pipelines/Steps/ResolveVmTest.php b/tests/Feature/Pipelines/Steps/ResolveVmTest.php index 24f8130..6b6b476 100644 --- a/tests/Feature/Pipelines/Steps/ResolveVmTest.php +++ b/tests/Feature/Pipelines/Steps/ResolveVmTest.php @@ -17,7 +17,7 @@ function makeResolveVmContext(bool $fresh = false): SessionContext function fakeListOutput(string $vm_name, string $state): string { - return json_encode([['name' => $vm_name, 'state' => $state]]); + return json_encode([['Name' => $vm_name, 'State' => $state]]); } function expectedVmName(): string diff --git a/tests/Feature/Services/TartManagerTest.php b/tests/Feature/Services/TartManagerTest.php index 17f1ae2..2958681 100644 --- a/tests/Feature/Services/TartManagerTest.php +++ b/tests/Feature/Services/TartManagerTest.php @@ -98,7 +98,7 @@ test('list returns parsed json', function() { Process::fake(fn(PendingProcess $process) => match (true) { - $process->command === ['tart', 'list', '--format', 'json'] => Process::result(output: '[{"name":"clave-base"}]'), + $process->command === ['tart', 'list', '--format', 'json'] => Process::result(output: '[{"Name":"clave-base"}]'), default => Process::result(), }); @@ -117,8 +117,8 @@ Process::fake(fn(PendingProcess $process) => match (true) { $process->command === ['tart', 'list', '--format', 'json'] => Process::result( output: json_encode([ - ['name' => 'clave-abc', 'state' => 'suspended'], - ['name' => 'other-vm', 'state' => 'running'], + ['Name' => 'clave-abc', 'State' => 'suspended'], + ['Name' => 'other-vm', 'State' => 'running'], ]) ), default => Process::result(), @@ -130,7 +130,7 @@ test('state returns null when vm not found', function() { Process::fake(fn(PendingProcess $process) => match (true) { $process->command === ['tart', 'list', '--format', 'json'] => Process::result( - output: json_encode([['name' => 'other-vm', 'state' => 'running']]) + output: json_encode([['Name' => 'other-vm', 'State' => 'running']]) ), default => Process::result(), }); From a9bf4e64270aab0d01cf53d3a6d2005dfdbfe327 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Sat, 4 Apr 2026 02:00:13 -0400 Subject: [PATCH 6/8] Add ephemeral mode and tunnel support Introduce ephemeral VMs that are created fresh and deleted on exit, alongside persistent mode (default) which suspends VMs for resume. Add --ephemeral CLI flag and `ephemeral` config option. Implement session-scoped VM naming for ephemeral sessions. Add `tunnels` config to reverse-tunnel host ports into the VM. Skip auth injection in persistent mode since it persists from initial setup. Skip redundant config setup when resuming a session. Update README with macOS guest support, persistent vs ephemeral documentation, and new configuration options. --- README.md | 95 ++++++++++++++++++- app/Agents/ClaudeCode.php | 14 +-- app/Commands/DefaultCommand.php | 17 ++-- app/Data/ProjectConfig.php | 4 + .../Steps/CheckClaudeAuthentication.php | 4 + app/Pipelines/Steps/LoadProjectConfig.php | 15 ++- app/Pipelines/Steps/ResolveVm.php | 29 ++++-- app/Pipelines/Steps/SetupClaudeCode.php | 33 ++++++- app/Prompts/ChecklistItem.php | 7 +- .../Feature/Pipelines/Steps/ResolveVmTest.php | 27 ++++++ tests/Unit/Dto/ProjectConfigTest.php | 50 +++++++++- 11 files changed, 267 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 543ac2b..5e782fe 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,25 @@ curl -fsSL https://clave.run | sh - macOS with [Tart](https://tart.run/) installed - A [Claude Code](https://claude.ai/code) account +## VM Guest OS Support + +Clave supports both **Linux** and **macOS** guest VMs. The guest OS is auto-detected from the base image name — images +matching `macos`, `sequoia`, `ventura`, `sonoma`, `monterey`, `bigsur`, or `darwin` are treated as macOS guests; +everything else defaults to Linux. + +macOS guests use a separate provisioning pipeline that installs dependencies via [pkgx](https://pkgx.sh) rather than +apt. To use a macOS guest, set the `base_image` in your `.clave.json`: + +```json +{ + "base_image": "ghcr.io/cirruslabs/macos-sequoia-base:latest", + "user": "admin" +} +``` + +> **Note:** macOS guests typically use `admin` as the default user. Set the `user` option in `.clave.json` to match +> your image's user account. + ## Usage ### Working Directory Mode @@ -25,10 +44,16 @@ cd /path/to/your/project clave ``` -Clave will spin up a fresh VM, mount your current working directory into the VM, and launch an interactive Claude Code session -inside it. When you exit, the VM and clone are torn down automatically. This lets you work on the local project similar to running -`claude`, but without having to worry about the agent accidentally deleting your home directory or getting tricked into shipping -your SSH private key somewhere. +Clave will spin up a VM, mount your current working directory into it, and launch an interactive Claude Code session. +When you exit, the VM is **suspended** automatically, so subsequent runs resume where you left off — no re-cloning or +re-provisioning needed. This lets you work on the local project similar to running `claude`, but without having to worry +about the agent accidentally deleting your home directory or getting tricked into shipping your SSH private key somewhere. + +To discard suspended state and start clean: + +```shell +clave --fresh +``` ### Isolate Mode @@ -39,7 +64,7 @@ cd /path/to/your/project clave --isolate ``` -Before spinning up a fresh VM, clave will create a local git clone of your working directory. (This is similarly efficient as a +Before spinning up a VM, clave will create a local git clone of your working directory. (This is similarly efficient as a worktree, but has a copy of your full git history.) Clave will then mount that clone into the VM, keeping any changes made inside the VM completely isolated from your project until you quit. Once you quit, you will be prompted to decide what to do with the changes before the clone is cleaned up. @@ -76,6 +101,10 @@ Add a `.clave.json` file to your project root to customize VM setup for your pro | `provision` | string[] | Bash commands to run during VM provisioning | | `env` | string[] | Environment variable names to pass through from your host into the VM | | `shims` | string[] | Host commands to proxy into the VM (see [Host Proxy Shims](#host-proxy-shims)) | +| `model` | string | Claude model to use (e.g. `opus`, `sonnet`) | +| `user` | string | SSH user for the VM. Defaults to `admin`. Useful for macOS guest images | +| `ephemeral` | boolean | Use ephemeral VMs that are deleted on exit (default `false`) | +| `tunnels` | integer[]| Host ports to reverse-tunnel into the VM (e.g. `[5432, 9222]`) | See the [`tart` documentation](https://tart.run/quick-start/#vm-images) for a list of available base images. @@ -114,6 +143,62 @@ Clave always forwards these environment variables into the VM when present on th Any additional variables listed in `.clave.json`'s `env` array are forwarded as well. +## Persistent vs Ephemeral Mode + +### Persistent Mode (default) + +By default, Clave **suspends** VMs when you exit instead of destroying them. The next time you run `clave` in the same +project, it resumes the suspended VM — skipping the clone and boot steps entirely. This makes subsequent sessions start +significantly faster. + +In persistent mode, Claude Code authentication and configuration **persist inside the VM**. On first boot, Clave writes +initial config files (onboarding bypass, settings) but does not inject auth credentials — you authenticate inside the VM +on first use (e.g. `claude login`), and that auth carries over to subsequent sessions. MCP servers should be configured +inside the VM using `claude mcp add`. + +If a resume fails (e.g. a suspended VM becomes corrupted), Clave automatically stops the VM and retries with a fresh +boot. If the base VM configuration changes (new image, updated provisioning), Clave replaces the suspended VM with a +fresh clone. + +Use `clave --fresh` to force a clean start, discarding any suspended state. + +If you start a second session while one is already running for the same project, Clave creates an ephemeral VM that is +fully torn down on exit. + +### Ephemeral Mode + +Ephemeral mode creates a fresh VM every session and deletes it on exit. Auth and configs are injected from the host +each run, similar to how Clave originally worked. + +Enable ephemeral mode via CLI flag or `.clave.json`: + +```shell +clave --ephemeral +``` + +```json +{ + "ephemeral": true +} +``` + +In ephemeral mode, Clave requires host-side authentication (via environment variable or stored token) and will prompt +you to set one up if missing. MCP servers configured in your host `~/.claude.json` are automatically forwarded into +the VM via SSH tunnels. + +### Host Tunnels + +Use the `tunnels` option to reverse-tunnel host ports into the VM. This is useful for host-side services like databases +or MCP servers that need to be reachable from inside the VM: + +```json +{ + "tunnels": [5432, 9222] +} +``` + +These ports are tunneled in both persistent and ephemeral modes. + ## Global Configuration Clave reads the following environment variables for global defaults: diff --git a/app/Agents/ClaudeCode.php b/app/Agents/ClaudeCode.php index f7d47d7..6b697e1 100644 --- a/app/Agents/ClaudeCode.php +++ b/app/Agents/ClaudeCode.php @@ -68,12 +68,14 @@ protected function env(SessionContext $context): string } } - if ($resolved = $this->auth->resolve()) { - $env_var = match ($resolved['type']) { - 'api_key' => 'ANTHROPIC_API_KEY', - 'oauth' => 'CLAUDE_CODE_OAUTH_TOKEN', - }; - $env[] = $env_var.'='.escapeshellarg($resolved['value']); + if ($context->ephemeral) { + if ($resolved = $this->auth->resolve()) { + $env_var = match ($resolved['type']) { + 'api_key' => 'ANTHROPIC_API_KEY', + 'oauth' => 'CLAUDE_CODE_OAUTH_TOKEN', + }; + $env[] = $env_var.'='.escapeshellarg($resolved['value']); + } } if ($context->ide) { diff --git a/app/Commands/DefaultCommand.php b/app/Commands/DefaultCommand.php index 9b9ea25..4750464 100644 --- a/app/Commands/DefaultCommand.php +++ b/app/Commands/DefaultCommand.php @@ -3,17 +3,17 @@ namespace App\Commands; use App\Agents\ClaudeCode; -use function App\clear_screen; use App\Data\OnExit; use App\Data\SessionContext; use App\Exceptions\AbortedPipelineException; -use function App\heading; use App\Pipelines\SessionSetup; use App\Support\SessionTeardown; use Illuminate\Support\Str; use Laravel\Prompts\Concerns\Colors; -use function Laravel\Prompts\error; use LaravelZero\Framework\Commands\Command; +use function App\clear_screen; +use function App\heading; +use function Laravel\Prompts\error; class DefaultCommand extends Command { @@ -22,7 +22,8 @@ class DefaultCommand extends Command protected $signature = 'default {--on-exit= : Action on exit: keep, merge, discard} {--isolate : Fork the repo into an isolated clone for this session} - {--fresh : Start with a fresh VM, discarding any suspended state}'; + {--fresh : Start with a fresh VM, discarding any suspended state} + {--ephemeral : Use an ephemeral VM that is deleted on exit}'; protected $description = 'Start a Clave session in the current project'; @@ -41,6 +42,10 @@ public function handle( $version = config('app.version'); $context = $this->newContext(); + if ($this->option('ephemeral')) { + $context->ephemeral = true; + } + heading("Clave {$version} session {$this->cyan($context->session_id)} in project {$this->cyan($context->project_name)}"); $this->trap([SIGINT, SIGTERM], static fn() => $teardown($context)); @@ -69,7 +74,7 @@ public function handle( protected function newContext(): SessionContext { $project_dir = getcwd(); - + return new SessionContext( session_id: Str::random(8), project_name: basename($project_dir), @@ -81,7 +86,7 @@ protected function newContext(): SessionContext claude_flags: $this->parsePassthroughFlags(), ); } - + protected function parsePassthroughFlags(): array { // TODO: our initial implementation of this doesn't work diff --git a/app/Data/ProjectConfig.php b/app/Data/ProjectConfig.php index 42477e8..cc33142 100644 --- a/app/Data/ProjectConfig.php +++ b/app/Data/ProjectConfig.php @@ -33,6 +33,8 @@ public static function fromProjectDir(string $project_dir, Filesystem $fs): stat model: $data['model'] ?? null, shims: $data['shims'] ?? [], user: $data['user'] ?? null, + ephemeral: $data['ephemeral'] ?? false, + tunnels: isset($data['tunnels']) ? array_map('intval', $data['tunnels']) : [], ); } @@ -45,6 +47,8 @@ public function __construct( public readonly ?string $model = null, public readonly array $shims = [], public readonly ?string $user = null, + public readonly bool $ephemeral = false, + public readonly array $tunnels = [], ) { $this->os_type = $base_image !== null ? OsType::fromImage($base_image) diff --git a/app/Pipelines/Steps/CheckClaudeAuthentication.php b/app/Pipelines/Steps/CheckClaudeAuthentication.php index 667ffa1..6065dc7 100644 --- a/app/Pipelines/Steps/CheckClaudeAuthentication.php +++ b/app/Pipelines/Steps/CheckClaudeAuthentication.php @@ -16,6 +16,10 @@ public function __construct( public function handle(SessionContext $context, Closure $next): mixed { + if (! $context->ephemeral) { + return $next($context); + } + $has_auth = $this->checklist('Verifying Claude authentication...') ->run(fn() => $this->auth->hasAuth()); diff --git a/app/Pipelines/Steps/LoadProjectConfig.php b/app/Pipelines/Steps/LoadProjectConfig.php index 76166cb..9650b87 100644 --- a/app/Pipelines/Steps/LoadProjectConfig.php +++ b/app/Pipelines/Steps/LoadProjectConfig.php @@ -17,7 +17,20 @@ public function __construct( public function handle(SessionContext $context, Closure $next): mixed { $this->checklist('Loading project config...') - ->run(fn() => $context->project_config = ProjectConfig::fromProjectDir($context->project_dir, $this->fs)); + ->run(function() use ($context) { + $context->project_config = ProjectConfig::fromProjectDir($context->project_dir, $this->fs); + + if ($context->project_config->ephemeral) { + $context->ephemeral = true; + } + + if ($context->project_config->tunnels) { + $context->tunnel_ports = array_unique(array_merge( + $context->tunnel_ports, + $context->project_config->tunnels, + )); + } + }); return $next($context); } diff --git a/app/Pipelines/Steps/ResolveVm.php b/app/Pipelines/Steps/ResolveVm.php index 65c3334..3168520 100644 --- a/app/Pipelines/Steps/ResolveVm.php +++ b/app/Pipelines/Steps/ResolveVm.php @@ -4,9 +4,9 @@ use App\Data\SessionContext; use App\Support\TartManager; -use function App\user_config_path; use Closure; use Illuminate\Filesystem\Filesystem; +use function App\user_config_path; class ResolveVm extends Step { @@ -22,7 +22,9 @@ public function handle(SessionContext $context, Closure $next): mixed $state = $this->tart->state($vm_name); $base_vm = $context->project_config->baseVmName(); - if ($context->fresh) { + if ($context->ephemeral) { + $this->handleEphemeral($context, $base_vm); + } elseif ($context->fresh) { $this->handleFresh($context, $vm_name, $state, $base_vm); } elseif ($state === 'suspended') { $this->handleSuspended($context, $vm_name, $base_vm); @@ -37,6 +39,21 @@ public function handle(SessionContext $context, Closure $next): mixed return $next($context); } + protected function handleEphemeral(SessionContext $context, string $base_vm): void + { + $ephemeral_name = "clave-{$context->session_id}"; + + $this->checklist('Creating ephemeral VM...') + ->run(function() use ($ephemeral_name, $base_vm) { + $this->tart->clone($base_vm, $ephemeral_name); + $this->tart->randomizeMac($ephemeral_name); + }); + + $context->vm_name = $ephemeral_name; + + $this->configureVm($context, $ephemeral_name); + } + protected function handleFresh(SessionContext $context, string $vm_name, ?string $state, string $base_vm): void { if ($state !== null) { @@ -90,18 +107,18 @@ protected function handleRunning(SessionContext $context, string $base_vm): void protected function handleStopped(SessionContext $context, string $vm_name, string $base_vm): void { $metadata = $this->readMetadata($vm_name); - + if (($metadata['base_vm'] ?? null) !== $base_vm) { $this->checklist('Base VM changed, replacing stopped VM...') ->run(fn() => $this->tart->delete($vm_name)); - + $this->cloneFresh($context, $vm_name, $base_vm); return; } - + $this->checklist("Booting stopped VM '{$vm_name}'...") ->run(fn() => null); - + $context->vm_name = $vm_name; } diff --git a/app/Pipelines/Steps/SetupClaudeCode.php b/app/Pipelines/Steps/SetupClaudeCode.php index 9b478ac..0dda297 100644 --- a/app/Pipelines/Steps/SetupClaudeCode.php +++ b/app/Pipelines/Steps/SetupClaudeCode.php @@ -22,15 +22,28 @@ public function __construct( public function handle(SessionContext $context, Closure $next): mixed { + if (! $context->ephemeral && $context->resumed) { + if ($context->ide) { + $this->checklist('Updating IDE integration...') + ->run(fn() => $this->writeIdeLockFile($context)); + } + + return $next($context); + } + $this->checklist('Writing Claude Code config...') ->run(function() use ($context) { $config = $this->readConfig('.claude.json'); $settings = $this->readConfig('.claude/settings.json'); $md = $this->readConfig('.claude/CLAUDE.md'); - $this->writeConfigFiles($context, $config, $settings, $md, $this->auth->resolve()); + $auth = $context->ephemeral ? $this->auth->resolve() : null; + + $this->writeConfigFiles($context, $config, $settings, $md, $auth); - $context->tunnel_ports = array_unique(array_merge($context->tunnel_ports, $this->extractMcpPorts($config))); + if ($context->ephemeral) { + $context->tunnel_ports = array_unique(array_merge($context->tunnel_ports, $this->extractMcpPorts($config))); + } }); return $next($context); @@ -120,6 +133,22 @@ protected function writeConfigFiles(SessionContext $context, array $config, arra $this->ssh->run($command); } + protected function writeIdeLockFile(SessionContext $context): void + { + $b64 = $context->project_config->os_type->base64DecodeFlag(); + + $lock = json_encode(array_filter([ + 'ideName' => $context->ide->ide_name, + 'transport' => $context->ide->transport, + 'authToken' => $context->ide->auth_token, + 'workspaceFolders' => [$context->project_dir], + ], fn($v) => $v !== null), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $lock_encoded = base64_encode($lock); + $port = $context->ide->port; + + $this->ssh->run("mkdir -p ~/.claude/ide && echo {$lock_encoded} | base64 {$b64} > ~/.claude/ide/{$port}.lock"); + } + protected function extractMcpPorts(array $config): array { $mcp_servers = $config['mcpServers'] ?? []; diff --git a/app/Prompts/ChecklistItem.php b/app/Prompts/ChecklistItem.php index dc9bfb1..e5b0d48 100644 --- a/app/Prompts/ChecklistItem.php +++ b/app/Prompts/ChecklistItem.php @@ -78,7 +78,12 @@ protected function getRenderer(): callable 'error' => $this->red('▣'), default => $this->yellow('□'), }; - $output .= ' '.$bullet.' '.$this->yellow($item->item); + $message = match ($this->state) { + 'complete' => $this->yellow($item->item), + 'error' => $this->red($item->item), + default => $this->bold($this->yellow($item->item)), + }; + $output .= " {$bullet} {$message}"; $output .= PHP_EOL; } diff --git a/tests/Feature/Pipelines/Steps/ResolveVmTest.php b/tests/Feature/Pipelines/Steps/ResolveVmTest.php index 6b6b476..c84c8e6 100644 --- a/tests/Feature/Pipelines/Steps/ResolveVmTest.php +++ b/tests/Feature/Pipelines/Steps/ResolveVmTest.php @@ -164,6 +164,33 @@ function expectedVmName(): string Process::assertRan(fn(PendingProcess $p) => $p->command[1] === 'clone'); }); +test('explicit ephemeral mode always creates session-scoped VM', function() { + $vm_name = expectedVmName(); + + Process::fake(fn(PendingProcess $process) => match (true) { + $process->command === ['tart', 'list', '--format', 'json'] => Process::result(output: fakeListOutput($vm_name, 'suspended')), + default => Process::result(), + }); + + $step = app(ResolveVm::class); + $context = makeResolveVmContext(); + $context->ephemeral = true; + + $next_called = false; + $step->handle($context, function($ctx) use (&$next_called) { + $next_called = true; + + return $ctx; + }); + + expect($next_called)->toBeTrue(); + expect($context->vm_name)->toBe('clave-test-sess'); + expect($context->ephemeral)->toBeTrue(); + expect($context->resumed)->toBeFalse(); + + Process::assertRan(fn(PendingProcess $p) => $p->command[1] === 'clone'); +}); + test('replaces suspended VM when base_vm hash does not match', function() { $vm_name = expectedVmName(); $context = makeResolveVmContext(); diff --git a/tests/Unit/Dto/ProjectConfigTest.php b/tests/Unit/Dto/ProjectConfigTest.php index eb00a4a..8ec3500 100644 --- a/tests/Unit/Dto/ProjectConfigTest.php +++ b/tests/Unit/Dto/ProjectConfigTest.php @@ -158,6 +158,54 @@ base_image: 'ghcr.io/cirruslabs/macos-sequoia-base:latest', user: 'chris', ); - + expect($config->homeDir())->toBe('/Users/chris'); }); + +test('ephemeral defaults to false', function() { + $config = new ProjectConfig(); + + expect($config->ephemeral)->toBeFalse(); +}); + +test('ephemeral can be set to true', function() { + $config = new ProjectConfig(ephemeral: true); + + expect($config->ephemeral)->toBeTrue(); +}); + +test('tunnels defaults to empty array', function() { + $config = new ProjectConfig(); + + expect($config->tunnels)->toBe([]); +}); + +test('tunnels can be set', function() { + $config = new ProjectConfig(tunnels: [5432, 9222]); + + expect($config->tunnels)->toBe([5432, 9222]); +}); + +test('fromProjectDir parses ephemeral field', function() { + $fs = Mockery::mock(Filesystem::class); + $fs->shouldReceive('exists')->with('/path/to/project/.clave.json')->andReturn(true); + $fs->shouldReceive('get')->with('/path/to/project/.clave.json')->andReturn(json_encode([ + 'ephemeral' => true, + ])); + + $config = ProjectConfig::fromProjectDir('/path/to/project', $fs); + + expect($config->ephemeral)->toBeTrue(); +}); + +test('fromProjectDir parses tunnels field', function() { + $fs = Mockery::mock(Filesystem::class); + $fs->shouldReceive('exists')->with('/path/to/project/.clave.json')->andReturn(true); + $fs->shouldReceive('get')->with('/path/to/project/.clave.json')->andReturn(json_encode([ + 'tunnels' => [5432, 9222], + ])); + + $config = ProjectConfig::fromProjectDir('/path/to/project', $fs); + + expect($config->tunnels)->toBe([5432, 9222]); +}); From 1635abef7cf66eca14614ba478bc938fb8e844ad Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Mon, 20 Apr 2026 21:44:42 -0400 Subject: [PATCH 7/8] Add .clave.local.json support --- app/Data/ProjectConfig.php | 20 +++++++++++-- tests/Unit/Dto/ProjectConfigTest.php | 42 +++++++++++++++++++++------- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/app/Data/ProjectConfig.php b/app/Data/ProjectConfig.php index cc33142..df5005f 100644 --- a/app/Data/ProjectConfig.php +++ b/app/Data/ProjectConfig.php @@ -12,9 +12,7 @@ class ProjectConfig public static function fromProjectDir(string $project_dir, Filesystem $fs): static { - $path = $project_dir.'/.clave.json'; - - if (! $fs->exists($path)) { + if (! $path = static::findConfig($project_dir, $fs)) { return new static(); } @@ -38,6 +36,22 @@ public static function fromProjectDir(string $project_dir, Filesystem $fs): stat ); } + protected static function findConfig(string $project_dir, Filesystem $fs): ?string + { + $candidates = [ + $project_dir.'/.clave.local.json', + $project_dir.'/.clave.json', + ]; + + foreach ($candidates as $path) { + if ($fs->exists($path)) { + return $path; + } + } + + return null; + } + public function __construct( public readonly ?string $base_image = null, public readonly array $provision = [], diff --git a/tests/Unit/Dto/ProjectConfigTest.php b/tests/Unit/Dto/ProjectConfigTest.php index 8ec3500..f2c350a 100644 --- a/tests/Unit/Dto/ProjectConfigTest.php +++ b/tests/Unit/Dto/ProjectConfigTest.php @@ -24,8 +24,9 @@ expect($config->hasCustomizations())->toBeTrue(); }); -test('fromProjectDir returns defaults when no .clave.json exists', function() { +test('fromProjectDir returns defaults when no config files exist', function() { $fs = Mockery::mock(Filesystem::class); + $fs->shouldReceive('exists')->with('/path/to/project/.clave.local.json')->andReturn(false); $fs->shouldReceive('exists')->with('/path/to/project/.clave.json')->andReturn(false); $config = ProjectConfig::fromProjectDir('/path/to/project', $fs); @@ -37,6 +38,7 @@ test('fromProjectDir parses .clave.json with base_image', function() { $fs = Mockery::mock(Filesystem::class); + $fs->shouldReceive('exists')->with('/path/to/project/.clave.local.json')->andReturn(false); $fs->shouldReceive('exists')->with('/path/to/project/.clave.json')->andReturn(true); $fs->shouldReceive('get')->with('/path/to/project/.clave.json')->andReturn(json_encode([ 'base_image' => 'ghcr.io/custom/image:latest', @@ -50,6 +52,7 @@ test('fromProjectDir parses .clave.json with provision steps', function() { $fs = Mockery::mock(Filesystem::class); + $fs->shouldReceive('exists')->with('/path/to/project/.clave.local.json')->andReturn(false); $fs->shouldReceive('exists')->with('/path/to/project/.clave.json')->andReturn(true); $fs->shouldReceive('get')->with('/path/to/project/.clave.json')->andReturn(json_encode([ 'provision' => [ @@ -69,6 +72,7 @@ test('fromProjectDir parses full .clave.json', function() { $fs = Mockery::mock(Filesystem::class); + $fs->shouldReceive('exists')->with('/path/to/project/.clave.local.json')->andReturn(false); $fs->shouldReceive('exists')->with('/path/to/project/.clave.json')->andReturn(true); $fs->shouldReceive('get')->with('/path/to/project/.clave.json')->andReturn(json_encode([ 'base_image' => 'ghcr.io/custom/image:latest', @@ -88,6 +92,7 @@ test('fromProjectDir parses cpus and memory', function() { $fs = Mockery::mock(Filesystem::class); + $fs->shouldReceive('exists')->with('/path/to/project/.clave.local.json')->andReturn(false); $fs->shouldReceive('exists')->with('/path/to/project/.clave.json')->andReturn(true); $fs->shouldReceive('get')->with('/path/to/project/.clave.json')->andReturn(json_encode([ 'cpus' => 2, @@ -102,6 +107,7 @@ test('fromProjectDir handles invalid json gracefully', function() { $fs = Mockery::mock(Filesystem::class); + $fs->shouldReceive('exists')->with('/path/to/project/.clave.local.json')->andReturn(false); $fs->shouldReceive('exists')->with('/path/to/project/.clave.json')->andReturn(true); $fs->shouldReceive('get')->with('/path/to/project/.clave.json')->andReturn('not valid json'); @@ -137,6 +143,7 @@ test('fromProjectDir parses user field', function() { $fs = Mockery::mock(Filesystem::class); + $fs->shouldReceive('exists')->with('/path/to/project/.clave.local.json')->andReturn(false); $fs->shouldReceive('exists')->with('/path/to/project/.clave.json')->andReturn(true); $fs->shouldReceive('get')->with('/path/to/project/.clave.json')->andReturn(json_encode([ 'user' => 'chris', @@ -158,54 +165,69 @@ base_image: 'ghcr.io/cirruslabs/macos-sequoia-base:latest', user: 'chris', ); - + expect($config->homeDir())->toBe('/Users/chris'); }); test('ephemeral defaults to false', function() { $config = new ProjectConfig(); - + expect($config->ephemeral)->toBeFalse(); }); test('ephemeral can be set to true', function() { $config = new ProjectConfig(ephemeral: true); - + expect($config->ephemeral)->toBeTrue(); }); test('tunnels defaults to empty array', function() { $config = new ProjectConfig(); - + expect($config->tunnels)->toBe([]); }); test('tunnels can be set', function() { $config = new ProjectConfig(tunnels: [5432, 9222]); - + expect($config->tunnels)->toBe([5432, 9222]); }); test('fromProjectDir parses ephemeral field', function() { $fs = Mockery::mock(Filesystem::class); + $fs->shouldReceive('exists')->with('/path/to/project/.clave.local.json')->andReturn(false); $fs->shouldReceive('exists')->with('/path/to/project/.clave.json')->andReturn(true); $fs->shouldReceive('get')->with('/path/to/project/.clave.json')->andReturn(json_encode([ 'ephemeral' => true, ])); - + $config = ProjectConfig::fromProjectDir('/path/to/project', $fs); - + expect($config->ephemeral)->toBeTrue(); }); test('fromProjectDir parses tunnels field', function() { $fs = Mockery::mock(Filesystem::class); + $fs->shouldReceive('exists')->with('/path/to/project/.clave.local.json')->andReturn(false); $fs->shouldReceive('exists')->with('/path/to/project/.clave.json')->andReturn(true); $fs->shouldReceive('get')->with('/path/to/project/.clave.json')->andReturn(json_encode([ 'tunnels' => [5432, 9222], ])); - + $config = ProjectConfig::fromProjectDir('/path/to/project', $fs); - + expect($config->tunnels)->toBe([5432, 9222]); }); + +test('fromProjectDir uses .clave.local.json instead of .clave.json when both exist', function() { + $fs = Mockery::mock(Filesystem::class); + $fs->shouldReceive('exists')->with('/path/to/project/.clave.local.json')->andReturn(true); + $fs->shouldReceive('get')->with('/path/to/project/.clave.local.json')->andReturn(json_encode([ + 'cpus' => 16, + ])); + + $config = ProjectConfig::fromProjectDir('/path/to/project', $fs); + + expect($config->cpus)->toBe(16) + ->and($config->base_image)->toBeNull(); +}); From a31a7b02dfd4951c34245be39faa87aca7b3b517 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Mon, 20 Apr 2026 22:34:20 -0400 Subject: [PATCH 8/8] Ignore .clave.local.json --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ac05164..d02528c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ /builds node_modules .wrangler +.clave.local.json