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) <noreply@anthropic.com>
This commit is contained in:
+151
-120
@@ -32,72 +32,78 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
/// What the first CLI argument means.
|
/// Parsed CLI arguments.
|
||||||
#[derive(Debug, PartialEq)]
|
#[derive(Debug, PartialEq)]
|
||||||
enum CliDirective {
|
struct CliArgs {
|
||||||
/// `--help` / `-h`
|
/// Value from `--port <VALUE>` flag, if supplied.
|
||||||
Help,
|
port: Option<u16>,
|
||||||
/// `--version` / `-V`
|
/// Positional project path argument, if supplied.
|
||||||
Version,
|
path: Option<String>,
|
||||||
/// `init [PATH]` — scaffold and start the setup wizard.
|
/// Whether the `init` subcommand was given.
|
||||||
Init,
|
init: bool,
|
||||||
/// An unrecognised flag (starts with `-`).
|
|
||||||
UnknownFlag(String),
|
|
||||||
/// A positional path argument.
|
|
||||||
Path,
|
|
||||||
/// No arguments at all.
|
|
||||||
None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inspect the raw CLI arguments and return the directive they imply.
|
/// Parse CLI arguments into `CliArgs`, or exit early for `--help` / `--version`.
|
||||||
fn classify_cli_args(args: &[String]) -> CliDirective {
|
fn parse_cli_args(args: &[String]) -> Result<CliArgs, String> {
|
||||||
match args.first().map(String::as_str) {
|
let mut port: Option<u16> = None;
|
||||||
None => CliDirective::None,
|
let mut path: Option<String> = None;
|
||||||
Some("--help" | "-h") => CliDirective::Help,
|
let mut init = false;
|
||||||
Some("--version" | "-V") => CliDirective::Version,
|
let mut i = 0;
|
||||||
Some("init") => CliDirective::Init,
|
|
||||||
Some(a) if a.starts_with('-') => CliDirective::UnknownFlag(a.to_string()),
|
while i < args.len() {
|
||||||
Some(_) => CliDirective::Path,
|
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::<u16>() {
|
||||||
|
Ok(p) => port = Some(p),
|
||||||
|
Err(_) => return Err(format!("invalid port value: '{}'", args[i])),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
a if a.starts_with("--port=") => {
|
||||||
/// Resolve the optional positional path argument (everything after the binary
|
let val = &a["--port=".len()..];
|
||||||
/// name) into an absolute `PathBuf`. Returns `None` when no argument was
|
match val.parse::<u16>() {
|
||||||
/// supplied so that the caller can fall back to the auto-detect behaviour.
|
Ok(p) => port = Some(p),
|
||||||
fn parse_project_path_arg(args: &[String], cwd: &std::path::Path) -> Option<PathBuf> {
|
Err(_) => return Err(format!("invalid port value: '{val}'")),
|
||||||
args.first().map(|s| io::fs::resolve_cli_path(cwd, s))
|
}
|
||||||
|
}
|
||||||
|
"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;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
Ok(CliArgs { port, path, init })
|
||||||
async fn main() -> Result<(), std::io::Error> {
|
}
|
||||||
let app_state = Arc::new(SessionState::default());
|
|
||||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
|
||||||
let store = Arc::new(
|
|
||||||
JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?,
|
|
||||||
);
|
|
||||||
|
|
||||||
let port = resolve_port();
|
fn print_help() {
|
||||||
|
println!("storkit [OPTIONS] [PATH]");
|
||||||
// Collect CLI args, skipping the binary name (argv[0]).
|
println!("storkit init [OPTIONS] [PATH]");
|
||||||
let cli_args: Vec<String> = 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!();
|
||||||
println!("Serve a storkit project.");
|
println!("Serve a storkit project.");
|
||||||
println!();
|
println!();
|
||||||
println!("USAGE:");
|
|
||||||
println!(" storkit [PATH]");
|
|
||||||
println!(" storkit init [PATH]");
|
|
||||||
println!();
|
|
||||||
println!("COMMANDS:");
|
println!("COMMANDS:");
|
||||||
println!(
|
println!(" init Scaffold a new .storkit/ project and start the interactive setup wizard.");
|
||||||
" init Scaffold a new .storkit/ project and start the interactive setup wizard."
|
|
||||||
);
|
|
||||||
println!();
|
println!();
|
||||||
println!("ARGS:");
|
println!("ARGS:");
|
||||||
println!(
|
println!(
|
||||||
@@ -108,27 +114,42 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
println!("OPTIONS:");
|
println!("OPTIONS:");
|
||||||
println!(" -h, --help Print this help and exit");
|
println!(" -h, --help Print this help and exit");
|
||||||
println!(" -V, --version Print the version and exit");
|
println!(" -V, --version Print the version and exit");
|
||||||
std::process::exit(0);
|
println!(" --port <PORT> Port to listen on (default: 3001). Persisted to project.toml.");
|
||||||
}
|
}
|
||||||
CliDirective::Version => {
|
|
||||||
println!("storkit {}", env!("CARGO_PKG_VERSION"));
|
/// Resolve the optional positional path argument into an absolute `PathBuf`.
|
||||||
std::process::exit(0);
|
fn resolve_path_arg(path_str: Option<&str>, cwd: &std::path::Path) -> Option<PathBuf> {
|
||||||
|
path_str.map(|s| io::fs::resolve_cli_path(cwd, s))
|
||||||
}
|
}
|
||||||
CliDirective::UnknownFlag(flag) => {
|
|
||||||
eprintln!("error: unknown option: {flag}");
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), std::io::Error> {
|
||||||
|
let app_state = Arc::new(SessionState::default());
|
||||||
|
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||||
|
let store = Arc::new(
|
||||||
|
JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collect CLI args, skipping the binary name (argv[0]).
|
||||||
|
let raw_args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
|
||||||
|
let cli = match parse_cli_args(&raw_args) {
|
||||||
|
Ok(args) => args,
|
||||||
|
Err(msg) => {
|
||||||
|
eprintln!("error: {msg}");
|
||||||
eprintln!("Run 'storkit --help' for usage.");
|
eprintln!("Run 'storkit --help' for usage.");
|
||||||
std::process::exit(1);
|
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
|
// 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.
|
// directory. We do not create directories from the command line.
|
||||||
if let Some(ref path) = explicit_path {
|
if let Some(ref path) = explicit_path {
|
||||||
@@ -611,96 +632,106 @@ name = "coder"
|
|||||||
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
|
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── classify_cli_args ─────────────────────────────────────────────────
|
// ── parse_cli_args ─────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_none_when_no_args() {
|
fn parse_no_args() {
|
||||||
assert_eq!(classify_cli_args(&[]), CliDirective::None);
|
let result = parse_cli_args(&[]).unwrap();
|
||||||
|
assert_eq!(result.port, None);
|
||||||
|
assert_eq!(result.path, None);
|
||||||
|
assert!(!result.init);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_help_long() {
|
fn parse_unknown_flag_is_error() {
|
||||||
assert_eq!(
|
let args = vec!["--serve".to_string()];
|
||||||
classify_cli_args(&["--help".to_string()]),
|
assert!(parse_cli_args(&args).is_err());
|
||||||
CliDirective::Help
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_help_short() {
|
fn parse_path_only() {
|
||||||
assert_eq!(classify_cli_args(&["-h".to_string()]), CliDirective::Help);
|
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]
|
#[test]
|
||||||
fn classify_version_long() {
|
fn parse_port_flag() {
|
||||||
assert_eq!(
|
let args = vec!["--port".to_string(), "4000".to_string()];
|
||||||
classify_cli_args(&["--version".to_string()]),
|
let result = parse_cli_args(&args).unwrap();
|
||||||
CliDirective::Version
|
assert_eq!(result.port, Some(4000));
|
||||||
);
|
assert_eq!(result.path, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_version_short() {
|
fn parse_port_equals_syntax() {
|
||||||
assert_eq!(
|
let args = vec!["--port=5000".to_string()];
|
||||||
classify_cli_args(&["-V".to_string()]),
|
let result = parse_cli_args(&args).unwrap();
|
||||||
CliDirective::Version
|
assert_eq!(result.port, Some(5000));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_unknown_flag() {
|
fn parse_port_with_path() {
|
||||||
assert_eq!(
|
let args = vec!["--port".to_string(), "4200".to_string(), "/some/path".to_string()];
|
||||||
classify_cli_args(&["--serve".to_string()]),
|
let result = parse_cli_args(&args).unwrap();
|
||||||
CliDirective::UnknownFlag("--serve".to_string())
|
assert_eq!(result.port, Some(4200));
|
||||||
);
|
assert_eq!(result.path, Some("/some/path".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_path() {
|
fn parse_port_missing_value_is_error() {
|
||||||
assert_eq!(
|
let args = vec!["--port".to_string()];
|
||||||
classify_cli_args(&["/some/path".to_string()]),
|
assert!(parse_cli_args(&args).is_err());
|
||||||
CliDirective::Path
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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]
|
#[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 cwd = PathBuf::from("/home/user/project");
|
||||||
let result = parse_project_path_arg(&[], &cwd);
|
let result = resolve_path_arg(None, &cwd);
|
||||||
assert!(result.is_none());
|
assert!(result.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 cwd = PathBuf::from("/home/user/project");
|
||||||
let args = vec!["/some/absolute/path".to_string()];
|
let result = resolve_path_arg(Some("/some/absolute/path"), &cwd).unwrap();
|
||||||
let result = parse_project_path_arg(&args, &cwd).unwrap();
|
|
||||||
// Absolute path returned as-is (canonicalize may fail, fallback used)
|
|
||||||
assert!(
|
assert!(
|
||||||
result.ends_with("absolute/path") || result == PathBuf::from("/some/absolute/path")
|
result.ends_with("absolute/path") || result == PathBuf::from("/some/absolute/path")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_project_path_arg_resolves_dot_to_cwd() {
|
fn resolve_path_arg_resolves_dot_to_cwd() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let cwd = tmp.path().to_path_buf();
|
let cwd = tmp.path().to_path_buf();
|
||||||
let args = vec![".".to_string()];
|
let result = resolve_path_arg(Some("."), &cwd).unwrap();
|
||||||
let result = parse_project_path_arg(&args, &cwd).unwrap();
|
|
||||||
// "." relative to an existing cwd should canonicalize to the cwd itself
|
|
||||||
assert_eq!(result, cwd.canonicalize().unwrap_or(cwd));
|
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user