From e5329fbafb37c02be53f09e0b93807e84be04d55 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 3 Jul 2026 14:47:25 +0200 Subject: [PATCH 1/7] Honor workspace npm config on install --- action.yml | 10 +++++++++- src/main.test.ts | 31 ++++++++++++++++++++++++++++++- src/main.ts | 2 ++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/action.yml b/action.yml index cbea1fc..d4c9582 100644 --- a/action.yml +++ b/action.yml @@ -61,12 +61,20 @@ runs: # actions/setup-node uses glibc Node builds, so install Alpine's Node/npm instead. if command -v apk >/dev/null 2>&1; then missing_packages="" - for package in libstdc++ libgcc nodejs npm; do + for package in libstdc++ libgcc; do if ! apk info -e "${package}" >/dev/null 2>&1; then missing_packages="${missing_packages} ${package}" fi done + if ! command -v node >/dev/null 2>&1; then + missing_packages="${missing_packages} nodejs" + fi + + if ! command -v npm >/dev/null 2>&1; then + missing_packages="${missing_packages} npm" + fi + if [ -z "${missing_packages}" ]; then exit 0 fi diff --git a/src/main.test.ts b/src/main.test.ts index 7e50420..759b766 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import process from "node:process"; @@ -20,6 +20,7 @@ afterEach(() => { delete process.env.FAKE_CLI_VERSION; delete process.env.FAKE_NPM_BIN; delete process.env.FAKE_NPM_INTEGRITY; + delete process.env.FAKE_NPM_CWD_LOG; delete process.env.FAKE_NPM_LOG; delete process.env.FAKE_NPM_PACKAGE_VERSION; delete process.env.FAKE_NPM_SCRIPTS; @@ -152,6 +153,7 @@ import path from "node:path"; const args = process.argv.slice(2); appendFileSync(process.env.FAKE_NPM_LOG, JSON.stringify(args) + "\\n"); +appendFileSync(process.env.FAKE_NPM_CWD_LOG, process.cwd() + "\\n"); if (args[0] === "view") { const bin = @@ -229,10 +231,13 @@ function installFakeNpm( } = {}, ): string { const binDir = createFakeNpm(); + const cwdLogPath = path.join(createTempDir("setup-cli-fake-npm-cwd-log-"), "npm-cwd.log"); const logPath = path.join(createTempDir("setup-cli-fake-npm-log-"), "npm.log"); + writeFileSync(cwdLogPath, ""); writeFileSync(logPath, ""); process.env.FAKE_CLI_VERSION = versionOutput; process.env.FAKE_NPM_BIN = options.bin ?? "dist/supabase.js"; + process.env.FAKE_NPM_CWD_LOG = cwdLogPath; process.env.FAKE_NPM_INTEGRITY = options.integrity ?? "sha512-test"; process.env.FAKE_NPM_LOG = logPath; process.env.FAKE_NPM_PACKAGE_VERSION = @@ -260,6 +265,13 @@ function readNpmCalls(logPath: string): string[][] { .map((line) => JSON.parse(line) as string[]); } +function readNpmCwds(): string[] { + return readFileSync(process.env.FAKE_NPM_CWD_LOG ?? "", "utf8") + .trim() + .split("\n") + .filter(Boolean); +} + function viewMetadataCall(spec: string): string[] { return ["view", spec, "version", "bin", "scripts", "dist.integrity", "--json"]; } @@ -448,6 +460,23 @@ test("installs the CLI with npm into an isolated prefix", async () => { ]); }); +test("runs npm from the caller workspace so project npm config is honored", async () => { + const workspace = createWorkspace({ + ".npmrc": "registry=https://registry.example.test\n", + }); + process.env.GITHUB_WORKSPACE = workspace; + installFakeNpm(); + const { installCli } = await getMainModule(); + + await installCli({ + spec: "supabase@2.101.0", + version: "2.101.0", + }); + + const realWorkspace = realpathSync(workspace); + expect(readNpmCwds().map((cwd) => realpathSync(cwd))).toEqual([realWorkspace, realWorkspace]); +}); + test("allows install scripts for legacy npm packages that declare a preinstall", async () => { const logPath = installFakeNpm("supabase 1.15.1", { bin: "bin/supabase", diff --git a/src/main.ts b/src/main.ts index eb42468..c7a8b4f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -292,7 +292,9 @@ function createInstallRoot(): string { async function runNpm(args: string[]): Promise { const executable = process.env[NPM_EXECUTABLE_ENV]?.trim() || "npm"; + const cwd = process.env.GITHUB_WORKSPACE?.trim() || process.cwd(); const proc = Bun.spawn([executable, ...args], { + cwd, env: process.env, stderr: "pipe", stdout: "pipe", From 5b48f8ff7e4871692c8480a8e5a5b8fc87652bb5 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 3 Jul 2026 15:13:33 +0200 Subject: [PATCH 2/7] Handle workspace npm config with prefixed installs --- action.yml | 24 ++++++++++++++++++++---- src/main.test.ts | 19 ++++++++++++++++++- src/main.ts | 15 +++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/action.yml b/action.yml index d4c9582..ec2aeaf 100644 --- a/action.yml +++ b/action.yml @@ -61,18 +61,31 @@ runs: # actions/setup-node uses glibc Node builds, so install Alpine's Node/npm instead. if command -v apk >/dev/null 2>&1; then missing_packages="" + prefer_apk_bin=false for package in libstdc++ libgcc; do if ! apk info -e "${package}" >/dev/null 2>&1; then missing_packages="${missing_packages} ${package}" fi done - if ! command -v node >/dev/null 2>&1; then - missing_packages="${missing_packages} nodejs" + if ! command -v node >/dev/null 2>&1 || ! node -p 'process.versions.node' >/dev/null 2>&1; then + if [ -x /usr/bin/node ] && /usr/bin/node -p 'process.versions.node' >/dev/null 2>&1; then + prefer_apk_bin=true + else + missing_packages="${missing_packages} nodejs" + fi fi - if ! command -v npm >/dev/null 2>&1; then - missing_packages="${missing_packages} npm" + if ! command -v npm >/dev/null 2>&1 || ! npm --version >/dev/null 2>&1; then + if [ -x /usr/bin/npm ] && /usr/bin/npm --version >/dev/null 2>&1; then + prefer_apk_bin=true + else + missing_packages="${missing_packages} npm" + fi + fi + + if [ "${prefer_apk_bin}" = "true" ]; then + echo "/usr/bin" >> "$GITHUB_PATH" fi if [ -z "${missing_packages}" ]; then @@ -85,6 +98,9 @@ runs: fi apk add --no-cache ${missing_packages} + if echo " ${missing_packages} " | grep -Eq ' (nodejs|npm) '; then + echo "/usr/bin" >> "$GITHUB_PATH" + fi exit 0 fi diff --git a/src/main.test.ts b/src/main.test.ts index 759b766..3c4ffb3 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -465,7 +465,7 @@ test("runs npm from the caller workspace so project npm config is honored", asyn ".npmrc": "registry=https://registry.example.test\n", }); process.env.GITHUB_WORKSPACE = workspace; - installFakeNpm(); + const logPath = installFakeNpm(); const { installCli } = await getMainModule(); await installCli({ @@ -475,6 +475,23 @@ test("runs npm from the caller workspace so project npm config is honored", asyn const realWorkspace = realpathSync(workspace); expect(readNpmCwds().map((cwd) => realpathSync(cwd))).toEqual([realWorkspace, realWorkspace]); + expect(readNpmCalls(logPath)).toEqual([ + viewMetadataCall("supabase@2.101.0"), + [ + "install", + "--prefix", + expect.any(String), + "--omit=dev", + "--include=optional", + "--no-audit", + "--no-fund", + "--no-package-lock", + "--ignore-scripts=true", + "--userconfig", + path.join(workspace, ".npmrc"), + "supabase@2.101.0", + ], + ]); }); test("allows install scripts for legacy npm packages that declare a preinstall", async () => { diff --git a/src/main.ts b/src/main.ts index c7a8b4f..5b511a4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -290,6 +290,20 @@ function createInstallRoot(): string { return mkdtempSync(path.join(tempRoot, "setup-cli-")); } +function getWorkspaceNpmConfigArgs(): string[] { + const workspace = process.env.GITHUB_WORKSPACE?.trim(); + if (!workspace) { + return []; + } + + const npmrcPath = path.join(workspace, ".npmrc"); + if (!existsSync(npmrcPath)) { + return []; + } + + return ["--userconfig", npmrcPath]; +} + async function runNpm(args: string[]): Promise { const executable = process.env[NPM_EXECUTABLE_ENV]?.trim() || "npm"; const cwd = process.env.GITHUB_WORKSPACE?.trim() || process.cwd(); @@ -329,6 +343,7 @@ export async function installCli(resolution: PackageResolution): Promise "--no-fund", "--no-package-lock", `--ignore-scripts=${shouldIgnoreInstallScripts(metadata)}`, + ...getWorkspaceNpmConfigArgs(), resolution.spec, ]); From a721094b729c7fe2694c91c9c4fa165f8f9cb235 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 3 Jul 2026 15:31:55 +0200 Subject: [PATCH 3/7] Filter workspace npm config for installs --- action.yml | 4 +-- src/main.test.ts | 69 +++++++++++++++++++++++++++++++++++++++++++++--- src/main.ts | 58 +++++++++++++++++++++++++++++++++++----- 3 files changed, 119 insertions(+), 12 deletions(-) diff --git a/action.yml b/action.yml index ec2aeaf..b731499 100644 --- a/action.yml +++ b/action.yml @@ -69,7 +69,7 @@ runs: done if ! command -v node >/dev/null 2>&1 || ! node -p 'process.versions.node' >/dev/null 2>&1; then - if [ -x /usr/bin/node ] && /usr/bin/node -p 'process.versions.node' >/dev/null 2>&1; then + if [ -x /usr/bin/node ] && PATH="/usr/bin:${PATH}" /usr/bin/node -p 'process.versions.node' >/dev/null 2>&1; then prefer_apk_bin=true else missing_packages="${missing_packages} nodejs" @@ -77,7 +77,7 @@ runs: fi if ! command -v npm >/dev/null 2>&1 || ! npm --version >/dev/null 2>&1; then - if [ -x /usr/bin/npm ] && /usr/bin/npm --version >/dev/null 2>&1; then + if [ -x /usr/bin/npm ] && PATH="/usr/bin:${PATH}" /usr/bin/npm --version >/dev/null 2>&1; then prefer_apk_bin=true else missing_packages="${missing_packages} npm" diff --git a/src/main.test.ts b/src/main.test.ts index 3c4ffb3..435d087 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -7,6 +7,7 @@ import * as core from "@actions/core"; const CLI_CONFIG_REGISTRY = "SUPABASE_INTERNAL_IMAGE_REGISTRY"; const originalPath = process.env.PATH; +const originalNpmUserconfig = process.env.NPM_CONFIG_USERCONFIG; const originalRunnerTemp = process.env.RUNNER_TEMP; const originalWorkspace = process.env.GITHUB_WORKSPACE; const tempDirs = new Set(); @@ -15,14 +16,21 @@ let mainModule: typeof import("./main.ts") | null = null; afterEach(() => { mock.restore(); process.env.PATH = originalPath; + if (originalNpmUserconfig === undefined) { + delete process.env.NPM_CONFIG_USERCONFIG; + } else { + process.env.NPM_CONFIG_USERCONFIG = originalNpmUserconfig; + } process.env.RUNNER_TEMP = originalRunnerTemp; process.env.GITHUB_WORKSPACE = originalWorkspace; delete process.env.FAKE_CLI_VERSION; delete process.env.FAKE_NPM_BIN; delete process.env.FAKE_NPM_INTEGRITY; delete process.env.FAKE_NPM_CWD_LOG; + delete process.env.FAKE_NPM_ENV_LOG; delete process.env.FAKE_NPM_LOG; delete process.env.FAKE_NPM_PACKAGE_VERSION; + delete process.env.FAKE_NPM_PREFIX_CONFIG_LOG; delete process.env.FAKE_NPM_SCRIPTS; delete process.env.SUPABASE_SETUP_CLI_NPM; @@ -148,12 +156,16 @@ function createFakeNpm(): string { mkdirSync(binDir, { recursive: true }); writeFileSync( scriptPath, - `import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; + `import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; const args = process.argv.slice(2); appendFileSync(process.env.FAKE_NPM_LOG, JSON.stringify(args) + "\\n"); appendFileSync(process.env.FAKE_NPM_CWD_LOG, process.cwd() + "\\n"); +appendFileSync( + process.env.FAKE_NPM_ENV_LOG, + JSON.stringify({ NPM_CONFIG_USERCONFIG: process.env.NPM_CONFIG_USERCONFIG ?? null }) + "\\n", +); if (args[0] === "view") { const bin = @@ -187,6 +199,11 @@ if (!prefix) { const binDir = path.join(prefix, "node_modules", ".bin"); mkdirSync(binDir, { recursive: true }); +const prefixConfigPath = path.join(prefix, ".npmrc"); +appendFileSync( + process.env.FAKE_NPM_PREFIX_CONFIG_LOG, + JSON.stringify(existsSync(prefixConfigPath) ? readFileSync(prefixConfigPath, "utf8") : null) + "\\n", +); if (process.platform === "win32") { writeFileSync( @@ -232,12 +249,20 @@ function installFakeNpm( ): string { const binDir = createFakeNpm(); const cwdLogPath = path.join(createTempDir("setup-cli-fake-npm-cwd-log-"), "npm-cwd.log"); + const envLogPath = path.join(createTempDir("setup-cli-fake-npm-env-log-"), "npm-env.log"); const logPath = path.join(createTempDir("setup-cli-fake-npm-log-"), "npm.log"); + const prefixConfigLogPath = path.join( + createTempDir("setup-cli-fake-npm-prefix-config-log-"), + "npm-prefix-config.log", + ); writeFileSync(cwdLogPath, ""); + writeFileSync(envLogPath, ""); writeFileSync(logPath, ""); + writeFileSync(prefixConfigLogPath, ""); process.env.FAKE_CLI_VERSION = versionOutput; process.env.FAKE_NPM_BIN = options.bin ?? "dist/supabase.js"; process.env.FAKE_NPM_CWD_LOG = cwdLogPath; + process.env.FAKE_NPM_ENV_LOG = envLogPath; process.env.FAKE_NPM_INTEGRITY = options.integrity ?? "sha512-test"; process.env.FAKE_NPM_LOG = logPath; process.env.FAKE_NPM_PACKAGE_VERSION = @@ -253,6 +278,7 @@ function installFakeNpm( binDir, process.platform === "win32" ? "npm.cmd" : "npm", ); + process.env.FAKE_NPM_PREFIX_CONFIG_LOG = prefixConfigLogPath; return logPath; } @@ -272,6 +298,22 @@ function readNpmCwds(): string[] { .filter(Boolean); } +function readNpmEnvs(): Array<{ NPM_CONFIG_USERCONFIG: string | null }> { + return readFileSync(process.env.FAKE_NPM_ENV_LOG ?? "", "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as { NPM_CONFIG_USERCONFIG: string | null }); +} + +function readNpmPrefixConfigs(): Array { + return readFileSync(process.env.FAKE_NPM_PREFIX_CONFIG_LOG ?? "", "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as string | null); +} + function viewMetadataCall(spec: string): string[] { return ["view", spec, "version", "bin", "scripts", "dist.integrity", "--json"]; } @@ -462,8 +504,17 @@ test("installs the CLI with npm into an isolated prefix", async () => { test("runs npm from the caller workspace so project npm config is honored", async () => { const workspace = createWorkspace({ - ".npmrc": "registry=https://registry.example.test\n", + ".npmrc": [ + "registry=https://registry.example.test", + "@internal:registry=https://registry.internal.example.test", + "//registry.example.test/:_authToken=${NPM_TOKEN}", + "bin-links=false", + "package-lock=true", + ].join("\n"), }); + const userconfigPath = path.join(createTempDir("setup-cli-userconfig-"), ".npmrc"); + writeFileSync(userconfigPath, "//registry.example.test/:username=existing\n"); + process.env.NPM_CONFIG_USERCONFIG = userconfigPath; process.env.GITHUB_WORKSPACE = workspace; const logPath = installFakeNpm(); const { installCli } = await getMainModule(); @@ -475,6 +526,18 @@ test("runs npm from the caller workspace so project npm config is honored", asyn const realWorkspace = realpathSync(workspace); expect(readNpmCwds().map((cwd) => realpathSync(cwd))).toEqual([realWorkspace, realWorkspace]); + expect(readNpmEnvs().map((env) => env.NPM_CONFIG_USERCONFIG)).toEqual([ + userconfigPath, + userconfigPath, + ]); + expect(readNpmPrefixConfigs()).toEqual([ + [ + "registry=https://registry.example.test", + "@internal:registry=https://registry.internal.example.test", + "//registry.example.test/:_authToken=${NPM_TOKEN}", + "", + ].join("\n"), + ]); expect(readNpmCalls(logPath)).toEqual([ viewMetadataCall("supabase@2.101.0"), [ @@ -487,8 +550,6 @@ test("runs npm from the caller workspace so project npm config is honored", asyn "--no-fund", "--no-package-lock", "--ignore-scripts=true", - "--userconfig", - path.join(workspace, ".npmrc"), "supabase@2.101.0", ], ]); diff --git a/src/main.ts b/src/main.ts index 5b511a4..309f7b2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,6 @@ import { semver } from "bun"; import * as core from "@actions/core"; -import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -14,6 +14,23 @@ const INSTALL_LIFECYCLE_SCRIPTS = ["preinstall", "install", "postinstall"] as co const SUPPORTED_DIST_TAGS = new Set([DEFAULT_VERSION, "beta"]); const CONCRETE_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; const CONCRETE_VERSION_EXTRACT_PATTERN = /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/; +const INSTALL_NPM_CONFIG_KEYS = new Set([ + "_auth", + "_authToken", + "_password", + "always-auth", + "ca", + "cafile", + "cert", + "email", + "https-proxy", + "key", + "noproxy", + "proxy", + "registry", + "strict-ssl", + "username", +]); type PackageResolution = { spec: string; @@ -290,18 +307,47 @@ function createInstallRoot(): string { return mkdtempSync(path.join(tempRoot, "setup-cli-")); } -function getWorkspaceNpmConfigArgs(): string[] { +function shouldCopyWorkspaceNpmConfigLine(line: string): boolean { + const trimmedLine = line.trim(); + if (!trimmedLine || trimmedLine.startsWith("#") || trimmedLine.startsWith(";")) { + return false; + } + + const separatorIndex = trimmedLine.indexOf("="); + if (separatorIndex === -1) { + return false; + } + + const rawKey = trimmedLine.slice(0, separatorIndex).trim(); + const key = rawKey.replace(/\[\]$/, ""); + + return ( + INSTALL_NPM_CONFIG_KEYS.has(key) || + key.endsWith(":registry") || + [...INSTALL_NPM_CONFIG_KEYS].some((configKey) => key.endsWith(`:${configKey}`)) + ); +} + +function copyWorkspaceNpmConfig(installRoot: string): void { const workspace = process.env.GITHUB_WORKSPACE?.trim(); if (!workspace) { - return []; + return; } const npmrcPath = path.join(workspace, ".npmrc"); if (!existsSync(npmrcPath)) { - return []; + return; + } + + const configLines = readFileSync(npmrcPath, "utf8") + .split(/\r?\n/) + .filter(shouldCopyWorkspaceNpmConfigLine); + + if (configLines.length === 0) { + return; } - return ["--userconfig", npmrcPath]; + writeFileSync(path.join(installRoot, ".npmrc"), `${configLines.join("\n")}\n`); } async function runNpm(args: string[]): Promise { @@ -332,6 +378,7 @@ export async function installCli(resolution: PackageResolution): Promise verifyPackageIntegrity(resolution, metadata); const installRoot = createInstallRoot(); + copyWorkspaceNpmConfig(installRoot); await runNpm([ "install", @@ -343,7 +390,6 @@ export async function installCli(resolution: PackageResolution): Promise "--no-fund", "--no-package-lock", `--ignore-scripts=${shouldIgnoreInstallScripts(metadata)}`, - ...getWorkspaceNpmConfigArgs(), resolution.spec, ]); From 6c557e4b825ac11fc812f0235d6b740928b9a3fa Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 7 Jul 2026 10:59:38 +0200 Subject: [PATCH 4/7] Preserve delegated npm userconfig --- src/main.test.ts | 3 +++ src/main.ts | 26 +++++++++++++++++++------- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/main.test.ts b/src/main.test.ts index 435d087..82daef8 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -508,9 +508,11 @@ test("runs npm from the caller workspace so project npm config is honored", asyn "registry=https://registry.example.test", "@internal:registry=https://registry.internal.example.test", "//registry.example.test/:_authToken=${NPM_TOKEN}", + "userconfig=.npmrc-ci", "bin-links=false", "package-lock=true", ].join("\n"), + ".npmrc-ci": "//registry.example.test/:_password=delegated\n", }); const userconfigPath = path.join(createTempDir("setup-cli-userconfig-"), ".npmrc"); writeFileSync(userconfigPath, "//registry.example.test/:username=existing\n"); @@ -535,6 +537,7 @@ test("runs npm from the caller workspace so project npm config is honored", asyn "registry=https://registry.example.test", "@internal:registry=https://registry.internal.example.test", "//registry.example.test/:_authToken=${NPM_TOKEN}", + `userconfig=${path.join(workspace, ".npmrc-ci")}`, "", ].join("\n"), ]); diff --git a/src/main.ts b/src/main.ts index 309f7b2..a8140e4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -307,25 +307,36 @@ function createInstallRoot(): string { return mkdtempSync(path.join(tempRoot, "setup-cli-")); } -function shouldCopyWorkspaceNpmConfigLine(line: string): boolean { +function copyWorkspaceNpmConfigLine(workspace: string, line: string): string | null { const trimmedLine = line.trim(); if (!trimmedLine || trimmedLine.startsWith("#") || trimmedLine.startsWith(";")) { - return false; + return null; } const separatorIndex = trimmedLine.indexOf("="); if (separatorIndex === -1) { - return false; + return null; } const rawKey = trimmedLine.slice(0, separatorIndex).trim(); const key = rawKey.replace(/\[\]$/, ""); + const rawValue = trimmedLine.slice(separatorIndex + 1).trim(); - return ( + if (key === "userconfig") { + if (!rawValue || rawValue.startsWith("~") || rawValue.includes("${")) { + return trimmedLine; + } + + const userconfigPath = path.isAbsolute(rawValue) ? rawValue : path.join(workspace, rawValue); + return `${rawKey}=${userconfigPath}`; + } + + const shouldCopy = INSTALL_NPM_CONFIG_KEYS.has(key) || key.endsWith(":registry") || - [...INSTALL_NPM_CONFIG_KEYS].some((configKey) => key.endsWith(`:${configKey}`)) - ); + [...INSTALL_NPM_CONFIG_KEYS].some((configKey) => key.endsWith(`:${configKey}`)); + + return shouldCopy ? trimmedLine : null; } function copyWorkspaceNpmConfig(installRoot: string): void { @@ -341,7 +352,8 @@ function copyWorkspaceNpmConfig(installRoot: string): void { const configLines = readFileSync(npmrcPath, "utf8") .split(/\r?\n/) - .filter(shouldCopyWorkspaceNpmConfigLine); + .map((line) => copyWorkspaceNpmConfigLine(workspace, line)) + .filter((line) => line !== null); if (configLines.length === 0) { return; From 18af7909625ea61f6035d74a401413d2d751ac73 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 7 Jul 2026 12:03:49 +0200 Subject: [PATCH 5/7] Filter npm config before metadata lookup --- src/main.test.ts | 45 +++++++++++------- src/main.ts | 120 +++++++++++++++++++++++++++++++---------------- 2 files changed, 108 insertions(+), 57 deletions(-) diff --git a/src/main.test.ts b/src/main.test.ts index 82daef8..a32ed1a 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -167,7 +167,16 @@ appendFileSync( JSON.stringify({ NPM_CONFIG_USERCONFIG: process.env.NPM_CONFIG_USERCONFIG ?? null }) + "\\n", ); +function recordConfig(configPath) { + appendFileSync( + process.env.FAKE_NPM_PREFIX_CONFIG_LOG, + JSON.stringify(existsSync(configPath) ? readFileSync(configPath, "utf8") : null) + "\\n", + ); +} + if (args[0] === "view") { + recordConfig(path.join(process.cwd(), ".npmrc")); + const bin = process.env.FAKE_NPM_BIN === "missing" ? undefined @@ -199,11 +208,7 @@ if (!prefix) { const binDir = path.join(prefix, "node_modules", ".bin"); mkdirSync(binDir, { recursive: true }); -const prefixConfigPath = path.join(prefix, ".npmrc"); -appendFileSync( - process.env.FAKE_NPM_PREFIX_CONFIG_LOG, - JSON.stringify(existsSync(prefixConfigPath) ? readFileSync(prefixConfigPath, "utf8") : null) + "\\n", -); +recordConfig(path.join(prefix, ".npmrc")); if (process.platform === "win32") { writeFileSync( @@ -502,7 +507,7 @@ test("installs the CLI with npm into an isolated prefix", async () => { ]); }); -test("runs npm from the caller workspace so project npm config is honored", async () => { +test("runs npm with filtered caller workspace config", async () => { const workspace = createWorkspace({ ".npmrc": [ "registry=https://registry.example.test", @@ -510,9 +515,14 @@ test("runs npm from the caller workspace so project npm config is honored", asyn "//registry.example.test/:_authToken=${NPM_TOKEN}", "userconfig=.npmrc-ci", "bin-links=false", + "offline=true", "package-lock=true", ].join("\n"), - ".npmrc-ci": "//registry.example.test/:_password=delegated\n", + ".npmrc-ci": [ + "//registry.example.test/:_password=delegated", + "bin-links=false", + "offline=true", + ].join("\n"), }); const userconfigPath = path.join(createTempDir("setup-cli-userconfig-"), ".npmrc"); writeFileSync(userconfigPath, "//registry.example.test/:username=existing\n"); @@ -527,20 +537,21 @@ test("runs npm from the caller workspace so project npm config is honored", asyn }); const realWorkspace = realpathSync(workspace); - expect(readNpmCwds().map((cwd) => realpathSync(cwd))).toEqual([realWorkspace, realWorkspace]); + const npmCwds = readNpmCwds().map((cwd) => realpathSync(cwd)); + expect(npmCwds[0]).not.toBe(realWorkspace); + expect(npmCwds[1]).toBe(npmCwds[0]); expect(readNpmEnvs().map((env) => env.NPM_CONFIG_USERCONFIG)).toEqual([ userconfigPath, userconfigPath, ]); - expect(readNpmPrefixConfigs()).toEqual([ - [ - "registry=https://registry.example.test", - "@internal:registry=https://registry.internal.example.test", - "//registry.example.test/:_authToken=${NPM_TOKEN}", - `userconfig=${path.join(workspace, ".npmrc-ci")}`, - "", - ].join("\n"), - ]); + const filteredConfig = [ + "registry=https://registry.example.test", + "@internal:registry=https://registry.internal.example.test", + "//registry.example.test/:_authToken=${NPM_TOKEN}", + "//registry.example.test/:_password=delegated", + "", + ].join("\n"); + expect(readNpmPrefixConfigs()).toEqual([filteredConfig, filteredConfig]); expect(readNpmCalls(logPath)).toEqual([ viewMetadataCall("supabase@2.101.0"), [ diff --git a/src/main.ts b/src/main.ts index a8140e4..31b82fd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -260,16 +260,14 @@ function verifyPackageIntegrity(resolution: PackageResolution, metadata: Package } } -async function getPackageMetadata(resolution: PackageResolution): Promise { - const output = await runNpm([ - "view", - resolution.spec, - "version", - "bin", - "scripts", - "dist.integrity", - "--json", - ]); +async function getPackageMetadata( + resolution: PackageResolution, + npmCwd: string, +): Promise { + const output = await runNpm( + ["view", resolution.spec, "version", "bin", "scripts", "dist.integrity", "--json"], + npmCwd, + ); const metadata = JSON.parse(output) as unknown; if (!isRecord(metadata)) { @@ -307,15 +305,49 @@ function createInstallRoot(): string { return mkdtempSync(path.join(tempRoot, "setup-cli-")); } -function copyWorkspaceNpmConfigLine(workspace: string, line: string): string | null { +function resolveNpmConfigPath(configRoot: string, rawValue: string): string | null { + if (!rawValue || rawValue.includes("${")) { + return null; + } + + if (rawValue === "~") { + return os.homedir(); + } + + if (rawValue.startsWith("~/")) { + return path.join(os.homedir(), rawValue.slice(2)); + } + + return path.isAbsolute(rawValue) ? rawValue : path.join(configRoot, rawValue); +} + +function copyNpmConfigLines( + configRoot: string, + text: string, + visitedConfigs = new Set(), +): string[] { + const configLines: string[] = []; + + for (const line of text.split(/\r?\n/)) { + configLines.push(...copyNpmConfigLine(configRoot, line, visitedConfigs)); + } + + return configLines; +} + +function copyNpmConfigLine( + configRoot: string, + line: string, + visitedConfigs: Set, +): string[] { const trimmedLine = line.trim(); if (!trimmedLine || trimmedLine.startsWith("#") || trimmedLine.startsWith(";")) { - return null; + return []; } const separatorIndex = trimmedLine.indexOf("="); if (separatorIndex === -1) { - return null; + return []; } const rawKey = trimmedLine.slice(0, separatorIndex).trim(); @@ -323,12 +355,21 @@ function copyWorkspaceNpmConfigLine(workspace: string, line: string): string | n const rawValue = trimmedLine.slice(separatorIndex + 1).trim(); if (key === "userconfig") { - if (!rawValue || rawValue.startsWith("~") || rawValue.includes("${")) { - return trimmedLine; + const userconfigPath = resolveNpmConfigPath(configRoot, rawValue); + if (!userconfigPath || visitedConfigs.has(userconfigPath)) { + return []; } - const userconfigPath = path.isAbsolute(rawValue) ? rawValue : path.join(workspace, rawValue); - return `${rawKey}=${userconfigPath}`; + try { + visitedConfigs.add(userconfigPath); + return copyNpmConfigLines( + path.dirname(userconfigPath), + readFileSync(userconfigPath, "utf8"), + visitedConfigs, + ); + } catch { + return []; + } } const shouldCopy = @@ -336,7 +377,7 @@ function copyWorkspaceNpmConfigLine(workspace: string, line: string): string | n key.endsWith(":registry") || [...INSTALL_NPM_CONFIG_KEYS].some((configKey) => key.endsWith(`:${configKey}`)); - return shouldCopy ? trimmedLine : null; + return shouldCopy ? [trimmedLine] : []; } function copyWorkspaceNpmConfig(installRoot: string): void { @@ -350,10 +391,7 @@ function copyWorkspaceNpmConfig(installRoot: string): void { return; } - const configLines = readFileSync(npmrcPath, "utf8") - .split(/\r?\n/) - .map((line) => copyWorkspaceNpmConfigLine(workspace, line)) - .filter((line) => line !== null); + const configLines = copyNpmConfigLines(workspace, readFileSync(npmrcPath, "utf8")); if (configLines.length === 0) { return; @@ -362,11 +400,10 @@ function copyWorkspaceNpmConfig(installRoot: string): void { writeFileSync(path.join(installRoot, ".npmrc"), `${configLines.join("\n")}\n`); } -async function runNpm(args: string[]): Promise { +async function runNpm(args: string[], cwd?: string): Promise { const executable = process.env[NPM_EXECUTABLE_ENV]?.trim() || "npm"; - const cwd = process.env.GITHUB_WORKSPACE?.trim() || process.cwd(); const proc = Bun.spawn([executable, ...args], { - cwd, + cwd: cwd ?? process.env.GITHUB_WORKSPACE?.trim() ?? process.cwd(), env: process.env, stderr: "pipe", stdout: "pipe", @@ -385,25 +422,28 @@ async function runNpm(args: string[]): Promise { } export async function installCli(resolution: PackageResolution): Promise { - const metadata = await getPackageMetadata(resolution); - verifyPackageMetadata(resolution, metadata); - verifyPackageIntegrity(resolution, metadata); - const installRoot = createInstallRoot(); copyWorkspaceNpmConfig(installRoot); - await runNpm([ - "install", - "--prefix", + const metadata = await getPackageMetadata(resolution, installRoot); + verifyPackageMetadata(resolution, metadata); + verifyPackageIntegrity(resolution, metadata); + + await runNpm( + [ + "install", + "--prefix", + installRoot, + "--omit=dev", + "--include=optional", + "--no-audit", + "--no-fund", + "--no-package-lock", + `--ignore-scripts=${shouldIgnoreInstallScripts(metadata)}`, + resolution.spec, + ], installRoot, - "--omit=dev", - "--include=optional", - "--no-audit", - "--no-fund", - "--no-package-lock", - `--ignore-scripts=${shouldIgnoreInstallScripts(metadata)}`, - resolution.spec, - ]); + ); return path.join(installRoot, "node_modules", ".bin"); } From fe1d46f2481f7e04dfe866bf44e09eacbf34168c Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 7 Jul 2026 12:24:49 +0200 Subject: [PATCH 6/7] Handle npm config edge cases --- src/main.test.ts | 16 +++++++-- src/main.ts | 89 +++++++++++++++++++++++++++++++++++------------- 2 files changed, 80 insertions(+), 25 deletions(-) diff --git a/src/main.test.ts b/src/main.test.ts index a32ed1a..915c29c 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -32,6 +32,7 @@ afterEach(() => { delete process.env.FAKE_NPM_PACKAGE_VERSION; delete process.env.FAKE_NPM_PREFIX_CONFIG_LOG; delete process.env.FAKE_NPM_SCRIPTS; + delete process.env.SETUP_CLI_TEST_WORKSPACE; delete process.env.SUPABASE_SETUP_CLI_NPM; for (const dir of tempDirs) { @@ -513,17 +514,24 @@ test("runs npm with filtered caller workspace config", async () => { "registry=https://registry.example.test", "@internal:registry=https://registry.internal.example.test", "//registry.example.test/:_authToken=${NPM_TOKEN}", - "userconfig=.npmrc-ci", + "always-auth", + "cafile=certs/project-ca.pem", + "userconfig=${SETUP_CLI_TEST_WORKSPACE}/.npmrc-ci", "bin-links=false", "offline=true", "package-lock=true", ].join("\n"), ".npmrc-ci": [ + "registry=https://delegated-registry.example.test", "//registry.example.test/:_password=delegated", + "cafile=certs/delegated-ca.pem", "bin-links=false", "offline=true", ].join("\n"), + "certs/delegated-ca.pem": "delegated-ca", + "certs/project-ca.pem": "project-ca", }); + process.env.SETUP_CLI_TEST_WORKSPACE = workspace; const userconfigPath = path.join(createTempDir("setup-cli-userconfig-"), ".npmrc"); writeFileSync(userconfigPath, "//registry.example.test/:username=existing\n"); process.env.NPM_CONFIG_USERCONFIG = userconfigPath; @@ -545,10 +553,14 @@ test("runs npm with filtered caller workspace config", async () => { userconfigPath, ]); const filteredConfig = [ + "registry=https://delegated-registry.example.test", + "//registry.example.test/:_password=delegated", + `cafile=${path.join(workspace, "certs", "delegated-ca.pem")}`, "registry=https://registry.example.test", "@internal:registry=https://registry.internal.example.test", "//registry.example.test/:_authToken=${NPM_TOKEN}", - "//registry.example.test/:_password=delegated", + "always-auth=true", + `cafile=${path.join(workspace, "certs", "project-ca.pem")}`, "", ].join("\n"); expect(readNpmPrefixConfigs()).toEqual([filteredConfig, filteredConfig]); diff --git a/src/main.ts b/src/main.ts index 31b82fd..0afe6a1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -45,6 +45,11 @@ type PackageMetadata = { "dist.integrity"?: unknown; }; +type CopiedNpmConfig = { + projectLines: string[]; + userLines: string[]; +}; + type BunLock = { workspaces?: { "": { @@ -305,20 +310,43 @@ function createInstallRoot(): string { return mkdtempSync(path.join(tempRoot, "setup-cli-")); } +function createCopiedNpmConfig(): CopiedNpmConfig { + return { + projectLines: [], + userLines: [], + }; +} + +function expandNpmConfigEnv(rawValue: string): string | null { + let missingEnv = false; + const expandedValue = rawValue.replace(/\$\{([^}]+)\}/g, (_match, envName: string) => { + const envValue = process.env[envName]; + if (envValue === undefined) { + missingEnv = true; + return ""; + } + + return envValue; + }); + + return missingEnv ? null : expandedValue; +} + function resolveNpmConfigPath(configRoot: string, rawValue: string): string | null { - if (!rawValue || rawValue.includes("${")) { + const expandedValue = expandNpmConfigEnv(rawValue); + if (!expandedValue) { return null; } - if (rawValue === "~") { + if (expandedValue === "~") { return os.homedir(); } - if (rawValue.startsWith("~/")) { - return path.join(os.homedir(), rawValue.slice(2)); + if (expandedValue.startsWith("~/")) { + return path.join(os.homedir(), expandedValue.slice(2)); } - return path.isAbsolute(rawValue) ? rawValue : path.join(configRoot, rawValue); + return path.isAbsolute(expandedValue) ? expandedValue : path.join(configRoot, expandedValue); } function copyNpmConfigLines( @@ -326,50 +354,51 @@ function copyNpmConfigLines( text: string, visitedConfigs = new Set(), ): string[] { - const configLines: string[] = []; + const copiedConfig = createCopiedNpmConfig(); for (const line of text.split(/\r?\n/)) { - configLines.push(...copyNpmConfigLine(configRoot, line, visitedConfigs)); + copyNpmConfigLine(configRoot, line, copiedConfig, visitedConfigs); } - return configLines; + return [...copiedConfig.userLines, ...copiedConfig.projectLines]; } function copyNpmConfigLine( configRoot: string, line: string, + copiedConfig: CopiedNpmConfig, visitedConfigs: Set, -): string[] { +): void { const trimmedLine = line.trim(); if (!trimmedLine || trimmedLine.startsWith("#") || trimmedLine.startsWith(";")) { - return []; + return; } const separatorIndex = trimmedLine.indexOf("="); - if (separatorIndex === -1) { - return []; - } - - const rawKey = trimmedLine.slice(0, separatorIndex).trim(); + const rawKey = separatorIndex === -1 ? trimmedLine : trimmedLine.slice(0, separatorIndex).trim(); const key = rawKey.replace(/\[\]$/, ""); - const rawValue = trimmedLine.slice(separatorIndex + 1).trim(); + const rawValue = separatorIndex === -1 ? "true" : trimmedLine.slice(separatorIndex + 1).trim(); if (key === "userconfig") { const userconfigPath = resolveNpmConfigPath(configRoot, rawValue); if (!userconfigPath || visitedConfigs.has(userconfigPath)) { - return []; + return; } try { visitedConfigs.add(userconfigPath); - return copyNpmConfigLines( - path.dirname(userconfigPath), - readFileSync(userconfigPath, "utf8"), - visitedConfigs, + copiedConfig.userLines.push( + ...copyNpmConfigLines( + path.dirname(userconfigPath), + readFileSync(userconfigPath, "utf8"), + visitedConfigs, + ), ); } catch { - return []; + return; } + + return; } const shouldCopy = @@ -377,7 +406,21 @@ function copyNpmConfigLine( key.endsWith(":registry") || [...INSTALL_NPM_CONFIG_KEYS].some((configKey) => key.endsWith(`:${configKey}`)); - return shouldCopy ? [trimmedLine] : []; + if (!shouldCopy) { + return; + } + + if (key === "cafile" || key.endsWith(":cafile")) { + const cafilePath = resolveNpmConfigPath(configRoot, rawValue); + if (!cafilePath) { + return; + } + + copiedConfig.projectLines.push(`${rawKey}=${cafilePath}`); + return; + } + + copiedConfig.projectLines.push(separatorIndex === -1 ? `${rawKey}=true` : trimmedLine); } function copyWorkspaceNpmConfig(installRoot: string): void { From 33501bfb7bc1b1264037c338adf39b3d55841bce Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 7 Jul 2026 12:49:29 +0200 Subject: [PATCH 7/7] Use npm config resolution directly --- src/main.test.ts | 46 +++++++---- src/main.ts | 210 +++++++---------------------------------------- 2 files changed, 56 insertions(+), 200 deletions(-) diff --git a/src/main.test.ts b/src/main.test.ts index 915c29c..c9f4f1c 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -321,7 +321,7 @@ function readNpmPrefixConfigs(): Array { } function viewMetadataCall(spec: string): string[] { - return ["view", spec, "version", "bin", "scripts", "dist.integrity", "--json"]; + return ["view", spec, "version", "bin", "scripts", "dist.integrity", "--json", "--offline=false"]; } function createActionSpies(inputVersion: string) { @@ -502,21 +502,26 @@ test("installs the CLI with npm into an isolated prefix", async () => { "--no-audit", "--no-fund", "--no-package-lock", + "--offline=false", + "--bin-links=true", "--ignore-scripts=true", "supabase@2.101.0", ], ]); }); -test("runs npm with filtered caller workspace config", async () => { +test("runs npm with caller workspace config and action-owned overrides", async () => { const workspace = createWorkspace({ ".npmrc": [ "registry=https://registry.example.test", "@internal:registry=https://registry.internal.example.test", "//registry.example.test/:_authToken=${NPM_TOKEN}", + "//registry.example.test/:certfile=certs/client.pem", + "//registry.example.test/:keyfile=certs/client-key.pem", "always-auth", - "cafile=certs/project-ca.pem", - "userconfig=${SETUP_CLI_TEST_WORKSPACE}/.npmrc-ci", + 'cafile="certs/project-ca.pem"', + 'userconfig="${SETUP_CLI_TEST_WORKSPACE}/.npmrc-ci"', + "globalconfig=.npmrc-global", "bin-links=false", "offline=true", "package-lock=true", @@ -528,6 +533,12 @@ test("runs npm with filtered caller workspace config", async () => { "bin-links=false", "offline=true", ].join("\n"), + ".npmrc-global": [ + "registry=https://global-registry.example.test", + "//registry.example.test/:username=global", + ].join("\n"), + "certs/client-key.pem": "client-key", + "certs/client.pem": "client", "certs/delegated-ca.pem": "delegated-ca", "certs/project-ca.pem": "project-ca", }); @@ -546,24 +557,15 @@ test("runs npm with filtered caller workspace config", async () => { const realWorkspace = realpathSync(workspace); const npmCwds = readNpmCwds().map((cwd) => realpathSync(cwd)); - expect(npmCwds[0]).not.toBe(realWorkspace); - expect(npmCwds[1]).toBe(npmCwds[0]); + expect(npmCwds).toEqual([realWorkspace, realWorkspace]); expect(readNpmEnvs().map((env) => env.NPM_CONFIG_USERCONFIG)).toEqual([ userconfigPath, userconfigPath, ]); - const filteredConfig = [ - "registry=https://delegated-registry.example.test", - "//registry.example.test/:_password=delegated", - `cafile=${path.join(workspace, "certs", "delegated-ca.pem")}`, - "registry=https://registry.example.test", - "@internal:registry=https://registry.internal.example.test", - "//registry.example.test/:_authToken=${NPM_TOKEN}", - "always-auth=true", - `cafile=${path.join(workspace, "certs", "project-ca.pem")}`, - "", - ].join("\n"); - expect(readNpmPrefixConfigs()).toEqual([filteredConfig, filteredConfig]); + expect(readNpmPrefixConfigs()).toEqual([ + readFileSync(path.join(workspace, ".npmrc"), "utf8"), + null, + ]); expect(readNpmCalls(logPath)).toEqual([ viewMetadataCall("supabase@2.101.0"), [ @@ -575,6 +577,8 @@ test("runs npm with filtered caller workspace config", async () => { "--no-audit", "--no-fund", "--no-package-lock", + "--offline=false", + "--bin-links=true", "--ignore-scripts=true", "supabase@2.101.0", ], @@ -604,6 +608,8 @@ test("allows install scripts for legacy npm packages that declare a preinstall", "--no-audit", "--no-fund", "--no-package-lock", + "--offline=false", + "--bin-links=true", "--ignore-scripts=false", "supabase@1.15.1", ], @@ -633,6 +639,8 @@ test("allows install scripts for npm packages that declare a postinstall", async "--no-audit", "--no-fund", "--no-package-lock", + "--offline=false", + "--bin-links=true", "--ignore-scripts=false", "supabase@1.178.2", ], @@ -660,6 +668,8 @@ test("verifies lockfile integrity before installing", async () => { "--no-audit", "--no-fund", "--no-package-lock", + "--offline=false", + "--bin-links=true", "--ignore-scripts=true", "supabase@2.101.0", ], diff --git a/src/main.ts b/src/main.ts index 0afe6a1..dac8cc7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,6 @@ import { semver } from "bun"; import * as core from "@actions/core"; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -14,23 +14,6 @@ const INSTALL_LIFECYCLE_SCRIPTS = ["preinstall", "install", "postinstall"] as co const SUPPORTED_DIST_TAGS = new Set([DEFAULT_VERSION, "beta"]); const CONCRETE_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; const CONCRETE_VERSION_EXTRACT_PATTERN = /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/; -const INSTALL_NPM_CONFIG_KEYS = new Set([ - "_auth", - "_authToken", - "_password", - "always-auth", - "ca", - "cafile", - "cert", - "email", - "https-proxy", - "key", - "noproxy", - "proxy", - "registry", - "strict-ssl", - "username", -]); type PackageResolution = { spec: string; @@ -45,11 +28,6 @@ type PackageMetadata = { "dist.integrity"?: unknown; }; -type CopiedNpmConfig = { - projectLines: string[]; - userLines: string[]; -}; - type BunLock = { workspaces?: { "": { @@ -265,14 +243,17 @@ function verifyPackageIntegrity(resolution: PackageResolution, metadata: Package } } -async function getPackageMetadata( - resolution: PackageResolution, - npmCwd: string, -): Promise { - const output = await runNpm( - ["view", resolution.spec, "version", "bin", "scripts", "dist.integrity", "--json"], - npmCwd, - ); +async function getPackageMetadata(resolution: PackageResolution): Promise { + const output = await runNpm([ + "view", + resolution.spec, + "version", + "bin", + "scripts", + "dist.integrity", + "--json", + "--offline=false", + ]); const metadata = JSON.parse(output) as unknown; if (!isRecord(metadata)) { @@ -310,143 +291,10 @@ function createInstallRoot(): string { return mkdtempSync(path.join(tempRoot, "setup-cli-")); } -function createCopiedNpmConfig(): CopiedNpmConfig { - return { - projectLines: [], - userLines: [], - }; -} - -function expandNpmConfigEnv(rawValue: string): string | null { - let missingEnv = false; - const expandedValue = rawValue.replace(/\$\{([^}]+)\}/g, (_match, envName: string) => { - const envValue = process.env[envName]; - if (envValue === undefined) { - missingEnv = true; - return ""; - } - - return envValue; - }); - - return missingEnv ? null : expandedValue; -} - -function resolveNpmConfigPath(configRoot: string, rawValue: string): string | null { - const expandedValue = expandNpmConfigEnv(rawValue); - if (!expandedValue) { - return null; - } - - if (expandedValue === "~") { - return os.homedir(); - } - - if (expandedValue.startsWith("~/")) { - return path.join(os.homedir(), expandedValue.slice(2)); - } - - return path.isAbsolute(expandedValue) ? expandedValue : path.join(configRoot, expandedValue); -} - -function copyNpmConfigLines( - configRoot: string, - text: string, - visitedConfigs = new Set(), -): string[] { - const copiedConfig = createCopiedNpmConfig(); - - for (const line of text.split(/\r?\n/)) { - copyNpmConfigLine(configRoot, line, copiedConfig, visitedConfigs); - } - - return [...copiedConfig.userLines, ...copiedConfig.projectLines]; -} - -function copyNpmConfigLine( - configRoot: string, - line: string, - copiedConfig: CopiedNpmConfig, - visitedConfigs: Set, -): void { - const trimmedLine = line.trim(); - if (!trimmedLine || trimmedLine.startsWith("#") || trimmedLine.startsWith(";")) { - return; - } - - const separatorIndex = trimmedLine.indexOf("="); - const rawKey = separatorIndex === -1 ? trimmedLine : trimmedLine.slice(0, separatorIndex).trim(); - const key = rawKey.replace(/\[\]$/, ""); - const rawValue = separatorIndex === -1 ? "true" : trimmedLine.slice(separatorIndex + 1).trim(); - - if (key === "userconfig") { - const userconfigPath = resolveNpmConfigPath(configRoot, rawValue); - if (!userconfigPath || visitedConfigs.has(userconfigPath)) { - return; - } - - try { - visitedConfigs.add(userconfigPath); - copiedConfig.userLines.push( - ...copyNpmConfigLines( - path.dirname(userconfigPath), - readFileSync(userconfigPath, "utf8"), - visitedConfigs, - ), - ); - } catch { - return; - } - - return; - } - - const shouldCopy = - INSTALL_NPM_CONFIG_KEYS.has(key) || - key.endsWith(":registry") || - [...INSTALL_NPM_CONFIG_KEYS].some((configKey) => key.endsWith(`:${configKey}`)); - - if (!shouldCopy) { - return; - } - - if (key === "cafile" || key.endsWith(":cafile")) { - const cafilePath = resolveNpmConfigPath(configRoot, rawValue); - if (!cafilePath) { - return; - } - - copiedConfig.projectLines.push(`${rawKey}=${cafilePath}`); - return; - } - - copiedConfig.projectLines.push(separatorIndex === -1 ? `${rawKey}=true` : trimmedLine); -} - -function copyWorkspaceNpmConfig(installRoot: string): void { - const workspace = process.env.GITHUB_WORKSPACE?.trim(); - if (!workspace) { - return; - } - - const npmrcPath = path.join(workspace, ".npmrc"); - if (!existsSync(npmrcPath)) { - return; - } - - const configLines = copyNpmConfigLines(workspace, readFileSync(npmrcPath, "utf8")); - - if (configLines.length === 0) { - return; - } - - writeFileSync(path.join(installRoot, ".npmrc"), `${configLines.join("\n")}\n`); -} - -async function runNpm(args: string[], cwd?: string): Promise { +async function runNpm(args: string[]): Promise { const executable = process.env[NPM_EXECUTABLE_ENV]?.trim() || "npm"; const proc = Bun.spawn([executable, ...args], { - cwd: cwd ?? process.env.GITHUB_WORKSPACE?.trim() ?? process.cwd(), + cwd: process.env.GITHUB_WORKSPACE?.trim() ?? process.cwd(), env: process.env, stderr: "pipe", stdout: "pipe", @@ -466,27 +314,25 @@ async function runNpm(args: string[], cwd?: string): Promise { export async function installCli(resolution: PackageResolution): Promise { const installRoot = createInstallRoot(); - copyWorkspaceNpmConfig(installRoot); - const metadata = await getPackageMetadata(resolution, installRoot); + const metadata = await getPackageMetadata(resolution); verifyPackageMetadata(resolution, metadata); verifyPackageIntegrity(resolution, metadata); - await runNpm( - [ - "install", - "--prefix", - installRoot, - "--omit=dev", - "--include=optional", - "--no-audit", - "--no-fund", - "--no-package-lock", - `--ignore-scripts=${shouldIgnoreInstallScripts(metadata)}`, - resolution.spec, - ], + await runNpm([ + "install", + "--prefix", installRoot, - ); + "--omit=dev", + "--include=optional", + "--no-audit", + "--no-fund", + "--no-package-lock", + "--offline=false", + "--bin-links=true", + `--ignore-scripts=${shouldIgnoreInstallScripts(metadata)}`, + resolution.spec, + ]); return path.join(installRoot, "node_modules", ".bin"); }