@gjsify/cli 0.34.0 → 0.36.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.
@@ -140,6 +140,25 @@ export declare class BuildAction {
140
140
  noCacheEnv?: string;
141
141
  /** Extra compile-time `define`s (e.g. rewrite the entry's `import.meta.url`). */
142
142
  define?: Record<string, string>;
143
+ /**
144
+ * Whether a fresh cached bundle may be reused (default true). Set
145
+ * false when the caller's input has a module graph the freshness
146
+ * check cannot see — the entry's own mtime plus the lockfile covers
147
+ * installed deps, not workspace sources. See `utils/node-script.ts`,
148
+ * which runs repo BUILD scripts and so cannot afford a stale one.
149
+ */
150
+ cache?: boolean;
151
+ /**
152
+ * Re-export the entry's `default` through the side-effect wrapper so
153
+ * the bundle IMPORTS as a library (default true — the plugin and
154
+ * config loaders both `import()` the result and read `.default`).
155
+ *
156
+ * A script is EXECUTED, not imported, and has no default export, so
157
+ * wrapping one only produces `IMPORT_IS_UNDEFINED: Import 'default'
158
+ * will always be undefined` on every build — a warning that says the
159
+ * wrapper does not apply here.
160
+ */
161
+ preserveDefaultExport?: boolean;
143
162
  }): Promise<string>;
144
163
  /** Application mode */
145
164
  buildApp(app?: App, opts?: {
@@ -468,7 +468,7 @@ export class BuildAction {
468
468
  const cacheDir = join(cwd, 'node_modules', '.cache', 'gjsify', opts.cacheSubdir);
469
469
  const safeName = opts.label.replace(/[^a-zA-Z0-9._-]/g, '_');
470
470
  const outfile = join(cacheDir, `${safeName}-${shortHash(inputPath)}.mjs`);
471
- const cacheDisabled = opts.noCacheEnv ? isTruthyEnv(process.env[opts.noCacheEnv]) : false;
471
+ const cacheDisabled = opts.cache === false || (opts.noCacheEnv ? isTruthyEnv(process.env[opts.noCacheEnv]) : false);
472
472
  if (!cacheDisabled && (await isBundleFresh(outfile, inputPath, cwd))) {
473
473
  if (opts.verbose)
474
474
  console.debug(`[gjsify] reusing cached GJS bundle ${outfile}`);
@@ -484,7 +484,7 @@ export class BuildAction {
484
484
  output: { file: outfile },
485
485
  ...(opts.define ? { transform: { define: opts.define } } : {}),
486
486
  },
487
- }).buildApp('gjs', { preserveDefaultExport: true });
487
+ }).buildApp('gjs', { preserveDefaultExport: opts.preserveDefaultExport ?? true });
488
488
  return outfile;
489
489
  }
490
490
  /** Application mode */
@@ -111,16 +111,16 @@ function diagnoseNativeEngine() {
111
111
  'package, whose `lib/` no `gjsify install` produces. An installed package from npm is NOT affected — ' +
112
112
  'the published tarball does contain `lib/esm/index.js` (verified against 0.26.1), so a consumer that ' +
113
113
  'hit this message should report it.\n' +
114
- 'Building it needs a bundler, which is why it cannot bootstrap itself under GJS; the Node CLI entry ' +
115
- 'does it instead. From the gjsify repo root — NOTE both of these REQUIRE Node:\n' +
116
- ' gjsify run build:infra # the full cold-tree chain (recommended)\n' +
117
- ' node scripts/bootstrap-native-facades.mjs # just the facades, if the rest is already built\n' +
118
- 'Both are idempotent and skip already-built facades in seconds.\n' +
119
- 'ON A NODE-LESS GJS HOST there is currently no in-repo way out, and saying so is the point — this ' +
120
- 'message used to recommend those two commands unconditionally, and on such a host BOTH fail (the ' +
121
- "second directly, the first at `@gjsify/create-app`'s `node scripts/process-template.mjs`). Until " +
122
- 'that is closed, seed `lib/` from the published package — which ships it — or from a host that has ' +
123
- 'Node. Tracked in `status/open-todos.md` ("A Node-less host cannot bootstrap a fresh CLONE").');
114
+ 'Build it from the gjsify repo root — both are idempotent and skip already-built facades in ' +
115
+ 'seconds, and NEITHER requires Node any more:\n' +
116
+ ' gjsify run build:infra # the full cold-tree chain\n' +
117
+ ' gjsify run --node-script scripts/bootstrap-native-facades.mjs # just the facades\n' +
118
+ "Under Node the facades are built by the CLI's own Node entry (npm `rolldown`); under GJS by the " +
119
+ '`gjsify` on PATH, which carries its own engine when it was installed with `gjs -m install.mjs`.\n' +
120
+ 'If you are READING THIS ON A GJS HOST, that global engine is what could not be reached: this ' +
121
+ 'workspace symlink shadowed it, because `require.resolve` returned the package DIRECTORY instead ' +
122
+ 'of failing. That is fixed in `@gjsify/module`, so a `gjsify` OLDER than the fix still stops here — ' +
123
+ 'update it (`gjs -m install.mjs`), or seed `lib/` from the published package, which ships it.');
124
124
  }
