@gjsify/cli 0.13.1 → 0.14.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.
@@ -2,7 +2,7 @@ import { runBundle, runWatch, bundleToChunks } from '../bundler-pick.js';
2
2
  import { gjsifyPlugin, textLoaderPlugin, resolveShebangLine, NODE_SHEBANG } from '@gjsify/rolldown-plugin-gjsify';
3
3
  import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
4
4
  import { resolveUserPlugins } from '../utils/resolve-plugin-by-name.js';
5
- import { resolveGlobalsList, writeRegisterInjectFile, detectAutoGlobals } from '@gjsify/rolldown-plugin-gjsify/globals';
5
+ import { resolveGlobalsList, writeRegisterInjectFile, detectAutoGlobals, detectNodeGiGlobals, } from '@gjsify/rolldown-plugin-gjsify/globals';
6
6
  import { pnpPlugin } from '@gjsify/rolldown-plugin-pnp';
7
7
  import { dirname, extname, join } from 'node:path';
8
8
  import { pathToFileURL } from 'node:url';
@@ -402,6 +402,36 @@ export class BuildAction {
402
402
  { extraGlobalsList: extras, excludeGlobals, cwd: process.cwd() }, bundleToChunks);
403
403
  pluginOpts.autoGlobalsInject = injectPath;
404
404
  }
405
+ else if (app === 'node' && autoMode) {
406
+ // `--app node` reverse direction: detect GJS ambient globals
407
+ // (`print`/`imports`/…) in the bundled, tree-shaken output and
408
+ // inject `@gjsify/node-gi/globals` only when present. A single
409
+ // in-memory analysis pass (the shim is external — no convergence
410
+ // loop). Gated on `autoMode` so `--globals none` opts out. The
411
+ // detection must run BEFORE the final build so the entry can be
412
+ // wrapped; nothing is injected when no globals are referenced (the
413
+ // eager-native-load regression guard).
414
+ const gjsifyPluginFactory = async (opts) => {
415
+ const cfg = await gjsifyPlugin({
416
+ input: userBundler.input,
417
+ output: { file: outfile, dir: outdir },
418
+ userExternal,
419
+ userBanner,
420
+ userAliases: aliases,
421
+ shebang: this.configData.shebang,
422
+ }, opts);
423
+ return { options: cfg.options, plugins: cfg.plugins };
424
+ };
425
+ const needsGiGlobals = await detectNodeGiGlobals({
426
+ input: userBundler.input,
427
+ plugins: [...pnpPlugins, ...userPlugins],
428
+ external: userBundler.external,
429
+ transform: userBundler.transform,
430
+ format,
431
+ }, pluginOpts, gjsifyPluginFactory, bundleToChunks);
432
+ if (needsGiGlobals)
433
+ pluginOpts.nodeGiGlobalsInject = true;
434
+ }
405
435
  else if (extras) {
406
436
  pluginOpts.autoGlobalsInject = await this.resolveGlobalsInject(app, extras, verbose);
407
437
  }
@@ -119,8 +119,15 @@ const IGNORE = [
119
119
  /^refs\//,
120
120
  /^website\//,
121
121
  /^docs\//,
122
- /^\.github\/workflows\/(deploy-docs|commitlint|release|audit-runtimes|prebuilds)\.yml$/,
122
+ /^\.github\/workflows\/(deploy-docs|commitlint|release|audit-runtimes|prebuilds|node-gi)\.yml$/,
123
123
  /^\.githooks\//,
124
+ // @gjsify/node-gi (the Node-native GObject-Introspection engine, Axis 5) is
125
+ // NOT a gjsify workspace member — the GJS-first install/foreach tooling can't
126
+ // build its node-gyp addon, so the main workflow neither builds nor tests it.
127
+ // Its own `.github/workflows/node-gi.yml` is the source of truth (built +
128
+ // tested on Node). Without this carve-out its files map to no workspace and
129
+ // land in `unmatched`, forcing a conservative full run on every node-gi PR.
130
+ /^packages\/node-gi\//,
124
131
  // Flatpak build/distribution tooling (SDK-extension manifest + metainfo).
125
132
  // Like `.githooks/`, it has no package-test consumers; its own
126
133
  // `tests/e2e/flatpak-sdk-extension` runs on `tests/e2e/**` / global triggers.
@@ -619,6 +619,11 @@ async function workspaceInstall(cwd, args, signal) {
619
619
  overrides,
620
620
  signal,
621
621
  progress,
622
+ // Workspace-named packages are symlinked above — the native backend
623
+ // must never fetch a same-named published version over those
624
+ // symlinks (e.g. a `types-dev/<lib>` workspace that also exists on
625
+ // npm and is pinned transitively in the lockfile).
626
+ workspaceNames: new Set(byName.keys()),
622
627
  });
