summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.rs252
-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.rs329
-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.rs324
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()
}