125
125
  // Branch 3: installed AND built, so the typelib/prebuild LOOKUP failed.
126
126
  //
@@ -4,6 +4,7 @@ interface RunOptions {
4
4
  args: string[];
5
5
  workspace?: string;
6
6
  runtime?: string;
7
+ nodeScript?: boolean;
7
8
  }
8
9
  export declare const runCommand: Command<unknown, RunOptions>;
9
10
  export {};
@@ -19,6 +19,8 @@ import { spawn } from 'node:child_process';
19
19
  import { runGjsBundle } from '../utils/run-gjs.js';
20
20
  import { runRuntimeBundle } from '../utils/run-node.js';
21
21
  import { readPackageJson } from '../utils/pkg-json-edit.js';
22
+ import { runNodeScript } from '../utils/node-script.js';
23
+ import { nodeShimDir } from '../utils/gjsify-shim.js';
22
24
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
23
25
  import { discoverWorkspaces } from '@gjsify/workspace';
24
26
  import { isGjs, hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
@@ -47,6 +49,16 @@ export const runCommand = {
47
49
  alias: 'w',
48
50
  type: 'string',
49
51
  description: 'Run <target> as a script in the named workspace (by name or path), like `npm run <script> -w <name>`.',
52
+ })
53
+ .option('node-script', {
54
+ // Run an UNBUNDLED `.mjs` that imports `node:*` builtins on the
55
+ // host runtime — bundling it for GJS first when that is the host.
56
+ // Declared here so yargs captures it instead of forwarding it to
57
+ // the target (this command routes unknown flags to the child).
58
+ // Forces file mode, like --runtime.
59
+ type: 'boolean',
60
+ default: false,
61
+ description: 'Treat <target> as an unbundled Node-style script (imports `node:` builtins) and run it on the host runtime. Under GJS the file is bundled `--app gjs` on the fly first, which is what makes a repo script runnable on a host that has no Node.',
50
62
  })
