@davidvornholt/standards 0.26.3 → 0.27.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 +36 -1
- package/package.json +7 -2
- package/src/cli.ts +154 -17
- package/src/creds-add-github.ts +12 -4
- package/src/creds-add.ts +14 -1
- package/src/creds-commands.ts +7 -1
- package/src/creds-github-apps.ts +71 -2
- package/src/creds-login-cloudflare.ts +6 -6
- package/src/creds-login-github.ts +6 -2
- package/src/creds-naming.ts +4 -1
- package/src/creds-plan-renew.ts +14 -2
- package/src/creds-plan-report.ts +1 -1
- package/src/creds-plan-unmanaged.ts +4 -5
- package/src/creds-plan.ts +1 -1
- package/src/creds-revoke-duplicate.ts +127 -0
- package/src/creds-revoke.ts +36 -15
- package/src/creds-store-lock-inspection.ts +26 -21
- package/src/creds-store-lock.ts +14 -1
- package/src/dependabot-directory-overlap.ts +100 -8
- package/src/dependabot-update.ts +11 -0
- package/src/dev-env-brokered-allowlist.ts +2 -2
- package/src/dev-env-brokered-preflight.ts +69 -0
- package/src/dev-env-brokered-preserve.ts +2 -1
- package/src/dev-env-brokered-refresh.ts +1 -1
- package/src/dev-env-brokered-resolve.ts +33 -22
- package/src/dev-env-brokered-source.ts +128 -0
- package/src/dev-env-brokered.ts +15 -2
- package/src/dev-env.ts +22 -0
- package/src/github-api.ts +5 -2
- package/src/github-apply.ts +26 -2
- package/src/github-bypass-actors.ts +2 -2
- package/src/sops-action-callers.ts +133 -0
- package/src/source-text.ts +98 -0
- package/src/structure-check.ts +5 -1
- package/src/structure-profile.ts +2 -0
package/README.md
CHANGED
|
@@ -148,6 +148,31 @@ brokeredReferences:
|
|
|
148
148
|
|
|
149
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
150
|
|
|
151
|
+
A pair owned by another repository uses an explicit source descriptor. The checkout path is resolved relative to the consumer root; put machine-specific paths in ignored `config/dev.local.yaml`.
|
|
152
|
+
|
|
153
|
+
```yaml
|
|
154
|
+
apps:
|
|
155
|
+
web:
|
|
156
|
+
S3_ACCESS_KEY_ID:
|
|
157
|
+
brokeredS3: garage
|
|
158
|
+
key: garage.development
|
|
159
|
+
part: access_key_id
|
|
160
|
+
source:
|
|
161
|
+
repository: owner/source
|
|
162
|
+
checkout: ../source
|
|
163
|
+
S3_SECRET_ACCESS_KEY:
|
|
164
|
+
brokeredS3: garage
|
|
165
|
+
key: garage.development
|
|
166
|
+
part: secret_access_key
|
|
167
|
+
source:
|
|
168
|
+
repository: owner/source
|
|
169
|
+
checkout: ../source
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The consumer's encrypted allowlist must authorize the exact repository, target, and key as `owner/source@garage:garage.development`. Local target authorization does not authorize an external source. The source must be a Git checkout root whose `origin` is that GitHub repository, with no symlinked checkout or target path. Targets remain contained regular SOPS files under the source repository. One source repository must resolve to one checkout per generation. This verifies a maintainer-managed checkout's identity; it does not attest untrusted local Git configuration.
|
|
173
|
+
|
|
174
|
+
Run `bun standards dev-env` in the consumer after updating the source checkout or rotating its pair. Both values are read from the one encrypted owner and only written to ignored generated environments. Rotation in a separate source repository cannot discover or update other workstations automatically. All selected source references and configuration are validated before any source target is decrypted; a failed generation leaves previous environments intact.
|
|
175
|
+
|
|
151
176
|
## github
|
|
152
177
|
|
|
153
178
|
```sh
|
|
@@ -205,7 +230,13 @@ To delete one Cloudflare token outside normal reconciliation:
|
|
|
205
230
|
bun standards creds revoke --account <account-id> --token-id <token-id>
|
|
206
231
|
```
|
|
207
232
|
|
|
208
|
-
|
|
233
|
+
For same-name brokered duplicates left by interrupted renewal, keep the stored key intact and select the unused token ID. `revoke` proves which credential is stored, refuses that token, and rechecks the destination before deleting the selected duplicate. Unreadable or unidentifiable stored credentials leave every token intact. A destination already missing its key can also be recovered after successful decryption.
|
|
234
|
+
|
|
235
|
+
Bootstrap credentials and singular tokens still owned by an active repository are refused.
|
|
236
|
+
|
|
237
|
+
### Prepare existing consumers for GitHub App client IDs
|
|
238
|
+
|
|
239
|
+
`standards creds add github --dest ci:ci.broker_app` writes and verifies `app_id`, `client_id`, and `private_key` together. Run it in each existing consumer, mirror the new `client_id` key with a placeholder in its `secrets/ci.example.yaml`, and commit both files before adopting a workflow that resolves `broker_app.client_id` and passes `client-id` to `actions/create-github-app-token@v3`. The numeric `app_id` remains available while existing workflows are migrated. Do not remove it until every local and canonical caller has switched. Canonical workflows continue using `app-id` until consumer provisioning is complete.
|
|
209
240
|
|
|
210
241
|
## screenshots
|
|
211
242
|
|
|
@@ -265,3 +296,7 @@ The worker accepts only exact issue or draft-PR revisions approved by a user wit
|
|
|
265
296
|
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.
|
|
266
297
|
|
|
267
298
|
The poller timers use monotonic intervals: after boot, acknowledgement starts after one minute and work starts after two minutes. Missed downtime intervals are not replayed; each tick reads the current queue. The service budget reserves thirty minutes for scanning and setup in addition to its configured agent budget, with at most twelve watched repositories per poller. Split larger lists across pollers with independent API budgets; unusually slow provider or clone operations can exhaust that overhead and require investigating the service timeout before retrying.
|
|
299
|
+
|
|
300
|
+
## Source text validation
|
|
301
|
+
|
|
302
|
+
`bun standards source-text` checks tracked source, configuration, and documentation files as UTF-8 text, regardless of Git’s binary classification. `bun standards check` includes this gate. Raw Unicode control characters are rejected except tab and LF; write control characters in fixtures and composite keys using escaped representations such as `\0` or `\u0000`. Binary asset formats and untracked files are outside this gate. Missing tracked source files and source symlinks fail validation.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davidvornholt/standards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"src/creds-prompt.ts",
|
|
57
57
|
"src/creds-r2.ts",
|
|
58
58
|
"src/creds-revoke.ts",
|
|
59
|
+
"src/creds-revoke-duplicate.ts",
|
|
59
60
|
"src/creds-sops.ts",
|
|
60
61
|
"src/creds-sops-editor.ts",
|
|
61
62
|
"src/creds-sops-structure.ts",
|
|
@@ -74,8 +75,10 @@
|
|
|
74
75
|
"src/dependabot-directory-overlap.ts",
|
|
75
76
|
"src/dependabot-inspect.ts",
|
|
76
77
|
"src/dependabot-update.ts",
|
|
78
|
+
"src/dev-env-brokered-source.ts",
|
|
77
79
|
"src/dev-env-brokered.ts",
|
|
78
80
|
"src/dev-env-brokered-allowlist.ts",
|
|
81
|
+
"src/dev-env-brokered-preflight.ts",
|
|
79
82
|
"src/dev-env-brokered-preserve.ts",
|
|
80
83
|
"src/dev-env-brokered-resolve.ts",
|
|
81
84
|
"src/dev-env-brokered-refresh.ts",
|
|
@@ -162,6 +165,7 @@
|
|
|
162
165
|
"src/screenshots-publish.ts",
|
|
163
166
|
"src/screenshots-upload.ts",
|
|
164
167
|
"src/sops-exec.ts",
|
|
168
|
+
"src/source-text.ts",
|
|
165
169
|
"src/structure-check.ts",
|
|
166
170
|
"src/structure-bun-version.ts",
|
|
167
171
|
"src/structure-profile.ts",
|
|
@@ -172,7 +176,8 @@
|
|
|
172
176
|
"src/structure-workspace.ts",
|
|
173
177
|
"src/sync-policy.ts",
|
|
174
178
|
"src/yaml-emit.ts",
|
|
175
|
-
"src/yaml-parse.ts"
|
|
179
|
+
"src/yaml-parse.ts",
|
|
180
|
+
"src/sops-action-callers.ts"
|
|
176
181
|
],
|
|
177
182
|
"scripts": {
|
|
178
183
|
"lint": "biome check --error-on-warnings .",
|
package/src/cli.ts
CHANGED
|
@@ -44,6 +44,8 @@ import {
|
|
|
44
44
|
} from './managed-path-ownership';
|
|
45
45
|
import { runPollerCommand } from './poller-commands';
|
|
46
46
|
import { runScreenshotsCommand } from './screenshots-commands';
|
|
47
|
+
import { collectSopsActionCallerProblems } from './sops-action-callers';
|
|
48
|
+
import { collectSourceTextProblems } from './source-text';
|
|
47
49
|
import { collectStructureProblems } from './structure-check';
|
|
48
50
|
import type { StructureProfile } from './structure-profile';
|
|
49
51
|
import { hasSafeCommand } from './structure-script';
|
|
@@ -92,6 +94,7 @@ type Command =
|
|
|
92
94
|
| 'help'
|
|
93
95
|
| 'init'
|
|
94
96
|
| 'poller'
|
|
97
|
+
| 'source-text'
|
|
95
98
|
| 'structure'
|
|
96
99
|
| 'sync';
|
|
97
100
|
|
|
@@ -153,13 +156,90 @@ const symlinkTargetProblem = (rel: string, target: string): string | null => {
|
|
|
153
156
|
return null;
|
|
154
157
|
};
|
|
155
158
|
|
|
159
|
+
// Resolve components in filesystem order: missing/../real is dangling, and a
|
|
160
|
+
// symlink component must be followed before a subsequent .. is interpreted.
|
|
161
|
+
const MAX_SYMLINK_TRAVERSALS = 40;
|
|
162
|
+
type ManagedTargetTraversal = {
|
|
163
|
+
readonly pending: Array<string>;
|
|
164
|
+
readonly resolved: Array<string>;
|
|
165
|
+
traversals: number;
|
|
166
|
+
};
|
|
167
|
+
const resolveManagedComponent = (
|
|
168
|
+
component: string,
|
|
169
|
+
state: ManagedTargetTraversal,
|
|
170
|
+
entries: ReadonlyMap<string, ManagedEntry>,
|
|
171
|
+
): boolean => {
|
|
172
|
+
if (component === '' || component === '.') {
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
if (component === '..') {
|
|
176
|
+
return state.resolved.pop() !== undefined;
|
|
177
|
+
}
|
|
178
|
+
const path = [...state.resolved, component].join('/');
|
|
179
|
+
const entry = entries.get(path);
|
|
180
|
+
if (entry?.kind === 'symlink') {
|
|
181
|
+
state.traversals += 1;
|
|
182
|
+
if (
|
|
183
|
+
state.traversals > MAX_SYMLINK_TRAVERSALS ||
|
|
184
|
+
symlinkTargetProblem(path, entry.target) !== null
|
|
185
|
+
) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
state.pending.unshift(...entry.target.split('/'));
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
const directory = [...entries.keys()].some((key) =>
|
|
192
|
+
key.startsWith(`${path}/`),
|
|
193
|
+
);
|
|
194
|
+
if (
|
|
195
|
+
!(directory || entry?.kind === 'file') ||
|
|
196
|
+
(state.pending.length > 0 && !directory)
|
|
197
|
+
) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
state.resolved.push(component);
|
|
201
|
+
return true;
|
|
202
|
+
};
|
|
203
|
+
const managedTargetExists = (
|
|
204
|
+
target: string,
|
|
205
|
+
entries: ReadonlyMap<string, ManagedEntry>,
|
|
206
|
+
): boolean => {
|
|
207
|
+
const state: ManagedTargetTraversal = {
|
|
208
|
+
pending: target.split('/'),
|
|
209
|
+
resolved: [],
|
|
210
|
+
// The original managed symlink also consumes one kernel traversal.
|
|
211
|
+
traversals: 1,
|
|
212
|
+
};
|
|
213
|
+
while (state.pending.length > 0) {
|
|
214
|
+
const component = state.pending.shift();
|
|
215
|
+
if (
|
|
216
|
+
component !== undefined &&
|
|
217
|
+
!resolveManagedComponent(component, state, entries)
|
|
218
|
+
) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return true;
|
|
223
|
+
};
|
|
224
|
+
|
|
156
225
|
const symlinkTargetProblems = (
|
|
157
226
|
entries: ReadonlyMap<string, ManagedEntry>,
|
|
227
|
+
requireManagedTarget = false,
|
|
158
228
|
): ReadonlyArray<string> =>
|
|
159
229
|
[...entries]
|
|
160
|
-
.map(([rel, entry]) =>
|
|
161
|
-
entry.kind
|
|
162
|
-
|
|
230
|
+
.map(([rel, entry]) => {
|
|
231
|
+
if (entry.kind !== 'symlink') {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
const problem = symlinkTargetProblem(rel, entry.target);
|
|
235
|
+
if (problem !== null) {
|
|
236
|
+
return problem;
|
|
237
|
+
}
|
|
238
|
+
const target = `${posix.dirname(rel)}/${entry.target}`;
|
|
239
|
+
return requireManagedTarget && !managedTargetExists(target, entries)
|
|
240
|
+
? `canonical symlink ${rel} targets ${target}, which is missing from the managed payload or forms a symlink cycle`
|
|
241
|
+
: null;
|
|
242
|
+
})
|
|
163
243
|
.filter((problem): problem is string => problem !== null);
|
|
164
244
|
|
|
165
245
|
const parseManifest = (raw: unknown): Manifest => {
|
|
@@ -332,6 +412,10 @@ const mirrorPreconditionProblems = async (
|
|
|
332
412
|
const locked = new Set(Object.keys(previous));
|
|
333
413
|
const collisions = await Promise.all(
|
|
334
414
|
[...upstream.keys()].map(async (rel) => {
|
|
415
|
+
const ancestor = await interposedAncestor(consumer, rel, upstream);
|
|
416
|
+
if (ancestor !== null) {
|
|
417
|
+
return `${rel} is below the ${ancestor.kind} ${ancestor.rel}; refusing to write through an existing file or symlink ancestor; move it aside before syncing this payload`;
|
|
418
|
+
}
|
|
335
419
|
const current = await inspectManagedPath(join(consumer, rel));
|
|
336
420
|
if (current?.kind !== 'directory') {
|
|
337
421
|
return null;
|
|
@@ -343,7 +427,8 @@ const mirrorPreconditionProblems = async (
|
|
|
343
427
|
}),
|
|
344
428
|
);
|
|
345
429
|
return [
|
|
346
|
-
...symlinkTargetProblems(upstream),
|
|
430
|
+
...symlinkTargetProblems(upstream, true),
|
|
431
|
+
...(await collectSopsActionCallerProblems(consumer, upstream)),
|
|
347
432
|
...collisions.filter((problem): problem is string => problem !== null),
|
|
348
433
|
];
|
|
349
434
|
};
|
|
@@ -362,16 +447,16 @@ const assertNoProblems = (
|
|
|
362
447
|
};
|
|
363
448
|
|
|
364
449
|
type PruneOutcome =
|
|
365
|
-
| { readonly kind: '
|
|
450
|
+
| { readonly kind: 'replaced'; readonly rel: string }
|
|
366
451
|
| { readonly kind: 'delete'; readonly rel: string }
|
|
367
452
|
| { readonly kind: 'retain'; readonly note: string }
|
|
368
453
|
| { readonly kind: 'stale'; readonly note: string };
|
|
369
454
|
|
|
370
455
|
// Two things the prune pass must never do: resolve through a symlink it (or an
|
|
371
456
|
// older CLI's lock) put in the parent chain, and recursively delete a
|
|
372
|
-
// destination the consumer turned into a directory.
|
|
373
|
-
//
|
|
374
|
-
//
|
|
457
|
+
// destination the consumer turned into a directory. Ancestor inspection uses
|
|
458
|
+
// planned canonical replacements so dry-run and application classify the same
|
|
459
|
+
// stale lock entries.
|
|
375
460
|
const pruneOutcome = async (
|
|
376
461
|
consumer: string,
|
|
377
462
|
rel: string,
|
|
@@ -388,13 +473,21 @@ const pruneOutcome = async (
|
|
|
388
473
|
// whether or not a directory still stands there while a dry run looks.
|
|
389
474
|
const current =
|
|
390
475
|
ancestor === null ? await inspectManagedPath(join(consumer, rel)) : null;
|
|
476
|
+
if (ancestor?.kind === 'file' && planned.has(ancestor.rel)) {
|
|
477
|
+
return { kind: 'replaced', rel };
|
|
478
|
+
}
|
|
391
479
|
if (current === null) {
|
|
392
|
-
return {
|
|
480
|
+
return {
|
|
481
|
+
kind: 'stale',
|
|
482
|
+
note: `${rel} (already absent; nothing was deleted)`,
|
|
483
|
+
};
|
|
393
484
|
}
|
|
394
485
|
if (current.kind === 'directory') {
|
|
395
486
|
return {
|
|
396
487
|
kind: 'retain',
|
|
397
|
-
note: `${rel}
|
|
488
|
+
note: [...planned.keys()].some((path) => path.startsWith(`${rel}/`))
|
|
489
|
+
? `${rel}: removed upstream as a file, but retained as a directory containing canonical paths; keep those managed descendants`
|
|
490
|
+
: `${rel}: removed upstream but now a directory this repository does not manage; delete it yourself if you no longer want it`,
|
|
398
491
|
};
|
|
399
492
|
}
|
|
400
493
|
return { kind: 'delete', rel };
|
|
@@ -432,7 +525,16 @@ const prune = async (
|
|
|
432
525
|
deleted.map((rel) => rm(join(consumer, rel), { force: true })),
|
|
433
526
|
);
|
|
434
527
|
}
|
|
435
|
-
return {
|
|
528
|
+
return {
|
|
529
|
+
deleted: [
|
|
530
|
+
...deleted,
|
|
531
|
+
...outcomes.flatMap((outcome) =>
|
|
532
|
+
outcome.kind === 'replaced' ? [outcome.rel] : [],
|
|
533
|
+
),
|
|
534
|
+
],
|
|
535
|
+
retained,
|
|
536
|
+
stale,
|
|
537
|
+
};
|
|
436
538
|
};
|
|
437
539
|
|
|
438
540
|
// Mirror managed files into the consumer, deleting any previously-locked file
|
|
@@ -475,9 +577,8 @@ const mirror = async ({
|
|
|
475
577
|
}
|
|
476
578
|
if (!dryRun) {
|
|
477
579
|
await mkdir(dirname(dest), { recursive: true });
|
|
478
|
-
// The precondition
|
|
479
|
-
//
|
|
480
|
-
// rather than destroying consumer work.
|
|
580
|
+
// The precondition proved every non-directory entry is locked. Empty
|
|
581
|
+
// directories are not tracked and are removed with the old shape.
|
|
481
582
|
if (current?.kind === 'directory') {
|
|
482
583
|
await rm(dest, { recursive: true, force: true });
|
|
483
584
|
}
|
|
@@ -667,7 +768,7 @@ const runSync = async (
|
|
|
667
768
|
|
|
668
769
|
// Offline drift detection: every locked file must still match its hash. Catches
|
|
669
770
|
// local edits or deletions of canonical files. Does NOT detect upstream moving
|
|
670
|
-
// on
|
|
771
|
+
// on. Mirror preconditions reject those ancestor conflicts before any writes.
|
|
671
772
|
const runCheck = async (consumer: string): Promise<boolean> => {
|
|
672
773
|
const lock = await readLock(consumer);
|
|
673
774
|
if (lock === null || Object.keys(lock.files).length === 0) {
|
|
@@ -694,7 +795,10 @@ const runCheck = async (consumer: string): Promise<boolean> => {
|
|
|
694
795
|
return null;
|
|
695
796
|
}),
|
|
696
797
|
);
|
|
697
|
-
const problems =
|
|
798
|
+
const problems = [
|
|
799
|
+
...results.filter((p): p is string => p !== null),
|
|
800
|
+
...symlinkTargetProblems(lockedFiles, true),
|
|
801
|
+
];
|
|
698
802
|
const directiveFiles =
|
|
699
803
|
await findManagedFilesContainingBiomeDirectiveToken(lockedFiles);
|
|
700
804
|
if (problems.length > 0) {
|
|
@@ -820,6 +924,15 @@ const prepareProspectiveDependabot = async (
|
|
|
820
924
|
const existingLocal = await readTextIfPresent(
|
|
821
925
|
join(consumer, DEPENDABOT_LOCAL_FILE),
|
|
822
926
|
);
|
|
927
|
+
if (
|
|
928
|
+
existingLocal === null &&
|
|
929
|
+
localSeed !== null &&
|
|
930
|
+
localSeed.kind !== 'file'
|
|
931
|
+
) {
|
|
932
|
+
throw new Error(
|
|
933
|
+
`source seed ${DEPENDABOT_LOCAL_FILE} must be a regular file; replace its symlink with the overlay content`,
|
|
934
|
+
);
|
|
935
|
+
}
|
|
823
936
|
const local =
|
|
824
937
|
existingLocal ??
|
|
825
938
|
(localSeed === null || localSeed.kind !== 'file'
|
|
@@ -969,6 +1082,7 @@ Commands:
|
|
|
969
1082
|
sync Mirror canonical files from upstream, regenerate the composed Dependabot config, and rewrite the lock
|
|
970
1083
|
check Verify canonical files, extension seams, monorepo structure, and GitHub settings
|
|
971
1084
|
doctor Validate extension seams only
|
|
1085
|
+
source-text Reject raw control characters in tracked source text
|
|
972
1086
|
structure Validate monorepo structure rules only
|
|
973
1087
|
dependabot Verify (--check) or regenerate (--write) the composed .github/dependabot.yml
|
|
974
1088
|
dev-env Compose each workspace's generated .env.local from its three dev layers and authorized broker-owned S3 pair references
|
|
@@ -1002,6 +1116,7 @@ const commandFromArg = (arg: string): Command => {
|
|
|
1002
1116
|
arg === 'help' ||
|
|
1003
1117
|
arg === 'init' ||
|
|
1004
1118
|
arg === 'poller' ||
|
|
1119
|
+
arg === 'source-text' ||
|
|
1005
1120
|
arg === 'structure' ||
|
|
1006
1121
|
arg === 'sync'
|
|
1007
1122
|
) {
|
|
@@ -1209,10 +1324,24 @@ const runStructure = async (
|
|
|
1209
1324
|
return true;
|
|
1210
1325
|
};
|
|
1211
1326
|
|
|
1327
|
+
const runSourceTextCheck = (consumer: string): boolean => {
|
|
1328
|
+
const problems = collectSourceTextProblems(consumer);
|
|
1329
|
+
if (problems.length > 0) {
|
|
1330
|
+
console.error(`standards source-text: ${problems.length} problem(s):`);
|
|
1331
|
+
console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
|
|
1332
|
+
return false;
|
|
1333
|
+
}
|
|
1334
|
+
console.log(
|
|
1335
|
+
'standards source-text: tracked source text contains no forbidden control characters',
|
|
1336
|
+
);
|
|
1337
|
+
return true;
|
|
1338
|
+
};
|
|
1339
|
+
|
|
1212
1340
|
const runCheckCommand = async (consumer: string): Promise<boolean> => {
|
|
1213
1341
|
const driftIsClean = await runCheck(consumer);
|
|
1214
1342
|
const integrationIsValid = await runDoctor(consumer);
|
|
1215
1343
|
const structureIsValid = await runStructure(consumer, 'consumer');
|
|
1344
|
+
const sourceTextIsValid = runSourceTextCheck(consumer);
|
|
1216
1345
|
// The GitHub gate activates with the synced declaration and then fails
|
|
1217
1346
|
// closed: once .github/settings.json exists, an unreachable API or an
|
|
1218
1347
|
// unreadable origin is a failure, not a skip.
|
|
@@ -1220,7 +1349,11 @@ const runCheckCommand = async (consumer: string): Promise<boolean> => {
|
|
|
1220
1349
|
? await runGithubCheckGate(consumer)
|
|
1221
1350
|
: true;
|
|
1222
1351
|
return (
|
|
1223
|
-
driftIsClean &&
|
|
1352
|
+
driftIsClean &&
|
|
1353
|
+
integrationIsValid &&
|
|
1354
|
+
structureIsValid &&
|
|
1355
|
+
sourceTextIsValid &&
|
|
1356
|
+
githubIsConverged
|
|
1224
1357
|
);
|
|
1225
1358
|
};
|
|
1226
1359
|
|
|
@@ -1296,6 +1429,7 @@ const runGateCommand = (
|
|
|
1296
1429
|
| 'dev-env'
|
|
1297
1430
|
| 'doctor'
|
|
1298
1431
|
| 'github'
|
|
1432
|
+
| 'source-text'
|
|
1299
1433
|
| 'structure',
|
|
1300
1434
|
consumer: string,
|
|
1301
1435
|
apply: boolean,
|
|
@@ -1313,6 +1447,9 @@ const runGateCommand = (
|
|
|
1313
1447
|
if (command === 'doctor') {
|
|
1314
1448
|
return runDoctor(consumer);
|
|
1315
1449
|
}
|
|
1450
|
+
if (command === 'source-text') {
|
|
1451
|
+
return Promise.resolve(runSourceTextCheck(consumer));
|
|
1452
|
+
}
|
|
1316
1453
|
if (command === 'structure') {
|
|
1317
1454
|
return runStructure(consumer, profile);
|
|
1318
1455
|
}
|
package/src/creds-add-github.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { inspectDestinations } from './creds-add-preflight';
|
|
|
8
8
|
import { resolveContext } from './creds-dest';
|
|
9
9
|
import { verifyGithubAppInstallation } from './creds-github-app-api';
|
|
10
10
|
import {
|
|
11
|
-
|
|
11
|
+
refreshOwnedGithubStore,
|
|
12
12
|
sameGithubApp,
|
|
13
13
|
selectGithubAppForRepo,
|
|
14
14
|
} from './creds-github-apps';
|
|
@@ -26,9 +26,11 @@ export const runCredsAddGithub = async (
|
|
|
26
26
|
return false;
|
|
27
27
|
}
|
|
28
28
|
const appIdPath = `${context.dest.key}.app_id`;
|
|
29
|
+
const clientIdPath = `${context.dest.key}.client_id`;
|
|
29
30
|
const privateKeyPath = `${context.dest.key}.private_key`;
|
|
30
31
|
const blocked = await inspectDestinations(consumer, context.rel, [
|
|
31
32
|
appIdPath,
|
|
33
|
+
clientIdPath,
|
|
32
34
|
privateKeyPath,
|
|
33
35
|
]);
|
|
34
36
|
if (blocked.problem !== null) {
|
|
@@ -36,7 +38,11 @@ export const runCredsAddGithub = async (
|
|
|
36
38
|
return false;
|
|
37
39
|
}
|
|
38
40
|
const storePath = resolveBrokerPath();
|
|
39
|
-
const loaded = await
|
|
41
|
+
const loaded = await refreshOwnedGithubStore(
|
|
42
|
+
storePath,
|
|
43
|
+
undefined,
|
|
44
|
+
context.repo.split('/')[0],
|
|
45
|
+
);
|
|
40
46
|
if (!loaded.ok) {
|
|
41
47
|
console.error(`standards creds: ${loaded.problem}`);
|
|
42
48
|
return false;
|
|
@@ -54,7 +60,7 @@ export const runCredsAddGithub = async (
|
|
|
54
60
|
console.error(`standards creds: ${installation.problem}`);
|
|
55
61
|
return false;
|
|
56
62
|
}
|
|
57
|
-
const { appId, privateKey, slug } = selected.value;
|
|
63
|
+
const { appId, clientId, privateKey, slug } = selected.value;
|
|
58
64
|
let writeResult:
|
|
59
65
|
| { readonly ok: true }
|
|
60
66
|
| { readonly ok: false; readonly problem: string };
|
|
@@ -75,6 +81,7 @@ export const runCredsAddGithub = async (
|
|
|
75
81
|
}
|
|
76
82
|
return setSopsValues(consumer, context.rel, [
|
|
77
83
|
{ path: appIdPath, value: String(appId) },
|
|
84
|
+
{ path: clientIdPath, value: clientId },
|
|
78
85
|
{ path: privateKeyPath, value: privateKey },
|
|
79
86
|
]);
|
|
80
87
|
});
|
|
@@ -89,6 +96,7 @@ export const runCredsAddGithub = async (
|
|
|
89
96
|
}
|
|
90
97
|
const verified = [
|
|
91
98
|
{ path: appIdPath, value: String(appId) },
|
|
99
|
+
{ path: clientIdPath, value: clientId },
|
|
92
100
|
{ path: privateKeyPath, value: privateKey },
|
|
93
101
|
].map(({ path, value }) => ({
|
|
94
102
|
path,
|
|
@@ -109,7 +117,7 @@ export const runCredsAddGithub = async (
|
|
|
109
117
|
return false;
|
|
110
118
|
}
|
|
111
119
|
console.log(
|
|
112
|
-
`standards creds: wrote App ${slug} credentials to ${context.rel} at ${context.dest.key}.{app_id,private_key}`,
|
|
120
|
+
`standards creds: wrote App ${slug} credentials to ${context.rel} at ${context.dest.key}.{app_id,client_id,private_key}`,
|
|
113
121
|
);
|
|
114
122
|
console.log(
|
|
115
123
|
' workflows mint short-lived installation tokens from these at runtime (actions/create-github-app-token), scoped per repository and permission',
|
package/src/creds-add.ts
CHANGED
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
s3PairPaths,
|
|
20
20
|
} from './creds-r2';
|
|
21
21
|
import { readEncryptedKeys, setSopsValues } from './creds-sops';
|
|
22
|
+
import { resolveBrokerPath } from './creds-store';
|
|
23
|
+
import { withBrokerLock } from './creds-store-lock';
|
|
22
24
|
import { refreshDevEnvForSopsWrites } from './dev-env-brokered-refresh';
|
|
23
25
|
|
|
24
26
|
const DEFAULT_TTL_DAYS = 90;
|
|
@@ -80,7 +82,7 @@ const warnOccupiedDestinations = (
|
|
|
80
82
|
}
|
|
81
83
|
};
|
|
82
84
|
|
|
83
|
-
|
|
85
|
+
const addCloudflareWhileLocked = async (
|
|
84
86
|
consumer: string,
|
|
85
87
|
options: {
|
|
86
88
|
readonly dest: string | undefined;
|
|
@@ -196,3 +198,14 @@ export const runCredsAddCloudflare = async (
|
|
|
196
198
|
addRefreshEvidence(format, context.dest),
|
|
197
199
|
);
|
|
198
200
|
};
|
|
201
|
+
|
|
202
|
+
// Share the transaction boundary with renewal and duplicate retirement. No
|
|
203
|
+
// contender may mint from an outdated collision check or retire an in-flight
|
|
204
|
+
// token before its destination is durably verified.
|
|
205
|
+
export const runCredsAddCloudflare = (
|
|
206
|
+
consumer: string,
|
|
207
|
+
options: Parameters<typeof addCloudflareWhileLocked>[1],
|
|
208
|
+
): Promise<boolean> =>
|
|
209
|
+
withBrokerLock(resolveBrokerPath(), () =>
|
|
210
|
+
addCloudflareWhileLocked(consumer, options),
|
|
211
|
+
);
|
package/src/creds-commands.ts
CHANGED
|
@@ -31,7 +31,7 @@ Options:
|
|
|
31
31
|
--dir <path> Repository to operate on (default: current directory)
|
|
32
32
|
--dest <target>:<key> SOPS destination, e.g. ci:ci.cloudflare_dns_token
|
|
33
33
|
--permissions <list> Comma-separated Cloudflare permission group names
|
|
34
|
-
--account <id> Cloudflare account
|
|
34
|
+
--account <id> Cloudflare account for login, add, revoke, or permissions; plan/apply always cover all accounts
|
|
35
35
|
--ttl-days <n> Token lifetime in days (default: 90)
|
|
36
36
|
--bucket <name> Scope a Cloudflare token to one R2 bucket
|
|
37
37
|
--zone <zone-id> Add a zone resource for zone-scoped groups (comma-separated IDs)
|
|
@@ -112,6 +112,12 @@ export const runCredsCommand = (
|
|
|
112
112
|
console.log(CREDS_USAGE);
|
|
113
113
|
return Promise.resolve(route === 'help');
|
|
114
114
|
}
|
|
115
|
+
if (flags.account !== undefined && (route === 'plan' || route === 'apply')) {
|
|
116
|
+
console.error(
|
|
117
|
+
`standards creds: ${route} always covers every configured account; --account is not supported for this command`,
|
|
118
|
+
);
|
|
119
|
+
return Promise.resolve(false);
|
|
120
|
+
}
|
|
115
121
|
const handlers: Readonly<Record<string, () => Promise<boolean>>> = {
|
|
116
122
|
'login github': () =>
|
|
117
123
|
runCredsLoginGithub({ name: flags.name, org: flags.org }),
|
package/src/creds-github-apps.ts
CHANGED
|
@@ -57,6 +57,72 @@ export const loadOwnedGithubStore = async (
|
|
|
57
57
|
return { ok: true, value: migrated };
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
+
// Refresh authenticated metadata before selecting by a mutable owner login.
|
|
61
|
+
// Owner changes are committed together, and concurrent credential replacement
|
|
62
|
+
// aborts the refresh rather than attaching stale metadata to a different App.
|
|
63
|
+
export const refreshOwnedGithubStore = async (
|
|
64
|
+
path: string,
|
|
65
|
+
lookupOwner: (
|
|
66
|
+
app: GithubBrokerApp,
|
|
67
|
+
) => Promise<GithubAppsResult<string>> = resolveGithubAppOwner,
|
|
68
|
+
requestedOwner?: string,
|
|
69
|
+
): Promise<GithubAppsResult<BrokerStore>> => {
|
|
70
|
+
const loaded = await loadOwnedGithubStore(path, lookupOwner);
|
|
71
|
+
if (!loaded.ok) {
|
|
72
|
+
return loaded;
|
|
73
|
+
}
|
|
74
|
+
const observed = loaded.value.github;
|
|
75
|
+
const hasDirectMatch = observed.some(
|
|
76
|
+
(app) => app.owner?.toLowerCase() === requestedOwner?.toLowerCase(),
|
|
77
|
+
);
|
|
78
|
+
const results = await Promise.all(
|
|
79
|
+
observed.map((app) =>
|
|
80
|
+
hasDirectMatch &&
|
|
81
|
+
app.owner?.toLowerCase() !== requestedOwner?.toLowerCase()
|
|
82
|
+
? Promise.resolve({ ok: true as const, value: app.owner ?? '' })
|
|
83
|
+
: lookupOwner(app),
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
const failed = results.find((result) => !result.ok);
|
|
87
|
+
if (failed !== undefined && !failed.ok) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
problem: `cannot refresh authenticated GitHub App owners: ${failed.problem}; keep existing credentials and retry before creating another App`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const refreshed = observed.map((app, index) => {
|
|
94
|
+
const result = results[index];
|
|
95
|
+
return result?.ok ? { ...app, owner: result.value } : app;
|
|
96
|
+
});
|
|
97
|
+
if (refreshed.every((app, index) => app.owner === observed[index]?.owner)) {
|
|
98
|
+
return loaded;
|
|
99
|
+
}
|
|
100
|
+
const committed = { value: loaded.value };
|
|
101
|
+
try {
|
|
102
|
+
await updateBrokerStore(path, (current) => {
|
|
103
|
+
if (
|
|
104
|
+
current.github.length !== observed.length ||
|
|
105
|
+
!current.github.every((app, index) => {
|
|
106
|
+
const previous = observed[index];
|
|
107
|
+
return previous !== undefined && sameGithubApp(app, previous);
|
|
108
|
+
})
|
|
109
|
+
) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
'GitHub Apps changed while authenticated owners were refreshed; retry',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
committed.value = { ...current, github: refreshed };
|
|
115
|
+
return committed.value;
|
|
116
|
+
});
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
problem: error instanceof Error ? error.message : String(error),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return { ok: true, value: committed.value };
|
|
124
|
+
};
|
|
125
|
+
|
|
60
126
|
// Private Apps belong to one owner. Selecting by repository owner keeps each
|
|
61
127
|
// owner's credentials separate; installation access is verified before export.
|
|
62
128
|
export const selectGithubAppForRepo = (
|
|
@@ -79,10 +145,13 @@ export const selectGithubAppForRepo = (
|
|
|
79
145
|
const loginHint =
|
|
80
146
|
apps.length === 0
|
|
81
147
|
? 'no broker GitHub Apps are configured'
|
|
82
|
-
: `configured owners: ${apps.map((app) => app.owner).join(', ')}`;
|
|
148
|
+
: `authenticated configured owners: ${apps.map((app) => app.owner).join(', ')}; if an account was renamed, update this checkout origin to the current owner shown here before retrying; do not create a duplicate App for a renamed account`;
|
|
83
149
|
return {
|
|
84
150
|
ok: false,
|
|
85
|
-
problem:
|
|
151
|
+
problem:
|
|
152
|
+
apps.length > 0
|
|
153
|
+
? `no broker GitHub App matches repository owner ${owner} (${loginHint}); verify the origin and existing App ownership before configuring a new App`
|
|
154
|
+
: `no broker GitHub App is configured for repository owner ${owner} (${loginHint}); run \`standards creds login github --org ${owner}\` for an organization, or run it without --org while signed in as ${owner}`,
|
|
86
155
|
};
|
|
87
156
|
};
|
|
88
157
|
|
|
@@ -83,6 +83,12 @@ export const verifyCloudflareBootstrapAuthority = async (
|
|
|
83
83
|
problem: `the token is named "${identified.value.name}", inside the broker's minted-token namespace — bootstrap credentials must remain distinguishable from minted credentials; rename it ${BROKER_IDENTITY_NAME} in the dashboard and re-run login`,
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
|
+
if (identified.value.name.toLowerCase() !== BROKER_IDENTITY_NAME) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
problem: `bootstrap tokens must use the reserved name ${BROKER_IDENTITY_NAME}; rename this token in the dashboard and re-run login so other machines cannot revoke it as unmanaged`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
86
92
|
return { ok: true, value: { tokenName: identified.value.name } };
|
|
87
93
|
};
|
|
88
94
|
|
|
@@ -136,12 +142,6 @@ export const runCredsLoginCloudflare = async (options: {
|
|
|
136
142
|
);
|
|
137
143
|
return false;
|
|
138
144
|
}
|
|
139
|
-
const { tokenName } = verified.value;
|
|
140
|
-
if (tokenName !== BROKER_IDENTITY_NAME) {
|
|
141
|
-
console.error(
|
|
142
|
-
`standards creds: warning — the token is named "${tokenName}", not "${BROKER_IDENTITY_NAME}"; rename it in the dashboard so it stays identifiable`,
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
145
|
await updateBrokerStore(storePath, (current) => {
|
|
146
146
|
if (current.cloudflare.some((entry) => entry.accountId === accountId)) {
|
|
147
147
|
throw new Error(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { openInBrowser } from './creds-browser';
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
refreshOwnedGithubStore,
|
|
5
5
|
sameGithubApp,
|
|
6
6
|
upsertGithubApp,
|
|
7
7
|
} from './creds-github-apps';
|
|
@@ -115,7 +115,11 @@ export const runCredsLoginGithub = async (
|
|
|
115
115
|
return false;
|
|
116
116
|
}
|
|
117
117
|
const storePath = resolveBrokerPath();
|
|
118
|
-
const loaded = await
|
|
118
|
+
const loaded = await refreshOwnedGithubStore(
|
|
119
|
+
storePath,
|
|
120
|
+
undefined,
|
|
121
|
+
options.org,
|
|
122
|
+
);
|
|
119
123
|
if (!loaded.ok) {
|
|
120
124
|
console.error(`standards creds: ${loaded.problem}`);
|
|
121
125
|
return false;
|