From 5faa77978d4c292bb66c2639c2de2f4235206f50 Mon Sep 17 00:00:00 2001 From: bq Date: Thu, 18 Jun 2026 11:23:24 +0200 Subject: [PATCH] init action --- package.json | 3 +- pnpm-lock.yaml | 8 + rolldown.config.js => rolldown.config.ts | 5 +- setup-auth/action.yml | 31 ++++ src/setup-auth/index.ts | 22 +++ src/setup-auth/setup.test.ts | 191 +++++++++++++++++++++++ src/setup-auth/setup.ts | 97 ++++++++++++ 7 files changed, 354 insertions(+), 3 deletions(-) rename rolldown.config.js => rolldown.config.ts (65%) create mode 100644 setup-auth/action.yml create mode 100644 src/setup-auth/index.ts create mode 100644 src/setup-auth/setup.test.ts create mode 100644 src/setup-auth/setup.ts diff --git a/package.json b/package.json index 471fccef..89c4da90 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@octokit/plugin-throttling": "^11.0.3", "human-id": "^4.1.3", "markdown-table": "^3.0.4", + "package-manager-detector": "^1.6.0", "semver": "^7.8.1", "tinyexec": "^1.2.4" }, @@ -50,5 +51,5 @@ "engines": { "node": ">=24" }, - "packageManager": "pnpm@11.1.1+sha512.d1fdf5f73c617b64fa1a56a81c3c8dfe0e966e33a6010aa256b517ae77be21d93e05affc0de1a83b0e4f29d569f68b446ae8f068cd7247c0bb3df0fb4d7bdf9a" + "packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34eea626..c559c3ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,6 +52,9 @@ importers: markdown-table: specifier: ^3.0.4 version: 3.0.4 + package-manager-detector: + specifier: ^1.6.0 + version: 1.6.0 semver: specifier: ^7.8.1 version: 7.8.1 @@ -964,6 +967,9 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -2162,6 +2168,8 @@ snapshots: dependencies: quansync: 0.2.11 + package-manager-detector@1.6.0: {} + pako@1.0.11: {} path-exists@4.0.0: {} diff --git a/rolldown.config.js b/rolldown.config.ts similarity index 65% rename from rolldown.config.js rename to rolldown.config.ts index 814f4f4b..dc3ed31f 100644 --- a/rolldown.config.js +++ b/rolldown.config.ts @@ -3,8 +3,9 @@ import { defineConfig } from "rolldown"; export default defineConfig({ input: { index: "src/index.ts", - ["pr-status"]: "src/pr-status/index.ts", - ["pr-comment"]: "src/pr-comment/index.ts", + "pr-status": "src/pr-status/index.ts", + "pr-comment": "src/pr-comment/index.ts", + "setup-auth": "src/setup-auth/index.ts", }, output: { dir: "dist", diff --git a/setup-auth/action.yml b/setup-auth/action.yml new file mode 100644 index 00000000..42092fc5 --- /dev/null +++ b/setup-auth/action.yml @@ -0,0 +1,31 @@ +name: "changesets/setup-auth" +description: Set up authentication for Node.js projects, without also re-downloading Node.js (again). + +branding: + icon: lock + color: blue + +runs: + using: node24 + main: ../dist/setup-auth.js + +inputs: + token: + description: The token to write to the package managers' config files. + required: true + scope: + description: Which scope to use the token with (e.g. `@my-org`). + registry: + description: Which registry to use the token with. + default: "//registry.npmjs.org" + package-manager: + # TODO: fix wording + description: Which package manager to set up. Defaults to detecting from project. (see supported package managers). + overwrite: + description: Whether to overwrite any existing configuration. + +outputs: + config-path: + description: The path to the updated config file + package-manager: + description: Which package manager was configured diff --git a/src/setup-auth/index.ts b/src/setup-auth/index.ts new file mode 100644 index 00000000..7ecfd633 --- /dev/null +++ b/src/setup-auth/index.ts @@ -0,0 +1,22 @@ +import * as core from "@actions/core"; +import { setupAuth } from "./setup.ts"; + +try { + await main(); +} catch (err) { + core.setFailed((err as Error).message); +} + +async function main() { + core.info("Setting up npm registry authentication..."); + const { configPath, packageManager } = await setupAuth({ + token: core.getInput("token", { required: true }), + registry: core.getInput("registry"), + scope: core.getInput("scope"), + packageManager: core.getInput("package-manager") as any, + overwrite: core.getInput("overwrite") === "true", + }); + core.setOutput("config-path", configPath); + core.setOutput("package-manager", packageManager); + core.info("Done!"); +} diff --git a/src/setup-auth/setup.test.ts b/src/setup-auth/setup.test.ts new file mode 100644 index 00000000..efdbc585 --- /dev/null +++ b/src/setup-auth/setup.test.ts @@ -0,0 +1,191 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { setupAuth, type SetupAuthInputs } from "./setup.ts"; + +describe("setup()", () => { + let originalHome = process.env.HOME; + let homeDir!: string; + beforeEach(async () => { + homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "setup-auth-")); + process.env.HOME = homeDir; + }); + afterAll(() => { + process.env.HOME = originalHome; + }); + + it("should refuse if file already exists", async () => { + const filePath = path.join(homeDir, ".npmrc"); + const originalFileContents = "registry=https://registry.npmjs.org"; + await fs.writeFile(filePath, originalFileContents); + + const inputs = { + token: "token", + registry: "//test.registry.com", + scope: undefined, + packageManager: "npm", + overwrite: false, + } satisfies SetupAuthInputs; + + await expect(setupAuth(inputs)).rejects.toThrow("file already exists"); + + await expect(fs.readFile(filePath, "utf8")).resolves.toEqual( + originalFileContents, + ); + }); + + it("should overwrite if configured to", async () => { + const filePath = path.join(homeDir, ".npmrc"); + const originalFileContents = "registry=https://registry.npmjs.org"; + await fs.writeFile(filePath, originalFileContents); + + const inputs = { + token: "token", + registry: "//test.registry.com", + scope: undefined, + packageManager: "npm", + overwrite: true, + } satisfies SetupAuthInputs; + + await expect(setupAuth(inputs)).resolves.toBeDefined(); + await expect(fs.readFile(filePath, "utf8")).resolves.not.toEqual( + originalFileContents, + ); + }); + + describe("pnpm", () => { + it("should configure token", async () => { + const inputs = { + token: "token", + registry: "//test.registry.com", + scope: undefined, + packageManager: "pnpm", + overwrite: false, + } satisfies SetupAuthInputs; + const { configPath, packageManager } = await setupAuth(inputs); + + expect(packageManager).toBe("pnpm"); + const expectedPath = path.join(homeDir, ".config", "pnpm", "auth.ini"); + expect(configPath).toEqual(expectedPath); + await expect( + fs.readFile(expectedPath, "utf8"), + ).resolves.toMatchInlineSnapshot( + `"//test.registry.com/:_authToken=token"`, + ); + }); + + it("should configure token with scope", async () => { + const inputs = { + token: "token", + registry: "//test.registry.com", + scope: "@my-org", + packageManager: "pnpm", + overwrite: false, + } satisfies SetupAuthInputs; + const { configPath, packageManager } = await setupAuth(inputs); + + expect(packageManager).toBe("pnpm"); + const expectedPath = path.join(homeDir, ".config", "pnpm", "auth.ini"); + expect(configPath).toEqual(expectedPath); + await expect( + fs.readFile(expectedPath, "utf8"), + ).resolves.toMatchInlineSnapshot( + `"@my-org://test.registry.com/:_authToken=token"`, + ); + }); + }); + + describe("npm", () => { + it("should configure token", async () => { + const inputs = { + token: "token", + registry: "//test.registry.com", + scope: undefined, + packageManager: "npm", + overwrite: false, + } satisfies SetupAuthInputs; + const { configPath, packageManager } = await setupAuth(inputs); + + expect(packageManager).toBe("npm"); + const expectedPath = path.join(homeDir, ".npmrc"); + expect(configPath).toEqual(expectedPath); + await expect( + fs.readFile(expectedPath, "utf8"), + ).resolves.toMatchInlineSnapshot( + `"//test.registry.com/:_authToken=token"`, + ); + }); + + it("should configure token with scope", async () => { + const inputs = { + token: "token", + registry: "//test.registry.com", + scope: "@my-org", + packageManager: "npm", + overwrite: false, + } satisfies SetupAuthInputs; + const { configPath, packageManager } = await setupAuth(inputs); + + expect(packageManager).toBe("npm"); + const expectedPath = path.join(homeDir, ".npmrc"); + expect(configPath).toEqual(expectedPath); + await expect( + fs.readFile(expectedPath, "utf8"), + ).resolves.toMatchInlineSnapshot( + `"@my-org://test.registry.com/:_authToken=token"`, + ); + }); + }); + + describe("yarn", () => { + it("should configure token", async () => { + const inputs = { + token: "token", + registry: "//test.registry.com", + scope: undefined, + packageManager: "yarn", + overwrite: false, + } satisfies SetupAuthInputs; + const { configPath, packageManager } = await setupAuth(inputs); + + expect(packageManager).toBe("yarn"); + const expectedPath = path.join(homeDir, ".yarnrc.yml"); + expect(configPath).toEqual(expectedPath); + await expect( + fs.readFile(expectedPath, "utf8"), + ).resolves.toMatchInlineSnapshot( + ` + "npmRegistries: + //test.registry.com: + npmAuthToken: "token"" + `, + ); + }); + + it("should configure token with scope", async () => { + const inputs = { + token: "token", + registry: "//test.registry.com", + scope: "@my-org", + packageManager: "yarn", + overwrite: false, + } satisfies SetupAuthInputs; + const { configPath, packageManager } = await setupAuth(inputs); + + expect(packageManager).toBe("yarn"); + const expectedPath = path.join(homeDir, ".yarnrc.yml"); + expect(configPath).toEqual(expectedPath); + await expect( + fs.readFile(expectedPath, "utf8"), + ).resolves.toMatchInlineSnapshot( + ` + "npmScopes: + my-org: + npmAuthToken: "token" + npmRegistryServer: "//test.registry.com"" + `, + ); + }); + }); +}); diff --git a/src/setup-auth/setup.ts b/src/setup-auth/setup.ts new file mode 100644 index 00000000..501b0e57 --- /dev/null +++ b/src/setup-auth/setup.ts @@ -0,0 +1,97 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { detect } from "package-manager-detector/detect"; + +type PackageManager = "pnpm" | "npm" | "yarn"; + +export type SetupAuthInputs = { + token: string; + registry: string; + scope?: string; + packageManager?: PackageManager; + overwrite: boolean; +}; + +export async function setupAuth( + inputs: SetupAuthInputs, +): Promise<{ configPath: string; packageManager: PackageManager }> { + async function detectPackageManager(): Promise { + const result = await detect(); + return ( + result?.agent + // normalize values + .replace(/^yarn$/, "yarn@1") + .replace(/^yarn@berry$/, "yarn") as PackageManager | undefined + ); + } + + const packageManager = + inputs.packageManager ?? (await detectPackageManager()); + if ( + packageManager !== "pnpm" && + packageManager !== "npm" && + packageManager !== "yarn" + ) { + // oxlint-disable-next-line typescript/no-unnecessary-condition + throw new Error(`Unsupported package manager: ${packageManager ?? "null"}`); + } + + const configPaths = { + // we do not use `.config/pnpm/ + pnpm: path.join("$HOME", ".config", "pnpm", "auth.ini"), + npm: path.join("$HOME", ".npmrc"), + yarn: path.join("$HOME", ".yarnrc.yml"), + } satisfies Record; + + const configPath = configPaths[packageManager].replace( + /\$HOME/, + process.env.HOME ?? process.env.userprofile!, + ); + + await fs.mkdir(path.dirname(configPath), { recursive: true }); + let fd: fs.FileHandle; + + try { + fd = await fs.open(configPath, inputs.overwrite ? "w" : "wx"); + } catch (error) { + if ((error as { code: string }).code !== "EEXIST") throw error; + + throw new Error( + `Config file already exists and \`overwrite\` is false: ${configPath}`, + { + cause: error, + }, + ); + } + + const scopePrefix = inputs.scope ? `${inputs.scope}:` : ""; + const configs = { + pnpm: (inputs) => + `${scopePrefix}${inputs.registry}/:_authToken=${inputs.token}`, + npm: (inputs) => + `${scopePrefix}${inputs.registry}/:_authToken=${inputs.token}`, + yarn: (inputs) => { + if (inputs.scope != null) { + return ` +npmScopes: + ${inputs.scope.slice(1)}: + npmAuthToken: "${inputs.token}" + npmRegistryServer: "${inputs.registry}" + `.trim(); + } + + return ` +npmRegistries: + ${inputs.registry}: + npmAuthToken: "${inputs.token}" + `.trim(); + }, + } satisfies Record string>; + + await fs.writeFile(fd, configs[packageManager](inputs)); + + return { + configPath, + packageManager, + }; +}