diff --git a/deps/cli/src/logseq/cli/common/mcp/tools.cljs b/deps/cli/src/logseq/cli/common/mcp/tools.cljs index 540aaa649f..a7df864c7b 100644 --- a/deps/cli/src/logseq/cli/common/mcp/tools.cljs +++ b/deps/cli/src/logseq/cli/common/mcp/tools.cljs @@ -16,60 +16,71 @@ [malli.core :as m] [malli.error :as me])) +(defn- ensure-db-graph + [db] + (when-not (ldb/db-based-graph? db) + (throw (ex-info "This tool must be called on a DB graph" {})))) + +(defn- minimal-list-item + [e] + (cond-> {:db/id (:db/id e) + :block/title (:block/title e) + :block/created-at (:block/created-at e) + :block/updated-at (:block/updated-at e)} + (:db/ident e) (assoc :db/ident (:db/ident e)))) + (defn list-properties "Main fn for ListProperties tool" [db {:keys [expand include-built-in] :as options}] (ensure-db-graph db) (let [include-built-in? (if (contains? options :include-built-in) include-built-in true)] - (->> (d/datoms db :avet :block/tags :logseq.class/Property) - (map #(d/entity db (:e %))) - (remove (fn [e] - (and (not include-built-in?) - (ldb/built-in? e)))) - #_((fn [x] (prn :prop-keys (distinct (mapcat keys x))) x)) - (map (fn [e] - (if expand - (cond-> (into {} e) - true - (dissoc e :block/tags :block/order :block/refs :block/name :db/index - :logseq.property.embedding/hnsw-label-updated-at :logseq.property/default-value) - true - (update :block/uuid str) - (:logseq.property/classes e) - (update :logseq.property/classes #(mapv :db/ident %)) - (:logseq.property/description e) - (update :logseq.property/description db-property/property-value-content)) - {:block/title (:block/title e) - :block/uuid (str (:block/uuid e))})))))) + (->> (d/datoms db :avet :block/tags :logseq.class/Property) + (map #(d/entity db (:e %))) + (remove (fn [e] + (and (not include-built-in?) + (ldb/built-in? e)))) + #_((fn [x] (prn :prop-keys (distinct (mapcat keys x))) x)) + (map (fn [e] + (if expand + (cond-> (into {} e) + true + (dissoc e :block/tags :block/order :block/refs :block/name :db/index + :logseq.property.embedding/hnsw-label-updated-at :logseq.property/default-value) + true + (update :block/uuid str) + (:logseq.property/classes e) + (update :logseq.property/classes #(mapv :db/ident %)) + (:logseq.property/description e) + (update :logseq.property/description db-property/property-value-content)) + (minimal-list-item e))))))) (defn list-tags "Main fn for ListTags tool" [db {:keys [expand include-built-in] :as options}] (ensure-db-graph db) (let [include-built-in? (if (contains? options :include-built-in) include-built-in true)] - (->> (d/datoms db :avet :block/tags :logseq.class/Tag) - (map #(d/entity db (:e %))) - (remove (fn [e] - (and (not include-built-in?) - (ldb/built-in? e)))) - (map (fn [e] - (if expand - (cond-> (into {} e) - true - (dissoc e :block/tags :block/order :block/refs :block/name - :logseq.property.embedding/hnsw-label-updated-at) - true - (update :block/uuid str) - (:logseq.property.class/extends e) - (update :logseq.property.class/extends #(mapv :db/ident %)) - (:logseq.property.class/properties e) - (update :logseq.property.class/properties #(mapv :db/ident %)) - (:logseq.property.view/type e) - (assoc :logseq.property.view/type (:db/ident (:logseq.property.view/type e))) - (:logseq.property/description e) - (update :logseq.property/description db-property/property-value-content)) - {:block/title (:block/title e) - :block/uuid (str (:block/uuid e))})))))) + (->> (d/datoms db :avet :block/tags :logseq.class/Tag) + (map #(d/entity db (:e %))) + (remove (fn [e] + (and (not include-built-in?) + (ldb/built-in? e)))) + (map (fn [e] + (if expand + (cond-> (into {} e) + true + (dissoc e :block/tags :block/order :block/refs :block/name + :logseq.property.embedding/hnsw-label-updated-at) + true + (update :block/uuid str) + (:logseq.property.class/extends e) + (update :logseq.property.class/extends #(mapv :db/ident %)) + (:logseq.property.class/properties e) + (update :logseq.property.class/properties #(mapv :db/ident %)) + (:logseq.property.view/type e) + (assoc :logseq.property.view/type (:db/ident (:logseq.property.view/type e))) + (:logseq.property/description e) + (update :logseq.property/description db-property/property-value-content)) + (minimal-list-item e))))))) (defn- get-page-blocks [db page-id] @@ -118,32 +129,31 @@ journal-only? (boolean journal-only) created-after-ms (parse-time created-after) updated-after-ms (parse-time updated-after)] - (->> (d/datoms db :avet :block/name) - (map #(d/entity db (:e %))) - (remove (fn [e] - (and (not include-hidden?) - (entity-util/hidden? e)))) - (remove (fn [e] - (let [is-journal? (ldb/journal? e)] - (cond - journal-only? (not is-journal?) - (false? include-journal?) is-journal? - :else false)))) - (remove (fn [e] - (and created-after-ms - (<= (:block/created-at e 0) created-after-ms)))) - (remove (fn [e] - (and updated-after-ms - (<= (:block/updated-at e 0) updated-after-ms)))) - (map (fn [e] - (if expand - (-> e - ;; Until there are options to limit pages, return minimal info to avoid - ;; exceeding max payload size - (select-keys [:block/uuid :block/title :block/created-at :block/updated-at]) - (update :block/uuid str)) - {:block/title (:block/title e) - :block/uuid (str (:block/uuid e))})))))) + (->> (d/datoms db :avet :block/name) + (map #(d/entity db (:e %))) + (remove (fn [e] + (and (not include-hidden?) + (entity-util/hidden? e)))) + (remove (fn [e] + (let [is-journal? (ldb/journal? e)] + (cond + journal-only? (not is-journal?) + (false? include-journal?) is-journal? + :else false)))) + (remove (fn [e] + (and created-after-ms + (<= (:block/created-at e 0) created-after-ms)))) + (remove (fn [e] + (and updated-after-ms + (<= (:block/updated-at e 0) updated-after-ms)))) + (map (fn [e] + (if expand + (-> e + ;; Until there are options to limit pages, return minimal info to avoid + ;; exceeding max payload size + (select-keys [:db/id :db/ident :block/uuid :block/title :block/created-at :block/updated-at]) + (update :block/uuid str)) + (minimal-list-item e))))))) ;; upsert-nodes tool ;; ================= diff --git a/docs/agent-guide/005-logseq-cli-output-and-db-worker-node-log.md b/docs/agent-guide/005-logseq-cli-output-and-db-worker-node-log.md index 06ad319da9..f2527db35e 100644 --- a/docs/agent-guide/005-logseq-cli-output-and-db-worker-node-log.md +++ b/docs/agent-guide/005-logseq-cli-output-and-db-worker-node-log.md @@ -9,6 +9,29 @@ Tech Stack: ClojureScript, babashka/cli, lambdaisland.glogi, Node.js fs/path. Related: Builds on docs/agent-guide/004-logseq-cli-verb-subcommands.md and docs/agent-guide/003-db-worker-node-cli-orchestration.md. +## Human Output Specification + +Target: plain text, no ANSI colors. Each command has a stable layout and ordering. + +| Command | OK output (human) | Empty output | Notes | +| --- | --- | --- | --- | +| graph list | Table with header `GRAPH` and rows of graph names, followed by `Count: N` | Header + `Count: 0` | Data from `{:graphs [...]}` | +| graph create | `Graph created: ` | n/a | Use graph name from action/options | +| graph switch | `Graph switched: ` | n/a | Use graph name from action/options | +| graph remove | `Graph removed: ` | n/a | Use graph name from action/options | +| graph validate | `Graph validated: ` | n/a | Use graph name from action/options | +| graph info | Lines: `Graph: `, `Created at: `, `Schema version: ` | n/a | Use `:logseq.kv/*` data; show `-` if missing | +| server list | Table with header `REPO STATUS HOST PORT PID`, rows for servers, followed by `Count: N` | Header + `Count: 0` | Data from `{:servers [...]}` | +| server status/start/stop/restart | `Server : ` + details line `Host: Port: ` when available | n/a | Use `:status` keyword where present | +| list page/tag/property | Table with header (fields vary by command) and rows, followed by `Count: N` | Header + `Count: 0` | Defaults: page/tag/property `ID TITLE UPDATED-AT CREATED-AT` (ID uses `:db/id`); if `:db/ident` present, include `IDENT` column | +| add block | `Added blocks: (repo: )` | n/a | Count = number of blocks submitted | +| add page | `Added page: (repo: )` | n/a | | +| remove block | `Removed block: (repo: )` | n/a | Prefer UUID if available | +| remove page | `Removed page: (repo: )` | n/a | | +| search | Table with header `TYPE TITLE/CONTENT UUID UPDATED-AT CREATED-AT`, rows in stable order, followed by `Count: N` | Header + `Count: 0` | For block rows use content snippet; for tag/property rows omit timestamps | +| show (text) | Raw tree text (no table), trimmed | n/a | For `--format json|edn`, keep existing structured output | +| errors | `Error (): ` + optional `Hint: ` line | n/a | Ensure error codes are stable and consistent | + ## Problem statement The current logseq-cli human output is mostly raw pr-str output, which is hard to read and inconsistent across commands. diff --git a/docs/cli/logseq-cli.md b/docs/cli/logseq-cli.md index 2d09e810f9..4f09c628b4 100644 --- a/docs/cli/logseq-cli.md +++ b/docs/cli/logseq-cli.md @@ -83,6 +83,7 @@ Subcommands: Output formats: - Global `--output ` (also accepted per subcommand) +- Human output is plain text. List/search commands render tables with a final `Count: N` line. For list subcommands, the ID column uses `:db/id` (not UUID). If `:db/ident` exists, an `IDENT` column is included. Errors include error codes and may include a `Hint:` line. Use `--output json|edn` for structured output. Examples: diff --git a/src/main/frontend/worker/db_worker_node.cljs b/src/main/frontend/worker/db_worker_node.cljs index afbbc792ea..7ef7b14b10 100644 --- a/src/main/frontend/worker/db_worker_node.cljs +++ b/src/main/frontend/worker/db_worker_node.cljs @@ -1,6 +1,8 @@ (ns frontend.worker.db-worker-node "Node.js daemon entrypoint for db-worker." - (:require ["http" :as http] + (:require ["fs" :as fs] + ["http" :as http] + ["path" :as node-path] [clojure.string :as string] [frontend.worker.db-core :as db-core] [frontend.worker.db-worker-node-lock :as db-lock] @@ -8,13 +10,13 @@ [frontend.worker.state :as worker-state] [goog.object :as gobj] [lambdaisland.glogi :as log] - [lambdaisland.glogi.console :as glogi-console] [logseq.db :as ldb] [promesa.core :as p])) (defonce ^:private *ready? (atom false)) (defonce ^:private *sse-clients (atom #{})) (defonce ^:private *lock-info (atom nil)) +(defonce ^:private *file-handler (atom nil)) (defn- send-json! [^js res status payload] @@ -222,15 +224,82 @@ (println " --repo (required)") (println " --rtc-ws-url (optional)") (println " --log-level (default info)") + (println " logs: //db-worker-node-YYYYMMDD.log (retains 7)") (println " --auth-token (optional)")) +(defn- pad2 + [value] + (if (< value 10) + (str "0" value) + (str value))) + +(defn- yyyymmdd + [^js date] + (str (.getFullYear date) + (pad2 (inc (.getMonth date))) + (pad2 (.getDate date)))) + +(defn- log-path + [data-dir repo] + (let [data-dir (db-lock/resolve-data-dir data-dir) + repo-dir (db-lock/repo-dir data-dir repo) + date-str (yyyymmdd (js/Date.))] + (node-path/join repo-dir (str "db-worker-node-" date-str ".log")))) + +(defn- log-files + [repo-dir] + (->> (when (fs/existsSync repo-dir) + (fs/readdirSync repo-dir)) + (filter (fn [^js name] + (re-matches #"db-worker-node-\d{8}\.log" name))) + (sort))) + +(defn- enforce-log-retention! + [repo-dir] + (let [files (log-files repo-dir) + excess (max 0 (- (count files) 7))] + (doseq [name (take excess files)] + (fs/unlinkSync (node-path/join repo-dir name))))) + +(defn- format-log-line + [{:keys [time level message logger-name exception]}] + (let [ts (.toISOString (js/Date. time)) + base (str ts + " [" + (name level) + "] [" + logger-name + "] " + (pr-str message))] + (str base (when exception (str " " (pr-str exception))) "\n"))) + +(defn- install-file-logger! + [{:keys [data-dir repo log-level]}] + (let [data-dir (db-lock/resolve-data-dir data-dir) + repo-dir (db-lock/repo-dir data-dir repo) + file-path (log-path data-dir repo)] + (fs/mkdirSync repo-dir #js {:recursive true}) + (fs/writeFileSync file-path "" #js {:flag "a"}) + (enforce-log-retention! repo-dir) + (when-let [handler @*file-handler] + (log/remove-handler handler)) + (let [handler (fn [record] + (fs/appendFileSync file-path (format-log-line record)))] + (reset! *file-handler handler) + (log/add-handler handler)) + (log/set-levels {:glogi/root log-level}) + file-path)) + (defn start-daemon! - [{:keys [data-dir repo rtc-ws-url auth-token]}] + [{:keys [data-dir repo rtc-ws-url auth-token log-level]}] (let [host "127.0.0.1" port 0] (if-not (seq repo) (p/rejected (ex-info "repo is required" {:code :missing-repo})) (do + (install-file-logger! {:data-dir data-dir + :repo repo + :log-level (keyword (or log-level "info"))}) (reset! *ready? false) (set-main-thread-stub!) (-> (p/let [platform (platform-node/node-platform {:data-dir data-dir @@ -288,22 +357,20 @@ (defn main [] - (let [{:keys [data-dir repo rtc-ws-url log-level auth-token help?]} - (parse-args (.-argv js/process)) - log-level (keyword (or log-level "info"))] + (let [{:keys [data-dir repo rtc-ws-url auth-token help?] :as opts} + (parse-args (.-argv js/process))] (when help? (show-help!) (.exit js/process 0)) (when-not (seq repo) (show-help!) (.exit js/process 1)) - (glogi-console/install!) - (log/set-levels {:glogi/root log-level}) (p/let [{:keys [stop!] :as daemon} (start-daemon! {:data-dir data-dir :repo repo :rtc-ws-url rtc-ws-url - :auth-token auth-token})] + :auth-token auth-token + :log-level (:log-level opts)})] (log/info :db-worker-node-ready {:host (:host daemon) :port (:port daemon)}) (let [shutdown (fn [] (-> (stop!) diff --git a/src/main/logseq/cli/commands.cljs b/src/main/logseq/cli/commands.cljs index 4026b2c088..9232c87b1c 100644 --- a/src/main/logseq/cli/commands.cljs +++ b/src/main/logseq/cli/commands.cljs @@ -778,17 +778,20 @@ (case command :graph-list {:ok? true - :action {:type :graph-list}} + :action {:type :graph-list + :command :graph-list}} :graph-create (if-not (seq graph) (missing-graph-error) {:ok? true :action {:type :invoke + :command :graph-create :method "thread-api/create-or-open-db" :direct-pass? false :args [repo {}] :repo repo + :graph (repo->graph repo) :allow-missing-graph true :persist-repo (repo->graph repo)}}) @@ -797,6 +800,7 @@ (missing-graph-error) {:ok? true :action {:type :graph-switch + :command :graph-switch :repo repo :graph (repo->graph repo)}}) @@ -805,26 +809,31 @@ (missing-graph-error) {:ok? true :action {:type :invoke + :command :graph-remove :method "thread-api/unsafe-unlink-db" :direct-pass? false :args [repo] - :repo repo}}) + :repo repo + :graph (repo->graph repo)}}) :graph-validate (if-not (seq repo) (missing-graph-error) {:ok? true :action {:type :invoke + :command :graph-validate :method "thread-api/validate-db" :direct-pass? false :args [repo] - :repo repo}}) + :repo repo + :graph (repo->graph repo)}}) :graph-info (if-not (seq repo) (missing-graph-error) {:ok? true :action {:type :graph-info + :command :graph-info :repo repo :graph (repo->graph repo)}}))) @@ -1369,29 +1378,32 @@ (defn execute [action config] - (-> (p/let [check (ensure-existing-graph action config)] - (if-not (:ok? check) - {:status :error - :error (:error check)} - (case (:type action) - :graph-list (execute-graph-list action config) - :invoke (execute-invoke action config) - :graph-switch (execute-graph-switch action config) - :graph-info (execute-graph-info action config) - :list-page (execute-list-page action config) - :list-tag (execute-list-tag action config) - :list-property (execute-list-property action config) - :add-block (execute-add-block action config) - :add-page (execute-add-page action config) - :remove-block (execute-remove action config) - :remove-page (execute-remove action config) - :search (execute-search action config) - :show (execute-show action config) - :server-list (execute-server-list action config) - :server-status (execute-server-status action config) - :server-start (execute-server-start action config) - :server-stop (execute-server-stop action config) - :server-restart (execute-server-restart action config) - {:status :error - :error {:code :unknown-action - :message "unknown action"}}))))) + (-> (p/let [check (ensure-existing-graph action config) + result (if-not (:ok? check) + {:status :error + :error (:error check)} + (case (:type action) + :graph-list (execute-graph-list action config) + :invoke (execute-invoke action config) + :graph-switch (execute-graph-switch action config) + :graph-info (execute-graph-info action config) + :list-page (execute-list-page action config) + :list-tag (execute-list-tag action config) + :list-property (execute-list-property action config) + :add-block (execute-add-block action config) + :add-page (execute-add-page action config) + :remove-block (execute-remove action config) + :remove-page (execute-remove action config) + :search (execute-search action config) + :show (execute-show action config) + :server-list (execute-server-list action config) + :server-status (execute-server-status action config) + :server-start (execute-server-start action config) + :server-stop (execute-server-stop action config) + :server-restart (execute-server-restart action config) + {:status :error + :error {:code :unknown-action + :message "unknown action"}}))] + (assoc result + :command (or (:command action) (:type action)) + :context (select-keys action [:repo :graph :page :block :blocks]))))) diff --git a/src/main/logseq/cli/format.cljs b/src/main/logseq/cli/format.cljs index 5cbb5fe625..d6fac1bc2a 100644 --- a/src/main/logseq/cli/format.cljs +++ b/src/main/logseq/cli/format.cljs @@ -1,6 +1,7 @@ (ns logseq.cli.format "Formatting helpers for CLI output." - (:require [clojure.walk :as walk])) + (:require [clojure.string :as string] + [clojure.walk :as walk])) (defn- normalize-json [value] @@ -22,18 +23,239 @@ (set! (.-error obj) (clj->js (normalize-json (update error :code name))))) (js/JSON.stringify obj))) +(defn- pad-right + [value width] + (let [text (str value) + missing (- width (count text))] + (if (pos? missing) + (str text (apply str (repeat missing " "))) + text))) + +(defn- normalize-cell + [value] + (cond + (nil? value) "-" + (keyword? value) (str value) + :else (str value))) + +(defn- render-table + [headers rows] + (let [normalized-rows (mapv (fn [row] + (mapv normalize-cell row)) + rows) + trim-right (fn [value] + (string/replace value #"\s+$" "")) + widths (mapv (fn [idx header] + (apply max (count header) + (map #(count (nth % idx)) normalized-rows))) + (range (count headers)) + headers) + render-row (fn [row] + (->> (map pad-right row widths) + (string/join " ") + (trim-right))) + lines (cons (render-row headers) + (map render-row normalized-rows))] + (string/join "\n" lines))) + +(defn- format-counted-table + [headers rows] + (str (render-table headers rows) + "\n" + "Count: " + (count rows))) + +(defn- error-hint + [{:keys [code]}] + (case code + :missing-graph "Use --graph " + :missing-repo "Use --repo " + :missing-content "Use --content or pass content as args" + :missing-search-text "Provide search text or --text" + nil)) + +(defn- format-error + [error] + (let [{:keys [code message]} error + hint (error-hint error)] + (cond-> (str "Error (" (name (or code :error)) "): " message) + hint (str "\nHint: " hint)))) + +(defn- maybe-ident-header + [items] + (when (some :db/ident items) + ["IDENT"])) + +(defn- parse-ts + [value] + (cond + (number? value) value + (string? value) (let [ms (js/Date.parse value)] + (when-not (js/isNaN ms) ms)) + :else nil)) + +(defn- human-ago + [value now-ms] + (if-let [ts (parse-ts value)] + (let [diff-ms (max 0 (- now-ms ts)) + secs (js/Math.floor (/ diff-ms 1000)) + mins (js/Math.floor (/ secs 60)) + hours (js/Math.floor (/ mins 60)) + days (js/Math.floor (/ hours 24)) + months (js/Math.floor (/ days 30)) + years (js/Math.floor (/ days 365))] + (cond + (< secs 60) (str secs "s ago") + (< mins 60) (str mins "m ago") + (< hours 24) (str hours "h ago") + (< days 30) (str days "d ago") + (< months 12) (str months "mo ago") + :else (str years "y ago"))) + "-")) + +(defn- format-list-row + [item include-ident? now-ms] + (let [base [(or (:db/id item) (:id item)) + (or (:title item) (:block/title item) (:name item))] + with-ident (cond-> base + include-ident? (conj (:db/ident item))) + updated (human-ago (or (:updated-at item) (:block/updated-at item)) now-ms) + created (human-ago (or (:created-at item) (:block/created-at item)) now-ms)] + (conj with-ident updated created))) + +(defn- format-list-page + [items now-ms] + (let [items (or items []) + include-ident? (boolean (some :db/ident items)) + headers (into ["ID" "TITLE"] + (concat (or (maybe-ident-header items) []) + ["UPDATED-AT" "CREATED-AT"]))] + (format-counted-table + headers + (mapv #(format-list-row % include-ident? now-ms) items)))) + +(defn- format-list-tag-or-property + [items now-ms] + (let [items (or items []) + include-ident? (boolean (some :db/ident items)) + headers (into ["ID" "TITLE"] + (concat (or (maybe-ident-header items) []) + ["UPDATED-AT" "CREATED-AT"]))] + (format-counted-table + headers + (mapv #(format-list-row % include-ident? now-ms) items)))) + +(defn- format-graph-list + [graphs] + (format-counted-table + ["GRAPH"] + (mapv (fn [graph] [graph]) (or graphs [])))) + +(defn- format-server-list + [servers] + (format-counted-table + ["REPO" "STATUS" "HOST" "PORT" "PID"] + (mapv (fn [server] + [(:repo server) + (:status server) + (:host server) + (:port server) + (:pid server)]) + (or servers [])))) + +(defn- format-search-results + [results] + (format-counted-table + ["TYPE" "TITLE/CONTENT" "UUID" "UPDATED-AT" "CREATED-AT"] + (mapv (fn [item] + [(:type item) + (or (:title item) (:content item)) + (:uuid item) + (:updated-at item) + (:created-at item)]) + (or results [])))) + +(defn- format-graph-info + [{:keys [graph logseq.kv/graph-created-at logseq.kv/schema-version]}] + (string/join "\n" + [(str "Graph: " (or graph "-")) + (str "Created at: " (or graph-created-at "-")) + (str "Schema version: " (or schema-version "-"))])) + +(defn- format-server-status + [{:keys [repo status host port]}] + (string/join "\n" + (cond-> [(str "Server " (name (or status :unknown)) ": " repo)] + (and host port) (conj (str "Host: " host " Port: " port))))) + +(defn- format-server-action + [command {:keys [repo status host port]}] + (let [status (or status + (case command + :server-start :started + :server-stop :stopped + :server-restart :restarted + :unknown))] + (string/join "\n" + (cond-> [(str "Server " (name status) ": " repo)] + (and host port) (conj (str "Host: " host " Port: " port)))))) + +(defn- format-add-block + [{:keys [repo blocks]}] + (str "Added blocks: " (count blocks) " (repo: " repo ")")) + +(defn- format-add-page + [{:keys [repo page]}] + (str "Added page: " page " (repo: " repo ")")) + +(defn- format-remove-page + [{:keys [repo page]}] + (str "Removed page: " page " (repo: " repo ")")) + +(defn- format-remove-block + [{:keys [repo block]}] + (str "Removed block: " block " (repo: " repo ")")) + +(defn- format-graph-action + [command {:keys [graph]}] + (let [verb (case command + :graph-create "created" + :graph-switch "switched" + :graph-remove "removed" + :graph-validate "validated" + "updated")] + (str "Graph " verb ": " graph))) + (defn- ->human - [{:keys [status data error]}] - (case status - :ok - (if (and (map? data) (contains? data :message)) - (:message data) - (pr-str data)) + [{:keys [status data error command context]} {:keys [now-ms]}] + (let [now-ms (or now-ms (js/Date.now))] + (case status + :ok + (case command + :graph-list (format-graph-list (:graphs data)) + :graph-info (format-graph-info data) + (:graph-create :graph-switch :graph-remove :graph-validate) + (format-graph-action command context) + :server-list (format-server-list (:servers data)) + :server-status (format-server-status data) + (:server-start :server-stop :server-restart) + (format-server-action command data) + :list-page (format-list-page (:items data) now-ms) + (:list-tag :list-property) (format-list-tag-or-property (:items data) now-ms) + :add-block (format-add-block context) + :add-page (format-add-page context) + :remove-page (format-remove-page context) + :remove-block (format-remove-block context) + :search (format-search-results (:results data)) + :show (or (:message data) (pr-str data)) + (if (and (map? data) (contains? data :message)) + (:message data) + (pr-str data))) - :error - (str "error: " (:message error)) + :error + (format-error error) - (pr-str {:status status :data data :error error}))) + (pr-str {:status status :data data :error error})))) (defn- ->edn [{:keys [status data error]}] @@ -42,7 +264,7 @@ (= status :error) (assoc :error error)))) (defn format-result - [result {:keys [output-format]}] + [result {:keys [output-format now-ms] :as opts}] (let [format (cond (= output-format :edn) :edn (= output-format :json) :json @@ -50,4 +272,4 @@ (case format :json (->json result) :edn (->edn result) - (->human result)))) + (->human result opts)))) diff --git a/src/main/logseq/cli/main.cljs b/src/main/logseq/cli/main.cljs index ff5ef19464..8d72d1bca5 100644 --- a/src/main/logseq/cli/main.cljs +++ b/src/main/logseq/cli/main.cljs @@ -29,7 +29,8 @@ (not (:ok? parsed)) (p/resolved {:exit-code 1 :output (format/format-result {:status :error - :error (:error parsed)} + :error (:error parsed) + :command (:command parsed)} {})}) :else @@ -38,7 +39,10 @@ (if-not (:ok? action-result) (p/resolved {:exit-code 1 :output (format/format-result {:status :error - :error (:error action-result)} + :error (:error action-result) + :command (:command parsed) + :context (select-keys (:options parsed) + [:repo :graph :page :block])} cfg)}) (-> (commands/execute (:action action-result) cfg) (p/then (fn [result] diff --git a/src/main/logseq/cli/server.cljs b/src/main/logseq/cli/server.cljs index 9063677371..b28a3123f4 100644 --- a/src/main/logseq/cli/server.cljs +++ b/src/main/logseq/cli/server.cljs @@ -6,17 +6,10 @@ ["os" :as os] ["path" :as node-path] [clojure.string :as string] - [frontend.worker.db-worker-node :as db-worker-node] [frontend.worker-common.util :as worker-util] [lambdaisland.glogi :as log] [promesa.core :as p])) -(defonce ^:private *inproc-servers (atom {})) - -(defn- inproc-enabled? - [] - (boolean (.-DEBUG js/goog))) - (defn- expand-home [path] (if (string/starts-with? path "~") @@ -172,20 +165,13 @@ (defn- spawn-server! [{:keys [repo data-dir]}] - (let [script (node-path/join (js/process.cwd) "static" "db-worker-node.js") + (let [script (node-path/join js/__dirname "db-worker-node.js") args #js [script "--repo" repo "--data-dir" data-dir] child (.spawn child-process "node" args #js {:detached true :stdio "ignore"})] (.unref child) child)) -(defn- start-inproc-server! - [{:keys [repo data-dir]}] - (p/let [daemon (db-worker-node/start-daemon! {:data-dir data-dir - :repo repo})] - (swap! *inproc-servers assoc repo daemon) - daemon)) - (defn- ensure-server-started! [config repo] (let [data-dir (resolve-data-dir config) @@ -193,9 +179,7 @@ (p/let [existing (read-lock path) _ (cleanup-stale-lock! path existing) _ (when (not (fs/existsSync path)) - (if (inproc-enabled?) - (start-inproc-server! {:repo repo :data-dir data-dir}) - (spawn-server! {:repo repo :data-dir data-dir})) + (spawn-server! {:repo repo :data-dir data-dir}) (wait-for-lock path)) lock (read-lock path)] (when-not lock @@ -232,7 +216,6 @@ (p/resolved (not (fs/existsSync path)))) {:timeout-ms 5000 :interval-ms 200}) - (swap! *inproc-servers dissoc repo) {:ok? true :data {:repo repo}}) (p/catch (fn [_] @@ -248,10 +231,8 @@ {:ok? false :error {:code :server-stop-timeout :message "timed out stopping server"}} - (do - (swap! *inproc-servers dissoc repo) - {:ok? true - :data {:repo repo}})))))))) + {:ok? true + :data {:repo repo}}))))))) (defn start-server! [config repo] diff --git a/src/test/frontend/worker/db_worker_node_test.cljs b/src/test/frontend/worker/db_worker_node_test.cljs index 0edbbbd77f..9ebc3870fc 100644 --- a/src/test/frontend/worker/db_worker_node_test.cljs +++ b/src/test/frontend/worker/db_worker_node_test.cljs @@ -79,6 +79,67 @@ repo-dir (node-path/join data-dir (str "." pool-name))] (node-path/join repo-dir "db-worker.lock"))) +(defn- pad2 + [value] + (if (< value 10) + (str "0" value) + (str value))) + +(defn- yyyymmdd + [^js date] + (str (.getFullYear date) + (pad2 (inc (.getMonth date))) + (pad2 (.getDate date)))) + +(defn- log-path + [data-dir repo] + (let [pool-name (worker-util/get-pool-name repo) + repo-dir (node-path/join data-dir (str "." pool-name)) + date-str (yyyymmdd (js/Date.))] + (node-path/join repo-dir (str "db-worker-node-" date-str ".log")))) + +(deftest db-worker-node-creates-log-file + (async done + (let [daemon (atom nil) + data-dir (node-helper/create-tmp-dir "db-worker-log") + repo (str "logseq_db_log_" (subs (str (random-uuid)) 0 8)) + log-file (log-path data-dir repo)] + (-> (p/let [{:keys [stop!]} + (db-worker-node/start-daemon! {:data-dir data-dir + :repo repo}) + _ (reset! daemon {:stop! stop!}) + _ (p/delay 50)] + (is (fs/existsSync log-file))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally (fn [] + (if-let [stop! (:stop! @daemon)] + (-> (stop!) (p/finally (fn [] (done)))) + (done)))))))) + +(deftest db-worker-node-log-file-has-entries + (async done + (let [daemon (atom nil) + data-dir (node-helper/create-tmp-dir "db-worker-log-entries") + repo (str "logseq_db_log_entries_" (subs (str (random-uuid)) 0 8)) + log-file (log-path data-dir repo)] + (-> (p/let [{:keys [host port stop!]} + (db-worker-node/start-daemon! {:data-dir data-dir + :repo repo}) + _ (reset! daemon {:stop! stop!}) + _ (invoke host port "thread-api/create-or-open-db" [repo {}]) + _ (p/delay 50) + contents (when (fs/existsSync log-file) + (.toString (fs/readFileSync log-file) "utf8"))] + (is (fs/existsSync log-file)) + (is (pos? (count contents)))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally (fn [] + (if-let [stop! (:stop! @daemon)] + (-> (stop!) (p/finally (fn [] (done)))) + (done)))))))) + (deftest db-worker-node-parse-args-ignores-host-and-port (let [parse-args #'db-worker-node/parse-args result (parse-args #js ["node" "db-worker-node.js" diff --git a/src/test/logseq/cli/format_test.cljs b/src/test/logseq/cli/format_test.cljs index 32e099764d..069d84f5bb 100644 --- a/src/test/logseq/cli/format_test.cljs +++ b/src/test/logseq/cli/format_test.cljs @@ -39,4 +39,133 @@ (testing "human error (default)" (let [result (format/format-result {:status :error :error {:code :boom :message "nope"}} {:output-format nil})] - (is (= "error: nope" result))))) + (is (= "Error (boom): nope" result))))) + +(deftest test-human-output-list-page + (testing "list page renders a table with count" + (let [result (format/format-result {:status :ok + :command :list-page + :data {:items [{:db/id 1 + :title "Alpha" + :updated-at 90000 + :created-at 40000}]}} + {:output-format nil + :now-ms 100000})] + (is (= (str "ID TITLE UPDATED-AT CREATED-AT\n" + "1 Alpha 10s ago 1m ago\n" + "Count: 1") + result))))) + +(deftest test-human-output-list-tag-property + (testing "list tag uses ID column from :db/id" + (let [result (format/format-result {:status :ok + :command :list-tag + :data {:items [{:block/title "Tag" + :db/id 42 + :block/created-at 40000 + :block/updated-at 90000 + :db/ident :logseq.class/Tag}]}} + {:output-format nil + :now-ms 100000})] + (is (= (str "ID TITLE IDENT UPDATED-AT CREATED-AT\n" + "42 Tag :logseq.class/Tag 10s ago 1m ago\n" + "Count: 1") + result)))) + + (testing "list property uses ID column from :db/id" + (let [result (format/format-result {:status :ok + :command :list-property + :data {:items [{:block/title "Prop" + :db/id 99 + :block/created-at 40000 + :block/updated-at 90000}]}} + {:output-format nil + :now-ms 100000})] + (is (= (str "ID TITLE UPDATED-AT CREATED-AT\n" + "99 Prop 10s ago 1m ago\n" + "Count: 1") + result))))) + +(deftest test-human-output-add-remove + (testing "add block renders a succinct success line" + (let [result (format/format-result {:status :ok + :command :add-block + :context {:repo "demo-repo" + :blocks ["a" "b"]} + :data {:result {:ok true}}} + {:output-format nil})] + (is (= "Added blocks: 2 (repo: demo-repo)" result)))) + + (testing "remove page renders a succinct success line" + (let [result (format/format-result {:status :ok + :command :remove-page + :context {:repo "demo-repo" + :page "Home"} + :data {:result {:ok true}}} + {:output-format nil})] + (is (= "Removed page: Home (repo: demo-repo)" result))))) + +(deftest test-human-output-graph-info + (testing "graph info includes key metadata lines" + (let [result (format/format-result {:status :ok + :command :graph-info + :data {:graph "demo-graph" + :logseq.kv/graph-created-at 123 + :logseq.kv/schema-version 2}} + {:output-format nil})] + (is (= (str "Graph: demo-graph\n" + "Created at: 123\n" + "Schema version: 2") + result))))) + +(deftest test-human-output-server-status + (testing "server status includes repo, status, host, port" + (let [result (format/format-result {:status :ok + :command :server-status + :data {:repo "demo-repo" + :status :ready + :host "127.0.0.1" + :port 1234}} + {:output-format nil})] + (is (= (str "Server ready: demo-repo\n" + "Host: 127.0.0.1 Port: 1234") + result))))) + +(deftest test-human-output-search-and-show + (testing "search renders a table with count" + (let [result (format/format-result {:status :ok + :command :search + :data {:results [{:type "page" + :title "Alpha" + :uuid "u1" + :updated-at 3 + :created-at 1} + {:type "block" + :content "Note" + :uuid "u2" + :updated-at 4 + :created-at 2}]}} + {:output-format nil})] + (is (= (str "TYPE TITLE/CONTENT UUID UPDATED-AT CREATED-AT\n" + "page Alpha u1 3 1\n" + "block Note u2 4 2\n" + "Count: 2") + result)))) + + (testing "show renders text payloads directly" + (let [result (format/format-result {:status :ok + :command :show + :data {:message "Line 1\nLine 2"}} + {:output-format nil})] + (is (= "Line 1\nLine 2" result))))) + +(deftest test-human-output-error-formatting + (testing "errors include code and hint when available" + (let [result (format/format-result {:status :error + :command :graph-create + :error {:code :missing-graph + :message "graph name is required"}} + {:output-format nil})] + (is (= (str "Error (missing-graph): graph name is required\n" + "Hint: Use --graph ") + result))))) diff --git a/src/test/logseq/cli/integration_test.cljs b/src/test/logseq/cli/integration_test.cljs index 683c667e3b..4f51bf2ae2 100644 --- a/src/test/logseq/cli/integration_test.cljs +++ b/src/test/logseq/cli/integration_test.cljs @@ -128,3 +128,48 @@ (p/catch (fn [e] (is false (str "unexpected error: " e)) (done))))))) + +(deftest test-cli-list-outputs-include-id + (async done + (let [data-dir (node-helper/create-tmp-dir "db-worker")] + (-> (p/let [cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn") + _ (fs/writeFileSync cfg-path "{:output-format :json}") + _ (run-cli ["graph" "create" "--repo" "list-id-graph"] data-dir cfg-path) + _ (run-cli ["add" "page" "--page" "TestPage"] data-dir cfg-path) + list-page-result (run-cli ["list" "page"] data-dir cfg-path) + list-page-payload (parse-json-output list-page-result) + list-tag-result (run-cli ["list" "tag"] data-dir cfg-path) + list-tag-payload (parse-json-output list-tag-result) + list-property-result (run-cli ["list" "property"] data-dir cfg-path) + list-property-payload (parse-json-output list-property-result) + stop-result (run-cli ["server" "stop" "--repo" "list-id-graph"] data-dir cfg-path) + stop-payload (parse-json-output stop-result)] + (is (= "ok" (:status list-page-payload))) + (is (every? #(contains? % :id) (get-in list-page-payload [:data :items]))) + (is (= "ok" (:status list-tag-payload))) + (is (every? #(contains? % :id) (get-in list-tag-payload [:data :items]))) + (is (= "ok" (:status list-property-payload))) + (is (every? #(contains? % :id) (get-in list-property-payload [:data :items]))) + (is (= "ok" (:status stop-payload))) + (done)) + (p/catch (fn [e] + (is false (str "unexpected error: " e)) + (done))))))) + +(deftest test-cli-list-page-human-output + (async done + (let [data-dir (node-helper/create-tmp-dir "db-worker")] + (-> (p/let [cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn") + _ (fs/writeFileSync cfg-path "{:output-format :json}") + _ (run-cli ["graph" "create" "--repo" "human-list-graph"] data-dir cfg-path) + _ (run-cli ["add" "page" "--page" "TestPage"] data-dir cfg-path) + list-page-result (run-cli ["list" "page" "--output" "human"] data-dir cfg-path) + output (:output list-page-result)] + (is (= 0 (:exit-code list-page-result))) + (is (string/includes? output "TITLE")) + (is (string/includes? output "TestPage")) + (is (string/includes? output "Count:")) + (done)) + (p/catch (fn [e] + (is false (str "unexpected error: " e)) + (done))))))) diff --git a/src/test/logseq/cli/server_test.cljs b/src/test/logseq/cli/server_test.cljs index 2d91b2ae89..a995cfea6c 100644 --- a/src/test/logseq/cli/server_test.cljs +++ b/src/test/logseq/cli/server_test.cljs @@ -11,7 +11,8 @@ (deftest spawn-server-omits-host-and-port-flags (let [spawn-server! #'cli-server/spawn-server! captured (atom nil) - original-spawn (.-spawn child-process)] + original-spawn (.-spawn child-process) + original-cwd (.cwd js/process)] (set! (.-spawn child-process) (fn [cmd args opts] (reset! captured {:cmd cmd @@ -19,15 +20,20 @@ :opts (js->clj opts :keywordize-keys true)}) (js-obj "unref" (fn [] nil)))) (try + (.chdir js/process "/") (spawn-server! {:repo "logseq_db_spawn_test" :data-dir "/tmp/logseq-db-worker"}) (is (= "node" (:cmd @captured))) + (is (= (node-path/join js/__dirname "db-worker-node.js") + (first (:args @captured)))) (is (some #{"--repo"} (:args @captured))) (is (some #{"--data-dir"} (:args @captured))) (is (not-any? #{"--host" "--port"} (:args @captured))) (finally + (.chdir js/process original-cwd) (set! (.-spawn child-process) original-spawn))))) + (deftest ensure-server-repairs-stale-lock (async done (let [data-dir (node-helper/create-tmp-dir "cli-server")