@smbcloud / cli / commits / bb8cea6

Add ControlKit commands

Seto Elkahfi committed Jul 30, 2026 at 17:38 UTC bb8cea64ae39f082423fbd43108d4d85ab198688
5 files changed +387 -2
crates/cli/src/cli/mod.rs
+49
@@ -3,6 +3,7 @@ use {
3 clap::{Parser, Subcommand},
4 smbcloud_network::environment::Environment,
5 spinners::Spinner,
6 + std::path::PathBuf,
7 };
8
9 pub struct CommandResult {
@@ -96,9 +97,57 @@ pub enum Commands {
97 #[clap(subcommand)]
98 command: cloud_auth::cli::Commands,
99 },
100 + #[clap(about = "Run and control XCRS ControlKit on Apple devices.")]
101 + ControlKit {
102 + #[clap(subcommand)]
103 + command: ControlKitCommands,
104 + },
105 #[clap(
106 about = "Migrate local .smb/config.toml deploy fields to the smbCloud server.",
107 display_order = 4
108 )]
109 Migrate {},
110 }
111 +
112 +#[derive(Subcommand)]
113 +pub enum ControlKitCommands {
114 + #[clap(about = "Build a signed ControlKit XCTest runner for a physical device.")]
115 + Build {
116 + #[arg(long)]
117 + project_path: PathBuf,
118 + #[arg(long, default_value = "ControlKit")]
119 + scheme: String,
120 + #[arg(long, default_value = "Release")]
121 + configuration: String,
122 + #[arg(long)]
123 + device_udid: String,
124 + #[arg(long)]
125 + derived_data_path: PathBuf,
126 + },
127 + #[clap(about = "Start a built ControlKit XCTest runner on a physical device.")]
128 + Start {
129 + #[arg(long)]
130 + device_udid: String,
131 + #[arg(long)]
132 + xctestrun_path: PathBuf,
133 + #[arg(long, default_value = "::")]
134 + listen_host: String,
135 + #[arg(long, default_value_t = 12004)]
136 + listen_port: u16,
137 + #[arg(long, default_value_t = 120)]
138 + timeout_seconds: u64,
139 + #[arg(long)]
140 + log_path: Option<PathBuf>,
141 + },
142 + #[clap(about = "Call a running ControlKit JSON-RPC method.")]
143 + Call {
144 + #[arg(long)]
145 + device_udid: String,
146 + #[arg(long, default_value_t = 12004)]
147 + port: u16,
148 + #[arg(long)]
149 + method: String,
150 + #[arg(long, default_value = "{}")]
151 + params: String,
152 + },
153 +}
crates/cli/src/controlkit.rs new
+119
@@ -0,0 +1,119 @@
1 +use {
2 + crate::{
3 + cli::{CommandResult, ControlKitCommands},
4 + ui::{succeed_message, succeed_symbol},
5 + },
6 + anyhow::{anyhow, Context, Result},
7 + spinners::{Spinner, Spinners},
8 + std::{path::PathBuf, time::Duration},
9 + xcrs::{
10 + BuildControlKitDeviceRunner, ControlKit, StartControlKitDeviceRunner, XcodeCommandLineTools,
11 + },
12 +};
13 +
14 +pub async fn process_controlkit(command: ControlKitCommands) -> Result<CommandResult> {
15 + match command {
16 + ControlKitCommands::Build {
17 + project_path,
18 + scheme,
19 + configuration,
20 + device_udid,
21 + derived_data_path,
22 + } => {
23 + let test_run_path = XcodeCommandLineTools::new().build_controlkit_device_runner(
24 + &BuildControlKitDeviceRunner {
25 + project_path,
26 + scheme,
27 + configuration,
28 + device_udid,
29 + derived_data_path,
30 + },
31 + )?;
32 + Ok(done_result(&format!(
33 + "Built ControlKit runner: {}",
34 + test_run_path.display()
35 + )))
36 + }
37 + ControlKitCommands::Start {
38 + device_udid,
39 + xctestrun_path,
40 + listen_host,
41 + listen_port,
42 + timeout_seconds,
43 + log_path,
44 + } => {
45 + let tools = XcodeCommandLineTools::new();
46 + let log_path = log_path.unwrap_or_else(|| {
47 + std::env::temp_dir().join(format!("controlkit-{device_udid}.log"))
48 + });
49 + let mut runner =
50 + tools.start_controlkit_device_runner(&StartControlKitDeviceRunner {
51 + device_udid,
52 + xctestrun_path,
53 + listen_host,
54 + listen_port,
55 + log_path: log_path.clone(),
56 + })?;
57 + let controlkit = ControlKit::with_host(&runner.tunnel_host, runner.listen_port);
58 + let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_seconds);
59 +
60 + loop {
61 + if controlkit.health().await.is_ok() {
62 + break;
63 + }
64 + if let Some(status) = runner.process.try_wait()? {
65 + return Err(runner_failure(status.to_string(), &runner.log_path));
66 + }
67 + if tokio::time::Instant::now() >= deadline {
68 + return Err(runner_failure(
69 + format!("did not become healthy within {timeout_seconds} seconds"),
70 + &runner.log_path,
71 + ));
72 + }
73 + tokio::time::sleep(Duration::from_secs(1)).await;
74 + }
75 +
76 + Ok(done_result(&format!(
77 + "ControlKit is running at http://[{}]:{} (PID {}, log {})",
78 + runner.tunnel_host,
79 + runner.listen_port,
80 + runner.process.id(),
81 + runner.log_path.display()
82 + )))
83 + }
84 + ControlKitCommands::Call {
85 + device_udid,
86 + port,
87 + method,
88 + params,
89 + } => {
90 + let tools = XcodeCommandLineTools::new();
91 + let tunnel_host = tools.device_tunnel_address(&device_udid)?;
92 + let params = serde_json::from_str(&params)
93 + .with_context(|| format!("invalid JSON for --params: {params}"))?;
94 + let result = ControlKit::with_host(&tunnel_host, port)
95 + .call(&method, params)
96 + .await?;
97 + Ok(done_result(&serde_json::to_string_pretty(&result)?))
98 + }
99 + }
100 +}
101 +
102 +fn runner_failure(reason: String, log_path: &PathBuf) -> anyhow::Error {
103 + let log = std::fs::read_to_string(log_path).unwrap_or_default();
104 + let tail = log.lines().rev().take(20).collect::<Vec<_>>();
105 + let tail = tail.into_iter().rev().collect::<Vec<_>>().join("\n");
106 + if tail.is_empty() {
107 + anyhow!("ControlKit runner {reason}; see {}", log_path.display())
108 + } else {
109 + anyhow!("ControlKit runner {reason}:\n{tail}")
110 + }
111 +}
112 +
113 +fn done_result(message: &str) -> CommandResult {
114 + CommandResult {
115 + spinner: Spinner::new(Spinners::SimpleDotsScrolling, succeed_message("Done")),
116 + symbol: succeed_symbol(),
117 + msg: succeed_message(message),
118 + }
119 +}
crates/cli/src/lib.rs
+1
@@ -5,6 +5,7 @@ pub mod ci;
5 pub mod cli;
6 #[path = "cloud-auth/mod.rs"]
7 pub mod cloud_auth;
8 +pub mod controlkit;
9 #[path = "cloud-deploy/mod.rs"]
10 pub mod deploy;
11 pub mod interface;
crates/cli/src/main.rs
+3
@@ -7,6 +7,7 @@ use {
7 clear_smb_token,
8 cli::{Cli, CommandResult, Commands},
9 cloud_auth::process::process_cloud_auth,
10 + controlkit::process_controlkit,
11 deploy::{process_deploy::process_deploy, process_migrate::process_migrate},
12 mail::process::process_mail,
13 project::{crud_create::process_project_init, process::process_project},
@@ -184,6 +185,7 @@ async fn run(cli: Cli) -> Result<CommandResult> {
185 | Some(Commands::Tenant { .. })
186 | Some(Commands::Migrate {})
187 | None => true,
188 + Some(Commands::ControlKit { .. }) => false,
189 Some(Commands::Init {}) => true,
190 };
191
@@ -205,6 +207,7 @@ async fn run(cli: Cli) -> Result<CommandResult> {
207 Some(Commands::Auth { command }) => process_cloud_auth(cli.environment, command).await,
208 Some(Commands::Project { command }) => process_project(cli.environment, command).await,
209 Some(Commands::Tenant { command }) => process_tenant(cli.environment, command).await,
210 + Some(Commands::ControlKit { command }) => process_controlkit(command).await,
211 Some(Commands::Migrate {}) => process_migrate(cli.environment).await,
212 None => process_deploy(cli.environment, None).await,
213 }
crates/xcrs/src/xcrs.rs
+215 -2
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
3 use std::collections::BTreeMap;
4 use std::ffi::OsStr;
5 use std::path::{Path, PathBuf};
6 -use std::process::{Command, Output};
6 +use std::process::{Child, Command, Output, Stdio};
7 use std::time::Duration;
8
9 pub mod mcp;
@@ -90,12 +90,40 @@ impl Default for ControlKit {
90
91 impl ControlKit {
92 pub fn new(port: u16) -> Self {
93 + Self::with_host("127.0.0.1", port)
94 + }
95 +
96 + pub fn with_host(host: &str, port: u16) -> Self {
97 + let host = if host.contains(':') && !host.starts_with('[') {
98 + format!("[{host}]")
99 + } else {
100 + host.to_string()
101 + };
102 Self {
103 client: reqwest::Client::new(),
95 - base_url: format!("http://127.0.0.1:{port}"),
104 + base_url: format!("http://{host}:{port}"),
105 }
106 }
107
108 + pub async fn health(&self) -> Result<()> {
109 + let response = self
110 + .client
111 + .get(format!("{}/health", self.base_url))
112 + .timeout(Duration::from_secs(5))
113 + .send()
114 + .await
115 + .with_context(|| format!("failed to connect to ControlKit at {}", self.base_url))?;
116 +
117 + if !response.status().is_success() {
118 + return Err(anyhow!(
119 + "ControlKit health check returned HTTP {}",
120 + response.status()
121 + ));
122 + }
123 +
124 + Ok(())
125 + }
126 +
127 pub async fn call(&self, method: &str, params: serde_json::Value) -> Result<serde_json::Value> {
128 let response = self
129 .client
@@ -455,6 +483,176 @@ impl XcodeCommandLineTools {
483 app_container_path,
484 })
485 }
486 +
487 + pub fn device_tunnel_address(&self, device_udid: &str) -> Result<String> {
488 + let output = self.run_xcrun([
489 + "devicectl",
490 + "device",
491 + "info",
492 + "details",
493 + "--device",
494 + device_udid,
495 + ])?;
496 +
497 + parse_device_tunnel_address(&output)
498 + .ok_or_else(|| anyhow!("device {device_udid} did not report a tunnel IP address"))
499 + }
500 +
501 + pub fn build_controlkit_device_runner(
502 + &self,
503 + request: &BuildControlKitDeviceRunner,
504 + ) -> Result<PathBuf> {
505 + ensure_path_exists(&request.project_path)?;
506 + let args = [
507 + "build-for-testing".to_string(),
508 + "-project".to_string(),
509 + path_to_string(&request.project_path)?,
510 + "-scheme".to_string(),
511 + request.scheme.clone(),
512 + "-configuration".to_string(),
513 + request.configuration.clone(),
514 + "-destination".to_string(),
515 + format!("id={}", request.device_udid),
516 + "-derivedDataPath".to_string(),
517 + path_to_string(&request.derived_data_path)?,
518 + ];
519 + self.run_xcodebuild(args)?;
520 +
521 + let products_path = request.derived_data_path.join("Build/Products");
522 + let entries = std::fs::read_dir(&products_path)
523 + .with_context(|| format!("failed to read {}", products_path.display()))?;
524 + let mut test_run_paths = entries
525 + .filter_map(|entry| entry.ok().map(|entry| entry.path()))
526 + .filter(|path| {
527 + path.extension()
528 + .is_some_and(|extension| extension == "xctestrun")
529 + })
530 + .collect::<Vec<_>>();
531 + test_run_paths.sort();
532 +
533 + test_run_paths.into_iter().next().ok_or_else(|| {
534 + anyhow!(
535 + "Xcode did not create an .xctestrun file in {}",
536 + products_path.display()
537 + )
538 + })
539 + }
540 +
541 + pub fn start_controlkit_device_runner(
542 + &self,
543 + request: &StartControlKitDeviceRunner,
544 + ) -> Result<RunningControlKitDeviceRunner> {
545 + ensure_path_exists(&request.xctestrun_path)?;
546 + set_xctestrun_environment(
547 + &request.xctestrun_path,
548 + "CONTROLKIT_LISTEN_HOST",
549 + &request.listen_host,
550 + )?;
551 + set_xctestrun_environment(
552 + &request.xctestrun_path,
553 + "CONTROLKIT_LISTEN_PORT",
554 + &request.listen_port.to_string(),
555 + )?;
556 +
557 + let tunnel_host = self.device_tunnel_address(&request.device_udid)?;
558 + let log_file = std::fs::OpenOptions::new()
559 + .create(true)
560 + .truncate(true)
561 + .write(true)
562 + .open(&request.log_path)
563 + .with_context(|| format!("failed to open {}", request.log_path.display()))?;
564 + let error_log_file = log_file
565 + .try_clone()
566 + .with_context(|| format!("failed to clone {}", request.log_path.display()))?;
567 +
568 + let child = Command::new(&self.xcodebuild_path)
569 + .arg("test-without-building")
570 + .arg("-xctestrun")
571 + .arg(&request.xctestrun_path)
572 + .arg("-destination")
573 + .arg(format!("id={}", request.device_udid))
574 + .stdin(Stdio::null())
575 + .stdout(Stdio::from(log_file))
576 + .stderr(Stdio::from(error_log_file))
577 + .spawn()
578 + .with_context(|| {
579 + format!(
580 + "failed to start ControlKit runner for device {}",
581 + request.device_udid
582 + )
583 + })?;
584 +
585 + Ok(RunningControlKitDeviceRunner {
586 + process: child,
587 + tunnel_host,
588 + listen_port: request.listen_port,
589 + log_path: request.log_path.clone(),
590 + })
591 + }
592 +}
593 +
594 +#[derive(Debug, Clone)]
595 +pub struct BuildControlKitDeviceRunner {
596 + pub project_path: PathBuf,
597 + pub scheme: String,
598 + pub configuration: String,
599 + pub device_udid: String,
600 + pub derived_data_path: PathBuf,
601 +}
602 +
603 +#[derive(Debug, Clone)]
604 +pub struct StartControlKitDeviceRunner {
605 + pub device_udid: String,
606 + pub xctestrun_path: PathBuf,
607 + pub listen_host: String,
608 + pub listen_port: u16,
609 + pub log_path: PathBuf,
610 +}
611 +
612 +#[derive(Debug)]
613 +pub struct RunningControlKitDeviceRunner {
614 + pub process: Child,
615 + pub tunnel_host: String,
616 + pub listen_port: u16,
617 + pub log_path: PathBuf,
618 +}
619 +
620 +fn set_xctestrun_environment(path: &Path, name: &str, value: &str) -> Result<()> {
621 + let key = format!(":TestConfigurations:0:TestTargets:0:EnvironmentVariables:{name}");
622 + let set_status = Command::new("/usr/libexec/PlistBuddy")
623 + .arg("-c")
624 + .arg(format!("Set {key} {value}"))
625 + .arg(path)
626 + .status()
627 + .with_context(|| format!("failed to update {}", path.display()))?;
628 +
629 + if set_status.success() {
630 + return Ok(());
631 + }
632 +
633 + let add_status = Command::new("/usr/libexec/PlistBuddy")
634 + .arg("-c")
635 + .arg(format!("Add {key} string {value}"))
636 + .arg(path)
637 + .status()
638 + .with_context(|| format!("failed to update {}", path.display()))?;
639 +
640 + if add_status.success() {
641 + return Ok(());
642 + }
643 +
644 + Err(anyhow!(
645 + "failed to set {name} in XCTest plan {}",
646 + path.display()
647 + ))
648 +}
649 +
650 +fn parse_device_tunnel_address(output: &str) -> Option<String> {
651 + output
652 + .lines()
653 + .find_map(|line| line.split_once("Tunnel IP Address:"))
654 + .map(|(_, address)| address.trim().to_string())
655 + .filter(|address| !address.is_empty())
656 }
657
658 pub fn parse_simctl_devices(output: &str) -> Result<Vec<Simulator>> {
@@ -567,4 +765,19 @@ mod tests {
765 assert_eq!(devices[0].state, "Booted");
766 assert!(devices[0].is_booted());
767 }
768 +
769 + #[test]
770 + fn formats_ipv6_controlkit_url() {
771 + let controlkit = ControlKit::with_host("fdb4:e020:7377::1", 12006);
772 + assert_eq!(controlkit.base_url, "http://[fdb4:e020:7377::1]:12006");
773 + }
774 +
775 + #[test]
776 + fn parses_device_tunnel_address() {
777 + let output = "• Device Name: iPhone\n• Tunnel IP Address: fd55:33ce:ad87::1\n";
778 + assert_eq!(
779 + parse_device_tunnel_address(output).as_deref(),
780 + Some("fd55:33ce:ad87::1")
781 + );
782 + }
783 }