@gjsify/cli 0.8.0 → 0.10.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.
@@ -37,9 +37,40 @@ export declare class BuildAction {
37
37
  * there is no in-bundle plugin, so this hook is the sole injection point.
38
38
  */
39
39
  private applyShebang;
40
+ /**
41
+ * GJS plugin loader — bundle the resolved plugin module to a single
42
+ * self-contained ESM file, then import that. Works around GJS's native
43
+ * ESM loader not following `package.json#exports` subpath maps for bare
44
+ * specifiers: Rolldown resolves those subpaths at bundle time, so the
45
+ * emitted file has no unresolvable bare-specifier imports left. Injected
46
+ * as the `loadModule` strategy in `buildApp` only when running under GJS.
47
+ */
48
+ private loadPluginViaGjsBundle;
49
+ /**
50
+ * Bundle a single plugin entry for `--app gjs` to a cached temp file and
51
+ * return its path. Reuses the full `--app gjs` pipeline (exports-map-aware
52
+ * resolution, `node:`→`@gjsify` aliases, `--globals auto`, single-file
53
+ * output) via a nested `BuildAction`. The nested config carries no
54
+ * `bundler.plugins`, so it never recurses back into plugin resolution.
55
+ *
56
+ * Cached under `node_modules/.cache/gjsify/plugins/`, keyed by the resolved
57
+ * source path. Invalidated when the plugin entry OR the project lockfile is
58
+ * newer than the cached bundle — a dep bump rewrites the lockfile but not
59
+ * the plugin's own entry mtime. The one window neither mtime catches (a
60
+ * transitive dep edited in place) is covered by the `GJSIFY_NO_PLUGIN_CACHE=1`
61
+ * escape hatch. Failures are rethrown with context naming the plugin.
62
+ */
63
+ private bundlePluginForGjs;
64
+ /**
65
+ * Fresh when the cached bundle is newer than BOTH the plugin entry AND the
66
+ * project's lockfile (the dep-change signal). Missing bundle/source → not
67
+ * fresh (rebuild).
68
+ */
69
+ private isPluginBundleFresh;
40
70
  /** Application mode */
41
71
  buildApp(app?: App, opts?: {
42
72
  watch?: boolean;
73
+ preserveDefaultExport?: boolean;
43
74
  }): Promise<RolldownOutput[]>;
44
75
  /**
45
76
  * Drive `rolldown.watch(...)`: rebuild on source change, apply the
@@ -1,10 +1,12 @@
1
1
  import { runBundle, runWatch, bundleToChunks } from '../bundler-pick.js';
2
2
  import { gjsifyPlugin, textLoaderPlugin, resolveShebangLine, NODE_SHEBANG } from '@gjsify/rolldown-plugin-gjsify';
3
+ import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
3
4
  import { resolveUserPlugins } from '../utils/resolve-plugin-by-name.js';
4
5
  import { resolveGlobalsList, writeRegisterInjectFile, detectAutoGlobals } from '@gjsify/rolldown-plugin-gjsify/globals';
5
6
  import { pnpPlugin } from '@gjsify/rolldown-plugin-pnp';
6
- import { dirname, extname } from 'node:path';
7
- import { chmod, readFile, writeFile } from 'node:fs/promises';
7
+ import { dirname, extname, join } from 'node:path';
8
+ import { pathToFileURL } from 'node:url';
9
+ import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
8
10
  import { normalizeBundlerOptions, mergeBundlerOptions } from '../utils/normalize-bundler-options.js';
9
11
  const DEFAULT_GJS_SHEBANG = '#!/usr/bin/env -S gjs -m';
10
12
  /**
@@ -34,6 +36,43 @@ function isUnsafeDefaultOutput(path) {
34
36
  async function buildPnpPlugin() {
35
37
  return pnpPlugin({ issuerUrl: import.meta.url });
36
38
  }
39
+ /**
40
+ * Stable, filesystem-safe short hash of a string (djb2 → 8 hex chars).
41
+ * Used to key a plugin's cached GJS bundle by its resolved source path.
42
+ * Dependency-free on purpose — avoids pulling crypto into this hot path.
43
+ */
44
+ function shortHash(value) {
45
+ let h = 5381;
46
+ for (let i = 0; i < value.length; i++) {
47
+ h = ((h << 5) + h + value.charCodeAt(i)) >>> 0;
48
+ }
49
+ return h.toString(16).padStart(8, '0');
50
+ }
51
+ function isTruthyEnv(v) {
52
+ return v === '1' || v === 'true' || v === 'yes';
53
+ }
54
+ /**
55
+ * Lockfiles whose change implies a plugin's *transitive* deps may have moved.
56
+ * A dep bump (`npm/yarn/pnpm/gjsify install`) rewrites one of these but leaves
57
+ * the plugin's own entry-file mtime untouched — so the plugin GJS-bundle cache
58
+ * must invalidate on these too, not only on the entry.
59
+ */
60
+ const PLUGIN_CACHE_DEP_SIGNALS = ['gjsify-lock.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'];
61
+ /** Newest mtime (ms) among known lockfiles in `cwd`, or 0 when none exist. */
62
+ async function newestLockfileMtime(cwd) {
63
+ let newest = 0;
64
+ for (const name of PLUGIN_CACHE_DEP_SIGNALS) {
65
+ try {
66
+ const s = await stat(join(cwd, name));
67
+ if (s.mtimeMs > newest)
68
+ newest = s.mtimeMs;
69
+ }
70
+ catch {
71
+ // not present — ignore
72
+ }
73
+ }
74
+ return newest;
75
+ }
37
76
  export class BuildAction {
38
77
  configData;
39
78
  constructor(configData = {}) {
@@ -179,6 +218,90 @@ export class BuildAction {
179
218
  if (verbose)
180
219
  console.debug(`[gjsify] --shebang: wrote ${line} + chmod 0o755 to ${outfile}`);
181
220
  }
221
+ /**
222
+ * GJS plugin loader — bundle the resolved plugin module to a single
223
+ * self-contained ESM file, then import that. Works around GJS's native
224
+ * ESM loader not following `package.json#exports` subpath maps for bare
225
+ * specifiers: Rolldown resolves those subpaths at bundle time, so the
226
+ * emitted file has no unresolvable bare-specifier imports left. Injected
227
+ * as the `loadModule` strategy in `buildApp` only when running under GJS.
228
+ */
229
+ async loadPluginViaGjsBundle(resolvedPath, pluginName, verbose) {
230
+ const outfile = await this.bundlePluginForGjs(resolvedPath, pluginName, verbose);
231
+ try {
232
+ return (await import(pathToFileURL(outfile).href));
233
+ }
234
+ catch (err) {
235
+ throw new Error(`gjsify config: failed to import the GJS bundle for plugin "${pluginName}" ` +
236
+ `(bundled to ${outfile}). (${err.message})`);
237
+ }
238
+ }
239
+ /**
240
+ * Bundle a single plugin entry for `--app gjs` to a cached temp file and
241
+ * return its path. Reuses the full `--app gjs` pipeline (exports-map-aware
242
+ * resolution, `node:`→`@gjsify` aliases, `--globals auto`, single-file
243
+ * output) via a nested `BuildAction`. The nested config carries no
244
+ * `bundler.plugins`, so it never recurses back into plugin resolution.
245
+ *
246
+ * Cached under `node_modules/.cache/gjsify/plugins/`, keyed by the resolved
247
+ * source path. Invalidated when the plugin entry OR the project lockfile is
248
+ * newer than the cached bundle — a dep bump rewrites the lockfile but not
249
+ * the plugin's own entry mtime. The one window neither mtime catches (a
250
+ * transitive dep edited in place) is covered by the `GJSIFY_NO_PLUGIN_CACHE=1`
251
+ * escape hatch. Failures are rethrown with context naming the plugin.
252
+ */
253
+ async bundlePluginForGjs(resolvedPath, pluginName, verbose) {
254
+ const cwd = process.cwd();
255
+ const cacheDir = join(cwd, 'node_modules', '.cache', 'gjsify', 'plugins');
256
+ const safeName = pluginName.replace(/[^a-zA-Z0-9._-]/g, '_');
257
+ const outfile = join(cacheDir, `${safeName}-${shortHash(resolvedPath)}.mjs`);
258
+ const cacheDisabled = isTruthyEnv(process.env.GJSIFY_NO_PLUGIN_CACHE);
259
+ if (!cacheDisabled && (await this.isPluginBundleFresh(outfile, resolvedPath, cwd))) {
260
+ if (verbose)
261
+ console.debug(`[gjsify] plugin "${pluginName}": reusing cached GJS bundle ${outfile}`);
262
+ return outfile;
263
+ }
264
+ if (verbose)
265
+ console.debug(`[gjsify] plugin "${pluginName}": bundling for GJS → ${outfile}`);
266
+ try {
267
+ await mkdir(cacheDir, { recursive: true });
268
+ // `--globals auto` stays on so the plugin's own runtime globals
269
+ // (the MDX/unified toolchain touches `document`, etc.) are injected
270
+ // — but that injection wraps the entry with `export * from <entry>`,
271
+ // which drops the `default` export the plugin factory lives on.
272
+ // `preserveDefaultExport` makes the wrapper re-export `default` too,
273
+ // so the bundled plugin imports correctly as a library.
274
+ // shebang is intentionally left unset — the artifact is imported,
275
+ // not executed, so it must NOT carry a `#!` line.
276
+ const pluginBuild = new BuildAction({
277
+ verbose,
278
+ bundler: { input: resolvedPath, output: { file: outfile } },
279
+ });
280
+ await pluginBuild.buildApp('gjs', { preserveDefaultExport: true });
281
+ }
282
+ catch (err) {
283
+ throw new Error(`gjsify config: failed to bundle plugin "${pluginName}" for GJS ` +
284
+ `(needed because GJS can't import packages that use exports-map subpaths directly; ` +
285
+ `cache dir ${cacheDir}). (${err.message})`);
286
+ }
287
+ return outfile;
288
+ }
289
+ /**
290
+ * Fresh when the cached bundle is newer than BOTH the plugin entry AND the
291
+ * project's lockfile (the dep-change signal). Missing bundle/source → not
292
+ * fresh (rebuild).
293
+ */
294
+ async isPluginBundleFresh(outfile, resolvedPath, cwd) {
295
+ try {
296
+ const outStat = await stat(outfile);
297
+ const srcStat = await stat(resolvedPath);
298
+ const depMtime = await newestLockfileMtime(cwd);
299
+ return outStat.mtimeMs >= Math.max(srcStat.mtimeMs, depMtime);
300
+ }
301
+ catch {
302
+ return false;
303
+ }
304
+ }
182
305
  /** Application mode */
183
306
  async buildApp(app = 'gjs', opts = {}) {
184
307
  const { verbose, typescript, exclude, library: pkg, aliases, excludeGlobals } = this.configData;
@@ -211,6 +334,7 @@ export class BuildAction {
211
334
  reflection: typescript?.reflection,
212
335
  consoleShim,
213
336
  ...(aliases ? { aliases } : {}),
337
+ ...(opts.preserveDefaultExport ? { preserveDefaultExport: true } : {}),
214
338
  };
215
339
  const { autoMode, extras } = this.parseGlobalsValue(globals);
216
340
  const pnp = await buildPnpPlugin();
@@ -230,7 +354,15 @@ export class BuildAction {
230
354
  // auto-globals pre-build to avoid claiming the same files via
231
355
  // Rolldown's default classifier.
232
356
  if (userBundler.plugins?.length) {
233
- const resolved = await resolveUserPlugins(userBundler.plugins, process.cwd());
357
+ // Under GJS, plugin modules can't always be imported directly:
358
+ // GJS's native ESM loader doesn't follow `package.json#exports`
359
+ // subpath maps, so a plugin whose source imports an internal
360
+ // subpath (e.g. `@mdx-js/mdx/internal-…`) throws `Module not
361
+ // found` even when the file is on disk. The injected loader
362
+ // first bundles such a plugin to a single self-contained ESM
363
+ // (Rolldown resolves the exports map at bundle time) and imports
364
+ // that instead. On Node the default direct-import path is used.
365
+ const resolved = await resolveUserPlugins(userBundler.plugins, process.cwd(), isGjs() ? { loadModule: (p, name) => this.loadPluginViaGjsBundle(p, name, verbose) } : {});
234
366
  userPlugins.push(...resolved);
235
367
  }
236
368
  // --- Auto mode (with optional extras): iterative multi-pass build ---
@@ -32,6 +32,7 @@ import * as path from 'node:path';
32
32
  import { createRequire } from 'node:module';
33
33
  import { pathToFileURL } from 'node:url';
34
34
  import { resolveNpmPackage } from './utils/resolve-npm-package.js';
35
+ import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
35
36
  // npm `rolldown` is a Rust crate with platform-specific prebuilds; loading
36
37
  // it eagerly at module init pulls musl-detection code that does
37
38
  // `require('node:fs')` synchronously — fine on Node, but fatal under GJS
@@ -46,8 +47,7 @@ async function loadNpmRolldown() {
46
47
  // inside the crate's wrapper (e.g. on a truly-cold workspace where
47
48
  // `@gjsify/rolldown-native`'s lib/ is not built yet, issue #497).
48
49
  // Fail with the actionable diagnosis instead.
49
- const isGjs = typeof globalThis.imports?.gi !== 'undefined';
50
- if (isGjs) {
50
+ if (isGjs()) {
51
51
  throw new Error('gjsify build: no usable bundler engine under GJS — `@gjsify/rolldown-native` is not loadable ' +
52
52
  '(its JS facade lib/ is not built, or its prebuild typelib is missing from GI_TYPELIB_PATH), ' +
53
53
  'and the npm `rolldown` engine is a Rust napi crate that cannot run under GJS. ' +
@@ -83,8 +83,7 @@ async function loadNpmRolldown() {
83
83
  * rolldown` even though the package is present under the install dir.
84
84
  */
85
85
  function resolveImportTargetForGjs(specifier) {
86
- const isGjs = typeof globalThis.imports?.gi !== 'undefined';
87
- if (!isGjs)
86
+ if (!isGjs())
88
87
  return specifier;
89
88
  const resolved = resolveNpmPackage(specifier, { bundleUrl: import.meta.url });
90
89
  if (resolved)
@@ -193,8 +192,7 @@ export async function shouldUseNative() {
193
192
  // GJS: try @gjsify/rolldown-native — it's the only engine
194
193
  // that can actually run here (npm rolldown is a Rust
195
194
  // crate). Fall back to npm only on Node.
196
- const isGjs = typeof globalThis.imports?.gi !== 'undefined';
197
- if (!isGjs)
195
+ if (!isGjs())
198
196
  return false;
199
197
  const native = await tryLoadNative();
200
198
  return native !== null;
@@ -236,8 +234,7 @@ async function tryLoadNative() {
236
234
  if (_nativeProbe)
237
235
  return _nativeProbe;
238
236
  _nativeProbe = (async () => {
239
- const isGjs = typeof globalThis.imports?.gi !== 'undefined';
240
- if (!isGjs)
237
+ if (!isGjs())
241
238
  return null;
242
239
  try {
243
240
  // Under GJS the ESM loader has no node_modules resolver — a bare
@@ -256,7 +253,7 @@ async function tryLoadNative() {
256
253
  // to npm rolldown (which then throws ImportError for `rolldown`).
257
254
  const specifier = '@gjsify/rolldown-native';
258
255
  let target = specifier;
259
- if (isGjs) {
256
+ if (isGjs()) {
260
257
  // Same multi-anchor resolution as `loadNpmRolldown` —
261
258
  // when the bundle is invoked from a cwd outside the
262
259
  // install dir, anchoring solely at `import.meta.url`
package/lib/cli-app.js CHANGED
@@ -21,6 +21,8 @@ import { fileURLToPath } from 'node:url';
21
21
  import yargs from 'yargs';
22
22
  import { buildCommand as build, testCommand as test, runCommand as run, infoCommand as info, systemCheckCommand as systemCheck, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, dlxCommand as dlx, installCommand as install, foreachCommand as foreach, workspaceCommand as workspace, packCommand as pack, publishCommand as publish, whoamiCommand as whoami, loginCommand as login, logoutCommand as logout, trustCommand as trust, selfUpdateCommand as selfUpdate, generateInstallerCommand as generateInstaller, uninstallCommand as uninstall, formatCommand as format, lintCommand as lint, fixCommand as fix, upgradeCommand as upgrade, barrelsCommand as barrels, tscCommand as tsc, affectedCommand as affected, storybookCommand as storybook, } from './commands/index.js';
23
23
  import { APP_NAME } from './constants.js';
24
+ import { isNode } from '@gjsify/rolldown-plugin-gjsify/runtime';
25
+ import { ensureGjsifyShimOnPath } from './utils/gjsify-shim.js';
24
26
  // Detect which runtime is executing the CLI (GJS or Node.js).
25
27
  // GJS MUST be checked first because @gjsify/process sets
26
28
  // `process.versions.node = '20.0.0'` for compatibility — a plain
@@ -36,7 +38,7 @@ function runtimeLabel() {
36
38
  catch {
37
39
  /* not GJS */
38
40
  }
39
- if (typeof process !== 'undefined' && typeof process.versions?.node === 'string') {
41
+ if (isNode()) {
40
42
  return `Node.js ${process.version}`;
41
43
  }
42
44
  return 'unknown runtime';
@@ -67,6 +69,11 @@ function readBundleVersion() {
67
69
  * exit before any async work runs.
68
70
  */
69
71
  export async function runCli(argv) {
72
+ // Under GJS, make a GJS-runnable `gjsify` available on PATH for child
73
+ // processes (workspace/foreach orchestration + compound `gjsify run`
74
+ // scripts) so node-free builds don't fall back to the Node bin. No-op on
75
+ // Node.
76
+ ensureGjsifyShimOnPath();
70
77
  const cli = yargs(argv);
71
78
  await cli
72
79
  .scriptName(APP_NAME)
@@ -14,6 +14,7 @@ import { cpus } from 'node:os';
14
14
  import { buildDependencyGraph, discoverWorkspaces, filterWorkspaces, topologicalSort, } from '@gjsify/workspace';
15
15
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
16
16
  import { prefixLines } from '../utils/prefixed-output.js';
17
+ import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
17
18
  // Every child spawned by spawnPrefixed registers here so fail-fast can
18
19
  // terminate the whole in-flight set instead of waiting on it. On CI a
19
20
  // single failed package used to leave foreach awaiting its still-running
@@ -520,6 +521,11 @@ async function runOne(ws, script, args, prefixOutput, verbose, exec) {
520
521
  await spawnPrefixed(runner, argv, ws.location, prefixOutput ? `[${ws.name}] ` : null);
521
522
  }
522
523
  function detectPackageManager() {
524
+ // Under GJS there is no Node — neither `npm` nor `yarn` can run scripts, so
525
+ // the runner is `gjsify` itself via the GJS shim on PATH
526
+ // (`ensureGjsifyShimOnPath`). Makes node-free `gjsify foreach` work.
527
+ if (isGjs())
528
+ return 'gjsify';
523
529
  // `npm_config_user_agent` is set by npm/yarn/pnpm — first token is
524
530
  // `<name>/<version>`. Reuse it so `gjsify foreach build` invoked
525
531
  // through `yarn run` keeps using yarn, etc.
@@ -19,6 +19,7 @@ import { spawn } from 'node:child_process';
19
19
  import { runGjsBundle } from '../utils/run-gjs.js';
20
20
  import { readPackageJson } from '../utils/pkg-json-edit.js';
21
21
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
22
+ import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
22
23
  export const runCommand = {
23
24
  command: 'run <target> [args..]',
24
25
  description: 'Run a script from package.json (yarn-run-style) or a GJS bundle file. If <target> resolves to a file on disk (or has a path-like prefix), it is launched via gjs with LD_LIBRARY_PATH + GI_TYPELIB_PATH set for native packages. Otherwise it is looked up in the current package.json `scripts`.',
@@ -126,6 +127,15 @@ async function runScript(script, extraArgs) {
126
127
  if (monorepoRoot && monorepoRoot !== cwd) {
127
128
  binDirs.push(join(monorepoRoot, 'node_modules', '.bin'));
128
129
  }
130
+ // Under GJS, the GJS-runnable `gjsify` shim (see `ensureGjsifyShimOnPath`)
131
+ // MUST shadow the workspace `node_modules/.bin/gjsify`, which is the Node
132
+ // entry and unusable in a node-free sandbox. So put the shim dir ahead of
133
+ // the `.bin` dirs in the child's PATH — otherwise a compound script like
134
+ // `gjsify run a && gjsify run b` resolves `gjsify` to the Node bin and
135
+ // fails. `GJSIFY_SHIM_DIR` is only set under GJS, so this is a no-op on Node.
136
+ if (process.env.GJSIFY_SHIM_DIR) {
137
+ binDirs.unshift(process.env.GJSIFY_SHIM_DIR);
138
+ }
129
139
  // Default FORCE_COLOR=1 when not already set, matching yarn / npm
130
140
  // script-runner behaviour. Without this, tools that check
131
141
  // `process.stdout.isTTY` (chalk, picocolors, biome, …) disable colors
@@ -151,7 +161,7 @@ async function runScript(script, extraArgs) {
151
161
  // Anything fancier (compound `&&`, pipes, a non-gjsify command) falls
152
162
  // through to the shell spawn below. Node keeps spawning (cheap there); this
153
163
  // only triggers under GJS, where the nesting actually hurts. See cli-app.ts.
154
- const inProcArgv = runningUnderGjs() ? gjsifyInProcessArgv(literal, extraArgs) : null;
164
+ const inProcArgv = isGjs() ? gjsifyInProcessArgv(literal, extraArgs) : null;
155
165
  if (inProcArgv) {
156
166
  // The subcommand runs in our process, so surface the script env on
157
167
  // `process.env` (PATH with the workspace .bin dirs, npm_* lifecycle
@@ -203,11 +213,6 @@ function shellEscape(arg) {
203
213
  return arg;
204
214
  return `'${arg.replace(/'/g, "'\\''")}'`;
205
215
  }
206
- // Detected at CALL time (a module-load const reads `undefined` in the bundled
207
- // `--app gjs` form). `bundler-pick.ts` uses the same expression.
208
- function runningUnderGjs() {
209
- return typeof globalThis.imports?.gi !== 'undefined';
210
- }
211
216
  /**
212
217
  * If `literal` is a SINGLE `gjsify <subcommand> …` command with no shell
213
218
  * operators / substitutions / unquoted globs, return the argv to feed `runCli`
@@ -17,6 +17,7 @@
17
17
  import { spawn } from 'node:child_process';
18
18
  import { buildDependencyGraph, discoverWorkspaces, topologicalSort, } from '@gjsify/workspace';
19
19
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
20
+ import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
20
21
  export const workspaceCommand = {
21
22
  command: 'workspace <name> <script> [args..]',
22
23
  description: 'Run a workspace script (`yarn workspace <name> run <script>` equivalent).',
@@ -203,6 +204,12 @@ async function runOne(ws, script, extraArgs, runner, verbose) {
203
204
  });
204
205
  }
205
206
  function detectPackageManager() {
207
+ // Under GJS there is no Node, so neither `npm` nor `yarn` can run scripts —
208
+ // the only option is `gjsify` itself, resolved via the GJS shim on PATH
209
+ // (see `ensureGjsifyShimOnPath`). This is what makes node-free multi-package
210
+ // orchestration work in the Flatpak sandbox.
211
+ if (isGjs())
212
+ return 'gjsify';
206
213
  const ua = process.env.npm_config_user_agent ?? '';
207
214
  if (ua.startsWith('yarn/'))
208
215
  return 'yarn';
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Ensure a GJS-runnable `gjsify` is on `process.env.PATH` for child processes.
3
+ * Idempotent per process; children inherit the shim via `GJSIFY_SHIM_DIR` and
4
+ * reuse it instead of creating their own. No-op when not running under GJS.
5
+ */
6
+ export declare function ensureGjsifyShimOnPath(): void;
@@ -0,0 +1,52 @@
1
+ // Node-free orchestration: a GJS-runnable `gjsify` on PATH.
2
+ //
3
+ // The CLI's npm `bin` (`node_modules/.bin/gjsify`) is the NODE entry — useless
4
+ // in a node-free environment (the Flatpak sandbox has no Node). But multi-
5
+ // package orchestration and compound package scripts resolve `gjsify` from
6
+ // PATH and would hit that Node bin:
7
+ // - `gjsify workspace`/`foreach` spawn `<pm> run <script>` per package; under
8
+ // GJS the package manager is `gjsify` itself, and `spawn('gjsify', …)`
9
+ // searches PATH.
10
+ // - a compound script like `gjsify run a && gjsify run b` is executed via
11
+ // `/bin/sh -c …`, and the shell resolves each `gjsify` from PATH.
12
+ // Both fail without Node (`spawn npm ENOENT`, `gjsify: not found`).
13
+ //
14
+ // Fix: under GJS, write a tiny wrapper that re-invokes the running GJS bundle
15
+ // under `gjs -m`, and prepend its directory to `process.env.PATH` so every
16
+ // child process (and grandchild, via the inherited `GJSIFY_SHIM_DIR` marker)
17
+ // resolves `gjsify` to a GJS-runnable executable. No-op on Node.
18
+ import { mkdtempSync, writeFileSync, chmodSync, existsSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join, delimiter } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
23
+ let _ensured = false;
24
+ /**
25
+ * Ensure a GJS-runnable `gjsify` is on `process.env.PATH` for child processes.
26
+ * Idempotent per process; children inherit the shim via `GJSIFY_SHIM_DIR` and
27
+ * reuse it instead of creating their own. No-op when not running under GJS.
28
+ */
29
+ export function ensureGjsifyShimOnPath() {
30
+ if (_ensured || !isGjs())
31
+ return;
32
+ _ensured = true;
33
+ // A parent gjsify (also under GJS) already created the shim — reuse it so
34
+ // nested orchestration doesn't pile up temp dirs / PATH entries.
35
+ const inherited = process.env.GJSIFY_SHIM_DIR;
36
+ if (inherited && existsSync(join(inherited, 'gjsify'))) {
37
+ if (!(process.env.PATH ?? '').split(delimiter).includes(inherited)) {
38
+ process.env.PATH = inherited + delimiter + (process.env.PATH ?? '');
39
+ }
40
+ return;
41
+ }
42
+ // `import.meta.url` resolves to the running GJS bundle (cli.gjs.mjs) — the
43
+ // whole CLI collapses into that single file, so this module's URL is it.
44
+ const selfBundle = fileURLToPath(import.meta.url);
45
+ const gjs = process.env.GJS_CONSOLE || 'gjs';
46
+ const dir = mkdtempSync(join(tmpdir(), 'gjsify-shim-'));
47
+ const shim = join(dir, 'gjsify');
48
+ writeFileSync(shim, `#!/bin/sh\nexec "${gjs}" -m "${selfBundle}" "$@"\n`, { mode: 0o755 });
49
+ chmodSync(shim, 0o755);
50
+ process.env.GJSIFY_SHIM_DIR = dir;
51
+ process.env.PATH = dir + delimiter + (process.env.PATH ?? '');
52
+ }
@@ -53,6 +53,7 @@ import { createRequire } from 'node:module';
53
53
  import { pathToFileURL } from 'node:url';
54
54
  import { findWorkspaceRoot } from './workspace-root.js';
55
55
  import { resolveNpmPackage } from './resolve-npm-package.js';
56
+ import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
56
57
  /**
57
58
  * Map Node.js `process.platform` + `arch` (+ musl on Linux) to the
58
59
  * napi-rs binding-package suffix oxc uses, e.g. `linux-x64-gnu`,
@@ -210,8 +211,7 @@ async function tryLoadNativeOxfmt() {
210
211
  if (_nativeOxfmtProbe)
211
212
  return _nativeOxfmtProbe;
212
213
  _nativeOxfmtProbe = (async () => {
213
- const isGjs = typeof globalThis.imports?.gi !== 'undefined';
214
- if (!isGjs)
214
+ if (!isGjs())
215
215
  return null;
216
216
  try {
217
217
  const specifier = '@gjsify/oxfmt-native';
@@ -249,8 +249,7 @@ export async function shouldUseNativeOxfmt() {
249
249
  }
250
250
  return true;
251
251
  }
252
- const isGjs = typeof globalThis.imports?.gi !== 'undefined';
253
- if (!isGjs)
252
+ if (!isGjs())
254
253
  return false;
255
254
  return (await tryLoadNativeOxfmt()) !== null;
256
255
  }
@@ -7,6 +7,26 @@ export interface PluginByName {
7
7
  /** Options forwarded to the plugin factory. */
8
8
  options?: unknown;
9
9
  }
10
+ /**
11
+ * Strategy for turning a resolved plugin file path into its module namespace.
12
+ *
13
+ * On Node this is a plain dynamic `import()`. Under GJS it cannot be — GJS's
14
+ * native ESM loader does not follow npm `package.json#exports` subpath maps
15
+ * for bare specifiers, so importing a plugin whose source does
16
+ * `import … from '@scope/pkg/internal-subpath'` throws
17
+ * `Module not found: @scope/pkg/internal-subpath` even when the file is on
18
+ * disk. The CLI injects a GJS strategy that first bundles the plugin to a
19
+ * single self-contained ESM file (Rolldown resolves the exports map at
20
+ * bundle time), then imports that. See `BuildAction.loadPluginViaGjsBundle`.
21
+ */
22
+ export type LoadPluginModule = (resolvedPath: string, pluginName: string) => Promise<Record<string, unknown>>;
23
+ export interface ResolveUserPluginsOptions {
24
+ /**
25
+ * Override how a resolved plugin path is turned into its module
26
+ * namespace. Defaults to a direct dynamic `import()` (correct on Node).
27
+ */
28
+ loadModule?: LoadPluginModule;
29
+ }
10
30
  /** Type-guard: a `PluginByName` shape rather than a Rolldown plugin object. */
11
31
  export declare function isPluginByName(value: unknown): value is PluginByName;
12
32
  /**
@@ -18,4 +38,4 @@ export declare function isPluginByName(value: unknown): value is PluginByName;
18
38
  * Throws when a name fails to resolve, when the chosen export is not a
19
39
  * function, or when the factory returns nothing.
20
40
  */
21
- export declare function resolveUserPlugins(plugins: ReadonlyArray<RolldownPluginOption | PluginByName>, projectDir: string): Promise<RolldownPluginOption[]>;
41
+ export declare function resolveUserPlugins(plugins: ReadonlyArray<RolldownPluginOption | PluginByName>, projectDir: string, options?: ResolveUserPluginsOptions): Promise<RolldownPluginOption[]>;
@@ -15,6 +15,8 @@
15
15
  import { createRequire } from 'node:module';
16
16
  import { join } from 'node:path';
17
17
  import { pathToFileURL } from 'node:url';
18
+ /** Default loader: a plain dynamic import of the resolved file URL (Node path). */
19
+ const defaultLoadModule = async (resolvedPath) => (await import(pathToFileURL(resolvedPath).href));
18
20
  /** Type-guard: a `PluginByName` shape rather than a Rolldown plugin object. */
19
21
  export function isPluginByName(value) {
20
22
  return (typeof value === 'object' &&
@@ -39,7 +41,8 @@ export function isPluginByName(value) {
39
41
  * Throws when a name fails to resolve, when the chosen export is not a
40
42
  * function, or when the factory returns nothing.
41
43
  */
42
- export async function resolveUserPlugins(plugins, projectDir) {
44
+ export async function resolveUserPlugins(plugins, projectDir, options = {}) {
45
+ const loadModule = options.loadModule ?? defaultLoadModule;
43
46
  const requireFromProject = createRequire(join(projectDir, 'package.json'));
44
47
  const out = [];
45
48
  for (const entry of plugins) {
@@ -56,7 +59,7 @@ export async function resolveUserPlugins(plugins, projectDir) {
56
59
  `Add it to your project's dependencies, or pass a Plugin object directly. ` +
57
60
  `(${err.message})`);
58
61
  }
59
- const mod = await import(pathToFileURL(resolvedPath).href);
62
+ const mod = await loadModule(resolvedPath, entry.name);
60
63
  const exportName = entry.export ?? 'default';
61
64
  const factory = mod[exportName];
62
65
  if (typeof factory !== 'function') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -120,19 +120,19 @@
120
120
  "cli"
121
121
  ],
122
122
  "dependencies": {
123
- "@gjsify/buffer": "^0.8.0",
124
- "@gjsify/create-app": "^0.8.0",
125
- "@gjsify/node-globals": "^0.8.0",
126
- "@gjsify/node-polyfills": "^0.8.0",
127
- "@gjsify/npm-registry": "^0.8.0",
128
- "@gjsify/resolve-npm": "^0.8.0",
129
- "@gjsify/rolldown-plugin-gjsify": "^0.8.0",
130
- "@gjsify/rolldown-plugin-pnp": "^0.8.0",
131
- "@gjsify/semver": "^0.8.0",
132
- "@gjsify/tar": "^0.8.0",
133
- "@gjsify/tsc": "^0.8.0",
134
- "@gjsify/web-polyfills": "^0.8.0",
135
- "@gjsify/workspace": "^0.8.0",
123
+ "@gjsify/buffer": "^0.10.0",
124
+ "@gjsify/create-app": "^0.10.0",
125
+ "@gjsify/node-globals": "^0.10.0",
126
+ "@gjsify/node-polyfills": "^0.10.0",
127
+ "@gjsify/npm-registry": "^0.10.0",
128
+ "@gjsify/resolve-npm": "^0.10.0",
129
+ "@gjsify/rolldown-plugin-gjsify": "^0.10.0",
130
+ "@gjsify/rolldown-plugin-pnp": "^0.10.0",
131
+ "@gjsify/semver": "^0.10.0",
132
+ "@gjsify/tar": "^0.10.0",
133
+ "@gjsify/tsc": "^0.10.0",
134
+ "@gjsify/web-polyfills": "^0.10.0",
135
+ "@gjsify/workspace": "^0.10.0",
136
136
  "cosmiconfig": "^9.0.2",
137
137
  "get-tsconfig": "^4.14.0",
138
138
  "pkg-types": "^2.3.1",
@@ -140,13 +140,13 @@
140
140
  "yargs": "^18.0.0"
141
141
  },
142
142
  "devDependencies": {
143
- "@gjsify/unit": "^0.8.0",
143
+ "@gjsify/unit": "^0.10.0",
144
144
  "@types/yargs": "^17.0.35",
145
145
  "typescript": "^6.0.3"
146
146
  },
147
147
  "peerDependencies": {
148
- "@gjsify/oxfmt-native": "^0.8.0",
149
- "@gjsify/rolldown-native": "^0.8.0"
148
+ "@gjsify/oxfmt-native": "^0.10.0",
149
+ "@gjsify/rolldown-native": "^0.10.0"
150
150
  },
151
151
  "peerDependenciesMeta": {
152
152
  "@gjsify/oxfmt-native": {