mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix: bypass npm freshness for managed installs (#83761)
* fix: bypass npm freshness for managed installs * test: tolerate npm config json differences * test: align npm freshness bypass expectation * fix: resolve npm config path expansions * test: tolerate npm zero config encoding --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
co-authored by
Peter Steinberger
parent
d761b98adc
commit
85a3d5312f
@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Memory/search: scan the JS-side fallback vector path (used when the sqlite-vec index is unavailable or has a mismatched dimension) in bounded rowid batches and yield to the event loop between batches so large chunk tables can no longer pin the Node.js main thread for multi-second windows. Also keeps the SQL prepared statement rooted in a local so node:sqlite cannot finalize it mid-scan under heap pressure. Fixes #81172. Thanks @dev23xyz-oss.
|
||||
- CLI/update: bypass npm freshness filters consistently during managed package and plugin installs so freshly published release plugins remain installable. Thanks @jalehman.
|
||||
- Agents/subagents: keep collect-mode announce queues batching unresolved-origin items with compatible same-route messages and resume collection after a true cross-channel drain when a later compatible batch remains. Fixes #83577.
|
||||
- Providers/Anthropic: preserve native image input for current Claude model rows when stale local catalog data marks them text-only. (#83756) Thanks @TurboTheTurtle.
|
||||
- Control UI: render live tool progress from session-scoped `session.tool` Gateway events so externally started runs show their tool cards in the active session. (#83734) Thanks @TurboTheTurtle.
|
||||
|
||||
@@ -259,7 +259,7 @@ export async function installPackageDir(params: {
|
||||
{
|
||||
timeoutMs: Math.max(params.timeoutMs, 300_000),
|
||||
cwd: stageDir,
|
||||
env: createSafeNpmInstallEnv(process.env),
|
||||
env: createSafeNpmInstallEnv(process.env, { npmConfigCwd: stageDir }),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -210,13 +210,13 @@ describe("packNpmSpecToArchive", () => {
|
||||
timeoutMs: 300_000,
|
||||
env: {
|
||||
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
|
||||
NPM_CONFIG_BEFORE: "",
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true",
|
||||
NPM_CONFIG_BEFORE: "",
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: "",
|
||||
"NPM_CONFIG_MIN-RELEASE-AGE": "",
|
||||
npm_config_before: "",
|
||||
"npm_config_min-release-age": "0",
|
||||
npm_config_min_release_age: "",
|
||||
"npm_config_min-release-age": "",
|
||||
npm_config_min_release_age: "0",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveArchiveKind } from "./archive.js";
|
||||
import { pathExists } from "./fs-safe.js";
|
||||
import { applyNpmFreshnessBypassEnv } from "./npm-install-env.js";
|
||||
import { applyNpmFreshnessBypassEnv, type NpmProjectInstallEnvOptions } from "./npm-install-env.js";
|
||||
import { withTempWorkspace } from "./private-temp-workspace.js";
|
||||
|
||||
export type NpmSpecResolution = {
|
||||
@@ -37,12 +37,14 @@ export function buildNpmResolutionFields(resolution?: NpmSpecResolution): NpmRes
|
||||
};
|
||||
}
|
||||
|
||||
export function createNpmMetadataEnv(): NodeJS.ProcessEnv {
|
||||
export function createNpmMetadataEnv(
|
||||
scope: Pick<NpmProjectInstallEnvOptions, "npmConfigCwd"> = {},
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true",
|
||||
};
|
||||
applyNpmFreshnessBypassEnv(env);
|
||||
applyNpmFreshnessBypassEnv(env, new Date(), scope);
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -286,7 +288,7 @@ export async function packNpmSpecToArchive(params: {
|
||||
{
|
||||
timeoutMs: Math.max(params.timeoutMs, 300_000),
|
||||
cwd: params.cwd,
|
||||
env: createNpmMetadataEnv(),
|
||||
env: createNpmMetadataEnv({ npmConfigCwd: params.cwd }),
|
||||
},
|
||||
);
|
||||
if (res.code !== 0) {
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withMockedPlatform, withRestoredMocks } from "../test-utils/vitest-spies.js";
|
||||
import { createNpmProjectInstallEnv } from "./npm-install-env.js";
|
||||
import { createNpmFreshnessBypassArgs, createNpmProjectInstallEnv } from "./npm-install-env.js";
|
||||
|
||||
const EXPECTED_MIN_FRESHNESS_ENV = {
|
||||
const FROZEN_NOW = new Date("2026-05-18T19:55:00.000Z");
|
||||
const EXPECTED_FRESHNESS_ENV = {
|
||||
NPM_CONFIG_BEFORE: "",
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: "",
|
||||
"NPM_CONFIG_MIN-RELEASE-AGE": "",
|
||||
npm_config_before: "",
|
||||
"npm_config_min-release-age": "0",
|
||||
npm_config_min_release_age: "",
|
||||
"npm_config_min-release-age": "",
|
||||
npm_config_min_release_age: "0",
|
||||
};
|
||||
|
||||
function readNpmConfigList(env: NodeJS.ProcessEnv): Record<string, unknown> {
|
||||
const raw = execFileSync("npm", ["config", "list", "--json"], {
|
||||
encoding: "utf-8",
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: 5_000,
|
||||
});
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function expectUnsetNpmJsonConfig(value: unknown): void {
|
||||
expect(value == null).toBe(true);
|
||||
}
|
||||
|
||||
function expectZeroNpmJsonConfig(value: unknown): void {
|
||||
expect(value === 0 || value === "0").toBe(true);
|
||||
}
|
||||
|
||||
describe("npm project install env", () => {
|
||||
it("uses an absolute POSIX script shell for npm lifecycle scripts", () => {
|
||||
withMockedPlatform("linux", () => {
|
||||
@@ -22,11 +45,15 @@ describe("npm project install env", () => {
|
||||
.mockImplementation((candidate) => candidate === "/bin/sh");
|
||||
withRestoredMocks([existsSyncSpy], () => {
|
||||
expect(
|
||||
createNpmProjectInstallEnv({
|
||||
PATH: "/tmp/openclaw-npm-global/bin",
|
||||
}),
|
||||
createNpmProjectInstallEnv(
|
||||
{
|
||||
PATH: "/tmp/openclaw-npm-global/bin",
|
||||
},
|
||||
{},
|
||||
FROZEN_NOW,
|
||||
),
|
||||
).toEqual({
|
||||
...EXPECTED_MIN_FRESHNESS_ENV,
|
||||
...EXPECTED_FRESHNESS_ENV,
|
||||
NPM_CONFIG_SCRIPT_SHELL: "/bin/sh",
|
||||
PATH: "/tmp/openclaw-npm-global/bin",
|
||||
npm_config_dry_run: "false",
|
||||
@@ -46,11 +73,15 @@ describe("npm project install env", () => {
|
||||
it("preserves explicit npm script shell config", () => {
|
||||
withMockedPlatform("linux", () => {
|
||||
expect(
|
||||
createNpmProjectInstallEnv({
|
||||
NPM_CONFIG_SCRIPT_SHELL: "/custom/sh",
|
||||
}),
|
||||
createNpmProjectInstallEnv(
|
||||
{
|
||||
NPM_CONFIG_SCRIPT_SHELL: "/custom/sh",
|
||||
},
|
||||
{},
|
||||
FROZEN_NOW,
|
||||
),
|
||||
).toEqual({
|
||||
...EXPECTED_MIN_FRESHNESS_ENV,
|
||||
...EXPECTED_FRESHNESS_ENV,
|
||||
NPM_CONFIG_SCRIPT_SHELL: "/custom/sh",
|
||||
npm_config_dry_run: "false",
|
||||
npm_config_fetch_retries: "5",
|
||||
@@ -63,11 +94,15 @@ describe("npm project install env", () => {
|
||||
npm_config_save: "false",
|
||||
});
|
||||
expect(
|
||||
createNpmProjectInstallEnv({
|
||||
npm_config_script_shell: "/custom/lower-sh",
|
||||
}),
|
||||
createNpmProjectInstallEnv(
|
||||
{
|
||||
npm_config_script_shell: "/custom/lower-sh",
|
||||
},
|
||||
{},
|
||||
FROZEN_NOW,
|
||||
),
|
||||
).toEqual({
|
||||
...EXPECTED_MIN_FRESHNESS_ENV,
|
||||
...EXPECTED_FRESHNESS_ENV,
|
||||
npm_config_dry_run: "false",
|
||||
npm_config_fetch_retries: "5",
|
||||
npm_config_fetch_retry_maxtimeout: "120000",
|
||||
@@ -83,30 +118,35 @@ describe("npm project install env", () => {
|
||||
});
|
||||
|
||||
it("bypasses npm release-age filters for OpenClaw-managed installs", () => {
|
||||
const env = createNpmProjectInstallEnv({
|
||||
NPM_CONFIG_BEFORE: "2026-01-01T00:00:00.000Z",
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: "7",
|
||||
"npm_config_min-release-age": "7",
|
||||
npm_config_before: "2026-01-01T00:00:00.000Z",
|
||||
npm_config_min_release_age: "7",
|
||||
});
|
||||
const env = createNpmProjectInstallEnv(
|
||||
{
|
||||
NPM_CONFIG_BEFORE: "2026-01-01T00:00:00.000Z",
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: "7",
|
||||
"npm_config_min-release-age": "7",
|
||||
npm_config_before: "2026-01-01T00:00:00.000Z",
|
||||
npm_config_min_release_age: "7",
|
||||
},
|
||||
{},
|
||||
FROZEN_NOW,
|
||||
);
|
||||
|
||||
expect(env.NPM_CONFIG_BEFORE).toBe("");
|
||||
expect(env.npm_config_before).toBe("");
|
||||
expect(env.NPM_CONFIG_MIN_RELEASE_AGE).toBe("");
|
||||
expect(env["npm_config_min-release-age"]).toBe("0");
|
||||
expect(env.npm_config_min_release_age).toBe("");
|
||||
expect(env["npm_config_min-release-age"]).toBe("");
|
||||
expect(env.npm_config_min_release_age).toBe("0");
|
||||
});
|
||||
|
||||
it("does not leak parent npm freshness env into explicit child envs", () => {
|
||||
const previousBefore = process.env.NPM_CONFIG_BEFORE;
|
||||
process.env.NPM_CONFIG_BEFORE = "2026-01-01T00:00:00.000Z";
|
||||
try {
|
||||
const env = createNpmProjectInstallEnv({});
|
||||
const env = createNpmProjectInstallEnv({}, {}, FROZEN_NOW);
|
||||
|
||||
expect(env.NPM_CONFIG_BEFORE).toBe("");
|
||||
expect(env.npm_config_before).toBe("");
|
||||
expect(env["npm_config_min-release-age"]).toBe("0");
|
||||
expect(env["npm_config_min-release-age"]).toBe("");
|
||||
expect(env.npm_config_min_release_age).toBe("0");
|
||||
} finally {
|
||||
if (previousBefore == null) {
|
||||
delete process.env.NPM_CONFIG_BEFORE;
|
||||
@@ -121,13 +161,218 @@ describe("npm project install env", () => {
|
||||
try {
|
||||
const npmrc = path.join(dir, "npmrc");
|
||||
fsSync.writeFileSync(npmrc, "before=2026-01-01T00:00:00.000Z\n", "utf-8");
|
||||
const env = createNpmProjectInstallEnv({
|
||||
NPM_CONFIG_USERCONFIG: npmrc,
|
||||
});
|
||||
const env = createNpmProjectInstallEnv(
|
||||
{
|
||||
NPM_CONFIG_USERCONFIG: npmrc,
|
||||
},
|
||||
{},
|
||||
FROZEN_NOW,
|
||||
);
|
||||
|
||||
expect(env["npm_config_min-release-age"]).toBe("");
|
||||
expect(env.npm_config_before).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(env.npm_config_min_release_age).toBe("");
|
||||
expect(env.npm_config_before).toBe(FROZEN_NOW.toISOString());
|
||||
expect(env.npm_config_before).not.toBe("2026-01-01T00:00:00.000Z");
|
||||
|
||||
const envWithParentAge = createNpmProjectInstallEnv(
|
||||
{
|
||||
NPM_CONFIG_USERCONFIG: npmrc,
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: "7",
|
||||
},
|
||||
{},
|
||||
FROZEN_NOW,
|
||||
);
|
||||
expect(envWithParentAge.npm_config_min_release_age).toBe("");
|
||||
expect(envWithParentAge.npm_config_before).toBe(FROZEN_NOW.toISOString());
|
||||
} finally {
|
||||
fsSync.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses release-age args by default", () => {
|
||||
expect(createNpmFreshnessBypassArgs({}, FROZEN_NOW)).toEqual(["--min-release-age=0"]);
|
||||
});
|
||||
|
||||
it("uses before args for stale npm before policies", () => {
|
||||
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-npmrc-"));
|
||||
try {
|
||||
const npmrc = path.join(dir, "npmrc");
|
||||
fsSync.writeFileSync(npmrc, "before=2026-01-01T00:00:00.000Z\n", "utf-8");
|
||||
|
||||
expect(
|
||||
createNpmFreshnessBypassArgs(
|
||||
{
|
||||
NPM_CONFIG_USERCONFIG: npmrc,
|
||||
},
|
||||
FROZEN_NOW,
|
||||
),
|
||||
).toEqual([`--before=${FROZEN_NOW.toISOString()}`]);
|
||||
} finally {
|
||||
fsSync.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses before args for expanded npm userconfig paths", () => {
|
||||
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-home-npmrc-"));
|
||||
try {
|
||||
fsSync.writeFileSync(path.join(dir, ".npmrc"), "before=2026-01-01T00:00:00.000Z\n", "utf-8");
|
||||
|
||||
expect(
|
||||
createNpmFreshnessBypassArgs(
|
||||
{
|
||||
HOME: dir,
|
||||
NPM_CONFIG_USERCONFIG: "~/.npmrc",
|
||||
},
|
||||
FROZEN_NOW,
|
||||
),
|
||||
).toEqual([`--before=${FROZEN_NOW.toISOString()}`]);
|
||||
expect(
|
||||
createNpmFreshnessBypassArgs(
|
||||
{
|
||||
HOME: dir,
|
||||
NPM_CONFIG_USERCONFIG: "${HOME}/.npmrc",
|
||||
},
|
||||
FROZEN_NOW,
|
||||
),
|
||||
).toEqual([`--before=${FROZEN_NOW.toISOString()}`]);
|
||||
} finally {
|
||||
fsSync.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses before args for npm default globalconfig before policies", () => {
|
||||
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-npm-prefix-"));
|
||||
try {
|
||||
const npmrcDir = path.join(dir, "etc");
|
||||
fsSync.mkdirSync(npmrcDir, { recursive: true });
|
||||
fsSync.writeFileSync(
|
||||
path.join(npmrcDir, "npmrc"),
|
||||
"before=2026-01-01T00:00:00.000Z\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
expect(
|
||||
createNpmFreshnessBypassArgs(
|
||||
{
|
||||
NPM_CONFIG_PREFIX: dir,
|
||||
},
|
||||
FROZEN_NOW,
|
||||
),
|
||||
).toEqual([`--before=${FROZEN_NOW.toISOString()}`]);
|
||||
} finally {
|
||||
fsSync.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses before args for command project npmrc before policies", () => {
|
||||
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-project-npmrc-"));
|
||||
try {
|
||||
fsSync.writeFileSync(path.join(dir, ".npmrc"), "before=2026-01-01T00:00:00.000Z\n", "utf-8");
|
||||
|
||||
expect(createNpmFreshnessBypassArgs({}, FROZEN_NOW, { npmConfigCwd: dir })).toEqual([
|
||||
`--before=${FROZEN_NOW.toISOString()}`,
|
||||
]);
|
||||
|
||||
const env = createNpmProjectInstallEnv({}, { npmConfigCwd: dir }, FROZEN_NOW);
|
||||
expect(env.npm_config_min_release_age).toBe("");
|
||||
expect(env.npm_config_before).toBe(FROZEN_NOW.toISOString());
|
||||
} finally {
|
||||
fsSync.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses before args for the current project npmrc by default", () => {
|
||||
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-current-npmrc-"));
|
||||
try {
|
||||
fsSync.writeFileSync(path.join(dir, ".npmrc"), "before=2026-01-01T00:00:00.000Z\n", "utf-8");
|
||||
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(dir);
|
||||
withRestoredMocks([cwdSpy], () => {
|
||||
expect(createNpmFreshnessBypassArgs({}, FROZEN_NOW)).toEqual([
|
||||
`--before=${FROZEN_NOW.toISOString()}`,
|
||||
]);
|
||||
});
|
||||
} finally {
|
||||
fsSync.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses before args for scoped npm prefix before policies", () => {
|
||||
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-prefix-npmrc-"));
|
||||
try {
|
||||
const npmrcDir = path.join(dir, "etc");
|
||||
fsSync.mkdirSync(npmrcDir, { recursive: true });
|
||||
fsSync.writeFileSync(
|
||||
path.join(npmrcDir, "npmrc"),
|
||||
"before=2026-01-01T00:00:00.000Z\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
expect(createNpmFreshnessBypassArgs({}, FROZEN_NOW, { npmConfigPrefix: dir })).toEqual([
|
||||
`--before=${FROZEN_NOW.toISOString()}`,
|
||||
]);
|
||||
} finally {
|
||||
fsSync.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("overrides stale npmrc before config without emitting release-age config", () => {
|
||||
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-npmrc-"));
|
||||
try {
|
||||
const npmrc = path.join(dir, "npmrc");
|
||||
fsSync.writeFileSync(npmrc, "before=2026-01-01T00:00:00.000Z\n", "utf-8");
|
||||
const env = createNpmProjectInstallEnv(
|
||||
{
|
||||
NPM_CONFIG_USERCONFIG: npmrc,
|
||||
},
|
||||
{},
|
||||
FROZEN_NOW,
|
||||
);
|
||||
|
||||
const npmConfig = readNpmConfigList(env);
|
||||
expect(npmConfig.before).toBe(FROZEN_NOW.toISOString());
|
||||
expectUnsetNpmJsonConfig(npmConfig["min-release-age"]);
|
||||
} finally {
|
||||
fsSync.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses release-age args for npmrc release-age policies", () => {
|
||||
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-npmrc-"));
|
||||
try {
|
||||
const npmrc = path.join(dir, "npmrc");
|
||||
fsSync.writeFileSync(npmrc, "min-release-age=7\n", "utf-8");
|
||||
|
||||
expect(
|
||||
createNpmFreshnessBypassArgs(
|
||||
{
|
||||
NPM_CONFIG_USERCONFIG: npmrc,
|
||||
},
|
||||
FROZEN_NOW,
|
||||
),
|
||||
).toEqual(["--min-release-age=0"]);
|
||||
} finally {
|
||||
fsSync.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("overrides npmrc release-age config without emitting before config", () => {
|
||||
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-npmrc-"));
|
||||
try {
|
||||
const npmrc = path.join(dir, "npmrc");
|
||||
fsSync.writeFileSync(npmrc, "min-release-age=7\n", "utf-8");
|
||||
const env = createNpmProjectInstallEnv(
|
||||
{
|
||||
NPM_CONFIG_USERCONFIG: npmrc,
|
||||
},
|
||||
{},
|
||||
FROZEN_NOW,
|
||||
);
|
||||
|
||||
expect(env.npm_config_before).toBe("");
|
||||
expect(env.npm_config_min_release_age).toBe("0");
|
||||
const npmConfig = readNpmConfigList(env);
|
||||
expect(npmConfig.before == null || typeof npmConfig.before === "string").toBe(true);
|
||||
expectZeroNpmJsonConfig(npmConfig["min-release-age"]);
|
||||
} finally {
|
||||
fsSync.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
+120
-23
@@ -1,15 +1,17 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export type NpmProjectInstallEnvOptions = {
|
||||
cacheDir?: string;
|
||||
npmConfigCwd?: string;
|
||||
npmConfigPrefix?: string | null;
|
||||
};
|
||||
|
||||
const NPM_CONFIG_SCRIPT_SHELL_KEYS = ["NPM_CONFIG_SCRIPT_SHELL", "npm_config_script_shell"];
|
||||
|
||||
const NPM_CONFIG_KEYS_TO_RESET = new Set([
|
||||
"npm_config_before",
|
||||
"npm_config_cache",
|
||||
"npm_config_dry_run",
|
||||
"npm_config_global",
|
||||
@@ -21,8 +23,6 @@ const NPM_CONFIG_KEYS_TO_RESET = new Set([
|
||||
"npm_config_strict_peer_deps",
|
||||
"npm_config_workspace",
|
||||
"npm_config_workspaces",
|
||||
"npm_config_min_release_age",
|
||||
"npm_config_min-release-age",
|
||||
]);
|
||||
|
||||
const NPM_FRESHNESS_BYPASS_KEYS = [
|
||||
@@ -34,11 +34,54 @@ const NPM_FRESHNESS_BYPASS_KEYS = [
|
||||
"npm_config_min-release-age",
|
||||
] as const;
|
||||
|
||||
const NPM_CONFIG_PROBE_PARENT_ENV_KEYS = ["PATH", "Path", "PATHEXT", "SystemRoot", "ComSpec"];
|
||||
type NpmFreshnessBypassMode = "before" | "min-release-age";
|
||||
|
||||
function createNpmConfigProbeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
type NpmFreshnessConfigScope = {
|
||||
npmConfigCwd?: string;
|
||||
npmConfigPrefix?: string | null;
|
||||
};
|
||||
|
||||
const NPM_CONFIG_PATH_PROBE_PARENT_ENV_KEYS = ["PATH", "Path", "PATHEXT", "SystemRoot", "ComSpec"];
|
||||
|
||||
function resolveEnvPath(env: NodeJS.ProcessEnv, upperKey: string, lowerKey: string): string | null {
|
||||
const raw = env[upperKey]?.trim() || env[lowerKey]?.trim();
|
||||
return raw ? resolveNpmConfigPath(raw, env) : null;
|
||||
}
|
||||
|
||||
function resolveHomeNpmrc(env: NodeJS.ProcessEnv): string {
|
||||
const home = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir();
|
||||
return path.join(home, ".npmrc");
|
||||
}
|
||||
|
||||
function replaceNpmEnvRefs(value: string, env: NodeJS.ProcessEnv): string {
|
||||
return value.replace(
|
||||
/(?<!\\)(\\*)\$\{([^${}?]+)(\?)?\}/gu,
|
||||
(original, escapes, name, modifier) => {
|
||||
const fallback = modifier === "?" ? "" : `\${${name}}`;
|
||||
const resolved = env[name] !== undefined ? env[name] : fallback;
|
||||
if (escapes.length % 2) {
|
||||
return original.slice((escapes.length + 1) / 2);
|
||||
}
|
||||
return `${escapes.slice(escapes.length / 2)}${resolved}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function resolveNpmConfigPath(rawPath: string, env: NodeJS.ProcessEnv): string {
|
||||
const expanded = replaceNpmEnvRefs(rawPath, env);
|
||||
const home = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir();
|
||||
const homePattern = process.platform === "win32" ? /^~(\/|\\)/u : /^~\//u;
|
||||
return homePattern.test(expanded) && home
|
||||
? path.resolve(home, expanded.slice(2))
|
||||
: path.resolve(expanded);
|
||||
}
|
||||
|
||||
function createNpmConfigPathProbeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const probeEnv = { ...env };
|
||||
for (const key of NPM_CONFIG_PROBE_PARENT_ENV_KEYS) {
|
||||
for (const key of NPM_FRESHNESS_BYPASS_KEYS) {
|
||||
delete probeEnv[key];
|
||||
}
|
||||
for (const key of NPM_CONFIG_PATH_PROBE_PARENT_ENV_KEYS) {
|
||||
if (probeEnv[key] == null && process.env[key] != null) {
|
||||
probeEnv[key] = process.env[key];
|
||||
}
|
||||
@@ -46,56 +89,110 @@ function createNpmConfigProbeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return probeEnv;
|
||||
}
|
||||
|
||||
function readNpmConfigValue(key: string, env: NodeJS.ProcessEnv): string | null {
|
||||
function readNpmGlobalConfigPath(
|
||||
env: NodeJS.ProcessEnv,
|
||||
scope: NpmFreshnessConfigScope,
|
||||
): string | null {
|
||||
try {
|
||||
return execFileSync("npm", ["config", "get", key], {
|
||||
const raw = execFileSync("npm", ["config", "get", "globalconfig"], {
|
||||
encoding: "utf-8",
|
||||
env: createNpmConfigProbeEnv(env),
|
||||
env: {
|
||||
...createNpmConfigPathProbeEnv(env),
|
||||
...(scope.npmConfigPrefix ? { npm_config_prefix: scope.npmConfigPrefix } : {}),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 2_000,
|
||||
}).trim();
|
||||
return raw && raw !== "null" && raw !== "undefined" ? raw : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isNullNpmConfigValue(value: string | null): boolean {
|
||||
return !value || value === "null" || value === "undefined";
|
||||
function resolveScopedProjectNpmrc(scope: NpmFreshnessConfigScope): string | null {
|
||||
const cwd = scope.npmConfigCwd?.trim() || process.cwd();
|
||||
return cwd ? path.join(cwd, ".npmrc") : null;
|
||||
}
|
||||
|
||||
function hasExplicitNpmBeforeConfig(env: NodeJS.ProcessEnv): boolean {
|
||||
const minReleaseAge = readNpmConfigValue("min-release-age", env);
|
||||
if (!isNullNpmConfigValue(minReleaseAge)) {
|
||||
function resolveScopedGlobalNpmrc(scope: NpmFreshnessConfigScope): string | null {
|
||||
const prefix = scope.npmConfigPrefix?.trim();
|
||||
return prefix ? path.join(prefix, "etc", "npmrc") : null;
|
||||
}
|
||||
|
||||
function resolveNpmConfigFiles(
|
||||
env: NodeJS.ProcessEnv,
|
||||
scope: NpmFreshnessConfigScope = {},
|
||||
): string[] {
|
||||
const files = [
|
||||
resolveScopedProjectNpmrc(scope),
|
||||
resolveEnvPath(env, "NPM_CONFIG_USERCONFIG", "npm_config_userconfig") ?? resolveHomeNpmrc(env),
|
||||
resolveEnvPath(env, "NPM_CONFIG_GLOBALCONFIG", "npm_config_globalconfig"),
|
||||
resolveScopedGlobalNpmrc(scope),
|
||||
readNpmGlobalConfigPath(env, scope),
|
||||
];
|
||||
return [...new Set(files.filter((file): file is string => Boolean(file)))];
|
||||
}
|
||||
|
||||
function hasNpmrcConfigKey(filePath: string, key: string): boolean {
|
||||
try {
|
||||
const raw = fsSync.readFileSync(filePath, "utf-8");
|
||||
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
const pattern = new RegExp(`^\\s*${escapedKey}\\s*=`, "imu");
|
||||
return pattern.test(raw);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return !isNullNpmConfigValue(readNpmConfigValue("before", env));
|
||||
}
|
||||
|
||||
function hasRawNpmConfigKey(
|
||||
env: NodeJS.ProcessEnv,
|
||||
key: "before" | "min-release-age",
|
||||
scope: NpmFreshnessConfigScope,
|
||||
): boolean {
|
||||
return resolveNpmConfigFiles(env, scope).some((file) => hasNpmrcConfigKey(file, key));
|
||||
}
|
||||
|
||||
function resolveNpmFreshnessBypassMode(
|
||||
env: NodeJS.ProcessEnv,
|
||||
scope: NpmFreshnessConfigScope,
|
||||
): NpmFreshnessBypassMode {
|
||||
if (hasRawNpmConfigKey(env, "min-release-age", scope)) {
|
||||
return "min-release-age";
|
||||
}
|
||||
return hasRawNpmConfigKey(env, "before", scope) ? "before" : "min-release-age";
|
||||
}
|
||||
|
||||
export function createNpmFreshnessBypassArgs(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
now = new Date(),
|
||||
scope: NpmFreshnessConfigScope = {},
|
||||
): string[] {
|
||||
if (hasExplicitNpmBeforeConfig(env)) {
|
||||
return [`--before=${now.toISOString()}`];
|
||||
if (resolveNpmFreshnessBypassMode(env, scope) === "min-release-age") {
|
||||
return ["--min-release-age=0"];
|
||||
}
|
||||
return ["--min-release-age=0"];
|
||||
return [`--before=${now.toISOString()}`];
|
||||
}
|
||||
|
||||
export function applyNpmFreshnessBypassEnv(env: NodeJS.ProcessEnv): void {
|
||||
export function applyNpmFreshnessBypassEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
now = new Date(),
|
||||
scope: NpmFreshnessConfigScope = {},
|
||||
): void {
|
||||
const [arg] = createNpmFreshnessBypassArgs(env, now, scope);
|
||||
for (const key of NPM_FRESHNESS_BYPASS_KEYS) {
|
||||
env[key] = "";
|
||||
}
|
||||
const [arg] = createNpmFreshnessBypassArgs(env);
|
||||
if (arg?.startsWith("--before=")) {
|
||||
env.npm_config_before = arg.slice("--before=".length);
|
||||
return;
|
||||
} else if (arg === "--min-release-age=0") {
|
||||
env.npm_config_min_release_age = "0";
|
||||
}
|
||||
env["npm_config_min-release-age"] = "0";
|
||||
}
|
||||
|
||||
export function createNpmProjectInstallEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
options: NpmProjectInstallEnvOptions = {},
|
||||
now = new Date(),
|
||||
): NodeJS.ProcessEnv {
|
||||
const nextEnv = { ...env };
|
||||
for (const key of Object.keys(nextEnv)) {
|
||||
@@ -116,7 +213,7 @@ export function createNpmProjectInstallEnv(
|
||||
npm_config_save: "false",
|
||||
...(options.cacheDir ? { npm_config_cache: options.cacheDir } : {}),
|
||||
};
|
||||
applyNpmFreshnessBypassEnv(installEnv);
|
||||
applyNpmFreshnessBypassEnv(installEnv, now, options);
|
||||
applyPosixNpmScriptShellEnv(installEnv);
|
||||
return installEnv;
|
||||
}
|
||||
|
||||
@@ -599,6 +599,7 @@ async function collectNpmResolvedManagedNpmRootPeerDependencyPins(params: {
|
||||
timeoutMs: Math.max(params.timeoutMs ?? 300_000, 300_000),
|
||||
env: createSafeNpmInstallEnv(process.env, {
|
||||
legacyPeerDeps: false,
|
||||
npmConfigCwd: tempRoot,
|
||||
packageLock: true,
|
||||
quiet: true,
|
||||
}),
|
||||
@@ -614,6 +615,7 @@ async function collectNpmResolvedManagedNpmRootPeerDependencyPins(params: {
|
||||
...npmPlanOptions,
|
||||
env: createSafeNpmInstallEnv(process.env, {
|
||||
legacyPeerDeps: true,
|
||||
npmConfigCwd: tempRoot,
|
||||
packageLock: true,
|
||||
quiet: true,
|
||||
}),
|
||||
@@ -815,6 +817,7 @@ export async function repairManagedNpmRootOpenClawPeer(params: {
|
||||
timeoutMs: Math.max(params.timeoutMs ?? 300_000, 300_000),
|
||||
env: createSafeNpmInstallEnv(process.env, {
|
||||
legacyPeerDeps: true,
|
||||
npmConfigCwd: params.npmRoot,
|
||||
packageLock: true,
|
||||
quiet: true,
|
||||
}),
|
||||
|
||||
@@ -76,8 +76,9 @@ describe("safe npm install helpers", () => {
|
||||
expect(env.npm_config_yes).toBe("true");
|
||||
expect(env.npm_config_include_workspace_root).toBeUndefined();
|
||||
expect(env.npm_config_workspace).toBeUndefined();
|
||||
expect(env["npm_config_min-release-age"]).toBe("0");
|
||||
expect(env.npm_config_min_release_age).toBe("");
|
||||
expect(env["npm_config_min-release-age"]).toBe("");
|
||||
expect(env.npm_config_min_release_age).toBe("0");
|
||||
expect(env.npm_config_before).toBe("");
|
||||
});
|
||||
|
||||
it("does not inherit host legacy peer dependency mode by default", () => {
|
||||
|
||||
@@ -148,7 +148,8 @@ describe("update global helpers", () => {
|
||||
expect(defaultEnv?.COREPACK_ENABLE_DOWNLOAD_PROMPT).toBe("0");
|
||||
expect(defaultEnv?.NPM_CONFIG_BEFORE).toBe("");
|
||||
expect(defaultEnv?.npm_config_before).toBe("");
|
||||
expect(defaultEnv?.["npm_config_min-release-age"]).toBe("0");
|
||||
expect(defaultEnv?.["npm_config_min-release-age"]).toBe("");
|
||||
expect(defaultEnv?.npm_config_min_release_age).toBe("0");
|
||||
|
||||
const explicitEnv = await createGlobalInstallEnv({
|
||||
COREPACK_ENABLE_DOWNLOAD_PROMPT: "1",
|
||||
|
||||
@@ -793,7 +793,9 @@ export function globalInstallArgs(
|
||||
...(installPrefix ? ["--prefix", installPrefix] : []),
|
||||
spec,
|
||||
...NPM_GLOBAL_INSTALL_QUIET_FLAGS,
|
||||
...createNpmFreshnessBypassArgs(),
|
||||
...createNpmFreshnessBypassArgs(process.env, new Date(), {
|
||||
npmConfigPrefix: installPrefix,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -815,7 +817,9 @@ export function globalInstallFallbackArgs(
|
||||
spec,
|
||||
"--omit=optional",
|
||||
...NPM_GLOBAL_INSTALL_QUIET_FLAGS,
|
||||
...createNpmFreshnessBypassArgs(),
|
||||
...createNpmFreshnessBypassArgs(process.env, new Date(), {
|
||||
npmConfigPrefix: installPrefix,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ describe("runGatewayUpdate", () => {
|
||||
argv: string[],
|
||||
options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number },
|
||||
) => {
|
||||
const key = argv.join(" ");
|
||||
const key = normalizeNpmFreshnessArgs(argv).join(" ");
|
||||
calls.push(key);
|
||||
const override = await params.onCommand?.(key, options);
|
||||
if (override) {
|
||||
@@ -286,6 +286,10 @@ describe("runGatewayUpdate", () => {
|
||||
return { nodeModules, pkgRoot };
|
||||
}
|
||||
|
||||
const npmFreshnessArg = "--min-release-age=0";
|
||||
const normalizeNpmFreshnessArgs = (argv: string[]) =>
|
||||
argv.map((arg) => (/^--before=\d{4}-\d{2}-\d{2}T/u.test(arg) ? npmFreshnessArg : arg));
|
||||
|
||||
const npmGlobalInstallCommand = (spec: string, extraArgs: string[] = []) =>
|
||||
[
|
||||
"npm",
|
||||
@@ -296,7 +300,7 @@ describe("runGatewayUpdate", () => {
|
||||
"--no-fund",
|
||||
"--no-audit",
|
||||
"--loglevel=error",
|
||||
"--min-release-age=0",
|
||||
npmFreshnessArg,
|
||||
].join(" ");
|
||||
|
||||
function createGlobalNpmUpdateRunner(params: {
|
||||
@@ -309,7 +313,7 @@ describe("runGatewayUpdate", () => {
|
||||
const omitOptionalInstallKey = npmGlobalInstallCommand("openclaw@latest", ["--omit=optional"]);
|
||||
|
||||
return async (argv: string[]): Promise<CommandResult> => {
|
||||
const key = argv.join(" ");
|
||||
const key = normalizeNpmFreshnessArgs(argv).join(" ");
|
||||
if (key === `git -C ${params.pkgRoot} rev-parse --show-toplevel`) {
|
||||
return { stdout: "", stderr: "not a git repository", code: 128 };
|
||||
}
|
||||
@@ -1660,7 +1664,7 @@ describe("runGatewayUpdate", () => {
|
||||
}) => {
|
||||
const calls: string[] = [];
|
||||
const runCommand = async (argv: string[], options?: { env?: NodeJS.ProcessEnv }) => {
|
||||
const key = argv.join(" ");
|
||||
const key = normalizeNpmFreshnessArgs(argv).join(" ");
|
||||
calls.push(key);
|
||||
if (key === `git -C ${params.pkgRoot} rev-parse --show-toplevel`) {
|
||||
if (params.gitRootMode === "missing") {
|
||||
@@ -1687,10 +1691,10 @@ describe("runGatewayUpdate", () => {
|
||||
const prefixIndex = argv.indexOf("--prefix");
|
||||
const installPrefix = prefixIndex >= 0 ? argv[prefixIndex + 1] : undefined;
|
||||
if (installPrefix) {
|
||||
const normalizedInstallCommand = [
|
||||
const normalizedInstallCommand = normalizeNpmFreshnessArgs([
|
||||
...argv.slice(0, prefixIndex),
|
||||
...argv.slice(prefixIndex + 2),
|
||||
].join(" ");
|
||||
]).join(" ");
|
||||
if (normalizedInstallCommand === params.installCommand) {
|
||||
const packageRoot =
|
||||
process.platform === "win32"
|
||||
|
||||
@@ -324,7 +324,11 @@ export async function installPluginFromGitSpec(
|
||||
{
|
||||
cwd: repoDir,
|
||||
timeoutMs: Math.max(params.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS, 300_000),
|
||||
env: createSafeNpmInstallEnv(process.env, { packageLock: true, quiet: true }),
|
||||
env: createSafeNpmInstallEnv(process.env, {
|
||||
npmConfigCwd: repoDir,
|
||||
packageLock: true,
|
||||
quiet: true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (install.code !== 0) {
|
||||
|
||||
@@ -355,6 +355,7 @@ async function rollbackManagedNpmPluginInstall(params: {
|
||||
timeoutMs: Math.max(params.timeoutMs, 300_000),
|
||||
env: createSafeNpmInstallEnv(process.env, {
|
||||
legacyPeerDeps: true,
|
||||
npmConfigCwd: params.npmRoot,
|
||||
packageLock: true,
|
||||
quiet: true,
|
||||
}),
|
||||
@@ -415,6 +416,7 @@ async function rollbackManagedNpmPluginInstall(params: {
|
||||
timeoutMs: Math.max(params.timeoutMs, 300_000),
|
||||
env: createSafeNpmInstallEnv(process.env, {
|
||||
legacyPeerDeps: true,
|
||||
npmConfigCwd: params.npmRoot,
|
||||
packageLock: true,
|
||||
quiet: true,
|
||||
}),
|
||||
@@ -697,6 +699,7 @@ async function installPluginFromManagedNpmRoot(
|
||||
timeoutMs: Math.max(timeoutMs, 300_000),
|
||||
env: createSafeNpmInstallEnv(process.env, {
|
||||
legacyPeerDeps: true,
|
||||
npmConfigCwd: npmRoot,
|
||||
packageLock: true,
|
||||
quiet: true,
|
||||
}),
|
||||
|
||||
@@ -622,6 +622,7 @@ export async function applyPluginUninstallDirectoryRemoval(
|
||||
timeoutMs: 300_000,
|
||||
env: createSafeNpmInstallEnv(process.env, {
|
||||
legacyPeerDeps: true,
|
||||
npmConfigCwd: removal.cleanup.npmRoot,
|
||||
packageLock: true,
|
||||
quiet: true,
|
||||
}),
|
||||
@@ -660,6 +661,7 @@ export async function applyPluginUninstallDirectoryRemoval(
|
||||
timeoutMs: 300_000,
|
||||
env: createSafeNpmInstallEnv(process.env, {
|
||||
legacyPeerDeps: true,
|
||||
npmConfigCwd: removal.cleanup.npmRoot,
|
||||
packageLock: true,
|
||||
quiet: true,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user