summaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorDoan Luu Duc Tai <doan@luuduct.ai>2026-10-31 13:08:08 -0500
committerDoan Luu Duc Tai <doan@luuduct.ai>2026-10-31 13:08:08 -0500
commita80df869a92c330fbad9695057720b11a18af901 (patch)
tree5307272821cb3adc3f0348413a3b904290890044 /src/app
parent9139398d465f5899cc31bc4cdd54daa7392ca550 (diff)
Forbid Rust lints and enforce strict typed TypeScript linting
Diffstat (limited to 'src/app')
-rw-r--r--src/app/archive.rs475
-rw-r--r--src/app/chat.rs177
-rw-r--r--src/app/cli.rs329
-rw-r--r--src/app/files.rs384
-rw-r--r--src/app/images.rs215
-rw-r--r--src/app/process.rs70
-rw-r--r--src/app/project.rs539
-rw-r--r--src/app/release.rs127
-rw-r--r--src/app/render.rs130
9 files changed, 2446 insertions, 0 deletions
diff --git a/src/app/archive.rs b/src/app/archive.rs
new file mode 100644
index 0000000..2a78644
--- /dev/null
+++ b/src/app/archive.rs
@@ -0,0 +1,475 @@
+// 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 std::collections::BTreeSet;
+use std::fs;
+use std::path::Path;
+use std::process::Command;
+
+use anyhow::{Context, Result, ensure};
+use serde_json::Value;
+
+use crate::app::project::{Mode, Project, Settings, field};
+use crate::app::{files, process};
+
+#[derive(Debug)]
+struct Paused<'a> {
+ project: &'a Project,
+ ids: Vec<String>,
+}
+
+impl Paused<'_> {
+ fn resume(&mut self) -> Result<()> {
+ let mut failure = None;
+ for id in self.ids.iter().rev() {
+ if let Err(error) =
+ process::capture(self.project.command("podman")?.args(["unpause", id]))
+ {
+ process::error(format_args!("Could not resume container {id}: {error:#}"))?;
+ failure = Some(error);
+ }
+ }
+ self.ids.clear();
+ if let Some(error) = failure {
+ return Err(error);
+ }
+ Ok(())
+ }
+}
+
+impl Drop for Paused<'_> {
+ fn drop(&mut self) {
+ if let Err(error) = self.resume() {
+ drop(process::error(format_args!(
+ "Container resume failed: {error:#}"
+ )));
+ }
+ }
+}
+
+pub(super) fn containers(project: &Project, settings: &Settings) -> Result<Vec<Value>> {
+ let records = project.inspect_containers(settings)?;
+ let actual: BTreeSet<_> = records
+ .iter()
+ .map(|record| {
+ field(record, &["Config", "Labels", "io.stack.service"])?
+ .as_str()
+ .context("Missing service label")
+ })
+ .collect::<Result<_>>()?;
+ let configuration = project.configuration(settings)?;
+ let expected: BTreeSet<_> = configuration.services.keys().map(String::as_str).collect();
+ ensure!(
+ actual == expected,
+ "Running services differ from configuration; refusing an incomplete backup"
+ );
+ Ok(records)
+}
+
+fn snapshot(project: &Project, records: &[Value], stage: &Path) -> Result<()> {
+ let mut guard = Paused {
+ project,
+ ids: Vec::new(),
+ };
+ for record in records {
+ if field(record, &["Config", "Labels", "io.stack.service"])?.as_str() != Some("chat")
+ && field(record, &["State", "Running"])?.as_bool() == Some(true)
+ && field(record, &["State", "Paused"])?.as_bool() == Some(false)
+ {
+ let id = field(record, &["Id"])?
+ .as_str()
+ .context("Missing container ID")?;
+ process::capture(project.command("podman")?.args(["pause", id]))?;
+ guard.ids.push(id.to_owned());
+ }
+ }
+ let result = snapshot_files(project, records, stage);
+ let resume = guard.resume();
+ result?;
+ resume
+}
+
+fn snapshot_chat(root: &Path, stage: &Path, name: &str) -> Result<()> {
+ let source = root.join("var/data").join(name).join("chat.sqlite");
+ if !source.is_file() {
+ return Ok(());
+ }
+ let directory = stage.join("var/data").join(name);
+ files::private_dir(&directory)?;
+ let database =
+ rusqlite::Connection::open_with_flags(source, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
+ database.busy_timeout(std::time::Duration::from_secs(5))?;
+ let destination = directory.join("chat.sqlite");
+ database.backup("main", &destination, None::<fn(rusqlite::backup::Progress)>)?;
+ fs::set_permissions(
+ destination,
+ std::os::unix::fs::PermissionsExt::from_mode(0o600),
+ )?;
+ Ok(())
+}
+
+fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result<()> {
+ files::copy_source(&project.root, stage)?;
+ files::copy_tree(&project.root.join(".env"), &stage.join(".env"))?;
+ if project.root.join(".git").is_dir() {
+ files::copy_tree(&project.root.join(".git"), &stage.join(".git"))?;
+ }
+ let data = project.root.join("var/data");
+ for item in fs::read_dir(&data)? {
+ let entry = item?;
+ if !["caddy", "chat", "chat-local"]
+ .iter()
+ .any(|name| entry.file_name() == *name)
+ {
+ files::copy_tree(
+ &entry.path(),
+ &stage.join("var/data").join(entry.file_name()),
+ )?;
+ }
+ }
+ for name in ["chat", "chat-local"] {
+ snapshot_chat(&project.root, stage, name)?;
+ }
+ let caddy = records
+ .iter()
+ .find(|record| {
+ field(record, &["Config", "Labels", "io.stack.service"])
+ .ok()
+ .and_then(Value::as_str)
+ == Some("caddy")
+ })
+ .context("Caddy is not present")?;
+ let id = field(caddy, &["Id"])?.as_str().context("Caddy has no ID")?;
+ let target = stage.join("var/data/caddy");
+ fs::create_dir_all(&target)?;
+ for name in ["data", "config"] {
+ process::capture(
+ project
+ .command("podman")?
+ .args(["cp", &format!("{id}:/{name}")])
+ .arg(&target),
+ )?;
+ }
+ fs::create_dir_all(stage.join("bin"))?;
+ let executable = [
+ project.root.join("bin/stack"),
+ project
+ .root
+ .join("target/x86_64-unknown-linux-musl/release/stack"),
+ ]
+ .into_iter()
+ .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::app::chat::copy_artifacts(project, stage)?;
+ Ok(())
+}
+
+pub(super) fn backup(
+ project: &Project,
+ destination: &Path,
+ with_images: bool,
+ recipient: &str,
+ public_key: Option<&Path>,
+) -> Result<()> {
+ ensure!(
+ recipient.len() == 40 && recipient.bytes().all(|byte| byte.is_ascii_hexdigit()),
+ "A full 40-character recipient fingerprint is required"
+ );
+ files::private_dir(destination)?;
+ let settings = project.settings()?;
+ let records = containers(project, &settings)?;
+ let temp = tempfile::Builder::new()
+ .prefix(".export-")
+ .tempdir_in(destination)?;
+ let home = temp.path().join("gnupg");
+ files::private_dir(&home)?;
+ let configuration = stack_config::Config::load(&project.root)?;
+ let key = public_key.map_or_else(
+ || Ok(project.root.join(configuration.value("BACKUP_PUBLIC_KEY")?)),
+ |path| Ok::<_, anyhow::Error>(path.to_path_buf()),
+ )?;
+ let operation = backup_inner(
+ project,
+ destination,
+ with_images,
+ &Encryption {
+ recipient,
+ key: &key,
+ home: &home,
+ },
+ &records,
+ temp.path(),
+ );
+ drop(process::capture(
+ Command::new("gpgconf")
+ .arg("--homedir")
+ .arg(&home)
+ .args(["--kill", "all"]),
+ ));
+ operation
+}
+
+#[derive(Debug)]
+struct Encryption<'a> {
+ recipient: &'a str,
+ key: &'a Path,
+ home: &'a Path,
+}
+
+fn backup_inner(
+ project: &Project,
+ destination: &Path,
+ with_images: bool,
+ encryption: &Encryption<'_>,
+ records: &[Value],
+ temp: &Path,
+) -> Result<()> {
+ let Encryption {
+ recipient,
+ key,
+ home,
+ } = encryption;
+ process::capture(
+ Command::new("gpg")
+ .args(["--no-options", "--batch", "--homedir"])
+ .arg(home)
+ .args(["--import"])
+ .arg(key),
+ )?;
+ let listed = process::text(
+ Command::new("gpg")
+ .args(["--no-options", "--batch", "--homedir"])
+ .arg(home)
+ .args(["--with-colons", "--list-keys", recipient]),
+ )?;
+ ensure!(
+ listed
+ .lines()
+ .filter(|line| line.starts_with("fpr:"))
+ .any(|line| line
+ .split(':')
+ .nth(9)
+ .is_some_and(|found| found.eq_ignore_ascii_case(recipient))),
+ "Public key fingerprint does not match the requested backup recipient"
+ );
+ let stage = temp.join("stack");
+ files::private_dir(&stage)?;
+ snapshot(project, records, &stage)?;
+ for entry in fs::read_dir(stage.join("var/data/repos"))? {
+ let repo = entry?.path();
+ if repo.is_dir() {
+ process::capture(
+ Command::new("git")
+ .arg("--git-dir")
+ .arg(repo)
+ .args(["fsck", "--full"]),
+ )?;
+ }
+ }
+ if with_images {
+ crate::app::images::save(project, records, &stage.join("var"))?;
+ }
+ files::manifest(&stage, "backup")?;
+ let plain = temp.join("stack.tar.gz");
+ files::pack(&stage, &plain)?;
+ let name = format!(
+ "stack-{}.tar.gz.gpg",
+ chrono::Utc::now().format("%Y%m%dT%H%M%SZ")
+ );
+ let encrypted = temp.join(&name);
+ process::run(
+ Command::new("gpg")
+ .args(["--no-options", "--batch", "--homedir"])
+ .arg(home)
+ .args([
+ "--rfc4880",
+ "--trust-model",
+ "always",
+ "--recipient",
+ recipient,
+ "--cipher-algo",
+ "AES256",
+ "--compress-algo",
+ "none",
+ "--set-filename",
+ "stack.tar.gz",
+ "--output",
+ ])
+ .arg(&encrypted)
+ .arg("--encrypt")
+ .arg(plain),
+ )?;
+ let output = destination.join(&name);
+ ensure!(
+ !output.exists(),
+ "An export with this timestamp already exists"
+ );
+ fs::set_permissions(
+ &encrypted,
+ std::os::unix::fs::PermissionsExt::from_mode(0o600),
+ )?;
+ fs::rename(&encrypted, &output)?;
+ let digest = format!("{} {name}\n", files::hash_file(&output)?);
+ files::write(
+ &destination.join(format!("{name}.sha256")),
+ digest.as_bytes(),
+ 0o600,
+ )?;
+ process::line(format_args!("Encrypted export: {}", output.display()))
+}
+
+pub(super) fn restore(archive: &Path, destination: &Path) -> Result<()> {
+ ensure!(!destination.exists(), "Restore destination must not exist");
+ let parent = destination
+ .parent()
+ .context("Restore destination has no parent")?;
+ fs::create_dir_all(parent)?;
+ let temp = tempfile::Builder::new()
+ .prefix(".restore-")
+ .tempdir_in(parent)?;
+ let decrypted = temp.path().join("stack.tar.gz");
+ let source = if archive.extension().is_some_and(|suffix| suffix == "gpg") {
+ process::run(
+ Command::new("gpg")
+ .args(["--no-options", "--output"])
+ .arg(&decrypted)
+ .arg("--decrypt")
+ .arg(archive),
+ )?;
+ &decrypted
+ } else {
+ archive
+ };
+ let stage = temp.path().join("stack");
+ files::extract(source, &stage)?;
+ let manifest = files::verify(&stage)?;
+ ensure!(
+ manifest.kind == "backup" || manifest.kind == "release",
+ "Unknown archive kind"
+ );
+ let project = Project::at(&stage)?;
+ let mut settings = project.settings()?;
+ settings.mode = Mode::Local;
+ settings.project = format!(
+ "stack-{}",
+ files::hash_bytes(destination.as_os_str().as_encoded_bytes())
+ .chars()
+ .take(10)
+ .collect::<String>()
+ );
+ project.save(&settings)?;
+ if stage.join(".git").is_dir() {
+ process::capture(
+ Command::new("git")
+ .arg("-C")
+ .arg(&stage)
+ .args(["remote", "set-url", "origin"])
+ .arg(destination.join("var/data/repos/stack")),
+ )?;
+ }
+ fs::rename(&stage, destination)?;
+ process::line(format_args!(
+ "Restored and verified {}. No services started.",
+ destination.display()
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+
+ use anyhow::Result;
+
+ use crate::app::files;
+ use crate::app::project::{Mode, Project};
+
+ #[test]
+ fn chat_snapshot_is_consistent_and_private() -> Result<()> {
+ let workspace = tempfile::tempdir()?;
+ let root = workspace.path().join("source");
+ let stage = workspace.path().join("snapshot");
+ files::private_dir(&root.join("var/data/chat"))?;
+ let connection = rusqlite::Connection::open(root.join("var/data/chat/chat.sqlite"))?;
+ connection.execute_batch(
+ "CREATE TABLE messages (body TEXT); INSERT INTO messages VALUES ('ciphertext-before');",
+ )?;
+ let transaction = connection.unchecked_transaction()?;
+ transaction.execute("INSERT INTO messages VALUES ('uncommitted')", [])?;
+ super::snapshot_chat(&root, &stage, "chat")?;
+ let snapshot = stage.join("var/data/chat/chat.sqlite");
+ let restored = rusqlite::Connection::open(&snapshot)?;
+ let count: i64 =
+ restored.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
+ 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(())
+ }
+
+ #[test]
+ fn restore_is_local_and_refuses_an_existing_destination() -> Result<()> {
+ let workspace = tempfile::tempdir()?;
+ let source = workspace.path().join("source");
+ files::private_dir(&source)?;
+ fs::write(source.join(".env"), "SEARXNG_SECRET=private\n")?;
+ let project = Project::at(&source)?;
+ let mut settings = project.settings()?;
+ settings.mode = Mode::Production;
+ project.save(&settings)?;
+ files::manifest(&source, "backup")?;
+ let archive = workspace.path().join("backup.tar.gz");
+ files::pack(&source, &archive)?;
+ let destination = workspace.path().join("restored");
+ super::restore(&archive, &destination)?;
+ 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"
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn corrupt_restore_never_publishes_a_destination() -> Result<()> {
+ let workspace = tempfile::tempdir()?;
+ let source = workspace.path().join("source");
+ files::private_dir(&source)?;
+ fs::write(source.join(".env"), "original")?;
+ files::manifest(&source, "backup")?;
+ fs::write(source.join(".env"), "tampered")?;
+ let archive = workspace.path().join("backup.tar.gz");
+ files::pack(&source, &archive)?;
+ let destination = workspace.path().join("restored");
+ anyhow::ensure!(
+ super::restore(&archive, &destination).is_err(),
+ "Test invariant failed"
+ );
+ anyhow::ensure!(!destination.exists(), "Test invariant failed");
+ Ok(())
+ }
+}
diff --git a/src/app/chat.rs b/src/app/chat.rs
new file mode 100644
index 0000000..1647018
--- /dev/null
+++ b/src/app/chat.rs
@@ -0,0 +1,177 @@
+// 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 crate::app::project::{Mode, Project, Settings};
+use crate::app::{files, process};
+use anyhow::{Context, Result, ensure};
+use stack_config::Config;
+use std::fs;
+use std::net::IpAddr;
+use std::path::Path;
+use std::process::Command;
+
+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(),
+ IpAddr::V6(_) => format!("[{address}]"),
+ })
+}
+
+pub(super) fn environment(
+ command: &mut Command,
+ configuration: &Config,
+ settings: &Settings,
+) -> Result<()> {
+ let local = (settings.mode == Mode::Local).then_some(settings.port);
+ let mut public = configuration.public_values(local)?;
+ let origin = public
+ .get("CHAT_PUBLIC_ORIGIN")
+ .context("Missing chat gateway origin")?
+ .clone();
+ public.insert("CHAT_ORIGIN".into(), origin);
+ public.insert("CHAT_BIND".into(), "0.0.0.0:8080".into());
+ for (key, value) in public {
+ command.arg("--env").arg(&key).env(key, value);
+ }
+ Ok(())
+}
+
+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
+ .root
+ .join(".tools/openssl-wasi/lib/libcrypto.a")
+ .is_file()
+ {
+ "infra/chat/build-browser.sh"
+ } else {
+ "infra/chat/build-openssl-wasi.sh"
+ };
+ process::run(project.command("sh")?.arg(wasm_build))?;
+ process::run(project.command("sh")?.arg("infra/chat/build-source.sh"))?;
+ package(project)
+}
+
+pub(super) fn package(project: &Project) -> Result<()> {
+ let directory = project.root.join("bin/chat");
+ fs::create_dir_all(&directory)?;
+ for (source, target) in [
+ (
+ "target/x86_64-unknown-linux-musl/release/stack-chat-server",
+ "server",
+ ),
+ (
+ "target/wasm32-wasip1/release/stack-chat-crypto.wasm",
+ "crypto.wasm",
+ ),
+ ("var/build/chat-source.tar.gz", "SOURCE.tar.gz"),
+ ("infra/containers/chat/Containerfile", "Containerfile"),
+ (
+ "var/build/typescript/crates/chat-crypto/browser/wasi.js",
+ "wasi.mjs",
+ ),
+ (
+ "var/build/typescript/crates/chat-server/web/app.js",
+ "web/app.js",
+ ),
+ (
+ "var/build/typescript/crates/chat-server/web/worker.js",
+ "web/worker.js",
+ ),
+ ("web/THIRD-PARTY-LICENSES.html", "THIRD-PARTY-LICENSES.html"),
+ ("docs/CHAT-APPLICATION.txt", "CHAT-APPLICATION.txt"),
+ ("docs/CHAT-PRODUCTION.txt", "CHAT-PRODUCTION.txt"),
+ ("LICENSE", "LICENSE"),
+ ] {
+ files::copy_tree(&project.root.join(source), &directory.join(target))?;
+ }
+ for name in ["index.html", "style.css", "javascript.html"] {
+ files::copy_tree(
+ &project.root.join("crates/chat-server/web").join(name),
+ &directory.join("web").join(name),
+ )?;
+ }
+ Ok(())
+}
+
+pub(super) fn copy_artifacts(project: &Project, destination: &Path) -> Result<()> {
+ let artifacts = project.root.join("bin/chat");
+ ensure!(
+ artifacts.join("server").is_file()
+ && artifacts.join("crypto.wasm").is_file()
+ && artifacts.join("SOURCE.tar.gz").is_file(),
+ "Build a complete chat release locally before export"
+ );
+ files::copy_tree(&artifacts, &destination.join("bin/chat"))
+}
+
+pub(super) fn admin(
+ project: &Project,
+ init: bool,
+ reset_owner: Option<&str>,
+ owner_fingerprint: bool,
+) -> Result<()> {
+ let settings = project.settings()?;
+ if init {
+ project.initialize(&settings)?;
+ return process::line(format_args!(
+ "Owner setup token is available in the single root .env"
+ ));
+ }
+ ensure!(
+ reset_owner.is_some() || owner_fingerprint,
+ "Use up --production to run chat; maintenance options are --owner-fingerprint and --reset-owner"
+ );
+ let configuration = Config::load(&project.root)?;
+ let services = project.configuration(&settings)?;
+ let service = services
+ .services
+ .get("chat")
+ .context("Chat is not packaged in this release")?;
+ let mut command = project.command("podman")?;
+ command
+ .args([
+ "run",
+ "--rm",
+ "--network",
+ "none",
+ "--read-only",
+ "--read-only-tmpfs=false",
+ "--cap-drop",
+ "ALL",
+ "--security-opt",
+ "no-new-privileges",
+ "--env",
+ "CHAT_SETUP_TOKEN",
+ ])
+ .env("CHAT_SETUP_TOKEN", configuration.value("CHAT_SETUP_TOKEN")?);
+ environment(&mut command, &configuration, &settings)?;
+ command
+ .arg("--volume")
+ .arg(format!(
+ "{}:/app/var/data/chat:rw,z",
+ project.root.join("var/data/chat").display()
+ ))
+ .arg(&service.image);
+ if let Some(fingerprint) = reset_owner {
+ command.arg("--reset-owner").arg(fingerprint);
+ }
+ if owner_fingerprint {
+ command.arg("--owner-fingerprint");
+ }
+ process::run(&mut command)
+}
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/app/files.rs b/src/app/files.rs
new file mode 100644
index 0000000..2c612a9
--- /dev/null
+++ b/src/app/files.rs
@@ -0,0 +1,384 @@
+// 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 std::collections::BTreeMap;
+use std::fs::{self, File, OpenOptions, Permissions};
+use std::io::{Read, Write};
+use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
+use std::path::{Component, Path, PathBuf};
+
+use anyhow::{Context, Result, bail, ensure};
+use flate2::Compression;
+use flate2::read::GzDecoder;
+use flate2::write::GzEncoder;
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use walkdir::WalkDir;
+
+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",
+ ".gitattributes",
+ ".forgejo",
+ "src",
+ "tests",
+ "crates",
+ "infra",
+ "web",
+ "docs",
+ "README.txt",
+ "LICENSE",
+ "deny.toml",
+ "about.toml",
+ "about.hbs",
+ ".env.example",
+];
+
+#[derive(Debug, Deserialize, Serialize)]
+#[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(super) fn private_dir(path: &Path) -> Result<()> {
+ fs::DirBuilder::new()
+ .recursive(true)
+ .mode(0o700)
+ .create(path)?;
+ fs::set_permissions(path, Permissions::from_mode(0o700))?;
+ Ok(())
+}
+
+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)?;
+ pending
+ .as_file_mut()
+ .set_permissions(Permissions::from_mode(mode))?;
+ pending.write_all(data)?;
+ pending.as_file().sync_all()?;
+ pending.persist(path).map_err(|error| error.error)?;
+ Ok(())
+}
+
+pub(super) fn hash_bytes(bytes: &[u8]) -> String {
+ hex::encode(Sha256::digest(bytes))
+}
+
+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];
+ loop {
+ let length = file.read(&mut buffer)?;
+ if length == 0 {
+ break;
+ }
+ digest.update(buffer.get(..length).context("Invalid read length")?);
+ }
+ Ok(hex::encode(digest.finalize()))
+}
+
+pub(super) fn relative(path: &Path, base: &Path) -> Result<String> {
+ Ok(path
+ .strip_prefix(base)?
+ .to_str()
+ .context("Paths must be UTF-8")?
+ .to_owned())
+}
+
+pub(super) fn regular_tree(path: &Path) -> Result<()> {
+ for item in WalkDir::new(path).follow_links(false) {
+ let entry = item?;
+ ensure!(
+ entry.file_type().is_dir() || entry.file_type().is_file(),
+ "Links and special files are not accepted: {}",
+ entry.path().display()
+ );
+ }
+ Ok(())
+}
+
+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")?;
+ fs::create_dir_all(parent)?;
+ fs::copy(source, destination)?;
+ return Ok(());
+ }
+ for item in WalkDir::new(source).follow_links(false) {
+ let entry = item?;
+ let target = destination.join(entry.path().strip_prefix(source)?);
+ if entry.file_type().is_dir() {
+ fs::create_dir_all(&target)?;
+ fs::set_permissions(&target, entry.metadata()?.permissions())?;
+ } else {
+ let parent = target.parent().context("File has no parent")?;
+ fs::create_dir_all(parent)?;
+ fs::copy(entry.path(), &target)?;
+ }
+ }
+ Ok(())
+}
+
+pub(super) fn copy_source(root: &Path, destination: &Path) -> Result<()> {
+ for name in SOURCE {
+ let source = root.join(name);
+ if source.exists() {
+ copy_tree(&source, &destination.join(name))?;
+ }
+ }
+ Ok(())
+}
+
+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?;
+ ensure!(!entry.file_type().is_symlink(), "Archive contains a link");
+ if entry.file_type().is_file() {
+ let name = relative(entry.path(), root)?;
+ if name != "manifest.json" {
+ files.insert(name, hash_file(entry.path())?);
+ }
+ }
+ }
+ Ok(files)
+}
+
+pub(super) fn manifest(root: &Path, kind: &str) -> Result<()> {
+ let record = Manifest {
+ format: 2,
+ kind: kind.to_owned(),
+ version: env!("CARGO_PKG_VERSION").to_owned(),
+ created_utc: chrono::Utc::now().to_rfc3339(),
+ files: inventory(root)?,
+ };
+ write(
+ &root.join("manifest.json"),
+ &serde_json::to_vec_pretty(&record)?,
+ 0o600,
+ )
+}
+
+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,
+ "Unsupported export format; legacy Python exports retain their embedded recovery helper"
+ );
+ ensure!(
+ inventory(root)? == record.files,
+ "Archive file list or checksums do not match the manifest"
+ );
+ Ok(record)
+}
+
+pub(super) fn pack(root: &Path, archive: &Path) -> Result<()> {
+ regular_tree(root)?;
+ let file = OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .mode(0o600)
+ .open(archive)?;
+ let encoder = GzEncoder::new(file, Compression::new(3));
+ let mut builder = tar::Builder::new(encoder);
+ builder.follow_symlinks(false);
+ builder.append_dir_all("stack", root)?;
+ builder.into_inner()?.finish()?.sync_all()?;
+ Ok(())
+}
+
+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())),
+ "Archive must have a stack root"
+ );
+ let mut target = destination.to_path_buf();
+ for part in parts {
+ match part {
+ Component::Normal(name) => target.push(name),
+ _ => bail!("Unsafe archive path: {}", path.display()),
+ }
+ }
+ Ok(target)
+}
+
+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();
+ for item in reader.entries()? {
+ let mut entry = item?;
+ let kind = entry.header().entry_type();
+ ensure!(
+ kind.is_dir() || kind.is_file(),
+ "Archive links and special files are forbidden"
+ );
+ let target = member_path(&entry.path()?, destination)?;
+ let mode = entry.header().mode()? & 0o777;
+ if kind.is_dir() {
+ fs::create_dir_all(&target)?;
+ directories.push((target, mode | 0o700));
+ } else {
+ let parent = target.parent().context("File has no parent")?;
+ fs::create_dir_all(parent)?;
+ let mut output = OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .mode(mode)
+ .open(&target)?;
+ std::io::copy(&mut entry, &mut output)?;
+ output.sync_all()?;
+ }
+ }
+ for (path, mode) in directories.into_iter().rev() {
+ fs::set_permissions(path, Permissions::from_mode(mode))?;
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+ use std::path::Path;
+
+ use anyhow::Result;
+
+ use super::{copy_source, extract, manifest, member_path, pack, verify};
+
+ #[test]
+ fn rejects_paths_outside_archive_root() -> Result<()> {
+ let destination = Path::new("/restore");
+ for name in ["/etc/passwd", "../escape", "stack/../escape", "other/file"] {
+ anyhow::ensure!(
+ member_path(Path::new(name), destination).is_err(),
+ "Test invariant failed"
+ );
+ }
+ anyhow::ensure!(
+ (member_path(Path::new("stack/web/index.html"), destination).ok())
+ == (Some(destination.join("web/index.html"))),
+ "Test invariant failed"
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn detects_modified_and_extra_files() -> Result<()> {
+ let root = tempfile::tempdir()?;
+ fs::write(root.path().join("public.txt"), "original")?;
+ manifest(root.path(), "release")?;
+ anyhow::ensure!(verify(root.path()).is_ok(), "Test invariant failed");
+ fs::write(root.path().join("public.txt"), "changed")?;
+ 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")?;
+ anyhow::ensure!(verify(root.path()).is_err(), "Test invariant failed");
+ Ok(())
+ }
+
+ #[test]
+ fn source_export_excludes_secrets_and_runtime() -> Result<()> {
+ 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())?;
+ 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(())
+ }
+
+ #[test]
+ fn archives_round_trip_without_overwriting() -> Result<()> {
+ let workspace = tempfile::tempdir()?;
+ let source = workspace.path().join("source");
+ fs::create_dir(&source)?;
+ fs::write(source.join(".env"), "secret")?;
+ manifest(&source, "backup")?;
+ let archive = workspace.path().join("backup.tar.gz");
+ pack(&source, &archive)?;
+ let restored = workspace.path().join("restored");
+ extract(&archive, &restored)?;
+ 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(())
+ }
+
+ #[test]
+ fn rejects_symbolic_links() -> Result<()> {
+ let root = tempfile::tempdir()?;
+ std::os::unix::fs::symlink("/etc/passwd", root.path().join("link"))?;
+ anyhow::ensure!(
+ super::regular_tree(root.path()).is_err(),
+ "Test invariant failed"
+ );
+ Ok(())
+ }
+}
diff --git a/src/app/images.rs b/src/app/images.rs
new file mode 100644
index 0000000..fac7aa7
--- /dev/null
+++ b/src/app/images.rs
@@ -0,0 +1,215 @@
+// 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 std::collections::BTreeMap;
+use std::fs::{self, File};
+use std::path::Path;
+
+use anyhow::{Context, Result, ensure};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::app::project::{Mode, Project, Settings, field};
+use crate::app::{files, process};
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(crate = "serde", deny_unknown_fields)]
+struct Image {
+ reference: String,
+ architecture: String,
+ layers: Value,
+ id: String,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(crate = "serde", deny_unknown_fields)]
+struct ImageLock {
+ format: u32,
+ platform: String,
+ services: BTreeMap<String, Image>,
+}
+
+fn inspect(project: &Project, name: &str) -> Result<Value> {
+ let records: Vec<Value> = serde_json::from_slice(
+ &process::capture(project.command("podman")?.args(["image", "inspect", name]))?.stdout,
+ )?;
+ records
+ .into_iter()
+ .next()
+ .context("Podman returned no image")
+}
+
+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();
+ let mut new_tags = Vec::new();
+ let result = save_inner(
+ project,
+ containers,
+ destination,
+ &mut services,
+ &mut new_tags,
+ );
+ for tag in new_tags {
+ if let Err(error) = process::capture(project.command("podman")?.args(["image", "rm", &tag]))
+ {
+ process::error(format_args!(
+ "Could not remove temporary image tag {tag}: {error:#}"
+ ))?;
+ }
+ }
+ result?;
+ let lock = ImageLock {
+ format: 2,
+ platform,
+ services,
+ };
+ files::write(
+ &destination.join("image-lock.json"),
+ &serde_json::to_vec_pretty(&lock)?,
+ 0o600,
+ )
+}
+
+fn save_inner(
+ project: &Project,
+ containers: &[Value],
+ destination: &Path,
+ services: &mut BTreeMap<String, Image>,
+ new_tags: &mut Vec<String>,
+) -> Result<()> {
+ for container in containers {
+ let service = field(container, &["Config", "Labels", "io.stack.service"])?
+ .as_str()
+ .context("Missing service label")?;
+ let id = field(container, &["Image"])?
+ .as_str()
+ .context("Missing image ID")?;
+ let suffix: String = id.trim_start_matches("sha256:").chars().take(16).collect();
+ let alias = format!("localhost/stack-export-{service}:{suffix}");
+ if !process::available(
+ project
+ .command("podman")?
+ .args(["image", "inspect", &alias]),
+ ) {
+ process::capture(
+ project
+ .command("podman")?
+ .args(["image", "tag", id, &alias]),
+ )?;
+ new_tags.push(alias.clone());
+ }
+ let actual = inspect(project, &alias)?;
+ services.insert(
+ service.to_owned(),
+ Image {
+ reference: alias,
+ architecture: field(&actual, &["Architecture"])?
+ .as_str()
+ .context("Missing architecture")?
+ .to_owned(),
+ layers: field(&actual, &["RootFS", "Layers"])?.clone(),
+ id: field(&actual, &["Id"])?
+ .as_str()
+ .context("Missing image ID")?
+ .trim_start_matches("sha256:")
+ .to_owned(),
+ },
+ );
+ }
+ let mut command = project.command("podman")?;
+ command
+ .args([
+ "save",
+ "--multi-image-archive",
+ "--format",
+ "docker-archive",
+ "--output",
+ ])
+ .arg(destination.join("images.tar"));
+ for image in services.values() {
+ command.arg(&image.reference);
+ }
+ process::run(&mut command)
+}
+
+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(());
+ }
+ let lock: ImageLock = serde_json::from_reader(File::open(path)?)?;
+ ensure!(lock.format == 2, "Unsupported image lock format");
+ let platform = project.platform()?;
+ if platform.trim() != lock.platform {
+ ensure!(
+ settings.mode == Mode::Local,
+ "Release images target {}, but this server is {}; build a native release locally",
+ lock.platform,
+ platform.trim()
+ );
+ settings.online = true;
+ return process::line(format_args!(
+ "Using native images/builds for {}",
+ platform.trim()
+ ));
+ }
+ let missing = lock.services.values().any(|item| {
+ project.command("podman").is_ok_and(|mut command| {
+ !process::available(command.args(["image", "inspect", &item.reference]))
+ })
+ });
+ if missing {
+ let archive = project.root.join("var/images.tar");
+ ensure!(
+ archive.is_file(),
+ "Release image archive is missing; deploy a complete release"
+ );
+ process::run(
+ project
+ .command("podman")?
+ .args(["image", "load", "--input"])
+ .arg(archive),
+ )?;
+ }
+ for (service, image) in lock.services {
+ let actual = inspect(project, &image.reference)?;
+ ensure!(
+ field(&actual, &["Architecture"])?.as_str() == Some(image.architecture.as_str())
+ && field(&actual, &["RootFS", "Layers"])? == &image.layers
+ && field(&actual, &["Id"])?
+ .as_str()
+ .context("Missing image ID")?
+ .trim_start_matches("sha256:")
+ == image.id,
+ "Loaded image does not match its content lock: {service}"
+ );
+ }
+ Ok(())
+}
+
+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 {
+ let actual = inspect(project, &value.image)?;
+ records.push(serde_json::json!({
+ "Image": field(&actual, &["Id"])?,
+ "Config": {"Labels": {"io.stack.service": service}}
+ }));
+ }
+ Ok(records)
+}
diff --git a/src/app/process.rs b/src/app/process.rs
new file mode 100644
index 0000000..02f6a6b
--- /dev/null
+++ b/src/app/process.rs
@@ -0,0 +1,70 @@
+// 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 std::fmt;
+use std::io::{self, Write};
+use std::process::{Command, Output};
+
+use anyhow::{Context, Result, bail};
+
+pub(super) fn capture(command: &mut Command) -> Result<Output> {
+ let output = command.output().with_context(|| {
+ format!(
+ "Could not start {}",
+ command.get_program().to_string_lossy()
+ )
+ })?;
+ if !output.status.success() {
+ bail!(
+ "{} failed ({}): {}",
+ command.get_program().to_string_lossy(),
+ output.status,
+ String::from_utf8_lossy(&output.stderr).trim()
+ );
+ }
+ Ok(output)
+}
+
+pub(super) fn text(command: &mut Command) -> Result<String> {
+ String::from_utf8(capture(command)?.stdout).context("Command returned non-UTF-8 output")
+}
+
+pub(super) fn run(command: &mut Command) -> Result<()> {
+ let status = command.status().with_context(|| {
+ format!(
+ "Could not start {}",
+ command.get_program().to_string_lossy()
+ )
+ })?;
+ if !status.success() {
+ bail!(
+ "{} failed ({status})",
+ command.get_program().to_string_lossy()
+ );
+ }
+ Ok(())
+}
+
+pub(super) fn available(command: &mut Command) -> bool {
+ command.output().is_ok_and(|output| output.status.success())
+}
+
+pub(super) fn line(message: fmt::Arguments<'_>) -> Result<()> {
+ writeln!(io::stdout().lock(), "{message}").context("Could not write command output")
+}
+
+pub(super) fn error(message: fmt::Arguments<'_>) -> io::Result<()> {
+ writeln!(io::stderr().lock(), "{message}")
+}
diff --git a/src/app/project.rs b/src/app/project.rs
new file mode 100644
index 0000000..ac55ff3
--- /dev/null
+++ b/src/app/project.rs
@@ -0,0 +1,539 @@
+// 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 std::collections::BTreeMap;
+use std::env;
+use std::fs::{self, File, OpenOptions, Permissions};
+use std::io::{Read, Write};
+use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
+use std::path::{Component, Path, PathBuf};
+use std::process::Command;
+use std::time::Duration;
+
+use anyhow::{Context, Result, bail, ensure};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use stack_config::Config;
+
+use crate::app::{files, process};
+
+#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(crate = "serde", rename_all = "lowercase")]
+pub(super) enum Mode {
+ #[default]
+ Local,
+ Production,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[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(super) online: bool,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[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(crate = "serde", deny_unknown_fields)]
+pub(super) struct Service {
+ pub(super) image: String,
+ pub(super) build: Option<String>,
+ #[serde(default)]
+ gateway: bool,
+ #[serde(default)]
+ environment: Vec<String>,
+ #[serde(default)]
+ drop_capabilities: bool,
+ #[serde(default)]
+ read_only: bool,
+ health: Vec<String>,
+ mounts: Vec<Mount>,
+ #[serde(default)]
+ tmpfs: Vec<String>,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(crate = "serde", deny_unknown_fields)]
+struct Mount {
+ source: String,
+ target: String,
+ #[serde(default)]
+ read_only: bool,
+}
+
+#[derive(Debug)]
+pub(super) struct Project {
+ pub(super) root: PathBuf,
+}
+
+impl Project {
+ pub(super) fn discover(explicit: Option<&Path>) -> Result<Self> {
+ if let Some(path) = explicit {
+ return Self::at(path);
+ }
+ let current = env::current_dir()?;
+ for candidate in current.ancestors() {
+ if candidate.join("infra/services.toml").is_file() {
+ return Self::at(candidate);
+ }
+ }
+ let executable = env::current_exe()?;
+ for candidate in executable.ancestors().skip(1).take(3) {
+ if candidate.join("infra/services.toml").is_file() {
+ return Self::at(candidate);
+ }
+ }
+ bail!("Run from the stack directory or supply --root PATH")
+ }
+
+ pub(super) fn at(path: &Path) -> Result<Self> {
+ let root = if path.is_absolute() {
+ path.to_path_buf()
+ } else {
+ env::current_dir()?.join(path)
+ };
+ Ok(Self { root })
+ }
+
+ 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)?)?;
+ let config = Config::load(&self.root)?;
+ if config.is_explicit("LOCAL_HTTP_PORT") {
+ settings.port = config.port("LOCAL_HTTP_PORT")?;
+ }
+ return Ok(settings);
+ }
+ Ok(Settings {
+ mode: Mode::Local,
+ port: Config::load(&self.root)?.port("LOCAL_HTTP_PORT")?,
+ project: format!(
+ "stack-{}",
+ files::hash_bytes(self.root.as_os_str().as_encoded_bytes())
+ .chars()
+ .take(10)
+ .collect::<String>()
+ ),
+ online: false,
+ })
+ }
+
+ pub(super) fn save(&self, settings: &Settings) -> Result<()> {
+ files::write(
+ &self.root.join("var/state.json"),
+ &serde_json::to_vec_pretty(settings)?,
+ 0o600,
+ )
+ }
+
+ pub(super) fn lock(&self) -> Result<File> {
+ files::private_dir(&self.root.join("var"))?;
+ let file = OpenOptions::new()
+ .read(true)
+ .write(true)
+ .create(true)
+ .truncate(false)
+ .mode(0o600)
+ .open(self.root.join("var/operation.lock"))?;
+ file.try_lock()
+ .context("Another stack operation is already running")?;
+ Ok(file)
+ }
+
+ 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
+ .root
+ .ancestors()
+ .map(|p| p.join(".tools"))
+ .find(|p| p.join("bin/cargo-deny").is_file())
+ {
+ let mut paths = vec![tools.join("bin")];
+ if let Some(inherited) = env::var_os("PATH") {
+ paths.extend(env::split_paths(&inherited));
+ }
+ command.env("PATH", env::join_paths(paths)?);
+ }
+ if name == "podman" {
+ let version = process::text(Command::new("podman").arg("--version"))?;
+ let major = version
+ .split_whitespace()
+ .nth(2)
+ .and_then(|value| value.split('.').next())
+ .context("Cannot determine the Podman version")?
+ .parse::<u32>()?;
+ if major < 6 {
+ command.env(
+ "CONTAINERS_CONF_OVERRIDE",
+ self.root.join("infra/podman.conf"),
+ );
+ }
+ command
+ .arg("--root")
+ .arg(self.root.join("var/podman/storage"))
+ .arg("--runroot")
+ .arg(self.root.join("var/podman/run"));
+ }
+ Ok(command)
+ }
+
+ pub(super) fn platform(&self) -> Result<String> {
+ let info: Value = serde_json::from_slice(
+ &process::capture(self.command("podman")?.args(["info", "--format", "json"]))?.stdout,
+ )?;
+ Ok(format!(
+ "{}/{}",
+ field(&info, &["host", "os"])?
+ .as_str()
+ .context("Missing host OS")?,
+ field(&info, &["host", "arch"])?
+ .as_str()
+ .context("Missing host architecture")?
+ ))
+ }
+
+ 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!(
+ config.format == 1 && config.services.contains_key("caddy"),
+ "Invalid services configuration"
+ );
+ if self.root.join("var/image-lock.json").is_file() && !settings.online {
+ let lock: Value =
+ serde_json::from_reader(File::open(self.root.join("var/image-lock.json"))?)?;
+ for (name, service) in &mut config.services {
+ field(&lock, &["services", name, "reference"])?
+ .as_str()
+ .context("Invalid image lock")?
+ .clone_into(&mut service.image);
+ }
+ }
+ Ok(config)
+ }
+
+ 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"] {
+ if !Config::load(&self.root)?.is_explicit(key) {
+ let mut random = [0; 32];
+ File::open("/dev/urandom")?.read_exact(&mut random)?;
+ let mut file = OpenOptions::new()
+ .create(true)
+ .append(true)
+ .mode(0o600)
+ .open(&env_file)?;
+ writeln!(file, "\n{key}={}", hex::encode(random))?;
+ }
+ }
+ stack_config::initialize_public(&self.root)?;
+ fs::set_permissions(&env_file, Permissions::from_mode(0o600))?;
+ for name in ["chat", "chat-local"] {
+ files::private_dir(&self.root.join("var/data").join(name))?;
+ }
+ for name in ["repos", "caddy/data", "caddy/config", "searxng"] {
+ fs::create_dir_all(self.root.join("var/data").join(name))?;
+ }
+ fs::set_permissions(
+ self.root.join("var/data/repos"),
+ Permissions::from_mode(0o755),
+ )?;
+ let repository = self.root.join("var/data/repos/stack");
+ if !repository.exists() {
+ if self.root.join(".git").is_dir() {
+ process::capture(
+ self.command("git")?
+ .args(["clone", "--bare", "."])
+ .arg(&repository),
+ )?;
+ } else {
+ process::capture(
+ self.command("git")?
+ .args(["init", "--bare", "--initial-branch=main"])
+ .arg(&repository),
+ )?;
+ }
+ }
+ files::copy_tree(
+ &self.root.join("infra/searxng"),
+ &self.root.join("var/data/searxng"),
+ )?;
+ crate::app::render::prepare(self, settings)
+ }
+
+ pub(super) fn container_name(settings: &Settings, service: &str) -> String {
+ format!("{}-{service}", settings.project)
+ }
+
+ pub(super) fn inspect_containers(&self, settings: &Settings) -> Result<Vec<Value>> {
+ let output = process::text(self.command("podman")?.args([
+ "ps",
+ "--all",
+ "--quiet",
+ "--filter",
+ &format!("label=io.stack.project={}", settings.project),
+ ]))?;
+ let ids: Vec<_> = output.split_whitespace().collect();
+ ensure!(!ids.is_empty(), "No stack containers found");
+ Ok(serde_json::from_slice(
+ &process::capture(self.command("podman")?.arg("inspect").args(ids))?.stdout,
+ )?)
+ }
+
+ pub(super) fn up(&self, settings: &Settings) -> Result<()> {
+ ensure!(
+ (1024..=65_534).contains(&settings.port),
+ "Local port must be between 1024 and 65534"
+ );
+ self.initialize(settings)?;
+ self.save(settings)?;
+ let config = self.configuration(settings)?;
+ let network = format!("{}-net", settings.project);
+ if !process::available(
+ self.command("podman")?
+ .args(["network", "exists", &network]),
+ ) {
+ process::capture(
+ self.command("podman")?
+ .args(["network", "create", &network]),
+ )?;
+ }
+ let environment = Config::load(&self.root)?;
+ for (name, service) in config
+ .services
+ .iter()
+ .filter(|(_, service)| !service.gateway)
+ .chain(
+ config
+ .services
+ .iter()
+ .filter(|(_, service)| service.gateway),
+ )
+ {
+ self.ensure_image(settings, service)?;
+ self.start_service(settings, &network, name, service, &environment)?;
+ self.wait_service(settings, name, service)?;
+ }
+ process::line(format_args!(
+ "Running {}",
+ if settings.mode == Mode::Production {
+ environment
+ .public_values(None)?
+ .get("SITE_ORIGIN")
+ .context("Missing public site origin")?
+ .to_owned()
+ } else {
+ format!("http://localhost:{}", settings.port)
+ }
+ ))
+ }
+
+ 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::app::chat::build(self)?;
+ }
+ return process::run(
+ self.command("podman")?
+ .args(["build", "--tag", &service.image])
+ .arg(self.path(context)?),
+ );
+ }
+ if process::available(
+ self.command("podman")?
+ .args(["image", "exists", &service.image]),
+ ) {
+ return Ok(());
+ }
+ ensure!(
+ settings.mode == Mode::Local,
+ "Production images must be built and deployed from the development machine"
+ );
+ process::run(self.command("podman")?.args(["pull", &service.image]))
+ }
+
+ fn start_service(
+ &self,
+ settings: &Settings,
+ network: &str,
+ name: &str,
+ service: &Service,
+ environment: &Config,
+ ) -> Result<()> {
+ let container = Self::container_name(settings, name);
+ let mut command = self.command("podman")?;
+ command.args([
+ "run",
+ "--detach",
+ "--replace",
+ "--pull",
+ "never",
+ "--name",
+ &container,
+ "--network",
+ network,
+ "--network-alias",
+ name,
+ "--restart",
+ "unless-stopped",
+ "--label",
+ &format!("io.stack.project={}", settings.project),
+ "--label",
+ &format!("io.stack.service={name}"),
+ "--security-opt",
+ "no-new-privileges",
+ ]);
+ if service.read_only {
+ command.args(["--read-only", "--read-only-tmpfs=false"]);
+ }
+ if service.drop_capabilities {
+ command.args(["--cap-drop", "ALL"]);
+ }
+ if service.gateway {
+ let ports = if settings.mode == Mode::Local {
+ let tls = settings.port.checked_add(1).context("Port overflow")?;
+ [
+ format!("127.0.0.1:{}:80", settings.port),
+ format!("127.0.0.1:{tls}:443"),
+ format!("127.0.0.1:{tls}:443/udp"),
+ ]
+ .to_vec()
+ } else {
+ let http = environment.port("HTTP_PORT")?;
+ let https = environment.port("HTTPS_PORT")?;
+ vec![
+ format!("{}:{http}:{http}", crate::app::chat::web_bind(environment)?),
+ format!(
+ "{}:{https}:{https}",
+ crate::app::chat::web_bind(environment)?
+ ),
+ format!(
+ "{}:{https}:{https}/udp",
+ crate::app::chat::web_bind(environment)?
+ ),
+ ]
+ };
+ for port in ports {
+ command.args(["--publish", &port]);
+ }
+ }
+ for key in &service.environment {
+ command
+ .arg("--env")
+ .arg(key)
+ .env(key, environment.value(key)?);
+ }
+ if name == "chat" {
+ crate::app::chat::environment(&mut command, environment, settings)?;
+ }
+ for mount in &service.mounts {
+ let source = match mount.source.as_str() {
+ "@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 {
+ "var/data/chat"
+ }),
+ other => self.path(other)?,
+ };
+ let mode = if mount.read_only { "ro,z" } else { "rw,z" };
+ command
+ .arg("--volume")
+ .arg(format!("{}:{}:{mode}", source.display(), mount.target));
+ }
+ for mount in &service.tmpfs {
+ command.args(["--tmpfs", mount]);
+ }
+ process::capture(command.arg(&service.image))?;
+ Ok(())
+ }
+
+ fn wait_service(&self, settings: &Settings, name: &str, service: &Service) -> Result<()> {
+ let container = Self::container_name(settings, name);
+ for _attempt in 0..120 {
+ if process::available(
+ self.command("podman")?
+ .args(["exec", &container])
+ .args(&service.health),
+ ) {
+ return Ok(());
+ }
+ std::thread::sleep(Duration::from_millis(500));
+ }
+ bail!("Service did not become ready: {name}; inspect its Podman logs")
+ }
+
+ pub(super) fn status(&self, settings: &Settings) -> Result<()> {
+ process::run(self.command("podman")?.args([
+ "ps",
+ "--all",
+ "--filter",
+ &format!("label=io.stack.project={}", settings.project),
+ ]))
+ }
+
+ pub(super) fn down(&self, settings: &Settings) -> Result<()> {
+ for name in self.configuration(settings)?.services.keys() {
+ process::capture(self.command("podman")?.args([
+ "rm",
+ "--force",
+ "--ignore",
+ &Self::container_name(settings, name),
+ ]))?;
+ }
+ let network = format!("{}-net", settings.project);
+ if process::available(
+ self.command("podman")?
+ .args(["network", "exists", &network]),
+ ) {
+ process::capture(self.command("podman")?.args(["network", "rm", &network]))?;
+ }
+ Ok(())
+ }
+
+ fn path(&self, relative: &str) -> Result<PathBuf> {
+ let path = Path::new(relative);
+ ensure!(
+ path.components()
+ .all(|part| matches!(part, Component::Normal(_))),
+ "Service paths must stay inside stack"
+ );
+ Ok(self.root.join(path))
+ }
+}
+
+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/app/release.rs b/src/app/release.rs
new file mode 100644
index 0000000..268a3c2
--- /dev/null
+++ b/src/app/release.rs
@@ -0,0 +1,127 @@
+// 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 std::fs;
+use std::path::Path;
+
+use anyhow::{Context, Result, ensure};
+
+use crate::app::project::{Mode, Project};
+use crate::app::{files, images, process};
+
+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(
+ project
+ .root
+ .join("target/x86_64-unknown-linux-musl/release/stack"),
+ )?,
+ 0o755,
+ )?;
+ let mut settings = project.settings()?;
+ settings.mode = Mode::Local;
+ settings.online = true;
+ for service in project.configuration(&settings)?.services.values() {
+ project.ensure_image(&settings, service)?;
+ }
+ files::private_dir(destination)?;
+ let temp = tempfile::Builder::new()
+ .prefix(".release-")
+ .tempdir_in(destination)?;
+ let stage = temp.path().join("stack");
+ files::private_dir(&stage)?;
+ files::copy_source(&project.root, &stage)?;
+ crate::app::chat::copy_artifacts(project, &stage)?;
+ fs::create_dir_all(stage.join("bin"))?;
+ fs::copy(
+ project
+ .root
+ .join("target/x86_64-unknown-linux-musl/release/stack"),
+ stage.join("bin/stack"),
+ )?;
+ images::save(
+ project,
+ &images::configured(project, &settings)?,
+ &stage.join("var"),
+ )?;
+ files::manifest(&stage, "release")?;
+ let output = destination.join(format!(
+ "stack-release-{}.tar.gz",
+ chrono::Utc::now().format("%Y%m%dT%H%M%SZ")
+ ));
+ files::pack(&stage, &output)?;
+ let filename = output
+ .file_name()
+ .context("Missing archive name")?
+ .to_string_lossy();
+ files::write(
+ &output.with_extension("gz.sha256"),
+ format!("{} {filename}\n", files::hash_file(&output)?).as_bytes(),
+ 0o600,
+ )?;
+ process::line(format_args!("Release: {}", output.display()))
+}
+
+pub(super) fn activate(archive: &Path, project: &Project) -> Result<()> {
+ let temp = tempfile::Builder::new()
+ .prefix(".activate-")
+ .tempdir_in(&project.root)?;
+ let stage = temp.path().join("stack");
+ files::extract(archive, &stage)?;
+ ensure!(
+ files::verify(&stage)?.kind == "release",
+ "Activation requires a release archive"
+ );
+ let pending = Project::at(&stage)?;
+ let mut settings = project.settings()?;
+ settings.mode = Mode::Production;
+ settings.online = false;
+ let lock = stage.join("var/image-lock.json");
+ let info: serde_json::Value = serde_json::from_slice(&fs::read(&lock)?)?;
+ ensure!(
+ info.get("platform").and_then(serde_json::Value::as_str)
+ == Some(project.platform()?.as_str()),
+ "Release architecture does not match the server"
+ );
+ let _config = pending.configuration(&settings)?;
+ for name in files::SOURCE {
+ let source = stage.join(name);
+ if source.exists() {
+ let target = project.root.join(name);
+ if target.is_dir() {
+ files::regular_tree(&target)?;
+ fs::remove_dir_all(&target)?;
+ }
+ files::copy_tree(&source, &target)?;
+ }
+ }
+ files::write(
+ &project.root.join("bin/stack"),
+ &fs::read(stage.join("bin/stack"))?,
+ 0o755,
+ )?;
+ crate::app::chat::copy_artifacts(&pending, &project.root)?;
+ for name in ["images.tar", "image-lock.json"] {
+ files::copy_tree(
+ &stage.join("var").join(name),
+ &project.root.join("var").join(name),
+ )?;
+ }
+ images::prepare(project, &mut settings)?;
+ project.up(&settings)
+}
diff --git a/src/app/render.rs b/src/app/render.rs
new file mode 100644
index 0000000..8a2d2ee
--- /dev/null
+++ b/src/app/render.rs
@@ -0,0 +1,130 @@
+// 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 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(super) fn directory(project: &Project, settings: &Settings) -> PathBuf {
+ project.root.join(match settings.mode {
+ Mode::Local => "var/local",
+ Mode::Production => "var/production",
+ })
+}
+
+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);
+ let web = output.join("web");
+ if web.exists() {
+ files::regular_tree(&web)?;
+ fs::remove_dir_all(&web)?;
+ }
+ files::copy_tree(&project.root.join("web"), &web)?;
+ let file = web.join("index.html");
+ files::write(
+ &file,
+ config
+ .render(&fs::read_to_string(&file)?, local)?
+ .as_bytes(),
+ 0o644,
+ )?;
+ let template = project.root.join(if settings.mode == Mode::Local {
+ "infra/Caddyfile.local"
+ } else {
+ "infra/Caddyfile"
+ });
+ files::write(
+ &output.join("Caddyfile"),
+ config
+ .render(&fs::read_to_string(template)?, local)?
+ .as_bytes(),
+ 0o644,
+ )?;
+ let cgit = fs::read_to_string(project.root.join("infra/containers/cgit/cgitrc"))?;
+ let mut rendered = config.render(&cgit, local)?;
+ if let Some(port) = local {
+ rendered = rendered.replace(
+ "clone-prefix=/git",
+ &format!("clone-prefix=http://localhost:{port}/git"),
+ );
+ }
+ files::write(&output.join("cgitrc"), rendered.as_bytes(), 0o644)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn custom_domains_and_ports_reach_public_outputs_without_secrets() -> Result<()> {
+ let root = tempfile::tempdir()?;
+ for name in ["web", "infra"] {
+ files::copy_tree(
+ &PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(name),
+ &root.path().join(name),
+ )?;
+ }
+ fs::write(
+ root.path().join(".env"),
+ "SITE_HOST=home.example.net\nGIT_HOST=git.example.net\nHTTP_PORT=8181\nHTTPS_PORT=8443\nSEARXNG_SECRET=do-not-publish-this-sentinel\n",
+ )?;
+ let project = Project::at(root.path())?;
+ let mut settings = project.settings()?;
+ settings.mode = Mode::Production;
+ prepare(&project, &settings)?;
+ let output = directory(&project, &settings);
+ let caddy = fs::read_to_string(output.join("Caddyfile"))?;
+ 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"))?;
+ anyhow::ensure!(
+ homepage.contains("https://git.example.net:8443"),
+ "Test invariant failed"
+ );
+ let stylesheet = files::hash_file(&root.path().join("web/styles.css"))?;
+ 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"))?;
+ 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"),
+ "Test invariant failed"
+ );
+ Ok(())
+ }
+}