diff --git a/Cargo.lock b/Cargo.lock index 11a049d..e5e0647 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3001,6 +3001,8 @@ name = "forge-docs-check" version = "0.1.0-alpha.1" dependencies = [ "anyhow", + "clap", + "forge_cli", "regex", "tempfile", "toml", diff --git a/Makefile b/Makefile index 3660816..cb0f9a8 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Forge developer convenience targets. # CI enforces fmt, clippy (-D warnings), tests, and the documentation drift gate. -.PHONY: docs-check docs-report docs-api docs-counts docs-crates docs-examples docs-baseline install-hooks fmt clippy test check ci +.PHONY: docs-check docs-report docs-api docs-counts docs-crates docs-examples docs-cli docs-baseline install-hooks fmt clippy test check ci # Documentation drift gate (baseline-aware): fails only on NEW drift beyond the # recorded baseline. Same logic as CI and the pre-commit hook. @@ -28,6 +28,11 @@ docs-crates: docs-examples: cargo run -q -p forge-docs-check -- --write-example-blocks +# Regenerate the reference in crates/forge.md from the +# forge_cli clap command model (subcommands, args, options). +docs-cli: + cargo run -q -p forge-docs-check -- --write-cli + # Full drift report: list ALL current drift (fails if any exists at all). # Useful for seeing the whole backlog; not the burn-down gate. docs-report: diff --git a/crates/forge-docs-check/Cargo.toml b/crates/forge-docs-check/Cargo.toml index cd5abdd..29c4d07 100644 --- a/crates/forge-docs-check/Cargo.toml +++ b/crates/forge-docs-check/Cargo.toml @@ -20,6 +20,11 @@ anyhow = "1" toml = "0.8" # Match SDK exports, doc headings, count phrases, and CLI usage strings. regex = "1" +# Introspect the real `forge` CLI surface (the clap command model exposed by +# `forge_cli::cli`) to generate the CLI reference and gate it against drift — +# the docs and the parser are the same model, so they cannot diverge. +clap = "4" +forge_cli = { path = "../forge_cli" } [dev-dependencies] # Drift-rule unit tests build throwaway crate/doc trees on disk. diff --git a/crates/forge-docs-check/src/checks/cli_commands.rs b/crates/forge-docs-check/src/checks/cli_commands.rs index e96d155..94529d4 100644 --- a/crates/forge-docs-check/src/checks/cli_commands.rs +++ b/crates/forge-docs-check/src/checks/cli_commands.rs @@ -1,44 +1,27 @@ //! Rule `cli-command`: every `forge` subcommand is documented in `crates/forge.md`. //! -//! Caught the missing `forge smelt` entry. The command list is sourced from the -//! CLI's own clap command model in `forge_cli/src/main.rs` (a single in-repo -//! source of truth), so adding a subcommand there surfaces the documentation -//! requirement automatically. +//! Caught the missing `forge smelt` entry. The command list is sourced directly +//! from the clap command model ([`forge_cli::cli`]) — the same model the binary +//! parses with — so adding a subcommand surfaces the documentation requirement +//! automatically, with no source-text parsing or name-mangling to keep in sync. //! -//! Before the Phase 5 clap migration this was sourced from a hand-written -//! `forge ` usage banner; clap derive replaced that banner with the -//! `#[derive(Subcommand)] enum Commands { .. }` block, so this rule now parses -//! the enum variants and applies clap's default PascalCase -> kebab-case rename -//! to recover the exact subcommand names the CLI exposes. +//! This is the lightweight *presence* guard (each subcommand is mentioned +//! somewhere on the page, prose or generated). The richer +//! [`crate::clidoc`] rule (`cli-doc`) regenerates the full `` +//! reference — synopsis, args, options — from the same model. use crate::checks::read_optional; use crate::discovery::Workspace; use crate::Finding; pub fn check(ws: &Workspace) -> Vec { - let main_rs = ws.root.join("crates/forge_cli/src/main.rs"); - let main_src = match read_optional(&main_rs) { - Some(s) => s, - None => { - return vec![Finding::new( - "cli-command", - format!( - "cannot read {} to determine the subcommand list", - main_rs.display() - ), - )] - } - }; - - let commands = match parse_subcommands(&main_src) { - Some(c) if !c.is_empty() => c, - _ => { - return vec![Finding::new( - "cli-command", - "could not find the `#[derive(Subcommand)] enum Commands` block in forge_cli/src/main.rs to source the command list", - )] - } - }; + let commands = subcommand_names(); + if commands.is_empty() { + return vec![Finding::new( + "cli-command", + "forge_cli::cli() exposes no subcommands — the CLI model could not be introspected", + )]; + } let forge_md = ws.docs_dir().join("crates/forge.md"); let doc = match read_optional(&forge_md) { @@ -53,7 +36,8 @@ pub fn check(ws: &Workspace) -> Vec { let mut findings = Vec::new(); for cmd in commands { - // Documented as a `forge ` reference (heading or code/example). + // Documented as a `forge ` reference (heading, prose, or the + // generated CLI reference block). if !doc.contains(&format!("forge {cmd}")) { findings.push(Finding::new( "cli-command", @@ -66,110 +50,12 @@ pub fn check(ws: &Workspace) -> Vec { findings } -/// Extract the top-level variant names from the clap `enum Commands { .. }` -/// block and convert each to the kebab-case name clap exposes on the CLI. -fn parse_subcommands(src: &str) -> Option> { - let body = enum_body(src, "Commands")?; - - let mut variants = Vec::new(); - let mut depth = 0i32; - for line in body.lines() { - let trimmed = line.trim(); - // A variant name is a PascalCase identifier sitting at the enum's top - // level (depth 0). Struct-variant fields live at depth >= 1, and - // attributes/doc-comments (`#[..]`, `///`) don't start with an - // uppercase identifier, so both are skipped naturally. - if depth == 0 { - if let Some(name) = variant_name(trimmed) { - variants.push(to_kebab(&name)); - } - } - depth += line.matches('{').count() as i32; - depth -= line.matches('}').count() as i32; - if depth < 0 { - depth = 0; - } - } - - if variants.is_empty() { - None - } else { - Some(variants) - } -} - -/// Return the brace-delimited body of `enum `, matching braces so nested -/// struct-variant `{ .. }` blocks don't terminate the scan early. -fn enum_body<'a>(src: &'a str, name: &str) -> Option<&'a str> { - let needle = format!("enum {name}"); - let start = src.find(&needle)?; - let open = src[start..].find('{')? + start; - - let bytes = src.as_bytes(); - let mut depth = 0i32; - let mut body_start = open + 1; - let mut i = open; - while i < bytes.len() { - match bytes[i] { - b'{' => { - depth += 1; - if depth == 1 { - body_start = i + 1; - } - } - b'}' => { - depth -= 1; - if depth == 0 { - return Some(&src[body_start..i]); - } - } - _ => {} - } - i += 1; - } - None -} - -/// If `line` begins a variant declaration, return its identifier. Accepts the -/// PascalCase name when it is followed by variant syntax — `{` (struct -/// variant), `(` (tuple variant), `,`, or end of line (unit variant). -fn variant_name(line: &str) -> Option { - let first = line.chars().next()?; - if !first.is_ascii_uppercase() { - return None; - } - let end = line - .char_indices() - .find(|(_, c)| !(c.is_ascii_alphanumeric() || *c == '_')) - .map(|(idx, _)| idx) - .unwrap_or(line.len()); - let ident = &line[..end]; - let rest = line[end..].trim_start(); - if rest.is_empty() || rest.starts_with('{') || rest.starts_with('(') || rest.starts_with(',') { - Some(ident.to_string()) - } else { - None - } -} - -/// Replicate clap's default subcommand rename (PascalCase -> kebab-case). -/// -/// This is a simple per-uppercase-boundary split, which matches clap's -/// `heck`-based kebab-casing for single-word variants (every current forge -/// subcommand). It diverges only on multi-word acronyms — `HTTPServer` here -/// becomes `h-t-t-p-server` where clap/heck emits `http-server`. No forge -/// subcommand triggers that, so the simpler form is kept intentionally. -fn to_kebab(name: &str) -> String { - let mut out = String::new(); - for (i, c) in name.chars().enumerate() { - if c.is_ascii_uppercase() { - if i != 0 { - out.push('-'); - } - out.push(c.to_ascii_lowercase()); - } else { - out.push(c); - } - } - out +/// The user-invocable top-level subcommand names from the clap model, skipping +/// clap's auto-generated `help` command and any hidden subcommands. +fn subcommand_names() -> Vec { + forge_cli::cli() + .get_subcommands() + .filter(|s| s.get_name() != "help" && !s.is_hide_set()) + .map(|s| s.get_name().to_string()) + .collect() } diff --git a/crates/forge-docs-check/src/clidoc.rs b/crates/forge-docs-check/src/clidoc.rs new file mode 100644 index 0000000..71c767d --- /dev/null +++ b/crates/forge-docs-check/src/clidoc.rs @@ -0,0 +1,277 @@ +//! Marker-hybrid CLI reference (``). +//! +//! The `forge` CLI surface is defined once as a clap command model in +//! `forge_cli` (the `Cli`/`Commands` derive). This module introspects that +//! model via [`forge_cli::cli`] and renders an authoritative command reference +//! — synopsis, arguments, options, nested subcommands — into the +//! `` region of `site/src/content/docs/crates/forge.md`. +//! +//! The `cli-doc` rule fails CI when that region is stale; `make docs-cli` +//! regenerates it. Because both the published reference and the binary's +//! argument parser are derived from the *same* clap model, a new subcommand, +//! argument, or flag updates the docs automatically and can never silently +//! drift from what the CLI actually accepts. Authored prose elsewhere on the +//! page (the narrative `## Commands` section) is outside the markers and is +//! never touched. + +use crate::checks::read_optional; +use crate::discovery::Workspace; +use crate::{markers, Finding}; +use clap::{Arg, ArgAction, Command}; +use std::path::PathBuf; + +pub const BLOCK_OPEN: &str = ""; +pub const BLOCK_CLOSE: &str = ""; + +/// The docs page that hosts the generated CLI reference. +fn page_path(ws: &Workspace) -> PathBuf { + ws.docs_dir().join("crates").join("forge.md") +} + +/// Render the body between the markers from the live clap command model. +pub fn render_block_body() -> String { + let cli = forge_cli::cli(); + let mut out = String::new(); + out.push_str( + "\n", + ); + for sub in visible_subcommands(&cli) { + render_command(&mut out, sub, "forge"); + } + out.trim_end().to_string() +} + +/// Rule `cli-doc`: the generated CLI reference in `forge.md` matches the clap +/// model. Only fires if the page has opted in with an opening marker. +pub fn check(ws: &Workspace) -> Vec { + let page = match read_optional(&page_path(ws)) { + Some(p) => p, + None => return Vec::new(), + }; + if !page.contains(BLOCK_OPEN) { + return Vec::new(); // not opted in + } + let expected = render_block_body(); + match markers::find_region(&page, BLOCK_OPEN, BLOCK_CLOSE) { + Some((_, _, body)) if body == expected => Vec::new(), + Some(_) => vec![Finding::new( + "cli-doc", + "the CLI reference in crates/forge.md is stale; run `make docs-cli` to regenerate it from the clap model", + )], + None => vec![Finding::new( + "cli-doc", + "crates/forge.md has an opening with no closing marker", + )], + } +} + +/// Regenerate the `` region in place. Returns the rewritten +/// path (empty if the page is absent, not opted in, or already current). +pub fn write_all(ws: &Workspace) -> std::io::Result> { + let path = page_path(ws); + let page = match read_optional(&path) { + Some(p) => p, + None => return Ok(Vec::new()), + }; + if !page.contains(BLOCK_OPEN) { + return Ok(Vec::new()); + } + let expected = render_block_body(); + if let Some((_, _, body)) = markers::find_region(&page, BLOCK_OPEN, BLOCK_CLOSE) { + if body == expected { + return Ok(Vec::new()); + } + if let Some(updated) = markers::replace_region(&page, BLOCK_OPEN, BLOCK_CLOSE, &expected) { + std::fs::write(&path, updated)?; + return Ok(vec![path]); + } + } + Ok(Vec::new()) +} + +/// Render one subcommand (and any nested subcommands) into the reference. +fn render_command(out: &mut String, cmd: &Command, parent: &str) { + let full = format!("{parent} {}", cmd.get_name()); + + let about = cmd + .get_about() + .map(|s| s.to_string()) + .unwrap_or_default() + .trim() + .to_string(); + out.push('\n'); + if about.is_empty() { + out.push_str(&format!("**`{full}`**\n\n")); + } else { + out.push_str(&format!("**`{full}`** — {about}\n\n")); + } + + let positionals: Vec<&Arg> = cmd.get_positionals().collect(); + let options: Vec<&Arg> = cmd + .get_arguments() + .filter(|a| !a.is_positional() && a.get_id() != "help" && a.get_id() != "version") + .collect(); + let subs = visible_subcommands(cmd); + + // Synopsis line: `forge [OPTIONS] ...`. + let mut synopsis = full.clone(); + if !options.is_empty() { + synopsis.push_str(" [OPTIONS]"); + } + if !subs.is_empty() { + synopsis.push_str(" "); + } + for p in &positionals { + let token = if p.is_required_set() { + format!("<{}>", value_name(p)) + } else { + format!("[{}]", value_name(p)) + }; + synopsis.push(' '); + synopsis.push_str(&token); + if is_multiple(p) { + synopsis.push_str("..."); + } + } + out.push_str("```text\n"); + out.push_str(&synopsis); + out.push_str("\n```\n"); + + if !positionals.is_empty() { + out.push_str("\nArguments:\n"); + for p in &positionals { + out.push_str(&format!( + "- `<{}>`{}\n", + value_name(p), + help_suffix(p.get_help().map(|s| s.to_string())) + )); + } + } + + if !options.is_empty() { + out.push_str("\nOptions:\n"); + for o in &options { + out.push_str(&format!( + "- `{}`{}\n", + option_flag(o), + help_suffix(o.get_help().map(|s| s.to_string())) + )); + } + } + + for s in subs { + render_command(out, s, &full); + } +} + +/// Subcommands a user can invoke: skips the auto-generated `help` command and +/// any hidden subcommands, sorted by name for deterministic output. +fn visible_subcommands(cmd: &Command) -> Vec<&Command> { + let mut subs: Vec<&Command> = cmd + .get_subcommands() + .filter(|s| s.get_name() != "help" && !s.is_hide_set()) + .collect(); + subs.sort_by_key(|s| s.get_name().to_string()); + subs +} + +/// The display value name for an argument (``), matching clap's +/// default of upper-casing the arg id when no explicit value name is set. +fn value_name(arg: &Arg) -> String { + if let Some(names) = arg.get_value_names() { + if !names.is_empty() { + return names + .iter() + .map(|n| n.to_string()) + .collect::>() + .join(" "); + } + } + arg.get_id().as_str().to_uppercase() +} + +/// The flag spelling for an option: `--out, -o ` or `--embed`. +fn option_flag(arg: &Arg) -> String { + let mut parts = Vec::new(); + if let Some(long) = arg.get_long() { + parts.push(format!("--{long}")); + } + if let Some(short) = arg.get_short() { + parts.push(format!("-{short}")); + } + let mut spelled = parts.join(", "); + if takes_value(arg) { + spelled.push_str(&format!(" <{}>", value_name(arg))); + } + spelled +} + +/// Whether the argument consumes a value (vs. a boolean/counting flag). +fn takes_value(arg: &Arg) -> bool { + matches!(arg.get_action(), ArgAction::Set | ArgAction::Append) +} + +/// Whether the argument accepts multiple values (e.g. a trailing var-arg). +fn is_multiple(arg: &Arg) -> bool { + matches!(arg.get_action(), ArgAction::Append) +} + +/// Format an optional help string as a ` — help` suffix (empty when absent). +fn help_suffix(help: Option) -> String { + match help { + Some(h) if !h.trim().is_empty() => format!(" — {}", h.trim()), + _ => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reference_lists_every_subcommand() { + let body = render_block_body(); + for cmd in ["dev", "build", "bundle", "smelt", "sign", "icon", "docs"] { + assert!( + body.contains(&format!("**`forge {cmd}`**")), + "reference must document `forge {cmd}`:\n{body}" + ); + } + // The auto-generated `help` subcommand must not leak into the docs. + assert!( + !body.contains("**`forge help`**"), + "help must be filtered out" + ); + } + + #[test] + fn reference_documents_flags_and_positionals() { + let body = render_block_body(); + // smelt: `--out, -o `, `--embed`, and the positional. + assert!( + body.contains("--out, -o "), + "smelt --out with alias:\n{body}" + ); + assert!(body.contains("`--embed`"), "smelt --embed flag:\n{body}"); + assert!(body.contains(""), "app_dir positional:\n{body}"); + // sign: `--identity, -i ` and the positional. + assert!( + body.contains("--identity, -i "), + "sign identity:\n{body}" + ); + assert!(body.contains(""), "sign artifact:\n{body}"); + } + + #[test] + fn reference_renders_nested_icon_subcommands() { + let body = render_block_body(); + assert!( + body.contains("**`forge icon create`**"), + "icon create:\n{body}" + ); + assert!( + body.contains("**`forge icon validate`**"), + "icon validate:\n{body}" + ); + } +} diff --git a/crates/forge-docs-check/src/lib.rs b/crates/forge-docs-check/src/lib.rs index 6089663..2c566c8 100644 --- a/crates/forge-docs-check/src/lib.rs +++ b/crates/forge-docs-check/src/lib.rs @@ -15,6 +15,7 @@ pub mod apiblock; pub mod checks; +pub mod clidoc; pub mod cratepage; pub mod discovery; pub mod exampleblock; @@ -95,5 +96,6 @@ pub fn run_all_checks(ws: &Workspace) -> Report { report.extend(apiblock::check(ws)); report.extend(exampleblock::check(ws)); report.extend(extindex::check(ws)); + report.extend(clidoc::check(ws)); report } diff --git a/crates/forge-docs-check/src/main.rs b/crates/forge-docs-check/src/main.rs index 39d8b0b..39c835d 100644 --- a/crates/forge-docs-check/src/main.rs +++ b/crates/forge-docs-check/src/main.rs @@ -52,6 +52,14 @@ fn main() -> Result { return Ok(ExitCode::SUCCESS); } + // `--write-cli`: regenerate the `` CLI reference in + // crates/forge.md from the forge_cli clap model, then exit. + if std::env::args().any(|a| a == "--write-cli") { + let written = forge_docs_check::clidoc::write_all(&ws)?; + report_written("CLI reference", &written, &ws.root); + return Ok(ExitCode::SUCCESS); + } + let report = run_all_checks(&ws); println!("{}", report.render()); diff --git a/crates/forge-docs-check/tests/rules.rs b/crates/forge-docs-check/tests/rules.rs index a0124ec..d34f19e 100644 --- a/crates/forge-docs-check/tests/rules.rs +++ b/crates/forge-docs-check/tests/rules.rs @@ -248,37 +248,12 @@ fn counts_marker_is_authoritative() { #[test] fn cli_command_flags_missing_subcommand() { - let mut fx = Fixture::new(); - fx.add_crate("forge_cli"); - // The clap command model is the source of truth (post-P5.2 migration). - // Includes doc comments, attributes, and a struct variant with nested - // braces to exercise the brace-matching parser. - fx.write( - "crates/forge_cli/src/main.rs", - r#"#[derive(Subcommand)] -enum Commands { - /// Run an app - Dev { - app_dir: PathBuf, - }, - /// Build assets - Build { - app_dir: PathBuf, - }, - /// AOT compile - Smelt { - app_dir: PathBuf, - #[arg(long, short)] - out: Option, - }, -} -"#, - ); - // forge.md documents dev and build but not smelt. - fx.write( - "site/src/content/docs/crates/forge.md", - "### `forge dev`\n### `forge build`\n", - ); + // The subcommand list is introspected from the real `forge_cli::cli()` + // clap model (post-P5.2), so the fixture only controls the docs page. + // `smelt` is a real subcommand; documenting `dev` but not `smelt` must + // flag the gap. + let fx = Fixture::new(); + fx.write("site/src/content/docs/crates/forge.md", "### `forge dev`\n"); let ws = fx.discover(); let findings = checks::cli_commands::check(&ws); assert!( @@ -292,6 +267,49 @@ enum Commands { ); } +#[test] +fn cli_doc_flags_stale_region_and_passes_when_current() { + use forge_docs_check::clidoc::{self, BLOCK_CLOSE, BLOCK_OPEN}; + + // A `` region whose body does not match the clap model + // must be flagged stale. + let fx = Fixture::new(); + fx.write( + "site/src/content/docs/crates/forge.md", + &format!("# forge\n\n{BLOCK_OPEN}\nstale, hand-edited content\n{BLOCK_CLOSE}\n"), + ); + let ws = fx.discover(); + assert!( + any_contains(&clidoc::check(&ws), "stale"), + "a region that doesn't match the clap model must be flagged: {:?}", + messages(&clidoc::check(&ws)) + ); + + // The canonical generated body (open\n{body}\n close) must pass. + let body = clidoc::render_block_body(); + fx.write( + "site/src/content/docs/crates/forge.md", + &format!("# forge\n\n{BLOCK_OPEN}\n{body}\n{BLOCK_CLOSE}\n"), + ); + let ws = fx.discover(); + assert!( + clidoc::check(&ws).is_empty(), + "the freshly generated region must be in sync: {:?}", + messages(&clidoc::check(&ws)) + ); + + // A page that never opts in (no marker) is silently skipped. + fx.write( + "site/src/content/docs/crates/forge.md", + "# forge\n\nno markers here\n", + ); + let ws = fx.discover(); + assert!( + clidoc::check(&ws).is_empty(), + "a page without the marker must not be flagged" + ); +} + /// Sanity: discovery distinguishes extension crates and maps page stems. #[test] fn discovery_classifies_crates() { diff --git a/crates/forge_cli/src/lib.rs b/crates/forge_cli/src/lib.rs new file mode 100644 index 0000000..bd8df92 --- /dev/null +++ b/crates/forge_cli/src/lib.rs @@ -0,0 +1,124 @@ +//! Forge CLI library surface. +//! +//! This crate is primarily the `forge` binary (`src/main.rs`), but it also +//! exposes its **clap command model** as a library so external tooling can +//! introspect the exact CLI surface instead of re-parsing source. +//! +//! In particular, the documentation drift gate (`forge-docs-check`) calls +//! [`cli()`] to enumerate subcommands, arguments, and options and to generate +//! the CLI reference in `site/src/content/docs/crates/forge.md`. The binary +//! consumes the same [`Cli`]/[`Commands`] model for argument dispatch, so the +//! docs and the runtime parser can never diverge — they are the same model. + +use clap::{CommandFactory, Parser, Subcommand}; +use std::path::PathBuf; + +/// Extra help shown under the generated command list (kept from the original +/// hand-written usage banner so `forge --help` still points at the examples). +pub const AFTER_HELP: &str = "\ +Getting Started: + Copy an example from the examples/ folder to start a new app: + - examples/example-deno-app Minimal TypeScript app + - examples/react-app React with TypeScript + - examples/nextjs-app Next.js-style patterns + - examples/svelte-app Svelte with TypeScript + - examples/todo-app Todo app with persistence + - examples/text-editor File operations example + +Bundle output formats: + Windows: .msix package + macOS: .app bundle + .dmg disk image + Linux: .AppImage or .tar.gz"; + +/// Forge — build cross-platform desktop apps with TypeScript and Deno. +#[derive(Parser)] +#[command( + name = "forge", + version, + about = "Forge — build cross-platform desktop apps with TypeScript and Deno", + after_help = AFTER_HELP, + subcommand_required = true, + arg_required_else_help = true +)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +/// Top-level `forge` subcommands. +#[derive(Subcommand)] +pub enum Commands { + /// Run an app in development mode (hot reload, full debugging) + Dev { + /// App directory (contains manifest.app.toml) + app_dir: PathBuf, + }, + /// Build an app's web assets for production + Build { + /// App directory + app_dir: PathBuf, + }, + /// Package an app into a platform distributable (.app/.dmg, .msix, AppImage) + Bundle { + /// App directory + app_dir: PathBuf, + }, + /// Ahead-of-time compile an app's TypeScript to JavaScript + Smelt { + /// App directory + app_dir: PathBuf, + /// Output directory for the compiled JavaScript tree + #[arg(long, short)] + out: Option, + /// Also write the standalone-binary bootstrap shim + #[arg(long)] + embed: bool, + }, + /// Code-sign a bundled artifact for distribution + Sign { + /// Signing identity (e.g. "Developer ID Application: Name (TEAM)") + #[arg(long, short)] + identity: Option, + /// The bundled artifact to sign + artifact: PathBuf, + }, + /// Manage app icons + Icon { + #[command(subcommand)] + command: IconCommand, + }, + /// Generate API documentation from extension TypeScript/Rust source + #[command(disable_help_flag = true)] + Docs { + /// Options/target forwarded to the docs generator: --all-extensions, + /// --extension , --output , --format + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, +} + +/// `forge icon` subcommands. +#[derive(Subcommand)] +pub enum IconCommand { + /// Create the default Forge-branded icon at + Create { + /// Output path for the icon (PNG) + path: PathBuf, + }, + /// Validate an app's icon meets platform requirements + Validate { + /// App directory (defaults to the current directory) + #[arg(default_value = ".")] + app_dir: PathBuf, + }, +} + +/// The fully-built clap command tree. +/// +/// This is the single source of truth for the `forge` CLI surface. The +/// documentation generator and drift gate introspect the returned +/// [`clap::Command`] (subcommands, arguments, options, help text) so the +/// published CLI reference always matches the parser the binary runs. +pub fn cli() -> clap::Command { + Cli::command() +} diff --git a/crates/forge_cli/src/main.rs b/crates/forge_cli/src/main.rs index 4004135..52f4032 100644 --- a/crates/forge_cli/src/main.rs +++ b/crates/forge_cli/src/main.rs @@ -95,7 +95,8 @@ //! - Main module - Command dispatch and build orchestration use anyhow::{anyhow, bail, Context, Result}; -use clap::{Parser, Subcommand}; +use clap::Parser; +use forge_cli::{Cli, Commands, IconCommand}; use std::{ env, fs, path::{Path, PathBuf}, @@ -105,103 +106,9 @@ use std::{ mod bundler; mod docs; -/// Extra help shown under the generated command list (kept from the original -/// hand-written usage banner so `forge --help` still points at the examples). -const AFTER_HELP: &str = "\ -Getting Started: - Copy an example from the examples/ folder to start a new app: - - examples/example-deno-app Minimal TypeScript app - - examples/react-app React with TypeScript - - examples/nextjs-app Next.js-style patterns - - examples/svelte-app Svelte with TypeScript - - examples/todo-app Todo app with persistence - - examples/text-editor File operations example - -Bundle output formats: - Windows: .msix package - macOS: .app bundle + .dmg disk image - Linux: .AppImage or .tar.gz"; - -/// Forge — build cross-platform desktop apps with TypeScript and Deno. -#[derive(Parser)] -#[command( - name = "forge", - version, - about = "Forge — build cross-platform desktop apps with TypeScript and Deno", - after_help = AFTER_HELP, - subcommand_required = true, - arg_required_else_help = true -)] -struct Cli { - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand)] -enum Commands { - /// Run an app in development mode (hot reload, full debugging) - Dev { - /// App directory (contains manifest.app.toml) - app_dir: PathBuf, - }, - /// Build an app's web assets for production - Build { - /// App directory - app_dir: PathBuf, - }, - /// Package an app into a platform distributable (.app/.dmg, .msix, AppImage) - Bundle { - /// App directory - app_dir: PathBuf, - }, - /// Ahead-of-time compile an app's TypeScript to JavaScript - Smelt { - /// App directory - app_dir: PathBuf, - /// Output directory for the compiled JavaScript tree - #[arg(long, short)] - out: Option, - /// Also write the standalone-binary bootstrap shim - #[arg(long)] - embed: bool, - }, - /// Code-sign a bundled artifact for distribution - Sign { - /// Signing identity (e.g. "Developer ID Application: Name (TEAM)") - #[arg(long, short)] - identity: Option, - /// The bundled artifact to sign - artifact: PathBuf, - }, - /// Manage app icons - Icon { - #[command(subcommand)] - command: IconCommand, - }, - /// Generate API documentation from extension TypeScript/Rust source - #[command(disable_help_flag = true)] - Docs { - /// Options/target forwarded to the docs generator: --all-extensions, - /// --extension , --output , --format - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] - args: Vec, - }, -} - -#[derive(Subcommand)] -enum IconCommand { - /// Create the default Forge-branded icon at - Create { - /// Output path for the icon (PNG) - path: PathBuf, - }, - /// Validate an app's icon meets platform requirements - Validate { - /// App directory (defaults to the current directory) - #[arg(default_value = ".")] - app_dir: PathBuf, - }, -} +// The clap command model (`Cli`, `Commands`, `IconCommand`, `AFTER_HELP`) lives +// in `lib.rs` so `forge-docs-check` can introspect the exact CLI surface to +// generate the docs. The binary imports it above and dispatches in `main()`. /// Find the forge-runtime binary in standard locations /// diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 2a238d4..6d18b4c 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -108,8 +108,10 @@ integration test (`cargo test --workspace`). | `api-drift` | a `sdk/runtime.*.ts` export is undocumented, or a documented `### method(` no longer exists | | `api-block` | a `` signature block is stale | | `count` | a `` marker (or a count phrase) disagrees with the real workspace counts | -| `cli-command` | a `forge` subcommand is missing from `crates/forge.md` | +| `cli-command` | a `forge` subcommand (from the clap model) is missing from `crates/forge.md` | +| `cli-doc` | the `` reference in `crates/forge.md` is stale vs. the `forge_cli` clap model | | `example-block` | an example page's `` block doesn't match the app's `runtime:*` imports | +| `ext-index` | the extension index/table in `architecture.md` is missing a real `ext_*` extension | ### Fixing a red gate @@ -120,6 +122,7 @@ make docs-api # refresh signature blocks from the SDK make docs-counts # refresh numbers make docs-crates # generate a page for any crate that lacks one make docs-examples # refresh module lists +make docs-cli # refresh the reference from the clap model make docs-check # the baseline-aware gate (what CI runs) make docs-report # list ALL current drift (not just new) ``` diff --git a/site/src/content/docs/crates/forge.md b/site/src/content/docs/crates/forge.md index fbaa734..7f613e9 100644 --- a/site/src/content/docs/crates/forge.md +++ b/site/src/content/docs/crates/forge.md @@ -136,6 +136,99 @@ Options: The extension list is discovered from `crates/ext_*` (no hardcoded list), so new extensions are documented automatically. +## CLI reference + +The reference below is generated from the `forge_cli` clap command model — the same definition the binary parses — so it always matches the arguments and options `forge` actually accepts. Run `make docs-cli` to refresh it. + + + + +**`forge build`** — Build an app's web assets for production + +```text +forge build +``` + +Arguments: +- `` — App directory + +**`forge bundle`** — Package an app into a platform distributable (.app/.dmg, .msix, AppImage) + +```text +forge bundle +``` + +Arguments: +- `` — App directory + +**`forge dev`** — Run an app in development mode (hot reload, full debugging) + +```text +forge dev +``` + +Arguments: +- `` — App directory (contains manifest.app.toml) + +**`forge docs`** — Generate API documentation from extension TypeScript/Rust source + +```text +forge docs [ARGS]... +``` + +Arguments: +- `` — Options/target forwarded to the docs generator: --all-extensions, --extension , --output , --format + +**`forge icon`** — Manage app icons + +```text +forge icon +``` + +**`forge icon create`** — Create the default Forge-branded icon at + +```text +forge icon create +``` + +Arguments: +- `` — Output path for the icon (PNG) + +**`forge icon validate`** — Validate an app's icon meets platform requirements + +```text +forge icon validate [APP_DIR] +``` + +Arguments: +- `` — App directory (defaults to the current directory) + +**`forge sign`** — Code-sign a bundled artifact for distribution + +```text +forge sign [OPTIONS] +``` + +Arguments: +- `` — The bundled artifact to sign + +Options: +- `--identity, -i ` — Signing identity (e.g. "Developer ID Application: Name (TEAM)") + +**`forge smelt`** — Ahead-of-time compile an app's TypeScript to JavaScript + +```text +forge smelt [OPTIONS] +``` + +Arguments: +- `` — App directory + +Options: +- `--out, -o ` — Output directory for the compiled JavaScript tree +- `--embed` — Also write the standalone-binary bootstrap shim + + ## Key Types ### Framework @@ -157,6 +250,7 @@ enum Framework { crates/forge_cli/ ├── src/ │ ├── main.rs # CLI entry point and commands +│ ├── lib.rs # clap command model (introspected to generate CLI docs) │ └── bundler/ # Platform bundling logic │ ├── mod.rs # Bundler module │ ├── codesign.rs # Code signing