1- import { spawnSync } from 'node:child_process'
1+ import { spawn , spawnSync } from 'node:child_process'
22import { getErrorMessage } from '@sim/utils/errors'
33import type { Detection } from '../detect.ts'
44import { ensureDocker } from '../docker.ts'
@@ -8,6 +8,7 @@ import { waitFor } from '../probes.ts'
88import * as p from '../prompter.ts'
99import { glyph , theme } from '../theme.ts'
1010
11+ const APP_URL = 'http://localhost:3000'
1112const RELEASE = 'sim-dev'
1213const NAMESPACE = 'sim-dev'
1314const LOCAL_CONTEXT_PREFIXES = [ 'kind-' , 'docker-desktop' , 'minikube' , 'orbstack' ]
@@ -179,6 +180,102 @@ async function ensureLocalContext(detection: Detection): Promise<string> {
179180 return 'kind-sim'
180181}
181182
183+ interface PodProgress {
184+ ready : number
185+ total : number
186+ detail : string
187+ }
188+
189+ /**
190+ * One-line summary of what the cluster is doing, for the install spinner. Only
191+ * long-running workloads count — the chart's CronJobs spawn short-lived pods
192+ * that finish as Completed, and counting those makes "ready" jitter downward
193+ * for reasons that have nothing to do with the install.
194+ */
195+ function podProgress ( context : string ) : PodProgress | null {
196+ const result = spawnSync (
197+ 'kubectl' ,
198+ [
199+ 'get' ,
200+ 'pods' ,
201+ '--context' ,
202+ context ,
203+ '-n' ,
204+ NAMESPACE ,
205+ '-o' ,
206+ 'jsonpath={range .items[*]}{.status.phase}{"\\t"}{.metadata.ownerReferences[0].kind}{"\\t"}{range .status.containerStatuses[*]}{.ready},{.state.waiting.reason}{" "}{end}{"\\n"}{end}' ,
207+ ] ,
208+ { encoding : 'utf8' }
209+ )
210+ if ( result . status !== 0 ) return null
211+ const rows = result . stdout . split ( '\n' ) . filter ( Boolean )
212+ if ( rows . length === 0 ) return null
213+
214+ let ready = 0
215+ let total = 0
216+ let pulling = 0
217+ let crashing = 0
218+ for ( const row of rows ) {
219+ const [ , ownerKind = '' , containers = '' ] = row . split ( '\t' )
220+ if ( ownerKind === 'Job' ) continue
221+ total ++
222+ if ( containers . includes ( 'true,' ) ) ready ++
223+ if ( containers . includes ( 'ContainerCreating' ) || containers . includes ( 'PodInitializing' ) )
224+ pulling ++
225+ if ( containers . includes ( 'CrashLoopBackOff' ) || containers . includes ( 'ImagePullBackOff' ) )
226+ crashing ++
227+ }
228+ if ( total === 0 ) return null
229+ const notes : string [ ] = [ ]
230+ if ( pulling > 0 ) notes . push ( `${ pulling } starting` )
231+ // Restarts while Postgres comes up are normal on a cold cluster; say so rather
232+ // than let a silent spinner imply nothing is happening.
233+ if ( crashing > 0 ) notes . push ( `${ crashing } restarting` )
234+ return { ready, total, detail : notes . join ( ' · ' ) }
235+ }
236+
237+ /**
238+ * `helm --wait` blocks for minutes with no output, so a slow image pull is
239+ * indistinguishable from a wedged install — the reason the old spinner was
240+ * unsatisfying. Run helm asynchronously and poll the cluster so the spinner
241+ * reports what is actually happening.
242+ */
243+ async function helmInstall (
244+ args : string [ ] ,
245+ input : string ,
246+ context : string ,
247+ spin : ReturnType < typeof p . spinner >
248+ ) : Promise < void > {
249+ const child = spawn ( 'helm' , args , { cwd : ROOT , stdio : [ 'pipe' , 'pipe' , 'pipe' ] } )
250+ child . stdin . write ( input )
251+ child . stdin . end ( )
252+
253+ let stderr = ''
254+ let stdout = ''
255+ child . stdout . on ( 'data' , ( chunk ) => {
256+ stdout += chunk
257+ } )
258+ child . stderr . on ( 'data' , ( chunk ) => {
259+ stderr += chunk
260+ } )
261+
262+ const ticker = setInterval ( ( ) => {
263+ const progress = podProgress ( context )
264+ if ( ! progress ) return
265+ const suffix = progress . detail ? ` · ${ progress . detail } ` : ''
266+ spin . message ( `${ progress . ready } /${ progress . total } pods ready${ suffix } ` )
267+ } , 3000 )
268+
269+ const code = await new Promise < number > ( ( resolve ) => {
270+ child . once ( 'close' , ( status ) => resolve ( status ?? 1 ) )
271+ } )
272+ clearInterval ( ticker )
273+
274+ if ( code !== 0 ) {
275+ throw new Error ( `helm upgrade --install failed: ${ stderr . trim ( ) || stdout . trim ( ) } ` )
276+ }
277+ }
278+
182279function existingReleaseSecrets ( context : string ) : Record < string , string > | null {
183280 const scope = [ '--kube-context' , context , '-n' , NAMESPACE ]
184281 const status = spawnSync ( 'helm' , [ 'status' , RELEASE , ...scope ] , { stdio : 'ignore' } )
@@ -239,8 +336,7 @@ export async function runK8sMode(detection: Detection): Promise<void> {
239336 const spin = p . spinner ( )
240337 spin . start ( 'helm upgrade --install (first run pulls images — this can take several minutes)…' )
241338 try {
242- run (
243- 'helm' ,
339+ await helmInstall (
244340 [
245341 'upgrade' ,
246342 '--install' ,
@@ -259,8 +355,9 @@ export async function runK8sMode(detection: Detection): Promise<void> {
259355 '--timeout' ,
260356 '15m' ,
261357 ] ,
262- 'helm upgrade --install failed' ,
263- secretValues ( secrets )
358+ secretValues ( secrets ) ,
359+ context ,
360+ spin
264361 )
265362 } catch ( error ) {
266363 spin . stop ( `${ glyph . fail } helm install failed` )
@@ -289,10 +386,47 @@ export async function runK8sMode(detection: Detection): Promise<void> {
289386
290387 p . note (
291388 [
292- `kubectl --context ${ shq ( context ) } -n ${ NAMESPACE } port-forward svc/${ RELEASE } -app 3000:3000` ,
293- `kubectl --context ${ shq ( context ) } -n ${ NAMESPACE } get pods` ,
294- `helm uninstall ${ RELEASE } --kube-context ${ shq ( context ) } -n ${ NAMESPACE } # tear down` ,
389+ `open ${ APP_URL } (needs the port-forward below)` ,
390+ `pods: kubectl --context ${ shq ( context ) } -n ${ NAMESPACE } get pods` ,
391+ `app logs: kubectl --context ${ shq ( context ) } -n ${ NAMESPACE } logs deploy/${ RELEASE } -app --tail 50` ,
392+ `forward: kubectl --context ${ shq ( context ) } -n ${ NAMESPACE } port-forward svc/${ RELEASE } -app 3000:3000` ,
393+ `tear down: helm uninstall ${ RELEASE } --kube-context ${ shq ( context ) } -n ${ NAMESPACE } ` ,
295394 ] . join ( '\n' ) ,
296395 'Reach your cluster'
297396 )
397+
398+ await offerPortForward ( context )
399+ }
400+
401+ /**
402+ * The services are ClusterIP, so a healthy release is still unreachable from the
403+ * host — "Sim is ready" with nothing on :3000 is the least satisfying way to end
404+ * a setup. Offer the forward the same way dev mode offers to start the server,
405+ * and run it in the foreground so Ctrl-C ends it.
406+ *
407+ * Realtime needs its own forward (one resource per invocation) or the editor's
408+ * socket fails; it runs as a child in this process group, so the terminal's
409+ * Ctrl-C reaches it too, and it is killed explicitly once the app forward exits.
410+ */
411+ async function offerPortForward ( context : string ) : Promise < void > {
412+ const forward = await p . confirm ( {
413+ message : `Port-forward now so you can open ${ APP_URL } ?` ,
414+ initialValue : true ,
415+ } )
416+ if ( ! forward ) {
417+ p . log . info ( theme . muted ( 'Skipped — run the forward command above when you want to reach it.' ) )
418+ return
419+ }
420+
421+ const scope = [ '--context' , context , '-n' , NAMESPACE ]
422+ const realtime = spawn (
423+ 'kubectl' ,
424+ [ ...scope , 'port-forward' , `svc/${ RELEASE } -realtime` , '3002:3002' ] ,
425+ { stdio : 'ignore' }
426+ )
427+ p . log . step ( `Forwarding ${ APP_URL } (app) and :3002 (realtime) — Ctrl-C to stop` )
428+ spawnSync ( 'kubectl' , [ ...scope , 'port-forward' , `svc/${ RELEASE } -app` , '3000:3000' ] , {
429+ stdio : 'inherit' ,
430+ } )
431+ realtime . kill ( )
298432}
0 commit comments