fix(update): harden root selection
This commit is contained in:
parent
777fb6b7bb
commit
4c4c167416
30
src/cli/run-main.test.ts
Normal file
30
src/cli/run-main.test.ts
Normal file
@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -8,7 +8,15 @@ import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
|||||||
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
|
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
|
||||||
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
|
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
|
||||||
import { enableConsoleCapture } from "../logging.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) {
|
export async function runCli(argv: string[] = process.argv) {
|
||||||
loadDotEnv({ quiet: true });
|
loadDotEnv({ quiet: true });
|
||||||
@ -21,12 +29,6 @@ export async function runCli(argv: string[] = process.argv) {
|
|||||||
// Enforce the minimum supported runtime before doing any work.
|
// Enforce the minimum supported runtime before doing any work.
|
||||||
assertSupportedRuntime();
|
assertSupportedRuntime();
|
||||||
|
|
||||||
// Handle --update flag before full program parsing
|
|
||||||
if (argv.includes("--update")) {
|
|
||||||
await updateCommand({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { buildProgram } = await import("./program.js");
|
const { buildProgram } = await import("./program.js");
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
|
|
||||||
@ -42,7 +44,7 @@ export async function runCli(argv: string[] = process.argv) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
await program.parseAsync(argv);
|
await program.parseAsync(rewriteUpdateFlagArgv(argv));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCliMainModule(): boolean {
|
export function isCliMainModule(): boolean {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
|
||||||
|
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
|
||||||
import {
|
import {
|
||||||
runGatewayUpdate,
|
runGatewayUpdate,
|
||||||
type UpdateRunResult,
|
type UpdateRunResult,
|
||||||
@ -103,8 +104,15 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
defaultRuntime.log("");
|
defaultRuntime.log("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const root =
|
||||||
|
(await resolveClawdbotPackageRoot({
|
||||||
|
moduleUrl: import.meta.url,
|
||||||
|
argv1: process.argv[1],
|
||||||
|
cwd: process.cwd(),
|
||||||
|
})) ?? process.cwd();
|
||||||
|
|
||||||
const result = await runGatewayUpdate({
|
const result = await runGatewayUpdate({
|
||||||
cwd: process.cwd(),
|
cwd: root,
|
||||||
argv1: process.argv[1],
|
argv1: process.argv[1],
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
});
|
});
|
||||||
@ -124,6 +132,18 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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);
|
defaultRuntime.exit(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -141,9 +161,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!opts.json) {
|
if (!opts.json) {
|
||||||
defaultRuntime.log(
|
defaultRuntime.log(theme.warn(`Daemon restart failed: ${String(err)}`));
|
||||||
theme.warn(`Daemon restart failed: ${String(err)}`),
|
|
||||||
);
|
|
||||||
defaultRuntime.log(
|
defaultRuntime.log(
|
||||||
theme.muted(
|
theme.muted(
|
||||||
"You may need to restart the daemon manually: clawdbot daemon restart",
|
"You may need to restart the daemon manually: clawdbot daemon restart",
|
||||||
@ -179,14 +197,14 @@ export function registerUpdateCli(program: Command) {
|
|||||||
"after",
|
"after",
|
||||||
`
|
`
|
||||||
Examples:
|
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 --restart # Update and restart the daemon
|
||||||
clawdbot update --json # Output result as JSON
|
clawdbot update --json # Output result as JSON
|
||||||
clawdbot --update # Shorthand for clawdbot update
|
clawdbot --update # Shorthand for clawdbot update
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- For git installs: fetches, rebases, installs deps, builds, and runs doctor
|
- 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
|
- Skips update if the working directory has uncommitted changes
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { resolveClawdbotPackageRoot } from "../../infra/clawdbot-root.js";
|
||||||
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
|
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
|
||||||
import {
|
import {
|
||||||
type RestartSentinelPayload,
|
type RestartSentinelPayload,
|
||||||
@ -48,9 +49,15 @@ export const updateHandlers: GatewayRequestHandlers = {
|
|||||||
|
|
||||||
let result: Awaited<ReturnType<typeof runGatewayUpdate>>;
|
let result: Awaited<ReturnType<typeof runGatewayUpdate>>;
|
||||||
try {
|
try {
|
||||||
|
const root =
|
||||||
|
(await resolveClawdbotPackageRoot({
|
||||||
|
moduleUrl: import.meta.url,
|
||||||
|
argv1: process.argv[1],
|
||||||
|
cwd: process.cwd(),
|
||||||
|
})) ?? process.cwd();
|
||||||
result = await runGatewayUpdate({
|
result = await runGatewayUpdate({
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
cwd: process.cwd(),
|
cwd: root,
|
||||||
argv1: process.argv[1],
|
argv1: process.argv[1],
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
66
src/infra/clawdbot-root.ts
Normal file
66
src/infra/clawdbot-root.ts
Normal file
@ -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<string | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
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";
|
import { runGatewayUpdate } from "./update-runner.js";
|
||||||
|
|
||||||
@ -86,7 +86,7 @@ describe("runGatewayUpdate", () => {
|
|||||||
expect(calls.some((call) => call.includes("rebase --abort"))).toBe(true);
|
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(
|
await fs.writeFile(
|
||||||
path.join(tempDir, "package.json"),
|
path.join(tempDir, "package.json"),
|
||||||
JSON.stringify({ name: "clawdbot", packageManager: "pnpm@8.0.0" }),
|
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");
|
await fs.writeFile(path.join(tempDir, "pnpm-lock.yaml"), "", "utf-8");
|
||||||
const { runner, calls } = createRunner({
|
const { runner, calls } = createRunner({
|
||||||
[`git -C ${tempDir} rev-parse --show-toplevel`]: { code: 1 },
|
[`git -C ${tempDir} rev-parse --show-toplevel`]: { code: 1 },
|
||||||
"pnpm update": { stdout: "ok" },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runGatewayUpdate({
|
const result = await runGatewayUpdate({
|
||||||
@ -104,8 +103,32 @@ describe("runGatewayUpdate", () => {
|
|||||||
timeoutMs: 5000,
|
timeoutMs: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.status).toBe("ok");
|
expect(result.status).toBe("skipped");
|
||||||
expect(result.mode).toBe("pnpm");
|
expect(result.reason).toBe("not-git-install");
|
||||||
expect(calls.some((call) => call.includes("pnpm update"))).toBe(true);
|
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,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -49,12 +49,27 @@ function normalizeDir(value?: string | null) {
|
|||||||
return path.resolve(trimmed);
|
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[] {
|
function buildStartDirs(opts: UpdateRunnerOptions): string[] {
|
||||||
const dirs: string[] = [];
|
const dirs: string[] = [];
|
||||||
const cwd = normalizeDir(opts.cwd);
|
const cwd = normalizeDir(opts.cwd);
|
||||||
if (cwd) dirs.push(cwd);
|
if (cwd) dirs.push(cwd);
|
||||||
const argv1 = normalizeDir(opts.argv1);
|
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());
|
const proc = normalizeDir(process.cwd());
|
||||||
if (proc) dirs.push(proc);
|
if (proc) dirs.push(proc);
|
||||||
return Array.from(new Set(dirs));
|
return Array.from(new Set(dirs));
|
||||||
@ -165,12 +180,6 @@ function managerInstallArgs(manager: "pnpm" | "bun" | "npm") {
|
|||||||
return ["npm", "install"];
|
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(
|
export async function runGatewayUpdate(
|
||||||
opts: UpdateRunnerOptions = {},
|
opts: UpdateRunnerOptions = {},
|
||||||
): Promise<UpdateRunResult> {
|
): Promise<UpdateRunResult> {
|
||||||
@ -185,8 +194,25 @@ export async function runGatewayUpdate(
|
|||||||
const steps: UpdateStepResult[] = [];
|
const steps: UpdateStepResult[] = [];
|
||||||
const candidates = buildStartDirs(opts);
|
const candidates = buildStartDirs(opts);
|
||||||
|
|
||||||
const gitRoot = await resolveGitRoot(runCommand, candidates, timeoutMs);
|
const pkgRoot = await findPackageRoot(candidates);
|
||||||
if (gitRoot) {
|
|
||||||
|
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 = (
|
const beforeSha = (
|
||||||
await runStep(
|
await runStep(
|
||||||
runCommand,
|
runCommand,
|
||||||
@ -349,7 +375,6 @@ export async function runGatewayUpdate(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const pkgRoot = await findPackageRoot(candidates);
|
|
||||||
if (!pkgRoot) {
|
if (!pkgRoot) {
|
||||||
return {
|
return {
|
||||||
status: "error",
|
status: "error",
|
||||||
@ -359,23 +384,15 @@ export async function runGatewayUpdate(
|
|||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const manager = await detectPackageManager(pkgRoot);
|
|
||||||
steps.push(
|
const beforeVersion = await readPackageVersion(pkgRoot);
|
||||||
await runStep(
|
|
||||||
runCommand,
|
|
||||||
"deps update",
|
|
||||||
managerUpdateArgs(manager),
|
|
||||||
pkgRoot,
|
|
||||||
timeoutMs,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const failed = steps.find((step) => step.exitCode !== 0);
|
|
||||||
return {
|
return {
|
||||||
status: failed ? "error" : "ok",
|
status: "skipped",
|
||||||
mode: manager,
|
mode: "unknown",
|
||||||
root: pkgRoot,
|
root: pkgRoot,
|
||||||
reason: failed ? failed.name : undefined,
|
reason: "not-git-install",
|
||||||
steps,
|
before: { version: beforeVersion },
|
||||||
|
steps: [],
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user