@gjsify/cli 0.44.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/cli.gjs.mjs +202 -167
  2. package/lib/commands/gettext.d.ts +2 -1
  3. package/lib/commands/gettext.js +105 -47
  4. package/lib/commands/onboard.d.ts +7 -0
  5. package/lib/commands/onboard.js +349 -96
  6. package/lib/commands/ship.d.ts +3 -0
  7. package/lib/commands/ship.js +850 -58
  8. package/lib/commands/trust.d.ts +6 -0
  9. package/lib/commands/trust.js +1 -1
  10. package/lib/commands/upgrade.d.ts +23 -0
  11. package/lib/commands/upgrade.js +95 -12
  12. package/lib/templates/install.mjs.tmpl +2 -2
  13. package/lib/types/config-data.d.ts +127 -1
  14. package/lib/utils/gi-search-path.js +11 -3
  15. package/lib/utils/msgfmt-merge.d.ts +35 -0
  16. package/lib/utils/msgfmt-merge.js +102 -0
  17. package/lib/utils/npm-otp.d.ts +27 -2
  18. package/lib/utils/npm-otp.js +82 -7
  19. package/lib/utils/onboard-discovery.d.ts +68 -0
  20. package/lib/utils/onboard-discovery.js +178 -0
  21. package/lib/utils/onboard-probe.d.ts +118 -1
  22. package/lib/utils/onboard-probe.js +153 -4
  23. package/lib/utils/prompt-output.d.ts +12 -0
  24. package/lib/utils/prompt-output.js +54 -0
  25. package/lib/utils/prompt.js +22 -2
  26. package/lib/utils/publishable-packages.d.ts +1 -1
  27. package/lib/utils/publishable-packages.js +1 -0
  28. package/lib/utils/resolve-npm-package.d.ts +56 -0
  29. package/lib/utils/resolve-npm-package.js +69 -0
  30. package/lib/utils/ship/app-runtime.d.ts +214 -0
  31. package/lib/utils/ship/app-runtime.js +429 -0
  32. package/lib/utils/ship/depends.d.ts +50 -0
  33. package/lib/utils/ship/depends.js +117 -3
  34. package/lib/utils/ship/discover.js +103 -1
  35. package/lib/utils/ship/dmg.d.ts +71 -0
  36. package/lib/utils/ship/dmg.js +181 -0
  37. package/lib/utils/ship/flatpak.js +1 -1
  38. package/lib/utils/ship/formats.d.ts +108 -7
  39. package/lib/utils/ship/formats.js +778 -15
  40. package/lib/utils/ship/launcher.d.ts +13 -3
  41. package/lib/utils/ship/launcher.js +419 -17
  42. package/lib/utils/ship/layout.d.ts +330 -0
  43. package/lib/utils/ship/layout.js +504 -0
  44. package/lib/utils/ship/localize-metadata.d.ts +22 -0
  45. package/lib/utils/ship/localize-metadata.js +126 -0
  46. package/lib/utils/ship/mime.js +1 -9
  47. package/lib/utils/ship/msi.d.ts +126 -0
  48. package/lib/utils/ship/msi.js +434 -0
  49. package/lib/utils/ship/node-runtime.d.ts +71 -0
  50. package/lib/utils/ship/node-runtime.js +122 -0
  51. package/lib/utils/ship/payload.d.ts +156 -5
  52. package/lib/utils/ship/payload.js +599 -14
  53. package/lib/utils/ship/pe-launcher.d.ts +29 -0
  54. package/lib/utils/ship/pe-launcher.js +663 -0
  55. package/lib/utils/ship/plan.d.ts +21 -0
  56. package/lib/utils/ship/plan.js +55 -7
  57. package/lib/utils/ship/plist.d.ts +98 -0
  58. package/lib/utils/ship/plist.js +256 -0
  59. package/lib/utils/ship/rpm.js +16 -3
  60. package/lib/utils/ship/schemas.d.ts +61 -0
  61. package/lib/utils/ship/schemas.js +118 -0
  62. package/lib/utils/ship/scripts.js +7 -3
  63. package/lib/utils/ship/settings.d.ts +91 -2
  64. package/lib/utils/ship/settings.js +93 -1
  65. package/lib/utils/ship/share-dirs.d.ts +53 -0
  66. package/lib/utils/ship/share-dirs.js +76 -0
  67. package/lib/utils/ship/signing.d.ts +311 -0
  68. package/lib/utils/ship/signing.js +506 -0
  69. package/lib/utils/ship/stage-manifest.d.ts +40 -12
  70. package/lib/utils/ship/stage-manifest.js +99 -13
  71. package/lib/utils/ship/stage-writer.d.ts +48 -0
  72. package/lib/utils/ship/stage-writer.js +91 -2
  73. package/lib/utils/ship/types.d.ts +191 -14
  74. package/lib/utils/ship/xml.d.ts +7 -0
  75. package/lib/utils/ship/xml.js +25 -0
  76. package/lib/utils/ship/zip.d.ts +69 -0
  77. package/lib/utils/ship/zip.js +245 -0
  78. package/lib/utils/system-gi.d.ts +20 -5
  79. package/lib/utils/system-gi.js +36 -13
  80. package/lib/utils/trust-registry.js +5 -0
  81. package/package.json +19 -19
@@ -6,6 +6,9 @@
6
6
  // reads what the first wrote. A packer fed straight from the planner could
7
7
  // drift from the staged tree and nothing would notice.
8
8
  import { statSync } from 'node:fs';
