@gjsify/cli 0.52.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 (107) hide show
  1. package/dist/cli.gjs.mjs +351 -305
  2. package/lib/actions/build.d.ts +22 -0
  3. package/lib/actions/build.js +37 -9
  4. package/lib/bundler-pick.d.ts +72 -1
  5. package/lib/bundler-pick.js +175 -17
  6. package/lib/cli-app.js +7 -2
  7. package/lib/commands/blueprint/index.d.ts +4 -0
  8. package/lib/commands/blueprint/index.js +15 -0
  9. package/lib/commands/blueprint/types.d.ts +8 -0
  10. package/lib/commands/blueprint/types.js +124 -0
  11. package/lib/commands/devtools.d.ts +21 -0
  12. package/lib/commands/devtools.js +146 -0
  13. package/lib/commands/env.d.ts +31 -0
  14. package/lib/commands/env.js +114 -0
  15. package/lib/commands/exec.d.ts +10 -0
  16. package/lib/commands/exec.js +208 -0
  17. package/lib/commands/foreach.js +5 -97
  18. package/lib/commands/format.js +26 -6
  19. package/lib/commands/index.d.ts +5 -0
  20. package/lib/commands/index.js +5 -0
  21. package/lib/commands/install.d.ts +2 -2
  22. package/lib/commands/install.js +5 -5
  23. package/lib/commands/self-update.js +2 -1
  24. package/lib/commands/ship.js +51 -8
  25. package/lib/commands/test.d.ts +18 -9
  26. package/lib/commands/test.js +56 -11
  27. package/lib/commands/tsc.js +4 -4
  28. package/lib/commands/upgrade.d.ts +15 -0
  29. package/lib/commands/upgrade.js +76 -2
  30. package/lib/commands/webext/index.d.ts +2 -0
  31. package/lib/commands/webext/index.js +15 -0
  32. package/lib/commands/webext/subcommands.d.ts +16 -0
  33. package/lib/commands/webext/subcommands.js +204 -0
  34. package/lib/types/config-data.d.ts +1 -1
  35. package/lib/utils/bin-shim.d.ts +8 -2
  36. package/lib/utils/bin-shim.js +6 -5
  37. package/lib/utils/bundle-inputs.d.ts +84 -0
  38. package/lib/utils/bundle-inputs.js +268 -0
  39. package/lib/utils/check-system-deps.d.ts +4 -2
  40. package/lib/utils/check-system-deps.js +21 -26
  41. package/lib/utils/dev-link.d.ts +9 -6
  42. package/lib/utils/dev-link.js +71 -19
  43. package/lib/utils/exec-bin.d.ts +137 -0
  44. package/lib/utils/exec-bin.js +321 -0
  45. package/lib/utils/gi-runtime-paths.js +3 -2
  46. package/lib/utils/gitignore.d.ts +15 -0
  47. package/lib/utils/gitignore.js +178 -0
  48. package/lib/utils/gjsify-shim.d.ts +47 -0
  49. package/lib/utils/gjsify-shim.js +97 -8
  50. package/lib/utils/install-backend-native.d.ts +19 -1
  51. package/lib/utils/install-backend-native.js +93 -7
  52. package/lib/utils/install-lock.js +4 -25
  53. package/lib/utils/native-skip-scan.d.ts +20 -0
  54. package/lib/utils/native-skip-scan.js +343 -0
  55. package/lib/utils/node-script.js +1 -1
  56. package/lib/utils/oxc-resolve.js +46 -5
  57. package/lib/utils/process-table.d.ts +22 -0
  58. package/lib/utils/process-table.js +188 -0
  59. package/lib/utils/prune-prefix.js +1 -1
  60. package/lib/utils/run-gjs.d.ts +15 -1
  61. package/lib/utils/run-gjs.js +16 -7
  62. package/lib/utils/run-node.d.ts +2 -0
  63. package/lib/utils/run-node.js +2 -1
  64. package/lib/utils/runtimes.js +14 -13
  65. package/lib/utils/ship/app-runtime.d.ts +7 -5
  66. package/lib/utils/ship/app-runtime.js +11 -8
  67. package/lib/utils/ship/appimage.d.ts +9 -0
  68. package/lib/utils/ship/appimage.js +2 -1
  69. package/lib/utils/ship/deb.d.ts +10 -0
  70. package/lib/utils/ship/deb.js +7 -0
  71. package/lib/utils/ship/depends.d.ts +37 -3
  72. package/lib/utils/ship/depends.js +21 -9
  73. package/lib/utils/ship/gi-namespaces.d.ts +16 -0
  74. package/lib/utils/ship/gi-namespaces.js +64 -1
  75. package/lib/utils/ship/gzip.js +1 -1
  76. package/lib/utils/ship/icons.d.ts +6 -0
  77. package/lib/utils/ship/icons.js +6 -5
  78. package/lib/utils/ship/launcher.js +2 -2
  79. package/lib/utils/ship/layout.js +25 -2
  80. package/lib/utils/ship/rpm.d.ts +5 -0
  81. package/lib/utils/ship/rpm.js +42 -0
  82. package/lib/utils/ship/settings.js +6 -0
  83. package/lib/utils/ship/signing.d.ts +7 -2
  84. package/lib/utils/ship/signing.js +59 -32
  85. package/lib/utils/ship/stage-manifest.d.ts +14 -0
  86. package/lib/utils/ship/stage-manifest.js +7 -0
  87. package/lib/utils/spawn.d.ts +13 -0
  88. package/lib/utils/spawn.js +10 -5
  89. package/lib/utils/watch-loop.d.ts +15 -1
  90. package/lib/utils/watch-loop.js +9 -5
  91. package/lib/utils/webext/build.d.ts +42 -0
  92. package/lib/utils/webext/build.js +120 -0
  93. package/lib/utils/webext/config.d.ts +72 -0
  94. package/lib/utils/webext/config.js +234 -0
  95. package/lib/utils/webext/icons.d.ts +24 -0
  96. package/lib/utils/webext/icons.js +83 -0
  97. package/lib/utils/webext/launch.d.ts +32 -0
  98. package/lib/utils/webext/launch.js +77 -0
  99. package/lib/utils/webext/manifest.d.ts +53 -0
  100. package/lib/utils/webext/manifest.js +219 -0
  101. package/lib/utils/webext/pages.d.ts +26 -0
  102. package/lib/utils/webext/pages.js +61 -0
  103. package/lib/utils/webext/targets.d.ts +13 -0
  104. package/lib/utils/webext/targets.js +48 -0
  105. package/lib/utils/win32-command.d.ts +17 -0
  106. package/lib/utils/win32-command.js +21 -6
  107. package/package.json +20 -19
