@gjsify/cli 0.4.0 → 0.4.3

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.
@@ -24,6 +24,8 @@
24
24
  // as build errors rather than silent wrong behavior.
25
25
  import { promises as fs } from 'node:fs';
26
26
  import * as path from 'node:path';
27
+ import { createRequire } from 'node:module';
28
+ import { pathToFileURL } from 'node:url';
27
29
  // npm `rolldown` is a Rust crate with platform-specific prebuilds; loading
28
30
  // it eagerly at module init pulls musl-detection code that does
29
31
  // `require('node:fs')` synchronously — fine on Node, but fatal under GJS
@@ -140,13 +142,19 @@ async function tryLoadNative() {
140
142
  // a real path, then dynamic-import the resulting file:// URL.
141
143
  // Under Node a bare specifier import works directly, so we keep
142
144
  // the simpler form there.
145
+ //
146
+ // `createRequire` + `pathToFileURL` are statically imported at
147
+ // the top of this file so the GJS bundle inlines them via
148
+ // `@gjsify/module` / `@gjsify/url`. A *dynamic* `import('node:…')`
149
+ // would instead hit the GJS native ESM loader which doesn't
150
+ // know the `node:` URI scheme and throws — silently swallowed
151
+ // by the surrounding catch, leaving the caller to fall back
152
+ // to npm rolldown (which then throws ImportError for `rolldown`).
143
153
  const specifier = '@gjsify/rolldown-native';
144
154
  let target = specifier;
145
155
  if (isGjs) {
146
- const { createRequire } = await import('node:module');
147
156
  const require = createRequire(import.meta.url);
148
157
  const resolved = require.resolve(specifier);
149
- const { pathToFileURL } = await import('node:url');
150
158
  target = pathToFileURL(resolved).href;
151
159
  }
152
160
  const mod = (await import(/* @vite-ignore */ target));
@@ -1,6 +1,6 @@
1
1
  import type { Command } from '../types/index.js';
2
2
  interface ForeachOptions {
3
- script: string;
3
+ script?: string;
4
4
  args?: string[];
5
5
  all?: boolean;
6
6
  parallel?: boolean;
@@ -11,6 +11,7 @@ interface ForeachOptions {
11
11
  private?: boolean;
12
12
  verbose?: boolean;
13
13
  jobs?: number;
14
+ exec?: boolean;
14
15
  }
15
16
  export declare const foreachCommand: Command<any, ForeachOptions>;
16
17
  export {};
@@ -11,14 +11,14 @@
11
11
  import { spawn } from 'node:child_process';
12
12
  import { cpus } from 'node:os';
13
13
  import { buildDependencyGraph, discoverWorkspaces, filterWorkspaces, topologicalSort, } from '@gjsify/workspace';
14
+ import { findWorkspaceRoot } from '../utils/workspace-root.js';
14
15
  export const foreachCommand = {
15
- command: 'foreach <script> [args..]',
16
- description: 'Run a workspace script across all (or filtered) workspaces. Drop-in for `yarn workspaces foreach`: -A/--all, -p/--parallel, -t/--topological, --include, --exclude, --no-private.',
16
+ command: 'foreach [script] [args..]',
17
+ description: 'Run a workspace script across all (or filtered) workspaces. Drop-in for `yarn workspaces foreach`: -A/--all, -p/--parallel, -t/--topological, --include, --exclude, --no-private. Pass --exec to run an arbitrary command instead of a script.',
17
18
  builder: (yargs) => yargs
18
19
  .positional('script', {
19
- description: 'Script name to run in each workspace (`run <name>`-equivalent).',
20
+ description: 'Script name to run in each workspace (`run <name>`-equivalent). With --exec, the command to run instead.',
20
21
  type: 'string',
21
- demandOption: true,
22
22
  })
23
23
  .positional('args', {
24
24
  description: 'Extra arguments forwarded to each child invocation.',
@@ -75,24 +75,75 @@ export const foreachCommand = {
75
75
  description: 'Maximum concurrent workspaces in --parallel mode (default: cpu count).',
76
76
  type: 'number',
77
77
  alias: 'j',
78
+ })
79
+ .option('exec', {
80
+ description: 'Treat <script> [args..] as an arbitrary command (yarn `workspaces foreach exec`-equivalent) instead of a package.json script lookup. Workspace filtering by script presence is skipped. Use `-- <cmd> <args...>` to pass flags to the command without yargs intercepting them.',
81
+ type: 'boolean',
82
+ default: false,
83
+ })
84
+ .parserConfiguration({
85
+ // Preserve `--` as args._['--'] so callers can write
86
+ // gjsify foreach --exec -- npm publish --tag latest
87
+ // without yargs grabbing --tag/--access/etc.
88
+ 'populate--': true,
78
89
  }),
79
90
  handler: async (args) => {
80
- const cwd = process.cwd();
91
+ // Walk up to the monorepo root — foreach is sometimes invoked
92
+ // from inside a child workspace's script chain.
93
+ const cwd = findWorkspaceRoot(process.cwd()) ?? process.cwd();
81
94
  const allWorkspaces = discoverWorkspaces(cwd);
95
+ const exec = args.exec === true;
96
+ // In --exec mode, support both
97
+ // gjsify foreach --exec npm something (no flags in command)
98
+ // gjsify foreach --exec -- npm publish --tag X (flags in command)
99
+ // The `--` form is the typical one: `populate--: true` puts the
100
+ // post-separator argv into args._['--'], where yargs cannot grab
101
+ // --tag/--access/etc. as its own options.
102
+ let cmd = args.script;
103
+ let cmdArgs = args.args ?? [];
104
+ if (exec) {
105
+ // With populate--:true, anything after the literal `--`
106
+ // separator lands in top-level args['--']. yargs DOES NOT
107
+ // attach it to args._ — it's a sibling array.
108
+ const fromDoubleDash = (args['--'] ?? [])
109
+ .filter((v) => typeof v === 'string');
110
+ if (fromDoubleDash.length > 0) {
111
+ if (!cmd) {
112
+ cmd = fromDoubleDash[0];
113
+ cmdArgs = [...cmdArgs, ...fromDoubleDash.slice(1)];
114
+ }
115
+ else {
116
+ cmdArgs = [...cmdArgs, ...fromDoubleDash];
117
+ }
118
+ }
119
+ if (!cmd) {
120
+ console.error('gjsify foreach --exec: missing command. Pass it after `--`, e.g. `gjsify foreach --exec -- npm publish --tag latest`.');
121
+ process.exit(1);
122
+ }
123
+ }
82
124
  let selected = filterWorkspaces(allWorkspaces, {
83
125
  include: args.include,
84
126
  exclude: args.exclude,
85
127
  noPrivate: args.private === false,
86
128
  });
87
- // Only run on workspaces that actually have the requested script —
88
- // yarn does this too, otherwise every project that doesn't declare
89
- // `<script>` would fail and force the user to `--exclude` it.
90
- selected = selected.filter((ws) => {
91
- const scripts = ws.manifest.scripts ?? {};
92
- return typeof scripts[args.script] === 'string';
93
- });
129
+ // In script mode, only run on workspaces that actually have the
130
+ // requested script — yarn does this too, otherwise every project
131
+ // that doesn't declare `<script>` would fail and force the user to
132
+ // `--exclude` it. In --exec mode the command runs unconditionally
133
+ // (yarn's `workspaces foreach exec` semantics).
134
+ if (!exec) {
135
+ if (!cmd) {
136
+ console.error('gjsify foreach: missing <script> positional. Pass --exec to run an arbitrary command instead.');
137
+ process.exit(1);
138
+ }
139
+ const scriptName = cmd;
140
+ selected = selected.filter((ws) => {
141
+ const scripts = ws.manifest.scripts ?? {};
142
+ return typeof scripts[scriptName] === 'string';
143
+ });
144
+ }
94
145
  if (selected.length === 0) {
95
- console.log(`gjsify foreach: no workspaces match (script="${args.script}", include=${JSON.stringify(args.include ?? [])}, exclude=${JSON.stringify(args.exclude ?? [])})`);
146
+ console.log(`gjsify foreach: no workspaces match (${exec ? 'exec' : 'script'}="${cmd}", include=${JSON.stringify(args.include ?? [])}, exclude=${JSON.stringify(args.exclude ?? [])})`);
96
147
  return;
97
148
  }
98
149
  if (args.topological || args['topological-dev']) {
@@ -101,44 +152,55 @@ export const foreachCommand = {
101
152
  });
102
153
  selected = topologicalSort(graph);
103
154
  }
104
- const cmd = args.script;
105
- const cmdArgs = args.args ?? [];
106
155
  const verbose = args.verbose === true;
107
- if (args.parallel && !args.topological && !args['topological-dev']) {
108
- const jobs = args.jobs && args.jobs > 0 ? args.jobs : cpus().length;
109
- await runParallel(selected, cmd, cmdArgs, jobs, verbose);
110
- return;
156
+ // `cmd` is guaranteed string at this point — both branches above
157
+ // exit on undefined, but TS doesn't narrow through them.
158
+ const finalCmd = cmd;
159
+ try {
160
+ if (args.parallel && !args.topological && !args['topological-dev']) {
161
+ const jobs = args.jobs && args.jobs > 0 ? args.jobs : cpus().length;
162
+ await runParallel(selected, finalCmd, cmdArgs, jobs, verbose, exec);
163
+ }
164
+ else if (args.parallel) {
165
+ // Topological + parallel: each workspace starts as soon as its
166
+ // deps (in the selected set) have finished. Yarn calls this
167
+ // "topological order with concurrency"; we cap at --jobs.
168
+ const jobs = args.jobs && args.jobs > 0 ? args.jobs : cpus().length;
169
+ await runTopologicalParallel(selected, finalCmd, cmdArgs, jobs, verbose, args['topological-dev'] === true, exec);
170
+ }
171
+ else {
172
+ await runSequential(selected, finalCmd, cmdArgs, verbose, exec);
173
+ }
111
174
  }
112
- if (args.parallel) {
113
- // Topological + parallel: each workspace starts as soon as its
114
- // deps (in the selected set) have finished. Yarn calls this
115
- // "topological order with concurrency"; we cap at --jobs.
116
- const jobs = args.jobs && args.jobs > 0 ? args.jobs : cpus().length;
117
- await runTopologicalParallel(selected, cmd, cmdArgs, jobs, verbose, args['topological-dev'] === true);
118
- return;
175
+ catch (err) {
176
+ console.error(err.message);
177
+ process.exit(1);
119
178
  }
120
- await runSequential(selected, cmd, cmdArgs, verbose);
179
+ // ensureMainLoop() (called inside spawn) keeps GJS alive after every
180
+ // child exits — without an explicit process.exit() the success path
181
+ // would park the loop forever.
182
+ process.exit(0);
121
183
  },
122
184
  };
123
- async function runSequential(workspaces, script, args, verbose) {
185
+ async function runSequential(workspaces, script, args, verbose, exec) {
124
186
  for (const ws of workspaces) {
125
- await runOne(ws, script, args, /* prefixOutput */ false, verbose);
187
+ await runOne(ws, script, args, /* prefixOutput */ false, verbose, exec);
126
188
  }
127
189
  }
128
- async function runParallel(workspaces, script, args, concurrency, verbose) {
190
+ async function runParallel(workspaces, script, args, concurrency, verbose, exec) {
129
191
  let cursor = 0;
130
192
  const workers = [];
131
193
  for (let w = 0; w < concurrency; w++) {
132
194
  workers.push((async () => {
133
195
  while (cursor < workspaces.length) {
134
196
  const i = cursor++;
135
- await runOne(workspaces[i], script, args, /* prefixOutput */ true, verbose);
197
+ await runOne(workspaces[i], script, args, /* prefixOutput */ true, verbose, exec);
136
198
  }
137
199
  })());
138
200
  }
139
201
  await Promise.all(workers);
140
202
  }
141
- async function runTopologicalParallel(workspaces, script, args, concurrency, verbose, includeDev) {
203
+ async function runTopologicalParallel(workspaces, script, args, concurrency, verbose, includeDev, exec) {
142
204
  const selectedNames = new Set(workspaces.map((w) => w.name));
143
205
  const remaining = new Map();
144
206
  for (const ws of workspaces) {
@@ -179,7 +241,7 @@ async function runTopologicalParallel(workspaces, script, args, concurrency, ver
179
241
  const next = ready.sort()[0];
180
242
  remaining.delete(next);
181
243
  inflight++;
182
- runOne(byName.get(next), script, args, /* prefixOutput */ true, verbose)
244
+ runOne(byName.get(next), script, args, /* prefixOutput */ true, verbose, exec)
183
245
  .then(() => {
184
246
  inflight--;
185
247
  done.add(next);
@@ -205,7 +267,18 @@ async function runTopologicalParallel(workspaces, script, args, concurrency, ver
205
267
  pump();
206
268
  });
207
269
  }
208
- async function runOne(ws, script, args, prefixOutput, verbose) {
270
+ async function runOne(ws, script, args, prefixOutput, verbose, exec) {
271
+ if (exec) {
272
+ // Arbitrary-command mode: spawn `<script> <args...>` directly
273
+ // (yarn `workspaces foreach exec`-equivalent). Used by callers
274
+ // that need to run binaries the workspace doesn't expose as a
275
+ // package.json script — e.g. `gjsify foreach --exec npm publish`.
276
+ if (verbose) {
277
+ console.error(`[${ws.name}] $ ${script} ${args.join(' ')}`);
278
+ }
279
+ await spawnPrefixed(script, args, ws.location, prefixOutput ? `[${ws.name}] ` : null);
280
+ return;
281
+ }
209
282
  // Use the same package manager that invoked us — yarn under yarn,
210
283
  // npm under npm, gjsify under gjsify. Default to `npm` for portability
211
284
  // when nothing is detectable; the script-runner (D.5) will replace
@@ -14,7 +14,7 @@
14
14
  // Workspace-aware install (`gjsify install` in a monorepo root with a
15
15
  // `"workspaces"` field) is Phase D.3 — for now we detect and surface a
16
16
  // clear error pointing at the in-progress work.
17
- import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } from 'node:fs';
17
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
18
18
  import { dirname, join, relative } from 'node:path';
19
19
  import { spawn } from 'node:child_process';
20
20
  import { discoverWorkspaces } from '@gjsify/workspace';
@@ -168,6 +168,34 @@ async function projectInstallNative(args) {
168
168
  addDependencyEntry(pkg, name, finalRange, kind);
169
169
  }
170
170
  writePackageJson(pkgPath, pkg);
171
+ // Re-sync the lockfile's `requested` field with what
172
+ // `projectSpecsFromPackageJson()` will return on the next
173
+ // invocation. Without this, a `gjsify install foo` (bare name,
174
+ // lockfile records `"foo"`) followed by `gjsify install
175
+ // --immutable` (reads package.json → spec `"foo@^1.2.3"`) would
176
+ // surface a spurious drift error.
177
+ if (!args.immutable) {
178
+ syncLockfileRequested(cwd, projectSpecsFromPackageJson(pkg));
179
+ }
180
+ }
181
+ }
182
+ function syncLockfileRequested(cwd, specs) {
183
+ const lockPath = join(cwd, 'gjsify-lock.json');
184
+ if (!existsSync(lockPath))
185
+ return;
186
+ try {
187
+ const lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
188
+ const sorted = [...specs].sort();
189
+ const current = [...(lock.requested ?? [])].sort();
190
+ if (sorted.length === current.length && sorted.every((s, i) => s === current[i])) {
191
+ return; // Already in sync; preserve byte-stability.
192
+ }
193
+ lock.requested = specs;
194
+ writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
195
+ }
196
+ catch {
197
+ // Best-effort sync; if the lockfile is malformed, the next
198
+ // non-immutable install will rewrite it from scratch.
171
199
  }
172
200
  }
173
201
  /**
@@ -256,6 +284,156 @@ async function workspaceInstall(cwd, args) {
256
284
  if (symlinks.length > 0) {
257
285
  console.log(`gjsify install: wired ${symlinks.length} workspace symlink(s)`);
258
286
  }
287
+ // Hoist EVERY workspace package to the repo root's `node_modules/` so
288
+ // transitive workspace deps are reachable from any descendant via
289
+ // standard Node parent-walk resolution. yarn's `nodeLinker: node-modules`
290
+ // does the same thing — the entire workspace graph is materialised at
291
+ // the root, which is how rolldown's resolver finds e.g.
292
+ // `@gjsify/abort-controller/register` injected from a deeply-nested
293
+ // package's `node_modules/.cache/gjsify/` cache file when the consumer
294
+ // didn't declare a direct dep on it (auto-globals injection at build
295
+ // time).
296
+ //
297
+ // Without this hoist, each workspace's `node_modules/` only contains
298
+ // its direct declared deps, and any auto-injected register import for
299
+ // a workspace package the consumer didn't list as a dep externalises
300
+ // and the bundle fails at runtime with `Module not found`.
301
+ const rootBinDir = join(cwd, 'node_modules');
302
+ let rootHoisted = 0;
303
+ for (const ws of workspaces) {
304
+ // Skip the root workspace itself (its location IS cwd; it can't
305
+ // symlink itself into its own node_modules).
306
+ if (ws.location === cwd)
307
+ continue;
308
+ if (!ws.name)
309
+ continue;
310
+ const linkPath = join(rootBinDir, ws.name);
311
+ // If a symlink already exists here (from the per-workspace loop
312
+ // above when the root workspace declared this dep directly), it
313
+ // already points at the right place — skip. We don't try to
314
+ // remove + recreate because under GJS's Gio-backed fs polyfill,
315
+ // `rmSync` on a symlink can race with `symlinkSync` and surface
316
+ // EEXIST. A real directory at this path is also left alone —
317
+ // someone else (npm, yarn) seeded it and we shouldn't clobber.
318
+ let existsHere = false;
319
+ try {
320
+ lstatSync(linkPath);
321
+ existsHere = true;
322
+ }
323
+ catch { /* ENOENT */ }
324
+ if (existsHere)
325
+ continue;
326
+ mkdirSync(dirname(linkPath), { recursive: true });
327
+ const relTarget = relative(dirname(linkPath), ws.location);
328
+ symlinkSync(relTarget, linkPath);
329
+ rootHoisted++;
330
+ }
331
+ if (rootHoisted > 0) {
332
+ console.log(`gjsify install: hoisted ${rootHoisted} workspace(s) to root node_modules/`);
333
+ }
334
+ // Link workspace bins into `node_modules/.bin/`. Without this,
335
+ // `npm run <script>` (or any `node_modules/.bin`-PATH consumer)
336
+ // cannot find the `gjsify` binary on a fresh checkout — yarn
337
+ // creates these shims at install time; we need to match.
338
+ //
339
+ // Each workspace's `bin` entry maps `<binName>` → `<relative-target>`.
340
+ // For GJS-runnable bins, `gjsify.bin` is preferred — its target is the
341
+ // committed `dist/cli.gjs.mjs` bundle that exists on a fresh checkout,
342
+ // versus the `bin` field which typically points at `lib/index.js`
343
+ // (a build artifact that may not yet exist). The shim wraps the
344
+ // target in a shell script that picks the right interpreter (`gjs -m`
345
+ // for `.mjs` bundles, `node` for `.js` files).
346
+ const wsBinDir = join(cwd, 'node_modules', '.bin');
347
+ let wsBinsCreated = 0;
348
+ for (const ws of workspaces) {
349
+ const m = ws.manifest;
350
+ const gjsifyBin = m.gjsify?.bin;
351
+ const nodeBin = m.bin;
352
+ // For each bin name, collect both the Node-target and GJS-target
353
+ // when they exist. The shim prefers Node at invocation time
354
+ // because Node's child_process is more reliable than GJS's
355
+ // Gio.Subprocess polyfill (parallel-spawn close-event delivery
356
+ // races under heavy concurrency); GJS is the fallback for fresh
357
+ // checkouts where the Node target hasn't been built yet.
358
+ const merged = mergeWorkspaceBins(ws.name, gjsifyBin, nodeBin);
359
+ if (merged.size === 0)
360
+ continue;
361
+ mkdirSync(wsBinDir, { recursive: true });
362
+ for (const [binName, { nodeTarget, gjsTarget }] of merged) {
363
+ const linkPath = join(wsBinDir, binName);
364
+ try {
365
+ rmSync(linkPath, { force: true });
366
+ }
367
+ catch { /* fine */ }
368
+ writeFileSync(linkPath, buildBinShim(ws.location, nodeTarget, gjsTarget), { mode: 0o755 });
369
+ chmodSync(linkPath, 0o755);
370
+ wsBinsCreated++;
371
+ }
372
+ }
373
+ if (wsBinsCreated > 0) {
374
+ console.log(`gjsify install: linked ${wsBinsCreated} workspace bin(s) into node_modules/.bin/`);
375
+ }
376
+ }
377
+ /**
378
+ * Build a shell shim that prefers Node when its target file exists at
379
+ * invocation time, falling back to GJS otherwise. The runtime check is
380
+ * per-invocation (not at install time) so the same shim works both
381
+ * before and after the workspace's `lib/` has been built — a fresh
382
+ * checkout only has the committed `dist/cli.gjs.mjs`, while every
383
+ * subsequent `npm run build` produces `lib/index.js`.
384
+ *
385
+ * Both targets are absolute paths so the shim is portable across the
386
+ * different cwds that consumers (`yarn run`, `npm run`, direct PATH
387
+ * invocation) call us from.
388
+ */
389
+ function buildBinShim(wsLocation, nodeTarget, gjsTarget) {
390
+ const nodeAbs = nodeTarget ? join(wsLocation, nodeTarget) : null;
391
+ const gjsAbs = gjsTarget ? join(wsLocation, gjsTarget) : null;
392
+ if (nodeAbs && gjsAbs) {
393
+ return `#!/bin/sh\nif [ -f "${nodeAbs}" ]; then\n exec node "${nodeAbs}" "$@"\nfi\nexec gjs -m "${gjsAbs}" "$@"\n`;
394
+ }
395
+ if (nodeAbs)
396
+ return `#!/bin/sh\nexec node "${nodeAbs}" "$@"\n`;
397
+ if (gjsAbs)
398
+ return `#!/bin/sh\nexec gjs -m "${gjsAbs}" "$@"\n`;
399
+ throw new Error('buildBinShim: either nodeTarget or gjsTarget must be provided');
400
+ }
401
+ /**
402
+ * Walk a workspace's `bin` (Node) + `gjsify.bin` (GJS) declarations
403
+ * into a unified `<binName> → {nodeTarget?, gjsTarget?}` map. The
404
+ * shim built from this picks Node at runtime when its target exists,
405
+ * GJS otherwise.
406
+ */
407
+ function mergeWorkspaceBins(pkgName, gjsifyBin, nodeBin) {
408
+ const out = new Map();
409
+ const baseName = pkgName.startsWith('@') ? pkgName.slice(pkgName.indexOf('/') + 1) : pkgName;
410
+ const get = (key) => {
411
+ let entry = out.get(key);
412
+ if (!entry) {
413
+ entry = {};
414
+ out.set(key, entry);
415
+ }
416
+ return entry;
417
+ };
418
+ if (typeof nodeBin === 'string') {
419
+ get(baseName).nodeTarget = nodeBin;
420
+ }
421
+ else if (nodeBin && typeof nodeBin === 'object') {
422
+ for (const [k, v] of Object.entries(nodeBin)) {
423
+ if (typeof v === 'string' && v.length > 0)
424
+ get(k).nodeTarget = v;
425
+ }
426
+ }
427
+ if (typeof gjsifyBin === 'string') {
428
+ get(baseName).gjsTarget = gjsifyBin;
429
+ }
430
+ else if (gjsifyBin && typeof gjsifyBin === 'object') {
431
+ for (const [k, v] of Object.entries(gjsifyBin)) {
432
+ if (typeof v === 'string' && v.length > 0)
433
+ get(k).gjsTarget = v;
434
+ }
435
+ }
436
+ return out;
259
437
  }
260
438
  async function projectInstallViaNpm(args) {
261
439
  const npmArgs = ['install'];
@@ -18,7 +18,7 @@ import { delimiter, join, resolve } from 'node:path';
18
18
  import { spawn } from 'node:child_process';
19
19
  import { runGjsBundle } from '../utils/run-gjs.js';
20
20
  import { readPackageJson } from '../utils/pkg-json-edit.js';
21
- import { discoverWorkspaces } from '@gjsify/workspace';
21
+ import { findWorkspaceRoot } from '../utils/workspace-root.js';
22
22
  export const runCommand = {
23
23
  command: 'run <target> [args..]',
24
24
  description: 'Run a script from package.json (yarn-run-style) or a GJS bundle file. If <target> resolves to a file on disk (or has a path-like prefix), it is launched via gjs with LD_LIBRARY_PATH + GI_TYPELIB_PATH set for native packages. Otherwise it is looked up in the current package.json `scripts`.',
@@ -94,8 +94,11 @@ async function runScript(script, extraArgs) {
94
94
  const fullCmd = extraArgs.length > 0
95
95
  ? `${literal} ${extraArgs.map(shellEscape).join(' ')}`
96
96
  : literal;
97
+ // ensureMainLoop() (called inside spawn) keeps GJS alive after the
98
+ // child exits — without an explicit process.exit() the success path
99
+ // would park the loop forever. The error path already exits.
97
100
  await new Promise((resolveOk, reject) => {
98
- const child = spawn(fullCmd, { cwd, env, stdio: 'inherit', shell: true });
101
+ const child = spawn(fullCmd, [], { cwd, env, stdio: 'inherit', shell: true });
99
102
  child.on('close', (code) => {
100
103
  if (code === 0)
101
104
  resolveOk();
@@ -107,30 +110,7 @@ async function runScript(script, extraArgs) {
107
110
  console.error(err.message);
108
111
  process.exit(1);
109
112
  });
110
- }
111
- function findWorkspaceRoot(start) {
112
- let dir = start;
113
- for (let i = 0; i < 12; i++) {
114
- const pkgPath = join(dir, 'package.json');
115
- if (existsSync(pkgPath)) {
116
- const pkg = readPackageJson(pkgPath);
117
- if (pkg?.workspaces !== undefined) {
118
- try {
119
- // Sanity-check that cwd is reachable as a workspace —
120
- // otherwise we'd pick an unrelated grand-parent monorepo.
121
- const ws = discoverWorkspaces(dir);
122
- if (dir === start || ws.some((w) => w.location === start))
123
- return dir;
124
- }
125
- catch { /* not a usable workspace root */ }
126
- }
127
- }
128
- const parent = resolve(dir, '..');
129
- if (parent === dir)
130
- break;
131
- dir = parent;
132
- }
133
- return null;
113
+ process.exit(0);
134
114
  }
135
115
  function shellEscape(arg) {
136
116
  if (/^[a-zA-Z0-9_\-./=:@,]+$/.test(arg))
@@ -5,6 +5,7 @@
5
5
  // extensively in gjsify's own root `package.json` (17 call sites).
6
6
  import { spawn } from 'node:child_process';
7
7
  import { discoverWorkspaces } from '@gjsify/workspace';
8
+ import { findWorkspaceRoot } from '../utils/workspace-root.js';
8
9
  export const workspaceCommand = {
9
10
  command: 'workspace <name> <script> [args..]',
10
11
  description: 'Run a workspace script (`yarn workspace <name> run <script>` equivalent).',
@@ -25,7 +26,12 @@ export const workspaceCommand = {
25
26
  array: true,
26
27
  }),
27
28
  handler: async (args) => {
28
- const workspaces = discoverWorkspaces(process.cwd());
29
+ // Walk up to the monorepo root — `gjsify workspace` is often
30
+ // invoked from a child workspace's script (e.g. website's
31
+ // `build:deps` calls `gjsify workspace @gjsify/adwaita-web …`),
32
+ // where process.cwd() is the child workspace, not the monorepo root.
33
+ const root = findWorkspaceRoot(process.cwd()) ?? process.cwd();
34
+ const workspaces = discoverWorkspaces(root);
29
35
  const target = workspaces.find((w) => w.name === args.name);
30
36
  if (!target) {
31
37
  console.error(`gjsify workspace: no workspace named "${args.name}" — discovered ${workspaces.length} workspace(s)`);
@@ -57,6 +63,10 @@ export const workspaceCommand = {
57
63
  console.error(err.message);
58
64
  process.exit(1);
59
65
  });
66
+ // ensureMainLoop() (called inside spawn) keeps GJS alive after the
67
+ // child exits — without an explicit process.exit() the success path
68
+ // would park the loop forever.
69
+ process.exit(0);
60
70
  },
61
71
  };
62
72
  function detectPackageManager() {