@gjsify/cli 0.48.0 → 0.50.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.
@@ -14,13 +14,16 @@
14
14
  // dependency, in topological build order, then in the target package.
15
15
  // Skips deps that don't declare the script (`--if-present` behaviour).
16
16
  // Stops on the first failure unless `--continue-on-error` is passed.
17
+ // A local dependency counts whether it is declared with the `workspace:`
18
+ // protocol or with a plain range the local member satisfies (@gjsify/workspace
19
+ // § buildDependencyGraph) — and the expansion SAYS when it found nothing.
17
20
  //
18
21
  // This replaces the manual `gjsify workspace <A> build && gjsify workspace
19
22
  // <B> build && …` chains in root `build:infra` scripts: cascade build
20
23
  // failures on uninbuilt workspace deps are the single biggest local-dev
21
24
  // pain point in this monorepo.
22
25
  import { spawnToCompletion } from '../utils/spawn.js';
23
- import { buildDependencyGraph, discoverWorkspaces, topologicalSort } from '@gjsify/workspace';
26
+ import { affectedClosure, buildDependencyGraph, discoverWorkspaces, topologicalSort, } from '@gjsify/workspace';
24
27
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
25
28
  import { BuildCacheRunner, buildCacheEnabledByEnv } from '../utils/build-cache.js';
26
29
  import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
