@cldmv/gh-broker 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +48 -13
  2. package/config.example.json +86 -3
  3. package/config.sample.json +1 -1
  4. package/deploy/README.md +58 -18
  5. package/deploy/cli.mjs +38 -0
  6. package/deploy/gh-broker.service +9 -2
  7. package/deploy/install.mjs +100 -41
  8. package/deploy/lib/agent-cli.mjs +166 -0
  9. package/deploy/lib/agents.mjs +128 -0
  10. package/deploy/lib/cli.mjs +72 -0
  11. package/deploy/lib/config-merge.mjs +29 -8
  12. package/deploy/lib/install-args.mjs +3 -2
  13. package/deploy/lib/mcp-config.mjs +211 -0
  14. package/deploy/lib/otp-cli.mjs +136 -0
  15. package/deploy/lib/otp.mjs +325 -0
  16. package/deploy/lib/secret-store.mjs +1 -0
  17. package/deploy/lib/system.mjs +24 -12
  18. package/deploy/real-deps.mjs +102 -0
  19. package/dist/index.mjs +5171 -1686
  20. package/dist/index.mjs.map +4 -4
  21. package/package.json +14 -3
  22. package/docs/TOOLS.md +0 -538
  23. package/docs/agent-user-model.md +0 -89
  24. package/docs/changelog/v0/v0.2.0.md +0 -26
  25. package/docs/changelog/v0/v0.2.1.md +0 -23
  26. package/docs/changelog/v0/v0.2.2.md +0 -23
  27. package/docs/changelog/v0/v0.2.3.md +0 -33
  28. package/docs/changelog/v0/v0.2.4.md +0 -25
  29. package/docs/changelog/v0/v0.2.5.md +0 -29
  30. package/docs/changelog/v0/v0.3.0.md +0 -84
  31. package/docs/changelog/v0/v0.3.1.md +0 -30
  32. package/docs/changelog/v0/v0.3.2.md +0 -24
  33. package/docs/changelog/v0/v0.3.3.md +0 -28
  34. package/docs/changelog/v0/v0.4.0.md +0 -58
  35. package/docs/changelog/v0/v0.4.1.md +0 -33
  36. package/docs/changelog/v0/v0.4.2.md +0 -45
  37. package/docs/changelog/v0/v0.5.0.md +0 -39
  38. package/docs/changelog/v0/v0.5.1.md +0 -28
  39. package/docs/changelog/v0/v0.6.0.md +0 -52
  40. package/docs/changelog/v0/v0.6.1.md +0 -48
  41. package/docs/changelog/v0/v0.7.0.md +0 -71
  42. package/docs/changelog/v0/v0.8.0.md +0 -115
  43. package/docs/deploy-architecture.md +0 -84
  44. package/src/appauth.ts +0 -56
  45. package/src/audit.ts +0 -34
  46. package/src/availability.ts +0 -42
  47. package/src/catalog.ts +0 -182
  48. package/src/config.ts +0 -157
  49. package/src/index.ts +0 -107
  50. package/src/paths.ts +0 -60
  51. package/src/router.ts +0 -299
  52. package/src/secrets.ts +0 -167
  53. package/src/server.ts +0 -91
  54. package/src/types.ts +0 -128
  55. package/src/upstream.ts +0 -85
  56. package/src/verbs/ghcr/archive.ts +0 -314
  57. package/src/verbs/ghcr/config.ts +0 -142
  58. package/src/verbs/ghcr/jobs.ts +0 -141
  59. package/src/verbs/ghcr/push.ts +0 -192
  60. package/src/verbs/ghcr/registry.ts +0 -275
  61. package/src/verbs/ghcr/tar.ts +0 -203
  62. package/src/verbs/ghcr_push.ts +0 -264
  63. package/src/verbs/issue_labels.ts +0 -329
  64. package/src/verbs/link_issue.ts +0 -84
  65. package/src/verbs/npm-registry.ts +0 -168
  66. package/src/verbs/npm-tarball.ts +0 -76
  67. package/src/verbs/npm_scaffold.ts +0 -325
  68. package/src/verbs/release-analysis.ts +0 -212
  69. package/src/verbs/repo-tier.ts +0 -90
  70. package/src/verbs/report_ci_status.ts +0 -358
  71. package/src/verbs/report_release_prs.ts +0 -238
  72. package/src/verbs/report_security_alerts.ts +0 -442
  73. package/src/verbs/report_unlabeled.ts +0 -194
  74. package/src/verbs/rest-client.ts +0 -181
  75. package/src/verbs/review_thread.ts +0 -138
  76. package/tests/appauth.test.vitest.mts +0 -76
  77. package/tests/audit.test.vitest.mts +0 -48
  78. package/tests/catalog.test.vitest.mts +0 -234
  79. package/tests/config-merge.test.vitest.mts +0 -325
  80. package/tests/config.test.vitest.mts +0 -388
  81. package/tests/credentials.test.vitest.mts +0 -127
  82. package/tests/ghcr-archive.test.vitest.mts +0 -439
  83. package/tests/ghcr-config.test.vitest.mts +0 -96
  84. package/tests/ghcr-push.test.vitest.mts +0 -526
  85. package/tests/ghcr-registry.test.vitest.mts +0 -206
  86. package/tests/helpers/ghcr-fixtures.mts +0 -373
  87. package/tests/install-args.test.vitest.mts +0 -152
  88. package/tests/issue-labels.test.vitest.mts +0 -362
  89. package/tests/npm-scaffold.test.vitest.mts +0 -479
  90. package/tests/npm-setup.test.vitest.mts +0 -196
  91. package/tests/paths.test.vitest.mts +0 -57
  92. package/tests/prompt.test.vitest.mts +0 -128
  93. package/tests/release-prs.test.vitest.mts +0 -247
  94. package/tests/rest-client.test.vitest.mts +0 -115
  95. package/tests/router.test.vitest.mts +0 -505
  96. package/tests/run-vitest.mjs +0 -51
  97. package/tests/secret-store.test.vitest.mts +0 -167
  98. package/tests/secrets.test.vitest.mts +0 -208
  99. package/tests/security-alerts.test.vitest.mts +0 -338
  100. package/tests/server.test.vitest.mts +0 -200
  101. package/tests/smoke.test.vitest.mts +0 -177
  102. package/tests/system.test.vitest.mts +0 -355
  103. package/tests/upstream.test.vitest.mts +0 -91
  104. package/tests/verb-helpers.test.vitest.mts +0 -377
  105. package/tests/verbs.test.vitest.mts +0 -624
  106. package/tsconfig.json +0 -21
