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}