@davidvornholt/standards 0.17.3 → 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/README.md +2 -2
- package/package.json +10 -1
- package/src/cli.ts +307 -82
- package/src/creds-login-github-manifest.ts +14 -7
- package/src/dependabot-directory-character-class.ts +139 -0
- package/src/dependabot-directory-normalize.ts +33 -0
- package/src/dependabot-directory-overlap.ts +139 -0
- package/src/dependabot-update.ts +32 -4
- package/src/github-api.ts +0 -80
- package/src/github-apply.ts +3 -2
- package/src/github-bypass-actors.ts +56 -0
- package/src/github-command-shared.ts +9 -2
- package/src/github-diff.ts +2 -109
- package/src/github-graphql.ts +198 -0
- package/src/github-live-drift.ts +57 -6
- package/src/github-ruleset-api.ts +57 -0
- package/src/github-ruleset-diff.ts +181 -0
- package/src/github-ruleset-visibility.ts +100 -0
- package/src/github-settings-parse.ts +12 -10
- package/src/github-settings.ts +3 -2
- package/src/managed-files.ts +116 -14
- package/src/managed-path-ownership.ts +85 -0
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ standards poller --config <path>
|
|
|
25
25
|
standards poller --print-units --config <path>
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
`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.
|
|
28
|
+
`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. Update blocks for the same ecosystem and target branch must not select intersecting directories after Dependabot's path normalization. A plural `directories` target is expanded as a glob when it contains `*`, `?`, or both `[` and `]`: `*` and bare `**` stay within one path segment, `**/` matches zero or more directory segments, `?` matches one non-separator character, and bracket expressions match character sets or ranges. Singular `directory` values are always literal.
|
|
29
29
|
|
|
30
30
|
`dev-env` decrypts the SOPS-encrypted `secrets/dev.yaml` — keyed by workspace group and a one-segment kebab-case workspace name (`apps.<name>`, `packages.<name>`), each mapping portable environment variable names to string values — and transactionally writes every declared workspace's `.env.local` with owner-only permissions and a do-not-edit header. Its Bun-specific dotenv codec exactly round-trips every value it accepts without adding helper environment variables; a value the codec cannot encode exactly is gathered as a validation problem instead of being changed silently. Validation gathers all document, containment, destination-type, workspace, and gitignore problems before writing anything. Generation then stages every output in its destination directory and rolls the whole set back if staging or commit fails, preserving prior contents, modes, and absent files; symlink destinations, duplicate resolved destinations, changed parent-directory identities, and symlinked workspace paths are rejected without following the link. Cleanup trouble after a completed commit is reported loudly as a warning rather than falsely reporting generation failure. Parent identities are checked immediately before each path operation; an unavoidable swap in the syscall-sized interval after a check remains outside the non-hostile maintainer threat model. The canonical `justfile` exposes the command as `just dev-env-generate`, and `just dev-refresh` chains a `just secrets edit dev` session into regeneration.
|
|
31
31
|
|
|
@@ -48,6 +48,6 @@ standards poller --print-units --config <path>
|
|
|
48
48
|
- **`.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.
|
|
49
49
|
- **Poller config file** (required for `poller`, passed via `--config`) — host-level JSON, deliberately not repository state. Required: `"repos"` (1–12 unique `owner/repo` strings), `"model"` and `"reasoningEffort"` (passed to `codex exec`; the model choice is deliberate and has no default). The 12-repository maximum and five-minute acknowledgement cooldown keep supported mixed fix/review queues and persisted review-plan recovery below the 5,000-request/hour GitHub API budget with at least 1,400 requests/hour reserved for pagination, active jobs, writes, and other token users; the compound regression bound covers four acknowledged fixes plus four acknowledged reviews with persisted plans in every repository at no more than 3,312 requests/hour. Existing configs above the repository limit must be reduced or split across pollers with independent GitHub API budgets before upgrading. Optional: `"maxJobsPerTick"` (default 1 — the maximum number of new Codex runs the sequential worker may start in one tick), `"staleClaimHours"` (default 6; must exceed `runTimeoutMinutes` so a sweep can never release the claim of a job that is still running), `"runTimeoutMinutes"` (default 240 — the timeout catches wedged agents, not honest multi-hour review runs), `"cacheDir"` (default `~/.cache/standards-poller`; holds bare mirrors and job worktrees; relative paths resolve against the config file), `"extraCodexArgs"` (extra `codex exec` arguments, e.g. enabling the collaboration feature for subagent fan-out). Unknown keys are rejected. The poller needs `git`, `gh`, `codex` (authenticated — its rotating auth state must live in a writable location), and a GitHub token per the bullet below; the token needs issues, pull-requests, and contents write on the watched repos, and nothing else.
|
|
50
50
|
- **Broker store** (created by `creds login`) — machine-global root credentials at `$XDG_CONFIG_HOME/standards/broker.yaml` (default `~/.config/standards/broker.yaml`; override with `STANDARDS_BROKER_FILE`), holding the GitHub App identity (id, slug, client id, private key) and one bootstrap token per Cloudflare account. The file is plaintext with `0600` permissions — deliberately the same local-account trust boundary as the plaintext personal age identity at `~/.config/sops/age/keys.txt`, which unlocks the same secrets; SOPS-encrypting the store to that adjacent identity would not improve custody. Writes use an owner-only temporary file and atomic replacement, and simultaneous provider logins merge rather than overwrite each other; `creds status` warns when the final mode is broader. The store lives outside every repository, is never synced or backed up, and losing it is cheap: re-run the two login commands and revoke the old App key and bootstrap token. On an additional machine, do not create a second App: generate an additional private key from the existing App's settings page (GitHub Apps support several active keys; key generation has no API) and add it to that machine's store, so retiring a machine revokes one key, not the setup.
|
|
51
|
-
- **`GH_TOKEN` / `GITHUB_TOKEN`** (optional generally; poller automation requires this or a logged-in `gh`) — GitHub API token for the `github` command and the GitHub portion of `check`; poller runs pass environment tokens through to Codex, while a local poller can share the service identity's authenticated `gh` state. When neither is available, other commands can still read public repositories anonymously, but poller writes and `github --apply` fail. GitHub's REST API reveals repo merge settings only to write-capable viewers
|
|
51
|
+
- **`GH_TOKEN` / `GITHUB_TOKEN`** (optional generally; poller automation requires this or a logged-in `gh`) — GitHub API token for the `github` command and the GitHub portion of `check`; poller runs pass environment tokens through to Codex, while a local poller can share the service identity's authenticated `gh` state. When neither is available, other commands can still read public repositories anonymously, but poller writes and `github --apply` fail. GitHub's REST API reveals repo merge settings only to write-capable viewers, and `check` retries REST-hidden keys over GraphQL, which serves them to read-only tokens. REST serves ruleset `bypass_actors` only to a user-scoped token holding repository Administration read (a fine-grained PAT with that permission, such as CI's settings token, or the local `gh` CLI), and never to a GitHub App installation token at any permission level, so `check` retries the hidden lists over GraphQL, which withholds the actor identities but answers an exact bypass-actor count (established for a GitHub App installation token and for a non-admin classic-scope viewer, not for every identity class): a declared-empty list verifies exactly, a count that disagrees with the declared length is drift, and a matching non-zero count leaves *which* actors bypass unconfirmed, so the gate fails closed on that ruleset. An installation-token run therefore leaves a declared non-empty list failing until the check is re-run locally with admin `gh` auth; broadening the settings PAT beyond read-only Administration is not the fix. Private-repository labels require Issues read (or Pull requests read); a token that still cannot see declared state fails the check rather than passing unverified. In CI, the canonical workflow decrypts `ci.github_settings_read_token` from the SOPS-encrypted `secrets/ci.yaml`: a repository-scoped fine-grained PAT with read-only "Administration" and "Issues" access.
|
|
52
52
|
|
|
53
53
|
See the standards repository README for the ownership model and adoption workflow.
|
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": {
|
|
@@ -54,6 +54,9 @@
|
|
|
54
54
|
"src/creds-store-lock.ts",
|
|
55
55
|
"src/dependabot-compose-input.ts",
|
|
56
56
|
"src/dependabot-compose.ts",
|
|
57
|
+
"src/dependabot-directory-character-class.ts",
|
|
58
|
+
"src/dependabot-directory-normalize.ts",
|
|
59
|
+
"src/dependabot-directory-overlap.ts",
|
|
57
60
|
"src/dependabot-inspect.ts",
|
|
58
61
|
"src/dependabot-update.ts",
|
|
59
62
|
"src/dev-env-document.ts",
|
|
@@ -66,17 +69,23 @@
|
|
|
66
69
|
"src/dev-env.ts",
|
|
67
70
|
"src/github-api.ts",
|
|
68
71
|
"src/github-apply.ts",
|
|
72
|
+
"src/github-bypass-actors.ts",
|
|
69
73
|
"src/github-command-shared.ts",
|
|
70
74
|
"src/github-commands.ts",
|
|
71
75
|
"src/github-diff.ts",
|
|
76
|
+
"src/github-graphql.ts",
|
|
72
77
|
"src/github-labels.ts",
|
|
73
78
|
"src/github-label-identity.ts",
|
|
74
79
|
"src/github-live-drift.ts",
|
|
75
80
|
"src/github-paginate.ts",
|
|
81
|
+
"src/github-ruleset-api.ts",
|
|
82
|
+
"src/github-ruleset-diff.ts",
|
|
83
|
+
"src/github-ruleset-visibility.ts",
|
|
76
84
|
"src/github-settings-merge.ts",
|
|
77
85
|
"src/github-settings-parse.ts",
|
|
78
86
|
"src/github-settings.ts",
|
|
79
87
|
"src/managed-files.ts",
|
|
88
|
+
"src/managed-path-ownership.ts",
|
|
80
89
|
"src/poller-approval.ts",
|
|
81
90
|
"src/poller-acknowledgements.ts",
|
|
82
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,61 +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
|
-
const changes =
|
|
332
|
-
result.created.length + result.updated.length + result.deleted.length;
|
|
333
|
-
console.log(
|
|
334
|
-
changes === 0
|
|
335
|
-
? 'dry run: already in sync; no changes'
|
|
336
|
-
: `dry run: ${result.created.length} to create, ${result.updated.length} to update, ${result.deleted.length} to delete`,
|
|
337
|
-
);
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
|
-
for (const rel of result.deleted) {
|
|
341
|
-
console.log(` deleted ${rel} (removed upstream)`);
|
|
342
|
-
}
|
|
343
|
-
if (result.tampered.length > 0) {
|
|
344
|
-
console.log(
|
|
345
|
-
` overwrote ${result.tampered.length} locally-modified canonical file(s): ${result.tampered.join(', ')}`,
|
|
346
|
-
);
|
|
532
|
+
for (const line of mirrorLines(result, dryRun)) {
|
|
533
|
+
console.log(` ${line}`);
|
|
347
534
|
}
|
|
348
535
|
};
|
|
349
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".
|
|
539
|
+
const reportDryRunSummary = (
|
|
540
|
+
result: MirrorResult,
|
|
541
|
+
generated: boolean,
|
|
542
|
+
): void => {
|
|
543
|
+
const unlocked = result.retained.length + result.stale.length;
|
|
544
|
+
const changes =
|
|
545
|
+
result.created.length +
|
|
546
|
+
result.updated.length +
|
|
547
|
+
result.deleted.length +
|
|
548
|
+
unlocked +
|
|
549
|
+
Number(generated);
|
|
550
|
+
console.log(
|
|
551
|
+
changes === 0
|
|
552
|
+
? 'dry run: already in sync; no changes'
|
|
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`,
|
|
554
|
+
);
|
|
555
|
+
};
|
|
556
|
+
|
|
350
557
|
const seedTargets = (
|
|
351
558
|
srcDir: string,
|
|
352
559
|
seedDir: string,
|
|
353
|
-
): Promise<ReadonlyMap<string,
|
|
560
|
+
): Promise<ReadonlyMap<string, ManagedEntry>> => {
|
|
354
561
|
const root = join(srcDir, seedDir);
|
|
355
562
|
return listManagedFiles(root, ['.']);
|
|
356
563
|
};
|
|
@@ -362,27 +569,39 @@ const runInit = async (
|
|
|
362
569
|
): Promise<void> => {
|
|
363
570
|
const seeds = await seedTargets(src.dir, manifest.seedDir);
|
|
364
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
|
+
);
|
|
365
582
|
const prospectiveDependabot = await prepareProspectiveDependabot(
|
|
366
|
-
|
|
367
|
-
src.dir,
|
|
583
|
+
upstream,
|
|
368
584
|
consumer,
|
|
369
585
|
seeds.get(DEPENDABOT_LOCAL_FILE) ?? null,
|
|
370
586
|
);
|
|
371
587
|
await Promise.all(
|
|
372
|
-
[...seeds].map(async ([rel,
|
|
588
|
+
[...seeds].map(async ([rel, entry]) => {
|
|
373
589
|
const dest = join(consumer, rel);
|
|
374
|
-
|
|
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) {
|
|
375
595
|
console.log(` kept ${rel} (already present)`);
|
|
376
596
|
return;
|
|
377
597
|
}
|
|
378
598
|
await mkdir(dirname(dest), { recursive: true });
|
|
379
|
-
await
|
|
599
|
+
await writeManagedEntry(dest, entry);
|
|
380
600
|
console.log(` seeded ${rel}`);
|
|
381
601
|
}),
|
|
382
602
|
);
|
|
383
603
|
const result = await mirror({
|
|
384
|
-
|
|
385
|
-
srcDir: src.dir,
|
|
604
|
+
upstream,
|
|
386
605
|
consumer,
|
|
387
606
|
previous: {},
|
|
388
607
|
dryRun: false,
|
|
@@ -395,7 +614,7 @@ const runInit = async (
|
|
|
395
614
|
files: result.files,
|
|
396
615
|
});
|
|
397
616
|
console.log(
|
|
398
|
-
`init complete: ${Object.keys(result.files).length} managed
|
|
617
|
+
`init complete: ${Object.keys(result.files).length} managed path(s) at ${src.sha}`,
|
|
399
618
|
);
|
|
400
619
|
};
|
|
401
620
|
|
|
@@ -407,23 +626,27 @@ const runSync = async (
|
|
|
407
626
|
): Promise<void> => {
|
|
408
627
|
const seeds = await seedTargets(src.dir, manifest.seedDir);
|
|
409
628
|
assertDisjoint(manifest.paths, [...seeds.keys()]);
|
|
629
|
+
const upstream = await listManagedFiles(src.dir, manifest.paths);
|
|
410
630
|
const prospectiveDependabot = await prepareProspectiveDependabot(
|
|
411
|
-
|
|
412
|
-
src.dir,
|
|
631
|
+
upstream,
|
|
413
632
|
consumer,
|
|
414
633
|
null,
|
|
415
634
|
);
|
|
416
635
|
const lock = await readLock(consumer);
|
|
417
636
|
const result = await mirror({
|
|
418
|
-
|
|
419
|
-
srcDir: src.dir,
|
|
637
|
+
upstream,
|
|
420
638
|
consumer,
|
|
421
639
|
previous: lock?.files ?? {},
|
|
422
640
|
dryRun,
|
|
423
641
|
});
|
|
424
642
|
reportMirror(result, dryRun);
|
|
425
|
-
await applyProspectiveDependabot(
|
|
643
|
+
const generated = await applyProspectiveDependabot(
|
|
644
|
+
consumer,
|
|
645
|
+
prospectiveDependabot,
|
|
646
|
+
dryRun,
|
|
647
|
+
);
|
|
426
648
|
if (dryRun) {
|
|
649
|
+
reportDryRunSummary(result, generated);
|
|
427
650
|
return;
|
|
428
651
|
}
|
|
429
652
|
await writeLock(consumer, {
|
|
@@ -432,7 +655,7 @@ const runSync = async (
|
|
|
432
655
|
files: result.files,
|
|
433
656
|
});
|
|
434
657
|
console.log(
|
|
435
|
-
`sync complete: ${Object.keys(result.files).length} managed
|
|
658
|
+
`sync complete: ${Object.keys(result.files).length} managed path(s) at ${src.sha}`,
|
|
436
659
|
);
|
|
437
660
|
};
|
|
438
661
|
|
|
@@ -454,10 +677,11 @@ const runCheck = async (consumer: string): Promise<boolean> => {
|
|
|
454
677
|
const results = await Promise.all(
|
|
455
678
|
Object.entries(lock.files).map(async ([rel, hash]) => {
|
|
456
679
|
const dest = join(consumer, rel);
|
|
457
|
-
|
|
680
|
+
const entry = await inspectManagedPath(dest);
|
|
681
|
+
if (entry === null) {
|
|
458
682
|
return ` missing: ${rel}`;
|
|
459
683
|
}
|
|
460
|
-
const current = sha256(await
|
|
684
|
+
const current = sha256(await managedEntryDigestInput(entry));
|
|
461
685
|
if (current !== hash) {
|
|
462
686
|
return ` modified: ${rel} (expected ${hash.slice(0, HASH_PREVIEW_LENGTH)}, found ${current.slice(0, HASH_PREVIEW_LENGTH)})`;
|
|
463
687
|
}
|
|
@@ -469,7 +693,7 @@ const runCheck = async (consumer: string): Promise<boolean> => {
|
|
|
469
693
|
await findManagedFilesContainingBiomeDirectiveToken(lockedFiles);
|
|
470
694
|
if (problems.length > 0) {
|
|
471
695
|
console.error(
|
|
472
|
-
`standards: ${problems.length} canonical
|
|
696
|
+
`standards: ${problems.length} canonical path(s) drifted from the last synced state:`,
|
|
473
697
|
);
|
|
474
698
|
console.error(problems.join('\n'));
|
|
475
699
|
console.error(
|
|
@@ -487,7 +711,7 @@ const runCheck = async (consumer: string): Promise<boolean> => {
|
|
|
487
711
|
}
|
|
488
712
|
if (problems.length === 0) {
|
|
489
713
|
console.log(
|
|
490
|
-
`standards: ${Object.keys(lock.files).length} canonical
|
|
714
|
+
`standards: ${Object.keys(lock.files).length} canonical path(s) match the last synced state`,
|
|
491
715
|
);
|
|
492
716
|
}
|
|
493
717
|
return problems.length === 0 && directiveFiles.length === 0;
|
|
@@ -577,14 +801,12 @@ type ProspectiveDependabot = {
|
|
|
577
801
|
// file. For init, an existing overlay wins; otherwise the overlay seed is what
|
|
578
802
|
// the command is about to install.
|
|
579
803
|
const prepareProspectiveDependabot = async (
|
|
580
|
-
|
|
581
|
-
srcDir: string,
|
|
804
|
+
incoming: ReadonlyMap<string, ManagedEntry>,
|
|
582
805
|
consumer: string,
|
|
583
|
-
localSeed:
|
|
806
|
+
localSeed: ManagedEntry | null,
|
|
584
807
|
): Promise<ProspectiveDependabot> => {
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
if (basePath === undefined) {
|
|
808
|
+
const base = incoming.get(DEPENDABOT_BASE_FILE);
|
|
809
|
+
if (base === undefined || base.kind !== 'file') {
|
|
588
810
|
throw new Error(
|
|
589
811
|
`source content must manage ${DEPENDABOT_BASE_FILE}; @davidvornholt/standards 0.10.1 requires a 0.10.1-compatible content ref`,
|
|
590
812
|
);
|
|
@@ -594,10 +816,12 @@ const prepareProspectiveDependabot = async (
|
|
|
594
816
|
);
|
|
595
817
|
const local =
|
|
596
818
|
existingLocal ??
|
|
597
|
-
(localSeed === null
|
|
819
|
+
(localSeed === null || localSeed.kind !== 'file'
|
|
820
|
+
? null
|
|
821
|
+
: await readFile(localSeed.absolutePath, 'utf8'));
|
|
598
822
|
const current = await readTextIfPresent(join(consumer, DEPENDABOT_FILE));
|
|
599
823
|
const sources = {
|
|
600
|
-
base: await readFile(
|
|
824
|
+
base: await readFile(base.absolutePath, 'utf8'),
|
|
601
825
|
local,
|
|
602
826
|
current,
|
|
603
827
|
};
|
|
@@ -612,16 +836,17 @@ const applyProspectiveDependabot = async (
|
|
|
612
836
|
consumer: string,
|
|
613
837
|
prospective: ProspectiveDependabot,
|
|
614
838
|
dryRun: boolean,
|
|
615
|
-
): Promise<
|
|
839
|
+
): Promise<boolean> => {
|
|
616
840
|
if (prospective.current === prospective.composed) {
|
|
617
|
-
return;
|
|
841
|
+
return false;
|
|
618
842
|
}
|
|
619
843
|
if (dryRun) {
|
|
620
844
|
console.log(` would generate ${DEPENDABOT_FILE}`);
|
|
621
|
-
return;
|
|
845
|
+
return true;
|
|
622
846
|
}
|
|
623
847
|
await writeComposedDependabot(consumer, prospective.composed);
|
|
624
848
|
console.log(` generated ${DEPENDABOT_FILE}`);
|
|
849
|
+
return true;
|
|
625
850
|
};
|
|
626
851
|
|
|
627
852
|
const runDependabotCheck = async (consumer: string): Promise<boolean> => {
|