feat: align update channel installs
This commit is contained in:
@@ -7,9 +7,9 @@ read_when:
|
|||||||
|
|
||||||
# `clawdbot update`
|
# `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
|
## Usage
|
||||||
|
|
||||||
@@ -48,7 +48,16 @@ Options:
|
|||||||
- `--json`: print machine-readable status JSON.
|
- `--json`: print machine-readable status JSON.
|
||||||
- `--timeout <seconds>`: timeout for checks (default is 3s).
|
- `--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:
|
Channels:
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ read_when:
|
|||||||
|
|
||||||
# Development channels
|
# Development channels
|
||||||
|
|
||||||
Last updated: 2026-01-20
|
Last updated: 2026-01-21
|
||||||
|
|
||||||
Clawdbot ships three update channels:
|
Clawdbot ships three update channels:
|
||||||
|
|
||||||
@@ -38,6 +38,13 @@ clawdbot update --channel dev
|
|||||||
|
|
||||||
This updates via the corresponding npm dist-tag (`latest`, `beta`, `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.
|
Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one.
|
||||||
|
|
||||||
## Plugins and channels
|
## 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)
|
// Mock doctor (heavy module; should not run in unit tests)
|
||||||
vi.mock("../commands/doctor.js", () => ({
|
vi.mock("../commands/doctor.js", () => ({
|
||||||
doctorCommand: vi.fn(),
|
doctorCommand: vi.fn(),
|
||||||
@@ -76,6 +80,7 @@ describe("update-cli", () => {
|
|||||||
const { readConfigFileSnapshot } = await import("../config/config.js");
|
const { readConfigFileSnapshot } = await import("../config/config.js");
|
||||||
const { checkUpdateStatus, fetchNpmTagVersion, resolveNpmChannelTag } =
|
const { checkUpdateStatus, fetchNpmTagVersion, resolveNpmChannelTag } =
|
||||||
await import("../infra/update-check.js");
|
await import("../infra/update-check.js");
|
||||||
|
const { runCommandWithTimeout } = await import("../process/exec.js");
|
||||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(process.cwd());
|
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(process.cwd());
|
||||||
vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot);
|
vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot);
|
||||||
vi.mocked(fetchNpmTagVersion).mockResolvedValue({
|
vi.mocked(fetchNpmTagVersion).mockResolvedValue({
|
||||||
@@ -111,6 +116,13 @@ describe("update-cli", () => {
|
|||||||
latestVersion: "1.2.3",
|
latestVersion: "1.2.3",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
vi.mocked(runCommandWithTimeout).mockResolvedValue({
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
code: 0,
|
||||||
|
signal: null,
|
||||||
|
killed: false,
|
||||||
|
});
|
||||||
setTty(false);
|
setTty(false);
|
||||||
setStdoutTty(false);
|
setStdoutTty(false);
|
||||||
});
|
});
|
||||||
@@ -202,9 +214,21 @@ describe("update-cli", () => {
|
|||||||
|
|
||||||
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
|
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
|
||||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||||
|
const { checkUpdateStatus } = await import("../infra/update-check.js");
|
||||||
const { updateCommand } = await import("./update-cli.js");
|
const { updateCommand } = await import("./update-cli.js");
|
||||||
|
|
||||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
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({
|
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
mode: "npm",
|
mode: "npm",
|
||||||
@@ -258,12 +282,24 @@ describe("update-cli", () => {
|
|||||||
const { resolveNpmChannelTag } = await import("../infra/update-check.js");
|
const { resolveNpmChannelTag } = await import("../infra/update-check.js");
|
||||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||||
const { updateCommand } = await import("./update-cli.js");
|
const { updateCommand } = await import("./update-cli.js");
|
||||||
|
const { checkUpdateStatus } = await import("../infra/update-check.js");
|
||||||
|
|
||||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
||||||
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
|
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
|
||||||
...baseSnapshot,
|
...baseSnapshot,
|
||||||
config: { update: { channel: "beta" } },
|
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({
|
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||||
tag: "latest",
|
tag: "latest",
|
||||||
version: "2026.1.20-1",
|
version: "2026.1.20-1",
|
||||||
@@ -459,8 +495,20 @@ describe("update-cli", () => {
|
|||||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
const { defaultRuntime } = await import("../runtime.js");
|
||||||
const { updateCommand } = await import("./update-cli.js");
|
const { updateCommand } = await import("./update-cli.js");
|
||||||
|
const { checkUpdateStatus } = await import("../infra/update-check.js");
|
||||||
|
|
||||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
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({
|
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||||
tag: "latest",
|
tag: "latest",
|
||||||
version: "0.0.1",
|
version: "0.0.1",
|
||||||
@@ -500,8 +548,20 @@ describe("update-cli", () => {
|
|||||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
const { defaultRuntime } = await import("../runtime.js");
|
||||||
const { updateCommand } = await import("./update-cli.js");
|
const { updateCommand } = await import("./update-cli.js");
|
||||||
|
const { checkUpdateStatus } = await import("../infra/update-check.js");
|
||||||
|
|
||||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
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({
|
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||||
tag: "latest",
|
tag: "latest",
|
||||||
version: "0.0.1",
|
version: "0.0.1",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { confirm, isCancel, spinner } from "@clack/prompts";
|
import { confirm, isCancel, spinner } from "@clack/prompts";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
|
||||||
@@ -18,6 +19,13 @@ import {
|
|||||||
type UpdateStepInfo,
|
type UpdateStepInfo,
|
||||||
type UpdateStepProgress,
|
type UpdateStepProgress,
|
||||||
} from "../infra/update-runner.js";
|
} from "../infra/update-runner.js";
|
||||||
|
import {
|
||||||
|
detectGlobalInstallManagerByPresence,
|
||||||
|
detectGlobalInstallManagerForRoot,
|
||||||
|
globalInstallArgs,
|
||||||
|
resolveGlobalPackageRoot,
|
||||||
|
type GlobalInstallManager,
|
||||||
|
} from "../infra/update-global.js";
|
||||||
import {
|
import {
|
||||||
channelToNpmTag,
|
channelToNpmTag,
|
||||||
DEFAULT_GIT_CHANNEL,
|
DEFAULT_GIT_CHANNEL,
|
||||||
@@ -26,6 +34,7 @@ import {
|
|||||||
normalizeUpdateChannel,
|
normalizeUpdateChannel,
|
||||||
resolveEffectiveUpdateChannel,
|
resolveEffectiveUpdateChannel,
|
||||||
} from "../infra/update-channels.js";
|
} from "../infra/update-channels.js";
|
||||||
|
import { trimLogTail } from "../infra/restart-sentinel.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { formatCliCommand } from "./command-format.js";
|
import { formatCliCommand } from "./command-format.js";
|
||||||
@@ -39,6 +48,7 @@ import {
|
|||||||
resolveUpdateAvailability,
|
resolveUpdateAvailability,
|
||||||
} from "../commands/status.update.js";
|
} from "../commands/status.update.js";
|
||||||
import { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } from "../plugins/update.js";
|
import { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } from "../plugins/update.js";
|
||||||
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
|
|
||||||
export type UpdateCommandOptions = {
|
export type UpdateCommandOptions = {
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
@@ -58,12 +68,14 @@ const STEP_LABELS: Record<string, string> = {
|
|||||||
"upstream check": "Upstream branch exists",
|
"upstream check": "Upstream branch exists",
|
||||||
"git fetch": "Fetching latest changes",
|
"git fetch": "Fetching latest changes",
|
||||||
"git rebase": "Rebasing onto upstream",
|
"git rebase": "Rebasing onto upstream",
|
||||||
|
"git clone": "Cloning git checkout",
|
||||||
"deps install": "Installing dependencies",
|
"deps install": "Installing dependencies",
|
||||||
build: "Building",
|
build: "Building",
|
||||||
"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",
|
"global update": "Updating via package manager",
|
||||||
|
"global install": "Installing global package",
|
||||||
};
|
};
|
||||||
|
|
||||||
const UPDATE_QUIPS = [
|
const UPDATE_QUIPS = [
|
||||||
@@ -89,6 +101,10 @@ const UPDATE_QUIPS = [
|
|||||||
"Version bump! Same chaos energy, fewer crashes (probably).",
|
"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 {
|
function normalizeTag(value?: string | null): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const trimmed = value.trim();
|
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: {
|
function formatGitStatusLine(params: {
|
||||||
branch: string | null;
|
branch: string | null;
|
||||||
tag: string | null;
|
tag: string | null;
|
||||||
@@ -394,6 +550,13 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
})) ?? process.cwd();
|
})) ?? process.cwd();
|
||||||
|
|
||||||
|
const updateStatus = await checkUpdateStatus({
|
||||||
|
root,
|
||||||
|
timeoutMs: timeoutMs ?? 3500,
|
||||||
|
fetchGit: false,
|
||||||
|
includeRegistry: false,
|
||||||
|
});
|
||||||
|
|
||||||
const configSnapshot = await readConfigFileSnapshot();
|
const configSnapshot = await readConfigFileSnapshot();
|
||||||
let activeConfig = configSnapshot.valid ? configSnapshot.config : null;
|
let activeConfig = configSnapshot.valid ? configSnapshot.config : null;
|
||||||
const storedChannel = configSnapshot.valid
|
const storedChannel = configSnapshot.valid
|
||||||
@@ -413,13 +576,18 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gitCheckout = await isGitCheckout(root);
|
const installKind = updateStatus.installKind;
|
||||||
const defaultChannel = gitCheckout ? DEFAULT_GIT_CHANNEL : DEFAULT_PACKAGE_CHANNEL;
|
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 channel = requestedChannel ?? storedChannel ?? defaultChannel;
|
||||||
const explicitTag = normalizeTag(opts.tag);
|
const explicitTag = normalizeTag(opts.tag);
|
||||||
let tag = explicitTag ?? channelToNpmTag(channel);
|
let tag = explicitTag ?? channelToNpmTag(channel);
|
||||||
if (!gitCheckout) {
|
if (updateInstallKind !== "git") {
|
||||||
const currentVersion = await readPackageVersion(root);
|
const currentVersion = switchToPackage ? null : await readPackageVersion(root);
|
||||||
const targetVersion = explicitTag
|
const targetVersion = explicitTag
|
||||||
? await resolveTargetVersion(tag, timeoutMs)
|
? await resolveTargetVersion(tag, timeoutMs)
|
||||||
: await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => {
|
: await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => {
|
||||||
@@ -487,14 +655,114 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
|
|
||||||
const { progress, stop } = createUpdateProgress(showProgress);
|
const { progress, stop } = createUpdateProgress(showProgress);
|
||||||
|
|
||||||
const result = await runGatewayUpdate({
|
const startedAt = Date.now();
|
||||||
cwd: root,
|
let result: UpdateRunResult;
|
||||||
argv1: process.argv[1],
|
|
||||||
timeoutMs,
|
if (switchToPackage) {
|
||||||
progress,
|
const manager = await resolveGlobalManager({
|
||||||
channel,
|
root,
|
||||||
tag,
|
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();
|
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 fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
|
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
|
||||||
import { compareSemverStrings } from "./update-check.js";
|
import { compareSemverStrings } from "./update-check.js";
|
||||||
import { DEV_BRANCH, isBetaTag, isStableTag, type UpdateChannel } from "./update-channels.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";
|
import { trimLogTail } from "./restart-sentinel.js";
|
||||||
|
|
||||||
export type UpdateStepResult = {
|
export type UpdateStepResult = {
|
||||||
@@ -210,52 +210,6 @@ 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;
|
||||||
@@ -324,13 +278,6 @@ function normalizeTag(tag?: string) {
|
|||||||
return trimmed.startsWith("clawdbot@") ? trimmed.slice("clawdbot@".length) : trimmed;
|
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> {
|
export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<UpdateRunResult> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const runCommand =
|
const runCommand =
|
||||||
@@ -604,12 +551,13 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const beforeVersion = await readPackageVersion(pkgRoot);
|
const beforeVersion = await readPackageVersion(pkgRoot);
|
||||||
const globalManager = await detectGlobalInstallManager(runCommand, pkgRoot, timeoutMs);
|
const globalManager = await detectGlobalInstallManagerForRoot(runCommand, pkgRoot, timeoutMs);
|
||||||
if (globalManager) {
|
if (globalManager) {
|
||||||
|
const spec = `clawdbot@${normalizeTag(opts.tag)}`;
|
||||||
const updateStep = await runStep({
|
const updateStep = await runStep({
|
||||||
runCommand,
|
runCommand,
|
||||||
name: "global update",
|
name: "global update",
|
||||||
argv: globalUpdateArgs(globalManager, opts.tag),
|
argv: globalInstallArgs(globalManager, spec),
|
||||||
cwd: pkgRoot,
|
cwd: pkgRoot,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
progress,
|
progress,
|
||||||
|
|||||||
Reference in New Issue
Block a user