@gjsify/cli 0.4.35 → 0.4.37
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 +160 -140
- package/lib/bundler-pick.js +43 -4
- package/lib/commands/affected.d.ts +10 -0
- package/lib/commands/affected.js +303 -0
- package/lib/commands/build.js +1 -1
- package/lib/commands/dlx.js +8 -1
- package/lib/commands/index.d.ts +5 -0
- package/lib/commands/index.js +5 -0
- package/lib/commands/install.d.ts +3 -0
- package/lib/commands/install.js +324 -77
- package/lib/commands/login.d.ts +10 -0
- package/lib/commands/login.js +139 -0
- package/lib/commands/logout.d.ts +8 -0
- package/lib/commands/logout.js +63 -0
- package/lib/commands/publish.js +36 -35
- package/lib/commands/tsc.d.ts +6 -0
- package/lib/commands/tsc.js +109 -0
- package/lib/commands/whoami.d.ts +7 -0
- package/lib/commands/whoami.js +118 -0
- package/lib/commands/workspace.d.ts +4 -0
- package/lib/commands/workspace.js +159 -32
- package/lib/index.js +6 -1
- package/lib/utils/auth-npmrc.d.ts +22 -0
- package/lib/utils/auth-npmrc.js +89 -0
- package/lib/utils/install-backend-native.js +236 -89
- package/lib/utils/install-backend.d.ts +19 -0
- package/lib/utils/install-backend.js +1 -0
- package/lib/utils/install-cache-fs.d.ts +22 -0
- package/lib/utils/install-cache-fs.js +64 -0
- package/lib/utils/install-packument-cache.d.ts +18 -0
- package/lib/utils/install-packument-cache.js +98 -0
- package/lib/utils/install-progress.d.ts +26 -0
- package/lib/utils/install-progress.js +109 -0
- package/lib/utils/install-tarball-cache.d.ts +31 -0
- package/lib/utils/install-tarball-cache.js +192 -0
- package/lib/utils/load-npmrc.d.ts +14 -0
- package/lib/utils/load-npmrc.js +61 -0
- package/lib/utils/prompt.d.ts +8 -0
- package/lib/utils/prompt.js +92 -0
- package/lib/utils/publish-diagnose.d.ts +38 -0
- package/lib/utils/publish-diagnose.js +103 -0
- package/lib/utils/resolve-npm-package.d.ts +21 -0
- package/lib/utils/resolve-npm-package.js +121 -0
- package/package.json +29 -18
package/lib/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import yargs from 'yargs';
|
|
6
6
|
import { hideBin } from 'yargs/helpers';
|
|
7
|
-
import { buildCommand as build, testCommand as test, runCommand as run, infoCommand as info, systemCheckCommand as systemCheck, 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, packCommand as pack, publishCommand as publish, selfUpdateCommand as selfUpdate, generateInstallerCommand as generateInstaller, uninstallCommand as uninstall, formatCommand as format, lintCommand as lint, fixCommand as fix, upgradeCommand as upgrade, barrelsCommand as barrels, } from './commands/index.js';
|
|
7
|
+
import { buildCommand as build, testCommand as test, runCommand as run, infoCommand as info, systemCheckCommand as systemCheck, 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, packCommand as pack, publishCommand as publish, whoamiCommand as whoami, loginCommand as login, logoutCommand as logout, selfUpdateCommand as selfUpdate, generateInstallerCommand as generateInstaller, uninstallCommand as uninstall, formatCommand as format, lintCommand as lint, fixCommand as fix, upgradeCommand as upgrade, barrelsCommand as barrels, tscCommand as tsc, affectedCommand as affected, } from './commands/index.js';
|
|
8
8
|
import { APP_NAME } from './constants.js';
|
|
9
9
|
// Detect which runtime is executing the CLI (GJS or Node.js).
|
|
10
10
|
// GJS MUST be checked first because @gjsify/process sets
|
|
@@ -79,6 +79,9 @@ await cli
|
|
|
79
79
|
.command(workspace.command, workspace.description, workspace.builder, workspace.handler)
|
|
80
80
|
.command(pack.command, pack.description, pack.builder, pack.handler)
|
|
81
81
|
.command(publish.command, publish.description, publish.builder, publish.handler)
|
|
82
|
+
.command(whoami.command, whoami.description, whoami.builder, whoami.handler)
|
|
83
|
+
.command(login.command, login.description, login.builder, login.handler)
|
|
84
|
+
.command(logout.command, logout.description, logout.builder, logout.handler)
|
|
82
85
|
.command(selfUpdate.command, selfUpdate.description, selfUpdate.builder, selfUpdate.handler)
|
|
83
86
|
.command(generateInstaller.command, generateInstaller.description, generateInstaller.builder, generateInstaller.handler)
|
|
84
87
|
.command(uninstall.command, uninstall.description, uninstall.builder, uninstall.handler)
|
|
@@ -87,6 +90,8 @@ await cli
|
|
|
87
90
|
.command(lint.command, lint.description, lint.builder, lint.handler)
|
|
88
91
|
.command(fix.command, fix.description, fix.builder, fix.handler)
|
|
89
92
|
.command(barrels.command, barrels.description, barrels.builder, barrels.handler)
|
|
93
|
+
.command(tsc.command, tsc.description, tsc.builder, tsc.handler)
|
|
94
|
+
.command(affected.command, affected.description, affected.builder, affected.handler)
|
|
90
95
|
.demandCommand(1)
|
|
91
96
|
.epilogue(`Running on ${runtimeLabel()}`)
|
|
92
97
|
.help()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** The `.npmrc` file `gjsify login` writes to (matches `load-npmrc.ts`'s userconfig source). */
|
|
2
|
+
export declare function userconfigNpmrcPath(): string;
|
|
3
|
+
/**
|
|
4
|
+
* The npm "nerf-dart" key for a registry: the URL with the protocol removed,
|
|
5
|
+
* keeping the host + path with a trailing slash — `//registry.npmjs.org/`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function nerfDart(registry: string): string;
|
|
8
|
+
/** The full `_authToken` config key for a registry. */
|
|
9
|
+
export declare function authTokenKey(registry: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Upsert `<nerf-dart>:_authToken=<token>` in the userconfig `.npmrc`, preserving
|
|
12
|
+
* all other lines. Returns the file path written.
|
|
13
|
+
*/
|
|
14
|
+
export declare function writeAuthToken(registry: string, token: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Remove the `<nerf-dart>:_authToken=` line for a registry from the userconfig
|
|
17
|
+
* `.npmrc`. Returns `{ path, removed }` — `removed` is false if no line matched.
|
|
18
|
+
*/
|
|
19
|
+
export declare function removeAuthToken(registry: string): {
|
|
20
|
+
path: string;
|
|
21
|
+
removed: boolean;
|
|
22
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Write/remove npm auth tokens in the user's `.npmrc` — the write side of
|
|
2
|
+
// `load-npmrc.ts` (which only reads). Used by `gjsify login` / `gjsify logout`.
|
|
3
|
+
//
|
|
4
|
+
// npm stores the token under a "nerf-dart" key: the registry URL with the
|
|
5
|
+
// protocol stripped, e.g. `//registry.npmjs.org/:_authToken=<token>`. We upsert
|
|
6
|
+
// exactly that line in the userconfig file (`$NPM_CONFIG_USERCONFIG` or
|
|
7
|
+
// `~/.npmrc`), preserving every other line, and chmod it to 0600 (it holds a
|
|
8
|
+
// credential).
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync, chmodSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { join, dirname } from 'node:path';
|
|
12
|
+
/** The `.npmrc` file `gjsify login` writes to (matches `load-npmrc.ts`'s userconfig source). */
|
|
13
|
+
export function userconfigNpmrcPath() {
|
|
14
|
+
return process.env.NPM_CONFIG_USERCONFIG || join(homedir(), '.npmrc');
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The npm "nerf-dart" key for a registry: the URL with the protocol removed,
|
|
18
|
+
* keeping the host + path with a trailing slash — `//registry.npmjs.org/`.
|
|
19
|
+
*/
|
|
20
|
+
export function nerfDart(registry) {
|
|
21
|
+
const u = new URL(registry);
|
|
22
|
+
const path = u.pathname.endsWith('/') ? u.pathname : `${u.pathname}/`;
|
|
23
|
+
return `//${u.host}${path}`;
|
|
24
|
+
}
|
|
25
|
+
/** The full `_authToken` config key for a registry. */
|
|
26
|
+
export function authTokenKey(registry) {
|
|
27
|
+
return `${nerfDart(registry)}:_authToken`;
|
|
28
|
+
}
|
|
29
|
+
function readUserconfig(path) {
|
|
30
|
+
return existsSync(path) ? readFileSync(path, 'utf-8') : '';
|
|
31
|
+
}
|
|
32
|
+
function writeUserconfig(path, content) {
|
|
33
|
+
const dir = dirname(path);
|
|
34
|
+
if (!existsSync(dir))
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
writeFileSync(path, content, 'utf-8');
|
|
37
|
+
// Credential file — restrict to the owner (best-effort; ignored on Windows).
|
|
38
|
+
try {
|
|
39
|
+
chmodSync(path, 0o600);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* non-POSIX filesystem */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Upsert `<nerf-dart>:_authToken=<token>` in the userconfig `.npmrc`, preserving
|
|
47
|
+
* all other lines. Returns the file path written.
|
|
48
|
+
*/
|
|
49
|
+
export function writeAuthToken(registry, token) {
|
|
50
|
+
const path = userconfigNpmrcPath();
|
|
51
|
+
const key = authTokenKey(registry);
|
|
52
|
+
const line = `${key}=${token}`;
|
|
53
|
+
const existing = readUserconfig(path);
|
|
54
|
+
const lines = existing.length ? existing.split('\n') : [];
|
|
55
|
+
let replaced = false;
|
|
56
|
+
const next = lines.map((l) => {
|
|
57
|
+
// Match the key at the start of the line (ignore `${...}` placeholder
|
|
58
|
+
// values too — we always overwrite with the concrete token).
|
|
59
|
+
if (l.replace(/\s+/g, '').startsWith(`${key}=`)) {
|
|
60
|
+
replaced = true;
|
|
61
|
+
return line;
|
|
62
|
+
}
|
|
63
|
+
return l;
|
|
64
|
+
});
|
|
65
|
+
// Strip trailing blank lines so we don't accumulate them across rewrites.
|
|
66
|
+
while (next.length && next[next.length - 1].trim() === '')
|
|
67
|
+
next.pop();
|
|
68
|
+
if (!replaced)
|
|
69
|
+
next.push(line);
|
|
70
|
+
writeUserconfig(path, next.join('\n') + '\n');
|
|
71
|
+
return path;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Remove the `<nerf-dart>:_authToken=` line for a registry from the userconfig
|
|
75
|
+
* `.npmrc`. Returns `{ path, removed }` — `removed` is false if no line matched.
|
|
76
|
+
*/
|
|
77
|
+
export function removeAuthToken(registry) {
|
|
78
|
+
const path = userconfigNpmrcPath();
|
|
79
|
+
const key = authTokenKey(registry);
|
|
80
|
+
const existing = readUserconfig(path);
|
|
81
|
+
if (!existing)
|
|
82
|
+
return { path, removed: false };
|
|
83
|
+
const lines = existing.split('\n');
|
|
84
|
+
const next = lines.filter((l) => !l.replace(/\s+/g, '').startsWith(`${key}=`));
|
|
85
|
+
const removed = next.length !== lines.length;
|
|
86
|
+
if (removed)
|
|
87
|
+
writeUserconfig(path, next.join('\n'));
|
|
88
|
+
return { path, removed };
|
|
89
|
+
}
|
|
@@ -17,9 +17,16 @@ import * as fs from 'node:fs';
|
|
|
17
17
|
import * as path from 'node:path';
|
|
18
18
|
import * as os from 'node:os';
|
|
19
19
|
import { Range, SemVer, maxSatisfying, satisfies } from '@gjsify/semver';
|
|
20
|
-
import { DEFAULT_REGISTRY, fetchPackument, fetchTarball, parseNpmrc, } from '@gjsify/npm-registry';
|
|
20
|
+
import { DEFAULT_REGISTRY, fetchPackument, fetchPackumentConditional, fetchTarball, parseNpmrc, registryFor, } from '@gjsify/npm-registry';
|
|
21
21
|
import { extractTarball } from '@gjsify/tar';
|
|
22
|
-
|
|
22
|
+
import { getCachedTarball, getForeignCachedTarball, putCachedTarball, } from './install-tarball-cache.js';
|
|
23
|
+
import { getCachedPackument, putCachedPackument } from './install-packument-cache.js';
|
|
24
|
+
// 16-wide download pool. Matched to the shared Soup.Session's lifted
|
|
25
|
+
// `max-conns-per-host` (see @gjsify/fetch `getSharedSession`) — a higher pool
|
|
26
|
+
// here only translates into real concurrency because that cap was raised from
|
|
27
|
+
// libsoup's default of 2. npm (`maxsockets` 15) and pnpm (`network-concurrency`
|
|
28
|
+
// 16) use the same order of magnitude. Override with GJSIFY_INSTALL_CONCURRENCY.
|
|
29
|
+
const DEFAULT_CONCURRENCY = Number(process.env.GJSIFY_INSTALL_CONCURRENCY ?? '16') || 16;
|
|
23
30
|
const LOCKFILE_NAME = 'gjsify-lock.json';
|
|
24
31
|
const LOCKFILE_VERSION = 2;
|
|
25
32
|
export async function installPackagesNative(opts) {
|
|
@@ -29,6 +36,7 @@ export async function installPackagesNative(opts) {
|
|
|
29
36
|
fs.mkdirSync(opts.prefix, { recursive: true });
|
|
30
37
|
const npmrc = await loadNpmrc(opts);
|
|
31
38
|
const log = makeLogger(opts.verbose ?? false);
|
|
39
|
+
const progress = opts.progress;
|
|
32
40
|
const lockfilePath = path.join(opts.prefix, LOCKFILE_NAME);
|
|
33
41
|
const existingLock = readLockfile(lockfilePath);
|
|
34
42
|
let nodes;
|
|
@@ -56,14 +64,14 @@ export async function installPackagesNative(opts) {
|
|
|
56
64
|
}
|
|
57
65
|
else {
|
|
58
66
|
log('install: resolving %d top-level spec(s) → %s', opts.specs.length, opts.prefix);
|
|
59
|
-
nodes = await resolveDeps(opts.specs, npmrc, log, opts.overrides, opts.skipDeps);
|
|
67
|
+
nodes = await resolveDeps(opts.specs, npmrc, log, opts.overrides, opts.skipDeps, opts.signal, progress);
|
|
60
68
|
if (opts.lockfile) {
|
|
61
69
|
writeLockfile(lockfilePath, opts.specs, nodes);
|
|
62
70
|
log('install: wrote %s (%d entries)', LOCKFILE_NAME, nodes.length);
|
|
63
71
|
}
|
|
64
72
|
}
|
|
65
73
|
log('install: downloading %d tarball(s)', nodes.length);
|
|
66
|
-
await downloadAndExtractAll(nodes, opts.prefix, npmrc, log);
|
|
74
|
+
await downloadAndExtractAll(nodes, opts.prefix, npmrc, log, opts.signal, progress);
|
|
67
75
|
await linkBins(nodes, opts.prefix, log);
|
|
68
76
|
log('install: done');
|
|
69
77
|
// Surface the top-level requested packages so callers can update
|
|
@@ -119,7 +127,8 @@ function parseSpecName(spec) {
|
|
|
119
127
|
* the root. Each placement returns a `ResolvedNode` whose `installPath`
|
|
120
128
|
* captures where it lives in the tree.
|
|
121
129
|
*/
|
|
122
|
-
async function resolveDeps(specs, npmrc, log, overrides, skipDeps) {
|
|
130
|
+
async function resolveDeps(specs, npmrc, log, overrides, skipDeps, signal, progress) {
|
|
131
|
+
progress?.beginPhase('resolve', specs.length);
|
|
123
132
|
const applyOverride = (name, range) => {
|
|
124
133
|
if (!overrides)
|
|
125
134
|
return range;
|
|
@@ -136,12 +145,7 @@ async function resolveDeps(specs, npmrc, log, overrides, skipDeps) {
|
|
|
136
145
|
const cached = packumentCache.get(name);
|
|
137
146
|
if (cached)
|
|
138
147
|
return cached;
|
|
139
|
-
const fresh =
|
|
140
|
-
npmrc,
|
|
141
|
-
onRetry: ({ attempt, error, delayMs }) => {
|
|
142
|
-
log('packument %s: retry %d after %dms (%s)', name, attempt, delayMs, errMsg(error));
|
|
143
|
-
},
|
|
144
|
-
});
|
|
148
|
+
const fresh = fetchPackumentWithDiskCache(name, npmrc, log, signal);
|
|
145
149
|
packumentCache.set(name, fresh);
|
|
146
150
|
return fresh;
|
|
147
151
|
};
|
|
@@ -155,83 +159,189 @@ async function resolveDeps(specs, npmrc, log, overrides, skipDeps) {
|
|
|
155
159
|
range: applyOverride(s.name, s.range),
|
|
156
160
|
required: true,
|
|
157
161
|
}));
|
|
162
|
+
// Wave-based BFS. Each iteration drains the current queue level, prefetches
|
|
163
|
+
// every not-yet-cached packument in that level IN PARALLEL (bounded), then
|
|
164
|
+
// applies placement SERIALLY in the same FIFO order the single-edge loop
|
|
165
|
+
// used. Because newly-discovered children always append to the end of the
|
|
166
|
+
// queue, "the current queue contents" is exactly one BFS level — so the
|
|
167
|
+
// serial pass visits edges in the identical order, and `decidePlacement`'s
|
|
168
|
+
// order-dependent hoist/nest decisions (hence the lockfile) are byte-for-
|
|
169
|
+
// byte unchanged. Only the network moved: N sequential ~RTT packument
|
|
170
|
+
// fetches per level collapse into one bounded-parallel batch. This is the
|
|
171
|
+
// dominant cold-install cost — at libsoup's old `max-conns-per-host=2` it
|
|
172
|
+
// would have throttled anyway, so it lands alongside the connection-cap
|
|
173
|
+
// lift in `@gjsify/fetch`. `packumentCache` still guarantees ≤1 fetch per
|
|
174
|
+
// unique name across the whole resolve, so prefetching every wave name
|
|
175
|
+
// fetches exactly the same SET as before, just batched.
|
|
158
176
|
while (queue.length > 0) {
|
|
159
|
-
const
|
|
160
|
-
//
|
|
161
|
-
// already
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
177
|
+
const wave = queue.splice(0, queue.length);
|
|
178
|
+
// Prefetch every not-yet-cached packument referenced in this level.
|
|
179
|
+
// Names already in `packumentCache` (resolved in an earlier wave, or a
|
|
180
|
+
// duplicate within this one) are skipped — the de-dup keeps the batch
|
|
181
|
+
// to genuinely-new names.
|
|
182
|
+
const toPrefetch = [];
|
|
183
|
+
const queuedForFetch = new Set();
|
|
184
|
+
for (const edge of wave) {
|
|
185
|
+
if (packumentCache.has(edge.name) || queuedForFetch.has(edge.name))
|
|
186
|
+
continue;
|
|
187
|
+
queuedForFetch.add(edge.name);
|
|
188
|
+
toPrefetch.push(edge.name);
|
|
167
189
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
// Decision: hoist to root, or nest under the requester?
|
|
183
|
-
// - Hoist iff the root has no conflicting placement (i.e. the
|
|
184
|
-
// root slot for `name` is empty OR holds the same version).
|
|
185
|
-
// - Otherwise nest. Top-level specs (from === null) always
|
|
186
|
-
// hoist; the resolver guarantees they never conflict with
|
|
187
|
-
// each other because the input set is checked once.
|
|
188
|
-
const installPath = decidePlacement(edge.from, edge.name, version, root);
|
|
189
|
-
const node = {
|
|
190
|
-
name: edge.name,
|
|
191
|
-
version,
|
|
192
|
-
tarballUrl: v.dist.tarball,
|
|
193
|
-
integrity: v.dist.integrity,
|
|
194
|
-
installPath,
|
|
195
|
-
dependencies: v.dependencies ?? {},
|
|
196
|
-
optionalDependencies: v.optionalDependencies ?? {},
|
|
197
|
-
bin: v.bin,
|
|
198
|
-
};
|
|
199
|
-
byPath.set(installPath, node);
|
|
200
|
-
if (installPath === `node_modules/${edge.name}`) {
|
|
201
|
-
root.set(edge.name, node);
|
|
190
|
+
await prefetchPackuments(toPrefetch, fetchPkg, signal);
|
|
191
|
+
// Serial placement pass — identical decisions and order to the original
|
|
192
|
+
// single-edge loop, but every `fetchPkg` now resolves from the warmed
|
|
193
|
+
// cache instead of blocking on a fresh round-trip.
|
|
194
|
+
for (let wi = 0; wi < wave.length; wi++) {
|
|
195
|
+
const edge = wave[wi];
|
|
196
|
+
// Walk the ancestor chain to see whether a satisfying placement is
|
|
197
|
+
// already visible from the requester's `node_modules` lookup. npm's
|
|
198
|
+
// resolver does this — each level of nesting acts as a fallback.
|
|
199
|
+
const visible = findVisible(edge.from, edge.name, byPath);
|
|
200
|
+
if (visible && satisfiesRange(visible.version, edge.range)) {
|
|
201
|
+
// Compatible placement reachable; reuse, no new install.
|
|
202
|
+
continue;
|
|
202
203
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
});
|
|
204
|
+
// No compatible existing placement. Resolve a fresh version.
|
|
205
|
+
let version = null;
|
|
206
|
+
try {
|
|
207
|
+
const packument = await fetchPkg(edge.name);
|
|
208
|
+
version = pickVersion(packument, edge.range);
|
|
209
|
+
if (!version) {
|
|
210
|
+
if (!edge.required)
|
|
211
|
+
continue;
|
|
212
|
+
throw new Error(`No version of ${edge.name} satisfies ${edge.range}`);
|
|
213
|
+
}
|
|
214
|
+
const v = packument.versions[version];
|
|
215
|
+
if (!v) {
|
|
216
|
+
throw new Error(`Packument for ${edge.name} promised ${version} but no entry exists`);
|
|
212
217
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
218
|
+
// Decision: hoist to root, or nest under the requester?
|
|
219
|
+
// - Hoist iff the root has no conflicting placement (i.e. the
|
|
220
|
+
// root slot for `name` is empty OR holds the same version).
|
|
221
|
+
// - Otherwise nest. Top-level specs (from === null) always
|
|
222
|
+
// hoist; the resolver guarantees they never conflict with
|
|
223
|
+
// each other because the input set is checked once.
|
|
224
|
+
const installPath = decidePlacement(edge.from, edge.name, version, root);
|
|
225
|
+
const node = {
|
|
226
|
+
name: edge.name,
|
|
227
|
+
version,
|
|
228
|
+
tarballUrl: v.dist.tarball,
|
|
229
|
+
integrity: v.dist.integrity,
|
|
230
|
+
installPath,
|
|
231
|
+
dependencies: v.dependencies ?? {},
|
|
232
|
+
optionalDependencies: v.optionalDependencies ?? {},
|
|
233
|
+
bin: v.bin,
|
|
234
|
+
};
|
|
235
|
+
byPath.set(installPath, node);
|
|
236
|
+
if (installPath === `node_modules/${edge.name}`) {
|
|
237
|
+
root.set(edge.name, node);
|
|
238
|
+
}
|
|
239
|
+
log('resolve: %s@%s ← %s (at %s)', edge.name, version, edge.range, installPath);
|
|
240
|
+
// Moving soft-total: resolved so far + edges still to visit in
|
|
241
|
+
// this wave + children already queued for the next wave. Same
|
|
242
|
+
// converging-estimate pattern yarn/pnpm use.
|
|
243
|
+
progress?.update({
|
|
244
|
+
phase: 'resolve',
|
|
245
|
+
current: byPath.size,
|
|
246
|
+
total: byPath.size + (wave.length - wi - 1) + queue.length,
|
|
247
|
+
name: `${edge.name}@${version}`,
|
|
248
|
+
});
|
|
249
|
+
if (!skipDeps) {
|
|
250
|
+
for (const [depName, depRange] of Object.entries(node.dependencies)) {
|
|
251
|
+
queue.push({
|
|
252
|
+
from: installPath,
|
|
253
|
+
name: depName,
|
|
254
|
+
range: applyOverride(depName, depRange),
|
|
255
|
+
required: true,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
for (const [depName, depRange] of Object.entries(node.optionalDependencies)) {
|
|
259
|
+
queue.push({
|
|
260
|
+
from: installPath,
|
|
261
|
+
name: depName,
|
|
262
|
+
range: applyOverride(depName, depRange),
|
|
263
|
+
required: false,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
220
266
|
}
|
|
221
267
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
268
|
+
catch (e) {
|
|
269
|
+
// Optional deps that fail to resolve are skipped — yarn/npm
|
|
270
|
+
// behavior. Required deps re-throw.
|
|
271
|
+
if (!edge.required) {
|
|
272
|
+
log('resolve: optional dep %s@%s skipped (%s)', edge.name, edge.range, e.message);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
throw e;
|
|
229
276
|
}
|
|
230
|
-
throw e;
|
|
231
277
|
}
|
|
232
278
|
}
|
|
279
|
+
progress?.endPhase('resolve');
|
|
233
280
|
return Array.from(byPath.values());
|
|
234
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* Warm `packumentCache` for a batch of package names with bounded parallelism
|
|
284
|
+
* (same `DEFAULT_CONCURRENCY` width as the download pool). Rejections — e.g. a
|
|
285
|
+
* 404 on an optional dep — are swallowed here: the promise stays cached in its
|
|
286
|
+
* rejected state and the caller's per-edge `await fetchPkg(name)` re-surfaces
|
|
287
|
+
* it, so required deps still throw and optional ones are skipped exactly as in
|
|
288
|
+
* the single-edge path. Swallowing also stops one bad optional dependency from
|
|
289
|
+
* aborting the whole wave's batch.
|
|
290
|
+
*/
|
|
291
|
+
/**
|
|
292
|
+
* Fetch a packument with on-disk ETag revalidation. Reads the cached
|
|
293
|
+
* `{ etag, packument }` for `(registry, name)`, sends it as `If-None-Match`,
|
|
294
|
+
* and on a `304 Not Modified` returns the cached body without re-downloading
|
|
295
|
+
* it; on a `200` it stores the fresh body + ETag and returns it. The cache is
|
|
296
|
+
* keyed by the registry the name resolves to, so scope-registry overrides never
|
|
297
|
+
* cross-contaminate. Falls back to a plain fetch when there's no cached entry
|
|
298
|
+
* or the registry doesn't send an ETag (the 304 fast-path simply never fires).
|
|
299
|
+
*/
|
|
300
|
+
async function fetchPackumentWithDiskCache(name, npmrc, log, signal) {
|
|
301
|
+
const registry = registryFor(name, npmrc);
|
|
302
|
+
const disk = getCachedPackument(registry, name);
|
|
303
|
+
const onRetry = ({ attempt, error, delayMs }) => {
|
|
304
|
+
log('packument %s: retry %d after %dms (%s)', name, attempt, delayMs, errMsg(error));
|
|
305
|
+
};
|
|
306
|
+
const result = await fetchPackumentConditional(name, {
|
|
307
|
+
npmrc,
|
|
308
|
+
signal,
|
|
309
|
+
ifNoneMatch: disk?.etag,
|
|
310
|
+
onRetry,
|
|
311
|
+
});
|
|
312
|
+
if (result.status === 'not-modified' && disk) {
|
|
313
|
+
log('packument-cache-hit: %s (304, etag %s)', name, disk.etag);
|
|
314
|
+
return disk.packument;
|
|
315
|
+
}
|
|
316
|
+
if (result.status === 'fresh' && result.packument) {
|
|
317
|
+
if (result.etag)
|
|
318
|
+
putCachedPackument(registry, name, result.etag, result.packument);
|
|
319
|
+
return result.packument;
|
|
320
|
+
}
|
|
321
|
+
// 304 with no cached body to satisfy it (a stale `If-None-Match` raced a
|
|
322
|
+
// cache eviction). Re-fetch unconditionally so we always return a body.
|
|
323
|
+
return fetchPackument(name, { npmrc, signal, onRetry });
|
|
324
|
+
}
|
|
325
|
+
async function prefetchPackuments(names, fetchPkg, signal) {
|
|
326
|
+
if (names.length === 0)
|
|
327
|
+
return;
|
|
328
|
+
let cursor = 0;
|
|
329
|
+
const concurrency = Math.max(1, Math.min(DEFAULT_CONCURRENCY, names.length));
|
|
330
|
+
const worker = async () => {
|
|
331
|
+
while (cursor < names.length) {
|
|
332
|
+
if (signal?.aborted)
|
|
333
|
+
return;
|
|
334
|
+
const name = names[cursor++];
|
|
335
|
+
try {
|
|
336
|
+
await fetchPkg(name);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
/* cached rejection — the serial placement pass handles it */
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
|
344
|
+
}
|
|
235
345
|
/**
|
|
236
346
|
* Walk the ancestor `node_modules` chain from `requesterPath` upward,
|
|
237
347
|
* looking for a placement of `name` that the requester would resolve
|
|
@@ -448,13 +558,24 @@ export function pickVersion(packument, range) {
|
|
|
448
558
|
});
|
|
449
559
|
return maxSatisfying(versions, parsedRange);
|
|
450
560
|
}
|
|
451
|
-
async function downloadAndExtractAll(nodes, prefix, npmrc, log) {
|
|
561
|
+
async function downloadAndExtractAll(nodes, prefix, npmrc, log, signal, progress) {
|
|
452
562
|
// Sort by install-path depth ascending so parents extract before
|
|
453
563
|
// children. Extracting a parent on top of an existing child would
|
|
454
564
|
// wipe out the child.
|
|
455
565
|
const queue = [...nodes].sort((a, b) => depth(a.installPath) - depth(b.installPath) || (a.installPath < b.installPath ? -1 : 1));
|
|
456
566
|
const workers = [];
|
|
457
567
|
const concurrency = Math.max(1, Math.min(DEFAULT_CONCURRENCY, queue.length));
|
|
568
|
+
progress?.beginPhase('download', queue.length);
|
|
569
|
+
let completed = 0;
|
|
570
|
+
const tickProgress = (node) => {
|
|
571
|
+
completed++;
|
|
572
|
+
progress?.update({
|
|
573
|
+
phase: 'download',
|
|
574
|
+
current: completed,
|
|
575
|
+
total: queue.length,
|
|
576
|
+
name: `${node.name}@${node.version}`,
|
|
577
|
+
});
|
|
578
|
+
};
|
|
458
579
|
// Parents (depth 1) are extracted serially first to avoid concurrent
|
|
459
580
|
// `rm -rf` + extract races with their children. Once depth-1 is done,
|
|
460
581
|
// depths >=2 run with full concurrency.
|
|
@@ -466,7 +587,8 @@ async function downloadAndExtractAll(nodes, prefix, npmrc, log) {
|
|
|
466
587
|
const node = queue[cursor++];
|
|
467
588
|
if (!node)
|
|
468
589
|
break;
|
|
469
|
-
await extractOne(node, prefix, npmrc, log);
|
|
590
|
+
await extractOne(node, prefix, npmrc, log, signal);
|
|
591
|
+
tickProgress(node);
|
|
470
592
|
}
|
|
471
593
|
// Concurrent nested pass.
|
|
472
594
|
for (let i = 0; i < concurrency; i++) {
|
|
@@ -478,13 +600,15 @@ async function downloadAndExtractAll(nodes, prefix, npmrc, log) {
|
|
|
478
600
|
const node = queue[idx];
|
|
479
601
|
if (!node)
|
|
480
602
|
return;
|
|
481
|
-
await extractOne(node, prefix, npmrc, log);
|
|
603
|
+
await extractOne(node, prefix, npmrc, log, signal);
|
|
604
|
+
tickProgress(node);
|
|
482
605
|
}
|
|
483
606
|
})());
|
|
484
607
|
}
|
|
485
608
|
await Promise.all(workers);
|
|
609
|
+
progress?.endPhase('download');
|
|
486
610
|
}
|
|
487
|
-
async function extractOne(node, prefix, npmrc, log) {
|
|
611
|
+
async function extractOne(node, prefix, npmrc, log, signal) {
|
|
488
612
|
const dest = path.join(prefix, node.installPath);
|
|
489
613
|
// Defense-in-depth against the workspace-source-wipe data-loss bug:
|
|
490
614
|
// every extractable node MUST land inside a `node_modules/` directory.
|
|
@@ -495,14 +619,37 @@ async function extractOne(node, prefix, npmrc, log) {
|
|
|
495
619
|
// source dir — the realpath check additionally rejects a `dest` that
|
|
496
620
|
// resolves THROUGH a symlink into a directory outside node_modules.
|
|
497
621
|
assertNodeModulesDest(dest, node);
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
}
|
|
622
|
+
// Hit the content-addressable cache before touching the network.
|
|
623
|
+
// Tarballs are immutable per SRI integrity, so a hash hit means the
|
|
624
|
+
// cached bytes are byte-identical to whatever the registry would
|
|
625
|
+
// return — no need to verify by re-download.
|
|
626
|
+
let bytes = getCachedTarball(node.integrity);
|
|
627
|
+
if (bytes) {
|
|
628
|
+
log('cache-hit: %s@%s ← %s', node.name, node.version, node.integrity);
|
|
629
|
+
}
|
|
630
|
+
else if ((bytes = getForeignCachedTarball(node.integrity))) {
|
|
631
|
+
// Second-chance: npm's cacache content store (same SRI key). A user
|
|
632
|
+
// who has run `npm install` before already has the tarball on disk —
|
|
633
|
+
// read it instead of the network. Write it through to OUR store so
|
|
634
|
+
// the next `gjsify install` is a first-class hit even if npm later
|
|
635
|
+
// prunes its cache.
|
|
636
|
+
log('npm-cache-hit: %s@%s ← %s', node.name, node.version, node.integrity);
|
|
637
|
+
putCachedTarball(node.integrity, bytes);
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
log('fetch: %s@%s ← %s (→ %s)', node.name, node.version, node.tarballUrl, node.installPath);
|
|
641
|
+
bytes = await fetchTarball(node.tarballUrl, {
|
|
642
|
+
npmrc,
|
|
643
|
+
signal,
|
|
644
|
+
integrity: node.integrity,
|
|
645
|
+
onRetry: ({ attempt, error, delayMs }) => {
|
|
646
|
+
log('tarball %s@%s: retry %d after %dms (%s)', node.name, node.version, attempt, delayMs, errMsg(error));
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
// Best-effort cache write — failures are swallowed by `putCachedTarball`
|
|
650
|
+
// so a read-only HOME / out-of-disk cache volume doesn't break the install.
|
|
651
|
+
putCachedTarball(node.integrity, bytes);
|
|
652
|
+
}
|
|
506
653
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
507
654
|
fs.mkdirSync(dest, { recursive: true });
|
|
508
655
|
await extractTarball(bytes, dest);
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { ProgressReporter } from './install-progress.js';
|
|
2
|
+
export type { ProgressEvent, ProgressPhase, ProgressReporter } from './install-progress.js';
|
|
3
|
+
export { makeProgressReporter } from './install-progress.js';
|
|
1
4
|
export interface InstallOptions {
|
|
2
5
|
/** Directory to install into (npm `--prefix`). Created by caller. */
|
|
3
6
|
prefix: string;
|
|
@@ -40,6 +43,22 @@ export interface InstallOptions {
|
|
|
40
43
|
* (npm does its own resolution and does not consult this flag).
|
|
41
44
|
*/
|
|
42
45
|
skipDeps?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Native backend only: overall wall-clock budget for the install. When
|
|
48
|
+
* fired, in-flight packument + tarball fetches are aborted via their
|
|
49
|
+
* AbortSignals (the resolver/extractor surface the abort as a normal
|
|
50
|
+
* AbortError), so the user gets a clean failure instead of a silent
|
|
51
|
+
* hang. Zero or undefined disables the overall budget — per-request
|
|
52
|
+
* timeouts in @gjsify/npm-registry still apply.
|
|
53
|
+
*/
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
/**
|
|
56
|
+
* Native backend only: progress reporter for resolve / download / extract /
|
|
57
|
+
* link phases. The CLI auto-creates a TTY-aware reporter by default; pass
|
|
58
|
+
* a custom reporter (or the `NOOP` from `makeProgressReporter({enabled:false})`)
|
|
59
|
+
* to override.
|
|
60
|
+
*/
|
|
61
|
+
progress?: ProgressReporter;
|
|
43
62
|
}
|
|
44
63
|
export interface InstallResult {
|
|
45
64
|
/** Top-level packages that were requested, with the version each
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { spawn } from 'node:child_process';
|
|
16
16
|
import { writeFileSync } from 'node:fs';
|
|
17
17
|
import { join } from 'node:path';
|
|
18
|
+
export { makeProgressReporter } from './install-progress.js';
|
|
18
19
|
const DEFAULT_BACKEND = process.env.GJSIFY_INSTALL_BACKEND ?? 'native';
|
|
19
20
|
export async function installPackages(opts) {
|
|
20
21
|
if (DEFAULT_BACKEND === 'npm') {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve `$XDG_CACHE_HOME/gjsify/<kind>/<layoutVersion>` (falling back to
|
|
3
|
+
* `~/.cache` when `XDG_CACHE_HOME` is unset/empty). `kind` is the cache area
|
|
4
|
+
* (`tarballs`, `metadata`); `layoutVersion` lets one cache evolve its on-disk
|
|
5
|
+
* shape without invalidating its siblings.
|
|
6
|
+
*/
|
|
7
|
+
export declare function gjsifyCacheRoot(kind: string, layoutVersion: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Write `bytes` to `path` atomically: write a `<path>.tmp.<pid>` sibling, then
|
|
10
|
+
* `rename` it into place, so a concurrent reader never observes a half-written
|
|
11
|
+
* file. Creates the parent directory. Best-effort — any failure (read-only /
|
|
12
|
+
* out-of-disk cache volume) is swallowed so a cache hiccup never breaks the
|
|
13
|
+
* install; the caller proceeds with its in-memory copy.
|
|
14
|
+
*/
|
|
15
|
+
export declare function atomicWrite(path: string, bytes: Uint8Array | string): void;
|
|
16
|
+
/**
|
|
17
|
+
* Read a cache file, returning its bytes on a HIT or `null` on a MISS. A
|
|
18
|
+
* missing file, a zero-byte file (an interrupted previous write), or any read
|
|
19
|
+
* error are all treated as a MISS — the file is left untouched so a follow-up
|
|
20
|
+
* writer's atomic rename isn't disturbed.
|
|
21
|
+
*/
|
|
22
|
+
export declare function readCacheFile(path: string): Buffer | null;
|