@marcusrbrown/infra 0.8.0 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -125,9 +125,27 @@ Commands:
125
125
  --include-ca Restore the mitmproxy CA certificate and private key. Currently the only supported restore target. (default: true)
126
126
 
127
127
 
128
+ umami status Show operational health of the Umami analytics deployment via docker compose ps.
129
+
130
+ --key [key] Environment variable name holding the SSH host. Falls back to UMAMI_DOMAIN when omitted.
131
+
132
+
133
+ umami deploy Deploy Umami analytics. Default mode triggers the GitHub Deploy Umami workflow, while --local runs apps/umami deploy directly with Bun.
134
+
135
+ --local Run local deployment with Bun using apps/umami instead of triggering GitHub Actions. (default: false)
136
+ --dry-run Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow. (default: false)
137
+
138
+
139
+ umami logs [service] Stream logs from an Umami service via SSH and docker compose.
140
+
141
+ --tail [n] Number of log lines to tail from each service. (default: 100)
142
+ --allow-ci Allow log streaming in CI environments. Logs may contain sensitive credentials. (default: false)
143
+ --key [key] Environment variable name holding the SSH host. Falls back to UMAMI_DOMAIN when omitted.
144
+
145
+
128
146
  status Show status of all deployments
129
147
 
130
- --json Output machine-readable JSON with keeweb, cliproxy, and gateway summary objects.
148
+ --json Output machine-readable JSON with keeweb, cliproxy, gateway, and umami summary objects.
131
149
  --verbose Include verbose per-app health check details when building the summary rows.
132
150
 
133
151
 
package/src/cli.ts CHANGED
@@ -7,6 +7,7 @@ import {registerGatewayCommands} from './commands/gateway'
7
7
  import {registerKeewebCommands} from './commands/keeweb'
8
8
  import {registerMcp} from './commands/mcp'
9
9
  import {registerStatus} from './commands/status'
10
+ import {registerUmamiCommands} from './commands/umami'
10
11
 
