@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.
@@ -18,11 +18,22 @@ export declare class BuildAction {
18
18
  private parseGlobalsValue;
19
19
  /**
20
20
  * Resolve the `--globals` CLI list into a pre-computed inject stub path
21
- * that the orchestrator appends to its input list. Only runs for
22
- * `--app gjs` — Node and browser builds rely on native globals.
21
+ * that the orchestrator appends to its input list. Runs for `--app gjs`
22
+ * AND `--app node`: on gjs the registers install our polyfills over the
23
+ * bare GJS runtime; on node an EXPLICIT list is the reverse bridge's
24
+ * DOM-surface request (a genuine GJS source built via `@gjsify/node-gi`
25
+ * needs the same `@gjsify/*` registers — document, HTMLCanvasElement, … —
26
+ * it gets under `--app gjs`, backed by gi:// through node-gi). Browser
27
+ * builds rely on native globals.
23
28
  *
24
- * Used only for the explicit-only path (no `auto` token in the value).
25
- * The auto path is handled in `buildApp` via the iterative multi-pass build.
29
+ * Node is EXPLICIT-ONLY by design: plain `--globals auto` never injects
30
+ * web/dom registers into a node bundle (cross-platform packages' node
31
+ * builds must stay loadable on plain Node without node-gi), so the
32
+ * `@gjsify/empty` routing is only lifted when the USER names the globals.
33
+ *
34
+ * Used for the explicit-only path (no `auto` token) and, on node, for the
35
+ * `auto,<extras>` extras. The gjs auto path is handled in `buildApp` via
36
+ * the iterative multi-pass build.
26
37
  */
27
38
  private resolveGlobalsInject;
28
39
  /**
@@ -207,21 +207,38 @@ export class BuildAction {
207
207
  }
208
208
  /**
209
209
  * Resolve the `--globals` CLI list into a pre-computed inject stub path
210
- * that the orchestrator appends to its input list. Only runs for
211
- * `--app gjs` — Node and browser builds rely on native globals.
210
+ * that the orchestrator appends to its input list. Runs for `--app gjs`
211
+ * AND `--app node`: on gjs the registers install our polyfills over the
212
+ * bare GJS runtime; on node an EXPLICIT list is the reverse bridge's
213
+ * DOM-surface request (a genuine GJS source built via `@gjsify/node-gi`
214
+ * needs the same `@gjsify/*` registers — document, HTMLCanvasElement, … —
215
+ * it gets under `--app gjs`, backed by gi:// through node-gi). Browser
216
+ * builds rely on native globals.
212
217
  *
213
- * Used only for the explicit-only path (no `auto` token in the value).
214
- * The auto path is handled in `buildApp` via the iterative multi-pass build.
218
+ * Node is EXPLICIT-ONLY by design: plain `--globals auto` never injects
219
+ * web/dom registers into a node bundle (cross-platform packages' node
220
+ * builds must stay loadable on plain Node without node-gi), so the
221
+ * `@gjsify/empty` routing is only lifted when the USER names the globals.
222
+ *
223
+ * Used for the explicit-only path (no `auto` token) and, on node, for the
224
+ * `auto,<extras>` extras. The gjs auto path is handled in `buildApp` via
225
+ * the iterative multi-pass build.
215
226
  */
216
227
  async resolveGlobalsInject(app, globals, verbose) {
217
- if (app !== 'gjs')
228
+ if (app !== 'gjs' && app !== 'node')
218
229
  return undefined;
219
230
  if (!globals)
220
231
  return undefined;
221
232
  const registerPaths = resolveGlobalsList(globals);
222
233
  if (registerPaths.size === 0)
223
234
  return undefined;
224
- const injectPath = await writeRegisterInjectFile(registerPaths, process.cwd());
235
+ // On `--app node` (the reverse bridge) the injected register bodies must
236
+ // OVERRIDE the runtime-native web globals gjsify owns (the `fetch`
237
+ // family — undici rejects the root-relative `/res/...` asset paths a GJS
238
+ // app uses). See writeRegisterInjectFile / REVERSE_BRIDGE_NATIVE_OVERRIDE_IDENTS.
239
+ const injectPath = await writeRegisterInjectFile(registerPaths, process.cwd(), {
240
+ reverseBridgeOverride: app === 'node',
241
+ });
225
242
  if (verbose && injectPath) {
226
243
  console.debug(`[gjsify] globals: injected ${registerPaths.size} register module(s) from --globals ${globals}`);
227
244
  }
@@ -517,6 +534,17 @@ export class BuildAction {
517
534
  }, pluginOpts, gjsifyPluginFactory, bundleToChunks);
518
535
  if (needsGiGlobals)
519
536
  pluginOpts.nodeGiGlobalsInject = true;
537
+ // Explicit extras on a node build (`--globals auto,dom,…`) are the
538
+ // reverse bridge's DOM-surface request: inject the SAME register
539
+ // modules the gjs target would for those identifiers, so a genuine
540
+ // GJS source (an Excalibur/WebGLBridge app) gets document /
541
+ // HTMLCanvasElement / matchMedia / … on Node via node-gi. The
542
+ // orchestrator lifts the `@gjsify/empty` register routing only when
543
+ // this inject stub is present (see app/node.ts) — plain `auto`
544
+ // node builds are byte-unchanged.
545
+ if (extras) {
546
+ pluginOpts.autoGlobalsInject = await this.resolveGlobalsInject(app, extras, verbose);
547
+ }
520
548
  }
