11import { spawn } from 'node:child_process' ;
22import { randomUUID } from 'node:crypto' ;
33import { homedir } from 'node:os' ;
4+ import type { Readable } from 'node:stream' ;
45
56import { gte , valid } from 'semver' ;
67
@@ -10,6 +11,7 @@ import type { TelemetryProperties } from '@pythoughts/pythinker-telemetry';
1011import {
1112 NATIVE_INSTALL_COMMAND_UNIX ,
1213 NATIVE_INSTALL_COMMAND_WIN ,
14+ PYTHINKER_CODE_INSTALL_SH_URL ,
1315} from '#/constant/app' ;
1416import { loadTuiConfig } from '#/tui/config' ;
1517
@@ -163,7 +165,18 @@ export function spawnForSource(
163165 // would look like a successful update. `pipefail` makes the pipeline
164166 // surface curl's non-zero status so installUpdate() rejects and we warn
165167 // instead of printing "Updated …".
166- return { cmd : 'bash' , args : [ '-c' , `set -o pipefail; ${ NATIVE_INSTALL_COMMAND_UNIX } ` ] } ;
168+ //
169+ // `-s -- --version` pins the install to the version this preflight
170+ // decided on, the same guarantee PYTHINKER_VERSION gives on Windows.
171+ // Without it the script installs whatever the CDN currently calls
172+ // latest, which can differ from the rollout's target.
173+ return {
174+ cmd : 'bash' ,
175+ args : [
176+ '-c' ,
177+ `set -o pipefail; curl -fsSL ${ PYTHINKER_CODE_INSTALL_SH_URL } | bash -s -- --version ${ version } ` ,
178+ ] ,
179+ } ;
167180 case 'unsupported' :
168181 throw new Error ( 'unsupported install source cannot be auto-installed' ) ;
169182 }
@@ -207,7 +220,10 @@ export function renderManualUpdateMessage(
207220}
208221
209222export function renderInstallSuccessMessage ( target : UpdateTarget ) : string {
210- return `Updated ${ NPM_PACKAGE_NAME } to ${ target . version } . Restart the CLI to use the new version.\n` ;
223+ return (
224+ `Updated ${ NPM_PACKAGE_NAME } to ${ target . version } . ` +
225+ 'Close this terminal and open a new one to use the new version.\n'
226+ ) ;
211227}
212228
213229function renderBackgroundInstallSuccessNotice ( version : string ) : string {
@@ -548,16 +564,31 @@ async function promptInstall(
548564 target : UpdateTarget ,
549565 source : InstallSource ,
550566 installCommand : string ,
567+ previousFailure : string | undefined ,
551568) : Promise < InstallPromptChoiceValue > {
552569 const options : InstallPromptOptions = {
553570 currentVersion,
554571 target,
555572 installSource : source ,
556573 installCommand,
574+ previousFailure,
557575 } ;
558576 return promptForInstallChoice ( options ) ;
559577}
560578
579+ /**
580+ * A recorded failure is only worth showing when it is about the version the
581+ * prompt is offering — an older version's failure is stale noise.
582+ */
583+ function failureMessageFor (
584+ state : UpdateInstallState ,
585+ target : UpdateTarget ,
586+ ) : string | undefined {
587+ const failure = state . lastFailure ;
588+ if ( failure === null || failure . version !== target . version ) return undefined ;
589+ return failure . message ;
590+ }
591+
561592export async function installUpdate (
562593 source : InstallSource ,
563594 version : string ,
@@ -581,6 +612,41 @@ export async function installUpdate(
581612 } ) ;
582613}
583614
615+ /** Keep the tail only: installers can be chatty, and the state file is small. */
616+ const INSTALLER_STDERR_TAIL_CHARS = 2000 ;
617+
618+ /**
619+ * Buffer the installer's stderr so a failed background install records why it
620+ * failed. Discarding it (the previous `stdio: 'ignore'`) left `lastFailure`
621+ * with nothing but an exit code, which made a broken installer script
622+ * impossible to diagnose without reproducing the spawn by hand.
623+ */
624+ function captureStderrTail ( child : ReturnType < typeof spawn > ) : ( ) => string | undefined {
625+ // Typed `Readable | null`, but absent entirely when stderr was not piped.
626+ const stream : Readable | null | undefined = child . stderr ;
627+ if ( stream === null || stream === undefined ) return ( ) => undefined ;
628+ let tail = '' ;
629+ stream . setEncoding ( 'utf8' ) ;
630+ stream . on ( 'data' , ( chunk : string ) => {
631+ tail = ( tail + chunk ) . slice ( - INSTALLER_STDERR_TAIL_CHARS ) ;
632+ } ) ;
633+ // A detached installer outliving this process must not crash it, and the
634+ // pipe must not hold the event loop open on the way out. `child.stderr` is
635+ // typed as a plain Readable, but the pipe is a Socket at runtime and that is
636+ // where unref lives.
637+ stream . on ( 'error' , ( ) => { } ) ;
638+ ( stream as Readable & { unref ?: ( ) => void } ) . unref ?.( ) ;
639+ return ( ) => {
640+ const trimmed = tail . trim ( ) ;
641+ return trimmed . length === 0 ? undefined : trimmed ;
642+ } ;
643+ }
644+
645+ function describeChildExit ( cmd : string , code : number | null , signal : NodeJS . Signals | null ) : string {
646+ const detail = signal !== null ? `signal ${ signal } ` : `code ${ String ( code ) } ` ;
647+ return `${ cmd } exited with ${ detail } ` ;
648+ }
649+
584650async function waitForChildSpawn ( child : ReturnType < typeof spawn > ) : Promise < void > {
585651 await new Promise < void > ( ( resolve , reject ) => {
586652 const onSpawn = ( ) : void => {
@@ -750,16 +816,18 @@ async function startBackgroundInstall(
750816 // the terminal outcome until the handler is "ready".
751817 let ready = false ;
752818 let settled = false ;
753- let pendingOutcome : boolean | undefined ;
819+ let pendingOutcome : { succeeded : boolean ; reason : string } | undefined ;
754820
755- const finish = async ( succeeded : boolean ) : Promise < void > => {
821+ const finish = async ( succeeded : boolean , reason : string ) : Promise < void > => {
756822 if ( ! ready ) {
757- pendingOutcome ??= succeeded ;
823+ pendingOutcome ??= { succeeded, reason } ;
758824 return ;
759825 }
760826 if ( settled ) return ;
761827 settled = true ;
762828 const attempts = failureAttemptsFor ( startedState , target , 'install' ) + 1 ;
829+ const stderrTail = readStderrTail ( ) ;
830+ const message = stderrTail === undefined ? reason : `${ reason } : ${ stderrTail } ` ;
763831
764832 const nextState : UpdateInstallState = succeeded
765833 ? {
@@ -780,6 +848,7 @@ async function startBackgroundInstall(
780848 failedAt : nowIso ( ) ,
781849 attempts,
782850 operation : 'install' ,
851+ message,
783852 } ,
784853 } ;
785854 try {
@@ -804,6 +873,7 @@ async function startBackgroundInstall(
804873 targetVersion : target . version ,
805874 source,
806875 attempts,
876+ message,
807877 } ) ;
808878 } finally {
809879 await lock . release ( ) . catch ( ( ) => { } ) ;
@@ -815,11 +885,16 @@ async function startBackgroundInstall(
815885 // A detached child gets its own console window on Windows regardless
816886 // of stdio; stdio: 'ignore' alone does not suppress it.
817887 windowsHide : platform === 'win32' ,
818- stdio : 'ignore' ,
888+ // stdout stays discarded (install progress is noise); stderr is piped so
889+ // a failure records the installer's own error text.
890+ stdio : [ 'ignore' , 'ignore' , 'pipe' ] ,
819891 env : env === undefined ? undefined : { ...process . env , ...env } ,
820892 } ) ;
821- child . once ( 'error' , ( ) => { void finish ( false ) ; } ) ;
822- child . once ( 'exit' , ( code ) => { void finish ( code === 0 ) ; } ) ;
893+ const readStderrTail = captureStderrTail ( child ) ;
894+ child . once ( 'error' , ( error ) => { void finish ( false , formatErrorMessage ( error ) ) ; } ) ;
895+ child . once ( 'exit' , ( code , signal ) => {
896+ void finish ( code === 0 , describeChildExit ( cmd , code , signal ) ) ;
897+ } ) ;
823898 if ( child . pid !== undefined && child . pid > 0 ) {
824899 const stateWithPid : UpdateInstallState = {
825900 ...startedState ,
@@ -841,7 +916,7 @@ async function startBackgroundInstall(
841916 child . unref ( ) ;
842917 finalizerOwnsLock = true ;
843918 ready = true ;
844- if ( pendingOutcome !== undefined ) void finish ( pendingOutcome ) ;
919+ if ( pendingOutcome !== undefined ) void finish ( pendingOutcome . succeeded , pendingOutcome . reason ) ;
845920 return true ;
846921 // When startup failed before handoff, release the lock here; the
847922 // finalizer releases it once the terminal state write completes.
@@ -1196,7 +1271,13 @@ export async function runUpdatePreflight(
11961271 return 'continue' ;
11971272 }
11981273
1199- const choice = await promptInstall ( currentVersion , userVisibleTarget , source , installCommand ) ;
1274+ const choice = await promptInstall (
1275+ currentVersion ,
1276+ userVisibleTarget ,
1277+ source ,
1278+ installCommand ,
1279+ failureMessageFor ( installState , userVisibleTarget ) ,
1280+ ) ;
12001281 if ( choice === 'skip' ) return 'continue' ;
12011282
12021283 try {
0 commit comments