@gjsify/cli 0.44.0 → 0.45.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 (73) hide show
  1. package/dist/cli.gjs.mjs +192 -165
  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 +833 -54
  8. package/lib/commands/trust.d.ts +6 -0
  9. package/lib/commands/trust.js +1 -1
  10. package/lib/types/config-data.d.ts +63 -1
  11. package/lib/utils/msgfmt-merge.d.ts +35 -0
  12. package/lib/utils/msgfmt-merge.js +102 -0
  13. package/lib/utils/npm-otp.d.ts +27 -2
  14. package/lib/utils/npm-otp.js +82 -7
  15. package/lib/utils/onboard-discovery.d.ts +68 -0
  16. package/lib/utils/onboard-discovery.js +178 -0
  17. package/lib/utils/onboard-probe.d.ts +118 -1
  18. package/lib/utils/onboard-probe.js +153 -4
  19. package/lib/utils/prompt-output.d.ts +12 -0
  20. package/lib/utils/prompt-output.js +54 -0
  21. package/lib/utils/prompt.js +22 -2
  22. package/lib/utils/publishable-packages.d.ts +1 -1
  23. package/lib/utils/publishable-packages.js +1 -0
  24. package/lib/utils/resolve-npm-package.d.ts +56 -0
  25. package/lib/utils/resolve-npm-package.js +69 -0
  26. package/lib/utils/ship/app-runtime.d.ts +214 -0
  27. package/lib/utils/ship/app-runtime.js +429 -0
  28. package/lib/utils/ship/depends.d.ts +50 -0
  29. package/lib/utils/ship/depends.js +117 -3
  30. package/lib/utils/ship/discover.js +18 -1
  31. package/lib/utils/ship/dmg.d.ts +71 -0
  32. package/lib/utils/ship/dmg.js +181 -0
  33. package/lib/utils/ship/flatpak.js +1 -1
  34. package/lib/utils/ship/formats.d.ts +85 -7
  35. package/lib/utils/ship/formats.js +734 -15
  36. package/lib/utils/ship/launcher.d.ts +13 -3
  37. package/lib/utils/ship/launcher.js +362 -17
  38. package/lib/utils/ship/layout.d.ts +268 -0
  39. package/lib/utils/ship/layout.js +351 -0
  40. package/lib/utils/ship/localize-metadata.d.ts +22 -0
  41. package/lib/utils/ship/localize-metadata.js +126 -0
  42. package/lib/utils/ship/mime.js +1 -9
  43. package/lib/utils/ship/msi.d.ts +126 -0
  44. package/lib/utils/ship/msi.js +417 -0
  45. package/lib/utils/ship/node-runtime.d.ts +71 -0
  46. package/lib/utils/ship/node-runtime.js +122 -0
  47. package/lib/utils/ship/payload.d.ts +156 -5
  48. package/lib/utils/ship/payload.js +580 -14
  49. package/lib/utils/ship/plan.d.ts +21 -0
  50. package/lib/utils/ship/plan.js +20 -7
  51. package/lib/utils/ship/plist.d.ts +58 -0
  52. package/lib/utils/ship/plist.js +198 -0
  53. package/lib/utils/ship/rpm.js +9 -3
  54. package/lib/utils/ship/schemas.d.ts +61 -0
  55. package/lib/utils/ship/schemas.js +118 -0
  56. package/lib/utils/ship/scripts.js +7 -3
  57. package/lib/utils/ship/settings.d.ts +19 -1
  58. package/lib/utils/ship/settings.js +12 -1
  59. package/lib/utils/ship/share-dirs.d.ts +23 -0
  60. package/lib/utils/ship/share-dirs.js +46 -0
  61. package/lib/utils/ship/signing.d.ts +195 -0
  62. package/lib/utils/ship/signing.js +317 -0
  63. package/lib/utils/ship/stage-manifest.d.ts +40 -12
  64. package/lib/utils/ship/stage-manifest.js +99 -13
  65. package/lib/utils/ship/stage-writer.d.ts +48 -0
  66. package/lib/utils/ship/stage-writer.js +71 -1
  67. package/lib/utils/ship/types.d.ts +158 -13
  68. package/lib/utils/ship/xml.d.ts +7 -0
  69. package/lib/utils/ship/xml.js +25 -0
  70. package/lib/utils/ship/zip.d.ts +69 -0
  71. package/lib/utils/ship/zip.js +245 -0
  72. package/lib/utils/trust-registry.js +5 -0
  73. 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,521 @@ 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
