@gjsify/cli 0.6.1 → 0.7.2

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/lib/cli-app.js CHANGED
@@ -19,7 +19,7 @@ import { readFileSync } from 'node:fs';
19
19
  import { dirname, join } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import yargs from 'yargs';
22
- import { buildCommand as build, testCommand as test, runCommand as run, infoCommand as info, systemCheckCommand as systemCheck, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, dlxCommand as dlx, installCommand as install, foreachCommand as foreach, workspaceCommand as workspace, packCommand as pack, publishCommand as publish, whoamiCommand as whoami, loginCommand as login, logoutCommand as logout, selfUpdateCommand as selfUpdate, generateInstallerCommand as generateInstaller, uninstallCommand as uninstall, formatCommand as format, lintCommand as lint, fixCommand as fix, upgradeCommand as upgrade, barrelsCommand as barrels, tscCommand as tsc, affectedCommand as affected, } from './commands/index.js';
22
+ import { buildCommand as build, testCommand as test, runCommand as run, infoCommand as info, systemCheckCommand as systemCheck, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, dlxCommand as dlx, installCommand as install, foreachCommand as foreach, workspaceCommand as workspace, packCommand as pack, publishCommand as publish, whoamiCommand as whoami, loginCommand as login, logoutCommand as logout, trustCommand as trust, selfUpdateCommand as selfUpdate, generateInstallerCommand as generateInstaller, uninstallCommand as uninstall, formatCommand as format, lintCommand as lint, fixCommand as fix, upgradeCommand as upgrade, barrelsCommand as barrels, tscCommand as tsc, affectedCommand as affected, } from './commands/index.js';
23
23
  import { APP_NAME } from './constants.js';
24
24
  // Detect which runtime is executing the CLI (GJS or Node.js).
25
25
  // GJS MUST be checked first because @gjsify/process sets
