@marcusrbrown/infra 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -25,7 +25,8 @@
25
25
  "src/"
26
26
  ],
27
27
  "scripts": {
28
- "cli": "bun run src/cli.ts"
28
+ "cli": "bun run src/cli.ts",
29
+ "test": "bun test"
29
30
  },
30
31
  "dependencies": {
31
32
  "@goke/mcp": "^0.0.9",
@@ -0,0 +1,30 @@
1
+ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots
2
+
3
+ exports[`infra CLI shows root help with registered top-level commands 1`] = `
4
+ "infra/x.x.x
5
+
6
+
7
+ Usage:
8
+ $ infra <command> [options]
9
+
10
+
11
+ Commands:
12
+ keeweb status Show operational health of the KeeWeb deployment
13
+
14
+
15
+ keeweb deploy Trigger KeeWeb deployment. Default mode dispatches the GitHub Deploy workflow. Use --local to run apps/keeweb/deploy.sh directly from this repo.
16
+
17
+ --local Run local deployment using apps/keeweb/deploy.sh instead of triggering GitHub Actions. (default: false)
18
+ --nginx Include nginx config deployment. Valid only with --local and passed through to deploy.sh as --nginx. (default: false)
19
+ --dry-run Print planned actions without validating preconditions, executing deploy.sh, or triggering GitHub Actions. (default: false)
20
+
21
+
22
+ mcp Start a stdio MCP server exposing all CLI commands as tools for coding agents
23
+
24
+
25
+ Options:
26
+ --verbose Enable verbose output for all commands
27
+ -h, --help Display this message
28
+ -v, --version Display version number
29
+ "
30
+ `;
@@ -0,0 +1,61 @@
1
+ import {resolve} from 'node:path'
2
+ import {describe, expect, it} from 'bun:test'
3
+
4
+ const cliDir = resolve(import.meta.dir, '..')
5
+
6
+ async function runCli(...args: string[]) {
7
+ const proc = Bun.spawn(['bun', 'src/cli.ts', ...args], {
8
+ cwd: cliDir,
9
+ env: {...process.env, NO_COLOR: '1'},
10
+ stdout: 'pipe',
11
+ stderr: 'pipe',
12
+ })
13
+
14
+ const [stdout, stderr, exitCode] = await Promise.all([
15
+ new Response(proc.stdout).text(),
16
+ new Response(proc.stderr).text(),
17
+ proc.exited,
18
+ ])
19
+
20
+ return {stdout, stderr, exitCode}
21
+ }
22
+
23
+ describe('infra CLI', () => {
24
+ it('shows root help with registered top-level commands', async () => {
25
+ const {stdout, stderr, exitCode} = await runCli('--help')
26
+
27
+ expect(exitCode).toBe(0)
28
+ expect(stderr).toBe('')
29
+ expect(stdout).toContain('keeweb')
30
+ expect(stdout).toContain('mcp')
31
+ // Normalize version so snapshot survives changeset bumps
32
+ const stableOutput = stdout.replace(/infra\/\d+\.\d+\.\d+/, 'infra/x.x.x')
33
+ expect(stableOutput).toMatchSnapshot()
34
+ })
35
+
36
+ it('shows keeweb deploy help with expected flags', async () => {
37
+ const {stdout, stderr, exitCode} = await runCli('keeweb', 'deploy', '--help')
38
+
39
+ expect(exitCode).toBe(0)
40
+ expect(stderr).toBe('')
41
+ expect(stdout).toContain('--local')
42
+ expect(stdout).toContain('--nginx')
43
+ expect(stdout).toContain('--dry-run')
44
+ })
45
+
46
+ it('falls back to root help for an unknown command', async () => {
47
+ const {stdout, stderr, exitCode} = await runCli('nonexistent')
48
+
49
+ expect(exitCode).toBe(0)
50
+ expect(stderr).toBe('')
51
+ expect(stdout).toContain('Usage:')
52
+ })
53
+
54
+ it('exits non-zero and prints an error for an invalid option', async () => {
55
+ const {stdout, stderr, exitCode} = await runCli('keeweb', 'deploy', '--bogus')
56
+
57
+ expect(exitCode).not.toBe(0)
58
+ expect(stdout).toBe('')
59
+ expect(stderr).toContain('--bogus')
60
+ })
61
+ })
@@ -0,0 +1,182 @@
1
+ import {resolve} from 'node:path'
2
+ import {afterEach, beforeEach, describe, expect, it, spyOn} from 'bun:test'
3
+
4
+ import {
5
+ getLocalDeployEnv,
6
+ resolveDeployScriptPath,
7
+ resolveDistIndexPath,
8
+ validateRemotePreconditions,
9
+ } from './keeweb-deploy'
10
+
11
+ const cliDir = resolve(import.meta.dir, '../..')
12
+
13
+ const envKeys = ['HOST', 'HOME', 'PATH', 'REMOTE_USER', 'SITE_DIR', 'SSH_AUTH_SOCK'] as const
14
+
15
+ type ManagedEnvKey = (typeof envKeys)[number]
16
+
17
+ let originalEnv: Partial<Record<ManagedEnvKey, string | undefined>>
18
+
19
+ function restoreManagedEnv(): void {
20
+ for (const key of envKeys) {
21
+ const value = originalEnv[key]
22
+
23
+ if (value === undefined) {
24
+ delete process.env[key]
25
+ continue
26
+ }
27
+
28
+ process.env[key] = value
29
+ }
30
+ }
31
+
32
+ function setManagedEnv(overrides: Partial<Record<ManagedEnvKey, string | undefined>>): void {
33
+ restoreManagedEnv()
34
+
35
+ for (const [key, value] of Object.entries(overrides)) {
36
+ if (value === undefined) {
37
+ delete process.env[key]
38
+ continue
39
+ }
40
+
41
+ process.env[key] = value
42
+ }
43
+ }
44
+
45
+ async function runDeployCommand(
46
+ args: string[],
47
+ envOverrides: Partial<Record<ManagedEnvKey, string | undefined>> = {},
48
+ ): Promise<{stdout: string; stderr: string; exitCode: number}> {
49
+ const env = {...process.env}
50
+
51
+ for (const [key, value] of Object.entries(envOverrides)) {
52
+ if (value === undefined) {
53
+ delete env[key]
54
+ continue
55
+ }
56
+
57
+ env[key] = value
58
+ }
59
+
60
+ const proc = Bun.spawn(['bun', 'src/cli.ts', 'keeweb', 'deploy', ...args], {
61
+ cwd: cliDir,
62
+ env: {
63
+ ...env,
64
+ NO_COLOR: '1',
65
+ SSH_AUTH_SOCK: env.SSH_AUTH_SOCK ?? '/tmp/test-sock',
66
+ },
67
+ stdout: 'pipe',
68
+ stderr: 'pipe',
69
+ })
70
+
71
+ const [stdout, stderr, exitCode] = await Promise.all([
72
+ new Response(proc.stdout).text(),
73
+ new Response(proc.stderr).text(),
74
+ proc.exited,
75
+ ])
76
+
77
+ return {stdout, stderr, exitCode}
78
+ }
79
+
80
+ describe('keeweb deploy', () => {
81
+ beforeEach(() => {
82
+ originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]]))
83
+ })
84
+
85
+ afterEach(() => {
86
+ restoreManagedEnv()
87
+ })
88
+
89
+ describe('resolveDeployScriptPath', () => {
90
+ it('returns a string path containing deploy.sh', () => {
91
+ const deployScriptPath = resolveDeployScriptPath()
92
+
93
+ expect(typeof deployScriptPath).toBe('string')
94
+ expect(deployScriptPath).toContain('deploy.sh')
95
+ })
96
+ })
97
+
98
+ describe('resolveDistIndexPath', () => {
99
+ it('returns a string path ending with dist/index.html', () => {
100
+ const distIndexPath = resolveDistIndexPath()
101
+
102
+ expect(typeof distIndexPath).toBe('string')
103
+ expect(distIndexPath).toEndWith('dist/index.html')
104
+ })
105
+ })
106
+
107
+ describe('getLocalDeployEnv', () => {
108
+ it('returns the expected deploy environment when required variables are present', () => {
109
+ setManagedEnv({
110
+ HOME: '/tmp/test-home',
111
+ PATH: '/usr/bin:/bin',
112
+ SSH_AUTH_SOCK: '/tmp/test-sock',
113
+ })
114
+
115
+ expect(getLocalDeployEnv()).toEqual({
116
+ HOME: '/tmp/test-home',
117
+ HOST: 'box.heatvision.co',
118
+ PATH: '/usr/bin:/bin',
119
+ REMOTE_USER: 'deploy-kw',
120
+ SITE_DIR: '/home/user-data/www/kw.igg.ms',
121
+ SSH_AUTH_SOCK: '/tmp/test-sock',
122
+ })
123
+ })
124
+
125
+ it('throws when SSH_AUTH_SOCK is missing', () => {
126
+ setManagedEnv({
127
+ HOME: '/tmp/test-home',
128
+ PATH: '/usr/bin:/bin',
129
+ SSH_AUTH_SOCK: undefined,
130
+ })
131
+
132
+ expect(() => getLocalDeployEnv()).toThrow(/SSH_AUTH_SOCK/)
133
+ })
134
+
135
+ it('throws when PATH is missing', () => {
136
+ setManagedEnv({
137
+ HOME: '/tmp/test-home',
138
+ PATH: undefined,
139
+ SSH_AUTH_SOCK: '/tmp/test-sock',
140
+ })
141
+
142
+ expect(() => getLocalDeployEnv()).toThrow(/PATH/)
143
+ })
144
+ })
145
+
146
+ describe('validateRemotePreconditions', () => {
147
+ it('does not throw when gh is available', () => {
148
+ const whichSpy = spyOn(Bun, 'which').mockReturnValue('/opt/homebrew/bin/gh')
149
+
150
+ expect(() => validateRemotePreconditions()).not.toThrow()
151
+
152
+ whichSpy.mockRestore()
153
+ })
154
+
155
+ it('throws when gh is unavailable', () => {
156
+ const whichSpy = spyOn(Bun, 'which').mockReturnValue(null)
157
+
158
+ expect(() => validateRemotePreconditions()).toThrow(/gh CLI is required/)
159
+
160
+ whichSpy.mockRestore()
161
+ })
162
+ })
163
+
164
+ describe('CLI flag interactions', () => {
165
+ it('rejects --nginx without --local', async () => {
166
+ const {stdout, stderr, exitCode} = await runDeployCommand(['--nginx'])
167
+
168
+ expect(exitCode).not.toBe(0)
169
+ expect(`${stdout}${stderr}`).toContain('only valid with --local')
170
+ })
171
+
172
+ it('prints the local dry-run plan without executing deploy.sh', async () => {
173
+ const deployScriptPath = resolveDeployScriptPath()
174
+ const {stdout, stderr, exitCode} = await runDeployCommand(['--local', '--dry-run'])
175
+
176
+ expect(exitCode).toBe(0)
177
+ expect(stderr).toBe('')
178
+ expect(stdout).toContain('Dry run: local KeeWeb deploy')
179
+ expect(stdout).toContain(deployScriptPath)
180
+ })
181
+ })
182
+ })
@@ -13,7 +13,7 @@ const DEFAULT_SITE_DIR = '/home/user-data/www/kw.igg.ms'
13
13
 
