diff --git a/docs/platform-module-auth.md b/docs/platform-module-auth.md new file mode 100644 index 0000000..3f5f9cf --- /dev/null +++ b/docs/platform-module-auth.md @@ -0,0 +1,44 @@ +# Platform → Module Auth: Per-Tunnel Service Token + +## Problem + +Platform calls modules via tunnel (lifecycle install, RPC). Today uses `MS_INTERNAL_SECRET` shared symmetric key. Breaks because CLI generates a random one for compose while the platform has a different one. + +## Design + +Use the **`stk_*` service token** already minted by dispatch during tunnel registration. Platform-minted, per-tunnel, delivered through the authenticated WSS channel. + +### Flow + +``` +mirrorstack dev --tunnel + → CLI registers tunnel via WSS (authenticated by user OAuth) + → dispatch mints stk_* token, stores in session, returns in register_ack + → CLI receives stk_*, passes as MS_PLATFORM_TOKEN to compose env + → module SDK reads MS_PLATFORM_TOKEN from env + +Platform → Module request + → lifecycle client looks up stk_* from tunnel session (Redis) + → sends as X-MS-Platform-Token header + → SDK validates via constant-time compare against MS_PLATFORM_TOKEN env +``` + +### Changes + +**api-platform** (3 files): +1. `internal/dispatch/ws/sessions.go` — add `ServiceToken` to Session struct +2. `internal/dispatch/ws/handlers.go` — store service token in session during handleRegister +3. `internal/applications/service/module_lifecycle.go` — read service token from session, send as `X-MS-Platform-Token` + +**app-module-sdk** (1 file): +1. `auth/middleware.go` — `InternalAuth` checks `X-MS-Platform-Token` against `MS_PLATFORM_TOKEN` env. Fallback to `MS_INTERNAL_SECRET` for backward compat. + +**mirrorstack-cli** (2 files): +1. `src/commands/dev/tunnel.rs` — expose `service_token` from `RegisterAck` on `TunnelHandle` +2. `src/commands/dev/mod.rs` — pass `MS_PLATFORM_TOKEN` from tunnel handles to compose env. Remove `MS_INTERNAL_SECRET` generation. + +### Build Order + +1. api-platform: store + expose service token +2. app-module-sdk: validate `X-MS-Platform-Token` +3. mirrorstack-cli: pass token through to compose diff --git a/src/api.rs b/src/api.rs index f11575d..65675f2 100644 --- a/src/api.rs +++ b/src/api.rs @@ -214,6 +214,71 @@ pub fn create_module( }) } +#[derive(Debug, Deserialize)] +#[allow(dead_code)] // name / owner_id / created_at are part of the API surface +pub struct App { + pub id: String, + pub name: String, + pub slug: String, + #[serde(default)] + pub owner_id: Option, + #[serde(default)] + pub created_at: Option, +} + +#[derive(Debug, Serialize)] +pub struct CreateAppInput<'a> { + pub name: &'a str, + pub slug: &'a str, +} + +/// POST /v1/apps — create an application on the platform. +pub fn create_app( + http: &Client, + apps_base: &str, + access_token: &str, + input: &CreateAppInput, +) -> Result { + let endpoint = format!("{}/v1/apps", apps_base.trim_end_matches('/')); + + let resp = http + .post(&endpoint) + .bearer_auth(access_token) + .header("Accept", "application/json") + .json(input) + .send()?; + + let status = resp.status(); + if status.is_success() { + return Ok(resp.json::()?); + } + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(ApiError::Unauthenticated); + } + + let status_u16 = status.as_u16(); + let body = match http::read_capped(resp) { + Ok(b) => b, + Err(e) => { + return Err(ApiError::Unexpected { + status: status_u16, + body: format!("(read body failed: {e})"), + }); + } + }; + if let Ok(env) = serde_json::from_slice::(&body) { + return Err(ApiError::Server { + status: status_u16, + code: env.error.code, + message: env.error.message, + }); + } + Err(ApiError::Unexpected { + status: status_u16, + body: String::from_utf8_lossy(&body).into_owned(), + }) +} + /// Body of a successful `POST /v1/auth/sessions/refresh`. The platform /// rotates the refresh token on every refresh (replay defense), so we /// must persist the new one back to credentials. expires_at is RFC3339 @@ -546,6 +611,64 @@ mod tests { } } + #[test] + fn create_app_success() { + let mut server = Server::new(); + let _m = server + .mock("POST", "/v1/apps") + .match_header("authorization", "Bearer AT") + .with_status(201) + .with_body( + json!({ + "id": "a-1", + "name": "My App", + "slug": "my-app", + "owner_id": "u-1", + "created_at": "2026-05-28T00:00:00Z" + }) + .to_string(), + ) + .create(); + + let a = create_app( + &test_client(), + &server.url(), + "AT", + &CreateAppInput { + name: "My App", + slug: "my-app", + }, + ) + .expect("ok"); + assert_eq!(a.slug, "my-app"); + assert_eq!(a.id, "a-1"); + } + + #[test] + fn create_app_409_surfaces_code() { + let mut server = Server::new(); + let _m = server + .mock("POST", "/v1/apps") + .with_status(409) + .with_body(r#"{"error":{"code":"slug_taken","message":"app slug already taken"}}"#) + .create(); + + let err = create_app( + &test_client(), + &server.url(), + "AT", + &CreateAppInput { + name: "My App", + slug: "my-app", + }, + ) + .unwrap_err(); + match err { + ApiError::Server { code, .. } => assert_eq!(code, "slug_taken"), + other => panic!("expected Server, got {other:?}"), + } + } + #[test] fn create_module_4xx_without_envelope_is_unexpected() { let mut server = Server::new(); diff --git a/src/commands/app/mod.rs b/src/commands/app/mod.rs new file mode 100644 index 0000000..301f584 --- /dev/null +++ b/src/commands/app/mod.rs @@ -0,0 +1,196 @@ +//! `mirrorstack apps …` — application management. + +use std::io::IsTerminal; +use std::time::Duration; + +use anyhow::{Result, anyhow}; +use clap::{Args, Subcommand}; +use console::style; +use dialoguer::{Confirm, Input, theme::ColorfulTheme}; +use indicatif::{ProgressBar, ProgressStyle}; + +use crate::api::{self, ApiError, CreateAppInput}; +use crate::credentials; +use crate::http; + +use super::{ + DEFAULT_API_BASE, DEFAULT_APPS_API_BASE, DEFAULT_WEB_BASE, ENV_API_URL, ENV_APPS_API_URL, + ENV_WEB_URL, ok_mark, resolve_base, session_expired, +}; + +#[derive(Args)] +pub struct AppArgs { + #[command(subcommand)] + command: AppCommand, +} + +#[derive(Subcommand)] +enum AppCommand { + /// Create a new application on the platform. + Create(CreateArgs), +} + +#[derive(Args)] +struct CreateArgs { + /// Application name (human-readable). + #[arg(long)] + name: Option, + /// URL slug. When omitted, derived from --name. + #[arg(long)] + slug: Option, + /// Skip prompts. + #[arg(long, short)] + yes: bool, +} + +pub fn run(args: AppArgs) -> Result<()> { + match args.command { + AppCommand::Create(c) => create(c), + } +} + +fn create(args: CreateArgs) -> Result<()> { + let theme = ColorfulTheme::default(); + + let mut creds = credentials::load_or_login_hint()?; + let api_base = resolve_base(ENV_API_URL, DEFAULT_API_BASE); + let apps_base = resolve_base(ENV_APPS_API_URL, DEFAULT_APPS_API_BASE); + let web_base = resolve_base(ENV_WEB_URL, DEFAULT_WEB_BASE); + let client = http::client(Duration::from_secs(15))?; + + let identity = + match credentials::with_refresh_retry(&mut creds, |tok| api::me(&client, &api_base, tok)) { + Ok(id) => id, + Err(ApiError::Unauthenticated) => return Err(session_expired()), + Err(e) => return Err(e.into()), + }; + let Some(username) = identity.slug.as_deref().filter(|s| !s.is_empty()) else { + return Err(anyhow!( + "no username set. Visit {web_base}/me to claim one first." + )); + }; + + let name = if let Some(n) = args + .name + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + n.to_string() + } else if args.yes { + return Err(anyhow!("--yes requires --name")); + } else { + Input::::with_theme(&theme) + .with_prompt("App name") + .interact_text()? + .trim() + .to_string() + }; + + let slug = if let Some(s) = args + .slug + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + s.to_string() + } else { + let suggested = derive_slug(&name); + if args.yes { + suggested + } else { + Input::::with_theme(&theme) + .with_prompt("Slug") + .default(suggested) + .interact_text()? + .trim() + .to_string() + } + }; + + if !args.yes { + eprintln!(); + eprintln!(" {} {}", style("App:").dim(), style(&name).bold()); + eprintln!( + " {} {}", + style("Slug:").dim(), + style(format!("@{username}/{slug}")).cyan().bold() + ); + let confirmed = Confirm::with_theme(&theme) + .with_prompt("Create this app?") + .default(true) + .interact()?; + if !confirmed { + eprintln!("{}", style("aborted.").yellow()); + return Ok(()); + } + } + + let result = with_spinner("Creating app…", || { + credentials::with_refresh_retry(&mut creds, |tok| { + api::create_app( + &client, + &apps_base, + tok, + &CreateAppInput { + name: &name, + slug: &slug, + }, + ) + }) + }); + + match result { + Ok(app) => { + eprintln!( + "{} created {}", + ok_mark(), + style(format!("@{username}/{slug}")).cyan().bold() + ); + eprintln!(" {} {}", style("id:").dim(), app.id); + Ok(()) + } + Err(ApiError::Server { code, message, .. }) => Err(anyhow!("{code}: {message}")), + Err(ApiError::Unauthenticated) => Err(session_expired()), + Err(e) => Err(e.into()), + } +} + +fn derive_slug(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + let mut last_dash = true; + for c in name.chars() { + let lc = c.to_ascii_lowercase(); + if lc.is_ascii_lowercase() || lc.is_ascii_digit() { + out.push(lc); + last_dash = false; + } else if !last_dash { + out.push('-'); + last_dash = true; + } + } + while out.ends_with('-') { + out.pop(); + } + out +} + +fn with_spinner(message: &str, f: F) -> T +where + F: FnOnce() -> T, +{ + if !std::io::stderr().is_terminal() { + return f(); + } + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::with_template("{spinner:.cyan} {msg}") + .unwrap() + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]), + ); + pb.enable_steady_tick(Duration::from_millis(80)); + pb.set_message(message.to_string()); + let result = f(); + pb.finish_and_clear(); + result +} diff --git a/src/commands/dev/compose.rs b/src/commands/dev/compose.rs deleted file mode 100644 index 382bda8..0000000 --- a/src/commands/dev/compose.rs +++ /dev/null @@ -1,108 +0,0 @@ -//! Docker-compose lifecycle for `mirrorstack dev`. We shell out to the -//! `docker compose` CLI rather than use a library — `docker compose` is the -//! canonical front-end and tracking its semantics in a Rust binding has a -//! perpetual maintenance cost we don't want to take on for one subcommand. - -use std::fs; -use std::io::Write; -use std::path::Path; -use std::process::{Command, Stdio}; - -use anyhow::{Context, Result, anyhow}; -use console::style; - -use super::super::ok_mark; - -const COMPOSE_TEMPLATE: &str = include_str!("../../../templates/dev/docker-compose.yml.tmpl"); - -/// Write a `docker-compose.yml` into `dir` if absent. Returns whether a new -/// file was written. -pub(super) fn ensure_compose_file(dir: &Path) -> Result { - let path = dir.join("docker-compose.yml"); - if path.exists() { - return Ok(false); - } - let mut f = fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&path) - .with_context(|| format!("dev: create {}", path.display()))?; - f.write_all(COMPOSE_TEMPLATE.as_bytes()) - .with_context(|| format!("dev: write {}", path.display()))?; - eprintln!( - "{} bootstrapped {}", - ok_mark(), - style(path.display()).cyan().bold() - ); - Ok(true) -} - -/// Bring the bundled services up and block until each healthcheck flips. -/// `--wait` is server-side polling so we don't replicate that loop here; -/// non-zero exit = something failed to come up healthy in time. -pub(super) fn up(dir: &Path) -> Result<()> { - let status = Command::new("docker") - .args(["compose", "up", "-d", "--wait"]) - .current_dir(dir) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .status() - .map_err(|e| match e.kind() { - std::io::ErrorKind::NotFound => anyhow!( - "`docker` not found on PATH. Install Docker Desktop (or set --no-compose to skip) before running dev." - ), - _ => anyhow!("dev: docker compose up: {e}"), - })?; - if !status.success() { - return Err(anyhow!( - "docker compose up failed (exit {}). Check the output above; \ - `docker compose logs postgres` may explain a stuck healthcheck.", - status.code().unwrap_or(-1) - )); - } - Ok(()) -} - -/// Tear the bundled services down. Surfaces a non-zero exit so a failed -/// teardown doesn't silently leak containers — the caller decides whether -/// to error out or just warn. -pub(super) fn down(dir: &Path) -> Result<()> { - let status = Command::new("docker") - .args(["compose", "down"]) - .current_dir(dir) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .status() - .map_err(|e| anyhow!("dev: docker compose down: {e}"))?; - if !status.success() { - return Err(anyhow!( - "docker compose down failed (exit {}). Run `docker compose down` manually to clean up.", - status.code().unwrap_or(-1) - )); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ensure_compose_file_writes_when_missing() { - let tmp = tempfile::tempdir().unwrap(); - let written = ensure_compose_file(tmp.path()).unwrap(); - assert!(written); - let body = fs::read_to_string(tmp.path().join("docker-compose.yml")).unwrap(); - assert!(body.contains("postgres:17-alpine")); - } - - #[test] - fn ensure_compose_file_skips_when_present() { - let tmp = tempfile::tempdir().unwrap(); - fs::write(tmp.path().join("docker-compose.yml"), b"# user's own file").unwrap(); - let written = ensure_compose_file(tmp.path()).unwrap(); - assert!(!written); - let body = fs::read_to_string(tmp.path().join("docker-compose.yml")).unwrap(); - assert_eq!(body, "# user's own file"); - } -} diff --git a/src/commands/dev/mod.rs b/src/commands/dev/mod.rs index 47c1828..642a2fa 100644 --- a/src/commands/dev/mod.rs +++ b/src/commands/dev/mod.rs @@ -1,20 +1,31 @@ -//! `mirrorstack dev` — run a module locally with the supporting services -//! it needs. +//! `mirrorstack dev` — run modules locally. //! -//! The lifecycle is: -//! 1. Bootstrap a `docker-compose.yml` in the cwd if missing -//! 2. `docker compose up -d --wait` (server-side healthcheck blocks until pg is ready) -//! 3. (optional, --tunnel) Mint a connect token and open the WSS so -//! remote callers can reach this module via the platform's Leaf 1 -//! 302 path -//! 4. Spawn `go run .` with `MS_LOCAL_DB_URL` injected -//! 5. Stream the module's stdout/stderr through a labeled prefix -//! 6. On Ctrl-C: kill the module (SIGKILL on unix; SIGTERM-then-SIGKILL -//! is queued as a follow-up — see issue #25), close the tunnel if -//! open, then `docker compose down` - -use std::io::IsTerminal; +//! Two modes: +//! - **Outer** (host, default): `docker compose up` — all services +//! including Go modules run inside Docker. +//! - **Inner** (`--all`, inside runner container): spawn `go run` +//! per module directly. This is what the compose runner calls. +//! +//! Tunnel registration always happens on the host side (outer mode). +//! +//! Tunnel mode exposes modules to remote callers (via dispatch's Leaf-1 +//! 307), so each module MUST enforce Internal-scope auth rather than +//! bypass it. Two cooperating mechanisms make that happen: +//! - Per-module `.ms-platform-token-` files carry the +//! dispatch-minted `stk_*` service token from each tunnel's register +//! ack. The runner points each module's MS_PLATFORM_TOKEN_FILE at its +//! own file (see docs/platform-module-auth.md). This is the primary +//! platform→module auth path. +//! - A per-session MS_INTERNAL_SECRET, minted here and both sent on the +//! register frame and injected into the compose env, keeps the SDK's +//! legacy InternalAuth fallback enforcing (not bypassing) while +//! dispatch round-trips the value. Backward-compat with older SDKs. + +use std::io::{BufRead, BufReader, IsTerminal, Read}; use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::thread; use std::time::Duration; use anyhow::{Context, Result, anyhow}; @@ -31,189 +42,459 @@ use super::{ }; use crate::{api, credentials, http}; -mod compose; -mod module_meta; -mod process; +pub(crate) mod module_meta; mod tunnel; +mod workspace; #[derive(Args)] pub struct DevArgs { - /// Working directory containing the module's main.go. Defaults to cwd. + /// Working directory containing go.work. #[arg(long)] dir: Option, - /// Skip bringing up `docker-compose` services. Use when you already have - /// Postgres running yourself. - #[arg(long)] - no_compose: bool, - /// `MS_LOCAL_DB_URL` override. Defaults to the bundled compose Postgres - /// when --no-compose is unset, otherwise must be supplied via env. - #[arg(long)] - db_url: Option, - /// Open a WSS tunnel to the platform so deployed callers can reach this - /// module via Leaf 1 (302 → localhost). Requires the developer to be - /// signed in (via `mirrorstack login`) and the platform's dispatch - /// service + WS API GW to be reachable. Module identity is parsed from - /// `Config.ID` in main.go; --local-url defaults to http://localhost:8080. + /// Open WSS tunnels so deployed callers can reach local modules. #[arg(long)] tunnel: bool, - /// URL the platform should 302 to when routing inbound calls for this - /// module. Only used when --tunnel is set. Default: http://localhost:8080. + /// Base URL for tunnel routing. Default: http://localhost. #[arg(long)] local_url: Option, + /// Run all registered modules directly (used inside Docker runner). + #[arg(long)] + all: bool, + /// Enable file watching with hot reload (used with --all). + #[arg(long)] + watch: bool, } -// Matches templates/dev/docker-compose.yml.tmpl: host port 5433 (chosen so -// the bundled compose can coexist with api-platform's own postgres on 5432). -const DEFAULT_DB_URL: &str = - "postgres://mirrorstack:mirrorstack@localhost:5433/mirrorstack?sslmode=disable"; - -/// Default local URL the platform should 302 to for Leaf 1 routing. Matches -/// the SDK's default HTTP listener port. Override with `--local-url`. -const DEFAULT_LOCAL_URL: &str = "http://localhost:8080"; +const DEFAULT_LOCAL_URL: &str = "http://localhost"; +const DEFAULT_MODULE_PORT: u16 = 9080; pub fn run(args: DevArgs) -> Result<()> { let cwd = args .dir .clone() .unwrap_or_else(|| std::env::current_dir().expect("cwd")); - if !cwd.join("main.go").exists() { + + if !cwd.join("go.work").exists() { return Err(anyhow!( - "{} doesn't look like a module — no main.go found. Run `mirrorstack module init` first, or pass --dir .", + "{} has no go.work. Run from a module workspace or pass --dir.", cwd.display() )); } - let db_url = resolve_db_url(&args)?; - - // Bring the tunnel up BEFORE compose so that a tunnel registration - // failure (auth expired, dispatch unreachable) doesn't waste the user's - // time bringing containers up first. - // - // Tunnel mode exposes the module to remote callers (via dispatch's - // Leaf-1 307), so the module MUST enforce Internal-scope auth. Mint a - // per-session secret, set MS_INTERNAL_SECRET on the spawned module - // process (which flips the bypass-vs-enforce matrix in - // auth/middleware.go to enforce), and ship the same value to dispatch - // in the register frame so dispatch can attach X-MS-Internal-Secret on - // forwarded requests. - let tunnel = if args.tunnel { - let secret = mint_internal_secret()?; - let (handle, runtime) = open_tunnel(&cwd, args.local_url.as_deref(), &secret)?; - Some(Tunnel { - handle, - runtime, - internal_secret: secret, - }) + if args.all { + run_inner(&cwd, &args) + } else { + run_outer(&cwd, &args) + } +} + +// ── Outer mode: host runs `docker compose up` ─────────────────────── + +fn run_outer(root: &Path, args: &DevArgs) -> Result<()> { + let all_modules = workspace::discover_modules(root)?; + + let mut ready = Vec::new(); + let mut skipped = Vec::new(); + for m in &all_modules { + match module_meta::read_module_meta(&m.abs_dir) { + Ok(meta) if !meta.id.is_empty() => ready.push(m.clone()), + Ok(meta) => skipped.push((m.dir.display().to_string(), meta.slug)), + Err(_) => skipped.push((m.dir.display().to_string(), String::new())), + } + } + + eprintln!( + "{} found {} modules in go.work ({} ready, {} skipped)", + ok_mark(), + style(all_modules.len()).cyan().bold(), + ready.len(), + skipped.len() + ); + for m in &ready { + eprintln!(" {} {}", style("✓").green(), m.dir.display()); + } + for (dir, slug) in &skipped { + let reason = if slug.is_empty() { + "no main.go" + } else { + "no ID — run `mirrorstack module register`" + }; + eprintln!( + " {} {} ({})", + style("–").yellow(), + dir, + style(reason).dim() + ); + } + + if ready.is_empty() { + return Err(anyhow!( + "no registered modules to run. Run `mirrorstack module register` first." + )); + } + + // Per-module platform-token files. Each tunnel session gets its OWN + // dispatch-minted service token, so each module process must read the + // token for ITS session. The old behavior wrote a single shared file + // (only the first module's token), which authenticated whichever module + // registered first and 401'd every other module on every + // platform-initiated call (lifecycle install, manifest read, dev-tunnel + // API). The files land in `root` and reach the runner through the + // `.:/modules` bind mount; dev-runner.sh points each module's + // MS_PLATFORM_TOKEN_FILE at `.ms-platform-token-`. + let mut token_files: Vec = Vec::new(); + + // Per-session MS_INTERNAL_SECRET. Sent on each register frame (so + // dispatch can attach X-MS-Internal-Secret on forwarded requests) and + // injected into the compose env (so the runner forwards it to each + // module and the SDK's legacy InternalAuth fallback enforces rather + // than bypasses). Only minted in tunnel mode — without a tunnel the + // module isn't reachable by remote callers. + let internal_secret = if args.tunnel { + Some(mint_internal_secret()?) } else { None }; - if !args.no_compose { - compose::ensure_compose_file(&cwd)?; - compose::up(&cwd)?; + // Register tunnels before compose so auth failures surface early. + let tunnel_state = if args.tunnel { + let secret = internal_secret + .as_deref() + .expect("tunnel mode mints a secret"); + let state = open_tunnels(&ready, args.local_url.as_deref(), secret)?; + // `open_tunnels` pushes handles in `ready` order, so zip is aligned. + for (m, handle) in ready.iter().zip(state.0.iter()) { + let slug = m.dir.file_name().unwrap().to_string_lossy(); + let f = root.join(format!(".ms-platform-token-{slug}")); + std::fs::write(&f, &handle.service_token) + .with_context(|| format!("dev: write platform token file for {slug}"))?; + eprintln!( + "{} wrote platform token for {} → {}", + ok_mark(), + style(&*slug).cyan(), + style(f.display()).dim() + ); + token_files.push(f); + } + Some(state) + } else { + None + }; + + eprintln!("{} starting docker compose…", ok_mark()); + let mut compose = Command::new("docker"); + compose + .args(["compose", "up", "--build"]) + .current_dir(root) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + // MS_PLATFORM_TOKEN_FILE is set per-module by dev-runner.sh (each module + // reads its own `.ms-platform-token-`). MS_INTERNAL_SECRET is + // injected once on the compose env; the runner forwards it to every + // module process as the SDK's legacy InternalAuth fallback. + if let Some(secret) = &internal_secret { + compose.env("MS_INTERNAL_SECRET", secret); } - eprintln!("{} module running — Ctrl-C to stop", ok_mark()); - let internal_secret = tunnel.as_ref().map(|t| t.internal_secret.as_str()); - let module_status = process::run_module(&cwd, &db_url, internal_secret); + let compose_status = compose.status().map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => { + anyhow!("`docker` not found on PATH. Install Docker Desktop before running dev.") + } + _ => anyhow!("dev: docker compose up: {e}"), + })?; - if let Some(t) = tunnel { - t.handle.shutdown(); - // Block on the runtime briefly to let the close frame land. - t.runtime.block_on(async { + // Cleanup per-module token files. + for f in &token_files { + let _ = std::fs::remove_file(f); + } + + if let Some((handles, runtime)) = tunnel_state { + for h in &handles { + h.shutdown(); + } + runtime.block_on(async { tokio::time::sleep(Duration::from_millis(200)).await; }); } - if !args.no_compose { - if let Err(e) = compose::down(&cwd) { - eprintln!( - "{} compose down failed: {e:#}. Run `docker compose down` to clean up.", - warn_prefix() - ); + if !compose_status.success() { + return Err(anyhow!( + "docker compose exited with status {}", + compose_status + .code() + .map(|c| c.to_string()) + .unwrap_or_else(|| "signal".into()) + )); + } + + Ok(()) +} + +// ── Inner mode: inside Docker, run modules directly ───────────────── + +fn run_inner(root: &Path, _args: &DevArgs) -> Result<()> { + let all_modules = workspace::discover_modules(root)?; + + let mut ready = Vec::new(); + for m in &all_modules { + match module_meta::read_module_meta(&m.abs_dir) { + Ok(meta) if !meta.id.is_empty() => ready.push(m.clone()), + _ => { + eprintln!("{} skipping {} (no ID)", warn_prefix(), m.dir.display()); + } } } - module_status + if ready.is_empty() { + return Err(anyhow!("no registered modules to run")); + } + + let db_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| { + "postgres://mirrorstack:mirrorstack@postgres:5432/ms_app_modules?sslmode=disable".into() + }); + + eprintln!( + "{} starting {} {}", + ok_mark(), + ready.len(), + if ready.len() == 1 { + "module" + } else { + "modules" + } + ); + + let mut children: Vec<(String, Child)> = Vec::new(); + + for m in &ready { + let slug = m.dir.file_name().unwrap().to_string_lossy().to_string(); + + let mut cmd = Command::new("go"); + cmd.args(["run", &format!("./{}", m.dir.display())]) + .current_dir(root) + .env("MS_LOCAL_DB_URL", &db_url) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + // Pass through env vars from compose (MS_INTERNAL_SECRET is the + // per-session secret minted by the outer run; the rest are infra). + for var in [ + "MS_INTERNAL_SECRET", + "REDIS_URL", + "AWS_ENDPOINT_URL", + "AWS_REGION", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + ] { + if let Ok(val) = std::env::var(var) { + cmd.env(var, val); + } + } + + let mut child = cmd + .spawn() + .with_context(|| format!("dev: spawn module {slug}"))?; + + let stdout = child.stdout.take().unwrap(); + let stderr = child.stderr.take().unwrap(); + let label: &'static str = Box::leak(slug.clone().into_boxed_str()); + spawn_forwarder(stdout, label, false); + spawn_forwarder(stderr, label, true); + + eprintln!(" {} {}", style("✓").green(), slug); + children.push((slug, child)); + } + + // Wait for Ctrl-C + let (tx, rx) = mpsc::channel::<()>(); + ctrlc::set_handler(move || { + let _ = tx.send(()); + }) + .context("dev: install ctrl-c handler")?; + + let mut exited = vec![false; children.len()]; + loop { + for (i, (label, child)) in children.iter_mut().enumerate() { + if exited[i] { + continue; + } + if let Ok(Some(status)) = child.try_wait() { + exited[i] = true; + eprintln!( + "{} module {} exited ({})", + warn_prefix(), + label, + status + .code() + .map(|c| c.to_string()) + .unwrap_or_else(|| "signal".into()) + ); + } + } + + if exited.iter().all(|e| *e) { + break; + } + + match rx.recv_timeout(Duration::from_millis(200)) { + Ok(()) => { + for (_, child) in &mut children { + let _ = child.kill(); + } + for (_, child) in &mut children { + let _ = child.wait(); + } + break; + } + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => { + for (_, child) in &mut children { + let _ = child.wait(); + } + break; + } + } + } + + Ok(()) } -/// Build a single-threaded tokio runtime, mint a connect token via -/// dispatch, open the WSS, send `register`, and return the handle so the -/// caller can shut it down on Ctrl-C. Runs blocking under the hood — -/// the tunnel itself stays alive on the runtime's worker thread. -fn open_tunnel( - module_dir: &Path, - local_url: Option<&str>, +fn spawn_forwarder(reader: R, label: &'static str, is_stderr: bool) { + thread::spawn(move || { + let prefix = line_prefix(label); + let mut buf = BufReader::new(reader); + let mut line = String::new(); + loop { + line.clear(); + match buf.read_line(&mut line) { + Ok(0) => return, + Ok(_) => { + let trimmed = line.trim_end_matches(['\n', '\r']); + if is_stderr { + eprintln!("{prefix} {trimmed}"); + } else { + println!("{prefix} {trimmed}"); + } + } + Err(_) => return, + } + } + }); +} + +fn line_prefix(label: &str) -> String { + if std::io::stderr().is_terminal() { + format!("{} {}", style(label).cyan().dim(), style("│").dim()) + } else { + format!("[{label}]") + } +} + +// ── Tunnel registration ───────────────────────────────────────────── + +/// Open one WSS tunnel per ready module. Returns the handles (in `modules` +/// order) plus the tokio runtime keeping their background tasks alive. +/// +/// Each module's tunnel-token mint is wrapped in +/// [`credentials::with_refresh_retry`] so a 401 on a long-running +/// `mirrorstack dev` silently refreshes the 15-minute access token from the +/// stored refresh token and retries once, rather than forcing a re-login. +/// The rotated refresh pair is persisted back to credentials between +/// modules. `internal_secret` is the per-session value sent on every +/// register frame so dispatch can attach X-MS-Internal-Secret on forwarded +/// requests. +fn open_tunnels( + modules: &[workspace::WorkspaceModule], + local_url_base: Option<&str>, internal_secret: &str, -) -> Result<(tunnel::TunnelHandle, tokio::runtime::Runtime)> { +) -> Result<(Vec, tokio::runtime::Runtime)> { let mut creds = credentials::load_or_login_hint()?; let dispatch_base = resolve_base(ENV_DISPATCH_URL, DEFAULT_DISPATCH_BASE); let client = http::client(Duration::from_secs(15))?; - let module_id = module_meta::read_module_id(module_dir)?; - let local_url = local_url.unwrap_or(DEFAULT_LOCAL_URL); - eprintln!( - "{} fetching tunnel token from {}", - ok_mark(), - style(&dispatch_base).cyan().dim() - ); - let token = match mint_tunnel_token(&client, &dispatch_base, &mut creds) { - Ok(t) => t, - Err(api::ApiError::Unauthenticated) => return Err(session_expired()), - Err(e) => { - // Dispatch unreachable is a routine "platform isn't running yet" - // error — point the user at the right env var. - return Err(anyhow!( - "dev: tunnel-token mint failed: {e}. Check {} (or {} env var) and that the dispatch service is running.", - dispatch_base, - ENV_DISPATCH_URL - )); - } - }; - // Multi-thread (one worker) so the WSS background task — pinger, - // server-frame reader — keeps ticking while the rest of the CLI runs - // its blocking module-process loop. A current-thread runtime would - // freeze every spawned future the moment block_on returns. let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(1) .enable_all() .build() .context("dev: build tokio runtime")?; - let handle = match runtime.block_on(async { - tunnel::open( - &token.wss_url, - &token.token, - tunnel::RegisterPayload { - module_id: &module_id, - local_url, - version: env!("CARGO_PKG_VERSION"), - internal_secret: Some(internal_secret), - }, - ) - .await - }) { - Ok(h) => h, - Err(tunnel::RegisterError::ModuleDevModeOff { slug }) => { - return Err(dev_mode_off_hint(&dispatch_base, slug.as_deref())); - } - Err(tunnel::RegisterError::ModuleNotYours) => { - return Err(anyhow!( - "dev: this module isn't owned by you — only the module owner can open a dev tunnel.\nIf you scaffolded with `mirrorstack module init`, your `Config.ID` should match the platform's record; re-check it under `mirrorstack module list`." - )); - } - Err(tunnel::RegisterError::Rejected { code, message }) => { - return Err(anyhow!("dev: register rejected ({code}): {message}")); - } - Err(tunnel::RegisterError::Transport(e)) => return Err(e), + let mut handles = Vec::with_capacity(modules.len()); + + let local_url = match local_url_base { + Some(url) => url.to_string(), + None => format!("{DEFAULT_LOCAL_URL}:{DEFAULT_MODULE_PORT}"), }; - eprintln!( - "{} tunnel registered (session {})", - ok_mark(), - style(&handle.session_id).cyan().dim() - ); - Ok((handle, runtime)) + for m in modules { + let module_id = module_meta::read_module_id(&m.abs_dir)?; + let slug = m.dir.file_name().unwrap().to_string_lossy(); + let module_local_url = format!("{local_url}/_m/{slug}"); + + eprintln!( + "{} fetching tunnel token for {} from {}", + ok_mark(), + style(m.dir.display()).cyan(), + style(&dispatch_base).dim() + ); + + let token = match mint_tunnel_token(&client, &dispatch_base, &mut creds) { + Ok(t) => t, + Err(api::ApiError::Unauthenticated) => return Err(session_expired()), + Err(e) => { + return Err(anyhow!( + "dev: tunnel-token mint for {} failed: {e}. Check {} (or {} env var) and that the dispatch service is running.", + m.dir.display(), + dispatch_base, + ENV_DISPATCH_URL + )); + } + }; + + let handle = match runtime.block_on(async { + tunnel::open( + &token.wss_url, + &token.token, + tunnel::RegisterPayload { + module_id: &module_id, + local_url: &module_local_url, + version: env!("CARGO_PKG_VERSION"), + internal_secret: Some(internal_secret), + }, + ) + .await + }) { + Ok(h) => h, + Err(tunnel::RegisterError::ModuleDevModeOff { slug }) => { + return Err(dev_mode_off_hint(&dispatch_base, slug.as_deref())); + } + Err(tunnel::RegisterError::ModuleNotYours) => { + return Err(anyhow!( + "dev: module {} isn't owned by you — only the module owner can open a dev tunnel.\nIf you scaffolded with `mirrorstack module init`, your `Config.ID` should match the platform's record; re-check it under `mirrorstack module list`.", + m.dir.display() + )); + } + Err(tunnel::RegisterError::Rejected { code, message }) => { + return Err(anyhow!( + "dev: register rejected for {} ({code}): {message}", + m.dir.display() + )); + } + Err(tunnel::RegisterError::Transport(e)) => return Err(e), + }; + + eprintln!( + "{} tunnel {} → {} (session {})", + ok_mark(), + style(m.dir.display()).cyan(), + style(&module_local_url).dim(), + style(&handle.session_id).dim() + ); + + handles.push(handle); + } + + Ok((handles, runtime)) } /// Access tokens are short (15 min per the platform's TokenConfig). @@ -279,16 +560,7 @@ fn dev_console_url(dispatch_base: &str, slug: Option<&str>) -> String { } } -/// Held for the lifetime of `mirrorstack dev --tunnel`. The runtime -/// keeps the WSS background task alive; the secret is the -/// `MS_INTERNAL_SECRET` value the spawned module enforces against. -struct Tunnel { - handle: tunnel::TunnelHandle, - runtime: tokio::runtime::Runtime, - internal_secret: String, -} - -/// Mint a 32-byte URL-safe base64 random token used as the module's +/// Mint a 32-byte URL-safe base64 random token used as the per-session /// MS_INTERNAL_SECRET. Same shape as auth::random_state (OsRng → b64url), /// inlined here to avoid a cross-module dependency for a one-off helper. fn mint_internal_secret() -> Result { @@ -299,72 +571,5 @@ fn mint_internal_secret() -> Result { Ok(URL_SAFE_NO_PAD.encode(buf)) } -fn resolve_db_url(args: &DevArgs) -> Result { - if let Some(url) = &args.db_url { - return Ok(url.clone()); - } - if let Ok(url) = std::env::var("MS_LOCAL_DB_URL") { - return Ok(url); - } - if args.no_compose { - return Err(anyhow!( - "--no-compose requires MS_LOCAL_DB_URL (env or --db-url) — without compose there's no fallback" - )); - } - Ok(DEFAULT_DB_URL.into()) -} - -/// Color-aware prefix used by both stdout and stderr forwarders. Skipped on -/// non-TTY targets so CI logs stay clean. -pub(super) fn line_prefix(label: &str) -> String { - if std::io::stderr().is_terminal() { - format!("{} {}", style(label).cyan().dim(), style("│").dim()) - } else { - format!("[{label}]") - } -} - #[cfg(test)] -mod tests { - use super::*; - - fn args_with(no_compose: bool, db_url: Option) -> DevArgs { - DevArgs { - dir: None, - no_compose, - db_url, - tunnel: false, - local_url: None, - } - } - - #[test] - fn resolve_db_url_prefers_explicit_arg() { - let args = args_with(false, Some("postgres://x".into())); - assert_eq!(resolve_db_url(&args).unwrap(), "postgres://x"); - } - - #[test] - fn resolve_db_url_no_compose_requires_env() { - // Skip when the runner happens to have MS_LOCAL_DB_URL set — the - // no-env error path is what we're after, and toggling process-global - // env from a test is a recipe for cross-test interference. - if std::env::var("MS_LOCAL_DB_URL").is_ok() { - return; - } - let args = args_with(true, None); - let err = resolve_db_url(&args).unwrap_err().to_string(); - assert!(err.contains("MS_LOCAL_DB_URL")); - } - - #[test] - fn resolve_db_url_compose_default_when_unset() { - if std::env::var("MS_LOCAL_DB_URL").is_ok() { - return; - } - let args = args_with(false, None); - let url = resolve_db_url(&args).unwrap(); - assert!(url.starts_with("postgres://mirrorstack")); - assert!(url.contains(":5433/")); - } -} +mod tests {} diff --git a/src/commands/dev/module_meta.rs b/src/commands/dev/module_meta.rs index 8bb89ed..1810009 100644 --- a/src/commands/dev/module_meta.rs +++ b/src/commands/dev/module_meta.rs @@ -1,47 +1,125 @@ //! Read the developer's module identity off the scaffolded source tree. //! -//! For Phase 1 we parse `Config.ID = "..."` out of `main.go`. That field -//! is what the SDK uses for `mod_` schema names, so it's the right -//! handle to register against the tunnel — and it's already the value -//! the CLI's scaffold substituted from the platform UUID. -//! -//! A future PR may switch to a `.mirrorstack/meta.json` written at -//! scaffold time. Until then, parsing main.go is a one-line regex on a -//! file whose shape we control. +//! Parses `Config.ID`, `Config.Slug`, and `Config.Name` out of `main.go`. +//! These fields drive tunnel registration, platform registration, and +//! the module display name. use std::fs; use std::path::Path; use anyhow::{Context, Result, anyhow}; -/// Find the `ID: ""` line inside the file's `ms.Init(ms.Config{...})` -/// block. The scaffold template's main.go has it on a predictable line, -/// but a developer is free to reformat — so we match the literal token -/// `ID:` followed by `"..."` rather than relying on column positions. -pub(super) fn read_module_id(module_dir: &Path) -> Result { +/// All parseable identity fields from a module's main.go. +#[derive(Debug, Clone)] +pub(crate) struct ModuleMeta { + pub id: String, + pub slug: String, + pub name: String, +} + +/// Read ID, Slug, and Name from `main.go` in `module_dir`. +pub(crate) fn read_module_meta(module_dir: &Path) -> Result { let path = module_dir.join("main.go"); let body = fs::read_to_string(&path).with_context(|| format!("dev: read {}", path.display()))?; - extract_id(&body).ok_or_else(|| { + let id = extract_field(&body, "ID").unwrap_or_default(); + let slug = extract_field(&body, "Slug").ok_or_else(|| { anyhow!( - "dev: couldn't find `ID: \"...\"` in {}. Is this a MirrorStack module?", + "dev: couldn't find `Slug: \"...\"` in {}. Is this a MirrorStack module?", path.display() ) - }) + })?; + let name = extract_field(&body, "Name").unwrap_or_else(|| slug.clone()); + Ok(ModuleMeta { id, slug, name }) +} + +/// Convenience wrapper that returns just the ID (for tunnel registration). +pub(super) fn read_module_id(module_dir: &Path) -> Result { + let meta = read_module_meta(module_dir)?; + if meta.id.is_empty() { + return Err(anyhow!( + "dev: module {} has no ID set. Run `mirrorstack modules register` first.", + module_dir.display() + )); + } + Ok(meta.id) } -/// Extract the first occurrence of the literal pattern `ID: "..."`. -/// -/// Tolerates any whitespace around `ID:` (the scaffold uses `ID: ` -/// for column alignment with `Name:`, but reformatters may shrink it -/// to a single space). Stops at the first closing quote — `Config.ID` -/// is a single string literal with no escapes possible under the SDK's -/// regex (`^[a-z][a-z0-9_]{0,35}$`). -fn extract_id(go_source: &str) -> Option { - let after_label = find_after(go_source, "ID:")?; +/// Extract the value of a `Field: "..."` pattern from Go source. +/// Tolerates variable whitespace between the field name and the value. +fn extract_field(go_source: &str, field: &str) -> Option { + let needle = format!("{field}:"); + let after_label = find_after(go_source, &needle)?; let after_open_quote = find_after(after_label, "\"")?; let close = after_open_quote.find('"')?; - Some(after_open_quote[..close].to_string()) + let val = &after_open_quote[..close]; + if val.is_empty() { + return None; + } + Some(val.to_string()) +} + +/// Write `new_id` into the `ID: "..."` field in `main.go`. If the field +/// has an empty string (`ID: ""`), it's replaced. If the field is missing +/// entirely, it's inserted after the `Slug:` line. +pub(crate) fn write_module_id(module_dir: &Path, new_id: &str) -> Result<()> { + let path = module_dir.join("main.go"); + let body = + fs::read_to_string(&path).with_context(|| format!("dev: read {}", path.display()))?; + + let new_body = if let Some(start) = body.find("ID:") { + let after_id = &body[start..]; + if let Some(q1) = after_id.find('"') { + let abs_q1 = start + q1 + 1; + let after_q1 = &body[abs_q1..]; + if let Some(q2) = after_q1.find('"') { + let abs_q2 = abs_q1 + q2; + format!("{}{}{}", &body[..abs_q1], new_id, &body[abs_q2..]) + } else { + return Err(anyhow!("malformed ID field in {}", path.display())); + } + } else { + return Err(anyhow!("malformed ID field in {}", path.display())); + } + } else { + // Insert ID field after Slug line + if let Some(slug_pos) = body.find("Slug:") { + let after_slug = &body[slug_pos..]; + if let Some(nl) = after_slug.find('\n') { + let insert_pos = slug_pos + nl + 1; + let indent = detect_indent(&body, slug_pos); + format!( + "{}{}ID: \"{}\",\n{}", + &body[..insert_pos], + indent, + new_id, + &body[insert_pos..] + ) + } else { + return Err(anyhow!("unexpected EOF after Slug in {}", path.display())); + } + } else { + return Err(anyhow!("no Slug or ID field found in {}", path.display())); + } + }; + + fs::write(&path, new_body).with_context(|| format!("dev: write {}", path.display()))?; + Ok(()) +} + +fn detect_indent(source: &str, field_pos: usize) -> String { + let before = &source[..field_pos]; + if let Some(nl) = before.rfind('\n') { + let line_start = &before[nl + 1..field_pos]; + // Extract leading whitespace + let ws: String = line_start + .chars() + .take_while(|c| c.is_whitespace()) + .collect(); + ws + } else { + String::new() + } } fn find_after<'a>(haystack: &'a str, needle: &str) -> Option<&'a str> { @@ -59,6 +137,7 @@ package main func main() { if err := ms.Init(ms.Config{ ID: "mbb8a3f8b123456789abcdef012345678", + Slug: "media", Name: "Media", Icon: "extension", }); err != nil { @@ -68,37 +147,48 @@ func main() { "#; #[test] - fn extract_id_from_scaffold_template() { + fn extract_field_id() { assert_eq!( - extract_id(SAMPLE_MAIN_GO).unwrap(), + extract_field(SAMPLE_MAIN_GO, "ID").unwrap(), "mbb8a3f8b123456789abcdef012345678" ); } #[test] - fn extract_id_tolerates_single_space() { + fn extract_field_slug() { + assert_eq!(extract_field(SAMPLE_MAIN_GO, "Slug").unwrap(), "media"); + } + + #[test] + fn extract_field_name() { + assert_eq!(extract_field(SAMPLE_MAIN_GO, "Name").unwrap(), "Media"); + } + + #[test] + fn extract_field_tolerates_single_space() { let src = r#"ms.Config{ ID: "media", Name: "Media" }"#; - assert_eq!(extract_id(src).unwrap(), "media"); + assert_eq!(extract_field(src, "ID").unwrap(), "media"); } #[test] - fn extract_id_returns_none_when_absent() { - assert!(extract_id(r#"ms.Config{Name: "X"}"#).is_none()); + fn extract_field_returns_none_when_absent() { + assert!(extract_field(r#"ms.Config{Name: "X"}"#, "ID").is_none()); } #[test] - fn extract_id_first_match_wins() { - // If the developer adds another `ID:` somewhere later (a struct - // tag, a comment, a literal in another field), the first one - // — Config.ID at the top of Init — is the canonical one. - let src = r#" -ms.Config{ - ID: "first", - Name: "X", -} -// later struct: SomethingElse{ID: "ignored"} -"#; - assert_eq!(extract_id(src).unwrap(), "first"); + fn extract_field_returns_none_for_empty_string() { + let src = r#"ms.Config{ ID: "", Slug: "media" }"#; + assert!(extract_field(src, "ID").is_none()); + } + + #[test] + fn read_module_meta_from_disk() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("main.go"), SAMPLE_MAIN_GO).unwrap(); + let meta = read_module_meta(tmp.path()).unwrap(); + assert_eq!(meta.id, "mbb8a3f8b123456789abcdef012345678"); + assert_eq!(meta.slug, "media"); + assert_eq!(meta.name, "Media"); } #[test] @@ -111,10 +201,48 @@ ms.Config{ ); } + #[test] + fn read_module_id_errors_when_empty() { + let tmp = tempfile::tempdir().unwrap(); + let src = r#" +package main +func main() { + ms.Init(ms.Config{ + ID: "", + Slug: "media", + Name: "Media", + }) +} +"#; + std::fs::write(tmp.path().join("main.go"), src).unwrap(); + let err = read_module_id(tmp.path()).unwrap_err().to_string(); + assert!(err.contains("no ID set")); + } + #[test] fn read_module_id_missing_main_errors() { let tmp = tempfile::tempdir().unwrap(); let err = read_module_id(tmp.path()).unwrap_err().to_string(); assert!(err.contains("read")); } + + #[test] + fn write_module_id_replaces_empty() { + let tmp = tempfile::tempdir().unwrap(); + let src = " ID: \"\",\n Slug: \"media\",\n"; + std::fs::write(tmp.path().join("main.go"), src).unwrap(); + write_module_id(tmp.path(), "m123abc").unwrap(); + let result = std::fs::read_to_string(tmp.path().join("main.go")).unwrap(); + assert!(result.contains("ID: \"m123abc\"")); + } + + #[test] + fn write_module_id_replaces_existing() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("main.go"), SAMPLE_MAIN_GO).unwrap(); + write_module_id(tmp.path(), "mnewid").unwrap(); + let result = std::fs::read_to_string(tmp.path().join("main.go")).unwrap(); + assert!(result.contains("\"mnewid\"")); + assert!(!result.contains("mbb8a3f8b123456789abcdef012345678")); + } } diff --git a/src/commands/dev/process.rs b/src/commands/dev/process.rs deleted file mode 100644 index 5f23410..0000000 --- a/src/commands/dev/process.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Spawn the user's module via `go run .` with `MS_LOCAL_DB_URL` injected, -//! line-prefix its stdout/stderr, and kill it on Ctrl-C. -//! -//! No async runtime: we spawn a thread per output stream and rely on -//! `ctrlc` for SIGINT delivery — the whole CLI is otherwise blocking. -//! -//! Ctrl-C calls `child.kill()`, which is SIGKILL on unix and -//! TerminateProcess on windows. A Go server mid-DB-write loses any -//! buffered work; for a dev shell where the user owns the database -//! that's an acceptable trade-off in v1. SIGTERM-first-then-SIGKILL -//! is queued as a follow-up. - -use std::io::{BufRead, BufReader, Read}; -use std::path::Path; -use std::process::{Child, Command, Stdio}; -use std::sync::mpsc; -use std::thread; -use std::time::Duration; - -use anyhow::{Context, Result, anyhow}; - -use super::line_prefix; - -/// Run `go run .` in `dir` with `MS_LOCAL_DB_URL=`. When -/// `internal_secret` is `Some`, also sets `MS_INTERNAL_SECRET` — used -/// by tunnel mode so the SDK's InternalAuth flips from bypass to -/// enforce. Blocks until the child exits, or until Ctrl-C is received -/// and the child has been asked to terminate. Returns Ok on a clean -/// child exit, Err on non-zero status or signal-driven termination. -pub(super) fn run_module(dir: &Path, db_url: &str, internal_secret: Option<&str>) -> Result<()> { - let mut cmd = Command::new("go"); - cmd.args(["run", "."]) - .current_dir(dir) - .env("MS_LOCAL_DB_URL", db_url) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - if let Some(secret) = internal_secret { - cmd.env("MS_INTERNAL_SECRET", secret); - } - let mut child = cmd.spawn().map_err(|e| match e.kind() { - std::io::ErrorKind::NotFound => { - anyhow!("`go` not found on PATH. Install Go 1.26+ before running dev.") - } - _ => anyhow!("dev: spawn `go run .`: {e}"), - })?; - - let stdout = child.stdout.take().context("dev: child stdout")?; - let stderr = child.stderr.take().context("dev: child stderr")?; - let stdout_handle = forward_stdout(stdout, "module"); - let stderr_handle = forward_stderr(stderr, "module"); - - let (tx, rx) = mpsc::channel::<()>(); - ctrlc::set_handler(move || { - // Best effort: a second Ctrl-C escalates via the kernel default. - let _ = tx.send(()); - }) - .context("dev: install ctrl-c handler")?; - - let exit_status = wait_or_interrupt(&mut child, rx); - - // Threads exit when their pipe closes (child exited). - let _ = stdout_handle.join(); - let _ = stderr_handle.join(); - - match exit_status { - Ok(s) if s.success() => Ok(()), - Ok(s) => Err(anyhow!( - "module exited with status {}", - s.code() - .map(|c| c.to_string()) - .unwrap_or_else(|| "signal".into()) - )), - Err(e) => Err(e), - } -} - -/// Wait for the child to exit, or for a Ctrl-C signal to arrive. On signal -/// we ask the child to terminate (kernel-level kill), then wait for it. -fn wait_or_interrupt( - child: &mut Child, - rx: mpsc::Receiver<()>, -) -> Result { - loop { - if let Some(status) = child.try_wait().context("dev: try_wait")? { - return Ok(status); - } - match rx.recv_timeout(Duration::from_millis(200)) { - Ok(()) => { - let _ = child.kill(); - let status = child.wait().context("dev: wait after interrupt")?; - return Ok(status); - } - Err(mpsc::RecvTimeoutError::Timeout) => continue, - Err(mpsc::RecvTimeoutError::Disconnected) => { - return child.wait().context("dev: wait"); - } - } - } -} - -fn forward_stdout( - reader: R, - label: &'static str, -) -> thread::JoinHandle<()> { - forward_lines(reader, label, |prefix, line| println!("{prefix} {line}")) -} - -fn forward_stderr( - reader: R, - label: &'static str, -) -> thread::JoinHandle<()> { - forward_lines(reader, label, |prefix, line| eprintln!("{prefix} {line}")) -} - -fn forward_lines(reader: R, label: &'static str, sink: F) -> thread::JoinHandle<()> -where - R: Read + Send + 'static, - F: Fn(&str, &str) + Send + 'static, -{ - thread::spawn(move || { - let prefix = line_prefix(label); - let mut buf = BufReader::new(reader); - let mut line = String::new(); - loop { - line.clear(); - match buf.read_line(&mut line) { - Ok(0) => return, // EOF - Ok(_) => sink(&prefix, line.trim_end_matches(['\n', '\r'])), - Err(_) => return, - } - } - }) -} diff --git a/src/commands/dev/tunnel.rs b/src/commands/dev/tunnel.rs index 7de7bfd..cbf19ac 100644 --- a/src/commands/dev/tunnel.rs +++ b/src/commands/dev/tunnel.rs @@ -138,10 +138,9 @@ pub(super) struct RegisterPayload<'a> { #[derive(Debug, Deserialize)] pub(super) struct RegisterAck { pub session_id: String, - /// Carried by future RPC/SQL frames as the per-tunnel service token. - /// Phase 1 doesn't emit those frames yet; the field is held so the - /// returned handle stays a single source of truth for the ack. - #[allow(dead_code)] + /// The per-tunnel service token. The dev runner writes it to a + /// per-module `.ms-platform-token-` file so each spawned module + /// authenticates platform-initiated calls against ITS own session. pub service_token: String, /// RFC3339 — informational; the L1.5 reconnect contract makes the /// client retry on any failure rather than expiry-driven refresh. @@ -197,11 +196,14 @@ type RegisterResult = std::result::Result; /// Spawned-task handle. Drop or call [`shutdown`] to close the WSS. /// -/// `service_token` is intentionally NOT held here — it lives on the -/// returned [`RegisterAck`] until a real consumer (the future RPC plane) -/// needs it on the handle itself. Re-thread when wired. +/// `service_token` is the per-tunnel dispatch-minted token from the +/// register ack. The dev runner writes it to a per-module +/// `.ms-platform-token-` file so each spawned module authenticates +/// platform-initiated calls (lifecycle install, manifest read) against +/// ITS own session. pub(super) struct TunnelHandle { pub session_id: String, + pub service_token: String, shutdown: Arc, } @@ -255,6 +257,7 @@ pub(super) async fn open( Ok(TunnelHandle { session_id: ack.session_id, + service_token: ack.service_token, shutdown, }) } diff --git a/src/commands/dev/workspace.rs b/src/commands/dev/workspace.rs new file mode 100644 index 0000000..54ee4ee --- /dev/null +++ b/src/commands/dev/workspace.rs @@ -0,0 +1,185 @@ +//! Parse `go.work` to discover module directories in a monorepo. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow}; + +/// A module entry discovered from `go.work`. +#[derive(Debug, Clone)] +pub(super) struct WorkspaceModule { + /// Relative directory from the go.work root (e.g. `./oauth-core` → `oauth-core`). + pub dir: PathBuf, + /// Absolute path to the module directory. + pub abs_dir: PathBuf, +} + +/// Parse `go.work` in `root` and return the list of `use` directives as +/// module entries. Fails if `go.work` is missing or contains no `use` +/// directives. +pub(super) fn discover_modules(root: &Path) -> Result> { + let go_work = root.join("go.work"); + let body = + fs::read_to_string(&go_work).with_context(|| format!("dev: read {}", go_work.display()))?; + + let dirs = parse_use_directives(&body); + if dirs.is_empty() { + return Err(anyhow!( + "go.work has no `use` directives — add at least one module" + )); + } + + let mut modules = Vec::with_capacity(dirs.len()); + for rel in dirs { + let abs = root.join(&rel); + if !abs.join("main.go").exists() { + return Err(anyhow!( + "module {} has no main.go — is it a valid module?", + abs.display() + )); + } + modules.push(WorkspaceModule { + dir: PathBuf::from(&rel), + abs_dir: abs, + }); + } + Ok(modules) +} + +/// Extract relative paths from `use` directives. Handles both block syntax: +/// +/// ```text +/// use ( +/// ./oauth-core +/// ./oauth-google +/// ) +/// ``` +/// +/// and single-line syntax: `use ./oauth-core` +fn parse_use_directives(body: &str) -> Vec { + let mut result = Vec::new(); + let mut in_block = false; + + for line in body.lines() { + let trimmed = line.trim(); + + if trimmed.starts_with("//") { + continue; + } + + if in_block { + if trimmed == ")" { + in_block = false; + continue; + } + if let Some(dir) = clean_use_path(trimmed) { + result.push(dir); + } + } else if let Some(rest) = trimmed.strip_prefix("use") { + let rest = rest.trim(); + if rest == "(" { + in_block = true; + } else if let Some(dir) = clean_use_path(rest) { + result.push(dir); + } + } + } + result +} + +/// Strip `./` prefix and trailing comments from a use-directive path. +fn clean_use_path(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() || s.starts_with("//") { + return None; + } + // Strip inline comment + let s = match s.find("//") { + Some(pos) => s[..pos].trim(), + None => s, + }; + let s = s.strip_prefix("./").unwrap_or(s); + if s.is_empty() { + return None; + } + Some(s.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_block_syntax() { + let input = r#" +go 1.26 + +use ( + ./oauth-core + ./oauth-google +) +"#; + let dirs = parse_use_directives(input); + assert_eq!(dirs, vec!["oauth-core", "oauth-google"]); + } + + #[test] + fn parse_single_line_syntax() { + let dirs = parse_use_directives("use ./my-module\n"); + assert_eq!(dirs, vec!["my-module"]); + } + + #[test] + fn parse_ignores_comments() { + let input = r#" +use ( + // ./disabled + ./active + ./with-comment // inline note +) +"#; + let dirs = parse_use_directives(input); + assert_eq!(dirs, vec!["active", "with-comment"]); + } + + #[test] + fn parse_empty_returns_empty() { + let dirs = parse_use_directives("go 1.26\n"); + assert!(dirs.is_empty()); + } + + #[test] + fn parse_without_dot_slash_prefix() { + let dirs = parse_use_directives("use my-module\n"); + assert_eq!(dirs, vec!["my-module"]); + } + + #[test] + fn discover_modules_from_disk() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("go.work"), + "go 1.26\n\nuse (\n\t./mod-a\n\t./mod-b\n)\n", + ) + .unwrap(); + std::fs::create_dir(tmp.path().join("mod-a")).unwrap(); + std::fs::write(tmp.path().join("mod-a/main.go"), "package main").unwrap(); + std::fs::create_dir(tmp.path().join("mod-b")).unwrap(); + std::fs::write(tmp.path().join("mod-b/main.go"), "package main").unwrap(); + + let mods = discover_modules(tmp.path()).unwrap(); + assert_eq!(mods.len(), 2); + assert_eq!(mods[0].dir, PathBuf::from("mod-a")); + assert_eq!(mods[1].dir, PathBuf::from("mod-b")); + } + + #[test] + fn discover_modules_rejects_missing_main_go() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("go.work"), "use ./empty-mod\n").unwrap(); + std::fs::create_dir(tmp.path().join("empty-mod")).unwrap(); + + let err = discover_modules(tmp.path()).unwrap_err(); + assert!(err.to_string().contains("no main.go")); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index d1ffb47..71030ad 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,6 +5,7 @@ use anyhow::{Result, anyhow}; use clap::{Parser, Subcommand}; use console::{StyledObject, style}; +mod app; mod dev; mod login; mod logout; @@ -77,8 +78,10 @@ enum Command { /// Manage developer modules (the per-developer reusable units installed /// into apps). Module(module::ModuleArgs), - /// Run a module locally with its supporting services (Postgres in v1; - /// the WSS tunnel layer lands in a follow-up). + /// Manage applications on the platform. + Apps(app::AppArgs), + /// Run modules locally with supporting services. Scans go.work for + /// monorepo mode; falls back to single-module if only main.go exists. Dev(dev::DevArgs), } @@ -89,6 +92,7 @@ impl Cli { Command::Logout(args) => logout::run(args), Command::Whoami(args) => whoami::run(args), Command::Module(args) => module::run(args), + Command::Apps(args) => app::run(args), Command::Dev(args) => dev::run(args), } } diff --git a/src/commands/module/mod.rs b/src/commands/module/mod.rs index 90c33c5..ca2d6c8 100644 --- a/src/commands/module/mod.rs +++ b/src/commands/module/mod.rs @@ -15,6 +15,7 @@ use dialoguer::{Confirm, Input, theme::ColorfulTheme}; use indicatif::{ProgressBar, ProgressStyle}; use crate::api::{self, ApiError, CreateModuleInput}; +use crate::commands::dev::module_meta; use crate::credentials; use crate::http; @@ -22,7 +23,7 @@ mod scaffold; use super::{ DEFAULT_API_BASE, DEFAULT_APPS_API_BASE, DEFAULT_WEB_BASE, ENV_API_URL, ENV_APPS_API_URL, - ENV_WEB_URL, ok_mark, resolve_base, session_expired, + ENV_WEB_URL, ok_mark, resolve_base, session_expired, warn_prefix, }; #[derive(Args)] @@ -33,9 +34,13 @@ pub struct ModuleArgs { #[derive(Subcommand)] enum ModuleCommand { - /// Register a new module on the platform. Interactive by default; - /// pass --yes for non-interactive use. + /// Create a new module on the platform and scaffold locally. + /// Interactive by default; pass --yes for non-interactive use. Init(InitArgs), + /// Register all unregistered modules in the workspace with the + /// platform. Scans go.work, finds modules with empty IDs, creates + /// them via the API, and writes the assigned ID back into main.go. + Register(RegisterArgs), } #[derive(Args)] @@ -64,9 +69,20 @@ pub struct InitArgs { dir: Option, } +#[derive(Args)] +pub struct RegisterArgs { + /// Working directory containing go.work. Defaults to cwd. + #[arg(long)] + dir: Option, + /// Skip confirmation prompts. + #[arg(long, short)] + yes: bool, +} + pub fn run(args: ModuleArgs) -> Result<()> { match args.command { ModuleCommand::Init(i) => init(i), + ModuleCommand::Register(r) => register(r), } } @@ -203,6 +219,238 @@ fn init(args: InitArgs) -> Result<()> { ) } +fn register(args: RegisterArgs) -> Result<()> { + let cwd = args + .dir + .clone() + .unwrap_or_else(|| std::env::current_dir().expect("cwd")); + + let go_work = cwd.join("go.work"); + if !go_work.exists() { + return Err(anyhow!( + "no go.work found in {}. Run from a module workspace.", + cwd.display() + )); + } + + let mut creds = credentials::load_or_login_hint()?; + let api_base = resolve_base(ENV_API_URL, DEFAULT_API_BASE); + let apps_base = resolve_base(ENV_APPS_API_URL, DEFAULT_APPS_API_BASE); + let web_base = resolve_base(ENV_WEB_URL, DEFAULT_WEB_BASE); + let client = http::client(Duration::from_secs(15))?; + + let identity = + match credentials::with_refresh_retry(&mut creds, |tok| api::me(&client, &api_base, tok)) { + Ok(id) => id, + Err(ApiError::Unauthenticated) => return Err(session_expired()), + Err(e) => return Err(e.into()), + }; + let Some(username) = identity.slug.as_deref().filter(|s| !s.is_empty()) else { + return Err(anyhow!( + "no username set. Visit {web_base}/me to claim one first." + )); + }; + + // Parse go.work to find module directories + let body = + std::fs::read_to_string(&go_work).with_context(|| format!("read {}", go_work.display()))?; + let module_dirs = parse_go_work_use_dirs(&body); + if module_dirs.is_empty() { + return Err(anyhow!("go.work has no `use` directives")); + } + + let theme = ColorfulTheme::default(); + let mut registered = 0u32; + let mut skipped = 0u32; + + for rel_dir in &module_dirs { + let abs_dir = cwd.join(rel_dir); + let meta = match module_meta::read_module_meta(&abs_dir) { + Ok(m) => m, + Err(e) => { + eprintln!("{} skipping {}: {e}", warn_prefix(), rel_dir); + continue; + } + }; + + if !meta.id.is_empty() { + eprintln!( + "{} {} already registered ({})", + ok_mark(), + style(format!("@{username}/{}", meta.slug)).cyan(), + style(&meta.id).dim() + ); + skipped += 1; + continue; + } + + if !slug_valid(&meta.slug) { + eprintln!( + "{} skipping {}: slug '{}' is invalid", + warn_prefix(), + rel_dir, + meta.slug + ); + continue; + } + + if !args.yes { + eprintln!(); + eprintln!(" {} {}", style("Module:").dim(), style(&meta.name).bold()); + eprintln!( + " {} {}", + style("Slug:").dim(), + style(format!("@{username}/{}", meta.slug)).cyan().bold() + ); + let confirmed = Confirm::with_theme(&theme) + .with_prompt(format!("Register {}?", meta.slug)) + .default(true) + .interact()?; + if !confirmed { + eprintln!("{}", style("skipped.").yellow()); + continue; + } + } + + let result = with_spinner(&format!("Registering {}…", meta.slug), || { + credentials::with_refresh_retry(&mut creds, |tok| { + api::create_module( + &client, + &apps_base, + tok, + &CreateModuleInput { + name: &meta.name, + slug: &meta.slug, + }, + ) + }) + }); + + let module_id = match result { + Ok(m) => { + eprintln!( + "{} created {}", + ok_mark(), + style(format!("@{username}/{}", m.slug)).cyan().bold() + ); + m.id + } + Err(ApiError::Server { code, .. }) if code == "slug_taken" => { + // Already exists on platform — fetch the ID + match credentials::with_refresh_retry(&mut creds, |tok| { + api::get_module(&client, &apps_base, tok, &meta.slug) + })? { + Some(existing) => { + eprintln!( + "{} {} already exists on platform, using existing ID", + ok_mark(), + style(format!("@{username}/{}", meta.slug)).cyan() + ); + existing.id + } + None => { + eprintln!( + "{} slug '{}' is taken by another user", + warn_prefix(), + meta.slug + ); + continue; + } + } + } + Err(ApiError::Unauthenticated) => return Err(session_expired()), + Err(e) => { + eprintln!("{} failed to register {}: {e}", warn_prefix(), meta.slug); + continue; + } + }; + + // Write the ID back into main.go + let sanitized_id = sanitize_module_id(&module_id); + module_meta::write_module_id(&abs_dir, &sanitized_id) + .with_context(|| format!("write ID to {}/main.go", rel_dir))?; + eprintln!( + " {} wrote ID {} → {}/main.go", + style("→").dim(), + style(&sanitized_id).dim(), + rel_dir + ); + registered += 1; + } + + eprintln!(); + eprintln!( + "{} done: {} registered, {} already had IDs", + ok_mark(), + registered, + skipped + ); + Ok(()) +} + +/// Parse `use` directives from go.work content (same logic as workspace.rs +/// but returns raw strings since we don't need abs paths here). +fn parse_go_work_use_dirs(body: &str) -> Vec { + let mut result = Vec::new(); + let mut in_block = false; + for line in body.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("//") { + continue; + } + if in_block { + if trimmed == ")" { + in_block = false; + continue; + } + if let Some(dir) = clean_go_work_path(trimmed) { + result.push(dir); + } + } else if let Some(rest) = trimmed.strip_prefix("use") { + let rest = rest.trim(); + if rest == "(" { + in_block = true; + } else if let Some(dir) = clean_go_work_path(rest) { + result.push(dir); + } + } + } + result +} + +fn clean_go_work_path(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() || s.starts_with("//") { + return None; + } + let s = match s.find("//") { + Some(pos) => s[..pos].trim(), + None => s, + }; + let s = s.strip_prefix("./").unwrap_or(s); + if s.is_empty() { + return None; + } + Some(s.to_string()) +} + +/// Convert platform UUID to SDK-compatible module ID (same as scaffold.rs). +fn sanitize_module_id(uuid: &str) -> String { + // If it already looks sanitized (starts with 'm', no hyphens), return as-is + if uuid.starts_with('m') && !uuid.contains('-') { + return uuid.to_string(); + } + let mut out = String::with_capacity(33); + out.push('m'); + for c in uuid.chars() { + if c == '-' { + continue; + } + out.extend(c.to_lowercase()); + } + out +} + fn refetch_module_id( client: &reqwest::blocking::Client, apps_base: &str,