@gjsify/cli 0.22.0 → 0.24.1

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.
Files changed (47) hide show
  1. package/README.md +28 -1
  2. package/dist/cli.gjs.mjs +265 -184
  3. package/lib/actions/build.d.ts +12 -0
  4. package/lib/actions/build.js +26 -0
  5. package/lib/bundler-pick.js +56 -7
  6. package/lib/commands/affected.js +84 -14
  7. package/lib/commands/check.js +38 -29
  8. package/lib/commands/flatpak/build.js +19 -18
  9. package/lib/commands/foreach.js +119 -42
  10. package/lib/commands/info.js +11 -5
  11. package/lib/commands/install.d.ts +16 -0
  12. package/lib/commands/install.js +123 -36
  13. package/lib/commands/onboard.js +6 -1
  14. package/lib/commands/pack.js +69 -4
  15. package/lib/commands/run.js +26 -14
  16. package/lib/commands/showcase.js +17 -13
  17. package/lib/commands/storybook.js +5 -2
  18. package/lib/commands/trust.js +4 -62
  19. package/lib/commands/workspace.js +49 -30
  20. package/lib/utils/bin-shim.d.ts +66 -0
  21. package/lib/utils/bin-shim.js +246 -0
  22. package/lib/utils/build-cache.d.ts +42 -6
  23. package/lib/utils/build-cache.js +114 -42
  24. package/lib/utils/detect-native-packages.d.ts +118 -7
  25. package/lib/utils/detect-native-packages.js +213 -29
  26. package/lib/utils/gjs-bundle-guard.d.ts +32 -0
  27. package/lib/utils/gjs-bundle-guard.js +103 -0
  28. package/lib/utils/install-backend-native.d.ts +24 -0
  29. package/lib/utils/install-backend-native.js +89 -16
  30. package/lib/utils/install-global.d.ts +16 -0
  31. package/lib/utils/install-global.js +58 -8
  32. package/lib/utils/oxc-resolve.js +25 -57
  33. package/lib/utils/publish-headers.d.ts +2 -3
  34. package/lib/utils/publish-headers.js +64 -3
  35. package/lib/utils/publishable-packages.d.ts +22 -0
  36. package/lib/utils/publishable-packages.js +93 -0
  37. package/lib/utils/run-gjs.d.ts +6 -7
  38. package/lib/utils/run-gjs.js +4 -3
  39. package/lib/utils/run-node.d.ts +16 -0
  40. package/lib/utils/run-node.js +28 -7
  41. package/lib/utils/runtimes.d.ts +21 -0
  42. package/lib/utils/runtimes.js +23 -0
  43. package/lib/utils/spawn.d.ts +82 -0
  44. package/lib/utils/spawn.js +139 -0
  45. package/lib/utils/stdin.d.ts +25 -0
  46. package/lib/utils/stdin.js +132 -0
  47. package/package.json +23 -18
@@ -23,7 +23,8 @@
23
23
  import * as fs from 'node:fs';
24
24
  import * as os from 'node:os';
25
25
  import * as path from 'node:path';
26
- import { detectNativePackages } from './detect-native-packages.js';
26
+ import { detectNativePackages, libraryPathVar } from './detect-native-packages.js';
27
+ import { buildLauncherShims, parseShebang } from './bin-shim.js';
27
28
  /**
28
29
  * Compute the canonical global install layout for the current user. Honours
29
30
  * `XDG_DATA_HOME` (per the XDG Base Directory Spec) plus
@@ -174,7 +175,9 @@ export function linkGlobalBins(packageNames, layout) {
174
175
  /* best effort */
175
176
  }
176
177
  const linkPath = path.join(layout.binDir, binName);
177
- fs.rmSync(linkPath, { force: true });
178
+ for (const stale of [linkPath, `${linkPath}.cmd`, `${linkPath}.ps1`]) {
179
+ fs.rmSync(stale, { force: true, maxRetries: 10, retryDelay: 100 });
180
+ }
178
181
  // Inline `${target}` directly — this file is rewritten on every
179
182
  // install, paths are user-owned, and POSIX `sh` quoting via
180
183
  // single-quotes plus `'\''` for embedded quotes is well-defined.
