@gjsify/cli 0.51.1 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/cli.gjs.mjs +539 -493
  2. package/lib/actions/build.d.ts +22 -0
  3. package/lib/actions/build.js +37 -9
  4. package/lib/bundler-pick.d.ts +37 -1
  5. package/lib/bundler-pick.js +108 -17
  6. package/lib/cli-app.js +7 -2
  7. package/lib/commands/affected-classify.d.ts +7 -1
  8. package/lib/commands/affected-classify.js +66 -9
  9. package/lib/commands/affected-script-refs.d.ts +20 -0
  10. package/lib/commands/affected-script-refs.js +218 -0
  11. package/lib/commands/affected.js +24 -2
  12. package/lib/commands/env.d.ts +31 -0
  13. package/lib/commands/env.js +114 -0
  14. package/lib/commands/exec.d.ts +10 -0
  15. package/lib/commands/exec.js +208 -0
  16. package/lib/commands/flatpak/diff.js +8 -2
  17. package/lib/commands/flatpak/init.js +4 -1
  18. package/lib/commands/flatpak/sources.js +4 -3
  19. package/lib/commands/flatpak/sync-flathub.d.ts +2 -0
  20. package/lib/commands/flatpak/sync-flathub.js +149 -7
  21. package/lib/commands/flatpak/utils.d.ts +10 -0
  22. package/lib/commands/flatpak/utils.js +10 -0
  23. package/lib/commands/foreach.js +5 -97
  24. package/lib/commands/format.js +26 -6
  25. package/lib/commands/index.d.ts +4 -0
  26. package/lib/commands/index.js +4 -0
  27. package/lib/commands/install.d.ts +2 -2
  28. package/lib/commands/install.js +81 -5
  29. package/lib/commands/link.d.ts +13 -0
  30. package/lib/commands/link.js +243 -0
  31. package/lib/commands/onboard.js +18 -2
  32. package/lib/commands/publish.d.ts +34 -0
  33. package/lib/commands/publish.js +153 -8
  34. package/lib/commands/self-update.js +2 -1
  35. package/lib/commands/ship.js +19 -1
  36. package/lib/commands/test.d.ts +18 -9
  37. package/lib/commands/test.js +56 -11
  38. package/lib/commands/tsc.js +4 -4
  39. package/lib/commands/upgrade.d.ts +15 -0
  40. package/lib/commands/upgrade.js +76 -2
  41. package/lib/commands/webext/index.d.ts +2 -0
  42. package/lib/commands/webext/index.js +15 -0
  43. package/lib/commands/webext/subcommands.d.ts +16 -0
  44. package/lib/commands/webext/subcommands.js +204 -0
  45. package/lib/types/config-data.d.ts +2 -2
  46. package/lib/utils/bin-shim.d.ts +8 -2
  47. package/lib/utils/bin-shim.js +6 -5
  48. package/lib/utils/bundle-inputs.d.ts +84 -0
  49. package/lib/utils/bundle-inputs.js +268 -0
  50. package/lib/utils/check-system-deps.d.ts +5 -19
  51. package/lib/utils/check-system-deps.js +31 -94
  52. package/lib/utils/dev-link.d.ts +225 -0
  53. package/lib/utils/dev-link.js +684 -0
  54. package/lib/utils/exec-bin.d.ts +137 -0
  55. package/lib/utils/exec-bin.js +321 -0
  56. package/lib/utils/gi-runtime-paths.js +3 -2
  57. package/lib/utils/gitignore.d.ts +15 -0
  58. package/lib/utils/gitignore.js +178 -0
  59. package/lib/utils/gjsify-shim.d.ts +47 -0
  60. package/lib/utils/gjsify-shim.js +97 -8
  61. package/lib/utils/hang-watchdog.d.ts +95 -0
  62. package/lib/utils/hang-watchdog.js +194 -0
  63. package/lib/utils/install-backend-native.d.ts +33 -1
  64. package/lib/utils/install-backend-native.js +184 -20
  65. package/lib/utils/install-extraneous.d.ts +90 -0
  66. package/lib/utils/install-extraneous.js +120 -0
  67. package/lib/utils/install-lock.js +4 -25
  68. package/lib/utils/install-tarball-cache.d.ts +37 -9
  69. package/lib/utils/install-tarball-cache.js +59 -14
  70. package/lib/utils/native-skip-scan.d.ts +20 -0
  71. package/lib/utils/native-skip-scan.js +343 -0
  72. package/lib/utils/node-script.js +1 -1
  73. package/lib/utils/normalize-bundler-options.js +11 -0
  74. package/lib/utils/oxc-resolve.js +42 -3
  75. package/lib/utils/process-table.d.ts +22 -0
  76. package/lib/utils/process-table.js +188 -0
  77. package/lib/utils/prune-prefix.js +1 -1
  78. package/lib/utils/publish-pins.d.ts +38 -0
  79. package/lib/utils/publish-pins.js +47 -0
  80. package/lib/utils/publish-readback.d.ts +103 -17
  81. package/lib/utils/publish-readback.js +326 -47
  82. package/lib/utils/run-gjs.d.ts +15 -1
  83. package/lib/utils/run-gjs.js +98 -13
  84. package/lib/utils/run-node.d.ts +2 -0
  85. package/lib/utils/run-node.js +2 -1
  86. package/lib/utils/runtimes.js +14 -13
  87. package/lib/utils/ship/gzip.js +1 -1
  88. package/lib/utils/ship/icns.d.ts +14 -0
  89. package/lib/utils/ship/icns.js +83 -0
  90. package/lib/utils/ship/ico.d.ts +25 -0
  91. package/lib/utils/ship/ico.js +120 -0
  92. package/lib/utils/ship/icons.d.ts +197 -0
  93. package/lib/utils/ship/icons.js +399 -0
  94. package/lib/utils/ship/launcher.js +16 -1
  95. package/lib/utils/ship/layout.d.ts +32 -0
  96. package/lib/utils/ship/layout.js +112 -38
  97. package/lib/utils/ship/msi.d.ts +1 -1
  98. package/lib/utils/ship/msi.js +30 -5
  99. package/lib/utils/ship/pe-launcher.d.ts +18 -0
  100. package/lib/utils/ship/pe-launcher.js +125 -4
  101. package/lib/utils/ship/plan.d.ts +1 -34
  102. package/lib/utils/ship/plan.js +7 -56
  103. package/lib/utils/ship/plist.d.ts +20 -10
  104. package/lib/utils/ship/plist.js +29 -12
  105. package/lib/utils/ship/settings.js +3 -2
  106. package/lib/utils/spawn.d.ts +13 -0
  107. package/lib/utils/spawn.js +10 -5
  108. package/lib/utils/watch-loop.d.ts +15 -1
  109. package/lib/utils/watch-loop.js +9 -5
  110. package/lib/utils/webext/build.d.ts +42 -0
  111. package/lib/utils/webext/build.js +120 -0
  112. package/lib/utils/webext/config.d.ts +72 -0
  113. package/lib/utils/webext/config.js +234 -0
  114. package/lib/utils/webext/icons.d.ts +24 -0
  115. package/lib/utils/webext/icons.js +83 -0
  116. package/lib/utils/webext/launch.d.ts +32 -0
  117. package/lib/utils/webext/launch.js +77 -0
  118. package/lib/utils/webext/manifest.d.ts +53 -0
  119. package/lib/utils/webext/manifest.js +219 -0
  120. package/lib/utils/webext/pages.d.ts +26 -0
  121. package/lib/utils/webext/pages.js +61 -0
  122. package/lib/utils/webext/targets.d.ts +13 -0
  123. package/lib/utils/webext/targets.js +36 -0
  124. package/lib/utils/win32-command.d.ts +17 -0
  125. package/lib/utils/win32-command.js +21 -6
  126. package/package.json +20 -20
