@gjsify/cli 0.52.0 → 0.54.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 (107) hide show
  1. package/dist/cli.gjs.mjs +351 -305
  2. package/lib/actions/build.d.ts +22 -0
  3. package/lib/actions/build.js +37 -9
  4. package/lib/bundler-pick.d.ts +72 -1
  5. package/lib/bundler-pick.js +175 -17
  6. package/lib/cli-app.js +7 -2
  7. package/lib/commands/blueprint/index.d.ts +4 -0
  8. package/lib/commands/blueprint/index.js +15 -0
  9. package/lib/commands/blueprint/types.d.ts +8 -0
  10. package/lib/commands/blueprint/types.js +124 -0
  11. package/lib/commands/devtools.d.ts +21 -0
  12. package/lib/commands/devtools.js +146 -0
  13. package/lib/commands/env.d.ts +31 -0
  14. package/lib/commands/env.js +114 -0
  15. package/lib/commands/exec.d.ts +10 -0
  16. package/lib/commands/exec.js +208 -0
  17. package/lib/commands/foreach.js +5 -97
  18. package/lib/commands/format.js +26 -6
  19. package/lib/commands/index.d.ts +5 -0
  20. package/lib/commands/index.js +5 -0
  21. package/lib/commands/install.d.ts +2 -2
  22. package/lib/commands/install.js +5 -5
  23. package/lib/commands/self-update.js +2 -1
  24. package/lib/commands/ship.js +51 -8
  25. package/lib/commands/test.d.ts +18 -9
  26. package/lib/commands/test.js +56 -11
  27. package/lib/commands/tsc.js +4 -4
  28. package/lib/commands/upgrade.d.ts +15 -0
  29. package/lib/commands/upgrade.js +76 -2
  30. package/lib/commands/webext/index.d.ts +2 -0
  31. package/lib/commands/webext/index.js +15 -0
  32. package/lib/commands/webext/subcommands.d.ts +16 -0
  33. package/lib/commands/webext/subcommands.js +204 -0
  34. package/lib/types/config-data.d.ts +1 -1
  35. package/lib/utils/bin-shim.d.ts +8 -2
  36. package/lib/utils/bin-shim.js +6 -5
  37. package/lib/utils/bundle-inputs.d.ts +84 -0
  38. package/lib/utils/bundle-inputs.js +268 -0
  39. package/lib/utils/check-system-deps.d.ts +4 -2
  40. package/lib/utils/check-system-deps.js +21 -26
  41. package/lib/utils/dev-link.d.ts +9 -6
  42. package/lib/utils/dev-link.js +71 -19
  43. package/lib/utils/exec-bin.d.ts +137 -0
  44. package/lib/utils/exec-bin.js +321 -0
  45. package/lib/utils/gi-runtime-paths.js +3 -2
  46. package/lib/utils/gitignore.d.ts +15 -0
  47. package/lib/utils/gitignore.js +178 -0
  48. package/lib/utils/gjsify-shim.d.ts +47 -0
  49. package/lib/utils/gjsify-shim.js +97 -8
  50. package/lib/utils/install-backend-native.d.ts +19 -1
  51. package/lib/utils/install-backend-native.js +93 -7
  52. package/lib/utils/install-lock.js +4 -25
  53. package/lib/utils/native-skip-scan.d.ts +20 -0
  54. package/lib/utils/native-skip-scan.js +343 -0
  55. package/lib/utils/node-script.js +1 -1
  56. package/lib/utils/oxc-resolve.js +46 -5
  57. package/lib/utils/process-table.d.ts +22 -0
  58. package/lib/utils/process-table.js +188 -0
  59. package/lib/utils/prune-prefix.js +1 -1
  60. package/lib/utils/run-gjs.d.ts +15 -1
  61. package/lib/utils/run-gjs.js +16 -7
  62. package/lib/utils/run-node.d.ts +2 -0
  63. package/lib/utils/run-node.js +2 -1
  64. package/lib/utils/runtimes.js +14 -13
  65. package/lib/utils/ship/app-runtime.d.ts +7 -5
  66. package/lib/utils/ship/app-runtime.js +11 -8
  67. package/lib/utils/ship/appimage.d.ts +9 -0
  68. package/lib/utils/ship/appimage.js +2 -1
  69. package/lib/utils/ship/deb.d.ts +10 -0
  70. package/lib/utils/ship/deb.js +7 -0
  71. package/lib/utils/ship/depends.d.ts +37 -3
  72. package/lib/utils/ship/depends.js +21 -9
  73. package/lib/utils/ship/gi-namespaces.d.ts +16 -0
  74. package/lib/utils/ship/gi-namespaces.js +64 -1
  75. package/lib/utils/ship/gzip.js +1 -1
  76. package/lib/utils/ship/icons.d.ts +6 -0
  77. package/lib/utils/ship/icons.js +6 -5
  78. package/lib/utils/ship/launcher.js +2 -2
  79. package/lib/utils/ship/layout.js +25 -2
  80. package/lib/utils/ship/rpm.d.ts +5 -0
  81. package/lib/utils/ship/rpm.js +42 -0
  82. package/lib/utils/ship/settings.js +6 -0
  83. package/lib/utils/ship/signing.d.ts +7 -2
  84. package/lib/utils/ship/signing.js +59 -32
  85. package/lib/utils/ship/stage-manifest.d.ts +14 -0
  86. package/lib/utils/ship/stage-manifest.js +7 -0
  87. package/lib/utils/spawn.d.ts +13 -0
  88. package/lib/utils/spawn.js +10 -5
  89. package/lib/utils/watch-loop.d.ts +15 -1
  90. package/lib/utils/watch-loop.js +9 -5
  91. package/lib/utils/webext/build.d.ts +42 -0
  92. package/lib/utils/webext/build.js +120 -0
  93. package/lib/utils/webext/config.d.ts +72 -0
  94. package/lib/utils/webext/config.js +234 -0
  95. package/lib/utils/webext/icons.d.ts +24 -0
  96. package/lib/utils/webext/icons.js +83 -0
  97. package/lib/utils/webext/launch.d.ts +32 -0
  98. package/lib/utils/webext/launch.js +77 -0
  99. package/lib/utils/webext/manifest.d.ts +53 -0
  100. package/lib/utils/webext/manifest.js +219 -0
  101. package/lib/utils/webext/pages.d.ts +26 -0
  102. package/lib/utils/webext/pages.js +61 -0
  103. package/lib/utils/webext/targets.d.ts +13 -0
  104. package/lib/utils/webext/targets.js +48 -0
  105. package/lib/utils/win32-command.d.ts +17 -0
  106. package/lib/utils/win32-command.js +21 -6
  107. package/package.json +20 -19
