@gjsify/cli 0.64.20 → 0.64.22

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.
@@ -1,5 +1,5 @@
1
1
  import type { AppMetadata, ConfigDataFlatpak, ConfigDataShip, DescriptionBlock, ShipAppOptions } from '../../types/config-data.js';
2
- import type { HostOs, ShipExecutable, ShipSettings } from './types.js';
2
+ import type { HostOs, ShipExecutable, ShipHelperExecutable, ShipNestedBundle, ShipSettings } from './types.js';
3
3
  /** The subset of `package.json` the resolver reads. */
4
4
  export interface ShipPackageManifest {
5
5
  name?: string;
@@ -177,6 +177,37 @@ export declare function resolveShipExecutables(input: {
177
177
  projectDir: string;
178
178
  bundleDir: string;
179
179
  }): ShipExecutable[];
180
+ /**
181
+ * `gjsify.ship.nestedBundles` → the further bundles the darwin container embeds
182
+ * under `Contents/PlugIns/` — e.g. a Safari Web Extension `.appex`.
183
+ *
184
+ * Darwin-only in EFFECT, not in form (ADR 0024 amendment, 2026-11-06,
185
+ * beifahrer#203): the declaration describes what the darwin `.app` carries and
186
+ * is not an error on another layout, so a non-empty list on `linux`/`windows`
187
+ * resolves to `[]` rather than throwing — a project with a Mac extension must
188
+ * still ship for Linux and Windows. The SHAPE check (schema, leaf names, `..`,
189
+ * duplicates, unknown keys) still runs on every layout, so a typo is caught on
190
+ * a Linux build too; only the SOURCE's existence is darwin-only, because a
191
+ * Linux runner never built the `.appex` to check. The caller
192
+ * ({@link resolveShipSettings}) prints the one-line notice this silence needs.
193
+ */
194
+ export declare function resolveShipNestedBundles(input: {
195
+ declared: unknown;
196
+ projectDir: string;
197
+ layoutOs: HostOs;
198
+ }): ShipNestedBundle[];
199
+ /**
200
+ * `gjsify.ship.helperExecutables` → prebuilt executables staged into
201
+ * `Contents/Helpers/` beside the carried interpreter — e.g. a small Swift
202
+ * helper with no GI binding. Darwin-only in EFFECT, not in form — same reason
203
+ * and same split (shape always, source existence darwin-only) as
204
+ * {@link resolveShipNestedBundles}.
205
+ */
206
+ export declare function resolveShipHelperExecutables(input: {
207
+ declared: unknown;
208
+ projectDir: string;
209
+ layoutOs: HostOs;
210
+ }): ShipHelperExecutable[];
180
211
  /** The entry one ship target installs, which key named it, and what it must NOT carry. */
181
212
  export interface ResolvedShipBundle {
182
213
  /** The declared path as written — project-relative or absolute. */
@@ -10,6 +10,7 @@
10
10
  // only) > derived from package.json. Never a yargs `default:`, which is
11
11
  // indistinguishable from a value the user typed and would clobber the config.
12
12
  import { validateMimeTypes } from './mime.js';
13
+ import { existsSync } from 'node:fs';
13
14
  import { basename, isAbsolute, posix, relative, resolve, sep } from 'node:path';
14
15
  import { DEFAULT_GJS_FLOOR, DEFAULT_NODE_FLOOR } from './depends.js';
15
16
  import { resolveShipFlatpakSettings } from './flatpak-config.js';
@@ -191,6 +192,26 @@ export function resolveShipSettings(input) {
191
192
  // finish-args default depends on `kind`, which is a project fact.
192
193
  const flatpakSettings = resolveShipFlatpakSettings({ ship, flatpak, kind });
193
194
  warnings.push(...flatpakSettings.warnings);
195
+ // `gjsify.ship.nestedBundles`/`helperExecutables` describe what the DARWIN
196
+ // `.app` carries — on any other layout they are IGNORED, with one combined
197
+ // notice (ADR 0024 amendment, 2026-11-06, beifahrer#203), never refused: a
198
+ // project with a Mac extension must still be able to ship for Linux and
199
+ // Windows. Shape is still validated on every layout inside each resolver.
200
+ const nestedBundlesResolved = resolveShipNestedBundles({
201
+ declared: ship.nestedBundles,
202
+ projectDir: input.projectDir,
203
+ layoutOs: input.cli.layoutOs,
204
+ });
205
+ const helperExecutablesResolved = resolveShipHelperExecutables({
206
+ declared: ship.helperExecutables,
207
+ projectDir: input.projectDir,
208
+ layoutOs: input.cli.layoutOs,
209
+ });
210
+ if (input.cli.layoutOs !== 'darwin' &&
211
+ ((Array.isArray(ship.nestedBundles) && ship.nestedBundles.length > 0) ||
212
+ (Array.isArray(ship.helperExecutables) && ship.helperExecutables.length > 0))) {
213
+ warnings.push(`gjsify ship: nestedBundles/helperExecutables apply to darwin only — not staged for ${input.cli.layoutOs}`);
214
+ }
194
215
  const settings = {
195
216
  projectDir: input.projectDir,
196
217
  appId,
@@ -227,6 +248,8 @@ export function resolveShipSettings(input) {
227
248
  projectDir: input.projectDir,
228
249
  bundleDir: discovered.bundleDir,
229
250
  }),
251
+ nestedBundles: nestedBundlesResolved,
252
+ helperExecutables: helperExecutablesResolved,
230
253
  outDir: input.cli.outDir ?? ship.outDir ?? 'ship',
231
254
  arch: input.cli.arch ?? process.arch,
232
255
  app,
@@ -292,6 +315,146 @@ export function resolveShipExecutables(input) {
292
315
  return { name, entry: rel.split(sep).join(posix.sep), gui: gui === true };
293
316
  });
294
317
  }
318
+ /** A `Contents/PlugIns` or `Contents/Helpers` leaf: no path separator, no NUL, not empty. */
319
+ function assertUsableBundleLeaf(name, at, under) {
320
+ if (name === '' ||
321
+ name === '.' ||
322
+ name === '..' ||
323
+ name.includes('/') ||
324
+ name.includes('\\') ||
325
+ name.includes('\u0000')) {
326
+ throw new Error(`gjsify ship: \`${at}.name\` "${name}" is not usable as a leaf under \`${under}\` — empty and ` +
327
+ '".", "..", and anything containing "/", "\\" or a NUL byte are refused.');
328
+ }
329
+ return name;
330
+ }
331
+ /**
332
+ * `gjsify.ship.nestedBundles` → the further bundles the darwin container embeds
333
+ * under `Contents/PlugIns/` — e.g. a Safari Web Extension `.appex`.
334
+ *
335
+ * Darwin-only in EFFECT, not in form (ADR 0024 amendment, 2026-11-06,
336
+ * beifahrer#203): the declaration describes what the darwin `.app` carries and
337
+ * is not an error on another layout, so a non-empty list on `linux`/`windows`
338
+ * resolves to `[]` rather than throwing — a project with a Mac extension must
339
+ * still ship for Linux and Windows. The SHAPE check (schema, leaf names, `..`,
340
+ * duplicates, unknown keys) still runs on every layout, so a typo is caught on
341
+ * a Linux build too; only the SOURCE's existence is darwin-only, because a
342
+ * Linux runner never built the `.appex` to check. The caller
343
+ * ({@link resolveShipSettings}) prints the one-line notice this silence needs.
344
+ */
345
+ export function resolveShipNestedBundles(input) {
346
+ const { declared } = input;
347
+ if (declared === undefined)
348
+ return [];
349
+ if (!Array.isArray(declared)) {
350
+ throw new Error('gjsify ship: `gjsify.ship.nestedBundles` must be a list of `{ "source", "name", "entitlements"? }` ' +
351
+ 'objects.');
352
+ }
353
+ const seen = new Set();
354
+ const resolved = declared.map((row, index) => {
355
+ const at = `gjsify.ship.nestedBundles[${index}]`;
356
+ if (typeof row !== 'object' || row === null || Array.isArray(row)) {
357
+ throw new Error(`gjsify ship: \`${at}\` must be an object with \`source\` and \`name\`.`);
358
+ }
359
+ const { source, name, entitlements, ...unknown } = row;
360
+ const stray = Object.keys(unknown);
361
+ if (stray.length > 0) {
362
+ throw new Error(`gjsify ship: \`${at}\` has unknown key(s) ${stray.join(', ')}; known: source, name, entitlements.`);
363
+ }
364
+ if (typeof source !== 'string' || source === '') {
365
+ throw new Error(`gjsify ship: \`${at}.source\` must be the project-relative directory of a built bundle.`);
366
+ }
367
+ if (typeof name !== 'string') {
368
+ throw new Error(`gjsify ship: \`${at}.name\` must be a string — the leaf under \`Contents/PlugIns/\`.`);
369
+ }
370
+ assertUsableBundleLeaf(name, at, 'Contents/PlugIns');
371
+ if (seen.has(name)) {
372
+ throw new Error(`gjsify ship: \`${at}.name\` "${name}" is already taken by an earlier nested bundle.`);
373
+ }
374
+ seen.add(name);
375
+ const abs = isAbsolute(source) ? source : resolve(input.projectDir, source);
376
+ // Existence is checked for darwin alone: on linux/windows this bundle is
377
+ // ignored outright (see below), and a CI runner assembling those layouts
378
+ // never built the `.appex` in the first place.
379
+ if (input.layoutOs === 'darwin' && !existsSync(abs)) {
380
+ throw new Error(`gjsify ship: \`${at}.source\` ${source} does not exist.`);
381
+ }
382
+ if (entitlements !== undefined && !Array.isArray(entitlements)) {
383
+ throw new Error(`gjsify ship: \`${at}.entitlements\` must be a list of strings.`);
384
+ }
385
+ return {
386
+ source: abs,
387
+ name,
388
+ entitlements: (entitlements ?? []).map((key, keyIndex) => {
389
+ if (typeof key !== 'string') {
390
+ throw new Error(`gjsify ship: \`${at}.entitlements[${keyIndex}]\` must be a string.`);
391
+ }
392
+ return key;
393
+ }),
394
+ };
395
+ });
396
+ return input.layoutOs === 'darwin' ? resolved : [];
397
+ }
398
+ /**
399
+ * `gjsify.ship.helperExecutables` → prebuilt executables staged into
400
+ * `Contents/Helpers/` beside the carried interpreter — e.g. a small Swift
401
+ * helper with no GI binding. Darwin-only in EFFECT, not in form — same reason
402
+ * and same split (shape always, source existence darwin-only) as
403
+ * {@link resolveShipNestedBundles}.
404
+ */
405
+ export function resolveShipHelperExecutables(input) {
406
+ const { declared } = input;
407
+ if (declared === undefined)
408
+ return [];
409
+ if (!Array.isArray(declared)) {
410
+ throw new Error('gjsify ship: `gjsify.ship.helperExecutables` must be a list of `{ "source", "name", ' +
411
+ '"entitlements"? }` objects.');
412
+ }
413
+ const seen = new Set();
414
+ const resolved = declared.map((row, index) => {
415
+ const at = `gjsify.ship.helperExecutables[${index}]`;
416
+ if (typeof row !== 'object' || row === null || Array.isArray(row)) {
417
+ throw new Error(`gjsify ship: \`${at}\` must be an object with \`source\` and \`name\`.`);
418
+ }
419
+ const { source, name, entitlements, ...unknown } = row;
420
+ const stray = Object.keys(unknown);
421
+ if (stray.length > 0) {
422
+ throw new Error(`gjsify ship: \`${at}\` has unknown key(s) ${stray.join(', ')}; known: source, name, entitlements.`);
423
+ }
424
+ if (typeof source !== 'string' || source === '') {
425
+ throw new Error(`gjsify ship: \`${at}.source\` must be the project-relative path of a prebuilt executable.`);
426
+ }
427
+ if (typeof name !== 'string') {
428
+ throw new Error(`gjsify ship: \`${at}.name\` must be a string — the leaf under \`Contents/Helpers/\`.`);
429
+ }
430
+ assertUsableBundleLeaf(name, at, 'Contents/Helpers');
431
+ if (seen.has(name)) {
432
+ throw new Error(`gjsify ship: \`${at}.name\` "${name}" is already taken by an earlier helper executable.`);
433
+ }
434
+ seen.add(name);
435
+ const abs = isAbsolute(source) ? source : resolve(input.projectDir, source);
436
+ // Existence is checked for darwin alone — on linux/windows this helper is
437
+ // ignored outright (see below), and a CI runner assembling those layouts
438
+ // never built it in the first place.
439
+ if (input.layoutOs === 'darwin' && !existsSync(abs)) {
440
+ throw new Error(`gjsify ship: \`${at}.source\` ${source} does not exist.`);
441
+ }
442
+ if (entitlements !== undefined && !Array.isArray(entitlements)) {
443
+ throw new Error(`gjsify ship: \`${at}.entitlements\` must be a list of strings.`);
444
+ }
445
+ return {
446
+ source: abs,
447
+ name,
448
+ entitlements: (entitlements ?? []).map((key, keyIndex) => {
449
+ if (typeof key !== 'string') {
450
+ throw new Error(`gjsify ship: \`${at}.entitlements[${keyIndex}]\` must be a string.`);
451
+ }
452
+ return key;
453
+ }),
454
+ };
455
+ });
456
+ return input.layoutOs === 'darwin' ? resolved : [];
457
+ }
295
458
  /**
296
459
  * The ONE place a ship target's ENTRY is decided — {@link resolveShipApp}'s twin.
297
460
  *
@@ -156,17 +156,25 @@ export declare function assertHostCanSign(signer: SignerDescriptor): void;
156
156
  * host. Everything else about a bundle seal needs `codesign`, which this
157
157
  * repository has on exactly one CI leg; this function needs nothing.
158
158
  *
159
- * `sealPrefix` absent means "this signer seals nothing", and then EVERY addition
160
- * is unexpected — which is what the file did before ADR 0040 and is still the
161
- * right answer for `signtool`.
159
+ * `sealPrefixes` empty or absent means "this signer seals nothing", and then
160
+ * EVERY addition is unexpected — which is what the file did before ADR 0040
161
+ * and is still the right answer for `signtool`.
162
162
  */
163
163
  export declare function partitionSignedFileSet(input: {
164
164
  /** Paths found under the scratch directory after the tool ran. */
165
165
  arrived: readonly string[];
166
166
  /** Paths the payload had before it. */
167
167
  planned: readonly string[];
168
- /** Directory prefix the seal is allowed to write into, when there is one. */
169
- sealPrefix?: string;
168
+ /**
169
+ * Directory prefixes a seal is allowed to write into — one per bundle sealed
170
+ * this run. A plain array rather than a single optional string, because a
171
+ * darwin run with nested bundles (ADR 0024 amendment, "nested bundles and
172
+ * helper executables") seals MORE than one directory: the outer `.app` and
173
+ * every embedded `.appex`, each writing its own `_CodeSignature/`. `[]` and
174
+ * absent mean the same thing — "no seal this run" — so a caller with
175
+ * nothing to add may still pass `[]` rather than reaching for `undefined`.
176
+ */
177
+ sealPrefixes?: readonly string[];
170
178
  }): {
171
179
  sealed: string[];
172
180
  unexpected: string[];
@@ -195,6 +203,20 @@ export interface SignArgs {
195
203
  }
196
204
  /** `codesign --sign -`: the one identity value that names no identity at all. */
197
205
  export declare const ADHOC_IDENTITY = "-";
206
+ /**
207
+ * One nested bundle or helper executable the darwin signer signs with its OWN
208
+ * entitlements, before the outer seal (ADR 0024 amendment, "nested bundles and
209
+ * helper executables"; the beifahrer ADR this answers: 0026 § 6).
210
+ */
211
+ export interface SignedSubordinate {
212
+ /** Payload-relative path — a bundle DIRECTORY for a nested bundle, a FILE for a helper. */
213
+ path: string;
214
+ /**
215
+ * Entitlements this subordinate is signed with, REPLACING the outer signer's
216
+ * own set — never unioned with it. `[]` signs with none.
217
+ */
218
+ entitlements: readonly string[];
219
+ }
198
220
  export interface SignPayloadInput {
199
221
  /** What `readStage` returned — the PRE-sign tree, already held against the manifest. */
200
222
  payload: readonly PayloadEntry[];
@@ -218,6 +240,24 @@ export interface SignPayloadInput {
218
240
  * read as a file the signer added.
219
241
  */
220
242
  entitlementsPath: string;
243
+ /**
244
+ * Nested BUNDLES this run embeds — e.g. a Safari Web Extension `.appex` under
245
+ * `Contents/PlugIns/`. Signed INSIDE OUT: every Mach-O under each one's path,
246
+ * then that bundle's own seal, ALL of it before the outer bundle's images or
247
+ * seal — because the outer seal's `CodeResources` hashes every file under its
248
+ * tree, nested bundles included, so resigning one afterwards would invalidate
249
+ * a hash the outer seal already recorded. `[]` for a layout or project with
250
+ * none. `sealsBundle` must be `true` on {@link signer} for a non-empty list —
251
+ * `signtool` has no bundle concept to nest one inside.
252
+ */
253
+ nestedBundles?: readonly SignedSubordinate[];
254
+ /**
255
+ * Helper EXECUTABLES this run embeds with their own entitlements — e.g. a
256
+ * small Swift binary with no GI binding. Signed before the outer images and
257
+ * seal, for the same reason as {@link nestedBundles}: a loose file's bytes
258
+ * are hashed into the outer seal too. `[]` for none.
259
+ */
260
+ helperExecutables?: readonly SignedSubordinate[];
221
261
  /** Print every invocation. */
222
262
  verbose: boolean;
223
263
  log: (line: string) => void;
@@ -341,18 +341,19 @@ export function assertHostCanSign(signer) {
341
341
  * host. Everything else about a bundle seal needs `codesign`, which this
342
342
  * repository has on exactly one CI leg; this function needs nothing.
343
343
  *
344
- * `sealPrefix` absent means "this signer seals nothing", and then EVERY addition
345
- * is unexpected — which is what the file did before ADR 0040 and is still the
346
- * right answer for `signtool`.
344
+ * `sealPrefixes` empty or absent means "this signer seals nothing", and then
345
+ * EVERY addition is unexpected — which is what the file did before ADR 0040
346
+ * and is still the right answer for `signtool`.
347
347
  */
348
348
  export function partitionSignedFileSet(input) {
349
349
  const planned = new Set(input.planned);
350
350
  const arrived = new Set(input.arrived);
351
351
  const added = [...arrived].filter((path) => !planned.has(path)).sort();
352
- const prefix = input.sealPrefix;
352
+ const prefixes = input.sealPrefixes ?? [];
353
+ const underAnyPrefix = (path) => prefixes.some((prefix) => path.startsWith(prefix));
353
354
  return {
354
- sealed: prefix === undefined ? [] : added.filter((path) => path.startsWith(prefix)),
355
- unexpected: prefix === undefined ? added : added.filter((path) => !path.startsWith(prefix)),
355
+ sealed: prefixes.length === 0 ? [] : added.filter(underAnyPrefix),
356
+ unexpected: prefixes.length === 0 ? added : added.filter((path) => !underAnyPrefix(path)),
356
357
  removed: [...planned].filter((path) => !arrived.has(path)).sort(),
357
358
  };
358
359
  }
@@ -381,16 +382,30 @@ export const ADHOC_IDENTITY = '-';
381
382
  */
382
383
  export async function signPayload(input) {
383
384
  const { payload, signer, workDir } = input;
385
+ const nestedBundles = input.nestedBundles ?? [];
386
+ const helperExecutables = input.helperExecutables ?? [];
387
+ if ((nestedBundles.length > 0 || helperExecutables.length > 0) && !signer.sealsBundle) {
388
+ throw new Error(`gjsify ship: this payload declares a nested bundle or helper executable, and the ` +
389
+ `${signer.layoutOs} signer has no bundle to embed one in — \`signtool\` signs loose images, ` +
390
+ 'never a bundle tree. Nested bundles and helper executables are a darwin-only feature.');
391
+ }
384
392
  writePayload(workDir, payload, '');
385
393
  const adhoc = input.identity === ADHOC_IDENTITY;
386
- let entitlements;
387
- const granted = signer.entitlements(adhoc);
388
- if (granted.length > 0) {
389
- mkdirSync(dirname(input.entitlementsPath), { recursive: true });
390
- writeFileSync(input.entitlementsPath, renderEntitlements(granted));
391
- entitlements = input.entitlementsPath;
392
- }
393
- const run = async (file, what, seal) => {
394
+ const entitlementsCache = new Map();
395
+ /** Write (or reuse) the entitlements plist for one KEYED set of grants, `[]` meaning none. */
396
+ const entitlementsFileFor = (key, granted) => {
397
+ if (granted.length === 0)
398
+ return undefined;
399
+ const cached = entitlementsCache.get(key);
400
+ if (cached !== undefined)
401
+ return cached;
402
+ const path = key === '' ? input.entitlementsPath : `${input.entitlementsPath}.${key}.plist`;
403
+ mkdirSync(dirname(path), { recursive: true });
404
+ writeFileSync(path, renderEntitlements(granted));
405
+ entitlementsCache.set(key, path);
406
+ return path;
407
+ };
408
+ const run = async (file, what, seal, entitlements) => {
394
409
  const args = signer.args({
395
410
  identity: input.identity,
396
411
  file,
@@ -412,7 +427,38 @@ export async function signPayload(input) {
412
427
  'signed installs and is refused at first launch, which is worse than not shipping.');
413
428
  }
414
429
  };
415
- const signable = payload.filter((entry) => classifyBinary(entry.data) === signer.signs);
430
+ const outerEntitlements = entitlementsFileFor('', signer.entitlements(adhoc));
431
+ // Every path this run signs with its OWN entitlements rather than the outer
432
+ // signer's default — excluded from the generic loop below so neither set of
433
+ // files is signed twice with two different entitlement lists.
434
+ const subordinatePrefixes = nestedBundles.map((bundle) => `${bundle.path}/`);
435
+ const helperPaths = new Set(helperExecutables.map((helper) => helper.path));
436
+ const underSubordinate = (path) => subordinatePrefixes.some((prefix) => path.startsWith(prefix));
437
+ // INSIDE OUT, and before anything else: the outer seal's `CodeResources`
438
+ // hashes every file under its tree, nested bundles and helpers included, so
439
+ // each one has to be in its FINAL signed form before that hash is taken —
440
+ // resigning afterwards would invalidate a digest the outer seal already
441
+ // recorded. Order WITHIN this block does not matter; order relative to the
442
+ // generic loop and the outer seal below does.
443
+ const nestedSealPrefixes = [];
444
+ let nestedImagesSigned = 0;
445
+ for (const bundle of nestedBundles) {
446
+ const entitlements = entitlementsFileFor(bundle.path, bundle.entitlements);
447
+ const images = payload.filter((entry) => entry.path.startsWith(`${bundle.path}/`) && classifyBinary(entry.data) === signer.signs);
448
+ for (const entry of images) {
449
+ await run(join(workDir, entry.path.split('/').join(sep)), entry.path, false, entitlements);
450
+ }
451
+ nestedImagesSigned += images.length;
452
+ await run(join(workDir, bundle.path.split('/').join(sep)), `${bundle.path} (nested bundle seal)`, true, entitlements);
453
+ nestedSealPrefixes.push(signer.sealAddPrefix(bundle.path));
454
+ }
455
+ for (const helper of helperExecutables) {
456
+ const entitlements = entitlementsFileFor(helper.path, helper.entitlements);
457
+ await run(join(workDir, helper.path.split('/').join(sep)), helper.path, false, entitlements);
458
+ }
459
+ const signable = payload.filter((entry) => classifyBinary(entry.data) === signer.signs &&
460
+ !underSubordinate(entry.path) &&
461
+ !helperPaths.has(entry.path));
416
462
  if (signable.length === 0 && !signer.sealsBundle) {
417
463
  // NOT silent, and not an error either. A `--app gjs` payload really is
418
464
  // JavaScript and a launcher, so there is nothing for the loader to
@@ -428,7 +474,7 @@ export async function signPayload(input) {
428
474
  return [...payload];
429
475
  }
430
476
  for (const entry of signable) {
431
- await run(join(workDir, entry.path.split('/').join(sep)), entry.path, false);
477
+ await run(join(workDir, entry.path.split('/').join(sep)), entry.path, false, outerEntitlements);
432
478
  }
433
479
  // THE IMAGES FIRST, THE SEAL LAST, and the order is not stylistic: a bundle
434
480
  // seal hashes the files it seals, so sealing before the closure is signed
@@ -442,28 +488,32 @@ export async function signPayload(input) {
442
488
  'stage would sign the payload of every other format beside it.');
443
489
  }
444
490
  sealPrefix = signer.sealAddPrefix(input.bundleRoot);
445
- await run(join(workDir, input.bundleRoot.split('/').join(sep)), `${input.bundleRoot} (bundle seal)`, true);
491
+ await run(join(workDir, input.bundleRoot.split('/').join(sep)), `${input.bundleRoot} (bundle seal)`, true, outerEntitlements);
446
492
  }
447
- // THE FILE SET MAY ONLY GROW WHERE THE SEAL WRITES. Both tools rewrite an
448
- // image in place, so any other new path means something else ran — and a file
449
- // that appeared unnoticed would be packed with no mode from the plan, while
450
- // one that vanished would be packed as absent.
493
+ // THE FILE SET MAY ONLY GROW WHERE A SEAL WRITES — the outer one, or one of
494
+ // the nested bundles signed above. Both tools rewrite an image in place, so
495
+ // any other new path means something else ran — and a file that appeared
496
+ // unnoticed would be packed with no mode from the plan, while one that
497
+ // vanished would be packed as absent.
498
+ const sealPrefixes = [...(sealPrefix === undefined ? [] : [sealPrefix]), ...nestedSealPrefixes];
451
499
  const changes = partitionSignedFileSet({
452
500
  arrived: listFilesRecursive(workDir),
453
501
  planned: payload.map((entry) => entry.path),
454
- ...(sealPrefix === undefined ? {} : { sealPrefix }),
502
+ sealPrefixes,
455
503
  });
456
504
  if (changes.unexpected.length > 0 || changes.removed.length > 0) {
457
505
  throw new Error(`gjsify ship: ${signer.tool} changed the payload's file set — ` +
458
506
  `${changes.unexpected.length} added (${changes.unexpected.slice(0, 3).join(', ') || '—'}), ` +
459
507
  `${changes.removed.length} removed (${changes.removed.slice(0, 3).join(', ') || '—'}). ` +
460
508
  'Signing is a mutation of the files that are already in the payload (ADR 0024 § A4), plus ' +
461
- `exactly the seal's own directory${sealPrefix === undefined ? '' : ` (\`${sealPrefix}\`)`}.`);
509
+ `exactly each seal's own directory${sealPrefixes.length === 0 ? '' : ` (${sealPrefixes.map((prefix) => `\`${prefix}\``).join(', ')})`}.`);
462
510
  }
463
511
  const sealed = changes.sealed;
464
- input.log(`${signer.tool} signed ${signable.length} of ${payload.length} payload file(s) as ` +
512
+ input.log(`${signer.tool} signed ${signable.length + nestedImagesSigned + helperExecutables.length} of ` +
513
+ `${payload.length} payload file(s) as ` +
465
514
  `${adhoc ? 'ad-hoc (`-`)' : `\`${input.identity}\``}` +
466
515
  `${sealPrefix === undefined ? '' : ` and sealed ${input.bundleRoot} (${sealed.length} seal file(s))`}` +
516
+ `${nestedBundles.length === 0 ? '' : `, nested: ${nestedBundles.map((bundle) => bundle.path).join(', ')}`}` +
467
517
  ` — ${signer.gap}.`);
468
518
  // Modes come from the PLAN, exactly as `readStage` insists: the signer
469
519
  // rewrites bytes and this re-read must not turn a 0755 launcher into
@@ -141,6 +141,8 @@ export function toPackSettings(settings) {
141
141
  minNodeVersion: settings.minNodeVersion,
142
142
  flatpak: settings.flatpak,
143
143
  executables: settings.executables.map(({ name, gui }) => ({ name, gui })),
144
+ nestedBundles: settings.nestedBundles.map(({ name, entitlements }) => ({ name, entitlements })),
145
+ helperExecutables: settings.helperExecutables.map(({ name, entitlements }) => ({ name, entitlements })),
144
146
  };
145
147
  }
146
148
  /** Absolute path of a stage's manifest. */
@@ -512,6 +514,23 @@ function readPackSettings(data, at) {
512
514
  }
513
515
  return { name: expectString(row.name, field(`executables[${index}].name`)), gui: row.gui };
514
516
  }),
517
+ // OPTIONAL on read, same reason as `executables`: a stage written before
518
+ // `gjsify.ship.nestedBundles`/`helperExecutables` existed carries neither,
519
+ // and `[]` is exactly right for it.
520
+ nestedBundles: expectArray(data.nestedBundles ?? [], field('nestedBundles')).map((entry, index) => {
521
+ const row = record(entry, field(`nestedBundles[${index}]`));
522
+ return {
523
+ name: expectString(row.name, field(`nestedBundles[${index}].name`)),
524
+ entitlements: expectArray(row.entitlements, field(`nestedBundles[${index}].entitlements`)).map((key, keyIndex) => expectString(key, field(`nestedBundles[${index}].entitlements[${keyIndex}]`))),
525
+ };
526
+ }),
527
+ helperExecutables: expectArray(data.helperExecutables ?? [], field('helperExecutables')).map((entry, index) => {
528
+ const row = record(entry, field(`helperExecutables[${index}]`));
529
+ return {
530
+ name: expectString(row.name, field(`helperExecutables[${index}].name`)),
531
+ entitlements: expectArray(row.entitlements, field(`helperExecutables[${index}].entitlements`)).map((key, keyIndex) => expectString(key, field(`helperExecutables[${index}].entitlements[${keyIndex}]`))),
532
+ };
533
+ }),
515
534
  };
516
535
  }
517
536
  /**
@@ -414,6 +414,19 @@ export interface PackSettings {
414
414
  * launcher, and the packing host needs the names for the Windows arch exemption.
415
415
  */
416
416
  executables: ShipExecutableFacts[];
417
+ /**
418
+ * Further bundles the darwin container embeds (`gjsify.ship.nestedBundles`); `[]` for a
419
+ * package with none. Name and entitlements only — the packing host derives each one's
420
+ * stage-relative path from `layoutForOs('darwin').dirs(settings).plugins` and signs it
421
+ * there; the SOURCE directory stays assembling-host-only (it is staged, not re-read).
422
+ */
423
+ nestedBundles: ShipNestedBundleFacts[];
424
+ /**
425
+ * Prebuilt helper executables staged into `Contents/Helpers/`
426
+ * (`gjsify.ship.helperExecutables`); `[]` for a package with none. Same shape as
427
+ * {@link PackSettings.nestedBundles}, one directory over (`dirs.helpers`).
428
+ */
429
+ helperExecutables: ShipHelperExecutableFacts[];
417
430
  }
418
431
  /** What the packing host needs to know about one further executable. */
419
432
  export interface ShipExecutableFacts {
@@ -427,6 +440,53 @@ export interface ShipExecutable extends ShipExecutableFacts {
427
440
  /** Its entry inside {@link ShipSettings.bundleDir}, POSIX-separated. */
428
441
  entry: string;
429
442
  }
443
+ /**
444
+ * What the packing host needs to know about one nested bundle, to sign it.
445
+ *
446
+ * `name` rather than a stage-relative path, mirroring {@link ShipExecutableFacts}:
447
+ * the PATH is derived — `posix.join(layoutForOs(format.layoutOs).dirs(settings).plugins,
448
+ * name)` — from data every packing host already has (the layout, `PackSettings`),
449
+ * so this closure carries nothing a stage-relative string would duplicate.
450
+ */
451
+ export interface ShipNestedBundleFacts {
452
+ /** Destination leaf under `Contents/PlugIns/`. */
453
+ name: string;
454
+ /**
455
+ * Entitlements this bundle is signed and sealed with, REPLACING the outer
456
+ * signer's own set — a sandboxed extension keeps the sandbox entitlements
457
+ * its own packager wrote and must not inherit the host app's grants.
458
+ */
459
+ entitlements: readonly string[];
460
+ }
461
+ /**
462
+ * One nested bundle a darwin container embeds, as resolved from
463
+ * `gjsify.ship.nestedBundles` — e.g. a Safari Web Extension `.appex`.
464
+ *
465
+ * Darwin-only (ADR 0024 § A12-§ A17 amendment, "nested bundles and helper
466
+ * executables"; the beifahrer ADR this answers: 0026 § 6): `Contents/PlugIns`
467
+ * is Apple's own nested-code location and no other layout has an equivalent
468
+ * slot, so `resolveShipSettings` refuses a non-empty list for any other OS.
469
+ */
470
+ export interface ShipNestedBundle extends ShipNestedBundleFacts {
471
+ /** Absolute path of the source directory — the built `.appex` tree. */
472
+ source: string;
473
+ }
474
+ /** What the packing host needs to know about one helper executable, to sign it. */
475
+ export interface ShipHelperExecutableFacts {
476
+ /** Destination leaf under `Contents/Helpers/`. */
477
+ name: string;
478
+ /** Entitlements this helper is signed with. */
479
+ entitlements: readonly string[];
480
+ }
481
+ /**
482
+ * One prebuilt helper executable staged into `Contents/Helpers/`, as resolved
483
+ * from `gjsify.ship.helperExecutables`. Darwin-only, for the same reason as
484
+ * {@link ShipNestedBundle}.
485
+ */
486
+ export interface ShipHelperExecutable extends ShipHelperExecutableFacts {
487
+ /** Absolute path of the prebuilt executable. */
488
+ source: string;
489
+ }
430
490
  /**
431
491
  * What the generated Flatpak manifest says about everything that is not the payload.
432
492
  *
@@ -511,6 +571,19 @@ export interface ShipSettings extends PackSettings {
511
571
  rel: string;
512
572
  abs: string;
513
573
  }[];
574
+ /**
575
+ * Further bundles the darwin container embeds under `Contents/PlugIns/`
576
+ * (`gjsify.ship.nestedBundles`) — `[]` for a package with none. Ignored on
577
+ * every other layout; `resolveShipSettings` refuses a non-empty list there
578
+ * rather than silently dropping it.
579
+ */
580
+ nestedBundles: ShipNestedBundle[];
581
+ /**
582
+ * Prebuilt helper executables staged into `Contents/Helpers/`
583
+ * (`gjsify.ship.helperExecutables`) — `[]` for a package with none.
584
+ * Darwin-only, like {@link ShipSettings.nestedBundles}.
585
+ */
586
+ helperExecutables: ShipHelperExecutable[];
514
587
  /**
515
588
  * Absolute paths of the font faces the application ships (`gjsify.ship.fonts`).
516
589
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.64.20",
3
+ "version": "0.64.22",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -142,22 +142,22 @@
142
142
  "cli"
143
143
  ],
144
144
  "dependencies": {
145
- "@gjsify/blueprint": "^0.64.20",
146
- "@gjsify/buffer": "^0.64.20",
147
- "@gjsify/create-app": "^0.64.20",
148
- "@gjsify/node-globals": "^0.64.20",
149
- "@gjsify/node-polyfills": "^0.64.20",
150
- "@gjsify/npm-registry": "^0.64.20",
151
- "@gjsify/resolve-npm": "^0.64.20",
152
- "@gjsify/rolldown-plugin-gjsify": "^0.64.20",
153
- "@gjsify/vite-plugin-blueprint": "^0.64.20",
154
- "@gjsify/rolldown-plugin-pnp": "^0.64.20",
155
- "@gjsify/semver": "^0.64.20",
156
- "@gjsify/tar": "^0.64.20",
157
- "@gjsify/tsc": "^0.64.20",
158
- "@gjsify/utils": "^0.64.20",
159
- "@gjsify/web-polyfills": "^0.64.20",
160
- "@gjsify/workspace": "^0.64.20",
145
+ "@gjsify/blueprint": "^0.64.22",
146
+ "@gjsify/buffer": "^0.64.22",
147
+ "@gjsify/create-app": "^0.64.22",
148
+ "@gjsify/node-globals": "^0.64.22",
149
+ "@gjsify/node-polyfills": "^0.64.22",
150
+ "@gjsify/npm-registry": "^0.64.22",
151
+ "@gjsify/resolve-npm": "^0.64.22",
152
+ "@gjsify/rolldown-plugin-gjsify": "^0.64.22",
153
+ "@gjsify/vite-plugin-blueprint": "^0.64.22",
154
+ "@gjsify/rolldown-plugin-pnp": "^0.64.22",
155
+ "@gjsify/semver": "^0.64.22",
156
+ "@gjsify/tar": "^0.64.22",
157
+ "@gjsify/tsc": "^0.64.22",
158
+ "@gjsify/utils": "^0.64.22",
159
+ "@gjsify/web-polyfills": "^0.64.22",
160
+ "@gjsify/workspace": "^0.64.22",
161
161
  "acorn": "^8.17.0",
162
162
  "cosmiconfig": "^9.0.2",
163
163
  "get-tsconfig": "^4.14.0",
@@ -166,13 +166,13 @@
166
166
  "yargs": "^18.0.0"
167
167
  },
168
168
  "devDependencies": {
169
- "@gjsify/unit": "^0.64.20",
169
+ "@gjsify/unit": "^0.64.22",
170
170
  "@types/yargs": "^17.0.35",
171
171
  "typescript": "^6.0.3"
172
172
  },
173
173
  "peerDependencies": {
174
- "@gjsify/oxfmt-native": "^0.64.20",
175
- "@gjsify/rolldown-native": "^0.64.20"
174
+ "@gjsify/oxfmt-native": "^0.64.22",
175
+ "@gjsify/rolldown-native": "^0.64.22"
176
176
  },
177
177
  "peerDependenciesMeta": {
178
178
  "@gjsify/oxfmt-native": {