mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
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:
committed by
GitHub
parent
e446679131
commit
05bbd48c51
@@ -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.13–11.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
|
||||
|
||||
|
||||
@@ -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
@@ -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.13–11.15 before installing.
|
||||
|
||||
</Step>
|
||||
<Step title="Onboard and install the service">
|
||||
```bash
|
||||
|
||||
@@ -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.13–11.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
@@ -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.13–11.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.13–11.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>
|
||||
|
||||
@@ -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.13–11.15 first.
|
||||
|
||||
```bash
|
||||
npm install -g openclaw@latest
|
||||
npm install -g openclaw@latest --allow-scripts=openclaw
|
||||
openclaw onboard --install-daemon
|
||||
```
|
||||
|
||||
|
||||
+37
-16
@@ -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.13–11.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.13–11.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.13–11.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.13–11.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.13–11.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
|
||||
|
||||
|
||||
@@ -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.13–11.15 first.
|
||||
|
||||
```bash
|
||||
sudo npm install -g openclaw
|
||||
sudo npm install -g openclaw --allow-scripts=openclaw
|
||||
```
|
||||
|
||||
## Run onboarding
|
||||
|
||||
@@ -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.13–11.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.
|
||||
|
||||
|
||||
@@ -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.13–11.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
|
||||
|
||||
@@ -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.13–11.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
|
||||
|
||||
@@ -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.13–11.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,
|
||||
|
||||
@@ -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.13–11.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
|
||||
|
||||
|
||||
@@ -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.13–11.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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user