@@ -548,11 +548,63 @@ function resolveProvider(tsCloudConfig: any): string {
548548 || 'aws'
549549}
550550
551+ /** Parse a positive-integer seconds env var, falling back to a default. */
552+ function readWaitSecs ( name : string , defaultSecs : number ) : number {
553+ const secs = process . env [ name ] ? Number . parseInt ( process . env [ name ] as string , 10 ) : Number . NaN
554+ return Number . isFinite ( secs ) && secs > 0 ? secs : defaultSecs
555+ }
556+
557+ /** Human-friendly duration, e.g. `8m` or `1m30s`. */
558+ function fmtDuration ( secs : number ) : string {
559+ const m = Math . floor ( secs / 60 )
560+ const s = secs % 60
561+ return m ? ( s ? `${ m } m${ s } s` : `${ m } m` ) : `${ s } s`
562+ }
563+
564+ /**
565+ * Poll `check()` until it stops throwing or the timeout elapses, emitting a
566+ * heartbeat every ~30s so a multi-minute wait never looks frozen (and, when
567+ * backgrounded, so the caller can see it is still alive).
568+ */
569+ async function pollUntil ( opts : {
570+ label : string
571+ timeoutSecs : number
572+ intervalMs ?: number
573+ check : ( ) => void
574+ timeoutMessage : ( elapsedSecs : number ) => string
575+ } ) : Promise < void > {
576+ log . info ( `${ opts . label } (up to ${ fmtDuration ( opts . timeoutSecs ) } )...` )
577+ const started = Date . now ( )
578+ const deadline = started + opts . timeoutSecs * 1000
579+ let lastHeartbeat = 0
580+ for ( ; ; ) {
581+ try {
582+ opts . check ( )
583+ return
584+ }
585+ catch {
586+ const elapsedSecs = Math . floor ( ( Date . now ( ) - started ) / 1000 )
587+ if ( Date . now ( ) > deadline )
588+ throw new Error ( opts . timeoutMessage ( elapsedSecs ) )
589+ if ( elapsedSecs - lastHeartbeat >= 30 ) {
590+ log . info ( ` … still waiting (${ elapsedSecs } s elapsed)` )
591+ lastHeartbeat = elapsedSecs
592+ }
593+ await new Promise ( r => setTimeout ( r , opts . intervalMs ?? 5000 ) )
594+ }
595+ }
596+ }
597+
551598/**
552599 * Wait until cloud-init finishes on the freshly provisioned host and the bun
553600 * runtime is on PATH. Cloud-init runs asynchronously after the server reports
554601 * "running", so deploying immediately would race the bun install and the
555602 * systemd unit's ExecStart (`/usr/local/bin/bun …`) would not exist yet.
603+ *
604+ * Timeouts are generous — cold Hetzner boots plus cloud-init installing
605+ * bun/caddy can take several minutes — and overridable per environment:
606+ * TS_CLOUD_SSH_WAIT_SECS (default 480 = 8m) — SSH reachability
607+ * TS_CLOUD_BOOT_WAIT_SECS (default 720 = 12m) — cloud-init + bun on PATH
556608 */
557609async function waitForRemoteReady ( ip : string , verbose : boolean ) : Promise < void > {
558610 const { execSync } = await import ( 'node:child_process' )
@@ -570,19 +622,15 @@ async function waitForRemoteReady(ip: string, verbose: boolean): Promise<void> {
570622 } )
571623
572624 // 1) Wait for SSH to accept connections (server may still be booting).
573- log . info ( 'Waiting for SSH to come up...' )
574- const sshDeadline = Date . now ( ) + 3 * 60_000
575- for ( ; ; ) {
576- try {
577- run ( 'true' )
578- break
579- }
580- catch {
581- if ( Date . now ( ) > sshDeadline )
582- throw new Error ( `SSH did not become reachable on ${ ip } within 3 minutes` )
583- await new Promise ( r => setTimeout ( r , 5000 ) )
584- }
585- }
625+ const sshWaitSecs = readWaitSecs ( 'TS_CLOUD_SSH_WAIT_SECS' , 8 * 60 )
626+ await pollUntil ( {
627+ label : 'Waiting for SSH to come up' ,
628+ timeoutSecs : sshWaitSecs ,
629+ check : ( ) => { run ( 'true' ) } ,
630+ timeoutMessage : elapsed =>
631+ `SSH did not become reachable on ${ ip } within ${ fmtDuration ( sshWaitSecs ) } (waited ${ elapsed } s). `
632+ + `The box may still be booting — raise TS_CLOUD_SSH_WAIT_SECS and retry.` ,
633+ } )
586634 log . success ( 'SSH is up' )
587635
588636 // 2) Block on cloud-init, then confirm bun landed on PATH.
@@ -594,18 +642,16 @@ async function waitForRemoteReady(ip: string, verbose: boolean): Promise<void> {
594642 log . debug ( 'cloud-init status --wait returned non-zero (continuing):' , err )
595643 }
596644
597- const bunDeadline = Date . now ( ) + 5 * 60_000
598- for ( ; ; ) {
599- try {
600- run ( 'test -x /usr/local/bin/bun' )
601- break
602- }
603- catch {
604- if ( Date . now ( ) > bunDeadline )
605- throw new Error ( 'bun runtime did not appear at /usr/local/bin/bun within 5 minutes (cloud-init may have failed — check /var/log/cloud-init-output.log)' )
606- await new Promise ( r => setTimeout ( r , 5000 ) )
607- }
608- }
645+ const bootWaitSecs = readWaitSecs ( 'TS_CLOUD_BOOT_WAIT_SECS' , 12 * 60 )
646+ await pollUntil ( {
647+ label : 'Waiting for the bun runtime' ,
648+ timeoutSecs : bootWaitSecs ,
649+ check : ( ) => { run ( 'test -x /usr/local/bin/bun' ) } ,
650+ timeoutMessage : elapsed =>
651+ `bun runtime did not appear at /usr/local/bin/bun within ${ fmtDuration ( bootWaitSecs ) } (waited ${ elapsed } s). `
652+ + `cloud-init may have failed — SSH in and check /var/log/cloud-init-output.log; `
653+ + `raise TS_CLOUD_BOOT_WAIT_SECS for slow regions.` ,
654+ } )
609655 log . success ( 'Server is ready (bun installed)' )
610656}
611657
@@ -1059,6 +1105,15 @@ export function deploy(buddy: CLI): void {
10591105}
10601106
10611107async function confirmProductionDeployment ( ) {
1108+ // In a non-interactive shell (CI, a background job, piped stdin) there is no
1109+ // one to answer the prompt — `prompts.confirm` would hang forever. Fail fast
1110+ // with a clear instruction instead of stalling the pipeline.
1111+ if ( ! process . stdin . isTTY ) {
1112+ log . error ( 'Refusing to deploy to production from a non-interactive shell without confirmation.' )
1113+ log . info ( ' ➡️ Re-run with `--yes` to confirm (e.g. in CI): `buddy deploy --prod --yes`' )
1114+ process . exit ( ExitCode . InvalidArgument )
1115+ }
1116+
10621117 const confirmed = await prompts . confirm ( {
10631118 message : 'Are you sure you want to deploy to production?' ,
10641119 initial : true ,
0 commit comments