* 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>
189 lines
6.8 KiB
TypeScript
189 lines
6.8 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
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,
|
|
type CommandRunner,
|
|
} from "./update-global.js";
|
|
|
|
describe("update global helpers", () => {
|
|
let envSnapshot: ReturnType<typeof captureEnv> | undefined;
|
|
|
|
afterEach(() => {
|
|
envSnapshot?.restore();
|
|
envSnapshot = undefined;
|
|
});
|
|
|
|
it("prefers explicit package spec overrides", () => {
|
|
envSnapshot = captureEnv(["OPENCLAW_UPDATE_PACKAGE_SPEC"]);
|
|
process.env.OPENCLAW_UPDATE_PACKAGE_SPEC = "file:/tmp/openclaw.tgz";
|
|
|
|
expect(resolveGlobalInstallSpec({ packageName: "openclaw", tag: "latest" })).toBe(
|
|
"file:/tmp/openclaw.tgz",
|
|
);
|
|
expect(
|
|
resolveGlobalInstallSpec({
|
|
packageName: "openclaw",
|
|
tag: "beta",
|
|
env: { OPENCLAW_UPDATE_PACKAGE_SPEC: "openclaw@next" },
|
|
}),
|
|
).toBe("openclaw@next");
|
|
});
|
|
|
|
it("resolves global roots and package roots from runner output", async () => {
|
|
const runCommand: CommandRunner = async (argv) => {
|
|
if (argv[0] === "npm") {
|
|
return { stdout: "/tmp/npm-root\n", stderr: "", code: 0 };
|
|
}
|
|
if (argv[0] === "pnpm") {
|
|
return { stdout: "", stderr: "", code: 1 };
|
|
}
|
|
throw new Error(`unexpected command: ${argv.join(" ")}`);
|
|
};
|
|
|
|
await expect(resolveGlobalRoot("npm", runCommand, 1000)).resolves.toBe("/tmp/npm-root");
|
|
await expect(resolveGlobalRoot("pnpm", runCommand, 1000)).resolves.toBeNull();
|
|
await expect(resolveGlobalRoot("bun", runCommand, 1000)).resolves.toContain(
|
|
path.join(".bun", "install", "global", "node_modules"),
|
|
);
|
|
await expect(resolveGlobalPackageRoot("npm", runCommand, 1000)).resolves.toBe(
|
|
path.join("/tmp/npm-root", "openclaw"),
|
|
);
|
|
});
|
|
|
|
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");
|
|
const pnpmRoot = path.join(base, "pnpm-root");
|
|
const bunRoot = path.join(base, ".bun", "install", "global", "node_modules");
|
|
const pkgRoot = path.join(pnpmRoot, "openclaw");
|
|
await fs.mkdir(pkgRoot, { recursive: true });
|
|
await fs.mkdir(path.join(npmRoot, "openclaw"), { recursive: true });
|
|
await fs.mkdir(path.join(bunRoot, "openclaw"), { recursive: true });
|
|
|
|
envSnapshot = captureEnv(["BUN_INSTALL"]);
|
|
process.env.BUN_INSTALL = path.join(base, ".bun");
|
|
|
|
const runCommand: CommandRunner = async (argv) => {
|
|
if (argv[0] === "npm") {
|
|
return { stdout: `${npmRoot}\n`, stderr: "", code: 0 };
|
|
}
|
|
if (argv[0] === "pnpm") {
|
|
return { stdout: `${pnpmRoot}\n`, stderr: "", code: 0 };
|
|
}
|
|
throw new Error(`unexpected command: ${argv.join(" ")}`);
|
|
};
|
|
|
|
await expect(detectGlobalInstallManagerForRoot(runCommand, pkgRoot, 1000)).resolves.toBe(
|
|
"pnpm",
|
|
);
|
|
await expect(detectGlobalInstallManagerByPresence(runCommand, 1000)).resolves.toBe("npm");
|
|
|
|
await fs.rm(path.join(npmRoot, "openclaw"), { recursive: true, force: true });
|
|
await fs.rm(path.join(pnpmRoot, "openclaw"), { recursive: true, force: true });
|
|
await expect(detectGlobalInstallManagerByPresence(runCommand, 1000)).resolves.toBe("bun");
|
|
});
|
|
|
|
it("builds install argv and npm fallback argv", () => {
|
|
expect(globalInstallArgs("npm", "openclaw@latest")).toEqual([
|
|
"npm",
|
|
"i",
|
|
"-g",
|
|
"openclaw@latest",
|
|
"--no-fund",
|
|
"--no-audit",
|
|
"--loglevel=error",
|
|
]);
|
|
expect(globalInstallArgs("pnpm", "openclaw@latest")).toEqual([
|
|
"pnpm",
|
|
"add",
|
|
"-g",
|
|
"openclaw@latest",
|
|
]);
|
|
expect(globalInstallArgs("bun", "openclaw@latest")).toEqual([
|
|
"bun",
|
|
"add",
|
|
"-g",
|
|
"openclaw@latest",
|
|
]);
|
|
|
|
expect(globalInstallFallbackArgs("npm", "openclaw@latest")).toEqual([
|
|
"npm",
|
|
"i",
|
|
"-g",
|
|
"openclaw@latest",
|
|
"--omit=optional",
|
|
"--no-fund",
|
|
"--no-audit",
|
|
"--loglevel=error",
|
|
]);
|
|
expect(globalInstallFallbackArgs("pnpm", "openclaw@latest")).toBeNull();
|
|
});
|
|
|
|
it("cleans only renamed package directories", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-cleanup-"));
|
|
await fs.mkdir(path.join(root, ".openclaw-123"), { recursive: true });
|
|
await fs.mkdir(path.join(root, ".openclaw-456"), { recursive: true });
|
|
await fs.writeFile(path.join(root, ".openclaw-file"), "nope", "utf8");
|
|
await fs.mkdir(path.join(root, "openclaw"), { recursive: true });
|
|
|
|
await expect(
|
|
cleanupGlobalRenameDirs({
|
|
globalRoot: root,
|
|
packageName: "openclaw",
|
|
}),
|
|
).resolves.toEqual({
|
|
removed: [".openclaw-123", ".openclaw-456"],
|
|
});
|
|
await expect(fs.stat(path.join(root, "openclaw"))).resolves.toBeDefined();
|
|
await expect(fs.stat(path.join(root, ".openclaw-file"))).resolves.toBeDefined();
|
|
});
|
|
});
|