@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.
- package/dist/cli.gjs.mjs +798 -0
- package/lib/actions/build.js +4 -17
- package/lib/bundler-pick.d.ts +79 -0
- package/lib/bundler-pick.js +428 -0
- package/lib/commands/foreach.d.ts +16 -0
- package/lib/commands/foreach.js +268 -0
- package/lib/commands/index.d.ts +2 -0
- package/lib/commands/index.js +2 -0
- package/lib/commands/install.d.ts +1 -0
- package/lib/commands/install.js +222 -26
- package/lib/commands/run.d.ts +1 -1
- package/lib/commands/run.js +133 -20
- package/lib/commands/workspace.d.ts +8 -0
- package/lib/commands/workspace.js +69 -0
- package/lib/config.js +12 -1
- package/lib/index.js +11 -3
- package/lib/types/config-data.d.ts +10 -1
- package/lib/utils/install-backend-native.d.ts +5 -1
- package/lib/utils/install-backend-native.js +88 -11
- package/lib/utils/install-backend.d.ts +11 -1
- package/lib/utils/install-backend.js +4 -2
- package/lib/utils/pkg-json-edit.d.ts +47 -0
- package/lib/utils/pkg-json-edit.js +108 -0
- package/package.json +36 -12
- package/src/actions/build.ts +0 -431
- package/src/actions/index.ts +0 -1
- package/src/commands/build.ts +0 -146
- package/src/commands/check.ts +0 -87
- package/src/commands/create.ts +0 -63
- package/src/commands/dlx.ts +0 -195
- package/src/commands/flatpak/build.ts +0 -225
- package/src/commands/flatpak/ci.ts +0 -173
- package/src/commands/flatpak/deps.ts +0 -120
- package/src/commands/flatpak/index.ts +0 -53
- package/src/commands/flatpak/init.ts +0 -191
- package/src/commands/flatpak/utils.ts +0 -76
- package/src/commands/gettext.ts +0 -258
- package/src/commands/gresource.ts +0 -97
- package/src/commands/gsettings.ts +0 -87
- package/src/commands/index.ts +0 -12
- package/src/commands/info.ts +0 -70
- package/src/commands/install.ts +0 -195
- package/src/commands/run.ts +0 -33
- package/src/commands/showcase.ts +0 -149
- package/src/config.ts +0 -304
- package/src/constants.ts +0 -1
- package/src/index.ts +0 -37
- package/src/types/cli-build-options.ts +0 -100
- package/src/types/command.ts +0 -10
- package/src/types/config-data-library.ts +0 -5
- package/src/types/config-data-typescript.ts +0 -6
- package/src/types/config-data.ts +0 -225
- package/src/types/cosmiconfig-result.ts +0 -5
- package/src/types/index.ts +0 -6
- package/src/utils/check-system-deps.ts +0 -480
- package/src/utils/detect-native-packages.ts +0 -153
- package/src/utils/discover-showcases.ts +0 -75
- package/src/utils/dlx-cache.ts +0 -135
- package/src/utils/install-backend-native.ts +0 -363
- package/src/utils/install-backend.ts +0 -88
- package/src/utils/install-global.ts +0 -182
- package/src/utils/normalize-bundler-options.ts +0 -129
- package/src/utils/parse-spec.ts +0 -48
- package/src/utils/resolve-gjs-entry.ts +0 -96
- package/src/utils/resolve-plugin-by-name.ts +0 -106
- package/src/utils/run-gjs.ts +0 -90
- package/tsconfig.json +0 -16
package/lib/commands/run.js
CHANGED
|
@@ -1,26 +1,139 @@
|
|
|
1
|
-
|
|
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 <
|
|
5
|
-
description: 'Run a GJS bundle,
|
|
6
|
-
builder: (yargs) =>
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
38
|
+
const target = args.target;
|
|
23
39
|
const extraArgs = args.args ?? [];
|
|
24
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
-
/**
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
21
|
+
await installViaNpm(opts);
|
|
22
|
+
return { installed: [] };
|
|
22
23
|
}
|
|
23
24
|
const { installPackagesNative } = await import('./install-backend-native.js');
|
|
24
|
-
|
|
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
|
+
}
|