@marcusrbrown/infra 0.4.9 → 0.4.10

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,209 @@
1
+ import type {goke} from 'goke'
2
+ import type {StatusSummary} from '../status'
3
+
4
+ import {z} from 'zod'
5
+
6
+ import {validateGatewayHost} from './host'
7
+
8
+ declare const process: {
9
+ env: Record<string, string | undefined>
10
+ exitCode?: number
11
+ }
12
+
13
+ const COMPOSE_PROJECT_DIR = '/opt/gateway/deploy'
14
+
15
+ // ─── Types ───────────────────────────────────────────────────────────────────
16
+
17
+ export interface ComposePsEntry {
18
+ Name: string
19
+ State: string
20
+ Health: string
21
+ }
22
+
23
+ export type HealthStatus = 'healthy' | 'unhealthy' | 'starting' | 'n-a'
24
+
25
+ export interface ServiceRow {
26
+ service: string
27
+ state: string
28
+ health: HealthStatus
29
+ }
30
+
31
+ export interface GatewayStatusResult {
32
+ ok: boolean
33
+ services: ServiceRow[]
34
+ error?: string
35
+ }
36
+
37
+ export type SpawnFn = (
38
+ cmd: string[],
39
+ opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
40
+ ) => {
41
+ stdout: ReadableStream<Uint8Array>
42
+ stderr: ReadableStream<Uint8Array>
43
+ exited: Promise<number>
44
+ }
45
+
46
+ // ─── Pure helpers ─────────────────────────────────────────────────────────────
47
+
48
+ function normalizeHealth(raw: string): HealthStatus {
49
+ if (raw === 'healthy' || raw === 'unhealthy' || raw === 'starting') {
50
+ return raw
51
+ }
52
+
53
+ return 'n-a'
54
+ }
55
+
56
+ export function parseComposePs(entries: ComposePsEntry[]): ServiceRow[] {
57
+ return entries.map(entry => ({
58
+ service: entry.Name,
59
+ state: entry.State,
60
+ // Workspace has no upstream healthcheck in v1; health will upgrade automatically
61
+ // when upstream adds one and docker compose ps starts reporting it.
62
+ health: normalizeHealth(entry.Health),
63
+ }))
64
+ }
65
+
66
+ export function isAllRunning(rows: ServiceRow[]): boolean {
67
+ return rows.every(row => row.state === 'running')
68
+ }
69
+
70
+ // ─── SSH-backed status fetch ──────────────────────────────────────────────────
71
+
72
+ function defaultSpawn(
73
+ cmd: string[],
74
+ opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
75
+ ): ReturnType<SpawnFn> {
76
+ return Bun.spawn(cmd, opts) as ReturnType<SpawnFn>
77
+ }
78
+
79
+ export async function getGatewayComposeStatus(
80
+ host: string,
81
+ spawn: SpawnFn = defaultSpawn,
82
+ ): Promise<GatewayStatusResult> {
83
+ validateGatewayHost(host)
84
+
85
+ const sshCmd = [
86
+ 'ssh',
87
+ '-o',
88
+ 'BatchMode=yes',
89
+ '-o',
90
+ 'StrictHostKeyChecking=yes',
91
+ `root@${host}`,
92
+ `docker compose --project-directory ${COMPOSE_PROJECT_DIR} ps --format json`,
93
+ ]
94
+
95
+ const env: Record<string, string> = {
96
+ PATH: process.env.PATH ?? '/usr/bin:/bin',
97
+ HOME: process.env.HOME ?? '/tmp',
98
+ ...(process.env.SSH_AUTH_SOCK ? {SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK} : {}),
99
+ }
100
+
101
+ const child = spawn(sshCmd, {env, stdout: 'pipe', stderr: 'pipe'})
102
+
103
+ const [stdoutText, stderrText, exitCode] = await Promise.all([
104
+ new Response(child.stdout).text(),
105
+ new Response(child.stderr).text(),
106
+ child.exited,
107
+ ])
108
+
109
+ if (exitCode !== 0) {
110
+ return {
111
+ ok: false,
112
+ services: [],
113
+ error: `SSH command failed (exit ${exitCode}): ${stderrText.trim() || 'unknown error'}`,
114
+ }
115
+ }
116
+
117
+ let entries: ComposePsEntry[]
118
+
119
+ try {
120
+ const parsed: unknown = JSON.parse(stdoutText)
121
+ entries = Array.isArray(parsed) ? (parsed as ComposePsEntry[]) : []
122
+ } catch {
123
+ return {ok: false, services: [], error: `Failed to parse docker compose ps output: ${stdoutText.slice(0, 200)}`}
124
+ }
125
+
126
+ const services = parseComposePs(entries)
127
+ const ok = isAllRunning(services)
128
+
129
+ return {ok, services}
130
+ }
131
+
132
+ // ─── Unified status aggregator export ────────────────────────────────────────
133
+
134
+ export async function getGatewayStatusSummary(host: string): Promise<StatusSummary> {
135
+ const result = await getGatewayComposeStatus(host)
136
+
137
+ if (!result.ok || result.services.length === 0) {
138
+ const errorMsg = result.error ?? 'No services reported'
139
+ return {
140
+ app: 'gateway',
141
+ http: `ERROR: ${errorMsg}`,
142
+ lastDeploy: '—',
143
+ version: '—',
144
+ contentHash: '—',
145
+ usageStats: '—',
146
+ }
147
+ }
148
+
149
+ const rows = result.services.map(s => `${s.service}:${s.state}/${s.health}`).join(', ')
150
+
151
+ return {
152
+ app: 'gateway',
153
+ http: `OK: ${rows}`,
154
+ lastDeploy: '—',
155
+ version: '—',
156
+ contentHash: '—',
157
+ usageStats: '—',
158
+ }
159
+ }
160
+
161
+ // ─── Command registration ─────────────────────────────────────────────────────
162
+
163
+ export function registerGatewayStatus(cli: ReturnType<typeof goke>): void {
164
+ cli
165
+ .command('gateway status', 'Show operational health of the gateway deployment via docker compose ps.')
166
+ .option(
167
+ '--key [key]',
168
+ z.string().describe('Environment variable name holding the SSH host. Falls back to GATEWAY_HOST when omitted.'),
169
+ )
170
+ .action(async options => {
171
+ const hostEnvKey = options.key ?? 'GATEWAY_HOST'
172
+ const host = process.env[hostEnvKey]
173
+
174
+ if (!host) {
175
+ console.error(`Gateway host not set. Export ${hostEnvKey} or pass --key <env-name> pointing to a set variable.`)
176
+ process.exitCode = 1
177
+ return
178
+ }
179
+
180
+ console.log('Gateway status')
181
+ console.log('')
182
+
183
+ const result = await getGatewayComposeStatus(host)
184
+
185
+ if (!result.ok && result.services.length === 0) {
186
+ console.error(`Error: ${result.error ?? 'Unknown error'}`)
187
+ process.exitCode = 1
188
+ return
189
+ }
190
+
191
+ console.log('Service State Health')
192
+ console.log('─────────────────────────────────────')
193
+
194
+ for (const row of result.services) {
195
+ const svc = row.service.padEnd(16)
196
+ const state = row.state.padEnd(10)
197
+ console.log(`${svc} ${state} ${row.health}`)
198
+ }
199
+
200
+ console.log('')
201
+
202
+ if (result.ok) {
203
+ console.log('Status: OK')
204
+ } else {
205
+ console.log('Status: DEGRADED (one or more services not running)')
206
+ process.exitCode = 1
207
+ }
208
+ })
209
+ }
@@ -50,11 +50,20 @@ describe('top-level status command', () => {
50
50
  contentHash: '—',
51
51
  usageStats: '12 req / 0 fail',
52
52
  }),
