rch 2.1.15

Remote Compilation Helper - PreToolUse hook CLI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! Self-update module for RCH binaries.
//!
//! Provides functionality to:
//! - Check for updates from GitHub releases
//! - Download and verify release artifacts
//! - Coordinate with daemon for safe updates
//! - Support rollback to previous versions
//! - Update fleet of workers

mod check;
mod download;
mod install;
mod lock;
mod types;
pub(crate) mod verify;

pub use check::{check_for_updates, spawn_update_check_if_needed};
pub use download::download_release;
pub use install::{install_update, rollback};
pub use types::{Channel, UpdateCheck};

use crate::commands;
use crate::ui::OutputContext;
use anyhow::Result;
use types::UpdateError;

/// Main entry point for the update command.
#[allow(clippy::too_many_arguments)]
pub async fn run_update(
    ctx: &OutputContext,
    check_only: bool,
    version: Option<String>,
    channel: Channel,
    fleet: bool,
    do_rollback: bool,
    verify_only: bool,
    dry_run: bool,
    yes: bool,
    skip_verify: bool,
    no_restart: bool,
    drain_timeout: u64,
    show_changelog: bool,
) -> Result<()> {
    if do_rollback {
        return rollback(ctx, dry_run, None)
            .await
            .map_err(|e| anyhow::anyhow!("{}", e));
    }

    if verify_only {
        return verify_installation(ctx).await;
    }

    // An explicit `rch update` (including --check) always asks GitHub.
    let update_info = check_for_updates(channel, version.clone(), false).await?;

    if check_only {
        display_update_check(ctx, &update_info, show_changelog);
        return Ok(());
    }

    if !update_info.update_available {
        if !ctx.is_json() {
            println!("Already up to date ({})", update_info.current_version);
        } else {
            println!(
                "{}",
                serde_json::json!({
                    "update_available": false,
                    "current_version": update_info.current_version.to_string(),
                })
            );
        }
        return Ok(());
    }

    // Show what we're about to do
    if !ctx.is_json() && !yes {
        println!(
            "Update available: {} -> {}",
            update_info.current_version, update_info.latest_version
        );
        if show_changelog && let Some(ref notes) = update_info.release_notes {
            println!("\nRelease notes:\n{}", notes);
        }
        if !dry_run {
            println!("\nProceed with update? [y/N]");
            // In a real implementation, we'd read user input here
            // For now, require --yes flag
            if !yes {
                println!("Use --yes to confirm update");
                return Ok(());
            }
        }
    }

    if dry_run {
        println!("Dry run: would update to {}", update_info.latest_version);
        return Ok(());
    }

    // Download and verify
    let download = download_release(ctx, &update_info, skip_verify).await?;

    if !download.checksum_verified {
        let asset = download
            .archive_path
            .file_name()
            .map(|name| name.to_string_lossy().to_string())
            .unwrap_or_else(|| "unknown".to_string());

        if skip_verify {
            if !ctx.is_json() {
                println!(
                    "Warning: proceeding without checksum verification for {} (--skip-verify)",
                    asset
                );
            }
        } else {
            return Err(UpdateError::ChecksumMissing { asset }.into());
        }
    }

    // Install
    let result = install_update(ctx, &download, !no_restart, drain_timeout).await?;

    if !ctx.is_json() {
        println!(
            "Successfully updated to {} (backup: {})",
            update_info.latest_version,
            result.backup_path.display()
        );
    }

    // Fleet update if requested
    if fleet {
        update_fleet(ctx, &update_info, dry_run).await?;
    }

    Ok(())
}

