@agent-flow-board/bridge 0.1.0 → 0.3.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.
Files changed (62) hide show
  1. package/README.md +29 -1
  2. package/dist/checks/appdir.js +105 -0
  3. package/dist/checks/approve.js +213 -0
  4. package/dist/checks/audit.js +126 -0
  5. package/dist/checks/bind.js +100 -0
  6. package/dist/checks/board.js +107 -0
  7. package/dist/checks/broker.js +439 -0
  8. package/dist/checks/browser.js +275 -0
  9. package/dist/checks/browserpool.js +221 -0
  10. package/dist/checks/cache.js +710 -0
  11. package/dist/checks/child.js +211 -0
  12. package/dist/checks/command.js +267 -0
  13. package/dist/checks/commands.js +219 -0
  14. package/dist/checks/connect.js +79 -0
  15. package/dist/checks/connections.js +42 -0
  16. package/dist/checks/copy.js +320 -0
  17. package/dist/checks/dbpool.js +710 -0
  18. package/dist/checks/doctor.js +649 -0
  19. package/dist/checks/envPolicy.js +212 -0
  20. package/dist/checks/envrules.js +322 -0
  21. package/dist/checks/evidence.js +114 -0
  22. package/dist/checks/fsretry.js +29 -0
  23. package/dist/checks/guard.js +188 -0
  24. package/dist/checks/guardrails.js +155 -0
  25. package/dist/checks/importlegacy.js +300 -0
  26. package/dist/checks/install.js +150 -0
  27. package/dist/checks/live.js +76 -0
  28. package/dist/checks/lock.js +207 -0
  29. package/dist/checks/login.js +203 -0
  30. package/dist/checks/mailguard-hook.cjs +216 -0
  31. package/dist/checks/mailguard.js +98 -0
  32. package/dist/checks/paths.js +85 -0
  33. package/dist/checks/preview.js +577 -0
  34. package/dist/checks/profiles.js +110 -0
  35. package/dist/checks/provision.js +175 -0
  36. package/dist/checks/recipe.js +246 -0
  37. package/dist/checks/redact.js +52 -0
  38. package/dist/checks/registry.js +548 -0
  39. package/dist/checks/repoenv.js +162 -0
  40. package/dist/checks/roots.js +144 -0
  41. package/dist/checks/routing.js +68 -0
  42. package/dist/checks/run.js +443 -0
  43. package/dist/checks/sandbox.js +208 -0
  44. package/dist/checks/scheduler.js +383 -0
  45. package/dist/checks/scope.js +383 -0
  46. package/dist/checks/sealedNames.js +39 -0
  47. package/dist/checks/secrets.js +452 -0
  48. package/dist/checks/secretscmd.js +80 -0
  49. package/dist/checks/service.js +589 -0
  50. package/dist/checks/signin.js +79 -0
  51. package/dist/checks/singleflight.js +307 -0
  52. package/dist/checks/store.js +92 -0
  53. package/dist/checks/surface.js +124 -0
  54. package/dist/checks/types.js +2 -0
  55. package/dist/checks/warm.js +342 -0
  56. package/dist/checks/watch.js +228 -0
  57. package/dist/checks/wire.js +87 -0
  58. package/dist/cli.js +18 -1
  59. package/dist/hooks/adapters.js +3 -1
  60. package/dist/hooks/call.js +6 -1
  61. package/dist/hooks/modVersion.js +26 -0
  62. package/package.json +2 -2
