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`]. That is what lets the command ship from a
5//! *different package* — `mes`, whose `main.rs` is three lines over
6//! [`run`] — while this crate stays a pure library. `mesofact-dev` emits no
7//! binaries at all (MFT-R822); see `../Cargo.toml` for why the name you type
8//! and the name you depend on are deliberately different.
9//!
10//! ## Why this is a superset of `mesofact`, not an alias of it
11//!
12//! `mes` carries the prod verbs (`serve`, `publish`) alongside the dev ones
13//! (`dev`, `build`), so a consumer learns one CLI. That does **not** weaken
14//! the W225 §2 dev/prod boundary, because that boundary is about which crates
15//! land in a *binary*, not about which verbs a binary spells. It only ever
16//! runs one direction: this crate already depends on the `mesofact` facade, so
17//! `mes serve` is an in-process call into [`mesofact::cli`] — free. The
18//! reverse (making the shipped `mesofact` binary a shim over `mes`) would drag
19//! the watcher, the dev S3 surface and rolldown into the prod closure, and is
20//! exactly what §2 forbids. `mesofact` stays a standalone binary.
21//!
22//! **Why not spawn the `mesofact` binary for the prod verbs instead of linking
23//! it?** (Asked more than once; the answer is not "in-process is tidier".) It
24//! would save nothing. `mes dev` — the 95% command — is built directly on the
25//! facade's engine: [`mesofact::Server::from_workload`],
26//! [`mesofact::ssr::spawn`], [`mesofact::SsrSpawnOptions`],
27//! [`mesofact::ProxyMap`]. The facade is linked into this binary for the DEV
28//! loop whether or not `serve` is in-process, so process-calling removes zero
29//! bytes and adds a runtime dependency on finding `mesofact` on `PATH` —
30//! `cargo install mes` would yield a `mes` whose `serve` fails until you
31//! separately install `mesofact`. It would also hand us exit-code and
32//! signal forwarding for no gain. The link-graph direction is what carries the
33//! security property here; the process boundary carries none of it.
34//!
35//! One verb is absent: **`proxy`** — Mode-2 deployment plumbing (worker pool,
36//! manifest reload on SIGHUP). It has no dev-loop meaning; `mesofact proxy`
37//! remains its home. Deliberate, and still true.
38//!
39//! `check` used to be a second one, for a reason that did
40//! not hold (R451, reported by @Ashguard:dragon 2026-09-01): the note read
41//! "running TypeScript needs node or bun on `PATH`", and it does not —
42//! typescript@7 ships `bin/tsc` as a three-line Node shim over a per-platform
43//! **native Go binary** (`node_modules/@typescript/typescript-<platform>-<arch>/lib/tsc`)
44//! that `mesofact-build`'s `check.rs` resolves and spawns directly.
45//!
46//! **`mes check` is implemented as of R832-T4**, and the reason it had to be
47//! is sharper than "cheap": a scaffolded project's `typecheck` script has to
48//! name a binary the user can actually reach, and the R759-F2 trampoline
49//! vends exactly three names — `mesofact`, `mes`, `mesofact-dev`. Every other
50//! name, `mesofact-build` included, exits 70. So the verb had to appear on one
51//! of those three before `mesofact new` could put it in a `package.json`.
52//!
53//! ## Bare invocation
54//!
55//! `mes` with no subcommand is `mes dev .` — the 95% command, so it is what
56//! you get for typing the least. The one divergence is a workload directory
57//! literally named after a subcommand (`mes serve`); spell it `./serve` if
58//! that ever comes up.
59//!
60//! This form also did a second job that is now spent, and the distinction
61//! matters because deleting the form along with the job would be wrong. While
62//! the binary was still named `mesofact-dev`, the bare form is what made the
63//! legacy `mesofact-dev <DIR> --port N …` invocation parse unchanged, so the
64//! ~30 spawn sites in the parent camp needed no flag day when `mes` arrived
65//! beside it. MFT-R822 retired that binary and migrated those sites, so no
66//! caller depends on the bare form for compatibility any more — but it is
67//! still the right default for `mes` on its own merits, which is the first
68//! paragraph and always was.
69//!
70//! @yah:relay(R822, "Retire the mesofact-dev BINARY; mes is the only dev command (the package name stays)")
71//! @yah:status(review)
72//! @yah:at(2026-09-03T08:13:42Z)
73//! @yah:assignee(bundle-anthropic-ashguard)
74//! @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.")
75//! @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.")
76//! @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.")
77//! @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 .`.")
78//! @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.")
79//! @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.")
80//! @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.")
81//! @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.")
82//! @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.")
83//! @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.")
84//! @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.")
85//! @yah:notify_on(R556-F6, "R556-F6 swept MFT-R822 rename fallout you had not reached: oss/qed/crates/qed/images/mesofact-musl-builder/build-mesofact.sh still built `-p mesofact-dev --bin mesofact-dev` and killed a live `yah qed run mesofact-musl` with \"error: no bin target named mesofact-dev in mesofact-dev package\". Fixed there to `-p mes --bin mes`, staged filename now `mes` (install.sh prefers `mes`, keeps `mesofact-dev` only as the declared pre-rename support window). The W225 §2 closure grep was deliberately left alone — `mesofact-dev` is still the right PACKAGE name and only the bin moved. STILL DRIFTING, LEFT FOR YOU because it is the release surface: scripts/publish-mesofact-release.sh (~148-149 cross-build-guarded.sh mesofact-dev, ~171 BINS=(mesofact mesofact-dev mesofact-build)) and .github/workflows/release.yml (~996-1001 --param package=mesofact-dev --param bin=mesofact-dev). Also worth a look: cdn.yah.dev/mesofact/latest.json is 0.8.30 published 2026-09-03T00:38Z and still advertises bins [mesofact, mesofact-dev, mesofact-build] — check whether that publish produced a mesofact-dev binary or whether the manifest now describes a tarball that no longer matches.")
86//! @yah:handoff("SPLIT LANDED. New package oss/mesofact/crates/mes — one Cargo.toml, one three-line src/main.rs over mesofact_dev::cli::run(), added to workspace members. mesofact-dev now emits ZERO bin targets: both [[bin]] blocks gone, src/main.rs and src/bin/mesofact-dev.rs deleted, manifest comment rewritten to say why there must never be one again (a bin here re-breaks `cargo install mes`, and a second package emitting `mes` races ../mes for ~/.cargo/bin/mes). cli.rs's module doc no longer claims two bin targets, and its bare-invocation section keeps the form while retiring the spent justification — MFT-R822 migrated the spawn sites, so the bare form now stands on its own merits, which it always did.")
87//! @yah:handoff("THE SHARP EDGE, ANSWERED: compat window, not store migration. A slot is a directory a past install.sh untarred and nothing rewrites it, so pre-rename slots hold `mesofact-dev` forever. crates/yah/mesofact-store gains LEGACY_BIN_NAMES + bin_candidates(); Slot::bin resolves the current name through them and falls back to the name asked for, and inspect_slot reports missing under the CURRENT name while accepting the old file. EXPECTED_BINS is now [\"mesofact\", \"mes\"] — a list of current names, which is why it is two constants and not one. mesofact-shim.sh (and its verbatim copy inside install.sh) does the same two-step probe: `$slot/mes`, else `$slot/mesofact-dev`, with the COMPLETENESS check using the resolved path so an old slot reads Complete rather than \"partially installed\". Retiring the fallback is a support-window decision; the shim and LEGACY_BIN_NAMES retire together or not at all, and both say so.")
88//! @yah:handoff("PARENT CAMP, all 30 exec-name sites: yah_bundled::BUNDLED's entry is `mes` (that one field is simultaneously the cargo -p, the bin, the staged file and the bundled: source_ref); tauri.conf.json externalBin follows and its drift test passes; desktop mesofact_versions does find(\"mes\") + slot.bin(\"mes\"); .yah/qed/{dashboard-e2e,dashboard-e2e-auth}.toml build and run -p mes --bin mes; scripts/publish-mesofact-release.sh ships BINS=(mesofact mes mesofact-build); .github/workflows/release.yml's leg B, its Package step and both manifest `bins` arrays follow; release-build.toml / mesofact-musl.toml / oss-publish.toml prose corrected. THREE THINGS DELIBERATELY DID NOT MOVE, each for a reason at the site: the plugin ID stays `mesofact-dev` (it keys the PINNED catalog, the discovery index, the Run-tab entry and .yah/jit/mesofact-dev-n — only its source_ref names a binary); MESOFACT_DEV_BIN stays (yubaba's mesofact-static reconciler and `cargo run -p desktop` read it, and renaming a documented env var buys nothing); and `mesofact-dev` stays in SHIM_NAMES as a pure PATH alias onto the same slot binary.")
89//! @yah:handoff("THE BUILTIN MANIFEST WAS RE-SIGNED, and that is the one step that needed a secret. source_ref = \"bundled:mesofact-dev\" -> \"bundled:mes\" invalidates the Ed25519 signature, and supervise_plugin verifies BEFORE it spawns and fails closed — so an unsigned edit would have silently stopped the Run tab supervising mesofact-dev at all. Ran the documented ceremony: YAH_PLUGIN_RELEASE_KEY=\"$(yah keys get yah-plugin-release-key)\" cargo run -p xtask -- plugin-sign. The key was never printed and the other three manifests report \"already signed, unchanged\". Proven by desktop::plugins::tests::every_shipped_builtin_verifies_under_the_real_release_key, which is green.")
90//! @yah:handoff("DISCOVERED + FIXED, outside the ticket's file list. (1) xtask/src/main.rs check_staged_sidecars was PRESENCE-ONLY, and app/yah/desktop/build.rs deliberately writes a ZERO-BYTE placeholder for any unstaged registry entry so `cargo check -p desktop` passes tauri-build's resource check — so the gate happily passed a staging where the real 82 MB binary sat under the OLD name and a 0-byte `mes-<triple>` sat beside it. `cargo tauri build` would have bundled the empty file. Now a zero-length staged file counts as missing; the fn's doc records the mechanism and how this was found. Verified both ways: red before staging, green after. (2) Staged the real sidecar (cargo run -p xtask -- build-mes-sidecar) and removed the orphaned mesofact-dev-aarch64-apple-darwin, which nothing references any more.")
91//! @yah:handoff("THE GOTCHA'S FEATURE FORWARDING IS NOW MECHANICAL, not a promise. mes re-exposes default/ssr/publish/build over mesofact-dev, INCLUDING the ssr->publish implication, and mes/src/main.rs carries a test that parses both manifests and asserts the two [features] tables have identical keys, identical defaults, that every non-default feature forwards `mesofact-dev/<same>`, and that any implication between mesofact-dev's own features is mirrored. It skips silently when the sibling manifest is absent (a packaged .crate). That is the check the gotcha asked for: forgetting a forward is otherwise invisible — mes still builds and silently ignores the flag.")
92//! @yah:verify("cargo check --workspace --all-targets (root) -> exit 0. cargo clippy -p yah-mesofact-store -p yah-bundled -p yah-plugin -p xtask --all-targets and -p mes -p mesofact-dev (oss/mesofact) -> exit 0, ZERO warnings in any crate this ticket changed (the ones printed are pre-existing, in yubaba's mesofact_static.rs, xtask/src/install.rs:276, mesofact-core and mesofact-build).")
93//! @yah:verify("cargo test — yah-mesofact-store 25 (2 new: a_pre_rename_slot_is_complete_and_resolves_to_the_file_it_holds, the_new_shim_runs_a_pre_rename_slot), yah-bundled 12, yah-plugin 69, xtask --test main bundled 3 (incl. tauri_external_bin_matches_the_bundled_registry), desktop --lib mesofact_versions 5 (1 new: a_slot_installed_before_the_rename_still_resolves) + plugins:: 8, yah --lib plugin_host 11, mesofact-dev 28, mes 1. All 0 failed.")
94//! @yah:verify("bash scripts/check-mesofact-store.sh -> 21 passed, 0 failed, driving the REAL embedded install.sh against a local CDN. Two of those cells are new and are the compat window end-to-end: a 0.8.29 tarball whose dev binary is named mesofact-dev installs as a COMPLETE slot, and the CURRENT shim runs it under both `mes` and `mesofact-dev`. bash scripts/dev/run-install-compat-nocosign.sh -> 24 passed, 0 failed, which is what proves the shim re-pasted into install.sh is byte-identical to mesofact-shim.sh.")
95//! @yah:verify("Feature forwarding measured, not assumed, via cargo tree -e normal on -p mes: default -> 79 lines matching deno_core|rolldown|mesofact-publisher; --no-default-features -> 0; --no-default-features --features ssr -> deno_core back (9). So the lean static/SPA path that exists to let a consumer skip the V8 toolchain survives the package split.")
96//! @yah:gotcha("NOT DONE, AND DELIBERATELY: `mes` is not published to crates.io. The name was unclaimed when this landed (sparse index 404 on /3/m/mes, 2026-09-02) and nothing here reserves it — the first `scripts/oss-publish.sh oss/mesofact` run picks it up on its own, because that script enumerates no members and `cargo publish --workspace` orders by dependency topology, so `mes` lands after `mesofact-dev`. Until then `cargo install mes` still fails for an outside user, which is the very problem this split exists to fix. cdn.yah.dev/mesofact/latest.json is 0.8.30 and advertises bins [mesofact, mesofact-dev, mesofact-build]; that is CORRECT, not drift — it was cut before this landed and its tarball really does hold a file of that name. The compat window covers it; nothing needs republishing, and the next cut ships `mes`.")
97//! @yah:gotcha("TWO THINGS LEFT ALONE ON PURPOSE, so nobody re-derives them as omissions. (1) `cargo test --workspace` in oss/mesofact is 194 passed / 3 FAILED, and none of the three are from this change — filed and attributed as R824. They are curated.rs's DELIBERATE red-until-publish alarm firing because the 0.8.31 bump's npm/crates.io publishes have not run (the barrel is still 0.8.29, so it was already red at 0.8.30 too). Do not silence it. (2) ~20 W###/A### docs still contain the string `mesofact-dev`. Checked rather than assumed: every one is either @yah: annotation prose (history, out of scope by the ticket's own framing) or a reference to the PACKAGE / the plugin id / the .mesofact-dev state dir — all three of which are still correct. W225's body needs no edit.")
98//! @yah:gotcha("SHARED-TREE NOTE. @Glimmerstone:griffin (R556-F6) fixed oss/qed/crates/qed/images/mesofact-musl-builder/build-mesofact.sh independently on 2026-09-03 after hitting this rename as a LIVE fleet-build failure — `yah qed run mesofact-musl` died ~6 min in with \"no bin target named mesofact-dev in mesofact-dev package\". Their hunks are correct and untouched here, including their decision NOT to change the W225 §2 closure grep (it greps `cargo tree` for the PACKAGE `mesofact-dev`, which is still right — only the bin moved). They also moved the script into mesofact-musl.toml's source_context so the digest-pinned image stops carrying a frozen copy that a rename can silently break. All my edits landed in commit 202fd70a (\"0.8.31\") — a peer's wip-commit swept them in mid-session; nothing was lost, but `git diff` will not show them.")
99
100use std::path::PathBuf;
101#[cfg(feature = "ssr")]
102use std::sync::Arc;
103
104use clap::{Parser, Subcommand};
105#[cfg(feature = "ssr")]
106use mesofact::{ssr, SsrSpawnOptions};
107use mesofact::{Server, DEFAULT_PORT};
108use crate::{watcher, WatchOptions};
109use tracing::info;
110
111#[derive(Parser, Debug)]
112#[command(
113    name = "mes",
114    version,
115    about = "mes — the mesofact dev toolchain",
116    long_about = "Build, serve and ship a mesofact project. `mes` on its own runs the \
117                  hot-reload dev loop in the current directory.\n\n\
118                  The `serve` and `publish` verbs are the same code the shipped `mesofact` \
119                  binary runs; `dev` and `build` are dev-tier and exist only here.",
120    // A subcommand and the bare-form args are mutually exclusive: `mes dev .`
121    // must not also try to bind `.` to the top-level positional.
122    args_conflicts_with_subcommands = true
123)]
124struct Cli {
125    #[command(subcommand)]
126    command: Option<Command>,
127
128    /// Bare form — `mes [DIR] [--port …]` is `mes dev [DIR] [--port …]`.
129    #[command(flatten)]
130    dev: DevArgs,
131}
132
133#[derive(Subcommand, Debug)]
134enum Command {
135    /// Hot-reload dev loop: build in-process, serve, rebuild on every edit.
136    Dev(DevArgs),
137    /// One-shot production build into `dist/`. No watcher, no server.
138    #[cfg(feature = "build")]
139    Build(BuildArgs),
140    /// Full TypeScript semantic pass (`tsc --noEmit`) over the project.
141    #[cfg(feature = "build")]
142    Check(CheckArgs),
143    /// Scaffold a new mesofact project pinned to this binary's version.
144    New(mesofact::cli::new::NewArgs),
145    /// Serve a built bundle or host SSR routes — the prod serving path.
146    Serve(mesofact::cli::serve::ServeArgs),
147    /// Upload a built dist/ tree, swap the manifest pointer, purge CDN tags.
148    #[cfg(feature = "publish")]
149    Publish(mesofact::cli::publish::PublishArgs),
150}
151
152/// Full semantic pass (`mes check`) — R832-T4, and the verb the header's
153/// "check is absent" note said belonged to whichever ticket owns the check
154/// surface.
155///
156/// It matters that this exists on the **dev** binary rather than only on
157/// `mesofact-build`. `mesofact-build` ships in the release tarball, but the
158/// R759-F2 trampoline dispatches on the name it was invoked as and knows only
159/// `mesofact`, `mes` and `mesofact-dev` — every other name exits 70. That is
160/// deliberate rather than a gap: `scripts/mesofact-build.sh` reaches that
161/// binary by *path* out of a per-version cache, which is how in-repo consumers
162/// use it. But a scaffolded `package.json` has only `PATH` to work with, so it
163/// can name only a verb the trampoline vends — and `mes check` is one.
164///
165/// A thin forward to `mesofact::build::check`; this crate already links that
166/// crate behind the default-on `build` feature, so it costs a match arm.
167#[cfg(feature = "build")]
168#[derive(clap::Args, Debug)]
169struct CheckArgs {
170    /// Project directory containing `tsconfig.json`. Defaults to the current
171    /// directory, matching `mes dev`.
172    #[arg(default_value = ".")]
173    project: PathBuf,
174
175    /// tsconfig path (default: `<project>/tsconfig.json`).
176    #[arg(long, value_name = "PATH")]
177    tsconfig: Option<PathBuf>,
178
179    /// Extra args forwarded to the checker verbatim, after `--`.
180    #[arg(last = true)]
181    checker_args: Vec<String>,
182}
183
184/// One-shot build (`mes build`).
185///
186/// Drives [`mesofact::build::pipeline`] directly rather than going through
187/// [`crate::Watcher::rebuild`]: `rebuild` additionally snapshots into a
188/// `.mesofact-dev/gen-N/` directory and swaps the live pointer, which is the
189/// watcher's business. A one-shot build should leave `dist/` and nothing else.
190#[cfg(feature = "build")]
191#[derive(clap::Args, Debug)]
192struct BuildArgs {
193    /// Project directory — the parent of `mesofact.routes.ts`.
194    #[arg(default_value = ".")]
195    workload: PathBuf,
196
197    /// Output directory. Defaults to `<workload>/dist`.
198    #[arg(long, value_name = "DIR")]
199    out_dir: Option<PathBuf>,
200}
201
202#[derive(clap::Args, Debug)]
203struct DevArgs {
204    /// Workload directory — the parent of `dist/html/` (e.g. `app/yah/web`).
205    /// Defaults to the current directory.
206    #[arg(default_value = ".")]
207    workload: PathBuf,
208
209    /// TCP port to bind on 127.0.0.1.
210    #[arg(long, default_value_t = DEFAULT_PORT)]
211    port: u16,
212
213    /// Disable the file-watch + auto-rebuild loop; serve whatever's on disk.
214    #[arg(long)]
215    no_watch: bool,
216
217    /// Skip the initial build at startup (watch mode only).
218    #[arg(long)]
219    no_initial_build: bool,
220
221    /// Path to a JSON `prefix → backend base URL` map for the same-origin
222    /// reverse proxy (R513-F10), e.g.
223    /// `{"/auth": "http://127.0.0.1:8745", "/dev": "http://127.0.0.1:8745"}`.
224    /// Matching requests are forwarded to the backend before static serving so
225    /// the SPA stays single-origin (no CORS). Camp-emitted at SPA-service spawn.
226    #[arg(long, value_name = "PATH")]
227    proxy_map: Option<PathBuf>,
228
229    /// Path to a JSON file served verbatim at `/config.json` (R513-F5/F10): the
230    /// SPA's `DashboardConfig` (apiBaseUrl / authBaseUrl / env …). Injected by
231    /// the server — NOT placed in the served `dist/` — so an Option-A pipeline
232    /// serving the same bundle never inherits a stale `env:ci` config.
233    #[arg(long, value_name = "PATH")]
234    config_json: Option<PathBuf>,
235
236    /// Logical service this dev server serves (R602-B4). Paired with
237    /// `--component`, it's surfaced at `/__mesofact/info` so the cloud
238    /// reconciler can confirm a listener on the configured port is *this*
239    /// server before adopting it — refusing a colliding foreign listener
240    /// instead of silently hijacking it.
241    #[arg(long, requires = "component")]
242    service: Option<String>,
243
244    /// Logical component id this dev server serves (R602-B4). See `--service`.
245    #[arg(long, requires = "service")]
246    component: Option<String>,
247}
248
249pub async fn run() -> std::process::ExitCode {
250    tracing_subscriber::fmt()
251        .with_env_filter(
252            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
253                tracing_subscriber::EnvFilter::new("mesofact_dev=info,tower_http=info")
254            }),
255        )
256        .init();
257
258    let command = match resolve(Cli::parse()) {
259        Ok(command) => command,
260        Err(err) => {
261            eprintln!("mes: {err:#}");
262            return std::process::ExitCode::FAILURE;
263        }
264    };
265
266    match command {
267        Command::Dev(args) => to_exit_code(dev(args).await),
268        #[cfg(feature = "build")]
269        Command::Build(args) => to_exit_code(build(args).await),
270        // Mirrors `mesofact-build check`'s exit-code contract rather than
271        // to_exit_code's: the checker's own status is the answer, and
272        // collapsing every non-zero to 1 would lose it.
273        #[cfg(feature = "build")]
274        Command::Check(args) => match mesofact::build::check::check(mesofact::build::check::CheckOptions {
275            project_root: args.project,
276            tsconfig: args.tsconfig,
277            extra_args: args.checker_args,
278        }) {
279            Ok(outcome) if outcome.code == 0 => {
280                println!("mes check ok — tsc full semantic pass, no errors");
281                std::process::ExitCode::SUCCESS
282            }
283            // The checker already streamed its diagnostics.
284            Ok(outcome) => std::process::ExitCode::from(outcome.code.clamp(1, 255) as u8),
285            Err(err) => {
286                eprintln!("mes: {err:#}");
287                std::process::ExitCode::FAILURE
288            }
289        },
290        Command::New(args) => to_exit_code(mesofact::cli::new::run(args)),
291        Command::Serve(args) => to_exit_code(mesofact::cli::serve::run(args).await),
292        // `publish` owns its exit codes (2 = missing config, etc.) — pass through.
293        #[cfg(feature = "publish")]
294        Command::Publish(args) => mesofact::cli::publish::run(args).await,
295    }
296}
297
298/// Apply the bare-form fallthrough: no subcommand means `dev`.
299///
300/// Guards the one sharp edge a default subcommand buys. `mes --port 3000 dev`
301/// parses as *"run dev against a directory named `dev`"* — clap fills the
302/// positional because the leading flag already moved the parser past the
303/// subcommand slot. Silently serving the wrong directory is a poor answer to
304/// an obvious word-order typo, so name it instead. The subcommand list comes
305/// from clap rather than a literal, so a verb added above cannot forget to
306/// appear here.
307fn resolve(cli: Cli) -> anyhow::Result<Command> {
308    use clap::CommandFactory;
309
310    if let Some(command) = cli.command {
311        return Ok(command);
312    }
313    if let Some(name) = Cli::command()
314        .get_subcommands()
315        .map(clap::Command::get_name)
316        .find(|name| std::path::Path::new(name) == cli.dev.workload)
317    {
318        anyhow::bail!(
319            "`{name}` is a subcommand, but a flag came first so it was read as a \
320             directory. Put the subcommand first: `mes {name} …` \
321             (or say `./{name}` if you did mean the directory)."
322        );
323    }
324    Ok(Command::Dev(cli.dev))
325}
326
327fn to_exit_code(result: anyhow::Result<()>) -> std::process::ExitCode {
328    match result {
329        Ok(()) => std::process::ExitCode::SUCCESS,
330        Err(err) => {
331            eprintln!("mes: {err:#}");
332            std::process::ExitCode::FAILURE
333        }
334    }
335}
336
337/// `mes build` — run the pipeline once and stop.
338#[cfg(feature = "build")]
339async fn build(args: BuildArgs) -> anyhow::Result<()> {
340    let project_root = args
341        .workload
342        .canonicalize()
343        .unwrap_or_else(|_| args.workload.clone());
344    let out_dir = args.out_dir.unwrap_or_else(|| project_root.join("dist"));
345    info!(root = %project_root.display(), out = %out_dir.display(), "mes build");
346    mesofact::build::pipeline::build(mesofact::build::pipeline::BuildOptions {
347        project_root,
348        out_dir: Some(out_dir),
349        build_id: None,
350        // Same `Auto` as the watcher: materialize `node_modules` from the
351        // lockfile only when missing, so the build needs no package manager.
352        install: mesofact::build::pipeline::InstallMode::Auto,
353    })
354    .await?;
355    Ok(())
356}
357
358/// `mes dev` — the hot-reload loop. This is the whole of what the
359/// `mesofact-dev` binary used to be.
360async fn dev(args: DevArgs) -> anyhow::Result<()> {
361    let mut server = Server::from_workload(&args.workload)?;
362
363    // Logical identity (R602-B4): stamp the server with the (service, component)
364    // the reconciler spawned it for so `/__mesofact/info` lets the adopt path
365    // verify the port holds *this* server. clap's `requires` couples the pair,
366    // so both-or-neither is guaranteed here.
367    if let (Some(service), Some(component)) = (&args.service, &args.component) {
368        info!(service, component, "mesofact-dev: identity stamped (R602-B4)");
369        server = server.with_identity(service.clone(), component.clone());
370    }
371
372    // Same-origin reverse proxy (R513-F10): forward `/auth/*` etc. to the
373    // camp-vended backend ports so the dashboard E2E (Option B) browser stays
374    // single-origin. No map → no proxy (the Option A static path is unchanged).
375    if let Some(map_path) = &args.proxy_map {
376        let map = mesofact::ProxyMap::from_json_file(map_path)?;
377        info!(
378            map = %map_path.display(),
379            routes = ?map.routes(),
380            "mesofact-dev: same-origin reverse proxy installed",
381        );
382        server = server.with_proxy(map);
383    }
384
385    // Server-injected runtime config (R513-F5/F10) served at /config.json.
386    if let Some(config_path) = &args.config_json {
387        let bytes = std::fs::read(config_path)
388            .map_err(|e| anyhow::anyhow!("reading config json {}: {e}", config_path.display()))?;
389        info!(config = %config_path.display(), "mesofact-dev: serving /config.json (R513-F10)");
390        server = server.with_config_json(bytes);
391    }
392
393    // Canonicalize so the bun child's manifest read + dynamic-import use
394    // absolute paths regardless of the cwd mesofact-dev was invoked from.
395    let workload_abs = args
396        .workload
397        .canonicalize()
398        .unwrap_or_else(|_| args.workload.clone());
399    let state_dir = workload_abs.join(".mesofact-dev");
400    #[cfg(feature = "ssr")]
401    let ssr_slot = server.ssr_slot();
402
403    // Dev-tier S3 surface (R490-F7): host a local s3s-fs bucket so a workload's
404    // @mesofact/runtime R2Adapter can resolve against it during dev instead of
405    // real Cloudflare R2. Coords go to .mesofact-dev/s3.json for discovery,
406    // into the build child's env below, AND (R444) into the in-process SSR
407    // isolate's env — started before the SSR spawn below so the first boot
408    // already has coordinates, not just post-build respawns.
409    let dev_s3 = crate::DevS3::start(state_dir.join("s3"), crate::DEV_S3_BUCKET).await?;
410    info!(endpoint = %dev_s3.endpoint, bucket = %dev_s3.bucket, "dev S3 surface ready");
411
412    // Attach an SSR child if the workload's manifest declares any mode:"ssr"
413    // routes. ssr::spawn returns Ok(None) for static/SPA-only workloads (or
414    // when no build has emitted a manifest yet); the no-bun path is preserved
415    // and the post-build hook below retries lazily. (Compiled out entirely
416    // under --no-default-features: static/SPA/proxy serving without V8.)
417    #[cfg(feature = "ssr")]
418    {
419        let ssr_opts = SsrSpawnOptions::new(
420            workload_abs.clone(),
421            workload_abs.join("dist"),
422            state_dir.clone(),
423        )
424        .with_env(dev_s3.env_vars());
425        match ssr::spawn(ssr_opts).await? {
426            Some(child) => {
427                info!(prefixes = ?child.prefixes(), "mesofact-dev ssr child attached");
428                ssr_slot.set(Some(Arc::new(child)));
429            }
430            None => {
431                info!("mesofact-dev: no SSR routes (or no manifest yet); static-only");
432            }
433        }
434    }
435
436    // Instance-addressed (deferred) route resolution (W270 §9): point an
437    // S3Store at the dev-S3 surface so a static miss on a `prerender:
438    // { deferred: true }` route resolves through the pointer store against the
439    // same local bucket the publisher flips into — the local mirror of the edge
440    // worker's R2 resolution. Region "auto" + dummy creds match R2 / the
441    // anonymous dev surface (s3s skips signature verification).
442    #[cfg(feature = "ssr")]
443    {
444        use mesofact_publisher::{ObjectStore, S3Store};
445        match S3Store::new(dev_s3.endpoint.clone(), dev_s3.bucket.clone(), "auto", "dev", "dev") {
446            Ok(store) => {
447                server = server.with_instance_store(Arc::new(store) as Arc<dyn ObjectStore>);
448                info!("mesofact-dev: instance-addressed route resolution wired to dev S3 (W270 §9)");
449            }
450            Err(e) => {
451                info!(error = %e, "mesofact-dev: could not build dev pointer store; deferred routes 404")
452            }
453        }
454    }
455
456    if let Err(e) = std::fs::write(
457        state_dir.join("s3.json"),
458        serde_json::json!({ "endpoint": dev_s3.endpoint, "bucket": dev_s3.bucket }).to_string(),
459    ) {
460        info!(error = %e, "dev S3: could not write s3.json discovery file");
461    }
462
463    if args.no_watch {
464        info!("watch mode disabled");
465        return server.serve(args.port).await;
466    }
467
468    let pointer = server.pointer();
469    // `workload_abs`, not `args.workload` (R759-T4). The watcher's paths flow
470    // into the post-build hook's `gen_dir`, and the SSR pool registers each
471    // entrypoint as a `file://` module URL — which cannot be built from a
472    // relative path. Running `mesofact-dev .` (the invocation the scaffold's
473    // README gives, and the natural one from inside a project) therefore lost
474    // EVERY `mode: "ssr"` route: the initial spawn logs "no SSR routes" because
475    // no manifest exists yet, the post-build respawn then fails, and the only
476    // trace is one WARN about a "publish hook" that has nothing to do with
477    // publishing. Routes 404 as if they were never declared.
478    let mut opts = WatchOptions::defaults_for_workload(&workload_abs);
479    opts.initial_build = !args.no_initial_build;
480    opts.build_env = dev_s3.env_vars();
481
482    let watcher_obj = crate::Watcher::new(workload_abs.clone(), pointer, opts);
483
484    // Post-build hook: each successful rebuild rotates dist into
485    // .mesofact-dev/gen-N/, so the SSR runtime must be re-spawned against
486    // the new gen dir — V8's module cache would otherwise keep serving the
487    // old route entrypoints. Under R449-F2 the in-process model swaps the
488    // whole SsrChild in the slot (no SIGKILL/respawn dance the bun era
489    // needed); the prior Arc<SsrChild> drops, which joins the isolate
490    // thread.
491    #[cfg(feature = "ssr")]
492    let watcher_obj = {
493        let slot_for_hook = ssr_slot.clone();
494        let workload_for_hook = workload_abs.clone();
495        let state_dir_for_hook = state_dir.clone();
496        let dev_s3_for_hook = dev_s3.clone();
497        let hook: watcher::PostBuildFn = Box::new(move |gen_dir: PathBuf| {
498            let slot = slot_for_hook.clone();
499            let workload = workload_for_hook.clone();
500            let state_dir = state_dir_for_hook.clone();
501            let env = dev_s3_for_hook.env_vars();
502            Box::pin(async move {
503                let opts = SsrSpawnOptions::new(workload, gen_dir, state_dir).with_env(env);
504                match ssr::spawn(opts).await? {
505                    Some(child) => {
506                        info!(
507                            prefixes = ?child.prefixes(),
508                            "mesofact-dev ssr runtime re-spawned against new gen",
509                        );
510                        slot.set(Some(Arc::new(child)));
511                    }
512                    None => {
513                        // Manifest declares no SSR routes; clear any prior child.
514                        slot.set(None);
515                    }
516                }
517                Ok(())
518            })
519        });
520        watcher_obj.with_post_build(hook)
521    };
522
523    let watcher_task = watcher::spawn(watcher_obj);
524
525    // Server owns the foreground; the spawned watcher continues until the
526    // process exits.
527    let result = server.serve(args.port).await;
528    drop(watcher_task);
529    result
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use clap::CommandFactory;
536
537    fn parse(argv: &[&str]) -> Cli {
538        Cli::try_parse_from(argv).unwrap_or_else(|e| panic!("{argv:?} failed to parse:\n{e}"))
539    }
540
541    /// Resolve exactly as `main` does, so these tests exercise the real
542    /// bare-form fallthrough rather than a restatement of it.
543    fn dispatch(argv: &[&str]) -> Command {
544        resolve(parse(argv)).unwrap_or_else(|e| panic!("{argv:?} did not resolve:\n{e}"))
545    }
546
547    #[test]
548    fn clap_definition_is_well_formed() {
549        Cli::command().debug_assert();
550    }
551
552    /// THE compatibility contract, and it outlived the binary it was written
553    /// for. Every dev-server spawn site in the parent camp (kamaji, desktop,
554    /// `serve_build.rs`, `local.sh`, …) passes this argv SHAPE — a bare
555    /// directory followed by `--port`/`--no-watch`/`--service`/`--component`,
556    /// no subcommand. MFT-R822 renamed the executable those sites exec from
557    /// `mesofact-dev` to `mes`; argv[0] is the only thing that moved, which is
558    /// why this test is spelled with the new name and asserts the same
559    /// bindings. A failure here breaks every one of those sites at once.
560    #[test]
561    fn bare_directory_spawn_shape_still_parses() {
562        let Command::Dev(args) = dispatch(&[
563            "mes",
564            "app/yah/web",
565            "--port",
566            "8080",
567            "--no-watch",
568            "--service",
569            "dashboard",
570            "--component",
571            "web",
572        ]) else {
573            panic!("bare-directory spawn shape did not resolve to `dev`");
574        };
575        assert_eq!(args.workload, PathBuf::from("app/yah/web"));
576        assert_eq!(args.port, 8080);
577        assert!(args.no_watch);
578        assert_eq!(args.service.as_deref(), Some("dashboard"));
579        assert_eq!(args.component.as_deref(), Some("web"));
580    }
581
582    #[test]
583    fn bare_mes_is_dev_in_the_current_directory() {
584        let Command::Dev(args) = dispatch(&["mes"]) else {
585            panic!("bare `mes` did not resolve to `dev`");
586        };
587        assert_eq!(args.workload, PathBuf::from("."));
588        assert_eq!(args.port, DEFAULT_PORT);
589    }
590
591    /// `mes check` has to be a real subcommand rather than a directory named
592    /// `check` handed to the bare `dev` form — the bare-form fallthrough makes
593    /// that a live confusion, and a scaffolded `package.json` names this verb.
594    #[cfg(feature = "build")]
595    #[test]
596    fn check_is_a_subcommand_and_defaults_to_here() {
597        let Command::Check(args) = dispatch(&["mes", "check"]) else {
598            panic!("`mes check` did not resolve to the check verb");
599        };
600        assert_eq!(args.project, PathBuf::from("."));
601        let Command::Check(args) = dispatch(&["mes", "check", "site"]) else {
602            panic!("`mes check site` did not resolve to the check verb");
603        };
604        assert_eq!(args.project, PathBuf::from("site"));
605    }
606
607    #[test]
608    fn bare_form_takes_dev_flags() {
609        let Command::Dev(args) = dispatch(&["mes", "site", "--port", "3000"]) else {
610            panic!("bare form with flags did not resolve to `dev`");
611        };
612        assert_eq!(args.workload, PathBuf::from("site"));
613        assert_eq!(args.port, 3000);
614    }
615
616    #[test]
617    fn explicit_dev_subcommand_binds_its_own_args() {
618        let Command::Dev(args) = dispatch(&["mes", "dev", "site", "--port", "3000"]) else {
619            panic!("`mes dev` did not resolve to `dev`");
620        };
621        assert_eq!(args.workload, PathBuf::from("site"));
622        assert_eq!(args.port, 3000);
623    }
624
625    /// A leading flag pushes clap past the subcommand slot, so `dev` lands on
626    /// the positional. Serving a directory named `dev` is not what anyone
627    /// typing this meant — `resolve` has to catch it.
628    #[test]
629    fn subcommand_after_a_flag_is_rejected_not_silently_served() {
630        let cli = parse(&["mes", "--port", "3000", "dev"]);
631        assert!(cli.command.is_none(), "clap bound `dev` as a subcommand after all");
632        assert_eq!(cli.dev.workload, PathBuf::from("dev"));
633
634        let err = resolve(cli).expect_err("`mes --port 3000 dev` should not resolve").to_string();
635        assert!(err.contains("`dev` is a subcommand"), "unhelpful message: {err}");
636        assert!(err.contains("mes dev"), "message omits the fix: {err}");
637    }
638
639    /// The guard keys off the subcommand names, so a real directory that
640    /// merely starts the same way must still go through.
641    #[test]
642    fn guard_does_not_swallow_ordinary_directories() {
643        let Command::Dev(args) = dispatch(&["mes", "developer-site"]) else {
644            panic!("`developer-site` was not treated as a workload directory");
645        };
646        assert_eq!(args.workload, PathBuf::from("developer-site"));
647
648        // And the escape hatch the error message promises actually works.
649        let Command::Dev(args) = dispatch(&["mes", "./dev"]) else {
650            panic!("`./dev` was not treated as a workload directory");
651        };
652        assert_eq!(args.workload, PathBuf::from("./dev"));
653    }
654
655    #[test]
656    #[cfg(feature = "build")]
657    fn build_defaults_to_cwd_and_takes_out_dir() {
658        let Command::Build(args) = dispatch(&["mes", "build"]) else {
659            panic!("`mes build` did not resolve to `build`");
660        };
661        assert_eq!(args.workload, PathBuf::from("."));
662        assert_eq!(args.out_dir, None);
663    }
664
665    /// The prod verbs are reachable from `mes` — that is the whole point of
666    /// the superset, and it is the half a `cargo check` cannot confirm.
667    #[test]
668    fn prod_verbs_are_reachable() {
669        assert!(matches!(dispatch(&["mes", "serve"]), Command::Serve(_)));
670        assert!(matches!(dispatch(&["mes", "new", "hello"]), Command::New(_)));
671        #[cfg(feature = "publish")]
672        assert!(matches!(dispatch(&["mes", "publish"]), Command::Publish(_)));
673    }
674
675    /// `--lib` (R832-T2) reaches `mesofact::cli::new` through this CLI too.
676    /// The flag is defined on the facade's `NewArgs`, so a `mes new --lib`
677    /// that failed to parse would mean the two CLIs had drifted apart — the
678    /// exact thing the superset exists to prevent.
679    #[test]
680    fn new_takes_the_library_tier_flag() {
681        let Command::New(args) = dispatch(&["mes", "new", "--lib", "hello"]) else {
682            panic!("expected New");
683        };
684        assert!(args.lib);
685        assert_eq!(args.path, PathBuf::from("hello"));
686
687        let Command::New(args) = dispatch(&["mes", "new", "hello"]) else {
688            panic!("expected New");
689        };
690        assert!(!args.lib, "the standalone tier stays the default");
691    }
692}