diff --git a/app/src-tauri/src/commands/auth_token_commands.rs b/app/src-tauri/src/commands/auth_token_commands.rs index c56545a..69b01d1 100644 --- a/app/src-tauri/src/commands/auth_token_commands.rs +++ b/app/src-tauri/src/commands/auth_token_commands.rs @@ -76,8 +76,7 @@ use tauri::{AppHandle, Emitter, State}; use tokio::io::AsyncWriteExt; use tokio::sync::{mpsc, oneshot, Mutex}; -use crate::docker::container::is_container_running; -use crate::docker::exec::{create_attached_exec, wait_for_exec_exit, AttachedExec}; +use crate::host::{AttachedExec, ContainerTarget}; use crate::storage::secure; use crate::AppState; @@ -867,7 +866,7 @@ async fn type_code_then_enter( async fn run_setup_token( app: &AppHandle, project_id: &str, - container_id: &str, + target: &ContainerTarget, mut input_rx: mpsc::UnboundedReceiver>, mut cancel_rx: oneshot::Receiver<()>, ) -> Result { @@ -877,16 +876,16 @@ async fn run_setup_token( exec_id, mut output, mut input, - } = create_attached_exec( - container_id, - vec![ - "sh".to_string(), - "-c".to_string(), - SETUP_TOKEN_SCRIPT.to_string(), - ], - true, - ) - .await?; + } = target + .attach_exec( + vec![ + "sh".to_string(), + "-c".to_string(), + SETUP_TOKEN_SCRIPT.to_string(), + ], + true, + ) + .await?; let mut stripper = AnsiStripper::default(); let mut redactor = SecretRedactor::default(); @@ -1041,7 +1040,7 @@ async fn run_setup_token( // Falling through to the token parse is right either way — a run that // printed a token succeeded whatever Docker says about it — but it is worth // saying so rather than silently calling it a clean exit. - match wait_for_exec_exit(&exec_id).await { + match target.wait_exec_exit(&exec_id).await { Some(0) => {} Some(code) => { return Err(format!( @@ -1091,13 +1090,13 @@ pub async fn acquire_claude_token( .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; - let container_id = project.container_id.clone().ok_or_else(|| { + let target = ContainerTarget::for_project(&project).ok_or_else(|| { format!( "Project '{}' has no container yet. Start it, then run authentication again.", project.name ) })?; - if !is_container_running(&container_id).await.unwrap_or(false) { + if !target.is_running().await.unwrap_or(false) { return Err(format!( "The container for '{}' is not running. Start it, then run authentication again.", project.name @@ -1133,8 +1132,7 @@ pub async fn acquire_claude_token( "Running `claude setup-token` — sign in at the URL below, then submit the code it gives you.", ); - let result = - run_setup_token(&app_handle, &project_id, &container_id, input_rx, cancel_rx).await; + let result = run_setup_token(&app_handle, &project_id, &target, input_rx, cancel_rx).await; // Release the flow. Nothing else needs unwinding: this command changes no // persisted state until the token itself is stored, which is the point. diff --git a/app/src-tauri/src/commands/marketplace_commands.rs b/app/src-tauri/src/commands/marketplace_commands.rs index 0f96436..c346224 100644 --- a/app/src-tauri/src/commands/marketplace_commands.rs +++ b/app/src-tauri/src/commands/marketplace_commands.rs @@ -7,7 +7,7 @@ use std::collections::{BTreeMap, HashSet}; use tauri::{AppHandle, Emitter, State}; use tokio::sync::oneshot; -use crate::docker::container::is_container_running; +use crate::host::ContainerTarget; use crate::marketplace::{self as mk, auth, diff, gh_login, git, MarketplaceManager}; use crate::models::marketplace::{ is_valid_commit, is_valid_item_key, AccountMethod, FileDiff, InstallScope, ItemUpdate, @@ -944,8 +944,8 @@ pub async fn apply_marketplace_now( }; let mut results = Vec::new(); for p in projects { - let running = match &p.container_id { - Some(cid) => is_container_running(cid).await.unwrap_or(false), + let running = match ContainerTarget::for_project(&p) { + Some(target) => target.is_running().await.unwrap_or(false), None => false, }; if !running { @@ -1051,13 +1051,13 @@ pub async fn start_marketplace_gh_container_login( return Err("Signing in through a container needs a host name without a port.".to_string()); } let project = find_project(&state, &project_id)?; - let container_id = project.container_id.clone().ok_or_else(|| { + let target = ContainerTarget::for_project(&project).ok_or_else(|| { format!( "\"{}\" has no container yet. Start it, then try again.", project.name ) })?; - if !is_container_running(&container_id).await.unwrap_or(false) { + if !target.is_running().await.unwrap_or(false) { return Err(format!( "\"{}\" is not running. Start it, then try again.", project.name @@ -1070,7 +1070,7 @@ pub async fn start_marketplace_gh_container_login( } let account_id = uuid::Uuid::new_v4().to_string(); let result = - gh_login::run_gh_container_login(&app_handle, &account_id, &container_id, &host, rx).await; + gh_login::run_gh_container_login(&app_handle, &account_id, &target, &host, rx).await; // `rx` is gone now, so this frees our slot and never a newer login's. state.marketplace.release_gh_login().await; let token = result?; diff --git a/app/src-tauri/src/file_viewer/poll.rs b/app/src-tauri/src/file_viewer/poll.rs index d7301cc..28dd359 100644 --- a/app/src-tauri/src/file_viewer/poll.rs +++ b/app/src-tauri/src/file_viewer/poll.rs @@ -7,7 +7,7 @@ use serde::Serialize; -use crate::docker::exec::exec_oneshot_streams_as; +use crate::host::ContainerTarget; #[derive(Clone, Debug, Serialize, PartialEq, Eq)] pub struct ViewerPoll { @@ -48,8 +48,9 @@ pub async fn poll_file(container_id: &str, container_path: &str) -> Result Result, String> { let mut cmd: Vec = vec!["sh".into(), "-c".into(), PROBE_SCRIPT.into(), "probe".into()]; cmd.extend(candidates.iter().cloned()); - let (stdout, _stderr, _code) = - exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?; + let (stdout, _stderr, _code) = ContainerTarget::docker(container_id) + .exec_oneshot_streams_as("claude", cmd, Vec::new()) + .await?; Ok(parse_probe_output(&stdout)) } diff --git a/app/src-tauri/src/marketplace/gh_login.rs b/app/src-tauri/src/marketplace/gh_login.rs index 3382c29..760e85a 100644 --- a/app/src-tauri/src/marketplace/gh_login.rs +++ b/app/src-tauri/src/marketplace/gh_login.rs @@ -5,16 +5,13 @@ use std::time::Duration; -use crate::host::ExecChunk; use futures_util::{Stream, StreamExt}; use tauri::{AppHandle, Emitter}; use tokio::io::{AsyncWrite, AsyncWriteExt}; use tokio::sync::oneshot; use crate::commands::auth_token_commands::{push_capped_tail, AnsiStripper, SUBMIT_ENTER_DELAY}; -use crate::docker::exec::{ - create_attached_exec_as, exec_oneshot_as, wait_for_exec_exit, AttachedExec, -}; +use crate::host::{AttachedExec, ContainerTarget, ExecChunk, ExecSpec}; pub const CODE_EVENT: &str = "marketplace-gh-login-code"; pub const OUTPUT_EVENT: &str = "marketplace-gh-login-output"; @@ -32,8 +29,8 @@ const MAX_PENDING_LINE: usize = 4096; /// script around it (whose text contains the same words); errors are ignored. const CANCEL_PKILL: [&str; 3] = ["pkill", "-f", "gh auth login --hostname"]; -/// Constant script; the host is `$1` (argv, never interpolated), because -/// `create_attached_exec_as` takes no env. +/// Constant script; the host is `$1` (argv, never interpolated); the attached +/// exec is opened with no extra env. /// /// * `GH_CONFIG_DIR` / `GIT_CONFIG_GLOBAL` live in a temp dir removed on exit, /// so the container is never left logged in. The `HUP INT TERM` trap turns a @@ -150,9 +147,9 @@ fn failure_tail(transcript: &str) -> String { /// Pre-flight N9 / review fix 1: stop the in-container login after any /// failed attempt. -async fn kill_container_login(container_id: &str) { +async fn kill_container_login(target: &ContainerTarget) { let cmd = CANCEL_PKILL.iter().map(|s| s.to_string()).collect(); - let _ = exec_oneshot_as(container_id, "claude", cmd, vec![]).await; + let _ = target.exec_oneshot_as("claude", cmd, vec![]).await; } /// Hand `result` back, running `cleanup` first when it is a failure. @@ -262,7 +259,7 @@ where pub async fn run_gh_container_login( app: &AppHandle, account_id: &str, - container_id: &str, + target: &ContainerTarget, host: &str, mut cancel: oneshot::Receiver<()>, ) -> Result { @@ -273,20 +270,20 @@ pub async fn run_gh_container_login( exec_id, output, input, - } = create_attached_exec_as( - container_id, - vec![ - "sh".to_string(), - "-c".to_string(), - GH_LOGIN_SCRIPT.to_string(), - "triple-c-gh-login".to_string(), - host.to_string(), - ], - true, - "claude", - "/home/claude", - ) - .await?; + } = target + .attach( + ExecSpec::new(vec![ + "sh".to_string(), + "-c".to_string(), + GH_LOGIN_SCRIPT.to_string(), + "triple-c-gh-login".to_string(), + host.to_string(), + ]) + .tty(true) + .user("claude") + .working_dir(Some("/home/claude")), + ) + .await?; let deadline = tokio::time::Instant::now() + LOGIN_TIMEOUT; let result = async { @@ -305,7 +302,7 @@ pub async fn run_gh_container_login( if let Some(token) = extract_token(&transcript) { return Ok(token); } - let status = wait_for_exec_exit(&exec_id).await; + let status = target.wait_exec_exit(&exec_id).await; Err(format!( "gh did not complete the sign-in (exit status {}). Nothing was stored.\n{}", status @@ -315,7 +312,7 @@ pub async fn run_gh_container_login( )) } .await; - cleanup_on_error(result, || kill_container_login(container_id)).await + cleanup_on_error(result, || kill_container_login(target)).await } #[cfg(test)] diff --git a/app/src-tauri/src/marketplace/sync.rs b/app/src-tauri/src/marketplace/sync.rs index 7ce9607..7c7352b 100644 --- a/app/src-tauri/src/marketplace/sync.rs +++ b/app/src-tauri/src/marketplace/sync.rs @@ -4,7 +4,7 @@ use std::time::Duration; use super::payload::Payload; -use crate::docker::exec::{exec_oneshot_as, exec_oneshot_streams_as, upload_bytes_to_container}; +use crate::host::ContainerTarget; use crate::models::marketplace::{SkippedItem, SyncReport}; /// Where the payload and the script are uploaded. Owned by `claude`. @@ -57,10 +57,12 @@ fn run_script_env() -> Vec { vec!["HOME=/home/claude".to_string()] } -async fn wait_until_ready(container_id: &str) -> Result<(), String> { +async fn wait_until_ready(target: &ContainerTarget) -> Result<(), String> { let deadline = tokio::time::Instant::now() + READY_TIMEOUT; loop { - let (_, code) = exec_oneshot_as(container_id, "root", ready_probe_cmd(), vec![]).await?; + let (_, code) = target + .exec_oneshot_as("root", ready_probe_cmd(), vec![]) + .await?; if code == 0 { return Ok(()); } @@ -91,34 +93,28 @@ fn tail(text: &str, max: usize) -> &str { /// Wait for readiness, upload the payload and the script, run the script as /// `claude`, and return its report. pub async fn sync_container(container_id: &str, payload: &Payload) -> Result { - wait_until_ready(container_id).await?; + let target = ContainerTarget::docker(container_id); + wait_until_ready(&target).await?; - let (out, code) = exec_oneshot_as(container_id, "root", sh(PREPARE_SCRIPT), vec![]).await?; + let (out, code) = target + .exec_oneshot_as("root", sh(PREPARE_SCRIPT), vec![]) + .await?; if code != 0 { return Err(format!( "Could not prepare the container for the marketplace sync: {}", tail(&out, 500) )); } - upload_bytes_to_container( - container_id, - INCOMING_DIR, - "payload.tar", - &payload.tar, - 0o644, - ) - .await?; - upload_bytes_to_container( - container_id, - INCOMING_DIR, - "sync.sh", - SYNC_SCRIPT.as_bytes(), - 0o755, - ) - .await?; + target + .upload_bytes(INCOMING_DIR, "payload.tar", &payload.tar, 0o644) + .await?; + target + .upload_bytes(INCOMING_DIR, "sync.sh", SYNC_SCRIPT.as_bytes(), 0o755) + .await?; - let (stdout, stderr, code) = - exec_oneshot_streams_as(container_id, "claude", run_script_cmd(), run_script_env()).await?; + let (stdout, stderr, code) = target + .exec_oneshot_streams_as("claude", run_script_cmd(), run_script_env()) + .await?; parse_report(&stdout).map_err(|e| { format!( "The marketplace sync script failed (exit {code}): {e}. {}",