diff --git a/crates/surge-cli/src/bootstrap.rs b/crates/surge-cli/src/bootstrap.rs new file mode 100644 index 0000000..97efe02 --- /dev/null +++ b/crates/surge-cli/src/bootstrap.rs @@ -0,0 +1,85 @@ +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use crate::cli::{Cli, Commands}; +use crate::{commands, envfile, logline, ui}; + +pub(crate) fn init_tracing(verbose: bool) { + let filter = if verbose { "debug" } else { "info" }; + let theme = ui::UiTheme::global(); + tracing_subscriber::fmt() + .with_timer(logline::CommandTimer::new()) + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(filter)), + ) + .with_target(false) + .with_ansi(theme.enabled()) + .init(); +} + +pub(crate) fn load_env_files_for_cli(cli: &Cli) -> surge_core::error::Result<()> { + match &cli.command { + Commands::Init { .. } | Commands::Lock { .. } | Commands::Sha256 { .. } => Ok(()), + Commands::Setup { dir, .. } => load_env_files_for_scope(dir, &envfile::candidate_paths_for_setup(dir)), + Commands::Install { options, .. } => { + let manifest_path = + commands::install::selected_install_manifest_path(&options.application_manifest, &cli.manifest_path); + load_env_files_for_scope(manifest_path, &envfile::candidate_paths_for_manifest(manifest_path)) + } + Commands::Migrate { dest_manifest, .. } => { + load_env_files_for_scope( + &cli.manifest_path, + &envfile::candidate_paths_for_manifest(&cli.manifest_path), + )?; + load_env_files_for_scope(dest_manifest, &envfile::candidate_paths_for_manifest(dest_manifest)) + } + _ => load_env_files_for_scope( + &cli.manifest_path, + &envfile::candidate_paths_for_manifest(&cli.manifest_path), + ), + } +} + +pub(crate) fn load_env_files_for_setup(dir: &Path) -> surge_core::error::Result<()> { + let loaded = envfile::load_storage_env_files(dir, &envfile::candidate_paths_for_setup(dir))?; + for path in loaded { + logline::info(&format!("Loaded storage env overrides from {}", path.display())); + } + Ok(()) +} + +fn load_env_files_for_scope(scope: &Path, candidates: &[PathBuf]) -> surge_core::error::Result<()> { + let loaded = envfile::load_storage_env_files(scope, candidates)?; + for path in loaded { + logline::info(&format!("Loaded storage env overrides from {}", path.display())); + } + Ok(()) +} + +pub(crate) fn detect_installer_context() -> Option { + let exe = std::env::current_exe().ok()?; + let dir = exe.parent()?; + let manifest = dir.join("installer.yml"); + if manifest.is_file() { + Some(dir.to_path_buf()) + } else { + None + } +} + +pub(crate) fn handle_parse_error(err: &clap::Error) -> ExitCode { + let is_success = matches!( + err.kind(), + clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion + ); + let rendered = err.to_string(); + let output = rendered.trim_end(); + if is_success { + logline::emit_raw(output); + ExitCode::SUCCESS + } else { + logline::emit_raw_stderr(output); + ExitCode::FAILURE + } +} diff --git a/crates/surge-cli/src/cli.rs b/crates/surge-cli/src/cli.rs new file mode 100644 index 0000000..a253116 --- /dev/null +++ b/crates/surge-cli/src/cli.rs @@ -0,0 +1,501 @@ +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand, ValueEnum}; +use surge_core::config::constants::PACK_DEFAULT_DELTA_STRATEGY; + +#[derive(Parser)] +#[command(name = "surge", version, about = "Surge update framework CLI")] +pub(crate) struct Cli { + /// Path to surge.yml manifest + #[arg(long, short = 'm', default_value = ".surge/surge.yml")] + pub(crate) manifest_path: PathBuf, + + /// Enable verbose logging + #[arg(long, short = 'v')] + pub(crate) verbose: bool, + + #[command(subcommand)] + pub(crate) command: Commands, +} + +#[derive(Subcommand)] +pub(crate) enum Commands { + /// Initialize a new surge.yml manifest + Init { + /// Application ID + #[arg(long)] + app_id: Option, + + /// Application display name + #[arg(long)] + name: Option, + + /// Storage provider (s3, azure, gcs, filesystem, `github_releases`) + #[arg(long)] + provider: Option, + + /// Storage bucket or root path + #[arg(long)] + bucket: Option, + + /// Runtime identifier (defaults to current RID for non-wizard init) + #[arg(long)] + rid: Option, + + /// Main executable (defaults to app id for non-wizard init) + #[arg(long)] + main_exe: Option, + + /// Install directory name (defaults to app id for non-wizard init) + #[arg(long)] + install_directory: Option, + + /// Supervisor ID GUID (defaults to random UUID v4 for non-wizard init) + #[arg(long)] + supervisor_id: Option, + + /// Force interactive setup wizard + #[arg(long)] + wizard: bool, + + /// Disable wizard and use command-line options only + #[arg(long, conflicts_with = "wizard")] + no_wizard: bool, + }, + + /// Build release packages (full + delta) + Pack { + /// Application ID (auto-selected when manifest has exactly one app) + #[arg(long)] + app_id: Option, + + /// Release version + #[arg(long)] + version: String, + + /// Runtime identifier (auto-selected when app has exactly one target) + #[arg(long)] + rid: Option, + + /// Path to build artifacts directory (defaults to .surge/artifacts///) + #[arg(long)] + artifacts_dir: Option, + + /// Output directory for packages + #[arg(long, short = 'o', default_value = ".surge/packages")] + output_dir: PathBuf, + }, + + /// Push packages to storage + Push { + /// Application ID (auto-selected when manifest has exactly one app) + #[arg(long)] + app_id: Option, + + /// Release version + #[arg(long)] + version: String, + + /// Runtime identifier (auto-selected when app has exactly one target) + #[arg(long)] + rid: Option, + + /// Channel to publish to + #[arg(long, default_value = "stable")] + channel: String, + + /// Directory containing built packages + #[arg(long, default_value = ".surge/packages")] + packages_dir: PathBuf, + }, + + /// Promote a release to a channel + Promote { + /// Application ID (auto-selected when manifest has exactly one app) + #[arg(long)] + app_id: Option, + + /// Release version to promote + #[arg(long)] + version: String, + + /// Runtime identifier (auto-selected when app has exactly one target) + #[arg(long)] + rid: Option, + + /// Target channel + #[arg(long)] + channel: String, + }, + + /// Demote a release from a channel + Demote { + /// Application ID (auto-selected when manifest has exactly one app) + #[arg(long)] + app_id: Option, + + /// Release version to demote + #[arg(long)] + version: String, + + /// Runtime identifier (auto-selected when app has exactly one target) + #[arg(long)] + rid: Option, + + /// Channel to remove from + #[arg(long)] + channel: String, + }, + + /// List releases and channels + List { + /// Application ID (auto-selected when manifest has exactly one app) + #[arg(long)] + app_id: Option, + + /// Runtime identifier (auto-selected when app has exactly one target) + #[arg(long)] + rid: Option, + + /// Filter by channel + #[arg(long)] + channel: Option, + }, + + /// Compact a channel to a single latest full release and prune stale artifacts + Compact { + /// Application ID (auto-selected when manifest has exactly one app) + #[arg(long)] + app_id: Option, + + /// Runtime identifier (auto-selected when app has exactly one target) + #[arg(long)] + rid: Option, + + /// Channel to compact (auto-selected only when exactly one channel exists) + #[arg(long)] + channel: Option, + }, + + /// Manage distributed locks + Lock { + #[command(subcommand)] + action: LockAction, + }, + + /// Benchmark pack policy candidates and optionally write the recommendation to the manifest + Tune { + #[command(subcommand)] + action: TuneAction, + }, + + /// Migrate release data between storage backends + Migrate { + /// Application ID (auto-selected when manifest has exactly one app) + #[arg(long)] + app_id: Option, + + /// Runtime identifier (auto-selected when app has exactly one target) + #[arg(long)] + rid: Option, + + /// Path to destination manifest + #[arg(long)] + dest_manifest: PathBuf, + }, + + /// Restore releases from local packages or build installers from existing packages + Restore { + /// Application ID (auto-selected when manifest has exactly one app) + #[arg(long)] + app_id: Option, + + /// Runtime identifier (auto-selected when app has exactly one target) + #[arg(long)] + rid: Option, + + /// Specific version to restore (defaults to latest when using --installers) + #[arg(long)] + version: Option, + + /// Build installers only (snapx-compatible restore mode) + #[arg(long, short = 'i')] + installers: bool, + + /// Upload generated installers to storage under installers/ + #[arg(long, requires = "installers", conflicts_with = "package_file")] + upload_installers: bool, + + /// Write a cache-manifest file for the selected installer package and exit + #[arg(long, requires = "installers")] + package_file: Option, + + /// Path to build artifacts directory (defaults to .surge/artifacts/// with --installers) + #[arg(long)] + artifacts_dir: Option, + + /// Directory containing built packages (used with --installers) + #[arg(long, default_value = ".surge/packages", requires = "installers")] + packages_dir: PathBuf, + }, + + /// Install from an extracted installer directory (used by self-extracting installers) + Setup { + /// Path to extracted installer directory + #[arg(default_value = ".")] + dir: PathBuf, + + /// Do not start the application after installation + #[arg(long)] + no_start: bool, + + /// Only cache the package locally without installing (used by --stage) + #[arg(long)] + stage: bool, + }, + + /// Print SHA-256 hash of a file + #[command(name = "sha256")] + Sha256 { + /// File to hash + file: PathBuf, + }, + + /// Install packages using a selected transport method + Install { + /// Install method (defaults to backend) + #[arg(value_enum, default_value_t = InstallMethod::Backend)] + method: InstallMethod, + + /// Target node for tailscale method as positional value (for example: my-node or user@my-node) + #[arg(index = 2, value_name = "NODE", conflicts_with = "node")] + target: Option, + + /// Target node for tailscale method (for example: my-node or user@my-node) + #[arg(long)] + node: Option, + + /// Node user account used for tailscale SSH login (tailscale method) + #[arg(long = "node-user", alias = "ssh-user")] + node_user: Option, + + #[command(flatten)] + options: InstallOptions, + }, +} + +#[derive(Subcommand)] +pub(crate) enum LockAction { + /// Acquire a distributed lock + Acquire { + /// Lock name + #[arg(long)] + name: String, + + /// Lock timeout in seconds + #[arg(long, default_value = "300")] + timeout: u32, + }, + + /// Release a distributed lock + Release { + /// Lock name + #[arg(long)] + name: String, + + /// Challenge token from acquire + #[arg(long)] + challenge: String, + }, +} + +#[derive(Subcommand)] +pub(crate) enum TuneAction { + /// Benchmark pack policy candidates for a specific app target and version + Pack { + /// Application ID (auto-selected when manifest has exactly one app) + #[arg(long)] + app_id: Option, + + /// Release version to benchmark + #[arg(long)] + version: String, + + /// Runtime identifier (auto-selected when app has exactly one target) + #[arg(long)] + rid: Option, + + /// Path to build artifacts directory (defaults to .surge/artifacts///) + #[arg(long)] + artifacts_dir: Option, + + /// Comma-separated zstd compression levels to benchmark + #[arg(long, default_value = "1,3,5,9", value_delimiter = ',')] + zstd_levels: Vec, + + /// Comma-separated delta strategies to benchmark + #[arg(long, default_value = PACK_DEFAULT_DELTA_STRATEGY, value_delimiter = ',')] + delta_strategies: Vec, + + /// Write the recommended pack policy back to the manifest + #[arg(long)] + write_manifest: bool, + }, +} + +#[derive(ValueEnum, Clone, Debug)] +pub(crate) enum InstallMethod { + /// Resolve a release from configured backend and download it locally + Backend, + /// Install to a tailscale node using an explicit/selected RID and transfer package + #[value(alias = "ssh")] + Tailscale, +} + +#[derive(Args, Clone)] +pub(crate) struct InstallOptions { + /// Path to application manifest used for install defaults + #[arg(long, default_value = ".surge/application.yml")] + pub(crate) application_manifest: PathBuf, + + /// Application ID (auto-selected when manifest has exactly one app) + #[arg(long)] + pub(crate) app_id: Option, + + /// Channel to resolve releases from (required only when multiple channels exist) + #[arg(long)] + pub(crate) channel: Option, + + /// Explicit target RID (required when app has multiple targets and no interactive selection) + #[arg(long)] + pub(crate) rid: Option, + + /// Specific version to install (defaults to latest matching version) + #[arg(long)] + pub(crate) version: Option, + + /// Only show the selected package and command hints, do not download/transfer + #[arg(long)] + pub(crate) plan_only: bool, + + /// Do not start the application after installation + #[arg(long)] + pub(crate) no_start: bool, + + #[command(flatten)] + pub(crate) stage_options: InstallStageOptions, + + /// Reinstall even if the selected version/channel is already installed on the target + #[arg(long)] + pub(crate) force: bool, + + /// Local cache directory for downloaded packages + #[arg(long, default_value = ".surge/install-cache")] + pub(crate) download_dir: PathBuf, + + /// Override storage provider from application manifest (s3, azure, gcs, filesystem, `github_releases`) + #[arg(long)] + pub(crate) provider: Option, + + /// Override storage bucket/root from application manifest + #[arg(long)] + pub(crate) bucket: Option, + + /// Override storage region from application manifest + #[arg(long)] + pub(crate) region: Option, + + /// Override storage endpoint from application manifest + #[arg(long)] + pub(crate) endpoint: Option, + + /// Override storage prefix from application manifest + #[arg(long)] + pub(crate) prefix: Option, +} + +#[derive(Args, Clone)] +pub(crate) struct InstallStageOptions { + /// Pre-stage packages on remote nodes without activating (tailscale method only) + #[arg(long)] + pub(crate) stage: bool, + + /// Verify that the selected release is already staged and ready for the next tailscale install + #[arg(long, conflicts_with = "stage")] + pub(crate) verify_stage: bool, +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::{Cli, Commands}; + + #[test] + fn restore_package_file_requires_installers_flag() { + let Err(err) = Cli::try_parse_from(["surge", "restore", "--package-file", "packages.txt"]) else { + panic!("package-file should require installers mode"); + }; + + assert!(err.to_string().contains("--installers")); + } + + #[test] + fn restore_upload_installers_requires_installers_flag() { + let Err(err) = Cli::try_parse_from(["surge", "restore", "--upload-installers"]) else { + panic!("upload-installers should require installers mode"); + }; + + assert!(err.to_string().contains("--installers")); + } + + #[test] + fn restore_upload_installers_conflicts_with_package_file() { + let Err(err) = Cli::try_parse_from([ + "surge", + "restore", + "--installers", + "--upload-installers", + "--package-file", + "packages.txt", + ]) else { + panic!("upload-installers should conflict with package-file"); + }; + + assert!(err.to_string().contains("--package-file")); + } + + #[test] + fn install_force_flag_parses() { + let cli = Cli::try_parse_from(["surge", "install", "tailscale", "my-node", "--force"]) + .expect("install command with --force should parse"); + + let Commands::Install { options, .. } = cli.command else { + panic!("expected install command"); + }; + + assert!(options.force); + } + + #[test] + fn install_verify_stage_flag_parses() { + let cli = Cli::try_parse_from(["surge", "install", "tailscale", "my-node", "--verify-stage"]) + .expect("install command with --verify-stage should parse"); + + let Commands::Install { options, .. } = cli.command else { + panic!("expected install command"); + }; + + assert!(options.stage_options.verify_stage); + } + + #[test] + fn install_verify_stage_conflicts_with_stage() { + let Err(err) = Cli::try_parse_from(["surge", "install", "tailscale", "my-node", "--stage", "--verify-stage"]) + else { + panic!("--verify-stage should conflict with --stage"); + }; + + assert!(err.to_string().contains("--stage")); + } +} diff --git a/crates/surge-cli/src/main.rs b/crates/surge-cli/src/main.rs index 604628c..0e63579 100644 --- a/crates/surge-cli/src/main.rs +++ b/crates/surge-cli/src/main.rs @@ -1,13 +1,13 @@ #![forbid(unsafe_code)] #![allow(clippy::too_many_lines)] -use clap::{Args, Parser, Subcommand, ValueEnum}; -use std::path::{Path, PathBuf}; +use clap::Parser; +use std::path::PathBuf; use std::process::ExitCode; use std::time::Instant; -use surge_core::config::constants::PACK_DEFAULT_DELTA_STRATEGY; - +mod bootstrap; +mod cli; mod commands; mod envfile; mod formatters; @@ -15,441 +15,7 @@ mod logline; mod prompts; mod ui; -#[derive(Parser)] -#[command(name = "surge", version, about = "Surge update framework CLI")] -struct Cli { - /// Path to surge.yml manifest - #[arg(long, short = 'm', default_value = ".surge/surge.yml")] - manifest_path: PathBuf, - - /// Enable verbose logging - #[arg(long, short = 'v')] - verbose: bool, - - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand)] -enum Commands { - /// Initialize a new surge.yml manifest - Init { - /// Application ID - #[arg(long)] - app_id: Option, - - /// Application display name - #[arg(long)] - name: Option, - - /// Storage provider (s3, azure, gcs, filesystem, `github_releases`) - #[arg(long)] - provider: Option, - - /// Storage bucket or root path - #[arg(long)] - bucket: Option, - - /// Runtime identifier (defaults to current RID for non-wizard init) - #[arg(long)] - rid: Option, - - /// Main executable (defaults to app id for non-wizard init) - #[arg(long)] - main_exe: Option, - - /// Install directory name (defaults to app id for non-wizard init) - #[arg(long)] - install_directory: Option, - - /// Supervisor ID GUID (defaults to random UUID v4 for non-wizard init) - #[arg(long)] - supervisor_id: Option, - - /// Force interactive setup wizard - #[arg(long)] - wizard: bool, - - /// Disable wizard and use command-line options only - #[arg(long, conflicts_with = "wizard")] - no_wizard: bool, - }, - - /// Build release packages (full + delta) - Pack { - /// Application ID (auto-selected when manifest has exactly one app) - #[arg(long)] - app_id: Option, - - /// Release version - #[arg(long)] - version: String, - - /// Runtime identifier (auto-selected when app has exactly one target) - #[arg(long)] - rid: Option, - - /// Path to build artifacts directory (defaults to .surge/artifacts///) - #[arg(long)] - artifacts_dir: Option, - - /// Output directory for packages - #[arg(long, short = 'o', default_value = ".surge/packages")] - output_dir: PathBuf, - }, - - /// Push packages to storage - Push { - /// Application ID (auto-selected when manifest has exactly one app) - #[arg(long)] - app_id: Option, - - /// Release version - #[arg(long)] - version: String, - - /// Runtime identifier (auto-selected when app has exactly one target) - #[arg(long)] - rid: Option, - - /// Channel to publish to - #[arg(long, default_value = "stable")] - channel: String, - - /// Directory containing built packages - #[arg(long, default_value = ".surge/packages")] - packages_dir: PathBuf, - }, - - /// Promote a release to a channel - Promote { - /// Application ID (auto-selected when manifest has exactly one app) - #[arg(long)] - app_id: Option, - - /// Release version to promote - #[arg(long)] - version: String, - - /// Runtime identifier (auto-selected when app has exactly one target) - #[arg(long)] - rid: Option, - - /// Target channel - #[arg(long)] - channel: String, - }, - - /// Demote a release from a channel - Demote { - /// Application ID (auto-selected when manifest has exactly one app) - #[arg(long)] - app_id: Option, - - /// Release version to demote - #[arg(long)] - version: String, - - /// Runtime identifier (auto-selected when app has exactly one target) - #[arg(long)] - rid: Option, - - /// Channel to remove from - #[arg(long)] - channel: String, - }, - - /// List releases and channels - List { - /// Application ID (auto-selected when manifest has exactly one app) - #[arg(long)] - app_id: Option, - - /// Runtime identifier (auto-selected when app has exactly one target) - #[arg(long)] - rid: Option, - - /// Filter by channel - #[arg(long)] - channel: Option, - }, - - /// Compact a channel to a single latest full release and prune stale artifacts - Compact { - /// Application ID (auto-selected when manifest has exactly one app) - #[arg(long)] - app_id: Option, - - /// Runtime identifier (auto-selected when app has exactly one target) - #[arg(long)] - rid: Option, - - /// Channel to compact (auto-selected only when exactly one channel exists) - #[arg(long)] - channel: Option, - }, - - /// Manage distributed locks - Lock { - #[command(subcommand)] - action: LockAction, - }, - - /// Benchmark pack policy candidates and optionally write the recommendation to the manifest - Tune { - #[command(subcommand)] - action: TuneAction, - }, - - /// Migrate release data between storage backends - Migrate { - /// Application ID (auto-selected when manifest has exactly one app) - #[arg(long)] - app_id: Option, - - /// Runtime identifier (auto-selected when app has exactly one target) - #[arg(long)] - rid: Option, - - /// Path to destination manifest - #[arg(long)] - dest_manifest: PathBuf, - }, - - /// Restore releases from local packages or build installers from existing packages - Restore { - /// Application ID (auto-selected when manifest has exactly one app) - #[arg(long)] - app_id: Option, - - /// Runtime identifier (auto-selected when app has exactly one target) - #[arg(long)] - rid: Option, - - /// Specific version to restore (defaults to latest when using --installers) - #[arg(long)] - version: Option, - - /// Build installers only (snapx-compatible restore mode) - #[arg(long, short = 'i')] - installers: bool, - - /// Upload generated installers to storage under installers/ - #[arg(long, requires = "installers", conflicts_with = "package_file")] - upload_installers: bool, - - /// Write a cache-manifest file for the selected installer package and exit - #[arg(long, requires = "installers")] - package_file: Option, - - /// Path to build artifacts directory (defaults to .surge/artifacts/// with --installers) - #[arg(long)] - artifacts_dir: Option, - - /// Directory containing built packages (used with --installers) - #[arg(long, default_value = ".surge/packages", requires = "installers")] - packages_dir: PathBuf, - }, - - /// Install from an extracted installer directory (used by self-extracting installers) - Setup { - /// Path to extracted installer directory - #[arg(default_value = ".")] - dir: PathBuf, - - /// Do not start the application after installation - #[arg(long)] - no_start: bool, - - /// Only cache the package locally without installing (used by --stage) - #[arg(long)] - stage: bool, - }, - - /// Print SHA-256 hash of a file - #[command(name = "sha256")] - Sha256 { - /// File to hash - file: PathBuf, - }, - - /// Install packages using a selected transport method - Install { - /// Install method (defaults to backend) - #[arg(value_enum, default_value_t = InstallMethod::Backend)] - method: InstallMethod, - - /// Target node for tailscale method as positional value (for example: my-node or user@my-node) - #[arg(index = 2, value_name = "NODE", conflicts_with = "node")] - target: Option, - - /// Target node for tailscale method (for example: my-node or user@my-node) - #[arg(long)] - node: Option, - - /// Node user account used for tailscale SSH login (tailscale method) - #[arg(long = "node-user", alias = "ssh-user")] - node_user: Option, - - #[command(flatten)] - options: InstallOptions, - }, -} - -#[derive(Subcommand)] -enum LockAction { - /// Acquire a distributed lock - Acquire { - /// Lock name - #[arg(long)] - name: String, - - /// Lock timeout in seconds - #[arg(long, default_value = "300")] - timeout: u32, - }, - - /// Release a distributed lock - Release { - /// Lock name - #[arg(long)] - name: String, - - /// Challenge token from acquire - #[arg(long)] - challenge: String, - }, -} - -#[derive(Subcommand)] -enum TuneAction { - /// Benchmark pack policy candidates for a specific app target and version - Pack { - /// Application ID (auto-selected when manifest has exactly one app) - #[arg(long)] - app_id: Option, - - /// Release version to benchmark - #[arg(long)] - version: String, - - /// Runtime identifier (auto-selected when app has exactly one target) - #[arg(long)] - rid: Option, - - /// Path to build artifacts directory (defaults to .surge/artifacts///) - #[arg(long)] - artifacts_dir: Option, - - /// Comma-separated zstd compression levels to benchmark - #[arg(long, default_value = "1,3,5,9", value_delimiter = ',')] - zstd_levels: Vec, - - /// Comma-separated delta strategies to benchmark - #[arg(long, default_value = PACK_DEFAULT_DELTA_STRATEGY, value_delimiter = ',')] - delta_strategies: Vec, - - /// Write the recommended pack policy back to the manifest - #[arg(long)] - write_manifest: bool, - }, -} - -#[derive(ValueEnum, Clone, Debug)] -enum InstallMethod { - /// Resolve a release from configured backend and download it locally - Backend, - /// Install to a tailscale node using an explicit/selected RID and transfer package - #[value(alias = "ssh")] - Tailscale, -} - -#[derive(Args, Clone)] -struct InstallOptions { - /// Path to application manifest used for install defaults - #[arg(long, default_value = ".surge/application.yml")] - application_manifest: PathBuf, - - /// Application ID (auto-selected when manifest has exactly one app) - #[arg(long)] - app_id: Option, - - /// Channel to resolve releases from (required only when multiple channels exist) - #[arg(long)] - channel: Option, - - /// Explicit target RID (required when app has multiple targets and no interactive selection) - #[arg(long)] - rid: Option, - - /// Specific version to install (defaults to latest matching version) - #[arg(long)] - version: Option, - - /// Only show the selected package and command hints, do not download/transfer - #[arg(long)] - plan_only: bool, - - /// Do not start the application after installation - #[arg(long)] - no_start: bool, - - #[command(flatten)] - stage_options: InstallStageOptions, - - /// Reinstall even if the selected version/channel is already installed on the target - #[arg(long)] - force: bool, - - /// Local cache directory for downloaded packages - #[arg(long, default_value = ".surge/install-cache")] - download_dir: PathBuf, - - /// Override storage provider from application manifest (s3, azure, gcs, filesystem, `github_releases`) - #[arg(long)] - provider: Option, - - /// Override storage bucket/root from application manifest - #[arg(long)] - bucket: Option, - - /// Override storage region from application manifest - #[arg(long)] - region: Option, - - /// Override storage endpoint from application manifest - #[arg(long)] - endpoint: Option, - - /// Override storage prefix from application manifest - #[arg(long)] - prefix: Option, -} - -#[derive(Args, Clone)] -struct InstallStageOptions { - /// Pre-stage packages on remote nodes without activating (tailscale method only) - #[arg(long)] - stage: bool, - - /// Verify that the selected release is already staged and ready for the next tailscale install - #[arg(long, conflicts_with = "stage")] - verify_stage: bool, -} - -fn init_tracing(verbose: bool) { - let filter = if verbose { "debug" } else { "info" }; - let theme = ui::UiTheme::global(); - tracing_subscriber::fmt() - .with_timer(logline::CommandTimer::new()) - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(filter)), - ) - .with_target(false) - .with_ansi(theme.enabled()) - .init(); -} +use cli::{Cli, Commands, InstallMethod, LockAction, TuneAction}; fn main() -> ExitCode { let started = Instant::now(); @@ -459,10 +25,10 @@ fn main() -> ExitCode { Ok(cli) => cli, Err(err) => { if err.kind() == clap::error::ErrorKind::MissingSubcommand - && let Some(installer_dir) = detect_installer_context() + && let Some(installer_dir) = bootstrap::detect_installer_context() { - init_tracing(false); - if let Err(e) = load_env_files_for_setup(&installer_dir) { + bootstrap::init_tracing(false); + if let Err(e) = bootstrap::load_env_files_for_setup(&installer_dir) { logline::error_chain(&e); return ExitCode::FAILURE; } @@ -482,12 +48,12 @@ fn main() -> ExitCode { } }; } - return handle_parse_error(&err); + return bootstrap::handle_parse_error(&err); } }; logline::init_verbose(cli.verbose); - init_tracing(cli.verbose); - if let Err(e) = load_env_files_for_cli(&cli) { + bootstrap::init_tracing(cli.verbose); + if let Err(e) = bootstrap::load_env_files_for_cli(&cli) { logline::error_chain(&e); return ExitCode::FAILURE; } @@ -512,74 +78,6 @@ fn main() -> ExitCode { } } -fn load_env_files_for_cli(cli: &Cli) -> surge_core::error::Result<()> { - match &cli.command { - Commands::Init { .. } | Commands::Lock { .. } | Commands::Sha256 { .. } => Ok(()), - Commands::Setup { dir, .. } => load_env_files_for_scope(dir, &envfile::candidate_paths_for_setup(dir)), - Commands::Install { options, .. } => { - let manifest_path = - commands::install::selected_install_manifest_path(&options.application_manifest, &cli.manifest_path); - load_env_files_for_scope(manifest_path, &envfile::candidate_paths_for_manifest(manifest_path)) - } - Commands::Migrate { dest_manifest, .. } => { - load_env_files_for_scope( - &cli.manifest_path, - &envfile::candidate_paths_for_manifest(&cli.manifest_path), - )?; - load_env_files_for_scope(dest_manifest, &envfile::candidate_paths_for_manifest(dest_manifest)) - } - _ => load_env_files_for_scope( - &cli.manifest_path, - &envfile::candidate_paths_for_manifest(&cli.manifest_path), - ), - } -} - -fn load_env_files_for_setup(dir: &std::path::Path) -> surge_core::error::Result<()> { - let loaded = envfile::load_storage_env_files(dir, &envfile::candidate_paths_for_setup(dir))?; - for path in loaded { - logline::info(&format!("Loaded storage env overrides from {}", path.display())); - } - Ok(()) -} - -fn load_env_files_for_scope(scope: &Path, candidates: &[PathBuf]) -> surge_core::error::Result<()> { - let loaded = envfile::load_storage_env_files(scope, candidates)?; - for path in loaded { - logline::info(&format!("Loaded storage env overrides from {}", path.display())); - } - Ok(()) -} - -/// Check if `installer.yml` exists next to the current executable. -/// This is the auto-detection path for warp-extracted bundles. -fn detect_installer_context() -> Option { - let exe = std::env::current_exe().ok()?; - let dir = exe.parent()?; - let manifest = dir.join("installer.yml"); - if manifest.is_file() { - Some(dir.to_path_buf()) - } else { - None - } -} - -fn handle_parse_error(err: &clap::Error) -> ExitCode { - let is_success = matches!( - err.kind(), - clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion - ); - let rendered = err.to_string(); - let output = rendered.trim_end(); - if is_success { - logline::emit_raw(output); - ExitCode::SUCCESS - } else { - logline::emit_raw_stderr(output); - ExitCode::FAILURE - } -} - async fn run(cli: Cli) -> surge_core::error::Result<()> { let manifest_path = cli.manifest_path; @@ -831,76 +329,3 @@ async fn run(cli: Cli) -> surge_core::error::Result<()> { } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn restore_package_file_requires_installers_flag() { - let Err(err) = Cli::try_parse_from(["surge", "restore", "--package-file", "packages.txt"]) else { - panic!("package-file should require installers mode"); - }; - - assert!(err.to_string().contains("--installers")); - } - - #[test] - fn restore_upload_installers_requires_installers_flag() { - let Err(err) = Cli::try_parse_from(["surge", "restore", "--upload-installers"]) else { - panic!("upload-installers should require installers mode"); - }; - - assert!(err.to_string().contains("--installers")); - } - - #[test] - fn restore_upload_installers_conflicts_with_package_file() { - let Err(err) = Cli::try_parse_from([ - "surge", - "restore", - "--installers", - "--upload-installers", - "--package-file", - "packages.txt", - ]) else { - panic!("upload-installers should conflict with package-file"); - }; - - assert!(err.to_string().contains("--package-file")); - } - - #[test] - fn install_force_flag_parses() { - let cli = Cli::try_parse_from(["surge", "install", "tailscale", "my-node", "--force"]) - .expect("install command with --force should parse"); - - let Commands::Install { options, .. } = cli.command else { - panic!("expected install command"); - }; - - assert!(options.force); - } - - #[test] - fn install_verify_stage_flag_parses() { - let cli = Cli::try_parse_from(["surge", "install", "tailscale", "my-node", "--verify-stage"]) - .expect("install command with --verify-stage should parse"); - - let Commands::Install { options, .. } = cli.command else { - panic!("expected install command"); - }; - - assert!(options.stage_options.verify_stage); - } - - #[test] - fn install_verify_stage_conflicts_with_stage() { - let Err(err) = Cli::try_parse_from(["surge", "install", "tailscale", "my-node", "--stage", "--verify-stage"]) - else { - panic!("--verify-stage should conflict with --stage"); - }; - - assert!(err.to_string().contains("--stage")); - } -} diff --git a/docs/architecture/cleanup-plan.md b/docs/architecture/cleanup-plan.md index fe3ee00..9b92947 100644 --- a/docs/architecture/cleanup-plan.md +++ b/docs/architecture/cleanup-plan.md @@ -39,37 +39,35 @@ These PRs are already merged: - `#67` `refactor(core): split azure storage backend helpers` - `#68` `refactor(core): split gcs storage backend helpers` - `#69` `refactor(bench): split payload generation helpers` +- `#70` `refactor(bench): split runner helpers` ## Active Phase -### `refactor/bench-runner-phase-1` +### `refactor/cli-main-phase-1` Current goal: -- split [`crates/surge-bench/src/runner/mod.rs`](../../crates/surge-bench/src/runner/mod.rs) +- split [`crates/surge-cli/src/main.rs`](../../crates/surge-cli/src/main.rs) into: - - `runner/microbench.rs` - - `runner/installer.rs` - - `runner/update.rs` - - `runner/fs_compare.rs` - - `runner/manifest.rs` + - `cli.rs` + - `bootstrap.rs` Current checkpoint: - the leaf modules have been created -- the root module has been reduced to shared constants, timing, and public reexports -- targeted compile of `surge-bench` passes -- focused `surge-bench` tests pass -- focused `surge-bench` clippy passes -- the runner baseline entry has been removed +- the root module has been reduced to runtime entrypoint and command dispatch +- targeted compile of `surge-cli` passes +- focused `surge-cli` tests pass +- focused `surge-cli` clippy passes +- the main baseline entry has been removed - the full pre-push suite passes on the branch Exit criteria: -- `cargo test -p surge-bench` passes -- `cargo clippy -p surge-bench --all-targets --all-features -- -D warnings -W clippy::pedantic` passes +- `cargo test -p surge-cli` passes +- `cargo clippy -p surge-cli --all-targets --all-features -- -D warnings -W clippy::pedantic` passes - `./scripts/check-maintainability.sh` reports the file below the target so the - runner baseline entry can be removed + main baseline entry can be removed - the full pre-push suite passes - the PR is merged with squash, local cleanup is done, and merged-`main` CI is green @@ -77,15 +75,13 @@ Exit criteria: These are the remaining planned PRs from the original Rust-first campaign. -### 1. `refactor/bench-runner-phase-1` +### 1. `refactor/cli-main-phase-1` -- split [`crates/surge-bench/src/runner/mod.rs`](../../crates/surge-bench/src/runner/mod.rs) +- split [`crates/surge-cli/src/main.rs`](../../crates/surge-cli/src/main.rs) into focused modules for: - - microbench archive, hash, diff, and synthetic installer helpers - - real installer scenario execution helpers - - real publish/update scenario helpers - - filesystem comparison and size helpers - - manifest-writing helpers + - clap command and argument definitions + - tracing/bootstrap and env-loading helpers + - keep `main` and runtime dispatch at the current path ### 2. `refactor/maintainability-phase-2` @@ -103,7 +99,6 @@ be decomposed to fully retire the baseline. - [`crates/surge-cli/src/commands/install/mod.rs`](../../crates/surge-cli/src/commands/install/mod.rs) - [`crates/surge-cli/src/commands/install/remote.rs`](../../crates/surge-cli/src/commands/install/remote.rs) -- [`crates/surge-cli/src/main.rs`](../../crates/surge-cli/src/main.rs) - [`crates/surge-installer-ui/src/app.rs`](../../crates/surge-installer-ui/src/app.rs) ### Core surfaces diff --git a/docs/architecture/maintainability-baseline.txt b/docs/architecture/maintainability-baseline.txt index 7a61af6..7ed08fa 100644 --- a/docs/architecture/maintainability-baseline.txt +++ b/docs/architecture/maintainability-baseline.txt @@ -2,6 +2,5 @@ # Format: 813 crates/surge-cli/src/commands/install/mod.rs 1860 crates/surge-cli/src/commands/install/remote.rs -835 crates/surge-cli/src/main.rs 782 crates/surge-core/src/releases/delta.rs 760 crates/surge-installer-ui/src/app.rs