@gjsify/cli 0.45.0 → 0.47.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 (37) hide show
  1. package/dist/cli.gjs.mjs +144 -135
  2. package/lib/commands/onboard.js +11 -0
  3. package/lib/commands/publish.d.ts +41 -0
  4. package/lib/commands/publish.js +222 -2
  5. package/lib/commands/ship.js +89 -76
  6. package/lib/commands/upgrade.d.ts +23 -0
  7. package/lib/commands/upgrade.js +95 -12
  8. package/lib/templates/install.mjs.tmpl +2 -2
  9. package/lib/types/config-data.d.ts +64 -0
  10. package/lib/utils/gi-search-path.js +11 -3
  11. package/lib/utils/publish-readback.d.ts +132 -0
  12. package/lib/utils/publish-readback.js +273 -0
  13. package/lib/utils/ship/discover.js +85 -0
  14. package/lib/utils/ship/formats.d.ts +23 -0
  15. package/lib/utils/ship/formats.js +44 -0
  16. package/lib/utils/ship/launcher.js +57 -0
  17. package/lib/utils/ship/layout.d.ts +64 -2
  18. package/lib/utils/ship/layout.js +169 -12
  19. package/lib/utils/ship/msi.js +24 -7
  20. package/lib/utils/ship/payload.js +22 -3
  21. package/lib/utils/ship/pe-launcher.d.ts +29 -0
  22. package/lib/utils/ship/pe-launcher.js +663 -0
  23. package/lib/utils/ship/plan.js +35 -0
  24. package/lib/utils/ship/plist.d.ts +41 -1
  25. package/lib/utils/ship/plist.js +59 -1
  26. package/lib/utils/ship/rpm.js +7 -0
  27. package/lib/utils/ship/settings.d.ts +76 -5
  28. package/lib/utils/ship/settings.js +89 -8
  29. package/lib/utils/ship/share-dirs.d.ts +30 -0
  30. package/lib/utils/ship/share-dirs.js +30 -0
  31. package/lib/utils/ship/signing.d.ts +122 -6
  32. package/lib/utils/ship/signing.js +256 -67
  33. package/lib/utils/ship/stage-writer.js +20 -1
  34. package/lib/utils/ship/types.d.ts +34 -2
  35. package/lib/utils/system-gi.d.ts +20 -5
  36. package/lib/utils/system-gi.js +36 -13
  37. package/package.json +19 -19
@@ -30,38 +30,53 @@
30
30
  // exist before the validation, because they are computed from its output,
31
31
  // and the arriving stage is never written to at all.
32
32
  //
33
- // WHAT THIS DELIBERATELY DOES NOT DO, each with its reason:
33
+ // THREE THINGS THIS FILE USED TO REFUSE TO DO, and the measurement that changed
34
+ // each of them (ADR 0040). Every one is marked UNVERIFIED where it is: this
35
+ // repository has no macOS host of its own, so what follows is argv and file
36
+ // structure decided from Apple's documentation and held by unit tests, with the
37
+ // darwin CI leg as the only thing that has ever run `codesign` here.
34
38
  //
