From 8c3bbbb24e36bcddd2b579b2954f5df5a2335e90 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Wed, 14 Jan 2026 22:21:10 +0800 Subject: [PATCH] add :logseq-cli build see also docs/cli/logseq-cli.md --- .gitignore | 1 + README.md | 4 + docs/agent-guide/001-logseq-cli.md | 152 ++++++ docs/cli/logseq-cli.md | 65 +++ shadow-cljs.edn | 10 + src/main/logseq/cli/commands.cljs | 592 ++++++++++++++++++++++ src/main/logseq/cli/config.cljs | 83 +++ src/main/logseq/cli/format.cljs | 54 ++ src/main/logseq/cli/main.cljs | 65 +++ src/main/logseq/cli/transport.cljs | 142 ++++++ src/test/logseq/cli/commands_test.cljs | 108 ++++ src/test/logseq/cli/config_test.cljs | 71 +++ src/test/logseq/cli/format_test.cljs | 25 + src/test/logseq/cli/integration_test.cljs | 106 ++++ src/test/logseq/cli/transport_test.cljs | 64 +++ 15 files changed, 1542 insertions(+) create mode 100644 docs/agent-guide/001-logseq-cli.md create mode 100644 docs/cli/logseq-cli.md create mode 100644 src/main/logseq/cli/commands.cljs create mode 100644 src/main/logseq/cli/config.cljs create mode 100644 src/main/logseq/cli/format.cljs create mode 100644 src/main/logseq/cli/main.cljs create mode 100644 src/main/logseq/cli/transport.cljs create mode 100644 src/test/logseq/cli/commands_test.cljs create mode 100644 src/test/logseq/cli/config_test.cljs create mode 100644 src/test/logseq/cli/format_test.cljs create mode 100644 src/test/logseq/cli/integration_test.cljs create mode 100644 src/test/logseq/cli/transport_test.cljs diff --git a/.gitignore b/.gitignore index ebc7ee6326..46c2eb49e8 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ node_modules/ static/** tmp cljs-test-runner-out +.tmp/ .cpcache/ /src/gen diff --git a/README.md b/README.md index 72f4244426..b675ddea94 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,10 @@ If you want to set up a development environment for the Logseq web or desktop ap In addition to these guides, you can also find other helpful resources in the [docs/](docs/) folder, such as the [Guide for Contributing to Translations](docs/contributing-to-translations.md), the [Docker Web App Guide](docs/docker-web-app-guide.md) and the [mobile development guide](docs/develop-logseq-on-mobile.md) +### 🧰 Logseq CLI (Node) + +Logseq CLI documentation is maintained in `docs/cli/logseq-cli.md`. + ## ✨ Inspiration Logseq is inspired by several unique tools and projects, including [Roam Research](https://roamresearch.com/), [Org Mode](https://orgmode.org/), [TiddlyWiki](https://tiddlywiki.com/), [Workflowy](https://workflowy.com/), and [Cuekeeper](https://github.com/talex5/cuekeeper). diff --git a/docs/agent-guide/001-logseq-cli.md b/docs/agent-guide/001-logseq-cli.md new file mode 100644 index 0000000000..69ed9847ea --- /dev/null +++ b/docs/agent-guide/001-logseq-cli.md @@ -0,0 +1,152 @@ +# Logseq CLI Implementation Plan + +Goal: Build a new Logseq CLI in ClojureScript that runs on Node.js and connects to the db-worker-node server. + +Architecture: The CLI is a Node-targeted ClojureScript program built via shadow-cljs and packaged with a small JavaScript launcher. +The CLI speaks a simple request and response protocol to the existing db-worker-node HTTP or WebSocket API and exposes high-level subcommands for users. + +Tech Stack: ClojureScript, shadow-cljs :node-script target, Node.js runtime, existing db-worker-node server. + +Related: Relates to docs/agent-guide/task--basic-logseq-cli.md and docs/agent-guide/task--db-worker-nodejs-compatible.md. + +## Problem statement + +We need a new Logseq CLI that is independent of any existing CLI code in the repo. +The CLI must run in Node.js, be written in ClojureScript, and connect to the db-worker-node server started from static/db-worker-node.js. +The CLI should provide a stable interface for scripting and troubleshooting, and it should be easy to extend with new commands. + +## Testing Plan + +I will add an integration test that starts db-worker-node on a test port and verifies the CLI can connect and run a simple request like ping or status. +I will add unit tests for command parsing, configuration precedence, and error formatting. +I will add unit tests for the client transport layer to ensure timeouts and retries behave correctly. +I will add unit tests for new graph/content commands (parsing, validation, and request mapping). +I will add integration tests for graph lifecycle commands and content commands against a real db-worker-node. +I will follow @test-driven-development for all behavior changes. +NOTE: I will write *all* tests before I add any implementation behavior. + +## Architecture sketch + +The CLI is a Node program that parses flags, loads config, and sends requests to db-worker-node. +The db-worker-node server is already built from the :db-worker-node shadow-cljs target and listens on a TCP port. + +ASCII diagram: + ++--------------+ HTTP or WS +---------------------+ +| logseq-cli | -----------------------> | db-worker-node | +| node script | <----------------------- | server on port 9101 | ++--------------+ +---------------------+ + +## Assumptions + +The db-worker-node server exposes a stable API for a small set of requests needed by the CLI. +The CLI will default to localhost:9101 unless configured otherwise. +The CLI will use JSON for request and response bodies for ease of scripting. + +## Implementation plan + +1. Use TodoWrite to track the full task list and include the @test-driven-development red-green-refactor steps. +2. Read @test-driven-development guidelines and confirm the red phase will include all CLI tests first. +3. Identify existing db-worker-node request handlers and document their request and response shapes. +4. Define the initial CLI command surface as a table that includes command, input, output, and errors. +5. Decide on transport protocol based on db-worker-node capabilities and document the selection. +6. Add a new shadow-cljs build target named :logseq-cli with :target :node-script and a dedicated output file in static/. +7. Create a new namespace for the CLI entrypoint in src/main/cli/main.cljs and wire it as the :main for the build. +8. Create src/main/cli/config.cljs with config resolution order of CLI flags, env vars, then config file. +9. Create src/main/cli/transport.cljs with a small client that can send requests and parse responses. +10. Create src/main/cli/commands.cljs with pure functions that map parsed args to transport requests. +11. Create src/main/cli/format.cljs that formats success and error output for human and machine usage. +12. Add unit tests in src/test/logseq/cli for config precedence, command parsing, and error formatting behavior. +13. Add integration tests in src/test/logseq/cli that start db-worker-node and invoke the CLI entrypoint. +14. Run tests in red phase with bb dev:test -v and confirm failures are behavior-related. +15. Implement the minimal code to make the tests pass and re-run in green phase. +16. Refactor for naming and reuse while keeping tests green. +17. Document how to build and run the CLI in a short section in README.md. + +## Command surface definition + +| Command | Input | Output | Errors | +| --- | --- | --- | --- | +| ping | none | ok message | server unavailable, timeout | +| status | none | server version, db state | server unavailable, timeout | +| query | query string or file | query result JSON | invalid query, parse error | +| export | target path and format | export result | unsupported format, write error | +| graph-list | none | list of graphs | server unavailable, timeout | +| graph-create | graph name | created graph + set current graph | invalid name, server unavailable | +| graph-switch | graph name | switched graph + set current graph | missing graph, server unavailable | +| graph-remove | graph name | removal confirmation | missing graph, server unavailable | +| graph-validate | graph name or current graph | validation result | missing graph, server unavailable | +| graph-info | graph name or current graph | graph metadata/info | missing graph, server unavailable | +| add | block/page payload | created block IDs | invalid input, server unavailable | +| remove | block/page id or name | removal confirmation | invalid input, server unavailable | +| search | query string | matched blocks/pages | invalid input, server unavailable | +| tree | block/page id or name | hierarchical tree output | invalid input, server unavailable | + +## Edge cases + +The db-worker-node server is not running or is listening on a different port. +The response payload is invalid JSON or missing fields. +The request times out or the server closes the connection early. +The user passes incompatible flags or unknown commands. +The CLI is run on Windows where path and quoting rules differ. +Graph commands are invoked without a current graph configured. +Content commands are invoked without specifying a graph and no current graph is set. +Content commands refer to missing pages/blocks. +Graph removal is attempted while a graph is open. + +## Testing commands and expected output + +Run a single unit test in red phase. + +```bash +bb dev:test -v logseq.cli.config-test/test-config-precedence +``` + +Expected output includes a failing assertion and ends with a non-zero exit code. + +Run the full unit test suite in green phase. + +```bash +bb dev:test -v logseq.cli.* +``` + +Expected output includes 0 failures and 0 errors. + +Run lint and unit tests when all work is complete. + +```bash +bb dev:lint-and-test +``` + +Expected output includes successful linting and tests with exit code 0. + +## Testing Details + +I will add behavior-driven tests that verify the CLI connects to a real db-worker-node process and that each command returns the expected output for valid input. +I will keep unit tests focused on pure functions like parsing, formatting, and config resolution, and avoid mocking internal implementation details. + +## Implementation Details + +- Add a new shadow-cljs build target for the CLI with a node-script output in static/. +- Create a dedicated CLI entrypoint namespace that handles args, logging, and exit codes. +- Implement config resolution for flags, env vars, and optional config file. +- Implement a transport client with timeouts and explicit error mapping. +- Define a small command map with functions that return request objects and output renderers. +- Add structured JSON output mode for scripting alongside human-readable output. +- Ensure the CLI exits with non-zero status codes on errors. +- Document build and run steps, including starting db-worker-node first. +- Add graph management commands that map to db-worker thread-apis. +- Add graph content commands (add/remove/search/tree) with clear input formats and output. +- Persist/resolve a “current graph” for commands that default to current context. + +## Question + +Which exact db-worker-node endpoints and request schemas should the CLI use for ping, status, query, and export. +- Answer: all thread-apis are available in http endpoint, check @src/main/frontend/worker/db_worker_node.cljs + +Do we want WebSocket or HTTP as the default transport for the CLI. +- HTTP + +Can I consult the clojure-expert and research-agent agents for architecture and reference implementations as required by the planning guidelines. +- yes +--- diff --git a/docs/cli/logseq-cli.md b/docs/cli/logseq-cli.md new file mode 100644 index 0000000000..240f9644fb --- /dev/null +++ b/docs/cli/logseq-cli.md @@ -0,0 +1,65 @@ +# Logseq CLI (Node) + +The Logseq CLI is a Node.js program compiled from ClojureScript that connects to the db-worker-node server. + +## Build the CLI + +```bash +clojure -M:cljs compile logseq-cli +``` + +## Start db-worker-node (in another terminal) + +```bash +clojure -M:cljs compile db-worker-node +node ./static/db-worker-node.js +``` + +## Run the CLI + +```bash +node ./static/logseq-cli.js ping --base-url http://127.0.0.1:9101 +``` + +## Configuration + +Optional configuration file: `~/.logseq/cli.edn` + +Supported keys include: +- `:base-url` +- `:auth-token` +- `:repo` +- `:timeout-ms` +- `:retries` +- `:output-format` (use `:json` or `:edn` for scripting) + +CLI flags take precedence over environment variables, which take precedence over the config file. + +## Commands + +Graph commands: +- `graph-list` - list all db graphs +- `graph-create --graph ` - create a new db graph and switch to it +- `graph-switch --graph ` - switch current graph +- `graph-remove --graph ` - remove a graph +- `graph-validate --graph ` - validate graph data +- `graph-info [--graph ]` - show graph metadata (defaults to current graph) + +Graph content commands: +- `add --content [--page ] [--parent ]` - add blocks; defaults to today’s journal page if no page is given +- `add --blocks [--page ] [--parent ]` - insert blocks via EDN vector +- `add --blocks-file [--page ] [--parent ]` - insert blocks from an EDN file +- `remove --block ` - remove a block and its children +- `remove --page ` - remove a page and its children +- `search --text [--limit ]` - search block titles (Datalog includes?) +- `tree --page [--format text|json|edn]` - show page tree +- `tree --block [--format text|json|edn]` - show block tree + +Examples: + +```bash +node ./static/logseq-cli.js graph-create --graph demo --base-url http://127.0.0.1:9101 +node ./static/logseq-cli.js add --page TestPage --content "hello world" +node ./static/logseq-cli.js search --text "hello" +node ./static/logseq-cli.js tree --page TestPage --format json +``` diff --git a/shadow-cljs.edn b/shadow-cljs.edn index bbdd9679f1..7c8e4de380 100644 --- a/shadow-cljs.edn +++ b/shadow-cljs.edn @@ -95,6 +95,16 @@ :warnings {:fn-deprecated false :redef false}}} + :logseq-cli {:target :node-script + :output-to "static/logseq-cli.js" + :main logseq.cli.main/main + :compiler-options {:infer-externs :auto + :source-map true + :externs ["datascript/externs.js" + "externs.js"] + :warnings {:fn-deprecated false + :redef false}}} + :inference-worker {:target :browser :module-loader true :js-options {:js-provider :external diff --git a/src/main/logseq/cli/commands.cljs b/src/main/logseq/cli/commands.cljs new file mode 100644 index 0000000000..ac7e545a78 --- /dev/null +++ b/src/main/logseq/cli/commands.cljs @@ -0,0 +1,592 @@ +(ns logseq.cli.commands + (:require ["fs" :as fs] + [cljs-time.coerce :as tc] + [cljs.reader :as reader] + [clojure.string :as string] + [clojure.tools.cli :as cli] + [logseq.cli.config :as cli-config] + [logseq.cli.transport :as transport] + [logseq.common.config :as common-config] + [logseq.common.util :as common-util] + [logseq.common.util.date-time :as date-time-util] + [promesa.core :as p])) + +(def ^:private command->keyword + {"ping" :ping + "status" :status + "query" :query + "export" :export + "graph-list" :graph-list + "graph-create" :graph-create + "graph-switch" :graph-switch + "graph-remove" :graph-remove + "graph-validate" :graph-validate + "graph-info" :graph-info + "add" :add + "remove" :remove + "search" :search + "tree" :tree}) + +(def ^:private cli-options + [["-h" "--help" "Show help"] + [nil "--config PATH" "Path to cli.edn" + :id :config-path] + [nil "--base-url URL" "Base URL for db-worker-node"] + [nil "--host HOST" "Host for db-worker-node"] + [nil "--port PORT" "Port for db-worker-node" + :parse-fn #(js/parseInt % 10)] + [nil "--auth-token TOKEN" "Auth token for db-worker-node"] + [nil "--repo REPO" "Graph name"] + [nil "--graph GRAPH" "Graph name (alias for --repo in graph commands)"] + [nil "--timeout-ms MS" "Request timeout in ms" + :parse-fn #(js/parseInt % 10)] + [nil "--retries N" "Retry count for requests" + :parse-fn #(js/parseInt % 10)] + [nil "--json" "Output JSON" + :id :json? + :default false] + [nil "--format FORMAT" "Output format (tree/export)"] + [nil "--limit N" "Limit results" + :parse-fn #(js/parseInt % 10)] + [nil "--page PAGE" "Page name"] + [nil "--block UUID" "Block UUID"] + [nil "--parent UUID" "Parent block UUID for add"] + [nil "--content TEXT" "Block content for add"] + [nil "--blocks EDN" "EDN vector of blocks for add"] + [nil "--blocks-file PATH" "EDN file of blocks for add"] + [nil "--text TEXT" "Search text"] + [nil "--query QUERY" "EDN query input"] + [nil "--file PATH" "Path to EDN query file"] + [nil "--out PATH" "Output path"]]) + +(defn parse-args + [args] + (let [{:keys [options arguments errors summary]} (cli/parse-opts args cli-options) + command-str (first arguments) + command-args (vec (rest arguments)) + command (get command->keyword command-str)] + (cond + (seq errors) + {:ok? false + :error {:code :invalid-options + :message (string/join "\n" errors)} + :summary summary} + + (:help options) + {:ok? false + :help? true + :summary summary} + + (nil? command-str) + {:ok? false + :error {:code :missing-command + :message "missing command"} + :summary summary} + + (nil? command) + {:ok? false + :error {:code :unknown-command + :message (str "unknown command: " command-str)} + :summary summary} + + :else + {:ok? true + :command command + :options options + :args command-args + :summary summary}))) + +(defn- graph->repo + [graph] + (when (seq graph) + (if (string/starts-with? graph common-config/db-version-prefix) + graph + (str common-config/db-version-prefix graph)))) + +(defn- repo->graph + [repo] + (when (seq repo) + (string/replace-first repo common-config/db-version-prefix ""))) + +(defn- pick-graph + [options command-args config] + (or (:graph options) + (:repo options) + (first command-args) + (:repo config))) + +(defn- read-query + [{:keys [query file]}] + (cond + (seq query) + {:ok? true :value (reader/read-string query)} + + (seq file) + (let [contents (.toString (fs/readFileSync file) "utf8")] + {:ok? true :value (reader/read-string contents)}) + + :else + {:ok? false + :error {:code :missing-query + :message "query is required"}})) + +(defn- read-blocks + [options command-args] + (cond + (seq (:blocks options)) + {:ok? true :value (reader/read-string (:blocks options))} + + (seq (:blocks-file options)) + (let [contents (.toString (fs/readFileSync (:blocks-file options)) "utf8")] + {:ok? true :value (reader/read-string contents)}) + + (seq (:content options)) + {:ok? true :value [{:block/title (:content options)}]} + + (seq command-args) + {:ok? true :value [{:block/title (string/join " " command-args)}]} + + :else + {:ok? false + :error {:code :missing-content + :message "content is required"}})) + +(defn- ensure-vector + [value] + (if (vector? value) + {:ok? true :value value} + {:ok? false + :error {:code :invalid-query + :message "query must be a vector"}})) + +(defn- ensure-blocks + [value] + (if (vector? value) + {:ok? true :value value} + {:ok? false + :error {:code :invalid-blocks + :message "blocks must be a vector"}})) + +(defn- today-page-title + [config repo] + (p/let [journal (transport/invoke config "thread-api/pull" false + [repo [:logseq.property.journal/title-format] :logseq.class/Journal]) + formatter (or (:logseq.property.journal/title-format journal) "MMM do, yyyy") + now (tc/from-date (js/Date.))] + (date-time-util/format now formatter))) + +(defn- ensure-page! + [config repo page-name] + (p/let [page (transport/invoke config "thread-api/pull" false + [repo [:db/id :block/uuid :block/name :block/title] [:block/name page-name]])] + (if (:db/id page) + page + (p/let [_ (transport/invoke config "thread-api/apply-outliner-ops" false + [repo [[:create-page [page-name {}]]] {}])] + (transport/invoke config "thread-api/pull" false + [repo [:db/id :block/uuid :block/name :block/title] [:block/name page-name]]))))) + +(defn- resolve-add-target + [config {:keys [repo page parent]}] + (if (seq parent) + (if-not (common-util/uuid-string? parent) + (p/rejected (ex-info "parent must be a uuid" {:code :invalid-parent})) + (p/let [block (transport/invoke config "thread-api/pull" false + [repo [:db/id :block/uuid :block/title] [:block/uuid (uuid parent)]])] + (if-let [id (:db/id block)] + id + (throw (ex-info "parent block not found" {:code :parent-not-found}))))) + (p/let [page-name (if (seq page) page (today-page-title config repo)) + page-entity (ensure-page! config repo page-name)] + (or (:db/id page-entity) + (throw (ex-info "page not found" {:code :page-not-found})))))) + +(defn- perform-remove + [config {:keys [repo block page]}] + (cond + (seq block) + (if-not (common-util/uuid-string? block) + (p/rejected (ex-info "block must be a uuid" {:code :invalid-block})) + (p/let [entity (transport/invoke config "thread-api/pull" false + [repo [:db/id :block/uuid] [:block/uuid (uuid block)]])] + (if-let [id (:db/id entity)] + (transport/invoke config "thread-api/apply-outliner-ops" false + [repo [[:delete-blocks [[id] {}]]] {}]) + (throw (ex-info "block not found" {:code :block-not-found}))))) + + (seq page) + (p/let [entity (transport/invoke config "thread-api/pull" false + [repo [:db/id :block/uuid] [:block/name page]])] + (if-let [page-uuid (:block/uuid entity)] + (transport/invoke config "thread-api/apply-outliner-ops" false + [repo [[:delete-page [page-uuid]]] {}]) + (throw (ex-info "page not found" {:code :page-not-found})))) + + :else + (p/rejected (ex-info "block or page required" {:code :missing-target})))) + +(def ^:private tree-block-selector + [:db/id :block/uuid :block/title :block/order {:block/parent [:db/id]}]) + +(defn- fetch-blocks-for-page + [config repo page-id] + (let [query [:find (list 'pull '?b tree-block-selector) + :in '$ '?page-id + :where ['?b :block/page '?page-id]]] + (p/let [rows (transport/invoke config "thread-api/q" false [repo [query page-id]])] + (mapv first rows)))) + +(defn- build-tree + [blocks root-id] + (let [parent->children (group-by #(get-in % [:block/parent :db/id]) blocks) + sort-children (fn [children] + (vec (sort-by :block/order children))) + build (fn build [parent-id] + (mapv (fn [b] + (let [children (build (:db/id b))] + (cond-> b + (seq children) (assoc :block/children children)))) + (sort-children (get parent->children parent-id))))] + (build root-id))) + +(defn- fetch-tree + [config {:keys [repo block page]}] + (if (seq block) + (if-not (common-util/uuid-string? block) + (p/rejected (ex-info "block must be a uuid" {:code :invalid-block})) + (p/let [entity (transport/invoke config "thread-api/pull" false + [repo [:db/id :block/uuid :block/title {:block/page [:db/id :block/title]}] + [:block/uuid (uuid block)]])] + (if-let [page-id (get-in entity [:block/page :db/id])] + (p/let [blocks (fetch-blocks-for-page config repo page-id) + children (build-tree blocks (:db/id entity))] + {:root (assoc entity :block/children children)}) + (throw (ex-info "block not found" {:code :block-not-found}))))) + (p/let [page-entity (transport/invoke config "thread-api/pull" false + [repo [:db/id :block/uuid :block/title] [:block/name page]])] + (if-let [page-id (:db/id page-entity)] + (p/let [blocks (fetch-blocks-for-page config repo page-id) + children (build-tree blocks page-id)] + {:root (assoc page-entity :block/children children)}) + (throw (ex-info "page not found" {:code :page-not-found})))))) + +(defn- tree->text + [{:keys [root]}] + (let [title (or (:block/title root) (:block/name root) (str (:block/uuid root))) + lines (atom [title]) + walk (fn walk [node depth] + (doseq [child (:block/children node)] + (let [prefix (apply str (repeat depth " ")) + label (or (:block/title child) (:block/name child) (str (:block/uuid child)))] + (swap! lines conj (str prefix "- " label))) + (walk child (inc depth))))] + (walk root 1) + (string/join "\n" @lines))) + +(defn- resolve-repo + [graph] + (let [graph (some-> graph string/trim)] + (when (seq graph) + (graph->repo graph)))) + +(defn build-action + [parsed config] + (if-not (:ok? parsed) + parsed + (let [{:keys [command options args]} parsed + graph (pick-graph options args config) + repo (resolve-repo graph)] + (case command + :ping + {:ok? true :action {:type :ping}} + + :status + {:ok? true :action {:type :status}} + + :query + (if-not (seq repo) + {:ok? false + :error {:code :missing-repo + :message "repo is required for query"}} + (let [query-result (read-query options)] + (if-not (:ok? query-result) + query-result + (let [vector-result (ensure-vector (:value query-result))] + (if-not (:ok? vector-result) + vector-result + {:ok? true + :action {:type :invoke + :method "thread-api/q" + :direct-pass? false + :args [repo (:value vector-result)]}}))))) + + :export + (let [format (some-> (:format options) string/lower-case) + out (:out options) + repo repo] + (cond + (not (seq repo)) + {:ok? false + :error {:code :missing-repo + :message "repo is required for export"}} + + (not (seq out)) + {:ok? false + :error {:code :missing-output + :message "output path is required"}} + + (= format "edn") + {:ok? true + :action {:type :invoke + :method "thread-api/export-edn" + :direct-pass? false + :args [repo {}] + :write {:format :edn + :path out}}} + + (= format "db") + {:ok? true + :action {:type :invoke + :method "thread-api/export-db" + :direct-pass? true + :args [repo] + :write {:format :db + :path out}}} + + :else + {:ok? false + :error {:code :unsupported-format + :message (str "unsupported format: " format)}})) + + :graph-list + {:ok? true + :action {:type :invoke + :method "thread-api/list-db" + :direct-pass? false + :args []}} + + :graph-create + (if-not (seq graph) + {:ok? false + :error {:code :missing-graph + :message "graph name is required"}} + {:ok? true + :action {:type :invoke + :method "thread-api/create-or-open-db" + :direct-pass? false + :args [repo {}] + :persist-repo (repo->graph repo)}}) + + :graph-switch + (if-not (seq graph) + {:ok? false + :error {:code :missing-graph + :message "graph name is required"}} + {:ok? true + :action {:type :graph-switch + :repo repo + :graph (repo->graph repo)}}) + + :graph-remove + (if-not (seq graph) + {:ok? false + :error {:code :missing-graph + :message "graph name is required"}} + {:ok? true + :action {:type :invoke + :method "thread-api/unsafe-unlink-db" + :direct-pass? false + :args [repo]}}) + + :graph-validate + (if-not (seq repo) + {:ok? false + :error {:code :missing-graph + :message "graph name is required"}} + {:ok? true + :action {:type :invoke + :method "thread-api/validate-db" + :direct-pass? false + :args [repo]}}) + + :graph-info + (if-not (seq repo) + {:ok? false + :error {:code :missing-graph + :message "graph name is required"}} + {:ok? true + :action {:type :graph-info + :repo repo + :graph (repo->graph repo)}}) + + :add + (if-not (seq repo) + {:ok? false + :error {:code :missing-repo + :message "repo is required for add"}} + (let [blocks-result (read-blocks options args)] + (if-not (:ok? blocks-result) + blocks-result + (let [vector-result (ensure-blocks (:value blocks-result))] + (if-not (:ok? vector-result) + vector-result + {:ok? true + :action {:type :add + :repo repo + :graph (repo->graph repo) + :page (:page options) + :parent (:parent options) + :blocks (:value vector-result)}}))))) + + :remove + (if-not (seq repo) + {:ok? false + :error {:code :missing-repo + :message "repo is required for remove"}} + (let [block (:block options) + page (:page options)] + (if (or (seq block) (seq page)) + {:ok? true + :action {:type :remove + :repo repo + :block block + :page page}} + {:ok? false + :error {:code :missing-target + :message "block or page is required"}}))) + + :search + (if-not (seq repo) + {:ok? false + :error {:code :missing-repo + :message "repo is required for search"}} + (let [text (or (:text options) (string/join " " args))] + (if (seq text) + {:ok? true + :action {:type :search + :repo repo + :text text + :limit (:limit options)}} + {:ok? false + :error {:code :missing-search-text + :message "search text is required"}}))) + + :tree + (if-not (seq repo) + {:ok? false + :error {:code :missing-repo + :message "repo is required for tree"}} + (let [block (:block options) + page (:page options) + target (or block page)] + (if (seq target) + {:ok? true + :action {:type :tree + :repo repo + :block block + :page page + :format (some-> (:format options) string/lower-case)}} + {:ok? false + :error {:code :missing-target + :message "block or page is required"}}))) + + {:ok? false + :error {:code :unknown-command + :message (str "unknown command: " command)}})))) + +(defn execute + [action config] + (case (:type action) + :ping + (-> (transport/ping config) + (p/then (fn [_] + {:status :ok :data {:message "ok"}}))) + + :status + (-> (p/let [ready? (transport/ready config) + dbs (transport/list-db config)] + {:status :ok + :data {:ready ready? + :dbs dbs}})) + + :invoke + (-> (p/let [result (transport/invoke config + (:method action) + (:direct-pass? action) + (:args action))] + (when-let [repo (:persist-repo action)] + (cli-config/update-config! config {:repo repo})) + (if-let [write (:write action)] + (let [{:keys [format path]} write] + (transport/write-output {:format format :path path :data result}) + {:status :ok + :data {:message (str "wrote " path)}}) + {:status :ok :data {:result result}}))) + + :graph-switch + (-> (p/let [exists? (transport/invoke config "thread-api/db-exists" false [(:repo action)])] + (if-not exists? + {:status :error + :error {:code :graph-not-found + :message (str "graph not found: " (:graph action))}} + (p/let [_ (transport/invoke config "thread-api/create-or-open-db" false [(:repo action) {}])] + (cli-config/update-config! config {:repo (:graph action)}) + {:status :ok + :data {:message (str "switched to " (:graph action))}})))) + + :graph-info + (-> (p/let [created (transport/invoke config "thread-api/pull" false [(:repo action) [:kv/value] :logseq.kv/graph-created-at]) + schema (transport/invoke config "thread-api/pull" false [(:repo action) [:kv/value] :logseq.kv/schema-version])] + {:status :ok + :data {:graph (:graph action) + :logseq.kv/graph-created-at (:kv/value created) + :logseq.kv/schema-version (:kv/value schema)}})) + + :add + (-> (p/let [target-id (resolve-add-target config action) + ops [[:insert-blocks [(:blocks action) + target-id + {:sibling? false + :bottom? true + :outliner-op :insert-blocks}]]] + result (transport/invoke config "thread-api/apply-outliner-ops" false [(:repo action) ops {}])] + {:status :ok + :data {:result result}})) + + :remove + (-> (p/let [result (perform-remove config action)] + {:status :ok + :data {:result result}})) + + :search + (-> (p/let [query '[:find ?e ?title + :in $ ?q + :where + [?e :block/title ?title] + [(clojure.string/includes? ?title ?q)]] + results (transport/invoke config "thread-api/q" false [(:repo action) [query (:text action)]]) + mapped (mapv (fn [[id title]] {:db/id id :block/title title}) results) + limited (if (some? (:limit action)) (vec (take (:limit action) mapped)) mapped)] + {:status :ok + :data {:results limited}})) + + :tree + (-> (p/let [tree-data (fetch-tree config action) + format (or (:format action) (when (:json? config) "json"))] + (case format + "edn" + {:status :ok + :data tree-data + :output-format :edn} + + "json" + {:status :ok + :data tree-data + :output-format :json} + + {:status :ok + :data {:message (tree->text tree-data)}}))) + + {:status :error + :error {:code :unknown-action + :message "unknown action"}})) diff --git a/src/main/logseq/cli/config.cljs b/src/main/logseq/cli/config.cljs new file mode 100644 index 0000000000..ff78753605 --- /dev/null +++ b/src/main/logseq/cli/config.cljs @@ -0,0 +1,83 @@ +(ns logseq.cli.config + (:require [cljs.reader :as reader] + [clojure.string :as string] + [goog.object :as gobj] + ["fs" :as fs] + ["os" :as os] + ["path" :as path])) + +(defn- parse-int + [value] + (when (and (some? value) (not (string/blank? value))) + (js/parseInt value 10))) + +(defn- default-config-path + [] + (path/join (.homedir os) ".logseq" "cli.edn")) + +(defn- read-config-file + [config-path] + (when (and (some? config-path) (fs/existsSync config-path)) + (let [contents (.toString (fs/readFileSync config-path) "utf8")] + (reader/read-string contents)))) + +(defn- ensure-config-dir! + [config-path] + (when (seq config-path) + (let [dir (path/dirname config-path)] + (when (and (seq dir) (not (fs/existsSync dir))) + (.mkdirSync fs dir #js {:recursive true}))))) + +(defn update-config! + [{:keys [config-path]} updates] + (let [path (or config-path (default-config-path)) + current (or (read-config-file path) {}) + next (merge current updates)] + (ensure-config-dir! path) + (.writeFileSync fs path (pr-str next)) + next)) + +(defn- env-config + [] + (let [env (.-env js/process)] + (cond-> {} + (seq (gobj/get env "LOGSEQ_DB_WORKER_URL")) + (assoc :base-url (gobj/get env "LOGSEQ_DB_WORKER_URL")) + + (seq (gobj/get env "LOGSEQ_DB_WORKER_AUTH_TOKEN")) + (assoc :auth-token (gobj/get env "LOGSEQ_DB_WORKER_AUTH_TOKEN")) + + (seq (gobj/get env "LOGSEQ_CLI_REPO")) + (assoc :repo (gobj/get env "LOGSEQ_CLI_REPO")) + + (seq (gobj/get env "LOGSEQ_CLI_TIMEOUT_MS")) + (assoc :timeout-ms (parse-int (gobj/get env "LOGSEQ_CLI_TIMEOUT_MS"))) + + (seq (gobj/get env "LOGSEQ_CLI_RETRIES")) + (assoc :retries (parse-int (gobj/get env "LOGSEQ_CLI_RETRIES"))) + + (seq (gobj/get env "LOGSEQ_CLI_CONFIG")) + (assoc :config-path (gobj/get env "LOGSEQ_CLI_CONFIG"))))) + +(defn- build-base-url + [{:keys [host port]}] + (when (or (seq host) (some? port)) + (str "http://" (or host "127.0.0.1") ":" (or port 9101)))) + +(defn resolve-config + [opts] + (let [defaults {:base-url "http://127.0.0.1:9101" + :timeout-ms 10000 + :retries 0 + :json? false + :output-format nil + :config-path (default-config-path)} + env (env-config) + config-path (or (:config-path opts) + (:config-path env) + (:config-path defaults)) + file-config (or (read-config-file config-path) {}) + merged (merge defaults file-config env opts {:config-path config-path}) + derived (build-base-url merged)] + (cond-> merged + (seq derived) (assoc :base-url derived)))) diff --git a/src/main/logseq/cli/format.cljs b/src/main/logseq/cli/format.cljs new file mode 100644 index 0000000000..3ed42cb8a8 --- /dev/null +++ b/src/main/logseq/cli/format.cljs @@ -0,0 +1,54 @@ +(ns logseq.cli.format + (:require [clojure.string :as string] + [clojure.walk :as walk])) + +(defn- normalize-json + [value] + (walk/postwalk (fn [entry] + (if (uuid? entry) + (str entry) + entry)) + value)) + +(defn- ->json + [{:keys [status data error]}] + (let [obj (js-obj)] + (set! (.-status obj) (name status)) + (cond + (= status :ok) + (set! (.-data obj) (clj->js (normalize-json data))) + + (= status :error) + (set! (.-error obj) (clj->js (normalize-json (update error :code name))))) + (js/JSON.stringify obj))) + +(defn- ->human + [{:keys [status data error]}] + (case status + :ok + (if (and (map? data) (contains? data :message)) + (:message data) + (pr-str data)) + + :error + (str "error: " (:message error)) + + (pr-str {:status status :data data :error error}))) + +(defn- ->edn + [{:keys [status data error]}] + (pr-str (cond-> {:status status} + (= status :ok) (assoc :data data) + (= status :error) (assoc :error error)))) + +(defn format-result + [result {:keys [json? output-format]}] + (let [format (cond + (= output-format :edn) :edn + (= output-format :json) :json + json? :json + :else :human)] + (case format + :json (->json result) + :edn (->edn result) + (->human result)))) diff --git a/src/main/logseq/cli/main.cljs b/src/main/logseq/cli/main.cljs new file mode 100644 index 0000000000..11a32a2a4a --- /dev/null +++ b/src/main/logseq/cli/main.cljs @@ -0,0 +1,65 @@ +(ns logseq.cli.main + (:refer-clojure :exclude [run!]) + (:require [clojure.string :as string] + [logseq.cli.commands :as commands] + [logseq.cli.config :as config] + [logseq.cli.format :as format] + [promesa.core :as p])) + +(defn- usage + [summary] + (string/join "\n" + ["logseq-cli [options]" + "" + "Commands: ping, status, query, export, graph-list, graph-create, graph-switch, graph-remove, graph-validate, graph-info, add, remove, search, tree" + "" + "Options:" + summary])) + +(defn run! + ([args] (run! args {:exit? true})) + ([args {:keys [exit?] :or {exit? true}}] + (let [parsed (commands/parse-args args)] + (cond + (:help? parsed) + (p/resolved {:exit-code 0 + :output (usage (:summary parsed))}) + + (not (:ok? parsed)) + (p/resolved {:exit-code 1 + :output (format/format-result {:status :error + :error (:error parsed)} + {:json? false})}) + + :else + (let [cfg (config/resolve-config (:options parsed)) + action-result (commands/build-action parsed cfg)] + (if-not (:ok? action-result) + (p/resolved {:exit-code 1 + :output (format/format-result {:status :error + :error (:error action-result)} + cfg)}) + (-> (commands/execute (:action action-result) cfg) + (p/then (fn [result] + (let [opts (cond-> cfg + (:output-format result) + (assoc :output-format (:output-format result)))] + {:exit-code 0 + :output (format/format-result result opts)}))) + (p/catch (fn [error] + (let [message (or (some-> (ex-data error) :message) + (.-message error) + (str error))] + {:exit-code 1 + :output (format/format-result {:status :error + :error {:code :exception + :message message}} + cfg)})))))))))) + +(defn main + [& args] + (-> (run! args) + (p/then (fn [{:keys [exit-code output]}] + (when (seq output) + (println output)) + (.exit js/process exit-code))))) diff --git a/src/main/logseq/cli/transport.cljs b/src/main/logseq/cli/transport.cljs new file mode 100644 index 0000000000..286034edbf --- /dev/null +++ b/src/main/logseq/cli/transport.cljs @@ -0,0 +1,142 @@ +(ns logseq.cli.transport + (:require [clojure.string :as string] + [logseq.db :as ldb] + [promesa.core :as p] + ["fs" :as fs] + ["http" :as http] + ["https" :as https] + ["url" :as url])) + +(defn- request-module + [^js parsed] + (if (= "https:" (.-protocol parsed)) + https + http)) + +(defn- base-headers + [auth-token] + (cond-> {"Content-Type" "application/json" + "Accept" "application/json"} + (seq auth-token) + (assoc "Authorization" (str "Bearer " auth-token)))) + +(defn- js headers)} + (fn [^js res] + (let [chunks (array)] + (.on res "data" (fn [chunk] (.push chunks chunk))) + (.on res "end" (fn [] + (let [buf (js/Buffer.concat chunks)] + (resolve {:status (.-statusCode res) + :body (.toString buf "utf8")})))) + (.on res "error" reject)))) + timeout-id (js/setTimeout + (fn [] + (.destroy req) + (reject (ex-info "request timeout" {:code :timeout}))) + timeout-ms)] + (.on req "error" (fn [err] + (js/clearTimeout timeout-id) + (reject err))) + (when body + (.write req body)) + (.end req) + (.on req "response" (fn [_] + (js/clearTimeout timeout-id))))))) + +(defn- retryable-error? + [error] + (let [{:keys [code status]} (ex-data error)] + (or (= :timeout code) + (and (= :http-error code) + (>= (or status 0) 500))))) + +(defn request + [{:keys [method url headers body timeout-ms retries] + :or {retries 0}}] + (p/loop [attempt 0] + (-> (p/let [response ( (request {:method "GET" + :url (str (string/replace base-url #"/$" "") "/readyz") + :timeout-ms timeout-ms + :retries retries + :headers {}}) + (p/then (fn [_] true)))) + +(defn invoke + [{:keys [base-url auth-token timeout-ms retries]} + method direct-pass? args] + (let [url (str (string/replace base-url #"/$" "") "/v1/invoke") + payload (if direct-pass? + {:method method + :directPass true + :args args} + {:method method + :directPass false + :argsTransit (ldb/write-transit-str args)}) + body (js/JSON.stringify (clj->js payload))] + (p/let [{:keys [body]} (request {:method "POST" + :url url + :headers (base-headers auth-token) + :body body + :timeout-ms timeout-ms + :retries retries}) + {:keys [result resultTransit]} (js->clj (js/JSON.parse body) :keywordize-keys true)] + (if direct-pass? + result + (ldb/read-transit-str resultTransit))))) + +(defn list-db + [config] + (invoke config "thread-api/list-db" false [])) + +(defn write-output + [{:keys [format path data]}] + (case format + :edn + (fs/writeFileSync path (pr-str data)) + + :db + (let [buffer (if (instance? js/Buffer data) + data + (js/Buffer.from data))] + (fs/writeFileSync path buffer)) + + (throw (ex-info "unsupported output format" {:format format})))) diff --git a/src/test/logseq/cli/commands_test.cljs b/src/test/logseq/cli/commands_test.cljs new file mode 100644 index 0000000000..1129dd82c5 --- /dev/null +++ b/src/test/logseq/cli/commands_test.cljs @@ -0,0 +1,108 @@ +(ns logseq.cli.commands-test + (:require [cljs.test :refer [deftest is testing]] + [logseq.cli.commands :as commands])) + +(deftest test-parse-args + (testing "parses ping" + (let [result (commands/parse-args ["ping"])] + (is (true? (:ok? result))) + (is (= :ping (:command result))))) + + (testing "errors on missing command" + (let [result (commands/parse-args [])] + (is (false? (:ok? result))) + (is (= :missing-command (get-in result [:error :code]))))) + + (testing "errors on unknown command" + (let [result (commands/parse-args ["wat"])] + (is (false? (:ok? result))) + (is (= :unknown-command (get-in result [:error :code])))))) + +(deftest test-build-action + (testing "query requires repo" + (let [parsed {:ok? true + :command :query + :options {:query "[:find ?e :where [?e :block/name]]"}} + result (commands/build-action parsed {})] + (is (false? (:ok? result))) + (is (= :missing-repo (get-in result [:error :code]))))) + + (testing "query uses repo from config" + (let [parsed {:ok? true + :command :query + :options {:query "[:find ?e :where [?e :block/name]]"}} + result (commands/build-action parsed {:repo "test-repo"})] + (is (true? (:ok? result))) + (is (= "thread-api/q" (get-in result [:action :method]))))) + + (testing "export rejects unsupported format" + (let [parsed {:ok? true + :command :export + :options {:repo "repo" :format "nope" :out "output.edn"}} + result (commands/build-action parsed {})] + (is (false? (:ok? result))) + (is (= :unsupported-format (get-in result [:error :code]))))) + + (testing "export builds edn action" + (let [parsed {:ok? true + :command :export + :options {:repo "repo" :format "edn" :out "output.edn"}} + result (commands/build-action parsed {})] + (is (true? (:ok? result))) + (is (= "thread-api/export-edn" (get-in result [:action :method])))))) + +(deftest test-graph-commands + (testing "graph-list uses list-db" + (let [parsed {:ok? true :command :graph-list :options {}} + result (commands/build-action parsed {})] + (is (true? (:ok? result))) + (is (= "thread-api/list-db" (get-in result [:action :method]))))) + + (testing "graph-create requires graph name" + (let [parsed {:ok? true :command :graph-create :options {}} + result (commands/build-action parsed {})] + (is (false? (:ok? result))) + (is (= :missing-graph (get-in result [:error :code]))))) + + (testing "graph-switch uses graph name" + (let [parsed {:ok? true :command :graph-switch :options {:graph "demo"}} + result (commands/build-action parsed {})] + (is (true? (:ok? result))) + (is (= :graph-switch (get-in result [:action :type]))))) + + (testing "graph-info defaults to config repo" + (let [parsed {:ok? true :command :graph-info :options {}} + result (commands/build-action parsed {:repo "demo"})] + (is (true? (:ok? result))) + (is (= :graph-info (get-in result [:action :type])))))) + +(deftest test-content-commands + (testing "add requires content" + (let [parsed {:ok? true :command :add :options {}} + result (commands/build-action parsed {:repo "demo"})] + (is (false? (:ok? result))) + (is (= :missing-content (get-in result [:error :code]))))) + + (testing "add builds insert-blocks op" + (let [parsed {:ok? true :command :add :options {:content "hello"}} + result (commands/build-action parsed {:repo "demo"})] + (is (true? (:ok? result))) + (is (= :add (get-in result [:action :type]))))) + + (testing "remove requires target" + (let [parsed {:ok? true :command :remove :options {}} + result (commands/build-action parsed {:repo "demo"})] + (is (false? (:ok? result))) + (is (= :missing-target (get-in result [:error :code]))))) + + (testing "search requires text" + (let [parsed {:ok? true :command :search :options {}} + result (commands/build-action parsed {:repo "demo"})] + (is (false? (:ok? result))) + (is (= :missing-search-text (get-in result [:error :code]))))) + + (testing "tree requires target" + (let [parsed {:ok? true :command :tree :options {}} + result (commands/build-action parsed {:repo "demo"})] + (is (false? (:ok? result))) + (is (= :missing-target (get-in result [:error :code])))))) diff --git a/src/test/logseq/cli/config_test.cljs b/src/test/logseq/cli/config_test.cljs new file mode 100644 index 0000000000..5e6d28b256 --- /dev/null +++ b/src/test/logseq/cli/config_test.cljs @@ -0,0 +1,71 @@ +(ns logseq.cli.config-test + (:require [cljs.reader :as reader] + [cljs.test :refer [deftest is testing]] + [frontend.test.node-helper :as node-helper] + [goog.object :as gobj] + [logseq.cli.config :as config] + ["fs" :as fs] + ["path" :as path])) + +(defn- with-env + [env f] + (let [original (js/Object.assign #js {} (.-env js/process))] + (doseq [[k v] env] + (if (some? v) + (gobj/set (.-env js/process) k v) + (gobj/remove (.-env js/process) k))) + (try + (f) + (finally + (set! (.-env js/process) original))))) + +(deftest test-config-precedence + (let [dir (node-helper/create-tmp-dir) + cfg-path (path/join dir "cli.edn") + _ (fs/writeFileSync cfg-path + (str "{:base-url \"http://file:7777\" " + ":auth-token \"file-token\" " + ":repo \"file-repo\" " + ":timeout-ms 111 " + ":retries 1}")) + env {"LOGSEQ_DB_WORKER_URL" "http://env:9999" + "LOGSEQ_DB_WORKER_AUTH_TOKEN" "env-token" + "LOGSEQ_CLI_REPO" "env-repo" + "LOGSEQ_CLI_TIMEOUT_MS" "222" + "LOGSEQ_CLI_RETRIES" "2"} + opts {:config-path cfg-path + :base-url "http://cli:1234" + :auth-token "cli-token" + :repo "cli-repo" + :timeout-ms 333 + :retries 3} + result (with-env env #(config/resolve-config opts))] + (is (= cfg-path (:config-path result))) + (is (= "http://cli:1234" (:base-url result))) + (is (= "cli-token" (:auth-token result))) + (is (= "cli-repo" (:repo result))) + (is (= 333 (:timeout-ms result))) + (is (= 3 (:retries result))))) + +(deftest test-host-port-derived-base-url + (let [result (config/resolve-config {:host "127.0.0.2" :port 9200})] + (is (= "http://127.0.0.2:9200" (:base-url result))))) + +(deftest test-env-overrides-file + (let [dir (node-helper/create-tmp-dir) + cfg-path (path/join dir "cli.edn") + _ (fs/writeFileSync cfg-path "{:base-url \"http://file:7777\" :repo \"file-repo\"}") + env {"LOGSEQ_DB_WORKER_URL" "http://env:9999" + "LOGSEQ_CLI_REPO" "env-repo"} + result (with-env env #(config/resolve-config {:config-path cfg-path}))] + (is (= "http://env:9999" (:base-url result))) + (is (= "env-repo" (:repo result))))) + +(deftest test-update-config + (let [dir (node-helper/create-tmp-dir "cli") + cfg-path (path/join dir "cli.edn") + _ (fs/writeFileSync cfg-path "{:repo \"old\"}") + _ (config/update-config! {:config-path cfg-path} {:repo "new"}) + contents (.toString (fs/readFileSync cfg-path) "utf8") + parsed (reader/read-string contents)] + (is (= "new" (:repo parsed))))) diff --git a/src/test/logseq/cli/format_test.cljs b/src/test/logseq/cli/format_test.cljs new file mode 100644 index 0000000000..db06a6cf47 --- /dev/null +++ b/src/test/logseq/cli/format_test.cljs @@ -0,0 +1,25 @@ +(ns logseq.cli.format-test + (:require [cljs.test :refer [deftest is testing]] + [logseq.cli.format :as format])) + +(deftest test-format-success + (testing "json output" + (let [result (format/format-result {:status :ok :data {:message "ok"}} + {:json? true})] + (is (= "{\"status\":\"ok\",\"data\":{\"message\":\"ok\"}}" result)))) + + (testing "human output" + (let [result (format/format-result {:status :ok :data {:message "ok"}} + {:json? false})] + (is (= "ok" result))))) + +(deftest test-format-error + (testing "json error" + (let [result (format/format-result {:status :error :error {:code :boom :message "nope"}} + {:json? true})] + (is (= "{\"status\":\"error\",\"error\":{\"code\":\"boom\",\"message\":\"nope\"}}" result)))) + + (testing "human error" + (let [result (format/format-result {:status :error :error {:code :boom :message "nope"}} + {:json? false})] + (is (= "error: nope" result))))) diff --git a/src/test/logseq/cli/integration_test.cljs b/src/test/logseq/cli/integration_test.cljs new file mode 100644 index 0000000000..492f49d663 --- /dev/null +++ b/src/test/logseq/cli/integration_test.cljs @@ -0,0 +1,106 @@ +(ns logseq.cli.integration-test + (:require [cljs.test :refer [deftest is async]] + [frontend.test.node-helper :as node-helper] + [frontend.worker.db-worker-node :as db-worker-node] + [logseq.cli.main :as cli-main] + [promesa.core :as p] + ["fs" :as fs] + ["path" :as path])) + +(defn- run-cli + [args url cfg-path] + (cli-main/run! (vec (concat args ["--base-url" url "--config" cfg-path "--json"])) + {:exit? false})) + +(defn- parse-json-output + [result] + (js->clj (js/JSON.parse (:output result)) :keywordize-keys true)) + +(deftest test-cli-ping + (async done + (let [data-dir (node-helper/create-tmp-dir "db-worker")] + (-> (p/let [daemon (db-worker-node/start-daemon! {:host "127.0.0.1" + :port 0 + :data-dir data-dir}) + url (str "http://127.0.0.1:" (:port daemon)) + result (cli-main/run! ["ping" "--base-url" url "--json"] {:exit? false})] + (is (= 0 (:exit-code result))) + (is (= "{\"status\":\"ok\",\"data\":{\"message\":\"ok\"}}" (:output result))) + (p/let [_ ((:stop! daemon))] + (done))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)) + (done))))))) + +(deftest test-cli-graph-list + (async done + (let [data-dir (node-helper/create-tmp-dir "db-worker")] + (-> (p/let [daemon (db-worker-node/start-daemon! {:host "127.0.0.1" + :port 0 + :data-dir data-dir}) + url (str "http://127.0.0.1:" (:port daemon)) + cfg-path (path/join (node-helper/create-tmp-dir "cli") "cli.edn") + result (run-cli ["graph-list"] url cfg-path) + payload (parse-json-output result)] + (is (= 0 (:exit-code result))) + (is (= "ok" (:status payload))) + (is (contains? payload :data)) + (p/let [_ ((:stop! daemon))] + (done))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)) + (done))))))) + +(deftest test-cli-graph-create-and-info + (async done + (let [data-dir (node-helper/create-tmp-dir "db-worker")] + (-> (p/let [daemon (db-worker-node/start-daemon! {:host "127.0.0.1" + :port 0 + :data-dir data-dir}) + url (str "http://127.0.0.1:" (:port daemon)) + cfg-path (path/join (node-helper/create-tmp-dir "cli") "cli.edn") + _ (fs/writeFileSync cfg-path "{}") + create-result (run-cli ["graph-create" "--graph" "demo-graph"] url cfg-path) + create-payload (parse-json-output create-result) + info-result (run-cli ["graph-info"] url cfg-path) + info-payload (parse-json-output info-result)] + (is (= 0 (:exit-code create-result))) + (is (= "ok" (:status create-payload))) + (is (= 0 (:exit-code info-result))) + (is (= "ok" (:status info-payload))) + (is (= "demo-graph" (get-in info-payload [:data :graph]))) + (p/let [_ ((:stop! daemon))] + (done))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)) + (done))))))) + +(deftest test-cli-add-search-tree-remove + (async done + (let [data-dir (node-helper/create-tmp-dir "db-worker")] + (-> (p/let [daemon (db-worker-node/start-daemon! {:host "127.0.0.1" + :port 0 + :data-dir data-dir}) + url (str "http://127.0.0.1:" (:port daemon)) + cfg-path (path/join (node-helper/create-tmp-dir "cli") "cli.edn") + _ (fs/writeFileSync cfg-path "{}") + _ (run-cli ["graph-create" "--graph" "content-graph"] url cfg-path) + add-result (run-cli ["add" "--page" "TestPage" "--content" "hello world"] url cfg-path) + _ (parse-json-output add-result) + search-result (run-cli ["search" "--text" "hello world"] url cfg-path) + search-payload (parse-json-output search-result) + tree-result (run-cli ["tree" "--page" "TestPage" "--format" "json"] url cfg-path) + tree-payload (parse-json-output tree-result) + block-uuid (get-in tree-payload [:data :root :children 0 :uuid]) + remove-result (run-cli ["remove" "--block" (str block-uuid)] url cfg-path) + remove-payload (parse-json-output remove-result)] + (is (= 0 (:exit-code add-result))) + (is (= "ok" (:status search-payload))) + (is (seq (get-in search-payload [:data :results]))) + (is (= "ok" (:status tree-payload))) + (is (= "ok" (:status remove-payload))) + (p/let [_ ((:stop! daemon))] + (done))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)) + (done))))))) diff --git a/src/test/logseq/cli/transport_test.cljs b/src/test/logseq/cli/transport_test.cljs new file mode 100644 index 0000000000..fa95fd351a --- /dev/null +++ b/src/test/logseq/cli/transport_test.cljs @@ -0,0 +1,64 @@ +(ns logseq.cli.transport-test + (:require [cljs.test :refer [deftest is async testing]] + [promesa.core :as p] + [logseq.cli.transport :as transport])) + +(defn- start-server + [handler] + (p/create + (fn [resolve reject] + (let [http (js/require "http") + server (.createServer http handler)] + (.on server "error" reject) + (.listen server 0 "127.0.0.1" + (fn [] + (let [address (.address server) + port (.-port address) + stop! (fn [] + (p/create (fn [resolve _] + (.close server (fn [] (resolve true))))))] + (resolve {:url (str "http://127.0.0.1:" port) + :stop! stop!})))))))) + +(deftest test-request-retries + (async done + (let [calls (atom 0)] + (-> (p/let [{:keys [url stop!]} (start-server + (fn [_req res] + (let [attempt (swap! calls inc)] + (if (= attempt 1) + (do + (.writeHead res 500 #js {"Content-Type" "text/plain"}) + (.end res "boom")) + (do + (.writeHead res 200 #js {"Content-Type" "text/plain"}) + (.end res "ok")))))) + response (transport/request {:method "GET" + :url (str url "/retry") + :retries 1 + :timeout-ms 1000})] + (is (= 200 (:status response))) + (is (= 2 @calls)) + (p/let [_ (stop!)] + (done))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)) + (done))))))) + +(deftest test-request-timeout + (async done + (-> (p/let [{:keys [url stop!]} (start-server + (fn [_req _res] + nil))] + (p/catch + (transport/request {:method "GET" + :url (str url "/hang") + :timeout-ms 10 + :retries 0}) + (fn [e] + (is (= :timeout (-> (ex-data e) :code))) + (p/let [_ (stop!)] + (done))))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)) + (done))))))