CLI: support package-manager installs from GitHub main (#47630)

* CLI: resolve package-manager main install specs

* CLI: skip registry resolution for raw package specs

* CLI: support main package target updates

* CLI: document package update specs in help

* Tests: cover package install spec resolution

* Tests: cover npm main-package updates

* Tests: cover update --tag main

* Installer: support main package targets

* Installer: support main package targets on Windows

* Docs: document package-manager main updates

* Docs: document installer main targets

* Docs: document npm and pnpm main installs

* Docs: document update --tag main

* Changelog: note package-manager main installs

* Update src/infra/update-global.test.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Vincent Koc
2026-03-15 14:18:12 -07:00
committed by GitHub
parent 3735156766
commit 5a7aba94a2
14 changed files with 320 additions and 63 deletions

View File

@@ -549,6 +549,48 @@ describe("update-cli", () => {
);
});
it("maps --tag main to the GitHub main package spec for package updates", async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
await updateCommand({ yes: true, tag: "main" });
expect(runGatewayUpdate).not.toHaveBeenCalled();
expect(runCommandWithTimeout).toHaveBeenCalledWith(
[
"npm",
"i",
"-g",
"github:openclaw/openclaw#main",
"--no-fund",
"--no-audit",
"--loglevel=error",
],
expect.any(Object),
);
});
it("passes explicit git package specs through for package updates", async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
await updateCommand({ yes: true, tag: "github:openclaw/openclaw#main" });
expect(runGatewayUpdate).not.toHaveBeenCalled();
expect(runCommandWithTimeout).toHaveBeenCalledWith(
[
"npm",
"i",
"-g",
"github:openclaw/openclaw#main",
"--no-fund",
"--no-audit",
"--loglevel=error",
],
expect.any(Object),
);
});
it("updateCommand outputs JSON when --json is set", async () => {
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
vi.mocked(defaultRuntime.log).mockClear();

View File

@@ -39,7 +39,10 @@ export function registerUpdateCli(program: Command) {
.option("--no-restart", "Skip restarting the gateway service after a successful update")
.option("--dry-run", "Preview update actions without making changes", false)
.option("--channel <stable|beta|dev>", "Persist update channel (git + npm)")
.option("--tag <dist-tag|version>", "Override npm dist-tag or version for this update")
.option(
"--tag <dist-tag|version|spec>",
"Override the package target for this update (dist-tag, version, or package spec)",
)
.option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)")
.option("--yes", "Skip confirmation prompts (non-interactive)", false)
.addHelpText("after", () => {
@@ -48,6 +51,7 @@ export function registerUpdateCli(program: Command) {
["openclaw update --channel beta", "Switch to beta channel (git + npm)"],
["openclaw update --channel dev", "Switch to dev channel (git + npm)"],
["openclaw update --tag beta", "One-off update to a dist-tag or version"],
["openclaw update --tag main", "One-off package install from GitHub main"],
["openclaw update --dry-run", "Preview actions without changing anything"],
["openclaw update --no-restart", "Update without restarting the service"],
["openclaw update --json", "Output result as JSON"],
@@ -66,7 +70,7 @@ ${theme.heading("What this does:")}
${theme.heading("Switch channels:")}
- Use --channel stable|beta|dev to persist the update channel in config
- Run openclaw update status to see the active channel and source
- Use --tag <dist-tag|version> for a one-off npm update without persisting
- Use --tag <dist-tag|version|spec> for a one-off package update without persisting
${theme.heading("Non-interactive:")}
- Use --yes to accept downgrade prompts

View File

@@ -10,6 +10,7 @@ import { trimLogTail } from "../../infra/restart-sentinel.js";
import { parseSemver } from "../../infra/runtime-guard.js";
import { fetchNpmTagVersion } from "../../infra/update-check.js";
import {
canResolveRegistryVersionForPackageTarget,
detectGlobalInstallManagerByPresence,
detectGlobalInstallManagerForRoot,
type CommandRunner,
@@ -77,6 +78,9 @@ export async function resolveTargetVersion(
tag: string,
timeoutMs?: number,
): Promise<string | null> {
if (!canResolveRegistryVersionForPackageTarget(tag)) {
return null;
}
const direct = normalizeVersionTag(tag);
if (direct) {
return direct;

View File

@@ -24,6 +24,7 @@ import {
checkUpdateStatus,
} from "../../infra/update-check.js";
import {
canResolveRegistryVersionForPackageTarget,
createGlobalInstallEnv,
cleanupGlobalRenameDirs,
globalInstallArgs,
@@ -731,22 +732,31 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
let targetVersion: string | null = null;
let downgradeRisk = false;
let fallbackToLatest = false;
let packageInstallSpec: string | null = null;
if (updateInstallKind !== "git") {
currentVersion = switchToPackage ? null : await readPackageVersion(root);
targetVersion = explicitTag
? await resolveTargetVersion(tag, timeoutMs)
: await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => {
tag = resolved.tag;
fallbackToLatest = channel === "beta" && resolved.tag === "latest";
return resolved.version;
});
if (explicitTag) {
targetVersion = await resolveTargetVersion(tag, timeoutMs);
} else {
targetVersion = await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => {
tag = resolved.tag;
fallbackToLatest = channel === "beta" && resolved.tag === "latest";
return resolved.version;
});
}
const cmp =
currentVersion && targetVersion ? compareSemverStrings(currentVersion, targetVersion) : null;
downgradeRisk =
canResolveRegistryVersionForPackageTarget(tag) &&
!fallbackToLatest &&
currentVersion != null &&
(targetVersion == null || (cmp != null && cmp > 0));
packageInstallSpec = resolveGlobalInstallSpec({
packageName: DEFAULT_PACKAGE_NAME,
tag,
env: process.env,
});
}
if (opts.dryRun) {
@@ -772,7 +782,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
} else if (updateInstallKind === "git") {
actions.push(`Run git update flow on channel ${channel} (fetch/rebase/build/doctor)`);
} else {
actions.push(`Run global package manager update with spec openclaw@${tag}`);
actions.push(`Run global package manager update with spec ${packageInstallSpec ?? tag}`);
}
actions.push("Run plugin update sync after core update");
actions.push("Refresh shell completion cache (if needed)");
@@ -789,6 +799,9 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
if (fallbackToLatest) {
notes.push("Beta channel resolves to latest for this run (fallback).");
}
if (explicitTag && !canResolveRegistryVersionForPackageTarget(tag)) {
notes.push("Non-registry package specs skip npm version lookup and downgrade previews.");
}
printDryRunPreview(
{
@@ -803,7 +816,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
requestedChannel,
storedChannel,
effectiveChannel: channel,
tag,
tag: packageInstallSpec ?? tag,
currentVersion,
targetVersion,
downgradeRisk,

View File

@@ -4,11 +4,15 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import {
canResolveRegistryVersionForPackageTarget,
cleanupGlobalRenameDirs,
detectGlobalInstallManagerByPresence,
detectGlobalInstallManagerForRoot,
globalInstallArgs,
globalInstallFallbackArgs,
isExplicitPackageInstallSpec,
isMainPackageTarget,
OPENCLAW_MAIN_PACKAGE_SPEC,
resolveGlobalPackageRoot,
resolveGlobalInstallSpec,
resolveGlobalRoot,
@@ -60,6 +64,40 @@ describe("update global helpers", () => {
);
});
it("maps main and explicit install specs for global installs", () => {
expect(resolveGlobalInstallSpec({ packageName: "openclaw", tag: "main" })).toBe(
OPENCLAW_MAIN_PACKAGE_SPEC,
);
expect(
resolveGlobalInstallSpec({
packageName: "openclaw",
tag: "github:openclaw/openclaw#feature/my-branch",
}),
).toBe("github:openclaw/openclaw#feature/my-branch");
expect(
resolveGlobalInstallSpec({
packageName: "openclaw",
tag: "https://example.com/openclaw-main.tgz",
}),
).toBe("https://example.com/openclaw-main.tgz");
});
it("classifies main and raw install specs separately from registry selectors", () => {
expect(isMainPackageTarget("main")).toBe(true);
expect(isMainPackageTarget(" MAIN ")).toBe(true);
expect(isMainPackageTarget("beta")).toBe(false);
expect(isExplicitPackageInstallSpec("github:openclaw/openclaw#main")).toBe(true);
expect(isExplicitPackageInstallSpec("https://example.com/openclaw-main.tgz")).toBe(true);
expect(isExplicitPackageInstallSpec("file:/tmp/openclaw-main.tgz")).toBe(true);
expect(isExplicitPackageInstallSpec("beta")).toBe(false);
expect(canResolveRegistryVersionForPackageTarget("latest")).toBe(true);
expect(canResolveRegistryVersionForPackageTarget("2026.3.14")).toBe(true);
expect(canResolveRegistryVersionForPackageTarget("main")).toBe(false);
expect(canResolveRegistryVersionForPackageTarget("github:openclaw/openclaw#main")).toBe(false);
});
it("detects install managers from resolved roots and on-disk presence", async () => {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-global-"));
const npmRoot = path.join(base, "npm-root");

View File

@@ -14,12 +14,41 @@ export type CommandRunner = (
const PRIMARY_PACKAGE_NAME = "openclaw";
const ALL_PACKAGE_NAMES = [PRIMARY_PACKAGE_NAME] as const;
const GLOBAL_RENAME_PREFIX = ".";
export const OPENCLAW_MAIN_PACKAGE_SPEC = "github:openclaw/openclaw#main";
const NPM_GLOBAL_INSTALL_QUIET_FLAGS = ["--no-fund", "--no-audit", "--loglevel=error"] as const;
const NPM_GLOBAL_INSTALL_OMIT_OPTIONAL_FLAGS = [
"--omit=optional",
...NPM_GLOBAL_INSTALL_QUIET_FLAGS,
] as const;
function normalizePackageTarget(value: string): string {
return value.trim();
}
export function isMainPackageTarget(value: string): boolean {
return normalizePackageTarget(value).toLowerCase() === "main";
}
export function isExplicitPackageInstallSpec(value: string): boolean {
const trimmed = normalizePackageTarget(value);
if (!trimmed) {
return false;
}
return (
trimmed.includes("://") ||
trimmed.includes("#") ||
/^(?:file|github|git\+ssh|git\+https|git\+http|git\+file|npm):/i.test(trimmed)
);
}
export function canResolveRegistryVersionForPackageTarget(value: string): boolean {
const trimmed = normalizePackageTarget(value);
if (!trimmed) {
return true;
}
return !isMainPackageTarget(trimmed) && !isExplicitPackageInstallSpec(trimmed);
}
async function resolvePortableGitPathPrepend(
env: NodeJS.ProcessEnv | undefined,
): Promise<string[]> {
@@ -68,7 +97,14 @@ export function resolveGlobalInstallSpec(params: {
if (override) {
return override;
}
return `${params.packageName}@${params.tag}`;
const target = normalizePackageTarget(params.tag);
if (isMainPackageTarget(target)) {
return OPENCLAW_MAIN_PACKAGE_SPEC;
}
if (isExplicitPackageInstallSpec(target)) {
return target;
}
return `${params.packageName}@${target}`;
}
export async function createGlobalInstallEnv(

View File

@@ -441,6 +441,20 @@ describe("runGatewayUpdate", () => {
expect(calls.some((call) => call === expectedInstallCommand)).toBe(true);
});
it("updates global npm installs from the GitHub main package spec", async () => {
const { calls, result } = await runNpmGlobalUpdateCase({
expectedInstallCommand:
"npm i -g github:openclaw/openclaw#main --no-fund --no-audit --loglevel=error",
tag: "main",
});
expect(result.status).toBe("ok");
expect(result.mode).toBe("npm");
expect(calls).toContain(
"npm i -g github:openclaw/openclaw#main --no-fund --no-audit --loglevel=error",
);
});
it("falls back to global npm update when git is missing from PATH", async () => {
const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir);
const { calls, runCommand } = createGlobalInstallHarness({