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}