@gjsify/cli 0.4.34 → 0.4.36

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.
@@ -19,6 +19,7 @@ import * as os from 'node:os';
19
19
  import { Range, SemVer, maxSatisfying, satisfies } from '@gjsify/semver';
20
20
  import { DEFAULT_REGISTRY, fetchPackument, fetchTarball, parseNpmrc, } from '@gjsify/npm-registry';
21
21
  import { extractTarball } from '@gjsify/tar';
22
+ import { getCachedTarball, putCachedTarball, } from './install-tarball-cache.js';
22
23
  const DEFAULT_CONCURRENCY = Number(process.env.GJSIFY_INSTALL_CONCURRENCY ?? '8') || 8;
23
24
  const LOCKFILE_NAME = 'gjsify-lock.json';
24
25
  const LOCKFILE_VERSION = 2;
@@ -29,6 +30,7 @@ export async function installPackagesNative(opts) {
29
30
  fs.mkdirSync(opts.prefix, { recursive: true });
30
31
  const npmrc = await loadNpmrc(opts);
31
32
  const log = makeLogger(opts.verbose ?? false);
33
+ const progress = opts.progress;
32
34
  const lockfilePath = path.join(opts.prefix, LOCKFILE_NAME);
33
35
  const existingLock = readLockfile(lockfilePath);
34
36
  let nodes;
@@ -56,14 +58,14 @@ export async function installPackagesNative(opts) {
56
58
  }
57
59
  else {
58
60
  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);
61
+ nodes = await resolveDeps(opts.specs, npmrc, log, opts.overrides, opts.skipDeps, opts.signal, progress);
60
62
  if (opts.lockfile) {
61
63
  writeLockfile(lockfilePath, opts.specs, nodes);
62
64
  log('install: wrote %s (%d entries)', LOCKFILE_NAME, nodes.length);
63
65
  }
64
66
  }
65
67
  log('install: downloading %d tarball(s)', nodes.length);
66
- await downloadAndExtractAll(nodes, opts.prefix, npmrc, log);
68
+ await downloadAndExtractAll(nodes, opts.prefix, npmrc, log, opts.signal, progress);
67
69
  await linkBins(nodes, opts.prefix, log);
68
70
  log('install: done');
69
71
  // Surface the top-level requested packages so callers can update
