@gjsify/cli 0.4.15 → 0.4.16

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.
@@ -7,6 +7,8 @@ interface PublishOptions {
7
7
  provenance?: boolean;
8
8
  'dry-run'?: boolean;
9
9
  json?: boolean;
10
+ trusted?: boolean | 'auto';
11
+ 'check-trusted'?: boolean;
10
12
  }
11
13
  export declare const publishCommand: Command<any, PublishOptions>;
12
14
  export {};
@@ -37,6 +37,7 @@ import { homedir } from 'node:os';
37
37
  import { join, resolve } from 'node:path';
38
38
  import { DEFAULT_REGISTRY, parseNpmrc, registryFor, buildHeaders, } from '@gjsify/npm-registry';
39
39
  import { packWorkspace } from './pack.js';
40
+ import { getNpmTrustedToken, hasGithubOidcEnv, OidcExchangeError, OidcUnavailableError, } from '../utils/npm-oidc.js';
40
41
  export const publishCommand = {
41
42
  command: 'publish [path]',
42
43
  description: 'Pack + upload the workspace at <path> (default: cwd) to its npm registry. Drop-in for `npm publish` with workspace:^ rewrite handled automatically.',
@@ -70,6 +71,19 @@ export const publishCommand = {
70
71
  description: 'Emit publish metadata as JSON on stdout.',
71
72
  type: 'boolean',
72
73
  default: false,
74
+ })
75
+ .option('trusted', {
76
+ description: 'Authenticate via npm Trusted Publishing (OIDC): exchange the GitHub Actions id-token for a short-lived npm token. ' +
77
+ 'Pass `--trusted` to force this mode (errors if env vars missing). ' +
78
+ 'Omit to auto-detect: OIDC is used iff `ACTIONS_ID_TOKEN_REQUEST_URL`+`_TOKEN` are set AND no `_authToken` is present in the resolved npmrc; otherwise the long-lived token path is used. ' +
79
+ 'Requires the calling workflow to declare `permissions: id-token: write` AND the target package to have a Trusted Publisher configured on npmjs.com.',
80
+ type: 'boolean',
81
+ default: undefined,
82
+ })
83
+ .option('check-trusted', {
84
+ description: 'Diagnostic mode: perform the OIDC id-token request + npm token exchange, report success/failure, then exit WITHOUT publishing. Useful as a bulk-verifier (e.g. via `gjsify foreach publish --check-trusted`) to confirm Trusted Publisher config across many packages.',
85
+ type: 'boolean',
86
+ default: false,
73
87
  }),
