@gjsify/cli 0.18.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
@@ -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
@@ -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
@@ -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
+ }
@@ -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 {};
@@ -0,0 +1,97 @@
1
+ // Shared runtime-selector tooling for running gjsify examples/showcases across
2
+ // the FOUR runtimes gjsify targets: gjs, node, bun, deno.
3
+ //
4
+ // This is the single source of truth generalized from the node-gi quad-runtime
5
+ // example's `harness.mjs`. Both the CLI (`gjsify run`/`gjsify showcase
6
+ // --runtime`) and the examples (the node-gi `harness.mjs`) consume it — so the
7
+ // RUNTIMES map, the runtime→build-target mapping and `availableRuntimes()` live
8
+ // in ONE place instead of a per-example copy.
9
+ //
10
+ // The module is intentionally PURE (only `node:child_process` and the pure,
11
+ // side-effect-free `@gjsify/rolldown-plugin-gjsify/runtime` host detector, no
12
+ // `gi://` / native imports) so an external example can deep-import it from the
13
+ // installed CLI (`@gjsify/cli/lib/utils/runtimes.js`) without pulling the whole
14
+ // bundler in. Mirrors the existing `@gjsify/cli/lib/utils/run-gjs.js`
15
+ // deep-import precedent used by the dlx e2e suites.
16
+ import { execFileSync } from 'node:child_process';
17
+ import { hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
18
+ /** All runtimes, in canonical order (gjs first — it is the reference). */
19
+ export const EXAMPLE_RUNTIMES = ['gjs', 'node', 'bun', 'deno'];
20
+ /**
21
+ * The runtime map — the generalization of the node-gi harness's `RUNTIMES`.
22
+ * `deno` uses `--node-modules-dir=manual`: `auto` would re-resolve the example's
23
+ * heavy build-time dep tree (`@gjsify/cli` + platform binaries) and hang, when
24
+ * the app only needs the already-linked runtime dep `@gjsify/node-gi`.
25
+ */
26
+ export const RUNTIMES = {
27
+ gjs: { probe: 'gjs', buildApp: 'gjs', launch: (e, a = []) => ['gjs', ['-m', e, ...a]] },
28
+ node: { probe: 'node', buildApp: 'node', launch: (e, a = []) => ['node', [e, ...a]] },
29
+ bun: { probe: 'bun', buildApp: 'node', launch: (e, a = []) => ['bun', [e, ...a]] },
30
+ deno: {
31
+ probe: 'deno',
32
+ buildApp: 'node',
33
+ launch: (e, a = []) => ['deno', ['run', '-A', '--node-modules-dir=manual', e, ...a]],
34
+ },
35
+ };
36
+ /** Type guard: is `value` one of the four known runtimes? */
37
+ export function isExampleRuntime(value) {
38
+ return EXAMPLE_RUNTIMES.includes(value);
39
+ }
40
+ /** The `gjsify build --app <target>` a runtime consumes (`gjs`, else `node`). */
41
+ export function buildAppForRuntime(runtime) {
42
+ return RUNTIMES[runtime].buildApp;
43
+ }
44
+ /** Whether `<probe> --version` succeeds (i.e. the runtime binary is on PATH). */
45
+ export function isRuntimeOnPath(probe) {
46
+ try {
47
+ execFileSync(probe, ['--version'], { stdio: 'ignore', timeout: 15000 });
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ /**
55
+ * Whether a specific runtime is runnable here. The HOST runtime (the one the
56
+ * CLI itself executes in — gjs / node / bun / deno) is always available: we are
57
+ * running on it, so no PATH probe is needed. Every other runtime is probed via
58
+ * `<binary> --version`.
59
+ */
60
+ export function isRuntimeAvailable(runtime) {
61
+ return runtime === hostRuntime() ? true : isRuntimeOnPath(RUNTIMES[runtime].probe);
62
+ }
63
+ /** Which of gjs/node/bun/deno are runnable on this host. */
64
+ export function availableRuntimes() {
65
+ return EXAMPLE_RUNTIMES.filter((rt) => isRuntimeAvailable(rt));
66
+ }
67
+ /**
68
+ * Read a package's declared example runtimes. Returns the (validated) list, or
69
+ * `null` when the package declares none — the "unconstrained / permissive" case.
70
+ * Unknown entries in the list are dropped (so a future runtime name doesn't hard
71
+ * error against an older CLI).
72
+ */
73
+ export function readDeclaredRuntimes(pkg) {
74
+ const list = pkg?.gjsify?.example?.runtimes;
75
+ if (!Array.isArray(list))
76
+ return null;
77
+ return list.filter((v) => typeof v === 'string' && isExampleRuntime(v));
78
+ }
79
+ /**
80
+ * Validate a requested runtime against a package's declaration. A `null`
81
+ * declaration is permissive (always ok). When the runtime is not in the
82
+ * declared set, returns a clear, actionable error message (never throws) so
83
+ * callers can print it and exit cleanly.
84
+ */
85
+ export function checkRuntimeSupported(runtime, declared, name) {
86
+ if (declared === null)
87
+ return { ok: true };
88
+ if (declared.includes(runtime))
89
+ return { ok: true };
90
+ const supported = declared.length > 0 ? declared.join(', ') : '(none)';
91
+ return {
92
+ ok: false,
93
+ message: `"${name}" does not support --runtime ${runtime}.\n` +
94
+ ` Declared runtimes: ${supported}.\n` +
95
+ ` Run it on one of the declared runtimes, e.g. --runtime ${declared[0] ?? 'gjs'}.`,
96
+ };
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -113,6 +113,9 @@
113
113
  "chmod": "chmod +x ./lib/index.js",
114
114
  "build:test:node": "node lib/index.js build src/test.mts --app node --outfile dist/test.node.mjs",
115
115
  "test:node": "node dist/test.node.mjs",
116
+ "test:bun": "bun dist/test.node.mjs",
117
+ "test:deno": "deno run -A --no-lock --node-modules-dir=none dist/test.node.mjs",
118
+ "test:cross-runtime": "gjsify run build:test:node && gjsify run test:node && gjsify run test:bun && gjsify run test:deno",
116
119
  "test": "gjsify run build:test:node && gjsify run test:node"
117
120
  },
118
121
  "keywords": [
@@ -122,19 +125,19 @@
122
125
  "cli"
123
126
  ],
124
127
  "dependencies": {
125
- "@gjsify/buffer": "^0.18.0",
126
- "@gjsify/create-app": "^0.18.0",
127
- "@gjsify/node-globals": "^0.18.0",
128
- "@gjsify/node-polyfills": "^0.18.0",
129
- "@gjsify/npm-registry": "^0.18.0",
130
- "@gjsify/resolve-npm": "^0.18.0",
131
- "@gjsify/rolldown-plugin-gjsify": "^0.18.0",
132
- "@gjsify/rolldown-plugin-pnp": "^0.18.0",
133
- "@gjsify/semver": "^0.18.0",
134
- "@gjsify/tar": "^0.18.0",
135
- "@gjsify/tsc": "^0.18.0",
136
- "@gjsify/web-polyfills": "^0.18.0",
137
- "@gjsify/workspace": "^0.18.0",
128
+ "@gjsify/buffer": "^0.19.0",
129
+ "@gjsify/create-app": "^0.19.0",
130
+ "@gjsify/node-globals": "^0.19.0",
131
+ "@gjsify/node-polyfills": "^0.19.0",
132
+ "@gjsify/npm-registry": "^0.19.0",
133
+ "@gjsify/resolve-npm": "^0.19.0",
134
+ "@gjsify/rolldown-plugin-gjsify": "^0.19.0",
135
+ "@gjsify/rolldown-plugin-pnp": "^0.19.0",
136
+ "@gjsify/semver": "^0.19.0",
137
+ "@gjsify/tar": "^0.19.0",
138
+ "@gjsify/tsc": "^0.19.0",
139
+ "@gjsify/web-polyfills": "^0.19.0",
140
+ "@gjsify/workspace": "^0.19.0",
138
141
  "cosmiconfig": "^9.0.2",
139
142
  "get-tsconfig": "^4.14.0",
140
143
  "pkg-types": "^2.3.1",
@@ -142,13 +145,13 @@
142
145
  "yargs": "^18.0.0"
143
146
  },
144
147
  "devDependencies": {
145
- "@gjsify/unit": "^0.18.0",
148
+ "@gjsify/unit": "^0.19.0",
146
149
  "@types/yargs": "^17.0.35",
147
150
  "typescript": "^6.0.3"
148
151
  },
149
152
  "peerDependencies": {
150
- "@gjsify/oxfmt-native": "^0.18.0",
151
- "@gjsify/rolldown-native": "^0.18.0"
153
+ "@gjsify/oxfmt-native": "^0.19.0",
154
+ "@gjsify/rolldown-native": "^0.19.0"
152
155
  },
153
156
  "peerDependenciesMeta": {
154
157
  "@gjsify/oxfmt-native": {