@davidvornholt/standards 0.18.0 → 0.19.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/package.json +2 -1
- package/src/cli.ts +282 -72
- package/src/managed-files.ts +116 -14
- package/src/managed-path-ownership.ts +85 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davidvornholt/standards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -85,6 +85,7 @@
|
|
|
85
85
|
"src/github-settings-parse.ts",
|
|
86
86
|
"src/github-settings.ts",
|
|
87
87
|
"src/managed-files.ts",
|
|
88
|
+
"src/managed-path-ownership.ts",
|
|
88
89
|
"src/poller-approval.ts",
|
|
89
90
|
"src/poller-acknowledgements.ts",
|
|
90
91
|
"src/poller-claim.ts",
|
package/src/cli.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { execFileSync } from 'node:child_process';
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
15
|
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
16
|
-
import {
|
|
16
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
17
17
|
import { tmpdir } from 'node:os';
|
|
18
18
|
import { dirname, isAbsolute, join, posix, resolve } from 'node:path';
|
|
19
19
|
import process from 'node:process';
|
|
@@ -32,8 +32,16 @@ import { loadGithubSettings } from './github-settings';
|
|
|
32
32
|
import { isNonEmptyString, isRecord } from './github-settings-parse';
|
|
33
33
|
import {
|
|
34
34
|
findManagedFilesContainingBiomeDirectiveToken,
|
|
35
|
+
inspectManagedPath,
|
|
35
36
|
listManagedFiles,
|
|
37
|
+
type ManagedEntry,
|
|
38
|
+
managedEntryDigestInput,
|
|
39
|
+
writeManagedEntry,
|
|
36
40
|
} from './managed-files';
|
|
41
|
+
import {
|
|
42
|
+
interposedAncestor,
|
|
43
|
+
unlockedPathsUnder,
|
|
44
|
+
} from './managed-path-ownership';
|
|
37
45
|
import { runPollerCommand } from './poller-commands';
|
|
38
46
|
import { collectStructureProblems } from './structure-check';
|
|
39
47
|
import type { StructureProfile } from './structure-profile';
|
|
@@ -44,6 +52,10 @@ const DEFAULT_UPSTREAM = 'github:davidvornholt/standards';
|
|
|
44
52
|
// Characters of a sha256 hex digest shown in drift reports; enough to identify.
|
|
45
53
|
const HASH_PREVIEW_LENGTH = 12;
|
|
46
54
|
|
|
55
|
+
// Unmanaged paths named as examples in a collision refusal, which also reports
|
|
56
|
+
// the full count.
|
|
57
|
+
const COLLISION_SAMPLE = 3;
|
|
58
|
+
|
|
47
59
|
const GITHUB_PREFIX = 'github:';
|
|
48
60
|
const SKIP_GITHUB_CHECK_ENV = 'STANDARDS_SKIP_GITHUB_CHECK';
|
|
49
61
|
|
|
@@ -112,6 +124,38 @@ const assertSafeRelativePath = (path: string, label: string): void => {
|
|
|
112
124
|
}
|
|
113
125
|
};
|
|
114
126
|
|
|
127
|
+
// `C:/Windows` is absolute on Windows and relative to POSIX `isAbsolute`, so a
|
|
128
|
+
// drive-qualified target written with forward slashes would escape a consumer
|
|
129
|
+
// synced on Linux. Managed targets are POSIX-relative on every platform.
|
|
130
|
+
const DRIVE_QUALIFIED = /^[A-Za-z]:/u;
|
|
131
|
+
|
|
132
|
+
// A managed symlink is mirrored verbatim into every consumer, so its target
|
|
133
|
+
// must resolve inside the consumer repository rather than at the host
|
|
134
|
+
// filesystem around it. Returns the problem instead of throwing, so a command
|
|
135
|
+
// can report every offending link before it writes anything.
|
|
136
|
+
const symlinkTargetProblem = (rel: string, target: string): string | null => {
|
|
137
|
+
const resolved = posix.normalize(posix.join(posix.dirname(rel), target));
|
|
138
|
+
if (
|
|
139
|
+
target.length === 0 ||
|
|
140
|
+
posix.isAbsolute(target) ||
|
|
141
|
+
DRIVE_QUALIFIED.test(target) ||
|
|
142
|
+
target.includes('\\') ||
|
|
143
|
+
resolved.split('/').includes('..')
|
|
144
|
+
) {
|
|
145
|
+
return `canonical symlink ${rel} must point inside the repository: ${target}`;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const symlinkTargetProblems = (
|
|
151
|
+
entries: ReadonlyMap<string, ManagedEntry>,
|
|
152
|
+
): ReadonlyArray<string> =>
|
|
153
|
+
[...entries]
|
|
154
|
+
.map(([rel, entry]) =>
|
|
155
|
+
entry.kind === 'symlink' ? symlinkTargetProblem(rel, entry.target) : null,
|
|
156
|
+
)
|
|
157
|
+
.filter((problem): problem is string => problem !== null);
|
|
158
|
+
|
|
115
159
|
const parseManifest = (raw: unknown): Manifest => {
|
|
116
160
|
if (typeof raw !== 'object' || raw === null) {
|
|
117
161
|
throw new Error('sync-standards.json must be a JSON object');
|
|
@@ -249,22 +293,147 @@ type MirrorResult = {
|
|
|
249
293
|
readonly updated: ReadonlyArray<string>;
|
|
250
294
|
readonly deleted: ReadonlyArray<string>;
|
|
251
295
|
readonly tampered: ReadonlyArray<string>;
|
|
296
|
+
// Locked paths the prune pass deliberately left on disk, each with the reason
|
|
297
|
+
// the operator needs in order to finish the job by hand.
|
|
298
|
+
readonly retained: ReadonlyArray<string>;
|
|
299
|
+
// Locked paths that sit below a managed symlink once this run has finished.
|
|
300
|
+
// Nothing is left on disk for the operator to act on: the lock entry is
|
|
301
|
+
// dropped, and the name now resolves through the link into content this run
|
|
302
|
+
// must not delete. Reported so the drop is not silent, never as an instruction.
|
|
303
|
+
readonly stale: ReadonlyArray<string>;
|
|
252
304
|
};
|
|
253
305
|
|
|
254
306
|
type MirrorOptions = {
|
|
255
|
-
readonly
|
|
256
|
-
readonly srcDir: string;
|
|
307
|
+
readonly upstream: ReadonlyMap<string, ManagedEntry>;
|
|
257
308
|
readonly consumer: string;
|
|
258
309
|
readonly previous: Record<string, string>;
|
|
259
310
|
readonly dryRun: boolean;
|
|
260
311
|
};
|
|
261
312
|
|
|
313
|
+
// Two things init/sync must settle before their first write: an escaping
|
|
314
|
+
// canonical link target, and a managed destination that is a directory holding
|
|
315
|
+
// paths the lock does not record. The lock is the only record of what this
|
|
316
|
+
// engine put in a consumer, so whether the destination itself is locked decides
|
|
317
|
+
// nothing — a consumer can replace a locked link with a directory of its own
|
|
318
|
+
// skills, and that directory is still consumer work. Both problems are decided
|
|
319
|
+
// from data already in hand, and both are reported together so one run surfaces
|
|
320
|
+
// every offending managed path.
|
|
321
|
+
const mirrorPreconditionProblems = async (
|
|
322
|
+
consumer: string,
|
|
323
|
+
upstream: ReadonlyMap<string, ManagedEntry>,
|
|
324
|
+
previous: Record<string, string>,
|
|
325
|
+
): Promise<ReadonlyArray<string>> => {
|
|
326
|
+
const locked = new Set(Object.keys(previous));
|
|
327
|
+
const collisions = await Promise.all(
|
|
328
|
+
[...upstream.keys()].map(async (rel) => {
|
|
329
|
+
const current = await inspectManagedPath(join(consumer, rel));
|
|
330
|
+
if (current?.kind !== 'directory') {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
const unlocked = await unlockedPathsUnder(consumer, rel, locked);
|
|
334
|
+
return unlocked.length === 0
|
|
335
|
+
? null
|
|
336
|
+
: `${rel} is a directory holding ${unlocked.length} path(s) this repository does not manage (${unlocked.slice(0, COLLISION_SAMPLE).join(', ')}); move or delete them, then re-run`;
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
return [
|
|
340
|
+
...symlinkTargetProblems(upstream),
|
|
341
|
+
...collisions.filter((problem): problem is string => problem !== null),
|
|
342
|
+
];
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const assertNoProblems = (
|
|
346
|
+
problems: ReadonlyArray<string>,
|
|
347
|
+
headline: string,
|
|
348
|
+
): void => {
|
|
349
|
+
if (problems.length > 0) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
[`${headline}:`, ...problems.map((problem) => ` - ${problem}`)].join(
|
|
352
|
+
'\n',
|
|
353
|
+
),
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
type PruneOutcome =
|
|
359
|
+
| { readonly kind: 'absent' }
|
|
360
|
+
| { readonly kind: 'delete'; readonly rel: string }
|
|
361
|
+
| { readonly kind: 'retain'; readonly note: string }
|
|
362
|
+
| { readonly kind: 'stale'; readonly note: string };
|
|
363
|
+
|
|
364
|
+
// Two things the prune pass must never do: resolve through a symlink it (or an
|
|
365
|
+
// older CLI's lock) put in the parent chain, and recursively delete a
|
|
366
|
+
// destination the consumer turned into a directory. Every answer is decided
|
|
367
|
+
// against the shape the mirror leaves behind, not the shape on disk right now,
|
|
368
|
+
// so `--dry-run` predicts what the matching real run does.
|
|
369
|
+
const pruneOutcome = async (
|
|
370
|
+
consumer: string,
|
|
371
|
+
rel: string,
|
|
372
|
+
planned: ReadonlyMap<string, ManagedEntry>,
|
|
373
|
+
): Promise<PruneOutcome> => {
|
|
374
|
+
const ancestor = await interposedAncestor(consumer, rel, planned);
|
|
375
|
+
if (ancestor?.kind === 'symlink') {
|
|
376
|
+
return {
|
|
377
|
+
kind: 'stale',
|
|
378
|
+
note: `${rel} (below the symlink ${ancestor.rel}; nothing was deleted through it)`,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
// Below a managed file the name resolves to nothing once the mirror has run,
|
|
382
|
+
// whether or not a directory still stands there while a dry run looks.
|
|
383
|
+
const current =
|
|
384
|
+
ancestor === null ? await inspectManagedPath(join(consumer, rel)) : null;
|
|
385
|
+
if (current === null) {
|
|
386
|
+
return { kind: 'absent' };
|
|
387
|
+
}
|
|
388
|
+
if (current.kind === 'directory') {
|
|
389
|
+
return {
|
|
390
|
+
kind: 'retain',
|
|
391
|
+
note: `${rel}: removed upstream but now a directory this repository does not manage; delete it yourself if you no longer want it`,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
return { kind: 'delete', rel };
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
// Remove the locked paths that vanished upstream. Retained paths are reported
|
|
398
|
+
// and left alone; the lock drops every removable key either way, so the next
|
|
399
|
+
// run is clean.
|
|
400
|
+
const prune = async (
|
|
401
|
+
consumer: string,
|
|
402
|
+
removable: ReadonlyArray<string>,
|
|
403
|
+
planned: ReadonlyMap<string, ManagedEntry>,
|
|
404
|
+
dryRun: boolean,
|
|
405
|
+
): Promise<{
|
|
406
|
+
readonly deleted: ReadonlyArray<string>;
|
|
407
|
+
readonly retained: ReadonlyArray<string>;
|
|
408
|
+
readonly stale: ReadonlyArray<string>;
|
|
409
|
+
}> => {
|
|
410
|
+
const outcomes = await Promise.all(
|
|
411
|
+
removable.map((rel) => pruneOutcome(consumer, rel, planned)),
|
|
412
|
+
);
|
|
413
|
+
const deleted = outcomes.flatMap((outcome) =>
|
|
414
|
+
outcome.kind === 'delete' ? [outcome.rel] : [],
|
|
415
|
+
);
|
|
416
|
+
const retained = outcomes.flatMap((outcome) =>
|
|
417
|
+
outcome.kind === 'retain' ? [outcome.note] : [],
|
|
418
|
+
);
|
|
419
|
+
const stale = outcomes.flatMap((outcome) =>
|
|
420
|
+
outcome.kind === 'stale' ? [outcome.note] : [],
|
|
421
|
+
);
|
|
422
|
+
if (!dryRun) {
|
|
423
|
+
// `pruneOutcome` decides what may be deleted; a directory takes the retain
|
|
424
|
+
// branch there and never reaches this call.
|
|
425
|
+
await Promise.all(
|
|
426
|
+
deleted.map((rel) => rm(join(consumer, rel), { force: true })),
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
return { deleted, retained, stale };
|
|
430
|
+
};
|
|
431
|
+
|
|
262
432
|
// Mirror managed files into the consumer, deleting any previously-locked file
|
|
263
433
|
// that no longer exists upstream (three-way reconcile against the lock). When
|
|
264
434
|
// `dryRun` is set nothing is written or deleted; the returned plan is reported.
|
|
265
435
|
const mirror = async ({
|
|
266
|
-
|
|
267
|
-
srcDir,
|
|
436
|
+
upstream,
|
|
268
437
|
consumer,
|
|
269
438
|
previous,
|
|
270
439
|
dryRun,
|
|
@@ -272,19 +441,23 @@ const mirror = async ({
|
|
|
272
441
|
for (const rel of Object.keys(previous)) {
|
|
273
442
|
assertSafeRelativePath(rel, 'sync-standards.lock file');
|
|
274
443
|
}
|
|
275
|
-
|
|
444
|
+
assertNoProblems(
|
|
445
|
+
await mirrorPreconditionProblems(consumer, upstream, previous),
|
|
446
|
+
'cannot mirror canonical files',
|
|
447
|
+
);
|
|
276
448
|
const next: Record<string, string> = {};
|
|
277
449
|
const created: Array<string> = [];
|
|
278
450
|
const updated: Array<string> = [];
|
|
279
451
|
const tampered: Array<string> = [];
|
|
280
452
|
await Promise.all(
|
|
281
|
-
[...upstream].map(async ([rel,
|
|
453
|
+
[...upstream].map(async ([rel, entry]) => {
|
|
282
454
|
const dest = join(consumer, rel);
|
|
283
|
-
const
|
|
284
|
-
const
|
|
285
|
-
const currentHash =
|
|
286
|
-
|
|
287
|
-
|
|
455
|
+
const hash = sha256(await managedEntryDigestInput(entry));
|
|
456
|
+
const current = await inspectManagedPath(dest);
|
|
457
|
+
const currentHash =
|
|
458
|
+
current === null
|
|
459
|
+
? null
|
|
460
|
+
: sha256(await managedEntryDigestInput(current));
|
|
288
461
|
const prev = previous[rel];
|
|
289
462
|
if (prev !== undefined && currentHash !== null && currentHash !== prev) {
|
|
290
463
|
tampered.push(rel);
|
|
@@ -296,70 +469,95 @@ const mirror = async ({
|
|
|
296
469
|
}
|
|
297
470
|
if (!dryRun) {
|
|
298
471
|
await mkdir(dirname(dest), { recursive: true });
|
|
299
|
-
|
|
472
|
+
// The precondition above proved every path inside this directory is one
|
|
473
|
+
// the lock already records, so replacing it restores the managed shape
|
|
474
|
+
// rather than destroying consumer work.
|
|
475
|
+
if (current?.kind === 'directory') {
|
|
476
|
+
await rm(dest, { recursive: true, force: true });
|
|
477
|
+
}
|
|
478
|
+
await writeManagedEntry(dest, entry);
|
|
300
479
|
}
|
|
301
480
|
next[rel] = hash;
|
|
302
481
|
}),
|
|
303
482
|
);
|
|
304
|
-
|
|
305
|
-
|
|
483
|
+
// `existsSync` would miss a broken link, so the prune pass lstats instead.
|
|
484
|
+
const { deleted, retained, stale } = await prune(
|
|
485
|
+
consumer,
|
|
486
|
+
Object.keys(previous).filter((rel) => !(rel in next)),
|
|
487
|
+
upstream,
|
|
488
|
+
dryRun,
|
|
306
489
|
);
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
490
|
+
return { files: next, created, updated, deleted, tampered, retained, stale };
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
// What a mirror reports, in the tense the run calls for: a dry run also
|
|
494
|
+
// announces the writes ahead of it, while a real sync stays quiet about them and
|
|
495
|
+
// speaks only of removals, lock entries it drops, and clobbered local edits. One
|
|
496
|
+
// list in both tenses so a dry run cannot describe an outcome differently from
|
|
497
|
+
// the real run it predicts.
|
|
498
|
+
const mirrorLines = (
|
|
499
|
+
result: MirrorResult,
|
|
500
|
+
dryRun: boolean,
|
|
501
|
+
): ReadonlyArray<string> => {
|
|
502
|
+
const tense = (would: string, did: string): string => (dryRun ? would : did);
|
|
503
|
+
const writes = dryRun
|
|
504
|
+
? [
|
|
505
|
+
...result.created.map((rel) => `would create ${rel}`),
|
|
506
|
+
...result.updated.map((rel) => `would update ${rel}`),
|
|
507
|
+
]
|
|
508
|
+
: [];
|
|
509
|
+
const tampered =
|
|
510
|
+
result.tampered.length === 0
|
|
511
|
+
? []
|
|
512
|
+
: [
|
|
513
|
+
`${tense('would overwrite', 'overwrote')} ${result.tampered.length} locally-modified canonical path(s): ${result.tampered.join(', ')}`,
|
|
514
|
+
];
|
|
515
|
+
return [
|
|
516
|
+
...writes,
|
|
517
|
+
...result.deleted.map(
|
|
518
|
+
(rel) => `${tense('would delete', 'deleted')} ${rel} (removed upstream)`,
|
|
519
|
+
),
|
|
520
|
+
...result.retained.map(
|
|
521
|
+
(note) => `${tense('would leave in place', 'left in place')} ${note}`,
|
|
522
|
+
),
|
|
523
|
+
...result.stale.map(
|
|
524
|
+
(note) =>
|
|
525
|
+
`${tense('would drop', 'dropped')} the stale lock entry ${note}`,
|
|
526
|
+
),
|
|
527
|
+
...tampered,
|
|
528
|
+
];
|
|
311
529
|
};
|
|
312
530
|
|
|
313
|
-
// Print what a mirror did (or, for a dry run, would do). Real syncs stay quiet
|
|
314
|
-
// about unchanged files and only announce deletions and clobbered local edits.
|
|
315
531
|
const reportMirror = (result: MirrorResult, dryRun: boolean): void => {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
console.log(` would create ${rel}`);
|
|
319
|
-
}
|
|
320
|
-
for (const rel of result.updated) {
|
|
321
|
-
console.log(` would update ${rel}`);
|
|
322
|
-
}
|
|
323
|
-
for (const rel of result.deleted) {
|
|
324
|
-
console.log(` would delete ${rel} (removed upstream)`);
|
|
325
|
-
}
|
|
326
|
-
if (result.tampered.length > 0) {
|
|
327
|
-
console.log(
|
|
328
|
-
` would overwrite ${result.tampered.length} locally-modified canonical file(s): ${result.tampered.join(', ')}`,
|
|
329
|
-
);
|
|
330
|
-
}
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
for (const rel of result.deleted) {
|
|
334
|
-
console.log(` deleted ${rel} (removed upstream)`);
|
|
335
|
-
}
|
|
336
|
-
if (result.tampered.length > 0) {
|
|
337
|
-
console.log(
|
|
338
|
-
` overwrote ${result.tampered.length} locally-modified canonical file(s): ${result.tampered.join(', ')}`,
|
|
339
|
-
);
|
|
532
|
+
for (const line of mirrorLines(result, dryRun)) {
|
|
533
|
+
console.log(` ${line}`);
|
|
340
534
|
}
|
|
341
535
|
};
|
|
342
536
|
|
|
537
|
+
// A retained or stale key leaves the lock, so the run rewrites `sync-standards.lock`
|
|
538
|
+
// even when it touches nothing on disk; neither may be summed into "no changes".
|
|
343
539
|
const reportDryRunSummary = (
|
|
344
540
|
result: MirrorResult,
|
|
345
541
|
generated: boolean,
|
|
346
542
|
): void => {
|
|
543
|
+
const unlocked = result.retained.length + result.stale.length;
|
|
347
544
|
const changes =
|
|
348
545
|
result.created.length +
|
|
349
546
|
result.updated.length +
|
|
350
547
|
result.deleted.length +
|
|
548
|
+
unlocked +
|
|
351
549
|
Number(generated);
|
|
352
550
|
console.log(
|
|
353
551
|
changes === 0
|
|
354
552
|
? 'dry run: already in sync; no changes'
|
|
355
|
-
: `dry run: ${result.created.length} to create, ${result.updated.length} to update, ${result.deleted.length} to delete, ${Number(generated)} to generate`,
|
|
553
|
+
: `dry run: ${result.created.length} to create, ${result.updated.length} to update, ${result.deleted.length} to delete, ${Number(generated)} to generate, ${unlocked} to drop from the lock without deleting`,
|
|
356
554
|
);
|
|
357
555
|
};
|
|
358
556
|
|
|
359
557
|
const seedTargets = (
|
|
360
558
|
srcDir: string,
|
|
361
559
|
seedDir: string,
|
|
362
|
-
): Promise<ReadonlyMap<string,
|
|
560
|
+
): Promise<ReadonlyMap<string, ManagedEntry>> => {
|
|
363
561
|
const root = join(srcDir, seedDir);
|
|
364
562
|
return listManagedFiles(root, ['.']);
|
|
365
563
|
};
|
|
@@ -371,27 +569,39 @@ const runInit = async (
|
|
|
371
569
|
): Promise<void> => {
|
|
372
570
|
const seeds = await seedTargets(src.dir, manifest.seedDir);
|
|
373
571
|
assertDisjoint(manifest.paths, [...seeds.keys()]);
|
|
572
|
+
const upstream = await listManagedFiles(src.dir, manifest.paths);
|
|
573
|
+
// Seeds are written before the mirror, so both halves of the payload must
|
|
574
|
+
// clear their preconditions here for `init` to refuse before writing anything.
|
|
575
|
+
assertNoProblems(
|
|
576
|
+
[
|
|
577
|
+
...symlinkTargetProblems(seeds),
|
|
578
|
+
...(await mirrorPreconditionProblems(consumer, upstream, {})),
|
|
579
|
+
],
|
|
580
|
+
'cannot initialize from this source',
|
|
581
|
+
);
|
|
374
582
|
const prospectiveDependabot = await prepareProspectiveDependabot(
|
|
375
|
-
|
|
376
|
-
src.dir,
|
|
583
|
+
upstream,
|
|
377
584
|
consumer,
|
|
378
585
|
seeds.get(DEPENDABOT_LOCAL_FILE) ?? null,
|
|
379
586
|
);
|
|
380
587
|
await Promise.all(
|
|
381
|
-
[...seeds].map(async ([rel,
|
|
588
|
+
[...seeds].map(async ([rel, entry]) => {
|
|
382
589
|
const dest = join(consumer, rel);
|
|
383
|
-
|
|
590
|
+
// A seed destination is kept whenever anything at all occupies it.
|
|
591
|
+
// `existsSync` reports a broken link as absent, so it would call this
|
|
592
|
+
// destination free and let the seed replace a link the consumer put
|
|
593
|
+
// there; `lstat` sees the link and keeps it.
|
|
594
|
+
if ((await inspectManagedPath(dest)) !== null) {
|
|
384
595
|
console.log(` kept ${rel} (already present)`);
|
|
385
596
|
return;
|
|
386
597
|
}
|
|
387
598
|
await mkdir(dirname(dest), { recursive: true });
|
|
388
|
-
await
|
|
599
|
+
await writeManagedEntry(dest, entry);
|
|
389
600
|
console.log(` seeded ${rel}`);
|
|
390
601
|
}),
|
|
391
602
|
);
|
|
392
603
|
const result = await mirror({
|
|
393
|
-
|
|
394
|
-
srcDir: src.dir,
|
|
604
|
+
upstream,
|
|
395
605
|
consumer,
|
|
396
606
|
previous: {},
|
|
397
607
|
dryRun: false,
|
|
@@ -404,7 +614,7 @@ const runInit = async (
|
|
|
404
614
|
files: result.files,
|
|
405
615
|
});
|
|
406
616
|
console.log(
|
|
407
|
-
`init complete: ${Object.keys(result.files).length} managed
|
|
617
|
+
`init complete: ${Object.keys(result.files).length} managed path(s) at ${src.sha}`,
|
|
408
618
|
);
|
|
409
619
|
};
|
|
410
620
|
|
|
@@ -416,16 +626,15 @@ const runSync = async (
|
|
|
416
626
|
): Promise<void> => {
|
|
417
627
|
const seeds = await seedTargets(src.dir, manifest.seedDir);
|
|
418
628
|
assertDisjoint(manifest.paths, [...seeds.keys()]);
|
|
629
|
+
const upstream = await listManagedFiles(src.dir, manifest.paths);
|
|
419
630
|
const prospectiveDependabot = await prepareProspectiveDependabot(
|
|
420
|
-
|
|
421
|
-
src.dir,
|
|
631
|
+
upstream,
|
|
422
632
|
consumer,
|
|
423
633
|
null,
|
|
424
634
|
);
|
|
425
635
|
const lock = await readLock(consumer);
|
|
426
636
|
const result = await mirror({
|
|
427
|
-
|
|
428
|
-
srcDir: src.dir,
|
|
637
|
+
upstream,
|
|
429
638
|
consumer,
|
|
430
639
|
previous: lock?.files ?? {},
|
|
431
640
|
dryRun,
|
|
@@ -446,7 +655,7 @@ const runSync = async (
|
|
|
446
655
|
files: result.files,
|
|
447
656
|
});
|
|
448
657
|
console.log(
|
|
449
|
-
`sync complete: ${Object.keys(result.files).length} managed
|
|
658
|
+
`sync complete: ${Object.keys(result.files).length} managed path(s) at ${src.sha}`,
|
|
450
659
|
);
|
|
451
660
|
};
|
|
452
661
|
|
|
@@ -468,10 +677,11 @@ const runCheck = async (consumer: string): Promise<boolean> => {
|
|
|
468
677
|
const results = await Promise.all(
|
|
469
678
|
Object.entries(lock.files).map(async ([rel, hash]) => {
|
|
470
679
|
const dest = join(consumer, rel);
|
|
471
|
-
|
|
680
|
+
const entry = await inspectManagedPath(dest);
|
|
681
|
+
if (entry === null) {
|
|
472
682
|
return ` missing: ${rel}`;
|
|
473
683
|
}
|
|
474
|
-
const current = sha256(await
|
|
684
|
+
const current = sha256(await managedEntryDigestInput(entry));
|
|
475
685
|
if (current !== hash) {
|
|
476
686
|
return ` modified: ${rel} (expected ${hash.slice(0, HASH_PREVIEW_LENGTH)}, found ${current.slice(0, HASH_PREVIEW_LENGTH)})`;
|
|
477
687
|
}
|
|
@@ -483,7 +693,7 @@ const runCheck = async (consumer: string): Promise<boolean> => {
|
|
|
483
693
|
await findManagedFilesContainingBiomeDirectiveToken(lockedFiles);
|
|
484
694
|
if (problems.length > 0) {
|
|
485
695
|
console.error(
|
|
486
|
-
`standards: ${problems.length} canonical
|
|
696
|
+
`standards: ${problems.length} canonical path(s) drifted from the last synced state:`,
|
|
487
697
|
);
|
|
488
698
|
console.error(problems.join('\n'));
|
|
489
699
|
console.error(
|
|
@@ -501,7 +711,7 @@ const runCheck = async (consumer: string): Promise<boolean> => {
|
|
|
501
711
|
}
|
|
502
712
|
if (problems.length === 0) {
|
|
503
713
|
console.log(
|
|
504
|
-
`standards: ${Object.keys(lock.files).length} canonical
|
|
714
|
+
`standards: ${Object.keys(lock.files).length} canonical path(s) match the last synced state`,
|
|
505
715
|
);
|
|
506
716
|
}
|
|
507
717
|
return problems.length === 0 && directiveFiles.length === 0;
|
|
@@ -591,14 +801,12 @@ type ProspectiveDependabot = {
|
|
|
591
801
|
// file. For init, an existing overlay wins; otherwise the overlay seed is what
|
|
592
802
|
// the command is about to install.
|
|
593
803
|
const prepareProspectiveDependabot = async (
|
|
594
|
-
|
|
595
|
-
srcDir: string,
|
|
804
|
+
incoming: ReadonlyMap<string, ManagedEntry>,
|
|
596
805
|
consumer: string,
|
|
597
|
-
localSeed:
|
|
806
|
+
localSeed: ManagedEntry | null,
|
|
598
807
|
): Promise<ProspectiveDependabot> => {
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
if (basePath === undefined) {
|
|
808
|
+
const base = incoming.get(DEPENDABOT_BASE_FILE);
|
|
809
|
+
if (base === undefined || base.kind !== 'file') {
|
|
602
810
|
throw new Error(
|
|
603
811
|
`source content must manage ${DEPENDABOT_BASE_FILE}; @davidvornholt/standards 0.10.1 requires a 0.10.1-compatible content ref`,
|
|
604
812
|
);
|
|
@@ -608,10 +816,12 @@ const prepareProspectiveDependabot = async (
|
|
|
608
816
|
);
|
|
609
817
|
const local =
|
|
610
818
|
existingLocal ??
|
|
611
|
-
(localSeed === null
|
|
819
|
+
(localSeed === null || localSeed.kind !== 'file'
|
|
820
|
+
? null
|
|
821
|
+
: await readFile(localSeed.absolutePath, 'utf8'));
|
|
612
822
|
const current = await readTextIfPresent(join(consumer, DEPENDABOT_FILE));
|
|
613
823
|
const sources = {
|
|
614
|
-
base: await readFile(
|
|
824
|
+
base: await readFile(base.absolutePath, 'utf8'),
|
|
615
825
|
local,
|
|
616
826
|
current,
|
|
617
827
|
};
|
package/src/managed-files.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
lstat,
|
|
3
|
+
readdir,
|
|
4
|
+
readFile,
|
|
5
|
+
readlink,
|
|
6
|
+
rm,
|
|
7
|
+
symlink,
|
|
8
|
+
writeFile,
|
|
9
|
+
} from 'node:fs/promises';
|
|
2
10
|
import { join, relative, sep } from 'node:path';
|
|
3
11
|
|
|
4
12
|
// Never mirrored, even under a managed directory path: build output, VCS
|
|
@@ -16,34 +24,76 @@ const directiveToken = Buffer.from(['biome', 'ignore'].join('-'));
|
|
|
16
24
|
|
|
17
25
|
const toPosix = (path: string): string => path.split(sep).join('/');
|
|
18
26
|
|
|
27
|
+
// A managed path is a file or a symlink. A symlink's identity is its target,
|
|
28
|
+
// never the content it resolves to: mirroring must reproduce the link itself,
|
|
29
|
+
// and following it would copy the whole target tree into every consumer.
|
|
30
|
+
export type ManagedEntry =
|
|
31
|
+
| { readonly kind: 'file'; readonly absolutePath: string }
|
|
32
|
+
| {
|
|
33
|
+
readonly kind: 'symlink';
|
|
34
|
+
readonly absolutePath: string;
|
|
35
|
+
readonly target: string;
|
|
36
|
+
}
|
|
37
|
+
// Only ever observed when inspecting a destination a consumer replaced; the
|
|
38
|
+
// upstream walk descends into directories instead of recording them.
|
|
39
|
+
| { readonly kind: 'directory'; readonly absolutePath: string };
|
|
40
|
+
|
|
41
|
+
// Built rather than written as an escape so this source file stays plain ASCII
|
|
42
|
+
// and greppable. File digests remain plain content hashes, so existing consumer
|
|
43
|
+
// locks keep validating; the markers only need to be unreachable content for a
|
|
44
|
+
// canonical file, and no text file begins with a NUL byte.
|
|
45
|
+
const nul = String.fromCharCode(0);
|
|
46
|
+
const SYMLINK_DIGEST_PREFIX = `${nul}standards-symlink${nul}`;
|
|
47
|
+
const DIRECTORY_DIGEST = `${nul}standards-directory${nul}`;
|
|
48
|
+
|
|
49
|
+
export const inspectManagedPath = async (
|
|
50
|
+
path: string,
|
|
51
|
+
): Promise<ManagedEntry | null> => {
|
|
52
|
+
const info = await lstat(path).catch(() => null);
|
|
53
|
+
if (info === null) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (info.isSymbolicLink()) {
|
|
57
|
+
return {
|
|
58
|
+
kind: 'symlink',
|
|
59
|
+
absolutePath: path,
|
|
60
|
+
target: toPosix(await readlink(path)),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (info.isDirectory()) {
|
|
64
|
+
return { kind: 'directory', absolutePath: path };
|
|
65
|
+
}
|
|
66
|
+
return { kind: 'file', absolutePath: path };
|
|
67
|
+
};
|
|
68
|
+
|
|
19
69
|
const walkManagedFiles = async (
|
|
20
70
|
path: string,
|
|
21
71
|
repositoryRoot: string,
|
|
22
|
-
files: Map<string,
|
|
72
|
+
files: Map<string, ManagedEntry>,
|
|
23
73
|
): Promise<void> => {
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
74
|
+
const entry = await inspectManagedPath(path);
|
|
75
|
+
if (entry === null) {
|
|
26
76
|
return;
|
|
27
77
|
}
|
|
28
|
-
if (
|
|
78
|
+
if (entry.kind === 'directory') {
|
|
29
79
|
const entries = await readdir(path);
|
|
30
80
|
await Promise.all(
|
|
31
81
|
entries
|
|
32
|
-
.filter((
|
|
33
|
-
.map((
|
|
34
|
-
walkManagedFiles(join(path,
|
|
82
|
+
.filter((name) => !IGNORED_DIRECTORIES.has(name))
|
|
83
|
+
.map((name) =>
|
|
84
|
+
walkManagedFiles(join(path, name), repositoryRoot, files),
|
|
35
85
|
),
|
|
36
86
|
);
|
|
37
87
|
return;
|
|
38
88
|
}
|
|
39
|
-
files.set(toPosix(relative(repositoryRoot, path)),
|
|
89
|
+
files.set(toPosix(relative(repositoryRoot, path)), entry);
|
|
40
90
|
};
|
|
41
91
|
|
|
42
92
|
export const listManagedFiles = async (
|
|
43
93
|
repositoryRoot: string,
|
|
44
94
|
paths: ReadonlyArray<string>,
|
|
45
|
-
): Promise<ReadonlyMap<string,
|
|
46
|
-
const files = new Map<string,
|
|
95
|
+
): Promise<ReadonlyMap<string, ManagedEntry>> => {
|
|
96
|
+
const files = new Map<string, ManagedEntry>();
|
|
47
97
|
await Promise.all(
|
|
48
98
|
paths.map((path) =>
|
|
49
99
|
walkManagedFiles(join(repositoryRoot, path), repositoryRoot, files),
|
|
@@ -52,12 +102,64 @@ export const listManagedFiles = async (
|
|
|
52
102
|
return files;
|
|
53
103
|
};
|
|
54
104
|
|
|
105
|
+
// What the lock hashes for an entry: file content, or the marker-prefixed link
|
|
106
|
+
// target. Retargeting a link therefore reads as drift the same way an edited
|
|
107
|
+
// file does.
|
|
108
|
+
export const managedEntryDigestInput = (
|
|
109
|
+
entry: ManagedEntry,
|
|
110
|
+
): Promise<Buffer> => {
|
|
111
|
+
if (entry.kind === 'symlink') {
|
|
112
|
+
return Promise.resolve(
|
|
113
|
+
Buffer.from(`${SYMLINK_DIGEST_PREFIX}${entry.target}`),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (entry.kind === 'directory') {
|
|
117
|
+
return Promise.resolve(Buffer.from(DIRECTORY_DIGEST));
|
|
118
|
+
}
|
|
119
|
+
return readFile(entry.absolutePath);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Plant one managed entry, replacing a file or a link that occupies the
|
|
123
|
+
// destination. Never recursive: a directory at the destination is unbounded
|
|
124
|
+
// consumer content, so removing it is the caller's decision to make explicitly
|
|
125
|
+
// once it has proven the path was engine-owned.
|
|
126
|
+
export const writeManagedEntry = async (
|
|
127
|
+
dest: string,
|
|
128
|
+
entry: ManagedEntry,
|
|
129
|
+
): Promise<void> => {
|
|
130
|
+
if (entry.kind === 'directory') {
|
|
131
|
+
throw new Error(`cannot mirror a directory as a managed path: ${dest}`);
|
|
132
|
+
}
|
|
133
|
+
const existing = await inspectManagedPath(dest);
|
|
134
|
+
if (existing?.kind === 'directory') {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`refusing to replace the directory ${dest} with a managed ${entry.kind}; move or delete it first`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
// `symlink` refuses an existing path, and writing a file through a stale link
|
|
140
|
+
// would clobber that link's target instead of replacing the managed path.
|
|
141
|
+
if (
|
|
142
|
+
existing !== null &&
|
|
143
|
+
(entry.kind === 'symlink' || existing.kind === 'symlink')
|
|
144
|
+
) {
|
|
145
|
+
await rm(dest, { force: true });
|
|
146
|
+
}
|
|
147
|
+
if (entry.kind === 'symlink') {
|
|
148
|
+
await symlink(entry.target, dest);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
await writeFile(dest, await readFile(entry.absolutePath));
|
|
152
|
+
};
|
|
153
|
+
|
|
55
154
|
export const findManagedFilesContainingBiomeDirectiveToken = async (
|
|
56
|
-
files: ReadonlyMap<string,
|
|
155
|
+
files: ReadonlyMap<string, ManagedEntry>,
|
|
57
156
|
): Promise<ReadonlyArray<string>> => {
|
|
58
157
|
const matches = await Promise.all(
|
|
59
|
-
[...files].map(async ([path,
|
|
60
|
-
|
|
158
|
+
[...files].map(async ([path, entry]) =>
|
|
159
|
+
entry.kind === 'file' &&
|
|
160
|
+
(await readFile(entry.absolutePath)).includes(directiveToken)
|
|
161
|
+
? path
|
|
162
|
+
: null,
|
|
61
163
|
),
|
|
62
164
|
);
|
|
63
165
|
return matches.filter((path): path is string => path !== null).sort();
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Ownership questions about a destination path, asked before init/sync writes
|
|
2
|
+
// to it or deletes it. Both answers come from the lock: it is the only record of
|
|
3
|
+
// what this engine put in a consumer, so anything absent from it is consumer
|
|
4
|
+
// work that a mirror must never destroy.
|
|
5
|
+
|
|
6
|
+
import { readdir } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { inspectManagedPath, type ManagedEntry } from './managed-files';
|
|
9
|
+
|
|
10
|
+
const collectPaths = async (
|
|
11
|
+
root: string,
|
|
12
|
+
rel: string,
|
|
13
|
+
found: Array<string>,
|
|
14
|
+
): Promise<void> => {
|
|
15
|
+
const entry = await inspectManagedPath(join(root, rel));
|
|
16
|
+
if (entry === null) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (entry.kind === 'directory') {
|
|
20
|
+
const names = await readdir(join(root, rel));
|
|
21
|
+
await Promise.all(
|
|
22
|
+
names.map((name) => collectPaths(root, `${rel}/${name}`, found)),
|
|
23
|
+
);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
found.push(rel);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Paths inside `rel` that the lock never recorded, so a caller can tell an
|
|
30
|
+
// engine-owned tree from consumer work. An older CLI that followed a canonical
|
|
31
|
+
// link materialized its whole target tree and locked every path in it; a
|
|
32
|
+
// consumer's own file under the same directory is absent from the lock. Nothing
|
|
33
|
+
// here follows a symlink: every entry is judged where it sits.
|
|
34
|
+
export const unlockedPathsUnder = async (
|
|
35
|
+
root: string,
|
|
36
|
+
rel: string,
|
|
37
|
+
locked: ReadonlySet<string>,
|
|
38
|
+
): Promise<ReadonlyArray<string>> => {
|
|
39
|
+
const found: Array<string> = [];
|
|
40
|
+
await collectPaths(root, rel, found);
|
|
41
|
+
return found.filter((path) => !locked.has(path)).sort();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// An ancestor that is not a directory, so nothing can sit below it under the
|
|
45
|
+
// name the lock recorded. The kind is part of the answer: below a symlink the
|
|
46
|
+
// name still resolves, to somebody else's content, while below a file it
|
|
47
|
+
// resolves to nothing at all.
|
|
48
|
+
export type InterposedAncestor = {
|
|
49
|
+
readonly rel: string;
|
|
50
|
+
readonly kind: 'file' | 'symlink';
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// The shallowest ancestor of `rel` inside `root` that is not a directory, or
|
|
54
|
+
// null when the whole parent chain is directories.
|
|
55
|
+
//
|
|
56
|
+
// `planned` is the payload this run mirrors, and it wins over the disk: every
|
|
57
|
+
// path in it has the kind upstream gives it once the mirror has finished, and
|
|
58
|
+
// the caller runs after the mirror. A dry run, which writes nothing, therefore
|
|
59
|
+
// reaches the same answer the matching real run does.
|
|
60
|
+
//
|
|
61
|
+
// A path whose parent chain crosses a link does not name what the caller thinks
|
|
62
|
+
// it names: reading or removing it reaches into the link's target tree, which
|
|
63
|
+
// is somebody else's content.
|
|
64
|
+
export const interposedAncestor = async (
|
|
65
|
+
root: string,
|
|
66
|
+
rel: string,
|
|
67
|
+
planned: ReadonlyMap<string, ManagedEntry>,
|
|
68
|
+
): Promise<InterposedAncestor | null> => {
|
|
69
|
+
const segments = rel.split('/').slice(0, -1);
|
|
70
|
+
const prefixes = segments.map((_, depth) =>
|
|
71
|
+
segments.slice(0, depth + 1).join('/'),
|
|
72
|
+
);
|
|
73
|
+
const entries = await Promise.all(
|
|
74
|
+
prefixes.map(
|
|
75
|
+
(prefix) => planned.get(prefix) ?? inspectManagedPath(join(root, prefix)),
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
for (const [index, prefix] of prefixes.entries()) {
|
|
79
|
+
const entry = entries[index] ?? null;
|
|
80
|
+
if (entry !== null && entry.kind !== 'directory') {
|
|
81
|
+
return { rel: prefix, kind: entry.kind };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
};
|