@gjsify/cli 0.53.0 → 0.54.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 +263 -248
  2. package/lib/bundler-pick.d.ts +35 -0
  3. package/lib/bundler-pick.js +74 -7
  4. package/lib/cli-app.js +3 -1
  5. package/lib/commands/blueprint/index.d.ts +4 -0
  6. package/lib/commands/blueprint/index.js +15 -0
  7. package/lib/commands/blueprint/types.d.ts +8 -0
  8. package/lib/commands/blueprint/types.js +124 -0
  9. package/lib/commands/devtools.d.ts +21 -0
  10. package/lib/commands/devtools.js +146 -0
  11. package/lib/commands/index.d.ts +2 -0
  12. package/lib/commands/index.js +2 -0
  13. package/lib/commands/ship.js +51 -8
  14. package/lib/commands/webext/index.js +1 -1
  15. package/lib/utils/oxc-resolve.js +9 -7
  16. package/lib/utils/ship/app-runtime.d.ts +7 -5
  17. package/lib/utils/ship/app-runtime.js +11 -8
  18. package/lib/utils/ship/appimage.d.ts +9 -0
  19. package/lib/utils/ship/appimage.js +2 -1
  20. package/lib/utils/ship/deb.d.ts +10 -0
  21. package/lib/utils/ship/deb.js +7 -0
  22. package/lib/utils/ship/depends.d.ts +37 -3
  23. package/lib/utils/ship/depends.js +21 -9
  24. package/lib/utils/ship/gi-namespaces.d.ts +16 -0
  25. package/lib/utils/ship/gi-namespaces.js +64 -1
  26. package/lib/utils/ship/launcher.js +2 -2
  27. package/lib/utils/ship/layout.js +25 -2
  28. package/lib/utils/ship/rpm.d.ts +5 -0
  29. package/lib/utils/ship/rpm.js +42 -0
  30. package/lib/utils/ship/settings.js +6 -0
  31. package/lib/utils/ship/signing.d.ts +7 -2
  32. package/lib/utils/ship/signing.js +59 -32
  33. package/lib/utils/ship/stage-manifest.d.ts +14 -0
  34. package/lib/utils/ship/stage-manifest.js +7 -0
  35. package/lib/utils/webext/targets.d.ts +1 -1
  36. package/lib/utils/webext/targets.js +20 -8
  37. package/package.json +20 -19
@@ -45,7 +45,7 @@ import { compileSchemasForPayload, compileSchemasForStage } from '../utils/ship/
45
45
  import { buildMsi, MSI_PAYLOAD_DIR } from '../utils/ship/msi.js';
46
46
  import { buildZip, zipEntriesFromPayload } from '../utils/ship/zip.js';
47
47
  import { assertEntryRunsUnder } from '../utils/ship/entry-interpreter.js';
48
- import { scanGiNamespaces } from '../utils/ship/gi-namespaces.js';
48
+ import { scanGiRequirements } from '../utils/ship/gi-namespaces.js';
49
49
  import { assertLauncherMatchesInterpreter, assertPayloadMatchesArch, buildTimestamp, isArchIndependent, linuxInstallDependent, readPayloadFacts, } from '../utils/ship/payload.js';
50
50
  import { assertOverlayIsLicensed, planOverlay, planStage } from '../utils/ship/plan.js';
51
51
  import { assertHostCanSign, notarizeArtifact, stapleArtifact, resolveNotaryPlan, resolveSignPlan, signPayload, } from '../utils/ship/signing.js';