9
+ import { launcherPath } from './layout.js';
10
+ import { SCHEMA_CACHE } from './schemas.js';
11
+ import { isUnder, SHARE } from './share-dirs.js';
9
12
  /**
10
13
  * The build stamp every header gets.
11
14
  *
@@ -45,16 +48,540 @@ export function buildTimestamp(bundlePath, env = process.env) {
45
48
  export function readPayloadFacts(entries) {
46
49
  const paths = entries.map((entry) => entry.path);
47
50
  return {
48
- hasDesktopEntry: paths.some((path) => path.startsWith('share/applications/') && path.endsWith('.desktop')),
49
- hasIcons: paths.some((path) => path.startsWith('share/icons/hicolor/')),
50
- hasSchemas: paths.some((path) => path.startsWith('share/glib-2.0/schemas/') && path.endsWith('.gschema.xml')),
51
- hasMimeTypes: paths.some((path) => path.startsWith('share/mime/packages/') && path.endsWith('.xml')),
51
+ hasDesktopEntry: paths.some((path) => isUnder(path, SHARE.applications) && path.endsWith('.desktop')),
52
+ hasIcons: paths.some((path) => isUnder(path, SHARE.icons)),
53
+ hasSchemas: paths.some((path) => isUnder(path, SHARE.schemas) && path.endsWith('.gschema.xml')),
54
+ hasMimeTypes: paths.some((path) => isUnder(path, SHARE.mime) && path.endsWith('.xml')),
52
55
  // Anywhere in the payload, not only `lib/<name>/gi/`: `gjsify.ship.extraFiles` can place
53
56
  // one elsewhere, and a typelib the package carries is a typelib the package must not also
54
57
  // declare a distro dependency for, wherever it sits.
55
58
  bundledTypelibs: paths.filter((path) => path.endsWith('.typelib')),
56
59
  };
57
60
  }
61
+ /**
62
+ * Every interpreter the STAGED launcher could exec, resolved to a bare name.
63
+ *
64
+ * WHY THIS IS READ BACK AT ALL, when `settings.app` already says so. Because
65
+ * `settings.app` says what the launcher was RENDERED from, and the dependency is
66
+ * a claim about what the installed package will RUN. Those were once two
67
+ * different things: the Node dependency was derived from a filename glob over
68
+ * the staged tree while `renderLauncher` execed `gjs` unconditionally, and the
69
+ * packer emitted `Depends: gjs (>= 1.86), nodejs (>= 24)` for a package that
70
+ * runs neither combination. Nothing in the pipeline compared them, and no
71
+ * structural check could: both artifacts were individually well-formed.
72
+ *
73
+ * A LIST, and every `exec` rather than one of them. The first cut took the first
74
+ * match while its own comment claimed the last — `/\nexec\s+/` is not global, so
75
+ * the two disagreed and the comment was the wrong one. Neither is right for a
76
+ * script this tree did not write: a launcher may exec different interpreters on
77
+ * different branches, and picking one branch's answer is a guess. Collecting
78
+ * them lets {@link assertLauncherMatchesInterpreter} ask the only question that
79
+ * is safe on a foreign script — see there.
80
+ *
81
+ * NAMES, not the tokens as written. `exec /usr/bin/gjs -m …` execs gjs, and an
82
+ * `env` prefix (`exec env NODE_OPTIONS=… node …`) execs whatever follows its
83
+ * assignments. Both are things `gjsify.ship.extraFiles` legitimately writes, and
84
+ * the raw-token version REFUSED them: measured, an untouched `--app gjs` project
85
+ * whose `extraFiles` replaced the launcher with `exec /usr/bin/gjs -m …` failed
86
+ * the pack at exit 1 with "execs `/usr/bin/gjs`, but this package would declare
87
+ * a dependency on `gjs`" — a working artifact rejected over a parser, which is
88
+ * exactly what the `null` branch was written to prevent and did not.
89
+ */
90
+ export function readLauncherInterpreters(payload, layout, identity) {
91
+ // THE LAYOUT'S launcher, which is what this function's doc has always claimed
92
+ // and what it did not do. It looked up `bin/<binaryName>` — the PREFIX-RELATIVE
93
+ // path — so off Linux it matched nothing and returned `[]`, i.e. it was vacuous
94
+ // for the darwin tree (`<App>.app/Contents/MacOS/<name>`) and the windows one
95
+ // (`<name>.cmd`). Harmless while every format that called it wrapped the Linux
96
+ // layout; a real hole the moment a darwin format exists, because `[]` is the
97
+ // value `assertLauncherMatchesInterpreter` treats as "nothing to check".
98
+ const launcher = payload.find((entry) => entry.path === launcherPath(layout, identity));
99
+ if (launcher === undefined)
100
+ return [];
101
+ const text = new TextDecoder().decode(launcher.data);
102
+ const dialect = layout.name === 'windows' ? 'cmd' : 'sh';
103
+ const found = [];
104
+ for (const command of dialect === 'cmd' ? batchCommands(text) : shellExecLines(text)) {
105
+ const name = interpreterOf(command, dialect);
106
+ if (name !== null)
107
+ found.push(name);
108
+ }
109
+ return [...new Set(found)];
110
+ }
111
+ /**
112
+ * The argument of every `exec` in a POSIX-shell launcher.
113
+ *
114
+ * `[ \t]*` because a branching launcher indents the `exec` inside its `if`, and a
115
+ * pattern anchored hard to the newline silently sees only the last, unindented one
116
+ * — measured: a two-branch script reported `gjs` alone. A comment (`# exec gjs …`)
117
+ * still cannot match, since `#` is not whitespace.
118
+ */
119
+ function shellExecLines(text) {
120
+ return [...`\n${text}`.matchAll(/\n[ \t]*exec\s+([^\n]*)/g)].map((match) => match[1].trim());
121
+ }
122
+ /**
123
+ * Batch keywords that can never be the program a `.cmd` line runs.
124
+ *
125
+ * The list is what makes {@link batchCommands} a filter rather than a parser, and
126
+ * it is deliberately only the keywords that CARRY NOTHING: `rem`, `echo`, `title`
127
+ * and `pause` take no command at all, `goto` takes a label, `exit` takes a code,
128
+ * `set`/`setlocal`/`endlocal` take a variable. A keyword missing from it costs a
129
+ * SILENT non-answer, never a wrong one — an unrecognised first word is handed to
130
+ * `interpreterOf`, which resolves it to `gjs`, to `node`, or to something
131
+ * `assertLauncherMatchesInterpreter` ignores.
132
+ *
133
+ * `if`, `else` and `for` are NOT here, and an earlier draft put them here with a
134
+ * doc claiming every entry "takes no program". That was false in the way batch
135
+ * actually gets written, and measured through the built reader:
136
+ *
137
+ * IF defined X (node x) → [] ← the whole reader, on one line
138
+ * for %f in (*) do node %f → []
139
+ *
140
+ * `[]` is the value `assertLauncherMatchesInterpreter` treats as "nothing to
141
+ * check", so a `gjsify.ship.extraFiles` launcher written as
142
+ * `if exist "%HERE%gjs.exe" ("%HERE%gjs.exe" -m …)` under `gjsify.app: "node"`
143
+ * passed. That is the same class as the POSIX form's un-indented-`exec` incident,
144
+ * one dialect over, so it gets the same treatment: {@link batchPrefix} REDUCES
145
+ * those three to the command they carry rather than dropping the line.
146
+ *
147
+ * `call` and `start` stay unhandled and stay silent: both DO invoke a program and
148
+ * both also take non-program arguments (`call :label`, `start "title"`), and
149
+ * guessing at that would be the parser answering confidently wrong.
150
+ */
151
+ const BATCH_NON_PROGRAMS = new Set([
152
+ 'rem',
153
+ 'set',
154
+ 'setlocal',
155
+ 'endlocal',
156
+ 'echo',
157
+ 'goto',
158
+ 'exit',
159
+ 'title',
160
+ 'pause',
161
+ 'shift',
162
+ 'cd',
163
+ 'chdir',
164
+ 'pushd',
165
+ 'popd',
166
+ 'color',
167
+ 'cls',
168
+ 'verify',
169
+ 'prompt',
170
+ ]);
171
+ /**
172
+ * One `cmd.exe` command line, split the way `cmd.exe` splits it.
173
+ *
174
+ * QUOTE-AWARE, and that is the whole reason it is not `line.split(/\s+/)`: a
175
+ * condition operand is routinely a path, and a program directory lives under
176
+ * `C:\Program Files\…`. Splitting `if exist "%HERE%My App\x" node y` on
177
+ * whitespace and then dropping "two tokens" would drop half the path and leave
178
+ * the rest as the program.
179
+ */
180
+ function cmdTokens(line) {
181
+ const tokens = [];
182
+ let at = 0;
183
+ while (at < line.length) {
184
+ if (/\s/.test(line[at])) {
185
+ at += 1;
186
+ continue;
187
+ }
188
+ const start = at;
189
+ let quoted = false;
190
+ while (at < line.length && (quoted || !/\s/.test(line[at]))) {
191
+ if (line[at] === '"')
192
+ quoted = !quoted;
193
+ at += 1;
194
+ }
195
+ tokens.push({ text: line.slice(start, at), end: at });
196
+ }
197
+ return tokens;
198
+ }
199
+ /**
200
+ * Split `( … ) rest` into the block's body and whatever follows it.
201
+ *
202
+ * Depth-counting and quote-aware, because `(A) else (B)` is how a batch launcher
203
+ * writes two branches and BOTH have to be read — the POSIX form collects every
204
+ * `exec`, and a reader that saw one arm of an `if`/`else` would answer for a
205
+ * branch the user may never take. `null` when the parenthesis is unbalanced,
206
+ * which is a line this reader cannot honestly split.
207
+ */
208
+ function splitBatchBlock(line) {
209
+ let depth = 0;
210
+ let quoted = false;
211
+ for (let at = 0; at < line.length; at++) {
212
+ const ch = line[at];
213
+ if (ch === '"')
214
+ quoted = !quoted;
215
+ else if (!quoted && ch === '(')
216
+ depth += 1;
217
+ else if (!quoted && ch === ')') {
218
+ depth -= 1;
219
+ if (depth === 0)
220
+ return [line.slice(1, at).trim(), line.slice(at + 1).trim()];
221
+ }
222
+ }
223
+ return null;
224
+ }
225
+ /** Conditions whose operand is one token: `if exist FILE …`, `if defined VAR …`. */
226
+ const BATCH_UNARY_CONDITIONS = new Set(['exist', 'defined', 'errorlevel']);
227
+ /** `if a EQU b …` — three tokens where `a==b` is one. */
228
+ const BATCH_COMPARISONS = new Set(['equ', 'neq', 'lss', 'leq', 'gtr', 'geq']);
229
+ /**
230
+ * Strip one batch construct that WRAPS a command, or `null` when the line carries
231
+ * none this reader can reach.
232
+ *
233
+ * Returns the pieces for another round — `if defined X (node a) else (gjs -m b)`
234
+ * reduces to `["node a", "else (gjs -m b)"]` — which is what makes a compound line
235
+ * answerable at all, and what keeps BOTH arms of a branch in the answer.
236
+ * `undefined` means "nothing to strip", i.e. the line already IS the command.
237
+ *
238
+ * The `if` conditions are enumerated rather than guessed: three unary forms, the
239
+ * `a==b` form (one token, because `==` needs no spaces), and the six comparison
240
+ * operators. A shape not in that list returns `null` rather than a best guess,
241
+ * because the failure mode of guessing here is not silence — it is naming
242
+ * whichever token happened to land in the program position.
243
+ */
244
+ function batchPrefix(line) {
245
+ if (line.startsWith('(')) {
246
+ const split = splitBatchBlock(line);
247
+ return split === null ? null : [split[0], split[1]];
248
+ }
249
+ const tokens = cmdTokens(line);
250
+ const word = (index) => (tokens[index]?.text ?? '').toLowerCase();
251
+ const after = (index) => [line.slice(tokens[index]?.end ?? line.length).trim()];
252
+ if (word(0) === 'else')
253
+ return after(0);
254
+ if (word(0) === 'for') {
255
+ const doAt = tokens.findIndex((token) => token.text.toLowerCase() === 'do');
256
+ return doAt === -1 ? null : after(doAt);
257
+ }
258
+ if (word(0) !== 'if')
259
+ return undefined;
260
+ // `not` and `/i` are modifiers, in either order and both optional.
261
+ let at = 1;
262
+ while (word(at) === 'not' || word(at) === '/i')
263
+ at += 1;
264
+ if (BATCH_UNARY_CONDITIONS.has(word(at)))
265
+ return after(at + 1);
266
+ if (word(at).includes('=='))
267
+ return after(at);
268
+ if (BATCH_COMPARISONS.has(word(at + 1)))
269
+ return after(at + 2);
270
+ return null;
271
+ }
272
+ /**
273
+ * Every command in a `.cmd` that could run a program.
274
+ *
275
+ * NOT an `exec` scan, because `cmd.exe` has no `exec`: the last command a batch
276
+ * file runs IS what the launcher runs, and its exit status is the script's. So
277
+ * where the POSIX form has a keyword to anchor on, this one has to rule lines OUT
278
+ * — see {@link BATCH_NON_PROGRAMS} for why ruling one out too few is the safe
279
+ * direction and ruling one out too many is not.
280
+ *
281
+ * `@` comes off first (`@echo off` is `echo` with output suppressed), and `::` is
282
+ * the label-abuse comment form, which no first-word test would otherwise catch.
283
+ * Lines are split on CR LF *or* LF: the form this tree writes is CRLF, and a
284
+ * launcher that arrived from `gjsify.ship.extraFiles` may be neither.
285
+ *
286
+ * The work list is BOUNDED. `if defined X (if defined Y (node a))` is legal batch
287
+ * and each round strips one construct; a cap turns a pathological line into a
288
+ * silent non-answer instead of a hang, which is the same trade `interpreterOf`
289
+ * makes for `env`.
290
+ */
291
+ const BATCH_REDUCTION_STEPS = 32;
292
+ function batchCommands(text) {
293
+ const commands = [];
294
+ const pending = text.split(/\r?\n/).map((raw) => raw.trim().replace(/^@+/, '').trim());
295
+ for (let step = 0; step < BATCH_REDUCTION_STEPS && pending.length > 0; step++) {
296
+ const line = pending.shift();
297
+ if (line === '' || line === ')' || line.startsWith('::'))
298
+ continue;
299
+ const first = (cmdTokens(line)[0]?.text ?? '').toLowerCase();
300
+ if (BATCH_NON_PROGRAMS.has(first))
301
+ continue;
302
+ const reduced = batchPrefix(line);
303
+ if (reduced === undefined)
304
+ commands.push(line);
305
+ else if (reduced !== null)
306
+ pending.unshift(...reduced);
307
+ }
308
+ return commands;
309
+ }
310
+ /**
311
+ * The program a command line runs, as a bare name, or `null` when the line is
312
+ * not one this reader can honestly resolve.
313
+ *
314
+ * TWO DIALECTS, because the two launchers are two languages and the difference is
315
+ * not cosmetic — it is what made the first Windows reading VACUOUS. See
316
+ * {@link cmdProgramOf}.
317
+ *
318
+ * `env` is followed through because it is the documented way to pass variables
319
+ * to an interpreter and says nothing about which one runs. Everything past the
320
+ * first non-assignment word is arguments, so the walk stops there. There is no
321
+ * `env` on Windows, so the hop is the POSIX branch's alone.
322
+ */
323
+ function interpreterOf(line, dialect) {
324
+ if (dialect === 'cmd')
325
+ return cmdProgramOf(line);
326
+ let words = line.split(/\s+/).filter((word) => word.length > 0);
327
+ for (let hop = 0; hop < 2 && words.length > 0; hop++) {
328
+ const program = basenameOf(words[0]);
329
+ if (program !== 'env')
330
+ return program === '' ? null : program;
331
+ // Past `env`: skip its flags and `NAME=value` assignments to reach the
332
+ // program. `-S` takes the rest of the line as one string, which the split
333
+ // above has already flattened into words — the right answer either way.
334
+ words = words.slice(1).filter((word) => !word.startsWith('-') && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word));
335
+ }
336
+ return null;
337
+ }
338
+ /**
339
+ * The program one `cmd.exe` command line runs, as a bare, extension-free name.
340
+ *
341
+ * THREE DIALECT RULES, and every one of them is the difference between a check
342
+ * and a vacuous pass. Measured on the launcher `gjsify ship windows` writes
343
+ * (`"%HERE%node.exe" "%HERE%app\app.node.mjs" %*`), reading it with the POSIX
344
+ * path alone:
345
+ *
346
+ * 1. there is no `exec` keyword in batch, so the scan matched NOTHING and the
347
+ * whole reader returned `[]` — the value `assertLauncherMatchesInterpreter`
348
+ * treats as "nothing to check". {@link batchCommands} is that half.
349
+ * 2. `%~dp0` ALREADY ENDS IN A SEPARATOR, so the renderer concatenates with
350
+ * none: the token is `%HERE%node.exe`, which splits on `[/\\]` to itself.
351
+ * Stripping one leading `%NAME%` is what makes the basename a basename —
352
+ * and a token that is nothing BUT an expansion (`%NODE%`) strips to empty
353
+ * and stays `null`, which is the honest answer.
354
+ * 3. Windows executables carry `.exe` and this vocabulary does not
355
+ * (`'gjs' | 'node'`). PATHEXT makes `node` and `node.exe` the same program,
356
+ * so the suffix comes off. `.cmd` and `.bat` deliberately do NOT: those are
357
+ * batch files, not interpreters, and resolving one to a bare name would be
358
+ * this reader claiming to know what that script runs.
359
+ *
360
+ * The first token is taken the way `cmd.exe` takes it — a quoted run up to the
361
+ * closing quote, otherwise up to the first whitespace — rather than by splitting
362
+ * on spaces, because a program directory under `C:\Program Files\…` is exactly
363
+ * the case a naive split gets wrong.
364
+ */
365
+ function cmdProgramOf(line) {
366
+ const quoted = /^"([^"]*)"/.exec(line);
367
+ const token = quoted !== null ? quoted[1] : (line.split(/\s+/)[0] ?? '');
368
+ const expanded = token.replace(/^%[^%\s]*%/, '');
369
+ const leaf = expanded.split(/[/\\]/).pop() ?? '';
370
+ const bare = leaf.replace(/\.exe$/i, '');
371
+ return bare === '' ? null : bare;
372
+ }
373
+ /**
374
+ * Last path segment of a shell TOKEN, POSIX or Windows.
375
+ *
376
+ * The quotes come off first, and that is not cosmetic. #1354 M2b makes the macOS
377
+ * launcher exec `"$here/node"` — the interpreter the bundle carries, named by a
378
+ * layout-relative path because macOS has no system Node to find on `PATH`.
379
+ * Without the strip this reader answered `node"`, which is neither `node` nor
380
+ * `gjs`, so {@link assertLauncherMatchesInterpreter} took its "a program that is
381
+ * neither" branch and passed. Not a wrong answer — a VACUOUS one, on exactly the
382
+ * layout that made the check matter, and green.
383
+ *
384
+ * Both quote characters, and only as a surrounding pair: a shell token is
385
+ * `"…"`, `'…'` or bare, and stripping a lone quote from the middle of a word
386
+ * would be this function guessing at a syntax it does not parse. The `$here`
387
+ * inside survives as a literal, which is fine — this reader wants the last
388
+ * SEGMENT, and no expansion can change which segment that is.
389
+ */
390
+ function basenameOf(token) {
391
+ const parts = unquote(token).split(/[/\\]/);
392
+ return parts[parts.length - 1] ?? '';
393
+ }
394
+ /** `"x"` and `'x'` → `x`; everything else unchanged. */
395
+ function unquote(token) {
396
+ const first = token[0];
397
+ if ((first === '"' || first === "'") && token.length > 1 && token[token.length - 1] === first) {
398
+ return token.slice(1, -1);
399
+ }
400
+ return token;
401
+ }
402
+ /**
403
+ * Refuse a package whose launcher execs an interpreter the package does not
404
+ * depend on.
405
+ *
406
+ * THE RULE, and it is deliberately asymmetric: fail ONLY when the launcher
407
+ * positively names the OTHER known interpreter and never names the declared one.
408
+ * Everything else passes — no launcher, no `exec` this reader resolves, a program
409
+ * that is neither `gjs` nor `node`, or a script whose branches include the
410
+ * declared one.
411
+ *
412
+ * That asymmetry is the correction. The first cut failed whenever the resolved
413
+ * token was not equal to the declared interpreter, and its own doc comment
414
+ * claimed the opposite ("`null` is SILENT ... refusing a package because this
415
+ * function did not understand a launcher somebody else wrote would fail a
416
+ * working artifact over a parser"). The parser's failure mode is not `null` — it
417
+ * answers confidently wrong. Measured on an otherwise untouched `--app gjs`
418
+ * project whose `gjsify.ship.extraFiles` replaced `bin/<name>`:
419
+ *
420
+ * exec /usr/bin/gjs -m … → exit 1, "execs `/usr/bin/gjs`"
421
+ * exec env NODE_OPTIONS=… node… → exit 1, "execs `env`"
422
+ *
423
+ * Both worked before the check existed. A guard that turns working packages into
424
+ * failures buys nothing over the defect it prevents.
425
+ *
426
+ * The advice in the message is also fixed. It used to say "re-run the `--stage`
427
+ * phase", which is FALSE for the case that actually reaches a user: re-staging
428
+ * an `extraFiles` override reproduces it forever. The two real causes are named
429
+ * instead.
430
+ */
431
+ export function assertLauncherMatchesInterpreter(payload, layout, identity, interpreter) {
432
+ const found = readLauncherInterpreters(payload, layout, identity);
433
+ if (found.length === 0 || found.includes(interpreter))
434
+ return;
435
+ const other = interpreter === 'gjs' ? 'node' : 'gjs';
436
+ if (!found.includes(other))
437
+ return;
438
+ throw new Error(`gjsify ship: the launcher ${launcherPath(layout, identity)} execs \`${other}\`, but this package ` +
439
+ `would declare a dependency on \`${interpreter}\`.\n` +
440
+ ' An installed package that depends on one interpreter and runs another installs cleanly and ' +
441
+ 'fails\n' +
442
+ " at first launch, on the user's machine.\n" +
443
+ // THIS TARGET's key, and not `gjsify.app`. Since #1486 the runtime is
444
+ // resolved per target, so sending the reader to the project field to
445
+ // fix ONE layout's launcher moves every other layout's `Depends:` with
446
+ // it — the defect § A22 exists to close, reintroduced by the message
447
+ // that reports it. Named as a project edit because `--from-stage`
448
+ // reaches here too, and that host has no config to change.
449
+ ` Either set \`gjsify.ship.app.${layout.os}\` to "${other}" in the project this was staged ` +
450
+ 'from — it overrides\n' +
451
+ ' `gjsify.app` for that target alone — or fix the launcher: if it comes from ' +
452
+ '`gjsify.ship.extraFiles`,\n' +
453
+ ' that override is what decides which interpreter runs and it must match the declaration.');
454
+ }
455
+ /**
456
+ * What each known `share/` directory costs a layout with no install step.
457
+ *
458
+ * Keyed on {@link SHARE}, so the compiler — not a comment — is what keeps this in
459
+ * step with where `plan.ts` stages the files and which directory
460
+ * `cacheRefreshCommands` refreshes. The previous version claimed exactly that in
461
+ * prose over five independent string literals, and pointing one rule at a
462
+ * directory matching nothing silently dropped a file from the printed warning
463
+ * with the whole suite green at exit 0.
464
+ *
465
+ * The verdict is a FUNCTION of the payload rather than a constant, and exactly
466
+ * one row uses that: the schema directory's cost depends on whether the compiled
467
+ * cache is in the tree with it. Making it a function keeps that in the table
468
+ * instead of as a branch in the loop — one place still answers "what does this
469
+ * directory cost", which is the property the five string literals cost us.
470
+ */
471
+ const SHARE_VERDICTS = [
472
+ [
473
+ SHARE.schemas,
474
+ // The `aborts` branch is what #1354 M2a removed the cause of, and it is
475
+ // deliberately still REACHABLE: a stage assembled before the compile step
476
+ // existed, or one whose cache was deleted, classifies as `aborts` again
477
+ // with no rule to remember to re-add. A rule that can no longer fire is a
478
+ // comment; this one is a mechanism.
479
+ ({ hasCompiledSchemas }) => hasCompiledSchemas
480
+ ? [
481
+ 'inert',
482
+ 'the schema SOURCE; `gschemas.compiled` is staged beside it and that is what ' +
483
+ 'GSettings reads, so this file is carried and never opened',
484
+ ]
485
+ : [
486
+ 'aborts',
487
+ 'needs `glib-compile-schemas`, and the launcher points XDG_DATA_DIRS here — ' +
488
+ 'GSettings ABORTS on a schema directory with no `gschemas.compiled`',
489
+ ],
490
+ ],
491
+ [SHARE.mime, () => ['inert', 'needs `update-mime-database`; detection runs off the compiled cache']],
492
+ [SHARE.icons, () => ['inert', 'needs `gtk-update-icon-cache`; neither OS reads the hicolor theme']],
493
+ [SHARE.applications, () => ['inert', 'a freedesktop desktop entry; neither OS reads one']],
494
+ [SHARE.metainfo, () => ['inert', 'an AppStream component; neither OS reads one']],
495
+ ];
496
+ /**
497
+ * Paths that survive the trip, so the rule below can be an INVERSE one.
498
+ *
499
+ * Three entries and they are different SHAPES, which is why the check below tests
500
+ * both containment and equality:
501
+ *
502
+ * - `share/locale` is a DIRECTORY. A `.mo` is read straight off disk by
503
+ * `bindtextdomain`, with no install step anywhere, and the launcher hands the
504
+ * directory over on all three layouts.
505
+ * - `share/fonts` is a directory too, and it is here for a NARROWER reason than
506
+ * the one next to it (ADR 0038). What this list means is "no package install
507
+ * step makes this correct", and that is true of a face on every layout: nothing
508
+ * compiles it, nothing reindexes it, and on Linux fontconfig scans the
509
+ * directory and caches lazily per user. What it deliberately does NOT claim is
510
+ * that a face is FOUND on the other two — macOS reaches it through
511
+ * `Info.plist`'s `ATSApplicationFontsPath` and Windows only through a call the
512
+ * app makes itself. Those are `Layout.fontGap`, printed separately, because
513
+ * they are facts about an OS rather than about a missing scriptlet, and folding
514
+ * them in here would report the answer under a heading that names the wrong
515
+ * question.
516
+ * - `gschemas.compiled` is one FILE, and it is here because it is not a cost —
517
+ * it is the thing that removes one. Reported under a warning headed "whose
518
+ * Linux correctness comes from a package install step" it would name the fix
519
+ * as if it were the problem, and it would be the sixth line of a five-line
520
+ * list nobody asked to grow.
521
+ */
522
+ const SHARE_PORTABLE = [SHARE.locale, SHARE.fonts, SCHEMA_CACHE];
523
+ /**
524
+ * Every `share/` entry a non-Linux layout carries, classified — including the
525
+ * ones nothing here has a rule for.
526
+ *
527
+ * WHY THIS EXISTS, and it is the hole the layout axis opened. `planStage` emits
528
+ * one prefix-relative plan and every layout carries it, so the darwin and windows
529
+ * trees hold the same `share/…` files the `.deb` does — and on Linux most of those
530
+ * are only correct because `cacheRefreshCommands` compiles or reindexes them at
531
+ * install time (`scripts.ts`), which is a `.deb`/`.rpm` scriptlet and nothing else.
532
+ *
533
+ * The equality `tests/e2e/ship-layout` checks — same file set, same bytes, modulo
534
+ * the map — is structurally blind to all of it, because SAMENESS IS THE DEFECT:
535
+ * the Linux tree is right for a reason the other two do not have.
536
+ *
537
+ * EXHAUSTIVE, not an allow-list, and that direction is the point. A closed list of
538
+ * five was measured to say "carries 5 file(s)" for a payload carrying six — a
539
+ * `share/dbus-1/services/*.service` added through `gjsify.ship.extraFiles` is
540
+ * meaningful on Linux only because the package installs it into a system prefix,
541
+ * and it went unnamed. Anything under `share/` that is neither classified nor
542
+ * known-portable comes back as `unknown` rather than silently passing.
543
+ *
544
+ * ONE ENTRY IS ANSWERED BY THE SET, not by its own path: the GSettings schema
545
+ * directory aborts the app when it holds a source `.gschema.xml` and no compiled
546
+ * cache, and is merely inert when the cache is beside it. #1354 M2a compiles that
547
+ * cache into every non-Linux stage, so the `aborts` branch is empty for a stage
548
+ * this gjsify wrote — and it is still REACHABLE, which is the point: a stage
549
+ * assembled before the compile step existed, or one whose cache was removed,
550
+ * classifies as `aborts` again with no rule to remember to re-add.
551
+ *
552
+ * What the rest BECOME — an `Info.plist` `CFBundleDocumentTypes`, a Windows
553
+ * registry association, or nothing — is ADR 0024 stages 4 and 5. The `.app`
554
+ * container exists as of M2a; the file-type and icon halves do not.
555
+ */
556
+ export function linuxInstallDependent(entries) {
557
+ const out = [];
558
+ // Asked of the WHOLE payload once, not per entry: "does this tree carry a
559
+ // compiled schema cache" is a fact about the set, and asking it inside the
560
+ // loop would make the answer depend on iteration order.
561
+ const context = { hasCompiledSchemas: entries.some((entry) => entry.path === SCHEMA_CACHE) };
562
+ for (const entry of entries) {
563
+ if (!isUnder(entry.path, 'share'))
564
+ continue;
565
+ if (SHARE_PORTABLE.some((portable) => entry.path === portable || isUnder(entry.path, portable)))
566
+ continue;
567
+ const rule = SHARE_VERDICTS.find(([dir]) => isUnder(entry.path, dir));
568
+ if (rule === undefined) {
569
+ out.push({
570
+ path: entry.path,
571
+ verdict: 'unknown',
572
+ why: 'nothing here classifies this directory — it may need an install step no ' +
573
+ 'non-Linux layout runs, or it may be inert; say which before shipping it',
574
+ });
575
+ continue;
576
+ }
577
+ const [verdict, why] = rule[1](context);
578
+ out.push({ path: entry.path, verdict, why });
579
+ }
580
+ // `aborts` first: a list sorted by path buried the one entry that stops the
581
+ // application behind four that merely do nothing.
582
+ const rank = { aborts: 0, unknown: 1, inert: 2 };
583
+ return out.sort((a, b) => rank[a.verdict] - rank[b.verdict] || (a.path < b.path ? -1 : 1));
584
+ }
58
585
  /**
59
586
  * The interpreters the payload's own executables need, read off their shebangs.
60
587
  *
@@ -148,15 +675,44 @@ const MACHO_CPUTYPE_TO_ARCH = {
148
675
  0x01000007: 'x64',
149
676
  0x0100000c: 'arm64',
150
677
  };
678
+ /**
679
+ * PE/COFF `IMAGE_FILE_HEADER.Machine` → `process.arch`.
680
+ *
681
+ * The same three rows `manifest-conformance/lib/binary.mjs` carries, restated
682
+ * rather than imported because `@gjsify/manifest-conformance` is `"private": true`
683
+ * and `@gjsify/cli` is published: a published package cannot depend on a workspace
684
+ * package that is never on npm. (An earlier version of this note said the CLI must
685
+ * not depend on it because the rules are `portable` — `portable` is a `RuleScope`
686
+ * on individual conformance RULES, and the ban it states runs the other way: a
687
+ * portable rule must not import the CLI.) The values themselves are Microsoft's
688
+ * architecture constants, so agreeing with that file is not agreeing with
689
+ * ourselves.
690
+ */
691
+ const PE_MACHINE_TO_ARCH = {
692
+ 0x014c: 'ia32',
693
+ 0x8664: 'x64',
694
+ 0xaa64: 'arm64',
695
+ };
151
696
  /**
152
697
  * The `process.arch` token an image says it is built for, or `null` when the
153
698
  * question cannot be answered from the bytes.
154
699
  *
155
- * `null` covers three different things on purpose, and all three must stay
156
- * silent: a file that is not a native binary at all (most of a payload), a PE
157
- * (whose COFF machine field this tree has never parsed — `isNativeBinary` reads
158
- * `MZ` and stops), and a Mach-O fat archive, which carries several
159
- * architectures and therefore matches any label a caller could pass.
700
+ * `null` still covers three different things on purpose, and all three must stay
701
+ * silent: a file that is not a native binary at all (most of a payload), an image
702
+ * whose machine constant is not in the tables above, and a Mach-O fat archive,
703
+ * which carries several architectures and therefore matches any label a caller
704
+ * could pass.
705
+ *
706
+ * PE WAS A FOURTH, AND IT WAS THE WORST ONE. This comment used to say PE was
707
+ * silent because "this tree has never parsed" the COFF machine field — true, and
708
+ * it made `assertPayloadMatchesArch` VACUOUS on the one layout whose native format
709
+ * IS PE. #1354 M2b made that check non-vacuous for darwin by staging Mach-O into a
710
+ * `.app`; M3 stages `node.exe` and a win32 GTK closure into a Windows program
711
+ * directory, so a windows stage built with the wrong `--arch` would have carried
712
+ * x64 DLLs under an `arm64` label with nothing to notice. Two reads — `e_lfanew`
713
+ * at 0x3c, then `Machine` four bytes past the `PE\0\0` signature — close it. A
714
+ * truncated `MZ` with no reachable PE header stays `null`, which is what
715
+ * `packers.spec.ts` has always asserted and still does.
160
716
  */
