@gjsify/cli 0.4.35 → 0.4.36

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.
@@ -20,13 +20,21 @@
20
20
  // wins today).
21
21
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
22
22
  import { dirname, join, relative } from 'node:path';
23
- import { spawn } from 'node:child_process';
23
+ import { spawn, spawnSync } from 'node:child_process';
24
24
  import { discoverWorkspaces } from '@gjsify/workspace';
25
25
  import { buildInstallCommand, detectPackageManager, runMinimalChecks } from '../utils/check-system-deps.js';
26
26
  import { detectNativePackages } from '../utils/detect-native-packages.js';
27
- import { installPackages } from '../utils/install-backend.js';
27
+ import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
28
28
  import { binDirOnPath, defaultGlobalLayout, linkGlobalBins, specToPackageName } from '../utils/install-global.js';
29
29
  import { addDependencyEntry, defaultRangeFromVersion, parseSpec, projectSpecsFromPackageJson, readPackageJson, writePackageJson, } from '../utils/pkg-json-edit.js';
30
+ // Default 30min wall-clock budget for the full install. Big workspaces
31
+ // (212+ sub-packages × 600+ external deps in the gjsify monorepo itself)
32
+ // can legitimately take 10-20 min on a fresh CI install when the npm CDN
33
+ // is slow — a 5-min default would false-positive on those legitimate
34
+ // flows. Per-fetch timeout (30s, retried) catches the truly stuck case
35
+ // inside this budget. Set --timeout 0 to disable the wall-clock guard
36
+ // entirely.
37
+ const DEFAULT_INSTALL_TIMEOUT_MS = 1_800_000;
30
38
  export const installCommand = {
31
39
  command: 'install [packages..]',
32
40
  description: 'Install npm dependencies in the current project (or globally with -g), then run gjsify-aware post-checks.',
@@ -54,11 +62,26 @@ export const installCommand = {
54
62
  description: 'Verbose install logging.',
55
63
  type: 'boolean',
56
64
  default: false,
65
+ })
66
+ .option('quiet', {
67
+ description: 'Silence the progress bar.',
68
+ type: 'boolean',
69
+ default: false,
70
+ })
71
+ .option('progress', {
72
+ description: 'Show a TTY-aware progress bar for resolve / download / extract phases. Auto-enabled when stderr is a TTY (override with --no-progress). Implicitly off under --verbose (per-package log lines replace the bar) or --quiet.',
73
+ type: 'boolean',
74
+ default: true,
57
75
  })
58
76
  .option('backend', {
59
77
  description: 'Install backend. `native` (default) routes through `@gjsify/{semver,npm-registry,tar}` — no Node/npm at runtime. `npm` shells out to `npm install` as an escape hatch for cases the native backend does not yet model (Yarn PnP repos, lifecycle scripts). Overrides `GJSIFY_INSTALL_BACKEND` if both are set.',
60
78
  type: 'string',
61
79
  choices: ['native', 'npm'],
80
+ })
81
+ .option('timeout', {
82
+ description: 'Overall install wall-clock timeout in ms (default 1800000 = 30 min). On timeout, all in-flight registry fetches are aborted and the install exits non-zero with a clear "install timed out — likely a registry slowdown" message. Per-request timeouts in @gjsify/npm-registry (default 30s) still apply within this budget. Set to 0 to disable the overall budget.',
83
+ type: 'number',
84
+ default: DEFAULT_INSTALL_TIMEOUT_MS,
62
85
  }),
