@@ -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+
1243export 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 ( ! / ( ^ | = ) (?: e n c | e n c r y p t e d ) : / 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