161
717
  export function readBinaryArch(data) {
162
718
  if (data.byteLength < 20)
@@ -174,6 +730,18 @@ export function readBinaryArch(data) {
174
730
  if (magic === 0xcefaedfe || magic === 0xcffaedfe) {
175
731
  return MACHO_CPUTYPE_TO_ARCH[((data[7] << 24) | (data[6] << 16) | (data[5] << 8) | data[4]) >>> 0] ?? null;
176
732
  }
733
+ // PE/COFF: `MZ`, then `IMAGE_DOS_HEADER.e_lfanew` at 0x3c points at `PE\0\0`,
734
+ // and `IMAGE_FILE_HEADER.Machine` is the u16 right after it. Every field here
735
+ // is little-endian regardless of the machine it names.
736
+ if (data[0] === 0x4d && data[1] === 0x5a && data.byteLength >= 0x40) {
737
+ const peOffset = (data[0x3c] | (data[0x3d] << 8) | (data[0x3e] << 16) | (data[0x3f] << 24)) >>> 0;
738
+ if (peOffset + 6 > data.byteLength)
739
+ return null;
740
+ const signature = (data[peOffset] | (data[peOffset + 1] << 8) | (data[peOffset + 2] << 16)) >>> 0;
741
+ if (signature !== 0x004550 || data[peOffset + 3] !== 0x00)
742
+ return null;
743
+ return PE_MACHINE_TO_ARCH[data[peOffset + 4] | (data[peOffset + 5] << 8)] ?? null;
744
+ }
177
745
  return null;
178
746
  }
179
747
  /**
@@ -214,20 +782,37 @@ export function assertPayloadMatchesArch(payload, arch) {
214
782
  'actually have.');
215
783
  }
216
784
  }
217
- /** ELF, Mach-O (both endiannesses, both widths, and a fat archive) or PE. */
218
- function isNativeBinary(data) {
785
+ /**
786
+ * Which executable format these bytes are, or `null` for anything else.
787
+ *
788
+ * The FAMILY and not merely "is it native", because the signer needs the
789
+ * distinction: `codesign` signs Mach-O and `signtool` signs PE, and an ELF in a
790
+ * darwin payload is neither — it is a file that should not be there. A caller
791
+ * asking only "is this native" would have handed one to `codesign`, whose
792
+ * refusal names a file rather than the mistake.
793
+ *
794
+ * Magic numbers rather than suffixes, and that is load-bearing on darwin: a GTK
795
+ * closure names its images `.dylib`, `.so`, `.node` and nothing at all (the
796
+ * interpreter), so a suffix list signs three of those four.
797
+ */
798
+ export function classifyBinary(data) {
219
799
  if (data.byteLength < 4)
220
- return false;
800
+ return null;
221
801
  const magic = (data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3];
222
802
  switch (magic >>> 0) {
223
803
  case 0x7f454c46: // \x7fELF
804
+ return 'elf';
224
805
  case 0xfeedface: // Mach-O 32
225
806
  case 0xfeedfacf: // Mach-O 64
226
807
  case 0xcefaedfe: // Mach-O 32, byte-swapped
227
808
  case 0xcffaedfe: // Mach-O 64, byte-swapped
228
809
  case 0xcafebabe: // Mach-O universal binary
229
- return true;
810
+ return 'macho';
230
811
  default:
231
- return data[0] === 0x4d && data[1] === 0x5a; // MZ — PE/COFF
812
+ return data[0] === 0x4d && data[1] === 0x5a ? 'pe' : null; // MZ — PE/COFF
232
813
  }
233
814
  }
815
+ /** ELF, Mach-O (both endiannesses, both widths, and a fat archive) or PE. */
816
+ function isNativeBinary(data) {
817
+ return classifyBinary(data) !== null;
818
+ }