53
+ getGatewayStatusSummary: async () => ({
54
+ app: 'gateway',
55
+ http: 'OK: gateway:running/healthy',
56
+ lastDeploy: '—',
57
+ version: '—',
58
+ contentHash: '—',
59
+ usageStats: '—',
60
+ }),
53
61
  })
54
62
 
55
63
  expect(lines).toContain('| App | HTTP | Last Deploy | Version | Content Hash | Usage Stats |')
56
64
  expect(lines).toContain('| keeweb | OK | 2026-04-12 10:00 | — | match | — |')
57
65
  expect(lines).toContain('| cliproxy | OK | — | v1.2.3 | — | 12 req / 0 fail |')
66
+ expect(lines).toContain('| gateway | OK: gateway:running/healthy | — | — | — | — |')
58
67
  })
59
68
 
60
69
  it('shows an error row when one app fails and keeps the other result', async () => {
@@ -70,6 +79,14 @@ describe('top-level status command', () => {
70
79
  contentHash: '—',
71
80
  usageStats: '12 req / 0 fail',
72
81
  }),
82
+ getGatewayStatusSummary: async () => ({
83
+ app: 'gateway',
84
+ http: 'OK: gateway:running/healthy',
85
+ lastDeploy: '—',
86
+ version: '—',
87
+ contentHash: '—',
88
+ usageStats: '—',
89
+ }),
73
90
  })
