Skip to main content

mesofact_dev/
cli.rs

1//! `mes` — the mesofact dev toolchain.
2//!
3//! The whole CLI lives here in the library rather than in a bin target,
4//! mirroring [`mesofact::cli`], so that the two bin targets — `mes` and the
5//! transitional `mesofact-dev` alias — are each three lines over one
6//! definition. (They cannot simply share one `main.rs`: cargo warns when a
7//! file backs two targets, and it compiles the whole CLI twice.)
8//!
9//! ## Why this is a superset of `mesofact`, not an alias of it
10//!
11//! `mes` carries the prod verbs (`serve`, `publish`) alongside the dev ones
12//! (`dev`, `build`), so a consumer learns one CLI. That does **not** weaken
13//! the W225 §2 dev/prod boundary, because that boundary is about which crates
14//! land in a *binary*, not about which verbs a binary spells. It only ever
15//! runs one direction: this crate already depends on the `mesofact` facade, so
16//! `mes serve` is an in-process call into [`mesofact::cli`] — free. The
17//! reverse (making the shipped `mesofact` binary a shim over `mes`) would drag
18//! the watcher, the dev S3 surface and rolldown into the prod closure, and is
19//! exactly what §2 forbids. `mesofact` stays a standalone binary.
20//!
21//! **Why not spawn the `mesofact` binary for the prod verbs instead of linking
22//! it?** (Asked more than once; the answer is not "in-process is tidier".) It
23//! would save nothing. `mes dev` — the 95% command — is built directly on the
24//! facade's engine: [`mesofact::Server::from_workload`],
25//! [`mesofact::ssr::spawn`], [`mesofact::SsrSpawnOptions`],
26//! [`mesofact::ProxyMap`]. The facade is linked into this binary for the DEV
27//! loop whether or not `serve` is in-process, so process-calling removes zero
28//! bytes and adds a runtime dependency on finding `mesofact` on `PATH` —
29//! `cargo install mesofact-dev` would yield a `mes` whose `serve` fails until
30//! you separately install `mesofact`. It would also hand us exit-code and
31//! signal forwarding for no gain. The link-graph direction is what carries the
32//! security property here; the process boundary carries none of it.
33//!
34//! Two verbs are absent:
35//!
36//! - **`proxy`** — Mode-2 deployment plumbing (worker pool, manifest reload on
37//!   SIGHUP). It has no dev-loop meaning; `mesofact proxy` remains its home.
38//!   Deliberate, and still true.
39//! - **`check`** (`tsc --noEmit`) — absent for a reason that **no longer
40//!   holds**, corrected here rather than left to misinform (R451, reported by
41//!   @Ashguard:dragon 2026-09-01). This used to read "running TypeScript needs
42//!   node or bun on `PATH`". It does not: typescript@7 ships `bin/tsc` as a
43//!   three-line Node shim over a per-platform **native Go binary**
44//!   (`node_modules/@typescript/typescript-<platform>-<arch>/lib/tsc`), and
45//!   `mesofact-build`'s `check.rs` resolves and spawns that binary directly —
46//!   `PATH=/nonexistent mesofact-build check <dir>` runs green and reports
47//!   `error TS2322` with exit 1 on a seeded type error. The illustrated
48//!   failure the old note gave ("dies with `bun: command not found`") was never
49//!   this path's failure mode either; `check.rs` has never invoked bun.
50//!
51//!   So `mes check` is now merely *unimplemented*, and cheap — this crate
52//!   already links `mesofact-build` behind the default-on `build` feature.
53//!   Adding it belongs with whichever ticket owns the `check` surface, so that
54//!   the facade verb and this one land together rather than drifting.
55//!
56//! ## Bare invocation
57//!
58//! `mes` with no subcommand is `mes dev .` — the 95% command, so it is what
59//! you get for typing the least. This is also what keeps the legacy
60//! `mesofact-dev <DIR> --port N …` form working verbatim: it parses as the
61//! bare form, so the ~30 spawn sites in the parent camp need no flag day. The
62//! one divergence is a workload directory literally named after a subcommand
63//! (`mesofact-dev serve`); spell it `./serve` if that ever comes up.
64//!
65//! @yah:relay(R822, "Retire the mesofact-dev BINARY; mes is the only dev command (the package name stays)")
66//! @yah:at(2026-09-02T06:04:50Z)
67//! @yah:status(open)
68//! @yah:assignee(agent:bundle-anthropic-ashguard)
69//! @yah:next("OPERATOR DECISION 2026-09-01, and it is the inverse of the split first proposed: descriptive name where you READ it, short name where you TYPE it. The PACKAGE stays mesofact-dev permanently -- not as a legacy spelling of mes, but as the meta crate, the entrypoint to the dev-tier tools mes needs that are NOT mesofact (the watcher, the local S3 surface standing in for R2, serve_app). It depends on the mesofact facade rather than being it, and that direction IS the W225 section 2 prod/dev boundary. The BINARY mesofact-dev goes: nobody wants to type it, ever.")
70//! @yah:next("SCOPE, measured not guessed: 30 Rust string-literal sites resolve \"mesofact-dev\" as an executable name (grep '\"mesofact-dev\"' app crates --include=*.rs, excluding @yah: annotation prose, which is the other ~170 hits and is history rather than reference). The load-bearing ones: crates/yah/plugin/src/source.rs:112 keys a builtin plugin manifest on it via include_str!; crates/yah/bundled/src/lib.rs:171 registers it as a bundled binary; app/yah/desktop/src/mesofact_versions.rs:80,94 resolve it through yah_bundled::find and slot.bin(); app/yah/cli/src/plugin_host.rs:414 names it as SourceRef::Bundled.")
71//! @yah:next("THE SHARP EDGE, and the reason this is a migration rather than a find-and-replace: ALREADY-INSTALLED store slots have a file literally named mesofact-dev on disk. app/yah/desktop/src/mesofact_versions.rs does slot.bin(\"mesofact-dev\"), so renaming the bin breaks resolution against versions a user already installed. Needs a compat window that accepts either filename, or a store migration -- decide which before touching the 30 sites.")
72//! @yah:next("ALSO IN SCOPE, all naming the bin rather than the package: .yah/qed/release-build.toml builds package=mesofact-dev bin=mesofact-dev (-> bin=mes); scripts/check-mesofact-store.sh asserts a slot holds mesofact AND mesofact-dev and that shims exist for mesofact/mes/mesofact-dev; scripts/check-install-sh-compat.sh compares the mesofact-dev shim; oss/mesofact/scripts/check-mesofact-new.sh copies target/debug/mesofact-dev into the test slot and runs `mesofact-dev .`.")
73//! @yah:next("AND THE LEGACY-FORM LOGIC ITSELF: cli.rs's bare-invocation design exists specifically so `mesofact-dev <DIR> --port N` parses as `mes dev <DIR>`, which is what let the parent camp's spawn sites keep working without a flag day. Once the bin is gone that rationale is spent -- the bare form is still right for `mes`, but the module doc's justification for it needs rewriting rather than deleting.")
74//! @yah:next("NOT BLOCKING THE CRATES.IO PUBLISH. Because the package name is unchanged, mesofact-dev@0.8.29 can be published before any of this lands; removing a bin target later is an ordinary deprecation (cargo install at 0.8.29 gets both binaries, at a later version gets only mes). This ticket was originally framed as racing that publish -- it is not.")
75//! @yah:next("END STATE DECIDED BY OPERATOR 2026-09-01, and it supersedes the narrower 'drop the second [[bin]]' framing this ticket opened with: mesofact-dev has NO BIN TARGETS AT ALL. It becomes a pure library -- the meta crate, the entrypoint to the dev-tier tools that are not mesofact. A new thin package `mes` owns the binary: one src/main.rs, three lines over mesofact_dev::cli::run(), depending on mesofact-dev.")
76//! @yah:next("WHY THIS SHAPE RATHER THAN JUST DELETING THE mesofact-dev BIN: `cargo install` takes a PACKAGE name, not a bin name. With the binary living in the mesofact-dev package, `cargo install mes` fails with 'could not find mes in registry' and users must know to type `cargo install mesofact-dev` -- unguessable from the command. Splitting gives library named for what it IS and binary named for what you TYPE, and removes the bin-collision footgun of two packages both emitting ~/.cargo/bin/mes.")
77//! @yah:next("SAFE TO DO: verified 2026-09-01 that NO crate anywhere takes mesofact-dev as a library dependency today -- the only line in the tree is the library-tier scaffold template (crates/mesofact/src/cli/new/template-lib/Cargo.toml:31), which wants the library and is unaffected. So nothing loses a binary it was consuming, and the new mes package is the first real lib consumer.")
78//! @yah:gotcha("SUPERSEDED NOTE, removed rather than left to mislead: an earlier gotcha here said the end state was 'package mesofact-dev while the only [[bin]] is mes'. It is not. The end state is mesofact-dev with ZERO bin targets and a separate `mes` package owning the binary. The cargo package-vs-bin decoupling still matters, but it is now the reason the LIBRARY can keep a descriptive name while the COMMAND gets a short one across a package boundary, not within one.")
79//! @yah:gotcha("FEATURE FORWARDING IS THE FIDDLY PART, and this crate already documents the same trap one level down. mesofact-dev's surface is default = [ssr, build]; ssr = [mesofact/ssr, dep:mesofact-publisher, publish]; publish = [mesofact/publish]; build = [mesofact/build]. The new `mes` package must re-expose these (ssr = [mesofact-dev/ssr], etc.) or `cargo install mes --no-default-features` silently loses the lean static/SPA path that exists so consumers can skip the V8 toolchain. mes itself has no cfgs -- cli.rs's #[cfg(feature = ...)] gates evaluate in mesofact-dev -- so forwarding is all that is required, but omitting it is invisible until someone tries the lean build.")
80
81use std::path::PathBuf;
82#[cfg(feature = "ssr")]
83use std::sync::Arc;
84
85use clap::{Parser, Subcommand};
86#[cfg(feature = "ssr")]
87use mesofact::{ssr, SsrSpawnOptions};
88use mesofact::{Server, DEFAULT_PORT};
89use crate::{watcher, WatchOptions};
90use tracing::info;
91
92#[derive(Parser, Debug)]
93#[command(
94    name = "mes",
95    version,
96    about = "mes — the mesofact dev toolchain",
97    long_about = "Build, serve and ship a mesofact project. `mes` on its own runs the \
98                  hot-reload dev loop in the current directory.\n\n\
99                  The `serve` and `publish` verbs are the same code the shipped `mesofact` \
100                  binary runs; `dev` and `build` are dev-tier and exist only here.",
101    // A subcommand and the bare-form args are mutually exclusive: `mes dev .`
102    // must not also try to bind `.` to the top-level positional.
103    args_conflicts_with_subcommands = true
104)]
105struct Cli {
106    #[command(subcommand)]
107    command: Option<Command>,
108
109    /// Bare form — `mes [DIR] [--port …]` is `mes dev [DIR] [--port …]`.
110    #[command(flatten)]
111    dev: DevArgs,
112}
113
114#[derive(Subcommand, Debug)]
115enum Command {
116    /// Hot-reload dev loop: build in-process, serve, rebuild on every edit.
117    Dev(DevArgs),
118    /// One-shot production build into `dist/`. No watcher, no server.
119    #[cfg(feature = "build")]
120    Build(BuildArgs),
121    /// Scaffold a new mesofact project pinned to this binary's version.
122    New(mesofact::cli::new::NewArgs),
123    /// Serve a built bundle or host SSR routes — the prod serving path.
124    Serve(mesofact::cli::serve::ServeArgs),
125    /// Upload a built dist/ tree, swap the manifest pointer, purge CDN tags.
126    #[cfg(feature = "publish")]
127    Publish(mesofact::cli::publish::PublishArgs),
128}
129
130/// One-shot build (`mes build`).
131///
132/// Drives [`mesofact::build::pipeline`] directly rather than going through
133/// [`crate::Watcher::rebuild`]: `rebuild` additionally snapshots into a
134/// `.mesofact-dev/gen-N/` directory and swaps the live pointer, which is the
135/// watcher's business. A one-shot build should leave `dist/` and nothing else.
136#[cfg(feature = "build")]
137#[derive(clap::Args, Debug)]
138struct BuildArgs {
139    /// Project directory — the parent of `mesofact.routes.ts`.
140    #[arg(default_value = ".")]
141    workload: PathBuf,
142
143    /// Output directory. Defaults to `<workload>/dist`.
144    #[arg(long, value_name = "DIR")]
145    out_dir: Option<PathBuf>,
146}
147
148#[derive(clap::Args, Debug)]
149struct DevArgs {
150    /// Workload directory — the parent of `dist/html/` (e.g. `app/yah/web`).
151    /// Defaults to the current directory.
152    #[arg(default_value = ".")]
153    workload: PathBuf,
154
155    /// TCP port to bind on 127.0.0.1.
156    #[arg(long, default_value_t = DEFAULT_PORT)]
157    port: u16,
158
159    /// Disable the file-watch + auto-rebuild loop; serve whatever's on disk.
160    #[arg(long)]
161    no_watch: bool,
162
163    /// Skip the initial build at startup (watch mode only).
164    #[arg(long)]
165    no_initial_build: bool,
166
167    /// Path to a JSON `prefix → backend base URL` map for the same-origin
168    /// reverse proxy (R513-F10), e.g.
169    /// `{"/auth": "http://127.0.0.1:8745", "/dev": "http://127.0.0.1:8745"}`.
170    /// Matching requests are forwarded to the backend before static serving so
171    /// the SPA stays single-origin (no CORS). Camp-emitted at SPA-service spawn.
172    #[arg(long, value_name = "PATH")]
173    proxy_map: Option<PathBuf>,
174
175    /// Path to a JSON file served verbatim at `/config.json` (R513-F5/F10): the
176    /// SPA's `DashboardConfig` (apiBaseUrl / authBaseUrl / env …). Injected by
177    /// the server — NOT placed in the served `dist/` — so an Option-A pipeline
178    /// serving the same bundle never inherits a stale `env:ci` config.
179    #[arg(long, value_name = "PATH")]
180    config_json: Option<PathBuf>,
181
182    /// Logical service this dev server serves (R602-B4). Paired with
183    /// `--component`, it's surfaced at `/__mesofact/info` so the cloud
184    /// reconciler can confirm a listener on the configured port is *this*
185    /// server before adopting it — refusing a colliding foreign listener
186    /// instead of silently hijacking it.
187    #[arg(long, requires = "component")]
188    service: Option<String>,
189
190    /// Logical component id this dev server serves (R602-B4). See `--service`.
191    #[arg(long, requires = "service")]
192    component: Option<String>,
193}
194
195pub async fn run() -> std::process::ExitCode {
196    tracing_subscriber::fmt()
197        .with_env_filter(
198            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
199                tracing_subscriber::EnvFilter::new("mesofact_dev=info,tower_http=info")
200            }),
201        )
202        .init();
203
204    let command = match resolve(Cli::parse()) {
205        Ok(command) => command,
206        Err(err) => {
207            eprintln!("mes: {err:#}");
208            return std::process::ExitCode::FAILURE;
209        }
210    };
211
212    match command {
213        Command::Dev(args) => to_exit_code(dev(args).await),
214        #[cfg(feature = "build")]
215        Command::Build(args) => to_exit_code(build(args).await),
216        Command::New(args) => to_exit_code(mesofact::cli::new::run(args)),
217        Command::Serve(args) => to_exit_code(mesofact::cli::serve::run(args).await),
218        // `publish` owns its exit codes (2 = missing config, etc.) — pass through.
219        #[cfg(feature = "publish")]
220        Command::Publish(args) => mesofact::cli::publish::run(args).await,
221    }
222}
223
224/// Apply the bare-form fallthrough: no subcommand means `dev`.
225///
226/// Guards the one sharp edge a default subcommand buys. `mes --port 3000 dev`
227/// parses as *"run dev against a directory named `dev`"* — clap fills the
228/// positional because the leading flag already moved the parser past the
229/// subcommand slot. Silently serving the wrong directory is a poor answer to
230/// an obvious word-order typo, so name it instead. The subcommand list comes
231/// from clap rather than a literal, so a verb added above cannot forget to
232/// appear here.
233fn resolve(cli: Cli) -> anyhow::Result<Command> {
234    use clap::CommandFactory;
235
236    if let Some(command) = cli.command {
237        return Ok(command);
238    }
239    if let Some(name) = Cli::command()
240        .get_subcommands()
241        .map(clap::Command::get_name)
242        .find(|name| std::path::Path::new(name) == cli.dev.workload)
243    {
244        anyhow::bail!(
245            "`{name}` is a subcommand, but a flag came first so it was read as a \
246             directory. Put the subcommand first: `mes {name} …` \
247             (or say `./{name}` if you did mean the directory)."
248        );
249    }
250    Ok(Command::Dev(cli.dev))
251}
252
253fn to_exit_code(result: anyhow::Result<()>) -> std::process::ExitCode {
254    match result {
255        Ok(()) => std::process::ExitCode::SUCCESS,
256        Err(err) => {
257            eprintln!("mes: {err:#}");
258            std::process::ExitCode::FAILURE
259        }
260    }
261}
262
263/// `mes build` — run the pipeline once and stop.
264#[cfg(feature = "build")]
265async fn build(args: BuildArgs) -> anyhow::Result<()> {
266    let project_root = args
267        .workload
268        .canonicalize()
269        .unwrap_or_else(|_| args.workload.clone());
270    let out_dir = args.out_dir.unwrap_or_else(|| project_root.join("dist"));
271    info!(root = %project_root.display(), out = %out_dir.display(), "mes build");
272    mesofact::build::pipeline::build(mesofact::build::pipeline::BuildOptions {
273        project_root,
274        out_dir: Some(out_dir),
275        build_id: None,
276        // Same `Auto` as the watcher: materialize `node_modules` from the
277        // lockfile only when missing, so the build needs no package manager.
278        install: mesofact::build::pipeline::InstallMode::Auto,
279    })
280    .await?;
281    Ok(())
282}
283
284/// `mes dev` — the hot-reload loop. This is the whole of what the
285/// `mesofact-dev` binary used to be.
286async fn dev(args: DevArgs) -> anyhow::Result<()> {
287    let mut server = Server::from_workload(&args.workload)?;
288
289    // Logical identity (R602-B4): stamp the server with the (service, component)
290    // the reconciler spawned it for so `/__mesofact/info` lets the adopt path
291    // verify the port holds *this* server. clap's `requires` couples the pair,
292    // so both-or-neither is guaranteed here.
293    if let (Some(service), Some(component)) = (&args.service, &args.component) {
294        info!(service, component, "mesofact-dev: identity stamped (R602-B4)");
295        server = server.with_identity(service.clone(), component.clone());
296    }
297
298    // Same-origin reverse proxy (R513-F10): forward `/auth/*` etc. to the
299    // camp-vended backend ports so the dashboard E2E (Option B) browser stays
300    // single-origin. No map → no proxy (the Option A static path is unchanged).
301    if let Some(map_path) = &args.proxy_map {
302        let map = mesofact::ProxyMap::from_json_file(map_path)?;
303        info!(
304            map = %map_path.display(),
305            routes = ?map.routes(),
306            "mesofact-dev: same-origin reverse proxy installed",
307        );
308        server = server.with_proxy(map);
309    }
310
311    // Server-injected runtime config (R513-F5/F10) served at /config.json.
312    if let Some(config_path) = &args.config_json {
313        let bytes = std::fs::read(config_path)
314            .map_err(|e| anyhow::anyhow!("reading config json {}: {e}", config_path.display()))?;
315        info!(config = %config_path.display(), "mesofact-dev: serving /config.json (R513-F10)");
316        server = server.with_config_json(bytes);
317    }
318
319    // Canonicalize so the bun child's manifest read + dynamic-import use
320    // absolute paths regardless of the cwd mesofact-dev was invoked from.
321    let workload_abs = args
322        .workload
323        .canonicalize()
324        .unwrap_or_else(|_| args.workload.clone());
325    let state_dir = workload_abs.join(".mesofact-dev");
326    #[cfg(feature = "ssr")]
327    let ssr_slot = server.ssr_slot();
328
329    // Dev-tier S3 surface (R490-F7): host a local s3s-fs bucket so a workload's
330    // @mesofact/runtime R2Adapter can resolve against it during dev instead of
331    // real Cloudflare R2. Coords go to .mesofact-dev/s3.json for discovery,
332    // into the build child's env below, AND (R444) into the in-process SSR
333    // isolate's env — started before the SSR spawn below so the first boot
334    // already has coordinates, not just post-build respawns.
335    let dev_s3 = crate::DevS3::start(state_dir.join("s3"), crate::DEV_S3_BUCKET).await?;
336    info!(endpoint = %dev_s3.endpoint, bucket = %dev_s3.bucket, "dev S3 surface ready");
337
338    // Attach an SSR child if the workload's manifest declares any mode:"ssr"
339    // routes. ssr::spawn returns Ok(None) for static/SPA-only workloads (or
340    // when no build has emitted a manifest yet); the no-bun path is preserved
341    // and the post-build hook below retries lazily. (Compiled out entirely
342    // under --no-default-features: static/SPA/proxy serving without V8.)
343    #[cfg(feature = "ssr")]
344    {
345        let ssr_opts = SsrSpawnOptions::new(
346            workload_abs.clone(),
347            workload_abs.join("dist"),
348            state_dir.clone(),
349        )
350        .with_env(dev_s3.env_vars());
351        match ssr::spawn(ssr_opts).await? {
352            Some(child) => {
353                info!(prefixes = ?child.prefixes(), "mesofact-dev ssr child attached");
354                ssr_slot.set(Some(Arc::new(child)));
355            }
356            None => {
357                info!("mesofact-dev: no SSR routes (or no manifest yet); static-only");
358            }
359        }
360    }
361
362    // Instance-addressed (deferred) route resolution (W270 §9): point an
363    // S3Store at the dev-S3 surface so a static miss on a `prerender:
364    // { deferred: true }` route resolves through the pointer store against the
365    // same local bucket the publisher flips into — the local mirror of the edge
366    // worker's R2 resolution. Region "auto" + dummy creds match R2 / the
367    // anonymous dev surface (s3s skips signature verification).
368    #[cfg(feature = "ssr")]
369    {
370        use mesofact_publisher::{ObjectStore, S3Store};
371        match S3Store::new(dev_s3.endpoint.clone(), dev_s3.bucket.clone(), "auto", "dev", "dev") {
372            Ok(store) => {
373                server = server.with_instance_store(Arc::new(store) as Arc<dyn ObjectStore>);
374                info!("mesofact-dev: instance-addressed route resolution wired to dev S3 (W270 §9)");
375            }
376            Err(e) => {
377                info!(error = %e, "mesofact-dev: could not build dev pointer store; deferred routes 404")
378            }
379        }
380    }
381
382    if let Err(e) = std::fs::write(
383        state_dir.join("s3.json"),
384        serde_json::json!({ "endpoint": dev_s3.endpoint, "bucket": dev_s3.bucket }).to_string(),
385    ) {
386        info!(error = %e, "dev S3: could not write s3.json discovery file");
387    }
388
389    if args.no_watch {
390        info!("watch mode disabled");
391        return server.serve(args.port).await;
392    }
393
394    let pointer = server.pointer();
395    // `workload_abs`, not `args.workload` (R759-T4). The watcher's paths flow
396    // into the post-build hook's `gen_dir`, and the SSR pool registers each
397    // entrypoint as a `file://` module URL — which cannot be built from a
398    // relative path. Running `mesofact-dev .` (the invocation the scaffold's
399    // README gives, and the natural one from inside a project) therefore lost
400    // EVERY `mode: "ssr"` route: the initial spawn logs "no SSR routes" because
401    // no manifest exists yet, the post-build respawn then fails, and the only
402    // trace is one WARN about a "publish hook" that has nothing to do with
403    // publishing. Routes 404 as if they were never declared.
404    let mut opts = WatchOptions::defaults_for_workload(&workload_abs);
405    opts.initial_build = !args.no_initial_build;
406    opts.build_env = dev_s3.env_vars();
407
408    let watcher_obj = crate::Watcher::new(workload_abs.clone(), pointer, opts);
409
410    // Post-build hook: each successful rebuild rotates dist into
411    // .mesofact-dev/gen-N/, so the SSR runtime must be re-spawned against
412    // the new gen dir — V8's module cache would otherwise keep serving the
413    // old route entrypoints. Under R449-F2 the in-process model swaps the
414    // whole SsrChild in the slot (no SIGKILL/respawn dance the bun era
415    // needed); the prior Arc<SsrChild> drops, which joins the isolate
416    // thread.
417    #[cfg(feature = "ssr")]
418    let watcher_obj = {
419        let slot_for_hook = ssr_slot.clone();
420        let workload_for_hook = workload_abs.clone();
421        let state_dir_for_hook = state_dir.clone();
422        let dev_s3_for_hook = dev_s3.clone();
423        let hook: watcher::PostBuildFn = Box::new(move |gen_dir: PathBuf| {
424            let slot = slot_for_hook.clone();
425            let workload = workload_for_hook.clone();
426            let state_dir = state_dir_for_hook.clone();
427            let env = dev_s3_for_hook.env_vars();
428            Box::pin(async move {
429                let opts = SsrSpawnOptions::new(workload, gen_dir, state_dir).with_env(env);
430                match ssr::spawn(opts).await? {
431                    Some(child) => {
432                        info!(
433                            prefixes = ?child.prefixes(),
434                            "mesofact-dev ssr runtime re-spawned against new gen",
435                        );
436                        slot.set(Some(Arc::new(child)));
437                    }
438                    None => {
439                        // Manifest declares no SSR routes; clear any prior child.
440                        slot.set(None);
441                    }
442                }
443                Ok(())
444            })
445        });
446        watcher_obj.with_post_build(hook)
447    };
448
449    let watcher_task = watcher::spawn(watcher_obj);
450
451    // Server owns the foreground; the spawned watcher continues until the
452    // process exits.
453    let result = server.serve(args.port).await;
454    drop(watcher_task);
455    result
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use clap::CommandFactory;
462
463    fn parse(argv: &[&str]) -> Cli {
464        Cli::try_parse_from(argv).unwrap_or_else(|e| panic!("{argv:?} failed to parse:\n{e}"))
465    }
466
467    /// Resolve exactly as `main` does, so these tests exercise the real
468    /// bare-form fallthrough rather than a restatement of it.
469    fn dispatch(argv: &[&str]) -> Command {
470        resolve(parse(argv)).unwrap_or_else(|e| panic!("{argv:?} did not resolve:\n{e}"))
471    }
472
473    #[test]
474    fn clap_definition_is_well_formed() {
475        Cli::command().debug_assert();
476    }
477
478    /// THE compatibility contract. Every `mesofact-dev` spawn site in the
479    /// parent camp (kamaji, desktop, `serve_build.rs`, `local.sh`, …) uses
480    /// this shape; the rename is only safe while it keeps parsing. A failure
481    /// here means the second `[[bin]]` target is a lie.
482    #[test]
483    fn legacy_mesofact_dev_invocation_still_parses() {
484        let Command::Dev(args) = dispatch(&[
485            "mesofact-dev",
486            "app/yah/web",
487            "--port",
488            "8080",
489            "--no-watch",
490            "--service",
491            "dashboard",
492            "--component",
493            "web",
494        ]) else {
495            panic!("legacy invocation did not resolve to `dev`");
496        };
497        assert_eq!(args.workload, PathBuf::from("app/yah/web"));
498        assert_eq!(args.port, 8080);
499        assert!(args.no_watch);
500        assert_eq!(args.service.as_deref(), Some("dashboard"));
501        assert_eq!(args.component.as_deref(), Some("web"));
502    }
503
504    #[test]
505    fn bare_mes_is_dev_in_the_current_directory() {
506        let Command::Dev(args) = dispatch(&["mes"]) else {
507            panic!("bare `mes` did not resolve to `dev`");
508        };
509        assert_eq!(args.workload, PathBuf::from("."));
510        assert_eq!(args.port, DEFAULT_PORT);
511    }
512
513    #[test]
514    fn bare_form_takes_dev_flags() {
515        let Command::Dev(args) = dispatch(&["mes", "site", "--port", "3000"]) else {
516            panic!("bare form with flags did not resolve to `dev`");
517        };
518        assert_eq!(args.workload, PathBuf::from("site"));
519        assert_eq!(args.port, 3000);
520    }
521
522    #[test]
523    fn explicit_dev_subcommand_binds_its_own_args() {
524        let Command::Dev(args) = dispatch(&["mes", "dev", "site", "--port", "3000"]) else {
525            panic!("`mes dev` did not resolve to `dev`");
526        };
527        assert_eq!(args.workload, PathBuf::from("site"));
528        assert_eq!(args.port, 3000);
529    }
530
531    /// A leading flag pushes clap past the subcommand slot, so `dev` lands on
532    /// the positional. Serving a directory named `dev` is not what anyone
533    /// typing this meant — `resolve` has to catch it.
534    #[test]
535    fn subcommand_after_a_flag_is_rejected_not_silently_served() {
536        let cli = parse(&["mes", "--port", "3000", "dev"]);
537        assert!(cli.command.is_none(), "clap bound `dev` as a subcommand after all");
538        assert_eq!(cli.dev.workload, PathBuf::from("dev"));
539
540        let err = resolve(cli).expect_err("`mes --port 3000 dev` should not resolve").to_string();
541        assert!(err.contains("`dev` is a subcommand"), "unhelpful message: {err}");
542        assert!(err.contains("mes dev"), "message omits the fix: {err}");
543    }
544
545    /// The guard keys off the subcommand names, so a real directory that
546    /// merely starts the same way must still go through.
547    #[test]
548    fn guard_does_not_swallow_ordinary_directories() {
549        let Command::Dev(args) = dispatch(&["mes", "developer-site"]) else {
550            panic!("`developer-site` was not treated as a workload directory");
551        };
552        assert_eq!(args.workload, PathBuf::from("developer-site"));
553
554        // And the escape hatch the error message promises actually works.
555        let Command::Dev(args) = dispatch(&["mes", "./dev"]) else {
556            panic!("`./dev` was not treated as a workload directory");
557        };
558        assert_eq!(args.workload, PathBuf::from("./dev"));
559    }
560
561    #[test]
562    #[cfg(feature = "build")]
563    fn build_defaults_to_cwd_and_takes_out_dir() {
564        let Command::Build(args) = dispatch(&["mes", "build"]) else {
565            panic!("`mes build` did not resolve to `build`");
566        };
567        assert_eq!(args.workload, PathBuf::from("."));
568        assert_eq!(args.out_dir, None);
569    }
570
571    /// The prod verbs are reachable from `mes` — that is the whole point of
572    /// the superset, and it is the half a `cargo check` cannot confirm.
573    #[test]
574    fn prod_verbs_are_reachable() {
575        assert!(matches!(dispatch(&["mes", "serve"]), Command::Serve(_)));
576        assert!(matches!(dispatch(&["mes", "new", "hello"]), Command::New(_)));
577        #[cfg(feature = "publish")]
578        assert!(matches!(dispatch(&["mes", "publish"]), Command::Publish(_)));
579    }
580
581    /// `--lib` (R832-T2) reaches `mesofact::cli::new` through this CLI too.
582    /// The flag is defined on the facade's `NewArgs`, so a `mes new --lib`
583    /// that failed to parse would mean the two CLIs had drifted apart — the
584    /// exact thing the superset exists to prevent.
585    #[test]
586    fn new_takes_the_library_tier_flag() {
587        let Command::New(args) = dispatch(&["mes", "new", "--lib", "hello"]) else {
588            panic!("expected New");
589        };
590        assert!(args.lib);
591        assert_eq!(args.path, PathBuf::from("hello"));
592
593        let Command::New(args) = dispatch(&["mes", "new", "hello"]) else {
594            panic!("expected New");
595        };
596        assert!(!args.lib, "the standalone tier stays the default");
597    }
598}