@marcusrbrown/infra 0.1.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.
- package/README.md +56 -0
- package/package.json +40 -0
- package/src/cli.ts +28 -0
- package/src/commands/keeweb-deploy.ts +186 -0
- package/src/commands/keeweb-status.ts +290 -0
- package/src/commands/mcp.ts +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# @marcusrbrown/infra
|
|
2
|
+
|
|
3
|
+
Infrastructure management CLI — deploy automation, health checks, and MCP bridge.
|
|
4
|
+
|
|
5
|
+
> **Requires [Bun](https://bun.sh)** — this package ships TypeScript source with a `#!/usr/bin/env bun` shebang.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add -g @marcusrbrown/infra
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or run directly:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bunx @marcusrbrown/infra --help
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
|
|
21
|
+
### `infra keeweb status`
|
|
22
|
+
|
|
23
|
+
Operational health check for the KeeWeb deployment:
|
|
24
|
+
|
|
25
|
+
- HTTP reachability (status code + response time)
|
|
26
|
+
- Last successful deploy timestamp via GitHub Actions
|
|
27
|
+
- SHA-256 content hash comparison (live site vs local `dist/`)
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bunx @marcusrbrown/infra keeweb status
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### `infra keeweb deploy`
|
|
34
|
+
|
|
35
|
+
Trigger a KeeWeb deployment:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
bunx @marcusrbrown/infra keeweb deploy # GitHub Actions workflow
|
|
39
|
+
bunx @marcusrbrown/infra keeweb deploy --dry-run # preview without executing
|
|
40
|
+
bunx @marcusrbrown/infra keeweb deploy --local # deploy directly via SSH
|
|
41
|
+
bunx @marcusrbrown/infra keeweb deploy --local --nginx # include nginx config
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Local deploy requires `ssh-agent` running with the deploy key loaded.
|
|
45
|
+
|
|
46
|
+
### `infra mcp`
|
|
47
|
+
|
|
48
|
+
Start a stdio MCP server exposing all commands as tools:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bunx @marcusrbrown/infra mcp
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## License
|
|
55
|
+
|
|
56
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@marcusrbrown/infra",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"infra",
|
|
7
|
+
"cli",
|
|
8
|
+
"keeweb",
|
|
9
|
+
"deploy",
|
|
10
|
+
"mcp",
|
|
11
|
+
"bun"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/marcusrbrown/infra.git",
|
|
16
|
+
"directory": "packages/cli"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "Marcus R. Brown <contact@marcusrbrown.com>",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"bin": {
|
|
22
|
+
"infra": "src/cli.ts"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src/"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"cli": "bun run src/cli.ts"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@goke/mcp": "^0.0.9",
|
|
32
|
+
"goke": "^6.3.2",
|
|
33
|
+
"string-dedent": "^3.0.2",
|
|
34
|
+
"zod": "^4.3.6"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public",
|
|
38
|
+
"provenance": true
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import {goke} from 'goke'
|
|
4
|
+
import pkg from '../package.json' with {type: 'json'}
|
|
5
|
+
import {registerKeewebDeploy} from './commands/keeweb-deploy'
|
|
6
|
+
import {registerKeewebStatus} from './commands/keeweb-status'
|
|
7
|
+
import {registerMcp} from './commands/mcp'
|
|
8
|
+
|
|
9
|
+
const cli = goke('infra')
|
|
10
|
+
|
|
11
|
+
cli.option('--verbose', 'Enable verbose output for all commands')
|
|
12
|
+
|
|
13
|
+
registerKeewebStatus(cli)
|
|
14
|
+
registerKeewebDeploy(cli)
|
|
15
|
+
registerMcp(cli)
|
|
16
|
+
|
|
17
|
+
cli.help()
|
|
18
|
+
cli.version(pkg.version)
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
cli.parse(process.argv, {run: false})
|
|
22
|
+
await cli.runMatchedCommand()
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (error instanceof Error) {
|
|
25
|
+
console.error(error.message)
|
|
26
|
+
}
|
|
27
|
+
process.exit(1)
|
|
28
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
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'
|
|
8
|
+
const WORKFLOW_URL = 'https://github.com/marcusrbrown/infra/actions/workflows/deploy.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
|
+
function resolveDeployScriptPath(): string {
|
|
17
|
+
const instructedPath = resolve(import.meta.dir, '../../../apps/keeweb/deploy.sh')
|
|
18
|
+
|
|
19
|
+
if (existsSync(instructedPath)) {
|
|
20
|
+
return instructedPath
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const fallbackPath = resolve(import.meta.dir, '../../../../apps/keeweb/deploy.sh')
|
|
24
|
+
|
|
25
|
+
return fallbackPath
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolveDistIndexPath(): string {
|
|
29
|
+
return resolve(import.meta.dir, '../../../../apps/keeweb/dist/index.html')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getLocalDeployEnv(): Record<string, string> {
|
|
33
|
+
const path = process.env.PATH
|
|
34
|
+
const home = process.env.HOME
|
|
35
|
+
const sshAuthSock = process.env.SSH_AUTH_SOCK
|
|
36
|
+
|
|
37
|
+
if (!path) {
|
|
38
|
+
throw new Error('PATH is required for local deploy')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!home) {
|
|
42
|
+
throw new Error('HOME is required for local deploy')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!sshAuthSock) {
|
|
46
|
+
throw new Error('SSH_AUTH_SOCK is required for local deploy. Start ssh-agent and load your deploy key first.')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
HOST: process.env.HOST ?? DEFAULT_HOST,
|
|
51
|
+
REMOTE_USER: process.env.REMOTE_USER ?? DEFAULT_REMOTE_USER,
|
|
52
|
+
SITE_DIR: process.env.SITE_DIR ?? DEFAULT_SITE_DIR,
|
|
53
|
+
SSH_AUTH_SOCK: sshAuthSock,
|
|
54
|
+
PATH: path,
|
|
55
|
+
HOME: home,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function validateLocalPreconditions(options: {nginx?: boolean}): {deployScriptPath: string; distIndexPath: string} {
|
|
60
|
+
const deployScriptPath = resolveDeployScriptPath()
|
|
61
|
+
if (!existsSync(deployScriptPath)) {
|
|
62
|
+
throw new Error(`deploy.sh not found at expected path: ${deployScriptPath}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const distIndexPath = resolveDistIndexPath()
|
|
66
|
+
if (!existsSync(distIndexPath)) {
|
|
67
|
+
throw new Error(`Local deploy requires built assets. Missing: ${distIndexPath}`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
getLocalDeployEnv()
|
|
71
|
+
|
|
72
|
+
if (options.nginx) {
|
|
73
|
+
// no-op placeholder to keep validation branch explicit
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {deployScriptPath, distIndexPath}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validateRemotePreconditions(): void {
|
|
80
|
+
if (!Bun.which('gh')) {
|
|
81
|
+
throw new Error('gh CLI is required for remote deploy. Install gh and run `gh auth login`.')
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function registerKeewebDeploy(cli: CliInstance): void {
|
|
86
|
+
cli
|
|
87
|
+
.command(
|
|
88
|
+
'keeweb deploy',
|
|
89
|
+
'Trigger KeeWeb deployment. Default mode dispatches the GitHub Deploy workflow. Use --local to run apps/keeweb/deploy.sh directly from this repo.',
|
|
90
|
+
)
|
|
91
|
+
.option(
|
|
92
|
+
'--local',
|
|
93
|
+
z
|
|
94
|
+
.boolean()
|
|
95
|
+
.default(false)
|
|
96
|
+
.describe('Run local deployment using apps/keeweb/deploy.sh instead of triggering GitHub Actions.'),
|
|
97
|
+
)
|
|
98
|
+
.option(
|
|
99
|
+
'--nginx',
|
|
100
|
+
z
|
|
101
|
+
.boolean()
|
|
102
|
+
.default(false)
|
|
103
|
+
.describe(
|
|
104
|
+
'Include nginx config deployment. Valid only with --local and passed through to deploy.sh as --nginx.',
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
.option(
|
|
108
|
+
'--dry-run',
|
|
109
|
+
z
|
|
110
|
+
.boolean()
|
|
111
|
+
.default(false)
|
|
112
|
+
.describe(
|
|
113
|
+
'Validate preconditions and print planned actions without executing deploy.sh or triggering GitHub Actions.',
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
.example('# Trigger GitHub Deploy workflow (default mode)')
|
|
117
|
+
.example('infra keeweb deploy')
|
|
118
|
+
.example('# Validate local deploy preconditions without side effects')
|
|
119
|
+
.example('infra keeweb deploy --local --dry-run')
|
|
120
|
+
.example('# Run local deploy including nginx config update')
|
|
121
|
+
.example('infra keeweb deploy --local --nginx')
|
|
122
|
+
.action(async options => {
|
|
123
|
+
if (options.nginx && !options.local) {
|
|
124
|
+
throw new Error('--nginx is only valid with --local')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (options.local) {
|
|
128
|
+
const {deployScriptPath} = validateLocalPreconditions({nginx: options.nginx})
|
|
129
|
+
const env = getLocalDeployEnv()
|
|
130
|
+
const args = ['bash', deployScriptPath]
|
|
131
|
+
|
|
132
|
+
if (options.nginx) {
|
|
133
|
+
args.push('--nginx')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (options.dryRun) {
|
|
137
|
+
console.log('Dry run: local KeeWeb deploy')
|
|
138
|
+
console.log(`- deploy script: ${deployScriptPath}`)
|
|
139
|
+
console.log(`- dist check: ${resolveDistIndexPath()}`)
|
|
140
|
+
console.log(`- command: ${args.join(' ')}`)
|
|
141
|
+
console.log(`- HOST=${env.HOST}`)
|
|
142
|
+
console.log(`- REMOTE_USER=${env.REMOTE_USER}`)
|
|
143
|
+
console.log(`- SITE_DIR=${env.SITE_DIR}`)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const child = Bun.spawn(args, {
|
|
148
|
+
env,
|
|
149
|
+
stdout: 'inherit',
|
|
150
|
+
stderr: 'inherit',
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const exitCode = await child.exited
|
|
154
|
+
if (exitCode !== 0) {
|
|
155
|
+
throw new Error(`Local deploy failed with exit code ${exitCode}`)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
validateRemotePreconditions()
|
|
162
|
+
|
|
163
|
+
console.warn('Warning: the Deploy workflow includes nginx config deployment as part of workflow_dispatch logic.')
|
|
164
|
+
console.warn('Warning: the workflow requires production environment approval before jobs execute.')
|
|
165
|
+
|
|
166
|
+
if (options.dryRun) {
|
|
167
|
+
console.log('Dry run: remote KeeWeb deploy')
|
|
168
|
+
console.log(`- command: gh workflow run ${WORKFLOW_NAME} --repo ${REPO}`)
|
|
169
|
+
console.log(`- workflow URL: ${WORKFLOW_URL}`)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const child = Bun.spawn(['gh', 'workflow', 'run', WORKFLOW_NAME, '--repo', REPO], {
|
|
174
|
+
stdout: 'inherit',
|
|
175
|
+
stderr: 'inherit',
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const exitCode = await child.exited
|
|
179
|
+
if (exitCode !== 0) {
|
|
180
|
+
throw new Error(`Failed to trigger Deploy workflow (exit code ${exitCode})`)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
console.log(`Workflow triggered: ${WORKFLOW_URL}`)
|
|
184
|
+
console.log('Approve the production environment deployment in GitHub Actions to continue.')
|
|
185
|
+
})
|
|
186
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import type {goke} from 'goke'
|
|
2
|
+
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import {z} from 'zod'
|
|
5
|
+
|
|
6
|
+
const SITE_URL = 'https://kw.igg.ms/'
|
|
7
|
+
const GH_REPO = 'marcusrbrown/infra'
|
|
8
|
+
const HTTP_TIMEOUT_MS = 10_000
|
|
9
|
+
|
|
10
|
+
type CheckLevel = 'ok' | 'warning' | 'error'
|
|
11
|
+
|
|
12
|
+
interface CheckResult {
|
|
13
|
+
title: string
|
|
14
|
+
level: CheckLevel
|
|
15
|
+
summary: string
|
|
16
|
+
details?: string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ghRunSchema = z.array(
|
|
20
|
+
z.object({
|
|
21
|
+
createdAt: z.string().min(1),
|
|
22
|
+
url: z.url(),
|
|
23
|
+
}),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
function levelLabel(level: CheckLevel): string {
|
|
27
|
+
if (level === 'ok') {
|
|
28
|
+
return 'OK'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (level === 'warning') {
|
|
32
|
+
return 'WARN'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return 'ERROR'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function formatDurationMs(durationMs: number): string {
|
|
39
|
+
return `${Math.max(0, Math.round(durationMs))}ms`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatDate(value: string): string {
|
|
43
|
+
const date = new Date(value)
|
|
44
|
+
if (Number.isNaN(date.getTime())) {
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return date.toLocaleString()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function hashSha256(value: string): string {
|
|
52
|
+
const hasher = new Bun.CryptoHasher('sha256')
|
|
53
|
+
hasher.update(value)
|
|
54
|
+
return hasher.digest('hex')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function streamToText(stream?: ReadableStream<Uint8Array> | null): Promise<string> {
|
|
58
|
+
if (!stream) {
|
|
59
|
+
return ''
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return await new Response(stream).text()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function checkHttpReachability(verbose: boolean): Promise<CheckResult> {
|
|
66
|
+
const startedAt = performance.now()
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch(SITE_URL, {
|
|
70
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
71
|
+
})
|
|
72
|
+
const elapsedMs = performance.now() - startedAt
|
|
73
|
+
const details: string[] = []
|
|
74
|
+
|
|
75
|
+
if (verbose) {
|
|
76
|
+
details.push(`URL: ${SITE_URL}`)
|
|
77
|
+
details.push(`Status text: ${response.statusText || '(none)'}`)
|
|
78
|
+
|
|
79
|
+
if (response.headers.get('content-type')) {
|
|
80
|
+
details.push(`Content-Type: ${response.headers.get('content-type')}`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (response.headers.get('server')) {
|
|
84
|
+
details.push(`Server: ${response.headers.get('server')}`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
title: 'HTTP reachability',
|
|
90
|
+
level: response.ok ? 'ok' : 'error',
|
|
91
|
+
summary: `GET ${SITE_URL} → ${response.status} (${formatDurationMs(elapsedMs)})`,
|
|
92
|
+
details,
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
const elapsedMs = performance.now() - startedAt
|
|
96
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
title: 'HTTP reachability',
|
|
100
|
+
level: 'error',
|
|
101
|
+
summary: `Request failed after ${formatDurationMs(elapsedMs)}: ${message}`,
|
|
102
|
+
details: verbose ? [`URL: ${SITE_URL}`, `Timeout: ${HTTP_TIMEOUT_MS}ms`] : undefined,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function checkLastDeploy(verbose: boolean): Promise<CheckResult> {
|
|
108
|
+
try {
|
|
109
|
+
const proc = Bun.spawn(
|
|
110
|
+
[
|
|
111
|
+
'gh',
|
|
112
|
+
'run',
|
|
113
|
+
'list',
|
|
114
|
+
'--workflow=Deploy',
|
|
115
|
+
'--status=success',
|
|
116
|
+
'--limit=1',
|
|
117
|
+
'--json',
|
|
118
|
+
'createdAt,url',
|
|
119
|
+
'--repo',
|
|
120
|
+
GH_REPO,
|
|
121
|
+
],
|
|
122
|
+
{
|
|
123
|
+
stderr: 'pipe',
|
|
124
|
+
stdout: 'pipe',
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
129
|
+
proc.exited,
|
|
130
|
+
streamToText(proc.stdout),
|
|
131
|
+
streamToText(proc.stderr),
|
|
132
|
+
])
|
|
133
|
+
|
|
134
|
+
if (exitCode !== 0) {
|
|
135
|
+
const baseDetails = stderr.trim() ? [stderr.trim()] : []
|
|
136
|
+
const verboseDetails = verbose && stdout.trim() ? [...baseDetails, `Raw stdout: ${stdout.trim()}`] : baseDetails
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
title: 'Last successful deploy',
|
|
140
|
+
level: 'warning',
|
|
141
|
+
summary: `Unable to query GitHub Actions (gh exited ${exitCode})`,
|
|
142
|
+
details: verboseDetails,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const parsed = ghRunSchema.safeParse(JSON.parse(stdout))
|
|
147
|
+
if (!parsed.success) {
|
|
148
|
+
return {
|
|
149
|
+
title: 'Last successful deploy',
|
|
150
|
+
level: 'warning',
|
|
151
|
+
summary: 'GitHub CLI output did not match expected schema',
|
|
152
|
+
details: verbose ? [parsed.error.message, `Raw stdout: ${stdout.trim()}`] : undefined,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const [latestRun] = parsed.data
|
|
157
|
+
|
|
158
|
+
if (!latestRun) {
|
|
159
|
+
return {
|
|
160
|
+
title: 'Last successful deploy',
|
|
161
|
+
level: 'warning',
|
|
162
|
+
summary: 'No successful Deploy workflow runs found',
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const details = [`Run URL: ${latestRun.url}`]
|
|
167
|
+
|
|
168
|
+
if (verbose) {
|
|
169
|
+
details.push(`Raw createdAt: ${latestRun.createdAt}`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
title: 'Last successful deploy',
|
|
174
|
+
level: 'ok',
|
|
175
|
+
summary: `${formatDate(latestRun.createdAt)}`,
|
|
176
|
+
details,
|
|
177
|
+
}
|
|
178
|
+
} catch (error) {
|
|
179
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
title: 'Last successful deploy',
|
|
183
|
+
level: 'warning',
|
|
184
|
+
summary: `Unable to query GitHub Actions: ${message}`,
|
|
185
|
+
details: verbose
|
|
186
|
+
? ['Requires GitHub CLI authenticated with access to marcusrbrown/infra', 'Install: https://cli.github.com/']
|
|
187
|
+
: undefined,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function checkContentHash(verbose: boolean): Promise<CheckResult> {
|
|
193
|
+
const distIndexPath = path.resolve(import.meta.dir, '../../../../apps/keeweb/dist/index.html')
|
|
194
|
+
const localFile = Bun.file(distIndexPath)
|
|
195
|
+
const localExists = await localFile.exists()
|
|
196
|
+
|
|
197
|
+
if (!localExists) {
|
|
198
|
+
return {
|
|
199
|
+
title: 'Content hash',
|
|
200
|
+
level: 'warning',
|
|
201
|
+
summary: `Local dist file not found: ${distIndexPath}`,
|
|
202
|
+
details: ['Run: bun run --cwd apps/keeweb build'],
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const response = await fetch(SITE_URL, {
|
|
208
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
if (!response.ok) {
|
|
212
|
+
return {
|
|
213
|
+
title: 'Content hash',
|
|
214
|
+
level: 'warning',
|
|
215
|
+
summary: `Could not fetch remote index.html (HTTP ${response.status})`,
|
|
216
|
+
details: verbose ? [`URL: ${SITE_URL}`] : undefined,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const [remoteBody, localBody] = await Promise.all([response.text(), localFile.text()])
|
|
221
|
+
const remoteHash = hashSha256(remoteBody)
|
|
222
|
+
const localHash = hashSha256(localBody)
|
|
223
|
+
|
|
224
|
+
const details = verbose ? [`Remote SHA-256: ${remoteHash}`, `Local SHA-256: ${localHash}`] : undefined
|
|
225
|
+
|
|
226
|
+
if (remoteHash === localHash) {
|
|
227
|
+
return {
|
|
228
|
+
title: 'Content hash',
|
|
229
|
+
level: 'ok',
|
|
230
|
+
summary: 'Remote index.html matches local dist/index.html',
|
|
231
|
+
details,
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
title: 'Content hash',
|
|
237
|
+
level: 'warning',
|
|
238
|
+
summary: 'Remote index.html differs from local dist/index.html',
|
|
239
|
+
details,
|
|
240
|
+
}
|
|
241
|
+
} catch (error) {
|
|
242
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
243
|
+
return {
|
|
244
|
+
title: 'Content hash',
|
|
245
|
+
level: 'warning',
|
|
246
|
+
summary: `Could not compare hashes: ${message}`,
|
|
247
|
+
details: verbose ? [`URL: ${SITE_URL}`, `Local path: ${distIndexPath}`] : undefined,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function printCheckResult(result: CheckResult): void {
|
|
253
|
+
console.log(`[${levelLabel(result.level)}] ${result.title}`)
|
|
254
|
+
console.log(` ${result.summary}`)
|
|
255
|
+
|
|
256
|
+
if (result.details && result.details.length > 0) {
|
|
257
|
+
for (const detail of result.details) {
|
|
258
|
+
console.log(` - ${detail}`)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function registerKeewebStatus(cli: ReturnType<typeof goke>): void {
|
|
264
|
+
cli.command('keeweb status', 'Show operational health of the KeeWeb deployment').action(async options => {
|
|
265
|
+
const verbose = options.verbose === true
|
|
266
|
+
|
|
267
|
+
console.log('KeeWeb status')
|
|
268
|
+
console.log('')
|
|
269
|
+
|
|
270
|
+
const results = await Promise.all([
|
|
271
|
+
checkHttpReachability(verbose),
|
|
272
|
+
checkLastDeploy(verbose),
|
|
273
|
+
checkContentHash(verbose),
|
|
274
|
+
])
|
|
275
|
+
|
|
276
|
+
for (const result of results) {
|
|
277
|
+
printCheckResult(result)
|
|
278
|
+
console.log('')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const errorCount = results.filter(result => result.level === 'error').length
|
|
282
|
+
const warningCount = results.filter(result => result.level === 'warning').length
|
|
283
|
+
|
|
284
|
+
console.log(`Summary: ${results.length} checks, ${errorCount} errors, ${warningCount} warnings`)
|
|
285
|
+
|
|
286
|
+
if (errorCount > 0) {
|
|
287
|
+
process.exitCode = 1
|
|
288
|
+
}
|
|
289
|
+
})
|
|
290
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type {goke} from 'goke'
|
|
2
|
+
import {createMcpAction} from '@goke/mcp'
|
|
3
|
+
|
|
4
|
+
export function registerMcp(cli: ReturnType<typeof goke>): void {
|
|
5
|
+
cli
|
|
6
|
+
.command('mcp', 'Start a stdio MCP server exposing all CLI commands as tools for coding agents')
|
|
7
|
+
.action(createMcpAction({cli}))
|
|
8
|
+
}
|