@@ -119,7 +121,8 @@ function parseSpecName(spec) {
119
121
  * the root. Each placement returns a `ResolvedNode` whose `installPath`
120
122
  * captures where it lives in the tree.
121
123
  */
122
- async function resolveDeps(specs, npmrc, log, overrides, skipDeps) {
124
+ async function resolveDeps(specs, npmrc, log, overrides, skipDeps, signal, progress) {
125
+ progress?.beginPhase('resolve', specs.length);
123
126
  const applyOverride = (name, range) => {
124
127
  if (!overrides)
125
128
  return range;
@@ -138,6 +141,7 @@ async function resolveDeps(specs, npmrc, log, overrides, skipDeps) {
138
141
  return cached;
139
142
  const fresh = fetchPackument(name, {
140
143
  npmrc,
144
+ signal,
141
145
  onRetry: ({ attempt, error, delayMs }) => {
142
146
  log('packument %s: retry %d after %dms (%s)', name, attempt, delayMs, errMsg(error));
143
147
  },
@@ -201,6 +205,16 @@ async function resolveDeps(specs, npmrc, log, overrides, skipDeps) {
201
205
  root.set(edge.name, node);
202
206
  }
203
207
  log('resolve: %s@%s ← %s (at %s)', edge.name, version, edge.range, installPath);
208
+ // Soft-total tracks the dep graph as it grows. We use
209
+ // byPath.size (resolved so far) + queue.length (still to
210
+ // process) as a moving estimate that converges as work
211
+ // finishes — yarn/pnpm use the same pattern.
212
+ progress?.update({
213
+ phase: 'resolve',
214
+ current: byPath.size,
215
+ total: byPath.size + queue.length,
216
+ name: `${edge.name}@${version}`,
217
+ });
204
218
  if (!skipDeps) {
205
219
  for (const [depName, depRange] of Object.entries(node.dependencies)) {
206
220
  queue.push({
@@ -230,6 +244,7 @@ async function resolveDeps(specs, npmrc, log, overrides, skipDeps) {
230
244
  throw e;
231
245
  }
232
246
  }
247
+ progress?.endPhase('resolve');
233
248
  return Array.from(byPath.values());
234
249
  }
235
250
  /**
@@ -448,13 +463,24 @@ export function pickVersion(packument, range) {
448
463
  });
449
464
  return maxSatisfying(versions, parsedRange);
450
465
  }
451
- async function downloadAndExtractAll(nodes, prefix, npmrc, log) {
466
+ async function downloadAndExtractAll(nodes, prefix, npmrc, log, signal, progress) {
452
467
  // Sort by install-path depth ascending so parents extract before
453
468
  // children. Extracting a parent on top of an existing child would
454
469
  // wipe out the child.
455
470
  const queue = [...nodes].sort((a, b) => depth(a.installPath) - depth(b.installPath) || (a.installPath < b.installPath ? -1 : 1));
456
471
  const workers = [];
457
472
  const concurrency = Math.max(1, Math.min(DEFAULT_CONCURRENCY, queue.length));
473
+ progress?.beginPhase('download', queue.length);
474
+ let completed = 0;
475
+ const tickProgress = (node) => {
476
+ completed++;
477
+ progress?.update({
478
+ phase: 'download',
479
+ current: completed,
480
+ total: queue.length,
481
+ name: `${node.name}@${node.version}`,
482
+ });
483
+ };
458
484
  // Parents (depth 1) are extracted serially first to avoid concurrent
459
485
  // `rm -rf` + extract races with their children. Once depth-1 is done,
460
486
  // depths >=2 run with full concurrency.
@@ -466,7 +492,8 @@ async function downloadAndExtractAll(nodes, prefix, npmrc, log) {
466
492
  const node = queue[cursor++];
467
493
  if (!node)
468
494
  break;
469
- await extractOne(node, prefix, npmrc, log);
495
+ await extractOne(node, prefix, npmrc, log, signal);
496
+ tickProgress(node);
470
497
  }
471
498
  // Concurrent nested pass.
472
499
  for (let i = 0; i < concurrency; i++) {
@@ -478,13 +505,15 @@ async function downloadAndExtractAll(nodes, prefix, npmrc, log) {
478
505
  const node = queue[idx];
479
506
  if (!node)
480
507
  return;
481
- await extractOne(node, prefix, npmrc, log);
508
+ await extractOne(node, prefix, npmrc, log, signal);
509
+ tickProgress(node);
482
510
  }
483
511
  })());
484
512
  }
485
513
  await Promise.all(workers);
514
+ progress?.endPhase('download');
486
515
  }
487
- async function extractOne(node, prefix, npmrc, log) {
516
+ async function extractOne(node, prefix, npmrc, log, signal) {
488
517
  const dest = path.join(prefix, node.installPath);
489
518
  // Defense-in-depth against the workspace-source-wipe data-loss bug:
490
519
  // every extractable node MUST land inside a `node_modules/` directory.
@@ -495,14 +524,28 @@ async function extractOne(node, prefix, npmrc, log) {
495
524
  // source dir — the realpath check additionally rejects a `dest` that
496
525
  // resolves THROUGH a symlink into a directory outside node_modules.
497
526
  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
- });
527
+ // Hit the content-addressable cache before touching the network.
528
+ // Tarballs are immutable per SRI integrity, so a hash hit means the
529
+ // cached bytes are byte-identical to whatever the registry would
530
+ // return — no need to verify by re-download.
531
+ let bytes = getCachedTarball(node.integrity);
532
+ if (bytes) {
533
+ log('cache-hit: %s@%s ← %s', node.name, node.version, node.integrity);
534
+ }
535
+ else {
536
+ log('fetch: %s@%s ← %s (→ %s)', node.name, node.version, node.tarballUrl, node.installPath);
537
+ bytes = await fetchTarball(node.tarballUrl, {
538
+ npmrc,
539
+ signal,
540
+ integrity: node.integrity,
541
+ onRetry: ({ attempt, error, delayMs }) => {
542
+ log('tarball %s@%s: retry %d after %dms (%s)', node.name, node.version, attempt, delayMs, errMsg(error));
543
+ },
544
+ });
545
+ // Best-effort cache write — failures are swallowed by `putCachedTarball`
546
+ // so a read-only HOME / out-of-disk cache volume doesn't break the install.
547
+ putCachedTarball(node.integrity, bytes);
548
+ }
506
549
  fs.rmSync(dest, { recursive: true, force: true });
507
550
  fs.mkdirSync(dest, { recursive: true });
508
551
  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,26 @@
1
+ export type ProgressPhase = 'resolve' | 'download' | 'extract' | 'link';
2
+ export interface ProgressEvent {
3
+ phase: ProgressPhase;
4
+ current: number;
5
+ total: number;
6
+ /** Package name being processed, surfaced in the right-hand side of the bar. */
7
+ name?: string;
8
+ }
9
+ export interface ProgressReporter {
10
+ /** Called for each step within a phase. */
11
+ update(event: ProgressEvent): void;
12
+ /** Called once when a phase begins; lets the reporter print a header line. */
13
+ beginPhase(phase: ProgressPhase, total: number): void;
14
+ /** Called once when a phase completes; clears the inline progress bar. */
15
+ endPhase(phase: ProgressPhase): void;
16
+ }
17
+ /**
18
+ * Make a progress reporter that auto-targets `process.stderr`.
19
+ * - `enabled=false` → silent.
20
+ * - stderr is not a TTY → fall back to one line per phase (begin + end).
21
+ * - stderr is a TTY → live single-line progress bar (\r-updated, 30fps).
22
+ */
23
+ export declare function makeProgressReporter(opts?: {
24
+ enabled?: boolean;
25
+ stream?: NodeJS.WriteStream;
26
+ }): ProgressReporter;
@@ -0,0 +1,109 @@
1
+ // Progress reporter for `gjsify install` + `gjsify dlx`.
2
+ //
3
+ // Auto-enables on TTY stderr. Renders a single-line progress bar that gets
4
+ // overwritten via `\r`, mirroring what `yarn install` / `pnpm install` show:
5
+ //
6
+ // resolving [████████░░░░░░░░] 234/500 typescript@^6.0.3
7
+ //
8
+ // Falls back to a chatty mode (one line per completed step) when stderr is
9
+ // not a TTY, e.g. when piped into a log file or running on CI.
10
+ //
11
+ // Caller plumbing — the install backend calls `report({phase, current, total,
12
+ // name})` after every resolve/download/extract step; the renderer rate-limits
13
+ // to ~30fps so a tight inner loop doesn't drown the terminal.
14
+ const PHASE_LABEL = {
15
+ resolve: 'resolving',
16
+ download: 'downloading',
17
+ extract: 'extracting',
18
+ link: 'linking bins',
19
+ };
20
+ const NOOP_REPORTER = {
21
+ update() { },
22
+ beginPhase() { },
23
+ endPhase() { },
24
+ };
25
+ /**
26
+ * Make a progress reporter that auto-targets `process.stderr`.
27
+ * - `enabled=false` → silent.
28
+ * - stderr is not a TTY → fall back to one line per phase (begin + end).
29
+ * - stderr is a TTY → live single-line progress bar (\r-updated, 30fps).
30
+ */
31
+ export function makeProgressReporter(opts = {}) {
32
+ if (opts.enabled === false)
33
+ return NOOP_REPORTER;
34
+ const stream = opts.stream ?? process.stderr;
35
+ const isTty = Boolean(stream.isTTY);
36
+ const enabled = opts.enabled ?? true;
37
+ if (!enabled)
38
+ return NOOP_REPORTER;
39
+ return isTty ? makeTtyReporter(stream) : makePlainReporter(stream);
40
+ }
41
+ // ──── TTY reporter — single-line, \r-updated, 30fps rate-limit ────────────
42
+ function makeTtyReporter(stream) {
43
+ let lastRender = 0;
44
+ let lastLine = '';
45
+ const FRAME_MS = 33; // ~30fps
46
+ function render(line, force = false) {
47
+ const now = Date.now();
48
+ if (!force && now - lastRender < FRAME_MS && line === lastLine)
49
+ return;
50
+ lastRender = now;
51
+ lastLine = line;
52
+ // Clear current line, write new content. \x1b[2K = erase entire line.
53
+ stream.write('\r\x1b[2K' + line);
54
+ }
55
+ function clearLine() {
56
+ stream.write('\r\x1b[2K');
57
+ lastLine = '';
58
+ }
59
+ return {
60
+ beginPhase(phase, total) {
61
+ // First frame so the user sees immediate feedback.
62
+ render(formatBar(phase, 0, total, undefined, stream.columns ?? 80), true);
63
+ },
64
+ update(ev) {
65
+ render(formatBar(ev.phase, ev.current, ev.total, ev.name, stream.columns ?? 80));
66
+ },
67
+ endPhase(phase) {
68
+ // Replace the live bar with a completion line so the user sees
69
+ // a definitive "done" after the spinner stops.
70
+ const label = PHASE_LABEL[phase];
71
+ clearLine();
72
+ stream.write(`gjsify install: ${label} done\n`);
73
+ },
74
+ };
75
+ }
76
+ // ──── Plain reporter — one line per phase begin + end (non-TTY) ────────────
77
+ function makePlainReporter(stream) {
78
+ return {
79
+ beginPhase(phase, total) {
80
+ stream.write(`gjsify install: ${PHASE_LABEL[phase]} ${total} package(s)\n`);
81
+ },
82
+ update() {
83
+ // Per-package output would flood logs; skip on non-TTY.
84
+ },
85
+ endPhase(phase) {
86
+ stream.write(`gjsify install: ${PHASE_LABEL[phase]} done\n`);
87
+ },
88
+ };
89
+ }
90
+ // ──── Bar formatter ────────────────────────────────────────────────────────
91
+ function formatBar(phase, current, total, name, columns) {
92
+ const label = PHASE_LABEL[phase];
93
+ const ratio = total > 0 ? Math.min(1, current / total) : 0;
94
+ const pct = `${current}/${total}`;
95
+ // Reserve: "gjsify install: " (16) + label (max ~12) + " [" (2) + "] " (2) + pct (~8) + " " + name
96
+ const prefix = `gjsify install: ${label} `;
97
+ const suffix = ` ${pct}` + (name ? ` ${name}` : '');
98
+ const barWidth = Math.max(8, Math.min(40, columns - prefix.length - suffix.length - 4));
99
+ const filled = Math.round(ratio * barWidth);
100
+ const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
101
+ let line = `${prefix}[${bar}]${suffix}`;
102
+ if (line.length > columns) {
103
+ // Truncate name if the terminal is narrow.
104
+ const overflow = line.length - columns + 1;
105
+ const truncatedName = name && name.length > overflow ? name.slice(0, name.length - overflow - 1) + '…' : '';
106
+ line = `${prefix}[${bar}] ${pct}${truncatedName ? ' ' + truncatedName : ''}`;
107
+ }
108
+ return line;
109
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Read a cached tarball by SRI integrity. Returns the raw tarball bytes if
3
+ * the cache has a HIT, `null` otherwise. A read failure (e.g. partial
4
+ * write from an interrupted previous run) is treated as a MISS — the file
5
+ * is left untouched so we don't trip a follow-up writer's atomic rename.
6
+ */
7
+ export declare function getCachedTarball(integrity: string | undefined): Uint8Array | null;
8
+ /**
9
+ * Persist a tarball to the cache. Writes to a `<path>.tmp.<pid>` sibling
10
+ * then atomically renames into place so concurrent installs can never
11
+ * observe a half-written entry. No-op when:
12
+ * - `integrity` is missing / malformed (no cache key)
13
+ * - the destination already exists (idempotent — content-addressed)
14
+ * - the write fails (e.g. cache root is read-only) — silently degrade
15
+ * so a cache-volume issue doesn't break the install itself.
16
+ */
17
+ export declare function putCachedTarball(integrity: string | undefined, bytes: Uint8Array): void;
18
+ /**
19
+ * Best-effort cache stats for diagnostics. Returns `null` when the cache
20
+ * root doesn't exist yet (first run).
21
+ */
22
+ export declare function cacheRootForLogging(): string;
23
+ export declare function isCacheHit(integrity: string | undefined): boolean;
@@ -0,0 +1,140 @@
1
+ // Content-addressable tarball cache for `gjsify install`.
2
+ //
3
+ // Mirrors pnpm / yarn-berry's store layout: each tarball is stored once on
4
+ // disk keyed by its SRI integrity hash (`sha512-…`). When the resolver hits
5
+ // a node whose integrity is already cached, `fetchTarball` is skipped and
6
+ // we read the bytes off the local filesystem instead.
7
+ //
8
+ // Why this matters: a cold install on this monorepo (200+ workspaces,
9
+ // 600+ transitive deps) spends ~20 minutes at 80% CPU. Most of that time is
10
+ // re-downloading + re-extracting the SAME tarballs that just came down in
11
+ // the previous run because there is no cache between runs. With this cache,
12
+ // the second `gjsify install` on the same repo skips every tarball fetch
13
+ // and just goes straight to extract → drops well below 1 minute in practice.
14
+ //
15
+ // Layout (matches the pnpm pattern so we stay forward-compatible with
16
+ // `~/.cache/gjsify/store` if we eventually share a store across projects):
17
+ //
18
+ // $XDG_CACHE_HOME/gjsify/tarballs/v1/<hex-prefix-2>/<full-hex>.tgz
19
+ //
20
+ // The 2-byte prefix is a directory-sharding step so the leaf directory
21
+ // never gets pathologically large — same pattern git's loose objects use.
22
+ // `v1` is a layout version so we can change the file shape (e.g. add a
23
+ // manifest sidecar) without invalidating the world.
24
+ //
25
+ // SRI integrity input → cache key:
26
+ //
27
+ // "sha512-AbCd…==" → ("sha512", "ab/cdefg….tgz")
28
+ //
29
+ // We hex-encode the base64 SRI digest. Two integrities that produce the
30
+ // same hex bytes share the same cache entry; that is the invariant pnpm
31
+ // relies on too. Tarballs without an integrity hash (older registries)
32
+ // fall through to a no-op cache and download every time.
33
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
34
+ import { homedir } from 'node:os';
35
+ import { join } from 'node:path';
36
+ const CACHE_LAYOUT_VERSION = 'v1';
37
+ /**
38
+ * Resolve the root of the tarball cache. Mirrors the dlx cache's
39
+ * XDG-honouring lookup so users with a custom `XDG_CACHE_HOME` get a
40
+ * single coherent cache root.
41
+ */
42
+ function cacheRoot() {
43
+ const xdg = process.env.XDG_CACHE_HOME;
44
+ const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.cache');
45
+ return join(base, 'gjsify', 'tarballs', CACHE_LAYOUT_VERSION);
46
+ }
47
+ /**
48
+ * Convert an SRI integrity string (`sha512-AbCd…=`) into a cache file path.
49
+ * Returns `null` for unsupported / malformed integrity values so the caller
50
+ * can fall back to a fresh download.
51
+ */
52
+ function pathFor(integrity) {
53
+ if (!integrity)
54
+ return null;
55
+ const dashIdx = integrity.indexOf('-');
56
+ if (dashIdx <= 0 || dashIdx === integrity.length - 1)
57
+ return null;
58
+ const algo = integrity.slice(0, dashIdx);
59
+ const b64 = integrity.slice(dashIdx + 1).replace(/=+$/, '');
60
+ // Decode base64 → hex; throws on malformed input which we swallow.
61
+ let hex;
62
+ try {
63
+ hex = Buffer.from(b64, 'base64').toString('hex');
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ if (hex.length < 4)
69
+ return null;
70
+ const shard = hex.slice(0, 2);
71
+ return join(cacheRoot(), algo, shard, `${hex}.tgz`);
72
+ }
73
+ /**
74
+ * Read a cached tarball by SRI integrity. Returns the raw tarball bytes if
75
+ * the cache has a HIT, `null` otherwise. A read failure (e.g. partial
76
+ * write from an interrupted previous run) is treated as a MISS — the file
77
+ * is left untouched so we don't trip a follow-up writer's atomic rename.
78
+ */
79
+ export function getCachedTarball(integrity) {
80
+ const path = pathFor(integrity);
81
+ if (!path)
82
+ return null;
83
+ if (!existsSync(path))
84
+ return null;
85
+ try {
86
+ const buf = readFileSync(path);
87
+ // Sanity: a zero-byte file is a previous-write failure; treat as MISS.
88
+ if (buf.length === 0)
89
+ return null;
90
+ return buf;
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ /**
97
+ * Persist a tarball to the cache. Writes to a `<path>.tmp.<pid>` sibling
98
+ * then atomically renames into place so concurrent installs can never
99
+ * observe a half-written entry. No-op when:
100
+ * - `integrity` is missing / malformed (no cache key)
101
+ * - the destination already exists (idempotent — content-addressed)
102
+ * - the write fails (e.g. cache root is read-only) — silently degrade
103
+ * so a cache-volume issue doesn't break the install itself.
104
+ */
105
+ export function putCachedTarball(integrity, bytes) {
106
+ const path = pathFor(integrity);
107
+ if (!path)
108
+ return;
109
+ if (existsSync(path))
110
+ return;
111
+ try {
112
+ mkdirSync(join(path, '..'), { recursive: true });
113
+ const tmp = `${path}.tmp.${process.pid}`;
114
+ writeFileSync(tmp, bytes);
115
+ renameSync(tmp, path);
116
+ }
117
+ catch {
118
+ // Cache write failure is non-fatal — the install proceeds with the
119
+ // in-memory bytes; we just won't get a hit on the next run.
120
+ }
121
+ }
122
+ /**
123
+ * Best-effort cache stats for diagnostics. Returns `null` when the cache
124
+ * root doesn't exist yet (first run).
125
+ */
126
+ export function cacheRootForLogging() {
127
+ return cacheRoot();
128
+ }
129
+ export function isCacheHit(integrity) {
130
+ const path = pathFor(integrity);
131
+ if (!path)
132
+ return false;
133
+ try {
134
+ const s = statSync(path);
135
+ return s.isFile() && s.size > 0;
136
+ }
137
+ catch {
138
+ return false;
139
+ }
140
+ }
@@ -0,0 +1,14 @@
1
+ import { type NpmrcConfig } from '@gjsify/npm-registry';
2
+ export declare function loadNpmrc(cwd: string): Promise<NpmrcConfig>;
3
+ /**
4
+ * True iff the parsed npmrc contains *any* `_authToken` entry. Cheap helper
5
+ * for commands that want a clear "no token configured" error message
6
+ * instead of a generic 401/empty response.
7
+ */
8
+ export declare function hasAnyAuthToken(npmrc: NpmrcConfig): boolean;
9
+ /**
10
+ * True iff the parsed npmrc has *any* credential (bearer token or basic
11
+ * auth). Used by `gjsify whoami` to differentiate "no token configured"
12
+ * from "token configured but rejected by the registry".
13
+ */
14
+ export declare function hasAnyCredential(npmrc: NpmrcConfig): boolean;
@@ -0,0 +1,61 @@
1
+ // Shared npmrc loader for `gjsify publish` / `gjsify whoami` / future
2
+ // auth-aware commands. Mirrors npm CLI's resolution order so a maintainer's
3
+ // `~/.npmrc` token AND a CI-injected `NPM_CONFIG_USERCONFIG` token both
4
+ // reach the registry call.
5
+ //
6
+ // Resolution order (lowest → highest precedence — last write wins on key
7
+ // collisions because the sources are concatenated and re-parsed):
8
+ // 1. globalconfig: /etc/npmrc (system; intentionally NOT read here —
9
+ // gjsify's user-facing commands operate on per-user
10
+ // credentials, matching `npm whoami`'s behavior)
11
+ // 2. userconfig: $NPM_CONFIG_USERCONFIG (overrides ~/.npmrc)
12
+ // or ~/.npmrc (default)
13
+ // 3. projectconfig: ./.npmrc (closest workspace)
14
+ //
15
+ // `actions/setup-node` writes the auth-token npmrc to $RUNNER_TEMP/.npmrc
16
+ // and exports NPM_CONFIG_USERCONFIG pointing at it — it does NOT touch
17
+ // ~/.npmrc. Honor the env var so CI authentication works end-to-end.
18
+ //
19
+ // `${VAR}` placeholders are expanded inline (npm CLI's expand-on-read
20
+ // behavior). The auth-token npmrc from actions/setup-node ships
21
+ // `_authToken=${NODE_AUTH_TOKEN}` as a literal placeholder; the env var is
22
+ // set on the publish step.
23
+ import { existsSync, readFileSync } from 'node:fs';
24
+ import { homedir } from 'node:os';
25
+ import { join } from 'node:path';
26
+ import { parseNpmrc } from '@gjsify/npm-registry';
27
+ export async function loadNpmrc(cwd) {
28
+ const sources = [];
29
+ const projectNpmrc = join(cwd, '.npmrc');
30
+ if (existsSync(projectNpmrc))
31
+ sources.push(readFileSync(projectNpmrc, 'utf-8'));
32
+ const userConfig = process.env.NPM_CONFIG_USERCONFIG;
33
+ if (userConfig && existsSync(userConfig)) {
34
+ sources.push(readFileSync(userConfig, 'utf-8'));
35
+ }
36
+ else {
37
+ const homeNpmrc = join(homedir(), '.npmrc');
38
+ if (existsSync(homeNpmrc))
39
+ sources.push(readFileSync(homeNpmrc, 'utf-8'));
40
+ }
41
+ const merged = sources
42
+ .join('\n')
43
+ .replace(/\$\{([A-Z_][A-Z0-9_]*)\}/gi, (_, name) => process.env[name] ?? '');
44
+ return parseNpmrc(merged);
45
+ }
46
+ /**
47
+ * True iff the parsed npmrc contains *any* `_authToken` entry. Cheap helper
48
+ * for commands that want a clear "no token configured" error message
49
+ * instead of a generic 401/empty response.
50
+ */
51
+ export function hasAnyAuthToken(npmrc) {
52
+ return Object.keys(npmrc.authTokens).length > 0;
53
+ }
54
+ /**
55
+ * True iff the parsed npmrc has *any* credential (bearer token or basic
56
+ * auth). Used by `gjsify whoami` to differentiate "no token configured"
57
+ * from "token configured but rejected by the registry".
58
+ */
59
+ export function hasAnyCredential(npmrc) {
60
+ return hasAnyAuthToken(npmrc) || Object.keys(npmrc.basicAuth).length > 0;
61
+ }
@@ -0,0 +1,38 @@
1
+ import { type NpmrcConfig } from '@gjsify/npm-registry';
2
+ export type Diagnose404Reason = 'dead-token' | 'live-token-404' | 'unknown';
3
+ export interface Diagnose404Result {
4
+ /** Discriminant — drives the JSON shape + exit-code path in the caller. */
5
+ reason: Diagnose404Reason;
6
+ /** Username from `/-/whoami` when reason === 'live-token-404'. */
7
+ username?: string;
8
+ /** Multi-line, human-friendly hint ready for stderr emission. */
9
+ message: string;
10
+ }
11
+ interface Diagnose404Input {
12
+ /** Full package name including scope, e.g. `@gjsify/abort-controller`. */
13
+ packageName: string;
14
+ /** Pinned version we tried to publish. */
15
+ version: string;
16
+ /** Registry URL (with or without trailing slash). */
17
+ registry: string;
18
+ /** Parsed .npmrc — used to derive Authorization on the whoami probe. */
19
+ npmrc: NpmrcConfig | undefined;
20
+ }
21
+ /**
22
+ * Probe `/-/whoami` to disambiguate the 404 cause. Best-effort: any thrown
23
+ * error / non-2xx falls through to `reason: 'unknown'` so the caller's
24
+ * generic error path runs untouched.
25
+ *
26
+ * Pure async I/O — no side effects, no process.exit, no console writes.
27
+ * The caller owns presentation (stderr vs stdout, plain vs JSON).
28
+ */
29
+ export declare function diagnose404(input: Diagnose404Input): Promise<Diagnose404Result>;
30
+ /**
31
+ * Heuristic: is the 404 body the "dead-token-or-missing-package" shape?
32
+ * npm's PUT returns plain text `Not Found` (sometimes empty body). We trigger
33
+ * the diagnostic for both, plus for the JSON `{"error":"Not Found"}` shape
34
+ * just in case. Other 404 bodies (e.g. with a structured npm error code)
35
+ * keep the generic error path.
36
+ */
37
+ export declare function is404DiagnosticCandidate(body: string): boolean;
38
+ export {};