返回 CodeWhale
bundle.rs
根目录 / crates / tui / src / remote_setup / bundle.rs
1 //! Bundle rendering for `codewhale remote-setup`.
2 //!
3 //! Renders a self-contained deploy bundle to `--out`:
4 //! `runtime.env`, `<bridge>.env`, the runtime + bridge systemd units, and a
5 //! `RUNBOOK.md` with the exact remaining manual steps and first-pairing flow.
6 //!
7 //! Env files lead with `CODEWHALE_*` keys; `DEEPSEEK_*` are documented as legacy
8 //! aliases. The provider lives entirely in `runtime.env` (the bridge is pure
9 //! transport and never needs to know which provider is behind the runtime).
10
11 use std::path::{Path, PathBuf};
12
13 use anyhow::{Context, Result};
14
15 use super::registry::{BridgeSpec, CloudTarget, DeployInputs, InstallMethod, SecretStore};
16
17 /// Default runtime port the units and bundle use.
18 pub const DEFAULT_PORT: u16 = 7878;
19 /// Default worker count.
20 pub const DEFAULT_WORKERS: u32 = 2;
21 /// Default runtime URL the bridge talks to (loopback only).
22 pub const DEFAULT_RUNTIME_URL: &str = "http://127.0.0.1:7878";
23
24 /// Minimal provider facts the bundle needs, read from the existing
25 /// `codewhale_config::provider` registry (the single source of truth).
26 #[derive(Debug, Clone)]
27 pub struct ProviderInfo {
28 /// Canonical provider slug, e.g. `"deepseek"`.
29 pub slug: String,
30 /// Human-readable display name, e.g. `"DeepSeek"`.
31 pub display: String,
32 /// The provider's own API-key env var, e.g. `"DEEPSEEK_API_KEY"` (`env_keys[0]`).
33 pub key_var: String,
34 /// Provider default model, used as a comment hint in the bundle.
35 pub default_model: String,
36 }
37
38 impl ProviderInfo {
39 /// Resolve a [`ProviderInfo`] from a slug against the config provider registry.
40 #[must_use]
41 pub fn from_slug(slug: &str) -> Option<Self> {
42 let kind = codewhale_config::ProviderKind::parse(slug)?;
43 let p = codewhale_config::provider::provider_for_kind(kind);
44 let key_var = p.env_vars().first().copied().unwrap_or("CODEWHALE_API_KEY");
45 Some(Self {
46 slug: p.id().to_string(),
47 display: p.display_name().to_string(),
48 key_var: key_var.to_string(),
49 default_model: p.default_model().to_string(),
50 })
51 }
52 }
53
54 /// Everything needed to render a bundle. Constructed by the wizard (or directly
55 /// in tests). Secret *values* are placeholders the RUNBOOK tells the user to
56 /// replace; the only generated secret is the runtime token.
57 #[derive(Debug, Clone)]
58 pub struct BundleInputs {
59 pub cloud: &'static CloudTarget,
60 pub bridge: &'static BridgeSpec,
61 pub provider: ProviderInfo,
62 /// Model id to write (default `"auto"`).
63 pub model: String,
64 /// Generated runtime token shared by `runtime.env` and `<bridge>.env`.
65 pub runtime_token: String,
66 /// Provider API-key value (placeholder unless the user supplied one).
67 pub provider_key_value: String,
68 /// Bridge secret values keyed by env var (placeholder unless supplied).
69 pub bridge_secret_values: Vec<(String, String)>,
70 /// Allowlist string (comma-separated chat ids); may be empty for first pairing.
71 pub allowlist: String,
72 /// Runtime port.
73 pub port: u16,
74 /// Runtime worker count.
75 pub workers: u32,
76 /// Workspace path on the host.
77 pub workspace: String,
78 }
79
80 impl BundleInputs {
81 /// Build the [`DeployInputs`] the cloud `plan()` consumes.
82 #[must_use]
83 pub fn deploy_inputs(&self) -> DeployInputs {
84 DeployInputs {
85 bridge_slug: self.bridge.slug.to_string(),
86 provider_slug: self.provider.slug.to_string(),
87 region: self.cloud.default_region.to_string(),
88 instance_name: "codewhale-remote".to_string(),
89 image: "ghcr.io/hmbown/codewhale:latest".to_string(),
90 }
91 }
92 }
93
94 /// A single rendered file: relative path within the bundle + contents.
95 #[derive(Debug, Clone, PartialEq, Eq)]
96 pub struct RenderedFile {
97 pub relative_path: String,
98 pub contents: String,
99 }
100
101 /// Render every bundle file in memory (no filesystem writes). Pure function —
102 /// used directly by tests so we never touch disk or run a command.
103 #[must_use]
104 pub fn render_bundle(inputs: &BundleInputs) -> Vec<RenderedFile> {
105 vec![
106 RenderedFile {
107 relative_path: "runtime.env".to_string(),
108 contents: render_runtime_env(inputs),
109 },
110 RenderedFile {
111 relative_path: format!("{}.env", inputs.bridge.slug),
112 contents: render_bridge_env(inputs),
113 },
114 RenderedFile {
115 relative_path: "codewhale-runtime.service".to_string(),
116 contents: render_runtime_unit(inputs),
117 },
118 RenderedFile {
119 relative_path: inputs.bridge.service_unit.to_string(),
120 contents: render_bridge_unit(inputs),
121 },
122 RenderedFile {
123 relative_path: "RUNBOOK.md".to_string(),
124 contents: render_runbook(inputs),
125 },
126 ]
127 }
128
129 /// Render the bundle to `out_dir`, creating it if needed. Returns the absolute
130 /// paths written, in render order.
131 pub fn write_bundle(inputs: &BundleInputs, out_dir: &Path) -> Result<Vec<PathBuf>> {
132 std::fs::create_dir_all(out_dir)
133 .with_context(|| format!("creating bundle dir {}", out_dir.display()))?;
134 let mut written = Vec::new();
135 for file in render_bundle(inputs) {
136 let path = out_dir.join(&file.relative_path);
137 std::fs::write(&path, file.contents)
138 .with_context(|| format!("writing {}", path.display()))?;
139 written.push(path);
140 }
141 Ok(written)
142 }
143
144 // ---------------------------------------------------------------------------
145 // runtime.env — provider config lives here
146 // ---------------------------------------------------------------------------
147
148 fn render_runtime_env(i: &BundleInputs) -> String {
149 let mut out = String::new();
150 out.push_str("# Codewhale runtime config — generated by `codewhale remote-setup`.\n");
151 out.push_str("# Provider configuration lives here; the bridge is pure transport.\n");
152 out.push_str("# CODEWHALE_* keys are canonical. DEEPSEEK_* are read as legacy aliases.\n\n");
153
154 out.push_str(&format!("CODEWHALE_PROVIDER={}\n", i.provider.slug));
155 out.push_str(&format!(
156 "# Provider API key ({}). Replace the placeholder with your real key.\n",
157 i.provider.display
158 ));
159 out.push_str(&format!(
160 "{}={}\n",
161 i.provider.key_var, i.provider_key_value
162 ));
163 out.push_str(&format!(
164 "CODEWHALE_MODEL={} # provider default is {}\n",
165 i.model, i.provider.default_model
166 ));
167 out.push('\n');
168 out.push_str("# Shared auth token between the runtime and the bridge. Generated for you;\n");
169 out.push_str("# rotate it any time (keep runtime.env and the bridge env in sync).\n");
170 out.push_str(&format!("CODEWHALE_RUNTIME_TOKEN={}\n", i.runtime_token));
171 out.push_str(&format!("CODEWHALE_RUNTIME_PORT={}\n", i.port));
172 out.push_str(&format!("CODEWHALE_RUNTIME_WORKERS={}\n", i.workers));
173 out.push_str("RUST_LOG=info\n\n");
174
175 if i.provider.slug == "deepseek" {
176 out.push_str(
177 "# Legacy aliases (still honored): DEEPSEEK_RUNTIME_TOKEN, DEEPSEEK_API_KEY,\n",
178 );
179 out.push_str("# DEEPSEEK_RUNTIME_PORT, DEEPSEEK_RUNTIME_WORKERS.\n");
180 } else {
181 out.push_str("# Legacy aliases (still honored): DEEPSEEK_RUNTIME_TOKEN,\n");
182 out.push_str("# DEEPSEEK_RUNTIME_PORT, DEEPSEEK_RUNTIME_WORKERS.\n");
183 }
184 out
185 }
186
187 // ---------------------------------------------------------------------------
188 // <bridge>.env — transport only
189 // ---------------------------------------------------------------------------
190
191 fn render_bridge_env(i: &BundleInputs) -> String {
192 let mut out = String::new();
193 out.push_str(&format!(
194 "# Codewhale {} bridge config — generated by `codewhale remote-setup`.\n",
195 i.bridge.display
196 ));
197 out.push_str("# Transport only: forwards chat <-> the local runtime. No provider keys here.\n");
198 out.push_str("# CODEWHALE_* keys are canonical; DEEPSEEK_* are read as legacy aliases.\n\n");
199
200 out.push_str("# --- bridge credentials (replace placeholders) ---\n");
201 for (key, value) in &i.bridge_secret_values {
202 out.push_str(&format!("{key}={value}\n"));
203 }
204 out.push('\n');
205
206 out.push_str("# --- transport to the local runtime ---\n");
207 out.push_str(&format!("CODEWHALE_RUNTIME_URL={DEFAULT_RUNTIME_URL}\n"));
208 out.push_str("# Must match CODEWHALE_RUNTIME_TOKEN in runtime.env.\n");
209 out.push_str(&format!("CODEWHALE_RUNTIME_TOKEN={}\n", i.runtime_token));
210 out.push_str(&format!("CODEWHALE_WORKSPACE={}\n", i.workspace));
211 out.push_str(&format!("CODEWHALE_MODEL={}\n", i.model));
212 out.push_str("CODEWHALE_MODE=agent\n");
213 out.push_str("CODEWHALE_ALLOW_SHELL=true\n");
214 out.push_str("CODEWHALE_TRUST_MODE=false\n");
215 out.push_str("CODEWHALE_AUTO_APPROVE=false\n\n");
216
217 out.push_str("# --- pairing / allowlist ---\n");
218 out.push_str(&format!("{}\n", allowlist_lines(i)));
219
220 out.push_str("\n# --- bridge tuning ---\n");
221 out.push_str(&format!(
222 "{}_THREAD_MAP_PATH=/var/lib/codewhale-{}-bridge/thread-map.json\n",
223 bridge_env_prefix(i.bridge),
224 i.bridge.slug
225 ));
226 out.push_str(&format!(
227 "{}_ALLOW_GROUPS=false\n",
228 bridge_env_prefix(i.bridge)
229 ));
230 out.push_str(&format!(
231 "{}_REQUIRE_PREFIX_IN_GROUP=true\n",
232 bridge_env_prefix(i.bridge)
233 ));
234 out.push_str(&format!(
235 "{}_GROUP_PREFIX=/cw\n",
236 bridge_env_prefix(i.bridge)
237 ));
238 out.push_str(&format!(
239 "{}_MAX_REPLY_CHARS=3500\n",
240 bridge_env_prefix(i.bridge)
241 ));
242 if i.bridge.slug == "telegram" {
243 out.push_str("TELEGRAM_POLL_TIMEOUT_SECONDS=50\n");
244 }
245 out.push_str("CODEWHALE_TURN_TIMEOUT_MS=900000\n");
246 out
247 }
248
249 /// The chat allowlist uses a bridge-prefixed var (TELEGRAM_/FEISHU_); the deploy
250 /// examples key it per bridge, so mirror that.
251 fn allowlist_lines(i: &BundleInputs) -> String {
252 let prefix = bridge_env_prefix(i.bridge);
253 format!(
254 "# Comma-separated chat/user IDs allowed to control the runtime.\n# Leave empty only during first pairing, with {prefix}_ALLOW_UNLISTED=true.\n{prefix}_CHAT_ALLOWLIST={}\n{prefix}_ALLOW_UNLISTED=false",
255 i.allowlist
256 )
257 }
258
259 fn bridge_env_prefix(bridge: &BridgeSpec) -> &'static str {
260 match bridge.slug {
261 "telegram" => "TELEGRAM",
262 "feishu" => "FEISHU",
263 _ => "CODEWHALE",
264 }
265 }
266
267 // ---------------------------------------------------------------------------
268 // systemd units
269 // ---------------------------------------------------------------------------
270
271 fn render_runtime_unit(i: &BundleInputs) -> String {
272 format!(
273 "[Unit]\n\
274 Description=Codewhale Runtime API\n\
275 Wants=network-online.target\n\
276 After=network-online.target\n\n\
277 [Service]\n\
278 Type=simple\n\
279 User=codewhale\n\
280 Group=codewhale\n\
281 WorkingDirectory={workspace}\n\
282 # Legacy /etc/deepseek is loaded first for old installs; /etc/codewhale wins.\n\
283 EnvironmentFile=-/etc/deepseek/runtime.env\n\
284 EnvironmentFile=-/etc/codewhale/runtime.env\n\
285 ExecStart=/bin/sh -lc 'exec /home/codewhale/.cargo/bin/codewhale serve --http --host 127.0.0.1 --port \"${{CODEWHALE_RUNTIME_PORT:-${{DEEPSEEK_RUNTIME_PORT:-{port}}}}}\" --workers \"${{CODEWHALE_RUNTIME_WORKERS:-${{DEEPSEEK_RUNTIME_WORKERS:-{workers}}}}}\" --auth-token \"${{CODEWHALE_RUNTIME_TOKEN:-${{DEEPSEEK_RUNTIME_TOKEN}}}}\"'\n\
286 Restart=on-failure\n\
287 RestartSec=5\n\
288 NoNewPrivileges=true\n\
289 PrivateTmp=true\n\
290 ProtectSystem=full\n\
291 ReadWritePaths=/home/codewhale/.codewhale /home/codewhale/.deepseek {workspace}\n\n\
292 [Install]\n\
293 WantedBy=multi-user.target\n",
294 workspace = i.workspace,
295 port = i.port,
296 workers = i.workers,
297 )
298 }
299
300 fn render_bridge_unit(i: &BundleInputs) -> String {
301 format!(
302 "[Unit]\n\
303 Description=Codewhale {display} Phone Bridge\n\
304 Wants=network-online.target codewhale-runtime.service\n\
305 After=network-online.target codewhale-runtime.service\n\n\
306 [Service]\n\
307 Type=simple\n\
308 User=codewhale\n\
309 Group=codewhale\n\
310 WorkingDirectory={install_dir}\n\
311 # Legacy /etc/deepseek is loaded first for old installs; /etc/codewhale wins.\n\
312 EnvironmentFile=-/etc/deepseek/{slug}-bridge.env\n\
313 EnvironmentFile=-/etc/codewhale/{slug}-bridge.env\n\
314 ExecStart=/usr/bin/node {install_dir}/src/index.mjs\n\
315 Restart=on-failure\n\
316 RestartSec=5\n\
317 NoNewPrivileges=true\n\
318 PrivateTmp=true\n\
319 ProtectSystem=full\n\
320 ReadWritePaths=/var/lib/codewhale-{slug}-bridge\n\n\
321 [Install]\n\
322 WantedBy=multi-user.target\n",
323 display = i.bridge.display,
324 slug = i.bridge.slug,
325 install_dir = i.bridge.install_dir,
326 )
327 }
328
329 // ---------------------------------------------------------------------------
330 // RUNBOOK.md
331 // ---------------------------------------------------------------------------
332
333 fn render_runbook(i: &BundleInputs) -> String {
334 let mut out = String::new();
335 let plan = (i.cloud.plan)(&i.deploy_inputs());
336
337 out.push_str(&format!(
338 "# Codewhale remote-setup runbook — {} + {}\n\n",
339 i.cloud.display, i.bridge.display
340 ));
341 out.push_str("Generated by `codewhale remote-setup` (generate-only). Nothing was run on\n");
342 out.push_str("your behalf. Follow the steps below to stand the agent up.\n\n");
343
344 out.push_str("## What was generated\n\n");
345 out.push_str("| File | Purpose |\n|---|---|\n");
346 out.push_str(
347 "| `runtime.env` | Provider + runtime config (the only place the provider is set). |\n",
348 );
349 out.push_str(&format!(
350 "| `{}.env` | {} bridge transport config (token, allowlist, runtime URL). |\n",
351 i.bridge.slug, i.bridge.display
352 ));
353 out.push_str("| `codewhale-runtime.service` | systemd unit for the runtime API. |\n");
354 out.push_str(&format!(
355 "| `{}` | systemd unit for the {} bridge. |\n\n",
356 i.bridge.service_unit, i.bridge.display
357 ));
358
359 out.push_str("## 1. Fill in the secrets\n\n");
360 out.push_str(&format!(
361 "- In `runtime.env`, set `{}` to your real {} API key.\n",
362 i.provider.key_var, i.provider.display
363 ));
364 out.push_str(&format!("- {}\n", i.bridge.setup_hint));
365 out.push_str(&format!(
366 " Then set {} in `{}.env`.\n",
367 i.bridge
368 .secret_keys
369 .iter()
370 .map(|k| format!("`{k}`"))
371 .collect::<Vec<_>>()
372 .join(" and "),
373 i.bridge.slug
374 ));
375 out.push_str(&format!(
376 "- A random `CODEWHALE_RUNTIME_TOKEN` was generated (`{}`). It is identical in\n",
377 i.runtime_token
378 ));
379 out.push_str(" both files; rotate it any time, keeping both files in sync.\n");
380 out.push_str(&format!(
381 "- Reference env template (every supported key, with comments): `{}`.\n\n",
382 i.bridge.env_template
383 ));
384
385 out.push_str("## 2. Provision the host\n\n");
386 out.push_str(&format!(
387 "Cloud: **{}** — install: {}, secrets: {}.\n\n",
388 i.cloud.display,
389 i.cloud.install.label(),
390 i.cloud.secret_store.label()
391 ));
392 out.push_str(&format!(
393 "Auto-provision (`--apply`) is **not yet implemented**. Run these `{}` steps\n",
394 i.cloud.cli_tool
395 ));
396 out.push_str("yourself (commands shown as data — review before running):\n\n");
397 for (n, step) in plan.iter().enumerate() {
398 out.push_str(&format!("{}. {}\n", n + 1, step.description));
399 out.push_str(&format!(
400 " ```sh\n {}\n ```\n",
401 step.display_command()
402 ));
403 }
404 out.push('\n');
405 if i.cloud.secret_store == SecretStore::KeyVault {
406 out.push_str("> The VM reads the provider key + runtime token from Key Vault via its\n");
407 out.push_str("> managed identity at boot — they are not baked into the image.\n\n");
408 }
409
410 out.push_str("## 3. Install the env files + units on the host\n\n");
411 out.push_str("```sh\nsudo install -d -m 750 /etc/codewhale\n");
412 out.push_str(&format!(
413 "sudo install -m 600 runtime.env /etc/codewhale/runtime.env\n\
414 sudo install -m 600 {slug}.env /etc/codewhale/{slug}-bridge.env\n\
415 sudo install -m 644 codewhale-runtime.service /etc/systemd/system/codewhale-runtime.service\n\
416 sudo install -m 644 {unit} /etc/systemd/system/{unit}\n\
417 sudo systemctl daemon-reload\n\
418 sudo systemctl enable --now codewhale-runtime {unit}\n```\n\n",
419 slug = i.bridge.slug,
420 unit = i.bridge.service_unit,
421 ));
422 if matches!(i.cloud.install, InstallMethod::NativeSystemd) {
423 out.push_str(&format!(
424 "The {} bridge is a zero-dep Node service; install it at `{}` (its\n",
425 i.bridge.display, i.bridge.install_dir
426 ));
427 out.push_str(&format!(
428 "`WorkingDirectory`) by copying `{}` there and running `npm install` if needed.\n\n",
429 i.bridge.package_dir
430 ));
431 }
432
433 out.push_str("## 4. First pairing\n\n");
434 match i.bridge.slug {
435 "telegram" => {
436 out.push_str("1. With `TELEGRAM_CHAT_ALLOWLIST` empty, temporarily set\n");
437 out.push_str(
438 " `TELEGRAM_ALLOW_UNLISTED=true`, restart the bridge, and DM your bot once.\n",
439 );
440 out.push_str(
441 "2. Read the chat id the bridge logs, add it to `TELEGRAM_CHAT_ALLOWLIST`,\n",
442 );
443 out.push_str(" set `TELEGRAM_ALLOW_UNLISTED=false`, and restart the bridge.\n");
444 }
445 "feishu" => {
446 out.push_str("1. With `FEISHU_CHAT_ALLOWLIST` empty, temporarily set\n");
447 out.push_str(
448 " `FEISHU_ALLOW_UNLISTED=true`, restart the bridge, and message the app once.\n",
449 );
450 out.push_str(
451 "2. Read the open id the bridge logs, add it to `FEISHU_CHAT_ALLOWLIST`,\n",
452 );
453 out.push_str(" set `FEISHU_ALLOW_UNLISTED=false`, and restart the bridge.\n");
454 }
455 _ => {
456 out.push_str("Pair by adding your chat id to the bridge allowlist, then disable\n");
457 out.push_str("unlisted access and restart the bridge.\n");
458 }
459 }
460 out.push('\n');
461
462 out.push_str("## 5. Verify\n\n");
463 out.push_str("```sh\nsudo systemctl status codewhale-runtime --no-pager\n");
464 out.push_str(&format!(
465 "sudo systemctl status {} --no-pager\n```\n\n",
466 i.bridge.service_unit
467 ));
468 out.push_str(
469 "Port 7878 stays bound to 127.0.0.1. To reach `/status` from a laptop, SSH-tunnel\n",
470 );
471 out.push_str("it (`ssh -L 7878:127.0.0.1:7878 <host>`) rather than opening the port.\n");
472 out
473 }
474
475 #[cfg(test)]
476 mod tests {
477 use super::*;
478 use crate::remote_setup::registry::{AZURE, DIGITALOCEAN, FEISHU, LIGHTHOUSE, TELEGRAM};
479
480 fn sample_inputs(
481 cloud: &'static CloudTarget,
482 bridge: &'static BridgeSpec,
483 provider_slug: &str,
484 ) -> BundleInputs {
485 let provider = ProviderInfo::from_slug(provider_slug)
486 .unwrap_or_else(|| panic!("provider {provider_slug} not in registry"));
487 let bridge_secret_values = bridge
488 .secret_keys
489 .iter()
490 .map(|k| {
491 (
492 (*k).to_string(),
493 format!("replace-{}", k.to_ascii_lowercase()),
494 )
495 })
496 .collect();
497 BundleInputs {
498 cloud,
499 bridge,
500 provider: provider.clone(),
501 model: "auto".to_string(),
502 // Fixed, clearly-fake token for deterministic tests (never executed).
503 runtime_token: "test-runtime-token-0000".to_string(),
504 provider_key_value: format!("replace-{}", provider.key_var.to_ascii_lowercase()),
505 bridge_secret_values,
506 allowlist: String::new(),
507 port: DEFAULT_PORT,
508 workers: DEFAULT_WORKERS,
509 workspace: "/opt/whalebro".to_string(),
510 }
511 }
512
513 #[test]
514 fn provider_info_reads_registry() {
515 let ds = ProviderInfo::from_slug("deepseek").unwrap();
516 assert_eq!(ds.slug, "deepseek");
517 assert_eq!(ds.key_var, "DEEPSEEK_API_KEY");
518 let oai = ProviderInfo::from_slug("openai").unwrap();
519 assert_eq!(oai.key_var, "OPENAI_API_KEY");
520 // Provider-registry aliases resolve to the canonical slug.
521 assert_eq!(
522 ProviderInfo::from_slug("nvidia").unwrap().slug,
523 "nvidia-nim"
524 );
525 assert_eq!(ProviderInfo::from_slug("kimi").unwrap().slug, "moonshot");
526 assert!(ProviderInfo::from_slug("not-a-provider").is_none());
527 }
528
529 #[test]
530 fn bundle_renders_expected_file_set() {
531 let inputs = sample_inputs(&LIGHTHOUSE, &FEISHU, "deepseek");
532 let files = render_bundle(&inputs);
533 let names: Vec<_> = files.iter().map(|f| f.relative_path.as_str()).collect();
534 assert!(names.contains(&"runtime.env"));
535 assert!(names.contains(&"feishu.env"));
536 assert!(names.contains(&"codewhale-runtime.service"));
537 assert!(names.contains(&"codewhale-feishu-bridge.service"));
538 assert!(names.contains(&"RUNBOOK.md"));
539 assert_eq!(files.len(), 5);
540 }
541
542 #[test]
543 fn runtime_and_bridge_share_the_token() {
544 let inputs = sample_inputs(&AZURE, &TELEGRAM, "openai");
545 let files = render_bundle(&inputs);
546 let runtime = &files
547 .iter()
548 .find(|f| f.relative_path == "runtime.env")
549 .unwrap()
550 .contents;
551 let bridge = &files
552 .iter()
553 .find(|f| f.relative_path == "telegram.env")
554 .unwrap()
555 .contents;
556 let token_line = format!("CODEWHALE_RUNTIME_TOKEN={}", inputs.runtime_token);
557 assert!(runtime.contains(&token_line), "runtime.env missing token");
558 assert!(bridge.contains(&token_line), "bridge env missing token");
559 }
560
561 #[test]
562 fn env_files_lead_with_codewhale_keys() {
563 let inputs = sample_inputs(&DIGITALOCEAN, &TELEGRAM, "deepseek");
564 let files = render_bundle(&inputs);
565 let runtime = &files
566 .iter()
567 .find(|f| f.relative_path == "runtime.env")
568 .unwrap()
569 .contents;
570 assert!(runtime.contains("CODEWHALE_PROVIDER=deepseek"));
571 assert!(runtime.contains("CODEWHALE_RUNTIME_TOKEN="));
572 assert!(runtime.contains("CODEWHALE_RUNTIME_PORT="));
573 // Provider key var present (DeepSeek doubles as canonical + legacy alias).
574 assert!(runtime.contains("DEEPSEEK_API_KEY="));
575 // Documents the legacy alias convention.
576 assert!(runtime.to_lowercase().contains("legacy alias"));
577
578 let bridge = &files
579 .iter()
580 .find(|f| f.relative_path == "telegram.env")
581 .unwrap()
582 .contents;
583 assert!(bridge.contains("CODEWHALE_RUNTIME_URL="));
584 assert!(bridge.contains("TELEGRAM_BOT_TOKEN="));
585 }
586
587 #[test]
588 fn runbook_is_non_empty_and_lists_the_plan() {
589 // DigitalOcean specifically: the RUNBOOK should carry the doctl plan.
590 let inputs = sample_inputs(&DIGITALOCEAN, &TELEGRAM, "deepseek");
591 let files = render_bundle(&inputs);
592 let runbook = &files
593 .iter()
594 .find(|f| f.relative_path == "RUNBOOK.md")
595 .unwrap()
596 .contents;
597 assert!(runbook.len() > 400, "RUNBOOK should be substantial");
598 assert!(runbook.contains("not yet implemented"));
599 assert!(runbook.contains("doctl"));
600 assert!(runbook.to_lowercase().contains("first pairing"));
601 }
602
603 #[test]
604 fn every_cloud_bridge_provider_triple_renders() {
605 // Cover the matrix per the RFC §Tests; assert CODEWHALE_* + matching token
606 // + non-empty RUNBOOK. No command is ever executed.
607 for cloud in &[LIGHTHOUSE, AZURE, DIGITALOCEAN] {
608 for bridge in &[FEISHU, TELEGRAM] {
609 for provider_slug in &["deepseek", "openai", "moonshot"] {
610 let inputs = sample_inputs(cloud, bridge, provider_slug);
611 let files = render_bundle(&inputs);
612 assert_eq!(files.len(), 5, "{}-{} file count", cloud.slug, bridge.slug);
613
614 let runtime = &files
615 .iter()
616 .find(|f| f.relative_path == "runtime.env")
617 .unwrap()
618 .contents;
619 assert!(runtime.contains(&format!("CODEWHALE_PROVIDER={provider_slug}")));
620 let token_line = format!("CODEWHALE_RUNTIME_TOKEN={}", inputs.runtime_token);
621 assert!(runtime.contains(&token_line));
622
623 let bridge_env = &files
624 .iter()
625 .find(|f| f.relative_path == format!("{}.env", bridge.slug))
626 .unwrap()
627 .contents;
628 assert!(bridge_env.contains(&token_line));
629
630 let runbook = &files
631 .iter()
632 .find(|f| f.relative_path == "RUNBOOK.md")
633 .unwrap()
634 .contents;
635 assert!(!runbook.is_empty());
636 }
637 }
638 }
639 }
640
641 #[test]
642 fn systemd_units_reference_codewhale_paths() {
643 let inputs = sample_inputs(&LIGHTHOUSE, &FEISHU, "deepseek");
644 let files = render_bundle(&inputs);
645 let unit = &files
646 .iter()
647 .find(|f| f.relative_path == "codewhale-runtime.service")
648 .unwrap()
649 .contents;
650 assert!(unit.contains("/etc/codewhale/runtime.env"));
651 assert!(unit.contains("CODEWHALE_RUNTIME_TOKEN"));
652 // Legacy path still loaded first.
653 assert!(unit.contains("/etc/deepseek/runtime.env"));
654
655 let bridge_unit = &files
656 .iter()
657 .find(|f| f.relative_path == "codewhale-feishu-bridge.service")
658 .unwrap()
659 .contents;
660 assert!(bridge_unit.contains("/etc/codewhale/feishu-bridge.env"));
661 }
662 }
663
663 lines RUST