521
549
  else if (extras) {
522
550
  pluginOpts.autoGlobalsInject = await this.resolveGlobalsInject(app, extras, verbose);
package/lib/cli-app.js CHANGED
@@ -21,23 +21,31 @@ import { fileURLToPath } from 'node:url';
21
21
  import yargs from 'yargs';
22
22
  import { buildCommand as build, testCommand as test, runCommand as run, infoCommand as info, systemCheckCommand as systemCheck, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, dlxCommand as dlx, installCommand as install, foreachCommand as foreach, workspaceCommand as workspace, packCommand as pack, publishCommand as publish, whoamiCommand as whoami, loginCommand as login, logoutCommand as logout, trustCommand as trust, onboardCommand as onboard, selfUpdateCommand as selfUpdate, generateInstallerCommand as generateInstaller, uninstallCommand as uninstall, formatCommand as format, lintCommand as lint, fixCommand as fix, upgradeCommand as upgrade, barrelsCommand as barrels, tscCommand as tsc, affectedCommand as affected, storybookCommand as storybook, debugCommand as debug, browseCommand as browse, } from './commands/index.js';
23
23
  import { APP_NAME } from './constants.js';
24
- import { isNode } from '@gjsify/rolldown-plugin-gjsify/runtime';
24
+ import { isBun, isDeno, isNode, gjsSystemVersion } from '@gjsify/rolldown-plugin-gjsify/runtime';
25
25
  import { ensureGjsifyShimOnPath } from './utils/gjsify-shim.js';
26
- // Detect which runtime is executing the CLI (GJS or Node.js).
27
- // GJS MUST be checked first because @gjsify/process sets
28
- // `process.versions.node = '20.0.0'` for compatibility — a plain
29
- // `process.versions.node` check would be a false Node positive under GJS.
26
+ // Detect which runtime is executing the CLI (GJS, Node.js, Bun or Deno) for the
27
+ // help epilogue. Bun and Deno MUST be checked before Node because both — like
28
+ // GJS's @gjsify/process shim — fake `process.versions.node` for npm
29
+ // compatibility, so a plain Node probe is a false positive on either.
30
30
  function runtimeLabel() {
31
31
  try {
32
- const sys = globalThis.imports?.system;
33
- if (sys?.version !== undefined) {
34
- const v = Number(sys.version);
32
+ const sysVersion = gjsSystemVersion();
33
+ if (sysVersion !== undefined) {
34
+ const v = Number(sysVersion);
35
35
  return `GJS ${Math.floor(v / 10000)}.${Math.floor((v % 10000) / 100)}.${v % 100} (SpiderMonkey)`;
36
36
  }
37
37
  }
38
38
  catch {
39
39
  /* not GJS */
40
40
  }
41
+ if (isBun()) {
42
+ const version = globalThis.Bun?.version ?? process.versions.bun;
43
+ return `Bun ${version ?? ''}`.trim();
44
+ }
45
+ if (isDeno()) {
46
+ const version = globalThis.Deno?.version?.deno;
47
+ return `Deno ${version ?? ''}`.trim();
48
+ }
41
49
  if (isNode()) {
42
50
  return `Node.js ${process.version}`;
43
51
  }
@@ -39,11 +39,17 @@ export const buildCommand = {
39
39
  default: false,
40
40
  })
41
41
  .option('app', {
42
- description: 'Use this if you want to build an application, the platform node is usually only used for tests',
42
+ description: 'Build target for an application. Defaults to the target of the HOST runtime running the CLI: gjs when run under gjs, node when run under node/bun/deno (both consume the --app node bundle). Set `gjsify.app` in package.json#gjsify to override the host default.',
43
43
  type: 'string',
44
44
  choices: ['gjs', 'node', 'browser', 'nativescript'],
45
45
  normalize: true,
46
- default: 'gjs',
46
+ // No yargs `default: 'gjs'` — a yargs default is
47
+ // indistinguishable from a user-set value and would clobber
48
+ // `package.json#gjsify.app` in config.ts (same footgun as
49
+ // `globals`/`entryPoints`). The host-derived fallback is applied
50
+ // post-merge in `Config.forBuild`: CLI flag > config file > host
51
+ // default (`buildAppForRuntime(hostRuntime())`).
52
+ defaultDescription: 'host runtime target (gjs on gjs, else node)',
47
53
  })
48
54
  .option('format', {
49
55
  description: 'Override the default output format',
@@ -160,7 +166,11 @@ export const buildCommand = {
160
166
  const action = new BuildAction(configData);
161
167
  await action.start({
162
168
  library: args.library,
163
- app: args.app,
169
+ // `configData.app` carries the CLI-flag > config-file > host-default
170
+ // resolution done in `Config.forBuild` (the yargs `--app` default was
171
+ // dropped so the config-file value survives). `BuildAction.start`
172
+ // falls back to 'gjs' only if this is somehow undefined.
173
+ app: configData.app,
164
174
  watch: args.watch,
165
175
  });
166
176
  },
@@ -16,22 +16,11 @@
16
16
  // (since the typescript-check binding is positional `[paths..]`).
17
17
  import { spawn, spawnSync } from 'node:child_process';
18
18
  import { prefixLines } from '../utils/prefixed-output.js';
19
- import { existsSync, readFileSync } from 'node:fs';
19
+ import { readPackageJson } from '../utils/pkg-json.js';
20
20
  import { join } from 'node:path';
21
21
  import { cpus } from 'node:os';
22
22
  import { discoverWorkspaces, filterWorkspaces } from '@gjsify/workspace';
23
23
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
24
- function readPackageJson(dir) {
25
- const path = join(dir, 'package.json');
26
- if (!existsSync(path))
27
- return null;
28
- try {
29
- return JSON.parse(readFileSync(path, 'utf-8'));
30
- }
31
- catch {
32
- return null;
33
- }
34
- }
35
24
  /**
36
25
  * Run a single workspace's `check` script. Returns the exit code; non-zero
37
26
  * indicates failure. Output is forwarded to the parent's stdout/stderr,
@@ -108,7 +97,7 @@ export const checkCommand = {
108
97
  // through gjsify so the command surface stays uniform with format/
109
98
  // lint/fix.
110
99
  if (workspaceRoot && cwd !== workspaceRoot) {
111
- const pkg = readPackageJson(cwd);
100
+ const pkg = readPackageJson(join(cwd, 'package.json'));
112
101
  if (pkg?.scripts?.check) {
113
102
  if (args.verbose) {
114
103
  console.log(`[check] cwd=${cwd} → npm run check`);
@@ -1,4 +1,5 @@
1
1
  import type { Command } from '../types/index.js';
2
+ import { type ParsedSpec } from '../utils/parse-spec.js';
2
3
  interface DlxOptions {
3
4
  spec: string;
4
5
  binOrArg?: string;
@@ -10,4 +11,20 @@ interface DlxOptions {
10
11
  registry?: string;
11
12
  }
12
13
  export declare const dlxCommand: Command<unknown, DlxOptions>;
14
+ export interface EnsureOpts {
15
+ verbose: boolean;
16
+ registry?: string;
17
+ cacheMaxAge: number;
18
+ frozen: boolean;
19
+ }
20
+ /**
21
+ * Resolve (installing + caching when remote) the on-disk directory of a package
22
+ * spec — the shared "get me this package's files" step behind `gjsify dlx` and
23
+ * `gjsify showcase`. Local paths resolve directly; remote specs install into the
24
+ * per-key dlx cache (atomic symlink-swap) and reuse a fresh cache entry.
25
+ */
26
+ export declare function ensurePkgDir(parsed: ParsedSpec, opts: EnsureOpts): Promise<{
27
+ pkgDir: string;
28
+ cachedPkgName: string | null;
29
+ }>;
13
30
  export {};
@@ -96,7 +96,13 @@ export const dlxCommand = {
96
96
  await runGjsBundle(entry.bundlePath, extraArgs, { exitOnSuccess: true });
97
97
  },
98
98
  };
99
- async function ensurePkgDir(parsed, opts) {
99
+ /**
100
+ * Resolve (installing + caching when remote) the on-disk directory of a package
101
+ * spec — the shared "get me this package's files" step behind `gjsify dlx` and
102
+ * `gjsify showcase`. Local paths resolve directly; remote specs install into the
103
+ * per-key dlx cache (atomic symlink-swap) and reuse a fresh cache entry.
104
+ */
105
+ export async function ensurePkgDir(parsed, opts) {
100
106
  if (parsed.kind === 'local') {
101
107
  return { pkgDir: parsed.path, cachedPkgName: null };
102
108
  }
@@ -31,12 +31,14 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, syml
31
31
  import { dirname, join, relative, resolve } from 'node:path';
32
32
  import { fileURLToPath } from 'node:url';
33
33
  import { spawn, spawnSync } from 'node:child_process';
34
+ import { gjsExit } from '@gjsify/rolldown-plugin-gjsify/runtime';
34
35
  import { discoverWorkspaces } from '@gjsify/workspace';
35
36
  import { buildInstallCommand, detectPackageManager, runMinimalChecks } from '../utils/check-system-deps.js';
36
37
  import { detectNativePackages } from '../utils/detect-native-packages.js';
37
38
  import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
38
39
  import { atomicWriteStrict } from '../utils/install-cache-fs.js';
39
40
  import { acquireInstallLock } from '../utils/install-lock.js';
41
+ import { nodeBinary } from '../utils/run-node.js';
40
42
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
41
43
  import { binDirOnPath, defaultGlobalLayout, installGjsEnginePackages, linkGlobalBins, specToPackageName, } from '../utils/install-global.js';
42
44
  import { addDependencyEntry, defaultRangeFromVersion, parseSpec, projectSpecsFromPackageJson, readPackageJson, writePackageJson, } from '../utils/pkg-json-edit.js';
@@ -247,11 +249,8 @@ function isAbortedFromOverallTimeout(err) {
247
249
  */
248
250
  function forceExit(code) {
249
251
  process.exitCode = code;
250
- const gjs = globalThis.imports;
251
- if (gjs?.system?.exit) {
252
- gjs.system.exit(code);
252
+ if (gjsExit(code))
253
253
  return;
254
- }
255
254
  process.exit(code);
256
255
  }
257
256
  const CLI_PACKAGE_NAME = '@gjsify/cli';
@@ -1221,7 +1220,13 @@ function maybeInstallGitHooks() {
1221
1220
  if (!existsSync(join(cwd, '.git')))
1222
1221
  return;
1223
1222
  try {
1224
- const result = spawnSync(process.execPath, [scriptPath, '--quiet'], {
1223
+ // `nodeBinary()`, NOT `process.execPath`: under the committed GJS bundle
1224
+ // `process.execPath` is `gjs`, so `spawnSync(process.execPath, [script])`
1225
+ // ran `gjs cli.gjs.mjs install-git-hooks.mjs` (wrong argv). The hook
1226
+ // installer is a plain Node ESM script; run it with a real node binary
1227
+ // (PATH `node` under GJS, the current node otherwise). Best-effort —
1228
+ // wrapped in try/catch, so a missing node just warns and continues.
1229
+ const result = spawnSync(nodeBinary(), [scriptPath, '--quiet'], {
1225
1230
  cwd,
1226
1231
  stdio: 'inherit',
1227
1232
  env: process.env,
@@ -3,6 +3,7 @@ interface RunOptions {
3
3
  target: string;
4
4
  args: string[];
5
5
  workspace?: string;
6
+ runtime?: string;
6
7
  }
7
8
  export declare const runCommand: Command<unknown, RunOptions>;
8
9
  export {};
@@ -13,14 +13,16 @@
13
13
  // extension, or that resolves to an existing path on disk is treated
14
14
  // as a bundle file. Everything else is a script name. Users who want
15
15
  // to disambiguate can pass `./<file>` explicitly.
16
- import { statSync } from 'node:fs';
16
+ import { readFileSync, statSync } from 'node:fs';
17
17
  import { basename, delimiter, join, resolve } from 'node:path';
18
18
  import { spawn } from 'node:child_process';
19
19
  import { runGjsBundle } from '../utils/run-gjs.js';
20
+ import { runRuntimeBundle } from '../utils/run-node.js';
20
21
  import { readPackageJson } from '../utils/pkg-json-edit.js';
21
22
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
22
23
  import { discoverWorkspaces } from '@gjsify/workspace';
23
- import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
24
+ import { isGjs, hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
25
+ import { EXAMPLE_RUNTIMES, isExampleRuntime, readDeclaredRuntimes, checkRuntimeSupported, } from '../utils/runtimes.js';
24
26
  export const runCommand = {
25
27
  command: 'run <target> [args..]',
26
28
  description: 'Run a script from package.json (yarn-run-style) or a GJS bundle file. If <target> resolves to a file on disk (or has a path-like prefix), it is launched via gjs with LD_LIBRARY_PATH + GI_TYPELIB_PATH set for native packages. Otherwise it is looked up in the current package.json `scripts`.',
@@ -45,6 +47,18 @@ export const runCommand = {
45
47
  alias: 'w',
46
48
  type: 'string',
47
49
  description: 'Run <target> as a script in the named workspace (by name or path), like `npm run <script> -w <name>`.',
50
+ })
51
+ .option('runtime', {
52
+ // Runtime selector for a BUNDLE FILE. `gjs` (default) launches
53
+ // the `--app gjs` bundle via `gjs -m`; node/bun/deno launch the
54
+ // `--app node` bundle on that runtime (Node-API is their common
55
+ // ABI, so bun/deno reuse the same node bundle). Declaring it here
56
+ // means yargs captures the flag instead of forwarding it to the
57
+ // target (this command routes unknown flags to the child).
58
+ // Passing --runtime FORCES file mode.
59
+ type: 'string',
60
+ choices: EXAMPLE_RUNTIMES,
61
+ description: 'Runtime to launch a bundle FILE on: gjs (default, `--app gjs` bundle) | node | bun | deno (the `--app node` bundle). Forces file mode.',
48
62
  })
49
63
  // Pass-through runner: the TARGET owns its whole flag namespace, so
50
64
  // disable gjsify's own `--version` / `--help` for THIS command. Without
@@ -85,6 +99,24 @@ export const runCommand = {
85
99
  const positionalArgs = args.args ?? [];
86
100
  const doubleDashArgs = (args['--'] ?? []).filter((v) => typeof v === 'string');
87
101
  const extraArgs = [...positionalArgs, ...doubleDashArgs];
102
+ // `--runtime <gjs|node|bun|deno>` (explicit) → run a BUNDLE FILE on the
103
+ // chosen runtime. This FORCES file mode: the runtime selector only makes
104
+ // sense for a prebuilt bundle, and it must beat the script-name lookup.
105
+ // gjs → `runGjsBundle` (gjs -m); node/bun/deno → `runRuntimeBundle` (the
106
+ // `--app node` bundle; bun/deno reuse it). Default gjs keeps back-compat.
107
+ const runtimeArg = args.runtime;
108
+ if (typeof runtimeArg === 'string' && runtimeArg.length > 0) {
109
+ if (typeof args.workspace === 'string' && args.workspace.length > 0) {
110
+ console.error('gjsify run: --runtime cannot be combined with --workspace (file mode vs script mode).');
111
+ return process.exit(1);
112
+ }
113
+ if (!isExampleRuntime(runtimeArg)) {
114
+ console.error(`gjsify run: unknown --runtime "${runtimeArg}" (expected: ${EXAMPLE_RUNTIMES.join(', ')}).`);
115
+ return process.exit(1);
116
+ }
117
+ await runTargetOnRuntime(runtimeArg, target, extraArgs);
118
+ return;
119
+ }
88
120
  // `-w <name>` (npm/yarn parity): run <target> as a script in the named
89
121
  // workspace's directory rather than the current one. Never file mode.
90
122
  if (typeof args.workspace === 'string' && args.workspace.length > 0) {
@@ -101,14 +133,87 @@ export const runCommand = {
101
133
  const hasScript = pkg?.scripts && typeof pkg.scripts[target] === 'string';
102
134
  if (!hasScript && looksLikeFile(target)) {
103
135
  const file = resolve(target);
104
- // Terminal call — exit on success, or the GJS main loop parks
136
+ // The default runtime for a bare `gjsify run <file>` FOLLOWS the host
137
+ // runtime the CLI executes in: gjs (global install) → gjs; node/bun/
138
+ // deno (npx/bunx/deno) → that runtime. EXCEPTION: a `--app gjs` bundle
139
+ // can only run on gjs (its gi:// imports are externalized, there is no
140
+ // node-gi shim), so it is always launched on gjs regardless of the
141
+ // host — this keeps `gjsify run dist/index.gjs.js` working when the
142
+ // CLI itself runs under node (e.g. `gjsify foreach test` for the
143
+ // node-cli examples). Pass `--runtime` explicitly to override.
144
+ const host = hostRuntime();
145
+ const runtime = host === 'gjs' || isLikelyGjsBundle(file) ? 'gjs' : host;
146
+ // Terminal calls — exit on success, or the GJS main loop parks
105
147
  // this process forever (see RunGjsBundleOptions.exitOnSuccess).
106
- await runGjsBundle(file, extraArgs, { exitOnSuccess: true });
148
+ if (runtime === 'gjs') {
149
+ await runGjsBundle(file, extraArgs, { exitOnSuccess: true });
150
+ }
151
+ else {
152
+ await runRuntimeBundle(runtime, file, extraArgs, { exitOnSuccess: true });
153
+ }
107
154
  return;
108
155
  }
109
156
  await runScript(target, extraArgs);
110
157
  },
111
158
  };
159
+ /**
160
+ * Run a bundle-file `target` on `runtime`. Validates that the target looks like
161
+ * a file (the runtime selector is for prebuilt bundles, not script names) and,
162
+ * best-effort, that the cwd package's `gjsify.example.runtimes` declaration
163
+ * permits the runtime — turning "this showcase can't run on node yet" into a
164
+ * clean, actionable error instead of a deep bundle crash. gjs → `runGjsBundle`;
165
+ * node/bun/deno → `runRuntimeBundle` (the shared `--app node` launcher).
166
+ */
167
+ async function runTargetOnRuntime(runtime, target, extraArgs) {
168
+ if (!looksLikeFile(target)) {
169
+ console.error(`gjsify run: --runtime ${runtime} needs a bundle FILE, but "${target}" is not a path.\n` +
170
+ ` Build first (e.g. \`gjsify build src/app.ts --app ${runtime === 'gjs' ? 'gjs' : 'node'} --outfile dist/app.${runtime === 'gjs' ? 'gjs' : 'node'}.mjs\`), then run the output file.`);
171
+ return process.exit(1);
172
+ }
173
+ // Best-effort declaration check against the cwd package (the example dir).
174
+ const pkg = readPackageJson(join(process.cwd(), 'package.json'));
175
+ const declared = readDeclaredRuntimes(pkg);
176
+ const support = checkRuntimeSupported(runtime, declared, pkg?.name ?? target);
177
+ if (!support.ok) {
178
+ console.error(`gjsify run: ${support.message}`);
179
+ return process.exit(1);
180
+ }
181
+ const file = resolve(target);
182
+ // Terminal call — exit on success (the GJS/GLib loop would otherwise park).
183
+ if (runtime === 'gjs') {
184
+ await runGjsBundle(file, extraArgs, { exitOnSuccess: true });
185
+ }
186
+ else {
187
+ await runRuntimeBundle(runtime, file, extraArgs, { exitOnSuccess: true });
188
+ }
189
+ }
190
+ /**
191
+ * Whether `file` is a `--app gjs` bundle (which can ONLY run on gjs — its
192
+ * `gi://` imports stay as external string literals and boots through a `gjs -m`
193
+ * shebang / `imports.system` stub). Used to keep such a bundle on gjs even when
194
+ * the host is node/bun/deno, so the host-following default in the bare-file run
195
+ * path never sends a gjs bundle to a runtime it cannot load.
196
+ *
197
+ * A `--app node` bundle (even a node-gi reverse-bridge one) rewrites `gi://` to
198
+ * `@gjsify/node-gi` and never keeps a `gi://` import literal, so it is NOT
199
+ * matched here and follows the host runtime.
200
+ */
201
+ function isLikelyGjsBundle(file) {
202
+ // Fast path on the conventional `.gjs.js` / `.gjs.mjs` naming.
203
+ if (/\.gjs\.[mc]?js$/.test(file))
204
+ return true;
205
+ try {
206
+ // gi:// imports + the process/system stub are hoisted to the top of the
207
+ // bundle, so a bounded prefix is enough — bundles can be several MB.
208
+ const head = readFileSync(file, 'utf-8').slice(0, 65536);
209
+ return (head.startsWith('#!/usr/bin/env -S gjs') ||
210
+ /(?:from|import)\s*["']gi:\/\//.test(head) ||
211
+ /\bimports\.(?:gi|system)\b/.test(head));
212
+ }
213
+ catch {
214
+ return false;
215
+ }
216
+ }
112
217
  function looksLikeFile(target) {
113
218
  if (target.startsWith('./') || target.startsWith('../') || target.startsWith('/'))
114
219
  return true;
@@ -3,6 +3,7 @@ interface ShowcaseOptions {
3
3
  name?: string;
4
4
  json: boolean;
5
5
  list: boolean;
6
+ runtime?: string;
6
7
  }
7
8
  export declare const showcaseCommand: Command<unknown, ShowcaseOptions>;
8
9
  export {};
@@ -1,8 +1,14 @@
1
1
  import { discoverShowcases, findShowcase } from '../utils/discover-showcases.js';
2
2
  import { runMinimalChecks, detectPackageManager, buildInstallCommand } from '../utils/check-system-deps.js';
3
- import { spawn } from 'node:child_process';
4
- import { fileURLToPath } from 'node:url';
5
- import { readFileSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { dirname } from 'node:path';
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { ensurePkgDir } from './dlx.js';
7
+ import { parseSpec } from '../utils/parse-spec.js';
8
+ import { resolveNodeEntry } from '../utils/resolve-gjs-entry.js';
9
+ import { runRuntimeBundle } from '../utils/run-node.js';
10
+ import { hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
11
+ import { EXAMPLE_RUNTIMES, isExampleRuntime, readDeclaredRuntimes, checkRuntimeSupported, } from '../utils/runtimes.js';
6
12
  function readCliVersion() {
7
13
  try {
8
14
  const pkgUrl = new URL('../../package.json', import.meta.url);
@@ -30,6 +36,20 @@ export const showcaseCommand = {
30
36
  description: 'List available showcases',
31
37
  type: 'boolean',
32
38
  default: false,
39
+ })
40
+ .option('runtime', {
41
+ // Runtime selector, mirroring `gjsify storybook --runtime`. The
42
+ // default FOLLOWS the host runtime the CLI executes in (gjs under
43
+ // gjs, node under node/bun/deno). gjs runs the showcase's GJS
44
+ // bundle via `gjsify dlx`; node/bun/deno run its `--app node`
45
+ // bundle on that runtime. Validated against the showcase's
46
+ // declared `gjsify.example.runtimes` — a GTK-only showcase
47
+ // requested under node fails with a clear message, not a crash.
48
+ type: 'string',
49
+ choices: EXAMPLE_RUNTIMES,
50
+ default: hostRuntime(),
51
+ defaultDescription: 'host runtime (gjs on gjs, else node/bun/deno)',
52
+ description: 'Runtime to run the showcase on: gjs | node | bun | deno (default: the host runtime). node/bun/deno run its `--app node` bundle.',
33
53
  }),
34
54
  handler: async (args) => {
35
55
  // List mode: no name given, or --list flag
@@ -67,7 +87,12 @@ export const showcaseCommand = {
67
87
  if (!showcase) {
68
88
  console.error(`Unknown showcase: "${args.name}"`);
69
89
  console.error('Run "gjsify showcase" to list available showcases.');
70
- process.exit(1);
90
+ // `return process.exit()`: a bare `process.exit()` does NOT halt
91
+ // synchronously under GJS (no atexit, GLib loop still armed), so
92
+ // execution would fall through to `showcase.packageName` below on an
93
+ // undefined `showcase` → throw racing the scheduled exit → the
94
+ // `m_should_exit` core dump. Returning halts the handler here.
95
+ return process.exit(1);
71
96
  }
72
97
  // System dependency check before delegating — only system libs (gjs,
73
98
  // gtk4, …). The showcase's npm deps (incl. `@gjsify/webgl` with the
@@ -88,11 +113,25 @@ export const showcaseCommand = {
88
113
  if (cmd) {
89
114
  console.error(`\nInstall with:\n ${cmd}`);
90
115
  }
91
- process.exit(1);
116
+ // `return` — bare `process.exit` falls through under GJS (see above).
117
+ return process.exit(1);
118
+ }
119
+ const runtime = (args.runtime ?? hostRuntime());
120
+ if (!isExampleRuntime(runtime)) {
121
+ console.error(`Unknown --runtime "${runtime}" (expected: ${EXAMPLE_RUNTIMES.join(', ')}).`);
122
+ // `return` — bare `process.exit` falls through under GJS (see above).
123
+ return process.exit(1);
124
+ }
125
+ const cliVersion = readCliVersion();
126
+ // Non-gjs runtime: resolve the showcase's `--app node` bundle and run it
127
+ // on node/bun/deno. Handled in-process (not via the GJS-only `dlx`).
128
+ if (runtime !== 'gjs') {
129
+ await runShowcaseOnRuntime(showcase, runtime, cliVersion);
130
+ return;
92
131
  }
93
- // Delegate to `gjsify dlx <package>@<cli-version>` — same npm-cache,
94
- // same atomic symlink-swap, same `gjsify.main` resolution. Re-spawning
95
- // the CLI keeps the dlx logic in one place.
132
+ // gjs runtime: delegate to `gjsify dlx <package>@<cli-version>` — same
133
+ // npm-cache, same atomic symlink-swap, same `gjsify.main` resolution.
134
+ // Dispatched in-process to keep the dlx logic in one place.
96
135
  //
97
136
  // Pinning to the CLI's own version is load-bearing: showcases ship in
98
137
  // lockstep with the CLI, so users running `npx @gjsify/cli@X showcase
@@ -102,26 +141,87 @@ export const showcaseCommand = {
102
141
  // and the user gets a stale showcase that may be missing deps the
103
142
  // newer CLI assumes (the `@gjsify/http-soup-bridge` regression
104
143
  // reported against `@gjsify/cli@0.3.17`).
105
- const cliVersion = readCliVersion();
106
144
  const dlxSpec = cliVersion ? `${showcase.packageName}@${cliVersion}` : showcase.packageName;
107
145
  console.log(`Running showcase: ${showcase.name} (via gjsify dlx ${dlxSpec})\n`);
108
- const cliBin = fileURLToPath(new URL('../index.js', import.meta.url));
109
- const child = spawn(process.execPath, [cliBin, 'dlx', dlxSpec], {
110
- stdio: 'inherit',
111
- });
112
- await new Promise((resolvePromise, reject) => {
113
- child.on('close', (code) => {
114
- if (code !== 0) {
115
- reject(new Error(`gjsify dlx exited with code ${code}`));
116
- }
117
- else {
118
- resolvePromise();
119
- }
120
- });
121
- child.on('error', reject);
122
- }).catch((err) => {
146
+ // Dispatch `dlx` IN-PROCESS (not `spawn(process.execPath, [cliBin, …])`):
147
+ // under the committed GJS bundle `process.execPath` is `gjs` and
148
+ // `../index.js` is the Node entry, so the spawn ran `gjs cli.gjs.mjs
149
+ // index.js dlx …` (yargs saw `index.js` as the command) — the
150
+ // CLAUDE.md-flagged trap. In-process runCli works identically under Node
151
+ // and GJS and stays node-free (same fix `gjsify storybook` uses).
152
+ try {
153
+ const { runCli } = await import('../cli-app.js');
154
+ await runCli(['dlx', dlxSpec]);
155
+ }
156
+ catch (err) {
123
157
  console.error(err.message);
124
158
  process.exit(1);
125
- });
159
+ }
126
160
  },
127
161
  };
162
+ /**
163
+ * Resolve a showcase package's on-disk directory. Prefers a LOCAL install (a
164
+ * workspace member symlinked in node_modules — no network, and it runs the
165
+ * in-tree build), falling back to `gjsify dlx`'s install-into-cache for a
166
+ * published showcase. Returns the directory containing the package.json.
167
+ */
168
+ async function resolveShowcaseDir(showcase, cliVersion) {
169
+ // Local-first: a workspace showcase resolves via its own package name.
170
+ try {
171
+ const req = createRequire(import.meta.url);
172
+ const localPkgJson = req.resolve(`${showcase.packageName}/package.json`);
173
+ if (existsSync(localPkgJson))
174
+ return dirname(localPkgJson);
175
+ }
176
+ catch {
177
+ // Not resolvable locally — fall through to dlx install.
178
+ }
179
+ const spec = cliVersion ? `${showcase.packageName}@${cliVersion}` : showcase.packageName;
180
+ const { pkgDir } = await ensurePkgDir(parseSpec(spec), {
181
+ verbose: false,
182
+ cacheMaxAge: 60 * 24 * 7,
183
+ frozen: false,
184
+ });
185
+ return pkgDir;
186
+ }
187
+ /**
188
+ * Run a showcase on a non-gjs runtime (node/bun/deno). Resolves the package,
189
+ * validates it against the showcase's `gjsify.example.runtimes` declaration
190
+ * (clean error if the runtime is unsupported — e.g. a GTK/Adw showcase under
191
+ * node), resolves its `--app node` bundle and launches it via the shared runner.
192
+ */
193
+ async function runShowcaseOnRuntime(showcase, runtime, cliVersion) {
194
+ let pkgDir;
195
+ try {
196
+ pkgDir = await resolveShowcaseDir(showcase, cliVersion);
197
+ }
198
+ catch (err) {
199
+ console.error(`Could not resolve showcase "${showcase.name}": ${err.message}`);
200
+ return process.exit(1);
201
+ }
202
+ // Declaration check — clear, actionable error for an unsupported runtime.
203
+ let pkg = {};
204
+ try {
205
+ pkg = JSON.parse(readFileSync(`${pkgDir}/package.json`, 'utf-8'));
206
+ }
207
+ catch {
208
+ // Fall through — no declaration means permissive.
209
+ }
210
+ const declared = readDeclaredRuntimes(pkg);
211
+ const support = checkRuntimeSupported(runtime, declared, showcase.name);
212
+ if (!support.ok) {
213
+ console.error(support.message);
214
+ return process.exit(1);
215
+ }
216
+ let entry;
217
+ try {
218
+ entry = resolveNodeEntry(pkgDir);
219
+ }
220
+ catch (err) {
221
+ console.error(`Cannot run showcase "${showcase.name}" on ${runtime}: ${err.message}`);
222
+ return process.exit(1);
223
+ }
224
+ console.log(`Running showcase: ${showcase.name} on ${runtime} (${entry})\n`);
225
+ // Terminal call — exit on success.
226
+ await runRuntimeBundle(runtime, entry, [], { exitOnSuccess: true });
227
+ }