@marcusrbrown/infra 0.8.1 → 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/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
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type {goke} from 'goke'
|
|
2
|
+
|
|
3
|
+
import {z} from 'zod'
|
|
4
|
+
|
|
5
|
+
declare const process: {
|
|
6
|
+
env: Record<string, string | undefined>
|
|
7
|
+
exitCode?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const REPO = 'marcusrbrown/infra'
|
|
11
|
+
const WORKFLOW_NAME = 'Deploy Umami'
|
|
12
|
+
const WORKFLOW_URL = 'https://github.com/marcusrbrown/infra/actions/workflows/deploy-umami.yaml'
|
|
13
|
+
|
|
14
|
+
type CliInstance = ReturnType<typeof goke>
|
|
15
|
+
|
|
16
|
+
// ─── Env helpers ─────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export function getUmamiDeployEnv(): Record<string, string> {
|
|
19
|
+
const path = process.env.PATH
|
|
20
|
+
const home = process.env.HOME
|
|
21
|
+
const sshAuthSock = process.env.SSH_AUTH_SOCK
|
|
22
|
+
const sshKey = process.env.UMAMI_SSH_KEY
|
|
23
|
+
|
|
24
|
+
if (!path) {
|
|
25
|
+
throw new Error('PATH is required for local deploy')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!home) {
|
|
29
|
+
throw new Error('HOME is required for local deploy')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!sshAuthSock && !sshKey) {
|
|
33
|
+
throw new Error('Local deploy needs an SSH context: set SSH_AUTH_SOCK (ssh-agent) or UMAMI_SSH_KEY (key from env).')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
PATH: path,
|
|
38
|
+
HOME: home,
|
|
39
|
+
...(sshAuthSock ? {SSH_AUTH_SOCK: sshAuthSock} : {}),
|
|
40
|
+
UMAMI_DOMAIN: process.env.UMAMI_DOMAIN ?? '',
|
|
41
|
+
UMAMI_APP_SECRET: process.env.UMAMI_APP_SECRET ?? '',
|
|
42
|
+
UMAMI_DB_PASSWORD: process.env.UMAMI_DB_PASSWORD ?? '',
|
|
43
|
+
UMAMI_ADMIN_PASSWORD: process.env.UMAMI_ADMIN_PASSWORD ?? '',
|
|
44
|
+
...(sshKey ? {UMAMI_SSH_KEY: sshKey} : {}),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function validateUmamiRemotePreconditions(): void {
|
|
49
|
+
if (!Bun.which('gh')) {
|
|
50
|
+
throw new Error('gh CLI is required for remote deploy. Install gh and run `gh auth login`.')
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Command registration ─────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export function registerUmamiDeploy(cli: CliInstance): void {
|
|
57
|
+
cli
|
|
58
|
+
.command(
|
|
59
|
+
'umami deploy',
|
|
60
|
+
'Deploy Umami analytics. Default mode triggers the GitHub Deploy Umami workflow, while --local runs apps/umami deploy directly with Bun.',
|
|
61
|
+
)
|
|
62
|
+
.option(
|
|
63
|
+
'--local',
|
|
64
|
+
z
|
|
65
|
+
.boolean()
|
|
66
|
+
.default(false)
|
|
67
|
+
.describe('Run local deployment with Bun using apps/umami instead of triggering GitHub Actions.'),
|
|
68
|
+
)
|
|
69
|
+
.option(
|
|
70
|
+
'--dry-run',
|
|
71
|
+
z
|
|
72
|
+
.boolean()
|
|
73
|
+
.default(false)
|
|
74
|
+
.describe(
|
|
75
|
+
'Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow.',
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
.example('# Trigger remote GitHub Actions deploy (default mode)')
|
|
79
|
+
.example('infra umami deploy')
|
|
80
|
+
.example('# Validate local deploy preconditions and planned command')
|
|
81
|
+
.example('infra umami deploy --local --dry-run')
|
|
82
|
+
.example('# Run local deploy with explicit SSH agent context')
|
|
83
|
+
.example('infra umami deploy --local')
|
|
84
|
+
.action(async options => {
|
|
85
|
+
if (options.local) {
|
|
86
|
+
const command = ['bun', 'run', '--cwd', 'apps/umami', 'deploy']
|
|
87
|
+
|
|
88
|
+
if (options.dryRun) {
|
|
89
|
+
console.log('Dry run: local umami deploy')
|
|
90
|
+
console.log(`- command: ${command.join(' ')}`)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const env = getUmamiDeployEnv()
|
|
95
|
+
|
|
96
|
+
const child = Bun.spawn(command, {
|
|
97
|
+
env,
|
|
98
|
+
stdout: 'inherit',
|
|
99
|
+
stderr: 'inherit',
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const exitCode = await child.exited
|
|
103
|
+
if (exitCode !== 0) {
|
|
104
|
+
throw new Error(`Local deploy failed with exit code ${exitCode}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
validateUmamiRemotePreconditions()
|
|
111
|
+
|
|
112
|
+
if (options.dryRun) {
|
|
113
|
+
console.log('Dry run: remote umami deploy')
|
|
114
|
+
console.log(`- command: gh workflow run "${WORKFLOW_NAME}" --repo ${REPO}`)
|
|
115
|
+
console.log(`- workflow URL: ${WORKFLOW_URL}`)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const child = Bun.spawn(['gh', 'workflow', 'run', WORKFLOW_NAME, '--repo', REPO], {
|
|
120
|
+
stdout: 'inherit',
|
|
121
|
+
stderr: 'inherit',
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const exitCode = await child.exited
|
|
125
|
+
if (exitCode !== 0) {
|
|
126
|
+
throw new Error(`Failed to trigger "${WORKFLOW_NAME}" workflow (exit code ${exitCode})`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log(`Workflow triggered: ${WORKFLOW_URL}`)
|
|
130
|
+
console.log('Approve the umami environment deployment in GitHub Actions to continue.')
|
|
131
|
+
})
|
|
132
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {describe, expect, it} from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {validateUmamiHost} from './host'
|
|
4
|
+
|
|
5
|
+
describe('validateUmamiHost', () => {
|
|
6
|
+
it('accepts a standard FQDN', () => {
|
|
7
|
+
expect(() => validateUmamiHost('metrics.fro.bot')).not.toThrow()
|
|
8
|
+
expect(validateUmamiHost('metrics.fro.bot')).toBe('metrics.fro.bot')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('accepts localhost', () => {
|
|
12
|
+
expect(() => validateUmamiHost('localhost')).not.toThrow()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('accepts an IPv4 address', () => {
|
|
16
|
+
expect(() => validateUmamiHost('147.182.133.210')).not.toThrow()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('accepts a single-character hostname', () => {
|
|
20
|
+
expect(() => validateUmamiHost('a')).not.toThrow()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('accepts a hostname with hyphens', () => {
|
|
24
|
+
expect(() => validateUmamiHost('my-metrics.prod.example.com')).not.toThrow()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('rejects a leading-hyphen value (ProxyCommand injection vector)', () => {
|
|
28
|
+
expect(() => validateUmamiHost('-oProxyCommand=evil')).toThrow('Invalid UMAMI_DOMAIN')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('rejects a value with shell metacharacters (semicolon)', () => {
|
|
32
|
+
expect(() => validateUmamiHost('metrics.fro.bot;rm -rf')).toThrow('Invalid UMAMI_DOMAIN')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('rejects a value with shell metacharacters (backtick)', () => {
|
|
36
|
+
expect(() => validateUmamiHost('metrics.fro.bot`id`')).toThrow('Invalid UMAMI_DOMAIN')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('rejects a value with spaces', () => {
|
|
40
|
+
expect(() => validateUmamiHost('metrics fro.bot')).toThrow('Invalid UMAMI_DOMAIN')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('rejects a value with an at-sign', () => {
|
|
44
|
+
expect(() => validateUmamiHost('user@metrics.fro.bot')).toThrow('Invalid UMAMI_DOMAIN')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('rejects an empty string', () => {
|
|
48
|
+
expect(() => validateUmamiHost('')).toThrow('Invalid UMAMI_DOMAIN')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('truncates the invalid value in the error message to ~30 chars', () => {
|
|
52
|
+
const longMalicious = `-oProxyCommand=${'A'.repeat(100)}`
|
|
53
|
+
let message = ''
|
|
54
|
+
try {
|
|
55
|
+
validateUmamiHost(longMalicious)
|
|
56
|
+
} catch (error) {
|
|
57
|
+
message = error instanceof Error ? error.message : String(error)
|
|
58
|
+
}
|
|
59
|
+
expect(message).toContain('Invalid UMAMI_DOMAIN')
|
|
60
|
+
expect(message.length).toBeLessThan(longMalicious.length + 50)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// ─── Umami host validation ────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Validates UMAMI_DOMAIN values before they are passed as ssh argv arguments.
|
|
4
|
+
// A value starting with `-` would be interpreted by ssh as an option flag,
|
|
5
|
+
// enabling ProxyCommand injection and local code execution.
|
|
6
|
+
|
|
7
|
+
const VALID_HOST_RE = /^[a-z\d][a-z\d.\-]*$/i
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validates a candidate UMAMI_DOMAIN value against a strict hostname allowlist.
|
|
11
|
+
*
|
|
12
|
+
* Accepts: hostnames, FQDNs, IPv4 addresses, `localhost`.
|
|
13
|
+
* Rejects: empty strings, values starting with `-`, and anything containing
|
|
14
|
+
* characters outside `[A-Za-z0-9.-]`.
|
|
15
|
+
*
|
|
16
|
+
* @throws {Error} with a sanitized excerpt of the invalid value.
|
|
17
|
+
* @returns The validated host string (unchanged).
|
|
18
|
+
*/
|
|
19
|
+
export function validateUmamiHost(host: string): string {
|
|
20
|
+
if (!host) {
|
|
21
|
+
throw new Error('Invalid UMAMI_DOMAIN: value is empty')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!VALID_HOST_RE.test(host)) {
|
|
25
|
+
// Truncate to 30 chars and strip non-printable bytes before echoing back
|
|
26
|
+
const excerpt = host.slice(0, 30).replaceAll(/[^\u0020-\u007E]/g, '?')
|
|
27
|
+
throw new Error(`Invalid UMAMI_DOMAIN: "${excerpt}" — must match ${String.raw`[A-Za-z0-9][A-Za-z0-9.\-]*`}`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return host
|
|
31
|
+
}
|