+ ` Either set \`gjsify.app\` to "${other}", or fix the launcher — if it comes from ` +
444
+ '`gjsify.ship.extraFiles`,\n' +
445
+ ' that override is what decides which interpreter runs and it must match the declaration.');
446
+ }
447
+ /**
448
+ * What each known `share/` directory costs a layout with no install step.
449
+ *
450
+ * Keyed on {@link SHARE}, so the compiler — not a comment — is what keeps this in
451
+ * step with where `plan.ts` stages the files and which directory
452
+ * `cacheRefreshCommands` refreshes. The previous version claimed exactly that in
453
+ * prose over five independent string literals, and pointing one rule at a
454
+ * directory matching nothing silently dropped a file from the printed warning
455
+ * with the whole suite green at exit 0.
456
+ *
457
+ * The verdict is a FUNCTION of the payload rather than a constant, and exactly
458
+ * one row uses that: the schema directory's cost depends on whether the compiled
459
+ * cache is in the tree with it. Making it a function keeps that in the table
460
+ * instead of as a branch in the loop — one place still answers "what does this
461
+ * directory cost", which is the property the five string literals cost us.
462
+ */
463
+ const SHARE_VERDICTS = [
464
+ [
465
+ SHARE.schemas,
466
+ // The `aborts` branch is what #1354 M2a removed the cause of, and it is
467
+ // deliberately still REACHABLE: a stage assembled before the compile step
468
+ // existed, or one whose cache was deleted, classifies as `aborts` again
469
+ // with no rule to remember to re-add. A rule that can no longer fire is a
470
+ // comment; this one is a mechanism.
471
+ ({ hasCompiledSchemas }) => hasCompiledSchemas
472
+ ? [
473
+ 'inert',
474
+ 'the schema SOURCE; `gschemas.compiled` is staged beside it and that is what ' +
475
+ 'GSettings reads, so this file is carried and never opened',
476
+ ]
477
+ : [
478
+ 'aborts',
479
+ 'needs `glib-compile-schemas`, and the launcher points XDG_DATA_DIRS here — ' +
480
+ 'GSettings ABORTS on a schema directory with no `gschemas.compiled`',
481
+ ],
482
+ ],
483
+ [SHARE.mime, () => ['inert', 'needs `update-mime-database`; detection runs off the compiled cache']],
484
+ [SHARE.icons, () => ['inert', 'needs `gtk-update-icon-cache`; neither OS reads the hicolor theme']],
485
+ [SHARE.applications, () => ['inert', 'a freedesktop desktop entry; neither OS reads one']],
486
+ [SHARE.metainfo, () => ['inert', 'an AppStream component; neither OS reads one']],
487
+ ];
488
+ /**
489
+ * Paths that survive the trip, so the rule below can be an INVERSE one.
490
+ *
491
+ * Two entries and they are different SHAPES, which is why the check below tests
492
+ * both containment and equality:
493
+ *
494
+ * - `share/locale` is a DIRECTORY. A `.mo` is read straight off disk by
495
+ * `bindtextdomain`, with no install step anywhere, and the launcher hands the
496
+ * directory over on all three layouts.
497
+ * - `gschemas.compiled` is one FILE, and it is here because it is not a cost —
498
+ * it is the thing that removes one. Reported under a warning headed "whose
499
+ * Linux correctness comes from a package install step" it would name the fix
500
+ * as if it were the problem, and it would be the sixth line of a five-line
501
+ * list nobody asked to grow.
502
+ */
503
+ const SHARE_PORTABLE = [SHARE.locale, SCHEMA_CACHE];
504
+ /**
505
+ * Every `share/` entry a non-Linux layout carries, classified — including the
506
+ * ones nothing here has a rule for.
507
+ *
508
+ * WHY THIS EXISTS, and it is the hole the layout axis opened. `planStage` emits
509
+ * one prefix-relative plan and every layout carries it, so the darwin and windows
510
+ * trees hold the same `share/…` files the `.deb` does — and on Linux most of those
511
+ * are only correct because `cacheRefreshCommands` compiles or reindexes them at
512
+ * install time (`scripts.ts`), which is a `.deb`/`.rpm` scriptlet and nothing else.
513
+ *
514
+ * The equality `tests/e2e/ship-layout` checks — same file set, same bytes, modulo
515
+ * the map — is structurally blind to all of it, because SAMENESS IS THE DEFECT:
516
+ * the Linux tree is right for a reason the other two do not have.
517
+ *
518
+ * EXHAUSTIVE, not an allow-list, and that direction is the point. A closed list of
519
+ * five was measured to say "carries 5 file(s)" for a payload carrying six — a
520
+ * `share/dbus-1/services/*.service` added through `gjsify.ship.extraFiles` is
521
+ * meaningful on Linux only because the package installs it into a system prefix,
522
+ * and it went unnamed. Anything under `share/` that is neither classified nor
523
+ * known-portable comes back as `unknown` rather than silently passing.
524
+ *
525
+ * ONE ENTRY IS ANSWERED BY THE SET, not by its own path: the GSettings schema
526
+ * directory aborts the app when it holds a source `.gschema.xml` and no compiled
527
+ * cache, and is merely inert when the cache is beside it. #1354 M2a compiles that
528
+ * cache into every non-Linux stage, so the `aborts` branch is empty for a stage
529
+ * this gjsify wrote — and it is still REACHABLE, which is the point: a stage
530
+ * assembled before the compile step existed, or one whose cache was removed,
531
+ * classifies as `aborts` again with no rule to remember to re-add.
532
+ *
533
+ * What the rest BECOME — an `Info.plist` `CFBundleDocumentTypes`, a Windows
534
+ * registry association, or nothing — is ADR 0024 stages 4 and 5. The `.app`
535
+ * container exists as of M2a; the file-type and icon halves do not.
536
+ */
537
+ export function linuxInstallDependent(entries) {
538
+ const out = [];
539
+ // Asked of the WHOLE payload once, not per entry: "does this tree carry a
540
+ // compiled schema cache" is a fact about the set, and asking it inside the
541
+ // loop would make the answer depend on iteration order.
542
+ const context = { hasCompiledSchemas: entries.some((entry) => entry.path === SCHEMA_CACHE) };
543
+ for (const entry of entries) {
544
+ if (!isUnder(entry.path, 'share'))
545
+ continue;
546
+ if (SHARE_PORTABLE.some((portable) => entry.path === portable || isUnder(entry.path, portable)))
547
+ continue;
548
+ const rule = SHARE_VERDICTS.find(([dir]) => isUnder(entry.path, dir));
549
+ if (rule === undefined) {
550
+ out.push({
551
+ path: entry.path,
552
+ verdict: 'unknown',
553
+ why: 'nothing here classifies this directory — it may need an install step no ' +
554
+ 'non-Linux layout runs, or it may be inert; say which before shipping it',
555
+ });
556
+ continue;
557
+ }
558
+ const [verdict, why] = rule[1](context);
559
+ out.push({ path: entry.path, verdict, why });
560
+ }
561
+ // `aborts` first: a list sorted by path buried the one entry that stops the
562
+ // application behind four that merely do nothing.
563
+ const rank = { aborts: 0, unknown: 1, inert: 2 };
564
+ return out.sort((a, b) => rank[a.verdict] - rank[b.verdict] || (a.path < b.path ? -1 : 1));
565
+ }
58
566
  /**
59
567
  * The interpreters the payload's own executables need, read off their shebangs.
60
568
  *
@@ -148,15 +656,44 @@ const MACHO_CPUTYPE_TO_ARCH = {
148
656
  0x01000007: 'x64',
149
657
  0x0100000c: 'arm64',
150
658
  };
659
+ /**
660
+ * PE/COFF `IMAGE_FILE_HEADER.Machine` → `process.arch`.
661
+ *
662
+ * The same three rows `manifest-conformance/lib/binary.mjs` carries, restated
663
+ * rather than imported because `@gjsify/manifest-conformance` is `"private": true`
664
+ * and `@gjsify/cli` is published: a published package cannot depend on a workspace
665
+ * package that is never on npm. (An earlier version of this note said the CLI must
666
+ * not depend on it because the rules are `portable` — `portable` is a `RuleScope`
667
+ * on individual conformance RULES, and the ban it states runs the other way: a
668
+ * portable rule must not import the CLI.) The values themselves are Microsoft's
669
+ * architecture constants, so agreeing with that file is not agreeing with
670
+ * ourselves.
671
+ */
672
+ const PE_MACHINE_TO_ARCH = {
673
+ 0x014c: 'ia32',
674
+ 0x8664: 'x64',
675
+ 0xaa64: 'arm64',
676
+ };
151
677
  /**
152
678
  * The `process.arch` token an image says it is built for, or `null` when the
153
679
  * question cannot be answered from the bytes.
154
680
  *
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.
681
+ * `null` still covers three different things on purpose, and all three must stay
682
+ * silent: a file that is not a native binary at all (most of a payload), an image
683
+ * whose machine constant is not in the tables above, and a Mach-O fat archive,
684
+ * which carries several architectures and therefore matches any label a caller
685
+ * could pass.
686
+ *
687
+ * PE WAS A FOURTH, AND IT WAS THE WORST ONE. This comment used to say PE was
688
+ * silent because "this tree has never parsed" the COFF machine field — true, and
689
+ * it made `assertPayloadMatchesArch` VACUOUS on the one layout whose native format
690
+ * IS PE. #1354 M2b made that check non-vacuous for darwin by staging Mach-O into a
691
+ * `.app`; M3 stages `node.exe` and a win32 GTK closure into a Windows program
692
+ * directory, so a windows stage built with the wrong `--arch` would have carried
693
+ * x64 DLLs under an `arm64` label with nothing to notice. Two reads — `e_lfanew`
694
+ * at 0x3c, then `Machine` four bytes past the `PE\0\0` signature — close it. A
695
+ * truncated `MZ` with no reachable PE header stays `null`, which is what
696
+ * `packers.spec.ts` has always asserted and still does.
160
697
  */
