Initial release: Ironpad v0.1.0 - Local-first, file-based project and knowledge management system. Rust backend, Vue 3 frontend, Milkdown editor, Git integration, cross-platform builds. Built with AI using Open Method.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
skepsismusic
2026-02-06 00:13:31 +01:00
commit ebe3e2aa8f
97 changed files with 25033 additions and 0 deletions

130
backend/src/main.rs Normal file
View File

@@ -0,0 +1,130 @@
use std::net::SocketAddr;
use std::path::Path;
use std::sync::Arc;
use axum::{routing::get, Router};
use tokio::net::TcpListener;
use tower_http::cors::CorsLayer;
use tower_http::services::ServeDir;
use tracing::{info, warn};
pub mod config;
mod models;
mod routes;
mod services;
mod watcher;
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) {
for port in 3000..=3010 {
let addr = SocketAddr::from(([127, 0, 0, 1], port));
if let Ok(listener) = TcpListener::bind(addr).await {
return (listener, port);
}
}
panic!("No available ports in range 30003010");
}
#[tokio::main]
async fn main() {
// Logging
tracing_subscriber::fmt().init();
// Resolve data directory (production vs development mode)
config::init_data_dir();
// Find port and bind (listener kept alive to avoid race condition)
let (listener, port) = find_available_port().await;
// WebSocket state (shared across handlers)
let ws_state = Arc::new(websocket::WsState::new());
// Start file watcher
let ws_state_clone = ws_state.clone();
if let Err(e) = watcher::start_watcher(ws_state_clone).await {
warn!("File watcher failed to start: {}", e);
}
// Initialize git repo if needed
if let Err(e) = services::git::init_repo() {
warn!("Git init skipped: {}", e);
}
// Start auto-commit background task (tries to commit every 60s)
services::git::start_auto_commit();
// CORS layer (permissive for local-only app)
let cors = CorsLayer::permissive();
// API router
let api_router = Router::new()
// Notes CRUD
.route(
"/notes",
get(routes::notes::list_notes).post(routes::notes::create_note),
)
.nest("/notes", routes::notes::router())
// Tasks
.nest("/tasks", routes::tasks::router())
// Search
.nest("/search", routes::search::router())
// Git
.nest("/git", routes::git::router())
// Projects
.nest("/projects", routes::projects::router())
// Daily notes
.nest("/daily", routes::daily::router())
// Assets
.nest("/assets", routes::assets::router());
// App router with WebSocket state
let mut app = Router::new()
.route("/health", get(|| async { "ok" }))
.route(
"/ws",
get({
let ws = ws_state.clone();
move |upgrade: axum::extract::WebSocketUpgrade| {
websocket::ws_handler(upgrade, axum::extract::State(ws))
}
}),
)
.nest("/api", api_router)
.layer(cors);
// Check for embedded frontend (production mode)
let static_dir = Path::new("static");
let has_frontend = static_dir.join("index.html").exists();
if has_frontend {
// Production mode: serve frontend from static/ and use SPA fallback
info!("Production mode: serving frontend from static/");
let serve_dir = ServeDir::new("static")
.fallback(tower_http::services::ServeFile::new("static/index.html"));
app = app.fallback_service(serve_dir);
} else {
// Development mode: API-only
app = app.fallback(|| async {
"Ironpad API server running. Use 'npm run dev' in frontend/ for the GUI."
});
}
// Start server
info!("🚀 Ironpad running on http://localhost:{port}");
// Auto-open browser in production mode
if has_frontend {
let url = format!("http://localhost:{}", port);
tokio::spawn(async move {
// Small delay to ensure server is ready
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
if let Err(e) = webbrowser::open(&url) {
tracing::warn!("Failed to open browser: {}. Open http://localhost:{} manually.", e, port);
}
});
}
axum::serve(listener, app).await.expect("Server failed");
}