@gjsify/cli 0.17.0 → 0.19.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.
@@ -19,6 +19,7 @@
19
19
  import { spawn } from 'node:child_process';
20
20
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
21
21
  import { join, relative, resolve } from 'node:path';
22
+ import { hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
22
23
  function readPackage(cwd) {
23
24
  try {
24
25
  return JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
@@ -91,11 +92,15 @@ export const storybookCommand = {
91
92
  type: 'string',
92
93
  })
93
94
  .option('runtime', {
94
- description: 'Runtime to build for and launch on: gjs (default) or node (via @gjsify/node-gi). ' +
95
- 'node requires @gjsify/node-gi installed in the project.',
95
+ description: 'Runtime to build for and launch on: gjs | node | bun | deno (default: the host runtime). ' +
96
+ 'node/bun/deno build the same `--app node` bundle (gi:// → @gjsify/node-gi) and require ' +
97
+ '@gjsify/node-gi installed in the project.',
96
98
  type: 'string',
97
- choices: ['gjs', 'node'],
98
- default: 'gjs',
99
+ choices: ['gjs', 'node', 'bun', 'deno'],
100
+ // No yargs `default: 'gjs'` — a yargs default clobbers the
101
+ // config-file value (`gjsify.storybook.runtime`). The host
102
+ // default is applied in the handler: flag > config > host.
103
+ defaultDescription: 'host runtime (gjs on gjs, else node/bun/deno)',
99
104
  })
100
105
  .option('watch', {
101
106
  description: 'Rebuild and relaunch when a story file changes',
@@ -111,12 +116,15 @@ export const storybookCommand = {
111
116
  const applicationId = args.appId ?? config.applicationId ?? deriveAppId(pkg.name);
112
117
  const title = args.title ?? config.title;
113
118
  const globals = args.globals ?? config.globals ?? 'auto';
114
- const runtime = (args.runtime ?? config.runtime ?? 'gjs');
119
+ // Default FOLLOWS the host runtime the CLI executes in. Precedence: CLI
120
+ // flag > `gjsify.storybook.runtime` config > host default (`hostRuntime()`).
121
+ const runtime = (args.runtime ?? config.runtime ?? hostRuntime());
115
122
  // The runtime drives BOTH the build target (`gjsify build --app <app>`)
116
- // and the launcher: `gjs` builds an `--app gjs` bundle run by `gjs -m`,
117
- // `node` builds an `--app node` bundle (gi:// → @gjsify/node-gi) run by
118
- // `node`. Same generated entry + `--globals auto` for both.
119
- const app = runtime === 'node' ? 'node' : 'gjs';
123
+ // and the launcher: `gjs` builds an `--app gjs` bundle run by `gjs -m`;
124
+ // `node`/`bun`/`deno` build the SAME `--app node` bundle (gi:// →
125
+ // @gjsify/node-gi) run on that runtime. Same generated entry +
126
+ // `--globals auto` for all.
127
+ const app = runtime === 'gjs' ? 'gjs' : 'node';
120
128
  const cacheDir = join(cwd, 'node_modules', '.cache', 'gjsify-storybook');
121
129
  mkdirSync(cacheDir, { recursive: true });
122
130
  const entryPath = join(cacheDir, 'entry.ts');
@@ -137,13 +145,20 @@ export const storybookCommand = {
137
145
  const { runCli } = await import('../cli-app.js');
138
146
  const buildBundle = () => runCli(['build', entryPath, '--app', app, '--globals', globals, '--outfile', outPath]);
139
147
  const launch = async () => {
140
- if (runtime === 'node') {
148
+ if (runtime === 'gjs') {
149
+ const { runGjsBundle } = await import('../utils/run-gjs.js');
150
+ await runGjsBundle(outPath, [], { exitOnSuccess: true });
151
+ }
152
+ else if (runtime === 'node') {
141
153
  const { runNodeBundle } = await import('../utils/run-node.js');
142
154
  await runNodeBundle(outPath, [], { exitOnSuccess: true });
143
155
  }
144
156
  else {
145
- const { runGjsBundle } = await import('../utils/run-gjs.js');
146
- await runGjsBundle(outPath, [], { exitOnSuccess: true });
157
+ // bun / deno — run the SAME `--app node` bundle via the shared
158
+ // runner (Node-API is their common ABI). node-gi is required
159
+ // because the storybook uses gi:// (checked in runRuntimeBundle).
160
+ const { runRuntimeBundle } = await import('../utils/run-node.js');
161
+ await runRuntimeBundle(runtime, outPath, [], { exitOnSuccess: true });
147
162
  }
148
163
  };
149
164
  if (!args.watch) {
@@ -159,17 +174,29 @@ export const storybookCommand = {
159
174
  // enough and stays killable for relaunch.
160
175
  const { watch } = await import('node:fs');
161
176
  const spawnChild = async () => {
177
+ if (runtime === 'gjs') {
178
+ return spawn('gjs', ['-m', outPath], { stdio: 'inherit' });
179
+ }
180
+ // node / bun / deno — run the `--app node` bundle with the native
181
+ // typelib env wired like `gjsify run`. All three need @gjsify/node-gi
182
+ // (the storybook uses gi://), kept external by the bundler.
183
+ const { computeNativeEnvForBundle } = await import('../utils/run-gjs.js');
184
+ const { resolveNodeGi, nodeBinary } = await import('../utils/run-node.js');
185
+ const { RUNTIMES } = await import('../utils/runtimes.js');
186
+ if (!resolveNodeGi(cwd)) {
187
+ throw new Error(`Cannot run the storybook on ${runtime}: \`@gjsify/node-gi\` is not installed. ` +
188
+ `Add @gjsify/node-gi as a dependency to run the storybook on ${runtime}.`);
189
+ }
190
+ const { env: nativeEnv } = computeNativeEnvForBundle(outPath, cwd);
191
+ const env = { ...process.env, ...nativeEnv };
192
+ // `node` uses the resolved node binary (under a GJS-hosted CLI
193
+ // `process.execPath` is gjs); bun/deno launch via the shared spec
194
+ // (deno's `--node-modules-dir=manual`, etc.).
162
195
  if (runtime === 'node') {
163
- const { computeNativeEnvForBundle } = await import('../utils/run-gjs.js');
164
- const { resolveNodeGi, nodeBinary } = await import('../utils/run-node.js');
165
- if (!resolveNodeGi(cwd)) {
166
- throw new Error('Cannot run the storybook on Node: `@gjsify/node-gi` is not installed. ' +
167
- 'Add @gjsify/node-gi as a dependency to run the storybook on Node.');
168
- }
169
- const { env: nativeEnv } = computeNativeEnvForBundle(outPath, cwd);
170
- return spawn(nodeBinary(), [outPath], { stdio: 'inherit', env: { ...process.env, ...nativeEnv } });
196
+ return spawn(nodeBinary(), [outPath], { stdio: 'inherit', env });
171
197
  }
172
- return spawn('gjs', ['-m', outPath], { stdio: 'inherit' });
198
+ const [cmd, launchArgs] = RUNTIMES[runtime].launch(outPath);
199
+ return spawn(cmd, launchArgs, { stdio: 'inherit', env });
173
200
  };
174
201
  let child = null;
175
202
  let rebuilding = false;
package/lib/config.js CHANGED
@@ -2,7 +2,8 @@ import { APP_NAME } from './constants.js';
2
2
  import { cosmiconfig } from 'cosmiconfig';
3
3
  import { basename } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
- import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
5
+ import { isGjs, hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
6
+ import { buildAppForRuntime } from './utils/runtimes.js';
6
7
  /**
7
8
  * Does the failed `import()` of a config look like a MODULE-RESOLUTION failure
8
9
  * (the config imports something GJS's ESM loader can't resolve for an external
@@ -227,6 +228,16 @@ export class Config {
227
228
  configData.exclude = cliArgs.exclude || [];
228
229
  if (cliArgs.consoleShim !== undefined)
229
230
  configData.consoleShim = cliArgs.consoleShim;
231
+ // Default `--app` FOLLOWS the host runtime the CLI executes in: gjs when
232
+ // run under gjs, node when run under node/bun/deno (both consume the
233
+ // `--app node` bundle). Applied post-merge (not as a yargs `default:`)
234
+ // so a `gjsify.app` declared in package.json#gjsify / `.gjsifyrc.*`
235
+ // survives — a yargs default is indistinguishable from a user value and
236
+ // would clobber the config one. Precedence: CLI flag > config file >
237
+ // host default (same pattern as `globals` / `bundler.input`).
238
+ if (cliArgs.app !== undefined)
239
+ configData.app = cliArgs.app;
240
+ configData.app ??= buildAppForRuntime(hostRuntime());
230
241
  if (cliArgs.globals !== undefined)
231
242
  configData.globals = cliArgs.globals;
232
243
  // Fallback applied post-merge (not as a yargs `default:`) so a
package/lib/index.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // This file is just the executable wrapper around `runCli`.
7
7
  import { hideBin } from 'yargs/helpers';
8
8
  import { runCli } from './cli-app.js';
9
+ import { gjsExit } from '@gjsify/rolldown-plugin-gjsify/runtime';
9
10
  try {
10
11
  await runCli(hideBin(process.argv));
11
12
  }
@@ -23,6 +24,5 @@ catch (err) {
23
24
  // atexit hook and would exit 0 at natural shutdown, so force a non-zero exit
24
25
  // via `imports.system.exit` there (no-op on Node, where `imports` is unset).
25
26
  process.exitCode = 1;
26
- const gjs = globalThis.imports;
27
- gjs?.system?.exit?.(1);
27
+ gjsExit(1);
28
28
  }
@@ -1,4 +1,5 @@
1
1
  import type { RolldownOptions, OutputOptions, RolldownPluginOption } from 'rolldown';
2
+ import type { App } from '@gjsify/rolldown-plugin-gjsify';
2
3
  import type { ConfigDataLibrary, ConfigDataTypescript } from './index.js';
3
4
  /**
4
5
  * Plugin entry resolvable by package name from the project's `node_modules`.
@@ -64,6 +65,14 @@ export interface LegacyEsbuildOptions {
64
65
  export interface ConfigData {
65
66
  /** Switch on the verbose mode */
66
67
  verbose?: boolean;
68
+ /**
69
+ * The build target for an application build (`gjs` | `node` | `browser` |
70
+ * `nativescript`). Resolved in `Config.forBuild` as CLI flag (`--app`) >
71
+ * `package.json#gjsify.app` > host-runtime default (`gjs` when the CLI runs
72
+ * under gjs, `node` when it runs under node/bun/deno). Consumed by the build
73
+ * handler and passed to `BuildAction.start`.
74
+ */
75
+ app?: App;
67
76
  /**
68
77
  * Bundler-level options forwarded to Rolldown. Replaces the legacy
69
78
  * `esbuild` field. The orchestrator applies platform-specific defaults
@@ -10,8 +10,9 @@
10
10
  // their full transitive prebuild set picked up automatically. The
11
11
  // transitive walk is what makes `gjsify showcase` / `gjsify dlx` work
12
12
  // for packages whose Vala typelibs live in *indirect* deps.
13
- import { readdirSync, existsSync, readFileSync } from 'node:fs';
13
+ import { readdirSync, existsSync } from 'node:fs';
14
14
  import { join, resolve } from 'node:path';
15
+ import { readPackageJson } from './pkg-json.js';
15
16
  /** Map Node.js process.arch values to the convention used in prebuilds/ directories. */
16
17
  function nodeArchToLinuxArch(arch) {
17
18
  const map = {
@@ -22,15 +23,6 @@ function nodeArchToLinuxArch(arch) {
22
23
  };
23
24
  return map[arch] ?? arch;
24
25
  }
25
- /** Read a package.json file and return its parsed content, or null on error. */
26
- function readPackageJson(pkgJsonPath) {
27
- try {
28
- return JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
29
- }
30
- catch {
31
- return null;
32
- }
33
- }
34
26
  /**
35
27
  * Scan all packages in a node_modules directory for gjsify native prebuilds.
36
28
  * Handles scoped packages (@scope/name) as well as flat packages.
@@ -9,8 +9,16 @@ import { existsSync, readFileSync } from 'node:fs';
9
9
  import { dirname, join } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  function manifestPath() {
12
- // `showcases.json` lives at the package root: ../../showcases.json from lib/utils/.
13
- return join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'showcases.json');
12
+ const here = dirname(fileURLToPath(import.meta.url));
13
+ // `showcases.json` lives at the CLI package root. Its position relative to
14
+ // this module depends on how the CLI is loaded:
15
+ // - `lib/utils/discover-showcases.js` (Node/Bun/Deno) → `../../showcases.json`.
16
+ // - the committed `dist/cli.gjs.mjs` GJS bundle (`gjsify showcase` under
17
+ // gjs) → `import.meta.url` is `dist/cli.gjs.mjs`, so the manifest is one
18
+ // level up (`../showcases.json`), not two — without this the gjs CLI
19
+ // finds no manifest and `showcase` wrongly reports "No showcases found".
20
+ const candidates = [join(here, '..', '..', 'showcases.json'), join(here, '..', 'showcases.json')];
21
+ return candidates.find((c) => existsSync(c)) ?? candidates[0];
14
22
  }
15
23
  /**
16
24
  * Read the curated showcase list from `showcases.json`. Returns showcases
@@ -7,7 +7,7 @@
7
7
  // confusing crash with no actionable message (observed: Node 22 in a Fedora
8
8
  // container segfaults `gjsify install --backend native` during download/extract).
9
9
  // This preflight turns that into a clear, actionable error.
10
- import { isNode } from '@gjsify/rolldown-plugin-gjsify/runtime';
10
+ import { isNode, gjsSystemVersion } from '@gjsify/rolldown-plugin-gjsify/runtime';
11
11
  /**
12
12
  * Minimum Node.js major the native install backend supports — its prebuilt native
13
13
  * modules are ABI-locked to this line (kept in sync with `.nvmrc`).
@@ -39,7 +39,7 @@ function onRealNode() {
39
39
  // GJS exposes `imports.system.version`; @gjsify/process fakes
40
40
  // `process.versions.node = '20.0.0'` for compat, so check GJS FIRST — a plain
41
41
  // `process.versions.node` read would be a false Node positive under GJS.
42
- const sysVersion = globalThis.imports?.system?.version;
42
+ const sysVersion = gjsSystemVersion();
43
43
  if (sysVersion !== undefined)
44
44
  return false;
45
45
  return isNode();
@@ -54,7 +54,7 @@ import { pathToFileURL } from 'node:url';
54
54
  import { findWorkspaceRoot } from './workspace-root.js';
55
55
  import { resolveNpmPackage } from './resolve-npm-package.js';
56
56
  import { nodeBinary } from './run-node.js';
57
- import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
57
+ import { isGjs, gjsExit } from '@gjsify/rolldown-plugin-gjsify/runtime';
58
58
  /**
59
59
  * Map Node.js `process.platform` + `arch` (+ musl on Linux) to the
60
60
  * napi-rs binding-package suffix oxc uses, e.g. `linux-x64-gnu`,
@@ -408,9 +408,7 @@ export function setOxcExitCode(code) {
408
408
  process.exitCode = code;
409
409
  if (code === 0)
410
410
  return;
411
- const gjs = globalThis.imports;
412
- if (gjs?.system?.exit)
413
- gjs.system.exit(code);
411
+ gjsExit(code);
414
412
  }
415
413
  /**
416
414
  * Has the given oxc tool's npm package (or its companion) been declared in the
@@ -11,6 +11,8 @@ export interface PackageJson {
11
11
  devDependencies?: Record<string, string>;
12
12
  peerDependencies?: Record<string, string>;
13
13
  optionalDependencies?: Record<string, string>;
14
+ scripts?: Record<string, string>;
15
+ private?: boolean;
14
16
  [key: string]: unknown;
15
17
  }
16
18
  export declare function readPackageJson(pkgPath: string): PackageJson | null;
@@ -0,0 +1,6 @@
1
+ import type { PackageJson } from './pkg-json-edit.js';
2
+ /**
3
+ * Read + parse a `package.json` at `pkgPath`. Permissive: any error (missing
4
+ * file, unreadable, invalid JSON) yields `null`.
5
+ */
6
+ export declare function readPackageJson(pkgPath: string): PackageJson | null;
@@ -0,0 +1,24 @@
1
+ // Single permissive reader for `package.json` files, shared by the CLI's
2
+ // workspace- and dependency-discovery paths (`gjsify run`/`workspace`/`foreach`/
3
+ // `check`, native-prebuild scanning). Returns `null` when the file is missing
4
+ // OR unparseable — discovery must degrade gracefully on a stray or corrupt
5
+ // manifest rather than crash the whole command.
6
+ //
7
+ // The install-save path keeps its own STRICTER reader in `pkg-json-edit.ts`
8
+ // (`readPackageJson` there): it returns `null` only when the file is absent but
9
+ // THROWS on invalid JSON, so a corrupt manifest is never silently overwritten
10
+ // during `gjsify install <pkg>`. That contract is intentional and load-bearing
11
+ // — do not fold it into this one.
12
+ import { readFileSync } from 'node:fs';
13
+ /**
14
+ * Read + parse a `package.json` at `pkgPath`. Permissive: any error (missing
15
+ * file, unreadable, invalid JSON) yields `null`.
16
+ */
17
+ export function readPackageJson(pkgPath) {
18
+ try {
19
+ return JSON.parse(readFileSync(pkgPath, 'utf-8'));
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
@@ -4,4 +4,15 @@ interface ResolvedEntry {
4
4
  fromFallback: boolean;
5
5
  }
6
6
  export declare function resolveGjsEntry(pkgDir: string, binName: string | null): ResolvedEntry;
7
+ /**
8
+ * Resolve the `--app node` bundle of a package for the non-gjs runtimes
9
+ * (node/bun/deno all consume it). Resolution order:
10
+ * 1. explicit `gjsify.example.node` (a package can name its node bundle)
11
+ * 2. convention: the node twin of the GJS entry — swap the `<name>.gjs.<ext>`
12
+ * infix to `<name>.node.<ext>` and probe `.node.{mjs,js,cjs}` (gjsify's
13
+ * `build:node → dist/<name>.node.mjs` convention; the GJS entry is
14
+ * `dist/<name>.gjs.js`, so the extension can differ).
15
+ * Hard-fails with an actionable hint when neither is found.
16
+ */
17
+ export declare function resolveNodeEntry(pkgDir: string): string;
7
18
  export {};
@@ -63,3 +63,45 @@ export function resolveGjsEntry(pkgDir, binName) {
63
63
  }
64
64
  return { bundlePath, binName: resolvedBin, fromFallback };
65
65
  }
66
+ /**
67
+ * Resolve the `--app node` bundle of a package for the non-gjs runtimes
68
+ * (node/bun/deno all consume it). Resolution order:
69
+ * 1. explicit `gjsify.example.node` (a package can name its node bundle)
70
+ * 2. convention: the node twin of the GJS entry — swap the `<name>.gjs.<ext>`
71
+ * infix to `<name>.node.<ext>` and probe `.node.{mjs,js,cjs}` (gjsify's
72
+ * `build:node → dist/<name>.node.mjs` convention; the GJS entry is
73
+ * `dist/<name>.gjs.js`, so the extension can differ).
74
+ * Hard-fails with an actionable hint when neither is found.
75
+ */
76
+ export function resolveNodeEntry(pkgDir) {
77
+ const pkgJsonPath = join(pkgDir, 'package.json');
78
+ if (!existsSync(pkgJsonPath)) {
79
+ throw new Error(`no package.json found at ${pkgDir}`);
80
+ }
81
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
82
+ // 1. Explicit declaration wins.
83
+ const explicit = pkg.gjsify?.example?.node;
84
+ if (explicit) {
85
+ const p = resolve(pkgDir, explicit);
86
+ if (!existsSync(p)) {
87
+ throw new Error(`declared node entry not found: ${p} (gjsify.example.node)`);
88
+ }
89
+ return p;
90
+ }
91
+ // 2. Derive from the GJS entry.
92
+ const gjsEntry = pkg.gjsify?.main ?? (pkg.gjsify?.bin ? Object.values(pkg.gjsify.bin)[0] : undefined) ?? pkg.main;
93
+ if (!gjsEntry) {
94
+ throw new Error(`package "${pkg.name ?? pkgDir}" has no entry to derive a node bundle from — ` +
95
+ 'set `gjsify.example.node` to its `--app node` bundle path.');
96
+ }
97
+ const stem = gjsEntry.replace(/\.gjs\.(js|mjs|cjs)$/, '').replace(/\.(js|mjs|cjs)$/, '');
98
+ const candidates = [`${stem}.node.mjs`, `${stem}.node.js`, `${stem}.node.cjs`];
99
+ for (const c of candidates) {
100
+ const p = resolve(pkgDir, c);
101
+ if (existsSync(p))
102
+ return p;
103
+ }
104
+ throw new Error(`package "${pkg.name ?? pkgDir}" has no \`--app node\` bundle — looked for ${candidates.join(', ')}.\n` +
105
+ ' Build one (e.g. `gjsify build src/app.ts --app node --outfile dist/app.node.mjs`) ' +
106
+ 'or set `gjsify.example.node` to its path.');
107
+ }
@@ -53,6 +53,7 @@ export async function runGjsBundle(bundlePath, extraArgs = [], options = {}) {
53
53
  const gjsCommand = ['gjs', ...gjsArgs.map((a) => (a.includes(' ') ? `"${a}"` : a))].join(' ');
54
54
  console.error(`$ ${envPrefix ? `${envPrefix} ` : ''}${gjsCommand}`);
55
55
  const child = spawn('gjs', gjsArgs, { env, stdio: 'inherit' });
56
+ let failed = false;
56
57
  await new Promise((resolvePromise, reject) => {
57
58
  child.on('close', (code) => {
58
59
  if (code !== 0) {
@@ -65,10 +66,22 @@ export async function runGjsBundle(bundlePath, extraArgs = [], options = {}) {
65
66
  child.on('error', reject);
66
67
  }).catch((err) => {
67
68
  console.error(err.message);
68
- process.exit(1);
69
+ failed = true;
69
70
  });
71
+ if (failed) {
72
+ // Set `process.exitCode` synchronously BEFORE exiting: under GJS
73
+ // `process.exit()` is deferred (the GLib main loop is armed), so when
74
+ // this runs via run.ts's in-process script dispatch, execution returns
75
+ // to the caller, which propagates by reading `process.exitCode`. Without
76
+ // this, `gjsify run <script>` whose body is `gjsify run <bundle>`
77
+ // swallowed a non-zero gjs exit and returned 0 — masking a failing
78
+ // `test:gjs` in the `test` script chain (and, via `gjsify foreach test`,
79
+ // in CI). The `return` guards against a fall-through second `exit`.
80
+ process.exitCode = 1;
81
+ return process.exit(1);
82
+ }
70
83
  // See RunGjsBundleOptions.exitOnSuccess — terminal callers opt in,
71
84
  // mid-flow callers (test.ts's runtime loop) keep the process alive.
72
85
  if (options.exitOnSuccess)
73
- process.exit(0);
86
+ return process.exit(0);
74
87
  }
@@ -1,3 +1,4 @@
1
+ import { type ExampleRuntime } from './runtimes.js';
1
2
  /**
2
3
  * Walk up from `startDir` looking for `<node_modules>/@gjsify/node-gi`. Returns
3
4
  * the package directory, or null when it is not installed anywhere on the chain.
@@ -18,3 +19,23 @@ export interface RunNodeBundleOptions {
18
19
  * throws a clear, actionable error otherwise.
19
20
  */
20
21
  export declare function runNodeBundle(bundlePath: string, extraArgs?: string[], options?: RunNodeBundleOptions): Promise<void>;
22
+ /**
23
+ * Whether a `--app node` bundle actually consumes `@gjsify/node-gi` (the gi://
24
+ * reverse bridge is kept EXTERNAL, so a bundle that uses gi:// imports the shim
25
+ * by name). A plain Node app (express, a pure `node:*` program) does NOT, and
26
+ * must run on node/bun/deno WITHOUT node-gi installed — so the node-gi check is
27
+ * conditional on this, not unconditional like the storybook path.
28
+ */
29
+ export declare function bundleRequiresNodeGi(bundlePath: string): boolean;
30
+ /**
31
+ * Run a built bundle on a non-gjs runtime (node / bun / deno). All three consume
32
+ * the SAME `--app node` bundle (Node-API is their common ABI), so this is the
33
+ * shared launcher `gjsify run --runtime` and `gjsify showcase --runtime` use for
34
+ * node/bun/deno. The native typelib env is wired exactly like {@link runGjsBundle}
35
+ * (so a bundle pulling an `@gjsify/*` Vala bridge resolves its prebuild), and
36
+ * `@gjsify/node-gi` is required ONLY when the bundle actually uses gi://.
37
+ *
38
+ * `gjs` is intentionally NOT handled here — the caller dispatches it to
39
+ * {@link runGjsBundle}.
40
+ */
41
+ export declare function runRuntimeBundle(runtime: Exclude<ExampleRuntime, 'gjs'>, bundlePath: string, extraArgs?: string[], options?: RunNodeBundleOptions): Promise<void>;
@@ -20,10 +20,11 @@
20
20
  // the PATH `node` otherwise (under the committed GJS bundle `process.execPath` is
21
21
  // `gjs`, which cannot run a `--app node` bundle).
22
22
  import { spawn } from 'node:child_process';
23
- import { existsSync } from 'node:fs';
23
+ import { existsSync, readFileSync } from 'node:fs';
24
24
  import { join, resolve } from 'node:path';
25
25
  import { isNode } from '@gjsify/rolldown-plugin-gjsify/runtime';
26
26
  import { computeNativeEnvForBundle } from './run-gjs.js';
27
+ import { RUNTIMES } from './runtimes.js';
27
28
  /**
28
29
  * Walk up from `startDir` looking for `<node_modules>/@gjsify/node-gi`. Returns
29
30
  * the package directory, or null when it is not installed anywhere on the chain.
@@ -83,3 +84,63 @@ export async function runNodeBundle(bundlePath, extraArgs = [], options = {}) {
83
84
  if (options.exitOnSuccess)
84
85
  process.exit(0);
85
86
  }
87
+ /**
88
+ * Whether a `--app node` bundle actually consumes `@gjsify/node-gi` (the gi://
89
+ * reverse bridge is kept EXTERNAL, so a bundle that uses gi:// imports the shim
90
+ * by name). A plain Node app (express, a pure `node:*` program) does NOT, and
91
+ * must run on node/bun/deno WITHOUT node-gi installed — so the node-gi check is
92
+ * conditional on this, not unconditional like the storybook path.
93
+ */
94
+ export function bundleRequiresNodeGi(bundlePath) {
95
+ try {
96
+ return readFileSync(bundlePath, 'utf-8').includes('@gjsify/node-gi');
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
102
+ /**
103
+ * Run a built bundle on a non-gjs runtime (node / bun / deno). All three consume
104
+ * the SAME `--app node` bundle (Node-API is their common ABI), so this is the
105
+ * shared launcher `gjsify run --runtime` and `gjsify showcase --runtime` use for
106
+ * node/bun/deno. The native typelib env is wired exactly like {@link runGjsBundle}
107
+ * (so a bundle pulling an `@gjsify/*` Vala bridge resolves its prebuild), and
108
+ * `@gjsify/node-gi` is required ONLY when the bundle actually uses gi://.
109
+ *
110
+ * `gjs` is intentionally NOT handled here — the caller dispatches it to
111
+ * {@link runGjsBundle}.
112
+ */
113
+ export async function runRuntimeBundle(runtime, bundlePath, extraArgs = [], options = {}) {
114
+ const cwd = process.cwd();
115
+ const resolvedBundle = resolve(bundlePath);
116
+ if (bundleRequiresNodeGi(resolvedBundle) && !resolveNodeGi(cwd)) {
117
+ throw new Error(`Cannot run this bundle on ${runtime}: it uses gi:// via \`@gjsify/node-gi\`, which is not installed.\n` +
118
+ 'Add @gjsify/node-gi as a dependency to run gi:// code off GJS, e.g.:\n' +
119
+ ' "dependencies": { "@gjsify/node-gi": "^0.13.0" }');
120
+ }
121
+ const { env: nativeEnv, envPrefix } = computeNativeEnvForBundle(resolvedBundle, cwd);
122
+ const env = { ...process.env, ...nativeEnv };
123
+ // Reuse the shared RUNTIMES launch spec for the argv (deno's
124
+ // `--node-modules-dir=manual`, etc.), substituting the resolved node binary
125
+ // for the `node` runtime (under a GJS-hosted CLI `process.execPath` is gjs).
126
+ const [, launchArgs] = RUNTIMES[runtime].launch(resolvedBundle, extraArgs);
127
+ const cmd = runtime === 'node' ? nodeBinary() : RUNTIMES[runtime].probe;
128
+ // Echo on stderr so a child speaking a protocol on stdout stays clean.
129
+ const shown = [cmd, ...launchArgs.map((a) => (a.includes(' ') ? `"${a}"` : a))].join(' ');
130
+ console.error(`$ ${envPrefix ? `${envPrefix} ` : ''}${shown}`);
131
+ const child = spawn(cmd, launchArgs, { env, stdio: 'inherit' });
132
+ await new Promise((resolvePromise, reject) => {
133
+ child.on('close', (code) => {
134
+ if (code !== 0)
135
+ reject(new Error(`${runtime} exited with code ${code}`));
136
+ else
137
+ resolvePromise();
138
+ });
139
+ child.on('error', reject);
140
+ }).catch((err) => {
141
+ console.error(err.message);
142
+ process.exit(1);
143
+ });
144
+ if (options.exitOnSuccess)
145
+ process.exit(0);
146
+ }
@@ -0,0 +1,66 @@
1
+ /** The runtimes a built gjsify example/showcase can run on. `gjs` is the reference. */
2
+ export type ExampleRuntime = 'gjs' | 'node' | 'bun' | 'deno';
3
+ /** All runtimes, in canonical order (gjs first — it is the reference). */
4
+ export declare const EXAMPLE_RUNTIMES: readonly ExampleRuntime[];
5
+ export interface RuntimeSpec {
6
+ /** Binary probed on PATH to decide availability (`<probe> --version`). */
7
+ readonly probe: string;
8
+ /**
9
+ * The `gjsify build --app <buildApp>` target this runtime consumes. `gjs`
10
+ * runs the native `--app gjs` bundle; node/bun/deno all reuse the SAME
11
+ * `--app node` bundle (gi:// → `@gjsify/node-gi`; Node-API is their common
12
+ * ABI), so bun/deno never need their own build target.
13
+ */
14
+ readonly buildApp: 'gjs' | 'node';
15
+ /** argv to launch a built `entry` (+ optional extra args) under this runtime. */
16
+ readonly launch: (entry: string, extraArgs?: readonly string[]) => [string, string[]];
17
+ }
18
+ /**
19
+ * The runtime map — the generalization of the node-gi harness's `RUNTIMES`.
20
+ * `deno` uses `--node-modules-dir=manual`: `auto` would re-resolve the example's
21
+ * heavy build-time dep tree (`@gjsify/cli` + platform binaries) and hang, when
22
+ * the app only needs the already-linked runtime dep `@gjsify/node-gi`.
23
+ */
24
+ export declare const RUNTIMES: Record<ExampleRuntime, RuntimeSpec>;
25
+ /** Type guard: is `value` one of the four known runtimes? */
26
+ export declare function isExampleRuntime(value: string): value is ExampleRuntime;
27
+ /** The `gjsify build --app <target>` a runtime consumes (`gjs`, else `node`). */
28
+ export declare function buildAppForRuntime(runtime: ExampleRuntime): 'gjs' | 'node';
29
+ /** Whether `<probe> --version` succeeds (i.e. the runtime binary is on PATH). */
30
+ export declare function isRuntimeOnPath(probe: string): boolean;
31
+ /**
32
+ * Whether a specific runtime is runnable here. The HOST runtime (the one the
33
+ * CLI itself executes in — gjs / node / bun / deno) is always available: we are
34
+ * running on it, so no PATH probe is needed. Every other runtime is probed via
35
+ * `<binary> --version`.
36
+ */
37
+ export declare function isRuntimeAvailable(runtime: ExampleRuntime): boolean;
38
+ /** Which of gjs/node/bun/deno are runnable on this host. */
39
+ export declare function availableRuntimes(): ExampleRuntime[];
40
+ interface ExampleGjsifyField {
41
+ example?: {
42
+ runtimes?: unknown;
43
+ };
44
+ }
45
+ /**
46
+ * Read a package's declared example runtimes. Returns the (validated) list, or
47
+ * `null` when the package declares none — the "unconstrained / permissive" case.
48
+ * Unknown entries in the list are dropped (so a future runtime name doesn't hard
49
+ * error against an older CLI).
50
+ */
51
+ export declare function readDeclaredRuntimes(pkg: {
52
+ gjsify?: ExampleGjsifyField;
53
+ } | null | undefined): ExampleRuntime[] | null;
54
+ export interface RuntimeSupportResult {
55
+ ok: boolean;
56
+ /** A clear, actionable message when `ok === false`; undefined otherwise. */
57
+ message?: string;
58
+ }
59
+ /**
60
+ * Validate a requested runtime against a package's declaration. A `null`
61
+ * declaration is permissive (always ok). When the runtime is not in the
62
+ * declared set, returns a clear, actionable error message (never throws) so
63
+ * callers can print it and exit cleanly.
64
+ */
65
+ export declare function checkRuntimeSupported(runtime: ExampleRuntime, declared: ExampleRuntime[] | null, name: string): RuntimeSupportResult;
66
+ export {};