@gjsify/cli 0.4.35 → 0.4.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.gjs.mjs +160 -140
- package/lib/bundler-pick.js +43 -4
- package/lib/commands/affected.d.ts +10 -0
- package/lib/commands/affected.js +303 -0
- package/lib/commands/build.js +1 -1
- package/lib/commands/dlx.js +8 -1
- package/lib/commands/index.d.ts +5 -0
- package/lib/commands/index.js +5 -0
- package/lib/commands/install.d.ts +3 -0
- package/lib/commands/install.js +324 -77
- package/lib/commands/login.d.ts +10 -0
- package/lib/commands/login.js +139 -0
- package/lib/commands/logout.d.ts +8 -0
- package/lib/commands/logout.js +63 -0
- package/lib/commands/publish.js +36 -35
- package/lib/commands/tsc.d.ts +6 -0
- package/lib/commands/tsc.js +109 -0
- package/lib/commands/whoami.d.ts +7 -0
- package/lib/commands/whoami.js +118 -0
- package/lib/commands/workspace.d.ts +4 -0
- package/lib/commands/workspace.js +159 -32
- package/lib/index.js +6 -1
- package/lib/utils/auth-npmrc.d.ts +22 -0
- package/lib/utils/auth-npmrc.js +89 -0
- package/lib/utils/install-backend-native.js +236 -89
- package/lib/utils/install-backend.d.ts +19 -0
- package/lib/utils/install-backend.js +1 -0
- package/lib/utils/install-cache-fs.d.ts +22 -0
- package/lib/utils/install-cache-fs.js +64 -0
- package/lib/utils/install-packument-cache.d.ts +18 -0
- package/lib/utils/install-packument-cache.js +98 -0
- package/lib/utils/install-progress.d.ts +26 -0
- package/lib/utils/install-progress.js +109 -0
- package/lib/utils/install-tarball-cache.d.ts +31 -0
- package/lib/utils/install-tarball-cache.js +192 -0
- package/lib/utils/load-npmrc.d.ts +14 -0
- package/lib/utils/load-npmrc.js +61 -0
- package/lib/utils/prompt.d.ts +8 -0
- package/lib/utils/prompt.js +92 -0
- package/lib/utils/publish-diagnose.d.ts +38 -0
- package/lib/utils/publish-diagnose.js +103 -0
- package/lib/utils/resolve-npm-package.d.ts +21 -0
- package/lib/utils/resolve-npm-package.js +121 -0
- package/package.json +29 -18
|
@@ -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>;
|