63
86
  handler: async (args) => {
64
87
  // --immutable is incompatible with explicit `<pkg>` adds and with
@@ -98,10 +121,58 @@ export const installCommand = {
98
121
  await runPostInstallChecks();
99
122
  return;
100
123
  }
101
- await projectInstallNative(args);
102
- await runPostInstallChecks();
124
+ // Overall wall-clock budget for the install (default 5 min). On
125
+ // timeout we abort every in-flight registry fetch via this controller
126
+ // so the process exits cleanly with an actionable message instead of
127
+ // a silent hang. Per-request timeouts inside @gjsify/npm-registry
128
+ // (default 30s, retried) still apply within this budget.
129
+ const overallTimeoutMs = args.timeout > 0 ? args.timeout : 0;
130
+ const overallController = overallTimeoutMs > 0 ? new AbortController() : null;
131
+ const overallTimerId = overallController !== null
132
+ ? setTimeout(() => overallController.abort(new Error('install-overall-timeout')), overallTimeoutMs)
133
+ : null;
134
+ try {
135
+ await projectInstallNative(args, overallController?.signal);
136
+ await runPostInstallChecks();
137
+ }
138
+ catch (err) {
139
+ if (overallController !== null && overallController.signal.aborted && isAbortedFromOverallTimeout(err)) {
140
+ const secs = Math.round(overallTimeoutMs / 100) / 10;
141
+ console.error(`gjsify install: timed out after ${secs}s — likely a registry slowdown.\n` +
142
+ `Re-run, or override with --timeout <ms> (set --timeout 0 to disable the overall budget).`);
143
+ process.exit(1);
144
+ }
145
+ throw err;
146
+ }
147
+ finally {
148
+ if (overallTimerId !== null)
149
+ clearTimeout(overallTimerId);
150
+ }
103
151
  },
104
152
  };
