@davidvornholt/standards 0.1.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +14 -0
  3. package/package.json +32 -0
  4. package/src/cli.ts +662 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Vornholt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # @davidvornholt/standards
2
+
3
+ CLI for bootstrapping, synchronizing, and validating repositories that consume
4
+ [davidvornholt/standards](https://github.com/davidvornholt/standards).
5
+
6
+ ```sh
7
+ bunx @davidvornholt/standards init
8
+ standards sync
9
+ standards check
10
+ standards doctor
11
+ ```
12
+
13
+ See the standards repository README for the ownership model and adoption
14
+ workflow.
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@davidvornholt/standards",
3
+ "version": "0.1.0",
4
+ "description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "standards": "src/cli.ts"
9
+ },
10
+ "files": [
11
+ "LICENSE",
12
+ "README.md",
13
+ "src/cli.ts"
14
+ ],
15
+ "scripts": {
16
+ "lint": "biome check --error-on-warnings .",
17
+ "lint:fix": "biome check --write --error-on-warnings .",
18
+ "check-types": "tsc --noEmit",
19
+ "test": "bun test"
20
+ },
21
+ "devDependencies": {
22
+ "@davidvornholt/typescript-config": "workspace:*",
23
+ "@types/bun": "^1.3.14",
24
+ "typescript": "7.0.2"
25
+ },
26
+ "engines": {
27
+ "bun": ">=1.3.14"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ }
32
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,662 @@
1
+ #!/usr/bin/env bun
2
+
3
+ // Standards CLI. Mirrors upstream-owned ("bucket 1") files from the
4
+ // davidvornholt/standards template into a consumer repo and detects local
5
+ // tampering with them. See the standards repository README for the design.
6
+ //
7
+ // This script is intentionally zero-dependency (Bun + Node built-ins only) and
8
+ // does NOT use Effect: `bunx` must be able to execute the published package
9
+ // before a consumer has dependencies. That is the documented exception to the
10
+ // repo's Effect standard for standalone bootstrap tooling.
11
+
12
+ import { execFileSync } from 'node:child_process';
13
+ import { createHash } from 'node:crypto';
14
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
15
+ import {
16
+ cp,
17
+ mkdir,
18
+ readdir,
19
+ readFile,
20
+ rm,
21
+ stat,
22
+ writeFile,
23
+ } from 'node:fs/promises';
24
+ import { tmpdir } from 'node:os';
25
+ import {
26
+ dirname,
27
+ isAbsolute,
28
+ join,
29
+ normalize,
30
+ relative,
31
+ resolve,
32
+ sep,
33
+ } from 'node:path';
34
+ import process from 'node:process';
35
+
36
+ const DEFAULT_UPSTREAM = 'github:davidvornholt/standards';
37
+
38
+ // Characters of a sha256 hex digest shown in drift reports; enough to identify.
39
+ const HASH_PREVIEW_LENGTH = 12;
40
+
41
+ const GITHUB_PREFIX = 'github:';
42
+
43
+ // Never mirrored, even under a managed directory path: build output, VCS
44
+ // metadata, and installed dependencies would otherwise pollute the lock when
45
+ // syncing from a working tree that has them.
46
+ const IGNORED_DIRS = new Set([
47
+ 'node_modules',
48
+ '.git',
49
+ '.turbo',
50
+ 'dist',
51
+ '.next',
52
+ ]);
53
+
54
+ type Manifest = {
55
+ readonly upstream: string;
56
+ readonly seedDir: string;
57
+ readonly paths: ReadonlyArray<string>;
58
+ };
59
+
60
+ type Lock = {
61
+ readonly upstream: string;
62
+ readonly sha: string;
63
+ readonly files: Record<string, string>;
64
+ };
65
+
66
+ type Source = {
67
+ readonly dir: string;
68
+ readonly sha: string;
69
+ readonly cleanup: () => void;
70
+ };
71
+
72
+ type Command = 'check' | 'doctor' | 'init' | 'sync';
73
+
74
+ type CliOptions = {
75
+ readonly command: Command;
76
+ readonly consumer: string;
77
+ readonly dryRun: boolean;
78
+ readonly from: string | undefined;
79
+ };
80
+
81
+ const sha256 = (buf: Buffer): string =>
82
+ createHash('sha256').update(buf).digest('hex');
83
+
84
+ const toPosix = (p: string): string => p.split(sep).join('/');
85
+
86
+ const assertSafeRelativePath = (path: string, label: string): void => {
87
+ const normalized = normalize(path);
88
+ if (
89
+ path.length === 0 ||
90
+ path === '.' ||
91
+ isAbsolute(path) ||
92
+ path.includes('\\') ||
93
+ normalized !== path ||
94
+ path.split('/').includes('..')
95
+ ) {
96
+ throw new Error(
97
+ `${label} must be a normalized repository-relative path: ${path}`,
98
+ );
99
+ }
100
+ };
101
+
102
+ const parseManifest = (raw: unknown): Manifest => {
103
+ if (typeof raw !== 'object' || raw === null) {
104
+ throw new Error('sync-standards.json must be a JSON object');
105
+ }
106
+ const o = raw as Record<string, unknown>;
107
+ if (typeof o.upstream !== 'string' || typeof o.seedDir !== 'string') {
108
+ throw new Error(
109
+ 'sync-standards.json requires string "upstream" and "seedDir"',
110
+ );
111
+ }
112
+ if (
113
+ !(Array.isArray(o.paths) && o.paths.every((p) => typeof p === 'string'))
114
+ ) {
115
+ throw new Error('sync-standards.json requires a string array "paths"');
116
+ }
117
+ assertSafeRelativePath(o.seedDir, 'sync-standards.json "seedDir"');
118
+ for (const path of o.paths as ReadonlyArray<string>) {
119
+ assertSafeRelativePath(path, 'sync-standards.json managed path');
120
+ }
121
+ if (new Set(o.paths).size !== o.paths.length) {
122
+ throw new Error('sync-standards.json managed paths must be unique');
123
+ }
124
+ return {
125
+ upstream: o.upstream,
126
+ seedDir: o.seedDir,
127
+ paths: o.paths as ReadonlyArray<string>,
128
+ };
129
+ };
130
+
131
+ const loadManifest = async (path: string): Promise<Manifest> => {
132
+ if (!existsSync(path)) {
133
+ throw new Error(`Manifest not found: ${path}`);
134
+ }
135
+ return parseManifest(JSON.parse(await readFile(path, 'utf8')) as unknown);
136
+ };
137
+
138
+ const readLock = async (dir: string): Promise<Lock | null> => {
139
+ const path = join(dir, 'sync-standards.lock');
140
+ if (!existsSync(path)) {
141
+ return null;
142
+ }
143
+ const raw = JSON.parse(await readFile(path, 'utf8')) as Lock;
144
+ return raw;
145
+ };
146
+
147
+ const writeLock = async (dir: string, lock: Lock): Promise<void> => {
148
+ const files = Object.fromEntries(
149
+ Object.entries(lock.files).sort(([a], [b]) => a.localeCompare(b)),
150
+ );
151
+ const ordered = { upstream: lock.upstream, sha: lock.sha, files };
152
+ await writeFile(
153
+ join(dir, 'sync-standards.lock'),
154
+ `${JSON.stringify(ordered, null, 2)}\n`,
155
+ );
156
+ };
157
+
158
+ // Fetch the template into a working directory. Accepts a local path (used to
159
+ // prove the engine before the public repo exists and in tests), a github:
160
+ // shorthand, or any git URL.
161
+ const resolveSource = (src: string): Source => {
162
+ if (existsSync(src)) {
163
+ let sha = 'local';
164
+ try {
165
+ sha = execFileSync('git', ['-C', src, 'rev-parse', 'HEAD'], {
166
+ encoding: 'utf8',
167
+ stdio: ['ignore', 'pipe', 'ignore'],
168
+ }).trim();
169
+ } catch {
170
+ // Not a git checkout; a content-independent marker is fine for local use.
171
+ }
172
+ return { dir: resolve(src), sha, cleanup: () => undefined };
173
+ }
174
+ const url = src.startsWith(GITHUB_PREFIX)
175
+ ? `https://github.com/${src.slice(GITHUB_PREFIX.length)}.git`
176
+ : src;
177
+ const dir = mkdtempSync(join(tmpdir(), 'standards-'));
178
+ execFileSync('git', ['clone', '--depth', '1', '--branch', 'main', url, dir], {
179
+ stdio: 'ignore',
180
+ });
181
+ const sha = execFileSync('git', ['-C', dir, 'rev-parse', 'HEAD'], {
182
+ encoding: 'utf8',
183
+ }).trim();
184
+ return {
185
+ dir,
186
+ sha,
187
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
188
+ };
189
+ };
190
+
191
+ // Recursively collect files under `abs`, keyed by their POSIX path relative to
192
+ // `base`. Missing paths are skipped so a manifest entry with no files is inert.
193
+ const walk = async (
194
+ abs: string,
195
+ base: string,
196
+ out: Map<string, string>,
197
+ ): Promise<void> => {
198
+ const info = await stat(abs).catch(() => null);
199
+ if (info === null) {
200
+ return;
201
+ }
202
+ if (info.isDirectory()) {
203
+ const entries = await readdir(abs);
204
+ await Promise.all(
205
+ entries
206
+ .filter((entry) => !IGNORED_DIRS.has(entry))
207
+ .map((entry) => walk(join(abs, entry), base, out)),
208
+ );
209
+ return;
210
+ }
211
+ out.set(toPosix(relative(base, abs)), abs);
212
+ };
213
+
214
+ const listManaged = async (
215
+ dir: string,
216
+ paths: ReadonlyArray<string>,
217
+ ): Promise<Map<string, string>> => {
218
+ const out = new Map<string, string>();
219
+ await Promise.all(paths.map((p) => walk(join(dir, p), dir, out)));
220
+ return out;
221
+ };
222
+
223
+ const isUnder = (a: string, b: string): boolean =>
224
+ a === b || a.startsWith(`${b}/`);
225
+
226
+ // Managed (bucket 1) paths and seed (bucket 2) targets must never overlap, or a
227
+ // file would have two owners and `sync` could clobber a repo-owned seed.
228
+ const assertDisjoint = (
229
+ managed: ReadonlyArray<string>,
230
+ seeds: ReadonlyArray<string>,
231
+ ): void => {
232
+ for (const m of managed) {
233
+ for (const s of seeds) {
234
+ if (isUnder(m, s) || isUnder(s, m)) {
235
+ throw new Error(
236
+ `Managed path "${m}" overlaps seed path "${s}"; ownership is ambiguous`,
237
+ );
238
+ }
239
+ }
240
+ }
241
+ };
242
+
243
+ type MirrorResult = {
244
+ readonly files: Record<string, string>;
245
+ readonly created: ReadonlyArray<string>;
246
+ readonly updated: ReadonlyArray<string>;
247
+ readonly deleted: ReadonlyArray<string>;
248
+ readonly tampered: ReadonlyArray<string>;
249
+ };
250
+
251
+ type MirrorOptions = {
252
+ readonly manifest: Manifest;
253
+ readonly srcDir: string;
254
+ readonly consumer: string;
255
+ readonly previous: Record<string, string>;
256
+ readonly dryRun: boolean;
257
+ };
258
+
259
+ // Mirror managed files into the consumer, deleting any previously-locked file
260
+ // that no longer exists upstream (three-way reconcile against the lock). When
261
+ // `dryRun` is set nothing is written or deleted; the returned plan is reported.
262
+ const mirror = async ({
263
+ manifest,
264
+ srcDir,
265
+ consumer,
266
+ previous,
267
+ dryRun,
268
+ }: MirrorOptions): Promise<MirrorResult> => {
269
+ for (const rel of Object.keys(previous)) {
270
+ assertSafeRelativePath(rel, 'sync-standards.lock file');
271
+ }
272
+ const upstream = await listManaged(srcDir, manifest.paths);
273
+ const next: Record<string, string> = {};
274
+ const created: Array<string> = [];
275
+ const updated: Array<string> = [];
276
+ const tampered: Array<string> = [];
277
+ await Promise.all(
278
+ [...upstream].map(async ([rel, abs]) => {
279
+ const dest = join(consumer, rel);
280
+ const buf = await readFile(abs);
281
+ const hash = sha256(buf);
282
+ const currentHash = existsSync(dest)
283
+ ? sha256(await readFile(dest))
284
+ : null;
285
+ const prev = previous[rel];
286
+ if (prev !== undefined && currentHash !== null && currentHash !== prev) {
287
+ tampered.push(rel);
288
+ }
289
+ if (currentHash === null) {
290
+ created.push(rel);
291
+ } else if (currentHash !== hash) {
292
+ updated.push(rel);
293
+ }
294
+ if (!dryRun) {
295
+ await mkdir(dirname(dest), { recursive: true });
296
+ await writeFile(dest, buf);
297
+ }
298
+ next[rel] = hash;
299
+ }),
300
+ );
301
+ const deleted = Object.keys(previous).filter(
302
+ (rel) => !(rel in next) && existsSync(join(consumer, rel)),
303
+ );
304
+ if (!dryRun) {
305
+ await Promise.all(deleted.map((rel) => rm(join(consumer, rel))));
306
+ }
307
+ return { files: next, created, updated, deleted, tampered };
308
+ };
309
+
310
+ // Print what a mirror did (or, for a dry run, would do). Real syncs stay quiet
311
+ // about unchanged files and only announce deletions and clobbered local edits.
312
+ const reportMirror = (result: MirrorResult, dryRun: boolean): void => {
313
+ if (dryRun) {
314
+ for (const rel of result.created) {
315
+ console.log(` would create ${rel}`);
316
+ }
317
+ for (const rel of result.updated) {
318
+ console.log(` would update ${rel}`);
319
+ }
320
+ for (const rel of result.deleted) {
321
+ console.log(` would delete ${rel} (removed upstream)`);
322
+ }
323
+ if (result.tampered.length > 0) {
324
+ console.log(
325
+ ` would overwrite ${result.tampered.length} locally-modified canonical file(s): ${result.tampered.join(', ')}`,
326
+ );
327
+ }
328
+ const changes =
329
+ result.created.length + result.updated.length + result.deleted.length;
330
+ console.log(
331
+ changes === 0
332
+ ? 'dry run: already in sync; no changes'
333
+ : `dry run: ${result.created.length} to create, ${result.updated.length} to update, ${result.deleted.length} to delete`,
334
+ );
335
+ return;
336
+ }
337
+ for (const rel of result.deleted) {
338
+ console.log(` deleted ${rel} (removed upstream)`);
339
+ }
340
+ if (result.tampered.length > 0) {
341
+ console.log(
342
+ ` overwrote ${result.tampered.length} locally-modified canonical file(s): ${result.tampered.join(', ')}`,
343
+ );
344
+ }
345
+ };
346
+
347
+ const seedTargets = async (
348
+ srcDir: string,
349
+ seedDir: string,
350
+ ): Promise<Map<string, string>> => {
351
+ const root = join(srcDir, seedDir);
352
+ const out = new Map<string, string>();
353
+ await walk(root, root, out);
354
+ return out;
355
+ };
356
+
357
+ const runInit = async (
358
+ manifest: Manifest,
359
+ src: Source,
360
+ consumer: string,
361
+ ): Promise<void> => {
362
+ const seeds = await seedTargets(src.dir, manifest.seedDir);
363
+ assertDisjoint(manifest.paths, [...seeds.keys()]);
364
+ await Promise.all(
365
+ [...seeds].map(async ([rel, abs]) => {
366
+ const dest = join(consumer, rel);
367
+ if (existsSync(dest)) {
368
+ console.log(` kept ${rel} (already present)`);
369
+ return;
370
+ }
371
+ await mkdir(dirname(dest), { recursive: true });
372
+ await cp(abs, dest);
373
+ console.log(` seeded ${rel}`);
374
+ }),
375
+ );
376
+ const result = await mirror({
377
+ manifest,
378
+ srcDir: src.dir,
379
+ consumer,
380
+ previous: {},
381
+ dryRun: false,
382
+ });
383
+ reportMirror(result, false);
384
+ await writeLock(consumer, {
385
+ upstream: manifest.upstream,
386
+ sha: src.sha,
387
+ files: result.files,
388
+ });
389
+ console.log(
390
+ `init complete: ${Object.keys(result.files).length} managed file(s) at ${src.sha}`,
391
+ );
392
+ };
393
+
394
+ const runSync = async (
395
+ manifest: Manifest,
396
+ src: Source,
397
+ consumer: string,
398
+ dryRun: boolean,
399
+ ): Promise<void> => {
400
+ const seeds = await seedTargets(src.dir, manifest.seedDir);
401
+ assertDisjoint(manifest.paths, [...seeds.keys()]);
402
+ const lock = await readLock(consumer);
403
+ const result = await mirror({
404
+ manifest,
405
+ srcDir: src.dir,
406
+ consumer,
407
+ previous: lock?.files ?? {},
408
+ dryRun,
409
+ });
410
+ reportMirror(result, dryRun);
411
+ if (dryRun) {
412
+ return;
413
+ }
414
+ await writeLock(consumer, {
415
+ upstream: manifest.upstream,
416
+ sha: src.sha,
417
+ files: result.files,
418
+ });
419
+ console.log(
420
+ `sync complete: ${Object.keys(result.files).length} managed file(s) at ${src.sha}`,
421
+ );
422
+ };
423
+
424
+ // Offline drift detection: every locked file must still match its hash. Catches
425
+ // local edits or deletions of canonical files. Does NOT detect upstream moving
426
+ // on — see the "known limitation" in the standards repository README.
427
+ const runCheck = async (consumer: string): Promise<boolean> => {
428
+ const lock = await readLock(consumer);
429
+ if (lock === null || Object.keys(lock.files).length === 0) {
430
+ console.error(
431
+ 'standards: no non-empty sync-standards.lock found; run `standards init` before checking',
432
+ );
433
+ return false;
434
+ }
435
+ for (const rel of Object.keys(lock.files)) {
436
+ assertSafeRelativePath(rel, 'sync-standards.lock file');
437
+ }
438
+ const results = await Promise.all(
439
+ Object.entries(lock.files).map(async ([rel, hash]) => {
440
+ const dest = join(consumer, rel);
441
+ if (!existsSync(dest)) {
442
+ return ` missing: ${rel}`;
443
+ }
444
+ const current = sha256(await readFile(dest));
445
+ if (current !== hash) {
446
+ return ` modified: ${rel} (expected ${hash.slice(0, HASH_PREVIEW_LENGTH)}, found ${current.slice(0, HASH_PREVIEW_LENGTH)})`;
447
+ }
448
+ return null;
449
+ }),
450
+ );
451
+ const problems = results.filter((p): p is string => p !== null);
452
+ if (problems.length > 0) {
453
+ console.error(
454
+ `standards: ${problems.length} canonical file(s) drifted from upstream:`,
455
+ );
456
+ console.error(problems.join('\n'));
457
+ console.error(
458
+ 'These files are read-only. Restore them with `just sync-standards`, or move your change upstream.',
459
+ );
460
+ return false;
461
+ }
462
+ console.log(
463
+ `standards: ${Object.keys(lock.files).length} canonical file(s) match upstream`,
464
+ );
465
+ return true;
466
+ };
467
+
468
+ const readTextIfPresent = async (path: string): Promise<string | null> =>
469
+ existsSync(path) ? readFile(path, 'utf8') : null;
470
+
471
+ const hasStandardsImport = (justfile: string): boolean =>
472
+ justfile.split('\n').some((line) => {
473
+ const trimmed = line.trim();
474
+ return (
475
+ trimmed === "import 'standards.just'" ||
476
+ trimmed === 'import "standards.just"'
477
+ );
478
+ });
479
+
480
+ const inspectPackageJson = (packageRaw: string): ReadonlyArray<string> => {
481
+ const problems: Array<string> = [];
482
+ const packageJson = JSON.parse(packageRaw) as Record<string, unknown>;
483
+ const scripts = packageJson.scripts as Record<string, unknown> | undefined;
484
+ const devDependencies = packageJson.devDependencies as
485
+ | Record<string, unknown>
486
+ | undefined;
487
+ if (typeof devDependencies?.['@davidvornholt/standards'] !== 'string') {
488
+ problems.push(
489
+ 'package.json must declare @davidvornholt/standards directly',
490
+ );
491
+ }
492
+ for (const name of ['check', 'check:fix']) {
493
+ const script = scripts?.[name];
494
+ if (typeof script !== 'string' || !script.includes('standards check')) {
495
+ problems.push(`package.json script "${name}" must run standards check`);
496
+ }
497
+ }
498
+ return problems;
499
+ };
500
+
501
+ const runDoctor = async (consumer: string): Promise<boolean> => {
502
+ const problems: Array<string> = [];
503
+ const justfile = await readTextIfPresent(join(consumer, 'justfile'));
504
+ if (justfile === null || !hasStandardsImport(justfile)) {
505
+ problems.push("justfile must import 'standards.just'");
506
+ }
507
+
508
+ const biome = await readTextIfPresent(join(consumer, 'biome.jsonc'));
509
+ if (biome === null || !biome.includes('"./biome.base.jsonc"')) {
510
+ problems.push('biome.jsonc must extend "./biome.base.jsonc"');
511
+ }
512
+
513
+ if (!existsSync(join(consumer, 'AGENTS.local.md'))) {
514
+ problems.push('AGENTS.local.md must exist for project-specific guidance');
515
+ }
516
+
517
+ const packagePath = join(consumer, 'package.json');
518
+ const packageRaw = await readTextIfPresent(packagePath);
519
+ if (packageRaw === null) {
520
+ problems.push('package.json must exist');
521
+ } else {
522
+ problems.push(...inspectPackageJson(packageRaw));
523
+ }
524
+
525
+ if (problems.length > 0) {
526
+ console.error(
527
+ `standards doctor: ${problems.length} integration problem(s):`,
528
+ );
529
+ console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
530
+ return false;
531
+ }
532
+ console.log('standards doctor: consumer integration seams are wired');
533
+ return true;
534
+ };
535
+
536
+ const commandFromArg = (arg: string): Command => {
537
+ if (arg === 'check' || arg === 'doctor' || arg === 'init' || arg === 'sync') {
538
+ return arg;
539
+ }
540
+ throw new Error(
541
+ arg.startsWith('--') ? `Unknown option: ${arg}` : `Unknown command: ${arg}`,
542
+ );
543
+ };
544
+
545
+ const setCommand = (current: Command | undefined, next: Command): Command => {
546
+ if (current !== undefined) {
547
+ throw new Error(`Unexpected second command: ${next}`);
548
+ }
549
+ return next;
550
+ };
551
+
552
+ const nextOptionValue = (
553
+ argv: ReadonlyArray<string>,
554
+ index: number,
555
+ ): string => {
556
+ const value = argv[index + 1];
557
+ if (value === undefined || value.startsWith('--')) {
558
+ throw new Error(`${argv[index]} requires a value`);
559
+ }
560
+ return value;
561
+ };
562
+
563
+ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
564
+ let command: Command | undefined;
565
+ let consumer = process.cwd();
566
+ let dryRun = false;
567
+ let from: string | undefined;
568
+
569
+ for (let index = 0; index < argv.length; index += 1) {
570
+ const arg = argv[index];
571
+ switch (arg) {
572
+ case '--check':
573
+ command = setCommand(command, 'check');
574
+ break;
575
+ case '--dir':
576
+ consumer = nextOptionValue(argv, index);
577
+ index += 1;
578
+ break;
579
+ case '--dry-run':
580
+ dryRun = true;
581
+ break;
582
+ case '--from':
583
+ from = nextOptionValue(argv, index);
584
+ index += 1;
585
+ break;
586
+ default:
587
+ command = setCommand(command, commandFromArg(arg));
588
+ }
589
+ }
590
+
591
+ return {
592
+ command: command ?? 'sync',
593
+ consumer: resolve(consumer),
594
+ dryRun,
595
+ from,
596
+ };
597
+ };
598
+
599
+ const main = async (): Promise<void> => {
600
+ const { command, consumer, dryRun, from } = parseArgs(process.argv.slice(2));
601
+
602
+ if (command === 'check') {
603
+ const driftIsClean = await runCheck(consumer);
604
+ const integrationIsValid = await runDoctor(consumer);
605
+ if (!(driftIsClean && integrationIsValid)) {
606
+ process.exitCode = 1;
607
+ }
608
+ return;
609
+ }
610
+
611
+ if (command === 'doctor') {
612
+ if (!(await runDoctor(consumer))) {
613
+ process.exitCode = 1;
614
+ }
615
+ return;
616
+ }
617
+
618
+ if (command === 'init') {
619
+ // Refuse before cloning upstream: re-initializing skips the lock, so it
620
+ // would silently overwrite local canonical edits and orphan files that
621
+ // upstream deleted (they leave the lock and no future sync removes them).
622
+ if (existsSync(join(consumer, 'sync-standards.lock'))) {
623
+ console.error(
624
+ 'standards: already initialized (sync-standards.lock exists). Use `just sync-standards` to update.',
625
+ );
626
+ process.exitCode = 1;
627
+ return;
628
+ }
629
+ const source = resolveSource(from ?? DEFAULT_UPSTREAM);
630
+ try {
631
+ const manifest = await loadManifest(
632
+ join(source.dir, 'sync-standards.json'),
633
+ );
634
+ await runInit(manifest, source, consumer);
635
+ } finally {
636
+ source.cleanup();
637
+ }
638
+ return;
639
+ }
640
+
641
+ if (command === 'sync') {
642
+ const consumerManifest = await loadManifest(
643
+ join(consumer, 'sync-standards.json'),
644
+ );
645
+ const source = resolveSource(from ?? consumerManifest.upstream);
646
+ try {
647
+ const manifest = await loadManifest(
648
+ join(source.dir, 'sync-standards.json'),
649
+ );
650
+ await runSync(manifest, source, consumer, dryRun);
651
+ } finally {
652
+ source.cleanup();
653
+ }
654
+ }
655
+ };
656
+
657
+ try {
658
+ await main();
659
+ } catch (error) {
660
+ console.error(error instanceof Error ? error.message : String(error));
661
+ process.exitCode = 1;
662
+ }