@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.
@@ -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
+ }