@@ -196,6 +199,36 @@ export function linkGlobalBins(packageNames, layout) {
196
199
  : `#!/bin/sh\nexec ${shQuote(targetAbs)} "$@"\n`;
197
200
  fs.writeFileSync(linkPath, launcher);
198
201
  fs.chmodSync(linkPath, 0o755);
202
+ // Windows runs neither an extension-less file nor a `#!` line, so
203
+ // the `sh` launcher above is only reachable from git-bash/MSYS/WSL.
204
+ // Write the `.cmd`/`.ps1` companions cmd.exe and pwsh need — the
205
+ // same three-file layout npm's cmd-shim produces.
206
+ if (process.platform === 'win32') {
207
+ const joined = nativePrebuildDirs.join(';');
208
+ // No dedicated DLL-search variable on Windows: LoadLibrary
209
+ // searches PATH, and GLib's search-path separator is `;` there
210
+ // too — so both variables take the same `;`-joined value.
211
+ const prependEnv = isGjsBundle && nativePrebuildDirs.length > 0 ? { GI_TYPELIB_PATH: joined, PATH: joined } : {};
212
+ // A non-bundle bin is exec'd directly by the sh launcher, which
213
+ // relies on its `#!` line; batch/pwsh have no shebang support,
214
+ // so read the interpreter out of it the way cmd-shim does
215
+ // (defaulting to `node`, what every npm bin declares).
216
+ const shebang = isGjsBundle ? null : readShebangOf(targetAbs);
217
+ const shims = isGjsBundle
218
+ ? buildLauncherShims({
219
+ interpreter: 'gjs',
220
+ interpreterArgs: ['-m'],
221
+ target: targetAbs,
222
+ prependEnv,
223
+ })
224
+ : buildLauncherShims({
225
+ interpreter: shebang?.prog || 'node',
226
+ interpreterArgs: shebang?.args.trim() ? shebang.args.trim().split(/\s+/) : [],
227
+ target: targetAbs,
228
+ });
229
+ fs.writeFileSync(`${linkPath}.cmd`, shims.cmd);
230
+ fs.writeFileSync(`${linkPath}.ps1`, shims.ps1);
231
+ }
199
232
  created.push({ name: binName, target: targetAbs, link: linkPath });
200
233
  }
201
234
  }
@@ -206,20 +239,37 @@ function shQuote(s) {
206
239
  }
207
240
  /**
208
241
  * Build the POSIX-sh `export` lines that prepend the given prebuild
209
- * directories to GI_TYPELIB_PATH and LD_LIBRARY_PATH. Any pre-existing value
210
- * inherited from the user's environment is preserved as a suffix so
211
- * user-installed typelibs/libraries still resolve.
242
+ * directories to GI_TYPELIB_PATH and the host's shared-library search path.
243
+ * Any pre-existing value inherited from the user's environment is preserved as
244
+ * a suffix so user-installed typelibs/libraries still resolve.
245
+ *
246
+ * The library variable is host-dependent — `LD_LIBRARY_PATH` on Linux,
247
+ * `DYLD_LIBRARY_PATH` on macOS (dyld never reads the ELF one), so a launcher
248
+ * written on a Mac used to export a variable nothing consults, leaving every
249
+ * `darwin-arm64` prebuild unloadable. `platform` is a parameter so both
250
+ * branches are unit-testable from either host.
212
251
  *
213
252
  * Returns the empty string when no prebuilds were found — avoids emitting an
214
253
  * inert assignment in the launcher.
215
254
  */
216
- function buildLauncherEnvPreamble(prebuildsDirs) {
255
+ export function buildLauncherEnvPreamble(prebuildsDirs, platform = process.platform) {
217
256
  if (prebuildsDirs.length === 0)
218
257
  return '';
258
+ const { name: libVar } = libraryPathVar(platform);
219
259
  const joined = shQuote(prebuildsDirs.join(':'));
220
260
  return (`GI_TYPELIB_PATH=${joined}\${GI_TYPELIB_PATH:+":$GI_TYPELIB_PATH"}\n` +
221
- `LD_LIBRARY_PATH=${joined}\${LD_LIBRARY_PATH:+":$LD_LIBRARY_PATH"}\n` +
222
- `export GI_TYPELIB_PATH LD_LIBRARY_PATH\n`);
261
+ `${libVar}=${joined}\${${libVar}:+":$${libVar}"}\n` +
262
+ `export GI_TYPELIB_PATH ${libVar}\n`);
263
+ }
264
+ /** Parse a bin file's `#!` line; `null` when unreadable or not a shebang. */
265
+ function readShebangOf(file) {
266
+ try {
267
+ const head = fs.readFileSync(file, 'utf-8');
268
+ return parseShebang(head.trimStart().split(/\r*\n/, 1)[0] ?? '');
269
+ }
270
+ catch {
271
+ return null;
272
+ }
223
273
  }