@@ -95,6 +95,7 @@ export async function runCli(argv) {
95
95
  .command(whoami.command, whoami.description, whoami.builder, whoami.handler)
96
96
  .command(login.command, login.description, login.builder, login.handler)
97
97
  .command(logout.command, logout.description, logout.builder, logout.handler)
98
+ .command(trust.command, trust.description, trust.builder, trust.handler)
98
99
  .command(selfUpdate.command, selfUpdate.description, selfUpdate.builder, selfUpdate.handler)
99
100
  .command(generateInstaller.command, generateInstaller.description, generateInstaller.builder, generateInstaller.handler)
100
101
  .command(uninstall.command, uninstall.description, uninstall.builder, uninstall.handler)
@@ -19,6 +19,7 @@ export * from './publish.js';
19
19
  export * from './whoami.js';
20
20
  export * from './login.js';
21
21
  export * from './logout.js';
22
+ export * from './trust.js';
22
23
  export * from './self-update.js';
23
24
  export * from './generate-installer.js';
24
25
  export * from './uninstall.js';
@@ -19,6 +19,7 @@ export * from './publish.js';
19
19
  export * from './whoami.js';
20
20
  export * from './login.js';
21
21
  export * from './logout.js';
22
+ export * from './trust.js';
22
23
  export * from './self-update.js';
23
24
  export * from './generate-installer.js';
24
25
  export * from './uninstall.js';
@@ -396,6 +396,134 @@ async function workspaceInstall(cwd, args, signal) {
396
396
  // (GI_TYPELIB_PATH for native prebuilds) reflects the freshly installed
397
397
  // tree — see the final `writeWorkspaceBinShims` call below.
398
398
  writeWorkspaceBinShims(cwd, workspaces);
399
+ // Materialise the workspace↔workspace symlinks: per-requester links
400
+ // (`<requester>/node_modules/<dep>`) plus a root hoist of every workspace
401
+ // into `node_modules/<name>` so transitive workspace deps resolve via
402
+ // Node's parent-walk from any descendant. Idempotent (rm + symlink, and
403
+ // the hoist skips entries that already exist), so it's safe to run more
404
+ // than once.
405
+ const wireWorkspaceSymlinks = async () => {
406
+ // Per-requester symlinks — pre-dedup the parent-dir mkdirs (every
407
+ // symlink for the same workspace shares a single `node_modules`
408
+ // parent), then run the per-link rm + symlink steps with bounded
409
+ // concurrency. Pure sync loops here used to dominate the tail of large
410
+ // installs (~793 symlinks × ~10ms each for mkdir+rm+symlink = ~24s of
411
+ // serial syscalls). With async + a 32-wide pool the same set lands in
412
+ // 1-2s.
413
+ if (symlinks.length > 0) {
414
+ const fsp = await import('node:fs/promises');
415
+ const parentDirs = new Set();
416
+ const plans = [];
417
+ for (const link of symlinks) {
418
+ const target = byName.get(link.fromWorkspaceName);
419
+ if (!target)
420
+ continue;
421
+ const linkPath = join(target.location, 'node_modules', link.depName);
422
+ parentDirs.add(dirname(linkPath));
423
+ const relTarget = relative(dirname(linkPath), link.targetLocation);
424
+ plans.push({ linkPath, relTarget });
425
+ }
426
+ // Phase 1: one mkdir per unique parent (max ~213 instead of ~793).
427
+ await Promise.all([...parentDirs].map((dir) => fsp.mkdir(dir, { recursive: true })));
428
+ // Phase 2: per-link rm + symlink, pooled. A semaphore-style cursor
429
+ // keeps the concurrent in-flight count bounded so we don't blow up
430
+ // the file-descriptor table on huge monorepos.
431
+ const SYMLINK_CONCURRENCY = 32;
432
+ let cursor = 0;
433
+ const workers = [];
434
+ const wireOne = async (linkPath, relTarget) => {
435
+ // Remove any prior entry — regular dir, broken symlink, file,
436
+ // or a normal symlink left over from a previous install.
437
+ // `{ recursive: true, force: true }` handles every shape (rm
438
+ // no-ops on missing paths under force; recursive covers dirs).
439
+ try {
440
+ await fsp.rm(linkPath, { recursive: true, force: true });
441
+ }
442
+ catch {
443
+ /* unexpected; symlink call below will surface the real
444
+ cause if it persists. */
445
+ }
446
+ await fsp.symlink(relTarget, linkPath);
447
+ };
448
+ for (let i = 0; i < Math.min(SYMLINK_CONCURRENCY, plans.length); i++) {
449
+ workers.push((async () => {
450
+ while (true) {
451
+ const idx = cursor++;
452
+ if (idx >= plans.length)
453
+ return;
454
+ const p = plans[idx];
455
+ if (!p)
456
+ return;
457
+ await wireOne(p.linkPath, p.relTarget);
458
+ }
459
+ })());
460
+ }
461
+ await Promise.all(workers);
462
+ console.log(`gjsify install: wired ${symlinks.length} workspace symlink(s)`);
463
+ }
464
+ // Hoist EVERY workspace package to the repo root's `node_modules/` so
465
+ // transitive workspace deps are reachable from any descendant via
466
+ // standard Node parent-walk resolution. yarn's `nodeLinker:
467
+ // node-modules` does the same thing — the entire workspace graph is
468
+ // materialised at the root, which is how rolldown's resolver finds
469
+ // e.g. `@gjsify/abort-controller/register` injected from a
470
+ // deeply-nested package's `node_modules/.cache/gjsify/` cache file
471
+ // when the consumer didn't declare a direct dep on it (auto-globals
472
+ // injection at build time).
473
+ //
474
+ // Without this hoist, each workspace's `node_modules/` only contains
475
+ // its direct declared deps, and any auto-injected register import for
476
+ // a workspace package the consumer didn't list as a dep externalises
477
+ // and the bundle fails at runtime with `Module not found`.
478
+ const rootBinDir = join(cwd, 'node_modules');
479
+ let rootHoisted = 0;
480
+ for (const ws of workspaces) {
481
+ // Skip the root workspace itself (its location IS cwd; it can't
482
+ // symlink itself into its own node_modules).
483
+ if (ws.location === cwd)
484
+ continue;
485
+ if (!ws.name)
486
+ continue;
487
+ const linkPath = join(rootBinDir, ws.name);
488
+ // If a symlink already exists here (from the per-requester loop
489
+ // above when the root workspace declared this dep directly), it
490
+ // already points at the right place — skip. We don't try to
491
+ // remove + recreate because under GJS's Gio-backed fs polyfill,
492
+ // `rmSync` on a symlink can race with `symlinkSync` and surface
493
+ // EEXIST. A real directory at this path is also left alone —
494
+ // someone else (npm, yarn) seeded it and we shouldn't clobber.
495
+ let existsHere = false;
496
+ try {
497
+ lstatSync(linkPath);
498
+ existsHere = true;
499
+ }
500
+ catch {
501
+ /* ENOENT */
502
+ }
503
+ if (existsHere)
504
+ continue;
505
+ mkdirSync(dirname(linkPath), { recursive: true });
506
+ const relTarget = relative(dirname(linkPath), ws.location);
507
+ symlinkSync(relTarget, linkPath);
508
+ rootHoisted++;
509
+ }
510
+ if (rootHoisted > 0) {
511
+ console.log(`gjsify install: hoisted ${rootHoisted} workspace(s) to root node_modules/`);
512
+ }
513
+ };
514
+ // Wire workspace↔workspace symlinks EARLY too — same rationale as the bin
515
+ // shims. These symlinks point at local workspace source trees and never
516
+ // depend on the external download, yet they USED to run only at the tail
517
+ // of the install. An interrupted/timed-out external fetch then left the
518
+ // tree with NO workspace symlinks at all, so cross-package imports
519
+ // (`@gjsify/native-fs-bridge` → `@gjsify/native-platform`) failed to
520
+ // resolve even though every package was on disk. Materialising them here
521
+ // makes workspace resolution survive a failed external phase. Safe to run
522
+ // before `installPackages`: that phase only fetches NON-workspace names
523
+ // (workspace-named deps are routed to symlinks and excluded from
524
+ // `externalSpecs`), and `extractOne` only `rm`s the individual external
525
+ // package dest it is about to write — never a workspace symlink.
526
+ await wireWorkspaceSymlinks();
399
527
  // Read top-level package.json's `overrides` (npm-native) or `resolutions`
400
528
  // (yarn-native, kept as the existing field name in pre-Phase-D.8 repos).
401
529
  // Flat `name → range` entries become global overrides applied to every
@@ -506,112 +634,13 @@ async function workspaceInstall(cwd, args, signal) {
506
634
  signal,
507
635
  });
508
636
  }
