@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.
- package/dist/cli.gjs.mjs +87 -77
- 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/flatpak/scaffold.js +9 -0
- package/lib/commands/index.d.ts +3 -0
- package/lib/commands/index.js +3 -0
- package/lib/commands/install.d.ts +3 -0
- package/lib/commands/install.js +324 -77
- 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 +4 -1
- package/lib/templates/flatpak/desktop.tmpl +1 -1
- package/lib/utils/install-backend-native.js +58 -15
- package/lib/utils/install-backend.d.ts +19 -0
- package/lib/utils/install-backend.js +1 -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 +23 -0
- package/lib/utils/install-tarball-cache.js +140 -0
- package/lib/utils/load-npmrc.d.ts +14 -0
- package/lib/utils/load-npmrc.js +61 -0
- package/lib/utils/publish-diagnose.d.ts +38 -0
- package/lib/utils/publish-diagnose.js +99 -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
|
@@ -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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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 {};
|