@gjsify/cli 0.44.0 → 0.46.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 (81) hide show
  1. package/dist/cli.gjs.mjs +202 -167
  2. package/lib/commands/gettext.d.ts +2 -1
  3. package/lib/commands/gettext.js +105 -47
  4. package/lib/commands/onboard.d.ts +7 -0
  5. package/lib/commands/onboard.js +349 -96
  6. package/lib/commands/ship.d.ts +3 -0
  7. package/lib/commands/ship.js +850 -58
  8. package/lib/commands/trust.d.ts +6 -0
  9. package/lib/commands/trust.js +1 -1
  10. package/lib/commands/upgrade.d.ts +23 -0
  11. package/lib/commands/upgrade.js +95 -12
  12. package/lib/templates/install.mjs.tmpl +2 -2
  13. package/lib/types/config-data.d.ts +127 -1
  14. package/lib/utils/gi-search-path.js +11 -3
  15. package/lib/utils/msgfmt-merge.d.ts +35 -0
  16. package/lib/utils/msgfmt-merge.js +102 -0
  17. package/lib/utils/npm-otp.d.ts +27 -2
  18. package/lib/utils/npm-otp.js +82 -7
  19. package/lib/utils/onboard-discovery.d.ts +68 -0
  20. package/lib/utils/onboard-discovery.js +178 -0
  21. package/lib/utils/onboard-probe.d.ts +118 -1
  22. package/lib/utils/onboard-probe.js +153 -4
  23. package/lib/utils/prompt-output.d.ts +12 -0
  24. package/lib/utils/prompt-output.js +54 -0
  25. package/lib/utils/prompt.js +22 -2
  26. package/lib/utils/publishable-packages.d.ts +1 -1
  27. package/lib/utils/publishable-packages.js +1 -0
  28. package/lib/utils/resolve-npm-package.d.ts +56 -0
  29. package/lib/utils/resolve-npm-package.js +69 -0
  30. package/lib/utils/ship/app-runtime.d.ts +214 -0
  31. package/lib/utils/ship/app-runtime.js +429 -0
  32. package/lib/utils/ship/depends.d.ts +50 -0
  33. package/lib/utils/ship/depends.js +117 -3
  34. package/lib/utils/ship/discover.js +103 -1
  35. package/lib/utils/ship/dmg.d.ts +71 -0
  36. package/lib/utils/ship/dmg.js +181 -0
  37. package/lib/utils/ship/flatpak.js +1 -1
  38. package/lib/utils/ship/formats.d.ts +108 -7
  39. package/lib/utils/ship/formats.js +778 -15
  40. package/lib/utils/ship/launcher.d.ts +13 -3
  41. package/lib/utils/ship/launcher.js +419 -17
  42. package/lib/utils/ship/layout.d.ts +330 -0
  43. package/lib/utils/ship/layout.js +504 -0
  44. package/lib/utils/ship/localize-metadata.d.ts +22 -0
  45. package/lib/utils/ship/localize-metadata.js +126 -0
  46. package/lib/utils/ship/mime.js +1 -9
  47. package/lib/utils/ship/msi.d.ts +126 -0
  48. package/lib/utils/ship/msi.js +434 -0
  49. package/lib/utils/ship/node-runtime.d.ts +71 -0
  50. package/lib/utils/ship/node-runtime.js +122 -0
  51. package/lib/utils/ship/payload.d.ts +156 -5
  52. package/lib/utils/ship/payload.js +599 -14
  53. package/lib/utils/ship/pe-launcher.d.ts +29 -0
  54. package/lib/utils/ship/pe-launcher.js +663 -0
  55. package/lib/utils/ship/plan.d.ts +21 -0
  56. package/lib/utils/ship/plan.js +55 -7
  57. package/lib/utils/ship/plist.d.ts +98 -0
  58. package/lib/utils/ship/plist.js +256 -0
  59. package/lib/utils/ship/rpm.js +16 -3
  60. package/lib/utils/ship/schemas.d.ts +61 -0
  61. package/lib/utils/ship/schemas.js +118 -0
  62. package/lib/utils/ship/scripts.js +7 -3
  63. package/lib/utils/ship/settings.d.ts +91 -2
  64. package/lib/utils/ship/settings.js +93 -1
  65. package/lib/utils/ship/share-dirs.d.ts +53 -0
  66. package/lib/utils/ship/share-dirs.js +76 -0
  67. package/lib/utils/ship/signing.d.ts +311 -0
  68. package/lib/utils/ship/signing.js +506 -0
  69. package/lib/utils/ship/stage-manifest.d.ts +40 -12
  70. package/lib/utils/ship/stage-manifest.js +99 -13
  71. package/lib/utils/ship/stage-writer.d.ts +48 -0
  72. package/lib/utils/ship/stage-writer.js +91 -2
  73. package/lib/utils/ship/types.d.ts +191 -14
  74. package/lib/utils/ship/xml.d.ts +7 -0
  75. package/lib/utils/ship/xml.js +25 -0
  76. package/lib/utils/ship/zip.d.ts +69 -0
  77. package/lib/utils/ship/zip.js +245 -0
  78. package/lib/utils/system-gi.d.ts +20 -5
  79. package/lib/utils/system-gi.js +36 -13
  80. package/lib/utils/trust-registry.js +5 -0
  81. package/package.json +19 -19