@@ -9,7 +9,7 @@
9
9
  // matching yarn's interactive flow. Exit code is non-zero if any child
10
10
  // process failed; first failure's stderr is forwarded.
11
11
  import { spawnToCompletion } from '../utils/spawn.js';
12
- import { readFileSync, readdirSync } from 'node:fs';
12
+ import { collectDescendants } from '../utils/process-table.js';
13
13
  import { cpus } from 'node:os';
14
14
  import { affectedClosure, buildDependencyGraph, discoverWorkspaces, filterWorkspaces, topologicalSort, } from '@gjsify/workspace';
15
15
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
@@ -34,101 +34,6 @@ const KILL_GRACE_MS = 5_000;
34
34
  // killed child whose grandchildren inherit (and keep open) the stdio pipe
35
35
  // never emits 'close', so we must not wait on it unboundedly.
36
36
  const DRAIN_DEADLINE_MS = 15_000;
37
- // Read the direct child PIDs of `pid` from /proc/<pid>/task/*/children
38
- // (Linux, CONFIG_PROC_CHILDREN — standard on every mainstream kernel). When
39
- // the per-task children files are unavailable, fall back to one full
40
- // /proc/*/stat scan building a ppid→children map. Both paths use only
41
- // `node:fs` reads, so they behave identically under Node and GJS
42
- // (@gjsify/fs) — unlike a process-group kill, which GJS cannot issue
43
- // (`@gjsify/process.kill` shells out `kill <sig> <pid>` where a negative
44
- // PID parses as an option, and Gio.Subprocess has no group-signal API).
45
- function readDirectChildren(pid) {
46
- let taskIds;
47
- try {
48
- taskIds = readdirSync(`/proc/${pid}/task`);
49
- }
50
- catch {
51
- // Process already gone (common) or /proc unavailable.
52
- return null;
53
- }
54
- const kids = [];
55
- let readAny = false;
56
- for (const tid of taskIds) {
57
- try {
58
- const data = readFileSync(`/proc/${pid}/task/${tid}/children`, 'utf-8');
59
- readAny = true;
60
- for (const tok of data.trim().split(/\s+/)) {
61
- const n = Number(tok);
62
- if (Number.isInteger(n) && n > 0)
63
- kids.push(n);
64
- }
65
- }
66
- catch {
67
- // Thread vanished mid-walk, or kernel lacks CONFIG_PROC_CHILDREN.
68
- }
69
- }
70
- return readAny ? kids : null;
71
- }
72
- // Fallback: scan every /proc/<pid>/stat once and build ppid → children.
73
- // O(#processes) small reads — fine for the rare kill path.
74
- function buildPpidMap() {
75
- const map = new Map();
76
- let entries = [];
77
- try {
78
- entries = readdirSync('/proc');
79
- }
80
- catch {
81
- return map;
82
- }
83
- for (const entry of entries) {
84
- const pid = Number(entry);
85
- if (!Number.isInteger(pid) || pid <= 0)
86
- continue;
87
- try {
88
- const stat = readFileSync(`/proc/${pid}/stat`, 'utf-8');
89
- // comm (field 2) may contain spaces/parens — parse after last ')'.
90
- const rest = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
91
- const ppid = Number(rest[1]);
92
- if (!Number.isInteger(ppid) || ppid <= 0)
93
- continue;
94
- const siblings = map.get(ppid);
95
- if (siblings)
96
- siblings.push(pid);
97
- else
98
- map.set(ppid, [pid]);
99
- }
100
- catch {
101
- // Process exited between readdir and read — fine.
102
- }
103
- }
104
- return map;
105
- }
106
- // Snapshot every transitive descendant of `pid`, breadth-first (so
107
- // reversing the list yields deepest-first order for signalling).
108
- function collectDescendants(pid) {
109
- const result = [];
110
- const seen = new Set([pid]);
111
- const queue = [pid];
112
- let ppidMap = null;
113
- while (queue.length > 0) {
114
- const cur = queue.shift();
115
- let kids = readDirectChildren(cur);
116
- if (kids === null) {
117
- // children files unreadable — use (and lazily build) the
118
- // full-scan fallback for this and subsequent levels.
119
- ppidMap ??= buildPpidMap();
120
- kids = ppidMap.get(cur) ?? [];
121
- }
122
- for (const kid of kids) {
123
- if (seen.has(kid))
124
- continue;
125
- seen.add(kid);
126
- result.push(kid);
127
- queue.push(kid);
128
- }
129
- }
130
- return result;
131
- }
132
37
  // Signal an already-collected descendant set deepest-first, then the direct