@@ -0,0 +1,710 @@
1
+ // WHAT A RUN MAY REUSE FROM AN EARLIER RUN: THE DEPENDENCIES OF A LOCKFILE AND A FINISHED BUILD, KEPT PER REGISTRATION (cacheDirFor(org, name) in paths.ts).
2
+ // Entries are written into a folder of their own (<id>.tmp-<pid>-<time>) and renamed into place whole, so a reader never sees half of one. An entry holds
3
+ // manifest.json (every file with its size and, for builds, its sha256), a .complete note and a `use` folder of in-use markers. A restore checks the content
4
+ // of every file against the manifest first and refuses a damaged entry (it is moved aside and never served again). Files come back as copies, or as hard
5
+ // links on the same volume with the shared files set read-only, so a write through a restored file fails loudly instead of changing the cache.
6
+ // Nothing here follows a link: a link is recreated as a link, a path with `..` is skipped, and a write never goes through a link in the destination.
7
+ // The heavy walks (snapshot, publish, verify, restore) are generators that yield every few hundred files: the `...Async` forms let the event loop run between
8
+ // batches (the single-flight heartbeat and the board beat must never starve while a big tree is copied); the plain forms run the same code to the end.
9
+ // -- DEP-MODULES -- //
10
+ import { createHash, randomBytes } from 'node:crypto';
11
+ import { chmodSync, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs';
12
+ import { dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
13
+ // -- MODULES -- //
14
+ import { pidAlive } from './lock.js';
15
+ /** A kept entry whose content no longer matches its manifest. It has been moved aside; the caller builds or installs instead. */
16
+ export class CacheCorruptError extends Error {
17
+ code = 'CACHE_CORRUPT';
18
+ constructor(what) {
19
+ super(`A kept cache entry is damaged (${what}) and was set aside.`);
20
+ this.name = 'CacheCorruptError';
21
+ }
22
+ }
23
+ // -- CONSTANTS -- //
24
+ export const KEEP_BUILDS = 5;
25
+ export const KEEP_DEPS = 3;
26
+ /** An entry used (restored from, or marked) this recently is never removed. */
27
+ const RECENT_USE_MS = 10 * 60_000;
28
+ const TMP_MAX_AGE_MS = 60 * 60_000;
29
+ const DAY_MS = 86_400_000;
30
+ /** A use marker older than this is ignored even when its process id is alive (ids are reused; a run lasts 40 minutes at most). */
31
+ const USE_MARKER_MAX_MS = 6 * 60 * 60_000;
32
+ const SKIP_DIRS = new Set(['.git', 'node_modules']);
33
+ const KEY = /^[a-f0-9]{16,64}$/;
34
+ const PORT_FREE_KEYS = new Set(['PORT', 'HOST']);
35
+ const SECRET_KEY = /(SECRET|PASSWORD|PASSWD|TOKEN|PRIVATE_?KEY|API_?KEY|SERVICE_?ROLE|CREDENTIAL|ACCESS_?KEY)/i;
36
+ const DB_KEY = /(DATABASE|DB|POSTGRES|POSTGRESQL|MYSQL|MONGO|REDIS)_?(URL|URI)$|^(DIRECT_URL|PG(HOST|PORT|USER|PASSWORD|DATABASE))$|^DB_(HOST|PORT|USER|PASSWORD|NAME)$/i;
37
+ const SELF_KEY = /(^|_)(BASE_?URL|APP_URL|SITE_URL|PUBLIC_URL|ORIGIN|SELF_URL)$|(^|_)PORT$/i;
38
+ const LOOPBACK_PORT = /(127\.0\.0\.1|localhost|\[::1\]|0\.0\.0\.0|host\.docker\.internal):\d{2,5}/i;
39
+ const PLACEHOLDER = /\{(port|baseUrl)\}/i;
40
+ const DB_SCHEME = /\b(postgres(ql)?|mysql|mongodb(\+srv)?|redis|rediss):\/\//i;
41
+ const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/;
42
+ // -- FUNCTIONS -- //
43
+ const sha = (text) => createHash('sha256').update(text).digest('hex');
44
+ /** How many files, or bytes, one batch may handle before it gives the event loop a turn. */
45
+ const BATCH_FILES = 50;
46
+ const BATCH_BYTES = 8 * 1024 * 1024;
47
+ /** A batch also ends after this long, whatever it holds: a file system with a scanner in front of it takes milliseconds per file. */
48
+ const BATCH_MS = 10;
49
+ /** Counts work and says when a batch is full (by files, by bytes or by time). */
50
+ function pacer() {
51
+ let files = 0;
52
+ let bytes = 0;
53
+ let since = Date.now();
54
+ return (size = 0) => {
55
+ files++;
56
+ bytes += size;
57
+ if (files < BATCH_FILES && bytes < BATCH_BYTES && Date.now() - since < BATCH_MS)
58
+ return false;
59
+ files = 0;
60
+ bytes = 0;
61
+ since = Date.now();
62
+ return true;
63
+ };
64
+ }
65
+ /** Runs a generator to its end without ever giving the event loop a turn (the plain forms of the functions below). */
66
+ function runSync(g) {
67
+ let r = g.next();
68
+ while (!r.done)
69
+ r = g.next();
70
+ return r.value;
71
+ }
72
+ /** Runs a generator and gives the event loop a turn at every yield. */
73
+ async function runAsync(g) {
74
+ let r = g.next();
75
+ while (!r.done) {
76
+ await new Promise((resolveTurn) => setImmediate(resolveTurn));
77
+ r = g.next();
78
+ }
79
+ return r.value;
80
+ }
81
+ const shaFile = (file) => createHash('sha256').update(readFileSync(file)).digest('hex');
82
+ // -- KEYS -- //
83
+ /** The key of an installed set of dependencies: the lockfile and everything that changes what an install makes on this machine. */
84
+ export function depsKey(i) {
85
+ return sha(JSON.stringify({
86
+ kind: 'deps',
87
+ lockHash: i.lockHash,
88
+ node: i.node,
89
+ platform: i.platform,
90
+ arch: i.arch,
91
+ pm: i.pm,
92
+ pmVersion: i.pmVersion
93
+ }));
94
+ }
95
+ /** The id of a copied tree: the commit with the hash of the uncommitted changes, or the content hash of a folder that is not a git repo. Null when neither is known. */
96
+ export function treeId(i) {
97
+ if (i.commit)
98
+ return sha(JSON.stringify({ git: i.commit, diff: i.diffHash ?? '' }));
99
+ if (i.contentHash)
100
+ return sha(JSON.stringify({ content: i.contentHash }));
101
+ return null;
102
+ }
103
+ /** Why a build must not be kept, or null when it may be. A kept build must not carry a value that belongs to one run (its port, its address, a database, a secret). */
104
+ export function uncacheableReason(i) {
105
+ const build = i.build?.trim();
106
+ if (!build)
107
+ return 'there is no build command';
108
+ const secrets = (i.secretValues ?? []).filter((v) => typeof v === 'string' && v.length >= 4);
109
+ const names = new Set(i.envNames ?? []);
110
+ const holdsSecret = (text) => secrets.some((s) => text.includes(s));
111
+ if (PLACEHOLDER.test(build))
112
+ return 'the build command holds a port or base url placeholder';
113
+ if (LOOPBACK_PORT.test(build) || /--port\b/.test(build))
114
+ return 'the build command holds a port';
115
+ if (/\b(DATABASE_URL|DB_URL|POSTGRES_URL)\b/i.test(build) || DB_SCHEME.test(build))
116
+ return 'the build command holds a database url';
117
+ if (holdsSecret(build))
118
+ return 'the build command holds a secret';
119
+ for (const [k, v] of Object.entries(i.env ?? {})) {
120
+ if (PORT_FREE_KEYS.has(k))
121
+ continue;
122
+ if (names.has(k))
123
+ return `the setting ${k} belongs to one run`;
124
+ if (SECRET_KEY.test(k))
125
+ return `the setting ${k} is a secret`;
126
+ if (DB_KEY.test(k))
127
+ return `the setting ${k} is a database setting`;
128
+ if (SELF_KEY.test(k))
129
+ return `the setting ${k} is an address or a port`;
130
+ const value = typeof v === 'string' ? v : '';
131
+ if (PLACEHOLDER.test(value))
132
+ return `the setting ${k} holds a port or base url placeholder`;
133
+ if (LOOPBACK_PORT.test(value))
134
+ return `the setting ${k} holds a local address with a port`;
135
+ if (DB_SCHEME.test(value))
136
+ return `the setting ${k} holds a database url`;
137
+ if (JWT.test(value) || holdsSecret(value))
138
+ return `the setting ${k} holds a secret`;
139
+ }
140
+ return null;
141
+ }
142
+ /** The key of a finished build, or null when it must not be kept (see uncacheableReason). */
143
+ export function buildKey(i) {
144
+ if (uncacheableReason(i) !== null)
145
+ return null;
146
+ const values = Object.entries(i.env ?? {})
147
+ .filter(([k]) => !PORT_FREE_KEYS.has(k))
148
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
149
+ return sha(JSON.stringify({
150
+ kind: 'build',
151
+ org: i.org,
152
+ name: i.name,
153
+ treeId: i.treeId,
154
+ surface: i.surface,
155
+ build: i.build,
156
+ node: i.node ?? process.version,
157
+ platform: i.platform ?? process.platform,
158
+ arch: i.arch ?? process.arch,
159
+ appDir: i.appDir ?? '',
160
+ envHash: sha(JSON.stringify(values))
161
+ }));
162
+ }
163
+ // -- ENTRIES -- //
164
+ function entryDir(cacheRoot, kind, key) {
165
+ if (!KEY.test(key))
166
+ throw new Error('The cache key is not a plain key.');
167
+ return join(cacheRoot, kind, key);
168
+ }
169
+ /** The folder of a kept entry (it may not exist). */
170
+ export const buildEntryDir = (cacheRoot, key) => entryDir(cacheRoot, 'builds', key);
171
+ export const depsEntryDir = (cacheRoot, key) => entryDir(cacheRoot, 'deps', key);
172
+ /** True when a finished entry exists for this key. */
173
+ export const hasEntry = (cacheRoot, kind, key) => existsSync(join(entryDir(cacheRoot, kind, key), '.complete'));
174
+ const unsafePath = (p) => !p ||
175
+ isAbsolute(p) ||
176
+ /^[A-Za-z]:/.test(p) ||
177
+ p.includes('\0') ||
178
+ p.split(/[\\/]/).includes('..');
179
+ function removeEntry(path) {
180
+ const tries = () => rmSync(path, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
181
+ try {
182
+ tries();
183
+ return true;
184
+ }
185
+ catch {
186
+ makeWritable(path);
187
+ try {
188
+ tries();
189
+ return true;
190
+ }
191
+ catch {
192
+ return false;
193
+ }
194
+ }
195
+ }
196
+ /** Sets every file below a folder writable again (restored hard links are read-only), without following links. */
197
+ function makeWritable(path) {
198
+ let st;
199
+ try {
200
+ st = lstatSync(path);
201
+ }
202
+ catch {
203
+ return;
204
+ }
205
+ if (st.isSymbolicLink())
206
+ return;
207
+ try {
208
+ chmodSync(path, st.isDirectory() ? 0o700 : 0o600);
209
+ }
210
+ catch {
211
+ /* Best effort */
212
+ }
213
+ if (!st.isDirectory())
214
+ return;
215
+ for (const n of readdirSync(path))
216
+ makeWritable(join(path, n));
217
+ }
218
+ const touch = (path) => {
219
+ try {
220
+ const now = new Date();
221
+ utimesSync(path, now, now);
222
+ }
223
+ catch {
224
+ /* An entry that cannot be touched is still usable */
225
+ }
226
+ };
227
+ function readManifest(dir) {
228
+ try {
229
+ const v = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8'));
230
+ if (!v || typeof v !== 'object')
231
+ return null;
232
+ const m = v;
233
+ if (m.version !== 1 || !Array.isArray(m.entries))
234
+ return null;
235
+ for (const e of m.entries) {
236
+ if (!e || typeof e.p !== 'string' || !['f', 'l', 'd'].includes(e.t))
237
+ return null;
238
+ }
239
+ return m;
240
+ }
241
+ catch {
242
+ return null;
243
+ }
244
+ }
245
+ /** Moves a damaged entry aside under a name eviction removes, so it is never served again. */
246
+ function quarantine(dir) {
247
+ try {
248
+ renameSync(dir, `${dir}.tmp-bad-${process.pid}-${Date.now()}`);
249
+ }
250
+ catch {
251
+ removeEntry(dir);
252
+ }
253
+ }
254
+ // -- SNAPSHOT AND PUBLISH -- //
255
+ /** Every regular file under a folder (not .git, not node_modules, no link) as relative path to "size:modified". */
256
+ export function snapshot(dir) {
257
+ return runSync(snapshotGen(dir));
258
+ }
259
+ export const snapshotAsync = (dir) => runAsync(snapshotGen(dir));
260
+ function* snapshotGen(dir) {
261
+ const out = new Map();
262
+ const due = pacer();
263
+ function* walk(rel) {
264
+ for (const e of readdirSync(join(dir, rel), { withFileTypes: true })) {
265
+ const r = rel ? `${rel}/${e.name}` : e.name;
266
+ if (e.isDirectory()) {
267
+ if (!SKIP_DIRS.has(e.name))
268
+ yield* walk(r);
269
+ }
270
+ else if (e.isFile()) {
271
+ const s = lstatSync(join(dir, r));
272
+ out.set(r, `${s.size}:${Math.floor(s.mtimeMs)}`);
273
+ if (due())
274
+ yield;
275
+ }
276
+ }
277
+ }
278
+ yield* walk('');
279
+ return out;
280
+ }
281
+ function ensureRoot(dir) {
282
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
283
+ }
284
+ /** Renames a finished temporary entry into place. False when an entry already exists (somebody else published first). */
285
+ function finish(tmp, entry) {
286
+ writeFileSync(join(tmp, '.complete'), new Date().toISOString() + '\n');
287
+ try {
288
+ renameSync(tmp, entry);
289
+ return true;
290
+ }
291
+ catch {
292
+ removeEntry(tmp);
293
+ return false;
294
+ }
295
+ }
296
+ /**
297
+ * Keeps what a build made: the files that are new or changed since `before` (a snapshot taken before the build). Returns false when there was nothing to keep,
298
+ * an entry already exists, or the files changed while they were copied. The entry is made in a folder of its own and renamed into place.
299
+ */
300
+ export function publishBuild(cacheRoot, key, dir, before) {
301
+ return runSync(publishBuildGen(cacheRoot, key, dir, before));
302
+ }
303
+ export const publishBuildAsync = (cacheRoot, key, dir, before) => runAsync(publishBuildGen(cacheRoot, key, dir, before));
304
+ function* publishBuildGen(cacheRoot, key, dir, before) {
305
+ const entry = buildEntryDir(cacheRoot, key);
306
+ if (hasEntry(cacheRoot, 'builds', key))
307
+ return false;
308
+ const after = yield* snapshotGen(dir);
309
+ const made = [...after].filter(([rel, sig]) => before.get(rel) !== sig).map(([rel]) => rel);
310
+ if (!made.length)
311
+ return false;
312
+ const tmp = `${entry}.tmp-${process.pid}-${Date.now()}`;
313
+ const due = pacer();
314
+ try {
315
+ const entries = [];
316
+ for (const rel of made) {
317
+ if (unsafePath(rel))
318
+ continue;
319
+ const from = join(dir, rel);
320
+ const to = join(tmp, 'files', rel);
321
+ mkdirSync(dirname(to), { recursive: true });
322
+ copyFileSync(from, to);
323
+ chmodSync(to, 0o644);
324
+ const st = lstatSync(to);
325
+ entries.push({ p: rel, t: 'f', size: st.size, sha: shaFile(to) });
326
+ if (due(st.size))
327
+ yield;
328
+ }
329
+ writeFileSync(join(tmp, 'manifest.json'), JSON.stringify({ version: 1, entries }));
330
+ return finish(tmp, entry);
331
+ }
332
+ catch {
333
+ removeEntry(tmp);
334
+ return false;
335
+ }
336
+ }
337
+ // -- RESTORE -- //
338
+ /** Checks every entry of a manifest against the files kept under `root`. Throws CacheCorruptError at the first difference. */
339
+ function* verify(root, entries) {
340
+ const due = pacer();
341
+ for (const e of entries) {
342
+ // Folders and links live only in the manifest and are made again on restore
343
+ if (e.t !== 'f' || unsafePath(e.p))
344
+ continue;
345
+ const full = join(root, e.p);
346
+ let st;
347
+ try {
348
+ st = lstatSync(full);
349
+ }
350
+ catch {
351
+ throw new CacheCorruptError('a file is missing');
352
+ }
353
+ if (!st.isFile() || st.size !== e.size)
354
+ throw new CacheCorruptError('a file has another size');
355
+ if (e.sha && shaFile(full) !== e.sha)
356
+ throw new CacheCorruptError('a file has other content');
357
+ if (due(st.size))
358
+ yield;
359
+ }
360
+ }
361
+ /** Makes the folders above `rel` inside `root`, refusing to step through a link that is already there. */
362
+ function ensureParent(root, rel) {
363
+ let at = root;
364
+ for (const seg of posix.dirname(rel.replace(/\\/g, '/')).split('/')) {
365
+ if (seg === '.' || seg === '')
366
+ continue;
367
+ at = join(at, seg);
368
+ let st;
369
+ try {
370
+ st = lstatSync(at);
371
+ }
372
+ catch {
373
+ mkdirSync(at, { mode: 0o755 });
374
+ continue;
375
+ }
376
+ if (st.isSymbolicLink())
377
+ throw new Error('Refusing to write through a link in the destination.');
378
+ if (!st.isDirectory())
379
+ throw new Error('A file is in the way of a folder in the destination.');
380
+ }
381
+ }
382
+ function clearTarget(path) {
383
+ let st;
384
+ try {
385
+ st = lstatSync(path);
386
+ }
387
+ catch {
388
+ return;
389
+ }
390
+ if (st.isDirectory() && !st.isSymbolicLink())
391
+ throw new Error('A folder is in the way of a file in the destination.');
392
+ unlinkSync(path);
393
+ }
394
+ /** Brings the entries of `srcRoot` into `destRoot`: files as hard links (read-only) when allowed and possible, else copies; links as links. */
395
+ function* materialize(srcRoot, destRoot, entries, link, linkBoundary) {
396
+ ensureRoot(destRoot);
397
+ const due = pacer();
398
+ let linked = 0;
399
+ let copied = 0;
400
+ let canLink = link === 'auto';
401
+ const order = (e) => (e.t === 'd' ? 0 : e.t === 'f' ? 1 : 2);
402
+ for (const e of [...entries].sort((a, b) => order(a) - order(b))) {
403
+ if (unsafePath(e.p))
404
+ continue;
405
+ const to = join(destRoot, e.p);
406
+ ensureParent(destRoot, e.p);
407
+ if (e.t === 'd') {
408
+ if (!existsSyncNoFollow(to))
409
+ mkdirSync(to, { mode: 0o755 });
410
+ }
411
+ else if (e.t === 'l') {
412
+ // A dependency link resolves inside the restoring project only: no absolute target, no escape with ..
413
+ if (linkBoundary &&
414
+ (isAbsolute(e.to ?? '') || !insideDir(linkBoundary, resolve(dirname(to), e.to ?? ''))))
415
+ throw new CacheCorruptError('a link points out of the project');
416
+ clearTarget(to);
417
+ // A folder link on Windows is a junction, which needs an absolute target made from where it now lives
418
+ if (e.dir && process.platform === 'win32')
419
+ symlinkSync(resolve(dirname(to), e.to ?? ''), to, 'junction');
420
+ else
421
+ symlinkSync(e.to ?? '', to, e.dir ? 'dir' : 'file');
422
+ }
423
+ else {
424
+ const from = join(srcRoot, e.p);
425
+ clearTarget(to);
426
+ let done = false;
427
+ if (canLink) {
428
+ try {
429
+ const mode = lstatSync(from).mode & 0o555;
430
+ chmodSync(from, mode || 0o444);
431
+ linkSync(from, to);
432
+ done = true;
433
+ linked++;
434
+ }
435
+ catch (err) {
436
+ // A different volume or a file system without links: copy this and the rest
437
+ const code = err.code;
438
+ if (code === 'EXDEV' || code === 'ENOTSUP' || code === 'EPERM' || code === 'EMLINK')
439
+ canLink = false;
440
+ }
441
+ }
442
+ if (!done) {
443
+ copyFileSync(from, to);
444
+ chmodSync(to, (lstatSync(from).mode & 0o777) | 0o600);
445
+ copied++;
446
+ }
447
+ }
448
+ if (due(e.size ?? 0))
449
+ yield;
450
+ }
451
+ return { files: entries.filter((e) => e.t === 'f').length, linked, copied };
452
+ }
453
+ const existsSyncNoFollow = (path) => {
454
+ try {
455
+ lstatSync(path);
456
+ return true;
457
+ }
458
+ catch {
459
+ return false;
460
+ }
461
+ };
462
+ function* restoreEntry(entry, sub, dest, link, linkBoundary) {
463
+ if (!existsSync(join(entry, '.complete')))
464
+ return null;
465
+ const release = acquireUse(entry);
466
+ try {
467
+ const manifest = readManifest(entry);
468
+ if (!manifest) {
469
+ quarantine(entry);
470
+ throw new CacheCorruptError('the manifest is unreadable');
471
+ }
472
+ const root = join(entry, sub);
473
+ try {
474
+ yield* verify(root, manifest.entries);
475
+ }
476
+ catch (e) {
477
+ if (e instanceof CacheCorruptError)
478
+ quarantine(entry);
479
+ throw e;
480
+ }
481
+ touch(entry);
482
+ return yield* materialize(root, dest, manifest.entries, link, linkBoundary);
483
+ }
484
+ finally {
485
+ release();
486
+ }
487
+ }
488
+ /** Restores a kept build into `dest` (the folder the build ran in). Null when there is no entry. Throws CacheCorruptError for a damaged one. */
489
+ export function restoreBuild(cacheRoot, key, dest, opts = {}) {
490
+ return runSync(restoreEntry(buildEntryDir(cacheRoot, key), 'files', dest, opts.link ?? 'auto'));
491
+ }
492
+ export const restoreBuildAsync = (cacheRoot, key, dest, opts = {}) => runAsync(restoreEntry(buildEntryDir(cacheRoot, key), 'files', dest, opts.link ?? 'auto'));
493
+ // -- DEPENDENCIES -- //
494
+ /** True when `path` is `base` or lies below it (both made absolute first). No link is followed. */
495
+ function insideDir(base, path) {
496
+ const rel = relative(resolve(base), resolve(path));
497
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
498
+ }
499
+ /**
500
+ * Copies a dependency tree into the entry and lists it. Files are copied, folders made, and a link is NOT copied: it is recorded in the manifest as a
501
+ * link relative to its own folder and made again on restore (Windows junctions hold an absolute target, which would point at the old project).
502
+ * A link must resolve inside the project (the folder holding node_modules): a workspace link to packages/x is recreated relative, so it resolves into the
503
+ * restoring run's own copy. Returns null when a link points outside the project, because such a tree cannot be moved.
504
+ */
505
+ function* copyTree(srcRoot, dstRoot) {
506
+ const out = [];
507
+ const due = pacer();
508
+ // A link may point anywhere inside the project (the folder holding node_modules), for example a yarn workspace package; never outside it
509
+ const rootAbs = resolve(dirname(srcRoot));
510
+ let ok = true;
511
+ mkdirSync(dstRoot, { recursive: true });
512
+ function* walk(rel) {
513
+ for (const d of readdirSync(join(srcRoot, rel), { withFileTypes: true })) {
514
+ const r = rel ? `${rel}/${d.name}` : d.name;
515
+ const from = join(srcRoot, r);
516
+ const st = lstatSync(from);
517
+ if (st.isSymbolicLink()) {
518
+ const raw = readlinkSync(from).replace(/^\\\\\?\\/, '');
519
+ const abs = isAbsolute(raw) ? resolve(raw) : resolve(dirname(from), raw);
520
+ if (!insideDir(rootAbs, abs))
521
+ ok = false;
522
+ let dir = false;
523
+ try {
524
+ dir = statSync(abs).isDirectory();
525
+ }
526
+ catch {
527
+ /* A link to nothing is kept as a file link */
528
+ }
529
+ out.push({ p: r, t: 'l', to: relative(dirname(from), abs).replace(/\\/g, '/'), dir });
530
+ }
531
+ else if (st.isDirectory()) {
532
+ mkdirSync(join(dstRoot, r), { recursive: true });
533
+ out.push({ p: r, t: 'd' });
534
+ yield* walk(r);
535
+ }
536
+ else if (st.isFile()) {
537
+ copyFileSync(from, join(dstRoot, r));
538
+ out.push({ p: r, t: 'f', size: st.size });
539
+ if (due(st.size))
540
+ yield;
541
+ }
542
+ }
543
+ }
544
+ yield* walk('');
545
+ return ok ? out : null;
546
+ }
547
+ /**
548
+ * Keeps the node_modules of `projectDir` under this key. Returns false when there is none, an entry exists, or a link in it points out of the tree
549
+ * (such a tree cannot be moved). Files are checked by size on restore; the dependency tree is too large to hash on every run.
550
+ */
551
+ export function publishDeps(cacheRoot, key, projectDir) {
552
+ return runSync(publishDepsGen(cacheRoot, key, projectDir));
553
+ }
554
+ export const publishDepsAsync = (cacheRoot, key, projectDir) => runAsync(publishDepsGen(cacheRoot, key, projectDir));
555
+ function* publishDepsGen(cacheRoot, key, projectDir) {
556
+ const entry = depsEntryDir(cacheRoot, key);
557
+ if (hasEntry(cacheRoot, 'deps', key))
558
+ return false;
559
+ const src = join(projectDir, 'node_modules');
560
+ try {
561
+ if (!lstatSync(src).isDirectory())
562
+ return false;
563
+ }
564
+ catch {
565
+ return false;
566
+ }
567
+ const tmp = `${entry}.tmp-${process.pid}-${Date.now()}`;
568
+ try {
569
+ mkdirSync(tmp, { recursive: true });
570
+ const entries = yield* copyTree(src, join(tmp, 'node_modules'));
571
+ if (!entries) {
572
+ removeEntry(tmp);
573
+ return false;
574
+ }
575
+ writeFileSync(join(tmp, 'manifest.json'), JSON.stringify({ version: 1, entries }));
576
+ return finish(tmp, entry);
577
+ }
578
+ catch {
579
+ removeEntry(tmp);
580
+ return false;
581
+ }
582
+ }
583
+ /** Restores kept dependencies as `<destDir>/node_modules`. Null when there is no entry. Throws CacheCorruptError for a damaged one. */
584
+ export function restoreDeps(cacheRoot, key, destDir, opts = {}) {
585
+ return runSync(restoreEntry(depsEntryDir(cacheRoot, key), 'node_modules', join(destDir, 'node_modules'), opts.link ?? 'auto', destDir));
586
+ }
587
+ export const restoreDepsAsync = (cacheRoot, key, destDir, opts = {}) => runAsync(restoreEntry(depsEntryDir(cacheRoot, key), 'node_modules', join(destDir, 'node_modules'), opts.link ?? 'auto', destDir));
588
+ // -- IN-USE MARKERS AND EVICTION -- //
589
+ /** Marks an entry as being read right now. Eviction leaves it alone while the process lives. Call the returned function when done (safe twice). */
590
+ export function acquireUse(entry) {
591
+ const dir = join(entry, 'use');
592
+ const token = randomBytes(6).toString('hex');
593
+ const file = join(dir, `${process.pid}-${token}.json`);
594
+ try {
595
+ mkdirSync(dir, { recursive: true });
596
+ writeFileSync(file, JSON.stringify({
597
+ pid: process.pid,
598
+ start: new Date().toISOString(),
599
+ token
600
+ }));
601
+ touch(entry);
602
+ }
603
+ catch {
604
+ /* An entry that cannot be marked is protected by its recent use time */
605
+ }
606
+ let released = false;
607
+ return () => {
608
+ if (released)
609
+ return;
610
+ released = true;
611
+ rmSync(file, { force: true });
612
+ };
613
+ }
614
+ /** True when a live process has marked this entry. Markers of dead processes, or older than 6 hours, are removed. */
615
+ function inUse(entry) {
616
+ const dir = join(entry, 'use');
617
+ let names;
618
+ try {
619
+ names = readdirSync(dir);
620
+ }
621
+ catch {
622
+ return false;
623
+ }
624
+ let live = false;
625
+ for (const n of names) {
626
+ const file = join(dir, n);
627
+ try {
628
+ const m = JSON.parse(readFileSync(file, 'utf8'));
629
+ const age = Date.now() - Date.parse(m.start ?? '');
630
+ if (typeof m.pid === 'number' && pidAlive(m.pid) && age < USE_MARKER_MAX_MS)
631
+ live = true;
632
+ else
633
+ rmSync(file, { force: true });
634
+ }
635
+ catch {
636
+ // Written whole by the owner, so an unreadable marker is damaged: dropped once it is old
637
+ try {
638
+ if (Date.now() - lstatSync(file).mtimeMs > 60_000)
639
+ rmSync(file, { force: true });
640
+ }
641
+ catch {
642
+ /* Gone */
643
+ }
644
+ }
645
+ }
646
+ return live;
647
+ }
648
+ /**
649
+ * Removes the oldest kept builds and dependencies beyond the number kept, and anything older than `maxAgeDays`. Never removes an entry with a live in-use
650
+ * marker or one used in the last 10 minutes. Half-written `.tmp-` entries (and entries without a .complete note) go after an hour. Returns what was removed.
651
+ */
652
+ export function evict(cacheRoot, opts = {}) {
653
+ const now = opts.now ?? Date.now();
654
+ const maxAge = (opts.maxAgeDays ?? 14) * DAY_MS;
655
+ const removed = [];
656
+ for (const [kind, keep] of [
657
+ ['builds', opts.builds ?? KEEP_BUILDS],
658
+ ['deps', opts.deps ?? KEEP_DEPS]
659
+ ]) {
660
+ const dir = join(cacheRoot, kind);
661
+ let names;
662
+ try {
663
+ names = readdirSync(dir);
664
+ }
665
+ catch {
666
+ continue;
667
+ }
668
+ const done = [];
669
+ for (const name of names) {
670
+ const path = join(dir, name);
671
+ let idle;
672
+ try {
673
+ idle = now - lstatSync(path).mtimeMs;
674
+ }
675
+ catch {
676
+ continue;
677
+ }
678
+ if (name.includes('.tmp-') || !existsSync(join(path, '.complete'))) {
679
+ if (idle > TMP_MAX_AGE_MS && removeEntry(path))
680
+ removed.push(`${kind}/${name}`);
681
+ }
682
+ else {
683
+ done.push({ name, path, idle });
684
+ }
685
+ }
686
+ done.sort((a, b) => a.idle - b.idle);
687
+ done.forEach((e, i) => {
688
+ if (e.idle < RECENT_USE_MS || inUse(e.path))
689
+ return;
690
+ if ((i >= keep || e.idle > maxAge) && removeEntry(e.path))
691
+ removed.push(`${kind}/${e.name}`);
692
+ });
693
+ }
694
+ return { removed };
695
+ }
696
+ /**
697
+ * Runs a restore and turns a damaged entry into "nothing to restore" (null), so the caller installs or builds instead of failing the run. The entry was already set
698
+ * aside by the restore. Any other error is thrown.
699
+ */
700
+ export async function restoreOrNull(what, restore, log) {
701
+ try {
702
+ return await restore();
703
+ }
704
+ catch (e) {
705
+ if (!(e instanceof CacheCorruptError))
706
+ throw e;
707
+ log(`The kept ${what} was damaged and set aside; making it again.`);
708
+ return null;
709
+ }
710
+ }