@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.
- package/dist/cli.gjs.mjs +80 -80
- package/lib/commands/ship.js +22 -1
- package/lib/types/config-data.d.ts +47 -0
- package/lib/utils/oxc-resolve.d.ts +43 -7
- package/lib/utils/oxc-resolve.js +79 -24
- package/lib/utils/ship/app-runtime.d.ts +21 -0
- package/lib/utils/ship/app-runtime.js +1 -1
- package/lib/utils/ship/layout.d.ts +13 -0
- package/lib/utils/ship/layout.js +12 -1
- package/lib/utils/ship/nested-bundles.d.ts +60 -0
- package/lib/utils/ship/nested-bundles.js +140 -0
- package/lib/utils/ship/release-record.d.ts +30 -0
- package/lib/utils/ship/release-record.js +1 -0
- package/lib/utils/ship/settings.d.ts +32 -1
- package/lib/utils/ship/settings.js +163 -0
- package/lib/utils/ship/signing.d.ts +45 -5
- package/lib/utils/ship/signing.js +74 -24
- package/lib/utils/ship/stage-manifest.js +19 -0
- package/lib/utils/ship/types.d.ts +73 -0
- package/package.json +20 -20
package/lib/commands/ship.js
CHANGED
|
@@ -35,6 +35,7 @@ import { describeExit, spawnToCompletion } from '../utils/spawn.js';
|
|
|
35
35
|
import { buildDeb } from '../utils/ship/deb.js';
|
|
36
36
|
import { deriveDepends, warnAboutGjsFloor, warnAboutNodeFloor } from '../utils/ship/depends.js';
|
|
37
37
|
import { isGtkRuntimeTarget, stageAppRuntime } from '../utils/ship/app-runtime.js';
|
|
38
|
+
import { recordedNestedBundles, signedSubordinatesFor, stageSubordinates } from '../utils/ship/nested-bundles.js';
|
|
38
39
|
import { resolveNodeModulesClosure, stageNodeModules } from '../utils/ship/node-modules.js';
|
|
39
40
|
import { acquireGtkRuntime } from '../utils/ship/gtk-runtime-acquire.js';
|
|
40
41
|
import { discoverPayload } from '../utils/ship/discover.js';
|
|
@@ -419,6 +420,17 @@ async function assemble(args) {
|
|
|
419
420
|
// distribution update ever patches.
|
|
420
421
|
const runtime = await resolveCarriedRuntime(layout, settings, projectDir);
|
|
421
422
|
carryNodeModules(runtime, layout, settings, projectDir, ship.nodeModules, ship.nodeModulesExclude);
|
|
423
|
+
// The nested bundles and helper executables ADR 0026 § 6 needed by hand —
|
|
424
|
+
// staged like the runtime above (tree-preserving, stage-relative, through
|
|
425
|
+
// `placeStage`'s `carried` seam), and never through `gjsify.ship.bundledTypelibs`'s
|
|
426
|
+
// flattening map for the reason `app-runtime.ts`'s header gives: a `.appex`'s
|
|
427
|
+
// internal relations survive only if its tree is copied as a tree.
|
|
428
|
+
const subordinates = stageSubordinates({
|
|
429
|
+
layout,
|
|
430
|
+
identity: settings,
|
|
431
|
+
nestedBundles: settings.nestedBundles,
|
|
432
|
+
helperExecutables: settings.helperExecutables,
|
|
433
|
+
});
|
|
422
434
|
const stageInputs = {
|
|
423
435
|
bundleFiles: discovered.bundleFiles,
|
|
424
436
|
launcher: renderLauncher(settings, basename(settings.bundlePath), layout, runtime.launcher),
|
|
@@ -489,7 +501,7 @@ async function assemble(args) {
|
|
|
489
501
|
: undefined;
|
|
490
502
|
if (icon !== undefined)
|
|
491
503
|
console.log(`${LOG} icon: ${iconSizes(icon).join('/')} px, ${icon.source}`);
|
|
492
|
-
const staged = placeStage(layout, { ...settings, icon }, planned, runtime.files);
|
|
504
|
+
const staged = placeStage(layout, { ...settings, icon }, planned, [...runtime.files, ...subordinates.files]);
|
|
493
505
|
writeStage(stageDir, staged);
|
|
494
506
|
console.log(`${LOG} staged ${staged.length} file(s) for ${layout.name} in ${relative(projectDir, stageDir)}/`);
|
|
495
507
|
if (args.verbose)
|
|
@@ -1078,6 +1090,12 @@ async function packOne(input) {
|
|
|
1078
1090
|
// `signed/<id>/` is scratch this function owns. The arriving stage is never
|
|
1079
1091
|
// written to: `writeStage` wipes what it is pointed at, and a re-signed file
|
|
1080
1092
|
// in it would fail the very `readStage` above on the next run.
|
|
1093
|
+
// DERIVED, not carried: the stage-relative path of a nested bundle or helper
|
|
1094
|
+
// executable is `layout.dirs(settings).plugins`/`.helpers` joined with its
|
|
1095
|
+
// name, computable from `settings` alone on either host — see
|
|
1096
|
+
// `nested-bundles.ts`'s `signedSubordinatesFor` header for why that is what
|
|
1097
|
+
// keeps this call and `stageSubordinates` from disagreeing about a path.
|
|
1098
|
+
const subordinates = signedSubordinatesFor(layout, settings, settings);
|
|
1081
1099
|
const payload = input.sign.kind === 'sign'
|
|
1082
1100
|
? await signPayload({
|
|
1083
1101
|
payload: assembled,
|
|
@@ -1091,6 +1109,8 @@ async function packOne(input) {
|
|
|
1091
1109
|
// with the payload, so an entitlements plist written into it would
|
|
1092
1110
|
// read as a file `codesign` added.
|
|
1093
1111
|
entitlementsPath: join(outRoot, 'signed', `${format.id}.entitlements.plist`),
|
|
1112
|
+
nestedBundles: subordinates.nestedBundles,
|
|
1113
|
+
helperExecutables: subordinates.helperExecutables,
|
|
1094
1114
|
verbose: input.verbose,
|
|
1095
1115
|
log: (line) => console.log(`${LOG} ${line}`),
|
|
1096
1116
|
})
|
|
@@ -1429,6 +1449,7 @@ async function packOne(input) {
|
|
|
1429
1449
|
signature: recordedSignature(input.sign.kind === 'sign' ? input.sign.identity : undefined, ADHOC_IDENTITY),
|
|
1430
1450
|
notarized,
|
|
1431
1451
|
stapled,
|
|
1452
|
+
nestedBundles: recordedNestedBundles(payload, subordinates.nestedBundles),
|
|
1432
1453
|
generator: `@gjsify/cli ${cliVersion()}`,
|
|
1433
1454
|
});
|
|
1434
1455
|
if (input.verbose)
|
|
@@ -753,6 +753,53 @@ export interface ConfigDataShip extends AppMetadata {
|
|
|
753
753
|
* is the default path and a legitimate output (ADR 0024 § A13).
|
|
754
754
|
*/
|
|
755
755
|
sign?: ShipSignOptions;
|
|
756
|
+
/**
|
|
757
|
+
* Further BUNDLES this package's darwin container embeds — e.g. a Safari Web
|
|
758
|
+
* Extension `.appex` built by `xcrun safari-web-extension-packager`. Each is
|
|
759
|
+
* copied wholesale into `Contents/PlugIns/` and signed+sealed as its OWN
|
|
760
|
+
* bundle, with its OWN entitlements, before the outer `.app` is sealed
|
|
761
|
+
* (ADR 0024 § A12-§ A17; the beifahrer ADR this answers: 0026 § 6).
|
|
762
|
+
*
|
|
763
|
+
* `Contents/PlugIns` is Apple's own nested-code location (`codesign(1)`'s
|
|
764
|
+
* bundle format, beside `Frameworks`/`Helpers`/`XPCServices`); no other
|
|
765
|
+
* layout has an equivalent slot, so this is IGNORED (not refused) outside
|
|
766
|
+
* `gjsify ship darwin` — one notice on stderr, exit 0 (ADR 0024 amendment,
|
|
767
|
+
* 2026-11-06 addendum, beifahrer#203). The shape is still validated on
|
|
768
|
+
* every layout.
|
|
769
|
+
*/
|
|
770
|
+
nestedBundles?: ShipNestedBundleOptions[];
|
|
771
|
+
/**
|
|
772
|
+
* Prebuilt helper EXECUTABLES this package carries beside the carried
|
|
773
|
+
* interpreter, in `Contents/Helpers/` — e.g. a small Swift binary with no GI
|
|
774
|
+
* binding (`SafariServices` has none), execed via `Gio.Subprocess`. Signed
|
|
775
|
+
* with its OWN entitlements before the outer `.app` is sealed. Darwin only,
|
|
776
|
+
* for the same reason as {@link ConfigDataShip.nestedBundles}.
|
|
777
|
+
*/
|
|
778
|
+
helperExecutables?: ShipHelperExecutableOptions[];
|
|
779
|
+
}
|
|
780
|
+
/** One entry of `gjsify.ship.nestedBundles`. */
|
|
781
|
+
export interface ShipNestedBundleOptions {
|
|
782
|
+
/** Project-relative directory holding the built bundle (e.g. the `.appex`). */
|
|
783
|
+
source: string;
|
|
784
|
+
/** Destination leaf under `Contents/PlugIns/` — keep the source's own extension. */
|
|
785
|
+
name: string;
|
|
786
|
+
/**
|
|
787
|
+
* Entitlements this bundle is signed and sealed with, REPLACING the outer
|
|
788
|
+
* signer's own set rather than adding to it: a sandboxed extension keeps the
|
|
789
|
+
* sandbox entitlements its packager wrote (`com.apple.security.app-sandbox`,
|
|
790
|
+
* file access) and must not inherit the host app's JIT/dyld-environment
|
|
791
|
+
* grants (ADR 0026 § 5). Absent or `[]` signs with none.
|
|
792
|
+
*/
|
|
793
|
+
entitlements?: string[];
|
|
794
|
+
}
|
|
795
|
+
/** One entry of `gjsify.ship.helperExecutables`. */
|
|
796
|
+
export interface ShipHelperExecutableOptions {
|
|
797
|
+
/** Project-relative path of the prebuilt executable. */
|
|
798
|
+
source: string;
|
|
799
|
+
/** Destination leaf under `Contents/Helpers/`. */
|
|
800
|
+
name: string;
|
|
801
|
+
/** Entitlements this helper is signed with. Absent or `[]` signs with none. */
|
|
802
|
+
entitlements?: string[];
|
|
756
803
|
}
|
|
757
804
|
/** One entry of `gjsify.ship.executables`. */
|
|
758
805
|
export interface ShipExecutableOptions {
|
|
@@ -51,15 +51,51 @@ export interface RunOxcOptions {
|
|
|
51
51
|
cwd?: string;
|
|
52
52
|
verbose?: boolean;
|
|
53
53
|
}
|
|
54
|
+
export interface OxfmtEngineInputs {
|
|
55
|
+
isGjs: boolean;
|
|
56
|
+
/** The `@gjsify/oxfmt-native` bridge loaded and its library opened. */
|
|
57
|
+
nativeLoadable: boolean;
|
|
58
|
+
/** The oxfmt release the bridge embeds; `undefined` when the bridge does not say. */
|
|
59
|
+
embeddedVersion?: string;
|
|
60
|
+
/** Version of the oxfmt npm package the project resolves; `undefined` when it has none. */
|
|
61
|
+
projectVersion?: string;
|
|
62
|
+
/** `GJSIFY_OXFMT`. */
|
|
63
|
+
override?: string;
|
|
64
|
+
}
|
|
65
|
+
export interface OxfmtEngineDecision {
|
|
66
|
+
engine: 'native' | 'npm';
|
|
67
|
+
/** The forced `native` engine runs a different oxfmt than the project's lock. */
|
|
68
|
+
warning?: string;
|
|
69
|
+
/** The forced `native` engine cannot be loaded. */
|
|
70
|
+
error?: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The engine pick as a pure function. Two engines that are not the same build of
|
|
74
|
+
* oxfmt format some inputs differently (`for (…; cond;) {` vs `for (…; cond; ) {`
|
|
75
|
+
* between 0.61 and 0.56), so a project that pins its own `oxfmt` must get THAT
|
|
76
|
+
* build, or `format:check` is green under GJS locally and red under Node in CI.
|
|
77
|
+
*
|
|
78
|
+
* - `npm` override → npm launcher.
|
|
79
|
+
* - `native` override → the bridge, an error when it is not loadable, a warning
|
|
80
|
+
* when it differs from the project's pinned version.
|
|
81
|
+
* - Not GJS, or bridge not loadable → npm launcher.
|
|
82
|
+
* - Project resolves no `oxfmt`, or either version is unknown → native (the
|
|
83
|
+
* Node-free default; there is nothing to disagree with).
|
|
84
|
+
* - Both known → native only when equal, else the project's npm `oxfmt` wins.
|
|
85
|
+
*/
|
|
86
|
+
export declare function decideOxfmtEngine(input: OxfmtEngineInputs): OxfmtEngineDecision;
|
|
87
|
+
/**
|
|
88
|
+
* Version of the `oxfmt` npm package the project resolves (same walk as
|
|
89
|
+
* {@link findOxcLauncher}), or `undefined` when there is none.
|
|
90
|
+
*/
|
|
91
|
+
export declare function findProjectOxfmtVersion(cwd?: string): string | undefined;
|
|
54
92
|
/**
|
|
55
|
-
* Engine pick for oxfmt —
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* - Default: native under GJS when loadable (the Node launcher cannot run
|
|
60
|
-
* without a Node host), npm launcher on Node.
|
|
93
|
+
* Engine pick for oxfmt — see {@link decideOxfmtEngine} for the rule. This wrapper
|
|
94
|
+
* gathers the inputs (bridge probe, project version) and applies the outcome:
|
|
95
|
+
* a forced `native` that cannot load throws, a forced `native` on another version
|
|
96
|
+
* than the project's warns once per process.
|
|
61
97
|
*/
|
|
62
|
-
export declare function shouldUseNativeOxfmt(): Promise<boolean>;
|
|
98
|
+
export declare function shouldUseNativeOxfmt(cwd?: string): Promise<boolean>;
|
|
63
99
|
/**
|
|
64
100
|
* Run an oxc tool (oxlint / oxfmt).
|
|
65
101
|
*
|
package/lib/utils/oxc-resolve.js
CHANGED
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
// Rust rule subset. Lint stays Node-spawned until that trade-off is decided
|
|
48
48
|
// (tracked in status/open-todos/README.md).
|
|
49
49
|
import { existsSync, readFileSync } from 'node:fs';
|
|
50
|
-
import { join, resolve } from 'node:path';
|
|
50
|
+
import { dirname, join, resolve } from 'node:path';
|
|
51
51
|
import { createRequire } from 'node:module';
|
|
52
52
|
import { pathToFileURL } from 'node:url';
|
|
53
53
|
import { hostLibc } from './platform-check.js';
|
|
@@ -297,38 +297,93 @@ async function tryLoadNativeOxfmt() {
|
|
|
297
297
|
})();
|
|
298
298
|
return _nativeOxfmtProbe;
|
|
299
299
|
}
|
|
300
|
+
const normalizeVersion = (v) => v.trim().replace(/^v/, '');
|
|
300
301
|
/**
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
302
|
+
* The engine pick as a pure function. Two engines that are not the same build of
|
|
303
|
+
* oxfmt format some inputs differently (`for (…; cond;) {` vs `for (…; cond; ) {`
|
|
304
|
+
* between 0.61 and 0.56), so a project that pins its own `oxfmt` must get THAT
|
|
305
|
+
* build, or `format:check` is green under GJS locally and red under Node in CI.
|
|
306
|
+
*
|
|
307
|
+
* - `npm` override → npm launcher.
|
|
308
|
+
* - `native` override → the bridge, an error when it is not loadable, a warning
|
|
309
|
+
* when it differs from the project's pinned version.
|
|
310
|
+
* - Not GJS, or bridge not loadable → npm launcher.
|
|
311
|
+
* - Project resolves no `oxfmt`, or either version is unknown → native (the
|
|
312
|
+
* Node-free default; there is nothing to disagree with).
|
|
313
|
+
* - Both known → native only when equal, else the project's npm `oxfmt` wins.
|
|
307
314
|
*/
|
|
308
|
-
export
|
|
309
|
-
const
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if (!
|
|
316
|
-
|
|
317
|
-
|
|
315
|
+
export function decideOxfmtEngine(input) {
|
|
316
|
+
const { isGjs: gjs, nativeLoadable, embeddedVersion, projectVersion, override } = input;
|
|
317
|
+
const known = embeddedVersion !== undefined && projectVersion !== undefined;
|
|
318
|
+
const differs = known && normalizeVersion(embeddedVersion) !== normalizeVersion(projectVersion);
|
|
319
|
+
if (override === 'npm')
|
|
320
|
+
return { engine: 'npm' };
|
|
321
|
+
if (override === 'native') {
|
|
322
|
+
if (!nativeLoadable) {
|
|
323
|
+
return {
|
|
324
|
+
engine: 'native',
|
|
325
|
+
error: 'GJSIFY_OXFMT=native but @gjsify/oxfmt-native is not loadable (no prebuild for this architecture, or not running under GJS).',
|
|
326
|
+
};
|
|
318
327
|
}
|
|
319
|
-
return
|
|
328
|
+
return differs
|
|
329
|
+
? {
|
|
330
|
+
engine: 'native',
|
|
331
|
+
warning: `GJSIFY_OXFMT=native: @gjsify/oxfmt-native embeds oxfmt ${embeddedVersion} but this project ` +
|
|
332
|
+
`pins oxfmt ${projectVersion}; output can differ from the project's own oxfmt (and from CI).`,
|
|
333
|
+
}
|
|
334
|
+
: { engine: 'native' };
|
|
320
335
|
}
|
|
321
|
-
if (!
|
|
336
|
+
if (!gjs || !nativeLoadable)
|
|
337
|
+
return { engine: 'npm' };
|
|
338
|
+
return { engine: differs ? 'npm' : 'native' };
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Version of the `oxfmt` npm package the project resolves (same walk as
|
|
342
|
+
* {@link findOxcLauncher}), or `undefined` when there is none.
|
|
343
|
+
*/
|
|
344
|
+
export function findProjectOxfmtVersion(cwd = process.cwd()) {
|
|
345
|
+
try {
|
|
346
|
+
const launcher = findOxcLauncher('oxfmt', cwd);
|
|
347
|
+
const pkg = JSON.parse(readFileSync(join(dirname(launcher), '..', 'package.json'), 'utf-8'));
|
|
348
|
+
return typeof pkg?.version === 'string' ? pkg.version : undefined;
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
let _engineWarned = false;
|
|
355
|
+
/**
|
|
356
|
+
* Engine pick for oxfmt — see {@link decideOxfmtEngine} for the rule. This wrapper
|
|
357
|
+
* gathers the inputs (bridge probe, project version) and applies the outcome:
|
|
358
|
+
* a forced `native` that cannot load throws, a forced `native` on another version
|
|
359
|
+
* than the project's warns once per process.
|
|
360
|
+
*/
|
|
361
|
+
export async function shouldUseNativeOxfmt(cwd = process.cwd()) {
|
|
362
|
+
const env = globalThis.process?.env ?? {};
|
|
363
|
+
const override = env.GJSIFY_OXFMT;
|
|
364
|
+
// Nothing to probe when npm is forced or there is no GJS host (and no native override).
|
|
365
|
+
if (override === 'npm' || (!isGjs() && override !== 'native'))
|
|
322
366
|
return false;
|
|
323
|
-
const native =
|
|
367
|
+
const native = await tryLoadNativeOxfmt();
|
|
368
|
+
const decision = decideOxfmtEngine({
|
|
369
|
+
isGjs: isGjs(),
|
|
370
|
+
nativeLoadable: native !== null,
|
|
371
|
+
embeddedVersion: native?.EMBEDDED_OXFMT_VERSION,
|
|
372
|
+
projectVersion: findProjectOxfmtVersion(cwd),
|
|
373
|
+
override,
|
|
374
|
+
});
|
|
375
|
+
if (decision.error)
|
|
376
|
+
throw new Error(decision.error + (_nativeOxfmtLoadError ? `\n${_nativeOxfmtLoadError.message}` : ''));
|
|
377
|
+
if (decision.warning && !_engineWarned) {
|
|
378
|
+
_engineWarned = true;
|
|
379
|
+
console.warn(`[gjsify oxc] ${decision.warning}`);
|
|
380
|
+
}
|
|
324
381
|
// Falling back to the npm launcher is not an error, so the default path is
|
|
325
382
|
// quiet — but a MEASURED reason why the native bridge would not load is the
|
|
326
383
|
// only place the user hears it, and it is the whole point of measuring.
|
|
327
|
-
|
|
328
|
-
// same answer for free.
|
|
329
|
-
if (!native && _nativeOxfmtLoadError)
|
|
384
|
+
if (!native && _nativeOxfmtLoadError && decision.engine === 'npm')
|
|
330
385
|
console.debug(_nativeOxfmtLoadError.message);
|
|
331
|
-
return native;
|
|
386
|
+
return decision.engine === 'native';
|
|
332
387
|
}
|
|
333
388
|
/**
|
|
334
389
|
* Run an oxc tool (oxlint / oxfmt).
|
|
@@ -233,3 +233,24 @@ export interface StageAppRuntimeInput {
|
|
|
233
233
|
* under `gtk/` on the other side of the copy.
|
|
234
234
|
*/
|
|
235
235
|
export declare function stageAppRuntime(input: StageAppRuntimeInput): StagedAppRuntime;
|
|
236
|
+
/**
|
|
237
|
+
* The mode one staged closure file gets.
|
|
238
|
+
*
|
|
239
|
+
* THREE tests, and each covers a case the others do not:
|
|
240
|
+
*
|
|
241
|
+
* * the source's own execute bit — the truth when the closure came off a real
|
|
242
|
+
* filesystem, and the ONLY thing that knows `libexec/gstreamer-1.0/gst-plugin-scanner`
|
|
243
|
+
* is a program (it has no extension, and GStreamer forks it out-of-process so a
|
|
244
|
+
* plugin that crashes on load cannot take the app down with it);
|
|
245
|
+
* * the shared-library spelling, shared with `plan.ts` rather than restated —
|
|
246
|
+
* load-bearing because `actions/upload-artifact` stores NO POSIX mode, so a
|
|
247
|
+
* closure that arrived through a CI artifact has 0644 on every file and the
|
|
248
|
+
* source-mode test above is blind on exactly the path this milestone's own CI
|
|
249
|
+
* leg takes;
|
|
250
|
+
* * `libexec/` and `bin/`, for the same reason applied to the extensionless
|
|
251
|
+
* programs the artifact round trip flattened.
|
|
252
|
+
*
|
|
253
|
+
* A dylib does not need the execute bit to be `dlopen`'d, so this is not about
|
|
254
|
+
* making the closure loadable — it is about the two files that are EXEC'd.
|
|
255
|
+
*/
|
|
256
|
+
export declare function stagedMode(rel: string, abs: string): number;
|
|
@@ -445,7 +445,7 @@ function discoveryOptions(input) {
|
|
|
445
445
|
* A dylib does not need the execute bit to be `dlopen`'d, so this is not about
|
|
446
446
|
* making the closure loadable — it is about the two files that are EXEC'd.
|
|
447
447
|
*/
|
|
448
|
-
function stagedMode(rel, abs) {
|
|
448
|
+
export function stagedMode(rel, abs) {
|
|
449
449
|
if (isExecutableAsset(rel))
|
|
450
450
|
return 0o755;
|
|
451
451
|
if (rel.startsWith('libexec/') || rel.startsWith('bin/'))
|
|
@@ -145,6 +145,19 @@ export interface LayoutDirs {
|
|
|
145
145
|
* first place, so its interpreter stays beside the `.cmd`.
|
|
146
146
|
*/
|
|
147
147
|
helpers: string;
|
|
148
|
+
/**
|
|
149
|
+
* macOS only: where a NESTED BUNDLE lives — `Contents/PlugIns`, Apple's own
|
|
150
|
+
* slot for a `.appex` (`codesign(1)`'s bundle format, beside `Frameworks`,
|
|
151
|
+
* `Helpers`, `XPCServices`). `''` on linux and windows, where neither a
|
|
152
|
+
* prefix nor a program directory has an equivalent: a Flatpak sandbox has no
|
|
153
|
+
* App Extension model and a Windows program directory has nothing Apple's
|
|
154
|
+
* extension points name. `gjsify.ship.nestedBundles` is IGNORED (not
|
|
155
|
+
* refused) on any layout where this is `''` — `resolveShipNestedBundles`
|
|
156
|
+
* resolves it to `[]` before `stageSubordinates` ever sees it (ADR 0024
|
|
157
|
+
* amendment, "nested bundles and helper executables", 2026-11-06 addendum;
|
|
158
|
+
* the beifahrer ADR this answers: 0026 § 6).
|
|
159
|
+
*/
|
|
160
|
+
plugins: string;
|
|
148
161
|
}
|
|
149
162
|
/**
|
|
150
163
|
* One operating system's layout.
|
package/lib/utils/ship/layout.js
CHANGED
|
@@ -140,6 +140,7 @@ export const LAYOUTS = {
|
|
|
140
140
|
data: 'share',
|
|
141
141
|
other: '',
|
|
142
142
|
helpers: '',
|
|
143
|
+
plugins: '',
|
|
143
144
|
}),
|
|
144
145
|
// THE RASTERS THE BUILD-TIME READERS OF THE THEME NEED, and the one row
|
|
145
146
|
// whose icon is written as ordinary payload files rather than into a
|
|
@@ -319,6 +320,8 @@ export const LAYOUTS = {
|
|
|
319
320
|
// file there named `<BinaryName>` collides with it bytewise even
|
|
320
321
|
// when the casing differs.
|
|
321
322
|
helpers: `${app}/Contents/Helpers`,
|
|
323
|
+
// Apple's own slot for a nested `.appex` — see `LayoutDirs.plugins`.
|
|
324
|
+
plugins: `${app}/Contents/PlugIns`,
|
|
322
325
|
};
|
|
323
326
|
},
|
|
324
327
|
// The two files that make the directory a BUNDLE. Without `Info.plist`
|
|
@@ -450,7 +453,15 @@ export const LAYOUTS = {
|
|
|
450
453
|
// LOADER: `lib/` is what the launcher prepends to `PATH`, because Windows
|
|
451
454
|
// has no rpath — a DLL is found on `PATH`, in the directory of the image
|
|
452
455
|
// that loaded it, or not at all.
|
|
453
|
-
dirs: () => ({
|
|
456
|
+
dirs: () => ({
|
|
457
|
+
launcher: '',
|
|
458
|
+
bundle: 'app',
|
|
459
|
+
native: 'lib',
|
|
460
|
+
data: 'share',
|
|
461
|
+
other: '',
|
|
462
|
+
helpers: '',
|
|
463
|
+
plugins: '',
|
|
464
|
+
}),
|
|
454
465
|
// NO METADATA, and one PROGRAM. A Windows installer's metadata lives in the
|
|
455
466
|
// `.msi`'s own tables, not in a file inside the program directory —
|
|
456
467
|
// `msi.ts` puts every such fact in the `Product`/`Package` attributes of the
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Layout, LayoutIdentity } from './layout.js';
|
|
2
|
+
import type { PayloadEntry } from './payload.js';
|
|
3
|
+
import type { RecordedNestedBundle } from './release-record.js';
|
|
4
|
+
import type { SignedSubordinate } from './signing.js';
|
|
5
|
+
import type { ShipHelperExecutable, ShipHelperExecutableFacts, ShipNestedBundle, ShipNestedBundleFacts, StagedFile } from './types.js';
|
|
6
|
+
/** The staged files, and what `signPayload` needs to sign them with their OWN entitlements. */
|
|
7
|
+
export interface StagedSubordinates {
|
|
8
|
+
files: StagedFile[];
|
|
9
|
+
nestedBundles: SignedSubordinate[];
|
|
10
|
+
helperExecutables: SignedSubordinate[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Stage every `gjsify.ship.nestedBundles` entry and `gjsify.ship.helperExecutables`
|
|
14
|
+
* entry into the layout's `plugins`/`helpers` slots.
|
|
15
|
+
*
|
|
16
|
+
* `[]` for both inputs is the common case and produces `{ files: [], ... }` —
|
|
17
|
+
* cheap enough that `commands/ship.ts` can call this unconditionally rather than
|
|
18
|
+
* branching on whether a project declares either key.
|
|
19
|
+
*/
|
|
20
|
+
export declare function stageSubordinates(input: {
|
|
21
|
+
layout: Layout;
|
|
22
|
+
identity: LayoutIdentity;
|
|
23
|
+
nestedBundles: readonly ShipNestedBundle[];
|
|
24
|
+
helperExecutables: readonly ShipHelperExecutable[];
|
|
25
|
+
}): StagedSubordinates;
|
|
26
|
+
/**
|
|
27
|
+
* What `signPayload` needs to sign the subordinates `stageSubordinates` staged —
|
|
28
|
+
* DERIVED from the layout and the facts alone, never carried as a separate
|
|
29
|
+
* closure.
|
|
30
|
+
*
|
|
31
|
+
* This is what lets `PackSettings` carry only `{ name, entitlements }` (see
|
|
32
|
+
* `types.ts`): the stage-relative PATH is `layout.dirs(settings).plugins` (or
|
|
33
|
+
* `.helpers`) joined with the name, which both the assembling host (which also
|
|
34
|
+
* has the absolute `source`) and a `--from-stage` finishing host (which has
|
|
35
|
+
* neither) can compute from data they already hold. Calling this twice — once
|
|
36
|
+
* in `assemble()`, once in `packOne` — can therefore never disagree with where
|
|
37
|
+
* {@link stageSubordinates} put the bytes.
|
|
38
|
+
*/
|
|
39
|
+
export declare function signedSubordinatesFor(layout: Layout, identity: LayoutIdentity, facts: {
|
|
40
|
+
nestedBundles: readonly ShipNestedBundleFacts[];
|
|
41
|
+
helperExecutables: readonly ShipHelperExecutableFacts[];
|
|
42
|
+
}): {
|
|
43
|
+
nestedBundles: SignedSubordinate[];
|
|
44
|
+
helperExecutables: SignedSubordinate[];
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* What the release record says about each nested bundle, read off its OWN
|
|
48
|
+
* `Info.plist` rather than off `gjsify.ship.nestedBundles` — the declaration
|
|
49
|
+
* names a `source` directory and an entitlements list, neither of which is the
|
|
50
|
+
* bundle identifier or its extension point, and both of THOSE only exist inside
|
|
51
|
+
* the `.appex` the project built.
|
|
52
|
+
*
|
|
53
|
+
* `extensionPoint` comes from `NSExtension.NSExtensionPointIdentifier`, Apple's
|
|
54
|
+
* own nesting for every extension point (`com.apple.Safari.web-extension` is
|
|
55
|
+
* one value among several, not a special case this reader has to know). A
|
|
56
|
+
* nested bundle that is not an extension at all — or whose `Info.plist` this
|
|
57
|
+
* reader cannot find or parse — simply omits the key, which is the same
|
|
58
|
+
* additive contract the field's own doc states (`release-record.ts`).
|
|
59
|
+
*/
|
|
60
|
+
export declare function recordedNestedBundles(payload: readonly PayloadEntry[], nestedBundles: readonly SignedSubordinate[]): RecordedNestedBundle[];
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Staging darwin's nested bundles and helper executables into the payload
|
|
2
|
+
// (ADR 0024 amendment, "nested bundles and helper executables"; the beifahrer
|
|
3
|
+
// ADR this answers: 0026 § 6).
|
|
4
|
+
//
|
|
5
|
+
// A consumer whose Xcode-built shell was discarded in favour of ADR 0026's "one
|
|
6
|
+
// app" decision still has ONE bundle `gjsify ship darwin` cannot carry on its
|
|
7
|
+
// own: a Safari Web Extension `.appex`, which macOS registers only when it
|
|
8
|
+
// sits under the PARENT app's `Contents/PlugIns/`. This module is the staging
|
|
9
|
+
// half of that gap — copy the built `.appex` (or any other nested bundle) and
|
|
10
|
+
// any prebuilt helper executable into the layout's own slots for them
|
|
11
|
+
// (`Layout.dirs().plugins` / `.helpers`), tree-preserving for the former and
|
|
12
|
+
// flat for the latter, because a bundle is a DIRECTORY and a helper is a FILE.
|
|
13
|
+
//
|
|
14
|
+
// SIGNING is a separate concern, in `signing.ts`: this module only decides
|
|
15
|
+
// WHERE the bytes land, never how they are signed. `stagedSubordinates` hands
|
|
16
|
+
// back the stage-relative paths `commands/ship.ts` passes to `signPayload` as
|
|
17
|
+
// `nestedBundles`/`helperExecutables`, so the two modules cannot drift on what
|
|
18
|
+
// a "nested bundle" or a "helper executable" even IS.
|
|
19
|
+
import { join, posix } from 'node:path';
|
|
20
|
+
import { listFilesRecursive } from './discover.js';
|
|
21
|
+
import { stagedMode } from './app-runtime.js';
|
|
22
|
+
/**
|
|
23
|
+
* Stage every `gjsify.ship.nestedBundles` entry and `gjsify.ship.helperExecutables`
|
|
24
|
+
* entry into the layout's `plugins`/`helpers` slots.
|
|
25
|
+
*
|
|
26
|
+
* `[]` for both inputs is the common case and produces `{ files: [], ... }` —
|
|
27
|
+
* cheap enough that `commands/ship.ts` can call this unconditionally rather than
|
|
28
|
+
* branching on whether a project declares either key.
|
|
29
|
+
*/
|
|
30
|
+
export function stageSubordinates(input) {
|
|
31
|
+
const dirs = input.layout.dirs(input.identity);
|
|
32
|
+
const files = [];
|
|
33
|
+
const nestedBundles = [];
|
|
34
|
+
const helperExecutables = [];
|
|
35
|
+
for (const bundle of input.nestedBundles) {
|
|
36
|
+
if (dirs.plugins === '') {
|
|
37
|
+
throw new Error(`gjsify ship: the ${input.layout.name} layout has no \`Contents/PlugIns\` equivalent, so ` +
|
|
38
|
+
`the nested bundle "${bundle.name}" has nowhere to go. ` +
|
|
39
|
+
'`gjsify.ship.nestedBundles` is darwin-only; `resolveShipNestedBundles` should have ' +
|
|
40
|
+
'resolved this to `[]` already — report this as a gjsify bug if it did not.');
|
|
41
|
+
}
|
|
42
|
+
const bundleRoot = posix.join(dirs.plugins, bundle.name);
|
|
43
|
+
for (const rel of listFilesRecursive(bundle.source)) {
|
|
44
|
+
const abs = join(bundle.source, ...rel.split(posix.sep));
|
|
45
|
+
files.push({
|
|
46
|
+
path: posix.join(bundleRoot, rel),
|
|
47
|
+
mode: stagedMode(rel, abs),
|
|
48
|
+
source: { kind: 'file', path: abs },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
nestedBundles.push({ path: bundleRoot, entitlements: bundle.entitlements });
|
|
52
|
+
}
|
|
53
|
+
for (const helper of input.helperExecutables) {
|
|
54
|
+
if (dirs.helpers === '') {
|
|
55
|
+
throw new Error(`gjsify ship: the ${input.layout.name} layout has no \`Contents/Helpers\` equivalent, so ` +
|
|
56
|
+
`the helper executable "${helper.name}" has nowhere to go. ` +
|
|
57
|
+
'`gjsify.ship.helperExecutables` is darwin-only; `resolveShipHelperExecutables` should have ' +
|
|
58
|
+
'resolved this to `[]` already — report this as a gjsify bug if it did not.');
|
|
59
|
+
}
|
|
60
|
+
const path = posix.join(dirs.helpers, helper.name);
|
|
61
|
+
files.push({ path, mode: 0o755, source: { kind: 'file', path: helper.source } });
|
|
62
|
+
helperExecutables.push({ path, entitlements: helper.entitlements });
|
|
63
|
+
}
|
|
64
|
+
return { files, nestedBundles, helperExecutables };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* What `signPayload` needs to sign the subordinates `stageSubordinates` staged —
|
|
68
|
+
* DERIVED from the layout and the facts alone, never carried as a separate
|
|
69
|
+
* closure.
|
|
70
|
+
*
|
|
71
|
+
* This is what lets `PackSettings` carry only `{ name, entitlements }` (see
|
|
72
|
+
* `types.ts`): the stage-relative PATH is `layout.dirs(settings).plugins` (or
|
|
73
|
+
* `.helpers`) joined with the name, which both the assembling host (which also
|
|
74
|
+
* has the absolute `source`) and a `--from-stage` finishing host (which has
|
|
75
|
+
* neither) can compute from data they already hold. Calling this twice — once
|
|
76
|
+
* in `assemble()`, once in `packOne` — can therefore never disagree with where
|
|
77
|
+
* {@link stageSubordinates} put the bytes.
|
|
78
|
+
*/
|
|
79
|
+
export function signedSubordinatesFor(layout, identity, facts) {
|
|
80
|
+
const dirs = layout.dirs(identity);
|
|
81
|
+
return {
|
|
82
|
+
nestedBundles: facts.nestedBundles.map((bundle) => ({
|
|
83
|
+
path: posix.join(dirs.plugins, bundle.name),
|
|
84
|
+
entitlements: bundle.entitlements,
|
|
85
|
+
})),
|
|
86
|
+
helperExecutables: facts.helperExecutables.map((helper) => ({
|
|
87
|
+
path: posix.join(dirs.helpers, helper.name),
|
|
88
|
+
entitlements: helper.entitlements,
|
|
89
|
+
})),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* One `<key>NAME</key>` entry's string value, read with a REGEX rather than a
|
|
94
|
+
* plist parser — the same tradeoff this tree already makes in `plist.ts`
|
|
95
|
+
* (renders XML by string template, never parses it): a release record is
|
|
96
|
+
* additive metadata, not a signature the pack path depends on, so a plist this
|
|
97
|
+
* reader cannot follow degrades to an absent field rather than a build failure.
|
|
98
|
+
*/
|
|
99
|
+
function plistStringValue(xml, key) {
|
|
100
|
+
const match = new RegExp(`<key>${key}</key>\\s*<string>([^<]*)</string>`).exec(xml);
|
|
101
|
+
return match?.[1];
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The `Info.plist` of one nested bundle, read back off the PACKED payload —
|
|
105
|
+
* after `signPayload` has run, so this sees exactly the bytes the artifact
|
|
106
|
+
* ships, never the assembling host's copy.
|
|
107
|
+
*/
|
|
108
|
+
function nestedBundlePlist(payload, bundlePath) {
|
|
109
|
+
const entry = payload.find((file) => file.path === posix.join(bundlePath, 'Contents', 'Info.plist'));
|
|
110
|
+
return entry === undefined ? undefined : new TextDecoder().decode(entry.data);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* What the release record says about each nested bundle, read off its OWN
|
|
114
|
+
* `Info.plist` rather than off `gjsify.ship.nestedBundles` — the declaration
|
|
115
|
+
* names a `source` directory and an entitlements list, neither of which is the
|
|
116
|
+
* bundle identifier or its extension point, and both of THOSE only exist inside
|
|
117
|
+
* the `.appex` the project built.
|
|
118
|
+
*
|
|
119
|
+
* `extensionPoint` comes from `NSExtension.NSExtensionPointIdentifier`, Apple's
|
|
120
|
+
* own nesting for every extension point (`com.apple.Safari.web-extension` is
|
|
121
|
+
* one value among several, not a special case this reader has to know). A
|
|
122
|
+
* nested bundle that is not an extension at all — or whose `Info.plist` this
|
|
123
|
+
* reader cannot find or parse — simply omits the key, which is the same
|
|
124
|
+
* additive contract the field's own doc states (`release-record.ts`).
|
|
125
|
+
*/
|
|
126
|
+
export function recordedNestedBundles(payload, nestedBundles) {
|
|
127
|
+
return nestedBundles.map((bundle) => {
|
|
128
|
+
const name = posix.basename(bundle.path);
|
|
129
|
+
const xml = nestedBundlePlist(payload, bundle.path);
|
|
130
|
+
if (xml === undefined)
|
|
131
|
+
return { name };
|
|
132
|
+
const bundleId = plistStringValue(xml, 'CFBundleIdentifier');
|
|
133
|
+
const extensionPoint = plistStringValue(xml, 'NSExtensionPointIdentifier');
|
|
134
|
+
return {
|
|
135
|
+
name,
|
|
136
|
+
...(bundleId === undefined ? {} : { bundleId }),
|
|
137
|
+
...(extensionPoint === undefined ? {} : { extensionPoint }),
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -24,9 +24,37 @@ export interface ReleaseRecord {
|
|
|
24
24
|
signature: RecordedSignature;
|
|
25
25
|
notarized: boolean;
|
|
26
26
|
stapled: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Bundles embedded under `Contents/PlugIns/` (`gjsify.ship.nestedBundles`) —
|
|
29
|
+
* `[]` for an artifact with none. ADDITIVE and backward-compatible: an older
|
|
30
|
+
* record with no such key is still a valid `schema: 1` record, because this
|
|
31
|
+
* is the first consumer of the field and nothing reads its absence as a
|
|
32
|
+
* claim. Named rather than counted, for `@gjsify/site` to tell a Safari
|
|
33
|
+
* extension apart from an unrelated nested bundle a future consumer adds —
|
|
34
|
+
* `extensionPoint` is what lets it do that without parsing `Info.plist`
|
|
35
|
+
* itself.
|
|
36
|
+
*/
|
|
37
|
+
nestedBundles: RecordedNestedBundle[];
|
|
27
38
|
/** `@gjsify/cli <version>` — who wrote this record. */
|
|
28
39
|
generator: string;
|
|
29
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* One nested bundle this artifact's `Contents/PlugIns/` carries, as the site
|
|
43
|
+
* needs to know it — e.g. a Safari Web Extension's `com.apple.Safari.web-extension`.
|
|
44
|
+
*/
|
|
45
|
+
export interface RecordedNestedBundle {
|
|
46
|
+
/** Leaf under `Contents/PlugIns/` — `gjsify.ship.nestedBundles[].name`. */
|
|
47
|
+
name: string;
|
|
48
|
+
/** `CFBundleIdentifier` from the bundle's own `Info.plist`, when it has one. */
|
|
49
|
+
bundleId?: string;
|
|
50
|
+
/**
|
|
51
|
+
* `NSExtension.NSExtensionPointIdentifier` from `Info.plist`, when the
|
|
52
|
+
* bundle declares an extension point — `com.apple.Safari.web-extension` for
|
|
53
|
+
* a Safari Web Extension. Absent for a nested bundle that is not an
|
|
54
|
+
* extension at all.
|
|
55
|
+
*/
|
|
56
|
+
extensionPoint?: string;
|
|
57
|
+
}
|
|
30
58
|
export interface ReleaseRecordInput {
|
|
31
59
|
/** Absolute path of the finished artifact file. */
|
|
32
60
|
file: string;
|
|
@@ -36,6 +64,8 @@ export interface ReleaseRecordInput {
|
|
|
36
64
|
signature: RecordedSignature;
|
|
37
65
|
notarized: boolean;
|
|
38
66
|
stapled: boolean;
|
|
67
|
+
/** `[]` for an artifact with no nested bundle. */
|
|
68
|
+
nestedBundles: RecordedNestedBundle[];
|
|
39
69
|
generator: string;
|
|
40
70
|
}
|
|
41
71
|
export declare function releaseRecordPath(artifact: string): string;
|