@gjsify/cli 0.15.1 → 0.16.1

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.
@@ -41,6 +41,8 @@ import { packWorkspace } from './pack.js';
41
41
  import { getNpmTrustedToken, hasGithubOidcEnv, OidcExchangeError, OidcUnavailableError } from '../utils/npm-oidc.js';
42
42
  import { diagnose404, is404DiagnosticCandidate } from '../utils/publish-diagnose.js';
43
43
  import { loadNpmrc } from '../utils/load-npmrc.js';
44
+ import { OtpProvider, withOtpRetry, isOtpChallenge } from '../utils/npm-otp.js';
45
+ import { promptLine } from '../utils/prompt.js';
44
46
  export const publishCommand = {
45
47
  command: 'publish [path]',
46
48
  description: 'Pack + upload the workspace at <path> (default: cwd) to its npm registry. Drop-in for `npm publish` with workspace:^ rewrite handled automatically.',
@@ -156,39 +158,14 @@ export const publishCommand = {
156
158
  return;
157
159
  }
158
160
  }
159
- // 1. Pack the workspace (rewrites workspace:^, computes integrity).
160
- // Lifecycle scripts: `prepublishOnly` (publish-specific) runs before
161
- // `prepack`. Matches `npm publish` semantics — packages that gate
162
- // release-time validation on `prepublishOnly` (typecheck, smoke
163
- // tests, version-tagging) get exactly that, and packages whose
164
- // `prepack` generates build artifacts (process-templates,
165
- // codegen, …) get those artifacts into the tarball.
166
- const packOpts = {
167
- dryRun: true,
168
- lifecycleScripts: ['prepublishOnly', 'prepack'],
169
- // `gjsify publish --json` emits the publish summary on stdout.
170
- // Lifecycle scripts must not pollute that stream with their
171
- // own log lines; redirect their stdout → parent's stderr.
172
- lifecycleStdio: args.json ? 'inherit-stderr' : 'inherit',
173
- };
174
- const packed = await packWorkspace(wsDir, packOpts);
175
- // We need the raw bytes — re-run with destination=null and capture.
176
- // packWorkspace returns metadata only; for the bytes we re-pack into
177
- // memory by reading + rebuilding. Cheap because the second pack runs
178
- // off the same source files. (A future optimization: have
179
- // packWorkspace optionally return the bytes itself.)
180
- const tarBytes = await packWorkspaceToBytes(wsDir);
181
- // 2. Read the workspace's (rewritten) package.json to assemble the
182
- // publish payload.
183
- const pkgPath = join(wsDir, 'package.json');
184
- const pkgSource = readFileSync(pkgPath, 'utf-8');
185
- const pkg = JSON.parse(pkgSource);
186
- // We need the rewritten manifest (workspace:^ → resolved) for the
187
- // payload — packWorkspace already wrote it into the tarball. Mirror
188
- // the same rewrite here so the registry's "package metadata" matches
189
- // the tarball's package.json byte-for-byte.
190
- const rewrittenPkg = await loadRewrittenManifest(wsDir, pkg);
161
+ // --dry-run: pack only, no PUT. Lifecycle scripts still run so the
162
+ // reported size/hash matches what a real publish would upload.
191
163
  if (dryRun) {
164
+ const packed = await packWorkspace(wsDir, {
165
+ dryRun: true,
166
+ lifecycleScripts: ['prepublishOnly', 'prepack'],
167
+ lifecycleStdio: args.json ? 'inherit-stderr' : 'inherit',
168
+ });
192
169
  const message = {
193
170
  ok: true,
194
171
  action: 'dry-run',
@@ -205,254 +182,282 @@ export const publishCommand = {
205
182
  process.stdout.write(`+ ${packed.name}@${packed.version} (dry-run, ${packed.size} bytes, ${packed.entryCount} files)\n`);
206
183
  return;
207
184
  }
208
- // 3. Resolve registry URL + auth
209
- const npmrc = await loadNpmrc(wsDir);
210
- const registry = process.env.npm_config_registry ?? registryFor(packed.name, npmrc) ?? DEFAULT_REGISTRY;
211
- const registryClean = registry.endsWith('/') ? registry.slice(0, -1) : registry;
212
- // npm-package-arg's escapedName convention (npa.js:188):
213
- // `name.replace('/', '%2f')` — scoped `@scope/name` → `@scope%2fname`
214
- // (literal @, lowercase %2f, segments NOT otherwise re-encoded);
215
- // unscoped names pass through. Match it exactly — the npm registry is
216
- // picky about the publish PUT URL shape.
217
- const escapedName = escapePackageName(packed.name);
218
- const url = `${registryClean}/${escapedName}`;
219
- // npm publish convention: the dist.tarball URL + _attachments key both
220
- // use the UNSCOPED basename — `cli-0.4.5.tgz`, not
221
- // `gjsify-cli-0.4.5.tgz`. This is what libnpmpublish does (see
222
- // `attachments[\`${unscopedName}-${version}.tgz\`]` in
223
- // libnpmpublish/lib/publish.js). The full scoped filename is what
224
- // `npm pack` writes to disk, but the registry stores tarballs at
225
- // the unscoped path.
226
- const unscopedName = packed.name.includes('/') ? packed.name.slice(packed.name.indexOf('/') + 1) : packed.name;
227
- const wireFilename = `${unscopedName}-${packed.version}.tgz`;
228
- const tarballUrl = `${registryClean}/${packed.name}/-/${wireFilename}`;
229
- // 4. Build payload + PUT
230
- const payload = buildPublishPayload({
231
- pkg: rewrittenPkg,
185
+ // Real publish. Prompt for a 2FA code only on a TTY; otherwise a
186
+ // challenge surfaces as a clear "re-run with --otp" error rather than a
187
+ // silent hang reading a non-existent terminal. `--otp` seeds the code so
188
+ // it rides the first PUT (matching npm's `--otp` behaviour).
189
+ const interactiveOtp = Boolean(process.stdin.isTTY && process.stdout.isTTY);
190
+ const otpProvider = new OtpProvider(otp, interactiveOtp ? promptLine : async () => '');
191
+ const outcome = await publishWorkspace({
192
+ wsDir,
232
193
  tag,
233
194
  access,
234
- tarballBytes: tarBytes,
235
- tarballUrl,
236
- packed: { ...packed, wireFilename },
237
195
  provenance,
196
+ tolerate,
197
+ tolerateUntrustedNew,
198
+ trustedFlag,
199
+ verbose,
200
+ json: args.json === true,
201
+ otpProvider,
202
+ seedOtpFirst: otp !== undefined,
238
203
  });
239
- // Build the publish-PUT headers to match what `npm publish` sends via
240
- // npm-registry-fetch's `getHeaders` — critically `npm-command: publish`
241
- // (the routing header npm's create-package frontdoor keys on; without it
242
- // a first-publish of a brand-new scoped package returns a bare 404
243
- // `Not Found`), plus `npm-auth-type` (legacy when --otp, else web), a
244
- // real `user-agent`, and `npm-otp` when an OTP is supplied. See
245
- // `utils/publish-headers.ts` for the field-by-field refs/npm-cli
246
- // evidence.
247
- const headers = buildPublishHeaders(url, { npmrc, otp });
248
- // Trusted Publishing path. `--trusted` forces OIDC (errors if env
249
- // vars are missing); the default `undefined` triggers auto-detect:
250
- // OIDC is used iff GitHub OIDC env vars are present AND no
251
- // `NODE_AUTH_TOKEN` is set. With `NODE_AUTH_TOKEN` set the user has
252
- // explicitly opted into token auth, so we don't shadow their choice.
253
- const wantTrusted = trustedFlag === true || (trustedFlag === undefined && hasGithubOidcEnv() && !process.env.NODE_AUTH_TOKEN);
254
- let authMode = 'token';
255
- if (wantTrusted) {
256
- try {
257
- const { token: oidcToken, audience } = await getNpmTrustedToken({
258
- packageName: packed.name,
259
- registry,
260
- log: verbose ? (m) => console.error(m) : undefined,
261
- });
262
- headers['authorization'] = `Bearer ${oidcToken}`;
263
- authMode = 'oidc';
264
- if (verbose) {
265
- console.error(`gjsify publish: OIDC token obtained (audience=${audience})`);
266
- }
204
+ reportPublishOutcome(outcome, args.json === true);
205
+ },
206
+ };
207
+ /**
208
+ * Pack + PUT a single workspace to its npm registry, returning a structured
209
+ * outcome instead of writing/exiting. Extracted from the `publish` command so
210
+ * `gjsify onboard` can drive the SAME publish flow while sharing one
211
+ * {@link OtpProvider} across every package in a sweep.
212
+ *
213
+ * The OTP dance runs through `withOtpRetry(provider)`: the cached code is tried
214
+ * before prompting, so a shared provider needs only one interactive entry for N
215
+ * packages.
216
+ */
217
+ export async function publishWorkspace(input) {
218
+ const { wsDir, tag, access, provenance, tolerate, tolerateUntrustedNew, trustedFlag, verbose, json, otpProvider } = input;
219
+ // 1. Pack the workspace (rewrites workspace:^, computes integrity).
220
+ // Lifecycle scripts: `prepublishOnly` runs before `prepack` (npm semantics).
221
+ const packOpts = {
222
+ dryRun: true,
223
+ lifecycleScripts: ['prepublishOnly', 'prepack'],
224
+ lifecycleStdio: json ? 'inherit-stderr' : 'inherit',
225
+ };
226
+ const packed = await packWorkspace(wsDir, packOpts);
227
+ const tarBytes = await packWorkspaceToBytes(wsDir);
228
+ // 2. Read the workspace's (rewritten) package.json for the payload.
229
+ const pkgPath = join(wsDir, 'package.json');
230
+ const pkgSource = readFileSync(pkgPath, 'utf-8');
231
+ const pkg = JSON.parse(pkgSource);
232
+ const rewrittenPkg = await loadRewrittenManifest(wsDir, pkg);
233
+ // 3. Resolve registry URL + auth.
234
+ const npmrc = await loadNpmrc(wsDir);
235
+ const registry = input.registry ?? process.env.npm_config_registry ?? registryFor(packed.name, npmrc) ?? DEFAULT_REGISTRY;
236
+ const registryClean = registry.endsWith('/') ? registry.slice(0, -1) : registry;
237
+ // npm-package-arg's escapedName convention (npa.js:188): `@scope/name` →
238
+ // `@scope%2fname` (literal @, lowercase %2f). Match it exactly.
239
+ const escapedName = escapePackageName(packed.name);
240
+ const url = `${registryClean}/${escapedName}`;
241
+ // npm publish convention: the tarball URL + _attachments key use the
242
+ // UNSCOPED basename (`cli-0.4.5.tgz`, not `gjsify-cli-0.4.5.tgz`).
243
+ const unscopedName = packed.name.includes('/') ? packed.name.slice(packed.name.indexOf('/') + 1) : packed.name;
244
+ const wireFilename = `${unscopedName}-${packed.version}.tgz`;
245
+ const tarballUrl = `${registryClean}/${packed.name}/-/${wireFilename}`;
246
+ // 4. Build payload.
247
+ const payload = buildPublishPayload({
248
+ pkg: rewrittenPkg,
249
+ tag,
250
+ access,
251
+ tarballBytes: tarBytes,
252
+ tarballUrl,
253
+ packed: { ...packed, wireFilename },
254
+ provenance,
255
+ });
256
+ // Base headers (npm-command: publish routing header etc.). The OTP is added
257
+ // per-PUT by `doPut` so `withOtpRetry` can retry with different codes.
258
+ const headers = buildPublishHeaders(url, { npmrc });
259
+ // Trusted Publishing (OIDC) path — `--trusted` forces it, `undefined`
260
+ // auto-detects from the GitHub OIDC env. Unused by `gjsify onboard` (local
261
+ // token auth), preserved verbatim for `gjsify publish` in CI.
262
+ const wantTrusted = trustedFlag === true || (trustedFlag === undefined && hasGithubOidcEnv() && !process.env.NODE_AUTH_TOKEN);
263
+ let authMode = 'token';
264
+ if (wantTrusted) {
265
+ try {
266
+ const { token: oidcToken, audience } = await getNpmTrustedToken({
267
+ packageName: packed.name,
268
+ registry,
269
+ log: verbose ? (m) => console.error(m) : undefined,
270
+ });
271
+ headers['authorization'] = `Bearer ${oidcToken}`;
272
+ authMode = 'oidc';
273
+ if (verbose)
274
+ console.error(`gjsify publish: OIDC token obtained (audience=${audience})`);
275
+ }
276
+ catch (err) {
277
+ const isUntrustedNewPackage = err instanceof OidcExchangeError && err.status === 404 && /package not found/i.test(err.body);
278
+ if (isUntrustedNewPackage && tolerateUntrustedNew) {
279
+ return { ok: true, action: 'skipped-untrusted-new', name: packed.name, version: packed.version };
267
280
  }
268
- catch (err) {
269
- // Detect the "never-before-published @scope/pkg" shape:
270
- // npm's OIDC exchange returns 404 with body
271
- // {"message":"OIDC token exchange error - package not found"}
272
- // for any package that has no Trusted Publisher entry (which
273
- // includes every package that doesn't exist on npm yet —
274
- // see AGENTS.md "New @gjsify/* package: first-publish +
275
- // Trusted Publisher bootstrap"). Skip such a package when
276
- // --tolerate-untrusted-new is set so one un-bootstrapped
277
- // package doesn't break the entire serialized publish loop.
278
- const isUntrustedNewPackage = err instanceof OidcExchangeError && err.status === 404 && /package not found/i.test(err.body);
279
- if (isUntrustedNewPackage && tolerateUntrustedNew) {
280
- const headerMsg = `${packed.name}@${packed.version} (skipped — no Trusted Publisher on npm, see AGENTS.md "New @gjsify/* package: first-publish + Trusted Publisher bootstrap")`;
281
- if (args.json) {
282
- process.stdout.write(`${JSON.stringify({
283
- ok: true,
284
- action: 'skipped-untrusted-new',
285
- name: packed.name,
286
- version: packed.version,
287
- reason: 'no-trusted-publisher',
288
- }, null, 2)}\n`);
289
- }
290
- else {
291
- process.stdout.write(`~ ${headerMsg}\n`);
292
- }
293
- return;
294
- }
295
- if (trustedFlag === true) {
296
- // Explicit --trusted: bail with a clear error.
297
- handleOidcFailure(err, packed.name, args.json === true);
298
- process.exit(1);
299
- }
300
- // Auto-detect: fall back to the npmrc token auth that
301
- // buildPublishHeaders already resolved into `headers` — BUT only
302
- // if one actually exists. In OIDC-only CI there is no npmrc
303
- // token, so a "fallback" would PUT with no credentials, get a
304
- // 404, and risk being mistaken for a tolerable outcome — exactly
305
- // the v0.7.3 incident where a transient OIDC 503 on
306
- // `@gjsify/process` left it unpublished while the release stayed
307
- // green. Refuse loudly so a transient OIDC failure can never
308
- // silently drop a package from a release.
309
- if (!headers['authorization']) {
310
- const msg = err instanceof Error ? err.message : String(err);
311
- console.error(`gjsify publish: OIDC token exchange failed for ${packed.name} (${msg}) and no fallback npm token is configured — refusing to publish without credentials.`);
312
- if (args.json) {
313
- process.stdout.write(`${JSON.stringify({ ok: false, name: packed.name, version: packed.version, error: 'oidc-failed-no-token' }, null, 2)}\n`);
314
- }
315
- process.exit(1);
316
- }
317
- if (verbose) {
318
- const msg = err instanceof Error ? err.message : String(err);
319
- console.error(`gjsify publish: OIDC auto-detect failed (${msg}) — falling back to token auth`);
320
- }
281
+ if (trustedFlag === true) {
282
+ return { ok: false, action: 'oidc-failed', name: packed.name, version: packed.version, error: err };
283
+ }
284
+ // Auto-detect fallback to token auth — but only if a token exists;
285
+ // refuse to PUT with no credentials (the v0.7.3 silent-drop incident).
286
+ if (!headers['authorization']) {
287
+ return { ok: false, action: 'oidc-no-token', name: packed.name, version: packed.version, error: err };
288
+ }
289
+ if (verbose) {
290
+ const msg = err instanceof Error ? err.message : String(err);
291
+ console.error(`gjsify publish: OIDC auto-detect failed (${msg}) — falling back to token auth`);
321
292
  }
322
293
  }
323
- if (verbose) {
324
- console.error(`gjsify publish: PUT ${url} (${packed.name}@${packed.version})`);
325
- console.error(` auth-mode: ${authMode}`);
326
- console.error(` authorization: ${headers['authorization'] ? '(set)' : '(none)'}`);
327
- console.error(` otp: ${headers['npm-otp'] ? '(set)' : '(none)'}`);
328
- console.error(` payload size: ${JSON.stringify(payload).length} bytes`);
329
- }
330
- const bodyStr = JSON.stringify(payload);
331
- // Helper that PUTs with a given header set and returns the response.
332
- async function doPut(reqHeaders) {
333
- return fetch(url, {
334
- method: 'PUT',
335
- headers: reqHeaders,
336
- body: bodyStr,
337
- });
294
+ }
295
+ if (verbose) {
296
+ console.error(`gjsify publish: PUT ${url} (${packed.name}@${packed.version})`);
297
+ console.error(` auth-mode: ${authMode}`);
298
+ console.error(` authorization: ${headers['authorization'] ? '(set)' : '(none)'}`);
299
+ console.error(` payload size: ${JSON.stringify(payload).length} bytes`);
300
+ }
301
+ const bodyStr = JSON.stringify(payload);
302
+ // Each attempt clones the base headers and adds `npm-otp` (+ legacy
303
+ // auth-type, matching buildPublishHeaders) when a code is supplied.
304
+ const doPut = (code) => {
305
+ const reqHeaders = { ...headers };
306
+ if (code) {
307
+ reqHeaders['npm-otp'] = code;
308
+ reqHeaders['npm-auth-type'] = 'legacy';
338
309
  }
339
- let res = await doPut(headers);
340
- // EOTP handling — mirrors npm's otplease.js (refs/npm-cli/lib/utils/auth.js).
341
- // npm signals "OTP required" via:
342
- // - HTTP 401 + `www-authenticate` header containing "otp"
343
- // (refs/npm-cli/node_modules/npm-registry-fetch/lib/check-response.js
344
- // line ~83: `auth.indexOf('otp') !== -1` → HttpErrorAuthOTP)
345
- // - HTTP 401 + body containing "one-time pass" (heuristic for
346
- // malformed responses missing the www-authenticate header —
347
- // same check-response.js lines ~92-98)
348
- // We check both shapes, exactly matching npm's logic.
349
- // If the caller already supplied --otp we skip this (they set the
350
- // header; if the registry still 401s that is a wrong-code error).
351
- if (!otp && res.status === 401) {
352
- const wwwAuth = res.headers.get('www-authenticate') ?? '';
353
- const body401 = await res.text().catch(() => '');
354
- const needsOtp = wwwAuth.toLowerCase().split(/,\s*/).includes('otp') || /one-time pass/i.test(body401);
355
- if (needsOtp) {
356
- // Interactive path: if stdin is a TTY, prompt and retry once.
357
- if (process.stdin.isTTY && process.stdout.isTTY) {
358
- process.stdout.write('This operation requires a one-time password.\nEnter OTP: ');
359
- const enteredOtp = await readLineFromStdin();
360
- if (enteredOtp) {
361
- const retryHeaders = { ...headers, 'npm-otp': enteredOtp };
362
- res = await doPut(retryHeaders);
363
- }
364
- else {
365
- console.error(`gjsify publish: no OTP entered — re-run with \`--otp <code>\``);
366
- process.exit(1);
367
- }
368
- }
369
- else {
370
- // Non-interactive: give the maintainer a clear, actionable message.
371
- console.error(`gjsify publish: npm requires a 2FA one-time code — re-run with \`--otp <code>\``);
372
- process.exit(1);
373
- }
374
- }
310
+ return fetch(url, { method: 'PUT', headers: reqHeaders, body: bodyStr });
311
+ };
312
+ const res = await withOtpRetry(doPut, otpProvider, { seedFirstAttempt: input.seedOtpFirst });
313
+ // A surviving OTP challenge means the provider gave up (no code entered /
314
+ // non-interactive). Surface it so the caller can point at --otp.
315
+ if (await isOtpChallenge(res)) {
316
+ return { ok: false, action: 'otp-required', name: packed.name, version: packed.version, status: res.status };
317
+ }
318
+ if (res.ok) {
319
+ return {
320
+ ok: true,
321
+ action: 'published',
322
+ name: packed.name,
323
+ version: packed.version,
324
+ filename: packed.filename,
325
+ size: packed.size,
326
+ integrity: packed.integrity,
327
+ tag,
328
+ registry: registryClean,
329
+ };
330
+ }
331
+ const text = await res.text().catch(() => '<no body>');
332
+ // "version already published" — 409 Conflict, or 403 + "previously published".
333
+ const isRepublishConflict = res.status === 409 || (res.status === 403 && /previously published/i.test(text));
334
+ if (isRepublishConflict && tolerate) {
335
+ return {
336
+ ok: true,
337
+ action: 'republish-tolerated',
338
+ name: packed.name,
339
+ version: packed.version,
340
+ status: res.status,
341
+ };
342
+ }
343
+ // 404 diagnostic (token-auth): disambiguate dead-token vs missing-package.
344
+ if (res.status === 404 && authMode === 'token' && is404DiagnosticCandidate(text)) {
345
+ const diag = await diagnose404({
346
+ packageName: packed.name,
347
+ version: packed.version,
348
+ registry: registryClean,
349
+ npmrc,
350
+ });
351
+ if (diag.reason !== 'unknown') {
352
+ return { ok: false, action: 'diagnostic', name: packed.name, version: packed.version, status: 404, diag };
375
353
  }
376
- if (res.ok) {
354
+ }
355
+ return {
356
+ ok: false,
357
+ action: 'error',
358
+ name: packed.name,
359
+ version: packed.version,
360
+ status: res.status,
361
+ statusText: res.statusText,
362
+ text,
363
+ };
364
+ }
365
+ /**
366
+ * Present a {@link PublishOutcome} on stdout/stderr and exit non-zero on
367
+ * failure — the single-command `gjsify publish` behaviour, reproduced exactly.
368
+ */
369
+ function reportPublishOutcome(outcome, asJson) {
370
+ switch (outcome.action) {
371
+ case 'published': {
377
372
  const out = {
378
373
  ok: true,
379
- name: packed.name,
380
- version: packed.version,
381
- filename: packed.filename,
382
- size: packed.size,
383
- integrity: packed.integrity,
384
- tag,
385
- registry: registryClean,
374
+ name: outcome.name,
375
+ version: outcome.version,
376
+ filename: outcome.filename,
377
+ size: outcome.size,
378
+ integrity: outcome.integrity,
379
+ tag: outcome.tag,
380
+ registry: outcome.registry,
386
381
  };
387
- if (args.json)
382
+ if (asJson)
388
383
  process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
389
384
  else
390
- process.stdout.write(`+ ${packed.name}@${packed.version}\n`);
385
+ process.stdout.write(`+ ${outcome.name}@${outcome.version}\n`);
391
386
  return;
392
387
  }
393
- const text = await res.text().catch(() => '<no body>');
394
- // npm signals "version already published" via two distinct shapes:
395
- // - HTTP 409 Conflict (classic libnpmpublish behaviour)
396
- // - HTTP 403 Forbidden with body
397
- // `{"error":"You cannot publish over the previously published
398
- // versions: <ver>."}` (observed on the OIDC publish path
399
- // starting around npm registry's 2026-05 changes — same
400
- // idempotency outcome, different status code)
401
- // Both are intentionally tolerated under --tolerate-republish so
402
- // that re-running a release workflow after a partial failure does
403
- // not error on the already-published packages.
404
- const isRepublishConflict = res.status === 409 || (res.status === 403 && /previously published/i.test(text));
405
- if (isRepublishConflict && tolerate) {
388
+ case 'republish-tolerated': {
406
389
  const out = {
407
390
  ok: true,
408
391
  action: 'republish-tolerated',
409
- name: packed.name,
410
- version: packed.version,
411
- status: res.status,
392
+ name: outcome.name,
393
+ version: outcome.version,
394
+ status: outcome.status,
412
395
  };
413
- if (args.json)
396
+ if (asJson)
414
397
  process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
415
398
  else
416
- process.stdout.write(`= ${packed.name}@${packed.version} (already published, tolerated)\n`);
399
+ process.stdout.write(`= ${outcome.name}@${outcome.version} (already published, tolerated)\n`);
417
400
  return;
418
401
  }
419
- // 404 diagnostic — token-auth (with or without --otp). npm returns 404
420
- // for a dead `_authToken`, a genuinely-missing package, AND transiently
421
- // while a brand-new scoped package is being provisioned; `/-/whoami`
422
- // disambiguates a dead token from a live one. OIDC has its own clear
423
- // error surfaces (handled in the OIDC catch block above), so the
424
- // diagnostic only kicks in for the token-auth PUT signature.
425
- if (res.status === 404 && authMode === 'token') {
426
- if (is404DiagnosticCandidate(text)) {
427
- const diag = await diagnose404({
428
- packageName: packed.name,
429
- version: packed.version,
430
- registry: registryClean,
431
- npmrc,
432
- });
433
- if (diag.reason !== 'unknown') {
434
- if (args.json) {
435
- process.stdout.write(`${JSON.stringify({
436
- ok: false,
437
- name: packed.name,
438
- version: packed.version,
439
- status: 404,
440
- diagnostic: diag.reason,
441
- username: diag.username,
442
- }, null, 2)}\n`);
443
- }
444
- else {
445
- process.stderr.write(`${diag.message}\n`);
446
- }
447
- process.exit(1);
448
- }
402
+ case 'skipped-untrusted-new': {
403
+ if (asJson) {
404
+ process.stdout.write(`${JSON.stringify({
405
+ ok: true,
406
+ action: 'skipped-untrusted-new',
407
+ name: outcome.name,
408
+ version: outcome.version,
409
+ reason: 'no-trusted-publisher',
410
+ }, null, 2)}\n`);
411
+ }
412
+ else {
413
+ process.stdout.write(`~ ${outcome.name}@${outcome.version} (skipped — no Trusted Publisher on npm, see AGENTS.md "New @gjsify/* package: first-publish + Trusted Publisher bootstrap")\n`);
449
414
  }
415
+ return;
450
416
  }
451
- console.error(`gjsify publish: ${packed.name}@${packed.version} — ${res.status} ${res.statusText}`);
452
- console.error(text);
453
- process.exit(1);
454
- },
455
- };
417
+ case 'otp-required': {
418
+ console.error(`gjsify publish: npm requires a 2FA one-time code — re-run with \`--otp <code>\``);
419
+ process.exit(1);
420
+ return;
421
+ }
422
+ case 'oidc-failed': {
423
+ handleOidcFailure(outcome.error, outcome.name, asJson);
424
+ process.exit(1);
425
+ return;
426
+ }
427
+ case 'oidc-no-token': {
428
+ const msg = outcome.error instanceof Error ? outcome.error.message : String(outcome.error);
429
+ console.error(`gjsify publish: OIDC token exchange failed for ${outcome.name} (${msg}) and no fallback npm token is configured — refusing to publish without credentials.`);
430
+ if (asJson) {
431
+ process.stdout.write(`${JSON.stringify({ ok: false, name: outcome.name, version: outcome.version, error: 'oidc-failed-no-token' }, null, 2)}\n`);
432
+ }
433
+ process.exit(1);
434
+ return;
435
+ }
436
+ case 'diagnostic': {
437
+ if (asJson) {
438
+ process.stdout.write(`${JSON.stringify({
439
+ ok: false,
440
+ name: outcome.name,
441
+ version: outcome.version,
442
+ status: 404,
443
+ diagnostic: outcome.diag.reason,
444
+ username: outcome.diag.username,
445
+ }, null, 2)}\n`);
446
+ }
447
+ else {
448
+ process.stderr.write(`${outcome.diag.message}\n`);
449
+ }
450
+ process.exit(1);
451
+ return;
452
+ }
453
+ case 'error': {
454
+ console.error(`gjsify publish: ${outcome.name}@${outcome.version} — ${outcome.status} ${outcome.statusText}`);
455
+ console.error(outcome.text);
456
+ process.exit(1);
457
+ return;
458
+ }
459
+ }
460
+ }
456
461
  async function packWorkspaceToBytes(wsDir) {
457
462
  // Cheap re-run that writes to a tempdir, then read back. Avoids
458
463
  // duplicating the file-walking + tar-building logic here. Lifecycle
@@ -545,46 +550,6 @@ function base64Encode(bytes) {
545
550
  }
546
551
  return btoa(str);
547
552
  }
548
- /**
549
- * Read a single line from stdin (blocking via async iterator). Used for the
550
- * interactive OTP prompt path (mirrors npm's `read.otp()`). Resolves with the
551
- * trimmed line or an empty string if stdin closed without input.
552
- */
553
- async function readLineFromStdin() {
554
- return new Promise((resolve) => {
555
- let buf = '';
556
- const onData = (chunk) => {
557
- buf += typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
558
- const nl = buf.indexOf('\n');
559
- if (nl >= 0) {
560
- cleanup();
561
- resolve(buf.slice(0, nl).trim());
562
- }
563
- };
564
- const onEnd = () => {
565
- cleanup();
566
- resolve(buf.trim());
567
- };
568
- const onError = () => {
569
- cleanup();
570
- resolve('');
571
- };
572
- const cleanup = () => {
573
- process.stdin.removeListener('data', onData);
574
- process.stdin.removeListener('end', onEnd);
575
- process.stdin.removeListener('error', onError);
576
- if (typeof process.stdin.unref === 'function') {
577
- process.stdin.unref?.();
578
- }
579
- };
580
- process.stdin.setEncoding('utf-8');
581
- if (process.stdin.isPaused())
582
- process.stdin.resume();
583
- process.stdin.once('data', onData);
584
- process.stdin.once('end', onEnd);
585
- process.stdin.once('error', onError);
586
- });
587
- }
588
553
  function handleOidcFailure(err, packageName, asJson) {
589
554
  if (err instanceof OidcUnavailableError) {
590
555
  const msg = `gjsify publish: OIDC not available — ${err.message}`;
@@ -610,7 +575,17 @@ function handleOidcFailure(err, packageName, asJson) {
610
575
  process.stderr.write(` ↳ npm said: ${err.body.slice(0, 300)}\n`);
611
576
  if (err.claims) {
612
577
  const c = err.claims;
613
- const shown = ['aud', 'iss', 'repository', 'repository_owner', 'workflow_ref', 'job_workflow_ref', 'ref', 'environment', 'sub']
578
+ const shown = [
579
+ 'aud',
580
+ 'iss',
581
+ 'repository',
582
+ 'repository_owner',
583
+ 'workflow_ref',
584
+ 'job_workflow_ref',
585
+ 'ref',
586
+ 'environment',
587
+ 'sub',
588
+ ]
614
589
  .filter((k) => c[k] !== undefined)
615
590
  .map((k) => `${k}=${JSON.stringify(c[k])}`)
616
591
  .join(', ');