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
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions nodejs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
180 changes: 180 additions & 0 deletions nodejs/test/npm-auth-refresh.test.ts
Original file line number Diff line number Diff line change
@@ -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<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 () => {
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"],
});
});
});
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[];
}

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