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//! One verb is absent: **`proxy`** — Mode-2 deployment plumbing (worker pool,
35//! manifest reload on SIGHUP). It has no dev-loop meaning; `mesofact proxy`
36//! remains its home. Deliberate, and still true.
37//!
38//! `check` used to be a second one, for a reason that did
39//! not hold (R451, reported by @Ashguard:dragon 2026-09-01): the note read
40//! "running TypeScript needs node or bun on `PATH`", and it does not —
41//! typescript@7 ships `bin/tsc` as a three-line Node shim over a per-platform
42//! **native Go binary** (`node_modules/@typescript/typescript-<platform>-<arch>/lib/tsc`)
43//! that `mesofact-build`'s `check.rs` resolves and spawns directly.
44//!
45//! **`mes check` is implemented as of R832-T4**, and the reason it had to be
46//! is sharper than "cheap": a scaffolded project's `typecheck` script has to
47//! name a binary the user can actually reach, and the R759-F2 trampoline
48//! vends exactly three names — `mesofact`, `mes`, `mesofact-dev`. Every other
49//! name, `mesofact-build` included, exits 70. So the verb had to appear on one
50//! of those three before `mesofact new` could put it in a `package.json`.
51//!
52//! ## Bare invocation
53//!
54//! `mes` with no subcommand is `mes dev .` — the 95% command, so it is what
55//! you get for typing the least. This is also what keeps the legacy
56//! `mesofact-dev <DIR> --port N …` form working verbatim: it parses as the
57//! bare form, so the ~30 spawn sites in the parent camp need no flag day. The
58//! one divergence is a workload directory literally named after a subcommand
59//! (`mesofact-dev serve`); spell it `./serve` if that ever comes up.
60//!
61//! @yah:relay(R822, "Retire the mesofact-dev BINARY; mes is the only dev command (the package name stays)")
62//! @yah:at(2026-09-02T06:04:50Z)
63//! @yah:status(open)
64//! @yah:assignee(agent:bundle-anthropic-ashguard)
65//! @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.")
66//! @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.")
67//! @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.")
68//! @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 .`.")
69//! @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.")
70//! @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.")
71//! @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.")
72//! @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.")
73//! @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.")
74//! @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.")
75//! @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.")
76
77use std::path::PathBuf;
78#[cfg(feature = "ssr")]
79use std::sync::Arc;
80
81use clap::{Parser, Subcommand};
82#[cfg(feature = "ssr")]
83use mesofact::{ssr, SsrSpawnOptions};
84use mesofact::{Server, DEFAULT_PORT};
85use crate::{watcher, WatchOptions};
86use tracing::info;
87
88#[derive(Parser, Debug)]
89#[command(
90    name = "mes",
91    version,
92    about = "mes — the mesofact dev toolchain",
93    long_about = "Build, serve and ship a mesofact project. `mes` on its own runs the \
94                  hot-reload dev loop in the current directory.\n\n\
95                  The `serve` and `publish` verbs are the same code the shipped `mesofact` \
96                  binary runs; `dev` and `build` are dev-tier and exist only here.",
97    // A subcommand and the bare-form args are mutually exclusive: `mes dev .`
98    // must not also try to bind `.` to the top-level positional.
99    args_conflicts_with_subcommands = true
100)]
101struct Cli {
102    #[command(subcommand)]
103    command: Option<Command>,
104
105    /// Bare form — `mes [DIR] [--port …]` is `mes dev [DIR] [--port …]`.
106    #[command(flatten)]
107    dev: DevArgs,
108}
109
110#[derive(Subcommand, Debug)]
111enum Command {
112    /// Hot-reload dev loop: build in-process, serve, rebuild on every edit.
113    Dev(DevArgs),
114    /// One-shot production build into `dist/`. No watcher, no server.
115    #[cfg(feature = "build")]
116    Build(BuildArgs),
117    /// Full TypeScript semantic pass (`tsc --noEmit`) over the project.
118    #[cfg(feature = "build")]
119    Check(CheckArgs),
120    /// Scaffold a new mesofact project pinned to this binary's version.
121    New(mesofact::cli::new::NewArgs),
122    /// Serve a built bundle or host SSR routes — the prod serving path.
123    Serve(mesofact::cli::serve::ServeArgs),
124    /// Upload a built dist/ tree, swap the manifest pointer, purge CDN tags.
125    #[cfg(feature = "publish")]
126    Publish(mesofact::cli::publish::PublishArgs),
127}
128
129/// Full semantic pass (`mes check`) — R832-T4, and the verb the header's
130/// "check is absent" note said belonged to whichever ticket owns the check
131/// surface.
132///
133/// It matters that this exists on the **dev** binary rather than only on
134/// `mesofact-build`. `mesofact-build` ships in the release tarball, but the
135/// R759-F2 trampoline dispatches on the name it was invoked as and knows only
136/// `mesofact`, `mes` and `mesofact-dev` — every other name exits 70. That is
137/// deliberate rather than a gap: `scripts/mesofact-build.sh` reaches that
138/// binary by *path* out of a per-version cache, which is how in-repo consumers
139/// use it. But a scaffolded `package.json` has only `PATH` to work with, so it
140/// can name only a verb the trampoline vends — and `mes check` is one.
141///
142/// A thin forward to `mesofact::build::check`; this crate already links that
143/// crate behind the default-on `build` feature, so it costs a match arm.
144#[cfg(feature = "build")]
145#[derive(clap::Args, Debug)]
146struct CheckArgs {
147    /// Project directory containing `tsconfig.json`. Defaults to the current
148    /// directory, matching `mes dev`.
149    #[arg(default_value = ".")]
150    project: PathBuf,
151
152    /// tsconfig path (default: `<project>/tsconfig.json`).
153    #[arg(long, value_name = "PATH")]
154    tsconfig: Option<PathBuf>,
155
156    /// Extra args forwarded to the checker verbatim, after `--`.
157    #[arg(last = true)]
158    checker_args: Vec<String>,
159}
160
161/// One-shot build (`mes build`).
162///
163/// Drives [`mesofact::build::pipeline`] directly rather than going through
164/// [`crate::Watcher::rebuild`]: `rebuild` additionally snapshots into a
165/// `.mesofact-dev/gen-N/` directory and swaps the live pointer, which is the
166/// watcher's business. A one-shot build should leave `dist/` and nothing else.
167#[cfg(feature = "build")]
168#[derive(clap::Args, Debug)]
169struct BuildArgs {
170    /// Project directory — the parent of `mesofact.routes.ts`.
171    #[arg(default_value = ".")]
172    workload: PathBuf,
173
174    /// Output directory. Defaults to `<workload>/dist`.
175    #[arg(long, value_name = "DIR")]
176    out_dir: Option<PathBuf>,
177}
178
179#[derive(clap::Args, Debug)]
180struct DevArgs {
181    /// Workload directory — the parent of `dist/html/` (e.g. `app/yah/web`).
182    /// Defaults to the current directory.
183    #[arg(default_value = ".")]
184    workload: PathBuf,
185
186    /// TCP port to bind on 127.0.0.1.
187    #[arg(long, default_value_t = DEFAULT_PORT)]
188    port: u16,
189
190    /// Disable the file-watch + auto-rebuild loop; serve whatever's on disk.
191    #[arg(long)]
192    no_watch: bool,
193
194    /// Skip the initial build at startup (watch mode only).
195    #[arg(long)]
196    no_initial_build: bool,
197
198    /// Path to a JSON `prefix → backend base URL` map for the same-origin
199    /// reverse proxy (R513-F10), e.g.
200    /// `{"/auth": "http://127.0.0.1:8745", "/dev": "http://127.0.0.1:8745"}`.
201    /// Matching requests are forwarded to the backend before static serving so
202    /// the SPA stays single-origin (no CORS). Camp-emitted at SPA-service spawn.
203    #[arg(long, value_name = "PATH")]
204    proxy_map: Option<PathBuf>,
205
206    /// Path to a JSON file served verbatim at `/config.json` (R513-F5/F10): the
207    /// SPA's `DashboardConfig` (apiBaseUrl / authBaseUrl / env …). Injected by
208    /// the server — NOT placed in the served `dist/` — so an Option-A pipeline
209    /// serving the same bundle never inherits a stale `env:ci` config.
210    #[arg(long, value_name = "PATH")]
211    config_json: Option<PathBuf>,
212
213    /// Logical service this dev server serves (R602-B4). Paired with
214    /// `--component`, it's surfaced at `/__mesofact/info` so the cloud
215    /// reconciler can confirm a listener on the configured port is *this*
216    /// server before adopting it — refusing a colliding foreign listener
217    /// instead of silently hijacking it.
218    #[arg(long, requires = "component")]
219    service: Option<String>,
220
221    /// Logical component id this dev server serves (R602-B4). See `--service`.
222    #[arg(long, requires = "service")]
223    component: Option<String>,
224}
225
226pub async fn run() -> std::process::ExitCode {
227    tracing_subscriber::fmt()
228        .with_env_filter(
229            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
230                tracing_subscriber::EnvFilter::new("mesofact_dev=info,tower_http=info")
231            }),
232        )
233        .init();
234
235    let command = match resolve(Cli::parse()) {
236        Ok(command) => command,
237        Err(err) => {
238            eprintln!("mes: {err:#}");
239            return std::process::ExitCode::FAILURE;
240        }
241    };
242
243    match command {
244        Command::Dev(args) => to_exit_code(dev(args).await),
245        #[cfg(feature = "build")]
246        Command::Build(args) => to_exit_code(build(args).await),
247        // Mirrors `mesofact-build check`'s exit-code contract rather than
248        // to_exit_code's: the checker's own status is the answer, and
249        // collapsing every non-zero to 1 would lose it.
250        #[cfg(feature = "build")]
251        Command::Check(args) => match mesofact::build::check::check(mesofact::build::check::CheckOptions {
252            project_root: args.project,
253            tsconfig: args.tsconfig,
254            extra_args: args.checker_args,
255        }) {
256            Ok(outcome) if outcome.code == 0 => {
257                println!("mes check ok — tsc full semantic pass, no errors");
258                std::process::ExitCode::SUCCESS
259            }
260            // The checker already streamed its diagnostics.
261            Ok(outcome) => std::process::ExitCode::from(outcome.code.clamp(1, 255) as u8),
262            Err(err) => {
263                eprintln!("mes: {err:#}");
264                std::process::ExitCode::FAILURE
265            }
266        },
267        Command::New(args) => to_exit_code(mesofact::cli::new::run(args)),
268        Command::Serve(args) => to_exit_code(mesofact::cli::serve::run(args).await),
269        // `publish` owns its exit codes (2 = missing config, etc.) — pass through.
270        #[cfg(feature = "publish")]
271        Command::Publish(args) => mesofact::cli::publish::run(args).await,
272    }
273}
274
275/// Apply the bare-form fallthrough: no subcommand means `dev`.
276///
277/// Guards the one sharp edge a default subcommand buys. `mes --port 3000 dev`
278/// parses as *"run dev against a directory named `dev`"* — clap fills the
279/// positional because the leading flag already moved the parser past the
280/// subcommand slot. Silently serving the wrong directory is a poor answer to
281/// an obvious word-order typo, so name it instead. The subcommand list comes
282/// from clap rather than a literal, so a verb added above cannot forget to
283/// appear here.
284fn resolve(cli: Cli) -> anyhow::Result<Command> {
285    use clap::CommandFactory;
286
287    if let Some(command) = cli.command {
288        return Ok(command);
289    }
290    if let Some(name) = Cli::command()
291        .get_subcommands()
292        .map(clap::Command::get_name)
293        .find(|name| std::path::Path::new(name) == cli.dev.workload)
294    {
295        anyhow::bail!(
296            "`{name}` is a subcommand, but a flag came first so it was read as a \
297             directory. Put the subcommand first: `mes {name} …` \
298             (or say `./{name}` if you did mean the directory)."
299        );
300    }
301    Ok(Command::Dev(cli.dev))
302}
303
304fn to_exit_code(result: anyhow::Result<()>) -> std::process::ExitCode {
305    match result {
306        Ok(()) => std::process::ExitCode::SUCCESS,
307        Err(err) => {
308            eprintln!("mes: {err:#}");
309            std::process::ExitCode::FAILURE
310        }
311    }
312}
313
314/// `mes build` — run the pipeline once and stop.
315#[cfg(feature = "build")]
316async fn build(args: BuildArgs) -> anyhow::Result<()> {
317    let project_root = args
318        .workload
319        .canonicalize()
320        .unwrap_or_else(|_| args.workload.clone());
321    let out_dir = args.out_dir.unwrap_or_else(|| project_root.join("dist"));
322    info!(root = %project_root.display(), out = %out_dir.display(), "mes build");
323    mesofact::build::pipeline::build(mesofact::build::pipeline::BuildOptions {
324        project_root,
325        out_dir: Some(out_dir),
326        build_id: None,
327        // Same `Auto` as the watcher: materialize `node_modules` from the
328        // lockfile only when missing, so the build needs no package manager.
329        install: mesofact::build::pipeline::InstallMode::Auto,
330    })
331    .await?;
332    Ok(())
333}
334
335/// `mes dev` — the hot-reload loop. This is the whole of what the
336/// `mesofact-dev` binary used to be.
337async fn dev(args: DevArgs) -> anyhow::Result<()> {
338    let mut server = Server::from_workload(&args.workload)?;
339
340    // Logical identity (R602-B4): stamp the server with the (service, component)
341    // the reconciler spawned it for so `/__mesofact/info` lets the adopt path
342    // verify the port holds *this* server. clap's `requires` couples the pair,
343    // so both-or-neither is guaranteed here.
344    if let (Some(service), Some(component)) = (&args.service, &args.component) {
345        info!(service, component, "mesofact-dev: identity stamped (R602-B4)");
346        server = server.with_identity(service.clone(), component.clone());
347    }
348
349    // Same-origin reverse proxy (R513-F10): forward `/auth/*` etc. to the
350    // camp-vended backend ports so the dashboard E2E (Option B) browser stays
351    // single-origin. No map → no proxy (the Option A static path is unchanged).
352    if let Some(map_path) = &args.proxy_map {
353        let map = mesofact::ProxyMap::from_json_file(map_path)?;
354        info!(
355            map = %map_path.display(),
356            routes = ?map.routes(),
357            "mesofact-dev: same-origin reverse proxy installed",
358        );
359        server = server.with_proxy(map);
360    }
361
362    // Server-injected runtime config (R513-F5/F10) served at /config.json.
363    if let Some(config_path) = &args.config_json {
364        let bytes = std::fs::read(config_path)
365            .map_err(|e| anyhow::anyhow!("reading config json {}: {e}", config_path.display()))?;
366        info!(config = %config_path.display(), "mesofact-dev: serving /config.json (R513-F10)");
367        server = server.with_config_json(bytes);
368    }
369
370    // Canonicalize so the bun child's manifest read + dynamic-import use
371    // absolute paths regardless of the cwd mesofact-dev was invoked from.
372    let workload_abs = args
373        .workload
374        .canonicalize()
375        .unwrap_or_else(|_| args.workload.clone());
376    let state_dir = workload_abs.join(".mesofact-dev");
377    #[cfg(feature = "ssr")]
378    let ssr_slot = server.ssr_slot();
379
380    // Dev-tier S3 surface (R490-F7): host a local s3s-fs bucket so a workload's
381    // @mesofact/runtime R2Adapter can resolve against it during dev instead of
382    // real Cloudflare R2. Coords go to .mesofact-dev/s3.json for discovery,
383    // into the build child's env below, AND (R444) into the in-process SSR
384    // isolate's env — started before the SSR spawn below so the first boot
385    // already has coordinates, not just post-build respawns.
386    let dev_s3 = crate::DevS3::start(state_dir.join("s3"), crate::DEV_S3_BUCKET).await?;
387    info!(endpoint = %dev_s3.endpoint, bucket = %dev_s3.bucket, "dev S3 surface ready");
388
389    // Attach an SSR child if the workload's manifest declares any mode:"ssr"
390    // routes. ssr::spawn returns Ok(None) for static/SPA-only workloads (or
391    // when no build has emitted a manifest yet); the no-bun path is preserved
392    // and the post-build hook below retries lazily. (Compiled out entirely
393    // under --no-default-features: static/SPA/proxy serving without V8.)
394    #[cfg(feature = "ssr")]
395    {
396        let ssr_opts = SsrSpawnOptions::new(
397            workload_abs.clone(),
398            workload_abs.join("dist"),
399            state_dir.clone(),
400        )
401        .with_env(dev_s3.env_vars());
402        match ssr::spawn(ssr_opts).await? {
403            Some(child) => {
404                info!(prefixes = ?child.prefixes(), "mesofact-dev ssr child attached");
405                ssr_slot.set(Some(Arc::new(child)));
406            }
407            None => {
408                info!("mesofact-dev: no SSR routes (or no manifest yet); static-only");
409            }
410        }
411    }
412
413    // Instance-addressed (deferred) route resolution (W270 §9): point an
414    // S3Store at the dev-S3 surface so a static miss on a `prerender:
415    // { deferred: true }` route resolves through the pointer store against the
416    // same local bucket the publisher flips into — the local mirror of the edge
417    // worker's R2 resolution. Region "auto" + dummy creds match R2 / the
418    // anonymous dev surface (s3s skips signature verification).
419    #[cfg(feature = "ssr")]
420    {
421        use mesofact_publisher::{ObjectStore, S3Store};
422        match S3Store::new(dev_s3.endpoint.clone(), dev_s3.bucket.clone(), "auto", "dev", "dev") {
423            Ok(store) => {
424                server = server.with_instance_store(Arc::new(store) as Arc<dyn ObjectStore>);
425                info!("mesofact-dev: instance-addressed route resolution wired to dev S3 (W270 §9)");
426            }
427            Err(e) => {
428                info!(error = %e, "mesofact-dev: could not build dev pointer store; deferred routes 404")
429            }
430        }
431    }
432
433    if let Err(e) = std::fs::write(
434        state_dir.join("s3.json"),
435        serde_json::json!({ "endpoint": dev_s3.endpoint, "bucket": dev_s3.bucket }).to_string(),
436    ) {
437        info!(error = %e, "dev S3: could not write s3.json discovery file");
438    }
439
440    if args.no_watch {
441        info!("watch mode disabled");
442        return server.serve(args.port).await;
443    }
444
445    let pointer = server.pointer();
446    // `workload_abs`, not `args.workload` (R759-T4). The watcher's paths flow
447    // into the post-build hook's `gen_dir`, and the SSR pool registers each
448    // entrypoint as a `file://` module URL — which cannot be built from a
449    // relative path. Running `mesofact-dev .` (the invocation the scaffold's
450    // README gives, and the natural one from inside a project) therefore lost
451    // EVERY `mode: "ssr"` route: the initial spawn logs "no SSR routes" because
452    // no manifest exists yet, the post-build respawn then fails, and the only
453    // trace is one WARN about a "publish hook" that has nothing to do with
454    // publishing. Routes 404 as if they were never declared.
455    let mut opts = WatchOptions::defaults_for_workload(&workload_abs);
456    opts.initial_build = !args.no_initial_build;
457    opts.build_env = dev_s3.env_vars();
458
459    let watcher_obj = crate::Watcher::new(workload_abs.clone(), pointer, opts);
460
461    // Post-build hook: each successful rebuild rotates dist into
462    // .mesofact-dev/gen-N/, so the SSR runtime must be re-spawned against
463    // the new gen dir — V8's module cache would otherwise keep serving the
464    // old route entrypoints. Under R449-F2 the in-process model swaps the
465    // whole SsrChild in the slot (no SIGKILL/respawn dance the bun era
466    // needed); the prior Arc<SsrChild> drops, which joins the isolate
467    // thread.
468    #[cfg(feature = "ssr")]
469    let watcher_obj = {
470        let slot_for_hook = ssr_slot.clone();
471        let workload_for_hook = workload_abs.clone();
472        let state_dir_for_hook = state_dir.clone();
473        let dev_s3_for_hook = dev_s3.clone();
474        let hook: watcher::PostBuildFn = Box::new(move |gen_dir: PathBuf| {
475            let slot = slot_for_hook.clone();
476            let workload = workload_for_hook.clone();
477            let state_dir = state_dir_for_hook.clone();
478            let env = dev_s3_for_hook.env_vars();
479            Box::pin(async move {
480                let opts = SsrSpawnOptions::new(workload, gen_dir, state_dir).with_env(env);
481                match ssr::spawn(opts).await? {
482                    Some(child) => {
483                        info!(
484                            prefixes = ?child.prefixes(),
485                            "mesofact-dev ssr runtime re-spawned against new gen",
486                        );
487                        slot.set(Some(Arc::new(child)));
488                    }
489                    None => {
490                        // Manifest declares no SSR routes; clear any prior child.
491                        slot.set(None);
492                    }
493                }
494                Ok(())
495            })
496        });
497        watcher_obj.with_post_build(hook)
498    };
499
500    let watcher_task = watcher::spawn(watcher_obj);
501
502    // Server owns the foreground; the spawned watcher continues until the
503    // process exits.
504    let result = server.serve(args.port).await;
505    drop(watcher_task);
506    result
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use clap::CommandFactory;
513
514    fn parse(argv: &[&str]) -> Cli {
515        Cli::try_parse_from(argv).unwrap_or_else(|e| panic!("{argv:?} failed to parse:\n{e}"))
516    }
517
518    /// Resolve exactly as `main` does, so these tests exercise the real
519    /// bare-form fallthrough rather than a restatement of it.
520    fn dispatch(argv: &[&str]) -> Command {
521        resolve(parse(argv)).unwrap_or_else(|e| panic!("{argv:?} did not resolve:\n{e}"))
522    }
523
524    #[test]
525    fn clap_definition_is_well_formed() {
526        Cli::command().debug_assert();
527    }
528
529    /// THE compatibility contract. Every `mesofact-dev` spawn site in the
530    /// parent camp (kamaji, desktop, `serve_build.rs`, `local.sh`, …) uses
531    /// this shape; the rename is only safe while it keeps parsing. A failure
532    /// here means the second `[[bin]]` target is a lie.
533    #[test]
534    fn legacy_mesofact_dev_invocation_still_parses() {
535        let Command::Dev(args) = dispatch(&[
536            "mesofact-dev",
537            "app/yah/web",
538            "--port",
539            "8080",
540            "--no-watch",
541            "--service",
542            "dashboard",
543            "--component",
544            "web",
545        ]) else {
546            panic!("legacy invocation did not resolve to `dev`");
547        };
548        assert_eq!(args.workload, PathBuf::from("app/yah/web"));
549        assert_eq!(args.port, 8080);
550        assert!(args.no_watch);
551        assert_eq!(args.service.as_deref(), Some("dashboard"));
552        assert_eq!(args.component.as_deref(), Some("web"));
553    }
554
555    #[test]
556    fn bare_mes_is_dev_in_the_current_directory() {
557        let Command::Dev(args) = dispatch(&["mes"]) else {
558            panic!("bare `mes` did not resolve to `dev`");
559        };
560        assert_eq!(args.workload, PathBuf::from("."));
561        assert_eq!(args.port, DEFAULT_PORT);
562    }
563
564    /// `mes check` has to be a real subcommand rather than a directory named
565    /// `check` handed to the bare `dev` form — the bare-form fallthrough makes
566    /// that a live confusion, and a scaffolded `package.json` names this verb.
567    #[cfg(feature = "build")]
568    #[test]
569    fn check_is_a_subcommand_and_defaults_to_here() {
570        let Command::Check(args) = dispatch(&["mes", "check"]) else {
571            panic!("`mes check` did not resolve to the check verb");
572        };
573        assert_eq!(args.project, PathBuf::from("."));
574        let Command::Check(args) = dispatch(&["mes", "check", "site"]) else {
575            panic!("`mes check site` did not resolve to the check verb");
576        };
577        assert_eq!(args.project, PathBuf::from("site"));
578    }
579
580    #[test]
581    fn bare_form_takes_dev_flags() {
582        let Command::Dev(args) = dispatch(&["mes", "site", "--port", "3000"]) else {
583            panic!("bare form with flags did not resolve to `dev`");
584        };
585        assert_eq!(args.workload, PathBuf::from("site"));
586        assert_eq!(args.port, 3000);
587    }
588
589    #[test]
590    fn explicit_dev_subcommand_binds_its_own_args() {
591        let Command::Dev(args) = dispatch(&["mes", "dev", "site", "--port", "3000"]) else {
592            panic!("`mes dev` did not resolve to `dev`");
593        };
594        assert_eq!(args.workload, PathBuf::from("site"));
595        assert_eq!(args.port, 3000);
596    }
597
598    /// A leading flag pushes clap past the subcommand slot, so `dev` lands on
599    /// the positional. Serving a directory named `dev` is not what anyone
600    /// typing this meant — `resolve` has to catch it.
601    #[test]
602    fn subcommand_after_a_flag_is_rejected_not_silently_served() {
603        let cli = parse(&["mes", "--port", "3000", "dev"]);
604        assert!(cli.command.is_none(), "clap bound `dev` as a subcommand after all");
605        assert_eq!(cli.dev.workload, PathBuf::from("dev"));
606
607        let err = resolve(cli).expect_err("`mes --port 3000 dev` should not resolve").to_string();
608        assert!(err.contains("`dev` is a subcommand"), "unhelpful message: {err}");
609        assert!(err.contains("mes dev"), "message omits the fix: {err}");
610    }
611
612    /// The guard keys off the subcommand names, so a real directory that
613    /// merely starts the same way must still go through.
614    #[test]
615    fn guard_does_not_swallow_ordinary_directories() {
616        let Command::Dev(args) = dispatch(&["mes", "developer-site"]) else {
617            panic!("`developer-site` was not treated as a workload directory");
618        };
619        assert_eq!(args.workload, PathBuf::from("developer-site"));
620
621        // And the escape hatch the error message promises actually works.
622        let Command::Dev(args) = dispatch(&["mes", "./dev"]) else {
623            panic!("`./dev` was not treated as a workload directory");
624        };
625        assert_eq!(args.workload, PathBuf::from("./dev"));
626    }
627
628    #[test]
629    #[cfg(feature = "build")]
630    fn build_defaults_to_cwd_and_takes_out_dir() {
631        let Command::Build(args) = dispatch(&["mes", "build"]) else {
632            panic!("`mes build` did not resolve to `build`");
633        };
634        assert_eq!(args.workload, PathBuf::from("."));
635        assert_eq!(args.out_dir, None);
636    }
637
638    /// The prod verbs are reachable from `mes` — that is the whole point of
639    /// the superset, and it is the half a `cargo check` cannot confirm.
640    #[test]
641    fn prod_verbs_are_reachable() {
642        assert!(matches!(dispatch(&["mes", "serve"]), Command::Serve(_)));
643        assert!(matches!(dispatch(&["mes", "new", "hello"]), Command::New(_)));
644        #[cfg(feature = "publish")]
645        assert!(matches!(dispatch(&["mes", "publish"]), Command::Publish(_)));
646    }
647
648    /// `--lib` (R832-T2) reaches `mesofact::cli::new` through this CLI too.
649    /// The flag is defined on the facade's `NewArgs`, so a `mes new --lib`
650    /// that failed to parse would mean the two CLIs had drifted apart — the
651    /// exact thing the superset exists to prevent.
652    #[test]
653    fn new_takes_the_library_tier_flag() {
654        let Command::New(args) = dispatch(&["mes", "new", "--lib", "hello"]) else {
655            panic!("expected New");
656        };
657        assert!(args.lib);
658        assert_eq!(args.path, PathBuf::from("hello"));
659
660        let Command::New(args) = dispatch(&["mes", "new", "hello"]) else {
661            panic!("expected New");
662        };
663        assert!(!args.lib, "the standalone tier stays the default");
664    }
665}