From 713b9f3d243b114507a04c9e5a7951c2f9f6838a Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Thu, 9 Jul 2026 02:09:20 +0700 Subject: [PATCH 1/7] fix: view scroll + document namespace/version gate (uteke v0.7.0) (#154) Memories/Dashboard scroll fix + drop document namespace entirely with a uteke 0.7.0 version gate (documents are global since #614). Adds version detection (uteke --version), cached in AppState, gated per-doc-command, with an update-required banner + Update button in DocumentsView. Non-doc features keep working on older uteke. Also syncs package-lock to 0.3.0. All gates green: fmt/clippy(--all-targets)/13 tests/svelte-check/build. --- package-lock.json | 4 +- src-tauri/src/commands.rs | 137 +++++++++++++++++++----- src-tauri/src/lib.rs | 92 ++++++++++++++++ src-tauri/src/uteke_client.rs | 35 ++---- src/App.svelte | 2 +- src/lib/components/Dashboard.svelte | 15 ++- src/lib/components/DocumentsView.svelte | 100 +++++++++++++---- src/lib/components/MemoryList.svelte | 15 ++- src/lib/ts/ipc.ts | 29 ++--- src/lib/ts/types.ts | 11 +- 10 files changed, 350 insertions(+), 90 deletions(-) diff --git a/package-lock.json b/package-lock.json index d209d70..8b2a154 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "corin", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "corin", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.4", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0898954..0fb20d7 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -114,6 +114,10 @@ pub struct AppState { /// HTTP client for uteke-serve (all memory CRUD + graph operations). /// None if server is not running. pub uteke_client: Option, + /// Cached installed uteke version ("X.Y.Z"), probed once at startup via + /// `uteke --version`. `None` if the CLI couldn't be probed. Used to gate + /// features that require a minimum server version (e.g. Documents → 0.7.0). + pub uteke_version: Option, } // --------------------------------------------------------------------------- @@ -2662,16 +2666,113 @@ pub async fn disconnect_connection( } // ── Document Engine (uteke-serve /doc/*) ───────────────────────────── +// +// Since uteke v0.7.0 (#614) documents are global — unique slugs across all +// namespaces. `namespace` is no longer accepted. Every command gates on +// `MIN_UTEKE_FOR_DOCS` ("0.7.0") so older servers never get ambiguous +// global-slug requests. Frontend reads `uteke_version_status` to render an +// upgrade banner; `uteke_self_update` runs `uteke upgrade` and re-detects. + +/// Installed vs required uteke version (for the Documents feature). +#[derive(Debug, Clone, Serialize)] +pub struct VersionStatus { + /// Detected `X.Y.Z`, or `None` if the `uteke` CLI couldn't be probed. + pub current: Option, + /// Minimum uteke version that supports global documents ("0.7.0"). + pub required: String, + /// `true` iff `current >= required` (false when current is unknown). + pub supported: bool, +} + +/// Reject the call unless the cached uteke version meets `min`. +/// +/// Reads `AppState::uteke_version` (set once at startup). Returns a clear, +/// upgrade-instructing message when unsupported or when the version is unknown. +async fn require_uteke_version( + state: &tauri::State<'_, Arc>>, + min: &str, +) -> Result<(), CommandError> { + let current = state.lock().await.uteke_version.clone(); + let ok = current + .as_ref() + .map(|c| crate::version_meets(c, min)) + .unwrap_or(false); + if ok { + Ok(()) + } else { + Err(CommandError::Uteke(format!( + "uteke {min} or newer is required for documents (current: {}). \ + Run 'uteke upgrade' or use the Update button in Documents.", + current.as_deref().unwrap_or("unknown") + ))) + } +} + +/// Report the installed uteke version and whether it supports Documents. +#[tauri::command] +pub async fn uteke_version_status( + state: tauri::State<'_, Arc>>, +) -> Result { + let current = state.lock().await.uteke_version.clone(); + let supported = current + .as_ref() + .map(|c| crate::version_meets(c, crate::MIN_UTEKE_FOR_DOCS)) + .unwrap_or(false); + Ok(VersionStatus { + current, + required: crate::MIN_UTEKE_FOR_DOCS.to_string(), + supported, + }) +} + +/// Run `uteke upgrade`, then re-detect and cache the version. +/// +/// Returns the post-upgrade `VersionStatus`. Errors if the `uteke` CLI is +/// missing or the upgrade exits non-zero. +#[tauri::command] +pub async fn uteke_self_update( + state: tauri::State<'_, Arc>>, +) -> Result { + let uteke = crate::find_uteke_cli().ok_or_else(|| { + CommandError::Uteke("uteke CLI not found — install via the setup script".into()) + })?; + let out = tokio::process::Command::new(&uteke) + .arg("upgrade") + .stdin(std::process::Stdio::null()) + .output() + .await + .map_err(|e| CommandError::Uteke(format!("failed to run 'uteke upgrade': {e}")))?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + return Err(CommandError::Uteke(format!( + "'uteke upgrade' failed: {stderr}" + ))); + } + let new_version = crate::detect_uteke_version(); + { + let mut s = state.lock().await; + s.uteke_version = new_version.clone(); + } + let supported = new_version + .as_ref() + .map(|c| crate::version_meets(c, crate::MIN_UTEKE_FOR_DOCS)) + .unwrap_or(false); + Ok(VersionStatus { + current: new_version, + required: crate::MIN_UTEKE_FOR_DOCS.to_string(), + supported, + }) +} /// List documents via uteke-serve POST /doc/list. #[tauri::command] pub async fn doc_list( state: tauri::State<'_, Arc>>, - namespace: Option, limit: Option, roots_only: Option, parent: Option, ) -> Result, CommandError> { + require_uteke_version(&state, crate::MIN_UTEKE_FOR_DOCS).await?; let client = { let s = state.lock().await; s.uteke_client.clone() @@ -2683,7 +2784,7 @@ pub async fn doc_list( return Err(CommandError::Uteke("uteke-serve not reachable".into())); } let docs = client - .doc_list(namespace.as_deref(), limit, roots_only, parent.as_deref()) + .doc_list(limit, roots_only, parent.as_deref()) .await .map_err(|e| CommandError::Uteke(e.to_string()))?; Ok(docs @@ -2698,8 +2799,8 @@ pub async fn doc_get( state: tauri::State<'_, Arc>>, slug: Option, id: Option, - namespace: Option, ) -> Result { + require_uteke_version(&state, crate::MIN_UTEKE_FOR_DOCS).await?; let client = { let s = state.lock().await; s.uteke_client.clone() @@ -2708,7 +2809,7 @@ pub async fn doc_get( return Err(CommandError::Uteke("uteke-serve not running".into())); }; let doc = client - .doc_get(slug.as_deref(), id.as_deref(), namespace.as_deref()) + .doc_get(slug.as_deref(), id.as_deref()) .await .map_err(|e| CommandError::Uteke(e.to_string()))?; Ok(serde_json::to_value(doc).unwrap_or_default()) @@ -2721,10 +2822,10 @@ pub async fn doc_create( slug: String, title: String, content: String, - namespace: Option, tags: Option>, parent: Option, ) -> Result { + require_uteke_version(&state, crate::MIN_UTEKE_FOR_DOCS).await?; let tags = tags.unwrap_or_default(); let client = { let s = state.lock().await; @@ -2734,14 +2835,7 @@ pub async fn doc_create( return Err(CommandError::Uteke("uteke-serve not running".into())); }; let doc = client - .doc_create( - &slug, - &title, - &content, - namespace.as_deref(), - &tags, - parent.as_deref(), - ) + .doc_create(&slug, &title, &content, &tags, parent.as_deref()) .await .map_err(|e| CommandError::Uteke(e.to_string()))?; Ok(serde_json::to_value(doc).unwrap_or_default()) @@ -2756,8 +2850,8 @@ pub async fn doc_update( title: Option, content: Option, tags: Option>, - namespace: Option, ) -> Result { + require_uteke_version(&state, crate::MIN_UTEKE_FOR_DOCS).await?; let id_or_slug = id .or(slug) .ok_or_else(|| CommandError::Uteke("provide either 'id' or 'slug'".into()))?; @@ -2771,7 +2865,6 @@ pub async fn doc_update( let doc = client .doc_update( &id_or_slug, - namespace.as_deref(), title.as_deref(), content.as_deref(), tags.as_deref(), @@ -2787,10 +2880,10 @@ pub async fn doc_update( pub async fn doc_search( state: tauri::State<'_, Arc>>, query: String, - namespace: Option, limit: Option, mode: Option, ) -> Result, CommandError> { + require_uteke_version(&state, crate::MIN_UTEKE_FOR_DOCS).await?; let client = { let s = state.lock().await; s.uteke_client.clone() @@ -2799,7 +2892,7 @@ pub async fn doc_search( return Err(CommandError::Uteke("uteke-serve not running".into())); }; let results = client - .doc_search(&query, namespace.as_deref(), limit, mode.as_deref()) + .doc_search(&query, limit, mode.as_deref()) .await .map_err(|e| CommandError::Uteke(e.to_string()))?; Ok(results @@ -2815,6 +2908,7 @@ pub async fn doc_delete( id: Option, slug: Option, ) -> Result<(), CommandError> { + require_uteke_version(&state, crate::MIN_UTEKE_FOR_DOCS).await?; let client = { let s = state.lock().await; s.uteke_client.clone() @@ -2835,8 +2929,8 @@ pub async fn doc_move( id: Option, slug: Option, new_parent: Option, - namespace: Option, ) -> Result { + require_uteke_version(&state, crate::MIN_UTEKE_FOR_DOCS).await?; let client = { let s = state.lock().await; s.uteke_client.clone() @@ -2845,12 +2939,7 @@ pub async fn doc_move( return Err(CommandError::Uteke("uteke-serve not running".into())); }; let result = client - .doc_move( - id.as_deref(), - slug.as_deref(), - new_parent.as_deref(), - namespace.as_deref(), - ) + .doc_move(id.as_deref(), slug.as_deref(), new_parent.as_deref()) .await .map_err(|e| CommandError::Uteke(e.to_string()))?; Ok(result) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 19c28ae..c7933a3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -195,6 +195,65 @@ fn find_uteke_serve() -> Option { None } +/// Minimum uteke version required for the global Documents feature (v0.7.0, #614). +pub(crate) const MIN_UTEKE_FOR_DOCS: &str = "0.7.0"; + +/// Find the `uteke` CLI binary in PATH or ~/.local/bin. +pub(crate) fn find_uteke_cli() -> Option { + if let Some(p) = find_in_path("uteke") { + return Some(p); + } + if let Some(home) = dirs::home_dir() { + let candidate = home.join(".local/bin/uteke"); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +/// Detect the installed uteke CLI version by shelling `uteke --version`. +/// +/// Returns the parsed `X.Y.Z` string (e.g. `"0.7.0"`), or `None` if the +/// binary is missing or the output can't be parsed. The HTTP server exposes +/// no version endpoint, so the CLI is the only source of truth. +pub(crate) fn detect_uteke_version() -> Option { + let uteke = find_uteke_cli()?; + let out = std::process::Command::new(uteke) + .arg("--version") + .stdin(std::process::Stdio::null()) + .output() + .ok()?; + if !out.status.success() { + return None; + } + // Output looks like "uteke 0.7.0". Take the last whitespace-separated token + // that contains a '.' so stray suffixes don't leak in. + String::from_utf8_lossy(&out.stdout) + .split_whitespace() + .last() + .filter(|s| s.contains('.')) + .map(|s| s.to_string()) +} + +/// Compare two `X.Y.Z` version strings. Returns `true` if `current >= min`. +/// +/// If either side fails to parse, returns `false` — conservative, so an +/// unknown/unparseable version prompts an upgrade rather than passing. +pub(crate) fn version_meets(current: &str, min: &str) -> bool { + fn parse(v: &str) -> Option<(u32, u32, u32)> { + let mut parts = v.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); + Some((major, minor, patch)) + } + match (parse(current), parse(min)) { + (Some(c), Some(m)) => c >= m, + _ => false, + } +} + /// Run the official uteke install script. /// /// Downloads `install.sh` from GitHub and pipes it to `sh`. @@ -369,6 +428,8 @@ pub fn run() { commands::reconnect_connection, commands::disconnect_connection, // Document Engine (#137) + commands::uteke_version_status, + commands::uteke_self_update, commands::doc_list, commands::doc_get, commands::doc_create, @@ -447,6 +508,10 @@ pub fn run() { let client = UtekeClient::with_auth(&server_url, auth_token); s.uteke_client = Some(client); } + + // Cache the installed uteke version for feature + // gating (e.g. Documents requires >= 0.7.0). + s.uteke_version = detect_uteke_version(); } Err(e) => { eprintln!("Failed to open database: {e}"); @@ -524,3 +589,30 @@ fn init_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> { )?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_meets_equal_or_higher() { + assert!(version_meets("0.7.0", "0.7.0")); + assert!(version_meets("0.7.1", "0.7.0")); + assert!(version_meets("0.8.0", "0.7.0")); + assert!(version_meets("1.0.0", "0.7.0")); + } + + #[test] + fn version_meets_lower_rejected() { + assert!(!version_meets("0.6.7", "0.7.0")); + assert!(!version_meets("0.6.0", "0.7.0")); + } + + #[test] + fn version_meets_unparseable_is_conservative() { + // A fully unparseable current must NOT pass the gate. + // (Pre-release suffixes like "0.7.0-rc" parse to 0.7.0 and are accepted — + // acceptable, since uteke ships clean semver tags in practice.) + assert!(!version_meets("unknown", "0.7.0")); + } +} diff --git a/src-tauri/src/uteke_client.rs b/src-tauri/src/uteke_client.rs index 6d6ca0e..b2137f2 100644 --- a/src-tauri/src/uteke_client.rs +++ b/src-tauri/src/uteke_client.rs @@ -928,6 +928,11 @@ impl UtekeClient { } // ── Documents (uteke-serve /doc/*) ───────────────────────────────── + // + // Since uteke v0.7.0 (#614) documents are GLOBAL — unique slugs across all + // namespaces. These methods send no `namespace`. Callers MUST gate access + // on `crate::MIN_UTEKE_FOR_DOCS` ("0.7.0") so older servers (where docs + // were namespace-scoped) never receive ambiguous global-slug requests. /// List documents via POST /doc/list. /// @@ -935,15 +940,11 @@ impl UtekeClient { /// `parent` returns direct children of a specific document. pub async fn doc_list( &self, - namespace: Option<&str>, limit: Option, roots_only: Option, parent: Option<&str>, ) -> Result, String> { let mut body = serde_json::json!({}); - if let Some(ns) = namespace { - body["namespace"] = serde_json::Value::String(ns.to_string()); - } if let Some(limit) = limit { body["limit"] = serde_json::json!(limit); } @@ -966,12 +967,7 @@ impl UtekeClient { /// Get a single document via POST /doc/get. /// /// Accepts either `slug` or `id`. Returns full document with content. - pub async fn doc_get( - &self, - slug: Option<&str>, - id: Option<&str>, - namespace: Option<&str>, - ) -> Result { + pub async fn doc_get(&self, slug: Option<&str>, id: Option<&str>) -> Result { let mut body = serde_json::json!({}); if let Some(s) = slug { body["slug"] = serde_json::Value::String(s.to_string()); @@ -979,9 +975,6 @@ impl UtekeClient { if let Some(i) = id { body["id"] = serde_json::Value::String(i.to_string()); } - if let Some(ns) = namespace { - body["namespace"] = serde_json::Value::String(ns.to_string()); - } let resp = self .authed(self.client.post(format!("{}/doc/get", self.base_url))) @@ -998,7 +991,6 @@ impl UtekeClient { slug: &str, title: &str, content: &str, - namespace: Option<&str>, tags: &[String], parent: Option<&str>, ) -> Result { @@ -1008,9 +1000,6 @@ impl UtekeClient { "content": content, "tags": tags, }); - if let Some(ns) = namespace { - body["namespace"] = serde_json::Value::String(ns.to_string()); - } if let Some(pid) = parent { body["parent"] = serde_json::Value::String(pid.to_string()); } @@ -1028,7 +1017,6 @@ impl UtekeClient { pub async fn doc_update( &self, id_or_slug: &str, - namespace: Option<&str>, title: Option<&str>, content: Option<&str>, tags: Option<&[String]>, @@ -1045,9 +1033,6 @@ impl UtekeClient { } else { body["slug"] = serde_json::Value::String(id_or_slug.to_string()); } - if let Some(ns) = namespace { - body["namespace"] = serde_json::Value::String(ns.to_string()); - } if let Some(t) = title { body["title"] = serde_json::Value::String(t.to_string()); } @@ -1074,16 +1059,12 @@ impl UtekeClient { pub async fn doc_search( &self, query: &str, - namespace: Option<&str>, limit: Option, mode: Option<&str>, ) -> Result, String> { let mut body = serde_json::json!({ "query": query, }); - if let Some(ns) = namespace { - body["namespace"] = serde_json::Value::String(ns.to_string()); - } if let Some(limit) = limit { body["limit"] = serde_json::json!(limit); } @@ -1127,7 +1108,6 @@ impl UtekeClient { id: Option<&str>, slug: Option<&str>, new_parent: Option<&str>, - namespace: Option<&str>, ) -> Result { let mut body = serde_json::json!({}); if let Some(i) = id { @@ -1139,9 +1119,6 @@ impl UtekeClient { if let Some(np) = new_parent { body["new_parent"] = serde_json::Value::String(np.to_string()); } - if let Some(ns) = namespace { - body["namespace"] = serde_json::Value::String(ns.to_string()); - } let resp = self .authed(self.client.post(format!("{}/doc/move", self.base_url))) diff --git a/src/App.svelte b/src/App.svelte index e30251d..0fe1d53 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -164,7 +164,7 @@ {:else if activeView === 'rooms'} {:else if activeView === 'documents'} - + {/if} {/key} diff --git a/src/lib/components/Dashboard.svelte b/src/lib/components/Dashboard.svelte index 49a71ff..ac6428c 100644 --- a/src/lib/components/Dashboard.svelte +++ b/src/lib/components/Dashboard.svelte @@ -82,7 +82,8 @@ - {#if loading} +
+ {#if loading}
Loading...
{:else} @@ -145,15 +146,27 @@ {/if}
{/if} +