@gjsify/cli 0.51.1 → 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 (61) 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 +19 -1
  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/icns.d.ts +14 -0
  44. package/lib/utils/ship/icns.js +83 -0
  45. package/lib/utils/ship/ico.d.ts +25 -0
  46. package/lib/utils/ship/ico.js +120 -0
  47. package/lib/utils/ship/icons.d.ts +191 -0
  48. package/lib/utils/ship/icons.js +398 -0
  49. package/lib/utils/ship/launcher.js +16 -1
  50. package/lib/utils/ship/layout.d.ts +32 -0
  51. package/lib/utils/ship/layout.js +112 -38
  52. package/lib/utils/ship/msi.d.ts +1 -1
  53. package/lib/utils/ship/msi.js +30 -5
  54. package/lib/utils/ship/pe-launcher.d.ts +18 -0
  55. package/lib/utils/ship/pe-launcher.js +125 -4
  56. package/lib/utils/ship/plan.d.ts +1 -34
  57. package/lib/utils/ship/plan.js +7 -56
  58. package/lib/utils/ship/plist.d.ts +20 -10
  59. package/lib/utils/ship/plist.js +29 -12
  60. package/lib/utils/ship/settings.js +3 -2
  61. package/package.json +20 -20
@@ -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';
@@ -48,7 +48,8 @@ import { buildPublishHeaders, escapePackageName } from '../utils/publish-headers
48
48
  import { packWorkspace } from './pack.js';
49
49
  import { getNpmTrustedToken, hasGithubOidcEnv, OidcExchangeError, OidcUnavailableError } from '../utils/npm-oidc.js';
50
50
  import { diagnose404, is404DiagnosticCandidate } from '../utils/publish-diagnose.js';
51
- import { DEFAULT_VERIFY_BUDGET_MS, formatUnconfirmedPublish, verifyPublishedVersion, } from '../utils/publish-readback.js';
51
+ import { DEFAULT_VERIFY_BUDGET_MS, formatUnconfirmedPublish, probePackageName, verifyPublishedVersion, } from '../utils/publish-readback.js';
52
+ import { collectWorkspacePins, REQUIRED_BLOCK } from '../utils/publish-pins.js';
52
53
  import { loadNpmrc } from '../utils/load-npmrc.js';
53
54
  import { OtpProvider, withOtpRetry, isOtpChallenge } from '../utils/npm-otp.js';
54
55
  import { promptLine } from '../utils/prompt.js';
@@ -81,7 +82,7 @@ export const publishCommand = {
81
82
  default: false,
82
83
  })
83
84
  .option('verify-timeout', {
84
- description: 'Seconds to keep asking the registry for the just-published version before giving up. npm answers 2xx to a write it has only ACCEPTED: in the v0.46.0 release 19 of 199 packages were recorded 56-252s after their 2xx and one was never recorded at all, so a shorter window turns normal queueing into a false red. `0` disables the read-back — for a registry with no packument read path.',
85
+ description: 'Seconds to keep asking the registry for the just-published version before giving up. npm answers 2xx to a write it has only ACCEPTED: in the v0.46.0 release 19 of 199 packages were recorded 56-252s after their 2xx and one was never recorded at all, so a shorter window turns normal queueing into a false red. `0` disables the read-back — for a registry with no packument read path; the success line then reads `UNVERIFIED` and says so, because a publish nothing checked must never look like one that was checked.',
85
86
  type: 'number',
86
87
  default: DEFAULT_VERIFY_BUDGET_MS / 1000,
87
88
  })
