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