/// Display update check results.
fn display_update_check(ctx: &OutputContext, info: &UpdateCheck, show_changelog: bool) {
    if ctx.is_json() {
        println!("{}", serde_json::to_string_pretty(info).unwrap());
        return;
    }

    if info.update_available {
        println!(
            "Update available: {} -> {}",
            info.current_version, info.latest_version
        );
        println!("Release URL: {}", info.release_url);

        if show_changelog {
            // Prefer changelog_diff (aggregated changes from all intermediate releases)
            // Fall back to release_notes (just the latest release) if diff isn't available
            if let Some(ref diff) = info.changelog_diff {
                println!("\nChanges since {}:\n{}", info.current_version, diff);
            } else if let Some(ref notes) = info.release_notes {
                println!("\nRelease notes:\n{}", notes);
            }
        }

        println!("\nRun 'rch update' to update.");
    } else {
        println!("Already up to date ({})", info.current_version);
    }
}

/// Verify the current installation integrity.
async fn verify_installation(ctx: &OutputContext) -> Result<()> {
    if !ctx.is_json() {
        println!("Verifying installation...");
    }

    // Check that binaries exist and can report versions
    let rch_version = std::process::Command::new(std::env::current_exe()?)
        .arg("--version")
        .output()?;

    if rch_version.status.success() {
        let version = String::from_utf8_lossy(&rch_version.stdout);
        if !ctx.is_json() {
            println!("rch: {}", version.trim());
        }
    }

    // Note: checksum verification is enforced during update downloads.

    if !ctx.is_json() {
        println!("Installation verified.");
    }

    Ok(())
}

