fix(update): harden root selection
This commit is contained in:
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 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<UpdateRunResult> {
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user