@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
@@ -0,0 +1,64 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Shared filesystem primitives for `gjsify install`'s on-disk caches.
3
+ //
4
+ // Both the content-addressable tarball cache (install-tarball-cache.ts) and the
5
+ // packument metadata cache (install-packument-cache.ts) need the same three
6
+ // things: an `XDG_CACHE_HOME`-honouring cache root, an atomic write (so a
7
+ // concurrent install never observes a half-written entry), and a read that
8
+ // treats a missing / zero-byte / unreadable file as a MISS. Centralising them
9
+ // here keeps the two caches byte-for-byte consistent.
10
+ //
11
+ // Out of scope: the dlx cache (`dlx-cache.ts`) has a different layout
12
+ // (`gjsify/dlx`, sha256 + symlink-swap) and its own TTL/prepare-dir concerns —
13
+ // it is a precedent for the XDG helper, not a reuse target.
14
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ /**
18
+ * Resolve `$XDG_CACHE_HOME/gjsify/<kind>/<layoutVersion>` (falling back to
19
+ * `~/.cache` when `XDG_CACHE_HOME` is unset/empty). `kind` is the cache area
20
+ * (`tarballs`, `metadata`); `layoutVersion` lets one cache evolve its on-disk
21
+ * shape without invalidating its siblings.
22
+ */
23
+ export function gjsifyCacheRoot(kind, layoutVersion) {
24
+ const xdg = process.env.XDG_CACHE_HOME;
25
+ const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.cache');
26
+ return join(base, 'gjsify', kind, layoutVersion);
27
+ }
28
+ /**
29
+ * Write `bytes` to `path` atomically: write a `<path>.tmp.<pid>` sibling, then
30
+ * `rename` it into place, so a concurrent reader never observes a half-written
31
+ * file. Creates the parent directory. Best-effort — any failure (read-only /
32
+ * out-of-disk cache volume) is swallowed so a cache hiccup never breaks the
33
+ * install; the caller proceeds with its in-memory copy.
34
+ */
35
+ export function atomicWrite(path, bytes) {
36
+ try {
37
+ mkdirSync(join(path, '..'), { recursive: true });
38
+ const tmp = `${path}.tmp.${process.pid}`;
39
+ writeFileSync(tmp, bytes);
40
+ renameSync(tmp, path);
41
+ }
42
+ catch {
43
+ /* best-effort — a cache-write failure must not break the install */
44
+ }
45
+ }
46
+ /**
47
+ * Read a cache file, returning its bytes on a HIT or `null` on a MISS. A
48
+ * missing file, a zero-byte file (an interrupted previous write), or any read
49
+ * error are all treated as a MISS — the file is left untouched so a follow-up
50
+ * writer's atomic rename isn't disturbed.
51
+ */
52
+ export function readCacheFile(path) {
53
+ if (!existsSync(path))
54
+ return null;
55
+ try {
56
+ const buf = readFileSync(path);
57
+ if (buf.length === 0)
58
+ return null;
59
+ return buf;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
@@ -0,0 +1,18 @@
1
+ import type { Packument } from '@gjsify/npm-registry';
2
+ export interface CachedPackument {
3
+ etag: string;
4
+ packument: Packument;
5
+ }
6
+ /**
7
+ * Read a cached packument + its ETag for `(registry, name)`. Returns `null` on
8
+ * a miss, when the cache is disabled, or when the entry is unreadable/corrupt
9
+ * (treated as a miss — the caller just re-fetches).
10
+ */
11
+ export declare function getCachedPackument(registry: string, name: string): CachedPackument | null;
12
+ /**
13
+ * Persist a packument + ETag for `(registry, name)`. Writes to a `<path>.tmp.<pid>`
14
+ * sibling then atomically renames so a concurrent reader never sees a partial
15
+ * write. No-op when the cache is disabled, the ETag is empty, or the write
16
+ * fails (read-only / out-of-disk cache volume must not break the install).
17
+ */
18
+ export declare function putCachedPackument(registry: string, name: string, etag: string, packument: Packument): void;
@@ -0,0 +1,98 @@
1
+ // On-disk packument metadata cache for `gjsify install`.
2
+ //
3
+ // Sibling to the content-addressable tarball cache (install-tarball-cache.ts).
4
+ // Stores each package's abbreviated packument plus the registry `ETag` it came
5
+ // with, so a re-resolve (lockfile miss — e.g. a dependency range changed) can
6
+ // send a conditional `If-None-Match` and turn the unchanged majority into empty
7
+ // `304 Not Modified` responses — a real bandwidth + parse saving on repeated /
8
+ // dep-churn installs, on top of the ~4× gzip transfer for the changed minority
9
+ // (packuments are fetched gzip-compressed and decoded by the fetch layer; see
10
+ // `@gjsify/npm-registry` `fetchPackumentConditional`).
11
+ //
12
+ // Mirrors pnpm's metadata cache + npm's make-fetch-happen HTTP-cache layer.
13
+ //
14
+ // Layout:
15
+ //
16
+ // $XDG_CACHE_HOME/gjsify/metadata/v1/<shard>/<encoded-registry>|<encoded-name>.json
17
+ //
18
+ // The cache is keyed by (registry, name) — NOT name alone — so switching the
19
+ // registry for a scope can never serve a packument from the wrong source on a
20
+ // coincidental ETag match. `<shard>` is a 2-hex FNV-1a digest of the key, a
21
+ // directory-fan-out step so the leaf dir never grows unbounded (same rationale
22
+ // as the tarball cache's hex sharding). `v1` is a layout version.
23
+ //
24
+ // Disabled with `GJSIFY_PACKUMENT_CACHE=0` (or `false`). Honours
25
+ // `XDG_CACHE_HOME` like the tarball + dlx caches.
26
+ import { join } from 'node:path';
27
+ import { atomicWrite, gjsifyCacheRoot, readCacheFile } from './install-cache-fs.js';
28
+ const CACHE_LAYOUT_VERSION = 'v1';
29
+ /** `true` unless `GJSIFY_PACKUMENT_CACHE` is `0` / `false` / empty. */
30
+ function isEnabled() {
31
+ const flag = process.env.GJSIFY_PACKUMENT_CACHE;
32
+ if (flag === undefined)
33
+ return true;
34
+ const trimmed = flag.trim();
35
+ return !(trimmed === '0' || trimmed === 'false' || trimmed === '');
36
+ }
37
+ /** Root of the packument cache: `$XDG_CACHE_HOME/gjsify/metadata/v1`. */
38
+ function cacheRoot() {
39
+ return gjsifyCacheRoot('metadata', CACHE_LAYOUT_VERSION);
40
+ }
41
+ /** FNV-1a 32-bit → 2 hex chars. Directory-fan-out only; not security-sensitive. */
42
+ function shardFor(key) {
43
+ let h = 0x811c9dc5;
44
+ for (let i = 0; i < key.length; i++) {
45
+ h ^= key.charCodeAt(i);
46
+ h = Math.imul(h, 0x01000193);
47
+ }
48
+ return ((h >>> 0) & 0xff).toString(16).padStart(2, '0');
49
+ }
50
+ /** Filesystem path for a (registry, name) pair, or `null` when disabled. */
51
+ function pathFor(registry, name) {
52
+ if (!isEnabled())
53
+ return null;
54
+ const key = `${registry}|${name}`;
55
+ // encodeURIComponent makes both halves filesystem-safe (`/` → `%2F`, etc.)
56
+ // while staying a stable, reversible, single path segment.
57
+ const file = `${encodeURIComponent(registry)}|${encodeURIComponent(name)}.json`;
58
+ return join(cacheRoot(), shardFor(key), file);
59
+ }
60
+ /**
61
+ * Read a cached packument + its ETag for `(registry, name)`. Returns `null` on
62
+ * a miss, when the cache is disabled, or when the entry is unreadable/corrupt
63
+ * (treated as a miss — the caller just re-fetches).
64
+ */
65
+ export function getCachedPackument(registry, name) {
66
+ const path = pathFor(registry, name);
67
+ if (!path)
68
+ return null;
69
+ const buf = readCacheFile(path);
70
+ if (!buf)
71
+ return null;
72
+ try {
73
+ const parsed = JSON.parse(buf.toString('utf-8'));
74
+ if (typeof parsed.etag !== 'string' || !parsed.etag)
75
+ return null;
76
+ if (!parsed.packument || typeof parsed.packument !== 'object')
77
+ return null;
78
+ return { etag: parsed.etag, packument: parsed.packument };
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ /**
85
+ * Persist a packument + ETag for `(registry, name)`. Writes to a `<path>.tmp.<pid>`
86
+ * sibling then atomically renames so a concurrent reader never sees a partial
87
+ * write. No-op when the cache is disabled, the ETag is empty, or the write
88
+ * fails (read-only / out-of-disk cache volume must not break the install).
89
+ */
90
+ export function putCachedPackument(registry, name, etag, packument) {
91
+ if (!etag)
92
+ return;
93
+ const path = pathFor(registry, name);
94
+ if (!path)
95
+ return;
96
+ const entry = { etag, packument };
97
+ atomicWrite(path, JSON.stringify(entry));
98
+ }
@@ -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,31 @@
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;
24
+ /**
25
+ * Read a tarball from npm's cacache content store by SRI integrity. Returns
26
+ * the raw `.tgz` bytes on a HIT, `null` on a MISS / disabled interop / read
27
+ * failure. Like {@link getCachedTarball}, this trusts the content-addressed
28
+ * path rather than re-hashing — the extractor surfaces any genuinely corrupt
29
+ * tarball loudly, and cacache verified the bytes on write.
30
+ */
31
+ export declare function getForeignCachedTarball(integrity: string | undefined): Uint8Array | null;
@@ -0,0 +1,192 @@
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, statSync } from 'node:fs';
34
+ import { homedir } from 'node:os';
35
+ import { join } from 'node:path';
36
+ import { atomicWrite, gjsifyCacheRoot, readCacheFile } from './install-cache-fs.js';
37
+ const CACHE_LAYOUT_VERSION = 'v1';
38
+ /** Root of the tarball cache: `$XDG_CACHE_HOME/gjsify/tarballs/v1`. */
39
+ function cacheRoot() {
40
+ return gjsifyCacheRoot('tarballs', CACHE_LAYOUT_VERSION);
41
+ }
42
+ /**
43
+ * Parse an SRI integrity string (`sha512-AbCd…=`) into its algorithm + hex
44
+ * digest, or `null` for a missing / malformed value (caller falls back to a
45
+ * fresh download). Shared by both the gjsify-store and npm-cacache path
46
+ * derivations below — the base64→hex decode used to be inlined in each.
47
+ */
48
+ function parseSri(integrity) {
49
+ if (!integrity)
50
+ return null;
51
+ const dashIdx = integrity.indexOf('-');
52
+ if (dashIdx <= 0 || dashIdx === integrity.length - 1)
53
+ return null;
54
+ const algorithm = integrity.slice(0, dashIdx);
55
+ const b64 = integrity.slice(dashIdx + 1).replace(/=+$/, '');
56
+ // Decode base64 → hex; throws on malformed input which we swallow.
57
+ let hex;
58
+ try {
59
+ hex = Buffer.from(b64, 'base64').toString('hex');
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ if (hex.length < 4)
65
+ return null;
66
+ return { algorithm, hex };
67
+ }
68
+ /**
69
+ * Convert an SRI integrity string into a cache file path. Returns `null` for
70
+ * unsupported / malformed integrity values so the caller can fall back to a
71
+ * fresh download.
72
+ */
73
+ function pathFor(integrity) {
74
+ const sri = parseSri(integrity);
75
+ if (!sri)
76
+ return null;
77
+ const shard = sri.hex.slice(0, 2);
78
+ return join(cacheRoot(), sri.algorithm, shard, `${sri.hex}.tgz`);
79
+ }
80
+ /**
81
+ * Read a cached tarball by SRI integrity. Returns the raw tarball bytes if
82
+ * the cache has a HIT, `null` otherwise. A read failure (e.g. partial
83
+ * write from an interrupted previous run) is treated as a MISS — the file
84
+ * is left untouched so we don't trip a follow-up writer's atomic rename.
85
+ */
86
+ export function getCachedTarball(integrity) {
87
+ const path = pathFor(integrity);
88
+ if (!path)
89
+ return null;
90
+ return readCacheFile(path);
91
+ }
92
+ /**
93
+ * Persist a tarball to the cache. Writes to a `<path>.tmp.<pid>` sibling
94
+ * then atomically renames into place so concurrent installs can never
95
+ * observe a half-written entry. No-op when:
96
+ * - `integrity` is missing / malformed (no cache key)
97
+ * - the destination already exists (idempotent — content-addressed)
98
+ * - the write fails (e.g. cache root is read-only) — silently degrade
99
+ * so a cache-volume issue doesn't break the install itself.
100
+ */
101
+ export function putCachedTarball(integrity, bytes) {
102
+ const path = pathFor(integrity);
103
+ if (!path)
104
+ return;
105
+ // Idempotent: content-addressed entries are immutable, never rewritten.
106
+ if (existsSync(path))
107
+ return;
108
+ atomicWrite(path, bytes);
109
+ }
110
+ /**
111
+ * Best-effort cache stats for diagnostics. Returns `null` when the cache
112
+ * root doesn't exist yet (first run).
113
+ */
114
+ export function cacheRootForLogging() {
115
+ return cacheRoot();
116
+ }
117
+ export function isCacheHit(integrity) {
118
+ const path = pathFor(integrity);
119
+ if (!path)
120
+ return false;
121
+ try {
122
+ const s = statSync(path);
123
+ return s.isFile() && s.size > 0;
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ // ---------------------------------------------------------------------------
130
+ // Foreign cache interop — read npm's cacache content store.
131
+ //
132
+ // npm stores downloaded tarballs in a content-addressable store
133
+ // (`cacache`) keyed by the SAME SRI integrity we key our own store on, and
134
+ // holds the raw `.tgz` bytes verbatim. Layout (cacache `content-v2`):
135
+ //
136
+ // <npm-cache>/_cacache/content-v2/<algo>/<hex[0:2]>/<hex[2:4]>/<hex[4:]>
137
+ //
138
+ // `<algo>` is the SRI algorithm (`sha512`), `<hex>` the hex-encoded digest —
139
+ // identical derivation to our own `pathFor`, just a different root and no
140
+ // `.tgz` extension. So anyone who has run `npm install` before already has
141
+ // these tarballs on disk; reading them turns a cold `gjsify install` into a
142
+ // near-warm one without a single network round-trip.
143
+ //
144
+ // pnpm/yarn/bun stores are deliberately NOT read here: pnpm/bun store
145
+ // *unpacked* per-file content (no tarball to hand to the extractor) and yarn
146
+ // berry stores zip archives under its own (non-SRI) cache key — none map to a
147
+ // tarball-by-integrity lookup the way npm's cacache does.
148
+ // ---------------------------------------------------------------------------
149
+ /**
150
+ * Resolve npm's `content-v2` directory, honouring `GJSIFY_NPM_CACHE`
151
+ * (full path to a `_cacache` dir; `0`/`false`/empty disables the interop),
152
+ * then `npm_config_cache`, then the platform default `~/.npm`. Returns `null`
153
+ * when the interop is disabled or no plausible cache root exists.
154
+ */
155
+ function npmCacacheContentDir() {
156
+ const override = process.env.GJSIFY_NPM_CACHE;
157
+ if (override !== undefined) {
158
+ const trimmed = override.trim();
159
+ if (trimmed === '' || trimmed === '0' || trimmed === 'false')
160
+ return null;
161
+ // Accept either the `_cacache` dir itself or its parent npm cache dir.
162
+ const base = trimmed.endsWith('_cacache') ? trimmed : join(trimmed, '_cacache');
163
+ return join(base, 'content-v2');
164
+ }
165
+ const npmConfigCache = process.env.npm_config_cache;
166
+ const cacheBase = npmConfigCache && npmConfigCache.length > 0 ? npmConfigCache : join(homedir(), '.npm');
167
+ return join(cacheBase, '_cacache', 'content-v2');
168
+ }
169
+ /** Map an SRI integrity to its npm cacache content-store path, or `null`. */
170
+ function npmCachePathFor(integrity) {
171
+ const contentDir = npmCacacheContentDir();
172
+ if (!contentDir)
173
+ return null;
174
+ const sri = parseSri(integrity);
175
+ if (!sri)
176
+ return null;
177
+ // cacache shards the hex digest into [0:2]/[2:4]/[4:] with NO extension.
178
+ return join(contentDir, sri.algorithm, sri.hex.slice(0, 2), sri.hex.slice(2, 4), sri.hex.slice(4));
179
+ }
180
+ /**
181
+ * Read a tarball from npm's cacache content store by SRI integrity. Returns
182
+ * the raw `.tgz` bytes on a HIT, `null` on a MISS / disabled interop / read
183
+ * failure. Like {@link getCachedTarball}, this trusts the content-addressed
184
+ * path rather than re-hashing — the extractor surfaces any genuinely corrupt
185
+ * tarball loudly, and cacache verified the bytes on write.
186
+ */
187
+ export function getForeignCachedTarball(integrity) {
188
+ const path = npmCachePathFor(integrity);
189
+ if (!path)
190
+ return null;
191
+ return readCacheFile(path);
192
+ }
@@ -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,8 @@
1
+ /** Print a question and read one line from stdin (visible). */
2
+ export declare function promptLine(question: string): Promise<string>;
3
+ /**
4
+ * Print a question and read one line WITHOUT echoing it (passwords). Uses raw
5
+ * mode + manual key handling on a TTY; falls back to a plain line read when
6
+ * stdin is not a TTY (piped). Ctrl-C aborts the process.
7
+ */
8
+ export declare function promptHidden(question: string): Promise<string>;