/// Update fleet of workers.
async fn update_fleet(ctx: &OutputContext, _info: &UpdateCheck, dry_run: bool) -> Result<()> {
    if !ctx.is_json() {
        println!("Updating fleet...");
    }

    if dry_run {
        if !ctx.is_json() {
            println!("Dry run: would update all configured workers");
        }
        commands::workers_deploy_binary(None, true, false, true, ctx).await?;
        return Ok(());
    }

    // Deploy rch-wkr to all configured workers (version-aware, skips if already up-to-date).
    commands::workers_deploy_binary(None, true, false, false, ctx).await?;

    if !ctx.is_json() {
        println!("Fleet update complete.");
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::commands::set_test_config_dir_override;
    use crate::ui::{OutputConfig, OutputMode};
    use types::Version;

    #[test]
    fn test_channel_default() {
        assert_eq!(Channel::default(), Channel::Stable);
    }

    #[test]
    fn test_channel_variants() {
        // Test all channel variants exist
        let stable = Channel::Stable;
        let beta = Channel::Beta;
        let nightly = Channel::Nightly;

        assert_ne!(stable, beta);
        assert_ne!(beta, nightly);
        assert_ne!(stable, nightly);
    }

    fn create_test_output_context(json: bool) -> OutputContext {
        let config = OutputConfig {
            force_mode: Some(if json {
                OutputMode::Json
            } else {
                OutputMode::Plain
            }),
            json,
            ..Default::default()
        };
        OutputContext::new(config)
    }

    fn create_test_update_check(update_available: bool) -> UpdateCheck {
        UpdateCheck {
            current_version: Version::parse("1.0.0").unwrap(),
            latest_version: if update_available {
                Version::parse("2.0.0").unwrap()
            } else {
                Version::parse("1.0.0").unwrap()
            },
            update_available,
            release_url: "https://github.com/test/releases/v2.0.0".to_string(),
            release_notes: Some("Test release notes".to_string()),
            changelog_diff: None,
            assets: vec![],
        }
    }

    #[test]
    fn test_update_check_creation_no_update() {
        let check = create_test_update_check(false);
        assert!(!check.update_available);
        assert_eq!(check.current_version, check.latest_version);
    }

    #[test]
    fn test_update_check_creation_with_update() {
        let check = create_test_update_check(true);
        assert!(check.update_available);
        assert!(check.latest_version > check.current_version);
    }

    #[test]
    fn test_update_check_has_release_notes() {
        let check = create_test_update_check(true);
        assert!(check.release_notes.is_some());
        assert!(check.release_notes.as_ref().unwrap().contains("Test"));
    }

    #[test]
    fn test_update_check_release_url() {
        let check = create_test_update_check(true);
        assert!(!check.release_url.is_empty());
        assert!(check.release_url.contains("github"));
    }

    #[test]
    fn test_display_update_check_json_mode() {
        let ctx = create_test_output_context(true);
        let info = create_test_update_check(true);

        // This function prints to stdout - just verify it doesn't panic
        display_update_check(&ctx, &info, false);
    }

    #[test]
    fn test_display_update_check_no_update() {
        let ctx = create_test_output_context(false);
        let info = create_test_update_check(false);

        // Verify it doesn't panic
        display_update_check(&ctx, &info, false);
    }

    #[test]
    fn test_display_update_check_with_update() {
        let ctx = create_test_output_context(false);
        let info = create_test_update_check(true);

        // Verify it doesn't panic
        display_update_check(&ctx, &info, false);
    }

    #[test]
    fn test_display_update_check_with_changelog() {
        let ctx = create_test_output_context(false);
        let info = create_test_update_check(true);

        // Verify it doesn't panic with changelog display
        display_update_check(&ctx, &info, true);
    }

    #[test]
    fn test_update_check_empty_assets() {
        let check = create_test_update_check(true);
        assert!(check.assets.is_empty());
    }

    #[test]
    fn test_update_check_with_changelog_diff() {
        let mut check = create_test_update_check(true);
        check.changelog_diff = Some("## Changes\n- Feature A\n- Bug fix B".to_string());
        assert!(check.changelog_diff.is_some());
    }

    #[tokio::test]
    async fn test_verify_installation_plain_mode() {
        let ctx = create_test_output_context(false);

        // This will run the current executable with --version
        // It may fail in test environment but shouldn't panic
        let _ = verify_installation(&ctx).await;
    }

    #[tokio::test]
    async fn test_verify_installation_json_mode() {
        let ctx = create_test_output_context(true);

        // Verify it doesn't panic in JSON mode
        let _ = verify_installation(&ctx).await;
    }

    #[tokio::test]
    async fn test_update_fleet_dry_run() {
        // Set up a temp config directory for the test
        let temp_dir = std::env::temp_dir().join("rch_test_update_fleet_dry_run");
        let _ = std::fs::create_dir_all(&temp_dir);
        set_test_config_dir_override(Some(temp_dir.clone()));

        let ctx = create_test_output_context(false);
        let info = create_test_update_check(true);

        // Dry run should succeed (workers will be empty but that returns Ok)
        let result = update_fleet(&ctx, &info, true).await;
        assert!(result.is_ok());

        // Clean up
        set_test_config_dir_override(None);
        let _ = std::fs::remove_dir_all(&temp_dir);
    }

    // current_thread: the config-dir override is thread-local, and this runs
    // the REAL (non-dry-run) fleet deploy path. On another thread it would
    // read the host's workers.toml and deploy to the live fleet over SSH.
    #[tokio::test(flavor = "current_thread")]
    async fn test_update_fleet_json_mode() {
        // Set up a temp config directory for the test
        let temp_dir = std::env::temp_dir().join("rch_test_update_fleet_json_mode");
        let _ = std::fs::create_dir_all(&temp_dir);
        set_test_config_dir_override(Some(temp_dir.clone()));
        // Fail closed before the real deploy path if isolation ever breaks.
        assert!(
            crate::commands::load_workers_from_config()
                .expect("load isolated workers")
                .is_empty(),
            "test config isolation failed: refusing to run a real fleet update"
        );

        let ctx = create_test_output_context(true);
        let info = create_test_update_check(true);

        // Should succeed (workers will be empty but that returns Ok)
        let result = update_fleet(&ctx, &info, false).await;
        assert!(result.is_ok());

        // Clean up
        set_test_config_dir_override(None);
        let _ = std::fs::remove_dir_all(&temp_dir);
    }
}