Skip to main content

xtask/
main.rs

1//! xtask — Repo automation for soma.
2//!
3//! Invoked via: `cargo xtask <command>`
4//!
5//! Commands:
6//!   dist         Build release binary into Cargo target dir
7//!   ci           Run all CI checks: fmt, clippy, nextest, taplo, audit
8//!   symlink-docs Create AGENTS.md and GEMINI.md symlinks next to every CLAUDE.md
9//!   check-env    Validate required environment variables are set
10//!   check-architecture Validate workspace dependency-layer boundaries
11//!   patterns     Check static contracts from docs/PATTERNS.md
12//!   contract-audit Run local static/spec checks for REST-client MCP servers
13//!   scaffold     Plan, generate, or verify a new project from Soma
14//!   codex-schema Rebuild/bisect/diff the vendored codex-app-server-client schema
15//!   cargo-generate Smoke-test cargo-generate output
16//!   cargo-generate-post Apply cargo-generate post-processing rewrites
17//!   generate-docs Generate volatile docs and metadata from canonical specs
18//!   doc           Generate Rust API documentation (rustdoc) for workspace crates
19//!   generate-provider-surfaces Generate provider docs and marketplace catalogs
20//!   check-docs    Validate generated docs and metadata are current
21//!   check-mcp-registry Validate server.json against the MCP registry schema
22//!   check-stale-claims Fail on stale hardcoded Soma claims
23//!   sync-web-source Copy apps/web into the bundled soma-web scaffold source
24//!   check-web-source-sync Validate bundled web source matches apps/web
25//!   update-aurora-web Refresh Aurora components, validate apps/web, then sync bundle
26//!   block-env-commits Prevent staged .env secrets from being committed
27//!   check-coupled-files Check common companion-file drift in a diff
28//!   check-file-size Check staged source files against size budgets
29//!   run-ascii-check Check or fix tracked source/config/docs ASCII hygiene
30//!   check-plugin-stdio-smoke Smoke-test installed plugin stdio binary
31//!   check-ts-client Regenerate/verify the checked-in codex-app-server-client TypeScript REST client
32//!   sync-cargo   Copy Cargo.lock into plugin data directories
33//!   check-release-versions Validate release component version policy
34//!   release-plan Print changed release components and candidate tags
35//!   sync-release-please-version Sync release files to .release-please-manifest.json
36//!   bump-version Bump a release component version
37//!   changed-paths Classify changed files into CI routing categories
38//!
39//! CUSTOMIZE: Add your own commands by adding arms to the match block below.
40//!           Keep each command as a separate `fn` for readability.
41//!
42//! Philosophy: xtask replaces ad-hoc shell scripts. It gets type-checked by the
43//! compiler, works cross-platform, and is easy to extend. Keep functions small
44//! and use `std::process::Command` to shell out to existing tools rather than
45//! reimplementing them in Rust.
46
47use anyhow::{Context, Result, bail};
48use std::process::{Command, Stdio};
49
50mod architecture;
51mod architecture_graph;
52mod cargo_generate;
53mod cargo_generate_post;
54mod ci_paths;
55mod codex_schema;
56mod doc_site;
57mod generated_surfaces;
58mod mcp_registry;
59mod patterns;
60mod provider_manifest;
61mod release_commands;
62mod release_versions;
63mod rmcp_release_monitor;
64mod scaffold;
65mod scripts;
66mod scripts_lane_a;
67mod scripts_lane_b;
68mod scripts_lane_c;
69mod scripts_lane_d;
70mod test_siblings;
71mod trace_headers_smoke;
72mod ts_client;
73mod web_source;
74mod workspace_commands;
75
76fn main() -> Result<()> {
77    // Cargo sets CARGO_MANIFEST_DIR for the workspace root when invoked as
78    // `cargo xtask`. Change into the workspace root so all commands work
79    // regardless of the cwd from which the user invoked cargo.
80    //
81    // CUSTOMIZE: This path navigation assumes xtask/ is a direct child of the
82    //           workspace root. If you restructure, adjust the `..` accordingly.
83    let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
84        .parent()
85        .expect("xtask/Cargo.toml must have a parent directory");
86    std::env::set_current_dir(workspace_root).context("Failed to change into workspace root")?;
87
88    let args: Vec<String> = std::env::args().skip(1).collect();
89    match args.first().map(String::as_str) {
90        Some("dist") => workspace_commands::dist(),
91        Some("ci") => workspace_commands::ci(),
92        Some("symlink-docs") => workspace_commands::symlink_docs(),
93        Some("check-env") => workspace_commands::check_env(),
94        Some("patterns") => patterns_cmd(&args[1..]),
95        Some("contract-audit") => workspace_commands::contract_audit(),
96        Some("scaffold") => scaffold::run(&args[1..]),
97        Some("codex-schema") => codex_schema::run(&args[1..]),
98        Some("cargo-generate") => cargo_generate(&args[1..]),
99        Some("cargo-generate-post") => cargo_generate_post::run(&args[1..]),
100        Some("generate-docs") => workspace_commands::generate_docs(),
101        Some("doc") => workspace_commands::doc(&args[1..]),
102        Some("generate-provider-surfaces") => generated_surfaces::provider_surfaces(&args[1..]),
103        Some("check-docs") => workspace_commands::check_docs(),
104        Some("check-architecture") => architecture::check(workspace_root),
105        Some("check-mcp-registry") => mcp_registry::check_cmd(workspace_root, &args[1..]),
106        Some("check-stale-claims") => workspace_commands::check_stale_claims(),
107        Some("check-cargo-generate") => scripts_lane_d::check_cargo_generate(&args[1..]),
108        Some("sync-web-source") => web_source::sync(),
109        Some("check-web-source-sync") => web_source::check(),
110        Some("update-aurora-web") => web_source::update_aurora(),
111        Some("build-web") => scripts_lane_a::build_web(),
112        Some("web-watch") => scripts_lane_a::web_watch(),
113        Some("generate-cli") => scripts_lane_a::generate_cli(),
114        Some("repair") => scripts_lane_a::repair(),
115        Some("test-mcp-auth") => scripts_lane_a::test_mcp_auth(&args[1..]),
116        Some("test-trace-headers") => trace_headers_smoke::test_trace_headers(&args[1..]),
117        Some("block-env-commits") => scripts::block_env_commits(),
118        Some("asciicheck") => scripts_lane_d::asciicheck(&args[1..]),
119        Some("check-blob-size") => scripts_lane_c::check_blob_size(&args[1..]),
120        Some("check-coupled-files") => scripts::check_coupled_files(&args[1..]),
121        Some("check-dependency-updates") => scripts_lane_c::check_dependency_updates(&args[1..]),
122        Some("check-file-size") => scripts::check_file_size(),
123        Some("check-openapi") => scripts_lane_d::check_openapi(&args[1..]),
124        Some("check-openapi-drift") => scripts_lane_d::check_openapi(&args[1..]),
125        Some("check-ts-client") => ts_client::run(&args[1..]),
126        Some("check-palette-manifest") => generated_surfaces::check_palette_manifest(&args[1..]),
127        Some("check-provider-manifest-contract") => provider_manifest::check(),
128        Some("check-plugin-hook-contract") => {
129            scripts_lane_c::check_plugin_hook_contract(&args[1..])
130        }
131        Some("run-ascii-check") => scripts::run_ascii_check(&args[1..]),
132        Some("check-plugin-stdio-smoke") => scripts::check_plugin_stdio_smoke(),
133        Some("check-runtime-current") => scripts_lane_c::check_runtime_current(&args[1..]),
134        Some("check-schema-docs") => scripts_lane_d::check_schema_docs(&args[1..]),
135        Some("check-scaffold-intent-contract") => scripts_lane_d::check_scaffold_intent_contract(),
136        Some("sync-cargo") => scripts::sync_cargo(),
137        Some("pre-release-check") => scripts_lane_b::pre_release_check(&args[1..]),
138        Some("refresh-docs") => scripts_lane_c::refresh_docs(&args[1..]),
139        Some("test-soma-features") => scripts_lane_b::test_soma_features(workspace_root),
140        Some("validate-plugin-layout") => {
141            let plugin_root = std::env::var_os("PLUGIN_ROOT").map(std::path::PathBuf::from);
142            scripts_lane_b::validate_plugin_layout(workspace_root, plugin_root.as_deref())
143        }
144        Some("check-test-siblings") => test_siblings::check(),
145        Some("check-version-sync") => {
146            scripts_lane_b::check_version_sync(workspace_root, &args[1..])
147        }
148        Some("check-release-versions") => release_commands::check(workspace_root, &args[1..]),
149        Some("release-plan") => release_commands::plan(workspace_root, &args[1..]),
150        Some("sync-release-please-version") => {
151            release_versions::sync_release_please_version(workspace_root, "soma")
152        }
153        Some("rmcp-release-monitor") => rmcp_release_monitor::run(&args[1..]),
154        Some("bump-version") => release_commands::bump(workspace_root, &args[1..]),
155        Some("bump-soma-version") => scripts_lane_b::bump_version(workspace_root, &args[1..]),
156        Some("changed-paths") => ci_paths::run(&args[1..]),
157        Some("--help") | Some("-h") | Some("help") | None => {
158            workspace_commands::print_help();
159            Ok(())
160        }
161        Some(unknown) => {
162            bail!("Unknown xtask command: {unknown:?}\nRun `cargo xtask --help` for usage.")
163        }
164    }
165}
166
167// =============================================================================
168// cargo-generate — Smoke-test generated scaffold output
169// =============================================================================
170
171fn cargo_generate(args: &[String]) -> Result<()> {
172    cargo_generate::run(args)
173}
174
175// =============================================================================
176// patterns — Check docs/PATTERNS.md contracts
177// =============================================================================
178
179fn patterns_cmd(args: &[String]) -> Result<()> {
180    let mut options = patterns::PatternOptions::default();
181    for arg in args {
182        match arg.as_str() {
183            "--strict" => options.strict = true,
184            "--json" => options.json = true,
185            "--help" | "-h" => {
186                println!("Usage: cargo xtask patterns [--strict] [--json]");
187                return Ok(());
188            }
189            unknown => bail!("Unknown patterns option: {unknown}"),
190        }
191    }
192    patterns::run(options)
193}
194
195// =============================================================================
196// Helpers
197// =============================================================================
198
199/// Run a `cargo` subcommand, forwarding stdout/stderr.
200pub(crate) fn run_cargo(args: &[&str]) -> Result<()> {
201    run_cmd("cargo", args)
202}
203
204/// Run an arbitrary command, forwarding stdout/stderr. Fails if exit code != 0.
205pub(crate) fn run_cmd(program: &str, args: &[&str]) -> Result<()> {
206    let status = Command::new(program)
207        .args(args)
208        .stdin(Stdio::null())
209        .status()
210        .with_context(|| format!("Failed to spawn `{program}`"))?;
211    if !status.success() {
212        bail!("`{program} {}` exited with status {status}", args.join(" "));
213    }
214    Ok(())
215}
216
217/// Run an arbitrary command and return stdout. Fails if exit code != 0.
218pub(crate) fn run_cmd_output(program: &str, args: &[&str]) -> Result<String> {
219    let output = Command::new(program)
220        .args(args)
221        .stdin(Stdio::null())
222        .output()
223        .with_context(|| format!("Failed to spawn `{program}`"))?;
224    if !output.status.success() {
225        bail!(
226            "`{program} {}` exited with status {}",
227            args.join(" "),
228            output.status
229        );
230    }
231    String::from_utf8(output.stdout)
232        .with_context(|| format!("`{program}` emitted non-UTF-8 stdout"))
233}
234
235/// Check whether a cargo subcommand (or standalone binary) is installed.
236///
237/// Checks for both `cargo-nextest` (cargo subcommand) and `nextest` in PATH.
238pub(crate) fn command_exists(name: &str) -> bool {
239    Command::new(name)
240        .arg("--version")
241        .stdout(Stdio::null())
242        .stderr(Stdio::null())
243        .status()
244        .map(|s| s.success())
245        .unwrap_or(false)
246}