35
- // * It does not seal the `<App>.app` BUNDLE. `codesign` on a bundle writes
36
- // `Contents/_CodeSignature/CodeResources` and — for a main executable that is
37
- // a shell script, which every layout's launcher is — stores that script's own
38
- // signature in an extended attribute (Apple's documented behaviour, not
39
- // measured here). What IS measured here is our side of it: the payload round
40
- // trip is `readStage` → bytes + mode → `writePayload`, and it carries no
41
- // extended attributes at all, so a bundle seal made now would not survive into
42
- // the `.zip` beside the `.app`. The images the dynamic loader validates are
43
- // the ones signed, which is what § A4's argument is about.
44
- // * It passes no `--options runtime`, no `--entitlements` and no `--timestamp`.
45
- // § A16 leaves library validation vs. hardened runtime explicitly unmeasured,
46
- // and a secure timestamp needs a real Developer ID plus network. Adding any of
47
- // them would be code no run in this repository has ever executed.
48
- // * It does not staple, and the reason is NOT that stapling is unevidenced.
49
- // Measured here on `refs/node` at the pinned `0618e9f0`, against the ADR's own
50
- // control of 16 files for `codesign`: `stapler` returns 4 files, of which
51
- // `tools/osx-notarize.sh:58` is real code — `xcrun stapler staple
52
- // "node-$pkgid.pkg"`, three lines past where § A15 stopped quoting; the other
53
- // three are changelog entries. What is NOT measured is whether it accepts
54
- // OUR container: the reference staples a `.pkg`, and the only file-shaped
55
- // darwin artifact this command produces is a `.zip`. Adding a call that may
56
- // refuse the one artifact it would run on is code no run in this repository
57
- // has exercised, deciding something it cannot justify. Recorded in
58
- // `status/open-todos.md` instead.
59
- import { readFileSync } from 'node:fs';
60
- import { join, sep } from 'node:path';
39
+ // * IT NOW SEALS THE `<App>.app` BUNDLE, and the reason it did not was WRONG.
40
+ // The header used to say a bundle seal "would not survive into the `.zip`"
41
+ // because a script main executable's signature lives in an extended attribute
42
+ // and the payload round trip carries none. That is Apple's rule for a LOOSE
43
+ // file, not for a bundle. Apple's TN3126 gives four cases and ours is the
44
+ // second: *"If the item is a bundle without a Mach-O image, the code signature
45
+ // is stored in the bundle's _CodeSignature directory"* — regular files, mode
46
+ // 0644, which a plain zip carries like any other. TN2206 states the same from
47
+ // the other side: *"a properly-signed app that has all of its files in the
48
+ // correct places will not contain any signatures stored as extended
49
+ // attributes."* So the seal is reachable, and what was actually blocking it was
50
+ // this file's own refusal of a changed file set. {@link signPayload} now allows
51
+ // exactly the additions the seal makes and still refuses every other one.
52
+ // * IT NOW PASSES `--options runtime`, `--entitlements` and (for a real identity)
53
+ // `--timestamp`. The hardened runtime is a bit in the CODE DIRECTORY, which an
54
+ // ad-hoc signature has, so the flag is accepted with `--sign -` as it is with a
55
+ // Developer ID. `--timestamp` is not: a timestamp lives in the CMS structure
56
+ // and an ad-hoc signature has no CMS and no certificate to pin, so it is passed
57
+ // only for a named identity — and Apple documents nothing about what
58
+ // `codesign --sign - --timestamp` does, which is a second reason not to send it.
59
+ // * IT NOW STAPLES what `stapler` accepts. `stapler(1)` is explicit —
60
+ // *"stapler works only with UDIF disk images, signed \"flat\" installer
61
+ // packages, and certain code-signed executable bundles such as \".app\""* — and
62
+ // Apple's notarisation guide adds the exception that decides our zip:
63
+ // *"While you can notarize a ZIP archive, you can't staple to it directly.
64
+ // Instead, run stapler against each item that you added to the archive."*
65
+ // `canCarryTicket` in `utils/ship/formats.ts` is that fact per format, so the
66
+ // `.dmg` is stapled and the `.zip` is told what a user has to do instead.
67
+ //
68
+ // WHAT IS STILL NOT DONE, deliberately: `signtool` is never given a timestamp URL
69
+ // (see the win32 row), and the entitlement § A16 is about —
70
+ // `com.apple.security.cs.disable-library-validation` — is NOT granted, because
71
+ // § A4's re-sign of every image in the closure is the design of record and
72
+ // granting the entitlement would quietly make it look unnecessary.
73
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
74
+ import { dirname, join, sep } from 'node:path';
61
75
  import { isOnPath } from '../check-system-deps.js';
62
76
  import { describeExit, spawnToCompletion } from '../spawn.js';
63
77
  import { listFilesRecursive } from './discover.js';
64
78
  import { classifyBinary } from './payload.js';
79
+ import { renderEntitlements } from './plist.js';
65
80
  import { writePayload } from './stage-writer.js';
