mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix: plan managed npm peer pins with npm
Plan managed npm peer dependency pins from npm's lockfile planner instead of recursively scanning nested node_modules packages, preserving host peer ranges when npm cannot produce a usable root pin. Also preserves active root-managed OpenClaw host runtimes during npm plugin installs, folding the active-host guard/test from #81632. Verification: - codex-review --full-access - pnpm check:test-types - pnpm exec oxfmt --check --threads=1 src/infra/npm-managed-root.ts src/infra/npm-managed-root.test.ts src/plugins/install.npm-spec.test.ts CHANGELOG.md test/scripts/mantis-build-telegram-desktop-proof-evidence.test.ts && git diff --check - OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test src/infra/npm-managed-root.test.ts src/plugins/install.npm-spec.test.ts -- --reporter=verbose - OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/test-projects.mjs src/plugins/install.npm-spec.e2e.test.ts -- --reporter=verbose - node scripts/run-vitest.mjs run --config test/vitest/vitest.full-core-support-boundary.config.ts test/scripts/mantis-build-telegram-desktop-proof-evidence.test.ts --reporter=verbose - GitHub current-head checks: 55 completed, 0 failures; remaining Blacksmith-backed jobs capacity-queued at merge decision time. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
6971036043
commit
d9ff8cfb01
@@ -169,6 +169,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/install: preserve third-party peer dependencies in the managed npm root when later plugin installs or updates recalculate the shared dependency tree. Thanks @shakkernerd.
|
||||
- Plugins/memory: prefer the npm-installed memory-lancedb plugin over the bundled fallback during duplicate resolution, keeping Active Memory's `memory_recall` tool visible after managed installs. Fixes #81193. Thanks @julio-arcila.
|
||||
- Plugins/uninstall: prune managed third-party peer dependencies after their owning npm plugin is removed, without blocking plugin cleanup on peer-prune failures.
|
||||
- Plugins/install: derive managed peer dependency pins from npm's lockfile planner instead of recursively scanning `node_modules`, while keeping OpenClaw host peers out of managed root ownership and preserving active root-managed runtimes. Thanks @fuller-stack-dev.
|
||||
- Docker: pin setup-time container paths so stale host `.env` OpenClaw paths cannot leak into Linux containers. Fixes #80381. (#81105) Thanks @brokemac79.
|
||||
- Channels/WeCom: refresh the official onboarding install to `@wecom/wecom-openclaw-plugin@2026.5.7` and update existing managed npm installs instead of failing on the package directory. Fixes #79884. (#80390) Thanks @brokemac79.
|
||||
- Control UI/WebChat: keep short assistant replies clear of in-bubble copy/open action buttons by applying the existing reserved action spacing in the grouped chat renderer. Fixes #79509. (#81244) Thanks @JARVIS-Glasses.
|
||||
|
||||
@@ -3,12 +3,14 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CommandOptions } from "../process/exec.js";
|
||||
import {
|
||||
repairManagedNpmRootOpenClawPeer,
|
||||
removeManagedNpmRootDependency,
|
||||
readManagedNpmRootInstalledDependency,
|
||||
readOpenClawManagedNpmRootOverrides,
|
||||
resolveManagedNpmRootDependencySpec,
|
||||
syncManagedNpmRootPeerDependencies,
|
||||
upsertManagedNpmRootDependency,
|
||||
} from "./npm-managed-root.js";
|
||||
|
||||
@@ -53,7 +55,10 @@ async function expectPathMissing(targetPath: string): Promise<void> {
|
||||
throw new Error(`Expected path to be missing: ${targetPath}`);
|
||||
}
|
||||
|
||||
function requireFirstMockCall<T>(mock: { mock: { calls: T[][] } }, label: string): T[] {
|
||||
function requireFirstMockCall<T extends unknown[]>(
|
||||
mock: { mock: { calls: T[] } },
|
||||
label: string,
|
||||
): T {
|
||||
const call = mock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error(`expected ${label} call`);
|
||||
@@ -61,6 +66,16 @@ function requireFirstMockCall<T>(mock: { mock: { calls: T[][] } }, label: string
|
||||
return call;
|
||||
}
|
||||
|
||||
function requireCommandOptions(
|
||||
options: number | CommandOptions | undefined,
|
||||
label: string,
|
||||
): CommandOptions {
|
||||
if (!options || typeof options === "number") {
|
||||
throw new Error(`expected ${label} command options`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
describe("managed npm root", () => {
|
||||
it("keeps existing plugin dependencies when adding another managed plugin", async () => {
|
||||
const npmRoot = await makeTempRoot();
|
||||
@@ -358,6 +373,334 @@ describe("managed npm root", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("syncs managed peer dependencies from npm's resolved lockfile plan", async () => {
|
||||
const npmRoot = await makeTempRoot();
|
||||
await fs.writeFile(
|
||||
path.join(npmRoot, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
private: true,
|
||||
dependencies: {
|
||||
"existing-root": "1.0.0",
|
||||
"old-peer": "1.0.0",
|
||||
plugin: "1.0.0",
|
||||
},
|
||||
devDependencies: {
|
||||
"dev-plugin": "1.0.0",
|
||||
},
|
||||
openclaw: {
|
||||
managedPeerDependencies: ["old-peer"],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
const runCommand = vi.fn(async (_args: string[], optionsOrTimeout: number | CommandOptions) => {
|
||||
const options = requireCommandOptions(optionsOrTimeout, "npm peer plan");
|
||||
if (!options.cwd) {
|
||||
throw new Error("expected npm peer plan cwd");
|
||||
}
|
||||
const tempManifest = JSON.parse(
|
||||
await fs.readFile(path.join(options.cwd, "package.json"), "utf8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
expect(tempManifest.dependencies).toEqual({
|
||||
"existing-root": "1.0.0",
|
||||
plugin: "1.0.0",
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(options.cwd, "package-lock.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"": {
|
||||
dependencies: tempManifest.dependencies,
|
||||
},
|
||||
"node_modules/existing-root": {
|
||||
version: "1.0.0",
|
||||
},
|
||||
"node_modules/dev-peer": {
|
||||
dev: true,
|
||||
version: "3.0.0",
|
||||
},
|
||||
"node_modules/dev-plugin": {
|
||||
dev: true,
|
||||
peerDependencies: {
|
||||
"dev-peer": "^3.0.0",
|
||||
},
|
||||
version: "1.0.0",
|
||||
},
|
||||
"node_modules/new-peer": {
|
||||
peer: true,
|
||||
version: "2.1.0",
|
||||
},
|
||||
"node_modules/openclaw": {
|
||||
peer: true,
|
||||
version: "2026.5.12",
|
||||
},
|
||||
"node_modules/plugin": {
|
||||
peerDependencies: {
|
||||
"existing-root": "^1.0.0",
|
||||
"new-peer": "^2.0.0",
|
||||
openclaw: ">=2026.5.0",
|
||||
},
|
||||
version: "1.0.0",
|
||||
},
|
||||
"node_modules/unsupported-optional": {
|
||||
optional: true,
|
||||
os: [process.platform === "win32" ? "darwin" : "win32"],
|
||||
peerDependencies: {
|
||||
"unsupported-peer": "^9.0.0",
|
||||
},
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
return successfulSpawn;
|
||||
});
|
||||
|
||||
await expect(syncManagedNpmRootPeerDependencies({ npmRoot, runCommand })).resolves.toBe(true);
|
||||
|
||||
const [args, rawOptions] = requireFirstMockCall(runCommand, "npm peer plan command");
|
||||
const options = requireCommandOptions(rawOptions, "npm peer plan");
|
||||
expect(args).toEqual([
|
||||
"npm",
|
||||
"install",
|
||||
"--package-lock-only",
|
||||
"--force",
|
||||
"--omit=dev",
|
||||
"--omit=peer",
|
||||
"--loglevel=error",
|
||||
"--ignore-scripts",
|
||||
"--workspaces=false",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
]);
|
||||
expect(options?.cwd).not.toBe(npmRoot);
|
||||
expect(options?.env?.npm_config_legacy_peer_deps).toBe("false");
|
||||
|
||||
await expect(
|
||||
fs.readFile(path.join(npmRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)),
|
||||
).resolves.toEqual({
|
||||
private: true,
|
||||
dependencies: {
|
||||
"existing-root": "1.0.0",
|
||||
"new-peer": "2.1.0",
|
||||
plugin: "1.0.0",
|
||||
},
|
||||
devDependencies: {
|
||||
"dev-plugin": "1.0.0",
|
||||
},
|
||||
openclaw: {
|
||||
managedPeerDependencies: ["new-peer"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves existing managed peer dependencies when npm cannot plan third-party peers", async () => {
|
||||
const npmRoot = await makeTempRoot();
|
||||
await fs.writeFile(
|
||||
path.join(npmRoot, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
private: true,
|
||||
dependencies: {
|
||||
plugin: "1.0.0",
|
||||
"runtime-peer": "2.0.0",
|
||||
},
|
||||
openclaw: {
|
||||
managedPeerDependencies: ["runtime-peer"],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
const runCommand = vi.fn(async () => ({
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "npm ERR! ERESOLVE could not resolve third-party peer dependency",
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit" as const,
|
||||
}));
|
||||
|
||||
await expect(syncManagedNpmRootPeerDependencies({ npmRoot, runCommand })).resolves.toBe(false);
|
||||
expect(runCommand).toHaveBeenCalledTimes(1);
|
||||
await expect(
|
||||
fs.readFile(path.join(npmRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)),
|
||||
).resolves.toEqual({
|
||||
private: true,
|
||||
dependencies: {
|
||||
plugin: "1.0.0",
|
||||
"runtime-peer": "2.0.0",
|
||||
},
|
||||
openclaw: {
|
||||
managedPeerDependencies: ["runtime-peer"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses lockfile metadata to preserve non-host peers when host peer planning fails", async () => {
|
||||
const npmRoot = await makeTempRoot();
|
||||
await fs.writeFile(
|
||||
path.join(npmRoot, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
private: true,
|
||||
dependencies: {
|
||||
plugin: "1.0.0",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
const runCommand = vi.fn(async (_args: string[], optionsOrTimeout: number | CommandOptions) => {
|
||||
const options = requireCommandOptions(optionsOrTimeout, "npm peer plan");
|
||||
if (!options.cwd) {
|
||||
throw new Error("expected npm peer plan cwd");
|
||||
}
|
||||
if (runCommand.mock.calls.length === 1) {
|
||||
return {
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "npm ERR! notarget No matching version found for openclaw@2026.5.99-beta.1",
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit" as const,
|
||||
};
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(options.cwd, "package-lock.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"": {
|
||||
dependencies: {
|
||||
plugin: "1.0.0",
|
||||
},
|
||||
},
|
||||
"node_modules/plugin": {
|
||||
peerDependencies: {
|
||||
openclaw: "2026.5.99-beta.1",
|
||||
"runtime-peer": "^2.0.0",
|
||||
},
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
return successfulSpawn;
|
||||
});
|
||||
|
||||
await expect(syncManagedNpmRootPeerDependencies({ npmRoot, runCommand })).resolves.toBe(true);
|
||||
expect(runCommand).toHaveBeenCalledTimes(2);
|
||||
const [strictArgs, rawStrictOptions] = runCommand.mock.calls[0] ?? [];
|
||||
const [fallbackArgs, rawFallbackOptions] = runCommand.mock.calls[1] ?? [];
|
||||
const strictOptions = requireCommandOptions(rawStrictOptions, "strict npm peer plan");
|
||||
const fallbackOptions = requireCommandOptions(rawFallbackOptions, "fallback npm peer plan");
|
||||
expect(strictArgs).not.toContain("--legacy-peer-deps");
|
||||
expect(strictOptions.env?.npm_config_legacy_peer_deps).toBe("false");
|
||||
expect(fallbackArgs).toContain("--legacy-peer-deps");
|
||||
expect(fallbackOptions.env?.npm_config_legacy_peer_deps).toBe("true");
|
||||
await expect(
|
||||
fs.readFile(path.join(npmRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)),
|
||||
).resolves.toEqual({
|
||||
private: true,
|
||||
dependencies: {
|
||||
plugin: "1.0.0",
|
||||
"runtime-peer": "^2.0.0",
|
||||
},
|
||||
openclaw: {
|
||||
managedPeerDependencies: ["runtime-peer"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not promote nested transitive lockfile versions into managed root peers", async () => {
|
||||
const npmRoot = await makeTempRoot();
|
||||
await fs.writeFile(
|
||||
path.join(npmRoot, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
private: true,
|
||||
dependencies: {
|
||||
plugin: "1.0.0",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
const runCommand = vi.fn(async (_args: string[], optionsOrTimeout: number | CommandOptions) => {
|
||||
const options = requireCommandOptions(optionsOrTimeout, "npm peer plan");
|
||||
if (!options.cwd) {
|
||||
throw new Error("expected npm peer plan cwd");
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(options.cwd, "package-lock.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"": {
|
||||
dependencies: {
|
||||
plugin: "1.0.0",
|
||||
},
|
||||
},
|
||||
"node_modules/plugin": {
|
||||
peerDependencies: {
|
||||
"runtime-peer": "^2.0.0",
|
||||
},
|
||||
version: "1.0.0",
|
||||
},
|
||||
"node_modules/transitive": {
|
||||
version: "1.0.0",
|
||||
},
|
||||
"node_modules/transitive/node_modules/runtime-peer": {
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
return successfulSpawn;
|
||||
});
|
||||
|
||||
await expect(syncManagedNpmRootPeerDependencies({ npmRoot, runCommand })).resolves.toBe(true);
|
||||
|
||||
await expect(
|
||||
fs.readFile(path.join(npmRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)),
|
||||
).resolves.toEqual({
|
||||
private: true,
|
||||
dependencies: {
|
||||
plugin: "1.0.0",
|
||||
"runtime-peer": "^2.0.0",
|
||||
},
|
||||
openclaw: {
|
||||
managedPeerDependencies: ["runtime-peer"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("removes one managed dependency without dropping unrelated metadata", async () => {
|
||||
const npmRoot = await makeTempRoot();
|
||||
await fs.writeFile(
|
||||
@@ -469,7 +812,8 @@ describe("managed npm root", () => {
|
||||
const runCommand = vi.fn().mockResolvedValue(successfulSpawn);
|
||||
await expect(repairManagedNpmRootOpenClawPeer({ npmRoot, runCommand })).resolves.toBe(true);
|
||||
expect(runCommand).toHaveBeenCalledTimes(1);
|
||||
const [repairArgs, repairOptions] = requireFirstMockCall(runCommand, "repair command");
|
||||
const [repairArgs, rawRepairOptions] = requireFirstMockCall(runCommand, "repair command");
|
||||
const repairOptions = requireCommandOptions(rawRepairOptions, "repair");
|
||||
expect(repairArgs).toEqual([
|
||||
"npm",
|
||||
"uninstall",
|
||||
@@ -508,4 +852,71 @@ describe("managed npm root", () => {
|
||||
}
|
||||
await expectPathMissing(path.join(npmRoot, "node_modules", ".package-lock.json"));
|
||||
});
|
||||
|
||||
it("does not repair the active OpenClaw host package in a root-managed install", async () => {
|
||||
const npmRoot = await makeTempRoot();
|
||||
const hostPackageRoot = path.join(npmRoot, "node_modules", "openclaw");
|
||||
await fs.mkdir(path.join(hostPackageRoot, "dist"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(npmRoot, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
private: true,
|
||||
dependencies: {
|
||||
openclaw: "2026.5.12-beta.6",
|
||||
"@xdarkicex/openclaw-memory-libravdb": "1.4.69",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(npmRoot, "package-lock.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"": {
|
||||
dependencies: {
|
||||
openclaw: "2026.5.12-beta.6",
|
||||
"@xdarkicex/openclaw-memory-libravdb": "1.4.69",
|
||||
},
|
||||
},
|
||||
"node_modules/openclaw": {
|
||||
version: "2026.5.12-beta.6",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(hostPackageRoot, "package.json"),
|
||||
`${JSON.stringify({ name: "openclaw", version: "2026.5.12-beta.6" })}\n`,
|
||||
);
|
||||
|
||||
const runCommand = vi.fn().mockResolvedValue(successfulSpawn);
|
||||
await expect(
|
||||
repairManagedNpmRootOpenClawPeer({
|
||||
npmRoot,
|
||||
packageRoot: hostPackageRoot,
|
||||
runCommand,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
fs.readFile(path.join(npmRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)),
|
||||
).resolves.toMatchObject({
|
||||
dependencies: {
|
||||
openclaw: "2026.5.12-beta.6",
|
||||
"@xdarkicex/openclaw-memory-libravdb": "1.4.69",
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
fs.readFile(path.join(hostPackageRoot, "package.json"), "utf8"),
|
||||
).resolves.toContain("2026.5.12-beta.6");
|
||||
});
|
||||
});
|
||||
|
||||
+380
-162
@@ -1,12 +1,12 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import type { NpmSpecResolution } from "./install-source-utils.js";
|
||||
import { readJson, readJsonIfExists, writeJson } from "./json-files.js";
|
||||
import type { ParsedRegistryNpmSpec } from "./npm-registry-spec.js";
|
||||
import { resolveOpenClawPackageRootSync } from "./openclaw-root.js";
|
||||
import { createSafeNpmInstallEnv } from "./safe-package-install.js";
|
||||
import { createSafeNpmInstallArgs, createSafeNpmInstallEnv } from "./safe-package-install.js";
|
||||
|
||||
type ManagedNpmRootManifest = {
|
||||
private?: boolean;
|
||||
@@ -52,16 +52,6 @@ type ManagedNpmRootLogger = {
|
||||
|
||||
type ManagedNpmRootRunCommand = typeof runCommandWithTimeout;
|
||||
|
||||
type ManagedNpmPeerTraversalLimits = {
|
||||
maxDepth: number;
|
||||
maxDirectories: number;
|
||||
};
|
||||
|
||||
const DEFAULT_MANAGED_NPM_PEER_TRAVERSAL_LIMITS: ManagedNpmPeerTraversalLimits = {
|
||||
maxDepth: 64,
|
||||
maxDirectories: 10_000,
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -83,35 +73,6 @@ function readDependencyRecord(value: unknown): Record<string, string> {
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
function readPositiveIntegerEnv(name: string, fallback: number): number {
|
||||
const rawValue = process.env[name];
|
||||
if (!rawValue) {
|
||||
return fallback;
|
||||
}
|
||||
const parsedValue = Number.parseInt(rawValue, 10);
|
||||
return Number.isFinite(parsedValue) && parsedValue >= 1 ? parsedValue : fallback;
|
||||
}
|
||||
|
||||
function resolveManagedNpmPeerTraversalLimits(): ManagedNpmPeerTraversalLimits {
|
||||
return {
|
||||
maxDepth: readPositiveIntegerEnv(
|
||||
"OPENCLAW_INSTALL_SCAN_MAX_DEPTH",
|
||||
DEFAULT_MANAGED_NPM_PEER_TRAVERSAL_LIMITS.maxDepth,
|
||||
),
|
||||
maxDirectories: readPositiveIntegerEnv(
|
||||
"OPENCLAW_INSTALL_SCAN_MAX_DIRECTORIES",
|
||||
DEFAULT_MANAGED_NPM_PEER_TRAVERSAL_LIMITS.maxDirectories,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function isSamePathOrInside(parentPath: string, candidatePath: string): boolean {
|
||||
const relative = path.relative(parentPath, candidatePath);
|
||||
return (
|
||||
relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function isSafePackageName(name: string): boolean {
|
||||
if (name.startsWith("@")) {
|
||||
const parts = name.split("/");
|
||||
@@ -124,6 +85,10 @@ function isSafePackageName(name: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isManagedNpmRootHostPeerPackageName(name: string): boolean {
|
||||
return name === "openclaw";
|
||||
}
|
||||
|
||||
function readOverrideRecord(value: unknown): Record<string, unknown> {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
@@ -315,25 +280,6 @@ export async function upsertManagedNpmRootDependency(params: {
|
||||
await writeJson(manifestPath, next, { trailingNewline: true });
|
||||
}
|
||||
|
||||
async function readPackageJsonIfExists(
|
||||
packageDir: string,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const parsed = JSON.parse(await fs.readFile(path.join(packageDir, "package.json"), "utf8"));
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function readPackageVersion(packageDir: string): Promise<string | undefined> {
|
||||
const parsed = await readPackageJsonIfExists(packageDir);
|
||||
return parsed ? readOptionalString(parsed.version) : undefined;
|
||||
}
|
||||
|
||||
function isOptionalPeerDependency(manifest: Record<string, unknown>, peerName: string): boolean {
|
||||
if (!isRecord(manifest.peerDependenciesMeta)) {
|
||||
return false;
|
||||
@@ -342,127 +288,348 @@ function isOptionalPeerDependency(manifest: Record<string, unknown>, peerName: s
|
||||
return isRecord(peerMetadata) && peerMetadata.optional === true;
|
||||
}
|
||||
|
||||
async function listNodeModulesPackageDirs(nodeModulesDir: string): Promise<string[]> {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(nodeModulesDir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
if (
|
||||
(err as NodeJS.ErrnoException).code === "ENOENT" ||
|
||||
(err as NodeJS.ErrnoException).code === "ENOTDIR"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const packageDirs: string[] = [];
|
||||
for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) {
|
||||
if (entry.name === ".bin" || entry.name === "openclaw" || entry.name.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
const entryPath = path.join(nodeModulesDir, entry.name);
|
||||
if (entry.name.startsWith("@") && entry.isDirectory()) {
|
||||
let scopedEntries: Dirent[];
|
||||
try {
|
||||
scopedEntries = await fs.readdir(entryPath, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
if (
|
||||
(err as NodeJS.ErrnoException).code === "ENOENT" ||
|
||||
(err as NodeJS.ErrnoException).code === "ENOTDIR"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
for (const scopedEntry of scopedEntries.toSorted((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
)) {
|
||||
if (scopedEntry.isDirectory() || scopedEntry.isSymbolicLink()) {
|
||||
packageDirs.push(path.join(entryPath, scopedEntry.name));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
packageDirs.push(entryPath);
|
||||
}
|
||||
}
|
||||
return packageDirs;
|
||||
function isDevOnlyLockPackage(value: unknown): boolean {
|
||||
return isRecord(value) && value.dev === true;
|
||||
}
|
||||
|
||||
async function collectManagedNpmRootPeerDependencyPins(params: {
|
||||
npmRoot: string;
|
||||
}): Promise<Record<string, string>> {
|
||||
const pins = new Map<string, string>();
|
||||
const limits = resolveManagedNpmPeerTraversalLimits();
|
||||
const boundaryRealPath = await fs
|
||||
.realpath(params.npmRoot)
|
||||
.catch(() => path.resolve(params.npmRoot));
|
||||
const queue = (await listNodeModulesPackageDirs(path.join(params.npmRoot, "node_modules"))).map(
|
||||
(packageDir) => ({ depth: 0, packageDir }),
|
||||
function readStringList(value: unknown): string[] | undefined {
|
||||
if (typeof value === "string") {
|
||||
return [value];
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const values = value.filter((entry): entry is string => typeof entry === "string");
|
||||
return values.length > 0 ? values : undefined;
|
||||
}
|
||||
|
||||
function matchesNpmPlatformList(value: string | undefined, list: string[] | undefined): boolean {
|
||||
if (!list) {
|
||||
return true;
|
||||
}
|
||||
if (list.length === 1 && list[0] === "any") {
|
||||
return true;
|
||||
}
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
let negated = 0;
|
||||
let matched = false;
|
||||
for (const entry of list) {
|
||||
const negate = entry.startsWith("!");
|
||||
const test = negate ? entry.slice(1) : entry;
|
||||
if (negate) {
|
||||
negated += 1;
|
||||
if (value === test) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
matched = matched || value === test;
|
||||
}
|
||||
}
|
||||
return matched || negated === list.length;
|
||||
}
|
||||
|
||||
function resolveCurrentLibc(): string | undefined {
|
||||
if (process.platform !== "linux") {
|
||||
return undefined;
|
||||
}
|
||||
const report: unknown = process.report?.getReport();
|
||||
const header = isRecord(report) ? report.header : undefined;
|
||||
if (isRecord(header) && header.glibcVersionRuntime) {
|
||||
return "glibc";
|
||||
}
|
||||
const sharedObjects = isRecord(report) ? report.sharedObjects : undefined;
|
||||
if (
|
||||
Array.isArray(sharedObjects) &&
|
||||
sharedObjects.some((file) => typeof file === "string" && file.includes("musl"))
|
||||
) {
|
||||
return "musl";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isUnsupportedOptionalLockPackage(value: unknown): boolean {
|
||||
if (!isRecord(value) || value.optional !== true) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
!matchesNpmPlatformList(process.platform, readStringList(value.os)) ||
|
||||
!matchesNpmPlatformList(process.arch, readStringList(value.cpu)) ||
|
||||
!matchesNpmPlatformList(resolveCurrentLibc(), readStringList(value.libc))
|
||||
);
|
||||
const visitedRealPaths = new Set<string>();
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const current = queue[index];
|
||||
if (!current) {
|
||||
}
|
||||
|
||||
function readLockPackageName(location: string, value: unknown): string | undefined {
|
||||
if (isRecord(value)) {
|
||||
const packageName = readOptionalString(value.name);
|
||||
if (packageName) {
|
||||
return packageName;
|
||||
}
|
||||
}
|
||||
const parts = location.split("/");
|
||||
for (let index = parts.length - 1; index >= 0; index -= 1) {
|
||||
if (parts[index] !== "node_modules") {
|
||||
continue;
|
||||
}
|
||||
if (current.depth > limits.maxDepth) {
|
||||
throw new Error(
|
||||
`managed npm peer dependency scan exceeded max depth (${limits.maxDepth}) at ${current.packageDir}`,
|
||||
);
|
||||
const first = parts[index + 1];
|
||||
if (!first) {
|
||||
return undefined;
|
||||
}
|
||||
const packageDirRealPath = await fs
|
||||
.realpath(current.packageDir)
|
||||
.catch(() => path.resolve(current.packageDir));
|
||||
if (!isSamePathOrInside(boundaryRealPath, packageDirRealPath)) {
|
||||
throw new Error(
|
||||
`managed npm peer dependency scan found package outside managed npm root at ${current.packageDir}`,
|
||||
);
|
||||
if (!first.startsWith("@")) {
|
||||
return first;
|
||||
}
|
||||
if (visitedRealPaths.has(packageDirRealPath)) {
|
||||
const second = parts[index + 2];
|
||||
return second ? `${first}/${second}` : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findLockPackageVersion(params: {
|
||||
lockfile: ManagedNpmRootLockfile;
|
||||
packageName: string;
|
||||
}): string | undefined {
|
||||
if (!isRecord(params.lockfile.packages)) {
|
||||
return undefined;
|
||||
}
|
||||
const preferredLocation = `node_modules/${params.packageName}`;
|
||||
const preferredPackage = params.lockfile.packages[preferredLocation];
|
||||
if (
|
||||
isRecord(preferredPackage) &&
|
||||
!isDevOnlyLockPackage(preferredPackage) &&
|
||||
!isUnsupportedOptionalLockPackage(preferredPackage)
|
||||
) {
|
||||
const preferredVersion = readOptionalString(preferredPackage.version);
|
||||
if (preferredVersion) {
|
||||
return preferredVersion;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectNpmLockPeerDependencyPins(params: {
|
||||
lockfile: ManagedNpmRootLockfile;
|
||||
}): Record<string, string> {
|
||||
const pins = new Map<string, string>();
|
||||
const packages = isRecord(params.lockfile.packages) ? params.lockfile.packages : {};
|
||||
for (const [location, value] of Object.entries(packages).toSorted(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
)) {
|
||||
if (
|
||||
location === "" ||
|
||||
!isRecord(value) ||
|
||||
isDevOnlyLockPackage(value) ||
|
||||
isUnsupportedOptionalLockPackage(value)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
visitedRealPaths.add(packageDirRealPath);
|
||||
if (visitedRealPaths.size > limits.maxDirectories) {
|
||||
throw new Error(
|
||||
`managed npm peer dependency scan exceeded max packages (${limits.maxDirectories}) under ${params.npmRoot}`,
|
||||
);
|
||||
const packageName = readLockPackageName(location, value);
|
||||
if (packageName && isManagedNpmRootHostPeerPackageName(packageName)) {
|
||||
continue;
|
||||
}
|
||||
const packageDir = current.packageDir;
|
||||
const manifest = await readPackageJsonIfExists(packageDir);
|
||||
if (manifest) {
|
||||
if (readOptionalString(manifest.name) === "openclaw") {
|
||||
const peerDependencies = readDependencyRecord(value.peerDependencies);
|
||||
for (const [peerName, peerRange] of Object.entries(peerDependencies)) {
|
||||
if (
|
||||
isManagedNpmRootHostPeerPackageName(peerName) ||
|
||||
pins.has(peerName) ||
|
||||
!isSafePackageName(peerName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const peerDependencies = readDependencyRecord(manifest.peerDependencies);
|
||||
for (const [peerName, peerRange] of Object.entries(peerDependencies)) {
|
||||
if (peerName === "openclaw" || pins.has(peerName) || !isSafePackageName(peerName)) {
|
||||
continue;
|
||||
}
|
||||
const installedVersion = await readPackageVersion(
|
||||
path.join(params.npmRoot, "node_modules", ...peerName.split("/")),
|
||||
);
|
||||
if (!installedVersion && isOptionalPeerDependency(manifest, peerName)) {
|
||||
continue;
|
||||
}
|
||||
pins.set(peerName, installedVersion ?? peerRange);
|
||||
const version = findLockPackageVersion({ lockfile: params.lockfile, packageName: peerName });
|
||||
if (!version && isOptionalPeerDependency(value, peerName)) {
|
||||
continue;
|
||||
}
|
||||
pins.set(peerName, version ?? peerRange);
|
||||
}
|
||||
queue.push(
|
||||
...(await listNodeModulesPackageDirs(path.join(packageDir, "node_modules"))).map(
|
||||
(nestedPackageDir) => ({
|
||||
depth: current.depth + 1,
|
||||
packageDir: nestedPackageDir,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Object.fromEntries(
|
||||
[...pins.entries()].toSorted(([left], [right]) => left.localeCompare(right)),
|
||||
);
|
||||
}
|
||||
|
||||
async function copyPathIfExists(source: string, destination: string): Promise<void> {
|
||||
try {
|
||||
await fs.cp(source, destination, { recursive: true });
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function scrubHostPeerFromLockPackage(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
let changed = false;
|
||||
if (isRecord(value.peerDependencies) && "openclaw" in value.peerDependencies) {
|
||||
const peerDependencies = { ...value.peerDependencies };
|
||||
delete peerDependencies.openclaw;
|
||||
if (Object.keys(peerDependencies).length > 0) {
|
||||
value.peerDependencies = peerDependencies;
|
||||
} else {
|
||||
delete value.peerDependencies;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if (isRecord(value.peerDependenciesMeta) && "openclaw" in value.peerDependenciesMeta) {
|
||||
const peerDependenciesMeta = { ...value.peerDependenciesMeta };
|
||||
delete peerDependenciesMeta.openclaw;
|
||||
if (Object.keys(peerDependenciesMeta).length > 0) {
|
||||
value.peerDependenciesMeta = peerDependenciesMeta;
|
||||
} else {
|
||||
delete value.peerDependenciesMeta;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
async function scrubHostPeerFromTempPackageLock(lockPath: string): Promise<void> {
|
||||
const parsed = await readJsonIfExists<unknown>(lockPath);
|
||||
if (!isRecord(parsed)) {
|
||||
return;
|
||||
}
|
||||
let changed = false;
|
||||
if (isRecord(parsed.packages)) {
|
||||
for (const value of Object.values(parsed.packages)) {
|
||||
changed = scrubHostPeerFromLockPackage(value) || changed;
|
||||
}
|
||||
}
|
||||
if (isRecord(parsed.dependencies)) {
|
||||
for (const value of Object.values(parsed.dependencies)) {
|
||||
changed = scrubHostPeerFromLockPackage(value) || changed;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
await writeJson(lockPath, parsed, { trailingNewline: true });
|
||||
}
|
||||
}
|
||||
|
||||
function collectExistingManagedPeerDependencyPins(
|
||||
dependencies: Record<string, string>,
|
||||
previousManagedPeerDependencies: string[],
|
||||
): Record<string, string> {
|
||||
const pins: Record<string, string> = {};
|
||||
for (const packageName of previousManagedPeerDependencies) {
|
||||
const dependencySpec = dependencies[packageName];
|
||||
if (dependencySpec) {
|
||||
pins[packageName] = dependencySpec;
|
||||
}
|
||||
}
|
||||
return pins;
|
||||
}
|
||||
|
||||
function isHostPeerResolutionFailure(
|
||||
result: Awaited<ReturnType<ManagedNpmRootRunCommand>>,
|
||||
): boolean {
|
||||
const output = `${result.stdout}\n${result.stderr}`;
|
||||
return /(^|[^@\w.-])openclaw(?=$|[@\s:,"'])/i.test(output);
|
||||
}
|
||||
|
||||
function createManagedNpmPeerPlanArgs(params?: {
|
||||
force?: boolean;
|
||||
legacyPeerDeps?: boolean;
|
||||
}): string[] {
|
||||
return [
|
||||
"npm",
|
||||
"install",
|
||||
"--package-lock-only",
|
||||
...(params?.force ? ["--force"] : []),
|
||||
...createSafeNpmInstallArgs({
|
||||
omitDev: true,
|
||||
omitPeer: true,
|
||||
legacyPeerDeps: params?.legacyPeerDeps,
|
||||
loglevel: "error",
|
||||
ignoreWorkspaces: true,
|
||||
noAudit: true,
|
||||
noFund: true,
|
||||
}).slice(1),
|
||||
];
|
||||
}
|
||||
|
||||
async function collectNpmResolvedManagedNpmRootPeerDependencyPins(params: {
|
||||
npmRoot: string;
|
||||
runCommand?: ManagedNpmRootRunCommand;
|
||||
timeoutMs?: number;
|
||||
}): Promise<Record<string, string>> {
|
||||
const manifest = await readManagedNpmRootManifest(path.join(params.npmRoot, "package.json"));
|
||||
const dependencies = readDependencyRecord(manifest.dependencies);
|
||||
const previousManagedPeerDependencies = readManagedPeerDependencyKeys(manifest.openclaw);
|
||||
const fallbackPeerPins = collectExistingManagedPeerDependencyPins(
|
||||
dependencies,
|
||||
previousManagedPeerDependencies,
|
||||
);
|
||||
for (const packageName of previousManagedPeerDependencies) {
|
||||
delete dependencies[packageName];
|
||||
}
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-managed-peer-plan-"));
|
||||
try {
|
||||
delete dependencies.openclaw;
|
||||
await writeJson(
|
||||
path.join(tempRoot, "package.json"),
|
||||
{
|
||||
...manifest,
|
||||
private: true,
|
||||
dependencies,
|
||||
},
|
||||
{ trailingNewline: true },
|
||||
);
|
||||
await copyPathIfExists(
|
||||
path.join(params.npmRoot, "package-lock.json"),
|
||||
path.join(tempRoot, "package-lock.json"),
|
||||
);
|
||||
const tempLockPath = path.join(tempRoot, "package-lock.json");
|
||||
await scrubHostPeerFromTempPackageLock(tempLockPath);
|
||||
await copyPathIfExists(path.join(params.npmRoot, ".npmrc"), path.join(tempRoot, ".npmrc"));
|
||||
await copyPathIfExists(
|
||||
path.join(params.npmRoot, "_openclaw-pack-archives"),
|
||||
path.join(tempRoot, "_openclaw-pack-archives"),
|
||||
);
|
||||
|
||||
const command = params.runCommand ?? runCommandWithTimeout;
|
||||
const npmPeerPlanArgs = createManagedNpmPeerPlanArgs({ force: true });
|
||||
const npmPlanOptions = {
|
||||
cwd: tempRoot,
|
||||
timeoutMs: Math.max(params.timeoutMs ?? 300_000, 300_000),
|
||||
env: createSafeNpmInstallEnv(process.env, {
|
||||
legacyPeerDeps: false,
|
||||
packageLock: true,
|
||||
quiet: true,
|
||||
}),
|
||||
};
|
||||
const result = await command(npmPeerPlanArgs, npmPlanOptions);
|
||||
if (result.code !== 0) {
|
||||
if (isHostPeerResolutionFailure(result)) {
|
||||
const hostPeerFallbackArgs = createManagedNpmPeerPlanArgs({
|
||||
force: true,
|
||||
legacyPeerDeps: true,
|
||||
});
|
||||
const hostPeerFallbackOptions = {
|
||||
...npmPlanOptions,
|
||||
env: createSafeNpmInstallEnv(process.env, {
|
||||
legacyPeerDeps: true,
|
||||
packageLock: true,
|
||||
quiet: true,
|
||||
}),
|
||||
};
|
||||
const hostPeerFallbackResult = await command(hostPeerFallbackArgs, hostPeerFallbackOptions);
|
||||
if (hostPeerFallbackResult.code === 0) {
|
||||
const lockfile = await readManagedNpmRootManifest(tempLockPath);
|
||||
return collectNpmLockPeerDependencyPins({ lockfile });
|
||||
}
|
||||
}
|
||||
return fallbackPeerPins;
|
||||
}
|
||||
const lockfile = await readManagedNpmRootManifest(tempLockPath);
|
||||
return collectNpmLockPeerDependencyPins({ lockfile });
|
||||
} finally {
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function readManagedNpmRootPeerDependencySnapshot(params: {
|
||||
npmRoot: string;
|
||||
}): Promise<ManagedNpmRootPeerDependencySnapshot> {
|
||||
@@ -516,13 +683,19 @@ export async function syncManagedNpmRootPeerDependencies(params: {
|
||||
npmRoot: string;
|
||||
managedOverrides?: Record<string, unknown>;
|
||||
omitUnsupportedManagedOverrides?: boolean;
|
||||
runCommand?: ManagedNpmRootRunCommand;
|
||||
timeoutMs?: number;
|
||||
}): Promise<boolean> {
|
||||
const manifestPath = path.join(params.npmRoot, "package.json");
|
||||
const manifest = await readManagedNpmRootManifest(manifestPath);
|
||||
const dependencies = readDependencyRecord(manifest.dependencies);
|
||||
const previousManagedPeerDependencies = readManagedPeerDependencyKeys(manifest.openclaw);
|
||||
const previousManagedPeerDependencySet = new Set(previousManagedPeerDependencies);
|
||||
const peerPins = await collectManagedNpmRootPeerDependencyPins({ npmRoot: params.npmRoot });
|
||||
const peerPins = await collectNpmResolvedManagedNpmRootPeerDependencyPins({
|
||||
npmRoot: params.npmRoot,
|
||||
runCommand: params.runCommand,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
const nextDependencies = { ...dependencies };
|
||||
for (const packageName of previousManagedPeerDependencies) {
|
||||
if (!Object.hasOwn(peerPins, packageName)) {
|
||||
@@ -578,12 +751,22 @@ export async function syncManagedNpmRootPeerDependencies(params: {
|
||||
|
||||
export async function repairManagedNpmRootOpenClawPeer(params: {
|
||||
npmRoot: string;
|
||||
packageRoot?: string | null;
|
||||
timeoutMs?: number;
|
||||
logger?: ManagedNpmRootLogger;
|
||||
runCommand?: ManagedNpmRootRunCommand;
|
||||
}): Promise<boolean> {
|
||||
await fs.mkdir(params.npmRoot, { recursive: true });
|
||||
|
||||
if (
|
||||
await managedNpmRootOpenClawPackageIsActiveHost({
|
||||
npmRoot: params.npmRoot,
|
||||
packageRoot: params.packageRoot,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const manifestPath = path.join(params.npmRoot, "package.json");
|
||||
const manifest = await readManagedNpmRootManifest(manifestPath);
|
||||
const dependencies = readDependencyRecord(manifest.dependencies);
|
||||
@@ -640,6 +823,30 @@ export async function repairManagedNpmRootOpenClawPeer(params: {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function managedNpmRootOpenClawPackageIsActiveHost(params: {
|
||||
npmRoot: string;
|
||||
packageRoot?: string | null;
|
||||
}): Promise<boolean> {
|
||||
const packageRoot =
|
||||
params.packageRoot === undefined
|
||||
? resolveOpenClawPackageRootSync({
|
||||
argv1: process.argv[1],
|
||||
moduleUrl: import.meta.url,
|
||||
cwd: process.cwd(),
|
||||
})
|
||||
: params.packageRoot;
|
||||
if (!packageRoot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const managedOpenClawPackageDir = path.join(params.npmRoot, "node_modules", "openclaw");
|
||||
const [hostPackageRoot, managedPackageRoot] = await Promise.all([
|
||||
realpathIfExists(packageRoot),
|
||||
realpathIfExists(managedOpenClawPackageDir),
|
||||
]);
|
||||
return hostPackageRoot !== null && hostPackageRoot === managedPackageRoot;
|
||||
}
|
||||
|
||||
async function managedNpmRootLockfileHasOpenClawPeer(npmRoot: string): Promise<boolean> {
|
||||
const lockPath = path.join(npmRoot, "package-lock.json");
|
||||
try {
|
||||
@@ -666,6 +873,17 @@ async function managedNpmRootLockfileHasOpenClawPeer(npmRoot: string): Promise<b
|
||||
}
|
||||
}
|
||||
|
||||
async function realpathIfExists(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await fs.realpath(filePath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(filePath: string): Promise<boolean> {
|
||||
return await fs
|
||||
.lstat(filePath)
|
||||
|
||||
@@ -10,7 +10,9 @@ import { installPluginFromNpmSpec } from "./install.js";
|
||||
|
||||
type PackedVersion = {
|
||||
archive: Buffer;
|
||||
dependencies?: Record<string, string>;
|
||||
integrity: string;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
|
||||
shasum: string;
|
||||
@@ -46,7 +48,9 @@ async function makeTempDir(label: string): Promise<string> {
|
||||
}
|
||||
|
||||
async function packPlugin(params: {
|
||||
dependencies?: Record<string, string>;
|
||||
packageName: string;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
|
||||
pluginId: string;
|
||||
@@ -70,6 +74,10 @@ async function packPlugin(params: {
|
||||
version: params.version,
|
||||
type: "module",
|
||||
openclaw: { extensions: ["./dist/index.js"] },
|
||||
...(params.dependencies ? { dependencies: params.dependencies } : {}),
|
||||
...(params.optionalDependencies
|
||||
? { optionalDependencies: params.optionalDependencies }
|
||||
: {}),
|
||||
...(params.peerDependencies
|
||||
? {
|
||||
peerDependencies: params.peerDependencies,
|
||||
@@ -114,7 +122,9 @@ async function packPlugin(params: {
|
||||
const archive = await fs.readFile(path.join(params.rootDir, tarballName));
|
||||
return {
|
||||
archive,
|
||||
...(params.dependencies ? { dependencies: params.dependencies } : {}),
|
||||
integrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`,
|
||||
...(params.optionalDependencies ? { optionalDependencies: params.optionalDependencies } : {}),
|
||||
...(params.peerDependencies ? { peerDependencies: params.peerDependencies } : {}),
|
||||
...(peerDependenciesMeta ? { peerDependenciesMeta } : {}),
|
||||
shasum: crypto.createHash("sha1").update(archive).digest("hex"),
|
||||
@@ -157,6 +167,10 @@ async function startStaticRegistry(
|
||||
{
|
||||
name: pkg.packageName,
|
||||
version,
|
||||
...(entry.dependencies ? { dependencies: entry.dependencies } : {}),
|
||||
...(entry.optionalDependencies
|
||||
? { optionalDependencies: entry.optionalDependencies }
|
||||
: {}),
|
||||
...(entry.peerDependencies ? { peerDependencies: entry.peerDependencies } : {}),
|
||||
...(entry.peerDependenciesMeta
|
||||
? { peerDependenciesMeta: entry.peerDependenciesMeta }
|
||||
@@ -444,6 +458,82 @@ describe("installPluginFromNpmSpec e2e", () => {
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("plans peers from installed optional dependencies", async () => {
|
||||
const rootDir = await makeTempDir("npm-plugin-optional-peer-e2e");
|
||||
const npmRoot = path.join(rootDir, "managed-npm");
|
||||
const pluginWithOptionalDependency = `optional-owner-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
const optionalDependency = `optional-dep-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
const runtimePeer = `optional-peer-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
const registry = await startStaticRegistry([
|
||||
{
|
||||
packageName: pluginWithOptionalDependency,
|
||||
latest: "1.0.0",
|
||||
versions: [
|
||||
await packPlugin({
|
||||
packageName: pluginWithOptionalDependency,
|
||||
optionalDependencies: { [optionalDependency]: "1.0.0" },
|
||||
pluginId: pluginWithOptionalDependency,
|
||||
version: "1.0.0",
|
||||
rootDir,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
packageName: optionalDependency,
|
||||
latest: "1.0.0",
|
||||
versions: [
|
||||
await packPlugin({
|
||||
packageName: optionalDependency,
|
||||
peerDependencies: { [runtimePeer]: "^1.0.0" },
|
||||
peerDependenciesMeta: {},
|
||||
pluginId: optionalDependency,
|
||||
version: "1.0.0",
|
||||
rootDir,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
packageName: runtimePeer,
|
||||
latest: "1.0.0",
|
||||
versions: [
|
||||
await packPlugin({
|
||||
packageName: runtimePeer,
|
||||
pluginId: runtimePeer,
|
||||
version: "1.0.0",
|
||||
rootDir,
|
||||
}),
|
||||
],
|
||||
},
|
||||
]);
|
||||
process.env.NPM_CONFIG_REGISTRY = registry;
|
||||
process.env.npm_config_registry = registry;
|
||||
|
||||
const result = await installPluginFromNpmSpec({
|
||||
spec: `${pluginWithOptionalDependency}@1.0.0`,
|
||||
npmDir: npmRoot,
|
||||
logger: { info: () => {}, warn: () => {} },
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
await expect(
|
||||
fs.lstat(path.join(npmRoot, "node_modules", optionalDependency, "package.json")),
|
||||
).resolves.toBeTruthy();
|
||||
await expect(
|
||||
fs.lstat(path.join(npmRoot, "node_modules", runtimePeer, "package.json")),
|
||||
).resolves.toBeTruthy();
|
||||
const rootManifest = JSON.parse(
|
||||
await fs.readFile(path.join(npmRoot, "package.json"), "utf8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
openclaw?: { managedPeerDependencies?: string[] };
|
||||
};
|
||||
expect(rootManifest.dependencies?.[runtimePeer]).toBe("1.0.0");
|
||||
expect(rootManifest.openclaw?.managedPeerDependencies ?? []).toContain(runtimePeer);
|
||||
});
|
||||
|
||||
it("repairs pre-existing peer dependencies during later installs", async () => {
|
||||
const rootDir = await makeTempDir("npm-plugin-repaired-peer-scan-e2e");
|
||||
const npmRoot = path.join(rootDir, "managed-npm");
|
||||
@@ -548,7 +638,7 @@ describe("installPluginFromNpmSpec e2e", () => {
|
||||
openclaw?: { managedPeerDependencies?: string[] };
|
||||
};
|
||||
expect(rootManifest.dependencies?.[laterPlugin]).toBe("1.0.0");
|
||||
expect(rootManifest.dependencies?.[runtimePeer]).toBe("^1.0.0");
|
||||
expect(rootManifest.dependencies?.[runtimePeer]).toBe("1.0.0");
|
||||
expect(rootManifest.openclaw?.managedPeerDependencies ?? []).toContain(runtimePeer);
|
||||
});
|
||||
|
||||
@@ -692,6 +782,52 @@ describe("installPluginFromNpmSpec e2e", () => {
|
||||
).rejects.toHaveProperty("code", "ENOENT");
|
||||
});
|
||||
|
||||
it("falls back to the legacy install path 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)}`;
|
||||
const missingPeer = `missing-peer-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
const registry = await startStaticRegistry([
|
||||
{
|
||||
packageName: blockedPlugin,
|
||||
latest: "1.0.0",
|
||||
versions: [
|
||||
await packPlugin({
|
||||
packageName: blockedPlugin,
|
||||
peerDependencies: { [missingPeer]: "^1.0.0" },
|
||||
peerDependenciesMeta: {},
|
||||
pluginId: blockedPlugin,
|
||||
version: "1.0.0",
|
||||
rootDir,
|
||||
}),
|
||||
],
|
||||
},
|
||||
]);
|
||||
process.env.NPM_CONFIG_REGISTRY = registry;
|
||||
process.env.npm_config_registry = registry;
|
||||
|
||||
const result = await installPluginFromNpmSpec({
|
||||
spec: `${blockedPlugin}@1.0.0`,
|
||||
npmDir: npmRoot,
|
||||
logger: { info: () => {}, warn: () => {} },
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const rootManifest = JSON.parse(
|
||||
await fs.readFile(path.join(npmRoot, "package.json"), "utf8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
openclaw?: { managedPeerDependencies?: string[] };
|
||||
};
|
||||
expect(rootManifest.dependencies?.[blockedPlugin]).toBe("1.0.0");
|
||||
expect(rootManifest.dependencies?.[missingPeer]).toBeUndefined();
|
||||
expect(rootManifest.openclaw?.managedPeerDependencies ?? []).not.toContain(missingPeer);
|
||||
await expect(
|
||||
fs.lstat(path.join(npmRoot, "node_modules", blockedPlugin, "package.json")),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not take ownership of an existing root dependency observed as a peer", async () => {
|
||||
const rootDir = await makeTempDir("npm-plugin-peer-existing-root-e2e");
|
||||
const npmRoot = path.join(rootDir, "managed-npm");
|
||||
|
||||
@@ -57,10 +57,20 @@ function resolveManagedFileDependency(npmRoot: string, dependencySpec: string):
|
||||
return path.isAbsolute(rawPath) ? rawPath : path.resolve(npmRoot, rawPath);
|
||||
}
|
||||
|
||||
function isNpmInstallCommand(argv: unknown): argv is string[] {
|
||||
return Array.isArray(argv) && argv[0] === "npm" && argv[1] === "install";
|
||||
}
|
||||
|
||||
function isNpmPeerPlannerInstallCommand(argv: unknown): argv is string[] {
|
||||
return isNpmInstallCommand(argv) && argv.includes("--package-lock-only");
|
||||
}
|
||||
|
||||
function isManagedNpmInstallCommand(argv: unknown): argv is string[] {
|
||||
return isNpmInstallCommand(argv) && !isNpmPeerPlannerInstallCommand(argv);
|
||||
}
|
||||
|
||||
function expectNpmInstallIntoRoot(params: { calls: unknown[][]; npmRoot: string }) {
|
||||
const installCalls = params.calls.filter(
|
||||
(call) => Array.isArray(call[0]) && call[0][0] === "npm" && call[0][1] === "install",
|
||||
);
|
||||
const installCalls = params.calls.filter((call) => isManagedNpmInstallCommand(call[0]));
|
||||
expect(installCalls).toHaveLength(1);
|
||||
expect((installCalls[0]?.[1] as { cwd?: unknown } | undefined)?.cwd).toBe(params.npmRoot);
|
||||
expect(installCalls[0]?.[0]).toEqual([
|
||||
@@ -291,10 +301,29 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) {
|
||||
JSON.stringify(versionsPackage.versions ?? [versionsPackage.version]),
|
||||
);
|
||||
}
|
||||
if (argv[0] === "npm" && argv[1] === "install") {
|
||||
if (isNpmPeerPlannerInstallCommand(argv)) {
|
||||
const npmRoot = options?.cwd;
|
||||
if (!npmRoot) {
|
||||
throw new Error(`unexpected npm install command: ${argv.join(" ")}`);
|
||||
throw new Error(`unexpected npm peer planner command: ${argv.join(" ")}`);
|
||||
}
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(npmRoot, "package.json"), "utf8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
writeNpmRootPackageLock({
|
||||
npmRoot,
|
||||
dependencies: manifest.dependencies ?? {},
|
||||
packages: Object.keys(manifest.dependencies ?? {})
|
||||
.map((packageName) => packagesByName.get(packageName))
|
||||
.filter((pkg): pkg is MockNpmPackage => Boolean(pkg)),
|
||||
});
|
||||
return successfulSpawn();
|
||||
}
|
||||
if (isManagedNpmInstallCommand(argv)) {
|
||||
const npmRoot = options?.cwd;
|
||||
if (!npmRoot) {
|
||||
throw new Error(`unexpected npm install command: ${(argv as string[]).join(" ")}`);
|
||||
}
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(npmRoot, "package.json"), "utf8"),
|
||||
@@ -353,11 +382,11 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) {
|
||||
return successfulSpawn();
|
||||
}
|
||||
if (argv[0] === "npm" && argv[1] === "uninstall") {
|
||||
const packageName = argv.at(-1);
|
||||
const packageName = (argv as string[]).at(-1);
|
||||
if (packageName === "openclaw") {
|
||||
const npmRoot = options?.cwd;
|
||||
if (!npmRoot) {
|
||||
throw new Error(`unexpected npm uninstall command: ${argv.join(" ")}`);
|
||||
throw new Error(`unexpected npm uninstall command: ${(argv as string[]).join(" ")}`);
|
||||
}
|
||||
fs.rmSync(path.join(npmRoot, "node_modules", "openclaw"), {
|
||||
recursive: true,
|
||||
@@ -375,7 +404,7 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) {
|
||||
});
|
||||
return successfulSpawn();
|
||||
}
|
||||
throw new Error(`unexpected command: ${argv.join(" ")}`);
|
||||
throw new Error(`unexpected command: ${(argv as string[]).join(" ")}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -949,6 +978,89 @@ describe("installPluginFromNpmSpec", () => {
|
||||
expect(lockfile.dependencies?.openclaw).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves the active host openclaw runtime package during npm plugin installs", async () => {
|
||||
const stateDir = suiteTempRootTracker.makeTempDir();
|
||||
const npmRoot = path.join(stateDir, "npm");
|
||||
const hostPackageRoot = path.join(npmRoot, "node_modules", "openclaw");
|
||||
fs.mkdirSync(hostPackageRoot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(npmRoot, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
private: true,
|
||||
dependencies: {
|
||||
openclaw: "2026.5.12-beta.6",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(npmRoot, "package-lock.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"": {
|
||||
dependencies: {
|
||||
openclaw: "2026.5.12-beta.6",
|
||||
},
|
||||
},
|
||||
"node_modules/openclaw": {
|
||||
version: "2026.5.12-beta.6",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(hostPackageRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "openclaw",
|
||||
version: "2026.5.12-beta.6",
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
resolveOpenClawPackageRootSyncMock.mockReturnValue(hostPackageRoot);
|
||||
mockNpmViewAndInstall({
|
||||
spec: "@xdarkicex/openclaw-memory-libravdb@1.4.69",
|
||||
packageName: "@xdarkicex/openclaw-memory-libravdb",
|
||||
version: "1.4.69",
|
||||
pluginId: "libravdb-memory",
|
||||
npmRoot,
|
||||
expectedDependencySpec: "1.4.69",
|
||||
});
|
||||
|
||||
const result = await installPluginFromNpmSpec({
|
||||
spec: "@xdarkicex/openclaw-memory-libravdb@1.4.69",
|
||||
npmDir: npmRoot,
|
||||
logger: { info: () => {}, warn: () => {} },
|
||||
});
|
||||
|
||||
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");
|
||||
expect(fs.existsSync(hostPackageRoot)).toBe(true);
|
||||
expect(
|
||||
runCommandWithTimeoutMock.mock.calls.some(
|
||||
([argv]) =>
|
||||
Array.isArray(argv) &&
|
||||
argv[0] === "npm" &&
|
||||
argv[1] === "uninstall" &&
|
||||
argv.includes("openclaw"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows npm-spec installs with dangerous code patterns when forced unsafe install is set", async () => {
|
||||
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
|
||||
const warnings: string[] = [];
|
||||
@@ -1000,38 +1112,57 @@ describe("installPluginFromNpmSpec", () => {
|
||||
"utf8",
|
||||
);
|
||||
fs.symlinkSync(suiteTempRootTracker.makeTempDir(), peerLink, "junction");
|
||||
runCommandWithTimeoutMock.mockImplementation(async (argv: string[]) => {
|
||||
if (JSON.stringify(argv) === JSON.stringify(npmViewArgv("@openclaw/voice-call@0.0.1"))) {
|
||||
return successfulSpawn(
|
||||
JSON.stringify({
|
||||
name: "@openclaw/voice-call",
|
||||
version: "0.0.1",
|
||||
dist: {
|
||||
integrity: "sha512-plugin-test",
|
||||
shasum: "pluginshasum",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (argv[0] === "npm" && argv[1] === "install") {
|
||||
fs.rmSync(peerLink, { recursive: true, force: true });
|
||||
return {
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "registry unavailable",
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit" as const,
|
||||
};
|
||||
}
|
||||
if (argv[0] === "npm" && argv[1] === "uninstall") {
|
||||
if (!argv.includes("--legacy-peer-deps")) {
|
||||
fs.mkdirSync(path.join(npmRoot, "node_modules", "openclaw"), { recursive: true });
|
||||
runCommandWithTimeoutMock.mockImplementation(
|
||||
async (argv: string[], options?: { cwd?: string }) => {
|
||||
if (JSON.stringify(argv) === JSON.stringify(npmViewArgv("@openclaw/voice-call@0.0.1"))) {
|
||||
return successfulSpawn(
|
||||
JSON.stringify({
|
||||
name: "@openclaw/voice-call",
|
||||
version: "0.0.1",
|
||||
dist: {
|
||||
integrity: "sha512-plugin-test",
|
||||
shasum: "pluginshasum",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return successfulSpawn("");
|
||||
}
|
||||
throw new Error(`unexpected command: ${argv.join(" ")}`);
|
||||
});
|
||||
if (isNpmPeerPlannerInstallCommand(argv)) {
|
||||
const npmRoot = options?.cwd;
|
||||
if (!npmRoot) {
|
||||
throw new Error(`unexpected npm peer planner command: ${argv.join(" ")}`);
|
||||
}
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(npmRoot, "package.json"), "utf8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
writeNpmRootPackageLock({
|
||||
npmRoot,
|
||||
dependencies: manifest.dependencies ?? {},
|
||||
packages: [],
|
||||
});
|
||||
return successfulSpawn();
|
||||
}
|
||||
if (isManagedNpmInstallCommand(argv)) {
|
||||
fs.rmSync(peerLink, { recursive: true, force: true });
|
||||
return {
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "registry unavailable",
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit" as const,
|
||||
};
|
||||
}
|
||||
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 });
|
||||
}
|
||||
return successfulSpawn("");
|
||||
}
|
||||
throw new Error(`unexpected command: ${(argv as string[]).join(" ")}`);
|
||||
},
|
||||
);
|
||||
|
||||
const result = await installPluginFromNpmSpec({
|
||||
spec: "@openclaw/voice-call@0.0.1",
|
||||
@@ -1087,7 +1218,7 @@ describe("installPluginFromNpmSpec", () => {
|
||||
let installAttempts = 0;
|
||||
runCommandWithTimeoutMock.mockImplementation(
|
||||
async (argv: string[], options?: { cwd?: string }) => {
|
||||
if (argv[0] === "npm" && argv[1] === "install") {
|
||||
if (isManagedNpmInstallCommand(argv)) {
|
||||
installAttempts += 1;
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(npmRoot, "package.json"), "utf8"),
|
||||
|
||||
+51
-8
@@ -635,13 +635,47 @@ async function installPluginFromManagedNpmRoot(
|
||||
const rollbackPeerDependencySnapshot = await readManagedNpmRootPeerDependencySnapshot({
|
||||
npmRoot,
|
||||
});
|
||||
const rollbackFailedManagedNpmInstall = async (error: string): Promise<InstallPluginResult> => {
|
||||
await rollbackManagedNpmPluginInstall({
|
||||
npmRoot,
|
||||
packageName: params.packageName,
|
||||
targetDir: installRoot,
|
||||
timeoutMs,
|
||||
logger,
|
||||
peerDependencySnapshot: rollbackPeerDependencySnapshot,
|
||||
});
|
||||
return { ok: false, error };
|
||||
};
|
||||
const syncManagedPeerDependenciesForInstall = async (options?: {
|
||||
omitUnsupportedManagedOverrides?: boolean;
|
||||
}): Promise<{ ok: true; changed: boolean } | { ok: false; error: string }> => {
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
changed: await syncManagedNpmRootPeerDependencies({
|
||||
npmRoot,
|
||||
managedOverrides,
|
||||
omitUnsupportedManagedOverrides: options?.omitUnsupportedManagedOverrides,
|
||||
timeoutMs,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `npm peer dependency planning failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
};
|
||||
await upsertManagedNpmRootDependency({
|
||||
npmRoot,
|
||||
packageName: params.packageName,
|
||||
dependencySpec: params.dependencySpec,
|
||||
managedOverrides,
|
||||
});
|
||||
await syncManagedNpmRootPeerDependencies({ npmRoot, managedOverrides });
|
||||
const initialPeerSync = await syncManagedPeerDependenciesForInstall();
|
||||
if (!initialPeerSync.ok) {
|
||||
return await rollbackFailedManagedNpmInstall(initialPeerSync.error);
|
||||
}
|
||||
const npmInstallArgs = [
|
||||
"npm",
|
||||
...createSafeNpmInstallArgs({
|
||||
@@ -676,6 +710,12 @@ async function installPluginFromManagedNpmRoot(
|
||||
managedOverrides,
|
||||
omitUnsupportedManagedOverrides: true,
|
||||
});
|
||||
const aliasRetryPeerSync = await syncManagedPeerDependenciesForInstall({
|
||||
omitUnsupportedManagedOverrides: true,
|
||||
});
|
||||
if (!aliasRetryPeerSync.ok) {
|
||||
return await rollbackFailedManagedNpmInstall(aliasRetryPeerSync.error);
|
||||
}
|
||||
install = await runCommandWithTimeout(npmInstallArgs, npmInstallOptions);
|
||||
}
|
||||
if (install.code !== 0) {
|
||||
@@ -694,11 +734,13 @@ async function installPluginFromManagedNpmRoot(
|
||||
}
|
||||
let settledManagedPeerDependencies = false;
|
||||
for (let peerSyncPass = 0; peerSyncPass < 10; peerSyncPass += 1) {
|
||||
const syncedPeerDependencies = await syncManagedNpmRootPeerDependencies({
|
||||
npmRoot,
|
||||
managedOverrides,
|
||||
const peerSync = await syncManagedPeerDependenciesForInstall({
|
||||
omitUnsupportedManagedOverrides,
|
||||
});
|
||||
if (!peerSync.ok) {
|
||||
return await rollbackFailedManagedNpmInstall(peerSync.error);
|
||||
}
|
||||
const syncedPeerDependencies = peerSync.changed;
|
||||
if (!syncedPeerDependencies) {
|
||||
settledManagedPeerDependencies = true;
|
||||
break;
|
||||
@@ -720,12 +762,13 @@ async function installPluginFromManagedNpmRoot(
|
||||
}
|
||||
}
|
||||
if (!settledManagedPeerDependencies) {
|
||||
const syncedPeerDependencies = await syncManagedNpmRootPeerDependencies({
|
||||
npmRoot,
|
||||
managedOverrides,
|
||||
const peerSync = await syncManagedPeerDependenciesForInstall({
|
||||
omitUnsupportedManagedOverrides,
|
||||
});
|
||||
settledManagedPeerDependencies = !syncedPeerDependencies;
|
||||
if (!peerSync.ok) {
|
||||
return await rollbackFailedManagedNpmInstall(peerSync.error);
|
||||
}
|
||||
settledManagedPeerDependencies = !peerSync.changed;
|
||||
}
|
||||
if (!settledManagedPeerDependencies) {
|
||||
await rollbackManagedNpmPluginInstall({
|
||||
|
||||
Reference in New Issue
Block a user