@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.
- package/dist/cli.gjs.mjs +114 -116
- package/lib/actions/build.js +24 -1
- package/lib/cli-app.js +2 -1
- package/lib/commands/index.d.ts +1 -0
- package/lib/commands/index.js +1 -0
- 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/pack.js +25 -11
- 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 +1 -0
- package/lib/commands/workspace.js +30 -9
- 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 +18 -18
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
// `gjsify onboard [--dry-run] [--otp <code>] [--registry <url>] [--json] [--yes]`
|
|
2
|
+
//
|
|
3
|
+
// One command that ensures EVERY publishable `@gjsify/*` workspace is both
|
|
4
|
+
// PUBLISHED on npm AND has a Trusted Publisher configured for this repo's
|
|
5
|
+
// `release.yml`. It does the minimum work — publishing/trusting only what's
|
|
6
|
+
// missing — and folds the whole manual bootstrap sequence documented in
|
|
7
|
+
// AGENTS.md ("New @gjsify/* package: first-publish + Trusted Publisher
|
|
8
|
+
// bootstrap") into a single idempotent sweep:
|
|
9
|
+
//
|
|
10
|
+
// 1. Auth gate — `whoami` first. If the token is live, proceed without asking
|
|
11
|
+
// for credentials; only when it is dead/missing do we run the `gjsify
|
|
12
|
+
// login` flow (unless `--yes` on a non-TTY, which fails clearly).
|
|
13
|
+
// 2. Enumerate publishable workspaces (non-private, excluding `@girs/*`).
|
|
14
|
+
// 3. Determine each package's state CONCURRENTLY (bounded) by reading npm's
|
|
15
|
+
// Trusted-Publisher config: unpublished (404) / published-but-untrusted /
|
|
16
|
+
// published-and-trusted. Reads never prompt for 2FA.
|
|
17
|
+
// 4. Act on the gaps, minimally: build+publish+trust an unpublished package;
|
|
18
|
+
// trust a published-but-untrusted one; skip an already-done one.
|
|
19
|
+
// 5. Share ONE OTP across every publish + trust operation — the cached code
|
|
20
|
+
// is tried before prompting, so a whole sweep of N packages typically
|
|
21
|
+
// needs the user to type an OTP only once (see utils/npm-otp.ts).
|
|
22
|
+
//
|
|
23
|
+
// Re-running when everything is already published + trusted does nothing and
|
|
24
|
+
// exits 0. `--dry-run` reports the plan and changes nothing.
|
|
25
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
26
|
+
import { DEFAULT_REGISTRY, buildHeaders, registryFor, whoami } from '@gjsify/npm-registry';
|
|
27
|
+
import { discoverWorkspaces, filterWorkspaces } from '@gjsify/workspace';
|
|
28
|
+
import { hasAnyCredential, loadNpmrc } from '../utils/load-npmrc.js';
|
|
29
|
+
import { OtpProvider } from '../utils/npm-otp.js';
|
|
30
|
+
import { publishWorkspace } from './publish.js';
|
|
31
|
+
import { createTrustRequester } from './trust.js';
|
|
32
|
+
import { runLogin, LoginError } from './login.js';
|
|
33
|
+
import { detectPackageManager } from './workspace.js';
|
|
34
|
+
import { findWorkspaceRoot } from '../utils/workspace-root.js';
|
|
35
|
+
import { classifyTrustList, githubTrustBody, normalizeWorkflowFile, parseRepoFromGitRemote, trustUrl, validateRepository, } from '../utils/trust-registry.js';
|
|
36
|
+
export const onboardCommand = {
|
|
37
|
+
command: 'onboard',
|
|
38
|
+
description: 'Ensure every publishable @gjsify/* workspace is both published on npm and has a Trusted Publisher configured — publishing/trusting only what is missing, one shared OTP across the whole sweep. Idempotent.',
|
|
39
|
+
builder: (yargs) => yargs
|
|
40
|
+
.option('repository', {
|
|
41
|
+
description: 'GitHub repo as `owner/repo`. Default: inferred from the `origin` git remote.',
|
|
42
|
+
type: 'string',
|
|
43
|
+
})
|
|
44
|
+
.option('workflow', {
|
|
45
|
+
description: 'Workflow filename allowed to publish via OIDC (basename only).',
|
|
46
|
+
type: 'string',
|
|
47
|
+
default: 'release.yml',
|
|
48
|
+
})
|
|
49
|
+
.option('environment', {
|
|
50
|
+
description: 'Optional GitHub Actions environment the workflow must run in.',
|
|
51
|
+
type: 'string',
|
|
52
|
+
})
|
|
53
|
+
.option('registry', {
|
|
54
|
+
description: `Registry override. Default: scope-aware lookup from .npmrc (falls back to ${DEFAULT_REGISTRY}).`,
|
|
55
|
+
type: 'string',
|
|
56
|
+
})
|
|
57
|
+
.option('otp', {
|
|
58
|
+
description: 'npm 2FA one-time code, used as the initial shared code. Prompted on demand (once) if omitted and a challenge occurs.',
|
|
59
|
+
type: 'string',
|
|
60
|
+
})
|
|
61
|
+
.option('concurrency', {
|
|
62
|
+
description: 'How many packages to probe (read state) in parallel.',
|
|
63
|
+
type: 'number',
|
|
64
|
+
default: 8,
|
|
65
|
+
})
|
|
66
|
+
.option('dry-run', {
|
|
67
|
+
description: 'Report the plan (what would be published / trusted) without changing anything.',
|
|
68
|
+
type: 'boolean',
|
|
69
|
+
default: false,
|
|
70
|
+
})
|
|
71
|
+
.option('json', {
|
|
72
|
+
description: 'Emit a machine-readable summary object as the final line of stdout.',
|
|
73
|
+
type: 'boolean',
|
|
74
|
+
default: false,
|
|
75
|
+
})
|
|
76
|
+
.option('yes', {
|
|
77
|
+
description: 'Non-interactive: never prompt. Fail clearly if a login or an OTP is required and not supplied via flags.',
|
|
78
|
+
type: 'boolean',
|
|
79
|
+
default: false,
|
|
80
|
+
}),
|
|
81
|
+
handler: async (args) => {
|
|
82
|
+
const cwd = process.cwd();
|
|
83
|
+
const dryRun = args['dry-run'] === true;
|
|
84
|
+
const asJson = args.json === true;
|
|
85
|
+
const nonInteractive = args.yes === true;
|
|
86
|
+
const log = (msg = '') => {
|
|
87
|
+
// In --json mode keep stdout clean for the final summary object;
|
|
88
|
+
// route progress to stderr.
|
|
89
|
+
if (asJson)
|
|
90
|
+
process.stderr.write(`${msg}\n`);
|
|
91
|
+
else
|
|
92
|
+
process.stdout.write(`${msg}\n`);
|
|
93
|
+
};
|
|
94
|
+
let npmrc = await loadNpmrc(cwd);
|
|
95
|
+
const defaultRegistry = args.registry ?? process.env.npm_config_registry ?? npmrc.registry ?? DEFAULT_REGISTRY;
|
|
96
|
+
// 1. Auth gate — whoami first; only login when the token is dead/missing.
|
|
97
|
+
let username;
|
|
98
|
+
try {
|
|
99
|
+
username = await ensureAuthenticated({
|
|
100
|
+
npmrc,
|
|
101
|
+
registry: defaultRegistry,
|
|
102
|
+
cwd,
|
|
103
|
+
nonInteractive,
|
|
104
|
+
dryRun,
|
|
105
|
+
otp: args.otp,
|
|
106
|
+
log,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
111
|
+
if (asJson)
|
|
112
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: 'auth', message: msg })}\n`);
|
|
113
|
+
else
|
|
114
|
+
console.error(msg);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// Login may have written a fresh token — reload so the probes/ops use it.
|
|
119
|
+
npmrc = await loadNpmrc(cwd);
|
|
120
|
+
if (username)
|
|
121
|
+
log(`gjsify onboard: authenticated as ${username}`);
|
|
122
|
+
// Repository: flag wins, else infer from the origin remote.
|
|
123
|
+
let repository = args.repository;
|
|
124
|
+
if (!repository) {
|
|
125
|
+
const r = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf8' });
|
|
126
|
+
if (r.status === 0)
|
|
127
|
+
repository = parseRepoFromGitRemote(r.stdout) ?? undefined;
|
|
128
|
+
}
|
|
129
|
+
if (!repository || !validateRepository(repository)) {
|
|
130
|
+
const msg = 'gjsify onboard: could not determine the GitHub repository — pass --repository owner/repo.';
|
|
131
|
+
if (asJson)
|
|
132
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: 'repository', message: msg })}\n`);
|
|
133
|
+
else
|
|
134
|
+
console.error(msg);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const workflow = normalizeWorkflowFile(args.workflow);
|
|
139
|
+
const environment = args.environment;
|
|
140
|
+
// 2. Enumerate publishable workspaces (non-private, exclude @girs/*).
|
|
141
|
+
const root = findWorkspaceRoot(cwd) ?? cwd;
|
|
142
|
+
const all = discoverWorkspaces(root, { includeRoot: true });
|
|
143
|
+
const selected = filterWorkspaces(all, { noPrivate: true, exclude: ['@girs/*'] }).filter((ws) => ws.name);
|
|
144
|
+
if (selected.length === 0) {
|
|
145
|
+
const msg = 'gjsify onboard: no publishable workspace packages found.';
|
|
146
|
+
if (asJson)
|
|
147
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: 'no-packages', message: msg })}\n`);
|
|
148
|
+
else
|
|
149
|
+
console.error(msg);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
log(`gjsify onboard: ${selected.length} package(s) | repo=${repository} workflow=${workflow}`);
|
|
154
|
+
if (dryRun)
|
|
155
|
+
log('(dry-run — nothing will be published or configured)');
|
|
156
|
+
log();
|
|
157
|
+
// 3. Determine per-package state CONCURRENTLY. Reads use a plain
|
|
158
|
+
// authenticated GET (no OTP prompting — a 401 becomes `auth-required`).
|
|
159
|
+
const plans = await mapWithConcurrency(selected, Math.max(1, args.concurrency), (ws) => probeTrustState(ws, {
|
|
160
|
+
registryOverride: args.registry,
|
|
161
|
+
npmrc,
|
|
162
|
+
repository,
|
|
163
|
+
workflow,
|
|
164
|
+
}));
|
|
165
|
+
// Summary table (published✓ / trusted✓ vs work to do).
|
|
166
|
+
const toPublish = plans.filter((p) => p.action === 'publish-and-trust');
|
|
167
|
+
const toTrust = plans.filter((p) => p.action === 'trust');
|
|
168
|
+
const done = plans.filter((p) => p.action === 'skip');
|
|
169
|
+
const blocked = plans.filter((p) => p.action === 'blocked');
|
|
170
|
+
for (const p of plans)
|
|
171
|
+
log(` ${statusCell(p)} ${p.ws.name}`);
|
|
172
|
+
log();
|
|
173
|
+
log(`Plan: ${done.length} already done, ${toPublish.length} to publish+trust, ${toTrust.length} to trust, ${blocked.length} unreadable.`);
|
|
174
|
+
const results = [];
|
|
175
|
+
if (dryRun) {
|
|
176
|
+
for (const p of plans) {
|
|
177
|
+
results.push({
|
|
178
|
+
name: p.ws.name,
|
|
179
|
+
result: p.action === 'skip' ? 'already-done' : p.action === 'blocked' ? 'failed' : 'skipped-dry-run',
|
|
180
|
+
detail: p.action === 'publish-and-trust'
|
|
181
|
+
? 'would publish + trust'
|
|
182
|
+
: p.action === 'trust'
|
|
183
|
+
? 'would trust'
|
|
184
|
+
: undefined,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
finish(results, plans, { asJson, dryRun });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
// 4. Act on the gaps, minimally. Publishes + trust POSTs run SERIALLY so
|
|
191
|
+
// the shared OTP is prompted at most once (and reused everywhere).
|
|
192
|
+
const otpProvider = new OtpProvider(args.otp, nonInteractive ? async () => '' : undefined);
|
|
193
|
+
const trustRequest = createTrustRequester({ npmrc, otpProvider });
|
|
194
|
+
const trustBody = githubTrustBody({ repository, workflow, environment });
|
|
195
|
+
let failed = 0;
|
|
196
|
+
for (const p of plans) {
|
|
197
|
+
if (p.action === 'skip') {
|
|
198
|
+
results.push({ name: p.ws.name, result: 'already-done' });
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (p.action === 'blocked') {
|
|
202
|
+
log(`→ ${p.ws.name}: cannot read trust state (HTTP ${p.httpStatus}) — skipped`);
|
|
203
|
+
results.push({ name: p.ws.name, result: 'failed', detail: `state HTTP ${p.httpStatus}` });
|
|
204
|
+
failed++;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
let publishedNow = false;
|
|
208
|
+
if (p.action === 'publish-and-trust') {
|
|
209
|
+
log(`→ ${p.ws.name}: building + publishing…`);
|
|
210
|
+
try {
|
|
211
|
+
await buildIfPresent(p.ws, log);
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
215
|
+
log(` build failed: ${msg}`);
|
|
216
|
+
results.push({ name: p.ws.name, result: 'failed', detail: `build: ${msg}` });
|
|
217
|
+
failed++;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const pub = await publishWorkspace({
|
|
221
|
+
wsDir: p.ws.location,
|
|
222
|
+
tag: 'latest',
|
|
223
|
+
access: 'public',
|
|
224
|
+
provenance: false,
|
|
225
|
+
tolerate: true, // a concurrent/racey publish is harmless
|
|
226
|
+
tolerateUntrustedNew: false,
|
|
227
|
+
trustedFlag: undefined,
|
|
228
|
+
registry: p.registry,
|
|
229
|
+
verbose: false,
|
|
230
|
+
json: false,
|
|
231
|
+
otpProvider,
|
|
232
|
+
seedOtpFirst: false,
|
|
233
|
+
});
|
|
234
|
+
if (!pub.ok) {
|
|
235
|
+
const detail = describePublishFailure(pub);
|
|
236
|
+
log(` publish failed: ${detail}`);
|
|
237
|
+
results.push({ name: p.ws.name, result: 'failed', detail: `publish: ${detail}` });
|
|
238
|
+
failed++;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
publishedNow = pub.action !== 'skipped-untrusted-new';
|
|
242
|
+
log(` published ${pub.name}@${'version' in pub ? pub.version : ''}`);
|
|
243
|
+
}
|
|
244
|
+
// Configure the Trusted Publisher.
|
|
245
|
+
const created = await trustRequest('POST', p.url, trustBody);
|
|
246
|
+
if (created.status >= 200 && created.status < 300) {
|
|
247
|
+
log(` trusted ${p.ws.name}`);
|
|
248
|
+
results.push({
|
|
249
|
+
name: p.ws.name,
|
|
250
|
+
result: publishedNow ? 'published+trusted' : 'trusted',
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
else if (created.status === 409) {
|
|
254
|
+
results.push({ name: p.ws.name, result: publishedNow ? 'published+trusted' : 'trusted' });
|
|
255
|
+
}
|
|
256
|
+
else if (created.status === 404) {
|
|
257
|
+
// Freshly-published package not yet provisioned for trust config.
|
|
258
|
+
log(` ${p.ws.name}: published but not yet trust-configurable (re-run onboard)`);
|
|
259
|
+
results.push({
|
|
260
|
+
name: p.ws.name,
|
|
261
|
+
result: publishedNow ? 'published' : 'failed',
|
|
262
|
+
detail: 'trust config deferred (404)',
|
|
263
|
+
});
|
|
264
|
+
if (!publishedNow)
|
|
265
|
+
failed++;
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
log(` trust failed (HTTP ${created.status})`);
|
|
269
|
+
results.push({
|
|
270
|
+
name: p.ws.name,
|
|
271
|
+
result: 'failed',
|
|
272
|
+
detail: `trust HTTP ${created.status}`,
|
|
273
|
+
});
|
|
274
|
+
failed++;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
finish(results, plans, { asJson, dryRun, failed });
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
/** Print the summary + exit with the right code. */
|
|
281
|
+
function finish(results, plans, opts) {
|
|
282
|
+
const failed = opts.failed ?? 0;
|
|
283
|
+
if (opts.asJson) {
|
|
284
|
+
process.stdout.write(`${JSON.stringify({
|
|
285
|
+
ok: failed === 0,
|
|
286
|
+
dryRun: opts.dryRun,
|
|
287
|
+
total: plans.length,
|
|
288
|
+
published: results.filter((r) => r.result === 'published' || r.result === 'published+trusted')
|
|
289
|
+
.length,
|
|
290
|
+
trusted: results.filter((r) => r.result === 'trusted' || r.result === 'published+trusted').length,
|
|
291
|
+
alreadyDone: results.filter((r) => r.result === 'already-done').length,
|
|
292
|
+
failed,
|
|
293
|
+
results,
|
|
294
|
+
}, null, 2)}\n`);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
const line = opts.dryRun
|
|
298
|
+
? 'Dry-run complete — nothing changed.'
|
|
299
|
+
: `Done: ${results.filter((r) => r.result.includes('published')).length} published, ` +
|
|
300
|
+
`${results.filter((r) => r.result === 'trusted' || r.result === 'published+trusted').length} trusted, ` +
|
|
301
|
+
`${results.filter((r) => r.result === 'already-done').length} already done, ${failed} failed.`;
|
|
302
|
+
process.stdout.write(`\n${line}\n`);
|
|
303
|
+
}
|
|
304
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
305
|
+
}
|
|
306
|
+
/** A compact per-package status cell for the summary table. */
|
|
307
|
+
function statusCell(p) {
|
|
308
|
+
switch (p.action) {
|
|
309
|
+
case 'skip':
|
|
310
|
+
return 'published✓ trusted✓';
|
|
311
|
+
case 'trust':
|
|
312
|
+
return 'published✓ trust… ';
|
|
313
|
+
case 'publish-and-trust':
|
|
314
|
+
return 'publish… trust… ';
|
|
315
|
+
case 'blocked':
|
|
316
|
+
return `unreadable (${p.httpStatus})`;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Run the whoami liveness check, and only run the login flow when the token is
|
|
321
|
+
* dead/missing. Returns the authenticated username. Throws on a non-interactive
|
|
322
|
+
* gate that would need input.
|
|
323
|
+
*/
|
|
324
|
+
async function ensureAuthenticated(ctx) {
|
|
325
|
+
const { npmrc, registry, cwd, nonInteractive, dryRun, otp, log } = ctx;
|
|
326
|
+
// A live token short-circuits — no credential prompt.
|
|
327
|
+
if (hasAnyCredential(npmrc)) {
|
|
328
|
+
try {
|
|
329
|
+
const who = await whoami(registry, npmrc);
|
|
330
|
+
if (who.username)
|
|
331
|
+
return who.username;
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
/* fall through to the login path */
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// Token dead or missing.
|
|
338
|
+
if (dryRun) {
|
|
339
|
+
log('gjsify onboard: no live npm token — dry-run continues (state reads may be limited).');
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
if (nonInteractive) {
|
|
343
|
+
throw new Error('gjsify onboard: not authenticated and --yes was passed — run `gjsify login` first (or omit --yes to log in interactively).');
|
|
344
|
+
}
|
|
345
|
+
log('gjsify onboard: no live npm token — logging in…');
|
|
346
|
+
try {
|
|
347
|
+
const res = await runLogin({ registry, otp, cwd });
|
|
348
|
+
return res.username;
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
if (err instanceof LoginError)
|
|
352
|
+
throw new Error(err.message);
|
|
353
|
+
throw err;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Read a package's current published + trust state via a plain authenticated
|
|
358
|
+
* GET on its trust config (no OTP prompting). 404 → unpublished; a matching
|
|
359
|
+
* github entry → trusted; otherwise → published-but-untrusted.
|
|
360
|
+
*/
|
|
361
|
+
async function probeTrustState(ws, ctx) {
|
|
362
|
+
const registry = ctx.registryOverride ?? registryFor(ws.name, ctx.npmrc) ?? DEFAULT_REGISTRY;
|
|
363
|
+
const url = trustUrl(registry, ws.name);
|
|
364
|
+
let state = 'error';
|
|
365
|
+
let httpStatus = 0;
|
|
366
|
+
try {
|
|
367
|
+
const headers = buildHeaders(url, { npmrc: ctx.npmrc });
|
|
368
|
+
headers['accept'] = 'application/json';
|
|
369
|
+
const res = await fetch(url, { method: 'GET', headers });
|
|
370
|
+
httpStatus = res.status;
|
|
371
|
+
const text = await res.text().catch(() => '');
|
|
372
|
+
let json = undefined;
|
|
373
|
+
try {
|
|
374
|
+
json = text ? JSON.parse(text) : undefined;
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
/* non-JSON body */
|
|
378
|
+
}
|
|
379
|
+
state = classifyTrustList(res.status, json, { repository: ctx.repository, workflow: ctx.workflow });
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
state = 'error';
|
|
383
|
+
}
|
|
384
|
+
let action;
|
|
385
|
+
switch (state) {
|
|
386
|
+
case 'trusted':
|
|
387
|
+
action = 'skip';
|
|
388
|
+
break;
|
|
389
|
+
case 'untrusted':
|
|
390
|
+
action = 'trust';
|
|
391
|
+
break;
|
|
392
|
+
case 'unpublished':
|
|
393
|
+
action = 'publish-and-trust';
|
|
394
|
+
break;
|
|
395
|
+
default:
|
|
396
|
+
action = 'blocked'; // auth-required / error — can't determine
|
|
397
|
+
}
|
|
398
|
+
return { ws, registry, url, state, httpStatus, action };
|
|
399
|
+
}
|
|
400
|
+
/** Run the package's `build` script (if it declares one) before publishing. */
|
|
401
|
+
async function buildIfPresent(ws, log) {
|
|
402
|
+
const scripts = ws.manifest.scripts ?? {};
|
|
403
|
+
if (typeof scripts.build !== 'string')
|
|
404
|
+
return; // nothing to build
|
|
405
|
+
const pm = detectPackageManager();
|
|
406
|
+
const argv = ['run', 'build'];
|
|
407
|
+
log(` $ ${pm} ${argv.join(' ')}`);
|
|
408
|
+
await new Promise((resolve, reject) => {
|
|
409
|
+
const child = spawn(pm, argv, {
|
|
410
|
+
cwd: ws.location,
|
|
411
|
+
stdio: 'inherit',
|
|
412
|
+
env: { ...process.env },
|
|
413
|
+
});
|
|
414
|
+
child.on('close', (code) => {
|
|
415
|
+
if (code === 0)
|
|
416
|
+
resolve();
|
|
417
|
+
else
|
|
418
|
+
reject(new Error(`build exited with code ${code}`));
|
|
419
|
+
});
|
|
420
|
+
child.on('error', reject);
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
/** Human-readable reason for a failed publish outcome. */
|
|
424
|
+
function describePublishFailure(pub) {
|
|
425
|
+
switch (pub.action) {
|
|
426
|
+
case 'otp-required':
|
|
427
|
+
return 'npm required a 2FA code (re-run with --otp, or on a TTY)';
|
|
428
|
+
case 'oidc-failed':
|
|
429
|
+
case 'oidc-no-token':
|
|
430
|
+
return 'OIDC/token auth failed';
|
|
431
|
+
case 'diagnostic':
|
|
432
|
+
return `HTTP 404 (${pub.diag.reason})`;
|
|
433
|
+
case 'error':
|
|
434
|
+
return `HTTP ${pub.status} ${pub.statusText}`;
|
|
435
|
+
default:
|
|
436
|
+
return 'unknown';
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/** Map with bounded concurrency, preserving input order in the result. */
|
|
440
|
+
async function mapWithConcurrency(items, limit, fn) {
|
|
441
|
+
const results = Array.from({ length: items.length });
|
|
442
|
+
let next = 0;
|
|
443
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
444
|
+
while (true) {
|
|
445
|
+
const i = next++;
|
|
446
|
+
if (i >= items.length)
|
|
447
|
+
return;
|
|
448
|
+
results[i] = await fn(items[i]);
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
await Promise.all(workers);
|
|
452
|
+
return results;
|
|
453
|
+
}
|
package/lib/commands/pack.js
CHANGED
|
@@ -172,8 +172,18 @@ export async function packWorkspace(wsDir, opts = {}) {
|
|
|
172
172
|
* Walk the workspace and return the list of files to include in the tarball,
|
|
173
173
|
* relative to `wsDir`. Mirrors npm pack's selection rules:
|
|
174
174
|
*
|
|
175
|
-
* - If pkg.files exists:
|
|
176
|
-
*
|
|
175
|
+
* - If pkg.files exists: the array is an ALLOWLIST that overrides
|
|
176
|
+
* .gitignore / .npmignore entirely. Every file under an allowlisted
|
|
177
|
+
* path/dir ships from disk; only the hardcoded implicit-exclusion set
|
|
178
|
+
* (node_modules, .git, lockfiles, … — applied by `walkAll` /
|
|
179
|
+
* `expandFilesPatterns`) and npm's always-ignore names are dropped.
|
|
180
|
+
* This matches `npm pack`: a `files` entry re-includes a path even when
|
|
181
|
+
* .gitignore/.npmignore would drop it (verified against `npm pack
|
|
182
|
+
* --dry-run`). Trimming `files`-allowlisted content by .gitignore
|
|
183
|
+
* silently dropped build-generated-but-allowlisted files like
|
|
184
|
+
* adwaita-web's `src/styles.generated.ts` (gitignored, allowlisted via
|
|
185
|
+
* `files: ["src"]`) — shipping a broken tarball whose `src/index.ts`
|
|
186
|
+
* imported a file the tarball didn't contain.
|
|
177
187
|
* - Otherwise: walk everything, apply .npmignore + .gitignore, drop the
|
|
178
188
|
* implicit-exclusion set (node_modules, .git, …)
|
|
179
189
|
*
|
|
@@ -185,18 +195,22 @@ function collectFiles(wsDir, pkg) {
|
|
|
185
195
|
const filesField = Array.isArray(pkg.files)
|
|
186
196
|
? pkg.files.filter((f) => typeof f === 'string')
|
|
187
197
|
: null;
|
|
188
|
-
|
|
198
|
+
const out = new Set();
|
|
189
199
|
if (filesField) {
|
|
190
|
-
|
|
200
|
+
// A `files` allowlist overrides .gitignore/.npmignore — ship every
|
|
201
|
+
// allowlisted path from disk. `expandFilesPatterns` already excludes
|
|
202
|
+
// the NEVER_INCLUDED_BASENAMES set (node_modules, .git, lockfiles, …)
|
|
203
|
+
// via `walkAll`, so junk is still kept out.
|
|
204
|
+
for (const f of expandFilesPatterns(wsDir, filesField))
|
|
205
|
+
out.add(f);
|
|
191
206
|
}
|
|
192
207
|
else {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
out.add(f);
|
|
208
|
+
// No `files` field — walk everything and apply .npmignore/.gitignore.
|
|
209
|
+
const ignore = loadIgnore(wsDir);
|
|
210
|
+
for (const f of walkAll(wsDir)) {
|
|
211
|
+
if (!ignore(f))
|
|
212
|
+
out.add(f);
|
|
213
|
+
}
|
|
200
214
|
}
|
|
201
215
|
for (const f of always) {
|
|
202
216
|
if (existsSync(join(wsDir, f)))
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Command } from '../types/index.js';
|
|
2
|
+
import { type Diagnose404Result } from '../utils/publish-diagnose.js';
|
|
3
|
+
import { OtpProvider } from '../utils/npm-otp.js';
|
|
2
4
|
interface PublishOptions {
|
|
3
5
|
path?: string;
|
|
4
6
|
tag?: string;
|
|
@@ -13,4 +15,94 @@ interface PublishOptions {
|
|
|
13
15
|
'check-trusted'?: boolean;
|
|
14
16
|
}
|
|
15
17
|
export declare const publishCommand: Command<unknown, PublishOptions>;
|
|
18
|
+
/** Input to {@link publishWorkspace} — the token-auth-aware, OTP-shareable publish flow. */
|
|
19
|
+
export interface PublishWorkspaceInput {
|
|
20
|
+
/** Workspace directory to pack + publish. */
|
|
21
|
+
wsDir: string;
|
|
22
|
+
tag: string;
|
|
23
|
+
access?: string;
|
|
24
|
+
provenance: boolean;
|
|
25
|
+
/** Treat "already published" as success. */
|
|
26
|
+
tolerate: boolean;
|
|
27
|
+
/** Skip an un-bootstrapped new package on the OIDC path. */
|
|
28
|
+
tolerateUntrustedNew: boolean;
|
|
29
|
+
/** `--trusted` (force OIDC) / `undefined` | `'auto'` (auto-detect). */
|
|
30
|
+
trustedFlag: boolean | 'auto' | undefined;
|
|
31
|
+
/** Registry override. Default: `$npm_config_registry` → scope-aware npmrc → npmjs. */
|
|
32
|
+
registry?: string;
|
|
33
|
+
verbose: boolean;
|
|
34
|
+
/** Redirect lifecycle-script stdout → stderr so `--json` stdout stays clean. */
|
|
35
|
+
json: boolean;
|
|
36
|
+
/** Shared OTP provider — inject ONE across many packages to reuse a single code. */
|
|
37
|
+
otpProvider: OtpProvider;
|
|
38
|
+
/** Send the cached code on the first PUT (publish `--otp`). */
|
|
39
|
+
seedOtpFirst: boolean;
|
|
40
|
+
}
|
|
41
|
+
/** Discriminated result of {@link publishWorkspace}; the caller owns presentation. */
|
|
42
|
+
export type PublishOutcome = {
|
|
43
|
+
ok: true;
|
|
44
|
+
action: 'published';
|
|
45
|
+
name: string;
|
|
46
|
+
version: string;
|
|
47
|
+
filename: string;
|
|
48
|
+
size: number;
|
|
49
|
+
integrity: string;
|
|
50
|
+
tag: string;
|
|
51
|
+
registry: string;
|
|
52
|
+
} | {
|
|
53
|
+
ok: true;
|
|
54
|
+
action: 'republish-tolerated';
|
|
55
|
+
name: string;
|
|
56
|
+
version: string;
|
|
57
|
+
status: number;
|
|
58
|
+
} | {
|
|
59
|
+
ok: true;
|
|
60
|
+
action: 'skipped-untrusted-new';
|
|
61
|
+
name: string;
|
|
62
|
+
version: string;
|
|
63
|
+
} | {
|
|
64
|
+
ok: false;
|
|
65
|
+
action: 'otp-required';
|
|
66
|
+
name: string;
|
|
67
|
+
version: string;
|
|
68
|
+
status: number;
|
|
69
|
+
} | {
|
|
70
|
+
ok: false;
|
|
71
|
+
action: 'oidc-failed';
|
|
72
|
+
name: string;
|
|
73
|
+
version: string;
|
|
74
|
+
error: unknown;
|
|
75
|
+
} | {
|
|
76
|
+
ok: false;
|
|
77
|
+
action: 'oidc-no-token';
|
|
78
|
+
name: string;
|
|
79
|
+
version: string;
|
|
80
|
+
error: unknown;
|
|
81
|
+
} | {
|
|
82
|
+
ok: false;
|
|
83
|
+
action: 'diagnostic';
|
|
84
|
+
name: string;
|
|
85
|
+
version: string;
|
|
86
|
+
status: number;
|
|
87
|
+
diag: Diagnose404Result;
|
|
88
|
+
} | {
|
|
89
|
+
ok: false;
|
|
90
|
+
action: 'error';
|
|
91
|
+
name: string;
|
|
92
|
+
version: string;
|
|
93
|
+
status: number;
|
|
94
|
+
statusText: string;
|
|
95
|
+
text: string;
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Pack + PUT a single workspace to its npm registry, returning a structured
|
|
99
|
+
* outcome instead of writing/exiting. Extracted from the `publish` command so
|
|
100
|
+
* `gjsify onboard` can drive the SAME publish flow while sharing one
|
|
101
|
+
* {@link OtpProvider} across every package in a sweep.
|
|
102
|
+
*
|
|
103
|
+
* The OTP dance runs through `withOtpRetry(provider)`: the cached code is tried
|
|
104
|
+
* before prompting, so a shared provider needs only one interactive entry for N
|
|
105
|
+
* packages.
|
|
106
|
+
*/
|
|
107
|
+
export declare function publishWorkspace(input: PublishWorkspaceInput): Promise<PublishOutcome>;
|
|
16
108
|
export {};
|