diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b4546d49d2..bf37c1757e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. - Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280. - Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898. +- Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs. ### Fixes diff --git a/docs/cli/update.md b/docs/cli/update.md index 7a1840096f2..d1c61518b0c 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -21,6 +21,7 @@ openclaw update wizard openclaw update --channel beta openclaw update --channel dev openclaw update --tag beta +openclaw update --tag main openclaw update --dry-run openclaw update --no-restart openclaw update --json @@ -31,7 +32,7 @@ openclaw --update - `--no-restart`: skip restarting the Gateway service after a successful update. - `--channel `: set the update channel (git + npm; persisted in config). -- `--tag `: override the npm dist-tag or version for this update only. +- `--tag `: override the package target for this update only. For package installs, `main` maps to `github:openclaw/openclaw#main`. - `--dry-run`: preview planned update actions (channel/tag/target/restart flow) without writing config, installing, syncing plugins, or restarting. - `--json`: print machine-readable `UpdateRunResult` JSON. - `--timeout `: per-step timeout (default is 1200s). diff --git a/docs/install/index.md b/docs/install/index.md index d0f847838d0..464a457a360 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -102,6 +102,16 @@ For VPS/cloud hosts, avoid third-party "1-click" marketplace images when possibl + Want the current GitHub `main` head with a package-manager install? + + ```bash + npm install -g github:openclaw/openclaw#main + ``` + + ```bash + pnpm add -g github:openclaw/openclaw#main + ``` + diff --git a/docs/install/installer.md b/docs/install/installer.md index 6317e8e06cc..5859c22fd0d 100644 --- a/docs/install/installer.md +++ b/docs/install/installer.md @@ -116,6 +116,11 @@ The script exits with code `2` for invalid method selection or invalid `--instal curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --install-method git ``` + + ```bash + curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --version main + ``` + ```bash curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --dry-run @@ -126,39 +131,39 @@ The script exits with code `2` for invalid method selection or invalid `--instal -| Flag | Description | -| ------------------------------- | ---------------------------------------------------------- | -| `--install-method npm\|git` | Choose install method (default: `npm`). Alias: `--method` | -| `--npm` | Shortcut for npm method | -| `--git` | Shortcut for git method. Alias: `--github` | -| `--version ` | npm version or dist-tag (default: `latest`) | -| `--beta` | Use beta dist-tag if available, else fallback to `latest` | -| `--git-dir ` | Checkout directory (default: `~/openclaw`). Alias: `--dir` | -| `--no-git-update` | Skip `git pull` for existing checkout | -| `--no-prompt` | Disable prompts | -| `--no-onboard` | Skip onboarding | -| `--onboard` | Enable onboarding | -| `--dry-run` | Print actions without applying changes | -| `--verbose` | Enable debug output (`set -x`, npm notice-level logs) | -| `--help` | Show usage (`-h`) | +| Flag | Description | +| ------------------------------------- | ---------------------------------------------------------- | +| `--install-method npm\|git` | Choose install method (default: `npm`). Alias: `--method` | +| `--npm` | Shortcut for npm method | +| `--git` | Shortcut for git method. Alias: `--github` | +| `--version ` | npm version, dist-tag, or package spec (default: `latest`) | +| `--beta` | Use beta dist-tag if available, else fallback to `latest` | +| `--git-dir ` | Checkout directory (default: `~/openclaw`). Alias: `--dir` | +| `--no-git-update` | Skip `git pull` for existing checkout | +| `--no-prompt` | Disable prompts | +| `--no-onboard` | Skip onboarding | +| `--onboard` | Enable onboarding | +| `--dry-run` | Print actions without applying changes | +| `--verbose` | Enable debug output (`set -x`, npm notice-level logs) | +| `--help` | Show usage (`-h`) | -| Variable | Description | -| ------------------------------------------- | --------------------------------------------- | -| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method | -| `OPENCLAW_VERSION=latest\|next\|` | npm version or dist-tag | -| `OPENCLAW_BETA=0\|1` | Use beta if available | -| `OPENCLAW_GIT_DIR=` | Checkout directory | -| `OPENCLAW_GIT_UPDATE=0\|1` | Toggle git updates | -| `OPENCLAW_NO_PROMPT=1` | Disable prompts | -| `OPENCLAW_NO_ONBOARD=1` | Skip onboarding | -| `OPENCLAW_DRY_RUN=1` | Dry run mode | -| `OPENCLAW_VERBOSE=1` | Debug mode | -| `OPENCLAW_NPM_LOGLEVEL=error\|warn\|notice` | npm log level | -| `SHARP_IGNORE_GLOBAL_LIBVIPS=0\|1` | Control sharp/libvips behavior (default: `1`) | +| Variable | Description | +| ------------------------------------------------------- | --------------------------------------------- | +| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method | +| `OPENCLAW_VERSION=latest\|next\|main\|\|` | npm version, dist-tag, or package spec | +| `OPENCLAW_BETA=0\|1` | Use beta if available | +| `OPENCLAW_GIT_DIR=` | Checkout directory | +| `OPENCLAW_GIT_UPDATE=0\|1` | Toggle git updates | +| `OPENCLAW_NO_PROMPT=1` | Disable prompts | +| `OPENCLAW_NO_ONBOARD=1` | Skip onboarding | +| `OPENCLAW_DRY_RUN=1` | Dry run mode | +| `OPENCLAW_VERBOSE=1` | Debug mode | +| `OPENCLAW_NPM_LOGLEVEL=error\|warn\|notice` | npm log level | +| `SHARP_IGNORE_GLOBAL_LIBVIPS=0\|1` | Control sharp/libvips behavior (default: `1`) | @@ -276,6 +281,11 @@ Designed for environments where you want everything under a local prefix (defaul & ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -InstallMethod git ``` + + ```powershell + & ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -Tag main + ``` + ```powershell & ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -InstallMethod git -GitDir "C:\openclaw" @@ -299,14 +309,14 @@ Designed for environments where you want everything under a local prefix (defaul -| Flag | Description | -| ------------------------- | ------------------------------------------------------ | -| `-InstallMethod npm\|git` | Install method (default: `npm`) | -| `-Tag ` | npm dist-tag (default: `latest`) | -| `-GitDir ` | Checkout directory (default: `%USERPROFILE%\openclaw`) | -| `-NoOnboard` | Skip onboarding | -| `-NoGitUpdate` | Skip `git pull` | -| `-DryRun` | Print actions only | +| Flag | Description | +| --------------------------- | ---------------------------------------------------------- | +| `-InstallMethod npm\|git` | Install method (default: `npm`) | +| `-Tag ` | npm dist-tag, version, or package spec (default: `latest`) | +| `-GitDir ` | Checkout directory (default: `%USERPROFILE%\openclaw`) | +| `-NoOnboard` | Skip onboarding | +| `-NoGitUpdate` | Skip `git pull` | +| `-DryRun` | Print actions only | diff --git a/docs/install/updating.md b/docs/install/updating.md index f94c2600776..e304fe0322b 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -65,7 +65,25 @@ openclaw update --channel dev openclaw update --channel stable ``` -Use `--tag ` for a one-off install tag/version. +Use `--tag ` for a one-off package target override. + +For the current GitHub `main` head via a package-manager install: + +```bash +openclaw update --tag main +``` + +Manual equivalents: + +```bash +npm i -g github:openclaw/openclaw#main +``` + +```bash +pnpm add -g github:openclaw/openclaw#main +``` + +You can also pass an explicit package spec to `--tag` for one-off updates (for example a GitHub ref or tarball URL). See [Development channels](/install/development-channels) for channel semantics and release notes. diff --git a/scripts/install.ps1 b/scripts/install.ps1 index ac30daf9cb5..fccf2fec06b 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -200,13 +200,15 @@ function Ensure-Git { } function Install-OpenClawNpm { - param([string]$Version = "latest") + param([string]$Target = "latest") + + $installSpec = Resolve-PackageInstallSpec -Target $Target - Write-Host "Installing OpenClaw (openclaw@$Version)..." -Level info + Write-Host "Installing OpenClaw ($installSpec)..." -Level info try { # Use -ExecutionPolicy Bypass to handle restricted execution policy - npm install -g openclaw@$Version --no-fund --no-audit 2>&1 + npm install -g $installSpec --no-fund --no-audit 2>&1 Write-Host "OpenClaw installed" -Level success return $true } catch { @@ -257,6 +259,34 @@ node "%~dp0..\openclaw\dist\entry.js" %* return $true } +function Test-ExplicitPackageInstallSpec { + param([string]$Target) + + if ([string]::IsNullOrWhiteSpace($Target)) { + return $false + } + + return $Target.Contains("://") -or + $Target.Contains("#") -or + $Target -match '^(file|github|git\+ssh|git\+https|git\+http|git\+file|npm):' +} + +function Resolve-PackageInstallSpec { + param([string]$Target = "latest") + + $trimmed = $Target.Trim() + if ([string]::IsNullOrWhiteSpace($trimmed)) { + return "openclaw@latest" + } + if ($trimmed.ToLowerInvariant() -eq "main") { + return "github:openclaw/openclaw#main" + } + if (Test-ExplicitPackageInstallSpec -Target $trimmed) { + return $trimmed + } + return "openclaw@$trimmed" +} + function Add-ToPath { param([string]$Path) @@ -301,9 +331,9 @@ function Main { } if ($DryRun) { - Write-Host "[DRY RUN] Would install OpenClaw via npm (tag: $Tag)" -Level info + Write-Host "[DRY RUN] Would install OpenClaw via npm ($((Resolve-PackageInstallSpec -Target $Tag)))" -Level info } else { - if (!(Install-OpenClawNpm -Version $Tag)) { + if (!(Install-OpenClawNpm -Target $Tag)) { exit 1 } } diff --git a/scripts/install.sh b/scripts/install.sh index 2abfbad9935..70c68bf703c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1011,7 +1011,7 @@ Options: --install-method, --method npm|git Install via npm (default) or from a git checkout --npm Shortcut for --install-method npm --git, --github Shortcut for --install-method git - --version npm install: version (default: latest) + --version npm install target (default: latest; use "main" for GitHub main) --beta Use beta if available, else latest --git-dir, --dir Checkout directory (default: ~/openclaw) --no-git-update Skip git pull for existing checkout @@ -1024,7 +1024,7 @@ Options: Environment variables: OPENCLAW_INSTALL_METHOD=git|npm - OPENCLAW_VERSION=latest|next| + OPENCLAW_VERSION=latest|next|main|| OPENCLAW_BETA=0|1 OPENCLAW_GIT_DIR=... OPENCLAW_GIT_UPDATE=0|1 @@ -1040,6 +1040,7 @@ Examples: curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --no-onboard curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --no-onboard --verify + curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --version main curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --install-method git --no-onboard EOF } @@ -1963,6 +1964,43 @@ resolve_beta_version() { echo "$beta" } +is_explicit_package_install_spec() { + local value="${1:-}" + [[ "$value" == *"://"* || "$value" == *"#"* || "$value" =~ ^(file|github|git\+ssh|git\+https|git\+http|git\+file|npm): ]] +} + +can_resolve_registry_package_version() { + local value="${1:-}" + if [[ -z "$value" ]]; then + return 0 + fi + if [[ "${value,,}" == "main" ]]; then + return 1 + fi + if is_explicit_package_install_spec "$value"; then + return 1 + fi + return 0 +} + +resolve_package_install_spec() { + local package_name="$1" + local value="$2" + if [[ "${value,,}" == "main" ]]; then + echo "github:openclaw/openclaw#main" + return 0 + fi + if is_explicit_package_install_spec "$value"; then + echo "$value" + return 0 + fi + if [[ "$value" == "latest" ]]; then + echo "${package_name}@latest" + return 0 + fi + echo "${package_name}@${value}" +} + install_openclaw() { local package_name="openclaw" if [[ "$USE_BETA" == "1" ]]; then @@ -1983,18 +2021,16 @@ install_openclaw() { fi local resolved_version="" - resolved_version="$(npm view "${package_name}@${OPENCLAW_VERSION}" version 2>/dev/null || true)" + if can_resolve_registry_package_version "${OPENCLAW_VERSION}"; then + resolved_version="$(npm view "${package_name}@${OPENCLAW_VERSION}" version 2>/dev/null || true)" + fi if [[ -n "$resolved_version" ]]; then ui_info "Installing OpenClaw v${resolved_version}" else ui_info "Installing OpenClaw (${OPENCLAW_VERSION})" fi local install_spec="" - if [[ "${OPENCLAW_VERSION}" == "latest" ]]; then - install_spec="${package_name}@latest" - else - install_spec="${package_name}@${OPENCLAW_VERSION}" - fi + install_spec="$(resolve_package_install_spec "${package_name}" "${OPENCLAW_VERSION}")" if ! install_openclaw_npm "${install_spec}"; then ui_warn "npm install failed; retrying" diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index f2138215327..77593f876aa 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -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(); diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 7f82f701c8a..529b65cd917 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -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 ", "Persist update channel (git + npm)") - .option("--tag ", "Override npm dist-tag or version for this update") + .option( + "--tag ", + "Override the package target for this update (dist-tag, version, or package spec)", + ) .option("--timeout ", "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 for a one-off npm update without persisting + - Use --tag for a one-off package update without persisting ${theme.heading("Non-interactive:")} - Use --yes to accept downgrade prompts diff --git a/src/cli/update-cli/shared.ts b/src/cli/update-cli/shared.ts index d7cbc5ec86b..1f934f3c9be 100644 --- a/src/cli/update-cli/shared.ts +++ b/src/cli/update-cli/shared.ts @@ -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 { + if (!canResolveRegistryVersionForPackageTarget(tag)) { + return null; + } const direct = normalizeVersionTag(tag); if (direct) { return direct; diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index b94fbd4ffb9..abc9c0080c7 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -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 { 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 { } 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 { 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 { requestedChannel, storedChannel, effectiveChannel: channel, - tag, + tag: packageInstallSpec ?? tag, currentVersion, targetVersion, downgradeRisk, diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index 54cda49a407..3df6151e11c 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -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"); diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index 4df88cc2221..e0dc9045f67 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -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 { @@ -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( diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index bb9be0d5be7..35716f84c2f 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -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({