installer.ts 53 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708
  1. /**
  2. * Native Installer Implementation
  3. *
  4. * This module implements the file-based native installer system described in
  5. * docs/native-installer.md. It provides:
  6. * - Directory structure management with symlinks
  7. * - Version installation and activation
  8. * - Multi-process safety with locking
  9. * - Simple fallback mechanism using modification time
  10. * - Support for both JS and native builds
  11. */
  12. import { constants as fsConstants, type Stats } from 'fs'
  13. import {
  14. access,
  15. chmod,
  16. copyFile,
  17. lstat,
  18. mkdir,
  19. readdir,
  20. readlink,
  21. realpath,
  22. rename,
  23. rm,
  24. rmdir,
  25. stat,
  26. symlink,
  27. unlink,
  28. writeFile,
  29. } from 'fs/promises'
  30. import { homedir } from 'os'
  31. import { basename, delimiter, dirname, join, resolve } from 'path'
  32. import {
  33. type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  34. logEvent,
  35. } from 'src/services/analytics/index.js'
  36. import { getMaxVersion, shouldSkipVersion } from '../autoUpdater.js'
  37. import { registerCleanup } from '../cleanupRegistry.js'
  38. import { getGlobalConfig, saveGlobalConfig } from '../config.js'
  39. import { logForDebugging } from '../debug.js'
  40. import { getCurrentInstallationType } from '../doctorDiagnostic.js'
  41. import { env } from '../env.js'
  42. import { envDynamic } from '../envDynamic.js'
  43. import { isEnvTruthy } from '../envUtils.js'
  44. import { errorMessage, getErrnoCode, isENOENT, toError } from '../errors.js'
  45. import { execFileNoThrowWithCwd } from '../execFileNoThrow.js'
  46. import { getShellType } from '../localInstaller.js'
  47. import * as lockfile from '../lockfile.js'
  48. import { logError } from '../log.js'
  49. import { gt, gte } from '../semver.js'
  50. import {
  51. filterClaudeAliases,
  52. getShellConfigPaths,
  53. readFileLines,
  54. writeFileLines,
  55. } from '../shellConfig.js'
  56. import { sleep } from '../sleep.js'
  57. import {
  58. getUserBinDir,
  59. getXDGCacheHome,
  60. getXDGDataHome,
  61. getXDGStateHome,
  62. } from '../xdg.js'
  63. import { downloadVersion, getLatestVersion } from './download.js'
  64. import {
  65. acquireProcessLifetimeLock,
  66. cleanupStaleLocks,
  67. isLockActive,
  68. isPidBasedLockingEnabled,
  69. readLockContent,
  70. withLock,
  71. } from './pidLock.js'
  72. export const VERSION_RETENTION_COUNT = 2
  73. // 7 days in milliseconds - used for mtime-based lock stale timeout.
  74. // This is long enough to survive laptop sleep durations while still
  75. // allowing cleanup of abandoned locks from crashed processes within a reasonable time.
  76. const LOCK_STALE_MS = 7 * 24 * 60 * 60 * 1000
  77. export type SetupMessage = {
  78. message: string
  79. userActionRequired: boolean
  80. type: 'path' | 'alias' | 'info' | 'error'
  81. }
  82. export function getPlatform(): string {
  83. // Use env.platform which already handles platform detection and defaults to 'linux'
  84. const os = env.platform
  85. const arch =
  86. process.arch === 'x64' ? 'x64' : process.arch === 'arm64' ? 'arm64' : null
  87. if (!arch) {
  88. const error = new Error(`Unsupported architecture: ${process.arch}`)
  89. logForDebugging(
  90. `Native installer does not support architecture: ${process.arch}`,
  91. { level: 'error' },
  92. )
  93. throw error
  94. }
  95. // Check for musl on Linux and adjust platform accordingly
  96. if (os === 'linux' && envDynamic.isMuslEnvironment()) {
  97. return `linux-${arch}-musl`
  98. }
  99. return `${os}-${arch}`
  100. }
  101. export function getBinaryName(platform: string): string {
  102. return platform.startsWith('win32') ? 'claude.exe' : 'claude'
  103. }
  104. function getBaseDirectories() {
  105. const platform = getPlatform()
  106. const executableName = getBinaryName(platform)
  107. return {
  108. // Data directories (permanent storage)
  109. versions: join(getXDGDataHome(), 'claude', 'versions'),
  110. // Cache directories (can be deleted)
  111. staging: join(getXDGCacheHome(), 'claude', 'staging'),
  112. // State directories
  113. locks: join(getXDGStateHome(), 'claude', 'locks'),
  114. // User bin
  115. executable: join(getUserBinDir(), executableName),
  116. }
  117. }
  118. async function isPossibleClaudeBinary(filePath: string): Promise<boolean> {
  119. try {
  120. const stats = await stat(filePath)
  121. // before download, the version lock file (located at the same filePath) will be size 0
  122. // also, we allow small sizes because we want to treat small wrapper scripts as valid
  123. if (!stats.isFile() || stats.size === 0) {
  124. return false
  125. }
  126. // Check if file is executable. Note: On Windows, this relies on file extensions
  127. // (.exe, .bat, .cmd) and ACL permissions rather than Unix permission bits,
  128. // so it may not work perfectly for all executable files on Windows.
  129. await access(filePath, fsConstants.X_OK)
  130. return true
  131. } catch {
  132. return false
  133. }
  134. }
  135. async function getVersionPaths(version: string) {
  136. const dirs = getBaseDirectories()
  137. // Create directories, but not the executable path (which is a file)
  138. const dirsToCreate = [dirs.versions, dirs.staging, dirs.locks]
  139. await Promise.all(dirsToCreate.map(dir => mkdir(dir, { recursive: true })))
  140. // Ensure parent directory of executable exists
  141. const executableParentDir = dirname(dirs.executable)
  142. await mkdir(executableParentDir, { recursive: true })
  143. const installPath = join(dirs.versions, version)
  144. // Create an empty file if it doesn't exist
  145. try {
  146. await stat(installPath)
  147. } catch {
  148. await writeFile(installPath, '', { encoding: 'utf8' })
  149. }
  150. return {
  151. stagingPath: join(dirs.staging, version),
  152. installPath,
  153. }
  154. }
  155. // Execute a callback while holding a lock on a version file
  156. // Returns false if the file is already locked, true if callback executed
  157. async function tryWithVersionLock(
  158. versionFilePath: string,
  159. callback: () => void | Promise<void>,
  160. retries = 0,
  161. ): Promise<boolean> {
  162. const dirs = getBaseDirectories()
  163. const lockfilePath = getLockFilePathFromVersionPath(dirs, versionFilePath)
  164. // Ensure the locks directory exists
  165. await mkdir(dirs.locks, { recursive: true })
  166. if (isPidBasedLockingEnabled()) {
  167. // Use PID-based locking with optional retries
  168. let attempts = 0
  169. const maxAttempts = retries + 1
  170. const minTimeout = retries > 0 ? 1000 : 100
  171. const maxTimeout = retries > 0 ? 5000 : 500
  172. while (attempts < maxAttempts) {
  173. const success = await withLock(
  174. versionFilePath,
  175. lockfilePath,
  176. async () => {
  177. try {
  178. await callback()
  179. } catch (error) {
  180. logError(error)
  181. throw error
  182. }
  183. },
  184. )
  185. if (success) {
  186. logEvent('tengu_version_lock_acquired', {
  187. is_pid_based: true,
  188. is_lifetime_lock: false,
  189. attempts: attempts + 1,
  190. })
  191. return true
  192. }
  193. attempts++
  194. if (attempts < maxAttempts) {
  195. // Wait before retrying with exponential backoff
  196. const timeout = Math.min(
  197. minTimeout * Math.pow(2, attempts - 1),
  198. maxTimeout,
  199. )
  200. await sleep(timeout)
  201. }
  202. }
  203. logEvent('tengu_version_lock_failed', {
  204. is_pid_based: true,
  205. is_lifetime_lock: false,
  206. attempts: maxAttempts,
  207. })
  208. logLockAcquisitionError(
  209. versionFilePath,
  210. new Error('Lock held by another process'),
  211. )
  212. return false
  213. }
  214. // Use mtime-based locking (proper-lockfile) with 30-day stale timeout
  215. let release: (() => Promise<void>) | null = null
  216. try {
  217. // Lock acquisition phase - catch lock errors and return false
  218. // Use 30 days for stale to match lockCurrentVersion() - this ensures we never
  219. // consider a running process's lock as stale during normal usage (including
  220. // laptop sleep). 30 days allows eventual cleanup of abandoned locks from
  221. // crashed processes while being long enough for any realistic session.
  222. try {
  223. release = await lockfile.lock(versionFilePath, {
  224. stale: LOCK_STALE_MS,
  225. retries: {
  226. retries,
  227. minTimeout: retries > 0 ? 1000 : 100,
  228. maxTimeout: retries > 0 ? 5000 : 500,
  229. },
  230. lockfilePath,
  231. // Handle lock compromise gracefully to prevent unhandled rejections
  232. // This can happen if another process deletes the lock directory while we hold it
  233. onCompromised: (err: Error) => {
  234. logForDebugging(
  235. `NON-FATAL: Version lock was compromised during operation: ${err.message}`,
  236. { level: 'info' },
  237. )
  238. },
  239. })
  240. } catch (lockError) {
  241. logEvent('tengu_version_lock_failed', {
  242. is_pid_based: false,
  243. is_lifetime_lock: false,
  244. })
  245. logLockAcquisitionError(versionFilePath, lockError)
  246. return false
  247. }
  248. // Operation phase - log errors but let them propagate
  249. try {
  250. await callback()
  251. logEvent('tengu_version_lock_acquired', {
  252. is_pid_based: false,
  253. is_lifetime_lock: false,
  254. })
  255. return true
  256. } catch (error) {
  257. logError(error)
  258. throw error
  259. }
  260. } finally {
  261. if (release) {
  262. await release()
  263. }
  264. }
  265. }
  266. async function atomicMoveToInstallPath(
  267. stagedBinaryPath: string,
  268. installPath: string,
  269. ) {
  270. // Create installation directory if it doesn't exist
  271. await mkdir(dirname(installPath), { recursive: true })
  272. // Move from staging to final location atomically
  273. const tempInstallPath = `${installPath}.tmp.${process.pid}.${Date.now()}`
  274. try {
  275. // Copy to temp next to install path, then rename. A direct rename from staging
  276. // would fail with EXDEV if staging and install are on different filesystems.
  277. await copyFile(stagedBinaryPath, tempInstallPath)
  278. await chmod(tempInstallPath, 0o755)
  279. await rename(tempInstallPath, installPath)
  280. logForDebugging(`Atomically installed binary to ${installPath}`)
  281. } catch (error) {
  282. // Clean up temp file if it exists
  283. try {
  284. await unlink(tempInstallPath)
  285. } catch {
  286. // Ignore cleanup errors
  287. }
  288. throw error
  289. }
  290. }
  291. async function installVersionFromPackage(
  292. stagingPath: string,
  293. installPath: string,
  294. ) {
  295. try {
  296. // Extract binary from npm package structure in staging
  297. const nodeModulesDir = join(stagingPath, 'node_modules', '@anthropic-ai')
  298. const entries = await readdir(nodeModulesDir)
  299. const nativePackage = entries.find((entry: string) =>
  300. entry.startsWith('claude-cli-native-'),
  301. )
  302. if (!nativePackage) {
  303. logEvent('tengu_native_install_package_failure', {
  304. stage_find_package: true,
  305. error_package_not_found: true,
  306. })
  307. const error = new Error('Could not find platform-specific native package')
  308. throw error
  309. }
  310. const stagedBinaryPath = join(nodeModulesDir, nativePackage, 'cli')
  311. try {
  312. await stat(stagedBinaryPath)
  313. } catch {
  314. logEvent('tengu_native_install_package_failure', {
  315. stage_binary_exists: true,
  316. error_binary_not_found: true,
  317. })
  318. const error = new Error('Native binary not found in staged package')
  319. throw error
  320. }
  321. await atomicMoveToInstallPath(stagedBinaryPath, installPath)
  322. // Clean up staging directory
  323. await rm(stagingPath, { recursive: true, force: true })
  324. logEvent('tengu_native_install_package_success', {})
  325. } catch (error) {
  326. // Log if not already logged above
  327. const msg = errorMessage(error)
  328. if (
  329. !msg.includes('Could not find platform-specific') &&
  330. !msg.includes('Native binary not found')
  331. ) {
  332. logEvent('tengu_native_install_package_failure', {
  333. stage_atomic_move: true,
  334. error_move_failed: true,
  335. })
  336. }
  337. logError(toError(error))
  338. throw error
  339. }
  340. }
  341. async function installVersionFromBinary(
  342. stagingPath: string,
  343. installPath: string,
  344. ) {
  345. try {
  346. // For direct binary downloads (GCS, generic bucket), the binary is directly in staging
  347. const platform = getPlatform()
  348. const binaryName = getBinaryName(platform)
  349. const stagedBinaryPath = join(stagingPath, binaryName)
  350. try {
  351. await stat(stagedBinaryPath)
  352. } catch {
  353. logEvent('tengu_native_install_binary_failure', {
  354. stage_binary_exists: true,
  355. error_binary_not_found: true,
  356. })
  357. const error = new Error('Staged binary not found')
  358. throw error
  359. }
  360. await atomicMoveToInstallPath(stagedBinaryPath, installPath)
  361. // Clean up staging directory
  362. await rm(stagingPath, { recursive: true, force: true })
  363. logEvent('tengu_native_install_binary_success', {})
  364. } catch (error) {
  365. if (!errorMessage(error).includes('Staged binary not found')) {
  366. logEvent('tengu_native_install_binary_failure', {
  367. stage_atomic_move: true,
  368. error_move_failed: true,
  369. })
  370. }
  371. logError(toError(error))
  372. throw error
  373. }
  374. }
  375. async function installVersion(
  376. stagingPath: string,
  377. installPath: string,
  378. downloadType: 'npm' | 'binary',
  379. ) {
  380. // Use the explicit download type instead of guessing
  381. if (downloadType === 'npm') {
  382. await installVersionFromPackage(stagingPath, installPath)
  383. } else {
  384. await installVersionFromBinary(stagingPath, installPath)
  385. }
  386. }
  387. /**
  388. * Performs the core update operation: download (if needed), install, and update symlink.
  389. * Returns whether a new install was performed (vs just updating symlink).
  390. */
  391. async function performVersionUpdate(
  392. version: string,
  393. forceReinstall: boolean,
  394. ): Promise<boolean> {
  395. const { stagingPath: baseStagingPath, installPath } =
  396. await getVersionPaths(version)
  397. const { executable: executablePath } = getBaseDirectories()
  398. // For lockless updates, use a unique staging path to avoid conflicts between concurrent downloads
  399. const stagingPath = isEnvTruthy(process.env.ENABLE_LOCKLESS_UPDATES)
  400. ? `${baseStagingPath}.${process.pid}.${Date.now()}`
  401. : baseStagingPath
  402. // Only download if not already installed (or if force reinstall)
  403. const needsInstall = !(await versionIsAvailable(version)) || forceReinstall
  404. if (needsInstall) {
  405. logForDebugging(
  406. forceReinstall
  407. ? `Force reinstalling native installer version ${version}`
  408. : `Downloading native installer version ${version}`,
  409. )
  410. const downloadType = await downloadVersion(version, stagingPath)
  411. await installVersion(stagingPath, installPath, downloadType)
  412. } else {
  413. logForDebugging(`Version ${version} already installed, updating symlink`)
  414. }
  415. // Create direct symlink from ~/.local/bin/claude to the version binary
  416. await removeDirectoryIfEmpty(executablePath)
  417. await updateSymlink(executablePath, installPath)
  418. // Verify the executable was actually created/updated
  419. if (!(await isPossibleClaudeBinary(executablePath))) {
  420. let installPathExists = false
  421. try {
  422. await stat(installPath)
  423. installPathExists = true
  424. } catch {
  425. // installPath doesn't exist
  426. }
  427. throw new Error(
  428. `Failed to create executable at ${executablePath}. ` +
  429. `Source file exists: ${installPathExists}. ` +
  430. `Check write permissions to ${executablePath}.`,
  431. )
  432. }
  433. return needsInstall
  434. }
  435. async function versionIsAvailable(version: string): Promise<boolean> {
  436. const { installPath } = await getVersionPaths(version)
  437. return isPossibleClaudeBinary(installPath)
  438. }
  439. async function updateLatest(
  440. channelOrVersion: string,
  441. forceReinstall: boolean = false,
  442. ): Promise<{
  443. success: boolean
  444. latestVersion: string
  445. lockFailed?: boolean
  446. lockHolderPid?: number
  447. }> {
  448. const startTime = Date.now()
  449. let version = await getLatestVersion(channelOrVersion)
  450. const { executable: executablePath } = getBaseDirectories()
  451. logForDebugging(`Checking for native installer update to version ${version}`)
  452. // Check if max version is set (server-side kill switch for auto-updates)
  453. if (!forceReinstall) {
  454. const maxVersion = await getMaxVersion()
  455. if (maxVersion && gt(version, maxVersion)) {
  456. logForDebugging(
  457. `Native installer: maxVersion ${maxVersion} is set, capping update from ${version} to ${maxVersion}`,
  458. )
  459. // If we're already at or above maxVersion, skip the update entirely
  460. if (gte(MACRO.VERSION, maxVersion)) {
  461. logForDebugging(
  462. `Native installer: current version ${MACRO.VERSION} is already at or above maxVersion ${maxVersion}, skipping update`,
  463. )
  464. logEvent('tengu_native_update_skipped_max_version', {
  465. latency_ms: Date.now() - startTime,
  466. max_version:
  467. maxVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  468. available_version:
  469. version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  470. })
  471. return { success: true, latestVersion: version }
  472. }
  473. version = maxVersion
  474. }
  475. }
  476. // Early exit: if we're already running this exact version AND both the version binary
  477. // and executable exist and are valid. We need to proceed if the executable doesn't exist,
  478. // is invalid (e.g., empty/corrupted from a failed install), or we're running via npx.
  479. if (
  480. !forceReinstall &&
  481. version === MACRO.VERSION &&
  482. (await versionIsAvailable(version)) &&
  483. (await isPossibleClaudeBinary(executablePath))
  484. ) {
  485. logForDebugging(`Found ${version} at ${executablePath}, skipping install`)
  486. logEvent('tengu_native_update_complete', {
  487. latency_ms: Date.now() - startTime,
  488. was_new_install: false,
  489. was_force_reinstall: false,
  490. was_already_running: true,
  491. })
  492. return { success: true, latestVersion: version }
  493. }
  494. // Check if this version should be skipped due to minimumVersion setting
  495. if (!forceReinstall && shouldSkipVersion(version)) {
  496. logEvent('tengu_native_update_skipped_minimum_version', {
  497. latency_ms: Date.now() - startTime,
  498. target_version:
  499. version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  500. })
  501. return { success: true, latestVersion: version }
  502. }
  503. // Track if we're actually installing or just symlinking
  504. let wasNewInstall = false
  505. let latencyMs: number
  506. if (isEnvTruthy(process.env.ENABLE_LOCKLESS_UPDATES)) {
  507. // Lockless: rely on atomic operations, errors propagate
  508. wasNewInstall = await performVersionUpdate(version, forceReinstall)
  509. latencyMs = Date.now() - startTime
  510. } else {
  511. // Lock-based updates
  512. const { installPath } = await getVersionPaths(version)
  513. // If force reinstall, remove any existing lock to bypass stale locks
  514. if (forceReinstall) {
  515. await forceRemoveLock(installPath)
  516. }
  517. const lockAcquired = await tryWithVersionLock(
  518. installPath,
  519. async () => {
  520. wasNewInstall = await performVersionUpdate(version, forceReinstall)
  521. },
  522. 3, // retries
  523. )
  524. latencyMs = Date.now() - startTime
  525. // Lock acquisition failed - get lock holder PID for error message
  526. if (!lockAcquired) {
  527. const dirs = getBaseDirectories()
  528. let lockHolderPid: number | undefined
  529. if (isPidBasedLockingEnabled()) {
  530. const lockfilePath = getLockFilePathFromVersionPath(dirs, installPath)
  531. if (isLockActive(lockfilePath)) {
  532. lockHolderPid = readLockContent(lockfilePath)?.pid
  533. }
  534. }
  535. logEvent('tengu_native_update_lock_failed', {
  536. latency_ms: latencyMs,
  537. lock_holder_pid: lockHolderPid,
  538. })
  539. return {
  540. success: false,
  541. latestVersion: version,
  542. lockFailed: true,
  543. lockHolderPid,
  544. }
  545. }
  546. }
  547. logEvent('tengu_native_update_complete', {
  548. latency_ms: latencyMs,
  549. was_new_install: wasNewInstall,
  550. was_force_reinstall: forceReinstall,
  551. })
  552. logForDebugging(`Successfully updated to version ${version}`)
  553. return { success: true, latestVersion: version }
  554. }
  555. // Exported for testing
  556. export async function removeDirectoryIfEmpty(path: string): Promise<void> {
  557. // rmdir alone handles all cases: ENOTDIR if path is a file, ENOTEMPTY if
  558. // directory is non-empty, ENOENT if missing. No need to stat+readdir first.
  559. try {
  560. await rmdir(path)
  561. logForDebugging(`Removed empty directory at ${path}`)
  562. } catch (error) {
  563. const code = getErrnoCode(error)
  564. // Expected cases (not-a-dir, missing, not-empty) — silently skip.
  565. // ENOTDIR is the normal path: executablePath is typically a symlink.
  566. if (code !== 'ENOTDIR' && code !== 'ENOENT' && code !== 'ENOTEMPTY') {
  567. logForDebugging(`Could not remove directory at ${path}: ${error}`)
  568. }
  569. }
  570. }
  571. async function updateSymlink(
  572. symlinkPath: string,
  573. targetPath: string,
  574. ): Promise<boolean> {
  575. const platform = getPlatform()
  576. const isWindows = platform.startsWith('win32')
  577. // On Windows, directly copy the executable instead of creating a symlink
  578. if (isWindows) {
  579. try {
  580. // Ensure parent directory exists
  581. const parentDir = dirname(symlinkPath)
  582. await mkdir(parentDir, { recursive: true })
  583. // Check if file already exists and has same content
  584. let existingStats: Stats | undefined
  585. try {
  586. existingStats = await stat(symlinkPath)
  587. } catch {
  588. // symlinkPath doesn't exist
  589. }
  590. if (existingStats) {
  591. try {
  592. const targetStats = await stat(targetPath)
  593. // If sizes match, assume files are the same (avoid reading large files)
  594. if (existingStats.size === targetStats.size) {
  595. return false
  596. }
  597. } catch {
  598. // Continue with copy if we can't compare
  599. }
  600. // Use rename strategy to handle file locking on Windows
  601. // Rename always works even for running executables, unlike delete
  602. const oldFileName = `${symlinkPath}.old.${Date.now()}`
  603. await rename(symlinkPath, oldFileName)
  604. // Try to copy new executable, with rollback on failure
  605. try {
  606. await copyFile(targetPath, symlinkPath)
  607. // Success - try immediate cleanup of old file (non-blocking)
  608. try {
  609. await unlink(oldFileName)
  610. } catch {
  611. // File still running - ignore, Windows will clean up eventually
  612. }
  613. } catch (copyError) {
  614. // Copy failed - restore the old executable
  615. try {
  616. await rename(oldFileName, symlinkPath)
  617. } catch (restoreError) {
  618. // Critical: User left without working executable - prioritize restore error
  619. const errorWithCause = new Error(
  620. `Failed to restore old executable: ${restoreError}`,
  621. { cause: copyError },
  622. )
  623. logError(errorWithCause)
  624. throw errorWithCause
  625. }
  626. throw copyError
  627. }
  628. } else {
  629. // First-time installation (no existing file to rename)
  630. // Copy the executable directly; handle ENOENT from copyFile itself
  631. // rather than a stat() pre-check (avoids TOCTOU + extra syscall)
  632. try {
  633. await copyFile(targetPath, symlinkPath)
  634. } catch (e) {
  635. if (isENOENT(e)) {
  636. throw new Error(`Source file does not exist: ${targetPath}`)
  637. }
  638. throw e
  639. }
  640. }
  641. // chmod is not needed on Windows - executability is determined by .exe extension
  642. return true
  643. } catch (error) {
  644. logError(
  645. new Error(
  646. `Failed to copy executable from ${targetPath} to ${symlinkPath}: ${error}`,
  647. ),
  648. )
  649. return false
  650. }
  651. }
  652. // For non-Windows platforms, use symlinks as before
  653. // Ensure parent directory exists (same as Windows path above)
  654. const parentDir = dirname(symlinkPath)
  655. try {
  656. await mkdir(parentDir, { recursive: true })
  657. logForDebugging(`Created directory ${parentDir} for symlink`)
  658. } catch (mkdirError) {
  659. logError(
  660. new Error(`Failed to create directory ${parentDir}: ${mkdirError}`),
  661. )
  662. return false
  663. }
  664. // Check if symlink already exists and points to the correct target
  665. try {
  666. let symlinkExists = false
  667. try {
  668. await stat(symlinkPath)
  669. symlinkExists = true
  670. } catch {
  671. // symlinkPath doesn't exist
  672. }
  673. if (symlinkExists) {
  674. try {
  675. const currentTarget = await readlink(symlinkPath)
  676. const resolvedCurrentTarget = resolve(
  677. dirname(symlinkPath),
  678. currentTarget,
  679. )
  680. const resolvedTargetPath = resolve(targetPath)
  681. if (resolvedCurrentTarget === resolvedTargetPath) {
  682. return false
  683. }
  684. } catch {
  685. // Path exists but is not a symlink - will remove it below
  686. }
  687. // Remove existing file/symlink before creating new one
  688. await unlink(symlinkPath)
  689. }
  690. } catch (error) {
  691. logError(new Error(`Failed to check/remove existing symlink: ${error}`))
  692. }
  693. // Use atomic rename to avoid race conditions. Create symlink with temporary name
  694. // then atomically rename to final name. This ensures the symlink always exists
  695. // and is always valid, even with concurrent updates.
  696. const tempSymlink = `${symlinkPath}.tmp.${process.pid}.${Date.now()}`
  697. try {
  698. await symlink(targetPath, tempSymlink)
  699. // Atomically rename to final name (replaces existing)
  700. await rename(tempSymlink, symlinkPath)
  701. logForDebugging(
  702. `Atomically updated symlink ${symlinkPath} -> ${targetPath}`,
  703. )
  704. return true
  705. } catch (error) {
  706. // Clean up temp symlink if it exists
  707. try {
  708. await unlink(tempSymlink)
  709. } catch {
  710. // Ignore cleanup errors
  711. }
  712. logError(
  713. new Error(
  714. `Failed to create symlink from ${symlinkPath} to ${targetPath}: ${error}`,
  715. ),
  716. )
  717. return false
  718. }
  719. }
  720. export async function checkInstall(
  721. force: boolean = false,
  722. ): Promise<SetupMessage[]> {
  723. // Skip all installation checks if disabled via environment variable
  724. if (isEnvTruthy(process.env.DISABLE_INSTALLATION_CHECKS)) {
  725. return []
  726. }
  727. // Get the actual installation type and config
  728. const installationType = await getCurrentInstallationType()
  729. // Skip checks for development builds - config.installMethod from a previous
  730. // native installation shouldn't trigger warnings when running dev builds
  731. if (installationType === 'development') {
  732. return []
  733. }
  734. const config = getGlobalConfig()
  735. // Only show warnings if:
  736. // 1. User is actually running from native installation, OR
  737. // 2. User has explicitly set installMethod to 'native' in config (they're trying to use native)
  738. // 3. force is true (used during installation process)
  739. const shouldCheckNative =
  740. force || installationType === 'native' || config.installMethod === 'native'
  741. if (!shouldCheckNative) {
  742. return []
  743. }
  744. const dirs = getBaseDirectories()
  745. const messages: SetupMessage[] = []
  746. const localBinDir = dirname(dirs.executable)
  747. const resolvedLocalBinPath = resolve(localBinDir)
  748. const platform = getPlatform()
  749. const isWindows = platform.startsWith('win32')
  750. // Check if bin directory exists
  751. try {
  752. await access(localBinDir)
  753. } catch {
  754. messages.push({
  755. message: `installMethod is native, but directory ${localBinDir} does not exist`,
  756. userActionRequired: true,
  757. type: 'error',
  758. })
  759. }
  760. // Check if claude executable exists and is valid.
  761. // On non-Windows, call readlink directly and route errno — ENOENT means
  762. // the executable is missing, EINVAL means it exists but isn't a symlink.
  763. // This avoids an access()→readlink() TOCTOU where deletion between the
  764. // two calls produces a misleading "Not a symlink" diagnostic.
  765. // isPossibleClaudeBinary stats the path internally, so we don't pre-check
  766. // with access() — that would be a TOCTOU between access and the stat.
  767. if (isWindows) {
  768. // On Windows it's a copied executable, not a symlink
  769. if (!(await isPossibleClaudeBinary(dirs.executable))) {
  770. messages.push({
  771. message: `installMethod is native, but claude command is missing or invalid at ${dirs.executable}`,
  772. userActionRequired: true,
  773. type: 'error',
  774. })
  775. }
  776. } else {
  777. try {
  778. const target = await readlink(dirs.executable)
  779. const absoluteTarget = resolve(dirname(dirs.executable), target)
  780. if (!(await isPossibleClaudeBinary(absoluteTarget))) {
  781. messages.push({
  782. message: `Claude symlink points to missing or invalid binary: ${target}`,
  783. userActionRequired: true,
  784. type: 'error',
  785. })
  786. }
  787. } catch (e) {
  788. if (isENOENT(e)) {
  789. messages.push({
  790. message: `installMethod is native, but claude command not found at ${dirs.executable}`,
  791. userActionRequired: true,
  792. type: 'error',
  793. })
  794. } else {
  795. // EINVAL (not a symlink) or other — check as regular binary
  796. if (!(await isPossibleClaudeBinary(dirs.executable))) {
  797. messages.push({
  798. message: `${dirs.executable} exists but is not a valid Claude binary`,
  799. userActionRequired: true,
  800. type: 'error',
  801. })
  802. }
  803. }
  804. }
  805. }
  806. // Check if bin directory is in PATH
  807. const isInCurrentPath = (process.env.PATH || '')
  808. .split(delimiter)
  809. .some(entry => {
  810. try {
  811. const resolvedEntry = resolve(entry)
  812. // On Windows, perform case-insensitive comparison for paths
  813. if (isWindows) {
  814. return (
  815. resolvedEntry.toLowerCase() === resolvedLocalBinPath.toLowerCase()
  816. )
  817. }
  818. return resolvedEntry === resolvedLocalBinPath
  819. } catch {
  820. return false
  821. }
  822. })
  823. if (!isInCurrentPath) {
  824. if (isWindows) {
  825. // Windows-specific PATH instructions
  826. const windowsBinPath = localBinDir.replace(/\//g, '\\')
  827. messages.push({
  828. message: `Native installation exists but ${windowsBinPath} is not in your PATH. Add it by opening: System Properties → Environment Variables → Edit User PATH → New → Add the path above. Then restart your terminal.`,
  829. userActionRequired: true,
  830. type: 'path',
  831. })
  832. } else {
  833. // Unix-style PATH instructions
  834. const shellType = getShellType()
  835. const configPaths = getShellConfigPaths()
  836. const configFile = configPaths[shellType as keyof typeof configPaths]
  837. const displayPath = configFile
  838. ? configFile.replace(homedir(), '~')
  839. : 'your shell config file'
  840. messages.push({
  841. message: `Native installation exists but ~/.local/bin is not in your PATH. Run:\n\necho 'export PATH="$HOME/.local/bin:$PATH"' >> ${displayPath} && source ${displayPath}`,
  842. userActionRequired: true,
  843. type: 'path',
  844. })
  845. }
  846. }
  847. return messages
  848. }
  849. type InstallLatestResult = {
  850. latestVersion: string | null
  851. wasUpdated: boolean
  852. lockFailed?: boolean
  853. lockHolderPid?: number
  854. }
  855. // In-process singleflight guard. NativeAutoUpdater remounts whenever the
  856. // prompt suggestions overlay toggles (PromptInput.tsx:2916), and the
  857. // isUpdating guard does not survive the remount. Each remount kicked off a
  858. // fresh 271MB binary download while previous ones were still in flight.
  859. // Telemetry: session 42fed33f saw arrayBuffers climb to 91GB at ~650MB/s.
  860. let inFlightInstall: Promise<InstallLatestResult> | null = null
  861. export function installLatest(
  862. channelOrVersion: string,
  863. forceReinstall: boolean = false,
  864. ): Promise<InstallLatestResult> {
  865. if (forceReinstall) {
  866. return installLatestImpl(channelOrVersion, forceReinstall)
  867. }
  868. if (inFlightInstall) {
  869. logForDebugging('installLatest: joining in-flight call')
  870. return inFlightInstall
  871. }
  872. const promise = installLatestImpl(channelOrVersion, forceReinstall)
  873. inFlightInstall = promise
  874. const clear = (): void => {
  875. inFlightInstall = null
  876. }
  877. void promise.then(clear, clear)
  878. return promise
  879. }
  880. async function installLatestImpl(
  881. channelOrVersion: string,
  882. forceReinstall: boolean = false,
  883. ): Promise<InstallLatestResult> {
  884. const updateResult = await updateLatest(channelOrVersion, forceReinstall)
  885. if (!updateResult.success) {
  886. return {
  887. latestVersion: null,
  888. wasUpdated: false,
  889. lockFailed: updateResult.lockFailed,
  890. lockHolderPid: updateResult.lockHolderPid,
  891. }
  892. }
  893. // Installation succeeded (early return above covers failure). Mark as native
  894. // and disable legacy auto-updater to protect symlinks.
  895. const config = getGlobalConfig()
  896. if (config.installMethod !== 'native') {
  897. saveGlobalConfig(current => ({
  898. ...current,
  899. installMethod: 'native',
  900. // Disable legacy auto-updater to prevent npm sessions from deleting native symlinks.
  901. // Native installations use NativeAutoUpdater instead, which respects native installation.
  902. autoUpdates: false,
  903. // Mark this as protection-based, not user preference
  904. autoUpdatesProtectedForNative: true,
  905. }))
  906. logForDebugging(
  907. 'Native installer: Set installMethod to "native" and disabled legacy auto-updater for protection',
  908. )
  909. }
  910. void cleanupOldVersions()
  911. return {
  912. latestVersion: updateResult.latestVersion,
  913. wasUpdated: updateResult.success,
  914. lockFailed: false,
  915. }
  916. }
  917. async function getVersionFromSymlink(
  918. symlinkPath: string,
  919. ): Promise<string | null> {
  920. try {
  921. const target = await readlink(symlinkPath)
  922. const absoluteTarget = resolve(dirname(symlinkPath), target)
  923. if (await isPossibleClaudeBinary(absoluteTarget)) {
  924. return absoluteTarget
  925. }
  926. } catch {
  927. // Not a symlink / doesn't exist / target doesn't exist
  928. }
  929. return null
  930. }
  931. function getLockFilePathFromVersionPath(
  932. dirs: ReturnType<typeof getBaseDirectories>,
  933. versionPath: string,
  934. ) {
  935. const versionName = basename(versionPath)
  936. return join(dirs.locks, `${versionName}.lock`)
  937. }
  938. /**
  939. * Acquire a lock on the current running version to prevent it from being deleted
  940. * This lock is held for the entire lifetime of the process
  941. *
  942. * Uses PID-based locking (when enabled) which can immediately detect crashed processes
  943. * (unlike mtime-based locking which requires a 30-day timeout)
  944. */
  945. export async function lockCurrentVersion(): Promise<void> {
  946. const dirs = getBaseDirectories()
  947. // Only lock if we're running from the versions directory
  948. if (!process.execPath.includes(dirs.versions)) {
  949. return
  950. }
  951. const versionPath = resolve(process.execPath)
  952. try {
  953. const lockfilePath = getLockFilePathFromVersionPath(dirs, versionPath)
  954. // Ensure locks directory exists
  955. await mkdir(dirs.locks, { recursive: true })
  956. if (isPidBasedLockingEnabled()) {
  957. // Acquire PID-based lock and hold it for the process lifetime
  958. // PID-based locking allows immediate detection of crashed processes
  959. // while still surviving laptop sleep (process is suspended but PID exists)
  960. const acquired = await acquireProcessLifetimeLock(
  961. versionPath,
  962. lockfilePath,
  963. )
  964. if (!acquired) {
  965. logEvent('tengu_version_lock_failed', {
  966. is_pid_based: true,
  967. is_lifetime_lock: true,
  968. })
  969. logLockAcquisitionError(
  970. versionPath,
  971. new Error('Lock already held by another process'),
  972. )
  973. return
  974. }
  975. logEvent('tengu_version_lock_acquired', {
  976. is_pid_based: true,
  977. is_lifetime_lock: true,
  978. })
  979. logForDebugging(`Acquired PID lock on running version: ${versionPath}`)
  980. } else {
  981. // Acquire mtime-based lock and never release it (until process exits)
  982. // Use 30 days for stale to prevent the lock from being considered stale during
  983. // normal usage. This is critical because laptop sleep suspends the process,
  984. // stopping the mtime heartbeat. 30 days is long enough for any realistic session
  985. // while still allowing eventual cleanup of abandoned locks.
  986. let release: (() => Promise<void>) | undefined
  987. try {
  988. release = await lockfile.lock(versionPath, {
  989. stale: LOCK_STALE_MS,
  990. retries: 0, // Don't retry - if we can't lock, that's fine
  991. lockfilePath,
  992. // Handle lock compromise gracefully (e.g., if another process deletes the lock directory)
  993. onCompromised: (err: Error) => {
  994. logForDebugging(
  995. `NON-FATAL: Lock on running version was compromised: ${err.message}`,
  996. { level: 'info' },
  997. )
  998. },
  999. })
  1000. logEvent('tengu_version_lock_acquired', {
  1001. is_pid_based: false,
  1002. is_lifetime_lock: true,
  1003. })
  1004. logForDebugging(
  1005. `Acquired mtime-based lock on running version: ${versionPath}`,
  1006. )
  1007. // Release lock explicitly; proper-lockfile's cleanup is unreliable with signal-exit v3+v4
  1008. registerCleanup(async () => {
  1009. try {
  1010. await release?.()
  1011. } catch {
  1012. // Lock may already be released
  1013. }
  1014. })
  1015. } catch (lockError) {
  1016. if (isENOENT(lockError)) {
  1017. logForDebugging(
  1018. `Cannot lock current version - file does not exist: ${versionPath}`,
  1019. { level: 'info' },
  1020. )
  1021. return
  1022. }
  1023. logEvent('tengu_version_lock_failed', {
  1024. is_pid_based: false,
  1025. is_lifetime_lock: true,
  1026. })
  1027. logLockAcquisitionError(versionPath, lockError)
  1028. return
  1029. }
  1030. }
  1031. } catch (error) {
  1032. if (isENOENT(error)) {
  1033. logForDebugging(
  1034. `Cannot lock current version - file does not exist: ${versionPath}`,
  1035. { level: 'info' },
  1036. )
  1037. return
  1038. }
  1039. // We fallback to previous behavior where we don't acquire a lock on a running version
  1040. // This ~mostly works but using native binaries like ripgrep will fail
  1041. logForDebugging(
  1042. `NON-FATAL: Failed to lock current version during execution ${errorMessage(error)}`,
  1043. { level: 'info' },
  1044. )
  1045. }
  1046. }
  1047. function logLockAcquisitionError(versionPath: string, lockError: unknown) {
  1048. logError(
  1049. new Error(
  1050. `NON-FATAL: Lock acquisition failed for ${versionPath} (expected in multi-process scenarios)`,
  1051. { cause: lockError },
  1052. ),
  1053. )
  1054. }
  1055. /**
  1056. * Force-remove a lock file for a given version path.
  1057. * Used when --force is specified to bypass stale locks.
  1058. */
  1059. async function forceRemoveLock(versionFilePath: string): Promise<void> {
  1060. const dirs = getBaseDirectories()
  1061. const lockfilePath = getLockFilePathFromVersionPath(dirs, versionFilePath)
  1062. try {
  1063. await unlink(lockfilePath)
  1064. logForDebugging(`Force-removed lock file at ${lockfilePath}`)
  1065. } catch (error) {
  1066. // Log but don't throw - we'll try to acquire the lock anyway
  1067. logForDebugging(`Failed to force-remove lock file: ${errorMessage(error)}`)
  1068. }
  1069. }
  1070. export async function cleanupOldVersions(): Promise<void> {
  1071. // Yield to ensure we don't block startup
  1072. await Promise.resolve()
  1073. const dirs = getBaseDirectories()
  1074. const oneHourAgo = Date.now() - 3600000
  1075. // Clean up old renamed executables on Windows (no longer running at startup)
  1076. if (getPlatform().startsWith('win32')) {
  1077. const executableDir = dirname(dirs.executable)
  1078. try {
  1079. const files = await readdir(executableDir)
  1080. let cleanedCount = 0
  1081. for (const file of files) {
  1082. if (!/^claude\.exe\.old\.\d+$/.test(file)) continue
  1083. try {
  1084. await unlink(join(executableDir, file))
  1085. cleanedCount++
  1086. } catch {
  1087. // File might still be in use by another process
  1088. }
  1089. }
  1090. if (cleanedCount > 0) {
  1091. logForDebugging(
  1092. `Cleaned up ${cleanedCount} old Windows executables on startup`,
  1093. )
  1094. }
  1095. } catch (error) {
  1096. if (!isENOENT(error)) {
  1097. logForDebugging(`Failed to clean up old Windows executables: ${error}`)
  1098. }
  1099. }
  1100. }
  1101. // Clean up orphaned staging directories older than 1 hour
  1102. try {
  1103. const stagingEntries = await readdir(dirs.staging)
  1104. let stagingCleanedCount = 0
  1105. for (const entry of stagingEntries) {
  1106. const stagingPath = join(dirs.staging, entry)
  1107. try {
  1108. // stat() is load-bearing here (we need mtime). There is a theoretical
  1109. // TOCTOU where a concurrent installer could freshen a stale staging
  1110. // dir between stat and rm — but the 1-hour threshold makes this
  1111. // vanishingly unlikely, and rm({force:true}) tolerates concurrent
  1112. // deletion.
  1113. const stats = await stat(stagingPath)
  1114. if (stats.mtime.getTime() < oneHourAgo) {
  1115. await rm(stagingPath, { recursive: true, force: true })
  1116. stagingCleanedCount++
  1117. logForDebugging(`Cleaned up old staging directory: ${entry}`)
  1118. }
  1119. } catch {
  1120. // Ignore individual errors
  1121. }
  1122. }
  1123. if (stagingCleanedCount > 0) {
  1124. logForDebugging(
  1125. `Cleaned up ${stagingCleanedCount} orphaned staging directories`,
  1126. )
  1127. logEvent('tengu_native_staging_cleanup', {
  1128. cleaned_count: stagingCleanedCount,
  1129. })
  1130. }
  1131. } catch (error) {
  1132. if (!isENOENT(error)) {
  1133. logForDebugging(`Failed to clean up staging directories: ${error}`)
  1134. }
  1135. }
  1136. // Clean up stale PID locks (crashed processes) — cleanupStaleLocks handles ENOENT
  1137. if (isPidBasedLockingEnabled()) {
  1138. const staleLocksCleaned = cleanupStaleLocks(dirs.locks)
  1139. if (staleLocksCleaned > 0) {
  1140. logForDebugging(`Cleaned up ${staleLocksCleaned} stale version locks`)
  1141. logEvent('tengu_native_stale_locks_cleanup', {
  1142. cleaned_count: staleLocksCleaned,
  1143. })
  1144. }
  1145. }
  1146. // Single readdir of versions dir. Partition into temp files vs candidate binaries,
  1147. // stat'ing each entry at most once.
  1148. let versionEntries: string[]
  1149. try {
  1150. versionEntries = await readdir(dirs.versions)
  1151. } catch (error) {
  1152. if (!isENOENT(error)) {
  1153. logForDebugging(`Failed to readdir versions directory: ${error}`)
  1154. }
  1155. return
  1156. }
  1157. type VersionInfo = {
  1158. name: string
  1159. path: string
  1160. resolvedPath: string
  1161. mtime: Date
  1162. }
  1163. const versionFiles: VersionInfo[] = []
  1164. let tempFilesCleanedCount = 0
  1165. for (const entry of versionEntries) {
  1166. const entryPath = join(dirs.versions, entry)
  1167. if (/\.tmp\.\d+\.\d+$/.test(entry)) {
  1168. // Orphaned temp install file — pattern: {version}.tmp.{pid}.{timestamp}
  1169. try {
  1170. const stats = await stat(entryPath)
  1171. if (stats.mtime.getTime() < oneHourAgo) {
  1172. await unlink(entryPath)
  1173. tempFilesCleanedCount++
  1174. logForDebugging(`Cleaned up orphaned temp install file: ${entry}`)
  1175. }
  1176. } catch {
  1177. // Ignore individual errors
  1178. }
  1179. continue
  1180. }
  1181. // Candidate version binary — stat once, reuse for isFile/size/mtime/mode
  1182. try {
  1183. const stats = await stat(entryPath)
  1184. if (!stats.isFile()) continue
  1185. if (
  1186. process.platform !== 'win32' &&
  1187. stats.size > 0 &&
  1188. (stats.mode & 0o111) === 0
  1189. ) {
  1190. // Check executability via mode bits from the existing stat result —
  1191. // avoids a second syscall (access(X_OK)) and the TOCTOU window between
  1192. // stat and access. Skip on Windows: libuv only sets execute bits for
  1193. // .exe/.com/.bat/.cmd, but version files are extensionless semver
  1194. // strings (e.g. "1.2.3"), so this check would reject all of them.
  1195. // The previous access(X_OK) passed any readable file on Windows anyway.
  1196. continue
  1197. }
  1198. versionFiles.push({
  1199. name: entry,
  1200. path: entryPath,
  1201. resolvedPath: resolve(entryPath),
  1202. mtime: stats.mtime,
  1203. })
  1204. } catch {
  1205. // Skip files we can't stat
  1206. }
  1207. }
  1208. if (tempFilesCleanedCount > 0) {
  1209. logForDebugging(
  1210. `Cleaned up ${tempFilesCleanedCount} orphaned temp install files`,
  1211. )
  1212. logEvent('tengu_native_temp_files_cleanup', {
  1213. cleaned_count: tempFilesCleanedCount,
  1214. })
  1215. }
  1216. if (versionFiles.length === 0) {
  1217. return
  1218. }
  1219. try {
  1220. // Identify protected versions
  1221. const currentBinaryPath = process.execPath
  1222. const protectedVersions = new Set<string>()
  1223. if (currentBinaryPath && currentBinaryPath.includes(dirs.versions)) {
  1224. protectedVersions.add(resolve(currentBinaryPath))
  1225. }
  1226. const currentSymlinkVersion = await getVersionFromSymlink(dirs.executable)
  1227. if (currentSymlinkVersion) {
  1228. protectedVersions.add(currentSymlinkVersion)
  1229. }
  1230. // Protect versions with active locks (running in other processes)
  1231. for (const v of versionFiles) {
  1232. if (protectedVersions.has(v.resolvedPath)) continue
  1233. const lockFilePath = getLockFilePathFromVersionPath(dirs, v.resolvedPath)
  1234. let hasActiveLock = false
  1235. if (isPidBasedLockingEnabled()) {
  1236. hasActiveLock = isLockActive(lockFilePath)
  1237. } else {
  1238. try {
  1239. hasActiveLock = await lockfile.check(v.resolvedPath, {
  1240. stale: LOCK_STALE_MS,
  1241. lockfilePath: lockFilePath,
  1242. })
  1243. } catch {
  1244. hasActiveLock = false
  1245. }
  1246. }
  1247. if (hasActiveLock) {
  1248. protectedVersions.add(v.resolvedPath)
  1249. logForDebugging(`Protecting locked version from cleanup: ${v.name}`)
  1250. }
  1251. }
  1252. // Eligible versions: not protected, sorted newest first (reuse cached mtime)
  1253. const eligibleVersions = versionFiles
  1254. .filter(v => !protectedVersions.has(v.resolvedPath))
  1255. .sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
  1256. const versionsToDelete = eligibleVersions.slice(VERSION_RETENTION_COUNT)
  1257. if (versionsToDelete.length === 0) {
  1258. logEvent('tengu_native_version_cleanup', {
  1259. total_count: versionFiles.length,
  1260. deleted_count: 0,
  1261. protected_count: protectedVersions.size,
  1262. retained_count: VERSION_RETENTION_COUNT,
  1263. lock_failed_count: 0,
  1264. error_count: 0,
  1265. })
  1266. return
  1267. }
  1268. let deletedCount = 0
  1269. let lockFailedCount = 0
  1270. let errorCount = 0
  1271. await Promise.all(
  1272. versionsToDelete.map(async version => {
  1273. try {
  1274. const deleted = await tryWithVersionLock(version.path, async () => {
  1275. await unlink(version.path)
  1276. })
  1277. if (deleted) {
  1278. deletedCount++
  1279. } else {
  1280. lockFailedCount++
  1281. logForDebugging(
  1282. `Skipping deletion of ${version.name} - locked by another process`,
  1283. )
  1284. }
  1285. } catch (error) {
  1286. errorCount++
  1287. logError(
  1288. new Error(`Failed to delete version ${version.name}: ${error}`),
  1289. )
  1290. }
  1291. }),
  1292. )
  1293. logEvent('tengu_native_version_cleanup', {
  1294. total_count: versionFiles.length,
  1295. deleted_count: deletedCount,
  1296. protected_count: protectedVersions.size,
  1297. retained_count: VERSION_RETENTION_COUNT,
  1298. lock_failed_count: lockFailedCount,
  1299. error_count: errorCount,
  1300. })
  1301. } catch (error) {
  1302. if (!isENOENT(error)) {
  1303. logError(new Error(`Version cleanup failed: ${error}`))
  1304. }
  1305. }
  1306. }
  1307. /**
  1308. * Check if a given path is managed by npm
  1309. * @param executablePath - The path to check (can be a symlink)
  1310. * @returns true if the path is npm-managed, false otherwise
  1311. */
  1312. async function isNpmSymlink(executablePath: string): Promise<boolean> {
  1313. // Resolve symlink to its target if applicable
  1314. let targetPath = executablePath
  1315. const stats = await lstat(executablePath)
  1316. if (stats.isSymbolicLink()) {
  1317. targetPath = await realpath(executablePath)
  1318. }
  1319. // checking npm prefix isn't guaranteed to work, as prefix can change
  1320. // and users may set --prefix manually when installing
  1321. // thus we use this heuristic:
  1322. return targetPath.endsWith('.js') || targetPath.includes('node_modules')
  1323. }
  1324. /**
  1325. * Remove the claude symlink from the executable directory
  1326. * This is used when switching away from native installation
  1327. * Will only remove if it's a native binary symlink, not npm-managed JS files
  1328. */
  1329. export async function removeInstalledSymlink(): Promise<void> {
  1330. const dirs = getBaseDirectories()
  1331. try {
  1332. // Check if this is an npm-managed installation
  1333. if (await isNpmSymlink(dirs.executable)) {
  1334. logForDebugging(
  1335. `Skipping removal of ${dirs.executable} - appears to be npm-managed`,
  1336. )
  1337. return
  1338. }
  1339. // It's a native binary symlink, safe to remove
  1340. await unlink(dirs.executable)
  1341. logForDebugging(`Removed claude symlink at ${dirs.executable}`)
  1342. } catch (error) {
  1343. if (isENOENT(error)) {
  1344. return
  1345. }
  1346. logError(new Error(`Failed to remove claude symlink: ${error}`))
  1347. }
  1348. }
  1349. /**
  1350. * Clean up old claude aliases from shell configuration files
  1351. * Only handles alias removal, not PATH setup
  1352. */
  1353. export async function cleanupShellAliases(): Promise<SetupMessage[]> {
  1354. const messages: SetupMessage[] = []
  1355. const configMap = getShellConfigPaths()
  1356. for (const [shellType, configFile] of Object.entries(configMap)) {
  1357. try {
  1358. const lines = await readFileLines(configFile)
  1359. if (!lines) continue
  1360. const { filtered, hadAlias } = filterClaudeAliases(lines)
  1361. if (hadAlias) {
  1362. await writeFileLines(configFile, filtered)
  1363. messages.push({
  1364. message: `Removed claude alias from ${configFile}. Run: unalias claude`,
  1365. userActionRequired: true,
  1366. type: 'alias',
  1367. })
  1368. logForDebugging(`Cleaned up claude alias from ${shellType} config`)
  1369. }
  1370. } catch (error) {
  1371. logError(error)
  1372. messages.push({
  1373. message: `Failed to clean up ${configFile}: ${error}`,
  1374. userActionRequired: false,
  1375. type: 'error',
  1376. })
  1377. }
  1378. }
  1379. return messages
  1380. }
  1381. async function manualRemoveNpmPackage(
  1382. packageName: string,
  1383. ): Promise<{ success: boolean; error?: string; warning?: string }> {
  1384. try {
  1385. // Get npm global prefix
  1386. const prefixResult = await execFileNoThrowWithCwd('npm', [
  1387. 'config',
  1388. 'get',
  1389. 'prefix',
  1390. ])
  1391. if (prefixResult.code !== 0 || !prefixResult.stdout) {
  1392. return {
  1393. success: false,
  1394. error: '获取 npm 全局前缀失败',
  1395. }
  1396. }
  1397. const globalPrefix = prefixResult.stdout.trim()
  1398. let manuallyRemoved = false
  1399. // Helper to try removing a file. unlink alone is sufficient — it throws
  1400. // ENOENT if the file is missing, which the catch handles identically.
  1401. // A stat() pre-check would add a syscall and a TOCTOU window where
  1402. // concurrent cleanup causes a false-negative return.
  1403. async function tryRemove(filePath: string, description: string) {
  1404. try {
  1405. await unlink(filePath)
  1406. logForDebugging(`Manually removed ${description}: ${filePath}`)
  1407. return true
  1408. } catch {
  1409. return false
  1410. }
  1411. }
  1412. if (getPlatform().startsWith('win32')) {
  1413. // Windows - only remove executables, not the package directory
  1414. const binCmd = join(globalPrefix, 'claude.cmd')
  1415. const binPs1 = join(globalPrefix, 'claude.ps1')
  1416. const binExe = join(globalPrefix, 'claude')
  1417. if (await tryRemove(binCmd, 'bin script')) {
  1418. manuallyRemoved = true
  1419. }
  1420. if (await tryRemove(binPs1, 'PowerShell script')) {
  1421. manuallyRemoved = true
  1422. }
  1423. if (await tryRemove(binExe, 'bin executable')) {
  1424. manuallyRemoved = true
  1425. }
  1426. } else {
  1427. // Unix/Mac - only remove symlink, not the package directory
  1428. const binSymlink = join(globalPrefix, 'bin', 'claude')
  1429. if (await tryRemove(binSymlink, 'bin symlink')) {
  1430. manuallyRemoved = true
  1431. }
  1432. }
  1433. if (manuallyRemoved) {
  1434. logForDebugging(`Successfully removed ${packageName} manually`)
  1435. const nodeModulesPath = getPlatform().startsWith('win32')
  1436. ? join(globalPrefix, 'node_modules', packageName)
  1437. : join(globalPrefix, 'lib', 'node_modules', packageName)
  1438. return {
  1439. success: true,
  1440. warning: `${packageName} executables removed, but node_modules directory was left intact for safety. You may manually delete it later at: ${nodeModulesPath}`,
  1441. }
  1442. } else {
  1443. return { success: false }
  1444. }
  1445. } catch (manualError) {
  1446. logForDebugging(`Manual removal failed: ${manualError}`, {
  1447. level: 'error',
  1448. })
  1449. return {
  1450. success: false,
  1451. error: `Manual removal failed: ${manualError}`,
  1452. }
  1453. }
  1454. }
  1455. async function attemptNpmUninstall(
  1456. packageName: string,
  1457. ): Promise<{ success: boolean; error?: string; warning?: string }> {
  1458. const { code, stderr } = await execFileNoThrowWithCwd(
  1459. 'npm',
  1460. ['uninstall', '-g', packageName],
  1461. // eslint-disable-next-line custom-rules/no-process-cwd -- matches original behavior
  1462. { cwd: process.cwd() },
  1463. )
  1464. if (code === 0) {
  1465. logForDebugging(`Removed global npm installation of ${packageName}`)
  1466. return { success: true }
  1467. } else if (stderr && !stderr.includes('npm ERR! code E404')) {
  1468. // Check for ENOTEMPTY error and try manual removal
  1469. if (stderr.includes('npm error code ENOTEMPTY')) {
  1470. logForDebugging(
  1471. `Failed to uninstall global npm package ${packageName}: ${stderr}`,
  1472. { level: 'error' },
  1473. )
  1474. logForDebugging(`Attempting manual removal due to ENOTEMPTY error`)
  1475. const manualResult = await manualRemoveNpmPackage(packageName)
  1476. if (manualResult.success) {
  1477. return { success: true, warning: manualResult.warning }
  1478. } else if (manualResult.error) {
  1479. return {
  1480. success: false,
  1481. error: `Failed to remove global npm installation of ${packageName}: ${stderr}. Manual removal also failed: ${manualResult.error}`,
  1482. }
  1483. }
  1484. }
  1485. // Only report as error if it's not a "package not found" error
  1486. logForDebugging(
  1487. `Failed to uninstall global npm package ${packageName}: ${stderr}`,
  1488. { level: 'error' },
  1489. )
  1490. return {
  1491. success: false,
  1492. error: `Failed to remove global npm installation of ${packageName}: ${stderr}`,
  1493. }
  1494. }
  1495. return { success: false } // Package not found, not an error
  1496. }
  1497. export async function cleanupNpmInstallations(): Promise<{
  1498. removed: number
  1499. errors: string[]
  1500. warnings: string[]
  1501. }> {
  1502. const errors: string[] = []
  1503. const warnings: string[] = []
  1504. let removed = 0
  1505. // Always attempt to remove @anthropic-ai/claude-code
  1506. const codePackageResult = await attemptNpmUninstall(
  1507. '@anthropic-ai/claude-code',
  1508. )
  1509. if (codePackageResult.success) {
  1510. removed++
  1511. if (codePackageResult.warning) {
  1512. warnings.push(codePackageResult.warning)
  1513. }
  1514. } else if (codePackageResult.error) {
  1515. errors.push(codePackageResult.error)
  1516. }
  1517. // Also attempt to remove MACRO.PACKAGE_URL if it's defined and different
  1518. if (MACRO.PACKAGE_URL && MACRO.PACKAGE_URL !== '@anthropic-ai/claude-code') {
  1519. const macroPackageResult = await attemptNpmUninstall(MACRO.PACKAGE_URL)
  1520. if (macroPackageResult.success) {
  1521. removed++
  1522. if (macroPackageResult.warning) {
  1523. warnings.push(macroPackageResult.warning)
  1524. }
  1525. } else if (macroPackageResult.error) {
  1526. errors.push(macroPackageResult.error)
  1527. }
  1528. }
  1529. // Check for local installation at ~/.claude/local
  1530. const localInstallDir = join(homedir(), '.claude', 'local')
  1531. try {
  1532. await rm(localInstallDir, { recursive: true })
  1533. removed++
  1534. logForDebugging(`Removed local installation at ${localInstallDir}`)
  1535. } catch (error) {
  1536. if (!isENOENT(error)) {
  1537. errors.push(`Failed to remove ${localInstallDir}: ${error}`)
  1538. logForDebugging(`Failed to remove local installation: ${error}`, {
  1539. level: 'error',
  1540. })
  1541. }
  1542. }
  1543. return { removed, errors, warnings }
  1544. }