arcbox-cli 0.4.9

Command-line interface for ArcBox
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
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
//! Docker CLI integration commands.
//!
//! Manages the integration between Docker CLI and ArcBox by controlling
//! Docker contexts and installing bundled Docker CLI tools.

use std::path::Path;
use std::sync::Arc;

use anyhow::{Context, Result};
use arcbox_docker::DockerContextManager;
use arcbox_docker_tools::{HostToolManager, ToolGroup, parse_tools_for_group};
use clap::Subcommand;
use serde::Serialize;

use super::OutputFormat;

/// Embedded `assets.lock` (same copy used by boot_assets).
const LOCK_TOML: &str = include_str!("../../../../assets.lock");

/// Docker integration commands.
#[derive(Subcommand)]
pub enum DockerCommands {
    /// Enable Docker CLI integration
    ///
    /// Creates an 'arcbox' Docker context and sets it as the default.
    /// After enabling, all `docker` commands will use ArcBox.
    Enable,

    /// Disable Docker CLI integration
    ///
    /// Restores the previous default Docker context.
    /// The 'arcbox' context is kept but no longer default.
    Disable,

    /// Show Docker integration status
    Status,

    /// Download and install Docker CLI tools (docker, buildx, compose)
    ///
    /// Downloads Docker CLI binaries to ~/.arcbox/runtime/bin/ and creates
    /// symlinks in ~/.arcbox/bin/. Also generates shell completions for the
    /// Docker CLI.
    Setup,
}

/// Executes a docker subcommand.
pub async fn execute(cmd: DockerCommands, format: OutputFormat) -> Result<()> {
    match cmd {
        DockerCommands::Enable => {
            let manager = context_manager()?;
            execute_enable(&manager)
        }
        DockerCommands::Disable => {
            let manager = context_manager()?;
            execute_disable(&manager)
        }
        DockerCommands::Status => {
            let manager = context_manager()?;
            execute_status(&manager);
            Ok(())
        }
        DockerCommands::Setup => execute_setup(format).await,
    }
}

pub(super) fn context_manager() -> Result<DockerContextManager> {
    let socket = arcbox_constants::paths::HostLayout::resolve(None).docker_socket;
    DockerContextManager::new(socket).context("Failed to initialize Docker context manager")
}

// =============================================================================
// Setup — NDJSON progress
// =============================================================================

/// NDJSON progress line for `arcbox docker setup --format json`.
#[derive(Serialize, Default)]
struct SetupProgress {
    phase: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    current: Option<usize>,
    #[serde(skip_serializing_if = "Option::is_none")]
    total: Option<usize>,
    #[serde(skip_serializing_if = "Option::is_none")]
    downloaded_bytes: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    total_bytes: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    percent: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    error: Option<String>,
}

/// Emit a single NDJSON progress line to stdout.
fn emit_ndjson(p: SetupProgress) {
    if let Ok(json) = serde_json::to_string(&p) {
        println!("{json}");
    }
}

// =============================================================================
// Setup — dispatch
// =============================================================================

/// Downloads and installs Docker CLI tools.
async fn execute_setup(format: OutputFormat) -> Result<()> {
    let home = dirs::home_dir().context("could not determine home directory")?;
    let runtime_bin = home.join(".arcbox/runtime/bin");
    let user_bin = home.join(".arcbox/bin");

    // Parse tool entries from lockfile.
    let tools = parse_tools_for_group(LOCK_TOML, ToolGroup::Docker)
        .context("failed to parse assets.lock")?;
    if tools.is_empty() {
        if matches!(format, OutputFormat::Table | OutputFormat::Quiet) {
            println!("No Docker tools configured in assets.lock.");
        }
        return Ok(());
    }

    let arch = arcbox_asset::current_arch().to_string();
    let mut manager = HostToolManager::new(tools, &arch, runtime_bin.clone());

    if let Some(xbin) = super::symlink::detect_bundle_xbin() {
        if matches!(format, OutputFormat::Table | OutputFormat::Quiet) {
            println!("Using Docker tools from app bundle: {}", xbin.display());
        }
        manager = manager.with_bundle_dir(xbin);
    }

    match format {
        OutputFormat::Json => execute_setup_json(&manager, &runtime_bin, &user_bin).await,
        OutputFormat::Table | OutputFormat::Quiet => {
            execute_setup_table(&manager, &home, &runtime_bin, &user_bin).await
        }
    }
}

