22
33use anyhow:: { Context , Result , anyhow, bail} ;
44use std:: path:: { Path , PathBuf } ;
5+ use tracing:: info;
56
67use crate :: config;
78use crate :: memory;
89
9- fn bun_download_url ( ) -> Result < & ' static str > {
10+ // ============================================================================
11+ // Pinned Versions
12+ // ============================================================================
13+
14+ const BUN_VERSION : & str = "1.2.4" ;
15+ const CLAUDE_CODE_VERSION : & str = "2.1.32" ;
16+
17+ const VERSION_FILE : & str = ".version" ;
18+
19+ /// Read the installed version from a dependency directory
20+ fn read_installed_version ( dep_dir : & Path ) -> Option < String > {
21+ std:: fs:: read_to_string ( dep_dir. join ( VERSION_FILE ) )
22+ . ok ( )
23+ . map ( |s| s. trim ( ) . to_string ( ) )
24+ . filter ( |s| !s. is_empty ( ) )
25+ }
26+
27+ /// Write the installed version marker to a dependency directory
28+ fn write_installed_version ( dep_dir : & Path , version : & str ) -> Result < ( ) > {
29+ std:: fs:: write ( dep_dir. join ( VERSION_FILE ) , version) ?;
30+ Ok ( ( ) )
31+ }
32+
33+ /// Check if the installed version matches the expected version
34+ fn needs_update ( dep_dir : & Path , expected : & str ) -> bool {
35+ read_installed_version ( dep_dir) . as_deref ( ) != Some ( expected)
36+ }
37+
38+ // ============================================================================
39+ // Bun
40+ // ============================================================================
41+
42+ fn bun_download_url ( ) -> Result < String > {
1043 match ( std:: env:: consts:: OS , std:: env:: consts:: ARCH ) {
11- ( "macos" , "aarch64" ) => {
12- Ok ( "https://github.com/oven-sh/bun/releases/download/bun-v1.2.4/bun-darwin-aarch64.zip" )
13- }
14- ( "macos" , "x86_64" ) => {
15- Ok ( "https://github.com/oven-sh/bun/releases/download/bun-v1.2.4/bun-darwin-x64.zip" )
16- }
17- ( "linux" , "aarch64" ) => {
18- Ok ( "https://github.com/oven-sh/bun/releases/download/bun-v1.2.4/bun-linux-aarch64.zip" )
19- }
20- ( "linux" , "x86_64" ) => {
21- Ok ( "https://github.com/oven-sh/bun/releases/download/bun-v1.2.4/bun-linux-x64.zip" )
22- }
44+ ( "macos" , "aarch64" ) => Ok ( format ! (
45+ "https://github.com/oven-sh/bun/releases/download/bun-v{}/bun-darwin-aarch64.zip" ,
46+ BUN_VERSION
47+ ) ) ,
48+ ( "macos" , "x86_64" ) => Ok ( format ! (
49+ "https://github.com/oven-sh/bun/releases/download/bun-v{}/bun-darwin-x64.zip" ,
50+ BUN_VERSION
51+ ) ) ,
52+ ( "linux" , "aarch64" ) => Ok ( format ! (
53+ "https://github.com/oven-sh/bun/releases/download/bun-v{}/bun-linux-aarch64.zip" ,
54+ BUN_VERSION
55+ ) ) ,
56+ ( "linux" , "x86_64" ) => Ok ( format ! (
57+ "https://github.com/oven-sh/bun/releases/download/bun-v{}/bun-linux-x64.zip" ,
58+ BUN_VERSION
59+ ) ) ,
2360 ( os, arch) => bail ! ( "Unsupported platform: {}-{}" , os, arch) ,
2461 }
2562}
@@ -42,29 +79,33 @@ pub fn find_bun() -> Option<PathBuf> {
4279 None
4380}
4481
45- /// Ensure Bun is available, downloading if necessary (async version)
82+ /// Ensure Bun is available and at the expected version
4683pub async fn ensure_bun ( ) -> Result < PathBuf > {
47- // Check if already available
48- if let Some ( path) = find_bun ( ) {
49- return Ok ( path) ;
84+ let paths = config:: paths ( ) ?;
85+
86+ if find_bun ( ) . is_some ( ) && !needs_update ( & paths. bun_dir , BUN_VERSION ) {
87+ return find_bun ( ) . ok_or_else ( || anyhow ! ( "Bun not found" ) ) ;
88+ }
89+
90+ if needs_update ( & paths. bun_dir , BUN_VERSION ) {
91+ info ! ( "Updating Bun to v{}..." , BUN_VERSION ) ;
92+ let _ = std:: fs:: remove_dir_all ( & paths. bun_dir ) ;
5093 }
5194
52- // Need to download
53- let paths = config:: paths ( ) ?;
5495 std:: fs:: create_dir_all ( & paths. bun_dir ) ?;
5596
5697 let url = bun_download_url ( ) ?;
5798 let bun_path = paths. bun_dir . join ( "bun" ) ;
5899
59- download_and_extract_bun ( url, & paths. bun_dir ) . await ?;
100+ download_and_extract_bun ( & url, & paths. bun_dir ) . await ?;
60101
61- // Make executable
62102 #[ cfg( unix) ]
63103 {
64104 use std:: os:: unix:: fs:: PermissionsExt ;
65105 std:: fs:: set_permissions ( & bun_path, std:: fs:: Permissions :: from_mode ( 0o755 ) ) ?;
66106 }
67107
108+ write_installed_version ( & paths. bun_dir , BUN_VERSION ) ?;
68109 Ok ( bun_path)
69110}
70111
@@ -115,20 +156,28 @@ pub fn find_claude_code() -> Option<PathBuf> {
115156 None
116157}
117158
118- /// Ensure Claude Code is available, downloading if necessary
159+ /// Ensure Claude Code is available and at the expected version
119160pub async fn ensure_claude_code ( ) -> Result < PathBuf > {
120- if let Some ( path) = find_claude_code ( ) {
121- return Ok ( path) ;
161+ if find_claude_code ( ) . is_some ( )
162+ && !needs_update ( & config:: paths ( ) ?. claude_code_dir , CLAUDE_CODE_VERSION )
163+ {
164+ return find_claude_code ( ) . ok_or_else ( || anyhow ! ( "Claude Code not found" ) ) ;
122165 }
123166
124167 let paths = config:: paths ( ) ?;
168+
169+ if needs_update ( & paths. claude_code_dir , CLAUDE_CODE_VERSION ) {
170+ info ! ( "Updating Claude Code to v{}..." , CLAUDE_CODE_VERSION ) ;
171+ let _ = std:: fs:: remove_dir_all ( & paths. claude_code_dir ) ;
172+ }
173+
125174 std:: fs:: create_dir_all ( & paths. claude_code_dir ) ?;
126175
127- // Use bun to install claude-code
128176 let bun = find_bun ( ) . ok_or_else ( || anyhow ! ( "Bun not found - run ensure_bun first" ) ) ?;
177+ let pkg = format ! ( "@anthropic-ai/claude-code@{}" , CLAUDE_CODE_VERSION ) ;
129178
130179 let status = tokio:: process:: Command :: new ( & bun)
131- . args ( [ "add" , "@anthropic-ai/claude-code" ] )
180+ . args ( [ "add" , & pkg ] )
132181 . current_dir ( & paths. claude_code_dir )
133182 . status ( )
134183 . await
@@ -138,6 +187,7 @@ pub async fn ensure_claude_code() -> Result<PathBuf> {
138187 bail ! ( "Failed to install Claude Code" ) ;
139188 }
140189
190+ write_installed_version ( & paths. claude_code_dir , CLAUDE_CODE_VERSION ) ?;
141191 find_claude_code ( ) . ok_or_else ( || anyhow ! ( "Claude Code installation failed" ) )
142192}
143193
@@ -217,9 +267,10 @@ fn validate_oauth_token(token: &str) -> Result<()> {
217267}
218268
219269// ============================================================================
220- // Java (for signal-cli)
270+ // Java & signal-cli
221271// ============================================================================
222272
273+ const JAVA_VERSION : & str = "21" ;
223274const SIGNAL_CLI_VERSION : & str = "0.13.22" ;
224275
225276fn java_download_url ( ) -> Result < & ' static str > {
@@ -270,18 +321,25 @@ pub fn find_java() -> Option<PathBuf> {
270321 None
271322}
272323
273- /// Ensure Java is available, downloading if necessary
324+ /// Ensure Java is available and at the expected version
274325pub async fn ensure_java ( ) -> Result < PathBuf > {
275- if let Some ( path) = find_java ( ) {
276- return Ok ( path) ;
326+ let paths = config:: paths ( ) ?;
327+
328+ if find_java ( ) . is_some ( ) && !needs_update ( & paths. java_dir , JAVA_VERSION ) {
329+ return find_java ( ) . ok_or_else ( || anyhow ! ( "Java not found" ) ) ;
330+ }
331+
332+ if needs_update ( & paths. java_dir , JAVA_VERSION ) {
333+ info ! ( "Updating Java JRE {}..." , JAVA_VERSION ) ;
334+ let _ = std:: fs:: remove_dir_all ( & paths. java_dir ) ;
277335 }
278336
279- let paths = config:: paths ( ) ?;
280337 std:: fs:: create_dir_all ( & paths. java_dir ) ?;
281338
282339 let url = java_download_url ( ) ?;
283340 download_and_extract_tarball ( url, & paths. java_dir ) . await ?;
284341
342+ write_installed_version ( & paths. java_dir , JAVA_VERSION ) ?;
285343 find_java ( )
286344 . ok_or_else ( || anyhow ! ( "Java installation failed - binary not found after extraction" ) )
287345}
@@ -309,18 +367,25 @@ pub fn find_signal_cli() -> Option<PathBuf> {
309367 None
310368}
311369
312- /// Ensure signal-cli is available, downloading if necessary
370+ /// Ensure signal-cli is available and at the expected version
313371pub async fn ensure_signal_cli ( ) -> Result < PathBuf > {
314- if let Some ( path) = find_signal_cli ( ) {
315- return Ok ( path) ;
372+ let paths = config:: paths ( ) ?;
373+
374+ if find_signal_cli ( ) . is_some ( ) && !needs_update ( & paths. signal_cli_dir , SIGNAL_CLI_VERSION ) {
375+ return find_signal_cli ( ) . ok_or_else ( || anyhow ! ( "signal-cli not found" ) ) ;
376+ }
377+
378+ if needs_update ( & paths. signal_cli_dir , SIGNAL_CLI_VERSION ) {
379+ info ! ( "Updating signal-cli to v{}..." , SIGNAL_CLI_VERSION ) ;
380+ let _ = std:: fs:: remove_dir_all ( & paths. signal_cli_dir ) ;
316381 }
317382
318- let paths = config:: paths ( ) ?;
319383 std:: fs:: create_dir_all ( & paths. signal_cli_dir ) ?;
320384
321385 let url = signal_cli_download_url ( ) ;
322386 download_and_extract_tarball ( & url, & paths. signal_cli_dir ) . await ?;
323387
388+ write_installed_version ( & paths. signal_cli_dir , SIGNAL_CLI_VERSION ) ?;
324389 find_signal_cli ( ) . ok_or_else ( || {
325390 anyhow ! ( "signal-cli installation failed - binary not found after extraction" )
326391 } )
@@ -379,19 +444,25 @@ pub fn find_cursor_cli() -> Option<PathBuf> {
379444 None
380445}
381446
382- /// Ensure Cursor CLI is available, downloading if necessary
447+ /// Ensure Cursor CLI is available and at the expected version
383448pub async fn ensure_cursor_cli ( ) -> Result < PathBuf > {
384- if let Some ( path) = find_cursor_cli ( ) {
385- return Ok ( path) ;
449+ let paths = config:: paths ( ) ?;
450+
451+ if find_cursor_cli ( ) . is_some ( ) && !needs_update ( & paths. cursor_cli_dir , CURSOR_CLI_VERSION ) {
452+ return find_cursor_cli ( ) . ok_or_else ( || anyhow ! ( "Cursor CLI not found" ) ) ;
453+ }
454+
455+ if needs_update ( & paths. cursor_cli_dir , CURSOR_CLI_VERSION ) {
456+ info ! ( "Updating Cursor CLI to {}..." , CURSOR_CLI_VERSION ) ;
457+ let _ = std:: fs:: remove_dir_all ( & paths. cursor_cli_dir ) ;
386458 }
387459
388- let paths = config:: paths ( ) ?;
389460 std:: fs:: create_dir_all ( & paths. cursor_cli_dir ) ?;
390461 std:: fs:: create_dir_all ( & paths. cursor_home ) ?;
391462
392- // Download Cursor CLI
393463 download_cursor_cli ( & paths. cursor_cli_dir ) . await ?;
394464
465+ write_installed_version ( & paths. cursor_cli_dir , CURSOR_CLI_VERSION ) ?;
395466 find_cursor_cli ( ) . ok_or_else ( || anyhow ! ( "Cursor CLI installation failed" ) )
396467}
397468
@@ -570,3 +641,32 @@ pub async fn validate_cursor_api_key(api_key: &str) -> Result<()> {
570641pub fn ensure_embedding_model ( ) -> Result < ( ) > {
571642 memory:: ensure_model_downloaded ( )
572643}
644+
645+ // ============================================================================
646+ // Startup Dependency Check
647+ // ============================================================================
648+
649+ /// Ensure all dependencies for the active backend are installed and up to date.
650+ /// Called on `cica run` startup.
651+ pub async fn ensure_deps ( config : & crate :: config:: Config ) -> Result < ( ) > {
652+ use crate :: config:: AiBackend ;
653+
654+ match config. backend {
655+ AiBackend :: Claude => {
656+ ensure_bun ( ) . await ?;
657+ ensure_claude_code ( ) . await ?;
658+ }
659+ AiBackend :: Cursor => {
660+ ensure_bun ( ) . await ?;
661+ ensure_cursor_cli ( ) . await ?;
662+ }
663+ }
664+
665+ if config. channels . signal . is_some ( ) {
666+ ensure_java ( ) . await ?;
667+ ensure_signal_cli ( ) . await ?;
668+ }
669+
670+ ensure_embedding_model ( ) ?;
671+ Ok ( ( ) )
672+ }
0 commit comments