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