@@ -2,7 +2,7 @@ import globalAxios, {
22 type AxiosInstance ,
33 type AxiosRequestConfig ,
44} from "axios" ;
5- import { createWriteStream , type WriteStream } from "node:fs" ;
5+ import { createWriteStream , type WriteStream , type Stats } from "node:fs" ;
66import fs from "node:fs/promises" ;
77import path from "node:path" ;
88import prettyBytes from "pretty-bytes" ;
@@ -29,6 +29,10 @@ import type { Logger } from "../logging/logger";
2929import type { CliCredentialManager } from "./cliCredentialManager" ;
3030import type { PathResolver } from "./pathResolver" ;
3131
32+ type ResolvedBinary =
33+ | { binPath : string ; stat : Stats ; source : "file-path" | "directory" }
34+ | { binPath : string ; source : "not-found" } ;
35+
3236export class CliManager {
3337 private readonly binaryLock : BinaryLock ;
3438
@@ -46,15 +50,51 @@ export class CliManager {
4650 */
4751 public async locateBinary ( url : string ) : Promise < string > {
4852 const safeHostname = toSafeHost ( url ) ;
49- const binPath = path . join (
50- this . pathResolver . getBinaryCachePath ( safeHostname ) ,
51- cliUtils . name ( ) ,
52- ) ;
53- const stat = await cliUtils . stat ( binPath ) ;
54- if ( ! stat ) {
55- throw new Error ( `No CLI binary found at ${ binPath } ` ) ;
53+ const resolved = await this . resolveBinaryPath ( safeHostname ) ;
54+ if ( resolved . source === "not-found" ) {
55+ throw new Error ( `No CLI binary found at ${ resolved . binPath } ` ) ;
56+ }
57+ return resolved . binPath ;
58+ }
59+
60+ /**
61+ * Resolve the CLI binary path from the configured cache path.
62+ *
63+ * Returns "file-path" when the cache path is an existing file (checked for
64+ * version match and updated if needed), "directory" when a binary was found
65+ * inside the directory, or "not-found" with the platform-specific path for
66+ * the caller to download into.
67+ */
68+ private async resolveBinaryPath (
69+ safeHostname : string ,
70+ ) : Promise < ResolvedBinary > {
71+ const cachePath = this . pathResolver . getBinaryCachePath ( safeHostname ) ;
72+ const cacheStat = await cliUtils . stat ( cachePath ) ;
73+
74+ if ( cacheStat ?. isFile ( ) ) {
75+ return { binPath : cachePath , stat : cacheStat , source : "file-path" } ;
76+ }
77+
78+ const fullNamePath = path . join ( cachePath , cliUtils . fullName ( ) ) ;
79+
80+ // Path does not exist yet; return the platform-specific path to download.
81+ if ( ! cacheStat ) {
82+ return { binPath : fullNamePath , source : "not-found" } ;
83+ }
84+
85+ // Directory exists; check platform-specific name, then simple name.
86+ const fullStat = await cliUtils . stat ( fullNamePath ) ;
87+ if ( fullStat ) {
88+ return { binPath : fullNamePath , stat : fullStat , source : "directory" } ;
89+ }
90+
91+ const simpleNamePath = path . join ( cachePath , cliUtils . simpleName ( ) ) ;
92+ const simpleStat = await cliUtils . stat ( simpleNamePath ) ;
93+ if ( simpleStat ) {
94+ return { binPath : simpleNamePath , stat : simpleStat , source : "directory" } ;
5695 }
57- return binPath ;
96+
97+ return { binPath : fullNamePath , source : "not-found" } ;
5898 }
5999
60100 /**
@@ -94,82 +134,87 @@ export class CliManager {
94134 ) ;
95135 }
96136
97- // Check if there is an existing binary and whether it looks valid. If it
98- // is valid and matches the server, or if it does not match the server but
99- // downloads are disabled, we can return early.
100- const binPath = path . join (
101- this . pathResolver . getBinaryCachePath ( safeHostname ) ,
102- cliUtils . name ( ) ,
137+ const resolved = await this . resolveBinaryPath ( safeHostname ) ;
138+ this . output . debug (
139+ `Resolved binary: ${ resolved . binPath } (${ resolved . source } )` ,
103140 ) ;
104- this . output . debug ( "Using binary path" , binPath ) ;
105- const stat = await cliUtils . stat ( binPath ) ;
106- if ( stat === undefined ) {
107- this . output . info ( "No existing binary found, starting download" ) ;
108- } else {
109- this . output . debug ( "Existing binary size is" , prettyBytes ( stat . size ) ) ;
141+
142+ let existingVersion : string | null = null ;
143+ if ( resolved . source !== "not-found" ) {
144+ this . output . debug (
145+ "Existing binary size is" ,
146+ prettyBytes ( resolved . stat . size ) ,
147+ ) ;
110148 try {
111- const version = await cliVersion ( binPath ) ;
112- this . output . debug ( "Existing binary version is" , version ) ;
113- // If we have the right version we can avoid the request entirely.
114- if ( version === buildInfo . version ) {
115- this . output . debug (
116- "Using existing binary since it matches the server version" ,
117- ) ;
118- return binPath ;
119- } else if ( ! enableDownloads ) {
120- this . output . info (
121- "Using existing binary even though it does not match the server version because downloads are disabled" ,
122- ) ;
123- return binPath ;
124- }
125- this . output . info (
126- "Downloading since existing binary does not match the server version" ,
127- ) ;
149+ existingVersion = await cliVersion ( resolved . binPath ) ;
150+ this . output . debug ( "Existing binary version is" , existingVersion ) ;
128151 } catch ( error ) {
129152 this . output . warn (
130- "Unable to get version of existing binary. Downloading new binary instead" ,
153+ "Unable to get version of existing binary, downloading instead" ,
131154 error ,
132155 ) ;
133156 }
157+ } else {
158+ this . output . info ( "No existing binary found, starting download" ) ;
159+ }
160+
161+ if ( existingVersion === buildInfo . version ) {
162+ this . output . debug ( "Existing binary matches server version" ) ;
163+ return resolved . binPath ;
134164 }
135165
136166 if ( ! enableDownloads ) {
167+ if ( existingVersion ) {
168+ this . output . info (
169+ "Using existing binary despite version mismatch because downloads are disabled" ,
170+ ) ;
171+ return resolved . binPath ;
172+ }
137173 this . output . warn ( "Unable to download CLI because downloads are disabled" ) ;
138174 throw new Error ( "Unable to download CLI because downloads are disabled" ) ;
139175 }
140176
177+ if ( existingVersion ) {
178+ this . output . info (
179+ "Downloading since existing binary does not match the server version" ,
180+ ) ;
181+ }
182+
183+ // Always download using the platform-specific name.
184+ const downloadBinPath = path . join (
185+ path . dirname ( resolved . binPath ) ,
186+ cliUtils . fullName ( ) ,
187+ ) ;
188+
141189 // Create the `bin` folder if it doesn't exist
142- await fs . mkdir ( path . dirname ( binPath ) , { recursive : true } ) ;
143- const progressLogPath = binPath + ".progress.log" ;
190+ await fs . mkdir ( path . dirname ( downloadBinPath ) , { recursive : true } ) ;
191+ const progressLogPath = downloadBinPath + ".progress.log" ;
144192
145193 let lockResult :
146194 | { release : ( ) => Promise < void > ; waited : boolean }
147195 | undefined ;
148196 let latestVersion = parsedVersion ;
149197 try {
150198 lockResult = await this . binaryLock . acquireLockOrWait (
151- binPath ,
199+ downloadBinPath ,
152200 progressLogPath ,
153201 ) ;
154202 this . output . debug ( "Acquired download lock" ) ;
155203
156- // If we waited for another process, re-check if binary is now ready
204+ // Another process may have finished the download while we waited.
157205 if ( lockResult . waited ) {
158206 const latestBuildInfo = await restClient . getBuildInfo ( ) ;
159207 this . output . debug ( "Got latest server version" , latestBuildInfo . version ) ;
160208
161209 const recheckAfterWait = await this . checkBinaryVersion (
162- binPath ,
210+ downloadBinPath ,
163211 latestBuildInfo . version ,
164212 ) ;
165213 if ( recheckAfterWait . matches ) {
166- this . output . debug (
167- "Using existing binary since it matches the latest server version" ,
168- ) ;
169- return binPath ;
214+ this . output . debug ( "Binary already matches server version after wait" ) ;
215+ return await this . renameToFinalPath ( resolved , downloadBinPath ) ;
170216 }
171217
172- // Parse the latest version for download
173218 const latestParsedVersion = semver . parse ( latestBuildInfo . version ) ;
174219 if ( ! latestParsedVersion ) {
175220 throw new Error (
@@ -179,19 +224,25 @@ export class CliManager {
179224 latestVersion = latestParsedVersion ;
180225 }
181226
182- return await this . performBinaryDownload (
227+ await this . performBinaryDownload (
183228 restClient ,
184229 latestVersion ,
185- binPath ,
230+ downloadBinPath ,
186231 progressLogPath ,
187232 ) ;
233+ return await this . renameToFinalPath ( resolved , downloadBinPath ) ;
188234 } catch ( error ) {
189- // Unified error handling - check for fallback binaries and prompt user
190- return await this . handleAnyBinaryFailure (
235+ const fallback = await this . handleAnyBinaryFailure (
191236 error ,
192- binPath ,
237+ downloadBinPath ,
193238 buildInfo . version ,
239+ resolved . binPath !== downloadBinPath ? resolved . binPath : undefined ,
194240 ) ;
241+ // Move the fallback to the expected path if needed.
242+ if ( fallback !== resolved . binPath ) {
243+ await fs . rename ( fallback , resolved . binPath ) ;
244+ }
245+ return resolved . binPath ;
195246 } finally {
196247 if ( lockResult ) {
197248 await lockResult . release ( ) ;
@@ -224,6 +275,27 @@ export class CliManager {
224275 }
225276 }
226277
278+ /**
279+ * Rename the downloaded binary to the user-configured file path if needed.
280+ */
281+ private async renameToFinalPath (
282+ resolved : ResolvedBinary ,
283+ downloadBinPath : string ,
284+ ) : Promise < string > {
285+ if (
286+ resolved . source === "file-path" &&
287+ downloadBinPath !== resolved . binPath
288+ ) {
289+ this . output . info (
290+ "Renaming downloaded binary to" ,
291+ path . basename ( resolved . binPath ) ,
292+ ) ;
293+ await fs . rename ( downloadBinPath , resolved . binPath ) ;
294+ return resolved . binPath ;
295+ }
296+ return downloadBinPath ;
297+ }
298+
227299 /**
228300 * Prompt the user to use an existing binary version.
229301 */
@@ -280,54 +352,59 @@ export class CliManager {
280352 }
281353
282354 /**
283- * Unified handler for any binary-related failure.
284- * Checks for existing or old binaries and prompts user once .
355+ * Try fallback binaries after a download failure, prompting the user once
356+ * if the best candidate is a version mismatch .
285357 */
286358 private async handleAnyBinaryFailure (
287359 error : unknown ,
288360 binPath : string ,
289361 expectedVersion : string ,
362+ fallbackBinPath ?: string ,
290363 ) : Promise < string > {
291364 const message =
292365 error instanceof cliUtils . FileLockError
293366 ? "Unable to update the Coder CLI binary because it's in use"
294367 : "Failed to update CLI binary" ;
295368
296- // Try existing binary first
297- const existingCheck = await this . checkBinaryVersion (
298- binPath ,
299- expectedVersion ,
300- ) ;
301- if ( existingCheck . version ) {
302- // Perfect match - use without prompting
303- if ( existingCheck . matches ) {
304- return binPath ;
369+ // Returns the path if usable, undefined if not found.
370+ // Throws the original error if the user declines a mismatch.
371+ const tryCandidate = async (
372+ candidate : string ,
373+ ) : Promise < string | undefined > => {
374+ const check = await this . checkBinaryVersion ( candidate , expectedVersion ) ;
375+ if ( ! check . version ) {
376+ return undefined ;
305377 }
306- // Version mismatch - prompt user
307- if ( await this . promptUseExistingBinary ( existingCheck . version , message ) ) {
308- return binPath ;
378+ if (
379+ ! check . matches &&
380+ ! ( await this . promptUseExistingBinary ( check . version , message ) )
381+ ) {
382+ throw error ;
383+ }
384+ return candidate ;
385+ } ;
386+
387+ const primary = await tryCandidate ( binPath ) ;
388+ if ( primary ) {
389+ return primary ;
390+ }
391+
392+ if ( fallbackBinPath ) {
393+ const fallback = await tryCandidate ( fallbackBinPath ) ;
394+ if ( fallback ) {
395+ return fallback ;
309396 }
310- throw error ;
311397 }
312398
313- // Try .old-* binaries as fallback
399+ // Last resort: most recent .old-* backup (deferred to avoid IO when unnecessary).
314400 const oldBinaries = await cliUtils . findOldBinaries ( binPath ) ;
315401 if ( oldBinaries . length > 0 ) {
316- const oldCheck = await this . checkBinaryVersion (
317- oldBinaries [ 0 ] ,
318- expectedVersion ,
319- ) ;
320- if (
321- oldCheck . version &&
322- ( oldCheck . matches ||
323- ( await this . promptUseExistingBinary ( oldCheck . version , message ) ) )
324- ) {
325- await fs . rename ( oldBinaries [ 0 ] , binPath ) ;
326- return binPath ;
402+ const old = await tryCandidate ( oldBinaries [ 0 ] ) ;
403+ if ( old ) {
404+ return old ;
327405 }
328406 }
329407
330- // No fallback available or user declined - re-throw original error
331408 throw error ;
332409 }
333410
@@ -351,7 +428,7 @@ export class CliManager {
351428 }
352429
353430 // Figure out where to get the binary.
354- const binName = cliUtils . name ( ) ;
431+ const binName = cliUtils . fullName ( ) ;
355432 const configSource = cfg . get < string > ( "binarySource" ) ;
356433 const binSource = configSource ?. trim ( ) ? configSource : "/bin/" + binName ;
357434 this . output . info ( "Downloading binary from" , binSource ) ;
0 commit comments