From a7d3fe4fa6d2db1c95fe4f10b6ded659080e3657 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Tue, 5 May 2026 08:25:33 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(dev):=20mirrorstack=20dev=20=E2=80=94?= =?UTF-8?q?=20run=20a=20module=20locally=20with=20docker=20pg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New top-level subcommand. Lifecycle: 1. Bootstrap docker-compose.yml in cwd if missing (vendored template) 2. docker compose up -d --wait — server-side healthcheck blocking 3. Spawn `go run .` with MS_LOCAL_DB_URL injected 4. Stream stdout/stderr through a labeled prefix 5. On Ctrl-C: child.kill(), then docker compose down Bundled Postgres binds host port 5433 (not 5432) so it can coexist with api-platform's own postgres on 5432 — devs can run platform + module side by side without port juggling. Flags: - --dir : module dir (default cwd) - --no-compose: skip docker, use the user's own pg (requires MS_LOCAL_DB_URL) - --db-url : override the default (highest precedence) No WSS tunnel yet — module runs as a plain HTTP server on its own port. A follow-up PR layers the tunnel registration on top so deployed-mode calls back to localhost can be exercised. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 60 +++++++++++ Cargo.toml | 1 + src/commands/dev/compose.rs | 108 +++++++++++++++++++ src/commands/dev/mod.rs | 150 ++++++++++++++++++++++++++ src/commands/dev/process.rs | 123 +++++++++++++++++++++ src/commands/mod.rs | 5 + templates/dev/docker-compose.yml.tmpl | 29 +++++ 7 files changed, 476 insertions(+) create mode 100644 src/commands/dev/compose.rs create mode 100644 src/commands/dev/mod.rs create mode 100644 src/commands/dev/process.rs create mode 100644 templates/dev/docker-compose.yml.tmpl diff --git a/Cargo.lock b/Cargo.lock index ad72e7e..78a64c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,6 +104,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -225,6 +234,17 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + [[package]] name = "dialoguer" version = "0.11.0" @@ -267,6 +287,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -855,6 +887,7 @@ dependencies = [ "base64", "clap", "console", + "ctrlc", "dialoguer", "dirs", "dotenvy", @@ -896,12 +929,39 @@ dependencies = [ "tokio", ] +[[package]] +name = "nix" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "number_prefix" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + [[package]] name = "once_cell" version = "1.21.4" diff --git a/Cargo.toml b/Cargo.toml index 4775695..dfbec57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ dotenvy = "0.15" dialoguer = { version = "0.11", default-features = false } console = "0.15" indicatif = "0.17" +ctrlc = "3" [dev-dependencies] mockito = "1.6" diff --git a/src/commands/dev/compose.rs b/src/commands/dev/compose.rs new file mode 100644 index 0000000..382bda8 --- /dev/null +++ b/src/commands/dev/compose.rs @@ -0,0 +1,108 @@ +//! 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 new file mode 100644 index 0000000..303969c --- /dev/null +++ b/src/commands/dev/mod.rs @@ -0,0 +1,150 @@ +//! `mirrorstack dev` — run a module locally with the supporting services +//! it needs (Postgres for now; Redis + WSS tunnel client land in later PRs). +//! +//! The lifecycle is: +//! 1. Bootstrap a `docker-compose.yml` in the cwd if missing +//! 2. `docker compose up -d` and wait for Postgres health +//! 3. Spawn `go run .` with `MS_LOCAL_DB_URL` injected +//! 4. Stream the module's stdout/stderr through a labeled prefix +//! 5. On Ctrl-C: graceful SIGTERM the module process, then `compose down` +//! +//! No WSS tunnel yet — the module runs as a normal HTTP server on its own +//! port. Future PR layers the tunnel registration on top. + +use std::io::IsTerminal; +use std::path::PathBuf; + +use anyhow::{Result, anyhow}; +use clap::Args; +use console::style; + +use super::{ok_mark, warn_prefix}; + +mod compose; +mod process; + +#[derive(Args)] +pub struct DevArgs { + /// Working directory containing the module's main.go. Defaults to cwd. + #[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, +} + +// 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"; + +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() { + return Err(anyhow!( + "{} doesn't look like a module — no main.go found. Run `mirrorstack module init` first, or pass --dir .", + cwd.display() + )); + } + + let db_url = resolve_db_url(&args)?; + + if !args.no_compose { + compose::ensure_compose_file(&cwd)?; + compose::up(&cwd)?; + } + + eprintln!("{} module running — Ctrl-C to stop", ok_mark()); + let module_status = process::run_module(&cwd, &db_url); + + 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() + ); + } + } + + module_status +} + +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::*; + + #[test] + fn resolve_db_url_prefers_explicit_arg() { + let args = DevArgs { + dir: None, + no_compose: false, + db_url: 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 = DevArgs { + dir: None, + no_compose: true, + db_url: 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 = DevArgs { + dir: None, + no_compose: false, + db_url: None, + }; + let url = resolve_db_url(&args).unwrap(); + assert!(url.starts_with("postgres://mirrorstack")); + assert!(url.contains(":5433/")); + } +} diff --git a/src/commands/dev/process.rs b/src/commands/dev/process.rs new file mode 100644 index 0000000..095d2c4 --- /dev/null +++ b/src/commands/dev/process.rs @@ -0,0 +1,123 @@ +//! Spawn the user's module via `go run .` with `MS_LOCAL_DB_URL` injected, +//! line-prefix its stdout/stderr, and graceful-exit 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. + +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=`. 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) -> Result<()> { + let mut child = Command::new("go") + .args(["run", "."]) + .current_dir(dir) + .env("MS_LOCAL_DB_URL", db_url) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .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/mod.rs b/src/commands/mod.rs index 41fcf51..e38d995 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,6 +5,7 @@ use anyhow::Result; use clap::{Parser, Subcommand}; use console::{StyledObject, style}; +mod dev; mod login; mod logout; mod module; @@ -62,6 +63,9 @@ 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). + Dev(dev::DevArgs), } impl Cli { @@ -71,6 +75,7 @@ impl Cli { Command::Logout(args) => logout::run(args), Command::Whoami(args) => whoami::run(args), Command::Module(args) => module::run(args), + Command::Dev(args) => dev::run(args), } } } diff --git a/templates/dev/docker-compose.yml.tmpl b/templates/dev/docker-compose.yml.tmpl new file mode 100644 index 0000000..d9874c2 --- /dev/null +++ b/templates/dev/docker-compose.yml.tmpl @@ -0,0 +1,29 @@ +# Local-only services for `mirrorstack dev`. Postgres holds the module's +# per-app + module-shared data; SDK migrations apply on module startup. +# +# Generated by `mirrorstack dev` on first run. Safe to edit — re-running +# `dev` won't overwrite an existing file. Wipe the volume to reset: +# docker compose down -v +services: + postgres: + image: postgres:17-alpine + container_name: mirrorstack-dev-pg + environment: + POSTGRES_USER: mirrorstack + POSTGRES_PASSWORD: mirrorstack + POSTGRES_DB: mirrorstack + # Host port is 5433 (not 5432) so this can coexist with api-platform's + # own docker-compose.yml when developing the platform and a module side + # by side. Override the host side if 5433 is also taken in your setup. + ports: + - "5433:5432" + volumes: + - mirrorstack-dev-pg-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mirrorstack -d mirrorstack"] + interval: 2s + timeout: 3s + retries: 30 + +volumes: + mirrorstack-dev-pg-data: From 1b9777dea387cfcdac5713dc7a618d4d795e2a5a Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Wed, 6 May 2026 01:36:09 +0800 Subject: [PATCH 2/2] docs(dev): align lifecycle comments with actual shutdown semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module docs claimed "graceful SIGTERM" but the code calls child.kill() — SIGKILL on unix, TerminateProcess on windows. Tightened the wording so docs and code agree, and queued the SIGTERM-first escalation as a follow-up note inside the file. No behavior change. --- src/commands/dev/mod.rs | 4 ++-- src/commands/dev/process.rs | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/commands/dev/mod.rs b/src/commands/dev/mod.rs index 303969c..c7d2328 100644 --- a/src/commands/dev/mod.rs +++ b/src/commands/dev/mod.rs @@ -3,10 +3,10 @@ //! //! The lifecycle is: //! 1. Bootstrap a `docker-compose.yml` in the cwd if missing -//! 2. `docker compose up -d` and wait for Postgres health +//! 2. `docker compose up -d --wait` (server-side healthcheck blocks until pg is ready) //! 3. Spawn `go run .` with `MS_LOCAL_DB_URL` injected //! 4. Stream the module's stdout/stderr through a labeled prefix -//! 5. On Ctrl-C: graceful SIGTERM the module process, then `compose down` +//! 5. On Ctrl-C: kill the module process (SIGKILL on unix; SIGTERM-then-SIGKILL is queued as a follow-up), then `docker compose down` //! //! No WSS tunnel yet — the module runs as a normal HTTP server on its own //! port. Future PR layers the tunnel registration on top. diff --git a/src/commands/dev/process.rs b/src/commands/dev/process.rs index 095d2c4..4d33044 100644 --- a/src/commands/dev/process.rs +++ b/src/commands/dev/process.rs @@ -1,8 +1,14 @@ //! Spawn the user's module via `go run .` with `MS_LOCAL_DB_URL` injected, -//! line-prefix its stdout/stderr, and graceful-exit on Ctrl-C. +//! 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;