623
628
  }
624
629
  else if (args.verbose) {
@@ -644,6 +649,7 @@ async function workspaceInstall(cwd, args, signal) {
644
649
  lockfile: !args.immutable,
645
650
  frozen: args.immutable,
646
651
  signal,
652
+ workspaceNames: new Set(byName.keys()),
647
653
  });
648
654
  }
649
655
  // Re-wire workspace symlinks once more now that the external + scoped
@@ -5,6 +5,7 @@ interface StorybookCliOptions {
5
5
  title?: string;
6
6
  globals?: string;
7
7
  out?: string;
8
+ runtime?: string;
8
9
  watch: boolean;
9
10
  buildOnly: boolean;
10
11
  }
@@ -1,7 +1,14 @@
1
1
  // `gjsify storybook` — discover every *.story.ts in a project, generate a tiny
2
- // entry that registers them, then build (`--app gjs`) and launch the generic
2
+ // entry that registers them, then build and launch the generic
3
3
  // @gjsify/storybook component browser. No per-project storybook app needed.
4
4
  //
5
+ // `--runtime gjs` (default) builds `--app gjs` and runs it with `gjs -m`.
6
+ // `--runtime node` builds `--app node` (gi:// → @gjsify/node-gi reverse bridge)
7
+ // and runs it with `node` — the SAME storybook source rendering on Node.js. The
8
+ // node path wires GI_TYPELIB_PATH/LD_LIBRARY_PATH like `gjsify run` (so any
9
+ // native @gjsify bridge a story uses resolves) and requires `@gjsify/node-gi`
10
+ // installed in the project (kept external by the bundler, a native addon).
11
+ //
5
12
  // Dispatch is IN-PROCESS (runCli + runGjsBundle), not a re-spawn of the CLI:
6
13
  // the showcase-style `spawn(process.execPath, [cliBin, …])` pattern assumes
7
14
  // Node, but under the committed GJS bundle `process.execPath` is `gjs` and the
@@ -82,6 +89,13 @@ export const storybookCommand = {
82
89
  .option('out', {
83
90
  description: 'Output bundle path (default: node_modules/.cache/gjsify-storybook)',
84
91
  type: 'string',
92
+ })
93
+ .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.',
96
+ type: 'string',
97
+ choices: ['gjs', 'node'],
98
+ default: 'gjs',
85
99
  })
