Fix local copied package installs honoring staged project .npmrc (#54543)

This commit is contained in:
Devin Robison
2026-03-25 08:59:33 -07:00
committed by GitHub
parent a0b9dc0078
commit c2a2edb329
2 changed files with 121 additions and 13 deletions

View File

@@ -21,6 +21,11 @@ async function listMatchingDirs(root: string, prefix: string): Promise<string[]>
.map((entry) => entry.name);
}
async function listMatchingEntries(root: string, prefix: string): Promise<string[]> {
const entries = await fs.readdir(root, { withFileTypes: true });
return entries.filter((entry) => entry.name.startsWith(prefix)).map((entry) => entry.name);
}
function normalizeDarwinTmpPath(filePath: string): string {
return process.platform === "darwin" && filePath.startsWith("/private/var/")
? filePath.slice("/private".length)
@@ -317,4 +322,59 @@ describe("installPackageDir", () => {
}),
);
});
it("hides the staged project .npmrc while npm install runs and restores it afterward", async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-install-package-dir-"));
const sourceDir = path.join(fixtureRoot, "source");
const targetDir = path.join(fixtureRoot, "plugins", "demo");
const npmrcContent = "git=calc.exe\n";
await fs.mkdir(sourceDir, { recursive: true });
await fs.writeFile(
path.join(sourceDir, "package.json"),
JSON.stringify({
name: "demo-plugin",
version: "1.0.0",
dependencies: {
zod: "^4.0.0",
},
}),
"utf-8",
);
await fs.writeFile(path.join(sourceDir, ".npmrc"), npmrcContent, "utf-8");
vi.mocked(runCommandWithTimeout).mockImplementation(async (_argv, optionsOrTimeout) => {
const cwd = typeof optionsOrTimeout === "number" ? undefined : optionsOrTimeout.cwd;
expect(cwd).toBeTruthy();
await expect(fs.stat(path.join(cwd ?? "", ".npmrc"))).rejects.toMatchObject({
code: "ENOENT",
});
await expect(
listMatchingEntries(cwd ?? "", ".openclaw-install-hidden-npmrc-"),
).resolves.toHaveLength(1);
return {
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
});
const result = await installPackageDir({
sourceDir,
targetDir,
mode: "install",
timeoutMs: 1_000,
copyErrorPrefix: "failed to copy plugin",
hasDeps: true,
depsLogMessage: "Installing deps…",
});
expect(result).toEqual({ ok: true });
await expect(fs.readFile(path.join(targetDir, ".npmrc"), "utf8")).resolves.toBe(npmrcContent);
await expect(
listMatchingEntries(targetDir, ".openclaw-install-hidden-npmrc-"),
).resolves.toHaveLength(0);
});
});

View File

@@ -9,6 +9,14 @@ const INSTALL_BASE_CHANGED_ABORT_WARNING =
"Install base directory changed during install; aborting staged publish.";
const INSTALL_BASE_CHANGED_BACKUP_WARNING =
"Install base directory changed before backup cleanup; leaving backup in place.";
const STAGED_NPM_PROJECT_CONFIG_NAME = ".npmrc";
const STAGED_NPM_PROJECT_CONFIG_PREFIX = ".openclaw-install-hidden-npmrc-";
type HiddenProjectConfigFile = {
hiddenDir: string;
originalPath: string;
hiddenPath: string;
} | null;
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -55,6 +63,35 @@ async function sanitizeManifestForNpmInstall(targetDir: string): Promise<void> {
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8");
}
async function hideProjectNpmConfigForInstall(targetDir: string): Promise<HiddenProjectConfigFile> {
const originalPath = path.join(targetDir, STAGED_NPM_PROJECT_CONFIG_NAME);
let hiddenDir = "";
try {
hiddenDir = await fs.mkdtemp(path.join(targetDir, STAGED_NPM_PROJECT_CONFIG_PREFIX));
const hiddenPath = path.join(hiddenDir, STAGED_NPM_PROJECT_CONFIG_NAME);
await fs.rename(originalPath, hiddenPath);
return { hiddenDir, originalPath, hiddenPath };
} catch (error) {
if (hiddenDir) {
await fs.rm(hiddenDir, { recursive: true, force: true }).catch(() => undefined);
}
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw error;
}
}
async function restoreProjectNpmConfigAfterInstall(
hiddenConfig: HiddenProjectConfigFile,
): Promise<void> {
if (!hiddenConfig) {
return;
}
await fs.rename(hiddenConfig.hiddenPath, hiddenConfig.originalPath);
await fs.rm(hiddenConfig.hiddenDir, { recursive: true, force: true });
}
async function assertInstallBoundaryPaths(params: {
installBaseDir: string;
candidatePaths: string[];
@@ -186,19 +223,30 @@ export async function installPackageDir(params: {
}
if (params.hasDeps) {
await sanitizeManifestForNpmInstall(stageDir);
params.logger?.info?.(params.depsLogMessage);
const npmRes = await runCommandWithTimeout(
// Plugins install into isolated directories, so omitting peer deps can strip
// runtime requirements that npm would otherwise materialize for the package.
["npm", "install", "--omit=dev", "--silent", "--ignore-scripts"],
{
timeoutMs: Math.max(params.timeoutMs, 300_000),
cwd: stageDir,
},
);
if (npmRes.code !== 0) {
return await fail(`npm install failed: ${npmRes.stderr.trim() || npmRes.stdout.trim()}`);
try {
await sanitizeManifestForNpmInstall(stageDir);
const hiddenProjectNpmConfig = await hideProjectNpmConfigForInstall(stageDir);
params.logger?.info?.(params.depsLogMessage);
const npmRes = await (async () => {
try {
return await runCommandWithTimeout(
// Plugins install into isolated directories, so omitting peer deps can strip
// runtime requirements that npm would otherwise materialize for the package.
["npm", "install", "--omit=dev", "--silent", "--ignore-scripts"],
{
timeoutMs: Math.max(params.timeoutMs, 300_000),
cwd: stageDir,
},
);
} finally {
await restoreProjectNpmConfigAfterInstall(hiddenProjectNpmConfig);
}
})();
if (npmRes.code !== 0) {
return await fail(`npm install failed: ${npmRes.stderr.trim() || npmRes.stdout.trim()}`);
}
} catch (error) {
return await fail(`npm install failed: ${String(error)}`, error);
}
}