224
274
  function pickBinMap(pkgName, pkgJson) {
225
275
  const gjsifyEntry = pkgJson.gjsify;
@@ -46,14 +46,14 @@
46
46
  // lives in the Node launcher, so a native oxlint bridge could only run the
47
47
  // Rust rule subset. Lint stays Node-spawned until that trade-off is decided
48
48
  // (tracked in STATUS.md Open TODOs).
49
- import { existsSync, readFileSync } from 'node:fs';
49
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
50
50
  import { join, resolve } from 'node:path';
51
- import { spawn, spawnSync } from 'node:child_process';
52
51
  import { createRequire } from 'node:module';
53
52
  import { pathToFileURL } from 'node:url';
54
53
  import { findWorkspaceRoot } from './workspace-root.js';
55
54
  import { resolveNpmPackage } from './resolve-npm-package.js';
56
55
  import { nodeBinary } from './run-node.js';
56
+ import { spawnToCompletion } from './spawn.js';
57
57
  import { isGjs, gjsExit } from '@gjsify/rolldown-plugin-gjsify/runtime';
58
58
  /**
59
59
  * Map Node.js `process.platform` + `arch` (+ musl on Linux) to the
@@ -89,7 +89,12 @@ function oxcBindingSuffix() {
89
89
  // glibc systems have `/lib/ld-linux-*`, musl has `/lib/ld-musl-*`.
90
90
  let libc = 'gnu';
91
91
  try {
92
- const { readdirSync } = require('node:fs');
92
+ // `readdirSync` is imported STATICALLY at the top of this file. It
93
+ // used to be a lazy `require('node:fs')` inside this try — a bare
94
+ // `require` in an ESM module, so it threw ReferenceError, the catch
95
+ // below swallowed it, and `libc` silently stayed 'gnu': a musl host
96
+ // would be told to install the WRONG binding package. Same defect
97
+ // class as commands/affected.ts, and even quieter.
93
98
  const libEntries = readdirSync('/lib');
94
99
  if (libEntries.some((e) => e.startsWith('ld-musl-'))) {
95
100
  libc = 'musl';
@@ -298,6 +303,14 @@ export async function runOxc(tool, args, opts = {}) {
298
303
  * launcher as its first positional (yargs prints top-level help) and the parent
299
304
  * hangs waiting on a child that never does the intended work. `nodeBinary()`
300
305
  * resolves to PATH `node` under GJS, so the launcher runs on Node as required.
306
+ *
307
+ * `completion: 'return'`: `lint` / `format` / `fix` hand the tool's exit code
308
+ * to `setOxcExitCode`, which deliberately does NOT terminate the process on a
309
+ * clean run — so under GJS this must take `spawnToCompletion`'s blocking path
310
+ * (see `utils/spawn.ts`; the async one would leave the main loop `spawn()`
311
+ * arms parked at 0% CPU on the way out). That path also captures and re-emits
312
+ * the tool's stdout/stderr with a raised `maxBuffer`, which is exactly what a
313
+ * full-workspace lint needs.
301
314
  */
302
315
  function spawnOxcLauncher(tool, args, opts = {}) {
303
316
  const cwd = opts.cwd ?? process.cwd();
@@ -306,61 +319,16 @@ function spawnOxcLauncher(tool, args, opts = {}) {
306
319
  if (opts.verbose) {
307
320
  console.log(`[gjsify oxc] ${node} ${launcher} ${args.join(' ')}`);
308
321
  }
309
- // Under the GJS bundle the ASYNC spawn's exit/close event is never delivered
310
- // back to JS: the child runs, streams its output, and exits (reaped, no
311
- // zombie), but the `Gio.Subprocess` `wait_async` callback that emits
312
- // `exit`/`close` does not fire, so the parent's await hangs at 0% CPU
313
- // forever (the process sits in `SNl`). `spawnSync` uses the synchronous
314
- // `communicate()` path instead — no async callback to lose — so it
315
- // completes reliably. It blocks, which is exactly right for a CLI leaf
316
- // command that just runs the tool to completion. On Node the async spawn is
317
- // fine (and keeps the event loop free), so it is kept there.
318
- if (isGjs()) {
319
- // Capture stdout/stderr (`'inherit'` is not forwarded through the GJS
320
- // spawnSync path — the tool's diagnostics would be silently lost), then
321
- // re-emit them so oxlint/oxfmt's own output still reaches the user.
322
- // maxBuffer generous: a full-workspace lint with many findings can emit
323
- // well past the 1 MB Node default; overflow would truncate diagnostics.
324
- const r = spawnSync(node, [launcher, ...args], {
325
- stdio: ['inherit', 'pipe', 'pipe'],
326
- cwd,
327
- maxBuffer: 64 * 1024 * 1024,
328
- });
329
- if (r.error) {
330
- const code = r.error.code;
331
- if (code === 'ENOENT')
332
- return Promise.reject(new OxcNotFoundError(tool, cwd));
333
- return Promise.reject(r.error);
334
- }
335
- if (r.stdout && r.stdout.length)
336
- process.stdout.write(r.stdout);
337
- if (r.stderr && r.stderr.length)
338
- process.stderr.write(r.stderr);
339
- if (r.signal) {
340
- console.error(`[gjsify oxc] ${tool} terminated by signal ${r.signal}`);
341
- return Promise.resolve(1);
322
+ return spawnToCompletion(node, [launcher, ...args], {
323
+ completion: 'return',
324
+ cwd,
325
+ notFound: () => new OxcNotFoundError(tool, cwd),
326
+ }).then(({ code, signal }) => {
327
+ if (signal !== null) {
328
+ console.error(`[gjsify oxc] ${tool} terminated by signal ${signal}`);
329
+ return 1;
342
330
  }
343
- return Promise.resolve(r.status ?? 0);
344
- }
345
- return new Promise((res, rej) => {
346
- const spawnOpts = { stdio: 'inherit', cwd };
347
- const child = spawn(node, [launcher, ...args], spawnOpts);
348
- child.on('error', (err) => {
349
- if (err.code === 'ENOENT') {
350
- rej(new OxcNotFoundError(tool, cwd));
351
- }
352
- else {
353
- rej(err);
354
- }
355
- });
356
- child.on('exit', (code, signal) => {
357
- if (signal) {
358
- console.error(`[gjsify oxc] ${tool} terminated by signal ${signal}`);
359
- res(1);
360
- return;
361
- }
362
- res(code ?? 0);
363
- });
331
+ return code ?? 0;
364
332
  });
365
333
  }
366
334
  /** Convenience wrappers. */
@@ -24,9 +24,8 @@ export interface PublishHeaderOptions {
24
24
  */
25
25
  authorization?: string;
26
26
  /**
27
- * npm user-agent. Defaults to an npm-CLI-shaped string. The registry does
28
- * not validate the exact value, but a real UA is what npm sends; the dead
29
- * `gjsify-install/0.3.7` placeholder is not used here.
27
+ * npm user-agent. Defaults to an npm-CLI-shaped string carrying this CLI's
28
+ * real version — never a hard-coded placeholder (see {@link cliVersion}).
30
29
  */
31
30
  userAgent?: string;
32
31
  }
@@ -27,8 +27,12 @@
27
27
  // literal `@`. (gjsify's URL encoding already matches this — it was never
28
28
  // the bug.)
29
29
  //
30
- // This module is pure (no I/O, no `gi://`) so it is unit-testable on both Node
31
- // and GJS without dragging in the runtime fetch/Soup stack.
30
+ // This module pulls in no `gi://` and no runtime fetch/Soup stack, so it is
31
+ // unit-testable on both Node and GJS. Its one filesystem touch is the
32
+ // manifest walk backing the user-agent version — see `cliVersion()`.
33
+ import { readFileSync } from 'node:fs';
34
+ import { dirname } from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
32
36
  import { buildHeaders } from '@gjsify/npm-registry';
33
37
  /**
34
38
  * Percent-encode a package name for the publish PUT path, matching
@@ -85,6 +89,63 @@ export function buildPublishHeaders(url, opts) {
85
89
  headers['authorization'] = opts.authorization;
86
90
  return headers;
87
91
  }
92
+ /**
93
+ * This CLI's own `package.json#version`, memoised. Two layers, because
94
+ * `@gjsify/cli` is a DUAL-ENTRY package and neither layer covers both entries:
95
+ *
96
+ * 1. **`__PACKAGE_VERSION__`** — the compile-time define
97
+ * (`package.json#gjsify.defineFromPackageJson`), the mechanism
98
+ * `@gjsify/npm-registry` uses for the same stale-UA bug. Zero I/O, but the
99
+ * bundler only ever writes `dist/cli.gjs.mjs`: the npm-wired Node bin under
100
+ * `lib/` is emitted by plain `tsc`, which knows nothing about defines. A
101
+ * define alone would therefore have left the Node entry — the one `npm
102
+ * install @gjsify/cli` actually runs — on a permanent placeholder.
103
+ *
104
+ * 2. **an upward manifest walk** for the nearest `package.json` naming
105
+ * `@gjsify/cli`, covering `lib/`. Deliberately NOT a fixed
106
+ * `new URL('../../package.json', import.meta.url)` read: that resolves
107
+ * correctly from `lib/utils/` but points one directory too high from
108
+ * `dist/`, and the build-time `inlineStaticReads` pass that would have
109
+ * frozen it is scoped to `node_modules` paths, so first-party source never
110
+ * gets it. The walk is depth-independent and so works from `src/`, `lib/`,
111
+ * `dist/`, and from an installed `node_modules/@gjsify/cli/**` alike.
112
+ *
113
+ * `GJSIFY_CLI_PACKAGE_JSON` overrides both, matching `self-update.ts`.
114
+ */
115
+ let cachedCliVersion;
116
+ function cliVersion() {
117
+ if (cachedCliVersion !== undefined)
118
+ return cachedCliVersion;
119
+ cachedCliVersion =
120
+ (typeof __PACKAGE_VERSION__ === 'string' ? __PACKAGE_VERSION__ : '') || walkForCliVersion() || 'unknown';
121
+ return cachedCliVersion;
122
+ }
123
+ /** Nearest ancestor `package.json` whose `name` is `@gjsify/cli`; '' when none. */
124
+ function walkForCliVersion() {
125
+ const override = globalThis.process?.env?.['GJSIFY_CLI_PACKAGE_JSON'];
126
+ let dir;
127
+ try {
128
+ dir = override ? dirname(override) : dirname(fileURLToPath(import.meta.url));
129
+ }
130
+ catch {
131
+ return '';
132
+ }
133
+ for (let i = 0; i < 12; i++) {
134
+ try {
135
+ const pkg = JSON.parse(readFileSync(`${dir}/package.json`, 'utf8'));
136
+ if (pkg.name === '@gjsify/cli' && typeof pkg.version === 'string')
137
+ return pkg.version;
138
+ }
139
+ catch {
140
+ /* no manifest here (or unreadable) — keep climbing */
141
+ }
142
+ const parent = dirname(dir);
143
+ if (parent === dir)
144
+ break;
145
+ dir = parent;
146
+ }
147
+ return '';
148
+ }
88
149
  /**
89
150
  * npm-CLI-shaped user-agent. Format mirrors npm-registry-fetch's
90
151
  * `default-opts.js` (`npm@<v>/node@<v>+<arch> (<platform>)`); we substitute the
@@ -95,5 +156,5 @@ function defaultUserAgent() {
95
156
  const proc = globalThis.process;
96
157
  const arch = proc?.arch ?? 'x64';
97
158
  const platform = proc?.platform ?? 'linux';
98
- return `gjsify-publish/0.5.0 (${platform} ${arch})`;
159
+ return `gjsify-publish/${cliVersion()} (${platform} ${arch})`;
99
160
  }
@@ -0,0 +1,22 @@
1
+ import type { Workspace } from '@gjsify/workspace';
2
+ /**
3
+ * Package-group directories holding publishable packages that are NOT root
4
+ * workspaces. Add one here the moment a new non-workspace publishable group
5
+ * appears — this list is the only thing standing between such a package and a
6
+ * silently broken first release.
7
+ */
8
+ export declare const NON_WORKSPACE_PUBLISHABLE_DIRS: readonly [readonly ["packages", "node-gi"], readonly ["packages", "napi"]];
9
+ /**
10
+ * Publishable packages in {@link NON_WORKSPACE_PUBLISHABLE_DIRS}, shaped as
11
+ * `Workspace` entries so callers can treat them like any other package.
12
+ *
13
+ * Best-effort by design: a missing directory or an unreadable/malformed
14
+ * manifest is skipped rather than thrown, because this runs inside sweeps whose
15
+ * value is covering everything they can reach.
16
+ */
17
+ export declare function discoverNonWorkspacePublishables(cwd: string): Workspace[];
18
+ /**
19
+ * Merge workspace-discovered packages with the non-workspace publishables,
20
+ * deduped by name. A real workspace always wins over a scanned directory.
21
+ */
22
+ export declare function mergePublishables(workspaces: Workspace[], cwd: string): Workspace[];
@@ -0,0 +1,93 @@
1
+ // Publishable-package discovery shared by `gjsify trust` and `gjsify onboard`.
2
+ //
3
+ // `discoverWorkspaces` only sees ROOT WORKSPACES. Some publishable packages
4
+ // deliberately are not: each is an independent native engine with its own CI
5
+ // workflow, kept out of the workspace graph so a full `gjsify foreach` never
6
+ // tries to build it. They still publish through `release.yml`, so every sweep
7
+ // that reasons about "what do we publish" has to include them — otherwise the
8
+ // sweep silently under-reports and the package's first release fails the OIDC
9
+ // exchange with `404 — package not found`.
10
+ //
11
+ // That is not hypothetical: `gjsify onboard` reported 127 packages "already
12
+ // done" on 2026-07-26 while `@gjsify/napi` was neither in the list nor
13
+ // published at all, because only `packages/node-gi/*` was carved out.
14
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
15
+ import { dirname, join } from 'node:path';
16
+ /**
17
+ * Package-group directories holding publishable packages that are NOT root
18
+ * workspaces. Add one here the moment a new non-workspace publishable group
19
+ * appears — this list is the only thing standing between such a package and a
20
+ * silently broken first release.
21
+ */
22
+ export const NON_WORKSPACE_PUBLISHABLE_DIRS = [
23
+ ['packages', 'node-gi'], // @gjsify/node-gi + the @gjsify/gtk-runtime-* bundles
24
+ ['packages', 'napi'], // @gjsify/napi (the N-API host in GJS, own napi.yml)
25
+ ];
26
+ /**
27
+ * Publishable packages in {@link NON_WORKSPACE_PUBLISHABLE_DIRS}, shaped as
28
+ * `Workspace` entries so callers can treat them like any other package.
29
+ *
30
+ * Best-effort by design: a missing directory or an unreadable/malformed
31
+ * manifest is skipped rather than thrown, because this runs inside sweeps whose
32
+ * value is covering everything they can reach.
33
+ */
34
+ export function discoverNonWorkspacePublishables(cwd) {
35
+ // Walk up to the repo root. Anchored on the FIRST configured group rather
36
+ // than on `packages/` itself, which also exists inside individual packages.
37
+ let root = cwd;
38
+ const anchor = NON_WORKSPACE_PUBLISHABLE_DIRS[0];
39
+ for (let i = 0; i < 8; i++) {
40
+ if (existsSync(join(root, ...anchor)))
41
+ break;
42
+ const parent = dirname(root);
43
+ if (parent === root)
44
+ return [];
45
+ root = parent;
46
+ }
47
+ const out = [];
48
+ for (const group of NON_WORKSPACE_PUBLISHABLE_DIRS) {
49
+ const base = join(root, ...group);
50
+ if (!existsSync(base))
51
+ continue;
52
+ let entries;
53
+ try {
54
+ entries = readdirSync(base, { withFileTypes: true });
55
+ }
56
+ catch {
57
+ continue;
58
+ }
59
+ for (const entry of entries) {
60
+ if (!entry.isDirectory())
61
+ continue;
62
+ const location = join(base, entry.name);
63
+ const pkgPath = join(location, 'package.json');
64
+ if (!existsSync(pkgPath))
65
+ continue;
66
+ try {
67
+ const manifest = JSON.parse(readFileSync(pkgPath, 'utf8'));
68
+ if (!manifest || typeof manifest.name !== 'string' || manifest.name.length === 0)
69
+ continue;
70
+ out.push({
71
+ location,
72
+ relativeLocation: join(...group, entry.name),
73
+ name: manifest.name,
74
+ version: typeof manifest.version === 'string' ? manifest.version : '0.0.0',
75
+ manifest,
76
+ private: manifest.private === true,
77
+ });
78
+ }
79
+ catch {
80
+ /* unreadable / malformed — skip */
81
+ }
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+ /**
87
+ * Merge workspace-discovered packages with the non-workspace publishables,
88
+ * deduped by name. A real workspace always wins over a scanned directory.
89
+ */
90
+ export function mergePublishables(workspaces, cwd) {
91
+ const seen = new Set(workspaces.map((ws) => ws.name));
92
+ return [...workspaces, ...discoverNonWorkspacePublishables(cwd).filter((ws) => !seen.has(ws.name))];
93
+ }
@@ -1,14 +1,13 @@
1
+ import { type NativeEnv } from './detect-native-packages.js';
1
2
  /**
2
- * Pure env computation for a given bundle. Returns the LD_LIBRARY_PATH /
3
- * GI_TYPELIB_PATH values that {@link runGjsBundle} would inject into the
3
+ * Pure env computation for a given bundle. Returns the typelib +
4
+ * shared-library search paths that {@link runGjsBundle} would inject into the
4
5
  * spawned `gjs` process, plus the formatted env-prefix string used for the
5
- * `$ …` echo.
6
+ * `$ …` echo. Which library variable is set is host-dependent
7
+ * (`LD_LIBRARY_PATH` / `DYLD_LIBRARY_PATH` / `PATH`) — see `buildNativeEnv`.
6
8
  */
7
9
  export declare function computeNativeEnvForBundle(bundlePath: string, cwd?: string): {
8
- env: {
9
- LD_LIBRARY_PATH: string;
10
- GI_TYPELIB_PATH: string;
11
- };
10
+ env: NativeEnv;
12
11
  envPrefix: string;
13
12
  };
14
13
  /**
@@ -20,10 +20,11 @@ import { spawn } from 'node:child_process';
20
20
  import { dirname, resolve } from 'node:path';
21
21
  import { detectNativePackages, buildNativeEnv } from './detect-native-packages.js';
22
22
  /**
23
- * Pure env computation for a given bundle. Returns the LD_LIBRARY_PATH /
24
- * GI_TYPELIB_PATH values that {@link runGjsBundle} would inject into the
23
+ * Pure env computation for a given bundle. Returns the typelib +
24
+ * shared-library search paths that {@link runGjsBundle} would inject into the
25
25
  * spawned `gjs` process, plus the formatted env-prefix string used for the
26
- * `$ …` echo.
26
+ * `$ …` echo. Which library variable is set is host-dependent
27
+ * (`LD_LIBRARY_PATH` / `DYLD_LIBRARY_PATH` / `PATH`) — see `buildNativeEnv`.
27
28
  */
28
29
  export function computeNativeEnvForBundle(bundlePath, cwd = process.cwd()) {
29
30
  const resolvedBundle = resolve(bundlePath);
@@ -6,6 +6,22 @@ import { type ExampleRuntime } from './runtimes.js';
6
6
  * not available under the GJS-bundled CLI.
7
7
  */
8
8
  export declare function resolveNodeGi(startDir: string): string | null;
9
+ /**
10
+ * Resolve `@gjsify/node-gi` for a bundle we are about to launch: from the
11
+ * BUNDLE's own directory first, then from `cwd`.
12
+ *
13
+ * The bundle's directory is the correct base and cwd is the fallback, for the
14
+ * same reason {@link computeNativeEnvForBundle} walks both: `@gjsify/node-gi` is
15
+ * kept EXTERNAL, so what has to be satisfiable is the RUNTIME's resolution of a
16
+ * bare specifier from inside the bundle — which Node performs relative to the
17
+ * importing file, never relative to cwd. A dlx-cache layout
18
+ * (`~/.cache/gjsify/dlx/<sha>/node_modules/<pkg>/dist/bundle.mjs`) has node-gi
19
+ * sitting right beside the bundle and nothing at all under the user's cwd, so a
20
+ * cwd-only probe rejected a run that would have worked. The cwd probe is kept
21
+ * because a project-local `node_modules` legitimately satisfies a bundle built
22
+ * into a `dist/` outside it (a `--outfile /tmp/…` build from a project root).
23
+ */
24
+ export declare function resolveNodeGiForBundle(bundlePath: string, cwd: string): string | null;
9
25
  /** Pick the node executable: the current one when on Node, else PATH `node`. */
10
26
  export declare function nodeBinary(): string;
11
27
  export interface RunNodeBundleOptions {
@@ -5,10 +5,12 @@
5
5
  // through `@gjsify/node-gi` (the Axis-5 reverse bridge). Two things have to be
6
6
  // true at runtime:
7
7
  //
8
- // 1. `@gjsify/node-gi` must resolve from the project — it is kept EXTERNAL by
9
- // the bundler (a native addon), so it has to physically exist in the
10
- // consumer's node_modules. We verify this up front and emit a clear hint
11
- // instead of letting Node throw `ERR_MODULE_NOT_FOUND` deep in the bundle.
8
+ // 1. `@gjsify/node-gi` must resolve FROM THE BUNDLE — it is kept EXTERNAL by
9
+ // the bundler (a native addon), so it has to physically exist in a
10
+ // node_modules the bundle's own directory can reach (that is where Node
11
+ // will look), with the CWD as a fallback. We verify this up front and emit
12
+ // a clear hint instead of letting Node throw `ERR_MODULE_NOT_FOUND` deep in
13
+ // the bundle. See `resolveNodeGiForBundle`.
12
14
  // 2. `gi://Gtk` / `gi://Adw` etc. must resolve. System typelibs come from the
13
15
  // default GI search path, but any `@gjsify/*` Vala bridge a story pulls in
14
16
  // ships its typelib in `prebuilds/` — so we reuse the SAME
@@ -21,7 +23,7 @@
21
23
  // `gjs`, which cannot run a `--app node` bundle).
22
24
  import { spawn } from 'node:child_process';
23
25
  import { existsSync, readFileSync } from 'node:fs';
24
- import { join, resolve } from 'node:path';
26
+ import { dirname, join, resolve } from 'node:path';
25
27
  import { isNode } from '@gjsify/rolldown-plugin-gjsify/runtime';
26
28
  import { computeNativeEnvForBundle } from './run-gjs.js';
27
29
  import { RUNTIMES } from './runtimes.js';
@@ -43,6 +45,25 @@ export function resolveNodeGi(startDir) {
43
45
  dir = parent;
44
46
  }
45
47
  }
48
+ /**
49
+ * Resolve `@gjsify/node-gi` for a bundle we are about to launch: from the
50
+ * BUNDLE's own directory first, then from `cwd`.
51
+ *
52
+ * The bundle's directory is the correct base and cwd is the fallback, for the
53
+ * same reason {@link computeNativeEnvForBundle} walks both: `@gjsify/node-gi` is
54
+ * kept EXTERNAL, so what has to be satisfiable is the RUNTIME's resolution of a
55
+ * bare specifier from inside the bundle — which Node performs relative to the
56
+ * importing file, never relative to cwd. A dlx-cache layout
57
+ * (`~/.cache/gjsify/dlx/<sha>/node_modules/<pkg>/dist/bundle.mjs`) has node-gi
58
+ * sitting right beside the bundle and nothing at all under the user's cwd, so a
59
+ * cwd-only probe rejected a run that would have worked. The cwd probe is kept
60
+ * because a project-local `node_modules` legitimately satisfies a bundle built
61
+ * into a `dist/` outside it (a `--outfile /tmp/…` build from a project root).
62
+ */
63
+ export function resolveNodeGiForBundle(bundlePath, cwd) {
64
+ const resolvedBundle = resolve(bundlePath);
65
+ return resolveNodeGi(dirname(resolvedBundle)) ?? resolveNodeGi(cwd);
66
+ }
46
67
  /** Pick the node executable: the current one when on Node, else PATH `node`. */
47
68
  export function nodeBinary() {
48
69
  return isNode() && process.execPath ? process.execPath : 'node';
@@ -55,7 +76,7 @@ export function nodeBinary() {
55
76
  */
56
77
  export async function runNodeBundle(bundlePath, extraArgs = [], options = {}) {
57
78
  const cwd = process.cwd();
58
- if (!resolveNodeGi(cwd)) {
79
+ if (!resolveNodeGiForBundle(bundlePath, cwd)) {
59
80
  throw new Error('Cannot run the storybook on Node: `@gjsify/node-gi` is not installed.\n' +
60
81
  'Add @gjsify/node-gi as a dependency to run the storybook on Node, e.g.:\n' +
61
82
  ' "devDependencies": { "@gjsify/node-gi": "^0.13.0" }');
@@ -113,7 +134,7 @@ export function bundleRequiresNodeGi(bundlePath) {
113
134
  export async function runRuntimeBundle(runtime, bundlePath, extraArgs = [], options = {}) {
114
135
  const cwd = process.cwd();
115
136
  const resolvedBundle = resolve(bundlePath);
116
- if (bundleRequiresNodeGi(resolvedBundle) && !resolveNodeGi(cwd)) {
137
+ if (bundleRequiresNodeGi(resolvedBundle) && !resolveNodeGiForBundle(resolvedBundle, cwd)) {
117
138
  throw new Error(`Cannot run this bundle on ${runtime}: it uses gi:// via \`@gjsify/node-gi\`, which is not installed.\n` +
118
139
  'Add @gjsify/node-gi as a dependency to run gi:// code off GJS, e.g.:\n' +
119
140
  ' "dependencies": { "@gjsify/node-gi": "^0.13.0" }');
@@ -37,6 +37,27 @@ export declare function isRuntimeOnPath(probe: string): boolean;
37
37
  export declare function isRuntimeAvailable(runtime: ExampleRuntime): boolean;
38
38
  /** Which of gjs/node/bun/deno are runnable on this host. */
39
39
  export declare function availableRuntimes(): ExampleRuntime[];
40
+ /**
41
+ * The default `--runtime` for running a prebuilt EXAMPLE/SHOWCASE: `gjs`
42
+ * whenever gjs is runnable here, else the host runtime.
43
+ *
44
+ * This is deliberately NOT a plain `hostRuntime()`. A showcase's canonical
45
+ * artifact is its `--app gjs` bundle (`gjsify.main`) — that is what every
46
+ * showcase ships, what the `gjs` path installs via `gjsify dlx`, and what the
47
+ * docs call the default. Following the host instead means `npx @gjsify/cli
48
+ * showcase <name>` (host = node) silently asks for the `--app node` bundle,
49
+ * which most showcases do not ship and which additionally needs
50
+ * `@gjsify/node-gi` in the consumer's project — so the FIRST-RUN experience of
51
+ * the primary documented entry point failed on a missing file.
52
+ *
53
+ * Same rule `gjsify run` already applies in its bare-file path, where a
54
+ * `--app gjs` bundle stays on gjs regardless of host (`isLikelyGjsBundle`);
55
+ * here the "is it a gjs artifact" question is answered by the command instead
56
+ * of by sniffing the file. `--runtime` still overrides explicitly, and a host
57
+ * WITHOUT gjs (a plain Node/bun/deno box) keeps following the host so the
58
+ * node-capable showcases stay runnable there.
59
+ */
60
+ export declare function defaultExampleRuntime(): ExampleRuntime;
40
61
  interface ExampleGjsifyField {
41
62
  example?: {
42
63
  runtimes?: unknown;
@@ -64,6 +64,29 @@ export function isRuntimeAvailable(runtime) {
64
64
  export function availableRuntimes() {
65
65
  return EXAMPLE_RUNTIMES.filter((rt) => isRuntimeAvailable(rt));
66
66
  }
67
+ /**
68
+ * The default `--runtime` for running a prebuilt EXAMPLE/SHOWCASE: `gjs`
69
+ * whenever gjs is runnable here, else the host runtime.
70
+ *
71
+ * This is deliberately NOT a plain `hostRuntime()`. A showcase's canonical
72
+ * artifact is its `--app gjs` bundle (`gjsify.main`) — that is what every
73
+ * showcase ships, what the `gjs` path installs via `gjsify dlx`, and what the
74
+ * docs call the default. Following the host instead means `npx @gjsify/cli
75
+ * showcase <name>` (host = node) silently asks for the `--app node` bundle,
76
+ * which most showcases do not ship and which additionally needs
77
+ * `@gjsify/node-gi` in the consumer's project — so the FIRST-RUN experience of
78
+ * the primary documented entry point failed on a missing file.
79
+ *
80
+ * Same rule `gjsify run` already applies in its bare-file path, where a
81
+ * `--app gjs` bundle stays on gjs regardless of host (`isLikelyGjsBundle`);
82
+ * here the "is it a gjs artifact" question is answered by the command instead
83
+ * of by sniffing the file. `--runtime` still overrides explicitly, and a host
84
+ * WITHOUT gjs (a plain Node/bun/deno box) keeps following the host so the
85
+ * node-capable showcases stay runnable there.
86
+ */
87
+ export function defaultExampleRuntime() {
88
+ return isRuntimeAvailable('gjs') ? 'gjs' : hostRuntime();
89
+ }
67
90
  /**
68
91
  * Read a package's declared example runtimes. Returns the (validated) list, or
69
92
  * `null` when the package declares none — the "unconstrained / permissive" case.