From 2cd90099d120c13ea9cd612d90d9c9c3bf45faf7 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 14 Aug 2026 15:45:37 -0700 Subject: [PATCH] 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 c1e983376..4ba7f847b 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 5135e596d..dc9e23076 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 9649c1b36..85430d959 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 000000000..f5f29f531 --- /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 000000000..c1d25bced --- /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 000000000..cc29e91be --- /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; + } +}