@@ -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);
@@ -27,6 +27,7 @@ import { fetchPackument } from '@gjsify/npm-registry';
27
27
  import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
28
28
  import { classifyInstall } from '../utils/install-provenance.js';
29
29
  import { defaultGlobalLayout, globalLayoutIsDefault, hasBundlerEngineInstalled, installGjsEnginePackages, linkGlobalBins, resolveBinOnPath, } from '../utils/install-global.js';
30
+ import { pathWithoutSelfShim } from '../utils/gjsify-shim.js';
30
31
  import { resolveHostPlatform } from '../utils/platform-check.js';
31
32
  import { pruneAfterInstall } from '../utils/prune-prefix.js';
32
33
  const PACKAGE_NAME = '@gjsify/cli';
@@ -210,7 +211,7 @@ export const selfUpdateCommand = {
210
211
  const verdict = verifyPathResolution({
211
212
  binName: BIN_NAME,
212
213
  binDir: layout.binDir,
213
- resolvedBin: resolveBinOnPath(BIN_NAME),
214
+ resolvedBin: resolveBinOnPath(BIN_NAME, { pathValue: pathWithoutSelfShim() }),
214
215
  runningVersion: currentVersion,
215
216
  targetVersion: target,
216
217
  });
@@ -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)
@@ -10,16 +10,25 @@ interface TestOptions {
10
10
  }
