feat: align update channel installs
This commit is contained in:
@@ -7,9 +7,9 @@ read_when:
|
||||
|
||||
# `clawdbot update`
|
||||
|
||||
Safely update a **source checkout** (git install) of Clawdbot.
|
||||
Safely update Clawdbot and switch between stable/beta/dev channels.
|
||||
|
||||
If you installed via **npm/pnpm** (global install, no git metadata), use the package manager flow in [Updating](/install/updating).
|
||||
If you installed via **npm/pnpm** (global install, no git metadata), updates happen via the package manager flow in [Updating](/install/updating).
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -48,7 +48,16 @@ Options:
|
||||
- `--json`: print machine-readable status JSON.
|
||||
- `--timeout <seconds>`: timeout for checks (default is 3s).
|
||||
|
||||
## What it does (git checkout)
|
||||
## What it does
|
||||
|
||||
When you switch channels explicitly (`--channel ...`), Clawdbot also keeps the
|
||||
install method aligned:
|
||||
|
||||
- `dev` → ensures a git checkout (default: `~/clawdbot`, override with `CLAWDBOT_GIT_DIR`),
|
||||
updates it, and installs the global CLI from that checkout.
|
||||
- `stable`/`beta` → installs from npm using the matching dist-tag.
|
||||
|
||||
## Git checkout flow
|
||||
|
||||
Channels:
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ read_when:
|
||||
|
||||
# Development channels
|
||||
|
||||
Last updated: 2026-01-20
|
||||
Last updated: 2026-01-21
|
||||
|
||||
Clawdbot ships three update channels:
|
||||
|
||||
@@ -38,6 +38,13 @@ clawdbot update --channel dev
|
||||
|
||||
This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`).
|
||||
|
||||
When you **explicitly** switch channels with `--channel`, Clawdbot also aligns
|
||||
the install method:
|
||||
|
||||
- `dev` ensures a git checkout (default `~/clawdbot`, override with `CLAWDBOT_GIT_DIR`),
|
||||
updates it, and installs the global CLI from that checkout.
|
||||
- `stable`/`beta` installs from npm using the matching dist-tag.
|
||||
|
||||
Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one.
|
||||
|
||||
## Plugins and channels
|
||||
|
||||
@@ -31,6 +31,10 @@ vi.mock("../infra/update-check.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runCommandWithTimeout: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock doctor (heavy module; should not run in unit tests)
|
||||
vi.mock("../commands/doctor.js", () => ({
|
||||
doctorCommand: vi.fn(),
|
||||
@@ -76,6 +80,7 @@ describe("update-cli", () => {
|
||||
const { readConfigFileSnapshot } = await import("../config/config.js");
|
||||
const { checkUpdateStatus, fetchNpmTagVersion, resolveNpmChannelTag } =
|
||||
await import("../infra/update-check.js");
|
||||
const { runCommandWithTimeout } = await import("../process/exec.js");
|
||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(process.cwd());
|
||||
vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot);
|
||||
vi.mocked(fetchNpmTagVersion).mockResolvedValue({
|
||||
@@ -111,6 +116,13 @@ describe("update-cli", () => {
|
||||
latestVersion: "1.2.3",
|
||||
},
|
||||
});
|
||||
vi.mocked(runCommandWithTimeout).mockResolvedValue({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
});
|
||||
setTty(false);
|
||||
setStdoutTty(false);
|
||||
});
|
||||
@@ -202,9 +214,21 @@ describe("update-cli", () => {
|
||||
|
||||
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
|
||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||
const { checkUpdateStatus } = await import("../infra/update-check.js");
|
||||
const { updateCommand } = await import("./update-cli.js");
|
||||
|
||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
||||
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||
root: tempDir,
|
||||
installKind: "package",
|
||||
packageManager: "npm",
|
||||
deps: {
|
||||
manager: "npm",
|
||||
status: "ok",
|
||||
lockfilePath: null,
|
||||
markerPath: null,
|
||||
},
|
||||
});
|
||||
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
||||
status: "ok",
|
||||
mode: "npm",
|
||||
@@ -258,12 +282,24 @@ describe("update-cli", () => {
|
||||
const { resolveNpmChannelTag } = await import("../infra/update-check.js");
|
||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||
const { updateCommand } = await import("./update-cli.js");
|
||||
const { checkUpdateStatus } = await import("../infra/update-check.js");
|
||||
|
||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
||||
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
|
||||
...baseSnapshot,
|
||||
config: { update: { channel: "beta" } },
|
||||
});
|
||||
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||
root: tempDir,
|
||||
installKind: "package",
|
||||
packageManager: "npm",
|
||||
deps: {
|
||||
manager: "npm",
|
||||
status: "ok",
|
||||
lockfilePath: null,
|
||||
markerPath: null,
|
||||
},
|
||||
});
|
||||
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||
tag: "latest",
|
||||
version: "2026.1.20-1",
|
||||
@@ -459,8 +495,20 @@ describe("update-cli", () => {
|
||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||
const { defaultRuntime } = await import("../runtime.js");
|
||||
const { updateCommand } = await import("./update-cli.js");
|
||||
const { checkUpdateStatus } = await import("../infra/update-check.js");
|
||||
|
||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
||||
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||
root: tempDir,
|
||||
installKind: "package",
|
||||
packageManager: "npm",
|
||||
deps: {
|
||||
manager: "npm",
|
||||
status: "ok",
|
||||
lockfilePath: null,
|
||||
markerPath: null,
|
||||
},
|
||||
});
|
||||
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||
tag: "latest",
|
||||
version: "0.0.1",
|
||||
@@ -500,8 +548,20 @@ describe("update-cli", () => {
|
||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||
const { defaultRuntime } = await import("../runtime.js");
|
||||
const { updateCommand } = await import("./update-cli.js");
|
||||
const { checkUpdateStatus } = await import("../infra/update-check.js");
|
||||
|
||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
||||
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||
root: tempDir,
|
||||
installKind: "package",
|
||||
packageManager: "npm",
|
||||
deps: {
|
||||
manager: "npm",
|
||||
status: "ok",
|
||||
lockfilePath: null,
|
||||
markerPath: null,
|
||||
},
|
||||
});
|
||||
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||
tag: "latest",
|
||||
version: "0.0.1",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { confirm, isCancel, spinner } from "@clack/prompts";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Command } from "commander";
|
||||
|
||||
@@ -18,6 +19,13 @@ import {
|
||||
type UpdateStepInfo,
|
||||
type UpdateStepProgress,
|
||||
} from "../infra/update-runner.js";
|
||||
import {
|
||||
detectGlobalInstallManagerByPresence,
|
||||
detectGlobalInstallManagerForRoot,
|
||||
globalInstallArgs,
|
||||
resolveGlobalPackageRoot,
|
||||
type GlobalInstallManager,
|
||||
} from "../infra/update-global.js";
|
||||
import {
|
||||
channelToNpmTag,
|
||||
DEFAULT_GIT_CHANNEL,
|
||||
@@ -26,6 +34,7 @@ import {
|
||||
normalizeUpdateChannel,
|
||||
resolveEffectiveUpdateChannel,
|
||||
} from "../infra/update-channels.js";
|
||||
import { trimLogTail } from "../infra/restart-sentinel.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
@@ -39,6 +48,7 @@ import {
|
||||
resolveUpdateAvailability,
|
||||
} from "../commands/status.update.js";
|
||||
import { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } from "../plugins/update.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
|
||||
export type UpdateCommandOptions = {
|
||||
json?: boolean;
|
||||
@@ -58,12 +68,14 @@ const STEP_LABELS: Record<string, string> = {
|
||||
"upstream check": "Upstream branch exists",
|
||||
"git fetch": "Fetching latest changes",
|
||||
"git rebase": "Rebasing onto upstream",
|
||||
"git clone": "Cloning git checkout",
|
||||
"deps install": "Installing dependencies",
|
||||
build: "Building",
|
||||
"ui:build": "Building UI",
|
||||
"clawdbot doctor": "Running doctor checks",
|
||||
"git rev-parse HEAD (after)": "Verifying update",
|
||||
"global update": "Updating via package manager",
|
||||
"global install": "Installing global package",
|
||||
};
|
||||
|
||||
const UPDATE_QUIPS = [
|
||||
@@ -89,6 +101,10 @@ const UPDATE_QUIPS = [
|
||||
"Version bump! Same chaos energy, fewer crashes (probably).",
|
||||
];
|
||||
|
||||
const MAX_LOG_CHARS = 8000;
|
||||
const CLAWDBOT_REPO_URL = "https://github.com/clawdbot/clawdbot.git";
|
||||
const DEFAULT_GIT_DIR = path.join(os.homedir(), "clawdbot");
|
||||
|
||||
function normalizeTag(value?: string | null): string | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
@@ -133,6 +149,146 @@ async function isGitCheckout(root: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function isClawdbotPackage(root: string): Promise<boolean> {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(root, "package.json"), "utf-8");
|
||||
const parsed = JSON.parse(raw) as { name?: string };
|
||||
return parsed?.name === "clawdbot";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isEmptyDir(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
const entries = await fs.readdir(targetPath);
|
||||
return entries.length === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGitInstallDir(): string {
|
||||
const override = process.env.CLAWDBOT_GIT_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
return DEFAULT_GIT_DIR;
|
||||
}
|
||||
|
||||
function resolveNodeRunner(): string {
|
||||
const base = path.basename(process.execPath).toLowerCase();
|
||||
if (base === "node" || base === "node.exe") return process.execPath;
|
||||
return "node";
|
||||
}
|
||||
|
||||
async function runUpdateStep(params: {
|
||||
name: string;
|
||||
argv: string[];
|
||||
cwd?: string;
|
||||
timeoutMs: number;
|
||||
progress?: UpdateStepProgress;
|
||||
}): Promise<UpdateStepResult> {
|
||||
const command = params.argv.join(" ");
|
||||
params.progress?.onStepStart?.({
|
||||
name: params.name,
|
||||
command,
|
||||
index: 0,
|
||||
total: 0,
|
||||
});
|
||||
const started = Date.now();
|
||||
const res = await runCommandWithTimeout(params.argv, {
|
||||
cwd: params.cwd,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
const durationMs = Date.now() - started;
|
||||
const stderrTail = trimLogTail(res.stderr, MAX_LOG_CHARS);
|
||||
params.progress?.onStepComplete?.({
|
||||
name: params.name,
|
||||
command,
|
||||
index: 0,
|
||||
total: 0,
|
||||
durationMs,
|
||||
exitCode: res.code,
|
||||
stderrTail,
|
||||
});
|
||||
return {
|
||||
name: params.name,
|
||||
command,
|
||||
cwd: params.cwd ?? process.cwd(),
|
||||
durationMs,
|
||||
exitCode: res.code,
|
||||
stdoutTail: trimLogTail(res.stdout, MAX_LOG_CHARS),
|
||||
stderrTail,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureGitCheckout(params: {
|
||||
dir: string;
|
||||
timeoutMs: number;
|
||||
progress?: UpdateStepProgress;
|
||||
}): Promise<UpdateStepResult | null> {
|
||||
const dirExists = await pathExists(params.dir);
|
||||
if (!dirExists) {
|
||||
return await runUpdateStep({
|
||||
name: "git clone",
|
||||
argv: ["git", "clone", CLAWDBOT_REPO_URL, params.dir],
|
||||
timeoutMs: params.timeoutMs,
|
||||
progress: params.progress,
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await isGitCheckout(params.dir))) {
|
||||
const empty = await isEmptyDir(params.dir);
|
||||
if (!empty) {
|
||||
throw new Error(
|
||||
`CLAWDBOT_GIT_DIR points at a non-git directory: ${params.dir}. Set CLAWDBOT_GIT_DIR to an empty folder or a clawdbot checkout.`,
|
||||
);
|
||||
}
|
||||
return await runUpdateStep({
|
||||
name: "git clone",
|
||||
argv: ["git", "clone", CLAWDBOT_REPO_URL, params.dir],
|
||||
cwd: params.dir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
progress: params.progress,
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await isClawdbotPackage(params.dir))) {
|
||||
throw new Error(`CLAWDBOT_GIT_DIR does not look like a clawdbot checkout: ${params.dir}.`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveGlobalManager(params: {
|
||||
root: string;
|
||||
installKind: "git" | "package" | "unknown";
|
||||
timeoutMs: number;
|
||||
}): Promise<GlobalInstallManager> {
|
||||
const runCommand = async (argv: string[], options: { timeoutMs: number }) => {
|
||||
const res = await runCommandWithTimeout(argv, options);
|
||||
return { stdout: res.stdout, stderr: res.stderr, code: res.code };
|
||||
};
|
||||
if (params.installKind === "package") {
|
||||
const detected = await detectGlobalInstallManagerForRoot(
|
||||
runCommand,
|
||||
params.root,
|
||||
params.timeoutMs,
|
||||
);
|
||||
if (detected) return detected;
|
||||
}
|
||||
const byPresence = await detectGlobalInstallManagerByPresence(runCommand, params.timeoutMs);
|
||||
return byPresence ?? "npm";
|
||||
}
|
||||
|
||||
function formatGitStatusLine(params: {
|
||||
branch: string | null;
|
||||
tag: string | null;
|
||||
@@ -394,6 +550,13 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
cwd: process.cwd(),
|
||||
})) ?? process.cwd();
|
||||
|
||||
const updateStatus = await checkUpdateStatus({
|
||||
root,
|
||||
timeoutMs: timeoutMs ?? 3500,
|
||||
fetchGit: false,
|
||||
includeRegistry: false,
|
||||
});
|
||||
|
||||
const configSnapshot = await readConfigFileSnapshot();
|
||||
let activeConfig = configSnapshot.valid ? configSnapshot.config : null;
|
||||
const storedChannel = configSnapshot.valid
|
||||
@@ -413,13 +576,18 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const gitCheckout = await isGitCheckout(root);
|
||||
const defaultChannel = gitCheckout ? DEFAULT_GIT_CHANNEL : DEFAULT_PACKAGE_CHANNEL;
|
||||
const installKind = updateStatus.installKind;
|
||||
const switchToGit = requestedChannel === "dev" && installKind !== "git";
|
||||
const switchToPackage =
|
||||
requestedChannel !== null && requestedChannel !== "dev" && installKind === "git";
|
||||
const updateInstallKind = switchToGit ? "git" : switchToPackage ? "package" : installKind;
|
||||
const defaultChannel =
|
||||
updateInstallKind === "git" ? DEFAULT_GIT_CHANNEL : DEFAULT_PACKAGE_CHANNEL;
|
||||
const channel = requestedChannel ?? storedChannel ?? defaultChannel;
|
||||
const explicitTag = normalizeTag(opts.tag);
|
||||
let tag = explicitTag ?? channelToNpmTag(channel);
|
||||
if (!gitCheckout) {
|
||||
const currentVersion = await readPackageVersion(root);
|
||||
if (updateInstallKind !== "git") {
|
||||
const currentVersion = switchToPackage ? null : await readPackageVersion(root);
|
||||
const targetVersion = explicitTag
|
||||
? await resolveTargetVersion(tag, timeoutMs)
|
||||
: await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => {
|
||||
@@ -487,14 +655,114 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
|
||||
const { progress, stop } = createUpdateProgress(showProgress);
|
||||
|
||||
const result = await runGatewayUpdate({
|
||||
cwd: root,
|
||||
argv1: process.argv[1],
|
||||
timeoutMs,
|
||||
progress,
|
||||
channel,
|
||||
tag,
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
let result: UpdateRunResult;
|
||||
|
||||
if (switchToPackage) {
|
||||
const manager = await resolveGlobalManager({
|
||||
root,
|
||||
installKind,
|
||||
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||
});
|
||||
const runCommand = async (argv: string[], options: { timeoutMs: number }) => {
|
||||
const res = await runCommandWithTimeout(argv, options);
|
||||
return { stdout: res.stdout, stderr: res.stderr, code: res.code };
|
||||
};
|
||||
const pkgRoot = await resolveGlobalPackageRoot(manager, runCommand, timeoutMs ?? 20 * 60_000);
|
||||
const beforeVersion = pkgRoot ? await readPackageVersion(pkgRoot) : null;
|
||||
const updateStep = await runUpdateStep({
|
||||
name: "global update",
|
||||
argv: globalInstallArgs(manager, `clawdbot@${tag}`),
|
||||
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||
progress,
|
||||
});
|
||||
const steps = [updateStep];
|
||||
let afterVersion = beforeVersion;
|
||||
if (pkgRoot) {
|
||||
afterVersion = await readPackageVersion(pkgRoot);
|
||||
const entryPath = path.join(pkgRoot, "dist", "entry.js");
|
||||
if (await pathExists(entryPath)) {
|
||||
const doctorStep = await runUpdateStep({
|
||||
name: "clawdbot doctor",
|
||||
argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive"],
|
||||
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||
progress,
|
||||
});
|
||||
steps.push(doctorStep);
|
||||
}
|
||||
}
|
||||
const failedStep = steps.find((step) => step.exitCode !== 0);
|
||||
result = {
|
||||
status: failedStep ? "error" : "ok",
|
||||
mode: manager,
|
||||
root: pkgRoot ?? root,
|
||||
reason: failedStep ? failedStep.name : undefined,
|
||||
before: { version: beforeVersion },
|
||||
after: { version: afterVersion },
|
||||
steps,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
} else {
|
||||
const updateRoot = switchToGit ? resolveGitInstallDir() : root;
|
||||
const cloneStep = switchToGit
|
||||
? await ensureGitCheckout({
|
||||
dir: updateRoot,
|
||||
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||
progress,
|
||||
})
|
||||
: null;
|
||||
if (cloneStep && cloneStep.exitCode !== 0) {
|
||||
result = {
|
||||
status: "error",
|
||||
mode: "git",
|
||||
root: updateRoot,
|
||||
reason: cloneStep.name,
|
||||
steps: [cloneStep],
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
stop();
|
||||
printResult(result, { ...opts, hideSteps: showProgress });
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const updateResult = await runGatewayUpdate({
|
||||
cwd: updateRoot,
|
||||
argv1: switchToGit ? undefined : process.argv[1],
|
||||
timeoutMs,
|
||||
progress,
|
||||
channel,
|
||||
tag,
|
||||
});
|
||||
const steps = [...(cloneStep ? [cloneStep] : []), ...updateResult.steps];
|
||||
if (switchToGit && updateResult.status === "ok") {
|
||||
const manager = await resolveGlobalManager({
|
||||
root,
|
||||
installKind,
|
||||
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||
});
|
||||
const installStep = await runUpdateStep({
|
||||
name: "global install",
|
||||
argv: globalInstallArgs(manager, updateRoot),
|
||||
cwd: updateRoot,
|
||||
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||
progress,
|
||||
});
|
||||
steps.push(installStep);
|
||||
const failedStep = [installStep].find((step) => step.exitCode !== 0);
|
||||
result = {
|
||||
...updateResult,
|
||||
status: updateResult.status === "ok" && !failedStep ? "ok" : "error",
|
||||
steps,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
} else {
|
||||
result = {
|
||||
...updateResult,
|
||||
steps,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
stop();
|
||||
|
||||
|
||||
109
src/infra/update-global.ts
Normal file
109
src/infra/update-global.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export type GlobalInstallManager = "npm" | "pnpm" | "bun";
|
||||
|
||||
export type CommandRunner = (
|
||||
argv: string[],
|
||||
options: { timeoutMs: number; cwd?: string; env?: NodeJS.ProcessEnv },
|
||||
) => Promise<{ stdout: string; stderr: string; code: number | null }>;
|
||||
|
||||
async function pathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function tryRealpath(targetPath: string): Promise<string> {
|
||||
try {
|
||||
return await fs.realpath(targetPath);
|
||||
} catch {
|
||||
return path.resolve(targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBunGlobalRoot(): string {
|
||||
const bunInstall = process.env.BUN_INSTALL?.trim() || path.join(os.homedir(), ".bun");
|
||||
return path.join(bunInstall, "install", "global", "node_modules");
|
||||
}
|
||||
|
||||
export async function resolveGlobalRoot(
|
||||
manager: GlobalInstallManager,
|
||||
runCommand: CommandRunner,
|
||||
timeoutMs: number,
|
||||
): Promise<string | null> {
|
||||
if (manager === "bun") return resolveBunGlobalRoot();
|
||||
const argv = manager === "pnpm" ? ["pnpm", "root", "-g"] : ["npm", "root", "-g"];
|
||||
const res = await runCommand(argv, { timeoutMs }).catch(() => null);
|
||||
if (!res || res.code !== 0) return null;
|
||||
const root = res.stdout.trim();
|
||||
return root || null;
|
||||
}
|
||||
|
||||
export async function resolveGlobalPackageRoot(
|
||||
manager: GlobalInstallManager,
|
||||
runCommand: CommandRunner,
|
||||
timeoutMs: number,
|
||||
): Promise<string | null> {
|
||||
const root = await resolveGlobalRoot(manager, runCommand, timeoutMs);
|
||||
if (!root) return null;
|
||||
return path.join(root, "clawdbot");
|
||||
}
|
||||
|
||||
export async function detectGlobalInstallManagerForRoot(
|
||||
runCommand: CommandRunner,
|
||||
pkgRoot: string,
|
||||
timeoutMs: number,
|
||||
): Promise<GlobalInstallManager | 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 || 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;
|
||||
}
|
||||
|
||||
const bunGlobalRoot = resolveBunGlobalRoot();
|
||||
const bunGlobalReal = await tryRealpath(bunGlobalRoot);
|
||||
const bunExpected = path.join(bunGlobalReal, "clawdbot");
|
||||
if (path.resolve(bunExpected) === path.resolve(pkgReal)) return "bun";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function detectGlobalInstallManagerByPresence(
|
||||
runCommand: CommandRunner,
|
||||
timeoutMs: number,
|
||||
): Promise<GlobalInstallManager | null> {
|
||||
for (const manager of ["npm", "pnpm"] as const) {
|
||||
const root = await resolveGlobalRoot(manager, runCommand, timeoutMs);
|
||||
if (!root) continue;
|
||||
if (await pathExists(path.join(root, "clawdbot"))) return manager;
|
||||
}
|
||||
|
||||
const bunRoot = resolveBunGlobalRoot();
|
||||
if (await pathExists(path.join(bunRoot, "clawdbot"))) return "bun";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function globalInstallArgs(manager: GlobalInstallManager, spec: string): string[] {
|
||||
if (manager === "pnpm") return ["pnpm", "add", "-g", spec];
|
||||
if (manager === "bun") return ["bun", "add", "-g", spec];
|
||||
return ["npm", "i", "-g", spec];
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import os from "node:os";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
|
||||
import { compareSemverStrings } from "./update-check.js";
|
||||
import { DEV_BRANCH, isBetaTag, isStableTag, type UpdateChannel } from "./update-channels.js";
|
||||
import { detectGlobalInstallManagerForRoot, globalInstallArgs } from "./update-global.js";
|
||||
import { trimLogTail } from "./restart-sentinel.js";
|
||||
|
||||
export type UpdateStepResult = {
|
||||
@@ -210,52 +210,6 @@ async function detectPackageManager(root: string) {
|
||||
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 = {
|
||||
runCommand: CommandRunner;
|
||||
name: string;
|
||||
@@ -324,13 +278,6 @@ function normalizeTag(tag?: string) {
|
||||
return trimmed.startsWith("clawdbot@") ? trimmed.slice("clawdbot@".length) : trimmed;
|
||||
}
|
||||
|
||||
function globalUpdateArgs(manager: "pnpm" | "npm" | "bun", tag?: string) {
|
||||
const spec = `clawdbot@${normalizeTag(tag)}`;
|
||||
if (manager === "pnpm") return ["pnpm", "add", "-g", spec];
|
||||
if (manager === "bun") return ["bun", "add", "-g", spec];
|
||||
return ["npm", "i", "-g", spec];
|
||||
}
|
||||
|
||||
export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<UpdateRunResult> {
|
||||
const startedAt = Date.now();
|
||||
const runCommand =
|
||||
@@ -604,12 +551,13 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
|
||||
}
|
||||
|
||||
const beforeVersion = await readPackageVersion(pkgRoot);
|
||||
const globalManager = await detectGlobalInstallManager(runCommand, pkgRoot, timeoutMs);
|
||||
const globalManager = await detectGlobalInstallManagerForRoot(runCommand, pkgRoot, timeoutMs);
|
||||
if (globalManager) {
|
||||
const spec = `clawdbot@${normalizeTag(opts.tag)}`;
|
||||
const updateStep = await runStep({
|
||||
runCommand,
|
||||
name: "global update",
|
||||
argv: globalUpdateArgs(globalManager, opts.tag),
|
||||
argv: globalInstallArgs(globalManager, spec),
|
||||
cwd: pkgRoot,
|
||||
timeoutMs,
|
||||
progress,
|
||||
|
||||
Reference in New Issue
Block a user