@gjsify/cli 0.3.20 → 0.4.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 (67) hide show
  1. package/dist/cli.gjs.mjs +798 -0
  2. package/lib/actions/build.js +4 -17
  3. package/lib/bundler-pick.d.ts +79 -0
  4. package/lib/bundler-pick.js +428 -0
  5. package/lib/commands/foreach.d.ts +16 -0
  6. package/lib/commands/foreach.js +268 -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 +222 -26
  11. package/lib/commands/run.d.ts +1 -1
  12. package/lib/commands/run.js +133 -20
  13. package/lib/commands/workspace.d.ts +8 -0
  14. package/lib/commands/workspace.js +69 -0
  15. package/lib/config.js +26 -0
  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 +88 -11
  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/package.json +36 -12
  25. package/src/actions/build.ts +0 -431
  26. package/src/actions/index.ts +0 -1
  27. package/src/commands/build.ts +0 -146
  28. package/src/commands/check.ts +0 -87
  29. package/src/commands/create.ts +0 -63
  30. package/src/commands/dlx.ts +0 -195
  31. package/src/commands/flatpak/build.ts +0 -225
  32. package/src/commands/flatpak/ci.ts +0 -173
  33. package/src/commands/flatpak/deps.ts +0 -120
  34. package/src/commands/flatpak/index.ts +0 -53
  35. package/src/commands/flatpak/init.ts +0 -191
  36. package/src/commands/flatpak/utils.ts +0 -76
  37. package/src/commands/gettext.ts +0 -258
  38. package/src/commands/gresource.ts +0 -97
  39. package/src/commands/gsettings.ts +0 -87
  40. package/src/commands/index.ts +0 -12
  41. package/src/commands/info.ts +0 -70
  42. package/src/commands/install.ts +0 -195
  43. package/src/commands/run.ts +0 -33
  44. package/src/commands/showcase.ts +0 -149
  45. package/src/config.ts +0 -289
  46. package/src/constants.ts +0 -1
  47. package/src/index.ts +0 -37
  48. package/src/types/cli-build-options.ts +0 -100
  49. package/src/types/command.ts +0 -10
  50. package/src/types/config-data-library.ts +0 -5
  51. package/src/types/config-data-typescript.ts +0 -6
  52. package/src/types/config-data.ts +0 -225
  53. package/src/types/cosmiconfig-result.ts +0 -5
  54. package/src/types/index.ts +0 -6
  55. package/src/utils/check-system-deps.ts +0 -480
  56. package/src/utils/detect-native-packages.ts +0 -153
  57. package/src/utils/discover-showcases.ts +0 -75
  58. package/src/utils/dlx-cache.ts +0 -135
  59. package/src/utils/install-backend-native.ts +0 -363
  60. package/src/utils/install-backend.ts +0 -88
  61. package/src/utils/install-global.ts +0 -182
  62. package/src/utils/normalize-bundler-options.ts +0 -129
  63. package/src/utils/parse-spec.ts +0 -48
  64. package/src/utils/resolve-gjs-entry.ts +0 -96
  65. package/src/utils/resolve-plugin-by-name.ts +0 -106
  66. package/src/utils/run-gjs.ts +0 -90
  67. package/tsconfig.json +0 -16
