@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 +1 -1
- package/src/__snapshots__/cli.test.ts.snap +19 -1
- package/src/cli.ts +2 -0
- package/src/commands/cliproxy/setup/providers.test.ts +50 -33
- package/src/commands/cliproxy/setup/smoke-test.test.ts +178 -0
- package/src/commands/cliproxy/setup/smoke-test.ts +29 -11
- package/src/commands/cliproxy/setup.test.ts +454 -48
- package/src/commands/cliproxy/setup.ts +128 -4
- package/src/commands/mcp.test.ts +5 -1
- package/src/commands/mcp.ts +3 -0
- package/src/commands/status.test.ts +36 -0
- package/src/commands/status.ts +10 -3
- package/src/commands/umami/deploy.test.ts +202 -0
- package/src/commands/umami/deploy.ts +132 -0
- package/src/commands/umami/host.test.ts +62 -0
- package/src/commands/umami/host.ts +31 -0
- package/src/commands/umami/index.ts +13 -0
- package/src/commands/umami/logs.test.ts +154 -0
- package/src/commands/umami/logs.ts +161 -0
- package/src/commands/umami/status.test.ts +387 -0
- package/src/commands/umami/status.ts +267 -0
|
@@ -90,6 +90,8 @@ export interface RunSetupDeps {
|
|
|
90
90
|
intro: typeof intro
|
|
91
91
|
note: typeof note
|
|
92
92
|
outro: typeof outro
|
|
93
|
+
promptForProviders?: typeof promptForProviders
|
|
94
|
+
promptForModel?: typeof promptForModel
|
|
93
95
|
}
|
|
94
96
|
smoke?: {
|
|
95
97
|
runSmokeTest: typeof runSmokeTest
|
|
@@ -105,13 +107,121 @@ function resolveBaseUrl(input?: string): string {
|
|
|
105
107
|
return stripTrailingSlash(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
|
|
106
108
|
}
|
|
107
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Emit a warning without ever throwing. Used inside verifyWrittenNamesVisible so that a
|
|
112
|
+
* failure in warning emission itself cannot escape to the outer write catch and wrongly
|
|
113
|
+
* roll back a key whose secrets are already written.
|
|
114
|
+
*/
|
|
115
|
+
function safeWarn(message: string): void {
|
|
116
|
+
try {
|
|
117
|
+
log.warn(message)
|
|
118
|
+
} catch {
|
|
119
|
+
// Warning emission must never escape the post-write readback — a throw here would
|
|
120
|
+
// reach the outer write catch and wrongly roll back a key whose secrets are written.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildCannotVerifyMessage(repo: string, writtenSecretNames: string[], writtenVariableNames: string[]): string {
|
|
125
|
+
const secretPart =
|
|
126
|
+
writtenSecretNames.length > 0
|
|
127
|
+
? `gh secret list --repo ${repo} (expect: ${writtenSecretNames.join(', ')})`
|
|
128
|
+
: `gh secret list --repo ${repo}`
|
|
129
|
+
const variablePart =
|
|
130
|
+
writtenVariableNames.length > 0
|
|
131
|
+
? `gh variable list --repo ${repo} (expect: ${writtenVariableNames.join(', ')})`
|
|
132
|
+
: `gh variable list --repo ${repo}`
|
|
133
|
+
return (
|
|
134
|
+
`Post-write readback: could not verify the written names are visible in ${repo} ` +
|
|
135
|
+
`(the GitHub list call failed). Verify manually: ${secretPart}; ${variablePart}.`
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* After a successful write, re-list secret and variable names and warn if any written name
|
|
141
|
+
* is absent on readback — signaling an unreliable token list view that may have bypassed
|
|
142
|
+
* the pre-write safety gates.
|
|
143
|
+
*
|
|
144
|
+
* Distinguishes:
|
|
145
|
+
* - Verified mismatch: readback succeeded but a written name is absent (strong signal).
|
|
146
|
+
* - Cannot verify: the readback gh call itself failed (weaker signal).
|
|
147
|
+
*
|
|
148
|
+
* NEVER throws. The entire body is wrapped in a single try/catch so any failure — including
|
|
149
|
+
* errors during diff computation or warning emission — degrades to the cannot-verify warning.
|
|
150
|
+
* A throw here would propagate to the mutationError rollback and wrongly delete a key whose
|
|
151
|
+
* secrets are already written.
|
|
152
|
+
*/
|
|
153
|
+
async function verifyWrittenNamesVisible(
|
|
154
|
+
repo: string,
|
|
155
|
+
writtenSecretNames: string[],
|
|
156
|
+
writtenVariableNames: string[],
|
|
157
|
+
listExistingGhNames: typeof import('./setup/gh').listExistingGhNames,
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
try {
|
|
160
|
+
let secretReadback: string[]
|
|
161
|
+
let variableReadback: string[]
|
|
162
|
+
let readbackFailed = false
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
secretReadback = await listExistingGhNames(repo, 'secret')
|
|
166
|
+
} catch {
|
|
167
|
+
readbackFailed = true
|
|
168
|
+
secretReadback = []
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (readbackFailed) {
|
|
172
|
+
variableReadback = []
|
|
173
|
+
} else {
|
|
174
|
+
try {
|
|
175
|
+
variableReadback = await listExistingGhNames(repo, 'variable')
|
|
176
|
+
} catch {
|
|
177
|
+
readbackFailed = true
|
|
178
|
+
variableReadback = []
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (readbackFailed) {
|
|
183
|
+
safeWarn(buildCannotVerifyMessage(repo, writtenSecretNames, writtenVariableNames))
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const absentSecrets = writtenSecretNames.filter(name => !secretReadback.includes(name))
|
|
188
|
+
const absentVariables = writtenVariableNames.filter(name => !variableReadback.includes(name))
|
|
189
|
+
|
|
190
|
+
if (absentSecrets.length === 0 && absentVariables.length === 0) {
|
|
191
|
+
// Happy path: all written names are visible. Emit nothing.
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const lines: string[] = [
|
|
196
|
+
`Post-write readback: the following written names are not visible in ${repo} — ` +
|
|
197
|
+
`the token's list view may be unreliable and the pre-write safety gates may have been bypassed.`,
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
if (absentSecrets.length > 0) {
|
|
201
|
+
lines.push(` Absent secrets: ${absentSecrets.join(', ')}`)
|
|
202
|
+
lines.push(` Verify manually: gh secret list --repo ${repo}`)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (absentVariables.length > 0) {
|
|
206
|
+
lines.push(` Absent variables: ${absentVariables.join(', ')}`)
|
|
207
|
+
lines.push(` Verify manually: gh variable list --repo ${repo}`)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
safeWarn(lines.join('\n'))
|
|
211
|
+
} catch {
|
|
212
|
+
// Any failure in the entire verification block (readback, diff, or warning emission)
|
|
213
|
+
// degrades to this softer cannot-verify warning. Never re-throw.
|
|
214
|
+
safeWarn(buildCannotVerifyMessage(repo, writtenSecretNames, writtenVariableNames))
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
108
218
|
function extractErrorMessage(error: unknown): string {
|
|
109
219
|
return error instanceof Error ? error.message : String(error)
|
|
110
220
|
}
|
|
111
221
|
|
|
112
222
|
// Redact a bearer token for display in interactive prompts — never show raw key values.
|
|
113
223
|
// Exported for direct unit testing of the redaction contract. The redacted form is
|
|
114
|
-
// what gets shown in the interactive
|
|
224
|
+
// what gets shown in the interactive key-reuse prompt; the raw key must never reach the prompt UI.
|
|
115
225
|
export function redactKey(key: string): string {
|
|
116
226
|
if (key.length < 12) return 'sk-***'
|
|
117
227
|
return `${key.slice(0, 3)}***${key.slice(-4)}`
|
|
@@ -172,8 +282,10 @@ async function buildInteractivePlan(
|
|
|
172
282
|
let model: string | undefined
|
|
173
283
|
|
|
174
284
|
if (harness === 'opencode') {
|
|
175
|
-
|
|
176
|
-
|
|
285
|
+
const doPromptForProviders = promptsImpl.promptForProviders ?? promptForProviders
|
|
286
|
+
const doPromptForModel = promptsImpl.promptForModel ?? promptForModel
|
|
287
|
+
providers = await doPromptForProviders()
|
|
288
|
+
model = await doPromptForModel(providers)
|
|
177
289
|
}
|
|
178
290
|
|
|
179
291
|
const keyValue = options.key ?? buildApiKeyValue(keyName ?? 'cliproxy')
|
|
@@ -271,6 +383,8 @@ const realPrompts: Required<RunSetupDeps>['prompts'] = {
|
|
|
271
383
|
intro,
|
|
272
384
|
note,
|
|
273
385
|
outro,
|
|
386
|
+
promptForProviders,
|
|
387
|
+
promptForModel,
|
|
274
388
|
}
|
|
275
389
|
|
|
276
390
|
const realSmoke: Required<RunSetupDeps>['smoke'] = {
|
|
@@ -437,7 +551,10 @@ export async function runSetupCommand(options: SetupOptions, deps: RunSetupDeps
|
|
|
437
551
|
}
|
|
438
552
|
|
|
439
553
|
if (!interactive && options.force) {
|
|
440
|
-
log.warn(
|
|
554
|
+
log.warn(
|
|
555
|
+
`Overwriting existing GitHub values: ${collisions.join(', ')}. ` +
|
|
556
|
+
`Concurrent setup runs against the same repo are not coordinated and resolve last-write-wins — don't run setup against this repo from two places at once.`,
|
|
557
|
+
)
|
|
441
558
|
// proceed
|
|
442
559
|
}
|
|
443
560
|
|
|
@@ -486,6 +603,13 @@ export async function runSetupCommand(options: SetupOptions, deps: RunSetupDeps
|
|
|
486
603
|
interactive,
|
|
487
604
|
)
|
|
488
605
|
|
|
606
|
+
await verifyWrittenNamesVisible(
|
|
607
|
+
plan.repo,
|
|
608
|
+
plan.template.secrets.map(s => s.name),
|
|
609
|
+
plan.template.variables.map(v => v.name),
|
|
610
|
+
gh.listExistingGhNames,
|
|
611
|
+
)
|
|
612
|
+
|
|
489
613
|
await withSpinner('Verifying the new key through the proxy', async () => {
|
|
490
614
|
await validation.assertProxyKeyWorks(baseUrl, plan.keyValue)
|
|
491
615
|
})
|
package/src/commands/mcp.test.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {registerGatewayCommands} from './gateway'
|
|
|
37
37
|
import {registerKeewebCommands} from './keeweb'
|
|
38
38
|
import {MCP_ALLOWLIST, registerMcp} from './mcp'
|
|
39
39
|
import {registerStatus} from './status'
|
|
40
|
+
import {registerUmamiCommands} from './umami'
|
|
40
41
|
|
|
41
42
|
// ─── Tool name constants ──────────────────────────────────────────────────────
|
|
42
43
|
|
|
@@ -54,6 +55,8 @@ const CLI_ONLY_TOOLS = [
|
|
|
54
55
|
'gateway_restore',
|
|
55
56
|
'keeweb_deploy',
|
|
56
57
|
'keeweb_open',
|
|
58
|
+
'umami_deploy',
|
|
59
|
+
'umami_logs',
|
|
57
60
|
].sort()
|
|
58
61
|
|
|
59
62
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -64,6 +67,7 @@ function buildTestCli(): ReturnType<typeof goke> {
|
|
|
64
67
|
registerKeewebCommands(cli)
|
|
65
68
|
registerCliproxyCommands(cli)
|
|
66
69
|
registerGatewayCommands(cli)
|
|
70
|
+
registerUmamiCommands(cli)
|
|
67
71
|
registerStatus(cli)
|
|
68
72
|
registerMcp(cli)
|
|
69
73
|
return cli
|
|
@@ -105,7 +109,7 @@ describe('mcp integration (Tier-1, in-process)', () => {
|
|
|
105
109
|
|
|
106
110
|
// ── tools/list assertions ──────────────────────────────────────────────────
|
|
107
111
|
|
|
108
|
-
test('tools/list returns exactly the
|
|
112
|
+
test('tools/list returns exactly the allowlist tool names', async () => {
|
|
109
113
|
const result = await client.listTools()
|
|
110
114
|
const names = result.tools.map((t: {name: string}) => t.name).sort()
|
|
111
115
|
expect(names).toEqual(EXPECTED_TOOLS)
|
package/src/commands/mcp.ts
CHANGED
|
@@ -13,6 +13,8 @@ import {createMcpAction} from '@goke/mcp'
|
|
|
13
13
|
* - `cliproxy setup` — interactive (@clack/prompts wizard, requires TTY)
|
|
14
14
|
* - `gateway restore` — destructive policy (replaces mitmproxy CA on live gateway, deferred to MCP v2 #292)
|
|
15
15
|
* - `keeweb open` — host-machine side effect (spawns local browser, requires user intent)
|
|
16
|
+
* - `umami deploy` — intentionally CLI-only: mutates live deployment and requires environment approval
|
|
17
|
+
* - `umami logs` — intentionally CLI-only: streams logs that may emit sensitive data (DB passwords, app secrets)
|
|
16
18
|
*/
|
|
17
19
|
export const MCP_ALLOWLIST: ReadonlySet<string> = new Set([
|
|
18
20
|
'gateway status',
|
|
@@ -24,6 +26,7 @@ export const MCP_ALLOWLIST: ReadonlySet<string> = new Set([
|
|
|
24
26
|
'cliproxy config get',
|
|
25
27
|
'cliproxy config set',
|
|
26
28
|
'keeweb status',
|
|
29
|
+
'umami status',
|
|
27
30
|
'status',
|
|
28
31
|
])
|
|
29
32
|
|
|
@@ -31,11 +31,21 @@ const healthyGateway: StatusSummary = {
|
|
|
31
31
|
usageStats: '—',
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
const healthyUmami: StatusSummary = {
|
|
35
|
+
app: 'umami',
|
|
36
|
+
http: 'OK: umami:running/healthy',
|
|
37
|
+
lastDeploy: '—',
|
|
38
|
+
version: '—',
|
|
39
|
+
contentHash: '—',
|
|
40
|
+
usageStats: '—',
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
function makeDeps(overrides?: Partial<Parameters<typeof registerStatus>[1]>): Parameters<typeof registerStatus>[1] {
|
|
35
44
|
return {
|
|
36
45
|
getKeewebStatusSummary: async () => healthyKeeweb,
|
|
37
46
|
getCliproxyStatusSummary: async () => healthyCliproxy,
|
|
38
47
|
getGatewayStatusSummary: async () => healthyGateway,
|
|
48
|
+
getUmamiStatusSummary: async () => healthyUmami,
|
|
39
49
|
...overrides,
|
|
40
50
|
}
|
|
41
51
|
}
|
|
@@ -52,6 +62,30 @@ describe('top-level status command (Tier-2 ctx capture)', () => {
|
|
|
52
62
|
expect(expectCapturedToInclude(captured, '| keeweb | OK | 2026-04-12 10:00 | — | match | — |')).toBe(true)
|
|
53
63
|
expect(expectCapturedToInclude(captured, '| cliproxy | OK | — | v1.2.3 | — | 12 req / 0 fail |')).toBe(true)
|
|
54
64
|
expect(expectCapturedToInclude(captured, '| gateway | OK: gateway:running/healthy | — | — | — | — |')).toBe(true)
|
|
65
|
+
expect(expectCapturedToInclude(captured, '| umami | OK: umami:running/healthy | — | — | — | — |')).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('shows an error row when umami is unreachable and keeps the other results', async () => {
|
|
69
|
+
const {ctx, captured} = createCapturedCtx()
|
|
70
|
+
|
|
71
|
+
await unifiedStatusAction(
|
|
72
|
+
{},
|
|
73
|
+
ctx,
|
|
74
|
+
makeDeps({
|
|
75
|
+
getUmamiStatusSummary: async () => {
|
|
76
|
+
throw new Error('UMAMI_DOMAIN not set')
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
expect(
|
|
82
|
+
expectCapturedToInclude(
|
|
83
|
+
captured,
|
|
84
|
+
'| umami | ❌ UMAMI_DOMAIN not set | ❌ UMAMI_DOMAIN not set | ❌ UMAMI_DOMAIN not set | ❌ UMAMI_DOMAIN not set | ❌ UMAMI_DOMAIN not set |',
|
|
85
|
+
),
|
|
86
|
+
).toBe(true)
|
|
87
|
+
expect(expectCapturedToInclude(captured, '| gateway | OK: gateway:running/healthy | — | — | — | — |')).toBe(true)
|
|
88
|
+
expect(captured.stderr.join('')).toContain('umami status check failed: UMAMI_DOMAIN not set')
|
|
55
89
|
})
|
|
56
90
|
|
|
57
91
|
it('shows an error row when one app fails and keeps the other results', async () => {
|
|
@@ -92,11 +126,13 @@ describe('top-level status command (Tier-2 ctx capture)', () => {
|
|
|
92
126
|
keeweb: {http: string}
|
|
93
127
|
cliproxy: {version: string}
|
|
94
128
|
gateway: {http: string}
|
|
129
|
+
umami: {http: string}
|
|
95
130
|
}
|
|
96
131
|
|
|
97
132
|
expect(parsed.keeweb.http).toBe('OK')
|
|
98
133
|
expect(parsed.cliproxy.version).toBe('v1.2.3')
|
|
99
134
|
expect(parsed.gateway.http).toBe('OK: gateway:running/healthy')
|
|
135
|
+
expect(parsed.umami.http).toBe('OK: umami:running/healthy')
|
|
100
136
|
})
|
|
101
137
|
|
|
102
138
|
it('does not write to global console (output is captured via ctx)', async () => {
|
package/src/commands/status.ts
CHANGED
|
@@ -7,13 +7,14 @@ import {z} from 'zod'
|
|
|
7
7
|
import {getCliproxyStatusSummary} from './cliproxy/status'
|
|
8
8
|
import {getGatewayStatusSummary} from './gateway'
|
|
9
9
|
import {getKeewebStatusSummary} from './keeweb/status'
|
|
10
|
+
import {getUmamiStatusSummary} from './umami'
|
|
10
11
|
|
|
11
12
|
declare const process: {
|
|
12
13
|
env: Record<string, string | undefined>
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export interface StatusSummary {
|
|
16
|
-
app: 'keeweb' | 'cliproxy' | 'gateway'
|
|
17
|
+
app: 'keeweb' | 'cliproxy' | 'gateway' | 'umami'
|
|
17
18
|
http: string
|
|
18
19
|
lastDeploy: string
|
|
19
20
|
version: string
|
|
@@ -27,6 +28,7 @@ interface StatusDependencies {
|
|
|
27
28
|
getKeewebStatusSummary: (verbose: boolean) => Promise<StatusSummary>
|
|
28
29
|
getCliproxyStatusSummary: (baseUrl: string, key: string, verbose: boolean) => Promise<StatusSummary>
|
|
29
30
|
getGatewayStatusSummary: (host: string) => Promise<StatusSummary>
|
|
31
|
+
getUmamiStatusSummary: (host: string) => Promise<StatusSummary>
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
|
|
@@ -63,6 +65,7 @@ function toJsonPayload(rows: StatusSummary[]): Record<AppName, StatusSummary> {
|
|
|
63
65
|
keeweb: rows.find(row => row.app === 'keeweb') ?? errorSummary('keeweb', 'missing result'),
|
|
64
66
|
cliproxy: rows.find(row => row.app === 'cliproxy') ?? errorSummary('cliproxy', 'missing result'),
|
|
65
67
|
gateway: rows.find(row => row.app === 'gateway') ?? errorSummary('gateway', 'missing result'),
|
|
68
|
+
umami: rows.find(row => row.app === 'umami') ?? errorSummary('umami', 'missing result'),
|
|
66
69
|
}
|
|
67
70
|
}
|
|
68
71
|
|
|
@@ -78,20 +81,23 @@ export async function unifiedStatusAction(
|
|
|
78
81
|
getKeewebStatusSummary,
|
|
79
82
|
getCliproxyStatusSummary,
|
|
80
83
|
getGatewayStatusSummary,
|
|
84
|
+
getUmamiStatusSummary,
|
|
81
85
|
},
|
|
82
86
|
): Promise<void> {
|
|
83
87
|
const verbose = options.verbose === true
|
|
84
88
|
const cliproxyBaseUrl = stripTrailingSlash(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
|
|
85
89
|
const cliproxyKey = process.env.CLIPROXY_MANAGEMENT_KEY ?? ''
|
|
86
90
|
const gatewayHost = process.env.GATEWAY_HOST ?? ''
|
|
91
|
+
const umamiHost = process.env.UMAMI_DOMAIN ?? ''
|
|
87
92
|
|
|
88
93
|
const results = await Promise.allSettled([
|
|
89
94
|
dependencies.getKeewebStatusSummary(verbose),
|
|
90
95
|
dependencies.getCliproxyStatusSummary(cliproxyBaseUrl, cliproxyKey, verbose),
|
|
91
96
|
dependencies.getGatewayStatusSummary(gatewayHost),
|
|
97
|
+
dependencies.getUmamiStatusSummary(umamiHost),
|
|
92
98
|
])
|
|
93
99
|
|
|
94
|
-
const appNames: AppName[] = ['keeweb', 'cliproxy', 'gateway']
|
|
100
|
+
const appNames: AppName[] = ['keeweb', 'cliproxy', 'gateway', 'umami']
|
|
95
101
|
const rows: StatusSummary[] = results.map((result, index) => {
|
|
96
102
|
const app = appNames[index] ?? 'keeweb'
|
|
97
103
|
if (result.status === 'fulfilled') {
|
|
@@ -120,13 +126,14 @@ export function registerStatus(
|
|
|
120
126
|
getKeewebStatusSummary,
|
|
121
127
|
getCliproxyStatusSummary,
|
|
122
128
|
getGatewayStatusSummary,
|
|
129
|
+
getUmamiStatusSummary,
|
|
123
130
|
},
|
|
124
131
|
): void {
|
|
125
132
|
cli
|
|
126
133
|
.command('status', 'Show status of all deployments')
|
|
127
134
|
.option(
|
|
128
135
|
'--json',
|
|
129
|
-
z.boolean().describe('Output machine-readable JSON with keeweb, cliproxy, and
|
|
136
|
+
z.boolean().describe('Output machine-readable JSON with keeweb, cliproxy, gateway, and umami summary objects.'),
|
|
130
137
|
)
|
|
131
138
|
.option(
|
|
132
139
|
'--verbose',
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import {resolve} from 'node:path'
|
|
2
|
+
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
3
|
+
|
|
4
|
+
import {getUmamiDeployEnv, validateUmamiRemotePreconditions} from './deploy'
|
|
5
|
+
|
|
6
|
+
const repoRoot = resolve(import.meta.dir, '../../../../..')
|
|
7
|
+
|
|
8
|
+
const envKeys = [
|
|
9
|
+
'HOME',
|
|
10
|
+
'PATH',
|
|
11
|
+
'SSH_AUTH_SOCK',
|
|
12
|
+
'UMAMI_DOMAIN',
|
|
13
|
+
'UMAMI_APP_SECRET',
|
|
14
|
+
'UMAMI_DB_PASSWORD',
|
|
15
|
+
'UMAMI_ADMIN_PASSWORD',
|
|
16
|
+
'UMAMI_SSH_KEY',
|
|
17
|
+
] as const
|
|
18
|
+
|
|
19
|
+
type ManagedEnvKey = (typeof envKeys)[number]
|
|
20
|
+
|
|
21
|
+
let originalEnv: Partial<Record<ManagedEnvKey, string | undefined>>
|
|
22
|
+
|
|
23
|
+
function restoreManagedEnv(): void {
|
|
24
|
+
for (const key of envKeys) {
|
|
25
|
+
const value = originalEnv[key]
|
|
26
|
+
if (value === undefined) {
|
|
27
|
+
delete process.env[key]
|
|
28
|
+
} else {
|
|
29
|
+
process.env[key] = value
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function setManagedEnv(overrides: Partial<Record<ManagedEnvKey, string | undefined>>): void {
|
|
35
|
+
restoreManagedEnv()
|
|
36
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
37
|
+
if (value === undefined) {
|
|
38
|
+
delete process.env[key as ManagedEnvKey]
|
|
39
|
+
} else {
|
|
40
|
+
process.env[key as ManagedEnvKey] = value
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
originalEnv = {}
|
|
47
|
+
for (const key of envKeys) {
|
|
48
|
+
originalEnv[key] = process.env[key]
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
restoreManagedEnv()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// ─── getUmamiDeployEnv ────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
describe('getUmamiDeployEnv', () => {
|
|
59
|
+
it('returns env object with required keys when all are set', () => {
|
|
60
|
+
setManagedEnv({
|
|
61
|
+
PATH: '/usr/bin:/bin',
|
|
62
|
+
HOME: '/home/user',
|
|
63
|
+
SSH_AUTH_SOCK: '/tmp/ssh-agent.sock',
|
|
64
|
+
UMAMI_DOMAIN: 'metrics.fro.bot',
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const env = getUmamiDeployEnv()
|
|
68
|
+
|
|
69
|
+
expect(env.PATH).toBe('/usr/bin:/bin')
|
|
70
|
+
expect(env.HOME).toBe('/home/user')
|
|
71
|
+
expect(env.SSH_AUTH_SOCK).toBe('/tmp/ssh-agent.sock')
|
|
72
|
+
expect(env.UMAMI_DOMAIN).toBe('metrics.fro.bot')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('throws when PATH is missing', () => {
|
|
76
|
+
setManagedEnv({PATH: undefined, HOME: '/home/user', SSH_AUTH_SOCK: '/tmp/ssh-agent.sock'})
|
|
77
|
+
|
|
78
|
+
expect(() => getUmamiDeployEnv()).toThrow('PATH is required')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('throws when HOME is missing', () => {
|
|
82
|
+
setManagedEnv({PATH: '/usr/bin:/bin', HOME: undefined, SSH_AUTH_SOCK: '/tmp/ssh-agent.sock'})
|
|
83
|
+
|
|
84
|
+
expect(() => getUmamiDeployEnv()).toThrow('HOME is required')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('throws when SSH_AUTH_SOCK is missing and no UMAMI_SSH_KEY either', () => {
|
|
88
|
+
setManagedEnv({
|
|
89
|
+
PATH: '/usr/bin:/bin',
|
|
90
|
+
HOME: '/home/user',
|
|
91
|
+
SSH_AUTH_SOCK: undefined,
|
|
92
|
+
UMAMI_SSH_KEY: undefined,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
expect(() => getUmamiDeployEnv()).toThrow('Local deploy needs an SSH context')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('succeeds with only SSH_AUTH_SOCK set (no UMAMI_SSH_KEY)', () => {
|
|
99
|
+
setManagedEnv({
|
|
100
|
+
PATH: '/usr/bin:/bin',
|
|
101
|
+
HOME: '/home/user',
|
|
102
|
+
SSH_AUTH_SOCK: '/tmp/ssh-agent.sock',
|
|
103
|
+
UMAMI_SSH_KEY: undefined,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const env = getUmamiDeployEnv()
|
|
107
|
+
|
|
108
|
+
expect(env.SSH_AUTH_SOCK).toBe('/tmp/ssh-agent.sock')
|
|
109
|
+
expect('UMAMI_SSH_KEY' in env).toBe(false)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('succeeds with only UMAMI_SSH_KEY set (no SSH_AUTH_SOCK) and includes the key in env', () => {
|
|
113
|
+
setManagedEnv({
|
|
114
|
+
PATH: '/usr/bin:/bin',
|
|
115
|
+
HOME: '/home/user',
|
|
116
|
+
SSH_AUTH_SOCK: undefined,
|
|
117
|
+
UMAMI_SSH_KEY: 'ssh-ed25519 AAAA...',
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const env = getUmamiDeployEnv()
|
|
121
|
+
|
|
122
|
+
expect(env.UMAMI_SSH_KEY).toBe('ssh-ed25519 AAAA...')
|
|
123
|
+
expect('SSH_AUTH_SOCK' in env).toBe(false)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('includes optional umami env vars when set', () => {
|
|
127
|
+
setManagedEnv({
|
|
128
|
+
PATH: '/usr/bin:/bin',
|
|
129
|
+
HOME: '/home/user',
|
|
130
|
+
SSH_AUTH_SOCK: '/tmp/ssh-agent.sock',
|
|
131
|
+
UMAMI_APP_SECRET: 'secret123',
|
|
132
|
+
UMAMI_DB_PASSWORD: 'dbpass',
|
|
133
|
+
UMAMI_ADMIN_PASSWORD: 'adminpass',
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const env = getUmamiDeployEnv()
|
|
137
|
+
|
|
138
|
+
expect(env.UMAMI_APP_SECRET).toBe('secret123')
|
|
139
|
+
expect(env.UMAMI_DB_PASSWORD).toBe('dbpass')
|
|
140
|
+
expect(env.UMAMI_ADMIN_PASSWORD).toBe('adminpass')
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// ─── validateUmamiRemotePreconditions ─────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
describe('validateUmamiRemotePreconditions', () => {
|
|
147
|
+
it('throws a clear error when gh is not available', () => {
|
|
148
|
+
// We cannot reliably mock Bun.which, so we test the function contract:
|
|
149
|
+
// if gh is not installed, it should throw with a helpful message.
|
|
150
|
+
// This test verifies the error message shape by calling with a known-missing binary.
|
|
151
|
+
// In CI where gh IS installed, we skip this test.
|
|
152
|
+
if (Bun.which('gh')) {
|
|
153
|
+
// gh is available — just verify the function does not throw
|
|
154
|
+
expect(() => validateUmamiRemotePreconditions()).not.toThrow()
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
expect(() => validateUmamiRemotePreconditions()).toThrow('gh CLI is required')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// ─── deploy command (subprocess integration via CLI) ─────────────────────────
|
|
163
|
+
|
|
164
|
+
describe('deploy command', () => {
|
|
165
|
+
it('dry-run remote mode prints planned gh workflow run command without executing', async () => {
|
|
166
|
+
const proc = Bun.spawn(['bun', 'run', 'packages/cli/src/cli.ts', 'umami', 'deploy', '--dry-run'], {
|
|
167
|
+
cwd: repoRoot,
|
|
168
|
+
env: {...process.env, NO_COLOR: '1'},
|
|
169
|
+
stdout: 'pipe',
|
|
170
|
+
stderr: 'pipe',
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const [stdout, _stderr, exitCode] = await Promise.all([
|
|
174
|
+
new Response(proc.stdout).text(),
|
|
175
|
+
new Response(proc.stderr).text(),
|
|
176
|
+
proc.exited,
|
|
177
|
+
])
|
|
178
|
+
|
|
179
|
+
expect(exitCode).toBe(0)
|
|
180
|
+
expect(stdout).toContain('Dry run')
|
|
181
|
+
expect(stdout).toContain('Deploy Umami')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('dry-run local mode prints planned bun command without executing', async () => {
|
|
185
|
+
const proc = Bun.spawn(['bun', 'run', 'packages/cli/src/cli.ts', 'umami', 'deploy', '--local', '--dry-run'], {
|
|
186
|
+
cwd: repoRoot,
|
|
187
|
+
env: {...process.env, NO_COLOR: '1'},
|
|
188
|
+
stdout: 'pipe',
|
|
189
|
+
stderr: 'pipe',
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
const [stdout, _stderr, exitCode] = await Promise.all([
|
|
193
|
+
new Response(proc.stdout).text(),
|
|
194
|
+
new Response(proc.stderr).text(),
|
|
195
|
+
proc.exited,
|
|
196
|
+
])
|
|
197
|
+
|
|
198
|
+
expect(exitCode).toBe(0)
|
|
199
|
+
expect(stdout).toContain('Dry run')
|
|
200
|
+
expect(stdout).toContain('apps/umami')
|
|
201
|
+
})
|
|
202
|
+
})
|