fix(cli): auto-update global installs
This commit is contained in:
@@ -48,6 +48,7 @@
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- WhatsApp: default response prefix only for self-chat, using identity name when set.
|
- WhatsApp: default response prefix only for self-chat, using identity name when set.
|
||||||
|
- Fix: make `clawdbot update` auto-update global installs when installed via a package manager.
|
||||||
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
||||||
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.
|
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.
|
||||||
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const STEP_LABELS: Record<string, string> = {
|
|||||||
"ui:build": "Building UI",
|
"ui:build": "Building UI",
|
||||||
"clawdbot doctor": "Running doctor checks",
|
"clawdbot doctor": "Running doctor checks",
|
||||||
"git rev-parse HEAD (after)": "Verifying update",
|
"git rev-parse HEAD (after)": "Verifying update",
|
||||||
|
"global update": "Updating via package manager",
|
||||||
};
|
};
|
||||||
|
|
||||||
function getStepLabel(step: UpdateStepInfo): string {
|
function getStepLabel(step: UpdateStepInfo): string {
|
||||||
@@ -206,12 +207,12 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
if (result.reason === "not-git-install") {
|
if (result.reason === "not-git-install") {
|
||||||
defaultRuntime.log(
|
defaultRuntime.log(
|
||||||
theme.warn(
|
theme.warn(
|
||||||
"Skipped: this Clawdbot install isn't a git checkout. Update via your package manager, then run `clawdbot doctor` and `clawdbot daemon restart`.",
|
"Skipped: this Clawdbot install isn't a git checkout, and the package manager couldn't be detected. Update via your package manager, then run `clawdbot doctor` and `clawdbot daemon restart`.",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
defaultRuntime.log(
|
defaultRuntime.log(
|
||||||
theme.muted(
|
theme.muted(
|
||||||
"Examples: `npm i -g clawdbot@latest`, `pnpm add -g clawdbot@latest`, or `bun add -g clawdbot@latest`",
|
"Examples: `npm i -g clawdbot@latest` or `pnpm add -g clawdbot@latest`",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -251,9 +252,17 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
} else if (!opts.json) {
|
} else if (!opts.json) {
|
||||||
defaultRuntime.log("");
|
defaultRuntime.log("");
|
||||||
defaultRuntime.log(
|
if (result.mode === "npm" || result.mode === "pnpm") {
|
||||||
theme.muted("Tip: Run `clawdbot daemon restart` to apply updates to a running gateway."),
|
defaultRuntime.log(
|
||||||
);
|
theme.muted(
|
||||||
|
"Tip: Run `clawdbot doctor`, then `clawdbot daemon restart` to apply updates to a running gateway.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
defaultRuntime.log(
|
||||||
|
theme.muted("Tip: Run `clawdbot daemon restart` to apply updates to a running gateway."),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +285,7 @@ Examples:
|
|||||||
|
|
||||||
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 global installs: use npm/pnpm/bun to reinstall (see docs/install/updating.md)
|
- For global installs: auto-updates via detected package manager when possible (see docs/install/updating.md)
|
||||||
- Skips update if the working directory has uncommitted changes
|
- Skips update if the working directory has uncommitted changes
|
||||||
|
|
||||||
${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`,
|
${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`,
|
||||||
|
|||||||
@@ -70,10 +70,7 @@ export async function maybeOfferUpdateBeforeDoctor(params: {
|
|||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
"This install is not a git checkout.",
|
"This install is not a git checkout.",
|
||||||
"Update via your package manager, then rerun doctor:",
|
"Run `clawdbot update` to update via your package manager (npm/pnpm), then rerun doctor.",
|
||||||
"- npm i -g clawdbot@latest",
|
|
||||||
"- pnpm add -g clawdbot@latest",
|
|
||||||
"- bun add -g clawdbot@latest",
|
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Update",
|
"Update",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ 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 },
|
||||||
|
"npm root -g": { code: 1 },
|
||||||
|
"pnpm root -g": { code: 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runGatewayUpdate({
|
const result = await runGatewayUpdate({
|
||||||
@@ -106,9 +108,111 @@ describe("runGatewayUpdate", () => {
|
|||||||
|
|
||||||
expect(result.status).toBe("skipped");
|
expect(result.status).toBe("skipped");
|
||||||
expect(result.reason).toBe("not-git-install");
|
expect(result.reason).toBe("not-git-install");
|
||||||
expect(calls.some((call) => call.startsWith("pnpm "))).toBe(false);
|
expect(calls.some((call) => call.startsWith("pnpm add -g"))).toBe(false);
|
||||||
expect(calls.some((call) => call.startsWith("npm "))).toBe(false);
|
expect(calls.some((call) => call.startsWith("npm i -g"))).toBe(false);
|
||||||
expect(calls.some((call) => call.startsWith("bun "))).toBe(false);
|
});
|
||||||
|
|
||||||
|
it("updates global npm installs when detected", async () => {
|
||||||
|
const nodeModules = path.join(tempDir, "node_modules");
|
||||||
|
const pkgRoot = path.join(nodeModules, "clawdbot");
|
||||||
|
await fs.mkdir(pkgRoot, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(pkgRoot, "package.json"),
|
||||||
|
JSON.stringify({ name: "clawdbot", version: "1.0.0" }),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls: string[] = [];
|
||||||
|
const runCommand = async (argv: string[]) => {
|
||||||
|
const key = argv.join(" ");
|
||||||
|
calls.push(key);
|
||||||
|
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) {
|
||||||
|
return { stdout: "", stderr: "not a git repository", code: 128 };
|
||||||
|
}
|
||||||
|
if (key === "npm root -g") {
|
||||||
|
return { stdout: nodeModules, stderr: "", code: 0 };
|
||||||
|
}
|
||||||
|
if (key === "npm i -g clawdbot@latest") {
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(pkgRoot, "package.json"),
|
||||||
|
JSON.stringify({ name: "clawdbot", version: "2.0.0" }),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
return { stdout: "ok", stderr: "", code: 0 };
|
||||||
|
}
|
||||||
|
if (key === "pnpm root -g") {
|
||||||
|
return { stdout: "", stderr: "", code: 1 };
|
||||||
|
}
|
||||||
|
return { stdout: "", stderr: "", code: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await runGatewayUpdate({
|
||||||
|
cwd: pkgRoot,
|
||||||
|
runCommand: async (argv, _options) => runCommand(argv),
|
||||||
|
timeoutMs: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("ok");
|
||||||
|
expect(result.mode).toBe("npm");
|
||||||
|
expect(result.before?.version).toBe("1.0.0");
|
||||||
|
expect(result.after?.version).toBe("2.0.0");
|
||||||
|
expect(calls.some((call) => call === "npm i -g clawdbot@latest")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates global bun installs when detected", async () => {
|
||||||
|
const oldBunInstall = process.env.BUN_INSTALL;
|
||||||
|
const bunInstall = path.join(tempDir, "bun-install");
|
||||||
|
process.env.BUN_INSTALL = bunInstall;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bunGlobalRoot = path.join(bunInstall, "install", "global", "node_modules");
|
||||||
|
const pkgRoot = path.join(bunGlobalRoot, "clawdbot");
|
||||||
|
await fs.mkdir(pkgRoot, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(pkgRoot, "package.json"),
|
||||||
|
JSON.stringify({ name: "clawdbot", version: "1.0.0" }),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls: string[] = [];
|
||||||
|
const runCommand = async (argv: string[]) => {
|
||||||
|
const key = argv.join(" ");
|
||||||
|
calls.push(key);
|
||||||
|
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) {
|
||||||
|
return { stdout: "", stderr: "not a git repository", code: 128 };
|
||||||
|
}
|
||||||
|
if (key === "npm root -g") {
|
||||||
|
return { stdout: "", stderr: "", code: 1 };
|
||||||
|
}
|
||||||
|
if (key === "pnpm root -g") {
|
||||||
|
return { stdout: "", stderr: "", code: 1 };
|
||||||
|
}
|
||||||
|
if (key === "bun add -g clawdbot@latest") {
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(pkgRoot, "package.json"),
|
||||||
|
JSON.stringify({ name: "clawdbot", version: "2.0.0" }),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
return { stdout: "ok", stderr: "", code: 0 };
|
||||||
|
}
|
||||||
|
return { stdout: "", stderr: "", code: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await runGatewayUpdate({
|
||||||
|
cwd: pkgRoot,
|
||||||
|
runCommand: async (argv, _options) => runCommand(argv),
|
||||||
|
timeoutMs: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("ok");
|
||||||
|
expect(result.mode).toBe("bun");
|
||||||
|
expect(result.before?.version).toBe("1.0.0");
|
||||||
|
expect(result.after?.version).toBe("2.0.0");
|
||||||
|
expect(calls.some((call) => call === "bun add -g clawdbot@latest")).toBe(true);
|
||||||
|
} finally {
|
||||||
|
if (oldBunInstall === undefined) delete process.env.BUN_INSTALL;
|
||||||
|
else process.env.BUN_INSTALL = oldBunInstall;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects git roots that are not a clawdbot checkout", async () => {
|
it("rejects git roots that are not a clawdbot checkout", async () => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os from "node:os";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
@@ -158,6 +159,52 @@ async function detectPackageManager(root: string) {
|
|||||||
return "npm";
|
return "npm";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function tryRealpath(value: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await fs.realpath(value);
|
||||||
|
} catch {
|
||||||
|
return path.resolve(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectGlobalInstallManager(
|
||||||
|
runCommand: CommandRunner,
|
||||||
|
pkgRoot: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<"npm" | "pnpm" | "bun" | null> {
|
||||||
|
const pkgReal = await tryRealpath(pkgRoot);
|
||||||
|
|
||||||
|
const candidates: Array<{
|
||||||
|
manager: "npm" | "pnpm";
|
||||||
|
argv: string[];
|
||||||
|
}> = [
|
||||||
|
{ manager: "npm", argv: ["npm", "root", "-g"] },
|
||||||
|
{ manager: "pnpm", argv: ["pnpm", "root", "-g"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { manager, argv } of candidates) {
|
||||||
|
const res = await runCommand(argv, { timeoutMs }).catch(() => null);
|
||||||
|
if (!res) continue;
|
||||||
|
if (res.code !== 0) continue;
|
||||||
|
const globalRoot = res.stdout.trim();
|
||||||
|
if (!globalRoot) continue;
|
||||||
|
|
||||||
|
const globalReal = await tryRealpath(globalRoot);
|
||||||
|
const expected = path.join(globalReal, "clawdbot");
|
||||||
|
if (path.resolve(expected) === path.resolve(pkgReal)) return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bun doesn't have an officially stable "global root" command across versions,
|
||||||
|
// so we check the common global install path (best-effort).
|
||||||
|
const bunInstall = process.env.BUN_INSTALL?.trim() || path.join(os.homedir(), ".bun");
|
||||||
|
const bunGlobalRoot = path.join(bunInstall, "install", "global", "node_modules");
|
||||||
|
const bunGlobalReal = await tryRealpath(bunGlobalRoot);
|
||||||
|
const bunExpected = path.join(bunGlobalReal, "clawdbot");
|
||||||
|
if (path.resolve(bunExpected) === path.resolve(pkgReal)) return "bun";
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
type RunStepOptions = {
|
type RunStepOptions = {
|
||||||
runCommand: CommandRunner;
|
runCommand: CommandRunner;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -220,6 +267,12 @@ function managerInstallArgs(manager: "pnpm" | "bun" | "npm") {
|
|||||||
return ["npm", "install"];
|
return ["npm", "install"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function globalUpdateArgs(manager: "pnpm" | "npm" | "bun") {
|
||||||
|
if (manager === "pnpm") return ["pnpm", "add", "-g", "clawdbot@latest"];
|
||||||
|
if (manager === "bun") return ["bun", "add", "-g", "clawdbot@latest"];
|
||||||
|
return ["npm", "i", "-g", "clawdbot@latest"];
|
||||||
|
}
|
||||||
|
|
||||||
// Total number of visible steps in a successful git update flow
|
// Total number of visible steps in a successful git update flow
|
||||||
const GIT_UPDATE_TOTAL_STEPS = 9;
|
const GIT_UPDATE_TOTAL_STEPS = 9;
|
||||||
|
|
||||||
@@ -414,6 +467,32 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const beforeVersion = await readPackageVersion(pkgRoot);
|
const beforeVersion = await readPackageVersion(pkgRoot);
|
||||||
|
const globalManager = await detectGlobalInstallManager(runCommand, pkgRoot, timeoutMs);
|
||||||
|
if (globalManager) {
|
||||||
|
const updateStep = await runStep({
|
||||||
|
runCommand,
|
||||||
|
name: "global update",
|
||||||
|
argv: globalUpdateArgs(globalManager),
|
||||||
|
cwd: pkgRoot,
|
||||||
|
timeoutMs,
|
||||||
|
progress,
|
||||||
|
stepIndex: 0,
|
||||||
|
totalSteps: 1,
|
||||||
|
});
|
||||||
|
const steps = [updateStep];
|
||||||
|
const afterVersion = await readPackageVersion(pkgRoot);
|
||||||
|
return {
|
||||||
|
status: updateStep.exitCode === 0 ? "ok" : "error",
|
||||||
|
mode: globalManager,
|
||||||
|
root: pkgRoot,
|
||||||
|
reason: updateStep.exitCode === 0 ? undefined : updateStep.name,
|
||||||
|
before: { version: beforeVersion },
|
||||||
|
after: { version: afterVersion },
|
||||||
|
steps,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: "skipped",
|
status: "skipped",
|
||||||
mode: "unknown",
|
mode: "unknown",
|
||||||
|
|||||||
Reference in New Issue
Block a user