51
63
  .option('runtime', {
52
64
  // Runtime selector for a BUNDLE FILE. `gjs` (default) launches
@@ -99,6 +111,22 @@ export const runCommand = {
99
111
  const positionalArgs = args.args ?? [];
100
112
  const doubleDashArgs = (args['--'] ?? []).filter((v) => typeof v === 'string');
101
113
  const extraArgs = [...positionalArgs, ...doubleDashArgs];
114
+ // `--node-script` (explicit) → run an UNBUNDLED Node-style script on the
115
+ // host runtime; under GJS it is bundled `--app gjs` on the fly first.
116
+ // Checked before `--runtime` and `--workspace`: it is a file mode, and
117
+ // combining it with either is a caller error rather than a precedence
118
+ // question (`--runtime` picks a launcher for a PREBUILT bundle,
119
+ // `--workspace` names a package.json script).
120
+ if (args.nodeScript === true) {
121
+ const isSet = (v) => typeof v === 'string' && v.length > 0;
122
+ const clash = isSet(args.runtime) ? '--runtime' : isSet(args.workspace) ? '--workspace' : null;
123
+ if (clash) {
124
+ console.error(`gjsify run: --node-script cannot be combined with ${clash}.`);
125
+ return process.exit(1);
126
+ }
127
+ await runNodeScript(target, extraArgs, { exitOnSuccess: true });
128
+ return;
129
+ }
102
130
  // `--runtime <gjs|node|bun|deno>` (explicit) → run a BUNDLE FILE on the
103
131
  // chosen runtime. This FORCES file mode: the runtime selector only makes
104
132
  // sense for a prebuilt bundle, and it must beat the script-name lookup.
@@ -308,6 +336,15 @@ async function runScript(script, extraArgs, cwd = process.cwd()) {
308
336
  if (process.env.GJSIFY_SHIM_DIR) {
309
337
  binDirs.unshift(process.env.GJSIFY_SHIM_DIR);
310
338
  }
339
+ // The `node` shim (see `nodeShimDir`) reaches PACKAGE SCRIPTS and nothing
340
+ // else. A script saying `node scripts/x.mjs` means "run this script", which
341
+ // gjsify can serve on a host with no Node; the CLI's own internals asking for
342
+ // `node` mean "I need a real Node", and answering those turned
343
+ // `gjsify tsc`'s honest "not on PATH" diagnosis into a bundling error two
344
+ // layers down (tests/e2e/tsc-node-fallback caught exactly that).
345
+ const nodeShim = nodeShimDir();
346
+ if (nodeShim)
347
+ binDirs.unshift(nodeShim);
311
348
  // Default FORCE_COLOR=1 when not already set, matching yarn / npm
312
349
  // script-runner behaviour. Without this, tools that check
313
350
  // `process.stdout.isTTY` (chalk, picocolors, biome, …) disable colors
@@ -130,6 +130,22 @@ export declare function runAllChecks(cwd: string): DepCheck[];
130
130
  * Used by `gjsify showcase` for examples that have no native deps.
131
131
  */
132
132
  export declare function runMinimalChecks(): DepCheck[];
133
+ /**
134
+ * Is a blueprint-compiler reachable, by the SAME rule the build uses?
135
+ *
136
+ * Delegates to `@gjsify/vite-plugin-blueprint/resolve` rather than probing PATH
137
+ * here, because the two answers must not be able to disagree: on win32 the
138
+ * compiler is normally an MSYS2 script that is deliberately NOT on PATH, so a
139
+ * PATH-only check would report "missing" on a host where every `.blp` builds
140
+ * fine — a check failing for the wrong reason, which is the failure mode this
141
+ * file's own history is made of.
142
+ *
143
+ * `--version` is not run. MSYS2 ships blueprint-compiler as a shebang script
144
+ * with no `.exe`, so `checkBinary` cannot execute it on Windows; presence of the
145
+ * resolved pair is the honest answer, and the `source` is worth reporting
146
+ * because "found via msys2" explains an install PATH does not show.
147
+ */
148
+ export declare function checkBlueprintCompiler(requiredBy?: string[]): DepCheck;
133
149
  /** Check gwebgl npm package (project first, CLI fallback). Optional — only needed by @gjsify/webgl users. */
134
150
  export declare function checkGwebgl(cwd: string): DepCheck;
135
151
  /**
@@ -15,6 +15,9 @@ import { dirname, join, resolve } from 'node:path';
15
15
  import { createRequire } from 'node:module';
16
16
  import { pathToFileURL } from 'node:url';
17
17
  import { readFileSync, existsSync, readdirSync } from 'node:fs';
18
+ // The zero-dependency subpath, deliberately: importing the plugin root would
19
+ // pull vite/execa/minify-xml into a system CHECK.
20
+ import { resolveBlueprintCompiler } from '@gjsify/vite-plugin-blueprint/resolve';
18
21
  /** Run a binary and return its stdout trimmed, or null if it fails. */
19
22
  function tryExecFile(binary, args) {
20
23
  try {
@@ -392,6 +395,32 @@ export function runMinimalChecks() {
392
395
  results.push(checkMsvcRuntime());
393
396
  return results;
394
397
  }
398
+ /**
399
+ * Is a blueprint-compiler reachable, by the SAME rule the build uses?
400
+ *
401
+ * Delegates to `@gjsify/vite-plugin-blueprint/resolve` rather than probing PATH
402
+ * here, because the two answers must not be able to disagree: on win32 the
403
+ * compiler is normally an MSYS2 script that is deliberately NOT on PATH, so a
404
+ * PATH-only check would report "missing" on a host where every `.blp` builds
405
+ * fine — a check failing for the wrong reason, which is the failure mode this
406
+ * file's own history is made of.
407
+ *
408
+ * `--version` is not run. MSYS2 ships blueprint-compiler as a shebang script
409
+ * with no `.exe`, so `checkBinary` cannot execute it on Windows; presence of the
410
+ * resolved pair is the honest answer, and the `source` is worth reporting
411
+ * because "found via msys2" explains an install PATH does not show.
412
+ */
413
+ export function checkBlueprintCompiler(requiredBy) {
414
+ const resolved = resolveBlueprintCompiler();
415
+ return {
416
+ id: 'blueprint-compiler',
417
+ name: 'blueprint-compiler (.blp templates)',
418
+ found: resolved !== null,
419
+ version: resolved ? `found via ${resolved.source}` : undefined,
420
+ severity: 'optional',
421
+ requiredBy,
422
+ };
423
+ }
395
424
  /** Check gwebgl npm package (project first, CLI fallback). Optional — only needed by @gjsify/webgl users. */
396
425
  export function checkGwebgl(cwd) {
397
426
  return checkNpmPackage('gwebgl', 'gwebgl (@gjsify/webgl)', '@gjsify/webgl', cwd, 'optional', ['@gjsify/webgl']);
@@ -454,7 +483,13 @@ function runNativeBuildToolchainChecks() {
454
483
  // Narrower on purpose: only the three bridges that link an upstream Rust
455
484
  // crate by Cargo path-dependency need a Rust toolchain.
456
485
  const rustBridges = ['@gjsify/lightningcss-native', '@gjsify/oxfmt-native', '@gjsify/rolldown-native'];
486
+ // Every showcase/app whose window comes from a `.blp` template. NOT a Vala
487
+ // bridge and nothing to do with meson — it is a BUILD-time toolchain in the
488
+ // same sense, and it belongs here so `gjsify system-check` names it before a
489
+ // build does.
490
+ const blueprintConsumers = ['@gjsify/vite-plugin-blueprint'];
457
491
  return [
492
+ checkBlueprintCompiler(blueprintConsumers),
458
493
  checkBinary('ninja', 'Ninja', 'ninja', ['--version'], 'optional', undefined, valaBridges),
459
494
  checkBinary('vala', 'Vala', 'valac', ['--version'], 'optional', (out) => out.replace(/^Vala\s+/i, ''), valaBridges),
460
495
  checkBinary('cargo', 'Cargo (Rust)', 'cargo', ['--version'], 'optional', (out) => out.replace(/^cargo\s+/i, '').split(' ')[0] ?? out, rustBridges),
@@ -697,11 +732,24 @@ export function buildInstallCommand(pm, missing) {
697
732
  const pkgMap = PM_PACKAGES[pm];
698
733
  const pkgs = [];
699
734
  const npmDeps = [];
735
+ const standalone = [];
700
736
  for (const dep of missing) {
701
737
  if (dep.id === 'gwebgl') {
702
738
  npmDeps.push('@gjsify/webgl');
703
739
  continue;
704
740
  }
741
+ // blueprint-compiler on Windows is the one dep whose install command is
742
+ // not this host's package manager. winget has no package for it, and it
743
+ // cannot get one that works: the tool needs PyGObject, which publishes
744
+ // no Windows wheel, so MSYS2 — which ships Python, PyGObject and the
745
+ // typelibs already fitted together — is the only source. Its command is
746
+ // not `winget install`-shaped, so it cannot live in PM_PACKAGES without
747
+ // producing a line that looks right and fails.
748
+ if (dep.id === 'blueprint-compiler' && process.platform === 'win32') {
749
+ standalone.push('pacman -S mingw-w64-ucrt-x86_64-blueprint-compiler mingw-w64-ucrt-x86_64-gtk4 ' +
750
+ 'mingw-w64-ucrt-x86_64-libadwaita # inside MSYS2; found automatically afterwards');
751
+ continue;
752
+ }
705
753
  if (dep.id === 'nodejs')
706
754
  continue; // can't be missing if we're running
707
755
  const pkg = pkgMap[dep.id];
@@ -715,6 +763,7 @@ export function buildInstallCommand(pm, missing) {
715
763
  if (npmDeps.length > 0) {
716
764
  lines.push(`npm install ${npmDeps.join(' ')}`);
717
765
  }
766
+ lines.push(...standalone);
718
767
  return lines.length > 0 ? lines.join('\n ') : null;
719
768
  }
720
769
  /**
@@ -31,3 +31,24 @@ export declare function needsSelfShim(opts: {
31
31
  * reuse it instead of creating their own.
32
32
  */
33
33
  export declare function ensureGjsifyShimOnPath(): void;
34
+ /**
35
+ * Directory carrying a `node` that runs the file through
36
+ * `gjsify run --node-script`, or null when this host needs none.
37
+ *
38
+ * **It is NOT on `process.env.PATH`, and that is the whole point.** Only
39
+ * `runScript` puts it on the PATH of a PACKAGE SCRIPT's child (`commands/run.ts`).
40
+ *
41
+ * The first version did prepend it globally, and CI named the cost immediately:
42
+ * `gjsify tsc`'s Node fallback spawns a real `node` to run typescript's CJS
43
+ * entry, and `tests/e2e/tsc-node-fallback` asserts that on a PATH without one it
44
+ * FAILS LOUDLY naming the missing interpreter — the assertion exists because a
45
+ * silent exit 0 there once let `set-bin-mode.mjs` run against a `lib/index.js`
46
+ * tsc never wrote. A global shim answered that spawn, so the CLI stopped being
47
+ * able to see that Node was missing at all.
48
+ *
49
+ * The line the subdirectory draws: a PACKAGE SCRIPT saying `node x.mjs` means
50
+ * "run this script", and gjsify can serve that. The CLI's own internals asking
51
+ * for `node` mean "I need a real Node", and lying to them turns an honest
52
+ * diagnosis into a confusing one two layers down.
53
+ */
54
+ export declare function nodeShimDir(): string | null;
@@ -59,12 +59,13 @@
59
59
  // shim IS the CLI the tree wants, and today's dispatch (GJS bundle first on a
60
60
  // host with `gjs`) stays byte-for-byte unchanged. Widening this to every Node
61
61
  // invocation would silently move in-repo nested builds off the GJS bundle.
62
- import { mkdtempSync, writeFileSync, chmodSync, existsSync } from 'node:fs';
62
+ import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, existsSync } from 'node:fs';
63
63
  import { tmpdir } from 'node:os';
64
64
  import { join, delimiter, relative, isAbsolute } from 'node:path';
65
65
  import { fileURLToPath } from 'node:url';
66
66
  import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
67
67
  import { buildLauncherShims } from './bin-shim.js';
68
+ import { resolveBinOnPath } from './install-global.js';
68
69
  import { findWorkspaceRoot } from './workspace-root.js';
69
70
  let _ensured = false;
70
71
  let _selfShimActive = false;
@@ -158,4 +159,91 @@ export function ensureGjsifyShimOnPath() {
158
159
  _selfShimActive = true;
159
160
  process.env.GJSIFY_SHIM_DIR = dir;
160
161
  process.env.PATH = dir + delimiter + (process.env.PATH ?? '');
162
+ writeNodeShim(dir, gjs, selfEntry);
163
+ }
164
+ /**
165
+ * Subdirectory holding the `node` shim. Deliberately NOT the shim dir itself —
166
+ * see {@link nodeShimDir}.
167
+ */
168
+ const NODE_SHIM_SUBDIR = 'node-shim';
169
+ /**
170
+ * Directory carrying a `node` that runs the file through
171
+ * `gjsify run --node-script`, or null when this host needs none.
172
+ *
173
+ * **It is NOT on `process.env.PATH`, and that is the whole point.** Only
174
+ * `runScript` puts it on the PATH of a PACKAGE SCRIPT's child (`commands/run.ts`).
175
+ *
176
+ * The first version did prepend it globally, and CI named the cost immediately:
177
+ * `gjsify tsc`'s Node fallback spawns a real `node` to run typescript's CJS
178
+ * entry, and `tests/e2e/tsc-node-fallback` asserts that on a PATH without one it
179
+ * FAILS LOUDLY naming the missing interpreter — the assertion exists because a
180
+ * silent exit 0 there once let `set-bin-mode.mjs` run against a `lib/index.js`
181
+ * tsc never wrote. A global shim answered that spawn, so the CLI stopped being
182
+ * able to see that Node was missing at all.
183
+ *
184
+ * The line the subdirectory draws: a PACKAGE SCRIPT saying `node x.mjs` means
185
+ * "run this script", and gjsify can serve that. The CLI's own internals asking
186
+ * for `node` mean "I need a real Node", and lying to them turns an honest
187
+ * diagnosis into a confusing one two layers down.
188
+ */
189
+ export function nodeShimDir() {
190
+ const dir = process.env.GJSIFY_SHIM_DIR;
191
+ if (!dir)
192
+ return null;
193
+ const sub = join(dir, NODE_SHIM_SUBDIR);
194
+ return existsSync(join(sub, 'node')) ? sub : null;
195
+ }
196
+ /**
197
+ * Write a `node` that runs the file through `gjsify run --node-script`, when the
198
+ * host is GJS and there is NO REAL `node`.
199
+ *
200
+ * WHY A SHIM AND NOT A MANIFEST REWRITE
201
+ *
202
+ * Every `node scripts/*.mjs` left in this repo's build chain imports nothing but
203
+ * `node:fs` / `node:path` / `node:url`, all of which gjsify polyfills, so the
204
+ * only thing stopping them on a Node-less host is that GJS's ESM loader cannot
205
+ * resolve `node:` for a file on disk. `--node-script` solves that — but spelling
206
+ * it in `package.json` does NOT work, for a structural reason rather than an
207
+ * aesthetic one: CI bootstraps a cold tree with
208
+ * `gjs -m "$GJSIFY_BOOTSTRAP" run build:infra`, and `$GJSIFY_BOOTSTRAP` is the
209
+ * PREVIOUS RELEASE's CLI (`.github/workflows/release-cut.yml` documents the same
210
+ * trap for its own step). A manifest using a flag the last release does not know
211
+ * reds every cold-cache CI run until a release ships — and the release runs that
212
+ * same chain to build itself.
213
+ *
214
+ * A shim has neither problem. The manifests keep saying `node scripts/x.mjs`, so
215
+ * an older CLI behaves exactly as it does today, and a Node-less host gets a
216
+ * `node` that works. It also covers what a per-script rewrite cannot: COMPOUND
217
+ * scripts (`gjsify tsc && node scripts/build-assets.mjs`) are executed through
218
+ * `/bin/sh`, which resolves `node` from PATH — as does anything those scripts
219
+ * spawn in turn.
220
+ *
221
+ * Two things keep it from shadowing anything: it is written only when `node`
222
+ * resolves NOWHERE, and it is reachable only from a package script's PATH (see
223
+ * {@link nodeShimDir}).
224
+ */
225
+ function writeNodeShim(dir, gjs, selfEntry) {
226
+ if (!gjs)
227
+ return;
228
+ const sub = join(dir, NODE_SHIM_SUBDIR);
229
+ if (existsSync(join(sub, 'node')))
230
+ return; // inherited from a parent gjsify
231
+ if (resolveBinOnPath('node'))
232
+ return; // a real Node is present — leave it alone
233
+ const interpreter = process.env.GJS_CONSOLE || 'gjs';
234
+ // A leading FLAG is refused rather than forwarded. `node --test x.mjs` wants
235
+ // Node's own test runner and `node -e '…'` an eval; neither is something
236
+ // `--node-script` can honour, and yargs would silently take `--test` FOR the
237
+ // script path (`unknown-options-as-args` turns an unknown flag into a
238
+ // positional). A refusal naming the limit beats a mis-parse naming a file.
239
+ mkdirSync(sub, { recursive: true });
240
+ const shim = join(sub, 'node');
241
+ writeFileSync(shim, '#!/bin/sh\n' +
242
+ 'case "$1" in\n' +
243
+ ' -*) echo "gjsify: this host has no node; the gjsify shim runs a SCRIPT FILE only" >&2\n' +
244
+ ' echo "gjsify: got: node $*" >&2\n' +
245
+ ' exit 127 ;;\n' +
246
+ 'esac\n' +
247
+ `exec "${interpreter}" -m "${selfEntry}" run --node-script "$@"\n`, { mode: 0o755 });
248
+ chmodSync(shim, 0o755);
161
249
  }
@@ -0,0 +1,12 @@
1
+ export interface RunNodeScriptOptions {
2
+ /** Exit the process when the script succeeds. See `RunGjsBundleOptions.exitOnSuccess`. */
3
+ exitOnSuccess?: boolean;
4
+ }
5
+ /**
6
+ * Run `file` — an unbundled ESM script that imports `node:` builtins — on the
7
+ * CLI's own host runtime, bundling it for GJS first when that is where we are.
8
+ *
9
+ * Terminal callers pass `exitOnSuccess` (a GJS main loop armed by the spawn
10
+ * parks the process otherwise); see {@link runGjsBundle}.
11
+ */
12
+ export declare function runNodeScript(file: string, extraArgs?: string[], options?: RunNodeScriptOptions): Promise<void>;
@@ -0,0 +1,116 @@
1
+ // `gjsify run --node-script <file>` — run an UNBUNDLED `.mjs`/`.js` that imports
2
+ // `node:*` builtins, on whatever runtime the CLI itself is executing on.
3
+ //
4
+ // WHY THIS EXISTS
5
+ //
6
+ // A repo script like `scripts/build-assets.mjs` imports only `node:fs` /
7
+ // `node:path` / `node:url` — every one of which gjsify polyfills — so nothing
8
+ // about it is intrinsically Node-bound. What was missing is a way to RUN one
9
+ // under GJS: GJS's ESM loader has no `node:` URI scheme and no node_modules
10
+ // walker, so `gjs -m scripts/build-assets.mjs` dies on the first import. That
11
+ // single gap is why a Node-less host could install gjsify, resolve every native
12
+ // prebuild, and then fail `build:infra` at `node scripts/process-template.mjs`
13
+ // (`status/open-todos.md`, "A Node-less host cannot bootstrap a fresh CLONE").
14
+ //
15
+ // The alternative that was considered and rejected: commit a `dist/*.gjs.mjs`
16
+ // for each script. That is one more artifact per script under the committed-
17
+ // bundle freshness gate, and it scales with the number of scripts. This is ONE
18
+ // mechanism that serves every script in the repo and every consumer's too.
19
+ //
20
+ // WHAT IT DOES
21
+ //
22
+ // node / bun / deno host → spawn the file on that runtime. Byte-for-byte what
23
+ // `node <file>` did before; no bundling, no cost.
24
+ // gjs host → bundle the file `--app gjs` (which is what maps
25
+ // `node:fs` → `@gjsify/fs`, injects the globals the
26
+ // script reads, and resolves bare specifiers), then
27
+ // run the bundle.
28
+ //
29
+ // `import.meta.url` IS THE WHOLE TRICK
30
+ //
31
+ // The bundle does not live where the source lives, and a script's own location
32
+ // is how it finds what it operates on — all four scripts this was built for open
33
+ // with `dirname(fileURLToPath(import.meta.url))` and derive their package root
34
+ // from it. Left alone, `import.meta.url` in the bundle is the BUNDLE's URL, so
35
+ // every one of them would write its output into the cache directory. So the
36
+ // source file's URL is baked in as a compile-time define, exactly as
37
+ // `config.ts`'s `loadConfigViaGjsBundle` does for a `gjsify.config.js` that
38
+ // reads its own siblings. `process.argv` needs no such care: the `--app gjs`
39
+ // bundle already exposes `[gjs, <bundle>, ...args]`, so `argv.slice(2)` and
40
+ // `argv.includes('--flag')` behave as they do on Node.
41
+ import { existsSync, statSync } from 'node:fs';
42
+ import { basename, resolve } from 'node:path';
43
+ import { pathToFileURL } from 'node:url';
44
+ import { hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
45
+ import { runGjsBundle } from './run-gjs.js';
46
+ import { runRuntimeBundle } from './run-node.js';
47
+ /**
48
+ * Whether the on-the-fly GJS bundle may be reused across runs.
49
+ *
50
+ * OFF by default, and that is deliberate. The cache `bundleFileForGjsCached`
51
+ * implements invalidates on the ENTRY file's mtime plus the project lockfile —
52
+ * which covers a plugin or a config (both leaf-ish, both consuming installed
53
+ * deps) but NOT a repo script, whose imports are workspace packages that change
54
+ * without any lockfile write. A stale bundle there does not fail loudly: the
55
+ * script runs and EMITS BUILD OUTPUT from code that no longer exists in the
56
+ * tree. That is the failure this repo has paid for repeatedly under a different
57
+ * name ("it never runs the source you just edited"), and a build script is the
58
+ * worst place to reintroduce it.
59
+ *
60
+ * The cost of being right is one bundle per invocation, and only under GJS —
61
+ * node/bun/deno never bundle at all. `GJSIFY_NODE_SCRIPT_CACHE=1` opts in for a
62
+ * host that would rather have the seconds back.
63
+ */
64
+ function scriptCacheEnabled() {
65
+ const v = process.env['GJSIFY_NODE_SCRIPT_CACHE'];
66
+ return v === '1' || v === 'true' || v === 'yes';
67
+ }
68
+ /**
69
+ * Run `file` — an unbundled ESM script that imports `node:` builtins — on the
70
+ * CLI's own host runtime, bundling it for GJS first when that is where we are.
71
+ *
72
+ * Terminal callers pass `exitOnSuccess` (a GJS main loop armed by the spawn
73
+ * parks the process otherwise); see {@link runGjsBundle}.
74
+ */
75
+ export async function runNodeScript(file, extraArgs = [], options = {}) {
76
+ const scriptPath = resolve(file);
77
+ if (!existsSync(scriptPath) || !statSync(scriptPath).isFile()) {
78
+ console.error(`gjsify run --node-script: no such file: ${scriptPath}`);
79
+ // `return process.exit` — a bare exit falls through under GJS (see run.ts).
80
+ return process.exit(1);
81
+ }
82
+ const host = hostRuntime();
83
+ if (host !== 'gjs') {
84
+ // Node/bun/deno can load the file as-is; bundling would only add a step
85
+ // and a way to differ from what the runtime would have done itself.
86
+ // `quiet`: the echo would print `node <file>` — which is exactly what the
87
+ // package.json script already reads — behind ~2 kB of native-library env
88
+ // that a build script never loads. Nothing is lost by leaving it out, and
89
+ // a build chain calls this a dozen times.
90
+ await runRuntimeBundle(host, scriptPath, extraArgs, { exitOnSuccess: options.exitOnSuccess, quiet: true });
91
+ return;
92
+ }
93
+ // Dynamic import keeps the bundler machinery out of the eager module graph
94
+ // of every `gjsify run`, matching how config.ts pulls it.
95
+ const { BuildAction } = await import('../actions/build.js');
96
+ let bundlePath;
97
+ try {
98
+ bundlePath = await BuildAction.bundleFileForGjsCached(scriptPath, {
99
+ cacheSubdir: 'node-scripts',
100
+ label: basename(scriptPath),
101
+ cache: scriptCacheEnabled(),
102
+ // A script is executed, never imported — no default export to preserve.
103
+ preserveDefaultExport: false,
104
+ define: { 'import.meta.url': JSON.stringify(pathToFileURL(scriptPath).href) },
105
+ });
106
+ }
107
+ catch (err) {
108
+ throw new Error(`gjsify run --node-script: failed to bundle ${scriptPath} for GJS. ` +
109
+ `The script must import only what gjsify can provide under GJS (node: builtins, ` +
110
+ `workspace packages, pure-JS npm deps). (${err.message})`);
111
+ }
112
+ // Quiet for the same reason as the node path above; the bundle's location is
113
+ // deterministic (`node_modules/.cache/gjsify/node-scripts/<name>-<hash>.mjs`)
114
+ // for anyone who needs to inspect it.
115
+ await runGjsBundle(bundlePath, extraArgs, { exitOnSuccess: options.exitOnSuccess, quiet: true });
116
+ }
@@ -28,5 +28,12 @@ export interface RunGjsBundleOptions {
28
28
  * truncated `gjsify test` after the first gjs bundle.
29
29
  */
30
30
  exitOnSuccess?: boolean;
31
+ /**
32
+ * Suppress the `$ <env> gjs -m …` echo. The echo exists so a user can
33
+ * copy-paste the exact command, which is worth ~2 kB of env prefix for a
34
+ * one-shot `gjsify run <bundle>` — and is pure noise for a build script the
35
+ * chain invokes a dozen times (`utils/node-script.ts`).
36
+ */
37
+ quiet?: boolean;
31
38
  }
32
39
  export declare function runGjsBundle(bundlePath: string, extraArgs?: string[], options?: RunGjsBundleOptions): Promise<void>;
@@ -68,12 +68,23 @@ export async function runGjsBundle(bundlePath, extraArgs = [], options = {}) {
68
68
  // Use stderr so that children speaking a protocol on stdout (e.g. an
69
69
  // MCP stdio server) receive an uncontaminated stdout stream.
70
70
  const gjsCommand = ['gjs', ...gjsArgs.map((a) => (a.includes(' ') ? `"${a}"` : a))].join(' ');
71
- console.error(`$ ${envPrefix ? `${envPrefix} ` : ''}${gjsCommand}`);
71
+ if (!options.quiet)
72
+ console.error(`$ ${envPrefix ? `${envPrefix} ` : ''}${gjsCommand}`);
72
73
  const child = spawn('gjs', gjsArgs, { env, stdio: 'inherit' });
73
74
  let failed = false;
75
+ // The CHILD's own exit code, so it can be re-raised verbatim. A spawn error
76
+ // (no `gjs` on PATH) has no code of its own and falls back to 1.
77
+ //
78
+ // Collapsing every failure to 1 lost information a caller acts on: a script
79
+ // run through `gjsify run --node-script` reports ITS status (the bootstrap
80
+ // scripts all end `process.exit(r.status ?? 1)`, forwarding a child's code),
81
+ // and a chain that reads `$?` to distinguish "tool failed" from "tool says
82
+ // no" saw the same 1 for both.
83
+ let childCode = 1;
74
84
  await new Promise((resolvePromise, reject) => {
75
85
  child.on('close', (code) => {
76
86
  if (code !== 0) {
87
+ childCode = code ?? 1;
77
88
  reject(new Error(`gjs exited with code ${code}`));
78
89
  }
79
90
  else {
@@ -94,8 +105,8 @@ export async function runGjsBundle(bundlePath, extraArgs = [], options = {}) {
94
105
  // swallowed a non-zero gjs exit and returned 0 — masking a failing
95
106
  // `test:gjs` in the `test` script chain (and, via `gjsify foreach test`,
96
107
  // in CI). The `return` guards against a fall-through second `exit`.
97
- process.exitCode = 1;
98
- return process.exit(1);
108
+ process.exitCode = childCode;
109
+ return process.exit(childCode);
99
110
  }
100
111
  // See RunGjsBundleOptions.exitOnSuccess — terminal callers opt in,
101
112
  // mid-flow callers (test.ts's runtime loop) keep the process alive.
@@ -27,6 +27,8 @@ export declare function nodeBinary(): string;
27
27
  export interface RunNodeBundleOptions {
28
28
  /** Exit this process with code 0 once the child succeeds (terminal callers). */
29
29
  exitOnSuccess?: boolean;
30
+ /** Suppress the `$ <env> node …` echo — see `RunGjsBundleOptions.quiet`. */
31
+ quiet?: boolean;
30
32
  }
31
33
  /**
32
34
  * Run a `--app node` bundle on Node.js, auto-setting LD_LIBRARY_PATH and
@@ -148,20 +148,37 @@ export async function runRuntimeBundle(runtime, bundlePath, extraArgs = [], opti
148
148
  const cmd = runtime === 'node' ? nodeBinary() : RUNTIMES[runtime].probe;
149
149
  // Echo on stderr so a child speaking a protocol on stdout stays clean.
150
150
  const shown = [cmd, ...launchArgs.map((a) => (a.includes(' ') ? `"${a}"` : a))].join(' ');
151
- console.error(`$ ${envPrefix ? `${envPrefix} ` : ''}${shown}`);
151
+ if (!options.quiet)
152
+ console.error(`$ ${envPrefix ? `${envPrefix} ` : ''}${shown}`);
152
153
  const child = spawn(cmd, launchArgs, { env, stdio: 'inherit' });
154
+ // Re-raise the CHILD's own code rather than a blanket 1 — same reason as
155
+ // `runGjsBundle`: a script run through `--node-script` reports its status
156
+ // through this path, and a caller that reads `$?` needs the real one.
157
+ let failed = false;
158
+ let childCode = 1;
153
159
  await new Promise((resolvePromise, reject) => {
154
160
  child.on('close', (code) => {
155
- if (code !== 0)
161
+ if (code !== 0) {
162
+ childCode = code ?? 1;
156
163
  reject(new Error(`${runtime} exited with code ${code}`));
164
+ }
157
165
  else
158
166
  resolvePromise();
159
167
  });
160
168
  child.on('error', reject);
161
169
  }).catch((err) => {
162
170
  console.error(err.message);
163
- process.exit(1);
171
+ failed = true;
164
172
  });
173
+ // `return process.exit(…)`, and the flag rather than an exit inside the
174
+ // `catch`: under GJS `process.exit()` is deferred and RETURNS, so a bare
175
+ // call in the catch fell through to the `exitOnSuccess` line below and
176
+ // scheduled a second exit — the same fall-through `runGjsBundle` and
177
+ // `commands/run.ts` already carry this shape for.
178
+ if (failed) {
179
+ process.exitCode = childCode;
180
+ return process.exit(childCode);
181
+ }
165
182
  if (options.exitOnSuccess)
166
- process.exit(0);
183
+ return process.exit(0);
167
184
  }