66
81
  /**
67
82
  * The signers, keyed by the LAYOUT they sign — never by the host they run on.
@@ -87,10 +102,63 @@ export const SIGNERS = {
87
102
  // ad-hoc (§ A4: 106 of 106) — `install_name_tool` invalidates the original
88
103
  // during relocation and the relocator re-signs. Without it `codesign`
89
104
  // refuses a file that already carries a signature.
90
- args: (identity, file) => ['--force', '--sign', identity, file],
105
+ //
106
+ // `--options runtime` on BOTH identities and `--timestamp` on only one:
107
+ // the hardened runtime is a code-directory bit, which an ad-hoc signature
108
+ // has, while a timestamp is a CMS countersignature over a CERTIFICATE, and
109
+ // an ad-hoc signature has neither ("Ad-hoc signing does not use an identity
110
+ // at all", `codesign(1)`). Apple documents nothing about what
111
+ // `--sign - --timestamp` does, so it is not sent rather than guessed at —
112
+ // and the one thing `codesign(1)` does promise about the flag is that "if
113
+ // the timestamp authority service cannot be contacted … the signing
114
+ // operation will fail", which is not a failure worth inventing for a
115
+ // signature that cannot carry the result.
116
+ args: ({ identity, file, entitlements, adhoc }) => [
117
+ '--force',
118
+ '--sign',
119
+ identity,
120
+ '--options',
121
+ 'runtime',
122
+ ...(entitlements === undefined ? [] : ['--entitlements', entitlements]),
123
+ ...(adhoc ? [] : ['--timestamp']),
124
+ file,
125
+ ],
126
+ // FOUR OF THE REFERENCE'S SIX, and both omissions are decisions:
127
+ //
128
+ // * `com.apple.security.cs.get-task-allow` is granted by
129
+ // `refs/node/tools/osx-entitlements.plist:15` and is a DEBUGGING
130
+ // entitlement — it lets another process attach to this one. Apple's
131
+ // notarisation rules refuse a Developer-ID artifact carrying it, so
132
+ // shipping it would trade a working debug build for an artifact that
133
+ // cannot be distributed. The reference signs its own build; we sign what
134
+ // a stranger downloads.
135
+ // * `com.apple.security.cs.disable-library-validation` is § A16's open
136
+ // question and stays open. § A4's design of record is that every Mach-O
137
+ // in the closure is re-signed with the SAME identity as the launcher, so
138
+ // library validation has nothing to object to — and granting the
139
+ // entitlement anyway would make that re-sign look optional the first
140
+ // time somebody reads this list.
141
+ //
142
+ // The four that remain are what a shipped V8 needs under a hardened
143
+ // runtime, plus the one our own loader story needs:
144
+ // `@gjsify/node-gi`'s `maybeReexecForGtkRuntime` sets `DYLD_*` for its
145
+ // child on darwin, and a hardened process may not do that without
146
+ // `allow-dyld-environment-variables`.
147
+ entitlements: [
148
+ 'com.apple.security.cs.allow-jit',
149
+ 'com.apple.security.cs.allow-unsigned-executable-memory',
150
+ 'com.apple.security.cs.disable-executable-page-protection',
151
+ 'com.apple.security.cs.allow-dyld-environment-variables',
152
+ ],
153
+ // The `.app` is a bundle, and a bundle's signature is the seal over its
154
+ // whole tree — the images alone leave `codesign --verify` with nothing to
155
+ // read at the bundle level.
156
+ sealsBundle: true,
157
+ sealAddPrefix: (bundleRoot) => `${bundleRoot}/Contents/_CodeSignature/`,
91
158
  configKey: 'gjsify.ship.sign.darwin.identity',
92
- gap: 'the images are signed; the `.app` bundle itself is not sealed and no entitlements are ' +
93
- 'granted (ADR 0024 § A16 leaves library validation unmeasured)',
159
+ gap: 'the images are signed and the bundle is sealed; UNVERIFIED end to end — no macOS host in this ' +
160
+ 'repository has run a `--sign` over a real `.app`, and ADR 0024 § A16 still leaves library ' +
161
+ 'validation unmeasured',
94
162
  },
95
163
  win32: {
96
164
  layoutOs: 'win32',
@@ -108,7 +176,17 @@ export const SIGNERS = {
108
176
  // records that SmartScreen only WARNS until per-file-hash reputation
109
177
  // accrues — signed or not. The argv is unit-tested, the skip and the
110
178
  // refusals are e2e-tested, and the invocation itself has no proof.
111
- args: (identity, file) => ['sign', '/n', identity, '/fd', 'sha256', file],
179
+ args: ({ identity, file }) => ['sign', '/n', identity, '/fd', 'sha256', file],
180
+ // Windows has no equivalent concept. An Authenticode signature carries no
181
+ // capability claims — what a hardened runtime and an entitlement do on
182
+ // macOS is done on Windows by the manifest and by AppContainer, neither of
183
+ // which this signer touches.
184
+ entitlements: [],
185
+ // A Windows program directory is a DIRECTORY and nothing more: there is no
186
+ // manifest to seal and no per-directory signature format. `signtool` signs
187
+ // images, one at a time, which is what the loop above already does.
188
+ sealsBundle: false,
189
+ sealAddPrefix: () => '',
112
190
  configKey: 'gjsify.ship.sign.win32.identity',
113
191
  gap: 'no timestamp countersignature is requested, so the signature expires with the certificate ' +
114
192
  '(UNVERIFIED: no run in this repository has ever invoked signtool)',
@@ -174,6 +252,31 @@ export function assertHostCanSign(signer) {
174
252
  'a legitimate output (ADR 0024 § A13); silently producing one while --sign was asked for is not.');
175
253
  }
176
254
  }
255
+ /**
256
+ * What a signing run did to the payload's FILE SET, split three ways.
257
+ *
258
+ * PURE, and extracted from {@link signPayload} for one reason: it is the whole of
259
+ * the seal decision and the only half of it that can be checked without a macOS
260
+ * host. Everything else about a bundle seal needs `codesign`, which this
261
+ * repository has on exactly one CI leg; this function needs nothing.
262
+ *
263
+ * `sealPrefix` absent means "this signer seals nothing", and then EVERY addition
264
+ * is unexpected — which is what the file did before ADR 0040 and is still the
265
+ * right answer for `signtool`.
266
+ */
267
+ export function partitionSignedFileSet(input) {
268
+ const planned = new Set(input.planned);
269
+ const arrived = new Set(input.arrived);
270
+ const added = [...arrived].filter((path) => !planned.has(path)).sort();
271
+ const prefix = input.sealPrefix;
272
+ return {
273
+ sealed: prefix === undefined ? [] : added.filter((path) => path.startsWith(prefix)),
274
+ unexpected: prefix === undefined ? added : added.filter((path) => !path.startsWith(prefix)),
275
+ removed: [...planned].filter((path) => !arrived.has(path)).sort(),
276
+ };
277
+ }
278
+ /** `codesign --sign -`: the one identity value that names no identity at all. */
279
+ export const ADHOC_IDENTITY = '-';
177
280
  /**
178
281
  * Re-sign every signable image in the payload, and hand back the new bytes.
179
282
  *
@@ -184,28 +287,29 @@ export function assertHostCanSign(signer) {
184
287
  * same tree fail `readStage`'s size comparison (measured — see the header), and
185
288
  * `writeStage`'s wipe would delete the payload it was about to pack.
186
289
  *
187
- * THE FILE SET MAY NOT CHANGE, and that is asserted rather than assumed. Both
188
- * tools rewrite an image in place, so a payload that gained or lost a path here
189
- * means something else ran — a bundle seal writing `_CodeSignature/`, say, whose
190
- * files would then be dropped on the way back into the payload without anyone
191
- * noticing.
290
+ * TWO STEPS ON DARWIN, ONE EVERYWHERE ELSE: every Mach-O in the closure, then the
291
+ * bundle itself. The second is what `codesign --verify <App>.app` reads, and it
292
+ * writes five regular files into `Contents/_CodeSignature/` that the returned
293
+ * payload carries — so the seal reaches the `.zip` and the `.dmg` alike, which is
294
+ * the correction ADR 0040 makes to this file's previous claim that it could not.
295
+ *
296
+ * THE FILE SET MAY ONLY GROW WHERE THE SEAL WRITES, and that is asserted rather
297
+ * than assumed. Both tools rewrite an image in place, so a payload that gained a
298
+ * path outside `sealAddPrefix` — or lost one anywhere — means something else ran,
299
+ * and its files would be packed with no mode from the plan.
192
300
  */