509
- // Workspace symlink wiring — pre-dedup the parent-dir mkdirs (every
510
- // symlink for the same workspace shares a single `node_modules` parent),
511
- // then run the per-link rm + symlink steps with bounded concurrency.
512
- // Pure sync loops here used to dominate the tail of large installs
513
- // (~793 symlinks × ~10ms each for mkdir+rm+symlink = ~24s of serial
514
- // syscalls). With async + a 32-wide pool the same set lands in 1-2s.
515
- if (symlinks.length > 0) {
516
- const fsp = await import('node:fs/promises');
517
- const parentDirs = new Set();
518
- const plans = [];
519
- for (const link of symlinks) {
520
- const target = byName.get(link.fromWorkspaceName);
521
- if (!target)
522
- continue;
523
- const linkPath = join(target.location, 'node_modules', link.depName);
524
- parentDirs.add(dirname(linkPath));
525
- const relTarget = relative(dirname(linkPath), link.targetLocation);
526
- plans.push({ linkPath, relTarget });
527
- }
528
- // Phase 1: one mkdir per unique parent (max ~213 instead of ~793).
529
- await Promise.all([...parentDirs].map((dir) => fsp.mkdir(dir, { recursive: true })));
530
- // Phase 2: per-link rm + symlink, pooled. A semaphore-style cursor
531
- // keeps the concurrent in-flight count bounded so we don't blow up
532
- // the file-descriptor table on huge monorepos.
533
- const SYMLINK_CONCURRENCY = 32;
534
- let cursor = 0;
535
- const workers = [];
536
- const wireOne = async (linkPath, relTarget) => {
537
- // Remove any prior entry — regular dir, broken symlink, file, or
538
- // a normal symlink left over from a previous install.
539
- // `{ recursive: true, force: true }` handles every shape (rm
540
- // no-ops on missing paths under force; recursive covers dirs).
541
- try {
542
- await fsp.rm(linkPath, { recursive: true, force: true });
543
- }
544
- catch {
545
- /* unexpected; symlink call below will surface the real
546
- cause if it persists. */
547
- }
548
- await fsp.symlink(relTarget, linkPath);
549
- };
550
- for (let i = 0; i < Math.min(SYMLINK_CONCURRENCY, plans.length); i++) {
551
- workers.push((async () => {
552
- while (true) {
553
- const idx = cursor++;
554
- if (idx >= plans.length)
555
- return;
556
- const p = plans[idx];
557
- if (!p)
558
- return;
559
- await wireOne(p.linkPath, p.relTarget);
560
- }
561
- })());
562
- }
563
- await Promise.all(workers);
564
- console.log(`gjsify install: wired ${symlinks.length} workspace symlink(s)`);
565
- }
566
- // Hoist EVERY workspace package to the repo root's `node_modules/` so
567
- // transitive workspace deps are reachable from any descendant via
568
- // standard Node parent-walk resolution. yarn's `nodeLinker: node-modules`
569
- // does the same thing — the entire workspace graph is materialised at
570
- // the root, which is how rolldown's resolver finds e.g.
571
- // `@gjsify/abort-controller/register` injected from a deeply-nested
572
- // package's `node_modules/.cache/gjsify/` cache file when the consumer
573
- // didn't declare a direct dep on it (auto-globals injection at build
574
- // time).
575
- //
576
- // Without this hoist, each workspace's `node_modules/` only contains
577
- // its direct declared deps, and any auto-injected register import for
578
- // a workspace package the consumer didn't list as a dep externalises
579
- // and the bundle fails at runtime with `Module not found`.
580
- const rootBinDir = join(cwd, 'node_modules');
581
- let rootHoisted = 0;
582
- for (const ws of workspaces) {
583
- // Skip the root workspace itself (its location IS cwd; it can't
584
- // symlink itself into its own node_modules).
585
- if (ws.location === cwd)
586
- continue;
587
- if (!ws.name)
588
- continue;
589
- const linkPath = join(rootBinDir, ws.name);
590
- // If a symlink already exists here (from the per-workspace loop
591
- // above when the root workspace declared this dep directly), it
592
- // already points at the right place — skip. We don't try to
593
- // remove + recreate because under GJS's Gio-backed fs polyfill,
594
- // `rmSync` on a symlink can race with `symlinkSync` and surface
595
- // EEXIST. A real directory at this path is also left alone —
596
- // someone else (npm, yarn) seeded it and we shouldn't clobber.
597
- let existsHere = false;
598
- try {
599
- lstatSync(linkPath);
600
- existsHere = true;
601
- }
602
- catch {
603
- /* ENOENT */
604
- }
605
- if (existsHere)
606
- continue;
607
- mkdirSync(dirname(linkPath), { recursive: true });
608
- const relTarget = relative(dirname(linkPath), ws.location);
609
- symlinkSync(relTarget, linkPath);
610
- rootHoisted++;
611
- }
612
- if (rootHoisted > 0) {
613
- console.log(`gjsify install: hoisted ${rootHoisted} workspace(s) to root node_modules/`);
614
- }
637
+ // Re-wire workspace symlinks once more now that the external + scoped
638
+ // installs are done. The EARLY pass above already created them so workspace
639
+ // resolution survives a failed external phase; this final idempotent pass
640
+ // is a cheap safety net in case any later step disturbed one (the per-link
641
+ // rm+symlink re-establishes them, the root hoist skips entries that already
642
+ // exist — so it's a near no-op when nothing changed).
643
+ await wireWorkspaceSymlinks();
615
644
  // Re-write workspace bins into `node_modules/.bin/` now that the tree is
