@gjsify/cli 0.3.21 → 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.
Files changed (69) hide show
  1. package/dist/cli.gjs.mjs +791 -0
  2. package/lib/actions/build.js +4 -17
  3. package/lib/bundler-pick.d.ts +79 -0
  4. package/lib/bundler-pick.js +436 -0
  5. package/lib/commands/foreach.d.ts +17 -0
  6. package/lib/commands/foreach.js +341 -0
  7. package/lib/commands/index.d.ts +2 -0
  8. package/lib/commands/index.js +2 -0
  9. package/lib/commands/install.d.ts +1 -0
  10. package/lib/commands/install.js +401 -27
  11. package/lib/commands/run.d.ts +1 -1
  12. package/lib/commands/run.js +113 -20
  13. package/lib/commands/workspace.d.ts +8 -0
  14. package/lib/commands/workspace.js +79 -0
  15. package/lib/config.js +12 -1
  16. package/lib/index.js +11 -3
  17. package/lib/types/config-data.d.ts +10 -1
  18. package/lib/utils/install-backend-native.d.ts +5 -1
  19. package/lib/utils/install-backend-native.js +329 -70
  20. package/lib/utils/install-backend.d.ts +11 -1
  21. package/lib/utils/install-backend.js +4 -2
  22. package/lib/utils/pkg-json-edit.d.ts +47 -0
  23. package/lib/utils/pkg-json-edit.js +108 -0
  24. package/lib/utils/workspace-root.d.ts +1 -0
  25. package/lib/utils/workspace-root.js +46 -0
  26. package/package.json +70 -44
  27. package/src/actions/build.ts +0 -431
  28. package/src/actions/index.ts +0 -1
  29. package/src/commands/build.ts +0 -146
  30. package/src/commands/check.ts +0 -87
  31. package/src/commands/create.ts +0 -63
  32. package/src/commands/dlx.ts +0 -195
  33. package/src/commands/flatpak/build.ts +0 -225
  34. package/src/commands/flatpak/ci.ts +0 -173
  35. package/src/commands/flatpak/deps.ts +0 -120
  36. package/src/commands/flatpak/index.ts +0 -53
  37. package/src/commands/flatpak/init.ts +0 -191
  38. package/src/commands/flatpak/utils.ts +0 -76
  39. package/src/commands/gettext.ts +0 -258
  40. package/src/commands/gresource.ts +0 -97
  41. package/src/commands/gsettings.ts +0 -87
  42. package/src/commands/index.ts +0 -12
  43. package/src/commands/info.ts +0 -70
  44. package/src/commands/install.ts +0 -195
  45. package/src/commands/run.ts +0 -33
  46. package/src/commands/showcase.ts +0 -149
  47. package/src/config.ts +0 -304
  48. package/src/constants.ts +0 -1
  49. package/src/index.ts +0 -37
  50. package/src/types/cli-build-options.ts +0 -100
  51. package/src/types/command.ts +0 -10
  52. package/src/types/config-data-library.ts +0 -5
  53. package/src/types/config-data-typescript.ts +0 -6
  54. package/src/types/config-data.ts +0 -225
  55. package/src/types/cosmiconfig-result.ts +0 -5
  56. package/src/types/index.ts +0 -6
  57. package/src/utils/check-system-deps.ts +0 -480
  58. package/src/utils/detect-native-packages.ts +0 -153
  59. package/src/utils/discover-showcases.ts +0 -75
  60. package/src/utils/dlx-cache.ts +0 -135
  61. package/src/utils/install-backend-native.ts +0 -363
  62. package/src/utils/install-backend.ts +0 -88
  63. package/src/utils/install-global.ts +0 -182
  64. package/src/utils/normalize-bundler-options.ts +0 -129
  65. package/src/utils/parse-spec.ts +0 -48
  66. package/src/utils/resolve-gjs-entry.ts +0 -96
  67. package/src/utils/resolve-plugin-by-name.ts +0 -106
  68. package/src/utils/run-gjs.ts +0 -90
  69. package/tsconfig.json +0 -16
@@ -1,28 +1,28 @@
1
1
  // `gjsify install [pkg...]` — install packages with gjsify-aware post-checks.
2
2
  //
3
3
  // Modes:
