@gjsify/cli 0.21.0 → 0.23.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.
Files changed (37) hide show
  1. package/README.md +24 -1
  2. package/dist/cli.gjs.mjs +257 -184
  3. package/lib/bundler-pick.js +12 -4
  4. package/lib/commands/affected.js +22 -10
  5. package/lib/commands/check.js +38 -29
  6. package/lib/commands/flatpak/build.js +19 -18
  7. package/lib/commands/foreach.js +48 -41
  8. package/lib/commands/info.js +11 -5
  9. package/lib/commands/install.d.ts +16 -0
  10. package/lib/commands/install.js +103 -32
  11. package/lib/commands/onboard.js +6 -1
  12. package/lib/commands/pack.js +69 -4
  13. package/lib/commands/run.js +26 -14
  14. package/lib/commands/trust.js +4 -62
  15. package/lib/commands/workspace.js +49 -30
  16. package/lib/utils/bin-shim.d.ts +66 -0
  17. package/lib/utils/bin-shim.js +246 -0
  18. package/lib/utils/build-cache.d.ts +42 -6
  19. package/lib/utils/build-cache.js +114 -42
  20. package/lib/utils/detect-native-packages.d.ts +112 -6
  21. package/lib/utils/detect-native-packages.js +201 -29
  22. package/lib/utils/install-backend-native.d.ts +55 -0
  23. package/lib/utils/install-backend-native.js +152 -13
  24. package/lib/utils/install-global.d.ts +16 -0
  25. package/lib/utils/install-global.js +58 -8
  26. package/lib/utils/oxc-resolve.js +18 -55
  27. package/lib/utils/publish-headers.d.ts +2 -3
  28. package/lib/utils/publish-headers.js +64 -3
  29. package/lib/utils/publishable-packages.d.ts +22 -0
  30. package/lib/utils/publishable-packages.js +93 -0
  31. package/lib/utils/run-gjs.d.ts +6 -7
  32. package/lib/utils/run-gjs.js +4 -3
  33. package/lib/utils/spawn.d.ts +82 -0
  34. package/lib/utils/spawn.js +139 -0
  35. package/lib/utils/stdin.d.ts +25 -0
  36. package/lib/utils/stdin.js +132 -0
  37. package/package.json +23 -18