/// Install Docker tools with NDJSON progress output.
async fn execute_setup_json(
    manager: &HostToolManager,
    runtime_bin: &Path,
    user_bin: &Path,
) -> Result<()> {
    let progress_cb: arcbox_asset::ProgressCallback =
        Box::new(|p: arcbox_asset::PrepareProgress| {
            let (phase, downloaded_bytes, total_bytes, percent) = match &p.phase {
                arcbox_asset::PreparePhase::Checking => ("checking", None, None, None),
                arcbox_asset::PreparePhase::Downloading { downloaded, total } => {
                    let pct = total.map(|t| (downloaded * 100).checked_div(t).unwrap_or(0));
                    ("downloading", Some(*downloaded), *total, pct)
                }
                arcbox_asset::PreparePhase::Verifying => ("verifying", None, None, None),
                arcbox_asset::PreparePhase::Ready => ("ready", None, None, None),
                arcbox_asset::PreparePhase::Cached => ("cached", None, None, None),
            };

            emit_ndjson(SetupProgress {
                phase: phase.to_string(),
                name: Some(p.name.clone()),
                current: Some(p.current),
                total: Some(p.total),
                downloaded_bytes,
                total_bytes,
                percent,
                ..Default::default()
            });
        });

    let progress = Arc::new(progress_cb);
    if let Err(e) = manager.install_all(Some(&progress)).await {
        emit_ndjson(SetupProgress {
            phase: "error".to_string(),
            error: Some(e.to_string()),
            ..Default::default()
        });
        return Err(e.into());
    }

    // Create symlinks.
    tokio::fs::create_dir_all(user_bin).await?;
    for tool in manager.tools() {
        let target = runtime_bin.join(&tool.name);
        let link = user_bin.join(&tool.name);
        create_or_update_symlink(&target, &link).await?;
    }

    emit_ndjson(SetupProgress {
        phase: "complete".to_string(),
        ..Default::default()
    });

    Ok(())
}

/// Install Docker tools with human-readable table output.
async fn execute_setup_table(
    manager: &HostToolManager,
    home: &Path,
    runtime_bin: &Path,
    user_bin: &Path,
) -> Result<()> {
    println!("Installing Docker CLI tools...");
    println!();

    let progress_cb: arcbox_asset::ProgressCallback =
        Box::new(|p: arcbox_asset::PrepareProgress| match &p.phase {
            arcbox_asset::PreparePhase::Checking => {
                eprint!("  [{}/{}] {} checking...", p.current, p.total, p.name);
            }
            arcbox_asset::PreparePhase::Downloading { downloaded, total } => {
                let pct = total.map_or(0, |t| (downloaded * 100).checked_div(t).unwrap_or(0));
                eprint!(
                    "\r  [{}/{}] {} downloading... {}%",
                    p.current, p.total, p.name, pct
                );
            }
            arcbox_asset::PreparePhase::Verifying => {
                eprint!(
                    "\r  [{}/{}] {} verifying...       ",
                    p.current, p.total, p.name
                );
            }
            arcbox_asset::PreparePhase::Ready => {
                eprintln!(
                    "\r  [{}/{}] {} installed          ",
                    p.current, p.total, p.name
                );
            }
            arcbox_asset::PreparePhase::Cached => {
                eprintln!(
                    "\r  [{}/{}] {} up to date         ",
                    p.current, p.total, p.name
                );
            }
        });

    let progress = Arc::new(progress_cb);
    manager
        .install_all(Some(&progress))
        .await
        .context("failed to install Docker tools")?;

    // Create symlinks in ~/.arcbox/bin/.
    tokio::fs::create_dir_all(user_bin).await?;
    for tool in manager.tools() {
        let target = runtime_bin.join(&tool.name);
        let link = user_bin.join(&tool.name);
        create_or_update_symlink(&target, &link).await?;
    }

    println!();
    println!("Docker tools installed to {}", runtime_bin.display());
    println!("Symlinks created in {}", user_bin.display());

    // Generate Docker shell completions.
    generate_docker_completions(home, runtime_bin).await?;

    println!();
    println!("Restart your shell or re-source your profile to use Docker completions.");

    Ok(())
}

// =============================================================================
// Completions
// =============================================================================

