11import { spawnSync } from 'node:child_process'
2+ import path from 'node:path'
23import { DB_CONTAINER , type Detection , REDIS_CONTAINER , runDetection } from './detect.ts'
34import { archiveEnvFile , ROOT } from './env-files.ts'
45import { SetupError } from './errors.ts'
@@ -38,28 +39,37 @@ function shq(value: string): string {
3839 return `'${ value . replace ( / ' / g, `'\\''` ) } '`
3940}
4041
42+ /** Is the Docker daemon reachable? Distinguishes "nothing installed" from "can't see". */
43+ function dockerReachable ( ) : boolean {
44+ return spawnSync ( 'docker' , [ 'info' ] , { stdio : 'ignore' } ) . status === 0
45+ }
46+
4147/** Non-throwing docker probe; returns trimmed stdout or null on any failure. */
42- function dockerText ( args : string [ ] ) : string | null {
43- const result = spawnSync ( 'docker' , args , { cwd : ROOT , encoding : 'utf8' } )
48+ function dockerText ( args : string [ ] , cwd : string = ROOT ) : string | null {
49+ const result = spawnSync ( 'docker' , args , { cwd, encoding : 'utf8' } )
4450 return result . status === 0 ? result . stdout . trim ( ) : null
4551}
4652
4753/** Docker command whose output the user should see (up, logs); returns exit code. */
48- function dockerInherit ( args : string [ ] ) : number {
49- return spawnSync ( 'docker' , args , { cwd : ROOT , stdio : 'inherit' } ) . status ?? 1
54+ function dockerInherit ( args : string [ ] , cwd : string = ROOT ) : number {
55+ return spawnSync ( 'docker' , args , { cwd, stdio : 'inherit' } ) . status ?? 1
5056}
5157
5258/** Docker command that must succeed; throws a SetupError with stderr on failure. */
53- function dockerRun ( args : string [ ] , failMessage : string ) : void {
54- const result = spawnSync ( 'docker' , args , { cwd : ROOT , encoding : 'utf8' } )
59+ function dockerRun ( args : string [ ] , failMessage : string , cwd : string = ROOT ) : void {
60+ const result = spawnSync ( 'docker' , args , { cwd, encoding : 'utf8' } )
5561 if ( result . status !== 0 ) {
5662 throw new SetupError ( `${ failMessage } : ${ result . stderr . trim ( ) || result . stdout . trim ( ) } ` )
5763 }
5864}
5965
6066interface ComposeInstall {
6167 kind : 'compose'
68+ /** Absolute path to the compose file Docker recorded for the project. */
6269 file : string
70+ /** Directory the stack was brought up from — every compose op runs here. */
71+ dir : string
72+ project : string
6373}
6474interface DevInstall {
6575 kind : 'dev'
@@ -74,15 +84,48 @@ interface K8sInstall {
7484}
7585type Install = ComposeInstall | DevInstall | K8sInstall
7686
87+ interface ComposeProject {
88+ Name : string
89+ Status : string
90+ ConfigFiles : string
91+ }
92+
7793/**
78- * A compose project brought up from ROOT reuses the same project name at `ps`,
79- * so probing each candidate compose file recovers exactly which one owns
80- * containers — no need to guess the project name or persist the choice.
94+ * Ask Docker which compose projects exist rather than guessing from the working
95+ * directory. Compose derives a project name from the directory it was started
96+ * in, so probing `-f <file> ps` only ever finds a stack when you happen to stand
97+ * in the checkout that launched it — a globally linked `sim` would never see one
98+ * — and it reports the same stack once per candidate file, since both files map
99+ * to the same directory-derived project. `compose ls` records the real project
100+ * and the exact config file, so one running stack yields exactly one install
101+ * wherever it was started from. Projects whose compose file isn't one of ours
102+ * (a devcontainer, an unrelated app) are filtered out by filename.
81103 */
82104function composeInstalls ( ) : ComposeInstall [ ] {
83- return COMPOSE_FILES . filter ( ( file ) => dockerText ( [ 'compose' , '-f' , file , 'ps' , '-aq' ] ) ) . map (
84- ( file ) => ( { kind : 'compose' , file } )
85- )
105+ const raw = dockerText ( [ 'compose' , 'ls' , '-a' , '--format' , 'json' ] )
106+ if ( ! raw ) return [ ]
107+ let projects : ComposeProject [ ]
108+ try {
109+ projects = JSON . parse ( raw )
110+ } catch {
111+ return [ ]
112+ }
113+ const installs : ComposeInstall [ ] = [ ]
114+ for ( const project of projects ) {
115+ // ConfigFiles is a comma-separated list when a stack was started with -f more than once.
116+ const file = ( project . ConfigFiles ?? '' )
117+ . split ( ',' )
118+ . map ( ( entry ) => entry . trim ( ) )
119+ . find ( ( entry ) => ( COMPOSE_FILES as readonly string [ ] ) . includes ( path . basename ( entry ) ) )
120+ if ( ! file ) continue
121+ installs . push ( {
122+ kind : 'compose' ,
123+ file,
124+ dir : path . dirname ( file ) ,
125+ project : project . Name ,
126+ } )
127+ }
128+ return installs
86129}
87130
88131/** Dev mode owns the split env files and, usually, the managed Postgres/Redis. */
@@ -124,7 +167,8 @@ function detectInstalls(detection: Detection): Install[] {
124167}
125168
126169function describeInstall ( install : Install ) : string {
127- if ( install . kind === 'compose' ) return `Docker Compose (${ install . file } )`
170+ if ( install . kind === 'compose' )
171+ return `Docker Compose (project ${ install . project } in ${ install . dir } )`
128172 if ( install . kind === 'dev' ) return 'Local dev (managed Postgres/Redis)'
129173 // Naming a non-local cluster is the guard against acting on the wrong one after
130174 // an ambient context switch — every destructive confirm renders this string.
@@ -165,7 +209,7 @@ function start(install: Install): void {
165209 if ( install . kind === 'compose' ) {
166210 const spin = p . spinner ( )
167211 spin . start ( 'Starting containers…' )
168- dockerRun ( [ 'compose' , '-f' , install . file , 'up' , '-d' ] , 'docker compose up failed' )
212+ dockerRun ( [ 'compose' , '-f' , install . file , 'up' , '-d' ] , 'docker compose up failed' , install . dir )
169213 spin . stop ( 'Containers up' )
170214 p . note (
171215 [ `open ${ APP_URL } ` , 'follow logs: sim logs' , 'stop: sim stop' ] . join ( '\n' ) ,
@@ -190,7 +234,7 @@ function stop(install: Install): void {
190234 if ( install . kind === 'compose' ) {
191235 const spin = p . spinner ( )
192236 spin . start ( 'Stopping containers…' )
193- dockerRun ( [ 'compose' , '-f' , install . file , 'stop' ] , 'docker compose stop failed' )
237+ dockerRun ( [ 'compose' , '-f' , install . file , 'stop' ] , 'docker compose stop failed' , install . dir )
194238 spin . stop ( 'Containers stopped (data kept)' )
195239 p . note ( [ 'start again: sim start' , 'remove: sim down' ] . join ( '\n' ) , 'Stopped' )
196240 return
@@ -220,7 +264,11 @@ function restart(install: Install): void {
220264 if ( install . kind === 'compose' ) {
221265 const spin = p . spinner ( )
222266 spin . start ( 'Restarting containers…' )
223- dockerRun ( [ 'compose' , '-f' , install . file , 'restart' ] , 'docker compose restart failed' )
267+ dockerRun (
268+ [ 'compose' , '-f' , install . file , 'restart' ] ,
269+ 'docker compose restart failed' ,
270+ install . dir
271+ )
224272 spin . stop ( 'Containers restarted' )
225273 p . note ( `open ${ APP_URL } ` , 'Running' )
226274 return
@@ -237,7 +285,7 @@ function restart(install: Install): void {
237285
238286function showLogs ( install : Install ) : void {
239287 if ( install . kind === 'compose' ) {
240- dockerInherit ( [ 'compose' , '-f' , install . file , 'logs' , '-f' , '--tail' , '100' ] )
288+ dockerInherit ( [ 'compose' , '-f' , install . file , 'logs' , '-f' , '--tail' , '100' ] , install . dir )
241289 return
242290 }
243291 if ( install . kind === 'dev' ) {
@@ -268,7 +316,7 @@ async function down(install: Install): Promise<void> {
268316 return
269317 }
270318 if ( install . kind === 'compose' ) {
271- dockerRun ( [ 'compose' , '-f' , install . file , 'down' ] , 'docker compose down failed' )
319+ dockerRun ( [ 'compose' , '-f' , install . file , 'down' ] , 'docker compose down failed' , install . dir )
272320 p . log . step ( 'Containers removed (volumes kept)' )
273321 return
274322 }
@@ -311,7 +359,11 @@ async function reset(install: Install | null): Promise<void> {
311359 if ( backup ) p . log . step ( `Archived ${ backup } ` )
312360 }
313361 if ( install ?. kind === 'compose' ) {
314- dockerRun ( [ 'compose' , '-f' , install . file , 'down' , '-v' ] , 'docker compose down -v failed' )
362+ dockerRun (
363+ [ 'compose' , '-f' , install . file , 'down' , '-v' ] ,
364+ 'docker compose down -v failed' ,
365+ install . dir
366+ )
315367 p . log . step ( 'Containers and volumes removed' )
316368 } else if ( install ?. kind === 'dev' ) {
317369 const names = managedNames ( install )
@@ -343,14 +395,29 @@ async function reset(install: Install | null): Promise<void> {
343395async function status ( ) : Promise < void > {
344396 const detection = await runDetection ( )
345397 const installs = detectInstalls ( detection )
398+ const docker = dockerReachable ( )
346399 console . log ( `\n${ theme . heading ( '◆ Sim status' ) } \n` )
400+ // Every container probe goes through Docker, so when the daemon is down the
401+ // honest answer is "unknown", not "absent" — and a compose stack is invisible
402+ // entirely. Saying "no install detected" there sends the user to re-run setup
403+ // for what is really a stopped Docker Desktop.
404+ if ( ! docker ) {
405+ console . log (
406+ ` ${ glyph . warn } Docker is not reachable — container and Compose state below is unknown.`
407+ )
408+ console . log ( ` ${ theme . muted ( 'start Docker Desktop (or OrbStack), then re-run this.' ) } \n` )
409+ }
347410 if ( installs . length === 0 ) {
348- console . log ( ` ${ glyph . warn } No Sim install detected — run ${ theme . command ( 'sim setup' ) } .` )
411+ console . log (
412+ docker
413+ ? ` ${ glyph . warn } No Sim install detected — run ${ theme . command ( 'sim setup' ) } .`
414+ : ` ${ glyph . warn } No install detected, but that may just be Docker being down.`
415+ )
349416 return
350417 }
351418 for ( const install of installs ) console . log ( ` ${ glyph . pass } ${ describeInstall ( install ) } ` )
352419 const containerState = ( state : { state : 'running' | 'stopped' } | null ) =>
353- state ? state . state : 'absent'
420+ docker ? ( state ? state . state : 'absent' ) : 'unknown (docker down) '
354421 console . log ( )
355422 console . log ( ` postgres (${ DB_CONTAINER } ): ${ containerState ( detection . dbContainer ) } ` )
356423 console . log ( ` redis (${ REDIS_CONTAINER } ): ${ containerState ( detection . redisContainer ) } ` )
0 commit comments