mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 00:11:19 +00:00
fix: isolate npm plugin installs per package (#87647)
* fix: isolate npm plugin installs per package * test: assert isolated npm plugin projects in upgrade survivor * test: assert plugin lifecycle npm project roots * test: resolve npm project deps in live assertions * fix: resolve codex bins from isolated npm projects * docs: document isolated npm plugin projects * ci: configure testbox workflow for crabbox * fix: stabilize npm project fingerprint * fix: keep fetch runtime import side-effect free * test: keep dynamic live model unit hermetic * ci: handle empty node toolcache roots * test: make nounset toolcache probe deterministic
This commit is contained in:
parent
2b587be44d
commit
ea682182d0
37 changed files with 981 additions and 295 deletions
|
|
@ -29,6 +29,11 @@ actions:
|
|||
- openclaw
|
||||
runnerVersion: latest
|
||||
ephemeral: true
|
||||
blacksmith:
|
||||
org: openclaw
|
||||
workflow: .github/workflows/ci-check-testbox.yml
|
||||
job: check
|
||||
ref: main
|
||||
aws:
|
||||
region: eu-west-1
|
||||
rootGB: 400
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ openclaw_find_toolcache_node() {
|
|||
done
|
||||
|
||||
local node_root candidate candidate_version
|
||||
for node_root in "${roots[@]}"; do
|
||||
for node_root in ${roots[@]+"${roots[@]}"}; do
|
||||
while IFS= read -r candidate; do
|
||||
candidate_version="$("$candidate" -p 'process.versions.node' 2>/dev/null || true)"
|
||||
if openclaw_node_version_matches "$candidate_version" "$requested_node"; then
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ is available, then fall back to `latest`.
|
|||
<Accordion title="Hook packs and npm specs">
|
||||
`plugins install` is also the install surface for hook packs that expose `openclaw.hooks` in `package.json`. Use `openclaw hooks` for filtered hook visibility and per-hook enablement, not package installation.
|
||||
|
||||
Npm specs are **registry-only** (package name + optional **exact version** or **dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency installs run project-local with `--ignore-scripts` for safety, even when your shell has global npm install settings. Managed plugin npm roots inherit OpenClaw's package-level npm `overrides`, so host security pins apply to hoisted plugin dependencies too.
|
||||
Npm specs are **registry-only** (package name + optional **exact version** or **dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency installs run in one managed npm project per plugin with `--ignore-scripts` for safety, even when your shell has global npm install settings. Managed plugin npm projects inherit OpenClaw's package-level npm `overrides`, so host security pins apply to hoisted plugin dependencies too.
|
||||
|
||||
Use `npm:<package>` when you want to make npm resolution explicit. Bare package specs also install directly from npm during the launch cutover unless they match an official plugin id.
|
||||
|
||||
|
|
@ -192,10 +192,10 @@ is available, then fall back to `latest`.
|
|||
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`. Native OpenClaw plugin archives must contain a valid `openclaw.plugin.json` at the extracted plugin root; archives that only contain `package.json` are rejected before OpenClaw writes install records.
|
||||
|
||||
Use `npm-pack:<path.tgz>` when the file is an npm-pack tarball and you want
|
||||
to test the same managed npm-root install path used by registry installs,
|
||||
including `package-lock.json` verification, hoisted dependency scanning, and
|
||||
npm install records. Plain archive paths still install as local archives
|
||||
under the plugin extensions root.
|
||||
to test the same per-plugin managed npm project path used by registry
|
||||
installs, including `package-lock.json` verification, hoisted dependency
|
||||
scanning, and npm install records. Plain archive paths still install as local
|
||||
archives under the plugin extensions root.
|
||||
|
||||
Claude marketplace installs are also supported.
|
||||
|
||||
|
|
@ -435,7 +435,7 @@ The local plugin registry is OpenClaw's persisted cold read model for installed
|
|||
|
||||
Use `plugins registry` to inspect whether the persisted registry is present, current, or stale. Use `--refresh` to rebuild it from the persisted plugin index, config policy, and manifest/package metadata. This is a repair path, not a runtime activation path.
|
||||
|
||||
`openclaw doctor --fix` also repairs registry-adjacent managed npm drift: if an orphaned or recovered `@openclaw/*` package under the managed plugin npm root shadows a bundled plugin, doctor removes that stale package and rebuilds the registry so startup validates against the bundled manifest. Doctor also relinks the host `openclaw` package into managed npm plugins that declare `peerDependencies.openclaw`, so package-local runtime imports such as `openclaw/plugin-sdk/*` resolve after updates or npm repairs.
|
||||
`openclaw doctor --fix` also repairs registry-adjacent managed npm drift: if an orphaned or recovered `@openclaw/*` package under a managed plugin npm project or the legacy flat managed npm root shadows a bundled plugin, doctor removes that stale package and rebuilds the registry so startup validates against the bundled manifest. Doctor also relinks the host `openclaw` package into managed npm plugins that declare `peerDependencies.openclaw`, so package-local runtime imports such as `openclaw/plugin-sdk/*` resolve after updates or npm repairs.
|
||||
|
||||
<Warning>
|
||||
`OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY=1` is a deprecated break-glass compatibility switch for registry read failures. Prefer `plugins registry --refresh` or `openclaw doctor --fix`; the env fallback is only for emergency startup recovery while the migration rolls out.
|
||||
|
|
|
|||
|
|
@ -30,9 +30,9 @@ Update and plugin tests protect these contracts:
|
|||
plugin state.
|
||||
- Plugin installs work from local directories, git repos, npm packages, and the
|
||||
ClawHub registry path.
|
||||
- Plugin npm dependencies are installed in the managed npm root, scanned before
|
||||
trust, and removed through npm during uninstall so hoisted dependencies do not
|
||||
linger.
|
||||
- Plugin npm dependencies are installed in one managed npm project per plugin,
|
||||
scanned before trust, and removed through npm during uninstall so hoisted
|
||||
dependencies do not linger.
|
||||
- Plugin update is stable when nothing changed: install records, resolved
|
||||
source, installed dependency layout, and enabled state stay intact.
|
||||
|
||||
|
|
@ -276,9 +276,9 @@ can fail for the right reason:
|
|||
- Registry/package source behavior: `test:docker:plugins` fixture or ClawHub
|
||||
fixture server.
|
||||
- Dependency layout or cleanup behavior: assert both runtime execution and the
|
||||
filesystem boundary. npm dependencies may be hoisted under the managed npm
|
||||
root, so tests should prove the root is scanned/cleaned instead of assuming a
|
||||
package-local `node_modules` tree.
|
||||
filesystem boundary. npm dependencies may be hoisted inside the plugin's
|
||||
managed npm project, so tests should prove that project is scanned/cleaned
|
||||
instead of assuming only the plugin package-local `node_modules` tree.
|
||||
|
||||
Keep new Docker fixtures hermetic by default. Use local fixture registries and
|
||||
fake packages unless the point of the test is live registry behavior.
|
||||
|
|
|
|||
|
|
@ -84,11 +84,12 @@ When debugging real providers/models (requires real creds):
|
|||
- Codex on-demand install smoke: `pnpm test:docker:codex-on-demand`
|
||||
- Installs the packaged OpenClaw tarball in Docker, runs OpenAI API-key
|
||||
onboarding, and verifies the Codex plugin plus `@openai/codex` dependency
|
||||
were downloaded into the managed npm root on demand.
|
||||
were downloaded into the managed npm project root on demand.
|
||||
- Live plugin tool dependency smoke: `pnpm test:docker:live-plugin-tool`
|
||||
- Packs a fixture plugin with a real `slugify` dependency, installs it through
|
||||
`npm-pack:`, verifies the dependency under the managed npm root, then asks a
|
||||
live OpenAI model to call the plugin tool and return the hidden slug.
|
||||
`npm-pack:`, verifies the dependency under the managed npm project root,
|
||||
then asks a live OpenAI model to call the plugin tool and return the hidden
|
||||
slug.
|
||||
- Crestodian rescue command smoke: `pnpm test:live:crestodian-rescue-channel`
|
||||
- Opt-in belt-and-suspenders check for the message-channel rescue command
|
||||
surface. It exercises `/crestodian status`, queues a persistent model
|
||||
|
|
|
|||
|
|
@ -34,34 +34,36 @@ OpenClaw owns only the plugin lifecycle:
|
|||
|
||||
OpenClaw uses stable per-source roots:
|
||||
|
||||
- npm packages install under `~/.openclaw/npm`
|
||||
- npm packages install into per-plugin projects under
|
||||
`~/.openclaw/npm/projects/<encoded-package>`
|
||||
- git packages clone under `~/.openclaw/git`
|
||||
- local/path/archive installs are copied or referenced without dependency repair
|
||||
|
||||
npm installs run in the npm root with:
|
||||
npm installs run in that per-plugin project root with:
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/npm
|
||||
cd ~/.openclaw/npm/projects/<encoded-package>
|
||||
npm install --omit=dev --omit=peer --legacy-peer-deps --ignore-scripts --no-audit --no-fund
|
||||
```
|
||||
|
||||
`openclaw plugins install npm-pack:<path.tgz>` uses that same managed npm root
|
||||
for a local npm-pack tarball. OpenClaw reads the tarball's npm metadata, adds it
|
||||
to the managed root as a copied `file:` dependency, runs the normal npm install,
|
||||
and then verifies the installed lockfile metadata before trusting the plugin.
|
||||
`openclaw plugins install npm-pack:<path.tgz>` uses that same per-plugin npm
|
||||
project root for a local npm-pack tarball. OpenClaw reads the tarball's npm
|
||||
metadata, adds it to the managed project as a copied `file:` dependency, runs
|
||||
the normal npm install, and then verifies the installed lockfile metadata before
|
||||
trusting the plugin.
|
||||
This is intended for package-acceptance and release-candidate proof where a
|
||||
local pack artifact should behave like the registry artifact it simulates.
|
||||
|
||||
npm may hoist transitive dependencies to `~/.openclaw/npm/node_modules` beside
|
||||
the plugin package. OpenClaw scans the managed npm root before trusting the
|
||||
install and uses npm to remove npm-managed packages during uninstall, so hoisted
|
||||
runtime dependencies stay inside the managed cleanup boundary.
|
||||
npm may hoist transitive dependencies to the per-plugin project's
|
||||
`node_modules` beside the plugin package. OpenClaw scans the managed project
|
||||
root before trusting the install and removes that project during uninstall, so
|
||||
hoisted runtime dependencies stay inside that plugin's cleanup boundary.
|
||||
|
||||
Published npm plugin packages can ship `npm-shrinkwrap.json`. npm uses that
|
||||
publishable lockfile during install, and OpenClaw's managed npm root supports it
|
||||
through the normal npm install path. OpenClaw-owned publishable plugin packages
|
||||
must include a package-local shrinkwrap generated from that plugin package's
|
||||
published dependency graph:
|
||||
publishable lockfile during install, and OpenClaw's managed npm project root
|
||||
supports it through the normal npm install path. OpenClaw-owned publishable
|
||||
plugin packages must include a package-local shrinkwrap generated from that
|
||||
plugin package's published dependency graph:
|
||||
|
||||
```bash
|
||||
pnpm deps:shrinkwrap:generate
|
||||
|
|
@ -87,11 +89,11 @@ instead of embedding every platform binary in the plugin tarball. The root
|
|||
|
||||
Plugins that import `openclaw/plugin-sdk/*` declare `openclaw` as a peer
|
||||
dependency. OpenClaw does not let npm install a separate registry copy of the
|
||||
host package into the managed root, because stale host packages can affect npm
|
||||
peer resolution during later plugin installs. Managed npm installs skip npm peer
|
||||
resolution/materialization for the shared root and OpenClaw reasserts
|
||||
plugin-local `node_modules/openclaw` links for installed packages that declare
|
||||
the host peer after install, update, or uninstall.
|
||||
host package into a managed project, because stale host packages can affect npm
|
||||
peer resolution inside that plugin. Managed npm installs skip npm peer
|
||||
resolution/materialization and OpenClaw reasserts plugin-local
|
||||
`node_modules/openclaw` links for installed packages that declare the host peer
|
||||
after install or update.
|
||||
|
||||
git installs clone or refresh the repository, then run:
|
||||
|
||||
|
|
@ -155,7 +157,7 @@ not a supported way to prepare bundled plugin dependencies.
|
|||
| -------------------------------- | ------------------------------------- | -------------------------------------------------------------------- |
|
||||
| `npm install -g openclaw` | 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/git/ClawHub plugin root | The plugin install/update flow |
|
||||
| `openclaw plugins install ...` | Managed npm project/git/ClawHub root | The plugin install/update flow |
|
||||
|
||||
## Legacy cleanup
|
||||
|
||||
|
|
@ -168,4 +170,7 @@ stage directories, and package-local pnpm stores. Packaged postinstall also
|
|||
removes those global symlinks before pruning the legacy target roots so upgrades
|
||||
do not leave dangling ESM package imports.
|
||||
|
||||
These paths are legacy debris only. New installs should not create them.
|
||||
Older npm installs also used a shared `~/.openclaw/npm/node_modules` root.
|
||||
Current install, update, uninstall, and doctor flows still recognize that legacy
|
||||
flat root only for recovery and cleanup. New npm installs should create
|
||||
per-plugin project roots instead.
|
||||
|
|
|
|||
|
|
@ -71,8 +71,8 @@ pnpm openclaw onboard --mode local
|
|||
Verify the installed package under the state directory:
|
||||
|
||||
```bash
|
||||
find "$OPENCLAW_STATE_DIR/npm/node_modules" -maxdepth 3 -name package.json -print
|
||||
grep -R '"@openclaw/codex"' "$OPENCLAW_STATE_DIR/npm/package-lock.json"
|
||||
find "$OPENCLAW_STATE_DIR/npm/projects" -path '*/node_modules/@openclaw/codex/package.json' -print
|
||||
grep -R '"@openclaw/codex"' "$OPENCLAW_STATE_DIR/npm/projects"/*/package-lock.json
|
||||
```
|
||||
|
||||
For live provider E2E, source the real API key from a trusted shell or CI secret
|
||||
|
|
|
|||
|
|
@ -534,7 +534,7 @@ openclaw plugins install <package-name>
|
|||
```
|
||||
|
||||
<Info>
|
||||
For npm-sourced installs, `openclaw plugins install` installs the package under `~/.openclaw/npm` with lifecycle scripts disabled. Keep plugin dependency trees pure JS/TS and avoid packages that require `postinstall` builds.
|
||||
For npm-sourced installs, `openclaw plugins install` installs the package into a per-plugin project under `~/.openclaw/npm/projects` with lifecycle scripts disabled. Keep plugin dependency trees pure JS/TS and avoid packages that require `postinstall` builds.
|
||||
</Info>
|
||||
|
||||
<Note>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ function startOptions(
|
|||
}
|
||||
|
||||
function managedCommandPath(root: string, platform: NodeJS.Platform): string {
|
||||
return path.join(root, "node_modules", ".bin", platform === "win32" ? "codex.cmd" : "codex");
|
||||
const pathApi = platform === "win32" ? path.win32 : path.posix;
|
||||
return pathApi.join(root, "node_modules", ".bin", platform === "win32" ? "codex.cmd" : "codex");
|
||||
}
|
||||
|
||||
describe("managed Codex app-server binary", () => {
|
||||
|
|
@ -96,6 +97,52 @@ describe("managed Codex app-server binary", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("finds Codex bins hoisted into an isolated npm project root", async () => {
|
||||
const projectRoot = path.join("/tmp", "state", "npm", "projects", "openclaw-codex-hash");
|
||||
const pluginRoot = path.join(projectRoot, "node_modules", "@openclaw", "codex");
|
||||
const installedCommand = managedCommandPath(projectRoot, "linux");
|
||||
const pathExists = vi.fn(async (filePath: string) => filePath === installedCommand);
|
||||
|
||||
await expect(
|
||||
resolveManagedCodexAppServerStartOptions(startOptions("managed"), {
|
||||
platform: "linux",
|
||||
pluginRoot,
|
||||
pathExists,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions("managed"),
|
||||
command: installedCommand,
|
||||
commandSource: "resolved-managed",
|
||||
});
|
||||
});
|
||||
|
||||
it("finds Windows Codex shims hoisted into an isolated npm project root", async () => {
|
||||
const projectRoot = path.win32.join(
|
||||
"C:\\",
|
||||
"Users",
|
||||
"test",
|
||||
".openclaw",
|
||||
"npm",
|
||||
"projects",
|
||||
"openclaw-codex-hash",
|
||||
);
|
||||
const pluginRoot = path.win32.join(projectRoot, "node_modules", "@openclaw", "codex");
|
||||
const installedCommand = managedCommandPath(projectRoot, "win32");
|
||||
const pathExists = vi.fn(async (filePath: string) => filePath === installedCommand);
|
||||
|
||||
await expect(
|
||||
resolveManagedCodexAppServerStartOptions(startOptions("managed"), {
|
||||
platform: "win32",
|
||||
pluginRoot,
|
||||
pathExists,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions("managed"),
|
||||
command: installedCommand,
|
||||
commandSource: "resolved-managed",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the resolved Codex package bin when no command shim exists", async () => {
|
||||
const installRoot = await mkdtemp(path.join(os.tmpdir(), "openclaw-codex-package-"));
|
||||
const pluginRoot = path.join(installRoot, "dist", "extensions", "codex");
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ function resolveManagedCodexAppServerCandidateRoots(
|
|||
platform: NodeJS.Platform,
|
||||
): string[] {
|
||||
const pathApi = pathForPlatform(platform);
|
||||
return [
|
||||
const directRoots = [
|
||||
pluginRoot,
|
||||
pathApi.dirname(pluginRoot),
|
||||
pathApi.dirname(pathApi.dirname(pluginRoot)),
|
||||
|
|
@ -98,6 +98,32 @@ function resolveManagedCodexAppServerCandidateRoots(
|
|||
? pathApi.dirname(pathApi.dirname(pathApi.dirname(pluginRoot)))
|
||||
: null,
|
||||
].filter((root): root is string => Boolean(root));
|
||||
return [
|
||||
...new Set([...directRoots, ...resolveNearestNodeModulesProjectRoots(directRoots, platform)]),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveNearestNodeModulesProjectRoots(
|
||||
roots: readonly string[],
|
||||
platform: NodeJS.Platform,
|
||||
): string[] {
|
||||
const pathApi = pathForPlatform(platform);
|
||||
const projectRoots: string[] = [];
|
||||
for (const root of roots) {
|
||||
let current = pathApi.resolve(root);
|
||||
while (true) {
|
||||
if (pathApi.basename(current) === "node_modules") {
|
||||
projectRoots.push(pathApi.dirname(current));
|
||||
break;
|
||||
}
|
||||
const parent = pathApi.dirname(current);
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
return projectRoots;
|
||||
}
|
||||
|
||||
function resolveManagedCodexPackageBinCandidates(
|
||||
|
|
|
|||
|
|
@ -210,15 +210,28 @@ function codexInstallPath() {
|
|||
return record.installPath.replace(/^~(?=$|\/)/u, process.env.HOME);
|
||||
}
|
||||
|
||||
function codexNpmProjectRoot() {
|
||||
const installPath = codexInstallPath();
|
||||
const packageRoot = "@openclaw/codex"
|
||||
.split("/")
|
||||
.reduce((current) => path.dirname(current), installPath);
|
||||
return path.basename(packageRoot) === "node_modules"
|
||||
? path.dirname(packageRoot)
|
||||
: managedNpmRoot();
|
||||
}
|
||||
|
||||
function findPackageJson(packageName) {
|
||||
const parts = packageName.split("/");
|
||||
const projectRoot = codexNpmProjectRoot();
|
||||
const candidates =
|
||||
packageName.startsWith("@") && parts.length === 2
|
||||
? [
|
||||
path.join(projectRoot, "node_modules", parts[0], parts[1], "package.json"),
|
||||
path.join(codexInstallPath(), "node_modules", parts[0], parts[1], "package.json"),
|
||||
path.join(managedNpmRoot(), "node_modules", parts[0], parts[1], "package.json"),
|
||||
]
|
||||
: [
|
||||
path.join(projectRoot, "node_modules", packageName, "package.json"),
|
||||
path.join(codexInstallPath(), "node_modules", packageName, "package.json"),
|
||||
path.join(managedNpmRoot(), "node_modules", packageName, "package.json"),
|
||||
];
|
||||
|
|
@ -256,6 +269,7 @@ function assertNpmDeps() {
|
|||
function resolveCodexBin() {
|
||||
const commandName = process.platform === "win32" ? "codex.cmd" : "codex";
|
||||
const candidates = [
|
||||
path.join(codexNpmProjectRoot(), "node_modules", ".bin", commandName),
|
||||
path.join(codexInstallPath(), "node_modules", ".bin", commandName),
|
||||
path.join(managedNpmRoot(), "node_modules", ".bin", commandName),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -42,6 +42,15 @@ function findPackageJson(packageName, roots) {
|
|||
return candidates.find((candidate) => fs.existsSync(candidate));
|
||||
}
|
||||
|
||||
function npmProjectRootForInstalledPackage(installPath, packageName) {
|
||||
const packageRoot = packageName
|
||||
.split("/")
|
||||
.reduce((current) => path.dirname(current), installPath);
|
||||
return path.basename(packageRoot) === "node_modules"
|
||||
? path.dirname(packageRoot)
|
||||
: path.join(stateDir(), "npm");
|
||||
}
|
||||
|
||||
const cfg = readJson(configPath());
|
||||
const inspect = readJson("/tmp/openclaw-codex-inspect.json");
|
||||
const records = installRecords();
|
||||
|
|
@ -72,7 +81,12 @@ if (codexPackage.name !== "@openclaw/codex") {
|
|||
throw new Error(`unexpected codex package name: ${codexPackage.name}`);
|
||||
}
|
||||
|
||||
const openAiCodexPackageJson = findPackageJson("@openai/codex", [installPath, npmRoot]);
|
||||
const npmProjectRoot = npmProjectRootForInstalledPackage(installPath, "@openclaw/codex");
|
||||
const openAiCodexPackageJson = findPackageJson("@openai/codex", [
|
||||
installPath,
|
||||
npmProjectRoot,
|
||||
npmRoot,
|
||||
]);
|
||||
if (!openAiCodexPackageJson) {
|
||||
throw new Error("missing @openai/codex dependency under managed npm root");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -313,7 +313,12 @@ function configure() {
|
|||
function findDependencyPackageJson(packageName) {
|
||||
const installPath = pluginInstallPath();
|
||||
const npmRoot = path.join(stateDir(), "npm");
|
||||
const pluginName = requireEnv("PLUGIN_NAME");
|
||||
const packageRoot = pluginName.split("/").reduce((current) => path.dirname(current), installPath);
|
||||
const projectRoot =
|
||||
path.basename(packageRoot) === "node_modules" ? path.dirname(packageRoot) : npmRoot;
|
||||
return [
|
||||
path.join(projectRoot, "node_modules", packageName, "package.json"),
|
||||
path.join(installPath, "node_modules", packageName, "package.json"),
|
||||
path.join(npmRoot, "node_modules", packageName, "package.json"),
|
||||
].find((candidate) => fs.existsSync(candidate));
|
||||
|
|
|
|||
|
|
@ -51,6 +51,37 @@ function assertVersion(pluginId, version) {
|
|||
);
|
||||
}
|
||||
|
||||
function assertNpmProjectRoot(pluginId, packageName) {
|
||||
const record = recordFor(pluginId);
|
||||
assert(record?.installPath, `install path missing for ${pluginId}`);
|
||||
const relative = path.relative(openclawPath("npm", "projects"), record.installPath);
|
||||
assert(
|
||||
!relative.startsWith("..") && !path.isAbsolute(relative),
|
||||
`install path outside npm projects: ${record.installPath}`,
|
||||
);
|
||||
const segments = relative.split(path.sep);
|
||||
const packageSegments = packageName.split("/");
|
||||
assert(
|
||||
segments.length === 2 + packageSegments.length,
|
||||
`unexpected npm project install path: ${record.installPath}`,
|
||||
);
|
||||
assert(Boolean(segments[0]), `missing npm project directory: ${record.installPath}`);
|
||||
assert(
|
||||
segments[1] === "node_modules",
|
||||
`missing project node_modules segment: ${record.installPath}`,
|
||||
);
|
||||
for (let index = 0; index < packageSegments.length; index++) {
|
||||
assert(
|
||||
segments[index + 2] === packageSegments[index],
|
||||
`package path mismatch: ${record.installPath}`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
!fs.existsSync(openclawPath("npm", "node_modules", ...packageSegments)),
|
||||
`legacy flat npm install path exists for ${packageName}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertEnabled(pluginId, expectedRaw) {
|
||||
const expected = expectedRaw === "true";
|
||||
const entry = config().plugins?.entries?.[pluginId];
|
||||
|
|
@ -82,6 +113,9 @@ switch (command) {
|
|||
case "assert-version":
|
||||
assertVersion(pluginId, arg);
|
||||
break;
|
||||
case "assert-npm-project-root":
|
||||
assertNpmProjectRoot(pluginId, arg);
|
||||
break;
|
||||
case "assert-enabled":
|
||||
assertEnabled(pluginId, arg);
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ start_npm_fixture_registry "$package_name" 1.0.0 /tmp/lifecycle-claw-1.0.0.tgz "
|
|||
|
||||
run_measured install-v1 node "$entry" plugins install "npm:$package_name@1.0.0"
|
||||
node "$probe" assert-version "$plugin_id" 1.0.0
|
||||
node "$probe" assert-npm-project-root "$plugin_id" "$package_name"
|
||||
|
||||
run_measured inspect-v1 bash -c 'node "$1" plugins inspect "$2" --runtime --json >/tmp/plugin-lifecycle-inspect-v1.json' bash "$entry" "$plugin_id"
|
||||
|
||||
|
|
@ -51,9 +52,11 @@ node "$probe" assert-enabled "$plugin_id" true
|
|||
|
||||
run_measured upgrade-v2 node "$entry" plugins update "$package_name@2.0.0"
|
||||
node "$probe" assert-version "$plugin_id" 2.0.0
|
||||
node "$probe" assert-npm-project-root "$plugin_id" "$package_name"
|
||||
|
||||
run_measured downgrade-v1 node "$entry" plugins update "$package_name@1.0.0"
|
||||
node "$probe" assert-version "$plugin_id" 1.0.0
|
||||
node "$probe" assert-npm-project-root "$plugin_id" "$package_name"
|
||||
|
||||
install_path="$(node "$probe" install-path "$plugin_id")"
|
||||
rm -rf "$install_path"
|
||||
|
|
|
|||
|
|
@ -51,6 +51,21 @@ function isPathInside(parent, child) {
|
|||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function isPathInsideManagedNpmProjectPackageRoot(params) {
|
||||
const relative = path.relative(path.join(params.stateDir, "npm", "projects"), params.installPath);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
return false;
|
||||
}
|
||||
const segments = relative.split(path.sep);
|
||||
const packageSegments = params.packageName.split("/");
|
||||
return (
|
||||
segments.length === 2 + packageSegments.length &&
|
||||
Boolean(segments[0]) &&
|
||||
segments[1] === "node_modules" &&
|
||||
packageSegments.every((segment, index) => segments[index + 2] === segment)
|
||||
);
|
||||
}
|
||||
|
||||
function write(file, contents) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, contents);
|
||||
|
|
@ -427,10 +442,10 @@ function assertExternalPluginInstall(records, pluginId, packageName) {
|
|||
`configured external ${pluginId} package name changed: ${packageJson.name}`,
|
||||
);
|
||||
if (installedFromNpm) {
|
||||
const npmRoot = path.join(requireEnv("OPENCLAW_STATE_DIR"), "npm", "node_modules");
|
||||
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
|
||||
assert(
|
||||
isPathInside(npmRoot, installPath),
|
||||
`configured external ${pluginId} npm install path outside managed npm root: ${installPath}`,
|
||||
isPathInsideManagedNpmProjectPackageRoot({ stateDir, installPath, packageName }),
|
||||
`configured external ${pluginId} npm install path outside managed npm project root: ${installPath}`,
|
||||
);
|
||||
assert(
|
||||
String(record.spec ?? record.resolvedSpec ?? "").startsWith(packageName),
|
||||
|
|
|
|||
|
|
@ -8,10 +8,6 @@ vi.mock("./agent-model-discovery.js", () => ({
|
|||
|
||||
import { appendPrioritizedDynamicLiveModels } from "./live-model-dynamic-candidates.js";
|
||||
|
||||
vi.mock("./agent-model-discovery.js", () => ({
|
||||
normalizeDiscoveredAgentModel: <T>(value: T) => value,
|
||||
}));
|
||||
|
||||
const REGISTRY = { find: () => undefined } as never;
|
||||
const DYNAMIC_PROVIDER = "dynamic-test-provider";
|
||||
type DynamicModelResolver = NonNullable<
|
||||
|
|
@ -20,6 +16,9 @@ type DynamicModelResolver = NonNullable<
|
|||
type DynamicModelPreparer = NonNullable<
|
||||
Parameters<typeof appendPrioritizedDynamicLiveModels>[0]["prepareDynamicModel"]
|
||||
>;
|
||||
type DynamicModelNormalizer = NonNullable<
|
||||
Parameters<typeof appendPrioritizedDynamicLiveModels>[0]["normalizeModel"]
|
||||
>;
|
||||
|
||||
function model(provider: string, id: string): Model {
|
||||
return {
|
||||
|
|
@ -44,7 +43,7 @@ describe("appendPrioritizedDynamicLiveModels", () => {
|
|||
: undefined,
|
||||
);
|
||||
const prepareDynamicModel: DynamicModelPreparer = vi.fn(async () => undefined);
|
||||
const normalizeModel = vi.fn((entry: Model) => entry);
|
||||
const normalizeModel: DynamicModelNormalizer = vi.fn((entry) => entry);
|
||||
const config = {
|
||||
models: {
|
||||
providers: {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import path from "node:path";
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listManagedPluginNpmRoots: vi.fn(),
|
||||
repairMissingConfiguredPluginInstalls: vi.fn(),
|
||||
relinkOpenClawPeerDependenciesInManagedNpmRoot: vi.fn(),
|
||||
runPluginPayloadSmokeCheck: vi.fn(),
|
||||
|
|
@ -16,6 +17,9 @@ vi.mock("../../plugins/plugin-peer-link.js", () => ({
|
|||
relinkOpenClawPeerDependenciesInManagedNpmRoot:
|
||||
mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot,
|
||||
}));
|
||||
vi.mock("../../plugins/npm-project-roots.js", () => ({
|
||||
listManagedPluginNpmRoots: mocks.listManagedPluginNpmRoots,
|
||||
}));
|
||||
vi.mock("./plugin-payload-validation.js", () => ({
|
||||
runPluginPayloadSmokeCheck: mocks.runPluginPayloadSmokeCheck,
|
||||
}));
|
||||
|
|
@ -33,6 +37,9 @@ describe("runPostCorePluginConvergence", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.listManagedPluginNpmRoots.mockImplementation((npmRoot: string) =>
|
||||
Promise.resolve([npmRoot]),
|
||||
);
|
||||
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
|
|
@ -148,28 +155,43 @@ describe("runPostCorePluginConvergence", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("repairs managed npm openclaw peer links before payload smoke checks", async () => {
|
||||
it("repairs managed npm openclaw peer links in every managed npm project before payload smoke checks", async () => {
|
||||
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
records: { codex: { source: "npm", installPath: "/p/codex" } },
|
||||
});
|
||||
mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot.mockResolvedValue({
|
||||
checked: 1,
|
||||
attempted: 1,
|
||||
repaired: 1,
|
||||
skipped: 0,
|
||||
});
|
||||
mocks.listManagedPluginNpmRoots.mockResolvedValue([
|
||||
"/tmp/openclaw-state/npm",
|
||||
"/tmp/openclaw-state/npm/projects/codex",
|
||||
]);
|
||||
mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot
|
||||
.mockResolvedValueOnce({
|
||||
checked: 0,
|
||||
attempted: 0,
|
||||
repaired: 0,
|
||||
skipped: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
checked: 1,
|
||||
attempted: 1,
|
||||
repaired: 1,
|
||||
skipped: 0,
|
||||
});
|
||||
|
||||
const result = await runPostCorePluginConvergence({
|
||||
cfg: { plugins: { entries: { codex: { enabled: true } } } } as unknown as OpenClawConfig,
|
||||
env: { OPENCLAW_STATE_DIR: "/tmp/openclaw-state" },
|
||||
});
|
||||
|
||||
expect(mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot).toHaveBeenCalledWith({
|
||||
expect(mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot).toHaveBeenNthCalledWith(1, {
|
||||
npmRoot: "/tmp/openclaw-state/npm",
|
||||
logger: {},
|
||||
});
|
||||
expect(mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot).toHaveBeenNthCalledWith(2, {
|
||||
npmRoot: "/tmp/openclaw-state/npm/projects/codex",
|
||||
logger: {},
|
||||
});
|
||||
expect(result.changes).toEqual([
|
||||
"Repaired OpenClaw host peer link(s) for 1 managed npm plugin package(s).",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
|||
import type { PluginInstallRecord } from "../../config/types.plugins.js";
|
||||
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js";
|
||||
import { resolveDefaultPluginNpmDir } from "../../plugins/install-paths.js";
|
||||
import { listManagedPluginNpmRoots } from "../../plugins/npm-project-roots.js";
|
||||
import { relinkOpenClawPeerDependenciesInManagedNpmRoot } from "../../plugins/plugin-peer-link.js";
|
||||
import { pruneStaleLocalBundledPluginInstallRecords } from "../../plugins/stale-local-bundled-plugin-install-records.js";
|
||||
import {
|
||||
|
|
@ -49,16 +50,20 @@ async function repairManagedNpmOpenClawPeerLinks(params: {
|
|||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<{ changes: string[]; warnings: PostCoreConvergenceWarning[] }> {
|
||||
try {
|
||||
const result = await relinkOpenClawPeerDependenciesInManagedNpmRoot({
|
||||
npmRoot: resolveDefaultPluginNpmDir(params.env),
|
||||
logger: {},
|
||||
});
|
||||
const npmRoots = await listManagedPluginNpmRoots(resolveDefaultPluginNpmDir(params.env));
|
||||
const results = await Promise.all(
|
||||
npmRoots.map((npmRoot) =>
|
||||
relinkOpenClawPeerDependenciesInManagedNpmRoot({
|
||||
npmRoot,
|
||||
logger: {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const repaired = results.reduce((total, result) => total + result.repaired, 0);
|
||||
return {
|
||||
changes:
|
||||
result.repaired > 0
|
||||
? [
|
||||
`Repaired OpenClaw host peer link(s) for ${result.repaired} managed npm plugin package(s).`,
|
||||
]
|
||||
repaired > 0
|
||||
? [`Repaired OpenClaw host peer link(s) for ${repaired} managed npm plugin package(s).`]
|
||||
: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import fs from "node:fs";
|
|||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginCandidate } from "../plugins/discovery.js";
|
||||
import { resolvePluginNpmProjectDir } from "../plugins/install-paths.js";
|
||||
import {
|
||||
readPersistedInstalledPluginIndex,
|
||||
writePersistedInstalledPluginIndex,
|
||||
|
|
@ -117,8 +118,12 @@ function createManagedNpmPlugin(params: {
|
|||
peerDependencies?: Record<string, string>;
|
||||
packageLock?: boolean;
|
||||
}) {
|
||||
const npmRoot = path.join(params.stateDir, "npm");
|
||||
const packageDir = path.join(npmRoot, "node_modules", params.packageName);
|
||||
const npmBaseDir = path.join(params.stateDir, "npm");
|
||||
const npmRoot = resolvePluginNpmProjectDir({
|
||||
npmDir: npmBaseDir,
|
||||
packageName: params.packageName,
|
||||
});
|
||||
const packageDir = path.join(npmRoot, "node_modules", ...params.packageName.split("/"));
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(npmRoot, "package.json"),
|
||||
|
|
@ -318,7 +323,7 @@ describe("maybeRepairPluginRegistryState", () => {
|
|||
const stateDir = makeTempDir();
|
||||
const bundledDir = path.join(stateDir, "bundled", "google-meet");
|
||||
fs.mkdirSync(bundledDir, { recursive: true });
|
||||
createManagedNpmPlugin({
|
||||
const managed = createManagedNpmPlugin({
|
||||
stateDir,
|
||||
id: "google-meet",
|
||||
packageName: "@openclaw/google-meet",
|
||||
|
|
@ -355,16 +360,14 @@ describe("maybeRepairPluginRegistryState", () => {
|
|||
"Managed npm plugin packages shadow bundled plugins",
|
||||
);
|
||||
expect(vi.mocked(note).mock.calls.join("\n")).toContain("@openclaw/google-meet@2026.5.2");
|
||||
expect(
|
||||
fs.existsSync(path.join(stateDir, "npm", "node_modules", "@openclaw", "google-meet")),
|
||||
).toBe(true);
|
||||
expect(fs.existsSync(managed.packageDir)).toBe(true);
|
||||
});
|
||||
|
||||
it("removes stale managed npm packages that shadow bundled plugins during repair", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const bundledDir = path.join(stateDir, "bundled", "google-meet");
|
||||
fs.mkdirSync(bundledDir, { recursive: true });
|
||||
createManagedNpmPlugin({
|
||||
const managed = createManagedNpmPlugin({
|
||||
stateDir,
|
||||
id: "google-meet",
|
||||
packageName: "@openclaw/google-meet",
|
||||
|
|
@ -397,11 +400,9 @@ describe("maybeRepairPluginRegistryState", () => {
|
|||
prompter: { shouldRepair: true },
|
||||
});
|
||||
|
||||
expect(fs.existsSync(managed.packageDir)).toBe(false);
|
||||
expect(
|
||||
fs.existsSync(path.join(stateDir, "npm", "node_modules", "@openclaw", "google-meet")),
|
||||
).toBe(false);
|
||||
expect(
|
||||
JSON.parse(fs.readFileSync(path.join(stateDir, "npm", "package.json"), "utf8")),
|
||||
JSON.parse(fs.readFileSync(path.join(managed.npmRoot, "package.json"), "utf8")),
|
||||
).not.toHaveProperty("dependencies");
|
||||
const persisted = await readRequiredPersistedInstalledPluginIndex(stateDir);
|
||||
expect(persisted.refreshReason).toBe("migration");
|
||||
|
|
@ -590,7 +591,7 @@ describe("maybeRepairPluginRegistryState", () => {
|
|||
const stateDir = makeTempDir();
|
||||
const bundledDir = path.join(stateDir, "bundled", "google-meet");
|
||||
fs.mkdirSync(bundledDir, { recursive: true });
|
||||
createManagedNpmPlugin({
|
||||
const managed = createManagedNpmPlugin({
|
||||
stateDir,
|
||||
id: "google-meet",
|
||||
packageName: "@openclaw/google-meet",
|
||||
|
|
@ -625,7 +626,7 @@ describe("maybeRepairPluginRegistryState", () => {
|
|||
});
|
||||
|
||||
const packageLock = JSON.parse(
|
||||
fs.readFileSync(path.join(stateDir, "npm", "package-lock.json"), "utf8"),
|
||||
fs.readFileSync(path.join(managed.npmRoot, "package-lock.json"), "utf8"),
|
||||
);
|
||||
expect(packageLock.packages[""].dependencies).toEqual({ "other-plugin": "1.0.0" });
|
||||
expect(packageLock.packages).not.toHaveProperty("node_modules/@openclaw/google-meet");
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
type InstalledPluginIndexRecordStoreOptions,
|
||||
} from "../plugins/installed-plugin-index-records.js";
|
||||
import { loadInstalledPluginIndex } from "../plugins/installed-plugin-index.js";
|
||||
import { listManagedPluginNpmRootsSync } from "../plugins/npm-project-roots.js";
|
||||
import {
|
||||
auditOpenClawPeerDependenciesInManagedNpmRoot,
|
||||
relinkOpenClawPeerDependenciesInManagedNpmRoot,
|
||||
|
|
@ -74,6 +75,10 @@ function resolveManagedPluginNpmRoot(params: PluginRegistryDoctorRepairParams):
|
|||
: resolveDefaultPluginNpmDir(params.env);
|
||||
}
|
||||
|
||||
function listManagedPluginNpmRoots(params: PluginRegistryDoctorRepairParams): string[] {
|
||||
return listManagedPluginNpmRootsSync(resolveManagedPluginNpmRoot(params));
|
||||
}
|
||||
|
||||
function deleteObjectKey(record: Record<string, unknown>, key: string): boolean {
|
||||
if (!Object.prototype.hasOwnProperty.call(record, key)) {
|
||||
return false;
|
||||
|
|
@ -104,33 +109,34 @@ function listStaleManagedNpmBundledPlugins(
|
|||
const bundledByPackage = new Map(
|
||||
currentBundled.map((plugin) => [plugin.packageName, plugin] as const),
|
||||
);
|
||||
const npmRoot = resolveManagedPluginNpmRoot(params);
|
||||
const npmPackageJsonPath = path.join(npmRoot, "package.json");
|
||||
const dependencies = readStringMap(readJsonObject(npmPackageJsonPath)?.dependencies);
|
||||
const stale: StaleManagedNpmBundledPlugin[] = [];
|
||||
|
||||
for (const packageName of Object.keys(dependencies).toSorted((left, right) =>
|
||||
left.localeCompare(right),
|
||||
)) {
|
||||
if (!packageName.startsWith("@openclaw/")) {
|
||||
continue;
|
||||
for (const npmRoot of listManagedPluginNpmRoots(params)) {
|
||||
const npmPackageJsonPath = path.join(npmRoot, "package.json");
|
||||
const dependencies = readStringMap(readJsonObject(npmPackageJsonPath)?.dependencies);
|
||||
for (const packageName of Object.keys(dependencies).toSorted((left, right) =>
|
||||
left.localeCompare(right),
|
||||
)) {
|
||||
if (!packageName.startsWith("@openclaw/")) {
|
||||
continue;
|
||||
}
|
||||
const bundled = bundledByPackage.get(packageName);
|
||||
if (!bundled) {
|
||||
continue;
|
||||
}
|
||||
const packageDir = path.join(npmRoot, "node_modules", ...packageName.split("/"));
|
||||
const pluginId = readPluginManifestId(packageDir);
|
||||
if (!pluginId || pluginId !== bundled.pluginId) {
|
||||
continue;
|
||||
}
|
||||
stale.push({
|
||||
pluginId,
|
||||
packageName,
|
||||
packageDir,
|
||||
npmRoot,
|
||||
...(readPackageVersion(packageDir) ? { version: readPackageVersion(packageDir) } : {}),
|
||||
});
|
||||
}
|
||||
const bundled = bundledByPackage.get(packageName);
|
||||
if (!bundled) {
|
||||
continue;
|
||||
}
|
||||
const packageDir = path.join(npmRoot, "node_modules", packageName);
|
||||
const pluginId = readPluginManifestId(packageDir);
|
||||
if (!pluginId || pluginId !== bundled.pluginId) {
|
||||
continue;
|
||||
}
|
||||
stale.push({
|
||||
pluginId,
|
||||
packageName,
|
||||
packageDir,
|
||||
npmRoot,
|
||||
...(readPackageVersion(packageDir) ? { version: readPackageVersion(packageDir) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return stale;
|
||||
|
|
@ -312,14 +318,17 @@ export async function maybeRepairStaleLocalBundledPluginInstallRecords(
|
|||
export async function maybeRepairManagedNpmOpenClawPeerLinks(
|
||||
params: PluginRegistryDoctorRepairParams,
|
||||
): Promise<boolean> {
|
||||
const npmRoot = resolveManagedPluginNpmRoot(params);
|
||||
const npmRoots = listManagedPluginNpmRoots(params);
|
||||
if (!params.prompter.shouldRepair) {
|
||||
const audit = await auditOpenClawPeerDependenciesInManagedNpmRoot({ npmRoot });
|
||||
if (audit.broken > 0) {
|
||||
const audits = await Promise.all(
|
||||
npmRoots.map((npmRoot) => auditOpenClawPeerDependenciesInManagedNpmRoot({ npmRoot })),
|
||||
);
|
||||
const issues = audits.flatMap((audit) => audit.issues);
|
||||
if (issues.length > 0) {
|
||||
note(
|
||||
[
|
||||
"Managed npm OpenClaw host peer links need repair:",
|
||||
...audit.issues.map((issue) => `- ${issue.packageName}: ${issue.reason}`),
|
||||
...issues.map((issue) => `- ${issue.packageName}: ${issue.reason}`),
|
||||
`Repair with ${formatCliCommand("openclaw doctor --fix")} to relink managed npm plugin packages.`,
|
||||
].join("\n"),
|
||||
"Plugin registry",
|
||||
|
|
@ -333,14 +342,19 @@ export async function maybeRepairManagedNpmOpenClawPeerLinks(
|
|||
info: (message) => messages.push({ level: "info", message }),
|
||||
warn: (message) => messages.push({ level: "warn", message }),
|
||||
};
|
||||
const result = await relinkOpenClawPeerDependenciesInManagedNpmRoot({
|
||||
npmRoot,
|
||||
logger,
|
||||
});
|
||||
const results = await Promise.all(
|
||||
npmRoots.map((npmRoot) =>
|
||||
relinkOpenClawPeerDependenciesInManagedNpmRoot({
|
||||
npmRoot,
|
||||
logger,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const repaired = results.reduce((total, result) => total + result.repaired, 0);
|
||||
|
||||
if (result.repaired > 0) {
|
||||
if (repaired > 0) {
|
||||
note(
|
||||
`Repaired OpenClaw host peer link(s) for ${result.repaired} managed npm plugin package(s).`,
|
||||
`Repaired OpenClaw host peer link(s) for ${repaired} managed npm plugin package(s).`,
|
||||
"Plugin registry",
|
||||
);
|
||||
}
|
||||
|
|
@ -354,7 +368,7 @@ export async function maybeRepairManagedNpmOpenClawPeerLinks(
|
|||
);
|
||||
}
|
||||
|
||||
return result.repaired > 0;
|
||||
return repaired > 0;
|
||||
}
|
||||
|
||||
async function loadInstallRecordsWithoutPluginIds(
|
||||
|
|
|
|||
|
|
@ -62,6 +62,16 @@ const mocks = vi.hoisted(() => ({
|
|||
),
|
||||
resolveDefaultPluginExtensionsDir: vi.fn(() => "/tmp/openclaw-plugins"),
|
||||
resolveDefaultPluginNpmDir: vi.fn(() => "/tmp/openclaw-npm"),
|
||||
resolvePluginNpmPackageDir: vi.fn(
|
||||
({ npmDir, packageName }: { npmDir?: string; packageName: string }) =>
|
||||
path.join(
|
||||
npmDir ?? "/tmp/openclaw-npm",
|
||||
"projects",
|
||||
packageName.replace(/[^a-zA-Z0-9._-]+/g, "-"),
|
||||
"node_modules",
|
||||
...packageName.split("/"),
|
||||
),
|
||||
),
|
||||
resolvePluginInstallDir: vi.fn(
|
||||
(pluginId: string, extensionsDir = "/tmp/openclaw-plugins") => `${extensionsDir}/${pluginId}`,
|
||||
),
|
||||
|
|
@ -114,6 +124,7 @@ vi.mock("../../../plugins/installed-plugin-index.js", async (importOriginal) =>
|
|||
vi.mock("../../../plugins/install-paths.js", () => ({
|
||||
resolveDefaultPluginExtensionsDir: mocks.resolveDefaultPluginExtensionsDir,
|
||||
resolveDefaultPluginNpmDir: mocks.resolveDefaultPluginNpmDir,
|
||||
resolvePluginNpmPackageDir: mocks.resolvePluginNpmPackageDir,
|
||||
resolvePluginInstallDir: mocks.resolvePluginInstallDir,
|
||||
validatePluginId: mocks.validatePluginId,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
import {
|
||||
resolveDefaultPluginNpmDir,
|
||||
resolveDefaultPluginExtensionsDir,
|
||||
resolvePluginNpmPackageDir,
|
||||
resolvePluginInstallDir,
|
||||
} from "../../../plugins/install-paths.js";
|
||||
import { installPluginFromNpmSpec } from "../../../plugins/install.js";
|
||||
|
|
@ -602,6 +603,16 @@ function pathsEqual(left: string, right: string): boolean {
|
|||
}
|
||||
|
||||
function resolveNpmPackageInstallPath(params: { packageName: string; npmRoot: string }): string {
|
||||
return resolvePluginNpmPackageDir({
|
||||
npmDir: params.npmRoot,
|
||||
packageName: params.packageName,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveLegacyNpmPackageInstallPath(params: {
|
||||
packageName: string;
|
||||
npmRoot: string;
|
||||
}): string {
|
||||
return path.join(params.npmRoot, "node_modules", ...params.packageName.split("/"));
|
||||
}
|
||||
|
||||
|
|
@ -698,11 +709,20 @@ function resolveSafeBrokenOfficialInstallRemovalPath(params: {
|
|||
if (!parsedNpmSpec?.name) {
|
||||
return null;
|
||||
}
|
||||
const expectedNpmPath = resolveNpmPackageInstallPath({
|
||||
packageName: parsedNpmSpec.name,
|
||||
npmRoot: resolveDefaultPluginNpmDir(params.env),
|
||||
});
|
||||
return pathsEqual(resolvedInstallPath, expectedNpmPath) ? resolvedInstallPath : null;
|
||||
const npmRoot = resolveDefaultPluginNpmDir(params.env);
|
||||
const expectedNpmPaths = [
|
||||
resolveNpmPackageInstallPath({
|
||||
packageName: parsedNpmSpec.name,
|
||||
npmRoot,
|
||||
}),
|
||||
resolveLegacyNpmPackageInstallPath({
|
||||
packageName: parsedNpmSpec.name,
|
||||
npmRoot,
|
||||
}),
|
||||
];
|
||||
return expectedNpmPaths.some((expectedPath) => pathsEqual(resolvedInstallPath, expectedPath))
|
||||
? resolvedInstallPath
|
||||
: null;
|
||||
}
|
||||
|
||||
function recordMatchesBundledPackage(
|
||||
|
|
@ -928,7 +948,14 @@ function resolveExistingCandidateNpmPackagePath(params: {
|
|||
packageName: npmName,
|
||||
npmRoot: params.npmDir,
|
||||
});
|
||||
return existsSync(packagePath) ? packagePath : null;
|
||||
if (existsSync(packagePath)) {
|
||||
return packagePath;
|
||||
}
|
||||
const legacyPackagePath = resolveLegacyNpmPackageInstallPath({
|
||||
packageName: npmName,
|
||||
npmRoot: params.npmDir,
|
||||
});
|
||||
return existsSync(legacyPackagePath) ? legacyPackagePath : null;
|
||||
}
|
||||
|
||||
function resolveExistingCandidateClawHubPackagePath(params: {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Agent as HttpAgent } from "node:http";
|
||||
import { createAmbientNodeProxyAgent } from "@openclaw/proxyline";
|
||||
import { createRequire } from "node:module";
|
||||
import { matchesNoProxy, resolveEnvHttpProxyAgentOptions } from "./proxy-env.js";
|
||||
import { resolveActiveManagedProxyTlsOptions } from "./proxy/managed-proxy-undici.js";
|
||||
|
||||
|
|
@ -7,10 +7,14 @@ export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE =
|
|||
"Unsupported proxy protocol. SOCKS and PAC proxy URLs are not supported; use an HTTP or HTTPS proxy URL.";
|
||||
|
||||
type NodeProxyProtocol = "http" | "https";
|
||||
type ProxylineAgentOptions = NonNullable<Parameters<typeof createAmbientNodeProxyAgent>[0]>;
|
||||
type ProxylineCreateAmbientNodeProxyAgent =
|
||||
typeof import("@openclaw/proxyline").createAmbientNodeProxyAgent;
|
||||
type ProxylineAgentOptions = NonNullable<Parameters<ProxylineCreateAmbientNodeProxyAgent>[0]>;
|
||||
type ProxylineEnvSnapshot = NonNullable<ProxylineAgentOptions["env"]>;
|
||||
type ProxylineTlsOptions = ProxylineAgentOptions["proxyTls"];
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export type CreateNodeProxyAgentOptions =
|
||||
| {
|
||||
mode: "env";
|
||||
|
|
@ -92,6 +96,11 @@ function fixedProxyEnv(proxyUrl: URL): ProxylineEnvSnapshot {
|
|||
};
|
||||
}
|
||||
|
||||
function loadCreateAmbientNodeProxyAgent(): ProxylineCreateAmbientNodeProxyAgent {
|
||||
return (require("@openclaw/proxyline") as typeof import("@openclaw/proxyline"))
|
||||
.createAmbientNodeProxyAgent;
|
||||
}
|
||||
|
||||
export function resolveEnvNodeProxyUrlForTarget(
|
||||
targetUrl: string | URL,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
|
|
@ -123,7 +132,7 @@ function createFixedNodeProxyAgent(
|
|||
proxyUrl instanceof URL
|
||||
? proxyUrl
|
||||
: proxyUrlWithDefaultScheme(proxyUrl, options.protocol ?? "https");
|
||||
const agent = createAmbientNodeProxyAgent({
|
||||
const agent = loadCreateAmbientNodeProxyAgent()({
|
||||
env: fixedProxyEnv(parsedProxyUrl),
|
||||
protocol: options.protocol ?? "https",
|
||||
...(options.proxyTls !== undefined ? { proxyTls: options.proxyTls } : {}),
|
||||
|
|
|
|||
|
|
@ -87,6 +87,40 @@ export function resolveDefaultPluginNpmDir(
|
|||
return path.join(resolveConfigDir(env, homedir), "npm");
|
||||
}
|
||||
|
||||
export function encodePluginNpmProjectDirName(packageName: string): string {
|
||||
const trimmed = packageName.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("invalid npm package name: missing");
|
||||
}
|
||||
return safePathSegmentHashed(trimmed);
|
||||
}
|
||||
|
||||
export function resolvePluginNpmProjectsDir(npmDir?: string): string {
|
||||
const npmBase = npmDir ? resolveUserPath(npmDir) : resolveDefaultPluginNpmDir();
|
||||
return path.join(npmBase, "projects");
|
||||
}
|
||||
|
||||
export function resolvePluginNpmProjectDir(params: {
|
||||
packageName: string;
|
||||
npmDir?: string;
|
||||
}): string {
|
||||
return path.join(
|
||||
resolvePluginNpmProjectsDir(params.npmDir),
|
||||
encodePluginNpmProjectDirName(params.packageName),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePluginNpmPackageDir(params: {
|
||||
packageName: string;
|
||||
npmDir?: string;
|
||||
}): string {
|
||||
return path.join(
|
||||
resolvePluginNpmProjectDir(params),
|
||||
"node_modules",
|
||||
...params.packageName.split("/"),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDefaultPluginGitDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homedir?: () => string,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resolvePluginNpmProjectDir } from "./install-paths.js";
|
||||
import { installPluginFromNpmSpec } from "./install.js";
|
||||
|
||||
type PackedVersion = {
|
||||
|
|
@ -47,6 +48,10 @@ async function makeTempDir(label: string): Promise<string> {
|
|||
return dir;
|
||||
}
|
||||
|
||||
function pluginNpmProjectRoot(npmRoot: string, packageName: string): string {
|
||||
return resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName });
|
||||
}
|
||||
|
||||
async function packPlugin(params: {
|
||||
dependencies?: Record<string, string>;
|
||||
packageName: string;
|
||||
|
|
@ -366,14 +371,16 @@ describe("installPluginFromNpmSpec e2e", () => {
|
|||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
const lock = JSON.parse(await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8")) as {
|
||||
const projectRoot = pluginNpmProjectRoot(npmRoot, packageName);
|
||||
const lock = JSON.parse(
|
||||
await fs.readFile(path.join(projectRoot, "package-lock.json"), "utf8"),
|
||||
) as {
|
||||
packages?: Record<string, unknown>;
|
||||
};
|
||||
expect(lock.packages?.["node_modules/openclaw"]).toBeUndefined();
|
||||
await expect(fs.lstat(path.join(npmRoot, "node_modules", "openclaw"))).rejects.toHaveProperty(
|
||||
"code",
|
||||
"ENOENT",
|
||||
);
|
||||
await expect(
|
||||
fs.lstat(path.join(projectRoot, "node_modules", "openclaw")),
|
||||
).rejects.toHaveProperty("code", "ENOENT");
|
||||
await expect(
|
||||
fs
|
||||
.lstat(path.join(result.targetDir, "node_modules", "openclaw"))
|
||||
|
|
@ -381,7 +388,7 @@ describe("installPluginFromNpmSpec e2e", () => {
|
|||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("keeps third-party peer dependencies across later managed npm installs", async () => {
|
||||
it("keeps third-party peer dependencies in the owning npm project across later installs", async () => {
|
||||
const rootDir = await makeTempDir("npm-plugin-third-party-peer-e2e");
|
||||
const npmRoot = path.join(rootDir, "managed-npm");
|
||||
const pluginWithRuntimePeer = `runtime-peer-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
|
|
@ -439,8 +446,9 @@ describe("installPluginFromNpmSpec e2e", () => {
|
|||
if (!first.ok) {
|
||||
throw new Error(first.error);
|
||||
}
|
||||
const firstProjectRoot = pluginNpmProjectRoot(npmRoot, pluginWithRuntimePeer);
|
||||
await expect(
|
||||
fs.lstat(path.join(npmRoot, "node_modules", runtimePeer, "package.json")),
|
||||
fs.lstat(path.join(firstProjectRoot, "node_modules", runtimePeer, "package.json")),
|
||||
).resolves.toBeTruthy();
|
||||
|
||||
const second = await installPluginFromNpmSpec({
|
||||
|
|
@ -454,7 +462,7 @@ describe("installPluginFromNpmSpec e2e", () => {
|
|||
}
|
||||
|
||||
await expect(
|
||||
fs.lstat(path.join(npmRoot, "node_modules", runtimePeer, "package.json")),
|
||||
fs.lstat(path.join(firstProjectRoot, "node_modules", runtimePeer, "package.json")),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
|
|
@ -518,14 +526,15 @@ describe("installPluginFromNpmSpec e2e", () => {
|
|||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
const projectRoot = pluginNpmProjectRoot(npmRoot, pluginWithOptionalDependency);
|
||||
await expect(
|
||||
fs.lstat(path.join(npmRoot, "node_modules", optionalDependency, "package.json")),
|
||||
fs.lstat(path.join(projectRoot, "node_modules", optionalDependency, "package.json")),
|
||||
).resolves.toBeTruthy();
|
||||
await expect(
|
||||
fs.lstat(path.join(npmRoot, "node_modules", runtimePeer, "package.json")),
|
||||
fs.lstat(path.join(projectRoot, "node_modules", runtimePeer, "package.json")),
|
||||
).resolves.toBeTruthy();
|
||||
const rootManifest = JSON.parse(
|
||||
await fs.readFile(path.join(npmRoot, "package.json"), "utf8"),
|
||||
await fs.readFile(path.join(projectRoot, "package.json"), "utf8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
openclaw?: { managedPeerDependencies?: string[] };
|
||||
|
|
@ -534,7 +543,7 @@ describe("installPluginFromNpmSpec e2e", () => {
|
|||
expect(rootManifest.openclaw?.managedPeerDependencies ?? []).toContain(runtimePeer);
|
||||
});
|
||||
|
||||
it("repairs pre-existing peer dependencies during later installs", async () => {
|
||||
it("leaves legacy flat-root peer dependencies alone during isolated later installs", async () => {
|
||||
const rootDir = await makeTempDir("npm-plugin-repaired-peer-scan-e2e");
|
||||
const npmRoot = path.join(rootDir, "managed-npm");
|
||||
const pluginWithRuntimePeer = `existing-peer-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
|
|
@ -625,24 +634,25 @@ describe("installPluginFromNpmSpec e2e", () => {
|
|||
throw new Error(later.error);
|
||||
}
|
||||
|
||||
const laterProjectRoot = pluginNpmProjectRoot(npmRoot, laterPlugin);
|
||||
await expect(
|
||||
fs.lstat(path.join(npmRoot, "node_modules", laterPlugin, "package.json")),
|
||||
fs.lstat(path.join(laterProjectRoot, "node_modules", laterPlugin, "package.json")),
|
||||
).resolves.toBeTruthy();
|
||||
await expect(
|
||||
fs.lstat(path.join(npmRoot, "node_modules", runtimePeer, "package.json")),
|
||||
).resolves.toBeTruthy();
|
||||
).rejects.toHaveProperty("code", "ENOENT");
|
||||
const rootManifest = JSON.parse(
|
||||
await fs.readFile(path.join(npmRoot, "package.json"), "utf8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
openclaw?: { managedPeerDependencies?: string[] };
|
||||
};
|
||||
expect(rootManifest.dependencies?.[laterPlugin]).toBe("1.0.0");
|
||||
expect(rootManifest.dependencies?.[runtimePeer]).toBe("1.0.0");
|
||||
expect(rootManifest.openclaw?.managedPeerDependencies ?? []).toContain(runtimePeer);
|
||||
expect(rootManifest.dependencies?.[laterPlugin]).toBeUndefined();
|
||||
expect(rootManifest.dependencies?.[runtimePeer]).toBeUndefined();
|
||||
expect(rootManifest.openclaw?.managedPeerDependencies ?? []).not.toContain(runtimePeer);
|
||||
});
|
||||
|
||||
it("bounds peer dependency discovery across repeated nested package realpaths", async () => {
|
||||
it("ignores legacy flat-root package cycles during isolated installs", async () => {
|
||||
const rootDir = await makeTempDir("npm-plugin-peer-cycle-e2e");
|
||||
const npmRoot = path.join(rootDir, "managed-npm");
|
||||
const existingPlugin = `existing-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
|
|
@ -716,7 +726,14 @@ describe("installPluginFromNpmSpec e2e", () => {
|
|||
|
||||
expect(later.ok).toBe(true);
|
||||
await expect(
|
||||
fs.lstat(path.join(npmRoot, "node_modules", laterPlugin, "package.json")),
|
||||
fs.lstat(
|
||||
path.join(
|
||||
pluginNpmProjectRoot(npmRoot, laterPlugin),
|
||||
"node_modules",
|
||||
laterPlugin,
|
||||
"package.json",
|
||||
),
|
||||
),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
|
|
@ -765,8 +782,9 @@ describe("installPluginFromNpmSpec e2e", () => {
|
|||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
const projectRoot = pluginNpmProjectRoot(npmRoot, blockedPlugin);
|
||||
const rootManifest = JSON.parse(
|
||||
await fs.readFile(path.join(npmRoot, "package.json"), "utf8"),
|
||||
await fs.readFile(path.join(projectRoot, "package.json"), "utf8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
openclaw?: { managedPeerDependencies?: string[] };
|
||||
|
|
@ -775,14 +793,14 @@ describe("installPluginFromNpmSpec e2e", () => {
|
|||
expect(rootManifest.dependencies?.[runtimePeer]).toBeUndefined();
|
||||
expect(rootManifest.openclaw?.managedPeerDependencies ?? []).not.toContain(runtimePeer);
|
||||
await expect(
|
||||
fs.lstat(path.join(npmRoot, "node_modules", blockedPlugin, "package.json")),
|
||||
fs.lstat(path.join(projectRoot, "node_modules", blockedPlugin, "package.json")),
|
||||
).rejects.toHaveProperty("code", "ENOENT");
|
||||
await expect(
|
||||
fs.lstat(path.join(npmRoot, "node_modules", runtimePeer, "package.json")),
|
||||
fs.lstat(path.join(projectRoot, "node_modules", runtimePeer, "package.json")),
|
||||
).rejects.toHaveProperty("code", "ENOENT");
|
||||
});
|
||||
|
||||
it("falls back to the legacy install path when npm cannot plan third-party peers", async () => {
|
||||
it("falls back to the legacy npm peer mode inside the plugin project when npm cannot plan third-party peers", async () => {
|
||||
const rootDir = await makeTempDir("npm-plugin-peer-plan-fallback-e2e");
|
||||
const npmRoot = path.join(rootDir, "managed-npm");
|
||||
const blockedPlugin = `missing-peer-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
|
|
@ -814,8 +832,9 @@ describe("installPluginFromNpmSpec e2e", () => {
|
|||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const projectRoot = pluginNpmProjectRoot(npmRoot, blockedPlugin);
|
||||
const rootManifest = JSON.parse(
|
||||
await fs.readFile(path.join(npmRoot, "package.json"), "utf8"),
|
||||
await fs.readFile(path.join(projectRoot, "package.json"), "utf8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
openclaw?: { managedPeerDependencies?: string[] };
|
||||
|
|
@ -824,7 +843,7 @@ describe("installPluginFromNpmSpec e2e", () => {
|
|||
expect(rootManifest.dependencies?.[missingPeer]).toBeUndefined();
|
||||
expect(rootManifest.openclaw?.managedPeerDependencies ?? []).not.toContain(missingPeer);
|
||||
await expect(
|
||||
fs.lstat(path.join(npmRoot, "node_modules", blockedPlugin, "package.json")),
|
||||
fs.lstat(path.join(projectRoot, "node_modules", blockedPlugin, "package.json")),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
|
|
@ -881,9 +900,10 @@ describe("installPluginFromNpmSpec e2e", () => {
|
|||
process.env.NPM_CONFIG_REGISTRY = registry;
|
||||
process.env.npm_config_registry = registry;
|
||||
|
||||
await fs.mkdir(npmRoot, { recursive: true });
|
||||
const blockedProjectRoot = pluginNpmProjectRoot(npmRoot, blockedPlugin);
|
||||
await fs.mkdir(blockedProjectRoot, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(npmRoot, "package.json"),
|
||||
path.join(blockedProjectRoot, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
private: true,
|
||||
|
|
@ -906,7 +926,7 @@ describe("installPluginFromNpmSpec e2e", () => {
|
|||
"--no-audit",
|
||||
"--no-fund",
|
||||
],
|
||||
{ cwd: npmRoot },
|
||||
{ cwd: blockedProjectRoot },
|
||||
);
|
||||
|
||||
const result = await installPluginFromNpmSpec({
|
||||
|
|
@ -918,7 +938,7 @@ describe("installPluginFromNpmSpec e2e", () => {
|
|||
|
||||
expect(result.ok).toBe(false);
|
||||
const rootManifest = JSON.parse(
|
||||
await fs.readFile(path.join(npmRoot, "package.json"), "utf8"),
|
||||
await fs.readFile(path.join(blockedProjectRoot, "package.json"), "utf8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
openclaw?: { managedPeerDependencies?: string[] };
|
||||
|
|
@ -931,17 +951,19 @@ describe("installPluginFromNpmSpec e2e", () => {
|
|||
);
|
||||
expect(rootManifest.openclaw?.managedPeerDependencies ?? []).not.toContain(runtimePeer);
|
||||
await expect(
|
||||
fs.lstat(path.join(npmRoot, "node_modules", existingRootDependency, "package.json")),
|
||||
fs.lstat(
|
||||
path.join(blockedProjectRoot, "node_modules", existingRootDependency, "package.json"),
|
||||
),
|
||||
).resolves.toBeTruthy();
|
||||
await expect(
|
||||
fs.lstat(path.join(npmRoot, "node_modules", blockedPlugin, "package.json")),
|
||||
fs.lstat(path.join(blockedProjectRoot, "node_modules", blockedPlugin, "package.json")),
|
||||
).rejects.toHaveProperty("code", "ENOENT");
|
||||
await expect(
|
||||
fs.lstat(path.join(npmRoot, "node_modules", runtimePeer, "package.json")),
|
||||
fs.lstat(path.join(blockedProjectRoot, "node_modules", runtimePeer, "package.json")),
|
||||
).rejects.toHaveProperty("code", "ENOENT");
|
||||
});
|
||||
|
||||
it("scrubs host peers when installing beside an existing host-peer plugin", async () => {
|
||||
it("scrubs host peers inside each isolated npm project", async () => {
|
||||
const rootDir = await makeTempDir("npm-plugin-sibling-peer-e2e");
|
||||
const npmRoot = path.join(rootDir, "managed-npm");
|
||||
const codexName = `codex-peer-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
|
|
@ -991,61 +1013,52 @@ describe("installPluginFromNpmSpec e2e", () => {
|
|||
process.env.NPM_CONFIG_REGISTRY = registry;
|
||||
process.env.npm_config_registry = registry;
|
||||
|
||||
await fs.mkdir(npmRoot, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(npmRoot, "package.json"),
|
||||
`${JSON.stringify({ private: true, dependencies: { [codexName]: "1.0.0" } }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await execFileAsync(
|
||||
"npm",
|
||||
["install", "--omit=peer", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error"],
|
||||
{
|
||||
cwd: npmRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
NPM_CONFIG_REGISTRY: registry,
|
||||
NPM_CONFIG_LEGACY_PEER_DEPS: "false",
|
||||
NPM_CONFIG_STRICT_PEER_DEPS: "false",
|
||||
npm_config_registry: registry,
|
||||
npm_config_legacy_peer_deps: "false",
|
||||
npm_config_strict_peer_deps: "false",
|
||||
},
|
||||
timeout: 120_000,
|
||||
},
|
||||
);
|
||||
const first = await installPluginFromNpmSpec({
|
||||
spec: `${codexName}@1.0.0`,
|
||||
npmDir: npmRoot,
|
||||
logger: { info: () => {}, warn: () => {} },
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
if (!first.ok) {
|
||||
throw new Error(first.error);
|
||||
}
|
||||
|
||||
const result = await installPluginFromNpmSpec({
|
||||
const second = await installPluginFromNpmSpec({
|
||||
spec: `${opikName}@1.0.0`,
|
||||
npmDir: npmRoot,
|
||||
logger: { info: () => {}, warn: () => {} },
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error);
|
||||
if (!second.ok) {
|
||||
throw new Error(second.error);
|
||||
}
|
||||
|
||||
const lock = JSON.parse(await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8")) as {
|
||||
packages?: Record<string, unknown>;
|
||||
};
|
||||
expect(lock.packages?.["node_modules/openclaw"]).toBeUndefined();
|
||||
await expect(fs.lstat(path.join(npmRoot, "node_modules", "openclaw"))).rejects.toHaveProperty(
|
||||
"code",
|
||||
"ENOENT",
|
||||
);
|
||||
const codexProjectRoot = pluginNpmProjectRoot(npmRoot, codexName);
|
||||
const opikProjectRoot = pluginNpmProjectRoot(npmRoot, opikName);
|
||||
for (const projectRoot of [codexProjectRoot, opikProjectRoot]) {
|
||||
const lock = JSON.parse(
|
||||
await fs.readFile(path.join(projectRoot, "package-lock.json"), "utf8"),
|
||||
) as {
|
||||
packages?: Record<string, unknown>;
|
||||
};
|
||||
expect(lock.packages?.["node_modules/openclaw"]).toBeUndefined();
|
||||
await expect(
|
||||
fs.lstat(path.join(projectRoot, "node_modules", "openclaw")),
|
||||
).rejects.toHaveProperty("code", "ENOENT");
|
||||
}
|
||||
await expect(
|
||||
fs
|
||||
.lstat(path.join(npmRoot, "node_modules", codexName, "node_modules", "openclaw"))
|
||||
.lstat(path.join(first.targetDir, "node_modules", "openclaw"))
|
||||
.then((stat) => stat.isSymbolicLink()),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
fs
|
||||
.lstat(path.join(npmRoot, "node_modules", opikName, "node_modules", "openclaw"))
|
||||
.lstat(path.join(second.targetDir, "node_modules", "openclaw"))
|
||||
.then((stat) => stat.isSymbolicLink()),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("relinks managed npm sibling openclaw peers after later plugin installs", async () => {
|
||||
it("keeps an earlier isolated openclaw peer link after later plugin installs", async () => {
|
||||
const rootDir = await makeTempDir("npm-plugin-peer-e2e");
|
||||
const npmRoot = path.join(rootDir, "managed-npm");
|
||||
const peerPackageName = `peer-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
|
|
@ -1097,11 +1110,16 @@ describe("installPluginFromNpmSpec e2e", () => {
|
|||
}
|
||||
|
||||
await expect(fs.lstat(peerLink).then((stat) => stat.isSymbolicLink())).resolves.toBe(true);
|
||||
const manifest = JSON.parse(await fs.readFile(path.join(npmRoot, "package.json"), "utf8")) as {
|
||||
const peerProjectRoot = pluginNpmProjectRoot(npmRoot, peerPackageName);
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(path.join(peerProjectRoot, "package.json"), "utf8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
expect(manifest.dependencies?.openclaw).toBeUndefined();
|
||||
const lock = JSON.parse(await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8")) as {
|
||||
const lock = JSON.parse(
|
||||
await fs.readFile(path.join(peerProjectRoot, "package-lock.json"), "utf8"),
|
||||
) as {
|
||||
packages?: Record<string, unknown>;
|
||||
};
|
||||
expect(lock.packages?.["node_modules/openclaw"]).toBeUndefined();
|
||||
|
|
@ -1138,7 +1156,10 @@ describe("installPluginFromNpmSpec e2e", () => {
|
|||
expect(result.ok).toBe(true);
|
||||
expect(result.npmResolution?.version).toBe("1.0.0");
|
||||
|
||||
const manifest = JSON.parse(await fs.readFile(path.join(npmRoot, "package.json"), "utf8")) as {
|
||||
const projectRoot = pluginNpmProjectRoot(npmRoot, packageName);
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(path.join(projectRoot, "package.json"), "utf8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
expect(manifest.dependencies?.[packageName]).toBe("1.0.0");
|
||||
|
|
@ -1148,7 +1169,9 @@ describe("installPluginFromNpmSpec e2e", () => {
|
|||
) as { version?: string };
|
||||
expect(installedManifest.version).toBe("1.0.0");
|
||||
|
||||
const lock = JSON.parse(await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8")) as {
|
||||
const lock = JSON.parse(
|
||||
await fs.readFile(path.join(projectRoot, "package-lock.json"), "utf8"),
|
||||
) as {
|
||||
packages?: Record<string, { integrity?: string; version?: string }>;
|
||||
};
|
||||
const installedLockEntry = lock.packages?.[`node_modules/${packageName}`];
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
expectIntegrityDriftRejected,
|
||||
mockNpmViewMetadataResult,
|
||||
} from "../test-utils/npm-spec-install-test-helpers.js";
|
||||
import { resolvePluginNpmProjectDir } from "./install-paths.js";
|
||||
import { createSuiteTempRootTracker } from "./test-helpers/fs-fixtures.js";
|
||||
|
||||
const runCommandWithTimeoutMock = vi.fn();
|
||||
|
|
@ -102,6 +103,31 @@ function expectNpmInstallIntoRoot(params: { calls: unknown[][]; npmRoot: string
|
|||
]);
|
||||
}
|
||||
|
||||
function expectNpmInstallIntoProject(params: {
|
||||
calls: unknown[][];
|
||||
npmRoot: string;
|
||||
packageName: string;
|
||||
}) {
|
||||
expectNpmInstallIntoRoot({
|
||||
calls: params.calls,
|
||||
npmRoot: resolvePluginNpmProjectDir({
|
||||
npmDir: params.npmRoot,
|
||||
packageName: params.packageName,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function resolveTestPluginPackageDir(npmRoot: string, packageName: string): string {
|
||||
return path.join(
|
||||
resolvePluginNpmProjectDir({
|
||||
npmDir: npmRoot,
|
||||
packageName,
|
||||
}),
|
||||
"node_modules",
|
||||
...packageName.split("/"),
|
||||
);
|
||||
}
|
||||
|
||||
function writeInstalledNpmPlugin(params: {
|
||||
npmRoot: string;
|
||||
packageName: string;
|
||||
|
|
@ -394,6 +420,7 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) {
|
|||
}
|
||||
writeInstalledNpmPlugin({
|
||||
...pkg,
|
||||
npmRoot,
|
||||
version: pkg.installedVersion ?? pkg.version,
|
||||
});
|
||||
if (pkg.materializesRootOpenClaw) {
|
||||
|
|
@ -431,10 +458,13 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) {
|
|||
if (!pkg) {
|
||||
throw new Error(`unexpected npm uninstall package: ${packageName ?? ""}`);
|
||||
}
|
||||
fs.rmSync(path.join(pkg.npmRoot, "node_modules", pkg.packageName), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
fs.rmSync(
|
||||
path.join(options?.cwd ?? pkg.npmRoot, "node_modules", ...pkg.packageName.split("/")),
|
||||
{
|
||||
recursive: true,
|
||||
force: true,
|
||||
},
|
||||
);
|
||||
return successfulSpawn();
|
||||
}
|
||||
throw new Error(`unexpected command: ${(argv as string[]).join(" ")}`);
|
||||
|
|
@ -509,22 +539,27 @@ describe("installPluginFromNpmSpec", () => {
|
|||
return;
|
||||
}
|
||||
expect(result.pluginId).toBe("pack-demo");
|
||||
expect(result.targetDir).toBe(path.join(npmRoot, "node_modules", "@openclaw/pack-demo"));
|
||||
const npmProjectRoot = resolvePluginNpmProjectDir({
|
||||
npmDir: npmRoot,
|
||||
packageName: "@openclaw/pack-demo",
|
||||
});
|
||||
expect(result.targetDir).toBe(resolveTestPluginPackageDir(npmRoot, "@openclaw/pack-demo"));
|
||||
expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/pack-demo@1.2.3");
|
||||
expect(result.npmResolution?.integrity).toBe("sha512-pack-demo");
|
||||
expect(result.npmTarballName).toBe("openclaw-pack-demo-1.2.3.tgz");
|
||||
expectNpmInstallIntoRoot({
|
||||
expectNpmInstallIntoProject({
|
||||
calls: runCommandWithTimeoutMock.mock.calls,
|
||||
npmRoot,
|
||||
packageName: "@openclaw/pack-demo",
|
||||
});
|
||||
const managedManifest = JSON.parse(
|
||||
await fs.promises.readFile(path.join(npmRoot, "package.json"), "utf8"),
|
||||
await fs.promises.readFile(path.join(npmProjectRoot, "package.json"), "utf8"),
|
||||
) as { dependencies?: Record<string, string> };
|
||||
const dependencySpec = managedManifest.dependencies?.["@openclaw/pack-demo"];
|
||||
expect(dependencySpec).toMatch(/^file:\.\/_openclaw-pack-archives\/.+\.tgz$/);
|
||||
expect(dependencySpec).not.toContain(archivePath);
|
||||
const stagedArchivePath = dependencySpec
|
||||
? resolveManagedFileDependency(npmRoot, dependencySpec)
|
||||
? resolveManagedFileDependency(npmProjectRoot, dependencySpec)
|
||||
: null;
|
||||
if (stagedArchivePath === null) {
|
||||
throw new Error("expected staged archive path");
|
||||
|
|
@ -594,15 +629,16 @@ describe("installPluginFromNpmSpec", () => {
|
|||
return;
|
||||
}
|
||||
expect(result.pluginId).toBe("voice-call");
|
||||
expect(result.targetDir).toBe(path.join(npmRoot, "node_modules", "@openclaw/voice-call"));
|
||||
expect(result.targetDir).toBe(resolveTestPluginPackageDir(npmRoot, "@openclaw/voice-call"));
|
||||
expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.1");
|
||||
expect(result.npmResolution?.integrity).toBe("sha512-plugin-test");
|
||||
expect(
|
||||
fs.existsSync(path.join(result.targetDir, "node_modules", "is-number", "package.json")),
|
||||
).toBe(true);
|
||||
expectNpmInstallIntoRoot({
|
||||
expectNpmInstallIntoProject({
|
||||
calls: runCommandWithTimeoutMock.mock.calls,
|
||||
npmRoot,
|
||||
packageName: "@openclaw/voice-call",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -624,8 +660,12 @@ describe("installPluginFromNpmSpec", () => {
|
|||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const npmProjectRoot = resolvePluginNpmProjectDir({
|
||||
npmDir: npmRoot,
|
||||
packageName: "mutable-plugin",
|
||||
});
|
||||
const manifest = JSON.parse(
|
||||
await fs.promises.readFile(path.join(npmRoot, "package.json"), "utf8"),
|
||||
await fs.promises.readFile(path.join(npmProjectRoot, "package.json"), "utf8"),
|
||||
) as { dependencies?: Record<string, string> };
|
||||
expect(manifest.dependencies?.["mutable-plugin"]).toBe("1.2.3");
|
||||
});
|
||||
|
|
@ -657,7 +697,7 @@ describe("installPluginFromNpmSpec", () => {
|
|||
}
|
||||
expect(result.error).toContain("integrity sha512-evil");
|
||||
expect(result.error).toContain("expected sha512-safe");
|
||||
expect(fs.existsSync(path.join(npmRoot, "node_modules", "drift-plugin"))).toBe(false);
|
||||
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "drift-plugin"))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects npm installs when the installed version drifts from verified metadata", async () => {
|
||||
|
|
@ -684,7 +724,7 @@ describe("installPluginFromNpmSpec", () => {
|
|||
}
|
||||
expect(result.error).toContain("version 1.0.1");
|
||||
expect(result.error).toContain("expected 1.0.0");
|
||||
expect(fs.existsSync(path.join(npmRoot, "node_modules", "version-drift-plugin"))).toBe(false);
|
||||
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "version-drift-plugin"))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects npm installs when package-lock omits the installed plugin", async () => {
|
||||
|
|
@ -712,7 +752,7 @@ describe("installPluginFromNpmSpec", () => {
|
|||
expect(result.error).toContain(
|
||||
"npm install did not record package-lock metadata for missing-lock-plugin",
|
||||
);
|
||||
expect(fs.existsSync(path.join(npmRoot, "node_modules", "missing-lock-plugin"))).toBe(false);
|
||||
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "missing-lock-plugin"))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects npm installs with blocked hoisted transitive dependencies", async () => {
|
||||
|
|
@ -771,10 +811,9 @@ describe("installPluginFromNpmSpec", () => {
|
|||
logger: { info: () => {}, warn: () => {} },
|
||||
});
|
||||
expect(first.ok).toBe(true);
|
||||
const peerPluginDir = resolveTestPluginPackageDir(npmRoot, "peer-plugin");
|
||||
expect(
|
||||
fs
|
||||
.lstatSync(path.join(npmRoot, "node_modules", "peer-plugin", "node_modules", "openclaw"))
|
||||
.isSymbolicLink(),
|
||||
fs.lstatSync(path.join(peerPluginDir, "node_modules", "openclaw")).isSymbolicLink(),
|
||||
).toBe(true);
|
||||
|
||||
const second = await installPluginFromNpmSpec({
|
||||
|
|
@ -788,9 +827,7 @@ describe("installPluginFromNpmSpec", () => {
|
|||
expect(second.error).not.toContain("peer-plugin/node_modules/openclaw");
|
||||
}
|
||||
expect(
|
||||
fs
|
||||
.lstatSync(path.join(npmRoot, "node_modules", "peer-plugin", "node_modules", "openclaw"))
|
||||
.isSymbolicLink(),
|
||||
fs.lstatSync(path.join(peerPluginDir, "node_modules", "openclaw")).isSymbolicLink(),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
|
@ -826,13 +863,9 @@ describe("installPluginFromNpmSpec", () => {
|
|||
logger: { info: () => {}, warn: () => {} },
|
||||
});
|
||||
expect(first.ok).toBe(true);
|
||||
const peerPluginDir = resolveTestPluginPackageDir(npmRoot, "peer-plugin");
|
||||
|
||||
const staleNodeModulesPath = path.join(
|
||||
npmRoot,
|
||||
"node_modules",
|
||||
"peer-plugin",
|
||||
"node_modules",
|
||||
);
|
||||
const staleNodeModulesPath = path.join(peerPluginDir, "node_modules");
|
||||
fs.rmSync(staleNodeModulesPath, { recursive: true, force: true });
|
||||
fs.writeFileSync(staleNodeModulesPath, "not a directory", "utf-8");
|
||||
|
||||
|
|
@ -843,12 +876,8 @@ describe("installPluginFromNpmSpec", () => {
|
|||
});
|
||||
|
||||
expect(second.ok).toBe(true);
|
||||
expect(
|
||||
warnings.some((warning) =>
|
||||
warning.includes(`Skipping openclaw peerDependency link because ${staleNodeModulesPath}`),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(fs.existsSync(path.join(npmRoot, "node_modules", "next-plugin"))).toBe(true);
|
||||
expect(warnings).toEqual([]);
|
||||
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "next-plugin"))).toBe(true);
|
||||
expect(fs.readFileSync(staleNodeModulesPath, "utf-8")).toBe("not a directory");
|
||||
},
|
||||
);
|
||||
|
|
@ -883,9 +912,13 @@ describe("installPluginFromNpmSpec", () => {
|
|||
expect(
|
||||
warnings.some((warning) => warning.includes("Could not locate openclaw package root")),
|
||||
).toBe(true);
|
||||
expect(fs.existsSync(path.join(npmRoot, "node_modules", "@openclaw", "codex"))).toBe(false);
|
||||
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "@openclaw/codex"))).toBe(false);
|
||||
const npmProjectRoot = resolvePluginNpmProjectDir({
|
||||
npmDir: npmRoot,
|
||||
packageName: "@openclaw/codex",
|
||||
});
|
||||
const managedManifest = JSON.parse(
|
||||
fs.readFileSync(path.join(npmRoot, "package.json"), "utf8"),
|
||||
fs.readFileSync(path.join(npmProjectRoot, "package.json"), "utf8"),
|
||||
) as { dependencies?: Record<string, string> };
|
||||
expect(managedManifest.dependencies?.["@openclaw/codex"]).toBeUndefined();
|
||||
});
|
||||
|
|
@ -1021,19 +1054,20 @@ describe("installPluginFromNpmSpec", () => {
|
|||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(fs.existsSync(path.join(npmRoot, "node_modules", "openclaw"))).toBe(false);
|
||||
const npmProjectRoot = resolvePluginNpmProjectDir({
|
||||
npmDir: npmRoot,
|
||||
packageName: "required-peer-plugin",
|
||||
});
|
||||
const requiredPeerPluginDir = resolveTestPluginPackageDir(npmRoot, "required-peer-plugin");
|
||||
expect(fs.existsSync(path.join(npmProjectRoot, "node_modules", "openclaw"))).toBe(false);
|
||||
const lockfile = JSON.parse(
|
||||
fs.readFileSync(path.join(npmRoot, "package-lock.json"), "utf8"),
|
||||
fs.readFileSync(path.join(npmProjectRoot, "package-lock.json"), "utf8"),
|
||||
) as {
|
||||
packages?: Record<string, unknown>;
|
||||
};
|
||||
expect(lockfile.packages?.["node_modules/openclaw"]).toBeUndefined();
|
||||
expect(
|
||||
fs
|
||||
.lstatSync(
|
||||
path.join(npmRoot, "node_modules", "required-peer-plugin", "node_modules", "openclaw"),
|
||||
)
|
||||
.isSymbolicLink(),
|
||||
fs.lstatSync(path.join(requiredPeerPluginDir, "node_modules", "openclaw")).isSymbolicLink(),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
|
@ -1041,9 +1075,13 @@ describe("installPluginFromNpmSpec", () => {
|
|||
it("repairs stale managed openclaw root packages before npm plugin installs", async () => {
|
||||
const stateDir = suiteTempRootTracker.makeTempDir();
|
||||
const npmRoot = path.join(stateDir, "npm");
|
||||
fs.mkdirSync(path.join(npmRoot, "node_modules", "openclaw"), { recursive: true });
|
||||
const npmProjectRoot = resolvePluginNpmProjectDir({
|
||||
npmDir: npmRoot,
|
||||
packageName: "@openclaw/discord",
|
||||
});
|
||||
fs.mkdirSync(path.join(npmProjectRoot, "node_modules", "openclaw"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(npmRoot, "package.json"),
|
||||
path.join(npmProjectRoot, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
private: true,
|
||||
|
|
@ -1057,7 +1095,7 @@ describe("installPluginFromNpmSpec", () => {
|
|||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(npmRoot, "package-lock.json"),
|
||||
path.join(npmProjectRoot, "package-lock.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
|
|
@ -1084,7 +1122,7 @@ describe("installPluginFromNpmSpec", () => {
|
|||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(npmRoot, "node_modules", "openclaw", "package.json"),
|
||||
path.join(npmProjectRoot, "node_modules", "openclaw", "package.json"),
|
||||
JSON.stringify({
|
||||
name: "openclaw",
|
||||
version: "2026.5.4",
|
||||
|
|
@ -1109,13 +1147,13 @@ describe("installPluginFromNpmSpec", () => {
|
|||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(npmRoot, "package.json"), "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(npmProjectRoot, "package.json"), "utf8"),
|
||||
) as { dependencies?: Record<string, string> };
|
||||
expect(manifest.dependencies).not.toHaveProperty("openclaw");
|
||||
expect(manifest.dependencies?.["@openclaw/discord"]).toBe("2026.5.5-beta.1");
|
||||
const lockfile = JSON.parse(
|
||||
fs.readFileSync(path.join(npmRoot, "package-lock.json"), "utf8"),
|
||||
fs.readFileSync(path.join(npmProjectRoot, "package-lock.json"), "utf8"),
|
||||
) as {
|
||||
packages?: Record<string, unknown>;
|
||||
dependencies?: Record<string, unknown>;
|
||||
|
|
@ -1190,12 +1228,26 @@ describe("installPluginFromNpmSpec", () => {
|
|||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(npmRoot, "package.json"), "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
expect(manifest.dependencies?.openclaw).toBe("2026.5.12-beta.6");
|
||||
expect(manifest.dependencies?.["@xdarkicex/openclaw-memory-libravdb"]).toBe("1.4.69");
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
const baseManifest = JSON.parse(
|
||||
fs.readFileSync(path.join(npmRoot, "package.json"), "utf8"),
|
||||
) as { dependencies?: Record<string, string> };
|
||||
expect(baseManifest.dependencies?.openclaw).toBe("2026.5.12-beta.6");
|
||||
expect(baseManifest.dependencies?.["@xdarkicex/openclaw-memory-libravdb"]).toBeUndefined();
|
||||
const npmProjectRoot = resolvePluginNpmProjectDir({
|
||||
npmDir: npmRoot,
|
||||
packageName: "@xdarkicex/openclaw-memory-libravdb",
|
||||
});
|
||||
const projectManifest = JSON.parse(
|
||||
fs.readFileSync(path.join(npmProjectRoot, "package.json"), "utf8"),
|
||||
) as { dependencies?: Record<string, string> };
|
||||
expect(projectManifest.dependencies?.["@xdarkicex/openclaw-memory-libravdb"]).toBe("1.4.69");
|
||||
expect(fs.existsSync(hostPackageRoot)).toBe(true);
|
||||
expect(result.targetDir).toBe(
|
||||
resolveTestPluginPackageDir(npmRoot, "@xdarkicex/openclaw-memory-libravdb"),
|
||||
);
|
||||
expect(
|
||||
runCommandWithTimeoutMock.mock.calls.some(
|
||||
([argv]) =>
|
||||
|
|
@ -1237,27 +1289,19 @@ describe("installPluginFromNpmSpec", () => {
|
|||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expectNpmInstallIntoRoot({
|
||||
expectNpmInstallIntoProject({
|
||||
calls: runCommandWithTimeoutMock.mock.calls,
|
||||
npmRoot,
|
||||
packageName: "dangerous-plugin",
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back the managed npm root when npm install fails", async () => {
|
||||
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
||||
const peerPluginDir = path.join(npmRoot, "node_modules", "peer-plugin");
|
||||
const peerLink = path.join(peerPluginDir, "node_modules", "openclaw");
|
||||
fs.mkdirSync(path.dirname(peerLink), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(peerPluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "peer-plugin",
|
||||
version: "1.0.0",
|
||||
peerDependencies: { openclaw: ">=2026.0.0" },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.symlinkSync(suiteTempRootTracker.makeTempDir(), peerLink, "junction");
|
||||
const npmProjectRoot = resolvePluginNpmProjectDir({
|
||||
npmDir: npmRoot,
|
||||
packageName: "@openclaw/voice-call",
|
||||
});
|
||||
runCommandWithTimeoutMock.mockImplementation(
|
||||
async (argv: string[], options?: { cwd?: string }) => {
|
||||
if (JSON.stringify(argv) === JSON.stringify(npmViewArgv("@openclaw/voice-call@0.0.1"))) {
|
||||
|
|
@ -1290,7 +1334,6 @@ describe("installPluginFromNpmSpec", () => {
|
|||
return successfulSpawn();
|
||||
}
|
||||
if (isManagedNpmInstallCommand(argv)) {
|
||||
fs.rmSync(peerLink, { recursive: true, force: true });
|
||||
return {
|
||||
code: 1,
|
||||
stdout: "",
|
||||
|
|
@ -1302,7 +1345,9 @@ describe("installPluginFromNpmSpec", () => {
|
|||
}
|
||||
if (argv[0] === "npm" && argv[1] === "uninstall") {
|
||||
if (!(argv as string[]).includes("--legacy-peer-deps")) {
|
||||
fs.mkdirSync(path.join(npmRoot, "node_modules", "openclaw"), { recursive: true });
|
||||
fs.mkdirSync(path.join(options?.cwd ?? npmRoot, "node_modules", "openclaw"), {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
return successfulSpawn("");
|
||||
}
|
||||
|
|
@ -1321,12 +1366,11 @@ describe("installPluginFromNpmSpec", () => {
|
|||
expect(result.error).toContain("registry unavailable");
|
||||
}
|
||||
const manifest = JSON.parse(
|
||||
await fs.promises.readFile(path.join(npmRoot, "package.json"), "utf8"),
|
||||
await fs.promises.readFile(path.join(npmProjectRoot, "package.json"), "utf8"),
|
||||
) as { dependencies?: Record<string, string> };
|
||||
expect(manifest.dependencies).toEqual({});
|
||||
expect(fs.lstatSync(peerLink).isSymbolicLink()).toBe(true);
|
||||
await expect(
|
||||
fs.promises.access(path.join(npmRoot, "node_modules", "openclaw")),
|
||||
fs.promises.access(path.join(npmProjectRoot, "node_modules", "openclaw")),
|
||||
).rejects.toHaveProperty("code", "ENOENT");
|
||||
});
|
||||
|
||||
|
|
@ -1366,8 +1410,12 @@ describe("installPluginFromNpmSpec", () => {
|
|||
async (argv: string[], options?: { cwd?: string }) => {
|
||||
if (isManagedNpmInstallCommand(argv)) {
|
||||
installAttempts += 1;
|
||||
const npmProjectRoot = options?.cwd;
|
||||
if (!npmProjectRoot) {
|
||||
throw new Error("expected npm install cwd");
|
||||
}
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(npmRoot, "package.json"), "utf8"),
|
||||
fs.readFileSync(path.join(npmProjectRoot, "package.json"), "utf8"),
|
||||
) as { overrides?: Record<string, unknown>; openclaw?: { managedOverrides?: string[] } };
|
||||
if (installAttempts === 1) {
|
||||
expect(manifest.overrides?.["node-domexception"]).toBe(
|
||||
|
|
@ -1431,9 +1479,13 @@ describe("installPluginFromNpmSpec", () => {
|
|||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(fs.existsSync(path.join(npmRoot, "node_modules", "dangerous-plugin"))).toBe(false);
|
||||
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "dangerous-plugin"))).toBe(false);
|
||||
const npmProjectRoot = resolvePluginNpmProjectDir({
|
||||
npmDir: npmRoot,
|
||||
packageName: "dangerous-plugin",
|
||||
});
|
||||
const manifest = JSON.parse(
|
||||
await fs.promises.readFile(path.join(npmRoot, "package.json"), "utf8"),
|
||||
await fs.promises.readFile(path.join(npmProjectRoot, "package.json"), "utf8"),
|
||||
) as { dependencies?: Record<string, string> };
|
||||
expect(manifest.dependencies).toEqual({});
|
||||
});
|
||||
|
|
@ -1489,7 +1541,7 @@ describe("installPluginFromNpmSpec", () => {
|
|||
return;
|
||||
}
|
||||
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
|
||||
expect(fs.existsSync(path.join(npmRoot, "node_modules", spec))).toBe(false);
|
||||
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, spec))).toBe(false);
|
||||
expect(
|
||||
warnings.some((warning) =>
|
||||
warning.includes("allowed because it is an official OpenClaw package"),
|
||||
|
|
@ -1529,9 +1581,10 @@ describe("installPluginFromNpmSpec", () => {
|
|||
}
|
||||
expect(result.pluginId).toBe(pluginId);
|
||||
expect(warnings.join("\n")).not.toContain("installation blocked");
|
||||
expectNpmInstallIntoRoot({
|
||||
expectNpmInstallIntoProject({
|
||||
calls: runCommandWithTimeoutMock.mock.calls,
|
||||
npmRoot,
|
||||
packageName: spec,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
@ -1548,7 +1601,7 @@ describe("installPluginFromNpmSpec", () => {
|
|||
it("rejects duplicate npm installs unless update mode is requested", async () => {
|
||||
const stateDir = suiteTempRootTracker.makeTempDir();
|
||||
const npmRoot = path.join(stateDir, "npm");
|
||||
const installRoot = path.join(npmRoot, "node_modules", "@openclaw", "voice-call");
|
||||
const installRoot = resolveTestPluginPackageDir(npmRoot, "@openclaw/voice-call");
|
||||
fs.mkdirSync(installRoot, { recursive: true });
|
||||
mockNpmViewMetadataResult(runCommandWithTimeoutMock, {
|
||||
name: "@openclaw/voice-call",
|
||||
|
|
@ -1578,7 +1631,7 @@ describe("installPluginFromNpmSpec", () => {
|
|||
it("allows duplicate npm installs in update mode", async () => {
|
||||
const stateDir = suiteTempRootTracker.makeTempDir();
|
||||
const npmRoot = path.join(stateDir, "npm");
|
||||
const installRoot = path.join(npmRoot, "node_modules", "@openclaw", "voice-call");
|
||||
const installRoot = resolveTestPluginPackageDir(npmRoot, "@openclaw/voice-call");
|
||||
fs.mkdirSync(installRoot, { recursive: true });
|
||||
fs.writeFileSync(path.join(installRoot, "old.txt"), "old", "utf-8");
|
||||
mockNpmViewAndInstall({
|
||||
|
|
@ -1602,9 +1655,10 @@ describe("installPluginFromNpmSpec", () => {
|
|||
}
|
||||
expect(result.targetDir).toBe(installRoot);
|
||||
expect(result.npmResolution?.version).toBe("0.0.2");
|
||||
expectNpmInstallIntoRoot({
|
||||
expectNpmInstallIntoProject({
|
||||
calls: runCommandWithTimeoutMock.mock.calls,
|
||||
npmRoot,
|
||||
packageName: "@openclaw/voice-call",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1644,12 +1698,13 @@ describe("installPluginFromNpmSpec", () => {
|
|||
});
|
||||
expect(result2.ok).toBe(true);
|
||||
|
||||
expectNpmInstallIntoRoot({
|
||||
expectNpmInstallIntoProject({
|
||||
calls: runCommandWithTimeoutMock.mock.calls,
|
||||
npmRoot,
|
||||
packageName: "@openclaw/whatsapp",
|
||||
});
|
||||
expect(fs.existsSync(path.join(npmRoot, "node_modules", "@openclaw", "voice-call"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(npmRoot, "node_modules", "@openclaw", "whatsapp"))).toBe(true);
|
||||
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "@openclaw/voice-call"))).toBe(true);
|
||||
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "@openclaw/whatsapp"))).toBe(true);
|
||||
});
|
||||
|
||||
it("aborts when integrity drift callback rejects the fetched artifact", async () => {
|
||||
|
|
@ -1854,9 +1909,10 @@ describe("installPluginFromNpmSpec", () => {
|
|||
}
|
||||
expect(accepted.npmResolution?.version).toBe("0.0.2-beta.1");
|
||||
expect(accepted.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.2-beta.1");
|
||||
expectNpmInstallIntoRoot({
|
||||
expectNpmInstallIntoProject({
|
||||
calls: runCommandWithTimeoutMock.mock.calls,
|
||||
npmRoot,
|
||||
packageName: "@openclaw/voice-call",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
matchesExpectedPluginId,
|
||||
resolveDefaultPluginExtensionsDir,
|
||||
resolveDefaultPluginNpmDir,
|
||||
resolvePluginNpmProjectDir,
|
||||
safePluginInstallFileName,
|
||||
validatePluginId,
|
||||
} from "./install-paths.js";
|
||||
|
|
@ -691,8 +692,12 @@ async function installPluginFromManagedNpmRoot(
|
|||
defaultLogger,
|
||||
);
|
||||
const expectedPluginId = params.expectedPluginId;
|
||||
const npmRoot = params.npmDir ? resolveUserPath(params.npmDir) : resolveDefaultPluginNpmDir();
|
||||
const installRoot = path.join(npmRoot, "node_modules", params.packageName);
|
||||
const npmBaseDir = params.npmDir ? resolveUserPath(params.npmDir) : resolveDefaultPluginNpmDir();
|
||||
const npmRoot = resolvePluginNpmProjectDir({
|
||||
npmDir: npmBaseDir,
|
||||
packageName: params.packageName,
|
||||
});
|
||||
const installRoot = resolveManagedNpmRootPackageDir(npmRoot, params.packageName);
|
||||
const effectiveMode = await resolveEffectiveInstallMode({
|
||||
runtime,
|
||||
requestedMode: mode,
|
||||
|
|
@ -2079,7 +2084,11 @@ export async function installPluginFromNpmPackArchive(
|
|||
return packageNameResult;
|
||||
}
|
||||
const packageName = packageNameResult.packageName;
|
||||
const npmRoot = params.npmDir ? resolveUserPath(params.npmDir) : resolveDefaultPluginNpmDir();
|
||||
const npmBaseDir = params.npmDir ? resolveUserPath(params.npmDir) : resolveDefaultPluginNpmDir();
|
||||
const npmRoot = resolvePluginNpmProjectDir({
|
||||
npmDir: npmBaseDir,
|
||||
packageName,
|
||||
});
|
||||
let dependencySpec = "";
|
||||
if (!dryRun) {
|
||||
try {
|
||||
|
|
@ -2113,7 +2122,7 @@ export async function installPluginFromNpmPackArchive(
|
|||
requestedSpecifier: `npm-pack:${metadataResult.archivePath}`,
|
||||
},
|
||||
extensionsDir: params.extensionsDir,
|
||||
npmDir: npmRoot,
|
||||
npmDir: npmBaseDir,
|
||||
timeoutMs,
|
||||
logger,
|
||||
mode,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
resolveInstalledPluginIndexStorePath,
|
||||
type InstalledPluginIndexStoreOptions,
|
||||
} from "./installed-plugin-index-store-path.js";
|
||||
import { listManagedPluginNpmProjectRootsSync } from "./npm-project-roots.js";
|
||||
|
||||
function cloneInstallRecords(
|
||||
records: Record<string, PluginInstallRecord> | undefined,
|
||||
|
|
@ -99,15 +100,14 @@ function resolveRecoveredManagedNpmPluginId(params: {
|
|||
return validatePluginId(pluginId) ? undefined : pluginId;
|
||||
}
|
||||
|
||||
function buildRecoveredManagedNpmInstallRecords(
|
||||
options: InstalledPluginIndexStoreOptions = {},
|
||||
function buildRecoveredManagedNpmInstallRecordsForRoot(
|
||||
npmRoot: string,
|
||||
): Record<string, PluginInstallRecord> {
|
||||
const npmRoot = resolveRecoveredManagedNpmRoot(options);
|
||||
const rootManifest = readJsonObjectFileSync(path.join(npmRoot, "package.json"));
|
||||
const dependencies = readStringRecord(rootManifest?.dependencies);
|
||||
const records: Record<string, PluginInstallRecord> = {};
|
||||
for (const [packageName, dependencySpec] of Object.entries(dependencies)) {
|
||||
const packageDir = path.join(npmRoot, "node_modules", packageName);
|
||||
const packageDir = path.join(npmRoot, "node_modules", ...packageName.split("/"));
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(packageDir);
|
||||
|
|
@ -137,6 +137,18 @@ function buildRecoveredManagedNpmInstallRecords(
|
|||
return records;
|
||||
}
|
||||
|
||||
function buildRecoveredManagedNpmInstallRecords(
|
||||
options: InstalledPluginIndexStoreOptions = {},
|
||||
): Record<string, PluginInstallRecord> {
|
||||
const npmRoot = resolveRecoveredManagedNpmRoot(options);
|
||||
const legacyRecords = buildRecoveredManagedNpmInstallRecordsForRoot(npmRoot);
|
||||
const projectRecords: Record<string, PluginInstallRecord> = {};
|
||||
for (const projectRoot of listManagedPluginNpmProjectRootsSync(npmRoot)) {
|
||||
Object.assign(projectRecords, buildRecoveredManagedNpmInstallRecordsForRoot(projectRoot));
|
||||
}
|
||||
return { ...legacyRecords, ...projectRecords };
|
||||
}
|
||||
|
||||
function recordsShareInstallPath(
|
||||
left: PluginInstallRecord | undefined,
|
||||
right: PluginInstallRecord,
|
||||
|
|
|
|||
|
|
@ -365,6 +365,28 @@ describe("plugin index install records store", () => {
|
|||
expectRecordFields(loadedSync.discord, { source: "npm", installPath: discordDir });
|
||||
});
|
||||
|
||||
it("still recovers legacy flat managed npm plugin records", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
const discordDir = writeManagedNpmPlugin({
|
||||
stateDir,
|
||||
packageName: "@openclaw/discord",
|
||||
pluginId: "discord",
|
||||
version: "2026.5.2",
|
||||
layout: "legacy",
|
||||
});
|
||||
const indexPath = resolveInstalledPluginIndexRecordsStorePath({ stateDir });
|
||||
fs.mkdirSync(path.dirname(indexPath), { recursive: true });
|
||||
fs.writeFileSync(indexPath, JSON.stringify({ installRecords: {}, plugins: [] }), "utf8");
|
||||
|
||||
const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir });
|
||||
expectRecordFields(loaded.discord, {
|
||||
source: "npm",
|
||||
spec: "@openclaw/discord@2026.5.2",
|
||||
installPath: discordDir,
|
||||
version: "2026.5.2",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps persisted install record metadata over recovered npm records", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
writeManagedNpmPlugin({
|
||||
|
|
|
|||
53
src/plugins/npm-project-roots.ts
Normal file
53
src/plugins/npm-project-roots.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolvePluginNpmProjectsDir } from "./install-paths.js";
|
||||
|
||||
function isMissing(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
||||
}
|
||||
|
||||
function sortPaths(paths: string[]): string[] {
|
||||
return paths.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function listManagedPluginNpmProjectRootsSync(npmRoot: string): string[] {
|
||||
const projectsDir = resolvePluginNpmProjectsDir(npmRoot);
|
||||
try {
|
||||
return sortPaths(
|
||||
fs
|
||||
.readdirSync(projectsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(projectsDir, entry.name)),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isMissing(error)) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listManagedPluginNpmProjectRoots(npmRoot: string): Promise<string[]> {
|
||||
const projectsDir = resolvePluginNpmProjectsDir(npmRoot);
|
||||
try {
|
||||
return sortPaths(
|
||||
(await fsp.readdir(projectsDir, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(projectsDir, entry.name)),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isMissing(error)) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function listManagedPluginNpmRootsSync(npmRoot: string): string[] {
|
||||
return [npmRoot, ...listManagedPluginNpmProjectRootsSync(npmRoot)];
|
||||
}
|
||||
|
||||
export async function listManagedPluginNpmRoots(npmRoot: string): Promise<string[]> {
|
||||
return [npmRoot, ...(await listManagedPluginNpmProjectRoots(npmRoot))];
|
||||
}
|
||||
|
|
@ -660,6 +660,42 @@ describe("loadPluginMetadataSnapshot process memo", () => {
|
|||
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps npm project package fingerprints stable across directory order changes", () => {
|
||||
const stateDir = tempStateDir();
|
||||
const projectsDir = path.join(stateDir, "npm", "projects");
|
||||
writeJson(path.join(projectsDir, "zeta", "package.json"), { name: "zeta", version: "1.0.0" });
|
||||
writeJson(path.join(projectsDir, "alpha", "package.json"), { name: "alpha", version: "1.0.0" });
|
||||
touchPersistedIndex(stateDir);
|
||||
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
|
||||
source: "persisted",
|
||||
snapshot: makeIndex(),
|
||||
diagnostics: [],
|
||||
});
|
||||
const originalReaddirSync = fs.readdirSync.bind(fs);
|
||||
let reverseProjectEntries = false;
|
||||
const readdirSpy = vi.spyOn(fs, "readdirSync").mockImplementation(((
|
||||
directoryPath: fs.PathLike,
|
||||
options?: Parameters<typeof fs.readdirSync>[1],
|
||||
): unknown => {
|
||||
const entries = originalReaddirSync(directoryPath, options as never);
|
||||
if (directoryPath === projectsDir && reverseProjectEntries && Array.isArray(entries)) {
|
||||
return entries.toReversed();
|
||||
}
|
||||
return entries;
|
||||
}) as unknown as typeof fs.readdirSync);
|
||||
|
||||
try {
|
||||
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
|
||||
reverseProjectEntries = true;
|
||||
loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir });
|
||||
} finally {
|
||||
readdirSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
|
||||
expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("requires reload before an in-root package manifest symlink target change is visible", () => {
|
||||
const stateDir = tempStateDir();
|
||||
const pluginDir = path.join(stateDir, "extensions", "demo");
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { isRecord } from "../shared/record-coerce.js";
|
|||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveCompatibilityHostVersion } from "../version.js";
|
||||
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
|
||||
import { resolveDefaultPluginNpmDir } from "./install-paths.js";
|
||||
import { resolveDefaultPluginNpmDir, resolvePluginNpmProjectsDir } from "./install-paths.js";
|
||||
import { hashJson } from "./installed-plugin-index-hash.js";
|
||||
import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js";
|
||||
import { resolveInstalledPluginIndexStorePath } from "./installed-plugin-index-store-path.js";
|
||||
|
|
@ -92,6 +92,22 @@ function fileFingerprint(filePath: string): unknown {
|
|||
}
|
||||
}
|
||||
|
||||
function directoryChildPackageJsonFingerprint(directoryPath: string): unknown {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(directoryPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return [directoryPath, "missing"];
|
||||
}
|
||||
return [
|
||||
directoryPath,
|
||||
...entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
.map((entry) => fileFingerprint(path.join(directoryPath, entry.name, "package.json"))),
|
||||
];
|
||||
}
|
||||
|
||||
function readJsonObject(filePath: string): Record<string, unknown> | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
|
|
@ -194,6 +210,9 @@ function resolvePersistedRegistryFastMemoFingerprint(params: {
|
|||
return {
|
||||
index: fileFingerprint(indexPath),
|
||||
npmPackageJson: fileFingerprint(path.join(npmRoot, "package.json")),
|
||||
npmProjectPackageJsons: directoryChildPackageJsonFingerprint(
|
||||
resolvePluginNpmProjectsDir(npmRoot),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolvePluginNpmProjectDir } from "../install-paths.js";
|
||||
|
||||
export function writeManagedNpmPlugin(params: {
|
||||
stateDir: string;
|
||||
|
|
@ -8,8 +9,16 @@ export function writeManagedNpmPlugin(params: {
|
|||
version: string;
|
||||
name?: string;
|
||||
dependencySpec?: string;
|
||||
layout?: "project" | "legacy";
|
||||
}): string {
|
||||
const npmRoot = path.join(params.stateDir, "npm");
|
||||
const npmBaseDir = path.join(params.stateDir, "npm");
|
||||
const npmRoot =
|
||||
params.layout === "legacy"
|
||||
? npmBaseDir
|
||||
: resolvePluginNpmProjectDir({
|
||||
npmDir: npmBaseDir,
|
||||
packageName: params.packageName,
|
||||
});
|
||||
const rootManifestPath = path.join(npmRoot, "package.json");
|
||||
fs.mkdirSync(npmRoot, { recursive: true });
|
||||
const rootManifest = fs.existsSync(rootManifestPath)
|
||||
|
|
@ -34,7 +43,7 @@ export function writeManagedNpmPlugin(params: {
|
|||
"utf8",
|
||||
);
|
||||
|
||||
const packageDir = path.join(npmRoot, "node_modules", params.packageName);
|
||||
const packageDir = path.join(npmRoot, "node_modules", ...params.packageName.split("/"));
|
||||
fs.mkdirSync(path.join(packageDir, "dist"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(packageDir, "package.json"),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import path from "node:path";
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { toRepoRelativePath } from "../test-utils/repo-files.js";
|
||||
import { resolvePluginNpmProjectDir } from "./install-paths.js";
|
||||
import { resolvePluginInstallDir } from "./install.js";
|
||||
import {
|
||||
cleanupTrackedTempDirsAsync,
|
||||
|
|
@ -1024,6 +1025,71 @@ describe("uninstallPlugin", () => {
|
|||
await expectPathAccessState(pluginDir, "missing");
|
||||
});
|
||||
|
||||
it("uninstalls per-plugin npm project packages through their project root", async () => {
|
||||
const stateDir = path.join(tempDir, "state");
|
||||
const extensionsDir = path.join(stateDir, "extensions");
|
||||
const npmBaseDir = path.join(stateDir, "npm");
|
||||
const npmRoot = resolvePluginNpmProjectDir({
|
||||
npmDir: npmBaseDir,
|
||||
packageName: "@openclaw/kitchen-sink",
|
||||
});
|
||||
const pluginDir = path.join(npmRoot, "node_modules", "@openclaw", "kitchen-sink");
|
||||
const hoistedDir = path.join(npmRoot, "node_modules", "is-number");
|
||||
await fs.mkdir(pluginDir, { recursive: true });
|
||||
await fs.mkdir(hoistedDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(npmRoot, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
private: true,
|
||||
dependencies: {
|
||||
"@openclaw/kitchen-sink": "1.0.0",
|
||||
"is-number": "7.0.0",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await fs.writeFile(path.join(pluginDir, "package.json"), "{}");
|
||||
await fs.writeFile(path.join(hoistedDir, "package.json"), "{}");
|
||||
|
||||
const plan = planPluginUninstall({
|
||||
config: createPluginConfig({
|
||||
entries: createSinglePluginEntries("openclaw-kitchen-sink-fixture"),
|
||||
installs: {
|
||||
"openclaw-kitchen-sink-fixture": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/kitchen-sink@1.0.0",
|
||||
installPath: pluginDir,
|
||||
},
|
||||
},
|
||||
}),
|
||||
pluginId: "openclaw-kitchen-sink-fixture",
|
||||
deleteFiles: true,
|
||||
extensionsDir,
|
||||
});
|
||||
|
||||
expect(plan.ok).toBe(true);
|
||||
if (!plan.ok) {
|
||||
throw new Error(plan.error);
|
||||
}
|
||||
expect(plan.directoryRemoval).toEqual({
|
||||
target: pluginDir,
|
||||
cleanup: {
|
||||
kind: "npm",
|
||||
npmRoot,
|
||||
packageName: "@openclaw/kitchen-sink",
|
||||
},
|
||||
});
|
||||
|
||||
const applied = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval);
|
||||
|
||||
expect(applied).toEqual({ directoryRemoved: true, warnings: [] });
|
||||
expectNpmUninstallCommand({ packageName: "@openclaw/kitchen-sink", npmRoot });
|
||||
await expectPathAccessState(pluginDir, "missing");
|
||||
});
|
||||
|
||||
it("repairs remaining npm plugin openclaw peer links after npm uninstall prunes them", async () => {
|
||||
const stateDir = path.join(tempDir, "state");
|
||||
const npmRoot = path.join(stateDir, "npm");
|
||||
|
|
@ -1719,6 +1785,29 @@ describe("resolveUninstallDirectoryTarget", () => {
|
|||
).toBe(installPath);
|
||||
});
|
||||
|
||||
it("uses configured installPath when npm installed it under a managed npm project", () => {
|
||||
const stateDir = path.join(os.tmpdir(), "openclaw-uninstall-safe");
|
||||
const extensionsDir = path.join(stateDir, "extensions");
|
||||
const npmRoot = resolvePluginNpmProjectDir({
|
||||
npmDir: path.join(stateDir, "npm"),
|
||||
packageName: "@openclaw/kitchen-sink",
|
||||
});
|
||||
const installPath = path.join(npmRoot, "node_modules", "@openclaw", "kitchen-sink");
|
||||
|
||||
expect(
|
||||
resolveUninstallDirectoryTarget({
|
||||
pluginId: "openclaw-kitchen-sink-fixture",
|
||||
hasInstall: true,
|
||||
installRecord: {
|
||||
source: "npm",
|
||||
spec: "@openclaw/kitchen-sink@latest",
|
||||
installPath,
|
||||
},
|
||||
extensionsDir,
|
||||
}),
|
||||
).toBe(installPath);
|
||||
});
|
||||
|
||||
it("uses configured installPath when git installed it under the managed git root", () => {
|
||||
const stateDir = path.join(os.tmpdir(), "openclaw-uninstall-safe");
|
||||
const extensionsDir = path.join(stateDir, "extensions");
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
resolveDefaultPluginGitDir,
|
||||
resolveDefaultPluginNpmDir,
|
||||
resolvePluginInstallDir,
|
||||
resolvePluginNpmProjectsDir,
|
||||
} from "./install-paths.js";
|
||||
import { relinkOpenClawPeerDependenciesInManagedNpmRoot } from "./plugin-peer-link.js";
|
||||
import { defaultSlotIdForKey } from "./slots.js";
|
||||
|
|
@ -211,10 +212,44 @@ function resolveNpmManagedInstall(params: {
|
|||
const packageName = resolveNpmPackageNameFromInstallPath({ installPath, nodeModulesRoot });
|
||||
return packageName ? { installPath, npmRoot, packageName } : null;
|
||||
}
|
||||
const projectMatch = resolveNpmManagedProjectInstall({
|
||||
installPath,
|
||||
projectsDir: resolvePluginNpmProjectsDir(npmRoot),
|
||||
});
|
||||
if (projectMatch) {
|
||||
return projectMatch;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveNpmManagedProjectInstall(params: {
|
||||
installPath: string;
|
||||
projectsDir: string;
|
||||
}): { installPath: string; npmRoot: string; packageName: string } | null {
|
||||
if (
|
||||
!isPathInsideOrEqual(params.projectsDir, params.installPath) ||
|
||||
resolveComparablePath(params.projectsDir) === resolveComparablePath(params.installPath)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const relativePath = path.relative(
|
||||
path.resolve(params.projectsDir),
|
||||
path.resolve(params.installPath),
|
||||
);
|
||||
const segments = relativePath.split(path.sep).filter(Boolean);
|
||||
if (segments.length < 3 || segments[1] !== "node_modules") {
|
||||
return null;
|
||||
}
|
||||
const npmRoot = path.join(params.projectsDir, segments[0] ?? "");
|
||||
const nodeModulesRoot = path.join(npmRoot, "node_modules");
|
||||
const packageName = resolveNpmPackageNameFromInstallPath({
|
||||
installPath: params.installPath,
|
||||
nodeModulesRoot,
|
||||
});
|
||||
return packageName ? { installPath: params.installPath, npmRoot, packageName } : null;
|
||||
}
|
||||
|
||||
function resolveNpmPackageNameFromInstallPath(params: {
|
||||
installPath: string;
|
||||
nodeModulesRoot: string;
|
||||
|
|
|
|||
|
|
@ -242,4 +242,36 @@ exit 1
|
|||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("handles missing toolcache roots under nounset", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "openclaw-ensure-node-"));
|
||||
try {
|
||||
const result = spawnSync(
|
||||
"bash",
|
||||
[
|
||||
"-c",
|
||||
[
|
||||
"set -euo pipefail",
|
||||
`source "${ensureNodeScript}"`,
|
||||
`openclaw_find_toolcache_node "99.99.99"`,
|
||||
].join("; "),
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
PATH: process.env.PATH ?? "",
|
||||
RUNNER_TOOL_CACHE: join(root, "missing-toolcache"),
|
||||
AGENT_TOOLSDIRECTORY: join(root, "missing-agent-tools"),
|
||||
ACTIONS_RUNNER_TOOL_CACHE: join(root, "missing-actions-cache"),
|
||||
OPENCLAW_CONTAINER_TOOL_CACHE: join(root, "missing-container-cache"),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).not.toContain("unbound variable");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue