From aebc8bbc9848a55c2296ca04cff6d1f4b7a61698 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 18 Mar 2026 21:35:01 +0100 Subject: [PATCH 01/15] chore: plan auto-update system for clawctl Co-Authored-By: Claude Opus 4.6 (1M context) --- .../TASK.md | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tasks/2026-03-18_2134_auto-update-system/TASK.md diff --git a/tasks/2026-03-18_2134_auto-update-system/TASK.md b/tasks/2026-03-18_2134_auto-update-system/TASK.md new file mode 100644 index 0000000..30eb7f4 --- /dev/null +++ b/tasks/2026-03-18_2134_auto-update-system/TASK.md @@ -0,0 +1,64 @@ +# Auto-Update System for clawctl + +## Status: In Progress + +## Scope + +Add a complete auto-update system that: +- Notifies users of new GitHub releases via pre-command check (4h TTL cache) +- Downloads and atomically replaces the clawctl binary +- Re-execs as the new binary to push updated claw to running VMs +- Marks stopped VMs as `pendingClawUpdate` for next start +- Adds `claw migrate` for migration-only updates (no full re-provisioning) + +Does NOT cover: +- Host-side schema migrations (registry already has `version: 1`) +- Auto-update without user consent (always prompts) + +## Context + +clawctl has versioned releases on GitHub, capability migrations for VM-side +components, and daemon staleness detection via build hashes. But there's no +mechanism to notify users of new releases or apply updates. + +The claw binary is embedded in the compiled clawctl binary via Bun's file +import. After replacing clawctl on disk, only the **new** process can extract +the new claw — creating a re-exec boundary. The daemon restart is already +handled for free by `ensureDaemon()` detecting binary hash changes. + +## Plan + +10-step implementation: +1. Add `semver` dependency +2. Update state management (`update-state.ts`) +3. GitHub release checker (`update-check.ts`) +4. Registry changes (add `clawVersion?`, `pendingClawUpdate?`) +5. Export `deployClaw` from provision +6. Binary download and replacement (`update-apply.ts`) +7. `clawctl update` command +8. Pre-command update hook +9. `claw migrate` command +10. Pending update on start + +Key design decisions: +- Per-version dismissal: saying "no" to v0.15.0 suppresses until v0.16.0+ +- `claw migrate` runs only explicit migrations, not full re-provisioning +- Silent degradation on network failure (3s timeout) +- Dev mode detection via `process.execPath.endsWith("/bun")` + +## Steps + +- [ ] Step 1: Add semver dependency +- [ ] Step 2: Create update-state.ts +- [ ] Step 3: Create update-check.ts +- [ ] Step 4: Add registry fields +- [ ] Step 5: Export deployClaw +- [ ] Step 6: Create update-apply.ts +- [ ] Step 7: Create update command +- [ ] Step 8: Create pre-command update hook +- [ ] Step 9: Create claw migrate command +- [ ] Step 10: Pending update on start + +## Notes + +## Outcome From 5e9e32461b3ff2a7ab460ba429d5c82f06ea4dbc Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 18 Mar 2026 21:36:22 +0100 Subject: [PATCH 02/15] feat: add host-core update infrastructure - Add semver dependency for version comparison - Add update-state.ts: cached update state with 4h TTL - Add update-check.ts: GitHub release checker with silent degradation - Add update-apply.ts: binary download/replace + VM update push - Add clawVersion/pendingClawUpdate fields to RegistryEntry - Export deployClaw from provision module Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 4 ++ package.json | 2 + packages/host-core/src/index.ts | 14 +++- packages/host-core/src/provision.ts | 2 +- packages/host-core/src/registry.ts | 2 + packages/host-core/src/update-apply.ts | 99 ++++++++++++++++++++++++++ packages/host-core/src/update-check.ts | 69 ++++++++++++++++++ packages/host-core/src/update-state.ts | 47 ++++++++++++ 8 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 packages/host-core/src/update-apply.ts create mode 100644 packages/host-core/src/update-check.ts create mode 100644 packages/host-core/src/update-state.ts diff --git a/bun.lock b/bun.lock index 4c427a9..b7335f2 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ "ink-text-input": "^6.0.0", "react": "^19.0.0", "react-devtools-core": "^7.0.1", + "semver": "^7.7.4", "yaml": "^2.8.2", "zod": "^4.3.6", }, @@ -26,6 +27,7 @@ "@release-it/conventional-changelog": "^10.0.5", "@types/bun": "latest", "@types/react": "^19.0.0", + "@types/semver": "^7.7.1", "eslint": "^10.0.2", "prettier": "^3.8.1", "release-it": "^19.2.4", @@ -280,6 +282,8 @@ "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="], diff --git a/package.json b/package.json index 8f3b059..097db9f 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "ink-text-input": "^6.0.0", "react": "^19.0.0", "react-devtools-core": "^7.0.1", + "semver": "^7.7.4", "yaml": "^2.8.2", "zod": "^4.3.6" }, @@ -82,6 +83,7 @@ "@release-it/conventional-changelog": "^10.0.5", "@types/bun": "latest", "@types/react": "^19.0.0", + "@types/semver": "^7.7.1", "eslint": "^10.0.2", "prettier": "^3.8.1", "release-it": "^19.2.4", diff --git a/packages/host-core/src/index.ts b/packages/host-core/src/index.ts index f67b420..5d1a59b 100644 --- a/packages/host-core/src/index.ts +++ b/packages/host-core/src/index.ts @@ -32,7 +32,7 @@ export { export type { SecretRef, ResolvedSecretRef } from "./secrets.js"; // Provision -export { provisionVM } from "./provision.js"; +export { provisionVM, deployClaw } from "./provision.js"; export type { ProvisionCallbacks } from "./provision.js"; // Claw binary (embedded asset in compiled mode, direct path in dev mode) @@ -122,3 +122,15 @@ export type { HeadlessResult, HeadlessCallbacks, HeadlessStage, StageStatus } fr // Capability host hooks export { getHostHooksForConfig, getCapabilityConfig } from "./capability-hooks.js"; export type { HostCapabilityHook } from "./capability-hooks.js"; + +// Update state +export { loadUpdateState, saveUpdateState, isCheckStale } from "./update-state.js"; +export type { UpdateState } from "./update-state.js"; + +// Update check +export { checkForUpdate } from "./update-check.js"; +export type { UpdateInfo } from "./update-check.js"; + +// Update apply +export { downloadAndReplace, applyVmUpdates } from "./update-apply.js"; +export type { VmUpdateResult } from "./update-apply.js"; diff --git a/packages/host-core/src/provision.ts b/packages/host-core/src/provision.ts index 7203800..2cf8392 100644 --- a/packages/host-core/src/provision.ts +++ b/packages/host-core/src/provision.ts @@ -18,7 +18,7 @@ export interface ProvisionCallbacks { * Uses `driver.copy()` (limactl copy) to transfer the file, then moves * it to /usr/local/bin with sudo. */ -async function deployClaw( +export async function deployClaw( driver: VMDriver, vmName: string, clawBinaryPath: string, diff --git a/packages/host-core/src/registry.ts b/packages/host-core/src/registry.ts index 07d87be..6798fa1 100644 --- a/packages/host-core/src/registry.ts +++ b/packages/host-core/src/registry.ts @@ -12,6 +12,8 @@ export interface RegistryEntry { providerType?: string; gatewayPort: number; tailscaleUrl?: string; + clawVersion?: string; + pendingClawUpdate?: boolean; } export interface Registry { diff --git a/packages/host-core/src/update-apply.ts b/packages/host-core/src/update-apply.ts new file mode 100644 index 0000000..fa5caf1 --- /dev/null +++ b/packages/host-core/src/update-apply.ts @@ -0,0 +1,99 @@ +import { writeFile, rename, chmod, rm, mkdtemp } from "fs/promises"; +import { dirname, join } from "path"; +import { tmpdir } from "os"; +import { execa } from "execa"; +import { CLAW_BIN_PATH } from "@clawctl/types"; +import { deployClaw } from "./provision.js"; +import { clawPath } from "./claw-binary.js"; +import { loadRegistry, saveRegistry } from "./registry.js"; +import { LimaDriver } from "./drivers/index.js"; + +/** + * Download a release zip from the given URL and atomically replace the + * current clawctl binary at process.execPath. + */ +export async function downloadAndReplace(assetUrl: string): Promise { + // Use same directory as the binary for atomic rename (same filesystem) + const binaryDir = dirname(process.execPath); + const tmpDir = await mkdtemp(join(binaryDir, ".clawctl-update-")); + + const zipPath = join(tmpDir, "clawctl.zip"); + const extractDir = join(tmpDir, "extracted"); + + try { + // Download + const res = await fetch(assetUrl); + if (!res.ok) { + throw new Error(`Download failed: ${res.status} ${res.statusText}`); + } + const buffer = Buffer.from(await res.arrayBuffer()); + await writeFile(zipPath, buffer); + + // Extract — macOS ships with unzip + await execa("unzip", ["-o", zipPath, "-d", extractDir]); + + // Atomic replace + const extractedBinary = join(extractDir, "clawctl"); + await rename(extractedBinary, process.execPath); + await chmod(process.execPath, 0o755); + } finally { + // Cleanup temp files + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } +} + +export interface VmUpdateResult { + name: string; + status: "updated" | "pending" | "skipped"; + detail?: string; +} + +/** + * Push the new claw binary to all instances and run migrations. + * Called by the NEW binary after self-replacement. + */ +export async function applyVmUpdates(configDir?: string): Promise { + const registry = await loadRegistry(configDir); + const driver = new LimaDriver(); + const results: VmUpdateResult[] = []; + + for (const [name, entry] of Object.entries(registry.instances)) { + try { + const vmStatus = await driver.status(entry.vmName); + + if (vmStatus === "Running") { + // Push new claw binary + await deployClaw(driver, entry.vmName, clawPath); + + // Run capability migrations only + const migrateResult = await driver.exec( + entry.vmName, + `${CLAW_BIN_PATH} migrate --json`, + ); + + const detail = + migrateResult.exitCode === 0 + ? "claw updated and migrations applied" + : `claw updated, migrate exited ${migrateResult.exitCode}`; + + entry.clawVersion = undefined; // Will be set from package version by caller + entry.pendingClawUpdate = false; + results.push({ name, status: "updated", detail }); + } else if (vmStatus === "Stopped") { + entry.pendingClawUpdate = true; + results.push({ name, status: "pending", detail: "VM stopped — will update on next start" }); + } else { + results.push({ name, status: "skipped", detail: `VM in unexpected state: ${vmStatus}` }); + } + } catch (err) { + results.push({ + name, + status: "skipped", + detail: `Error: ${err instanceof Error ? err.message : err}`, + }); + } + } + + await saveRegistry(registry, configDir); + return results; +} diff --git a/packages/host-core/src/update-check.ts b/packages/host-core/src/update-check.ts new file mode 100644 index 0000000..8d911b8 --- /dev/null +++ b/packages/host-core/src/update-check.ts @@ -0,0 +1,69 @@ +import semver from "semver"; +import { loadUpdateState, saveUpdateState, isCheckStale } from "./update-state.js"; + +const GITHUB_RELEASES_URL = "https://api.github.com/repos/TimBeyer/clawctl/releases/latest"; +const ASSET_URL = (v: string) => + `https://github.com/TimBeyer/clawctl/releases/download/v${v}/clawctl-darwin-arm64.zip`; +const CHECK_TIMEOUT_MS = 3_000; + +export interface UpdateInfo { + available: boolean; + version?: string; + assetUrl?: string; +} + +export async function checkForUpdate( + currentVersion: string, + configDir?: string, +): Promise { + try { + const state = await loadUpdateState(configDir); + + if (!isCheckStale(state) && state.latestVersion) { + const isNewer = semver.gt(state.latestVersion, currentVersion); + return { + available: isNewer, + version: isNewer ? state.latestVersion : undefined, + assetUrl: isNewer ? ASSET_URL(state.latestVersion) : undefined, + }; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS); + + try { + const res = await fetch(GITHUB_RELEASES_URL, { + signal: controller.signal, + headers: { Accept: "application/vnd.github+json" }, + }); + clearTimeout(timeout); + + if (!res.ok) return null; + + const data = (await res.json()) as { tag_name?: string }; + const tagVersion = semver.coerce(data.tag_name)?.version; + if (!tagVersion) return null; + + await saveUpdateState( + { + ...state, + lastCheckAt: new Date().toISOString(), + latestVersion: tagVersion, + }, + configDir, + ); + + const isNewer = semver.gt(tagVersion, currentVersion); + return { + available: isNewer, + version: isNewer ? tagVersion : undefined, + assetUrl: isNewer ? ASSET_URL(tagVersion) : undefined, + }; + } finally { + clearTimeout(timeout); + } + } catch { + // Silent degradation — network failure, timeout, parse error + return null; + } +} diff --git a/packages/host-core/src/update-state.ts b/packages/host-core/src/update-state.ts new file mode 100644 index 0000000..7fc88e9 --- /dev/null +++ b/packages/host-core/src/update-state.ts @@ -0,0 +1,47 @@ +import { readFile, writeFile, mkdir, rename } from "fs/promises"; +import { join } from "path"; +import { homedir } from "os"; +import { randomBytes } from "crypto"; + +export interface UpdateState { + lastCheckAt?: string; + latestVersion?: string; + latestReleaseUrl?: string; + dismissedVersion?: string; +} + +const DEFAULT_CONFIG_DIR = join(homedir(), ".config", "clawctl"); +const STATE_FILE = "update-state.json"; +const CHECK_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours + +function statePath(configDir: string): string { + return join(configDir, STATE_FILE); +} + +export async function loadUpdateState( + configDir: string = DEFAULT_CONFIG_DIR, +): Promise { + try { + const raw = await readFile(statePath(configDir), "utf-8"); + return JSON.parse(raw) as UpdateState; + } catch { + return {}; + } +} + +export async function saveUpdateState( + state: UpdateState, + configDir: string = DEFAULT_CONFIG_DIR, +): Promise { + await mkdir(configDir, { recursive: true }); + const path = statePath(configDir); + const tmpPath = `${path}.${randomBytes(4).toString("hex")}.tmp`; + await writeFile(tmpPath, JSON.stringify(state, null, 2) + "\n"); + await rename(tmpPath, path); +} + +export function isCheckStale(state: UpdateState): boolean { + if (!state.lastCheckAt) return true; + const elapsed = Date.now() - new Date(state.lastCheckAt).getTime(); + return elapsed > CHECK_TTL_MS; +} From 57114d77bab06b5ea15c8988e8af323846056e14 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 18 Mar 2026 21:37:53 +0100 Subject: [PATCH 03/15] feat: add clawctl update command and pre-command hook - Add `clawctl update` command (check + download + re-exec for VM updates) - Add `--apply-vm` internal flag for the new binary to push claw to VMs - Add pre-command update hook with user prompt and per-version dismissal - Skip update check for update/daemon/completions commands and dev mode Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/bin/cli.tsx | 31 +++++++++++++++ packages/cli/src/commands/index.ts | 1 + packages/cli/src/commands/update.ts | 40 +++++++++++++++++++ packages/cli/src/update-hook.ts | 60 +++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+) create mode 100644 packages/cli/src/commands/update.ts create mode 100644 packages/cli/src/update-hook.ts diff --git a/packages/cli/bin/cli.tsx b/packages/cli/bin/cli.tsx index 8a4fdab..b983d12 100755 --- a/packages/cli/bin/cli.tsx +++ b/packages/cli/bin/cli.tsx @@ -25,8 +25,10 @@ import { runDaemonStatus, runDaemonLogs, runDaemonRun, + runUpdate, } from "../src/commands/index.js"; import { ensureDaemon } from "@clawctl/daemon"; +import { checkAndPromptUpdate } from "../src/update-hook.js"; const driver = new LimaDriver(); @@ -238,4 +240,33 @@ completionsCmd await runCompletionsUpdateOc(driver, opts); }); +program + .command("update") + .description("Check for and apply clawctl updates") + .option("--apply-vm", "Apply VM updates after binary replacement (internal)") + .action(async (opts: { applyVm?: boolean }) => { + try { + await runUpdate(opts); + } catch (err) { + console.error(err instanceof Error ? `Error: ${err.message}` : err); + process.exit(1); + } + }); + +// Pre-command update check (skip for commands that handle updates themselves or are non-interactive) +const SKIP_UPDATE_COMMANDS = new Set(["update", "daemon", "completions"]); + +program.hook("preAction", async (_thisCommand, actionCommand) => { + // Walk up to find the top-level subcommand name + let cmd = actionCommand; + while (cmd.parent && cmd.parent !== program) { + cmd = cmd.parent; + } + const commandName = cmd.name(); + if (SKIP_UPDATE_COMMANDS.has(commandName)) return; + + const result = await checkAndPromptUpdate(pkg.version); + if (result === "updated") process.exit(0); +}); + await program.parseAsync(); diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 353ab73..e0b9f1d 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -18,3 +18,4 @@ export { runDaemonLogs, runDaemonRun, } from "./daemon.js"; +export { runUpdate } from "./update.js"; diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts new file mode 100644 index 0000000..6476572 --- /dev/null +++ b/packages/cli/src/commands/update.ts @@ -0,0 +1,40 @@ +import { execa } from "execa"; +import { checkForUpdate, downloadAndReplace, applyVmUpdates } from "@clawctl/host-core"; +import pkg from "../../../../package.json"; + +export async function runUpdate(opts: { applyVm?: boolean }): Promise { + if (opts.applyVm) { + // Internal mode: called by the NEW binary after self-replacement + console.log("Updating VMs with new claw binary..."); + const results = await applyVmUpdates(); + for (const r of results) { + const icon = r.status === "updated" ? "\u2713" : r.status === "pending" ? "\u25cb" : "\u00d7"; + console.log(` ${icon} ${r.name}: ${r.detail ?? r.status}`); + } + const updated = results.filter((r) => r.status === "updated").length; + const pending = results.filter((r) => r.status === "pending").length; + if (results.length === 0) { + console.log("No instances registered."); + } else { + console.log(`\n${updated} updated, ${pending} pending.`); + } + return; + } + + // Normal mode: check + download + re-exec + console.log(`Current version: v${pkg.version}`); + const update = await checkForUpdate(pkg.version); + + if (!update || !update.available) { + console.log(`clawctl is up to date (v${pkg.version}).`); + return; + } + + console.log(`New version available: v${update.version}`); + console.log("Downloading..."); + await downloadAndReplace(update.assetUrl!); + console.log("Binary updated. Applying VM updates..."); + + // Spawn the NEW binary to handle VM updates (it has the new embedded claw) + await execa(process.execPath, ["update", "--apply-vm"], { stdio: "inherit" }); +} diff --git a/packages/cli/src/update-hook.ts b/packages/cli/src/update-hook.ts new file mode 100644 index 0000000..69d4521 --- /dev/null +++ b/packages/cli/src/update-hook.ts @@ -0,0 +1,60 @@ +import { createInterface } from "readline"; +import { execa } from "execa"; +import { + checkForUpdate, + loadUpdateState, + saveUpdateState, + downloadAndReplace, +} from "@clawctl/host-core"; + +/** + * Pre-command hook that checks for updates and prompts the user. + * + * Returns: + * - "updated": binary was replaced and VM updates spawned — caller should exit + * - "skipped": user declined the update + * - "none": no update available (or dev mode, or error) + */ +export async function checkAndPromptUpdate( + currentVersion: string, +): Promise<"updated" | "skipped" | "none"> { + // Dev mode: running via `bun cli.tsx`, not a compiled binary + if (process.execPath.endsWith("/bun")) return "none"; + + const update = await checkForUpdate(currentVersion); + if (!update || !update.available || !update.version) return "none"; + + // Check if this version was already dismissed + const state = await loadUpdateState(); + if (state.dismissedVersion === update.version) return "none"; + + // Prompt the user + const answer = await prompt( + `clawctl v${update.version} is available (you have v${currentVersion}). Update? [Y/n] `, + ); + + if (answer.toLowerCase() === "n" || answer.toLowerCase() === "no") { + await saveUpdateState({ ...state, dismissedVersion: update.version }); + return "skipped"; + } + + // Download and replace + console.log("Downloading update..."); + await downloadAndReplace(update.assetUrl!); + console.log("Updated. Applying VM updates..."); + + // Spawn the NEW binary for VM updates + await execa(process.execPath, ["update", "--apply-vm"], { stdio: "inherit" }); + + return "updated"; +} + +function prompt(question: string): Promise { + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} From fc4f4b18f11ed78a00a81e4f304034d19f5b089b Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 18 Mar 2026 21:38:33 +0100 Subject: [PATCH 04/15] feat: add claw migrate command for capability migrations Runs migration chains for capabilities with version drift after a claw binary update. Skips capabilities where the version bump has no explicit migration (binary update is sufficient). Supports --json for structured output consumed by the host. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/vm-cli/bin/claw.ts | 2 + packages/vm-cli/src/commands/migrate.ts | 90 +++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 packages/vm-cli/src/commands/migrate.ts diff --git a/packages/vm-cli/bin/claw.ts b/packages/vm-cli/bin/claw.ts index 8d2c8b6..f45b09a 100644 --- a/packages/vm-cli/bin/claw.ts +++ b/packages/vm-cli/bin/claw.ts @@ -4,6 +4,7 @@ import { Command } from "commander"; import { registerProvisionCommand } from "../src/commands/provision/index.js"; import { registerDoctorCommand } from "../src/commands/doctor.js"; import { registerCheckpointCommand } from "../src/commands/checkpoint.js"; +import { registerMigrateCommand } from "../src/commands/migrate.js"; const program = new Command() .name("claw") @@ -13,5 +14,6 @@ const program = new Command() registerProvisionCommand(program); registerDoctorCommand(program); registerCheckpointCommand(program); +registerMigrateCommand(program); await program.parseAsync(); diff --git a/packages/vm-cli/src/commands/migrate.ts b/packages/vm-cli/src/commands/migrate.ts new file mode 100644 index 0000000..498f15d --- /dev/null +++ b/packages/vm-cli/src/commands/migrate.ts @@ -0,0 +1,90 @@ +import { Command } from "commander"; +import { setJsonMode, ok, fail, log } from "../output.js"; +import { createCapabilityContext } from "../capabilities/context.js"; +import { getEnabledCapabilities } from "../capabilities/registry.js"; +import { readCapabilityState, needsMigration, findMigrationPath, markInstalled } from "../capabilities/state.js"; +import { readProvisionConfig } from "../tools/provision-config.js"; +import type { ProvisionResult } from "@clawctl/types"; + +interface MigrateResult { + migrated: string[]; + skipped: string[]; + failed: string[]; + results: ProvisionResult[]; +} + +async function runMigrate(): Promise { + const config = await readProvisionConfig(); + const ctx = createCapabilityContext(); + const state = await readCapabilityState(ctx); + const enabled = getEnabledCapabilities(config); + + const migrated: string[] = []; + const skipped: string[] = []; + const failed: string[] = []; + const results: ProvisionResult[] = []; + + for (const capability of enabled) { + if (!needsMigration(state, capability)) { + continue; // Already at current version or not installed + } + + const migrations = findMigrationPath(capability, state); + if (migrations.length === 0) { + // Version bump with no migration chain — skip (binary update is enough) + log( + `${capability.label}: v${state.installed[capability.name]?.version} → v${capability.version} (no migration needed)`, + ); + await markInstalled(ctx, state, capability.name, capability.version); + skipped.push(capability.name); + continue; + } + + log( + `${capability.label}: migrating v${state.installed[capability.name]?.version} → v${capability.version}`, + ); + let anyFailed = false; + + for (const migration of migrations) { + const result = await migration.run(ctx); + results.push(result); + const icon = result.status === "failed" ? "\u2717" : "\u2713"; + log(` ${icon} ${result.name}: ${result.status}${result.detail ? ` — ${result.detail}` : ""}${result.error ? ` — ${result.error}` : ""}`); + + if (result.status === "failed") { + anyFailed = true; + failed.push(capability.name); + break; + } + } + + if (!anyFailed) { + await markInstalled(ctx, state, capability.name, capability.version); + migrated.push(capability.name); + } + } + + return { migrated, skipped, failed, results }; +} + +export function registerMigrateCommand(program: Command): void { + program + .command("migrate") + .description("Run capability migrations (used after claw binary update)") + .option("--json", "Output structured JSON") + .action(async (opts: { json?: boolean }) => { + if (opts.json) setJsonMode(true); + + const result = await runMigrate(); + + if (result.failed.length > 0) { + fail(result.failed.map((name) => `${name}: migration failed`), result); + process.exit(1); + } + + log( + `Migrations complete: ${result.migrated.length} migrated, ${result.skipped.length} skipped.`, + ); + ok(result); + }); +} From 64b659a393a57e64be53243a8668e3c26a34fc94 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 18 Mar 2026 21:39:28 +0100 Subject: [PATCH 05/15] feat: apply pending claw update on instance start When a VM was stopped during a clawctl update, pendingClawUpdate is set. On next `clawctl start`, the new claw binary is pushed and `claw migrate` is run before notifying the daemon. Also removes unused tmpdir import from update-apply.ts. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/start.ts | 36 +++++++++++++++++++++++++- packages/host-core/src/update-apply.ts | 1 - 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index d275692..b28c2b2 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -1,5 +1,12 @@ import type { VMDriver } from "@clawctl/host-core"; -import { requireInstance } from "@clawctl/host-core"; +import { + requireInstance, + deployClaw, + clawPath, + loadRegistry, + saveRegistry, +} from "@clawctl/host-core"; +import { CLAW_BIN_PATH } from "@clawctl/types"; import { notifyDaemon } from "@clawctl/daemon"; export async function runStart(driver: VMDriver, opts: { instance?: string }): Promise { @@ -14,5 +21,32 @@ export async function runStart(driver: VMDriver, opts: { instance?: string }): P console.log(`Starting "${entry.name}"...`); await driver.start(entry.vmName); console.log(`Instance "${entry.name}" started.`); + + // Apply pending claw update if the binary was replaced while VM was stopped + if (entry.pendingClawUpdate) { + console.log("Applying pending claw update..."); + try { + await deployClaw(driver, entry.vmName, clawPath); + const migrateResult = await driver.exec(entry.vmName, `${CLAW_BIN_PATH} migrate --json`); + if (migrateResult.exitCode !== 0) { + console.error(`Warning: claw migrate exited ${migrateResult.exitCode}`); + } + + // Clear the pending flag + const registry = await loadRegistry(); + const current = registry.instances[entry.name]; + if (current) { + current.pendingClawUpdate = false; + current.clawVersion = undefined; + await saveRegistry(registry); + } + console.log("Claw update applied."); + } catch (err) { + console.error( + `Warning: pending claw update failed: ${err instanceof Error ? err.message : err}`, + ); + } + } + await notifyDaemon(); } diff --git a/packages/host-core/src/update-apply.ts b/packages/host-core/src/update-apply.ts index fa5caf1..6aaf75e 100644 --- a/packages/host-core/src/update-apply.ts +++ b/packages/host-core/src/update-apply.ts @@ -1,6 +1,5 @@ import { writeFile, rename, chmod, rm, mkdtemp } from "fs/promises"; import { dirname, join } from "path"; -import { tmpdir } from "os"; import { execa } from "execa"; import { CLAW_BIN_PATH } from "@clawctl/types"; import { deployClaw } from "./provision.js"; From 991ecd7c555614a1921c8538af4feaccbdbe5235 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 18 Mar 2026 21:40:03 +0100 Subject: [PATCH 06/15] test: add update-state round-trip and staleness tests Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/host-core/src/update-state.test.ts | 69 +++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 packages/host-core/src/update-state.test.ts diff --git a/packages/host-core/src/update-state.test.ts b/packages/host-core/src/update-state.test.ts new file mode 100644 index 0000000..70e8eb1 --- /dev/null +++ b/packages/host-core/src/update-state.test.ts @@ -0,0 +1,69 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, rm, readFile } from "fs/promises"; +import { join } from "path"; +import { tmpdir } from "os"; +import { loadUpdateState, saveUpdateState, isCheckStale } from "./update-state.js"; +import type { UpdateState } from "./update-state.js"; + +describe("update-state", () => { + let configDir: string; + + beforeEach(async () => { + configDir = await mkdtemp(join(tmpdir(), "clawctl-test-")); + }); + + afterEach(async () => { + await rm(configDir, { recursive: true, force: true }); + }); + + test("loadUpdateState returns empty object when file does not exist", async () => { + const state = await loadUpdateState(configDir); + expect(state).toEqual({}); + }); + + test("round-trip save and load", async () => { + const state: UpdateState = { + lastCheckAt: "2026-03-18T12:00:00.000Z", + latestVersion: "0.15.0", + latestReleaseUrl: "https://example.com/release", + dismissedVersion: "0.14.0", + }; + await saveUpdateState(state, configDir); + const loaded = await loadUpdateState(configDir); + expect(loaded).toEqual(state); + }); + + test("saveUpdateState creates config dir if missing", async () => { + const nested = join(configDir, "sub", "dir"); + await saveUpdateState({ latestVersion: "1.0.0" }, nested); + const loaded = await loadUpdateState(nested); + expect(loaded.latestVersion).toBe("1.0.0"); + }); + + test("saveUpdateState writes valid JSON", async () => { + await saveUpdateState({ latestVersion: "1.0.0" }, configDir); + const raw = await readFile(join(configDir, "update-state.json"), "utf-8"); + const parsed = JSON.parse(raw); + expect(parsed.latestVersion).toBe("1.0.0"); + }); + + describe("isCheckStale", () => { + test("returns true when lastCheckAt is missing", () => { + expect(isCheckStale({})).toBe(true); + }); + + test("returns true when lastCheckAt is older than 4 hours", () => { + const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString(); + expect(isCheckStale({ lastCheckAt: fiveHoursAgo })).toBe(true); + }); + + test("returns false when lastCheckAt is within 4 hours", () => { + const oneHourAgo = new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(); + expect(isCheckStale({ lastCheckAt: oneHourAgo })).toBe(false); + }); + + test("returns false when lastCheckAt is just now", () => { + expect(isCheckStale({ lastCheckAt: new Date().toISOString() })).toBe(false); + }); + }); +}); From 3d57710b26766916ac533e0e53a36728a6f7a66b Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 18 Mar 2026 21:40:31 +0100 Subject: [PATCH 07/15] docs: mark auto-update system task as resolved Co-Authored-By: Claude Opus 4.6 (1M context) --- .../TASK.md | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/tasks/2026-03-18_2134_auto-update-system/TASK.md b/tasks/2026-03-18_2134_auto-update-system/TASK.md index 30eb7f4..a29a291 100644 --- a/tasks/2026-03-18_2134_auto-update-system/TASK.md +++ b/tasks/2026-03-18_2134_auto-update-system/TASK.md @@ -1,6 +1,6 @@ # Auto-Update System for clawctl -## Status: In Progress +## Status: Resolved ## Scope @@ -48,17 +48,29 @@ Key design decisions: ## Steps -- [ ] Step 1: Add semver dependency -- [ ] Step 2: Create update-state.ts -- [ ] Step 3: Create update-check.ts -- [ ] Step 4: Add registry fields -- [ ] Step 5: Export deployClaw -- [ ] Step 6: Create update-apply.ts -- [ ] Step 7: Create update command -- [ ] Step 8: Create pre-command update hook -- [ ] Step 9: Create claw migrate command -- [ ] Step 10: Pending update on start +- [x] Step 1: Add semver dependency +- [x] Step 2: Create update-state.ts +- [x] Step 3: Create update-check.ts +- [x] Step 4: Add registry fields +- [x] Step 5: Export deployClaw +- [x] Step 6: Create update-apply.ts +- [x] Step 7: Create update command +- [x] Step 8: Create pre-command update hook +- [x] Step 9: Create claw migrate command +- [x] Step 10: Pending update on start ## Notes ## Outcome + +All 10 steps implemented. The auto-update system covers: + +- **Host-side**: `update-state.ts` (cached state with 4h TTL), `update-check.ts` + (GitHub API with 3s timeout, silent degradation), `update-apply.ts` (atomic + binary replacement + VM update push) +- **CLI**: `clawctl update` command, pre-command hook with per-version dismissal +- **VM-side**: `claw migrate` command that runs only capability migration chains +- **Lifecycle**: `pendingClawUpdate` flag in registry, applied on next `clawctl start` + +Tests added for update-state round-trip and staleness logic. All existing tests pass. +Typecheck and lint clean. From 1a2764af725d4246c996df20887e20eb9c0f5c54 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 18 Mar 2026 21:49:03 +0100 Subject: [PATCH 08/15] fix: skip self-update in dev mode Running `clawctl-dev update` via bun would overwrite the bun binary itself with the clawctl release zip. Add a dev mode check (same as the pre-command hook) to bail early with a helpful message. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/update.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 6476572..07fd01f 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -21,6 +21,13 @@ export async function runUpdate(opts: { applyVm?: boolean }): Promise { return; } + // Dev mode: running via bun, not a compiled binary — can't self-update + if (process.execPath.endsWith("/bun")) { + console.log("Dev mode detected — self-update is not available."); + console.log("Build a release binary with `bun run build` to use auto-update."); + return; + } + // Normal mode: check + download + re-exec console.log(`Current version: v${pkg.version}`); const update = await checkForUpdate(pkg.version); From caf99eb3fef2b4464cffa7f8bfa83c857571a702 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 18 Mar 2026 21:58:52 +0100 Subject: [PATCH 09/15] style: auto-format with prettier Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/host-core/src/update-apply.ts | 5 +---- packages/vm-cli/src/commands/migrate.ts | 16 +++++++++++++--- tasks/2026-03-18_2134_auto-update-system/TASK.md | 4 ++++ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/host-core/src/update-apply.ts b/packages/host-core/src/update-apply.ts index 6aaf75e..cfb19fe 100644 --- a/packages/host-core/src/update-apply.ts +++ b/packages/host-core/src/update-apply.ts @@ -65,10 +65,7 @@ export async function applyVmUpdates(configDir?: string): Promise { const result = await migration.run(ctx); results.push(result); const icon = result.status === "failed" ? "\u2717" : "\u2713"; - log(` ${icon} ${result.name}: ${result.status}${result.detail ? ` — ${result.detail}` : ""}${result.error ? ` — ${result.error}` : ""}`); + log( + ` ${icon} ${result.name}: ${result.status}${result.detail ? ` — ${result.detail}` : ""}${result.error ? ` — ${result.error}` : ""}`, + ); if (result.status === "failed") { anyFailed = true; @@ -78,7 +85,10 @@ export function registerMigrateCommand(program: Command): void { const result = await runMigrate(); if (result.failed.length > 0) { - fail(result.failed.map((name) => `${name}: migration failed`), result); + fail( + result.failed.map((name) => `${name}: migration failed`), + result, + ); process.exit(1); } diff --git a/tasks/2026-03-18_2134_auto-update-system/TASK.md b/tasks/2026-03-18_2134_auto-update-system/TASK.md index a29a291..969a32a 100644 --- a/tasks/2026-03-18_2134_auto-update-system/TASK.md +++ b/tasks/2026-03-18_2134_auto-update-system/TASK.md @@ -5,6 +5,7 @@ ## Scope Add a complete auto-update system that: + - Notifies users of new GitHub releases via pre-command check (4h TTL cache) - Downloads and atomically replaces the clawctl binary - Re-execs as the new binary to push updated claw to running VMs @@ -12,6 +13,7 @@ Add a complete auto-update system that: - Adds `claw migrate` for migration-only updates (no full re-provisioning) Does NOT cover: + - Host-side schema migrations (registry already has `version: 1`) - Auto-update without user consent (always prompts) @@ -29,6 +31,7 @@ handled for free by `ensureDaemon()` detecting binary hash changes. ## Plan 10-step implementation: + 1. Add `semver` dependency 2. Update state management (`update-state.ts`) 3. GitHub release checker (`update-check.ts`) @@ -41,6 +44,7 @@ handled for free by `ensureDaemon()` detecting binary hash changes. 10. Pending update on start Key design decisions: + - Per-version dismissal: saying "no" to v0.15.0 suppresses until v0.16.0+ - `claw migrate` runs only explicit migrations, not full re-provisioning - Silent degradation on network failure (3s timeout) From debd70f3cbbbe60e04bc75b355f973dbef86b994 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 18 Mar 2026 22:19:33 +0100 Subject: [PATCH 10/15] docs: expand TASK.md structure and plan mode handover guidance Add Context section to TASK.md template. Expand Plan section to require design rationale (alternatives, trade-offs, rejected approaches) rather than just implementation steps. Clarify that Notes are for implementation-time discoveries, not design decisions. Add explicit guidance that plan mode plans must include all context needed for the TASK.md, since context is cleared before implementation. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 44 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3f08a0e..6a213b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,11 +132,14 @@ clawctl-dev create --config ./vm-bootstrap.json - Name: `YYYY-MM-DD_hhmm_descriptive-kebab-case` (e.g., `2026-02-24_1337_log-coloring`) - **Get the timestamp from the OS** (`date +%Y-%m-%d_%H%M`) — do not guess or make up a time - A task is a concrete, completable unit of work — not an epic or a backlog -- Include a TASK.md with: scope, plan, steps, current status +- Include a TASK.md with: scope, context, plan, steps, current status - Keep TASK.md updated as work progresses - When coding work is done: mark TASK.md status as **Resolved** — the task directory stays in `tasks/` - `tasks/archive/` is for periodic manual cleanup, not part of the PR workflow - Commit task+plan first, before implementation code +- Since we will usually clear the context before implementation when using the plan mode, + your plan MUST include any context that we want to be included in the `TASK.md`, + especially concerning user inputs and feedback, and explicit design choices ### TASK.md Structure @@ -151,9 +154,32 @@ Every TASK.md should have these sections: What this task covers and — just as importantly — what it does not. +## Context + +The motivation and background behind this task. Capture: + +- Why we're doing this — the problem, constraint, or goal that triggered it +- Relevant background the user provided (domain knowledge, prior decisions, + architectural constraints) that shaped the approach +- Key requirements or invariants that must hold + +This section is written at the start of the task, drawn from the initial +prompt and early discussion. It's the "why" behind the "what". + ## Plan -Numbered high-level steps. +The design and approach, not just a numbered list of steps. Capture: + +- The chosen approach and _why_ it was chosen +- Alternatives that were considered and why they were rejected +- Pushback or refinements from discussion — if the initial idea was + changed, record what changed and why +- Trade-offs acknowledged (e.g., "simpler but less flexible", "more work + now but avoids X later") + +The plan should read as a record of the design process, not just its +output. A future reader should understand not only what we decided to do, +but what we decided _not_ to do and why. ## Steps @@ -161,16 +187,16 @@ Checkbox list (- [x] / - [ ]) of concrete work items. ## Notes -Running log of observations, questions, and decisions made during the work. +Running log of observations and decisions made _during implementation_. Write these as you go — not after the fact. Include: -- Design decisions and _why_ (not just what) -- Alternatives you considered and why they were rejected +- Implementation-time discoveries that affected the approach - Anything a future reader would look at in the code and wonder "why?" - Links to relevant docs, issues, or conversations Don't log routine fixes (type errors, lint fixes, minor API quirks) — only things where the reasoning isn't obvious from the code itself. +Design-level reasoning belongs in Context and Plan, not here. ## Outcome @@ -182,9 +208,11 @@ Written when marking the task as Resolved. A short summary of: ``` **Why this matters**: Task documents are the project's decision log. When -someone later asks "why did we do X?", the answer should be findable by -scanning task Notes and Outcomes — not locked in someone's head or lost -in a chat transcript. +someone later asks "why did we do X?" or "why didn't we do Y?", the +answer should be findable by scanning task Context, Plan, and Notes — +not locked in someone's head or lost in a chat transcript. Recording +the design process (not just the result) means we don't re-litigate +the same trade-offs when revisiting a decision later. ## Committing From 4c7f61ff31ef79aefc01a2310f3bee2ac3a5b690 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 18 Mar 2026 22:22:27 +0100 Subject: [PATCH 11/15] fix: don't clear pendingClawUpdate when migrate fails Keep the flag set so migrations are retried on next start. A future clawctl update may ship a fixed claw binary that unblocks it. This avoids bricking a setup if a migration has a bug. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/start.ts | 17 ++++++++++------- packages/host-core/src/update-apply.ts | 25 +++++++++++++++++-------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index b28c2b2..6db6afc 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -28,20 +28,23 @@ export async function runStart(driver: VMDriver, opts: { instance?: string }): P try { await deployClaw(driver, entry.vmName, clawPath); const migrateResult = await driver.exec(entry.vmName, `${CLAW_BIN_PATH} migrate --json`); - if (migrateResult.exitCode !== 0) { - console.error(`Warning: claw migrate exited ${migrateResult.exitCode}`); - } - // Clear the pending flag const registry = await loadRegistry(); const current = registry.instances[entry.name]; - if (current) { + + if (migrateResult.exitCode !== 0) { + // Don't clear the flag — retry on next start. A future clawctl + // update may ship a fixed claw that unblocks it. + console.error( + `Warning: claw migrate failed (exit ${migrateResult.exitCode}). Will retry on next start.`, + ); + } else if (current) { current.pendingClawUpdate = false; - current.clawVersion = undefined; await saveRegistry(registry); + console.log("Claw update applied."); } - console.log("Claw update applied."); } catch (err) { + // deployClaw failed — flag stays set, will retry next start console.error( `Warning: pending claw update failed: ${err instanceof Error ? err.message : err}`, ); diff --git a/packages/host-core/src/update-apply.ts b/packages/host-core/src/update-apply.ts index cfb19fe..ae53d38 100644 --- a/packages/host-core/src/update-apply.ts +++ b/packages/host-core/src/update-apply.ts @@ -67,14 +67,23 @@ export async function applyVmUpdates(configDir?: string): Promise Date: Wed, 18 Mar 2026 22:26:23 +0100 Subject: [PATCH 12/15] docs: clarify findMigrationPath contract for different callers The empty-array return from findMigrationPath is handled differently by runner.ts (falls through to re-provision) and migrate.ts (skips). Update the JSDoc to document both paths and note that explicit re-provisioning during updates should be a dedicated capability hook if ever needed, not a silent fallback. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/vm-cli/src/capabilities/state.ts | 11 ++++++++--- packages/vm-cli/src/commands/migrate.ts | 4 +++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/vm-cli/src/capabilities/state.ts b/packages/vm-cli/src/capabilities/state.ts index f2d85ad..b3fcfb4 100644 --- a/packages/vm-cli/src/capabilities/state.ts +++ b/packages/vm-cli/src/capabilities/state.ts @@ -62,8 +62,14 @@ export function needsMigration(state: CapabilityState, capability: CapabilityDef * Find the migration path from installed version to current version. * * Returns an ordered array of migrations to apply, or an empty array if - * no migration path exists (in which case the capability should be - * re-provisioned from scratch, which is idempotent). + * no migration path exists (no migrations declared, or a gap in the chain). + * + * Callers handle the empty case based on context: + * - `runner.ts` (full provisioning): falls through to re-provision steps + * - `migrate.ts` (update path): skips — version bump with no VM-side action + * + * If we ever need explicit re-provisioning during updates, it should be + * built as a dedicated capability hook, not as a silent fallback here. */ export function findMigrationPath( capability: CapabilityDef, @@ -81,7 +87,6 @@ export function findMigrationPath( const next = capability.migrations.find((m) => m.from === current); if (!next) { // No migration from current version — gap in the chain. - // Fall back to re-provision (return empty path). return []; } path.push(next); diff --git a/packages/vm-cli/src/commands/migrate.ts b/packages/vm-cli/src/commands/migrate.ts index 8ebc665..bed1761 100644 --- a/packages/vm-cli/src/commands/migrate.ts +++ b/packages/vm-cli/src/commands/migrate.ts @@ -36,7 +36,9 @@ async function runMigrate(): Promise { const migrations = findMigrationPath(capability, state); if (migrations.length === 0) { - // Version bump with no migration chain — skip (binary update is enough) + // No migration chain (or gap in chain) — the version bump only needed + // a binary update, no VM-side action. If we ever need re-provisioning + // during updates, it should be an explicit capability hook. log( `${capability.label}: v${state.installed[capability.name]?.version} → v${capability.version} (no migration needed)`, ); From 016053aa1fe6c2aaeae3efaa8bccf094bbd1a3e3 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 18 Mar 2026 22:31:12 +0100 Subject: [PATCH 13/15] docs: document versioning and migration evolution strategy Simplify findMigrationPath JSDoc to just describe the function. Add a "Versioning and migrations" section to docs/capabilities.md that explains the two contexts (full provision vs binary update), when migrations are needed vs skipped, divergence risk, and the escape hatch (re-provision from scratch). Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/capabilities.md | 46 ++++++++++++++++++++++- packages/vm-cli/src/capabilities/state.ts | 9 +---- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/docs/capabilities.md b/docs/capabilities.md index 00be4fc..e7c8d50 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -304,8 +304,50 @@ migrations: [ ], ``` -If no migration path exists, the runner falls through to re-provision -(since steps are idempotent, this is safe). +### Versioning and migrations + +There are two contexts where version drift is handled: + +**Full provisioning** (`claw provision`) — the runner checks for +migrations, and if none exist (no chain declared, or a gap), falls +through to re-running the capability's provision steps. Since steps are +idempotent this is safe, though it may be slower than a targeted +migration. + +**Binary updates** (`claw migrate`) — runs after a `clawctl update` +pushes a new claw binary. Only explicit migration chains are executed. +If no migration path exists, the version is bumped with no VM-side +action — the assumption is that the version bump only needed new binary +code, not VM state changes. + +This means a version bump without a migration is fine when: + +- The change is in claw binary code only (new command, bug fix, better + config interface) +- The change is in provision steps that only matter for fresh installs + +A migration is required when the update needs VM-side action on existing +instances: + +- Config file format changes +- New files that must be written (SKILL.md, wrapper scripts) +- Package installs or removals +- systemd unit changes + +### Divergence risk + +Fresh provisioning always produces the "current version" state. Migrations +produce it incrementally. There is an inherent risk that a migration +chain doesn't exactly reproduce what a clean install would — for example, +a migration might forget to remove an old config key that a clean install +never creates. + +The escape hatch is re-provisioning from scratch: delete the VM and +re-create it. This is always safe and produces a known-good state. + +If we ever need to trigger a full re-provision during an update (without +deleting the VM), it should be built as an explicit capability hook — +not as a silent fallback from a missing migration chain. ## Core vs optional capabilities diff --git a/packages/vm-cli/src/capabilities/state.ts b/packages/vm-cli/src/capabilities/state.ts index b3fcfb4..a577520 100644 --- a/packages/vm-cli/src/capabilities/state.ts +++ b/packages/vm-cli/src/capabilities/state.ts @@ -63,13 +63,8 @@ export function needsMigration(state: CapabilityState, capability: CapabilityDef * * Returns an ordered array of migrations to apply, or an empty array if * no migration path exists (no migrations declared, or a gap in the chain). - * - * Callers handle the empty case based on context: - * - `runner.ts` (full provisioning): falls through to re-provision steps - * - `migrate.ts` (update path): skips — version bump with no VM-side action - * - * If we ever need explicit re-provisioning during updates, it should be - * built as a dedicated capability hook, not as a silent fallback here. + * See docs/capabilities.md "Versioning and migrations" for how callers + * handle the empty case. */ export function findMigrationPath( capability: CapabilityDef, From 9650088e47824fc7ff61f5ece9e1fa5ab059c20b Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 18 Mar 2026 22:39:03 +0100 Subject: [PATCH 14/15] fix: move semver to host-core, add preAction error handling, set clawVersion - Move semver dependency from root to @clawctl/host-core where it's used - Wrap preAction update hook in try/catch so failures don't crash the user's command with a raw stack trace - Actually set clawVersion in the registry on successful update (was always left undefined despite the comment saying callers would set it) Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 2 +- package.json | 1 - packages/cli/bin/cli.tsx | 11 +++++++++-- packages/cli/src/commands/start.ts | 2 ++ packages/cli/src/commands/update.ts | 2 +- packages/host-core/package.json | 3 ++- packages/host-core/src/update-apply.ts | 8 +++++++- 7 files changed, 22 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index b7335f2..b5aa1ff 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,6 @@ "ink-text-input": "^6.0.0", "react": "^19.0.0", "react-devtools-core": "^7.0.1", - "semver": "^7.7.4", "yaml": "^2.8.2", "zod": "^4.3.6", }, @@ -75,6 +74,7 @@ "@clawctl/templates": "workspace:*", "@clawctl/types": "workspace:*", "execa": "^9.0.0", + "semver": "^7.7.4", }, }, "packages/templates": { diff --git a/package.json b/package.json index 097db9f..1f88089 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,6 @@ "ink-text-input": "^6.0.0", "react": "^19.0.0", "react-devtools-core": "^7.0.1", - "semver": "^7.7.4", "yaml": "^2.8.2", "zod": "^4.3.6" }, diff --git a/packages/cli/bin/cli.tsx b/packages/cli/bin/cli.tsx index b983d12..a6b44c9 100755 --- a/packages/cli/bin/cli.tsx +++ b/packages/cli/bin/cli.tsx @@ -265,8 +265,15 @@ program.hook("preAction", async (_thisCommand, actionCommand) => { const commandName = cmd.name(); if (SKIP_UPDATE_COMMANDS.has(commandName)) return; - const result = await checkAndPromptUpdate(pkg.version); - if (result === "updated") process.exit(0); + try { + const result = await checkAndPromptUpdate(pkg.version); + if (result === "updated") process.exit(0); + } catch (err) { + // Update check/apply failed — don't block the user's command + console.error( + `Warning: update check failed: ${err instanceof Error ? err.message : err}`, + ); + } }); await program.parseAsync(); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 6db6afc..835742b 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -8,6 +8,7 @@ import { } from "@clawctl/host-core"; import { CLAW_BIN_PATH } from "@clawctl/types"; import { notifyDaemon } from "@clawctl/daemon"; +import pkg from "../../../../package.json"; export async function runStart(driver: VMDriver, opts: { instance?: string }): Promise { const entry = await requireInstance(opts); @@ -40,6 +41,7 @@ export async function runStart(driver: VMDriver, opts: { instance?: string }): P ); } else if (current) { current.pendingClawUpdate = false; + current.clawVersion = pkg.version; await saveRegistry(registry); console.log("Claw update applied."); } diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 07fd01f..c55bd8d 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -6,7 +6,7 @@ export async function runUpdate(opts: { applyVm?: boolean }): Promise { if (opts.applyVm) { // Internal mode: called by the NEW binary after self-replacement console.log("Updating VMs with new claw binary..."); - const results = await applyVmUpdates(); + const results = await applyVmUpdates(pkg.version); for (const r of results) { const icon = r.status === "updated" ? "\u2713" : r.status === "pending" ? "\u25cb" : "\u00d7"; console.log(` ${icon} ${r.name}: ${r.detail ?? r.status}`); diff --git a/packages/host-core/package.json b/packages/host-core/package.json index 2d675b0..57f029c 100644 --- a/packages/host-core/package.json +++ b/packages/host-core/package.json @@ -10,6 +10,7 @@ "dependencies": { "@clawctl/types": "workspace:*", "@clawctl/templates": "workspace:*", - "execa": "^9.0.0" + "execa": "^9.0.0", + "semver": "^7.7.4" } } diff --git a/packages/host-core/src/update-apply.ts b/packages/host-core/src/update-apply.ts index ae53d38..be9e8b5 100644 --- a/packages/host-core/src/update-apply.ts +++ b/packages/host-core/src/update-apply.ts @@ -50,8 +50,13 @@ export interface VmUpdateResult { /** * Push the new claw binary to all instances and run migrations. * Called by the NEW binary after self-replacement. + * + * @param clawVersion - Version string of the new claw binary (from package.json) */ -export async function applyVmUpdates(configDir?: string): Promise { +export async function applyVmUpdates( + clawVersion: string, + configDir?: string, +): Promise { const registry = await loadRegistry(configDir); const driver = new LimaDriver(); const results: VmUpdateResult[] = []; @@ -69,6 +74,7 @@ export async function applyVmUpdates(configDir?: string): Promise Date: Wed, 18 Mar 2026 22:45:35 +0100 Subject: [PATCH 15/15] style: auto-format with prettier Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/bin/cli.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cli/bin/cli.tsx b/packages/cli/bin/cli.tsx index a6b44c9..a02e5bf 100755 --- a/packages/cli/bin/cli.tsx +++ b/packages/cli/bin/cli.tsx @@ -270,9 +270,7 @@ program.hook("preAction", async (_thisCommand, actionCommand) => { if (result === "updated") process.exit(0); } catch (err) { // Update check/apply failed — don't block the user's command - console.error( - `Warning: update check failed: ${err instanceof Error ? err.message : err}`, - ); + console.error(`Warning: update check failed: ${err instanceof Error ? err.message : err}`); } });