Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
14 changes: 14 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ 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 can run this command from PowerShell at the repository root:

```powershell
node .\scripts\npm-auth-refresh.mjs --run
```

Alternatively, on any platform, run `npm run auth:refresh` from the `nodejs` directory.

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

Setup, build, and test instructions are maintained with each SDK:
Expand Down
1 change: 1 addition & 0 deletions nodejs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
},
"type": "module",
"scripts": {
"auth:refresh": "node ../scripts/npm-auth-refresh.mjs --run",
"clean": "rimraf --glob dist *.tgz",
"build": "tsx esbuild-copilotsdk-nodejs.ts",
"pack:release": "tsx scripts/package-sdk.ts",
Expand Down
263 changes: 263 additions & 0 deletions nodejs/test/npm-auth-refresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import { spawnSync } from "node:child_process";
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";

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";

vi.mock("node:child_process", async (importOriginal) => {
const original = await importOriginal<typeof import("node:child_process")>();
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<typeof spawnSync> = {
pid: 1,
output: [],
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
status: 0,
signal: null,
};

async function createTemporaryNpmrcPaths(): Promise<string[]> {
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 () => {
vi.mocked(spawnSync).mockReset();
vi.unstubAllEnvs();
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.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",
"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", ".npmrc", "-Force", "-ReadOnly"],
"win32",
"C:\\repo\\nodejs"
);
});

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", ".npmrc", "-Force", "-ReadOnly"],
cwd: "C:\\repo\\nodejs",
},
]);
});

it("launches Windows command shims through the command interpreter", () => {
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", () => {
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", () => {
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) => {
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: ["-f", "-c", ".npmrc"],
cwd: "/repo/nodejs",
},
]);
expect(getCommandInvocation(platform, "npm", ["--version"])).toEqual({
command: "npm",
args: ["--version"],
});
});
});
39 changes: 39 additions & 0 deletions scripts/npm-auth-refresh.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

export interface AuthCommand {
command: string;
args: string[];
cwd?: string;
}

export type ConfigWriter = (npmrcPaths: 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(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[]
): AuthCommand;
export function runCommand(command: string, args: string[], platform?: string, cwd?: string): void;
export function refreshNpmAuthentication(
platform?: string,
npmrcPaths?: string[],
writer?: ConfigWriter,
runner?: CommandRunner
): void;
export function main(args?: string[], refresh?: AuthRefresher): number;
Loading
Loading