@@ -135,11 +138,29 @@ export const workspaceCommand = {
135
138
  // target itself.
136
139
  let runList;
137
140
  if (withDeps) {
138
- const closure = collectTransitiveClosure(target, allWorkspaces, args['include-dev'] === true);
139
- // Sort the closure topologically using the shared @gjsify/workspace
140
- // algorithm — same code path that drives `gjsify foreach -t`.
141
- const graph = buildDependencyGraph(closure, { includeDev: args['include-dev'] === true });
141
+ const includeDev = args['include-dev'] === true;
142
+ // The SHARED graph, walked by the shared closure — the same two calls
143
+ // `gjsify foreach --with-dependencies` makes. This used to be a private
144
+ // copy of the walk here, and the copy is where the defect lived: it
145
+ // followed `workspace:` edges only, so in a monorepo declaring its local
146
+ // deps by plain semver range it matched nothing, built nothing and exited
147
+ // 0 (#1587). One walk means the rule for "what is a local dependency" has
148
+ // one place to be wrong in.
149
+ const forward = buildDependencyGraph(allWorkspaces, { includeDev });
150
+ const names = affectedClosure(forward, [target.name]);
151
+ const closure = allWorkspaces.filter((ws) => names.has(ws.name));
152
+ reportUnlinked(forward.unlinked, names);
153
+ const graph = buildDependencyGraph(closure, { includeDev });
142
154
  runList = topologicalSort(graph);
155
+ if (runList.length === 1) {
156
+ // The expansion found nothing, and it SAYS so. A leaf package
157
+ // legitimately has no local dependencies, but "no local dependencies"
158
+ // and "the flag could not see them" produced the same silent exit 0,
159
+ // and that ambiguity is what cost a Flatpak build, a container run and
160
+ // a manual `ls` to resolve.
161
+ console.error(`gjsify workspace: --with-dependencies found no workspace dependencies of ` +
162
+ `"${target.name}" — running "${script}" in it alone.`);
163
+ }
143
164
  }
144
165
  else {
145
166
  runList = [target];
@@ -197,48 +218,30 @@ export const workspaceCommand = {
197
218
  },
198
219
  };
199
220
  /**
200
- * Walk the workspace-dep graph from `target`, collecting every transitive
201
- * workspace dependency (production + optional, plus devDependencies when
202
- * `includeDev`). The returned list includes `target` itself so the caller
203
- * can pass it straight to `buildDependencyGraph` / `topologicalSort`.
221
+ * Name a dependency that points at a local workspace but does not link to it, because
222
+ * the member's version does not satisfy the declared range.
223
+ *
224
+ * Only entries declared BY a package in the closure are printed: an unlinked pair
225
+ * elsewhere in the monorepo is somebody else's business, and printing all of them would
226
+ * bury the one that explains why this closure is the size it is.
227
+ *
228
+ * This is a warning, not a failure, and it deliberately asserts NO outcome, because the
229
+ * outcome depends on which installer placed `node_modules`: npm and yarn fetch such a
230
+ * dependency from the registry — the rule the graph follows, under which leaving it out of
231
+ * the build order is simply right — while `gjsify install` symlinks the local member
232
+ * whatever the range says. Under that one the build order really is short a package, so
233
+ * the message asks for the range and the version to be brought into agreement instead of
234
+ * telling the reader where the files came from.
204
235
  */
205
- function collectTransitiveClosure(target, allWorkspaces, includeDev) {
206
- const byName = new Map();
207
- for (const ws of allWorkspaces)
208
- byName.set(ws.name, ws);
209
- const seen = new Set();
210
- const out = [];
211
- const stack = [target];
212
- while (stack.length > 0) {
213
- const ws = stack.pop();
214
- if (seen.has(ws.name))
236
+ function reportUnlinked(unlinked, closure) {
237
+ for (const entry of unlinked) {
238
+ if (!closure.has(entry.from))
215
239
  continue;
216
- seen.add(ws.name);
217
- out.push(ws);
218
- const m = ws.manifest;
219
- const blocks = [
220
- m.dependencies,
221
- includeDev ? m.devDependencies : undefined,
222
- m.optionalDependencies,
223
- // peerDependencies excluded by default (yarn does the same)
224
- ];
225
- for (const block of blocks) {
226
- if (!block)
227
- continue;
228
- for (const [depName, spec] of Object.entries(block)) {
229
- if (typeof spec !== 'string')
230
- continue;
231
- if (!spec.startsWith('workspace:'))
232
- continue;
233
- const dep = byName.get(depName);
234
- if (!dep)
235
- continue;
236
- if (!seen.has(dep.name))
237
- stack.push(dep);
238
- }
239
- }
240
+ console.error(`gjsify workspace: "${entry.from}" depends on "${entry.to}": ${entry.spec}, which the local ` +
241
+ `workspace (${entry.to}@${entry.version}) does not satisfy — NOT built as a dependency. ` +
242
+ `npm and yarn take that one from the registry; \`gjsify install\` links the local one ` +
243
+ `anyway, so if it is the local one you mean, make the range admit ${entry.version}.`);
240
244
  }
241
- return out;
242
245
  }
243
246
  async function runOne(ws, script, extraArgs, runner, verbose, cache) {
244
247
  // Build-cache fast path: on a key hit restore the stored outputs and
package/lib/config.js CHANGED
@@ -5,6 +5,7 @@ import { pathToFileURL } from 'node:url';
5
5
  import { isGjs, hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
6
6
  import { buildAppForRuntime } from './utils/runtimes.js';
7
7
  import { DIALECT_APPS, isSourceDialect, SOURCE_DIALECTS } from '@gjsify/rolldown-plugin-gjsify';
8
+ import { GI_RENDERER_APPS } from '@gjsify/resolve-npm';
8
9
  /**
9
10
  * Does the failed `import()` of a config look like a MODULE-RESOLUTION failure
10
11
  * (the config imports something GJS's ESM loader can't resolve for an external
@@ -269,6 +270,8 @@ export class Config {
269
270
  configData.exclude = cliArgs.exclude || [];
270
271
  if (cliArgs.consoleShim !== undefined)
271
272
  configData.consoleShim = cliArgs.consoleShim;
273
+ if (cliArgs.giRenderer !== undefined)
274
+ configData.giRenderer = cliArgs.giRenderer;
272
275
  // `--dialect` narrows here rather than at the call site. yargs `choices`
273
276
  // already rejects an unknown value typed on the command line, but a
274
277
  // `gjsify.dialect` in package.json or `.gjsifyrc.*` never passes through
@@ -303,6 +306,16 @@ export class Config {
303
306
  `It composes on ${[...DIALECT_APPS].join(' and ')} only. ` +
304
307
  'Drop the dialect, or build one of those targets.');
305
308
  }
309
+ // Same rule for the `gi://` arm, and the same reason: `--gi-renderer --app gjs`
310
+ // would parse, resolve and compose nothing, so the flag would be accepted and
311
+ // ignored. On `gjs` and `node` the specifier is already answered — natively and
312
+ // through `requireGi` — so there is nothing for a renderer to substitute for.
313
+ if (configData.giRenderer === true && !GI_RENDERER_APPS.includes(configData.app)) {
314
+ throw new Error(`gjsify build: --gi-renderer has no effect on --app ${configData.app}. ` +
315
+ `It composes on ${GI_RENDERER_APPS.join(' and ')} only — the targets with no GObject ` +
316
+ 'introspection to answer `gi://` with. `--app gjs` resolves it natively and `--app node` ' +
317
+ 'through @gjsify/node-gi, so neither needs a widget renderer standing in for it.');
318
+ }
306
319
  if (cliArgs.globals !== undefined)
307
320
  configData.globals = cliArgs.globals;
308
321
  // Fallback applied post-merge (not as a yargs `default:`) so a
package/lib/index.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // This file is just the executable wrapper around `runCli`.
7
7
  import { hideBin } from 'yargs/helpers';
8
8
  import { runCli } from './cli-app.js';
9
+ import { exitOnReportedFailure } from './utils/cli-exit.js';
9
10
  import { gjsExit } from '@gjsify/rolldown-plugin-gjsify/runtime';
10
11
  try {
11
12
  await runCli(hideBin(process.argv));
@@ -43,3 +44,12 @@ catch (err) {
43
44
  process.exitCode = 1;
44
45
  gjsExit(1);
45
46
  }
47
+ // The catch above is only HALF the entry's exit funnel, and the other half was
48
+ // missing: a handler that reports a failure by assigning `process.exitCode` and
49
+ // returning normally never reaches it. That is the Node idiom and the CLI uses
50
+ // it wherever the failure is already printed in full (`gjsify gresource`,
51
+ // `gjsify gsettings`, `gjsify barrels --check`, `setOxcExitCode`) — and under
52
+ // GJS, which has no atexit hook, nothing read it back, so the process ended 0
53
+ // with the error on stderr. `cli-exit.ts` carries the measurement and why `&&`
54
+ // did not protect against it (JumpLink/Learn6502#180).
55
+ exitOnReportedFailure();
@@ -36,6 +36,12 @@ export interface CliBuildOptions {
36
36
  * whatever was typed, and the narrowing happens once, where the value is read.
37
37
  */
38
38
  dialect?: string;
39
+ /**
40
+ * Resolve `gi://Ns?version=X` to the target's widget renderer instead of an empty
41
+ * module — `--gi-renderer` (ADR 0034 stage 9). `browser` and `nativescript` targets.
42
+ * Default: unset; see `ConfigData.giRenderer` for why it is opt-in.
43
+ */
44
+ giRenderer?: boolean;
39
45
  /**
40
46
  * Comma-separated global identifiers the code needs, e.g.
41
47
  * `"fetch,Buffer,process,URL,crypto"`. Each maps to the corresponding
@@ -96,6 +96,20 @@ export interface ConfigData {
96
96
  * see the header of `SourceDialect`.
97
97
  */
98
98
  dialect?: SourceDialect;
99
+ /**
100
+ * Resolve `gi://Ns?version=X` to the target's widget renderer rather than to an
101
+ * empty module (ADR 0034 stage 9). `--app browser` answers out of
102
+ * `@gjsify/adwaita-web`, `--app nativescript` out of `@gjsify/adwaita-nativescript`;
103
+ * `GI_RENDERERS` in `@gjsify/resolve-npm` is the table, and a target with no row
104
+ * there has no arm. Default: unset.
105
+ *
106
+ * Opt-in, never inferred: a `gi://` import reaching one of those targets today is
107
+ * usually a GJS-only code path pulled in transitively, which the empty module
108
+ * silences on purpose. On by default it would pull a widget toolkit into bundles
109
+ * that never named one, and turn every unanswerable namespace from a silent stub
110
+ * into a hard build failure.
111
+ */
112
+ giRenderer?: boolean;
99
113
  /** Comma-separated global identifiers to register. Format: see CliBuildOptions. */
100
114
  globals?: string;
101
115
  /**
@@ -11,9 +11,19 @@ import { type Workspace } from '@gjsify/workspace';
11
11
  * still on v2's whole-dir ownership and could restore over a sibling unit; a v2
12
12
  * manifest for them records `lib`, so it must not be replayed against the
13
13
  * per-child units this version stores.
14
+ *
15
+ * v4: the input set is now `packageBuildInputs` instead of `src/**` + the
16
+ * manifests (#1651). Every v3 key was computed over a strictly narrower set,
17
+ * so replaying one would report a hit for a change it could not have seen.
18
+ */
19
+ export declare const BUILD_CACHE_LAYOUT_VERSION = "v4";
20
+ /**
21
+ * Conventional per-package build output directories, checked in this order.
22
+ *
23
+ * Deliberately the same constant the input definition subtracts: a directory
24
+ * this cache STORES as an output must never also be hashed as an input, or
25
+ * storing it would invalidate the key it was stored under.
14
26
  */
15
- export declare const BUILD_CACHE_LAYOUT_VERSION = "v3";
16
- /** Conventional per-package build output directories, checked in this order. */
17
27
  export declare const OUTPUT_DIR_CANDIDATES: readonly ["lib", "dist", "dist-templates"];
18
28
  /** Keep at most this many keys per package; evict the least recently stored/used. */
19
29
  export declare const MAX_KEYS_PER_PACKAGE = 2;
@@ -45,8 +55,9 @@ export declare function sanitizePackageDirName(name: string): string;
45
55
  /** Streamed sha256 of one file (bounded memory, real contents). */
46
56
  export declare function hashFileStream(path: string): string;
47
57
  /**
48
- * Content hash of a package's build INPUTS: every file under `src/**`, plus
49
- * `package.json`, plus root `tsconfig*.json`. Sorted, content-streamed —
58
+ * Content hash of a package's build INPUTS — the set is
59
+ * {@link packageBuildInputs}, shared with `gjsify test`'s freshness check so
60
+ * the two cannot drift about what an input is. Sorted, content-streamed:
50
61
  * mtimes do not participate, so a `git checkout` with identical contents
51
62
  * hashes identically across machines.
52
63
  */
@@ -11,9 +11,9 @@
11
11
  // - a toolchain salt (workspace-resolved versions of @gjsify/cli,
12
12
  // @gjsify/tsc, rolldown, typescript — a bundler/compiler bump must
13
13
  // invalidate everything),
14
- // - the package's own input hash (every file under `src/**`, plus
15
- // `package.json`, plus the package's root `tsconfig*.json` — real file
16
- // contents, streamed sha256, sorted for determinism),
14
+ // - the package's own input hash — `packageBuildInputs`, the ONE definition
15
+ // of "what are this package's build inputs" (`utils/package-inputs.ts`),
16
+ // hashed by real file contents, streamed sha256, sorted for determinism,
17
17
  // - the input hashes of EVERY workspace dependency in the package's
18
18
  // transitive dep closure (prod + dev + optional + peer `workspace:`
19
19
  // edges). Composing the closure's OWN hashes (instead of recursing into
@@ -54,6 +54,7 @@ import { dirname, join } from 'node:path';
54
54
  import { fileURLToPath } from 'node:url';
55
55
  import { buildDependencyGraph } from '@gjsify/workspace';
56
56
  import { replicateLinkSync } from './dir-link.js';
57
+ import { CONVENTIONAL_OUTPUT_DIRS, packageBuildInputs } from './package-inputs.js';
57
58
  /**
58
59
  * Bump to invalidate every existing cache entry (schema/semantic changes).
59
60
  *
@@ -66,10 +67,20 @@ import { replicateLinkSync } from './dir-link.js';
66
67
  * still on v2's whole-dir ownership and could restore over a sibling unit; a v2
67
68
  * manifest for them records `lib`, so it must not be replayed against the
68
69
  * per-child units this version stores.
70
+ *
71
+ * v4: the input set is now `packageBuildInputs` instead of `src/**` + the
72
+ * manifests (#1651). Every v3 key was computed over a strictly narrower set,
73
+ * so replaying one would report a hit for a change it could not have seen.
69
74
  */
70
- export const BUILD_CACHE_LAYOUT_VERSION = 'v3';
71
- /** Conventional per-package build output directories, checked in this order. */
72
- export const OUTPUT_DIR_CANDIDATES = ['lib', 'dist', 'dist-templates'];
75
+ export const BUILD_CACHE_LAYOUT_VERSION = 'v4';
76
+ /**
77
+ * Conventional per-package build output directories, checked in this order.
78
+ *
79
+ * Deliberately the same constant the input definition subtracts: a directory
80
+ * this cache STORES as an output must never also be hashed as an input, or
81
+ * storing it would invalidate the key it was stored under.
82
+ */
83
+ export const OUTPUT_DIR_CANDIDATES = CONVENTIONAL_OUTPUT_DIRS;
73
84
  /** Keep at most this many keys per package; evict the least recently stored/used. */
74
85
  export const MAX_KEYS_PER_PACKAGE = 2;
75
86
  /** Toolchain packages whose resolved versions salt every cache key. */
@@ -113,11 +124,50 @@ export function hashFileStream(path) {
113
124
  }
114
125
  return hash.digest('hex');
115
126
  }
116
- // Recursive, sorted file walk. Yields workspace-relative paths (forward
117
- // slashes). Directory symlinks are NOT followed (loop safety) — the link
118
- // target string participates in the hash instead. `node_modules` and the
119
- // output dirs never appear because callers only walk `src/` + explicit root
120
- // files, but guard anyway for nested oddities.
127
+ /**
128
+ * Content hash of a package's build INPUTS — the set is
129
+ * {@link packageBuildInputs}, shared with `gjsify test`'s freshness check so
130
+ * the two cannot drift about what an input is. Sorted, content-streamed:
131
+ * mtimes do not participate, so a `git checkout` with identical contents
132
+ * hashes identically across machines.
133
+ */
134
+ export function hashPackageInputs(location) {
135
+ const files = packageBuildInputs(location);
136
+ const hash = createHash('sha256');
137
+ hash.update(`gjsify-build-cache-inputs/${BUILD_CACHE_LAYOUT_VERSION}\n`);
138
+ for (const f of files) {
139
+ if (f.link !== undefined) {
140
+ // Symlink: try content (file links), fall back to the target path.
141
+ let content;
142
+ try {
143
+ content = hashFileStream(f.abs);
144
+ }
145
+ catch {
146
+ content = `link:${f.link}`;
147
+ }
148
+ hash.update(`${f.rel} ${content}\n`);
149
+ continue;
150
+ }
151
+ try {
152
+ hash.update(`${f.rel} ${hashFileStream(f.abs)}\n`);
153
+ }
154
+ catch {
155
+ // Vanished mid-hash (concurrent edit) — record the absence so the
156
+ // key still changes vs. the file being present.
157
+ hash.update(`${f.rel} unreadable\n`);
158
+ }
159
+ }
160
+ return hash.digest('hex');
161
+ }
162
+ /**
163
+ * Recursive, sorted walk of an OUTPUT tree, yielding `/`-separated relative
164
+ * paths. Directory symlinks are not followed (loop safety) — the link target
165
+ * string participates in the hash instead.
166
+ *
167
+ * Deliberately NOT `packageBuildInputs`: this one is handed a directory that
168
+ * the input walk subtracts, and it must report everything inside it, dot-files
169
+ * included — an emitted `.gresource` is part of what was built.
170
+ */
121
171
  function walkFiles(dir, prefix, out) {
122
172
  let entries;
123
173
  try {
@@ -140,11 +190,8 @@ function walkFiles(dir, prefix, out) {
140
190
  continue;
141
191
  }
142
192
  if (st.isSymbolicLink()) {
143
- // File symlinks: hash the resolved content below if readable;
144
- // dir symlinks: record the target only.
145
193
  try {
146
- const target = readlinkSync(abs);
147
- out.push({ rel, abs, link: target });
194
+ out.push({ rel, abs, link: readlinkSync(abs) });
148
195
  }
149
196
  catch {
150
197
  /* dropped mid-walk */
@@ -158,58 +205,6 @@ function walkFiles(dir, prefix, out) {
158
205
  }
159
206
  }
160
207
  }
161
- /**
162
- * Content hash of a package's build INPUTS: every file under `src/**`, plus
163
- * `package.json`, plus root `tsconfig*.json`. Sorted, content-streamed —
164
- * mtimes do not participate, so a `git checkout` with identical contents
165
- * hashes identically across machines.
166
- */
167
- export function hashPackageInputs(location) {
168
- const files = [];
169
- const pkgJson = join(location, 'package.json');
170
- if (existsSync(pkgJson))
171
- files.push({ rel: 'package.json', abs: pkgJson });
172
- let rootEntries = [];
173
- try {
174
- rootEntries = readdirSync(location);
175
- }
176
- catch {
177
- /* unreadable package dir — hash degenerates to the empty set */
178
- }
179
- for (const entry of rootEntries.sort()) {
180
- if (/^tsconfig.*\.json$/.test(entry)) {
181
- files.push({ rel: entry, abs: join(location, entry) });
182
- }
183
- }
184
- const srcDir = join(location, 'src');
185
- if (existsSync(srcDir))
186
- walkFiles(srcDir, 'src', files);
187
- const hash = createHash('sha256');
188
- hash.update(`gjsify-build-cache-inputs/${BUILD_CACHE_LAYOUT_VERSION}\n`);
189
- for (const f of files) {
190
- if (f.link !== undefined) {
191
- // Symlink: try content (file links), fall back to the target path.
192
- let content;
193
- try {
194
- content = hashFileStream(f.abs);
195
- }
196
- catch {
197
- content = `link:${f.link}`;
198
- }
199
- hash.update(`${f.rel} ${content}\n`);
200
- continue;
201
- }
202
- try {
203
- hash.update(`${f.rel} ${hashFileStream(f.abs)}\n`);
204
- }
205
- catch {
206
- // Vanished mid-hash (concurrent edit) — record the absence so the
207
- // key still changes vs. the file being present.
208
- hash.update(`${f.rel} unreadable\n`);
209
- }
210
- }
211
- return hash.digest('hex');
212
- }
213
208
  /**
214
209
  * Mtime-independent content hash of a set of output dirs — used to skip the
215
210
  * restore copy when the on-disk outputs already match the cached entry, and
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The status a settled CLI run still owes the shell, or `null` when it owes
3
+ * none.
4
+ *
5
+ * @param exitCode what `process.exitCode` holds now — `number | string | null |
6
+ * undefined`, the full set Node accepts for that property.
7
+ * @param isDaemon whether the command declared itself a daemon. A watch loop
8
+ * RESOLVES as soon as it is armed and keeps running, so its resolution is not
9
+ * an ending to exit on — the same rule `gjsify run`'s fast path applies, and
10
+ * the incident behind it (`gjsify run dev` killing the loop it had just
11
+ * started) is in `daemon-command.ts`.
12
+ */
13
+ export declare function pendingFailureCode(exitCode: number | string | null | undefined, isDaemon: boolean): number | null;
14
+ /**
15
+ * End a settled CLI run on the status its handler reported, on both runtimes.
16
+ *
17
+ * No-op unless a handler actually reported a failure. On Node it is a no-op
18
+ * even then — `gjsExit` finds no `imports.system` and the runtime honours
19
+ * `process.exitCode` at natural shutdown by itself, which is why this does NOT
20
+ * reach for `process.exit()`: that would cut short the stdout flush Node's own
21
+ * idiom exists to allow.
22
+ */
23
+ export declare function exitOnReportedFailure(): void;
@@ -0,0 +1,78 @@
1
+ // The CLI has TWO ways to fail, and until now only one of them reached the shell
2
+ // under GJS.
3
+ //
4
+ // A command handler either THROWS — `index.ts` catches it, prints, and calls
5
+ // `gjsExit(1)` — or it REPORTS the failure by assigning `process.exitCode` and
6
+ // returning normally. The second shape is the documented Node idiom (streams
7
+ // flush, the process ends naturally) and the CLI uses it wherever a failure has
8
+ // already been printed in full: `gjsify gresource`/`gjsify gsettings` propagate
9
+ // the code `glib-compile-*` exited with, `gjsify barrels --check` reports drift,
10
+ // `setOxcExitCode` propagates a formatter's verdict.
11
+ //
12
+ // **Under GJS that assignment is a REPORT and nothing more.** There is no atexit
13
+ // hook, so nothing reads `process.exitCode` back at natural shutdown and the
14
+ // process ends 0. Measured on the v0.49.0 bundle, same argv, same broken input:
15
+ //
16
+ // $ node lib/index.js gresource broken.gresource.xml → exit 1
17
+ // $ gjs -m dist/cli.gjs.mjs gresource broken.gresource.xml → exit 0
18
+ // ("[gjsify gresource] glib-compile-resources failed (exit 1)" printed)
19
+ //
20
+ // and the same split for `gjsify gsettings` and `gjsify barrels --check`. The
21
+ // consequence is not a cosmetic status: a package script chaining on it runs the
22
+ // next step over an artifact that was never written —
23
+ //
24
+ // "build:resources": "gjsify gresource … && echo SECOND"
25
+ // → SECOND printed, script exits 0 (JumpLink/Learn6502#180)
26
+ //
27
+ // — which is exactly the shape `&&` is supposed to protect. `gjsify run`'s
28
+ // in-process fast path was already immune (it reads `process.exitCode` back
29
+ // itself, #1568), so the hole survived precisely in the SPAWNED command, i.e.
30
+ // in every chained script and every direct CI invocation.
31
+ //
32
+ // This module is the missing half of the entry's exit funnel, and a sibling of
33
+ // `force-exit.ts`: that one is for a caller that knows the code it wants NOW,
34
+ // this one for the status a settled run left behind. The rule is pure so it can
35
+ // be stated as a test without driving a process to its death.
36
+ //
37
+ // Related, and deliberately distinct: `gjsify/deferred-process-exit` (oxlint)
38
+ // covers the OTHER GJS exit trap, a bare `process.exit()` that does not halt.
39
+ // No lint rule could have caught this one — every `process.exitCode = …` site
40
+ // is correct; the entry was not reading them.
41
+ import { gjsExit } from '@gjsify/rolldown-plugin-gjsify/runtime';
42
+ import { isDaemonCommand } from './daemon-command.js';
43
+ /**
44
+ * The status a settled CLI run still owes the shell, or `null` when it owes
45
+ * none.
46
+ *
47
+ * @param exitCode what `process.exitCode` holds now — `number | string | null |
48
+ * undefined`, the full set Node accepts for that property.
49
+ * @param isDaemon whether the command declared itself a daemon. A watch loop
50
+ * RESOLVES as soon as it is armed and keeps running, so its resolution is not
51
+ * an ending to exit on — the same rule `gjsify run`'s fast path applies, and
52
+ * the incident behind it (`gjsify run dev` killing the loop it had just
53
+ * started) is in `daemon-command.ts`.
54
+ */
55
+ export function pendingFailureCode(exitCode, isDaemon) {
56
+ if (isDaemon)
57
+ return null;
58
+ if (exitCode === null || exitCode === undefined)
59
+ return null;
60
+ const code = Number(exitCode);
61
+ // A non-finite value cannot be assigned to `process.exitCode` on Node at
62
+ // all, so there is no failure here to invent one from.
63
+ return Number.isFinite(code) && code !== 0 ? code : null;
64
+ }
65
+ /**
66
+ * End a settled CLI run on the status its handler reported, on both runtimes.
67
+ *
68
+ * No-op unless a handler actually reported a failure. On Node it is a no-op
69
+ * even then — `gjsExit` finds no `imports.system` and the runtime honours
70
+ * `process.exitCode` at natural shutdown by itself, which is why this does NOT
71
+ * reach for `process.exit()`: that would cut short the stdout flush Node's own
72
+ * idiom exists to allow.
73
+ */
74
+ export function exitOnReportedFailure() {
75
+ const code = pendingFailureCode(process.exitCode, isDaemonCommand());
76
+ if (code !== null)
77
+ gjsExit(code);
78
+ }
@@ -233,6 +233,37 @@ export declare function resolveHostLibc(input: {
233
233
  * reach it.
234
234
  */
235
235
  export declare function detectHostLibc(platform: string): HostLibc | null;
236
+ /**
237
+ * The packages a musl host had to take the DEFAULT prebuild of, because they ship
238
+ * no `-musl` directory — the fallback {@link hostPlatformTokens} deliberately
239
+ * allows, named so a caller can report it. Returns names and prints nothing: this
240
+ * module resolves, and `libc` is a parameter for the same reason it is on every
241
+ * other function here (there is no musl runner in this repo's CI, so an injected
242
+ * `libc: 'musl'` is the only way the branch executes).
243
+ *
244
+ * Why a fallback that mostly WORKS is worth naming. musl's loader resolves
245
+ * `libc.so.6`/`libm.so.6` to itself, so a glibc-linked prebuild loads — and a
246
+ * bridge that records no libc at all then runs correctly, which is why the
247
+ * fallback exists rather than resolving to null. The one that is NOT agnostic dies
248
+ * at `dlopen` on its first glibc-only symbol, far from its cause. Measured on a
249
+ * OnePlus 6 (postmarketOS v26.06, musl 1.2.6, aarch64, gjs 1.88.1) against the
250
+ * published 0.48.0 train: nine of ten prebuilds loaded, while
251
+ * `@gjsify/lightningcss-native-linux-arm64` answered `gnu_get_libc_version: symbol
252
+ * not found`. `gjsify install` had printed "System dependencies OK", and the first
253
+ * symptom was rolldown reporting `Could not load src/application.css` — the CSS
254
+ * bridge silently absent from the bundle. Nothing in that chain names libc, which
255
+ * is what makes the report worth more than the failure.
256
+ *
257
+ * Membership goes through {@link parsePlatformToken}, not the suffix constant, so
258
+ * this does not become a second reader of the libc half of the token grammar —
259
+ * `darwin-arm64-musl` is a malformed token rather than a musl build, and only the
260
+ * grammar knows that.
261
+ *
262
+ * TODO(open-todos: `-musl` prebuild package is published): naming the fallback is
263
+ * not fixing it. No `-musl` target is published for any bridge, so on a musl host
264
+ * this reports every prebuild in the tree and there is nothing to fall back FROM.
265
+ */
266
+ export declare function muslPrebuildFallbacks(libc: HostLibc | null, packages: readonly NativePackage[]): string[];
236
267
  /**
237
268
  * The environment-variable name the host's dynamic loader consults for an
238
269
  * additional shared-library search path, plus the separator its search paths
@@ -22,7 +22,7 @@
22
22
  // win32 AND musl branches unit-testable from a glibc Linux host. The host values
23
23
  // are read only at the outermost call site, as a default.
24
24
  import { readdirSync, existsSync } from 'node:fs';
25
- import { join, resolve } from 'node:path';
25
+ import { basename, join, resolve } from 'node:path';
26
26
  import { readPackageJson } from './pkg-json.js';
27
27
  import { composeDyldFallback, systemGiLibraryDirs } from './system-gi.js';
28
28
  /**
@@ -500,6 +500,42 @@ export function detectHostLibc(platform) {
500
500
  const muslLoaderPresent = existsSync('/lib') && readdirSync('/lib').some((f) => f.startsWith('ld-musl-'));
501
501
  return resolveHostLibc({ platform, glibcVersionRuntime, muslLoaderPresent });
502
502
  }
503
+ /**
504
+ * The packages a musl host had to take the DEFAULT prebuild of, because they ship
505
+ * no `-musl` directory — the fallback {@link hostPlatformTokens} deliberately
506
+ * allows, named so a caller can report it. Returns names and prints nothing: this
507
+ * module resolves, and `libc` is a parameter for the same reason it is on every
508
+ * other function here (there is no musl runner in this repo's CI, so an injected
509
+ * `libc: 'musl'` is the only way the branch executes).
510
+ *
511
+ * Why a fallback that mostly WORKS is worth naming. musl's loader resolves
512
+ * `libc.so.6`/`libm.so.6` to itself, so a glibc-linked prebuild loads — and a
513
+ * bridge that records no libc at all then runs correctly, which is why the
514
+ * fallback exists rather than resolving to null. The one that is NOT agnostic dies
515
+ * at `dlopen` on its first glibc-only symbol, far from its cause. Measured on a
516
+ * OnePlus 6 (postmarketOS v26.06, musl 1.2.6, aarch64, gjs 1.88.1) against the
517
+ * published 0.48.0 train: nine of ten prebuilds loaded, while
518
+ * `@gjsify/lightningcss-native-linux-arm64` answered `gnu_get_libc_version: symbol
519
+ * not found`. `gjsify install` had printed "System dependencies OK", and the first
520
+ * symptom was rolldown reporting `Could not load src/application.css` — the CSS
521
+ * bridge silently absent from the bundle. Nothing in that chain names libc, which
522
+ * is what makes the report worth more than the failure.
523
+ *
524
+ * Membership goes through {@link parsePlatformToken}, not the suffix constant, so
525
+ * this does not become a second reader of the libc half of the token grammar —
526
+ * `darwin-arm64-musl` is a malformed token rather than a musl build, and only the
527
+ * grammar knows that.
528
+ *
529
+ * TODO(open-todos: `-musl` prebuild package is published): naming the fallback is
530
+ * not fixing it. No `-musl` target is published for any bridge, so on a musl host
531
+ * this reports every prebuild in the tree and there is nothing to fall back FROM.
532
+ */
533
+ export function muslPrebuildFallbacks(libc, packages) {
534
+ if (libc !== 'musl')
535
+ return [];
536
+ const fellBack = packages.filter((pkg) => parsePlatformToken(basename(pkg.prebuildsDir)).libc !== 'musl');
537
+ return fellBack.map((pkg) => pkg.name);
538
+ }
503
539
  /**
504
540
  * The environment-variable name the host's dynamic loader consults for an
505
541
  * additional shared-library search path, plus the separator its search paths