@davidvornholt/standards 0.11.0 → 0.12.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 +9 -4
- package/package.json +10 -1
- package/src/cli.ts +46 -79
- package/src/dev-env-destination-duplicates.ts +32 -0
- package/src/dev-env-destination.ts +175 -0
- package/src/dev-env-document.ts +117 -0
- package/src/dev-env-dotenv-value.ts +51 -0
- package/src/dev-env-dotenv.ts +22 -0
- package/src/dev-env-transaction-recovery.ts +80 -0
- package/src/dev-env-transaction.ts +152 -0
- package/src/dev-env.ts +165 -0
- package/src/github-api.ts +2 -0
- package/src/github-labels.ts +2 -2
- package/src/github-live-drift.ts +21 -0
- package/src/github-paginate.ts +14 -2
- package/src/managed-files.ts +64 -0
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@ standards doctor
|
|
|
10
10
|
standards structure
|
|
11
11
|
standards dependabot --check
|
|
12
12
|
standards dependabot --write
|
|
13
|
+
standards dev-env
|
|
13
14
|
standards github --check
|
|
14
15
|
standards github --apply
|
|
15
16
|
standards poller --config <path>
|
|
@@ -18,20 +19,24 @@ standards poller --print-units --config <path>
|
|
|
18
19
|
|
|
19
20
|
`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.
|
|
20
21
|
|
|
21
|
-
`
|
|
22
|
+
`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.
|
|
23
|
+
|
|
24
|
+
`check` also rejects the raw token formed by `biome-` + `ignore` anywhere in lock-listed canonical files. The intentionally blunt policy keeps synced sources compatible with consumer Biome configurations without invoking Biome or maintaining language-aware comment parsing.
|
|
25
|
+
|
|
26
|
+
`github --check` compares the live GitHub repository (including the canonical squash-only repository settings, rulesets, and declared labels) against the merged declaration in `.github/settings.json` + `.github/settings.local.json`; `github --apply` converges that declared state. The canonical declaration enables squash while disabling merge commits and rebases, and its default-branch ruleset independently allows only squash. Undeclared live labels remain untouched, while undeclared repository-owned rulesets are removed. `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. The canonical workflow sets `STANDARDS_SKIP_GITHUB_CHECK=true` only in its unprivileged quality step because its isolated `github-settings` job runs the live comparison with the settings-read token; this is a workflow-internal integration seam, not a general way to disable the local gate. The CLI prints an explicit diagnostic whenever that exact value skips either `check`'s live phase or `github --check`; `github --apply` is never skipped. `--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.
|
|
22
27
|
|
|
23
28
|
`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.
|
|
24
29
|
|
|
25
|
-
`poller` runs one tick of the fix poller: host-level automation that turns maintainer-approved GitHub issues into verified draft PRs and maintainer-approved draft PRs into completed review-fix cycles, using headless Codex runs. All workflow state lives in GitHub labels, comments, and draft PRs, so ticks are stateless, re-runnable, and safe to schedule from any host. A tick claims work by label (`fix-in-progress` / `review-in-progress`), releases claims stale for longer than `staleClaimHours`, and fails loudly — a red tick means jobs stalled, never silence. Trust is verified per event, not per configuration: the approval labels (`approved-for-fix`, `approved-for-review`) only count when the applying user holds the `admin` or `maintain` role, bind to the exact approved issue revision or PR head, and are revalidated before publication; only admin/maintain comments are treated as answers to agent questions. The poller removes its direct GitHub token variables before launching Codex, but the approved run shares the host service identity and can read its other ambient credentials and logged-in tool state; that accepted visibility must not be treated as credential isolation. The run works in a throwaway git worktree, hands results back through a structured outcome file, and the poller verifies effects (commits exist, no protected paths touched) before pushing, opening the draft PR, or posting the review — the required CI checks on the resulting PR remain the quality gate. Host mutation is declarative-only: `--print-units` emits a correctly escaped systemd service and timer for the polling host's infrastructure repository to adapt and deploy. That repository owns the service identity, writable HOME, PATH, GitHub token environment, lingering, and deployment. The service's tick budget (`TimeoutStartSec`) is derived from `maxJobsPerTick` × `runTimeoutMinutes` plus overhead, so re-render and redeploy the units after changing either value.
|
|
30
|
+
`poller` runs one tick of the fix poller: host-level automation that turns maintainer-approved GitHub issues into verified draft PRs and maintainer-approved draft PRs into completed review-fix cycles, using headless Codex runs. All workflow state lives in GitHub labels, comments, and draft PRs (the labels are canonical declared settings — see `github --apply`), so ticks are stateless, re-runnable, and safe to schedule from any host. A tick claims work by label (`fix-in-progress` / `review-in-progress`), releases claims stale for longer than `staleClaimHours`, and fails loudly — a red tick means jobs stalled, never silence. Trust is verified per event, not per configuration: the approval labels (`approved-for-fix`, `approved-for-review`) only count when the applying user holds the `admin` or `maintain` role, bind to the exact approved issue revision or PR head, and are revalidated before publication; only admin/maintain comments are treated as answers to agent questions. The poller removes its direct GitHub token variables before launching Codex, but the approved run shares the host service identity and can read its other ambient credentials and logged-in tool state; that accepted visibility must not be treated as credential isolation. The run works in a throwaway git worktree, hands results back through a structured outcome file, and the poller verifies effects (commits exist, no protected paths touched) before pushing, opening the draft PR, or posting the review — the required CI checks on the resulting PR remain the quality gate. Host mutation is declarative-only: `--print-units` emits a correctly escaped systemd service and timer for the polling host's infrastructure repository to adapt and deploy. That repository owns the service identity, writable HOME, PATH, GitHub token environment, lingering, and deployment. The service's tick budget (`TimeoutStartSec`) is derived from `maxJobsPerTick` × `runTimeoutMinutes` plus overhead, so re-render and redeploy the units after changing either value.
|
|
26
31
|
|
|
27
32
|
`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.
|
|
28
33
|
|
|
29
34
|
## Configuration
|
|
30
35
|
|
|
31
36
|
- **`.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.
|
|
32
|
-
- **`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.
|
|
37
|
+
- **`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; deliberate syncs then run locally because the secret-bearing workflow is schedule-only. 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.
|
|
33
38
|
- **`.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.
|
|
34
39
|
- **Poller config file** (required for `poller`, passed via `--config`) — host-level JSON, deliberately not repository state. Required: `"repos"` (unique `owner/repo` strings — one poller watches any number of repositories), `"model"` and `"reasoningEffort"` (passed to `codex exec`; the model choice is deliberate and has no default). Optional: `"maxJobsPerTick"` (default 1 — the concurrency and rate-limit guard; subscription-authenticated Codex runs share the interactive rate window), `"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`, `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.
|
|
35
|
-
- **`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 —
|
|
40
|
+
- **`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 — ruleset `bypass_actors` requires repository Administration read, and 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.
|
|
36
41
|
|
|
37
42
|
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.12.0",
|
|
4
4
|
"description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -24,6 +24,14 @@
|
|
|
24
24
|
"src/dependabot-compose.ts",
|
|
25
25
|
"src/dependabot-inspect.ts",
|
|
26
26
|
"src/dependabot-update.ts",
|
|
27
|
+
"src/dev-env-document.ts",
|
|
28
|
+
"src/dev-env-destination-duplicates.ts",
|
|
29
|
+
"src/dev-env-destination.ts",
|
|
30
|
+
"src/dev-env-dotenv-value.ts",
|
|
31
|
+
"src/dev-env-dotenv.ts",
|
|
32
|
+
"src/dev-env-transaction-recovery.ts",
|
|
33
|
+
"src/dev-env-transaction.ts",
|
|
34
|
+
"src/dev-env.ts",
|
|
27
35
|
"src/github-api.ts",
|
|
28
36
|
"src/github-apply.ts",
|
|
29
37
|
"src/github-command-shared.ts",
|
|
@@ -36,6 +44,7 @@
|
|
|
36
44
|
"src/github-settings-merge.ts",
|
|
37
45
|
"src/github-settings-parse.ts",
|
|
38
46
|
"src/github-settings.ts",
|
|
47
|
+
"src/managed-files.ts",
|
|
39
48
|
"src/poller-approval.ts",
|
|
40
49
|
"src/poller-claim.ts",
|
|
41
50
|
"src/poller-codex.ts",
|
package/src/cli.ts
CHANGED
|
@@ -13,25 +13,9 @@
|
|
|
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 {
|
|
17
|
-
cp,
|
|
18
|
-
mkdir,
|
|
19
|
-
readdir,
|
|
20
|
-
readFile,
|
|
21
|
-
rm,
|
|
22
|
-
stat,
|
|
23
|
-
writeFile,
|
|
24
|
-
} from 'node:fs/promises';
|
|
16
|
+
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
25
17
|
import { tmpdir } from 'node:os';
|
|
26
|
-
import {
|
|
27
|
-
dirname,
|
|
28
|
-
isAbsolute,
|
|
29
|
-
join,
|
|
30
|
-
posix,
|
|
31
|
-
relative,
|
|
32
|
-
resolve,
|
|
33
|
-
sep,
|
|
34
|
-
} from 'node:path';
|
|
18
|
+
import { dirname, isAbsolute, join, posix, resolve } from 'node:path';
|
|
35
19
|
import process from 'node:process';
|
|
36
20
|
import {
|
|
37
21
|
composeDependabot,
|
|
@@ -40,10 +24,15 @@ import {
|
|
|
40
24
|
DEPENDABOT_LOCAL_FILE,
|
|
41
25
|
} from './dependabot-compose';
|
|
42
26
|
import { inspectDependabot } from './dependabot-inspect';
|
|
27
|
+
import { runDevEnv } from './dev-env';
|
|
43
28
|
import { CANONICAL_SETTINGS_FILE, LOCAL_SETTINGS_FILE } from './github-api';
|
|
44
29
|
import { runGithubApply, runGithubCheck } from './github-commands';
|
|
45
30
|
import { loadGithubSettings } from './github-settings';
|
|
46
31
|
import { isNonEmptyString, isRecord } from './github-settings-parse';
|
|
32
|
+
import {
|
|
33
|
+
findManagedFilesContainingBiomeDirectiveToken,
|
|
34
|
+
listManagedFiles,
|
|
35
|
+
} from './managed-files';
|
|
47
36
|
import { runPollerCommand } from './poller-commands';
|
|
48
37
|
import { collectStructureProblems } from './structure-check';
|
|
49
38
|
import type { StructureProfile } from './structure-profile';
|
|
@@ -57,17 +46,6 @@ const HASH_PREVIEW_LENGTH = 12;
|
|
|
57
46
|
const GITHUB_PREFIX = 'github:';
|
|
58
47
|
const SKIP_GITHUB_CHECK_ENV = 'STANDARDS_SKIP_GITHUB_CHECK';
|
|
59
48
|
|
|
60
|
-
// Never mirrored, even under a managed directory path: build output, VCS
|
|
61
|
-
// metadata, and installed dependencies would otherwise pollute the lock when
|
|
62
|
-
// syncing from a working tree that has them.
|
|
63
|
-
const IGNORED_DIRS = new Set([
|
|
64
|
-
'node_modules',
|
|
65
|
-
'.git',
|
|
66
|
-
'.turbo',
|
|
67
|
-
'dist',
|
|
68
|
-
'.next',
|
|
69
|
-
]);
|
|
70
|
-
|
|
71
49
|
type Manifest = {
|
|
72
50
|
readonly upstream: string;
|
|
73
51
|
readonly seedDir: string;
|
|
@@ -89,6 +67,7 @@ type Source = {
|
|
|
89
67
|
type Command =
|
|
90
68
|
| 'check'
|
|
91
69
|
| 'dependabot'
|
|
70
|
+
| 'dev-env'
|
|
92
71
|
| 'doctor'
|
|
93
72
|
| 'github'
|
|
94
73
|
| 'help'
|
|
@@ -113,8 +92,6 @@ type CliOptions = {
|
|
|
113
92
|
const sha256 = (buf: Buffer): string =>
|
|
114
93
|
createHash('sha256').update(buf).digest('hex');
|
|
115
94
|
|
|
116
|
-
const toPosix = (p: string): string => p.split(sep).join('/');
|
|
117
|
-
|
|
118
95
|
const assertSafeRelativePath = (path: string, label: string): void => {
|
|
119
96
|
// POSIX semantics on every platform: managed paths are repository-relative
|
|
120
97
|
// POSIX paths, and win32 normalize would rewrite `/` to `\` and reject them.
|
|
@@ -244,38 +221,6 @@ const resolveSource = (src: string, ref: string | undefined): Source => {
|
|
|
244
221
|
return { dir, sha, cleanup };
|
|
245
222
|
};
|
|
246
223
|
|
|
247
|
-
// Recursively collect files under `abs`, keyed by their POSIX path relative to
|
|
248
|
-
// `base`. Missing paths are skipped so a manifest entry with no files is inert.
|
|
249
|
-
const walk = async (
|
|
250
|
-
abs: string,
|
|
251
|
-
base: string,
|
|
252
|
-
out: Map<string, string>,
|
|
253
|
-
): Promise<void> => {
|
|
254
|
-
const info = await stat(abs).catch(() => null);
|
|
255
|
-
if (info === null) {
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
if (info.isDirectory()) {
|
|
259
|
-
const entries = await readdir(abs);
|
|
260
|
-
await Promise.all(
|
|
261
|
-
entries
|
|
262
|
-
.filter((entry) => !IGNORED_DIRS.has(entry))
|
|
263
|
-
.map((entry) => walk(join(abs, entry), base, out)),
|
|
264
|
-
);
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
out.set(toPosix(relative(base, abs)), abs);
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
const listManaged = async (
|
|
271
|
-
dir: string,
|
|
272
|
-
paths: ReadonlyArray<string>,
|
|
273
|
-
): Promise<Map<string, string>> => {
|
|
274
|
-
const out = new Map<string, string>();
|
|
275
|
-
await Promise.all(paths.map((p) => walk(join(dir, p), dir, out)));
|
|
276
|
-
return out;
|
|
277
|
-
};
|
|
278
|
-
|
|
279
224
|
const isUnder = (a: string, b: string): boolean =>
|
|
280
225
|
a === b || a.startsWith(`${b}/`);
|
|
281
226
|
|
|
@@ -325,7 +270,7 @@ const mirror = async ({
|
|
|
325
270
|
for (const rel of Object.keys(previous)) {
|
|
326
271
|
assertSafeRelativePath(rel, 'sync-standards.lock file');
|
|
327
272
|
}
|
|
328
|
-
const upstream = await
|
|
273
|
+
const upstream = await listManagedFiles(srcDir, manifest.paths);
|
|
329
274
|
const next: Record<string, string> = {};
|
|
330
275
|
const created: Array<string> = [];
|
|
331
276
|
const updated: Array<string> = [];
|
|
@@ -400,14 +345,12 @@ const reportMirror = (result: MirrorResult, dryRun: boolean): void => {
|
|
|
400
345
|
}
|
|
401
346
|
};
|
|
402
347
|
|
|
403
|
-
const seedTargets =
|
|
348
|
+
const seedTargets = (
|
|
404
349
|
srcDir: string,
|
|
405
350
|
seedDir: string,
|
|
406
|
-
): Promise<
|
|
351
|
+
): Promise<ReadonlyMap<string, string>> => {
|
|
407
352
|
const root = join(srcDir, seedDir);
|
|
408
|
-
|
|
409
|
-
await walk(root, root, out);
|
|
410
|
-
return out;
|
|
353
|
+
return listManagedFiles(root, ['.']);
|
|
411
354
|
};
|
|
412
355
|
|
|
413
356
|
const runInit = async (
|
|
@@ -505,6 +448,7 @@ const runCheck = async (consumer: string): Promise<boolean> => {
|
|
|
505
448
|
for (const rel of Object.keys(lock.files)) {
|
|
506
449
|
assertSafeRelativePath(rel, 'sync-standards.lock file');
|
|
507
450
|
}
|
|
451
|
+
const lockedFiles = await listManagedFiles(consumer, Object.keys(lock.files));
|
|
508
452
|
const results = await Promise.all(
|
|
509
453
|
Object.entries(lock.files).map(async ([rel, hash]) => {
|
|
510
454
|
const dest = join(consumer, rel);
|
|
@@ -519,6 +463,8 @@ const runCheck = async (consumer: string): Promise<boolean> => {
|
|
|
519
463
|
}),
|
|
520
464
|
);
|
|
521
465
|
const problems = results.filter((p): p is string => p !== null);
|
|
466
|
+
const directiveFiles =
|
|
467
|
+
await findManagedFilesContainingBiomeDirectiveToken(lockedFiles);
|
|
522
468
|
if (problems.length > 0) {
|
|
523
469
|
console.error(
|
|
524
470
|
`standards: ${problems.length} canonical file(s) drifted from the last synced state:`,
|
|
@@ -527,12 +473,22 @@ const runCheck = async (consumer: string): Promise<boolean> => {
|
|
|
527
473
|
console.error(
|
|
528
474
|
'These files are read-only. Restore them with `bun standards sync`, or move your change upstream.',
|
|
529
475
|
);
|
|
530
|
-
return false;
|
|
531
476
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
477
|
+
if (directiveFiles.length > 0) {
|
|
478
|
+
console.error(
|
|
479
|
+
`standards: ${directiveFiles.length} canonical file(s) contain the forbidden inline Biome directive token:`,
|
|
480
|
+
);
|
|
481
|
+
console.error(directiveFiles.map((path) => ` - ${path}`).join('\n'));
|
|
482
|
+
console.error(
|
|
483
|
+
'Canonical synced files must remain compatible with consumer lint configurations.',
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
if (problems.length === 0) {
|
|
487
|
+
console.log(
|
|
488
|
+
`standards: ${Object.keys(lock.files).length} canonical file(s) match the last synced state`,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
return problems.length === 0 && directiveFiles.length === 0;
|
|
536
492
|
};
|
|
537
493
|
|
|
538
494
|
const readTextIfPresent = async (path: string): Promise<string | null> =>
|
|
@@ -624,7 +580,7 @@ const prepareProspectiveDependabot = async (
|
|
|
624
580
|
consumer: string,
|
|
625
581
|
localSeed: string | null,
|
|
626
582
|
): Promise<ProspectiveDependabot> => {
|
|
627
|
-
const incoming = await
|
|
583
|
+
const incoming = await listManagedFiles(srcDir, manifest.paths);
|
|
628
584
|
const basePath = incoming.get(DEPENDABOT_BASE_FILE);
|
|
629
585
|
if (basePath === undefined) {
|
|
630
586
|
throw new Error(
|
|
@@ -782,6 +738,7 @@ Commands:
|
|
|
782
738
|
doctor Validate extension seams only
|
|
783
739
|
structure Validate monorepo structure rules only
|
|
784
740
|
dependabot Verify (--check) or regenerate (--write) the composed .github/dependabot.yml
|
|
741
|
+
dev-env Write each workspace's generated .env.local from the SOPS-encrypted secrets/dev.yaml
|
|
785
742
|
github Compare (--check) or converge (--apply) live GitHub settings
|
|
786
743
|
poller Run one fix-poller tick over the configured repositories (host automation)
|
|
787
744
|
help Show this help
|
|
@@ -802,6 +759,7 @@ const commandFromArg = (arg: string): Command => {
|
|
|
802
759
|
if (
|
|
803
760
|
arg === 'check' ||
|
|
804
761
|
arg === 'dependabot' ||
|
|
762
|
+
arg === 'dev-env' ||
|
|
805
763
|
arg === 'doctor' ||
|
|
806
764
|
arg === 'github' ||
|
|
807
765
|
arg === 'help' ||
|
|
@@ -1013,7 +971,7 @@ const runCheckCommand = async (consumer: string): Promise<boolean> => {
|
|
|
1013
971
|
};
|
|
1014
972
|
|
|
1015
973
|
// The canonical workflow sets this only for its unprivileged quality job,
|
|
1016
|
-
// where a separate isolated job runs the same live check with the
|
|
974
|
+
// where a separate isolated job runs the same live check with the settings-read
|
|
1017
975
|
// token. Absent that exact workflow seam, local and explicit checks remain
|
|
1018
976
|
// fail-closed.
|
|
1019
977
|
const runGithubCheckGate = (consumer: string): Promise<boolean> => {
|
|
@@ -1030,8 +988,8 @@ const runGithubCheckGate = (consumer: string): Promise<boolean> => {
|
|
|
1030
988
|
// standards-sync workflow it configures — versioned and reviewable, unlike
|
|
1031
989
|
// repository Actions variables. All fields are optional; a missing file means
|
|
1032
990
|
// the defaults (track main, weekly auto-sync on).
|
|
1033
|
-
// autoSync false skips the scheduled workflow run;
|
|
1034
|
-
//
|
|
991
|
+
// autoSync false skips the scheduled workflow run; local CLI runs remain
|
|
992
|
+
// the deliberate sync path.
|
|
1035
993
|
// ref tag, branch, or full commit sha to sync from instead of main.
|
|
1036
994
|
const POLICY_FILE = 'sync-standards.local.json';
|
|
1037
995
|
const LINE_BREAK = /[\r\n]/u;
|
|
@@ -1154,7 +1112,13 @@ const runSyncCommand = async (
|
|
|
1154
1112
|
|
|
1155
1113
|
// Commands whose success is reported through the exit code.
|
|
1156
1114
|
const runGateCommand = (
|
|
1157
|
-
command:
|
|
1115
|
+
command:
|
|
1116
|
+
| 'check'
|
|
1117
|
+
| 'dependabot'
|
|
1118
|
+
| 'dev-env'
|
|
1119
|
+
| 'doctor'
|
|
1120
|
+
| 'github'
|
|
1121
|
+
| 'structure',
|
|
1158
1122
|
consumer: string,
|
|
1159
1123
|
apply: boolean,
|
|
1160
1124
|
profile: StructureProfile,
|
|
@@ -1165,6 +1129,9 @@ const runGateCommand = (
|
|
|
1165
1129
|
if (command === 'dependabot') {
|
|
1166
1130
|
return runDependabotCheck(consumer);
|
|
1167
1131
|
}
|
|
1132
|
+
if (command === 'dev-env') {
|
|
1133
|
+
return runDevEnv(consumer);
|
|
1134
|
+
}
|
|
1168
1135
|
if (command === 'doctor') {
|
|
1169
1136
|
return runDoctor(consumer);
|
|
1170
1137
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import type { DevEnvWrite } from './dev-env-destination';
|
|
3
|
+
|
|
4
|
+
export const duplicateDestinationProblems = (
|
|
5
|
+
root: string,
|
|
6
|
+
writes: ReadonlyArray<DevEnvWrite>,
|
|
7
|
+
): ReadonlyArray<string> => {
|
|
8
|
+
const rawDuplicates = writes.flatMap((write, index) => {
|
|
9
|
+
const firstIndex = writes.findIndex(({ rel }) => rel === write.rel);
|
|
10
|
+
return firstIndex < index
|
|
11
|
+
? [`${write.rel} is declared more than once`]
|
|
12
|
+
: [];
|
|
13
|
+
});
|
|
14
|
+
const resolved = writes.map((write) => ({
|
|
15
|
+
write,
|
|
16
|
+
dest: resolve(root, write.rel),
|
|
17
|
+
}));
|
|
18
|
+
const normalizedDuplicates = resolved.flatMap((destination, index) => {
|
|
19
|
+
const firstIndex = resolved.findIndex(
|
|
20
|
+
({ dest }) => dest === destination.dest,
|
|
21
|
+
);
|
|
22
|
+
const first = resolved[firstIndex];
|
|
23
|
+
return firstIndex < index &&
|
|
24
|
+
first !== undefined &&
|
|
25
|
+
first.write.rel !== destination.write.rel
|
|
26
|
+
? [
|
|
27
|
+
`${destination.write.rel} resolves to the same destination as ${first.write.rel}`,
|
|
28
|
+
]
|
|
29
|
+
: [];
|
|
30
|
+
});
|
|
31
|
+
return [...rawDuplicates, ...normalizedDuplicates];
|
|
32
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { Stats } from 'node:fs';
|
|
3
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { duplicateDestinationProblems } from './dev-env-destination-duplicates';
|
|
6
|
+
|
|
7
|
+
export type DevEnvWrite = {
|
|
8
|
+
readonly rel: string;
|
|
9
|
+
readonly content: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type DevEnvPathIdentity = {
|
|
13
|
+
readonly path: string;
|
|
14
|
+
readonly device: number;
|
|
15
|
+
readonly inode: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type DevEnvDestination = {
|
|
19
|
+
readonly write: DevEnvWrite;
|
|
20
|
+
readonly dest: string;
|
|
21
|
+
readonly previous: Stats | null;
|
|
22
|
+
readonly temp: string;
|
|
23
|
+
readonly backup: string;
|
|
24
|
+
readonly parents: ReadonlyArray<DevEnvPathIdentity>;
|
|
25
|
+
readonly realParent: string;
|
|
26
|
+
readonly realRoot: string;
|
|
27
|
+
backupCreated: boolean;
|
|
28
|
+
committed: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type PreflightResult =
|
|
32
|
+
| {
|
|
33
|
+
readonly ok: true;
|
|
34
|
+
readonly destinations: ReadonlyArray<DevEnvDestination>;
|
|
35
|
+
}
|
|
36
|
+
| { readonly ok: false; readonly problems: ReadonlyArray<string> };
|
|
37
|
+
|
|
38
|
+
const containedBy = (root: string, candidate: string): boolean => {
|
|
39
|
+
const rel = relative(root, candidate);
|
|
40
|
+
return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const devEnvStatOrNull = async (path: string): Promise<Stats | null> => {
|
|
44
|
+
try {
|
|
45
|
+
return await lstat(path);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const inspectDestination = async (
|
|
55
|
+
root: string,
|
|
56
|
+
realRoot: string,
|
|
57
|
+
write: DevEnvWrite,
|
|
58
|
+
): Promise<string | DevEnvDestination> => {
|
|
59
|
+
const dest = resolve(root, write.rel);
|
|
60
|
+
if (!containedBy(root, dest)) {
|
|
61
|
+
return `${write.rel} escapes the consumer repository`;
|
|
62
|
+
}
|
|
63
|
+
const paths = dirname(write.rel)
|
|
64
|
+
.split(sep)
|
|
65
|
+
.map((_segment, index, segments) =>
|
|
66
|
+
join(root, ...segments.slice(0, index + 1)),
|
|
67
|
+
);
|
|
68
|
+
const parentPaths = [root, ...paths];
|
|
69
|
+
const parents = await Promise.all(parentPaths.map(devEnvStatOrNull));
|
|
70
|
+
if (
|
|
71
|
+
parents.some(
|
|
72
|
+
(entry) =>
|
|
73
|
+
entry === null || !entry.isDirectory() || entry.isSymbolicLink(),
|
|
74
|
+
)
|
|
75
|
+
) {
|
|
76
|
+
return `${write.rel} has an unsafe destination directory`;
|
|
77
|
+
}
|
|
78
|
+
const realParent = await realpath(dirname(dest));
|
|
79
|
+
if (!containedBy(realRoot, realParent)) {
|
|
80
|
+
return `${write.rel} resolves outside the consumer repository`;
|
|
81
|
+
}
|
|
82
|
+
const previous = await devEnvStatOrNull(dest);
|
|
83
|
+
if (previous !== null && (!previous.isFile() || previous.isSymbolicLink())) {
|
|
84
|
+
return `${write.rel} must be absent or a regular file, not a symlink or other file type`;
|
|
85
|
+
}
|
|
86
|
+
const suffix = randomUUID();
|
|
87
|
+
return {
|
|
88
|
+
write,
|
|
89
|
+
dest,
|
|
90
|
+
previous,
|
|
91
|
+
temp: join(dirname(dest), `.env.local.standards-${suffix}.tmp`),
|
|
92
|
+
backup: join(dirname(dest), `.env.local.standards-${suffix}.bak`),
|
|
93
|
+
parents: parents.map((entry, index) => ({
|
|
94
|
+
path: parentPaths[index] ?? root,
|
|
95
|
+
device: entry?.dev ?? 0,
|
|
96
|
+
inode: entry?.ino ?? 0,
|
|
97
|
+
})),
|
|
98
|
+
realParent,
|
|
99
|
+
realRoot,
|
|
100
|
+
backupCreated: false,
|
|
101
|
+
committed: false,
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const preflightDevEnvDestinations = async (
|
|
106
|
+
consumer: string,
|
|
107
|
+
writes: ReadonlyArray<DevEnvWrite>,
|
|
108
|
+
): Promise<PreflightResult> => {
|
|
109
|
+
const root = resolve(consumer);
|
|
110
|
+
const realRoot = await realpath(root);
|
|
111
|
+
const duplicates = duplicateDestinationProblems(root, writes);
|
|
112
|
+
const checked = await Promise.all(
|
|
113
|
+
writes.map(async (write) => {
|
|
114
|
+
try {
|
|
115
|
+
return await inspectDestination(root, realRoot, write);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
118
|
+
return `${write.rel} could not be preflighted: ${detail}`;
|
|
119
|
+
}
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
const destinations = checked.filter(
|
|
123
|
+
(item): item is DevEnvDestination => typeof item !== 'string',
|
|
124
|
+
);
|
|
125
|
+
const problems = [
|
|
126
|
+
...duplicates,
|
|
127
|
+
...checked.filter((item): item is string => typeof item === 'string'),
|
|
128
|
+
];
|
|
129
|
+
return problems.length > 0
|
|
130
|
+
? { ok: false, problems }
|
|
131
|
+
: {
|
|
132
|
+
ok: true,
|
|
133
|
+
destinations,
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const devEnvParentProblem = async (
|
|
138
|
+
destination: DevEnvDestination,
|
|
139
|
+
): Promise<string | null> => {
|
|
140
|
+
try {
|
|
141
|
+
const current = await Promise.all(
|
|
142
|
+
destination.parents.map(async (parent) => ({
|
|
143
|
+
expected: parent,
|
|
144
|
+
actual: await devEnvStatOrNull(parent.path),
|
|
145
|
+
})),
|
|
146
|
+
);
|
|
147
|
+
const changed = current.some(
|
|
148
|
+
({ expected, actual }) =>
|
|
149
|
+
actual === null ||
|
|
150
|
+
!actual.isDirectory() ||
|
|
151
|
+
actual.isSymbolicLink() ||
|
|
152
|
+
actual.dev !== expected.device ||
|
|
153
|
+
actual.ino !== expected.inode,
|
|
154
|
+
);
|
|
155
|
+
const realParent = await realpath(dirname(destination.dest));
|
|
156
|
+
if (
|
|
157
|
+
changed ||
|
|
158
|
+
realParent !== destination.realParent ||
|
|
159
|
+
!containedBy(destination.realRoot, realParent)
|
|
160
|
+
) {
|
|
161
|
+
return `${destination.write.rel} destination directory changed after preflight`;
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
} catch {
|
|
165
|
+
return `${destination.write.rel} destination directory changed after preflight`;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export const devEnvDestinationProblems = async (
|
|
170
|
+
consumer: string,
|
|
171
|
+
writes: ReadonlyArray<DevEnvWrite>,
|
|
172
|
+
): Promise<ReadonlyArray<string>> => {
|
|
173
|
+
const checked = await preflightDevEnvDestinations(consumer, writes);
|
|
174
|
+
return checked.ok ? [] : checked.problems;
|
|
175
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Workspace-group keyed dev secrets document: `apps.<name>` and
|
|
2
|
+
// `packages.<name>` map env keys to string values — the shape mirrored in
|
|
3
|
+
// secrets/dev.example.yaml. Parsing gathers every problem instead of failing
|
|
4
|
+
// on the first one so a malformed document is repaired in one pass.
|
|
5
|
+
|
|
6
|
+
import { encodeBunDotenvValue } from './dev-env-dotenv-value';
|
|
7
|
+
import { isRecord } from './github-settings-parse';
|
|
8
|
+
|
|
9
|
+
export type EnvValues = Readonly<Record<string, string>>;
|
|
10
|
+
|
|
11
|
+
export type DevEnvTarget = {
|
|
12
|
+
readonly group: string;
|
|
13
|
+
readonly workspace: string;
|
|
14
|
+
readonly env: EnvValues;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type DevEnvDocument = {
|
|
18
|
+
readonly targets: ReadonlyArray<DevEnvTarget>;
|
|
19
|
+
readonly problems: ReadonlyArray<string>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const WORKSPACE_GROUPS: ReadonlyArray<string> = ['apps', 'packages'];
|
|
23
|
+
const PORTABLE_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
24
|
+
const WORKSPACE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
25
|
+
|
|
26
|
+
type ParsedWorkspaces = {
|
|
27
|
+
readonly targets: ReadonlyArray<DevEnvTarget>;
|
|
28
|
+
readonly problems: ReadonlyArray<string>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const parseWorkspaceEnv = (
|
|
32
|
+
label: string,
|
|
33
|
+
raw: Record<string, unknown>,
|
|
34
|
+
): { readonly env: EnvValues; readonly problems: ReadonlyArray<string> } => {
|
|
35
|
+
const problems: Array<string> = [];
|
|
36
|
+
const entries: Array<readonly [string, string]> = [];
|
|
37
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
38
|
+
const portableName = PORTABLE_ENV_NAME.test(key);
|
|
39
|
+
if (!portableName) {
|
|
40
|
+
problems.push(
|
|
41
|
+
`${label} env key ${JSON.stringify(key)} must be a portable environment variable name`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (typeof value !== 'string') {
|
|
45
|
+
problems.push(`${label}.${key} must be a string value`);
|
|
46
|
+
} else if (encodeBunDotenvValue(value) === null) {
|
|
47
|
+
problems.push(
|
|
48
|
+
`${label}.${key} cannot be represented losslessly in Bun dotenv syntax`,
|
|
49
|
+
);
|
|
50
|
+
} else if (portableName) {
|
|
51
|
+
entries.push([key, value]);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { env: Object.fromEntries(entries), problems };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const parseWorkspaces = (
|
|
58
|
+
source: string,
|
|
59
|
+
group: string,
|
|
60
|
+
workspaces: Record<string, unknown>,
|
|
61
|
+
): ParsedWorkspaces => {
|
|
62
|
+
const problems: Array<string> = [];
|
|
63
|
+
const targets: Array<DevEnvTarget> = [];
|
|
64
|
+
for (const [workspace, env] of Object.entries(workspaces)) {
|
|
65
|
+
const validWorkspace = WORKSPACE_NAME.test(workspace);
|
|
66
|
+
if (!validWorkspace) {
|
|
67
|
+
problems.push(
|
|
68
|
+
`${source} ${JSON.stringify(`${group}.${workspace}`)} workspace name must be one kebab-case path segment`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (isRecord(env)) {
|
|
72
|
+
const parsed = parseWorkspaceEnv(
|
|
73
|
+
`${source} "${group}.${workspace}"`,
|
|
74
|
+
env,
|
|
75
|
+
);
|
|
76
|
+
problems.push(...parsed.problems);
|
|
77
|
+
if (validWorkspace) {
|
|
78
|
+
targets.push({ group, workspace, env: parsed.env });
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
problems.push(
|
|
82
|
+
`${source} "${group}.${workspace}" must map env keys to string values`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { targets, problems };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const parseDevEnvDocument = (
|
|
90
|
+
raw: unknown,
|
|
91
|
+
source: string,
|
|
92
|
+
): DevEnvDocument => {
|
|
93
|
+
if (!isRecord(raw)) {
|
|
94
|
+
return {
|
|
95
|
+
targets: [],
|
|
96
|
+
problems: [`${source} must decrypt to a YAML object`],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const problems: Array<string> = [];
|
|
100
|
+
const targets: Array<DevEnvTarget> = [];
|
|
101
|
+
for (const [group, workspaces] of Object.entries(raw)) {
|
|
102
|
+
if (!WORKSPACE_GROUPS.includes(group)) {
|
|
103
|
+
problems.push(
|
|
104
|
+
`${source} top-level key "${group}" must be "apps" or "packages"`,
|
|
105
|
+
);
|
|
106
|
+
} else if (isRecord(workspaces)) {
|
|
107
|
+
const parsed = parseWorkspaces(source, group, workspaces);
|
|
108
|
+
problems.push(...parsed.problems);
|
|
109
|
+
targets.push(...parsed.targets);
|
|
110
|
+
} else {
|
|
111
|
+
problems.push(
|
|
112
|
+
`${source} "${group}" must map workspace names to env objects`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { targets, problems };
|
|
117
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const EMPTY_EXPANSION = ['$', '{:-}'].join('');
|
|
2
|
+
const QUOTED_BACKSLASH_HAZARD = /\\(?:[nr]|\r|\n)/u;
|
|
3
|
+
const EDGE_BACKSLASH = /\\$/u;
|
|
4
|
+
const UNQUOTED_BODY_HAZARD = /[#\r\n]/u;
|
|
5
|
+
const EDGE_DOLLAR = /\$$/u;
|
|
6
|
+
const EDGE_WHITESPACE = /\s$/u;
|
|
7
|
+
|
|
8
|
+
const escapeDollars = (value: string): string => value.replaceAll('$', '\\$');
|
|
9
|
+
|
|
10
|
+
const terminalDollarExpansion = (value: string): string =>
|
|
11
|
+
EDGE_DOLLAR.test(value) ? EMPTY_EXPANSION : '';
|
|
12
|
+
|
|
13
|
+
const encodeUnquoted = (value: string): string | null => {
|
|
14
|
+
if (UNQUOTED_BODY_HAZARD.test(value)) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const terminator =
|
|
18
|
+
EDGE_WHITESPACE.test(value) || EDGE_DOLLAR.test(value)
|
|
19
|
+
? EMPTY_EXPANSION
|
|
20
|
+
: '#';
|
|
21
|
+
return `${EMPTY_EXPANSION}${escapeDollars(value)}${terminator}`;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const encodeDoubleQuoted = (value: string): string | null => {
|
|
25
|
+
if (
|
|
26
|
+
value.includes('"') ||
|
|
27
|
+
EDGE_BACKSLASH.test(value) ||
|
|
28
|
+
QUOTED_BACKSLASH_HAZARD.test(value)
|
|
29
|
+
) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return `"${escapeDollars(value)
|
|
33
|
+
.replaceAll('\r', '\\r')
|
|
34
|
+
.replaceAll('\n', '\\n')}${terminalDollarExpansion(value)}"`;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const encodeLiteralQuoted = (
|
|
38
|
+
value: string,
|
|
39
|
+
delimiter: "'" | '`',
|
|
40
|
+
): string | null =>
|
|
41
|
+
value.includes(delimiter) ||
|
|
42
|
+
value.includes('\r') ||
|
|
43
|
+
EDGE_BACKSLASH.test(value)
|
|
44
|
+
? null
|
|
45
|
+
: `${delimiter}${escapeDollars(value)}${terminalDollarExpansion(value)}${delimiter}`;
|
|
46
|
+
|
|
47
|
+
export const encodeBunDotenvValue = (value: string): string | null =>
|
|
48
|
+
encodeUnquoted(value) ??
|
|
49
|
+
encodeDoubleQuoted(value) ??
|
|
50
|
+
encodeLiteralQuoted(value, "'") ??
|
|
51
|
+
encodeLiteralQuoted(value, '`');
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { EnvValues } from './dev-env-document';
|
|
2
|
+
import { encodeBunDotenvValue } from './dev-env-dotenv-value';
|
|
3
|
+
|
|
4
|
+
const renderValue = (key: string, value: string): string => {
|
|
5
|
+
const encoded = encodeBunDotenvValue(value);
|
|
6
|
+
if (encoded === null) {
|
|
7
|
+
throw new Error(`${key} was not validated for Bun dotenv rendering`);
|
|
8
|
+
}
|
|
9
|
+
return `${key}=${encoded}`;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const renderDotenv = (
|
|
13
|
+
sourcePath: string,
|
|
14
|
+
secretsFile: string,
|
|
15
|
+
env: EnvValues,
|
|
16
|
+
): string =>
|
|
17
|
+
[
|
|
18
|
+
'# Generated by `bun standards dev-env` (just dev-env-generate). Do not edit.',
|
|
19
|
+
`# Source: ${secretsFile} (${sourcePath})`,
|
|
20
|
+
...Object.entries(env).map(([key, value]) => renderValue(key, value)),
|
|
21
|
+
'',
|
|
22
|
+
].join('\n');
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { rename, rm } from 'node:fs/promises';
|
|
2
|
+
import {
|
|
3
|
+
type DevEnvDestination,
|
|
4
|
+
devEnvParentProblem,
|
|
5
|
+
} from './dev-env-destination';
|
|
6
|
+
|
|
7
|
+
const message = (error: unknown): string =>
|
|
8
|
+
error instanceof Error ? error.message : String(error);
|
|
9
|
+
|
|
10
|
+
const requireParent = async (
|
|
11
|
+
destination: DevEnvDestination,
|
|
12
|
+
): Promise<string | null> => devEnvParentProblem(destination);
|
|
13
|
+
|
|
14
|
+
const rollbackOne = async (
|
|
15
|
+
destination: DevEnvDestination,
|
|
16
|
+
): Promise<ReadonlyArray<string>> => {
|
|
17
|
+
const problems: Array<string> = [];
|
|
18
|
+
if (destination.committed) {
|
|
19
|
+
const parentProblem = await requireParent(destination);
|
|
20
|
+
if (parentProblem !== null) {
|
|
21
|
+
return [parentProblem];
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
await rm(destination.dest, { force: true });
|
|
25
|
+
destination.committed = false;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
problems.push(`${destination.write.rel}: ${message(error)}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (destination.backupCreated && problems.length === 0) {
|
|
31
|
+
const parentProblem = await requireParent(destination);
|
|
32
|
+
if (parentProblem !== null) {
|
|
33
|
+
return [parentProblem];
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
await rename(destination.backup, destination.dest);
|
|
37
|
+
destination.backupCreated = false;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
problems.push(`${destination.write.rel}: ${message(error)}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return problems;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const rollbackDevEnvFiles = async (
|
|
46
|
+
destinations: ReadonlyArray<DevEnvDestination>,
|
|
47
|
+
): Promise<ReadonlyArray<string>> => {
|
|
48
|
+
const outcomes = await Promise.all(destinations.map(rollbackOne));
|
|
49
|
+
return outcomes.flat();
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const cleanupOne = async (
|
|
53
|
+
destination: DevEnvDestination,
|
|
54
|
+
preserveBackups: boolean,
|
|
55
|
+
): Promise<ReadonlyArray<string>> => {
|
|
56
|
+
const parentProblem = await requireParent(destination);
|
|
57
|
+
if (parentProblem !== null) {
|
|
58
|
+
return [parentProblem];
|
|
59
|
+
}
|
|
60
|
+
const paths = [destination.temp];
|
|
61
|
+
if (!preserveBackups) {
|
|
62
|
+
paths.push(destination.backup);
|
|
63
|
+
}
|
|
64
|
+
const outcomes = await Promise.allSettled(
|
|
65
|
+
paths.map((path) => rm(path, { force: true })),
|
|
66
|
+
);
|
|
67
|
+
return outcomes.flatMap((outcome) =>
|
|
68
|
+
outcome.status === 'rejected' ? [message(outcome.reason)] : [],
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const cleanupDevEnvArtifacts = async (
|
|
73
|
+
destinations: ReadonlyArray<DevEnvDestination>,
|
|
74
|
+
preserveBackups: boolean,
|
|
75
|
+
): Promise<ReadonlyArray<string>> => {
|
|
76
|
+
const outcomes = await Promise.all(
|
|
77
|
+
destinations.map((destination) => cleanupOne(destination, preserveBackups)),
|
|
78
|
+
);
|
|
79
|
+
return outcomes.flat();
|
|
80
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { link, open, rename } from 'node:fs/promises';
|
|
2
|
+
import {
|
|
3
|
+
type DevEnvDestination,
|
|
4
|
+
type DevEnvWrite,
|
|
5
|
+
devEnvParentProblem,
|
|
6
|
+
devEnvStatOrNull,
|
|
7
|
+
preflightDevEnvDestinations,
|
|
8
|
+
} from './dev-env-destination';
|
|
9
|
+
import {
|
|
10
|
+
cleanupDevEnvArtifacts,
|
|
11
|
+
rollbackDevEnvFiles,
|
|
12
|
+
} from './dev-env-transaction-recovery';
|
|
13
|
+
|
|
14
|
+
const OWNER_ONLY_FILE_MODE = 0o600;
|
|
15
|
+
|
|
16
|
+
export type DevEnvTransactionHooks = {
|
|
17
|
+
readonly beforeStage?: (index: number) => void | Promise<void>;
|
|
18
|
+
readonly beforeCommit?: (index: number) => void | Promise<void>;
|
|
19
|
+
readonly beforeCleanup?: () => void | Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type DevEnvTransactionResult =
|
|
23
|
+
| { readonly ok: true; readonly warnings: ReadonlyArray<string> }
|
|
24
|
+
| { readonly ok: false; readonly problems: ReadonlyArray<string> };
|
|
25
|
+
|
|
26
|
+
const requireParent = async (destination: DevEnvDestination): Promise<void> => {
|
|
27
|
+
const problem = await devEnvParentProblem(destination);
|
|
28
|
+
if (problem !== null) {
|
|
29
|
+
throw new Error(problem);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const stage = async (destination: DevEnvDestination): Promise<void> => {
|
|
34
|
+
await requireParent(destination);
|
|
35
|
+
const file = await open(destination.temp, 'wx', OWNER_ONLY_FILE_MODE);
|
|
36
|
+
try {
|
|
37
|
+
await file.chmod(OWNER_ONLY_FILE_MODE);
|
|
38
|
+
await file.writeFile(destination.write.content, 'utf8');
|
|
39
|
+
await file.sync();
|
|
40
|
+
} finally {
|
|
41
|
+
await file.close();
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const unchanged = async (destination: DevEnvDestination): Promise<boolean> => {
|
|
46
|
+
const current = await devEnvStatOrNull(destination.dest);
|
|
47
|
+
if (current === null || destination.previous === null) {
|
|
48
|
+
return current === destination.previous;
|
|
49
|
+
}
|
|
50
|
+
return (
|
|
51
|
+
current.isFile() &&
|
|
52
|
+
!current.isSymbolicLink() &&
|
|
53
|
+
current.dev === destination.previous.dev &&
|
|
54
|
+
current.ino === destination.previous.ino &&
|
|
55
|
+
current.mode === destination.previous.mode
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const stageAll = async (
|
|
60
|
+
destinations: ReadonlyArray<DevEnvDestination>,
|
|
61
|
+
hooks: DevEnvTransactionHooks,
|
|
62
|
+
): Promise<void> => {
|
|
63
|
+
await destinations.reduce<Promise<void>>(
|
|
64
|
+
async (previous, destination, index) => {
|
|
65
|
+
await previous;
|
|
66
|
+
await hooks.beforeStage?.(index);
|
|
67
|
+
await stage(destination);
|
|
68
|
+
},
|
|
69
|
+
Promise.resolve(),
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const commitOne = async (destination: DevEnvDestination): Promise<void> => {
|
|
74
|
+
await requireParent(destination);
|
|
75
|
+
if (!(await unchanged(destination))) {
|
|
76
|
+
throw new Error(`${destination.write.rel} changed after preflight`);
|
|
77
|
+
}
|
|
78
|
+
if (destination.previous !== null) {
|
|
79
|
+
await requireParent(destination);
|
|
80
|
+
await link(destination.dest, destination.backup);
|
|
81
|
+
destination.backupCreated = true;
|
|
82
|
+
}
|
|
83
|
+
await requireParent(destination);
|
|
84
|
+
await rename(destination.temp, destination.dest);
|
|
85
|
+
destination.committed = true;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const commitAll = async (
|
|
89
|
+
destinations: ReadonlyArray<DevEnvDestination>,
|
|
90
|
+
hooks: DevEnvTransactionHooks,
|
|
91
|
+
): Promise<void> => {
|
|
92
|
+
await destinations.reduce<Promise<void>>(
|
|
93
|
+
async (previous, destination, index) => {
|
|
94
|
+
await previous;
|
|
95
|
+
await hooks.beforeCommit?.(index);
|
|
96
|
+
await commitOne(destination);
|
|
97
|
+
},
|
|
98
|
+
Promise.resolve(),
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const problemMessage = (error: unknown): string =>
|
|
103
|
+
error instanceof Error ? error.message : String(error);
|
|
104
|
+
|
|
105
|
+
const recoverFailure = async (
|
|
106
|
+
destinations: ReadonlyArray<DevEnvDestination>,
|
|
107
|
+
error: unknown,
|
|
108
|
+
): Promise<DevEnvTransactionResult> => {
|
|
109
|
+
const rollbackProblems = await rollbackDevEnvFiles(destinations);
|
|
110
|
+
const cleanupProblems = await cleanupDevEnvArtifacts(
|
|
111
|
+
destinations,
|
|
112
|
+
rollbackProblems.length > 0,
|
|
113
|
+
);
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
problems: [
|
|
117
|
+
problemMessage(error),
|
|
118
|
+
...rollbackProblems.map((problem) => `rollback failed: ${problem}`),
|
|
119
|
+
...cleanupProblems.map((problem) => `cleanup failed: ${problem}`),
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export const writeDevEnvFiles = async (
|
|
125
|
+
consumer: string,
|
|
126
|
+
writes: ReadonlyArray<DevEnvWrite>,
|
|
127
|
+
hooks: DevEnvTransactionHooks = {},
|
|
128
|
+
): Promise<DevEnvTransactionResult> => {
|
|
129
|
+
const checked = await preflightDevEnvDestinations(consumer, writes);
|
|
130
|
+
if (!checked.ok) {
|
|
131
|
+
return checked;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
await stageAll(checked.destinations, hooks);
|
|
135
|
+
await commitAll(checked.destinations, hooks);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
return recoverFailure(checked.destinations, error);
|
|
138
|
+
}
|
|
139
|
+
let cleanupProblems: ReadonlyArray<string>;
|
|
140
|
+
try {
|
|
141
|
+
await hooks.beforeCleanup?.();
|
|
142
|
+
cleanupProblems = await cleanupDevEnvArtifacts(checked.destinations, false);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
cleanupProblems = [problemMessage(error)];
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
warnings: cleanupProblems.map(
|
|
149
|
+
(problem) => `generation committed but cleanup failed: ${problem}`,
|
|
150
|
+
),
|
|
151
|
+
};
|
|
152
|
+
};
|
package/src/dev-env.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
type DevEnvWrite,
|
|
6
|
+
devEnvDestinationProblems,
|
|
7
|
+
} from './dev-env-destination';
|
|
8
|
+
import { parseDevEnvDocument } from './dev-env-document';
|
|
9
|
+
import { renderDotenv } from './dev-env-dotenv';
|
|
10
|
+
import { writeDevEnvFiles } from './dev-env-transaction';
|
|
11
|
+
|
|
12
|
+
export const DEV_SECRETS_FILE = 'secrets/dev.yaml';
|
|
13
|
+
|
|
14
|
+
const SOPS_ARGS = ['--decrypt', '--output-type', 'json', DEV_SECRETS_FILE];
|
|
15
|
+
|
|
16
|
+
type DecryptResult =
|
|
17
|
+
| { readonly ok: true; readonly value: unknown }
|
|
18
|
+
| { readonly ok: false; readonly problem: string };
|
|
19
|
+
|
|
20
|
+
// sops emits JSON here so the CLI never parses the encrypted YAML itself; the
|
|
21
|
+
// nix fallback mirrors the canonical secrets.just tool resolution.
|
|
22
|
+
const decryptDevSecrets = (consumer: string): DecryptResult => {
|
|
23
|
+
const localSops = spawnSync('sops', SOPS_ARGS, {
|
|
24
|
+
cwd: consumer,
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
});
|
|
27
|
+
const result =
|
|
28
|
+
localSops.error === undefined && localSops.status !== null
|
|
29
|
+
? localSops
|
|
30
|
+
: spawnSync(
|
|
31
|
+
'nix',
|
|
32
|
+
[
|
|
33
|
+
'--extra-experimental-features',
|
|
34
|
+
'nix-command flakes',
|
|
35
|
+
'run',
|
|
36
|
+
'nixpkgs#sops',
|
|
37
|
+
'--',
|
|
38
|
+
...SOPS_ARGS,
|
|
39
|
+
],
|
|
40
|
+
{ cwd: consumer, encoding: 'utf8' },
|
|
41
|
+
);
|
|
42
|
+
if (result.status !== 0) {
|
|
43
|
+
const detail = result.error?.message ?? result.stderr?.trim();
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
problem: detail
|
|
47
|
+
? `could not decrypt ${DEV_SECRETS_FILE}: ${detail}`
|
|
48
|
+
: `could not decrypt ${DEV_SECRETS_FILE}`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return { ok: true, value: JSON.parse(result.stdout) as unknown };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
problem: `could not parse decrypted ${DEV_SECRETS_FILE} as JSON: ${
|
|
57
|
+
error instanceof Error ? error.message : String(error)
|
|
58
|
+
}`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Writing decrypted values into the tree is only safe when git will never
|
|
64
|
+
// track them. `git check-ignore` is authoritative; anything but a clear
|
|
65
|
+
// "ignored" answer fails closed, including running outside a git checkout.
|
|
66
|
+
const gitIgnoreProblem = (consumer: string, rel: string): string | null => {
|
|
67
|
+
const result = spawnSync('git', ['check-ignore', '-q', '--', rel], {
|
|
68
|
+
cwd: consumer,
|
|
69
|
+
});
|
|
70
|
+
if (result.error !== undefined || result.status === null) {
|
|
71
|
+
return `cannot run git to verify ${rel} is gitignored`;
|
|
72
|
+
}
|
|
73
|
+
if (result.status === 0) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (result.status === 1) {
|
|
77
|
+
return `${rel} is not gitignored; ignore it before generating dev env files`;
|
|
78
|
+
}
|
|
79
|
+
return `cannot verify ${rel} is gitignored (git check-ignore exited ${result.status})`;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type DevEnvPlan = {
|
|
83
|
+
readonly writes: ReadonlyArray<DevEnvWrite>;
|
|
84
|
+
readonly problems: ReadonlyArray<string>;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const planDevEnvWrites = (
|
|
88
|
+
consumer: string,
|
|
89
|
+
raw: unknown,
|
|
90
|
+
): DevEnvPlan => {
|
|
91
|
+
const document = parseDevEnvDocument(raw, DEV_SECRETS_FILE);
|
|
92
|
+
const problems: Array<string> = [...document.problems];
|
|
93
|
+
const writes: Array<DevEnvWrite> = [];
|
|
94
|
+
for (const target of document.targets) {
|
|
95
|
+
const workspaceDir = `${target.group}/${target.workspace}`;
|
|
96
|
+
const rel = `${workspaceDir}/.env.local`;
|
|
97
|
+
if (existsSync(join(consumer, workspaceDir, 'package.json'))) {
|
|
98
|
+
const ignoreProblem = gitIgnoreProblem(consumer, rel);
|
|
99
|
+
if (ignoreProblem === null) {
|
|
100
|
+
writes.push({
|
|
101
|
+
rel,
|
|
102
|
+
content: renderDotenv(
|
|
103
|
+
`${target.group}.${target.workspace}`,
|
|
104
|
+
DEV_SECRETS_FILE,
|
|
105
|
+
target.env,
|
|
106
|
+
),
|
|
107
|
+
});
|
|
108
|
+
} else {
|
|
109
|
+
problems.push(ignoreProblem);
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
problems.push(
|
|
113
|
+
`${DEV_SECRETS_FILE} defines ${target.group}.${target.workspace}, but ${workspaceDir}/package.json does not exist`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { writes, problems };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const runDevEnv = async (consumer: string): Promise<boolean> => {
|
|
121
|
+
if (!existsSync(join(consumer, DEV_SECRETS_FILE))) {
|
|
122
|
+
console.error(
|
|
123
|
+
`standards dev-env: ${DEV_SECRETS_FILE} not found; create it with \`just secrets edit dev\``,
|
|
124
|
+
);
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
const decrypted = decryptDevSecrets(consumer);
|
|
128
|
+
if (!decrypted.ok) {
|
|
129
|
+
console.error(`standards dev-env: ${decrypted.problem}`);
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
const plan = planDevEnvWrites(consumer, decrypted.value);
|
|
133
|
+
const problems = [
|
|
134
|
+
...plan.problems,
|
|
135
|
+
...(await devEnvDestinationProblems(consumer, plan.writes)),
|
|
136
|
+
];
|
|
137
|
+
if (problems.length > 0) {
|
|
138
|
+
console.error(`standards dev-env: ${problems.length} problem(s):`);
|
|
139
|
+
console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
const generated = await writeDevEnvFiles(consumer, plan.writes);
|
|
143
|
+
if (!generated.ok) {
|
|
144
|
+
console.error('standards dev-env: generation failed:');
|
|
145
|
+
console.error(
|
|
146
|
+
generated.problems.map((problem) => ` - ${problem}`).join('\n'),
|
|
147
|
+
);
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
if (generated.warnings.length > 0) {
|
|
151
|
+
console.error(
|
|
152
|
+
`standards dev-env: generated files with ${generated.warnings.length} cleanup warning(s):`,
|
|
153
|
+
);
|
|
154
|
+
console.error(
|
|
155
|
+
generated.warnings.map((warning) => ` - ${warning}`).join('\n'),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
for (const write of plan.writes) {
|
|
159
|
+
console.log(` wrote ${write.rel}`);
|
|
160
|
+
}
|
|
161
|
+
console.log(
|
|
162
|
+
`standards dev-env: generated ${plan.writes.length} env file(s) from ${DEV_SECRETS_FILE}`,
|
|
163
|
+
);
|
|
164
|
+
return true;
|
|
165
|
+
};
|
package/src/github-api.ts
CHANGED
|
@@ -21,6 +21,8 @@ const GITHUB_REMOTE_PATTERN =
|
|
|
21
21
|
export const HTTP_OK = 200;
|
|
22
22
|
export const HTTP_CREATED = 201;
|
|
23
23
|
export const HTTP_NO_CONTENT = 204;
|
|
24
|
+
export const HTTP_UNAUTHORIZED = 401;
|
|
25
|
+
export const HTTP_FORBIDDEN = 403;
|
|
24
26
|
|
|
25
27
|
export const CANONICAL_SETTINGS_FILE = '.github/settings.json';
|
|
26
28
|
export const LOCAL_SETTINGS_FILE = '.github/settings.local.json';
|
package/src/github-labels.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Live issue-label reading, drift, and convergence. Labels are an additive
|
|
2
2
|
// floor: every declared label must exist with the declared color and
|
|
3
3
|
// description; undeclared live labels (GitHub defaults, ad-hoc triage labels)
|
|
4
|
-
// are ignored.
|
|
5
|
-
//
|
|
4
|
+
// are ignored. Private-repository reads require Issues read (or Pull requests
|
|
5
|
+
// read), and an API denial fails the settings check closed.
|
|
6
6
|
|
|
7
7
|
import { apiError, HTTP_CREATED, HTTP_OK, request } from './github-api';
|
|
8
8
|
import { labelIdentity } from './github-label-identity';
|
package/src/github-live-drift.ts
CHANGED
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
apiError,
|
|
7
7
|
fetchLiveRulesets,
|
|
8
8
|
fetchMergeSettingsViaGraphql,
|
|
9
|
+
HTTP_FORBIDDEN,
|
|
9
10
|
HTTP_OK,
|
|
11
|
+
HTTP_UNAUTHORIZED,
|
|
10
12
|
request,
|
|
11
13
|
resolveGithubRepo,
|
|
12
14
|
resolveToken,
|
|
@@ -18,8 +20,24 @@ import {
|
|
|
18
20
|
} from './github-command-shared';
|
|
19
21
|
import { diffRepositorySettings, diffRulesets } from './github-diff';
|
|
20
22
|
import { diffLabels, fetchLiveLabels } from './github-labels';
|
|
23
|
+
import { GithubListResponseError } from './github-paginate';
|
|
21
24
|
import { type GithubSettings, isRecord } from './github-settings-parse';
|
|
22
25
|
|
|
26
|
+
const LABEL_VISIBILITY_PROBLEM =
|
|
27
|
+
'declared labels not visible to this token, so the gate cannot verify them. In CI, use a valid ci.github_settings_read_token from secrets/ci.yaml with read-only "Issues" access (or "Pull requests" read); locally use a token with one of those permissions';
|
|
28
|
+
const PERMISSION_DENIAL_MESSAGES: ReadonlySet<string> = new Set([
|
|
29
|
+
'Resource not accessible by integration',
|
|
30
|
+
'Resource not accessible by personal access token',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const isLabelPermissionDenial = (error: unknown): boolean =>
|
|
34
|
+
error instanceof GithubListResponseError &&
|
|
35
|
+
(error.status === HTTP_UNAUTHORIZED ||
|
|
36
|
+
(error.status === HTTP_FORBIDDEN &&
|
|
37
|
+
isRecord(error.responseBody) &&
|
|
38
|
+
typeof error.responseBody.message === 'string' &&
|
|
39
|
+
PERMISSION_DENIAL_MESSAGES.has(error.responseBody.message)));
|
|
40
|
+
|
|
23
41
|
const repositoryDrift = async (
|
|
24
42
|
token: string | null,
|
|
25
43
|
repo: string,
|
|
@@ -100,6 +118,9 @@ export const collectLiveDrift = async (
|
|
|
100
118
|
...labelDrift,
|
|
101
119
|
];
|
|
102
120
|
} catch (error) {
|
|
121
|
+
if (isLabelPermissionDenial(error)) {
|
|
122
|
+
return [LABEL_VISIBILITY_PROBLEM];
|
|
123
|
+
}
|
|
103
124
|
return [
|
|
104
125
|
`GitHub API unreachable: ${error instanceof Error ? error.message : String(error)}`,
|
|
105
126
|
];
|
package/src/github-paginate.ts
CHANGED
|
@@ -4,11 +4,23 @@
|
|
|
4
4
|
// comments break answer detection, and truncated label lists read as drift.
|
|
5
5
|
// Overflow past the page cap fails loudly instead of silently truncating.
|
|
6
6
|
|
|
7
|
-
import { apiError, HTTP_OK, request } from './github-api';
|
|
7
|
+
import { type ApiResponse, apiError, HTTP_OK, request } from './github-api';
|
|
8
8
|
|
|
9
9
|
const PAGE_SIZE = 100;
|
|
10
10
|
const MAX_PAGES = 30;
|
|
11
11
|
|
|
12
|
+
export class GithubListResponseError extends Error {
|
|
13
|
+
readonly responseBody: unknown;
|
|
14
|
+
readonly status: number;
|
|
15
|
+
|
|
16
|
+
constructor(context: string, response: ApiResponse) {
|
|
17
|
+
super(apiError(context, response));
|
|
18
|
+
this.name = 'GithubListResponseError';
|
|
19
|
+
this.responseBody = response.body;
|
|
20
|
+
this.status = response.status;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
export const listAllPages = async (
|
|
13
25
|
token: string | null,
|
|
14
26
|
path: string,
|
|
@@ -24,7 +36,7 @@ export const listAllPages = async (
|
|
|
24
36
|
`${path}${separator}per_page=${PAGE_SIZE}&page=${page}`,
|
|
25
37
|
);
|
|
26
38
|
if (response.status !== HTTP_OK || !Array.isArray(response.body)) {
|
|
27
|
-
throw new
|
|
39
|
+
throw new GithubListResponseError(context, response);
|
|
28
40
|
}
|
|
29
41
|
items.push(...response.body);
|
|
30
42
|
if (response.body.length < PAGE_SIZE) {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { join, relative, sep } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Never mirrored, even under a managed directory path: build output, VCS
|
|
5
|
+
// metadata, and installed dependencies would otherwise pollute the lock when
|
|
6
|
+
// syncing from a working tree that has them.
|
|
7
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
8
|
+
'node_modules',
|
|
9
|
+
'.git',
|
|
10
|
+
'.turbo',
|
|
11
|
+
'dist',
|
|
12
|
+
'.next',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const directiveToken = Buffer.from(['biome', 'ignore'].join('-'));
|
|
16
|
+
|
|
17
|
+
const toPosix = (path: string): string => path.split(sep).join('/');
|
|
18
|
+
|
|
19
|
+
const walkManagedFiles = async (
|
|
20
|
+
path: string,
|
|
21
|
+
repositoryRoot: string,
|
|
22
|
+
files: Map<string, string>,
|
|
23
|
+
): Promise<void> => {
|
|
24
|
+
const info = await stat(path).catch(() => null);
|
|
25
|
+
if (info === null) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (info.isDirectory()) {
|
|
29
|
+
const entries = await readdir(path);
|
|
30
|
+
await Promise.all(
|
|
31
|
+
entries
|
|
32
|
+
.filter((entry) => !IGNORED_DIRECTORIES.has(entry))
|
|
33
|
+
.map((entry) =>
|
|
34
|
+
walkManagedFiles(join(path, entry), repositoryRoot, files),
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
files.set(toPosix(relative(repositoryRoot, path)), path);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const listManagedFiles = async (
|
|
43
|
+
repositoryRoot: string,
|
|
44
|
+
paths: ReadonlyArray<string>,
|
|
45
|
+
): Promise<ReadonlyMap<string, string>> => {
|
|
46
|
+
const files = new Map<string, string>();
|
|
47
|
+
await Promise.all(
|
|
48
|
+
paths.map((path) =>
|
|
49
|
+
walkManagedFiles(join(repositoryRoot, path), repositoryRoot, files),
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
return files;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const findManagedFilesContainingBiomeDirectiveToken = async (
|
|
56
|
+
files: ReadonlyMap<string, string>,
|
|
57
|
+
): Promise<ReadonlyArray<string>> => {
|
|
58
|
+
const matches = await Promise.all(
|
|
59
|
+
[...files].map(async ([path, absolutePath]) =>
|
|
60
|
+
(await readFile(absolutePath)).includes(directiveToken) ? path : null,
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
return matches.filter((path): path is string => path !== null).sort();
|
|
64
|
+
};
|