616
645
  // fully materialized. The early pass at the top of this function already
617
646
  // guaranteed the shims exist; this final pass refreshes their GJS-preamble
@@ -0,0 +1,16 @@
1
+ import type { Command } from '../types/index.js';
2
+ import { type TrustEntry } from '../utils/trust-registry.js';
3
+ interface TrustOptions {
4
+ packages?: string[];
5
+ repository?: string;
6
+ workflow: string;
7
+ environment?: string;
8
+ registry?: string;
9
+ otp?: string;
10
+ 'dry-run'?: boolean;
11
+ force?: boolean;
12
+ list?: boolean;
13
+ 'no-private'?: boolean;
14
+ }
15
+ export declare const trustCommand: Command<unknown, TrustOptions>;
16
+ export type { TrustEntry };
@@ -0,0 +1,306 @@
1
+ // `gjsify trust [packages..]` — configure npm "Trusted Publishers" (OIDC /
2
+ // GitHub Actions) for publishable workspace packages, natively (no `npm`).
3
+ //
4
+ // npm Trusted Publishing is configured PER PACKAGE against the registry's
5
+ // trust API (npm >= 11.10). This command sweeps every publishable workspace
6
+ // (same discovery as `gjsify foreach` / `publish`), skips packages already
7
+ // trusted for this repo+workflow, reports unpublished ones separately, and
8
+ // POSTs the GitHub config for the rest — so `.github/workflows/release.yml`
9
+ // can publish via OIDC with no long-lived NPM_TOKEN.
10
+ //
11
+ // 2FA: the registry challenges with 401 + `www-authenticate: otp`. We send a
12
+ // TOTP via the `npm-otp` header (prompted on demand, or `--otp`), matching
13
+ // `gjsify login`'s legacy/header-OTP path — no browser web-auth flow. After
14
+ // one successful OTP the registry opens a ~5-min skip window, so subsequent
15
+ // packages go through without re-prompting; when it lapses we re-prompt.
16
+ //
17
+ // Reference: refs/npm-cli/lib/trust-cmd.js + lib/commands/trust/github.js.
18
+ import { spawnSync } from 'node:child_process';
19
+ import { existsSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { DEFAULT_REGISTRY, buildHeaders, registryFor, whoami } from '@gjsify/npm-registry';
22
+ import { discoverWorkspaces, filterWorkspaces } from '@gjsify/workspace';
23
+ import { hasAnyCredential, loadNpmrc } from '../utils/load-npmrc.js';
24
+ import { promptLine } from '../utils/prompt.js';
25
+ import { classifyTrustList, githubTrustBody, normalizeWorkflowFile, parseRepoFromGitRemote, trustUrl, validateRepository, } from '../utils/trust-registry.js';
26
+ export const trustCommand = {
27
+ command: 'trust [packages..]',
28
+ description: 'Configure npm Trusted Publishers (OIDC/GitHub Actions) for publishable workspace packages so release.yml can publish without a token. Native — no `npm` needed. Incremental: skips already-configured packages.',
29
+ builder: (yargs) => yargs
30
+ .positional('packages', {
31
+ description: 'Optional package-name globs to limit the sweep (default: all publishable workspaces).',
32
+ type: 'string',
33
+ array: true,
34
+ })
35
+ .option('repository', {
36
+ description: 'GitHub repo as `owner/repo`. Default: inferred from the `origin` git remote.',
37
+ type: 'string',
38
+ })
39
+ .option('workflow', {
40
+ description: 'Workflow filename allowed to publish (basename only).',
41
+ type: 'string',
42
+ default: 'release.yml',
43
+ })
44
+ .option('environment', {
45
+ description: 'Optional GitHub Actions environment the workflow must run in.',
46
+ type: 'string',
47
+ })
48
+ .option('registry', {
49
+ description: `Registry override. Default: scope-aware lookup from .npmrc (falls back to ${DEFAULT_REGISTRY}).`,
50
+ type: 'string',
51
+ })
52
+ .option('otp', {
53
+ description: 'npm 2FA one-time code; sent as `npm-otp`. Prompted on demand if omitted.',
54
+ type: 'string',
55
+ })
56
+ .option('dry-run', {
57
+ description: 'List what would be configured without writing anything.',
58
+ type: 'boolean',
59
+ default: false,
60
+ })
61
+ .option('force', {
62
+ description: 'Re-POST the config even for packages already trusted for this repo+workflow.',
63
+ type: 'boolean',
64
+ default: false,
65
+ })
66
+ .option('list', {
67
+ description: 'Only report each package’s current trust state; configure nothing.',
68
+ type: 'boolean',
69
+ default: false,
70
+ })
71
+ .option('private', {
72
+ description: 'Include private workspaces (default false — private packages are not publishable).',
73
+ type: 'boolean',
74
+ default: false,
75
+ }),
76
+ handler: async (args) => {
77
+ const cwd = process.cwd();
78
+ const npmrc = await loadNpmrc(cwd);
79
+ if (!hasAnyCredential(npmrc)) {
80
+ console.error('gjsify trust: no npm token configured. Run `gjsify login` first.');
81
+ process.exit(1);
82
+ }
83
+ // Pre-flight: a present-but-DEAD token would otherwise surface as a
84
+ // confusing per-package `401 "You must be logged in to publish
85
+ // packages"`. Probe `/-/whoami` once and fail early with the real fix.
86
+ // (Skipped for --dry-run, which never writes.) `whoami` throws on a
87
+ // 401/non-2xx and returns `{}` on a dead-but-200 token — both are dead.
88
+ const probeRegistry = args.registry ?? process.env.npm_config_registry ?? npmrc.registry ?? DEFAULT_REGISTRY;
89
+ if (args['dry-run'] !== true) {
90
+ let who;
91
+ try {
92
+ who = await whoami(probeRegistry, npmrc);
93
+ }
94
+ catch (e) {
95
+ const msg = e instanceof Error ? e.message : String(e);
96
+ console.error(`gjsify trust: npm auth check failed (${msg}).\n` +
97
+ 'Your ~/.npmrc token is dead or revoked — refresh it with `npm login`, then re-run.\n' +
98
+ '(`gjsify trust` uses the ~/.npmrc bearer token directly — unlike `npm trust`, which logs in via the browser.)');
99
+ process.exit(1);
100
+ return;
101
+ }
102
+ if (!who.username) {
103
+ console.error('gjsify trust: your npm token appears dead or revoked (registry returned an empty whoami).\n' +
104
+ 'Refresh it with `npm login`, then re-run.');
105
+ process.exit(1);
106
+ }
107
+ console.log(`gjsify trust: authenticated as ${who.username}`);
108
+ }
109
+ // Repository: flag wins, else infer from the origin remote.
110
+ let repository = args.repository;
111
+ if (!repository) {
112
+ const r = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf8' });
113
+ if (r.status === 0)
114
+ repository = parseRepoFromGitRemote(r.stdout) ?? undefined;
115
+ }
116
+ if (!repository) {
117
+ console.error('gjsify trust: could not determine the GitHub repository.\n' +
118
+ 'Pass --repository owner/repo (no `origin` remote to infer from).');
119
+ process.exit(1);
120
+ }
121
+ if (!validateRepository(repository)) {
122
+ console.error(`gjsify trust: --repository must be "owner/repo", got "${repository}".`);
123
+ process.exit(1);
124
+ }
125
+ const workflow = normalizeWorkflowFile(args.workflow);
126
+ const environment = args.environment;
127
+ const listOnly = args.list === true;
128
+ const dryRun = args['dry-run'] === true;
129
+ const force = args.force === true;
130
+ // Warn (don't fail) if the workflow file isn't present — the trust
131
+ // config is still valid, but a typo'd workflow name silently never
132
+ // publishes, so surface it.
133
+ const wfPath = join(cwd, '.github', 'workflows', workflow);
134
+ if (!existsSync(wfPath)) {
135
+ console.warn(`gjsify trust: note — ${wfPath} not found; configuring trust for "${workflow}" anyway.`);
136
+ }
137
+ // Publishable workspaces: non-private by default, optionally name-filtered.
138
+ const all = discoverWorkspaces(cwd, { includeRoot: true });
139
+ const selected = filterWorkspaces(all, {
140
+ noPrivate: args.private !== true,
141
+ include: args.packages && args.packages.length > 0 ? args.packages : undefined,
142
+ }).filter((ws) => ws.name);
143
+ if (selected.length === 0) {
144
+ console.error('gjsify trust: no publishable workspace packages matched.');
145
+ process.exit(1);
146
+ }
147
+ console.log(`gjsify trust: ${selected.length} package(s) | repo=${repository} workflow=${workflow}`);
148
+ if (dryRun)
149
+ console.log('(dry-run — nothing will be written)');
150
+ console.log();
151
+ // A one-shot OTP supplied via --otp, used on the FIRST challenge then
152
+ // cleared (a TOTP code is single-use within its ~30s window). After the
153
+ // first successful OTP the registry opens a ~5-min skip window, so we
154
+ // do NOT cache + re-send the code — that would re-challenge once it
155
+ // expires. Instead each request tries WITHOUT an OTP first (the window
156
+ // covers it) and only prompts when actually challenged. Mirrors npm's
157
+ // `otplease`.
158
+ let pendingOtp = args.otp;
159
+ const request = async (method, url, body) => {
160
+ const doFetch = async (code) => {
161
+ const headers = buildHeaders(url, { npmrc });
162
+ headers['accept'] = 'application/json';
163
+ if (body !== undefined)
164
+ headers['content-type'] = 'application/json';
165
+ if (code)
166
+ headers['npm-otp'] = code;
167
+ return fetch(url, {
168
+ method,
169
+ headers,
170
+ body: body === undefined ? undefined : JSON.stringify(body),
171
+ });
172
+ };
173
+ // 1) Try without an OTP — succeeds outright (read) or via an active
174
+ // 2FA-skip window.
175
+ let res = await doFetch();
176
+ // 2) On a challenge, use the --otp (once) or prompt, and retry.
177
+ if (await isOtpChallenge(res)) {
178
+ let code = pendingOtp;
179
+ pendingOtp = undefined; // one-shot — never reuse a stale code
180
+ if (!code) {
181
+ code = await promptLine('This operation requires a one-time password.\nEnter OTP: ');
182
+ }
183
+ if (!code) {
184
+ console.error('gjsify trust: no OTP entered.');
185
+ process.exit(1);
186
+ }
187
+ res = await doFetch(code);
188
+ // 3) Still challenged → the code was stale/expired. Prompt fresh once.
189
+ if (await isOtpChallenge(res)) {
190
+ const fresh = await promptLine('OTP rejected (expired?) — enter a fresh code: ');
191
+ if (!fresh) {
192
+ console.error('gjsify trust: no OTP entered.');
193
+ process.exit(1);
194
+ }
195
+ res = await doFetch(fresh);
196
+ }
197
+ }
198
+ const text = await res.text().catch(() => '');
199
+ let json = undefined;
200
+ try {
201
+ json = text ? JSON.parse(text) : undefined;
202
+ }
203
+ catch {
204
+ /* non-JSON body — leave json undefined */
205
+ }
206
+ return { status: res.status, json, text };
207
+ };
208
+ let ok = 0;
209
+ let skipped = 0;
210
+ let unpublished = 0;
211
+ let failed = 0;
212
+ const needsPublish = [];
213
+ const failures = [];
214
+ for (const ws of selected) {
215
+ const name = ws.name;
216
+ const registry = args.registry ?? registryFor(name, npmrc) ?? DEFAULT_REGISTRY;
217
+ const url = trustUrl(registry, name);
218
+ process.stdout.write(`→ ${name.padEnd(42)} `);
219
+ // Read current state.
220
+ const listed = await request('GET', url);
221
+ const state = classifyTrustList(listed.status, listed.json, { repository, workflow });
222
+ if (listOnly) {
223
+ if (state === 'trusted')
224
+ console.log('trusted');
225
+ else if (state === 'unpublished')
226
+ console.log('unpublished');
227
+ else if (state === 'auth-required')
228
+ console.log('auth-required (could not read)');
229
+ else if (state === 'untrusted')
230
+ console.log('not trusted');
231
+ else
232
+ console.log(`error (HTTP ${listed.status})`);
233
+ continue;
234
+ }
235
+ if (state === 'trusted' && !force) {
236
+ console.log('already configured — skip');
237
+ skipped++;
238
+ continue;
239
+ }
240
+ if (state === 'unpublished') {
241
+ console.log('not on npm yet — needs first-publish, skip');
242
+ unpublished++;
243
+ needsPublish.push(name);
244
+ continue;
245
+ }
246
+ if (dryRun) {
247
+ console.log('would configure');
248
+ ok++;
249
+ continue;
250
+ }
251
+ // Configure.
252
+ const body = githubTrustBody({ repository, workflow, environment });
253
+ const created = await request('POST', url, body);
254
+ if (created.status >= 200 && created.status < 300) {
255
+ console.log('configured');
256
+ ok++;
257
+ }
258
+ else if (created.status === 409) {
259
+ // Exact config already exists — idempotent, not a failure.
260
+ console.log('already configured (409) — done');
261
+ skipped++;
262
+ }
263
+ else if (created.status === 404) {
264
+ console.log('not on npm yet — needs first-publish, skip');
265
+ unpublished++;
266
+ needsPublish.push(name);
267
+ }
268
+ else {
269
+ console.log(`FAILED (HTTP ${created.status})`);
270
+ failed++;
271
+ failures.push(`${name} — HTTP ${created.status}${created.text ? `: ${firstLine(created.text)}` : ''}`);
272
+ }
273
+ }
274
+ if (listOnly)
275
+ return;
276
+ console.log();
277
+ console.log(`Done: ${ok} ${dryRun ? 'would-configure' : 'configured'}, ${skipped} already-configured, ${unpublished} unpublished, ${failed} failed.`);
278
+ if (needsPublish.length > 0) {
279
+ console.log('Need a first publish before they can be trust-configured (then re-run `gjsify trust`):');
280
+ for (const n of needsPublish)
281
+ console.log(` ${n}`);
282
+ }
283
+ if (failures.length > 0) {
284
+ console.log('Failed (re-run `gjsify trust` — it is incremental; usually a lapsed 2FA window or rate-limit):');
285
+ for (const f of failures)
286
+ console.log(` ${f}`);
287
+ process.exit(1);
288
+ }
289
+ },
290
+ };
291
+ /** True when a response is an npm OTP / 2FA challenge. */
292
+ async function isOtpChallenge(res) {
293
+ if (res.status !== 401)
294
+ return false;
295
+ const wwwAuth = (res.headers.get('www-authenticate') ?? '').toLowerCase();
296
+ if (wwwAuth.includes('otp'))
297
+ return true;
298
+ const text = await res
299
+ .clone()
300
+ .text()
301
+ .catch(() => '');
302
+ return /one-time pass/i.test(text) || /\bEOTP\b/.test(text);
303
+ }
304
+ function firstLine(s) {
305
+ return s.split('\n')[0].slice(0, 200);
306
+ }
@@ -69,10 +69,13 @@ function readLine() {
69
69
  let buf = '';
70
70
  const onData = (chunk) => {
71
71
  buf += typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
72
- const nl = buf.indexOf('\n');
73
- if (nl >= 0) {
72
+ // Accept CR, LF, or CRLF as the line terminator. A bare CR (`\r`)
73
+ // is what some terminals / the GJS terminal-native stdin deliver on
74
+ // Enter — keying only on `\n` made the prompt hang forever there.
75
+ const m = buf.search(/[\r\n]/);
76
+ if (m >= 0) {
74
77
  cleanup();
75
- resolve(buf.slice(0, nl).replace(/\r$/, ''));
78
+ resolve(buf.slice(0, m));
76
79
  }
77
80
  };
78
81
  const onEnd = () => {