133
38
  // child itself. Snapshot-then-signal (not signal-while-walking) so killing
134
39
  // the parent can't orphan grandchildren before we enumerated them. The
@@ -157,7 +62,10 @@ function killActiveChildren() {
157
62
  // The direct child is `npm run <script>` — killing only it leaves the
158
63
  // npm-spawned shell → gjs/node build grandchildren alive (observed: an
159
64
  // orphaned gjs bundler at 100% CPU for 19+ min after a fail-fast kill).
160
- // So terminate the whole process TREE of each child via a /proc walk.
65
+ // So terminate the whole process TREE of each child — walked by
66
+ // `utils/process-table.ts` (procfs on Linux, the OS process list elsewhere).
67
+ // A process-group kill would be simpler and GJS cannot issue one:
68
+ // Gio.Subprocess has no group-signal API.
161
69
  const termSnapshots = new Map();
162
70
  for (const child of activeChildren) {
163
71
  const descendants = child.pid ? collectDescendants(child.pid) : [];
@@ -7,9 +7,10 @@
7
7
  // optionalDependency, keeping the Node-free runtime promise for the bundled
8
8
  // CLI intact (the formatter runs in dev/CI, not in shipped GJS apps).
9
9
  //
10
- // oxfmt formats JS/TS (+TOML) only. CSS/JSON formatting that the old Biome
11
- // toolchain handled is intentionally DROPPED in the oxc migration — no other
12
- // formatter is wired up for those file types.
10
+ // oxfmt formats JS/TS, JSON, TOML, CSS and GraphQL in both builds; the
11
+ // languages it hands to its Prettier host (Markdown, HTML, Vue, YAML, …) are
12
+ // napi-only — the native GJS build drops them, which the skip scan below
13
+ // reports.
13
14
  //
14
15
  // A BARE `gjsify format` WRITES. `--check` is the read-only CI mode and
15
16
  // `--no-write` the read-only local one; there is no flagless report mode.
@@ -24,11 +25,12 @@
24
25
  // tuned for GJS/GNOME projects (4-space, single-quote, printWidth 120,
25
26
  // trailing-comma all, semicolons always, arrow parens always).
26
27
  import { existsSync, writeFileSync } from 'node:fs';
27
- import { resolve } from 'node:path';
28
- import { OxcNotFoundError, findOxfmtConfig, loadOxfmtTemplate, loadOxlintTemplate, printOxcNotFound, runOxfmt, setOxcExitCode, } from '../utils/oxc-resolve.js';
28
+ import { relative, resolve } from 'node:path';
29
+ import { OxcNotFoundError, findOxfmtConfig, loadOxfmtTemplate, loadOxlintTemplate, printOxcNotFound, runOxfmt, setOxcExitCode, shouldUseNativeOxfmt, } from '../utils/oxc-resolve.js';
30
+ import { scanForNativeSkips } from '../utils/native-skip-scan.js';
29
31
  export const formatCommand = {
30
32
  command: 'format [paths..]',
31
- description: 'Format JS/TS source files via oxfmt (CSS/JSON formatting is not supported).',
33
+ description: 'Format source files via oxfmt (Markdown/YAML/HTML/Vue need the napi build).',
32
34
  builder: (yargs) => {
33
35
  return yargs
34
36
  .positional('paths', {
@@ -90,6 +92,24 @@ export const formatCommand = {
90
92
  if (configPath)
91
93
  oxfmtArgs.push('--config', resolve(configPath));
92
94
  oxfmtArgs.push(...paths);
95
+ // Under GJS the native oxfmt formats everything but the files napi oxfmt
96
+ // hands to its Prettier host (Markdown, HTML, Vue, YAML, …), and it drops
97
+ // those without a word. `--check` FAILS on them, so a green check that
98
+ // checked nothing is impossible — asked about exactly the files oxfmt
99
+ // itself would walk with this config, never `node_modules` or ignored ones.
100
+ if (args.check && (await shouldUseNativeOxfmt())) {
101
+ const scan = scanForNativeSkips(paths, cwd, { configPath });
102
+ if (scan.skipped.length > 0) {
103
+ const list = scan.skipped.map((f) => ` ${relative(cwd, f) || f}`).join('\n');
104
+ console.error(`[gjsify format] ERROR: ${scan.skipped.length} file(s) skipped — ` +
105
+ `the native oxfmt (GJS) has no Prettier host for them (Markdown/HTML/Vue/YAML/…):\n` +
106
+ `${list}\n` +
107
+ ` The format check is INCOMPLETE. Run \`gjsify format --check\` under Node to check these ` +
108
+ `files, or add them to \`ignorePatterns\`.`);
109
+ setOxcExitCode(1);
110
+ return;
111
+ }
112
+ }
93
113
  try {
94
114
  const code = await runOxfmt(oxfmtArgs, { cwd, verbose: args.verbose });
95
115
  setOxcExitCode(code);
@@ -1,6 +1,8 @@
1
1
  export * from './build.js';
2
2
  export * from './clear.js';
3
3
  export * from './copy.js';
4
+ export * from './env.js';
5
+ export * from './exec.js';
4
6
  export * from './test.js';
5
7
  export * from './run.js';
6
8
  export * from './info.js';
@@ -39,4 +41,7 @@ export * from './affected.js';
39
41
  export * from './storybook.js';
40
42
  export * from './dev.js';
41
43
  export * from './debug.js';
44
+ export * from './devtools.js';
42
45
  export * from './browse.js';
46
+ export { webextCommand } from './webext/index.js';
47
+ export { blueprintCommand, blueprintTypesCommand } from './blueprint/index.js';
@@ -1,6 +1,8 @@
1
1
  export * from './build.js';
2
2
  export * from './clear.js';
3
3
  export * from './copy.js';
4
+ export * from './env.js';
5
+ export * from './exec.js';
4
6
  export * from './test.js';
5
7
  export * from './run.js';
6
8
  export * from './info.js';
@@ -39,4 +41,7 @@ export * from './affected.js';
39
41
  export * from './storybook.js';
40
42
  export * from './dev.js';
41
43
  export * from './debug.js';
44
+ export * from './devtools.js';
42
45
  export * from './browse.js';
46
+ export { webextCommand } from './webext/index.js';
47
+ export { blueprintCommand, blueprintTypesCommand } from './blueprint/index.js';
@@ -83,8 +83,8 @@ export interface EnsureProjectGjsEngineDeps {
83
83
  *
84
84
  * The engine is an OPTIONAL PEER of `@gjsify/cli` on purpose — a plain
85
85
  * `npm install @gjsify/cli` on Node must not fetch prebuilds — but nobody resolves
86
- * it for a project: npm 7+ skips optional peers and the native backend does not
87
- * resolve `peerDependencies` at all. Under GJS there is no npm `rolldown`
86
+ * it for a project: npm 7+ skips optional peers and so does the native backend
87
+ * (it installs REQUIRED peers only). Under GJS there is no npm `rolldown`
88
88
  * fallback, so `gjsify build` had nothing to load.
89
89
  *
90
90
  * Four conditions, and (b) is the one worth reading:
@@ -10,7 +10,7 @@
10
10
  // `node_modules/` and symlinks `workspace:` refs to their source. Members with
11
11
  // conflicting ranges of the same external still share ONE hoisted copy; the
12
12
  // resolver warns naming both ranges, both requesters and the winner (per-member
13
- // dedup is the open Phase D.8 item in status/open-todos.md).
13
+ // dedup is the open Phase D.8 item in status/open-todos/README.md).
14
14
  //
15
15
  // Concurrency (ADR 0001): every prefix mutation runs under a per-prefix
16
16
  // cross-process lock (utils/install-lock.ts), so installs into different
@@ -641,7 +641,7 @@ function syncLockfileRequested(cwd, specs) {
641
641
  *
642
642
  * Hoisting is deliberately minimal — members with conflicting ranges of the same
643
643
  * dep share one hoisted copy and the resolver warns. Per-member dedup and nested
644
- * `node_modules/` are the open Phase D.8 item in status/open-todos.md.
644
+ * `node_modules/` are the open Phase D.8 item in status/open-todos/README.md.
645
645
  */
646
646
  async function workspaceInstall(cwd, args, signal) {
647
647
  // Held for the WHOLE flow: bin shims, workspace symlinks and the external
@@ -1272,7 +1272,7 @@ async function installGlobalAndLink(specs, opts) {
1272
1272
  // A global install of `@gjsify/cli` must also lay down the GJS bundler engine
1273
1273
  // + the sibling format/CSS bridges. They are OPTIONAL PEERS of the CLI (so a
1274
1274
  // plain `npm install @gjsify/cli` on Node does not force a prebuild) and the
1275
- // native backend does not resolve peerDependencies at all, so without this
1275
+ // native backend, like npm ≥ 7, installs required peers only, so without this
1276
1276
  // they never arrive and `gjsify build` hard-fails under GJS with "no usable
1277
1277
  // bundler engine". Pinned to the CLI's resolved version so they move in
1278
1278
  // lockstep with the bundle; a platform with no published prebuild warns.
@@ -1333,8 +1333,8 @@ function reportLinkedBins(created, binDir) {
1333
1333
  *
1334
1334
  * The engine is an OPTIONAL PEER of `@gjsify/cli` on purpose — a plain
1335
1335
  * `npm install @gjsify/cli` on Node must not fetch prebuilds — but nobody resolves
1336
- * it for a project: npm 7+ skips optional peers and the native backend does not
1337
- * resolve `peerDependencies` at all. Under GJS there is no npm `rolldown`
1336
+ * it for a project: npm 7+ skips optional peers and so does the native backend
1337
+ * (it installs REQUIRED peers only). Under GJS there is no npm `rolldown`
1338
1338
  * fallback, so `gjsify build` had nothing to load.
1339
1339
  *
1340
1340
  * Four conditions, and (b) is the one worth reading:
@@ -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
  });
@@ -45,7 +45,7 @@ import { compileSchemasForPayload, compileSchemasForStage } from '../utils/ship/
45
45
  import { buildMsi, MSI_PAYLOAD_DIR } from '../utils/ship/msi.js';
46
46
  import { buildZip, zipEntriesFromPayload } from '../utils/ship/zip.js';
47
47
  import { assertEntryRunsUnder } from '../utils/ship/entry-interpreter.js';
48
- import { scanGiNamespaces } from '../utils/ship/gi-namespaces.js';
48
+ import { scanGiRequirements } from '../utils/ship/gi-namespaces.js';
49
49
  import { assertLauncherMatchesInterpreter, assertPayloadMatchesArch, buildTimestamp, isArchIndependent, linuxInstallDependent, readPayloadFacts, } from '../utils/ship/payload.js';
50
50
  import { assertOverlayIsLicensed, planOverlay, planStage } from '../utils/ship/plan.js';
51
51
  import { assertHostCanSign, notarizeArtifact, stapleArtifact, resolveNotaryPlan, resolveSignPlan, signPayload, } from '../utils/ship/signing.js';
@@ -537,9 +537,20 @@ async function assemble(args) {
537
537
  // `gi://` specifiers are what the emitted `Depends:` is derived from
538
538
  // (ADR 0024 § 6), and the packing host has the staged copy but no way to
539
539
  // tell which staged file is the entry.
540
- const namespaces = scanGiNamespaces(bundleSource);
541
- if (args.verbose)
540
+ const gi = scanGiRequirements(bundleSource);
541
+ const namespaces = gi.namespaces;
542
+ const optionalNamespaces = gi.optional;
543
+ if (args.verbose) {
544
+ const hard = namespaces.filter((ns) => !optionalNamespaces.includes(ns));
542
545
  console.log(`${LOG} gi namespaces: ${namespaces.join(', ') || '(none)'}`);
546
+ // The split is worth its own line whenever it is non-empty: an author who
547
+ // wrote `&optional` and sees the namespace under the plain list has learned
548
+ // nothing about whether their package will declare it.
549
+ if (optionalNamespaces.length > 0) {
550
+ console.log(`${LOG} gi optional namespaces: ${optionalNamespaces.join(', ')}`);
551
+ console.log(`${LOG} gi required namespaces: ${hard.join(', ') || '(none)'}`);
552
+ }
553
+ }
543
554
  // PLACED, like the payload, and through the same map. `planOverlay` answers a
544
555
  // question about the FORMAT (`share/doc/<pkg>/copyright` for Debian policy,
545
556
  // `share/licenses/<pkg>/LICENSE` for rpm) in the prefix-relative shape every
@@ -578,8 +589,9 @@ async function assemble(args) {
578
589
  // into a Debian `Depends:`, at exit 0).
579
590
  if (format.depends === null)
580
591
  continue;
581
- deriveDepends(format.depends, {
592
+ const derived = deriveDepends(format.depends, {
582
593
  namespaces,
594
+ optionalNamespaces,
583
595
  hasIcons: facts.hasIcons,
584
596
  hasSchemas: facts.hasSchemas,
585
597
  interpreter: settings.app,
@@ -589,6 +601,17 @@ async function assemble(args) {
589
601
  minGjsVersion: settings.minGjsVersion,
590
602
  minNodeVersion: settings.minNodeVersion,
591
603
  });
604
+ // Reported here and not inside `deriveDepends`, which is also called by
605
+ // `packOne` on a host that has no project — and this is the only place that
606
+ // does. An optional namespace nobody mapped produces NO package field, and
607
+ // the app runs without the typelib either way, so nothing downstream will
608
+ // ever say so again.
609
+ for (const namespace of derived.unmappedOptional) {
610
+ console.warn(`${LOG} gi://${namespace} is imported optionally, but no ${format.depends} package is known ` +
611
+ 'to ship its typelib, so no Recommends entry names it. The package builds and runs ' +
612
+ `without it. Add the row if the namespace should be recommended: \`gjsify.ship.typelibPackages.` +
613
+ `${namespace} = { "${format.depends}": "…" }\`.`);
614
+ }
592
615
  // One warning per package, for the interpreter it actually declares. A
593
616
  // floor warning about a dependency this package does not emit is noise,
594
617
  // and a noisy warning is the one nobody reads when it matters.
@@ -602,7 +625,16 @@ async function assemble(args) {
602
625
  // is sometimes packable elsewhere and sometimes not is a worse contract
603
626
  // than one that always is, and it is what lets the e2e suite compare the
604
627
  // one-process artifact with the two-phase one.
605
- const manifest = writeStageManifest({ stageDir, settings, formats, mtime, namespaces, staged, overlay });
628
+ const manifest = writeStageManifest({
629
+ stageDir,
630
+ settings,
631
+ formats,
632
+ mtime,
633
+ namespaces,
634
+ optionalNamespaces,
635
+ staged,
636
+ overlay,
637
+ });
606
638
  if (args.stage) {
607
639
  // `(none)` spelled out, because an empty list printed as nothing after
608
640
  // "formats " reads as a truncated line rather than as a fact. WHICH fact
@@ -638,6 +670,7 @@ async function assemble(args) {
638
670
  stageDir,
639
671
  outRoot,
640
672
  namespaces,
673
+ optionalNamespaces,
641
674
  mtime,
642
675
  sign,
643
676
  notarize,
@@ -799,6 +832,9 @@ async function finishFromStage(args, fromStage) {
799
832
  for (const file of manifest.staged)
800
833
  console.log(`${LOG} ${file.path}`);
801
834
  console.log(`${LOG} gi namespaces: ${manifest.namespaces.join(', ') || '(none)'}`);
835
+ if ((manifest.optionalNamespaces ?? []).length > 0) {
836
+ console.log(`${LOG} gi optional namespaces: ${manifest.optionalNamespaces?.join(', ')}`);
837
+ }
802
838
  }
803
839
  // The stage's own directory is not written to — `writeStage` wipes what it
804
840
  // is pointed at, and pointing it at an arriving stage would delete the
@@ -832,6 +868,7 @@ async function finishFromStage(args, fromStage) {
832
868
  stageDir,
833
869
  outRoot,
834
870
  namespaces: manifest.namespaces,
871
+ optionalNamespaces: manifest.optionalNamespaces ?? [],
835
872
  mtime,
836
873
  sign,
837
874
  notarize,
@@ -858,10 +895,11 @@ async function packOne(input) {
858
895
  // this answers used to be answered from the project's file lists, which are
859
896
  // absolute paths on the build host and therefore unavailable here.
860
897
  const facts = readPayloadFacts(assembled);
861
- const depends = format.depends === null
862
- ? []
898
+ const derived = format.depends === null
899
+ ? { requires: [], recommends: [], unmappedOptional: [] }
863
900
  : deriveDepends(format.depends, {
864
901
  namespaces: input.namespaces,
902
+ optionalNamespaces: input.optionalNamespaces,
865
903
  hasIcons: facts.hasIcons,
866
904
  hasSchemas: facts.hasSchemas,
867
905
  interpreter: settings.app,
@@ -871,6 +909,8 @@ async function packOne(input) {
871
909
  minGjsVersion: settings.minGjsVersion,
872
910
  minNodeVersion: settings.minNodeVersion,
873
911
  });
912
+ const depends = derived.requires;
913
+ const recommends = derived.recommends;
874
914
  // `--arch` is a CLAIM about the payload and nothing else compared it to one:
875
915
  // an x86-64 `.so` staged with `--arch arm64` packed at exit 0 and `rpm -qp
876
916
  // --qf %{ARCH}` confirmed `aarch64`, because rpm reads the header and the
@@ -956,7 +996,9 @@ async function packOne(input) {
956
996
  })
957
997
  : assembled;
958
998
  const archLabel = format.archName(input.arch, isArchIndependent(payload));
959
- const common = { settings, payload, prefix: format.prefix, depends, archLabel, mtime };
999
+ // `recommends` rides the shared input object, so a format that does not write the
1000
+ // field (every non-deb/rpm row) simply ignores an extra property it never reads.
1001
+ const common = { settings, payload, prefix: format.prefix, depends, recommends, archLabel, mtime };
960
1002
  const outDir = join(outRoot, 'out');
961
1003
  mkdirSync(outDir, { recursive: true });
962
1004
  const target = join(outDir, format.fileName(settings, archLabel));
@@ -1010,6 +1052,7 @@ async function packOne(input) {
1010
1052
  minGjsVersion: settings.minGjsVersion,
1011
1053
  minNodeVersion: settings.minNodeVersion,
1012
1054
  namespaces: input.namespaces,
1055
+ optionalNamespaces: input.optionalNamespaces,
1013
1056
  bundledTypelibs: facts.bundledTypelibs,
1014
1057
  });
1015
1058
  const appDir = appDirFor(outRoot);
@@ -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
  *