@gjsify/cli 0.14.0 → 0.16.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.
- package/dist/cli.gjs.mjs +45 -44
- package/lib/actions/build.js +24 -1
- package/lib/cli-app.js +2 -1
- package/lib/commands/foreach.d.ts +1 -0
- package/lib/commands/foreach.js +48 -13
- package/lib/commands/index.d.ts +1 -0
- package/lib/commands/index.js +1 -0
- package/lib/commands/install.js +132 -65
- package/lib/commands/login.d.ts +29 -0
- package/lib/commands/login.js +122 -96
- package/lib/commands/onboard.d.ts +14 -0
- package/lib/commands/onboard.js +453 -0
- package/lib/commands/publish.d.ts +92 -0
- package/lib/commands/publish.js +270 -295
- package/lib/commands/trust.d.ts +21 -0
- package/lib/commands/trust.js +40 -70
- package/lib/commands/workspace.d.ts +2 -0
- package/lib/commands/workspace.js +64 -14
- package/lib/utils/build-cache.d.ts +157 -0
- package/lib/utils/build-cache.js +670 -0
- package/lib/utils/install-backend-native.js +104 -9
- package/lib/utils/install-backend.d.ts +10 -0
- package/lib/utils/install-cache-fs.d.ts +9 -0
- package/lib/utils/install-cache-fs.js +20 -6
- package/lib/utils/install-lock.d.ts +15 -0
- package/lib/utils/install-lock.js +293 -0
- package/lib/utils/library-output.d.ts +30 -0
- package/lib/utils/library-output.js +92 -0
- package/lib/utils/npm-otp.d.ts +67 -0
- package/lib/utils/npm-otp.js +117 -0
- package/lib/utils/suggest.d.ts +13 -0
- package/lib/utils/suggest.js +51 -0
- package/package.json +19 -18
package/lib/commands/publish.js
CHANGED
|
@@ -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
|
-
//
|
|
160
|
-
//
|
|
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
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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:
|
|
380
|
-
version:
|
|
381
|
-
filename:
|
|
382
|
-
size:
|
|
383
|
-
integrity:
|
|
384
|
-
tag,
|
|
385
|
-
registry:
|
|
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 (
|
|
382
|
+
if (asJson)
|
|
388
383
|
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
|
389
384
|
else
|
|
390
|
-
process.stdout.write(`+ ${
|
|
385
|
+
process.stdout.write(`+ ${outcome.name}@${outcome.version}\n`);
|
|
391
386
|
return;
|
|
392
387
|
}
|
|
393
|
-
|
|
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:
|
|
410
|
-
version:
|
|
411
|
-
status:
|
|
392
|
+
name: outcome.name,
|
|
393
|
+
version: outcome.version,
|
|
394
|
+
status: outcome.status,
|
|
412
395
|
};
|
|
413
|
-
if (
|
|
396
|
+
if (asJson)
|
|
414
397
|
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
|
415
398
|
else
|
|
416
|
-
process.stdout.write(`= ${
|
|
399
|
+
process.stdout.write(`= ${outcome.name}@${outcome.version} (already published, tolerated)\n`);
|
|
417
400
|
return;
|
|
418
401
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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 = [
|
|
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(', ');
|