@marcusrbrown/infra 0.11.4 → 0.12.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/dashboard/deploy.test.ts +218 -0
- package/src/commands/dashboard/deploy.ts +135 -0
- package/src/commands/dashboard/host.test.ts +79 -0
- package/src/commands/dashboard/host.ts +33 -0
- package/src/commands/dashboard/index.ts +13 -0
- package/src/commands/dashboard/logs.test.ts +317 -0
- package/src/commands/dashboard/logs.ts +180 -0
- package/src/commands/dashboard/status.test.ts +708 -0
- package/src/commands/dashboard/status.ts +293 -0
- package/src/commands/mcp.test.ts +5 -0
- package/src/commands/mcp.ts +3 -0
- package/src/commands/status.test.ts +17 -0
- package/src/commands/status.ts +12 -3
- package/src/resources/known_hosts +8 -0
package/package.json
CHANGED
|
@@ -150,6 +150,24 @@ Commands:
|
|
|
150
150
|
--key [key] Environment variable name holding the SSH host. Falls back to UMAMI_DOMAIN when omitted.
|
|
151
151
|
|
|
152
152
|
|
|
153
|
+
dashboard status Show operational health of the dashboard deployment via docker compose ps.
|
|
154
|
+
|
|
155
|
+
--key [key] Environment variable name holding the SSH host. Falls back to DASHBOARD_DOMAIN when omitted.
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
dashboard deploy Deploy the dashboard. Default mode triggers the GitHub Deploy Dashboard workflow, while --local runs apps/dashboard deploy directly with Bun.
|
|
159
|
+
|
|
160
|
+
--local Run local deployment with Bun using apps/dashboard instead of triggering GitHub Actions. (default: false)
|
|
161
|
+
--dry-run Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow. (default: false)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
dashboard logs [service] Stream logs from a dashboard service via SSH and docker compose.
|
|
165
|
+
|
|
166
|
+
--tail [n] Number of log lines to tail from each service. (default: 100)
|
|
167
|
+
--allow-ci Allow log streaming in CI environments. Logs may contain sensitive credentials. (default: false)
|
|
168
|
+
--key [key] Environment variable name holding the SSH host. Falls back to DASHBOARD_DOMAIN when omitted.
|
|
169
|
+
|
|
170
|
+
|
|
153
171
|
vpn status Show operational health of the VPN box via SSH and wg show.
|
|
154
172
|
|
|
155
173
|
--key [key] Environment variable name holding the SSH host. Falls back to VPN_HOST when omitted.
|
|
@@ -185,7 +203,7 @@ Commands:
|
|
|
185
203
|
|
|
186
204
|
status Show status of all deployments
|
|
187
205
|
|
|
188
|
-
--json Output machine-readable JSON with keeweb, cliproxy, gateway, umami, and vpn summary objects.
|
|
206
|
+
--json Output machine-readable JSON with keeweb, cliproxy, gateway, umami, dashboard, and vpn summary objects.
|
|
189
207
|
--verbose Include verbose per-app health check details when building the summary rows.
|
|
190
208
|
|
|
191
209
|
|
package/src/cli.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import {goke} from 'goke'
|
|
4
4
|
import pkg from '../package.json' with {type: 'json'}
|
|
5
5
|
import {registerCliproxyCommands} from './commands/cliproxy'
|
|
6
|
+
import {registerDashboardCommands} from './commands/dashboard'
|
|
6
7
|
import {registerGatewayCommands} from './commands/gateway'
|
|
7
8
|
import {registerKeewebCommands} from './commands/keeweb'
|
|
8
9
|
import {registerMcp} from './commands/mcp'
|
|
@@ -23,6 +24,7 @@ registerKeewebCommands(cli)
|
|
|
23
24
|
registerCliproxyCommands(cli)
|
|
24
25
|
registerGatewayCommands(cli)
|
|
25
26
|
registerUmamiCommands(cli)
|
|
27
|
+
registerDashboardCommands(cli)
|
|
26
28
|
registerVpnCommands(cli)
|
|
27
29
|
registerStatus(cli)
|
|
28
30
|
registerMcp(cli)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import {resolve} from 'node:path'
|
|
2
|
+
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
3
|
+
|
|
4
|
+
import {getDashboardDeployEnv, validateDashboardRemotePreconditions} from './deploy'
|
|
5
|
+
|
|
6
|
+
const repoRoot = resolve(import.meta.dir, '../../../../..')
|
|
7
|
+
|
|
8
|
+
const envKeys = [
|
|
9
|
+
'HOME',
|
|
10
|
+
'PATH',
|
|
11
|
+
'SSH_AUTH_SOCK',
|
|
12
|
+
'DASHBOARD_DOMAIN',
|
|
13
|
+
'DASHBOARD_SSH_KEY',
|
|
14
|
+
'DASHBOARD_IMAGE_DIGEST',
|
|
15
|
+
'DASHBOARD_GITHUB_APP_ID',
|
|
16
|
+
'DASHBOARD_GITHUB_APP_KEY',
|
|
17
|
+
'DASHBOARD_OAUTH_CLIENT_ID',
|
|
18
|
+
'DASHBOARD_OAUTH_CLIENT_SECRET',
|
|
19
|
+
'DASHBOARD_OPERATOR_LOGIN',
|
|
20
|
+
'DASHBOARD_COOKIE_KEY',
|
|
21
|
+
] as const
|
|
22
|
+
|
|
23
|
+
type ManagedEnvKey = (typeof envKeys)[number]
|
|
24
|
+
|
|
25
|
+
let originalEnv: Partial<Record<ManagedEnvKey, string | undefined>>
|
|
26
|
+
|
|
27
|
+
function restoreManagedEnv(): void {
|
|
28
|
+
for (const key of envKeys) {
|
|
29
|
+
const value = originalEnv[key]
|
|
30
|
+
if (value === undefined) {
|
|
31
|
+
delete process.env[key]
|
|
32
|
+
} else {
|
|
33
|
+
process.env[key] = value
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function setManagedEnv(overrides: Partial<Record<ManagedEnvKey, string | undefined>>): void {
|
|
39
|
+
restoreManagedEnv()
|
|
40
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
41
|
+
if (value === undefined) {
|
|
42
|
+
delete process.env[key as ManagedEnvKey]
|
|
43
|
+
} else {
|
|
44
|
+
process.env[key as ManagedEnvKey] = value
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
originalEnv = {}
|
|
51
|
+
for (const key of envKeys) {
|
|
52
|
+
originalEnv[key] = process.env[key]
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
restoreManagedEnv()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// ─── getDashboardDeployEnv ────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
describe('getDashboardDeployEnv', () => {
|
|
63
|
+
it('returns env object with required keys when all are set', () => {
|
|
64
|
+
setManagedEnv({
|
|
65
|
+
PATH: '/usr/bin:/bin',
|
|
66
|
+
HOME: '/home/user',
|
|
67
|
+
SSH_AUTH_SOCK: '/tmp/ssh-agent.sock',
|
|
68
|
+
DASHBOARD_DOMAIN: 'dashboard.fro.bot',
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const env = getDashboardDeployEnv()
|
|
72
|
+
|
|
73
|
+
expect(env.PATH).toBe('/usr/bin:/bin')
|
|
74
|
+
expect(env.HOME).toBe('/home/user')
|
|
75
|
+
expect(env.SSH_AUTH_SOCK).toBe('/tmp/ssh-agent.sock')
|
|
76
|
+
expect(env.DASHBOARD_DOMAIN).toBe('dashboard.fro.bot')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('throws when PATH is missing', () => {
|
|
80
|
+
setManagedEnv({PATH: undefined, HOME: '/home/user', SSH_AUTH_SOCK: '/tmp/ssh-agent.sock'})
|
|
81
|
+
|
|
82
|
+
expect(() => getDashboardDeployEnv()).toThrow('PATH is required')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('throws when HOME is missing', () => {
|
|
86
|
+
setManagedEnv({PATH: '/usr/bin:/bin', HOME: undefined, SSH_AUTH_SOCK: '/tmp/ssh-agent.sock'})
|
|
87
|
+
|
|
88
|
+
expect(() => getDashboardDeployEnv()).toThrow('HOME is required')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('throws when SSH_AUTH_SOCK is missing and no DASHBOARD_SSH_KEY either', () => {
|
|
92
|
+
setManagedEnv({
|
|
93
|
+
PATH: '/usr/bin:/bin',
|
|
94
|
+
HOME: '/home/user',
|
|
95
|
+
SSH_AUTH_SOCK: undefined,
|
|
96
|
+
DASHBOARD_SSH_KEY: undefined,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
expect(() => getDashboardDeployEnv()).toThrow('Local deploy needs an SSH context')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('succeeds with only SSH_AUTH_SOCK set (no DASHBOARD_SSH_KEY)', () => {
|
|
103
|
+
setManagedEnv({
|
|
104
|
+
PATH: '/usr/bin:/bin',
|
|
105
|
+
HOME: '/home/user',
|
|
106
|
+
SSH_AUTH_SOCK: '/tmp/ssh-agent.sock',
|
|
107
|
+
DASHBOARD_SSH_KEY: undefined,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const env = getDashboardDeployEnv()
|
|
111
|
+
|
|
112
|
+
expect(env.SSH_AUTH_SOCK).toBe('/tmp/ssh-agent.sock')
|
|
113
|
+
expect('DASHBOARD_SSH_KEY' in env).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('succeeds with only DASHBOARD_SSH_KEY set (no SSH_AUTH_SOCK) and includes the key in env', () => {
|
|
117
|
+
setManagedEnv({
|
|
118
|
+
PATH: '/usr/bin:/bin',
|
|
119
|
+
HOME: '/home/user',
|
|
120
|
+
SSH_AUTH_SOCK: undefined,
|
|
121
|
+
DASHBOARD_SSH_KEY: 'ssh-ed25519 AAAA...',
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const env = getDashboardDeployEnv()
|
|
125
|
+
|
|
126
|
+
expect(env.DASHBOARD_SSH_KEY).toBe('ssh-ed25519 AAAA...')
|
|
127
|
+
expect('SSH_AUTH_SOCK' in env).toBe(false)
|
|
128
|
+
expect(env.DASHBOARD_SSH_KEY).toBe('ssh-ed25519 AAAA...')
|
|
129
|
+
expect('SSH_AUTH_SOCK' in env).toBe(false)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('forwards all DASHBOARD_* vars from process.env (not a selective subset)', () => {
|
|
133
|
+
setManagedEnv({
|
|
134
|
+
PATH: '/usr/bin:/bin',
|
|
135
|
+
HOME: '/home/user',
|
|
136
|
+
SSH_AUTH_SOCK: '/tmp/ssh-agent.sock',
|
|
137
|
+
DASHBOARD_DOMAIN: 'dashboard.fro.bot',
|
|
138
|
+
DASHBOARD_IMAGE_DIGEST: `sha256:${'a'.repeat(64)}`,
|
|
139
|
+
DASHBOARD_GITHUB_APP_ID: '123456',
|
|
140
|
+
DASHBOARD_GITHUB_APP_KEY: '-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----',
|
|
141
|
+
DASHBOARD_OAUTH_CLIENT_ID: 'Iv1.abc123',
|
|
142
|
+
DASHBOARD_OAUTH_CLIENT_SECRET: 'oauthsecret',
|
|
143
|
+
DASHBOARD_OPERATOR_LOGIN: 'marcusrbrown',
|
|
144
|
+
DASHBOARD_COOKIE_KEY: 'cookiekey',
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const env = getDashboardDeployEnv()
|
|
148
|
+
|
|
149
|
+
// All DASHBOARD_* vars must be present in the returned env
|
|
150
|
+
expect(env.DASHBOARD_DOMAIN).toBe('dashboard.fro.bot')
|
|
151
|
+
expect(env.DASHBOARD_IMAGE_DIGEST).toBe(`sha256:${'a'.repeat(64)}`)
|
|
152
|
+
expect(env.DASHBOARD_GITHUB_APP_ID).toBe('123456')
|
|
153
|
+
expect(env.DASHBOARD_OAUTH_CLIENT_ID).toBe('Iv1.abc123')
|
|
154
|
+
expect(env.DASHBOARD_OAUTH_CLIENT_SECRET).toBe('oauthsecret')
|
|
155
|
+
expect(env.DASHBOARD_OPERATOR_LOGIN).toBe('marcusrbrown')
|
|
156
|
+
expect(env.DASHBOARD_COOKIE_KEY).toBe('cookiekey')
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// ─── validateDashboardRemotePreconditions ─────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
describe('validateDashboardRemotePreconditions', () => {
|
|
163
|
+
it('throws a clear error when gh is not available', () => {
|
|
164
|
+
// We cannot reliably mock Bun.which, so we test the function contract:
|
|
165
|
+
// if gh is not installed, it should throw with a helpful message.
|
|
166
|
+
// This test verifies the error message shape by calling with a known-missing binary.
|
|
167
|
+
// In CI where gh IS installed, we skip this test.
|
|
168
|
+
if (Bun.which('gh')) {
|
|
169
|
+
// gh is available — just verify the function does not throw
|
|
170
|
+
expect(() => validateDashboardRemotePreconditions()).not.toThrow()
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
expect(() => validateDashboardRemotePreconditions()).toThrow('gh CLI is required')
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
// ─── deploy command (subprocess integration via CLI) ─────────────────────────
|
|
179
|
+
|
|
180
|
+
describe('deploy command', () => {
|
|
181
|
+
it('dry-run remote mode prints planned gh workflow run command without executing', async () => {
|
|
182
|
+
const proc = Bun.spawn(['bun', 'run', 'packages/cli/src/cli.ts', 'dashboard', 'deploy', '--dry-run'], {
|
|
183
|
+
cwd: repoRoot,
|
|
184
|
+
env: {...process.env, NO_COLOR: '1'},
|
|
185
|
+
stdout: 'pipe',
|
|
186
|
+
stderr: 'pipe',
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
const [stdout, _stderr, exitCode] = await Promise.all([
|
|
190
|
+
new Response(proc.stdout).text(),
|
|
191
|
+
new Response(proc.stderr).text(),
|
|
192
|
+
proc.exited,
|
|
193
|
+
])
|
|
194
|
+
|
|
195
|
+
expect(exitCode).toBe(0)
|
|
196
|
+
expect(stdout).toContain('Dry run')
|
|
197
|
+
expect(stdout).toContain('Deploy Dashboard')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('dry-run local mode prints planned bun command without executing', async () => {
|
|
201
|
+
const proc = Bun.spawn(['bun', 'run', 'packages/cli/src/cli.ts', 'dashboard', 'deploy', '--local', '--dry-run'], {
|
|
202
|
+
cwd: repoRoot,
|
|
203
|
+
env: {...process.env, NO_COLOR: '1'},
|
|
204
|
+
stdout: 'pipe',
|
|
205
|
+
stderr: 'pipe',
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const [stdout, _stderr, exitCode] = await Promise.all([
|
|
209
|
+
new Response(proc.stdout).text(),
|
|
210
|
+
new Response(proc.stderr).text(),
|
|
211
|
+
proc.exited,
|
|
212
|
+
])
|
|
213
|
+
|
|
214
|
+
expect(exitCode).toBe(0)
|
|
215
|
+
expect(stdout).toContain('Dry run')
|
|
216
|
+
expect(stdout).toContain('apps/dashboard')
|
|
217
|
+
})
|
|
218
|
+
})
|
|
@@ -0,0 +1,135 @@
|
|
|
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 Dashboard'
|
|
12
|
+
const WORKFLOW_URL = 'https://github.com/marcusrbrown/infra/actions/workflows/deploy-dashboard.yaml'
|
|
13
|
+
|
|
14
|
+
type CliInstance = ReturnType<typeof goke>
|
|
15
|
+
|
|
16
|
+
// ─── Env helpers ─────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export function getDashboardDeployEnv(): 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.DASHBOARD_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(
|
|
34
|
+
'Local deploy needs an SSH context: set SSH_AUTH_SOCK (ssh-agent) or DASHBOARD_SSH_KEY (key from env).',
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Spread all of process.env so DASHBOARD_* vars (IMAGE_DIGEST, GITHUB_APP_ID, etc.)
|
|
39
|
+
// are forwarded to the spawned deploy.ts process. validateEnv in deploy.ts requires
|
|
40
|
+
// all DASHBOARD_* vars to be present — a selective subset would cause it to fail.
|
|
41
|
+
// Filter out undefined values (Record<string, string> does not allow undefined).
|
|
42
|
+
const fullEnv: Record<string, string> = {}
|
|
43
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
44
|
+
if (v !== undefined) {
|
|
45
|
+
fullEnv[k] = v
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return fullEnv
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function validateDashboardRemotePreconditions(): void {
|
|
52
|
+
if (!Bun.which('gh')) {
|
|
53
|
+
throw new Error('gh CLI is required for remote deploy. Install gh and run `gh auth login`.')
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Command registration ─────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export function registerDashboardDeploy(cli: CliInstance): void {
|
|
60
|
+
cli
|
|
61
|
+
.command(
|
|
62
|
+
'dashboard deploy',
|
|
63
|
+
'Deploy the dashboard. Default mode triggers the GitHub Deploy Dashboard workflow, while --local runs apps/dashboard deploy directly with Bun.',
|
|
64
|
+
)
|
|
65
|
+
.option(
|
|
66
|
+
'--local',
|
|
67
|
+
z
|
|
68
|
+
.boolean()
|
|
69
|
+
.default(false)
|
|
70
|
+
.describe('Run local deployment with Bun using apps/dashboard instead of triggering GitHub Actions.'),
|
|
71
|
+
)
|
|
72
|
+
.option(
|
|
73
|
+
'--dry-run',
|
|
74
|
+
z
|
|
75
|
+
.boolean()
|
|
76
|
+
.default(false)
|
|
77
|
+
.describe(
|
|
78
|
+
'Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow.',
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
.example('# Trigger remote GitHub Actions deploy (default mode)')
|
|
82
|
+
.example('infra dashboard deploy')
|
|
83
|
+
.example('# Validate local deploy preconditions and planned command')
|
|
84
|
+
.example('infra dashboard deploy --local --dry-run')
|
|
85
|
+
.example('# Run local deploy with explicit SSH agent context')
|
|
86
|
+
.example('infra dashboard deploy --local')
|
|
87
|
+
.action(async options => {
|
|
88
|
+
if (options.local) {
|
|
89
|
+
const command = ['bun', 'run', '--cwd', 'apps/dashboard', 'deploy']
|
|
90
|
+
|
|
91
|
+
if (options.dryRun) {
|
|
92
|
+
console.log('Dry run: local dashboard deploy')
|
|
93
|
+
console.log(`- command: ${command.join(' ')}`)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const env = getDashboardDeployEnv()
|
|
98
|
+
|
|
99
|
+
const child = Bun.spawn(command, {
|
|
100
|
+
env,
|
|
101
|
+
stdout: 'inherit',
|
|
102
|
+
stderr: 'inherit',
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const exitCode = await child.exited
|
|
106
|
+
if (exitCode !== 0) {
|
|
107
|
+
throw new Error(`Local deploy failed with exit code ${exitCode}`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
validateDashboardRemotePreconditions()
|
|
114
|
+
|
|
115
|
+
if (options.dryRun) {
|
|
116
|
+
console.log('Dry run: remote dashboard deploy')
|
|
117
|
+
console.log(`- command: gh workflow run "${WORKFLOW_NAME}" --repo ${REPO}`)
|
|
118
|
+
console.log(`- workflow URL: ${WORKFLOW_URL}`)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const child = Bun.spawn(['gh', 'workflow', 'run', WORKFLOW_NAME, '--repo', REPO], {
|
|
123
|
+
stdout: 'inherit',
|
|
124
|
+
stderr: 'inherit',
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const exitCode = await child.exited
|
|
128
|
+
if (exitCode !== 0) {
|
|
129
|
+
throw new Error(`Failed to trigger "${WORKFLOW_NAME}" workflow (exit code ${exitCode})`)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log(`Workflow triggered: ${WORKFLOW_URL}`)
|
|
133
|
+
console.log('Approve the dashboard environment deployment in GitHub Actions to continue.')
|
|
134
|
+
})
|
|
135
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {describe, expect, it} from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {validateDashboardHost} from './host'
|
|
4
|
+
|
|
5
|
+
describe('validateDashboardHost', () => {
|
|
6
|
+
it('accepts a standard FQDN', () => {
|
|
7
|
+
expect(() => validateDashboardHost('dashboard.fro.bot')).not.toThrow()
|
|
8
|
+
expect(validateDashboardHost('dashboard.fro.bot')).toBe('dashboard.fro.bot')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('accepts localhost', () => {
|
|
12
|
+
expect(() => validateDashboardHost('localhost')).not.toThrow()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('accepts an IPv4 address', () => {
|
|
16
|
+
expect(() => validateDashboardHost('147.182.133.210')).not.toThrow()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('accepts a single-character hostname', () => {
|
|
20
|
+
expect(() => validateDashboardHost('a')).not.toThrow()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('accepts a hostname with hyphens', () => {
|
|
24
|
+
expect(() => validateDashboardHost('my-dashboard.prod.example.com')).not.toThrow()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('rejects a leading-hyphen value (ProxyCommand injection vector)', () => {
|
|
28
|
+
expect(() => validateDashboardHost('-oProxyCommand=evil')).toThrow('Invalid DASHBOARD_DOMAIN')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('rejects a value with shell metacharacters (semicolon)', () => {
|
|
32
|
+
expect(() => validateDashboardHost('dashboard.fro.bot;rm -rf')).toThrow('Invalid DASHBOARD_DOMAIN')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('rejects a value with shell metacharacters (backtick)', () => {
|
|
36
|
+
expect(() => validateDashboardHost('dashboard.fro.bot`id`')).toThrow('Invalid DASHBOARD_DOMAIN')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('rejects a value with spaces', () => {
|
|
40
|
+
expect(() => validateDashboardHost('dashboard fro.bot')).toThrow('Invalid DASHBOARD_DOMAIN')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('rejects a value with an at-sign', () => {
|
|
44
|
+
expect(() => validateDashboardHost('user@dashboard.fro.bot')).toThrow('Invalid DASHBOARD_DOMAIN')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('rejects an empty string', () => {
|
|
48
|
+
expect(() => validateDashboardHost('')).toThrow('Invalid DASHBOARD_DOMAIN')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('omits the rejected value entirely from the error message', () => {
|
|
52
|
+
const longMalicious = `-oProxyCommand=${'A'.repeat(100)}`
|
|
53
|
+
let message = ''
|
|
54
|
+
try {
|
|
55
|
+
validateDashboardHost(longMalicious)
|
|
56
|
+
} catch (error) {
|
|
57
|
+
message = error instanceof Error ? error.message : String(error)
|
|
58
|
+
}
|
|
59
|
+
// The value is never echoed (it may be a misdirected secret) — only the constraint is shown.
|
|
60
|
+
expect(message).toContain('Invalid DASHBOARD_DOMAIN')
|
|
61
|
+
expect(message).not.toContain('ProxyCommand')
|
|
62
|
+
expect(message).not.toContain('AAAA')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// ── Secret value redaction ───────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
it('does not echo the rejected value in the error message', () => {
|
|
68
|
+
const secretValue = 'AWS_SECRET_KEY=AKIA1234567890EXAMPLE'
|
|
69
|
+
let message = ''
|
|
70
|
+
try {
|
|
71
|
+
validateDashboardHost(secretValue)
|
|
72
|
+
} catch (error) {
|
|
73
|
+
message = error instanceof Error ? error.message : String(error)
|
|
74
|
+
}
|
|
75
|
+
expect(message).toContain('Invalid DASHBOARD_DOMAIN')
|
|
76
|
+
expect(message).not.toContain('AKIA1234567890EXAMPLE')
|
|
77
|
+
expect(message).not.toContain('AWS_SECRET_KEY')
|
|
78
|
+
})
|
|
79
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// ─── Dashboard host validation ────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Validates DASHBOARD_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 DASHBOARD_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} when empty or not a valid hostname. The rejected value is
|
|
17
|
+
* never included in the message (it may be a misdirected secret).
|
|
18
|
+
* @returns The validated host string (unchanged).
|
|
19
|
+
*/
|
|
20
|
+
export function validateDashboardHost(host: string): string {
|
|
21
|
+
if (!host) {
|
|
22
|
+
throw new Error('Invalid DASHBOARD_DOMAIN: value is empty')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!VALID_HOST_RE.test(host)) {
|
|
26
|
+
// Do NOT echo the value — it may be a secret that was redirected via --key
|
|
27
|
+
throw new Error(
|
|
28
|
+
`Invalid DASHBOARD_DOMAIN: value is not a valid hostname (must match ${String.raw`[A-Za-z0-9][A-Za-z0-9.\-]*`})`,
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return host
|
|
33
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type {goke} from 'goke'
|
|
2
|
+
|
|
3
|
+
import {registerDashboardDeploy} from './deploy'
|
|
4
|
+
import {registerDashboardLogs} from './logs'
|
|
5
|
+
import {registerDashboardStatus} from './status'
|
|
6
|
+
|
|
7
|
+
export {getDashboardStatusSummary} from './status'
|
|
8
|
+
|
|
9
|
+
export function registerDashboardCommands(cli: ReturnType<typeof goke>): void {
|
|
10
|
+
registerDashboardStatus(cli)
|
|
11
|
+
registerDashboardDeploy(cli)
|
|
12
|
+
registerDashboardLogs(cli)
|
|
13
|
+
}
|