@davidvornholt/standards 0.10.0 → 0.10.2
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/README.md +7 -2
- package/package.json +11 -2
- package/src/cli.ts +241 -189
- package/src/dependabot-compose-input.ts +136 -0
- package/src/dependabot-compose.ts +188 -0
- package/src/dependabot-inspect.ts +181 -0
- package/src/dependabot-update.ts +156 -0
- package/src/github-settings-parse.ts +3 -0
- package/src/structure-profile.ts +2 -0
- package/src/yaml-emit.ts +67 -0
- package/src/yaml-parse.ts +25 -0
package/src/cli.ts
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
// davidvornholt/standards template into a consumer repo and detects local
|
|
5
5
|
// tampering with them. See the standards repository README for the design.
|
|
6
6
|
//
|
|
7
|
-
// This
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// repo's Effect standard
|
|
7
|
+
// This bootstrap executable keeps its runtime surface minimal and does not use
|
|
8
|
+
// Effect: `bunx` must be able to execute the published package before a
|
|
9
|
+
// consumer has dependencies. Its strict YAML parser is a declared runtime
|
|
10
|
+
// dependency. This is the documented exception to the repo's Effect standard
|
|
11
|
+
// for standalone bootstrap tooling.
|
|
11
12
|
|
|
12
13
|
import { execFileSync } from 'node:child_process';
|
|
13
14
|
import { createHash } from 'node:crypto';
|
|
@@ -32,21 +33,28 @@ import {
|
|
|
32
33
|
sep,
|
|
33
34
|
} from 'node:path';
|
|
34
35
|
import process from 'node:process';
|
|
36
|
+
import {
|
|
37
|
+
composeDependabot,
|
|
38
|
+
DEPENDABOT_BASE_FILE,
|
|
39
|
+
DEPENDABOT_FILE,
|
|
40
|
+
DEPENDABOT_LOCAL_FILE,
|
|
41
|
+
} from './dependabot-compose';
|
|
42
|
+
import { inspectDependabot } from './dependabot-inspect';
|
|
35
43
|
import { CANONICAL_SETTINGS_FILE, LOCAL_SETTINGS_FILE } from './github-api';
|
|
36
44
|
import { runGithubApply, runGithubCheck } from './github-commands';
|
|
37
45
|
import { loadGithubSettings } from './github-settings';
|
|
46
|
+
import { isNonEmptyString, isRecord } from './github-settings-parse';
|
|
38
47
|
import { collectStructureProblems } from './structure-check';
|
|
39
48
|
import type { StructureProfile } from './structure-profile';
|
|
40
49
|
import { hasSafeCommand } from './structure-script';
|
|
41
50
|
|
|
42
|
-
const { YAML: BunYaml } = await import('bun');
|
|
43
|
-
|
|
44
51
|
const DEFAULT_UPSTREAM = 'github:davidvornholt/standards';
|
|
45
52
|
|
|
46
53
|
// Characters of a sha256 hex digest shown in drift reports; enough to identify.
|
|
47
54
|
const HASH_PREVIEW_LENGTH = 12;
|
|
48
55
|
|
|
49
56
|
const GITHUB_PREFIX = 'github:';
|
|
57
|
+
const SKIP_GITHUB_CHECK_ENV = 'STANDARDS_SKIP_GITHUB_CHECK';
|
|
50
58
|
|
|
51
59
|
// Never mirrored, even under a managed directory path: build output, VCS
|
|
52
60
|
// metadata, and installed dependencies would otherwise pollute the lock when
|
|
@@ -79,6 +87,7 @@ type Source = {
|
|
|
79
87
|
|
|
80
88
|
type Command =
|
|
81
89
|
| 'check'
|
|
90
|
+
| 'dependabot'
|
|
82
91
|
| 'doctor'
|
|
83
92
|
| 'github'
|
|
84
93
|
| 'help'
|
|
@@ -93,6 +102,7 @@ type CliOptions = {
|
|
|
93
102
|
readonly from: string | undefined;
|
|
94
103
|
readonly ref: string | undefined;
|
|
95
104
|
readonly apply: boolean;
|
|
105
|
+
readonly write: boolean;
|
|
96
106
|
readonly profile: StructureProfile;
|
|
97
107
|
};
|
|
98
108
|
|
|
@@ -403,6 +413,12 @@ const runInit = async (
|
|
|
403
413
|
): Promise<void> => {
|
|
404
414
|
const seeds = await seedTargets(src.dir, manifest.seedDir);
|
|
405
415
|
assertDisjoint(manifest.paths, [...seeds.keys()]);
|
|
416
|
+
const prospectiveDependabot = await prepareProspectiveDependabot(
|
|
417
|
+
manifest,
|
|
418
|
+
src.dir,
|
|
419
|
+
consumer,
|
|
420
|
+
seeds.get(DEPENDABOT_LOCAL_FILE) ?? null,
|
|
421
|
+
);
|
|
406
422
|
await Promise.all(
|
|
407
423
|
[...seeds].map(async ([rel, abs]) => {
|
|
408
424
|
const dest = join(consumer, rel);
|
|
@@ -423,6 +439,7 @@ const runInit = async (
|
|
|
423
439
|
dryRun: false,
|
|
424
440
|
});
|
|
425
441
|
reportMirror(result, false);
|
|
442
|
+
await applyProspectiveDependabot(consumer, prospectiveDependabot, false);
|
|
426
443
|
await writeLock(consumer, {
|
|
427
444
|
upstream: manifest.upstream,
|
|
428
445
|
sha: src.sha,
|
|
@@ -441,6 +458,12 @@ const runSync = async (
|
|
|
441
458
|
): Promise<void> => {
|
|
442
459
|
const seeds = await seedTargets(src.dir, manifest.seedDir);
|
|
443
460
|
assertDisjoint(manifest.paths, [...seeds.keys()]);
|
|
461
|
+
const prospectiveDependabot = await prepareProspectiveDependabot(
|
|
462
|
+
manifest,
|
|
463
|
+
src.dir,
|
|
464
|
+
consumer,
|
|
465
|
+
null,
|
|
466
|
+
);
|
|
444
467
|
const lock = await readLock(consumer);
|
|
445
468
|
const result = await mirror({
|
|
446
469
|
manifest,
|
|
@@ -450,6 +473,7 @@ const runSync = async (
|
|
|
450
473
|
dryRun,
|
|
451
474
|
});
|
|
452
475
|
reportMirror(result, dryRun);
|
|
476
|
+
await applyProspectiveDependabot(consumer, prospectiveDependabot, dryRun);
|
|
453
477
|
if (dryRun) {
|
|
454
478
|
return;
|
|
455
479
|
}
|
|
@@ -510,177 +534,159 @@ const runCheck = async (consumer: string): Promise<boolean> => {
|
|
|
510
534
|
const readTextIfPresent = async (path: string): Promise<string | null> =>
|
|
511
535
|
existsSync(path) ? readFile(path, 'utf8') : null;
|
|
512
536
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
'monthly',
|
|
518
|
-
'quarterly',
|
|
519
|
-
'semiannually',
|
|
520
|
-
'yearly',
|
|
521
|
-
'cron',
|
|
522
|
-
]);
|
|
523
|
-
|
|
524
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
525
|
-
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
526
|
-
|
|
527
|
-
const isNonEmptyString = (value: unknown): value is string =>
|
|
528
|
-
typeof value === 'string' && value.length > 0;
|
|
529
|
-
|
|
530
|
-
type DependabotUpdateInspection = {
|
|
531
|
-
readonly problems: ReadonlyArray<string>;
|
|
532
|
-
readonly rootEcosystem: string | null;
|
|
537
|
+
type DependabotSources = {
|
|
538
|
+
readonly base: string | null;
|
|
539
|
+
readonly local: string | null;
|
|
540
|
+
readonly current: string | null;
|
|
533
541
|
};
|
|
534
542
|
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
};
|
|
550
|
-
|
|
551
|
-
type DependabotGroupInspection = {
|
|
543
|
+
const readDependabotSources = async (
|
|
544
|
+
consumer: string,
|
|
545
|
+
): Promise<DependabotSources> => ({
|
|
546
|
+
base: await readTextIfPresent(join(consumer, DEPENDABOT_BASE_FILE)),
|
|
547
|
+
local: await readTextIfPresent(join(consumer, DEPENDABOT_LOCAL_FILE)),
|
|
548
|
+
current: await readTextIfPresent(join(consumer, DEPENDABOT_FILE)),
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// Compose the generated Dependabot config from its on-disk sources, folding
|
|
552
|
+
// Dependabot semantic validation into the same problem list.
|
|
553
|
+
const composedDependabot = (
|
|
554
|
+
sources: DependabotSources,
|
|
555
|
+
): {
|
|
556
|
+
readonly composed: string | null;
|
|
552
557
|
readonly problems: ReadonlyArray<string>;
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
const inspectDependabotGroups = (
|
|
557
|
-
groups: unknown,
|
|
558
|
-
): DependabotGroupInspection => {
|
|
559
|
-
if (groups === undefined) {
|
|
560
|
-
return { problems: [], scheduledGroups: new Set() };
|
|
561
|
-
}
|
|
562
|
-
if (!isRecord(groups)) {
|
|
558
|
+
} => {
|
|
559
|
+
if (sources.base === null) {
|
|
563
560
|
return {
|
|
561
|
+
composed: null,
|
|
564
562
|
problems: [
|
|
565
|
-
|
|
563
|
+
`${DEPENDABOT_BASE_FILE} must exist; run \`bun standards sync\` to mirror it in`,
|
|
566
564
|
],
|
|
567
|
-
scheduledGroups: new Set(),
|
|
568
565
|
};
|
|
569
566
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
for (const [name, group] of Object.entries(groups)) {
|
|
574
|
-
const label = `.github/dependabot.yml multi-ecosystem-groups.${name}`;
|
|
575
|
-
const groupProblems = isRecord(group)
|
|
576
|
-
? inspectDependabotSchedule(group.schedule, label)
|
|
577
|
-
: [`${label} must be a mapping`];
|
|
578
|
-
problems.push(...groupProblems);
|
|
579
|
-
if (groupProblems.length === 0) {
|
|
580
|
-
scheduledGroups.add(name);
|
|
581
|
-
}
|
|
567
|
+
const result = composeDependabot(sources.base, sources.local);
|
|
568
|
+
if (result.composed === null) {
|
|
569
|
+
return result;
|
|
582
570
|
}
|
|
583
|
-
|
|
571
|
+
const problems = inspectDependabot(result.composed);
|
|
572
|
+
return problems.length > 0 ? { composed: null, problems } : result;
|
|
584
573
|
};
|
|
585
574
|
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
return { problems: [`${label} must be a mapping`], rootEcosystem: null };
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
const {
|
|
597
|
-
directory,
|
|
598
|
-
directories,
|
|
599
|
-
schedule,
|
|
600
|
-
'multi-ecosystem-group': multiEcosystemGroup,
|
|
601
|
-
'package-ecosystem': ecosystem,
|
|
602
|
-
} = update;
|
|
603
|
-
const problems: Array<string> = [];
|
|
604
|
-
if (!isNonEmptyString(ecosystem)) {
|
|
605
|
-
problems.push(`${label} must define package-ecosystem`);
|
|
575
|
+
const dependabotProblems = async (
|
|
576
|
+
consumer: string,
|
|
577
|
+
): Promise<ReadonlyArray<string>> => {
|
|
578
|
+
const sources = await readDependabotSources(consumer);
|
|
579
|
+
const { composed, problems } = composedDependabot(sources);
|
|
580
|
+
if (composed === null) {
|
|
581
|
+
return problems;
|
|
606
582
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
directories.length > 0 &&
|
|
612
|
-
directories.every(isNonEmptyString);
|
|
613
|
-
if (hasDirectory === hasDirectories) {
|
|
614
|
-
problems.push(
|
|
615
|
-
`${label} must define exactly one of directory or directories`,
|
|
616
|
-
);
|
|
583
|
+
if (sources.current !== composed) {
|
|
584
|
+
return [
|
|
585
|
+
`${DEPENDABOT_FILE} does not match its composed sources; regenerate it with \`bun standards dependabot --write\``,
|
|
586
|
+
];
|
|
617
587
|
}
|
|
588
|
+
return [];
|
|
589
|
+
};
|
|
618
590
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
}
|
|
591
|
+
const writeComposedDependabot = async (
|
|
592
|
+
consumer: string,
|
|
593
|
+
composed: string,
|
|
594
|
+
): Promise<void> => {
|
|
595
|
+
const dest = join(consumer, DEPENDABOT_FILE);
|
|
596
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
597
|
+
await writeFile(dest, composed);
|
|
598
|
+
};
|
|
628
599
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
600
|
+
const composeProblemsError = (problems: ReadonlyArray<string>): Error =>
|
|
601
|
+
new Error(
|
|
602
|
+
[
|
|
603
|
+
`cannot compose ${DEPENDABOT_FILE}:`,
|
|
604
|
+
...problems.map((problem) => ` - ${problem}`),
|
|
605
|
+
].join('\n'),
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
type ProspectiveDependabot = {
|
|
609
|
+
readonly composed: string;
|
|
610
|
+
readonly current: string | null;
|
|
637
611
|
};
|
|
638
612
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
613
|
+
// Validate the incoming canonical base against the effective local overlay
|
|
614
|
+
// before init/sync mutates any consumer-owned, canonical, generated, or lock
|
|
615
|
+
// file. For init, an existing overlay wins; otherwise the overlay seed is what
|
|
616
|
+
// the command is about to install.
|
|
617
|
+
const prepareProspectiveDependabot = async (
|
|
618
|
+
manifest: Manifest,
|
|
619
|
+
srcDir: string,
|
|
620
|
+
consumer: string,
|
|
621
|
+
localSeed: string | null,
|
|
622
|
+
): Promise<ProspectiveDependabot> => {
|
|
623
|
+
const incoming = await listManaged(srcDir, manifest.paths);
|
|
624
|
+
const basePath = incoming.get(DEPENDABOT_BASE_FILE);
|
|
625
|
+
if (basePath === undefined) {
|
|
626
|
+
throw new Error(
|
|
627
|
+
`source content must manage ${DEPENDABOT_BASE_FILE}; @davidvornholt/standards 0.10.1 requires a 0.10.1-compatible content ref`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
const existingLocal = await readTextIfPresent(
|
|
631
|
+
join(consumer, DEPENDABOT_LOCAL_FILE),
|
|
632
|
+
);
|
|
633
|
+
const local =
|
|
634
|
+
existingLocal ??
|
|
635
|
+
(localSeed === null ? null : await readFile(localSeed, 'utf8'));
|
|
636
|
+
const current = await readTextIfPresent(join(consumer, DEPENDABOT_FILE));
|
|
637
|
+
const sources = {
|
|
638
|
+
base: await readFile(basePath, 'utf8'),
|
|
639
|
+
local,
|
|
640
|
+
current,
|
|
641
|
+
};
|
|
642
|
+
const { composed, problems } = composedDependabot(sources);
|
|
643
|
+
if (composed === null) {
|
|
644
|
+
throw composeProblemsError(problems);
|
|
646
645
|
}
|
|
646
|
+
return { composed, current };
|
|
647
|
+
};
|
|
647
648
|
|
|
648
|
-
|
|
649
|
-
|
|
649
|
+
const applyProspectiveDependabot = async (
|
|
650
|
+
consumer: string,
|
|
651
|
+
prospective: ProspectiveDependabot,
|
|
652
|
+
dryRun: boolean,
|
|
653
|
+
): Promise<void> => {
|
|
654
|
+
if (prospective.current === prospective.composed) {
|
|
655
|
+
return;
|
|
650
656
|
}
|
|
651
|
-
if (
|
|
652
|
-
|
|
657
|
+
if (dryRun) {
|
|
658
|
+
console.log(` would generate ${DEPENDABOT_FILE}`);
|
|
659
|
+
return;
|
|
653
660
|
}
|
|
661
|
+
await writeComposedDependabot(consumer, prospective.composed);
|
|
662
|
+
console.log(` generated ${DEPENDABOT_FILE}`);
|
|
663
|
+
};
|
|
654
664
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
665
|
+
const runDependabotCheck = async (consumer: string): Promise<boolean> => {
|
|
666
|
+
const problems = await dependabotProblems(consumer);
|
|
667
|
+
if (problems.length > 0) {
|
|
668
|
+
console.error(`standards dependabot: ${problems.length} problem(s):`);
|
|
669
|
+
console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
|
|
670
|
+
return false;
|
|
659
671
|
}
|
|
672
|
+
console.log(
|
|
673
|
+
`standards dependabot: ${DEPENDABOT_FILE} matches its composed sources`,
|
|
674
|
+
);
|
|
675
|
+
return true;
|
|
676
|
+
};
|
|
660
677
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
update,
|
|
667
|
-
index,
|
|
668
|
-
groupInspection.scheduledGroups,
|
|
669
|
-
);
|
|
670
|
-
problems.push(...inspection.problems);
|
|
671
|
-
if (inspection.rootEcosystem !== null) {
|
|
672
|
-
rootEcosystems.add(inspection.rootEcosystem);
|
|
673
|
-
}
|
|
678
|
+
const runDependabotWrite = async (consumer: string): Promise<void> => {
|
|
679
|
+
const sources = await readDependabotSources(consumer);
|
|
680
|
+
const { composed, problems } = composedDependabot(sources);
|
|
681
|
+
if (composed === null) {
|
|
682
|
+
throw composeProblemsError(problems);
|
|
674
683
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
problems.push(
|
|
679
|
-
`.github/dependabot.yml must include a root-directory ${ecosystem} ecosystem`,
|
|
680
|
-
);
|
|
681
|
-
}
|
|
684
|
+
if (sources.current === composed) {
|
|
685
|
+
console.log(`standards dependabot: ${DEPENDABOT_FILE} is up to date`);
|
|
686
|
+
return;
|
|
682
687
|
}
|
|
683
|
-
|
|
688
|
+
await writeComposedDependabot(consumer, composed);
|
|
689
|
+
console.log(`standards dependabot: generated ${DEPENDABOT_FILE}`);
|
|
684
690
|
};
|
|
685
691
|
|
|
686
692
|
const inspectPackageJson = (packageRaw: string): ReadonlyArray<string> => {
|
|
@@ -726,14 +732,7 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
|
|
|
726
732
|
problems.push('AGENTS.local.md must exist for project-specific guidance');
|
|
727
733
|
}
|
|
728
734
|
|
|
729
|
-
|
|
730
|
-
join(consumer, '.github/dependabot.yml'),
|
|
731
|
-
);
|
|
732
|
-
if (dependabot === null) {
|
|
733
|
-
problems.push('.github/dependabot.yml must exist');
|
|
734
|
-
} else {
|
|
735
|
-
problems.push(...inspectDependabot(dependabot));
|
|
736
|
-
}
|
|
735
|
+
problems.push(...(await dependabotProblems(consumer)));
|
|
737
736
|
|
|
738
737
|
const packagePath = join(consumer, 'package.json');
|
|
739
738
|
const packageRaw = await readTextIfPresent(packagePath);
|
|
@@ -773,13 +772,14 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
|
|
|
773
772
|
const USAGE = `Usage: standards <command> [options]
|
|
774
773
|
|
|
775
774
|
Commands:
|
|
776
|
-
init
|
|
777
|
-
sync
|
|
778
|
-
check
|
|
779
|
-
doctor
|
|
780
|
-
structure
|
|
781
|
-
|
|
782
|
-
|
|
775
|
+
init Bootstrap a consumer repo: seed repo-owned files, mirror canonical files, write the lock
|
|
776
|
+
sync Mirror canonical files from upstream, regenerate the composed Dependabot config, and rewrite the lock
|
|
777
|
+
check Verify canonical files, extension seams, monorepo structure, and GitHub settings
|
|
778
|
+
doctor Validate extension seams only
|
|
779
|
+
structure Validate monorepo structure rules only
|
|
780
|
+
dependabot Verify (--check) or regenerate (--write) the composed .github/dependabot.yml
|
|
781
|
+
github Compare (--check) or converge (--apply) live GitHub settings
|
|
782
|
+
help Show this help
|
|
783
783
|
|
|
784
784
|
Options:
|
|
785
785
|
--dir <path> Consumer directory to operate on (default: current directory)
|
|
@@ -787,12 +787,14 @@ Options:
|
|
|
787
787
|
--from <src> Upstream override for init/sync (GitHub repo or local path)
|
|
788
788
|
--ref <ref> Upstream tag, branch, or full commit sha for init/sync (remote Git/GitHub sources only; default: main)
|
|
789
789
|
--dry-run Preview a sync without writing anything
|
|
790
|
-
--check With github: compare
|
|
791
|
-
--apply With github: converge the live repository (needs admin auth)
|
|
790
|
+
--check With github/dependabot: compare against the declared sources (default)
|
|
791
|
+
--apply With github: converge the live repository (needs admin auth)
|
|
792
|
+
--write With dependabot: regenerate the composed .github/dependabot.yml`;
|
|
792
793
|
|
|
793
794
|
const commandFromArg = (arg: string): Command => {
|
|
794
795
|
if (
|
|
795
796
|
arg === 'check' ||
|
|
797
|
+
arg === 'dependabot' ||
|
|
796
798
|
arg === 'doctor' ||
|
|
797
799
|
arg === 'github' ||
|
|
798
800
|
arg === 'help' ||
|
|
@@ -832,6 +834,44 @@ const nextOptionValue = (
|
|
|
832
834
|
return value;
|
|
833
835
|
};
|
|
834
836
|
|
|
837
|
+
type ParsedFlags = {
|
|
838
|
+
readonly command: Command | undefined;
|
|
839
|
+
readonly checkFlag: boolean;
|
|
840
|
+
readonly apply: boolean;
|
|
841
|
+
readonly write: boolean;
|
|
842
|
+
readonly ref: string | undefined;
|
|
843
|
+
readonly profile: StructureProfile | undefined;
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
// Every option is only meaningful with specific commands; reject the rest so a
|
|
847
|
+
// typo fails loudly instead of silently doing the default thing.
|
|
848
|
+
const assertOptionUsage = (flags: ParsedFlags): void => {
|
|
849
|
+
const { command, checkFlag, apply, write, ref, profile } = flags;
|
|
850
|
+
if (checkFlag && command !== 'github' && command !== 'dependabot') {
|
|
851
|
+
throw new Error(
|
|
852
|
+
'--check is only valid with the github and dependabot commands',
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
if (apply && command !== 'github') {
|
|
856
|
+
throw new Error('--apply is only valid with the github command');
|
|
857
|
+
}
|
|
858
|
+
if (apply && checkFlag) {
|
|
859
|
+
throw new Error('github accepts exactly one of --check or --apply');
|
|
860
|
+
}
|
|
861
|
+
if (write && command !== 'dependabot') {
|
|
862
|
+
throw new Error('--write is only valid with the dependabot command');
|
|
863
|
+
}
|
|
864
|
+
if (write && checkFlag) {
|
|
865
|
+
throw new Error('dependabot accepts exactly one of --check or --write');
|
|
866
|
+
}
|
|
867
|
+
if (ref !== undefined && command !== 'init' && command !== 'sync') {
|
|
868
|
+
throw new Error('--ref is only valid with the init and sync commands');
|
|
869
|
+
}
|
|
870
|
+
if (profile !== undefined && command !== 'structure') {
|
|
871
|
+
throw new Error('--profile is only valid with the structure command');
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
|
|
835
875
|
const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
836
876
|
let command: Command | undefined;
|
|
837
877
|
let consumer = process.cwd();
|
|
@@ -840,6 +880,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
840
880
|
let ref: string | undefined;
|
|
841
881
|
let checkFlag = false;
|
|
842
882
|
let apply = false;
|
|
883
|
+
let write = false;
|
|
843
884
|
let profile: StructureProfile | undefined;
|
|
844
885
|
|
|
845
886
|
for (let index = 0; index < argv.length; index += 1) {
|
|
@@ -851,6 +892,9 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
851
892
|
case '--check':
|
|
852
893
|
checkFlag = true;
|
|
853
894
|
break;
|
|
895
|
+
case '--write':
|
|
896
|
+
write = true;
|
|
897
|
+
break;
|
|
854
898
|
case '--dir':
|
|
855
899
|
consumer = nextOptionValue(argv, index);
|
|
856
900
|
index += 1;
|
|
@@ -879,21 +923,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
879
923
|
}
|
|
880
924
|
}
|
|
881
925
|
|
|
882
|
-
|
|
883
|
-
throw new Error('--check is only valid with the github command');
|
|
884
|
-
}
|
|
885
|
-
if (apply && command !== 'github') {
|
|
886
|
-
throw new Error('--apply is only valid with the github command');
|
|
887
|
-
}
|
|
888
|
-
if (apply && checkFlag) {
|
|
889
|
-
throw new Error('github accepts exactly one of --check or --apply');
|
|
890
|
-
}
|
|
891
|
-
if (ref !== undefined && command !== 'init' && command !== 'sync') {
|
|
892
|
-
throw new Error('--ref is only valid with the init and sync commands');
|
|
893
|
-
}
|
|
894
|
-
if (profile !== undefined && command !== 'structure') {
|
|
895
|
-
throw new Error('--profile is only valid with the structure command');
|
|
896
|
-
}
|
|
926
|
+
assertOptionUsage({ command, checkFlag, apply, write, ref, profile });
|
|
897
927
|
|
|
898
928
|
return {
|
|
899
929
|
command,
|
|
@@ -902,6 +932,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
902
932
|
from,
|
|
903
933
|
ref,
|
|
904
934
|
apply,
|
|
935
|
+
write,
|
|
905
936
|
profile: profile ?? 'consumer',
|
|
906
937
|
};
|
|
907
938
|
};
|
|
@@ -934,13 +965,27 @@ const runCheckCommand = async (consumer: string): Promise<boolean> => {
|
|
|
934
965
|
// closed: once .github/settings.json exists, an unreachable API or an
|
|
935
966
|
// unreadable origin is a failure, not a skip.
|
|
936
967
|
const githubIsConverged = existsSync(join(consumer, CANONICAL_SETTINGS_FILE))
|
|
937
|
-
? await
|
|
968
|
+
? await runGithubCheckGate(consumer)
|
|
938
969
|
: true;
|
|
939
970
|
return (
|
|
940
971
|
driftIsClean && integrationIsValid && structureIsValid && githubIsConverged
|
|
941
972
|
);
|
|
942
973
|
};
|
|
943
974
|
|
|
975
|
+
// The canonical workflow sets this only for its unprivileged quality job,
|
|
976
|
+
// where a separate isolated job runs the same live check with the admin-read
|
|
977
|
+
// token. Absent that exact workflow seam, local and explicit checks remain
|
|
978
|
+
// fail-closed.
|
|
979
|
+
const runGithubCheckGate = (consumer: string): Promise<boolean> => {
|
|
980
|
+
if (process.env[SKIP_GITHUB_CHECK_ENV] === 'true') {
|
|
981
|
+
console.log(
|
|
982
|
+
`standards github: live settings check skipped because ${SKIP_GITHUB_CHECK_ENV}=true; the canonical workflow's isolated github-settings job must own this check`,
|
|
983
|
+
);
|
|
984
|
+
return Promise.resolve(true);
|
|
985
|
+
}
|
|
986
|
+
return runGithubCheck(consumer);
|
|
987
|
+
};
|
|
988
|
+
|
|
944
989
|
// Consumer-owned sync policy, checked in next to the canonical (read-only)
|
|
945
990
|
// standards-sync workflow it configures — versioned and reviewable, unlike
|
|
946
991
|
// repository Actions variables. All fields are optional; a missing file means
|
|
@@ -1069,7 +1114,7 @@ const runSyncCommand = async (
|
|
|
1069
1114
|
|
|
1070
1115
|
// Commands whose success is reported through the exit code.
|
|
1071
1116
|
const runGateCommand = (
|
|
1072
|
-
command: 'check' | 'doctor' | 'github' | 'structure',
|
|
1117
|
+
command: 'check' | 'dependabot' | 'doctor' | 'github' | 'structure',
|
|
1073
1118
|
consumer: string,
|
|
1074
1119
|
apply: boolean,
|
|
1075
1120
|
profile: StructureProfile,
|
|
@@ -1077,19 +1122,21 @@ const runGateCommand = (
|
|
|
1077
1122
|
if (command === 'check') {
|
|
1078
1123
|
return runCheckCommand(consumer);
|
|
1079
1124
|
}
|
|
1125
|
+
if (command === 'dependabot') {
|
|
1126
|
+
return runDependabotCheck(consumer);
|
|
1127
|
+
}
|
|
1080
1128
|
if (command === 'doctor') {
|
|
1081
1129
|
return runDoctor(consumer);
|
|
1082
1130
|
}
|
|
1083
1131
|
if (command === 'structure') {
|
|
1084
1132
|
return runStructure(consumer, profile);
|
|
1085
1133
|
}
|
|
1086
|
-
return apply ? runGithubApply(consumer) :
|
|
1134
|
+
return apply ? runGithubApply(consumer) : runGithubCheckGate(consumer);
|
|
1087
1135
|
};
|
|
1088
1136
|
|
|
1089
1137
|
const main = async (): Promise<void> => {
|
|
1090
|
-
const { command, consumer, dryRun, from, ref, apply, profile } =
|
|
1091
|
-
process.argv.slice(2)
|
|
1092
|
-
);
|
|
1138
|
+
const { command, consumer, dryRun, from, ref, apply, write, profile } =
|
|
1139
|
+
parseArgs(process.argv.slice(2));
|
|
1093
1140
|
|
|
1094
1141
|
if (command === undefined) {
|
|
1095
1142
|
console.error('standards: a command is required\n');
|
|
@@ -1113,6 +1160,11 @@ const main = async (): Promise<void> => {
|
|
|
1113
1160
|
return;
|
|
1114
1161
|
}
|
|
1115
1162
|
|
|
1163
|
+
if (command === 'dependabot' && write) {
|
|
1164
|
+
await runDependabotWrite(consumer);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1116
1168
|
if (!(await runGateCommand(command, consumer, apply, profile))) {
|
|
1117
1169
|
process.exitCode = 1;
|
|
1118
1170
|
}
|