aboutsummaryrefslogtreecommitdiff
path: root/src/main.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/main.rs')
-rw-r--r--src/main.rs252
1 files changed, 252 insertions, 0 deletions
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..ec8784e
--- /dev/null
+++ b/src/main.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."]
+
+pub(crate) mod archive;
+pub(crate) mod files;
+pub(crate) mod images;
+pub(crate) mod process;
+pub(crate) mod project;
+pub(crate) mod release;
+
+use std::path::{Path, PathBuf};
+use std::process::ExitCode;
+
+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 = "Start the local development services")]
+ Dev {
+ #[arg(long, default_value_t = 8080)]
+ port: 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 = "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, default_value = archive::RECIPIENT)]
+ recipient: 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())?;
+ let _operation = project.lock()?;
+ match arguments.command {
+ Action::Dev { port } => {
+ let mut settings = project.settings()?;
+ settings.mode = Mode::Local;
+ settings.port = 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::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),
+ )?;
+ archive::backup(
+ &project,
+ &output,
+ include_images,
+ &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::Restore { .. } => anyhow::bail!("Restore action was not handled"),
+ }
+}
+
+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",
+ "about.hbs",
+ "--output-file",
+ "web/THIRD-PARTY-LICENSES.html",
+ ]))
+}
+
+fn check(project: &Project) -> Result<()> {
+ 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", "--all-features", "check"]),
+ )?;
+ licenses(project)
+}