@@ -20,6 +20,12 @@ export interface TrustRequestResult {
20
20
  status: number;
21
21
  json: unknown;
22
22
  text: string;
23
+ /**
24
+ * Response headers, so a caller can honour npm's own `Retry-After` on a 429
25
+ * instead of guessing a backoff. Optional because test doubles construct this
26
+ * shape by hand and a missing header is a well-defined "it said nothing".
27
+ */
28
+ headers?: Headers;
23
29
  }
24
30
  /** A `(method, url, body?) => TrustRequestResult` function bound to a registry auth + OTP context. */
25
31
  export type TrustRequester = (method: string, url: string, body?: unknown) => Promise<TrustRequestResult>;
@@ -279,7 +279,7 @@ export function createTrustRequester(ctx) {
279
279
  catch {
280
280
  /* non-JSON body — leave json undefined */
281
281
  }
282
- return { status: res.status, json, text };
282
+ return { status: res.status, json, text, headers: res.headers };
283
283
  };
284
284
  }
285
285
  function firstLine(s) {
@@ -1,3 +1,4 @@
1
+ import { type DependencyGroup } from '../utils/dep-aggregation.js';
1
2
  import type { Command } from '../types/index.js';
2
3
  interface UpgradeOptions {
3
4
  latest?: boolean;
@@ -8,10 +9,32 @@ interface UpgradeOptions {
8
9
  excludeWorkspace?: string;
9
10
  align?: boolean;
10
11
  check?: boolean;
12
+ exact?: boolean;
11
13
  dryRun?: boolean;
12
14
  cwd?: string;
13
15
  verbose?: boolean;
14
16
  yes?: boolean;
15
17
  }
16
18
  export declare const upgradeCommand: Command<unknown, UpgradeOptions>;
19
+ /**
20
+ * `--exact` half of the CI gate: does every matched dep carry a bare version?
21
+ *
22
+ * Consistency and exactness are different questions, and the first cannot answer the
23
+ * second: a repository where every manifest agrees on `^4.1.0` is perfectly consistent
24
+ * and still lets a minor release move a subpath under a lockfile-less install. That is
25
+ * the hazard ADR 0029 § Risks 1 names for `@girs/*`, whose `./vocabulary` export is what
26
+ * `@gjsify/gtk-host` consumes — so the pin has to be the whole version, and something
27
+ * has to say so on every PR.
28
+ *
29
+ * Returns the number of offending declarations so the caller can exit once for both arms.
30
+ */
31
+ export declare function reportInexactRanges(groups: readonly DependencyGroup[]): number;
32
+ /**
33
+ * The range a dependency will be WRITTEN at.
34
+ *
35
+ * Preview and write share this deliberately. They were two expressions for one decision
36
+ * for exactly one revision, and `--dry-run --exact` announced `^4.3.0` while the writer
37
+ * would have produced `4.3.0` — a tool describing a change it was not about to make.
38
+ */
39
+ export declare function targetRange(prefix: string, version: string, exact: boolean): string;
17
40
  export {};
@@ -79,6 +79,11 @@ export const upgradeCommand = {
79
79
  description: 'CI gate: exit non-zero when any dep is declared inconsistently across workspaces. Implies offline (no registry calls). Pairs with `--align` as the fix.',
80
80
  type: 'boolean',
81
81
  default: false,
82
+ })
83
+ .option('exact', {
84
+ description: 'Pin without a range operator. When writing, emits `1.2.3` instead of `^1.2.3`; with `--check`, fails on any matched dep that carries one. Pair with `--filter` — a repository-wide exactness check fails on every ordinary caret dep by design.',
85
+ type: 'boolean',
86
+ default: false,
82
87
  })
83
88
  .option('dry-run', {
84
89
  description: 'Print the upgrade plan without writing package.json files.',
@@ -118,7 +123,7 @@ export const upgradeCommand = {
118
123
  const groups = groupByDependency(decls);
119
124
  // --check: exit non-zero if any inconsistency.
120
125
  if (args.check) {
121
- return runCheckMode(groups);
126
+ return runCheckMode(groups, args.exact);
122
127
  }
123
128
  // --align: offline consistency-only, no registry calls.
124
129
  if (args.align) {
@@ -163,10 +168,10 @@ export const upgradeCommand = {
163
168
  if (args.dryRun) {
164
169
  const fileCount = uniqueLocations(selected).length;
165
170
  console.log(`[gjsify upgrade] --dry-run: would update ${selected.length} deps across ${fileCount} package.json file(s).`);
166
- printChangePlan(selected);
171
+ printChangePlan(selected, args.exact);
167
172
  return;
168
173
  }
169
- const files = applyToFiles(selected);
174
+ const files = applyToFiles(selected, args.exact);
170
175
  console.log(`✏️ updated ${selected.length} dep(s) across ${files} package.json file(s). Run \`gjsify install\` to apply.`);
171
176
  },
172
177
  };
@@ -281,10 +286,53 @@ function splitRange(range) {
281
286
  return { prefix, version: parsed?.version ?? null };
282
287
  }
283
288
  // ─── Modes: --check / --align ──────────────────────────────────────────
284
- function runCheckMode(groups) {
289
+ /**
290
+ * `--exact` half of the CI gate: does every matched dep carry a bare version?
291
+ *
292
+ * Consistency and exactness are different questions, and the first cannot answer the
293
+ * second: a repository where every manifest agrees on `^4.1.0` is perfectly consistent
294
+ * and still lets a minor release move a subpath under a lockfile-less install. That is
295
+ * the hazard ADR 0029 § Risks 1 names for `@girs/*`, whose `./vocabulary` export is what
296
+ * `@gjsify/gtk-host` consumes — so the pin has to be the whole version, and something
297
+ * has to say so on every PR.
298
+ *
299
+ * Returns the number of offending declarations so the caller can exit once for both arms.
300
+ */
301
+ export function reportInexactRanges(groups) {
302
+ const offenders = groups
303
+ .map((g) => ({ name: g.name, loose: g.occurrences.filter((o) => o.prefix !== '') }))
304
+ .filter((entry) => entry.loose.length > 0);
305
+ if (offenders.length === 0)
306
+ return 0;
307
+ const total = offenders.reduce((n, entry) => n + entry.loose.length, 0);
308
+ console.error(`gjsify upgrade --check --exact: FAIL. ${total} declaration(s) across ${offenders.length} dep(s) carry a range operator:\n`);
309
+ for (const entry of offenders) {
310
+ const byRange = new Map();
311
+ for (const occ of entry.loose) {
312
+ const list = byRange.get(occ.currentRange) ?? [];
313
+ list.push(occ.workspace);
314
+ byRange.set(occ.currentRange, list);
315
+ }
316
+ console.error(` ${ANSI.bold}${entry.name}${ANSI.reset}`);
317
+ for (const [range, holders] of byRange.entries()) {
318
+ const shown = holders.length > 6 ? `${holders.slice(0, 6).join(', ')} … +${holders.length - 6}` : holders.join(', ');
319
+ console.error(` ${range.padEnd(16)} — ${shown}`);
320
+ }
321
+ }
322
+ return total;
323
+ }
324
+ function runCheckMode(groups, exact = false) {
325
+ const inexact = exact ? reportInexactRanges(groups) : 0;
285
326
  const inconsistencies = findInconsistencies(groups);
327
+ if (inconsistencies.length === 0 && inexact > 0) {
328
+ console.error(`\nFix: run \`gjsify upgrade --align --exact\` (offline; drops the operator and keeps the version).`);
329
+ // `return`, not a bare call: under GJS the process does not halt here, and the
330
+ // consistency report below would run on top of the exactness one.
331
+ return process.exit(1);
332
+ }
286
333
  if (inconsistencies.length === 0) {
287
- console.log(`gjsify upgrade --check: OK. ${groups.length} dep(s) consistently declared across workspaces.`);
334
+ console.log(`gjsify upgrade --check: OK. ${groups.length} dep(s) consistently declared across workspaces` +
335
+ (exact ? `, every declaration pinned exactly.` : `.`));
288
336
  return;
289
337
  }
290
338
  console.error(`gjsify upgrade --check: FAIL. ${inconsistencies.length} dep(s) declared at inconsistent ranges:\n`);
@@ -334,13 +382,13 @@ function runAlignMode(groups, args) {
334
382
  console.log(`gjsify upgrade --align: aligning ${updates.length} inconsistent dep(s) to their highest declared version:\n`);
335
383
  for (const u of updates) {
336
384
  const newPrefix = u.occurrences[0]?.prefix ?? '^';
337
- console.log(` ${u.name.padEnd(28)} ranges: ${[...u.declaredRanges].join(', ')} → ${newPrefix}${u.latestVersion}`);
385
+ console.log(` ${u.name.padEnd(28)} ranges: ${[...u.declaredRanges].join(', ')} → ${targetRange(newPrefix, u.latestVersion, args.exact ?? false)}`);
338
386
  }
339
387
  if (args.dryRun) {
340
388
  console.log('\n[gjsify upgrade --align] --dry-run: no files changed.');
341
389
  return;
342
390
  }
343
- const files = applyToFiles(updates);
391
+ const files = applyToFiles(updates, args.exact);
344
392
  console.log(`\n✏️ updated ${updates.length} dep(s) across ${files} package.json file(s).`);
345
393
  }
346
394
  // ─── Registry resolution (group-aware) ─────────────────────────────────
@@ -491,10 +539,10 @@ function renderDeclaredCell(c) {
491
539
  return joined;
492
540
  return joined.slice(0, 29) + '…';
493
541
  }
494
- function printChangePlan(selected) {
542
+ function printChangePlan(selected, exact = false) {
495
543
  for (const u of selected) {
496
544
  for (const occ of u.occurrences) {
497
- console.log(` ${occ.workspace.padEnd(38)} ${u.name.padEnd(28)} ${occ.currentRange.padEnd(12)} → ${occ.prefix}${u.latestVersion}`);
545
+ console.log(` ${occ.workspace.padEnd(38)} ${u.name.padEnd(28)} ${occ.currentRange.padEnd(12)} → ${targetRange(occ.prefix, u.latestVersion, exact)}`);
498
546
  }
499
547
  }
500
548
  }
@@ -543,7 +591,17 @@ async function promptSelection(candidates) {
543
591
  }
544
592
  }
545
593
  // ─── Write-back across all affected workspaces ─────────────────────────
546
- function applyToFiles(updates) {
594
+ /**
595
+ * The range a dependency will be WRITTEN at.
596
+ *
597
+ * Preview and write share this deliberately. They were two expressions for one decision
598
+ * for exactly one revision, and `--dry-run --exact` announced `^4.3.0` while the writer
599
+ * would have produced `4.3.0` — a tool describing a change it was not about to make.
600
+ */
601
+ export function targetRange(prefix, version, exact) {
602
+ return exact ? version : `${prefix}${version}`;
603
+ }
604
+ function applyToFiles(updates, exact = false) {
547
605
  // Group by workspace.location so each package.json is read + written once.
548
606
  const byLocation = new Map();
549
607
  for (const u of updates) {
@@ -559,25 +617,50 @@ function applyToFiles(updates) {
559
617
  const raw = readFileSync(pkgJsonPath, 'utf-8');
560
618
  const pkg = JSON.parse(raw);
561
619
  let changed = false;
620
+ const changedNames = [];
562
621
  for (const g of groups) {
563
622
  const occ = g.occurrences[0];
564
623
  const map = pkg[occ.field];
565
624
  if (!map)
566
625
  continue;
567
- const next = `${occ.prefix}${g.latestVersion}`;
626
+ const next = targetRange(occ.prefix, g.latestVersion, exact);
568
627
  if (map[g.name] !== next) {
569
628
  map[g.name] = next;
570
629
  changed = true;
630
+ changedNames.push(g.name);
571
631
  }
572
632
  }
573
633
  if (changed) {
574
634
  const indent = detectIndent(raw);
575
- writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, indent) + (raw.endsWith('\n') ? '\n' : ''), 'utf-8');
635
+ const rewritten = JSON.stringify(pkg, null, indent) + (raw.endsWith('\n') ? '\n' : '');
636
+ writeFileSync(pkgJsonPath, keepUntouchedLines(raw, rewritten, changedNames), 'utf-8');
576
637
  touched++;
577
638
  }
578
639
  }
579
640
  return touched;
580
641
  }
642
+ /**
643
+ * Take the re-serialised file, but keep every line that is not about a changed dep.
644
+ *
645
+ * A `JSON.parse` → `JSON.stringify` round trip rewrites the WHOLE file, and re-encoding
646
+ * is not identity: measured on this repository, pinning `@girs/*` decoded `\u2014` into
647
+ * an em dash inside three `description` fields nobody asked to touch, so a dependency
648
+ * bump arrived as a diff over unrelated prose. Semantically equal, and still noise a
649
+ * reviewer has to read past.
650
+ *
651
+ * Line-for-line only. Adding or removing a key changes the line count, and then the
652
+ * mapping is not sound — so that case falls back to the plain re-serialisation rather
653
+ * than guessing an alignment.
654
+ */
655
+ function keepUntouchedLines(raw, rewritten, changedNames) {
656
+ const before = raw.split('\n');
657
+ const after = rewritten.split('\n');
658
+ if (before.length !== after.length)
659
+ return rewritten;
660
+ return after
661
+ .map((line, i) => (changedNames.some((name) => line.includes(`"${name}"`)) ? line : before[i]))
662
+ .join('\n');
663
+ }
581
664
  function uniqueLocations(updates) {
582
665
  const set = new Set();
583
666
  for (const u of updates) {
@@ -18,8 +18,8 @@
18
18
  * GJSIFY_INSTALL_BOOTSTRAP_CACHE.
19
19
  */
20
20
 
21
- import GLib from 'gi://GLib';
22
- import Gio from 'gi://Gio';
21
+ import GLib from 'gi://GLib?version=2.0';
22
+ import Gio from 'gi://Gio?version=2.0';
23
23
  import system, { exit } from 'system';
24
24
 
25
25
  /**
@@ -430,7 +430,34 @@ export interface ConfigDataShip extends AppMetadata {
430
430
  release?: string;
431
431
  /** `Maintainer:` / `Packager:` as `Name <email>`. Default: `package.json#author`. */
432
432
  maintainer?: string;
433
- /** Formats to build when `--target` is not given. Default `['deb', 'rpm']`. */
433
+ /**
434
+ * The runtime a shipped artifact runs, PER TARGET — `gjsify.app` is the
435
+ * default and a key here overrides it for one layout.
436
+ *
437
+ * KEYED IN THE `process.platform` SPELLING, like {@link ShipSignOptions} and
438
+ * for the same reason: the value that resolves is chosen by the LAYOUT being
439
+ * assembled — `FormatDescriptor.layoutOs` and the stage manifest's `target.os`
440
+ * — never by the host doing the assembling. `macos` and `windows` are the
441
+ * `gjsify ship <os>` POSITIONAL's spelling, and both `gjsify ship` and
442
+ * `manifest-conformance`'s `ship` rule refuse them BY NAME, because a runtime
443
+ * under a key nothing reads is a target that silently keeps the project-wide
444
+ * answer.
445
+ *
446
+ * `resolveShipApp` (`utils/ship/settings.ts`) is the only reader of this key
447
+ * or of `gjsify.app`, and holds the rest: what one field could not answer,
448
+ * the measurement behind it, and why an absent key means `gjsify.app` and
449
+ * never `Layout.shippedRuntime`.
450
+ */
451
+ app?: ShipAppOptions;
452
+ /**
453
+ * Formats to build when `--target` is not given. Default `['deb', 'rpm']`.
454
+ *
455
+ * A project-level DEFAULT, so it is filtered to the layout `gjsify ship <os>`
456
+ * assembles rather than refused against it — a name here that wraps another
457
+ * OS's layout is dropped, and `gjsify ship` prints which. A `--target` on the
458
+ * command line is a claim about ONE run and is refused instead. Without that
459
+ * split, declaring this key made `gjsify ship darwin --stage` impossible.
460
+ */
434
461
  targets?: string[];
435
462
  /** Output root, relative to the project. Default `'ship'`. */
436
463
  outDir?: string;
@@ -457,6 +484,18 @@ export interface ConfigDataShip extends AppMetadata {
457
484
  * start.
458
485
  */
459
486
  minGjsVersion?: string;
487
+ /**
488
+ * Minimum Node major the emitted dependency asks for, when the payload is a
489
+ * `--app node` bundle. Default `'24'` — the LTS line the
490
+ * `@gjsify/node-runtime-*` packages bundle for macOS and Windows.
491
+ *
492
+ * Lower it only if the bundle genuinely runs on an older Node. The default
493
+ * excludes every current DEB stable/LTS (trixie 20, Ubuntu 24.04 18, Ubuntu
494
+ * 26.04 22), so it makes a `.deb` that apt refuses there rather than one that
495
+ * installs and then dies on the first unsupported syntax — the same trade
496
+ * `minGjsVersion` makes, and `gjsify ship` warns about it either way.
497
+ */
498
+ minNodeVersion?: string;
460
499
  /**
461
500
  * Runtime dependencies appended to the set derived from the bundle's
462
501
  * `gi://` imports — the escape hatch for a namespace the table does not
@@ -506,6 +545,30 @@ export interface ConfigDataShip extends AppMetadata {
506
545
  * produces a package that installs its translations and shows none of them.
507
546
  */
508
547
  localeDir?: string;
548
+ /**
549
+ * A font file, or a directory of them, the application SHIPS (ADR 0038).
550
+ * Default: `data/fonts` when it exists.
551
+ *
552
+ * Staged into `share/fonts/<appId>/`, which is where fontconfig looks: under a
553
+ * `/usr` prefix through the stock `fonts.conf`'s unconditional
554
+ * `<dir>/usr/share/fonts</dir>`, and under every other prefix through
555
+ * `<dir prefix="xdg">fonts</dir>` plus the `XDG_DATA_DIRS` the launcher already
556
+ * exports. The app names the FAMILY in its styles and needs no install-layout
557
+ * knowledge, exactly as it needs none for icons or schemas.
558
+ *
559
+ * This is what a branded application needs: on a GNOME host the family is
560
+ * usually installed anyway, and on macOS, on Windows and on any Linux that is
561
+ * not GNOME it is not — so without shipping it Pango silently substitutes a
562
+ * fallback and the app merely looks wrong, at exit 0.
563
+ *
564
+ * `.woff` / `.woff2` / `.eot` are REFUSED. Whether FreeType opens a web-font
565
+ * wrapper is a BUILD option of the FreeType the app runs against (brotli for
566
+ * woff2, zlib for woff), so it is a property of the host's or the bundle's
567
+ * closure rather than of the file — and a font that resolves on the developer's
568
+ * box and not in the shipped bundle is the exact silent substitution this key
569
+ * exists to prevent. Ship the desktop face.
570
+ */
571
+ fonts?: string;
509
572
  /** Extra payload entries: prefix-relative destination → project-relative source. */
510
573
  extraFiles?: Record<string, string>;
511
574
  /** Arguments the launcher appends before the user's own. */
@@ -519,6 +582,69 @@ export interface ConfigDataShip extends AppMetadata {
519
582
  * NOT move, is `utils/ship/flatpak-config.ts`.
520
583
  */
521
584
  flatpak?: ShipFlatpakOptions;
585
+ /**
586
+ * The signing identity this project uses by default, per OS.
587
+ *
588
+ * `--sign <identity>` on the command line wins. Absent means unsigned, which
589
+ * is the default path and a legitimate output (ADR 0024 § A13).
590
+ */
591
+ sign?: ShipSignOptions;
592
+ }
593
+ /**
594
+ * `gjsify.ship.app` — the shipped runtime per OS, overriding `gjsify.app`.
595
+ *
596
+ * All three keys are present, which is where this differs from
597
+ * {@link ShipSignOptions}: `linux` is absent there because a `.deb` carries no
598
+ * per-file signature, while a Linux artifact very much has a runtime — it is the
599
+ * one that emits `Depends: gjs (>= 1.86)` or `Depends: nodejs (>= 24)`, and the
600
+ * whole point of this table is that stating darwin's answer must not move it.
601
+ *
602
+ * Only `gjs` and `node` are shippable. `browser` has no process to launch and
603
+ * `nativescript` ships as an APK/IPA through another pipeline (ADR 0024 § 4), so
604
+ * either one is refused here exactly as it is on `gjsify.app`.
605
+ */
606
+ export interface ShipAppOptions {
607
+ /** What a `.deb`/`.rpm`/Flatpak depends on and what its launcher execs. */
608
+ linux?: 'gjs' | 'node';
609
+ /** What a `<App>.app` carries. `node` is the only value the macOS formats accept. */
610
+ darwin?: 'gjs' | 'node';
611
+ /** What a Windows program directory carries. `node` is the only value its formats accept. */
612
+ win32?: 'gjs' | 'node';
613
+ }
614
+ /**
615
+ * `gjsify.ship.sign` — an IDENTITY per OS, and never a certificate.
616
+ *
617
+ * KEYED PER OS, and the two keys must not be one field: a Developer ID string
618
+ * and an Authenticode subject name live in different namespaces, and the value
619
+ * that resolves is chosen by the LAYOUT being packed (`FormatDescriptor.layoutOs`
620
+ * and the stage manifest's `target.os`), not by the host doing the packing
621
+ * (ADR 0024 § A12).
622
+ *
623
+ * `linux` is deliberately absent: a `.deb` and an `.rpm` carry no per-file
624
+ * signature — the artifact is signed as a whole by the repository that serves
625
+ * it, with that repository's key. A key here would look like it did something.
626
+ *
627
+ * WHAT IS NOT HERE, and each is a decision rather than an omission:
628
+ * - a certificate, a `.p12` path or a password. `codesign` and `signtool` are
629
+ * handed a NAME and resolve the private key themselves, so `gjsify ship`
630
+ * never sees a secret and nothing on this surface has to be redacted from a
631
+ * log line (§ A14).
632
+ * - an `entitlements` key. § A16 leaves library validation vs. hardened runtime
633
+ * explicitly unmeasured, and a config key for an unmeasured mechanism is a
634
+ * promise the implementation cannot keep.
635
+ * - a notarisation credential. That is a per-RUN account credential, not a
636
+ * machine-held identity, and a config key for one is an invitation to commit
637
+ * it — `--notarize <keychain-profile>` is the only way to pass it.
638
+ */
639
+ export interface ShipSignOptions {
640
+ /** `codesign --sign <identity>`. `'-'` is ad-hoc and needs no developer identity. */
641
+ darwin?: {
642
+ identity?: string;
643
+ };
644
+ /** `signtool sign /n <identity>` — the certificate's SUBJECT NAME in a Windows store. */
645
+ win32?: {
646
+ identity?: string;
647
+ };
522
648
  }
523
649
  /**
524
650
  * The Flatpak knobs `gjsify ship` reads. Every one has a derived default.
@@ -36,9 +36,17 @@ import { detectNativePackages } from './detect-native-packages.js';
36
36
  *
37
37
  * `globalThis.imports?.gi` is both the sanctioned GJS probe spelling
38
38
  * (`.oxlintrc.json`) and the correct CONDITION, so no `isGjs()` beside it: the
39
- * question is "is there a GIRepository to prepend to", and a Node host answers
40
- * no by itself — the required no-op, since on Node the CLI uses the npm
41
- * `rolldown` crate and never wants a prebuild.
39
+ * question is "is there a GIRepository to prepend to", and a plain Node process has
40
+ * none — nothing here to write to, which is the required no-op.
41
+ *
42
+ * NOT because a Node host wants no prebuild. That justification stood here and is
43
+ * false exactly where it matters: on darwin `@gjsify/webkit-native`'s prebuild is
44
+ * the ONLY WebKit any host has (ADR 0022), and ADR 0024 § 4 puts macOS and Windows
45
+ * applications on Node + node-gi. What is true is narrower — the CLI on Node uses
46
+ * the npm `rolldown` crate — and generalising it to "a Node host never wants a
47
+ * prebuild" is what kept every OTHER Node prebuild env-bound until
48
+ * `@gjsify/node-gi`'s `native-prebuilds.js` (ADR 0021 § The Node host). The Node
49
+ * host is covered THERE, in the package that owns a GIRepository, not here.
42
50
  *
43
51
  * `dup_default()` is GLib >= 2.86, probed as a capability rather than caught:
44
52
  * an older GLib simply keeps today's behaviour (the launcher's environment, or
@@ -0,0 +1,35 @@
1
+ /** Which of msgfmt's two template writers to run. */
2
+ export type MsgfmtMergeMode = '--desktop' | '--xml';
3
+ /** One catalogue: the language tag msgfmt is TOLD, and the `.po` holding it. */
4
+ export interface MsgfmtCatalogue {
5
+ /** Passed as `--locale=`. Named explicitly so no `LINGUAS` file is needed — constraint 2. */
6
+ locale: string;
7
+ /** Path to a `.po`. msgfmt reads only this format here: a `.mo` gives `syntax error`, exit 1. */
8
+ po: string;
9
+ }
10
+ export interface MsgfmtMergeOptions {
11
+ mode: MsgfmtMergeMode;
12
+ /** The file the FIRST call substitutes into. Every later call substitutes into the previous output. */
13
+ template: string;
14
+ /** The suffix every intermediate keeps — load-bearing for `--xml`, see constraint 4. */
15
+ extension: string;
16
+ catalogues: readonly MsgfmtCatalogue[];
17
+ /** A directory this function may fill with intermediates; the caller owns its removal. */
18
+ workDir: string;
19
+ /** Called with each msgfmt argv, for `--verbose`. */
20
+ onCall?: (args: readonly string[]) => void;
21
+ }
22
+ /**
23
+ * Fold every catalogue into one file, chaining `msgfmt` once per language.
24
+ *
25
+ * Returns the PATH of the last file written — the caller decides whether to read
26
+ * it, post-process it or copy it. With no catalogues that is `template` itself,
27
+ * so a project with nothing to merge never spawns msgfmt at all.
28
+ *
29
+ * `stdio: 'pipe'` so a failure's stderr survives on the thrown error rather than
30
+ * going to the terminal and leaving the caller with an exit code.
31
+ *
32
+ * Throws when two catalogues name the same locale — constraint 5, which msgfmt
33
+ * itself reports as two successes and one invalid file.
34
+ */
35
+ export declare function mergeCatalogues(options: MsgfmtMergeOptions): string;
@@ -0,0 +1,102 @@
1
+ // Merging gettext catalogues INTO a template — the `msgfmt --desktop` / `--xml` half.
2
+ //
3
+ // Two callers need this and they are not variants of each other: `gjsify gettext`
4
+ // substitutes a template a user wrote, `gjsify ship` substitutes metadata it just
5
+ // rendered. What they share is every constraint below, all five measured against
6
+ // GNU gettext 0.26, and each one fails a DIFFERENT way — two of them silently.
7
+ // Written twice, the silent ones are the copies that lose them.
8
+ //
9
+ // 1. `--desktop` and `--xml` REQUIRE `--template`:
10
+ // `msgfmt: --desktop requires a "--template template" specification`, exit 1.
11
+ // Neither format can be produced from `.po` files alone, so a caller without a
12
+ // template has nothing to ask for.
13
+ //
14
+ // 2. The bulk form (`-d <podir>`) fails SILENTLY. With no `LINGUAS` file beside
15
+ // the `.po` files:
16
+ // msgfmt --desktop --template=app.desktop.in -d po -o out.desktop
17
+ // exit 0 · stderr `po/LINGUAS does not exist` · out.desktop UNTRANSLATED
18
+ // and `desktop-file-validate out.desktop` then exits 0 as well. Exit 0, an
19
+ // English file and a passing oracle is the worst of the three outcomes, which
20
+ // is why this module names each language with `--locale=` instead: that form
21
+ // needs no `LINGUAS` and cannot degrade to a no-op without a non-zero exit.
22
+ //
23
+ // 3. The template and the output must never be the SAME path. msgfmt truncates
24
+ // `--output-file` before it reads the template, so chaining in place destroys
25
+ // it: measured, `--desktop` wrote a 0-byte file and exited 0, `--xml` printed
26
+ // `cannot read <file>: Document is empty` and died on SIGSEGV (exit 139).
27
+ // Hence the numbered intermediates below rather than one file rewritten.
28
+ //
29
+ // 4. `--xml` finds its ITS rules by FILENAME PATTERN, not by reading the document.
30
+ // gettext walks `/usr/share/gettext/its/*.loc`, where each rule pairs a glob
31
+ // with a root element; AppStream's is `pattern="*.metainfo.xml"` +
32
+ // `localName="component"`, so the same bytes named `foo.xml` die with
33
+ // `msgfmt: cannot locate ITS rules for foo.xml` (exit 1). `extension` is
34
+ // therefore part of this function's contract, not a temp-file detail.
35
+ //
36
+ // Stated as "`--xml` needs a `.metainfo.xml` name" the rule would be wrong in
37
+ // the direction that matters next: `shared-mime-info.loc` pairs `pattern="*.xml"`
38
+ // with `localName="mime-info"`, so a MIME package needs no suffix care at all.
39
+ // The constraint belongs to the AppStream rule, not to `--xml`.
40
+ //
41
+ // 5. A locale may appear at most ONCE in a chain. Two calls naming the same
42
+ // `--locale` both APPEND — neither replaces the other's key — and the result
43
+ // is a file BOTH oracles reject, from two exit-0 msgfmt calls:
44
+ // msgfmt --desktop --template=app.desktop.in --locale=de -o d0 a.po # 0
45
+ // msgfmt --desktop --template=d0 --locale=de -o d1 b.po # 0
46
+ // desktop-file-validate d1
47
+ // → error: file contains multiple keys named "Name[de]" # 1
48
+ // appstreamcli validate --no-net x1.metainfo.xml (same shape, --xml)
49
+ // → E: tag-duplicated name (lang=de) · Validation failed # 3
50
+ // So this is worse than constraint 2: not an untranslated file that installs
51
+ // fine, but an INVALID one, produced without a single non-zero exit. It is
52
+ // refused here rather than deduplicated here, because the caller holding two
53
+ // catalogues for one language is the only party that can say which
54
+ // translation wins — `utils/ship/localize-metadata.ts` answers it with
55
+ // `msgcat --use-first`.
56
+ import { execFileSync } from 'node:child_process';
57
+ import { join } from 'node:path';
58
+ /**
59
+ * Fold every catalogue into one file, chaining `msgfmt` once per language.
60
+ *
61
+ * Returns the PATH of the last file written — the caller decides whether to read
62
+ * it, post-process it or copy it. With no catalogues that is `template` itself,
63
+ * so a project with nothing to merge never spawns msgfmt at all.
64
+ *
65
+ * `stdio: 'pipe'` so a failure's stderr survives on the thrown error rather than
66
+ * going to the terminal and leaving the caller with an exit code.
67
+ *
68
+ * Throws when two catalogues name the same locale — constraint 5, which msgfmt
69
+ * itself reports as two successes and one invalid file.
70
+ */
71
+ export function mergeCatalogues(options) {
72
+ let current = options.template;
73
+ // BEFORE the first spawn, so a caller that got this wrong is told rather than
74
+ // handed a half-written chain to clean up. `seen` is a Set of the locale, not
75
+ // of the catalogue: the same language reached through two `.po` files is
76
+ // exactly the case that produces the duplicate key.
77
+ const seen = new Set();
78
+ for (const { locale } of options.catalogues) {
79
+ if (seen.has(locale)) {
80
+ throw new Error(`mergeCatalogues: two catalogues both claim locale "${locale}". msgfmt APPENDS each ` +
81
+ `--locale, so the chain would emit a duplicate key (desktop-file-validate: ` +
82
+ `"multiple keys named Name[${locale}]"; appstreamcli: "tag-duplicated") from two ` +
83
+ 'exit-0 calls. Merge them into one .po first (msgcat --use-first) and pass that.');
84
+ }
85
+ seen.add(locale);
86
+ }
87
+ for (const [index, catalogue] of options.catalogues.entries()) {
88
+ const next = join(options.workDir, `${index}${options.extension}`);
89
+ const args = [
90
+ options.mode,
91
+ `--template=${current}`,
92
+ `--locale=${catalogue.locale}`,
93
+ `--output-file=${next}`,
94
+ catalogue.po,
95
+ ];
96
+ options.onCall?.(args);
97
+ execFileSync('msgfmt', args, { stdio: 'pipe' });
98
+ // Every language after the first merges INTO what the previous one wrote.
99
+ current = next;
100
+ }
101
+ return current;
102
+ }
@@ -31,6 +31,23 @@ export declare class OtpProvider {
31
31
  private cached;
32
32
  private readonly promptFn;
33
33
  private readonly registry;
34
+ /**
35
+ * Bumped every time a NEW code becomes current. Concurrent callers compare the
36
+ * epoch they last saw against this one to tell "my code was rejected" from
37
+ * "somebody already replaced it while I was waiting" — the second needs no
38
+ * prompt, it needs the other one's code.
39
+ */
40
+ private generation;
41
+ /**
42
+ * The prompt currently on screen, if any. THE fix for a sweep that probes with
43
+ * concurrency > 1: `promptLine` puts the terminal in RAW mode and echoes each
44
+ * keystroke by hand (utils/prompt.ts), so two live prompts mean two `data`
45
+ * listeners on the same stdin and every digit is echoed TWICE. Measured on a
46
+ * real 703-package sweep at the default concurrency of 4: five prompts stacked
47
+ * up and the typed code came back as `666644440000999944444444` — four echoes
48
+ * per keystroke. Whoever arrives second awaits this promise instead.
49
+ */
50
+ private pending;
34
51
  /**
35
52
  * @param seed Initial cached code (from `--otp`), or undefined.
36
53
  * @param promptFn How to read a fresh code. Defaults to the TTY-aware
@@ -41,17 +58,25 @@ export declare class OtpProvider {
41
58
  constructor(seed?: string, promptFn?: OtpPromptFn, options?: OtpProviderOptions);
42
59
  /** The currently cached OTP code, if any. */
43
60
  current(): string | undefined;
61
+ /**
62
+ * How many times a new code has become current. Capture it before an attempt
63
+ * and hand it back to {@link refresh} / {@link invalidate} so a concurrent
64
+ * caller neither prompts for a code somebody already typed nor throws that
65
+ * code away on its own stale rejection.
66
+ */
67
+ epoch(): number;
44
68
  /**
45
69
  * Prompt for a fresh code, cache it (in-process + file cache), and return the
46
70
  * trimmed input — `''` when nothing was entered, which callers read as "give up".
47
71
  */
48
- refresh(question?: string): Promise<string>;
72
+ refresh(question?: string, sinceEpoch?: number): Promise<string>;
73
+ private promptOnce;
49
74
  /**
50
75
  * Drop the cached code (in-process AND the cross-invocation file entry) after npm
51
76
  * rejects it, so the NEXT command re-prompts instead of replaying a single-use
52
77
  * code the registry already burned.
53
78
  */
54
- invalidate(): void;
79
+ invalidate(sinceEpoch?: number): void;
55
80
  }
56
81
  export interface OtpRetryOptions {
57
82
  /**