diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.rs | 252 | ||||
| -rw-r--r-- | src/app/archive.rs (renamed from src/archive.rs) | 51 | ||||
| -rw-r--r-- | src/app/chat.rs (renamed from src/chat.rs) | 16 | ||||
| -rw-r--r-- | src/app/cli.rs | 329 | ||||
| -rw-r--r-- | src/app/files.rs (renamed from src/files.rs) | 115 | ||||
| -rw-r--r-- | src/app/images.rs (renamed from src/images.rs) | 14 | ||||
| -rw-r--r-- | src/app/process.rs (renamed from src/process.rs) | 12 | ||||
| -rw-r--r-- | src/app/project.rs (renamed from src/project.rs) | 93 | ||||
| -rw-r--r-- | src/app/release.rs (renamed from src/release.rs) | 16 | ||||
| -rw-r--r-- | src/app/render.rs (renamed from src/render.rs) | 44 | ||||
| -rw-r--r-- | src/main.rs | 324 |
11 files changed, 802 insertions, 464 deletions
diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..d655774 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,252 @@ +// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai> +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +#![doc = "Rust commands for personal web infrastructure."] + +mod archive; +mod chat; +mod cli; +mod files; +mod images; +mod process; +mod project; +mod release; +mod render; + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use crate::app::cli::{Action, Cli}; +use anyhow::Result; + +use crate::app::project::{Mode, Project}; + +#[doc = "Runs the checked command-line entry point and returns its process status."] +#[must_use] +pub fn run() -> ExitCode { + let arguments = match Cli::try_parse() { + Ok(arguments) => arguments, + Err(error) => { + let status = if error.use_stderr() { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + }; + drop(error.print()); + return status; + } + }; + match execute(arguments) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + drop(process::error(format_args!("Error: {error:#}"))); + ExitCode::FAILURE + } + } +} + +fn execute(arguments: Cli) -> Result<()> { + if let Action::Restore { + archive, + destination, + } = &arguments.command + { + return archive::restore(&absolute(archive)?, &absolute(destination)?); + } + let project = Project::discover(arguments.root.as_deref())?; + if let Action::Chat { + init, + reset_owner, + owner_fingerprint, + } = arguments.command + { + return chat(&project, init, reset_owner.as_deref(), owner_fingerprint); + } + + let _operation = project.lock()?; + match arguments.command { + Action::Dev { port } => { + let mut settings = project.settings()?; + settings.mode = Mode::Local; + if let Some(selected_port) = port { + settings.port = selected_port; + } + images::prepare(&project, &mut settings)?; + project.up(&settings) + } + Action::Up { + production, + local, + port, + online, + } => { + let mut settings = project.settings()?; + if production { + settings.mode = Mode::Production; + } + if local { + settings.mode = Mode::Local; + } + if let Some(selected_port) = port { + settings.port = selected_port; + } + if online { + settings.online = true; + } + images::prepare(&project, &mut settings)?; + project.up(&settings) + } + Action::Config { production, port } => { + let mut settings = project.settings()?; + settings.mode = if production { + Mode::Production + } else { + Mode::Local + }; + if let Some(selected_port) = port { + settings.port = selected_port; + } + stack_config::initialize_public(&project.root)?; + render::prepare(&project, &settings)?; + process::line(format_args!( + "Public configuration: {}", + render::directory(&project, &settings).display() + )) + } + Action::Status => project.status(&project.settings()?), + Action::Down => project.down(&project.settings()?), + Action::Backup { + destination, + include_images, + recipient, + key, + } => { + let output = destination.map_or_else( + || Ok(project.root.join("var/exports")), + |path| absolute(&path), + )?; + let configuration = stack_config::Config::load(&project.root)?; + let selected_recipient = + recipient.unwrap_or(configuration.value("BACKUP_RECIPIENT")?.to_owned()); + archive::backup( + &project, + &output, + include_images, + &selected_recipient, + key.as_deref(), + ) + } + Action::Release { destination } => { + let output = destination + .map_or_else(|| Ok(project.root.join("dist")), |path| absolute(&path))?; + release::create(&project, &output) + } + Action::Activate { archive } => release::activate(&absolute(&archive)?, &project), + Action::Check => check(&project), + Action::Licenses => licenses(&project), + Action::Chat { .. } => anyhow::bail!("Chat action was not handled"), + Action::Restore { .. } => anyhow::bail!("Restore action was not handled"), + } +} + +fn chat( + project: &Project, + init: bool, + reset_owner: Option<&str>, + owner_fingerprint: bool, +) -> Result<()> { + if project.settings()?.mode == Mode::Production { + return chat::admin(project, init, reset_owner, owner_fingerprint); + } + let mut command = project.command("cargo")?; + command + .args(["run", "--locked", "-p", "stack-chat-server", "--", "--root"]) + .arg(&project.root); + if init { + command.arg("--init"); + } + if let Some(fingerprint) = reset_owner { + command.arg("--reset-owner").arg(fingerprint); + } + if owner_fingerprint { + command.arg("--owner-fingerprint"); + } + process::run(&mut command) +} + +fn absolute(path: &Path) -> Result<PathBuf> { + Ok(if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }) +} + +fn licenses(project: &Project) -> Result<()> { + process::run(project.command("cargo")?.args([ + "about", + "generate", + "--workspace", + "--locked", + "about.hbs", + "--output-file", + "web/THIRD-PARTY-LICENSES.html", + ])) +} + +fn check(project: &Project) -> Result<()> { + process::run(project.command("sh")?.arg("infra/chat/build-typescript.sh"))?; + process::run( + project + .command("cargo")? + .args(["fmt", "--all", "--", "--check"]), + )?; + process::run(project.command("cargo")?.args([ + "clippy", + "--locked", + "--workspace", + "--all-targets", + "--all-features", + "--", + "-D", + "warnings", + ]))?; + process::run(project.command("cargo")?.args([ + "test", + "--locked", + "--workspace", + "--all-targets", + "--all-features", + ]))?; + process::run( + project + .command("cargo")? + .env("RUSTDOCFLAGS", "-D warnings") + .args([ + "doc", + "--locked", + "--workspace", + "--no-deps", + "--document-private-items", + ]), + )?; + process::run(project.command("cargo")?.args([ + "deny", + "--workspace", + "--all-features", + "check", + ]))?; + licenses(project) +} diff --git a/src/archive.rs b/src/app/archive.rs index 1e58992..2a78644 100644 --- a/src/archive.rs +++ b/src/app/archive.rs @@ -21,8 +21,8 @@ use std::process::Command; use anyhow::{Context, Result, ensure}; use serde_json::Value; -use crate::project::{Mode, Project, Settings, field}; -use crate::{files, process}; +use crate::app::project::{Mode, Project, Settings, field}; +use crate::app::{files, process}; #[derive(Debug)] struct Paused<'a> { @@ -59,7 +59,7 @@ impl Drop for Paused<'_> { } } -pub(crate) fn containers(project: &Project, settings: &Settings) -> Result<Vec<Value>> { +pub(super) fn containers(project: &Project, settings: &Settings) -> Result<Vec<Value>> { let records = project.inspect_containers(settings)?; let actual: BTreeSet<_> = records .iter() @@ -173,11 +173,11 @@ fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result< .find(|path| path.is_file()) .context("Build a release locally before exporting so the backup includes a portable binary")?; fs::copy(executable, stage.join("bin/stack"))?; - crate::chat::copy_artifacts(project, stage)?; + crate::app::chat::copy_artifacts(project, stage)?; Ok(()) } -pub(crate) fn backup( +pub(super) fn backup( project: &Project, destination: &Path, with_images: bool, @@ -280,7 +280,7 @@ fn backup_inner( } } if with_images { - crate::images::save(project, records, &stage.join("var"))?; + crate::app::images::save(project, records, &stage.join("var"))?; } files::manifest(&stage, "backup")?; let plain = temp.join("stack.tar.gz"); @@ -331,7 +331,7 @@ fn backup_inner( process::line(format_args!("Encrypted export: {}", output.display())) } -pub(crate) fn restore(archive: &Path, destination: &Path) -> Result<()> { +pub(super) fn restore(archive: &Path, destination: &Path) -> Result<()> { ensure!(!destination.exists(), "Restore destination must not exist"); let parent = destination .parent() @@ -393,8 +393,8 @@ mod tests { use anyhow::Result; - use crate::files; - use crate::project::{Mode, Project}; + use crate::app::files; + use crate::app::project::{Mode, Project}; #[test] fn chat_snapshot_is_consistent_and_private() -> Result<()> { @@ -413,10 +413,12 @@ mod tests { let restored = rusqlite::Connection::open(&snapshot)?; let count: i64 = restored.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?; - assert_eq!(count, 1); - assert_eq!( - std::os::unix::fs::PermissionsExt::mode(&fs::metadata(snapshot)?.permissions()) & 0o777, - 0o600 + anyhow::ensure!((count) == (1), "Test invariant failed"); + anyhow::ensure!( + (std::os::unix::fs::PermissionsExt::mode(&fs::metadata(snapshot)?.permissions()) + & 0o777) + == (0o600), + "Test invariant failed" ); transaction.rollback()?; Ok(()) @@ -437,12 +439,18 @@ mod tests { files::pack(&source, &archive)?; let destination = workspace.path().join("restored"); super::restore(&archive, &destination)?; - assert_eq!(Project::at(&destination)?.settings()?.mode, Mode::Local); - assert_eq!( - fs::read_to_string(destination.join(".env"))?, - "SEARXNG_SECRET=private\n" + anyhow::ensure!( + (Project::at(&destination)?.settings()?.mode) == (Mode::Local), + "Test invariant failed" + ); + anyhow::ensure!( + (fs::read_to_string(destination.join(".env"))?) == ("SEARXNG_SECRET=private\n"), + "Test invariant failed" + ); + anyhow::ensure!( + super::restore(&archive, &destination).is_err(), + "Test invariant failed" ); - assert!(super::restore(&archive, &destination).is_err()); Ok(()) } @@ -457,8 +465,11 @@ mod tests { let archive = workspace.path().join("backup.tar.gz"); files::pack(&source, &archive)?; let destination = workspace.path().join("restored"); - assert!(super::restore(&archive, &destination).is_err()); - assert!(!destination.exists()); + anyhow::ensure!( + super::restore(&archive, &destination).is_err(), + "Test invariant failed" + ); + anyhow::ensure!(!destination.exists(), "Test invariant failed"); Ok(()) } } diff --git a/src/chat.rs b/src/app/chat.rs index 0739f22..1647018 100644 --- a/src/chat.rs +++ b/src/app/chat.rs @@ -13,8 +13,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. -use crate::project::{Mode, Project, Settings}; -use crate::{files, process}; +use crate::app::project::{Mode, Project, Settings}; +use crate::app::{files, process}; use anyhow::{Context, Result, ensure}; use stack_config::Config; use std::fs; @@ -22,7 +22,7 @@ use std::net::IpAddr; use std::path::Path; use std::process::Command; -pub(crate) fn web_bind(configuration: &Config) -> Result<String> { +pub(super) fn web_bind(configuration: &Config) -> Result<String> { let address: IpAddr = configuration.value("WEB_BIND")?.parse()?; Ok(match address { IpAddr::V4(_) => address.to_string(), @@ -30,7 +30,7 @@ pub(crate) fn web_bind(configuration: &Config) -> Result<String> { }) } -pub(crate) fn environment( +pub(super) fn environment( command: &mut Command, configuration: &Config, settings: &Settings, @@ -49,7 +49,7 @@ pub(crate) fn environment( Ok(()) } -pub(crate) fn build(project: &Project) -> Result<()> { +pub(super) fn build(project: &Project) -> Result<()> { process::run(project.command("sh")?.arg("infra/chat/build-typescript.sh"))?; process::run(project.command("sh")?.arg("infra/chat/build-native.sh"))?; let wasm_build = if project @@ -66,7 +66,7 @@ pub(crate) fn build(project: &Project) -> Result<()> { package(project) } -pub(crate) fn package(project: &Project) -> Result<()> { +pub(super) fn package(project: &Project) -> Result<()> { let directory = project.root.join("bin/chat"); fs::create_dir_all(&directory)?; for (source, target) in [ @@ -108,7 +108,7 @@ pub(crate) fn package(project: &Project) -> Result<()> { Ok(()) } -pub(crate) fn copy_artifacts(project: &Project, destination: &Path) -> Result<()> { +pub(super) fn copy_artifacts(project: &Project, destination: &Path) -> Result<()> { let artifacts = project.root.join("bin/chat"); ensure!( artifacts.join("server").is_file() @@ -119,7 +119,7 @@ pub(crate) fn copy_artifacts(project: &Project, destination: &Path) -> Result<() files::copy_tree(&artifacts, &destination.join("bin/chat")) } -pub(crate) fn admin( +pub(super) fn admin( project: &Project, init: bool, reset_owner: Option<&str>, diff --git a/src/app/cli.rs b/src/app/cli.rs new file mode 100644 index 0000000..1830ec1 --- /dev/null +++ b/src/app/cli.rs @@ -0,0 +1,329 @@ +// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai> +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +use clap::{Arg, ArgAction, ArgMatches, Command, error::ErrorKind}; +use std::ffi::OsString; +use std::path::PathBuf; + +#[derive(Debug)] + +pub(super) struct Cli { + pub(super) root: Option<PathBuf>, + + pub(super) command: Action, +} + +#[derive(Debug)] +pub(super) enum Action { + Chat { + init: bool, + + reset_owner: Option<String>, + + owner_fingerprint: bool, + }, + + Dev { + port: Option<u16>, + }, + + Up { + production: bool, + + local: bool, + + port: Option<u16>, + + online: bool, + }, + + Config { + production: bool, + + port: Option<u16>, + }, + + Status, + + Down, + + Backup { + destination: Option<PathBuf>, + + include_images: bool, + + recipient: Option<String>, + + key: Option<PathBuf>, + }, + + Restore { + archive: PathBuf, + destination: PathBuf, + }, + + Check, + + Release { + destination: Option<PathBuf>, + }, + + Activate { + archive: PathBuf, + }, + + Licenses, +} + +fn flag(name: &'static str) -> Arg { + Arg::new(name).long(name).action(ArgAction::SetTrue) +} + +fn path(name: &'static str) -> Arg { + Arg::new(name).value_parser(clap::builder::PathBufValueParser::new()) +} + +fn port() -> Arg { + Arg::new("port") + .long("port") + .value_parser(clap::builder::RangedU64ValueParser::<u16>::new()) +} + +fn development() -> [Command; 3] { + [ + Command::new("dev") + .about("Start the local development services") + .arg(port()), + Command::new("up") + .about("Start or update services in the selected mode") + .args([ + flag("production").conflicts_with("local"), + flag("local"), + port(), + flag("online"), + ]), + Command::new("config") + .about("Render public Caddy, website, and cgit configuration without starting services") + .args([flag("production"), port()]), + ] +} + +fn archives() -> [Command; 4] { + [ + Command::new("backup") + .about("Create a GPG-encrypted application export") + .args([ + path("destination"), + flag("include-images"), + Arg::new("recipient").long("recipient"), + path("key").long("key"), + ]), + Command::new("restore") + .about("Decrypt, validate, and restore into a new directory without starting services") + .args([ + path("archive").required(true), + path("destination").required(true), + ]), + Command::new("release") + .about("Check and build a portable production release locally") + .arg(path("destination")), + Command::new("activate") + .about("Install a prebuilt release without compiling on the server") + .arg(path("archive").required(true)), + ] +} + +fn command() -> Command { + Command::new("stack") + .version(env!("CARGO_PKG_VERSION")) + .about("Develop, deploy, and back up the personal web stack") + .subcommand_required(true) + .arg_required_else_help(true) + .arg( + path("root").long("root").global(true).help( + "Application root; otherwise discovered from the working directory or binary", + ), + ) + .subcommand( + Command::new("chat") + .about("Run experimental local chat or administer its packaged deployment") + .args([ + flag("init").conflicts_with_all(["reset-owner", "owner-fingerprint"]), + Arg::new("reset-owner") + .long("reset-owner") + .value_name("FINGERPRINT") + .conflicts_with("owner-fingerprint") + .help( + "Permanently clear owner registration and chat history after key loss", + ), + flag("owner-fingerprint") + .help("Print the registered owner's public fingerprint"), + ]), + ) + .subcommands(development()) + .subcommands(archives()) + .subcommands([ + Command::new("status").about("Show service status"), + Command::new("down").about("Stop services while retaining data"), + Command::new("check") + .about("Run formatting, strict lints, tests, documentation, and dependency checks"), + Command::new("licenses") + .about("Generate the third-party license page with GNU license names"), + ]) +} + +fn required<T: Clone + Send + Sync + 'static>( + matches: &mut ArgMatches, + name: &str, +) -> Result<T, clap::Error> { + matches.remove_one(name).ok_or_else(|| { + clap::Error::raw( + ErrorKind::MissingRequiredArgument, + format!("Missing {name}"), + ) + }) +} + +fn action(name: &str, args: &mut ArgMatches) -> Result<Action, clap::Error> { + Ok(match name { + "chat" => Action::Chat { + init: args.get_flag("init"), + reset_owner: args.remove_one("reset-owner"), + owner_fingerprint: args.get_flag("owner-fingerprint"), + }, + "dev" => Action::Dev { + port: args.remove_one("port"), + }, + "up" => Action::Up { + production: args.get_flag("production"), + local: args.get_flag("local"), + port: args.remove_one("port"), + online: args.get_flag("online"), + }, + "config" => Action::Config { + production: args.get_flag("production"), + port: args.remove_one("port"), + }, + "status" => Action::Status, + "down" => Action::Down, + "backup" => Action::Backup { + destination: args.remove_one("destination"), + include_images: args.get_flag("include-images"), + recipient: args.remove_one("recipient"), + key: args.remove_one("key"), + }, + "restore" => Action::Restore { + archive: required(args, "archive")?, + destination: required(args, "destination")?, + }, + "check" => Action::Check, + "release" => Action::Release { + destination: args.remove_one("destination"), + }, + "activate" => Action::Activate { + archive: required(args, "archive")?, + }, + "licenses" => Action::Licenses, + _ => { + return Err(clap::Error::raw( + ErrorKind::InvalidSubcommand, + "Unknown command", + )); + } + }) +} + +impl Cli { + pub(super) fn try_parse() -> Result<Self, clap::Error> { + Self::try_parse_from(std::env::args_os()) + } + + fn try_parse_from( + arguments: impl IntoIterator<Item = impl Into<OsString> + Clone>, + ) -> Result<Self, clap::Error> { + let mut matches = command().try_get_matches_from(arguments)?; + let root = matches.remove_one("root"); + let (name, mut arguments) = matches.remove_subcommand().ok_or_else(|| { + clap::Error::raw(ErrorKind::MissingSubcommand, "Select a stack command") + })?; + Ok(Self { + root, + command: action(&name, &mut arguments)?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::{Action, Cli, command}; + use anyhow::Result; + use std::path::PathBuf; + + #[test] + fn checked_command_schema() { + command().debug_assert(); + } + + #[test] + fn modes_conflicts_and_required_paths() -> Result<()> { + let parsed = Cli::try_parse_from([ + "stack", + "up", + "--root", + "/tmp/stack", + "--local", + "--port", + "8180", + "--online", + ])?; + anyhow::ensure!( + (parsed.root) == (Some(PathBuf::from("/tmp/stack"))), + "Test invariant failed" + ); + anyhow::ensure!( + matches!( + parsed.command, + Action::Up { + production: false, + local: true, + port: Some(8180), + online: true + } + ), + "Test invariant failed" + ); + for args in [ + vec!["stack", "up", "--local", "--production"], + vec!["stack", "dev", "--port", "65536"], + vec!["stack", "chat", "--init", "--reset-owner", "abc"], + vec![ + "stack", + "chat", + "--reset-owner", + "abc", + "--owner-fingerprint", + ], + vec!["stack", "restore", "archive"], + vec!["stack", "activate"], + ] { + anyhow::ensure!(Cli::try_parse_from(args).is_err(), "Test invariant failed"); + } + anyhow::ensure!( + matches!(Cli::try_parse_from(["stack", "restore", "export.gpg", "new-root"])?.command, Action::Restore { archive, destination } if archive == std::path::Path::new("export.gpg") && destination == std::path::Path::new("new-root")), + "Test invariant failed" + ); + Ok(()) + } +} diff --git a/src/files.rs b/src/app/files.rs index 81c57b0..2c612a9 100644 --- a/src/files.rs +++ b/src/app/files.rs @@ -27,15 +27,18 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use walkdir::WalkDir; -pub(crate) const SOURCE: &[&str] = &[ +pub(super) const SOURCE: &[&str] = &[ "Cargo.toml", "Cargo.lock", + "clippy.toml", + "eslint.config.ts", "package.json", "package-lock.json", "tsconfig.json", "tsconfig.browser.json", "tsconfig.worker.json", "tsconfig.tests.json", + "tsconfig.tools.json", "rust-toolchain.toml", ".cargo", ".gitignore", @@ -56,16 +59,16 @@ pub(crate) const SOURCE: &[&str] = &[ ]; #[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct Manifest { - pub(crate) format: u32, - pub(crate) kind: String, - pub(crate) version: String, - pub(crate) created_utc: String, - pub(crate) files: BTreeMap<String, String>, +#[serde(crate = "serde", deny_unknown_fields)] +pub(super) struct Manifest { + pub(super) format: u32, + pub(super) kind: String, + pub(super) version: String, + pub(super) created_utc: String, + pub(super) files: BTreeMap<String, String>, } -pub(crate) fn private_dir(path: &Path) -> Result<()> { +pub(super) fn private_dir(path: &Path) -> Result<()> { fs::DirBuilder::new() .recursive(true) .mode(0o700) @@ -74,7 +77,7 @@ pub(crate) fn private_dir(path: &Path) -> Result<()> { Ok(()) } -pub(crate) fn write(path: &Path, data: &[u8], mode: u32) -> Result<()> { +pub(super) fn write(path: &Path, data: &[u8], mode: u32) -> Result<()> { let parent = path.parent().context("File has no parent directory")?; fs::create_dir_all(parent)?; let mut pending = tempfile::NamedTempFile::new_in(parent)?; @@ -87,11 +90,11 @@ pub(crate) fn write(path: &Path, data: &[u8], mode: u32) -> Result<()> { Ok(()) } -pub(crate) fn hash_bytes(bytes: &[u8]) -> String { +pub(super) fn hash_bytes(bytes: &[u8]) -> String { hex::encode(Sha256::digest(bytes)) } -pub(crate) fn hash_file(path: &Path) -> Result<String> { +pub(super) fn hash_file(path: &Path) -> Result<String> { let mut file = File::open(path).with_context(|| format!("Cannot read {}", path.display()))?; let mut digest = Sha256::new(); let mut buffer = [0; 8192]; @@ -105,7 +108,7 @@ pub(crate) fn hash_file(path: &Path) -> Result<String> { Ok(hex::encode(digest.finalize())) } -pub(crate) fn relative(path: &Path, base: &Path) -> Result<String> { +pub(super) fn relative(path: &Path, base: &Path) -> Result<String> { Ok(path .strip_prefix(base)? .to_str() @@ -113,7 +116,7 @@ pub(crate) fn relative(path: &Path, base: &Path) -> Result<String> { .to_owned()) } -pub(crate) fn regular_tree(path: &Path) -> Result<()> { +pub(super) fn regular_tree(path: &Path) -> Result<()> { for item in WalkDir::new(path).follow_links(false) { let entry = item?; ensure!( @@ -125,7 +128,7 @@ pub(crate) fn regular_tree(path: &Path) -> Result<()> { Ok(()) } -pub(crate) fn copy_tree(source: &Path, destination: &Path) -> Result<()> { +pub(super) fn copy_tree(source: &Path, destination: &Path) -> Result<()> { regular_tree(source)?; if source.is_file() { let parent = destination.parent().context("File has no parent")?; @@ -148,7 +151,7 @@ pub(crate) fn copy_tree(source: &Path, destination: &Path) -> Result<()> { Ok(()) } -pub(crate) fn copy_source(root: &Path, destination: &Path) -> Result<()> { +pub(super) fn copy_source(root: &Path, destination: &Path) -> Result<()> { for name in SOURCE { let source = root.join(name); if source.exists() { @@ -158,7 +161,7 @@ pub(crate) fn copy_source(root: &Path, destination: &Path) -> Result<()> { Ok(()) } -pub(crate) fn inventory(root: &Path) -> Result<BTreeMap<String, String>> { +pub(super) fn inventory(root: &Path) -> Result<BTreeMap<String, String>> { let mut files = BTreeMap::new(); for item in WalkDir::new(root).follow_links(false) { let entry = item?; @@ -173,7 +176,7 @@ pub(crate) fn inventory(root: &Path) -> Result<BTreeMap<String, String>> { Ok(files) } -pub(crate) fn manifest(root: &Path, kind: &str) -> Result<()> { +pub(super) fn manifest(root: &Path, kind: &str) -> Result<()> { let record = Manifest { format: 2, kind: kind.to_owned(), @@ -188,7 +191,7 @@ pub(crate) fn manifest(root: &Path, kind: &str) -> Result<()> { ) } -pub(crate) fn verify(root: &Path) -> Result<Manifest> { +pub(super) fn verify(root: &Path) -> Result<Manifest> { let record: Manifest = serde_json::from_reader(File::open(root.join("manifest.json"))?)?; ensure!( record.format == 2, @@ -201,7 +204,7 @@ pub(crate) fn verify(root: &Path) -> Result<Manifest> { Ok(record) } -pub(crate) fn pack(root: &Path, archive: &Path) -> Result<()> { +pub(super) fn pack(root: &Path, archive: &Path) -> Result<()> { regular_tree(root)?; let file = OpenOptions::new() .write(true) @@ -216,7 +219,7 @@ pub(crate) fn pack(root: &Path, archive: &Path) -> Result<()> { Ok(()) } -pub(crate) fn member_path(path: &Path, destination: &Path) -> Result<PathBuf> { +pub(super) fn member_path(path: &Path, destination: &Path) -> Result<PathBuf> { let mut parts = path.components(); ensure!( parts.next() == Some(Component::Normal("stack".as_ref())), @@ -232,7 +235,7 @@ pub(crate) fn member_path(path: &Path, destination: &Path) -> Result<PathBuf> { Ok(target) } -pub(crate) fn extract(archive: &Path, destination: &Path) -> Result<()> { +pub(super) fn extract(archive: &Path, destination: &Path) -> Result<()> { private_dir(destination)?; let mut reader = tar::Archive::new(GzDecoder::new(File::open(archive)?)); let mut directories = Vec::new(); @@ -276,15 +279,20 @@ mod tests { use super::{copy_source, extract, manifest, member_path, pack, verify}; #[test] - fn rejects_paths_outside_archive_root() { + fn rejects_paths_outside_archive_root() -> Result<()> { let destination = Path::new("/restore"); for name in ["/etc/passwd", "../escape", "stack/../escape", "other/file"] { - assert!(member_path(Path::new(name), destination).is_err()); + anyhow::ensure!( + member_path(Path::new(name), destination).is_err(), + "Test invariant failed" + ); } - assert_eq!( - member_path(Path::new("stack/web/index.html"), destination).ok(), - Some(destination.join("web/index.html")) + anyhow::ensure!( + (member_path(Path::new("stack/web/index.html"), destination).ok()) + == (Some(destination.join("web/index.html"))), + "Test invariant failed" ); + Ok(()) } #[test] @@ -292,12 +300,12 @@ mod tests { let root = tempfile::tempdir()?; fs::write(root.path().join("public.txt"), "original")?; manifest(root.path(), "release")?; - assert!(verify(root.path()).is_ok()); + anyhow::ensure!(verify(root.path()).is_ok(), "Test invariant failed"); fs::write(root.path().join("public.txt"), "changed")?; - assert!(verify(root.path()).is_err()); + anyhow::ensure!(verify(root.path()).is_err(), "Test invariant failed"); fs::write(root.path().join("public.txt"), "original")?; fs::write(root.path().join("unexpected.txt"), "extra")?; - assert!(verify(root.path()).is_err()); + anyhow::ensure!(verify(root.path()).is_err(), "Test invariant failed"); Ok(()) } @@ -306,13 +314,37 @@ mod tests { let source = tempfile::tempdir()?; let target = tempfile::tempdir()?; fs::write(source.path().join("Cargo.toml"), "source")?; + for name in ["clippy.toml", "eslint.config.ts", "tsconfig.tools.json"] { + fs::write(source.path().join(name), "public lint configuration")?; + } + fs::create_dir_all(source.path().join("node_modules"))?; + fs::write( + source.path().join("node_modules/private-fixture"), + "private", + )?; fs::write(source.path().join(".env"), "private")?; fs::create_dir(source.path().join("var"))?; fs::write(source.path().join("var/private.key"), "private")?; copy_source(source.path(), target.path())?; - assert!(target.path().join("Cargo.toml").is_file()); - assert!(!target.path().join(".env").exists()); - assert!(!target.path().join("var").exists()); + anyhow::ensure!( + target.path().join("Cargo.toml").is_file(), + "Test invariant failed" + ); + anyhow::ensure!( + !target.path().join(".env").exists(), + "Test invariant failed" + ); + anyhow::ensure!(!target.path().join("var").exists(), "Test invariant failed"); + for name in ["clippy.toml", "eslint.config.ts", "tsconfig.tools.json"] { + anyhow::ensure!( + target.path().join(name).is_file(), + "Required lint configuration omitted from export" + ); + } + anyhow::ensure!( + !target.path().join("node_modules").exists(), + "Installed development packages entered export" + ); Ok(()) } @@ -327,9 +359,15 @@ mod tests { pack(&source, &archive)?; let restored = workspace.path().join("restored"); extract(&archive, &restored)?; - assert!(verify(&restored).is_ok()); - assert_eq!(fs::read_to_string(restored.join(".env"))?, "secret"); - assert!(extract(&archive, &restored).is_err()); + anyhow::ensure!(verify(&restored).is_ok(), "Test invariant failed"); + anyhow::ensure!( + (fs::read_to_string(restored.join(".env"))?) == ("secret"), + "Test invariant failed" + ); + anyhow::ensure!( + extract(&archive, &restored).is_err(), + "Test invariant failed" + ); Ok(()) } @@ -337,7 +375,10 @@ mod tests { fn rejects_symbolic_links() -> Result<()> { let root = tempfile::tempdir()?; std::os::unix::fs::symlink("/etc/passwd", root.path().join("link"))?; - assert!(super::regular_tree(root.path()).is_err()); + anyhow::ensure!( + super::regular_tree(root.path()).is_err(), + "Test invariant failed" + ); Ok(()) } } diff --git a/src/images.rs b/src/app/images.rs index 30019ca..fac7aa7 100644 --- a/src/images.rs +++ b/src/app/images.rs @@ -21,11 +21,11 @@ use anyhow::{Context, Result, ensure}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::project::{Mode, Project, Settings, field}; -use crate::{files, process}; +use crate::app::project::{Mode, Project, Settings, field}; +use crate::app::{files, process}; #[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] +#[serde(crate = "serde", deny_unknown_fields)] struct Image { reference: String, architecture: String, @@ -34,7 +34,7 @@ struct Image { } #[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] +#[serde(crate = "serde", deny_unknown_fields)] struct ImageLock { format: u32, platform: String, @@ -51,7 +51,7 @@ fn inspect(project: &Project, name: &str) -> Result<Value> { .context("Podman returned no image") } -pub(crate) fn save(project: &Project, containers: &[Value], destination: &Path) -> Result<()> { +pub(super) fn save(project: &Project, containers: &[Value], destination: &Path) -> Result<()> { fs::create_dir_all(destination)?; let platform = project.platform()?; let mut services = BTreeMap::new(); @@ -146,7 +146,7 @@ fn save_inner( process::run(&mut command) } -pub(crate) fn prepare(project: &Project, settings: &mut Settings) -> Result<()> { +pub(super) fn prepare(project: &Project, settings: &mut Settings) -> Result<()> { let path = project.root.join("var/image-lock.json"); if settings.online || !path.is_file() { return Ok(()); @@ -201,7 +201,7 @@ pub(crate) fn prepare(project: &Project, settings: &mut Settings) -> Result<()> Ok(()) } -pub(crate) fn configured(project: &Project, settings: &Settings) -> Result<Vec<Value>> { +pub(super) fn configured(project: &Project, settings: &Settings) -> Result<Vec<Value>> { let config = project.configuration(settings)?; let mut records = Vec::new(); for (service, value) in config.services { diff --git a/src/process.rs b/src/app/process.rs index eb48bc8..02f6a6b 100644 --- a/src/process.rs +++ b/src/app/process.rs @@ -19,7 +19,7 @@ use std::process::{Command, Output}; use anyhow::{Context, Result, bail}; -pub(crate) fn capture(command: &mut Command) -> Result<Output> { +pub(super) fn capture(command: &mut Command) -> Result<Output> { let output = command.output().with_context(|| { format!( "Could not start {}", @@ -37,11 +37,11 @@ pub(crate) fn capture(command: &mut Command) -> Result<Output> { Ok(output) } -pub(crate) fn text(command: &mut Command) -> Result<String> { +pub(super) fn text(command: &mut Command) -> Result<String> { String::from_utf8(capture(command)?.stdout).context("Command returned non-UTF-8 output") } -pub(crate) fn run(command: &mut Command) -> Result<()> { +pub(super) fn run(command: &mut Command) -> Result<()> { let status = command.status().with_context(|| { format!( "Could not start {}", @@ -57,14 +57,14 @@ pub(crate) fn run(command: &mut Command) -> Result<()> { Ok(()) } -pub(crate) fn available(command: &mut Command) -> bool { +pub(super) fn available(command: &mut Command) -> bool { command.output().is_ok_and(|output| output.status.success()) } -pub(crate) fn line(message: fmt::Arguments<'_>) -> Result<()> { +pub(super) fn line(message: fmt::Arguments<'_>) -> Result<()> { writeln!(io::stdout().lock(), "{message}").context("Could not write command output") } -pub(crate) fn error(message: fmt::Arguments<'_>) -> io::Result<()> { +pub(super) fn error(message: fmt::Arguments<'_>) -> io::Result<()> { writeln!(io::stderr().lock(), "{message}") } diff --git a/src/project.rs b/src/app/project.rs index 57185b1..ac55ff3 100644 --- a/src/project.rs +++ b/src/app/project.rs @@ -27,38 +27,38 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use stack_config::Config; -use crate::{files, process}; +use crate::app::{files, process}; #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "lowercase")] -pub(crate) enum Mode { +#[serde(crate = "serde", rename_all = "lowercase")] +pub(super) enum Mode { #[default] Local, Production, } #[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct Settings { - pub(crate) mode: Mode, - pub(crate) port: u16, - pub(crate) project: String, +#[serde(crate = "serde", deny_unknown_fields)] +pub(super) struct Settings { + pub(super) mode: Mode, + pub(super) port: u16, + pub(super) project: String, #[serde(default)] - pub(crate) online: bool, + pub(super) online: bool, } #[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct Configuration { - pub(crate) format: u32, - pub(crate) services: BTreeMap<String, Service>, +#[serde(crate = "serde", deny_unknown_fields)] +pub(super) struct Configuration { + pub(super) format: u32, + pub(super) services: BTreeMap<String, Service>, } #[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct Service { - pub(crate) image: String, - pub(crate) build: Option<String>, +#[serde(crate = "serde", deny_unknown_fields)] +pub(super) struct Service { + pub(super) image: String, + pub(super) build: Option<String>, #[serde(default)] gateway: bool, #[serde(default)] @@ -74,7 +74,7 @@ pub(crate) struct Service { } #[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] +#[serde(crate = "serde", deny_unknown_fields)] struct Mount { source: String, target: String, @@ -83,12 +83,12 @@ struct Mount { } #[derive(Debug)] -pub(crate) struct Project { - pub(crate) root: PathBuf, +pub(super) struct Project { + pub(super) root: PathBuf, } impl Project { - pub(crate) fn discover(explicit: Option<&Path>) -> Result<Self> { + pub(super) fn discover(explicit: Option<&Path>) -> Result<Self> { if let Some(path) = explicit { return Self::at(path); } @@ -107,7 +107,7 @@ impl Project { bail!("Run from the stack directory or supply --root PATH") } - pub(crate) fn at(path: &Path) -> Result<Self> { + pub(super) fn at(path: &Path) -> Result<Self> { let root = if path.is_absolute() { path.to_path_buf() } else { @@ -116,7 +116,7 @@ impl Project { Ok(Self { root }) } - pub(crate) fn settings(&self) -> Result<Settings> { + pub(super) fn settings(&self) -> Result<Settings> { let path = self.root.join("var/state.json"); if path.exists() { let mut settings: Settings = serde_json::from_reader(File::open(path)?)?; @@ -140,7 +140,7 @@ impl Project { }) } - pub(crate) fn save(&self, settings: &Settings) -> Result<()> { + pub(super) fn save(&self, settings: &Settings) -> Result<()> { files::write( &self.root.join("var/state.json"), &serde_json::to_vec_pretty(settings)?, @@ -148,7 +148,7 @@ impl Project { ) } - pub(crate) fn lock(&self) -> Result<File> { + pub(super) fn lock(&self) -> Result<File> { files::private_dir(&self.root.join("var"))?; let file = OpenOptions::new() .read(true) @@ -162,7 +162,7 @@ impl Project { Ok(file) } - pub(crate) fn command(&self, name: &str) -> Result<Command> { + pub(super) fn command(&self, name: &str) -> Result<Command> { let mut command = Command::new(name); command.current_dir(&self.root); if let Some(tools) = self @@ -200,7 +200,7 @@ impl Project { Ok(command) } - pub(crate) fn platform(&self) -> Result<String> { + pub(super) fn platform(&self) -> Result<String> { let info: Value = serde_json::from_slice( &process::capture(self.command("podman")?.args(["info", "--format", "json"]))?.stdout, )?; @@ -215,7 +215,7 @@ impl Project { )) } - pub(crate) fn configuration(&self, settings: &Settings) -> Result<Configuration> { + pub(super) fn configuration(&self, settings: &Settings) -> Result<Configuration> { let mut config: Configuration = toml::from_str(&fs::read_to_string(self.root.join("infra/services.toml"))?)?; ensure!( @@ -235,7 +235,7 @@ impl Project { Ok(config) } - pub(crate) fn initialize(&self, settings: &Settings) -> Result<()> { + pub(super) fn initialize(&self, settings: &Settings) -> Result<()> { files::private_dir(&self.root.join("var/data"))?; let env_file = self.root.join(".env"); for key in ["SEARXNG_SECRET", "CHAT_SETUP_TOKEN"] { @@ -282,14 +282,14 @@ impl Project { &self.root.join("infra/searxng"), &self.root.join("var/data/searxng"), )?; - crate::render::prepare(self, settings) + crate::app::render::prepare(self, settings) } - pub(crate) fn container_name(settings: &Settings, service: &str) -> String { + pub(super) fn container_name(settings: &Settings, service: &str) -> String { format!("{}-{service}", settings.project) } - pub(crate) fn inspect_containers(&self, settings: &Settings) -> Result<Vec<Value>> { + pub(super) fn inspect_containers(&self, settings: &Settings) -> Result<Vec<Value>> { let output = process::text(self.command("podman")?.args([ "ps", "--all", @@ -304,7 +304,7 @@ impl Project { )?) } - pub(crate) fn up(&self, settings: &Settings) -> Result<()> { + pub(super) fn up(&self, settings: &Settings) -> Result<()> { ensure!( (1024..=65_534).contains(&settings.port), "Local port must be between 1024 and 65534" @@ -352,13 +352,13 @@ impl Project { )) } - pub(crate) fn ensure_image(&self, settings: &Settings, service: &Service) -> Result<()> { + pub(super) fn ensure_image(&self, settings: &Settings, service: &Service) -> Result<()> { if settings.mode == Mode::Local && (settings.online || !self.root.join("var/image-lock.json").exists()) && let Some(context) = &service.build { if context == "bin/chat" && !self.root.join("bin/chat/server").is_file() { - crate::chat::build(self)?; + crate::app::chat::build(self)?; } return process::run( self.command("podman")? @@ -429,11 +429,14 @@ impl Project { let http = environment.port("HTTP_PORT")?; let https = environment.port("HTTPS_PORT")?; vec![ - format!("{}:{http}:{http}", crate::chat::web_bind(environment)?), - format!("{}:{https}:{https}", crate::chat::web_bind(environment)?), + format!("{}:{http}:{http}", crate::app::chat::web_bind(environment)?), + format!( + "{}:{https}:{https}", + crate::app::chat::web_bind(environment)? + ), format!( "{}:{https}:{https}/udp", - crate::chat::web_bind(environment)? + crate::app::chat::web_bind(environment)? ), ] }; @@ -448,13 +451,13 @@ impl Project { .env(key, environment.value(key)?); } if name == "chat" { - crate::chat::environment(&mut command, environment, settings)?; + crate::app::chat::environment(&mut command, environment, settings)?; } for mount in &service.mounts { let source = match mount.source.as_str() { - "@caddy" => crate::render::directory(self, settings).join("Caddyfile"), - "@web" => crate::render::directory(self, settings).join("web"), - "@cgit" => crate::render::directory(self, settings).join("cgitrc"), + "@caddy" => crate::app::render::directory(self, settings).join("Caddyfile"), + "@web" => crate::app::render::directory(self, settings).join("web"), + "@cgit" => crate::app::render::directory(self, settings).join("cgitrc"), "@chat" => self.root.join(if settings.mode == Mode::Local { "var/data/chat-local" } else { @@ -489,7 +492,7 @@ impl Project { bail!("Service did not become ready: {name}; inspect its Podman logs") } - pub(crate) fn status(&self, settings: &Settings) -> Result<()> { + pub(super) fn status(&self, settings: &Settings) -> Result<()> { process::run(self.command("podman")?.args([ "ps", "--all", @@ -498,7 +501,7 @@ impl Project { ])) } - pub(crate) fn down(&self, settings: &Settings) -> Result<()> { + pub(super) fn down(&self, settings: &Settings) -> Result<()> { for name in self.configuration(settings)?.services.keys() { process::capture(self.command("podman")?.args([ "rm", @@ -528,7 +531,7 @@ impl Project { } } -pub(crate) fn field<'a>(value: &'a Value, path: &[&str]) -> Result<&'a Value> { +pub(super) fn field<'a>(value: &'a Value, path: &[&str]) -> Result<&'a Value> { path.iter().try_fold(value, |node, key| { node.get(key) .with_context(|| format!("Missing JSON field {key}")) diff --git a/src/release.rs b/src/app/release.rs index 92f2df4..268a3c2 100644 --- a/src/release.rs +++ b/src/app/release.rs @@ -18,12 +18,12 @@ use std::path::Path; use anyhow::{Context, Result, ensure}; -use crate::project::{Mode, Project}; -use crate::{files, images, process}; +use crate::app::project::{Mode, Project}; +use crate::app::{files, images, process}; -pub(crate) fn create(project: &Project, destination: &Path) -> Result<()> { - crate::check(project)?; - crate::chat::build(project)?; +pub(super) fn create(project: &Project, destination: &Path) -> Result<()> { + crate::app::check(project)?; + crate::app::chat::build(project)?; files::write( &project.root.join("bin/stack"), &fs::read( @@ -46,7 +46,7 @@ pub(crate) fn create(project: &Project, destination: &Path) -> Result<()> { let stage = temp.path().join("stack"); files::private_dir(&stage)?; files::copy_source(&project.root, &stage)?; - crate::chat::copy_artifacts(project, &stage)?; + crate::app::chat::copy_artifacts(project, &stage)?; fs::create_dir_all(stage.join("bin"))?; fs::copy( project @@ -77,7 +77,7 @@ pub(crate) fn create(project: &Project, destination: &Path) -> Result<()> { process::line(format_args!("Release: {}", output.display())) } -pub(crate) fn activate(archive: &Path, project: &Project) -> Result<()> { +pub(super) fn activate(archive: &Path, project: &Project) -> Result<()> { let temp = tempfile::Builder::new() .prefix(".activate-") .tempdir_in(&project.root)?; @@ -115,7 +115,7 @@ pub(crate) fn activate(archive: &Path, project: &Project) -> Result<()> { &fs::read(stage.join("bin/stack"))?, 0o755, )?; - crate::chat::copy_artifacts(&pending, &project.root)?; + crate::app::chat::copy_artifacts(&pending, &project.root)?; for name in ["images.tar", "image-lock.json"] { files::copy_tree( &stage.join("var").join(name), diff --git a/src/render.rs b/src/app/render.rs index a552643..8a2d2ee 100644 --- a/src/render.rs +++ b/src/app/render.rs @@ -13,21 +13,21 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. -use crate::files; -use crate::project::{Mode, Project, Settings}; +use crate::app::files; +use crate::app::project::{Mode, Project, Settings}; use anyhow::Result; use stack_config::Config; use std::fs; use std::path::PathBuf; -pub(crate) fn directory(project: &Project, settings: &Settings) -> PathBuf { +pub(super) fn directory(project: &Project, settings: &Settings) -> PathBuf { project.root.join(match settings.mode { Mode::Local => "var/local", Mode::Production => "var/production", }) } -pub(crate) fn prepare(project: &Project, settings: &Settings) -> Result<()> { +pub(super) fn prepare(project: &Project, settings: &Settings) -> Result<()> { let config = Config::load(&project.root)?; let local = (settings.mode == Mode::Local).then_some(settings.port); let output = directory(project, settings); @@ -91,23 +91,39 @@ mod tests { prepare(&project, &settings)?; let output = directory(&project, &settings); let caddy = fs::read_to_string(output.join("Caddyfile"))?; - assert!(caddy.contains("https://home.example.net:8443")); - assert!(caddy.contains("http_port 8181")); - assert!(!caddy.contains("do-not-publish")); + anyhow::ensure!( + caddy.contains("https://home.example.net:8443"), + "Test invariant failed" + ); + anyhow::ensure!(caddy.contains("http_port 8181"), "Test invariant failed"); + anyhow::ensure!(!caddy.contains("do-not-publish"), "Test invariant failed"); let homepage = fs::read_to_string(output.join("web/index.html"))?; - assert!(homepage.contains("https://git.example.net:8443")); + anyhow::ensure!( + homepage.contains("https://git.example.net:8443"), + "Test invariant failed" + ); let stylesheet = files::hash_file(&root.path().join("web/styles.css"))?; - assert!(homepage.contains(&format!("/styles.css?v={stylesheet}"))); - assert!(homepage.contains("https://chat.doan.luuduct.ai:8443/LIMITATIONS.txt")); + anyhow::ensure!( + homepage.contains(&format!("/styles.css?v={stylesheet}")), + "Test invariant failed" + ); + anyhow::ensure!( + homepage.contains("https://chat.doan.luuduct.ai:8443/LIMITATIONS.txt"), + "Test invariant failed" + ); settings.mode = Mode::Local; settings.port = 8180; prepare(&project, &settings)?; let preview = fs::read_to_string(directory(&project, &settings).join("web/index.html"))?; - assert!(preview.contains("href=\"/git/\"")); - assert!(preview.contains("http://chat.localhost:8180/LIMITATIONS.txt")); - assert!( + anyhow::ensure!(preview.contains("href=\"/git/\""), "Test invariant failed"); + anyhow::ensure!( + preview.contains("http://chat.localhost:8180/LIMITATIONS.txt"), + "Test invariant failed" + ); + anyhow::ensure!( fs::read_to_string(directory(&project, &settings).join("cgitrc"))? - .contains("clone-prefix=http://localhost:8180/git") + .contains("clone-prefix=http://localhost:8180/git"), + "Test invariant failed" ); Ok(()) } diff --git a/src/main.rs b/src/main.rs index e6323e2..942a379 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,326 +13,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. -#![doc = "Rust commands for personal web infrastructure."] +#![doc = "Checked command-line entry point for the personal infrastructure stack."] -pub(crate) mod archive; -pub(crate) mod chat; -pub(crate) mod files; -pub(crate) mod images; -pub(crate) mod process; -pub(crate) mod project; -pub(crate) mod release; -pub(crate) mod render; +mod app; -use std::path::{Path, PathBuf}; -use std::process::ExitCode; +pub use app::run; -use anyhow::Result; -use clap::{Parser, Subcommand}; - -use crate::project::{Mode, Project}; - -#[derive(Debug, Parser)] -#[command( - name = "stack", - version, - about = "Develop, deploy, and back up the personal web stack" -)] -struct Cli { - #[arg( - long, - global = true, - help = "Application root; otherwise discovered from the working directory or binary" - )] - root: Option<PathBuf>, - #[command(subcommand)] - command: Action, -} - -#[derive(Debug, Subcommand)] -enum Action { - #[command(about = "Run experimental local chat or administer its packaged deployment")] - Chat { - #[arg(long, conflicts_with_all = ["reset_owner", "owner_fingerprint"])] - init: bool, - #[arg( - long, - value_name = "FINGERPRINT", - conflicts_with = "owner_fingerprint", - help = "Permanently clear owner registration and chat history after key loss" - )] - reset_owner: Option<String>, - #[arg(long, help = "Print the registered owner's public fingerprint")] - owner_fingerprint: bool, - }, - #[command(about = "Start the local development services")] - Dev { - #[arg(long)] - port: Option<u16>, - }, - #[command(about = "Start or update services in the selected mode")] - Up { - #[arg(long, conflicts_with = "local")] - production: bool, - #[arg(long)] - local: bool, - #[arg(long)] - port: Option<u16>, - #[arg(long)] - online: bool, - }, - #[command( - about = "Render public Caddy, website, and cgit configuration without starting services" - )] - Config { - #[arg(long)] - production: bool, - #[arg(long)] - port: Option<u16>, - }, - #[command(about = "Show service status")] - Status, - #[command(about = "Stop services while retaining data")] - Down, - #[command(about = "Create a GPG-encrypted application export")] - Backup { - destination: Option<PathBuf>, - #[arg(long)] - include_images: bool, - #[arg(long)] - recipient: Option<String>, - #[arg(long)] - key: Option<PathBuf>, - }, - #[command( - about = "Decrypt, validate, and restore into a new directory without starting services" - )] - Restore { - archive: PathBuf, - destination: PathBuf, - }, - #[command(about = "Run formatting, strict lints, tests, documentation, and dependency checks")] - Check, - #[command(about = "Check and build a portable production release locally")] - Release { destination: Option<PathBuf> }, - #[command(about = "Install a prebuilt release without compiling on the server")] - Activate { archive: PathBuf }, - #[command(about = "Generate the third-party license page with GNU license names")] - Licenses, -} - -fn main() -> ExitCode { - let arguments = match Cli::try_parse() { - Ok(arguments) => arguments, - Err(error) => { - let status = if error.use_stderr() { - ExitCode::FAILURE - } else { - ExitCode::SUCCESS - }; - drop(error.print()); - return status; - } - }; - match execute(arguments) { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - drop(process::error(format_args!("Error: {error:#}"))); - ExitCode::FAILURE - } - } -} - -fn execute(arguments: Cli) -> Result<()> { - if let Action::Restore { - archive, - destination, - } = &arguments.command - { - return archive::restore(&absolute(archive)?, &absolute(destination)?); - } - let project = Project::discover(arguments.root.as_deref())?; - if let Action::Chat { - init, - reset_owner, - owner_fingerprint, - } = arguments.command - { - return chat(&project, init, reset_owner.as_deref(), owner_fingerprint); - } - - let _operation = project.lock()?; - match arguments.command { - Action::Dev { port } => { - let mut settings = project.settings()?; - settings.mode = Mode::Local; - if let Some(selected_port) = port { - settings.port = selected_port; - } - images::prepare(&project, &mut settings)?; - project.up(&settings) - } - Action::Up { - production, - local, - port, - online, - } => { - let mut settings = project.settings()?; - if production { - settings.mode = Mode::Production; - } - if local { - settings.mode = Mode::Local; - } - if let Some(selected_port) = port { - settings.port = selected_port; - } - if online { - settings.online = true; - } - images::prepare(&project, &mut settings)?; - project.up(&settings) - } - Action::Config { production, port } => { - let mut settings = project.settings()?; - settings.mode = if production { - Mode::Production - } else { - Mode::Local - }; - if let Some(selected_port) = port { - settings.port = selected_port; - } - stack_config::initialize_public(&project.root)?; - render::prepare(&project, &settings)?; - process::line(format_args!( - "Public configuration: {}", - render::directory(&project, &settings).display() - )) - } - Action::Status => project.status(&project.settings()?), - Action::Down => project.down(&project.settings()?), - Action::Backup { - destination, - include_images, - recipient, - key, - } => { - let output = destination.map_or_else( - || Ok(project.root.join("var/exports")), - |path| absolute(&path), - )?; - let configuration = stack_config::Config::load(&project.root)?; - let selected_recipient = - recipient.unwrap_or(configuration.value("BACKUP_RECIPIENT")?.to_owned()); - archive::backup( - &project, - &output, - include_images, - &selected_recipient, - key.as_deref(), - ) - } - Action::Release { destination } => { - let output = destination - .map_or_else(|| Ok(project.root.join("dist")), |path| absolute(&path))?; - release::create(&project, &output) - } - Action::Activate { archive } => release::activate(&absolute(&archive)?, &project), - Action::Check => check(&project), - Action::Licenses => licenses(&project), - Action::Chat { .. } => anyhow::bail!("Chat action was not handled"), - Action::Restore { .. } => anyhow::bail!("Restore action was not handled"), - } -} - -fn chat( - project: &Project, - init: bool, - reset_owner: Option<&str>, - owner_fingerprint: bool, -) -> Result<()> { - if project.settings()?.mode == Mode::Production { - return chat::admin(project, init, reset_owner, owner_fingerprint); - } - let mut command = project.command("cargo")?; - command - .args(["run", "--locked", "-p", "stack-chat-server", "--", "--root"]) - .arg(&project.root); - if init { - command.arg("--init"); - } - if let Some(fingerprint) = reset_owner { - command.arg("--reset-owner").arg(fingerprint); - } - if owner_fingerprint { - command.arg("--owner-fingerprint"); - } - process::run(&mut command) -} - -fn absolute(path: &Path) -> Result<PathBuf> { - Ok(if path.is_absolute() { - path.to_path_buf() - } else { - std::env::current_dir()?.join(path) - }) -} - -fn licenses(project: &Project) -> Result<()> { - process::run(project.command("cargo")?.args([ - "about", - "generate", - "--workspace", - "--locked", - "about.hbs", - "--output-file", - "web/THIRD-PARTY-LICENSES.html", - ])) -} - -fn check(project: &Project) -> Result<()> { - process::run(project.command("sh")?.arg("infra/chat/build-typescript.sh"))?; - process::run( - project - .command("cargo")? - .args(["fmt", "--all", "--", "--check"]), - )?; - process::run(project.command("cargo")?.args([ - "clippy", - "--locked", - "--workspace", - "--all-targets", - "--all-features", - "--", - "-D", - "warnings", - ]))?; - process::run(project.command("cargo")?.args([ - "test", - "--locked", - "--workspace", - "--all-targets", - "--all-features", - ]))?; - process::run( - project - .command("cargo")? - .env("RUSTDOCFLAGS", "-D warnings") - .args([ - "doc", - "--locked", - "--workspace", - "--no-deps", - "--document-private-items", - ]), - )?; - process::run(project.command("cargo")?.args([ - "deny", - "--workspace", - "--all-features", - "check", - ]))?; - licenses(project) +fn main() -> std::process::ExitCode { + run() } |