14
14
  type CliInstance = ReturnType<typeof goke>
15
15
 
16
- function resolveDeployScriptPath(): string {
16
+ export function resolveDeployScriptPath(): string {
17
17
  const instructedPath = resolve(import.meta.dir, '../../../apps/keeweb/deploy.sh')
18
18
 
19
19
  if (existsSync(instructedPath)) {
@@ -25,11 +25,11 @@ function resolveDeployScriptPath(): string {
25
25
  return fallbackPath
26
26
  }
27
27
 
28
- function resolveDistIndexPath(): string {
28
+ export function resolveDistIndexPath(): string {
29
29
  return resolve(import.meta.dir, '../../../../apps/keeweb/dist/index.html')
30
30
  }
31
31
 
32
- function getLocalDeployEnv(): Record<string, string> {
32
+ export function getLocalDeployEnv(): Record<string, string> {
33
33
  const path = process.env.PATH
34
34
  const home = process.env.HOME
35
35
  const sshAuthSock = process.env.SSH_AUTH_SOCK
@@ -56,7 +56,10 @@ function getLocalDeployEnv(): Record<string, string> {
56
56
  }
57
57
  }
58
58
 
59
- function validateLocalPreconditions(options: {nginx?: boolean}): {deployScriptPath: string; distIndexPath: string} {
59
+ export function validateLocalPreconditions(options: {nginx?: boolean}): {
60
+ deployScriptPath: string
61
+ distIndexPath: string
62
+ } {
60
63
  const deployScriptPath = resolveDeployScriptPath()
61
64
  if (!existsSync(deployScriptPath)) {
62
65
  throw new Error(`deploy.sh not found at expected path: ${deployScriptPath}`)
@@ -76,7 +79,7 @@ function validateLocalPreconditions(options: {nginx?: boolean}): {deployScriptPa
76
79
  return {deployScriptPath, distIndexPath}
77
80
  }
78
81
 
79
- function validateRemotePreconditions(): void {
82
+ export function validateRemotePreconditions(): void {
80
83
  if (!Bun.which('gh')) {
81
84
  throw new Error('gh CLI is required for remote deploy. Install gh and run `gh auth login`.')
82
85
  }
@@ -110,12 +113,12 @@ export function registerKeewebDeploy(cli: CliInstance): void {
110
113
  .boolean()
111
114
  .default(false)
112
115
  .describe(
113
- 'Validate preconditions and print planned actions without executing deploy.sh or triggering GitHub Actions.',
116
+ 'Print planned actions without validating preconditions, executing deploy.sh, or triggering GitHub Actions.',
114
117
  ),
115
118
  )
116
119
  .example('# Trigger GitHub Deploy workflow (default mode)')
117
120
  .example('infra keeweb deploy')
118
- .example('# Validate local deploy preconditions without side effects')
121
+ .example('# Preview local deploy plan without side effects')
119
122
  .example('infra keeweb deploy --local --dry-run')
120
123
  .example('# Run local deploy including nginx config update')
121
124
  .example('infra keeweb deploy --local --nginx')
@@ -125,25 +128,27 @@ export function registerKeewebDeploy(cli: CliInstance): void {
125
128
  }
126
129
 
127
130
  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
131
  if (options.dryRun) {
132
+ const deployScriptPath = resolveDeployScriptPath()
133
+ const args = ['bash', deployScriptPath]
134
+ if (options.nginx) {
135
+ args.push('--nginx')
136
+ }
137
137
  console.log('Dry run: local KeeWeb deploy')
138
138
  console.log(`- deploy script: ${deployScriptPath}`)
139
139
  console.log(`- dist check: ${resolveDistIndexPath()}`)
140
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
141
  return
145
142
  }
146
143
 
144
+ const {deployScriptPath} = validateLocalPreconditions({nginx: options.nginx})
145
+ const env = getLocalDeployEnv()
146
+ const args = ['bash', deployScriptPath]
147
+
148
+ if (options.nginx) {
149
+ args.push('--nginx')
150
+ }
151
+
147
152
  const child = Bun.spawn(args, {
148
153
  env,
149
154
  stdout: 'inherit',
@@ -158,11 +163,6 @@ export function registerKeewebDeploy(cli: CliInstance): void {
158
163
  return
159
164
  }
160
165
 
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
166
  if (options.dryRun) {
167
167
  console.log('Dry run: remote KeeWeb deploy')
168
168
  console.log(`- command: gh workflow run ${WORKFLOW_NAME} --repo ${REPO}`)
@@ -170,6 +170,11 @@ export function registerKeewebDeploy(cli: CliInstance): void {
170
170
  return
171
171
  }
172
172
 
173
+ validateRemotePreconditions()
174
+
175
+ console.warn('Warning: the Deploy workflow includes nginx config deployment as part of workflow_dispatch logic.')
176
+ console.warn('Warning: the workflow requires production environment approval before jobs execute.')
177
+
173
178
  const child = Bun.spawn(['gh', 'workflow', 'run', WORKFLOW_NAME, '--repo', REPO], {
174
179
  stdout: 'inherit',
175
180
  stderr: 'inherit',
@@ -0,0 +1,196 @@
1
+ import {afterEach, beforeEach, describe, expect, it, mock, spyOn} from 'bun:test'
2
+
3
+ import {
4
+ checkContentHash,
5
+ checkHttpReachability,
6
+ checkLastDeploy,
7
+ formatDate,
8
+ formatDurationMs,
9
+ hashSha256,
10
+ } from './keeweb-status'
11
+
12
+ const originalFetch = globalThis.fetch
13
+
14
+ type SpawnResult = ReturnType<typeof Bun.spawn>
15
+
16
+ function createFetchImplementation(
17
+ handler: (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => ReturnType<typeof fetch>,
18
+ ): typeof fetch {
19
+ return Object.assign(
20
+ (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => handler(input, init),
21
+ {preconnect: originalFetch.preconnect},
22
+ )
23
+ }
24
+
25
+ function textStream(value: string): ReadableStream<Uint8Array> {
26
+ return new ReadableStream({
27
+ start(controller) {
28
+ controller.enqueue(new TextEncoder().encode(value))
29
+ controller.close()
30
+ },
31
+ })
32
+ }
33
+
34
+ function mockSpawnResult(exitCode: number, stdout: string, stderr = ''): SpawnResult {
35
+ return {
36
+ exited: Promise.resolve(exitCode),
37
+ stdout: textStream(stdout),
38
+ stderr: textStream(stderr),
39
+ } as unknown as SpawnResult
40
+ }
41
+
42
+ describe('keeweb status helpers', () => {
43
+ let fetchMock: ReturnType<typeof mock<typeof fetch>>
44
+ let fileSpy: ReturnType<typeof spyOn<typeof Bun, 'file'>> | undefined
45
+ let spawnSpy: ReturnType<typeof spyOn<typeof Bun, 'spawn'>> | undefined
46
+
47
+ beforeEach(() => {
48
+ fetchMock = mock(
49
+ createFetchImplementation(async () => {
50
+ throw new Error('Unexpected fetch call')
51
+ }),
52
+ )
53
+ globalThis.fetch = Object.assign(fetchMock, {preconnect: originalFetch.preconnect})
54
+ })
55
+
56
+ afterEach(() => {
57
+ globalThis.fetch = originalFetch
58
+ fileSpy?.mockRestore()
59
+ fileSpy = undefined
60
+ spawnSpy?.mockRestore()
61
+ spawnSpy = undefined
62
+ })
63
+
64
+ describe('hashSha256', () => {
65
+ it('returns the known digest for hello', () => {
66
+ expect(hashSha256('hello')).toBe('2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824')
67
+ })
68
+
69
+ it('returns the known digest for an empty string', () => {
70
+ expect(hashSha256('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
71
+ })
72
+ })
73
+
74
+ describe('formatDate', () => {
75
+ it('formats valid ISO dates', () => {
76
+ const value = '2026-01-15T10:30:00Z'
77
+ const formatted = formatDate(value)
78
+
79
+ expect(formatted).not.toBe(value)
80
+ expect(formatted).not.toContain('NaN')
81
+ expect(formatted).toContain('2026')
82
+ })
83
+
84
+ it('passes through invalid dates', () => {
85
+ expect(formatDate('not-a-date')).toBe('not-a-date')
86
+ })
87
+ })
88
+
89
+ describe('formatDurationMs', () => {
90
+ it('formats positive durations', () => {
91
+ expect(formatDurationMs(1234)).toBe('1234ms')
92
+ })
93
+
94
+ it('formats zero duration', () => {
95
+ expect(formatDurationMs(0)).toBe('0ms')
96
+ })
97
+
98
+ it('clamps negative durations to zero', () => {
99
+ expect(formatDurationMs(-5)).toBe('0ms')
100
+ })
101
+ })
102
+
103
+ describe('checkHttpReachability', () => {
104
+ it('returns ok for HTTP 200', async () => {
105
+ fetchMock.mockImplementation(createFetchImplementation(async () => new Response('ok', {status: 200})))
106
+
107
+ const result = await checkHttpReachability(false)
108
+
109
+ expect(result.level).toBe('ok')
110
+ expect(result.summary).toContain('GET https://kw.igg.ms/ → 200')
111
+ })
112
+
113
+ it('returns error for HTTP 500', async () => {
114
+ fetchMock.mockImplementation(createFetchImplementation(async () => new Response('error', {status: 500})))
115
+
116
+ const result = await checkHttpReachability(false)
117
+
118
+ expect(result.level).toBe('error')
119
+ expect(result.summary).toContain('GET https://kw.igg.ms/ → 500')
120
+ })
121
+
122
+ it('returns error details for network failures', async () => {
123
+ fetchMock.mockImplementation(
124
+ createFetchImplementation(async () => {
125
+ throw new Error('Network timeout')
126
+ }),
127
+ )
128
+
129
+ const result = await checkHttpReachability(true)
130
+
131
+ expect(result.level).toBe('error')
132
+ expect(result.summary).toContain('Network timeout')
133
+ expect(result.details).toEqual(['URL: https://kw.igg.ms/', 'Timeout: 10000ms'])
134
+ })
135
+ })
136
+
137
+ describe('checkLastDeploy', () => {
138
+ it('returns ok for valid gh output', async () => {
139
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation(() =>
140
+ mockSpawnResult(0, JSON.stringify([{createdAt: '2026-01-15T10:30:00Z', url: 'https://example.com'}])),
141
+ )
142
+
143
+ const result = await checkLastDeploy(false)
144
+
145
+ expect(result.level).toBe('ok')
146
+ expect(result.summary).toBe(formatDate('2026-01-15T10:30:00Z'))
147
+ expect(result.details).toEqual(['Run URL: https://example.com'])
148
+ })
149
+
150
+ it('returns warning when no successful runs are found', async () => {
151
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation(() => mockSpawnResult(0, '[]'))
152
+
153
+ const result = await checkLastDeploy(false)
154
+
155
+ expect(result.level).toBe('warning')
156
+ expect(result.summary).toContain('No successful')
157
+ })
158
+
159
+ it('returns warning when gh exits non-zero', async () => {
160
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation(() => mockSpawnResult(1, '', 'boom'))
161
+
162
+ const result = await checkLastDeploy(false)
163
+
164
+ expect(result.level).toBe('warning')
165
+ expect(result.summary).toContain('gh exited 1')
166
+ })
167
+
168
+ it('returns warning when gh cannot be invoked', async () => {
169
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation(() => {
170
+ throw new Error('gh not found')
171
+ })
172
+
173
+ const result = await checkLastDeploy(false)
174
+
175
+ expect(result.level).toBe('warning')
176
+ expect(result.summary).toContain('Unable to query')
177
+ expect(result.summary).toContain('gh not found')
178
+ })
179
+ })
180
+
181
+ describe('checkContentHash', () => {
182
+ it('returns warning when the local dist file does not exist', async () => {
183
+ fileSpy = spyOn(Bun, 'file').mockImplementation(
184
+ () =>
185
+ ({
186
+ exists: async () => false,
187
+ }) as ReturnType<typeof Bun.file>,
188
+ )
189
+
190
+ const result = await checkContentHash(false)
191
+
192
+ expect(result.level).toBe('warning')
193
+ expect(result.summary).toContain('not found')
194
+ })
195
+ })
196
+ })
@@ -35,11 +35,11 @@ function levelLabel(level: CheckLevel): string {
35
35
  return 'ERROR'
36
36
  }
37
37
 
38
- function formatDurationMs(durationMs: number): string {
38
+ export function formatDurationMs(durationMs: number): string {
39
39
  return `${Math.max(0, Math.round(durationMs))}ms`
40
40
  }
41
41
 
42
- function formatDate(value: string): string {
42
+ export function formatDate(value: string): string {
43
43
  const date = new Date(value)
44
44
  if (Number.isNaN(date.getTime())) {
45
45
  return value
@@ -48,7 +48,7 @@ function formatDate(value: string): string {
48
48
  return date.toLocaleString()
49
49
  }
50
50
 
51
- function hashSha256(value: string): string {
51
+ export function hashSha256(value: string): string {
52
52
  const hasher = new Bun.CryptoHasher('sha256')
53
53
  hasher.update(value)
54
54
  return hasher.digest('hex')
@@ -62,7 +62,7 @@ async function streamToText(stream?: ReadableStream<Uint8Array> | null): Promise
62
62
  return await new Response(stream).text()
63
63
  }
64
64
 
65
- async function checkHttpReachability(verbose: boolean): Promise<CheckResult> {
65
+ export async function checkHttpReachability(verbose: boolean): Promise<CheckResult> {
66
66
  const startedAt = performance.now()
67
67
 
68
68
  try {
@@ -104,7 +104,7 @@ async function checkHttpReachability(verbose: boolean): Promise<CheckResult> {
104
104
  }
105
105
  }
106
106
 
107
- async function checkLastDeploy(verbose: boolean): Promise<CheckResult> {
107
+ export async function checkLastDeploy(verbose: boolean): Promise<CheckResult> {
108
108
  try {
109
109
  const proc = Bun.spawn(
110
110
  [
@@ -189,7 +189,7 @@ async function checkLastDeploy(verbose: boolean): Promise<CheckResult> {
189
189
  }
190
190
  }
191
191
 
192
- async function checkContentHash(verbose: boolean): Promise<CheckResult> {
192
+ export async function checkContentHash(verbose: boolean): Promise<CheckResult> {
193
193
  const distIndexPath = path.resolve(import.meta.dir, '../../../../apps/keeweb/dist/index.html')
194
194
  const localFile = Bun.file(distIndexPath)
195
195
  const localExists = await localFile.exists()