@@ -305,6 +306,55 @@ export async function publishWorkspace(input) {
305
306
  // Base headers (npm-command: publish routing header etc.). The OTP is added
306
307
  // per-PUT by `doPut` so `withOtpRetry` can retry with different codes.
307
308
  const headers = buildPublishHeaders(url, { npmrc });
309
+ // 5. Do not PUT a tarball whose required intra-repo pins point at a name
310
+ // this registry does not have (#1713).
311
+ //
312
+ // The question is PRESENCE OF THE NAME, never a version, and that is what
313
+ // makes it safe to gate on: a version is eventually consistent for
314
+ // minutes after its own 2xx, so a version gate would manufacture a red on
315
+ // a publish that worked. A name does not move once its first version
316
+ // lands. `unknown` — a 5xx, a timeout, an auth wall — is not `absent` and
317
+ // never blocks.
318
+ const assumePresent = new Set(input.assumePresent ?? []);
319
+ const pins = collectWorkspacePins(pkg, rewrittenPkg).filter((pin) => !assumePresent.has(pin.name));
320
+ if (pins.length > 0) {
321
+ const probed = await Promise.all(pins.map(async (pin) => ({
322
+ pin,
323
+ presence: await probePackageName({
324
+ registry,
325
+ name: pin.name,
326
+ authorization: headers['authorization'],
327
+ }),
328
+ })));
329
+ const missing = [];
330
+ for (const { pin, presence } of probed) {
331
+ if (presence.state === 'present')
332
+ continue;
333
+ const where = `${pin.block}.${pin.name}@${pin.spec}`;
334
+ if (presence.state === 'unknown') {
335
+ console.error(`gjsify publish: could not establish whether ${pin.name} exists (${presence.detail}) — not blocking on ${where}`);
336
+ continue;
337
+ }
338
+ if (pin.block !== REQUIRED_BLOCK) {
339
+ // npm and pnpm skip an unresolvable optional edge in silence;
340
+ // Yarn refuses the whole install. Loud, not fatal.
341
+ console.error(`gjsify publish: ${where} names a package ${registry} does not have — npm installs around it, Yarn (\`YN0035\`) does not`);
342
+ continue;
343
+ }
344
+ missing.push(pin);
345
+ }
346
+ if (missing.length > 0) {
347
+ return {
348
+ ok: tolerateUntrustedNew,
349
+ action: 'blocked-missing-dependency',
350
+ name: packed.name,
351
+ version: packed.version,
352
+ registry,
353
+ tolerated: tolerateUntrustedNew,
354
+ missing,
355
+ };
356
+ }
357
+ }
308
358
  // Trusted Publishing (OIDC) path — `--trusted` forces it, `undefined`
309
359
  // auto-detects from the GitHub OIDC env. Unused by `gjsify onboard` (local
310
360
  // token auth), preserved verbatim for `gjsify publish` in CI.
@@ -414,7 +464,7 @@ export async function publishWorkspace(input) {
414
464
  //
415
465
  // NOT CONFIRMED YET — the other 19. Here the read-back waits out
416
466
  // npm's own write queue, and those residual lags SUM TO 2554 s: a
417
- // sweep that waited them all out with the 300 s default would have
467
+ // sweep that waited them all out under the fatal default would have
418
468
  // added ~43 min to that 33 min job. That single number decides the
419
469
  // shape below.
420
470
  //
@@ -438,7 +488,7 @@ export async function publishWorkspace(input) {
438
488
  //
439
489
  // Hence BOTH, split along that cost. The read-back runs per package
440
490
  // everywhere and is FATAL by default — that is what turns the
441
- // incident's green single-package job red, for up to 300 s of
491
+ // incident's green single-package job red, for up to 600 s of
442
492
  // patience on a job that otherwise ran 59 s. The
443
493
  // 199-package sweep passes `--verify-defer --verify-timeout 5`: it
444
494
  // still CONFIRMS nine packages in ten and still names the suspect
@@ -458,7 +508,7 @@ export async function publishWorkspace(input) {
458
508
  // decoration: it is the only client-side measurement of when a
459
509
  // version became RESOLVABLE, as against `time[version]`, which is
460
510
  // when the registry recorded the write. The two are assumed equal by
461
- // the 4.2-minute figure and the 300 s window, and nothing had ever
511
+ // the 4.2-minute figure and the default window, and nothing had ever
462
512
  // measured the gap. The pre-registered comparison — and what each
463
513
  // outcome would change — is in #1509's description; this line, under
464
514
  // GJSIFY_PUBLISH_DEBUG (which release.yml sets), is its input. Note
@@ -534,6 +584,7 @@ export async function publishWorkspace(input) {
534
584
  name: packed.name,
535
585
  version: packed.version,
536
586
  status: res.status,
587
+ registry: registryClean,
537
588
  readback,
538
589
  };
539
590
  }
@@ -568,9 +619,68 @@ export async function publishWorkspace(input) {
568
619
  * re-checks the same set afterwards. It is policy, so it lives here and not in
569
620
  * {@link publishWorkspace}, which stays a pure fact-finder.
570
621
  */
622
+ /**
623
+ * What the success line SAYS it established — the half of the read-back that
624
+ * was missing.
625
+ *
626
+ * AN INSTRUMENT STATES WHAT IT MEASURED, and `+ name@version` states nothing.
627
+ * It is the identical string from a CLI that polled the registry and read a
628
+ * tarball back, from one run with `--verify-timeout 0`, and from any
629
+ * `@gjsify/cli` older than v0.47.0, which had no read-back at all. That is not a
630
+ * hypothetical: ts-for-gir's v5.1.0 release (run 34899018849) printed twelve
631
+ * `+ <name>@5.1.0` lines with `GJSIFY_PUBLISH_DEBUG=1` set and not one
632
+ * `response:` or read-back line among them, because the job's
633
+ * `PATH="$WS_PATH/node_modules/.bin:$PATH"` put that workspace's pinned
634
+ * `@gjsify/cli@^0.44.0` ahead of the 0.51.1 it had just bootstrapped. Three of
635
+ * the twelve were not on the registry. The marker could not have told anyone,
636
+ * and nobody could have known to look.
637
+ *
638
+ * So every terminal success marker now carries its verification clause, and an
639
+ * unverified one says UNVERIFIED in a word a log sweep can grep for. A bare
640
+ * `+ name@version` with no clause is now itself the signature of a CLI too old
641
+ * to check.
642
+ */
643
+ function describeVerification(readback, registry) {
644
+ if (!readback?.confirmed) {
645
+ return {
646
+ verified: false,
647
+ clause: '(UNVERIFIED — read-back disabled by --verify-timeout 0)',
648
+ json: null,
649
+ };
650
+ }
651
+ const seconds = (readback.elapsedMs / 1000).toFixed(1);
652
+ return {
653
+ verified: true,
654
+ clause: `(verified on ${registry} — ${readback.attempts} probe(s), ${seconds}s)`,
655
+ json: {
656
+ registry,
657
+ verdict: readback.verdict,
658
+ probes: readback.attempts,
659
+ elapsedMs: readback.elapsedMs,
660
+ tarball: readback.last.state === 'present' ? readback.last.tarball : null,
661
+ },
662
+ };
663
+ }
664
+ /**
665
+ * An unverified SUCCESS gets an Actions annotation too.
666
+ *
667
+ * `--verify-timeout 0` is a legitimate escape hatch for a registry with no
668
+ * packument read path, and it restores exactly the behaviour every incident in
669
+ * this file came out of. A disabled verifier that announces itself is a
670
+ * decision; one that stays quiet is the original defect wearing a hat — and the
671
+ * channel is the one `--verify-defer` already uses, beside the job rather than
672
+ * in a log nobody re-reads. stderr, because `--json` owns stdout.
673
+ */
674
+ function warnUnverified(name, version, registry) {
675
+ if (!process.env.GITHUB_ACTIONS)
676
+ return;
677
+ process.stderr.write(`::warning title=Publish unverified::${name}@${version} was published with the read-back disabled ` +
678
+ `(--verify-timeout 0) — nothing confirmed that ${registry} serves it\n`);
679
+ }
571
680
  function reportPublishOutcome(outcome, asJson, deferUnconfirmed = false) {
572
681
  switch (outcome.action) {
573
682
  case 'published': {
683
+ const verification = describeVerification(outcome.readback, outcome.registry);
574
684
  const out = {
575
685
  ok: true,
576
686
  name: outcome.name,
@@ -580,11 +690,15 @@ function reportPublishOutcome(outcome, asJson, deferUnconfirmed = false) {
580
690
  integrity: outcome.integrity,
581
691
  tag: outcome.tag,
582
692
  registry: outcome.registry,
693
+ verified: verification.verified,
694
+ verification: verification.json,
583
695
  };
584
696
  if (asJson)
585
697
  process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
586
698
  else
587
- process.stdout.write(`+ ${outcome.name}@${outcome.version}\n`);
699
+ process.stdout.write(`+ ${outcome.name}@${outcome.version} ${verification.clause}\n`);
700
+ if (!verification.verified)
701
+ warnUnverified(outcome.name, outcome.version, outcome.registry);
588
702
  return;
589
703
  }
590
704
  case 'publish-unconfirmed': {
@@ -602,6 +716,9 @@ function reportPublishOutcome(outcome, asJson, deferUnconfirmed = false) {
602
716
  elapsedMs: outcome.readback.elapsedMs,
603
717
  readbackUrl: outcome.readback.url,
604
718
  readbackState: outcome.readback.last.state,
719
+ verdict: outcome.readback.verdict,
720
+ verdictDetail: outcome.readback.verdictDetail,
721
+ recordedAt: outcome.readback.recordedAt ?? null,
605
722
  deferred: deferUnconfirmed,
606
723
  }, null, 2)}\n`);
607
724
  }
@@ -619,24 +736,31 @@ function reportPublishOutcome(outcome, asJson, deferUnconfirmed = false) {
619
736
  const claimed = outcome.claim === 'already-published' ? 'was reported already published' : 'was accepted';
620
737
  process.stderr.write(`::warning title=Publish unconfirmed::${outcome.name}@${outcome.version} ${claimed} ` +
621
738
  `(HTTP ${outcome.putStatus}) but did not resolve on ${outcome.registry} within ` +
622
- `${(outcome.readback.elapsedMs / 1000).toFixed(1)}s — the release-closure job must confirm it\n`);
739
+ `${(outcome.readback.elapsedMs / 1000).toFixed(1)}s [${outcome.readback.verdict}] — ` +
740
+ 'the release-closure job must confirm it\n');
623
741
  }
624
742
  return;
625
743
  }
626
744
  return process.exit(1);
627
745
  }
628
746
  case 'republish-tolerated': {
747
+ const verification = describeVerification(outcome.readback, outcome.registry);
629
748
  const out = {
630
749
  ok: true,
631
750
  action: 'republish-tolerated',
632
751
  name: outcome.name,
633
752
  version: outcome.version,
634
753
  status: outcome.status,
754
+ registry: outcome.registry,
755
+ verified: verification.verified,
756
+ verification: verification.json,
635
757
  };
636
758
  if (asJson)
637
759
  process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
638
760
  else
639
- process.stdout.write(`= ${outcome.name}@${outcome.version} (already published, tolerated)\n`);
761
+ process.stdout.write(`= ${outcome.name}@${outcome.version} (already published, tolerated) ${verification.clause}\n`);
762
+ if (!verification.verified)
763
+ warnUnverified(outcome.name, outcome.version, outcome.registry);
640
764
  return;
641
765
  }
642
766
  case 'skipped-untrusted-new': {
@@ -654,6 +778,27 @@ function reportPublishOutcome(outcome, asJson, deferUnconfirmed = false) {
654
778
  }
655
779
  return;
656
780
  }
781
+ case 'blocked-missing-dependency': {
782
+ const edges = outcome.missing.map((m) => `${m.block}.${m.name}@${m.spec}`).join(', ');
783
+ if (asJson) {
784
+ process.stdout.write(`${JSON.stringify({
785
+ ok: outcome.ok,
786
+ action: 'blocked-missing-dependency',
787
+ name: outcome.name,
788
+ version: outcome.version,
789
+ registry: outcome.registry,
790
+ tolerated: outcome.tolerated,
791
+ missing: outcome.missing,
792
+ }, null, 2)}\n`);
793
+ }
794
+ else {
795
+ process.stdout.write(`~ ${outcome.name}@${outcome.version} (NOT published — ${outcome.missing.length} required pin(s) name a package ${outcome.registry} does not have: ${edges})\n`);
796
+ }
797
+ console.error(`gjsify publish: every consumer install of ${outcome.name} would fail with \`npm error 404\`, on every package manager — a required edge is not skippable. Bootstrap the target first (docs/publishing.md § New \`@gjsify/*\` package), then re-run. See #1713.`);
798
+ if (!outcome.tolerated)
799
+ return process.exit(1);
800
+ return;
801
+ }
657
802
  case 'otp-required': {
658
803
  console.error(`gjsify publish: npm requires a 2FA one-time code — re-run with \`--otp <code>\``);
659
804
  return process.exit(1);
@@ -35,6 +35,7 @@ import { discoverPayload } from '../utils/ship/discover.js';
35
35
  import { appDirFor, appDirPayload, appImageHostRequirements, appImageRuntimeNotice, buildAppImage, findPinnedRuntime, stampAppDirTimes, } from '../utils/ship/appimage.js';
36
36
  import { buildDmgImage, dmgVolumeDir, dmgVolumeName } from '../utils/ship/dmg.js';
37
37
  import { buildFlatpakBundle } from '../utils/ship/flatpak.js';
38
+ import { iconSizes, resolveAppIcon } from '../utils/ship/icons.js';
38
39
  import { localizeMetadata } from '../utils/ship/localize-metadata.js';
39
40
  import { assertHostCanFinish, assertKindCanPack, assertToolsInstalled, canCarryTicket, configuredFormats, defaultFormatIds, formatIdsFor, resolveFormats, windowsProgramDirName, FORMAT_IDS, FORMATS, } from '../utils/ship/formats.js';
40
41
  import { assertLayoutSupportsArch, hostLayout, layoutForOs, place, placeStage, resolveLayout, LAYOUT_NAMES, } from '../utils/ship/layout.js';
@@ -433,7 +434,24 @@ async function assemble(args) {
433
434
  workDir: join(outRoot, 'schemas'),
434
435
  })),
435
436
  ];
436
- const staged = placeStage(layout, settings, planned, runtime.files);
437
+ // THE APPLICATION ICON, rendered once for the sizes this layout's writers
438
+ // embed — six for the `.exe`'s resource directory, seven for the `.icns` —
439
+ // and nothing for a CLI or for Linux, where the theme carries the SVG the
440
+ // project declared. `resolveAppIcon` REFUSES an app that has no source for a
441
+ // size rather than staging without it: the released artifacts this closes
442
+ // shipped for a year with the generic icon on two platforms, and the only
443
+ // reason was that nothing said so (`utils/ship/icons.ts`).
444
+ const icon = settings.kind === 'app' && layout.icon !== undefined
445
+ ? await resolveAppIcon({
446
+ iconFiles: settings.iconFiles,
447
+ appId: settings.appId,
448
+ sizes: layout.icon.sizes,
449
+ target: layout.icon.writes,
450
+ })
451
+ : undefined;
452
+ if (icon !== undefined)
453
+ console.log(`${LOG} icon: ${iconSizes(icon).join('/')} px, ${icon.source}`);
454
+ const staged = placeStage(layout, { ...settings, icon }, planned, runtime.files);
437
455
  writeStage(stageDir, staged);
438
456
  console.log(`${LOG} staged ${staged.length} file(s) for ${layout.name} in ${relative(projectDir, stageDir)}/`);
439
457
  if (args.verbose)
@@ -771,7 +771,7 @@ export interface ConfigDataFlatpak extends AppMetadata {
771
771
  command?: string;
772
772
  /** Finish-args (capabilities). Default depends on `runtime` + `--cli-only`. */
773
773
  finishArgs?: string[];
774
- /** Extra Flatpak modules prepended before the app's own meson/simple module (e.g. `blueprint-compiler` build). */
774
+ /** Extra Flatpak modules prepended before the app's own meson/simple module (e.g. a GI library the runtime lacks). */
775
775
  extraModules?: unknown[];
776
776
  /**
777
777
  * Full replacement for the manifest's `modules` array: used verbatim, with