diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts new file mode 100644 index 000000000..c9905bd93 --- /dev/null +++ b/src/cli/run-main.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { rewriteUpdateFlagArgv } from "./run-main.js"; + +describe("rewriteUpdateFlagArgv", () => { + it("leaves argv unchanged when --update is absent", () => { + const argv = ["node", "entry.js", "status"]; + expect(rewriteUpdateFlagArgv(argv)).toBe(argv); + }); + + it("rewrites --update into the update command", () => { + expect(rewriteUpdateFlagArgv(["node", "entry.js", "--update"])).toEqual([ + "node", + "entry.js", + "update", + ]); + }); + + it("preserves global flags that appear before --update", () => { + expect( + rewriteUpdateFlagArgv(["node", "entry.js", "--profile", "p", "--update"]), + ).toEqual(["node", "entry.js", "--profile", "p", "update"]); + }); + + it("keeps update options after the rewritten command", () => { + expect( + rewriteUpdateFlagArgv(["node", "entry.js", "--update", "--json"]), + ).toEqual(["node", "entry.js", "update", "--json"]); + }); +}); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index e52bf0c6a..0c05c2505 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -8,7 +8,15 @@ import { ensureClawdbotCliOnPath } from "../infra/path-env.js"; import { assertSupportedRuntime } from "../infra/runtime-guard.js"; import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import { enableConsoleCapture } from "../logging.js"; -import { updateCommand } from "./update-cli.js"; + +export function rewriteUpdateFlagArgv(argv: string[]): string[] { + const index = argv.indexOf("--update"); + if (index === -1) return argv; + + const next = [...argv]; + next.splice(index, 1, "update"); + return next; +} export async function runCli(argv: string[] = process.argv) { loadDotEnv({ quiet: true }); @@ -21,12 +29,6 @@ export async function runCli(argv: string[] = process.argv) { // Enforce the minimum supported runtime before doing any work. assertSupportedRuntime(); - // Handle --update flag before full program parsing - if (argv.includes("--update")) { - await updateCommand({}); - return; - } - const { buildProgram } = await import("./program.js"); const program = buildProgram(); @@ -42,7 +44,7 @@ export async function runCli(argv: string[] = process.argv) { process.exit(1); }); - await program.parseAsync(argv); + await program.parseAsync(rewriteUpdateFlagArgv(argv)); } export function isCliMainModule(): boolean { diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 9ef5a1e3e..3f05cf0fa 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; +import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js"; import { runGatewayUpdate, type UpdateRunResult, @@ -103,8 +104,15 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { defaultRuntime.log(""); } + const root = + (await resolveClawdbotPackageRoot({ + moduleUrl: import.meta.url, + argv1: process.argv[1], + cwd: process.cwd(), + })) ?? process.cwd(); + const result = await runGatewayUpdate({ - cwd: process.cwd(), + cwd: root, argv1: process.argv[1], timeoutMs, }); @@ -124,6 +132,18 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { ), ); } + if (result.reason === "not-git-install") { + defaultRuntime.log( + theme.warn( + "Skipped: this Clawdbot install isn't a git checkout. Update via your package manager, then run `clawdbot doctor` and `clawdbot daemon restart`.", + ), + ); + defaultRuntime.log( + theme.muted( + "Examples: `npm i -g clawdbot@latest` or `pnpm add -g clawdbot@latest`", + ), + ); + } defaultRuntime.exit(0); return; } @@ -141,9 +161,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { } } catch (err) { if (!opts.json) { - defaultRuntime.log( - theme.warn(`Daemon restart failed: ${String(err)}`), - ); + defaultRuntime.log(theme.warn(`Daemon restart failed: ${String(err)}`)); defaultRuntime.log( theme.muted( "You may need to restart the daemon manually: clawdbot daemon restart", @@ -179,14 +197,14 @@ export function registerUpdateCli(program: Command) { "after", ` Examples: - clawdbot update # Update from git or package manager + clawdbot update # Update a source checkout (git) clawdbot update --restart # Update and restart the daemon clawdbot update --json # Output result as JSON clawdbot --update # Shorthand for clawdbot update Notes: - For git installs: fetches, rebases, installs deps, builds, and runs doctor - - For npm installs: runs package manager update command + - For npm installs: use npm/pnpm to reinstall (see docs/install/updating.md) - Skips update if the working directory has uncommitted changes `, ) diff --git a/src/gateway/server-methods/update.ts b/src/gateway/server-methods/update.ts index 3901eb782..086eeb7fe 100644 --- a/src/gateway/server-methods/update.ts +++ b/src/gateway/server-methods/update.ts @@ -1,3 +1,4 @@ +import { resolveClawdbotPackageRoot } from "../../infra/clawdbot-root.js"; import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; import { type RestartSentinelPayload, @@ -48,9 +49,15 @@ export const updateHandlers: GatewayRequestHandlers = { let result: Awaited>; try { + const root = + (await resolveClawdbotPackageRoot({ + moduleUrl: import.meta.url, + argv1: process.argv[1], + cwd: process.cwd(), + })) ?? process.cwd(); result = await runGatewayUpdate({ timeoutMs, - cwd: process.cwd(), + cwd: root, argv1: process.argv[1], }); } catch (err) { diff --git a/src/infra/clawdbot-root.ts b/src/infra/clawdbot-root.ts new file mode 100644 index 000000000..5cc5e21c1 --- /dev/null +++ b/src/infra/clawdbot-root.ts @@ -0,0 +1,66 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +async function readPackageName(dir: string): Promise { + try { + const raw = await fs.readFile(path.join(dir, "package.json"), "utf-8"); + const parsed = JSON.parse(raw) as { name?: unknown }; + return typeof parsed.name === "string" ? parsed.name : null; + } catch { + return null; + } +} + +async function findPackageRoot( + startDir: string, + maxDepth = 12, +): Promise { + let current = path.resolve(startDir); + for (let i = 0; i < maxDepth; i += 1) { + const name = await readPackageName(current); + if (name === "clawdbot") return current; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return null; +} + +function candidateDirsFromArgv1(argv1: string): string[] { + const normalized = path.resolve(argv1); + const candidates = [path.dirname(normalized)]; + const parts = normalized.split(path.sep); + const binIndex = parts.lastIndexOf(".bin"); + if (binIndex > 0 && parts[binIndex - 1] === "node_modules") { + const binName = path.basename(normalized); + const nodeModulesDir = parts.slice(0, binIndex).join(path.sep); + candidates.push(path.join(nodeModulesDir, binName)); + } + return candidates; +} + +export async function resolveClawdbotPackageRoot(opts: { + cwd?: string; + argv1?: string; + moduleUrl?: string; +}): Promise { + const candidates: string[] = []; + + if (opts.moduleUrl) { + candidates.push(path.dirname(fileURLToPath(opts.moduleUrl))); + } + if (opts.argv1) { + candidates.push(...candidateDirsFromArgv1(opts.argv1)); + } + if (opts.cwd) { + candidates.push(opts.cwd); + } + + for (const candidate of candidates) { + const found = await findPackageRoot(candidate); + if (found) return found; + } + + return null; +} diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index e6c10ad02..ccc7cceaa 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { runGatewayUpdate } from "./update-runner.js"; @@ -86,7 +86,7 @@ describe("runGatewayUpdate", () => { expect(calls.some((call) => call.includes("rebase --abort"))).toBe(true); }); - it("runs package manager update when no git root", async () => { + it("skips update when no git root", async () => { await fs.writeFile( path.join(tempDir, "package.json"), JSON.stringify({ name: "clawdbot", packageManager: "pnpm@8.0.0" }), @@ -95,7 +95,6 @@ describe("runGatewayUpdate", () => { await fs.writeFile(path.join(tempDir, "pnpm-lock.yaml"), "", "utf-8"); const { runner, calls } = createRunner({ [`git -C ${tempDir} rev-parse --show-toplevel`]: { code: 1 }, - "pnpm update": { stdout: "ok" }, }); const result = await runGatewayUpdate({ @@ -104,8 +103,32 @@ describe("runGatewayUpdate", () => { timeoutMs: 5000, }); - expect(result.status).toBe("ok"); - expect(result.mode).toBe("pnpm"); - expect(calls.some((call) => call.includes("pnpm update"))).toBe(true); + expect(result.status).toBe("skipped"); + expect(result.reason).toBe("not-git-install"); + expect(calls.some((call) => call.startsWith("pnpm "))).toBe(false); + expect(calls.some((call) => call.startsWith("npm "))).toBe(false); + expect(calls.some((call) => call.startsWith("bun "))).toBe(false); + }); + + it("rejects git roots that are not a clawdbot checkout", async () => { + await fs.mkdir(path.join(tempDir, ".git")); + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(tempDir); + const { runner, calls } = createRunner({ + [`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir }, + }); + + const result = await runGatewayUpdate({ + cwd: tempDir, + runCommand: async (argv, _options) => runner(argv), + timeoutMs: 5000, + }); + + cwdSpy.mockRestore(); + + expect(result.status).toBe("error"); + expect(result.reason).toBe("not-clawdbot-root"); + expect(calls.some((call) => call.includes("status --porcelain"))).toBe( + false, + ); }); }); diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 68eea79fb..5d3f8be7e 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -49,12 +49,27 @@ function normalizeDir(value?: string | null) { return path.resolve(trimmed); } +function resolveNodeModulesBinPackageRoot(argv1: string): string | null { + const normalized = path.resolve(argv1); + const parts = normalized.split(path.sep); + const binIndex = parts.lastIndexOf(".bin"); + if (binIndex <= 0) return null; + if (parts[binIndex - 1] !== "node_modules") return null; + const binName = path.basename(normalized); + const nodeModulesDir = parts.slice(0, binIndex).join(path.sep); + return path.join(nodeModulesDir, binName); +} + function buildStartDirs(opts: UpdateRunnerOptions): string[] { const dirs: string[] = []; const cwd = normalizeDir(opts.cwd); if (cwd) dirs.push(cwd); const argv1 = normalizeDir(opts.argv1); - if (argv1) dirs.push(path.dirname(argv1)); + if (argv1) { + dirs.push(path.dirname(argv1)); + const packageRoot = resolveNodeModulesBinPackageRoot(argv1); + if (packageRoot) dirs.push(packageRoot); + } const proc = normalizeDir(process.cwd()); if (proc) dirs.push(proc); return Array.from(new Set(dirs)); @@ -165,12 +180,6 @@ function managerInstallArgs(manager: "pnpm" | "bun" | "npm") { return ["npm", "install"]; } -function managerUpdateArgs(manager: "pnpm" | "bun" | "npm") { - if (manager === "pnpm") return ["pnpm", "update"]; - if (manager === "bun") return ["bun", "update"]; - return ["npm", "update"]; -} - export async function runGatewayUpdate( opts: UpdateRunnerOptions = {}, ): Promise { @@ -185,8 +194,25 @@ export async function runGatewayUpdate( const steps: UpdateStepResult[] = []; const candidates = buildStartDirs(opts); - const gitRoot = await resolveGitRoot(runCommand, candidates, timeoutMs); - if (gitRoot) { + const pkgRoot = await findPackageRoot(candidates); + + let gitRoot = await resolveGitRoot(runCommand, candidates, timeoutMs); + if (gitRoot && pkgRoot && path.resolve(gitRoot) !== path.resolve(pkgRoot)) { + gitRoot = null; + } + + if (gitRoot && !pkgRoot) { + return { + status: "error", + mode: "unknown", + root: gitRoot, + reason: "not-clawdbot-root", + steps: [], + durationMs: Date.now() - startedAt, + }; + } + + if (gitRoot && pkgRoot && path.resolve(gitRoot) === path.resolve(pkgRoot)) { const beforeSha = ( await runStep( runCommand, @@ -349,7 +375,6 @@ export async function runGatewayUpdate( }; } - const pkgRoot = await findPackageRoot(candidates); if (!pkgRoot) { return { status: "error", @@ -359,23 +384,15 @@ export async function runGatewayUpdate( durationMs: Date.now() - startedAt, }; } - const manager = await detectPackageManager(pkgRoot); - steps.push( - await runStep( - runCommand, - "deps update", - managerUpdateArgs(manager), - pkgRoot, - timeoutMs, - ), - ); - const failed = steps.find((step) => step.exitCode !== 0); + + const beforeVersion = await readPackageVersion(pkgRoot); return { - status: failed ? "error" : "ok", - mode: manager, + status: "skipped", + mode: "unknown", root: pkgRoot, - reason: failed ? failed.name : undefined, - steps, + reason: "not-git-install", + before: { version: beforeVersion }, + steps: [], durationMs: Date.now() - startedAt, }; }