11
11
  export declare const testCommand: Command<unknown, TestOptions>;
12
12
  /**
13
- * True when `outfile` is at least as new as every build input of the package
14
- * at `cwd`.
13
+ * True when `outfile` is at least as new as every input of the build that
14
+ * wrote it.
15
15
  *
16
- * The set is {@link packageBuildInputs}, the same one the build cache hashes.
17
- * It used to be `dirname(entry)` — `tests/` for any package whose suite lives
18
- * there, so `src/**` was never walked: a change to the code UNDER TEST left
19
- * the bundle "fresh" and `gjsify test` reran the previous build and reported
20
- * on it. That went both ways inside one dependency bump — a green run
21
- * measuring a version that was no longer installed, then a red one after the
22
- * source had already been repaired (#1651).
16
+ * The set is the union of two answers, because neither covers the question
17
+ * alone: {@link packageBuildInputs} — what may this PACKAGE's build read, the
18
+ * same walk the build cache hashes — and the manifest the build itself wrote
19
+ * beside `outfile` ({@link readBundleInputs}), what this build DID read. The
20
+ * second is what reaches a workspace package through a `node_modules` symlink
21
+ * into a sibling directory: the package walk is blind to it by construction,
22
+ * so an edit there left the bundle "fresh" and the run reported on code that
23
+ * was no longer on disk — a stale green, worse than a slow build. The first is
24
+ * what the walk alone never managed: it was `dirname(entry)`, `tests/` for any
25
+ * package whose suite lives there, so `src/**` was never seen and `gjsify test`
26
+ * reran the previous build and reported on it, in both directions inside one
27
+ * dependency bump (#1651).
28
+ *
29
+ * A missing manifest is NOT an answer: it is the pre-recorder shape, and the
30
+ * package walk then decides alone — never weaker than before, and the next
31
+ * build writes one.
23
32
  *
24
33
  * `outdir` is subtracted as this command's own output; `entry` is added
25
34
  * because `--entry ../shared/test.mts` may point outside the package.
@@ -7,6 +7,7 @@
7
7
  // exit codes.
8
8
  import { existsSync, statSync } from 'node:fs';
9
9
  import { join, resolve, relative, sep } from 'node:path';
10
+ import { newestBundleInputMtimeMs, readBundleInputs, createBundleInputsRecorder } from '../utils/bundle-inputs.js';
10
11
  import { newestDepSignalMtimeMs, newestInputMtimeMs, packageBuildInputs } from '../utils/package-inputs.js';
11
12
  import { isRuntimeAvailable, RUNTIMES } from '../utils/runtimes.js';
12
13
  import { nodeBinary } from '../utils/run-node.js';
@@ -180,13 +181,39 @@ async function buildTestBundle(entry, outfile, runtime, verbose) {
180
181
  // bundler.output.file from the surrounding project don't redirect the
181
182
  // bundle elsewhere.
182
183
  configData.library = { ...configData.library };
184
+ // Record what this build READ, so the next `isFresh` can judge against the
185
+ // bundle's real dependencies rather than this package's tree. A recorder
186
+ // rides the same plugin chain a user's `bundler.plugins` entry does, and
187
+ // the auto-globals pre-build pass therefore feeds it too — a superset of
188
+ // the final graph, which errs towards a rebuild and never towards a stale
189
+ // green. Appended last so it observes whatever the earlier hooks loaded.
190
+ const recorder = createBundleInputsRecorder(outfile);
183
191
  configData.bundler = {
184
192
  ...configData.bundler,
185
193
  input: [entry],
186
194
  output: { ...configData.bundler?.output, file: outfile },
195
+ plugins: [...(configData.bundler?.plugins ?? []), recorder.plugin],
187
196
  };
188
197
  const action = new BuildAction(configData);
189
- await action.start({ app: runtime, library: false });
198
+ // `onWatchFiles` is the half the transform observer cannot reach: a file a
199
+ // plugin read itself and declared (a stylesheet's `@import` chain). The
200
+ // recorder takes the union — npm reports its module set too, the native
201
+ // engine reports nothing but the declarations, and each engine alone would
202
+ // leave a class of input unrecorded.
203
+ await action.start({
204
+ app: runtime,
205
+ library: false,
206
+ onWatchFiles: (watch) => recorder.addWatchFiles(watch),
207
+ });
208
+ // Written AFTER the build resolves, and that ordering is the whole of what
209
+ // a failure leaves behind: a build that fails BEFORE writing an artifact
210
+ // (parse, resolve, a plugin hook) throws out of `start()` and never reaches
211
+ // this line, so the previous manifest still describes the previous bundle.
212
+ // A failure in a POST-WRITE gate is the other case and is not ours to
213
+ // paper over: `actions/build.ts` has already replaced the bundle, the
214
+ // manifest is rewritten beside it, and both then describe the same build —
215
+ // the next run re-runs the failing bundle rather than the one before it.
216
+ recorder.write();
190
217
  }
191
218
  /** Run a single test bundle and reject on non-zero exit. */
