@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/dist/cli.gjs.mjs +14 -12
- 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/install.js +135 -106
- package/lib/commands/trust.d.ts +16 -0
- package/lib/commands/trust.js +306 -0
- package/lib/utils/prompt.js +6 -3
- package/lib/utils/trust-registry.d.ts +71 -0
- package/lib/utils/trust-registry.js +110 -0
- package/package.json +17 -17
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)
|
package/lib/commands/index.d.ts
CHANGED
|
@@ -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';
|
package/lib/commands/index.js
CHANGED
|
@@ -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';
|
package/lib/commands/install.js
CHANGED
|
@@ -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
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
|
|
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
|
+
}
|
package/lib/utils/prompt.js
CHANGED
|
@@ -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
|
-
|
|
73
|
-
|
|
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,
|
|
78
|
+
resolve(buf.slice(0, m));
|
|
76
79
|
}
|
|
77
80
|
};
|
|
78
81
|
const onEnd = () => {
|