@gjsify/cli 0.51.0 → 0.52.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 (66) hide show
  1. package/dist/cli.gjs.mjs +505 -490
  2. package/lib/cli-app.js +3 -1
  3. package/lib/commands/affected-classify.d.ts +7 -1
  4. package/lib/commands/affected-classify.js +66 -9
  5. package/lib/commands/affected-script-refs.d.ts +20 -0
  6. package/lib/commands/affected-script-refs.js +218 -0
  7. package/lib/commands/affected.js +24 -2
  8. package/lib/commands/flatpak/diff.js +8 -2
  9. package/lib/commands/flatpak/init.js +4 -1
  10. package/lib/commands/flatpak/sources.js +4 -3
  11. package/lib/commands/flatpak/sync-flathub.d.ts +2 -0
  12. package/lib/commands/flatpak/sync-flathub.js +149 -7
  13. package/lib/commands/flatpak/utils.d.ts +10 -0
  14. package/lib/commands/flatpak/utils.js +10 -0
  15. package/lib/commands/index.d.ts +1 -0
  16. package/lib/commands/index.js +1 -0
  17. package/lib/commands/install.js +76 -0
  18. package/lib/commands/link.d.ts +13 -0
  19. package/lib/commands/link.js +243 -0
  20. package/lib/commands/onboard.js +18 -2
  21. package/lib/commands/publish.d.ts +34 -0
  22. package/lib/commands/publish.js +153 -8
  23. package/lib/commands/ship.js +46 -4
  24. package/lib/types/config-data.d.ts +1 -1
  25. package/lib/utils/check-system-deps.d.ts +1 -17
  26. package/lib/utils/check-system-deps.js +10 -68
  27. package/lib/utils/dev-link.d.ts +222 -0
  28. package/lib/utils/dev-link.js +632 -0
  29. package/lib/utils/hang-watchdog.d.ts +95 -0
  30. package/lib/utils/hang-watchdog.js +194 -0
  31. package/lib/utils/install-backend-native.d.ts +14 -0
  32. package/lib/utils/install-backend-native.js +91 -13
  33. package/lib/utils/install-extraneous.d.ts +90 -0
  34. package/lib/utils/install-extraneous.js +120 -0
  35. package/lib/utils/install-tarball-cache.d.ts +37 -9
  36. package/lib/utils/install-tarball-cache.js +59 -14
  37. package/lib/utils/normalize-bundler-options.js +11 -0
  38. package/lib/utils/publish-pins.d.ts +38 -0
  39. package/lib/utils/publish-pins.js +47 -0
  40. package/lib/utils/publish-readback.d.ts +103 -17
  41. package/lib/utils/publish-readback.js +326 -47
  42. package/lib/utils/run-gjs.js +82 -6
  43. package/lib/utils/ship/appimage.d.ts +24 -4
  44. package/lib/utils/ship/appimage.js +51 -6
  45. package/lib/utils/ship/formats.js +44 -11
  46. package/lib/utils/ship/icns.d.ts +14 -0
  47. package/lib/utils/ship/icns.js +83 -0
  48. package/lib/utils/ship/ico.d.ts +25 -0
  49. package/lib/utils/ship/ico.js +120 -0
  50. package/lib/utils/ship/icons.d.ts +191 -0
  51. package/lib/utils/ship/icons.js +398 -0
  52. package/lib/utils/ship/launcher.js +16 -1
  53. package/lib/utils/ship/layout.d.ts +32 -0
  54. package/lib/utils/ship/layout.js +112 -38
  55. package/lib/utils/ship/msi.d.ts +1 -1
  56. package/lib/utils/ship/msi.js +30 -5
  57. package/lib/utils/ship/pe-launcher.d.ts +18 -0
  58. package/lib/utils/ship/pe-launcher.js +125 -4
  59. package/lib/utils/ship/plan.d.ts +1 -34
  60. package/lib/utils/ship/plan.js +7 -56
  61. package/lib/utils/ship/plist.d.ts +20 -10
  62. package/lib/utils/ship/plist.js +29 -12
  63. package/lib/utils/ship/schemas.d.ts +46 -0
  64. package/lib/utils/ship/schemas.js +92 -11
  65. package/lib/utils/ship/settings.js +3 -2
  66. package/package.json +20 -20
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The half of `--immutable` that was missing: every locked package is present AND
3
+ * NOTHING ELSE IS.
4
+ *
5
+ * The installer only ever ADDED. Handed a `node_modules` carrying a package the
6
+ * lockfile never mentions, `--immutable` extracted whatever was missing, left the
7
+ * stranger in place and exited 0 — a green install over a tree its own lockfile does
8
+ * not describe.
9
+ *
10
+ * THE INCIDENT (gjsify#1683, 2026-09-15). CI caches `node_modules` keyed on
11
+ * `hashFiles('gjsify-lock.json')` with a prefix fallback `restore-keys:
12
+ * node-modules-v1-`. An earlier run of a `@girs` bump genuinely nested
13
+ * `node_modules/@girs/gtk-4.0/node_modules/@girs/graphene-1.0` and saved that tree.
14
+ * The fix push changed the lockfile → new key → miss → the FALLBACK restored the
15
+ * nested tree. `--immutable` added what was missing, pruned nothing, and `tsc` then
16
+ * saw one type under two identities: TS2883. Worse, the still-nested tree was saved
17
+ * under the NEW key, so no plain re-run could go green — the failure reproduced
18
+ * itself until the caches were deleted by hand. Nothing about the PR's code was
19
+ * wrong, and any lockfile-changing PR could poison itself the same way.
20
+ *
21
+ * WHY NAMING, NOT DELETING. The peers split: `npm ci` removes `node_modules` first,
22
+ * yarn's `--immutable` fails on a mismatch. This follows yarn, for three reasons.
23
+ * The flag's own name promises not to mutate, and this repo already refuses
24
+ * automatic deletion under it ({@link automaticPruneRefusal} returns `'--immutable'`).
25
+ * A verifier that deletes can delete the wrong thing — a `link`ed dep, a package
26
+ * some other tool put there — while a verifier that reports cannot. And the failure
27
+ * mode is asymmetric: a wrong deletion loses work, a wrong refusal costs one red run
28
+ * carrying the exact paths and the fix.
29
+ *
30
+ * That choice has a price, paid in `.github/actions/gjsify-setup/action.yml`: with
31
+ * this check fatal, a restored FOREIGN tree stops being silent corruption and
32
+ * becomes a hard failure on every lockfile-changing PR. So the `restore-keys`
33
+ * fallback is gone — see the comment there. The two decisions are one decision.
34
+ */
35
+ import { relative, sep } from 'node:path';
36
+ /** `/`-separated, no trailing slash — lockfile key shape on every host. */
37
+ function normalizePath(p) {
38
+ const slashed = sep === '/' ? p : p.split(sep).join('/');
39
+ return slashed.endsWith('/') ? slashed.slice(0, -1) : slashed;
40
+ }
41
+ /**
42
+ * Package directories under `prefix` that no entry of `expected` accounts for.
43
+ *
44
+ * Pure: it takes the scan rows ({@link scanPrefix}) and the resolved set rather than
45
+ * reading either, so every branch — nested placements, symlinks, subtree collapsing —
46
+ * is decidable without a filesystem.
47
+ *
48
+ * Three things are deliberately NOT extraneous:
49
+ * - a SYMLINK, which is a workspace source tree linked in by `workspaceInstall`
50
+ * and by construction absent from the lockfile (`scanPrefix` does not descend
51
+ * into one either, so the user's own sources are never walked);
52
+ * - a dot-entry like `.bin` or `.cache`, already dropped by the scan;
53
+ * - a package the lockfile describes but the PLATFORM filter skipped on this host.
54
+ * It is described, so it is not a stranger; removing the foreign-platform residue
55
+ * is `prune-prefix.ts`'s separate job under a separate rule.
56
+ *
57
+ * Descendants of an extraneous directory are collapsed away: reporting the parent
58
+ * says everything, and the nested `@girs` tree of the incident would otherwise list
59
+ * its whole subtree.
60
+ */
61
+ export function findExtraneous(opts) {
62
+ const described = new Set(opts.expected.map((n) => normalizePath(n.installPath)));
63
+ const found = [];
64
+ for (const pkg of opts.installed) {
65
+ if (pkg.linked)
66
+ continue;
67
+ const installPath = normalizePath(relative(opts.prefix, pkg.dir));
68
+ // Outside the prefix, or the prefix itself: not this install's to judge.
69
+ if (installPath === '' || installPath.startsWith('..'))
70
+ continue;
71
+ if (described.has(installPath))
72
+ continue;
73
+ found.push({ installPath, name: pkg.name, version: pkg.version });
74
+ }
75
+ found.sort((a, b) => (a.installPath < b.installPath ? -1 : a.installPath > b.installPath ? 1 : 0));
76
+ const roots = [];
77
+ for (const entry of found) {
78
+ const parent = roots[roots.length - 1];
79
+ // Sorted, so an enclosing extraneous directory is always the last one kept.
80
+ if (parent && entry.installPath.startsWith(`${parent.installPath}/`))
81
+ continue;
82
+ roots.push(entry);
83
+ }
84
+ return roots;
85
+ }
86
+ /** How many paths the message names before it starts counting. */
87
+ export const EXTRANEOUS_REPORT_LIMIT = 20;
88
+ /**
89
+ * The refusal, built to be actionable from the CI log alone: what is wrong, which
90
+ * paths, and the one thing that fixes it. Truncated past
91
+ * {@link EXTRANEOUS_REPORT_LIMIT} so a wholly foreign tree does not bury the advice
92
+ * under a thousand lines.
93
+ *
94
+ * It says DELETE, and says so as the only remedy, because that is what was measured:
95
+ * a plain `gjsify install` — and an explicit `--prune` — leave both a top-level and a
96
+ * nested stranger exactly where they were. The installer only adds, and `prune-prefix`
97
+ * judges one thing, npm's `os`/`cpu`/`libc`, so a package that is merely UNDESCRIBED
98
+ * matches no rule either owns. Sending a developer to `gjsify install` instead would
99
+ * hand them a command that changes nothing and a refusal that repeats verbatim.
100
+ */
101
+ export function formatExtraneousError(entries, prefix) {
102
+ const shown = entries.slice(0, EXTRANEOUS_REPORT_LIMIT);
103
+ const lines = shown.map((e) => ` ${e.installPath} (${e.name}@${e.version})`);
104
+ if (entries.length > shown.length)
105
+ lines.push(` … and ${entries.length - shown.length} more`);
106
+ return [
107
+ `install: --immutable, but ${prefix}/node_modules holds ${entries.length} package(s) ` +
108
+ 'that gjsify-lock.json does not describe.',
109
+ '--immutable installs the lockfile and removes nothing, so each of these would survive ' +
110
+ 'the install and be resolved, bundled and type-checked as if it belonged ' +
111
+ '(gjsify#1683: a nested copy of an already-hoisted package → TS2883, one type ' +
112
+ 'with two identities).',
113
+ ...lines,
114
+ 'Fix: delete those directories — or the whole node_modules — and re-run. A plain ' +
115
+ '`gjsify install` does NOT clear them: it only ever adds, and its prune pass judges ' +
116
+ 'foreign-PLATFORM packages alone (prune-prefix.ts), so an undescribed package ' +
117
+ 'survives both. Deleting is currently the only thing that removes one. ' +
118
+ 'In CI this usually means a cache entry was restored from a DIFFERENT lockfile.',
119
+ ].join('\n');
120
+ }
@@ -1,9 +1,31 @@
1
1
  /**
2
- * Read a cached tarball by SRI integrity — raw bytes on a HIT, `null` otherwise. A
3
- * read failure (e.g. a partial write from an interrupted run) is a MISS, and the
4
- * file is left untouched so a follow-up writer's atomic rename is not tripped.
2
+ * Read a cached tarball by SRI integrity — raw bytes on a VERIFIED hit, `null`
3
+ * otherwise. A read failure (e.g. a partial write from an interrupted run) is a MISS,
4
+ * and the file is left untouched so a follow-up writer's atomic rename is not tripped.
5
+ *
6
+ * The bytes are re-hashed against the integrity that named them, through the SAME
7
+ * {@link verifyIntegrity} the download path uses — so both paths agree by
8
+ * construction instead of by comment.
9
+ *
10
+ * This used to trust the path: "content-addressed, so a hash hit is byte-identical to
11
+ * what the registry would return". That is a claim ABOUT the bytes, and nothing read
12
+ * them to check it. It held while the store was a local directory this machine had
13
+ * written itself. It stops holding the moment the store is a restorable CI artifact —
14
+ * a GitHub Actions cache entry that is truncated, corrupted, or written by a workflow
15
+ * on another branch would have been consumed as authentic, because the only thing
16
+ * asserting its contents was its filename. `gjsify-setup` now restores exactly such an
17
+ * artifact, so the check is no longer optional. Yarn re-hashes on every install for
18
+ * this reason (refs/yarn-berry Cache.ts, "we check the file hashes during each install
19
+ * anyway"); measured here, it costs ~0.5 s over this repo's ~420 MB closure, against
20
+ * an extract phase of minutes.
21
+ *
22
+ * A mismatch DELETES the entry rather than merely skipping it. That is not the prune
23
+ * this repo refuses elsewhere — the name asserts the content and the content
24
+ * disproves the name, so the file is garbage by its own definition. It has to go:
25
+ * {@link putCachedTarball} is idempotent on existence, so a corrupt blob left in place
26
+ * would never be replaced and every future install would re-download it forever.
5
27
  */
6
- export declare function getCachedTarball(integrity: string | undefined): Uint8Array | null;
28
+ export declare function getCachedTarball(integrity: string | undefined): Promise<Uint8Array | null>;
7
29
  /**
8
30
  * Persist a tarball to the cache, via a `<path>.tmp.<pid>` sibling and an atomic
9
31
  * rename so concurrent installs never observe a half-written entry. No-op when
@@ -17,9 +39,15 @@ export declare function cacheRootForLogging(): string;
17
39
  export declare function isCacheHit(integrity: string | undefined): boolean;
18
40
  /**
19
41
  * Read a tarball from npm's cacache content store by SRI integrity — raw `.tgz`
20
- * bytes on a HIT, `null` on a MISS, disabled interop or read failure. Like
21
- * {@link getCachedTarball} it trusts the content-addressed path rather than
22
- * re-hashing: cacache verified the bytes on write, and the extractor surfaces a
23
- * genuinely corrupt tarball loudly.
42
+ * bytes on a VERIFIED hit, `null` on a MISS, disabled interop or read failure.
43
+ *
44
+ * Re-hashed like {@link getCachedTarball}, and with more reason: these bytes come
45
+ * from ANOTHER tool's store, on a path this code does not control and cannot assume
46
+ * was written by a cacache that verified anything. The old note argued cacache
47
+ * verified on write and a corrupt tarball would fail loudly in the extractor — but
48
+ * "the next stage will probably notice" is not verification, and the caller
49
+ * write-throughs a foreign hit into OUR store, which would launder an unchecked blob
50
+ * into a first-class entry. A mismatch is left alone here rather than deleted: this
51
+ * store belongs to npm, and removing another tool's files is not this code's call.
24
52
  */
25
- export declare function getForeignCachedTarball(integrity: string | undefined): Uint8Array | null;
53
+ export declare function getForeignCachedTarball(integrity: string | undefined): Promise<Uint8Array | null>;
@@ -25,9 +25,10 @@
25
25
  // on too. Tarballs without an integrity hash (older registries) fall through to a
26
26
  // no-op cache and download every time.
27
27
  import { Buffer } from 'node:buffer';
28
- import { existsSync, statSync } from 'node:fs';
28
+ import { existsSync, statSync, unlinkSync } from 'node:fs';
29
29
  import { homedir } from 'node:os';
30
30
  import { join } from 'node:path';
31
+ import { verifyIntegrity } from '@gjsify/npm-registry';
31
32
  import { atomicWrite, gjsifyCacheRoot, readCacheFile } from './install-cache-fs.js';
32
33
  const CACHE_LAYOUT_VERSION = 'v1';
33
34
  /** Root of the tarball cache: `$XDG_CACHE_HOME/gjsify/tarballs/v1`. */
@@ -68,15 +69,50 @@ function pathFor(integrity) {
68
69
  return join(cacheRoot(), sri.algorithm, shard, `${sri.hex}.tgz`);
69
70
  }
70
71
  /**
71
- * Read a cached tarball by SRI integrity — raw bytes on a HIT, `null` otherwise. A
72
- * read failure (e.g. a partial write from an interrupted run) is a MISS, and the
73
- * file is left untouched so a follow-up writer's atomic rename is not tripped.
72
+ * Read a cached tarball by SRI integrity — raw bytes on a VERIFIED hit, `null`
73
+ * otherwise. A read failure (e.g. a partial write from an interrupted run) is a MISS,
74
+ * and the file is left untouched so a follow-up writer's atomic rename is not tripped.
75
+ *
76
+ * The bytes are re-hashed against the integrity that named them, through the SAME
77
+ * {@link verifyIntegrity} the download path uses — so both paths agree by
78
+ * construction instead of by comment.
79
+ *
80
+ * This used to trust the path: "content-addressed, so a hash hit is byte-identical to
81
+ * what the registry would return". That is a claim ABOUT the bytes, and nothing read
82
+ * them to check it. It held while the store was a local directory this machine had
83
+ * written itself. It stops holding the moment the store is a restorable CI artifact —
84
+ * a GitHub Actions cache entry that is truncated, corrupted, or written by a workflow
85
+ * on another branch would have been consumed as authentic, because the only thing
86
+ * asserting its contents was its filename. `gjsify-setup` now restores exactly such an
87
+ * artifact, so the check is no longer optional. Yarn re-hashes on every install for
88
+ * this reason (refs/yarn-berry Cache.ts, "we check the file hashes during each install
89
+ * anyway"); measured here, it costs ~0.5 s over this repo's ~420 MB closure, against
90
+ * an extract phase of minutes.
91
+ *
92
+ * A mismatch DELETES the entry rather than merely skipping it. That is not the prune
93
+ * this repo refuses elsewhere — the name asserts the content and the content
94
+ * disproves the name, so the file is garbage by its own definition. It has to go:
95
+ * {@link putCachedTarball} is idempotent on existence, so a corrupt blob left in place
96
+ * would never be replaced and every future install would re-download it forever.
74
97
  */
75
- export function getCachedTarball(integrity) {
98
+ export async function getCachedTarball(integrity) {
76
99
  const path = pathFor(integrity);
77
- if (!path)
100
+ if (!path || !integrity)
101
+ return null;
102
+ const bytes = readCacheFile(path);
103
+ if (!bytes)
78
104
  return null;
79
- return readCacheFile(path);
105
+ if (await verifyIntegrity(bytes, integrity))
106
+ return bytes;
107
+ // Best-effort: a read-only cache volume must not break the install, and the
108
+ // caller falls through to a verifying download either way.
109
+ try {
110
+ unlinkSync(path);
111
+ }
112
+ catch {
113
+ /* keep going — the download path replaces the value we return as a MISS */
114
+ }
115
+ return null;
80
116
  }
81
117
  /**
82
118
  * Persist a tarball to the cache, via a `<path>.tmp.<pid>` sibling and an atomic
@@ -158,14 +194,23 @@ function npmCachePathFor(integrity) {
158
194
  }
159
195
  /**
160
196
  * Read a tarball from npm's cacache content store by SRI integrity — raw `.tgz`
161
- * bytes on a HIT, `null` on a MISS, disabled interop or read failure. Like
162
- * {@link getCachedTarball} it trusts the content-addressed path rather than
163
- * re-hashing: cacache verified the bytes on write, and the extractor surfaces a
164
- * genuinely corrupt tarball loudly.
197
+ * bytes on a VERIFIED hit, `null` on a MISS, disabled interop or read failure.
198
+ *
199
+ * Re-hashed like {@link getCachedTarball}, and with more reason: these bytes come
200
+ * from ANOTHER tool's store, on a path this code does not control and cannot assume
201
+ * was written by a cacache that verified anything. The old note argued cacache
202
+ * verified on write and a corrupt tarball would fail loudly in the extractor — but
203
+ * "the next stage will probably notice" is not verification, and the caller
204
+ * write-throughs a foreign hit into OUR store, which would launder an unchecked blob
205
+ * into a first-class entry. A mismatch is left alone here rather than deleted: this
206
+ * store belongs to npm, and removing another tool's files is not this code's call.
165
207
  */
166
- export function getForeignCachedTarball(integrity) {
208
+ export async function getForeignCachedTarball(integrity) {
167
209
  const path = npmCachePathFor(integrity);
168
- if (!path)
210
+ if (!path || !integrity)
211
+ return null;
212
+ const bytes = readCacheFile(path);
213
+ if (!bytes)
169
214
  return null;
170
- return readCacheFile(path);
215
+ return (await verifyIntegrity(bytes, integrity)) ? bytes : null;
171
216
  }
@@ -130,6 +130,17 @@ export function mergeBundlerOptions(base, overrides) {
130
130
  if (base.transform?.define || overrides.transform?.define) {
131
131
  out.transform.define = { ...base.transform?.define, ...overrides.transform?.define };
132
132
  }
133
+ // `inject` is a map like `define` and merges the same way. Replacing it wholesale
134
+ // meant a user who injected ONE identifier silently lost the orchestrator's
135
+ // `console` → `@gjsify/rolldown-plugin-gjsify/shims/console-gjs` rewrite, which
136
+ // `--app gjs` cannot express any other way: GJS defines `globalThis.console`
137
+ // non-writable and non-configurable, so assigning to it from a register module
138
+ // no-ops. It also has to stay in lockstep with `mergeTransformOptions` in the
139
+ // plugin's auto-globals analyser — when the two merge the same options
140
+ // differently, the detector measures a bundle that is not the one that ships.
141
+ if (base.transform?.inject || overrides.transform?.inject) {
142
+ out.transform.inject = { ...base.transform?.inject, ...overrides.transform?.inject };
143
+ }
133
144
  }
134
145
  if (base.resolve || overrides.resolve) {
135
146
  out.resolve = { ...base.resolve, ...overrides.resolve };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The intra-repo pins a publish is about to ship, enumerated from the manifest
3
+ * PAIR — the workspace's own `package.json` and the rewritten one that comes
4
+ * back out of the tarball.
5
+ *
6
+ * Why the pair and not just the rewritten manifest: after
7
+ * `rewriteWorkspaceDeps` (see `commands/pack.ts`) a sibling pin is an ordinary
8
+ * `^0.51.1` and indistinguishable from a third-party dependency. The `workspace:`
9
+ * prefix survives only in the SOURCE manifest, so which edges are this repo's
10
+ * own is a fact that exists in one file and the version they will ship at exists
11
+ * in the other.
12
+ */
13
+ /** The blocks `rewriteWorkspaceDeps` rewrites and a consumer install resolves. */
14
+ export type DependencyBlock = 'dependencies' | 'optionalDependencies' | 'peerDependencies';
15
+ /**
16
+ * The one block whose unresolvable edge fails EVERY consumer install.
17
+ *
18
+ * Measured, npm 11.17.0, against the live registry, on an edge to a name that
19
+ * did not exist: `optionalDependencies` installs clean (exit 0, silently),
20
+ * `dependencies` is `npm error 404`, exit 1. Yarn refuses both (`YN0035`),
21
+ * which is why an optional edge is still reported — just not fatal.
22
+ */
23
+ export declare const REQUIRED_BLOCK: DependencyBlock;
24
+ export interface WorkspacePin {
25
+ name: string;
26
+ /** The concrete range the tarball will carry, after the rewrite. */
27
+ spec: string;
28
+ block: DependencyBlock;
29
+ }
30
+ /**
31
+ * Every edge that was a `workspace:` spec before the rewrite, with the version
32
+ * it will ship at.
33
+ *
34
+ * A name appearing in two blocks yields two pins: `dependencies` and
35
+ * `optionalDependencies` do not cost the same at install time, so collapsing
36
+ * them would throw away the distinction the caller decides on.
37
+ */
38
+ export declare function collectWorkspacePins(source: Record<string, unknown>, rewritten: Record<string, unknown>): WorkspacePin[];
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The intra-repo pins a publish is about to ship, enumerated from the manifest
3
+ * PAIR — the workspace's own `package.json` and the rewritten one that comes
4
+ * back out of the tarball.
5
+ *
6
+ * Why the pair and not just the rewritten manifest: after
7
+ * `rewriteWorkspaceDeps` (see `commands/pack.ts`) a sibling pin is an ordinary
8
+ * `^0.51.1` and indistinguishable from a third-party dependency. The `workspace:`
9
+ * prefix survives only in the SOURCE manifest, so which edges are this repo's
10
+ * own is a fact that exists in one file and the version they will ship at exists
11
+ * in the other.
12
+ */
13
+ const BLOCKS = ['dependencies', 'optionalDependencies', 'peerDependencies'];
14
+ /**
15
+ * The one block whose unresolvable edge fails EVERY consumer install.
16
+ *
17
+ * Measured, npm 11.17.0, against the live registry, on an edge to a name that
18
+ * did not exist: `optionalDependencies` installs clean (exit 0, silently),
19
+ * `dependencies` is `npm error 404`, exit 1. Yarn refuses both (`YN0035`),
20
+ * which is why an optional edge is still reported — just not fatal.
21
+ */
22
+ export const REQUIRED_BLOCK = 'dependencies';
23
+ const depsOf = (pkg, block) => {
24
+ const value = pkg[block];
25
+ return value && typeof value === 'object' ? value : {};
26
+ };
27
+ /**
28
+ * Every edge that was a `workspace:` spec before the rewrite, with the version
29
+ * it will ship at.
30
+ *
31
+ * A name appearing in two blocks yields two pins: `dependencies` and
32
+ * `optionalDependencies` do not cost the same at install time, so collapsing
33
+ * them would throw away the distinction the caller decides on.
34
+ */
35
+ export function collectWorkspacePins(source, rewritten) {
36
+ const pins = [];
37
+ for (const block of BLOCKS) {
38
+ const before = depsOf(source, block);
39
+ const after = depsOf(rewritten, block);
40
+ for (const [name, spec] of Object.entries(before)) {
41
+ if (typeof spec !== 'string' || !spec.startsWith('workspace:'))
42
+ continue;
43
+ pins.push({ name, spec: after[name] ?? spec, block });
44
+ }
45
+ }
46
+ return pins;
47
+ }
@@ -19,9 +19,42 @@ export type ReadbackProbe = {
19
19
  status?: number;
20
20
  detail: string;
21
21
  };
22
+ /**
23
+ * What the read-back CONCLUDED — the three outcomes a caller may act on, plus
24
+ * the one it may not.
25
+ *
26
+ * `absent` on its own is not a conclusion. The install document not carrying a
27
+ * version has two causes with opposite remedies — the write never landed
28
+ * (re-publish) and the write landed but that document does not serve it yet
29
+ * (wait; a re-publish is answered 409) — and until this type existed both left
30
+ * through one message asserting the first. Observed live on the ts-for-gir
31
+ * v5.1.0 release: `@ts-for-gir/reporter` was recorded by the registry at
32
+ * 21:43:00.983Z and a cache-busted abbreviated read ~40 min later still did not
33
+ * carry 5.1.0, while the full packument's `time` map did; two minutes after
34
+ * that, both agreed.
35
+ */
36
+ export type ReadbackVerdict =
37
+ /** The install document serves `name@version` and advertises a tarball. */
38
+ 'confirmed'
39
+ /** The registry has no record of this version at all — the v0.46.0 shape. */
40
+ | 'not-published'
41
+ /** The registry RECORDS the write; the document npm installs from does not serve it. */
42
+ | 'recorded-not-served'
43
+ /**
44
+ * Nothing was established. A 5xx, a timeout, DNS, an auth wall — or an
45
+ * install document without the version whose registry record could then
46
+ * not be read, which leaves BOTH other verdicts unearned. NEVER "published".
47
+ */
48
+ | 'unknown';
22
49
  export interface ReadbackResult {
23
50
  /** True iff a probe saw the exact version with a tarball on it. */
24
51
  confirmed: boolean;
52
+ /** What the read-back concluded. `confirmed === (verdict === 'confirmed')`. */
53
+ verdict: ReadbackVerdict;
54
+ /** One line naming the evidence the verdict rests on — it goes in the report. */
55
+ verdictDetail: string;
56
+ /** `time[version]` from the registry's own record, when the corroboration read one. */
57
+ recordedAt?: string;
25
58
  /** Probes actually sent (≥ 1). */
26
59
  attempts: number;
27
60
  elapsedMs: number;
@@ -80,29 +113,42 @@ export declare function backoffFor(round: number): number;
80
113
  */
81
114
  export declare function probeTimeoutFor(configuredMs: number, remainingMs: number): number;
82
115
  /**
83
- * Default polling budget: 300 s.
116
+ * Default polling budget: 600 s.
84
117
  *
85
- * The measured maximum lag between a 2xx and the registry recording the version
86
- * was 251.7 s (@gjsify/child_process, v0.46.0), so a budget below ~4.2 minutes
87
- * turns npm's normal queueing into a false red — and a false red at release time
88
- * costs a manual re-run of a workflow that did its job. 300 s clears that
89
- * maximum and is still short enough that a genuinely lost write is reported by
90
- * the job that lost it rather than by a later sweep.
118
+ * The lag between a 2xx and the registry recording the version has been measured
119
+ * on two releases, and the second broke the first's ceiling. v0.46.0 (199
120
+ * packages): 19 late writes, maximum 251.7 s (@gjsify/child_process). ts-for-gir
121
+ * v5.1.0 (12 packages, run 34899018849, PUT lines against `time["5.1.0"]`):
122
+ * four recorded ~0.7 s BEFORE the 2xx, eight after it — +74, +74, +75, +75,
123
+ * +96, +248, +248 and +368 s (@ts-for-gir/reporter) — the last past the 300 s
124
+ * this constant used to be, so "above the measured maximum" had quietly
125
+ * stopped being true of it. A budget below the lag turns npm's normal queueing into a false
126
+ * red, and a false red at release time costs a manual re-run of a workflow that
127
+ * did its job. A budget above it costs nothing on the nine in ten that confirm
128
+ * on the first probe; all it delays is the report of a genuinely LOST write —
129
+ * one in 199, measured — by the difference. 600 s is ~1.6x the larger maximum.
91
130
  *
92
- * THAT MAXIMUM IS A SAMPLE MAXIMUM, and the sample does not look truncated by
93
- * nature. The 19 lags fall in buckets ~20-30 s apart — 56(2), 76(3), 96(3),
131
+ * BOTH MAXIMA ARE SAMPLE MAXIMA, and neither sample looks truncated by nature.
132
+ * The v0.46.0 lags fall in buckets ~20-30 s apart — 56(2), 76(3), 96(3),
94
133
  * 127(4), 157(3), 189(1), 251(3) — and the TOP bucket is as populated as the
95
134
  * middle ones, which is the shape of a distribution cut off by the observation
96
- * rather than one decaying to zero. One release is one sample, so a lag past
97
- * 300 s is not ruled out; what makes 300 s affordable anyway is that the
98
- * remediation is verified rather than assumed. The re-run this reports lands on
99
- * the 409 path, and that path READS BACK too (see the conflict branch in
100
- * `commands/publish.ts`), so a window that turns out to be too short costs one
101
- * re-run that confirms — it does not hand back an unverified success.
135
+ * rather than one decaying to zero; the next release then produced a value past
136
+ * it. So a lag past 600 s is not ruled out either. What makes any finite window
137
+ * affordable is that the remediation is verified rather than assumed: the
138
+ * re-run this reports lands on the 409 path, and that path READS BACK too (see
139
+ * the conflict branch in `commands/publish.ts`), so a window that turns out too
140
+ * short costs one re-run that confirms — never an unverified success.
141
+ *
142
+ * `time[version]` is when the registry RECORDED the write, a lower bound on
143
+ * when the document npm installs from SERVES it: the reporter above was
144
+ * recorded at +368 s and a cache-busted read of its install document still
145
+ * lacked 5.1.0 ~40 min later. No budget waits that out, and none should — that
146
+ * state is what the `recorded-not-served` verdict names.
102
147
  */
103
- export declare const DEFAULT_VERIFY_BUDGET_MS = 300000;
148
+ export declare const DEFAULT_VERIFY_BUDGET_MS = 600000;
104
149
  /**
105
- * Poll the registry until `name@version` resolves, or the budget runs out.
150
+ * Poll the registry until `name@version` resolves, or the budget runs out, then
151
+ * say WHICH of the three states it found (see {@link ReadbackVerdict}).
106
152
  *
107
153
  * No side effects, no exits, no printing beyond the injected `log` — the caller
108
154
  * owns presentation, same contract as {@link import('./publish-diagnose.js')}.
@@ -130,3 +176,43 @@ export declare function formatUnconfirmedPublish(opts: {
130
176
  */
131
177
  claim?: 'accepted' | 'already-published';
132
178
  }): string;
179
+ /** Whether the registry serves a packument for a NAME at all. */
180
+ export type NamePresence = {
181
+ state: 'present';
182
+ status: number;
183
+ versions: number;
184
+ } | {
185
+ state: 'absent';
186
+ status: number;
187
+ detail: string;
188
+ }
189
+ /**
190
+ * We do not KNOW — a 5xx, a timeout, an auth wall. A caller that blocks a
191
+ * publish on this must treat it as "not established", never as "absent":
192
+ * the whole point of the guard is that it cannot manufacture a red.
193
+ */
194
+ | {
195
+ state: 'unknown';
196
+ status?: number;
197
+ detail: string;
198
+ };
199
+ export interface ProbePackageNameInput {
200
+ registry: string;
201
+ name: string;
202
+ probeTimeoutMs?: number;
203
+ authorization?: string;
204
+ fetchImpl?: typeof fetch;
205
+ }
206
+ /**
207
+ * Ask ONE question: does this NAME exist on the registry at all.
208
+ *
209
+ * Deliberately not a version question, and that is what makes it safe to gate a
210
+ * publish on. A version is eventually consistent — measured on v0.46.0, 19 of
211
+ * 199 packages were recorded 56-252 s AFTER their own 2xx — so "is name@version
212
+ * there" answers "not yet" for minutes and a gate built on it would manufacture
213
+ * a red on a publish that worked. Presence of the NAME does not move once the
214
+ * first version lands, which is the same reason
215
+ * `verify-published-closure.mjs --phase pre-release` asks it rather than the
216
+ * version: an answer that does not move when the train moves.
217
+ */
218
+ export declare function probePackageName(input: ProbePackageNameInput): Promise<NamePresence>;