@@ -0,0 +1,268 @@
1
+ // `gjsify foreach [flags] <script>` — yarn-workspaces-foreach replacement.
2
+ //
3
+ // Replaces every `yarn workspaces foreach -A -p --no-private --exclude
4
+ // '@girs/*' --topological run build` style invocation in monorepo
5
+ // scripts. Flags mirror yarn 4's shape so root package.json scripts can
6
+ // move over with a 1:1 substitution.
7
+ //
8
+ // Output is line-prefixed `[<workspace-name>]` when --parallel is set,
9
+ // matching yarn's interactive flow. Exit code is non-zero if any child
10
+ // process failed; first failure's stderr is forwarded.
11
+ import { spawn } from 'node:child_process';
12
+ import { cpus } from 'node:os';
13
+ import { buildDependencyGraph, discoverWorkspaces, filterWorkspaces, topologicalSort, } from '@gjsify/workspace';
14
+ 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.',
17
+ builder: (yargs) => yargs
18
+ .positional('script', {
19
+ description: 'Script name to run in each workspace (`run <name>`-equivalent).',
20
+ type: 'string',
21
+ demandOption: true,
22
+ })
23
+ .positional('args', {
24
+ description: 'Extra arguments forwarded to each child invocation.',
25
+ type: 'string',
26
+ array: true,
27
+ })
28
+ .option('all', {
29
+ description: 'Include workspaces declared as `private: true`.',
30
+ type: 'boolean',
31
+ alias: 'A',
32
+ default: false,
33
+ })
34
+ .option('parallel', {
35
+ description: 'Run workspaces in parallel (capped by --jobs).',
36
+ type: 'boolean',
37
+ alias: 'p',
38
+ default: false,
39
+ })
40
+ .option('topological', {
41
+ description: 'Wait for each workspace\'s deps to finish before starting it (production deps only).',
42
+ type: 'boolean',
43
+ alias: 't',
44
+ default: false,
45
+ })
46
+ .option('topological-dev', {
47
+ description: 'Like --topological but also respects devDependencies (often cyclic — use sparingly).',
48
+ type: 'boolean',
49
+ default: false,
50
+ })
51
+ .option('include', {
52
+ description: 'Glob pattern to include workspaces by name (repeatable).',
53
+ type: 'string',
54
+ array: true,
55
+ })
56
+ .option('exclude', {
57
+ description: 'Glob pattern to exclude workspaces by name (repeatable).',
58
+ type: 'string',
59
+ array: true,
60
+ })
61
+ .option('private', {
62
+ // Yargs auto-negates `--no-private` to `private=false`, so the
63
+ // user-facing flag stays `--no-private` (yarn-compatible).
64
+ description: 'Include private workspaces (default true). Pass --no-private to skip them.',
65
+ type: 'boolean',
66
+ default: true,
67
+ })
68
+ .option('verbose', {
69
+ description: 'Echo every spawned command before running it.',
70
+ type: 'boolean',
71
+ alias: 'v',
72
+ default: false,
73
+ })
74
+ .option('jobs', {
75
+ description: 'Maximum concurrent workspaces in --parallel mode (default: cpu count).',
76
+ type: 'number',
77
+ alias: 'j',
78
+ }),
79
+ handler: async (args) => {
80
+ const cwd = process.cwd();
81
+ const allWorkspaces = discoverWorkspaces(cwd);
82
+ let selected = filterWorkspaces(allWorkspaces, {
83
+ include: args.include,
84
+ exclude: args.exclude,
85
+ noPrivate: args.private === false,
86
+ });
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
+ });
94
+ 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 ?? [])})`);
96
+ return;
97
+ }
98
+ if (args.topological || args['topological-dev']) {
99
+ const graph = buildDependencyGraph(selected, {
100
+ includeDev: args['topological-dev'] === true,
101
+ });
102
+ selected = topologicalSort(graph);
103
+ }
104
+ const cmd = args.script;
105
+ const cmdArgs = args.args ?? [];
106
+ 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;
111
+ }
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;
119
+ }
120
+ await runSequential(selected, cmd, cmdArgs, verbose);
121
+ },
122
+ };
123
+ async function runSequential(workspaces, script, args, verbose) {
124
+ for (const ws of workspaces) {
125
+ await runOne(ws, script, args, /* prefixOutput */ false, verbose);
126
+ }
127
+ }
128
+ async function runParallel(workspaces, script, args, concurrency, verbose) {
129
+ let cursor = 0;
130
+ const workers = [];
131
+ for (let w = 0; w < concurrency; w++) {
132
+ workers.push((async () => {
133
+ while (cursor < workspaces.length) {
134
+ const i = cursor++;
135
+ await runOne(workspaces[i], script, args, /* prefixOutput */ true, verbose);
136
+ }
137
+ })());
138
+ }
139
+ await Promise.all(workers);
140
+ }
141
+ async function runTopologicalParallel(workspaces, script, args, concurrency, verbose, includeDev) {
142
+ const selectedNames = new Set(workspaces.map((w) => w.name));
143
+ const remaining = new Map();
144
+ for (const ws of workspaces) {
145
+ const wsDeps = new Set();
146
+ const m = ws.manifest;
147
+ for (const block of [
148
+ m.dependencies,
149
+ includeDev ? m.devDependencies : undefined,
150
+ m.optionalDependencies,
151
+ ]) {
152
+ if (!block)
153
+ continue;
154
+ for (const [name, spec] of Object.entries(block)) {
155
+ if (typeof spec !== 'string')
156
+ continue;
157
+ if (!spec.startsWith('workspace:'))
158
+ continue;
159
+ if (selectedNames.has(name))
160
+ wsDeps.add(name);
161
+ }
162
+ }
163
+ remaining.set(ws.name, wsDeps);
164
+ }
165
+ const byName = new Map(workspaces.map((w) => [w.name, w]));
166
+ const done = new Set();
167
+ let inflight = 0;
168
+ return new Promise((resolve, reject) => {
169
+ let error = null;
170
+ const pump = () => {
171
+ if (error)
172
+ return;
173
+ while (inflight < concurrency) {
174
+ const ready = [...remaining.entries()]
175
+ .filter(([, deps]) => [...deps].every((d) => done.has(d)))
176
+ .map(([n]) => n);
177
+ if (ready.length === 0)
178
+ break;
179
+ const next = ready.sort()[0];
180
+ remaining.delete(next);
181
+ inflight++;
182
+ runOne(byName.get(next), script, args, /* prefixOutput */ true, verbose)
183
+ .then(() => {
184
+ inflight--;
185
+ done.add(next);
186
+ if (remaining.size === 0 && inflight === 0) {
187
+ resolve();
188
+ return;
189
+ }
190
+ pump();
191
+ })
192
+ .catch((e) => {
193
+ error = e instanceof Error ? e : new Error(String(e));
194
+ // Wait for in-flight tasks to finish (yarn does the
195
+ // same — surfaces all errors instead of abruptly
196
+ // killing siblings).
197
+ if (inflight === 0)
198
+ reject(error);
199
+ });
200
+ }
201
+ if (remaining.size > 0 && inflight === 0 && !error) {
202
+ reject(new Error(`gjsify foreach --topological: stuck — workspaces ${[...remaining.keys()].join(', ')} have unsatisfied deps in the selected set`));
203
+ }
204
+ };
205
+ pump();
206
+ });
207
+ }
208
+ async function runOne(ws, script, args, prefixOutput, verbose) {
209
+ // Use the same package manager that invoked us — yarn under yarn,
210
+ // npm under npm, gjsify under gjsify. Default to `npm` for portability
211
+ // when nothing is detectable; the script-runner (D.5) will replace
212
+ // this once `gjsify run` ships.
213
+ const runner = detectPackageManager();
214
+ const argv = runner === 'gjsify'
215
+ ? ['run', script, ...args]
216
+ : ['run', script, ...(args.length > 0 ? ['--', ...args] : [])];
217
+ if (verbose) {
218
+ console.error(`[${ws.name}] $ ${runner} ${argv.join(' ')}`);
219
+ }
220
+ await spawnPrefixed(runner, argv, ws.location, prefixOutput ? `[${ws.name}] ` : null);
221
+ }
222
+ function detectPackageManager() {
223
+ // `npm_config_user_agent` is set by npm/yarn/pnpm — first token is
224
+ // `<name>/<version>`. Reuse it so `gjsify foreach build` invoked
225
+ // through `yarn run` keeps using yarn, etc.
226
+ const ua = process.env.npm_config_user_agent ?? '';
227
+ if (ua.startsWith('yarn/'))
228
+ return 'yarn';
229
+ if (ua.startsWith('gjsify/'))
230
+ return 'gjsify';
231
+ return 'npm';
232
+ }
233
+ function spawnPrefixed(cmd, args, cwd, prefix) {
234
+ return new Promise((resolve, reject) => {
235
+ const child = spawn(cmd, args, {
236
+ cwd,
237
+ stdio: prefix ? ['ignore', 'pipe', 'pipe'] : 'inherit',
238
+ env: process.env,
239
+ });
240
+ if (prefix && child.stdout && child.stderr) {
241
+ prefixLines(child.stdout, process.stdout, prefix);
242
+ prefixLines(child.stderr, process.stderr, prefix);
243
+ }
244
+ child.on('close', (code) => {
245
+ if (code === 0)
246
+ resolve();
247
+ else
248
+ reject(new Error(`${cmd} ${args.join(' ')} exited with code ${code}`));
249
+ });
250
+ child.on('error', (err) => reject(err));
251
+ });
252
+ }
253
+ function prefixLines(src, sink, prefix) {
254
+ let buf = '';
255
+ src.setEncoding('utf-8');
256
+ src.on('data', (chunk) => {
257
+ buf += chunk;
258
+ let idx;
259
+ while ((idx = buf.indexOf('\n')) !== -1) {
260
+ sink.write(prefix + buf.slice(0, idx + 1));
261
+ buf = buf.slice(idx + 1);
262
+ }
263
+ });
264
+ src.on('end', () => {
265
+ if (buf.length > 0)
266
+ sink.write(prefix + buf + '\n');
267
+ });
268
+ }
@@ -10,3 +10,5 @@ export * from './gsettings.js';
10
10
  export { flatpakCommand } from './flatpak/index.js';
11
11
  export * from './dlx.js';
12
12
  export * from './install.js';
13
+ export * from './foreach.js';
14
+ export * from './workspace.js';
@@ -10,3 +10,5 @@ export * from './gsettings.js';
10
10
  export { flatpakCommand } from './flatpak/index.js';
11
11
  export * from './dlx.js';
12
12
  export * from './install.js';
13
+ export * from './foreach.js';
14
+ export * from './workspace.js';
@@ -5,6 +5,7 @@ interface InstallOptions {
5
5
  'save-dev'?: boolean;
6
6
  'save-peer'?: boolean;
7
7
  'save-optional'?: boolean;
8
+ immutable?: boolean;
8
9
  verbose: boolean;
9
10
  }
10
11
  export declare const installCommand: Command<any, InstallOptions>;
@@ -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 { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } 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,199 @@ 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);
82
+ // Escape-hatch: legacy npm subprocess flow.
83
+ if (process.env.GJSIFY_INSTALL_BACKEND === 'npm') {
84
+ await projectInstallViaNpm(args);
85
+ await runPostInstallChecks();
86
+ return;
74
87
  }
75
- await spawnNpm(npmArgs);
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
+ }
172
+ }
173
+ /**
174
+ * Phase D.3 — workspace-aware install. Mirrors what `yarn install` does
175
+ * at a monorepo root:
176
+ * 1. Discover every workspace under the root.
177
+ * 2. Aggregate the union of their external (non-`workspace:`) deps.
178
+ * 3. Run the native install backend ONCE at the root prefix so all
179
+ * externals land in a single `node_modules/` (poor-man's hoisting —
180
+ * we don't deduplicate version-range conflicts yet, the BFS resolver
181
+ * picks first-match).
182
+ * 4. For every `workspace:` reference, symlink the target workspace's
183
+ * directory into the requesting workspace's `node_modules/<dep>`
184
+ * so `import '@gjsify/utils'` resolves to the local source.
185
+ *
186
+ * Hoisting strategy is intentionally minimal — D.3 ships the working
187
+ * baseline; per-workspace dedup + nested `node_modules/` for version
188
+ * conflicts are tracked as a follow-up in STATUS.md "Open TODOs".
189
+ */
190
+ async function workspaceInstall(cwd, args) {
191
+ const workspaces = discoverWorkspaces(cwd, { includeRoot: true });
192
+ if (workspaces.length === 0) {
193
+ throw new Error(`gjsify install: ${cwd} has a "workspaces" field but no workspaces were discovered`);
194
+ }
195
+ const byName = new Map(workspaces.map((w) => [w.name, w]));
196
+ const externalSpecs = new Set();
197
+ const symlinks = [];
198
+ for (const ws of workspaces) {
199
+ const m = ws.manifest;
200
+ for (const kind of ['dependencies', 'devDependencies', 'optionalDependencies']) {
201
+ const block = m[kind];
202
+ if (!block)
203
+ continue;
204
+ for (const [depName, spec] of Object.entries(block)) {
205
+ if (typeof spec !== 'string')
206
+ continue;
207
+ if (spec.startsWith('workspace:')) {
208
+ const target = byName.get(depName);
209
+ if (!target) {
210
+ throw new Error(`gjsify install: ${ws.name} declares "${depName}: ${spec}" but ` +
211
+ `no workspace with that name exists`);
212
+ }
213
+ symlinks.push({ fromWorkspaceName: ws.name, depName, targetLocation: target.location });
214
+ continue;
215
+ }
216
+ if (/^(link|file|portal|git\+|https?):/.test(spec))
217
+ continue;
218
+ externalSpecs.add(`${depName}@${spec}`);
219
+ }
220
+ }
221
+ }
222
+ console.log(`gjsify install: ${workspaces.length} workspace(s), ${externalSpecs.size} external dep spec(s), ${symlinks.length} workspace symlink(s)`);
223
+ if (externalSpecs.size > 0) {
224
+ await installPackages({
225
+ prefix: cwd,
226
+ specs: [...externalSpecs],
227
+ verbose: args.verbose,
228
+ lockfile: !args.immutable,
229
+ frozen: args.immutable,
230
+ });
231
+ }
232
+ else if (args.verbose) {
233
+ console.log('gjsify install: no external deps to fetch');
234
+ }
235
+ for (const link of symlinks) {
236
+ const target = byName.get(link.fromWorkspaceName);
237
+ if (!target)
238
+ continue;
239
+ const linkPath = join(target.location, 'node_modules', link.depName);
240
+ mkdirSync(dirname(linkPath), { recursive: true });
241
+ // Remove any prior entry (regular dir, broken symlink, file).
242
+ try {
243
+ const stat = lstatSync(linkPath);
244
+ if (stat.isSymbolicLink() || stat.isFile()) {
245
+ rmSync(linkPath, { force: true });
246
+ }
247
+ else if (stat.isDirectory()) {
248
+ rmSync(linkPath, { recursive: true, force: true });
249
+ }
250
+ }
251
+ catch { /* ENOENT — fine, nothing to remove */ }
252
+ // Relative symlink so the repo is portable across checkout paths.
253
+ const relTarget = relative(dirname(linkPath), link.targetLocation);
254
+ symlinkSync(relTarget, linkPath);
255
+ }
256
+ if (symlinks.length > 0) {
257
+ console.log(`gjsify install: wired ${symlinks.length} workspace symlink(s)`);
258
+ }
259
+ }
260
+ async function projectInstallViaNpm(args) {
261
+ const npmArgs = ['install'];
262
+ if (args['save-dev'])
263
+ npmArgs.push('--save-dev');
264
+ if (args['save-peer'])
265
+ npmArgs.push('--save-peer');
266
+ if (args['save-optional'])
267
+ npmArgs.push('--save-optional');
268
+ if (args.verbose)
269
+ npmArgs.push('--loglevel', 'verbose');
270
+ if (args.packages && args.packages.length > 0) {
271
+ npmArgs.push(...args.packages);
272
+ }
273
+ await spawnNpm(npmArgs);
274
+ }
79
275
  async function spawnNpm(npmArgs) {
80
276
  return new Promise((resolve, reject) => {
81
277
  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>;