@gjsify/cli 0.51.1 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/cli.gjs.mjs +539 -493
  2. package/lib/actions/build.d.ts +22 -0
  3. package/lib/actions/build.js +37 -9
  4. package/lib/bundler-pick.d.ts +37 -1
  5. package/lib/bundler-pick.js +108 -17
  6. package/lib/cli-app.js +7 -2
  7. package/lib/commands/affected-classify.d.ts +7 -1
  8. package/lib/commands/affected-classify.js +66 -9
  9. package/lib/commands/affected-script-refs.d.ts +20 -0
  10. package/lib/commands/affected-script-refs.js +218 -0
  11. package/lib/commands/affected.js +24 -2
  12. package/lib/commands/env.d.ts +31 -0
  13. package/lib/commands/env.js +114 -0
  14. package/lib/commands/exec.d.ts +10 -0
  15. package/lib/commands/exec.js +208 -0
  16. package/lib/commands/flatpak/diff.js +8 -2
  17. package/lib/commands/flatpak/init.js +4 -1
  18. package/lib/commands/flatpak/sources.js +4 -3
  19. package/lib/commands/flatpak/sync-flathub.d.ts +2 -0
  20. package/lib/commands/flatpak/sync-flathub.js +149 -7
  21. package/lib/commands/flatpak/utils.d.ts +10 -0
  22. package/lib/commands/flatpak/utils.js +10 -0
  23. package/lib/commands/foreach.js +5 -97
  24. package/lib/commands/format.js +26 -6
  25. package/lib/commands/index.d.ts +4 -0
  26. package/lib/commands/index.js +4 -0
  27. package/lib/commands/install.d.ts +2 -2
  28. package/lib/commands/install.js +81 -5
  29. package/lib/commands/link.d.ts +13 -0
  30. package/lib/commands/link.js +243 -0
  31. package/lib/commands/onboard.js +18 -2
  32. package/lib/commands/publish.d.ts +34 -0
  33. package/lib/commands/publish.js +153 -8
  34. package/lib/commands/self-update.js +2 -1
  35. package/lib/commands/ship.js +19 -1
  36. package/lib/commands/test.d.ts +18 -9
  37. package/lib/commands/test.js +56 -11
  38. package/lib/commands/tsc.js +4 -4
  39. package/lib/commands/upgrade.d.ts +15 -0
  40. package/lib/commands/upgrade.js +76 -2
  41. package/lib/commands/webext/index.d.ts +2 -0
  42. package/lib/commands/webext/index.js +15 -0
  43. package/lib/commands/webext/subcommands.d.ts +16 -0
  44. package/lib/commands/webext/subcommands.js +204 -0
  45. package/lib/types/config-data.d.ts +2 -2
  46. package/lib/utils/bin-shim.d.ts +8 -2
  47. package/lib/utils/bin-shim.js +6 -5
  48. package/lib/utils/bundle-inputs.d.ts +84 -0
  49. package/lib/utils/bundle-inputs.js +268 -0
  50. package/lib/utils/check-system-deps.d.ts +5 -19
  51. package/lib/utils/check-system-deps.js +31 -94
  52. package/lib/utils/dev-link.d.ts +225 -0
  53. package/lib/utils/dev-link.js +684 -0
  54. package/lib/utils/exec-bin.d.ts +137 -0
  55. package/lib/utils/exec-bin.js +321 -0
  56. package/lib/utils/gi-runtime-paths.js +3 -2
  57. package/lib/utils/gitignore.d.ts +15 -0
  58. package/lib/utils/gitignore.js +178 -0
  59. package/lib/utils/gjsify-shim.d.ts +47 -0
  60. package/lib/utils/gjsify-shim.js +97 -8
  61. package/lib/utils/hang-watchdog.d.ts +95 -0
  62. package/lib/utils/hang-watchdog.js +194 -0
  63. package/lib/utils/install-backend-native.d.ts +33 -1
  64. package/lib/utils/install-backend-native.js +184 -20
  65. package/lib/utils/install-extraneous.d.ts +90 -0
  66. package/lib/utils/install-extraneous.js +120 -0
  67. package/lib/utils/install-lock.js +4 -25
  68. package/lib/utils/install-tarball-cache.d.ts +37 -9
  69. package/lib/utils/install-tarball-cache.js +59 -14
  70. package/lib/utils/native-skip-scan.d.ts +20 -0
  71. package/lib/utils/native-skip-scan.js +343 -0
  72. package/lib/utils/node-script.js +1 -1
  73. package/lib/utils/normalize-bundler-options.js +11 -0
  74. package/lib/utils/oxc-resolve.js +42 -3
  75. package/lib/utils/process-table.d.ts +22 -0
  76. package/lib/utils/process-table.js +188 -0
  77. package/lib/utils/prune-prefix.js +1 -1
  78. package/lib/utils/publish-pins.d.ts +38 -0
  79. package/lib/utils/publish-pins.js +47 -0
  80. package/lib/utils/publish-readback.d.ts +103 -17
  81. package/lib/utils/publish-readback.js +326 -47
  82. package/lib/utils/run-gjs.d.ts +15 -1
  83. package/lib/utils/run-gjs.js +98 -13
  84. package/lib/utils/run-node.d.ts +2 -0
  85. package/lib/utils/run-node.js +2 -1
  86. package/lib/utils/runtimes.js +14 -13
  87. package/lib/utils/ship/gzip.js +1 -1
  88. package/lib/utils/ship/icns.d.ts +14 -0
  89. package/lib/utils/ship/icns.js +83 -0
  90. package/lib/utils/ship/ico.d.ts +25 -0
  91. package/lib/utils/ship/ico.js +120 -0
  92. package/lib/utils/ship/icons.d.ts +197 -0
  93. package/lib/utils/ship/icons.js +399 -0
  94. package/lib/utils/ship/launcher.js +16 -1
  95. package/lib/utils/ship/layout.d.ts +32 -0
  96. package/lib/utils/ship/layout.js +112 -38
  97. package/lib/utils/ship/msi.d.ts +1 -1
  98. package/lib/utils/ship/msi.js +30 -5
  99. package/lib/utils/ship/pe-launcher.d.ts +18 -0
  100. package/lib/utils/ship/pe-launcher.js +125 -4
  101. package/lib/utils/ship/plan.d.ts +1 -34
  102. package/lib/utils/ship/plan.js +7 -56
  103. package/lib/utils/ship/plist.d.ts +20 -10
  104. package/lib/utils/ship/plist.js +29 -12
  105. package/lib/utils/ship/settings.js +3 -2
  106. package/lib/utils/spawn.d.ts +13 -0
  107. package/lib/utils/spawn.js +10 -5
  108. package/lib/utils/watch-loop.d.ts +15 -1
  109. package/lib/utils/watch-loop.js +9 -5
  110. package/lib/utils/webext/build.d.ts +42 -0
  111. package/lib/utils/webext/build.js +120 -0
  112. package/lib/utils/webext/config.d.ts +72 -0
  113. package/lib/utils/webext/config.js +234 -0
  114. package/lib/utils/webext/icons.d.ts +24 -0
  115. package/lib/utils/webext/icons.js +83 -0
  116. package/lib/utils/webext/launch.d.ts +32 -0
  117. package/lib/utils/webext/launch.js +77 -0
  118. package/lib/utils/webext/manifest.d.ts +53 -0
  119. package/lib/utils/webext/manifest.js +219 -0
  120. package/lib/utils/webext/pages.d.ts +26 -0
  121. package/lib/utils/webext/pages.js +61 -0
  122. package/lib/utils/webext/targets.d.ts +13 -0
  123. package/lib/utils/webext/targets.js +36 -0
  124. package/lib/utils/win32-command.d.ts +17 -0
  125. package/lib/utils/win32-command.js +21 -6
  126. package/package.json +20 -20