4
- // gjsify install → project install (npm install)
5
- // gjsify install <pkg> [<pkg>...] → add package(s) to project (npm install <pkg>...)
4
+ // gjsify install → project install (native, reads pkg.json)
5
+ // gjsify install <pkg> [<pkg>...] → add package(s) to project (native)
6
6
  // gjsify install -g <pkg> [...] → user-global install (XDG, GJS-runnable bin)
7
7
  //
8
- // Project mode delegates to `npm install` in cwd and runs `runMinimalChecks()`
9
- // + `detectNativePackages()` to surface missing system deps and `@gjsify/*`
10
- // packages with native prebuilds.
8
+ // All three modes route through `@gjsify/{semver,npm-registry,tar}` via
9
+ // `installPackagesNative` no Node/npm required at runtime. Set
10
+ // `GJSIFY_INSTALL_BACKEND=npm` to opt back into the legacy `npm install`
11
+ // subprocess flow (useful as escape-hatch for projects that hit a
12
+ // missing native-backend feature).
11
13
  //
12
- // Global mode is the GJS equivalent of `npm i -g`: extracts the package tree
13
- // into `${XDG_DATA_HOME}/gjsify/global/node_modules/<pkg>/` via the native
14
- // install backend (no Node/npm required at runtime), then symlinks the bins
15
- // declared by `gjsify.bin` (preferred) or `bin` (fallback) into
16
- // `~/.local/bin/`. Subsequent commands invoked by name resolve to the
17
- // extracted package, so package-relative assets like `@ts-for-gir/cli`'s
18
- // `dist-templates/` are found by ordinary `__dirname/..` resolution — no
19
- // embedded asset stores, no separate release tarballs.
14
+ // Workspace-aware install (`gjsify install` in a monorepo root with a
15
+ // `"workspaces"` field) is Phase D.3 — for now we detect and surface a
16
+ // clear error pointing at the in-progress work.
17
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
18
+ import { dirname, join, relative } from 'node:path';
20
19
  import { spawn } from 'node:child_process';
21
- import { mkdirSync } from 'node:fs';
20
+ import { discoverWorkspaces } from '@gjsify/workspace';
22
21
  import { buildInstallCommand, detectPackageManager, runMinimalChecks, } from '../utils/check-system-deps.js';
23
22
  import { detectNativePackages } from '../utils/detect-native-packages.js';
24
23
  import { installPackages } from '../utils/install-backend.js';
25
24
  import { binDirOnPath, defaultGlobalLayout, linkGlobalBins, specToPackageName, } from '../utils/install-global.js';
