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 diff --git a/bun.lock b/bun.lock index 4c427a9..b5aa1ff 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,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", @@ -73,6 +74,7 @@ "@clawctl/templates": "workspace:*", "@clawctl/types": "workspace:*", "execa": "^9.0.0", + "semver": "^7.7.4", }, }, "packages/templates": { @@ -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/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/package.json b/package.json index 8f3b059..1f88089 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,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/cli/bin/cli.tsx b/packages/cli/bin/cli.tsx index 8a4fdab..a02e5bf 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,38 @@ 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; + + 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/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/start.ts b/packages/cli/src/commands/start.ts index d275692..835742b 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -1,6 +1,14 @@ 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"; +import pkg from "../../../../package.json"; export async function runStart(driver: VMDriver, opts: { instance?: string }): Promise { const entry = await requireInstance(opts); @@ -14,5 +22,36 @@ 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`); + + const registry = await loadRegistry(); + const current = registry.instances[entry.name]; + + 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 = pkg.version; + await saveRegistry(registry); + 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}`, + ); + } + } + await notifyDaemon(); } diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts new file mode 100644 index 0000000..c55bd8d --- /dev/null +++ b/packages/cli/src/commands/update.ts @@ -0,0 +1,47 @@ +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(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}`); + } + 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; + } + + // 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); + + 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()); + }); + }); +} 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/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..be9e8b5 --- /dev/null +++ b/packages/host-core/src/update-apply.ts @@ -0,0 +1,110 @@ +import { writeFile, rename, chmod, rm, mkdtemp } from "fs/promises"; +import { dirname, join } from "path"; +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. + * + * @param clawVersion - Version string of the new claw binary (from package.json) + */ +export async function applyVmUpdates( + clawVersion: string, + 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`); + + if (migrateResult.exitCode === 0) { + entry.pendingClawUpdate = false; + entry.clawVersion = clawVersion; + results.push({ + name, + status: "updated", + detail: "claw updated and migrations applied", + }); + } else { + // Keep pendingClawUpdate set — retry on next start or next update. + // A future clawctl update may ship a fixed claw that unblocks it. + entry.pendingClawUpdate = true; + results.push({ + name, + status: "pending", + detail: `claw updated but migrate failed (exit ${migrateResult.exitCode}) — will retry`, + }); + } + } 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.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); + }); + }); +}); 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; +} 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/capabilities/state.ts b/packages/vm-cli/src/capabilities/state.ts index f2d85ad..a577520 100644 --- a/packages/vm-cli/src/capabilities/state.ts +++ b/packages/vm-cli/src/capabilities/state.ts @@ -62,8 +62,9 @@ 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). + * See docs/capabilities.md "Versioning and migrations" for how callers + * handle the empty case. */ export function findMigrationPath( capability: CapabilityDef, @@ -81,7 +82,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 new file mode 100644 index 0000000..bed1761 --- /dev/null +++ b/packages/vm-cli/src/commands/migrate.ts @@ -0,0 +1,102 @@ +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) { + // 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)`, + ); + 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); + }); +} 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..969a32a --- /dev/null +++ b/tasks/2026-03-18_2134_auto-update-system/TASK.md @@ -0,0 +1,80 @@ +# Auto-Update System for clawctl + +## Status: Resolved + +## 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 + +- [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.