@marcusrbrown/infra 0.13.1 → 0.13.2
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/dist/cli.js +4977 -0
- package/package.json +7 -3
- package/src/__snapshots__/cli.test.ts.snap +0 -219
- package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
- package/src/__test__/mcp-ctx-fixture.ts +0 -104
- package/src/cli.test.ts +0 -66
- package/src/cli.ts +0 -43
- package/src/commands/cliproxy/config.test.ts +0 -475
- package/src/commands/cliproxy/config.ts +0 -238
- package/src/commands/cliproxy/deploy.test.ts +0 -181
- package/src/commands/cliproxy/deploy.ts +0 -157
- package/src/commands/cliproxy/host.test.ts +0 -73
- package/src/commands/cliproxy/host.ts +0 -31
- package/src/commands/cliproxy/index.ts +0 -21
- package/src/commands/cliproxy/keys.test.ts +0 -326
- package/src/commands/cliproxy/keys.ts +0 -201
- package/src/commands/cliproxy/login.test.ts +0 -415
- package/src/commands/cliproxy/login.ts +0 -131
- package/src/commands/cliproxy/models.test.ts +0 -592
- package/src/commands/cliproxy/models.ts +0 -237
- package/src/commands/cliproxy/open.test.ts +0 -132
- package/src/commands/cliproxy/open.ts +0 -78
- package/src/commands/cliproxy/setup/gh.test.ts +0 -218
- package/src/commands/cliproxy/setup/gh.ts +0 -250
- package/src/commands/cliproxy/setup/preview.test.ts +0 -159
- package/src/commands/cliproxy/setup/preview.ts +0 -41
- package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
- package/src/commands/cliproxy/setup/prompts.ts +0 -99
- package/src/commands/cliproxy/setup/providers.test.ts +0 -245
- package/src/commands/cliproxy/setup/providers.ts +0 -136
- package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
- package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
- package/src/commands/cliproxy/setup/templates.test.ts +0 -357
- package/src/commands/cliproxy/setup/templates.ts +0 -145
- package/src/commands/cliproxy/setup/validation.test.ts +0 -567
- package/src/commands/cliproxy/setup/validation.ts +0 -209
- package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
- package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
- package/src/commands/cliproxy/setup.test.ts +0 -2366
- package/src/commands/cliproxy/setup.ts +0 -809
- package/src/commands/cliproxy/shared.test.ts +0 -118
- package/src/commands/cliproxy/shared.ts +0 -84
- package/src/commands/cliproxy/status.test.ts +0 -1157
- package/src/commands/cliproxy/status.ts +0 -597
- package/src/commands/dashboard/deploy.test.ts +0 -215
- package/src/commands/dashboard/deploy.ts +0 -135
- package/src/commands/dashboard/host.test.ts +0 -79
- package/src/commands/dashboard/host.ts +0 -33
- package/src/commands/dashboard/index.ts +0 -13
- package/src/commands/dashboard/logs.test.ts +0 -317
- package/src/commands/dashboard/logs.ts +0 -180
- package/src/commands/dashboard/status.test.ts +0 -708
- package/src/commands/dashboard/status.ts +0 -293
- package/src/commands/gateway/backup.test.ts +0 -426
- package/src/commands/gateway/backup.ts +0 -205
- package/src/commands/gateway/deploy.test.ts +0 -491
- package/src/commands/gateway/deploy.ts +0 -173
- package/src/commands/gateway/host.test.ts +0 -89
- package/src/commands/gateway/host.ts +0 -33
- package/src/commands/gateway/index.ts +0 -17
- package/src/commands/gateway/logs.test.ts +0 -263
- package/src/commands/gateway/logs.ts +0 -160
- package/src/commands/gateway/restore.test.ts +0 -650
- package/src/commands/gateway/restore.ts +0 -308
- package/src/commands/gateway/status.test.ts +0 -755
- package/src/commands/gateway/status.ts +0 -255
- package/src/commands/keeweb/deploy.test.ts +0 -177
- package/src/commands/keeweb/deploy.ts +0 -191
- package/src/commands/keeweb/index.ts +0 -11
- package/src/commands/keeweb/open.test.ts +0 -54
- package/src/commands/keeweb/open.ts +0 -30
- package/src/commands/keeweb/status.test.ts +0 -509
- package/src/commands/keeweb/status.ts +0 -359
- package/src/commands/mcp.test.ts +0 -260
- package/src/commands/mcp.ts +0 -58
- package/src/commands/status.test.ts +0 -222
- package/src/commands/status.ts +0 -161
- package/src/commands/umami/deploy.test.ts +0 -202
- package/src/commands/umami/deploy.ts +0 -132
- package/src/commands/umami/host.test.ts +0 -79
- package/src/commands/umami/host.ts +0 -33
- package/src/commands/umami/index.ts +0 -13
- package/src/commands/umami/logs.test.ts +0 -191
- package/src/commands/umami/logs.ts +0 -163
- package/src/commands/umami/status.test.ts +0 -633
- package/src/commands/umami/status.ts +0 -285
- package/src/commands/vpn/client.test.ts +0 -949
- package/src/commands/vpn/client.ts +0 -467
- package/src/commands/vpn/deploy.test.ts +0 -208
- package/src/commands/vpn/deploy.ts +0 -143
- package/src/commands/vpn/host.test.ts +0 -89
- package/src/commands/vpn/host.ts +0 -36
- package/src/commands/vpn/index.ts +0 -15
- package/src/commands/vpn/logs.test.ts +0 -234
- package/src/commands/vpn/logs.ts +0 -136
- package/src/commands/vpn/peers.test.ts +0 -478
- package/src/commands/vpn/shared.test.ts +0 -454
- package/src/commands/vpn/shared.ts +0 -170
- package/src/commands/vpn/status.test.ts +0 -142
- package/src/commands/vpn/status.ts +0 -78
- package/src/conventions.test.ts +0 -1180
- package/src/lib/action-ctx.ts +0 -25
- package/src/lib/known-hosts.test.ts +0 -192
- package/src/lib/known-hosts.ts +0 -76
- package/src/lib/redact.ts +0 -21
- package/src/lib/ssh-identity.test.ts +0 -134
- package/src/lib/ssh-identity.ts +0 -76
- /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
|
@@ -1,255 +0,0 @@
|
|
|
1
|
-
import type {goke} from 'goke'
|
|
2
|
-
import type {ActionCtx} from '../../lib/action-ctx'
|
|
3
|
-
import type {StatusSummary} from '../status'
|
|
4
|
-
import {z} from 'zod'
|
|
5
|
-
|
|
6
|
-
import {buildKnownHostsArgs} from '../../lib/known-hosts'
|
|
7
|
-
import {redactHost} from '../../lib/redact'
|
|
8
|
-
import {buildIdentityArgs} from '../../lib/ssh-identity'
|
|
9
|
-
// ─── Minimal ctx interface (subset of GokeExecutionContext used by this action) ─
|
|
10
|
-
import {validateGatewayHost} from './host'
|
|
11
|
-
|
|
12
|
-
declare const process: {
|
|
13
|
-
env: Record<string, string | undefined>
|
|
14
|
-
exitCode?: number
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const COMPOSE_PROJECT_DIR = '/opt/gateway/deploy'
|
|
18
|
-
|
|
19
|
-
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
20
|
-
|
|
21
|
-
export interface ComposePsEntry {
|
|
22
|
-
Name: string
|
|
23
|
-
State: string
|
|
24
|
-
Health: string
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export type HealthStatus = 'healthy' | 'unhealthy' | 'starting' | 'n-a'
|
|
28
|
-
|
|
29
|
-
export interface ServiceRow {
|
|
30
|
-
service: string
|
|
31
|
-
state: string
|
|
32
|
-
health: HealthStatus
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface GatewayStatusResult {
|
|
36
|
-
ok: boolean
|
|
37
|
-
services: ServiceRow[]
|
|
38
|
-
error?: string
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export type SpawnFn = (
|
|
42
|
-
cmd: string[],
|
|
43
|
-
opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
|
|
44
|
-
) => {
|
|
45
|
-
stdout: ReadableStream<Uint8Array>
|
|
46
|
-
stderr: ReadableStream<Uint8Array>
|
|
47
|
-
exited: Promise<number>
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
|
51
|
-
|
|
52
|
-
export function parseComposePsOutput(stdoutText: string): ComposePsEntry[] {
|
|
53
|
-
const trimmed = stdoutText.trim()
|
|
54
|
-
if (trimmed.length === 0) return []
|
|
55
|
-
|
|
56
|
-
// Legacy compose may emit a single JSON array; current versions emit NDJSON.
|
|
57
|
-
if (trimmed.startsWith('[')) {
|
|
58
|
-
const parsed: unknown = JSON.parse(trimmed)
|
|
59
|
-
return Array.isArray(parsed) ? (parsed as ComposePsEntry[]) : []
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// NDJSON: one JSON object per non-empty line.
|
|
63
|
-
return trimmed
|
|
64
|
-
.split('\n')
|
|
65
|
-
.map(line => line.trim())
|
|
66
|
-
.filter(line => line.length > 0)
|
|
67
|
-
.map(line => JSON.parse(line) as ComposePsEntry)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function normalizeHealth(raw: string): HealthStatus {
|
|
71
|
-
if (raw === 'healthy' || raw === 'unhealthy' || raw === 'starting') {
|
|
72
|
-
return raw
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return 'n-a'
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function parseComposePs(entries: ComposePsEntry[]): ServiceRow[] {
|
|
79
|
-
return entries.map(entry => ({
|
|
80
|
-
service: entry.Name,
|
|
81
|
-
state: entry.State,
|
|
82
|
-
// Workspace has no upstream healthcheck in v1; health will upgrade automatically
|
|
83
|
-
// when upstream adds one and docker compose ps starts reporting it.
|
|
84
|
-
health: normalizeHealth(entry.Health),
|
|
85
|
-
}))
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function isAllRunning(rows: ServiceRow[]): boolean {
|
|
89
|
-
return rows.every(row => row.state === 'running')
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// ─── SSH-backed status fetch ──────────────────────────────────────────────────
|
|
93
|
-
|
|
94
|
-
function defaultSpawn(
|
|
95
|
-
cmd: string[],
|
|
96
|
-
opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
|
|
97
|
-
): ReturnType<SpawnFn> {
|
|
98
|
-
return Bun.spawn(cmd, opts) as ReturnType<SpawnFn>
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export async function getGatewayComposeStatus(
|
|
102
|
-
host: string,
|
|
103
|
-
spawn: SpawnFn = defaultSpawn,
|
|
104
|
-
): Promise<GatewayStatusResult> {
|
|
105
|
-
validateGatewayHost(host)
|
|
106
|
-
|
|
107
|
-
const knownHostsArgs = buildKnownHostsArgs()
|
|
108
|
-
const {args: identityArgs, cleanup} = buildIdentityArgs(process.env.GATEWAY_SSH_KEY)
|
|
109
|
-
|
|
110
|
-
const sshCmd = [
|
|
111
|
-
'ssh',
|
|
112
|
-
'-o',
|
|
113
|
-
'BatchMode=yes',
|
|
114
|
-
'-o',
|
|
115
|
-
'StrictHostKeyChecking=yes',
|
|
116
|
-
...knownHostsArgs,
|
|
117
|
-
...identityArgs,
|
|
118
|
-
`root@${host}`,
|
|
119
|
-
`docker compose --project-directory ${COMPOSE_PROJECT_DIR} ps --format json`,
|
|
120
|
-
]
|
|
121
|
-
|
|
122
|
-
const env: Record<string, string> = {
|
|
123
|
-
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
124
|
-
HOME: process.env.HOME ?? '/tmp',
|
|
125
|
-
...(process.env.SSH_AUTH_SOCK ? {SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK} : {}),
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
let stdoutText: string
|
|
129
|
-
let stderrText: string
|
|
130
|
-
let exitCode: number
|
|
131
|
-
|
|
132
|
-
try {
|
|
133
|
-
const child = spawn(sshCmd, {env, stdout: 'pipe', stderr: 'pipe'})
|
|
134
|
-
;[stdoutText, stderrText, exitCode] = await Promise.all([
|
|
135
|
-
new Response(child.stdout).text(),
|
|
136
|
-
new Response(child.stderr).text(),
|
|
137
|
-
child.exited,
|
|
138
|
-
])
|
|
139
|
-
} finally {
|
|
140
|
-
cleanup()
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (exitCode !== 0) {
|
|
144
|
-
const redacted = redactHost(stderrText.trim(), host) || 'unknown error'
|
|
145
|
-
return {
|
|
146
|
-
ok: false,
|
|
147
|
-
services: [],
|
|
148
|
-
error: `SSH command failed (exit ${exitCode}): ${redacted}`,
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
let entries: ComposePsEntry[]
|
|
153
|
-
|
|
154
|
-
try {
|
|
155
|
-
entries = parseComposePsOutput(stdoutText)
|
|
156
|
-
} catch {
|
|
157
|
-
return {
|
|
158
|
-
ok: false,
|
|
159
|
-
services: [],
|
|
160
|
-
error: `Failed to parse docker compose ps output: ${redactHost(stdoutText.slice(0, 200), host)}`,
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const services = parseComposePs(entries)
|
|
165
|
-
const ok = isAllRunning(services)
|
|
166
|
-
|
|
167
|
-
return {ok, services}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// ─── Unified status aggregator export ────────────────────────────────────────
|
|
171
|
-
|
|
172
|
-
export async function getGatewayStatusSummary(host: string): Promise<StatusSummary> {
|
|
173
|
-
const result = await getGatewayComposeStatus(host)
|
|
174
|
-
|
|
175
|
-
if (!result.ok || result.services.length === 0) {
|
|
176
|
-
const errorMsg = result.error ?? 'No services reported'
|
|
177
|
-
return {
|
|
178
|
-
app: 'gateway',
|
|
179
|
-
http: `ERROR: ${errorMsg}`,
|
|
180
|
-
lastDeploy: '—',
|
|
181
|
-
version: '—',
|
|
182
|
-
contentHash: '—',
|
|
183
|
-
usageStats: '—',
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const rows = result.services.map(s => `${s.service}:${s.state}/${s.health}`).join(', ')
|
|
188
|
-
|
|
189
|
-
return {
|
|
190
|
-
app: 'gateway',
|
|
191
|
-
http: `OK: ${rows}`,
|
|
192
|
-
lastDeploy: '—',
|
|
193
|
-
version: '—',
|
|
194
|
-
contentHash: '—',
|
|
195
|
-
usageStats: '—',
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// ─── Action (exported for direct testing) ────────────────────────────────────
|
|
200
|
-
|
|
201
|
-
export async function gatewayStatusAction(
|
|
202
|
-
options: {key?: string | undefined},
|
|
203
|
-
ctx: ActionCtx,
|
|
204
|
-
spawn?: SpawnFn,
|
|
205
|
-
): Promise<void> {
|
|
206
|
-
const hostEnvKey = options.key ?? 'GATEWAY_HOST'
|
|
207
|
-
const host = process.env[hostEnvKey]
|
|
208
|
-
|
|
209
|
-
if (!host) {
|
|
210
|
-
ctx.console.error(`Gateway host not set. Export ${hostEnvKey} or pass --key <env-name> pointing to a set variable.`)
|
|
211
|
-
ctx.process.exit(1)
|
|
212
|
-
return
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
ctx.console.log('Gateway status')
|
|
216
|
-
ctx.console.log('')
|
|
217
|
-
|
|
218
|
-
const result = await getGatewayComposeStatus(host, spawn)
|
|
219
|
-
|
|
220
|
-
if (!result.ok && result.services.length === 0) {
|
|
221
|
-
ctx.console.error(`Error: ${result.error ?? 'Unknown error'}`)
|
|
222
|
-
ctx.process.exit(1)
|
|
223
|
-
return
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
ctx.console.log('Service State Health')
|
|
227
|
-
ctx.console.log('─────────────────────────────────────')
|
|
228
|
-
|
|
229
|
-
for (const row of result.services) {
|
|
230
|
-
const svc = row.service.padEnd(16)
|
|
231
|
-
const state = row.state.padEnd(10)
|
|
232
|
-
ctx.console.log(`${svc} ${state} ${row.health}`)
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
ctx.console.log('')
|
|
236
|
-
|
|
237
|
-
if (result.ok) {
|
|
238
|
-
ctx.console.log('Status: OK')
|
|
239
|
-
} else {
|
|
240
|
-
ctx.console.log('Status: DEGRADED (one or more services not running)')
|
|
241
|
-
ctx.process.exit(1)
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// ─── Command registration ─────────────────────────────────────────────────────
|
|
246
|
-
|
|
247
|
-
export function registerGatewayStatus(cli: ReturnType<typeof goke>): void {
|
|
248
|
-
cli
|
|
249
|
-
.command('gateway status', 'Show operational health of the gateway deployment via docker compose ps.')
|
|
250
|
-
.option(
|
|
251
|
-
'--key [key]',
|
|
252
|
-
z.string().describe('Environment variable name holding the SSH host. Falls back to GATEWAY_HOST when omitted.'),
|
|
253
|
-
)
|
|
254
|
-
.action((options, ctx) => gatewayStatusAction(options, ctx))
|
|
255
|
-
}
|
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
import {resolve} from 'node:path'
|
|
2
|
-
import {afterEach, beforeEach, describe, expect, it, spyOn} from 'bun:test'
|
|
3
|
-
|
|
4
|
-
import {getLocalDeployEnv, resolveDeployScriptPath, resolveDistIndexPath, validateRemotePreconditions} from './deploy'
|
|
5
|
-
|
|
6
|
-
const cliDir = resolve(import.meta.dir, '../../..')
|
|
7
|
-
|
|
8
|
-
const envKeys = ['HOST', 'HOME', 'PATH', 'REMOTE_USER', 'SITE_DIR', 'SSH_AUTH_SOCK'] as const
|
|
9
|
-
|
|
10
|
-
type ManagedEnvKey = (typeof envKeys)[number]
|
|
11
|
-
|
|
12
|
-
let originalEnv: Partial<Record<ManagedEnvKey, string | undefined>>
|
|
13
|
-
|
|
14
|
-
function restoreManagedEnv(): void {
|
|
15
|
-
for (const key of envKeys) {
|
|
16
|
-
const value = originalEnv[key]
|
|
17
|
-
|
|
18
|
-
if (value === undefined) {
|
|
19
|
-
delete process.env[key]
|
|
20
|
-
continue
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
process.env[key] = value
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function setManagedEnv(overrides: Partial<Record<ManagedEnvKey, string | undefined>>): void {
|
|
28
|
-
restoreManagedEnv()
|
|
29
|
-
|
|
30
|
-
for (const [key, value] of Object.entries(overrides)) {
|
|
31
|
-
if (value === undefined) {
|
|
32
|
-
delete process.env[key]
|
|
33
|
-
continue
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
process.env[key] = value
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async function runDeployCommand(
|
|
41
|
-
args: string[],
|
|
42
|
-
envOverrides: Partial<Record<ManagedEnvKey, string | undefined>> = {},
|
|
43
|
-
): Promise<{stdout: string; stderr: string; exitCode: number}> {
|
|
44
|
-
const env = {...process.env}
|
|
45
|
-
|
|
46
|
-
for (const [key, value] of Object.entries(envOverrides)) {
|
|
47
|
-
if (value === undefined) {
|
|
48
|
-
delete env[key]
|
|
49
|
-
continue
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
env[key] = value
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const proc = Bun.spawn(['bun', 'src/cli.ts', 'keeweb', 'deploy', ...args], {
|
|
56
|
-
cwd: cliDir,
|
|
57
|
-
env: {
|
|
58
|
-
...env,
|
|
59
|
-
NO_COLOR: '1',
|
|
60
|
-
SSH_AUTH_SOCK: env.SSH_AUTH_SOCK ?? '/tmp/test-sock',
|
|
61
|
-
},
|
|
62
|
-
stdout: 'pipe',
|
|
63
|
-
stderr: 'pipe',
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
67
|
-
new Response(proc.stdout).text(),
|
|
68
|
-
new Response(proc.stderr).text(),
|
|
69
|
-
proc.exited,
|
|
70
|
-
])
|
|
71
|
-
|
|
72
|
-
return {stdout, stderr, exitCode}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
describe('keeweb deploy', () => {
|
|
76
|
-
beforeEach(() => {
|
|
77
|
-
originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]]))
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
afterEach(() => {
|
|
81
|
-
restoreManagedEnv()
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
describe('resolveDeployScriptPath', () => {
|
|
85
|
-
it('returns a string path containing deploy.sh', () => {
|
|
86
|
-
const deployScriptPath = resolveDeployScriptPath()
|
|
87
|
-
|
|
88
|
-
expect(typeof deployScriptPath).toBe('string')
|
|
89
|
-
expect(deployScriptPath).toContain('deploy.sh')
|
|
90
|
-
})
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
describe('resolveDistIndexPath', () => {
|
|
94
|
-
it('returns a string path ending with dist/index.html', () => {
|
|
95
|
-
const distIndexPath = resolveDistIndexPath()
|
|
96
|
-
|
|
97
|
-
expect(typeof distIndexPath).toBe('string')
|
|
98
|
-
expect(distIndexPath).toEndWith('dist/index.html')
|
|
99
|
-
})
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
describe('getLocalDeployEnv', () => {
|
|
103
|
-
it('returns the expected deploy environment when required variables are present', () => {
|
|
104
|
-
setManagedEnv({
|
|
105
|
-
HOME: '/tmp/test-home',
|
|
106
|
-
PATH: '/usr/bin:/bin',
|
|
107
|
-
SSH_AUTH_SOCK: '/tmp/test-sock',
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
expect(getLocalDeployEnv()).toEqual({
|
|
111
|
-
HOME: '/tmp/test-home',
|
|
112
|
-
HOST: 'box.heatvision.co',
|
|
113
|
-
PATH: '/usr/bin:/bin',
|
|
114
|
-
REMOTE_USER: 'deploy-kw',
|
|
115
|
-
SITE_DIR: '/home/user-data/www/kw.igg.ms',
|
|
116
|
-
SSH_AUTH_SOCK: '/tmp/test-sock',
|
|
117
|
-
})
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
it('throws when SSH_AUTH_SOCK is missing', () => {
|
|
121
|
-
setManagedEnv({
|
|
122
|
-
HOME: '/tmp/test-home',
|
|
123
|
-
PATH: '/usr/bin:/bin',
|
|
124
|
-
SSH_AUTH_SOCK: undefined,
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
expect(() => getLocalDeployEnv()).toThrow(/SSH_AUTH_SOCK/)
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
it('throws when PATH is missing', () => {
|
|
131
|
-
setManagedEnv({
|
|
132
|
-
HOME: '/tmp/test-home',
|
|
133
|
-
PATH: undefined,
|
|
134
|
-
SSH_AUTH_SOCK: '/tmp/test-sock',
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
expect(() => getLocalDeployEnv()).toThrow(/PATH/)
|
|
138
|
-
})
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
describe('validateRemotePreconditions', () => {
|
|
142
|
-
it('does not throw when gh is available', () => {
|
|
143
|
-
const whichSpy = spyOn(Bun, 'which').mockReturnValue('/opt/homebrew/bin/gh')
|
|
144
|
-
|
|
145
|
-
expect(() => validateRemotePreconditions()).not.toThrow()
|
|
146
|
-
|
|
147
|
-
whichSpy.mockRestore()
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
it('throws when gh is unavailable', () => {
|
|
151
|
-
const whichSpy = spyOn(Bun, 'which').mockReturnValue(null)
|
|
152
|
-
|
|
153
|
-
expect(() => validateRemotePreconditions()).toThrow(/gh CLI is required/)
|
|
154
|
-
|
|
155
|
-
whichSpy.mockRestore()
|
|
156
|
-
})
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
describe('CLI flag interactions', () => {
|
|
160
|
-
it('rejects --nginx without --local', async () => {
|
|
161
|
-
const {stdout, stderr, exitCode} = await runDeployCommand(['--nginx'])
|
|
162
|
-
|
|
163
|
-
expect(exitCode).not.toBe(0)
|
|
164
|
-
expect(`${stdout}${stderr}`).toContain('only valid with --local')
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
it('prints the local dry-run plan without executing deploy.sh', async () => {
|
|
168
|
-
const deployScriptPath = resolveDeployScriptPath()
|
|
169
|
-
const {stdout, stderr, exitCode} = await runDeployCommand(['--local', '--dry-run'])
|
|
170
|
-
|
|
171
|
-
expect(exitCode).toBe(0)
|
|
172
|
-
expect(stderr).toBe('')
|
|
173
|
-
expect(stdout).toContain('Dry run: local KeeWeb deploy')
|
|
174
|
-
expect(stdout).toContain(deployScriptPath)
|
|
175
|
-
})
|
|
176
|
-
})
|
|
177
|
-
})
|
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
import type {goke} from 'goke'
|
|
2
|
-
import {existsSync} from 'node:fs'
|
|
3
|
-
import {resolve} from 'node:path'
|
|
4
|
-
import {z} from 'zod'
|
|
5
|
-
|
|
6
|
-
const REPO = 'marcusrbrown/infra'
|
|
7
|
-
const WORKFLOW_NAME = 'Deploy KeeWeb'
|
|
8
|
-
const WORKFLOW_URL = 'https://github.com/marcusrbrown/infra/actions/workflows/deploy-keeweb.yaml'
|
|
9
|
-
|
|
10
|
-
const DEFAULT_HOST = 'box.heatvision.co'
|
|
11
|
-
const DEFAULT_REMOTE_USER = 'deploy-kw'
|
|
12
|
-
const DEFAULT_SITE_DIR = '/home/user-data/www/kw.igg.ms'
|
|
13
|
-
|
|
14
|
-
type CliInstance = ReturnType<typeof goke>
|
|
15
|
-
|
|
16
|
-
export function resolveDeployScriptPath(): string {
|
|
17
|
-
const primary = resolve(import.meta.dir, '../../../../../apps/keeweb/deploy.sh')
|
|
18
|
-
|
|
19
|
-
if (existsSync(primary)) {
|
|
20
|
-
return primary
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
return resolve(import.meta.dir, '../../../../apps/keeweb/deploy.sh')
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function resolveDistIndexPath(): string {
|
|
27
|
-
return resolve(import.meta.dir, '../../../../../apps/keeweb/dist/index.html')
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function getLocalDeployEnv(): Record<string, string> {
|
|
31
|
-
const path = process.env.PATH
|
|
32
|
-
const home = process.env.HOME
|
|
33
|
-
const sshAuthSock = process.env.SSH_AUTH_SOCK
|
|
34
|
-
|
|
35
|
-
if (!path) {
|
|
36
|
-
throw new Error('PATH is required for local deploy')
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (!home) {
|
|
40
|
-
throw new Error('HOME is required for local deploy')
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (!sshAuthSock) {
|
|
44
|
-
throw new Error('SSH_AUTH_SOCK is required for local deploy. Start ssh-agent and load your deploy key first.')
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return {
|
|
48
|
-
HOST: process.env.HOST ?? DEFAULT_HOST,
|
|
49
|
-
REMOTE_USER: process.env.REMOTE_USER ?? DEFAULT_REMOTE_USER,
|
|
50
|
-
SITE_DIR: process.env.SITE_DIR ?? DEFAULT_SITE_DIR,
|
|
51
|
-
SSH_AUTH_SOCK: sshAuthSock,
|
|
52
|
-
PATH: path,
|
|
53
|
-
HOME: home,
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function validateLocalPreconditions(options: {nginx?: boolean}): {
|
|
58
|
-
deployScriptPath: string
|
|
59
|
-
distIndexPath: string
|
|
60
|
-
} {
|
|
61
|
-
const deployScriptPath = resolveDeployScriptPath()
|
|
62
|
-
if (!existsSync(deployScriptPath)) {
|
|
63
|
-
throw new Error(`deploy.sh not found at expected path: ${deployScriptPath}`)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const distIndexPath = resolveDistIndexPath()
|
|
67
|
-
if (!existsSync(distIndexPath)) {
|
|
68
|
-
throw new Error(`Local deploy requires built assets. Missing: ${distIndexPath}`)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
getLocalDeployEnv()
|
|
72
|
-
|
|
73
|
-
if (options.nginx) {
|
|
74
|
-
// no-op placeholder to keep validation branch explicit
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return {deployScriptPath, distIndexPath}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function validateRemotePreconditions(): void {
|
|
81
|
-
if (!Bun.which('gh')) {
|
|
82
|
-
throw new Error('gh CLI is required for remote deploy. Install gh and run `gh auth login`.')
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function registerKeewebDeploy(cli: CliInstance): void {
|
|
87
|
-
cli
|
|
88
|
-
.command(
|
|
89
|
-
'keeweb deploy',
|
|
90
|
-
'Trigger KeeWeb deployment. Default mode dispatches the GitHub Deploy KeeWeb workflow. Use --local to run apps/keeweb/deploy.sh directly from this repo.',
|
|
91
|
-
)
|
|
92
|
-
.option(
|
|
93
|
-
'--local',
|
|
94
|
-
z
|
|
95
|
-
.boolean()
|
|
96
|
-
.default(false)
|
|
97
|
-
.describe('Run local deployment using apps/keeweb/deploy.sh instead of triggering GitHub Actions.'),
|
|
98
|
-
)
|
|
99
|
-
.option(
|
|
100
|
-
'--nginx',
|
|
101
|
-
z
|
|
102
|
-
.boolean()
|
|
103
|
-
.default(false)
|
|
104
|
-
.describe(
|
|
105
|
-
'Include nginx config deployment. Valid only with --local and passed through to deploy.sh as --nginx.',
|
|
106
|
-
),
|
|
107
|
-
)
|
|
108
|
-
.option(
|
|
109
|
-
'--dry-run',
|
|
110
|
-
z
|
|
111
|
-
.boolean()
|
|
112
|
-
.default(false)
|
|
113
|
-
.describe(
|
|
114
|
-
'Print planned actions without validating preconditions, executing deploy.sh, or triggering GitHub Actions.',
|
|
115
|
-
),
|
|
116
|
-
)
|
|
117
|
-
.example('# Trigger GitHub Deploy KeeWeb workflow (default mode)')
|
|
118
|
-
.example('infra keeweb deploy')
|
|
119
|
-
.example('# Preview local deploy plan without side effects')
|
|
120
|
-
.example('infra keeweb deploy --local --dry-run')
|
|
121
|
-
.example('# Run local deploy including nginx config update')
|
|
122
|
-
.example('infra keeweb deploy --local --nginx')
|
|
123
|
-
.action(async options => {
|
|
124
|
-
if (options.nginx && !options.local) {
|
|
125
|
-
throw new Error('--nginx is only valid with --local')
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (options.local) {
|
|
129
|
-
if (options.dryRun) {
|
|
130
|
-
const deployScriptPath = resolveDeployScriptPath()
|
|
131
|
-
const args = ['bash', deployScriptPath]
|
|
132
|
-
if (options.nginx) {
|
|
133
|
-
args.push('--nginx')
|
|
134
|
-
}
|
|
135
|
-
console.log('Dry run: local KeeWeb deploy')
|
|
136
|
-
console.log(`- deploy script: ${deployScriptPath}`)
|
|
137
|
-
console.log(`- dist check: ${resolveDistIndexPath()}`)
|
|
138
|
-
console.log(`- command: ${args.join(' ')}`)
|
|
139
|
-
return
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const {deployScriptPath} = validateLocalPreconditions({nginx: options.nginx})
|
|
143
|
-
const env = getLocalDeployEnv()
|
|
144
|
-
const args = ['bash', deployScriptPath]
|
|
145
|
-
|
|
146
|
-
if (options.nginx) {
|
|
147
|
-
args.push('--nginx')
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const child = Bun.spawn(args, {
|
|
151
|
-
env,
|
|
152
|
-
stdout: 'inherit',
|
|
153
|
-
stderr: 'inherit',
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
const exitCode = await child.exited
|
|
157
|
-
if (exitCode !== 0) {
|
|
158
|
-
throw new Error(`Local deploy failed with exit code ${exitCode}`)
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
return
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (options.dryRun) {
|
|
165
|
-
console.log('Dry run: remote KeeWeb deploy')
|
|
166
|
-
console.log(`- command: gh workflow run ${WORKFLOW_NAME} --repo ${REPO}`)
|
|
167
|
-
console.log(`- workflow URL: ${WORKFLOW_URL}`)
|
|
168
|
-
return
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
validateRemotePreconditions()
|
|
172
|
-
|
|
173
|
-
console.warn(
|
|
174
|
-
'Warning: the Deploy KeeWeb workflow includes nginx config deployment as part of workflow_dispatch logic.',
|
|
175
|
-
)
|
|
176
|
-
console.warn('Warning: the workflow requires keeweb environment approval before jobs execute.')
|
|
177
|
-
|
|
178
|
-
const child = Bun.spawn(['gh', 'workflow', 'run', WORKFLOW_NAME, '--repo', REPO], {
|
|
179
|
-
stdout: 'inherit',
|
|
180
|
-
stderr: 'inherit',
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
const exitCode = await child.exited
|
|
184
|
-
if (exitCode !== 0) {
|
|
185
|
-
throw new Error(`Failed to trigger Deploy KeeWeb workflow (exit code ${exitCode})`)
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
console.log(`Workflow triggered: ${WORKFLOW_URL}`)
|
|
189
|
-
console.log('Approve the keeweb environment deployment in GitHub Actions to continue.')
|
|
190
|
-
})
|
|
191
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import type {goke} from 'goke'
|
|
2
|
-
|
|
3
|
-
import {registerKeewebDeploy} from './deploy'
|
|
4
|
-
import {registerKeewebOpen} from './open'
|
|
5
|
-
import {registerKeewebStatus} from './status'
|
|
6
|
-
|
|
7
|
-
export function registerKeewebCommands(cli: ReturnType<typeof goke>): void {
|
|
8
|
-
registerKeewebStatus(cli)
|
|
9
|
-
registerKeewebDeploy(cli)
|
|
10
|
-
registerKeewebOpen(cli)
|
|
11
|
-
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import {resolve} from 'node:path'
|
|
2
|
-
import {describe, expect, it} from 'bun:test'
|
|
3
|
-
|
|
4
|
-
import {getOpenerCommand, KEEWEB_URL} from './open'
|
|
5
|
-
|
|
6
|
-
const cliDir = resolve(import.meta.dir, '../../..')
|
|
7
|
-
|
|
8
|
-
describe('keeweb open', () => {
|
|
9
|
-
describe('getOpenerCommand', () => {
|
|
10
|
-
it('returns "open" on darwin', () => {
|
|
11
|
-
const originalPlatform = process.platform
|
|
12
|
-
Object.defineProperty(process, 'platform', {value: 'darwin', configurable: true})
|
|
13
|
-
|
|
14
|
-
expect(getOpenerCommand()).toBe('open')
|
|
15
|
-
|
|
16
|
-
Object.defineProperty(process, 'platform', {value: originalPlatform, configurable: true})
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('returns "xdg-open" on linux', () => {
|
|
20
|
-
const originalPlatform = process.platform
|
|
21
|
-
Object.defineProperty(process, 'platform', {value: 'linux', configurable: true})
|
|
22
|
-
|
|
23
|
-
expect(getOpenerCommand()).toBe('xdg-open')
|
|
24
|
-
|
|
25
|
-
Object.defineProperty(process, 'platform', {value: originalPlatform, configurable: true})
|
|
26
|
-
})
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
describe('KEEWEB_URL', () => {
|
|
30
|
-
it('is the expected URL', () => {
|
|
31
|
-
expect(KEEWEB_URL).toBe('https://kw.igg.ms/')
|
|
32
|
-
})
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
describe('CLI help', () => {
|
|
36
|
-
it('prints help text', async () => {
|
|
37
|
-
const proc = Bun.spawn(['bun', 'src/cli.ts', 'keeweb', 'open', '--help'], {
|
|
38
|
-
cwd: cliDir,
|
|
39
|
-
env: {...process.env, NO_COLOR: '1'},
|
|
40
|
-
stdout: 'pipe',
|
|
41
|
-
stderr: 'pipe',
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
const [stdout, , exitCode] = await Promise.all([
|
|
45
|
-
new Response(proc.stdout).text(),
|
|
46
|
-
new Response(proc.stderr).text(),
|
|
47
|
-
proc.exited,
|
|
48
|
-
])
|
|
49
|
-
|
|
50
|
-
expect(exitCode).toBe(0)
|
|
51
|
-
expect(stdout).toContain('Open KeeWeb in the default browser')
|
|
52
|
-
})
|
|
53
|
-
})
|
|
54
|
-
})
|