@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.
- package/dist/cli.gjs.mjs +135 -125
- package/lib/actions/build.d.ts +19 -0
- package/lib/actions/build.js +2 -2
- package/lib/bundler-pick.js +10 -10
- package/lib/commands/run.d.ts +1 -0
- package/lib/commands/run.js +37 -0
- package/lib/utils/check-system-deps.d.ts +16 -0
- package/lib/utils/check-system-deps.js +49 -0
- package/lib/utils/gjsify-shim.d.ts +21 -0
- package/lib/utils/gjsify-shim.js +89 -1
- package/lib/utils/node-script.d.ts +12 -0
- package/lib/utils/node-script.js +116 -0
- package/lib/utils/run-gjs.d.ts +7 -0
- package/lib/utils/run-gjs.js +14 -3
- package/lib/utils/run-node.d.ts +2 -0
- package/lib/utils/run-node.js +21 -4
- package/package.json +18 -17
- package/showcases.json +6 -0
package/lib/actions/build.d.ts
CHANGED
|
@@ -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?: {
|
package/lib/actions/build.js
CHANGED
|
@@ -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 */
|
package/lib/bundler-pick.js
CHANGED
|
@@ -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
|
-
'
|
|
115
|
-
'
|
|
116
|
-
' gjsify run build:infra
|
|
117
|
-
' node scripts/bootstrap-native-facades.mjs
|
|
118
|
-
|
|
119
|
-
'
|
|
120
|
-
'
|
|
121
|
-
|
|
122
|
-
'
|
|
123
|
-
'
|
|
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
|
//
|
package/lib/commands/run.d.ts
CHANGED
package/lib/commands/run.js
CHANGED
|
@@ -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;
|
package/lib/utils/gjsify-shim.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/utils/run-gjs.d.ts
CHANGED
|
@@ -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>;
|
package/lib/utils/run-gjs.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
98
|
-
return process.exit(
|
|
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.
|
package/lib/utils/run-node.d.ts
CHANGED
|
@@ -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
|
package/lib/utils/run-node.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|