add :logseq-cli build

see also docs/cli/logseq-cli.md
This commit is contained in:
rcmerci
2026-01-14 22:21:10 +08:00
parent 72454257d1
commit 8c3bbbb24e
15 changed files with 1542 additions and 0 deletions

1
.gitignore vendored
View File

@@ -16,6 +16,7 @@ node_modules/
static/**
tmp
cljs-test-runner-out
.tmp/
.cpcache/
/src/gen

View File

@@ -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).

View File

@@ -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
---

65
docs/cli/logseq-cli.md Normal file
View File

@@ -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 <name>` - create a new db graph and switch to it
- `graph-switch --graph <name>` - switch current graph
- `graph-remove --graph <name>` - remove a graph
- `graph-validate --graph <name>` - validate graph data
- `graph-info [--graph <name>]` - show graph metadata (defaults to current graph)
Graph content commands:
- `add --content <text> [--page <name>] [--parent <uuid>]` - add blocks; defaults to todays journal page if no page is given
- `add --blocks <edn> [--page <name>] [--parent <uuid>]` - insert blocks via EDN vector
- `add --blocks-file <path> [--page <name>] [--parent <uuid>]` - insert blocks from an EDN file
- `remove --block <uuid>` - remove a block and its children
- `remove --page <name>` - remove a page and its children
- `search --text <query> [--limit <n>]` - search block titles (Datalog includes?)
- `tree --page <name> [--format text|json|edn]` - show page tree
- `tree --block <uuid> [--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
```

View File

@@ -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

View File

@@ -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"}}))

View File

@@ -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))))

View File

@@ -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))))

View File

@@ -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 <command> [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)))))

View File

@@ -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- <raw-request
[{:keys [method url headers body timeout-ms]}]
(p/create
(fn [resolve reject]
(let [parsed (url/parse url)
module (request-module parsed)
timeout-ms (or timeout-ms 10000)
req (.request
module
#js {:method method
:hostname (.-hostname parsed)
:port (or (.-port parsed) (if (= "https:" (.-protocol parsed)) 443 80))
:path (str (.-pathname parsed) (.-search parsed))
:headers (clj->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 (<raw-request {:method method
:url url
:headers headers
:body body
:timeout-ms timeout-ms})]
(if (<= 200 (:status response) 299)
response
(throw (ex-info "http request failed"
{:code :http-error
:status (:status response)
:body (:body response)}))))
(p/catch (fn [error]
(if (and (< attempt retries) (retryable-error? error))
(p/recur (inc attempt))
(throw error)))))))
(defn ping
[{:keys [base-url timeout-ms retries]}]
(request {:method "GET"
:url (str (string/replace base-url #"/$" "") "/healthz")
:timeout-ms timeout-ms
:retries retries
:headers {}}))
(defn ready
[{:keys [base-url timeout-ms retries]}]
(-> (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}))))

View File

@@ -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]))))))

View File

@@ -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)))))

View File

@@ -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)))))

View File

@@ -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)))))))

View File

@@ -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))))))