@marcusrbrown/infra 0.4.4 → 0.4.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -35,6 +35,9 @@
35
35
  "string-dedent": "^3.0.2",
36
36
  "zod": "^4.3.6"
37
37
  },
38
+ "devDependencies": {
39
+ "yaml": "^2.8.3"
40
+ },
38
41
  "engines": {
39
42
  "bun": ">=1.0.0"
40
43
  },
@@ -14,7 +14,7 @@ Commands:
14
14
  --verbose Enable verbose output for all commands
15
15
 
16
16
 
17
- keeweb deploy Trigger KeeWeb deployment. Default mode dispatches the GitHub Deploy workflow. Use --local to run apps/keeweb/deploy.sh directly from this repo.
17
+ keeweb deploy Trigger KeeWeb deployment. Default mode dispatches the GitHub Deploy KeeWeb workflow. Use --local to run apps/keeweb/deploy.sh directly from this repo.
18
18
 
19
19
  --local Run local deployment using apps/keeweb/deploy.sh instead of triggering GitHub Actions. (default: false)
20
20
  --nginx Include nginx config deployment. Valid only with --local and passed through to deploy.sh as --nginx. (default: false)
@@ -31,7 +31,7 @@ Commands:
31
31
  --verbose Enable verbose output for all commands
32
32
 
33
33
 
34
- cliproxy deploy Deploy CLIProxyAPI. Default mode triggers the GitHub Deploy workflow, while --local runs apps/cliproxy/src/deploy.ts directly with Bun.
34
+ cliproxy deploy Deploy CLIProxyAPI. Default mode triggers the GitHub Deploy CLIProxy workflow, while --local runs apps/cliproxy/src/deploy.ts directly with Bun.
35
35
 
36
36
  --local Run local deployment with Bun using apps/cliproxy/src/deploy.ts instead of triggering GitHub Actions. (default: false)
37
37
  --force-config Force upload config.yaml even if it exists on the server. WARNING: overwrites runtime API keys and settings. (default: false)
@@ -5,8 +5,8 @@ import {resolve} from 'node:path'
5
5
  import {z} from 'zod'
6
6
 
7
7
  const REPO = 'marcusrbrown/infra'
8
- const WORKFLOW_NAME = 'Deploy'
9
- const WORKFLOW_URL = 'https://github.com/marcusrbrown/infra/actions/workflows/deploy.yaml'
8
+ const WORKFLOW_NAME = 'Deploy CLIProxy'
9
+ const WORKFLOW_URL = 'https://github.com/marcusrbrown/infra/actions/workflows/deploy-cliproxy.yaml'
10
10
 
11
11
  type CliInstance = ReturnType<typeof goke>
12
12
 
@@ -66,7 +66,7 @@ export function registerCliproxyDeploy(cli: CliInstance): void {
66
66
  cli
67
67
  .command(
68
68
  'cliproxy deploy',
69
- 'Deploy CLIProxyAPI. Default mode triggers the GitHub Deploy workflow, while --local runs apps/cliproxy/src/deploy.ts directly with Bun.',
69
+ 'Deploy CLIProxyAPI. Default mode triggers the GitHub Deploy CLIProxy workflow, while --local runs apps/cliproxy/src/deploy.ts directly with Bun.',
70
70
  )
71
71
  .option(
72
72
  '--local',
@@ -8,8 +8,10 @@ import {
8
8
  formatWorkflowSnippet,
9
9
  getHarnessTemplate,
10
10
  interpretGhContentResult,
11
+ isGhRateLimitError,
11
12
  registerCliproxySetup,
12
13
  validateSetupOptions,
14
+ withGhRetry,
13
15
  type SecretAssignment,
14
16
  type VariableAssignment,
15
17
  } from './setup'
@@ -266,6 +268,62 @@ describe('cliproxy setup helpers', () => {
266
268
  })
267
269
  })
268
270
 