@@ -10,7 +10,7 @@
10
10
  // `node_modules/` and symlinks `workspace:` refs to their source. Members with
11
11
  // conflicting ranges of the same external still share ONE hoisted copy; the
12
12
  // resolver warns naming both ranges, both requesters and the winner (per-member
13
- // dedup is the open Phase D.8 item in status/open-todos.md).
13
+ // dedup is the open Phase D.8 item in status/open-todos/README.md).
14
14
  //
15
15
  // Concurrency (ADR 0001): every prefix mutation runs under a per-prefix
16
16
  // cross-process lock (utils/install-lock.ts), so installs into different
@@ -35,6 +35,7 @@ import { binDirOnPath, BUNDLER_ENGINE_PACKAGE, defaultGlobalLayout, hasBundlerEn
35
35
  import { resolveHostPlatform } from '../utils/platform-check.js';
36
36
  import { pruneAfterInstall } from '../utils/prune-prefix.js';
37
37
  import { addDependencyEntry, defaultRangeFromVersion, parseSpec, projectSpecsFromPackageJson, readPackageJson, writePackageJson, } from '../utils/pkg-json-edit.js';
38
+ import { applyDevLinks, assertNoDevLinkUnderImmutable, devLinkRoot, prepareDevLinks, } from '../utils/dev-link.js';
38
39
  /**
39
40
  * Link type for the workspace↔workspace directory links.
40
41
  *
@@ -157,6 +158,12 @@ export const installCommand = {
157
158
  '(--immutable forbids rewriting the lockfile). Drop one.');
158
159
  return process.exit(1);
159
160
  }
161
+ // FAIL CLOSED before anything is resolved or written: an active `gjsify
162
+ // link` override would make a local development tree an input to the one
163
+ // install shape whose whole promise is "exactly what is committed" — CI,
164
+ // the Flatpak build, a release. Throws rather than exits so `index.ts`
165
+ // prints the message that names the override file (utils/dev-link.ts).
166
+ assertNoDevLinkUnderImmutable(process.cwd());
160
167
  }
161
168
  if (args.global) {
162
169
  if (!args.packages || args.packages.length === 0) {
@@ -426,6 +433,61 @@ function depKindFromArgs(args) {
426
433
  return 'optionalDependencies';
427
434
  return 'dependencies';
428
435
  }
436
+ /**
437
+ * Wire the `gjsify link` development override for this prefix, and SAY SO.
438
+ *
439
+ * Called before the fetch phase for the same reason `wireWorkspaceSymlinks` is:
440
+ * these links point at local source and depend on no download, so writing them
441
+ * first means an install that dies mid-way still leaves a resolvable tree. Called
442
+ * again afterwards as a near no-op safety net.
443
+ *
444
+ * The announcement is not decoration. A linked tree changes what a build compiles
445
+ * without changing any file the consumer commits, so the one place it can be seen
446
+ * is the output of the command that re-applies it — this is exactly what a bare
447
+ * `npm link` does not do, and why the next `npm install` silently undoes it.
448
+ *
449
+ * Returns `null` when no override is present; throws when one names a checkout
450
+ * that is gone, is not a gjsify workspace, or is not BUILT (utils/dev-link.ts).
451
+ *
452
+ * `devLinkRoot` and not `prefix` verbatim: `assertNoDevLinkUnderImmutable` already
453
+ * walks up to the workspace root to decide whether an override is in play, and two
454
+ * answers to "which override governs this install" is one answer too many. Nothing
455
+ * rescued the difference either — the bare `install` path happens to delegate to
456
+ * the workspace root before reaching here, `install <pkg>` does not.
457
+ */
458
+ function activateDevLinks(prefix, announce) {
459
+ const active = prepareDevLinks(devLinkRoot(prefix));
460
+ if (!active)
461
+ return null;
462
+ const written = applyDevLinks(active.links);
463
+ if (!announce)
464
+ return active;
465
+ console.log(`gjsify install: development link ACTIVE (${active.overridePath}) — ${active.links.length} ` +
466
+ `package(s) come from ${active.checkout}, linked rather than fetched ` +
467
+ `(${written.length} (re)written, ${active.links.length - written.length} already current):`);
468
+ const names = active.links.map((l) => l.name);
469
+ const shown = names.slice(0, 12);
470
+ console.log(` ${shown.join(', ')}${names.length > shown.length ? `, +${names.length - shown.length} more` : ''}`);
471
+ console.log(' `gjsify unlink` restores the registry copies; `gjsify install --immutable` refuses while it exists.');
472
+ return active;
473
+ }
474
+ /**
475
+ * Re-apply the dev links after the install, and SAY SO when it changed anything.
476
+ *
477
+ * `applyDevLinks` returns what it had to (re)write, and both post-install call
478
+ * sites used to throw that away — so an installer that clobbered a link and got it
479
+ * put back looked exactly like one that never touched it. The safety net is
480
+ * supposed to be a no-op; the run where it is not is the one worth a line.
481
+ */
482
+ function reportRewiredDevLinks(active) {
483
+ if (!active)
484
+ return;
485
+ const rewired = applyDevLinks(active.links);
486
+ if (rewired.length === 0)
487
+ return;
488
+ console.log(`gjsify install: re-applied ${rewired.length} development link(s) the install had replaced: ` +
489
+ `${rewired.map((l) => l.name).join(', ')}`);
490
+ }
429
491
  async function projectInstallNative(args, signal) {
430
492
  const cwd = process.cwd();
431
493
  const pkgPath = join(cwd, 'package.json');
@@ -497,6 +559,7 @@ async function projectInstallNative(args, signal) {
497
559
  // re-acquires the same lock re-entrantly.
498
560
  const lock = await acquireInstallLock(cwd, { signal });
499
561
  try {
562
+ const devLinks = activateDevLinks(cwd, true);
500
563
  // Typed as NativeInstallOptions so the native-backend-only `optionalSpecs`
501
564
  // is CHECKED here instead of smuggled past an excess-property check;
502
565
  // `installPackages` forwards the object to that backend unchanged.
@@ -518,8 +581,14 @@ async function projectInstallNative(args, signal) {
518
581
  ...optionalDependencyNames(pkg ? [pkg] : []),
519
582
  ...(args['save-optional'] ? (args.packages ?? []).map((s) => parseSpec(s).name) : []),
520
583
  ]),
584
+ linkedNames: devLinks?.names,
521
585
  };
522
586
  const result = await installPackages(nativeOpts);
587
+ // The link survives the install — the point of reading the override at all.
588
+ // The return value is REPORTED, not dropped: a non-empty one means the
589
+ // install disturbed a link that the pre-fetch pass had already wired, which
590
+ // is a fact about the installer, not a routine no-op.
591
+ reportRewiredDevLinks(devLinks);
523
592
  // Only the `gjsify install <pkg>...` add-a-dep flow mutates the manifest;
524
593
  // the no-args refresh must not.
525
594
  if (args.packages && args.packages.length > 0 && pkg) {
@@ -572,7 +641,7 @@ function syncLockfileRequested(cwd, specs) {
572
641
  *
573
642
  * Hoisting is deliberately minimal — members with conflicting ranges of the same
574
643
  * dep share one hoisted copy and the resolver warns. Per-member dedup and nested
575
- * `node_modules/` are the open Phase D.8 item in status/open-todos.md.
644
+ * `node_modules/` are the open Phase D.8 item in status/open-todos/README.md.
576
645
  */
577
646
  async function workspaceInstall(cwd, args, signal) {
578
647
  // Held for the WHOLE flow: bin shims, workspace symlinks and the external
@@ -650,6 +719,10 @@ async function workspaceInstallLocked(cwd, args, signal) {
650
719
  }
651
720
  }
652
721
  console.log(`gjsify install: ${workspaces.length} workspace(s), ${externalSpecs.size} external dep spec(s), ${symlinks.length} workspace symlink(s)`);
722
+ // A `gjsify link` override at the workspace ROOT governs the whole install, and
723
+ // is wired here for the same reason the shims and workspace symlinks are: before
724
+ // anything can fail on the network.
725
+ const devLinks = activateDevLinks(cwd, true);
653
726
  // EARLY, before the download/extract phase: the shims derive from workspace
654
727
  // manifests alone, while the heavy phase that follows can die mid-way (network,
655
728
  // timeout, crash). Writing them up front means a failed install can never leave
@@ -836,6 +909,7 @@ async function workspaceInstallLocked(cwd, args, signal) {
836
909
  // `types-dev/<lib>` workspace that also exists on npm and is pinned
837
910
  // transitively in the lockfile).
838
911
  workspaceNames: new Set(byName.keys()),
912
+ linkedNames: devLinks?.names,
839
913
  specOrigins: new Map([...specOrigins].map(([k, v]) => [k, [...v]])),
840
914
  // Aggregated over EVERY member, because the specs are: a name any member
841
915
  // declares as a plain dependency stays required.
@@ -865,6 +939,7 @@ async function workspaceInstallLocked(cwd, args, signal) {
865
939
  frozen: args.immutable,
866
940
  signal,
867
941
  workspaceNames: new Set(byName.keys()),
942
+ linkedNames: devLinks?.names,
868
943
  // Same aggregated answer as the root install: these specs are a SUBSET of
869
944
  // the root's, so classifying them differently would make one dep optional
870
945
  // at the root and required in a member.
@@ -874,6 +949,7 @@ async function workspaceInstallLocked(cwd, args, signal) {
874
949
  }
875
950
  // A near no-op safety net in case a later step disturbed a link.
876
951
  await wireWorkspaceSymlinks();
952
+ reportRewiredDevLinks(devLinks);
877
953
  // Now that the tree is materialised, refresh the shims' GJS preamble with the
878
954
  // native prebuild dirs that only became discoverable after the install (on a
879
955
  // fresh checkout `detectNativePackages` finds nothing before extraction).
@@ -1196,7 +1272,7 @@ async function installGlobalAndLink(specs, opts) {
1196
1272
  // A global install of `@gjsify/cli` must also lay down the GJS bundler engine
1197
1273
  // + the sibling format/CSS bridges. They are OPTIONAL PEERS of the CLI (so a
1198
1274
  // plain `npm install @gjsify/cli` on Node does not force a prebuild) and the
1199
- // native backend does not resolve peerDependencies at all, so without this
1275
+ // native backend, like npm ≥ 7, installs required peers only, so without this
1200
1276
  // they never arrive and `gjsify build` hard-fails under GJS with "no usable
1201
1277
  // bundler engine". Pinned to the CLI's resolved version so they move in
1202
1278
  // lockstep with the bundle; a platform with no published prebuild warns.
@@ -1257,8 +1333,8 @@ function reportLinkedBins(created, binDir) {
1257
1333
  *
1258
1334
  * The engine is an OPTIONAL PEER of `@gjsify/cli` on purpose — a plain
1259
1335
  * `npm install @gjsify/cli` on Node must not fetch prebuilds — but nobody resolves
1260
- * it for a project: npm 7+ skips optional peers and the native backend does not
1261
- * resolve `peerDependencies` at all. Under GJS there is no npm `rolldown`
1336
+ * it for a project: npm 7+ skips optional peers and so does the native backend
1337
+ * (it installs REQUIRED peers only). Under GJS there is no npm `rolldown`
1262
1338
  * fallback, so `gjsify build` had nothing to load.
1263
1339
  *
1264
1340
  * Four conditions, and (b) is the one worth reading:
@@ -0,0 +1,13 @@
1
+ import type { LeafCommand } from '../types/index.js';
2
+ interface LinkOptions {
3
+ checkout: string;
4
+ packages?: string[];
5
+ 'dry-run'?: boolean;
6
+ }
7
+ interface UnlinkOptions {
8
+ 'dry-run'?: boolean;
9
+ install?: boolean;
10
+ }
11
+ export declare const linkCommand: LeafCommand<unknown, LinkOptions>;
12
+ export declare const unlinkCommand: LeafCommand<unknown, UnlinkOptions>;
13
+ export {};
@@ -0,0 +1,243 @@
1
+ // `gjsify link <checkout>` / `gjsify unlink` — develop a consumer against a local
2
+ // gjsify checkout without waiting for an npm release, and without changing a byte
3
+ // of what the consumer commits.
4
+ //
5
+ // The model, the trap it closes and why `--immutable` refuses it are the header of
6
+ // `utils/dev-link.ts` and ADR 0065. This file is the surface: two commands, the
7
+ // output that makes an active link visible, and the undo.
8
+ //
9
+ // `unlink` REINSTALLS by default. Removing a link leaves a hole where the registry
10
+ // copy used to be, and a command that promises to undo itself must not hand back a
11
+ // tree that no longer resolves; `--no-install` is there for the case where the next
12
+ // step is an install anyway.
13
+ import { existsSync } from 'node:fs';
14
+ import { relative, resolve } from 'node:path';
15
+ import { applyDevLinks, consumerKnownNames, DEV_LINK_FILE, devLinkPath, ensureLocallyIgnored, formatUnbuiltDevLinks, planDevLinks, prepareDevLinks, readDevLinkOverride, removeDevLinkOverride, removeDevLinks, removeLocalIgnore, resolveCheckoutWorkspaces, retireDevLinks, scanDevLinks, unbuiltDevLinks, writeDevLinkOverride, } from '../utils/dev-link.js';
16
+ export const linkCommand = {
17
+ command: 'link <checkout>',
18
+ description: 'Link this project against a local gjsify checkout for development: its workspace packages replace the installed registry copies, recorded in a git-ignored .gjsify-link.json that every later `gjsify install` re-applies. Undo with `gjsify unlink`.',
19
+ builder: (yargs) => yargs
20
+ .positional('checkout', {
21
+ description: 'Path to the gjsify checkout to link from (a repo whose package.json declares "workspaces").',
22
+ type: 'string',
23
+ demandOption: true,
24
+ })
25
+ .option('packages', {
26
+ description: "Name glob selecting which of the checkout's workspace packages take part (repeatable; default: all of them). Matched against the package NAME with the same single-segment dialect as `gjsify foreach --include`, so a whole scope is `@gjsify/*` — a bare `*` matches no scoped name. Only packages this project actually depends on are ever linked.",
27
+ type: 'string',
28
+ array: true,
29
+ })
30
+ .option('dry-run', {
31
+ description: 'Print the links that would be made and write nothing.',
32
+ type: 'boolean',
33
+ default: false,
34
+ }),
35
+ handler: (args) => {
36
+ const consumerRoot = process.cwd();
37
+ const dryRun = args['dry-run'] ?? false;
38
+ const checkout = resolve(consumerRoot, args.checkout);
39
+ const overridePath = devLinkPath(consumerRoot);
40
+ // Validated BEFORE anything is written: a `.gjsify-link.json` naming a
41
+ // directory that is not a gjsify checkout would fail on every later install
42
+ // instead of on the command that created it.
43
+ const workspaces = resolveCheckoutWorkspaces(checkout, overridePath);
44
+ // No `--packages` means every workspace package, spelled as the EMPTY list:
45
+ // `['*']` would select nothing, because the glob dialect's `*` does not
46
+ // cross a `/` and every name here is scoped (utils/dev-link.ts).
47
+ const patterns = args.packages ?? [];
48
+ const links = planDevLinks({
49
+ consumerRoot,
50
+ workspaces,
51
+ patterns,
52
+ knownNames: consumerKnownNames(consumerRoot),
53
+ });
54
+ if (links.length === 0) {
55
+ console.error(`gjsify link: nothing to link — ${checkout} has ${workspaces.length} workspace package(s), ` +
56
+ `none of which ${consumerRoot} depends on${patterns.length === 0 ? '' : ` and matches ${patterns.join(', ')}`}.\n` +
57
+ `A link that links nothing is a silent no-op, so this is an error. Check the --packages ` +
58
+ `pattern, or run \`gjsify install\` first so the dependency tree exists.`);
59
+ return process.exit(1);
60
+ }
61
+ const label = `gjsify link${dryRun ? ' (dry-run)' : ''}`;
62
+ console.log(`${label} → ${checkout}`);
63
+ console.log(`${' '.repeat(label.length)} in ${consumerRoot}`);
64
+ for (const link of links) {
65
+ console.log(` ${link.name} → ${relative(checkout, link.target) || '.'}`);
66
+ }
67
+ // REFUSES rather than warns, and it refuses before anything is written.
68
+ // A link to an unbuilt package is not a smaller link, it is a tree whose
69
+ // first import fails — and the earlier draft's warning was printed once,
70
+ // at link time, while `gjsify install` re-applied the same link on every
71
+ // later run in silence (`utils/dev-link.ts`, `assertDevLinksBuilt`).
72
+ const unbuilt = unbuiltDevLinks(links);
73
+ if (unbuilt.length > 0) {
74
+ console.error(`\n${formatUnbuiltDevLinks(unbuilt)}`);
75
+ return process.exit(1);
76
+ }
77
+ // A dry run that hides the REMOVALS is the half-report this feature keeps
78
+ // being caught by: retiring a link is the destructive half of a re-link,
79
+ // and it is the half worth seeing before it happens.
80
+ const sweep = sweepCheckouts(consumerRoot, checkout);
81
+ const keep = new Set(links.map((l) => l.name));
82
+ const stale = scanDevLinks(consumerRoot, sweep).filter((link) => !keep.has(link.name));
83
+ for (const link of stale)
84
+ console.log(` retire ${link.name} (no longer selected)`);
85
+ if (dryRun) {
86
+ console.log(`\n${label}: nothing written. Drop --dry-run to apply.`);
87
+ return;
88
+ }
89
+ // RE-linking is a re-SELECTION, not an addition: whatever the previous
90
+ // override linked and this one does not is removed here. Without it a
91
+ // narrower `--packages` left an orphan symlink out of `node_modules` that
92
+ // no later command would ever name, and every `gjsify install` after it
93
+ // aborted (utils/dev-link.ts, `scanDevLinks`). Both checkouts are swept,
94
+ // because the new override may point somewhere else entirely.
95
+ const retired = retireDevLinks(consumerRoot, sweep, keep);
96
+ const written = applyDevLinks(links);
97
+ writeDevLinkOverride(consumerRoot, { version: 1, checkout, packages: patterns });
98
+ const ignored = ensureLocallyIgnored(consumerRoot);
99
+ console.log(`\n ${written.length} link(s) written, ${links.length - written.length} already current.`);
100
+ // Counted against what was PLANNED, not just reported: `removeDevLinks`
101
+ // refuses a real directory sitting where a link belongs, and a retire that
102
+ // silently did less than it announced is the same half-report again.
103
+ if (stale.length > 0) {
104
+ console.log(` ${retired.length} of ${stale.length} retired link(s) removed.`);
105
+ }
106
+ console.log(` override: ${overridePath}`);
107
+ if (ignored === 'added')
108
+ console.log(` git: ${DEV_LINK_FILE} added to .git/info/exclude (local, never committed)`);
109
+ if (ignored === 'no-git')
110
+ console.log(` git: ${consumerRoot} is not a git repository — nothing to ignore`);
111
+ if (ignored === 'unreadable-git') {
112
+ console.warn(` ! git: ${consumerRoot} has a .git POINTER that does not parse, so ${DEV_LINK_FILE} is NOT ignored. ` +
113
+ `Add it to .gitignore by hand, or the next \`git add -A\` commits your local override.`);
114
+ }
115
+ console.log(`\nEvery \`gjsify install\` here now re-applies these links and says so.\n` +
116
+ `\`gjsify install --immutable\` refuses while the override exists. Undo: \`gjsify unlink\`.`);
117
+ },
118
+ };
119
+ export const unlinkCommand = {
120
+ command: 'unlink',
121
+ description: 'Undo `gjsify link`: remove the development links and the .gjsify-link.json override, then reinstall the registry copies.',
122
+ builder: (yargs) => yargs
123
+ .option('dry-run', {
124
+ description: 'Print what would be removed and change nothing.',
125
+ type: 'boolean',
126
+ default: false,
127
+ })
128
+ .option('install', {
129
+ description: 'Reinstall after unlinking, to put the registry copies back where the links were. On by default; --no-install leaves the holes for a later `gjsify install`.',
130
+ type: 'boolean',
131
+ default: true,
132
+ }),
133
+ handler: async (args) => {
134
+ const consumerRoot = process.cwd();
135
+ const dryRun = args['dry-run'] ?? false;
136
+ const overridePath = devLinkPath(consumerRoot);
137
+ if (!existsSync(overridePath)) {
138
+ console.log(`gjsify unlink: no development link in ${consumerRoot} (no ${DEV_LINK_FILE}).`);
139
+ return;
140
+ }
141
+ // TWO readers, because the plan alone is not a census of what is linked
142
+ // (utils/dev-link.ts, `scanDevLinks`). A checkout that has since been
143
+ // deleted must still be unlinkable, so the dead-target refusal is caught
144
+ // here rather than propagated.
145
+ //
146
+ // The checkout is read separately from the PLAN, because the plan is the
147
+ // part that can fail (a checkout that has since been deleted, an unbuilt
148
+ // one) and the checkout path is what the tree sweep below needs.
149
+ const checkouts = [];
150
+ try {
151
+ const checkout = readDevLinkOverride(consumerRoot)?.checkout;
152
+ if (checkout)
153
+ checkouts.push(checkout);
154
+ }
155
+ catch {
156
+ // Unparseable override: the sweep finds nothing, the plan below says
157
+ // why, and the override is still removed.
158
+ }
159
+ let links = [];
160
+ try {
161
+ links = prepareDevLinks(consumerRoot)?.links ?? [];
162
+ }
163
+ catch (err) {
164
+ console.warn(`gjsify unlink: ${err instanceof Error ? err.message : String(err)}\n` +
165
+ ` Removing the override anyway; any dangling links are cleaned by the reinstall.`);
166
+ }
167
+ const label = `gjsify unlink${dryRun ? ' (dry-run)' : ''}`;
168
+ console.log(`${label} in ${consumerRoot}`);
169
+ if (dryRun) {
170
+ const names = new Set(links.map((l) => l.name));
171
+ for (const link of scanDevLinks(consumerRoot, checkouts))
172
+ names.add(link.name);
173
+ for (const name of [...names].sort())
174
+ console.log(` would remove link ${name}`);
175
+ console.log(` would remove ${overridePath}`);
176
+ console.log(` would remove ${DEV_LINK_FILE} entry from .git/info/exclude (if this module wrote it)`);
177
+ console.log(`\n${label}: nothing changed. Drop --dry-run to apply.`);
178
+ return;
179
+ }
180
+ // ORDER IS THE FIX. The links go first, the override LAST: the override is
181
+ // the only record of which checkout to sweep, so removing it while a link
182
+ // is still standing destroys the escape route — measured, that left
183
+ // `gjsify unlink` reporting success, the reinstall aborting on the orphan,
184
+ // and no command able to name it again.
185
+ //
186
+ // And the sweep is by TREE, not by plan: `removeDevLinks(links)` only ever
187
+ // visits what the CURRENT override selects, which is exactly how the orphan
188
+ // survived in the first place.
189
+ const removed = removeDevLinks(links);
190
+ const kept = new Set(removed.map((l) => l.name));
191
+ for (const link of retireDevLinks(consumerRoot, checkouts, new Set())) {
192
+ if (kept.has(link.name))
193
+ continue;
194
+ removed.push(link);
195
+ }
196
+ for (const link of removed)
197
+ console.log(` unlinked ${link.name}`);
198
+ // Nothing may point out of `node_modules` any more, or the reinstall below
199
+ // dies on it. If something does, the override STAYS: it is what a later
200
+ // `gjsify unlink` needs, and a half-undone tree with no record of the
201
+ // checkout is the wedge this whole block exists to prevent.
202
+ const stragglers = scanDevLinks(consumerRoot, checkouts);
203
+ if (stragglers.length > 0) {
204
+ console.error(`gjsify unlink: ${stragglers.length} link(s) still point into a checkout and could not be removed:\n` +
205
+ stragglers.map((l) => ` ${l.name} ${l.linkPath} → ${l.target}`).join('\n') +
206
+ `\n ${overridePath} is kept so this stays undoable. Remove the paths above and re-run.`);
207
+ return process.exit(1);
208
+ }
209
+ removeDevLinkOverride(consumerRoot);
210
+ console.log(` removed ${overridePath}`);
211
+ // The `.git/info/exclude` block is part of what `link` wrote, so it is part
212
+ // of what `unlink` owes back. Leaving it behind made the undo incomplete in
213
+ // a way nobody notices until they read the file and wonder what wrote it.
214
+ if (removeLocalIgnore(consumerRoot) === 'removed') {
215
+ console.log(` removed ${DEV_LINK_FILE} entry from .git/info/exclude`);
216
+ }
217
+ if (args.install === false) {
218
+ console.log(`\n ${removed.length} link(s) removed. Run \`gjsify install\` to restore the registry copies.`);
219
+ return;
220
+ }
221
+ console.log(`\n ${removed.length} link(s) removed — reinstalling to restore the registry copies.\n`);
222
+ const { runCli } = await import('../cli-app.js');
223
+ await runCli(['install']);
224
+ },
225
+ };
226
+ /**
227
+ * Which checkouts a re-link has to sweep: the one it is about to record, plus the
228
+ * one the CURRENT override names when that is a different directory.
229
+ *
230
+ * Every failure reading the old override is swallowed. A malformed or dead one
231
+ * must not stop a fresh `gjsify link` from fixing the situation, which is usually
232
+ * exactly why it is being run.
233
+ */
234
+ function sweepCheckouts(consumerRoot, checkout) {
235
+ let previous = null;
236
+ try {
237
+ previous = readDevLinkOverride(consumerRoot)?.checkout ?? null;
238
+ }
239
+ catch {
240
+ previous = null;
241
+ }
242
+ return previous && previous !== checkout ? [previous, checkout] : [checkout];
243
+ }
@@ -464,6 +464,11 @@ export const onboardCommand = {
464
464
  };
465
465
  // 4a. Bookkeeping-only rows, and the serial publish path.
466
466
  const trustOnly = [];
467
+ // What THIS sweep has already put on the registry. A name is not served
468
+ // the instant its PUT is accepted, so the pin guard in `publishWorkspace`
469
+ // must not ask the registry about a package this loop created moments
470
+ // ago — target before dependent is the order, and the order is kept here.
471
+ const publishedHere = new Set();
467
472
  for (let i = 0; i < plans.length; i++) {
468
473
  const p = plans[i];
469
474
  if (p.action === 'skip') {
@@ -494,6 +499,7 @@ export const onboardCommand = {
494
499
  continue;
495
500
  }
496
501
  const pub = await publishWorkspace({
502
+ assumePresent: [...publishedHere],
497
503
  wsDir: p.ws.location,
498
504
  tag: 'latest',
499
505
  access: args.access,
@@ -514,7 +520,13 @@ export const onboardCommand = {
514
520
  failed++;
515
521
  continue;
516
522
  }
517
- log(` published ${pub.name}@${'version' in pub ? pub.version : ''}`);
523
+ log(` published ${pub.name}@${'version' in pub ? pub.version : ''}` +
524
+ ('readback' in pub && pub.readback?.confirmed
525
+ ? ` (verified on ${pub.registry} — ${pub.readback.attempts} probe(s))`
526
+ : pub.action === 'skipped-untrusted-new'
527
+ ? ''
528
+ : ' (UNVERIFIED)'));
529
+ publishedHere.add(pub.name);
518
530
  await configureTrust(p, i, pub.action !== 'skipped-untrusted-new');
519
531
  }
520
532
  // 4b. The trust-only packages, concurrently.
@@ -670,7 +682,11 @@ function describePublishFailure(pub) {
670
682
  return (`npm ${pub.claim === 'already-published' ? 'reported it already published' : 'accepted the upload'} ` +
671
683
  `(HTTP ${pub.putStatus}) but ${pub.name}@${pub.version} did not ` +
672
684
  `resolve on ${pub.registry} after ${pub.readback.attempts} probe(s) over ` +
673
- `${(pub.readback.elapsedMs / 1000).toFixed(1)}s`);
685
+ `${(pub.readback.elapsedMs / 1000).toFixed(1)}s [${pub.readback.verdict}: ${pub.readback.verdictDetail}]`);
686
+ case 'blocked-missing-dependency':
687
+ return (`not published — ${pub.missing.length} required pin(s) name a package ${pub.registry} does not ` +
688
+ `have: ${pub.missing.map((m) => `${m.block}.${m.name}@${m.spec}`).join(', ')}. Bootstrap the ` +
689
+ `target BEFORE its dependent; that order is a correctness property`);
674
690
  case 'error':
675
691
  return `HTTP ${pub.status} ${pub.statusText}`;
676
692
  default:
@@ -1,6 +1,7 @@
1
1
  import type { Command } from '../types/index.js';
2
2
  import { type Diagnose404Result } from '../utils/publish-diagnose.js';
3
3
  import { type ReadbackResult } from '../utils/publish-readback.js';
4
+ import { type WorkspacePin } from '../utils/publish-pins.js';
4
5
  import { OtpProvider } from '../utils/npm-otp.js';
5
6
  interface PublishOptions {
6
7
  path?: string;
@@ -29,6 +30,18 @@ export interface PublishWorkspaceInput {
29
30
  tolerate: boolean;
30
31
  /** Skip an un-bootstrapped new package on the OIDC path. */
31
32
  tolerateUntrustedNew: boolean;
33
+ /**
34
+ * Names this CALLER has published in this same process, which the pin guard
35
+ * therefore does not ask the registry about.
36
+ *
37
+ * A brand-new name is not served the instant its PUT is accepted — the same
38
+ * eventual consistency that makes a version gate unsafe — so a sweep that
39
+ * bootstraps a target and its dependent in one run would otherwise block the
40
+ * dependent on a name it had just created itself. `gjsify onboard` is that
41
+ * sweep. The release sweep is `foreach --exec`, one process per package, so
42
+ * its children pass nothing and every edge is a real registry question.
43
+ */
44
+ assumePresent?: readonly string[];
32
45
  /** `--trusted` (force OIDC) / `undefined` | `'auto'` (auto-detect). */
33
46
  trustedFlag: boolean | 'auto' | undefined;
34
47
  /** Registry override. Default: `$npm_config_registry` → scope-aware npmrc → npmjs. */
@@ -94,6 +107,7 @@ export type PublishOutcome = {
94
107
  name: string;
95
108
  version: string;
96
109
  status: number;
110
+ registry: string;
97
111
  /** The read-back that CONFIRMED the conflicting version. */
98
112
  readback?: ReadbackResult;
99
113
  } | {
@@ -101,6 +115,26 @@ export type PublishOutcome = {
101
115
  action: 'skipped-untrusted-new';
102
116
  name: string;
103
117
  version: string;
118
+ }
119
+ /**
120
+ * The tarball was NOT sent, because a required intra-repo pin in it names a
121
+ * package the registry does not have (#1713).
122
+ *
123
+ * `ok` is the TOLERANCE, not the health: under `--tolerate-untrusted-new`
124
+ * this is a skip the sweep continues past, because a fatal child stops
125
+ * `foreach --exec` mid-roster and leaves a PARTIAL publish. Either way the
126
+ * broken tarball stays off the registry, which is the difference from the
127
+ * shape this outcome exists to end — where the dependency was skipped, exit
128
+ * 0, and its dependent shipped a pin npm could not resolve.
129
+ */
130
+ | {
131
+ ok: boolean;
132
+ action: 'blocked-missing-dependency';
133
+ name: string;
134
+ version: string;
135
+ registry: string;
136
+ tolerated: boolean;
137
+ missing: WorkspacePin[];
104
138
  } | {
105
139
  ok: false;
106
140
  action: 'otp-required';