@marcusrbrown/infra 0.8.1 → 0.9.1
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/validation.test.ts +168 -0
- package/src/commands/cliproxy/setup/validation.ts +36 -9
- package/src/commands/cliproxy/status.test.ts +368 -38
- package/src/commands/cliproxy/status.ts +168 -41
- package/src/commands/mcp.test.ts +5 -7
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type {goke} from 'goke'
|
|
2
|
+
|
|
3
|
+
import {registerUmamiDeploy} from './deploy'
|
|
4
|
+
import {registerUmamiLogs} from './logs'
|
|
5
|
+
import {registerUmamiStatus} from './status'
|
|
6
|
+
|
|
7
|
+
export {getUmamiStatusSummary} from './status'
|
|
8
|
+
|
|
9
|
+
export function registerUmamiCommands(cli: ReturnType<typeof goke>): void {
|
|
10
|
+
registerUmamiStatus(cli)
|
|
11
|
+
registerUmamiDeploy(cli)
|
|
12
|
+
registerUmamiLogs(cli)
|
|
13
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {streamUmamiLogs, type LogsSpawnFn, type StreamLogsOpts} from './logs'
|
|
4
|
+
|
|
5
|
+
// ─── streamUmamiLogs ─────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
function makeLogsSpawn(exitCode = 0): LogsSpawnFn {
|
|
8
|
+
return (_cmd, _opts) => ({exited: Promise.resolve(exitCode)})
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('logs command', () => {
|
|
12
|
+
let originalCI: string | undefined
|
|
13
|
+
let originalUmamiDomain: string | undefined
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
originalCI = process.env.CI
|
|
17
|
+
originalUmamiDomain = process.env.UMAMI_DOMAIN
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
if (originalCI === undefined) {
|
|
22
|
+
delete process.env.CI
|
|
23
|
+
} else {
|
|
24
|
+
process.env.CI = originalCI
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (originalUmamiDomain === undefined) {
|
|
28
|
+
delete process.env.UMAMI_DOMAIN
|
|
29
|
+
} else {
|
|
30
|
+
process.env.UMAMI_DOMAIN = originalUmamiDomain
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('refuses to stream logs in CI without --allow-ci', async () => {
|
|
35
|
+
process.env.CI = 'true'
|
|
36
|
+
|
|
37
|
+
const opts: StreamLogsOpts = {
|
|
38
|
+
host: 'metrics.fro.bot',
|
|
39
|
+
service: 'umami',
|
|
40
|
+
tail: 100,
|
|
41
|
+
allowCi: false,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const messages: string[] = []
|
|
45
|
+
const result = await streamUmamiLogs(opts, makeLogsSpawn(), msg => messages.push(msg))
|
|
46
|
+
|
|
47
|
+
expect(result.refused).toBe(true)
|
|
48
|
+
expect(messages.join('')).toContain('Refusing to stream logs in CI')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('proceeds in CI when --allow-ci is set', async () => {
|
|
52
|
+
process.env.CI = 'true'
|
|
53
|
+
|
|
54
|
+
const opts: StreamLogsOpts = {
|
|
55
|
+
host: 'metrics.fro.bot',
|
|
56
|
+
service: 'umami',
|
|
57
|
+
tail: 100,
|
|
58
|
+
allowCi: true,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const warnings: string[] = []
|
|
62
|
+
const result = await streamUmamiLogs(opts, makeLogsSpawn(), undefined, msg => warnings.push(msg))
|
|
63
|
+
|
|
64
|
+
expect(result.refused).toBe(false)
|
|
65
|
+
expect(result.exitCode).toBe(0)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('always emits a sensitive-data warning to stderr', async () => {
|
|
69
|
+
delete process.env.CI
|
|
70
|
+
|
|
71
|
+
const opts: StreamLogsOpts = {
|
|
72
|
+
host: 'metrics.fro.bot',
|
|
73
|
+
service: 'umami',
|
|
74
|
+
tail: 100,
|
|
75
|
+
allowCi: false,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const warnings: string[] = []
|
|
79
|
+
await streamUmamiLogs(opts, makeLogsSpawn(), undefined, msg => warnings.push(msg))
|
|
80
|
+
|
|
81
|
+
expect(warnings.join('')).toContain('sensitive')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('rejects invalid host before spawning SSH', async () => {
|
|
85
|
+
delete process.env.CI
|
|
86
|
+
|
|
87
|
+
const opts: StreamLogsOpts = {
|
|
88
|
+
host: '-oProxyCommand=evil',
|
|
89
|
+
service: 'umami',
|
|
90
|
+
tail: 100,
|
|
91
|
+
allowCi: false,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let spawnCalled = false
|
|
95
|
+
const spy: LogsSpawnFn = (_cmd, _opts) => {
|
|
96
|
+
spawnCalled = true
|
|
97
|
+
return {exited: Promise.resolve(0)}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await expect(streamUmamiLogs(opts, spy)).rejects.toThrow('Invalid UMAMI_DOMAIN')
|
|
101
|
+
expect(spawnCalled).toBe(false)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('returns exitCode from the SSH subprocess', async () => {
|
|
105
|
+
delete process.env.CI
|
|
106
|
+
|
|
107
|
+
const opts: StreamLogsOpts = {
|
|
108
|
+
host: 'metrics.fro.bot',
|
|
109
|
+
service: 'umami',
|
|
110
|
+
tail: 50,
|
|
111
|
+
allowCi: false,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const result = await streamUmamiLogs(opts, makeLogsSpawn(42))
|
|
115
|
+
|
|
116
|
+
expect(result.refused).toBe(false)
|
|
117
|
+
expect(result.exitCode).toBe(42)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('accepts caddy as a valid service', async () => {
|
|
121
|
+
delete process.env.CI
|
|
122
|
+
|
|
123
|
+
const opts: StreamLogsOpts = {
|
|
124
|
+
host: 'metrics.fro.bot',
|
|
125
|
+
service: 'caddy',
|
|
126
|
+
tail: 100,
|
|
127
|
+
allowCi: false,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const result = await streamUmamiLogs(opts, makeLogsSpawn(0))
|
|
131
|
+
expect(result.refused).toBe(false)
|
|
132
|
+
expect(result.exitCode).toBe(0)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('rejects an unknown service when called directly (bypassing the CLI action)', async () => {
|
|
136
|
+
delete process.env.CI
|
|
137
|
+
|
|
138
|
+
const opts: StreamLogsOpts = {
|
|
139
|
+
host: 'metrics.fro.bot',
|
|
140
|
+
service: 'notaservice',
|
|
141
|
+
tail: 100,
|
|
142
|
+
allowCi: false,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let spawnCalled = false
|
|
146
|
+
const spy: LogsSpawnFn = (_cmd, _opts) => {
|
|
147
|
+
spawnCalled = true
|
|
148
|
+
return {exited: Promise.resolve(0)}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
await expect(streamUmamiLogs(opts, spy)).rejects.toThrow(/Invalid service/)
|
|
152
|
+
expect(spawnCalled).toBe(false)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type {goke} from 'goke'
|
|
2
|
+
|
|
3
|
+
import {z} from 'zod'
|
|
4
|
+
|
|
5
|
+
import {validateUmamiHost} from './host'
|
|
6
|
+
|
|
7
|
+
declare const process: {
|
|
8
|
+
env: Record<string, string | undefined>
|
|
9
|
+
exitCode?: number
|
|
10
|
+
stderr: {write: (msg: string) => void}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const COMPOSE_PROJECT_DIR = '/opt/umami'
|
|
14
|
+
const SENSITIVE_WARNING =
|
|
15
|
+
'Warning: Logs may contain database passwords, app secrets, or user data. Treat output as sensitive; do not capture in shared logs or chat.'
|
|
16
|
+
|
|
17
|
+
export const VALID_SERVICES = ['umami', 'db', 'caddy'] as const
|
|
18
|
+
|
|
19
|
+
export type ValidService = (typeof VALID_SERVICES)[number]
|
|
20
|
+
|
|
21
|
+
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export function validateService(service: string): asserts service is ValidService {
|
|
24
|
+
if (!VALID_SERVICES.includes(service as ValidService)) {
|
|
25
|
+
throw new Error(`Invalid service "${service}". Valid services: ${VALID_SERVICES.join(', ')}`)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── Injectable spawn type ────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export type LogsSpawnFn = (
|
|
32
|
+
cmd: string[],
|
|
33
|
+
opts: {env: Record<string, string>; stdout: 'inherit'; stderr: 'inherit'},
|
|
34
|
+
) => {
|
|
35
|
+
exited: Promise<number>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface StreamLogsOpts {
|
|
39
|
+
host: string
|
|
40
|
+
service: string
|
|
41
|
+
tail: number
|
|
42
|
+
allowCi: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface StreamLogsResult {
|
|
46
|
+
refused: boolean
|
|
47
|
+
exitCode?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function defaultLogsSpawn(
|
|
51
|
+
cmd: string[],
|
|
52
|
+
opts: {env: Record<string, string>; stdout: 'inherit'; stderr: 'inherit'},
|
|
53
|
+
): {exited: Promise<number>} {
|
|
54
|
+
return Bun.spawn(cmd, opts)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function streamUmamiLogs(
|
|
58
|
+
opts: StreamLogsOpts,
|
|
59
|
+
spawn: LogsSpawnFn = defaultLogsSpawn,
|
|
60
|
+
printOut?: (msg: string) => void,
|
|
61
|
+
printErr?: (msg: string) => void,
|
|
62
|
+
): Promise<StreamLogsResult> {
|
|
63
|
+
// Validate service at the top so direct callers cannot bypass the guard.
|
|
64
|
+
validateService(opts.service)
|
|
65
|
+
|
|
66
|
+
const isCI = process.env.CI === 'true'
|
|
67
|
+
|
|
68
|
+
if (isCI && !opts.allowCi) {
|
|
69
|
+
const msg = 'Refusing to stream logs in CI without --allow-ci. Logs may contain sensitive credentials or user data.'
|
|
70
|
+
if (printOut) {
|
|
71
|
+
printOut(msg)
|
|
72
|
+
} else {
|
|
73
|
+
console.log(msg)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {refused: true}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Warn on every run — logs are sensitive regardless of context
|
|
80
|
+
if (printErr) {
|
|
81
|
+
printErr(SENSITIVE_WARNING)
|
|
82
|
+
} else {
|
|
83
|
+
console.error(SENSITIVE_WARNING)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
validateUmamiHost(opts.host)
|
|
87
|
+
|
|
88
|
+
const sshCmd = [
|
|
89
|
+
'ssh',
|
|
90
|
+
'-o',
|
|
91
|
+
'BatchMode=yes',
|
|
92
|
+
'-o',
|
|
93
|
+
'StrictHostKeyChecking=yes',
|
|
94
|
+
`root@${opts.host}`,
|
|
95
|
+
`docker compose --project-directory ${COMPOSE_PROJECT_DIR} logs --no-color --tail=${opts.tail} ${opts.service}`,
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
const env: Record<string, string> = {
|
|
99
|
+
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
100
|
+
HOME: process.env.HOME ?? '/tmp',
|
|
101
|
+
...(process.env.SSH_AUTH_SOCK ? {SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK} : {}),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const child = spawn(sshCmd, {env, stdout: 'inherit', stderr: 'inherit'})
|
|
105
|
+
const exitCode = await child.exited
|
|
106
|
+
|
|
107
|
+
return {refused: false, exitCode}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Command registration ─────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export function registerUmamiLogs(cli: ReturnType<typeof goke>): void {
|
|
113
|
+
cli
|
|
114
|
+
.command('umami logs [service]', 'Stream logs from an Umami service via SSH and docker compose.')
|
|
115
|
+
.option('--tail [n]', z.number().default(100).describe('Number of log lines to tail from each service.'))
|
|
116
|
+
.option(
|
|
117
|
+
'--allow-ci',
|
|
118
|
+
z
|
|
119
|
+
.boolean()
|
|
120
|
+
.default(false)
|
|
121
|
+
.describe('Allow log streaming in CI environments. Logs may contain sensitive credentials.'),
|
|
122
|
+
)
|
|
123
|
+
.option(
|
|
124
|
+
'--key [key]',
|
|
125
|
+
z.string().describe('Environment variable name holding the SSH host. Falls back to UMAMI_DOMAIN when omitted.'),
|
|
126
|
+
)
|
|
127
|
+
.action(async (service, options) => {
|
|
128
|
+
const targetService = (service as string | undefined) ?? 'umami'
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
validateService(targetService)
|
|
132
|
+
} catch (error) {
|
|
133
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
134
|
+
process.exitCode = 1
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const hostEnvKey = options.key ?? 'UMAMI_DOMAIN'
|
|
139
|
+
const host = process.env[hostEnvKey]
|
|
140
|
+
|
|
141
|
+
if (!host) {
|
|
142
|
+
console.error('Umami host not set. Export UMAMI_DOMAIN or pass --key <env-name> pointing to a set variable.')
|
|
143
|
+
process.exitCode = 1
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const tail = typeof options.tail === 'number' ? options.tail : 100
|
|
148
|
+
const allowCi = options.allowCi === true
|
|
149
|
+
|
|
150
|
+
const result = await streamUmamiLogs({host, service: targetService, tail, allowCi})
|
|
151
|
+
|
|
152
|
+
if (result.refused) {
|
|
153
|
+
process.exitCode = 1
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (result.exitCode !== 0) {
|
|
158
|
+
process.exitCode = result.exitCode ?? 1
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
}
|