@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.
- package/dist/cli.gjs.mjs +192 -165
- package/lib/commands/gettext.d.ts +2 -1
- package/lib/commands/gettext.js +105 -47
- package/lib/commands/onboard.d.ts +7 -0
- package/lib/commands/onboard.js +349 -96
- package/lib/commands/ship.d.ts +3 -0
- package/lib/commands/ship.js +833 -54
- package/lib/commands/trust.d.ts +6 -0
- package/lib/commands/trust.js +1 -1
- package/lib/types/config-data.d.ts +63 -1
- package/lib/utils/msgfmt-merge.d.ts +35 -0
- package/lib/utils/msgfmt-merge.js +102 -0
- package/lib/utils/npm-otp.d.ts +27 -2
- package/lib/utils/npm-otp.js +82 -7
- package/lib/utils/onboard-discovery.d.ts +68 -0
- package/lib/utils/onboard-discovery.js +178 -0
- package/lib/utils/onboard-probe.d.ts +118 -1
- package/lib/utils/onboard-probe.js +153 -4
- package/lib/utils/prompt-output.d.ts +12 -0
- package/lib/utils/prompt-output.js +54 -0
- package/lib/utils/prompt.js +22 -2
- package/lib/utils/publishable-packages.d.ts +1 -1
- package/lib/utils/publishable-packages.js +1 -0
- package/lib/utils/resolve-npm-package.d.ts +56 -0
- package/lib/utils/resolve-npm-package.js +69 -0
- package/lib/utils/ship/app-runtime.d.ts +214 -0
- package/lib/utils/ship/app-runtime.js +429 -0
- package/lib/utils/ship/depends.d.ts +50 -0
- package/lib/utils/ship/depends.js +117 -3
- package/lib/utils/ship/discover.js +18 -1
- package/lib/utils/ship/dmg.d.ts +71 -0
- package/lib/utils/ship/dmg.js +181 -0
- package/lib/utils/ship/flatpak.js +1 -1
- package/lib/utils/ship/formats.d.ts +85 -7
- package/lib/utils/ship/formats.js +734 -15
- package/lib/utils/ship/launcher.d.ts +13 -3
- package/lib/utils/ship/launcher.js +362 -17
- package/lib/utils/ship/layout.d.ts +268 -0
- package/lib/utils/ship/layout.js +351 -0
- package/lib/utils/ship/localize-metadata.d.ts +22 -0
- package/lib/utils/ship/localize-metadata.js +126 -0
- package/lib/utils/ship/mime.js +1 -9
- package/lib/utils/ship/msi.d.ts +126 -0
- package/lib/utils/ship/msi.js +417 -0
- package/lib/utils/ship/node-runtime.d.ts +71 -0
- package/lib/utils/ship/node-runtime.js +122 -0
- package/lib/utils/ship/payload.d.ts +156 -5
- package/lib/utils/ship/payload.js +580 -14
- package/lib/utils/ship/plan.d.ts +21 -0
- package/lib/utils/ship/plan.js +20 -7
- package/lib/utils/ship/plist.d.ts +58 -0
- package/lib/utils/ship/plist.js +198 -0
- package/lib/utils/ship/rpm.js +9 -3
- package/lib/utils/ship/schemas.d.ts +61 -0
- package/lib/utils/ship/schemas.js +118 -0
- package/lib/utils/ship/scripts.js +7 -3
- package/lib/utils/ship/settings.d.ts +19 -1
- package/lib/utils/ship/settings.js +12 -1
- package/lib/utils/ship/share-dirs.d.ts +23 -0
- package/lib/utils/ship/share-dirs.js +46 -0
- package/lib/utils/ship/signing.d.ts +195 -0
- package/lib/utils/ship/signing.js +317 -0
- package/lib/utils/ship/stage-manifest.d.ts +40 -12
- package/lib/utils/ship/stage-manifest.js +99 -13
- package/lib/utils/ship/stage-writer.d.ts +48 -0
- package/lib/utils/ship/stage-writer.js +71 -1
- package/lib/utils/ship/types.d.ts +158 -13
- package/lib/utils/ship/xml.d.ts +7 -0
- package/lib/utils/ship/xml.js +25 -0
- package/lib/utils/ship/zip.d.ts +69 -0
- package/lib/utils/ship/zip.js +245 -0
- package/lib/utils/trust-registry.js +5 -0
- 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.
|
|
49
|
-
hasIcons: paths.some((path) => path.
|
|
50
|
-
hasSchemas: paths.some((path) => path.
|
|
51
|
-
hasMimeTypes: paths.some((path) => path.
|
|
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),
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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
|
-
/**
|
|
218
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|