@davidvornholt/standards 0.10.0 → 0.10.1
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 +6 -1
- package/package.json +11 -2
- package/src/cli.ts +224 -187
- 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/README.md
CHANGED
|
@@ -8,18 +8,23 @@ standards sync
|
|
|
8
8
|
standards check
|
|
9
9
|
standards doctor
|
|
10
10
|
standards structure
|
|
11
|
+
standards dependabot --check
|
|
12
|
+
standards dependabot --write
|
|
11
13
|
standards github --check
|
|
12
14
|
standards github --apply
|
|
13
15
|
```
|
|
14
16
|
|
|
17
|
+
`dependabot --write` regenerates the composed `.github/dependabot.yml` from the canonical `.github/dependabot.base.yml` and the optional repo-owned `.github/dependabot.local.yml`; `init` and `sync` do the same automatically. `dependabot --check` verifies the generated file still matches its sources, and `doctor`/`check` include the same verification. The overlay is deliberately lean and additive only: new update blocks for repo-specific ecosystems, top-level private registry definitions, and `ignore` or `registries` entries appended by repeating a canonical block's normalized target. Matching blocks cannot add labels, groups, cooldowns, pull-request limits, or other policy.
|
|
18
|
+
|
|
15
19
|
`github --check` compares the live GitHub repository (merge settings and rulesets) against the merged declaration in `.github/settings.json` + `.github/settings.local.json`; `github --apply` converges the live repository to exactly the declared state. `check` runs the same comparison whenever `.github/settings.json` is present, and fails closed: declared state the token cannot see fails the gate with a message naming the token fix instead of passing unverified. `--apply` re-diffs the PATCH response, so a setting GitHub accepts with HTTP 200 but silently ignores (plan-unavailable features) is a reported failure, not a false success. A repository whose GitHub plan cannot enforce rulesets can declare that in the seam (see `rulesetEnforcement` below); both commands then skip rulesets and plan-gated repository settings, converge the rest, and print an unprotected-branch notice on every run.
|
|
16
20
|
|
|
17
21
|
`structure` enforces the canonical monorepo structure contract: workspace scripts (`check-types`, `lint`, `lint:fix`, `test`), root gate scripts, filtered Turbo convenience aliases, internal `0.0.0`/`workspace:*` versioning, package `exports`, shared tsconfig inheritance, and browser a11y wiring. Only a workspace containing an explicit `*.a11y.ts` suite must provide a `test:a11y` script and direct `@axe-core/playwright` and `@playwright/test` dependencies. `check` includes `structure`, so these rules gate every consumer PR.
|
|
18
22
|
|
|
19
|
-
`structure --profile source` validates the standards source repository itself, which is deliberately not a consumer. The profile pins its intentional exceptions so they cannot drift silently: exact, ordered root gate scripts that run this CLI from the local checkout (`structure --profile source`, `github --check`, and the Turbo gate) instead of a recursive `standards check`, and a non-private published bin-only CLI workspace that carries a stable release SemVer and exactly maps the `standards` bin to `src/cli.ts` without `exports`. Every other consumer rule still applies.
|
|
23
|
+
`structure --profile source` validates the standards source repository itself, which is deliberately not a consumer. The profile pins its intentional exceptions so they cannot drift silently: exact, ordered root gate scripts that run this CLI from the local checkout (`structure --profile source`, `dependabot --check` or `dependabot --write`, `github --check`, and the Turbo gate) instead of a recursive `standards check`, and a non-private published bin-only CLI workspace that carries a stable release SemVer and exactly maps the `standards` bin to `src/cli.ts` without `exports`. Every other consumer rule still applies.
|
|
20
24
|
|
|
21
25
|
## Configuration
|
|
22
26
|
|
|
27
|
+
- **`.github/dependabot.local.yml`** (optional) — repo-owned Dependabot extension composed with the canonical `.github/dependabot.base.yml` into the generated `.github/dependabot.yml`. May define `updates` and top-level `registries`; a block matching a canonical normalized target may only add `ignore` and/or `registries` entries. Other matching-block policy is rejected deliberately. A missing or comments-only file means no additions. CLI 0.10.1 requires the selected content ref to contain the canonical base and rejects older refs before mutation.
|
|
23
28
|
- **`sync-standards.local.json`** (optional) — consumer-owned sync policy at the repo root, validated by `doctor`/`check` and every `init`/`sync` even when an explicit ref or local source makes its `"ref"` irrelevant. All fields optional; a missing file means the defaults. `"ref"` is a non-empty single-line string that pins a tag, branch, or full commit sha to sync from instead of `main` (an explicit `--ref` overrides it; a local-path `--from` source is used as-is and ignores only the validated pin). `"autoSync": false` is read by the standards-sync workflow, not the CLI, and skips the weekly scheduled run. Version 0.7.0 removes the legacy `STANDARDS_AUTO_SYNC` and `STANDARDS_SYNC_REF` variable behavior; consumers must upgrade the package and lockfile before adopting a policy file.
|
|
24
29
|
- **`.github/settings.local.json` `"rulesetEnforcement": "unavailable-on-plan"`** (optional) — declares that the repository's GitHub plan cannot enforce rulesets (private repositories on GitHub Free, both personal accounts and organizations). `github --check` and `--apply` then skip rulesets instead of comparing them, because a comparison cannot be trusted there: personal accounts answer ruleset reads with HTTP 403, and free-plan organizations report declared rulesets as active while silently not enforcing them. Plan-gated repository settings that only function alongside branch protection (`allow_auto_merge`) are skipped for the same reason — GitHub accepts a PATCH for them with HTTP 200 and silently keeps the old value. The remaining repository merge settings are still checked and converged, and both commands print an unprotected-branch notice on every run. The only accepted value is `"unavailable-on-plan"` (enforcement is the default; omit the key on paid plans), and combining the opt-out with additional local rulesets is rejected. After upgrading the plan, remove the declaration and run `bun standards github --apply` to restore enforcement.
|
|
25
30
|
- **`GH_TOKEN` / `GITHUB_TOKEN`** (optional) — GitHub API token for the `github` command and the GitHub portion of `check`. When neither is set, the token from the local `gh` CLI is used; with no token at all, reads still work on public repositories. `--apply` needs an admin token. GitHub's REST API reveals repo merge settings only to write-capable viewers — `check` retries REST-hidden keys over GraphQL, which serves them to read-only tokens — and ruleset `bypass_actors` requires repository Administration read; a token that still cannot see declared state fails the check rather than passing unverified. In CI, where the workflow token can never hold that permission, the canonical workflow decrypts `ci.github_settings_read_token` from the SOPS-encrypted `secrets/ci.yaml` — a fine-grained PAT with read-only "Administration" access to the repository.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davidvornholt/standards",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
"LICENSE",
|
|
21
21
|
"README.md",
|
|
22
22
|
"src/cli.ts",
|
|
23
|
+
"src/dependabot-compose-input.ts",
|
|
24
|
+
"src/dependabot-compose.ts",
|
|
25
|
+
"src/dependabot-inspect.ts",
|
|
26
|
+
"src/dependabot-update.ts",
|
|
23
27
|
"src/github-api.ts",
|
|
24
28
|
"src/github-apply.ts",
|
|
25
29
|
"src/github-command-shared.ts",
|
|
@@ -31,7 +35,9 @@
|
|
|
31
35
|
"src/structure-check.ts",
|
|
32
36
|
"src/structure-profile.ts",
|
|
33
37
|
"src/structure-script.ts",
|
|
34
|
-
"src/structure-workspace.ts"
|
|
38
|
+
"src/structure-workspace.ts",
|
|
39
|
+
"src/yaml-emit.ts",
|
|
40
|
+
"src/yaml-parse.ts"
|
|
35
41
|
],
|
|
36
42
|
"scripts": {
|
|
37
43
|
"lint": "biome check --error-on-warnings .",
|
|
@@ -49,5 +55,8 @@
|
|
|
49
55
|
},
|
|
50
56
|
"publishConfig": {
|
|
51
57
|
"access": "public"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"yaml": "^2.9.0"
|
|
52
61
|
}
|
|
53
62
|
}
|
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,15 +33,21 @@ 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.
|
|
@@ -79,6 +86,7 @@ type Source = {
|
|
|
79
86
|
|
|
80
87
|
type Command =
|
|
81
88
|
| 'check'
|
|
89
|
+
| 'dependabot'
|
|
82
90
|
| 'doctor'
|
|
83
91
|
| 'github'
|
|
84
92
|
| 'help'
|
|
@@ -93,6 +101,7 @@ type CliOptions = {
|
|
|
93
101
|
readonly from: string | undefined;
|
|
94
102
|
readonly ref: string | undefined;
|
|
95
103
|
readonly apply: boolean;
|
|
104
|
+
readonly write: boolean;
|
|
96
105
|
readonly profile: StructureProfile;
|
|
97
106
|
};
|
|
98
107
|
|
|
@@ -403,6 +412,12 @@ const runInit = async (
|
|
|
403
412
|
): Promise<void> => {
|
|
404
413
|
const seeds = await seedTargets(src.dir, manifest.seedDir);
|
|
405
414
|
assertDisjoint(manifest.paths, [...seeds.keys()]);
|
|
415
|
+
const prospectiveDependabot = await prepareProspectiveDependabot(
|
|
416
|
+
manifest,
|
|
417
|
+
src.dir,
|
|
418
|
+
consumer,
|
|
419
|
+
seeds.get(DEPENDABOT_LOCAL_FILE) ?? null,
|
|
420
|
+
);
|
|
406
421
|
await Promise.all(
|
|
407
422
|
[...seeds].map(async ([rel, abs]) => {
|
|
408
423
|
const dest = join(consumer, rel);
|
|
@@ -423,6 +438,7 @@ const runInit = async (
|
|
|
423
438
|
dryRun: false,
|
|
424
439
|
});
|
|
425
440
|
reportMirror(result, false);
|
|
441
|
+
await applyProspectiveDependabot(consumer, prospectiveDependabot, false);
|
|
426
442
|
await writeLock(consumer, {
|
|
427
443
|
upstream: manifest.upstream,
|
|
428
444
|
sha: src.sha,
|
|
@@ -441,6 +457,12 @@ const runSync = async (
|
|
|
441
457
|
): Promise<void> => {
|
|
442
458
|
const seeds = await seedTargets(src.dir, manifest.seedDir);
|
|
443
459
|
assertDisjoint(manifest.paths, [...seeds.keys()]);
|
|
460
|
+
const prospectiveDependabot = await prepareProspectiveDependabot(
|
|
461
|
+
manifest,
|
|
462
|
+
src.dir,
|
|
463
|
+
consumer,
|
|
464
|
+
null,
|
|
465
|
+
);
|
|
444
466
|
const lock = await readLock(consumer);
|
|
445
467
|
const result = await mirror({
|
|
446
468
|
manifest,
|
|
@@ -450,6 +472,7 @@ const runSync = async (
|
|
|
450
472
|
dryRun,
|
|
451
473
|
});
|
|
452
474
|
reportMirror(result, dryRun);
|
|
475
|
+
await applyProspectiveDependabot(consumer, prospectiveDependabot, dryRun);
|
|
453
476
|
if (dryRun) {
|
|
454
477
|
return;
|
|
455
478
|
}
|
|
@@ -510,177 +533,159 @@ const runCheck = async (consumer: string): Promise<boolean> => {
|
|
|
510
533
|
const readTextIfPresent = async (path: string): Promise<string | null> =>
|
|
511
534
|
existsSync(path) ? readFile(path, 'utf8') : null;
|
|
512
535
|
|
|
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;
|
|
533
|
-
};
|
|
534
|
-
|
|
535
|
-
const inspectDependabotSchedule = (
|
|
536
|
-
schedule: unknown,
|
|
537
|
-
label: string,
|
|
538
|
-
): ReadonlyArray<string> => {
|
|
539
|
-
if (!(isRecord(schedule) && isNonEmptyString(schedule.interval))) {
|
|
540
|
-
return [`${label} must define schedule.interval`];
|
|
541
|
-
}
|
|
542
|
-
if (!DEPENDABOT_SCHEDULE_INTERVALS.has(schedule.interval)) {
|
|
543
|
-
return [`${label} has an unsupported schedule.interval`];
|
|
544
|
-
}
|
|
545
|
-
if (schedule.interval === 'cron' && !isNonEmptyString(schedule.cronjob)) {
|
|
546
|
-
return [`${label} must define schedule.cronjob for a cron interval`];
|
|
547
|
-
}
|
|
548
|
-
return [];
|
|
536
|
+
type DependabotSources = {
|
|
537
|
+
readonly base: string | null;
|
|
538
|
+
readonly local: string | null;
|
|
539
|
+
readonly current: string | null;
|
|
549
540
|
};
|
|
550
541
|
|
|
551
|
-
|
|
542
|
+
const readDependabotSources = async (
|
|
543
|
+
consumer: string,
|
|
544
|
+
): Promise<DependabotSources> => ({
|
|
545
|
+
base: await readTextIfPresent(join(consumer, DEPENDABOT_BASE_FILE)),
|
|
546
|
+
local: await readTextIfPresent(join(consumer, DEPENDABOT_LOCAL_FILE)),
|
|
547
|
+
current: await readTextIfPresent(join(consumer, DEPENDABOT_FILE)),
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// Compose the generated Dependabot config from its on-disk sources, folding
|
|
551
|
+
// Dependabot semantic validation into the same problem list.
|
|
552
|
+
const composedDependabot = (
|
|
553
|
+
sources: DependabotSources,
|
|
554
|
+
): {
|
|
555
|
+
readonly composed: string | null;
|
|
552
556
|
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)) {
|
|
557
|
+
} => {
|
|
558
|
+
if (sources.base === null) {
|
|
563
559
|
return {
|
|
560
|
+
composed: null,
|
|
564
561
|
problems: [
|
|
565
|
-
|
|
562
|
+
`${DEPENDABOT_BASE_FILE} must exist; run \`bun standards sync\` to mirror it in`,
|
|
566
563
|
],
|
|
567
|
-
scheduledGroups: new Set(),
|
|
568
564
|
};
|
|
569
565
|
}
|
|
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
|
-
}
|
|
566
|
+
const result = composeDependabot(sources.base, sources.local);
|
|
567
|
+
if (result.composed === null) {
|
|
568
|
+
return result;
|
|
582
569
|
}
|
|
583
|
-
|
|
570
|
+
const problems = inspectDependabot(result.composed);
|
|
571
|
+
return problems.length > 0 ? { composed: null, problems } : result;
|
|
584
572
|
};
|
|
585
573
|
|
|
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`);
|
|
574
|
+
const dependabotProblems = async (
|
|
575
|
+
consumer: string,
|
|
576
|
+
): Promise<ReadonlyArray<string>> => {
|
|
577
|
+
const sources = await readDependabotSources(consumer);
|
|
578
|
+
const { composed, problems } = composedDependabot(sources);
|
|
579
|
+
if (composed === null) {
|
|
580
|
+
return problems;
|
|
606
581
|
}
|
|
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
|
-
);
|
|
582
|
+
if (sources.current !== composed) {
|
|
583
|
+
return [
|
|
584
|
+
`${DEPENDABOT_FILE} does not match its composed sources; regenerate it with \`bun standards dependabot --write\``,
|
|
585
|
+
];
|
|
617
586
|
}
|
|
587
|
+
return [];
|
|
588
|
+
};
|
|
618
589
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
}
|
|
590
|
+
const writeComposedDependabot = async (
|
|
591
|
+
consumer: string,
|
|
592
|
+
composed: string,
|
|
593
|
+
): Promise<void> => {
|
|
594
|
+
const dest = join(consumer, DEPENDABOT_FILE);
|
|
595
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
596
|
+
await writeFile(dest, composed);
|
|
597
|
+
};
|
|
628
598
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
599
|
+
const composeProblemsError = (problems: ReadonlyArray<string>): Error =>
|
|
600
|
+
new Error(
|
|
601
|
+
[
|
|
602
|
+
`cannot compose ${DEPENDABOT_FILE}:`,
|
|
603
|
+
...problems.map((problem) => ` - ${problem}`),
|
|
604
|
+
].join('\n'),
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
type ProspectiveDependabot = {
|
|
608
|
+
readonly composed: string;
|
|
609
|
+
readonly current: string | null;
|
|
637
610
|
};
|
|
638
611
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
612
|
+
// Validate the incoming canonical base against the effective local overlay
|
|
613
|
+
// before init/sync mutates any consumer-owned, canonical, generated, or lock
|
|
614
|
+
// file. For init, an existing overlay wins; otherwise the overlay seed is what
|
|
615
|
+
// the command is about to install.
|
|
616
|
+
const prepareProspectiveDependabot = async (
|
|
617
|
+
manifest: Manifest,
|
|
618
|
+
srcDir: string,
|
|
619
|
+
consumer: string,
|
|
620
|
+
localSeed: string | null,
|
|
621
|
+
): Promise<ProspectiveDependabot> => {
|
|
622
|
+
const incoming = await listManaged(srcDir, manifest.paths);
|
|
623
|
+
const basePath = incoming.get(DEPENDABOT_BASE_FILE);
|
|
624
|
+
if (basePath === undefined) {
|
|
625
|
+
throw new Error(
|
|
626
|
+
`source content must manage ${DEPENDABOT_BASE_FILE}; @davidvornholt/standards 0.10.1 requires a 0.10.1-compatible content ref`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
const existingLocal = await readTextIfPresent(
|
|
630
|
+
join(consumer, DEPENDABOT_LOCAL_FILE),
|
|
631
|
+
);
|
|
632
|
+
const local =
|
|
633
|
+
existingLocal ??
|
|
634
|
+
(localSeed === null ? null : await readFile(localSeed, 'utf8'));
|
|
635
|
+
const current = await readTextIfPresent(join(consumer, DEPENDABOT_FILE));
|
|
636
|
+
const sources = {
|
|
637
|
+
base: await readFile(basePath, 'utf8'),
|
|
638
|
+
local,
|
|
639
|
+
current,
|
|
640
|
+
};
|
|
641
|
+
const { composed, problems } = composedDependabot(sources);
|
|
642
|
+
if (composed === null) {
|
|
643
|
+
throw composeProblemsError(problems);
|
|
646
644
|
}
|
|
645
|
+
return { composed, current };
|
|
646
|
+
};
|
|
647
647
|
|
|
648
|
-
|
|
649
|
-
|
|
648
|
+
const applyProspectiveDependabot = async (
|
|
649
|
+
consumer: string,
|
|
650
|
+
prospective: ProspectiveDependabot,
|
|
651
|
+
dryRun: boolean,
|
|
652
|
+
): Promise<void> => {
|
|
653
|
+
if (prospective.current === prospective.composed) {
|
|
654
|
+
return;
|
|
650
655
|
}
|
|
651
|
-
if (
|
|
652
|
-
|
|
656
|
+
if (dryRun) {
|
|
657
|
+
console.log(` would generate ${DEPENDABOT_FILE}`);
|
|
658
|
+
return;
|
|
653
659
|
}
|
|
660
|
+
await writeComposedDependabot(consumer, prospective.composed);
|
|
661
|
+
console.log(` generated ${DEPENDABOT_FILE}`);
|
|
662
|
+
};
|
|
654
663
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
664
|
+
const runDependabotCheck = async (consumer: string): Promise<boolean> => {
|
|
665
|
+
const problems = await dependabotProblems(consumer);
|
|
666
|
+
if (problems.length > 0) {
|
|
667
|
+
console.error(`standards dependabot: ${problems.length} problem(s):`);
|
|
668
|
+
console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
|
|
669
|
+
return false;
|
|
659
670
|
}
|
|
671
|
+
console.log(
|
|
672
|
+
`standards dependabot: ${DEPENDABOT_FILE} matches its composed sources`,
|
|
673
|
+
);
|
|
674
|
+
return true;
|
|
675
|
+
};
|
|
660
676
|
|
|
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
|
-
}
|
|
677
|
+
const runDependabotWrite = async (consumer: string): Promise<void> => {
|
|
678
|
+
const sources = await readDependabotSources(consumer);
|
|
679
|
+
const { composed, problems } = composedDependabot(sources);
|
|
680
|
+
if (composed === null) {
|
|
681
|
+
throw composeProblemsError(problems);
|
|
674
682
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
problems.push(
|
|
679
|
-
`.github/dependabot.yml must include a root-directory ${ecosystem} ecosystem`,
|
|
680
|
-
);
|
|
681
|
-
}
|
|
683
|
+
if (sources.current === composed) {
|
|
684
|
+
console.log(`standards dependabot: ${DEPENDABOT_FILE} is up to date`);
|
|
685
|
+
return;
|
|
682
686
|
}
|
|
683
|
-
|
|
687
|
+
await writeComposedDependabot(consumer, composed);
|
|
688
|
+
console.log(`standards dependabot: generated ${DEPENDABOT_FILE}`);
|
|
684
689
|
};
|
|
685
690
|
|
|
686
691
|
const inspectPackageJson = (packageRaw: string): ReadonlyArray<string> => {
|
|
@@ -726,14 +731,7 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
|
|
|
726
731
|
problems.push('AGENTS.local.md must exist for project-specific guidance');
|
|
727
732
|
}
|
|
728
733
|
|
|
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
|
-
}
|
|
734
|
+
problems.push(...(await dependabotProblems(consumer)));
|
|
737
735
|
|
|
738
736
|
const packagePath = join(consumer, 'package.json');
|
|
739
737
|
const packageRaw = await readTextIfPresent(packagePath);
|
|
@@ -773,13 +771,14 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
|
|
|
773
771
|
const USAGE = `Usage: standards <command> [options]
|
|
774
772
|
|
|
775
773
|
Commands:
|
|
776
|
-
init
|
|
777
|
-
sync
|
|
778
|
-
check
|
|
779
|
-
doctor
|
|
780
|
-
structure
|
|
781
|
-
|
|
782
|
-
|
|
774
|
+
init Bootstrap a consumer repo: seed repo-owned files, mirror canonical files, write the lock
|
|
775
|
+
sync Mirror canonical files from upstream, regenerate the composed Dependabot config, and rewrite the lock
|
|
776
|
+
check Verify canonical files, extension seams, monorepo structure, and GitHub settings
|
|
777
|
+
doctor Validate extension seams only
|
|
778
|
+
structure Validate monorepo structure rules only
|
|
779
|
+
dependabot Verify (--check) or regenerate (--write) the composed .github/dependabot.yml
|
|
780
|
+
github Compare (--check) or converge (--apply) live GitHub settings
|
|
781
|
+
help Show this help
|
|
783
782
|
|
|
784
783
|
Options:
|
|
785
784
|
--dir <path> Consumer directory to operate on (default: current directory)
|
|
@@ -787,12 +786,14 @@ Options:
|
|
|
787
786
|
--from <src> Upstream override for init/sync (GitHub repo or local path)
|
|
788
787
|
--ref <ref> Upstream tag, branch, or full commit sha for init/sync (remote Git/GitHub sources only; default: main)
|
|
789
788
|
--dry-run Preview a sync without writing anything
|
|
790
|
-
--check With github: compare
|
|
791
|
-
--apply With github: converge the live repository (needs admin auth)
|
|
789
|
+
--check With github/dependabot: compare against the declared sources (default)
|
|
790
|
+
--apply With github: converge the live repository (needs admin auth)
|
|
791
|
+
--write With dependabot: regenerate the composed .github/dependabot.yml`;
|
|
792
792
|
|
|
793
793
|
const commandFromArg = (arg: string): Command => {
|
|
794
794
|
if (
|
|
795
795
|
arg === 'check' ||
|
|
796
|
+
arg === 'dependabot' ||
|
|
796
797
|
arg === 'doctor' ||
|
|
797
798
|
arg === 'github' ||
|
|
798
799
|
arg === 'help' ||
|
|
@@ -832,6 +833,44 @@ const nextOptionValue = (
|
|
|
832
833
|
return value;
|
|
833
834
|
};
|
|
834
835
|
|
|
836
|
+
type ParsedFlags = {
|
|
837
|
+
readonly command: Command | undefined;
|
|
838
|
+
readonly checkFlag: boolean;
|
|
839
|
+
readonly apply: boolean;
|
|
840
|
+
readonly write: boolean;
|
|
841
|
+
readonly ref: string | undefined;
|
|
842
|
+
readonly profile: StructureProfile | undefined;
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
// Every option is only meaningful with specific commands; reject the rest so a
|
|
846
|
+
// typo fails loudly instead of silently doing the default thing.
|
|
847
|
+
const assertOptionUsage = (flags: ParsedFlags): void => {
|
|
848
|
+
const { command, checkFlag, apply, write, ref, profile } = flags;
|
|
849
|
+
if (checkFlag && command !== 'github' && command !== 'dependabot') {
|
|
850
|
+
throw new Error(
|
|
851
|
+
'--check is only valid with the github and dependabot commands',
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
if (apply && command !== 'github') {
|
|
855
|
+
throw new Error('--apply is only valid with the github command');
|
|
856
|
+
}
|
|
857
|
+
if (apply && checkFlag) {
|
|
858
|
+
throw new Error('github accepts exactly one of --check or --apply');
|
|
859
|
+
}
|
|
860
|
+
if (write && command !== 'dependabot') {
|
|
861
|
+
throw new Error('--write is only valid with the dependabot command');
|
|
862
|
+
}
|
|
863
|
+
if (write && checkFlag) {
|
|
864
|
+
throw new Error('dependabot accepts exactly one of --check or --write');
|
|
865
|
+
}
|
|
866
|
+
if (ref !== undefined && command !== 'init' && command !== 'sync') {
|
|
867
|
+
throw new Error('--ref is only valid with the init and sync commands');
|
|
868
|
+
}
|
|
869
|
+
if (profile !== undefined && command !== 'structure') {
|
|
870
|
+
throw new Error('--profile is only valid with the structure command');
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
|
|
835
874
|
const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
836
875
|
let command: Command | undefined;
|
|
837
876
|
let consumer = process.cwd();
|
|
@@ -840,6 +879,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
840
879
|
let ref: string | undefined;
|
|
841
880
|
let checkFlag = false;
|
|
842
881
|
let apply = false;
|
|
882
|
+
let write = false;
|
|
843
883
|
let profile: StructureProfile | undefined;
|
|
844
884
|
|
|
845
885
|
for (let index = 0; index < argv.length; index += 1) {
|
|
@@ -851,6 +891,9 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
851
891
|
case '--check':
|
|
852
892
|
checkFlag = true;
|
|
853
893
|
break;
|
|
894
|
+
case '--write':
|
|
895
|
+
write = true;
|
|
896
|
+
break;
|
|
854
897
|
case '--dir':
|
|
855
898
|
consumer = nextOptionValue(argv, index);
|
|
856
899
|
index += 1;
|
|
@@ -879,21 +922,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
879
922
|
}
|
|
880
923
|
}
|
|
881
924
|
|
|
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
|
-
}
|
|
925
|
+
assertOptionUsage({ command, checkFlag, apply, write, ref, profile });
|
|
897
926
|
|
|
898
927
|
return {
|
|
899
928
|
command,
|
|
@@ -902,6 +931,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
902
931
|
from,
|
|
903
932
|
ref,
|
|
904
933
|
apply,
|
|
934
|
+
write,
|
|
905
935
|
profile: profile ?? 'consumer',
|
|
906
936
|
};
|
|
907
937
|
};
|
|
@@ -1069,7 +1099,7 @@ const runSyncCommand = async (
|
|
|
1069
1099
|
|
|
1070
1100
|
// Commands whose success is reported through the exit code.
|
|
1071
1101
|
const runGateCommand = (
|
|
1072
|
-
command: 'check' | 'doctor' | 'github' | 'structure',
|
|
1102
|
+
command: 'check' | 'dependabot' | 'doctor' | 'github' | 'structure',
|
|
1073
1103
|
consumer: string,
|
|
1074
1104
|
apply: boolean,
|
|
1075
1105
|
profile: StructureProfile,
|
|
@@ -1077,6 +1107,9 @@ const runGateCommand = (
|
|
|
1077
1107
|
if (command === 'check') {
|
|
1078
1108
|
return runCheckCommand(consumer);
|
|
1079
1109
|
}
|
|
1110
|
+
if (command === 'dependabot') {
|
|
1111
|
+
return runDependabotCheck(consumer);
|
|
1112
|
+
}
|
|
1080
1113
|
if (command === 'doctor') {
|
|
1081
1114
|
return runDoctor(consumer);
|
|
1082
1115
|
}
|
|
@@ -1087,9 +1120,8 @@ const runGateCommand = (
|
|
|
1087
1120
|
};
|
|
1088
1121
|
|
|
1089
1122
|
const main = async (): Promise<void> => {
|
|
1090
|
-
const { command, consumer, dryRun, from, ref, apply, profile } =
|
|
1091
|
-
process.argv.slice(2)
|
|
1092
|
-
);
|
|
1123
|
+
const { command, consumer, dryRun, from, ref, apply, write, profile } =
|
|
1124
|
+
parseArgs(process.argv.slice(2));
|
|
1093
1125
|
|
|
1094
1126
|
if (command === undefined) {
|
|
1095
1127
|
console.error('standards: a command is required\n');
|
|
@@ -1113,6 +1145,11 @@ const main = async (): Promise<void> => {
|
|
|
1113
1145
|
return;
|
|
1114
1146
|
}
|
|
1115
1147
|
|
|
1148
|
+
if (command === 'dependabot' && write) {
|
|
1149
|
+
await runDependabotWrite(consumer);
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1116
1153
|
if (!(await runGateCommand(command, consumer, apply, profile))) {
|
|
1117
1154
|
process.exitCode = 1;
|
|
1118
1155
|
}
|