9 Commits
v0.2.0 ... v.1

Author SHA1 Message Date
5299190973 Add ssh skeleton directory
Some checks failed
CI / Backend (Rust) (push) Failing after 11s
CI / Frontend (Vue) (push) Successful in 10m2s
Release / Build Linux (x86_64) (push) Failing after 54s
Release / Build macOS (x86_64) (push) Has been cancelled
Release / Build Windows (x86_64) (push) Has been cancelled
Release / Create Release (push) Has been cancelled
2026-02-27 13:00:02 +00:00
Keith Solomon
f1f106c948 🐞 fix: Even more git fixes
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m57s
2026-02-25 08:05:02 -06:00
Keith Solomon
2a55d5ebec 🐞 fix: More git fixes
Some checks failed
CI / Backend (Rust) (push) Failing after 10s
CI / Frontend (Vue) (push) Successful in 9m57s
2026-02-23 18:25:20 -06:00
Keith Solomon
adefed7c74 🐞 fix: Git fixes
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m57s
2026-02-23 17:04:36 -06:00
Keith Solomon
d39bbd1801 feature: Add remote git support
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m56s
2026-02-23 08:50:06 -06:00
Keith Solomon
0b5816621b 🐞 fix: Update #2 for dbus error
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m54s
2026-02-23 08:22:32 -06:00
Keith Solomon
97508e4f0a 🐞 fix: Update for dbus error
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m53s
2026-02-23 06:53:14 -06:00
Keith Solomon
d98181bed9 feature: Add docker support
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 10m0s
2026-02-22 22:38:55 -06:00
skepsismusic
10ead43260 Update Cargo.lock for ksni dependency and add release to backend gitignore
Some checks failed
CI / Backend (Rust) (push) Failing after 1m36s
CI / Frontend (Vue) (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 14:21:36 +01:00
10 changed files with 690 additions and 33 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.github
.ferrite
backend/target
frontend/node_modules
frontend/dist
data
docs
*.log
*.tmp

1
.gitignore vendored
View File

@@ -37,6 +37,7 @@ data/notes/assets/*
!data/notes/assets/.gitkeep
data/projects/*/
!data/projects/.gitkeep
Codex Session.txt
# === Stray root lock file (frontend/package-lock.json is kept for CI) ===
/package-lock.json

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
FROM node:20-bookworm-slim AS frontend-builder
WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM rust:1.85-bookworm AS backend-builder
WORKDIR /src/backend
RUN apt-get update \
&& apt-get install -y --no-install-recommends pkg-config libdbus-1-dev \
&& rm -rf /var/lib/apt/lists/*
COPY backend/ ./
RUN cargo build --release
FROM debian:bookworm-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates ripgrep libdbus-1-3 git openssh-client \
&& rm -rf /var/lib/apt/lists/*
COPY --from=backend-builder /src/backend/target/release/ironpad /app/ironpad
COPY --from=frontend-builder /src/frontend/dist /app/static
RUN mkdir -p /app/data
ENV IRONPAD_HOST=0.0.0.0
ENV IRONPAD_PORT=3000
ENV IRONPAD_DISABLE_TRAY=1
ENV IRONPAD_DATA_DIR=/app/data
ENV RUST_LOG=info
EXPOSE 3000
VOLUME ["/app/data"]
CMD ["./ironpad"]

View File

@@ -68,6 +68,53 @@ npm run dev
Open http://localhost:5173 in your browser.
### Option 3: Run with Docker (Centralized Server)
This runs Ironpad as a single container that serves both API and frontend on port `3000`.
```bash
# Build and start in the background
docker compose up -d --build
# View logs
docker compose logs -f
```
Then open:
- `http://localhost:3000` from the same machine, or
- `http://<your-server-ip>:3000` from another device on your network.
Data persists in `./data` on the host via the compose volume mapping.
To stop:
```bash
docker compose down
```
#### Docker + Private Git Remote Sync
Ironpad can automatically sync the `data/` git repo with a private remote over SSH.
1. Put your SSH key files on the host (example: `./deploy/ssh/id_ed25519` and `./deploy/ssh/id_ed25519.pub`).
2. Uncomment the SSH volume mount and git env vars in `docker-compose.yml`.
3. Set:
- `IRONPAD_GIT_REMOTE_URL` (example: `git@github.com:your-org/ironpad-data.git`)
- `IRONPAD_GIT_SSH_PRIVATE_KEY` (path inside container)
- `IRONPAD_GIT_SSH_KNOWN_HOSTS` (optional; defaults to `/root/.ssh/known_hosts`)
- `IRONPAD_GIT_SYNC_INTERVAL_SECS` (example: `300`)
4. Recreate the stack:
```bash
docker compose up -d --build
```
Sync behavior:
- Every cycle: `fetch -> safe fast-forward if possible -> push`
- If local and remote diverge, auto fast-forward is skipped and a warning is logged.
- If libgit2 SSH auth fails, Ironpad can fall back to `git` CLI (controlled by `IRONPAD_GIT_USE_CLI_FALLBACK`, default `true`).
## Tech Stack
![Tech Stack](docs/graphics/tech-stack.png)
@@ -121,7 +168,18 @@ Read about the method:
|---------|---------|-------------|
| Data directory | `data/` next to executable | Override with `IRONPAD_DATA_DIR` env var |
| Backend port | 3000 (auto-increments to 3010) | Dynamic port selection |
| Backend host | `127.0.0.1` | Override with `IRONPAD_HOST` (use `0.0.0.0` for Docker/server access) |
| Fixed port | disabled | Set `IRONPAD_PORT` to force a specific port |
| Disable tray mode | `false` | Set `IRONPAD_DISABLE_TRAY=1` to run headless in production static mode |
| Auto-commit | Every 60 seconds | Git commits when changes exist |
| Git remote URL | not set | `IRONPAD_GIT_REMOTE_URL` creates/updates `origin` |
| Git sync interval | `0` (disabled) | Set `IRONPAD_GIT_SYNC_INTERVAL_SECS` to enable scheduled sync |
| Git SSH private key | not set | `IRONPAD_GIT_SSH_PRIVATE_KEY` path to private key in container |
| Git SSH public key | not set | Optional `IRONPAD_GIT_SSH_PUBLIC_KEY` path |
| Git known_hosts path | `/root/.ssh/known_hosts` | Override with `IRONPAD_GIT_SSH_KNOWN_HOSTS` |
| Git SSH username | `git` | Override with `IRONPAD_GIT_SSH_USERNAME` if needed |
| Git SSH passphrase | not set | Optional `IRONPAD_GIT_SSH_PASSPHRASE` |
| Git CLI fallback | `true` | `IRONPAD_GIT_USE_CLI_FALLBACK` for fetch/push auth fallback |
| Auto-save | 1 second debounce | Frontend saves after typing stops |
## Documentation

1
backend/.gitignore vendored
View File

@@ -1 +1,2 @@
/target
/release

161
backend/Cargo.lock generated
View File

@@ -20,6 +20,15 @@ dependencies = [
"libc",
]
[[package]]
name = "ansi_term"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
dependencies = [
"winapi",
]
[[package]]
name = "anyhow"
version = "1.0.101"
@@ -32,6 +41,17 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "atty"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
dependencies = [
"hermit-abi",
"libc",
"winapi",
]
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -188,6 +208,21 @@ dependencies = [
"windows-link",
]
[[package]]
name = "clap"
version = "2.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c"
dependencies = [
"ansi_term",
"atty",
"bitflags 1.3.2",
"strsim",
"textwrap",
"unicode-width",
"vec_map",
]
[[package]]
name = "cocoa"
version = "0.25.0"
@@ -318,6 +353,37 @@ version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "dbus"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4"
dependencies = [
"libc",
"libdbus-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "dbus-codegen"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a49da9fdfbe872d4841d56605dc42efa5e6ca3291299b87f44e1cde91a28617c"
dependencies = [
"clap",
"dbus",
"xml-rs",
]
[[package]]
name = "dbus-tree"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f456e698ae8e54575e19ddb1f9b7bce2298568524f215496b248eb9498b4f508"
dependencies = [
"dbus",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -665,6 +731,15 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
dependencies = [
"libc",
]
[[package]]
name = "http"
version = "1.4.0"
@@ -1014,6 +1089,18 @@ dependencies = [
"libc",
]
[[package]]
name = "ksni"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4934310bdd016e55725482b8d35ac0c16fd058c1b955d8959aa2d953b918c85b"
dependencies = [
"dbus",
"dbus-codegen",
"dbus-tree",
"thiserror 1.0.69",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -1032,6 +1119,15 @@ version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]]
name = "libdbus-sys"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
dependencies = [
"pkg-config",
]
[[package]]
name = "libgit2-sys"
version = "0.17.0+1.8.1"
@@ -1713,6 +1809,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strsim"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
[[package]]
name = "syn"
version = "2.0.116"
@@ -1750,6 +1852,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "textwrap"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
dependencies = [
"unicode-width",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -2025,6 +2136,7 @@ checksum = "59d4bd406170690dc30eabb3badc67a085beaf9b2c3b1923afcc9c26a2191353"
dependencies = [
"cocoa",
"core-graphics",
"ksni",
"libc",
"objc",
"objc-foundation",
@@ -2074,6 +2186,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-xid"
version = "0.2.6"
@@ -2133,6 +2251,12 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "vec_map"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
[[package]]
name = "version_check"
version = "0.9.5"
@@ -2278,6 +2402,22 @@ dependencies = [
"web-sys",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -2287,6 +2427,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -2373,6 +2519,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
@@ -2744,6 +2899,12 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
name = "xml-rs"
version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]]
name = "yoke"
version = "0.8.1"

View File

@@ -1,7 +1,7 @@
// Hide console window on Windows in release builds (production mode)
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::net::SocketAddr;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use axum::{routing::get, Router};
@@ -20,22 +20,58 @@ mod websocket;
/// Find an available port and return the bound listener.
/// Avoids TOCTOU race by keeping the listener alive.
async fn find_available_port() -> (TcpListener, u16) {
let host = std::env::var("IRONPAD_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let bind_ip = match host.parse::<IpAddr>() {
Ok(ip) => ip,
Err(_) => {
warn!("Invalid IRONPAD_HOST '{}', falling back to 127.0.0.1", host);
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))
}
};
if let Ok(port_str) = std::env::var("IRONPAD_PORT") {
match port_str.parse::<u16>() {
Ok(port) => {
let addr = SocketAddr::new(bind_ip, port);
let listener = TcpListener::bind(addr)
.await
.unwrap_or_else(|e| panic!("Failed to bind to {addr}: {e}"));
return (listener, port);
}
Err(_) => {
warn!("Invalid IRONPAD_PORT '{}', falling back to 3000-3010", port_str);
}
}
}
for port in 3000..=3010 {
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let addr = SocketAddr::new(bind_ip, port);
if let Ok(listener) = TcpListener::bind(addr).await {
return (listener, port);
}
}
panic!("No available ports in range 30003010");
panic!("No available ports in range 3000-3010");
}
fn env_flag(name: &str) -> bool {
std::env::var(name)
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
}
fn main() {
tracing_subscriber::fmt().init();
config::init_data_dir();
if config::is_production() {
let production = config::is_production();
let disable_tray = env_flag("IRONPAD_DISABLE_TRAY");
if production && !disable_tray {
run_with_tray();
} else {
if production && disable_tray {
info!("Production static mode detected; running headless (IRONPAD_DISABLE_TRAY=1)");
}
// Development mode: normal tokio runtime, no tray
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(run_server(None));
@@ -66,8 +102,14 @@ async fn run_server(port_tx: Option<std::sync::mpsc::Sender<u16>>) {
warn!("Git init skipped: {}", e);
}
// Configure git remote from env (if provided)
if let Err(e) = services::git::configure_remote_from_env() {
warn!("Git remote setup skipped: {}", e);
}
// Start auto-commit background task (tries to commit every 60s)
services::git::start_auto_commit();
services::git::start_auto_sync();
// CORS layer (permissive for local-only app)
let cors = CorsLayer::permissive();
@@ -130,7 +172,11 @@ async fn run_server(port_tx: Option<std::sync::mpsc::Sender<u16>>) {
}
// Start server
info!("Ironpad running on http://localhost:{port}");
let bound_addr = listener
.local_addr()
.map(|a| a.to_string())
.unwrap_or_else(|_| format!("127.0.0.1:{port}"));
info!("Ironpad running on http://{bound_addr}");
axum::serve(listener, app).await.expect("Server failed");
}

View File

@@ -1,3 +1,5 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use chrono::Utc;
@@ -96,6 +98,171 @@ pub struct RemoteInfo {
/// The background task simply tries to commit every interval;
/// commit_all() already handles "no changes" gracefully.
#[derive(Debug, Clone)]
struct GitAuthConfig {
username: String,
private_key: Option<PathBuf>,
public_key: Option<PathBuf>,
passphrase: Option<String>,
}
fn git_auth_config() -> GitAuthConfig {
let username = std::env::var("IRONPAD_GIT_SSH_USERNAME").unwrap_or_else(|_| "git".to_string());
let private_key = std::env::var("IRONPAD_GIT_SSH_PRIVATE_KEY")
.ok()
.map(PathBuf::from)
.filter(|p| p.exists());
let public_key = std::env::var("IRONPAD_GIT_SSH_PUBLIC_KEY")
.ok()
.map(PathBuf::from)
.filter(|p| p.exists());
let passphrase = std::env::var("IRONPAD_GIT_SSH_PASSPHRASE")
.ok()
.and_then(|s| {
let trimmed = s.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
});
GitAuthConfig {
username,
private_key,
public_key,
passphrase,
}
}
fn remote_callbacks() -> git2::RemoteCallbacks<'static> {
let auth = git_auth_config();
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.credentials(move |_url, username_from_url, _allowed_types| {
// Always prefer configured username (env-driven).
// Some remotes embed a username in URL; that can cause auth mismatches.
let username = auth.username.as_str();
if let Some(url_user) = username_from_url {
if url_user != username {
tracing::warn!(
"Remote URL username '{}' differs from IRONPAD_GIT_SSH_USERNAME '{}'; using env value",
url_user,
username
);
}
}
if let Some(private_key) = auth.private_key.as_deref() {
let public_key: Option<&Path> = auth.public_key.as_deref();
let passphrase = auth.passphrase.as_deref();
// First try with configured public key path (if provided),
// then retry without public key file to avoid mismatch issues.
if let Some(pub_key_path) = public_key {
match git2::Cred::ssh_key(username, Some(pub_key_path), private_key, passphrase) {
Ok(cred) => return Ok(cred),
Err(e) => {
tracing::warn!(
"SSH key auth with explicit public key failed for user '{}', private '{}', public '{}': {}",
username,
private_key.display(),
pub_key_path.display(),
e
);
}
}
}
match git2::Cred::ssh_key(username, None, private_key, passphrase) {
Ok(cred) => return Ok(cred),
Err(e) => {
tracing::warn!(
"SSH key auth from private key failed for user '{}', key '{}': {}",
username,
private_key.display(),
e
);
}
}
} else {
tracing::warn!(
"IRONPAD_GIT_SSH_PRIVATE_KEY not set or file missing; falling back to SSH agent"
);
}
git2::Cred::ssh_key_from_agent(username)
});
callbacks
}
fn use_git_cli_fallback() -> bool {
std::env::var("IRONPAD_GIT_USE_CLI_FALLBACK")
.map(|v| !matches!(v.to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off"))
.unwrap_or(true)
}
fn known_hosts_path() -> Option<PathBuf> {
if let Ok(path) = std::env::var("IRONPAD_GIT_SSH_KNOWN_HOSTS") {
let p = PathBuf::from(path);
if p.exists() {
return Some(p);
}
}
let default = PathBuf::from("/root/.ssh/known_hosts");
if default.exists() {
Some(default)
} else {
None
}
}
fn git_ssh_command(auth: &GitAuthConfig) -> Option<String> {
let private_key = auth.private_key.as_ref()?;
let mut cmd = format!(
"ssh -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes",
private_key.display()
);
if let Some(known_hosts) = known_hosts_path() {
cmd.push_str(&format!(" -o UserKnownHostsFile={}", known_hosts.display()));
}
Some(cmd)
}
fn run_git_cli(args: &[&str]) -> Result<(), String> {
let auth = git_auth_config();
let data_path = config::data_dir();
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(data_path)
.env("GIT_TERMINAL_PROMPT", "0");
if let Some(ssh_cmd) = git_ssh_command(&auth) {
cmd.env("GIT_SSH_COMMAND", ssh_cmd);
}
let output = cmd
.output()
.map_err(|e| format!("Failed to run git CLI: {}", e))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let msg = if !stderr.is_empty() { stderr } else { stdout };
Err(format!(
"git {} failed (exit {}): {}",
args.join(" "),
output.status.code().unwrap_or(-1),
msg
))
}
}
/// Get repository status
pub fn get_status() -> Result<RepoStatus, String> {
let data_path = config::data_dir();
@@ -317,28 +484,34 @@ pub fn push_to_remote() -> Result<(), String> {
return Err("No remote URL configured".to_string());
}
// Create callbacks for authentication
let mut callbacks = git2::RemoteCallbacks::new();
// Try to use credential helper from git config
callbacks.credentials(|_url, username_from_url, _allowed_types| {
// Try SSH agent first
git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
});
// Set up push options
let mut push_options = git2::PushOptions::new();
push_options.remote_callbacks(callbacks);
push_options.remote_callbacks(remote_callbacks());
// Push the current branch
let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name);
remote
.push(&[&refspec], Some(&mut push_options))
.map_err(|e| format!("Push failed: {}. Make sure SSH keys are configured.", e))?;
match remote.push(&[&refspec], Some(&mut push_options)) {
Ok(_) => {
tracing::info!("Successfully pushed to origin/{}", branch_name);
Ok(())
}
Err(e) => {
let err_text = e.to_string();
if !use_git_cli_fallback() {
return Err(format!(
"Push failed: {}. Make sure SSH keys are configured.",
err_text
));
}
tracing::warn!("libgit2 push failed, trying git CLI fallback: {}", err_text);
run_git_cli(&["push", "origin", branch_name]).map_err(|cli_err| {
format!("Push failed: {} (libgit2) / {} (git CLI)", err_text, cli_err)
})?;
tracing::info!("Successfully pushed to origin/{} (git CLI fallback)", branch_name);
Ok(())
}
}
}
/// Check if remote is configured
pub fn has_remote() -> bool {
@@ -351,6 +524,30 @@ pub fn has_remote() -> bool {
false
}
/// Configure `origin` from IRONPAD_GIT_REMOTE_URL.
/// If `origin` exists, updates its URL. Otherwise creates it.
pub fn configure_remote_from_env() -> Result<(), String> {
let remote_url = match std::env::var("IRONPAD_GIT_REMOTE_URL") {
Ok(url) if !url.trim().is_empty() => url,
_ => return Ok(()),
};
let data_path = config::data_dir();
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
if repo.find_remote("origin").is_ok() {
repo.remote_set_url("origin", &remote_url)
.map_err(|e| format!("Failed to update origin URL: {}", e))?;
tracing::info!("Updated git remote origin from IRONPAD_GIT_REMOTE_URL");
} else {
repo.remote("origin", &remote_url)
.map_err(|e| format!("Failed to create origin remote: {}", e))?;
tracing::info!("Configured git remote origin from IRONPAD_GIT_REMOTE_URL");
}
Ok(())
}
/// Start auto-commit background task.
/// Tries to commit every 60 seconds; commit_all() already handles "no changes" gracefully.
pub fn start_auto_commit() {
@@ -374,6 +571,111 @@ pub fn start_auto_commit() {
});
}
fn fast_forward_to_upstream(repo: &Repository, branch_name: &str) -> Result<(), String> {
let local_branch = repo
.find_branch(branch_name, git2::BranchType::Local)
.map_err(|e| format!("Local branch not found: {}", e))?;
let upstream_branch = match local_branch.upstream() {
Ok(upstream) => upstream,
Err(_) => return Ok(()),
};
let local_oid = match local_branch.get().target() {
Some(oid) => oid,
None => return Ok(()),
};
let upstream_oid = match upstream_branch.get().target() {
Some(oid) => oid,
None => return Ok(()),
};
let (ahead, behind) = repo
.graph_ahead_behind(local_oid, upstream_oid)
.map_err(|e| e.to_string())?;
if behind == 0 {
return Ok(());
}
if ahead > 0 {
tracing::warn!(
"Remote is ahead by {} and local is ahead by {}; skipping auto fast-forward",
behind,
ahead
);
return Ok(());
}
let refname = format!("refs/heads/{}", branch_name);
repo.reference(&refname, upstream_oid, true, "ironpad auto fast-forward")
.map_err(|e| format!("Failed to move local branch ref: {}", e))?;
repo.set_head(&refname)
.map_err(|e| format!("Failed to set HEAD: {}", e))?;
let mut checkout = git2::build::CheckoutBuilder::new();
checkout.force();
repo.checkout_head(Some(&mut checkout))
.map_err(|e| format!("Failed to checkout fast-forwarded HEAD: {}", e))?;
tracing::info!("Fast-forwarded local branch {} by {} commit(s)", branch_name, behind);
Ok(())
}
fn sync_once() -> Result<(), String> {
configure_remote_from_env()?;
if !has_remote() {
return Ok(());
}
fetch_from_remote()?;
// Try to fast-forward local branch to upstream when safe (no divergence).
let data_path = config::data_dir();
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
if let Ok(head) = repo.head() {
if let Some(branch_name) = head.shorthand() {
let _ = fast_forward_to_upstream(&repo, branch_name);
}
}
// Push local commits (if any) after fetching.
if let Err(e) = push_to_remote() {
if !(e.contains("non-fast-forward") || e.contains("rejected")) {
return Err(e);
}
tracing::warn!("Auto-sync push skipped: {}", e);
}
Ok(())
}
/// Start periodic remote sync.
/// Controlled via env vars:
/// - IRONPAD_GIT_SYNC_INTERVAL_SECS: sync interval in seconds (default 0 = disabled)
/// - IRONPAD_GIT_REMOTE_URL: optional remote URL used to create/update `origin`
pub fn start_auto_sync() {
let interval_secs = std::env::var("IRONPAD_GIT_SYNC_INTERVAL_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
if interval_secs == 0 {
tracing::info!("Git auto-sync disabled (set IRONPAD_GIT_SYNC_INTERVAL_SECS > 0 to enable)");
return;
}
tokio::spawn(async move {
let mut ticker = interval(Duration::from_secs(interval_secs));
loop {
ticker.tick().await;
if let Err(e) = sync_once() {
tracing::warn!("Git auto-sync failed: {}", e);
}
}
});
}
/// Get commit history (most recent first)
pub fn get_log(limit: Option<usize>) -> Result<Vec<CommitDetail>, String> {
let data_path = config::data_dir();
@@ -630,18 +932,20 @@ pub fn fetch_from_remote() -> Result<(), String> {
.find_remote("origin")
.map_err(|e| format!("Remote 'origin' not found: {}", e))?;
// Create callbacks for authentication
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.credentials(|_url, username_from_url, _allowed_types| {
git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
});
let mut fetch_options = git2::FetchOptions::new();
fetch_options.remote_callbacks(callbacks);
fetch_options.remote_callbacks(remote_callbacks());
remote
.fetch(&[] as &[&str], Some(&mut fetch_options), None)
.map_err(|e| format!("Fetch failed: {}", e))?;
Ok(())
match remote.fetch(&[] as &[&str], Some(&mut fetch_options), None) {
Ok(_) => Ok(()),
Err(e) => {
let err_text = e.to_string();
if !use_git_cli_fallback() {
return Err(format!("Fetch failed: {}", err_text));
}
tracing::warn!("libgit2 fetch failed, trying git CLI fallback: {}", err_text);
run_git_cli(&["fetch", "origin", "--prune"]).map_err(|cli_err| {
format!("Fetch failed: {} (libgit2) / {} (git CLI)", err_text, cli_err)
})
}
}
}

0
deploy/ssh/.gitkeep Normal file
View File

32
docker-compose.yml Normal file
View File

@@ -0,0 +1,32 @@
services:
ironpad:
container_name: ironpad
restart: unless-stopped
build:
context: .
dockerfile: Dockerfile
environment:
IRONPAD_HOST: "0.0.0.0"
IRONPAD_PORT: "3000"
IRONPAD_DISABLE_TRAY: "1"
IRONPAD_DATA_DIR: "/app/data"
RUST_LOG: "info"
# Git sync (optional)
# IRONPAD_GIT_REMOTE_URL: "git@github.com:your-org/your-private-repo.git"
# IRONPAD_GIT_SSH_USERNAME: "git"
# IRONPAD_GIT_SSH_PRIVATE_KEY: "/run/secrets/ironpad_ssh/id_ed25519"
# IRONPAD_GIT_SSH_PUBLIC_KEY: "/run/secrets/ironpad_ssh/id_ed25519.pub"
# IRONPAD_GIT_SSH_KNOWN_HOSTS: "/run/secrets/ironpad_ssh/known_hosts"
# IRONPAD_GIT_SSH_PASSPHRASE: ""
# IRONPAD_GIT_SYNC_INTERVAL_SECS: "300"
# IRONPAD_GIT_USE_CLI_FALLBACK: "true"
ports:
- "3000:3000"
volumes:
- ./data:/app/data
# Mount SSH key material read-only for private remote auth
# - ./deploy/ssh:/run/secrets/ironpad_ssh:ro