193
301
  export async function signPayload(input) {
194
302
  const { payload, signer, workDir } = input;
195
303
  writePayload(workDir, payload, '');
196
- const signable = payload.filter((entry) => classifyBinary(entry.data) === signer.signs);
197
- if (signable.length === 0) {
198
- // NOT silent, and not an error either. A `--app gjs` payload really is
199
- // JavaScript and a launcher, so there is nothing for the loader to
200
- // validate — but a run that asked for a signature and made none has to
201
- // say so, or "signed" and "signed nothing" look identical in the log.
202
- input.log(`nothing in this payload is a ${signer.signs === 'macho' ? 'Mach-O' : 'PE'} image, so ` +
203
- `${signer.tool} signed 0 file(s). The artifact carries no signature.`);
204
- return [...payload];
304
+ const adhoc = input.identity === ADHOC_IDENTITY;
305
+ let entitlements;
306
+ if (signer.entitlements.length > 0) {
307
+ mkdirSync(dirname(input.entitlementsPath), { recursive: true });
308
+ writeFileSync(input.entitlementsPath, renderEntitlements(signer.entitlements));
309
+ entitlements = input.entitlementsPath;
205
310
  }
206
- for (const entry of signable) {
207
- const file = join(workDir, entry.path.split('/').join(sep));
208
- const args = signer.args(input.identity, file);
311
+ const run = async (file, what) => {
312
+ const args = signer.args({ identity: input.identity, file, adhoc, ...(entitlements ? { entitlements } : {}) });
209
313
  if (input.verbose)
210
314
  input.log(`${signer.tool} ${args.join(' ')}`);
211
315
  const result = await spawnToCompletion(signer.tool, args, {
@@ -215,33 +319,75 @@ export async function signPayload(input) {
215
319
  notFound: () => new Error(`gjsify ship: \`${signer.tool}\` is not on PATH — ${signer.installHint}.`),
216
320
  });
217
321
  if (result.code !== 0) {
218
- throw new Error(`gjsify ship: ${signer.tool} failed on ${entry.path} with ${describeExit(result)}. ` +
322
+ throw new Error(`gjsify ship: ${signer.tool} failed on ${what} with ${describeExit(result)}. ` +
219
323
  'Nothing is packed from a payload whose signature failed: an artifact that is half ' +
220
324
  'signed installs and is refused at first launch, which is worse than not shipping.');
221
325
  }
326
+ };
327
+ const signable = payload.filter((entry) => classifyBinary(entry.data) === signer.signs);
328
+ if (signable.length === 0 && !signer.sealsBundle) {
329
+ // NOT silent, and not an error either. A `--app gjs` payload really is
330
+ // JavaScript and a launcher, so there is nothing for the loader to
331
+ // validate — but a run that asked for a signature and made none has to
332
+ // say so, or "signed" and "signed nothing" look identical in the log.
333
+ //
334
+ // `&& !sealsBundle` is what keeps the message TRUE rather than what
335
+ // narrows it: a darwin run with no image still seals the bundle, which is
336
+ // a signature, so printing "the artifact carries no signature" there would
337
+ // be the log contradicting the artifact.
338
+ input.log(`nothing in this payload is a ${signer.signs === 'macho' ? 'Mach-O' : 'PE'} image, so ` +
339
+ `${signer.tool} signed 0 file(s). The artifact carries no signature.`);
340
+ return [...payload];
222
341
  }
223
- const arrived = new Set(listFilesRecursive(workDir));
224
- const planned = new Set(payload.map((entry) => entry.path));
225
- const added = [...arrived].filter((path) => !planned.has(path));
226
- const removed = [...planned].filter((path) => !arrived.has(path));
227
- if (added.length > 0 || removed.length > 0) {
342
+ for (const entry of signable) {
343
+ await run(join(workDir, entry.path.split('/').join(sep)), entry.path);
344
+ }
345
+ // THE IMAGES FIRST, THE SEAL LAST, and the order is not stylistic: a bundle
346
+ // seal hashes the files it seals, so sealing before the closure is signed
347
+ // would record the digests of the images this run is about to replace and
348
+ // produce a bundle that verifies against a tree it no longer contains.
349
+ let sealPrefix;
350
+ if (signer.sealsBundle) {
351
+ if (input.bundleRoot === '') {
352
+ throw new Error(`gjsify ship: the ${signer.layoutOs} signer seals a bundle and this layout has no root to ` +
353
+ 'seal. `Layout.root` is what names the artifact directory; a signer that sealed the whole ' +
354
+ 'stage would sign the payload of every other format beside it.');
355
+ }
356
+ sealPrefix = signer.sealAddPrefix(input.bundleRoot);
357
+ await run(join(workDir, input.bundleRoot.split('/').join(sep)), `${input.bundleRoot} (bundle seal)`);
358
+ }
359
+ // THE FILE SET MAY ONLY GROW WHERE THE SEAL WRITES. Both tools rewrite an
360
+ // image in place, so any other new path means something else ran — and a file
361
+ // that appeared unnoticed would be packed with no mode from the plan, while
362
+ // one that vanished would be packed as absent.
363
+ const changes = partitionSignedFileSet({
364
+ arrived: listFilesRecursive(workDir),
365
+ planned: payload.map((entry) => entry.path),
366
+ ...(sealPrefix === undefined ? {} : { sealPrefix }),
367
+ });
368
+ if (changes.unexpected.length > 0 || changes.removed.length > 0) {
228
369
  throw new Error(`gjsify ship: ${signer.tool} changed the payload's file set — ` +
229
- `${added.length} added (${added.slice(0, 3).join(', ') || '—'}), ` +
230
- `${removed.length} removed (${removed.slice(0, 3).join(', ') || '—'}). ` +
231
- 'Signing is a mutation of the files that are already in the payload (ADR 0024 § A4); a ' +
232
- 'file that appeared here would be packed with no mode from the plan, and one that ' +
233
- 'vanished would be packed as absent.');
370
+ `${changes.unexpected.length} added (${changes.unexpected.slice(0, 3).join(', ') || '—'}), ` +
371
+ `${changes.removed.length} removed (${changes.removed.slice(0, 3).join(', ') || '—'}). ` +
372
+ 'Signing is a mutation of the files that are already in the payload (ADR 0024 § A4), plus ' +
373
+ `exactly the seal's own directory${sealPrefix === undefined ? '' : ` (\`${sealPrefix}\`)`}.`);
234
374
  }
375
+ const sealed = changes.sealed;
235
376
  input.log(`${signer.tool} signed ${signable.length} of ${payload.length} payload file(s) as ` +
236
- `${input.identity === '-' ? 'ad-hoc (`-`)' : `\`${input.identity}\``} — ${signer.gap}.`);
377
+ `${adhoc ? 'ad-hoc (`-`)' : `\`${input.identity}\``}` +
378
+ `${sealPrefix === undefined ? '' : ` and sealed ${input.bundleRoot} (${sealed.length} seal file(s))`}` +
379
+ ` — ${signer.gap}.`);
237
380
  // Modes come from the PLAN, exactly as `readStage` insists: the signer
238
381
  // rewrites bytes and this re-read must not turn a 0755 launcher into
239
- // whatever the scratch directory's umask produced.
240
- return payload.map((entry) => ({
241
- path: entry.path,
242
- mode: entry.mode,
243
- data: new Uint8Array(readFileSync(join(workDir, entry.path.split('/').join(sep)))),
244
- }));
382
+ // whatever the scratch directory's umask produced. The seal's own files have
383
+ // no plan entry — they did not exist when the plan was made — so they take
384
+ // 0644, which is what `codesign` writes them as
385
+ // (`bundlediskrep.cpp`: `O_WRONLY | O_CREAT | O_TRUNC, 0644`).
386
+ const read = (path) => new Uint8Array(readFileSync(join(workDir, path.split('/').join(sep))));
387
+ return [
388
+ ...payload.map((entry) => ({ path: entry.path, mode: entry.mode, data: read(entry.path) })),
389
+ ...sealed.map((path) => ({ path, mode: 0o644, data: read(path) })),
390
+ ].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
245
391
  }
246
392
  export const NOTARIES = {
247
393
  darwin: {
@@ -313,5 +459,48 @@ export async function notarizeArtifact(input) {
313
459
  if (result.code !== 0) {
314
460
  throw new Error(`gjsify ship: ${notary.tool} notarytool failed with ${describeExit(result)}.`);
315
461
  }
316
- input.log(`${notary.tool} notarytool accepted ${input.artifact}. The artifact is NOT stapled.`);
462
+ input.log(`${notary.tool} notarytool accepted ${input.artifact}.`);
463
+ }
464
+ /**
465
+ * Attach the notarisation ticket to an artifact that can hold one.
466
+ *
467
+ * WHY IT IS A SEPARATE CALL AND NOT THE TAIL OF {@link notarizeArtifact}: not
468
+ * every artifact the notary service ACCEPTS can carry a ticket, and the pair that
469
+ * comes apart is exactly the one this command produces. Apple:
470
+ * *"While you can notarize a ZIP archive, you can't staple to it directly.
471
+ * Instead, run stapler against each item that you added to the archive. Then
472
+ * create a new ZIP file containing the stapled items for distribution."*
473
+ * `stapler(1)` gives the accepting side of the same list: *"stapler works only
474
+ * with UDIF disk images, signed \"flat\" installer packages, and certain
475
+ * code-signed executable bundles such as \".app\"."* So the `.dmg` is stapled
476
+ * here and the `.zip` is told, in the artifact log, what a person has to do
477
+ * instead — which is a fact about the CONTAINER and therefore lives on the format
478
+ * (`canCarryTicket`), not in a branch here.
479
+ *
480
+ * ⚠️ UNVERIFIED END TO END, for the same reason `notarizeArtifact` is: stapling
481
+ * needs a real notarisation, which needs an Apple account (ADR 0024 § A15, § A17).
482
+ * The argv is unit-tested and the invocation has never run in this repository.
483
+ *
484
+ * `stapler(1)` also states the constraint that decides the ORDER: *"Stapling does
485
+ * not invalidate the code signature and must be run after an executable or archive
486
+ * has been code-signed and notarized … Code-signing a supported file format
487
+ * invalidates any stapled tickets"* — so nothing may re-sign after this, and
488
+ * nothing in `packOne` does.
489
+ */
490
+ export async function stapleArtifact(input) {
491
+ const args = ['stapler', 'staple', input.artifact];
492
+ if (input.verbose)
493
+ input.log(`${input.notary.tool} ${args.join(' ')}`);
494
+ const result = await spawnToCompletion(input.notary.tool, args, {
495
+ completion: 'return',
496
+ stdio: 'inherit',
497
+ notFound: () => new Error(`gjsify ship: \`${input.notary.tool}\` is not on PATH — ${input.notary.installHint}.`),
498
+ });
499
+ if (result.code !== 0) {
500
+ throw new Error(`gjsify ship: ${input.notary.tool} stapler failed with ${describeExit(result)}. The artifact is ` +
501
+ 'notarised and NOT stapled, which means every launch asks Gatekeeper to check online — and ' +
502
+ 'fails closed on a machine with no network. Re-run the finish phase; `stapler` requires ' +
503
+ 'internet access and the ticket may not have propagated yet.');
504
+ }
505
+ input.log(`${input.notary.tool} stapler attached the ticket to ${input.artifact}.`);
317
506
  }
@@ -45,7 +45,26 @@ export function writeStage(root, files) {
45
45
  for (const file of files) {
46
46
  const target = join(root, file.path.split('/').join(sep));
47
47
  mkdirSync(dirname(target), { recursive: true });
48
- const data = file.source.kind === 'text' ? new TextEncoder().encode(file.source.text) : readFileSync(file.source.path);
48
+ // A `switch` with a `never` guard rather than a ternary chain: the third
49
+ // source kind arrived because a LAYOUT started owning a binary, and a
50
+ // fourth would otherwise be written as whatever the last branch happens
51
+ // to be — the same closed-vocabulary hazard `packOne` uses one for.
52
+ let data;
53
+ switch (file.source.kind) {
54
+ case 'text':
55
+ data = new TextEncoder().encode(file.source.text);
56
+ break;
57
+ case 'file':
58
+ data = new Uint8Array(readFileSync(file.source.path));
59
+ break;
60
+ case 'bytes':
61
+ data = file.source.data;
62
+ break;
63
+ default: {
64
+ const unhandled = file.source;
65
+ throw new Error(`gjsify ship: no writer for the "${JSON.stringify(unhandled)}" source kind.`);
66
+ }
67
+ }
49
68
  writeFileSync(target, data);
50
69
  chmodSync(target, file.mode);
51
70
  }
@@ -7,13 +7,23 @@ export interface StagedMode {
7
7
  }
8
8
  /** One file in the payload, addressed relative to the install prefix. */
9
9
  export interface StagedFile extends StagedMode {
10
- /** Where the bytes come from. */
10
+ /**
11
+ * Where the bytes come from.
12
+ *
13
+ * `bytes` is the third kind because a layout can now OWN a binary: the
14
+ * windows row emits a GUI-subsystem launcher (`utils/ship/pe-launcher.ts`)
15
+ * that exists nowhere on disk and is not text. Spelling it as `text` would
16
+ * round-trip it through UTF-8 and rewrite every byte above 0x7f.
17
+ */
11
18
  source: {
12
19
  kind: 'text';
13
20
  text: string;
14
21
  } | {
15
22
  kind: 'file';
16
23
  path: string;
24
+ } | {
25
+ kind: 'bytes';
26
+ data: Uint8Array;
17
27
  };
18
28
  }
19
29
  /**
@@ -286,6 +296,12 @@ export interface ShipMimeType {
286
296
  * `share/locale/<rel>`, and no packer reads the field. So translations
287
297
  * cross in the payload, where they belong, and the `abs` path does not
288
298
  * cross at all. This list being explicit is what caught that.
299
+ * - `fontFiles` — the same shape one milestone later (ADR 0038). `planStage`
300
+ * copies each face into `share/fonts/<appId>/`, no packer reads the field, and
301
+ * the only pack-time question — "does this payload carry fonts" — is answered
302
+ * by the payload's own paths. There is no scriptlet to key off it either:
303
+ * fontconfig scans the directory and caches lazily per user, measured rather
304
+ * than assumed.
289
305
  * - `kind`, `execArgs` — read by the planner and the metadata renderers, all of
290
306
  * which have run by the time a stage exists. `appId` was on this line and
291
307
  * MOVED: the Flatpak packer needs it at pack time twice over — it is the
@@ -342,7 +358,13 @@ export interface PackSettings {
342
358
  typelibPackages: Record<string, Record<DistroFormatId, string>>;
343
359
  /**
344
360
  * The interpreter the launcher execs, and therefore the one the package
345
- * depends on. From `gjsify.app`; default `'gjs'`.
361
+ * depends on. THIS TARGET's runtime, resolved once by `resolveShipApp` out of
362
+ * `gjsify.ship.app.<os>` falling back to `gjsify.app`; default `'gjs'`.
363
+ *
364
+ * PER TARGET and never the project field, which is #1486's fix and whose
365
+ * measurement `resolveShipApp` carries. That the stage manifest records the
366
+ * RESOLVED value is what keeps `--from-stage` honest: the packing host has no
367
+ * project and needs none.
346
368
  *
347
369
  * ON `PackSettings`, not on `ShipSettings` alone, because BOTH halves need it
348
370
  * and they must not answer it differently: `renderLauncher` writes
@@ -432,6 +454,16 @@ export interface ShipSettings extends PackSettings {
432
454
  rel: string;
433
455
  abs: string;
434
456
  }[];
457
+ /**
458
+ * Absolute paths of the font faces the application ships (`gjsify.ship.fonts`).
459
+ *
460
+ * Read by `planStage` alone, which copies each into `share/fonts/<appId>/` — the
461
+ * one directory fontconfig reaches through the launcher's existing
462
+ * `XDG_DATA_DIRS` (ADR 0038). Nothing in the pack path reads it, which is why it
463
+ * is not on {@link PackSettings}; the fonts cross in the payload, like the
464
+ * catalogues.
465
+ */
466
+ fontFiles: string[];
435
467
  /** `<dest relative to prefix>` → absolute source path. */
436
468
  extraFiles: Record<string, string>;
437
469
  /** Arguments appended to the launcher's `exec` line. */
@@ -28,11 +28,25 @@ export declare const PROBED_GI_LIBDIRS: Record<string, readonly string[]>;
28
28
  /**
29
29
  * The subdir GI installs typelibs into — historically `1.0` even for the girepository-2.0 API.
30
30
  *
31
- * Exported so `gi-typelib.ts` shares it rather than keeping a third copy of the
32
- * one string this whole layout rule turns on. Both mirrors export it, so the
33
- * agreement suite still compares two files that differ only in language.
31
+ * Exported so `gi-typelib.ts` shares it rather than keeping a fourth copy of the
32
+ * one string this whole layout rule turns on, and compared across all three copies
33
+ * by the agreement suite rather than trusting three literals to stay equal.
34
34
  */
35
35
  export declare const TYPELIB_SUBDIR = "girepository-1.0";
36
+ /**
37
+ * The library directories a typelib directory implies, most specific first.
38
+ *
39
+ * TWO LAYOUTS, and a path alone cannot always say which: GI's INSTALL layout keeps
40
+ * the libraries in the PARENT (`<libdir>/girepository-1.0/…`), while a STAGED pair —
41
+ * an ADR 0017 prebuild directory, `gjsify.ship.bundledTypelibs` — keeps them in the
42
+ * DIRECTORY ITSELF. {@link TYPELIB_SUBDIR} as the basename is a positive signal for
43
+ * the first; absent it, both readings are offered and the caller's `existsDir` keeps
44
+ * whichever is real.
45
+ *
46
+ * The measurement, and why the ORDER of the two is the load-bearing half, are in the
47
+ * ORIGINAL, `packages/node-gi/node-gi/system-gi.js`.
48
+ */
49
+ export declare function giLibraryDirsForTypelibDir(typelibDir: string): string[];
36
50
  /** Split an OS search-path variable, dropping empty segments. */
37
51
  export declare function splitSearchPath(value: string | undefined, separator?: string): string[];
38
52
  /**
@@ -52,8 +66,9 @@ export declare function pkgConfigSearchDirs(env: Record<string, string | undefin
52
66
  *
53
67
  * Three sources, in precedence order:
54
68
  * 1. `GI_TYPELIB_PATH` — the host stating outright where typelibs live; the
55
- * libraries are the sibling `dirname()`. Accepted on directory existence
56
- * alone: an explicit statement is not second-guessed.
69
+ * libraries are wherever {@link giLibraryDirsForTypelibDir} says the layout
70
+ * puts them. Accepted on directory existence alone: an explicit statement is
71
+ * not second-guessed.
57
72
  * 2. `pkg-config`'s `.pc` search path → each `<dir>/pkgconfig`'s parent. The
58
73
  * general mechanism (any prefix, including a custom one).
59
74
  * 3. {@link PROBED_GI_LIBDIRS} — the standard macOS prefixes, for the common
@@ -3,10 +3,12 @@
3
3
  // dynamic loader can find the ones a typelib names by BARE LEAF
4
4
  // (`libgtk-4.1.dylib`, `libgdk_pixbuf-2.0.0.dylib`, …).
5
5
  //
6
- // A PINNED MIRROR of `packages/node-gi/node-gi/system-gi.js` — the two must agree
7
- // and `system-gi.spec.ts` asserts it over a table of injected host shapes. Why a
8
- // mirror and not an import, and what is owed to retire it:
9
- // `status/open-todos.md` § "systemGiLibraryDirs() lives in two places".
6
+ // A PINNED MIRROR of `packages/node-gi/node-gi/system-gi.js`, and of
7
+ // `@gjsify/utils/core`'s `system-gi-dirs.ts` — all three must agree and
8
+ // `system-gi.spec.ts` asserts it over a table of injected host shapes. Why a mirror
9
+ // and not an import, and what is owed to retire THIS copy (a delegation to
10
+ // `@gjsify/utils`, which the CLI already depends on):
11
+ // `status/open-todos.md` § "systemGiLibraryDirs() lives in three places".
10
12
  //
11
13
  // WHY THIS EXISTS. A failed `dlopen('libgtk-4.1.dylib')` surfaces one layer up as
12
14
  // something that reads like a type-system bug — `TypeError: Gtk.DrawingArea is not
@@ -32,6 +34,8 @@
32
34
  // `<libdir>/libgtk-4.1.dylib`. A directory holding `girepository-1.0/` therefore IS
33
35
  // a GI library directory, which is what makes this package-manager-agnostic — it
34
36
  // lands on Homebrew, MacPorts or a custom prefix without knowing any of them exist.
37
+ // THAT IS ONE LAYOUT, NOT THE LAYOUT — {@link giLibraryDirsForTypelibDir}; a staged
38
+ // pair keeps typelib and backer in ONE directory, and the incident is in the ORIGINAL.
35
39
  //
36
40
  // PURE FUNCTION of injected host facts (platform / env / fs / pkg-config), like
37
41
  // `resolvePrebuildDirName()` and `buildNativeEnv()` beside it, so the darwin branch
@@ -74,11 +78,28 @@ export const PROBED_GI_LIBDIRS = {
74
78
  /**
75
79
  * The subdir GI installs typelibs into — historically `1.0` even for the girepository-2.0 API.
76
80
  *
77
- * Exported so `gi-typelib.ts` shares it rather than keeping a third copy of the
78
- * one string this whole layout rule turns on. Both mirrors export it, so the
79
- * agreement suite still compares two files that differ only in language.
81
+ * Exported so `gi-typelib.ts` shares it rather than keeping a fourth copy of the
82
+ * one string this whole layout rule turns on, and compared across all three copies
83
+ * by the agreement suite rather than trusting three literals to stay equal.
80
84
  */
81
85
  export const TYPELIB_SUBDIR = 'girepository-1.0';
86
+ /**
87
+ * The library directories a typelib directory implies, most specific first.
88
+ *
89
+ * TWO LAYOUTS, and a path alone cannot always say which: GI's INSTALL layout keeps
90
+ * the libraries in the PARENT (`<libdir>/girepository-1.0/…`), while a STAGED pair —
91
+ * an ADR 0017 prebuild directory, `gjsify.ship.bundledTypelibs` — keeps them in the
92
+ * DIRECTORY ITSELF. {@link TYPELIB_SUBDIR} as the basename is a positive signal for
93
+ * the first; absent it, both readings are offered and the caller's `existsDir` keeps
94
+ * whichever is real.
95
+ *
96
+ * The measurement, and why the ORDER of the two is the load-bearing half, are in the
97
+ * ORIGINAL, `packages/node-gi/node-gi/system-gi.js`.
98
+ */
99
+ export function giLibraryDirsForTypelibDir(typelibDir) {
100
+ const parent = dirname(typelibDir);
101
+ return basename(typelibDir) === TYPELIB_SUBDIR ? [parent] : [typelibDir, parent];
102
+ }
82
103
  /** Memoized `pkg-config` answer: the spawn happens at most once per process. */
83
104
  let pkgConfigDirsCache = null;
84
105
  /** Split an OS search-path variable, dropping empty segments. */
@@ -124,8 +145,9 @@ export function pkgConfigSearchDirs(env) {
124
145
  *
125
146
  * Three sources, in precedence order:
126
147
  * 1. `GI_TYPELIB_PATH` — the host stating outright where typelibs live; the
127
- * libraries are the sibling `dirname()`. Accepted on directory existence
128
- * alone: an explicit statement is not second-guessed.
148
+ * libraries are wherever {@link giLibraryDirsForTypelibDir} says the layout
149
+ * puts them. Accepted on directory existence alone: an explicit statement is
150
+ * not second-guessed.
129
151
  * 2. `pkg-config`'s `.pc` search path → each `<dir>/pkgconfig`'s parent. The
130
152
  * general mechanism (any prefix, including a custom one).
131
153
  * 3. {@link PROBED_GI_LIBDIRS} — the standard macOS prefixes, for the common
@@ -155,11 +177,12 @@ export function systemGiLibraryDirs({ platform = process.platform, env = process
155
177
  if (dir && dir !== '/' && !out.includes(dir))
156
178
  out.push(dir);
157
179
  };
158
- // 1. Explicit: the typelib dir's sibling libdir.
180
+ // 1. Explicit: the libdirs each typelib dir implies, both layouts.
159
181
  for (const typelibDir of splitSearchPath(env['GI_TYPELIB_PATH'])) {
160
- const libDir = dirname(typelibDir);
161
- if (existsDir(libDir))
162
- add(libDir);
182
+ for (const libDir of giLibraryDirsForTypelibDir(typelibDir)) {
183
+ if (existsDir(libDir))
184
+ add(libDir);
185
+ }
163
186
  }
164
187
  // 2 + 3. Candidate prefixes, believed only on the girepository-1.0 marker.
165
188
  const candidates = [];