@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.
Files changed (44) hide show
  1. package/dist/cli.gjs.mjs +160 -140
  2. package/lib/bundler-pick.js +43 -4
  3. package/lib/commands/affected.d.ts +10 -0
  4. package/lib/commands/affected.js +303 -0
  5. package/lib/commands/build.js +1 -1
  6. package/lib/commands/dlx.js +8 -1
  7. package/lib/commands/index.d.ts +5 -0
  8. package/lib/commands/index.js +5 -0
  9. package/lib/commands/install.d.ts +3 -0
  10. package/lib/commands/install.js +324 -77
  11. package/lib/commands/login.d.ts +10 -0
  12. package/lib/commands/login.js +139 -0
  13. package/lib/commands/logout.d.ts +8 -0
  14. package/lib/commands/logout.js +63 -0
  15. package/lib/commands/publish.js +36 -35
  16. package/lib/commands/tsc.d.ts +6 -0
  17. package/lib/commands/tsc.js +109 -0
  18. package/lib/commands/whoami.d.ts +7 -0
  19. package/lib/commands/whoami.js +118 -0
  20. package/lib/commands/workspace.d.ts +4 -0
  21. package/lib/commands/workspace.js +159 -32
  22. package/lib/index.js +6 -1
  23. package/lib/utils/auth-npmrc.d.ts +22 -0
  24. package/lib/utils/auth-npmrc.js +89 -0
  25. package/lib/utils/install-backend-native.js +236 -89
  26. package/lib/utils/install-backend.d.ts +19 -0
  27. package/lib/utils/install-backend.js +1 -0
  28. package/lib/utils/install-cache-fs.d.ts +22 -0
  29. package/lib/utils/install-cache-fs.js +64 -0
  30. package/lib/utils/install-packument-cache.d.ts +18 -0
  31. package/lib/utils/install-packument-cache.js +98 -0
  32. package/lib/utils/install-progress.d.ts +26 -0
  33. package/lib/utils/install-progress.js +109 -0
  34. package/lib/utils/install-tarball-cache.d.ts +31 -0
  35. package/lib/utils/install-tarball-cache.js +192 -0
  36. package/lib/utils/load-npmrc.d.ts +14 -0
  37. package/lib/utils/load-npmrc.js +61 -0
  38. package/lib/utils/prompt.d.ts +8 -0
  39. package/lib/utils/prompt.js +92 -0
  40. package/lib/utils/publish-diagnose.d.ts +38 -0
  41. package/lib/utils/publish-diagnose.js +103 -0
  42. package/lib/utils/resolve-npm-package.d.ts +21 -0
  43. package/lib/utils/resolve-npm-package.js +121 -0
  44. 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
- const DEFAULT_CONCURRENCY = Number(process.env.GJSIFY_INSTALL_CONCURRENCY ?? '8') || 8;
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 = fetchPackument(name, {
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 edge = queue.shift();
160
- // Walk the ancestor chain to see whether a satisfying placement is
161
- // already visible from the requester's `node_modules` lookup. npm's
162
- // resolver does this each level of nesting acts as a fallback.
163
- const visible = findVisible(edge.from, edge.name, byPath);
164
- if (visible && satisfiesRange(visible.version, edge.range)) {
165
- // Compatible placement reachable; reuse, no new install.
166
- continue;
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
- // No compatible existing placement. Resolve a fresh version.
169
- let version = null;
170
- try {
171
- const packument = await fetchPkg(edge.name);
172
- version = pickVersion(packument, edge.range);
173
- if (!version) {
174
- if (!edge.required)
175
- continue;
176
- throw new Error(`No version of ${edge.name} satisfies ${edge.range}`);
177
- }
178
- const v = packument.versions[version];
179
- if (!v) {
180
- throw new Error(`Packument for ${edge.name} promised ${version} but no entry exists`);
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
- log('resolve: %s@%s %s (at %s)', edge.name, version, edge.range, installPath);
204
- if (!skipDeps) {
205
- for (const [depName, depRange] of Object.entries(node.dependencies)) {
206
- queue.push({
207
- from: installPath,
208
- name: depName,
209
- range: applyOverride(depName, depRange),
210
- required: true,
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
- for (const [depName, depRange] of Object.entries(node.optionalDependencies)) {
214
- queue.push({
215
- from: installPath,
216
- name: depName,
217
- range: applyOverride(depName, depRange),
218
- required: false,
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
- catch (e) {
224
- // Optional deps that fail to resolve are skipped — yarn/npm
225
- // behavior. Required deps re-throw.
226
- if (!edge.required) {
227
- log('resolve: optional dep %s@%s skipped (%s)', edge.name, edge.range, e.message);
228
- continue;
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
- log('fetch: %s@%s %s (→ %s)', node.name, node.version, node.tarballUrl, node.installPath);
499
- const bytes = await fetchTarball(node.tarballUrl, {
500
- npmrc,
501
- integrity: node.integrity,
502
- onRetry: ({ attempt, error, delayMs }) => {
503
- log('tarball %s@%s: retry %d after %dms (%s)', node.name, node.version, attempt, delayMs, errMsg(error));
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;