271
+ describe('isGhRateLimitError', () => {
272
+ it('returns true when text contains "rate limit"', () => {
273
+ expect(isGhRateLimitError('API rate limit exceeded')).toBe(true)
274
+ })
275
+
276
+ it('is case-insensitive', () => {
277
+ expect(isGhRateLimitError('You have exceeded a secondary RATE LIMIT')).toBe(true)
278
+ })
279
+
280
+ it('returns false for unrelated error messages', () => {
281
+ expect(isGhRateLimitError('Not Found (HTTP 404)')).toBe(false)
282
+ })
283
+
284
+ it('returns false for an empty string', () => {
285
+ expect(isGhRateLimitError('')).toBe(false)
286
+ })
287
+
288
+ it('returns false for a connection timeout', () => {
289
+ expect(isGhRateLimitError('connection timeout')).toBe(false)
290
+ })
291
+ })
292
+
293
+ describe('withGhRetry', () => {
294
+ it('returns the value when fn succeeds immediately', async () => {
295
+ const result = await withGhRetry('test label', async () => 'ok', false)
296
+
297
+ expect(result).toBe('ok')
298
+ })
299
+
300
+ it('re-throws non-rate-limit errors without querying the reset time', async () => {
301
+ const queryReset = async (): Promise<string> => {
302
+ throw new Error('queryReset should not have been called')
303
+ }
304
+ const err = new Error('some other error')
305
+
306
+ await expect(withGhRetry('test label', async () => Promise.reject(err), false, queryReset)).rejects.toThrow(
307
+ 'some other error',
308
+ )
309
+ })
310
+
311
+ it('re-throws with reset time appended in non-interactive mode on rate limit', async () => {
312
+ const queryReset = async (): Promise<string> => '2:30 PM'
313
+
314
+ await expect(
315
+ withGhRetry(
316
+ 'test label',
317
+ async () => {
318
+ throw new Error('API rate limit exceeded for url')
319
+ },
320
+ false,
321
+ queryReset,
322
+ ),
323
+ ).rejects.toThrow('resets at 2:30 PM')
324
+ })
325
+ })
326
+
269
327
  describe('help output', () => {
270
328
  it('shows --key, --repo, and --harness flags', () => {
271
329
  const cli = goke('infra')
@@ -225,6 +225,75 @@ async function runGh(args: string[]): Promise<CommandResult> {
225
225
  return runCommand('gh', args)
226
226
  }
227
227
 
228
+ export function isGhRateLimitError(text: string): boolean {
229
+ return /rate limit/i.test(text)
230
+ }
231
+
232
+ /**
233
+ * Query the GitHub API rate limit reset time. The `rate_limit` endpoint is
234
+ * exempt from rate limiting itself, so this should succeed even when the
235
+ * primary GraphQL limit is exhausted. Returns a formatted local time string
236
+ * or a fallback phrase when the endpoint is unreachable.
237
+ */
238
+ async function queryRateLimitReset(): Promise<string> {
239
+ try {
240
+ const result = await runGh(['api', 'rate_limit'])
241
+ if (result.exitCode === 0) {
242
+ const parsed = JSON.parse(result.stdout) as {
243
+ resources?: {graphql?: {reset?: number}; core?: {reset?: number}}
244
+ }
245
+ const reset = parsed.resources?.graphql?.reset ?? parsed.resources?.core?.reset
246
+ if (reset) {
247
+ return new Date(reset * 1000).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
248
+ }
249
+ }
250
+ } catch {
251
+ // Fall through to generic phrase
252
+ }
253
+ return 'an unknown time'
254
+ }
255
+
256
+ /**
257
+ * Run a GitHub API operation wrapped in a spinner, retrying indefinitely on
258
+ * rate-limit errors when in interactive mode. In non-interactive mode the
259
+ * error is re-thrown with the reset time appended so the caller can surface
260
+ * it without prompting.
261
+ */
262
+ export async function withGhRetry<T>(
263
+ label: string,
264
+ fn: (spinnerInstance: SpinnerResult) => Promise<T>,
265
+ interactive: boolean,
266
+ queryReset: () => Promise<string> = queryRateLimitReset,
267
+ ): Promise<T> {
268
+ for (;;) {
269
+ try {
270
+ return await withSpinner(label, fn)
271
+ } catch (error) {
272
+ const message = extractErrorMessage(error)
273
+ if (!isGhRateLimitError(message)) {
274
+ throw error
275
+ }
276
+ const reset = await queryReset()
277
+ if (!interactive) {
278
+ throw new Error(`${message} — GitHub API rate limit resets at ${reset}. Re-run when ready.`)
279
+ }
280
+ log.warn(`GitHub API rate limit exceeded. Resets at ${reset}.`)
281
+ const retry = await promptValue(
282
+ confirm({
283
+ message: 'Retry this step when ready?',
284
+ active: 'retry',
285
+ inactive: 'abort',
286
+ initialValue: true,
287
+ }),
288
+ 'Setup aborted after rate limit.',
289
+ )
290
+ if (!retry) {
291
+ cancelAndExit('Setup aborted after GitHub API rate limit.')
292
+ }
293
+ }
294
+ }
295
+ }
296
+
228
297
  async function assertGhInstalled(): Promise<void> {
229
298
  if (!Bun.which('gh')) {
230
299
  throw new Error('GitHub CLI is required for cliproxy setup. Install gh first: https://cli.github.com/')
@@ -720,9 +789,13 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
720
789
  resolveManagementKey()
721
790
  }
722
791
 
723
- await withSpinner(`Checking GitHub access for ${plan.repo}`, async () => {
724
- await assertRepoAccess(plan.repo)
725
- })
792
+ await withGhRetry(
793
+ `Checking GitHub access for ${plan.repo}`,
794
+ async () => {
795
+ await assertRepoAccess(plan.repo)
796
+ },
797
+ interactive,
798
+ )
726
799
 
727
800
  if (options.key) {
728
801
  log.info('Using the provided API key value directly. No new CLIProxyAPI key will be created.')
@@ -756,10 +829,12 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
756
829
  }
757
830
  }
758
831
 
759
- const [existingSecrets, existingVariables] = await Promise.all([
760
- listExistingGhNames(plan.repo, 'secret'),
761
- listExistingGhNames(plan.repo, 'variable'),
762
- ])
832
+ const [existingSecrets, existingVariables] = await withGhRetry(
833
+ 'Checking existing GitHub secrets and variables',
834
+ async () =>
835
+ Promise.all([listExistingGhNames(plan.repo, 'secret'), listExistingGhNames(plan.repo, 'variable')]),
836
+ interactive,
837
+ )
763
838
  const collisions = collectCollisions(plan.template, existingSecrets, existingVariables)
764
839
 
765
840
  if (collisions.length > 0) {
@@ -796,26 +871,34 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
796
871
  }
797
872
 
798
873
  try {
799
- await withSpinner('Writing GitHub secrets and variables', async spinnerInstance => {
800
- for (const secret of plan.template.secrets) {
801
- spinnerInstance.message(`Setting secret ${secret.name}`)
802
- await applyGhValue('secret', secret.name, plan.repo, secret.value)
803
- }
874
+ await withGhRetry(
875
+ 'Writing GitHub secrets and variables',
876
+ async spinnerInstance => {
877
+ for (const secret of plan.template.secrets) {
878
+ spinnerInstance.message(`Setting secret ${secret.name}`)
879
+ await applyGhValue('secret', secret.name, plan.repo, secret.value)
880
+ }
804
881
 
805
- for (const variable of plan.template.variables) {
806
- spinnerInstance.message(`Setting variable ${variable.name}`)
807
- await applyGhValue('variable', variable.name, plan.repo, variable.value)
808
- }
809
- })
882
+ for (const variable of plan.template.variables) {
883
+ spinnerInstance.message(`Setting variable ${variable.name}`)
884
+ await applyGhValue('variable', variable.name, plan.repo, variable.value)
885
+ }
886
+ },
887
+ interactive,
888
+ )
810
889
 
811
890
  await withSpinner('Verifying the new key through the proxy', async () => {
812
891
  await assertProxyKeyWorks(baseUrl, plan.keyValue)
813
892
  })
814
893
 
815
894
  if (plan.harness === 'opencode') {
816
- const workflow = await withSpinner(`Checking ${plan.repo} fro-bot.yaml wiring`, async () => {
817
- return checkFroBotWorkflow(plan.repo)
818
- })
895
+ const workflow = await withGhRetry(
896
+ `Checking ${plan.repo} fro-bot.yaml wiring`,
897
+ async () => {
898
+ return checkFroBotWorkflow(plan.repo)
899
+ },
900
+ interactive,
901
+ )
819
902
 
820
903
  switch (workflow.kind) {
821
904
  case 'missing': {
@@ -4,8 +4,8 @@ import {resolve} from 'node:path'
4
4
  import {z} from 'zod'
5
5
 
6
6
  const REPO = 'marcusrbrown/infra'
7
- const WORKFLOW_NAME = 'Deploy'
8
- const WORKFLOW_URL = 'https://github.com/marcusrbrown/infra/actions/workflows/deploy.yaml'
7
+ const WORKFLOW_NAME = 'Deploy KeeWeb'
8
+ const WORKFLOW_URL = 'https://github.com/marcusrbrown/infra/actions/workflows/deploy-keeweb.yaml'
9
9
 
10
10
  const DEFAULT_HOST = 'box.heatvision.co'
11
11
  const DEFAULT_REMOTE_USER = 'deploy-kw'
@@ -87,7 +87,7 @@ export function registerKeewebDeploy(cli: CliInstance): void {
87
87
  cli
88
88
  .command(
89
89
  'keeweb deploy',
90
- 'Trigger KeeWeb deployment. Default mode dispatches the GitHub Deploy workflow. Use --local to run apps/keeweb/deploy.sh directly from this repo.',
90
+ 'Trigger KeeWeb deployment. Default mode dispatches the GitHub Deploy KeeWeb workflow. Use --local to run apps/keeweb/deploy.sh directly from this repo.',
91
91
  )
92
92
  .option(
93
93
  '--local',
@@ -114,7 +114,7 @@ export function registerKeewebDeploy(cli: CliInstance): void {
114
114
  'Print planned actions without validating preconditions, executing deploy.sh, or triggering GitHub Actions.',
115
115
  ),
116
116
  )
117
- .example('# Trigger GitHub Deploy workflow (default mode)')
117
+ .example('# Trigger GitHub Deploy KeeWeb workflow (default mode)')
118
118
  .example('infra keeweb deploy')
119
119
  .example('# Preview local deploy plan without side effects')
120
120
  .example('infra keeweb deploy --local --dry-run')
@@ -170,7 +170,9 @@ export function registerKeewebDeploy(cli: CliInstance): void {
170
170
 
171
171
  validateRemotePreconditions()
172
172
 
173
- console.warn('Warning: the Deploy workflow includes nginx config deployment as part of workflow_dispatch logic.')
173
+ console.warn(
174
+ 'Warning: the Deploy KeeWeb workflow includes nginx config deployment as part of workflow_dispatch logic.',
175
+ )
174
176
  console.warn('Warning: the workflow requires keeweb environment approval before jobs execute.')
175
177
 
176
178
  const child = Bun.spawn(['gh', 'workflow', 'run', WORKFLOW_NAME, '--repo', REPO], {
@@ -180,7 +182,7 @@ export function registerKeewebDeploy(cli: CliInstance): void {
180
182
 
181
183
  const exitCode = await child.exited
182
184
  if (exitCode !== 0) {
183
- throw new Error(`Failed to trigger Deploy workflow (exit code ${exitCode})`)
185
+ throw new Error(`Failed to trigger Deploy KeeWeb workflow (exit code ${exitCode})`)
184
186
  }
185
187
 
186
188
  console.log(`Workflow triggered: ${WORKFLOW_URL}`)
@@ -116,7 +116,7 @@ export async function checkLastDeploy(verbose: boolean): Promise<CheckResult> {
116
116
  'gh',
117
117
  'run',
118
118
  'list',
119
- '--workflow=Deploy',
119
+ '--workflow=Deploy KeeWeb',
120
120
  '--status=success',
121
121
  '--limit=1',
122
122
  '--json',
@@ -164,7 +164,7 @@ export async function checkLastDeploy(verbose: boolean): Promise<CheckResult> {
164
164
  return {
165
165
  title: 'Last successful deploy',
166
166
  level: 'warning',
167
- summary: 'No successful Deploy workflow runs found',
167
+ summary: 'No successful Deploy KeeWeb workflow runs found',
168
168
  }
169
169
  }
170
170
 
@@ -0,0 +1,316 @@
1
+ import {relative, resolve} from 'node:path'
2
+ import {describe, expect, it} from 'bun:test'
3
+ import {parse as parseYaml} from 'yaml'
4
+
5
+ const REPO_ROOT = resolve(import.meta.dir, '../../..')
6
+ const INTERNAL_ORGS = new Set(['marcusrbrown'])
7
+ const ALLOWED_SHELL_SCRIPTS = new Set(['apps/keeweb/deploy.sh'])
8
+
9
+ // Accepted version-comment forms on SHA-pinned `uses:` lines:
10
+ // # v6.0.2 (semver tag)
11
+ // # renovate-changesets@0.2.31 (scoped release tag)
12
+ const VERSION_COMMENT_RE = /^#\s+(?:v\d+(?:\.\d+){0,2}|[\w@/-]+@\d+(?:\.\d+){0,2})\s*$/
13
+
14
+ // Match `[- ]uses: owner/repo@<sha>[ trailing]` — step-level (indented, possibly dash-prefixed) and job-level.
15
+ // Uses [ \t] instead of \s to avoid regex backtracking ambiguity between overlapping quantifiers.
16
+ const USES_SHA_LINE_RE = /^[ \t]+(?:-[ \t]+)?uses:[ \t]+(\S+)@([a-f0-9]{7,})(?:[ \t]+(\S.*))?$/
17
+
18
+ interface Violation {
19
+ file: string
20
+ detail: string
21
+ }
22
+
23
+ // `.github/` is a dot-directory; Bun.Glob skips dot-dirs by default, so every
24
+ // glob that traverses `.github/` must pass `{ dot: true }`. Without it, the
25
+ // workflow rules silently pass on an empty file set. The tripwire test at the
26
+ // top of the suite asserts the file count is >= 1 to catch this regression.
27
+ function listWorkflowFiles(extension: '.yaml' | '.yml'): string[] {
28
+ const glob = new Bun.Glob(`.github/workflows/*${extension}`)
29
+ return [...glob.scanSync({cwd: REPO_ROOT, absolute: true, dot: true})]
30
+ }
31
+
32
+ function listPackageJsonFiles(): string[] {
33
+ // Bun.Glob auto-excludes node_modules/** by default; no package.json lives under dot-dirs.
34
+ const glob = new Bun.Glob('**/package.json')
35
+ return [...glob.scanSync({cwd: REPO_ROOT, absolute: true})].filter(f => !f.includes('/node_modules/'))
36
+ }
37
+
38
+ function listShellScriptFiles(): string[] {
39
+ const glob = new Bun.Glob('**/*.sh')
40
+ return [...glob.scanSync({cwd: REPO_ROOT, absolute: true})].filter(
41
+ f => !f.includes('/node_modules/') && !f.includes('/.cache/') && !f.includes('/dist/'),
42
+ )
43
+ }
44
+
45
+ /**
46
+ * Detect cross-org `secrets: inherit` on reusable-workflow job calls.
47
+ *
48
+ * Rules:
49
+ * - Jobs without a `uses:` string are step-based and out of scope.
50
+ * - `uses:` values starting with `./` or `../` are local reusable workflows; `secrets: inherit` is legitimate.
51
+ * - `uses:` values whose owner (first path segment) is in INTERNAL_ORGS are same-org; `secrets: inherit` is legitimate.
52
+ * - Everything else is cross-org and must not use `secrets: inherit`.
53
+ */
54
+ export function findCrossOrgSecretsInherit(parsed: unknown): {jobId: string; uses: string}[] {
55
+ if (typeof parsed !== 'object' || parsed === null) return []
56
+ const jobs = (parsed as {jobs?: Record<string, unknown>}).jobs
57
+ if (typeof jobs !== 'object' || jobs === null) return []
58
+
59
+ const violations: {jobId: string; uses: string}[] = []
60
+ for (const [jobId, jobRaw] of Object.entries(jobs)) {
61
+ if (typeof jobRaw !== 'object' || jobRaw === null) continue
62
+ const job = jobRaw as {uses?: unknown; secrets?: unknown}
63
+ if (typeof job.uses !== 'string') continue
64
+ if (job.uses.startsWith('./') || job.uses.startsWith('../')) continue
65
+ const owner = job.uses.split('/')[0] ?? ''
66
+ if (INTERNAL_ORGS.has(owner)) continue
67
+ if (job.secrets === 'inherit') {
68
+ violations.push({jobId, uses: job.uses})
69
+ }
70
+ }
71
+ return violations
72
+ }
73
+
74
+ describe('repo conventions', () => {
75
+ it('tripwire: workflow glob resolves to at least one file (catches dot-dir glob regressions)', () => {
76
+ const workflows = listWorkflowFiles('.yaml')
77
+ expect(workflows.length).toBeGreaterThan(0)
78
+ })
79
+
80
+ it('no `bundledDependencies` in any package.json', async () => {
81
+ const files = listPackageJsonFiles()
82
+ const offenders: string[] = []
83
+ for (const file of files) {
84
+ const json = (await Bun.file(file).json()) as Record<string, unknown>
85
+ if ('bundledDependencies' in json) {
86
+ offenders.push(relative(REPO_ROOT, file))
87
+ }
88
+ }
89
+ expect(offenders).toEqual([])
90
+ })
91
+
92
+ it("apps/keeweb/config/config.json has settings.dropboxSecret === ''", async () => {
93
+ const configPath = resolve(REPO_ROOT, 'apps/keeweb/config/config.json')
94
+ const config = (await Bun.file(configPath).json()) as {
95
+ settings?: {dropboxSecret?: unknown}
96
+ }
97
+ expect(config.settings?.dropboxSecret).toBe('')
98
+ })
99
+
100
+ it('no `secrets: inherit` on any job whose `uses:` points to a cross-org workflow', async () => {
101
+ const files = listWorkflowFiles('.yaml')
102
+ const violations: Violation[] = []
103
+ for (const file of files) {
104
+ const text = await Bun.file(file).text()
105
+ const parsed = parseYaml(text, {merge: true})
106
+ for (const v of findCrossOrgSecretsInherit(parsed)) {
107
+ violations.push({
108
+ file: relative(REPO_ROOT, file),
109
+ detail: `job '${v.jobId}' uses '${v.uses}' with secrets: inherit`,
110
+ })
111
+ }
112
+ }
113
+ expect(violations).toEqual([])
114
+ })
115
+
116
+ it('no `ssh-keyscan` under .github/workflows/**', async () => {
117
+ const files = listWorkflowFiles('.yaml')
118
+ const violations: Violation[] = []
119
+ for (const file of files) {
120
+ const text = await Bun.file(file).text()
121
+ const lines = text.split(/\r?\n/)
122
+ for (const [index, line] of lines.entries()) {
123
+ if (/\bssh-keyscan\b/.test(line)) {
124
+ violations.push({
125
+ file: relative(REPO_ROOT, file),
126
+ detail: `line ${index + 1}: ${line.trim()}`,
127
+ })
128
+ }
129
+ }
130
+ }
131
+ expect(violations).toEqual([])
132
+ })
133
+
134
+ it('every SHA-pinned `uses:` line has a trailing `# vX.Y.Z` or `# scope@X.Y.Z` comment', async () => {
135
+ const files = listWorkflowFiles('.yaml')
136
+ const violations: Violation[] = []
137
+ for (const file of files) {
138
+ const text = await Bun.file(file).text()
139
+ const lines = text.split(/\r?\n/)
140
+ for (const [index, line] of lines.entries()) {
141
+ const match = line.match(USES_SHA_LINE_RE)
142
+ if (!match) continue
143
+ const [, ref = '', sha = '', tail] = match
144
+ const shortSha = sha.slice(0, 7)
145
+ if (tail === undefined) {
146
+ violations.push({
147
+ file: relative(REPO_ROOT, file),
148
+ detail: `line ${index + 1}: missing version comment on '${ref}@${shortSha}…'`,
149
+ })
150
+ } else if (!VERSION_COMMENT_RE.test(tail.trim())) {
151
+ violations.push({
152
+ file: relative(REPO_ROOT, file),
153
+ detail: `line ${index + 1}: malformed version comment '${tail.trim()}' on '${ref}@${shortSha}…'`,
154
+ })
155
+ }
156
+ }
157
+ }
158
+ expect(violations).toEqual([])
159
+ })
160
+
161
+ it('no `.yml` files under .github/workflows/ (use `.yaml`)', () => {
162
+ const files = listWorkflowFiles('.yml')
163
+ expect(files.map(f => relative(REPO_ROOT, f))).toEqual([])
164
+ })
165
+
166
+ it('no `.sh` files outside `apps/keeweb/deploy.sh`', () => {
167
+ const files = listShellScriptFiles()
168
+ const offenders = files.map(f => relative(REPO_ROOT, f)).filter(f => !ALLOWED_SHELL_SCRIPTS.has(f))
169
+ expect(offenders).toEqual([])
170
+ })
171
+ })
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // (enforced) marker drift detection
175
+ // ---------------------------------------------------------------------------
176
+ //
177
+ // Every bullet in AGENTS.md tagged `(enforced)` must map to a known
178
+ // enforcement mechanism (test or ESLint rule). Adding `(enforced)` without
179
+ // backing enforcement, or deleting the enforcement while keeping the marker,
180
+ // both cause a test failure here.
181
+ //
182
+ // Manifest keys are unique substrings of the AGENTS.md bullet text.
183
+ // Values describe where the enforcement lives — they are documentation only.
184
+ const ENFORCED_MANIFEST: Record<string, string> = {
185
+ 'Only bash script': 'conventions.test.ts: no .sh files outside apps/keeweb/deploy.sh',
186
+ 'GitHub Actions': 'conventions.test.ts: .yaml extension + SHA-pin version comment',
187
+ 'Cross-org reusable workflows': 'conventions.test.ts: no secrets: inherit on cross-org jobs',
188
+ 'as any': 'eslint.config.ts: @typescript-eslint/no-explicit-any + ban-ts-comment at error',
189
+ 'No secret values in tracked files': 'conventions.test.ts: settings.dropboxSecret === empty string',
190
+ 'ssh-keyscan': 'conventions.test.ts: no ssh-keyscan under .github/workflows/**',
191
+ 'Never `secrets: inherit`': 'conventions.test.ts: no secrets: inherit on cross-org jobs',
192
+ bundledDependencies: 'conventions.test.ts: no bundledDependencies in any package.json',
193
+ }
194
+
195
+ describe('(enforced) marker drift', () => {
196
+ it('every (enforced) bullet in AGENTS.md is accounted for in the enforcement manifest', async () => {
197
+ const agentsMd = await Bun.file(resolve(REPO_ROOT, 'AGENTS.md')).text()
198
+ const enforcedLines = agentsMd.split(/\r?\n/).filter((l: string) => /\(enforced\)/.test(l))
199
+
200
+ // Tripwire — if the grep logic breaks, the whole suite silently passes with 0 checks
201
+ expect(enforcedLines.length).toBeGreaterThan(0)
202
+
203
+ const unmatched = enforcedLines.filter(
204
+ (line: string) => !Object.keys(ENFORCED_MANIFEST).some((key: string) => line.includes(key)),
205
+ )
206
+ expect(unmatched, 'New (enforced) bullet has no manifest entry — add enforcement before tagging').toEqual([])
207
+ })
208
+
209
+ it('every manifest entry corresponds to an actual (enforced) bullet in AGENTS.md', async () => {
210
+ const agentsMd = await Bun.file(resolve(REPO_ROOT, 'AGENTS.md')).text()
211
+ const enforcedLines = agentsMd.split(/\r?\n/).filter((l: string) => /\(enforced\)/.test(l))
212
+
213
+ const stale = Object.keys(ENFORCED_MANIFEST).filter(
214
+ (key: string) => !enforcedLines.some((l: string) => l.includes(key)),
215
+ )
216
+ expect(stale, 'Manifest entry has no matching (enforced) bullet in AGENTS.md — remove or update').toEqual([])
217
+ })
218
+
219
+ it('@typescript-eslint/no-explicit-any is configured at error severity in eslint.config.ts', async () => {
220
+ const eslintConfig = await Bun.file(resolve(REPO_ROOT, 'eslint.config.ts')).text()
221
+ // Matches: '@typescript-eslint/no-explicit-any': 'error'
222
+ expect(eslintConfig).toMatch(/'@typescript-eslint\/no-explicit-any'\s*:\s*'error'/)
223
+ })
224
+
225
+ it('@typescript-eslint/ban-ts-comment is configured in eslint.config.ts', async () => {
226
+ const eslintConfig = await Bun.file(resolve(REPO_ROOT, 'eslint.config.ts')).text()
227
+ expect(eslintConfig).toContain('@typescript-eslint/ban-ts-comment')
228
+ })
229
+ })
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Per-app invariants
233
+ // ---------------------------------------------------------------------------
234
+ //
235
+ // Guard that critical safety mechanisms in each app are not accidentally
236
+ // removed. These are the runtime equivalents of (enforced) markers: code that
237
+ // must remain present for the documented behaviour to hold.
238
+
239
+ describe('per-app invariants', () => {
240
+ it('cliproxy deploy.ts guards config.yaml upload with a remoteFileExists() check', async () => {
241
+ const deployTs = await Bun.file(resolve(REPO_ROOT, 'apps/cliproxy/src/deploy.ts')).text()
242
+ // The guard: remoteFileExists(host, `${REMOTE_DIR}/config/config.yaml`, env)
243
+ // This prevents overwriting runtime API keys on the server.
244
+ expect(deployTs).toContain('remoteFileExists')
245
+ expect(deployTs).toContain('config.yaml')
246
+ })
247
+
248
+ it('keeweb build.ts defines an EXPECTED_SHA256 constant for archive integrity', async () => {
249
+ const buildTs = await Bun.file(resolve(REPO_ROOT, 'apps/keeweb/src/build.ts')).text()
250
+ // EXPECTED_SHA256 is the KeeWeb release zip checksum; its presence proves
251
+ // SHA verification is wired in and was not accidentally stripped.
252
+ expect(buildTs).toMatch(/const\s+EXPECTED_SHA256\s*=/)
253
+ })
254
+ })
255
+
256
+ describe('findCrossOrgSecretsInherit', () => {
257
+ it('flags a cross-org reusable workflow that uses `secrets: inherit`', () => {
258
+ const parsed = parseYaml(`
259
+ jobs:
260
+ build:
261
+ uses: bfra-me/.github/.github/workflows/example.yaml@abc1234
262
+ secrets: inherit
263
+ `)
264
+ const violations = findCrossOrgSecretsInherit(parsed)
265
+ expect(violations).toHaveLength(1)
266
+ expect(violations[0]?.jobId).toBe('build')
267
+ expect(violations[0]?.uses).toContain('bfra-me/')
268
+ })
269
+
270
+ it('allows a same-org reusable workflow to use `secrets: inherit`', () => {
271
+ const parsed = parseYaml(`
272
+ jobs:
273
+ release:
274
+ uses: marcusrbrown/infra/.github/workflows/release.yaml@sha
275
+ secrets: inherit
276
+ `)
277
+ expect(findCrossOrgSecretsInherit(parsed)).toEqual([])
278
+ })
279
+
280
+ it('allows a local (./) reusable workflow to use `secrets: inherit`', () => {
281
+ const parsed = parseYaml(`
282
+ jobs:
283
+ build:
284
+ uses: ./.github/workflows/local.yaml
285
+ secrets: inherit
286
+ `)
287
+ expect(findCrossOrgSecretsInherit(parsed)).toEqual([])
288
+ })
289
+
290
+ it('ignores `secrets: inherit` strings appearing inside prose block scalars', () => {
291
+ // Mirrors fro-bot.yaml: SCHEDULE_PROMPT contains the literal string
292
+ // `secrets: inherit` inside a prompt heredoc, but no job-level key exists.
293
+ const parsed = parseYaml(`
294
+ env:
295
+ SCHEDULE_PROMPT: |
296
+ Remember: never use secrets: inherit with cross-org workflows.
297
+ jobs:
298
+ run:
299
+ runs-on: ubuntu-latest
300
+ steps:
301
+ - run: echo hi
302
+ `)
303
+ expect(findCrossOrgSecretsInherit(parsed)).toEqual([])
304
+ })
305
+
306
+ it('does not flag jobs without a `uses:` key (step-based jobs)', () => {
307
+ const parsed = parseYaml(`
308
+ jobs:
309
+ lint:
310
+ runs-on: ubuntu-latest
311
+ steps:
312
+ - run: echo hi
313
+ `)
314
+ expect(findCrossOrgSecretsInherit(parsed)).toEqual([])
315
+ })
316
+ })