153
+ /**
154
+ * Heuristic: was this error raised because the overall-install AbortSignal
155
+ * fired? The signal's `reason` is the sentinel `Error('install-overall-timeout')`
156
+ * we installed above; the abort surfaces either as that exact reason or as
157
+ * any AbortError thrown by a downstream fetch / setTimeout-on-abort path.
158
+ * We match permissively because intermediate layers (fetch, GJS Soup, our
159
+ * own delay()) re-wrap the reason in their own AbortError instances.
160
+ */
161
+ function isAbortedFromOverallTimeout(err) {
162
+ if (!err || typeof err !== 'object')
163
+ return false;
164
+ const name = err.name;
165
+ if (name === 'AbortError')
166
+ return true;
167
+ const message = err.message;
168
+ if (typeof message === 'string' && message.includes('install-overall-timeout'))
169
+ return true;
170
+ // RegistryTimeoutError surfaces the per-request budget — distinct from
171
+ // the overall budget but typically the symptom the overall timer reports.
172
+ if (name === 'RegistryTimeoutError')
173
+ return true;
174
+ return false;
175
+ }
105
176
  function isWorkspaceRoot(cwd) {
106
177
  const pkgPath = join(cwd, 'package.json');
107
178
  const pkg = readPackageJson(pkgPath);
@@ -118,7 +189,7 @@ function depKindFromArgs(args) {
118
189
  return 'optionalDependencies';
119
190
  return 'dependencies';
120
191
  }
121
- async function projectInstallNative(args) {
192
+ async function projectInstallNative(args, signal) {
122
193
  const cwd = process.cwd();
123
194
  const pkgPath = join(cwd, 'package.json');
124
195
  // gjsify install is a node_modules-linker installer (like `npm install`
@@ -155,7 +226,7 @@ async function projectInstallNative(args) {
155
226
  // fires for the root no-args case, which is the `yarn install`
156
227
  // equivalent).
157
228
  if ((!args.packages || args.packages.length === 0) && isWorkspaceRoot(cwd)) {
158
- await workspaceInstall(cwd, args);
229
+ await workspaceInstall(cwd, args, signal);
159
230
  return;
160
231
  }
161
232
  let specs;
@@ -181,6 +252,12 @@ async function projectInstallNative(args) {
181
252
  }
182
253
  }
183
254
  mkdirSync(cwd, { recursive: true });
255
+ // Progress bar is auto-enabled when stderr is a TTY (and `--verbose` /
256
+ // `--quiet` / `--no-progress` aren't set). When piped to a log file the
257
+ // reporter falls back to one line per phase begin/end.
258
+ const progress = makeProgressReporter({
259
+ enabled: !args.verbose && !args.quiet && args.progress !== false,
260
+ });
184
261
  const result = await installPackages({
185
262
  prefix: cwd,
186
263
  specs,
@@ -189,6 +266,8 @@ async function projectInstallNative(args) {
189
266
  // it (the whole point is byte-stability under CI).
190
267
  lockfile: !args.immutable,
191
268
  frozen: args.immutable,
269
+ signal,
270
+ progress,
192
271
  });
193
272
  // Update package.json only when the user passed explicit packages
194
273
  // (the `gjsify install <pkg>...` add-a-dep flow). The no-args refresh
@@ -248,7 +327,7 @@ function syncLockfileRequested(cwd, specs) {
248
327
  * nested `node_modules/` for version conflicts are tracked as a follow-up
249
328
  * in STATUS.md "Open TODOs".
250
329
  */
251
- async function workspaceInstall(cwd, args) {
330
+ async function workspaceInstall(cwd, args, signal) {
252
331
  const workspaces = discoverWorkspaces(cwd, { includeRoot: true });
253
332
  if (workspaces.length === 0) {
254
333
  throw new Error(`gjsify install: ${cwd} has a "workspaces" field but no workspaces were discovered`);
@@ -308,12 +387,78 @@ async function workspaceInstall(cwd, args) {
308
387
  console.log(`gjsify install: ${workspaces.length} workspace(s), ${externalSpecs.size} external dep spec(s), ${symlinks.length} workspace symlink(s)`);
309
388
  // Read top-level package.json's `overrides` (npm-native) or `resolutions`
310
389
  // (yarn-native, kept as the existing field name in pre-Phase-D.8 repos).
311
- // Both are flattened to a name version map and passed to the install
312
- // backend. Pattern keys like `typescript@*` are normalised to bare names —
313
- // we don't yet support per-parent scoping (npm's nested overrides shape).
390
+ // Flat `name range` entries become global overrides applied to every
391
+ // workspace; nested `<workspace> {dep range}` entries become
392
+ // workspace-local installs that place the overridden dep inside that
393
+ // workspace's own `node_modules/`. Lets a monorepo pin one workspace to
394
+ // an older `typescript` (e.g. a downstream integration test) without
395
+ // forcing the rest of the tree to the same version.
314
396
  const rootManifest = workspaces.find((w) => w.location === cwd)?.manifest;
315
- const overrides = extractOverrides(rootManifest);
397
+ const extracted = extractOverrides(rootManifest);
398
+ const overrides = extracted?.global;
399
+ // Second pass: pluck specs that have a workspace-scoped override out of
400
+ // `externalSpecs` and re-collect them into a per-workspace map. Those
401
+ // specs will be installed into the workspace's own `node_modules/` after
402
+ // the root install completes, so the resolver in the root pass does NOT
403
+ // see the conflicting versions.
404
+ const wsLocalSpecs = new Map(); // wsLocation → name@range set
405
+ const droppedFromExternal = new Set();
406
+ if (extracted && extracted.scoped.size > 0) {
407
+ for (const ws of workspaces) {
408
+ const wsManifest = ws.manifest;
409
+ for (const kind of ['dependencies', 'devDependencies', 'optionalDependencies']) {
410
+ const deps = wsManifest[kind];
411
+ if (!deps)
412
+ continue;
413
+ for (const [depName, spec] of Object.entries(deps)) {
414
+ const override = scopedOverrideFor(ws, depName, extracted);
415
+ if (!override)
416
+ continue;
417
+ // Re-route this dep to the workspace's own install
418
+ const targetRange = override;
419
+ const wsKey = ws.location;
420
+ let bucket = wsLocalSpecs.get(wsKey);
421
+ if (!bucket) {
422
+ bucket = new Set();
423
+ wsLocalSpecs.set(wsKey, bucket);
424
+ }
425
+ bucket.add(`${depName}@${targetRange}`);
426
+ // Drop the un-overridden version from the root spec set:
427
+ // the workspace will see its scoped version via parent-walk
428
+ // resolution. Note we only drop the EXACT `name@spec` the
429
+ // workspace declared; other workspaces' instances of the
430
+ // same name+spec stay in the root set.
431
+ droppedFromExternal.add(`${depName}@${spec}`);
432
+ }
433
+ }
434
+ }
435
+ // Apply the drops only when no OTHER workspace declared the same spec
436
+ // — otherwise it has legitimate root requesters and must stay.
437
+ const stillNeeded = new Set();
438
+ for (const ws of workspaces) {
439
+ for (const kind of ['dependencies', 'devDependencies', 'optionalDependencies']) {
440
+ const deps = ws.manifest[kind];
441
+ if (!deps)
442
+ continue;
443
+ for (const [depName, spec] of Object.entries(deps)) {
444
+ if (scopedOverrideFor(ws, depName, extracted))
445
+ continue;
446
+ stillNeeded.add(`${depName}@${spec}`);
447
+ }
448
+ }
449
+ }
450
+ for (const dropped of droppedFromExternal) {
451
+ if (!stillNeeded.has(dropped))
452
+ externalSpecs.delete(dropped);
453
+ }
454
+ if (wsLocalSpecs.size > 0) {
455
+ console.log(`gjsify install: ${wsLocalSpecs.size} workspace(s) have scoped overrides — they will install their overridden deps locally after the root install`);
456
+ }
457
+ }
316
458
  if (externalSpecs.size > 0) {
459
+ const progress = makeProgressReporter({
460
+ enabled: !args.verbose && !args.quiet && args.progress !== false,
461
+ });
317
462
  await installPackages({
318
463
  prefix: cwd,
319
464
  specs: [...externalSpecs],
@@ -321,40 +466,90 @@ async function workspaceInstall(cwd, args) {
321
466
  lockfile: !args.immutable,
322
467
  frozen: args.immutable,
323
468
  overrides,
469
+ signal,
470
+ progress,
324
471
  });
325
472
  }
326
473
  else if (args.verbose) {
327
474
  console.log('gjsify install: no external deps to fetch');
328
475
  }
329
- for (const link of symlinks) {
330
- const target = byName.get(link.fromWorkspaceName);
331
- if (!target)
476
+ // Workspace-local installs for scoped overrides. Each runs as its own
477
+ // `installPackages` call inside the workspace location — the resulting
478
+ // `node_modules/<dep>` shadows the root-hoisted version via standard
479
+ // Node parent-walk resolution.
480
+ for (const [wsLocation, specSet] of wsLocalSpecs) {
481
+ if (specSet.size === 0)
332
482
  continue;
333
- const linkPath = join(target.location, 'node_modules', link.depName);
334
- mkdirSync(dirname(linkPath), { recursive: true });
335
- // Remove any prior entry regular dir, broken symlink, file, or
336
- // a normal symlink left over from a previous install. Using
337
- // `{ recursive: true, force: true }` handles every shape in one
338
- // call: `rmSync` no-ops on missing paths under `force: true`, and
339
- // `recursive: true` covers the directory case. Avoids the EEXIST
340
- // race a previous lstat-then-branch version hit when the stat's
341
- // type-discrimination missed an edge case (e.g. broken symlink
342
- // whose `isSymbolicLink()` returned a non-truthy value through
343
- // Gio's NOFOLLOW path, leaving a leftover entry that
344
- // `symlinkSync` would then refuse to overwrite).
345
- try {
346
- rmSync(linkPath, { recursive: true, force: true });
483
+ const wsName = workspaces.find((w) => w.location === wsLocation)?.name ?? wsLocation;
484
+ if (args.verbose) {
485
+ console.log(`gjsify install: ${wsName}installing ${specSet.size} scoped-override spec(s) into ${wsLocation}/node_modules/`);
347
486
  }
348
- catch {
349
- /* unexpected — Gio failure on a path we just lstat'd to
350
- decide we wanted to remove. The subsequent symlinkSync
351
- will surface the real reason if there is one. */
352
- }
353
- // Relative symlink so the repo is portable across checkout paths.
354
- const relTarget = relative(dirname(linkPath), link.targetLocation);
355
- symlinkSync(relTarget, linkPath);
487
+ await installPackages({
488
+ prefix: wsLocation,
489
+ specs: [...specSet],
490
+ verbose: args.verbose,
491
+ // Per-workspace installs get a thin lockfile next to the workspace
492
+ // package.json. Same `--immutable` semantics as the root install.
493
+ lockfile: !args.immutable,
494
+ frozen: args.immutable,
495
+ signal,
496
+ });
356
497
  }
498
+ // Workspace symlink wiring — pre-dedup the parent-dir mkdirs (every
499
+ // symlink for the same workspace shares a single `node_modules` parent),
500
+ // then run the per-link rm + symlink steps with bounded concurrency.
501
+ // Pure sync loops here used to dominate the tail of large installs
502
+ // (~793 symlinks × ~10ms each for mkdir+rm+symlink = ~24s of serial
503
+ // syscalls). With async + a 32-wide pool the same set lands in 1-2s.
357
504
  if (symlinks.length > 0) {
505
+ const fsp = await import('node:fs/promises');
506
+ const parentDirs = new Set();
507
+ const plans = [];
508
+ for (const link of symlinks) {
509
+ const target = byName.get(link.fromWorkspaceName);
510
+ if (!target)
511
+ continue;
512
+ const linkPath = join(target.location, 'node_modules', link.depName);
513
+ parentDirs.add(dirname(linkPath));
514
+ const relTarget = relative(dirname(linkPath), link.targetLocation);
515
+ plans.push({ linkPath, relTarget });
516
+ }
517
+ // Phase 1: one mkdir per unique parent (max ~213 instead of ~793).
518
+ await Promise.all([...parentDirs].map((dir) => fsp.mkdir(dir, { recursive: true })));
519
+ // Phase 2: per-link rm + symlink, pooled. A semaphore-style cursor
520
+ // keeps the concurrent in-flight count bounded so we don't blow up
521
+ // the file-descriptor table on huge monorepos.
522
+ const SYMLINK_CONCURRENCY = 32;
523
+ let cursor = 0;
524
+ const workers = [];
525
+ const wireOne = async (linkPath, relTarget) => {
526
+ // Remove any prior entry — regular dir, broken symlink, file, or
527
+ // a normal symlink left over from a previous install.
528
+ // `{ recursive: true, force: true }` handles every shape (rm
529
+ // no-ops on missing paths under force; recursive covers dirs).
530
+ try {
531
+ await fsp.rm(linkPath, { recursive: true, force: true });
532
+ }
533
+ catch {
534
+ /* unexpected; symlink call below will surface the real
535
+ cause if it persists. */
536
+ }
537
+ await fsp.symlink(relTarget, linkPath);
538
+ };
539
+ for (let i = 0; i < Math.min(SYMLINK_CONCURRENCY, plans.length); i++) {
540
+ workers.push((async () => {
541
+ while (true) {
542
+ const idx = cursor++;
543
+ if (idx >= plans.length)
544
+ return;
545
+ const p = plans[idx];
546
+ if (!p)
547
+ return;
548
+ await wireOne(p.linkPath, p.relTarget);
549
+ }
550
+ })());
551
+ }
552
+ await Promise.all(workers);
358
553
  console.log(`gjsify install: wired ${symlinks.length} workspace symlink(s)`);
359
554
  }
360
555
  // Hoist EVERY workspace package to the repo root's `node_modules/` so
@@ -461,62 +656,75 @@ async function workspaceInstall(cwd, args) {
461
656
  console.log(`gjsify install: linked ${wsBinsCreated} workspace bin(s) into node_modules/.bin/`);
462
657
  }
463
658
  }
464
- /**
465
- * Build a shell shim that prefers Node when its target file exists at
466
- * invocation time, falling back to GJS otherwise. The runtime check is
467
- * per-invocation (not at install time) so the same shim works both
468
- * before and after the workspace's `lib/` has been built — a fresh
469
- * checkout only has the committed `dist/cli.gjs.mjs`, while every
470
- * subsequent `npm run build` produces `lib/index.js`.
471
- *
472
- * Both targets are absolute paths so the shim is portable across the
473
- * different cwds that consumers (`yarn run`, `npm run`, direct PATH
474
- * invocation) call us from.
475
- */
476
- /**
477
- * Flatten npm `overrides` or yarn `resolutions` into a bare name → range map.
478
- *
479
- * Supports two input shapes:
480
- *
481
- * "overrides": { "typescript": "~5.9.2" } (npm)
482
- * "resolutions": { "typescript@*": "~5.9.2" } (yarn pattern)
483
- *
484
- * Pattern keys with a version glob (`name@*`, `name@^x`) are normalised to the
485
- * bare name — gjsify's resolver doesn't yet support per-incoming-range
486
- * scoping. Object-valued nested overrides (npm's per-parent shape, e.g.
487
- * `"foo": { ".": "1.0", "bar": "2.0" }`) are intentionally ignored; they would
488
- * silently misbehave without per-parent support, so we surface a warning
489
- * instead of half-applying them.
490
- *
491
- * Keys beginning with `_` are skipped (convention for documentation entries
492
- * like `"_comment_typescript"` used in the wild).
493
- */
494
659
  function extractOverrides(rootManifest) {
495
660
  if (!rootManifest)
496
661
  return undefined;
497
- const out = {};
662
+ const global = {};
663
+ const scoped = new Map();
498
664
  const merge = (source, fieldName) => {
499
665
  if (!source)
500
666
  return;
501
667
  for (const [key, value] of Object.entries(source)) {
502
668
  if (key.startsWith('_'))
503
669
  continue;
504
- if (typeof value !== 'string') {
505
- console.warn(`gjsify install: ${fieldName}["${key}"] is not a string nested override shape isn't supported yet, skipping`);
670
+ if (typeof value === 'string') {
671
+ // Flat `name range` entry. Normalise pattern keys (`name@*`,
672
+ // `name@^range`) → bare name. For scoped packages preserve the
673
+ // leading `@`.
674
+ let name = key;
675
+ const atIdx = key.startsWith('@') ? key.indexOf('@', 1) : key.indexOf('@');
676
+ if (atIdx > 0)
677
+ name = key.slice(0, atIdx);
678
+ global[name] = value;
679
+ continue;
680
+ }
681
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
682
+ // Scoped entry — `<workspace> → {dep → range}`. This is the
683
+ // npm-overrides nested shape and yarn's resolutions
684
+ // selectors collapsed to per-workspace level.
685
+ const sub = {};
686
+ for (const [depKey, depValue] of Object.entries(value)) {
687
+ if (depKey.startsWith('_'))
688
+ continue;
689
+ if (typeof depValue !== 'string') {
690
+ console.warn(`gjsify install: ${fieldName}["${key}"]["${depKey}"] is not a string — only one level of nesting is supported, skipping`);
691
+ continue;
692
+ }
693
+ let depName = depKey;
694
+ const atIdx = depKey.startsWith('@') ? depKey.indexOf('@', 1) : depKey.indexOf('@');
695
+ if (atIdx > 0)
696
+ depName = depKey.slice(0, atIdx);
697
+ sub[depName] = depValue;
698
+ }
699
+ if (Object.keys(sub).length > 0) {
700
+ const existing = scoped.get(key) ?? {};
701
+ scoped.set(key, { ...existing, ...sub });
702
+ }
506
703
  continue;
507
704
  }
508
- // Normalise pattern keys (`name@*`, `name@^range`) bare name.
509
- // For scoped packages preserve the leading `@`.
510
- let name = key;
511
- const atIdx = key.startsWith('@') ? key.indexOf('@', 1) : key.indexOf('@');
512
- if (atIdx > 0)
513
- name = key.slice(0, atIdx);
514
- out[name] = value;
705
+ console.warn(`gjsify install: ${fieldName}["${key}"] is not a string or object — skipping`);
515
706
  }
516
707
  };
517
708
  merge(rootManifest.overrides, 'overrides');
518
709
  merge(rootManifest.resolutions, 'resolutions');
519
- return Object.keys(out).length > 0 ? out : undefined;
710
+ if (Object.keys(global).length === 0 && scoped.size === 0)
711
+ return undefined;
712
+ return { global, scoped };
713
+ }
714
+ /**
715
+ * Look up the scoped override for a given workspace + dep. Matches by
716
+ * workspace name OR relative location (both accepted for readability).
717
+ */
718
+ function scopedOverrideFor(ws, depName, extracted) {
719
+ if (!extracted || extracted.scoped.size === 0)
720
+ return undefined;
721
+ const candidates = [ws.name, ws.relativeLocation];
722
+ for (const key of candidates) {
723
+ const entry = extracted.scoped.get(key);
724
+ if (entry?.[depName])
725
+ return entry[depName];
726
+ }
727
+ return undefined;
520
728
  }
521
729
  function buildBinShim(wsLocation, nodeTarget, gjsTarget, nativePrebuildDirs = []) {
522
730
  const nodeAbs = nodeTarget ? join(wsLocation, nodeTarget) : null;
@@ -668,4 +876,43 @@ async function runPostInstallChecks() {
668
876
  }
669
877
  console.log('\nUse `gjsify run <bundle>` to launch with LD_LIBRARY_PATH/GI_TYPELIB_PATH set.');
670
878
  }
879
+ // 3. Install workspace git hooks (only fires inside the gjsify monorepo
880
+ // itself, NOT in consumer projects that depend on @gjsify/cli — gated
881
+ // by the presence of `scripts/install-git-hooks.mjs` + a `.git`
882
+ // checkout). Idempotent; safe to re-run on every install.
883
+ maybeInstallGitHooks();
884
+ }
885
+ /**
886
+ * Wire `core.hooksPath = .githooks` when running `gjsify install` inside a
887
+ * git checkout that ships `scripts/install-git-hooks.mjs` (i.e. the gjsify
888
+ * monorepo). Consumer projects that don't ship the script are skipped
889
+ * silently — they wouldn't have hooks to install.
890
+ *
891
+ * The script itself handles its own no-op cases (extracted tarball, already
892
+ * configured, SKIP_GJSIFY_HOOKS=1).
893
+ */
894
+ function maybeInstallGitHooks() {
895
+ const cwd = process.cwd();
896
+ const scriptPath = join(cwd, 'scripts', 'install-git-hooks.mjs');
897
+ if (!existsSync(scriptPath))
898
+ return;
899
+ // Need a git checkout — the script also checks, but skipping here
900
+ // avoids spawning a process when we know the answer.
901
+ if (!existsSync(join(cwd, '.git')))
902
+ return;
903
+ try {
904
+ const result = spawnSync(process.execPath, [scriptPath, '--quiet'], {
905
+ cwd,
906
+ stdio: 'inherit',
907
+ env: process.env,
908
+ });
909
+ if (result.status !== 0) {
910
+ console.warn(`[gjsify install] scripts/install-git-hooks.mjs exited ${result.status} — git hooks may not be active.`);
911
+ }
912
+ }
913
+ catch (err) {
914
+ // Hook installation is a quality-of-life touchup, not a hard install
915
+ // requirement. Never let it abort the surrounding install.
916
+ console.warn(`[gjsify install] git hook installation skipped: ${err instanceof Error ? err.message : String(err)}`);
917
+ }
671
918
  }
@@ -33,12 +33,13 @@
33
33
  // Source: documented in https://docs.npmjs.com/cli/v10/commands/npm-publish
34
34
  // and npm's @npmcli/registry-fetch internals — verified against npm's
35
35
  // in-the-wild publish payloads.
36
- import { existsSync, readFileSync } from 'node:fs';
37
- import { homedir } from 'node:os';
36
+ import { readFileSync } from 'node:fs';
38
37
  import { join, resolve } from 'node:path';
39
- import { DEFAULT_REGISTRY, parseNpmrc, registryFor, buildHeaders } from '@gjsify/npm-registry';
38
+ import { DEFAULT_REGISTRY, registryFor, buildHeaders } from '@gjsify/npm-registry';
40
39
  import { packWorkspace } from './pack.js';
41
40
  import { getNpmTrustedToken, hasGithubOidcEnv, OidcExchangeError, OidcUnavailableError } from '../utils/npm-oidc.js';
41
+ import { diagnose404, is404DiagnosticCandidate } from '../utils/publish-diagnose.js';
42
+ import { loadNpmrc } from '../utils/load-npmrc.js';
42
43
  export const publishCommand = {
43
44
  command: 'publish [path]',
44
45
  description: 'Pack + upload the workspace at <path> (default: cwd) to its npm registry. Drop-in for `npm publish` with workspace:^ rewrite handled automatically.',
@@ -403,6 +404,38 @@ export const publishCommand = {
403
404
  process.stdout.write(`= ${packed.name}@${packed.version} (already published, tolerated)\n`);
404
405
  return;
405
406
  }
407
+ // 404 diagnostic — token-auth only. npm returns 404 for both a
408
+ // dead `_authToken` and a genuinely-missing package; `/-/whoami`
409
+ // disambiguates. OIDC has its own clear error surfaces (handled in
410
+ // the OIDC catch block above) and `--otp` flows take a different
411
+ // 401 path, so the diagnostic only kicks in for the plain
412
+ // token-auth PUT signature.
413
+ if (res.status === 404 && authMode === 'token' && !otp) {
414
+ if (is404DiagnosticCandidate(text)) {
415
+ const diag = await diagnose404({
416
+ packageName: packed.name,
417
+ version: packed.version,
418
+ registry: registryClean,
419
+ npmrc,
420
+ });
421
+ if (diag.reason !== 'unknown') {
422
+ if (args.json) {
423
+ process.stdout.write(`${JSON.stringify({
424
+ ok: false,
425
+ name: packed.name,
426
+ version: packed.version,
427
+ status: 404,
428
+ diagnostic: diag.reason,
429
+ username: diag.username,
430
+ }, null, 2)}\n`);
431
+ }
432
+ else {
433
+ process.stderr.write(`${diag.message}\n`);
434
+ }
435
+ process.exit(1);
436
+ }
437
+ }
438
+ }
406
439
  console.error(`gjsify publish: ${packed.name}@${packed.version} — ${res.status} ${res.statusText}`);
407
440
  console.error(text);
408
441
  process.exit(1);
@@ -460,38 +493,6 @@ async function loadRewrittenManifest(wsDir, pkg) {
460
493
  }
461
494
  return pkg;
462
495
  }
463
- async function loadNpmrc(cwd) {
464
- // npm CLI's npmrc resolution order (lowest → highest precedence):
465
- // 1. globalconfig: /etc/npmrc (system)
466
- // 2. userconfig: $NPM_CONFIG_USERCONFIG (overrides ~/.npmrc)
467
- // or ~/.npmrc (default)
468
- // 3. projectconfig: ./.npmrc (closest)
469
- //
470
- // actions/setup-node writes the auth-token npmrc to $RUNNER_TEMP/.npmrc
471
- // and exports NPM_CONFIG_USERCONFIG pointing at it — it does NOT touch
472
- // ~/.npmrc. Honor the env var so CI authentication works end-to-end.
473
- const sources = [];
474
- const projectNpmrc = join(cwd, '.npmrc');
475
- if (existsSync(projectNpmrc))
476
- sources.push(readFileSync(projectNpmrc, 'utf-8'));
477
- const userConfig = process.env.NPM_CONFIG_USERCONFIG;
478
- if (userConfig && existsSync(userConfig)) {
479
- sources.push(readFileSync(userConfig, 'utf-8'));
480
- }
481
- else {
482
- const homeNpmrc = join(homedir(), '.npmrc');
483
- if (existsSync(homeNpmrc))
484
- sources.push(readFileSync(homeNpmrc, 'utf-8'));
485
- }
486
- // Inline `${VAR}` placeholders (npm CLI's expand-on-read behavior).
487
- // The auth-token npmrc from actions/setup-node ships
488
- // `_authToken=${NODE_AUTH_TOKEN}` as a literal placeholder; the env var
489
- // is set on the publish step.
490
- const merged = sources
491
- .join('\n')
492
- .replace(/\$\{([A-Z_][A-Z0-9_]*)\}/gi, (_, name) => process.env[name] ?? '');
493
- return parseNpmrc(merged);
494
- }
495
496
  function buildPublishPayload(opts) {
496
497
  const { pkg, tag, access, tarballBytes, tarballUrl, packed, provenance } = opts;
497
498
  const versionEntry = {
@@ -0,0 +1,6 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface TscOptions {
3
+ tscArgs: string[];
4
+ }
5
+ export declare const tscCommand: Command<unknown, TscOptions>;
6
+ export {};