25
+ import { addDependencyEntry, defaultRangeFromVersion, parseSpec, projectSpecsFromPackageJson, readPackageJson, writePackageJson, } from '../utils/pkg-json-edit.js';
26
26
  export const installCommand = {
27
27
  command: 'install [packages..]',
28
28
  description: 'Install npm dependencies in the current project (or globally with -g), then run gjsify-aware post-checks.',
@@ -41,12 +41,31 @@ export const installCommand = {
41
41
  .option('save-dev', { type: 'boolean', alias: 'D' })
42
42
  .option('save-peer', { type: 'boolean' })
43
43
  .option('save-optional', { type: 'boolean', alias: 'O' })
44
+ .option('immutable', {
45
+ description: 'CI mode: install strictly from gjsify-lock.json, fail if the lockfile is missing or stale. Equivalent to yarn --immutable / npm ci --frozen-lockfile.',
46
+ type: 'boolean',
47
+ default: false,
48
+ })
44
49
  .option('verbose', {
45
50
  description: 'Verbose install logging.',
46
51
  type: 'boolean',
47
52
  default: false,
48
53
  }),
49
54
  handler: async (args) => {
55
+ // --immutable is incompatible with explicit `<pkg>` adds and with
56
+ // `--global` (which has no lockfile concept). Matches yarn's
57
+ // behavior: `yarn add --immutable` is a hard error.
58
+ if (args.immutable) {
59
+ if (args.packages && args.packages.length > 0) {
60
+ console.error('gjsify install --immutable does not accept package arguments. ' +
61
+ 'Remove the package names or drop --immutable.');
62
+ process.exit(1);
63
+ }
64
+ if (args.global) {
65
+ console.error('gjsify install --immutable is incompatible with --global.');
66
+ process.exit(1);
67
+ }
68
+ }
50
69
  if (args.global) {
51
70
  if (!args.packages || args.packages.length === 0) {
52
71
  console.error('gjsify install --global requires at least one <pkg> argument.');
@@ -60,22 +79,377 @@ export const installCommand = {
60
79
  await installGlobalAndLink(args.packages, { verbose: args.verbose });
61
80
  return;
62
81
  }
63
- const npmArgs = ['install'];
64
- if (args['save-dev'])
65
- npmArgs.push('--save-dev');
66
- if (args['save-peer'])
67
- npmArgs.push('--save-peer');
68
- if (args['save-optional'])
69
- npmArgs.push('--save-optional');
70
- if (args.verbose)
71
- npmArgs.push('--loglevel', 'verbose');
72
- if (args.packages && args.packages.length > 0) {
73
- npmArgs.push(...args.packages);
74
- }
75
- await spawnNpm(npmArgs);
82
+ // Escape-hatch: legacy npm subprocess flow.
83
+ if (process.env.GJSIFY_INSTALL_BACKEND === 'npm') {
84
+ await projectInstallViaNpm(args);
85
+ await runPostInstallChecks();
86
+ return;
87
+ }
88
+ await projectInstallNative(args);
76
89
  await runPostInstallChecks();
77
90
  },
78
91
  };
92
+ function isWorkspaceRoot(cwd) {
93
+ const pkgPath = join(cwd, 'package.json');
94
+ const pkg = readPackageJson(pkgPath);
95
+ if (!pkg)
96
+ return false;
97
+ return pkg.workspaces !== undefined;
98
+ }
99
+ function depKindFromArgs(args) {
100
+ if (args['save-dev'])
101
+ return 'devDependencies';
102
+ if (args['save-peer'])
103
+ return 'peerDependencies';
104
+ if (args['save-optional'])
105
+ return 'optionalDependencies';
106
+ return 'dependencies';
107
+ }
108
+ async function projectInstallNative(args) {
109
+ const cwd = process.cwd();
110
+ const pkgPath = join(cwd, 'package.json');
111
+ // Yarn-Berry / PnP detection: fall back to yarn with a clear warning
112
+ // rather than producing a half-working node_modules tree.
113
+ if (existsSync(join(cwd, '.pnp.cjs')) || existsSync(join(cwd, '.pnp.loader.mjs'))) {
114
+ throw new Error('gjsify install: detected Yarn PnP (.pnp.cjs) — native install is ' +
115
+ 'not PnP-aware yet. Use `yarn install` or set ' +
116
+ 'GJSIFY_INSTALL_BACKEND=npm.');
117
+ }
118
+ // Workspace install (no args, root pkg.json has `workspaces`) — Phase D.3.
119
+ // Project-local `gjsify install <pkg>` inside a workspace child still
120
+ // goes through the single-package code path below (this branch only
121
+ // fires for the root no-args case, which is the `yarn install`
122
+ // equivalent).
123
+ if ((!args.packages || args.packages.length === 0) && isWorkspaceRoot(cwd)) {
124
+ await workspaceInstall(cwd, args);
125
+ return;
126
+ }
127
+ let specs;
128
+ const pkg = readPackageJson(pkgPath);
129
+ const existingSpecs = pkg ? projectSpecsFromPackageJson(pkg) : [];
130
+ if (args.packages && args.packages.length > 0) {
131
+ // Combine new specs with existing manifest deps so a single
132
+ // `gjsify install <new>` doesn't churn the lockfile (would drop
133
+ // every previously-pinned entry otherwise). New specs with the
134
+ // same name as an existing dep override.
135
+ const newNames = new Set(args.packages.map((s) => parseSpec(s).name));
136
+ const carryover = existingSpecs.filter((s) => !newNames.has(parseSpec(s).name));
137
+ specs = [...carryover, ...args.packages];
138
+ }
139
+ else {
140
+ if (!pkg) {
141
+ throw new Error(`gjsify install: no package.json in ${cwd}`);
142
+ }
143
+ specs = existingSpecs;
144
+ if (specs.length === 0) {
145
+ console.log('gjsify install: no dependencies declared in package.json — nothing to do.');
146
+ return;
147
+ }
148
+ }
149
+ mkdirSync(cwd, { recursive: true });
150
+ const result = await installPackages({
151
+ prefix: cwd,
152
+ specs,
153
+ verbose: args.verbose,
154
+ // --immutable consumes the lockfile verbatim and must NOT rewrite
155
+ // it (the whole point is byte-stability under CI).
156
+ lockfile: !args.immutable,
157
+ frozen: args.immutable,
158
+ });
159
+ // Update package.json only when the user passed explicit packages
160
+ // (the `gjsify install <pkg>...` add-a-dep flow). The no-args refresh
161
+ // flow doesn't mutate manifest entries.
162
+ if (args.packages && args.packages.length > 0 && pkg) {
163
+ const kind = depKindFromArgs(args);
164
+ for (const spec of args.packages) {
165
+ const { name, range } = parseSpec(spec);
166
+ const installed = result.installed.find((r) => r.name === name);
167
+ const finalRange = range ?? (installed ? defaultRangeFromVersion(installed.version) : 'latest');
168
+ addDependencyEntry(pkg, name, finalRange, kind);
169
+ }
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.
199
+ }
200
+ }
201
+ /**
202
+ * Phase D.3 — workspace-aware install. Mirrors what `yarn install` does
203
+ * at a monorepo root:
204
+ * 1. Discover every workspace under the root.
205
+ * 2. Aggregate the union of their external (non-`workspace:`) deps.
206
+ * 3. Run the native install backend ONCE at the root prefix so all
207
+ * externals land in a single `node_modules/` (poor-man's hoisting —
208
+ * we don't deduplicate version-range conflicts yet, the BFS resolver
209
+ * picks first-match).
210
+ * 4. For every `workspace:` reference, symlink the target workspace's
211
+ * directory into the requesting workspace's `node_modules/<dep>`
212
+ * so `import '@gjsify/utils'` resolves to the local source.
213
+ *
214
+ * Hoisting strategy is intentionally minimal — D.3 ships the working
215
+ * baseline; per-workspace dedup + nested `node_modules/` for version
216
+ * conflicts are tracked as a follow-up in STATUS.md "Open TODOs".
217
+ */
218
+ async function workspaceInstall(cwd, args) {
219
+ const workspaces = discoverWorkspaces(cwd, { includeRoot: true });
220
+ if (workspaces.length === 0) {
221
+ throw new Error(`gjsify install: ${cwd} has a "workspaces" field but no workspaces were discovered`);
222
+ }
223
+ const byName = new Map(workspaces.map((w) => [w.name, w]));
224
+ const externalSpecs = new Set();
225
+ const symlinks = [];
226
+ for (const ws of workspaces) {
227
+ const m = ws.manifest;
228
+ for (const kind of ['dependencies', 'devDependencies', 'optionalDependencies']) {
229
+ const block = m[kind];
230
+ if (!block)
231
+ continue;
232
+ for (const [depName, spec] of Object.entries(block)) {
233
+ if (typeof spec !== 'string')
234
+ continue;
235
+ if (spec.startsWith('workspace:')) {
236
+ const target = byName.get(depName);
237
+ if (!target) {
238
+ throw new Error(`gjsify install: ${ws.name} declares "${depName}: ${spec}" but ` +
239
+ `no workspace with that name exists`);
240
+ }
241
+ symlinks.push({ fromWorkspaceName: ws.name, depName, targetLocation: target.location });
242
+ continue;
243
+ }
244
+ if (/^(link|file|portal|git\+|https?):/.test(spec))
245
+ continue;
246
+ externalSpecs.add(`${depName}@${spec}`);
247
+ }
248
+ }
249
+ }
250
+ console.log(`gjsify install: ${workspaces.length} workspace(s), ${externalSpecs.size} external dep spec(s), ${symlinks.length} workspace symlink(s)`);
251
+ if (externalSpecs.size > 0) {
252
+ await installPackages({
253
+ prefix: cwd,
254
+ specs: [...externalSpecs],
255
+ verbose: args.verbose,
256
+ lockfile: !args.immutable,
257
+ frozen: args.immutable,
258
+ });
259
+ }
260
+ else if (args.verbose) {
261
+ console.log('gjsify install: no external deps to fetch');
262
+ }
263
+ for (const link of symlinks) {
264
+ const target = byName.get(link.fromWorkspaceName);
265
+ if (!target)
266
+ continue;
267
+ const linkPath = join(target.location, 'node_modules', link.depName);
268
+ mkdirSync(dirname(linkPath), { recursive: true });
269
+ // Remove any prior entry (regular dir, broken symlink, file).
270
+ try {
271
+ const stat = lstatSync(linkPath);
272
+ if (stat.isSymbolicLink() || stat.isFile()) {
273
+ rmSync(linkPath, { force: true });
274
+ }
275
+ else if (stat.isDirectory()) {
276
+ rmSync(linkPath, { recursive: true, force: true });
277
+ }
278
+ }
279
+ catch { /* ENOENT — fine, nothing to remove */ }
280
+ // Relative symlink so the repo is portable across checkout paths.
281
+ const relTarget = relative(dirname(linkPath), link.targetLocation);
282
+ symlinkSync(relTarget, linkPath);
283
+ }
284
+ if (symlinks.length > 0) {
285
+ console.log(`gjsify install: wired ${symlinks.length} workspace symlink(s)`);
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;
437
+ }
438
+ async function projectInstallViaNpm(args) {
439
+ const npmArgs = ['install'];
440
+ if (args['save-dev'])
441
+ npmArgs.push('--save-dev');
442
+ if (args['save-peer'])
443
+ npmArgs.push('--save-peer');
444
+ if (args['save-optional'])
445
+ npmArgs.push('--save-optional');
446
+ if (args.verbose)
447
+ npmArgs.push('--loglevel', 'verbose');
448
+ if (args.packages && args.packages.length > 0) {
449
+ npmArgs.push(...args.packages);
450
+ }
451
+ await spawnNpm(npmArgs);
452
+ }
79
453
  async function spawnNpm(npmArgs) {
80
454
  return new Promise((resolve, reject) => {
81
455
  const child = spawn('npm', npmArgs, { stdio: 'inherit' });
@@ -1,6 +1,6 @@
1
1
  import type { Command } from '../types/index.js';
2
2
  interface RunOptions {
3
- file: string;
3
+ target: string;
4
4
  args: string[];
5
5
  }
6
6
  export declare const runCommand: Command<any, RunOptions>;
@@ -1,26 +1,119 @@
1
- import { resolve } from 'node:path';
1
+ // `gjsify run <target> [args..]` — dual-mode runner.
2
+ //
3
+ // gjsify run <file> → existing behavior: run a GJS bundle file
4
+ // via `gjs -m`, with LD_LIBRARY_PATH +
5
+ // GI_TYPELIB_PATH set for native packages.
6
+ // gjsify run <script> → yarn-run-style: look up `<script>` in the
7
+ // current workspace's package.json `scripts`
8
+ // and execute it with `node_modules/.bin` on
9
+ // PATH (workspace + monorepo root).
10
+ //
11
+ // Phase D.5 added the script-runner side. The two modes coexist via a
12
+ // `looksLikeFile()` heuristic: anything with a path separator, JS-ish
13
+ // extension, or that resolves to an existing path on disk is treated
14
+ // as a bundle file. Everything else is a script name. Users who want
15
+ // to disambiguate can pass `./<file>` explicitly.
16
+ import { existsSync } from 'node:fs';
17
+ import { delimiter, join, resolve } from 'node:path';
18
+ import { spawn } from 'node:child_process';
2
19
  import { runGjsBundle } from '../utils/run-gjs.js';
20
+ import { readPackageJson } from '../utils/pkg-json-edit.js';
21
+ import { findWorkspaceRoot } from '../utils/workspace-root.js';
3
22
  export const runCommand = {
4
- command: 'run <file> [args..]',
5
- description: 'Run a GJS bundle, automatically setting LD_LIBRARY_PATH and GI_TYPELIB_PATH for any installed native gjsify packages (e.g. @gjsify/webgl).',
6
- builder: (yargs) => {
7
- return yargs
8
- .positional('file', {
9
- description: 'The GJS bundle to run (e.g. dist/gjs.js)',
10
- type: 'string',
11
- normalize: true,
12
- demandOption: true,
13
- })
14
- .positional('args', {
15
- description: 'Extra arguments passed through to gjs',
16
- type: 'string',
17
- array: true,
18
- default: [],
19
- });
20
- },
23
+ command: 'run <target> [args..]',
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`.',
25
+ builder: (yargs) => yargs
26
+ .positional('target', {
27
+ description: 'Either a script name (looked up in package.json `scripts`) or a path to a GJS bundle (e.g. dist/gjs.js).',
28
+ type: 'string',
29
+ demandOption: true,
30
+ })
31
+ .positional('args', {
32
+ description: 'Extra arguments passed through to the script / gjs.',
33
+ type: 'string',
34
+ array: true,
35
+ default: [],
36
+ }),
21
37
  handler: async (args) => {
22
- const file = resolve(args.file);
38
+ const target = args.target;
23
39
  const extraArgs = args.args ?? [];
24
- await runGjsBundle(file, extraArgs);
40
+ if (looksLikeFile(target)) {
41
+ const file = resolve(target);
42
+ await runGjsBundle(file, extraArgs);
43
+ return;
44
+ }
45
+ await runScript(target, extraArgs);
25
46
  },
26
47
  };
48
+ function looksLikeFile(target) {
49
+ if (target.startsWith('./') || target.startsWith('../') || target.startsWith('/'))
50
+ return true;
51
+ if (target.includes('/') || target.includes('\\'))
52
+ return true;
53
+ if (/\.(c?js|mjs|cjs|gjs)$/.test(target))
54
+ return true;
55
+ return existsSync(target);
56
+ }
57
+ /**
58
+ * Run a script declared in the current workspace's `package.json#scripts`.
59
+ * Mirrors `yarn run <script>` semantics:
60
+ * - PATH prepended with `<workspace>/node_modules/.bin` AND the
61
+ * monorepo-root `node_modules/.bin` (covers locally-installed bins
62
+ * and hoisted bins)
63
+ * - extra args appended after the script's literal command, shell-escaped
64
+ * - executed through `shell: true` so `&&` / `|` / env-var refs work
65
+ * exactly as in package.json scripts (matches npm/yarn)
66
+ */
67
+ async function runScript(script, extraArgs) {
68
+ const cwd = process.cwd();
69
+ const pkgPath = join(cwd, 'package.json');
70
+ const pkg = readPackageJson(pkgPath);
71
+ if (!pkg) {
72
+ console.error(`gjsify run: no package.json in ${cwd}`);
73
+ process.exit(1);
74
+ }
75
+ const scripts = pkg.scripts ?? {};
76
+ const literal = scripts[script];
77
+ if (typeof literal !== 'string') {
78
+ const available = Object.keys(scripts).join(', ') || '<none>';
79
+ console.error(`gjsify run: no script "${script}" in ${pkgPath} (available: ${available})`);
80
+ process.exit(1);
81
+ }
82
+ const monorepoRoot = findWorkspaceRoot(cwd);
83
+ const binDirs = [join(cwd, 'node_modules', '.bin')];
84
+ if (monorepoRoot && monorepoRoot !== cwd) {
85
+ binDirs.push(join(monorepoRoot, 'node_modules', '.bin'));
86
+ }
87
+ const env = {
88
+ ...process.env,
89
+ PATH: [...binDirs, process.env.PATH ?? ''].filter(Boolean).join(delimiter),
90
+ npm_lifecycle_event: script,
91
+ npm_package_name: pkg.name ?? '',
92
+ npm_package_version: pkg.version ?? '',
93
+ };
94
+ const fullCmd = extraArgs.length > 0
95
+ ? `${literal} ${extraArgs.map(shellEscape).join(' ')}`
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.
100
+ await new Promise((resolveOk, reject) => {
101
+ const child = spawn(fullCmd, [], { cwd, env, stdio: 'inherit', shell: true });
102
+ child.on('close', (code) => {
103
+ if (code === 0)
104
+ resolveOk();
105
+ else
106
+ reject(new Error(`script "${script}" exited with code ${code}`));
107
+ });
108
+ child.on('error', reject);
109
+ }).catch((err) => {
110
+ console.error(err.message);
111
+ process.exit(1);
112
+ });
113
+ process.exit(0);
114
+ }
115
+ function shellEscape(arg) {
116
+ if (/^[a-zA-Z0-9_\-./=:@,]+$/.test(arg))
117
+ return arg;
118
+ return `'${arg.replace(/'/g, "'\\''")}'`;
119
+ }
@@ -0,0 +1,8 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface WorkspaceCmdOptions {
3
+ name: string;
4
+ script: string;
5
+ args?: string[];
6
+ }
7
+ export declare const workspaceCommand: Command<any, WorkspaceCmdOptions>;
8
+ export {};