@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
@@ -27,6 +27,7 @@
27
27
  import { spawn, spawnSync } from 'node:child_process';
28
28
  import { DEFAULT_REGISTRY, whoami } from '@gjsify/npm-registry';
29
29
  import { discoverWorkspaces, filterWorkspaces } from '@gjsify/workspace';
30
+ import { mergePublishables } from '../utils/publishable-packages.js';
30
31
  import { hasAnyCredential, loadNpmrc } from '../utils/load-npmrc.js';
31
32
  import { OtpProvider } from '../utils/npm-otp.js';
32
33
  import { publishWorkspace } from './publish.js';
@@ -142,7 +143,11 @@ export const onboardCommand = {
142
143
  const environment = args.environment;
143
144
  // 2. Enumerate publishable workspaces (non-private, exclude @girs/*).
144
145
  const root = findWorkspaceRoot(cwd) ?? cwd;
145
- const all = discoverWorkspaces(root, { includeRoot: true });
146
+ // Non-workspace publishables (packages/{node-gi,napi}/*) publish through
147
+ // release.yml but `discoverWorkspaces` cannot see them — without this
148
+ // merge the sweep reports "all done" while a package that was never
149
+ // published is simply absent from the list.
150
+ const all = mergePublishables(discoverWorkspaces(root, { includeRoot: true }), cwd);
146
151
  const selected = filterWorkspaces(all, { noPrivate: true, exclude: ['@girs/*'] }).filter((ws) => ws.name);
147
152
  if (selected.length === 0) {
148
153
  const msg = 'gjsify onboard: no publishable workspace packages found.';
@@ -29,6 +29,7 @@ import { join, resolve } from 'node:path';
29
29
  import { createTarball, gzip } from '@gjsify/tar';
30
30
  import { discoverWorkspaces } from '@gjsify/workspace';
31
31
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
32
+ import { readPackageJson } from '../utils/pkg-json.js';
32
33
  import { runLifecycleScript } from '../utils/run-lifecycle-script.js';
33
34
  export const packCommand = {
34
35
  command: 'pack [path]',
@@ -488,23 +489,87 @@ function globToRegex(glob) {
488
489
  pat = pat.replace(/\?/g, '[^/]');
489
490
  return new RegExp(`^${pat}($|/)`);
490
491
  }
492
+ const DEP_BLOCKS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
493
+ /** Every `<block>.<name>` in `pkg` whose range uses the `workspace:` protocol. */
494
+ function collectWorkspaceProtocolDeps(pkg) {
495
+ const found = [];
496
+ for (const block of DEP_BLOCKS) {
497
+ const deps = pkg[block];
498
+ if (!deps)
499
+ continue;
500
+ for (const [name, range] of Object.entries(deps)) {
501
+ if (typeof range === 'string' && range.startsWith('workspace:'))
502
+ found.push(`${block}.${name}`);
503
+ }
504
+ }
505
+ return found;
506
+ }
507
+ /**
508
+ * Monorepo root to resolve `workspace:` ranges against.
509
+ *
510
+ * `findWorkspaceRoot` is deliberately strict: it only accepts a candidate root
511
+ * whose OWN `workspaces` globs list `start` as a member (so an unrelated
512
+ * grand-parent monorepo can't be picked up). Packing needs a WIDER answer,
513
+ * because some packages live inside the monorepo on purpose WITHOUT being
514
+ * members — `packages/napi/**` and `packages/node-gi/**` are excluded from
515
+ * `package.json#workspaces` (own CI, own release cadence, see AGENTS.md). For
516
+ * those the strict lookup returns `null`, and the pre-fix code took that as
517
+ * "nothing to do" and shipped the literal `workspace:^` string.
518
+ *
519
+ * So: strict answer first, else the nearest ancestor that declares
520
+ * `workspaces` at all — the monorepo the package physically lives in, whose
521
+ * members are exactly the siblings a `workspace:` range can refer to.
522
+ */
523
+ function findPackWorkspaceRoot(start) {
524
+ const strict = findWorkspaceRoot(start);
525
+ if (strict)
526
+ return strict;
527
+ let dir = start;
528
+ for (let i = 0; i < 12; i++) {
529
+ const pkgPath = join(dir, 'package.json');
530
+ if (existsSync(pkgPath) && readPackageJson(pkgPath)?.workspaces !== undefined)
531
+ return dir;
532
+ const parent = resolve(dir, '..');
533
+ if (parent === dir)
534
+ break;
535
+ dir = parent;
536
+ }
537
+ return null;
538
+ }
491
539
  /**
492
540
  * Walk pkg.dependencies / devDependencies / peerDependencies /
493
541
  * optionalDependencies and replace `workspace:^` / `workspace:~` /
494
542
  * `workspace:*` ranges with the resolved npm range based on each sibling
495
543
  * workspace's version field.
544
+ *
545
+ * A `workspace:` range that survives into a published tarball is unresolvable
546
+ * by EVERY registry client (`npm install` fails with `Unsupported URL Type
547
+ * "workspace:"`), so there is no such thing as a harmless skip here: this
548
+ * either substitutes or throws. Same failure mode, same reasoning as
549
+ * `expandFilesPatterns` above — a packer that silently produces a broken
550
+ * tarball costs a whole release train (v0.4.20, v0.4.37).
496
551
  */
497
552
  function rewriteWorkspaceDeps(pkg, wsDir) {
498
- const root = findWorkspaceRoot(wsDir);
499
- if (!root)
553
+ const root = findPackWorkspaceRoot(wsDir);
554
+ if (!root) {
555
+ // No enclosing monorepo. Fine for a standalone package — but then a
556
+ // `workspace:` range cannot possibly be resolved, so refuse rather
557
+ // than ship it.
558
+ const unresolvable = collectWorkspaceProtocolDeps(pkg);
559
+ if (unresolvable.length > 0) {
560
+ throw new Error(`gjsify pack: ${String(pkg.name)} declares workspace: ranges (${unresolvable.join(', ')}) ` +
561
+ `but ${wsDir} is not inside a monorepo (no ancestor package.json declares "workspaces"). ` +
562
+ `A workspace: range cannot be published — replace it with a real npm range, or pack from within the monorepo.`);
563
+ }
500
564
  return pkg;
565
+ }
501
566
  const siblings = new Map();
502
567
  for (const ws of discoverWorkspaces(root)) {
503
568
  if (ws.name && ws.version)
504
569
  siblings.set(ws.name, ws.version);
505
570
  }
506
571
  const cloned = JSON.parse(JSON.stringify(pkg));
507
- for (const block of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
572
+ for (const block of DEP_BLOCKS) {
508
573
  const deps = cloned[block];
509
574
  if (!deps)
510
575
  continue;
@@ -513,7 +578,7 @@ function rewriteWorkspaceDeps(pkg, wsDir) {
513
578
  continue;
514
579
  const wsVer = siblings.get(name);
515
580
  if (!wsVer) {
516
- throw new Error(`gjsify pack: ${cloned.name} declares workspace:^ on ${name} but no sibling workspace with that name exists in the monorepo at ${root}`);
581
+ throw new Error(`gjsify pack: ${cloned.name} declares ${range} on ${name} (${block}) but no sibling workspace with that name exists in the monorepo at ${root}`);
517
582
  }
518
583
  const operator = range.slice('workspace:'.length);
519
584
  if (operator === '*' || operator === '') {
@@ -342,6 +342,17 @@ async function runScript(script, extraArgs, cwd = process.cwd()) {
342
342
  process.env.npm_package_version = env.npm_package_version;
343
343
  if (env.FORCE_COLOR !== undefined)
344
344
  process.env.FORCE_COLOR = env.FORCE_COLOR;
345
+ // Track the failure LOCALLY rather than through `process.exitCode`.
346
+ // Under GJS `process.exit()` is deferred (see the note below), so the
347
+ // `process.exit(1)` that used to live in the catch returned instead of
348
+ // terminating, execution fell through to the exit-code line below, and
349
+ // whatever that computed WON. The dispatched command's failure was then
350
+ // reported as success: `gjsify run build:gjsify` exited 0 while the
351
+ // `gjsify build` it dispatched exited 1, so `gjsify onboard` happily
352
+ // published a package whose build had just failed. Anything chaining on
353
+ // `gjsify run <script>` — every `a && b` build script, CI, onboard —
354
+ // got a false green from it.
355
+ let dispatchFailed = false;
345
356
  try {
346
357
  // Dynamic import to avoid a static cli-app ↔ run.ts import cycle;
347
358
  // cli-app is already loaded (the CLI is running through it), so this
@@ -351,21 +362,22 @@ async function runScript(script, extraArgs, cwd = process.cwd()) {
351
362
  }
352
363
  catch (err) {
353
364
  console.error(err.message);
354
- process.exit(1);
365
+ dispatchFailed = true;
355
366
  }
356
- // Honor a non-zero `process.exitCode` set by the dispatched handler
357
- // (e.g. a failing check that sets the code instead of exiting) —
358
- // a bare exit(0) here masked such failures as success.
359
- process.exit(process.exitCode != null ? Number(process.exitCode) : 0);
360
- // `process.exit()` under GJS is NOT synchronous — with the GLib main
361
- // loop armed (`ensureMainLoop`, e.g. by a bundle the in-process dispatch
362
- // just ran) it defers the actual teardown, so execution CONTINUES past
363
- // the call. Without this `return` the function falls through to the
364
- // shell-spawn fallback below and runs the SAME command a SECOND time
365
- // (the double-execution bug). Mirrors the file-mode path's `return`
366
- // after `runGjsBundle`. Keep both — the return is the real guard, the
367
- // exit sets the status code for when the loop finally unwinds.
368
- return;
367
+ // A thrown command wins over `process.exitCode`; otherwise honour a
368
+ // non-zero code the handler set instead of throwing (e.g. a failing
369
+ // check) — a bare exit(0) here masked those as success too.
370
+ const dispatchCode = dispatchFailed ? 1 : process.exitCode != null ? Number(process.exitCode) : 0;
371
+ // `return process.exit(…)`, never a bare call. Under GJS `process.exit()`
372
+ // is NOT synchronous — with the GLib main loop armed (`ensureMainLoop`,
373
+ // e.g. by a bundle the in-process dispatch just ran) it defers the
374
+ // actual teardown and RETURNS, so execution continues past the call.
375
+ // Without the `return` the function falls through to the shell-spawn
376
+ // fallback below and runs the SAME command a SECOND time (the
377
+ // double-execution bug). Mirrors the file-mode path's `return` after
378
+ // `runGjsBundle`. The return is the real guard; the exit sets the status
379
+ // code for when the loop finally unwinds.
380
+ return process.exit(dispatchCode);
369
381
  }
370
382
  const fullCmd = extraArgs.length > 0 ? `${literal} ${extraArgs.map(shellEscape).join(' ')}` : literal;
371
383
  // ensureMainLoop() (called inside spawn) keeps GJS alive after the
@@ -16,70 +16,14 @@
16
16
  //
17
17
  // Reference: refs/npm-cli/lib/trust-cmd.js + lib/commands/trust/github.js.
18
18
  import { spawnSync } from 'node:child_process';
19
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
20
- import { dirname, join } from 'node:path';
19
+ import { existsSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
21
  import { DEFAULT_REGISTRY, buildHeaders, registryFor, whoami } from '@gjsify/npm-registry';
22
22
  import { discoverWorkspaces, filterWorkspaces } from '@gjsify/workspace';
23
+ import { mergePublishables } from '../utils/publishable-packages.js';
23
24
  import { hasAnyCredential, loadNpmrc } from '../utils/load-npmrc.js';
24
25
  import { OtpProvider, withOtpRetry } from '../utils/npm-otp.js';
25
26
  import { classifyTrustList, githubTrustBody, normalizeWorkflowFile, parseRepoFromGitRemote, trustUrl, validateRepository, } from '../utils/trust-registry.js';
26
- /**
27
- * Publishable packages under `packages/node-gi/*` that are NOT root workspaces
28
- * (the workspaces glob excludes `packages/node-gi/*`) yet publish via release.yml —
29
- * `@gjsify/node-gi` + the `@gjsify/gtk-runtime-*` bundles. `discoverWorkspaces`
30
- * misses them, so `gjsify trust` would never configure their Trusted Publisher.
31
- * Enumerate them here as Workspace-shaped entries so the sweep covers them too.
32
- * Best-effort: a missing dir / unreadable manifest is simply skipped.
33
- */
34
- function discoverNodeGiPublishables(cwd) {
35
- // Walk up from cwd to the repo root — the dir that holds `packages/node-gi` —
36
- // so the command works from the root or any subdirectory.
37
- let root = cwd;
38
- for (let i = 0; i < 8; i++) {
39
- if (existsSync(join(root, 'packages', 'node-gi')))
40
- break;
41
- const parent = dirname(root);
42
- if (parent === root)
43
- return [];
44
- root = parent;
45
- }
46
- const base = join(root, 'packages', 'node-gi');
47
- if (!existsSync(base))
48
- return [];
49
- const out = [];
50
- let entries;
51
- try {
52
- entries = readdirSync(base, { withFileTypes: true });
53
- }
54
- catch {
55
- return [];
56
- }
57
- for (const entry of entries) {
58
- if (!entry.isDirectory())
59
- continue;
60
- const location = join(base, entry.name);
61
- const pkgPath = join(location, 'package.json');
62
- if (!existsSync(pkgPath))
63
- continue;
64
- try {
65
- const manifest = JSON.parse(readFileSync(pkgPath, 'utf8'));
66
- if (!manifest || typeof manifest.name !== 'string' || manifest.name.length === 0)
67
- continue;
68
- out.push({
69
- location,
70
- relativeLocation: join('packages', 'node-gi', entry.name),
71
- name: manifest.name,
72
- version: typeof manifest.version === 'string' ? manifest.version : '0.0.0',
73
- manifest,
74
- private: manifest.private === true,
75
- });
76
- }
77
- catch {
78
- /* unreadable / malformed — skip */
79
- }
80
- }
81
- return out;
82
- }
83
27
  export const trustCommand = {
84
28
  command: 'trust [packages..]',
85
29
  description: 'Configure npm Trusted Publishers (OIDC/GitHub Actions) for publishable workspace packages so release.yml can publish without a token. Native — no `npm` needed. Incremental: skips already-configured packages.',
@@ -197,9 +141,7 @@ export const trustCommand = {
197
141
  // which publish via release.yml but are NOT root workspaces, so
198
142
  // discoverWorkspaces misses them and their Trusted Publisher would never get
199
143
  // configured. Dedup by name so a real workspace always wins.
200
- const workspaceNames = new Set(all.map((ws) => ws.name));
201
- const extra = discoverNodeGiPublishables(cwd).filter((ws) => !workspaceNames.has(ws.name));
202
- const merged = [...all, ...extra];
144
+ const merged = mergePublishables(all, cwd);
203
145
  const selected = filterWorkspaces(merged, {
204
146
  noPrivate: args.private !== true,
205
147
  include: args.packages && args.packages.length > 0 ? args.packages : undefined,
@@ -1,9 +1,14 @@
1
- // `gjsify workspace <name> <script> [args..]` — yarn-workspace shortcut.
1
+ // `gjsify workspace <name> [run] <script> [args..]` — yarn-workspace shortcut.
2
2
  //
3
3
  // Equivalent to `yarn workspace <name> run <script>`: locates the named
4
4
  // workspace in the current monorepo, then runs the script there. Used
5
5
  // extensively in gjsify's own root `package.json` (17 call sites).
6
6
  //
7
+ // BOTH spellings work — the yarn-style `run` prefix is accepted and optional.
8
+ // It is deliberately NOT the default and NOT a full CLI proxy: gjsify's command
9
+ // names collide with the most common script names, so `workspace <name> build`
10
+ // must keep meaning the SCRIPT. See the note at the normalization site below.
11
+ //
7
12
  // With `--with-dependencies` / `-d` (synonym: `--topological` / `-t`) the
8
13
  // command first runs the SAME script in every transitive workspace
9
14
  // dependency, in topological build order, then in the target package.
@@ -14,7 +19,7 @@
14
19
  // <B> build && …` chains in root `build:infra` scripts: cascade build
15
20
  // failures on uninbuilt workspace deps are the single biggest local-dev
16
21
  // pain point in this monorepo.
17
- import { spawn } from 'node:child_process';
22
+ import { spawnToCompletion } from '../utils/spawn.js';
18
23
  import { buildDependencyGraph, discoverWorkspaces, topologicalSort } from '@gjsify/workspace';
19
24
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
20
25
  import { BuildCacheRunner, buildCacheEnabledByEnv } from '../utils/build-cache.js';
@@ -22,7 +27,7 @@ import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
22
27
  import { suggestClosest } from '../utils/suggest.js';
23
28
  export const workspaceCommand = {
24
29
  command: 'workspace <name> <script> [args..]',
25
- description: 'Run a workspace script (`yarn workspace <name> run <script>` equivalent).',
30
+ description: 'Run a workspace script. Both `workspace <name> <script>` and the yarn spelling `workspace <name> run <script>` work.',
26
31
  builder: (yargs) => yargs
27
32
  .positional('name', {
28
33
  description: 'Workspace name (matches package.json `name` field).',
@@ -97,10 +102,28 @@ export const workspaceCommand = {
97
102
  const verbose = args.verbose === true;
98
103
  const withDeps = args['with-dependencies'] === true;
99
104
  const continueOnError = args['continue-on-error'] === true;
105
+ // yarn spells this `yarn workspace <name> run <script>`, and the header
106
+ // comment above has always advertised that form as the equivalent — so
107
+ // accept it. `run` is an OPTIONAL prefix, not the default: gjsify's own
108
+ // command surface collides head-on with the most common script names
109
+ // (`build`, `check`, `test`, `install`, `publish`, `pack`, `lint`,
110
+ // `format`), so proxying the whole CLI the way yarn does would make
111
+ // `gjsify workspace <name> build` ambiguous between the build COMMAND
112
+ // and the build SCRIPT — and the script reading is the one every script
113
+ // in this repo relies on.
114
+ //
115
+ // A package with a literal `run` script stays reachable: the prefix is
116
+ // only consumed when something follows it (`… run build`), so a bare
117
+ // `gjsify workspace <name> run` still runs that package's `run` script.
118
+ let script = args.script;
119
+ let extraArgs = args.args ?? [];
120
+ if (script === 'run' && extraArgs.length > 0) {
121
+ [script, ...extraArgs] = extraArgs;
122
+ }
100
123
  const targetScripts = target.manifest.scripts ?? {};
101
- if (typeof targetScripts[args.script] !== 'string') {
102
- const scriptHint = suggestClosest(args.script, Object.keys(targetScripts));
103
- console.error(`gjsify workspace: workspace "${args.name}" has no script "${args.script}"` +
124
+ if (typeof targetScripts[script] !== 'string') {
125
+ const scriptHint = suggestClosest(script, Object.keys(targetScripts));
126
+ console.error(`gjsify workspace: workspace "${args.name}" has no script "${script}"` +
104
127
  (scriptHint ? ` — did you mean "${scriptHint}"?` : ''));
105
128
  // `return process.exit` — see the not-found branch above for why a
106
129
  // bare `process.exit` is unsafe under GJS.
@@ -129,26 +152,26 @@ export const workspaceCommand = {
129
152
  ? new BuildCacheRunner({
130
153
  root,
131
154
  workspaces: allWorkspaces,
132
- script: args.script,
133
- args: args.args ?? [],
155
+ script: script,
156
+ args: extraArgs,
134
157
  targets: runList.map((w) => w.name),
135
158
  })
136
159
  : undefined;
137
160
  const failures = [];
138
161
  for (const ws of runList) {
139
162
  const scripts = ws.manifest.scripts ?? {};
140
- if (typeof scripts[args.script] !== 'string') {
163
+ if (typeof scripts[script] !== 'string') {
141
164
  // Skip deps that don't declare the script — mirrors
142
165
  // `gjsify foreach`'s --if-present behaviour. The target
143
166
  // itself was validated above; this branch only filters
144
167
  // intermediate deps. A skipped package is also never touched
145
168
  // by the build cache (the no-build-package safety rule).
146
169
  if (verbose)
147
- console.error(`[${ws.name}] (no "${args.script}" script — skipping)`);
170
+ console.error(`[${ws.name}] (no "${script}" script — skipping)`);
148
171
  continue;
149
172
  }
150
173
  try {
151
- await runOne(ws, args.script, args.args ?? [], runner, verbose, cache);
174
+ await runOne(ws, script, extraArgs, runner, verbose, cache);
152
175
  }
153
176
  catch (err) {
154
177
  const e = err instanceof Error ? err : new Error(String(err));
@@ -230,26 +253,22 @@ async function runOne(ws, script, extraArgs, runner, verbose, cache) {
230
253
  if (verbose) {
231
254
  console.error(`[${ws.name}] $ ${runner} ${argv.join(' ')}`);
232
255
  }
233
- // Default FORCE_COLOR=1 unless the user explicitly opted out (matches
234
- // yarn / npm / gjsify run behaviour) — without this, tools that key
235
- // on isTTY (chalk, picocolors, biome) drop colors when stdout is a
236
- // pipe, including GitHub Actions where the log viewer renders ANSI
237
- // fine.
238
- const colorEnv = process.env.FORCE_COLOR !== undefined || process.env.NO_COLOR !== undefined ? {} : { FORCE_COLOR: '1' };
239
- await new Promise((resolve, reject) => {
240
- const child = spawn(runner, argv, {
241
- cwd: ws.location,
242
- stdio: 'inherit',
243
- env: { ...process.env, ...colorEnv },
244
- });
245
- child.on('close', (code) => {
246
- if (code === 0)
247
- resolve();
248
- else
249
- reject(new Error(`${runner} ${argv.join(' ')} exited with code ${code}`));
250
- });
251
- child.on('error', reject);
256
+ // `completion: 'exit'` — every path out of this command's handler ends in
257
+ // `process.exit(…)`, which quits the GLib main loop `spawn()` arms under
258
+ // GJS, so the streaming async path stays safe here. See utils/spawn.ts.
259
+ //
260
+ // `color: true` defaults FORCE_COLOR=1 unless the user explicitly opted
261
+ // out (matches yarn / npm / gjsify run) — without it, tools that key on
262
+ // isTTY (chalk, picocolors, oxc) drop colors when stdout is a pipe,
263
+ // including GitHub Actions where the log viewer renders ANSI fine.
264
+ const { code } = await spawnToCompletion(runner, argv, {
265
+ completion: 'exit',
266
+ cwd: ws.location,
267
+ color: true,
252
268
  });
269
+ if (code !== 0) {
270
+ throw new Error(`${runner} ${argv.join(' ')} exited with code ${code}`);
271
+ }
253
272
  if (cache && before)
254
273
  cache.storeAfterSuccess(ws, before);
255
274
  }
@@ -0,0 +1,66 @@
1
+ /** The three sibling files a Windows bin entry consists of. */
2
+ export interface WindowsShimFiles {
3
+ /** POSIX `sh` script — the extension-less `<name>` file. */
4
+ sh: string;
5
+ /** Batch file — `<name>.cmd`. */
6
+ cmd: string;
7
+ /** PowerShell script — `<name>.ps1`. */
8
+ ps1: string;
9
+ }
10
+ /** A parsed `#!` line. */
11
+ export interface Shebang {
12
+ /** Leading `KEY=VALUE ` assignments from `env -S KEY=VALUE prog` (may be ''). */
13
+ variables: string;
14
+ /** The interpreter, e.g. `node` or `gjs`. */
15
+ prog: string;
16
+ /** Everything after the interpreter, e.g. ` -m` (may be ''). */
17
+ args: string;
18
+ }
19
+ /**
20
+ * Parse the first line of a bin file. Returns `null` when it is not a shebang
21
+ * — cmd-shim then treats the target as directly executable.
22
+ */
23
+ export declare function parseShebang(firstLine: string): Shebang | null;
24
+ /**
25
+ * Build the three Windows shim files for a package bin.
26
+ *
27
+ * @param relTargetPosix POSIX-style path from the `.bin` directory to the real
28
+ * bin file (i.e. `path.relative(binDir, target)` with `\` normalised to `/`).
29
+ * @param shebang the target's parsed `#!` line, or `null` when it has none —
30
+ * in which case the shims invoke the target directly, matching cmd-shim.
31
+ */
32
+ export declare function buildCmdShim(relTargetPosix: string, shebang: Shebang | null): WindowsShimFiles;
33
+ /**
34
+ * `.cmd` / `.ps1` companions for a launcher gjsify *generates* itself
35
+ * (`linkGlobalBins`, `writeWorkspaceBinShims`) rather than links.
36
+ *
37
+ * Those launchers are not a symlink to a bin with a shebang — they are a
38
+ * hand-written `sh` script that picks an interpreter and passes an ABSOLUTE
39
+ * script path. `buildCmdShim` cannot express them (it derives everything from
40
+ * a relative target plus a shebang), so this builds the equivalent batch /
41
+ * PowerShell form: optional `SET` / `$env:` assignments, then
42
+ * `<interpreter> [args] "<absolute target>" <argv>`.
43
+ *
44
+ * Note the interpreter is invoked by NAME (resolved through `PATH`), matching
45
+ * the `sh` launcher's `exec gjs`/`exec node`. A missing interpreter therefore
46
+ * fails the same way on every OS.
47
+ */
48
+ export declare function buildLauncherShims(opts: {
49
+ /** Interpreter binary name, e.g. `node` or `gjs`. */
50
+ interpreter: string;
51
+ /** Arguments before the script path, e.g. `['-m']` for `gjs -m`. */
52
+ interpreterArgs?: readonly string[];
53
+ /** Absolute path of the script to run. */
54
+ target: string;
55
+ /**
56
+ * Search-path variables to PREPEND to before the call, preserving whatever
57
+ * the caller's environment already had. Rendered with each shell's own
58
+ * expansion syntax (`%K%` in batch, `$env:K` in PowerShell) — a plain
59
+ * assignment would clobber `PATH`, which on Windows is also the DLL search
60
+ * path the prebuilds have to be reachable through.
61
+ */
62
+ prependEnv?: Readonly<Record<string, string>>;
63
+ }): {
64
+ cmd: string;
65
+ ps1: string;
66
+ };