Skip to content

Commit 6f7287d

Browse files
authored
feat: add registry-url and scope inputs for npm auth (#9)
## Summary - Add `registry-url` input to configure npm registry authentication via `.npmrc` - Add `scope` input for scoped registries (auto-detects repo owner for GitHub Packages) - Ported from `actions/setup-node`'s authutil.ts implementation - Writes `.npmrc` at `$RUNNER_TEMP`, exports `NPM_CONFIG_USERCONFIG` and `NODE_AUTH_TOKEN` ## Usage ```yaml - uses: voidzero-dev/setup-vp@v1 with: registry-url: "https://npm.pkg.github.com" scope: "@myorg" - run: vp install env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ``` ## Test plan - [x] 10 unit tests for auth module (`src/auth.test.ts`) - [x] All 85 tests pass - [x] CI job `test-registry-url` verifies `.npmrc` is created correctly - [x] README updated with usage example and input docs 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent e1609b4 commit 6f7287d

File tree

9 files changed

+391
-85
lines changed

9 files changed

+391
-85
lines changed

.github/workflows/test.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,30 @@ jobs:
131131
echo "Installed version: ${{ steps.setup.outputs.version }}"
132132
echo "Cache hit: ${{ steps.setup.outputs.cache-hit }}"
133133
134+
test-registry-url:
135+
runs-on: ubuntu-latest
136+
steps:
137+
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
138+
139+
- name: Setup Vite+ with registry-url
140+
uses: ./
141+
with:
142+
run-install: false
143+
cache: false
144+
registry-url: "https://npm.pkg.github.com"
145+
scope: "@voidzero-dev"
146+
147+
- name: Verify .npmrc was created
148+
run: |
149+
echo "NPM_CONFIG_USERCONFIG=$NPM_CONFIG_USERCONFIG"
150+
cat "$NPM_CONFIG_USERCONFIG"
151+
grep -q "@voidzero-dev:registry=https://npm.pkg.github.com/" "$NPM_CONFIG_USERCONFIG"
152+
grep -q "_authToken=\${NODE_AUTH_TOKEN}" "$NPM_CONFIG_USERCONFIG"
153+
154+
- name: Verify NODE_AUTH_TOKEN is exported
155+
run: |
156+
echo "NODE_AUTH_TOKEN is set: ${NODE_AUTH_TOKEN:+yes}"
157+
134158
build:
135159
runs-on: ubuntu-latest
136160
steps:

README.md

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,24 @@ steps:
8080
- cwd: ./packages/lib
8181
```
8282
83+
### With Private Registry (GitHub Packages)
84+
85+
When using `registry-url`, set `run-install: false` and run install manually with the auth token, otherwise the default auto-install will fail for private packages.
86+
87+
```yaml
88+
steps:
89+
- uses: actions/checkout@v6
90+
- uses: voidzero-dev/setup-vp@v1
91+
with:
92+
node-version: "22"
93+
registry-url: "https://npm.pkg.github.com"
94+
scope: "@myorg"
95+
run-install: false
96+
- run: vp install
97+
env:
98+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
99+
```
100+
83101
### Matrix Testing with Multiple Node.js Versions
84102

85103
```yaml
@@ -100,14 +118,16 @@ jobs:
100118

101119
## Inputs
102120

103-
| Input | Description | Required | Default |
104-
| ----------------------- | ----------------------------------------------------------------------------------------------------- | -------- | ------------- |
105-
| `version` | Version of Vite+ to install | No | `latest` |
106-
| `node-version` | Node.js version to install via `vp env use` | No | Latest LTS |
107-
| `node-version-file` | Path to file containing Node.js version (`.nvmrc`, `.node-version`, `.tool-versions`, `package.json`) | No | |
108-
| `run-install` | Run `vp install` after setup. Accepts boolean or YAML object with `cwd`/`args` | No | `true` |
109-
| `cache` | Enable caching of project dependencies | No | `false` |
110-
| `cache-dependency-path` | Path to lock file for cache key generation | No | Auto-detected |
121+
| Input | Description | Required | Default |
122+
| ----------------------- | --------------------------------------------------------------------------------------------------------- | -------- | ------------- |
123+
| `version` | Version of Vite+ to install | No | `latest` |
124+
| `node-version` | Node.js version to install via `vp env use` | No | Latest LTS |
125+
| `node-version-file` | Path to file containing Node.js version (`.nvmrc`, `.node-version`, `.tool-versions`, `package.json`) | No | |
126+
| `run-install` | Run `vp install` after setup. Accepts boolean or YAML object with `cwd`/`args` | No | `true` |
127+
| `cache` | Enable caching of project dependencies | No | `false` |
128+
| `cache-dependency-path` | Path to lock file for cache key generation | No | Auto-detected |
129+
| `registry-url` | Optional registry to set up for auth. Sets the registry in `.npmrc` and reads auth from `NODE_AUTH_TOKEN` | No | |
130+
| `scope` | Optional scope for scoped registries. Falls back to repo owner for GitHub Packages | No | |
111131

112132
## Outputs
113133

action.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ inputs:
2727
cache-dependency-path:
2828
description: "Path to lock file for cache key generation. Auto-detected if not specified."
2929
required: false
30+
registry-url:
31+
description: "Optional registry to set up for auth. Will write .npmrc in $RUNNER_TEMP with registry and auth config, and set NPM_CONFIG_USERCONFIG to point npm at it. Auth token is read from env.NODE_AUTH_TOKEN."
32+
required: false
33+
scope:
34+
description: "Optional scope for authenticating against scoped registries. Will fall back to the repository owner when using the GitHub Packages registry (https://npm.pkg.github.com/)."
35+
required: false
3036

3137
outputs:
3238
version:

dist/index.mjs

Lines changed: 75 additions & 75 deletions
Large diffs are not rendered by default.

src/auth.test.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test";
2+
import { join } from "node:path";
3+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
4+
import { configAuthentication } from "./auth.js";
5+
import { exportVariable } from "@actions/core";
6+
7+
vi.mock("@actions/core", () => ({
8+
debug: vi.fn(),
9+
exportVariable: vi.fn(),
10+
}));
11+
12+
vi.mock("node:fs", async () => {
13+
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
14+
return {
15+
...actual,
16+
existsSync: vi.fn(),
17+
readFileSync: vi.fn(),
18+
writeFileSync: vi.fn(),
19+
};
20+
});
21+
22+
describe("configAuthentication", () => {
23+
const runnerTemp = "/tmp/runner";
24+
25+
beforeEach(() => {
26+
vi.stubEnv("RUNNER_TEMP", runnerTemp);
27+
vi.mocked(existsSync).mockReturnValue(false);
28+
});
29+
30+
afterEach(() => {
31+
vi.unstubAllEnvs();
32+
vi.resetAllMocks();
33+
});
34+
35+
it("should write .npmrc with registry and auth token", () => {
36+
configAuthentication("https://registry.npmjs.org/");
37+
38+
const expectedPath = join(runnerTemp, ".npmrc");
39+
expect(writeFileSync).toHaveBeenCalledWith(
40+
expectedPath,
41+
expect.stringContaining("//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}"),
42+
);
43+
expect(writeFileSync).toHaveBeenCalledWith(
44+
expectedPath,
45+
expect.stringContaining("registry=https://registry.npmjs.org/"),
46+
);
47+
});
48+
49+
it("should append trailing slash if missing", () => {
50+
configAuthentication("https://registry.npmjs.org");
51+
52+
expect(writeFileSync).toHaveBeenCalledWith(
53+
expect.any(String),
54+
expect.stringContaining("registry=https://registry.npmjs.org/"),
55+
);
56+
});
57+
58+
it("should auto-detect scope for GitHub Packages registry", () => {
59+
vi.stubEnv("GITHUB_REPOSITORY_OWNER", "voidzero-dev");
60+
61+
configAuthentication("https://npm.pkg.github.com");
62+
63+
expect(writeFileSync).toHaveBeenCalledWith(
64+
expect.any(String),
65+
expect.stringContaining("@voidzero-dev:registry=https://npm.pkg.github.com/"),
66+
);
67+
});
68+
69+
it("should use explicit scope", () => {
70+
configAuthentication("https://npm.pkg.github.com", "@myorg");
71+
72+
expect(writeFileSync).toHaveBeenCalledWith(
73+
expect.any(String),
74+
expect.stringContaining("@myorg:registry=https://npm.pkg.github.com/"),
75+
);
76+
});
77+
78+
it("should prepend @ to scope if missing", () => {
79+
configAuthentication("https://npm.pkg.github.com", "myorg");
80+
81+
expect(writeFileSync).toHaveBeenCalledWith(
82+
expect.any(String),
83+
expect.stringContaining("@myorg:registry=https://npm.pkg.github.com/"),
84+
);
85+
});
86+
87+
it("should lowercase scope", () => {
88+
configAuthentication("https://npm.pkg.github.com", "@MyOrg");
89+
90+
expect(writeFileSync).toHaveBeenCalledWith(
91+
expect.any(String),
92+
expect.stringContaining("@myorg:registry=https://npm.pkg.github.com/"),
93+
);
94+
});
95+
96+
it("should preserve existing .npmrc content except registry and auth lines", () => {
97+
vi.mocked(existsSync).mockReturnValue(true);
98+
vi.mocked(readFileSync).mockReturnValue(
99+
[
100+
"always-auth=true",
101+
"registry=https://old.reg/",
102+
"//old.reg/:_authToken=${NODE_AUTH_TOKEN}",
103+
].join("\n"),
104+
);
105+
106+
configAuthentication("https://registry.npmjs.org/");
107+
108+
const written = vi.mocked(writeFileSync).mock.calls[0]![1] as string;
109+
expect(written).toContain("always-auth=true");
110+
expect(written).not.toContain("https://old.reg/");
111+
expect(written).toContain("registry=https://registry.npmjs.org/");
112+
});
113+
114+
it("should remove existing auth token lines for the same registry", () => {
115+
vi.mocked(existsSync).mockReturnValue(true);
116+
vi.mocked(readFileSync).mockReturnValue(
117+
[
118+
"//registry.npmjs.org/:_authToken=old-token",
119+
"registry=https://registry.npmjs.org/",
120+
"other-config=true",
121+
].join("\n"),
122+
);
123+
124+
configAuthentication("https://registry.npmjs.org/");
125+
126+
const written = vi.mocked(writeFileSync).mock.calls[0]![1] as string;
127+
expect(written).not.toContain("old-token");
128+
expect(written).toContain("other-config=true");
129+
expect(written).toContain("//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}");
130+
});
131+
132+
it("should handle Windows-style line endings in existing .npmrc", () => {
133+
vi.mocked(existsSync).mockReturnValue(true);
134+
vi.mocked(readFileSync).mockReturnValue("always-auth=true\r\nregistry=https://old.reg/\r\n");
135+
136+
configAuthentication("https://registry.npmjs.org/");
137+
138+
const written = vi.mocked(writeFileSync).mock.calls[0]![1] as string;
139+
expect(written).toContain("always-auth=true");
140+
expect(written).not.toContain("https://old.reg/");
141+
});
142+
143+
it("should not auto-detect scope for lookalike GitHub Packages URLs", () => {
144+
vi.stubEnv("GITHUB_REPOSITORY_OWNER", "voidzero-dev");
145+
146+
configAuthentication("https://npm.pkg.github.com.evil.example");
147+
148+
const written = vi.mocked(writeFileSync).mock.calls[0]![1] as string;
149+
// Should NOT have scoped registry — the host doesn't match exactly
150+
expect(written).not.toContain("@voidzero-dev:");
151+
});
152+
153+
it("should throw on invalid URL", () => {
154+
expect(() => configAuthentication("not-a-url")).toThrow("Invalid registry-url");
155+
});
156+
157+
it("should export NPM_CONFIG_USERCONFIG", () => {
158+
configAuthentication("https://registry.npmjs.org/");
159+
160+
expect(exportVariable).toHaveBeenCalledWith(
161+
"NPM_CONFIG_USERCONFIG",
162+
join(runnerTemp, ".npmrc"),
163+
);
164+
});
165+
166+
it("should export NODE_AUTH_TOKEN placeholder when not set", () => {
167+
configAuthentication("https://registry.npmjs.org/");
168+
169+
expect(exportVariable).toHaveBeenCalledWith("NODE_AUTH_TOKEN", "XXXXX-XXXXX-XXXXX-XXXXX");
170+
});
171+
172+
it("should preserve existing NODE_AUTH_TOKEN", () => {
173+
vi.stubEnv("NODE_AUTH_TOKEN", "my-real-token");
174+
175+
configAuthentication("https://registry.npmjs.org/");
176+
177+
expect(exportVariable).toHaveBeenCalledWith("NODE_AUTH_TOKEN", "my-real-token");
178+
});
179+
});

src/auth.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
2+
import { EOL } from "node:os";
3+
import { resolve } from "node:path";
4+
import { debug, exportVariable } from "@actions/core";
5+
6+
/**
7+
* Configure npm registry authentication by writing a .npmrc file.
8+
* Ported from actions/setup-node's authutil.ts.
9+
*/
10+
export function configAuthentication(registryUrl: string, scope?: string): void {
11+
// Validate and normalize the registry URL
12+
let url: URL;
13+
try {
14+
url = new URL(registryUrl);
15+
} catch {
16+
throw new Error(`Invalid registry-url: "${registryUrl}". Must be a valid URL.`);
17+
}
18+
19+
// Ensure trailing slash
20+
const normalizedUrl = url.href.endsWith("/") ? url.href : url.href + "/";
21+
const npmrc = resolve(process.env.RUNNER_TEMP || process.cwd(), ".npmrc");
22+
23+
writeRegistryToFile(normalizedUrl, npmrc, scope);
24+
}
25+
26+
function writeRegistryToFile(registryUrl: string, fileLocation: string, scope?: string): void {
27+
// Auto-detect scope for GitHub Packages registry using exact host match
28+
if (!scope) {
29+
const url = new URL(registryUrl);
30+
if (url.hostname === "npm.pkg.github.com") {
31+
scope = process.env.GITHUB_REPOSITORY_OWNER;
32+
}
33+
}
34+
35+
let scopePrefix = "";
36+
if (scope) {
37+
scopePrefix = (scope.startsWith("@") ? scope : "@" + scope).toLowerCase() + ":";
38+
}
39+
40+
debug(`Setting auth in ${fileLocation}`);
41+
42+
// Compute the auth line prefix for filtering existing entries
43+
const authPrefix = registryUrl.replace(/^\w+:/, "").toLowerCase();
44+
45+
const lines: string[] = [];
46+
if (existsSync(fileLocation)) {
47+
const curContents = readFileSync(fileLocation, "utf8");
48+
for (const line of curContents.split(/\r?\n/)) {
49+
const lower = line.toLowerCase();
50+
// Remove existing registry and auth token lines for this scope/registry
51+
if (lower.startsWith(`${scopePrefix}registry`)) continue;
52+
if (lower.startsWith(authPrefix) && lower.includes("_authtoken")) continue;
53+
lines.push(line);
54+
}
55+
}
56+
57+
// Auth token line: remove protocol prefix from registry URL
58+
const authString = registryUrl.replace(/^\w+:/, "") + ":_authToken=${NODE_AUTH_TOKEN}";
59+
const registryString = `${scopePrefix}registry=${registryUrl}`;
60+
lines.push(authString, registryString);
61+
62+
writeFileSync(fileLocation, lines.join(EOL));
63+
64+
exportVariable("NPM_CONFIG_USERCONFIG", fileLocation);
65+
// Export placeholder if NODE_AUTH_TOKEN is not set so npm doesn't error
66+
exportVariable("NODE_AUTH_TOKEN", process.env.NODE_AUTH_TOKEN || "XXXXX-XXXXX-XXXXX-XXXXX");
67+
}

src/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { saveVpCache } from "./cache-vp.js";
99
import { State, Outputs } from "./types.js";
1010
import type { Inputs } from "./types.js";
1111
import { resolveNodeVersionFile } from "./node-version-file.js";
12+
import { configAuthentication } from "./auth.js";
1213

1314
async function runMain(inputs: Inputs): Promise<void> {
1415
// Mark that post action should run
@@ -29,12 +30,17 @@ async function runMain(inputs: Inputs): Promise<void> {
2930
await exec("vp", ["env", "use", nodeVersion]);
3031
}
3132

32-
// Step 4: Restore cache if enabled
33+
// Step 4: Configure registry authentication if specified
34+
if (inputs.registryUrl) {
35+
configAuthentication(inputs.registryUrl, inputs.scope);
36+
}
37+
38+
// Step 5: Restore cache if enabled
3339
if (inputs.cache) {
3440
await restoreCache(inputs);
3541
}
3642

37-
// Step 5: Run vp install if requested
43+
// Step 6: Run vp install if requested
3844
if (inputs.runInstall.length > 0) {
3945
await runViteInstall(inputs);
4046
}

src/inputs.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export function getInputs(): Inputs {
1212
runInstall: parseRunInstall(getInput("run-install")),
1313
cache: getBooleanInput("cache"),
1414
cacheDependencyPath: getInput("cache-dependency-path") || undefined,
15+
registryUrl: getInput("registry-url") || undefined,
16+
scope: getInput("scope") || undefined,
1517
};
1618
}
1719

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export interface Inputs {
2424
readonly runInstall: RunInstall[];
2525
readonly cache: boolean;
2626
readonly cacheDependencyPath?: string;
27+
readonly registryUrl?: string;
28+
readonly scope?: string;
2729
}
2830

2931
// Lock file types

0 commit comments

Comments
 (0)