@@ -1,168 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/verbs/npm-registry.ts
5
- * @Date: 2026-10-31T10:56:47-07:00 (1793469407)
6
- * @Author: Nate Corcoran <CLDMV>
7
- * @Email: <Shinrai@users.noreply.github.com>
8
- * -----
9
- * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-31T12:58:18-07:00 (1793476698)
11
- * -----
12
- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
- *
14
- */
15
-
16
- /**
17
- * The npm registry calls npm_scaffold makes, as plain HTTP requests (the same ones the npm CLI
18
- * sends; see its npm-registry-fetch, libnpmpublish and `npm trust` sources). The token and OTP
19
- * only ever travel in request headers: no error message, result or log line built here
20
- * contains either.
21
- */
22
-
23
- export const DEFAULT_REGISTRY = "https://registry.npmjs.org";
24
-
25
- export type FetchLike = (url: string, init: { method: string; headers: Record<string, string>; body?: string }) => Promise<{
26
- status: number;
27
- headers: { get(name: string): string | null };
28
- text(): Promise<string>;
29
- }>;
30
-
31
- /** The registry wants a (fresh) one-time password for this request. */
32
- export class NpmOtpRequired extends Error {
33
- constructor(what: string) {
34
- super(`npm: ${what} needs a one-time password (missing, expired or already used)`);
35
- this.name = "NpmOtpRequired";
36
- }
37
- }
38
-
39
- export class NpmHttpError extends Error {
40
- readonly status: number;
41
- constructor(what: string, status: number, detail: string) {
42
- super(`npm: ${what} failed (HTTP ${status})${detail ? `: ${detail}` : ""}`);
43
- this.name = "NpmHttpError";
44
- this.status = status;
45
- }
46
- }
47
-
48
- /** `@scope/name` → `@scope%2fname`, the form registry URLs use. */
49
- export function escapePackageName(name: string): string {
50
- return name.replace("/", "%2f");
51
- }
52
-
53
- /** The registry's own message, kept short; never echoes request headers. */
54
- function errorDetail(body: string): string {
55
- try {
56
- const j = JSON.parse(body) as { error?: unknown; message?: unknown; reason?: unknown };
57
- const msg = [j.error, j.message, j.reason].find((v) => typeof v === "string" && v);
58
- if (typeof msg === "string") return msg.slice(0, 300);
59
- } catch {
60
- /* not JSON */
61
- }
62
- return body.trim().slice(0, 300);
63
- }
64
-
65
- export interface PackumentVersion {
66
- scaffoldedBy?: unknown;
67
- [k: string]: unknown;
68
- }
69
-
70
- export interface Packument {
71
- name?: string;
72
- versions?: Record<string, PackumentVersion>;
73
- "dist-tags"?: Record<string, string>;
74
- }
75
-
76
- export interface TrustConfig {
77
- id?: string;
78
- [k: string]: unknown;
79
- }
80
-
81
- export class NpmRegistry {
82
- private readonly base: string;
83
- private readonly token: string;
84
- private readonly fetchImpl: FetchLike;
85
-
86
- constructor(opts: { registry?: string; token: string; fetch?: FetchLike }) {
87
- this.base = (opts.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, "");
88
- this.token = opts.token;
89
- this.fetchImpl = opts.fetch ?? (globalThis.fetch as unknown as FetchLike);
90
- }
91
-
92
- get registry(): string {
93
- return this.base;
94
- }
95
-
96
- private async send(what: string, method: string, path: string, opts: { otp?: string; body?: unknown } = {}): Promise<{ status: number; body: string }> {
97
- const headers: Record<string, string> = {
98
- authorization: `Bearer ${this.token}`,
99
- accept: "application/json",
100
- "user-agent": "gh-broker (npm_scaffold)"
101
- };
102
- if (opts.otp) headers["npm-otp"] = opts.otp;
103
- if (opts.body !== undefined) headers["content-type"] = "application/json";
104
- const res = await this.fetchImpl(`${this.base}${path}`, {
105
- method,
106
- headers,
107
- ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {})
108
- });
109
- const body = await res.text();
110
- if (res.status === 401) {
111
- const auth = (res.headers.get("www-authenticate") ?? "").toLowerCase();
112
- if (auth.includes("otp") || /one-time pass/i.test(body)) throw new NpmOtpRequired(what);
113
- }
114
- return { status: res.status, body };
115
- }
116
-
117
- /**
118
- * The package's registry document, or undefined when it does not exist (or is not visible to
119
- * the token). `?write=true` reads from the primary rather than a cache, as npm itself does
120
- * before a write: a stale 404 here is the one thing that could let a placeholder land on a
121
- * package that already exists.
122
- */
123
- async packument(name: string): Promise<Packument | undefined> {
124
- const res = await this.send("package lookup", "GET", `/${escapePackageName(name)}?write=true`);
125
- if (res.status === 404) return undefined;
126
- if (res.status !== 200) throw new NpmHttpError("package lookup", res.status, errorDetail(res.body));
127
- return JSON.parse(res.body) as Packument;
128
- }
129
-
130
- /** First publish of a package: PUT of the full document with the tarball attached. */
131
- async publish(document: unknown, name: string, otp: string): Promise<void> {
132
- const res = await this.send("publish", "PUT", `/${escapePackageName(name)}`, { otp, body: document });
133
- if (res.status < 200 || res.status >= 300) throw new NpmHttpError("publish", res.status, errorDetail(res.body));
134
- }
135
-
136
- /**
137
- * Adds a GitHub Actions trusted publisher. A 409 means the package already has a trusted
138
- * publisher, which may or may not be this one, so it is reported as `conflict` for the
139
- * caller to check against listGithubTrust rather than as success.
140
- */
141
- async addGithubTrust(name: string, claims: { repository: string; file: string }, otp: string): Promise<{ config?: TrustConfig; conflict: boolean }> {
142
- const body = [{ type: "github", claims: { repository: claims.repository, workflow_ref: { file: claims.file } }, permissions: ["createPackage"] }];
143
- const res = await this.send("trusted publisher setup", "POST", `/-/package/${escapePackageName(name)}/trust`, { otp, body });
144
- if (res.status === 409) return { conflict: true };
145
- if (res.status < 200 || res.status >= 300) throw new NpmHttpError("trusted publisher setup", res.status, errorDetail(res.body));
146
- let parsed: unknown;
147
- try {
148
- parsed = JSON.parse(res.body);
149
- } catch {
150
- return { conflict: false };
151
- }
152
- const first = Array.isArray(parsed) ? parsed[0] : parsed;
153
- return { config: first && typeof first === "object" ? (first as TrustConfig) : undefined, conflict: false };
154
- }
155
-
156
- /** The package's trusted-publisher configurations (`npm trust list`). May need an OTP. */
157
- async listTrust(name: string, otp?: string): Promise<TrustConfig[]> {
158
- const res = await this.send("trusted publisher lookup", "GET", `/-/package/${escapePackageName(name)}/trust`, otp ? { otp } : {});
159
- if (res.status !== 200) throw new NpmHttpError("trusted publisher lookup", res.status, errorDetail(res.body));
160
- let parsed: unknown;
161
- try {
162
- parsed = JSON.parse(res.body);
163
- } catch {
164
- throw new NpmHttpError("trusted publisher lookup", res.status, "response is not JSON");
165
- }
166
- return (Array.isArray(parsed) ? parsed : [parsed]).filter((c): c is TrustConfig => Boolean(c) && typeof c === "object");
167
- }
168
- }
@@ -1,76 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/verbs/npm-tarball.ts
5
- * @Date: 2026-10-31T10:56:28-07:00 (1793469388)
6
- * @Author: Nate Corcoran <CLDMV>
7
- * @Email: <Shinrai@users.noreply.github.com>
8
- * -----
9
- * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-31T11:04:31-07:00 (1793469871)
11
- * -----
12
- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
- *
14
- */
15
-
16
- /**
17
- * A minimal npm package tarball: gzip over a POSIX ustar archive whose entries sit under
18
- * `package/`, the layout `npm pack` produces. It exists so npm_scaffold can publish a
19
- * placeholder built entirely by the broker (no agent-supplied files) without pulling in a
20
- * tar dependency. Only regular files, only short ASCII paths: exactly what a placeholder needs.
21
- */
22
-
23
- import { createHash } from "node:crypto";
24
- import { gzipSync } from "node:zlib";
25
-
26
- /** npm pack's fixed mtime (1985-10-26T08:15:00Z), so the tarball is reproducible. */
27
- const MTIME = 499162500;
28
- const BLOCK = 512;
29
-
30
- export interface TarFile {
31
- /** Path inside `package/`, e.g. "package.json". */
32
- path: string;
33
- content: string;
34
- }
35
-
36
- function header(name: string, size: number): Buffer {
37
- const h = Buffer.alloc(BLOCK, 0);
38
- const put = (value: string, offset: number, length: number): void => {
39
- h.write(value, offset, length, "ascii");
40
- };
41
- const octal = (n: number, length: number): string => n.toString(8).padStart(length - 1, "0") + "\0";
42
- put(name, 0, 100);
43
- put(octal(0o644, 8), 100, 8);
44
- put(octal(0, 8), 108, 8);
45
- put(octal(0, 8), 116, 8);
46
- put(octal(size, 12), 124, 12);
47
- put(octal(MTIME, 12), 136, 12);
48
- put(" ", 148, 8); // checksum field counts as spaces while summing
49
- put("0", 156, 1); // regular file
50
- put("ustar\0", 257, 6);
51
- put("00", 263, 2);
52
- let sum = 0;
53
- for (const byte of h) sum += byte;
54
- put(sum.toString(8).padStart(6, "0") + "\0 ", 148, 8);
55
- return h;
56
- }
57
-
58
- export function buildTarball(files: TarFile[]): Buffer {
59
- const parts: Buffer[] = [];
60
- for (const f of files) {
61
- const name = `package/${f.path}`;
62
- if (!/^[\x20-\x7e]+$/.test(name) || name.length > 100) throw new Error(`npm-tarball: unsupported path "${f.path}"`);
63
- const body = Buffer.from(f.content, "utf8");
64
- parts.push(header(name, body.length), body, Buffer.alloc((BLOCK - (body.length % BLOCK)) % BLOCK, 0));
65
- }
66
- parts.push(Buffer.alloc(BLOCK * 2, 0));
67
- return gzipSync(Buffer.concat(parts), { level: 9 });
68
- }
69
-
70
- /** The `dist` integrity fields the registry expects for a tarball. */
71
- export function tarballDigests(tarball: Buffer): { integrity: string; shasum: string } {
72
- return {
73
- integrity: `sha512-${createHash("sha512").update(tarball).digest("base64")}`,
74
- shasum: createHash("sha1").update(tarball).digest("hex")
75
- };
76
- }
@@ -1,325 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/verbs/npm_scaffold.ts
5
- * @Date: 2026-10-31T10:57:49-07:00 (1793469469)
6
- * @Author: Nate Corcoran <CLDMV>
7
- * @Email: <Shinrai@users.noreply.github.com>
8
- * -----
9
- * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-31T12:58:19-07:00 (1793476699)
11
- * -----
12
- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
- *
14
- */
15
-
16
- import type { Octokit } from "@octokit/core";
17
- import { NpmOtpRequired, type NpmRegistry } from "./npm-registry.ts";
18
- import { buildTarball, tarballDigests } from "./npm-tarball.ts";
19
-
20
- /**
21
- * Custom verb: npm_scaffold — create a new npm package and register its GitHub Actions
22
- * trusted publisher, so the repo's own publish workflow can release it from then on.
23
- *
24
- * Why it exists: npm only accepts a trusted publisher on a package that already exists, and
25
- * the first publish needs a human's 2FA. The broker holds a granular access token created
26
- * WITHOUT 2FA bypass (by the installer), and the user supplies a one-time password for each
27
- * call, which the agent passes through. The token alone can do nothing here.
28
- *
29
- * Guardrails, all checked before any npm write:
30
- * - the package is under a scope listed in config.npm.scopes;
31
- * - the repo belongs to the routed owner, is not archived, its package.json on the default
32
- * branch names this package, and the publish workflow file exists there;
33
- * - the package does not exist yet, or exists only as this verb's own placeholder (resume).
34
- * An existing real package is refused, so the verb can never publish a version of one.
35
- *
36
- * The placeholder is built here from fixed fields (no agent-supplied files): a package.json
37
- * marked `scaffoldedBy: "gh-broker"` and a README pointing at the repo. Access follows the
38
- * repo's visibility (public repo → public package), the rule the CI publish also uses.
39
- *
40
- * Every npm write needs its own one-time password, so each call makes exactly one write:
41
- * the first call publishes the placeholder and returns `needs_otp` for the trusted
42
- * publisher; the second call (with the next code) adds it. A refused or expired code is
43
- * not an error either: the result is `needs_otp` naming what is left.
44
- */
45
-
46
- export const PLACEHOLDER_VERSION = "0.0.0-stage";
47
- export const SCAFFOLD_MARKER = "gh-broker";
48
- const DEFAULT_WORKFLOW = "publish.yml";
49
- const INPUT_KEYS = new Set(["owner", "repo", "package", "otp", "workflow"]);
50
- const PACKAGE_NAME = /^@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*$/;
51
-
52
- export interface NpmScaffoldInput {
53
- owner: string;
54
- repo: string;
55
- /** Scoped package name, e.g. "@cldmv/new-thing". */
56
- package: string;
57
- /** One-time password from the user's authenticator. */
58
- otp: string;
59
- /** The repo's publish workflow file name. Defaults to publish.yml. */
60
- workflow?: string;
61
- }
62
-
63
- export const NPM_SCAFFOLD_SCHEMA = {
64
- type: "object" as const,
65
- properties: {
66
- owner: { type: "string", description: "Repository owner (a configured owner)." },
67
- repo: { type: "string", description: "Repository whose package.json names the package." },
68
- package: { type: "string", description: 'Scoped package name, e.g. "@cldmv/new-thing". Must be under a scope in config.npm.scopes.' },
69
- otp: { type: "string", description: "One-time password the user read from their authenticator. Ask the user for it right before calling; codes expire in about 30 seconds." },
70
- workflow: { type: "string", description: 'The repo\'s publish workflow file in .github/workflows. Default "publish.yml".' }
71
- },
72
- required: ["owner", "repo", "package", "otp"],
73
- additionalProperties: false
74
- };
75
-
76
- export type NpmScaffoldStep = "publish" | "trust";
77
-
78
- export interface NpmScaffoldResult {
79
- status: "done" | "needs_otp";
80
- package: string;
81
- repository: string;
82
- workflow: string;
83
- access: "public" | "restricted";
84
- /** Steps this call completed. */
85
- done: NpmScaffoldStep[];
86
- /** Steps still to do: only set with needs_otp. */
87
- remaining?: NpmScaffoldStep[];
88
- /** Steps skipped because an earlier call already completed them. */
89
- skipped?: NpmScaffoldStep[];
90
- version: string;
91
- url: string;
92
- trustId?: string;
93
- message: string;
94
- }
95
-
96
- export interface NpmScaffoldDeps {
97
- /** Read access to the repo (contents + metadata). */
98
- octokit: Octokit;
99
- registry: NpmRegistry;
100
- scopes: string[];
101
- }
102
-
103
- const fail = (msg: string): never => {
104
- throw new Error(`npm_scaffold: ${msg}`);
105
- };
106
-
107
- function validate(input: unknown): Required<NpmScaffoldInput> {
108
- if (typeof input !== "object" || input === null || Array.isArray(input)) fail("arguments must be an object");
109
- const a = input as Record<string, unknown>;
110
- const unknown = Object.keys(a).filter((k) => !INPUT_KEYS.has(k));
111
- if (unknown.length) fail(`unknown argument(s): ${unknown.join(", ")} — expected ${[...INPUT_KEYS].join(", ")}`);
112
- if (typeof a.owner !== "string" || !/^[A-Za-z0-9-]+$/.test(a.owner)) fail('"owner" must be a GitHub owner name');
113
- if (typeof a.repo !== "string" || !/^[A-Za-z0-9._-]+$/.test(a.repo)) fail('"repo" must be a repository name');
114
- if (typeof a.package !== "string" || a.package.length > 214 || !PACKAGE_NAME.test(a.package)) fail('"package" must be a lowercase scoped npm package name, e.g. "@cldmv/new-thing"');
115
- // Never echo the value: it is a live credential.
116
- if (typeof a.otp !== "string" || !/^[0-9]{6,8}$/.test(a.otp)) fail('"otp" must be the 6-8 digit code from the authenticator');
117
- if (a.workflow !== undefined && (typeof a.workflow !== "string" || !/^[A-Za-z0-9._-]+\.ya?ml$/.test(a.workflow))) {
118
- fail('"workflow" must be a workflow file name in .github/workflows, e.g. "publish.yml"');
119
- }
120
- return {
121
- owner: a.owner as string,
122
- repo: a.repo as string,
123
- package: a.package as string,
124
- otp: a.otp as string,
125
- workflow: (a.workflow as string | undefined) ?? DEFAULT_WORKFLOW
126
- };
127
- }
128
-
129
- function statusOf(err: unknown): number | undefined {
130
- return (err as { status?: number })?.status;
131
- }
132
-
133
- async function repoFile(octokit: Octokit, owner: string, repo: string, path: string, ref: string): Promise<string | undefined> {
134
- try {
135
- const res = await octokit.request("GET /repos/{owner}/{repo}/contents/{path}", { owner, repo, path, ref });
136
- const d = res.data as { content?: string; encoding?: string; type?: string };
137
- if (d.type !== "file" || typeof d.content !== "string") return undefined;
138
- return d.encoding === "base64" ? Buffer.from(d.content, "base64").toString("utf8") : d.content;
139
- } catch (err) {
140
- if (statusOf(err) === 404) return undefined;
141
- throw err;
142
- }
143
- }
144
-
145
- interface RepoFacts {
146
- repository: string;
147
- isPrivate: boolean;
148
- license?: string;
149
- description?: string;
150
- }
151
-
152
- async function checkRepo(octokit: Octokit, args: Required<NpmScaffoldInput>): Promise<RepoFacts> {
153
- let info: { full_name?: string; private?: boolean; archived?: boolean; default_branch?: string };
154
- try {
155
- info = (await octokit.request("GET /repos/{owner}/{repo}", { owner: args.owner, repo: args.repo })).data as typeof info;
156
- } catch (err) {
157
- if (statusOf(err) === 404) fail(`repository ${args.owner}/${args.repo} not found`);
158
- throw err;
159
- }
160
- const asked = `${args.owner}/${args.repo}`;
161
- const repository = info.full_name ?? asked;
162
- // GitHub answers a renamed or transferred repo with a redirect, which the client follows.
163
- // The trusted publisher must name the repo that was routed and checked, so refuse that.
164
- if (repository.toLowerCase() !== asked.toLowerCase()) fail(`${asked} redirects to ${repository} (renamed or transferred) — call with the repository's current owner and name`);
165
- if (info.archived) fail(`repository ${repository} is archived`);
166
- const ref = info.default_branch ?? "HEAD";
167
- const raw = await repoFile(octokit, args.owner, args.repo, "package.json", ref);
168
- if (raw === undefined) fail(`${repository} has no package.json on ${ref}`);
169
- let pkg: { name?: unknown; license?: unknown; description?: unknown; private?: unknown };
170
- try {
171
- pkg = JSON.parse(raw as string) as typeof pkg;
172
- } catch {
173
- return fail(`${repository}'s package.json on ${ref} is not valid JSON`);
174
- }
175
- if (pkg.name !== args.package) fail(`${repository}'s package.json on ${ref} names ${JSON.stringify(pkg.name)}, not "${args.package}"`);
176
- if (pkg.private === true) fail(`${repository}'s package.json is marked "private": true, so it is not meant to be published`);
177
- if ((await repoFile(octokit, args.owner, args.repo, `.github/workflows/${args.workflow}`, ref)) === undefined) {
178
- fail(`${repository} has no .github/workflows/${args.workflow} on ${ref} — the trusted publisher must name the workflow that publishes`);
179
- }
180
- return {
181
- repository,
182
- isPrivate: info.private !== false,
183
- ...(typeof pkg.license === "string" ? { license: pkg.license } : {}),
184
- ...(typeof pkg.description === "string" ? { description: pkg.description } : {})
185
- };
186
- }
187
-
188
- /** The registry document for the placeholder's first publish (the shape libnpmpublish sends). */
189
- export function placeholderDocument(name: string, facts: RepoFacts, access: "public" | "restricted", registry: string) {
190
- const repoUrl = `https://github.com/${facts.repository}`;
191
- const manifest = {
192
- name,
193
- version: PLACEHOLDER_VERSION,
194
- description: `Placeholder for ${name}. Releases are published from ${repoUrl} by CI.`,
195
- license: facts.license ?? "UNLICENSED",
196
- repository: { type: "git", url: `git+${repoUrl}.git` },
197
- homepage: `${repoUrl}#readme`,
198
- scaffoldedBy: SCAFFOLD_MARKER
199
- };
200
- const readme = `# ${name}\n\nThis version is a placeholder that reserves the package name so the repository's release workflow can publish it with npm trusted publishing.\n\n${facts.description ? `${facts.description}\n\n` : ""}Source and real releases: ${repoUrl}\n`;
201
- const tarball = buildTarball([
202
- { path: "package.json", content: JSON.stringify(manifest, null, "\t") + "\n" },
203
- { path: "README.md", content: readme }
204
- ]);
205
- const { integrity, shasum } = tarballDigests(tarball);
206
- const tarballName = `${name}-${PLACEHOLDER_VERSION}.tgz`;
207
- const unscoped = name.split("/")[1]!;
208
- return {
209
- _id: name,
210
- name,
211
- description: manifest.description,
212
- "dist-tags": { latest: PLACEHOLDER_VERSION },
213
- access,
214
- versions: {
215
- [PLACEHOLDER_VERSION]: {
216
- ...manifest,
217
- readme,
218
- _id: `${name}@${PLACEHOLDER_VERSION}`,
219
- dist: { integrity, shasum, tarball: `${registry}/${name}/-/${unscoped}-${PLACEHOLDER_VERSION}.tgz` }
220
- }
221
- },
222
- _attachments: { [tarballName]: { content_type: "application/octet-stream", data: tarball.toString("base64"), length: tarball.length } }
223
- };
224
- }
225
-
226
- export async function npmScaffold(input: unknown, deps: NpmScaffoldDeps): Promise<NpmScaffoldResult> {
227
- const args = validate(input);
228
- const scope = args.package.split("/")[0]!;
229
- if (!deps.scopes.includes(scope)) fail(`scope ${scope} is not in config.npm.scopes (${deps.scopes.join(", ")})`);
230
-
231
- const facts = await checkRepo(deps.octokit, args);
232
- const access = facts.isPrivate ? "restricted" : "public";
233
- const base = {
234
- package: args.package,
235
- repository: facts.repository,
236
- workflow: args.workflow,
237
- access,
238
- version: PLACEHOLDER_VERSION,
239
- url: `https://www.npmjs.com/package/${args.package}`
240
- } as const;
241
-
242
- const existing = await deps.registry.packument(args.package);
243
- if (!existing) {
244
- try {
245
- await deps.registry.publish(placeholderDocument(args.package, facts, access, deps.registry.registry), args.package, args.otp);
246
- } catch (err) {
247
- if (err instanceof NpmOtpRequired) {
248
- return { ...base, status: "needs_otp", done: [], remaining: ["publish", "trust"], message: "npm refused the one-time password before publishing; nothing was written. Call again with a fresh code." };
249
- }
250
- throw err;
251
- }
252
- // That code is spent: every npm write needs its own one-time password, so don't
253
- // send it again (a guaranteed refusal that counts as a failed 2FA attempt).
254
- return {
255
- ...base,
256
- status: "needs_otp",
257
- done: ["publish"],
258
- remaining: ["trust"],
259
- message: "The placeholder is published. Each npm write needs its own one-time password: call again with the next code (wait for the authenticator to change) to add the trusted publisher."
260
- };
261
- }
262
-
263
- // The package exists: carry on only if it is this verb's own placeholder (a resumed call).
264
- const versions = Object.keys(existing.versions ?? {});
265
- const placeholder = existing.versions?.[PLACEHOLDER_VERSION];
266
- const ours = versions.length === 1 && versions[0] === PLACEHOLDER_VERSION && placeholder?.scaffoldedBy === SCAFFOLD_MARKER;
267
- if (!ours) fail(`${args.package} already exists on the registry (${versions.length} version(s)) and was not created by npm_scaffold — set up its trusted publisher on npmjs.com instead`);
268
- // The placeholder records the repo it was made for; the trusted publisher may only go there.
269
- const madeFor = (placeholder?.repository as { url?: unknown } | undefined)?.url;
270
- if (typeof madeFor !== "string" || madeFor.toLowerCase() !== `git+https://github.com/${facts.repository}.git`.toLowerCase()) {
271
- fail(`${args.package}'s placeholder was published for ${typeof madeFor === "string" ? madeFor : "an unrecorded repository"}, not ${facts.repository}`);
272
- }
273
-
274
- let out: Awaited<ReturnType<NpmRegistry["addGithubTrust"]>>;
275
- try {
276
- out = await deps.registry.addGithubTrust(args.package, { repository: facts.repository, file: args.workflow }, args.otp);
277
- } catch (err) {
278
- if (err instanceof NpmOtpRequired) {
279
- return {
280
- ...base,
281
- status: "needs_otp",
282
- done: [],
283
- remaining: ["trust"],
284
- skipped: ["publish"],
285
- message: "The placeholder is published, but npm refused the one-time password for the trusted publisher (expired or already used). Call again with a new code to finish."
286
- };
287
- }
288
- throw err;
289
- }
290
-
291
- let alreadyThere = false;
292
- if (out.conflict) {
293
- // npm already has a trusted publisher for this package. Only call it done if it is the
294
- // one asked for. The code was spent on the POST, so the list may need another one.
295
- const access = `https://www.npmjs.com/package/${args.package}/access`;
296
- let configs: Awaited<ReturnType<NpmRegistry["listTrust"]>>;
297
- try {
298
- configs = await deps.registry.listTrust(args.package);
299
- } catch (err) {
300
- if (err instanceof NpmOtpRequired) fail(`${args.package} already has a trusted publisher, and npm needs another one-time password to show which; check it at ${access}`);
301
- throw err;
302
- }
303
- const claimsOf = (c: Record<string, unknown>) => (c.claims ?? {}) as { repository?: unknown; workflow_ref?: { file?: unknown } };
304
- const matches = configs.find((c) => {
305
- const cl = claimsOf(c);
306
- return c.type === "github" && typeof cl.repository === "string" && cl.repository.toLowerCase() === facts.repository.toLowerCase() && cl.workflow_ref?.file === args.workflow;
307
- });
308
- if (!matches) {
309
- const existingList = configs.map((c) => `${String(c.type)} ${String(claimsOf(c).repository)} (${String(claimsOf(c).workflow_ref?.file)})`).join("; ") || "unreadable";
310
- fail(`${args.package} already has a different trusted publisher: ${existingList}. Change it at ${access}`);
311
- }
312
- alreadyThere = true;
313
- out = { config: matches, conflict: true };
314
- }
315
-
316
- const trustId = typeof out.config?.id === "string" ? out.config.id : undefined;
317
- return {
318
- ...base,
319
- status: "done",
320
- done: alreadyThere ? [] : ["trust"],
321
- skipped: alreadyThere ? ["publish", "trust"] : ["publish"],
322
- ...(trustId ? { trustId } : {}),
323
- message: `${args.package} exists with a trusted publisher for ${facts.repository} (.github/workflows/${args.workflow}); its next release can publish through OIDC.`
324
- };
325
- }