mirror of
https://github.com/logseq/logseq.git
synced 2026-02-01 22:47:36 +00:00
add :logseq-cli build
see also docs/cli/logseq-cli.md
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,6 +16,7 @@ node_modules/
|
||||
static/**
|
||||
tmp
|
||||
cljs-test-runner-out
|
||||
.tmp/
|
||||
|
||||
.cpcache/
|
||||
/src/gen
|
||||
|
||||
@@ -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).
|
||||
|
||||
152
docs/agent-guide/001-logseq-cli.md
Normal file
152
docs/agent-guide/001-logseq-cli.md
Normal 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
65
docs/cli/logseq-cli.md
Normal 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 today’s 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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
592
src/main/logseq/cli/commands.cljs
Normal file
592
src/main/logseq/cli/commands.cljs
Normal 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"}}))
|
||||
83
src/main/logseq/cli/config.cljs
Normal file
83
src/main/logseq/cli/config.cljs
Normal 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))))
|
||||
54
src/main/logseq/cli/format.cljs
Normal file
54
src/main/logseq/cli/format.cljs
Normal 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))))
|
||||
65
src/main/logseq/cli/main.cljs
Normal file
65
src/main/logseq/cli/main.cljs
Normal 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)))))
|
||||
142
src/main/logseq/cli/transport.cljs
Normal file
142
src/main/logseq/cli/transport.cljs
Normal 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}))))
|
||||
108
src/test/logseq/cli/commands_test.cljs
Normal file
108
src/test/logseq/cli/commands_test.cljs
Normal 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]))))))
|
||||
71
src/test/logseq/cli/config_test.cljs
Normal file
71
src/test/logseq/cli/config_test.cljs
Normal 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)))))
|
||||
25
src/test/logseq/cli/format_test.cljs
Normal file
25
src/test/logseq/cli/format_test.cljs
Normal 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)))))
|
||||
106
src/test/logseq/cli/integration_test.cljs
Normal file
106
src/test/logseq/cli/integration_test.cljs
Normal 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)))))))
|
||||
64
src/test/logseq/cli/transport_test.cljs
Normal file
64
src/test/logseq/cli/transport_test.cljs
Normal 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))))))
|
||||
Reference in New Issue
Block a user