diff --git a/README.md b/README.md index 9651426..1d32376 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ pnpm desktop:build Packaged builds on `main` CI upload installers as Actions artifacts (`desktop-windows-x86_64`, `desktop-macos-aarch64`, `desktop-linux-x86_64`). Details: [`apps/desktop/README.md`](./apps/desktop/README.md). +The desktop shell spawns a bundled Node API sidecar, injects `window.__PRM_API_BASE_URL__` + launch token into the webview, and stores vault data under the OS app-data directory (not a CI build path). If unlock hangs, check `desktop-api.log` next to the vault. + ### Cursor MCP (bulk load) `@prm/mcp` unlocks the vault and writes dossier evidence. Pair it with Jira, Bitbucket, or other MCPs when you need a cold-start backfill instead of typing every note by hand. diff --git a/apps/api/src/seed.ts b/apps/api/src/seed.ts index cda0c9f..dba19b3 100644 --- a/apps/api/src/seed.ts +++ b/apps/api/src/seed.ts @@ -379,7 +379,10 @@ export async function seedWorkspace(opts: { }; } -if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("seed.ts")) { +// Only when running `tsx src/seed.ts` directly — never when bundled into the desktop +// sidecar entry (`prm-api.mjs`), where import.meta.url === argv[1] would also match. +const entry = process.argv[1] ?? ""; +if (/(^|[/\\])seed\.(ts|js|mjs)$/.test(entry)) { seedWorkspace() .then((r) => { console.log("Seeded workspace:", r); diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 674f6be..db8e887 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -15,9 +15,9 @@ pnpm desktop:dev 1. Writes a shared `PRM_LAUNCH_TOKEN` under `$PRM_DATA_DIR/.launch_token` (default `data/`) 2. Starts API + UI with that env so the API requires `x-prm-launch-token` -3. The Tauri process reads the same token, injects `window.__PRM_LAUNCH_TOKEN__`, and exposes `get_launch_config` IPC +3. The Tauri process reads the same token, injects `window.__PRM_LAUNCH_TOKEN__` and `window.__PRM_API_BASE_URL__`, and exposes `get_launch_config` IPC -Browser-only `pnpm dev` does **not** set the launch token (middleware stays off). +Browser-only `pnpm dev` does **not** set the launch token (middleware stays off). The UI uses relative `/api` under Vite (proxied). Packaged builds must use the injected absolute API base URL — relative `/api` has no proxy in the Tauri webview. Optional: set `PRM_SPAWN_API=1` so the shell also starts the API via `pnpm exec tsx` (useful when not using `desktop-dev.sh`). @@ -38,7 +38,9 @@ pnpm desktop:build 1. Bundles `@prm/api` into `src-tauri/sidecars/prm-api.mjs` (esbuild) and copies a portable **Node** binary + `better-sqlite3` next to it 2. Builds the UI into `apps/ui/dist` (Tauri `frontendDist` is `../../ui/dist` from `src-tauri`) -At runtime the shell resolves `sidecars/` from the app resource dir (or `PRM_API_SIDECAR_DIR`), spawns bundled Node with the API entry, sets `PRM_LAUNCH_TOKEN` / `PRM_DATA_DIR`, and kills the child on exit — one process from the user’s point of view. +At runtime the shell resolves `sidecars/` from the app resource dir (or `PRM_API_SIDECAR_DIR`), spawns bundled Node with the API entry, sets `PRM_LAUNCH_TOKEN` / `PRM_DATA_DIR`, waits until `/api/health` responds, and kills the child on exit — one process from the user’s point of view. + +Packaged data lives in the OS app-data directory (for example `%APPDATA%\com.prm.workbench` on Windows). Sidecar stdout/stderr is appended to `desktop-api.log` there for debugging startup failures. CI builds the same matrix (Ubuntu / macOS / Windows) on pushes to `main`. Each green desktop job uploads an Actions artifact (retained 30 days): diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 1095c0c..5e5a8df 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -2,15 +2,20 @@ use std::{ fs, - io::Read, + io::{Read, Write}, + net::TcpStream, path::{Path, PathBuf}, process::{Child, Command, Stdio}, sync::Mutex, + thread, + time::{Duration, Instant}, }; use serde::Serialize; -use tauri::{Manager, State, WebviewUrl, WebviewWindowBuilder}; +use tauri::{AppHandle, Manager, State, WebviewUrl, WebviewWindowBuilder}; +const DEFAULT_API_HOST: &str = "127.0.0.1"; +const DEFAULT_API_PORT: u16 = 8787; const DEFAULT_API_BASE: &str = "http://127.0.0.1:8787"; const DEV_UI_URL: &str = "http://localhost:5173"; @@ -33,11 +38,32 @@ fn get_launch_config(state: State<'_, LaunchState>) -> Result PathBuf { +fn api_port() -> u16 { + std::env::var("PRM_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_API_PORT) +} + +fn api_base_url() -> String { + std::env::var("PRM_API_BASE_URL").unwrap_or_else(|_| { + format!("http://{DEFAULT_API_HOST}:{}", api_port()) + }) +} + +fn resolve_data_dir(app: &AppHandle) -> PathBuf { if let Ok(dir) = std::env::var("PRM_DATA_DIR") { return PathBuf::from(dir); } - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../data") + // Dev: keep repo `data/` next to the monorepo for shared browser/desktop use. + if cfg!(debug_assertions) { + return PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../data"); + } + // Packaged: never use CARGO_MANIFEST_DIR (that is a CI/build-machine path). + match app.path().app_data_dir() { + Ok(dir) => dir, + Err(_) => std::env::temp_dir().join("prm-workbench-data"), + } } fn read_launch_token(path: &Path) -> Option { @@ -71,8 +97,8 @@ fn random_token() -> String { buf.iter().map(|b| format!("{b:02x}")).collect() } -fn resolve_launch_config() -> LaunchConfig { - let token_path = data_dir().join(".launch_token"); +fn resolve_launch_config(data: &Path) -> LaunchConfig { + let token_path = data.join(".launch_token"); let from_env = std::env::var("PRM_LAUNCH_TOKEN") .ok() .map(|s| s.trim().to_string()) @@ -82,7 +108,7 @@ fn resolve_launch_config() -> LaunchConfig { .unwrap_or_else(random_token); let _ = write_launch_token(&token_path, &token); LaunchConfig { - api_base_url: std::env::var("PRM_API_BASE_URL").unwrap_or_else(|_| DEFAULT_API_BASE.into()), + api_base_url: api_base_url(), launch_token: token, } } @@ -100,10 +126,22 @@ fn candidate_sidecar_dirs(resource_dir: Option<&Path>) -> Vec { dirs } +fn append_log(path: &Path, line: &str) { + if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(f, "{line}"); + } +} + /// Spawn the local Node API when a bundled sidecar exists or `PRM_SPAWN_API=1`. -fn maybe_spawn_api(token: &str, resource_dir: Option<&Path>) -> Option { +fn maybe_spawn_api( + token: &str, + data: &Path, + resource_dir: Option<&Path>, + log_path: &Path, +) -> Option { let force = std::env::var("PRM_SPAWN_API").ok().as_deref() == Some("1"); let api_entry = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../api/src/index.ts"); + let port = api_port(); let mut program: Option = None; let mut args: Vec = Vec::new(); @@ -137,56 +175,153 @@ fn maybe_spawn_api(token: &str, resource_dir: Option<&Path>) -> Option { let (program, args, cwd) = match (program, cwd) { (Some(p), Some(c)) => (p, args, c), - _ => return None, + _ => { + append_log(log_path, "PRM: no API sidecar found (prm-api.mjs missing)"); + return None; + } }; - let data = data_dir(); - let _ = fs::create_dir_all(&data); + let _ = fs::create_dir_all(data); + append_log( + log_path, + &format!( + "PRM: spawning API sidecar program={program} cwd={} data={} port={port}", + cwd.display(), + data.display() + ), + ); + match Command::new(&program) .args(&args) .current_dir(&cwd) .env("PRM_LAUNCH_TOKEN", token) - .env("PRM_DATA_DIR", &data) - .env("PRM_PORT", "8787") + .env("PRM_DATA_DIR", data) + .env("PRM_PORT", port.to_string()) .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) .spawn() { - Ok(child) => { - eprintln!("PRM: spawned API sidecar ({program})"); + Ok(mut child) => { + let log = log_path.to_path_buf(); + if let Some(mut out) = child.stdout.take() { + thread::spawn(move || { + let mut buf = String::new(); + let _ = out.read_to_string(&mut buf); + if !buf.trim().is_empty() { + append_log(&log, &format!("PRM API stdout:\n{buf}")); + } + }); + } + let log = log_path.to_path_buf(); + if let Some(mut err) = child.stderr.take() { + thread::spawn(move || { + let mut buf = String::new(); + let _ = err.read_to_string(&mut buf); + if !buf.trim().is_empty() { + append_log(&log, &format!("PRM API stderr:\n{buf}")); + } + }); + } + append_log(log_path, &format!("PRM: spawned API sidecar ({program})")); Some(child) } Err(err) => { - eprintln!("PRM: failed to spawn API sidecar: {err}"); + append_log(log_path, &format!("PRM: failed to spawn API sidecar: {err}")); None } } } -fn main() { - let launch = resolve_launch_config(); - std::env::set_var("PRM_LAUNCH_TOKEN", &launch.launch_token); - let token_for_spawn = launch.launch_token.clone(); +fn api_health_ok(port: u16) -> bool { + let addr = format!("{DEFAULT_API_HOST}:{port}"); + let Ok(mut stream) = TcpStream::connect_timeout( + &addr.parse().expect("api addr"), + Duration::from_millis(400), + ) else { + return false; + }; + let _ = stream.set_read_timeout(Some(Duration::from_millis(800))); + let _ = stream.set_write_timeout(Some(Duration::from_millis(800))); + let req = format!( + "GET /api/health HTTP/1.1\r\nHost: {DEFAULT_API_HOST}:{port}\r\nConnection: close\r\n\r\n" + ); + if stream.write_all(req.as_bytes()).is_err() { + return false; + } + let mut buf = Vec::new(); + let _ = stream.read_to_end(&mut buf); + let body = String::from_utf8_lossy(&buf); + body.contains("200") && body.contains("ok") +} - let inject = format!( - "window.__PRM_LAUNCH_TOKEN__={};", - serde_json::to_string(&launch.launch_token).unwrap_or_else(|_| "\"\"".into()) +fn wait_for_api(port: u16, timeout: Duration, log_path: &Path) -> bool { + let start = Instant::now(); + while start.elapsed() < timeout { + if api_health_ok(port) { + append_log( + log_path, + &format!("PRM: API healthy on :{port} after {:?}", start.elapsed()), + ); + return true; + } + thread::sleep(Duration::from_millis(200)); + } + append_log( + log_path, + &format!("PRM: API did not become healthy on :{port} within {timeout:?}"), ); + false +} +fn main() { tauri::Builder::default() - .manage(LaunchState(Mutex::new(launch))) + .manage(LaunchState(Mutex::new(LaunchConfig { + api_base_url: DEFAULT_API_BASE.into(), + launch_token: String::new(), + }))) .manage(ApiChild(Mutex::new(None))) .invoke_handler(tauri::generate_handler![get_launch_config]) .setup(move |app| { + let data = resolve_data_dir(app.handle()); + let _ = fs::create_dir_all(&data); + let log_path = data.join("desktop-api.log"); + let launch = resolve_launch_config(&data); + std::env::set_var("PRM_LAUNCH_TOKEN", &launch.launch_token); + std::env::set_var("PRM_DATA_DIR", &data); + + if let Ok(mut guard) = app.state::().0.lock() { + *guard = launch.clone(); + } + let resource_dir = app.path().resource_dir().ok(); - let child = maybe_spawn_api(&token_for_spawn, resource_dir.as_deref()); - if let Some(state) = app.try_state::() { - if let Ok(mut guard) = state.0.lock() { - *guard = child; - } + let child = maybe_spawn_api( + &launch.launch_token, + &data, + resource_dir.as_deref(), + &log_path, + ); + if let Ok(mut guard) = app.state::().0.lock() { + *guard = child; + } + + let port = api_port(); + let ready = wait_for_api(port, Duration::from_secs(25), &log_path); + if !ready { + eprintln!( + "PRM: API sidecar not healthy yet — see {}", + log_path.display() + ); } + let inject = format!( + "window.__PRM_LAUNCH_TOKEN__={};window.__PRM_API_BASE_URL__={};", + serde_json::to_string(&launch.launch_token).unwrap_or_else(|_| "\"\"".into()), + serde_json::to_string(&launch.api_base_url).unwrap_or_else(|_| { + serde_json::to_string(DEFAULT_API_BASE).unwrap_or_else(|_| "\"\"".into()) + }), + ); + let url = if cfg!(debug_assertions) { WebviewUrl::External(DEV_UI_URL.parse().expect("dev ui url")) } else { diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 32d8b45..fd3cef7 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -1,7 +1,7 @@ import { NavLink, Navigate, Route, Routes } from "react-router-dom"; import { useEffect, useState } from "react"; import type { WorkspaceStatus } from "@prm/shared"; -import { api, getToken, setToken } from "./lib/api"; +import { api, getToken, setToken, waitForApi } from "./lib/api"; import { useTheme } from "./hooks/useTheme"; import { UnlockPage } from "./pages/UnlockPage"; import { HomePage } from "./pages/HomePage"; @@ -26,6 +26,7 @@ export function App() { async function refresh() { try { + await waitForApi(); const s = await api("/api/workspace/status"); setStatus(s); setError(null); diff --git a/apps/ui/src/lib/api.ts b/apps/ui/src/lib/api.ts index 03381d3..be3dbf1 100644 --- a/apps/ui/src/lib/api.ts +++ b/apps/ui/src/lib/api.ts @@ -3,6 +3,8 @@ const TOKEN_KEY = "prm_token"; declare global { interface Window { __PRM_LAUNCH_TOKEN__?: string; + /** Absolute API origin injected by the Tauri shell (e.g. http://127.0.0.1:8787). */ + __PRM_API_BASE_URL__?: string; } } @@ -21,6 +23,40 @@ function launchToken(): string | null { return window.__PRM_LAUNCH_TOKEN__?.trim() || null; } +/** Packaged desktop sets this; browser/Vite leaves it empty so `/api` stays relative (proxy). */ +export function apiBaseUrl(): string { + if (typeof window === "undefined") return ""; + return window.__PRM_API_BASE_URL__?.trim().replace(/\/$/, "") || ""; +} + +/** Resolve an API path for fetch (absolute in Tauri, relative under Vite). */ +export function apiUrl(path: string): string { + if (/^https?:\/\//i.test(path)) return path; + const normalized = path.startsWith("/") ? path : `/${path}`; + const base = apiBaseUrl(); + return base ? `${base}${normalized}` : normalized; +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Wait until the local API answers /api/health (desktop sidecar warmup). */ +export async function waitForApi(opts?: { attempts?: number; delayMs?: number }): Promise { + const attempts = opts?.attempts ?? (apiBaseUrl() ? 40 : 1); + const delayMs = opts?.delayMs ?? 250; + for (let i = 0; i < attempts; i++) { + try { + const res = await fetch(apiUrl("/api/health"), { method: "GET" }); + if (res.ok) return true; + } catch { + /* still starting */ + } + if (i < attempts - 1) await sleep(delayMs); + } + return false; +} + export async function api(path: string, init: RequestInit = {}): Promise { const headers = new Headers(init.headers); if (!headers.has("Content-Type") && !(init.body instanceof FormData)) { @@ -30,7 +66,7 @@ export async function api(path: string, init: RequestInit = {}): Promise { if (token) headers.set("x-prm-token", token); const launch = launchToken(); if (launch) headers.set("x-prm-launch-token", launch); - const res = await fetch(path, { ...init, headers }); + const res = await fetch(apiUrl(path), { ...init, headers }); if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })); throw new Error((err as { error?: string }).error || "Request failed"); diff --git a/apps/ui/src/pages/PersonPage.tsx b/apps/ui/src/pages/PersonPage.tsx index a3e80a1..5dec08e 100644 --- a/apps/ui/src/pages/PersonPage.tsx +++ b/apps/ui/src/pages/PersonPage.tsx @@ -10,7 +10,7 @@ import type { PersonExternalLinkDTO, PersonTimelineDTO, } from "@prm/shared"; -import { api, getToken } from "../lib/api"; +import { api, getToken, apiUrl } from "../lib/api"; import { NextStep, PageHeader } from "../components/PageChrome"; export function PersonPage() { @@ -424,7 +424,7 @@ export function PersonPage() { {d.title} ({d.filename} · {d.visibility} · {d.occurredAt}){" "} { e.preventDefault(); - fetch(`/api/documents/${d.id}/download`, { headers: { "x-prm-token": getToken() ?? "" } }) + fetch(apiUrl(`/api/documents/${d.id}/download`), { headers: { "x-prm-token": getToken() ?? "" } }) .then((r) => r.blob()) .then((blob) => { const url = URL.createObjectURL(blob); @@ -447,9 +447,13 @@ export function PersonPage() { fd.append("title", docTitle || docFile.name); fd.append("visibility", "em_and_subject"); fd.append("occurredAt", new Date().toISOString().slice(0, 10)); - const res = await fetch(`/api/people/${id}/documents`, { + const headers: Record = { "x-prm-token": getToken() ?? "" }; + if (typeof window !== "undefined" && window.__PRM_LAUNCH_TOKEN__) { + headers["x-prm-launch-token"] = window.__PRM_LAUNCH_TOKEN__; + } + const res = await fetch(apiUrl(`/api/people/${id}/documents`), { method: "POST", - headers: { "x-prm-token": getToken() ?? "" }, + headers, body: fd, }); if (!res.ok) { diff --git a/apps/ui/src/pages/PromotionDetailPage.tsx b/apps/ui/src/pages/PromotionDetailPage.tsx index 6ed8b41..4eb5c3f 100644 --- a/apps/ui/src/pages/PromotionDetailPage.tsx +++ b/apps/ui/src/pages/PromotionDetailPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; import type { GapMark, RoleDefinitionDTO } from "@prm/shared"; -import { api, getToken } from "../lib/api"; +import { api, getToken, apiUrl } from "../lib/api"; import { NextStep, PageHeader } from "../components/PageChrome"; type PromoDetail = { @@ -89,7 +89,7 @@ export function PromotionDetailPage() { async function openHtmlPacket() { if (!id) return; const token = getToken(); - const res = await fetch(`/api/exports/promotion/${id}?format=html`, { + const res = await fetch(apiUrl(`/api/exports/promotion/${id}?format=html`), { headers: token ? { "x-prm-token": token } : {}, }); const html = await res.text(); diff --git a/apps/ui/src/pages/RolesPage.tsx b/apps/ui/src/pages/RolesPage.tsx index dc9b4e5..c16f7c4 100644 --- a/apps/ui/src/pages/RolesPage.tsx +++ b/apps/ui/src/pages/RolesPage.tsx @@ -6,7 +6,7 @@ import type { RoleDraftDTO, RoleFrameworkInfo, } from "@prm/shared"; -import { api, getToken } from "../lib/api"; +import { api, getToken, apiUrl } from "../lib/api"; import { NextStep, PageHeader } from "../components/PageChrome"; type ExpectationEdit = { id?: string; kind: "responsibility" | "expectation"; text: string }; @@ -163,7 +163,7 @@ export function RolesPage() { if (typeof window !== "undefined" && window.__PRM_LAUNCH_TOKEN__) { headers["x-prm-launch-token"] = window.__PRM_LAUNCH_TOKEN__; } - const res = await fetch("/api/roles/framework/upload", { method: "POST", headers, body: fd }); + const res = await fetch(apiUrl("/api/roles/framework/upload"), { method: "POST", headers, body: fd }); const json = (await res.json()) as { error?: string; textPreview?: string | null; diff --git a/apps/ui/src/pages/TeamPage.tsx b/apps/ui/src/pages/TeamPage.tsx index bb52c8e..76da4d8 100644 --- a/apps/ui/src/pages/TeamPage.tsx +++ b/apps/ui/src/pages/TeamPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import type { PeopleImportResult, PersonDTO } from "@prm/shared"; -import { api, getToken } from "../lib/api"; +import { api, getToken, apiUrl } from "../lib/api"; import { NextStep, PageHeader } from "../components/PageChrome"; export function TeamPage() { @@ -182,7 +182,7 @@ export function TeamPage() { if (token) headers["x-prm-token"] = token; const launch = typeof window !== "undefined" ? window.__PRM_LAUNCH_TOKEN__ : undefined; if (launch) headers["x-prm-launch-token"] = launch; - const res = await fetch("/api/people/import/template", { headers }); + const res = await fetch(apiUrl("/api/people/import/template"), { headers }); if (!res.ok) { setImportMsg("Could not download template"); return; diff --git a/apps/ui/src/pages/WritingDeskPage.tsx b/apps/ui/src/pages/WritingDeskPage.tsx index babbcf1..e50173e 100644 --- a/apps/ui/src/pages/WritingDeskPage.tsx +++ b/apps/ui/src/pages/WritingDeskPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; import type { BiasFinding, DecisionLogEntry, GapMark, PacketDTO, PromoReadiness } from "@prm/shared"; import { COMPETENCY_CATALOG, MIDDLE_BAND, competencyLabel } from "@prm/shared"; -import { api, getToken } from "../lib/api"; +import { api, getToken, apiUrl } from "../lib/api"; import { NextStep, PageHeader } from "../components/PageChrome"; type EvidenceLinkDraft = { @@ -325,7 +325,7 @@ export function WritingDeskPage() { async function openSubjectHtml() { if (!cycleId || !personId) return; const token = getToken(); - const res = await fetch(`/api/exports/subject/${cycleId}/${personId}?format=html`, { + const res = await fetch(apiUrl(`/api/exports/subject/${cycleId}/${personId}?format=html`), { headers: token ? { "x-prm-token": token } : {}, }); if (!res.ok) {