86
100
  .option('watch', {
87
101
  description: 'Rebuild and relaunch when a story file changes',
@@ -97,10 +111,16 @@ export const storybookCommand = {
97
111
  const applicationId = args.appId ?? config.applicationId ?? deriveAppId(pkg.name);
98
112
  const title = args.title ?? config.title;
99
113
  const globals = args.globals ?? config.globals ?? 'auto';
114
+ const runtime = (args.runtime ?? config.runtime ?? 'gjs');
115
+ // 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';
100
120
  const cacheDir = join(cwd, 'node_modules', '.cache', 'gjsify-storybook');
101
121
  mkdirSync(cacheDir, { recursive: true });
102
122
  const entryPath = join(cacheDir, 'entry.ts');
103
- const outPath = args.out ? resolve(cwd, args.out) : join(cacheDir, 'storybook.gjs.mjs');
123
+ const outPath = args.out ? resolve(cwd, args.out) : join(cacheDir, `storybook.${app}.mjs`);
104
124
  const writeEntry = () => {
105
125
  const files = findStoryFiles(storiesDir).sort();
106
126
  writeFileSync(entryPath, generateEntry(files, applicationId, title));
@@ -111,24 +131,46 @@ export const storybookCommand = {
111
131
  console.error(`No *.story.ts files found under ${relative(cwd, storiesDir) || storiesDir}`);
112
132
  process.exit(1);
113
133
  }
114
- console.log(`Found ${initialCount} story file(s) under ${relative(cwd, storiesDir) || '.'}`);
134
+ console.log(`Found ${initialCount} story file(s) under ${relative(cwd, storiesDir) || '.'} (runtime: ${runtime})`);
115
135
  // In-process build — runtime-agnostic (Node uses npm rolldown, GJS uses
116
136
  // the @gjsify/rolldown-native facade), no re-spawn of the CLI binary.
117
137
  const { runCli } = await import('../cli-app.js');
118
- const buildBundle = () => runCli(['build', entryPath, '--app', 'gjs', '--globals', globals, '--outfile', outPath]);
138
+ const buildBundle = () => runCli(['build', entryPath, '--app', app, '--globals', globals, '--outfile', outPath]);
139
+ const launch = async () => {
140
+ if (runtime === 'node') {
141
+ const { runNodeBundle } = await import('../utils/run-node.js');
142
+ await runNodeBundle(outPath, [], { exitOnSuccess: true });
143
+ }
144
+ else {
145
+ const { runGjsBundle } = await import('../utils/run-gjs.js');
146
+ await runGjsBundle(outPath, [], { exitOnSuccess: true });
147
+ }
148
+ };
119
149
  if (!args.watch) {
120
150
  await buildBundle();
121
151
  if (args.buildOnly)
122
152
  return;
123
- const { runGjsBundle } = await import('../utils/run-gjs.js');
124
- await runGjsBundle(outPath, [], { exitOnSuccess: true });
153
+ await launch();
125
154
  return;
126
155
  }
127
156
  // Watch mode: rebuild + relaunch on any change under the stories dir.
128
157
  // The storybook bundle only needs system Gtk/Adw typelibs at runtime
129
- // (no @gjsify native bridges), so a plain `gjs -m` child is enough and
130
- // stays killable for relaunch.
158
+ // (no @gjsify native bridges), so a plain `gjs -m` / `node` child is
159
+ // enough and stays killable for relaunch.
131
160
  const { watch } = await import('node:fs');
161
+ const spawnChild = async () => {
162
+ 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 } });
171
+ }
172
+ return spawn('gjs', ['-m', outPath], { stdio: 'inherit' });
173
+ };
132
174
  let child = null;
133
175
  let rebuilding = false;
134
176
  let pending = false;
@@ -146,7 +188,7 @@ export const storybookCommand = {
146
188
  }
147
189
  await buildBundle();
148
190
  if (!args.buildOnly) {
149
- child = spawn('gjs', ['-m', outPath], { stdio: 'inherit' });
191
+ child = await spawnChild();
150
192
  }
151
193
  }
