Skip to content

Commit 259d9e3

Browse files
committed
feat(buddy): doctor probes db, cache, queue, mail, AWS creds, env decryption
The existing 'buddy doctor' only checked Bun/Node versions, package.json, .env presence, and APP_KEY shape. Most boot-time problems show up elsewhere — a missing db connection, a typo'd MAIL_MAILER, an APP_KEY that's set but doesn't decrypt the existing enc: values. Adds five new probes, each with a 2-second budget: - Database (SELECT 1) - Cache (set/get/del round-trip) - Queue driver (config-derived) - Mail driver (config-derived) - AWS credentials (env vars or IAM role configured) - .env decryption (verify all enc: values decrypt with the configured DOTENV_PRIVATE_KEY) Promotes APP_KEY missing from warn → fail because every encryption feature in the framework now hard-depends on it, and tightens the 'set but short' check to flag keys under 32 chars.
1 parent e823cbb commit 259d9e3

1 file changed

Lines changed: 100 additions & 3 deletions

File tree

  • storage/framework/core/buddy/src/commands

storage/framework/core/buddy/src/commands/doctor.ts

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,37 @@ interface HealthCheck {
99
message: string
1010
}
1111

12+
/**
13+
* Run a check that may throw, recording the result in `checks`. Each
14+
* probe gets a 2-second budget — long enough to catch transient
15+
* latency, short enough that `buddy doctor` returns in a few seconds
16+
* even when every dependency is dead.
17+
*/
18+
async function probe(
19+
checks: HealthCheck[],
20+
name: string,
21+
fn: () => Promise<string>,
22+
): Promise<void> {
23+
const start = Date.now()
24+
try {
25+
const ac = new AbortController()
26+
const timer = setTimeout(() => ac.abort(), 2000)
27+
const message = await Promise.race([
28+
fn(),
29+
new Promise<never>((_, rej) => ac.signal.addEventListener('abort', () => rej(new Error('timed out (>2s)')))),
30+
])
31+
clearTimeout(timer)
32+
checks.push({ name, status: 'pass', message: `${message} (${Date.now() - start}ms)` })
33+
}
34+
catch (err) {
35+
checks.push({
36+
name,
37+
status: 'fail',
38+
message: err instanceof Error ? err.message : String(err),
39+
})
40+
}
41+
}
42+
1243
export function doctor(buddy: CLI): void {
1344
buddy
1445
.command('doctor', 'Run health checks on your Stacks installation')
@@ -106,18 +137,84 @@ export function doctor(buddy: CLI): void {
106137
if (appKey && appKey.length > 0) {
107138
checks.push({
108139
name: 'APP_KEY',
109-
status: 'pass',
110-
message: 'Set',
140+
status: appKey.length >= 32 ? 'pass' : 'warn',
141+
message: appKey.length >= 32 ? 'Set (≥32 chars)' : `Set but short (${appKey.length} chars; ≥32 recommended)`,
111142
})
112143
}
113144
else {
114145
checks.push({
115146
name: 'APP_KEY',
147+
status: 'fail',
148+
message: 'Not set — features that depend on it (encrypted columns, signed URLs, env decryption) will refuse to run. Run `buddy key:generate`.',
149+
})
150+
}
151+
152+
// Database connectivity
153+
await probe(checks, 'Database', async () => {
154+
const { db } = await import('@stacksjs/database')
155+
await (db as any).unsafe?.('SELECT 1')
156+
return 'Reachable'
157+
})
158+
159+
// Cache connectivity
160+
await probe(checks, 'Cache', async () => {
161+
const { cache } = await import('@stacksjs/cache')
162+
const k = `__doctor_${Date.now()}`
163+
await cache.set(k, 1, 5)
164+
const v = await cache.get(k)
165+
await cache.del(k)
166+
if (v !== 1) throw new Error('round-trip failed')
167+
return 'Round-trip ok'
168+
})
169+
170+
// Queue driver
171+
await probe(checks, 'Queue driver', async () => {
172+
const { config } = await import('@stacksjs/config')
173+
const driver = (config as { queue?: { default?: string } }).queue?.default ?? 'sync'
174+
return `Driver: ${driver}`
175+
})
176+
177+
// Mail driver — just verify it's resolvable; don't actually send
178+
await probe(checks, 'Mail driver', async () => {
179+
const { config } = await import('@stacksjs/config')
180+
const driver = (config as { email?: { default?: string } }).email?.default ?? 'log'
181+
return `Driver: ${driver}`
182+
})
183+
184+
// AWS credentials — check the env vars even without making a real call
185+
const hasAwsCreds = Boolean(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY)
186+
const hasAwsRole = Boolean(process.env.AWS_PROFILE) || Boolean(process.env.AWS_ROLE_ARN)
187+
if (hasAwsCreds || hasAwsRole) {
188+
checks.push({
189+
name: 'AWS credentials',
190+
status: 'pass',
191+
message: hasAwsCreds ? 'Static keys in env' : 'IAM role configured',
192+
})
193+
}
194+
else {
195+
checks.push({
196+
name: 'AWS credentials',
116197
status: 'warn',
117-
message: 'Not set (run: buddy key:generate)',
198+
message: 'Not configured (cloud / SES / S3 commands will fail)',
118199
})
119200
}
120201

202+
// .env decryption — verify enc: values can be decrypted with the
203+
// configured private key, if any are present. No-op when there
204+
// are no encrypted values or no key.
205+
await probe(checks, '.env decryption', async () => {
206+
const fs = await import('node:fs')
207+
if (!fs.existsSync('.env')) return 'No .env (skipped)'
208+
const content = fs.readFileSync('.env', 'utf8')
209+
if (!/(^|=)(?:enc|encrypted):/m.test(content)) return 'No encrypted values'
210+
const privateKey = process.env.DOTENV_PRIVATE_KEY
211+
if (!privateKey) throw new Error('Encrypted values present but DOTENV_PRIVATE_KEY is unset')
212+
const { parse } = await import('@stacksjs/env')
213+
const { errors } = parse(content, { privateKey })
214+
if (errors.length > 0) throw new Error(errors.join('; '))
215+
return 'All encrypted values decrypt cleanly'
216+
})
217+
121218
// Display results
122219
log.info('')
123220
log.info(bold('Health Check Results:'))

0 commit comments

Comments
 (0)