192
219
  async function runTestBundle(outfile, runtime) {
@@ -206,16 +233,25 @@ async function runTestBundle(outfile, runtime) {
206
233
  throw new Error(`node exited with ${describeExit(result)}`);
207
234
  }
208
235
  /**
209
- * True when `outfile` is at least as new as every build input of the package
210
- * at `cwd`.
236
+ * True when `outfile` is at least as new as every input of the build that
237
+ * wrote it.
238
+ *
239
+ * The set is the union of two answers, because neither covers the question
240
+ * alone: {@link packageBuildInputs} — what may this PACKAGE's build read, the
241
+ * same walk the build cache hashes — and the manifest the build itself wrote
242
+ * beside `outfile` ({@link readBundleInputs}), what this build DID read. The
243
+ * second is what reaches a workspace package through a `node_modules` symlink
244
+ * into a sibling directory: the package walk is blind to it by construction,
245
+ * so an edit there left the bundle "fresh" and the run reported on code that
246
+ * was no longer on disk — a stale green, worse than a slow build. The first is
247
+ * what the walk alone never managed: it was `dirname(entry)`, `tests/` for any
248
+ * package whose suite lives there, so `src/**` was never seen and `gjsify test`
249
+ * reran the previous build and reported on it, in both directions inside one
250
+ * dependency bump (#1651).
211
251
  *
212
- * The set is {@link packageBuildInputs}, the same one the build cache hashes.
213
- * It used to be `dirname(entry)` — `tests/` for any package whose suite lives
214
- * there, so `src/**` was never walked: a change to the code UNDER TEST left
215
- * the bundle "fresh" and `gjsify test` reran the previous build and reported
216
- * on it. That went both ways inside one dependency bump — a green run
217
- * measuring a version that was no longer installed, then a red one after the
218
- * source had already been repaired (#1651).
252
+ * A missing manifest is NOT an answer: it is the pre-recorder shape, and the
253
+ * package walk then decides alone — never weaker than before, and the next
254
+ * build writes one.
219
255
  *
220
256
  * `outdir` is subtracted as this command's own output; `entry` is added
221
257
  * because `--entry ../shared/test.mts` may point outside the package.
@@ -229,7 +265,16 @@ export function isFresh(outfile, entry, cwd, outdir) {
229
265
  const outdirRel = relative(cwd, outdir).split(sep).join('/');
230
266
  if (outdirRel !== '' && !outdirRel.startsWith('../'))
231
267
  extraOutputs.push(outdirRel);
232
- const newest = Math.max(newestInputMtimeMs(packageBuildInputs(cwd, { extraOutputs })), statSync(entry).mtimeMs,
268
+ const bundleInputs = readBundleInputs(outfile);
269
+ // A build whose engine could report NO watch list never entered a file
270
+ // only a PLUGIN read — a stylesheet's `@import` chain, an inlined
271
+ // `readFileSync` — into the set below, and no mtime comparison can
272
+ // recover it. Judging such a bundle fresh IS the stale green, so the
273
+ // answer is no: rebuild, on every run, and say why. `bundler-pick.ts`
274
+ // prints the skew once; the manifest carries it across processes.
275
+ if (bundleInputs !== null && !bundleInputs.watchList)
276
+ return false;
277
+ const newest = Math.max(newestInputMtimeMs(packageBuildInputs(cwd, { extraOutputs })), bundleInputs === null ? 0 : newestBundleInputMtimeMs(bundleInputs.inputs), statSync(entry).mtimeMs,
233
278
  // A dependency bump changes no file in the package — and that is
234
279
  // the shape #1651 was found in. `node_modules` is far too large
235
280
  // to stat per run, so the lockfile stands in for it.
@@ -128,10 +128,10 @@ export const tscCommand = {
128
128
  // Run upstream `typescript` under Node — the Node fallback.
129
129
  //
130
130
  // `nodeBinary()`, NEVER a bare `process.execPath`: under the GJS bundle
131
- // `process.execPath` is the GJS interpreter (`/proc/self/exe` →
132
- // `gjs-console`), so `spawn(process.execPath, [tsc.js])` hands
133
- // TypeScript's CommonJS CLI to GJS, which dies on the first `module`
134
- // reference — or, where execPath resolves to a gjsify launcher instead,
131
+ // `process.execPath` is the GJS interpreter (`hostExecPath()`:
132
+ // `/proc/self/exe` on Linux, the PATH lookup of `gjs` on macOS), so
133
+ // `spawn(process.execPath, [tsc.js])` hands TypeScript's CommonJS CLI to
134
+ // GJS, which dies on the first `module` reference — or, where execPath resolves to a gjsify launcher instead,
135
135
  // re-executes the CLI with the tsc entry as an unknown argument. Both
136
136
  // were reached from a COLD tree: `@gjsify/tsc`'s `dist/tsc.gjs.mjs` is a
137
137
  // build output, and `build:infra` builds `@gjsify/create-app` (its first
@@ -29,6 +29,21 @@ export declare const upgradeCommand: Command<unknown, UpgradeOptions>;
29
29
  * Returns the number of offending declarations so the caller can exit once for both arms.
30
30
  */
31
31
  export declare function reportInexactRanges(groups: readonly DependencyGroup[]): number;
32
+ /**
33
+ * Check that every lockfile resolution satisfies the ranges declared for it.
34
+ *
35
+ * `gjsify upgrade --check` compares declared ranges against each other across
36
+ * workspaces. It does NOT compare the resolution against any of them, so a
37
+ * lockfile entry that violates every declaration in the repo passes silently.
38
+ *
39
+ * This check reads the lockfile, resolves each entry's version, and verifies
40
+ * it satisfies at least one declared range for that dependency.
41
+ *
42
+ * @param groups dependency groups with declared ranges
43
+ * @param cwd working directory
44
+ * @returns number of violations
45
+ */
46
+ export declare function checkLockfileRangeViolations(groups: readonly DependencyGroup[], cwd: string): number;
32
47
  /**
33
48
  * The range a dependency will be WRITTEN at.
34
49
  *
@@ -36,7 +36,7 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
36
36
  import { join, resolve } from 'node:path';
37
37
  import { homedir } from 'node:os';
38
38
  import { createInterface } from 'node:readline/promises';
39
- import { parse } from '@gjsify/semver';
39
+ import { parse, satisfies } from '@gjsify/semver';
40
40
  import { DEFAULT_REGISTRY, fetchPackument, parseNpmrc } from '@gjsify/npm-registry';
41
41
  import { discoverWorkspaces, filterWorkspaces } from '@gjsify/workspace';
42
42
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
@@ -325,7 +325,81 @@ export function reportInexactRanges(groups) {
325
325
  }
326
326
  return total;
327
327
  }
328
- function runCheckMode(groups, exact = false) {
328
+ /**
329
+ * Check that every lockfile resolution satisfies the ranges declared for it.
330
+ *
331
+ * `gjsify upgrade --check` compares declared ranges against each other across
332
+ * workspaces. It does NOT compare the resolution against any of them, so a
333
+ * lockfile entry that violates every declaration in the repo passes silently.
334
+ *
335
+ * This check reads the lockfile, resolves each entry's version, and verifies
336
+ * it satisfies at least one declared range for that dependency.
337
+ *
338
+ * @param groups dependency groups with declared ranges
339
+ * @param cwd working directory
340
+ * @returns number of violations
341
+ */
342
+ export function checkLockfileRangeViolations(groups, cwd) {
343
+ const lockPath = join(cwd, 'gjsify-lock.json');
344
+ if (!existsSync(lockPath))
345
+ return 0;
346
+ let lock;
347
+ try {
348
+ lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
349
+ }
350
+ catch {
351
+ return 0;
352
+ }
353
+ if (!lock.packages)
354
+ return 0;
355
+ // Build a map: dep name -> set of declared ranges
356
+ const declaredRanges = new Map();
357
+ for (const g of groups) {
358
+ const ranges = declaredRanges.get(g.name) ?? new Set();
359
+ for (const occ of g.occurrences) {
360
+ ranges.add(occ.currentRange);
361
+ }
362
+ declaredRanges.set(g.name, ranges);
363
+ }
364
+ let violations = 0;
365
+ for (const [key, entry] of Object.entries(lock.packages)) {
366
+ if (!entry.version)
367
+ continue;
368
+ // Only check direct dependencies — the single-segment "node_modules/<name>" form.
369
+ // A deep transitive resolution (e.g. "node_modules/a/node_modules/b") is pinned by
370
+ // its parent, not by the root's declared range, and must not be checked against it.
371
+ const parts = key.split('node_modules/');
372
+ if (parts.length !== 2)
373
+ continue;
374
+ const depName = parts[1];
375
+ const ranges = declaredRanges.get(depName);
376
+ if (!ranges || ranges.size === 0)
377
+ continue;
378
+ // Check if the resolved version satisfies at least one declared range
379
+ let satisfied = false;
380
+ for (const range of ranges) {
381
+ try {
382
+ if (entry.version && satisfies(entry.version, range)) {
383
+ satisfied = true;
384
+ break;
385
+ }
386
+ }
387
+ catch {
388
+ // Invalid range, skip
389
+ }
390
+ }
391
+ if (!satisfied) {
392
+ console.error(` ${depName}: lockfile has ${entry.version}, but declared ranges are ${[...ranges].join(', ')}`);
393
+ violations++;
394
+ }
395
+ }
396
+ if (violations > 0) {
397
+ console.error(`gjsify upgrade --check: FAIL. ${violations} lockfile entr${violations === 1 ? 'y' : 'ies'} violate declared ranges:\n`);
398
+ }
399
+ return violations;
400
+ }
401
+ function runCheckMode(groups, exact = false, cwd = process.cwd()) {
402
+ const lockfileViolations = checkLockfileRangeViolations(groups, cwd);
329
403
  const inexact = exact ? reportInexactRanges(groups) : 0;
330
404
  const inconsistencies = findInconsistencies(groups);
331
405
  if (inconsistencies.length === 0 && inexact > 0) {
@@ -0,0 +1,2 @@
1
+ import type { Command } from '../../types/index.js';
2
+ export declare const webextCommand: Command;
@@ -0,0 +1,15 @@
1
+ // `gjsify webext` — yargs subcommand-group dispatcher (ADR 0077).
2
+ //
3
+ // Wires {build, zip, dev}; each is a self-contained `Command<>` in `subcommands.ts`,
4
+ // the same composition `flatpak/index.ts` uses.
5
+ import { webextBuildCommand, webextDevCommand, webextZipCommand } from './subcommands.js';
6
+ export const webextCommand = {
7
+ command: 'webext <subcommand>',
8
+ description: 'Browser extensions: build, zip and develop one WebExtension for Chrome, Edge, Firefox and Safari.',
9
+ builder: (yargs) => yargs
10
+ .command(webextBuildCommand.command, webextBuildCommand.description, webextBuildCommand.builder, webextBuildCommand.handler)
11
+ .command(webextZipCommand.command, webextZipCommand.description, webextZipCommand.builder, webextZipCommand.handler)
12
+ .command(webextDevCommand.command, webextDevCommand.description, webextDevCommand.builder, webextDevCommand.handler)
13
+ .demandCommand(1)
14
+ .strict(),
15
+ };
@@ -0,0 +1,16 @@
1
+ import type { Command } from '../../types/index.js';
2
+ interface WebextArgs {
3
+ target?: string[];
4
+ outDir?: string;
5
+ define: string[];
6
+ mode?: string;
7
+ launch: boolean;
8
+ browserBinary?: string;
9
+ profile?: string;
10
+ headless: boolean;
11
+ debounce: number;
12
+ }
13
+ export declare const webextBuildCommand: Command<unknown, WebextArgs>;
14
+ export declare const webextZipCommand: Command<unknown, WebextArgs>;
15
+ export declare const webextDevCommand: Command<unknown, WebextArgs>;
16
+ export {};