@davidvornholt/standards 0.24.4 → 0.26.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 +248 -38
- package/package.json +9 -3
- package/src/cli.ts +18 -4
- package/src/creds-r2.ts +31 -0
- package/src/creds-sops-editor.ts +0 -1
- package/src/creds-store-lock-lease.ts +1 -1
- package/src/dev-env-brokered-resolve.ts +17 -48
- package/src/dev-env-secrets.ts +3 -27
- package/src/screenshots-commands.ts +64 -0
- package/src/screenshots-config.ts +194 -0
- package/src/screenshots-pair.ts +54 -0
- package/src/screenshots-publish.ts +177 -0
- package/src/screenshots-upload.ts +59 -0
- package/src/sops-exec.ts +28 -0
- package/src/structure-bun-version.ts +12 -0
- package/src/structure-check.ts +2 -0
- package/src/structure-profile.ts +5 -4
package/README.md
CHANGED
|
@@ -1,55 +1,265 @@
|
|
|
1
1
|
# @davidvornholt/standards
|
|
2
2
|
|
|
3
|
-
CLI for
|
|
3
|
+
CLI for initializing, synchronizing, and validating repositories that consume [`davidvornholt/standards`](../../README.md).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Start a new repository without a prior installation:
|
|
4
8
|
|
|
5
9
|
```sh
|
|
6
10
|
bunx @davidvornholt/standards init
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
standards
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
11
|
+
bun install
|
|
12
|
+
bun run check
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Adopt an existing repository by installing the exact package first:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
bun add --dev --exact @davidvornholt/standards
|
|
19
|
+
bun standards init
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Read the [adoption guide](../../docs/adoption.md) before running `init` in a repository that already has agent instructions, Just recipes, Dependabot configuration, or Claude skills.
|
|
23
|
+
|
|
24
|
+
## Commands
|
|
25
|
+
|
|
26
|
+
| Command | Purpose |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| `init` | Seed project-owned files, mirror canonical paths, generate owned output, and create the lock. |
|
|
29
|
+
| `sync` | Mirror the selected upstream revision and update the lock. |
|
|
30
|
+
| `check` | Verify drift, extension points, structure, generated output, and GitHub settings. |
|
|
31
|
+
| `doctor` | Validate integration points without checking lock-backed drift. |
|
|
32
|
+
| `structure` | Validate the monorepo contract. |
|
|
33
|
+
| `dependabot` | Verify or regenerate `.github/dependabot.yml`. |
|
|
34
|
+
| `dev-env` | Generate workspace `.env.local` files. |
|
|
35
|
+
| `github` | Compare or converge live repository settings. |
|
|
36
|
+
| `creds` | Mint and reconcile brokered credentials. |
|
|
37
|
+
| `screenshots` | Publish pull-request screenshots through the configured bucket. |
|
|
38
|
+
| `poller` | Run one approved fix or review automation tick. |
|
|
39
|
+
| `help` | Print command and option help. |
|
|
40
|
+
|
|
41
|
+
Use `bun standards help`, `bun standards creds help`, or `bun standards screenshots help` for the complete invocation syntax.
|
|
42
|
+
|
|
43
|
+
## init
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
bun standards init [--dir <repo>] [--from <source>] [--ref <ref>]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`init` is a one-time ownership cutover. It:
|
|
50
|
+
|
|
51
|
+
1. seeds missing project-owned files from `template/`;
|
|
52
|
+
2. mirrors every canonical path in `sync-standards.json`;
|
|
53
|
+
3. regenerates engine-owned output;
|
|
54
|
+
4. writes `sync-standards.lock`.
|
|
55
|
+
|
|
56
|
+
Existing project-owned files are kept. Synced files are replaced. `init` refuses after a lock exists and refuses before writing when a managed destination is a directory containing unowned work.
|
|
57
|
+
|
|
58
|
+
## sync
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
bun standards sync --dry-run
|
|
62
|
+
bun standards sync
|
|
63
|
+
bun standards sync --ref v0.26.0
|
|
64
|
+
bun standards sync --from ../standards
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`sync` mirrors canonical files and symlinks, removes canonical paths deleted upstream, regenerates `.github/dependabot.yml`, and rewrites the lock.
|
|
68
|
+
|
|
69
|
+
`--dry-run` prints the full plan without writing. `--ref` selects a tag, branch, or full commit SHA for a remote source. `--from` accepts another GitHub source or a local checkout.
|
|
70
|
+
|
|
71
|
+
A local source is useful for testing unpublished canonical changes, but its lock may describe content that does not exist upstream. Discard that test state afterward. See [Sync and ownership](../../docs/sync-and-ownership.md#test-an-unpublished-canonical-change).
|
|
72
|
+
|
|
73
|
+
## check
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
bun standards check
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The command fails when:
|
|
80
|
+
|
|
81
|
+
- a lock-backed canonical path changed, disappeared, or has the wrong path type;
|
|
82
|
+
- a required extension point is missing or invalid;
|
|
83
|
+
- generated Dependabot output differs from its inputs;
|
|
84
|
+
- the monorepo structure contract fails;
|
|
85
|
+
- the CI secret files have an invalid encrypted or example shape;
|
|
86
|
+
- declared GitHub settings differ from live state or cannot be verified.
|
|
87
|
+
|
|
88
|
+
The canonical workflow performs the live GitHub comparison in its restricted aggregator job. Local checks use the available `gh`, `GH_TOKEN`, or `GITHUB_TOKEN` authentication.
|
|
89
|
+
|
|
90
|
+
## doctor and structure
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
bun standards doctor
|
|
94
|
+
bun standards structure
|
|
95
|
+
bun standards structure --profile source
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`doctor` checks project-owned integration points such as `biome.jsonc`, `AGENTS.local.md`, package scripts, sync policy, settings overlays, and Dependabot inputs.
|
|
99
|
+
|
|
100
|
+
`structure` checks the exact Bun declaration, workspace scripts, internal dependency versions, package exports, TypeScript inheritance, accessibility wiring, workspace READMEs, and CI secret structure. The `source` profile records the few intentional differences in this repository itself.
|
|
101
|
+
|
|
102
|
+
## dependabot
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
bun standards dependabot --check
|
|
106
|
+
bun standards dependabot --write
|
|
28
107
|
```
|
|
29
108
|
|
|
30
|
-
|
|
109
|
+
The generated `.github/dependabot.yml` combines:
|
|
110
|
+
|
|
111
|
+
- canonical `.github/dependabot.base.yml`;
|
|
112
|
+
- project-owned `.github/dependabot.local.yml`.
|
|
31
113
|
|
|
32
|
-
|
|
114
|
+
The local overlay may add update blocks, private registries, and `ignore` or `registries` entries on matching canonical targets. It cannot replace canonical policy.
|
|
33
115
|
|
|
34
|
-
|
|
116
|
+
## dev-env
|
|
35
117
|
|
|
36
|
-
|
|
118
|
+
```sh
|
|
119
|
+
bun standards dev-env
|
|
120
|
+
just dev-env-generate
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Each workspace environment is composed in this order:
|
|
37
124
|
|
|
38
|
-
|
|
125
|
+
1. `config/dev.yaml`, tracked configuration;
|
|
126
|
+
2. `secrets/dev.yaml`, SOPS-encrypted shared secrets and reference policy;
|
|
127
|
+
3. `config/dev.local.yaml`, gitignored machine-specific overrides.
|
|
39
128
|
|
|
40
|
-
|
|
129
|
+
Later layers win, but one key cannot exist in both tracked layers. The command validates every destination before writing and commits all generated-file changes as one transaction.
|
|
130
|
+
|
|
131
|
+
A plain layer may reference one part of a broker-owned S3 pair:
|
|
132
|
+
|
|
133
|
+
```yaml
|
|
134
|
+
apps:
|
|
135
|
+
web:
|
|
136
|
+
R2_ACCESS_KEY_ID:
|
|
137
|
+
brokeredS3: assets
|
|
138
|
+
key: assets.web
|
|
139
|
+
part: access_key_id
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Authorize the complete pair in encrypted `secrets/dev.yaml`:
|
|
143
|
+
|
|
144
|
+
```yaml
|
|
145
|
+
brokeredReferences:
|
|
146
|
+
- assets:assets.web
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The referenced target must contain both `access_key_id` and `secret_access_key`. Every generated `.env.local`, transaction artifact, and `config/dev.local.yaml` must be ignored by Git.
|
|
150
|
+
|
|
151
|
+
## github
|
|
152
|
+
|
|
153
|
+
```sh
|
|
154
|
+
bun standards github --check
|
|
155
|
+
bun standards github --apply
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The command merges `.github/settings.json` with `.github/settings.local.json`, then compares or converges repository settings, declared rulesets, and canonical labels. Undeclared live labels are left alone. Undeclared repository-owned rulesets are removed by `--apply`.
|
|
159
|
+
|
|
160
|
+
`--apply` needs admin authentication and rechecks the API response instead of treating an accepted request as success. A repository whose plan cannot enforce rulesets may declare `"rulesetEnforcement": "unavailable-on-plan"` in the local settings file.
|
|
161
|
+
|
|
162
|
+
## creds
|
|
163
|
+
|
|
164
|
+
The broker writes secret values directly into SOPS targets. It does not print them or pass them through command arguments.
|
|
165
|
+
|
|
166
|
+
### Bootstrap providers
|
|
167
|
+
|
|
168
|
+
```sh
|
|
169
|
+
bun standards creds login github
|
|
170
|
+
bun standards creds login github --org <org>
|
|
171
|
+
bun standards creds login cloudflare
|
|
172
|
+
bun standards creds status
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The machine-global broker store lives at `$XDG_CONFIG_HOME/standards/broker.yaml`, or `~/.config/standards/broker.yaml` by default, with owner-only permissions. It holds one private GitHub App per repository owner and one Cloudflare bootstrap token per account.
|
|
176
|
+
|
|
177
|
+
### Mint credentials
|
|
178
|
+
|
|
179
|
+
```sh
|
|
180
|
+
bun standards creds add github --dest ci:ci.broker_app
|
|
181
|
+
bun standards creds add cloudflare \
|
|
182
|
+
--dest ci:ci.cloudflare_workers_token \
|
|
183
|
+
--permissions "Workers Scripts Write"
|
|
184
|
+
bun standards creds add cloudflare \
|
|
185
|
+
--dest assets:assets.screenshots_rw \
|
|
186
|
+
--bucket screenshots \
|
|
187
|
+
--s3 \
|
|
188
|
+
--permissions "Workers R2 Storage Bucket Item Write"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Use `--zone <id>` for zone-scoped Cloudflare permissions and `--jurisdiction eu` for an EU R2 bucket. Run `bun standards creds permissions` to list accepted permission-group names.
|
|
192
|
+
|
|
193
|
+
### Reconcile Cloudflare tokens
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
bun standards creds plan
|
|
197
|
+
bun standards creds apply
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The desired destinations are inferred from SOPS key structure. `plan` reports renewals, revocations, unmanaged tokens, and tokens owned by another repository. `apply` renews expiring brokered tokens and revokes brokered tokens whose SOPS destination was removed. It never changes credentials it did not mint.
|
|
201
|
+
|
|
202
|
+
To delete one Cloudflare token outside normal reconciliation:
|
|
203
|
+
|
|
204
|
+
```sh
|
|
205
|
+
bun standards creds revoke --account <account-id> --token-id <token-id>
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Bootstrap credentials and tokens still owned by an active repository are refused.
|
|
209
|
+
|
|
210
|
+
## screenshots
|
|
211
|
+
|
|
212
|
+
```sh
|
|
213
|
+
bun standards screenshots publish docs/pr/settings-page.png docs/pr/settings-empty-state.png
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The command reads `config/screenshots.yaml`, resolves its S3 pair from SOPS, uploads each supported image to a content-addressed key, and prints Markdown image lines in input order.
|
|
217
|
+
|
|
218
|
+
```yaml
|
|
219
|
+
pair: assets:assets.screenshots_rw
|
|
220
|
+
bucket: screenshots
|
|
221
|
+
endpoint: https://<account-id>.r2.cloudflarestorage.com
|
|
222
|
+
publicBaseUrl: https://screenshots.example.com
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Published URLs are public and permanent. Use demo data and exclude secrets and personal data.
|
|
226
|
+
|
|
227
|
+
## poller
|
|
228
|
+
|
|
229
|
+
```sh
|
|
230
|
+
bun standards poller --config <path>
|
|
231
|
+
bun standards poller --acknowledge-only --config <path>
|
|
232
|
+
bun standards poller --print-units --config <path>
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The host-level JSON configuration requires:
|
|
236
|
+
|
|
237
|
+
```json
|
|
238
|
+
{
|
|
239
|
+
"repos": ["owner/repo"],
|
|
240
|
+
"model": "<codex model>",
|
|
241
|
+
"reasoningEffort": "<effort>"
|
|
242
|
+
}
|
|
243
|
+
```
|
|
41
244
|
|
|
42
|
-
`creds` is the credential broker: it mints scoped Cloudflare tokens, rotates and revokes those tokens, and places GitHub App credentials into SOPS-encrypted targets — secret values never touch stdout, argv, or an agent's context; commands print only metadata (token name, scopes, expiry, destination). Cloudflare reconciliation has exactly two sources of truth: the plaintext key structure of the SOPS files and the provider token list under the deterministic naming scheme `standards/<owner>/<repo>/<sops-target>/<dotted.key>`; there is no separate credential manifest. `plan` and `apply` reconcile Cloudflare only: each run uses the stored bootstrap credential to verify its current provider token ID, excludes that ID from reconciliation regardless of its current name, and aborts if the identity cannot be correlated with the complete account token list; a brokered token whose SOPS key was deleted is revoked, while one nearing expiry (30 days) is renewed by creating a fresh-expiry replacement with the live token's policies, durably writing and verifying the new value, and only then revoking the old token; secrets the broker did not mint are never touched. Both also report what reconciliation will never touch, in two separately labelled and separately counted blocks that never block: unmanaged tokens, meaning every unexpired token in the account whose name falls outside the reserved `standards/` namespace — with one exception, this machine's own bootstrap credential, which every run removes by verified ID before anything is classified — and tokens brokered to another repository, each printed with the repository whose `apply` owns it. An expired token is omitted from the unmanaged listing because it grants nothing. Neither block is idle information: nothing here renews or revokes those tokens, and a forgotten one — the original half of a hand-made credential that was replaced rather than retired, or a brokered token whose repository was renamed away — stays valid indefinitely, so a listed token is normally printed with the exact command that retires it, carrying `--account` and, for a token brokered elsewhere, `--force` and the one condition that justifies it. Two rows are exceptions. A token named `standards-broker` holds the reserved name for a machine's bootstrap credential, so it is either another machine's or a superseded one on this machine; `revoke` refuses it by name, so the row names the Cloudflare dashboard rather than a command that would always fail. The other exception looks like an ordinary brokered-elsewhere row, and its command fails. `plan` classifies by exact token name, so a token whose brokered repository differs from this checkout's origin only in capitalisation lands in the brokered-elsewhere block with a `--force` command; `revoke` compares repository names case-insensitively, reads the same token as this checkout's own, and refuses. Row and block header both read that token as foreign — the header labels the block tokens brokered to another repository, and the row explains the `--force` by the owning repository having been renamed, transferred, or deleted. Neither holds there: GitHub names are case-insensitive, so the owning repository is this one and it is still alive. Re-point the origin remote at the capitalisation the token carries, or retire the token in the Cloudflare dashboard. A name that claims this repository's brokered namespace without being one the broker mints is a finding instead, and findings abort reconciliation before any mutation, because reconciliation cannot own that token; plain `revoke --token-id` takes it once you decide it should go. `revoke --token-id <id>` deletes exactly one Cloudflare token, named by its 32-character hexadecimal ID, with `--account <id>` required whenever more than one account is configured; it records no ownership, so the power is momentary rather than standing and a human names the target every time. It refuses an ID the account does not hold, the bootstrap credential this machine has stored (matched by verified ID, so a rename cannot disguise it), and any other token named `standards-broker`, the reserved name for a machine's bootstrap (matched by name, because that one is another machine's bootstrap or a superseded one, retired in the Cloudflare dashboard where you can confirm whose it is — the broker cannot re-mint its own root credential). A token brokered to this repository is refused with the remedy that works here: delete its SOPS key in this repository and run `apply`, which revokes the token and keeps the secret in step. A token whose brokered name differs from this checkout's origin only in capitalisation is refused too, and without the `apply` remedy: reconciliation matches the name exactly, so from here it would neither renew nor revoke the token. Its remedies are re-pointing the origin remote at the capitalisation the token carries, which brings it back under reconciliation, or retiring it in the Cloudflare dashboard. A token brokered to a repository that is genuinely a different one is refused with that repository named, because the same `apply` remedy runs there. `--force` widens exactly that last refusal, and nothing else: it lets `revoke` delete a token whose brokered repository is not this checkout's. It verifies nothing about the state of that repository, so it is justified only when nothing reconciles the token any more — the owning repository was renamed, transferred, or deleted, and no checkout resolves to its name. `revoke` decides ownership from the origin remote and compares it case-insensitively, as GitHub names are; `plan` classifies by exact name instead, which is why the two disagree about a case-only difference. When the origin remote resolves to no GitHub repository, every brokered token is refused, `--force` included, because the check it widens is the one that could not be run. `--force` never bypasses either bootstrap guard, the case-only refusal, or that unresolvable-origin refusal. Without a manifest there is no desired-policy record, so live policy drift is not detected, and a repository rename or transfer changes the deterministic namespace and requires the visible old token to be re-minted under the new name and revoked. `add cloudflare` mints an account-owned API token (default TTL 90 days) scoped to the named permission groups (`permissions` lists the valid names) and writes it to `--dest <target>:<dotted.key>`, where `<target>` resolves like the canonical `just secrets` targets (`ci` → `secrets/ci.yaml`, `prod-1` → `infra/hosts/prod-1/secrets.yaml`). `--zone <zone-id>[,<zone-id>...]` adds a zone resource so zone-scoped groups such as DNS Write can be minted. Zones are named by their 32-character hexadecimal ID, shown on the zone's dashboard overview and not a secret; resolving a domain name instead would need Zone Read on the bootstrap token, which holds Account API Tokens / Edit and nothing else. With zones named, the selection is split by what each group can target: every group Cloudflare reports as zone-scoped forms one policy holding all named zones, and groups that can only target an account keep their own account policy on the same token, so one credential can hold DNS access next to account-wide access. A group Cloudflare reports as both takes the zone policy, which is the narrower reading of `--zone`. A group matching neither resource is named and rejected, `--zone` requires at least one zone-scoped group, and `--zone` cannot be combined with `--bucket`, which is a separate credential. The mint summary prints the resources the token reaches, so an account policy alongside the zones is visible rather than implied. `--bucket <name>` switches the policy resource to a single R2 bucket; `--jurisdiction default|eu` selects the provider jurisdiction and defaults to `default`, producing `com.cloudflare.edge.r2.bucket.<account_id>_default_<bucket>` or `com.cloudflare.edge.r2.bucket.<account_id>_eu_<bucket>`. FedRAMP jurisdictions are not supported. Bucket destinations accept only bucket-scoped groups such as Workers R2 Storage Bucket Item Read/Write, and account-scoped groups are rejected. `--s3` requires `--bucket` and stores the S3-compatible credential pair Cloudflare derives from an API token — access key ID is the token ID, secret access key is the SHA-256 hex digest of the token value — as `<dotted.key>.access_key_id` and `<dotted.key>.secret_access_key` under the destination instead of the raw token value; the default endpoint is `https://<account_id>.r2.cloudflarestorage.com`, while EU uses `https://<account_id>.eu.r2.cloudflarestorage.com`. Reconciliation infers each destination's concrete write footprint from the plaintext SOPS key structure — a bearer owns the leaf at its key, while an S3 token owns both pair leaves — and aborts before mutation when managed footprints intersect. `plan` and `apply` renew and revoke S3 destinations like any other brokered token, but renewal first proves the stored access key ID matches the current provider token; an incomplete pair, unreadable value, or identity mismatch is surfaced as a finding and never mutated automatically. `add github` selects the private broker App whose owner matches the destination repository's origin owner, checks both local SOPS leaves before authenticating, proves that the exact selected App is installed on that repository, and writes only while that full App identity still holds the synchronized broker-store entry; missing, duplicate, replaced, or mismatched owners fail before SOPS changes. Durable GitHub tokens cannot be created via API (PAT and OAuth-app creation are UI-only), so workflows mint short-lived installation tokens from the App at runtime (`actions/create-github-app-token`), scoped per repository and permission. The canonical weekly sync expects `bun standards creds add github --dest ci:ci.broker_app`, then mints a current-repository branch writer with Contents write and Workflows write plus a separate PR opener with Contents read and Pull requests write; both mint before sync but enter only their post-sync operations, and missing credentials, unapproved App permissions, or minting failures stop the workflow without a fallback. Existing broker Apps must add Workflows write on the App settings page and have every installation owner approve that permission before consumers adopt the workflow. Bootstrap is once per machine and per GitHub owner: `login github` creates a private personal App, while `login github --org <org>` creates or replaces only that organization's entry; each manifest conversion records GitHub's authoritative owner, and the command opens the installation page, where the App must be bound to selected repositories rather than all repositories. GitHub App names are globally unique and limited to 34 characters, so organization defaults include owners up to 17 characters; a longer owner or a colliding replacement name requires an explicit shorter unused `--name`. The first GitHub-specific command against an existing singleton broker store authenticates as that App, resolves its registration owner, and atomically migrates the old mapping to that one owner; a failed lookup leaves the persisted bytes untouched. If the singleton was a public App installed on repositories owned by other users or organizations, those repositories stop matching after migration because owner selection no longer treats that App as shared. For each affected owner, run `login github` while signed in as that personal owner or `login github --org <owner>`, install the new private App only on the selected repositories, run `bun standards creds add github --dest <target>:<key>` in every consumer, and verify its workflows can mint tokens. Retire the old public App only after every repository owner has its own entry, every intended SOPS destination has been reprovisioned, and token minting has been verified; until then, keep its key and installation available for any destinations not yet migrated. `login cloudflare` guides the one dashboard paste Cloudflare requires for the first account token (named `standards-broker` to match the App, created from the "Create additional tokens" token template because its "Account API Tokens: Edit" permission is unavailable in the custom token builder, and holding that permission only), verifies that it is active, correlates its verified ID with the complete account token list and an active list record, and stores it; a bootstrap token named inside the minted `standards/` namespace is rejected before storage to keep its role unambiguous, while any other divergence from the recommended name only warns. `plan` and `apply` do not rotate GitHub App keys; create a replacement key on the App's settings page, atomically replace that owner's `private_key` in the protected machine-global broker store while preserving its `0600` mode, then update every intended SOPS destination, verify workflows can mint installation tokens, and revoke the old key manually. Root credentials live in the machine-global broker store (see Configuration); SOPS writes go through `sops edit` with a non-interactive editor, preserving comments and reusing the file's data key.
|
|
245
|
+
Optional keys are `maxJobsPerTick`, `staleClaimHours`, `runTimeoutMinutes`, `cacheDir`, and `extraCodexArgs`. One poller supports at most 12 repositories.
|
|
43
246
|
|
|
44
|
-
|
|
247
|
+
The worker accepts only exact issue or draft-PR revisions approved by a user with `admin` or `maintain` role. It runs Codex in a disposable worktree, revalidates approval before publication, and leaves merge to a human. The polling host owns authenticated `codex` and `gh`, the service token, writable home, configuration, and systemd deployment.
|
|
45
248
|
|
|
46
|
-
## Configuration
|
|
249
|
+
## Configuration index
|
|
47
250
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
251
|
+
| File or variable | Purpose |
|
|
252
|
+
| --- | --- |
|
|
253
|
+
| `sync-standards.json` | Canonical source, seed directory, and managed paths. |
|
|
254
|
+
| `sync-standards.local.json` | Optional `ref` pin and `autoSync` policy. |
|
|
255
|
+
| `sync-standards.lock` | Exact synchronized commit and managed digests. |
|
|
256
|
+
| `.github/dependabot.local.yml` | Project-owned Dependabot additions. |
|
|
257
|
+
| `.github/settings.local.json` | Project-owned GitHub settings additions. |
|
|
258
|
+
| `config/dev.yaml` | Shared non-secret development configuration. |
|
|
259
|
+
| `secrets/dev.yaml` | Shared development secrets and broker-reference policy. |
|
|
260
|
+
| `config/dev.local.yaml` | Machine-specific overrides. |
|
|
261
|
+
| `config/screenshots.yaml` | Public screenshot bucket and SOPS pair reference. |
|
|
262
|
+
| `STANDARDS_BROKER_FILE` | Optional broker-store path override. |
|
|
263
|
+
| `GH_TOKEN` or `GITHUB_TOKEN` | GitHub API authentication where required. |
|
|
54
264
|
|
|
55
|
-
|
|
265
|
+
The [root README](../../README.md) gives the short overview. The [adoption guide](../../docs/adoption.md) and [sync guide](../../docs/sync-and-ownership.md) explain repository ownership and first use.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davidvornholt/standards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -157,8 +157,14 @@
|
|
|
157
157
|
"src/poller-trust.ts",
|
|
158
158
|
"src/poller-units.ts",
|
|
159
159
|
"src/poller-workspace.ts",
|
|
160
|
+
"src/screenshots-commands.ts",
|
|
161
|
+
"src/screenshots-config.ts",
|
|
162
|
+
"src/screenshots-pair.ts",
|
|
163
|
+
"src/screenshots-publish.ts",
|
|
164
|
+
"src/screenshots-upload.ts",
|
|
160
165
|
"src/sops-exec.ts",
|
|
161
166
|
"src/structure-check.ts",
|
|
167
|
+
"src/structure-bun-version.ts",
|
|
162
168
|
"src/structure-profile.ts",
|
|
163
169
|
"src/structure-readme.ts",
|
|
164
170
|
"src/structure-secret-document.ts",
|
|
@@ -178,12 +184,12 @@
|
|
|
178
184
|
},
|
|
179
185
|
"devDependencies": {
|
|
180
186
|
"@davidvornholt/typescript-config": "0.0.0",
|
|
181
|
-
"@types/bun": "^1.
|
|
187
|
+
"@types/bun": "^1.4.0",
|
|
182
188
|
"sigstore": "5.0.0",
|
|
183
189
|
"typescript": "7.0.2"
|
|
184
190
|
},
|
|
185
191
|
"engines": {
|
|
186
|
-
"bun": ">=1.
|
|
192
|
+
"bun": ">=1.4.0"
|
|
187
193
|
},
|
|
188
194
|
"publishConfig": {
|
|
189
195
|
"access": "public"
|
package/src/cli.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
unlockedPathsUnder,
|
|
44
44
|
} from './managed-path-ownership';
|
|
45
45
|
import { runPollerCommand } from './poller-commands';
|
|
46
|
+
import { runScreenshotsCommand } from './screenshots-commands';
|
|
46
47
|
import { collectStructureProblems } from './structure-check';
|
|
47
48
|
import type { StructureProfile } from './structure-profile';
|
|
48
49
|
import { hasSafeCommand } from './structure-script';
|
|
@@ -973,6 +974,7 @@ Commands:
|
|
|
973
974
|
dev-env Compose each workspace's generated .env.local from its three dev layers and authorized broker-owned S3 pair references
|
|
974
975
|
github Compare (--check) or converge (--apply) live GitHub settings
|
|
975
976
|
creds Mint, rotate, and revoke brokered credentials (see \`standards creds help\`)
|
|
977
|
+
screenshots Publish UI screenshots to the configured public bucket and print PR-ready markdown (see \`standards screenshots help\`)
|
|
976
978
|
poller Run one fix-poller tick over the configured repositories (host automation)
|
|
977
979
|
help Show this help
|
|
978
980
|
|
|
@@ -1317,12 +1319,24 @@ const runGateCommand = (
|
|
|
1317
1319
|
return apply ? runGithubApply(consumer) : runGithubCheckGate(consumer);
|
|
1318
1320
|
};
|
|
1319
1321
|
|
|
1322
|
+
// The creds family owns its flag vocabulary and the screenshots family takes
|
|
1323
|
+
// variadic file operands; both route before the strict global parser rejects
|
|
1324
|
+
// their arguments.
|
|
1325
|
+
const commandFamilies: Readonly<
|
|
1326
|
+
Record<string, (argv: ReadonlyArray<string>) => Promise<boolean>>
|
|
1327
|
+
> = {
|
|
1328
|
+
creds: runCredsCommand,
|
|
1329
|
+
screenshots: runScreenshotsCommand,
|
|
1330
|
+
};
|
|
1331
|
+
|
|
1320
1332
|
const main = async (): Promise<void> => {
|
|
1321
|
-
// The creds family owns its flag vocabulary; route it before the strict
|
|
1322
|
-
// global parser rejects those flags.
|
|
1323
1333
|
const rawArgs = process.argv.slice(2);
|
|
1324
|
-
|
|
1325
|
-
|
|
1334
|
+
const familyName = rawArgs[0] ?? '';
|
|
1335
|
+
const family = Object.hasOwn(commandFamilies, familyName)
|
|
1336
|
+
? commandFamilies[familyName as keyof typeof commandFamilies]
|
|
1337
|
+
: undefined;
|
|
1338
|
+
if (family !== undefined) {
|
|
1339
|
+
if (!(await family(rawArgs.slice(1)))) {
|
|
1326
1340
|
process.exitCode = 1;
|
|
1327
1341
|
}
|
|
1328
1342
|
return;
|
package/src/creds-r2.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { createHash } from 'node:crypto';
|
|
10
10
|
import type { SopsValueChange } from './creds-sops';
|
|
11
|
+
import { isRecord } from './github-settings-parse';
|
|
11
12
|
|
|
12
13
|
// Scope the permission_groups endpoint reports for the bucket-item groups
|
|
13
14
|
// ("Workers R2 Storage Bucket Item Read"/"... Write"); account-scoped groups
|
|
@@ -41,6 +42,36 @@ export const s3Endpoint = (
|
|
|
41
42
|
): string =>
|
|
42
43
|
`https://${accountId}.${jurisdiction === 'eu' ? 'eu.' : ''}r2.cloudflarestorage.com`;
|
|
43
44
|
|
|
45
|
+
export type S3PairLookup =
|
|
46
|
+
| {
|
|
47
|
+
readonly ok: true;
|
|
48
|
+
readonly accessKeyId: string;
|
|
49
|
+
readonly secretAccessKey: string;
|
|
50
|
+
}
|
|
51
|
+
| { readonly ok: false; readonly kind: 'missing-key' | 'incomplete' };
|
|
52
|
+
|
|
53
|
+
// Walks a decrypted SOPS document to the dotted key and requires the complete
|
|
54
|
+
// pair shape destinationWrites stores: both leaves must be strings even when
|
|
55
|
+
// the caller needs only one part.
|
|
56
|
+
export const lookupS3Pair = (document: unknown, key: string): S3PairLookup => {
|
|
57
|
+
let node: unknown = document;
|
|
58
|
+
for (const segment of key.split('.')) {
|
|
59
|
+
if (!isRecord(node) || node[segment] === undefined) {
|
|
60
|
+
return { ok: false, kind: 'missing-key' };
|
|
61
|
+
}
|
|
62
|
+
node = node[segment];
|
|
63
|
+
}
|
|
64
|
+
return isRecord(node) &&
|
|
65
|
+
typeof node.access_key_id === 'string' &&
|
|
66
|
+
typeof node.secret_access_key === 'string'
|
|
67
|
+
? {
|
|
68
|
+
ok: true,
|
|
69
|
+
accessKeyId: node.access_key_id,
|
|
70
|
+
secretAccessKey: node.secret_access_key,
|
|
71
|
+
}
|
|
72
|
+
: { ok: false, kind: 'incomplete' };
|
|
73
|
+
};
|
|
74
|
+
|
|
44
75
|
export type DestinationFormat = 'bearer' | 's3';
|
|
45
76
|
|
|
46
77
|
const ACCESS_KEY_SEGMENT = 'access_key_id';
|
package/src/creds-sops-editor.ts
CHANGED
|
@@ -143,7 +143,6 @@ export const applySopsEditorChanges = (
|
|
|
143
143
|
}
|
|
144
144
|
document.setIn(path, change.value);
|
|
145
145
|
}
|
|
146
|
-
// biome-ignore lint/nursery/noBaseToString: YAML Document implements toString as its comment-preserving serializer.
|
|
147
146
|
return document.toString();
|
|
148
147
|
};
|
|
149
148
|
const runEditor = (): void => {
|
|
@@ -24,7 +24,7 @@ export const startBrokerLockLease = (
|
|
|
24
24
|
holderPath: string,
|
|
25
25
|
intervalMs: number,
|
|
26
26
|
): Worker => {
|
|
27
|
-
const lease = new Worker(import.meta.url, {
|
|
27
|
+
const lease = new Worker(new URL(import.meta.url), {
|
|
28
28
|
workerData: { holderPath, intervalMs } satisfies LeaseWorkerData,
|
|
29
29
|
});
|
|
30
30
|
lease.unref();
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
// after layer composition so a later layer's literal can override an earlier
|
|
4
4
|
// layer's reference, and a reference that lost the merge is never decrypted.
|
|
5
5
|
|
|
6
|
+
import { lookupS3Pair } from './creds-r2';
|
|
6
7
|
import { resolveTargetRelResult } from './creds-target';
|
|
7
8
|
import type { BrokeredS3Reference } from './dev-env-brokered';
|
|
8
9
|
import type { ComposedDevEnvTarget } from './dev-env-compose';
|
|
9
10
|
import { encodePortableDotenvValue } from './dev-env-dotenv-value';
|
|
10
|
-
import {
|
|
11
|
-
import { runSops } from './sops-exec';
|
|
11
|
+
import { decryptSopsJson, type SopsJsonResult } from './sops-exec';
|
|
12
12
|
|
|
13
13
|
export type ResolvedDevEnvTarget = {
|
|
14
14
|
readonly group: string;
|
|
@@ -22,58 +22,27 @@ export type ResolvedDevEnv = {
|
|
|
22
22
|
readonly problems: ReadonlyArray<string>;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
type SopsDocumentResult =
|
|
26
|
-
| { readonly ok: true; readonly value: unknown }
|
|
27
|
-
| { readonly ok: false; readonly problem: string };
|
|
28
|
-
|
|
29
|
-
const decryptSopsJson = (consumer: string, rel: string): SopsDocumentResult => {
|
|
30
|
-
const result = runSops(['--decrypt', '--output-type', 'json', rel], consumer);
|
|
31
|
-
if (result.status !== 0) {
|
|
32
|
-
const detail = result.errorMessage ?? result.stderr.trim();
|
|
33
|
-
return {
|
|
34
|
-
ok: false,
|
|
35
|
-
problem: detail
|
|
36
|
-
? `could not decrypt ${rel}: ${detail}`
|
|
37
|
-
: `could not decrypt ${rel}`,
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
try {
|
|
41
|
-
return { ok: true, value: JSON.parse(result.stdout) as unknown };
|
|
42
|
-
} catch (error) {
|
|
43
|
-
return {
|
|
44
|
-
ok: false,
|
|
45
|
-
problem: `could not parse decrypted ${rel} as JSON: ${
|
|
46
|
-
error instanceof Error ? error.message : String(error)
|
|
47
|
-
}`,
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
};
|
|
51
|
-
|
|
52
25
|
const lookupReference = (
|
|
53
26
|
document: unknown,
|
|
54
27
|
reference: BrokeredS3Reference,
|
|
55
28
|
): { readonly value: string | null; readonly problem: string | null } => {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (!isRecord(node) || node[segment] === undefined) {
|
|
59
|
-
return {
|
|
60
|
-
value: null,
|
|
61
|
-
problem: `has no key "${reference.key}"; mint the pair with \`bun standards creds add cloudflare --s3 --dest ${reference.brokeredS3}:${reference.key}\``,
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
node = node[segment];
|
|
65
|
-
}
|
|
66
|
-
if (
|
|
67
|
-
!isRecord(node) ||
|
|
68
|
-
typeof node.access_key_id !== 'string' ||
|
|
69
|
-
typeof node.secret_access_key !== 'string'
|
|
70
|
-
) {
|
|
29
|
+
const pair = lookupS3Pair(document, reference.key);
|
|
30
|
+
if (!pair.ok) {
|
|
71
31
|
return {
|
|
72
32
|
value: null,
|
|
73
|
-
problem:
|
|
33
|
+
problem:
|
|
34
|
+
pair.kind === 'missing-key'
|
|
35
|
+
? `has no key "${reference.key}"; mint the pair with \`bun standards creds add cloudflare --s3 --dest ${reference.brokeredS3}:${reference.key}\``
|
|
36
|
+
: `key "${reference.key}" does not hold a complete brokered S3 pair; both "access_key_id" and "secret_access_key" must be strings`,
|
|
74
37
|
};
|
|
75
38
|
}
|
|
76
|
-
return {
|
|
39
|
+
return {
|
|
40
|
+
value:
|
|
41
|
+
reference.part === 'access_key_id'
|
|
42
|
+
? pair.accessKeyId
|
|
43
|
+
: pair.secretAccessKey,
|
|
44
|
+
problem: null,
|
|
45
|
+
};
|
|
77
46
|
};
|
|
78
47
|
|
|
79
48
|
export const resolveBrokeredReferences = (
|
|
@@ -83,8 +52,8 @@ export const resolveBrokeredReferences = (
|
|
|
83
52
|
preservedReferences: ReadonlySet<string> = new Set(),
|
|
84
53
|
): ResolvedDevEnv => {
|
|
85
54
|
const problems: Array<string> = [];
|
|
86
|
-
const documents = new Map<string,
|
|
87
|
-
const readDocument = (targetName: string):
|
|
55
|
+
const documents = new Map<string, SopsJsonResult>();
|
|
56
|
+
const readDocument = (targetName: string): SopsJsonResult => {
|
|
88
57
|
const cached = documents.get(targetName);
|
|
89
58
|
if (cached !== undefined) {
|
|
90
59
|
return cached;
|
package/src/dev-env-secrets.ts
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { decryptSopsJson, type SopsJsonResult } from './sops-exec';
|
|
4
4
|
|
|
5
5
|
export const DEV_SECRETS_FILE = 'secrets/dev.yaml';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
export type DevSecretsResult = SopsJsonResult;
|
|
8
8
|
|
|
9
|
-
export type DevSecretsResult =
|
|
10
|
-
| { readonly ok: true; readonly value: unknown }
|
|
11
|
-
| { readonly ok: false; readonly problem: string };
|
|
12
|
-
|
|
13
|
-
// sops emits JSON here so the CLI never parses the encrypted YAML itself.
|
|
14
9
|
export const readDevSecrets = (consumer: string): DevSecretsResult => {
|
|
15
10
|
if (!existsSync(join(consumer, DEV_SECRETS_FILE))) {
|
|
16
11
|
return {
|
|
@@ -18,24 +13,5 @@ export const readDevSecrets = (consumer: string): DevSecretsResult => {
|
|
|
18
13
|
problem: `${DEV_SECRETS_FILE} not found; create it with \`just secrets edit dev\``,
|
|
19
14
|
};
|
|
20
15
|
}
|
|
21
|
-
|
|
22
|
-
if (result.status !== 0) {
|
|
23
|
-
const detail = result.errorMessage ?? result.stderr.trim();
|
|
24
|
-
return {
|
|
25
|
-
ok: false,
|
|
26
|
-
problem: detail
|
|
27
|
-
? `could not decrypt ${DEV_SECRETS_FILE}: ${detail}`
|
|
28
|
-
: `could not decrypt ${DEV_SECRETS_FILE}`,
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
try {
|
|
32
|
-
return { ok: true, value: JSON.parse(result.stdout) as unknown };
|
|
33
|
-
} catch (error) {
|
|
34
|
-
return {
|
|
35
|
-
ok: false,
|
|
36
|
-
problem: `could not parse decrypted ${DEV_SECRETS_FILE} as JSON: ${
|
|
37
|
-
error instanceof Error ? error.message : String(error)
|
|
38
|
-
}`,
|
|
39
|
-
};
|
|
40
|
-
}
|
|
16
|
+
return decryptSopsJson(consumer, DEV_SECRETS_FILE);
|
|
41
17
|
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The screenshots command family: publish UI screenshots to the repository's
|
|
2
|
+
// configured public bucket and print the markdown that embeds them in a pull
|
|
3
|
+
// request description. Configuration lives in the tracked
|
|
4
|
+
// config/screenshots.yaml; the signing credential stays in its SOPS target.
|
|
5
|
+
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
import process from 'node:process';
|
|
8
|
+
import { SCREENSHOTS_CONFIG_FILE } from './screenshots-config';
|
|
9
|
+
import { runScreenshotsPublish } from './screenshots-publish';
|
|
10
|
+
|
|
11
|
+
const SCREENSHOTS_USAGE = `Usage: standards screenshots <command> [options]
|
|
12
|
+
|
|
13
|
+
Commands:
|
|
14
|
+
publish <files...> Upload screenshots to the configured public bucket at content-addressed keys and print one markdown image line per file
|
|
15
|
+
|
|
16
|
+
Options:
|
|
17
|
+
--dir <path> Repository holding ${SCREENSHOTS_CONFIG_FILE} and the SOPS credential target (default: current directory)
|
|
18
|
+
|
|
19
|
+
Publishing reads ${SCREENSHOTS_CONFIG_FILE} (pair, bucket, endpoint, publicBaseUrl) and resolves the credential pair from its SOPS target at publish time. Published URLs are public to anyone holding the link and are kept indefinitely so pull request history keeps rendering.`;
|
|
20
|
+
|
|
21
|
+
type ScreenshotsFlags = {
|
|
22
|
+
dir: string;
|
|
23
|
+
readonly words: Array<string>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const parseScreenshotsArgs = (
|
|
27
|
+
argv: ReadonlyArray<string>,
|
|
28
|
+
): ScreenshotsFlags => {
|
|
29
|
+
const flags: ScreenshotsFlags = { dir: process.cwd(), words: [] };
|
|
30
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
31
|
+
const arg = argv[index] ?? '';
|
|
32
|
+
if (arg === '--dir') {
|
|
33
|
+
const value = argv[index + 1];
|
|
34
|
+
if (value === undefined || value.startsWith('--')) {
|
|
35
|
+
throw new Error(`${arg} requires a value`);
|
|
36
|
+
}
|
|
37
|
+
flags.dir = value;
|
|
38
|
+
index += 1;
|
|
39
|
+
} else if (arg.startsWith('--')) {
|
|
40
|
+
throw new Error(`Unknown screenshots option: ${arg}`);
|
|
41
|
+
} else {
|
|
42
|
+
flags.words.push(arg);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
flags.dir = resolve(flags.dir);
|
|
46
|
+
return flags;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const runScreenshotsCommand = (
|
|
50
|
+
argv: ReadonlyArray<string>,
|
|
51
|
+
): Promise<boolean> => {
|
|
52
|
+
const flags = parseScreenshotsArgs(argv);
|
|
53
|
+
const [route, ...files] = flags.words;
|
|
54
|
+
if (route === undefined || route === 'help') {
|
|
55
|
+
console.log(SCREENSHOTS_USAGE);
|
|
56
|
+
return Promise.resolve(route === 'help');
|
|
57
|
+
}
|
|
58
|
+
if (route === 'publish') {
|
|
59
|
+
return runScreenshotsPublish(flags.dir, files);
|
|
60
|
+
}
|
|
61
|
+
console.error(`standards screenshots: unknown command: ${route}\n`);
|
|
62
|
+
console.error(SCREENSHOTS_USAGE);
|
|
63
|
+
return Promise.resolve(false);
|
|
64
|
+
};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// The tracked config/screenshots.yaml is a repository's contract for
|
|
2
|
+
// `standards screenshots publish`: which brokered S3 pair signs uploads,
|
|
3
|
+
// which bucket and endpoint receive them, and which public base URL serves
|
|
4
|
+
// the published objects. Every value here is configuration; the credential
|
|
5
|
+
// pair stays in its SOPS target and is resolved only at publish time.
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { type CredsDestination, parseDestination } from './creds-dest';
|
|
10
|
+
import { isR2BucketName } from './creds-r2';
|
|
11
|
+
import { isRecord } from './github-settings-parse';
|
|
12
|
+
import { parseYaml } from './yaml-parse';
|
|
13
|
+
|
|
14
|
+
export const SCREENSHOTS_CONFIG_FILE = 'config/screenshots.yaml';
|
|
15
|
+
|
|
16
|
+
const FIELDS = ['pair', 'bucket', 'endpoint', 'publicBaseUrl'] as const;
|
|
17
|
+
|
|
18
|
+
const R2_ENDPOINT_HOST = /^[0-9a-f]{32}(?:\.eu)?\.r2\.cloudflarestorage\.com$/u;
|
|
19
|
+
const LOCAL_ENDPOINT_HOSTS = new Set(['127.0.0.1', '[::1]', 'localhost']);
|
|
20
|
+
const TRAILING_SLASHES = /\/+$/u;
|
|
21
|
+
const MAX_ASCII_CONTROL_CODE = 0x1f;
|
|
22
|
+
const DELETE_CONTROL_CODE = 0x7f;
|
|
23
|
+
|
|
24
|
+
export type ScreenshotsConfig = {
|
|
25
|
+
readonly pair: CredsDestination;
|
|
26
|
+
readonly bucket: string;
|
|
27
|
+
readonly endpoint: string;
|
|
28
|
+
readonly publicBaseUrl: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type ScreenshotsConfigResult =
|
|
32
|
+
| { readonly ok: true; readonly value: ScreenshotsConfig }
|
|
33
|
+
| { readonly ok: false; readonly problems: ReadonlyArray<string> };
|
|
34
|
+
|
|
35
|
+
type RawScreenshotsConfigResult =
|
|
36
|
+
| { readonly ok: true; readonly value: string }
|
|
37
|
+
| { readonly ok: false; readonly problems: ReadonlyArray<string> };
|
|
38
|
+
|
|
39
|
+
const parseUrl = (value: string): URL | null => {
|
|
40
|
+
if (
|
|
41
|
+
[...value].some((character) => {
|
|
42
|
+
const code = character.codePointAt(0) ?? 0;
|
|
43
|
+
return code <= MAX_ASCII_CONTROL_CODE || code === DELETE_CONTROL_CODE;
|
|
44
|
+
})
|
|
45
|
+
) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
return new URL(value);
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const isSafeEndpoint = (value: string): boolean => {
|
|
56
|
+
const url = parseUrl(value);
|
|
57
|
+
if (
|
|
58
|
+
url === null ||
|
|
59
|
+
url.username !== '' ||
|
|
60
|
+
url.password !== '' ||
|
|
61
|
+
url.pathname !== '/' ||
|
|
62
|
+
url.search !== '' ||
|
|
63
|
+
url.hash !== ''
|
|
64
|
+
) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (LOCAL_ENDPOINT_HOSTS.has(url.hostname)) {
|
|
68
|
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
69
|
+
}
|
|
70
|
+
return (
|
|
71
|
+
url.protocol === 'https:' &&
|
|
72
|
+
url.port === '' &&
|
|
73
|
+
R2_ENDPOINT_HOST.test(url.hostname)
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const isSafePublicBaseUrl = (value: string): boolean => {
|
|
78
|
+
const url = parseUrl(value);
|
|
79
|
+
return (
|
|
80
|
+
url !== null &&
|
|
81
|
+
(url.protocol === 'https:' || url.protocol === 'http:') &&
|
|
82
|
+
url.username === '' &&
|
|
83
|
+
url.password === '' &&
|
|
84
|
+
url.search === '' &&
|
|
85
|
+
url.hash === ''
|
|
86
|
+
);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const normalizeUrl = (value: string): string => {
|
|
90
|
+
const url = parseUrl(value);
|
|
91
|
+
if (url === null) {
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
return `${url.origin}${url.pathname.replace(TRAILING_SLASHES, '')}`;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const readScreenshotsConfig = (
|
|
98
|
+
consumer: string,
|
|
99
|
+
): RawScreenshotsConfigResult => {
|
|
100
|
+
const path = join(consumer, SCREENSHOTS_CONFIG_FILE);
|
|
101
|
+
if (!existsSync(path)) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
problems: [
|
|
105
|
+
`${SCREENSHOTS_CONFIG_FILE} not found; screenshot publishing is not enabled for this repository`,
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
return { ok: true, value: readFileSync(path, 'utf8') };
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
problems: [
|
|
115
|
+
`could not read ${SCREENSHOTS_CONFIG_FILE}: ${
|
|
116
|
+
error instanceof Error ? error.message : String(error)
|
|
117
|
+
}`,
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const loadScreenshotsConfig = (
|
|
124
|
+
consumer: string,
|
|
125
|
+
): ScreenshotsConfigResult => {
|
|
126
|
+
const raw = readScreenshotsConfig(consumer);
|
|
127
|
+
if (!raw.ok) {
|
|
128
|
+
return raw;
|
|
129
|
+
}
|
|
130
|
+
const parsed = parseYaml(raw.value, SCREENSHOTS_CONFIG_FILE);
|
|
131
|
+
if (parsed.problem !== null) {
|
|
132
|
+
return { ok: false, problems: [parsed.problem] };
|
|
133
|
+
}
|
|
134
|
+
if (!isRecord(parsed.value)) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
problems: [
|
|
138
|
+
`${SCREENSHOTS_CONFIG_FILE} must be a mapping with ${FIELDS.join(', ')}`,
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const record = parsed.value;
|
|
143
|
+
const problems: Array<string> = [];
|
|
144
|
+
for (const key of Object.keys(record)) {
|
|
145
|
+
if (!(FIELDS as ReadonlyArray<string>).includes(key)) {
|
|
146
|
+
problems.push(`${SCREENSHOTS_CONFIG_FILE} has an unknown key "${key}"`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const stringField = (field: (typeof FIELDS)[number]): string | null => {
|
|
150
|
+
const value = record[field];
|
|
151
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
problems.push(
|
|
155
|
+
`${SCREENSHOTS_CONFIG_FILE} ${field} must be a non-empty string`,
|
|
156
|
+
);
|
|
157
|
+
return null;
|
|
158
|
+
};
|
|
159
|
+
const pairRaw = stringField('pair');
|
|
160
|
+
const pair = pairRaw === null ? null : parseDestination(pairRaw);
|
|
161
|
+
if (pairRaw !== null && pair === null) {
|
|
162
|
+
problems.push(
|
|
163
|
+
`${SCREENSHOTS_CONFIG_FILE} pair must be "<target>:<dotted.key>", e.g. assets:assets.screenshots_rw`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
const bucket = stringField('bucket');
|
|
167
|
+
if (bucket !== null && !isR2BucketName(bucket)) {
|
|
168
|
+
problems.push(
|
|
169
|
+
`${SCREENSHOTS_CONFIG_FILE} bucket "${bucket}" is not a valid bucket name`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
const urlField = (
|
|
173
|
+
field: 'endpoint' | 'publicBaseUrl',
|
|
174
|
+
isValid: (value: string) => boolean,
|
|
175
|
+
): string | null => {
|
|
176
|
+
const value = stringField(field);
|
|
177
|
+
if (value !== null && !isValid(value)) {
|
|
178
|
+
problems.push(
|
|
179
|
+
`${SCREENSHOTS_CONFIG_FILE} ${field} must be a safe http(s) URL`,
|
|
180
|
+
);
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
return value === null ? null : normalizeUrl(value);
|
|
184
|
+
};
|
|
185
|
+
const endpoint = urlField('endpoint', isSafeEndpoint);
|
|
186
|
+
const publicBaseUrl = urlField('publicBaseUrl', isSafePublicBaseUrl);
|
|
187
|
+
return problems.length === 0 &&
|
|
188
|
+
pair !== null &&
|
|
189
|
+
bucket !== null &&
|
|
190
|
+
endpoint !== null &&
|
|
191
|
+
publicBaseUrl !== null
|
|
192
|
+
? { ok: true, value: { pair, bucket, endpoint, publicBaseUrl } }
|
|
193
|
+
: { ok: false, problems };
|
|
194
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Resolves the configured screenshots credential from its SOPS target: the
|
|
2
|
+
// same `<target>:<dotted.key>` pair shape `creds add cloudflare --s3` mints
|
|
3
|
+
// and dev-env references. The decrypted values stay in process memory for
|
|
4
|
+
// the upload and are never printed.
|
|
5
|
+
|
|
6
|
+
import type { CredsDestination } from './creds-dest';
|
|
7
|
+
import { lookupS3Pair } from './creds-r2';
|
|
8
|
+
import { resolveTargetRelResult } from './creds-target';
|
|
9
|
+
import { decryptSopsJson } from './sops-exec';
|
|
10
|
+
|
|
11
|
+
export type ScreenshotsPairResult =
|
|
12
|
+
| {
|
|
13
|
+
readonly ok: true;
|
|
14
|
+
readonly accessKeyId: string;
|
|
15
|
+
readonly secretAccessKey: string;
|
|
16
|
+
}
|
|
17
|
+
| { readonly ok: false; readonly problem: string };
|
|
18
|
+
|
|
19
|
+
export const resolveScreenshotsPair = (
|
|
20
|
+
consumer: string,
|
|
21
|
+
pair: CredsDestination,
|
|
22
|
+
bucket: string,
|
|
23
|
+
): ScreenshotsPairResult => {
|
|
24
|
+
const mintHint = `mint the pair with \`bun standards creds add cloudflare --s3 --dest ${pair.target}:${pair.key} --bucket ${bucket} --permissions "Workers R2 Storage Bucket Item Write"\``;
|
|
25
|
+
const resolved = resolveTargetRelResult(consumer, pair.target);
|
|
26
|
+
if (!resolved.ok) {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
problem:
|
|
30
|
+
resolved.kind === 'missing'
|
|
31
|
+
? `${resolved.problem}; ${mintHint}`
|
|
32
|
+
: resolved.problem,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const document = decryptSopsJson(consumer, resolved.rel);
|
|
36
|
+
if (!document.ok) {
|
|
37
|
+
return { ok: false, problem: document.problem };
|
|
38
|
+
}
|
|
39
|
+
const lookup = lookupS3Pair(document.value, pair.key);
|
|
40
|
+
if (!lookup.ok) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
problem:
|
|
44
|
+
lookup.kind === 'missing-key'
|
|
45
|
+
? `${resolved.rel} has no key "${pair.key}"; ${mintHint}`
|
|
46
|
+
: `${resolved.rel} key "${pair.key}" does not hold a complete S3 pair; both "access_key_id" and "secret_access_key" must be strings`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
accessKeyId: lookup.accessKeyId,
|
|
52
|
+
secretAccessKey: lookup.secretAccessKey,
|
|
53
|
+
};
|
|
54
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// `standards screenshots publish` uploads UI screenshots to the configured
|
|
2
|
+
// public bucket at content-addressed keys and prints the markdown that embeds
|
|
3
|
+
// them in a pull request description. Validation gathers every config and
|
|
4
|
+
// file problem before any upload starts, and the resolved credential pair is
|
|
5
|
+
// used in memory only, never printed.
|
|
6
|
+
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import {
|
|
9
|
+
closeSync,
|
|
10
|
+
constants,
|
|
11
|
+
fstatSync,
|
|
12
|
+
lstatSync,
|
|
13
|
+
openSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
realpathSync,
|
|
16
|
+
} from 'node:fs';
|
|
17
|
+
import { basename, extname, resolve } from 'node:path';
|
|
18
|
+
import {
|
|
19
|
+
loadScreenshotsConfig,
|
|
20
|
+
type ScreenshotsConfig,
|
|
21
|
+
} from './screenshots-config';
|
|
22
|
+
import { resolveScreenshotsPair } from './screenshots-pair';
|
|
23
|
+
import {
|
|
24
|
+
cleanupUploads,
|
|
25
|
+
type PublishedScreenshot,
|
|
26
|
+
uploadIfAbsent,
|
|
27
|
+
} from './screenshots-upload';
|
|
28
|
+
|
|
29
|
+
const CONTENT_TYPES: Readonly<Record<string, string>> = {
|
|
30
|
+
'.gif': 'image/gif',
|
|
31
|
+
'.jpeg': 'image/jpeg',
|
|
32
|
+
'.jpg': 'image/jpeg',
|
|
33
|
+
'.png': 'image/png',
|
|
34
|
+
'.webp': 'image/webp',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// The basename becomes a URL path segment, so it must stay portable as-is:
|
|
38
|
+
// no separators, spaces, or characters that need percent-encoding.
|
|
39
|
+
const SAFE_BASENAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
|
|
40
|
+
|
|
41
|
+
const KEY_PREFIX = 'screenshots';
|
|
42
|
+
|
|
43
|
+
const errorMessage = (error: unknown): string =>
|
|
44
|
+
error instanceof Error ? error.message : String(error);
|
|
45
|
+
|
|
46
|
+
const readImageFile = (file: string): Uint8Array => {
|
|
47
|
+
const path = resolve(file);
|
|
48
|
+
const link = lstatSync(path);
|
|
49
|
+
if (link.isSymbolicLink() || !link.isFile() || realpathSync(path) !== path) {
|
|
50
|
+
throw new Error('not a regular file');
|
|
51
|
+
}
|
|
52
|
+
let descriptor: number | null = null;
|
|
53
|
+
try {
|
|
54
|
+
descriptor = openSync(path, constants.O_RDONLY + constants.O_NOFOLLOW);
|
|
55
|
+
if (!fstatSync(descriptor).isFile()) {
|
|
56
|
+
throw new Error('not a regular file');
|
|
57
|
+
}
|
|
58
|
+
return readFileSync(descriptor);
|
|
59
|
+
} finally {
|
|
60
|
+
if (descriptor !== null) {
|
|
61
|
+
closeSync(descriptor);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const fileProblems = (file: string): ReadonlyArray<string> => {
|
|
67
|
+
const name = basename(file);
|
|
68
|
+
const problems: Array<string> = [];
|
|
69
|
+
let size: number | null = null;
|
|
70
|
+
try {
|
|
71
|
+
size = readImageFile(file).byteLength;
|
|
72
|
+
} catch {
|
|
73
|
+
size = null;
|
|
74
|
+
}
|
|
75
|
+
if (size === null) {
|
|
76
|
+
problems.push(`${file} is not a readable file`);
|
|
77
|
+
} else if (size === 0) {
|
|
78
|
+
problems.push(`${file} is empty`);
|
|
79
|
+
}
|
|
80
|
+
if (CONTENT_TYPES[extname(name).toLowerCase()] === undefined) {
|
|
81
|
+
problems.push(
|
|
82
|
+
`${file} has an unsupported extension; supported: ${Object.keys(CONTENT_TYPES).join(', ')}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (!SAFE_BASENAME.test(name)) {
|
|
86
|
+
problems.push(
|
|
87
|
+
`${file} has a name unsafe for URLs; rename it to letters, digits, ".", "_", and "-"`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return problems;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const publishOne = async (
|
|
94
|
+
client: Bun.S3Client,
|
|
95
|
+
config: ScreenshotsConfig,
|
|
96
|
+
file: string,
|
|
97
|
+
): Promise<PublishedScreenshot> => {
|
|
98
|
+
const bytes = readImageFile(file);
|
|
99
|
+
const digest = createHash('sha256').update(bytes).digest('hex');
|
|
100
|
+
const name = basename(file);
|
|
101
|
+
const key = `${KEY_PREFIX}/${digest}/${name}`;
|
|
102
|
+
const contentType = CONTENT_TYPES[extname(name).toLowerCase()];
|
|
103
|
+
const created = await uploadIfAbsent(client, key, bytes, contentType);
|
|
104
|
+
const alt = name.slice(0, name.length - extname(name).length);
|
|
105
|
+
const objectUrl = new URL(key, `${config.publicBaseUrl}/`).href;
|
|
106
|
+
return { key, line: ``, created };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const runScreenshotsPublish = async (
|
|
110
|
+
dir: string,
|
|
111
|
+
files: ReadonlyArray<string>,
|
|
112
|
+
): Promise<boolean> => {
|
|
113
|
+
if (files.length === 0) {
|
|
114
|
+
console.error(
|
|
115
|
+
'standards screenshots: publish requires at least one image file',
|
|
116
|
+
);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
const config = loadScreenshotsConfig(dir);
|
|
120
|
+
const problems = [
|
|
121
|
+
...(config.ok ? [] : config.problems),
|
|
122
|
+
...files.flatMap(fileProblems),
|
|
123
|
+
];
|
|
124
|
+
if (problems.length > 0 || !config.ok) {
|
|
125
|
+
console.error(
|
|
126
|
+
`standards screenshots: ${problems.length} problem(s):\n${problems.map((problem) => ` - ${problem}`).join('\n')}`,
|
|
127
|
+
);
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
const pair = resolveScreenshotsPair(
|
|
131
|
+
dir,
|
|
132
|
+
config.value.pair,
|
|
133
|
+
config.value.bucket,
|
|
134
|
+
);
|
|
135
|
+
if (!pair.ok) {
|
|
136
|
+
console.error(`standards screenshots: ${pair.problem}`);
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
const client = new globalThis.Bun.S3Client({
|
|
140
|
+
accessKeyId: pair.accessKeyId,
|
|
141
|
+
secretAccessKey: pair.secretAccessKey,
|
|
142
|
+
bucket: config.value.bucket,
|
|
143
|
+
endpoint: config.value.endpoint,
|
|
144
|
+
});
|
|
145
|
+
try {
|
|
146
|
+
const results = await Promise.allSettled(
|
|
147
|
+
files.map((file) => publishOne(client, config.value, file)),
|
|
148
|
+
);
|
|
149
|
+
const failures = results.flatMap((result) =>
|
|
150
|
+
result.status === 'rejected' ? [result.reason] : [],
|
|
151
|
+
);
|
|
152
|
+
if (failures.length > 0) {
|
|
153
|
+
const cleanupFailures = await cleanupUploads(client, results);
|
|
154
|
+
const [failure] = failures;
|
|
155
|
+
const detail = errorMessage(failure);
|
|
156
|
+
const cleanupDetail =
|
|
157
|
+
cleanupFailures.length > 0
|
|
158
|
+
? `; cleanup failed: ${cleanupFailures.map(errorMessage).join('; ')}`
|
|
159
|
+
: '';
|
|
160
|
+
console.error(
|
|
161
|
+
`standards screenshots: upload failed: ${detail}${cleanupDetail}`,
|
|
162
|
+
);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
for (const result of results) {
|
|
166
|
+
if (result.status === 'fulfilled') {
|
|
167
|
+
console.log(result.value.line);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return true;
|
|
171
|
+
} catch (error) {
|
|
172
|
+
console.error(
|
|
173
|
+
`standards screenshots: upload failed: ${errorMessage(error)}`,
|
|
174
|
+
);
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// S3 upload helpers keep content-addressed writes atomic and clean up only
|
|
2
|
+
// objects this invocation successfully created.
|
|
3
|
+
|
|
4
|
+
const HTTP_PRECONDITION_FAILED = 412;
|
|
5
|
+
|
|
6
|
+
export type PublishedScreenshot = {
|
|
7
|
+
readonly key: string;
|
|
8
|
+
readonly line: string;
|
|
9
|
+
readonly created: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const uploadIfAbsent = async (
|
|
13
|
+
client: Bun.S3Client,
|
|
14
|
+
key: string,
|
|
15
|
+
bytes: Uint8Array,
|
|
16
|
+
contentType: string,
|
|
17
|
+
): Promise<boolean> => {
|
|
18
|
+
const body = new ArrayBuffer(bytes.byteLength);
|
|
19
|
+
new Uint8Array(body).set(bytes);
|
|
20
|
+
let response: Response;
|
|
21
|
+
try {
|
|
22
|
+
response = await fetch(client.presign(key, { method: 'PUT' }), {
|
|
23
|
+
method: 'PUT',
|
|
24
|
+
headers: {
|
|
25
|
+
'content-type': contentType,
|
|
26
|
+
'if-none-match': '*',
|
|
27
|
+
},
|
|
28
|
+
body,
|
|
29
|
+
});
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new Error('S3 upload request failed', { cause: error });
|
|
32
|
+
}
|
|
33
|
+
if (response.status === HTTP_PRECONDITION_FAILED) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
throw new Error(`S3 upload failed with HTTP ${response.status}`);
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const cleanupUploads = async (
|
|
43
|
+
client: Bun.S3Client,
|
|
44
|
+
results: ReadonlyArray<PromiseSettledResult<PublishedScreenshot>>,
|
|
45
|
+
): Promise<ReadonlyArray<unknown>> => {
|
|
46
|
+
const keys = new Set(
|
|
47
|
+
results.flatMap((result) =>
|
|
48
|
+
result.status === 'fulfilled' && result.value.created
|
|
49
|
+
? [result.value.key]
|
|
50
|
+
: [],
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
const cleanupResults = await Promise.allSettled(
|
|
54
|
+
[...keys].map((key) => client.delete(key)),
|
|
55
|
+
);
|
|
56
|
+
return cleanupResults.flatMap((result) =>
|
|
57
|
+
result.status === 'rejected' ? [result.reason] : [],
|
|
58
|
+
);
|
|
59
|
+
};
|
package/src/sops-exec.ts
CHANGED
|
@@ -12,6 +12,34 @@ export type SopsRunResult = {
|
|
|
12
12
|
readonly errorMessage: string | null;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
export type SopsJsonResult =
|
|
16
|
+
| { readonly ok: true; readonly value: unknown }
|
|
17
|
+
| { readonly ok: false; readonly problem: string };
|
|
18
|
+
|
|
19
|
+
// sops emits JSON here so callers never parse encrypted YAML themselves.
|
|
20
|
+
export const decryptSopsJson = (cwd: string, rel: string): SopsJsonResult => {
|
|
21
|
+
const result = runSops(['--decrypt', '--output-type', 'json', rel], cwd);
|
|
22
|
+
if (result.status !== 0) {
|
|
23
|
+
const detail = result.errorMessage ?? result.stderr.trim();
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
problem: detail
|
|
27
|
+
? `could not decrypt ${rel}: ${detail}`
|
|
28
|
+
: `could not decrypt ${rel}`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
return { ok: true, value: JSON.parse(result.stdout) as unknown };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
problem: `could not parse decrypted ${rel} as JSON: ${
|
|
37
|
+
error instanceof Error ? error.message : String(error)
|
|
38
|
+
}`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
15
43
|
export const runSops = (
|
|
16
44
|
args: ReadonlyArray<string>,
|
|
17
45
|
cwd: string,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const BUN_PACKAGE_MANAGER =
|
|
2
|
+
/^bun@(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u;
|
|
3
|
+
const PACKAGE_MANAGER_PROBLEM =
|
|
4
|
+
'package.json: "packageManager" must pin an exact bun@x.y.z version';
|
|
5
|
+
|
|
6
|
+
export const collectBunVersionProblems = (
|
|
7
|
+
manifest: Record<string, unknown>,
|
|
8
|
+
): ReadonlyArray<string> =>
|
|
9
|
+
typeof manifest.packageManager === 'string' &&
|
|
10
|
+
BUN_PACKAGE_MANAGER.test(manifest.packageManager)
|
|
11
|
+
? []
|
|
12
|
+
: [PACKAGE_MANAGER_PROBLEM];
|
package/src/structure-check.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readdir } from 'node:fs/promises';
|
|
|
3
3
|
import { isAbsolute, join } from 'node:path';
|
|
4
4
|
import { isRecord } from './github-settings-parse';
|
|
5
5
|
import { readJsonFile } from './json-file';
|
|
6
|
+
import { collectBunVersionProblems } from './structure-bun-version';
|
|
6
7
|
import {
|
|
7
8
|
missingPublishedCliProblems,
|
|
8
9
|
rootScriptExpectations,
|
|
@@ -189,6 +190,7 @@ export const collectStructureProblems = async (
|
|
|
189
190
|
...resolved.flatMap((r) => (r.problem === null ? [] : [r.problem])),
|
|
190
191
|
...loaded.flatMap((l) => (l.problem === null ? [] : [l.problem])),
|
|
191
192
|
...missingPublishedCliProblems(profile, workspaces),
|
|
193
|
+
...collectBunVersionProblems(root),
|
|
192
194
|
...inspectRootScripts(root, profile, requireA11y),
|
|
193
195
|
...inspections.flatMap((i) => [...i.problems]),
|
|
194
196
|
...readmeProblems,
|
package/src/structure-profile.ts
CHANGED
|
@@ -8,9 +8,10 @@ import { isRecord } from './github-settings-parse';
|
|
|
8
8
|
// drifting silently while the normal quality gate stays green.
|
|
9
9
|
export type StructureProfile = 'consumer' | 'source';
|
|
10
10
|
|
|
11
|
-
const CONSUMER_CHECK =
|
|
11
|
+
const CONSUMER_CHECK =
|
|
12
|
+
'turbo run lint check-types test build test:a11y --output-logs=errors-only';
|
|
12
13
|
const CONSUMER_CHECK_FIX =
|
|
13
|
-
'turbo run lint:fix check-types test build test:a11y';
|
|
14
|
+
'turbo run lint:fix check-types test build test:a11y --output-logs=errors-only';
|
|
14
15
|
const ROOT_A11Y = 'turbo run test:a11y';
|
|
15
16
|
|
|
16
17
|
export const PUBLISHED_CLI_WORKSPACE = 'packages/standards-cli';
|
|
@@ -32,7 +33,7 @@ const SOURCE_ROOT_EXPECTATIONS: ReadonlyArray<RootScriptExpectation> = [
|
|
|
32
33
|
`${SOURCE_CLI} structure --profile source`,
|
|
33
34
|
`${SOURCE_CLI} dependabot --check`,
|
|
34
35
|
`${SOURCE_CLI} github --check`,
|
|
35
|
-
'turbo run lint check-types test',
|
|
36
|
+
'turbo run lint check-types test --output-logs=errors-only',
|
|
36
37
|
],
|
|
37
38
|
exact: true,
|
|
38
39
|
},
|
|
@@ -42,7 +43,7 @@ const SOURCE_ROOT_EXPECTATIONS: ReadonlyArray<RootScriptExpectation> = [
|
|
|
42
43
|
`${SOURCE_CLI} structure --profile source`,
|
|
43
44
|
`${SOURCE_CLI} dependabot --write`,
|
|
44
45
|
`${SOURCE_CLI} github --check`,
|
|
45
|
-
'turbo run lint:fix check-types test',
|
|
46
|
+
'turbo run lint:fix check-types test --output-logs=errors-only',
|
|
46
47
|
],
|
|
47
48
|
exact: true,
|
|
48
49
|
},
|