@gjsify/cli 0.14.0 → 0.16.0
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 +45 -44
- package/lib/actions/build.js +24 -1
- package/lib/cli-app.js +2 -1
- package/lib/commands/foreach.d.ts +1 -0
- package/lib/commands/foreach.js +48 -13
- package/lib/commands/index.d.ts +1 -0
- package/lib/commands/index.js +1 -0
- package/lib/commands/install.js +132 -65
- package/lib/commands/login.d.ts +29 -0
- package/lib/commands/login.js +122 -96
- package/lib/commands/onboard.d.ts +14 -0
- package/lib/commands/onboard.js +453 -0
- package/lib/commands/publish.d.ts +92 -0
- package/lib/commands/publish.js +270 -295
- package/lib/commands/trust.d.ts +21 -0
- package/lib/commands/trust.js +40 -70
- package/lib/commands/workspace.d.ts +2 -0
- package/lib/commands/workspace.js +64 -14
- package/lib/utils/build-cache.d.ts +157 -0
- package/lib/utils/build-cache.js +670 -0
- package/lib/utils/install-backend-native.js +104 -9
- package/lib/utils/install-backend.d.ts +10 -0
- package/lib/utils/install-cache-fs.d.ts +9 -0
- package/lib/utils/install-cache-fs.js +20 -6
- package/lib/utils/install-lock.d.ts +15 -0
- package/lib/utils/install-lock.js +293 -0
- package/lib/utils/library-output.d.ts +30 -0
- package/lib/utils/library-output.js +92 -0
- package/lib/utils/npm-otp.d.ts +67 -0
- package/lib/utils/npm-otp.js +117 -0
- package/lib/utils/suggest.d.ts +13 -0
- package/lib/utils/suggest.js +51 -0
- package/package.json +19 -18
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Content-hash per-package build cache (ADR 0006 phase 1).
|
|
3
|
+
//
|
|
4
|
+
// `gjsify workspace <name> <script> --cached` / `gjsify foreach <script>
|
|
5
|
+
// --cached` skip re-running a build-ish script for a workspace package whose
|
|
6
|
+
// inputs are unchanged, restoring the previously stored outputs instead.
|
|
7
|
+
//
|
|
8
|
+
// Cache key per package = sha256 over:
|
|
9
|
+
// - a layout version (bump BUILD_CACHE_LAYOUT_VERSION to invalidate all),
|
|
10
|
+
// - the script name + forwarded args,
|
|
11
|
+
// - a toolchain salt (workspace-resolved versions of @gjsify/cli,
|
|
12
|
+
// @gjsify/tsc, rolldown, typescript — a bundler/compiler bump must
|
|
13
|
+
// invalidate everything),
|
|
14
|
+
// - the package's own input hash (every file under `src/**`, plus
|
|
15
|
+
// `package.json`, plus the package's root `tsconfig*.json` — real file
|
|
16
|
+
// contents, streamed sha256, sorted for determinism),
|
|
17
|
+
// - the input hashes of EVERY workspace dependency in the package's
|
|
18
|
+
// transitive dep closure (prod + dev + optional + peer `workspace:`
|
|
19
|
+
// edges). Composing the closure's OWN hashes (instead of recursing into
|
|
20
|
+
// dep KEYS) gives identical invalidation semantics — any change in any
|
|
21
|
+
// transitive dep changes this package's key — while staying well-defined
|
|
22
|
+
// on the cyclic dev-dependency graphs this monorepo actually has.
|
|
23
|
+
//
|
|
24
|
+
// Storage layout (per workspace root):
|
|
25
|
+
// node_modules/.cache/gjsify/build/<pkg-name-sanitized>/<key>/
|
|
26
|
+
// manifest.json — { package, script, key, dirs, outputsHash, … }
|
|
27
|
+
// outputs/<dir>/… — copies of the output dirs the build MODIFIED
|
|
28
|
+
//
|
|
29
|
+
// Output contract: after a successful (miss) run, the candidate output dirs
|
|
30
|
+
// (`lib/`, `dist/`, `dist-templates/`) are compared against a pre-run
|
|
31
|
+
// snapshot and ONLY the dirs the script actually created/modified are
|
|
32
|
+
// stored. On a later hit, EXACTLY those dirs are replaced (delete-then-copy
|
|
33
|
+
// within the package). This is the safety rule from the v0.x CI incident
|
|
34
|
+
// where a raw YAML cache restored over a package whose committed `lib/` is
|
|
35
|
+
// tracked source: a dir the build never touches is never recorded, so it can
|
|
36
|
+
// never be restored over. Packages that don't define the script at all are
|
|
37
|
+
// filtered out by the calling command and never reach the cache.
|
|
38
|
+
//
|
|
39
|
+
// Growth is bounded: at most MAX_KEYS_PER_PACKAGE entries per package,
|
|
40
|
+
// oldest (by last store/use) evicted.
|
|
41
|
+
//
|
|
42
|
+
// Every cache operation is fail-soft: an unreadable cache entry, a full disk
|
|
43
|
+
// or a permission error degrade to "run the script uncached" — a cache
|
|
44
|
+
// hiccup must never break a build.
|
|
45
|
+
import { createHash } from 'node:crypto';
|
|
46
|
+
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, readSync, renameSync, rmSync, symlinkSync, copyFileSync, writeFileSync, } from 'node:fs';
|
|
47
|
+
import { dirname, join } from 'node:path';
|
|
48
|
+
import { fileURLToPath } from 'node:url';
|
|
49
|
+
import { buildDependencyGraph } from '@gjsify/workspace';
|
|
50
|
+
/** Bump to invalidate every existing cache entry (schema/semantic changes). */
|
|
51
|
+
export const BUILD_CACHE_LAYOUT_VERSION = 'v1';
|
|
52
|
+
/** Conventional per-package build output directories, checked in this order. */
|
|
53
|
+
export const OUTPUT_DIR_CANDIDATES = ['lib', 'dist', 'dist-templates'];
|
|
54
|
+
/** Keep at most this many keys per package; evict the least recently stored/used. */
|
|
55
|
+
export const MAX_KEYS_PER_PACKAGE = 2;
|
|
56
|
+
/** Toolchain packages whose resolved versions salt every cache key. */
|
|
57
|
+
export const TOOLCHAIN_PACKAGES = ['@gjsify/cli', '@gjsify/tsc', 'rolldown', 'typescript'];
|
|
58
|
+
/** `true` when GJSIFY_BUILD_CACHE is set to a non-empty, non-"0"/"false" value. */
|
|
59
|
+
export function buildCacheEnabledByEnv(env = process.env) {
|
|
60
|
+
const v = env['GJSIFY_BUILD_CACHE'];
|
|
61
|
+
if (v === undefined)
|
|
62
|
+
return false;
|
|
63
|
+
return v !== '' && v !== '0' && v !== 'false';
|
|
64
|
+
}
|
|
65
|
+
/** Cache root for a workspace: `<root>/node_modules/.cache/gjsify/build`. */
|
|
66
|
+
export function buildCacheRoot(workspaceRoot) {
|
|
67
|
+
return join(workspaceRoot, 'node_modules', '.cache', 'gjsify', 'build');
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Directory-safe package name. Scoped names collapse (`@gjsify/cli` →
|
|
71
|
+
* `gjsify-cli`); an 8-hex sha256 suffix of the exact name rules out
|
|
72
|
+
* collisions between e.g. `@a/b-c` and `@a-b/c`.
|
|
73
|
+
*/
|
|
74
|
+
export function sanitizePackageDirName(name) {
|
|
75
|
+
const cleaned = name.replace(/^@/, '').replace(/[^A-Za-z0-9._-]/g, '-');
|
|
76
|
+
const suffix = createHash('sha256').update(name).digest('hex').slice(0, 8);
|
|
77
|
+
return `${cleaned}-${suffix}`;
|
|
78
|
+
}
|
|
79
|
+
/** Streamed sha256 of one file (bounded memory, real contents). */
|
|
80
|
+
export function hashFileStream(path) {
|
|
81
|
+
const hash = createHash('sha256');
|
|
82
|
+
const fd = openSync(path, 'r');
|
|
83
|
+
try {
|
|
84
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
85
|
+
for (;;) {
|
|
86
|
+
const n = readSync(fd, buf, 0, buf.length, null);
|
|
87
|
+
if (n <= 0)
|
|
88
|
+
break;
|
|
89
|
+
hash.update(buf.subarray(0, n));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
closeSync(fd);
|
|
94
|
+
}
|
|
95
|
+
return hash.digest('hex');
|
|
96
|
+
}
|
|
97
|
+
// Recursive, sorted file walk. Yields workspace-relative paths (forward
|
|
98
|
+
// slashes). Directory symlinks are NOT followed (loop safety) — the link
|
|
99
|
+
// target string participates in the hash instead. `node_modules` and the
|
|
100
|
+
// output dirs never appear because callers only walk `src/` + explicit root
|
|
101
|
+
// files, but guard anyway for nested oddities.
|
|
102
|
+
function walkFiles(dir, prefix, out) {
|
|
103
|
+
let entries;
|
|
104
|
+
try {
|
|
105
|
+
entries = readdirSync(dir);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
entries.sort();
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
if (entry === 'node_modules')
|
|
113
|
+
continue;
|
|
114
|
+
const abs = join(dir, entry);
|
|
115
|
+
const rel = prefix === '' ? entry : `${prefix}/${entry}`;
|
|
116
|
+
let st;
|
|
117
|
+
try {
|
|
118
|
+
st = lstatSync(abs);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (st.isSymbolicLink()) {
|
|
124
|
+
// File symlinks: hash the resolved content below if readable;
|
|
125
|
+
// dir symlinks: record the target only.
|
|
126
|
+
try {
|
|
127
|
+
const target = readlinkSync(abs);
|
|
128
|
+
out.push({ rel, abs, link: target });
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
/* dropped mid-walk */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else if (st.isDirectory()) {
|
|
135
|
+
walkFiles(abs, rel, out);
|
|
136
|
+
}
|
|
137
|
+
else if (st.isFile()) {
|
|
138
|
+
out.push({ rel, abs });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Content hash of a package's build INPUTS: every file under `src/**`, plus
|
|
144
|
+
* `package.json`, plus root `tsconfig*.json`. Sorted, content-streamed —
|
|
145
|
+
* mtimes do not participate, so a `git checkout` with identical contents
|
|
146
|
+
* hashes identically across machines.
|
|
147
|
+
*/
|
|
148
|
+
export function hashPackageInputs(location) {
|
|
149
|
+
const files = [];
|
|
150
|
+
const pkgJson = join(location, 'package.json');
|
|
151
|
+
if (existsSync(pkgJson))
|
|
152
|
+
files.push({ rel: 'package.json', abs: pkgJson });
|
|
153
|
+
let rootEntries = [];
|
|
154
|
+
try {
|
|
155
|
+
rootEntries = readdirSync(location);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
/* unreadable package dir — hash degenerates to the empty set */
|
|
159
|
+
}
|
|
160
|
+
for (const entry of rootEntries.sort()) {
|
|
161
|
+
if (/^tsconfig.*\.json$/.test(entry)) {
|
|
162
|
+
files.push({ rel: entry, abs: join(location, entry) });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const srcDir = join(location, 'src');
|
|
166
|
+
if (existsSync(srcDir))
|
|
167
|
+
walkFiles(srcDir, 'src', files);
|
|
168
|
+
const hash = createHash('sha256');
|
|
169
|
+
hash.update(`gjsify-build-cache-inputs/${BUILD_CACHE_LAYOUT_VERSION}\n`);
|
|
170
|
+
for (const f of files) {
|
|
171
|
+
if (f.link !== undefined) {
|
|
172
|
+
// Symlink: try content (file links), fall back to the target path.
|
|
173
|
+
let content;
|
|
174
|
+
try {
|
|
175
|
+
content = hashFileStream(f.abs);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
content = `link:${f.link}`;
|
|
179
|
+
}
|
|
180
|
+
hash.update(`${f.rel} ${content}\n`);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
hash.update(`${f.rel} ${hashFileStream(f.abs)}\n`);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// Vanished mid-hash (concurrent edit) — record the absence so the
|
|
188
|
+
// key still changes vs. the file being present.
|
|
189
|
+
hash.update(`${f.rel} unreadable\n`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return hash.digest('hex');
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Mtime-independent content hash of a set of output dirs — used to skip the
|
|
196
|
+
* restore copy when the on-disk outputs already match the cached entry, and
|
|
197
|
+
* as an integrity stamp of what was stored.
|
|
198
|
+
*/
|
|
199
|
+
export function hashOutputDirs(location, dirs) {
|
|
200
|
+
const hash = createHash('sha256');
|
|
201
|
+
hash.update(`gjsify-build-cache-outputs/${BUILD_CACHE_LAYOUT_VERSION}\n`);
|
|
202
|
+
for (const dir of [...dirs].sort()) {
|
|
203
|
+
const abs = join(location, dir);
|
|
204
|
+
if (!existsSync(abs)) {
|
|
205
|
+
hash.update(`${dir} absent\n`);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const files = [];
|
|
209
|
+
walkFiles(abs, dir, files);
|
|
210
|
+
for (const f of files) {
|
|
211
|
+
if (f.link !== undefined) {
|
|
212
|
+
hash.update(`${f.rel} link:${f.link}\n`);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
hash.update(`${f.rel} ${hashFileStream(f.abs)}\n`);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
hash.update(`${f.rel} unreadable\n`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return hash.digest('hex');
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Cheap per-dir change signature (existence + file list + sizes + mtimes).
|
|
227
|
+
* Taken BEFORE running the script and compared AFTER, to detect which output
|
|
228
|
+
* dirs the build actually modified. Content hashing is deliberately avoided
|
|
229
|
+
* here — a build that rewrites identical bytes still bumps mtimes, and that
|
|
230
|
+
* is exactly the "this dir belongs to the build" signal we want.
|
|
231
|
+
*/
|
|
232
|
+
export function snapshotOutputDirs(location) {
|
|
233
|
+
const snapshot = {};
|
|
234
|
+
for (const dir of OUTPUT_DIR_CANDIDATES) {
|
|
235
|
+
const abs = join(location, dir);
|
|
236
|
+
if (!existsSync(abs)) {
|
|
237
|
+
snapshot[dir] = null;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const files = [];
|
|
241
|
+
walkFiles(abs, dir, files);
|
|
242
|
+
const hash = createHash('sha256');
|
|
243
|
+
for (const f of files) {
|
|
244
|
+
let stamp = 'link';
|
|
245
|
+
if (f.link === undefined) {
|
|
246
|
+
try {
|
|
247
|
+
const st = lstatSync(f.abs);
|
|
248
|
+
stamp = `${st.size}:${st.mtimeMs}`;
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
stamp = 'gone';
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
hash.update(`${f.rel} ${stamp}\n`);
|
|
255
|
+
}
|
|
256
|
+
snapshot[dir] = hash.digest('hex');
|
|
257
|
+
}
|
|
258
|
+
return snapshot;
|
|
259
|
+
}
|
|
260
|
+
/** Output dirs whose signature changed (or that newly appeared) since `before`. */
|
|
261
|
+
export function modifiedOutputDirs(location, before) {
|
|
262
|
+
const after = snapshotOutputDirs(location);
|
|
263
|
+
const modified = [];
|
|
264
|
+
for (const dir of OUTPUT_DIR_CANDIDATES) {
|
|
265
|
+
// Newly created, deleted, or changed — all count as "the build owns
|
|
266
|
+
// this dir". A dir that is now ABSENT cannot be stored, though.
|
|
267
|
+
if (after[dir] !== before[dir] && after[dir] !== null)
|
|
268
|
+
modified.push(dir);
|
|
269
|
+
}
|
|
270
|
+
return modified;
|
|
271
|
+
}
|
|
272
|
+
/** Deterministic cache key (32 hex chars) from all invalidation ingredients. */
|
|
273
|
+
export function composeCacheKey(input) {
|
|
274
|
+
const hash = createHash('sha256');
|
|
275
|
+
hash.update(`gjsify-build-cache/${BUILD_CACHE_LAYOUT_VERSION}\n`);
|
|
276
|
+
hash.update(`script:${input.script}\n`);
|
|
277
|
+
for (const arg of input.args ?? [])
|
|
278
|
+
hash.update(`arg:${arg}\n`);
|
|
279
|
+
for (const name of Object.keys(input.toolchain).sort()) {
|
|
280
|
+
hash.update(`tool:${name}@${input.toolchain[name]}\n`);
|
|
281
|
+
}
|
|
282
|
+
hash.update(`self:${input.ownHash}\n`);
|
|
283
|
+
const deps = input.depHashes instanceof Map ? input.depHashes : new Map(Object.entries(input.depHashes));
|
|
284
|
+
for (const name of [...deps.keys()].sort()) {
|
|
285
|
+
hash.update(`dep:${name} ${deps.get(name)}\n`);
|
|
286
|
+
}
|
|
287
|
+
return hash.digest('hex').slice(0, 32);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Resolve the toolchain versions that salt every key from the workspace's
|
|
291
|
+
* OWN resolution (`<root>/node_modules/<pkg>/package.json`). In the gjsify
|
|
292
|
+
* monorepo these are symlinks into the workspace itself, so a CLI/tsc bump
|
|
293
|
+
* invalidates immediately. A package that isn't resolvable maps to 'none'
|
|
294
|
+
* (deterministic). `@gjsify/cli` additionally falls back to the RUNNING
|
|
295
|
+
* CLI's own version so globally-installed CLIs still salt correctly.
|
|
296
|
+
*/
|
|
297
|
+
export function resolveToolchainVersions(workspaceRoot) {
|
|
298
|
+
const toolchain = {};
|
|
299
|
+
for (const name of TOOLCHAIN_PACKAGES) {
|
|
300
|
+
let version = 'none';
|
|
301
|
+
try {
|
|
302
|
+
const raw = readFileSync(join(workspaceRoot, 'node_modules', ...name.split('/'), 'package.json'), 'utf-8');
|
|
303
|
+
const parsed = JSON.parse(raw);
|
|
304
|
+
if (typeof parsed.version === 'string')
|
|
305
|
+
version = parsed.version;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
/* not resolvable in this workspace */
|
|
309
|
+
}
|
|
310
|
+
if (name === '@gjsify/cli' && version === 'none') {
|
|
311
|
+
version = readOwnCliVersion() ?? 'none';
|
|
312
|
+
}
|
|
313
|
+
toolchain[name] = version;
|
|
314
|
+
}
|
|
315
|
+
return toolchain;
|
|
316
|
+
}
|
|
317
|
+
// Walk up from this module (lib/utils/… in the package build, dist/… in the
|
|
318
|
+
// GJS bundle) to the CLI's own package.json. Mirrors cli-app's
|
|
319
|
+
// readBundleVersion but tolerates both directory depths.
|
|
320
|
+
function readOwnCliVersion() {
|
|
321
|
+
try {
|
|
322
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
323
|
+
for (let i = 0; i < 4; i++) {
|
|
324
|
+
const candidate = join(dir, 'package.json');
|
|
325
|
+
if (existsSync(candidate)) {
|
|
326
|
+
const pkg = JSON.parse(readFileSync(candidate, 'utf-8'));
|
|
327
|
+
if (pkg.name === '@gjsify/cli' && typeof pkg.version === 'string')
|
|
328
|
+
return pkg.version;
|
|
329
|
+
}
|
|
330
|
+
dir = dirname(dir);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
/* fall through */
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
// Recursive copy preserving file modes (the CLI's own `lib/index.js` carries
|
|
339
|
+
// an exec bit) and symlinks. Primitive fs ops only, so it behaves the same
|
|
340
|
+
// under Node and the GJS facade.
|
|
341
|
+
function copyDirRecursive(from, to) {
|
|
342
|
+
mkdirSync(to, { recursive: true });
|
|
343
|
+
for (const entry of readdirSync(from).sort()) {
|
|
344
|
+
const src = join(from, entry);
|
|
345
|
+
const dst = join(to, entry);
|
|
346
|
+
const st = lstatSync(src);
|
|
347
|
+
if (st.isSymbolicLink()) {
|
|
348
|
+
symlinkSync(readlinkSync(src), dst);
|
|
349
|
+
}
|
|
350
|
+
else if (st.isDirectory()) {
|
|
351
|
+
copyDirRecursive(src, dst);
|
|
352
|
+
}
|
|
353
|
+
else if (st.isFile()) {
|
|
354
|
+
copyFileSync(src, dst);
|
|
355
|
+
try {
|
|
356
|
+
chmodSync(dst, st.mode & 0o777);
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
/* mode best-effort */
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/** Directory of one cache entry: `<cacheRoot>/<pkg-sanitized>/<key>`. */
|
|
365
|
+
export function buildCacheEntryDir(cacheRoot, pkgName, key) {
|
|
366
|
+
return join(cacheRoot, sanitizePackageDirName(pkgName), key);
|
|
367
|
+
}
|
|
368
|
+
const entryDir = buildCacheEntryDir;
|
|
369
|
+
/** Read + validate a cache entry's manifest; any mismatch is a MISS (null). */
|
|
370
|
+
export function readCacheEntry(cacheRoot, pkgName, key) {
|
|
371
|
+
const manifestPath = join(entryDir(cacheRoot, pkgName, key), 'manifest.json');
|
|
372
|
+
try {
|
|
373
|
+
const parsed = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
374
|
+
if (parsed.version !== 1)
|
|
375
|
+
return null;
|
|
376
|
+
if (parsed.layout !== BUILD_CACHE_LAYOUT_VERSION)
|
|
377
|
+
return null;
|
|
378
|
+
if (parsed.package !== pkgName || parsed.key !== key)
|
|
379
|
+
return null;
|
|
380
|
+
if (!Array.isArray(parsed.dirs))
|
|
381
|
+
return null;
|
|
382
|
+
// Every stored dir must actually exist in the entry — a half-written
|
|
383
|
+
// entry (crash mid-store before the atomic rename could never produce
|
|
384
|
+
// this, but a manually truncated one could) is a MISS.
|
|
385
|
+
for (const dir of parsed.dirs) {
|
|
386
|
+
if (!existsSync(join(entryDir(cacheRoot, pkgName, key), 'outputs', dir)))
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
return parsed;
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Store the given output dirs of a package under `key`. Atomic: builds a
|
|
397
|
+
* `.tmp-<pid>` sibling then renames into place, so concurrent readers never
|
|
398
|
+
* observe a partial entry. Best-effort — failures are swallowed (the build
|
|
399
|
+
* itself already succeeded).
|
|
400
|
+
*/
|
|
401
|
+
export function storeCacheEntry(cacheRoot, pkg, key, script, dirs) {
|
|
402
|
+
const finalDir = entryDir(cacheRoot, pkg.name, key);
|
|
403
|
+
const tmpDir = `${finalDir}.tmp-${process.pid}`;
|
|
404
|
+
try {
|
|
405
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
406
|
+
mkdirSync(join(tmpDir, 'outputs'), { recursive: true });
|
|
407
|
+
for (const dir of dirs) {
|
|
408
|
+
copyDirRecursive(join(pkg.location, dir), join(tmpDir, 'outputs', dir));
|
|
409
|
+
}
|
|
410
|
+
const now = new Date().toISOString();
|
|
411
|
+
const manifest = {
|
|
412
|
+
version: 1,
|
|
413
|
+
layout: BUILD_CACHE_LAYOUT_VERSION,
|
|
414
|
+
package: pkg.name,
|
|
415
|
+
script,
|
|
416
|
+
key,
|
|
417
|
+
dirs: [...dirs],
|
|
418
|
+
outputsHash: hashOutputDirs(pkg.location, dirs),
|
|
419
|
+
createdAt: now,
|
|
420
|
+
usedAt: now,
|
|
421
|
+
};
|
|
422
|
+
writeFileSync(join(tmpDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
423
|
+
rmSync(finalDir, { recursive: true, force: true });
|
|
424
|
+
renameSync(tmpDir, finalDir);
|
|
425
|
+
pruneCacheEntries(cacheRoot, pkg.name);
|
|
426
|
+
return manifest;
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
try {
|
|
430
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
/* best-effort cleanup */
|
|
434
|
+
}
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Restore a validated entry into the package. Replaces EXACTLY the dirs the
|
|
440
|
+
* manifest records (delete-then-copy within the package); skips the copy
|
|
441
|
+
* when the on-disk outputs already content-match the entry. Returns how the
|
|
442
|
+
* hit was satisfied, or null when restoration failed (treat as MISS).
|
|
443
|
+
*/
|
|
444
|
+
export function restoreCacheEntry(cacheRoot, pkg, manifest) {
|
|
445
|
+
const dir = entryDir(cacheRoot, pkg.name, manifest.key);
|
|
446
|
+
try {
|
|
447
|
+
if (manifest.dirs.length > 0 && hashOutputDirs(pkg.location, manifest.dirs) === manifest.outputsHash) {
|
|
448
|
+
touchManifestUsed(dir, manifest);
|
|
449
|
+
return 'up-to-date';
|
|
450
|
+
}
|
|
451
|
+
for (const outDir of manifest.dirs) {
|
|
452
|
+
const target = join(pkg.location, outDir);
|
|
453
|
+
rmSync(target, { recursive: true, force: true });
|
|
454
|
+
copyDirRecursive(join(dir, 'outputs', outDir), target);
|
|
455
|
+
}
|
|
456
|
+
touchManifestUsed(dir, manifest);
|
|
457
|
+
return manifest.dirs.length > 0 ? 'restored' : 'up-to-date';
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
function touchManifestUsed(dir, manifest) {
|
|
464
|
+
try {
|
|
465
|
+
manifest.usedAt = new Date().toISOString();
|
|
466
|
+
writeFileSync(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
/* recency bump is best-effort */
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/** Evict the oldest entries (by last store/use) beyond `keep` per package. */
|
|
473
|
+
export function pruneCacheEntries(cacheRoot, pkgName, keep = MAX_KEYS_PER_PACKAGE) {
|
|
474
|
+
const pkgDir = join(cacheRoot, sanitizePackageDirName(pkgName));
|
|
475
|
+
let entries;
|
|
476
|
+
try {
|
|
477
|
+
entries = readdirSync(pkgDir);
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const dated = [];
|
|
483
|
+
for (const entry of entries) {
|
|
484
|
+
// Leftover tmp dirs from crashed stores are always eligible.
|
|
485
|
+
if (entry.includes('.tmp-')) {
|
|
486
|
+
try {
|
|
487
|
+
rmSync(join(pkgDir, entry), { recursive: true, force: true });
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
/* ignore */
|
|
491
|
+
}
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
let stamp = '';
|
|
495
|
+
try {
|
|
496
|
+
const manifest = JSON.parse(readFileSync(join(pkgDir, entry, 'manifest.json'), 'utf-8'));
|
|
497
|
+
stamp = manifest.usedAt ?? manifest.createdAt ?? '';
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
/* unreadable → oldest */
|
|
501
|
+
}
|
|
502
|
+
dated.push({ name: entry, stamp });
|
|
503
|
+
}
|
|
504
|
+
if (dated.length <= keep)
|
|
505
|
+
return;
|
|
506
|
+
dated.sort((a, b) => (a.stamp < b.stamp ? 1 : a.stamp > b.stamp ? -1 : 0));
|
|
507
|
+
for (const evict of dated.slice(keep)) {
|
|
508
|
+
try {
|
|
509
|
+
rmSync(join(pkgDir, evict.name), { recursive: true, force: true });
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
/* best-effort */
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
// Per-package opt-out: `package.json#gjsify.buildCache: false`. For packages
|
|
517
|
+
// whose build writes outside the conventional output dirs (e.g.
|
|
518
|
+
// `@gjsify/adwaita-web` generates `src/styles.generated.ts` plus per-file JS
|
|
519
|
+
// output inside `src/` that consumers resolve from SOURCE) — a cached skip
|
|
520
|
+
// would leave those generated files missing, so such packages always run
|
|
521
|
+
// uncached.
|
|
522
|
+
export function buildCacheOptedOut(ws) {
|
|
523
|
+
const gjsify = ws.manifest['gjsify'];
|
|
524
|
+
return gjsify?.buildCache === false;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Per-run orchestrator used by `gjsify workspace` / `gjsify foreach`:
|
|
528
|
+
* memoizes input hashes + keys across packages, restores on hit, snapshots +
|
|
529
|
+
* stores on miss. All entry points are fail-soft — any internal error logs a
|
|
530
|
+
* warning and reports a MISS so the script runs normally.
|
|
531
|
+
*/
|
|
532
|
+
export class BuildCacheRunner {
|
|
533
|
+
options;
|
|
534
|
+
cacheRoot;
|
|
535
|
+
graph;
|
|
536
|
+
toolchain;
|
|
537
|
+
ownHashes = new Map();
|
|
538
|
+
closures = new Map();
|
|
539
|
+
keys = new Map();
|
|
540
|
+
log;
|
|
541
|
+
constructor(options) {
|
|
542
|
+
this.options = options;
|
|
543
|
+
this.cacheRoot = buildCacheRoot(options.root);
|
|
544
|
+
// Broadest edge set for KEY correctness: builds consume prod deps'
|
|
545
|
+
// outputs, type-check against dev deps, and may link optional/peer
|
|
546
|
+
// workspace siblings. The dev graph is cyclic in this monorepo —
|
|
547
|
+
// fine, the closure below is a BFS, not a topological sort.
|
|
548
|
+
this.graph = buildDependencyGraph(options.workspaces, {
|
|
549
|
+
includeDev: true,
|
|
550
|
+
includePeer: true,
|
|
551
|
+
includeOptional: true,
|
|
552
|
+
});
|
|
553
|
+
this.toolchain = resolveToolchainVersions(options.root);
|
|
554
|
+
this.log = options.log ?? ((line) => console.error(line));
|
|
555
|
+
// Pin every relevant input hash to the run-start tree state (see
|
|
556
|
+
// `targets` docs above). Keys derived later stay consistent even if
|
|
557
|
+
// an earlier build in this run mutates a package's src.
|
|
558
|
+
for (const target of options.targets ?? []) {
|
|
559
|
+
if (!this.graph.byName.has(target))
|
|
560
|
+
continue;
|
|
561
|
+
this.ownHashOf(target);
|
|
562
|
+
for (const dep of this.closureOf(target))
|
|
563
|
+
this.ownHashOf(dep);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
ownHashOf(name) {
|
|
567
|
+
let hash = this.ownHashes.get(name);
|
|
568
|
+
if (hash === undefined) {
|
|
569
|
+
const ws = this.graph.byName.get(name);
|
|
570
|
+
hash = ws ? hashPackageInputs(ws.location) : 'unknown-workspace';
|
|
571
|
+
this.ownHashes.set(name, hash);
|
|
572
|
+
}
|
|
573
|
+
return hash;
|
|
574
|
+
}
|
|
575
|
+
/** BFS transitive workspace-dep closure (cycle-safe), excluding `name` itself. */
|
|
576
|
+
closureOf(name) {
|
|
577
|
+
let closure = this.closures.get(name);
|
|
578
|
+
if (closure === undefined) {
|
|
579
|
+
const seen = new Set();
|
|
580
|
+
const queue = [...(this.graph.edges.get(name) ?? [])];
|
|
581
|
+
while (queue.length > 0) {
|
|
582
|
+
const dep = queue.shift();
|
|
583
|
+
if (dep === name || seen.has(dep))
|
|
584
|
+
continue;
|
|
585
|
+
seen.add(dep);
|
|
586
|
+
for (const next of this.graph.edges.get(dep) ?? []) {
|
|
587
|
+
if (!seen.has(next) && next !== name)
|
|
588
|
+
queue.push(next);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
closure = [...seen].sort();
|
|
592
|
+
this.closures.set(name, closure);
|
|
593
|
+
}
|
|
594
|
+
return closure;
|
|
595
|
+
}
|
|
596
|
+
/** Content-hash cache key for one workspace (memoized per run). */
|
|
597
|
+
keyFor(ws) {
|
|
598
|
+
let key = this.keys.get(ws.name);
|
|
599
|
+
if (key === undefined) {
|
|
600
|
+
const depHashes = new Map();
|
|
601
|
+
for (const dep of this.closureOf(ws.name))
|
|
602
|
+
depHashes.set(dep, this.ownHashOf(dep));
|
|
603
|
+
key = composeCacheKey({
|
|
604
|
+
script: this.options.script,
|
|
605
|
+
args: this.options.args,
|
|
606
|
+
toolchain: this.toolchain,
|
|
607
|
+
ownHash: this.ownHashOf(ws.name),
|
|
608
|
+
depHashes,
|
|
609
|
+
});
|
|
610
|
+
this.keys.set(ws.name, key);
|
|
611
|
+
}
|
|
612
|
+
return key;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Try to satisfy the script from the cache. Returns `true` on a hit
|
|
616
|
+
* (outputs restored / already current — the caller SKIPS the script).
|
|
617
|
+
*/
|
|
618
|
+
tryRestore(ws) {
|
|
619
|
+
try {
|
|
620
|
+
if (buildCacheOptedOut(ws)) {
|
|
621
|
+
this.log(`[gjsify build-cache] ${ws.name}: opted out (gjsify.buildCache=false) — running uncached`);
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
const key = this.keyFor(ws);
|
|
625
|
+
const manifest = readCacheEntry(this.cacheRoot, ws.name, key);
|
|
626
|
+
if (!manifest || manifest.script !== this.options.script) {
|
|
627
|
+
this.log(`[gjsify build-cache] ${ws.name}: miss ${key} — running "${this.options.script}"`);
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
630
|
+
const how = restoreCacheEntry(this.cacheRoot, ws, manifest);
|
|
631
|
+
if (how === null) {
|
|
632
|
+
this.log(`[gjsify build-cache] ${ws.name}: entry ${key} unrestorable — running "${this.options.script}"`);
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
const what = manifest.dirs.length > 0 ? manifest.dirs.join(', ') : 'no output dirs';
|
|
636
|
+
this.log(`[gjsify build-cache] ${ws.name}: hit ${key} — ${how === 'restored' ? `restored ${what}` : `${what} up to date`} (skipped "${this.options.script}")`);
|
|
637
|
+
return true;
|
|
638
|
+
}
|
|
639
|
+
catch (err) {
|
|
640
|
+
this.log(`[gjsify build-cache] ${ws.name}: cache error (${err.message}) — running uncached`);
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
/** Snapshot the candidate output dirs BEFORE running the script. */
|
|
645
|
+
snapshotOutputs(ws) {
|
|
646
|
+
try {
|
|
647
|
+
return snapshotOutputDirs(ws.location);
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
return {};
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
/** After a SUCCESSFUL script run: store the dirs the build modified. */
|
|
654
|
+
storeAfterSuccess(ws, before) {
|
|
655
|
+
try {
|
|
656
|
+
if (buildCacheOptedOut(ws))
|
|
657
|
+
return;
|
|
658
|
+
const key = this.keyFor(ws);
|
|
659
|
+
const dirs = modifiedOutputDirs(ws.location, before);
|
|
660
|
+
const manifest = storeCacheEntry(this.cacheRoot, ws, key, this.options.script, dirs);
|
|
661
|
+
if (manifest) {
|
|
662
|
+
const what = dirs.length > 0 ? dirs.join(', ') : 'no output dirs modified';
|
|
663
|
+
this.log(`[gjsify build-cache] ${ws.name}: stored ${key} (${what})`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
catch (err) {
|
|
667
|
+
this.log(`[gjsify build-cache] ${ws.name}: store failed (${err.message}) — continuing`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|