@@ -6,7 +6,7 @@ import { type SpawnCompletionContract } from './spawn.js';
6
6
  * variable is set is host-dependent (`LD_LIBRARY_PATH` / `DYLD_LIBRARY_PATH` / `PATH`) — see
7
7
  * `buildNativeEnv`.
8
8
  */
9
- export declare function computeNativeEnvForBundle(bundlePath: string, cwd?: string, inherited?: Record<string, string | undefined>): {
9
+ export declare function computeNativeEnvForBundle(bundlePath: string, cwd?: string, inherited?: Record<string, string | undefined>, extraRoots?: readonly string[]): {
10
10
  env: NativeEnv;
11
11
  envPrefix: string;
12
12
  };
@@ -41,5 +41,19 @@ export interface RunGjsBundleOptions {
41
41
  * (`utils/node-script.ts`).
42
42
  */
43
43
  quiet?: boolean;
44
+ /**
45
+ * Do not add `gjs exited with code N` to a failing child's own output. For a
46
+ * transparent runner (`gjsify exec`) a non-zero exit is often an ANSWER — `semver
47
+ * -r '>2' 1.0.0` exits 1 to say "no match" — and the child's code is re-raised
48
+ * either way.
49
+ */
50
+ quietExit?: boolean;
51
+ /**
52
+ * More directories to look for native `@gjsify/*` prebuilds in, after the cwd and the
53
+ * bundle's own tree. `gjsify exec` passes the running CLI's: a rebuilt bin takes its
54
+ * polyfills from beside the CLI, so their typelibs live there too — web-ext's rebuild
55
+ * died at load with `Typelib file for namespace 'GjsifyHttpSoupBridge' … not found`.
56
+ */
57
+ nativeRoots?: readonly string[];
44
58
  }
45
59
  export declare function runGjsBundle(bundlePath: string, extraArgs: string[] | undefined, options: RunGjsBundleOptions): Promise<void>;
@@ -21,12 +21,20 @@ import { captureNativeBacktrace, createHangWatchdog, formatHangReport, hangGrace
21
21
  * variable is set is host-dependent (`LD_LIBRARY_PATH` / `DYLD_LIBRARY_PATH` / `PATH`) — see
22
22
  * `buildNativeEnv`.
23
23
  */
24
- export function computeNativeEnvForBundle(bundlePath, cwd = process.cwd(), inherited = process.env) {
24
+ export function computeNativeEnvForBundle(bundlePath, cwd = process.cwd(), inherited = process.env, extraRoots = []) {
25
25
  const resolvedBundle = resolve(bundlePath);
26
- const cwdPackages = detectNativePackages(cwd);
27
- const bundlePackages = detectNativePackages(dirname(resolvedBundle));
28
- const seen = new Set(cwdPackages.map((p) => p.name));
29
- const nativePackages = [...cwdPackages, ...bundlePackages.filter((p) => !seen.has(p.name))];
26
+ // First root wins per package name: the project, then the bundle's own tree, then
27
+ // whatever the caller adds (see `RunGjsBundleOptions.nativeRoots`).
28
+ const nativePackages = [];
29
+ const seen = new Set();
30
+ for (const root of [cwd, dirname(resolvedBundle), ...extraRoots]) {
31
+ for (const pkg of detectNativePackages(root)) {
32
+ if (seen.has(pkg.name))
33
+ continue;
34
+ seen.add(pkg.name);
35
+ nativePackages.push(pkg);
36
+ }
37
+ }
30
38
  // `inherited` feeds BOTH halves — composition and comparison. Split them and the win32
31
39
  // branch disagrees with itself: `buildNativeEnv` writes the library variable back under
32
40
  // the host's own spelling (`Path` in a stock env block), so a comparison keyed on the
@@ -58,7 +66,7 @@ function safeMkdtemp() {
58
66
  }
59
67
  }
60
68
  export async function runGjsBundle(bundlePath, extraArgs = [], options) {
61
- const { env: nativeEnv, envPrefix } = computeNativeEnvForBundle(bundlePath);
69
+ const { env: nativeEnv, envPrefix } = computeNativeEnvForBundle(bundlePath, process.cwd(), process.env, options.nativeRoots);
62
70
  // The breadcrumb `@gjsify/unit` writes what it is running into, and this process polls —
63
71
  // the only way a bundle that has stopped turning its main loop can still say which test it
64
72
  // died in. Anything that is not a unit run never writes the file, so the watchdog stays
@@ -149,7 +157,8 @@ export async function runGjsBundle(bundlePath, extraArgs = [], options) {
149
157
  if (hung)
150
158
  return failed(result.code ?? 1);
151
159
  if (result.code !== 0) {
152
- console.error(`gjs exited with ${describeExit(result)}`);
160
+ if (!options.quietExit)
161
+ console.error(`gjs exited with ${describeExit(result)}`);
153
162
  return failed(result.code ?? 1);
154
163
  }
155
164
  // See RunGjsBundleOptions.exitOnSuccess — terminal callers opt in,
@@ -26,6 +26,8 @@ export interface RunNodeBundleOptions {
26
26
  exitOnSuccess?: boolean;
27
27
  /** Suppress the `$ <env> node …` echo — see `RunGjsBundleOptions.quiet`. */
28
28
  quiet?: boolean;
29
+ /** Leave a failing child's output alone — see `RunGjsBundleOptions.quietExit`. */
30
+ quietExit?: boolean;
29
31
  }
30
32
  /**
31
33
  * Run a `--app node` bundle on Node.js with the native typelib env set (same detection as
@@ -148,7 +148,8 @@ export async function runRuntimeBundle(runtime, bundlePath, extraArgs = [], opti
148
148
  return failed(1);
149
149
  }
150
150
  if (result.code !== 0) {
151
- console.error(`${runtime} exited with ${describeExit(result)}`);
151
+ if (!options.quietExit)
152
+ console.error(`${runtime} exited with ${describeExit(result)}`);
152
153
  return failed(result.code ?? 1);
153
154
  }
154
155
  if (options.exitOnSuccess)
@@ -6,13 +6,14 @@
6
6
  // runtime→build-target mapping and `availableRuntimes()` live in ONE place instead
7
7
  // of a per-example copy.
8
8
  //
9
- // Intentionally PURE (only `node:child_process` and the side-effect-free
10
- // `@gjsify/rolldown-plugin-gjsify/runtime` host detector, no `gi://` or native
11
- // imports) so an external example can deep-import it from the installed CLI
12
- // (`@gjsify/cli/lib/utils/runtimes.js`) without pulling in the whole bundler — the
13
- // same deep-import precedent as `@gjsify/cli/lib/utils/run-gjs.js`.
14
- import { execFileSync } from 'node:child_process';
9
+ // Intentionally PURE (only `node:child_process`, `./spawn.js`'s Windows command
10
+ // rewrite and the side-effect-free `@gjsify/rolldown-plugin-gjsify/runtime` host
11
+ // detector, no `gi://` or native imports) so an external example can deep-import
12
+ // it from the installed CLI (`@gjsify/cli/lib/utils/runtimes.js`) without pulling
13
+ // in the whole bundler — the same deep-import precedent as `@gjsify/cli/lib/utils/run-gjs.js`.
14
+ import { spawnSync } from 'node:child_process';
15
15
  import { hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
16
+ import { toSpawnable } from './spawn.js';
16
17
  /** All runtimes, in canonical order (gjs first — it is the reference). */
17
18
  export const EXAMPLE_RUNTIMES = ['gjs', 'node', 'bun', 'deno'];
18
19
  /**
@@ -57,13 +58,13 @@ export function requiresGjsSystemDeps(runtime) {
57
58
  }
58
59
  /** Whether `<probe> --version` succeeds (i.e. the runtime binary is on PATH). */
59
60
  export function isRuntimeOnPath(probe) {
60
- try {
61
- execFileSync(probe, ['--version'], { stdio: 'ignore', timeout: 15000 });
62
- return true;
63
- }
64
- catch {
65
- return false;
66
- }
61
+ const inv = toSpawnable(probe, ['--version']);
62
+ const r = spawnSync(inv.cmd, inv.args, {
63
+ stdio: 'ignore',
64
+ timeout: 15000,
65
+ windowsVerbatimArguments: inv.windowsVerbatimArguments,
66
+ });
67
+ return !r.error && r.status === 0;
67
68
  }
68
69
  /**
69
70
  * Whether a specific runtime is runnable here. The HOST runtime needs no PATH probe
@@ -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) {
@@ -26,7 +26,7 @@
26
26
  // asked for only where a reader demands it (see below) and not blanket-applied
27
27
  // to the payload tarballs.
28
28
  //
29
- // Ledgered in `status/open-todos.md`; closing it means pinning ONE deflate
29
+ // Ledgered in `status/open-todos/README.md`; closing it means pinning ONE deflate
30
30
  // implementation, which is a larger decision than this file.
31
31
  import { gzip } from '@gjsify/tar';
32
32
  /**
@@ -157,6 +157,12 @@ export interface RasterizeInput {
157
157
  * alone passes just as well when nobody wires it in.
158
158
  */
159
159
  spawn?: typeof spawnToCompletion;
160
+ /**
161
+ * The command a refusal names (default `gjsify ship`). `gjsify webext` renders
162
+ * its toolbar icons through the same child, and a message blaming `ship`
163
+ * would send its reader to the wrong command's documentation.
164
+ */
165
+ command?: string;
160
166
  }
161
167
  /**
162
168
  * Render an SVG at every size through librsvg, in a child GJS.
@@ -267,6 +267,7 @@ export function rasterizerEnv(opts = {}) {
267
267
  export async function rasterizeSvg(input) {
268
268
  if (input.sizes.length === 0)
269
269
  return new Map();
270
+ const command = input.command ?? 'gjsify ship';
270
271
  // THE CLI'S OWN SPAWN WRAPPER, not `promisify(execFile)`. Measured under the
271
272
  // GJS-built CLI (`dist/cli.gjs.mjs`, gjs 1.88): `node:util`'s `promisify` over
272
273
  // `@gjsify/child_process`'s `execFile` resolves with the bare stdout string —
@@ -285,7 +286,7 @@ export async function rasterizeSvg(input) {
285
286
  // A 1024 px PNG of a detailed icon is a few hundred kilobytes; base64 adds
286
287
  // a third. Node's default 1 MiB is within reach of one large element.
287
288
  maxBuffer: 64 * 1024 * 1024,
288
- notFound: () => new Error(`gjsify ship: \`${RASTERIZER}\` is not on PATH, and it is what renders ${input.svg} into the ` +
289
+ notFound: () => new Error(`${command}: \`${RASTERIZER}\` is not on PATH, and it is what renders ${input.svg} into the ` +
289
290
  `sizes an icon needs on this OS. ${RASTERIZER_HINT}.`),
290
291
  });
291
292
  const stdout = result.stdout ?? '';
@@ -296,7 +297,7 @@ export async function rasterizeSvg(input) {
296
297
  const hint = /Rsvg|Typelib|cairo/i.test(stderr)
297
298
  ? ` The Rsvg typelib or GJS's cairo binding is missing. ${RASTERIZER_HINT}.`
298
299
  : '';
299
- throw new Error(`gjsify ship: rendering ${input.svg} through ${RASTERIZER} failed (exit ${result.code ?? result.signal}).` +
300
+ throw new Error(`${command}: rendering ${input.svg} through ${RASTERIZER} failed (exit ${result.code ?? result.signal}).` +
300
301
  `${hint}${stderr === '' ? '' : `\n ${stderr.split('\n').join('\n ')}`}`);
301
302
  }
302
303
  let parsed;
@@ -304,19 +305,19 @@ export async function rasterizeSvg(input) {
304
305
  parsed = JSON.parse(stdout);
305
306
  }
306
307
  catch {
307
- throw new Error(`gjsify ship: ${RASTERIZER} printed something other than the rendered icon while rendering ` +
308
+ throw new Error(`${command}: ${RASTERIZER} printed something other than the rendered icon while rendering ` +
308
309
  `${input.svg}: ${stdout.slice(0, 200)}`);
309
310
  }
310
311
  const out = new Map();
311
312
  for (const size of input.sizes) {
312
313
  const encoded = parsed[String(size)];
313
314
  if (encoded === undefined) {
314
- throw new Error(`gjsify ship: ${RASTERIZER} rendered no ${size} px image of ${input.svg}.`);
315
+ throw new Error(`${command}: ${RASTERIZER} rendered no ${size} px image of ${input.svg}.`);
315
316
  }
316
317
  const png = new Uint8Array(Buffer.from(encoded, 'base64'));
317
318
  const { width, height } = readPngSize(png, `the ${size} px render of ${input.svg}`);
318
319
  if (width !== size || height !== size) {
319
- throw new Error(`gjsify ship: asked ${RASTERIZER} for a ${size} px render of ${input.svg} and got ${width}×${height}.`);
320
+ throw new Error(`${command}: asked ${RASTERIZER} for a ${size} px render of ${input.svg} and got ${width}×${height}.`);
320
321
  }
321
322
  out.set(size, png);
322
323
  }
@@ -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