From eff8f6a6a6ebc04120ebec967d8f036654d449ce Mon Sep 17 00:00:00 2001 From: dave Date: Sat, 28 Mar 2026 13:47:02 +0000 Subject: [PATCH] feat(399): add --port CLI flag with project.toml persistence Manual merge of story 399 feature branch, adapted for the current CLI parser (which includes the init subcommand from 429). - storkit --port 3000 sets the listening port - storkit --port=3000 also works - Port resolution: CLI flag > STORKIT_PORT env > default 3001 - Supports combining with init: storkit init --port 3000 /path - Replaces CliDirective enum with CliArgs struct that handles both --port and init in a single pass Co-Authored-By: Claude Opus 4.6 (1M context) --- server/src/main.rs | 283 +++++++++++++++++++++++++-------------------- 1 file changed, 157 insertions(+), 126 deletions(-) diff --git a/server/src/main.rs b/server/src/main.rs index 9cd94305..4bd05f27 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -32,40 +32,94 @@ use std::path::PathBuf; use std::sync::Arc; use tokio::sync::broadcast; -/// What the first CLI argument means. +/// Parsed CLI arguments. #[derive(Debug, PartialEq)] -enum CliDirective { - /// `--help` / `-h` - Help, - /// `--version` / `-V` - Version, - /// `init [PATH]` — scaffold and start the setup wizard. - Init, - /// An unrecognised flag (starts with `-`). - UnknownFlag(String), - /// A positional path argument. - Path, - /// No arguments at all. - None, +struct CliArgs { + /// Value from `--port ` flag, if supplied. + port: Option, + /// Positional project path argument, if supplied. + path: Option, + /// Whether the `init` subcommand was given. + init: bool, } -/// Inspect the raw CLI arguments and return the directive they imply. -fn classify_cli_args(args: &[String]) -> CliDirective { - match args.first().map(String::as_str) { - None => CliDirective::None, - Some("--help" | "-h") => CliDirective::Help, - Some("--version" | "-V") => CliDirective::Version, - Some("init") => CliDirective::Init, - Some(a) if a.starts_with('-') => CliDirective::UnknownFlag(a.to_string()), - Some(_) => CliDirective::Path, +/// Parse CLI arguments into `CliArgs`, or exit early for `--help` / `--version`. +fn parse_cli_args(args: &[String]) -> Result { + let mut port: Option = None; + let mut path: Option = None; + let mut init = false; + let mut i = 0; + + while i < args.len() { + match args[i].as_str() { + "--help" | "-h" => { + print_help(); + std::process::exit(0); + } + "--version" | "-V" => { + println!("storkit {}", env!("CARGO_PKG_VERSION")); + std::process::exit(0); + } + "--port" => { + i += 1; + if i >= args.len() { + return Err("--port requires a value".to_string()); + } + match args[i].parse::() { + Ok(p) => port = Some(p), + Err(_) => return Err(format!("invalid port value: '{}'", args[i])), + } + } + a if a.starts_with("--port=") => { + let val = &a["--port=".len()..]; + match val.parse::() { + Ok(p) => port = Some(p), + Err(_) => return Err(format!("invalid port value: '{val}'")), + } + } + "init" => { + init = true; + } + a if a.starts_with('-') => { + return Err(format!("unknown option: {a}")); + } + a => { + if path.is_some() { + return Err(format!("unexpected argument: {a}")); + } + path = Some(a.to_string()); + } + } + i += 1; } + + Ok(CliArgs { port, path, init }) } -/// Resolve the optional positional path argument (everything after the binary -/// name) into an absolute `PathBuf`. Returns `None` when no argument was -/// supplied so that the caller can fall back to the auto-detect behaviour. -fn parse_project_path_arg(args: &[String], cwd: &std::path::Path) -> Option { - args.first().map(|s| io::fs::resolve_cli_path(cwd, s)) +fn print_help() { + println!("storkit [OPTIONS] [PATH]"); + println!("storkit init [OPTIONS] [PATH]"); + println!(); + println!("Serve a storkit project."); + println!(); + println!("COMMANDS:"); + println!(" init Scaffold a new .storkit/ project and start the interactive setup wizard."); + println!(); + println!("ARGS:"); + println!( + " PATH Path to an existing project directory. \ + If omitted, storkit searches parent directories for a .storkit/ root." + ); + println!(); + println!("OPTIONS:"); + println!(" -h, --help Print this help and exit"); + println!(" -V, --version Print the version and exit"); + println!(" --port Port to listen on (default: 3001). Persisted to project.toml."); +} + +/// Resolve the optional positional path argument into an absolute `PathBuf`. +fn resolve_path_arg(path_str: Option<&str>, cwd: &std::path::Path) -> Option { + path_str.map(|s| io::fs::resolve_cli_path(cwd, s)) } #[tokio::main] @@ -76,59 +130,26 @@ async fn main() -> Result<(), std::io::Error> { JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?, ); - let port = resolve_port(); - // Collect CLI args, skipping the binary name (argv[0]). - let cli_args: Vec = std::env::args().skip(1).collect(); + let raw_args: Vec = std::env::args().skip(1).collect(); - // Handle CLI flags before treating anything as a project path. - let is_init = matches!(classify_cli_args(&cli_args), CliDirective::Init); - match classify_cli_args(&cli_args) { - CliDirective::Help => { - println!("storkit [PATH]"); - println!("storkit init [PATH]"); - println!(); - println!("Serve a storkit project."); - println!(); - println!("USAGE:"); - println!(" storkit [PATH]"); - println!(" storkit init [PATH]"); - println!(); - println!("COMMANDS:"); - println!( - " init Scaffold a new .storkit/ project and start the interactive setup wizard." - ); - println!(); - println!("ARGS:"); - println!( - " PATH Path to an existing project directory. \ - If omitted, storkit searches parent directories for a .storkit/ root." - ); - println!(); - println!("OPTIONS:"); - println!(" -h, --help Print this help and exit"); - println!(" -V, --version Print the version and exit"); - std::process::exit(0); - } - CliDirective::Version => { - println!("storkit {}", env!("CARGO_PKG_VERSION")); - std::process::exit(0); - } - CliDirective::UnknownFlag(flag) => { - eprintln!("error: unknown option: {flag}"); + let cli = match parse_cli_args(&raw_args) { + Ok(args) => args, + Err(msg) => { + eprintln!("error: {msg}"); eprintln!("Run 'storkit --help' for usage."); std::process::exit(1); } - CliDirective::Init | CliDirective::Path | CliDirective::None => {} - } - - // For `storkit init [PATH]`, the path argument follows "init". - let explicit_path = if is_init { - parse_project_path_arg(&cli_args[1..], &cwd) - } else { - parse_project_path_arg(&cli_args, &cwd) }; + let is_init = cli.init; + let explicit_path = resolve_path_arg(cli.path.as_deref(), &cwd); + + // Port resolution: CLI flag > project.toml (loaded later) > default. + // Use the CLI port for scaffolding .mcp.json; final port is resolved + // after the project root is known. + let port = cli.port.unwrap_or_else(resolve_port); + // When a path is given explicitly on the CLI, it must already exist as a // directory. We do not create directories from the command line. if let Some(ref path) = explicit_path { @@ -611,96 +632,106 @@ name = "coder" .unwrap_or_else(|e| panic!("Invalid project.toml: {e}")); } - // ── classify_cli_args ───────────────────────────────────────────────── + // ── parse_cli_args ───────────────────────────────────────────────── #[test] - fn classify_none_when_no_args() { - assert_eq!(classify_cli_args(&[]), CliDirective::None); + fn parse_no_args() { + let result = parse_cli_args(&[]).unwrap(); + assert_eq!(result.port, None); + assert_eq!(result.path, None); + assert!(!result.init); } #[test] - fn classify_help_long() { - assert_eq!( - classify_cli_args(&["--help".to_string()]), - CliDirective::Help - ); + fn parse_unknown_flag_is_error() { + let args = vec!["--serve".to_string()]; + assert!(parse_cli_args(&args).is_err()); } #[test] - fn classify_help_short() { - assert_eq!(classify_cli_args(&["-h".to_string()]), CliDirective::Help); + fn parse_path_only() { + let args = vec!["/some/path".to_string()]; + let result = parse_cli_args(&args).unwrap(); + assert_eq!(result.path, Some("/some/path".to_string())); + assert_eq!(result.port, None); + assert!(!result.init); } #[test] - fn classify_version_long() { - assert_eq!( - classify_cli_args(&["--version".to_string()]), - CliDirective::Version - ); + fn parse_port_flag() { + let args = vec!["--port".to_string(), "4000".to_string()]; + let result = parse_cli_args(&args).unwrap(); + assert_eq!(result.port, Some(4000)); + assert_eq!(result.path, None); } #[test] - fn classify_version_short() { - assert_eq!( - classify_cli_args(&["-V".to_string()]), - CliDirective::Version - ); + fn parse_port_equals_syntax() { + let args = vec!["--port=5000".to_string()]; + let result = parse_cli_args(&args).unwrap(); + assert_eq!(result.port, Some(5000)); } #[test] - fn classify_unknown_flag() { - assert_eq!( - classify_cli_args(&["--serve".to_string()]), - CliDirective::UnknownFlag("--serve".to_string()) - ); + fn parse_port_with_path() { + let args = vec!["--port".to_string(), "4200".to_string(), "/some/path".to_string()]; + let result = parse_cli_args(&args).unwrap(); + assert_eq!(result.port, Some(4200)); + assert_eq!(result.path, Some("/some/path".to_string())); } #[test] - fn classify_path() { - assert_eq!( - classify_cli_args(&["/some/path".to_string()]), - CliDirective::Path - ); + fn parse_port_missing_value_is_error() { + let args = vec!["--port".to_string()]; + assert!(parse_cli_args(&args).is_err()); } - // ── parse_project_path_arg ──────────────────────────────────────────── + #[test] + fn parse_port_invalid_value_is_error() { + let args = vec!["--port".to_string(), "abc".to_string()]; + assert!(parse_cli_args(&args).is_err()); + } #[test] - fn parse_project_path_arg_none_when_no_args() { + fn parse_init_subcommand() { + let args = vec!["init".to_string()]; + let result = parse_cli_args(&args).unwrap(); + assert!(result.init); + assert_eq!(result.path, None); + } + + #[test] + fn parse_init_with_path_and_port() { + let args = vec!["init".to_string(), "--port".to_string(), "3000".to_string(), "/my/project".to_string()]; + let result = parse_cli_args(&args).unwrap(); + assert!(result.init); + assert_eq!(result.port, Some(3000)); + assert_eq!(result.path, Some("/my/project".to_string())); + } + + // ── resolve_path_arg ──────────────────────────────────────────── + + #[test] + fn resolve_path_arg_none_when_no_path() { let cwd = PathBuf::from("/home/user/project"); - let result = parse_project_path_arg(&[], &cwd); + let result = resolve_path_arg(None, &cwd); assert!(result.is_none()); } #[test] - fn parse_project_path_arg_returns_path_for_absolute_arg() { + fn resolve_path_arg_returns_path_for_absolute_arg() { let cwd = PathBuf::from("/home/user/project"); - let args = vec!["/some/absolute/path".to_string()]; - let result = parse_project_path_arg(&args, &cwd).unwrap(); - // Absolute path returned as-is (canonicalize may fail, fallback used) + let result = resolve_path_arg(Some("/some/absolute/path"), &cwd).unwrap(); assert!( result.ends_with("absolute/path") || result == PathBuf::from("/some/absolute/path") ); } #[test] - fn parse_project_path_arg_resolves_dot_to_cwd() { + fn resolve_path_arg_resolves_dot_to_cwd() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path().to_path_buf(); - let args = vec![".".to_string()]; - let result = parse_project_path_arg(&args, &cwd).unwrap(); - // "." relative to an existing cwd should canonicalize to the cwd itself + let result = resolve_path_arg(Some("."), &cwd).unwrap(); assert_eq!(result, cwd.canonicalize().unwrap_or(cwd)); } - - #[test] - fn parse_project_path_arg_resolves_relative_path() { - let tmp = tempfile::tempdir().unwrap(); - let cwd = tmp.path().to_path_buf(); - let subdir = cwd.join("myproject"); - std::fs::create_dir_all(&subdir).unwrap(); - let args = vec!["myproject".to_string()]; - let result = parse_project_path_arg(&args, &cwd).unwrap(); - assert_eq!(result, subdir.canonicalize().unwrap_or(subdir)); - } }