@@ -537,9 +537,20 @@ async function assemble(args) {
537
537
  // `gi://` specifiers are what the emitted `Depends:` is derived from
538
538
  // (ADR 0024 § 6), and the packing host has the staged copy but no way to
539
539
  // tell which staged file is the entry.
540
- const namespaces = scanGiNamespaces(bundleSource);
541
- if (args.verbose)
540
+ const gi = scanGiRequirements(bundleSource);
541
+ const namespaces = gi.namespaces;
542
+ const optionalNamespaces = gi.optional;
543
+ if (args.verbose) {
544
+ const hard = namespaces.filter((ns) => !optionalNamespaces.includes(ns));
542
545
  console.log(`${LOG} gi namespaces: ${namespaces.join(', ') || '(none)'}`);
546
+ // The split is worth its own line whenever it is non-empty: an author who
547
+ // wrote `&optional` and sees the namespace under the plain list has learned
548
+ // nothing about whether their package will declare it.
549
+ if (optionalNamespaces.length > 0) {
550
+ console.log(`${LOG} gi optional namespaces: ${optionalNamespaces.join(', ')}`);
551
+ console.log(`${LOG} gi required namespaces: ${hard.join(', ') || '(none)'}`);
552
+ }
553
+ }
543
554
  // PLACED, like the payload, and through the same map. `planOverlay` answers a
544
555
  // question about the FORMAT (`share/doc/<pkg>/copyright` for Debian policy,
545
556
  // `share/licenses/<pkg>/LICENSE` for rpm) in the prefix-relative shape every
@@ -578,8 +589,9 @@ async function assemble(args) {
578
589
  // into a Debian `Depends:`, at exit 0).
579
590
  if (format.depends === null)
580
591
  continue;
581
- deriveDepends(format.depends, {
592
+ const derived = deriveDepends(format.depends, {
582
593
  namespaces,
594
+ optionalNamespaces,
583
595
  hasIcons: facts.hasIcons,
584
596
  hasSchemas: facts.hasSchemas,
585
597
  interpreter: settings.app,
@@ -589,6 +601,17 @@ async function assemble(args) {
589
601
  minGjsVersion: settings.minGjsVersion,
590
602
  minNodeVersion: settings.minNodeVersion,
591
603
  });
604
+ // Reported here and not inside `deriveDepends`, which is also called by
605
+ // `packOne` on a host that has no project — and this is the only place that
606
+ // does. An optional namespace nobody mapped produces NO package field, and
607
+ // the app runs without the typelib either way, so nothing downstream will
608
+ // ever say so again.
609
+ for (const namespace of derived.unmappedOptional) {
610
+ console.warn(`${LOG} gi://${namespace} is imported optionally, but no ${format.depends} package is known ` +
611
+ 'to ship its typelib, so no Recommends entry names it. The package builds and runs ' +
612
+ `without it. Add the row if the namespace should be recommended: \`gjsify.ship.typelibPackages.` +
613
+ `${namespace} = { "${format.depends}": "…" }\`.`);
614
+ }
592
615
  // One warning per package, for the interpreter it actually declares. A
593
616
  // floor warning about a dependency this package does not emit is noise,
594
617
  // and a noisy warning is the one nobody reads when it matters.
@@ -602,7 +625,16 @@ async function assemble(args) {
602
625
  // is sometimes packable elsewhere and sometimes not is a worse contract
603
626
  // than one that always is, and it is what lets the e2e suite compare the
604
627
  // one-process artifact with the two-phase one.
605
- const manifest = writeStageManifest({ stageDir, settings, formats, mtime, namespaces, staged, overlay });
628
+ const manifest = writeStageManifest({
629
+ stageDir,
630
+ settings,
631
+ formats,
632
+ mtime,
633
+ namespaces,
634
+ optionalNamespaces,
635
+ staged,
636
+ overlay,
637
+ });
606
638
  if (args.stage) {
607
639
  // `(none)` spelled out, because an empty list printed as nothing after
608
640
  // "formats " reads as a truncated line rather than as a fact. WHICH fact
@@ -638,6 +670,7 @@ async function assemble(args) {
638
670
  stageDir,
639
671
  outRoot,
640
672
  namespaces,
673
+ optionalNamespaces,
641
674
  mtime,
642
675
  sign,
643
676
  notarize,
@@ -799,6 +832,9 @@ async function finishFromStage(args, fromStage) {
799
832
  for (const file of manifest.staged)
800
833
  console.log(`${LOG} ${file.path}`);
801
834
  console.log(`${LOG} gi namespaces: ${manifest.namespaces.join(', ') || '(none)'}`);
835
+ if ((manifest.optionalNamespaces ?? []).length > 0) {
836
+ console.log(`${LOG} gi optional namespaces: ${manifest.optionalNamespaces?.join(', ')}`);
837
+ }
802
838
  }
803
839
  // The stage's own directory is not written to — `writeStage` wipes what it
804
840
  // is pointed at, and pointing it at an arriving stage would delete the
@@ -832,6 +868,7 @@ async function finishFromStage(args, fromStage) {
832
868
  stageDir,
833
869
  outRoot,
834
870
  namespaces: manifest.namespaces,
871
+ optionalNamespaces: manifest.optionalNamespaces ?? [],
835
872
  mtime,
836
873
  sign,
837
874
  notarize,
@@ -858,10 +895,11 @@ async function packOne(input) {
858
895
  // this answers used to be answered from the project's file lists, which are
859
896
  // absolute paths on the build host and therefore unavailable here.
860
897
  const facts = readPayloadFacts(assembled);
861
- const depends = format.depends === null
862
- ? []
898
+ const derived = format.depends === null
899
+ ? { requires: [], recommends: [], unmappedOptional: [] }
863
900
  : deriveDepends(format.depends, {
864
901
  namespaces: input.namespaces,
902
+ optionalNamespaces: input.optionalNamespaces,
865
903
  hasIcons: facts.hasIcons,
866
904
  hasSchemas: facts.hasSchemas,
867
905
  interpreter: settings.app,
@@ -871,6 +909,8 @@ async function packOne(input) {
871
909
  minGjsVersion: settings.minGjsVersion,
872
910
  minNodeVersion: settings.minNodeVersion,
873
911
  });
912
+ const depends = derived.requires;
913
+ const recommends = derived.recommends;
874
914
  // `--arch` is a CLAIM about the payload and nothing else compared it to one:
875
915
  // an x86-64 `.so` staged with `--arch arm64` packed at exit 0 and `rpm -qp
876
916
  // --qf %{ARCH}` confirmed `aarch64`, because rpm reads the header and the
@@ -956,7 +996,9 @@ async function packOne(input) {
956
996
  })
957
997
  : assembled;
958
998
  const archLabel = format.archName(input.arch, isArchIndependent(payload));
959
- const common = { settings, payload, prefix: format.prefix, depends, archLabel, mtime };
999
+ // `recommends` rides the shared input object, so a format that does not write the
1000
+ // field (every non-deb/rpm row) simply ignores an extra property it never reads.
1001
+ const common = { settings, payload, prefix: format.prefix, depends, recommends, archLabel, mtime };
960
1002
  const outDir = join(outRoot, 'out');
961
1003
  mkdirSync(outDir, { recursive: true });
962
1004
  const target = join(outDir, format.fileName(settings, archLabel));
@@ -1010,6 +1052,7 @@ async function packOne(input) {
1010
1052
  minGjsVersion: settings.minGjsVersion,
1011
1053
  minNodeVersion: settings.minNodeVersion,
1012
1054
  namespaces: input.namespaces,
1055
+ optionalNamespaces: input.optionalNamespaces,
1013
1056
  bundledTypelibs: facts.bundledTypelibs,
1014
1057
  });
1015
1058
  const appDir = appDirFor(outRoot);
@@ -5,7 +5,7 @@
5
5
  import { webextBuildCommand, webextDevCommand, webextZipCommand } from './subcommands.js';
6
6
  export const webextCommand = {
7
7
  command: 'webext <subcommand>',
8
- description: 'Browser extensions: build, zip and develop one WebExtension for Chrome, Edge, Firefox and Safari.',
8
+ description: 'Browser extensions: build, zip and develop one WebExtension for Chrome, Edge, Firefox, Opera and Safari.',
9
9
  builder: (yargs) => yargs
10
10
  .command(webextBuildCommand.command, webextBuildCommand.description, webextBuildCommand.builder, webextBuildCommand.handler)
11
11
  .command(webextZipCommand.command, webextZipCommand.description, webextZipCommand.builder, webextZipCommand.handler)
@@ -41,8 +41,8 @@
41
41
  // `GJSIFY_OXFMT=native|npm` is the explicit override (native throws when the
42
42
  // prebuild isn't loadable instead of silently switching engines).
43
43
  //
44
- // oxlint has NO native path yet: its JS-plugin host (the internal
45
- // `gjsify/register-class-order` rule wired via `.oxlintrc.json` jsPlugins)
44
+ // oxlint has NO native path yet: its JS-plugin host (the
45
+ // `@gjsify/oxlint-plugin-gjsify` rules wired via `.oxlintrc.json` jsPlugins)
46
46
  // lives in the Node launcher, so a native oxlint bridge could only run the
47
47
  // Rust rule subset. Lint stays Node-spawned until that trade-off is decided
48
48
  // (tracked in status/open-todos/README.md).
@@ -264,11 +264,13 @@ async function tryLoadNativeOxfmt() {
264
264
  // `bundler-pick.ts`'s `tryLoadNative()` gives — so a library that will
265
265
  // not load names itself instead of failing inside `runOxfmt()`.
266
266
  // Resolved and imported, not statically imported — the reasoning is
267
- // at the top of this file. By the time a formatter asks for the
268
- // native backend, utils' `lib/esm` is built. `./native-library`
269
- // rather than `./core`: `core` re-exports `main-loop`, whose
270
- // module-level singleton would then exist twice in a process that
271
- // already carries it inlined in the GJS bundle.
267
+ // at the top of this file. `./native-library` rather than `./core`:
268
+ // `core` re-exports `main-loop`, whose module-level singleton would
269
+ // then exist twice in a process that already carries it inlined in the
270
+ // GJS bundle. Off disk, so the walk follows the WORKSPACE and
271
+ // `@gjsify/utils build:esm` must precede this call — the same
272
+ // precondition `bundler-pick.ts` carries, held by rule 5 of
273
+ // `scripts/check-build-infra-order.mjs`.
272
274
  //
273
275
  // Its own `try` because the outer one cannot tell this apart from
274
276
  // "there is no native backend", and `_nativeOxfmtLoadError` is the
@@ -102,8 +102,8 @@ export interface ResolvedNodeGiPackage {
102
102
  * NOT the published tarball. `files` in node-gi's manifest also lists `src/`,
103
103
  * `binding.gyp` and `scripts/install.mjs` — the inputs to `node-gyp`, which a
104
104
  * `.app` never runs — and the `.d.ts` declarations, which nothing inside a bundle
105
- * compiles against. And it lists `prebuilds/`, which this module stages into
106
- * `Contents/Frameworks` instead, where a `.app` keeps loadable code.
105
+ * compiles against. And it lists `prebuilds/`, which this module stages under
106
+ * `Layout.dirs().native`, the directory a `.app` keeps loadable code in.
107
107
  *
108
108
  * So the rule is what `require('@gjsify/node-gi/<subpath>')` can REACH:
109
109
  * `package.json` (the `exports` map is what resolves every subpath), every `.js`
@@ -120,15 +120,17 @@ export declare function resolveNodeGiPackage(options?: ResolveRuntimeOptions): R
120
120
  * literals, so the paths the launcher exports and the paths the stager writes
121
121
  * cannot drift: they are computed here once and both callers read this result.
122
122
  *
123
- * `Contents/Frameworks/node-gi/prebuilds/<target>/` is node-gi's OWN sibling
124
- * layout reproduced verbatim inside `dirs.native`. It is not a naming preference:
123
+ * `<dirs.native>/node-gi/prebuilds/<target>/` is node-gi's OWN sibling layout
124
+ * reproduced verbatim inside `Layout.dirs().native` (`Contents/Resources/native`
125
+ * on a `.app` — `layout.ts`'s darwin row carries why that is not
126
+ * `Contents/Frameworks`). It is not a naming preference:
125
127
  * `resolveGtkRuntimeBundle()` probes `<dir>/lib` and `<dir>/girepository-1.0`,
126
128
  * the addon's `@rpath` is `@loader_path/gtk/lib`, and `loaders.cache` addresses
127
129
  * each pixbuf loader relative to the bundle toplevel. Reproducing the shape is
128
130
  * what makes every one of those relations still true after the copy.
129
131
  */
130
132
  export interface AppRuntimePaths {
131
- /** `<App>.app/Contents/Frameworks/node-gi` — the package root the layout imitates. */
133
+ /** `<App>.app/Contents/Resources/native/node-gi` — the package root the layout imitates. */
132
134
  nodeGiRoot: string;
133
135
  /**
134
136
  * `<App>.app/Contents/Resources/lib/node_modules/@gjsify/node-gi` — where
@@ -47,11 +47,14 @@
47
47
  // THAT directory — so `prebuildAddonPath()` looks for
48
48
  // `…/node_modules/@gjsify/node-gi/prebuilds/<target>/node_gi.node` and
49
49
  // `resolveGtkRuntimeBundle()`'s candidate 2 for `…/prebuilds/<target>/gtk`, and
50
- // neither is where a `.app` may keep loadable code: `Contents/Frameworks` is, and
51
- // it is what `codesign` now reaches (ADR 0024 § A4) — M6 landed, and
52
- // `utils/ship/signing.ts` re-signs every Mach-O in the payload by magic number,
53
- // which is most of this closure. Putting it under `Resources` to save two
54
- // `export` lines would trade a correct bundle for a shorter launcher.
50
+ // neither is where a `.app` may keep loadable code: `Layout.dirs().native` is, and
51
+ // `utils/ship/signing.ts` re-signs every Mach-O in the payload by magic number
52
+ // (ADR 0024 § A4 — M6 landed), which is most of this closure. What `native` may NOT
53
+ // be is `Contents/Frameworks`: `codesign` reads a version-named DIRECTORY there as
54
+ // a nested bundle and refuses the seal, which is the whole reason this file's
55
+ // output went into `Contents/Resources/native` instead — see `layout.ts`'s darwin
56
+ // row for the measurement and `gjsify ship darwin --sign` for what it used to
57
+ // cost.
55
58
  //
56
59
  // WHY NOT `gjsify.ship.bundledTypelibs`. Because it FLATTENS. `plan.ts` stages
57
60
  // each such file as `posix.join(libDir, 'gi', basename(file))`, and
@@ -210,8 +213,8 @@ function resolveStagedPackage(specifier, options) {
210
213
  * NOT the published tarball. `files` in node-gi's manifest also lists `src/`,
211
214
  * `binding.gyp` and `scripts/install.mjs` — the inputs to `node-gyp`, which a
212
215
  * `.app` never runs — and the `.d.ts` declarations, which nothing inside a bundle
213
- * compiles against. And it lists `prebuilds/`, which this module stages into
214
- * `Contents/Frameworks` instead, where a `.app` keeps loadable code.
216
+ * compiles against. And it lists `prebuilds/`, which this module stages under
217
+ * `Layout.dirs().native`, the directory a `.app` keeps loadable code in.
215
218
  *
216
219
  * So the rule is what `require('@gjsify/node-gi/<subpath>')` can REACH:
217
220
  * `package.json` (the `exports` map is what resolves every subpath), every `.js`
@@ -252,7 +255,7 @@ export function appRuntimePaths(layout, identity, target) {
252
255
  addonPath: posix.join(prebuildDir, NODE_GI_ADDON_FILENAME),
253
256
  // `Contents/MacOS`, because that is where a `.app` keeps executables and
254
257
  // because `$here` — which the launcher already computes — is then the whole
255
- // path expression. `Contents/Frameworks` would work for dyld and would make
258
+ // path expression. `dirs.native` would work for dyld and would make
256
259
  // the launcher walk back up for the one file it execs. On Windows
257
260
  // `dirs.launcher` is the program directory itself, so the interpreter lands
258
261
  // beside the `.cmd` for the same reason.
@@ -89,6 +89,15 @@ export declare function appImageHostRequirements(input: {
89
89
  minGjsVersion?: string;
90
90
  minNodeVersion?: string;
91
91
  namespaces: readonly string[];
92
+ /**
93
+ * The subset of {@link namespaces} the bundle marks optional (ADR 0087).
94
+ *
95
+ * An AppImage has no `Depends:` field, so "optional" cannot become a package
96
+ * list here — it becomes WORDING instead. That is not cosmetic: an AppRun user
97
+ * told a typelib is required and lacking it goes looking for another distro
98
+ * package, while one told it is optional reads the same sentence correctly.
99
+ */
100
+ optionalNamespaces?: readonly string[];
92
101
  bundledTypelibs?: readonly string[];
93
102
  }): string[];
94
103
  /**
@@ -204,9 +204,10 @@ export function appDirFor(outRoot) {
204
204
  */
205
205
  export function appImageHostRequirements(input) {
206
206
  const floor = interpreterFloor(input);
207
+ const optional = new Set(input.optionalNamespaces ?? []);
207
208
  return [
208
209
  `${input.app} (>= ${floor})`,
209
- ...hostProvidedNamespaces(input.namespaces, input.bundledTypelibs).map((ns) => `the ${ns} typelib`),
210
+ ...hostProvidedNamespaces(input.namespaces, input.bundledTypelibs).map((ns) => optional.has(ns) ? `the ${ns} typelib (optional)` : `the ${ns} typelib`),
210
211
  ];
211
212
  }
212
213
  /**
@@ -8,6 +8,16 @@ export interface DebInputs {
8
8
  prefix: string;
9
9
  /** Already-derived dependency list, in `name`/`name >= version` form. */
10
10
  depends: readonly string[];
11
+ /**
12
+ * Soft dependencies, rendered as `Recommends:`.
13
+ *
14
+ * Debian Policy § 7.2: a recommended package is installed by default and
15
+ * `--no-install-recommends` is the user's way out. That is what an `&optional`
16
+ * typelib is (ADR 0087) — the app runs without it and loses a feature — so
17
+ * putting it in `Depends:` would let an optional integration decide whether
18
+ * the package installs at all.
19
+ */
20
+ recommends?: readonly string[];
11
21
  /** Debian architecture (`amd64`, `all`, …). */
12
22
  archLabel: string;
13
23
  /** Unix seconds stamped into every header. */
@@ -104,6 +104,13 @@ function renderControl(inputs, installedSize) {
104
104
  ];
105
105
  if (inputs.depends.length > 0)
106
106
  fields.push(['Depends', inputs.depends.map(formatDebDepend).join(', ')]);
107
+ // After `Depends`, which is the field order dpkg-deb --control writes and the
108
+ // one `grep '^Depends:'` on a control file assumes. Omitted when empty rather
109
+ // than written as `Recommends:` with nothing after it — a field with an empty
110
+ // value is a parse error here, the same reason `formatDebDepend` exists.
111
+ if (inputs.recommends !== undefined && inputs.recommends.length > 0) {
112
+ fields.push(['Recommends', inputs.recommends.map(formatDebDepend).join(', ')]);
113
+ }
107
114
  fields.push(['Section', settings.section], ['Priority', 'optional']);
108
115
  if (settings.homepage)
109
116
  fields.push(['Homepage', settings.homepage]);
@@ -43,6 +43,17 @@ export declare function warnAboutNodeFloor(format: DistroFormatId, floor: string
43
43
  export interface DependsInputs {
44
44
  /** GI namespaces the bundle imports, in `Ns-Version` spelling. */
45
45
  namespaces: readonly string[];
46
+ /**
47
+ * The subset of {@link namespaces} the bundle declares OPTIONAL (ADR 0087 —
48
+ * `gi://Ns?version=X&optional`): a `Recommends:` entry, never a `Depends:`.
49
+ *
50
+ * Kept as its own list rather than filtered out of {@link namespaces}, because
51
+ * the AppImage has no package list at all and still has to SAY which of its host
52
+ * requirements are optional — and because a stage manifest written before the
53
+ * split carries no such field, where answering "everything is hard" is the safe
54
+ * direction.
55
+ */
56
+ optionalNamespaces?: readonly string[];
46
57
  /**
47
58
  * Whether the payload installs into `share/icons/hicolor/`.
48
59
  *
@@ -112,11 +123,34 @@ export interface DependsInputs {
112
123
  */
113
124
  export declare function hostProvidedNamespaces(namespaces: readonly string[], bundledTypelibs?: readonly string[]): string[];
114
125
  /**
115
- * The `Depends:` / `Requires:` list for one format.
126
+ * One format's dependency lists, split by how hard the requirement is.
127
+ *
128
+ * `requires` is what an absent package makes the app fail over; `recommends` is what
129
+ * its absence only costs. A namespace the BUNDLE marked optional (ADR 0087) is the
130
+ * second kind by declaration — the app's own `check()` degrades — and both `.deb`
131
+ * and `.rpm` have a field for it, so there is no reason to declare it hard.
132
+ */
133
+ export interface DerivedDepends {
134
+ /** `Depends:` / `Requires:` — a package the artifact cannot run without. */
135
+ requires: string[];
136
+ /** `Recommends:` — installed by default where the format has such a field. */
137
+ recommends: string[];
138
+ /**
139
+ * Optional namespaces no table row is known for.
140
+ *
141
+ * REPORTED, never fatal: a `Recommends:` line for a namespace nobody mapped is
142
+ * the one case where saying nothing is correct — the app runs without the
143
+ * typelib either way, which is precisely what the flag declared. The HARD
144
+ * unmapped case still throws, below.
145
+ */
146
+ unmappedOptional: string[];
147
+ }
148
+ /**
149
+ * The `Depends:` / `Requires:` list for one format, plus its `Recommends:`.
116
150
  *
117
- * @throws when a namespace has no entry in the table — see the module header.
151
+ * @throws when a HARD namespace has no entry in the table — see the module header.
118
152
  */
119
- export declare function deriveDepends(format: DistroFormatId, inputs: DependsInputs): string[];
153
+ export declare function deriveDepends(format: DistroFormatId, inputs: DependsInputs): DerivedDepends;
120
154
  /** One parsed dependency. `relation`/`version` are absent for an unversioned one. */
121
155
  export interface ParsedDepend {
122
156
  name: string;
@@ -287,9 +287,9 @@ export function hostProvidedNamespaces(namespaces, bundledTypelibs = []) {
287
287
  return [...new Set(namespaces)].sort().filter((ns) => !BUNDLED_TYPELIB.test(ns) && !shipped.has(ns));
288
288
  }
289
289
  /**
290
- * The `Depends:` / `Requires:` list for one format.
290
+ * The `Depends:` / `Requires:` list for one format, plus its `Recommends:`.
291
291
  *
292
- * @throws when a namespace has no entry in the table — see the module header.
292
+ * @throws when a HARD namespace has no entry in the table — see the module header.
293
293
  */
294
294
  export function deriveDepends(format, inputs) {
295
295
  // The interpreter the launcher execs, and nothing else: a package declares ONE.
@@ -297,17 +297,25 @@ export function deriveDepends(format, inputs) {
297
297
  // no system Node, so an artifact for those CARRIES one from
298
298
  // `@gjsify/node-runtime-<target>`, and that is the whole reason those three
299
299
  // packages exist and this one line is their opposite.
300
- const out = inputs.interpreter === 'node'
300
+ const requires = inputs.interpreter === 'node'
301
301
  ? [`${NODE_PACKAGE[format]} >= ${inputs.minNodeVersion ?? DEFAULT_NODE_FLOOR}`]
302
302
  : [`gjs >= ${inputs.minGjsVersion ?? DEFAULT_GJS_FLOOR}`];
303
+ const recommends = [];
304
+ const optional = new Set(inputs.optionalNamespaces ?? []);
303
305
  const unmapped = [];
306
+ const unmappedOptional = [];
304
307
  for (const namespace of hostProvidedNamespaces(inputs.namespaces, inputs.bundledTypelibs)) {
305
308
  const entry = lookupTypelib(namespace, inputs.typelibPackages);
306
309
  if (entry === undefined) {
307
- unmapped.push(namespace);
310
+ (optional.has(namespace) ? unmappedOptional : unmapped).push(namespace);
308
311
  continue;
309
312
  }
310
- out.push(entry[format]);
313
+ // A namespace imported both ways is a HARD dependency — one importer asked
314
+ // for it unguarded — and a package manager field may not name it twice.
315
+ if (optional.has(namespace) && !requires.includes(entry[format]))
316
+ recommends.push(entry[format]);
317
+ else
318
+ requires.push(entry[format]);
311
319
  }
312
320
  if (unmapped.length > 0) {
313
321
  throw new Error(`gjsify ship: the bundle imports ${unmapped.map((n) => `gi://${n.split('-')[0]}`).join(', ')}, ` +
@@ -323,7 +331,7 @@ export function deriveDepends(format, inputs) {
323
331
  // The package that owns `/usr/share/icons/hicolor` — so this follows the icons, not the
324
332
  // app/cli distinction that used to stand in for them.
325
333
  if (inputs.hasIcons)
326
- out.push('hicolor-icon-theme');
334
+ requires.push('hicolor-icon-theme');
327
335
  // The package that ships `glib-compile-schemas`, NOT `gsettings-desktop-schemas`
328
336
  // (which ships GNOME's own `org.gnome.desktop.*` schemas and has nothing to
329
337
  // do with compiling ours). Measured: `rpm -qf /usr/bin/glib-compile-schemas`
@@ -333,11 +341,15 @@ export function deriveDepends(format, inputs) {
333
341
  // `Gio.Settings.new()` aborts the app — an install that succeeds and an app
334
342
  // that does not start.
335
343
  if (inputs.hasSchemas)
336
- out.push(SCHEMA_COMPILER_PACKAGE[format]);
337
- out.push(...inputs.extra);
344
+ requires.push(SCHEMA_COMPILER_PACKAGE[format]);
345
+ requires.push(...inputs.extra);
338
346
  // Set-dedupe keeps first-insertion order, so the interpreter stays first and
339
347
  // the list is stable across runs.
340
- return [...new Set(out)];
348
+ return {
349
+ requires: [...new Set(requires)],
350
+ recommends: [...new Set(recommends)].filter((name) => !requires.includes(name)),
351
+ unmappedOptional,
352
+ };
341
353
  }
342
354
  /**
343
355
  * Resolve a scanned specifier to a table row.
@@ -1,7 +1,23 @@
1
+ /**
2
+ * What a bundle loads, split by whether the host must provide it.
3
+ *
4
+ * `namespaces` is the union — the same answer {@link scanGiNamespaces} has always
5
+ * given — and `optional` is the subset the artifact itself marks optional. A namespace
6
+ * in both is still a hard dependency as far as anything else is concerned: the
7
+ * importer asked for it both ways, and only one of those edges degrades.
8
+ */
9
+ export interface GiRequirements {
10
+ /** Every GI namespace the bundle loads, `Ns-Version` where a specifier pins one. */
11
+ namespaces: string[];
12
+ /** The subset carrying the bundler's optional marker — absent is not fatal. */
13
+ optional: string[];
14
+ }
1
15
  /**
2
16
  * Extract the GI namespaces a bundle imports, as `Ns-Version` when the
3
17
  * specifier pins one and bare `Ns` when it does not.
4
18
  */
5
19
  export declare function scanGiNamespaces(source: string): string[];
20
+ /** {@link scanGiNamespaces}, plus which of them the artifact marks optional (ADR 0087). */
21
+ export declare function scanGiRequirements(source: string): GiRequirements;
6
22
  /** `gi://Gtk?version=4.0` → `Gtk-4.0`; `gi://Gtk` → `Gtk`; anything else → null. */
7
23
  export declare function parseGiSpecifier(specifier: string): string | null;
@@ -34,6 +34,18 @@
34
34
  // `walkModuleAst` answers both questions in ONE pass, and it is the same acorn
35
35
  // pass the CLI already uses to compute its own runtime closure — so there is one
36
36
  // definition of "what does this file import" rather than two.
37
+ //
38
+ // HARD OR OPTIONAL, since ADR 0087: a `gi://Ns?version=X&optional` import still
39
+ // appears in the emitted file, so this scanner still reports its namespace — and
40
+ // `depends.ts` has to know it may be absent. The discriminator is the marker
41
+ // statement the bundler's own optional shim emits (`GI_OPTIONAL_MARKER`, imported
42
+ // from the plugin that writes it rather than restated here). NOT the `import()`
43
+ // SHAPE, which was the tempting reading of the same file: an app that writes its own
44
+ // `await import('gi://Goa')` without the flag must stay a HARD dependency, and
45
+ // classifying it as optional would move a package the host must have out of its
46
+ // `Depends:` — the failure this whole chain exists to prevent, reached from the other
47
+ // direction.
48
+ import { GI_OPTIONAL_MARKER } from '@gjsify/rolldown-plugin-gjsify';
37
49
  import { importedSpecifier, staticStringValue, walkModuleAst, } from '../cli-runtime-closure.js';
38
50
  /** The module a `--app node` bundle reaches GI through. */
39
51
  const NODE_GI_MODULE = '@gjsify/node-gi/gi';
@@ -44,7 +56,12 @@ const NAMESPACE = /^[A-Za-z][A-Za-z\d_]*$/;
44
56
  * specifier pins one and bare `Ns` when it does not.
45
57
  */
46
58
  export function scanGiNamespaces(source) {
59
+ return scanGiRequirements(source).namespaces;
60
+ }
61
+ /** {@link scanGiNamespaces}, plus which of them the artifact marks optional (ADR 0087). */
62
+ export function scanGiRequirements(source) {
47
63
  const found = new Set();
64
+ const optional = new Set();
48
65
  // TWO SETS, because the two kinds of binding are not interchangeable and
49
66
  // treating them as one over-approximated on a real minified bundle. A
50
67
  // namespace object (`import * as gi`) and a required module object are only
@@ -89,11 +106,57 @@ export function scanGiNamespaces(source) {
89
106
  calls.push(node);
90
107
  });
91
108
  for (const call of calls) {
109
+ const marker = optionalMarkerNamespace(call);
110
+ if (marker !== null) {
111
+ optional.add(marker);
112
+ continue;
113
+ }
92
114
  const key = requireGiNamespace(call, callable, objects);
93
115
  if (key !== null)
94
116
  found.add(key);
95
117
  }
96
- return [...found].sort();
118
+ return { namespaces: [...found].sort(), optional: [...optional].filter((ns) => found.has(ns)).sort() };
119
+ }
120
+ /**
121
+ * The namespace an optional-marker call names, or `null` when this is not one.
122
+ *
123
+ * The shape is the bundler's: `globalThis[Symbol.for(GI_OPTIONAL_MARKER)]?.("Ns", "X")`,
124
+ * matched on BOTH halves rather than on the whole statement — the minifier rewrites
125
+ * the string literals to backticks and the optional call to a `ChainExpression`
126
+ * around the `CallExpression` this walk still reaches, and a plain call (a host that
127
+ * has a handler the minifier could prove is non-null) reads the same way.
128
+ */
129
+ function optionalMarkerNamespace(call) {
130
+ const callee = call.callee;
131
+ if (callee?.type !== 'MemberExpression' || callee.computed !== true)
132
+ return null;
133
+ // `globalThis[Symbol.for(KEY)]` — a computed member whose PROPERTY is the
134
+ // `Symbol.for` call, so the key is an argument of that inner call rather than
135
+ // of the outer one. Verified against acorn's own shape: `optional` chaining
136
+ // wraps the whole thing in a `ChainExpression`, which the walk descends into,
137
+ // so the `CallExpression` reaches this function either way.
138
+ const keyCall = callee.property;
139
+ if (keyCall?.type !== 'CallExpression')
140
+ return null;
141
+ const keyCallee = keyCall.callee;
142
+ const keyObject = keyCallee?.type === 'MemberExpression' ? keyCallee.object : undefined;
143
+ const keyProperty = keyCallee?.type === 'MemberExpression' ? keyCallee.property : undefined;
144
+ if (keyObject?.type !== 'Identifier' || keyObject.name !== 'Symbol')
145
+ return null;
146
+ if (keyProperty?.type !== 'Identifier' || keyProperty.name !== 'for')
147
+ return null;
148
+ const keyArgs = keyCall.arguments ?? [];
149
+ if (staticStringValue(keyArgs[0]) !== GI_OPTIONAL_MARKER)
150
+ return null;
151
+ const host = callee.object;
152
+ if (host?.type !== 'Identifier' || host.name !== 'globalThis')
153
+ return null;
154
+ const args = call.arguments ?? [];
155
+ const namespace = staticStringValue(args[0]);
156
+ if (namespace === null || !NAMESPACE.test(namespace))
157
+ return null;
158
+ const version = staticStringValue(args[1]);
159
+ return version === null || version === '' ? namespace : `${namespace}-${version}`;
97
160
  }
98
161
  /** `gi://Gtk?version=4.0` → `Gtk-4.0`; `gi://Gtk` → `Gtk`; anything else → null. */
99
162
  export function parseGiSpecifier(specifier) {
@@ -171,7 +171,7 @@ function renderPrefixLauncher(settings, bundleRelPath, layout) {
171
171
  * IT EXECS WHAT THE BUNDLE CARRIES. `runtime` comes from
172
172
  * `utils/ship/app-runtime.ts` and names the interpreter, the relocated GTK
173
173
  * closure and the node-gi addon staged inside `Contents/` — so the exec line is
174
- * `"$here/node"` and the two locators below point into `Contents/Frameworks`.
174
+ * `"$here/node"` and the two locators below point into `dirs.native`.
175
175
  * When the stage carries none of them every line here is byte-identical to what
176
176
  * M2a wrote, which is what keeps a bundle assembled without the runtime packages
177
177
  * exactly as (un)usable as it was rather than differently broken.
@@ -261,7 +261,7 @@ function renderAppBundleLauncher(settings, bundleRelPath, layout, runtime) {
261
261
  // `node_modules`, and a shipped `.app` has neither: its JavaScript is one
262
262
  // bundled file under `Contents/Resources/lib`, so `import.meta.url` there
263
263
  // resolves node-gi's "package root" to the bundle's directory and every probed
264
- // path lands beside the bundle instead of in `Contents/Frameworks`.
264
+ // path lands beside the bundle instead of in `dirs.native`.
265
265
  //
266
266
  // `NODE_GI_NATIVE` is the same problem for the addon: `prebuildAddonPath()`
267
267
  // joins `prebuilds/<target>/node_gi.node` onto that same wrong root
@@ -7,7 +7,8 @@
7
7
  // is the first layout that is NOT a prefix substitution: the launcher, the bundle,
8
8
  // the shared libraries and the data tree each move somewhere different, and on
9
9
  // macOS the native files leave the bundle directory altogether for
10
- // `Contents/Frameworks`.
10
+ // `Contents/Resources/native` — a path Apple's own layout does not have, for the
11
+ // measured reason {@link layoutForOs}'s darwin row carries.
11
12
  //
12
13
  // So the layout is a MAP from the prefix-relative plan to a stage-relative path,
13
14
  // and `planStage` keeps producing the prefix-relative plan for all three. That
@@ -168,12 +169,34 @@ export const LAYOUTS = {
168
169
  // everything that is not, and `Contents/Frameworks` the dylibs — which is
169
170
  // the split that makes this more than a prefix: the `gi/` directory that
170
171
  // sits INSIDE `lib/<name>/` on Linux leaves the bundle directory entirely.
172
+ //
173
+ // …AND THE NATIVE DIRECTORY IS NOT APPLE'S, because `Contents/Frameworks`
174
+ // is RESERVED FOR NESTED CODE and one directory of it is loadable code
175
+ // that cannot be there. Measured on darwin-arm64 / macOS 27 with the
176
+ // published `@gjsify/gtk-runtime-darwin-arm64` staged by M2b: `codesign`
177
+ // refuses the BUNDLE SEAL with *"bundle format unrecognized, invalid, or
178
+ // unsuitable / In subcomponent: …/gtk/lib/gstreamer-1.0"*, so
179
+ // `gjsify ship darwin --sign` cannot sign a real GTK application at all —
180
+ // and it does so at exit 1 with the payload half-signed. The trigger is not
181
+ // gstreamer: it is any directory under `Contents/Frameworks` whose last
182
+ // dot-separated component parses as a version, because `codesign` reads
183
+ // `foo-1.0`, `foo1.0` and `Versions/1.0` as a VERSIONED NESTED BUNDLE
184
+ // (`<name>-<version>` is a framework's shape) and then finds no
185
+ // `Contents/Info.plist` inside. The relocated closure carries three:
186
+ // `girepository-1.0`, `gdk-pixbuf-2.0/2.10.0` and `lib/gstreamer-1.0`.
187
+ //
188
+ // The same three under `Contents/Resources` seal and verify at exit 0, which
189
+ // is the whole measurement. So `native` is the ONE seam that moves: the
190
+ // relocated closure, the addon, the app's own `gi/` dylibs and both launchers'
191
+ // `GI_TYPELIB_PATH`/`GJSIFY_GI_LIBRARY_PATH` all read it, so the tree and
192
+ // every path that names it move together. Library validation is unaffected —
193
+ // it follows the SIGNATURE (§ `signing.ts`'s darwin row), not the directory.
171
194
  dirs: (identity) => {
172
195
  const app = appBundleDir(identity);
173
196
  return {
174
197
  launcher: `${app}/Contents/MacOS`,
175
198
  bundle: `${app}/Contents/Resources/lib`,
176
- native: `${app}/Contents/Frameworks`,
199
+ native: `${app}/Contents/Resources/native`,
177
200
  data: `${app}/Contents/Resources/share`,
178
201
  other: `${app}/Contents/Resources`,
179
202
  };