161
698
  export function readBinaryArch(data) {
162
699
  if (data.byteLength < 20)
@@ -174,6 +711,18 @@ export function readBinaryArch(data) {
174
711
  if (magic === 0xcefaedfe || magic === 0xcffaedfe) {
175
712
  return MACHO_CPUTYPE_TO_ARCH[((data[7] << 24) | (data[6] << 16) | (data[5] << 8) | data[4]) >>> 0] ?? null;
176
713
  }
714
+ // PE/COFF: `MZ`, then `IMAGE_DOS_HEADER.e_lfanew` at 0x3c points at `PE\0\0`,
715
+ // and `IMAGE_FILE_HEADER.Machine` is the u16 right after it. Every field here
716
+ // is little-endian regardless of the machine it names.
717
+ if (data[0] === 0x4d && data[1] === 0x5a && data.byteLength >= 0x40) {
718
+ const peOffset = (data[0x3c] | (data[0x3d] << 8) | (data[0x3e] << 16) | (data[0x3f] << 24)) >>> 0;
719
+ if (peOffset + 6 > data.byteLength)
720
+ return null;
721
+ const signature = (data[peOffset] | (data[peOffset + 1] << 8) | (data[peOffset + 2] << 16)) >>> 0;
722
+ if (signature !== 0x004550 || data[peOffset + 3] !== 0x00)
723
+ return null;
724
+ return PE_MACHINE_TO_ARCH[data[peOffset + 4] | (data[peOffset + 5] << 8)] ?? null;
725
+ }
177
726
  return null;
178
727
  }
179
728
  /**
@@ -214,20 +763,37 @@ export function assertPayloadMatchesArch(payload, arch) {
214
763
  'actually have.');
215
764
  }
216
765
  }
217
- /** ELF, Mach-O (both endiannesses, both widths, and a fat archive) or PE. */
218
- function isNativeBinary(data) {
766
+ /**
767
+ * Which executable format these bytes are, or `null` for anything else.
768
+ *
769
+ * The FAMILY and not merely "is it native", because the signer needs the
770
+ * distinction: `codesign` signs Mach-O and `signtool` signs PE, and an ELF in a
771
+ * darwin payload is neither — it is a file that should not be there. A caller
772
+ * asking only "is this native" would have handed one to `codesign`, whose
773
+ * refusal names a file rather than the mistake.
774
+ *
775
+ * Magic numbers rather than suffixes, and that is load-bearing on darwin: a GTK
776
+ * closure names its images `.dylib`, `.so`, `.node` and nothing at all (the
777
+ * interpreter), so a suffix list signs three of those four.
778
+ */
779
+ export function classifyBinary(data) {
219
780
  if (data.byteLength < 4)
220
- return false;
781
+ return null;
221
782
  const magic = (data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3];
222
783
  switch (magic >>> 0) {
223
784
  case 0x7f454c46: // \x7fELF
785
+ return 'elf';
224
786
  case 0xfeedface: // Mach-O 32
225
787
  case 0xfeedfacf: // Mach-O 64
226
788
  case 0xcefaedfe: // Mach-O 32, byte-swapped
227
789
  case 0xcffaedfe: // Mach-O 64, byte-swapped
228
790
  case 0xcafebabe: // Mach-O universal binary
229
- return true;
791
+ return 'macho';
230
792
  default:
231
- return data[0] === 0x4d && data[1] === 0x5a; // MZ — PE/COFF
793
+ return data[0] === 0x4d && data[1] === 0x5a ? 'pe' : null; // MZ — PE/COFF
232
794
  }
233
795
  }
796
+ /** ELF, Mach-O (both endiannesses, both widths, and a fat archive) or PE. */
797
+ function isNativeBinary(data) {
798
+ return classifyBinary(data) !== null;
799
+ }