74
88
  handler: async (args) => {
75
89
  const wsDir = resolve(args.path ?? process.cwd());
@@ -78,9 +92,56 @@ export const publishCommand = {
78
92
  const tolerate = args['tolerate-republish'] === true;
79
93
  const provenance = args.provenance === true;
80
94
  const dryRun = args['dry-run'] === true;
95
+ const checkTrustedOnly = args['check-trusted'] === true;
96
+ const trustedFlag = args.trusted;
97
+ const verbose = Boolean(process.env.GJSIFY_PUBLISH_DEBUG);
81
98
  if (provenance) {
82
99
  console.warn('gjsify publish: --provenance recorded but not signed (no sigstore integration yet).');
83
100
  }
101
+ // `--check-trusted` short-circuits the entire pack + publish flow.
102
+ // Reports the OIDC exchange result for the workspace's package and
103
+ // exits 0 either way — by design, so `gjsify foreach publish
104
+ // --check-trusted` walks every workspace without bailing on the
105
+ // first misconfigured one. CI can grep `^✗ ` (or parse `--json`
106
+ // entries with `ok: false`) to surface failures.
107
+ if (checkTrustedOnly) {
108
+ const rawPkgPath = join(wsDir, 'package.json');
109
+ const rawPkg = JSON.parse(readFileSync(rawPkgPath, 'utf-8'));
110
+ if (typeof rawPkg.name !== 'string') {
111
+ process.stderr.write(`gjsify publish --check-trusted: ${rawPkgPath} has no \`name\` field\n`);
112
+ process.exit(2);
113
+ }
114
+ if (rawPkg.private === true) {
115
+ const out = { ok: true, action: 'check-trusted', name: rawPkg.name, skipped: 'private' };
116
+ if (args.json)
117
+ process.stdout.write(`${JSON.stringify(out)}\n`);
118
+ else
119
+ process.stdout.write(`- ${rawPkg.name}: skipped (private package)\n`);
120
+ return;
121
+ }
122
+ const npmrcCheck = await loadNpmrc(wsDir);
123
+ const registry = process.env.npm_config_registry ?? registryFor(rawPkg.name, npmrcCheck) ?? DEFAULT_REGISTRY;
124
+ try {
125
+ await getNpmTrustedToken({
126
+ packageName: rawPkg.name,
127
+ registry,
128
+ log: verbose ? (m) => console.error(m) : undefined,
129
+ });
130
+ const out = { ok: true, action: 'check-trusted', name: rawPkg.name, registry };
131
+ if (args.json)
132
+ process.stdout.write(`${JSON.stringify(out)}\n`);
133
+ else
134
+ process.stdout.write(`✓ ${rawPkg.name}: trusted publisher OK\n`);
135
+ return;
136
+ }
137
+ catch (err) {
138
+ handleOidcFailure(err, rawPkg.name, args.json === true);
139
+ // Report-mode: exit 0 so `gjsify foreach` keeps walking. The
140
+ // `✗ <name>: <reason>` (or JSON `ok:false`) line is the
141
+ // failure signal for CI to grep / parse.
142
+ return;
143
+ }
144
+ }
84
145
  // 1. Pack the workspace (rewrites workspace:^, computes integrity)
85
146
  const packOpts = { dryRun: true };
86
147
  const packed = await packWorkspace(wsDir, packOpts);
@@ -159,10 +220,45 @@ export const publishCommand = {
159
220
  const headers = buildHeaders(url, { npmrc });
160
221
  headers['content-type'] = 'application/json';
161
222
  headers['accept'] = '*/*';
162
- if (process.env.GJSIFY_PUBLISH_DEBUG) {
223
+ // Trusted Publishing path. `--trusted` forces OIDC (errors if env
224
+ // vars are missing); the default `undefined` triggers auto-detect:
225
+ // OIDC is used iff GitHub OIDC env vars are present AND no
226
+ // `NODE_AUTH_TOKEN` is set. With `NODE_AUTH_TOKEN` set the user has
227
+ // explicitly opted into token auth, so we don't shadow their choice.
228
+ const wantTrusted = trustedFlag === true ||
229
+ (trustedFlag === undefined && hasGithubOidcEnv() && !process.env.NODE_AUTH_TOKEN);
230
+ let authMode = 'token';
231
+ if (wantTrusted) {
232
+ try {
233
+ const { token: oidcToken, audience } = await getNpmTrustedToken({
234
+ packageName: packed.name,
235
+ registry,
236
+ log: verbose ? (m) => console.error(m) : undefined,
237
+ });
238
+ headers['authorization'] = `Bearer ${oidcToken}`;
239
+ authMode = 'oidc';
240
+ if (verbose) {
241
+ console.error(`gjsify publish: OIDC token obtained (audience=${audience})`);
242
+ }
243
+ }
244
+ catch (err) {
245
+ if (trustedFlag === true) {
246
+ // Explicit --trusted: bail with a clear error.
247
+ handleOidcFailure(err, packed.name, args.json === true);
248
+ process.exit(1);
249
+ }
250
+ // Auto-detect: fall back to whatever buildHeaders found.
251
+ if (verbose) {
252
+ const msg = err instanceof Error ? err.message : String(err);
253
+ console.error(`gjsify publish: OIDC auto-detect failed (${msg}) — falling back to token auth`);
254
+ }
255
+ }
256
+ }
257
+ if (verbose) {
163
258
  console.error(`gjsify publish: PUT ${url} (${packed.name}@${packed.version})`);
259
+ console.error(` auth-mode: ${authMode}`);
164
260
  console.error(` authorization: ${headers['authorization'] ? '(set)' : '(none)'}`);
165
- console.error(` payload size: ${JSON.stringify(payload).length} bytes`);
261
+ console.error(` payload size: ${JSON.stringify(payload).length} bytes`);
166
262
  }
167
263
  const res = await fetch(url, {
168
264
  method: 'PUT',
@@ -317,3 +413,28 @@ function base64Encode(bytes) {
317
413
  }
318
414
  return btoa(str);
319
415
  }
416
+ function handleOidcFailure(err, packageName, asJson) {
417
+ if (err instanceof OidcUnavailableError) {
418
+ const msg = `gjsify publish: OIDC not available — ${err.message}`;
419
+ if (asJson)
420
+ process.stdout.write(`${JSON.stringify({ ok: false, name: packageName, error: 'oidc-unavailable', reason: err.reason, message: err.message })}\n`);
421
+ else
422
+ process.stderr.write(`${msg}\n`);
423
+ return;
424
+ }
425
+ if (err instanceof OidcExchangeError) {
426
+ const friendly = err.status === 401 || err.status === 403
427
+ ? `npm rejected the OIDC exchange (${err.status}) — check that ${packageName} has a Trusted Publisher configured at https://www.npmjs.com/package/${encodeURIComponent(packageName)}/access pointing at this workflow.`
428
+ : err.message;
429
+ if (asJson)
430
+ process.stdout.write(`${JSON.stringify({ ok: false, name: packageName, error: 'oidc-exchange', status: err.status, body: err.body, message: err.message })}\n`);
431
+ else
432
+ process.stderr.write(`✗ ${packageName}: ${friendly}\n`);
433
+ return;
434
+ }
435
+ const msg = err instanceof Error ? err.message : String(err);
436
+ if (asJson)
437
+ process.stdout.write(`${JSON.stringify({ ok: false, name: packageName, error: 'unknown', message: msg })}\n`);
438
+ else
439
+ process.stderr.write(`✗ ${packageName}: ${msg}\n`);
440
+ }
@@ -71,6 +71,11 @@ export async function installPackagesNative(opts) {
71
71
  // behavior). Sub-deps are not included.
72
72
  return topLevelResolutions(opts.specs, nodes);
73
73
  }
74
+ function errMsg(err) {
75
+ if (err instanceof Error)
76
+ return err.message;
77
+ return String(err);
78
+ }
74
79
  function topLevelResolutions(specs, nodes) {
75
80
  // Top-level installs live at `node_modules/<name>` (no nesting). Build
76
81
  // a name → root-node lookup limited to the top-level set.
@@ -131,7 +136,12 @@ async function resolveDeps(specs, npmrc, log, overrides) {
131
136
  const cached = packumentCache.get(name);
132
137
  if (cached)
133
138
  return cached;
134
- const fresh = fetchPackument(name, { npmrc });
139
+ const fresh = fetchPackument(name, {
140
+ npmrc,
141
+ onRetry: ({ attempt, error, delayMs }) => {
142
+ log("packument %s: retry %d after %dms (%s)", name, attempt, delayMs, errMsg(error));
143
+ },
144
+ });
135
145
  packumentCache.set(name, fresh);
136
146
  return fresh;
137
147
  };
@@ -455,6 +465,9 @@ async function extractOne(node, prefix, npmrc, log) {
455
465
  const bytes = await fetchTarball(node.tarballUrl, {
456
466
  npmrc,
457
467
  integrity: node.integrity,
468
+ onRetry: ({ attempt, error, delayMs }) => {
469
+ log("tarball %s@%s: retry %d after %dms (%s)", node.name, node.version, attempt, delayMs, errMsg(error));
470
+ },
458
471
  });
459
472
  fs.rmSync(dest, { recursive: true, force: true });
460
473
  fs.mkdirSync(dest, { recursive: true });
@@ -0,0 +1,53 @@
1
+ interface OidcExchangeOptions {
2
+ /** Full package name including scope, e.g. `@gjsify/cli`. */
3
+ packageName: string;
4
+ /** Registry URL, e.g. `https://registry.npmjs.org`. */
5
+ registry: string;
6
+ /** Optional verbose logger — receives single-line strings. */
7
+ log?: (msg: string) => void;
8
+ }
9
+ export interface OidcExchangeResult {
10
+ /** Short-lived npm token (`Authorization: Bearer <token>`-compatible). */
11
+ token: string;
12
+ /** Audience used for the GitHub OIDC token request. */
13
+ audience: string;
14
+ }
15
+ export declare class OidcUnavailableError extends Error {
16
+ readonly reason: 'no-env' | 'fetch-id-token' | 'no-id-token';
17
+ constructor(message: string, reason: 'no-env' | 'fetch-id-token' | 'no-id-token');
18
+ }
19
+ export declare class OidcExchangeError extends Error {
20
+ readonly status: number;
21
+ readonly body: string;
22
+ readonly packageName: string;
23
+ constructor(message: string, status: number, body: string, packageName: string);
24
+ }
25
+ /**
26
+ * Probe whether OIDC publishing is available in the current process —
27
+ * cheap env-var check, no network access. Used by `gjsify publish` to
28
+ * decide between OIDC and token-based auth in auto-detect mode.
29
+ */
30
+ export declare function hasGithubOidcEnv(): boolean;
31
+ /**
32
+ * Request a GitHub Actions OIDC ID token for the given audience.
33
+ * Throws `OidcUnavailableError` when the required env vars are missing
34
+ * (caller can fall back to token auth) or when GitHub rejects the request.
35
+ */
36
+ export declare function fetchGithubOidcToken(audience: string, log?: (msg: string) => void): Promise<string>;
37
+ /**
38
+ * Exchange a GitHub OIDC JWT for a short-lived npm publish token at
39
+ * `/-/npm/v1/oidc/token/exchange/package/<escaped-name>`. The npm
40
+ * registry validates the JWT against the package's Trusted Publisher
41
+ * config — if no Trusted Publisher is configured, or the JWT comes from
42
+ * a repo/workflow that doesn't match, the exchange returns a 4xx with
43
+ * a descriptive body which we propagate as `OidcExchangeError`.
44
+ */
45
+ export declare function exchangeOidcForNpmToken(args: OidcExchangeOptions & {
46
+ idToken: string;
47
+ }): Promise<string>;
48
+ /**
49
+ * End-to-end: probe env → fetch id-token → exchange for npm token.
50
+ * One-shot convenience for `gjsify publish` and the verification command.
51
+ */
52
+ export declare function getNpmTrustedToken(opts: OidcExchangeOptions): Promise<OidcExchangeResult>;
53
+ export {};
@@ -0,0 +1,140 @@
1
+ // npm Trusted Publishing — OIDC token exchange for `gjsify publish`.
2
+ //
3
+ // Two-step flow, mirroring `refs/npm-cli/lib/utils/oidc.js`:
4
+ //
5
+ // 1. Request a GitHub Actions OIDC ID token (JWT) from the runner.
6
+ // Requires `permissions: id-token: write` in the calling workflow.
7
+ // GitHub provides `ACTIONS_ID_TOKEN_REQUEST_URL` and
8
+ // `ACTIONS_ID_TOKEN_REQUEST_TOKEN` env vars; we GET the URL with
9
+ // the runner-provided audience (`npm:registry.npmjs.org`).
10
+ //
11
+ // 2. Exchange that JWT at npm's `/-/npm/v1/oidc/token/exchange/package/<name>`
12
+ // endpoint for a short-lived (~5 min) npm publish token. npm verifies
13
+ // the JWT against the package's configured Trusted Publisher
14
+ // (repository + workflow filename + optional environment) and either
15
+ // issues a token or rejects with an explanatory error.
16
+ //
17
+ // The token returned by step 2 is used for the publish PUT in the same
18
+ // way the long-lived NPM_TOKEN would have been — drop-in replacement.
19
+ //
20
+ // Reference: refs/npm-cli/lib/utils/oidc.js
21
+ // Original: Copyright (c) npm contributors. Artistic-2.0.
22
+ export class OidcUnavailableError extends Error {
23
+ reason;
24
+ constructor(message, reason) {
25
+ super(message);
26
+ this.reason = reason;
27
+ this.name = 'OidcUnavailableError';
28
+ }
29
+ }
30
+ export class OidcExchangeError extends Error {
31
+ status;
32
+ body;
33
+ packageName;
34
+ constructor(message, status, body, packageName) {
35
+ super(message);
36
+ this.status = status;
37
+ this.body = body;
38
+ this.packageName = packageName;
39
+ this.name = 'OidcExchangeError';
40
+ }
41
+ }
42
+ /**
43
+ * Probe whether OIDC publishing is available in the current process —
44
+ * cheap env-var check, no network access. Used by `gjsify publish` to
45
+ * decide between OIDC and token-based auth in auto-detect mode.
46
+ */
47
+ export function hasGithubOidcEnv() {
48
+ return Boolean(process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN);
49
+ }
50
+ /**
51
+ * Request a GitHub Actions OIDC ID token for the given audience.
52
+ * Throws `OidcUnavailableError` when the required env vars are missing
53
+ * (caller can fall back to token auth) or when GitHub rejects the request.
54
+ */
55
+ export async function fetchGithubOidcToken(audience, log) {
56
+ const url = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
57
+ const bearer = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
58
+ if (!url || !bearer) {
59
+ throw new OidcUnavailableError('GitHub Actions OIDC env vars (ACTIONS_ID_TOKEN_REQUEST_{URL,TOKEN}) not set. ' +
60
+ 'The calling workflow needs `permissions: id-token: write`.', 'no-env');
61
+ }
62
+ const requestUrl = new URL(url);
63
+ requestUrl.searchParams.set('audience', audience);
64
+ log?.(`gjsify oidc: GET ${requestUrl.href.replace(bearer, '<bearer>')}`);
65
+ const res = await fetch(requestUrl.href, {
66
+ method: 'GET',
67
+ headers: {
68
+ Accept: 'application/json',
69
+ Authorization: `Bearer ${bearer}`,
70
+ },
71
+ });
72
+ if (!res.ok) {
73
+ const text = await res.text().catch(() => '<no body>');
74
+ throw new OidcUnavailableError(`Failed to fetch GitHub OIDC id_token: ${res.status} ${res.statusText} — ${text.slice(0, 200)}`, 'fetch-id-token');
75
+ }
76
+ const json = (await res.json().catch(() => ({})));
77
+ if (!json.value) {
78
+ throw new OidcUnavailableError('GitHub OIDC response missing `value` field', 'no-id-token');
79
+ }
80
+ return json.value;
81
+ }
82
+ /**
83
+ * Exchange a GitHub OIDC JWT for a short-lived npm publish token at
84
+ * `/-/npm/v1/oidc/token/exchange/package/<escaped-name>`. The npm
85
+ * registry validates the JWT against the package's Trusted Publisher
86
+ * config — if no Trusted Publisher is configured, or the JWT comes from
87
+ * a repo/workflow that doesn't match, the exchange returns a 4xx with
88
+ * a descriptive body which we propagate as `OidcExchangeError`.
89
+ */
90
+ export async function exchangeOidcForNpmToken(args) {
91
+ const { packageName, registry, idToken, log } = args;
92
+ const registryClean = registry.endsWith('/') ? registry.slice(0, -1) : registry;
93
+ // npm-package-arg's escapedName convention — same as gjsify publish.ts.
94
+ const escapedName = packageName.startsWith('@')
95
+ ? (() => {
96
+ const slash = packageName.indexOf('/');
97
+ const scope = packageName.slice(1, slash);
98
+ const base = packageName.slice(slash + 1);
99
+ return `@${encodeURIComponent(scope)}%2f${encodeURIComponent(base)}`;
100
+ })()
101
+ : encodeURIComponent(packageName);
102
+ const exchangeUrl = `${registryClean}/-/npm/v1/oidc/token/exchange/package/${escapedName}`;
103
+ log?.(`gjsify oidc: POST ${exchangeUrl}`);
104
+ const res = await fetch(exchangeUrl, {
105
+ method: 'POST',
106
+ headers: {
107
+ Authorization: `Bearer ${idToken}`,
108
+ 'Content-Type': 'application/json',
109
+ Accept: 'application/json',
110
+ },
111
+ // npm's exchange endpoint accepts an empty JSON body — the JWT is
112
+ // the proof, no additional claims needed from us.
113
+ body: '{}',
114
+ });
115
+ const text = await res.text().catch(() => '');
116
+ if (!res.ok) {
117
+ throw new OidcExchangeError(`npm OIDC token exchange failed for ${packageName}: ${res.status} ${res.statusText} — ${text.slice(0, 300)}`, res.status, text, packageName);
118
+ }
119
+ let json;
120
+ try {
121
+ json = JSON.parse(text);
122
+ }
123
+ catch {
124
+ throw new OidcExchangeError(`npm OIDC token exchange returned non-JSON body for ${packageName}: ${text.slice(0, 200)}`, res.status, text, packageName);
125
+ }
126
+ if (!json.token) {
127
+ throw new OidcExchangeError(`npm OIDC token exchange returned no \`token\` field for ${packageName}`, res.status, text, packageName);
128
+ }
129
+ return json.token;
130
+ }
131
+ /**
132
+ * End-to-end: probe env → fetch id-token → exchange for npm token.
133
+ * One-shot convenience for `gjsify publish` and the verification command.
134
+ */
135
+ export async function getNpmTrustedToken(opts) {
136
+ const audience = `npm:${new URL(opts.registry).hostname}`;
137
+ const idToken = await fetchGithubOidcToken(audience, opts.log);
138
+ const token = await exchangeOidcForNpmToken({ ...opts, idToken });
139
+ return { token, audience };
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.4.15",
3
+ "version": "0.4.16",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -37,18 +37,18 @@
37
37
  "cli"
38
38
  ],
39
39
  "dependencies": {
40
- "@gjsify/buffer": "^0.4.15",
41
- "@gjsify/create-app": "^0.4.15",
42
- "@gjsify/node-globals": "^0.4.15",
43
- "@gjsify/node-polyfills": "^0.4.15",
44
- "@gjsify/npm-registry": "^0.4.15",
45
- "@gjsify/resolve-npm": "^0.4.15",
46
- "@gjsify/rolldown-plugin-gjsify": "^0.4.15",
47
- "@gjsify/rolldown-plugin-pnp": "^0.4.15",
48
- "@gjsify/semver": "^0.4.15",
49
- "@gjsify/tar": "^0.4.15",
50
- "@gjsify/web-polyfills": "^0.4.15",
51
- "@gjsify/workspace": "^0.4.15",
40
+ "@gjsify/buffer": "^0.4.16",
41
+ "@gjsify/create-app": "^0.4.16",
42
+ "@gjsify/node-globals": "^0.4.16",
43
+ "@gjsify/node-polyfills": "^0.4.16",
44
+ "@gjsify/npm-registry": "^0.4.16",
45
+ "@gjsify/resolve-npm": "^0.4.16",
46
+ "@gjsify/rolldown-plugin-gjsify": "^0.4.16",
47
+ "@gjsify/rolldown-plugin-pnp": "^0.4.16",
48
+ "@gjsify/semver": "^0.4.16",
49
+ "@gjsify/tar": "^0.4.16",
50
+ "@gjsify/web-polyfills": "^0.4.16",
51
+ "@gjsify/workspace": "^0.4.16",
52
52
  "cosmiconfig": "^9.0.1",
53
53
  "get-tsconfig": "^4.14.0",
54
54
  "pkg-types": "^2.3.1",
@@ -56,12 +56,12 @@
56
56
  "yargs": "^18.0.0"
57
57
  },
58
58
  "devDependencies": {
59
- "@gjsify/unit": "^0.4.15",
59
+ "@gjsify/unit": "^0.4.16",
60
60
  "@types/yargs": "^17.0.35",
61
61
  "typescript": "^6.0.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@gjsify/rolldown-native": "^0.4.15"
64
+ "@gjsify/rolldown-native": "^0.4.16"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
67
  "@gjsify/rolldown-native": {