74
91
 
75
92
  expect(lines).toContain(
@@ -96,6 +113,14 @@ describe('top-level status command', () => {
96
113
  contentHash: '—',
97
114
  usageStats: '12 req / 0 fail',
98
115
  }),
116
+ getGatewayStatusSummary: async () => ({
117
+ app: 'gateway',
118
+ http: 'OK: gateway:running/healthy',
119
+ lastDeploy: '—',
120
+ version: '—',
121
+ contentHash: '—',
122
+ usageStats: '—',
123
+ }),
99
124
  })
100
125
 
101
126
  expect(lines).toHaveLength(1)
@@ -3,6 +3,7 @@ import type {goke} from 'goke'
3
3
  import {z} from 'zod'
4
4
 
5
5
  import {getCliproxyStatusSummary} from './cliproxy/status'
6
+ import {getGatewayStatusSummary} from './gateway'
6
7
  import {getKeewebStatusSummary} from './keeweb/status'
7
8
 
8
9
  declare const process: {
@@ -10,7 +11,7 @@ declare const process: {
10
11
  }
11
12
 
12
13
  export interface StatusSummary {
13
- app: 'keeweb' | 'cliproxy'
14
+ app: 'keeweb' | 'cliproxy' | 'gateway'
14
15
  http: string
15
16
  lastDeploy: string
16
17
  version: string
@@ -23,6 +24,7 @@ type AppName = StatusSummary['app']
23
24
  interface StatusDependencies {
24
25
  getKeewebStatusSummary: (verbose: boolean) => Promise<StatusSummary>
25
26
  getCliproxyStatusSummary: (baseUrl: string, key: string, verbose: boolean) => Promise<StatusSummary>
27
+ getGatewayStatusSummary: (host: string) => Promise<StatusSummary>
26
28
  }
27
29
 
28
30
  const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
@@ -58,6 +60,7 @@ function toJsonPayload(rows: StatusSummary[]): Record<AppName, StatusSummary> {
58
60
  return {
59
61
  keeweb: rows.find(row => row.app === 'keeweb') ?? errorSummary('keeweb', 'missing result'),
60
62
  cliproxy: rows.find(row => row.app === 'cliproxy') ?? errorSummary('cliproxy', 'missing result'),
63
+ gateway: rows.find(row => row.app === 'gateway') ?? errorSummary('gateway', 'missing result'),
61
64
  }
62
65
  }
63
66
 
@@ -71,11 +74,15 @@ export function registerStatus(
71
74
  dependencies: StatusDependencies = {
72
75
  getKeewebStatusSummary,
73
76
  getCliproxyStatusSummary,
77
+ getGatewayStatusSummary,
74
78
  },
75
79
  ): void {
76
80
  cli
77
81
  .command('status', 'Show status of all deployments')
78
- .option('--json', z.boolean().describe('Output machine-readable JSON with keeweb and cliproxy summary objects.'))
82
+ .option(
83
+ '--json',
84
+ z.boolean().describe('Output machine-readable JSON with keeweb, cliproxy, and gateway summary objects.'),
85
+ )
79
86
  .option(
80
87
  '--verbose',
81
88
  z.boolean().describe('Include verbose per-app health check details when building the summary rows.'),
@@ -84,14 +91,17 @@ export function registerStatus(
84
91
  const verbose = options.verbose === true
85
92
  const cliproxyBaseUrl = stripTrailingSlash(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
86
93
  const cliproxyKey = process.env.CLIPROXY_MANAGEMENT_KEY ?? ''
94
+ const gatewayHost = process.env.GATEWAY_HOST ?? ''
87
95
 
88
96
  const results = await Promise.allSettled([
89
97
  dependencies.getKeewebStatusSummary(verbose),
90
98
  dependencies.getCliproxyStatusSummary(cliproxyBaseUrl, cliproxyKey, verbose),
99
+ dependencies.getGatewayStatusSummary(gatewayHost),
91
100
  ])
92
101
 
102
+ const appNames: AppName[] = ['keeweb', 'cliproxy', 'gateway']
93
103
  const rows: StatusSummary[] = results.map((result, index) => {
94
- const app: AppName = index === 0 ? 'keeweb' : 'cliproxy'
104
+ const app = appNames[index] ?? 'keeweb'
95
105
  return result.status === 'fulfilled' ? result.value : errorSummary(app, result.reason)
96
106
  })
97
107