From 6621c705dc1af393923002b80c5cd4d29301eeaf Mon Sep 17 00:00:00 2001 From: bitpshr Date: Wed, 7 Oct 2026 15:08:57 -0400 Subject: [PATCH] feat: add onboard init, connect, check and test for agent-led setup --- .fernignore | 1 + Cargo.lock | 273 +++- Cargo.toml | 5 +- cli/elevenlabs/workflow/mod.rs | 2 + cli/elevenlabs/workflow/onboard/auth.rs | 229 +++ cli/elevenlabs/workflow/onboard/browser.rs | 34 + cli/elevenlabs/workflow/onboard/check.rs | 265 ++++ cli/elevenlabs/workflow/onboard/clipboard.rs | 104 ++ cli/elevenlabs/workflow/onboard/connect.rs | 362 +++++ cli/elevenlabs/workflow/onboard/env_file.rs | 295 ++++ cli/elevenlabs/workflow/onboard/init.rs | 55 + cli/elevenlabs/workflow/onboard/manual.rs | 45 + cli/elevenlabs/workflow/onboard/mod.rs | 155 ++ cli/elevenlabs/workflow/onboard/project.rs | 438 ++++++ cli/elevenlabs/workflow/onboard/reply.rs | 150 ++ cli/elevenlabs/workflow/onboard/session.rs | 346 +++++ .../workflow/onboard/test_request.rs | 111 ++ cli/elevenlabs/workflow/onboard/tests.rs | 1056 ++++++++++++++ cli/elevenlabs/workflow/project.rs | 4 +- cli/elevenlabs/workflow/residency.rs | 11 +- cli/elevenlabs/workflow/say.rs | 8 +- src/lib.rs | 21 +- tests/onboard_test.rs | 1248 +++++++++++++++++ 23 files changed, 5193 insertions(+), 25 deletions(-) create mode 100644 cli/elevenlabs/workflow/onboard/auth.rs create mode 100644 cli/elevenlabs/workflow/onboard/browser.rs create mode 100644 cli/elevenlabs/workflow/onboard/check.rs create mode 100644 cli/elevenlabs/workflow/onboard/clipboard.rs create mode 100644 cli/elevenlabs/workflow/onboard/connect.rs create mode 100644 cli/elevenlabs/workflow/onboard/env_file.rs create mode 100644 cli/elevenlabs/workflow/onboard/init.rs create mode 100644 cli/elevenlabs/workflow/onboard/manual.rs create mode 100644 cli/elevenlabs/workflow/onboard/mod.rs create mode 100644 cli/elevenlabs/workflow/onboard/project.rs create mode 100644 cli/elevenlabs/workflow/onboard/reply.rs create mode 100644 cli/elevenlabs/workflow/onboard/session.rs create mode 100644 cli/elevenlabs/workflow/onboard/test_request.rs create mode 100644 cli/elevenlabs/workflow/onboard/tests.rs create mode 100644 tests/onboard_test.rs diff --git a/.fernignore b/.fernignore index 645fa03..39d7bbe 100644 --- a/.fernignore +++ b/.fernignore @@ -38,6 +38,7 @@ elevenlabs-types/.gitignore # Hand-written tests (tests/wire_test.rs next to them is generated). tests/e2e_smoke.rs tests/say_test.rs +tests/onboard_test.rs .fern/replay.lock .fern/replay.yml .gitattributes diff --git a/Cargo.lock b/Cargo.lock index 3c0ee47..a30ea97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,7 +62,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -73,7 +73,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -82,6 +82,24 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -272,6 +290,15 @@ dependencies = [ "roff", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -470,11 +497,18 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "elevenlabs-cli" -version = "1.4.0" +version = "1.5.0" dependencies = [ "anyhow", + "arboard", "base64", "bytes", "chrono", @@ -562,9 +596,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "fastrand" version = "2.4.1" @@ -577,6 +617,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -593,6 +639,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -715,6 +767,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -772,6 +834,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -1055,7 +1126,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -1319,13 +1390,22 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1390,6 +1470,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags", "objc2", + "objc2-core-graphics", "objc2-foundation", ] @@ -1404,6 +1485,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -1421,6 +1515,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1497,6 +1602,16 @@ dependencies = [ "serde", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1526,6 +1641,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -1591,6 +1717,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.11" @@ -1870,7 +2005,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2057,7 +2192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b992cea3194eea663ba99a042d61cea4bd1872da37021af56f6a37e0359b9d33" dependencies = [ "inventory", - "nom", + "nom 7.1.3", "regex", "serde", "serde_json", @@ -2260,7 +2395,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2328,7 +2463,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2662,6 +2797,17 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -2878,6 +3024,76 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.103" @@ -2920,7 +3136,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3167,12 +3383,47 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 9f793fa..d6ec9f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "elevenlabs-cli" -version = "1.4.0" +version = "1.5.0" edition = "2021" description = "CLI for the ElevenLabs API" license = "MIT" @@ -48,6 +48,9 @@ rustls = ["reqwest/rustls-tls-native-roots", "tokio-tungstenite/rustls-tls-nativ [dependencies] anyhow = "1" +# Reads the API key the approval page copied for `elevenlabs onboard`; no image +# support, so the musl targets link without an image stack. +arboard = { version = "3", default-features = false, features = ["wayland-data-control"] } base64 = "0.22" bytes = "1" clap = { version = "4", features = ["derive", "string", "env"] } diff --git a/cli/elevenlabs/workflow/mod.rs b/cli/elevenlabs/workflow/mod.rs index 421c246..cd6a96d 100644 --- a/cli/elevenlabs/workflow/mod.rs +++ b/cli/elevenlabs/workflow/mod.rs @@ -18,6 +18,7 @@ mod api; mod components; mod feedback; mod intent; +mod onboard; mod project; mod residency; mod say; @@ -35,6 +36,7 @@ pub fn register(app: CliApp) -> CliApp { // and the environment, which has to happen before `CliApp::run` parses // anything (see that module's docs). let app = intent::register(app); + let app = onboard::register(app); let app = agents::register(app); let app = templates::register(app); let app = tools::register(app); diff --git a/cli/elevenlabs/workflow/onboard/auth.rs b/cli/elevenlabs/workflow/onboard/auth.rs new file mode 100644 index 0000000..632bf19 --- /dev/null +++ b/cli/elevenlabs/workflow/onboard/auth.rs @@ -0,0 +1,229 @@ +//! The sign-in: the same browser flow as `auth login`, plus the two query +//! parameters that ask the approval page for a key, and the account check +//! that a key on the clipboard has to pass. + +use std::sync::OnceLock; + +use fern_cli_sdk::auth::{active_store, LoginContext, LoginFlow, PkceLoginFlow, TokenBundle}; +use fern_cli_sdk::error::CliError; +use fern_cli_sdk::openapi::AppContext; +use serde_json::Value; + +use super::super::say::run_async as block_on; +use super::super::settings; + +pub(super) const CLI_NAME: &str = "elevenlabs"; + +/// How `auth login` signs in. The runtime keeps its copy private, so this is +/// read out of the generated `main.rs`, the one place it is declared. +pub(super) struct SignIn { + pub scheme: String, + pub client_id: String, + pub authorize_url: String, + pub token_url: String, + pub redirect_host: String, + pub redirect_ports: Vec, + pub success_url: String, + pub error_url: String, + pub scopes: Vec, +} + +pub(super) fn sign_in_config() -> Result<&'static SignIn, CliError> { + static CONFIG: OnceLock> = OnceLock::new(); + CONFIG + .get_or_init(|| parse_sign_in(include_str!("../../main.rs"))) + .as_ref() + .map_err(|e| CliError::Other(anyhow::anyhow!("main.rs sign-in: {e}"))) +} + +fn parse_sign_in(main_rs: &str) -> Result { + let flow = main_rs + .split("PkceLoginFlow::new(") + .nth(1) + .ok_or("no PkceLoginFlow")?; + // The text inside `.method(`…`)`; no argument in that chain contains a `)`. + let arg = |method: &str| -> Result<&str, String> { + let needle = format!(".{method}("); + let start = if method.is_empty() { + 0 + } else { + flow.find(&needle).ok_or(format!("no {needle}"))? + needle.len() + }; + flow[start..] + .split(')') + .next() + .ok_or(format!("unterminated {needle}")) + }; + let strings = |method: &str| -> Result, String> { + let found: Vec = arg(method)? + .split('"') + .skip(1) + .step_by(2) + .map(str::to_string) + .collect(); + (!found.is_empty()) + .then_some(found) + .ok_or(format!(".{method}( has no string")) + }; + let one = |method: &str| strings(method).map(|mut v| v.remove(0)); + Ok(SignIn { + scheme: one("")?, + client_id: one("client_id")?, + authorize_url: one("authorization_url")?, + token_url: one("token_url")?, + redirect_host: one("redirect_host")?, + redirect_ports: arg("redirect_ports")? + .split(|c: char| !c.is_ascii_digit()) + .filter(|p| !p.is_empty()) + .map(|p| p.parse().map_err(|_| format!("port {p}"))) + .collect::>()?, + success_url: one("success_redirect_url")?, + error_url: one("error_redirect_url")?, + scopes: strings("scopes")?, + }) +} + +/// Sign in through the browser, asking the page for a key. +pub(super) fn sign_in( + ctx: &AppContext, + authorize_url: &str, + no_browser: bool, +) -> Result<(), CliError> { + let config = sign_in_config()?; + // A local --base-url also moves the token endpoint, for testing against a local backend. + let oauth_base = ctx + .base_url_override() + .and_then(loopback) + .unwrap_or_else(|| config.token_url.trim_end_matches("/v1/oauth/token").into()); + let token_url = format!("{oauth_base}/v1/oauth/token"); + PkceLoginFlow::new(&config.scheme) + .client_id(&config.client_id) + .authorization_url(authorize_url) + .token_url(&token_url) + .scopes(&config.scopes) + .redirect_host(&config.redirect_host) + .redirect_ports(config.redirect_ports.iter().copied()) + .success_redirect_url(&config.success_url) + .error_redirect_url(&config.error_url) + .authorization_params(vec![("key_handoff", "clipboard".to_string())]) + .run(&LoginContext { + cli_name: CLI_NAME.to_string(), + no_browser, + })?; + Ok(()) +} + +pub(super) fn stored_access_token() -> Option { + let scheme = &sign_in_config().ok()?.scheme; + let raw = active_store().get(CLI_NAME, scheme).ok().flatten()?; + let token = TokenBundle::parse_or_raw(&raw).access_token; + (!token.is_empty()).then_some(token) +} + +/// `Err` when the signed-in account itself cannot be identified; `Ok(false)` only +/// when it can and the key does not belong to it. +pub(super) fn key_matches_sign_in( + client: &reqwest::Client, + base: &str, + key: &str, +) -> Result { + let unknown = || { + CliError::Other(anyhow::anyhow!( + "the signed-in account could not be identified" + )) + }; + let token = stored_access_token().ok_or_else(unknown)?; + let bearer = format!("Bearer {token}"); + let signed_in = account_for(client, base, "authorization", &bearer)?.ok_or_else(unknown)?; + Ok(account_for(client, base, "xi-api-key", key)? == Some(signed_in)) +} + +/// The `user_id` a credential opens; `Ok(None)` when the API rejects it. +pub(super) fn account_for( + client: &reqwest::Client, + base: &str, + header: &str, + value: &str, +) -> Result, CliError> { + let url = format!("{base}/v1/user"); + let fail = |e: reqwest::Error| CliError::Other(anyhow::anyhow!("reach {url}: {e}")); + let response = block_on( + client + .get(&url) + .header(header, value) + .timeout(std::time::Duration::from_secs(15)) + .send(), + ) + .map_err(fail)?; + match response.status() { + s if s == 401 || s == 403 => Ok(None), + s if !s.is_success() => Err(CliError::Other(anyhow::anyhow!("{url} answered {s}"))), + _ => { + let body: Value = block_on(response.json()).map_err(fail)?; + Ok(body["user_id"].as_str().map(str::to_string)) + } + } +} +/// What ElevenLabs says about an API key. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum KeyCheck { + Works, + Rejected, + /// No answer about the key: unreachable, or an error such as a 5xx. + Unanswered(String), +} + +/// Ask `GET /v1/user` about `key`. That endpoint needs the key's `user_read` +/// permission, which a key made by hand on the API keys page may lack: such a +/// key still works, it just can't read the account, so it counts as working. +pub(super) fn check_key(ctx: &AppContext, key: &str) -> KeyCheck { + let Ok(client) = ctx.http_config().build_client() else { + return KeyCheck::Unanswered("the HTTP client could not start".into()); + }; + let url = format!("{}/v1/user", api_base(ctx)); + let sent = block_on( + client + .get(&url) + .header("xi-api-key", key) + .timeout(std::time::Duration::from_secs(15)) + .send(), + ); + let response = match sent { + Ok(r) => r, + Err(_) => return KeyCheck::Unanswered("ElevenLabs couldn't be reached".into()), + }; + let status = response.status(); + if status.is_success() { + return KeyCheck::Works; + } + if status == 401 || status == 403 { + let body: Value = block_on(response.json()).unwrap_or(Value::Null); + return if body["detail"]["status"] == "missing_permissions" { + KeyCheck::Works + } else { + KeyCheck::Rejected + }; + } + KeyCheck::Unanswered(format!("ElevenLabs answered {}", status.as_u16())) +} + +/// `base` without its trailing slash when its host is this machine. +pub(super) fn loopback(base: &str) -> Option { + let url = reqwest::Url::parse(base).ok()?; + let host = url.host_str()?; + let local = host.eq_ignore_ascii_case("localhost") + || host + .trim_matches(|c| c == '[' || c == ']') + .parse::() + .is_ok_and(|ip| ip.is_loopback()); + local.then(|| base.trim_end_matches('/').to_string()) +} + +/// The API host for this run: a local `--base-url` (for testing against a +/// local backend), else the configured residency. A key and a bearer token +/// go to this host, so, like the token endpoint, nothing else may redirect it. +pub(super) fn api_base(ctx: &AppContext) -> String { + ctx.base_url_override() + .and_then(loopback) + .unwrap_or_else(|| settings::base_url_for(&settings::read_residency()).to_string()) +} diff --git a/cli/elevenlabs/workflow/onboard/browser.rs b/cli/elevenlabs/workflow/onboard/browser.rs new file mode 100644 index 0000000..50e4f0e --- /dev/null +++ b/cli/elevenlabs/workflow/onboard/browser.rs @@ -0,0 +1,34 @@ +//! Whether a browser this process opens lands on this machine, so the +//! approval page's redirect and clipboard can reach the CLI. + +use std::path::Path; + +/// False over SSH, in a container, or on Linux with no display. +pub(super) fn reachable_in( + os: &str, + var: &dyn Fn(&str) -> Option, + in_container: bool, +) -> bool { + let set = |name: &str| var(name).is_some_and(|v| !v.trim().is_empty()); + if set("SSH_CONNECTION") || set("SSH_TTY") || set("SSH_CLIENT") { + return false; + } + if in_container || set("CODESPACES") || set("REMOTE_CONTAINERS") { + return false; + } + os != "linux" || set("WSL_DISTRO_NAME") || set("DISPLAY") || set("WAYLAND_DISPLAY") +} + +pub(super) fn reachable() -> bool { + let in_container = Path::new("/.dockerenv").exists() + || std::fs::read_to_string("/proc/1/cgroup").is_ok_and(|c| { + ["docker", "containerd", "kubepods"] + .iter() + .any(|m| c.contains(m)) + }); + reachable_in( + std::env::consts::OS, + &|n| std::env::var(n).ok(), + in_container, + ) +} diff --git a/cli/elevenlabs/workflow/onboard/check.rs b/cli/elevenlabs/workflow/onboard/check.rs new file mode 100644 index 0000000..d73eadb --- /dev/null +++ b/cli/elevenlabs/workflow/onboard/check.rs @@ -0,0 +1,265 @@ +//! `onboard check` (also `onboard status`): the one way to verify the setup. +//! It stops at the first problem and says how to fix it, fixing on its own +//! only what is safe: the `.gitignore` entry and the file's permissions. + +use fern_cli_sdk::error::CliError; +use fern_cli_sdk::openapi::AppContext; + +use super::auth::{check_key, KeyCheck}; +use super::connect; +use super::env_file::{self, API_KEY_ENV}; +use super::manual::{self, NO_BROWSER}; +use super::project::{last4, shell_arg, Key, Project}; +use super::reply::{Next, Reply}; +use super::session::Session; + +pub(super) fn handle(matches: &clap::ArgMatches, ctx: &AppContext) -> Result { + let answered = matches + .try_get_one::("answered") + .ok() + .flatten() + .copied() + .unwrap_or(false); + verify(ctx, &Project::open(matches)?, answered) +} + +/// The whole check, for `check` and for `init` when the project already has a +/// key, so the next step comes straight from it. `answered`: the developer has +/// said what to build (`--answered`, which the question's `run` passes). +pub(super) fn verify(ctx: &AppContext, project: &Project, answered: bool) -> Result { + let Some(key) = project.key() else { + return no_key(project); + }; + if let Some(reply) = key_problem(ctx, project, &key)? { + return Ok(reply); + } + let mut fixed = Vec::new(); + if let Some(file) = key.file(project) { + let name = project.show(file); + if env_file::is_tracked(file) { + return Ok(Reply::new( + Next::Fix, + format!( + "git tracks {name}, so the key could be committed. Untrack it with `git rm --cached {}` \ + (the file stays on disk). If it was ever committed and pushed, replace the key too.", + shell_arg(&name) + ), + ) + .run(&project.next("check"))); + } + match env_file::ensure_ignored(file) { + Ok(true) => fixed.push(format!("added {name} to .gitignore")), + Ok(false) => {} + Err(e) => return Ok(Reply::new(Next::Fix, e.to_string()).run(&project.next("check"))), + } + if env_file::make_private(file)? { + fixed.push(format!("made {name} readable only by you")); + } + } + let declared = project.sdk_declared(); + let imported = project.sdk_imported(); + let ending = last4(&key.value); + let key_file = key.file(project).map(|f| project.show(f)); + let wired = declared && imported; + let file = key_file.as_deref(); + let reply = match (wired, answered) { + (true, _) => offer_test(project, &ending, file), + (false, false) => ask_what_to_build(project, &ending, file, "Your key works."), + (false, true) => write_code(project, &ending, file)?, + }; + Ok(reply + .detail("api_key_source", key.source_name()) + .detail("sdk_declared", declared) + .detail("sdk_imported", imported) + .detail("fixed", fixed)) +} + +/// Everything is in place: offer the test request. +fn offer_test(project: &Project, ending: &str, key_file: Option<&str>) -> Reply { + let location = key_file.unwrap_or("your shell"); + Reply::new( + Next::OfferTest, + format!( + "ElevenLabs is set up: a working key ending in {ending} in {location}, and the SDK in your code. \ + Want me to send a test request? It makes a short text-to-speech clip with your key and uses a few credits." + ), + ) + .run(&project.next("test")) + .detail("key_last4", ending) +} + +/// Asked every time, so the agent never picks a product for the developer. +pub(super) const WHAT_TO_BUILD: &str = + "What would you like to build with ElevenLabs, or should I just add the SDK for now?"; + +/// The ElevenLabs skills, the whole repo in one download. +const SKILLS_ARCHIVE: &str = "https://github.com/elevenlabs/skills/archive/refs/heads/main.tar.gz"; + +/// The question, from `connect` or `check`, whenever the code doesn't use the +/// SDK yet: `say` ends with [`WHAT_TO_BUILD`]. Its `run` passes `--answered`, +/// so the next reply, right after the answer, is how to write the code. +/// `key_file` is the file the key is in, as the developer knows it; `None` +/// when it is set in the shell. +pub(super) fn ask_what_to_build(project: &Project, key_last4: &str, key_file: Option<&str>, say: &str) -> Reply { + let reply = Reply::new(Next::AskWhatToBuild, format!("{say} {WHAT_TO_BUILD}")) + .run(&project.next("check --answered")) + .detail("key_last4", key_last4); + match key_file { + Some(file) => reply.detail("env_file", file), + None => reply, + } +} + +/// What the agent needs to write the integration once the developer has said +/// what they want: the SDK, a new temp folder for the skills, and the key. +fn write_code(project: &Project, key_last4: &str, key_file: Option<&str>) -> Result { + let dir = skills_dir()?; + let reply = Reply::new( + Next::WriteCode, + "Next I'll add the ElevenLabs SDK to the project and use it in the code.", + ) + .detail("sdk_install", project.sdk_install()) + .detail("skills_dir", dir.display().to_string()) + .detail("fetch_skills", fetch_skills(&dir, cfg!(windows))); + // Still `--answered`: code that doesn't use the SDK yet is no reason to ask again. The rules + // come again with the code, a turn after the reply that started the flow. + Ok(key_details(reply, project, key_last4, key_file, "check --answered").with_rules()) +} + +/// A first key for an app that already uses the SDK: nothing to build, only +/// the key to load. +pub(super) fn load_key(project: &Project, key_last4: &str, key_file: &str, say: String) -> Reply { + key_details(Reply::new(Next::LoadKey, say), project, key_last4, Some(key_file), "check").with_rules() +} + +/// Where the key is and how the app gets it, then `step`. +fn key_details(reply: Reply, project: &Project, key_last4: &str, key_file: Option<&str>, step: &str) -> Reply { + let found = match key_file { + Some(file) => format!("It's in {file}. {}", project.loading(file)), + None => "It's set in your shell, so the app gets it from the environment.".into(), + }; + let reply = reply + .run(&project.next(step)) + .detail("key_last4", key_last4) + .detail( + "load_key", + format!( + "The SDK reads {API_KEY_ENV} from the environment; never put the key in the code. {found}" + ), + ); + match key_file { + Some(file) => reply.detail("env_file", file), + None => reply, + } +} + +/// A new folder for the skills in the system's temp folder, never the +/// project, so nothing lands in the developer's repo. Its name is random, so +/// no one can have put anything there first. +fn skills_dir() -> Result { + tempfile::Builder::new() + .prefix("elevenlabs-skills-") + .tempdir() + .map(tempfile::TempDir::keep) + .map_err(|e| CliError::Other(anyhow::anyhow!("create a folder for the ElevenLabs skills: {e}"))) +} + +/// The command that downloads the skills into `dir`, one folder per skill. +/// Two commands, not `curl | tar`: Windows PowerShell 5.1 corrupts binary +/// pipes, and its `curl` is another program, hence `curl.exe`. +pub(super) fn fetch_skills(dir: &std::path::Path, windows: bool) -> String { + let archive = dir.join("skills.tar.gz"); + if windows { + let (archive, dir) = (archive.display(), dir.display()); + format!( + "curl.exe -fsSL -o \"{archive}\" {SKILLS_ARCHIVE}; tar -xzf \"{archive}\" -C \"{dir}\" --strip-components=1" + ) + } else { + let archive = shell_arg(&archive.display().to_string()); + let dir = shell_arg(&dir.display().to_string()); + format!( + "curl -fsSL -o {archive} {SKILLS_ARCHIVE} && tar -xzf {archive} -C {dir} --strip-components=1" + ) + } +} + +/// No key anywhere: the developer is mid-paste, or it's time to sign in. +pub(super) fn no_key(project: &Project) -> Result { + // A sign-in is still open for this project: wait on it, don't start another. + // (No home folder means no sign-in could have started.) + if let Ok(session) = Session::for_project(&project.dir) { + if session.running() { + return Ok(connect::waiting(&session, project, false)); + } + } + let file = &project.env_display; + let placeholder = env_file::read(&project.env_file) + .ok() + .and_then(|c| env_file::file_key(&c)) + .is_some(); + if placeholder { + return Ok(Reply::new( + Next::AskUser, + format!( + "{API_KEY_ENV} in {file} is still empty. Paste the key after the = (never into this chat), and tell me when it's done." + ), + ) + .run(&project.next("check"))); + } + if project.remote() { + return manual::reply(project, &project.env_file, NO_BROWSER); + } + Ok(Reply::new( + Next::Run, + format!( + "There's no ElevenLabs API key in this project yet, so {} to create one.", + project.approval() + ), + ) + .run(&project.next("connect"))) +} + +/// `None` when the key works; otherwise what to do about it. Shared with `init`. +pub(super) fn key_problem( + ctx: &AppContext, + project: &Project, + key: &Key, +) -> Result, CliError> { + let ending = last4(&key.value); + Ok(match check_key(ctx, &key.value) { + KeyCheck::Works => None, + KeyCheck::Unanswered(why) => Some( + Reply::new( + Next::AskUser, + format!("I couldn't check your key: {why}. Tell me when you'd like me to try again."), + ) + .run(&project.next("check")), + ), + KeyCheck::Rejected => Some(match key.file(project) { + None => Reply::new( + Next::AskUser, + format!( + "{API_KEY_ENV} is set in your shell (ending in {ending}), and ElevenLabs rejects it. Unset it \ + (and remove it from your shell profile), then tell me to check again." + ), + ) + .run(&project.next("check")), + Some(file) if project.remote() => manual::reply( + project, + file, + &format!("ElevenLabs rejects the key ending in {ending}."), + )?, + Some(file) => Reply::new( + Next::Run, + format!( + "ElevenLabs rejects the key ending in {ending}, so {} to create a new one.", + project.approval() + ), + ) + .run(&project.next(&format!( + "connect --replace --env-file {}", + shell_arg(&project.show(file)) + ))), + }), + }) +} diff --git a/cli/elevenlabs/workflow/onboard/clipboard.rs b/cli/elevenlabs/workflow/onboard/clipboard.rs new file mode 100644 index 0000000..a21b9f0 --- /dev/null +++ b/cli/elevenlabs/workflow/onboard/clipboard.rs @@ -0,0 +1,104 @@ +//! What the approval page left on the clipboard. + +/// The page's answer when it did not copy a key, written as `elevenlabs:`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Marker { + /// This account can't create keys this way (the experiment is off for it). + NotAvailable, + /// The member's workspace role can't create keys. + NotPermitted, + /// No key was handed over: the page couldn't create one (a refused + /// request, a limit, an outage), or the developer continued without it. + Failed, +} + +impl Marker { + const ALL: [Marker; 3] = [Marker::NotAvailable, Marker::NotPermitted, Marker::Failed]; + + pub(super) fn status(self) -> &'static str { + match self { + Marker::NotAvailable => "not_available", + Marker::NotPermitted => "not_permitted", + Marker::Failed => "failed", + } + } + + /// The exact clipboard text the page writes. + pub(super) fn text(self) -> String { + format!("elevenlabs:{}", self.status()) + } +} + +/// A key, one of the page's markers, or noise. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum Clip { + Key(String), + Marker(Marker), + Nothing, +} + +pub(super) fn classify(text: Option) -> Clip { + let text = text.map(|t| t.trim().to_string()).unwrap_or_default(); + if is_key(&text) { + return Clip::Key(text); + } + Marker::ALL + .into_iter() + .find(|m| m.text() == text) + .map_or(Clip::Nothing, Clip::Marker) +} + +/// `sk_`, at least 20 hex digits, then only lowercase letters, digits and +/// underscores (residency keys carry a suffix). Other vendors' `sk_` keys +/// have uppercase or non-hex segments and are left alone. +fn is_key(text: &str) -> bool { + let Some(rest) = text.strip_prefix("sk_") else { + return false; + }; + let hex = rest + .chars() + .take_while(|c| c.is_ascii_digit() || ('a'..='f').contains(c)) + .count(); + hex >= 20 + && rest + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') +} + +/// Set to keep the system clipboard out of the CLI's own end-to-end tests. +pub(super) const NO_CLIPBOARD_ENV: &str = "ELEVENLABS_ONBOARD_NO_CLIPBOARD"; + +/// The system clipboard; `None` where there is none to read. +pub(super) struct Clipboard(Option); + +impl Clipboard { + pub(super) fn open() -> Self { + if std::env::var_os(NO_CLIPBOARD_ENV).is_some() { + return Self(None); + } + Self(arboard::Clipboard::new().ok()) + } + + pub(super) fn read(&mut self) -> Clip { + classify(self.0.as_mut().and_then(|c| c.get_text().ok())) + } + + /// Clear the clipboard if it still holds `value`; the user may have copied + /// something else since. + pub(super) fn clear_if(&mut self, value: &str) { + if let Some(c) = self.0.as_mut() { + if c.get_text().is_ok_and(|t| t.trim() == value) { + let _ = c.clear(); + } + } + } + + /// Clear a key left behind by a sign-in that did not finish. Nothing + /// vouches for that key (no token to check it against), so it is never + /// stored; leaving it on the clipboard would only let it leak. + pub(super) fn discard_key(&mut self) { + if let Clip::Key(key) = self.read() { + self.clear_if(&key); + } + } +} diff --git a/cli/elevenlabs/workflow/onboard/connect.rs b/cli/elevenlabs/workflow/onboard/connect.rs new file mode 100644 index 0000000..e5a66bd --- /dev/null +++ b/cli/elevenlabs/workflow/onboard/connect.rs @@ -0,0 +1,362 @@ +//! `onboard connect`: sign in through the browser and store the API key the +//! approval page hands over on the clipboard. The sign-in runs in a +//! background [`Session`], so a slow approval never depends on how long the +//! agent lets a command run. + +use std::time::Duration; + +use fern_cli_sdk::error::CliError; +use fern_cli_sdk::openapi::AppContext; +use serde_json::Value; + +use super::super::util::opt_string; +use super::auth; +use super::check::{ask_what_to_build, load_key}; +use super::clipboard::{Clip, Clipboard, Marker}; +use super::env_file::{self, API_KEY_ENV}; +use super::manual::{self, NO_BROWSER}; +use super::project::{last4, shell_arg, KeySource, Project}; +use super::reply::{command, Next, Reply}; +use super::session::{Session, LAUNCH_FLAG, SIGN_IN_MINUTES, WORKER_FLAG}; + +/// How long one `connect` waits for the approval before handing back to the +/// agent; well under the time agents let a command run. +pub(super) const WAIT: Duration = Duration::from_secs(25); + +/// `None` for the launcher and the worker, which print nothing. +pub(super) fn handle( + matches: &clap::ArgMatches, + ctx: &AppContext, +) -> Result, CliError> { + let project = Project::open(matches)?; + let session = Session::for_project(&project.dir)?; + let replace = matches.get_flag("replace"); + if matches.get_flag(LAUNCH_FLAG) { + session.launch_worker()?; + return Ok(None); + } + if matches.get_flag(WORKER_FLAG) { + // Another worker already signing in for this project wins. + let Some(lock) = session.take()? else { + return Ok(None); + }; + // The browser the sign-in opens reads this environment, and by now the + // runtime has loaded the project's `.env` into it. That also drops the + // request tag `dispatch` set, so it is set again. + for key in super::added_since_startup() { + std::env::remove_var(key); + } + let _scope = super::super::api::command_scope("onboard.connect"); + let reply = sign_in_and_store(matches, ctx, &project, || session.signed_in(&lock)) + .unwrap_or_else(|e| failed(&project, &e.to_string(), replace)); + session.finish(&lock, &reply.to_value())?; + return Ok(None); + } + let waiting = matches.get_flag("wait"); + if !waiting { + if let Some(reply) = preflight(matches, &project, replace)? { + return Ok(Some(reply.to_value())); + } + // Waits on the sign-in already running for this request, if there is one. + session.start(&format!("{}\n{replace}", project.env_file.display()))?; + } + // Without a browser, the developer needs the link as soon as there is one. + let link_first = !waiting && project.no_browser; + Ok(Some(wait(&session, &project, WAIT, replace, link_first))) +} + +fn connect_step(replace: bool) -> &'static str { + if replace { + "connect --replace" + } else { + "connect" + } +} + +/// Everything that can be refused before a browser opens. +fn preflight( + matches: &clap::ArgMatches, + project: &Project, + replace: bool, +) -> Result, CliError> { + if let Some(problem) = project.env_file_problem() { + return Ok(Some( + Reply::new(Next::Fix, problem).run(&project.next(connect_step(replace))), + )); + } + authorize_url(matches)?; + if let Some(key) = project.key() { + match (&key.source, replace) { + (_, false) => { + return Ok(Some( + Reply::new( + Next::Run, + format!( + "This project already has an ElevenLabs key (ending in {}).", + last4(&key.value) + ), + ) + .run(&project.next("check")), + )) + } + (KeySource::Shell, true) => { + return Ok(Some( + Reply::new( + Next::AskUser, + format!( + "{API_KEY_ENV} is set in your shell, and it wins over any env file. Unset it \ + (and remove it from your shell profile), then tell me to try again." + ), + ) + .run(&project.next("connect --replace")), + )) + } + // The new key has to replace the one in the `.env` loaders read. + (KeySource::Dotenv(path), true) => { + let file = project.show(path); + return Ok(Some( + Reply::new(Next::Run, format!("The key is in {file}, so the new one goes there.")) + .run(&project.next(&format!("connect --replace --env-file {}", shell_arg(&file)))), + )); + } + (KeySource::EnvFile, true) => {} + } + } + if project.remote() { + return manual::reply(project, &project.env_file, NO_BROWSER).map(Some); + } + Ok(None) +} + +/// The worker's reply, or what to do while there is none yet. `link_first` +/// returns as soon as the approval link is known. +pub(super) fn wait( + session: &Session, + project: &Project, + budget: Duration, + replace: bool, + link_first: bool, +) -> Value { + let retry = project.next(connect_step(replace)); + if let Some(mut result) = session.wait(budget, |s| link_first && s.url().is_some()) { + // Written by another project's `connect`, which can't know this one's flags. + if result["details"]["key_status"] == "sign_in_replaced" { + result["run"] = command(&retry).into(); + } + return result; + } + if session.running() { + return waiting(session, project, replace).to_value(); + } + Reply::new( + Next::Run, + "The sign-in isn't running anymore, so I'll start it again.", + ) + .run(&retry) + .to_value() +} + +/// The approval is still open: wait on it, with the link in case no tab opened. +pub(super) fn waiting(session: &Session, project: &Project, replace: bool) -> Reply { + let link = session + .url() + .map(|url| format!(" If no browser tab opened, open this link: {url}")) + .unwrap_or_default(); + Reply::new( + Next::Wait, + format!("Waiting for you to approve ElevenLabs in the browser.{link}"), + ) + .run(&project.next(&format!("{} --wait", connect_step(replace)))) +} + +/// Only a local page may replace production: the developer types a password there. +fn authorize_url(matches: &clap::ArgMatches) -> Result { + match opt_string(matches, "authorize-url") { + None => Ok(auth::sign_in_config()?.authorize_url.clone()), + Some(url) if auth::loopback(&url).is_some() => Ok(url), + Some(_) => Err(CliError::Validation( + "--authorize-url must point at a local (loopback) page.".into(), + )), + } +} + +/// What the approval page left on the clipboard came to. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum Outcome { + /// A key for the signed-in account: store it. + Store(String), + /// The workspace role can't create keys: an admin has to. + NotPermitted, + /// The developer adds a key by hand. `status` names the case; `why` explains it. + Manual { + status: &'static str, + why: &'static str, + }, +} + +/// `verdict` is whether a key on the clipboard belongs to the signed-in account. +pub(super) fn outcome_of( + clip: Clip, + verdict: impl FnOnce(&str) -> Result, +) -> Outcome { + match clip { + Clip::Key(key) => match verdict(&key) { + Ok(true) => Outcome::Store(key), + Ok(false) => Outcome::Manual { + status: "clipboard_invalid", + why: "the key on the clipboard belongs to a different account", + }, + Err(_) => Outcome::Manual { + status: "could_not_verify", + why: "the key couldn't be checked, so it wasn't stored", + }, + }, + Clip::Marker(Marker::NotPermitted) => Outcome::NotPermitted, + Clip::Marker(Marker::NotAvailable) => Outcome::Manual { + status: Marker::NotAvailable.status(), + why: "this account can't create a key this way", + }, + Clip::Marker(Marker::Failed) => Outcome::Manual { + status: Marker::Failed.status(), + why: "the approval page didn't hand a key over", + }, + Clip::Nothing => Outcome::Manual { + status: "no_key", + why: "no key reached this command", + }, + } +} + +/// The worker: sign in, then move the key from the clipboard into the env file. +fn sign_in_and_store( + matches: &clap::ArgMatches, + ctx: &AppContext, + project: &Project, + on_signed_in: impl FnOnce(), +) -> Result { + let client = ctx.http_config().build_client()?; + let signed_in = auth::sign_in(ctx, &authorize_url(matches)?, project.no_browser); + if let Err(error) = signed_in { + // The page may have copied a key before the callback failed; nothing + // can vouch for it now, so it is cleared, not stored. + Clipboard::open().discard_key(); + return Ok(failed( + project, + &error.to_string(), + matches.get_flag("replace"), + )); + } + on_signed_in(); + let mut clipboard = Clipboard::open(); + let clip = clipboard.read(); + let copied = match &clip { + Clip::Key(key) => key.clone(), + Clip::Marker(m) => m.text(), + Clip::Nothing => String::new(), + }; + let outcome = outcome_of(clip, |key| { + auth::key_matches_sign_in(&client, &auth::api_base(ctx), key) + }); + let reply = reply_for(project, &outcome); + // Whatever the outcome, and even if storing failed, a key never stays on the clipboard. + clipboard.clear_if(&copied); + reply +} + +/// What the agent is told for each outcome, after storing the key if there is one. +pub(super) fn reply_for(project: &Project, outcome: &Outcome) -> Result { + let path = &project.env_file; + // Read now: the file may have changed while the browser was open. + let existing = env_file::read(path)?; + let old_key = env_file::file_key(&existing).filter(|k| !k.is_empty()); + let file = &project.env_display; + Ok(match outcome { + Outcome::Store(key) => { + env_file::write(path, &env_file::with_key(&existing, key))?; + // The key is stored either way; `check` re-applies this and reports a failure. + let gitignored = env_file::ensure_ignored(path).is_ok(); + let say = format!( + "Signed in. Your new key ending in {} is in {file}, and your clipboard was cleared.", + last4(key) + ); + // An app that already uses the SDK has nothing to build: a replaced + // key goes where the app already reads it, and a first one only + // needs loading. + let wired = project.sdk_declared() && project.sdk_imported(); + let reply = match (wired, old_key.is_some()) { + (true, true) => Reply::new(Next::Run, say).run(&project.next("check")), + (true, false) => load_key(project, &last4(key), file, say), + (false, _) => ask_what_to_build(project, &last4(key), Some(file), &say), + }; + reply + .with_rules() + .detail("key_status", "created") + .detail("gitignored", gitignored) + } + Outcome::NotPermitted => { + manual::prepare(path)?; + Reply::new( + Next::AskUser, + format!( + "You're signed in, but your workspace role can't create API keys. Ask a workspace admin for a key, \ + paste it after {API_KEY_ENV}= in {file} (never into this chat), and tell me when it's done." + ), + ) + .run(&project.next("check")) + .detail("key_status", "not_permitted") + .detail("env_file", file) + } + Outcome::Manual { status, why } => { + let kept = old_key + .map(|k| { + format!( + " The old key (ending in {}) is still in {file}; replace it.", + last4(&k) + ) + }) + .unwrap_or_default(); + manual::reply( + project, + path, + &format!("You're signed in, but {why}.{kept}"), + )? + .detail("key_status", *status) + } + }) +} + +/// A sign-in that ended before the clipboard step. `error` is the sign-in +/// flow's message; a declined approval comes back as an `access_denied` error. +pub(super) fn failed(project: &Project, error: &str, replace: bool) -> Reply { + let retry = project.next(connect_step(replace)); + let reply = if error.contains("returned error: access_denied") { + Reply::new( + Next::AskUser, + format!( + "The approval was declined. Tell me if you want to try again, or create a key at {} and paste it \ + after {API_KEY_ENV}= in {} yourself (never into this chat).", + manual::API_KEYS_PAGE, + project.env_display + ), + ) + } else if error.contains("Timed out waiting") { + Reply::new( + Next::Run, + format!( + "The approval page wasn't completed within {SIGN_IN_MINUTES} minutes, so {} again.", + project.approval() + ), + ) + } else { + Reply::new( + Next::Run, + format!( + "The sign-in didn't finish ({error}). If it fails again, create a key at {} and paste it after \ + {API_KEY_ENV}= in {} instead.", + manual::API_KEYS_PAGE, + project.env_display + ), + ) + }; + reply.run(&retry).detail("key_status", "sign_in_failed") +} diff --git a/cli/elevenlabs/workflow/onboard/env_file.rs b/cli/elevenlabs/workflow/onboard/env_file.rs new file mode 100644 index 0000000..a19a06c --- /dev/null +++ b/cli/elevenlabs/workflow/onboard/env_file.rs @@ -0,0 +1,295 @@ +//! The env file the key goes into, and keeping it out of git. + +use std::path::Path; +use std::process::Command; + +use fern_cli_sdk::error::CliError; + +pub(super) const API_KEY_ENV: &str = "ELEVENLABS_API_KEY"; +pub(super) const DEFAULT_ENV_FILE: &str = ".env.local"; +pub(super) const PLACEHOLDER_NOTE: &str = + "# Paste your ElevenLabs API key after the = below. Create one at \ + https://elevenlabs.io/app/settings/api-keys if you need to. Never paste it into a chat."; + +/// The line without a leading `export`, when it is a key line. +fn key_line(line: &str) -> Option<&str> { + let line = line + .trim_start() + .strip_prefix('\u{feff}') + .unwrap_or(line.trim_start()); + let line = match line.strip_prefix("export") { + Some(rest) if rest.starts_with(char::is_whitespace) => rest.trim_start(), + _ => line, + }; + line.strip_prefix(API_KEY_ENV) + .filter(|rest| rest.trim_start().starts_with('=')) + .map(|_| line) +} + +/// `Some("")` for an empty placeholder line, `None` when there is no line. +/// The last line wins, as it does for the app's dotenv loaders. +pub(super) fn file_key(contents: &str) -> Option { + file_keys(contents).pop() +} + +/// The value of every key line, in order. Lines are found as [`with_key`] +/// finds them, so reading and writing always agree on which line is the key; +/// only the value is parsed by the runtime's loader (quotes, comments). +fn file_keys(contents: &str) -> Vec { + let lines: Vec<&str> = contents.lines().collect(); + lines + .iter() + .enumerate() + .filter_map(|(i, l)| key_line(l).map(|line| line_value(line, &lines[..i]))) + .collect() +} + +/// A key line's value as dotenv loaders read it, or the text after `=` when +/// the loader rejects the line. A `$` is filled in from the earlier lines +/// that set other variables and parse on their own. +fn line_value(line: &str, earlier: &[&str]) -> String { + let mut text = String::new(); + if line.contains('$') { + // Trimmed as the key line is: a trailing space would fail the line alone. + let trimmed = earlier + .iter() + .map(|l| l.trim_start_matches('\u{feff}').trim_end()); + let parses = |l: &&str| dotenvy::from_read_iter(l.as_bytes()).all(|r| r.is_ok()); + for l in trimmed.filter(|l| key_line(l).is_none()).filter(parses) { + text.push_str(l); + text.push('\n'); + } + } + text.push_str(line.trim_end()); + dotenvy::from_read_iter(text.as_bytes()) + .flatten() + .filter(|(key, _)| key == API_KEY_ENV) + .map(|(_, value)| value) + .last() + .unwrap_or_else(|| { + line.split_once('=') + .map_or("", |(_, v)| v) + .trim() + .to_string() + }) +} + +/// `contents` with the (last) key line set to `value`, keeping its `export` +/// and the file's line endings; appended when there is none. +pub(super) fn with_key(contents: &str, value: &str) -> String { + let newline = if contents.contains("\r\n") { + "\r\n" + } else { + "\n" + }; + let mut lines: Vec = contents.lines().map(str::to_string).collect(); + match lines.iter().rposition(|l| key_line(l).is_some()) { + Some(i) => { + let export = if lines[i].trim_start().starts_with("export") { + "export " + } else { + "" + }; + lines[i] = format!("{export}{API_KEY_ENV}={value}"); + } + None => { + if value.is_empty() { + lines.push(PLACEHOLDER_NOTE.to_string()); + } + lines.push(format!("{API_KEY_ENV}={value}")); + } + } + lines.join(newline) + newline +} + +/// Only a missing file counts as empty; never overwrite a file we could not read. +pub(super) fn read(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(c) => Ok(c), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), + Err(e) => Err(CliError::Other(anyhow::anyhow!( + "read {}: {e}", + path.display() + ))), + } +} + +/// Write the whole file owner-only and atomically: a temp file next to it, +/// then a rename, so a crash never leaves it truncated. Rename replaces a +/// symlink rather than following it. Also used for the sign-in session's files. +pub(super) fn write(path: &Path, contents: &str) -> Result<(), CliError> { + let (dir, name) = split(path); + let tmp = dir.join(format!(".{name}.{}.tmp", std::process::id())); + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let result = (|| { + let mut file = opts.open(&tmp)?; + std::io::Write::write_all(&mut file, contents.as_bytes())?; + file.sync_all()?; + std::fs::rename(&tmp, path) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&tmp); + } + result.map_err(|e| CliError::Other(anyhow::anyhow!("write {}: {e}", path.display()))) +} + +/// The file's directory and name, for git and for the temp file. +fn split(path: &Path) -> (std::path::PathBuf, String) { + let dir = match path.parent() { + Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(), + _ => Path::new(".").to_path_buf(), + }; + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| DEFAULT_ENV_FILE.to_string()); + (dir, name) +} + +/// Make a regular file owner-only; `true` when that changed something. +/// A symlink is left alone: its target belongs to whoever made it. +pub(super) fn make_private(path: &Path) -> Result { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let Ok(meta) = std::fs::symlink_metadata(path) else { + return Ok(false); + }; + if !meta.is_file() || meta.permissions().mode() & 0o077 == 0 { + return Ok(false); + } + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| CliError::Other(anyhow::anyhow!("chmod {}: {e}", path.display())))?; + Ok(true) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(false) + } +} + +pub(super) fn is_symlink(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) +} + +/// The `.env` a dotenv loader would read from `cwd` (the nearest one in the +/// directory or its ancestors, as `dotenvy::dotenv()` searches) and its key, +/// as [`file_key`] reads it. `None` when there is no such file. +pub(super) fn nearest_dotenv(cwd: &Path) -> Option<(std::path::PathBuf, Option)> { + let path = cwd + .ancestors() + .map(|dir| dir.join(".env")) + .find(|p| p.is_file())?; + let key = file_key(&std::fs::read_to_string(&path).ok()?); + Some((path, key)) +} + +pub(super) fn is_tracked(path: &Path) -> bool { + git(path, &["ls-files", "--error-unmatch"]) == Some(true) +} + +/// Make git ignore the file, adding its name to the `.gitignore` in its +/// directory unless git already ignores it. `Ok(true)` when it added the line; +/// `Ok(false)` when nothing was needed (already ignored, or not a repository). +/// An error when git still doesn't ignore the file afterwards. +pub(super) fn ensure_ignored(path: &Path) -> Result { + if git(path, &["check-ignore", "-q"]) != Some(false) { + return Ok(false); + } + let (dir, name) = split(path); + let gitignore_path = dir.join(".gitignore"); + let not_ignored = || { + CliError::Validation(format!( + "git doesn't ignore {name}, so the key could be committed. Add `/{}` to {}.", + gitignore_pattern(&name), + gitignore_path.display() + )) + }; + // Never write through a symlinked .gitignore; the repository owns that file. + if is_symlink(&gitignore_path) { + return Err(not_ignored()); + } + let mut gitignore = match std::fs::read_to_string(&gitignore_path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(e) => { + return Err(CliError::Other(anyhow::anyhow!( + "read {}: {e}", + gitignore_path.display() + ))) + } + }; + if !gitignore.is_empty() && !gitignore.ends_with('\n') { + gitignore.push('\n'); + } + let note = "# Local secrets written by `elevenlabs onboard`\n"; + if !gitignore.contains(note) { + gitignore.push_str(note); + } + gitignore.push_str(&format!("/{}\n", gitignore_pattern(&name))); + std::fs::write(&gitignore_path, gitignore) + .map_err(|e| CliError::Other(anyhow::anyhow!("write {}: {e}", gitignore_path.display())))?; + // Report what git now says, not what was written. + match git(path, &["check-ignore", "-q"]) { + Some(true) => Ok(true), + _ => Err(not_ignored()), + } +} + +/// `name` as a `.gitignore` pattern that matches only itself. +fn gitignore_pattern(name: &str) -> String { + let mut out = String::new(); + for (i, c) in name.chars().enumerate() { + if "\\[]*?".contains(c) || (i == 0 && "!#".contains(c)) { + out.push('\\'); + } + out.push(c); + } + // Trailing spaces are dropped unless escaped. + if out.ends_with(' ') { + out.pop(); + out.push_str("\\ "); + } + out +} + +/// git, with only the environment the shell gave this process. The runtime +/// loads the nearest `.env` at startup, and a repository must not configure +/// the git we run for it: `GIT_CONFIG_*`, `GIT_SSH_COMMAND`, `XDG_CONFIG_HOME` +/// and the like can make it run any command. The repository's own config is +/// read too, and its `core.fsmonitor` would run a command on every `ls-files` +/// and `check-ignore`, so it is switched off. +pub(super) fn git_command() -> Command { + let mut cmd = Command::new("git"); + cmd.args(["-c", "core.fsmonitor="]); + for key in super::added_since_startup() { + cmd.env_remove(key); + } + cmd +} + +/// git's yes (0) or no (1) about the file, asked from its own directory so the +/// right repository answers; `None` for anything else, such as no repository. +fn git(path: &Path, args: &[&str]) -> Option { + let (dir, name) = split(path); + let out = git_command() + .arg("-C") + .arg(&dir) + .args(args) + .arg("--") + .arg(&name) + .output() + .ok()?; + match out.status.code()? { + 0 => Some(true), + 1 => Some(false), + _ => None, + } +} diff --git a/cli/elevenlabs/workflow/onboard/init.rs b/cli/elevenlabs/workflow/onboard/init.rs new file mode 100644 index 0000000..7805c66 --- /dev/null +++ b/cli/elevenlabs/workflow/onboard/init.rs @@ -0,0 +1,55 @@ +//! `onboard init`: the first step. It checks the project and says what's next: +//! signing in, the manual key path on a remote machine, or, when the project +//! already has a key, whatever `check` says about it. + +use fern_cli_sdk::error::CliError; +use fern_cli_sdk::openapi::AppContext; +use super::check; +use super::connect; +use super::manual::{self, NO_BROWSER}; +use super::project::Project; +use super::reply::{Next, Reply}; +use super::session::Session; + +const SIGN_IN: &str = "Next, I'll open the ElevenLabs approval page in your browser. Sign in (or create an account) and click Authorize."; +const SIGN_IN_LINK: &str = "Next, I'll give you the link to the ElevenLabs approval page. Open it, sign in (or create an account) and click Authorize."; + +pub(super) fn handle(matches: &clap::ArgMatches, ctx: &AppContext) -> Result { + let project = Project::open(matches)?; + if let Some(problem) = project.env_file_problem() { + return Ok(Reply::new(Next::Fix, problem) + .run(&project.next("init")) + .with_rules()); + } + let reply = next_step(ctx, &project)?.with_rules(); + // A reply from `check` already names the file the key is in. + Ok(if reply.details.contains_key("env_file") { + reply + } else { + reply.detail("env_file", &project.env_display) + }) +} + +fn next_step(ctx: &AppContext, project: &Project) -> Result { + // A key already there: check it now, so what comes next (what to build, or + // the test) is this reply, not a `check` the agent could skip. + if project.key().is_some() { + return check::verify(ctx, project, false); + } + if project.remote() { + return manual::reply(project, &project.env_file, NO_BROWSER); + } + // A sign-in is still open for this project: wait on it, don't start another. + // (No home folder means no sign-in could have started.) + if let Ok(session) = Session::for_project(&project.dir) { + if session.running() { + return Ok(connect::waiting(&session, project, false)); + } + } + let say = if project.no_browser { + SIGN_IN_LINK + } else { + SIGN_IN + }; + Ok(Reply::new(Next::Run, say).run(&project.next("connect"))) +} diff --git a/cli/elevenlabs/workflow/onboard/manual.rs b/cli/elevenlabs/workflow/onboard/manual.rs new file mode 100644 index 0000000..6a6425a --- /dev/null +++ b/cli/elevenlabs/workflow/onboard/manual.rs @@ -0,0 +1,45 @@ +//! The manual key path: where the approval page can't hand a key over (a +//! remote machine, a refused copy, an account that can't create keys this +//! way), the developer creates one and pastes it into the env file. The file +//! is prepared first, so pasting is the only thing left to do. + +use std::path::Path; + +use fern_cli_sdk::error::CliError; + +use super::env_file::{self, API_KEY_ENV}; +use super::project::{shell_arg, Project}; +use super::reply::{Next, Reply}; + +pub(super) const API_KEYS_PAGE: &str = "https://elevenlabs.io/app/settings/api-keys"; + +pub(super) const NO_BROWSER: &str = "A browser can't reach this machine (an SSH session, a container or a cloud environment), so the approval page can't hand a key to it."; + +/// The manual steps for putting a key in `file` (the project's env file, or +/// the file that holds a rejected key). `why` opens the message. +pub(super) fn reply(project: &Project, file: &Path, why: &str) -> Result { + prepare(file)?; + let name = project.show(file); + Ok(Reply::new( + Next::AskUser, + format!( + "{why} Please add a key by hand:\n\ + 1. Create a key at {API_KEYS_PAGE}.\n\ + 2. Paste it after {API_KEY_ENV}= in {name}, or set {API_KEY_ENV} in this environment's secrets.\n\ + 3. Never paste it into this chat. Tell me when it's done." + ), + ) + .run(&project.next(&format!("check --env-file {}", shell_arg(&name)))) + .detail("env_file", name)) +} + +/// An empty key line with a note above it, and the file gitignored if it can +/// be; when it can't, `check` stops on that before the key is used. +pub(super) fn prepare(file: &Path) -> Result<(), CliError> { + let existing = env_file::read(file)?; + if env_file::file_key(&existing).is_none() { + env_file::write(file, &env_file::with_key(&existing, ""))?; + } + let _ = env_file::ensure_ignored(file); + Ok(()) +} diff --git a/cli/elevenlabs/workflow/onboard/mod.rs b/cli/elevenlabs/workflow/onboard/mod.rs new file mode 100644 index 0000000..cc6edac --- /dev/null +++ b/cli/elevenlabs/workflow/onboard/mod.rs @@ -0,0 +1,155 @@ +//! `elevenlabs onboard`: set ElevenLabs up in a project from a coding agent. +//! Each step prints what the agent does next (see [`reply`]), so the flow is +//! driven by the CLI rather than by instructions the agent has to remember: +//! +//! - `init`: check the project, say what's next +//! - `connect`: sign in in the browser and store an API key for the project +//! - `check`: confirm the key and the integration (`status` is an alias) +//! - `test`: a short text-to-speech request, only when the developer says yes + +mod auth; +mod browser; +mod check; +mod clipboard; +mod connect; +mod env_file; +mod init; +mod manual; +mod project; +mod reply; +mod session; +mod test_request; +#[cfg(test)] +mod tests; + +use std::collections::HashMap; +use std::ffi::OsString; +use std::sync::OnceLock; + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::error::CliError; +use fern_cli_sdk::openapi::AppContext; + +use super::api; +use super::util::downcast_ctx; + +fn dispatch(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliError> { + let (name, sub) = matches + .subcommand() + .ok_or_else(|| CliError::Validation("Start with `elevenlabs onboard init`.".into()))?; + let _scope = api::command_scope(match name { + "init" => "onboard.init", + "connect" => "onboard.connect", + "check" => "onboard.check", + _ => "onboard.test", + }); + let reply = match name { + "init" => Some(init::handle(sub, ctx)?.to_value()), + "connect" => connect::handle(sub, ctx)?, + "check" => Some(check::handle(sub, ctx)?.to_value()), + _ => Some(test_request::handle(sub, ctx)?.to_value()), + }; + match reply { + Some(reply) => reply::emit(&reply), + None => Ok(()), + } +} + +fn env_file_arg() -> clap::Arg { + clap::Arg::new("env-file") + .long("env-file") + .value_name("PATH") + .help("The env file for ELEVENLABS_API_KEY (default: .env for Python projects, else .env.local)") +} + +fn flag(id: &'static str, help: &'static str) -> clap::Arg { + clap::Arg::new(id) + .long(id) + .action(clap::ArgAction::SetTrue) + .help(help) +} + +fn no_browser_arg() -> clap::Arg { + flag( + "no-browser", + "Don't open a browser; the reply gives the approval link to open", + ) +} + +fn onboard_command() -> clap::Command { + clap::Command::new("onboard") + // Run by coding agents, not typed by people: kept out of `--help`. + .hide(true) + .about("Set up ElevenLabs in this project from a coding agent. Start with `onboard init`.") + .subcommand_required(true) + .arg_required_else_help(true) + .subcommand( + clap::Command::new("init") + .about("Check the project and say what's next") + .arg(env_file_arg()) + .arg(no_browser_arg()), + ) + .subcommand( + clap::Command::new("connect") + .about("Sign in in the browser and store an API key for this project") + .arg(env_file_arg()) + .arg(no_browser_arg()) + .arg(flag("replace", "Store a new key even if the project already has one")) + .arg(flag("wait", "Keep waiting for an approval that is already open")) + .arg( + clap::Arg::new("authorize-url") + .long("authorize-url") + .value_name("URL") + .hide(true) + .help("A local approval page to open instead of production (development only)"), + ) + .arg(flag(session::WORKER_FLAG, "").hide(true)) + .arg(flag(session::LAUNCH_FLAG, "").hide(true)), + ) + .subcommand( + clap::Command::new("check") + .alias("status") + .about("Check the key and the integration, and say what's left") + .arg(env_file_arg()) + .arg(no_browser_arg()) + // Passed by the question's `run` once the developer has said what to build. + .arg(flag("answered", "The developer has said what to build").hide(true)), + ) + .subcommand( + clap::Command::new("test") + .about("Make a short text-to-speech request with the project's key (ask the developer first)") + .arg(env_file_arg()) + .arg(no_browser_arg()), + ) +} + +/// The environment as the shell gave it, from before the runtime loads a +/// project's `.env` into it: what the shell itself set. +static SHELL_ENV: OnceLock> = OnceLock::new(); + +/// The shell's own environment, once `register` has run (always, but in unit tests). +fn shell_env() -> Option<&'static HashMap> { + SHELL_ENV.get() +} + +/// What this process added to its environment after startup: chiefly the +/// project's `.env`, which the runtime loads and which must never configure +/// a program the CLI starts (git, the browser). +fn added_since_startup() -> Vec { + let Some(shell) = shell_env() else { + return Vec::new(); + }; + std::env::vars_os() + .map(|(key, _)| key) + .filter(|key| !shell.contains_key(key)) + .collect() +} + +/// Runs before the runtime loads `.env`. +pub fn register(app: CliApp) -> CliApp { + let _ = SHELL_ENV.set(std::env::vars_os().collect()); + app.command( + onboard_command(), + Box::new(|matches, ctx| dispatch(matches, downcast_ctx(ctx)?)), + ) +} diff --git a/cli/elevenlabs/workflow/onboard/project.rs b/cli/elevenlabs/workflow/onboard/project.rs new file mode 100644 index 0000000..cedac72 --- /dev/null +++ b/cli/elevenlabs/workflow/onboard/project.rs @@ -0,0 +1,438 @@ +//! The project the agent is working in: its stack, its env file, any key it +//! already has, and whether the SDK is wired in. Every step reads it the same +//! way, so they agree on the env file without passing state between them. + +use std::path::{Path, PathBuf}; + +use fern_cli_sdk::error::CliError; + +use super::browser; +use super::env_file::{self, API_KEY_ENV, DEFAULT_ENV_FILE}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Stack { + Node, + Python, + Other, +} + +pub(super) struct Project { + pub dir: PathBuf, + pub stack: Stack, + pub env_file: PathBuf, + /// The env file as the developer would write it (relative when it was given so). + pub env_display: String, + /// `--no-browser`: the developer opens the link themselves (port forwarding). + pub no_browser: bool, +} + +/// Where the key the SDK would use comes from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum KeySource { + /// The project's env file. + EnvFile, + /// A `.env` in the folder or above it, which loaders read before the env file. + Dotenv(PathBuf), + /// A variable set in the shell, which wins over any file. + Shell, +} + +pub(super) struct Key { + pub value: String, + pub source: KeySource, +} + +impl Key { + /// The file holding the key, which a replacement goes into; `None` for the shell. + pub fn file<'a>(&'a self, project: &'a Project) -> Option<&'a Path> { + match &self.source { + KeySource::EnvFile => Some(&project.env_file), + KeySource::Dotenv(path) => Some(path), + KeySource::Shell => None, + } + } + + pub fn source_name(&self) -> &'static str { + match self.source { + KeySource::EnvFile => "env_file", + KeySource::Dotenv(_) => "dotenv", + KeySource::Shell => "shell", + } + } +} + +/// A flag's value when the step defines it. +fn arg(matches: &clap::ArgMatches, id: &str) -> Option { + matches.try_get_one::(id).ok().flatten().cloned() +} + +impl Project { + pub fn open(matches: &clap::ArgMatches) -> Result { + let dir = std::env::current_dir() + .map_err(|e| CliError::Other(anyhow::anyhow!("read the working directory: {e}")))?; + let mut project = Self::at(dir, arg::(matches, "env-file")); + project.no_browser = arg::(matches, "no-browser").unwrap_or(false); + Ok(project) + } + + /// The project in `dir`, with `--env-file` when one was given. + pub fn at(dir: PathBuf, env_file: Option) -> Self { + let stack = stack_of(&dir); + let env_display = env_file.unwrap_or_else(|| { + match stack { + // python-dotenv's load_dotenv() reads `.env`; JS tooling reads `.env.local`. + Stack::Python => ".env", + _ => DEFAULT_ENV_FILE, + } + .into() + }); + Self { + env_file: dir.join(&env_display), + env_display, + dir, + stack, + no_browser: false, + } + } + + /// The next command's arguments: `step`, plus the flags of this run that it + /// takes (`--no-browser`), so following `run` never loses one, even + /// through `check`. The env file is always named, so every later step uses + /// the same file even if the project's stack (and so the default) changes. + pub fn next(&self, step: &str) -> String { + let mut cmd = step.to_string(); + if !step.contains("--env-file") { + cmd.push_str(&format!(" --env-file {}", shell_arg(&self.env_display))); + } + if self.no_browser { + cmd.push_str(" --no-browser"); + } + cmd + } + + /// No browser can reach this machine, so keys are added by hand. Even with + /// `--no-browser` and a forwarded port, the page would copy the key on the + /// developer's machine, not this one. + pub fn remote(&self) -> bool { + !browser::reachable() + } + + /// How to name `path` to the developer: the env file as they know it, and + /// any other file relative to the project (a `.env` up the tree as + /// `../../.env`), so it can go in a command run from the project. + pub fn show(&self, path: &Path) -> String { + if same_file(path, &self.env_file) { + return self.env_display.clone(); + } + if let Ok(inside) = path.strip_prefix(&self.dir) { + return inside.display().to_string(); + } + let up = path + .parent() + .and_then(|parent| self.dir.strip_prefix(parent).ok()) + .map(|below| below.components().count()); + match (up, path.file_name()) { + (Some(n), Some(name)) => format!("{}{}", "../".repeat(n), name.to_string_lossy()), + _ => path.display().to_string(), + } + } + + /// Why the env file can't hold a key, and how to fix it so the same step then works. + pub fn env_file_problem(&self) -> Option { + let name = &self.env_display; + if let Some(parent) = self.env_file.parent() { + if !parent.is_dir() { + return Some(format!( + "The folder for {name} doesn't exist. Create {}, then try again.", + parent.display() + )); + } + } + if env_file::is_symlink(&self.env_file) { + return Some(format!( + "{name} is a symlink, and the key must go in a regular file. Replace it with a regular file, then try again." + )); + } + if env_file::is_tracked(&self.env_file) { + return Some(format!( + "git tracks {name}, so a key in it could be committed. Untrack it with `git rm --cached {}` (the file stays on disk), then try again. \ + If it ever held a key that was pushed, replace that key too.", + shell_arg(name) + )); + } + None + } + + /// The key the app would use: a variable set in the shell first, then the + /// env file (which the CLI tells the app to load, and which Next.js and + /// Vite read before `.env`), then a `.env` in the folder or above it. The + /// shell's value is read from before the runtime loaded `.env`. + pub fn key(&self) -> Option { + let shell = match super::shell_env() { + Some(env) => env + .get(std::ffi::OsStr::new(API_KEY_ENV)) + .and_then(|v| v.to_str()) + .map(str::to_string), + None => std::env::var(API_KEY_ENV).ok(), + }; + self.key_given(shell) + } + + /// [`Self::key`] with the shell's value passed in. + pub fn key_given(&self, shell: Option) -> Option { + let line = env_file::read(&self.env_file) + .ok() + .and_then(|c| env_file::file_key(&c)); + // An empty line is still the value the app loads, so `.env` doesn't count then. + let has_line = line.is_some(); + let from_file = line.filter(|k| !k.is_empty()); + let shell = shell.filter(|v| !v.trim().is_empty()); + let dotenv = env_file::nearest_dotenv(&self.dir) + .filter(|(path, _)| !has_line && !same_file(path, &self.env_file)) + .and_then(|(path, key)| Some((path, key.filter(|k| !k.is_empty())?))); + let key = |value, source| Some(Key { value, source }); + match (shell, from_file, dotenv) { + // Exported from the file (direnv, mise): the file is what to change. + (Some(v), Some(f), _) if v == f => key(f, KeySource::EnvFile), + (Some(v), None, Some((path, k))) if v == k => key(k, KeySource::Dotenv(path)), + (Some(v), _, _) => key(v, KeySource::Shell), + (None, Some(f), _) => key(f, KeySource::EnvFile), + (None, None, Some((path, k))) => key(k, KeySource::Dotenv(path)), + (None, None, None) => None, + } + } + + /// The command that adds the SDK. A Python one also names it in a manifest, + /// which is how `check` sees it. + pub fn sdk_install(&self) -> &'static str { + match self.stack { + Stack::Python => "pip install elevenlabs (and add elevenlabs to the project's dependencies)", + Stack::Node => "npm install @elevenlabs/elevenlabs-js", + Stack::Other => { + "npm install @elevenlabs/elevenlabs-js (or pip install elevenlabs, and add elevenlabs to requirements.txt)" + } + } + } + + /// What the next sign-in does, as the developer will see it. + pub fn approval(&self) -> &'static str { + if self.no_browser { + "I'll give you a link to the approval page" + } else { + "I'll open the approval page" + } + } + + /// How the app gets the key from `file` (as [`Self::show`] names it) into + /// its environment, as an instruction. A file above the app is never read + /// by the framework. + pub fn loading(&self, file: &str) -> String { + let dotenv = format!("`dotenv.config({{ path: \"{file}\" }})`"); + if self.stack == Stack::Python { + return if file.starts_with("../") { + "Load it with python-dotenv (`load_dotenv()` finds it up the tree).".into() + } else { + format!("Load it with python-dotenv (`load_dotenv(\"{file}\")`).") + }; + } + if file.starts_with("../") { + return format!( + "Frameworks only read env files in the app's folder, so load it explicitly: run the app \ + with `node --env-file={file}` (in its start script too) or call {dotenv} first." + ); + } + match (self.stack, self.env_loading_framework()) { + (_, Some(framework)) => format!("{framework} reads it on its own."), + (Stack::Node, None) => format!( + "Plain Node doesn't read it on its own, so run the app with `node --env-file={file}` \ + (in its start script too) or call {dotenv} first." + ), + _ => format!( + "Make the app load it: run plain Node with `node --env-file={file}` (in its start script \ + too) or call {dotenv} first; Next.js and Vite read it on their own." + ), + } + } + + /// The framework in the app's package.json that loads `.env.local` on its + /// own, if any: Next.js, or Vite and what's built on it. + fn env_loading_framework(&self) -> Option<&'static str> { + let text = std::fs::read_to_string(self.dir.join("package.json")).ok()?; + let pkg: serde_json::Value = serde_json::from_str(&text).ok()?; + let has = |name: &str| { + ["dependencies", "devDependencies"] + .iter() + .any(|d| pkg[d].get(name).is_some()) + }; + if has("next") { + Some("Next.js") + } else if ["vite", "astro", "@sveltejs/kit"].iter().any(|d| has(d)) { + Some("Vite") + } else { + None + } + } + + /// Some manifest in the project declares an ElevenLabs SDK (any + /// `@elevenlabs/` package but the CLI, or Python's `elevenlabs`), in the + /// app's folder or one below it, as in a monorepo. + pub fn sdk_declared(&self) -> bool { + self.any_file(declares_sdk) + } + + /// Some source file imports an ElevenLabs SDK. + pub fn sdk_imported(&self) -> bool { + self.any_file(imports_sdk) + } + + /// Whether `test` holds for a file in the project, skipping hidden folders + /// and installed dependencies. + fn any_file(&self, test: fn(&Path) -> bool) -> bool { + let mut stack = vec![self.dir.clone()]; + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + let Ok(kind) = entry.file_type() else { + continue; + }; + if kind.is_dir() { + let sample = dir == self.dir && SAMPLE_DIRS.contains(&&*name); + if !name.starts_with('.') && !SKIPPED_DIRS.contains(&&*name) && !sample { + stack.push(entry.path()); + } + } else if kind.is_file() && test(&entry.path()) { + return true; + } + } + } + false + } +} + +fn declares_sdk(path: &Path) -> bool { + let read = || std::fs::read_to_string(path).unwrap_or_default(); + match path.file_name().and_then(|n| n.to_str()) { + Some("package.json") => serde_json::from_str::(&read()).is_ok_and(|p| { + ["dependencies", "devDependencies"].iter().any(|d| { + p[d].as_object().is_some_and(|deps| { + deps.keys() + .any(|name| name.starts_with("@elevenlabs/") && name != "@elevenlabs/cli") + }) + }) + }), + Some("requirements.txt" | "pyproject.toml" | "Pipfile" | "setup.py" | "setup.cfg") => { + names_python_sdk(&read()) + } + _ => false, + } +} + +/// `elevenlabs` as a whole requirement name: at the start of a line or after +/// a space or quote, and not followed by more of a name (`elevenlabs-foo`). +fn names_python_sdk(text: &str) -> bool { + text.match_indices("elevenlabs").any(|(i, m)| { + let before = text[..i].chars().next_back(); + let after = text[i + m.len()..].chars().next(); + before.is_none_or(|c| c.is_whitespace() || c == '"' || c == '\'') + && after.is_none_or(|c| !(c.is_alphanumeric() || c == '-' || c == '_' || c == '.')) + }) +} + +/// Installed dependencies, build output and vendored code. +const SKIPPED_DIRS: &[&str] = &[ + "node_modules", + "venv", + "__pycache__", + "dist", + "build", + "target", + "vendor", + "third_party", +]; + +/// Sample projects beside the app, at its top level only: deeper down, a +/// folder of that name is the app's own code (`app/examples/`). +const SAMPLE_DIRS: &[&str] = &["examples", "example"]; + +fn imports_sdk(path: &Path) -> bool { + let source = matches!( + path.extension().and_then(|e| e.to_str()), + Some( + "js" | "mjs" + | "cjs" + | "ts" + | "mts" + | "cts" + | "jsx" + | "tsx" + | "vue" + | "svelte" + | "astro" + | "py" + ) + ); + source + && std::fs::read_to_string(path).is_ok_and(|s| { + imports_js_sdk(&s) + || s.lines().any(|l| { + let l = l.trim_start(); + l.starts_with("from elevenlabs") || l.starts_with("import elevenlabs") + }) + }) +} + +/// A quoted `@elevenlabs/` import of any package but the CLI. +pub(super) fn imports_js_sdk(source: &str) -> bool { + source.match_indices("@elevenlabs/").any(|(i, m)| { + let name: String = source[i + m.len()..] + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || "-_.".contains(*c)) + .collect(); + source[..i].ends_with(['"', '\'']) && !name.is_empty() && name != "cli" + }) +} + +fn stack_of(dir: &Path) -> Stack { + let has = |f: &str| dir.join(f).exists(); + if has("package.json") { + Stack::Node + } else if ["pyproject.toml", "requirements.txt", "Pipfile", "setup.py"] + .iter() + .any(|f| has(f)) + { + Stack::Python + } else { + Stack::Other + } +} + +/// `arg` as one shell word: as is when it is plain, else single-quoted. +pub(super) fn shell_arg(arg: &str) -> String { + if !arg.is_empty() + && arg + .chars() + .all(|c| c.is_ascii_alphanumeric() || "._-/".contains(c)) + { + arg.to_string() + } else { + format!("'{}'", arg.replace('\'', r"'\''")) + } +} + +/// Whether two paths name the same file, by canonical path when both exist. +pub(super) fn same_file(a: &Path, b: &Path) -> bool { + match (a.canonicalize(), b.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => a == b, + } +} + +pub(super) fn last4(key: &str) -> String { + key.chars() + .skip(key.chars().count().saturating_sub(4)) + .collect() +} diff --git a/cli/elevenlabs/workflow/onboard/reply.rs b/cli/elevenlabs/workflow/onboard/reply.rs new file mode 100644 index 0000000..0401594 --- /dev/null +++ b/cli/elevenlabs/workflow/onboard/reply.rs @@ -0,0 +1,150 @@ +//! The one shape every step prints, so an agent follows the flow without +//! interpreting prose: what to do next, the exact command, and what to tell +//! the developer. `say` is always written to the developer; what the agent +//! does is `next`, spelled out in `instruction`, and `run`. + +use fern_cli_sdk::error::CliError; +use serde::Serialize; +use serde_json::{Map, Value}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum Next { + Run, + Wait, + AskUser, + Fix, + AskWhatToBuild, + WriteCode, + LoadKey, + OfferTest, + Done, +} + +impl Next { + /// What the agent does with the reply. Printed with every reply, so the + /// flow needs nothing but the CLI's output. + fn instruction(self) -> &'static str { + match self { + Next::Run => "Tell the user `say`, then run `run`.", + Next::Wait => { + "Show the user `say`, then run `run` right away, without ending your turn: it waits while they \ + approve in the browser." + } + Next::AskUser => { + "Ask the user what `say` says and wait for their answer, then run `run`, unless they said no." + } + Next::Fix => "Do what `say` describes, then run `run`.", + Next::AskWhatToBuild => { + "Tell the user `say`, then end your turn and wait for their answer to its question. Ask \ + it even if they already said what they want, even just the SDK: their answer confirms \ + it. Write no code before they answer. When they have, run `run` before anything else, \ + without installing or writing anything first: its reply says how to write the code. If \ + you already asked and they answered, don't ask again, just run `run`." + } + Next::WriteCode => { + "Tell the user `say`, then write what they asked for. If they only want the SDK, install \ + it (`details.sdk_install`) and create the client in one small file. Otherwise first run \ + `details.fetch_skills`: it downloads the ElevenLabs skills into `details.skills_dir`, one \ + folder per skill, outside the project, so list and read it with shell commands like `ls` \ + and `cat` (file search tools may not look there). If one fits what they want, read its \ + SKILL.md and the `references/` \ + files it links before writing any code, and write the code the way the skill shows, \ + not from the SDK's own files; if none fits, write it without one. Install the SDK, and \ + make the app load the key the way `details.load_key` says. Then run `run` to check it." + } + Next::LoadKey => { + "Tell the user `say`. The code already uses the SDK, but the app must load the key from \ + where it is now: do what `details.load_key` says and change nothing else. Then run `run` \ + to check it." + } + Next::OfferTest => { + "End your reply with the question in `say`, as it is, and wait for the answer; run `run` \ + only if they say yes." + } + Next::Done => "Tell the user `say`. There's nothing left to run.", + } + } +} + +/// Never relaxed by any step, so every reply that starts a flow carries them. +const RULES: &[&str] = &[ + "Never open, print or cat the env file; run `onboard check` to verify it.", + "Never ask for an API key in chat, and never accept one pasted there.", + "Never put the key in code; the SDK reads ELEVENLABS_API_KEY.", + "Never run git clean or delete untracked files.", + "Don't write or change any code until a reply's `next` is `write_code` or `load_key`.", + "Put anything you download only to read, like a package or an archive, in a temp folder, never in the project.", + "Don't run the app or make your own API calls to try the key unless the user says yes. Once the code is written, run `onboard check` before you finish: it verifies the key and the code.", +]; + +#[derive(Debug, Serialize)] +pub(super) struct Reply { + pub next: Next, + pub instruction: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub run: Option, + pub say: String, + #[serde(skip_serializing_if = "<[_]>::is_empty")] + pub rules: &'static [&'static str], + #[serde(skip_serializing_if = "Map::is_empty")] + pub details: Map, +} + +impl Reply { + pub fn new(next: Next, say: impl Into) -> Self { + Self { + next, + instruction: next.instruction(), + run: None, + say: say.into(), + rules: &[], + details: Map::new(), + } + } + + /// The next command, `onboard `, spelled the way this run was invoked. + pub fn run(mut self, args: &str) -> Self { + self.run = Some(command(args)); + self + } + + pub fn detail(mut self, key: &str, value: impl Serialize) -> Self { + self.details.insert( + key.into(), + serde_json::to_value(value).unwrap_or(Value::Null), + ); + self + } + + pub fn with_rules(mut self) -> Self { + self.rules = RULES; + self + } + + pub fn to_value(&self) -> Value { + serde_json::to_value(self).unwrap_or(Value::Null) + } +} + +/// `onboard ` as the developer's shell would run it: under npx when npx +/// started this process (it sets `npm_command=exec`), so the agent keeps +/// running the same, latest CLI; otherwise the installed `elevenlabs`. +pub(super) fn command(args: &str) -> String { + let cli = if std::env::var("npm_command").is_ok_and(|c| c == "exec") { + "npx -y @elevenlabs/cli@latest" + } else { + "elevenlabs" + }; + format!("{cli} onboard {args}") +} + +/// Print a reply (or one a background sign-in saved). Always JSON, whatever +/// stdout is and whatever `--human` or `--format` says: only agents run these +/// steps, often in pseudo-terminals, and prose would drop the instruction. +pub(super) fn emit(reply: &Value) -> Result<(), CliError> { + let text = serde_json::to_string_pretty(reply) + .map_err(|e| CliError::Other(anyhow::anyhow!("{e}")))?; + println!("{text}"); + Ok(()) +} diff --git a/cli/elevenlabs/workflow/onboard/session.rs b/cli/elevenlabs/workflow/onboard/session.rs new file mode 100644 index 0000000..6948014 --- /dev/null +++ b/cli/elevenlabs/workflow/onboard/session.rs @@ -0,0 +1,346 @@ +//! A sign-in that outlives the command that started it. +//! +//! Approving in the browser can take minutes (a new account, an emailed +//! code), and coding agents stop or background shell commands long before +//! that. So `connect` hands the sign-in to a background worker and waits for +//! it only briefly; `connect --wait` waits again. The worker writes its +//! result to a file, so whichever command is waiting when it lands prints it. +//! +//! The worker holds `worker.lock` for its whole life, and the OS releases the +//! lock when it exits, however it exits. That lock is the only test of whether +//! a sign-in is running: no pids, clocks or timeouts to get wrong. +//! +//! The worker is started through a short-lived launcher, so it is not a +//! descendant of the agent's command and survives the agent killing that +//! command's process tree; it also runs in its own process group. +//! +//! One session per project folder, under `~/.elevenlabs/onboard/`, and one +//! sign-in waiting for the browser on the machine at a time: two would share +//! the callback port. + +use std::ffi::OsString; +use std::fs::{File, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use fern_cli_sdk::error::CliError; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use super::clipboard::Clipboard; +use super::env_file; +use super::reply::{Next, Reply}; + +/// How long the sign-in waits for the approval page: the framework's callback timeout. +pub(super) const SIGN_IN_MINUTES: u64 = 5; +/// How long a new worker may take to take its lock before starting counts as failed. +const STARTUP: Duration = Duration::from_secs(10); +const POLL: Duration = Duration::from_millis(250); + +pub(super) const WORKER_FLAG: &str = "worker"; +pub(super) const LAUNCH_FLAG: &str = "launch-worker"; + +pub(super) struct Session { + dir: PathBuf, +} + +/// The worker's hold on its session; released when the worker exits. +pub(super) struct WorkerLock(#[allow(dead_code)] File); + +impl Session { + pub fn for_project(project_dir: &Path) -> Result { + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .ok_or_else(|| { + CliError::Other(anyhow::anyhow!("no home folder to keep the sign-in in")) + })?; + let canonical = project_dir + .canonicalize() + .unwrap_or_else(|_| project_dir.to_path_buf()); + let id = Sha256::digest(canonical.to_string_lossy().as_bytes()); + let id: String = id.iter().take(8).map(|b| format!("{b:02x}")).collect(); + Ok(Self::in_dir( + PathBuf::from(home) + .join(".elevenlabs") + .join("onboard") + .join(id), + )) + } + + pub fn in_dir(dir: PathBuf) -> Self { + Self { dir } + } + + fn path(&self, name: &str) -> PathBuf { + self.dir.join(name) + } + + /// The last finished sign-in's reply, until a new one starts. + pub fn result(&self) -> Option { + let text = std::fs::read_to_string(self.path("result.json")).ok()?; + serde_json::from_str(&text).ok() + } + + /// A worker is waiting for the approval, or finishing: it holds its lock. + pub fn running(&self) -> bool { + let Ok(file) = File::open(self.path("worker.lock")) else { + return false; + }; + match file.try_lock() { + Ok(()) => { + let _ = file.unlock(); + false + } + Err(_) => true, + } + } + + /// Start a worker for this sign-in. `request` names what was asked for + /// (the env file, a replacement): a worker already running for the same + /// request is waited on instead, and so is one past the browser, which is + /// storing a key the developer just approved. One for another request is + /// replaced. Returns once the worker holds its lock, so every later command + /// sees it running. + pub fn start(&self, request: &str) -> Result<(), CliError> { + let root = self.dir.parent().unwrap_or(&self.dir).to_path_buf(); + std::fs::create_dir_all(&self.dir).map_err(io(&self.dir))?; + #[cfg(unix)] + for dir in [&root, &self.dir] { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)); + } + // One starter at a time on the machine, so two folders never both listen. + let claim_path = root.join("claim.lock"); + let claim = open_lock(&claim_path)?; + claim.lock().map_err(io(&claim_path))?; + if self.running() { + let same = std::fs::read_to_string(self.path("request")).is_ok_and(|r| r == request); + if same || self.path("signed-in").exists() { + return Ok(()); + } + self.stop(); + } + for stale in ["result.json", "worker.log", "worker.pid", "signed-in"] { + let _ = std::fs::remove_file(self.path(stale)); + } + std::fs::write(self.path("request"), request).map_err(io(&self.path("request")))?; + self.stop_others(); + let status = this_program()? + .args(worker_args(LAUNCH_FLAG)) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|e| CliError::Other(anyhow::anyhow!("start the sign-in: {e}")))?; + let deadline = Instant::now() + STARTUP; + while status.success() && !self.running() && self.result().is_none() { + if Instant::now() >= deadline { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + if self.running() || self.result().is_some() { + Ok(()) + } else { + Err(CliError::Other(anyhow::anyhow!( + "the sign-in didn't start (launcher: {status}); see {}", + self.path("worker.log").display() + ))) + } + } + + /// Stop any other project's sign-in still waiting for the browser. Only + /// one may listen for the browser's callback: two can hold the same port + /// (one on IPv4, one on IPv6), and the callback then reaches the wrong one. + /// One past the browser no longer listens, and is left to store its key. + /// The stopped one's agent is told why, so it asks before signing in again. + pub(super) fn stop_others(&self) { + let Some(root) = self.dir.parent() else { + return; + }; + for entry in std::fs::read_dir(root).into_iter().flatten().flatten() { + let other = Session::in_dir(entry.path()); + if other.dir == self.dir || !other.running() || other.path("signed-in").exists() { + continue; + } + other.stop(); + let replaced = Reply::new( + Next::AskUser, + "A sign-in started for another project replaced this one. Tell me if you want to sign in for this project again.", + ) + .detail("key_status", "sign_in_replaced"); + let _ = env_file::write(&other.path("result.json"), &replaced.to_value().to_string()); + } + } + + /// End this session's worker. Its lock is held, so the pid it recorded is + /// still that worker's. Stopped between the browser's callback and storing + /// the key, it may leave the key the page copied, so that goes too. + fn stop(&self) { + let pid = std::fs::read_to_string(self.path("worker.pid")) + .ok() + .and_then(|t| t.trim().parse::().ok()); + if let Some(pid) = pid { + stop(pid); + Clipboard::open().discard_key(); + } + } + + /// The launcher's job: start the worker detached, then exit. + pub fn launch_worker(&self) -> Result<(), CliError> { + let log = File::create(self.path("worker.log")).map_err(io(&self.path("worker.log")))?; + let mut command = this_program()?; + command + .args(worker_args(WORKER_FLAG)) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(log); + detach(&mut command); + command + .spawn() + .map(drop) + .map_err(|e| CliError::Other(anyhow::anyhow!("start the sign-in worker: {e}"))) + } + + /// The worker's first act: take the session's lock, and record its pid + /// (in its own file: a locked file can't be read on Windows). `None` when + /// another worker holds the lock. A command checking `running()` holds it + /// for an instant, so taking it is retried briefly. + pub fn take(&self) -> Result, CliError> { + let file = open_lock(&self.path("worker.lock"))?; + let deadline = Instant::now() + Duration::from_millis(500); + while file.try_lock().is_err() { + if Instant::now() >= deadline { + return Ok(None); + } + std::thread::sleep(Duration::from_millis(10)); + } + let pid = self.path("worker.pid"); + std::fs::write(&pid, std::process::id().to_string()).map_err(io(&pid))?; + Ok(Some(WorkerLock(file))) + } + + /// The browser step is over: another project's sign-in must not stop this one now. + pub fn signed_in(&self, _lock: &WorkerLock) { + let _ = std::fs::write(self.path("signed-in"), ""); + } + + /// The worker's last act: its reply, for whoever is waiting. + pub fn finish(&self, _lock: &WorkerLock, reply: &Value) -> Result<(), CliError> { + env_file::write(&self.path("result.json"), &reply.to_string()) + } + + /// Wait up to `budget` for the result, returning early when the worker is + /// gone or `enough` says so. + pub fn wait(&self, budget: Duration, enough: impl Fn(&Self) -> bool) -> Option { + let deadline = Instant::now() + budget; + loop { + if let Some(result) = self.result() { + return Some(result); + } + if !self.running() || enough(self) || Instant::now() >= deadline { + return self.result(); + } + std::thread::sleep(POLL); + } + } + + /// The approval link the worker printed (the sign-in flow logs ` URL: ` before it waits). + pub fn url(&self) -> Option { + let log = std::fs::read_to_string(self.path("worker.log")).ok()?; + log.lines() + .find_map(|l| l.trim_start().strip_prefix("URL: ")) + .map(|url| url.trim().to_string()) + .filter(|url| url.starts_with("http")) + } +} + +fn open_lock(path: &Path) -> Result { + OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(path) + .map_err(io(path)) +} + +/// This invocation's own arguments with `flag` added, so the worker signs in +/// exactly as this command was asked to. +fn worker_args(flag: &str) -> Vec { + worker_args_from(std::env::args_os().skip(1), flag) +} + +/// `args` without the session's own flags, then `--`. +pub(super) fn worker_args_from(args: impl Iterator, flag: &str) -> Vec { + let ours = [format!("--{WORKER_FLAG}"), format!("--{LAUNCH_FLAG}")]; + let mut kept: Vec = args + .filter(|a| !ours.iter().any(|o| a == o.as_str())) + .collect(); + kept.push(format!("--{flag}").into()); + kept +} + +/// This program again, started with the shell's environment: by now this +/// process has the project's `.env` loaded, which git must never see. +fn this_program() -> Result { + let exe = std::env::current_exe() + .map_err(|e| CliError::Other(anyhow::anyhow!("find this program: {e}")))?; + let mut command = Command::new(exe); + if let Some(shell) = super::shell_env() { + command.env_clear().envs(shell); + } + Ok(command) +} + +#[cfg(unix)] +fn detach(command: &mut Command) { + use std::os::unix::process::CommandExt; + command.process_group(0); +} + +#[cfg(windows)] +fn detach(command: &mut Command) { + use std::os::windows::process::CommandExt; + const DETACHED_PROCESS: u32 = 0x0000_0008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + command.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); +} + +/// End a worker and wait briefly for it to let go of its port. Only ever one +/// real process: never 0 or 1, and never a value that would read as a process +/// group. +fn stop(pid: u32) { + if pid <= 1 || i32::try_from(pid).is_err() { + return; + } + #[cfg(unix)] + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + #[cfg(windows)] + let _ = Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/F"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline && process_exists(pid) { + std::thread::sleep(Duration::from_millis(50)); + } +} + +#[cfg(unix)] +fn process_exists(pid: u32) -> bool { + unsafe { libc::kill(pid as i32, 0) == 0 } +} + +#[cfg(windows)] +fn process_exists(_pid: u32) -> bool { + false +} + +fn io(path: &Path) -> impl Fn(std::io::Error) -> CliError + '_ { + move |e| CliError::Other(anyhow::anyhow!("{}: {e}", path.display())) +} diff --git a/cli/elevenlabs/workflow/onboard/test_request.rs b/cli/elevenlabs/workflow/onboard/test_request.rs new file mode 100644 index 0000000..de005e4 --- /dev/null +++ b/cli/elevenlabs/workflow/onboard/test_request.rs @@ -0,0 +1,111 @@ +//! `onboard test`: the test request the developer said yes to. One short +//! line of text to speech with the project's key, saved where the agent can +//! point them to play it. It never runs on its own: `check` only offers it. + +use fern_cli_sdk::error::CliError; +use fern_cli_sdk::openapi::AppContext; +use serde_json::{json, Value}; + +use super::super::api::api_error_message; +use super::super::say::{run_async as block_on, DEFAULT_MODEL_ID, DEFAULT_VOICE_ID, MP3_FORMAT}; +use super::auth; +use super::project::Project; +use super::reply::{Next, Reply}; + +const TEXT: &str = "Welcome to ElevenLabs"; +const PLANS_PAGE: &str = "https://elevenlabs.io/app/subscription"; + +pub(super) fn handle(matches: &clap::ArgMatches, ctx: &AppContext) -> Result { + let project = Project::open(matches)?; + let Some(key) = project.key() else { + return Ok(Reply::new( + Next::Run, + "There's no ElevenLabs API key in this project yet.", + ) + .run(&project.next("check"))); + }; + // Every outcome ends on what is set up, so the agent's last message says it. + let location = key + .file(&project) + .map(|f| project.show(f)) + .unwrap_or_else(|| "your shell".into()); + let code = if project.sdk_declared() && project.sdk_imported() { + " and the SDK is in your code" + } else { + "" + }; + let set_up = format!("ElevenLabs is set up: your key is in {location}{code}."); + let client = ctx.http_config().build_client()?; + let url = format!( + "{}/v1/text-to-speech/{DEFAULT_VOICE_ID}", + auth::api_base(ctx) + ); + let sent = block_on( + client + .post(&url) + .query(&[("output_format", MP3_FORMAT)]) + .header("xi-api-key", &key.value) + .json(&json!({ "text": TEXT, "model_id": DEFAULT_MODEL_ID })) + .timeout(std::time::Duration::from_secs(60)) + .send(), + ); + let response = match sent { + Ok(r) => r, + Err(_) => { + return Ok(done(format!( + "I couldn't reach ElevenLabs for the test request; ask me to try again later. {set_up}" + )) + .detail("status", Value::Null)) + } + }; + let status = response.status().as_u16(); + let body = block_on(response.bytes()).unwrap_or_default(); + let error: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); + // An exhausted quota comes back as a 401 `quota_exceeded`, not only as a 402. + let no_credits = + status == 402 || (status == 401 && error["detail"]["status"] == "quota_exceeded"); + let reply = if (200..300).contains(&status) { + let clip = save(&body)?; + done(format!( + "Your first request worked. Play {clip} to hear it. {set_up}" + )) + .detail("audio_file", clip) + } else if no_credits { + done(format!( + "The test request needs credits on your account. Add some at {PLANS_PAGE}, then ask me to try again. {set_up}" + )) + } else { + let message = if error.is_null() { + "no details".to_string() + } else { + api_error_message(&error) + }; + // Only a busy or failing ElevenLabs is worth trying again later. + let later = if status == 429 || status >= 500 { + "; ask me to try again later" + } else { + "" + }; + done(format!( + "The test request failed ({status}: {message}){later}. {set_up}" + )) + }; + Ok(reply.detail("status", status)) +} + +/// The clip in a new file of its own, so nothing already at that path is overwritten. +fn save(audio: &[u8]) -> Result { + let fail = |e: std::io::Error| CliError::Other(anyhow::anyhow!("save the test clip: {e}")); + let mut file = tempfile::Builder::new() + .prefix("elevenlabs-hello-") + .suffix(".mp3") + .tempfile() + .map_err(fail)?; + std::io::Write::write_all(&mut file, audio).map_err(fail)?; + let (_, path) = file.keep().map_err(|e| fail(e.error))?; + Ok(path.display().to_string()) +} + +fn done(say: impl Into) -> Reply { + Reply::new(Next::Done, say) +} diff --git a/cli/elevenlabs/workflow/onboard/tests.rs b/cli/elevenlabs/workflow/onboard/tests.rs new file mode 100644 index 0000000..c54f4d2 --- /dev/null +++ b/cli/elevenlabs/workflow/onboard/tests.rs @@ -0,0 +1,1056 @@ +use super::super::util::opt_string; +use super::auth::*; +use super::browser::reachable_in; +use super::clipboard::{classify, Clip, Marker}; +use super::env_file::{file_key, with_key, PLACEHOLDER_NOTE}; +use super::onboard_command; +use super::project::last4; + +#[test] +fn the_sign_in_is_read_out_of_main_rs() { + let c = sign_in_config().expect("main.rs declares the sign-in"); + assert_eq!(c.scheme, "OAuth"); + assert_eq!(c.client_id.len(), 36, "{}", c.client_id); + assert!( + c.authorize_url.ends_with("/app/oauth/authorize"), + "{}", + c.authorize_url + ); + assert!(c.token_url.ends_with("/v1/oauth/token"), "{}", c.token_url); + assert_eq!(c.redirect_host, "localhost"); + assert!(c.redirect_ports.contains(&8484)); + assert!(c.scopes.iter().any(|s| s == "user_read") && c.scopes.len() > 20); + assert!(c.success_url.starts_with("https://") && c.error_url.starts_with("https://")); +} + +#[test] +fn the_clipboard_is_a_key_a_marker_or_nothing() { + let key = "sk_0123456789abcdef0123456789abcdef"; + assert!(matches!(classify(Some(format!(" {key}\n"))), Clip::Key(k) if k == key)); + for m in [Marker::NotAvailable, Marker::NotPermitted, Marker::Failed] { + assert_eq!(classify(Some(m.text())), Clip::Marker(m)); + } + assert_eq!(Marker::Failed.text(), "elevenlabs:failed"); + assert!( + matches!(classify(Some(format!("{key}_residency_eu1"))), Clip::Key(_)), + "residency suffix" + ); + for junk in [ + "", + "sk_short", + "sk_0123456789abcdef with space", + "sk_vendor_ABCDEF0123456789abcdef0123456789", + "sk-proj-0123456789abcdef0123456789abcdef", + "elevenlabs:", + "elevenlabs:other", + "hi", + ] { + assert!( + matches!(classify(Some(junk.into())), Clip::Nothing), + "{junk:?}" + ); + } + assert!(matches!(classify(None), Clip::Nothing)); +} + +#[test] +fn the_nearest_dotenv_and_its_key_are_found_up_the_tree() { + use super::env_file::nearest_dotenv; + let root = tempfile::tempdir().unwrap(); + let nested = root.path().join("apps").join("web"); + std::fs::create_dir_all(&nested).unwrap(); + assert!(nearest_dotenv(&nested).is_none(), "no .env anywhere"); + + std::fs::write(root.path().join(".env"), "ELEVENLABS_API_KEY=sk_root\n").unwrap(); + assert_eq!( + nearest_dotenv(&nested).expect("the root .env"), + (root.path().join(".env"), Some("sk_root".to_string())) + ); + + // Only the nearest file is read, as loaders do, even when its line is empty. + std::fs::write(nested.join(".env"), "ELEVENLABS_API_KEY=\n").unwrap(); + assert_eq!(nearest_dotenv(&nested).unwrap().1, Some(String::new())); + std::fs::write(nested.join(".env"), "OTHER=1\n").unwrap(); + assert_eq!(nearest_dotenv(&nested).unwrap().1, None, "no key line"); + std::fs::write( + nested.join(".env"), + "ELEVENLABS_API_KEY=sk_old\nOTHER=1\nELEVENLABS_API_KEY=sk_new\n", + ) + .unwrap(); + assert_eq!( + nearest_dotenv(&nested).unwrap().1.as_deref(), + Some("sk_new") + ); +} + +#[test] +fn the_key_line_is_replaced_in_place_or_appended_once() { + assert_eq!(with_key("", "sk_1"), "ELEVENLABS_API_KEY=sk_1\n"); + assert_eq!(with_key("A=1", "sk_1"), "A=1\nELEVENLABS_API_KEY=sk_1\n"); + assert_eq!( + with_key("A=1\nexport ELEVENLABS_API_KEY=\"old\"\nB=2\n", "sk_1"), + "A=1\nexport ELEVENLABS_API_KEY=sk_1\nB=2\n", + "export is kept" + ); + assert_eq!( + with_key("A=1\r\nB=2\r\n", "sk_1"), + "A=1\r\nB=2\r\nELEVENLABS_API_KEY=sk_1\r\n", + "line endings are kept" + ); + assert_eq!( + file_key("ELEVENLABS_API_KEY=\nELEVENLABS_API_KEY=sk_2\n"), + Some("sk_2".into()), + "the last line wins, as for dotenv" + ); + assert_eq!( + with_key("ELEVENLABS_API_KEY=sk_1\nELEVENLABS_API_KEY=sk_2\n", "sk_3"), + "ELEVENLABS_API_KEY=sk_1\nELEVENLABS_API_KEY=sk_3\n" + ); + assert_eq!(file_key("exported=1"), None); + let placeholder = with_key("", ""); + assert!(placeholder.starts_with(PLACEHOLDER_NOTE)); + assert!(placeholder.ends_with("ELEVENLABS_API_KEY=\n")); + assert_eq!( + file_key(&placeholder), + Some(String::new()), + "an empty line is a placeholder" + ); + assert_eq!(with_key(&placeholder, ""), placeholder, "not duplicated"); + assert_eq!(file_key("ELEVENLABS_API_KEY = 'sk_1'"), Some("sk_1".into())); + assert_eq!(file_key("OTHER=1"), None); + assert_eq!( + file_key("ELEVENLABS_API_KEY=sk_1 # prod"), + Some("sk_1".into()) + ); + // A line the loader can't parse still holds the key, and is still the one replaced. + for contents in [ + "NEXT_PUBLIC_APP_NAME=Joe's Pizza\nELEVENLABS_API_KEY=sk_1\n", + "\u{feff}ELEVENLABS_API_KEY=sk_1\n", + "ELEVENLABS_API_KEY=sk_1 ", + "ELEVENLABS_API_KEY=\"sk_1\" \nOTHER=1\n", + "export ELEVENLABS_API_KEY='sk_1'\t\n", + "ELEVEN_KEY=sk_1\nELEVENLABS_API_KEY=${ELEVEN_KEY}\n", + "ELEVEN_KEY=sk_1 \nELEVENLABS_API_KEY=${ELEVEN_KEY}\n", + "\u{feff}ELEVEN_KEY=sk_1\nELEVENLABS_API_KEY=${ELEVEN_KEY}\n", + "OTHER=sk_1\nAPP=Joe's Pizza\nELEVENLABS_API_KEY=${OTHER}\n", + "ELEVENLABS_API_KEY=sk_0\nAPP=Joe's Pizza\nELEVENLABS_API_KEY=sk_1 # $5\n", + ] { + assert_eq!(file_key(contents), Some("sk_1".into()), "{contents:?}"); + assert_eq!(file_key(&with_key(contents, "sk_2")), Some("sk_2".into())); + } + // Parsed as the runtime's loader parses it. + for line in [ + "ELEVENLABS_API_KEY=sk_1\t# prod", + "export ELEVENLABS_API_KEY=sk_1", + ] { + assert_eq!(file_key(line), Some("sk_1".into()), "{line}"); + } + assert_eq!(last4("sk_abc"), "_abc"); + assert_eq!(last4("ab"), "ab"); +} + +#[test] +fn only_a_loopback_url_counts_as_local() { + assert_eq!( + loopback("http://localhost:8000/"), + Some("http://localhost:8000".into()) + ); + assert_eq!( + loopback("http://127.0.0.1:8000"), + Some("http://127.0.0.1:8000".into()) + ); + assert_eq!( + loopback("http://[::1]:3000"), + Some("http://[::1]:3000".into()) + ); + assert_eq!(loopback("http://localhost:8000@evil.example.com/"), None); + assert_eq!(loopback("https://evil.example.com/localhost"), None); + assert_eq!(loopback("https://api.elevenlabs.io"), None); +} + +#[test] +fn a_browser_reaches_a_laptop_but_not_a_remote_box() { + let none = |_: &str| None; + let only = |name: &'static str| move |n: &str| (n == name).then(|| "x".to_string()); + assert!(reachable_in("macos", &none, false)); + assert!(reachable_in("windows", &none, false)); + assert!(!reachable_in("macos", &only("SSH_CONNECTION"), false)); + assert!(!reachable_in("linux", &none, false), "no display"); + assert!(reachable_in("linux", &only("DISPLAY"), false)); + assert!(reachable_in("linux", &only("WSL_DISTRO_NAME"), false)); + assert!(!reachable_in("linux", &only("DISPLAY"), true), "container"); + assert!(!reachable_in("linux", &only("CODESPACES"), false)); +} + +/// The developer's own git config (a global excludes file, say) must not +/// change what the tests see, and the tests must not touch their clipboard. +/// Set once for the whole test process, before any test runs git. +fn isolate() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + std::env::set_var("GIT_CONFIG_GLOBAL", "/dev/null"); + std::env::set_var("GIT_CONFIG_NOSYSTEM", "1"); + // Nor its default excludes file, $XDG_CONFIG_HOME/git/ignore. + let empty = tempfile::tempdir().unwrap().keep(); + std::env::set_var("XDG_CONFIG_HOME", empty); + std::env::set_var(super::clipboard::NO_CLIPBOARD_ENV, "1"); + }); +} + +fn git(dir: &std::path::Path, args: &[&str]) { + isolate(); + let ok = std::process::Command::new("git") + .arg("-C") + .arg(dir) + .args(["-c", "user.email=t@example.com", "-c", "user.name=t"]) + .args(args) + .output() + .unwrap() + .status + .success(); + assert!(ok, "git {args:?}"); +} + +fn project(dir: &std::path::Path) -> super::project::Project { + isolate(); + super::project::Project::at(dir.to_path_buf(), None) +} + +#[test] +fn every_step_parses_its_flags_and_bare_onboard_is_refused() { + let cmd = onboard_command(); + let parse = |args: &[&str]| cmd.clone().try_get_matches_from(args); + assert!(parse(&["onboard"]).is_err(), "a step is required"); + let m = parse(&["onboard", "init", "--env-file", ".env.dev", "--no-browser"]).unwrap(); + let (name, sub) = m.subcommand().unwrap(); + assert_eq!(name, "init"); + assert_eq!(opt_string(sub, "env-file").as_deref(), Some(".env.dev")); + assert!(sub.get_flag("no-browser")); + assert!( + parse(&["onboard", "connect", "--product", "music"]).is_err(), + "one key for every product, so no product to name" + ); + let m = parse(&["onboard", "connect", "--replace", "--wait", "--no-browser"]).unwrap(); + let sub = m.subcommand_matches("connect").unwrap(); + assert!(sub.get_flag("replace") && sub.get_flag("wait") && sub.get_flag("no-browser")); + assert_eq!( + parse(&["onboard", "status"]).unwrap().subcommand_name(), + Some("check") + ); + assert_eq!( + parse(&["onboard", "test"]).unwrap().subcommand_name(), + Some("test") + ); + assert!(parse(&["onboard", "check", "--replace"]).is_err()); +} + +#[test] +fn the_env_file_follows_the_stack_and_is_pinned_in_every_next_command() { + use super::project::{Project, Stack}; + let dir = tempfile::tempdir().unwrap(); + let p = project(dir.path()); + assert_eq!( + (p.stack, p.env_display.as_str()), + (Stack::Other, ".env.local") + ); + // Named in every next command, so a later stack change can't move it. + assert_eq!(p.next("check"), "check --env-file .env.local"); + std::fs::write(dir.path().join("requirements.txt"), "elevenlabs==2\n").unwrap(); + let p = project(dir.path()); + assert_eq!((p.stack, p.env_display.as_str()), (Stack::Python, ".env")); + assert!(p.sdk_declared()); + std::fs::write(dir.path().join("package.json"), "{}").unwrap(); + let mut p = Project::at(dir.path().to_path_buf(), Some("config/my env".into())); + assert_eq!(p.stack, Stack::Node); + assert_eq!(p.env_file, dir.path().join("config/my env")); + assert_eq!(p.show(&dir.path().join("config/my env")), "config/my env"); + assert!(p.env_file_problem().unwrap().contains("doesn't exist")); + p.no_browser = true; + assert_eq!( + p.next("connect --replace"), + "connect --replace --env-file 'config/my env' --no-browser" + ); + assert_eq!( + p.next("init"), + "init --env-file 'config/my env' --no-browser" + ); + assert_eq!(super::project::shell_arg("it's"), r"'it'\''s'"); + // --no-browser goes on through check, so a later connect keeps it. + assert_eq!( + p.next("check"), + "check --env-file 'config/my env' --no-browser" + ); + // A file up the tree is named from the project, as a command run there needs it. + let app = dir.path().join("apps/web"); + std::fs::create_dir_all(&app).unwrap(); + let p = project(&app); + assert_eq!(p.show(&dir.path().join(".env")), "../../.env"); + assert_eq!(p.show(&app.join("config/.env")), "config/.env"); + assert!(p + .loading("../../.env") + .contains("only read env files in the app's folder")); +} + +#[test] +fn loading_the_key_is_an_instruction_for_the_app_at_hand() { + let dir = tempfile::tempdir().unwrap(); + let pkg = |deps: &str| { + std::fs::write( + dir.path().join("package.json"), + format!(r#"{{"scripts":{{"start":"node index.js"}},"dependencies":{{{deps}}}}}"#), + ) + .unwrap(); + project(dir.path()).loading(".env.local") + }; + // Plain Node reads no env file, so the start script has to load it. + let plain = pkg(""); + assert!( + plain.starts_with("Plain Node doesn't read it on its own") + && plain.contains("`node --env-file=.env.local` (in its start script too)"), + "{plain}" + ); + assert_eq!(pkg(r#""next":"15""#), "Next.js reads it on its own."); + assert_eq!(pkg(r#""astro":"5""#), "Vite reads it on its own."); + let empty = tempfile::tempdir().unwrap(); + assert!(project(empty.path()) + .loading(".env.local") + .starts_with("Make the app load it: run plain Node with `node --env-file=.env.local`")); +} + +#[test] +fn a_tracked_env_file_says_how_to_untrack_it() { + let dir = tempfile::tempdir().unwrap(); + git(dir.path(), &["init", "-q"]); + std::fs::write(dir.path().join(".env.local"), "A=1\n").unwrap(); + git(dir.path(), &["add", ".env.local"]); + let problem = project(dir.path()).env_file_problem().unwrap(); + assert!(problem.contains("git rm --cached .env.local"), "{problem}"); + assert!( + !problem.contains("such as .env.local"), + "never suggests the refused file" + ); +} + +#[test] +fn the_shell_wins_then_the_env_file_then_a_dotenv_up_the_tree() { + use super::project::KeySource; + let root = tempfile::tempdir().unwrap(); + let app = root.path().join("app"); + std::fs::create_dir_all(&app).unwrap(); + let p = project(&app); + assert!(p.key_given(None).is_none()); + + std::fs::write(app.join(".env.local"), "ELEVENLABS_API_KEY=\n").unwrap(); + assert!( + p.key_given(None).is_none(), + "an empty placeholder is not a key" + ); + + // A `.env` up the tree counts when the env file has no key line. + std::fs::write(app.join(".env.local"), "OTHER=1\n").unwrap(); + std::fs::write(root.path().join(".env"), "ELEVENLABS_API_KEY=sk_dotenv\n").unwrap(); + let k = p.key_given(None).unwrap(); + assert_eq!( + (k.value.as_str(), &k.source), + ("sk_dotenv", &KeySource::Dotenv(root.path().join(".env"))) + ); + // Exported from that `.env` into the shell (direnv, mise), it's still the file's. + assert_eq!( + p.key_given(Some("sk_dotenv".into())).unwrap().source, + KeySource::Dotenv(root.path().join(".env")) + ); + + // The env file wins over that `.env`, as it does for Next.js and Vite. + std::fs::write(app.join(".env.local"), "ELEVENLABS_API_KEY=sk_file\n").unwrap(); + let k = p.key_given(None).unwrap(); + assert_eq!( + (k.value.as_str(), &k.source), + ("sk_file", &KeySource::EnvFile) + ); + + // A variable really set in the shell wins over every file. + let k = p.key_given(Some("sk_shell".into())).unwrap(); + assert_eq!( + (k.value.as_str(), &k.source), + ("sk_shell", &KeySource::Shell) + ); + assert!(k.file(&p).is_none()); + assert_eq!( + p.key_given(Some("sk_file".into())).unwrap().source, + KeySource::EnvFile + ); + + // An empty line in the env file is the value Next.js and Vite load, over any `.env`. + std::fs::write(app.join(".env.local"), "ELEVENLABS_API_KEY=\n").unwrap(); + assert!( + p.key_given(None).is_none(), + "the placeholder wins over the .env up the tree" + ); + + // A Python app's env file is the `.env` the runtime loads, but the shell's + // value is read from before that, so a key set twice in it is still the + // file's, and the last line (the app's loader's) is checked. + let py = tempfile::tempdir().unwrap(); + std::fs::write(py.path().join("requirements.txt"), "elevenlabs\n").unwrap(); + std::fs::write( + py.path().join(".env"), + "ELEVENLABS_API_KEY=sk_old\nELEVENLABS_API_KEY=sk_new\n", + ) + .unwrap(); + let k = project(py.path()).key_given(None).unwrap(); + assert_eq!( + (k.value.as_str(), &k.source), + ("sk_new", &KeySource::EnvFile) + ); +} + +#[test] +fn the_sdk_counts_only_when_declared_and_imported_in_source() { + let dir = tempfile::tempdir().unwrap(); + let d = dir.path(); + std::fs::write( + d.join("package.json"), + r#"{"dependencies":{"express":"4"}}"#, + ) + .unwrap(); + std::fs::create_dir_all(d.join("node_modules/x")).unwrap(); + std::fs::write( + d.join("node_modules/x/i.js"), + "import '@elevenlabs/elevenlabs-js'", + ) + .unwrap(); + std::fs::write(d.join("README.md"), "uses @elevenlabs/elevenlabs-js").unwrap(); + let p = project(d); + assert!(!p.sdk_declared()); + assert!(!p.sdk_imported(), "node_modules and docs don't count"); + std::fs::write( + d.join("package.json"), + r#"{"dependencies":{"@elevenlabs/elevenlabs-js":"2"}}"#, + ) + .unwrap(); + std::fs::create_dir_all(d.join("src")).unwrap(); + std::fs::write( + d.join("src/speak.ts"), + "import { ElevenLabsClient } from \"@elevenlabs/elevenlabs-js\";", + ) + .unwrap(); + assert!(p.sdk_declared() && p.sdk_imported()); + + // Any ElevenLabs SDK counts, in any component file, but not the CLI. + let web = tempfile::tempdir().unwrap(); + let w = web.path(); + std::fs::write( + w.join("package.json"), + r#"{"devDependencies":{"@elevenlabs/cli":"1"}}"#, + ) + .unwrap(); + std::fs::write( + w.join("App.vue"), + "", + ) + .unwrap(); + let p = project(w); + assert!( + !p.sdk_declared() && !p.sdk_imported(), + "the CLI is not an SDK" + ); + std::fs::write( + w.join("package.json"), + r#"{"dependencies":{"@elevenlabs/react":"0.8"}}"#, + ) + .unwrap(); + std::fs::write( + w.join("App.vue"), + "", + ) + .unwrap(); + assert!(p.sdk_declared() && p.sdk_imported()); + assert!(super::project::imports_js_sdk( + "import '@elevenlabs/client'" + )); + + let py = tempfile::tempdir().unwrap(); + std::fs::write( + py.path().join("pyproject.toml"), + "dependencies = [\"elevenlabs-extra\"]\n", + ) + .unwrap(); + std::fs::write( + py.path().join("app.py"), + "from elevenlabs.client import ElevenLabs\n", + ) + .unwrap(); + let p = project(py.path()); + assert!( + !p.sdk_declared(), + "another package that starts with the name" + ); + std::fs::write( + py.path().join("pyproject.toml"), + "dependencies = [\"elevenlabs>=2\"]\n", + ) + .unwrap(); + assert!(p.sdk_declared() && p.sdk_imported()); + + let setup = tempfile::tempdir().unwrap(); + std::fs::write( + setup.path().join("setup.py"), + "setup(install_requires=[\"elevenlabs\"])\n", + ) + .unwrap(); + assert!(project(setup.path()).sdk_declared(), "setup.py counts too"); + std::fs::write(setup.path().join("setup.py"), "setup()\n").unwrap(); + std::fs::write( + setup.path().join("setup.cfg"), + "[options]\ninstall_requires =\n elevenlabs>=2\n", + ) + .unwrap(); + assert!( + project(setup.path()).sdk_declared(), + "and so does setup.cfg" + ); + + // A monorepo app declares it in its own folder; a sample beside the app doesn't count. + let mono = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(mono.path().join("examples/tts")).unwrap(); + std::fs::write( + mono.path().join("examples/tts/requirements.txt"), + "elevenlabs\n", + ) + .unwrap(); + assert!(!project(mono.path()).sdk_declared()); + // The app's own `examples` folder does. + std::fs::create_dir_all(mono.path().join("app/examples")).unwrap(); + std::fs::write( + mono.path().join("app/examples/page.tsx"), + "import { ElevenLabsClient } from \"@elevenlabs/elevenlabs-js\";", + ) + .unwrap(); + assert!(project(mono.path()).sdk_imported()); + std::fs::create_dir_all(mono.path().join("backend")).unwrap(); + std::fs::write(mono.path().join("backend/requirements.txt"), "elevenlabs\n").unwrap(); + assert!(project(mono.path()).sdk_declared()); +} + +#[test] +fn the_worker_gets_this_commands_arguments() { + use super::session::worker_args_from; + let args = ["onboard", "connect", "--replace", "--launch-worker"].map(std::ffi::OsString::from); + let out = worker_args_from(args.into_iter(), "worker"); + assert_eq!( + out, + ["onboard", "connect", "--replace", "--worker"].map(std::ffi::OsString::from) + ); +} + +#[test] +fn a_session_runs_exactly_while_its_worker_holds_the_lock() { + use super::session::Session; + let dir = tempfile::tempdir().unwrap(); + let s = Session::in_dir(dir.path().to_path_buf()); + assert!(!s.running(), "nothing started"); + let lock = s.take().unwrap().expect("the first worker takes the lock"); + assert!(s.running()); + assert!( + s.take().unwrap().is_none(), + "a second worker for the project stands down" + ); + s.finish(&lock, &serde_json::json!({ "next": "write_code" })) + .unwrap(); + drop(lock); + // A child another test is spawning holds a copy of the lock until it execs. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + while s.running() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(!s.running(), "the lock goes with the worker"); + assert_eq!( + s.wait(std::time::Duration::ZERO, |_| false).unwrap()["next"], + "write_code", + "--wait prints the result" + ); +} + +#[test] +fn waiting_gives_the_link_while_the_approval_is_open() { + use super::connect::wait; + use super::session::Session; + let dir = tempfile::tempdir().unwrap(); + let p = project(dir.path()); + let s = Session::in_dir(dir.path().join("session")); + std::fs::create_dir_all(dir.path().join("session")).unwrap(); + let r = wait(&s, &p, std::time::Duration::ZERO, true, false); + assert_eq!( + (r["next"].as_str(), r["run"].as_str()), + ( + Some("run"), + Some("elevenlabs onboard connect --replace --env-file .env.local") + ), + "the restart keeps --replace" + ); + + let _lock = s.take().unwrap().unwrap(); + std::fs::write( + dir.path().join("session/worker.log"), + "Opening browser…\n URL: https://elevenlabs.io/app/oauth/authorize?x=1\n", + ) + .unwrap(); + let r = wait(&s, &p, std::time::Duration::ZERO, false, false); + assert_eq!(r["next"], "wait"); + assert!(r["instruction"] + .as_str() + .unwrap() + .contains("without ending your turn")); + assert_eq!( + r["run"], + "elevenlabs onboard connect --wait --env-file .env.local" + ); + // A line the worker is still writing is not a link yet. + std::fs::write(dir.path().join("session/worker.log"), " URL: ").unwrap(); + assert!(s.url().is_none()); + std::fs::write( + dir.path().join("session/worker.log"), + "Opening browser…\n URL: https://elevenlabs.io/app/oauth/authorize?x=1\n", + ) + .unwrap(); + assert!(r["say"] + .as_str() + .unwrap() + .contains("https://elevenlabs.io/app/oauth/authorize?x=1")); + let r = wait(&s, &p, std::time::Duration::ZERO, true, false); + assert_eq!( + r["run"], + "elevenlabs onboard connect --replace --wait --env-file .env.local" + ); + + // Without a browser, the first reply hands the link over as soon as there is one. + let mut p = p; + p.no_browser = true; + let started = std::time::Instant::now(); + let r = wait(&s, &p, std::time::Duration::from_secs(20), false, true); + assert_eq!(r["next"], "wait"); + assert!(started.elapsed() < std::time::Duration::from_secs(5)); + + // A sign-in another project replaced: the retry is this command's own. + std::fs::write( + dir.path().join("session/result.json"), + r#"{"next":"ask_user","say":"replaced","details":{"key_status":"sign_in_replaced"}}"#, + ) + .unwrap(); + let r = wait(&s, &p, std::time::Duration::ZERO, true, false); + assert_eq!( + r["run"], + "elevenlabs onboard connect --replace --env-file .env.local --no-browser" + ); +} + +#[cfg(unix)] +#[test] +fn starting_a_sign_in_replaces_another_projects() { + use super::session::Session; + isolate(); + let root = tempfile::tempdir().unwrap(); + let other = Session::in_dir(root.path().join("other")); + std::fs::create_dir_all(root.path().join("other")).unwrap(); + let _held = other.take().unwrap().unwrap(); + // The other worker: a real child process, so only it is signalled. + let mut worker = std::process::Command::new("sleep") + .arg("30") + .spawn() + .unwrap(); + std::fs::write( + root.path().join("other/worker.pid"), + worker.id().to_string(), + ) + .unwrap(); + assert!(other.running()); + + // One past the browser is storing a key the developer approved: left alone. + let storing = Session::in_dir(root.path().join("storing")); + std::fs::create_dir_all(root.path().join("storing")).unwrap(); + let storing_lock = storing.take().unwrap().unwrap(); + storing.signed_in(&storing_lock); + let mut storing_worker = std::process::Command::new("sleep") + .arg("30") + .spawn() + .unwrap(); + std::fs::write( + root.path().join("storing/worker.pid"), + storing_worker.id().to_string(), + ) + .unwrap(); + + Session::in_dir(root.path().join("ours")).stop_others(); + assert!( + !worker.wait().unwrap().success(), + "the other worker was stopped" + ); + let told = other.result().expect("the replaced session is told why"); + assert_eq!(told["details"]["key_status"], "sign_in_replaced"); + assert_eq!(told["next"], "ask_user"); + assert!(told["instruction"].as_str().is_some()); + assert!( + storing_worker.try_wait().unwrap().is_none() && storing.result().is_none(), + "the one storing its key keeps going" + ); + storing_worker.kill().unwrap(); + let _ = storing_worker.wait(); +} + +#[test] +fn the_question_and_the_code_name_no_product() { + use super::reply::{Next, Reply}; + let ask = Reply::new(Next::AskWhatToBuild, "Your key works.").to_value(); + let code = Reply::new(Next::WriteCode, "Next I'll add the SDK.").to_value(); + let told = format!( + "{} {} {}", + ask["instruction"], + code["instruction"], + super::check::WHAT_TO_BUILD + ); + for product in [ + "agents", + "dubbing", + "music", + "sound-effects", + "speech-engine", + "speech-to-text", + "text-to-speech", + "voice-changer", + "voice-isolator", + ] { + assert!(!told.contains(product), "{product}: {told}"); + } + // Asked every time, even when the developer said earlier. + let ask = ask["instruction"].as_str().unwrap(); + assert!( + ask.contains("Ask it even if they already said what they want") + && ask.contains("Write no code before they answer") + && ask.contains("run `run` before anything else"), + "{ask}" + ); + // Right after the answer: the download first, unless they only want the SDK. + let code = code["instruction"].as_str().unwrap(); + assert!(code.contains("Otherwise first run `details.fetch_skills`"), "{code}"); + // Outside the project, where some agents' file search doesn't look. + assert!(code.contains("with shell commands like `ls` and `cat`"), "{code}"); +} + +#[test] +fn the_skills_download_is_two_commands_that_quote_the_folder() { + use super::check::fetch_skills; + let url = "https://github.com/elevenlabs/skills/archive/refs/heads/main.tar.gz"; + let unix = fetch_skills(std::path::Path::new("/tmp/my skills"), false); + assert_eq!( + unix, + format!( + "curl -fsSL -o '/tmp/my skills/skills.tar.gz' {url} && tar -xzf '/tmp/my skills/skills.tar.gz' -C '/tmp/my skills' --strip-components=1" + ) + ); + let windows = fetch_skills(std::path::Path::new(r"C:\Temp\s"), true); + assert!( + windows.starts_with("curl.exe -fsSL -o \"C:\\Temp\\s") + && windows.contains(&format!("{url}; tar -xzf \"")) + && !windows.contains('|'), + "{windows}" + ); +} + +#[test] +fn a_reply_names_the_next_step_and_the_command() { + use super::reply::{Next, Reply}; + let v = Reply::new(Next::WriteCode, "Signed in.") + .run("check") + .with_rules() + .detail("key_last4", "abcd") + .to_value(); + assert_eq!(v["next"], "write_code"); + assert!(v["instruction"] + .as_str() + .unwrap() + .contains("run `run` to check it")); + assert_eq!(v["run"], "elevenlabs onboard check"); + assert!(v["rules"] + .as_array() + .unwrap() + .iter() + .any(|r| r.as_str().unwrap().contains("git clean"))); + assert_eq!(v["details"]["key_last4"], "abcd"); + let bare = Reply::new(Next::Done, "Done.").to_value(); + assert!( + bare.get("run").is_none() && bare.get("rules").is_none() && bare.get("details").is_none() + ); +} + +#[test] +fn a_failed_sign_in_says_what_to_do_and_keeps_its_flags() { + use super::connect::failed; + let dir = tempfile::tempdir().unwrap(); + let mut p = project(dir.path()); + p.no_browser = true; + // The sign-in flow's own wording for a declined approval. + let denied = failed( + &p, + "Authorization server returned error: access_denied (declined)", + true, + ) + .to_value(); + assert_eq!(denied["next"], "ask_user"); + assert_eq!( + denied["run"], + "elevenlabs onboard connect --replace --env-file .env.local --no-browser" + ); + assert!( + denied["say"] + .as_str() + .unwrap() + .contains("app/settings/api-keys"), + "offers the manual path too" + ); + let late = failed( + &p, + "Timed out waiting for the OAuth callback after 300s.", + false, + ) + .to_value(); + assert_eq!(late["next"], "run"); + assert!(late["say"].as_str().unwrap().contains("5 minutes")); + let other = failed(&p, "state mismatch", false).to_value(); + assert!(other["say"].as_str().unwrap().contains("state mismatch")); + assert_eq!(other["details"]["key_status"], "sign_in_failed"); +} + +#[test] +fn every_clipboard_outcome_has_its_reply() { + use super::connect::{outcome_of, reply_for, Outcome}; + let key = "sk_0123456789abcdef0123456789abcdef01234567"; + let ok = |_: &str| Ok(true); + assert_eq!( + outcome_of(Clip::Key(key.into()), ok), + Outcome::Store(key.into()) + ); + let other = outcome_of(Clip::Key(key.into()), |_| Ok(false)); + assert!(matches!( + other, + Outcome::Manual { + status: "clipboard_invalid", + .. + } + )); + let unknown = outcome_of(Clip::Key(key.into()), |_| { + Err(fern_cli_sdk::error::CliError::Validation("x".into())) + }); + assert!(matches!( + unknown, + Outcome::Manual { + status: "could_not_verify", + .. + } + )); + assert_eq!( + outcome_of(Clip::Marker(Marker::NotPermitted), ok), + Outcome::NotPermitted + ); + assert!(matches!( + outcome_of(Clip::Marker(Marker::NotAvailable), ok), + Outcome::Manual { + status: "not_available", + .. + } + )); + assert!(matches!( + outcome_of(Clip::Marker(Marker::Failed), ok), + Outcome::Manual { + status: "failed", + .. + } + )); + assert!(matches!( + outcome_of(Clip::Nothing, ok), + Outcome::Manual { + status: "no_key", + .. + } + )); + + let dir = tempfile::tempdir().unwrap(); + git(dir.path(), &["init", "-q"]); + let p = project(dir.path()); + let stored = reply_for(&p, &Outcome::Store(key.into())) + .unwrap() + .to_value(); + // The key is stored, and the next step is the question. + assert_eq!(stored["next"], "ask_what_to_build"); + assert!( + stored["say"] + .as_str() + .unwrap() + .ends_with(super::check::WHAT_TO_BUILD), + "{stored}" + ); + assert_eq!( + stored["run"], + "elevenlabs onboard check --answered --env-file .env.local" + ); + assert_eq!(stored["details"]["key_status"], "created"); + assert_eq!(stored["details"]["key_last4"], "4567"); + assert_eq!(stored["details"]["env_file"], ".env.local"); + assert!( + !stored.to_string().contains(key), + "the key is never in a reply" + ); + let env = std::fs::read_to_string(dir.path().join(".env.local")).unwrap(); + assert!(env.contains(key)); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(dir.path().join(".env.local")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + + let admin = reply_for(&p, &Outcome::NotPermitted).unwrap().to_value(); + assert_eq!( + ( + admin["next"].as_str(), + admin["details"]["key_status"].as_str() + ), + (Some("ask_user"), Some("not_permitted")) + ); + let manual = reply_for( + &p, + &Outcome::Manual { + status: "no_key", + why: "no key reached this command", + }, + ) + .unwrap() + .to_value(); + assert_eq!(manual["next"], "ask_user"); + assert!(manual["say"] + .as_str() + .unwrap() + .contains("The old key (ending in 4567) is still in .env.local")); + assert_eq!( + manual["run"], + "elevenlabs onboard check --env-file .env.local" + ); + + // A first key in an app that already uses the SDK has nothing to build, + // only the key to load; a replaced one goes straight to check. + let fresh = tempfile::tempdir().unwrap(); + git(fresh.path(), &["init", "-q"]); + std::fs::write( + fresh.path().join("package.json"), + r#"{"dependencies":{"@elevenlabs/elevenlabs-js":"2"}}"#, + ) + .unwrap(); + std::fs::write( + fresh.path().join("index.js"), + "import { ElevenLabsClient } from '@elevenlabs/elevenlabs-js';", + ) + .unwrap(); + let first = reply_for(&project(fresh.path()), &Outcome::Store(key.into())) + .unwrap() + .to_value(); + assert_eq!(first["next"], "load_key"); + assert!(first["details"]["load_key"] + .as_str() + .unwrap() + .contains(".env.local")); + assert!( + !first["say"] + .as_str() + .unwrap() + .contains(super::check::WHAT_TO_BUILD) + && first["details"].get("fetch_skills").is_none(), + "{first}" + ); + std::fs::write( + dir.path().join("package.json"), + r#"{"dependencies":{"@elevenlabs/elevenlabs-js":"2"}}"#, + ) + .unwrap(); + std::fs::write( + dir.path().join("index.js"), + "import { ElevenLabsClient } from '@elevenlabs/elevenlabs-js';", + ) + .unwrap(); + let replaced = reply_for(&p, &Outcome::Store(key.into())) + .unwrap() + .to_value(); + assert_eq!( + (replaced["next"].as_str(), replaced["run"].as_str()), + ( + Some("run"), + Some("elevenlabs onboard check --env-file .env.local") + ) + ); + assert_eq!(replaced["details"]["key_status"], "created"); +} + +#[test] +fn the_manual_path_prepares_the_named_file_and_pins_it_for_check() { + let dir = tempfile::tempdir().unwrap(); + git(dir.path(), &["init", "-q"]); + std::fs::create_dir_all(dir.path().join("config")).unwrap(); + let p = super::project::Project::at(dir.path().to_path_buf(), Some("config/.env.dev".into())); + let v = super::manual::reply(&p, &p.env_file, "No browser.") + .unwrap() + .to_value(); + assert_eq!(v["next"], "ask_user"); + assert_eq!( + v["run"], + "elevenlabs onboard check --env-file config/.env.dev" + ); + let say = v["say"].as_str().unwrap(); + assert!( + say.starts_with("No browser.") + && say.contains("in config/.env.dev") + && say.contains("Never paste it into this chat") + ); + let env = std::fs::read_to_string(dir.path().join("config/.env.dev")).unwrap(); + assert!(env.ends_with("ELEVENLABS_API_KEY=\n")); + assert!( + std::fs::read_to_string(dir.path().join("config/.gitignore")) + .unwrap() + .contains("/.env.dev") + ); + super::manual::reply(&p, &p.env_file, "No browser.").unwrap(); + assert_eq!( + std::fs::read_to_string(dir.path().join("config/.env.dev")).unwrap(), + env, + "nothing duplicated" + ); + let pending = super::check::no_key(&p).unwrap().to_value(); + assert_eq!(pending["next"], "ask_user"); + assert!(pending["say"].as_str().unwrap().contains("still empty")); +} + +#[test] +fn gitignore_entries_match_only_the_env_file() { + let dir = tempfile::tempdir().unwrap(); + git(dir.path(), &["init", "-q"]); + let odd = dir.path().join("keys[dev].env"); + std::fs::write(&odd, "x").unwrap(); + assert!(super::env_file::ensure_ignored(&odd).unwrap(), "added"); + let other = dir.path().join("keysd.env"); + std::fs::write(&other, "x").unwrap(); + assert!( + !super::env_file::ensure_ignored(&odd).unwrap(), + "already ignored" + ); + let status = std::process::Command::new("git") + .arg("-C") + .arg(dir.path()) + .args(["status", "--porcelain"]) + .output() + .unwrap(); + let status = String::from_utf8_lossy(&status.stdout); + assert!( + status.contains("keysd.env") && !status.contains("keys[dev].env"), + "{status}" + ); +} diff --git a/cli/elevenlabs/workflow/project.rs b/cli/elevenlabs/workflow/project.rs index f8536f3..c3c49ae 100644 --- a/cli/elevenlabs/workflow/project.rs +++ b/cli/elevenlabs/workflow/project.rs @@ -207,7 +207,7 @@ fn escaped(path_str: &str, root: &Path) -> CliError { /// Open a file for writing, refusing to follow a symlink at the final /// component. Without this a symlink planted in the project redirects the write /// to its target — including creating the target when the link dangles. -fn create_no_follow(path: &Path) -> Result { +pub(super) fn create_no_follow(path: &Path) -> Result { let mut opts = std::fs::OpenOptions::new(); opts.write(true).create(true).truncate(true); #[cfg(unix)] @@ -284,7 +284,7 @@ pub fn remove_in_project(path_str: &str) -> Result { /// Config files can hold webhook headers and other sensitive values, so keep /// them owner-only (v0 used the same posture for its credential file). -fn restrict_permissions(file: &std::fs::File) { +pub(super) fn restrict_permissions(file: &std::fs::File) { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; diff --git a/cli/elevenlabs/workflow/residency.rs b/cli/elevenlabs/workflow/residency.rs index e4300d3..386df8a 100644 --- a/cli/elevenlabs/workflow/residency.rs +++ b/cli/elevenlabs/workflow/residency.rs @@ -38,12 +38,13 @@ fn base_url_to_export(existing: Option<&str>, residency: &str) -> Option<&'stati /// Export the stored residency's base URL unless something more explicit /// already set one. Precedence ends up: `--base-url` > `ELEVENLABS_BASE_URL` -/// (real env or `.env`) > stored residency > default. +/// (the real environment) > stored residency > default. +/// +/// A `.env` is deliberately not read here: the framework loads it later +/// through its filter, which refuses base URL, proxy and TLS settings from a +/// repository's `.env`. Loading it unfiltered at this point let a `.env` +/// route every request, and the key with it, through a host of its choosing. fn apply_stored_residency() { - // The framework loads `.env` inside `run()`, i.e. after this point, and - // dotenvy never overrides an existing var — so load it here first to see - // a `.env`-provided base URL and leave it alone. - let _ = dotenvy::dotenv(); let existing = std::env::var(BASE_URL_ENV).ok(); let residency = settings::read_residency(); if let Some(url) = base_url_to_export(existing.as_deref(), &residency) { diff --git a/cli/elevenlabs/workflow/say.rs b/cli/elevenlabs/workflow/say.rs index 3b314b9..59c4f4a 100644 --- a/cli/elevenlabs/workflow/say.rs +++ b/cli/elevenlabs/workflow/say.rs @@ -31,15 +31,15 @@ use super::settings; /// George — the voice the co-generated SDK uses in its own doctests, so a /// first run with no config still produces something sensible. -const DEFAULT_VOICE_ID: &str = "JBFqnCBsd6RMkjVDRZzb"; +pub(super) const DEFAULT_VOICE_ID: &str = "JBFqnCBsd6RMkjVDRZzb"; /// The most expressive model, and the only family that honors audio tags /// (`[whispers]`, `[laughs]`) rather than reading them aloud as text. Costs /// roughly 0.8s more to first audio than `eleven_flash_v2_5`, which stays the /// swap when speed matters more than delivery: /// `elevenlabs say config model eleven_flash_v2_5`. -const DEFAULT_MODEL_ID: &str = "eleven_v3"; +pub(super) const DEFAULT_MODEL_ID: &str = "eleven_v3"; -const MP3_FORMAT: &str = "mp3_44100_128"; +pub(super) const MP3_FORMAT: &str = "mp3_44100_128"; const WAV_FORMAT: &str = "wav_44100"; /// Section holding `say`'s settings inside `~/.elevenlabs/config.json`. @@ -450,7 +450,7 @@ fn read_text(source: TextSource) -> Result { /// parks this worker thread so a nested `block_on` is legal — but generic /// over the output so the body can return [`CliError`] directly instead of /// being forced through `SdkError`. -fn run_async(future: F) -> F::Output { +pub(super) fn run_async(future: F) -> F::Output { tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future)) } diff --git a/src/lib.rs b/src/lib.rs index 604d959..124efdc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -79,9 +79,12 @@ pub fn reset_sigpipe() {} /// Unscoped env vars a `.env` file may not set, because each one redirects /// traffic, weakens transport security, or names a program to execute. +/// Matched ignoring case (see [`dotenv_key_is_denied`]). const DOTENV_DENIED_BARE: &[&str] = &[ // Names a program the CLI executes when paging output. "PAGER", + // Names the program that opens the sign-in page. + "BROWSER", // Route traffic through an attacker-chosen intermediary. "HTTP_PROXY", "HTTPS_PROXY", @@ -89,6 +92,7 @@ const DOTENV_DENIED_BARE: &[&str] = &[ "NO_PROXY", // Replace the trust store, enabling transparent interception. "SSL_CERT_FILE", + "SSL_CERT_DIR", "CURL_CA_BUNDLE", "REQUESTS_CA_BUNDLE", ]; @@ -110,9 +114,12 @@ const DOTENV_DENIED_SUFFIXES: &[&str] = &[ "_ALLOW_CROSS_HOST_PAGINATION", ]; -/// True when `key` is one a `.env` file must not be able to set. +/// True when `key` is one a `.env` file must not be able to set. Names are +/// compared ignoring case: the HTTP stack reads lowercase proxy names, and +/// Windows treats environment names as case-insensitive. pub(crate) fn dotenv_key_is_denied(key: &str, prefix: &str) -> bool { - if DOTENV_DENIED_BARE.contains(&key) { + let key = key.to_ascii_uppercase(); + if DOTENV_DENIED_BARE.contains(&key.as_str()) { return true; } key.strip_prefix(prefix) @@ -211,13 +218,23 @@ mod dotenv_filter_tests { "ELEVENLABS_CA_BUNDLE", "ELEVENLABS_EXTRA_CA_CERTS", "PAGER", + "BROWSER", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "SSL_CERT_FILE", + "SSL_CERT_DIR", "CURL_CA_BUNDLE", "REQUESTS_CA_BUNDLE", + // The HTTP stack reads the lowercase names too, and Windows any case. + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", + "elevenlabs_base_url", + "Elevenlabs_Insecure", + "browser", ] { assert!( dotenv_key_is_denied(key, "ELEVENLABS"), diff --git a/tests/onboard_test.rs b/tests/onboard_test.rs new file mode 100644 index 0000000..6a46bf8 --- /dev/null +++ b/tests/onboard_test.rs @@ -0,0 +1,1248 @@ +//! Integration tests for `elevenlabs onboard` (`init`, `connect`, `check`, +//! `test`) against a local mock API. The clipboard hand-off needs a real +//! approval page and clipboard, so it is covered by the onboarding harness +//! (and `store_key`'s outcomes by the unit tests); these run the real binary +//! for every reply that doesn't, and the background sign-in up to a declined +//! approval. They never touch the system clipboard, a browser, or GitHub. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; +use wiremock::matchers::{header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const KEY: &str = "sk_0123456789abcdef0123456789abcdef01234567"; +const VOICE: &str = "JBFqnCBsd6RMkjVDRZzb"; +const OFFLINE: &str = "http://127.0.0.1:9"; + +/// The CLI pointed at `server`, run in `cwd`, with `home` as `HOME` and the +/// developer's own environment kept out (including the agent it runs in). +fn cli(home: &Path, cwd: &Path, server: &str, args: &[&str]) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_elevenlabs")); + cmd.args(["--base-url", server]) + .args(args) + .current_dir(cwd) + .env("HOME", home) + .env("TMPDIR", home) + // The file store follows these before HOME; CI runners set the first. + .env_remove("XDG_CONFIG_HOME") + .env_remove("APPDATA") + .env("NO_COLOR", "1") + .env("FERN_CLI_CREDENTIAL_STORE", "file") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("ELEVENLABS_ONBOARD_NO_CLIPBOARD", "1") + // A browser reaches this machine unless a test says otherwise. + .env("DISPLAY", ":0"); + for var in [ + "ELEVENLABS_API_KEY", + "ELEVENLABS_BASE_URL", + "ELEVENLABS_VIA", + "ELEVENLABS_OUTPUT", + "SSH_CONNECTION", + "SSH_TTY", + "SSH_CLIENT", + "CODESPACES", + "REMOTE_CONTAINERS", + "CLAUDECODE", + "CODEX_THREAD_ID", + "CURSOR_AGENT", + "npm_command", + ] { + cmd.env_remove(var); + } + cmd +} + +fn all_output(output: &Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +/// Run and parse stdout as the reply, failing with both streams when the CLI did not succeed. +fn reply(cmd: &mut Command) -> Value { + let output = cmd.output().unwrap(); + assert!(output.status.success(), "{}", all_output(&output)); + assert!( + !all_output(&output).contains(KEY), + "the key never appears in the output" + ); + serde_json::from_slice(&output.stdout).expect("stdout is JSON") +} + +fn say(reply: &Value) -> &str { + reply["say"].as_str().unwrap_or_default() +} + +fn git(dir: &Path, args: &[&str]) { + let out = Command::new("git") + .args([ + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "-c", + "commit.gpgsign=false", + ]) + .args(args) + .current_dir(dir) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") + // Nor the developer's default excludes file (~/.config/git/ignore). + .env("HOME", dir) + .env_remove("XDG_CONFIG_HOME") + .output() + .unwrap(); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +fn temp() -> (tempfile::TempDir, tempfile::TempDir) { + (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()) +} + +/// `/v1/user` accepts exactly `key` and rejects every other credential. +async fn user_endpoint_accepting(server: &MockServer, key: &str) { + Mock::given(method("GET")) + .and(path("/v1/user")) + .and(header("xi-api-key", key)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"user_id": "user_1"}))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/v1/user")) + .respond_with( + ResponseTemplate::new(401) + .set_body_json(json!({"detail": {"status": "invalid_api_key"}})), + ) + .mount(server) + .await; +} + +fn write(dir: &Path, file: &str, text: &str) { + let path = dir.join(file); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); +} + +// ── check ─────────────────────────────────────────────────────────── + +#[tokio::test] +async fn check_with_nothing_configured_says_to_connect_without_asking_the_api() { + let server = MockServer::start().await; + let (home, cwd) = temp(); + let r = reply(&mut cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "check"], + )); + assert_eq!(r["next"], "run"); + assert_eq!(r["run"], "elevenlabs onboard connect --env-file .env.local"); + assert_eq!(server.received_requests().await.unwrap().len(), 0); + // `status` is the same command. + let r = reply(&mut cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "status"], + )); + assert_eq!(r["run"], "elevenlabs onboard connect --env-file .env.local"); +} + +/// While a sign-in for the project is open, `check` and `init` wait on it +/// rather than starting another. The open sign-in is stood in for by its held lock. +#[test] +fn check_and_init_wait_on_an_open_sign_in() { + use sha2::Digest; + let (home, cwd) = temp(); + let canonical = cwd.path().canonicalize().unwrap(); + let id = sha2::Sha256::digest(canonical.to_string_lossy().as_bytes()); + let id: String = id.iter().take(8).map(|b| format!("{b:02x}")).collect(); + let session = home.path().join(".elevenlabs/onboard").join(id); + std::fs::create_dir_all(&session).unwrap(); + let lock = std::fs::File::create(session.join("worker.lock")).unwrap(); + lock.lock().unwrap(); + let url = "http://127.0.0.1:9/app/oauth/authorize?x=1"; + std::fs::write(session.join("worker.log"), format!(" URL: {url}\n")).unwrap(); + + let r = reply(&mut cli( + home.path(), + cwd.path(), + OFFLINE, + &["onboard", "check"], + )); + assert_eq!( + (r["next"].as_str(), r["run"].as_str()), + ( + Some("wait"), + Some("elevenlabs onboard connect --wait --env-file .env.local") + ), + "{r}" + ); + assert!(say(&r).contains(url), "{r}"); + // So does init, run again mid-approval. + let again = reply(&mut cli( + home.path(), + cwd.path(), + OFFLINE, + &["onboard", "init"], + )); + assert_eq!(again["next"], "wait", "{again}"); + + lock.unlock().unwrap(); + let r = reply(&mut cli( + home.path(), + cwd.path(), + OFFLINE, + &["onboard", "check"], + )); + assert_eq!( + r["run"], "elevenlabs onboard connect --env-file .env.local", + "{r}" + ); +} + +#[test] +fn check_on_a_remote_machine_needs_no_home_folder() { + let (home, cwd) = temp(); + let r = reply( + cli(home.path(), cwd.path(), OFFLINE, &["onboard", "check"]) + .env_remove("HOME") + .env_remove("USERPROFILE") + .env("SSH_CONNECTION", "10.0.0.1 52000 10.0.0.2 22"), + ); + assert_eq!(r["next"], "ask_user", "the manual key steps: {r}"); +} + +#[tokio::test] +async fn check_verifies_the_key_secures_the_file_and_asks_for_the_code() { + let server = MockServer::start().await; + user_endpoint_accepting(&server, KEY).await; + let (home, cwd) = temp(); + git(cwd.path(), &["init", "-q"]); + write( + cwd.path(), + ".env.local", + &format!("export ELEVENLABS_API_KEY=\"{KEY}\"\n"), + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + cwd.path().join(".env.local"), + std::fs::Permissions::from_mode(0o644), + ) + .unwrap(); + } + + let r = reply(&mut cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "check"], + )); + assert_eq!(r["details"]["api_key_source"], "env_file"); + assert_eq!(r["details"]["key_last4"], "4567"); + let fixed = r["details"]["fixed"].to_string(); + assert!(fixed.contains(".gitignore"), "{fixed}"); + // Always the same question first, and no product chosen for the developer. + assert_eq!(r["next"], "ask_what_to_build", "{r}"); + assert_eq!( + say(&r), + "Your key works. What would you like to build with ElevenLabs, or should I just add the SDK for now?" + ); + assert_eq!( + r["run"], + "elevenlabs onboard check --answered --env-file .env.local" + ); + assert!(r["details"].get("skills_dir").is_none(), "{r}"); + + // Once the developer has answered: how to write the code, with the rules again. + let r = reply(&mut cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "check", "--answered"], + )); + assert_eq!(r["next"], "write_code", "{r}"); + assert!(r["rules"].as_array().is_some_and(|r| !r.is_empty()), "{r}"); + assert_eq!( + r["run"], + "elevenlabs onboard check --answered --env-file .env.local" + ); + assert!( + r["details"]["load_key"] + .as_str() + .unwrap() + .contains("--env-file=.env.local"), + "{r}" + ); + assert!( + r["details"].get("ask_what_to_build").is_none() && r["details"].get("voice_id").is_none(), + "{r}" + ); + // The skills go in a new folder in the temp folder (here HOME), never the project. + let skills = PathBuf::from(r["details"]["skills_dir"].as_str().unwrap()); + assert!( + skills.is_dir() && skills.starts_with(home.path()) && !skills.starts_with(cwd.path()), + "{r}" + ); + assert_eq!(std::fs::read_dir(&skills).unwrap().count(), 0, "a new folder"); + let fetch = r["details"]["fetch_skills"].as_str().unwrap(); + assert!( + fetch.starts_with("curl -fsSL -o ") + && fetch.contains("https://github.com/elevenlabs/skills/archive/refs/heads/main.tar.gz") + && fetch.contains(&format!("-C {}", skills.display())), + "{fetch}" + ); + // The first check secured the file. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert!(fixed.contains("readable only by you"), "{fixed}"); + let mode = std::fs::metadata(cwd.path().join(".env.local")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + + // With the SDK declared and imported, the next step is offering the test. + write( + cwd.path(), + "package.json", + r#"{"dependencies":{"@elevenlabs/elevenlabs-js":"^2"}}"#, + ); + write( + cwd.path(), + "src/speak.js", + "import { ElevenLabsClient } from \"@elevenlabs/elevenlabs-js\";\n", + ); + let r = reply(&mut cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "check"], + )); + assert_eq!(r["next"], "offer_test", "{r}"); + assert_eq!(r["run"], "elevenlabs onboard test --env-file .env.local"); + assert!(say(&r).contains("Want me to send a test request?"), "{r}"); + assert_eq!(r["details"]["fixed"], json!([]), "nothing left to fix"); +} + +#[tokio::test] +async fn the_reply_is_json_even_when_prose_is_asked_for() { + // Only agents run these steps; prose would drop the instruction, rules and details. + let server = MockServer::start().await; + user_endpoint_accepting(&server, KEY).await; + let (home, cwd) = temp(); + write(cwd.path(), ".env.local", &format!("ELEVENLABS_API_KEY={KEY}\n")); + for flag in [&["--human"][..], &["--format", "table"][..]] { + let args: Vec<&str> = ["onboard", "check"].iter().chain(flag).copied().collect(); + let r = reply(&mut cli(home.path(), cwd.path(), &server.uri(), &args)); + assert!(r["instruction"].as_str().is_some() && r["details"].is_object(), "{flag:?}: {r}"); + } +} + +#[tokio::test] +async fn a_key_that_cannot_read_the_account_still_works() { + // Keys made by hand on the API keys page may lack `user_read`, which only the check needs. + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user")) + .respond_with( + ResponseTemplate::new(401) + .set_body_json(json!({"detail": {"status": "missing_permissions"}})), + ) + .mount(&server) + .await; + let (home, cwd) = temp(); + write( + cwd.path(), + ".env.local", + &format!("ELEVENLABS_API_KEY={KEY}\n"), + ); + let r = reply(&mut cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "check"], + )); + assert_eq!(r["next"], "ask_what_to_build", "{r}"); +} + +#[tokio::test] +async fn an_error_from_elevenlabs_is_not_a_rejected_key() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user")) + .respond_with(ResponseTemplate::new(503)) + .mount(&server) + .await; + let (home, cwd) = temp(); + write( + cwd.path(), + ".env.local", + &format!("ELEVENLABS_API_KEY={KEY}\n"), + ); + let r = reply(&mut cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "check"], + )); + assert_eq!(r["next"], "ask_user", "{r}"); + assert!(say(&r).contains("answered 503"), "{r}"); + let r = reply(&mut cli( + home.path(), + cwd.path(), + OFFLINE, + &["onboard", "check"], + )); + assert!(say(&r).contains("couldn't be reached"), "{r}"); +} + +#[tokio::test] +async fn check_stops_at_a_tracked_env_file() { + let server = MockServer::start().await; + user_endpoint_accepting(&server, KEY).await; + let (home, cwd) = temp(); + git(cwd.path(), &["init", "-q"]); + write( + cwd.path(), + ".env.local", + &format!("ELEVENLABS_API_KEY={KEY}\n"), + ); + git(cwd.path(), &["add", ".env.local"]); + let r = reply(&mut cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "check"], + )); + assert_eq!(r["next"], "fix", "{r}"); + assert!(say(&r).contains("git rm --cached .env.local"), "{r}"); +} + +#[tokio::test] +async fn check_says_how_to_replace_a_rejected_key_wherever_it_lives() { + let server = MockServer::start().await; + user_endpoint_accepting(&server, KEY).await; + let (home, cwd) = temp(); + write( + cwd.path(), + ".env", + "ELEVENLABS_API_KEY=sk_stale_0000000000000000\n", + ); + + let r = reply(&mut cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "check", "--env-file", ".env"], + )); + assert_eq!(r["next"], "run", "{r}"); + assert_eq!( + r["run"], + "elevenlabs onboard connect --replace --env-file .env" + ); + + // A rejected shell variable can only be fixed in the shell. + let r = reply( + cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "check", "--env-file", ".env"], + ) + .env("ELEVENLABS_API_KEY", "sk_shell_000000000000000000"), + ); + assert_eq!(r["next"], "ask_user", "{r}"); + assert!(say(&r).contains("set in your shell"), "{r}"); + + // A working shell variable wins over the file. + let r = reply( + cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "check", "--env-file", ".env"], + ) + .env("ELEVENLABS_API_KEY", KEY), + ); + assert_eq!(r["details"]["api_key_source"], "shell", "{r}"); +} + +#[tokio::test] +async fn a_rejected_key_on_a_remote_machine_is_replaced_in_its_own_file() { + let server = MockServer::start().await; + user_endpoint_accepting(&server, KEY).await; + let (home, root) = temp(); + let app = root.path().join("app"); + write( + root.path(), + ".env", + "ELEVENLABS_API_KEY=sk_stale_0000000000000000\n", + ); + write(&app, "package.json", "{}"); + let r = reply( + cli(home.path(), &app, &server.uri(), &["onboard", "check"]) + .env("SSH_CONNECTION", "1 2 3 4"), + ); + assert_eq!(r["next"], "ask_user", "{r}"); + let env = r["details"]["env_file"].as_str().unwrap().to_string(); + assert!(env.ends_with(".env") && !env.ends_with(".env.local"), "{r}"); + assert!(say(&r).contains(&format!("in {env}")), "{r}"); + assert!( + r["run"] + .as_str() + .unwrap() + .contains(&format!("--env-file {env}")), + "{r}" + ); +} + +#[tokio::test] +async fn check_tells_a_dotenv_key_apart_from_the_env_file() { + let server = MockServer::start().await; + user_endpoint_accepting(&server, KEY).await; + let (home, cwd) = temp(); + write(cwd.path(), ".env", &format!("ELEVENLABS_API_KEY={KEY}\n")); + // How to load the key comes with the code, once the developer has answered. + let r = reply(&mut cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "check", "--answered"], + )); + assert_eq!(r["details"]["api_key_source"], "dotenv", "{r}"); + // The code is told to load the file the key is in, not the default env file. + assert_eq!(r["details"]["env_file"], ".env", "{r}"); + assert!( + r["details"]["load_key"] + .as_str() + .unwrap() + .contains("It's in .env. "), + "{r}" + ); + let r = reply(&mut cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "check", "--env-file", ".env"], + )); + assert_eq!(r["details"]["api_key_source"], "env_file", "{r}"); +} + +// ── test ──────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_makes_one_text_to_speech_request_and_saves_the_clip() { + let server = MockServer::start().await; + let audio = b"ID3-fake-mp3".to_vec(); + Mock::given(method("POST")) + .and(path(format!("/v1/text-to-speech/{VOICE}"))) + .and(header("xi-api-key", KEY)) + .respond_with(ResponseTemplate::new(200).set_body_bytes(audio.clone())) + .expect(1) + .mount(&server) + .await; + let (home, cwd) = temp(); + write( + cwd.path(), + ".env.local", + &format!("ELEVENLABS_API_KEY={KEY}\n"), + ); + + let r = reply(&mut cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "test"], + )); + assert_eq!(r["next"], "done", "{r}"); + assert_eq!(r["details"]["status"], 200); + let clip = PathBuf::from(r["details"]["audio_file"].as_str().unwrap()); + assert_eq!(std::fs::read(&clip).unwrap(), audio); + assert!(say(&r).contains("Play"), "{r}"); + assert!( + say(&r).contains("ElevenLabs is set up: your key is in .env.local"), + "the last word says what is set up: {r}" + ); + let sent = &server.received_requests().await.unwrap()[0]; + let body: Value = serde_json::from_slice(&sent.body).unwrap(); + assert_eq!(body["text"], "Welcome to ElevenLabs"); +} + +#[tokio::test] +async fn test_reports_missing_credits_and_other_failures_as_done() { + let server = MockServer::start().await; + // An exhausted quota, as ElevenLabs reports it. + Mock::given(method("POST")) + .and(path(format!("/v1/text-to-speech/{VOICE}"))) + .respond_with( + ResponseTemplate::new(401) + .set_body_json(json!({"detail": {"status": "quota_exceeded", "message": "quota"}})), + ) + .mount(&server) + .await; + let (home, cwd) = temp(); + write( + cwd.path(), + ".env.local", + &format!("ELEVENLABS_API_KEY={KEY}\n"), + ); + let r = reply(&mut cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "test"], + )); + assert_eq!( + (r["next"].as_str(), r["details"]["status"].as_u64()), + (Some("done"), Some(401)), + "{r}" + ); + assert!(say(&r).contains("needs credits"), "{r}"); + + let failing = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v1/text-to-speech/{VOICE}"))) + .respond_with( + ResponseTemplate::new(500) + .set_body_json(json!({"detail": {"message": "speech service down"}})), + ) + .mount(&failing) + .await; + let r = reply(&mut cli( + home.path(), + cwd.path(), + &failing.uri(), + &["onboard", "test"], + )); + assert!(say(&r).contains("500: speech service down"), "{r}"); + assert!(say(&r).contains("try again later"), "{r}"); + + // A key the request refuses won't work later either. + let refused = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v1/text-to-speech/{VOICE}"))) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "detail": {"status": "missing_permissions", "message": "missing text_to_speech"} + }))) + .mount(&refused) + .await; + let r = reply(&mut cli( + home.path(), + cwd.path(), + &refused.uri(), + &["onboard", "test"], + )); + assert!( + say(&r).contains("401: missing text_to_speech") && !say(&r).contains("try again"), + "{r}" + ); + + let r = reply(&mut cli( + home.path(), + cwd.path(), + OFFLINE, + &["onboard", "test"], + )); + assert_eq!(r["next"], "done", "{r}"); + assert!(say(&r).contains("couldn't reach"), "{r}"); + + std::fs::remove_file(cwd.path().join(".env.local")).unwrap(); + let r = reply(&mut cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "test"], + )); + assert_eq!( + r["run"], "elevenlabs onboard check --env-file .env.local", + "no key: nothing is sent" + ); +} + +// ── init ──────────────────────────────────────────────────────────── + +#[tokio::test] +async fn init_hands_on_to_sign_in() { + let server = MockServer::start().await; + let (home, cwd) = temp(); + + let r = reply( + cli(home.path(), cwd.path(), &server.uri(), &["onboard", "init"]) + .env("CLAUDECODE", "1"), + ); + assert_eq!(r["next"], "run", "{r}"); + assert!(say(&r).contains("approval page in your browser"), "{r}"); + assert_eq!(r["run"], "elevenlabs onboard connect --env-file .env.local"); + assert!(r["rules"] + .to_string() + .contains("Never open, print or cat the env file")); + assert!(!cwd.path().join(".claude").exists(), "no skills are installed"); + + // Flags go on to the next command. + let r = reply( + cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "init", "--no-browser"], + ) + .env("CLAUDECODE", "1") + .env("CODEX_THREAD_ID", "t"), + ); + assert_eq!( + r["run"], "elevenlabs onboard connect --env-file .env.local --no-browser", + "init's flags go on to connect" + ); + + // Under npx, every next command runs the same latest CLI. + let r = reply( + cli(home.path(), cwd.path(), &server.uri(), &["onboard", "init"]) + .env("npm_command", "exec"), + ); + assert_eq!( + r["run"], + "npx -y @elevenlabs/cli@latest onboard connect --env-file .env.local" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn a_repositorys_own_git_config_cannot_run_a_command() { + // A checkout's .git/config is read by every git command in it, and its + // core.fsmonitor runs on `ls-files` and `check-ignore`. + let server = MockServer::start().await; + let (home, cwd) = temp(); + let marker = home.path().join("payload-ran"); + let payload = home.path().join("payload.sh"); + std::fs::write( + &payload, + format!("#!/bin/sh\ntouch {}\nexit 1\n", marker.display()), + ) + .unwrap(); + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&payload, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + git(cwd.path(), &["init", "-q"]); + write(cwd.path(), "README.md", "app\n"); + git(cwd.path(), &["add", "README.md"]); + git(cwd.path(), &["commit", "-qm", "app"]); + git( + cwd.path(), + &["config", "core.fsmonitor", &payload.display().to_string()], + ); + let r = reply(&mut cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "init"], + )); + assert_eq!(r["next"], "run", "{r}"); + assert!(!marker.exists(), "the repository's fsmonitor ran a command"); +} + +#[tokio::test] +async fn a_projects_env_cannot_send_the_key_through_a_proxy() { + // Proxy and TLS settings in a repository's `.env` are refused, so the key goes straight to the API. + let server = MockServer::start().await; + user_endpoint_accepting(&server, KEY).await; + let (home, cwd) = temp(); + write( + cwd.path(), + ".env", + &format!( + "ELEVENLABS_API_KEY={KEY}\nHTTP_PROXY=http://127.0.0.1:9\nHTTPS_PROXY=http://127.0.0.1:9\n\ + ALL_PROXY=http://127.0.0.1:9\nhttp_proxy=http://127.0.0.1:9\nhttps_proxy=http://127.0.0.1:9\n\ + all_proxy=http://127.0.0.1:9\n" + ), + ); + let r = reply( + cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "check", "--env-file", ".env"], + ) + .env_remove("HTTP_PROXY") + .env_remove("HTTPS_PROXY") + .env_remove("ALL_PROXY") + .env_remove("http_proxy") + .env_remove("https_proxy") + .env_remove("all_proxy"), + ); + assert_eq!( + r["next"], "ask_what_to_build", + "the check reached the API directly: {r}" + ); +} + +#[tokio::test] +async fn init_checks_a_key_that_is_already_there() { + // The check's own reply, so the question about what to build can't be skipped. + let server = MockServer::start().await; + user_endpoint_accepting(&server, KEY).await; + let (home, cwd) = temp(); + write( + cwd.path(), + ".env.local", + &format!("ELEVENLABS_API_KEY={KEY}\n"), + ); + let r = reply(&mut cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "init"], + )); + assert_eq!(r["next"], "ask_what_to_build", "{r}"); + assert!(say(&r).ends_with("or should I just add the SDK for now?"), "{r}"); + assert_eq!(r["run"], "elevenlabs onboard check --answered --env-file .env.local"); + assert_eq!(r["details"]["env_file"], ".env.local"); + assert!(r["rules"].as_array().is_some_and(|r| !r.is_empty()), "{r}"); + + // The SDK already wired in: straight to the test offer. + write( + cwd.path(), + "package.json", + r#"{"dependencies":{"@elevenlabs/elevenlabs-js":"^2"}}"#, + ); + write( + cwd.path(), + "index.js", + "import { ElevenLabsClient } from \"@elevenlabs/elevenlabs-js\";\n", + ); + let r = reply(&mut cli( + home.path(), + cwd.path(), + &server.uri(), + &["onboard", "init"], + )); + assert_eq!(r["next"], "offer_test", "{r}"); +} + +#[test] +fn init_on_a_remote_machine_prepares_the_manual_key() { + let (home, cwd) = temp(); + git(cwd.path(), &["init", "-q"]); + let r = reply( + cli(home.path(), cwd.path(), OFFLINE, &["onboard", "init"]) + .env("SSH_CONNECTION", "10.0.0.1 22 10.0.0.2 22"), + ); + assert_eq!(r["next"], "ask_user", "{r}"); + assert_eq!(r["run"], "elevenlabs onboard check --env-file .env.local"); + assert!( + say(&r).contains("elevenlabs.io/app/settings/api-keys"), + "{r}" + ); + assert!(std::fs::read_to_string(cwd.path().join(".env.local")) + .unwrap() + .ends_with("ELEVENLABS_API_KEY=\n")); + assert!(std::fs::read_to_string(cwd.path().join(".gitignore")) + .unwrap() + .contains("/.env.local")); +} + +// ── connect: everything before the browser ────────────────────────── + +fn connect(home: &Path, cwd: &Path, args: &[&str]) -> Value { + reply(&mut cli( + home, + cwd, + OFFLINE, + &[&["onboard", "connect"], args].concat(), + )) +} + +#[test] +fn connect_refuses_a_tracked_file_and_a_symlink() { + let (home, cwd) = temp(); + git(cwd.path(), &["init", "-q"]); + write(cwd.path(), ".env", "TRACKED=1\n"); + git(cwd.path(), &["add", ".env"]); + let r = connect( + home.path(), + cwd.path(), + &["--env-file", ".env", "--replace"], + ); + assert_eq!(r["next"], "fix", "{r}"); + assert!(say(&r).contains("git rm --cached .env"), "{r}"); + assert_eq!( + r["run"], "elevenlabs onboard connect --replace --env-file .env", + "the fix makes the same step work" + ); + assert_eq!( + std::fs::read_to_string(cwd.path().join(".env")).unwrap(), + "TRACKED=1\n" + ); + + #[cfg(unix)] + { + std::os::unix::fs::symlink(cwd.path().join("elsewhere"), cwd.path().join(".env.local")) + .unwrap(); + let r = connect(home.path(), cwd.path(), &[]); + assert!(say(&r).contains("is a symlink"), "{r}"); + } +} + +#[test] +fn connect_keeps_an_existing_key_unless_told_to_replace_it() { + let (home, cwd) = temp(); + write( + cwd.path(), + ".env.local", + &format!("ELEVENLABS_API_KEY={KEY}\n"), + ); + let r = connect(home.path(), cwd.path(), &[]); + assert_eq!( + (r["next"].as_str(), r["run"].as_str()), + ( + Some("run"), + Some("elevenlabs onboard check --env-file .env.local") + ), + "{r}" + ); + assert!(say(&r).contains("ending in 4567"), "{r}"); + + // With --replace the key is no longer the reason to stop: here, the remote shell is, + // even with --no-browser (the page would copy the key on another machine). + let r = reply( + cli( + home.path(), + cwd.path(), + OFFLINE, + &["onboard", "connect", "--replace", "--no-browser"], + ) + .env("SSH_CONNECTION", "1 2 3 4"), + ); + assert_eq!(r["next"], "ask_user", "{r}"); + assert!( + say(&r).contains("A browser can't reach this machine"), + "{r}" + ); +} + +#[test] +fn connect_replaces_a_dotenv_key_in_that_file() { + let (home, cwd) = temp(); + write(cwd.path(), ".env", &format!("ELEVENLABS_API_KEY={KEY}\n")); + let r = connect(home.path(), cwd.path(), &["--replace", "--no-browser"]); + assert_eq!(r["next"], "run", "{r}"); + let run = r["run"].as_str().unwrap(); + assert!( + run.starts_with("elevenlabs onboard connect --replace --env-file ") + && run.ends_with(".env --no-browser"), + "{run}" + ); +} + +#[test] +fn connect_wait_without_a_sign_in_starts_one() { + let (home, cwd) = temp(); + let r = connect(home.path(), cwd.path(), &["--wait"]); + assert_eq!( + (r["next"].as_str(), r["run"].as_str()), + ( + Some("run"), + Some("elevenlabs onboard connect --env-file .env.local") + ), + "{r}" + ); +} + +#[test] +fn onboard_lists_its_steps_and_stays_out_of_the_top_level_help() { + let (home, cwd) = temp(); + let out = cli(home.path(), cwd.path(), OFFLINE, &["onboard"]) + .output() + .unwrap(); + let help = all_output(&out); + for step in ["init", "connect", "check", "test"] { + assert!(help.contains(step), "help lacks {step}:\n{help}"); + } + let out = cli( + home.path(), + cwd.path(), + OFFLINE, + &["onboard", "connect", "--help"], + ) + .output() + .unwrap(); + let help = String::from_utf8_lossy(&out.stdout); + for flag in ["--env-file", "--no-browser", "--replace", "--wait"] { + assert!(help.contains(flag), "connect help lacks {flag}:\n{help}"); + } + for hidden in ["--authorize-url", "--worker", "--launch-worker"] { + assert!(!help.contains(hidden), "{hidden} stays hidden:\n{help}"); + } + let top = cli(home.path(), cwd.path(), OFFLINE, &["--help"]) + .output() + .unwrap(); + assert!(!String::from_utf8_lossy(&top.stdout).contains("onboard")); +} + +// ── connect: the background sign-in ───────────────────────────────── + +/// The session folder the worker writes to, and the approval link it logged. +fn approval(home: &Path) -> (PathBuf, String) { + let deadline = Instant::now() + Duration::from_secs(60); + loop { + for dir in std::fs::read_dir(home.join(".elevenlabs/onboard")) + .into_iter() + .flatten() + .flatten() + { + let log = std::fs::read_to_string(dir.path().join("worker.log")).unwrap_or_default(); + if let Some(url) = log.lines().find_map(|l| l.trim().strip_prefix("URL: ")) { + return (dir.path(), url.to_string()); + } + } + assert!( + Instant::now() < deadline, + "the worker never printed the approval link" + ); + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn query(url: &str, name: &str) -> String { + reqwest::Url::parse(url) + .unwrap() + .query_pairs() + .find(|(k, _)| k == name) + .map(|(_, v)| v.into_owned()) + .unwrap_or_else(|| panic!("no {name} in {url}")) +} + +/// The browser's side of a declined approval: the page sends it back to the +/// callback. `localhost` is dialed as a browser would, IPv4 or IPv6. +fn decline(url: &str) { + callback(url, "error=access_denied&error_description=declined"); +} + +/// The browser's side of an approval: the page sends the code to the callback. +fn approve(url: &str) { + callback(url, "code=approved"); +} + +fn callback(url: &str, params: &str) { + let redirect = reqwest::Url::parse(&query(url, "redirect_uri")).unwrap(); + let authority = format!( + "{}:{}", + redirect.host_str().unwrap(), + redirect.port().unwrap() + ); + let state = query(url, "state"); + let request = format!( + "GET {}?{params}&state={state} HTTP/1.1\r\nHost: {authority}\r\nConnection: close\r\n\r\n", + redirect.path() + ); + // The worker can be a moment from listening on a slow machine (CI): it may refuse the + // connection or drop it unread. Try again a few times, as a browser's retry would. + let mut last = None; + for _ in 0..10 { + let sent = std::net::TcpStream::connect(&authority).and_then(|mut stream| { + stream.write_all(request.as_bytes())?; + std::io::Read::read_to_end(&mut stream, &mut Vec::new()) + }); + match sent { + Ok(_) => return, + Err(e) => last = Some(e), + } + std::thread::sleep(Duration::from_millis(300)); + } + panic!("the callback never reached the sign-in: {last:?}"); +} + +fn reply_of(child: std::process::Child) -> Value { + let out = child.wait_with_output().unwrap(); + assert!(out.status.success(), "{}", all_output(&out)); + serde_json::from_slice(&out.stdout).unwrap() +} + +/// The approval link in a `wait` reply. +fn link(reply: &Value) -> String { + let say = say(reply); + let at = say.find("open this link: ").expect("a link") + "open this link: ".len(); + say[at..].trim().to_string() +} + +/// The tests that sign in take turns: each listens on the callback ports. +async fn signing_in() -> tokio::sync::MutexGuard<'static, ()> { + static PORTS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + PORTS.lock().await +} + +/// Something already listens on the sign-in's callback ports (another sign-in +/// on this machine), which this test would disturb. +fn callback_ports_busy() -> bool { + [8484, 8483, 8482] + .iter() + .any(|p| std::net::TcpStream::connect(("localhost", *p)).is_ok()) +} + +#[tokio::test] +async fn connect_signs_in_in_the_background_and_every_waiter_gets_the_result() { + let _turn = signing_in().await; + if callback_ports_busy() { + eprintln!("skipped: another sign-in is listening on the callback ports"); + return; + } + let server = MockServer::start().await; + let (home, cwd) = temp(); + let authorize = format!("{}/app/oauth/authorize", server.uri()); + let start = |args: &[&str]| { + cli( + home.path(), + cwd.path(), + &server.uri(), + &[ + &[ + "onboard", + "connect", + "--no-browser", + "--authorize-url", + &authorize, + ], + args, + ] + .concat(), + ) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap() + }; + // Without a browser, connect hands the link over as soon as the worker has it. + let first = reply_of(start(&[])); + assert_eq!(first["next"], "wait", "{first}"); + let (session, url) = approval(home.path()); + assert!(say(&first).contains(&url), "{first}"); + let worker = std::fs::read_to_string(session.join("worker.pid")).unwrap(); + assert!(url.starts_with(&authorize), "{url}"); + assert_eq!(query(&url, "key_handoff"), "clipboard"); + assert!( + !url.contains("product="), + "one key for every product: {url}" + ); + + // Another connect for the same request waits on that sign-in. + let again = reply_of(start(&[])); + assert!(say(&again).contains(&url), "{again}"); + assert_eq!( + std::fs::read_to_string(session.join("worker.pid")).unwrap(), + worker, + "still the first worker" + ); + + // One for another env file replaces it, with a new approval link. + let other = reply_of(start(&["--env-file", "other.env"])); + assert_eq!(other["next"], "wait", "{other}"); + let url = link(&other); + assert_ne!( + std::fs::read_to_string(session.join("worker.pid")).unwrap(), + worker, + "a new worker for the new request" + ); + + // --wait keeps waiting, and gets the result when it lands. + let waiter = start(&["--env-file", "other.env", "--wait"]); + std::thread::sleep(Duration::from_millis(500)); + decline(&url); + let r = reply_of(waiter); + assert_eq!(r["next"], "ask_user", "{r}"); + assert!(say(&r).contains("declined"), "{r}"); + assert_eq!(r["details"]["key_status"], "sign_in_failed"); + assert!( + r["run"].as_str().unwrap().contains("--env-file other.env"), + "{r}" + ); + // The result stays readable until a new sign-in starts. + let r = connect( + home.path(), + cwd.path(), + &["--env-file", "other.env", "--wait"], + ); + assert!(say(&r).contains("declined"), "{r}"); + assert!( + !cwd.path().join(".env.local").exists(), + "nothing was stored" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn a_projects_env_cannot_reach_the_git_the_sign_in_runs() { + let _turn = signing_in().await; + if callback_ports_busy() { + eprintln!("skipped: another sign-in is listening on the callback ports"); + return; + } + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "refresh" + }))) + .mount(&server) + .await; + let (home, cwd) = temp(); + git(cwd.path(), &["init", "-q"]); + // The background sign-in is this program started again, after this one loaded the `.env`. + let trace = home.path().join("git-trace"); + write( + cwd.path(), + ".env", + &format!("GIT_TRACE={}\n", trace.display()), + ); + let authorize = format!("{}/app/oauth/authorize", server.uri()); + let first = reply(&mut cli( + home.path(), + cwd.path(), + &server.uri(), + &[ + "onboard", + "connect", + "--no-browser", + "--authorize-url", + &authorize, + ], + )); + assert_eq!(first["next"], "wait", "{first}"); + let (_, url) = approval(home.path()); + approve(&url); + // Generous: the background sign-in is slow on a loaded machine, and the loop ends when it does. + let deadline = Instant::now() + Duration::from_secs(60); + let done = loop { + let r = connect(home.path(), cwd.path(), &["--wait", "--no-browser"]); + if r["next"] != "wait" || Instant::now() >= deadline { + break r; + } + }; + // No key reached the clipboard, so the env file was prepared for one, and gitignored. + assert_eq!(done["details"]["key_status"], "no_key", "{done}"); + assert!(std::fs::read_to_string(cwd.path().join(".gitignore")) + .unwrap() + .contains(".env.local")); + assert!( + !trace.exists(), + "the .env's GIT_TRACE reached the worker's git" + ); +}