@marcusrbrown/infra 0.1.0 → 0.3.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 +3 -2
- package/src/__snapshots__/cli.test.ts.snap +77 -0
- package/src/cli.test.ts +61 -0
- package/src/cli.ts +10 -0
- package/src/commands/cliproxy-config.test.ts +181 -0
- package/src/commands/cliproxy-config.ts +164 -0
- package/src/commands/cliproxy-deploy.test.ts +181 -0
- package/src/commands/cliproxy-deploy.ts +145 -0
- package/src/commands/cliproxy-keys.ts +168 -0
- package/src/commands/cliproxy-login.ts +81 -0
- package/src/commands/cliproxy-status.test.ts +271 -0
- package/src/commands/cliproxy-status.ts +274 -0
- package/src/commands/keeweb-deploy.test.ts +182 -0
- package/src/commands/keeweb-deploy.ts +28 -23
- package/src/commands/keeweb-status.test.ts +196 -0
- package/src/commands/keeweb-status.ts +6 -6
|
@@ -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}): {
|
|
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
|
-
'
|
|
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('#
|
|
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()
|