| 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 |