11
12
  declare const process: {
12
13
  argv: string[]
@@ -20,6 +21,7 @@ cli.option('--verbose', 'Enable verbose output for all commands')
20
21
  registerKeewebCommands(cli)
21
22
  registerCliproxyCommands(cli)
22
23
  registerGatewayCommands(cli)
24
+ registerUmamiCommands(cli)
23
25
  registerStatus(cli)
24
26
  registerMcp(cli)
25
27
 
@@ -1,9 +1,23 @@
1
1
  /// <reference types="bun" />
2
2
 
3
+ import type {MultiSelectOptions, TextOptions} from '@clack/prompts'
3
4
  import {describe, expect, it, spyOn} from 'bun:test'
4
5
 
5
6
  import {parseProviders, promptForModel, promptForProviders} from './providers'
6
7
 
8
+ // Type helper: cast a concrete-typed clack implementation to the generic spy type.
9
+ // clack's multiselect/text/select are generic functions; Bun's spyOn preserves the
10
+ // generic signature, so mockImplementation requires the same generic. We provide a
11
+ // concrete instantiation and widen through `unknown` — this is safe because the
12
+ // concrete type is a structural subtype of the generic at the call site.
13
+ function asMultiselectImpl<V>(fn: (opts: MultiSelectOptions<V>) => Promise<V[] | symbol>) {
14
+ return fn as unknown as <Value>(opts: MultiSelectOptions<Value>) => Promise<Value[] | symbol>
15
+ }
16
+
17
+ function asTextImpl(fn: (opts: TextOptions) => Promise<string | symbol>) {
18
+ return fn as unknown as (opts: TextOptions) => Promise<string | symbol>
19
+ }
20
+
7
21
  describe('option parsing', () => {
8
22
  describe('parseProviders', () => {
9
23
  it("parses \"anthropic,openai\" to ['anthropic', 'openai']", () => {
@@ -40,7 +54,6 @@ describe('option parsing', () => {
40
54
  })
41
55
  })
42
56
 
43
- /* eslint-disable @typescript-eslint/no-explicit-any -- spyOn mock return values require `any` casts */
44
57
  describe('interactive provider/model prompts', () => {
45
58
  // We spy on @clack/prompts functions directly since Bun's mock.module
46
59
  // requires static hoisting. Instead we use spyOn on the imported module.
@@ -60,7 +73,8 @@ describe('interactive provider/model prompts', () => {
60
73
  describe('promptForProviders', () => {
61
74
  it('happy path: anthropic-only selection returns [anthropic]', async () => {
62
75
  const clack = await import('@clack/prompts')
63
- const multiselectSpy = spyOn(clack, 'multiselect').mockResolvedValue(['anthropic'] as any)
76
+ // multiselect<Value> returns Promise<Value[] | symbol>; resolved value is string[] | symbol
77
+ const multiselectSpy = spyOn(clack, 'multiselect').mockResolvedValue(['anthropic'])
64
78
 
65
79
  const result = await promptForProviders()
66
80
 
@@ -72,7 +86,7 @@ describe('interactive provider/model prompts', () => {
72
86
 
73
87
  it('happy path: both providers selected returns [anthropic, openai]', async () => {
74
88
  const clack = await import('@clack/prompts')
75
- const multiselectSpy = spyOn(clack, 'multiselect').mockResolvedValue(['anthropic', 'openai'] as any)
89
+ const multiselectSpy = spyOn(clack, 'multiselect').mockResolvedValue(['anthropic', 'openai'])
76
90
 
77
91
  const result = await promptForProviders()
78
92
 
@@ -84,11 +98,13 @@ describe('interactive provider/model prompts', () => {
84
98
  it('edge case: empty selection re-prompts; multiselect called exactly twice', async () => {
85
99
  const clack = await import('@clack/prompts')
86
100
  let callCount = 0
87
- const multiselectSpy = spyOn(clack, 'multiselect').mockImplementation(async () => {
88
- callCount++
89
- if (callCount === 1) return [] as any
90
- return ['anthropic'] as any
91
- })
101
+ const multiselectSpy = spyOn(clack, 'multiselect').mockImplementation(
102
+ asMultiselectImpl<string>(async () => {
103
+ callCount++
104
+ if (callCount === 1) return []
105
+ return ['anthropic']
106
+ }),
107
+ )
92
108
 
93
109
  const result = await promptForProviders()
94
110
 
@@ -101,12 +117,12 @@ describe('interactive provider/model prompts', () => {
101
117
  it('edge case: cancel mid-flow causes process.exit(0)', async () => {
102
118
  const clack = await import('@clack/prompts')
103
119
  const cancelSymbol = Symbol('cancel')
104
- const multiselectSpy = spyOn(clack, 'multiselect').mockResolvedValue(cancelSymbol as any)
120
+ const multiselectSpy = spyOn(clack, 'multiselect').mockResolvedValue(cancelSymbol)
105
121
  const isCancelSpy = spyOn(clack, 'isCancel').mockImplementation(v => v === cancelSymbol)
106
122
  const cancelSpy = spyOn(clack, 'cancel').mockImplementation(() => {})
107
- const exitSpy = spyOn(process, 'exit').mockImplementation((() => {
123
+ const exitSpy = spyOn(process, 'exit').mockImplementation((_code?: number): never => {
108
124
  throw new Error('process.exit called')
109
- }) as any)
125
+ })
110
126
 
111
127
  await expect(promptForProviders()).rejects.toThrow('process.exit called')
112
128
 
@@ -144,7 +160,7 @@ describe('interactive provider/model prompts', () => {
144
160
 
145
161
  it('happy path: both providers, operator picks openai/gpt-5.4-mini from select', async () => {
146
162
  const clack = await import('@clack/prompts')
147
- const selectSpy = spyOn(clack, 'select').mockResolvedValue('openai/gpt-5.4-mini' as any)
163
+ const selectSpy = spyOn(clack, 'select').mockResolvedValue('openai/gpt-5.4-mini')
148
164
 
149
165
  const result = await promptForModel(['anthropic', 'openai'])
150
166
 
@@ -156,7 +172,7 @@ describe('interactive provider/model prompts', () => {
156
172
 
157
173
  it('happy path: both providers, operator picks anthropic/claude-sonnet-4-6 from select', async () => {
158
174
  const clack = await import('@clack/prompts')
159
- const selectSpy = spyOn(clack, 'select').mockResolvedValue('anthropic/claude-sonnet-4-6' as any)
175
+ const selectSpy = spyOn(clack, 'select').mockResolvedValue('anthropic/claude-sonnet-4-6')
160
176
 
161
177
  const result = await promptForModel(['anthropic', 'openai'])
162
178
 
@@ -167,8 +183,8 @@ describe('interactive provider/model prompts', () => {
167
183
 
168
184
  it('happy path: operator picks "enter custom..." then types openai/gpt-5.4-mini', async () => {
169
185
  const clack = await import('@clack/prompts')
170
- const selectSpy = spyOn(clack, 'select').mockResolvedValue('__custom__' as any)
171
- const textSpy = spyOn(clack, 'text').mockResolvedValue('openai/gpt-5.4-mini' as any)
186
+ const selectSpy = spyOn(clack, 'select').mockResolvedValue('__custom__')
187
+ const textSpy = spyOn(clack, 'text').mockResolvedValue('openai/gpt-5.4-mini')
172
188
 
173
189
  const result = await promptForModel(['anthropic', 'openai'])
174
190
 
@@ -181,21 +197,23 @@ describe('interactive provider/model prompts', () => {
181
197
 
182
198
  it('edge case: custom model entry fails regex then succeeds on second attempt', async () => {
183
199
  const clack = await import('@clack/prompts')
184
- const selectSpy = spyOn(clack, 'select').mockResolvedValue('__custom__' as any)
200
+ const selectSpy = spyOn(clack, 'select').mockResolvedValue('__custom__')
185
201
  let textCallCount = 0
186
- const textSpy = spyOn(clack, 'text').mockImplementation(async (_opts: any) => {
187
- textCallCount++
188
- // Simulate the validate function being called inline by the mock
189
- // The real clack text prompt calls validate internally; here we just
190
- // return the value and let the helper's validate logic re-prompt.
191
- // Since we can't simulate clack's internal validate loop, we test
192
- // that the helper's validate function rejects bad input.
193
- if (textCallCount === 1) {
194
- // Return a bad value — the helper should detect this and re-prompt
195
- return 'bad-model' as any
196
- }
197
- return 'openai/gpt-5.4-mini' as any
198
- })
202
+ const textSpy = spyOn(clack, 'text').mockImplementation(
203
+ asTextImpl(async () => {
204
+ textCallCount++
205
+ // Simulate the validate function being called inline by the mock
206
+ // The real clack text prompt calls validate internally; here we just
207
+ // return the value and let the helper's validate logic re-prompt.
208
+ // Since we can't simulate clack's internal validate loop, we test
209
+ // that the helper's validate function rejects bad input.
210
+ if (textCallCount === 1) {
211
+ // Return a bad value — the helper should detect this and re-prompt
212
+ return 'bad-model'
213
+ }
214
+ return 'openai/gpt-5.4-mini'
215
+ }),
216
+ )
199
217
 
200
218
  const result = await promptForModel(['anthropic', 'openai'])
201
219
 
@@ -209,12 +227,12 @@ describe('interactive provider/model prompts', () => {
209
227
  it('edge case: cancel during model select causes process.exit(0)', async () => {
210
228
  const clack = await import('@clack/prompts')
211
229
  const cancelSymbol = Symbol('cancel')
212
- const selectSpy = spyOn(clack, 'select').mockResolvedValue(cancelSymbol as any)
230
+ const selectSpy = spyOn(clack, 'select').mockResolvedValue(cancelSymbol)
213
231
  const isCancelSpy = spyOn(clack, 'isCancel').mockImplementation(v => v === cancelSymbol)
214
232
  const cancelSpy = spyOn(clack, 'cancel').mockImplementation(() => {})
215
- const exitSpy = spyOn(process, 'exit').mockImplementation((() => {
233
+ const exitSpy = spyOn(process, 'exit').mockImplementation((_code?: number): never => {
216
234
  throw new Error('process.exit called')
217
- }) as any)
235
+ })
218
236
 
219
237
  await expect(promptForModel(['anthropic', 'openai'])).rejects.toThrow('process.exit called')
220
238
 
@@ -225,4 +243,3 @@ describe('interactive provider/model prompts', () => {
225
243
  })
226
244
  })
227
245
  })
228
- /* eslint-enable @typescript-eslint/no-explicit-any */
@@ -640,4 +640,182 @@ describe('smoke test runner', () => {
640
640
  expect(result.kind).toBe('pass')
641
641
  expect(result.runUrl).toBe('https://github.com/owner/test-repo/actions/runs/105')
642
642
  })
643
+
644
+ // ── Zod schema validation hardening ──────────────────────────────────────
645
+
646
+ it('poll JSON validation — non-array response degrades to unverified without throwing', async () => {
647
+ const triggerTime = new Date('2026-05-25T10:00:00Z')
648
+
649
+ let callIndex = 0
650
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: unknown[]) => {
651
+ callIndex++
652
+ if (callIndex === 1) {
653
+ // baseline: valid empty list
654
+ return makeSmokeChild('[]', '', 0)
655
+ }
656
+ if (callIndex === 2) {
657
+ // trigger succeeds
658
+ return makeSmokeChild('', '', 0)
659
+ }
660
+ // poll returns a non-array object instead of an array — schema rejects it
661
+ return makeSmokeChild('{"error":"unexpected"}', '', 0)
662
+ })
663
+
664
+ const result = await runSmokeTest(REPO, MODEL, {_testDelayMs: 0, _testTriggerTime: triggerTime})
665
+
666
+ // Schema validation fails → pollRuns stays [] → no candidates → all polls exhaust → unverified
667
+ expect(result.kind).toBe('unverified')
668
+ })
669
+
670
+ it('poll JSON validation — missing required databaseId field degrades gracefully', async () => {
671
+ const triggerTime = new Date('2026-05-25T10:00:00Z')
672
+
673
+ let callIndex = 0
674
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: unknown[]) => {
675
+ callIndex++
676
+ if (callIndex === 1) {
677
+ return makeSmokeChild('[]', '', 0)
678
+ }
679
+ if (callIndex === 2) {
680
+ return makeSmokeChild('', '', 0)
681
+ }
682
+ // poll returns array entries missing databaseId — schema rejects it
683
+ return makeSmokeChild(
684
+ '[{"status":"completed","conclusion":"success","url":"https://x","createdAt":"2026-05-25T10:00:05Z"}]',
685
+ '',
686
+ 0,
687
+ )
688
+ })
689
+
690
+ const result = await runSmokeTest(REPO, MODEL, {_testDelayMs: 0, _testTriggerTime: triggerTime})
691
+
692
+ expect(result.kind).toBe('unverified')
693
+ })
694
+
695
+ it('poll JSON validation — wrong type for databaseId (string instead of number) degrades gracefully', async () => {
696
+ const triggerTime = new Date('2026-05-25T10:00:00Z')
697
+
698
+ let callIndex = 0
699
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: unknown[]) => {
700
+ callIndex++
701
+ if (callIndex === 1) {
702
+ return makeSmokeChild('[]', '', 0)
703
+ }
704
+ if (callIndex === 2) {
705
+ return makeSmokeChild('', '', 0)
706
+ }
707
+ // databaseId is a string, not a number — schema rejects it
708
+ return makeSmokeChild(
709
+ '[{"databaseId":"not-a-number","status":"completed","conclusion":"success","url":"https://x","createdAt":"2026-05-25T10:00:05Z"}]',
710
+ '',
711
+ 0,
712
+ )
713
+ })
714
+
715
+ const result = await runSmokeTest(REPO, MODEL, {_testDelayMs: 0, _testTriggerTime: triggerTime})
716
+
717
+ expect(result.kind).toBe('unverified')
718
+ })
719
+
720
+ it('poll JSON validation — one malformed entry does not discard a valid matching run', async () => {
721
+ const triggerTime = new Date('2026-05-25T10:00:00Z')
722
+ const createdAt = new Date(triggerTime.getTime() + 5000).toISOString()
723
+
724
+ let callIndex = 0
725
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: unknown[]) => {
726
+ callIndex++
727
+ if (callIndex === 1) {
728
+ // baseline: valid empty list → createdAt heuristic
729
+ return makeSmokeChild('[]', '', 0)
730
+ }
731
+ if (callIndex === 2) {
732
+ // trigger succeeds
733
+ return makeSmokeChild('', '', 0)
734
+ }
735
+ if (callIndex === 3) {
736
+ // poll: one malformed entry (databaseId is a string) PLUS one valid matching
737
+ // completed-success run. Per-entry validation must drop only the bad row and
738
+ // keep the good one — whole-batch rejection would lose our run.
739
+ return makeSmokeChild(
740
+ `[{"databaseId":"bad","status":"completed","conclusion":"success","url":"https://x","createdAt":"${
741
+ createdAt
742
+ }"},{"databaseId":105,"status":"completed","conclusion":"success","url":"${RUN_URL}","createdAt":"${
743
+ createdAt
744
+ }"}]`,
745
+ '',
746
+ 0,
747
+ )
748
+ }
749
+ // log view for the matched run → contains "ack"
750
+ return makeSmokeChild('some log output with ack in it', '', 0)
751
+ })
752
+
753
+ const result = await runSmokeTest(REPO, MODEL, {_testDelayMs: 0, _testTriggerTime: triggerTime})
754
+
755
+ // The valid entry survives per-entry validation and is matched → pass.
756
+ expect(result.kind).toBe('pass')
757
+ expect(result.runUrl).toBe(RUN_URL)
758
+ })
759
+
760
+ it('baseline JSON validation — non-array baseline falls back to createdAt heuristic', async () => {
761
+ const triggerTime = new Date('2026-05-25T10:00:00Z')
762
+ const createdAt = new Date(triggerTime.getTime() + 5000).toISOString()
763
+
764
+ let callIndex = 0
765
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: unknown[]) => {
766
+ callIndex++
767
+ if (callIndex === 1) {
768
+ // baseline returns a non-array object — schema rejects it, baselineId stays null
769
+ return makeSmokeChild('{"databaseId":100}', '', 0)
770
+ }
771
+ if (callIndex === 2) {
772
+ return makeSmokeChild('', '', 0)
773
+ }
774
+ if (callIndex === 3) {
775
+ return makeSmokeChild(
776
+ makeSmokeRunList([{databaseId: 1, status: 'completed', conclusion: 'success', url: RUN_URL, createdAt}]),
777
+ '',
778
+ 0,
779
+ )
780
+ }
781
+ return makeSmokeChild('ack', '', 0)
782
+ })
783
+
784
+ const result = await runSmokeTest(REPO, MODEL, {_testDelayMs: 0, _testTriggerTime: triggerTime})
785
+
786
+ // Schema rejects non-array → baselineId stays null → createdAt heuristic → run found → pass
787
+ expect(result.kind).toBe('pass')
788
+ expect(result.runUrl).toBe(RUN_URL)
789
+ })
790
+
791
+ it('baseline JSON validation — missing databaseId in baseline entry falls back to createdAt heuristic', async () => {
792
+ const triggerTime = new Date('2026-05-25T10:00:00Z')
793
+ const createdAt = new Date(triggerTime.getTime() + 5000).toISOString()
794
+
795
+ let callIndex = 0
796
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: unknown[]) => {
797
+ callIndex++
798
+ if (callIndex === 1) {
799
+ // baseline entry missing databaseId — schema rejects it, baselineId stays null
800
+ return makeSmokeChild('[{"id":100}]', '', 0)
801
+ }
802
+ if (callIndex === 2) {
803
+ return makeSmokeChild('', '', 0)
804
+ }
805
+ if (callIndex === 3) {
806
+ return makeSmokeChild(
807
+ makeSmokeRunList([{databaseId: 1, status: 'completed', conclusion: 'success', url: RUN_URL, createdAt}]),
808
+ '',
809
+ 0,
810
+ )
811
+ }
812
+ return makeSmokeChild('ack', '', 0)
813
+ })
814
+
815
+ const result = await runSmokeTest(REPO, MODEL, {_testDelayMs: 0, _testTriggerTime: triggerTime})
816
+
817
+ // Schema rejects missing databaseId → baselineId stays null → createdAt heuristic → run found → pass
818
+ expect(result.kind).toBe('pass')
819
+ expect(result.runUrl).toBe(RUN_URL)
820
+ })
643
821
  })
@@ -1,18 +1,25 @@
1
1
  /// <reference types="bun" />
2
2
 
3
+ import {z} from 'zod'
4
+
3
5
  export type SmokeResult =
4
6
  | {kind: 'pass'; message: string; runUrl: string}
5
7
  | {kind: 'fail'; message: string; runUrl: string}
6
8
  | {kind: 'unverified'; message: string; runUrl?: string}
7
9
 
10
+ // Zod schemas for gh CLI JSON output — single source of truth.
11
+ const baselineRunSchema = z.array(z.object({databaseId: z.number()}))
12
+
13
+ const ghRunEntrySchema = z.object({
14
+ databaseId: z.number(),
15
+ status: z.string(),
16
+ conclusion: z.string().nullable(),
17
+ url: z.string(),
18
+ createdAt: z.string(),
19
+ })
20
+
8
21
  // Exported for tests only.
9
- export interface GhRunEntry {
10
- databaseId: number
11
- status: string
12
- conclusion: string | null
13
- url: string
14
- createdAt: string
15
- }
22
+ export type GhRunEntry = z.infer<typeof ghRunEntrySchema>
16
23
 
17
24
  // Exported for tests only. Override poll delays and trigger time.
18
25
  export interface SmokeTestInternals {
@@ -66,10 +73,11 @@ export async function runSmokeTest(
66
73
  baselineChild.exited,
67
74
  ])
68
75
  if (baselineExit === 0) {
69
- const parsed = JSON.parse(baselineStdout) as {databaseId: number}[]
70
- if (parsed.length > 0 && parsed[0]) {
71
- baselineId = parsed[0].databaseId
76
+ const parseResult = baselineRunSchema.safeParse(JSON.parse(baselineStdout))
77
+ if (parseResult.success && parseResult.data.length > 0 && parseResult.data[0]) {
78
+ baselineId = parseResult.data[0].databaseId
72
79
  }
80
+ // If schema validation fails, baselineId stays null — we'll use createdAt heuristic
73
81
  }
74
82
  // If baseline call fails, baselineId stays null — we'll use createdAt heuristic
75
83
  } catch {
@@ -123,7 +131,17 @@ export async function runSmokeTest(
123
131
  pollChild.exited,
124
132
  ])
125
133
  if (pollExit === 0) {
126
- pollRuns = JSON.parse(pollStdout) as GhRunEntry[]
134
+ const rawParsed: unknown = JSON.parse(pollStdout)
135
+ if (Array.isArray(rawParsed)) {
136
+ // Validate each entry independently so a single malformed row does not
137
+ // discard the whole batch — dropping a legitimate matching run would be
138
+ // worse than skipping the bad entry.
139
+ pollRuns = rawParsed.flatMap(entry => {
140
+ const entryResult = ghRunEntrySchema.safeParse(entry)
141
+ return entryResult.success ? [entryResult.data] : []
142
+ })
143
+ }
144
+ // Non-array payload or all entries malformed → pollRuns stays [] — retry on next poll
127
145
  }
128
146
  } catch {
129
147
  // Parse/network error — retry on next poll