@@ -48,10 +48,18 @@ async function loadNpmRolldown() {
48
48
  // `@gjsify/rolldown-native`'s lib/ is not built yet, issue #497).
49
49
  // Fail with the actionable diagnosis instead.
50
50
  if (isGjs()) {
51
- throw new Error('gjsify build: no usable bundler engine under GJS — `@gjsify/rolldown-native` is not loadable ' +
52
- '(its JS facade lib/ is not built, or its prebuild typelib is missing from GI_TYPELIB_PATH), ' +
53
- 'and the npm `rolldown` engine is a Rust napi crate that cannot run under GJS. ' +
54
- 'Build the facade first (`gjsify workspace @gjsify/rolldown-native build` under Node) or run this build under Node.');
51
+ throw new Error('gjsify build: no usable bundler engine under GJS — `@gjsify/rolldown-native` is not loadable, ' +
52
+ 'and the npm `rolldown` engine is a Rust napi crate that cannot run under GJS.\n' +
53
+ 'Most likely cause: the CLI bundle was invoked DIRECTLY (`gjs -m …/cli.gjs.mjs` or by executing ' +
54
+ 'dist/cli.gjs.mjs), which bypasses the `gjsify` launcher that exports GI_TYPELIB_PATH / ' +
55
+ 'LD_LIBRARY_PATH for the prebuilds. The typelib lookup happens inside the GJS runtime, so those ' +
56
+ 'must be set BEFORE the process starts — the CLI cannot repair it from the inside. ' +
57
+ 'Run through the `gjsify` bin instead (`node_modules/.bin/gjsify …`, or the globally installed shim), ' +
58
+ 'or export the env yourself:\n' +
59
+ ' P=<repo>/packages/infra/rolldown-native/prebuilds/linux-x86_64\n' +
60
+ ' GI_TYPELIB_PATH=$P LD_LIBRARY_PATH=$P gjs -m …/cli.gjs.mjs build …\n' +
61
+ 'Other causes: the JS facade `lib/` is not built (`gjsify workspace @gjsify/rolldown-native build` ' +
62
+ 'under Node), or no prebuild exists for this architecture. Running the build under Node also works.');
55
63
  }
56
64
  // Indirect specifier so Rolldown's static-analysis doesn't try to
57
65
  // bundle the npm crate into a GJS target build.
@@ -47,9 +47,12 @@
47
47
  //
48
48
  // `--changed-from-stdin` skips `git diff` entirely and reads a newline-
49
49
  // separated list of paths from stdin. Useful for local debugging and
50
- // for the spec suite.
50
+ // for the spec suite. Stdin is slurped via `utils/stdin.ts`, NOT
51
+ // `readFileSync(0)` — `@gjsify/fs` has no numeric-fd path, so the Node
52
+ // idiom opens the relative file `./0` and the committed GJS classifier
53
+ // bundle died with `ENOENT … read '0'` on every `--changed-from-stdin` run.
51
54
  import { spawnSync } from 'node:child_process';
52
- import { readFileSync } from 'node:fs';
55
+ import { readStdinLines } from '../utils/stdin.js';
53
56
  import { discoverWorkspaces, buildDependencyGraph, buildReverseDependencyGraph, affectedClosure, workspacesForChangedFiles, } from '@gjsify/workspace';
54
57
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
55
58
  export const affectedCommand = {
@@ -85,7 +88,7 @@ export const affectedCommand = {
85
88
  const changedFiles = args['changed-from-stdin']
86
89
  ? readStdinLines()
87
90
  : runGitDiff(rootDir, args.base ?? 'origin/main', args.head ?? 'HEAD');
88
- const result = classifyAndExpand(workspaces, rootDir, changedFiles);
91
+ const result = classifyAndExpand(workspaces, changedFiles);
89
92
  emit(args.format, result);
90
93
  },
91
94
  };
@@ -103,6 +106,11 @@ const GLOBAL_TRIGGERS = [
103
106
  // can break assertions anywhere. Treat it as a global trigger so a
104
107
  // `@gjsify/unit` change re-runs the full suite.
105
108
  /^packages\/gjs\/unit\//,
109
+ // The composite action every CI job runs to set itself up. A change here
110
+ // alters the environment of the whole matrix, so it must force a full run
111
+ // EXPLICITLY — it already did, but only by falling through to the
112
+ // conservative "unmatched files" path, which is luck rather than intent.
113
+ /^\.github\/actions\//,
106
114
  // Cross-cutting dep + lockfile + root config.
107
115
  /^gjsify-lock\.json$/,
108
116
  /^package\.json$/,
@@ -119,7 +127,7 @@ const IGNORE = [
119
127
  /^refs\//,
120
128
  /^website\//,
121
129
  /^docs\//,
122
- /^\.github\/workflows\/(deploy-docs|commitlint|release|audit-runtimes|prebuilds|node-gi)\.yml$/,
130
+ /^\.github\/workflows\/(deploy-docs|commitlint|release|audit-runtimes|prebuilds|node-gi|napi|cli-cross-platform|build-ci-image)\.yml$/,
123
131
  /^\.githooks\//,
124
132
  // @gjsify/node-gi (the Node-native GObject-Introspection engine, Axis 5) is
125
133
  // NOT a gjsify workspace member — the GJS-first install/foreach tooling can't
@@ -128,6 +136,14 @@ const IGNORE = [
128
136
  // tested on Node). Without this carve-out its files map to no workspace and
129
137
  // land in `unmatched`, forcing a conservative full run on every node-gi PR.
130
138
  /^packages\/node-gi\//,
139
+ // @gjsify/napi (the N-API host over GJS's SpiderMonkey, the forward mirror of
140
+ // node-gi) is likewise NOT a gjsify workspace member — the GJS-first
141
+ // install/foreach tooling can't build its Vala+C++/meson shim or its node-gyp
142
+ // test addons, so the main workflow neither builds nor tests it. Its own
143
+ // `.github/workflows/napi.yml` is the source of truth. Without this carve-out
144
+ // its files map to no workspace and force a conservative full run on every
145
+ // napi PR.
146
+ /^packages\/napi\//,
131
147
  // Flatpak build/distribution tooling (SDK-extension manifest + metainfo).
132
148
  // Like `.githooks/`, it has no package-test consumers; its own
133
149
  // `tests/e2e/flatpak-sdk-extension` runs on `tests/e2e/**` / global triggers.
@@ -143,7 +159,7 @@ const IGNORE = [
143
159
  ];
144
160
  /** Patterns that suggest a test-only change. */
145
161
  const TEST_PATHS = [/\.spec\.[mc]?[tj]sx?$/, /^tests\/(e2e|integration)\//];
146
- function classifyAndExpand(workspaces, rootDir, changedFiles) {
162
+ function classifyAndExpand(workspaces, changedFiles) {
147
163
  const files = changedFiles.map((f) => f.replace(/\\/g, '/')).filter((f) => f.length > 0);
148
164
  if (files.length === 0) {
149
165
  return {
@@ -192,7 +208,7 @@ function classifyAndExpand(workspaces, rootDir, changedFiles) {
192
208
  }
193
209
  }
194
210
  // Map files → workspaces. Files outside any workspace stay in `unmatched`.
195
- const { matched, unmatched } = workspacesForChangedFiles(workspaces, rootDir, remaining);
211
+ const { matched, unmatched } = workspacesForChangedFiles(workspaces, remaining);
196
212
  // Unmatched-but-not-ignored files are suspicious enough to fall back to
197
213
  // the conservative "full run" path. Examples: a new top-level dotfile,
198
214
  // a script in `scripts/` we haven't carved out, a refs/-adjacent file.
@@ -290,10 +306,6 @@ function runGitDiff(cwd, base, head) {
290
306
  }
291
307
  return r.stdout.split('\n').filter(Boolean);
292
308
  }
293
- function readStdinLines() {
294
- const data = readFileSync(0, 'utf8');
295
- return data.split('\n').map((s) => s.trim()).filter(Boolean);
296
- }
297
309
  // ─── Output ────────────────────────────────────────────────────────────────
298
310
  function emit(format, r) {
299
311
  if (format === 'json') {
@@ -14,8 +14,9 @@
14
14
  // — if both `system-check`-style flags (`--json`) and `check`-style scripts
15
15
  // are reachable here, the `--json` flag routes through this command first
16
16
  // (since the typescript-check binding is positional `[paths..]`).
17
- import { spawn, spawnSync } from 'node:child_process';
17
+ import { spawnSync } from 'node:child_process';
18
18
  import { prefixLines } from '../utils/prefixed-output.js';
19
+ import { spawnToCompletion } from '../utils/spawn.js';
19
20
  import { readPackageJson } from '../utils/pkg-json.js';
20
21
  import { join } from 'node:path';
21
22
  import { cpus } from 'node:os';
@@ -26,36 +27,44 @@ import { findWorkspaceRoot } from '../utils/workspace-root.js';
26
27
  * indicates failure. Output is forwarded to the parent's stdout/stderr,
27
28
  * line-prefixed with the workspace name when `prefix` is set.
28
29
  */
29
- function runCheck(ws, prefix) {
30
- return new Promise((resolve) => {
31
- const child = spawn('npm', ['run', 'check', '--if-present'], {
30
+ async function runCheck(ws, prefix) {
31
+ // `completion: 'exit'` — every path out of this command's handler ends in
32
+ // `process.exit(…)`, which quits the GLib main loop `spawn()` arms under
33
+ // GJS, so the STREAMING async path is safe here (and required: the
34
+ // blocking one would serialize the parallel workers). See utils/spawn.ts.
35
+ const flushers = [];
36
+ try {
37
+ const { code } = await spawnToCompletion('npm', ['run', 'check', '--if-present'], {
38
+ completion: 'exit',
32
39
  cwd: ws.location,
33
- stdio: prefix === null ? 'inherit' : ['ignore', 'pipe', 'pipe'],
34
- });
35
- // Shared with `foreach`: live line-prefixing on a tty, buffered
36
- // flush-on-close on a non-tty sink. The previous inline forwarder
37
- // wrote every line live — under GJS `process.stdout.write` is a
38
- // blocking `Gio.write_all`, so a parallel check into a
39
- // backpressuring CI pipe could stall the GLib loop the same way
40
- // the foreach examples build did. See utils/prefixed-output.ts.
41
- const buffered = !process.stdout.isTTY;
42
- const flushers = [];
43
- if (prefix !== null && child.stdout && child.stderr) {
44
- const tag = `[${prefix}] `;
45
- flushers.push(prefixLines(child.stdout, process.stdout, tag, buffered));
46
- flushers.push(prefixLines(child.stderr, process.stderr, tag, buffered));
47
- }
48
- child.on('close', (code) => {
49
- for (const flush of flushers)
50
- flush();
51
- resolve(code ?? 1);
52
- });
53
- child.on('error', () => {
54
- for (const flush of flushers)
55
- flush();
56
- resolve(1);
40
+ stdio: prefix === null ? 'inherit' : 'pipe',
41
+ onSpawn: (child) => {
42
+ // Shared with `foreach`: live line-prefixing on a tty, buffered
43
+ // flush-on-close on a non-tty sink. The previous inline
44
+ // forwarder wrote every line live — under GJS
45
+ // `process.stdout.write` is a blocking `Gio.write_all`, so a
46
+ // parallel check into a backpressuring CI pipe could stall the
47
+ // GLib loop the same way the foreach examples build did. See
48
+ // utils/prefixed-output.ts.
49
+ const buffered = !process.stdout.isTTY;
50
+ if (prefix !== null && child.stdout && child.stderr) {
51
+ const tag = `[${prefix}] `;
52
+ flushers.push(prefixLines(child.stdout, process.stdout, tag, buffered));
53
+ flushers.push(prefixLines(child.stderr, process.stderr, tag, buffered));
54
+ }
55
+ },
57
56
  });
58
- });
57
+ for (const flush of flushers)
58
+ flush();
59
+ return code ?? 1;
60
+ }
61
+ catch {
62
+ // Spawn failure (npm not on PATH, …) reports as a failed check —
63
+ // `gjsify check` aggregates codes rather than throwing per workspace.
64
+ for (const flush of flushers)
65
+ flush();
66
+ return 1;
67
+ }
59
68
  }
60
69
  export const checkCommand = {
61
70
  command: 'check',
@@ -3,9 +3,9 @@
3
3
  // Replaces the project-local `build-flatpak.sh`-style scripts: same flag
4
4
  // shape (manifest, build-dir, install, repo, bundle), plus a `--tarball`
5
5
  // helper for Flathub submissions.
6
- import { spawn } from 'node:child_process';
7
6
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
8
7
  import { dirname, resolve } from 'node:path';
8
+ import { spawnToCompletion } from '../../utils/spawn.js';
9
9
  export const flatpakBuildCommand = {
10
10
  command: 'build [manifest]',
11
11
  description: 'Build the Flatpak via `flatpak-builder`. Wraps a typical install + export + bundle + tarball pipeline.',
@@ -115,6 +115,12 @@ export const flatpakBuildCommand = {
115
115
  await runTar(['-czf', tarballPath, '-C', buildDir, '.'], { verbose: args.verbose });
116
116
  }
117
117
  console.log(`[gjsify flatpak build] done (${buildDir})`);
118
+ // Terminal command: exit explicitly. Under GJS the async `spawn` used
119
+ // above arms a GLib main loop (`ensureMainLoop`) that only
120
+ // `process.exit()` quits — returning here would park the CLI at 0% CPU
121
+ // after flatpak-builder had already finished. Same reason `foreach` /
122
+ // `workspace` / `check` end this way; see utils/spawn.ts.
123
+ return process.exit(0);
118
124
  },
119
125
  };
120
126
  /** Pick the first JSON file in cwd that looks like a Flatpak manifest. */
@@ -167,25 +173,20 @@ async function runFlatpak(args, opts) {
167
173
  async function runTar(args, opts) {
168
174
  return runProc('tar', args, opts, { notFoundHint: 'tar not found.' });
169
175
  }
170
- function runProc(cmd, args, opts, extra) {
176
+ async function runProc(cmd, args, opts, extra) {
171
177
  if (opts.verbose) {
172
178
  console.log(`[gjsify flatpak] ${cmd} ${args.join(' ')}`);
173
179
  }
174
- return new Promise((res, rej) => {
175
- const child = spawn(cmd, args, { stdio: 'inherit' });
176
- child.on('error', (err) => {
177
- if (err.code === 'ENOENT') {
178
- rej(new Error(`gjsify flatpak: ${extra.notFoundHint}`));
179
- }
180
- else {
181
- rej(err);
182
- }
183
- });
184
- child.on('exit', (code) => {
185
- if (code === 0)
186
- res();
187
- else
188
- rej(new Error(`gjsify flatpak: ${cmd} exited with status ${code}`));
189
- });
180
+ // `completion: 'exit'` — the handler ends in `process.exit(0)` (added for
181
+ // exactly this reason), so the GLib main loop `spawn()` arms under GJS is
182
+ // quit on the way out and the STREAMING path stays available. That matters
183
+ // here more than anywhere: `flatpak-builder` runs for minutes and its live
184
+ // progress is the whole point of `stdio: 'inherit'`. See utils/spawn.ts.
185
+ const { code } = await spawnToCompletion(cmd, args, {
186
+ completion: 'exit',
187
+ notFound: () => new Error(`gjsify flatpak: ${extra.notFoundHint}`),
190
188
  });
189
+ if (code !== 0) {
190
+ throw new Error(`gjsify flatpak: ${cmd} exited with status ${code}`);
191
+ }
191
192
  }
@@ -8,7 +8,7 @@
8
8
  // Output is line-prefixed `[<workspace-name>]` when --parallel is set,
9
9
  // matching yarn's interactive flow. Exit code is non-zero if any child
10
10
  // process failed; first failure's stderr is forwarded.
11
- import { spawn } from 'node:child_process';
11
+ import { spawnToCompletion } from '../utils/spawn.js';
12
12
  import { readFileSync, readdirSync } from 'node:fs';
13
13
  import { cpus } from 'node:os';
14
14
  import { buildDependencyGraph, discoverWorkspaces, filterWorkspaces, topologicalSort, } from '@gjsify/workspace';
@@ -602,47 +602,54 @@ function detectPackageManager() {
602
602
  return 'gjsify';
603
603
  return 'npm';
604
604
  }
605
- function spawnPrefixed(cmd, args, cwd, prefix) {
606
- // Default FORCE_COLOR=1 unless the user explicitly opted out, so tools
607
- // that key on `process.stdout.isTTY` (chalk, picocolors, …) still emit
608
- // ANSI colors when run under gjsify foreach. Mirrors yarn / npm.
609
- const colorEnv = process.env.FORCE_COLOR !== undefined || process.env.NO_COLOR !== undefined ? {} : { FORCE_COLOR: '1' };
610
- return new Promise((resolve, reject) => {
611
- const child = spawn(cmd, args, {
605
+ async function spawnPrefixed(cmd, args, cwd, prefix) {
606
+ // `completion: 'exit'` — every path out of this command's handler ends in
607
+ // `process.exit(…)` (see the handler's tail), which is what quits the GLib
608
+ // main loop `spawn()` arms under GJS. That keeps foreach on the STREAMING
609
+ // async path: live prefixed output and real parallelism, both of which the
610
+ // blocking path would destroy. See utils/spawn.ts for the full contract.
611
+ let tracked = null;
612
+ const flushers = [];
613
+ try {
614
+ const result = await spawnToCompletion(cmd, args, {
615
+ completion: 'exit',
612
616
  cwd,
613
- stdio: prefix ? ['ignore', 'pipe', 'pipe'] : 'inherit',
614
- env: { ...process.env, ...colorEnv },
617
+ stdio: prefix ? 'pipe' : 'inherit',
618
+ // Default FORCE_COLOR=1 unless the user explicitly opted out, so
619
+ // tools that key on `process.stdout.isTTY` (chalk, picocolors, …)
620
+ // still emit ANSI colors under gjsify foreach. Mirrors yarn / npm.
621
+ color: true,
622
+ onSpawn: (child) => {
623
+ tracked = child;
624
+ activeChildren.add(child);
625
+ // Under GJS, `process.stdout.write` is a BLOCKING
626
+ // `Gio.write_all`. Writing each prefixed line LIVE during a
627
+ // PARALLEL foreach to a backpressuring pipe (a CI log
628
+ // collector that drains slowly) stalls the single GLib main
629
+ // loop on a full pipe → every parallel child's pipe backs up →
630
+ // their reads stall → the whole run HANGS. (A tty or a file
631
+ // sink never backpressures, which is why it only bites in CI.)
632
+ // On a NON-tty sink we therefore BUFFER each child's prefixed
633
+ // output and flush it as ONE write when that child closes: the
634
+ // child is already done by then, so its own read can't stall,
635
+ // and concurrent flushes serialize into brief loop stalls
636
+ // instead of a deadlock. On a tty (interactive) we keep live
637
+ // line-prefixing for responsive output.
638
+ const buffered = !process.stdout.isTTY;
639
+ if (prefix && child.stdout && child.stderr) {
640
+ flushers.push(prefixLines(child.stdout, process.stdout, prefix, buffered));
641
+ flushers.push(prefixLines(child.stderr, process.stderr, prefix, buffered));
642
+ }
643
+ },
615
644
  });
616
- activeChildren.add(child);
617
- // Under GJS, `process.stdout.write` is a BLOCKING `Gio.write_all`.
618
- // Writing each prefixed line LIVE during a PARALLEL foreach to a
619
- // backpressuring pipe (a CI log collector that drains slowly) stalls
620
- // the single GLib main loop on a full pipe → every parallel child's
621
- // pipe backs up → their reads stall → the whole run HANGS. (A tty or a
622
- // file sink never backpressures, which is why it only bites in CI.)
623
- // On a NON-tty sink we therefore BUFFER each child's prefixed output
624
- // and flush it as ONE write when that child closes: the child is
625
- // already done by then, so its own read can't stall, and concurrent
626
- // flushes serialize into brief loop stalls instead of a deadlock. On a
627
- // tty (interactive) we keep live line-prefixing for responsive output.
628
- const buffered = !process.stdout.isTTY;
629
- const flushers = [];
630
- if (prefix && child.stdout && child.stderr) {
631
- flushers.push(prefixLines(child.stdout, process.stdout, prefix, buffered));
632
- flushers.push(prefixLines(child.stderr, process.stderr, prefix, buffered));
645
+ for (const flush of flushers)
646
+ flush();
647
+ if (result.code !== 0) {
648
+ throw new Error(`${cmd} ${args.join(' ')} exited with code ${result.code}`);
633
649
  }
634
- child.on('close', (code) => {
635
- activeChildren.delete(child);
636
- for (const flush of flushers)
637
- flush();
638
- if (code === 0)
639
- resolve();
640
- else
641
- reject(new Error(`${cmd} ${args.join(' ')} exited with code ${code}`));
642
- });
643
- child.on('error', (err) => {
644
- activeChildren.delete(child);
645
- reject(err);
646
- });
647
- });
650
+ }
651
+ finally {
652
+ if (tracked)
653
+ activeChildren.delete(tracked);
654
+ }
648
655
  }
@@ -20,11 +20,17 @@ export const infoCommand = {
20
20
  const cwd = process.cwd();
21
21
  const file = args.file ? resolve(args.file) : null;
22
22
  const nativePackages = detectNativePackages(cwd);
23
- const { LD_LIBRARY_PATH, GI_TYPELIB_PATH } = buildNativeEnv(nativePackages);
23
+ // Which variables this yields is host-dependent: GI_TYPELIB_PATH
24
+ // everywhere, plus the loader path variable the host actually consults
25
+ // (LD_LIBRARY_PATH on Linux, DYLD_LIBRARY_PATH on macOS, PATH on
26
+ // Windows). Iterate rather than destructuring two fixed names, or the
27
+ // printed environment is wrong off Linux.
28
+ const nativeEnv = buildNativeEnv(nativePackages);
29
+ const envEntries = Object.entries(nativeEnv).filter(([, value]) => value !== undefined);
24
30
  if (args.export) {
25
31
  // Machine-readable output for eval
26
- console.log(`export LD_LIBRARY_PATH="${LD_LIBRARY_PATH}"`);
27
- console.log(`export GI_TYPELIB_PATH="${GI_TYPELIB_PATH}"`);
32
+ for (const [key, value] of envEntries)
33
+ console.log(`export ${key}="${value}"`);
28
34
  return;
29
35
  }
30
36
  // Human-readable output
@@ -39,8 +45,8 @@ export const infoCommand = {
39
45
  }
40
46
  console.log('');
41
47
  console.log('To run your app directly with gjs, set:');
42
- console.log(` export LD_LIBRARY_PATH="${LD_LIBRARY_PATH}"`);
43
- console.log(` export GI_TYPELIB_PATH="${GI_TYPELIB_PATH}"`);
48
+ for (const [key, value] of envEntries)
49
+ console.log(` export ${key}="${value}"`);
44
50
  if (file) {
45
51
  console.log(` gjs -m ${file}`);
46
52
  }
@@ -14,4 +14,20 @@ interface InstallOptions {
14
14
  timeout: number;
15
15
  }
16
16
  export declare const installCommand: Command<unknown, InstallOptions>;
17
+ export declare function buildBinShim(wsLocation: string, nodeTarget?: string, gjsTarget?: string, nativePrebuildDirs?: string[], platform?: string): string;
18
+ /**
19
+ * `.cmd` / `.ps1` companions for {@link buildBinShim}.
20
+ *
21
+ * The `sh` shim's gjs-first / node-fallback probe has no Windows counterpart to
22
+ * express: there is no GJS host on Windows at all (no prebuilt `libgjs` — see
23
+ * `website/src/content/docs/platform-support.md`), so a workspace that ships
24
+ * both targets resolves to Node here, and a GJS-only workspace gets an honest
25
+ * `gjs`-invoking shim that fails exactly the way the `sh` one does.
26
+ *
27
+ * Exported + platform-free so it is unit-tested from Linux.
28
+ */
29
+ export declare function buildWorkspaceBinShimsForWindows(wsLocation: string, nodeTarget?: string, gjsTarget?: string, nativePrebuildDirs?: string[]): {
30
+ cmd: string;
31
+ ps1: string;
32
+ };
17
33
  export {};
@@ -30,18 +30,40 @@
30
30
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
31
31
  import { dirname, join, relative, resolve } from 'node:path';
32
32
  import { fileURLToPath } from 'node:url';
33
- import { spawn, spawnSync } from 'node:child_process';
33
+ import { spawnSync } from 'node:child_process';
34
34
  import { gjsExit } from '@gjsify/rolldown-plugin-gjsify/runtime';
35
35
  import { discoverWorkspaces } from '@gjsify/workspace';
36
36
  import { buildInstallCommand, detectPackageManager, runMinimalChecks } from '../utils/check-system-deps.js';
37
- import { detectNativePackages } from '../utils/detect-native-packages.js';
37
+ import { detectNativePackages, libraryPathVar } from '../utils/detect-native-packages.js';
38
+ import { buildLauncherShims } from '../utils/bin-shim.js';
38
39
  import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
39
40
  import { atomicWriteStrict } from '../utils/install-cache-fs.js';
40
41
  import { acquireInstallLock } from '../utils/install-lock.js';
41
42
  import { nodeBinary } from '../utils/run-node.js';
43
+ import { spawnToCompletion } from '../utils/spawn.js';
42
44
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
43
45
  import { binDirOnPath, defaultGlobalLayout, installGjsEnginePackages, linkGlobalBins, specToPackageName, } from '../utils/install-global.js';
44
46
  import { addDependencyEntry, defaultRangeFromVersion, parseSpec, projectSpecsFromPackageJson, readPackageJson, writePackageJson, } from '../utils/pkg-json-edit.js';
47
+ /**
48
+ * Link type for the workspace↔workspace directory links.
49
+ *
50
+ * Windows cannot create a *directory symlink* without elevation or Developer
51
+ * Mode (`EPERM`), but any user can create an NTFS **junction** — which is why
52
+ * npm and yarn both use junctions for workspace links. These two call sites had
53
+ * no try/catch, so a non-elevated Windows workspace install hard-failed here.
54
+ */
55
+ const WORKSPACE_LINK_TYPE = process.platform === 'win32' ? 'junction' : undefined;
56
+ /**
57
+ * The `target` argument for a workspace link.
58
+ *
59
+ * Node normalises a `'junction'` target with `path.resolve()` — i.e. against
60
+ * the process CWD, NOT the link's own directory — so a junction MUST be given
61
+ * an absolute path. POSIX symlinks stay relative so the tree survives being
62
+ * moved.
63
+ */
64
+ function workspaceLinkTarget(linkPath, absTarget) {
65
+ return WORKSPACE_LINK_TYPE === 'junction' ? resolve(absTarget) : relative(dirname(linkPath), absTarget);
66
+ }
45
67
  // Default 30min wall-clock budget for the full install. Big workspaces
46
68
  // (212+ sub-packages × 600+ external deps in the gjsify monorepo itself)
47
69
  // can legitimately take 10-20 min on a fresh CI install when the npm CDN
@@ -621,7 +643,7 @@ async function workspaceInstallLocked(cwd, args, signal) {
621
643
  continue;
622
644
  const linkPath = join(target.location, 'node_modules', link.depName);
623
645
  parentDirs.add(dirname(linkPath));
624
- const relTarget = relative(dirname(linkPath), link.targetLocation);
646
+ const relTarget = workspaceLinkTarget(linkPath, link.targetLocation);
625
647
  plans.push({ linkPath, relTarget });
626
648
  }
627
649
  // Phase 1: one mkdir per unique parent (max ~213 instead of ~793).
@@ -638,13 +660,13 @@ async function workspaceInstallLocked(cwd, args, signal) {
638
660
  // `{ recursive: true, force: true }` handles every shape (rm
639
661
  // no-ops on missing paths under force; recursive covers dirs).
640
662
  try {
641
- await fsp.rm(linkPath, { recursive: true, force: true });
663
+ await fsp.rm(linkPath, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
642
664
  }
643
665
  catch {
644
666
  /* unexpected; symlink call below will surface the real
645
667
  cause if it persists. */
646
668
  }
647
- await fsp.symlink(relTarget, linkPath);
669
+ await fsp.symlink(relTarget, linkPath, WORKSPACE_LINK_TYPE);
648
670
  };
649
671
  for (let i = 0; i < Math.min(SYMLINK_CONCURRENCY, plans.length); i++) {
650
672
  workers.push((async () => {
@@ -704,8 +726,7 @@ async function workspaceInstallLocked(cwd, args, signal) {
704
726
  if (existsHere)
705
727
  continue;
706
728
  mkdirSync(dirname(linkPath), { recursive: true });
707
- const relTarget = relative(dirname(linkPath), ws.location);
708
- symlinkSync(relTarget, linkPath);
729
+ symlinkSync(workspaceLinkTarget(linkPath, ws.location), linkPath, WORKSPACE_LINK_TYPE);
709
730
  rootHoisted++;
710
731
  }
711
732
  if (rootHoisted > 0) {
@@ -912,16 +933,27 @@ function writeWorkspaceBinShims(cwd, workspaces) {
912
933
  mkdirSync(wsBinDir, { recursive: true });
913
934
  for (const [binName, { nodeTarget, gjsTarget }] of merged) {
914
935
  const linkPath = join(wsBinDir, binName);
915
- try {
916
- rmSync(linkPath, { force: true });
917
- }
918
- catch {
919
- /* fine */
936
+ for (const file of [linkPath, `${linkPath}.cmd`, `${linkPath}.ps1`]) {
937
+ try {
938
+ rmSync(file, { force: true, maxRetries: 10, retryDelay: 100 });
939
+ }
940
+ catch {
941
+ /* fine */
942
+ }
920
943
  }
921
944
  writeFileSync(linkPath, buildBinShim(ws.location, nodeTarget, gjsTarget, nativePrebuildDirs), {
922
945
  mode: 0o755,
923
946
  });
924
947
  chmodSync(linkPath, 0o755);
948
+ // Windows executes neither an extension-less file nor a `#!` line —
949
+ // the sh shim above is only reachable from git-bash/MSYS/WSL. Write
950
+ // the `.cmd`/`.ps1` companions cmd.exe and pwsh need, mirroring what
951
+ // npm's cmd-shim lays down next to every linked bin.
952
+ if (process.platform === 'win32') {
953
+ for (const [suffix, contents] of Object.entries(buildWorkspaceBinShimsForWindows(ws.location, nodeTarget, gjsTarget, nativePrebuildDirs))) {
954
+ writeFileSync(`${linkPath}.${suffix}`, contents, { mode: 0o755 });
955
+ }
956
+ }
925
957
  wsBinsCreated++;
926
958
  }
927
959
  }
@@ -997,19 +1029,25 @@ function scopedOverrideFor(ws, depName, extracted) {
997
1029
  }
998
1030
  return undefined;
999
1031
  }
1000
- function buildBinShim(wsLocation, nodeTarget, gjsTarget, nativePrebuildDirs = []) {
1032
+ export function buildBinShim(wsLocation, nodeTarget, gjsTarget, nativePrebuildDirs = [], platform = process.platform) {
1001
1033
  const nodeAbs = nodeTarget ? join(wsLocation, nodeTarget) : null;
1002
1034
  const gjsAbs = gjsTarget ? join(wsLocation, gjsTarget) : null;
1003
1035
  // GJS-only env preamble — Node ignores GI_TYPELIB_PATH so we scope the
1004
1036
  // export to the gjs branch, keeping the shim minimal when no native pkgs
1005
1037
  // exist or only the Node bin is in play.
1038
+ //
1039
+ // The library-path variable is host-dependent: `dyld` on macOS never reads
1040
+ // LD_LIBRARY_PATH, so exporting it there silently produced a launcher that
1041
+ // could not load a single `darwin-arm64` prebuild. `libraryPathVar()` is the
1042
+ // one place that mapping lives.
1043
+ const { name: libVar } = libraryPathVar(platform);
1006
1044
  const gjsPreamble = nativePrebuildDirs.length === 0
1007
1045
  ? ''
1008
1046
  : (() => {
1009
1047
  const joined = `'${nativePrebuildDirs.join(':').replace(/'/g, `'\\''`)}'`;
1010
1048
  return (`GI_TYPELIB_PATH=${joined}\${GI_TYPELIB_PATH:+":$GI_TYPELIB_PATH"}\n` +
1011
- `LD_LIBRARY_PATH=${joined}\${LD_LIBRARY_PATH:+":$LD_LIBRARY_PATH"}\n` +
1012
- `export GI_TYPELIB_PATH LD_LIBRARY_PATH\n`);
1049
+ `${libVar}=${joined}\${${libVar}:+":$${libVar}"}\n` +
1050
+ `export GI_TYPELIB_PATH ${libVar}\n`);
1013
1051
  })();
1014
1052
  if (nodeAbs && gjsAbs) {
1015
1053
  // GJS-FIRST: prefer the committed Node-free GJS bundle when `gjs` is on
@@ -1039,6 +1077,37 @@ function buildBinShim(wsLocation, nodeTarget, gjsTarget, nativePrebuildDirs = []
1039
1077
  return `#!/bin/sh\n${gjsPreamble}exec gjs -m "${gjsAbs}" "$@"\n`;
1040
1078
  throw new Error('buildBinShim: either nodeTarget or gjsTarget must be provided');
1041
1079
  }
1080
+ /**
1081
+ * `.cmd` / `.ps1` companions for {@link buildBinShim}.
1082
+ *
1083
+ * The `sh` shim's gjs-first / node-fallback probe has no Windows counterpart to
1084
+ * express: there is no GJS host on Windows at all (no prebuilt `libgjs` — see
1085
+ * `website/src/content/docs/platform-support.md`), so a workspace that ships
1086
+ * both targets resolves to Node here, and a GJS-only workspace gets an honest
1087
+ * `gjs`-invoking shim that fails exactly the way the `sh` one does.
1088
+ *
1089
+ * Exported + platform-free so it is unit-tested from Linux.
1090
+ */
1091
+ export function buildWorkspaceBinShimsForWindows(wsLocation, nodeTarget, gjsTarget, nativePrebuildDirs = []) {
1092
+ const nodeAbs = nodeTarget ? join(wsLocation, nodeTarget) : null;
1093
+ const gjsAbs = gjsTarget ? join(wsLocation, gjsTarget) : null;
1094
+ if (nodeAbs)
1095
+ return buildLauncherShims({ interpreter: 'node', target: nodeAbs });
1096
+ if (gjsAbs) {
1097
+ // Windows has no dedicated library-path variable — LoadLibrary searches
1098
+ // PATH — so the prebuild dirs go there, `;`-separated (which is also
1099
+ // GLib's G_SEARCHPATH_SEPARATOR on Windows, hence the same join for
1100
+ // GI_TYPELIB_PATH).
1101
+ const joined = nativePrebuildDirs.join(';');
1102
+ return buildLauncherShims({
1103
+ interpreter: 'gjs',
1104
+ interpreterArgs: ['-m'],
1105
+ target: gjsAbs,
1106
+ prependEnv: nativePrebuildDirs.length === 0 ? {} : { GI_TYPELIB_PATH: joined, PATH: joined },
1107
+ });
1108
+ }
1109
+ throw new Error('buildWorkspaceBinShimsForWindows: either nodeTarget or gjsTarget must be provided');
1110
+ }
1042
1111
  /**
1043
1112
  * Walk a workspace's `bin` (Node) + `gjsify.bin` (GJS) declarations
1044
1113
  * into a unified `<binName> → {nodeTarget?, gjsTarget?}` map. The
@@ -1092,23 +1161,25 @@ async function projectInstallViaNpm(args) {
1092
1161
  await spawnNpm(npmArgs);
1093
1162
  }
1094
1163
  async function spawnNpm(npmArgs) {
1095
- return new Promise((resolve, reject) => {
1096
- const child = spawn('npm', npmArgs, { stdio: 'inherit' });
1097
- child.on('close', (code) => {
1098
- if (code === 0)
1099
- resolve();
1100
- else
1101
- reject(new Error(`npm install exited with code ${code}`));
1102
- });
1103
- child.on('error', (err) => {
1104
- const code = err.code;
1105
- const msg = code === 'ENOENT'
1106
- ? 'npm not found on PATH — install Node.js first.'
1107
- : `npm install failed: ${err.message}`;
1108
- reject(new Error(msg));
1109
- });
1110
- }).catch((err) => {
1111
- console.error(err.message);
1164
+ // `completion: 'return'` — the `--backend npm` branch of the handler
1165
+ // RETURNS after `runPostInstallChecks()` instead of exiting, so under GJS
1166
+ // this must not leave the main loop `spawn()` arms parked (see
1167
+ // utils/spawn.ts). The npm backend needs a Node host anyway, so the
1168
+ // blocking path costs nothing in practice.
1169
+ return spawnToCompletion('npm', npmArgs, {
1170
+ completion: 'return',
1171
+ notFound: () => new Error('npm not found on PATH — install Node.js first.'),
1172
+ })
1173
+ .then(({ code }) => {
1174
+ if (code !== 0)
1175
+ throw new Error(`npm install exited with code ${code}`);
1176
+ })
1177
+ .catch((err) => {
1178
+ // A raw spawn failure still carries an errno `code`; the mapped
1179
+ // not-found hint and the non-zero-exit error do not, and already
1180
+ // read as finished sentences.
1181
+ const errno = err.code;
1182
+ console.error(errno === undefined ? err.message : `npm install failed: ${err.message}`);
1112
1183
  process.exit(1);
1113
1184
  });
1114
1185
  }