/// Generate Docker CLI completions by running the installed docker binary.
async fn generate_docker_completions(home: &Path, runtime_bin: &Path) -> Result<()> {
    let comp_dir = home.join(".arcbox/completions");

    let docker_bin = runtime_bin.join("docker");
    if !docker_bin.exists() {
        return Ok(());
    }

    println!("Generating Docker shell completions...");

    let shells = [
        ("zsh", comp_dir.join("zsh/_docker")),
        ("bash", comp_dir.join("bash/docker")),
        ("fish", comp_dir.join("fish/docker.fish")),
    ];

    for (shell, dest) in &shells {
        if let Some(parent) = dest.parent() {
            tokio::fs::create_dir_all(parent).await?;
        }

        let output = tokio::process::Command::new(&docker_bin)
            .arg("completion")
            .arg(shell)
            .output()
            .await;

        match output {
            Ok(out) if out.status.success() => {
                tokio::fs::write(dest, &out.stdout).await?;
            }
            Ok(out) => {
                let stderr = String::from_utf8_lossy(&out.stderr);
                eprintln!("  Warning: docker completion {shell} failed: {stderr}");
            }
            Err(e) => {
                eprintln!("  Warning: could not run docker completion: {e}");
            }
        }
    }

    // Also try docker compose completion.
    let compose_bin = runtime_bin.join("docker-compose");
    if compose_bin.exists() {
        let compose_shells = [
            ("zsh", comp_dir.join("zsh/_docker-compose")),
            ("bash", comp_dir.join("bash/docker-compose")),
            ("fish", comp_dir.join("fish/docker-compose.fish")),
        ];

        for (shell, dest) in &compose_shells {
            if let Some(parent) = dest.parent() {
                tokio::fs::create_dir_all(parent).await?;
            }

            let output = tokio::process::Command::new(&compose_bin)
                .arg("completion")
                .arg(shell)
                .output()
                .await;

            if let Ok(out) = output {
                if out.status.success() {
                    tokio::fs::write(dest, &out.stdout).await?;
                }
            }
        }
    }

    println!("  Completions saved to {}", comp_dir.display());
    Ok(())
}

// =============================================================================
// Helpers
// =============================================================================

/// Create or update a symlink, removing any stale one first.
async fn create_or_update_symlink(target: &Path, link: &Path) -> Result<()> {
    if tokio::fs::symlink_metadata(link).await.is_ok() {
        tokio::fs::remove_file(link).await.ok();
    }

    #[cfg(unix)]
    tokio::fs::symlink(target, link).await.with_context(|| {
        format!(
            "failed to create symlink {} -> {}",
            link.display(),
            target.display()
        )
    })?;

    Ok(())
}

// =============================================================================
// Docker context management
// =============================================================================

/// Enables Docker CLI integration.
fn execute_enable(manager: &DockerContextManager) -> Result<()> {
    // Check if already enabled.
    if manager.context_exists() && manager.is_default()? {
        println!("Docker integration is already enabled.");
        return Ok(());
    }

    manager
        .enable()
        .context("Failed to enable Docker integration")?;

    println!("Docker integration enabled.");
    println!();
    println!("You can now use the docker CLI with ArcBox:");
    println!("  docker ps");
    println!("  docker run alpine echo hello");
    println!();
    println!("To disable, run: arcbox docker disable");

    // Warn if socket doesn't exist.
    if !manager.socket_path().exists() {
        println!();
        println!(
            "Warning: ArcBox Docker socket not found at {}",
            manager.socket_path().display()
        );
        println!("Make sure the ArcBox daemon is running.");
    }

    Ok(())
}

/// Disables Docker CLI integration.
fn execute_disable(manager: &DockerContextManager) -> Result<()> {
    if !manager.is_default()? {
        println!("Docker integration is not currently enabled.");
        return Ok(());
    }

    manager
        .disable()
        .context("Failed to disable Docker integration")?;

    println!("Docker integration disabled.");
    println!("The previous default Docker context has been restored.");

    Ok(())
}

/// Shows Docker integration status.
fn execute_status(manager: &DockerContextManager) {
    let status = manager.status();

    println!("Docker Integration Status");
    println!("=========================");
    println!();
    println!(
        "Context exists:  {}",
        if status.context_exists { "yes" } else { "no" }
    );
    println!(
        "Is default:      {}",
        if status.is_default { "yes" } else { "no" }
    );
    println!("Socket path:     {}", status.socket_path.display());
    println!(
        "Socket exists:   {}",
        if status.socket_exists { "yes" } else { "no" }
    );

    println!();
    if status.is_default && status.socket_exists {
        println!("Status: Ready - docker commands will use ArcBox");
    } else if status.is_default && !status.socket_exists {
        println!("Status: Enabled but daemon not running");
        println!("        Start the ArcBox daemon to use docker commands");
    } else if status.context_exists {
        println!("Status: Context exists but not default");
        println!("        Run 'arcbox docker enable' to activate");
    } else {
        println!("Status: Not configured");
        println!("        Run 'arcbox docker enable' to set up");
    }
}