152
194
  catch (err) {
@@ -21,6 +21,7 @@ import { DEFAULT_REGISTRY, fetchPackument, fetchPackumentConditional, fetchTarba
21
21
  import { extractTarball } from '@gjsify/tar';
22
22
  import { getCachedTarball, getForeignCachedTarball, putCachedTarball, } from './install-tarball-cache.js';
23
23
  import { getCachedPackument, putCachedPackument } from './install-packument-cache.js';
24
+ import { assertNativeBackendNodeVersion } from './node-version.js';
24
25
  // 16-wide download pool. Matched to the shared Soup.Session's lifted
25
26
  // `max-conns-per-host` (see @gjsify/fetch `getSharedSession`) — a higher pool
26
27
  // here only translates into real concurrency because that cap was raised from
@@ -30,6 +31,9 @@ const DEFAULT_CONCURRENCY = Number(process.env.GJSIFY_INSTALL_CONCURRENCY ?? '16
30
31
  const LOCKFILE_NAME = 'gjsify-lock.json';
31
32
  const LOCKFILE_VERSION = 2;
32
33
  export async function installPackagesNative(opts) {
34
+ // Fail clearly on an unsupported Node major BEFORE touching the ABI-locked
35
+ // native deps — otherwise they SIGSEGV mid-extract with no actionable message.
36
+ assertNativeBackendNodeVersion();
33
37
  if (opts.specs.length === 0) {
34
38
  throw new Error('installPackagesNative: empty specs list');
35
39
  }
@@ -72,12 +76,29 @@ export async function installPackagesNative(opts) {
72
76
  // (and silently bumping transitive deps) on a one-package add.
73
77
  const preferred = !opts.refreshLockfile && existingLock ? buildPreferredVersions(existingLock) : undefined;
74
78
  log('install: resolving %d top-level spec(s) → %s%s', opts.specs.length, opts.prefix, preferred ? ` (preserving ${preferred.size} locked name(s))` : '');
75
- nodes = await resolveDeps(opts.specs, npmrc, log, opts.overrides, opts.skipDeps, opts.signal, progress, preferred);
79
+ nodes = await resolveDeps(opts.specs, npmrc, log, opts.overrides, opts.skipDeps, opts.signal, progress, preferred, opts.workspaceNames);
76
80
  if (opts.lockfile) {
77
81
  writeLockfile(lockfilePath, opts.specs, nodes);
78
82
  log('install: wrote %s (%d entries)', LOCKFILE_NAME, nodes.length);
79
83
  }
80
84
  }
85
+ // A package whose name is one of the monorepo's own workspaces is provided
86
+ // by a workspace symlink (wired by `workspaceInstall`), NOT by a registry
87
+ // tarball — even when the lockfile or a transitive edge pins a same-named
88
+ // published version. Drop those nodes from the fetch/extract set so
89
+ // `extractOne` never `rm`s + overwrites the workspace source symlink (its
90
+ // data-loss guard would otherwise abort the whole install). This keeps
91
+ // `--immutable` robust against a committed lockfile that still carries such
92
+ // registry entries (it is built from the lockfile verbatim and never runs
93
+ // `resolveDeps`); a fresh resolve additionally skips them at the source.
94
+ if (opts.workspaceNames && opts.workspaceNames.size > 0) {
95
+ const before = nodes.length;
96
+ nodes = nodes.filter((n) => !opts.workspaceNames.has(n.name));
97
+ const dropped = before - nodes.length;
98
+ if (dropped > 0) {
99
+ log('install: %d workspace-provided package(s) symlinked, not fetched', dropped);
100
+ }
101
+ }
81
102
  log('install: downloading %d tarball(s)', nodes.length);
82
103
  await downloadAndExtractAll(nodes, opts.prefix, npmrc, log, opts.signal, progress);
83
104
  await linkBins(nodes, opts.prefix, log);
@@ -143,7 +164,14 @@ async function resolveDeps(specs, npmrc, log, overrides, skipDeps, signal, progr
143
164
  * Undefined ⇒ a fresh resolve that always picks the newest in-range version
144
165
  * (first install, or an explicit `--refresh-lockfile`).
145
166
  */
146
- preferredVersions) {
167
+ preferredVersions,
168
+ /**
169
+ * Names of the monorepo's own workspace packages. An edge whose name is in
170
+ * this set is satisfied by the workspace symlink, so it is skipped here —
171
+ * the published version (and its subtree) never enters the resolved tree or
172
+ * the lockfile.
173
+ */
174
+ workspaceNames) {
147
175
  progress?.beginPhase('resolve', specs.length);
148
176
  const applyOverride = (name, range) => {
149
177
  if (!overrides)
@@ -209,6 +237,11 @@ preferredVersions) {
209
237
  // cache instead of blocking on a fresh round-trip.
210
238
  for (let wi = 0; wi < wave.length; wi++) {
211
239
  const edge = wave[wi];
240
+ // A workspace member is satisfied by its workspace symlink, never by
241
+ // a registry tarball — skip the edge so the published version (and
242
+ // its subtree) never enters the resolved tree or the lockfile.
243
+ if (workspaceNames?.has(edge.name))
244
+ continue;
212
245
  // Walk the ancestor chain to see whether a satisfying placement is
213
246
  // already visible from the requester's `node_modules` lookup. npm's
214
247
  // resolver does this — each level of nesting acts as a fallback.
@@ -70,6 +70,19 @@ export interface InstallOptions {
70
70
  * to override.
71
71
  */
72
72
  progress?: ProgressReporter;
73
+ /**
74
+ * Native backend only: the names of the monorepo's own workspace packages.
75
+ * A package whose name is in this set is materialised as a symlink to its
76
+ * workspace source by `workspaceInstall`, never fetched from the registry —
77
+ * even when the lockfile or a transitive edge references a same-named
78
+ * published version (e.g. a `types-dev/<lib>` workspace that ALSO exists on
79
+ * npm). The native backend drops such names from `resolveDeps` AND from the
80
+ * fetch/extract set, so `extractOne` never tries to `rm` + overwrite a
81
+ * workspace source symlink (its data-loss guard would otherwise abort the
82
+ * whole install). The `npm` backend ignores this — npm resolves workspaces
83
+ * itself.
84
+ */
85
+ workspaceNames?: Set<string>;
73
86
  }
74
87
  export interface InstallResult {
75
88
  /** Top-level packages that were requested, with the version each
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Minimum Node.js major the native install backend supports — its prebuilt native
3
+ * modules are ABI-locked to this line (kept in sync with `.nvmrc`).
4
+ */
5
+ export declare const MIN_NODE_MAJOR = 24;
6
+ /** Parse the major from a `process.versions.node`-style string (`'24.18.0'` → 24). */
7
+ export declare function parseNodeMajor(version: string): number;
8
+ /**
9
+ * The preflight verdict for the native install backend, factored out so it can be
10
+ * unit-tested without spawning a foreign Node. Returns an error message when the
11
+ * runtime is real Node older than {@link MIN_NODE_MAJOR}, else `null` — i.e. on a
12
+ * supported Node, on GJS (which fakes `process.versions.node` and does not use
13
+ * these prebuilds), or on a version string that does not parse.
14
+ */
15
+ export declare function nativeBackendNodeError(version: string, onRealNode: boolean): string | null;
16
+ /**
17
+ * Guard the native install backend: throw a clear error on an unsupported Node
18
+ * major instead of letting the ABI-mismatched prebuilds SIGSEGV mid-install.
19
+ * No-op under GJS and on supported Node.
20
+ */
21
+ export declare function assertNativeBackendNodeVersion(): void;
@@ -0,0 +1,56 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Node-version preflight for the native install backend.
3
+ //
4
+ // The native backend composes native deps whose prebuilt binaries are ABI-locked
5
+ // to the Node line gjsify targets (see `.nvmrc`). On an older Node major they load
6
+ // a mismatched ABI and SIGSEGV mid-install instead of failing cleanly — a
7
+ // confusing crash with no actionable message (observed: Node 22 in a Fedora
8
+ // container segfaults `gjsify install --backend native` during download/extract).
9
+ // This preflight turns that into a clear, actionable error.
10
+ import { isNode } from '@gjsify/rolldown-plugin-gjsify/runtime';
11
+ /**
12
+ * Minimum Node.js major the native install backend supports — its prebuilt native
13
+ * modules are ABI-locked to this line (kept in sync with `.nvmrc`).
14
+ */
15
+ export const MIN_NODE_MAJOR = 24;
16
+ /** Parse the major from a `process.versions.node`-style string (`'24.18.0'` → 24). */
17
+ export function parseNodeMajor(version) {
18
+ return Number.parseInt(String(version).split('.', 1)[0] ?? '', 10);
19
+ }
20
+ /**
21
+ * The preflight verdict for the native install backend, factored out so it can be
22
+ * unit-tested without spawning a foreign Node. Returns an error message when the
23
+ * runtime is real Node older than {@link MIN_NODE_MAJOR}, else `null` — i.e. on a
24
+ * supported Node, on GJS (which fakes `process.versions.node` and does not use
25
+ * these prebuilds), or on a version string that does not parse.
26
+ */
27
+ export function nativeBackendNodeError(version, onRealNode) {
28
+ if (!onRealNode)
29
+ return null;
30
+ const major = parseNodeMajor(version);
31
+ if (!Number.isFinite(major) || major >= MIN_NODE_MAJOR)
32
+ return null;
33
+ return (`gjsify install (native backend) requires Node ≥ ${MIN_NODE_MAJOR}, but this is Node ${version}. ` +
34
+ `Its prebuilt native modules are ABI-locked to Node ${MIN_NODE_MAJOR} and crash on older majors. ` +
35
+ `Use Node ${MIN_NODE_MAJOR} (nvm/asdf locally, actions/setup-node in CI), or run with --backend npm.`);
36
+ }
37
+ /** True when the CLI runs on real Node — NOT GJS, which fakes `process.versions.node`. */
38
+ function onRealNode() {
39
+ // GJS exposes `imports.system.version`; @gjsify/process fakes
40
+ // `process.versions.node = '20.0.0'` for compat, so check GJS FIRST — a plain
41
+ // `process.versions.node` read would be a false Node positive under GJS.
42
+ const sysVersion = globalThis.imports?.system?.version;
43
+ if (sysVersion !== undefined)
44
+ return false;
45
+ return isNode();
46
+ }
47
+ /**
48
+ * Guard the native install backend: throw a clear error on an unsupported Node
49
+ * major instead of letting the ABI-mismatched prebuilds SIGSEGV mid-install.
50
+ * No-op under GJS and on supported Node.
51
+ */
52
+ export function assertNativeBackendNodeVersion() {
53
+ const message = nativeBackendNodeError(process.versions.node, onRealNode());
54
+ if (message)
55
+ throw new Error(message);
56
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Walk up from `startDir` looking for `<node_modules>/@gjsify/node-gi`. Returns
3
+ * the package directory, or null when it is not installed anywhere on the chain.
4
+ * A plain fs walk (works identically on Node and GJS) — Node's own resolver is
5
+ * not available under the GJS-bundled CLI.
6
+ */
7
+ export declare function resolveNodeGi(startDir: string): string | null;
8
+ /** Pick the node executable: the current one when on Node, else PATH `node`. */
9
+ export declare function nodeBinary(): string;
10
+ export interface RunNodeBundleOptions {
11
+ /** Exit this process with code 0 once the child succeeds (terminal callers). */
12
+ exitOnSuccess?: boolean;
13
+ }
14
+ /**
15
+ * Run a `--app node` bundle on Node.js, auto-setting LD_LIBRARY_PATH and
16
+ * GI_TYPELIB_PATH for any installed native gjsify packages (same detection as
17
+ * {@link runGjsBundle}). Verifies `@gjsify/node-gi` is resolvable first and
18
+ * throws a clear, actionable error otherwise.
19
+ */
20
+ export declare function runNodeBundle(bundlePath: string, extraArgs?: string[], options?: RunNodeBundleOptions): Promise<void>;
@@ -0,0 +1,85 @@
1
+ // Shared utility for running a `--app node` bundle on Node.js with the native
2
+ // typelib env wired the same way `runGjsBundle` does.
3
+ //
4
+ // A `gjsify build --app node` bundle of a `gi://` source runs the GObject stack
5
+ // through `@gjsify/node-gi` (the Axis-5 reverse bridge). Two things have to be
6
+ // true at runtime:
7
+ //
8
+ // 1. `@gjsify/node-gi` must resolve from the project — it is kept EXTERNAL by
9
+ // the bundler (a native addon), so it has to physically exist in the
10
+ // consumer's node_modules. We verify this up front and emit a clear hint
11
+ // instead of letting Node throw `ERR_MODULE_NOT_FOUND` deep in the bundle.
12
+ // 2. `gi://Gtk` / `gi://Adw` etc. must resolve. System typelibs come from the
13
+ // default GI search path, but any `@gjsify/*` Vala bridge a story pulls in
14
+ // ships its typelib in `prebuilds/` — so we reuse the SAME
15
+ // `computeNativeEnvForBundle()` walker `runGjsBundle` uses to prepend
16
+ // `GI_TYPELIB_PATH` / `LD_LIBRARY_PATH`. It is a no-op when no native
17
+ // gjsify packages are present (the storybook's common case).
18
+ //
19
+ // The node binary is `process.execPath` when the CLI itself runs under Node, and
20
+ // the PATH `node` otherwise (under the committed GJS bundle `process.execPath` is
21
+ // `gjs`, which cannot run a `--app node` bundle).
22
+ import { spawn } from 'node:child_process';
23
+ import { existsSync } from 'node:fs';
24
+ import { join, resolve } from 'node:path';
25
+ import { isNode } from '@gjsify/rolldown-plugin-gjsify/runtime';
26
+ import { computeNativeEnvForBundle } from './run-gjs.js';
27
+ /**
28
+ * Walk up from `startDir` looking for `<node_modules>/@gjsify/node-gi`. Returns
29
+ * the package directory, or null when it is not installed anywhere on the chain.
30
+ * A plain fs walk (works identically on Node and GJS) — Node's own resolver is
31
+ * not available under the GJS-bundled CLI.
32
+ */
33
+ export function resolveNodeGi(startDir) {
34
+ let dir = resolve(startDir);
35
+ while (true) {
36
+ const candidate = join(dir, 'node_modules', '@gjsify', 'node-gi', 'package.json');
37
+ if (existsSync(candidate))
38
+ return join(dir, 'node_modules', '@gjsify', 'node-gi');
39
+ const parent = resolve(dir, '..');
40
+ if (parent === dir)
41
+ return null;
42
+ dir = parent;
43
+ }
44
+ }
45
+ /** Pick the node executable: the current one when on Node, else PATH `node`. */
46
+ export function nodeBinary() {
47
+ return isNode() && process.execPath ? process.execPath : 'node';
48
+ }
49
+ /**
50
+ * Run a `--app node` bundle on Node.js, auto-setting LD_LIBRARY_PATH and
51
+ * GI_TYPELIB_PATH for any installed native gjsify packages (same detection as
52
+ * {@link runGjsBundle}). Verifies `@gjsify/node-gi` is resolvable first and
53
+ * throws a clear, actionable error otherwise.
54
+ */
55
+ export async function runNodeBundle(bundlePath, extraArgs = [], options = {}) {
56
+ const cwd = process.cwd();
57
+ if (!resolveNodeGi(cwd)) {
58
+ throw new Error('Cannot run the storybook on Node: `@gjsify/node-gi` is not installed.\n' +
59
+ 'Add @gjsify/node-gi as a dependency to run the storybook on Node, e.g.:\n' +
60
+ ' "devDependencies": { "@gjsify/node-gi": "^0.13.0" }');
61
+ }
62
+ const { env: nativeEnv, envPrefix } = computeNativeEnvForBundle(bundlePath, cwd);
63
+ const env = { ...process.env, ...nativeEnv };
64
+ const nodeBin = nodeBinary();
65
+ const nodeArgs = [bundlePath, ...extraArgs];
66
+ // Echo the exact command (stderr, so a child speaking a protocol on stdout
67
+ // stays uncontaminated) so users can reproduce the run without the wrapper.
68
+ const cmd = [nodeBin, ...nodeArgs.map((a) => (a.includes(' ') ? `"${a}"` : a))].join(' ');
69
+ console.error(`$ ${envPrefix ? `${envPrefix} ` : ''}${cmd}`);
70
+ const child = spawn(nodeBin, nodeArgs, { env, stdio: 'inherit' });
71
+ await new Promise((resolvePromise, reject) => {
72
+ child.on('close', (code) => {
73
+ if (code !== 0)
74
+ reject(new Error(`node exited with code ${code}`));
75
+ else
76
+ resolvePromise();
77
+ });
78
+ child.on('error', reject);
79
+ }).catch((err) => {
80
+ console.error(err.message);
81
+ process.exit(1);
82
+ });
83
+ if (options.exitOnSuccess)
84
+ process.exit(0);
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -108,6 +108,7 @@
108
108
  "start": "node lib/index.js",
109
109
  "build": "tsc && mkdir -p lib/templates/flatpak && cp -L src/templates/install.mjs.tmpl lib/templates/install.mjs.tmpl && cp src/templates/flatpak/*.tmpl lib/templates/flatpak/ && cp src/templates/oxlintrc.json.tmpl lib/templates/oxlintrc.json.tmpl && cp src/templates/oxfmtrc.tmpl lib/templates/oxfmtrc.tmpl && gjsify run chmod",
110
110
  "build:gjs-bundle": "node lib/index.js build src/index.ts --app gjs --outfile dist/cli.gjs.mjs --shebang",
111
+ "build:affected-bundle": "node lib/index.js build src/affected-entry.ts --app gjs --outfile dist/affected.gjs.mjs",
111
112
  "chmod": "chmod +x ./lib/index.js",
112
113
  "build:test:node": "node lib/index.js build src/test.mts --app node --outfile dist/test.node.mjs",
113
114
  "test:node": "node dist/test.node.mjs",
@@ -120,19 +121,19 @@
120
121
  "cli"
121
122
  ],
122
123
  "dependencies": {
123
- "@gjsify/buffer": "^0.13.1",
124
- "@gjsify/create-app": "^0.13.1",
125
- "@gjsify/node-globals": "^0.13.1",
126
- "@gjsify/node-polyfills": "^0.13.1",
127
- "@gjsify/npm-registry": "^0.13.1",
128
- "@gjsify/resolve-npm": "^0.13.1",
129
- "@gjsify/rolldown-plugin-gjsify": "^0.13.1",
130
- "@gjsify/rolldown-plugin-pnp": "^0.13.1",
131
- "@gjsify/semver": "^0.13.1",
132
- "@gjsify/tar": "^0.13.1",
133
- "@gjsify/tsc": "^0.13.1",
134
- "@gjsify/web-polyfills": "^0.13.1",
135
- "@gjsify/workspace": "^0.13.1",
124
+ "@gjsify/buffer": "^0.14.0",
125
+ "@gjsify/create-app": "^0.14.0",
126
+ "@gjsify/node-globals": "^0.14.0",
127
+ "@gjsify/node-polyfills": "^0.14.0",
128
+ "@gjsify/npm-registry": "^0.14.0",
129
+ "@gjsify/resolve-npm": "^0.14.0",
130
+ "@gjsify/rolldown-plugin-gjsify": "^0.14.0",
131
+ "@gjsify/rolldown-plugin-pnp": "^0.14.0",
132
+ "@gjsify/semver": "^0.14.0",
133
+ "@gjsify/tar": "^0.14.0",
134
+ "@gjsify/tsc": "^0.14.0",
135
+ "@gjsify/web-polyfills": "^0.14.0",
136
+ "@gjsify/workspace": "^0.14.0",
136
137
  "cosmiconfig": "^9.0.2",
137
138
  "get-tsconfig": "^4.14.0",
138
139
  "pkg-types": "^2.3.1",
@@ -140,13 +141,13 @@
140
141
  "yargs": "^18.0.0"
141
142
  },
142
143
  "devDependencies": {
143
- "@gjsify/unit": "^0.13.1",
144
+ "@gjsify/unit": "^0.14.0",
144
145
  "@types/yargs": "^17.0.35",
145
146
  "typescript": "^6.0.3"
146
147
  },
147
148
  "peerDependencies": {
148
- "@gjsify/oxfmt-native": "^0.13.1",
149
- "@gjsify/rolldown-native": "^0.13.1"
149
+ "@gjsify/oxfmt-native": "^0.14.0",
150
+ "@gjsify/rolldown-native": "^0.14.0"
150
151
  },
151
152
  "peerDependenciesMeta": {
152
153
  "@gjsify/oxfmt-native": {