diff --git a/CHANGELOG.md b/CHANGELOG.md index 9799e3f..b3df051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,70 +12,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [v1.2.3] - 2026-06-06 - -### Features - -- heph-tui's status line now shows a live sync indicator for spokes: how long since the last successful sync (`⟳ 5m`), a red `⚠ auth` when the hub is rejecting the token (re-login needed), `⚠ offline` when the hub is unreachable, and a `⚠ N conflicts` chip when merge conflicts are pending. The daemon tracks this health and exposes it via `sync.status` (also visible in `heph sync --status`), so a silently-broken spoke is obvious at a glance instead of buried in the log. - -### Documentation - -- [[set-up-sync-hub]] now documents recommended Authentik token-validity settings (access + refresh token lifetime) to avoid frequent re-logins, with an iOS PWA storage-eviction caveat; [[host-heph-pwa]] points the PWA's login note at it. - - -## [v1.2.2] - 2026-06-06 - -### Features - -- Recurring tasks now show their schedule in plain language (`every other week`, `weekdays`, `yearly on Apr 15`) instead of a raw RRULE — in both the TUI detail pane and the mobile PWA. The TUI's project sidebar gained subproject indentation, per-project outstanding-task counts, a wider pane, and scrolling when the list overflows. - -### Documentation - -- New explanation card [[hub-spoke-data-evolution]] covering why heph's op-based sync lets most new features ship without a coordinated migration, and the narrow case (a new required SQLite column) that does need a hub-first rollout. - - -## [v1.2.1] - 2026-06-05 - -### Features - -- heph-pwa: added a **Login with Authentik** button — a proper browser OIDC sign-in (Authorization Code + PKCE) that replaces the manual bearer-token paste. The hub exposes an unauthenticated `GET /config` (`{issuer, client_id}`) so the app is zero-config when served from the hub; the PWA discovers the IdP endpoints, runs the PKCE redirect, exchanges the code for a token, and silently refreshes it (`offline_access`). The manual token field remains as a fallback. Requires the PWA origin registered as a redirect URI on the Authentik `heph` provider. - - -## [v1.2.0] - 2026-06-04 - -### Features - -- New **heph-pwa** mobile app: an installable, phone-first PWA that mirrors heph-tui — browse the built-in views and projects, triage tasks, and capture new tasks fast with the same quick-add syntax (`p1-4`, `#Project`, `today/+3d/fri`, `every …`) and live preview. Voice capture via on-device dictation. The hub (`hephd --mode server`) gains CORS and an optional `--web-root` so it can serve the app same-origin straight from the daemon. - - -## [v1.1.1] - 2026-06-04 - -### Bug Fixes - -- Fix `hephd --self-update` never detecting releases: the release poll used the daemon's `reqwest` client, which is built without a TLS backend (`default-features = false`), so every HTTPS request to the forge failed (`release check failed: requesting forge releases/latest`). The poll now uses `ureq` — already a dependency, with a rustls/ring TLS stack that needs no system libraries (and no cmake/`aws-lc-sys`). Hub sync is unaffected (it is plain HTTP). - - -## [v1.1.0] - 2026-06-04 - -### Features - -- Opt-in (default off) **hephd self-update**: `hephd --self-update` polls the forge for a newer release on an interval and, when one appears, rebuilds via `cargo install` from the release tag (anonymous HTTPS clone of the public repo — no credentials) and restarts onto the new binary. Enable it on the managed service with `heph daemon start --self-update` (which also bakes a cargo-capable `PATH` into the launchd/systemd unit and switches systemd to `Restart=always` so a clean self-exit respawns). The install mechanism is verified end-to-end; a live cross-version upgrade is confirmed on the first release after this lands. Also hardens hub resilience: the daemon's HTTP client now has a 30s timeout so a black-hole hub can't stall the sync/self-update loop. - - -## [v1.0.3] - 2026-06-04 - -### Features - -- New `heph context ` command reads or edits a task's canonical-context doc body **by task id**, with no manual `canonical_context_id` lookup. With no flag it prints the body; `--body ` replaces it (`-` reads stdin, like `node update`); `--append ` adds a blank-line-separated paragraph. Errors clearly on a node that has no canonical-context doc (e.g. a plain doc, not a task). -- `heph-tui --version` now reports the version plus build commit (e.g. `1.0.0 (ab6701d12)`), matching `heph` and `hephd`. All three daily-driver binaries answer `--version` consistently. -- `--project ` is now case-insensitive and prefix-fuzzy when unambiguous, across `heph task`, `heph edit`, `heph promote`, `heph list`, and project-parent resolution. `--project heph` or `--project hephaestus` both resolve `Hephaestus`. An exact (case-sensitive) title always wins outright, and an ambiguous prefix (e.g. `Wor` matching both `Work` and `Workshop`) resolves to nothing rather than silently picking one. A new `project.resolve` RPC backs the shared resolver. -- New `version` RPC returns the daemon's build version (`heph_core::VERSION`, e.g. `1.0.0 (ab6701d12)`), so RPC clients — notably the `hephaestus.nvim` plugin's `:Heph version` command — can report which `hephd` they are talking to without shelling out to the binary. - -### Infrastructure - -- Added a `cargo-fmt-check` pre-push prek hook that runs `cargo fmt --all --check` (mirroring CI's Dagger `check` step) whenever a push touches a `.rs` file. The pre-commit `cargo-fmt` hook reformats in place, but only fires when installed and run; the pre-push check is a last-line guard so an unformatted commit can't reach the runner. Run `prek install --hook-type pre-push` to activate it. - - ## [v1.0.2] - 2026-06-04 ### Bug Fixes diff --git a/Cargo.lock b/Cargo.lock index be8f974..0a2c89f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2274,7 +2274,6 @@ dependencies = [ "rand 0.8.6", "reqwest", "rsa", - "semver", "serde", "serde_json", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index e24c881..7d34a27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,7 +59,6 @@ reqwest = { version = "0.13", default-features = false, features = [ "json", "query", ] } -semver = "1" [profile.release] lto = "thin" diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index 6554d48..e5ff637 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -38,7 +38,7 @@ pub use filter::{builtin as builtin_view, ListFilter, ViewSpec, BUILTIN_VIEWS}; pub use hlc::{Hlc, HlcClock}; pub use model::{ deterministic_id, Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, - NodeKind, ProjectOverview, SchedulePatch, SyncCursors, Task, TaskState, + NodeKind, SchedulePatch, SyncCursors, Task, TaskState, }; pub use oplog::Op; pub use ranking::{rank, Dimension, RankedTask, RANKING}; diff --git a/crates/heph-core/src/model.rs b/crates/heph-core/src/model.rs index 783f4cf..423c96b 100644 --- a/crates/heph-core/src/model.rs +++ b/crates/heph-core/src/model.rs @@ -314,24 +314,6 @@ pub struct Health { pub sync_status: String, } -/// A project plus the two facts a sidebar needs to render it as a counted, -/// indented tree (§8.1): its parent project (via a `parent` link, if any) and -/// the number of outstanding tasks filed **directly** under it. Pure read-side — -/// both derive from existing data, so this carries no schema or sync change (see -/// [[hub-spoke-data-evolution]]). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ProjectOverview { - /// The project node id. - pub id: String, - /// The project's title. - pub title: String, - /// The parent project's node id, or `None` for a top-level project. - pub parent_id: Option, - /// Outstanding tasks filed directly in this project (children counted under - /// their own row, not summed here). - pub outstanding: usize, -} - /// An ambiguous merge surfaced to the user (a discarded LWW value, tech-spec /// §12). The winning value is already in the store; this records what was /// dropped so `heph conflicts` can show and settle it. diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index af07a8f..28d73b5 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -32,8 +32,8 @@ use crate::error::{Error, Result}; use crate::filter::ListFilter; use crate::hlc::Hlc; use crate::model::{ - Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, ProjectOverview, - SchedulePatch, SyncCursors, Task, TaskState, + Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch, + SyncCursors, Task, TaskState, }; use crate::oplog::Op; use crate::ranking::RankedTask; @@ -297,10 +297,6 @@ impl Store for LocalStore { tasks::health(&self.conn, &self.owner_id) } - fn project_overview(&self) -> Result> { - tasks::project_overview(&self.conn, &self.owner_id) - } - fn search(&self, query: &str) -> Result> { nodes::search(&self.conn, &self.owner_id, query) } @@ -502,67 +498,6 @@ mod tests { assert!(store.project_scope("Nope").is_err()); } - #[test] - fn project_overview_carries_parent_and_direct_outstanding_count() { - use crate::model::{LinkType, NewNode, NewTask, NodeKind, TaskState}; - let mut store = store_at(1); - let mk_proj = |store: &mut LocalStore, title: &str| { - store - .create_node(NewNode { - kind: NodeKind::Project, - title: title.into(), - body: None, - }) - .unwrap() - .id - }; - let mk_task = |store: &mut LocalStore, title: &str, project: Option<&str>| { - store - .create_task(NewTask { - title: title.into(), - attention: None, - do_date: None, - late_on: None, - recurrence: None, - project_id: project.map(String::from), - }) - .unwrap() - .node_id - }; - - let work = mk_proj(&mut store, "Work"); - let sub = mk_proj(&mut store, "Work Sub"); - mk_proj(&mut store, "Garden"); - // Work Sub is a child of Work (child holds the `parent` link → parent). - store.add_link(&sub, &work, LinkType::Parent).unwrap(); - - // Two outstanding + one done in Work; one outstanding in the subproject. - mk_task(&mut store, "ship", Some(&work)); - mk_task(&mut store, "review", Some(&work)); - let done = mk_task(&mut store, "archived", Some(&work)); - store.set_task_state(&done, TaskState::Done).unwrap(); - mk_task(&mut store, "nested", Some(&sub)); - // An unfiled task counts toward no project. - mk_task(&mut store, "loose", None); - - let overview = store.project_overview().unwrap(); - // Title-sorted. - let titles: Vec<_> = overview.iter().map(|p| p.title.as_str()).collect(); - assert_eq!(titles, ["Garden", "Work", "Work Sub"]); - - let by_title = |t: &str| overview.iter().find(|p| p.title == t).unwrap(); - assert_eq!(by_title("Work").outstanding, 2, "done task excluded"); - assert_eq!(by_title("Work").parent_id, None); - assert_eq!( - by_title("Work Sub").outstanding, - 1, - "direct only, not summed" - ); - assert_eq!(by_title("Work Sub").parent_id, Some(work.clone())); - assert_eq!(by_title("Garden").outstanding, 0); - assert_eq!(by_title("Garden").parent_id, None); - } - #[test] fn resolve_project_is_fuzzy_only_when_unambiguous() { use crate::model::{NewNode, NodeKind}; diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 29ac341..3df2a53 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -3,8 +3,6 @@ //! A committed task is a `task` node plus a `tasks` row. On creation it also //! gets a canonical context `doc` and a `canonical-context` link (tech-spec §6). -use std::collections::HashMap; - use rusqlite::{Connection, OptionalExtension, Row}; use serde_json::json; @@ -14,7 +12,7 @@ use crate::error::{Error, Result}; use crate::extract; use crate::filter::ListFilter; use crate::model::{ - Attention, Health, LinkType, NewTask, NodeKind, ProjectOverview, SchedulePatch, Task, TaskState, + Attention, Health, LinkType, NewTask, NodeKind, SchedulePatch, Task, TaskState, }; use crate::oplog::op_type; use crate::ranking::{self, RankedTask}; @@ -484,57 +482,6 @@ pub(super) fn health(conn: &Connection, owner: &str) -> Result { }) } -/// Every project (owner-scoped, non-tombstoned) with its parent project (via a -/// `parent` link) and its direct outstanding-task count — the shape a sidebar -/// renders as a counted, indented tree (§8.1). Pure read-side: counts come from -/// a single `GROUP BY` over the `in-project` links, parents from the `parent` -/// links, both already in the store. Title-sorted for a stable sibling order. -pub(super) fn project_overview(conn: &Connection, owner: &str) -> Result> { - // Direct outstanding count per project: each task's project is its first - // `in-project` link target (mirrors `list`/`load_candidates`). - let mut count_stmt = conn.prepare( - "SELECT (SELECT dst_id FROM links - WHERE src_id = n.id AND type = 'in-project' AND tombstoned = 0 - ORDER BY created_at, id LIMIT 1) AS project_id, - COUNT(*) - FROM nodes n JOIN tasks t ON t.node_id = n.id - WHERE n.owner_id = ?1 AND n.tombstoned = 0 AND t.state = 'outstanding' - GROUP BY project_id", - )?; - let mut counts: HashMap = HashMap::new(); - let rows = count_stmt.query_map([owner], |r| { - Ok((r.get::<_, Option>(0)?, r.get::<_, i64>(1)?)) - })?; - for row in rows { - let (project_id, count) = row?; - if let Some(pid) = project_id { - counts.insert(pid, count as usize); - } - } - - // Parent of each project: the dst of its (first) `parent` link. - let mut parent_stmt = conn.prepare( - "SELECT dst_id FROM links - WHERE src_id = ?1 AND type = 'parent' AND tombstoned = 0 - ORDER BY created_at, id LIMIT 1", - )?; - - let mut out = Vec::new(); - for node in nodes::list(conn, owner, Some(NodeKind::Project))? { - let parent_id = parent_stmt - .query_row([&node.id], |r| r.get::<_, String>(0)) - .optional()?; - out.push(ProjectOverview { - outstanding: counts.get(&node.id).copied().unwrap_or(0), - id: node.id, - title: node.title, - parent_id, - }); - } - out.sort_by(|a, b| a.title.cmp(&b.title)); - Ok(out) -} - /// Load every non-tombstoned committed task for `owner` as a ranking candidate, /// joining in its project and canonical-context link targets. fn load_candidates(conn: &Connection, owner: &str) -> Result> { diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 473b3ee..72e13d3 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -7,8 +7,8 @@ use crate::error::Result; use crate::filter::ListFilter; use crate::model::{ - Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, ProjectOverview, - SchedulePatch, SyncCursors, Task, TaskState, + Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch, + SyncCursors, Task, TaskState, }; use crate::oplog::Op; use crate::ranking::RankedTask; @@ -142,12 +142,6 @@ pub trait Store { /// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7). fn health(&self) -> Result; - /// Every project with its parent (via a `parent` link) and its direct - /// outstanding-task count — the shape a sidebar renders as a counted, - /// indented tree (§8.1). Read-only over existing data; no schema or sync - /// change (see [[hub-spoke-data-evolution]]). - fn project_overview(&self) -> Result>; - /// Full-text search over title + body (FTS5), owner-scoped, best-match /// first, tombstones excluded (tech-spec §6). `query` is FTS5 MATCH syntax. fn search(&self, query: &str) -> Result>; diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index e60a969..2a89cdd 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -7,9 +7,9 @@ use std::collections::HashMap; use anyhow::Result; use chrono::NaiveDate; -use heph_core::{Attention, ProjectOverview, RankedTask, SchedulePatch, TaskState, BUILTIN_VIEWS}; +use heph_core::{Attention, RankedTask, SchedulePatch, TaskState, BUILTIN_VIEWS}; -use crate::backend::{Backend, Project, SearchHit, SyncStatus}; +use crate::backend::{Backend, Project, SearchHit}; use crate::fmt::{days_overdue, today_local}; /// How the task list is ordered (toggled in the UI, §8.1). @@ -313,18 +313,8 @@ pub enum Focus { #[derive(Debug, Clone, PartialEq, Eq)] pub enum SidebarEntry { Header(String), - View { - name: String, - title: String, - }, - /// A project row. `depth` is its nesting level (0 = top-level) for indent; - /// `count` is its direct outstanding-task count, shown as a trailing chip. - Project { - id: String, - title: String, - depth: u16, - count: usize, - }, + View { name: String, title: String }, + Project { id: String, title: String }, } impl SidebarEntry { @@ -333,70 +323,6 @@ impl SidebarEntry { } } -/// Turn the daemon's flat (title-sorted) project overview into sidebar rows in -/// tree order — each project followed by its descendants, carrying the nesting -/// `depth` and outstanding `count` the renderer needs. -fn project_entries(overview: Vec) -> Vec { - let order = order_projects(&overview); - let mut overview: Vec> = overview.into_iter().map(Some).collect(); - order - .into_iter() - .map(|(i, depth)| { - let p = overview[i].take().expect("each index visited once"); - SidebarEntry::Project { - id: p.id, - title: p.title, - depth, - count: p.outstanding, - } - }) - .collect() -} - -/// Depth-first display order over the project forest: returns `(index, depth)` -/// pairs, each project ahead of its children, siblings in the input's title -/// order. A project whose parent is missing (tombstoned, or not in the set) -/// renders at the top level; cycles can't loop (each node is emitted once). -fn order_projects(overview: &[ProjectOverview]) -> Vec<(usize, u16)> { - use std::collections::HashSet; - let ids: HashSet<&str> = overview.iter().map(|p| p.id.as_str()).collect(); - let mut children: HashMap<&str, Vec> = HashMap::new(); - let mut roots: Vec = Vec::new(); - for (i, p) in overview.iter().enumerate() { - match &p.parent_id { - Some(pid) if ids.contains(pid.as_str()) => { - children.entry(pid.as_str()).or_default().push(i); - } - _ => roots.push(i), - } - } - let mut out = Vec::with_capacity(overview.len()); - let mut visited = vec![false; overview.len()]; - // Stack of (index, depth); push siblings reversed so we pop in title order. - let mut stack: Vec<(usize, u16)> = roots.iter().rev().map(|&i| (i, 0)).collect(); - while let Some((i, depth)) = stack.pop() { - if visited[i] { - continue; - } - visited[i] = true; - out.push((i, depth)); - if let Some(kids) = children.get(overview[i].id.as_str()) { - for &k in kids.iter().rev() { - if !visited[k] { - stack.push((k, depth + 1)); - } - } - } - } - // Defensive: any node trapped in a parent-cycle still gets one top-level row. - for (i, seen) in visited.iter().enumerate() { - if !seen { - out.push((i, 0)); - } - } - out -} - /// The selected sidebar source, as owned values (so reloads don't hold a borrow /// on `self.sidebar` while calling the backend). enum Target { @@ -433,8 +359,6 @@ pub struct App { undo_stack: Vec, redo_stack: Vec, pub status: String, - /// Latest sync health for the status-line indicator (refreshed on a tick). - pub sync: SyncStatus, pub should_quit: bool, } @@ -452,7 +376,9 @@ impl App { }); } sidebar.push(SidebarEntry::Header("Projects".into())); - sidebar.extend(project_entries(backend.project_overview()?)); + for Project { id, title } in backend.projects()? { + sidebar.push(SidebarEntry::Project { id, title }); + } let sidebar_cursor = sidebar .iter() @@ -474,23 +400,12 @@ impl App { undo_stack: Vec::new(), redo_stack: Vec::new(), status: String::new(), - sync: SyncStatus::default(), should_quit: false, }; app.reload(); - app.refresh_sync(); Ok(app) } - /// Refresh the sync-health snapshot for the status line. Best-effort: a - /// failed read leaves the previous snapshot in place (a stale indicator - /// beats a flicker), so this never disrupts navigation. - pub fn refresh_sync(&mut self) { - if let Ok(status) = self.backend.sync_status() { - self.sync = status; - } - } - /// The title shown above the task list (the selected source). pub fn task_pane_title(&self) -> String { match self.sidebar.get(self.sidebar_cursor) { @@ -508,7 +423,7 @@ impl App { /// The title of a project node id, resolved from the sidebar. pub fn project_name(&self, id: &str) -> Option { self.sidebar.iter().find_map(|e| match e { - SidebarEntry::Project { id: pid, title, .. } if pid == id => Some(title.clone()), + SidebarEntry::Project { id: pid, title } if pid == id => Some(title.clone()), _ => None, }) } @@ -554,7 +469,7 @@ impl App { self.sidebar .iter() .filter_map(|e| match e { - SidebarEntry::Project { id, title, .. } => Some((id.clone(), title.clone())), + SidebarEntry::Project { id, title } => Some((id.clone(), title.clone())), _ => None, }) .collect() @@ -827,7 +742,7 @@ impl App { /// become unfiled (they move to the Inbox), not deleted. pub fn begin_delete_project(&mut self) { match self.sidebar.get(self.sidebar_cursor) { - Some(SidebarEntry::Project { id, title, .. }) => { + Some(SidebarEntry::Project { id, title }) => { self.pending_delete = Some(PendingDelete::Project { project_id: id.clone(), title: title.clone(), @@ -966,8 +881,10 @@ impl App { .filter(|e| !matches!(e, SidebarEntry::Project { .. })) .cloned() .collect(); - if let Ok(overview) = self.backend.project_overview() { - rebuilt.extend(project_entries(overview)); + if let Ok(projects) = self.backend.projects() { + for Project { id, title } in projects { + rebuilt.push(SidebarEntry::Project { id, title }); + } } self.sidebar = rebuilt; // Restore the cursor: same entry if present, else the nearest selectable @@ -1006,7 +923,7 @@ impl App { self.sidebar .iter() .filter_map(|e| match e { - SidebarEntry::Project { id, title, .. } => Some(Project { + SidebarEntry::Project { id, title } => Some(Project { id: id.clone(), title: title.clone(), }), @@ -1296,67 +1213,4 @@ mod sort_tests { // Alpha group (red before blue), then Beta, then project-less tasks last. assert_eq!(ids(&tasks), vec!["a_red", "a_blue", "b_white", "none_red"]); } - - fn po(id: &str, title: &str, parent: Option<&str>, outstanding: usize) -> ProjectOverview { - ProjectOverview { - id: id.into(), - title: title.into(), - parent_id: parent.map(str::to_string), - outstanding, - } - } - - #[test] - fn project_entries_nest_children_under_parents_with_depth_and_count() { - // Input arrives title-sorted from the daemon. - let overview = vec![ - po("g", "Garden", None, 0), - po("w", "Work", None, 2), - po("ws", "Work Sub", Some("w"), 1), - po("wsx", "Work Sub Sub", Some("ws"), 5), - ]; - let rows: Vec<(String, u16, usize)> = project_entries(overview) - .into_iter() - .map(|e| match e { - SidebarEntry::Project { - title, - depth, - count, - .. - } => (title, depth, count), - _ => unreachable!("project_entries yields only Project rows"), - }) - .collect(); - assert_eq!( - rows, - vec![ - ("Garden".into(), 0, 0), - ("Work".into(), 0, 2), - ("Work Sub".into(), 1, 1), - ("Work Sub Sub".into(), 2, 5), - ] - ); - } - - #[test] - fn project_entries_treat_a_missing_parent_as_top_level() { - // A child whose parent isn't in the set (e.g. tombstoned) still shows. - let overview = vec![po("orphan", "Orphan", Some("gone"), 3)]; - let rows = project_entries(overview); - assert!(matches!( - rows.as_slice(), - [SidebarEntry::Project { - depth: 0, - count: 3, - .. - }] - )); - } - - #[test] - fn order_projects_does_not_loop_on_a_parent_cycle() { - // a→b→a is pathological but must still terminate, each row once. - let overview = vec![po("a", "A", Some("b"), 0), po("b", "B", Some("a"), 0)]; - assert_eq!(order_projects(&overview).len(), 2); - } } diff --git a/crates/heph-tui/src/backend.rs b/crates/heph-tui/src/backend.rs index a52fd90..784c520 100644 --- a/crates/heph-tui/src/backend.rs +++ b/crates/heph-tui/src/backend.rs @@ -22,55 +22,10 @@ pub struct SearchHit { pub kind: String, } -/// Sync health for the status line (the `sync.status` RPC). On a standalone -/// instance `hub_url` is `None` and `health` is absent; the conflict count is -/// always present. -#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)] -pub struct SyncStatus { - /// The hub this device syncs with, or `None` if standalone (no indicator). - pub hub_url: Option, - /// Pending merge conflicts awaiting resolution. - #[serde(default)] - pub conflicts: usize, - /// Observed health of the background sync loop (spoke only). - #[serde(default)] - pub health: Option, -} - -/// The spoke's observed sync health (mirrors `hephd`'s `SyncHealth`). -#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)] -pub struct SyncHealth { - /// Epoch ms of the last successful exchange ("last synced"), if any. - pub last_success_ms: Option, - /// Epoch ms of the last attempt (success or failure), if any. - pub last_attempt_ms: Option, - /// The last error message, cleared on the next success. - pub last_error: Option, - /// Whether the most recent attempt failed authentication (needs re-login). - #[serde(default)] - pub auth_failure: bool, -} - /// Everything the agenda surface asks of the daemon. pub trait Backend { /// All project nodes (for the sidebar), title-sorted. fn projects(&mut self) -> Result>; - /// Projects enriched with parent + direct outstanding-task count, for the - /// indented, counted sidebar tree (§8.1). The default derives a flat list - /// from [`projects`](Self::projects); the real backend forwards the - /// dedicated `project.overview` RPC. - fn project_overview(&mut self) -> Result> { - Ok(self - .projects()? - .into_iter() - .map(|p| heph_core::ProjectOverview { - id: p.id, - title: p.title, - parent_id: None, - outstanding: 0, - }) - .collect()) - } /// Run a built-in filter view (`tom|ondeck|chores|work|tasks`, §8.2). fn view(&mut self, name: &str) -> Result>; /// Run a raw [`ListFilter`] (used for per-project scope). @@ -85,11 +40,6 @@ pub trait Backend { /// A task's canonical-context doc id (where its description/checklist live), /// for opening a task search-hit at the useful node. `None` if it has none. fn context_of(&mut self, task_id: &str) -> Result>; - /// Sync health for the status line. The default is a standalone instance - /// (no hub, no conflicts); the real backend forwards `sync.status`. - fn sync_status(&mut self) -> Result { - Ok(SyncStatus::default()) - } // --- triage mutations (T2) --- @@ -153,11 +103,6 @@ impl Backend for ClientBackend { Ok(projects) } - fn project_overview(&mut self) -> Result> { - let v = self.call("project.overview", json!({}))?; - Ok(serde_json::from_value(v)?) - } - fn view(&mut self, name: &str) -> Result> { let v = self.call("view", json!({ "name": name }))?; Ok(serde_json::from_value(v)?) @@ -204,11 +149,6 @@ impl Backend for ClientBackend { .map(|l| l.dst_id)) } - fn sync_status(&mut self) -> Result { - let v = self.call("sync.status", json!({}))?; - Ok(serde_json::from_value(v)?) - } - fn set_state(&mut self, task_id: &str, state: &str) -> Result<()> { self.call("task.set_state", json!({ "id": task_id, "state": state }))?; Ok(()) diff --git a/crates/heph-tui/src/fmt.rs b/crates/heph-tui/src/fmt.rs index 8c49ac9..3fc7373 100644 --- a/crates/heph-tui/src/fmt.rs +++ b/crates/heph-tui/src/fmt.rs @@ -25,29 +25,6 @@ pub fn today_local() -> NaiveDate { Local::now().date_naive() } -/// Now, in epoch milliseconds (the reference for [`fmt_age`]). -pub fn now_ms() -> i64 { - Local::now().timestamp_millis() -} - -/// A compact "how long ago" for the sync indicator: `Ns` under a minute, then -/// `Nm` / `Nh` / `Nd`. Second-granularity under a minute makes the chip a visible -/// heartbeat (the sync loop runs every 30s) and surfaces a missed beat as the age -/// climbing, rather than hiding under a flat "just now". Clamped at zero so a -/// little clock skew never shows a negative age. -pub fn fmt_age(now_ms: i64, then_ms: i64) -> String { - let secs = (now_ms - then_ms).max(0) / 1000; - if secs < 60 { - format!("{secs}s") - } else if secs < 3_600 { - format!("{}m", secs / 60) - } else if secs < 86_400 { - format!("{}h", secs / 3_600) - } else { - format!("{}d", secs / 86_400) - } -} - /// How many days past its do-date a task is (0 if not overdue, no do-date, or /// future-dated). The "how overdue" signal the agenda sort ranks on (§8.1). pub fn days_overdue(do_date: Option, today: NaiveDate) -> i64 { @@ -125,19 +102,6 @@ mod tests { assert_eq!(fmt_date(ms(day(2027, 1, 1)), today), "2027-01-01"); } - #[test] - fn age_is_compact_and_clamped() { - let now = 1_000_000_000_000; - assert_eq!(fmt_age(now, now), "0s"); - assert_eq!(fmt_age(now, now - 30_000), "30s"); - assert_eq!(fmt_age(now, now - 59_000), "59s"); - assert_eq!(fmt_age(now, now - 5 * 60_000), "5m"); - assert_eq!(fmt_age(now, now - 3 * 3_600_000), "3h"); - assert_eq!(fmt_age(now, now - 2 * 86_400_000), "2d"); - // Clock skew (then in the future) never shows a negative age. - assert_eq!(fmt_age(now, now + 10_000), "0s"); - } - #[test] fn project_color_is_stable_distinct_and_neutral_when_absent() { assert_eq!(project_color(None), Color::DarkGray); diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index b672d7b..27be96e 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -61,22 +61,14 @@ fn run( mut app: App, socket: &std::path::Path, ) -> Result<()> { - // Poll with a timeout so the sync indicator's age advances and a sync - // failure surfaces within a couple of seconds even while the user is idle. - let tick = std::time::Duration::from_secs(2); loop { terminal.draw(|f| ui::render(f, &app))?; - if event::poll(tick)? { - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press { - if let Some(action) = handle_key(&mut app, key) { - perform(terminal, &mut app, socket, action)?; - } + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + if let Some(action) = handle_key(&mut app, key) { + perform(terminal, &mut app, socket, action)?; } } - } else { - // Idle tick: refresh the sync-health snapshot for the status line. - app.refresh_sync(); } if app.should_quit { return Ok(()); diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index 6e15453..23305c6 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -3,7 +3,7 @@ use heph_core::Attention; use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Margin, Rect}, + layout::{Constraint, Direction, Layout, Margin, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{ @@ -14,8 +14,8 @@ use ratatui::{ }; use crate::app::{App, Focus, InputState, Mode, MoveOption, MoveState, SidebarEntry, SortMode}; -use crate::backend::{Backend, SyncStatus}; -use crate::fmt::{fmt_age, fmt_date, now_ms, project_color, today_local}; +use crate::backend::Backend; +use crate::fmt::{fmt_date, project_color, today_local}; // Task-pane gestures (the focused pane shows its own hints, §8.1). const HINTS: &str = @@ -37,7 +37,7 @@ pub fn render(frame: &mut Frame, app: &App) { let panes = Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Length(28), + Constraint::Length(22), Constraint::Min(28), Constraint::Length(38), ]) @@ -151,25 +151,8 @@ fn pane_border(focused: bool) -> Style { } } -/// The (label, trailing-count) styles for a sidebar row given its selection -/// state: a full-width cyan bar when focus-selected, reversed when selected in -/// the unfocused pane, otherwise plain with a dimmed count. -fn sidebar_row_styles(selected: bool, focused: bool) -> (Style, Style) { - if selected { - let s = if focused { - Style::default().fg(Color::Black).bg(Color::Cyan) - } else { - Style::default().add_modifier(Modifier::REVERSED) - }; - (s, s) - } else { - (Style::default(), Style::default().fg(Color::DarkGray)) - } -} - fn render_sidebar(frame: &mut Frame, app: &App, area: Rect) { let focused = app.focus == Focus::Sidebar; - let width = area.width.saturating_sub(2) as usize; // inside borders let items: Vec = app .sidebar .iter() @@ -183,38 +166,17 @@ fn render_sidebar(frame: &mut Frame, app: &App, area: Rect) { .fg(Color::DarkGray) .add_modifier(Modifier::BOLD), ))), - SidebarEntry::View { title, .. } => { - let (style, _) = sidebar_row_styles(selected, focused); + SidebarEntry::View { title, .. } | SidebarEntry::Project { title, .. } => { + let mut style = Style::default(); + if selected { + style = if focused { + style.fg(Color::Black).bg(Color::Cyan) + } else { + style.add_modifier(Modifier::REVERSED) + }; + } ListItem::new(Line::from(Span::styled(format!(" {title}"), style))) } - SidebarEntry::Project { - title, - depth, - count, - .. - } => { - // Indent two columns per nesting level (one base level so a - // top-level project still clears the pane border). - let indent = " ".repeat(1 + *depth as usize); - // A right-aligned outstanding-task count (blank when zero). - let count_str = if *count > 0 { - format!(" {count}") - } else { - String::new() - }; - let label_w = width.saturating_sub(count_str.chars().count()); - let title_room = label_w.saturating_sub(indent.chars().count()); - let title_trunc: String = title.chars().take(title_room).collect(); - let mut label = format!("{indent}{title_trunc}"); - let pad = label_w.saturating_sub(label.chars().count()); - label.push_str(&" ".repeat(pad)); - - let (label_style, count_style) = sidebar_row_styles(selected, focused); - ListItem::new(Line::from(vec![ - Span::styled(label, label_style), - Span::styled(count_str, count_style), - ])) - } } }) .collect(); @@ -225,29 +187,7 @@ fn render_sidebar(frame: &mut Frame, app: &App, area: Rect) { .border_style(pane_border(focused)) .title(" Views "), ); - // Drive scroll-to-visible off the cursor so projects below the fold stay - // reachable; the row's own highlight remains the selection cue. - let mut state = ListState::default(); - state.select(Some(app.sidebar_cursor)); - frame.render_stateful_widget(list, area, &mut state); - - // A scrollbar once the entries can't all fit at once (position tracks the - // cursor — an honest "where am I in the list" signal). - let inner_h = area.height.saturating_sub(2) as usize; - if app.sidebar.len() > inner_h { - let mut sb = ScrollbarState::new(app.sidebar.len()).position(app.sidebar_cursor); - let bar = Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(None) - .end_symbol(None); - frame.render_stateful_widget( - bar, - area.inner(Margin { - vertical: 1, - horizontal: 0, - }), - &mut sb, - ); - } + frame.render_widget(list, area); } /// A dimmed `──── Project ────` group header for the project sort mode, padded @@ -299,7 +239,7 @@ fn task_detail_lines( } } if let Some(rrule) = &t.recurrence { - field("recurs:", hephd::datespec::humanize_rrule(rrule)); + field("recurs:", rrule.clone()); } if let Some(d) = t.do_date { field("do:", fmt_date(d, today)); @@ -538,130 +478,5 @@ fn render_status(frame: &mut Frame, app: &App, area: Rect) { } else { Style::default().fg(Color::DarkGray) }; - let left = Paragraph::new(Line::from(Span::styled(text, style))); - - // A right-aligned sync indicator (spoke only); the hints take the rest. - let indicator = sync_indicator(&app.sync, now_ms()); - if indicator.is_empty() { - frame.render_widget(left, area); - return; - } - let ind_w: usize = indicator.iter().map(|s| s.content.chars().count()).sum(); - let cols = - Layout::horizontal([Constraint::Min(1), Constraint::Length(ind_w as u16 + 1)]).split(area); - frame.render_widget(left, cols[0]); - frame.render_widget( - Paragraph::new(Line::from(indicator)).alignment(Alignment::Right), - cols[1], - ); -} - -/// The status-line sync indicator (empty on a standalone instance): a sync-state -/// chip — `⚠ auth` when re-login is needed, `⟳ ` since the last successful -/// sync, `⚠ offline` when erroring, `⟳ …` before the first sync — plus a -/// conflict chip when any merge conflicts are pending. -fn sync_indicator(sync: &SyncStatus, now: i64) -> Vec> { - if sync.hub_url.is_none() { - return Vec::new(); - } - let dim = Style::default().fg(Color::DarkGray); - let red = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD); - let yellow = Style::default().fg(Color::Yellow); - - let health = sync.health.clone().unwrap_or_default(); - let mut spans = vec![if health.auth_failure { - Span::styled("⚠ auth", red) - } else if let Some(ts) = health.last_success_ms { - Span::styled(format!("⟳ {}", fmt_age(now, ts)), dim) - } else if health.last_error.is_some() { - Span::styled("⚠ offline", yellow) - } else { - Span::styled("⟳ …", dim) - }]; - - if sync.conflicts > 0 { - let label = if sync.conflicts == 1 { - "1 conflict".to_string() - } else { - format!("{} conflicts", sync.conflicts) - }; - spans.push(Span::raw(" ")); - spans.push(Span::styled(format!("⚠ {label}"), red)); - } - spans -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::backend::SyncHealth; - - fn render(sync: &SyncStatus, now: i64) -> String { - sync_indicator(sync, now) - .iter() - .map(|s| s.content.to_string()) - .collect() - } - - const NOW: i64 = 1_000_000_000_000; - - fn spoke(health: SyncHealth, conflicts: usize) -> SyncStatus { - SyncStatus { - hub_url: Some("http://hub:8787".into()), - conflicts, - health: Some(health), - } - } - - #[test] - fn standalone_shows_no_indicator() { - assert!(sync_indicator(&SyncStatus::default(), NOW).is_empty()); - } - - #[test] - fn indicator_reflects_each_sync_state() { - // Recently synced → a dim age chip. - let ok = spoke( - SyncHealth { - last_success_ms: Some(NOW - 5 * 60_000), - ..Default::default() - }, - 0, - ); - assert_eq!(render(&ok, NOW), "⟳ 5m"); - - // Auth failure wins over age (it's the actionable state). - let auth = spoke( - SyncHealth { - last_success_ms: Some(NOW - 60_000), - auth_failure: true, - ..Default::default() - }, - 0, - ); - assert_eq!(render(&auth, NOW), "⚠ auth"); - - // Errored with no prior success → offline. - let offline = spoke( - SyncHealth { - last_error: Some("error sending request".into()), - ..Default::default() - }, - 0, - ); - assert_eq!(render(&offline, NOW), "⚠ offline"); - - // Before the first sync. - assert_eq!(render(&spoke(SyncHealth::default(), 0), NOW), "⟳ …"); - } - - #[test] - fn conflicts_chip_appends_and_pluralizes() { - let h = SyncHealth { - last_success_ms: Some(NOW), - ..Default::default() - }; - assert_eq!(render(&spoke(h.clone(), 1), NOW), "⟳ 0s ⚠ 1 conflict"); - assert_eq!(render(&spoke(h, 3), NOW), "⟳ 0s ⚠ 3 conflicts"); - } + frame.render_widget(Paragraph::new(Line::from(Span::styled(text, style))), area); } diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index 84ca739..89399e4 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -98,12 +98,6 @@ fn agenda_renders_views_projects_and_tasks() { // The red/orange tasks carry a flag glyph in the leading column (§8.1). assert!(s.contains('⚑'), "attention flag glyph missing:\n{s}"); assert!(s.contains("Preview"), "preview pane missing:\n{s}"); - // A standalone daemon (no hub) shows no sync indicator — the `sync.status` - // RPC round-trips and reports `hub_url: null`. - assert!( - !s.contains('⟳'), - "sync indicator should be hidden without a hub:\n{s}" - ); } #[test] @@ -212,12 +206,7 @@ fn recurring_task_shows_glyph_and_selected_detail_block() { assert!(s.contains('↻'), "recurrence glyph missing:\n{s}"); // ...and the selected task's inline detail block (cursor starts on row 0). assert!(s.contains("recurs:"), "no recurrence detail:\n{s}"); - // The RRULE is humanized for display (§8.1), not shown raw. - assert!(s.contains("daily"), "recurrence not humanized:\n{s}"); - assert!( - !s.contains("FREQ=DAILY"), - "raw rrule leaked into detail:\n{s}" - ); + assert!(s.contains("FREQ=DAILY"), "no rrule in detail:\n{s}"); assert!(s.contains("project:"), "no project detail:\n{s}"); assert!(s.contains("Routines"), "project name missing:\n{s}"); } diff --git a/crates/heph/src/service.rs b/crates/heph/src/service.rs index 7b15865..6015a3d 100644 --- a/crates/heph/src/service.rs +++ b/crates/heph/src/service.rs @@ -19,22 +19,12 @@ const LABEL: &str = "org.hephaestus.hephd"; #[derive(Subcommand, Debug)] pub enum DaemonAction { /// Install (if needed) and start the daemon service. - Start { - /// Generate a service that runs with opt-in self-update enabled - /// (default off). The service gets a PATH that can find cargo. - #[arg(long)] - self_update: bool, - }, + Start, /// Stop the daemon now (it may restart at next login; use `uninstall` to /// stop it for good). Stop, - /// Restart the daemon — run this after upgrading the binary. Preserves the - /// existing self-update setting unless `--self-update` re-enables it. - Restart { - /// Force self-update on when regenerating the service definition. - #[arg(long)] - self_update: bool, - }, + /// Restart the daemon — run this after upgrading the binary. + Restart, /// Show whether the service is installed and running. Status, /// Stop and remove the service entirely. @@ -124,26 +114,8 @@ fn xml_escape(s: &str) -> String { .replace('>', ">") } -fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path, self_update: bool) -> String { +fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path) -> String { let arg = |p: &Path| xml_escape(&p.to_string_lossy()); - // Opt-in self-update: pass the flag, and give the service a PATH/HOME that - // can find cargo + the toolchain (a LaunchAgent's default env can't), since - // the apply path shells out to `cargo install`. - let self_update_arg = if self_update { - "\n --self-update".to_string() - } else { - String::new() - }; - let cargo_env = if self_update { - let (path, home) = cargo_env(); - format!( - "\n PATH\n {}\n HOME\n {}", - xml_escape(&path), - xml_escape(&home), - ) - } else { - String::new() - }; format!( r#" @@ -159,7 +131,7 @@ fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path, self_update --db {db} --socket - {socket}{self_update_arg} + {socket} RunAtLoad @@ -171,7 +143,7 @@ fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path, self_update Aqua session as a LaunchAgent, so its child gets the GUI/hotkey it needs. Opt-in here (not in dev/test runs, which never set it). --> HEPH_QUICKADD - 1{cargo_env} + 1 StandardOutPath {log} @@ -188,46 +160,15 @@ fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path, self_update ) } -/// A `PATH`/`HOME` pair for a service that must run `cargo install`. Service -/// managers start with a minimal environment, so we prepend `~/.cargo/bin` (which -/// holds cargo and the rustup toolchain shims) to the usual locations and pin -/// `HOME`, which cargo needs for its registry/cache. -fn cargo_env() -> (String, String) { - let home = std::env::var("HOME").unwrap_or_default(); - let path = - format!("{home}/.cargo/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"); - (path, home) -} - -/// Whether an already-installed service file opted into self-update — so -/// `restart` (which regenerates the file) preserves the setting instead of -/// silently turning it off. -fn file_opts_into_self_update(path: &Path) -> bool { - std::fs::read_to_string(path) - .map(|s| s.contains("--self-update")) - .unwrap_or(false) -} - -fn systemd_unit(hephd: &Path, db: &Path, socket: &Path, self_update: bool) -> String { - // Opt-in self-update: pass the flag and give the unit a PATH/HOME that can - // find cargo + the toolchain, since the apply path runs `cargo install`. - let su_arg = if self_update { " --self-update" } else { "" }; - let cargo_env = if self_update { - let (path, home) = cargo_env(); - format!("Environment=PATH={path}\nEnvironment=HOME={home}\n") - } else { - String::new() - }; +fn systemd_unit(hephd: &Path, db: &Path, socket: &Path) -> String { format!( "[Unit]\n\ Description=heph daemon (hephd)\n\ After=default.target\n\ \n\ [Service]\n\ - ExecStart={hephd} --mode local --db {db} --socket {socket}{su_arg}\n\ - {cargo_env}\ - Restart=always\n\ - RestartSec=1\n\ + ExecStart={hephd} --mode local --db {db} --socket {socket}\n\ + Restart=on-failure\n\ \n\ [Install]\n\ WantedBy=default.target\n", @@ -303,11 +244,8 @@ fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> { let target = format!("gui/{uid}/{LABEL}"); match action { - DaemonAction::Start { self_update } => { - write_if_changed( - &plist, - &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, *self_update), - )?; + DaemonAction::Start => { + write_if_changed(&plist, &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log))?; if launchd_loaded(&target) { println!("heph daemon already running ({LABEL})."); } else { @@ -322,12 +260,8 @@ fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> { let (_ok, _err) = run_cmd("launchctl", &["bootout", &target])?; println!("heph daemon stopped (still installed; `uninstall` to remove)."); } - DaemonAction::Restart { self_update } => { - let su = *self_update || file_opts_into_self_update(&plist); - write_if_changed( - &plist, - &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, su), - )?; + DaemonAction::Restart => { + write_if_changed(&plist, &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log))?; let _ = run_cmd("launchctl", &["bootout", &target])?; let (ok, err) = run_cmd("launchctl", &["bootstrap", &domain, &plist_str(&plist)?])?; if !ok { @@ -380,11 +314,8 @@ fn sc(args: &[&str]) -> Result<(bool, String)> { fn systemd(action: &DaemonAction, p: &Paths) -> Result<()> { let unit = systemd_unit_path()?; match action { - DaemonAction::Start { self_update } => { - write_if_changed( - &unit, - &systemd_unit(&p.hephd, &p.db, &p.socket, *self_update), - )?; + DaemonAction::Start => { + write_if_changed(&unit, &systemd_unit(&p.hephd, &p.db, &p.socket))?; sc(&["daemon-reload"])?; let (ok, err) = sc(&["enable", "--now", UNIT])?; if !ok { @@ -396,9 +327,8 @@ fn systemd(action: &DaemonAction, p: &Paths) -> Result<()> { sc(&["stop", UNIT])?; println!("heph daemon stopped (still enabled; `uninstall` to remove)."); } - DaemonAction::Restart { self_update } => { - let su = *self_update || file_opts_into_self_update(&unit); - write_if_changed(&unit, &systemd_unit(&p.hephd, &p.db, &p.socket, su))?; + DaemonAction::Restart => { + write_if_changed(&unit, &systemd_unit(&p.hephd, &p.db, &p.socket))?; sc(&["daemon-reload"])?; let (ok, err) = sc(&["restart", UNIT])?; if !ok { @@ -447,7 +377,6 @@ mod tests { Path::new("/home/e/.local/share/heph/heph.db"), Path::new("/tmp/heph/hephd.sock"), Path::new("/home/e/.local/share/heph/hephd.log"), - false, ); assert!(plist.contains("org.hephaestus.hephd")); assert!(plist.contains("/usr/local/bin/hephd")); @@ -457,24 +386,6 @@ mod tests { assert!(plist.contains("RunAtLoad")); assert!(plist.contains("KeepAlive")); assert!(plist.contains("hephd.log")); - // Default (no self-update): no flag, no cargo PATH baked in. - assert!(!plist.contains("--self-update")); - assert!(!plist.contains(".cargo/bin")); - } - - #[test] - fn launchd_plist_self_update_adds_flag_and_cargo_path() { - let plist = launchd_plist( - Path::new("/usr/local/bin/hephd"), - Path::new("/db"), - Path::new("/sock"), - Path::new("/log"), - true, - ); - assert!(plist.contains("--self-update")); - assert!(plist.contains("PATH")); - assert!(plist.contains(".cargo/bin")); - assert!(plist.contains("HOME")); } #[test] @@ -483,36 +394,14 @@ mod tests { Path::new("/usr/local/bin/hephd"), Path::new("/home/e/.local/share/heph/heph.db"), Path::new("/run/user/1000/heph/hephd.sock"), - false, ); assert!(unit.contains( "ExecStart=/usr/local/bin/hephd --mode local \ --db /home/e/.local/share/heph/heph.db \ --socket /run/user/1000/heph/hephd.sock" )); - // Restart=always (not on-failure) so a clean exit (code 0) — what - // self-update does to hand off to the new binary — is respawned too. - assert!(unit.contains("Restart=always")); - assert!(!unit.contains("Restart=on-failure")); - assert!(unit.contains("RestartSec=")); + assert!(unit.contains("Restart=on-failure")); assert!(unit.contains("WantedBy=default.target")); - // Default (no self-update): no flag, no baked env. - assert!(!unit.contains("--self-update")); - assert!(!unit.contains("Environment=PATH=")); - } - - #[test] - fn systemd_unit_self_update_adds_flag_and_env() { - let unit = systemd_unit( - Path::new("/usr/local/bin/hephd"), - Path::new("/db"), - Path::new("/sock"), - true, - ); - assert!(unit.contains("--self-update")); - assert!(unit.contains("Environment=PATH=")); - assert!(unit.contains(".cargo/bin")); - assert!(unit.contains("Environment=HOME=")); } #[test] diff --git a/crates/hephd/Cargo.toml b/crates/hephd/Cargo.toml index fb30b17..9bb7b9e 100644 --- a/crates/hephd/Cargo.toml +++ b/crates/hephd/Cargo.toml @@ -32,7 +32,6 @@ jsonwebtoken.workspace = true keyring-core.workspace = true reqwest.workspace = true ureq.workspace = true -semver.workspace = true # The OS credential backend that `oauth.rs` registers as the keyring-core # default store — exactly one per platform, not the whole keyring meta-crate. diff --git a/crates/hephd/src/auth.rs b/crates/hephd/src/auth.rs index c601d90..e3081a1 100644 --- a/crates/hephd/src/auth.rs +++ b/crates/hephd/src/auth.rs @@ -49,13 +49,6 @@ pub trait TokenVerifier: Send + Sync { /// Validate `bearer` (the raw token, no `Bearer ` prefix) and return its /// claims, or an [`AuthError`]. fn verify(&self, bearer: &str) -> Result; - - /// The public OIDC parameters a browser client (the `heph-pwa`) needs to - /// start a login: `(issuer, client_id)`. Neither is a secret. `None` for - /// non-OIDC verifiers (e.g. test stubs). - fn oidc_config(&self) -> Option<(&str, &str)> { - None - } } /// What an OIDC provider's discovery document tells us (we only need the JWKS). @@ -163,9 +156,4 @@ impl TokenVerifier for OidcVerifier { .map_err(|e| AuthError::Invalid(e.to_string()))?; Ok(data.claims) } - - fn oidc_config(&self) -> Option<(&str, &str)> { - // The audience is the OIDC client id (Authentik sets `aud` to it). - Some((&self.issuer, &self.audience)) - } } diff --git a/crates/hephd/src/datespec.rs b/crates/hephd/src/datespec.rs index fa91611..71d23e6 100644 --- a/crates/hephd/src/datespec.rs +++ b/crates/hephd/src/datespec.rs @@ -288,172 +288,6 @@ fn parse_month_day(s: &str) -> Option<(u32, u32)> { None } -// --------------------------------------------------------------------------- -// Reverse datespec: humanize an RRULE for display (§8.1). -// --------------------------------------------------------------------------- - -/// Render an RFC-5545 RRULE back into the compact human phrasing the owner would -/// have typed — the inverse of [`parse_recurrence`] for the forms it produces: -/// `daily`, `every 3 days`, `every other day`, `weekly`, `every other week`, -/// `weekdays`, `every Fri`, `every other Wed`, `weekly on Mon, Wed, Fri`, -/// `monthly on the 5th`, `yearly on Apr 15`. Any rule that uses parts we don't -/// model (`COUNT`, `UNTIL`, `BYSETPOS`, ordinal `BYDAY` like `2MO`, …) is -/// returned **verbatim** so nothing is silently hidden from the reader. -pub fn humanize_rrule(rrule: &str) -> String { - humanize_known(rrule).unwrap_or_else(|| rrule.trim().to_string()) -} - -/// The fallible core: `None` whenever the rule contains anything we don't model, -/// so [`humanize_rrule`] can fall back to the raw text. -fn humanize_known(rrule: &str) -> Option { - let mut freq: Option = None; - let mut interval: u32 = 1; - let mut byday: Option = None; - let mut bymonth: Option = None; - let mut bymonthday: Option = None; - for part in rrule.trim().split(';') { - let part = part.trim(); - if part.is_empty() { - continue; - } - let (k, v) = part.split_once('=')?; - match k.trim().to_uppercase().as_str() { - "FREQ" => freq = Some(v.trim().to_uppercase()), - "INTERVAL" => interval = v.trim().parse().ok()?, - "BYDAY" => byday = Some(v.trim().to_uppercase()), - "BYMONTH" => bymonth = Some(v.trim().parse().ok()?), - "BYMONTHDAY" => bymonthday = Some(v.trim().parse().ok()?), - // A part we don't render → don't risk a misleading summary. - _ => return None, - } - } - - match freq?.as_str() { - "DAILY" => { - if byday.is_some() || bymonth.is_some() || bymonthday.is_some() { - return None; - } - Some(every_unit(interval, "day", "days", "daily")) - } - "WEEKLY" => { - if bymonth.is_some() || bymonthday.is_some() { - return None; - } - match byday { - None => Some(every_unit(interval, "week", "weeks", "weekly")), - Some(days) => { - if interval == 1 && is_weekday_set(&days) { - return Some("weekdays".into()); - } - let names = weekday_names(&days)?; - if names.len() == 1 { - let day = names[0]; - Some(match interval { - 1 => format!("every {day}"), - 2 => format!("every other {day}"), - n => format!("every {n} weeks on {day}"), - }) - } else { - let joined = names.join(", "); - Some(match interval { - 1 => format!("weekly on {joined}"), - 2 => format!("every other week on {joined}"), - n => format!("every {n} weeks on {joined}"), - }) - } - } - } - } - "MONTHLY" => { - if byday.is_some() || bymonth.is_some() { - return None; - } - match bymonthday { - None => Some(every_unit(interval, "month", "months", "monthly")), - Some(d @ 1..=31) => { - let day = ordinal(d as u32); - Some(match interval { - 1 => format!("monthly on the {day}"), - 2 => format!("every other month on the {day}"), - n => format!("every {n} months on the {day}"), - }) - } - Some(_) => None, // negative / out-of-range day-of-month → raw - } - } - "YEARLY" => { - if byday.is_some() { - return None; - } - match (bymonth, bymonthday) { - (None, None) => Some(every_unit(interval, "year", "years", "yearly")), - (Some(m @ 1..=12), Some(d @ 1..=31)) => { - let mon = MONTH_ABBR[(m - 1) as usize]; - Some(match interval { - 1 => format!("yearly on {mon} {d}"), - 2 => format!("every other year on {mon} {d}"), - n => format!("every {n} years on {mon} {d}"), - }) - } - _ => None, - } - } - _ => None, - } -} - -const MONTH_ABBR: [&str; 12] = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", -]; - -/// `preset` for `n == 1`, `every other ` for 2, `every N ` otherwise. -fn every_unit(n: u32, singular: &str, plural: &str, preset: &str) -> String { - match n { - 1 => preset.to_string(), - 2 => format!("every other {singular}"), - n => format!("every {n} {plural}"), - } -} - -/// `1st`, `2nd`, `3rd`, `4th`, … `11th`, `21st`, `22nd`. -fn ordinal(n: u32) -> String { - let suffix = match (n % 10, n % 100) { - (_, 11..=13) => "th", - (1, _) => "st", - (2, _) => "nd", - (3, _) => "rd", - _ => "th", - }; - format!("{n}{suffix}") -} - -/// `MO,TU,WE,TH,FR` in any order (and only those), the inverse of the `weekdays` -/// preset. -fn is_weekday_set(byday: &str) -> bool { - let mut days: Vec<&str> = byday.split(',').map(str::trim).collect(); - days.sort_unstable(); - days == ["FR", "MO", "TH", "TU", "WE"] -} - -/// `BYDAY` tokens → capitalized weekday abbreviations, order preserved. `None` if -/// any token isn't a bare weekday (e.g. an ordinal `2MO`), so the caller falls -/// back to the raw rule. -fn weekday_names(byday: &str) -> Option> { - byday - .split(',') - .map(|t| match t.trim() { - "MO" => Some("Mon"), - "TU" => Some("Tue"), - "WE" => Some("Wed"), - "TH" => Some("Thu"), - "FR" => Some("Fri"), - "SA" => Some("Sat"), - "SU" => Some("Sun"), - _ => None, - }) - .collect() -} - #[cfg(test)] mod tests { use super::*; @@ -570,71 +404,4 @@ mod tests { ); assert!(parse_recurrence("every blue moon").is_err()); } - - #[test] - fn humanize_inverts_the_natural_language_forms() { - let cases = [ - ("FREQ=DAILY", "daily"), - ("FREQ=DAILY;INTERVAL=2", "every other day"), - ("FREQ=DAILY;INTERVAL=3", "every 3 days"), - ("FREQ=WEEKLY", "weekly"), - ("FREQ=WEEKLY;INTERVAL=2", "every other week"), - ("FREQ=MONTHLY", "monthly"), - ("FREQ=MONTHLY;INTERVAL=6", "every 6 months"), - ("FREQ=YEARLY", "yearly"), - ("FREQ=WEEKLY;BYDAY=FR", "every Fri"), - ("FREQ=WEEKLY;INTERVAL=2;BYDAY=WE", "every other Wed"), - ("FREQ=WEEKLY;INTERVAL=3;BYDAY=WE", "every 3 weeks on Wed"), - ("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", "weekdays"), - ("FREQ=WEEKLY;BYDAY=MO,WE,FR", "weekly on Mon, Wed, Fri"), - ("FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15", "yearly on Apr 15"), - ("FREQ=MONTHLY;BYMONTHDAY=5", "monthly on the 5th"), - ("FREQ=MONTHLY;BYMONTHDAY=22", "monthly on the 22nd"), - ("FREQ=MONTHLY;BYMONTHDAY=1", "monthly on the 1st"), - ("FREQ=MONTHLY;BYMONTHDAY=13", "monthly on the 13th"), - ]; - for (rrule, want) in cases { - assert_eq!(humanize_rrule(rrule), want, "humanizing {rrule}"); - } - } - - #[test] - fn humanize_round_trips_through_parse_recurrence() { - // For the interval/weekday forms the display text is itself a valid input: - // owner types text → we store an RRULE → we show it back → it re-parses to - // the same rule. (The `yearly on Apr 15` / `monthly on the 5th` forms are - // tuned for reading, not re-typing — the stored RRULE, never this string, - // is what gets parsed — so they're covered by the exact-output test above.) - for input in [ - "every 3 days", - "every other day", - "every other wed", - "weekdays", - "every fri", - "every 6 months", - "every 2 weeks", - ] { - let rrule = parse_recurrence(input).unwrap(); - let shown = humanize_rrule(&rrule); - assert_eq!( - parse_recurrence(&shown).unwrap(), - rrule, - "{input:?} → {rrule:?} → shown {shown:?} must re-parse to the same rule" - ); - } - } - - #[test] - fn humanize_falls_back_to_raw_for_unmodeled_rules() { - // COUNT/UNTIL/BYSETPOS and ordinal BYDAY would be misleading if dropped. - for raw in [ - "FREQ=DAILY;COUNT=5", - "FREQ=WEEKLY;UNTIL=20261231T000000Z", - "FREQ=MONTHLY;BYDAY=2MO", - "FREQ=MONTHLY;BYMONTHDAY=-1", - "not an rrule at all", - ] { - assert_eq!(humanize_rrule(raw), raw, "should pass {raw} through"); - } - } } diff --git a/crates/hephd/src/lib.rs b/crates/hephd/src/lib.rs index 5d68bad..09f8714 100644 --- a/crates/hephd/src/lib.rs +++ b/crates/hephd/src/lib.rs @@ -17,7 +17,6 @@ pub mod oauth; pub mod quickadd; pub mod remote; pub mod rpc; -pub mod selfupdate; pub mod server; pub mod sync; diff --git a/crates/hephd/src/main.rs b/crates/hephd/src/main.rs index fde7d57..62df200 100644 --- a/crates/hephd/src/main.rs +++ b/crates/hephd/src/main.rs @@ -17,8 +17,8 @@ use tokio::net::{TcpListener, UnixListener}; use heph_core::LocalStore; use hephd::{ - default_db_path, default_socket_path, selfupdate::SelfUpdateConfig, sync, Daemon, - KeyringTokenStore, LockGuard, RemoteStore, SystemClock, TokenStore, + default_db_path, default_socket_path, sync, Daemon, KeyringTokenStore, LockGuard, RemoteStore, + SystemClock, TokenStore, }; /// How often a spoke background-syncs with its hub. @@ -60,12 +60,6 @@ struct Cli { #[arg(long)] http_addr: Option, - /// Directory of static files to serve for non-API paths (server mode). Point - /// this at the `heph-pwa/` shell to host the mobile app same-origin from the - /// hub. Unset: the hub serves only its API routes. - #[arg(long)] - web_root: Option, - /// Server to proxy to (client mode only; required there). #[arg(long)] server_url: Option, @@ -83,16 +77,6 @@ struct Cli { /// --oidc-issuer, the device attaches a cached bearer token to hub requests. #[arg(long)] oidc_client_id: Option, - - /// Opt-in (default off): periodically poll the forge for a newer release and - /// auto-update this daemon. Off unless this flag is given. - #[arg(long)] - self_update: bool, - - /// Override the self-update poll interval, in seconds (default: 6h). Only - /// meaningful with --self-update. - #[arg(long)] - self_update_interval_secs: Option, } /// Build the spoke/client token source: a keyring store keyed by `account` (the @@ -128,11 +112,6 @@ async fn main() -> Result<()> { .with_context(|| format!("creating socket dir {}", parent.display()))?; } - // Opt-in self-update (default off): `Some` only when `--self-update` is set. - let self_update = cli - .self_update - .then(|| SelfUpdateConfig::new(cli.self_update_interval_secs.map(Duration::from_secs))); - // Build the daemon for the chosen mode. `local`/`server` own the file (and // hold its lock for the process's life); `client` keeps no replica. let (_lock, daemon) = match cli.mode { @@ -152,10 +131,7 @@ async fn main() -> Result<()> { } None => RemoteStore::new(&server_url), }; - ( - None, - Daemon::new(store).with_self_update(self_update.clone()), - ) + (None, Daemon::new(store)) } Mode::Local | Mode::Server => { let db = cli.db.clone().unwrap_or_else(default_db_path); @@ -171,8 +147,7 @@ async fn main() -> Result<()> { }); let daemon = Daemon::new(store) .with_hub(cli.hub_url.clone()) - .with_spoke_auth(spoke) - .with_self_update(self_update.clone()); + .with_spoke_auth(spoke); // server mode: expose the hub HTTP endpoint over the same store. if cli.mode == Mode::Server { @@ -196,10 +171,7 @@ async fn main() -> Result<()> { anyhow::bail!("--oidc-issuer and --oidc-audience must be set together") } }; - if let Some(root) = cli.web_root.as_deref() { - tracing::info!(web_root = %root.display(), "hub serving static PWA shell"); - } - let app = sync::router_with_web(daemon.store(), verifier, cli.web_root.clone()); + let app = sync::router(daemon.store(), verifier); let http_listener = TcpListener::bind(&addr) .await .with_context(|| format!("binding hub HTTP endpoint {addr}"))?; @@ -218,9 +190,6 @@ async fn main() -> Result<()> { } }; - // Opt-in self-update poller (no-op unless --self-update); mode-agnostic. - daemon.spawn_self_update_loop(); - // Replace any stale socket from a previous run, then bind. if socket.exists() { std::fs::remove_file(&socket) @@ -231,17 +200,14 @@ async fn main() -> Result<()> { tracing::info!(socket = %socket.display(), mode = ?cli.mode, "hephd listening"); - // macOS store-owning modes: supervise the global quick-capture popover (⌘'). - // hephd already runs as a `gui/$uid` LaunchAgent, so its child inherits the - // Aqua session the hotkey/GUI need — no separate launch agent. Both `local` - // and `server` own the local store on the device (server is local + an HTTP - // hub), so both should drive the desktop popover; only `client` (a thin - // remote proxy) does not. Opt-in via HEPH_QUICKADD=1 (the installed plist - // sets it) so dev/test runs that spawn a daemon never pop a window. The - // helper self-exits when this daemon goes away, so killing hephd (even - // `kill -9`) leaves nothing behind. + // macOS local mode: supervise the global quick-capture popover (⌘'). hephd + // already runs as a `gui/$uid` LaunchAgent, so its child inherits the Aqua + // session the hotkey/GUI need — no separate launch agent. Opt-in via + // HEPH_QUICKADD=1 (the installed plist sets it) so dev/test runs that spawn a + // local daemon never pop a window. The helper self-exits when this daemon + // goes away, so killing hephd (even `kill -9`) leaves nothing behind. #[cfg(target_os = "macos")] - if matches!(cli.mode, Mode::Local | Mode::Server) && quickadd_enabled() { + if cli.mode == Mode::Local && quickadd_enabled() { spawn_quickadd_supervisor(socket.clone()); } diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index 45a581d..43f8ad4 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -221,10 +221,6 @@ impl Store for RemoteStore { self.call_as("health", json!({})) } - fn project_overview(&self) -> Result> { - self.call_as("project.overview", json!({})) - } - fn search(&self, query: &str) -> Result> { self.call_as("search", json!({ "query": query })) } diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index e46f7a6..bc29eca 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -352,7 +352,6 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result json!(store.project_overview()?), "task.create" => { let p: NewTask = parse(params)?; json!(store.create_task(p)?) diff --git a/crates/hephd/src/selfupdate.rs b/crates/hephd/src/selfupdate.rs deleted file mode 100644 index e276955..0000000 --- a/crates/hephd/src/selfupdate.rs +++ /dev/null @@ -1,423 +0,0 @@ -//! Opt-in self-update (cards: `docs/how-to/self-update/`). When enabled, hephd -//! polls the forge for a newer tagged release and rebuilds + restarts onto it. -//! -//! The moving parts are dependency-injected behind traits — [`ReleaseSource`] -//! (where the latest tag comes from) and [`Installer`] (how the upgrade is -//! applied) — so the poll/apply logic is unit-tested without a live forge or a -//! real `cargo install`. The production wiring (`ForgeReleaseSource`, -//! `CargoInstaller`) is exercised only at runtime. - -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{Context, Result}; - -/// Default poll cadence when `--self-update` is on and no interval is given. -pub const DEFAULT_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); - -/// Configuration for the opt-in self-update mode. Its mere presence (the daemon -/// holds an `Option`) means the mode is enabled; absent ⇒ off. -#[derive(Clone, Debug)] -pub struct SelfUpdateConfig { - /// How often to poll the forge for a newer release. - pub interval: Duration, -} - -impl SelfUpdateConfig { - /// Build a config, falling back to [`DEFAULT_INTERVAL`] when no override. - pub fn new(interval: Option) -> Self { - Self { - interval: interval.unwrap_or(DEFAULT_INTERVAL), - } - } -} - -/// The forge releases feed for this project — the latest tagged release. The -/// repo is public, so this is an unauthenticated GET on the canonical public -/// host. -pub const RELEASES_LATEST_URL: &str = - "https://forge.eblu.me/api/v1/repos/eblume/hephaestus/releases/latest"; - -/// Extract the bare `X.Y.Z` semver from a version string that may carry a build -/// suffix (`heph_core::VERSION` is e.g. `"1.0.3 (aa376b4)"`) or a leading `v` -/// (release tags are `v1.0.4`). -fn parse_version(s: &str) -> Result { - let head = s - .trim() - .trim_start_matches('v') - .split_whitespace() - .next() - .unwrap_or(""); - semver::Version::parse(head).with_context(|| format!("parsing version {s:?}")) -} - -/// Whether `latest_tag` names a strictly newer release than `current` (the -/// running `heph_core::VERSION`). A malformed version on either side is an -/// error — never a silent "no update". -pub fn update_available(current: &str, latest_tag: &str) -> Result { - Ok(parse_version(latest_tag)? > parse_version(current)?) -} - -/// Pull the `tag_name` out of a Forgejo/Gitea `releases/latest` response body. -/// Split out from the HTTP fetch so it can be tested against a sample payload. -pub fn parse_latest_tag(body: &str) -> Result { - #[derive(serde::Deserialize)] - struct Release { - tag_name: String, - } - let rel: Release = - serde_json::from_str(body).context("parsing forge releases/latest response")?; - Ok(rel.tag_name) -} - -/// Fetch the latest release tag from the forge over HTTPS, blocking. Uses -/// `ureq` (already a dependency, with a rustls/ring TLS backend that needs no -/// system libs) rather than the daemon's `reqwest` client, which is built -/// without TLS — the forge poll is the only production HTTPS-over-HTTP-client -/// path (hub sync is plain HTTP). Network/HTTP/JSON failures surface as `Err`. -pub fn fetch_latest_tag(url: &str) -> Result { - let body = ureq::get(url) - .call() - .context("requesting forge releases/latest")? - .body_mut() - .read_to_string() - .context("reading forge releases/latest body")?; - parse_latest_tag(&body) -} - -/// Where "the latest release tag" comes from. Injectable so the poll loop can -/// be exercised without hitting the network (real impl: [`ForgeReleaseSource`]). -pub trait ReleaseSource: Send + Sync + 'static { - fn latest_tag(&self) -> impl std::future::Future> + Send; -} - -/// The production source: the forge's `releases/latest` over HTTPS (via `ureq`). -pub struct ForgeReleaseSource { - url: String, -} - -impl ForgeReleaseSource { - /// Source hitting [`RELEASES_LATEST_URL`]. - pub fn new() -> Self { - Self { - url: RELEASES_LATEST_URL.to_string(), - } - } -} - -impl Default for ForgeReleaseSource { - fn default() -> Self { - Self::new() - } -} - -impl ReleaseSource for ForgeReleaseSource { - async fn latest_tag(&self) -> Result { - // `ureq` is blocking; keep it off the async runtime. - let url = self.url.clone(); - tokio::task::spawn_blocking(move || fetch_latest_tag(&url)) - .await - .context("release-fetch task panicked")? - } -} - -/// The result of one self-update check — kept separate from logging so it can be -/// asserted in tests. -#[derive(Debug, PartialEq, Eq)] -pub enum CheckOutcome { - /// The running version is at or ahead of the latest release. - UpToDate, - /// A strictly newer release exists, named by this tag (e.g. `v1.0.4`). - UpdateAvailable(String), - /// The check failed (forge unreachable, bad body, unparseable version). - Failed(String), -} - -/// Run one check against `source`, comparing the latest tag to `current`. Never -/// returns `Err` — a failure is folded into [`CheckOutcome::Failed`] so the loop -/// keeps going (a flaky forge must never crash or stall the daemon). -pub async fn check_release(source: &S, current: &str) -> CheckOutcome { - match source.latest_tag().await { - Ok(tag) => match update_available(current, &tag) { - Ok(true) => CheckOutcome::UpdateAvailable(tag), - Ok(false) => CheckOutcome::UpToDate, - Err(e) => CheckOutcome::Failed(e.to_string()), - }, - Err(e) => CheckOutcome::Failed(e.to_string()), - } -} - -/// The git URL self-update installs from. hephaestus is a **public** repo, and -/// `cargo install --git` is a plain anonymous git clone — *not* the Forgejo -/// cargo *registry* (that's access-restricted and needs `forge.ops.eblu.me`; -/// this is unrelated). So a credential-free HTTPS clone of the canonical public -/// host works from any device. -pub const INSTALL_GIT_URL: &str = "https://forge.eblu.me/eblume/hephaestus.git"; - -/// All workspace binaries, installed in lockstep so `heph`/`hephd`/`heph-tui` -/// never skew after an update. -pub const INSTALL_BINS: &[&str] = &["heph", "hephd", "heph-tui", "heph-quickadd"]; - -/// Applies a detected upgrade. Injectable so the apply path is testable without -/// spawning a real (minutes-long) `cargo install` (real impl: [`CargoInstaller`]). -pub trait Installer: Send + Sync + 'static { - /// Install the binaries for release `tag` (e.g. `v1.0.4`). Blocking. - fn install(&self, tag: &str) -> Result<()>; -} - -/// The production installer: `cargo install --locked --git --tag ` -/// for every workspace binary — the exact command the install how-to documents. -pub struct CargoInstaller; - -impl Installer for CargoInstaller { - fn install(&self, tag: &str) -> Result<()> { - let mut cmd = std::process::Command::new("cargo"); - cmd.args([ - "install", - "--locked", - "--git", - INSTALL_GIT_URL, - "--tag", - tag, - ]); - cmd.args(INSTALL_BINS); - let status = cmd.status().context("spawning cargo install")?; - if !status.success() { - anyhow::bail!("cargo install for {tag} exited with {status}"); - } - Ok(()) - } -} - -/// Hands off to the freshly-installed binary. Injectable so the apply path is -/// testable without actually exiting the test process (real: [`ProcessRestarter`]). -pub trait Restarter: Send + Sync + 'static { - /// Restart onto the new binary. The production impl does not return. - fn restart(&self) -> Result<()>; -} - -/// The production restarter: exit cleanly so the OS service manager (launchd -/// `KeepAlive` / systemd `Restart=always`) respawns the new binary. In-flight -/// RPC connections simply drop; clients reconnect (the nvim plugin already does). -pub struct ProcessRestarter; - -impl Restarter for ProcessRestarter { - fn restart(&self) -> Result<()> { - tracing::info!("self-update: exiting to let the service manager start the new binary"); - std::process::exit(0); - } -} - -/// Apply a detected update: install the binaries for `tag`, then restart onto -/// them. The blocking install runs on the blocking pool so it never stalls the -/// async runtime; the restart only happens if the install succeeded. -pub async fn apply_update( - installer: Arc, - restarter: Arc, - tag: &str, -) -> Result<()> { - let owned = tag.to_string(); - tokio::task::spawn_blocking(move || installer.install(&owned)) - .await - .context("self-update install task panicked")??; - tracing::info!(%tag, "self-update: installed; restarting into the new binary"); - restarter.restart() -} - -/// The background poll loop: tick on `interval`, check for a newer release, and -/// when one is available, apply it. Runs forever; spawned as a task. -pub async fn run_poll_loop( - source: S, - installer: Arc, - restarter: Arc, - interval: Duration, - current: &'static str, -) { - let mut tick = tokio::time::interval(interval); - loop { - tick.tick().await; - match check_release(&source, current).await { - CheckOutcome::UpdateAvailable(tag) => { - tracing::info!(%tag, current, "self-update: newer release available, applying"); - // On success the restarter exits the process, so this only - // returns on failure — log it and keep polling. - if let Err(e) = apply_update(installer.clone(), restarter.clone(), &tag).await { - tracing::error!("self-update: failed for {tag}: {e}"); - } - } - CheckOutcome::UpToDate => tracing::debug!(current, "self-update: up to date"), - CheckOutcome::Failed(e) => tracing::warn!("self-update: release check failed: {e}"), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A canned release source for deterministic loop/decision tests. - struct FakeSource(Result); - impl ReleaseSource for FakeSource { - async fn latest_tag(&self) -> Result { - self.0.clone().map_err(|e| anyhow::anyhow!(e)) - } - } - - /// Records install calls; optionally fails, to drive the apply path. - #[derive(Default)] - struct FakeInstaller { - installed: std::sync::Mutex>, - fail: bool, - } - impl Installer for FakeInstaller { - fn install(&self, tag: &str) -> Result<()> { - self.installed.lock().unwrap().push(tag.to_string()); - if self.fail { - anyhow::bail!("simulated install failure"); - } - Ok(()) - } - } - - /// Records whether a restart was requested (instead of exiting the process). - #[derive(Default)] - struct FakeRestarter { - restarted: std::sync::Mutex, - } - impl Restarter for FakeRestarter { - fn restart(&self) -> Result<()> { - *self.restarted.lock().unwrap() = true; - Ok(()) - } - } - - #[test] - fn install_and_release_urls_are_public_https_no_ssh() { - // hephaestus is public; cargo install --git is a plain clone (not the - // access-restricted Forgejo cargo registry), so no SSH / credentials. - for url in [INSTALL_GIT_URL, RELEASES_LATEST_URL] { - assert!(url.starts_with("https://"), "{url} must be HTTPS"); - assert!(!url.contains("ssh://"), "{url} must not use SSH"); - assert!( - url.contains("forge.eblu.me"), - "{url} should use the canonical public host" - ); - } - } - - #[tokio::test] - async fn apply_update_installs_then_restarts_on_success() { - let inst = Arc::new(FakeInstaller::default()); - let restart = Arc::new(FakeRestarter::default()); - apply_update(inst.clone(), restart.clone(), "v1.0.4") - .await - .unwrap(); - assert_eq!(*inst.installed.lock().unwrap(), vec!["v1.0.4".to_string()]); - assert!( - *restart.restarted.lock().unwrap(), - "should restart on success" - ); - } - - #[tokio::test] - async fn apply_update_does_not_restart_when_install_fails() { - let inst = Arc::new(FakeInstaller { - fail: true, - ..Default::default() - }); - let restart = Arc::new(FakeRestarter::default()); - assert!(apply_update(inst.clone(), restart.clone(), "v1.0.4") - .await - .is_err()); - assert_eq!(*inst.installed.lock().unwrap(), vec!["v1.0.4".to_string()]); - assert!( - !*restart.restarted.lock().unwrap(), - "must NOT restart after a failed install" - ); - } - - #[tokio::test] - async fn check_release_reports_outcomes_from_a_stubbed_source() { - // Newer release available. - let s = FakeSource(Ok("v1.0.4".into())); - assert_eq!( - check_release(&s, "1.0.3 (sha)").await, - CheckOutcome::UpdateAvailable("v1.0.4".into()) - ); - // Already current. - let s = FakeSource(Ok("v1.0.3".into())); - assert_eq!( - check_release(&s, "1.0.3 (sha)").await, - CheckOutcome::UpToDate - ); - // Fetch failure → folded into Failed, never a panic/Err. - let s = FakeSource(Err("forge unreachable")); - assert!(matches!( - check_release(&s, "1.0.3 (sha)").await, - CheckOutcome::Failed(_) - )); - // Malformed tag → Failed. - let s = FakeSource(Ok("not-a-tag".into())); - assert!(matches!( - check_release(&s, "1.0.3 (sha)").await, - CheckOutcome::Failed(_) - )); - } - - #[test] - fn config_defaults_interval_and_honors_override() { - assert_eq!(SelfUpdateConfig::new(None).interval, DEFAULT_INTERVAL); - assert_eq!( - SelfUpdateConfig::new(Some(Duration::from_secs(900))).interval, - Duration::from_secs(900) - ); - } - - #[test] - fn update_available_compares_ignoring_build_suffix_and_v_prefix() { - // Running version carries a build-sha suffix; tags carry a `v`. - assert!(update_available("1.0.3 (aa376b4)", "v1.0.4").unwrap()); - assert!(update_available("1.0.3 (aa376b4)", "v2.0.0").unwrap()); - // Same version → no update (a dirty rebuild of the same tag isn't newer). - assert!(!update_available("1.0.3 (aa376b4-dirty)", "v1.0.3").unwrap()); - // Older tag than running → no update. - assert!(!update_available("1.0.3", "v1.0.2").unwrap()); - // Patch/minor/major ordering. - assert!(update_available("1.0.9", "v1.1.0").unwrap()); - assert!(!update_available("1.1.0", "v1.0.9").unwrap()); - } - - #[test] - fn update_available_errors_on_malformed_version() { - assert!(update_available("not-a-version", "v1.0.4").is_err()); - assert!(update_available("1.0.3", "vNope").is_err()); - } - - #[test] - fn parse_latest_tag_reads_tag_name_from_forge_body() { - // A trimmed sample of a Forgejo releases/latest payload. - let body = r#"{ - "id": 42, - "tag_name": "v1.0.4", - "name": "Release v1.0.4", - "draft": false, - "prerelease": false - }"#; - assert_eq!(parse_latest_tag(body).unwrap(), "v1.0.4"); - } - - #[test] - fn parse_latest_tag_errors_on_unexpected_body() { - assert!(parse_latest_tag("{}").is_err()); - assert!(parse_latest_tag("not json").is_err()); - } - - #[test] - fn end_to_end_body_to_decision() { - // Parse a release body, then decide against a fixed running version. - let tag = parse_latest_tag(r#"{"tag_name": "v1.0.4"}"#).unwrap(); - assert!(update_available("1.0.3 (aa376b4)", &tag).unwrap()); - let tag = parse_latest_tag(r#"{"tag_name": "v1.0.3"}"#).unwrap(); - assert!(!update_available("1.0.3 (aa376b4)", &tag).unwrap()); - } -} diff --git a/crates/hephd/src/server.rs b/crates/hephd/src/server.rs index 30c5d5a..389b7ea 100644 --- a/crates/hephd/src/server.rs +++ b/crates/hephd/src/server.rs @@ -10,10 +10,9 @@ //! ops with the configured hub (tech-spec §6.1, §12). use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use anyhow::Result; -use serde::Serialize; use serde_json::{json, Value}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; @@ -22,7 +21,6 @@ use heph_core::Store; use crate::oauth::{self, TokenStore}; use crate::rpc::{self, Request, Response, RpcError, INTERNAL_ERROR, PARSE_ERROR}; -use crate::selfupdate::{self, SelfUpdateConfig}; use crate::sync::{self, SharedStore}; /// How a spoke obtains the bearer token it presents to its hub (tech-spec §13). @@ -33,23 +31,6 @@ struct SpokeAuth { client_id: String, } -/// A spoke's observed sync health, updated after every exchange (background loop -/// or manual `sync.now`). Surfaced by `sync.status` so clients can show whether -/// sync is actually working instead of trusting silence (tech-spec §3.1 / the -/// `Spoke sync health` task). All times are epoch ms; `None` means "not yet". -#[derive(Clone, Default, Serialize)] -struct SyncHealth { - /// When we last attempted an exchange. - last_attempt_ms: Option, - /// When we last completed one without error (the "last synced" time). - last_success_ms: Option, - /// The last error message, cleared on the next success. - last_error: Option, - /// Whether the most recent attempt failed authentication (a 401) — the - /// "re-auth needed" signal, distinct from a transient network blip. - auth_failure: bool, -} - /// The shared, cheaply-cloneable context each connection serves from. #[derive(Clone)] struct Ctx { @@ -59,43 +40,6 @@ struct Ctx { http: reqwest::Client, /// Token source for authenticated sync (None ⇒ unauthenticated hub). auth: Option, - /// Opt-in self-update config (`Some` ⇒ enabled, tech-spec self-update card). - self_update: Option, - /// Live sync health, shared between the background loop and `sync.status`. - sync_health: Arc>, -} - -/// Epoch-ms wall clock (the daemon may read it; only `heph-core` is clock-pure). -fn now_ms() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -/// True if `e` carries an HTTP 401 — i.e. the hub rejected our bearer token. -fn is_auth_error(e: &anyhow::Error) -> bool { - e.downcast_ref::() - .and_then(|re| re.status()) - .is_some_and(|s| s == reqwest::StatusCode::UNAUTHORIZED) -} - -/// Fold one exchange outcome into the shared [`SyncHealth`]. -fn record_sync_outcome(health: &Arc>, result: &Result) { - let now = now_ms(); - let mut h = health.lock().expect("sync_health mutex poisoned"); - h.last_attempt_ms = Some(now); - match result { - Ok(_) => { - h.last_success_ms = Some(now); - h.last_error = None; - h.auth_failure = false; - } - Err(e) => { - h.auth_failure = is_auth_error(e); - h.last_error = Some(e.to_string()); - } - } } impl Ctx { @@ -130,17 +74,8 @@ impl Daemon { ctx: Ctx { store: Arc::new(Mutex::new(store)), hub_url: None, - // Bound every hub request so a black-hole hub (one that accepts - // a connection but never replies) can't stall the sync / - // self-update loops — "the hub can vanish at any moment" is the - // base case, including vanishing mid-request. - http: reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .expect("building the daemon HTTP client"), + http: reqwest::Client::new(), auth: None, - self_update: None, - sync_health: Arc::new(Mutex::new(SyncHealth::default())), }, } } @@ -165,52 +100,12 @@ impl Daemon { self } - /// Enable opt-in self-update with the given config (`None` ⇒ stays off). - pub fn with_self_update(mut self, cfg: Option) -> Daemon { - self.ctx.self_update = cfg; - self - } - /// The shared store handle, for code that needs to reach the same store the /// daemon serves (the hub HTTP router and background sync, tech-spec §6.1). pub fn store(&self) -> SharedStore { self.ctx.store.clone() } - /// If self-update is enabled, spawn its background poller: every - /// `cfg.interval` it checks the forge for a newer release and, when one is - /// found, installs it and restarts onto the new binary. No-op when off. - /// - /// Note: the *apply* path runs `cargo install` and exits, which only works - /// when the daemon's service environment can reach the forge over SSH and - /// find cargo — see the `service-env-forge-access` card (the deployment step - /// that makes this operational). - pub fn spawn_self_update_loop(&self) { - let Some(cfg) = self.ctx.self_update.clone() else { - return; - }; - let source = selfupdate::ForgeReleaseSource::new(); - let installer: std::sync::Arc = - std::sync::Arc::new(selfupdate::CargoInstaller); - let restarter: std::sync::Arc = - std::sync::Arc::new(selfupdate::ProcessRestarter); - tracing::info!( - interval_secs = cfg.interval.as_secs(), - current = heph_core::VERSION, - "self-update enabled" - ); - tokio::spawn(async move { - selfupdate::run_poll_loop( - source, - installer, - restarter, - cfg.interval, - heph_core::VERSION, - ) - .await; - }); - } - /// If this is a spoke (`hub_url` set), spawn a background task that syncs the /// op-log with the hub every `interval` (attaching a bearer token when auth /// is configured). No-op otherwise. @@ -224,10 +119,7 @@ impl Daemon { loop { tick.tick().await; let bearer = ctx.bearer().await; - let result = - sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await; - record_sync_outcome(&ctx.sync_health, &result); - match result { + match sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await { Ok(report) => tracing::debug!(?report, "background sync"), Err(e) => tracing::warn!("background sync failed: {e}"), } @@ -322,9 +214,7 @@ async fn sync_now(ctx: &Ctx) -> Result { }); }; let bearer = ctx.bearer().await; - let result = sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await; - record_sync_outcome(&ctx.sync_health, &result); - match result { + match sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await { Ok(report) => Ok(json!(report)), Err(e) => Err(RpcError { code: INTERNAL_ERROR, @@ -333,28 +223,11 @@ async fn sync_now(ctx: &Ctx) -> Result { } } -/// `sync.status` — the hub url, the current per-hub cursors, the observed sync -/// health (last-success time / last error / auth-failure flag), and the pending -/// merge-conflict count. A spoke that is silently failing is visible here (and, -/// via it, in the TUI status line). +/// `sync.status` — the hub url and the current per-hub cursors. async fn sync_status(ctx: &Ctx) -> Result { - // Conflict count is meaningful even on a hub / standalone instance. - let store = ctx.store.clone(); - let conflicts = tokio::task::spawn_blocking(move || { - let guard = store.lock().expect("store mutex poisoned"); - guard.conflicts_list().map(|c| c.len()) - }) - .await - .map_err(|e| RpcError { - code: INTERNAL_ERROR, - message: format!("sync.status task failed: {e}"), - })? - .map_err(RpcError::from)?; - let Some(hub_url) = ctx.hub_url.clone() else { - return Ok(json!({ "hub_url": Value::Null, "conflicts": conflicts })); + return Ok(json!({ "hub_url": Value::Null })); }; - let store = ctx.store.clone(); let hub = hub_url.clone(); let cursors = tokio::task::spawn_blocking(move || { @@ -367,17 +240,5 @@ async fn sync_status(ctx: &Ctx) -> Result { message: format!("sync.status task failed: {e}"), })? .map_err(RpcError::from)?; - - let health = ctx - .sync_health - .lock() - .expect("sync_health mutex poisoned") - .clone(); - - Ok(json!({ - "hub_url": hub_url, - "cursors": cursors, - "conflicts": conflicts, - "health": health, - })) + Ok(json!({ "hub_url": hub_url, "cursors": cursors })) } diff --git a/crates/hephd/src/sync.rs b/crates/hephd/src/sync.rs index bfaa323..266cae1 100644 --- a/crates/hephd/src/sync.rs +++ b/crates/hephd/src/sync.rs @@ -10,12 +10,6 @@ //! - `POST /rpc` — the full daemon API ([`crate::rpc::dispatch`]) over HTTP, for //! a no-replica `client`-mode [`crate::remote::RemoteStore`] to proxy against. //! -//! All routes carry permissive CORS headers and answer the browser preflight -//! (`OPTIONS`), so a browser surface (the `heph-pwa` mobile app) can call `/rpc` -//! cross-origin. When the hub is given a `web_root`, unmatched paths fall back to -//! serving that directory's static files (the PWA shell), so the app can be -//! hosted same-origin straight from the hub. -//! //! Exchange is **incremental by HLC cursor** (`sync_state`, [`heph_core::SyncCursors`]): //! each side transfers only the tail it hasn't sent/seen. Merge is idempotent, //! so a re-pushed op the hub already has is a harmless no-op. When the hub is @@ -23,14 +17,13 @@ //! OIDC bearer token whose `sub` owns the hub (tech-spec §13); spokes attach //! that token via the `bearer` argument to [`sync_once`]. -use std::path::PathBuf; use std::sync::{Arc, Mutex}; use anyhow::Result; use axum::extract::{Query, Request, State}; -use axum::http::{header, HeaderValue, Method, StatusCode, Uri}; +use axum::http::StatusCode; use axum::middleware::{self, Next}; -use axum::response::{IntoResponse, Response as AxumResponse}; +use axum::response::Response as AxumResponse; use axum::routing::{get, post}; use axum::{Json, Router}; use serde::{Deserialize, Serialize}; @@ -51,9 +44,6 @@ pub type SharedStore = Arc>; struct HubState { store: SharedStore, verifier: Option>, - /// When set, unmatched paths serve static files from this directory (the - /// `heph-pwa` shell), so the app can be hosted same-origin from the hub. - web_root: Option, } /// A batch of ops in flight (push body / pull response). @@ -112,134 +102,15 @@ fn apply_batch( /// `verifier` is `Some`, every route requires a valid OIDC bearer token whose /// `sub` owns this hub (tech-spec §13); `None` leaves the hub open (local dev). pub fn router(store: SharedStore, verifier: Option>) -> Router { - router_with_web(store, verifier, None) -} - -/// [`router`] plus an optional `web_root`: when `Some(dir)`, paths that don't -/// match an API route serve static files from `dir` (the `heph-pwa` shell), -/// with a `index.html` fallback so the single-page app can deep-link. Static -/// files are served without authentication — they are only the app shell; all -/// data still flows through the auth-gated `/rpc` and `/sync/*` routes. -pub fn router_with_web( - store: SharedStore, - verifier: Option>, - web_root: Option, -) -> Router { - let state = HubState { - store, - verifier, - web_root, - }; + let state = HubState { store, verifier }; Router::new() .route("/sync/pull", get(pull)) .route("/sync/push", post(push)) .route("/rpc", post(rpc_call)) .route_layer(middleware::from_fn_with_state(state.clone(), require_auth)) - // Unauthenticated: the public OIDC params (issuer + client id) a browser - // client reads to start a PKCE login. Added after the auth `route_layer` - // so it is NOT gated — the app needs it *before* it has a token. - .route("/config", get(config)) - // The static shell is unauthenticated and lives behind the API routes. - .fallback(serve_static) - // Outermost: stamp CORS headers on every response and short-circuit the - // browser's `OPTIONS` preflight (before it reaches auth or routing). - .layer(middleware::from_fn(cors)) .with_state(state) } -/// Permissive-CORS middleware. Answers the browser preflight (`OPTIONS`) with a -/// 204 and stamps `Access-Control-*` headers on every response. The hub is a -/// personal endpoint guarded by bearer tokens (not cookies), so a wildcard -/// origin is safe — there are no ambient credentials for `*` to expose. -async fn cors(request: Request, next: Next) -> AxumResponse { - let is_preflight = request.method() == Method::OPTIONS; - let mut response = if is_preflight { - StatusCode::NO_CONTENT.into_response() - } else { - next.run(request).await - }; - let h = response.headers_mut(); - h.insert( - header::ACCESS_CONTROL_ALLOW_ORIGIN, - HeaderValue::from_static("*"), - ); - h.insert( - header::ACCESS_CONTROL_ALLOW_METHODS, - HeaderValue::from_static("GET, POST, OPTIONS"), - ); - h.insert( - header::ACCESS_CONTROL_ALLOW_HEADERS, - HeaderValue::from_static("authorization, content-type"), - ); - h.insert( - header::ACCESS_CONTROL_MAX_AGE, - HeaderValue::from_static("86400"), - ); - response -} - -/// Public OIDC parameters for a browser client (the `heph-pwa`) to start a PKCE -/// login: `{ "issuer", "client_id" }`. Unauthenticated — neither value is a -/// secret. Returns an empty object `{}` when the hub runs without OIDC, so the -/// app can detect that and fall back to a manually pasted token. -async fn config(State(state): State) -> Json { - let body = state - .verifier - .as_ref() - .and_then(|v| v.oidc_config()) - .map(|(issuer, client_id)| serde_json::json!({ "issuer": issuer, "client_id": client_id })) - .unwrap_or_else(|| serde_json::json!({})); - Json(body) -} - -/// Serve the PWA shell from `web_root` for any non-API path. Returns 404 when no -/// `web_root` is configured. Unknown paths fall back to `index.html` so the SPA -/// can own its own routing. Path traversal (`..`) is rejected. -async fn serve_static(State(state): State, uri: Uri) -> AxumResponse { - let Some(root) = state.web_root.as_ref() else { - return StatusCode::NOT_FOUND.into_response(); - }; - let rel = uri.path().trim_start_matches('/'); - if rel.split('/').any(|seg| seg == "..") { - return StatusCode::BAD_REQUEST.into_response(); - } - let rel = if rel.is_empty() { "index.html" } else { rel }; - - let direct = root.join(rel); - let index = root.join("index.html"); - // File reads run on the blocking pool (tokio's `fs` feature is off, and DB / - // disk I/O never runs on an async worker, tech-spec §3). - let read = tokio::task::spawn_blocking(move || { - match std::fs::read(&direct) { - Ok(bytes) => Some((content_type(&direct), bytes)), - // SPA fallback: serve index.html for unknown (extension-less) routes. - Err(_) => std::fs::read(&index) - .ok() - .map(|bytes| ("text/html; charset=utf-8", bytes)), - } - }) - .await; - match read { - Ok(Some((ctype, bytes))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(), - _ => StatusCode::NOT_FOUND.into_response(), - } -} - -/// Best-effort content type from a file extension (the handful the PWA serves). -fn content_type(path: &std::path::Path) -> &'static str { - match path.extension().and_then(|e| e.to_str()) { - Some("html") => "text/html; charset=utf-8", - Some("js" | "mjs") => "text/javascript; charset=utf-8", - Some("css") => "text/css; charset=utf-8", - Some("json" | "webmanifest") => "application/json; charset=utf-8", - Some("svg") => "image/svg+xml", - Some("png") => "image/png", - Some("ico") => "image/x-icon", - Some("woff2") => "font/woff2", - _ => "application/octet-stream", - } -} - /// Reject any request lacking a valid bearer token whose `sub` owns this hub. /// A no-op when the hub has no verifier configured (open dev mode). async fn require_auth( diff --git a/crates/hephd/tests/sync_http.rs b/crates/hephd/tests/sync_http.rs index ed093ce..de8b7bf 100644 --- a/crates/hephd/tests/sync_http.rs +++ b/crates/hephd/tests/sync_http.rs @@ -84,65 +84,6 @@ async fn a_node_propagates_a_to_hub_to_b() { assert_eq!(on_b.body.as_deref(), Some("shingles need work")); } -#[tokio::test] -async fn spoke_survives_an_unreachable_hub_then_reconciles_when_it_returns() { - // "The hub can vanish at any moment" is the base case, not a guarded edge: - // a spoke whose hub is down keeps serving + accepting writes, and when the - // hub returns its accumulated ops reconcile with no special recovery. This - // is what makes a self-updating hub (which restarts under its spokes) safe. - let http = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(5)) // never hang the test - .build() - .unwrap(); - let (a, _ca, _da) = replica(1000); - - // Hub down: work happens locally, and a sync attempt fails *fast* (Err — not - // a panic, not a hang) and leaves the store untouched. - let id = { - let mut ga = a.lock().unwrap(); - ga.create_node(NewNode::doc( - "Offline note", - "written while the hub was down", - )) - .unwrap() - .id - }; - let dead_hub = "http://127.0.0.1:1"; // nothing listens → connection refused - assert!( - sync::sync_once(a.clone(), dead_hub, &http, None) - .await - .is_err(), - "sync against a dead hub should error, not hang or panic" - ); - - // The spoke is unharmed: the note is intact and further writes still succeed. - { - let mut ga = a.lock().unwrap(); - assert_eq!(ga.get_node(&id).unwrap().unwrap().title, "Offline note"); - ga.create_node(NewNode::doc("Another", "still working offline")) - .unwrap(); - } - - // The hub returns: the spoke pushes everything it accumulated while offline, - // and a fresh replica pulls it — convergence resumes, no manual recovery. - let hub_url = start_hub().await; - let up = sync::sync_once(a.clone(), &hub_url, &http, None) - .await - .unwrap(); - assert!(up.pushed > 0, "spoke pushed nothing after the hub returned"); - let (b, _cb, _db) = replica(1000); - sync::sync_once(b.clone(), &hub_url, &http, None) - .await - .unwrap(); - let on_b = b - .lock() - .unwrap() - .get_node(&id) - .unwrap() - .expect("offline-authored node reached B after the hub recovered"); - assert_eq!(on_b.title, "Offline note"); -} - #[tokio::test] async fn divergent_scalar_edits_converge_through_the_hub_with_a_conflict() { let hub_url = start_hub().await; diff --git a/crates/hephd/tests/web_serve.rs b/crates/hephd/tests/web_serve.rs deleted file mode 100644 index bd43577..0000000 --- a/crates/hephd/tests/web_serve.rs +++ /dev/null @@ -1,213 +0,0 @@ -//! The hub's browser-facing surface (for the `heph-pwa` mobile app): permissive -//! CORS on every response, an `OPTIONS` preflight answer, and—when a `web_root` -//! is configured—static serving of the app shell with an `index.html` SPA -//! fallback. A tiny raw-HTTP client keeps this dependency-free and lets us drive -//! arbitrary methods (`OPTIONS`) and inspect response headers directly. - -use std::io::{Read, Write}; -use std::net::TcpStream; -use std::sync::mpsc; -use std::sync::{Arc, Mutex}; -use std::thread; -use std::time::Duration; - -use heph_core::{FixedClock, LocalStore}; -use hephd::auth::{AuthError, Claims, TokenVerifier}; -use hephd::sync::{self, SharedStore}; - -const NOW: i64 = 1_704_067_200_000; // 2024-01-01T00:00:00Z - -/// A verifier that never admits a token but advertises OIDC params, so we can -/// drive the unauthenticated `/config` route without a live IdP. -struct StubOidc; -impl TokenVerifier for StubOidc { - fn verify(&self, _bearer: &str) -> Result { - Err(AuthError::Missing) - } - fn oidc_config(&self) -> Option<(&str, &str)> { - Some(("https://idp.example/application/o/heph/", "heph")) - } -} - -/// One parsed HTTP response: status line code, lowercased headers, and body. -struct Resp { - status: u16, - headers: Vec<(String, String)>, - body: String, -} - -impl Resp { - fn header(&self, name: &str) -> Option<&str> { - let name = name.to_ascii_lowercase(); - self.headers - .iter() - .find(|(k, _)| *k == name) - .map(|(_, v)| v.as_str()) - } -} - -/// Issue one HTTP/1.1 request over a fresh connection (`Connection: close`, so -/// we can read the whole response to EOF) and parse the response. -fn request(addr: &str, method: &str, path: &str) -> Resp { - let mut stream = TcpStream::connect(addr).unwrap(); - let req = format!("{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"); - stream.write_all(req.as_bytes()).unwrap(); - let mut raw = String::new(); - stream.read_to_string(&mut raw).unwrap(); - - let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((&raw, "")); - let mut lines = head.split("\r\n"); - let status = lines - .next() - .and_then(|l| l.split_whitespace().nth(1)) - .and_then(|c| c.parse().ok()) - .unwrap(); - let headers = lines - .filter_map(|l| l.split_once(": ")) - .map(|(k, v)| (k.to_ascii_lowercase(), v.to_string())) - .collect(); - Resp { - status, - headers, - body: body.to_string(), - } -} - -/// Start the hub router (with the given `web_root`) over a temp `LocalStore` on -/// an ephemeral port; return its `host:port`. The server thread + temp dirs live -/// for the test's duration. -fn start(web_root: Option) -> String { - start_with(None, web_root) -} - -/// As [`start`], but with an explicit token verifier (to exercise the `/config` -/// route, which reports the verifier's OIDC params). -fn start_with( - verifier: Option>, - web_root: Option, -) -> String { - let (tx, rx) = mpsc::channel(); - thread::spawn(move || { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - rt.block_on(async move { - let dir = tempfile::tempdir().unwrap(); - let store = - LocalStore::open(dir.path().join("heph.db"), Box::new(FixedClock(NOW))).unwrap(); - let shared: SharedStore = Arc::new(Mutex::new(store)); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - tx.send(listener.local_addr().unwrap()).unwrap(); - let _keep = dir; - let app = sync::router_with_web(shared, verifier, web_root); - axum::serve(listener, app).await.unwrap(); - }); - }); - rx.recv_timeout(Duration::from_secs(5)).unwrap().to_string() -} - -#[test] -fn cors_headers_on_rpc_and_preflight_answered() { - let addr = start(None); - - // The browser preflight gets a 204 with the CORS allowances, without auth. - let pre = request(&addr, "OPTIONS", "/rpc"); - assert_eq!(pre.status, 204); - assert_eq!(pre.header("access-control-allow-origin"), Some("*")); - assert!(pre - .header("access-control-allow-headers") - .unwrap() - .contains("authorization")); - assert!(pre - .header("access-control-allow-methods") - .unwrap() - .contains("POST")); - - // A regular GET also carries the origin header (so XHR can read the body). - let get = request(&addr, "GET", "/sync/pull"); - assert_eq!(get.header("access-control-allow-origin"), Some("*")); -} - -#[test] -fn serves_static_shell_with_index_fallback() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write( - dir.path().join("index.html"), - "heph", - ) - .unwrap(); - std::fs::write(dir.path().join("app.js"), "export const x = 1;\n").unwrap(); - let addr = start(Some(dir.path().to_path_buf())); - - // Root serves index.html as HTML. - let root = request(&addr, "GET", "/"); - assert_eq!(root.status, 200); - assert!(root.body.contains("heph")); - assert_eq!( - root.header("content-type"), - Some("text/html; charset=utf-8") - ); - - // A real asset is served with a JS content type. - let js = request(&addr, "GET", "/app.js"); - assert_eq!(js.status, 200); - assert!(js.body.contains("export const x")); - assert_eq!( - js.header("content-type"), - Some("text/javascript; charset=utf-8") - ); - - // An unknown (extension-less) route falls back to index.html for the SPA. - let deep = request(&addr, "GET", "/inbox"); - assert_eq!(deep.status, 200); - assert!(deep.body.contains("heph")); - - // Path traversal never escapes web_root (whether the client/proxy normalizes - // the `..` away or our guard rejects it, the crate's Cargo.toml never leaks). - let escape = request(&addr, "GET", "/../../Cargo.toml"); - assert!( - !escape.body.contains("[package]"), - "must not serve files outside web_root" - ); - - // The temp dir must outlive the server thread's reads. - drop(dir); -} - -#[test] -fn no_web_root_yields_404_for_static_paths() { - let addr = start(None); - let resp = request(&addr, "GET", "/inbox"); - assert_eq!(resp.status, 404); - // Even the 404 carries CORS headers (it passed through the layer). - assert_eq!(resp.header("access-control-allow-origin"), Some("*")); -} - -#[test] -fn config_is_empty_without_oidc() { - let addr = start(None); - let resp = request(&addr, "GET", "/config"); - assert_eq!(resp.status, 200); - assert_eq!(resp.body.trim(), "{}"); -} - -#[test] -fn config_reports_oidc_params_unauthenticated() { - // Even on an authed hub, /config is reachable without a token (it is added - // after the auth layer) and reports the issuer + public client id. - let addr = start_with(Some(Arc::new(StubOidc)), None); - let resp = request(&addr, "GET", "/config"); - assert_eq!(resp.status, 200); - assert!( - resp.body - .contains("\"issuer\":\"https://idp.example/application/o/heph/\""), - "body was: {}", - resp.body - ); - assert!( - resp.body.contains("\"client_id\":\"heph\""), - "body was: {}", - resp.body - ); -} diff --git a/docs/changelog.d/+sync-age-seconds.feature.md b/docs/changelog.d/+sync-age-seconds.feature.md deleted file mode 100644 index cf453c2..0000000 --- a/docs/changelog.d/+sync-age-seconds.feature.md +++ /dev/null @@ -1 +0,0 @@ -heph-tui's sync indicator now shows the last-sync age in seconds under a minute (`⟳ 26s`) instead of a flat `just now`, so the chip reads as a live heartbeat and a missed sync (the loop runs every 30s) shows up as the age climbing. diff --git a/docs/changelog.d/heph-context-command.feature.md b/docs/changelog.d/heph-context-command.feature.md new file mode 100644 index 0000000..146a99c --- /dev/null +++ b/docs/changelog.d/heph-context-command.feature.md @@ -0,0 +1 @@ +New `heph context ` command reads or edits a task's canonical-context doc body **by task id**, with no manual `canonical_context_id` lookup. With no flag it prints the body; `--body ` replaces it (`-` reads stdin, like `node update`); `--append ` adds a blank-line-separated paragraph. Errors clearly on a node that has no canonical-context doc (e.g. a plain doc, not a task). diff --git a/docs/changelog.d/heph-tui-version.feature.md b/docs/changelog.d/heph-tui-version.feature.md new file mode 100644 index 0000000..17832b1 --- /dev/null +++ b/docs/changelog.d/heph-tui-version.feature.md @@ -0,0 +1 @@ +`heph-tui --version` now reports the version plus build commit (e.g. `1.0.0 (ab6701d12)`), matching `heph` and `hephd`. All three daily-driver binaries answer `--version` consistently. diff --git a/docs/changelog.d/prek-fmt-prepush.infra.md b/docs/changelog.d/prek-fmt-prepush.infra.md new file mode 100644 index 0000000..26cf000 --- /dev/null +++ b/docs/changelog.d/prek-fmt-prepush.infra.md @@ -0,0 +1 @@ +Added a `cargo-fmt-check` pre-push prek hook that runs `cargo fmt --all --check` (mirroring CI's Dagger `check` step) whenever a push touches a `.rs` file. The pre-commit `cargo-fmt` hook reformats in place, but only fires when installed and run; the pre-push check is a last-line guard so an unformatted commit can't reach the runner. Run `prek install --hook-type pre-push` to activate it. diff --git a/docs/changelog.d/project-arg-fuzzy.feature.md b/docs/changelog.d/project-arg-fuzzy.feature.md new file mode 100644 index 0000000..76b1a4f --- /dev/null +++ b/docs/changelog.d/project-arg-fuzzy.feature.md @@ -0,0 +1 @@ +`--project ` is now case-insensitive and prefix-fuzzy when unambiguous, across `heph task`, `heph edit`, `heph promote`, `heph list`, and project-parent resolution. `--project heph` or `--project hephaestus` both resolve `Hephaestus`. An exact (case-sensitive) title always wins outright, and an ambiguous prefix (e.g. `Wor` matching both `Work` and `Workshop`) resolves to nothing rather than silently picking one. A new `project.resolve` RPC backs the shared resolver. diff --git a/docs/changelog.d/version-rpc.feature.md b/docs/changelog.d/version-rpc.feature.md new file mode 100644 index 0000000..36171fd --- /dev/null +++ b/docs/changelog.d/version-rpc.feature.md @@ -0,0 +1 @@ +New `version` RPC returns the daemon's build version (`heph_core::VERSION`, e.g. `1.0.0 (ab6701d12)`), so RPC clients — notably the `hephaestus.nvim` plugin's `:Heph version` command — can report which `hephd` they are talking to without shelling out to the binary. diff --git a/docs/explanation/explanation.md b/docs/explanation/explanation.md index bbc81dc..c4763ab 100644 --- a/docs/explanation/explanation.md +++ b/docs/explanation/explanation.md @@ -12,4 +12,3 @@ Background context and design decisions. - [[design]] — Hephaestus design document: vision, data model, architecture, sync, and roadmap - [[task-lifecycle]] — the two-axis task model (lifecycle state × attention), drop vs delete, and where each task shows up -- [[hub-spoke-data-evolution]] — why op-based sync lets most new features skip migrations, and when a coordinated SQLite migration is actually required diff --git a/docs/explanation/hub-spoke-data-evolution.md b/docs/explanation/hub-spoke-data-evolution.md deleted file mode 100644 index adf3453..0000000 --- a/docs/explanation/hub-spoke-data-evolution.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -title: Hub + Spoke Data Evolution -modified: 2026-06-05 -tags: - - explanation - - sync ---- - -# Hub + Spoke Data Evolution - -How the data model evolves safely when nodes run different versions across the -hub/spoke deployment (indri is the hub; see [[set-up-sync-hub]] and -[[host-heph-pwa]]). The short version: **sync is op-based, not schema-based**, so -most new features need no coordinated migration — but adding a SQLite *column* -does. - -## Two independent layers - -heph keeps two layers that evolve on different clocks: - -1. **The op-log (synced).** Every change is an operation — `node.create`, - `node.set`, `task.set`, `link.add`, `link.remove`, … — carrying an HLC, an - origin device, and a JSON payload. Spokes push/pull ops to/from the hub; both - sides run the **same** merge logic from `heph-core` (`sqlite/apply.rs`). This - is the only thing that crosses the wire. -2. **The SQLite schema (local, per node).** Each node materializes ops into local - tables. The schema version is tracked by SQLite's `PRAGMA user_version` and - advanced by the ordered, append-only migration list in - `heph-core/src/sqlite/migrations.rs`. **No schema or migration state is ever - synced.** A spoke can sit on an older schema than the hub indefinitely. - -Because the wire format is ops — not rows — a node only has to understand the -*ops* its peers emit, not their table layout. - -## What forward/backward compatibility already buys you - -The merge engine is deliberately lenient: - -- **Unknown op types are stored but not applied** (`apply.rs`) — a spoke that - receives a newer op type keeps it in the log (so a later upgrade can replay it) - but doesn't choke on it. -- **Unknown payload fields are ignored.** Field extraction is by name - (`str_field` / `i64_field`), so a payload with extra keys an older node doesn't - recognize just drops the extras. -- **Links are schema-free.** A link's `type` is a string column. A brand-new link - kind (a new `LinkType`) needs no migration — every version reads it as text and - applies OR-set add/remove identically. - -## The rule of thumb - -| Change | Needs coordinated migration? | -|--------|------------------------------| -| New `LinkType` (e.g. a new relationship between nodes) | **No** — just emit `link.add` with the new `type` string | -| New optional/nullable scalar carried in an op payload | **No, if** every node's `apply` reads it defensively and tolerates its absence | -| New *read-side* feature over existing data (counts, hierarchy from existing `parent` links) | **No** — pure local queries, no op or schema change | -| New **required** SQLite column that `apply` must write on every relevant op | **Yes** — old spokes lack the column and the `UPDATE` fails | -| Renaming/removing a column other nodes' `apply` paths reference | **Yes** | - -## When a migration *is* required, do it hub-first - -If a change genuinely needs a new column that the apply path writes: - -1. Ship the migration to **every** node (hub and all spokes) **before** any node - emits an op that depends on the new column. The migration list is - append-only and ordered, so rolling the new `hephd` out everywhere is the - gate. -2. Keep new columns **nullable / defaulted** so an op that predates the column - still applies, and so a node that hasn't yet upgraded degrades to "field - absent" rather than erroring. -3. Prefer encoding the new fact as a **link or an op-payload field** over a new - column whenever you can — that keeps the change in the no-migration column of - the table above. - -## Worked example: indented, counted projects - -The sidebar's subproject indentation and per-project task counts (see -[[install-heph]] and the agenda surface in [[design]] §8.1) are a pure read-side -feature: - -- **Nesting** is read from `parent` links that already exist — created by - `heph project add --parent ` — via the existing - `project_subtree` traversal. -- **Counts** are a read-only `SELECT … GROUP BY` over the `tasks`/`links` tables. - -No new column, no new op type, no migration — it works against a hub and a spoke -on any schema version that already understands `parent` links. That is the case -the rule of thumb is meant to make obvious. diff --git a/docs/how-to/heph-pwa.md b/docs/how-to/heph-pwa.md deleted file mode 100644 index 2a158e9..0000000 --- a/docs/how-to/heph-pwa.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -title: heph-pwa (mobile app) -modified: 2026-06-04 -tags: - - how-to ---- - -# heph-pwa — the mobile app - -`heph-pwa` is a phone-first, installable web app that mirrors [[v1-prototype-tech-spec|heph-tui]]: -browse the built-in views and projects, triage tasks, and — the primary use -case — **capture tasks fast** with the same quick-add syntax as the TUI's `a` / -Cmd-' popover. Context/KB is **read-only** here (no Neovim editing surface). - -It is a thin, online-only client: every read and write is a JSON-RPC call to a -**server-mode `hephd`** (the sync hub, see [[set-up-sync-hub]]). There is no -local replica or background sync — when the hub is unreachable, the app shows an -error rather than queueing offline. - -> **Why a PWA and not native iOS?** A native Swift app cannot be signed, built, -> or installed without an Apple Developer account. A PWA delivers the primary -> use case today — installable to the home screen, full-screen, with home-screen -> launch and offline app-shell — and keeps the door open to a native wrapper -> later. This was a deliberate first-cut choice; revisit if a native app becomes -> worthwhile. - -## Serve it from the hub - -The hub can serve the app shell same-origin (no CORS or separate static host -needed). Point `hephd` at the `heph-pwa/` directory: - -```bash -hephd --mode server \ - --http-addr 0.0.0.0:8787 \ - --web-root /path/to/hephaestus/heph-pwa \ - --oidc-issuer https://auth.example.com/... \ - --oidc-audience heph-mobile -``` - -- `--web-root` is optional. Unset, the hub serves only its API routes (unchanged - behavior). Set, it serves the static shell for any non-API path, with an - `index.html` SPA fallback. The shell is unauthenticated (it's just HTML/JS); - all data still flows through the auth-gated `/rpc`. -- Every hub response now carries permissive CORS headers and answers the browser - `OPTIONS` preflight, so you can alternatively host the shell anywhere (any - static server, GitHub Pages, etc.) and still call the hub cross-origin. - -Then open `https://:8787/` on your phone and **Add to Home Screen**. - -## Connect - -On first launch the app opens **Settings**: - -- **Hub URL** — the server-mode `hephd` base URL (e.g. `https://hub.example.com:8787`). - When served from the hub, use that same origin. -- **Token** — a bearer token, if the hub requires OIDC (`--oidc-issuer`/`-audience`). - Leave blank for an unauthenticated hub (local network / dev). Tap **Test** to - verify the connection (it calls the `version` RPC). - -Settings persist in the browser's local storage. - -> The device-code OIDC login flow (RFC 8628) the CLI/daemon use is **not** yet -> wired into the PWA — for now paste a bearer token obtained out-of-band. Wiring -> the in-app device flow is the obvious next step. - -## Quick-add - -Tap **+** (or press Cmd-' / Ctrl-' on a keyboard) to capture. The single input -accepts the exact [[v1-prototype-tech-spec|tech-spec §8.1]] syntax, parsed live -into preview chips before you submit: - -| Token | Example | Effect | -|-------|---------|--------| -| `p1`–`p4` | `p1` | attention: red / orange / blue / white | -| `#Project` | `#Camano Chores` | file under a project (greedy multi-word match) | -| date | `today` `tomorrow` `+3d` `fri` `2026-07-01` | do-date | -| `every …` | `every 3 days` `every other wed` `every workday` | recurrence (RRULE) | - -Unmatched `#tags` stay in the title verbatim. With no `#Project` token, the task -files into the currently selected project (or Inbox). The parser is a faithful -JS port of the Rust `quickadd`/`datespec` modules, covered by parity tests -(`heph-pwa/test/parsers.test.mjs`, run with `node --test`). - -## Voice - -The quick-add field supports voice two ways: - -- **iOS / iPadOS:** use the **microphone key on the on-screen keyboard** — Apple - dictation works in the text field for free, no app permission needed. -- **Chrome / Android / desktop:** a 🎤 button appears when the Web Speech API is - available and dictates straight into the field. - -(Anthropic has no speech-to-text endpoint, so transcription leans on the -platform. A server-side transcription proxy could be added later if needed.) - -## Triage - -Tap a task to expand its actions, mirroring the TUI keys: **Done** (`x`), -**Drop** (`d`), **Skip** (`S`, recurring only), **Attn** (cycle attention, `A`), -**Date** (reschedule, `e`), **Move** (project picker, `m`), **Delete** -(tombstone, `D`). Done/Drop show an **Undo**. The expanded view also shows the -task's canonical-context body + recent log tail (read-only). - -Search (🔍 or `/`) runs full-text search across tasks and docs. - -## Known limitations (first cut) - -- Online-only; no offline write queue or CRDT replica. -- No in-app OIDC device-code login yet (paste a token). -- Context/KB is read-only (no wiki-link navigation or editing). -- Undo covers Done/Drop only. - -## Related - -- [[host-heph-pwa]] — serve this app from the hub (indri) with OIDC, in the hub/spoke deployment -- [[set-up-sync-hub]] — stand up the server-mode hub the app talks to -- [[run-the-daemon]] — run `hephd` as a managed service -- [[v1-prototype-tech-spec]] — data model, RPC API, quick-add spec -- [[design]] — vision and rationale diff --git a/docs/how-to/host-heph-pwa.md b/docs/how-to/host-heph-pwa.md deleted file mode 100644 index dc22359..0000000 --- a/docs/how-to/host-heph-pwa.md +++ /dev/null @@ -1,135 +0,0 @@ ---- -title: Host heph-pwa from the hub -modified: 2026-06-04 -tags: - - how-to ---- - -# Host heph-pwa from the hub - -How to serve the [[heph-pwa]] mobile app from the canonical **hub** (`indri`) in -the hub-and-spoke deployment, with OIDC auth — the production counterpart of the -unauthenticated single-machine demo. Assumes the `heph-pwa` work is **merged and -released**, so the installed `hephd` already has `--web-root` and CORS. - -> Read [[set-up-sync-hub]] first — this builds directly on the hub it stands up -> (server mode, Authentik OIDC, Tailscale transport). - -## What the app needs from the hub - -The PWA is a thin, online-only client: it loads its static shell over HTTP and -makes JSON-RPC calls to the hub's `/rpc`. So the hub must (1) serve the shell -files and (2) accept the app's authenticated RPC calls. Both are already in -`hephd --mode server`: - -- `--web-root ` serves the shell for any non-API path (with an `index.html` - SPA fallback). The shell is unauthenticated — it is only HTML/JS; all data - still flows through the OIDC-gated `/rpc`. -- Every response carries permissive CORS headers and answers the `OPTIONS` - preflight, so the shell may instead be hosted anywhere and still call the hub - cross-origin. - -## 1. Put the shell on the hub - -The release does not yet bundle the app, so fetch the `heph-pwa/` directory at -the **same version tag** the hub runs (keeping shell and hub in lockstep matters -— see *Upgrades* below), and copy it to a stable path: - -```bash -# on indri, matching the running hephd version (e.g. v1.4.0) -git clone --depth 1 --branch v1.4.0 \ - https://forge.ops.eblu.me/eblume/hephaestus.git /tmp/heph-src -sudo mkdir -p /var/lib/heph/web -sudo cp -r /tmp/heph-src/heph-pwa/. /var/lib/heph/web/ -``` - -> **Future improvement:** have the release workflow package a `heph-pwa-.tar.gz` -> asset (as it already does for docs), so this step becomes "download + extract" -> and the lockstep is automatic. Until then, pin the clone to the hub's tag. - -## 2. Add `--web-root` to the hub service - -Extend the hub invocation from [[set-up-sync-hub]] with `--web-root` (everything -else — issuer, audience, db — unchanged): - -```bash -hephd --mode server \ - --http-addr 0.0.0.0:8787 \ - --db /var/lib/heph/heph.db \ - --web-root /var/lib/heph/web \ - --oidc-issuer https://authentik.ops.eblu.me/application/o/heph/ \ - --oidc-audience -``` - -In the systemd unit (or launchd plist), add the two `--web-root` arguments and -`systemctl restart hephd`. Self-update is compatible now that the release ships -the flag — just refresh the web-root on each upgrade (next section). - -## 3. Terminate TLS (recommended) - -Serve the app over **HTTPS** so it is a *secure context*: only then do the -service worker (offline launch), proper PWA install, and the Web Speech mic -work. (On iOS, "Add to Home Screen" and keyboard dictation work over plain HTTP -too, so HTTPS is a polish step, not a blocker.) Two good options: - -- **Tailscale serve** — tailnet-only, automatic MagicDNS cert, no public - exposure: - - ```bash - tailscale serve --bg --https=443 http://127.0.0.1:8787 - # app is then at https://indri..ts.net/ - ``` - - Bind `hephd` to `127.0.0.1:8787` in this case and let Tailscale be the only - thing exposing it. - -- **Reverse proxy** (Caddy / nginx) terminating a real cert, if the hub should - be reachable beyond the tailnet. Proxy all paths (`/`, `/rpc`, `/sync/*`) to - `hephd`. - -Either way the app is same-origin with the hub, so no CORS is involved and the -app defaults its hub URL to its own origin. - -## 4. Connect a phone - -1. Ensure the phone is on the tailnet (or can reach the proxy). -2. Open the hub URL (`https://indri..ts.net/`) and **Add to Home Screen**. -3. The app defaults its **Hub URL** to the origin it loaded from — no typing. -4. **Sign in:** open **Settings → Login with Authentik**. The app reads the - hub's `GET /config` for the issuer + client id (zero-config) and runs an - Authorization-Code + PKCE redirect to Authentik; after you approve it lands - back on the app, signed in, and silently refreshes the token from then on. - (A manual **Bearer token** field remains as a fallback for hubs without - OIDC, or for pasting a one-off token.) - - > Re-prompted for login too often? The fix is the Authentik provider's - > **refresh token validity**, not the app — see the token-lifetime note in - > [[set-up-sync-hub]]. (On iOS, Safari may also purge an un-installed PWA's - > storage after ~7 idle days; Add to Home Screen mitigates it.) - -**Prerequisite — register the PWA redirect URI.** Browser PKCE needs the app's -origin registered on the Authentik `heph` provider's **Redirect URIs** (Authentik -also keys token-endpoint CORS off those origins). Add the PWA origin(s) with a -trailing slash, e.g. `https://heph.ops.eblu.me/` (and `http://localhost:8787/` -for local dev). In blumeops this is the `redirect_uris` list on the heph -provider blueprint. - -## Upgrades - -On each hub upgrade, refresh the shell so it matches the running `hephd`: - -```bash -git -C /tmp/heph-src fetch --depth 1 origin v1.5.0 && git -C /tmp/heph-src checkout v1.5.0 -sudo cp -r /tmp/heph-src/heph-pwa/. /var/lib/heph/web/ -``` - -The service worker is versioned (`CACHE = "heph-pwa-vN"`), so an updated shell -evicts the old cache on next load. Hard-refresh once if a phone seems stuck on a -stale version. - -## Related - -- [[heph-pwa]] — the app itself (features, quick-add, voice, triage) -- [[set-up-sync-hub]] — stand up the hub + Authentik OIDC this doc extends -- [[run-the-daemon]] — run `hephd` as a managed service -- [[v1-prototype-tech-spec]] — RPC API and auth model diff --git a/docs/how-to/how-to.md b/docs/how-to/how-to.md index c20c904..9a3a758 100644 --- a/docs/how-to/how-to.md +++ b/docs/how-to/how-to.md @@ -20,6 +20,3 @@ Task-oriented guides for common operations. - [[run-the-daemon]] — Run `hephd` as an OS service with `heph daemon start/stop/restart/status` - [[set-up-sync-hub]] — Stand up the canonical hub (indri) and connect an existing device as an offline-capable spoke - [[import-todoist]] — Seed a heph store from your Todoist projects + tasks (`mise run import-todoist`) -- [[self-update]] — Opt-in `hephd` self-update: poll the forge for new releases and auto-update -- [[heph-pwa]] — The mobile app: an installable PWA mirror of heph-tui (browse, triage, fast quick-add, voice) -- [[host-heph-pwa]] — Serve the mobile app from the hub (indri) with OIDC, in the hub/spoke deployment diff --git a/docs/how-to/run-the-daemon.md b/docs/how-to/run-the-daemon.md index 2b00dff..8ada221 100644 --- a/docs/how-to/run-the-daemon.md +++ b/docs/how-to/run-the-daemon.md @@ -28,14 +28,9 @@ when it's already stopped is fine. `~/Library/LaunchAgents/org.hephaestus.hephd.plist`, with `RunAtLoad` + `KeepAlive` (starts at login, restarts if it crashes). - **Linux** — a **systemd user service** (`heph.service`) at - `~/.config/systemd/user/heph.service`, with `Restart=always`, enabled for + `~/.config/systemd/user/heph.service`, with `Restart=on-failure`, enabled for login. -> **Upgrading from an older install:** earlier units used `Restart=on-failure`, -> which does **not** respawn after a clean exit — so opt-in self-update (which -> exits cleanly to hand off to the new binary) wouldn't come back on Linux. Run -> `heph daemon restart` once (it regenerates the unit) to pick up `Restart=always`. - Either way it runs `hephd --mode local` against the default store (`~/.local/share/heph/heph.db`) and socket, with logs at `~/.local/share/heph/hephd.log`. @@ -53,16 +48,6 @@ still the old binary until you restart it: heph daemon restart ``` -## Self-update (opt-in) - -`hephd` can keep itself current: `heph daemon start --self-update` generates a -service that polls the forge for newer releases and, when one appears, rebuilds -via `cargo install` (anonymous HTTPS clone of the public repo — no credentials) -and restarts onto the new binary. It is **off by default**; the generated -service also gets a `PATH` that can find cargo. `heph daemon restart` preserves -the setting (pass `--self-update` again to turn it on later). Requires the Rust -toolchain (`cargo`) installed for the service user. - ## Development isolation `heph daemon` manages the **installed** daemon on the default paths. For in-repo diff --git a/docs/how-to/self-update.md b/docs/how-to/self-update.md deleted file mode 100644 index d4dda1f..0000000 --- a/docs/how-to/self-update.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -title: hephd self-update -modified: 2026-06-04 -tags: - - how-to ---- - -# hephd self-update - -`hephd` can keep itself current: it polls the forge for a newer release and, when -one appears, rebuilds and restarts onto it — unattended. It is **opt-in and off -by default**. - -## Enable it - -On the managed service: - -```bash -heph daemon start --self-update -``` - -That generates a launchd/systemd service that runs `hephd --self-update` and -gives it a `PATH` that can find `cargo`. `heph daemon restart` preserves the -setting (pass `--self-update` again to turn it on later). To run the daemon -directly instead: - -```bash -hephd --self-update # default: poll every 6h -hephd --self-update --self-update-interval-secs 3600 -``` - -## How it works - -1. Each interval, `hephd` GETs the forge's `releases/latest` and compares the tag - against its own version (the one `heph --version` reports). -2. On a newer release it runs `cargo install --locked --git - --tag vX.Y.Z` for `heph`/`hephd`/`heph-tui`/`heph-quickadd`. hephaestus is a - public repo, so this is an anonymous clone — **no credentials**. -3. On a successful install it exits cleanly; the service manager (launchd - `KeepAlive` / systemd `Restart=always`) brings the new binary up. - -A failed poll or build is logged and the daemon keeps running on its current -version — self-update never takes the daemon down. - -## Requirements & notes - -- The **Rust toolchain** (`cargo`) must be installed for the service user; the - update builds from source. -- Off by default — nothing happens unless `--self-update` is passed. -- The first real cross-version upgrade is observable on the first release cut - after enabling it. - -## Related - -- [[run-the-daemon]] — running `hephd` as an OS service -- [[install-heph]] — installing the binaries diff --git a/docs/how-to/set-up-sync-hub.md b/docs/how-to/set-up-sync-hub.md index a5b56ea..a0f7706 100644 --- a/docs/how-to/set-up-sync-hub.md +++ b/docs/how-to/set-up-sync-hub.md @@ -51,26 +51,6 @@ need: - **Issuer** — e.g. `https://authentik.ops.eblu.me/application/o/heph/` - **Client id** — the device-code client id (this is also the token *audience*). -### Token lifetime (avoid frequent re-logins) - -Token lifetimes are set on the Authentik **provider**, not in heph — heph honors -whatever `expires_in` Authentik returns and silently refreshes using the -`offline_access` refresh token (both the CLI/daemon and the PWA do this). To -avoid re-authenticating often, set generous validities on the heph provider: - -- **Access token validity** — e.g. `hours=24`. The hub validates `exp` and keeps - no revocation list, so this is the window in which a leaked token stays usable; - on a Tailscale-only hub, 24–48h is a reasonable trade. -- **Refresh token validity** — e.g. `days=30`+. This is the setting that stops - the re-logins: while the refresh token is valid, the spoke **and** the PWA - renew silently with no browser round-trip. A short refresh window is the usual - cause of "I have to log in constantly". - -> **iOS PWA caveat:** Safari can purge an *un-installed* PWA's `localStorage` -> (where its tokens live) after ~7 idle days regardless of these settings. -> Installing the app to the home screen mitigates it, but expect the occasional -> re-login on iOS. - ## 2. Bring up the hub on `indri` **Seed it from `gilbert` (Path A).** Quiesce `gilbert` (`heph daemon stop`), @@ -118,16 +98,10 @@ and background-syncs on its interval. ## 4. Verify ```bash -heph sync --status # hub url, last push/pull cursors, sync health +heph sync --status # last push/pull cursors, hub url heph sync # force a cycle now ``` -`heph sync --status` also reports **sync health** — the time of the last -successful exchange, any last error, and whether the spoke is currently failing -to authenticate. The same signal is surfaced live in `heph-tui`'s status line -(last-sync age · pending conflicts · an auth-failure flag), so a silently-broken -spoke is visible at a glance rather than buried in the daemon log. - Make a change on `gilbert`, force a sync, and confirm it appears via the hub. ## Current gaps (finalized by the blumeops deployment) diff --git a/heph-pwa/README.md b/heph-pwa/README.md deleted file mode 100644 index 197d86a..0000000 --- a/heph-pwa/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# heph-pwa - -A phone-first, installable **Progressive Web App** that mirrors `heph-tui`: -browse the built-in views and projects, triage tasks, and — the primary use -case — capture tasks fast with the same quick-add syntax as the TUI's `a` / -Cmd-' popover. Context/KB is read-only here. - -Full guide: [`docs/how-to/heph-pwa.md`](../docs/how-to/heph-pwa.md). - -## What it is - -- **Thin, online-only client.** Every read/write is a JSON-RPC call to a - server-mode `hephd` (the sync hub). No local replica, no offline write queue. -- **Buildless.** Plain ES modules, no bundler, no `npm install`. Serve the - directory and go. -- **Same parser as the TUI.** `src/quickadd.js` + `src/datespec.js` are faithful - ports of the Rust `hephd::quickadd` / `hephd::datespec` modules, verified by - parity tests against the original Rust unit cases. - -## Layout - -``` -index.html # app shell -styles.css # dark, terminal-flavored, touch-tuned -manifest.webmanifest # PWA manifest (installable) -sw.js # service worker — caches the app shell for offline launch -icons/ # app icons (svg + rasterized png, incl. maskable) -src/ - app.js # UI controller: views, list, quick-add, triage, search, voice - rpc.js # hephd JSON-RPC-over-HTTP client + settings (localStorage) - quickadd.js # quick-add parser (port of quickadd.rs) - datespec.js # date + recurrence parser (port of datespec.rs) - fmt.js # display helpers (date chips, attention colors, bullets) -test/ - parsers.test.mjs # parity tests for the parser ports -``` - -## Run it - -Serve from the hub (recommended — same-origin, no CORS): - -```bash -hephd --mode server --http-addr 0.0.0.0:8787 --web-root /path/to/heph-pwa -# then open http://:8787/ on your phone and Add to Home Screen -``` - -Or from any static server (the hub now sends CORS headers, so cross-origin -`/rpc` calls work); set the hub URL in the app's Settings screen. - -## Test - -```bash -node --test heph-pwa/test/parsers.test.mjs -``` - -## Status / next steps - -First cut (C1). Known gaps, roughly in priority order: - -- In-app OIDC device-code login (today: paste a bearer token in Settings). -- Offline write queue / CRDT replica (today: online-only). -- Read-only context could grow wiki-link navigation. -- A native Swift wrapper, if/when an Apple Developer account is in play. diff --git a/heph-pwa/icons/icon-180.png b/heph-pwa/icons/icon-180.png deleted file mode 100644 index dfa2b2a..0000000 Binary files a/heph-pwa/icons/icon-180.png and /dev/null differ diff --git a/heph-pwa/icons/icon-192.png b/heph-pwa/icons/icon-192.png deleted file mode 100644 index 571bc1e..0000000 Binary files a/heph-pwa/icons/icon-192.png and /dev/null differ diff --git a/heph-pwa/icons/icon-512.png b/heph-pwa/icons/icon-512.png deleted file mode 100644 index 8ac6bbc..0000000 Binary files a/heph-pwa/icons/icon-512.png and /dev/null differ diff --git a/heph-pwa/icons/icon-maskable.png b/heph-pwa/icons/icon-maskable.png deleted file mode 100644 index ec5c299..0000000 Binary files a/heph-pwa/icons/icon-maskable.png and /dev/null differ diff --git a/heph-pwa/icons/icon.svg b/heph-pwa/icons/icon.svg deleted file mode 100644 index a0beeed..0000000 --- a/heph-pwa/icons/icon.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - diff --git a/heph-pwa/index.html b/heph-pwa/index.html deleted file mode 100644 index 1943625..0000000 --- a/heph-pwa/index.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - heph - - - - - - -
- - - - diff --git a/heph-pwa/manifest.webmanifest b/heph-pwa/manifest.webmanifest deleted file mode 100644 index d5b14c8..0000000 --- a/heph-pwa/manifest.webmanifest +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "heph", - "short_name": "heph", - "description": "Capture and triage hephaestus tasks from your phone.", - "start_url": "./", - "scope": "./", - "display": "standalone", - "orientation": "portrait", - "background_color": "#15181d", - "theme_color": "#15181d", - "icons": [ - { "src": "./icons/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }, - { "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, - { "src": "./icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, - { "src": "./icons/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } - ] -} diff --git a/heph-pwa/src/app.js b/heph-pwa/src/app.js deleted file mode 100644 index 4452c89..0000000 --- a/heph-pwa/src/app.js +++ /dev/null @@ -1,895 +0,0 @@ -// heph-pwa — a mobile-first browser mirror of heph-tui. Browse the built-in -// views and projects, triage tasks, and (the primary use case) capture new -// tasks fast with the same quick-add syntax as the TUI's `a` / Cmd-' popover. -// -// Online-only thin client: every action is an RPC to the configured hub (see -// rpc.js). Context/KB is read-only here (no nvim editing surface). - -import { Client, loadSettings, saveSettings, RpcError } from "./rpc.js"; -import * as oauth from "./oauth.js"; -import { parse as quickParse } from "./quickadd.js"; -import { today, parseDate, toEpochMs, humanizeRecurrence } from "./datespec.js"; -import { - ATTENTION_COLORS, - fmtRelative, - hasFlag, - isOverdue, - nextAttention, - projectColor, -} from "./fmt.js"; - -// The built-in views, in the TUI sidebar order (filter.rs BUILTIN_VIEWS). -const VIEWS = [ - { id: "tom", title: "Top of Mind" }, - { id: "tasks", title: "Tasks" }, - { id: "work", title: "Work Tasks" }, - { id: "chores", title: "Chores" }, - { id: "ondeck", title: "On Deck" }, - { id: "inbox", title: "Inbox" }, -]; - -const state = { - settings: loadSettings(), - client: null, - target: { type: "view", id: "tom", title: "Top of Mind" }, - tasks: [], - projects: [], - expandedId: null, - loading: false, - error: null, - search: null, // null, or { query, results } - lastUndo: null, // { label, run } -}; - -// Build the RPC client from the current settings, wiring an OIDC silent-refresh -// hook: on a 401 the client calls this to renew the token (oauth.js) and retry -// once before surfacing the error. -function makeClient() { - return new Client({ - baseUrl: state.settings.baseUrl, - token: state.settings.token, - refresh: async () => { - const tok = await oauth.ensureFreshToken(true); - applyToken(tok || ""); - return tok; - }, - }); -} - -// Adopt `token` as the active bearer: persist it and rebuild the client. -function applyToken(token) { - state.settings.token = token || ""; - saveSettings(state.settings); - state.client = makeClient(); -} - -state.client = makeClient(); - -// --- tiny DOM helper -------------------------------------------------------- - -/** h("div", {class:"x", onclick:fn}, child, child...) → HTMLElement. */ -function h(tag, props = {}, ...children) { - const el = document.createElement(tag); - for (const [k, v] of Object.entries(props || {})) { - if (v == null || v === false) continue; - if (k === "class") el.className = v; - else if (k === "html") el.innerHTML = v; - else if (k.startsWith("on") && typeof v === "function") { - el.addEventListener(k.slice(2).toLowerCase(), v); - } else el.setAttribute(k, v === true ? "" : String(v)); - } - for (const c of children.flat()) { - if (c == null || c === false) continue; - el.append(c.nodeType ? c : document.createTextNode(String(c))); - } - return el; -} - -const $ = (sel) => document.querySelector(sel); - -function toast(message, action) { - const root = $("#toast"); - root.innerHTML = ""; - const node = h( - "div", - { class: "toast-body" }, - h("span", {}, message), - action && - h( - "button", - { - class: "toast-action", - onclick: () => { - root.innerHTML = ""; - action.run(); - }, - }, - action.label, - ), - ); - root.append(node); - if (!action) setTimeout(() => (root.innerHTML === "" ? null : (root.innerHTML = "")), 2600); -} - -// --- data ------------------------------------------------------------------- - -async function reload() { - if (!state.client.configured) { - state.error = "Set your hub URL in Settings to begin."; - render(); - openSettings(); - return; - } - state.loading = true; - state.error = null; - render(); - try { - const [tasks, projects] = await Promise.all([ - state.target.type === "view" - ? state.client.view(state.target.id) - : state.client.list({ scope: [state.target.id] }), - state.client.projects(), - ]); - state.tasks = tasks; - state.projects = projects; - state.error = null; - } catch (e) { - state.error = e instanceof RpcError ? e.message : String(e); - } finally { - state.loading = false; - render(); - } -} - -function projectTitle(id) { - if (!id) return null; - return state.projects.find((p) => p.id === id)?.title || id; -} - -async function refreshProjects() { - try { - state.projects = await state.client.projects(); - } catch { - /* keep stale list */ - } -} - -// --- rendering -------------------------------------------------------------- - -function render() { - renderHeader(); - renderMain(); -} - -function renderHeader() { - $("#view-title").textContent = state.search ? "Search" : state.target.title; -} - -function attentionDot(att) { - return h("span", { - class: "flag", - style: hasFlag(att) ? `color:${ATTENTION_COLORS[att]}` : "color:transparent", - }, hasFlag(att) ? "⚑" : "·"); -} - -function dateChip(t) { - const now = Date.now(); - if (isOverdue(t.late_on, now)) { - return h("span", { class: "chip overdue" }, `late ${fmtRelative(t.late_on, now)}`); - } - if (t.do_date != null) { - return h("span", { class: "chip" }, fmtRelative(t.do_date, now)); - } - return null; -} - -function taskRow(t) { - const expanded = state.expandedId === t.node_id; - const row = h( - "div", - { class: "row" + (expanded ? " expanded" : "") }, - h( - "div", - { - class: "row-head", - onclick: () => { - state.expandedId = expanded ? null : t.node_id; - render(); - if (!expanded) loadPreview(t); - }, - }, - attentionDot(t.attention), - h("span", { class: "bullet", style: `color:${projectColor(t.project_id)}` }, "●"), - h("span", { class: "title" }, t.title), - t.recurrence && h("span", { class: "recur" }, "↻"), - dateChip(t), - ), - expanded && taskDetail(t), - ); - return row; -} - -function taskDetail(t) { - const meta = []; - if (t.project_id) meta.push(["project", projectTitle(t.project_id)]); - if (t.recurrence) meta.push(["recurs", humanizeRecurrence(t.recurrence)]); - if (t.do_date != null) meta.push(["do", fmtRelative(t.do_date)]); - if (t.late_on != null) meta.push(["late", fmtRelative(t.late_on)]); - - return h( - "div", - { class: "detail" }, - meta.length && - h( - "div", - { class: "meta" }, - meta.map(([k, v]) => h("div", { class: "meta-row" }, h("span", { class: "meta-k" }, k), h("span", {}, v))), - ), - h( - "div", - { class: "actions" }, - actionBtn("✓ Done", () => triage(t, "done")), - actionBtn("⤓ Drop", () => triage(t, "dropped")), - t.recurrence && actionBtn("↻ Skip", () => doSkip(t)), - actionBtn("⚑ Attn", () => cycleAttention(t)), - actionBtn("📅 Date", () => openReschedule(t)), - actionBtn("📁 Move", () => openMove(t)), - actionBtn("🗑 Delete", () => doDelete(t), "danger"), - ), - h("pre", { class: "preview", id: `preview-${t.node_id}` }, "…"), - ); -} - -function actionBtn(label, onclick, extra = "") { - return h("button", { class: `act ${extra}`, onclick }, label); -} - -async function loadPreview(t) { - const pre = $(`#preview-${t.node_id}`); - if (!pre) return; - try { - const ctxId = t.canonical_context_id || (await state.client.contextOf(t.node_id)); - const [body, log] = await Promise.all([ - ctxId ? state.client.nodeBody(ctxId) : Promise.resolve(""), - state.client.logTail(t.node_id, 5).catch(() => []), - ]); - const parts = []; - if (body.trim()) parts.push(body.trim()); - if (log && log.length) parts.push("— log —\n" + log.join("\n")); - pre.textContent = parts.join("\n\n") || "(no context yet)"; - } catch (e) { - pre.textContent = `(could not load context: ${e.message})`; - } -} - -function renderMain() { - const main = $("#main"); - main.innerHTML = ""; - - if (state.search) { - main.append(searchPane()); - return; - } - if (state.error) { - main.append(h("div", { class: "notice error" }, state.error)); - } - if (state.loading && state.tasks.length === 0) { - main.append(h("div", { class: "notice" }, "Loading…")); - return; - } - if (!state.loading && state.tasks.length === 0 && !state.error) { - main.append(h("div", { class: "notice" }, "Nothing here. Tap + to capture a task.")); - return; - } - const list = h("div", { class: "list" }, state.tasks.map(taskRow)); - main.append(list); -} - -// --- drawer (views + projects) --------------------------------------------- - -function renderDrawer() { - const body = $("#drawer-body"); - body.innerHTML = ""; - body.append(h("div", { class: "drawer-section" }, "Views")); - for (const v of VIEWS) { - body.append(drawerItem(v.title, state.target.type === "view" && state.target.id === v.id, () => { - state.target = { type: "view", id: v.id, title: v.title }; - closeDrawer(); - reload(); - })); - } - body.append(h("div", { class: "drawer-section" }, "Projects")); - if (state.projects.length === 0) { - body.append(h("div", { class: "drawer-empty" }, "(none yet)")); - } - for (const p of state.projects) { - body.append(drawerItem(p.title, state.target.type === "project" && state.target.id === p.id, () => { - state.target = { type: "project", id: p.id, title: p.title }; - closeDrawer(); - reload(); - }, projectColor(p.id))); - } -} - -function drawerItem(label, active, onclick, dot) { - return h( - "div", - { class: "drawer-item" + (active ? " active" : ""), onclick }, - dot ? h("span", { class: "bullet", style: `color:${dot}` }, "●") : h("span", { class: "bullet" }, " "), - h("span", {}, label), - ); -} - -function openDrawer() { - renderDrawer(); - $("#drawer").classList.add("open"); - $("#backdrop").classList.add("show"); -} -function closeDrawer() { - $("#drawer").classList.remove("open"); - $("#backdrop").classList.remove("show"); -} - -// --- modal scaffolding ------------------------------------------------------ - -function openModal(node) { - const root = $("#modal-root"); - root.innerHTML = ""; - root.append(h("div", { class: "modal-backdrop", onclick: closeModal }, h("div", { class: "modal", onclick: (e) => e.stopPropagation() }, node))); - root.classList.add("show"); -} -function closeModal() { - $("#modal-root").classList.remove("show"); - $("#modal-root").innerHTML = ""; -} -function modalOpen() { - return $("#modal-root").classList.contains("show"); -} - -// --- quick-add (the primary use case) -------------------------------------- - -function openQuickAdd() { - closeDrawer(); - const input = h("input", { - class: "qa-input", - type: "text", - placeholder: "Buy milk tomorrow p2 #Work every week", - autocomplete: "off", - autocapitalize: "sentences", - enterkeyhint: "done", - }); - const preview = h("div", { class: "qa-preview" }); - - const updatePreview = () => { - const parsed = quickParse(input.value, today(), state.projects); - preview.innerHTML = ""; - if (!input.value.trim()) { - preview.append(h("span", { class: "qa-hint" }, "p1–p4 · #Project · today/+3d/fri · every week")); - return; - } - preview.append(h("span", { class: "qa-title" }, parsed.title || "(no title)")); - if (parsed.attention) { - preview.append(h("span", { class: "qa-tag", style: `color:${ATTENTION_COLORS[parsed.attention]}` }, "⚑ " + parsed.attention)); - } - if (parsed.doDate != null) preview.append(h("span", { class: "qa-tag" }, "📅 " + fmtRelative(parsed.doDate))); - if (parsed.projectId) preview.append(h("span", { class: "qa-tag" }, "📁 " + projectTitle(parsed.projectId))); - if (parsed.recurrence) preview.append(h("span", { class: "qa-tag" }, "↻ " + humanizeRecurrence(parsed.recurrence))); - }; - input.addEventListener("input", updatePreview); - input.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - submitQuickAdd(input.value); - } else if (e.key === "Escape") { - closeModal(); - } - }); - - const mic = voiceButton(input, updatePreview); - - const node = h( - "div", - { class: "qa" }, - h("div", { class: "qa-row" }, input, mic), - preview, - h( - "div", - { class: "qa-foot" }, - state.target.type === "project" - ? h("span", { class: "qa-dest" }, "→ " + state.target.title) - : h("span", { class: "qa-dest" }, "→ Inbox (unless #Project given)"), - h("button", { class: "qa-add", onclick: () => submitQuickAdd(input.value) }, "Add"), - ), - ); - openModal(node); - updatePreview(); - setTimeout(() => input.focus(), 50); -} - -async function submitQuickAdd(raw) { - const text = raw.trim(); - if (!text) return; - const parsed = quickParse(text, today(), state.projects); - if (!parsed.title) { - toast("Needs a title."); - return; - } - const projectId = - parsed.projectId || (state.target.type === "project" ? state.target.id : null); - closeModal(); - try { - await state.client.createTask({ - title: parsed.title, - attention: parsed.attention, - doDate: parsed.doDate, - recurrence: parsed.recurrence, - projectId, - }); - toast(`Added: ${parsed.title}`); - reload(); - } catch (e) { - toast(`Add failed: ${e.message}`); - } -} - -// --- voice input ------------------------------------------------------------ - -// Web Speech API where available (desktop Chrome, Android). On iOS Safari the -// API is absent, but the on-screen keyboard's dictation mic works in the text -// field for free — so we simply omit the button there. -function voiceButton(input, onUpdate) { - const SR = window.SpeechRecognition || window.webkitSpeechRecognition; - if (!SR) return null; - let rec = null; - const btn = h("button", { class: "qa-mic", title: "Dictate" }, "🎤"); - btn.addEventListener("click", () => { - if (rec) { - rec.stop(); - return; - } - rec = new SR(); - rec.lang = navigator.language || "en-US"; - rec.interimResults = true; - btn.classList.add("listening"); - let base = input.value ? input.value + " " : ""; - rec.onresult = (ev) => { - let text = ""; - for (const r of ev.results) text += r[0].transcript; - input.value = base + text; - onUpdate(); - }; - rec.onend = () => { - rec = null; - btn.classList.remove("listening"); - input.focus(); - }; - rec.onerror = () => toast("Voice input unavailable."); - rec.start(); - }); - return btn; -} - -// --- reschedule ------------------------------------------------------------- - -function openReschedule(t) { - const input = h("input", { - class: "qa-input", - type: "text", - placeholder: "today · tomorrow · +3d · fri · 2026-07-01 · (blank to clear)", - value: t.do_date != null ? fmtRelative(t.do_date) : "", - autocomplete: "off", - enterkeyhint: "done", - }); - const apply = async () => { - const v = input.value.trim(); - let doDate = null; - if (v) { - try { - doDate = toEpochMs(parseDate(v, today())); - } catch { - toast("Unrecognized date."); - return; - } - } - closeModal(); - try { - await state.client.setSchedule(t.node_id, { doDate }); - toast(doDate ? `Rescheduled: ${fmtRelative(doDate)}` : "Do-date cleared"); - reload(); - } catch (e) { - toast(`Failed: ${e.message}`); - } - }; - input.addEventListener("keydown", (e) => { - if (e.key === "Enter") (e.preventDefault(), apply()); - if (e.key === "Escape") closeModal(); - }); - openModal( - h( - "div", - { class: "qa" }, - h("div", { class: "modal-title" }, "Reschedule"), - input, - h("div", { class: "qa-foot" }, h("button", { class: "qa-add", onclick: apply }, "Set")), - ), - ); - setTimeout(() => input.focus(), 50); -} - -// --- move / project picker -------------------------------------------------- - -function openMove(t) { - const filter = h("input", { class: "qa-input", type: "text", placeholder: "Filter or new project…", autocomplete: "off" }); - const list = h("div", { class: "picker-list" }); - - const renderOptions = () => { - const q = filter.value.trim().toLowerCase(); - list.innerHTML = ""; - list.append(pickerItem("(Unfile)", () => move(t, null))); - for (const p of state.projects) { - if (q && !p.title.toLowerCase().includes(q)) continue; - list.append(pickerItem(p.title, () => move(t, p.id), projectColor(p.id))); - } - const exact = state.projects.some((p) => p.title.toLowerCase() === q); - if (q && !exact) { - list.append(pickerItem(`+ New project "${filter.value.trim()}"`, () => createAndMove(t, filter.value.trim()))); - } - }; - filter.addEventListener("input", renderOptions); - filter.addEventListener("keydown", (e) => e.key === "Escape" && closeModal()); - - openModal(h("div", { class: "qa" }, h("div", { class: "modal-title" }, `Move "${t.title}"`), filter, list)); - renderOptions(); - setTimeout(() => filter.focus(), 50); -} - -function pickerItem(label, onclick, dot) { - return h( - "div", - { class: "picker-item", onclick }, - dot ? h("span", { class: "bullet", style: `color:${dot}` }, "●") : h("span", { class: "bullet" }, " "), - h("span", {}, label), - ); -} - -async function move(t, projectId) { - closeModal(); - try { - await state.client.setProject(t.node_id, projectId); - toast(projectId ? `Moved to ${projectTitle(projectId)}` : "Unfiled"); - reload(); - } catch (e) { - toast(`Failed: ${e.message}`); - } -} - -async function createAndMove(t, name) { - closeModal(); - try { - const id = await state.client.createProject(name); - await refreshProjects(); - await state.client.setProject(t.node_id, id); - toast(`Moved to ${name}`); - reload(); - } catch (e) { - toast(`Failed: ${e.message}`); - } -} - -// --- triage actions --------------------------------------------------------- - -async function triage(t, newState) { - state.expandedId = null; - try { - await state.client.setState(t.node_id, newState); - const verb = newState === "done" ? "Done" : "Dropped"; - toast(`${verb}: ${t.title}`, { - label: "Undo", - run: async () => { - try { - await state.client.setState(t.node_id, "outstanding"); - reload(); - } catch (e) { - toast(`Undo failed: ${e.message}`); - } - }, - }); - reload(); - } catch (e) { - toast(`Failed: ${e.message}`); - } -} - -async function doSkip(t) { - try { - await state.client.skip(t.node_id); - toast(`Skipped: ${t.title}`); - reload(); - } catch (e) { - toast(`Failed: ${e.message}`); - } -} - -async function cycleAttention(t) { - const next = nextAttention(t.attention); - try { - await state.client.setAttention(t.node_id, next); - toast(`Attention: ${next}`); - reload(); - } catch (e) { - toast(`Failed: ${e.message}`); - } -} - -async function doDelete(t) { - if (!confirm(`Delete "${t.title}"? This removes it for good (use Drop to triage instead).`)) { - return; - } - state.expandedId = null; - try { - await state.client.tombstone(t.node_id); - toast(`Deleted: ${t.title}`); - reload(); - } catch (e) { - toast(`Failed: ${e.message}`); - } -} - -// --- search ----------------------------------------------------------------- - -function openSearch() { - state.search = { query: "", results: [] }; - render(); - setTimeout(() => $("#search-input")?.focus(), 50); -} - -function closeSearch() { - state.search = null; - render(); -} - -function searchPane() { - const input = h("input", { - id: "search-input", - class: "search-input", - type: "search", - placeholder: "Search tasks & docs…", - value: state.search.query, - autocomplete: "off", - enterkeyhint: "search", - }); - let timer = null; - const run = async () => { - state.search.query = input.value; - const q = input.value.trim(); - if (!q) { - state.search.results = []; - renderSearchResults(); - return; - } - try { - state.search.results = await state.client.search(q); - } catch (e) { - state.search.results = []; - toast(e.message); - } - renderSearchResults(); - }; - input.addEventListener("input", () => { - clearTimeout(timer); - timer = setTimeout(run, 200); - }); - input.addEventListener("keydown", (e) => e.key === "Escape" && closeSearch()); - - return h( - "div", - { class: "search-pane" }, - h("div", { class: "search-bar" }, input, h("button", { class: "search-close", onclick: closeSearch }, "✕")), - h("div", { class: "search-results", id: "search-results" }), - ); -} - -function renderSearchResults() { - const root = $("#search-results"); - if (!root) return; - root.innerHTML = ""; - if (!state.search.results.length) { - root.append(h("div", { class: "notice" }, state.search.query ? "No matches." : "Type to search.")); - return; - } - for (const hit of state.search.results) { - root.append( - h( - "div", - { class: "search-hit" }, - h("span", { class: "hit-kind" }, `[${hit.kind}]`), - h("span", {}, hit.title), - ), - ); - } -} - -// --- settings --------------------------------------------------------------- - -function openSettings() { - const url = h("input", { class: "qa-input", type: "url", placeholder: "https://hub.example.com:8787", value: state.settings.baseUrl, autocomplete: "off", inputmode: "url" }); - const tok = h("input", { class: "qa-input", type: "password", placeholder: "Bearer token (optional)", value: state.settings.token, autocomplete: "off" }); - const test = h("div", { class: "settings-test" }); - const setTest = (msg, ok) => { - test.textContent = msg; - test.className = "settings-test" + (ok === true ? " ok" : ok === false ? " bad" : ""); - }; - - const save = async () => { - state.settings.baseUrl = url.value.trim(); - state.settings.token = tok.value.trim(); - saveSettings(state.settings); - state.client = makeClient(); - closeModal(); - reload(); - }; - const check = async () => { - setTest("Checking…", null); - const probe = new Client({ baseUrl: url.value.trim(), token: tok.value.trim() }); - try { - const v = await probe.call("version", {}); - setTest(`✓ Connected (hephd ${v.version})`, true); - } catch (e) { - setTest(`✗ ${e.message}`, false); - } - }; - // Login with Authentik: read the hub's /config for the issuer + client id, - // then start the PKCE redirect (this navigates away and returns to init()). - const login = async () => { - const hub = url.value.trim() || state.settings.baseUrl; - if (!hub) return setTest("✗ Set the hub URL first.", false); - setTest("Contacting hub…", null); - const cfg = await oauth.fetchHubConfig(hub); - if (!cfg) { - return setTest("✗ Hub has no OIDC (/config) — paste a token, or enable OIDC on the hub.", false); - } - state.settings.baseUrl = hub; - saveSettings(state.settings); // persist before we navigate away - try { - await oauth.beginLogin(cfg); - } catch (e) { - setTest(`✗ ${e.message}`, false); - } - }; - const logout = () => { - oauth.clearAuth(); - applyToken(""); - closeModal(); - reload(); - }; - - const authRow = oauth.loggedIn() - ? h( - "div", - { class: "settings-auth" }, - h("span", { class: "settings-test ok" }, "✓ Signed in with Authentik"), - h("button", { class: "act", onclick: logout }, "Log out"), - ) - : h("button", { class: "qa-add settings-login", onclick: login }, "Login with Authentik"); - - openModal( - h( - "div", - { class: "qa" }, - h("div", { class: "modal-title" }, "Settings"), - h("label", { class: "settings-label" }, "Hub URL"), - url, - h("label", { class: "settings-label" }, "Sign-in"), - authRow, - h( - "details", - { class: "settings-manual" }, - h("summary", {}, "Or paste a bearer token"), - tok, - ), - test, - h( - "div", - { class: "qa-foot settings-foot" }, - h("button", { class: "act", onclick: check }, "Test"), - h("button", { class: "qa-add", onclick: save }, "Save"), - ), - h("div", { class: "settings-hint" }, "“Login with Authentik” needs OIDC enabled on the hub. Leave sign-in empty for a hub running without OIDC."), - ), - ); -} - -// --- keyboard --------------------------------------------------------------- - -function onKeydown(e) { - const typing = ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName); - // Cmd/Ctrl + ' opens quick-add anywhere (mirrors the global popover). - if ((e.metaKey || e.ctrlKey) && e.key === "'") { - e.preventDefault(); - openQuickAdd(); - return; - } - if (typing || modalOpen()) { - if (e.key === "Escape" && modalOpen()) closeModal(); - return; - } - if (e.key === "a") (e.preventDefault(), openQuickAdd()); - else if (e.key === "/") (e.preventDefault(), openSearch()); - else if (e.key === "r") reload(); - else if (e.key === "Escape") state.search ? closeSearch() : closeDrawer(); -} - -// --- shell + init ----------------------------------------------------------- - -function buildShell() { - const app = $("#app"); - app.append( - h( - "header", - { class: "appbar" }, - h("button", { class: "icon-btn", title: "Menu", onclick: openDrawer }, "☰"), - h("div", { id: "view-title", class: "appbar-title" }, state.target.title), - h("button", { class: "icon-btn", title: "Search", onclick: openSearch }, "🔍"), - h("button", { class: "icon-btn", title: "Settings", onclick: openSettings }, "⚙"), - ), - h("main", { id: "main" }), - h("button", { id: "fab", class: "fab", title: "Quick add (Cmd-’)", onclick: openQuickAdd }, "+"), - h("div", { id: "backdrop", class: "backdrop", onclick: closeDrawer }), - h( - "aside", - { id: "drawer", class: "drawer" }, - h("div", { class: "drawer-head" }, "heph"), - h("div", { id: "drawer-body", class: "drawer-body" }), - ), - h("div", { id: "modal-root", class: "modal-root" }), - h("div", { id: "toast", class: "toast" }), - ); -} - -async function init() { - buildShell(); - document.addEventListener("keydown", onKeydown); - - // The PWA shares the daemon's store with the TUI / desktop popover, but only - // re-fetches on a view switch or an action. So another surface marking a task - // done leaves a stale list on screen until then. Re-fetch the current view - // whenever the app regains focus (switching back to the phone, unlock, tab - // re-show) — but not while a modal or search is mid-interaction. - document.addEventListener("visibilitychange", () => { - if ( - document.visibilityState === "visible" && - state.client.configured && - !modalOpen() && - !state.search - ) { - reload(); - } - }); - - // OIDC: finish a redirect callback (back from Authentik), or refresh an - // existing session, so the first reload() already carries a valid bearer. - if (oauth.isCallback()) { - try { - applyToken(await oauth.completeLogin()); - toast("Signed in."); - } catch (e) { - toast(`Sign-in failed: ${e.message}`); - } - } else if (oauth.loggedIn()) { - const tok = await oauth.ensureFreshToken(); - if (tok) applyToken(tok); - } - - render(); - reload(); - - if ("serviceWorker" in navigator) { - try { - await navigator.serviceWorker.register("./sw.js"); - } catch { - /* offline shell is best-effort */ - } - } -} - -init(); diff --git a/heph-pwa/src/datespec.js b/heph-pwa/src/datespec.js deleted file mode 100644 index afe798a..0000000 --- a/heph-pwa/src/datespec.js +++ /dev/null @@ -1,361 +0,0 @@ -// Human-friendly date and recurrence parsing — a faithful JS port of hephd's -// `datespec.rs` (tech-spec §1, §8, §8.1) so the PWA's quick-add accepts the -// exact same forms as the CLI/TUI and produces identical RRULEs and do-dates. -// -// Dates are date-grained and stored as epoch ms at *local midnight* (matching -// `to_epoch_ms`). All pure functions take an explicit `today` so they stay -// deterministically testable; the thin wrappers read the local clock. - -/** A local-midnight Date for today (time component stripped). */ -export function today() { - const n = new Date(); - return new Date(n.getFullYear(), n.getMonth(), n.getDate()); -} - -/** Local-midnight epoch ms for a Date (the form do_date/late_on are stored in). */ -export function toEpochMs(date) { - return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); -} - -function addDays(date, n) { - return new Date(date.getFullYear(), date.getMonth(), date.getDate() + n); -} -function addMonths(date, n) { - return new Date(date.getFullYear(), date.getMonth() + n, date.getDate()); -} - -// JS getDay(): 0=Sun..6=Sat. -const WEEKDAYS = { - mon: 1, monday: 1, - tue: 2, tues: 2, tuesday: 2, - wed: 3, weds: 3, wednesday: 3, - thu: 4, thur: 4, thurs: 4, thursday: 4, - fri: 5, friday: 5, - sat: 6, saturday: 6, - sun: 0, sunday: 0, -}; -const BYDAY = { 0: "SU", 1: "MO", 2: "TU", 3: "WE", 4: "TH", 5: "FR", 6: "SA" }; - -/** Weekday name (full or common abbreviation) → JS day index, or null. */ -function parseWeekday(s) { - return Object.prototype.hasOwnProperty.call(WEEKDAYS, s) ? WEEKDAYS[s] : null; -} - -/** The soonest date on/after `today` whose weekday is `wd` (JS day index). */ -function soonestWeekday(today, wd) { - let d = today; - for (let i = 0; i < 7; i++) { - if (d.getDay() === wd) return d; - d = addDays(d, 1); - } - return today; -} - -function parseOffset(rest, today) { - rest = rest.trim(); - const m = rest.match(/^(\d+)\s*([a-z]*)$/); - if (!m) throw new Error(`not a relative date offset: +${rest}`); - const n = parseInt(m[1], 10); - switch (m[2]) { - case "": case "d": case "day": case "days": return addDays(today, n); - case "w": case "wk": case "week": case "weeks": return addDays(today, n * 7); - case "m": case "mo": case "month": case "months": return addMonths(today, n); - default: throw new Error(`unknown offset unit "${m[2]}" (use d, w, or m)`); - } -} - -/** - * Parse a human date spec relative to `today` (a local-midnight Date) into a - * local-midnight Date. Accepts: today/now, tomorrow/tom, yesterday; +Nd/+Nw/+Nm - * (bare +N = days); weekday names (soonest on/after today); ISO YYYY-MM-DD. - * Throws on anything unrecognized. - */ -export function parseDate(input, todayDate) { - const s = input.trim().toLowerCase(); - if (s === "") throw new Error("empty date"); - switch (s) { - case "today": case "now": return todayDate; - case "tomorrow": case "tom": return addDays(todayDate, 1); - case "yesterday": return addDays(todayDate, -1); - } - const wd = parseWeekday(s); - if (wd !== null) return soonestWeekday(todayDate, wd); - if (s.startsWith("+")) return parseOffset(s.slice(1), todayDate); - - // ISO YYYY-MM-DD (strict; construct as local midnight). - const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})$/); - if (iso) { - const [, y, mo, d] = iso; - const date = new Date(Number(y), Number(mo) - 1, Number(d)); - if ( - date.getFullYear() === Number(y) && - date.getMonth() === Number(mo) - 1 && - date.getDate() === Number(d) - ) { - return date; - } - } - throw new Error( - `unrecognized date: "${input}" (try today, tomorrow, +3d, fri, or YYYY-MM-DD)`, - ); -} - -/** parseDate to epoch ms, or null if unparseable (convenience for quick-add). */ -export function parseDateMsOrNull(input, todayDate) { - try { - return toEpochMs(parseDate(input, todayDate)); - } catch { - return null; - } -} - -// --------------------------------------------------------------------------- -// Recurrence -// --------------------------------------------------------------------------- - -const MONTHS = { - jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, - jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12, -}; - -function parseMonthDay(s) { - const toks = s.split(/\s+/).filter(Boolean); - if (toks.length !== 2) return null; - const month = (t) => MONTHS[t.slice(0, 3)] ?? null; - const day = (t) => { - const m = t.match(/^(\d+)/); - return m ? parseInt(m[1], 10) : null; - }; - let m = month(toks[0]); - let d = day(toks[1]); - if (m !== null && d !== null) return [m, d]; - d = day(toks[0]); - m = month(toks[1]); - if (m !== null && d !== null) return [m, d]; - return null; -} - -function parseMonthdayOrdinal(s) { - const m = s.match(/^(\d+)(st|nd|rd|th)$/); - if (!m) return null; - const d = parseInt(m[1], 10); - return d >= 1 && d <= 31 ? d : null; -} - -function intervalForm(n, unit) { - const wd = parseWeekday(unit); - if (wd !== null) { - return n === 1 - ? `FREQ=WEEKLY;BYDAY=${BYDAY[wd]}` - : `FREQ=WEEKLY;INTERVAL=${n};BYDAY=${BYDAY[wd]}`; - } - let freq; - switch (unit) { - case "day": case "days": freq = "DAILY"; break; - case "week": case "weeks": freq = "WEEKLY"; break; - case "month": case "months": freq = "MONTHLY"; break; - case "year": case "years": freq = "YEARLY"; break; - default: - throw new Error( - `unrecognized recurrence "${unit}" (try daily/weekly/monthly/yearly, ` + - `'every 3 days', 'every fri', or a raw RRULE)`, - ); - } - return n === 1 ? `FREQ=${freq}` : `FREQ=${freq};INTERVAL=${n}`; -} - -/** - * Parse a recurrence spec into an RFC-5545 RRULE. Accepts a raw RRULE (anything - * containing FREQ=), presets (daily/weekly/monthly/yearly/weekdays), and the - * common natural-language forms (§6.2.1): every N (day|week|month|year)s, every - * , every other , every workday, every , - * every . A trailing "at