From 2cd90099d120c13ea9cd612d90d9c9c3bf45faf7 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 14 Aug 2026 15:45:37 -0700 Subject: [PATCH 1/2] Add Azure Artifacts npm auth refresh Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .gitignore | 5 + CONTRIBUTING.md | 12 ++ nodejs/package.json | 1 + nodejs/test/npm-auth-refresh.test.ts | 180 +++++++++++++++++++++++++++ scripts/npm-auth-refresh.d.mts | 39 ++++++ scripts/npm-auth-refresh.mjs | 142 +++++++++++++++++++++ 6 files changed, 379 insertions(+) create mode 100644 nodejs/test/npm-auth-refresh.test.ts create mode 100644 scripts/npm-auth-refresh.d.mts create mode 100644 scripts/npm-auth-refresh.mjs diff --git a/.gitignore b/.gitignore index c1e9833769..4ba7f847b1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,11 @@ docs/.validation/ .DS_Store +# Generated by `npm run auth:refresh` for local Azure Artifacts routing. +/nodejs/.npmrc +/test/harness/.npmrc +/java/scripts/codegen/.npmrc + # Visual Studio .vs/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5135e596dd..dc9e230768 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,6 +33,18 @@ We are generally **not** looking for: - Additional documentation - **SDKs for other languages** — if you want to create a Copilot SDK for another language, we'd love to hear from you and may offer to link to your SDK from our repo. However we do not plan to add further language-specific SDKs to this repo in the short term, since we need to retain our maintenance capacity for moving forwards quickly with the existing language set. For other languages, please consider running your own external project. +## Microsoft Contributor Setup + +Microsoft contributors who need recent builds of `@github`-scoped packages from the internal Azure Artifacts feed should run this command from `nodejs`: + +```bash +npm run auth:refresh +``` + +The command generates scoped registry configurations at `nodejs/.npmrc`, `test/harness/.npmrc`, and `java/scripts/codegen/.npmrc`. Each configuration routes only the `@github` scope through the `copilot-canary` feed's `@Local` view, so you can then use the normal dependency installation commands. Credentials remain in your user-level npm configuration rather than in project files. On Windows, the command uses `vsts-npm-auth`; on Linux and macOS, it uses the Microsoft Azure Artifacts npm credential provider. + +Run `npm run auth:refresh` again after an Azure Artifacts 401 or 403 response. To return to public registry behavior, delete the three generated `.npmrc` files. Public contributors do not need this setup and are unaffected. + ## Developing an SDK Setup, build, and test instructions are maintained with each SDK: diff --git a/nodejs/package.json b/nodejs/package.json index 9649c1b364..85430d9595 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -32,6 +32,7 @@ }, "type": "module", "scripts": { + "auth:refresh": "node ../scripts/npm-auth-refresh.mjs --run", "clean": "rimraf --glob dist *.tgz", "build": "tsx esbuild-copilotsdk-nodejs.ts", "test": "vitest run", diff --git a/nodejs/test/npm-auth-refresh.test.ts b/nodejs/test/npm-auth-refresh.test.ts new file mode 100644 index 0000000000..f5f29f5310 --- /dev/null +++ b/nodejs/test/npm-auth-refresh.test.ts @@ -0,0 +1,180 @@ +import { spawnSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + azureFeedLocalRegistry, + buildProjectNpmConfig, + cfsRegistry, + credentialProviderRegistry, + getAuthCommands, + getCommandInvocation, + getProjectNpmrcPaths, + main, + refreshNpmAuthentication, + runCommand, + writeProjectNpmConfigs, +} from "../../scripts/npm-auth-refresh.mjs"; + +const scriptPath = fileURLToPath(new URL("../../scripts/npm-auth-refresh.mjs", import.meta.url)); +const temporaryDirectories: string[] = []; + +async function createTemporaryNpmrcPaths(): Promise { + const repositoryRoot = await mkdtemp(path.join(tmpdir(), "copilot-sdk-npm-auth-")); + temporaryDirectories.push(repositoryRoot); + + const directories = [ + path.join(repositoryRoot, "nodejs"), + path.join(repositoryRoot, "test", "harness"), + path.join(repositoryRoot, "java", "scripts", "codegen"), + ]; + await Promise.all(directories.map((directory) => mkdir(directory, { recursive: true }))); + return directories.map((directory) => path.join(directory, ".npmrc")); +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ); +}); + +describe("local npm authentication refresh", () => { + it.each(["--help", "-h"])("prints help successfully for %s", (flag) => { + const result = spawnSync(process.execPath, [scriptPath, flag], { + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Usage: npm run auth:refresh"); + }); + + it.each([[[]], [["--refresh"]], [["--run", "unexpected"]]])( + "requires the explicit --run argument for %j", + (args) => { + const result = spawnSync(process.execPath, [scriptPath, ...args], { + encoding: "utf8", + }); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("Usage: npm run auth:refresh"); + } + ); + + it("runs authentication only for --run", () => { + const refresh = vi.fn(); + + expect(main(["--run"], refresh)).toBe(0); + expect(refresh).toHaveBeenCalledOnce(); + }); + + it("resolves all project configs from the script URL", () => { + const repositoryRoot = path.resolve(path.dirname(scriptPath), ".."); + expect(getProjectNpmrcPaths(pathToFileURL(scriptPath).href)).toEqual([ + path.join(repositoryRoot, "nodejs", ".npmrc"), + path.join(repositoryRoot, "test", "harness", ".npmrc"), + path.join(repositoryRoot, "java", "scripts", "codegen", ".npmrc"), + ]); + }); + + it("writes only the scoped registry to all three project configs", async () => { + const npmrcPaths = await createTemporaryNpmrcPaths(); + + writeProjectNpmConfigs(npmrcPaths); + + const expected = `@github:registry=${azureFeedLocalRegistry}\n`; + await Promise.all( + npmrcPaths.map(async (npmrcPath) => { + await expect(readFile(npmrcPath, "utf8")).resolves.toBe(expected); + }) + ); + expect(buildProjectNpmConfig()).not.toMatch(/^registry=/m); + expect(buildProjectNpmConfig()).not.toMatch(/(?:_auth|token|password)/i); + }); + + it("authenticates once using the nodejs config", () => { + const npmrcPaths = [ + "C:\\repo\\nodejs\\.npmrc", + "C:\\repo\\test\\harness\\.npmrc", + "C:\\repo\\java\\scripts\\codegen\\.npmrc", + ]; + const writer = vi.fn(); + const runner = vi.fn(); + + refreshNpmAuthentication("win32", npmrcPaths, writer, runner); + + expect(writer).toHaveBeenCalledOnce(); + expect(writer).toHaveBeenCalledWith(npmrcPaths); + expect(runner).toHaveBeenCalledTimes(2); + expect(runner).toHaveBeenLastCalledWith( + "vsts-npm-auth.cmd", + ["-config", npmrcPaths[0], "-Force", "-ReadOnly"], + "win32" + ); + }); + + it("uses vsts-npm-auth on Windows", () => { + expect(getAuthCommands("win32", "C:\\repo\\nodejs\\.npmrc")).toEqual([ + { + command: "npm.cmd", + args: ["install", "--global", "vsts-npm-auth@0.43.0", `--registry=${cfsRegistry}`], + }, + { + command: "vsts-npm-auth.cmd", + args: ["-config", "C:\\repo\\nodejs\\.npmrc", "-Force", "-ReadOnly"], + }, + ]); + }); + + it("launches Windows command shims through the command interpreter", () => { + expect( + getCommandInvocation( + "win32", + "npm.cmd", + ["--version"], + "C:\\Windows\\System32\\cmd.exe" + ) + ).toEqual({ + command: "C:\\Windows\\System32\\cmd.exe", + args: ["/d", "/s", "/c", "npm.cmd", "--version"], + }); + }); + + it("surfaces command spawn errors", () => { + expect(() => + runCommand(path.join(tmpdir(), "copilot-sdk-command-does-not-exist"), [], "linux") + ).toThrow(); + }); + + it("surfaces nonzero command exit statuses", () => { + expect(() => runCommand(process.execPath, ["-e", "process.exit(7)"], "linux")).toThrow( + "exited with code 7" + ); + }); + + it.each(["linux", "darwin"])("uses the Azure credential provider on %s", (platform) => { + expect(getAuthCommands(platform, "/repo/nodejs/.npmrc")).toEqual([ + { + command: "npm", + args: [ + "install", + "--global", + "@microsoft/artifacts-npm-credprovider@1.1.3", + `--registry=${credentialProviderRegistry}`, + `--@microsoft:registry=${credentialProviderRegistry}`, + ], + }, + { + command: "artifacts-npm-credprovider", + args: ["-c", "/repo/nodejs/.npmrc"], + }, + ]); + expect(getCommandInvocation(platform, "npm", ["--version"])).toEqual({ + command: "npm", + args: ["--version"], + }); + }); +}); diff --git a/scripts/npm-auth-refresh.d.mts b/scripts/npm-auth-refresh.d.mts new file mode 100644 index 0000000000..c1d25bced9 --- /dev/null +++ b/scripts/npm-auth-refresh.d.mts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +export interface AuthCommand { + command: string; + args: string[]; +} + +export type ConfigWriter = (npmrcPaths: string[]) => void; +export type CommandRunner = (command: string, args: string[], platform: string) => void; +export type AuthRefresher = () => void; + +export const azureFeedLocalRegistry: string; +export const cfsRegistry: string; +export const credentialProviderRegistry: string; +export function getProjectNpmrcPaths(scriptUrl?: string): string[]; +export function buildProjectNpmConfig(): string; +export function writeProjectNpmConfigs(npmrcPaths: string[]): void; +export function getAuthCommands(platform: string, npmrcPath: string): AuthCommand[]; +export function getCommandInvocation( + platform: string, + command: string, + args: string[], + commandInterpreter?: string +): AuthCommand; +export function runCommand( + command: string, + args: string[], + platform?: string, + commandInterpreter?: string +): void; +export function refreshNpmAuthentication( + platform?: string, + npmrcPaths?: string[], + writer?: ConfigWriter, + runner?: CommandRunner +): void; +export function main(args?: string[], refresh?: AuthRefresher): number; diff --git a/scripts/npm-auth-refresh.mjs b/scripts/npm-auth-refresh.mjs new file mode 100644 index 0000000000..cc29e91bef --- /dev/null +++ b/scripts/npm-auth-refresh.mjs @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { spawnSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +export const azureFeedLocalRegistry = + "https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary@Local/npm/registry/"; +export const cfsRegistry = "https://packagefeedproxy.microsoft.io/npm/"; +export const credentialProviderRegistry = + "https://pkgs.dev.azure.com/artifacts-public/23934c1b-a3b5-4b70-9dd3-d1bef4cc72a0/_packaging/AzureArtifacts/npm/registry/"; + +export function getProjectNpmrcPaths(scriptUrl = import.meta.url) { + const repositoryRoot = path.resolve(path.dirname(fileURLToPath(scriptUrl)), ".."); + return [ + path.join(repositoryRoot, "nodejs", ".npmrc"), + path.join(repositoryRoot, "test", "harness", ".npmrc"), + path.join(repositoryRoot, "java", "scripts", "codegen", ".npmrc"), + ]; +} + +export function buildProjectNpmConfig() { + return `@github:registry=${azureFeedLocalRegistry}\n`; +} + +export function writeProjectNpmConfigs(npmrcPaths) { + const config = buildProjectNpmConfig(); + for (const npmrcPath of npmrcPaths) { + writeFileSync(npmrcPath, config, "utf8"); + } +} + +export function getAuthCommands(platform, npmrcPath) { + if (platform === "win32") { + return [ + { + command: "npm.cmd", + args: ["install", "--global", "vsts-npm-auth@0.43.0", `--registry=${cfsRegistry}`], + }, + { + command: "vsts-npm-auth.cmd", + args: ["-config", npmrcPath, "-Force", "-ReadOnly"], + }, + ]; + } + + return [ + { + command: "npm", + args: [ + "install", + "--global", + "@microsoft/artifacts-npm-credprovider@1.1.3", + `--registry=${credentialProviderRegistry}`, + `--@microsoft:registry=${credentialProviderRegistry}`, + ], + }, + { + command: "artifacts-npm-credprovider", + args: ["-c", npmrcPath], + }, + ]; +} + +export function getCommandInvocation(platform, command, args, commandInterpreter = "cmd.exe") { + if (platform === "win32") { + return { + command: commandInterpreter, + args: ["/d", "/s", "/c", command, ...args], + }; + } + + return { command, args }; +} + +export function runCommand( + command, + args, + platform = process.platform, + commandInterpreter = process.env.ComSpec ?? "cmd.exe" +) { + const invocation = getCommandInvocation(platform, command, args, commandInterpreter); + const result = spawnSync(invocation.command, invocation.args, { + stdio: "inherit", + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + const outcome = + result.status === null + ? `terminated by signal ${result.signal ?? "unknown"}` + : `exited with code ${result.status}`; + throw new Error(`${command} ${outcome}`); + } +} + +export function refreshNpmAuthentication( + platform = process.platform, + npmrcPaths = getProjectNpmrcPaths(), + writer = writeProjectNpmConfigs, + runner = runCommand +) { + writer(npmrcPaths); + for (const { command, args } of getAuthCommands(platform, npmrcPaths[0])) { + runner(command, args, platform); + } +} + +function usage() { + console.log(`Usage: npm run auth:refresh + +Generate scoped project .npmrc files for the copilot-canary @Local view, then +refresh Azure Artifacts credentials in the user-level npm configuration.`); +} + +export function main(args = process.argv.slice(2), refresh = refreshNpmAuthentication) { + if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) { + usage(); + return 0; + } + + if (args.length !== 1 || args[0] !== "--run") { + usage(); + return 1; + } + + refresh(); + return 0; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + process.exitCode = main(); + } catch (error) { + console.error(error); + process.exitCode = 1; + } +} From 10229701e22ffce6ef0b95622296f3d7629d561d Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 8 Sep 2026 14:12:16 -0700 Subject: [PATCH 2/2] Fix npm authentication refresh review feedback Preserve local npm settings, force Unix credential refresh, and keep Windows command execution independent of ComSpec and repository path arguments. Document running the helper from the repository root. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ee3a1493-bf0d-4e08-9e13-03b3999affe0 --- CONTRIBUTING.md | 12 +-- nodejs/test/npm-auth-refresh.test.ts | 123 ++++++++++++++++++++++----- scripts/npm-auth-refresh.d.mts | 20 ++--- scripts/npm-auth-refresh.mjs | 52 ++++++----- 4 files changed, 152 insertions(+), 55 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dc9e230768..4e7a3ee1e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,15 +35,17 @@ We are generally **not** looking for: ## Microsoft Contributor Setup -Microsoft contributors who need recent builds of `@github`-scoped packages from the internal Azure Artifacts feed should run this command from `nodejs`: +Microsoft contributors who need recent builds of `@github`-scoped packages from the internal Azure Artifacts feed can run this command from PowerShell at the repository root: -```bash -npm run auth:refresh +```powershell +node .\scripts\npm-auth-refresh.mjs --run ``` -The command generates scoped registry configurations at `nodejs/.npmrc`, `test/harness/.npmrc`, and `java/scripts/codegen/.npmrc`. Each configuration routes only the `@github` scope through the `copilot-canary` feed's `@Local` view, so you can then use the normal dependency installation commands. Credentials remain in your user-level npm configuration rather than in project files. On Windows, the command uses `vsts-npm-auth`; on Linux and macOS, it uses the Microsoft Azure Artifacts npm credential provider. +Alternatively, on any platform, run `npm run auth:refresh` from the `nodejs` directory. -Run `npm run auth:refresh` again after an Azure Artifacts 401 or 403 response. To return to public registry behavior, delete the three generated `.npmrc` files. Public contributors do not need this setup and are unaffected. +The command creates or updates scoped registry configurations at `nodejs/.npmrc`, `test/harness/.npmrc`, and `java/scripts/codegen/.npmrc`, preserving unrelated settings. Each configuration routes only the `@github` scope through the `copilot-canary` feed's `@Local` view, so you can then use the normal dependency installation commands. Credentials remain in your user-level npm configuration rather than in project files. On Windows, the command uses `vsts-npm-auth`; on Linux and macOS, it uses the Microsoft Azure Artifacts npm credential provider. Both paths force a credential refresh. + +Run `node .\scripts\npm-auth-refresh.mjs --run` from PowerShell at the repository root again after an Azure Artifacts 401 or 403 response, or rerun `npm run auth:refresh` from `nodejs`. To return to your previous registry behavior, remove the `@github:registry` entry from each of the three `.npmrc` files, or restore its previous value if you had a custom entry. Delete a file only if it contains no other settings. Public contributors do not need this setup and are unaffected. ## Developing an SDK diff --git a/nodejs/test/npm-auth-refresh.test.ts b/nodejs/test/npm-auth-refresh.test.ts index f5f29f5310..19a074d9fc 100644 --- a/nodejs/test/npm-auth-refresh.test.ts +++ b/nodejs/test/npm-auth-refresh.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -20,8 +20,21 @@ import { writeProjectNpmConfigs, } from "../../scripts/npm-auth-refresh.mjs"; +vi.mock("node:child_process", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, spawnSync: vi.fn(original.spawnSync) }; +}); + const scriptPath = fileURLToPath(new URL("../../scripts/npm-auth-refresh.mjs", import.meta.url)); const temporaryDirectories: string[] = []; +const successfulSpawn: ReturnType = { + pid: 1, + output: [], + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + status: 0, + signal: null, +}; async function createTemporaryNpmrcPaths(): Promise { const repositoryRoot = await mkdtemp(path.join(tmpdir(), "copilot-sdk-npm-auth-")); @@ -37,6 +50,8 @@ async function createTemporaryNpmrcPaths(): Promise { } afterEach(async () => { + vi.mocked(spawnSync).mockReset(); + vi.unstubAllEnvs(); await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) ); @@ -95,6 +110,59 @@ describe("local npm authentication refresh", () => { expect(buildProjectNpmConfig()).not.toMatch(/(?:_auth|token|password)/i); }); + it.each(["\n", "\r\n"])( + "preserves unrelated config content using %j line endings", + async (newline) => { + const npmrcPaths = await createTemporaryNpmrcPaths(); + const unrelatedLines = [ + "; Local npm settings", + "registry=https://registry.npmjs.org/", + "@other:registry=https://example.com/npm/", + "# @github:registry=https://example.com/commented/", + "strict-ssl=true", + ]; + const existingConfig = [ + ...unrelatedLines.slice(0, 2), + "@github:registry=https://example.com/old/", + ...unrelatedLines.slice(2), + "\t@github:registry = https://example.com/duplicate/", + ].join(newline); + await Promise.all(npmrcPaths.map((npmrcPath) => writeFile(npmrcPath, existingConfig))); + + const expected = [ + ...unrelatedLines, + `@github:registry=${azureFeedLocalRegistry}`, + "", + ].join(newline); + for (let refresh = 0; refresh < 2; refresh++) { + writeProjectNpmConfigs(npmrcPaths); + await Promise.all( + npmrcPaths.map(async (npmrcPath) => { + await expect(readFile(npmrcPath, "utf8")).resolves.toBe(expected); + }) + ); + } + } + ); + + it.each(["strict-ssl=true", "strict-ssl=true\n", "strict-ssl=true\r\n"])( + "adds the scoped registry without joining existing settings for %j", + (existingConfig) => { + const newline = existingConfig.includes("\r\n") ? "\r\n" : "\n"; + const expected = `strict-ssl=true${newline}@github:registry=${azureFeedLocalRegistry}${newline}`; + + expect(buildProjectNpmConfig(existingConfig)).toBe(expected); + expect(buildProjectNpmConfig(expected)).toBe(expected); + } + ); + + it("surfaces errors reading existing configs", async () => { + const npmrcPaths = await createTemporaryNpmrcPaths(); + await mkdir(npmrcPaths[0]); + + expect(() => writeProjectNpmConfigs(npmrcPaths)).toThrow(); + }); + it("authenticates once using the nodejs config", () => { const npmrcPaths = [ "C:\\repo\\nodejs\\.npmrc", @@ -111,8 +179,9 @@ describe("local npm authentication refresh", () => { expect(runner).toHaveBeenCalledTimes(2); expect(runner).toHaveBeenLastCalledWith( "vsts-npm-auth.cmd", - ["-config", npmrcPaths[0], "-Force", "-ReadOnly"], - "win32" + ["-config", ".npmrc", "-Force", "-ReadOnly"], + "win32", + "C:\\repo\\nodejs" ); }); @@ -124,35 +193,48 @@ describe("local npm authentication refresh", () => { }, { command: "vsts-npm-auth.cmd", - args: ["-config", "C:\\repo\\nodejs\\.npmrc", "-Force", "-ReadOnly"], + args: ["-config", ".npmrc", "-Force", "-ReadOnly"], + cwd: "C:\\repo\\nodejs", }, ]); }); it("launches Windows command shims through the command interpreter", () => { - expect( - getCommandInvocation( - "win32", - "npm.cmd", - ["--version"], - "C:\\Windows\\System32\\cmd.exe" - ) - ).toEqual({ - command: "C:\\Windows\\System32\\cmd.exe", + expect(getCommandInvocation("win32", "npm.cmd", ["--version"])).toEqual({ + command: "cmd.exe", args: ["/d", "/s", "/c", "npm.cmd", "--version"], }); }); + it.each([ + "C:\\repo with spaces\\nodejs\\.npmrc", + "C:\\repo&other\\nodejs\\.npmrc", + "C:\\repo%TEMP%^!()\\nodejs\\.npmrc", + ])("ignores ComSpec and keeps the config path out of shell arguments for %s", (npmrcPath) => { + vi.stubEnv("ComSpec", "C:\\untrusted\\not-cmd.exe"); + vi.mocked(spawnSync).mockReturnValueOnce(successfulSpawn); + const { command, args, cwd } = getAuthCommands("win32", npmrcPath)[1]; + + runCommand(command, args, "win32", cwd); + + expect(spawnSync).toHaveBeenCalledExactlyOnceWith( + "cmd.exe", + ["/d", "/s", "/c", "vsts-npm-auth.cmd", "-config", ".npmrc", "-Force", "-ReadOnly"], + { stdio: "inherit", cwd: path.win32.dirname(npmrcPath) } + ); + }); + it("surfaces command spawn errors", () => { - expect(() => - runCommand(path.join(tmpdir(), "copilot-sdk-command-does-not-exist"), [], "linux") - ).toThrow(); + const error = new Error("Unable to spawn command"); + vi.mocked(spawnSync).mockReturnValueOnce({ ...successfulSpawn, status: null, error }); + + expect(() => runCommand("npm", ["--version"], "linux")).toThrow(error); }); it("surfaces nonzero command exit statuses", () => { - expect(() => runCommand(process.execPath, ["-e", "process.exit(7)"], "linux")).toThrow( - "exited with code 7" - ); + vi.mocked(spawnSync).mockReturnValueOnce({ ...successfulSpawn, status: 7 }); + + expect(() => runCommand("npm", ["--version"], "linux")).toThrow("exited with code 7"); }); it.each(["linux", "darwin"])("uses the Azure credential provider on %s", (platform) => { @@ -169,7 +251,8 @@ describe("local npm authentication refresh", () => { }, { command: "artifacts-npm-credprovider", - args: ["-c", "/repo/nodejs/.npmrc"], + args: ["-f", "-c", ".npmrc"], + cwd: "/repo/nodejs", }, ]); expect(getCommandInvocation(platform, "npm", ["--version"])).toEqual({ diff --git a/scripts/npm-auth-refresh.d.mts b/scripts/npm-auth-refresh.d.mts index c1d25bced9..a1c9985646 100644 --- a/scripts/npm-auth-refresh.d.mts +++ b/scripts/npm-auth-refresh.d.mts @@ -5,31 +5,31 @@ export interface AuthCommand { command: string; args: string[]; + cwd?: string; } export type ConfigWriter = (npmrcPaths: string[]) => void; -export type CommandRunner = (command: string, args: string[], platform: string) => void; +export type CommandRunner = ( + command: string, + args: string[], + platform: string, + cwd?: string +) => void; export type AuthRefresher = () => void; export const azureFeedLocalRegistry: string; export const cfsRegistry: string; export const credentialProviderRegistry: string; export function getProjectNpmrcPaths(scriptUrl?: string): string[]; -export function buildProjectNpmConfig(): string; +export function buildProjectNpmConfig(existingConfig?: string): string; export function writeProjectNpmConfigs(npmrcPaths: string[]): void; export function getAuthCommands(platform: string, npmrcPath: string): AuthCommand[]; export function getCommandInvocation( platform: string, command: string, - args: string[], - commandInterpreter?: string + args: string[] ): AuthCommand; -export function runCommand( - command: string, - args: string[], - platform?: string, - commandInterpreter?: string -): void; +export function runCommand(command: string, args: string[], platform?: string, cwd?: string): void; export function refreshNpmAuthentication( platform?: string, npmrcPaths?: string[], diff --git a/scripts/npm-auth-refresh.mjs b/scripts/npm-auth-refresh.mjs index cc29e91bef..994fcf5db8 100644 --- a/scripts/npm-auth-refresh.mjs +++ b/scripts/npm-auth-refresh.mjs @@ -3,7 +3,7 @@ *--------------------------------------------------------------------------------------------*/ import { spawnSync } from "node:child_process"; -import { writeFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -22,18 +22,32 @@ export function getProjectNpmrcPaths(scriptUrl = import.meta.url) { ]; } -export function buildProjectNpmConfig() { - return `@github:registry=${azureFeedLocalRegistry}\n`; +export function buildProjectNpmConfig(existingConfig = "") { + const newline = existingConfig.includes("\r\n") ? "\r\n" : "\n"; + const config = existingConfig.replace( + /^[ \t]*@github:registry[ \t]*=[^\r\n]*(?:\r?\n|$)/gm, + "" + ); + const separator = config.length > 0 && !config.endsWith("\n") ? newline : ""; + return `${config}${separator}@github:registry=${azureFeedLocalRegistry}${newline}`; } export function writeProjectNpmConfigs(npmrcPaths) { - const config = buildProjectNpmConfig(); for (const npmrcPath of npmrcPaths) { - writeFileSync(npmrcPath, config, "utf8"); + let existingConfig = ""; + try { + existingConfig = readFileSync(npmrcPath, "utf8"); + } catch (error) { + if (error.code !== "ENOENT") { + throw error; + } + } + writeFileSync(npmrcPath, buildProjectNpmConfig(existingConfig), "utf8"); } } export function getAuthCommands(platform, npmrcPath) { + const cwd = (platform === "win32" ? path.win32 : path.posix).dirname(npmrcPath); if (platform === "win32") { return [ { @@ -42,7 +56,8 @@ export function getAuthCommands(platform, npmrcPath) { }, { command: "vsts-npm-auth.cmd", - args: ["-config", npmrcPath, "-Force", "-ReadOnly"], + args: ["-config", ".npmrc", "-Force", "-ReadOnly"], + cwd, }, ]; } @@ -60,15 +75,16 @@ export function getAuthCommands(platform, npmrcPath) { }, { command: "artifacts-npm-credprovider", - args: ["-c", npmrcPath], + args: ["-f", "-c", ".npmrc"], + cwd, }, ]; } -export function getCommandInvocation(platform, command, args, commandInterpreter = "cmd.exe") { +export function getCommandInvocation(platform, command, args) { if (platform === "win32") { return { - command: commandInterpreter, + command: "cmd.exe", args: ["/d", "/s", "/c", command, ...args], }; } @@ -76,15 +92,11 @@ export function getCommandInvocation(platform, command, args, commandInterpreter return { command, args }; } -export function runCommand( - command, - args, - platform = process.platform, - commandInterpreter = process.env.ComSpec ?? "cmd.exe" -) { - const invocation = getCommandInvocation(platform, command, args, commandInterpreter); +export function runCommand(command, args, platform = process.platform, cwd) { + const invocation = getCommandInvocation(platform, command, args); const result = spawnSync(invocation.command, invocation.args, { stdio: "inherit", + cwd, }); if (result.error) { throw result.error; @@ -105,15 +117,15 @@ export function refreshNpmAuthentication( runner = runCommand ) { writer(npmrcPaths); - for (const { command, args } of getAuthCommands(platform, npmrcPaths[0])) { - runner(command, args, platform); + for (const { command, args, cwd } of getAuthCommands(platform, npmrcPaths[0])) { + runner(command, args, platform, cwd); } } function usage() { - console.log(`Usage: npm run auth:refresh + console.log(`Usage: npm run auth:refresh (from the nodejs directory) -Generate scoped project .npmrc files for the copilot-canary @Local view, then +Update scoped project .npmrc files for the copilot-canary @Local view, then refresh Azure Artifacts credentials in the user-level npm configuration.`); }