@gjsify/cli 0.3.21 → 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 +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 +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 -304
  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
@@ -1,26 +1,139 @@
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 { discoverWorkspaces } from '@gjsify/workspace';
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
+ await new Promise((resolveOk, reject) => {
98
+ const child = spawn(fullCmd, { cwd, env, stdio: 'inherit', shell: true });
99
+ child.on('close', (code) => {
100
+ if (code === 0)
101
+ resolveOk();
102
+ else
103
+ reject(new Error(`script "${script}" exited with code ${code}`));
104
+ });
105
+ child.on('error', reject);
106
+ }).catch((err) => {
107
+ console.error(err.message);
108
+ process.exit(1);
109
+ });
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;
134
+ }
135
+ function shellEscape(arg) {
136
+ if (/^[a-zA-Z0-9_\-./=:@,]+$/.test(arg))
137
+ return arg;
138
+ return `'${arg.replace(/'/g, "'\\''")}'`;
139
+ }
@@ -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 {};
@@ -0,0 +1,69 @@
1
+ // `gjsify workspace <name> <script> [args..]` — yarn-workspace shortcut.
2
+ //
3
+ // Equivalent to `yarn workspace <name> run <script>`: locates the named
4
+ // workspace in the current monorepo, then runs the script there. Used
5
+ // extensively in gjsify's own root `package.json` (17 call sites).
6
+ import { spawn } from 'node:child_process';
7
+ import { discoverWorkspaces } from '@gjsify/workspace';
8
+ export const workspaceCommand = {
9
+ command: 'workspace <name> <script> [args..]',
10
+ description: 'Run a workspace script (`yarn workspace <name> run <script>` equivalent).',
11
+ builder: (yargs) => yargs
12
+ .positional('name', {
13
+ description: 'Workspace name (matches package.json `name` field).',
14
+ type: 'string',
15
+ demandOption: true,
16
+ })
17
+ .positional('script', {
18
+ description: 'Script name to run inside that workspace.',
19
+ type: 'string',
20
+ demandOption: true,
21
+ })
22
+ .positional('args', {
23
+ description: 'Extra arguments forwarded to the script.',
24
+ type: 'string',
25
+ array: true,
26
+ }),
27
+ handler: async (args) => {
28
+ const workspaces = discoverWorkspaces(process.cwd());
29
+ const target = workspaces.find((w) => w.name === args.name);
30
+ if (!target) {
31
+ console.error(`gjsify workspace: no workspace named "${args.name}" — discovered ${workspaces.length} workspace(s)`);
32
+ process.exit(1);
33
+ }
34
+ const scripts = target.manifest.scripts ?? {};
35
+ if (typeof scripts[args.script] !== 'string') {
36
+ console.error(`gjsify workspace: workspace "${args.name}" has no script "${args.script}"`);
37
+ process.exit(1);
38
+ }
39
+ const runner = detectPackageManager();
40
+ const argv = runner === 'gjsify'
41
+ ? ['run', args.script, ...(args.args ?? [])]
42
+ : ['run', args.script, ...(args.args && args.args.length > 0 ? ['--', ...args.args] : [])];
43
+ await new Promise((resolve, reject) => {
44
+ const child = spawn(runner, argv, {
45
+ cwd: target.location,
46
+ stdio: 'inherit',
47
+ env: process.env,
48
+ });
49
+ child.on('close', (code) => {
50
+ if (code === 0)
51
+ resolve();
52
+ else
53
+ reject(new Error(`${runner} ${argv.join(' ')} exited with code ${code}`));
54
+ });
55
+ child.on('error', reject);
56
+ }).catch((err) => {
57
+ console.error(err.message);
58
+ process.exit(1);
59
+ });
60
+ },
61
+ };
62
+ function detectPackageManager() {
63
+ const ua = process.env.npm_config_user_agent ?? '';
64
+ if (ua.startsWith('yarn/'))
65
+ return 'yarn';
66
+ if (ua.startsWith('gjsify/'))
67
+ return 'gjsify';
68
+ return 'npm';
69
+ }
package/lib/config.js CHANGED
@@ -247,7 +247,18 @@ export class Config {
247
247
  if (output.minify === undefined)
248
248
  output.minify = true;
249
249
  if (output.minify === true) {
250
- output.minify = { mangle: { keepNames: { function: true, class: true } } };
250
+ // `keepNames: true` on output is the top-level BundlerOptions
251
+ // path: rolldown wires it into both `mangle.keepNames.all_true()`
252
+ // (function+class) AND `compress.keepNames.all_true()` for us.
253
+ // The previous `minify: { mangle: { keepNames: {...} } }` shape
254
+ // worked under npm rolldown's JS API but rolldown's serde
255
+ // `deserialize_minify` (deserialize_minify_options.rs:311) only
256
+ // accepts SimpleMinifyOptions (bool/string), so the object form
257
+ // was rejected by the native facade's JSON-deserializer with
258
+ // "data did not match any variant of untagged enum
259
+ // SimpleMinifyOptions". `output.keepNames` reaches the binding
260
+ // through the documented top-level path in both engines.
261
+ output.keepNames = true;
251
262
  }
252
263
  if (cliArgs.logLevel) {
253
264
  // Map esbuild log levels to Rolldown's narrower set:
package/lib/index.js CHANGED
@@ -1,9 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import yargs from 'yargs';
3
3
  import { hideBin } from 'yargs/helpers';
4
- import { buildCommand as build, runCommand as run, infoCommand as info, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, dlxCommand as dlx, installCommand as install, } from './commands/index.js';
4
+ import { buildCommand as build, runCommand as run, infoCommand as info, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, dlxCommand as dlx, installCommand as install, foreachCommand as foreach, workspaceCommand as workspace, } from './commands/index.js';
5
5
  import { APP_NAME } from './constants.js';
6
- void yargs(hideBin(process.argv))
6
+ // `parseAsync()` instead of `.argv` so the top-level await keeps the
7
+ // process alive until command handlers complete. Under Node this is
8
+ // cosmetic — the event loop holds the process up — but under GJS the
9
+ // script ends as soon as the top-level synchronous flow finishes, and
10
+ // fire-and-forget handlers silently exit before any async work runs.
11
+ await yargs(hideBin(process.argv))
7
12
  .scriptName(APP_NAME)
8
13
  .strict()
9
14
  .command(create.command, create.description, create.builder, create.handler)
@@ -18,5 +23,8 @@ void yargs(hideBin(process.argv))
18
23
  .command(gettext.command, gettext.description, gettext.builder, gettext.handler)
19
24
  .command(gsettings.command, gsettings.description, gsettings.builder, gsettings.handler)
20
25
  .command(flatpak.command, flatpak.description, flatpak.builder, flatpak.handler)
26
+ .command(foreach.command, foreach.description, foreach.builder, foreach.handler)
27
+ .command(workspace.command, workspace.description, workspace.builder, workspace.handler)
21
28
  .demandCommand(1)
22
- .help().argv;
29
+ .help()
30
+ .parseAsync();
@@ -202,7 +202,16 @@ export interface ConfigDataFlatpak {
202
202
  runtime?: 'gnome' | 'freedesktop';
203
203
  /** Runtime/SDK version, e.g. `'50'` for GNOME or `'24.08'` for Freedesktop. */
204
204
  runtimeVersion?: string;
205
- /** Extra SDK extensions, e.g. `['org.freedesktop.Sdk.Extension.node24']` for build-time `yarn install`. */
205
+ /**
206
+ * Extra SDK extensions to include in the manifest, e.g.
207
+ * `['org.freedesktop.Sdk.Extension.llvm17']` for projects with native
208
+ * code that needs a specific toolchain. Leave empty (the default) for
209
+ * pure gjsify projects — the GNOME runtime already ships GJS + GLib
210
+ * + libsoup, and `gjsify build` produces a self-contained bundle that
211
+ * needs no build-time Node anymore. (Before Phase D-3 we added
212
+ * `org.freedesktop.Sdk.Extension.node24` here by default for the
213
+ * yarn-install + esbuild build step — that's no longer required.)
214
+ */
206
215
  sdkExtensions?: string[];
207
216
  /** Path components prepended to PATH inside the build sandbox. */
208
217
  appendPath?: string[];
@@ -1,2 +1,6 @@
1
1
  import type { InstallOptions } from "./install-backend.ts";
2
- export declare function installPackagesNative(opts: InstallOptions): Promise<void>;
2
+ export interface InstalledTopLevel {
3
+ name: string;
4
+ version: string;
5
+ }
6
+ export declare function installPackagesNative(opts: InstallOptions): Promise<InstalledTopLevel[]>;
@@ -26,14 +26,29 @@ export async function installPackagesNative(opts) {
26
26
  const lockfilePath = path.join(opts.prefix, LOCKFILE_NAME);
27
27
  const existingLock = readLockfile(lockfilePath);
28
28
  let nodes;
29
- if (existingLock && (opts.frozen || lockfileMatchesRequest(existingLock, opts.specs))) {
29
+ if (opts.frozen) {
30
+ // --immutable / --frozen: lockfile is the authoritative source.
31
+ // Reject if the file is missing, version-mismatched, or its
32
+ // `requested` set has drifted from the live request — silently
33
+ // honoring a stale lockfile would mask real dep churn (the original
34
+ // bug --immutable exists to catch).
35
+ if (!existingLock) {
36
+ throw new Error(`install: --immutable requires ${LOCKFILE_NAME} at ${opts.prefix} — none found. ` +
37
+ `Run \`gjsify install\` (without --immutable) to generate one and commit it.`);
38
+ }
39
+ const drift = describeLockfileDrift(existingLock, opts.specs);
40
+ if (drift) {
41
+ throw new Error(`install: --immutable but ${lockfilePath} is stale.\n${drift}\n` +
42
+ `Re-run \`gjsify install\` (without --immutable) to refresh the lockfile.`);
43
+ }
44
+ log("install: --immutable, using lockfile (%d package(s))", Object.keys(existingLock.packages).length);
45
+ nodes = lockfileToNodes(existingLock);
46
+ }
47
+ else if (existingLock && lockfileMatchesRequest(existingLock, opts.specs)) {
30
48
  log("install: using lockfile (%d package(s))", Object.keys(existingLock.packages).length);
31
49
  nodes = lockfileToNodes(existingLock);
32
50
  }
33
51
  else {
34
- if (opts.frozen) {
35
- throw new Error(`install: --frozen requested but ${lockfilePath} is missing or stale (specs differ)`);
36
- }
37
52
  log("install: resolving %d top-level spec(s) → %s", opts.specs.length, opts.prefix);
38
53
  nodes = await resolveDeps(opts.specs, npmrc, log);
39
54
  if (opts.lockfile) {
@@ -45,6 +60,32 @@ export async function installPackagesNative(opts) {
45
60
  await downloadAndExtractAll(nodes, opts.prefix, npmrc, log);
46
61
  await linkBins(nodes, opts.prefix, log);
47
62
  log("install: done");
63
+ // Surface the top-level requested packages so callers can update
64
+ // package.json with the resolved version (mirrors `npm install --save`
65
+ // behavior). Sub-deps are not included.
66
+ return topLevelResolutions(opts.specs, nodes);
67
+ }
68
+ function topLevelResolutions(specs, nodes) {
69
+ const byName = new Map(nodes.map((n) => [n.name, n]));
70
+ const out = [];
71
+ for (const spec of specs) {
72
+ const name = parseSpecName(spec);
73
+ const node = byName.get(name);
74
+ if (node)
75
+ out.push({ name: node.name, version: node.version });
76
+ }
77
+ return out;
78
+ }
79
+ function parseSpecName(spec) {
80
+ if (spec.startsWith("@")) {
81
+ const slash = spec.indexOf("/");
82
+ if (slash === -1)
83
+ return spec;
84
+ const at = spec.indexOf("@", slash + 1);
85
+ return at === -1 ? spec : spec.slice(0, at);
86
+ }
87
+ const at = spec.indexOf("@");
88
+ return at === -1 ? spec : spec.slice(0, at);
48
89
  }
49
90
  async function resolveDeps(specs, npmrc, log) {
50
91
  const packumentCache = new Map();
@@ -149,6 +190,32 @@ function lockfileMatchesRequest(lockfile, specs) {
149
190
  const b = [...specs].sort();
150
191
  return a.every((v, i) => v === b[i]);
151
192
  }
193
+ /**
194
+ * Human-readable diff between `lockfile.requested` and the live request.
195
+ * Returns null when the two sets are identical (the lockfile is in sync).
196
+ * Used by `--immutable` to surface exactly which deps drifted, so CI
197
+ * failures don't force the user to diff lockfile JSON by hand.
198
+ */
199
+ function describeLockfileDrift(lockfile, specs) {
200
+ const lockSet = new Set(lockfile.requested);
201
+ const liveSet = new Set(specs);
202
+ const added = [];
203
+ const removed = [];
204
+ for (const s of liveSet)
205
+ if (!lockSet.has(s))
206
+ added.push(s);
207
+ for (const s of lockSet)
208
+ if (!liveSet.has(s))
209
+ removed.push(s);
210
+ if (added.length === 0 && removed.length === 0)
211
+ return null;
212
+ const lines = [];
213
+ if (added.length > 0)
214
+ lines.push(` + ${added.sort().join("\n + ")}`);
215
+ if (removed.length > 0)
216
+ lines.push(` - ${removed.sort().join("\n - ")}`);
217
+ return lines.join("\n");
218
+ }
152
219
  function parseSpec(raw) {
153
220
  if (raw.startsWith("@")) {
154
221
  const slash = raw.indexOf("/");
@@ -265,25 +332,35 @@ function normalizeBin(pkgName, bin) {
265
332
  }
266
333
  async function loadNpmrc(opts) {
267
334
  const home = os.homedir();
268
- const homeRc = path.join(home, ".npmrc");
269
335
  let parsed = {
270
336
  registry: opts.registry ?? DEFAULT_REGISTRY,
271
337
  scopes: {},
272
338
  authTokens: {},
273
339
  basicAuth: {},
274
340
  };
275
- if (fs.existsSync(homeRc)) {
341
+ // Layered .npmrc lookup (most-specific wins): home → project (cwd's
342
+ // prefix). npm itself merges through `XDG_CONFIG_HOME/npm/npmrc` and a
343
+ // workspace-root one too; the gjsify project-local case is what users
344
+ // hit most often (mock-registry tests, scoped-registry overrides), so
345
+ // we cover that explicitly.
346
+ for (const candidate of [path.join(home, ".npmrc"), path.join(opts.prefix, ".npmrc")]) {
347
+ if (!fs.existsSync(candidate))
348
+ continue;
276
349
  try {
277
- parsed = parseNpmrc(fs.readFileSync(homeRc, "utf-8"));
350
+ const projectParsed = parseNpmrc(fs.readFileSync(candidate, "utf-8"));
351
+ parsed = { ...parsed, ...projectParsed, scopes: { ...parsed.scopes, ...projectParsed.scopes } };
278
352
  }
279
353
  catch (e) {
280
- // Don't let a busted .npmrc prevent installs from anonymous registries.
281
- console.warn(`gjsify install: ignoring malformed ${homeRc}: ${e.message}`);
354
+ console.warn(`gjsify install: ignoring malformed ${candidate}: ${e.message}`);
282
355
  }
283
356
  }
284
- if (opts.registry) {
357
+ // env-var override (npm convention: `npm_config_registry`).
358
+ const envRegistry = process.env.npm_config_registry;
359
+ if (envRegistry)
360
+ parsed.registry = envRegistry;
361
+ // Explicit caller-provided registry trumps everything else.
362
+ if (opts.registry)
285
363
  parsed.registry = opts.registry;
286
- }
287
364
  return parsed;
288
365
  }
289
366
  function makeLogger(verbose) {
@@ -16,4 +16,14 @@ export interface InstallOptions {
16
16
  /** Use `<prefix>/gjsify-lock.json` as the source of truth — fail if missing. */
17
17
  frozen?: boolean;
18
18
  }
19
- export declare function installPackages(opts: InstallOptions): Promise<void>;
19
+ export interface InstallResult {
20
+ /** Top-level packages that were requested, with the version each
21
+ * resolved to. Empty for the npm backend (parsing npm's stdout would
22
+ * be unreliable; callers that need this should set
23
+ * GJSIFY_INSTALL_BACKEND=native). */
24
+ installed: Array<{
25
+ name: string;
26
+ version: string;
27
+ }>;
28
+ }
29
+ export declare function installPackages(opts: InstallOptions): Promise<InstallResult>;
@@ -18,10 +18,12 @@ import { join } from 'node:path';
18
18
  const DEFAULT_BACKEND = process.env.GJSIFY_INSTALL_BACKEND ?? 'native';
19
19
  export async function installPackages(opts) {
20
20
  if (DEFAULT_BACKEND === 'npm') {
21
- return installViaNpm(opts);
21
+ await installViaNpm(opts);
22
+ return { installed: [] };
22
23
  }
23
24
  const { installPackagesNative } = await import('./install-backend-native.js');
24
- return installPackagesNative(opts);
25
+ const installed = await installPackagesNative(opts);
26
+ return { installed };
25
27
  }
26
28
  async function installViaNpm({ prefix, specs, verbose, registry }) {
27
29
  if (specs.length === 0) {
@@ -0,0 +1,47 @@
1
+ export type DependencyKind = 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies';
2
+ export interface PackageJson {
3
+ name?: string;
4
+ version?: string;
5
+ type?: string;
6
+ workspaces?: string[] | {
7
+ packages?: string[];
8
+ nohoist?: string[];
9
+ };
10
+ dependencies?: Record<string, string>;
11
+ devDependencies?: Record<string, string>;
12
+ peerDependencies?: Record<string, string>;
13
+ optionalDependencies?: Record<string, string>;
14
+ [key: string]: unknown;
15
+ }
16
+ export declare function readPackageJson(pkgPath: string): PackageJson | null;
17
+ export declare function writePackageJson(pkgPath: string, pkg: PackageJson): void;
18
+ /**
19
+ * Parse a user spec into `{ name, range }`:
20
+ * `react` → { name: 'react', range: undefined }
21
+ * `react@^18` → { name: 'react', range: '^18' }
22
+ * `@types/node` → { name: '@types/node', range: undefined }
23
+ * `@types/node@1` → { name: '@types/node', range: '1' }
24
+ */
25
+ export declare function parseSpec(spec: string): {
26
+ name: string;
27
+ range?: string;
28
+ };
29
+ /**
30
+ * Collect existing dependencies + devDependencies + optionalDependencies
31
+ * from a project package.json into installable specs of the form
32
+ * `name@range`. Used by `gjsify install` (no args) to seed the resolver
33
+ * with the project's existing dependency manifest — equivalent to
34
+ * `npm install` reading `package.json`.
35
+ */
36
+ export declare function projectSpecsFromPackageJson(pkg: PackageJson): string[];
37
+ /**
38
+ * Add or update a dependency entry in `pkg`. If the spec didn't include
39
+ * a range, callers fill in the installed version after resolution and
40
+ * call this again with `installedVersion` set.
41
+ */
42
+ export declare function addDependencyEntry(pkg: PackageJson, name: string, range: string, kind: DependencyKind): void;
43
+ /**
44
+ * Default version range when the user didn't pin one: `^x.y.z` from the
45
+ * installed version. Mirrors npm's `save-prefix` default (`^`).
46
+ */
47
+ export declare function defaultRangeFromVersion(version: string): string;
@@ -0,0 +1,108 @@
1
+ // Helpers for editing `package.json` during `gjsify install <pkg>`.
2
+ //
3
+ // Mirrors npm's `--save-{prod,dev,peer,optional}` semantics:
4
+ // - default → dependencies (production)
5
+ // - --save-dev → devDependencies
6
+ // - --save-peer → peerDependencies
7
+ // - --save-optional → optionalDependencies
8
+ //
9
+ // Version specifier resolution mirrors npm's default (`^x.y.z` from the
10
+ // installed version), unless the user passed an explicit range in the spec
11
+ // (`react@^18` → keep `^18`).
12
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
13
+ export function readPackageJson(pkgPath) {
14
+ if (!existsSync(pkgPath))
15
+ return null;
16
+ const raw = readFileSync(pkgPath, 'utf-8');
17
+ try {
18
+ return JSON.parse(raw);
19
+ }
20
+ catch (e) {
21
+ throw new Error(`gjsify install: ${pkgPath} is not valid JSON: ${e.message}`);
22
+ }
23
+ }
24
+ export function writePackageJson(pkgPath, pkg) {
25
+ const sorted = sortKnownDepFields(pkg);
26
+ writeFileSync(pkgPath, JSON.stringify(sorted, null, 2) + '\n', 'utf-8');
27
+ }
28
+ /**
29
+ * Parse a user spec into `{ name, range }`:
30
+ * `react` → { name: 'react', range: undefined }
31
+ * `react@^18` → { name: 'react', range: '^18' }
32
+ * `@types/node` → { name: '@types/node', range: undefined }
33
+ * `@types/node@1` → { name: '@types/node', range: '1' }
34
+ */
35
+ export function parseSpec(spec) {
36
+ if (spec.startsWith('@')) {
37
+ const slash = spec.indexOf('/');
38
+ if (slash === -1)
39
+ return { name: spec };
40
+ const at = spec.indexOf('@', slash + 1);
41
+ if (at === -1)
42
+ return { name: spec };
43
+ return { name: spec.slice(0, at), range: spec.slice(at + 1) };
44
+ }
45
+ const at = spec.indexOf('@');
46
+ if (at === -1)
47
+ return { name: spec };
48
+ return { name: spec.slice(0, at), range: spec.slice(at + 1) };
49
+ }
50
+ /**
51
+ * Collect existing dependencies + devDependencies + optionalDependencies
52
+ * from a project package.json into installable specs of the form
53
+ * `name@range`. Used by `gjsify install` (no args) to seed the resolver
54
+ * with the project's existing dependency manifest — equivalent to
55
+ * `npm install` reading `package.json`.
56
+ */
57
+ export function projectSpecsFromPackageJson(pkg) {
58
+ const out = [];
59
+ for (const kind of ['dependencies', 'devDependencies', 'optionalDependencies']) {
60
+ const block = pkg[kind];
61
+ if (!block)
62
+ continue;
63
+ for (const [name, range] of Object.entries(block)) {
64
+ // Skip workspace: / link: / file: / portal: specifiers — those
65
+ // are workspace-local references handled by Phase D.3, not by
66
+ // the project-local install path.
67
+ if (typeof range !== 'string')
68
+ continue;
69
+ if (/^(workspace|link|file|portal|git\+|https?):/.test(range))
70
+ continue;
71
+ out.push(`${name}@${range}`);
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+ /**
77
+ * Add or update a dependency entry in `pkg`. If the spec didn't include
78
+ * a range, callers fill in the installed version after resolution and
79
+ * call this again with `installedVersion` set.
80
+ */
81
+ export function addDependencyEntry(pkg, name, range, kind) {
82
+ if (pkg[kind] === undefined) {
83
+ pkg[kind] = {};
84
+ }
85
+ pkg[kind][name] = range;
86
+ }
87
+ /**
88
+ * Default version range when the user didn't pin one: `^x.y.z` from the
89
+ * installed version. Mirrors npm's `save-prefix` default (`^`).
90
+ */
91
+ export function defaultRangeFromVersion(version) {
92
+ return `^${version}`;
93
+ }
94
+ function sortKnownDepFields(pkg) {
95
+ const out = { ...pkg };
96
+ for (const kind of [
97
+ 'dependencies',
98
+ 'devDependencies',
99
+ 'peerDependencies',
100
+ 'optionalDependencies',
101
+ ]) {
102
+ const block = out[kind];
103
+ if (!block)
104
+ continue;
105
+ out[kind] = Object.fromEntries(Object.entries(block).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
106
+ }
107
+ return out;
108
+ }