docs(install): clarify lifecycle script trust (#124727)

* docs(install): approve OpenClaw lifecycle scripts

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

* fix(update): bind npm lifecycle policy to install owner

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

* fix(update): gate npm lifecycle policy before mutation

* test(update): cover npm lifecycle ownership

* fix(update): defer backup cleanup until package preflight passes

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

* fix(update): preflight npm policy before service stop

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

* docs(install): avoid unsupported lifecycle claim

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Peter Steinberger
2026-08-17 01:00:17 -07:00
committed by GitHub
parent e446679131
commit 05bbd48c51
26 changed files with 568 additions and 75 deletions
+5 -2
View File
@@ -36,10 +36,13 @@ iwr -useb https://openclaw.ai/install.ps1 | iex
Already manage Node.js? Install the published package instead (Node 22.22.3+, 24.15+, or 25.9+):
```bash
npm install -g openclaw@latest
npm install -g openclaw@latest --allow-scripts=openclaw
```
See the [installation guide](https://docs.openclaw.ai/install) for npm 12 lifecycle-script requirements, Docker, Nix, and other deployment paths.
That command is for npm 12 or npm 11.16+. On npm 11.12 and earlier, omit
`--allow-scripts=openclaw`. If you have npm 11.1311.15, upgrade npm first. See
the [installation guide](https://docs.openclaw.ai/install) for the lifecycle
script contract, Docker, Nix, and other deployment paths.
## Quick start
+2 -1
View File
@@ -401,7 +401,8 @@ Default Docker image: `openclaw-sandbox:bookworm-slim`
The `scripts/sandbox-setup.sh`, `scripts/sandbox-common-setup.sh`, and `scripts/sandbox-browser-setup.sh` helper scripts are only available when running from a [source checkout](https://github.com/openclaw/openclaw). They are not included in the npm package.
If you installed OpenClaw via `npm install -g openclaw`, use the inline `docker build` commands shown below instead.
If you installed the global OpenClaw npm package, use the inline `docker build`
commands shown below instead.
</Note>
<Steps>
+7 -1
View File
@@ -142,9 +142,15 @@ The Gateway is the single source of truth for sessions, routing, and channel con
<Steps>
<Step title="Install OpenClaw">
On npm 12 or npm 11.16+:
```bash
npm install -g openclaw@latest
npm install -g openclaw@latest --allow-scripts=openclaw
```
On npm 11.12 and earlier, omit `--allow-scripts=openclaw`. Upgrade npm
11.1311.15 before installing.
</Step>
<Step title="Onboard and install the service">
```bash
+4 -1
View File
@@ -242,8 +242,11 @@ personal-number mode, and self-chat details: [WhatsApp](/channels/whatsapp).
The snapshot's global npm tree is owned by root, so plain `openclaw update`
cannot write to it. Update from the sandbox SSH session with:
The command below is for npm 12 or npm 11.16+. On npm 11.12 and earlier,
omit `--allow-scripts=openclaw`; upgrade npm 11.1311.15 first.
```bash
sudo env "PATH=$PATH" npm install --global openclaw@latest
sudo env "PATH=$PATH" npm install --global openclaw@latest --allow-scripts=openclaw
openclaw doctor
```
+23 -16
View File
@@ -75,26 +75,31 @@ If you already manage Node yourself:
<Tabs>
<Tab title="npm">
On npm 12 or npm 11.16+:
```bash
npm install -g openclaw@latest
npm install -g openclaw@latest --allow-scripts=openclaw
openclaw onboard --install-daemon
```
On npm 11.12 and earlier, use the same command without
`--allow-scripts=openclaw`. Do not use npm 11.1311.15 for this install;
upgrade to npm 11.16+ first.
<Note>
npm 12 blocks package lifecycle scripts by default, so the command above
skips OpenClaw's `preinstall` and `postinstall` steps — npm reports them
as `blocked because they are not covered by allowScripts`. Allow them
explicitly:
npm 12 blocks unapproved package lifecycle scripts by default. The
`--allow-scripts=openclaw` option explicitly allows OpenClaw's `preinstall`
and `postinstall` steps; without it, npm reports them as `blocked because
they are not covered by allowScripts`.
```bash
npm install -g openclaw@latest --allow-scripts openclaw
```
npm 11.16.x only warns that the scripts are `not yet covered by
allowScripts` and still runs them. If you want to clear that warning, be
aware that the `npm approve-scripts openclaw` command it suggests does not
work for a global install — it fails with `ENOMATCH No installed packages
match: openclaw`. npm 11.12 and earlier have no such policy.
npm 11.16 accepts the option but otherwise only warns that the scripts are
`not yet covered by allowScripts` and still runs them. npm 11.12 and earlier
have neither the policy nor the option, so their command must be unflagged.
npm 11.1311.15 also lack the option, but they are transitional upstream
releases outside this documented install contract; upgrade rather than
relying on their unflagged behavior. The `npm approve-scripts openclaw`
command suggested by npm 11.16 does not work for a global install — it fails
with `ENOMATCH No installed packages match: openclaw`.
</Note>
<Note>
@@ -117,12 +122,14 @@ If you already manage Node yourself:
</Tab>
<Tab title="bun">
```bash
bun add -g openclaw@latest
bun add -g --trust openclaw@latest
openclaw onboard --install-daemon
```
<Note>
Bun can install the global package, but the resulting `openclaw` executable requires a supported Node runtime because OpenClaw state uses `node:sqlite`.
`--trust` allows OpenClaw's package lifecycle scripts for this install. Bun
can install the global package, but the resulting `openclaw` executable
requires a supported Node runtime because OpenClaw state uses `node:sqlite`.
</Note>
</Tab>
+3 -2
View File
@@ -116,10 +116,11 @@ Replace `youruser` with the account you created, and the IP with your VM's IP.
## 6) Install OpenClaw
Inside the VM:
Inside the VM, use the following command on npm 12 or npm 11.16+. On npm 11.12
and earlier, omit `--allow-scripts=openclaw`; upgrade npm 11.1311.15 first.
```bash
npm install -g openclaw@latest
npm install -g openclaw@latest --allow-scripts=openclaw
openclaw onboard --install-daemon
```
+37 -16
View File
@@ -45,9 +45,12 @@ verifies the selected exact package, and installs that exact version. Missing
or inconsistent registry data fails closed; it never falls back to `latest`.
If the selected version is older than the installed version, the normal
downgrade confirmation still applies. The CLI persists the channel after a
successful core update; a direct `npm install -g openclaw@extended-stable`
does not update `update.channel`, but a final extended-stable package version
still checks only the verified `extended-stable` selector for update availability.
successful core update; a direct
`npm install -g openclaw@extended-stable --allow-scripts=openclaw` does not
update `update.channel`, but a final extended-stable package version still
checks only the verified `extended-stable` selector for update availability.
That direct command is for npm 12 or npm 11.16+. On npm 11.12 and earlier,
omit `--allow-scripts=openclaw`; upgrade npm 11.1311.15 first.
After the core swap, eligible official npm plugins with bare/default or
`latest` intent converge to that exact core version. Exact pins and explicit
non-`latest` tags, third-party plugins, and non-npm sources remain unchanged.
@@ -154,8 +157,11 @@ curl -fsSL https://openclaw.ai/install.sh | bash -s -- --install-method npm --ve
## Alternative: manual npm, pnpm, or bun
The npm command below is for npm 12 or npm 11.16+. On npm 11.12 and earlier,
omit `--allow-scripts=openclaw`; upgrade npm 11.1311.15 first.
```bash
npm i -g openclaw@latest
npm i -g openclaw@latest --allow-scripts=openclaw
```
Prefer `openclaw update` for supervised installs: it can coordinate the package
@@ -171,9 +177,12 @@ manual replacement. Use the same profile flags/environment you normally use for
that Gateway. Replace `/usr/bin/npm` with the system npm that owns the
root-owned global prefix on your host:
The npm command below follows the same version contract: use the flag on npm 12
or npm 11.16+, omit it on npm 11.12 and earlier, and upgrade npm 11.1311.15.
```bash
openclaw gateway stop
sudo /usr/bin/npm i -g openclaw@latest
sudo /usr/bin/npm i -g openclaw@latest --allow-scripts=openclaw
openclaw gateway install --force
openclaw gateway restart
```
@@ -194,11 +203,13 @@ Node version during `preinstall`; only then does OpenClaw verify the packaged
`dist` inventory and swap the clean package tree into the real global prefix. A
packed completion guard is omitted from the expected inventory and removed only
after `preinstall` succeeds, so skipped lifecycle scripts also fail before the
swap. On npm 12 and newer, the updater approves only the candidate OpenClaw
lifecycle; transitive dependency scripts remain blocked. This avoids npm
overlaying a new package onto stale files from the old one. If the install
command fails, OpenClaw retries once with `--omit=optional`, which helps hosts
where native optional dependencies cannot compile.
swap. The updater probes the owning npm before mutation. On npm 11.12 and
earlier it omits the unsupported lifecycle-policy flag; on npm 11.1311.15 it
stops with upgrade guidance. On npm 12 and npm 11.16+, it approves only the
candidate OpenClaw lifecycle; transitive dependency scripts remain unapproved.
This avoids npm overlaying a new package onto stale files from the old one. If
the install command fails, OpenClaw retries once with `--omit=optional`, which
helps hosts where native optional dependencies cannot compile.
OpenClaw-managed npm update and plugin-update commands also clear npm's
`min-release-age` supply-chain quarantine (or the older `before` config key)
@@ -206,7 +217,7 @@ for the child npm process. That policy exists for general protection, but an
explicit OpenClaw update means "install the selected release now."
```bash
pnpm add -g openclaw@latest
pnpm add -g --allow-build=openclaw openclaw@latest
```
If pnpm 11 installed OpenClaw 2026.7.1, run that manual command once. That
@@ -224,9 +235,12 @@ comma-separated group manually so its sibling packages and build policy stay
intact.
```bash
bun add -g openclaw@latest
bun add -g --trust openclaw@latest
```
`--trust` allows OpenClaw's lifecycle scripts. The canonical `openclaw update`
path applies the same OpenClaw-only Bun trust when it owns the install.
### Advanced npm install topics
<AccordionGroup>
@@ -431,16 +445,23 @@ automatically replacing the package again.
If the CLI update path is unavailable, use the same package manager and install
scope that own the current Gateway:
The npm command below is for npm 12 or npm 11.16+. On npm 11.12 and earlier,
omit `--allow-scripts=openclaw`; upgrade npm 11.1311.15 first.
```bash
openclaw gateway stop
npm i -g openclaw@<known-good-version>
npm i -g openclaw@<known-good-version> --allow-scripts=openclaw
openclaw gateway install --force
openclaw gateway restart
```
Replace `npm` with `pnpm` or `bun` when that manager owns the install. During
incident recovery, prevent an enabled auto-updater from immediately applying a
newer release by setting `OPENCLAW_NO_AUTO_UPDATE=1` in the Gateway environment.
For a pnpm-owned install, use
`pnpm add -g --allow-build=openclaw openclaw@<known-good-version>` instead. For
a Bun-owned install, use
`bun add -g --trust openclaw@<known-good-version>`; `--trust` allows OpenClaw's
lifecycle scripts. During incident recovery, prevent an enabled auto-updater
from immediately applying a newer release by setting
`OPENCLAW_NO_AUTO_UPDATE=1` in the Gateway environment.
### Roll back a source checkout
+3 -2
View File
@@ -39,10 +39,11 @@ The keepalive options reduce idle tunnel drops during onboarding.
## Install OpenClaw
Inside the Box:
Inside the Box, use the following command on npm 12 or npm 11.16+. On npm 11.12
and earlier, omit `--allow-scripts=openclaw`; upgrade npm 11.1311.15 first.
```bash
sudo npm install -g openclaw
sudo npm install -g openclaw --allow-scripts=openclaw
```
## Run onboarding
+1 -1
View File
@@ -88,7 +88,7 @@ RPCs: `environments.create`, `environments.destroy`, extended `environments.list
No bespoke worker artifact, and no dependence on npm availability:
- Canonical install for all modes: a gateway-produced, content-hashed worker bundle (the gateway's own build output packed as a tarball), pushed over SSH and installed on the box. This covers dev builds and unreleased commits by construction.
- `npm i -g openclaw@<exact gateway version>` is an optimization when the gateway runs a released version; never `latest`.
- On npm 12 or npm 11.16+, `npm i -g openclaw@<exact gateway version> --allow-scripts=openclaw` is an optimization when the gateway runs a released version; never `latest`. On npm 11.12 and earlier, omit `--allow-scripts=openclaw`; upgrade npm 11.1311.15 first.
- Bootstrap is idempotent; a warm lease with a matching bundle hash skips install. Raw machines may need a networked toolchain phase (Node runtime) — part of the setup phase, closed afterwards.
- Handshake verifies worker build hash, protocol feature set, and runtime compatibility. The existing gateway version/protocol checks are insufficient for this (SSH-tunneled nodes are exempted from exact-version rejection), so worker admission does its own exact-build check.
+3 -1
View File
@@ -53,7 +53,9 @@ Full server guidance lives in the [Linux guide](/platforms/linux) and the
## Prefer the native install over Docker
On a single user Chromebook, use the native npm install (the installer script,
or a global `npm i -g openclaw@latest`) rather than [Docker](/install/docker).
or `npm i -g openclaw@latest --allow-scripts=openclaw` on npm 12 or npm
11.16+) rather than [Docker](/install/docker). On npm 11.12 and earlier, omit
`--allow-scripts=openclaw`; upgrade npm 11.1311.15 first.
Docker works inside Crostini, but Docker in Crostini adds friction: if you use
the Claude Code CLI as your model runtime, it has to be installed and logged in
+1 -1
View File
@@ -130,7 +130,7 @@ Linux v1 uses one Canvas window. HTTP and HTTPS pages are renderable, but A2UI a
The CLI remains the simplest option for a headless server, a VPS, or a remote Gateway:
1. Install Node 26 (recommended), or another supported release: Node 22.22.3+, Node 24.15+, or Node 25.9+.
2. `npm i -g openclaw@latest`
2. On npm 12 or npm 11.16+, run `npm i -g openclaw@latest --allow-scripts=openclaw`. On npm 11.12 and earlier, omit `--allow-scripts=openclaw`; upgrade npm 11.1311.15 first.
3. `openclaw onboard --install-daemon`
4. From your laptop: `ssh -N -L 18789:127.0.0.1:18789 <user>@<host>`
5. Open `http://127.0.0.1:18789/` and authenticate with the configured shared
+4 -1
View File
@@ -29,8 +29,11 @@ OpenClaw package.
For a manual install, use Node 26 (recommended) or another supported release:
Node 22.22.3+, Node 24.15+, or Node 25.9+. Install `openclaw` globally:
The command below is for npm 12 or npm 11.16+. On npm 11.12 and earlier,
omit `--allow-scripts=openclaw`; upgrade npm 11.1311.15 first.
```bash
npm install -g openclaw@<version>
npm install -g openclaw@<version> --allow-scripts=openclaw
```
Use **Retry setup** after a failed automatic setup. If that still fails,
+8 -3
View File
@@ -55,12 +55,17 @@ matching user-space CLI and runtime before starting the Gateway wizard.
For manual development recovery, install the matching CLI yourself:
The npm command below is for npm 12 or npm 11.16+. On npm 11.12 and earlier,
omit `--allow-scripts=openclaw`; upgrade npm 11.1311.15 first.
```bash
npm install -g openclaw@<version>
npm install -g openclaw@<version> --allow-scripts=openclaw
```
`pnpm add -g openclaw@<version>` and `bun add -g openclaw@<version>` also
work. Node remains the recommended runtime for the Gateway itself.
`pnpm add -g --allow-build=openclaw openclaw@<version>` and
`bun add -g --trust openclaw@<version>` also work. Bun's `--trust` allows the
OpenClaw lifecycle scripts for that install. Node remains the recommended
runtime for the Gateway itself.
## Troubleshooting
+7 -1
View File
@@ -201,10 +201,16 @@ the repository root does not prepare bundled plugin dependencies.
| Install shape | Bundled plugin location | Dependency owner |
| -------------------------------- | ------------------------------------- | -------------------------------------------------------------------- |
| `npm install -g openclaw` | Built runtime tree inside the package | OpenClaw package and explicit plugin install/update/doctor flows |
| Global npm install | Built runtime tree inside the package | OpenClaw package and explicit plugin install/update/doctor flows |
| Git checkout plus `pnpm install` | `extensions/<id>` workspace packages | The pnpm workspace, including each plugin package's own dependencies |
| `openclaw plugins install ...` | Managed npm project/git/ClawHub root | The plugin install/update flow |
For the global npm row, use
`npm install -g openclaw --allow-scripts=openclaw` on npm 12 or npm 11.16+.
On npm 11.12 and earlier, omit `--allow-scripts=openclaw`; upgrade npm
11.1311.15 first. Plugin dependency convergence remains intentionally
script-disabled and continues to use the `--ignore-scripts` commands above.
## Legacy cleanup
Older OpenClaw versions generated bundled-plugin dependency roots at startup
+111
View File
@@ -1267,6 +1267,9 @@ describe("update-cli", () => {
const mockNpmGlobalRoot = (nodeModules: string) => {
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (Array.isArray(argv) && argv[1] === "--version") {
return commandResult({ stdout: "12.0.0\n" });
}
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
return commandResult({ stdout: `${nodeModules}\n` });
}
@@ -1410,6 +1413,9 @@ describe("update-cli", () => {
},
});
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (argv[1] === "--version") {
return commandResult({ stdout: "12.0.0\n" });
}
if (argv[0] === "npm" && argv[1] === "pack") {
const destination = argv[argv.indexOf("--pack-destination") + 1];
if (destination) {
@@ -4392,6 +4398,9 @@ describe("update-cli", () => {
});
readPackageVersion.mockResolvedValue("2026.3.23");
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (Array.isArray(argv) && argv[1] === "--version") {
return commandResult({ stdout: "12.0.0\n" });
}
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
return commandResult({ stdout: nodeModules });
}
@@ -4415,6 +4424,9 @@ describe("update-cli", () => {
readPackageVersion.mockResolvedValue("2026.4.20");
primeNpmChannelTag("latest", "2026.4.25");
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (Array.isArray(argv) && argv[1] === "--version") {
return commandResult({ stdout: "12.0.0\n" });
}
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
return commandResult({ stdout: `${nodeModules}\n` });
}
@@ -4505,6 +4517,9 @@ describe("update-cli", () => {
primeNpmChannelTag("latest", "2026.4.21");
mockFileBackedPathExists();
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv, options) => {
if (Array.isArray(argv) && argv[1] === "--version") {
return commandResult({ stdout: "12.0.0\n" });
}
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
return commandResult({ stdout: `${nodeModules}\n` });
}
@@ -4571,6 +4586,9 @@ describe("update-cli", () => {
primeNpmChannelTag("latest", "2026.4.21");
mockFileBackedPathExists();
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (Array.isArray(argv) && argv[1] === "--version") {
return commandResult({ stdout: "12.0.0\n" });
}
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
return commandResult({ stdout: `${nodeModules}\n` });
}
@@ -4614,6 +4632,9 @@ describe("update-cli", () => {
if (!Array.isArray(argv)) {
return commandResult();
}
if (argv[1] === "--version") {
return commandResult({ stdout: "12.0.0\n" });
}
if (argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
return commandResult({ stdout: `${nodeModules}\n` });
}
@@ -4658,6 +4679,34 @@ describe("update-cli", () => {
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
});
it.each(["11.13.0", "11.15.9"])(
"refuses npm %s before stopping the managed gateway or cleaning update backups",
async (npmVersion) => {
const tempDir = await createTrackedTempDir("openclaw-update-npm-policy-");
const { nodeModules, entryPath } = await setupInstalledPackageRoot(tempDir);
const backupDir = path.join(nodeModules, ".openclaw-interrupted");
await fs.mkdir(backupDir, { recursive: true });
mockRunningManagedGateway(["node", entryPath, "gateway", "run"]);
mockFileBackedPathExists();
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (argv[0] === "npm" && argv[1] === "--version") {
return commandResult({ stdout: `${npmVersion}\n` });
}
if (argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
return commandResult({ stdout: `${nodeModules}\n` });
}
return commandResult();
});
await updateCommand({ yes: true });
expect(serviceStop).not.toHaveBeenCalled();
expect(packageInstallCommandCall()).toBeUndefined();
await expect(fs.access(backupDir)).resolves.toBeUndefined();
expect(getErrorOutput()).toContain(`npm ${npmVersion} cannot safely approve`);
},
);
it("stops a running managed gateway before package replacement", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const processOnSpy = vi.spyOn(process, "on");
@@ -4834,6 +4883,9 @@ describe("update-cli", () => {
mockFileBackedPathExists();
mockNpmGlobalRoot(nodeModules);
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (argv[1] === "--version") {
return commandResult({ stdout: "12.0.0\n" });
}
if (argv[0] === "npm" && argv[1] === "i" && argv[2] === "-g") {
throw new Error("package replacement failed");
}
@@ -4872,6 +4924,9 @@ describe("update-cli", () => {
mockFileBackedPathExists();
mockNpmGlobalRoot(nodeModules);
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (argv[1] === "--version") {
return commandResult({ stdout: "12.0.0\n" });
}
if (argv[0] === "npm" && argv[1] === "i" && argv[2] === "-g") {
throw new Error("package replacement failed");
}
@@ -5019,6 +5074,9 @@ describe("update-cli", () => {
serviceReadRuntime.mockResolvedValue({ status: "stopped", state: "stopped" });
suspendScheduledTaskAutoStartForUpdate.mockResolvedValue(true);
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (argv[1] === "--version") {
return commandResult({ stdout: "12.0.0\n" });
}
if (argv[0] === "npm" && argv[1] === "i" && argv[2] === "-g") {
throw new Error("update invariant broke");
}
@@ -5440,6 +5498,9 @@ describe("update-cli", () => {
});
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (Array.isArray(argv) && argv[1] === "--version") {
return commandResult({ stdout: "12.0.0\n" });
}
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
return commandResult({ stdout: `${nodeModules}\n` });
}
@@ -5505,6 +5566,9 @@ describe("update-cli", () => {
if (!Array.isArray(argv)) {
return commandResult();
}
if (isOwningNpmCommand(argv[0]) && argv[1] === "--version") {
return commandResult({ stdout: "12.0.0\n" });
}
if (argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
return commandResult({ stdout: `${pathNpmRoot}\n` });
}
@@ -5719,6 +5783,9 @@ describe("update-cli", () => {
if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") {
return commandResult({ stdout: "v22.18.0\n" });
}
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "--version") {
return commandResult({ stdout: "12.0.0\n" });
}
return commandResult();
});
nodeVersionSatisfiesEngine.mockReturnValue(false);
@@ -5762,6 +5829,13 @@ describe("update-cli", () => {
if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") {
return commandResult({ stdout: "v22.22.0\n" });
}
if (
Array.isArray(argv) &&
(argv[0] === serviceNpm || argv[0] === serviceNpmReal) &&
argv[1] === "--version"
) {
return commandResult({ stdout: "12.0.0\n" });
}
if (
Array.isArray(argv) &&
(argv[0] === serviceNpm || argv[0] === serviceNpmReal) &&
@@ -5860,6 +5934,13 @@ describe("update-cli", () => {
if (Array.isArray(argv) && argv[0] === process.execPath && argv[1] === "--version") {
return commandResult({ stdout: "v24.15.0\n" });
}
if (
Array.isArray(argv) &&
(argv[0] === serviceNpm || argv[0] === serviceNpmReal) &&
argv[1] === "--version"
) {
return commandResult({ stdout: "12.0.0\n" });
}
if (
Array.isArray(argv) &&
(argv[0] === serviceNpm || argv[0] === serviceNpmReal) &&
@@ -5929,6 +6010,9 @@ describe("update-cli", () => {
if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") {
return commandResult({ stdout: "v24.14.0\n" });
}
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "--version") {
return commandResult({ stdout: "12.0.0\n" });
}
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
// PATH npm returns Node-B's root, NOT the service root.
return commandResult({ stdout: `${nodeBGlobalRoot}\n` });
@@ -6755,6 +6839,33 @@ describe("update-cli", () => {
expectNoSideEffects(runRestartScript, runDaemonRestart);
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
});
it.each(["11.13.0", "11.15.9"])(
"refuses npm %s package-to-dev updates before checkout or install",
async (npmVersion) => {
const packageRoot = createCaseDir("openclaw-npm-transition");
mockPackageInstallStatus(packageRoot);
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (argv[0] === "npm" && argv[1] === "--version") {
return commandResult({ stdout: `${npmVersion}\n` });
}
if (argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
return commandResult({ stdout: `${path.dirname(packageRoot)}\n` });
}
return commandResult();
});
await updateCommand({ channel: "dev", yes: true, restart: false });
expect(runGatewayUpdate).not.toHaveBeenCalled();
expect(commandCalls().some(([argv]) => argv[0] === "git")).toBe(false);
expect(
commandCalls().some(([argv]) => argv[0] === "npm" && argv[1] === "i" && argv[2] === "-g"),
).toBe(false);
expect(getErrorOutput()).toContain(`npm ${npmVersion} cannot safely approve`);
},
);
it("explains why git updates cannot run with edited files", async () => {
vi.mocked(defaultRuntime.log).mockClear();
vi.mocked(defaultRuntime.error).mockClear();
+45 -13
View File
@@ -4,6 +4,7 @@ import {
createGlobalInstallEnv,
globalInstallArgs,
resolveGlobalInstallTarget,
resolveNpmLifecyclePolicyGate,
resolvePnpmGlobalDirFromGlobalRoot,
} from "../../infra/update-global.js";
import { runGatewayUpdate, type UpdateRunResult } from "../../infra/update-runner.js";
@@ -136,6 +137,39 @@ export async function updateGitInstall(params: {
const updateRoot = params.switchToGit ? resolveGitInstallDir() : params.root;
const effectiveTimeout = params.timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS;
const installEnv = await createGlobalInstallEnv();
const runCommand = createGlobalCommandRunner();
const installTarget = params.switchToGit
? await resolveGlobalInstallTarget({
manager: await resolveGlobalManager({
root: params.root,
installKind: params.installKind,
timeoutMs: effectiveTimeout,
}),
runCommand,
timeoutMs: effectiveTimeout,
pkgRoot: params.root,
})
: null;
const npmLifecycleGate = installTarget
? resolveNpmLifecyclePolicyGate(installTarget)
: { policy: null, error: null };
// Package-to-Git updates must settle package-manager policy before cloning or
// updating the checkout; carry this exact decision into the later install.
if (npmLifecycleGate.error) {
const result: UpdateRunResult = {
status: "error",
mode: "git",
root: updateRoot,
reason: "npm lifecycle policy preflight",
steps: [],
durationMs: Date.now() - params.startedAt,
};
params.stop();
defaultRuntime.error(npmLifecycleGate.error);
defaultRuntime.exit(1);
return result;
}
const cloneStep = params.switchToGit
? await ensureGitCheckout({
@@ -177,25 +211,23 @@ export async function updateGitInstall(params: {
const steps = [...(cloneStep ? [cloneStep] : []), ...updateResult.steps];
if (params.switchToGit && updateResult.status === "ok") {
const manager = await resolveGlobalManager({
root: params.root,
installKind: params.installKind,
timeoutMs: effectiveTimeout,
});
const runCommand = createGlobalCommandRunner();
const installTarget = await resolveGlobalInstallTarget({
manager,
runCommand,
timeoutMs: effectiveTimeout,
pkgRoot: params.root,
});
if (!installTarget) {
throw new Error("global install target missing after package-to-Git preflight");
}
const installLocation =
installTarget.manager === "pnpm"
? resolvePnpmGlobalDirFromGlobalRoot(installTarget.globalRoot)
: null;
const installStep = await runUpdateStep({
name: "global install",
argv: globalInstallArgs(installTarget, updateRoot, undefined, installLocation, updateRoot),
argv: globalInstallArgs(
installTarget,
updateRoot,
undefined,
installLocation,
updateRoot,
npmLifecycleGate.policy ?? undefined,
),
cwd: updateRoot,
env: installEnv,
timeoutMs: effectiveTimeout,
@@ -20,7 +20,6 @@ import {
} from "../../infra/update-doctor-result.js";
import {
createGlobalInstallEnv,
cleanupGlobalRenameDirs,
resolveGlobalInstallSpec,
resolveGlobalInstallTarget,
type ResolvedGlobalInstallTarget,
@@ -94,12 +93,6 @@ export async function runPackageInstallUpdate(params: {
});
const beforeVersion = pkgRoot ? await readPackageVersion(pkgRoot) : null;
if (pkgRoot) {
await cleanupGlobalRenameDirs({
globalRoot: path.dirname(pkgRoot),
packageName,
});
}
const diskWarning = createLowDiskSpaceWarning({
targetPath: pkgRoot ? path.dirname(pkgRoot) : params.root,
@@ -101,7 +101,11 @@ const POST_CORE_UPDATE_RESULT_POLL_MS = 100;
export async function reportPreMutationUpdateFailure(params: {
root: string;
installKind: "git" | "package" | "unknown";
reason: ExtendedStableFailureReason | typeof EXTENDED_STABLE_TAG_UNSUPPORTED_REASON;
reason:
| ExtendedStableFailureReason
| typeof EXTENDED_STABLE_TAG_UNSUPPORTED_REASON
| "npm lifecycle policy preflight";
message?: string;
opts: UpdateCommandOptions;
controlPlaneUpdateSentinelMeta: ControlPlaneUpdateSentinelMetaFile["meta"] | null;
}): Promise<void> {
@@ -120,6 +124,9 @@ export async function reportPreMutationUpdateFailure(params: {
jsonMode: Boolean(params.opts.json),
});
}
if (params.message) {
defaultRuntime.error(params.message);
}
printResult(result, params.opts);
defaultRuntime.exit(1);
}
+13
View File
@@ -37,6 +37,7 @@ import {
createGlobalInstallEnv,
resolveGlobalInstallSpec,
resolveGlobalInstallTarget,
resolveNpmLifecyclePolicyGate,
type ResolvedGlobalInstallTarget,
} from "../../infra/update-global.js";
import { updateInstallRootsMatch } from "../../infra/update-install-root.js";
@@ -375,6 +376,18 @@ async function updateCommandInternal(
managedServiceRootRedirect !== null || managedServiceNodeRunner !== undefined,
packageName: installedPackageName,
});
const npmLifecycleGate = resolveNpmLifecyclePolicyGate(packageInstallTarget);
if (npmLifecycleGate.error) {
await reportPreMutationUpdateFailure({
root,
installKind: updateInstallKind,
reason: "npm lifecycle policy preflight",
message: npmLifecycleGate.error,
opts,
controlPlaneUpdateSentinelMeta,
});
return;
}
}
const npmMetadataCommand =
packageInstallTarget?.manager === "npm" ? packageInstallTarget.command : undefined;
+37
View File
@@ -47,6 +47,10 @@ function createNpmTarget(globalRoot: string): ResolvedGlobalInstallTarget {
command: "npm",
globalRoot,
packageRoot: path.join(globalRoot, "openclaw"),
npmOwner: {
version: "12.0.0",
lifecyclePolicy: "allow-scripts",
},
};
}
@@ -75,6 +79,9 @@ async function expectPathMissing(filePath: string): Promise<void> {
function createRootRunner(globalRoot: string): CommandRunner {
return async (argv) => {
if (argv.join(" ") === "npm --version") {
return { stdout: "12.0.0\n", stderr: "", code: 0 };
}
if (argv.join(" ") === "npm root -g") {
return { stdout: `${globalRoot}\n`, stderr: "", code: 0 };
}
@@ -155,6 +162,36 @@ describe("markPackagePostInstallDoctorAdvisory", () => {
});
});
describe("npm lifecycle policy preflight", () => {
it.each([
{ version: "11.13.0", message: "Upgrade the owning npm to 11.16" },
{ version: "11.15.9", message: "Upgrade the owning npm to 11.16" },
{ version: null, message: "Unable to determine the owning npm version" },
])("stops before mutation for npm $version", async ({ version, message }) => {
const runStep = vi.fn();
const runCommand = vi.fn<CommandRunner>();
const installTarget = createNpmTarget("/tmp/npm-policy-test/lib/node_modules");
installTarget.npmOwner = {
version,
lifecyclePolicy: version ? "unsupported-transition" : null,
...(version ? {} : { probeError: "version probe failed" }),
};
const result = await runGlobalPackageUpdateSteps({
installTarget,
installSpec: "openclaw@2.0.0",
packageName: "openclaw",
runCommand,
runStep,
timeoutMs: 1000,
});
expect(runCommand).not.toHaveBeenCalled();
expect(result.failedStep?.stderrTail).toContain(message);
expect(runStep).not.toHaveBeenCalled();
});
});
describe("runGlobalPackageUpdateSteps", () => {
it("installs npm updates into a clean staged prefix before swapping the global package", async () => {
await withTestDir({ prefix: "openclaw-package-update-staged-" }, async (base) => {
+49
View File
@@ -17,6 +17,7 @@ import {
} from "./update-doctor-result.js";
import {
collectInstalledGlobalPackageErrors,
cleanupGlobalRenameDirs,
globalInstallArgs,
globalInstallFallbackArgs,
listActivePnpmIsolatedGlobalPackages,
@@ -25,6 +26,7 @@ import {
resolveNpmGlobalPrefixLayoutFromPrefix,
resolvePnpmIsolatedInstallOwner,
resolvePnpmGlobalDirFromGlobalRoot,
resolveNpmLifecyclePolicyGate,
resolveExpectedInstalledVersionFromSpec,
resolveGlobalInstallTarget,
type CommandRunner,
@@ -85,6 +87,32 @@ const PACKAGE_PREINSTALL_SCRIPT_PATH = path.join(
"preinstall-package-manager-warning.mjs",
);
async function resolveNpmUpdateLifecyclePolicy(params: {
installTarget: ResolvedGlobalInstallTarget;
}): Promise<{
policy: "unflagged" | "allow-scripts" | null;
failedStep: PackageUpdateStepResult | null;
}> {
const gate = resolveNpmLifecyclePolicyGate(params.installTarget);
if (!gate.error) {
return { policy: gate.policy, failedStep: null };
}
const argv = [params.installTarget.command, "--version"];
const version = params.installTarget.npmOwner?.version ?? "";
return {
policy: null,
failedStep: {
name: "npm lifecycle policy preflight",
command: argv.join(" "),
cwd: process.cwd(),
durationMs: 0,
exitCode: 1,
stdoutTail: version || null,
stderrTail: gate.error,
},
};
}
async function resolveCanonicalPath(filePath: string): Promise<string> {
return path.resolve(await fs.realpath(filePath).catch(() => filePath));
}
@@ -842,6 +870,17 @@ export async function runGlobalPackageUpdateSteps(params: {
let packedInstallDir: string | null = null;
try {
const npmPreflight = await resolveNpmUpdateLifecyclePolicy({
installTarget: params.installTarget,
});
if (npmPreflight.failedStep) {
return {
steps: [npmPreflight.failedStep],
verifiedPackageRoot: params.packageRoot ?? params.installTarget.packageRoot,
afterVersion: null,
failedStep: npmPreflight.failedStep,
};
}
const pnpmPreflight = await validatePnpmIsolatedUpdate({
installTarget: params.installTarget,
packageName: params.packageName,
@@ -857,6 +896,14 @@ export async function runGlobalPackageUpdateSteps(params: {
failedStep: pnpmPreflight.failedStep,
};
}
const packageRoot = params.packageRoot ?? params.installTarget.packageRoot;
if (packageRoot) {
// Lifecycle policy must refuse before cleanup can remove an interrupted update backup.
await cleanupGlobalRenameDirs({
globalRoot: path.dirname(packageRoot),
packageName: params.packageName,
});
}
// Keep the preflight and mutation on the same pnpm executable. `pnpm bin -g`
// already verifies its reported bin is on PATH, so no PATH rewrite is needed.
const effectiveInstallEnv = params.env;
@@ -933,6 +980,7 @@ export async function runGlobalPackageUpdateSteps(params: {
undefined,
installLocation,
preparedSpec.installCwd,
npmPreflight.policy ?? undefined,
),
...(updateCwd ? { cwd: updateCwd } : {}),
...installEnv,
@@ -965,6 +1013,7 @@ export async function runGlobalPackageUpdateSteps(params: {
undefined,
stagedInstall?.prefix,
preparedSpec.installCwd,
npmPreflight.policy ?? undefined,
);
if (fallbackArgv) {
const fallbackStep = await params.runStep({
@@ -280,6 +280,9 @@ describe("pnpm 11 global install discovery", () => {
if (command === "npm root -g") {
return { stdout: `${npmGlobalRoot}\n`, stderr: "", code: 0 };
}
if (command === "npm --version") {
return { stdout: "12.0.0\n", stderr: "", code: 0 };
}
if (command === "pnpm root -g") {
return {
stdout: `${path.join(base, "pnpm-home", "global", "v11")}\n`,
@@ -307,6 +310,10 @@ describe("pnpm 11 global install discovery", () => {
command: "npm",
globalRoot: npmGlobalRoot,
packageRoot,
npmOwner: {
version: "12.0.0",
lifecyclePolicy: "allow-scripts",
},
});
});
});
+73
View File
@@ -71,6 +71,9 @@ function createNpmRootRunner(params: {
overrideNpmRoot?: string;
}): CommandRunner {
return async (argv) => {
if (argv[1] === "--version") {
return { stdout: "12.0.0\n", stderr: "", code: 0 };
}
if (argv[0] === "npm") {
return { stdout: `${params.defaultNpmRoot}\n`, stderr: "", code: 0 };
}
@@ -113,6 +116,18 @@ describe("update global helpers", () => {
).toBe("openclaw@next");
});
it("applies an unflagged npm policy to primary and retry argv", () => {
expect(
globalInstallArgs("npm", "openclaw@latest", null, null, null, "unflagged"),
).not.toContain("--allow-scripts=openclaw");
expect(
globalInstallFallbackArgs("npm", "openclaw@latest", null, null, null, "unflagged"),
).toEqual(expect.arrayContaining(["--omit=optional"]));
expect(
globalInstallFallbackArgs("npm", "openclaw@latest", null, null, null, "unflagged"),
).not.toContain("--allow-scripts=openclaw");
});
it("maps main and explicit package targets to install specs", () => {
expect(resolveGlobalInstallSpec({ packageName: "openclaw", tag: "main" })).toBe(
"github:openclaw/openclaw#main",
@@ -196,6 +211,55 @@ describe("update global helpers", () => {
});
});
it.each([
["11.12.0", "unflagged"],
["11.13.0", "unsupported-transition"],
["11.14.0", "unsupported-transition"],
["11.15.9", "unsupported-transition"],
["11.16.0", "allow-scripts"],
["12.0.0", "allow-scripts"],
] as const)("binds npm %s lifecycle policy to the owning executable", async (version, policy) => {
await withTestDir({ prefix: "openclaw-npm-owner-" }, async (prefix) => {
const globalRoot = path.join(prefix, "lib", "node_modules");
const packageRoot = path.join(globalRoot, "openclaw");
const owningNpm = path.join(prefix, "bin", "npm");
await Promise.all([
fs.mkdir(packageRoot, { recursive: true }),
fs.mkdir(path.dirname(owningNpm), { recursive: true }),
]);
await fs.writeFile(owningNpm, "", "utf8");
const calls: string[][] = [];
const runCommand: CommandRunner = async (argv) => {
calls.push(argv);
if (argv[0] === owningNpm && argv[1] === "root") {
return { stdout: `${globalRoot}\n`, stderr: "", code: 0 };
}
if (argv[0] === owningNpm && argv[1] === "--version") {
return { stdout: `${version}\n`, stderr: "", code: 0 };
}
throw new Error(`unexpected command: ${argv.join(" ")}`);
};
await expect(
resolveGlobalInstallTarget({
manager: "npm",
runCommand,
timeoutMs: 1000,
pkgRoot: packageRoot,
packageName: "openclaw",
}),
).resolves.toMatchObject({
command: owningNpm,
npmOwner: {
version,
lifecyclePolicy: policy,
},
});
expect(calls).toContainEqual([owningNpm, "--version"]);
expect(calls).not.toContainEqual(["npm", "--version"]);
});
});
it("defaults corepack download prompts off for global install env", async () => {
const defaultEnv = await createGlobalInstallEnv({});
expect(defaultEnv?.COREPACK_ENABLE_DOWNLOAD_PROMPT).toBe("0");
@@ -355,6 +419,7 @@ describe("update global helpers", () => {
command: "npm",
globalRoot: nvmRoot,
packageRoot: pkgRoot,
npmOwner: { version: "12.0.0", lifecyclePolicy: "allow-scripts" },
});
});
});
@@ -393,6 +458,7 @@ describe("update global helpers", () => {
command: "npm",
globalRoot: nvmRoot,
packageRoot: pkgRoot,
npmOwner: { version: "12.0.0", lifecyclePolicy: "allow-scripts" },
});
});
});
@@ -420,6 +486,7 @@ describe("update global helpers", () => {
command: "npm",
globalRoot: nvmRoot,
packageRoot: path.join(nvmRoot, "openclaw"),
npmOwner: { version: "12.0.0", lifecyclePolicy: "allow-scripts" },
});
});
});
@@ -446,6 +513,7 @@ describe("update global helpers", () => {
command: "npm",
globalRoot,
packageRoot: pkgRoot,
npmOwner: { version: null, lifecyclePolicy: null },
});
});
});
@@ -488,6 +556,9 @@ describe("update global helpers", () => {
await fs.mkdir(path.join(otherPnpmRoot, "openclaw"), { recursive: true });
const runCommand: CommandRunner = async (argv) => {
if (argv[1] === "--version") {
return { stdout: "12.0.0\n", stderr: "", code: 0 };
}
if (argv[0] === "npm" || argv[0] === customNpm) {
return { stdout: `${pathNpmRoot}\n`, stderr: "", code: 0 };
}
@@ -511,6 +582,7 @@ describe("update global helpers", () => {
globalRoot: managedNpmRoot,
packageRoot: pkgRoot,
directNodeModulesRoot: true,
npmOwner: { version: "12.0.0", lifecyclePolicy: "allow-scripts" },
});
await expect(
resolveGlobalInstallTarget({
@@ -526,6 +598,7 @@ describe("update global helpers", () => {
globalRoot: managedNpmRoot,
packageRoot: pkgRoot,
directNodeModulesRoot: true,
npmOwner: { version: "12.0.0", lifecyclePolicy: "allow-scripts" },
});
expect(
+89 -2
View File
@@ -54,6 +54,11 @@ export type ResolvedGlobalInstallTarget = ResolvedGlobalInstallCommand & {
globalRoot: string | null;
packageRoot: string | null;
directNodeModulesRoot?: boolean;
npmOwner?: {
version: string | null;
lifecyclePolicy: NpmLifecyclePolicy | null;
probeError?: string;
};
};
const PRIMARY_PACKAGE_NAME = "openclaw";
@@ -78,6 +83,73 @@ export type NpmGlobalPrefixLayout = {
binDir: string;
};
type NpmLifecyclePolicy = "unsupported-transition" | "unflagged" | "allow-scripts";
type SupportedNpmLifecyclePolicy = Exclude<NpmLifecyclePolicy, "unsupported-transition">;
type NpmLifecyclePolicyGate =
| { policy: SupportedNpmLifecyclePolicy | null; error: null }
| { policy: null; error: string };
/** Selects npm's lifecycle policy from the version of the owning executable. */
function resolveNpmLifecyclePolicy(version: string): NpmLifecyclePolicy | null {
const parsed = parseSemver(version);
if (!parsed) {
return null;
}
if (parsed.major !== 11) {
return parsed.major >= 12 ? "allow-scripts" : "unflagged";
}
if (parsed.minor <= 12) {
return "unflagged";
}
return parsed.minor >= 16 ? "allow-scripts" : "unsupported-transition";
}
/** Resolves the owning npm policy once, before any update mutation. */
export function resolveNpmLifecyclePolicyGate(
installTarget: ResolvedGlobalInstallTarget,
): NpmLifecyclePolicyGate {
if (installTarget.manager !== "npm") {
return { policy: null, error: null };
}
const version = installTarget.npmOwner?.version ?? "";
const policy = installTarget.npmOwner?.lifecyclePolicy ?? null;
if (policy === "unflagged" || policy === "allow-scripts") {
return { policy, error: null };
}
if (policy === "unsupported-transition") {
return {
policy: null,
error: `npm ${version} cannot safely approve OpenClaw lifecycle scripts. Upgrade the owning npm to 11.16 or newer before updating; no package changes were made.`,
};
}
return {
policy: null,
error: `Unable to determine the owning npm version before updating; no package changes were made.${installTarget.npmOwner?.probeError ? ` ${installTarget.npmOwner.probeError}` : ""}`,
};
}
async function resolveNpmOwner(params: {
command: string;
runCommand: CommandRunner;
timeoutMs: number;
}): Promise<NonNullable<ResolvedGlobalInstallTarget["npmOwner"]>> {
const result = await params
.runCommand([params.command, "--version"], { timeoutMs: params.timeoutMs })
.catch((error: unknown) => ({
stdout: "",
stderr: error instanceof Error ? error.message : String(error),
code: 1,
}));
const version = result.code === 0 ? readPackageManagerProbeValue(result.stdout) : "";
return {
version: version || null,
lifecyclePolicy: version ? resolveNpmLifecyclePolicy(version) : null,
...(result.code === 0 || !result.stderr ? {} : { probeError: result.stderr }),
};
}
function normalizePackageTarget(value: string): string {
return value.trim();
}
@@ -1089,6 +1161,14 @@ export async function resolveGlobalInstallTarget(params: {
? (pnpmIsolatedPackage?.packageRoot ??
(verifiedPnpmIsolatedGlobalRoot && params.pkgRoot ? params.pkgRoot : fallbackPackageRoot))
: fallbackPackageRoot;
const npmOwner =
command.manager === "npm"
? await resolveNpmOwner({
command: command.command,
runCommand: params.runCommand,
timeoutMs: params.timeoutMs,
})
: null;
// Preserve metadata-backed pnpm ownership when the invoking project link is gone.
// The update preflight must reject that orphan instead of falling through to npm.
return {
@@ -1102,6 +1182,7 @@ export async function resolveGlobalInstallTarget(params: {
: {}),
globalRoot: targetGlobalRoot,
packageRoot,
...(npmOwner ? { npmOwner } : {}),
...(honoredPackageRootGlobalRoot &&
targetGlobalRoot === honoredPackageRootGlobalRoot &&
honoredDirectNpmRoot
@@ -1215,6 +1296,7 @@ export function globalInstallArgs(
pkgRoot?: string | null,
installPrefix?: string | null,
installCwd?: string | null,
npmLifecyclePolicy: SupportedNpmLifecyclePolicy = "allow-scripts",
): string[] {
const resolved = normalizeGlobalInstallCommand(managerOrCommand, pkgRoot);
if (resolved.manager === "pnpm") {
@@ -1243,7 +1325,9 @@ export function globalInstallArgs(
resolved.command,
"i",
"-g",
resolveNpmInstallScriptsAllowFlag(spec, installCwd),
...(npmLifecyclePolicy === "allow-scripts"
? [resolveNpmInstallScriptsAllowFlag(spec, installCwd)]
: []),
...(installPrefix ? ["--prefix", installPrefix] : []),
spec,
...NPM_GLOBAL_INSTALL_QUIET_FLAGS,
@@ -1263,6 +1347,7 @@ export function globalInstallFallbackArgs(
pkgRoot?: string | null,
installPrefix?: string | null,
installCwd?: string | null,
npmLifecyclePolicy: SupportedNpmLifecyclePolicy = "allow-scripts",
): string[] | null {
const resolved = normalizeGlobalInstallCommand(managerOrCommand, pkgRoot);
if (resolved.manager !== "npm") {
@@ -1272,7 +1357,9 @@ export function globalInstallFallbackArgs(
resolved.command,
"i",
"-g",
resolveNpmInstallScriptsAllowFlag(spec, installCwd),
...(npmLifecyclePolicy === "allow-scripts"
? [resolveNpmInstallScriptsAllowFlag(spec, installCwd)]
: []),
...(installPrefix ? ["--prefix", installPrefix] : []),
spec,
"--omit=optional",
-3
View File
@@ -1,4 +1,3 @@
import path from "node:path";
import { resolveGatewayInstallEntrypoint } from "../daemon/gateway-entrypoint.js";
import { readPackageName, readPackageVersion } from "./package-json.js";
import { normalizePackageTagInput } from "./package-tag.js";
@@ -11,7 +10,6 @@ import {
} from "./update-channels.js";
import { resolveExtendedStablePackage } from "./update-check.js";
import {
cleanupGlobalRenameDirs,
createGlobalInstallEnv,
resolveGlobalInstallSpec,
resolveGlobalInstallTarget,
@@ -73,7 +71,6 @@ export async function runGlobalUpdate(params: {
pkgRoot,
packageName,
});
await cleanupGlobalRenameDirs({ globalRoot: path.dirname(pkgRoot), packageName });
const extendedStable =
channel === "extended-stable"
? await resolveExtendedStablePackage({ installKind: "package", timeoutMs, packageName })
+28
View File
@@ -772,6 +772,9 @@ describe("runGatewayUpdate", () => {
if (key === "pnpm root -g") {
return { stdout: "", stderr: "", code: 1 };
}
if (key === "npm --version") {
return { stdout: "12.0.0", stderr: "", code: 0 };
}
if (key === baseInstallKey) {
return (await params.onBaseInstall?.()) ?? { stdout: "ok", stderr: "", code: 0 };
}
@@ -2732,6 +2735,7 @@ describe("runGatewayUpdate", () => {
const createGlobalInstallHarness = (params: {
pkgRoot: string;
npmRootOutput?: string;
npmVersion?: string;
pnpmRootOutput?: string;
installCommand: InstallCommandExpectation;
gitRootMode?: "not-git" | "missing";
@@ -2757,6 +2761,9 @@ describe("runGatewayUpdate", () => {
}
return { stdout: "", stderr: "", code: 1 };
}
if (key === "npm --version") {
return { stdout: params.npmVersion ?? "12.0.0", stderr: "", code: 0 };
}
if (key === "pnpm root -g") {
if (params.pnpmRootOutput) {
return { stdout: params.pnpmRootOutput, stderr: "", code: 0 };
@@ -3006,6 +3013,27 @@ describe("runGatewayUpdate", () => {
expect(await pathExists(staleDir)).toBe(false);
});
it("refuses unsupported npm before global update cleanup mutates backups", async () => {
const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir);
const backupDir = path.join(nodeModules, ".openclaw-interrupted");
await fs.mkdir(backupDir, { recursive: true });
const { runCommand } = createGlobalInstallHarness({
pkgRoot,
npmRootOutput: nodeModules,
npmVersion: "11.15.9",
installCommand: npmGlobalInstallCommand("openclaw@latest"),
});
const result = await runWithCommand(runCommand, { cwd: pkgRoot });
expect(result).toMatchObject({
status: "error",
reason: "unexpected-error",
});
expect(result.steps.at(-1)?.name).toBe("npm lifecycle policy preflight");
await expect(fs.access(backupDir)).resolves.toBeUndefined();
});
it("retries global npm update with --omit=optional when initial install fails", async () => {
const nodeModules = path.join(tempDir, "node_modules");
const pkgRoot = path.join(nodeModules, "openclaw");