返回 CodeWhale
mod.rs
根目录 / crates / tui / src / remote_setup / mod.rs
1 //! `codewhale remote-setup` — guided generation of a remote-agent deploy bundle.
2 //!
3 //! Generate-only MVP: the wizard collects a cloud target, a chat bridge, and a
4 //! model provider, then renders a deploy bundle (env files, systemd units,
5 //! RUNBOOK) to `--out`. The `--apply` cloud-CLI auto-provision path is stubbed
6 //! ("not yet implemented") — nothing is ever executed.
7 //!
8 //! Design mirrors the table-driven provider registry in
9 //! `crates/config/src/lib.rs`: the wizard iterates [`registry::CLOUD_TARGETS`],
10 //! [`registry::BRIDGES`], and the existing `codewhale_config::provider` registry
11 //! rather than hard-coding the matrix.
12
13 pub mod bundle;
14 pub mod registry;
15
16 use std::io::{self, Write};
17 use std::path::PathBuf;
18
19 use anyhow::{Result, bail};
20 use clap::Args;
21
22 use bundle::{BundleInputs, DEFAULT_PORT, DEFAULT_WORKERS, ProviderInfo, write_bundle};
23 use registry::{BRIDGES, BridgeSpec, CLOUD_TARGETS, CloudTarget};
24
25 /// Flags for `codewhale remote-setup` (clap), per the RFC command surface.
26 #[derive(Args, Debug, Clone, Default)]
27 pub struct RemoteSetupArgs {
28 /// Cloud target slug (lighthouse, azure, digitalocean). Skips the prompt.
29 #[arg(long)]
30 pub cloud: Option<String>,
31 /// Chat bridge slug (feishu, telegram). Skips the prompt.
32 #[arg(long)]
33 pub bridge: Option<String>,
34 /// Provider slug; validated against the provider registry. Skips the prompt.
35 #[arg(long)]
36 pub provider: Option<String>,
37 /// Bundle output directory (default `./codewhale-deploy/<cloud>-<bridge>`).
38 #[arg(long, value_name = "DIR")]
39 pub out: Option<PathBuf>,
40 /// Emit the bundle, do not provision (default).
41 #[arg(long, default_value_t = false)]
42 pub generate_only: bool,
43 /// Run the cloud CLI to auto-provision (MVP: not yet implemented).
44 #[arg(long, default_value_t = false, conflicts_with = "generate_only")]
45 pub apply: bool,
46 /// Skip the final confirmation gate (CI / non-interactive).
47 #[arg(long, default_value_t = false)]
48 pub yes: bool,
49 /// Fail instead of prompting if any required value is missing.
50 #[arg(long, default_value_t = false)]
51 pub non_interactive: bool,
52 }
53
54 /// Entry point invoked by the TUI command dispatcher.
55 pub fn run_remote_setup(args: RemoteSetupArgs) -> Result<()> {
56 print_header();
57
58 let cloud = resolve_cloud(&args)?;
59 let bridge = resolve_bridge(&args)?;
60 let provider = resolve_provider(&args)?;
61
62 println!();
63 println!("Plan:");
64 println!(" cloud : {} ({})", cloud.display, cloud.slug);
65 println!(" bridge : {} ({})", bridge.display, bridge.slug);
66 println!(
67 " provider : {} ({}) — key var {}",
68 provider.display, provider.slug, provider.key_var
69 );
70 println!(" hint : {}", bridge.setup_hint);
71
72 // Generate the shared runtime token with the codebase's established CSPRNG
73 // pattern (uuid v4, as in acp_server.rs) — never Math.random / time-based.
74 let runtime_token = generate_runtime_token();
75
76 let inputs = BundleInputs {
77 cloud,
78 bridge,
79 provider: provider.clone(),
80 model: "auto".to_string(),
81 runtime_token,
82 provider_key_value: format!("replace-with-{}-key", provider.slug),
83 bridge_secret_values: bridge
84 .secret_keys
85 .iter()
86 .map(|k| {
87 (
88 (*k).to_string(),
89 format!("replace-with-{}", k.to_ascii_lowercase()),
90 )
91 })
92 .collect(),
93 allowlist: String::new(),
94 port: DEFAULT_PORT,
95 workers: DEFAULT_WORKERS,
96 workspace: "/opt/whalebro".to_string(),
97 };
98
99 let out_dir = args.out.clone().unwrap_or_else(|| {
100 PathBuf::from("codewhale-deploy").join(format!("{}-{}", cloud.slug, bridge.slug))
101 });
102
103 // Always render the bundle, even when --apply is requested.
104 let written = write_bundle(&inputs, &out_dir)?;
105 println!();
106 println!("Generated bundle in {}:", out_dir.display());
107 for path in &written {
108 let name = path
109 .file_name()
110 .map(|n| n.to_string_lossy().into_owned())
111 .unwrap_or_default();
112 println!(" - {name}");
113 }
114
115 if args.apply {
116 // MVP: the auto-provision path is intentionally not implemented yet.
117 println!();
118 println!("auto-provision not yet implemented; bundle generated, follow RUNBOOK.md");
119 } else {
120 println!();
121 println!(
122 "Next: open {}/RUNBOOK.md and follow the steps.",
123 out_dir.display()
124 );
125 }
126
127 Ok(())
128 }
129
130 fn print_header() {
131 use crate::palette;
132 use colored::Colorize;
133 let (r, g, b) = palette::WHALE_INFO_RGB;
134 println!("{}", "Codewhale Remote Setup".truecolor(r, g, b).bold());
135 println!("{}", "======================".truecolor(r, g, b));
136 println!("Generate a deploy bundle for a remote Codewhale agent (cloud + chat bridge).");
137 }
138
139 // ---------------------------------------------------------------------------
140 // Resolution: flag -> prompt (unless --non-interactive) -> validated value
141 // ---------------------------------------------------------------------------
142
143 fn resolve_cloud(args: &RemoteSetupArgs) -> Result<&'static CloudTarget> {
144 if let Some(slug) = &args.cloud {
145 return registry::cloud_by_slug(slug)
146 .ok_or_else(|| anyhow::anyhow!("unknown cloud '{slug}'. {}", cloud_choices()));
147 }
148 if args.non_interactive {
149 bail!(
150 "--cloud is required in --non-interactive mode. {}",
151 cloud_choices()
152 );
153 }
154 let idx = prompt_choice(
155 "Cloud target",
156 &CLOUD_TARGETS
157 .iter()
158 .map(|c| format!("{} ({})", c.display, c.slug))
159 .collect::<Vec<_>>(),
160 )?;
161 Ok(&CLOUD_TARGETS[idx])
162 }
163
164 fn resolve_bridge(args: &RemoteSetupArgs) -> Result<&'static BridgeSpec> {
165 if let Some(slug) = &args.bridge {
166 return registry::bridge_by_slug(slug)
167 .ok_or_else(|| anyhow::anyhow!("unknown bridge '{slug}'. {}", bridge_choices()));
168 }
169 if args.non_interactive {
170 bail!(
171 "--bridge is required in --non-interactive mode. {}",
172 bridge_choices()
173 );
174 }
175 let idx = prompt_choice(
176 "Chat bridge",
177 &BRIDGES
178 .iter()
179 .map(|b| format!("{} ({})", b.display, b.slug))
180 .collect::<Vec<_>>(),
181 )?;
182 Ok(&BRIDGES[idx])
183 }
184
185 fn resolve_provider(args: &RemoteSetupArgs) -> Result<ProviderInfo> {
186 if let Some(slug) = &args.provider {
187 return ProviderInfo::from_slug(slug).ok_or_else(|| {
188 anyhow::anyhow!(
189 "unknown provider '{slug}'. Known: {}",
190 codewhale_config::ProviderKind::names_hint()
191 )
192 });
193 }
194 if args.non_interactive {
195 bail!(
196 "--provider is required in --non-interactive mode. Known: {}",
197 codewhale_config::ProviderKind::names_hint()
198 );
199 }
200 // List providers by their canonical names from the existing registry.
201 let providers: Vec<ProviderInfo> = codewhale_config::ProviderKind::all()
202 .iter()
203 .filter_map(|kind| ProviderInfo::from_slug(kind.as_str()))
204 .collect();
205 let labels: Vec<String> = providers
206 .iter()
207 .map(|p| format!("{} ({})", p.display, p.slug))
208 .collect();
209 let idx = prompt_choice("Model provider", &labels)?;
210 Ok(providers[idx].clone())
211 }
212
213 fn cloud_choices() -> String {
214 format!(
215 "Choices: {}",
216 CLOUD_TARGETS
217 .iter()
218 .map(|c| c.slug)
219 .collect::<Vec<_>>()
220 .join(", ")
221 )
222 }
223
224 fn bridge_choices() -> String {
225 format!(
226 "Choices: {}",
227 BRIDGES
228 .iter()
229 .map(|b| b.slug)
230 .collect::<Vec<_>>()
231 .join(", ")
232 )
233 }
234
235 // ---------------------------------------------------------------------------
236 // Prompt helpers (reuse the stdin pattern from main.rs `pick_session_id`)
237 // ---------------------------------------------------------------------------
238
239 /// Print a numbered menu, read a 1-based selection from stdin, return the index.
240 fn prompt_choice(title: &str, options: &[String]) -> Result<usize> {
241 println!();
242 println!("{title}:");
243 for (idx, opt) in options.iter().enumerate() {
244 println!(" {:>2}. {}", idx + 1, opt);
245 }
246 print!("Enter a number: ");
247 io::stdout().flush()?;
248
249 let mut input = String::new();
250 io::stdin().read_line(&mut input)?;
251 let input = input.trim();
252 if input.is_empty() {
253 bail!("No selection made.");
254 }
255 let n: usize = input
256 .parse()
257 .map_err(|_| anyhow::anyhow!("Invalid input: {input}"))?;
258 options
259 .get(n.saturating_sub(1))
260 .map(|_| n - 1)
261 .ok_or_else(|| anyhow::anyhow!("Selection out of range"))
262 }
263
264 /// Generate a runtime token from two random v4 UUIDs (OS CSPRNG via uuid),
265 /// matching the existing token-generation pattern in this crate.
266 fn generate_runtime_token() -> String {
267 let a = uuid::Uuid::new_v4().simple().to_string();
268 let b = uuid::Uuid::new_v4().simple().to_string();
269 format!("{a}{b}")
270 }
271
272 #[cfg(test)]
273 mod tests {
274 use super::*;
275
276 #[test]
277 fn generated_token_is_long_and_hex() {
278 let t = generate_runtime_token();
279 assert_eq!(t.len(), 64, "two simple uuids = 64 hex chars");
280 assert!(t.chars().all(|c| c.is_ascii_hexdigit()));
281 // Two successive tokens differ (random, not fixed).
282 assert_ne!(t, generate_runtime_token());
283 }
284
285 #[test]
286 fn unknown_flags_fail_with_choices() {
287 let args = RemoteSetupArgs {
288 cloud: Some("nope".to_string()),
289 non_interactive: true,
290 ..Default::default()
291 };
292 let err = resolve_cloud(&args).unwrap_err().to_string();
293 assert!(err.contains("unknown cloud"));
294 assert!(err.contains("digitalocean"));
295
296 let args = RemoteSetupArgs {
297 bridge: Some("nope".to_string()),
298 non_interactive: true,
299 ..Default::default()
300 };
301 let err = resolve_bridge(&args).unwrap_err().to_string();
302 assert!(err.contains("unknown bridge"));
303
304 let args = RemoteSetupArgs {
305 provider: Some("nope".to_string()),
306 non_interactive: true,
307 ..Default::default()
308 };
309 let err = resolve_provider(&args).unwrap_err().to_string();
310 assert!(err.contains("unknown provider"));
311 }
312
313 #[test]
314 fn non_interactive_requires_flags() {
315 let args = RemoteSetupArgs {
316 non_interactive: true,
317 ..Default::default()
318 };
319 assert!(
320 resolve_cloud(&args)
321 .unwrap_err()
322 .to_string()
323 .contains("--cloud is required")
324 );
325 }
326
327 #[test]
328 fn flags_resolve_to_registry_rows() {
329 let args = RemoteSetupArgs {
330 cloud: Some("digitalocean".to_string()),
331 bridge: Some("telegram".to_string()),
332 provider: Some("deepseek".to_string()),
333 non_interactive: true,
334 ..Default::default()
335 };
336 assert_eq!(resolve_cloud(&args).unwrap().slug, "digitalocean");
337 assert_eq!(resolve_bridge(&args).unwrap().slug, "telegram");
338 assert_eq!(resolve_provider(&args).unwrap().slug, "deepseek");
339 }
340 }
341
341 lines RUST