fantsec-docmost-cli 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/build/__tests__/cli-utils.test.js +287 -0
- package/build/__tests__/client-pagination.test.js +103 -0
- package/build/__tests__/discovery.test.js +40 -0
- package/build/__tests__/envelope.test.js +91 -0
- package/build/__tests__/filters.test.js +235 -0
- package/build/__tests__/integration/comment.test.js +48 -0
- package/build/__tests__/integration/discovery.test.js +24 -0
- package/build/__tests__/integration/file.test.js +33 -0
- package/build/__tests__/integration/group.test.js +48 -0
- package/build/__tests__/integration/helpers/global-setup.js +80 -0
- package/build/__tests__/integration/helpers/run-cli.js +163 -0
- package/build/__tests__/integration/invite.test.js +34 -0
- package/build/__tests__/integration/page.test.js +69 -0
- package/build/__tests__/integration/search.test.js +45 -0
- package/build/__tests__/integration/share.test.js +49 -0
- package/build/__tests__/integration/space.test.js +56 -0
- package/build/__tests__/integration/user.test.js +15 -0
- package/build/__tests__/integration/workspace.test.js +42 -0
- package/build/__tests__/markdown-converter.test.js +445 -0
- package/build/__tests__/mcp-tooling.test.js +58 -0
- package/build/__tests__/page-mentions.test.js +65 -0
- package/build/__tests__/tiptap-extensions.test.js +135 -0
- package/build/client.js +715 -0
- package/build/commands/comment.js +54 -0
- package/build/commands/discovery.js +21 -0
- package/build/commands/file.js +36 -0
- package/build/commands/group.js +91 -0
- package/build/commands/invite.js +67 -0
- package/build/commands/page.js +227 -0
- package/build/commands/search.js +33 -0
- package/build/commands/share.js +65 -0
- package/build/commands/space.js +154 -0
- package/build/commands/user.js +38 -0
- package/build/commands/workspace.js +77 -0
- package/build/index.js +19 -0
- package/build/lib/auth-utils.js +53 -0
- package/build/lib/cli-utils.js +293 -0
- package/build/lib/collaboration.js +126 -0
- package/build/lib/filters.js +137 -0
- package/build/lib/markdown-converter.js +187 -0
- package/build/lib/mcp-tooling.js +295 -0
- package/build/lib/page-mentions.js +162 -0
- package/build/lib/tiptap-extensions.js +86 -0
- package/build/mcp.js +186 -0
- package/build/program.js +60 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Moritz Krause
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
[](https://github.com/dapi/docmost-cli/actions/workflows/ci.yml)
|
|
2
|
+
|
|
3
|
+
# Docmost CLI + MCP Server
|
|
4
|
+
|
|
5
|
+
A Docmost CLI plus standard stdio and HTTP Model Context Protocol (MCP) servers for [Docmost](https://docmost.com/), enabling AI agents to search, create, modify, and organize documentation pages and spaces.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
### Core Management
|
|
10
|
+
|
|
11
|
+
- **`create_page`**: Smart creation tool. Creates content (via import) AND handles hierarchy (nesting under a parent) in one go.
|
|
12
|
+
- **`update_page`**: Update a page's content and/or title. Updates are performed via real-time collaboration (WebSocket).
|
|
13
|
+
- **`delete_page` / `delete_pages`**: Delete single or multiple pages at once.
|
|
14
|
+
- **`move_page`**: Organize pages hierarchically by moving them to a new parent or root.
|
|
15
|
+
|
|
16
|
+
### Exploration & Retrieval
|
|
17
|
+
|
|
18
|
+
- **`search`**: Full-text search across spaces with optional space filtering (`query`, `spaceId`).
|
|
19
|
+
- **`get_workspace`**: Get information about the current Docmost workspace.
|
|
20
|
+
- **`list_spaces`**: View all spaces within the current workspace.
|
|
21
|
+
- **`list_groups`**: View all groups within the current workspace.
|
|
22
|
+
- **`list_pages`**: List pages within a space (ordered by `updatedAt` descending).
|
|
23
|
+
- **`get_page`**: Retrieve full content and metadata of a specific page.
|
|
24
|
+
|
|
25
|
+
### Technical Details
|
|
26
|
+
|
|
27
|
+
- **Standard MCP over stdio**: Ships a dedicated `docmost-mcp` executable for Codex, Claude Desktop, and other MCP clients.
|
|
28
|
+
- **Automatic tool generation**: Every supported CLI command is exposed as an MCP tool with JSON schema derived from Commander options.
|
|
29
|
+
- **Automatic Markdown Conversion**: Page content is automatically converted from Docmost's internal ProseMirror/TipTap JSON format to clean Markdown for easy agent consumption. Supports all Docmost extensions including callouts, task lists, math blocks, embeds, and more.
|
|
30
|
+
- **Smart Import API**: Uses Docmost's import API to ensure clean Markdown-to-ProseMirror conversion when creating pages.
|
|
31
|
+
- **Child Preservation**: The `update_page` tool creates a new page ID but effectively simulates an in-place update by reparenting existing child pages to the new version.
|
|
32
|
+
- **Pagination Support**: Automatically handles pagination for large datasets (spaces, pages, groups).
|
|
33
|
+
- **Filtered Responses**: API responses are filtered to include only relevant information, optimizing data transfer for agents.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
### From npm (recommended)
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm install -g fantsec-docmost-cli
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### From source
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
git clone https://github.com/youngwawapro/docmost-cli.git
|
|
47
|
+
cd docmost-cli
|
|
48
|
+
npm install
|
|
49
|
+
npm run build
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Configuration
|
|
53
|
+
|
|
54
|
+
The CLI and MCP server use the same environment variables:
|
|
55
|
+
|
|
56
|
+
- `DOCMOST_API_URL`: The full URL to your Docmost API (e.g., `https://docs.example.com/api`).
|
|
57
|
+
- `DOCMOST_EMAIL`: The email address for authentication.
|
|
58
|
+
- `DOCMOST_PASSWORD`: The password for authentication.
|
|
59
|
+
|
|
60
|
+
For remote HTTP MCP mode, set `DOCMOST_API_URL` on the server and send user credentials in the bearer token:
|
|
61
|
+
|
|
62
|
+
- `Authorization: Bearer <docmost-api-token>`
|
|
63
|
+
- `Authorization: Bearer <email>:<password>`
|
|
64
|
+
|
|
65
|
+
## Usage with Codex / MCP Clients
|
|
66
|
+
|
|
67
|
+
### Codex
|
|
68
|
+
|
|
69
|
+
Add the server directly with `codex mcp add`:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
codex mcp add docmost \
|
|
73
|
+
--env DOCMOST_API_URL=http://localhost:3000/api \
|
|
74
|
+
--env DOCMOST_EMAIL=test@docmost.com \
|
|
75
|
+
--env DOCMOST_PASSWORD=test \
|
|
76
|
+
-- npx -y -p fantsec-docmost-cli docmost-mcp
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Generic MCP config
|
|
80
|
+
|
|
81
|
+
If your MCP client uses a JSON config file, point it at the dedicated `docmost-mcp` executable:
|
|
82
|
+
|
|
83
|
+
#### Using `npx`
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"mcpServers": {
|
|
88
|
+
"docmost": {
|
|
89
|
+
"command": "npx",
|
|
90
|
+
"args": ["-y", "-p", "fantsec-docmost-cli", "docmost-mcp"],
|
|
91
|
+
"env": {
|
|
92
|
+
"DOCMOST_API_URL": "http://localhost:3000/api",
|
|
93
|
+
"DOCMOST_EMAIL": "test@docmost.com",
|
|
94
|
+
"DOCMOST_PASSWORD": "test"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
#### Using local build
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"mcpServers": {
|
|
106
|
+
"docmost": {
|
|
107
|
+
"command": "node",
|
|
108
|
+
"args": ["./build/mcp.js"],
|
|
109
|
+
"env": {
|
|
110
|
+
"DOCMOST_API_URL": "http://localhost:3000/api",
|
|
111
|
+
"DOCMOST_EMAIL": "test@docmost.com",
|
|
112
|
+
"DOCMOST_PASSWORD": "test"
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Development
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
# Watch mode
|
|
123
|
+
npm run watch
|
|
124
|
+
|
|
125
|
+
# Build
|
|
126
|
+
npm run build
|
|
127
|
+
|
|
128
|
+
# Start stdio MCP server
|
|
129
|
+
npm run start:mcp
|
|
130
|
+
|
|
131
|
+
# Start HTTP MCP server
|
|
132
|
+
npm run start:mcp:http
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { AxiosError, AxiosHeaders } from "axios";
|
|
3
|
+
import { CommanderError } from "commander";
|
|
4
|
+
const { normalizeOutputFormat, resolveOptions, normalizeError, flattenForTable, toTableRows, parseCommaSeparatedIds, CliError, EXIT_CODES, isCommanderHelpExit, } = await import("../lib/cli-utils.js");
|
|
5
|
+
// ── normalizeOutputFormat ────────────────────────────────────────────
|
|
6
|
+
describe("normalizeOutputFormat", () => {
|
|
7
|
+
it.each(["json", "table", "text"])("accepts '%s'", (fmt) => {
|
|
8
|
+
expect(normalizeOutputFormat(fmt)).toBe(fmt);
|
|
9
|
+
});
|
|
10
|
+
it("is case insensitive", () => {
|
|
11
|
+
expect(normalizeOutputFormat("JSON")).toBe("json");
|
|
12
|
+
expect(normalizeOutputFormat("Table")).toBe("table");
|
|
13
|
+
expect(normalizeOutputFormat("TEXT")).toBe("text");
|
|
14
|
+
});
|
|
15
|
+
it("defaults to json when undefined", () => {
|
|
16
|
+
expect(normalizeOutputFormat(undefined)).toBe("json");
|
|
17
|
+
});
|
|
18
|
+
it("throws VALIDATION_ERROR for invalid format", () => {
|
|
19
|
+
expect(() => normalizeOutputFormat("csv")).toThrow(CliError);
|
|
20
|
+
try {
|
|
21
|
+
normalizeOutputFormat("csv");
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
expect(e.code).toBe("VALIDATION_ERROR");
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
// ── resolveOptions ───────────────────────────────────────────────────
|
|
29
|
+
describe("resolveOptions", () => {
|
|
30
|
+
const savedEnv = {};
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
for (const key of [
|
|
33
|
+
"DOCMOST_API_URL",
|
|
34
|
+
"DOCMOST_TOKEN",
|
|
35
|
+
"DOCMOST_EMAIL",
|
|
36
|
+
"DOCMOST_PASSWORD",
|
|
37
|
+
]) {
|
|
38
|
+
savedEnv[key] = process.env[key];
|
|
39
|
+
delete process.env[key];
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
for (const [key, value] of Object.entries(savedEnv)) {
|
|
44
|
+
if (value === undefined)
|
|
45
|
+
delete process.env[key];
|
|
46
|
+
else
|
|
47
|
+
process.env[key] = value;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
it("throws when apiUrl is missing", () => {
|
|
51
|
+
expect(() => resolveOptions({})).toThrow(CliError);
|
|
52
|
+
expect(() => resolveOptions({})).toThrow(/API URL is required/);
|
|
53
|
+
});
|
|
54
|
+
it("reads apiUrl from env", () => {
|
|
55
|
+
process.env.DOCMOST_API_URL = "http://env-url";
|
|
56
|
+
process.env.DOCMOST_TOKEN = "tok";
|
|
57
|
+
const opts = resolveOptions({});
|
|
58
|
+
expect(opts.apiUrl).toBe("http://env-url");
|
|
59
|
+
});
|
|
60
|
+
it("throws when requireAuth=true and no auth provided", () => {
|
|
61
|
+
expect(() => resolveOptions({ apiUrl: "http://x" })).toThrow(/Authentication is required/);
|
|
62
|
+
});
|
|
63
|
+
it("skips auth check when requireAuth=false", () => {
|
|
64
|
+
const opts = resolveOptions({ apiUrl: "http://x" }, { requireAuth: false });
|
|
65
|
+
expect(opts.apiUrl).toBe("http://x");
|
|
66
|
+
});
|
|
67
|
+
it("accepts token auth", () => {
|
|
68
|
+
const opts = resolveOptions({ apiUrl: "http://x", token: "t1" });
|
|
69
|
+
expect(opts.auth).toEqual({ token: "t1" });
|
|
70
|
+
});
|
|
71
|
+
it("accepts email/password auth", () => {
|
|
72
|
+
const opts = resolveOptions({ apiUrl: "http://x", email: "a@b", password: "p" });
|
|
73
|
+
expect(opts.auth).toEqual({ email: "a@b", password: "p" });
|
|
74
|
+
});
|
|
75
|
+
it("token takes precedence over email/password", () => {
|
|
76
|
+
const opts = resolveOptions({
|
|
77
|
+
apiUrl: "http://x",
|
|
78
|
+
token: "tok",
|
|
79
|
+
email: "a@b",
|
|
80
|
+
password: "p",
|
|
81
|
+
});
|
|
82
|
+
expect(opts.auth).toEqual({ token: "tok" });
|
|
83
|
+
});
|
|
84
|
+
it("clamps limit below 1 to 1", () => {
|
|
85
|
+
const opts = resolveOptions({ apiUrl: "http://x", token: "t", limit: "0" });
|
|
86
|
+
expect(opts.limit).toBe(1);
|
|
87
|
+
});
|
|
88
|
+
it("clamps limit above 100 to 100", () => {
|
|
89
|
+
const opts = resolveOptions({ apiUrl: "http://x", token: "t", limit: "999" });
|
|
90
|
+
expect(opts.limit).toBe(100);
|
|
91
|
+
});
|
|
92
|
+
it("defaults limit to 100", () => {
|
|
93
|
+
const opts = resolveOptions({ apiUrl: "http://x", token: "t" });
|
|
94
|
+
expect(opts.limit).toBe(100);
|
|
95
|
+
});
|
|
96
|
+
it("maxItems defaults to Infinity when 0 or not set", () => {
|
|
97
|
+
const opts1 = resolveOptions({ apiUrl: "http://x", token: "t" });
|
|
98
|
+
expect(opts1.maxItems).toBe(Infinity);
|
|
99
|
+
const opts2 = resolveOptions({ apiUrl: "http://x", token: "t", maxItems: "0" });
|
|
100
|
+
expect(opts2.maxItems).toBe(Infinity);
|
|
101
|
+
});
|
|
102
|
+
it("maxItems set to positive value", () => {
|
|
103
|
+
const opts = resolveOptions({ apiUrl: "http://x", token: "t", maxItems: "50" });
|
|
104
|
+
expect(opts.maxItems).toBe(50);
|
|
105
|
+
});
|
|
106
|
+
it("throws on invalid limit", () => {
|
|
107
|
+
expect(() => resolveOptions({ apiUrl: "http://x", token: "t", limit: "abc" })).toThrow(/Invalid --limit/);
|
|
108
|
+
});
|
|
109
|
+
it("throws on invalid maxItems", () => {
|
|
110
|
+
expect(() => resolveOptions({ apiUrl: "http://x", token: "t", maxItems: "abc" })).toThrow(/Invalid --max-items/);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
// ── normalizeError ───────────────────────────────────────────────────
|
|
114
|
+
describe("normalizeError", () => {
|
|
115
|
+
it("passes CliError through unchanged", () => {
|
|
116
|
+
const err = new CliError("NOT_FOUND", "gone");
|
|
117
|
+
expect(normalizeError(err)).toBe(err);
|
|
118
|
+
});
|
|
119
|
+
it("converts CommanderError to VALIDATION_ERROR", () => {
|
|
120
|
+
const err = new CommanderError(1, "commander.missingArgument", "missing arg");
|
|
121
|
+
const result = normalizeError(err);
|
|
122
|
+
expect(result).toBeInstanceOf(CliError);
|
|
123
|
+
expect(result.code).toBe("VALIDATION_ERROR");
|
|
124
|
+
});
|
|
125
|
+
it.each([401, 403])("converts AxiosError %d to AUTH_ERROR", (status) => {
|
|
126
|
+
const err = new AxiosError("fail", "ERR", undefined, undefined, {
|
|
127
|
+
status,
|
|
128
|
+
data: {},
|
|
129
|
+
statusText: "",
|
|
130
|
+
headers: {},
|
|
131
|
+
config: { headers: new AxiosHeaders() },
|
|
132
|
+
});
|
|
133
|
+
expect(normalizeError(err).code).toBe("AUTH_ERROR");
|
|
134
|
+
});
|
|
135
|
+
it("converts AxiosError 404 to NOT_FOUND", () => {
|
|
136
|
+
const err = new AxiosError("fail", "ERR", undefined, undefined, {
|
|
137
|
+
status: 404,
|
|
138
|
+
data: {},
|
|
139
|
+
statusText: "",
|
|
140
|
+
headers: {},
|
|
141
|
+
config: { headers: new AxiosHeaders() },
|
|
142
|
+
});
|
|
143
|
+
expect(normalizeError(err).code).toBe("NOT_FOUND");
|
|
144
|
+
});
|
|
145
|
+
it.each([400, 422])("converts AxiosError %d to VALIDATION_ERROR", (status) => {
|
|
146
|
+
const err = new AxiosError("fail", "ERR", undefined, undefined, {
|
|
147
|
+
status,
|
|
148
|
+
data: {},
|
|
149
|
+
statusText: "",
|
|
150
|
+
headers: {},
|
|
151
|
+
config: { headers: new AxiosHeaders() },
|
|
152
|
+
});
|
|
153
|
+
expect(normalizeError(err).code).toBe("VALIDATION_ERROR");
|
|
154
|
+
});
|
|
155
|
+
it("converts AxiosError without response to NETWORK_ERROR", () => {
|
|
156
|
+
const err = new AxiosError("timeout", "ECONNABORTED");
|
|
157
|
+
expect(normalizeError(err).code).toBe("NETWORK_ERROR");
|
|
158
|
+
});
|
|
159
|
+
it("converts plain Error to INTERNAL_ERROR", () => {
|
|
160
|
+
const result = normalizeError(new Error("boom"));
|
|
161
|
+
expect(result.code).toBe("INTERNAL_ERROR");
|
|
162
|
+
expect(result.message).toBe("boom");
|
|
163
|
+
});
|
|
164
|
+
it("converts unknown value to INTERNAL_ERROR", () => {
|
|
165
|
+
const result = normalizeError("string-error");
|
|
166
|
+
expect(result.code).toBe("INTERNAL_ERROR");
|
|
167
|
+
expect(result.message).toBe("Unknown error");
|
|
168
|
+
});
|
|
169
|
+
it("converts AxiosError with response message from data", () => {
|
|
170
|
+
const err = new AxiosError("generic", "ERR", undefined, undefined, {
|
|
171
|
+
status: 404,
|
|
172
|
+
data: { message: "Page not found" },
|
|
173
|
+
statusText: "",
|
|
174
|
+
headers: {},
|
|
175
|
+
config: { headers: new AxiosHeaders() },
|
|
176
|
+
});
|
|
177
|
+
const result = normalizeError(err);
|
|
178
|
+
expect(result.message).toBe("Page not found");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
// ── flattenForTable ──────────────────────────────────────────────────
|
|
182
|
+
describe("flattenForTable", () => {
|
|
183
|
+
it("wraps primitives in { value }", () => {
|
|
184
|
+
expect(flattenForTable(42)).toEqual({ value: 42 });
|
|
185
|
+
expect(flattenForTable("hello")).toEqual({ value: "hello" });
|
|
186
|
+
expect(flattenForTable(true)).toEqual({ value: true });
|
|
187
|
+
});
|
|
188
|
+
it("handles null", () => {
|
|
189
|
+
expect(flattenForTable(null)).toEqual({ value: null });
|
|
190
|
+
});
|
|
191
|
+
it("passes through primitive object values", () => {
|
|
192
|
+
expect(flattenForTable({ a: 1, b: "x", c: null, d: true })).toEqual({
|
|
193
|
+
a: 1,
|
|
194
|
+
b: "x",
|
|
195
|
+
c: null,
|
|
196
|
+
d: true,
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
it("joins primitive arrays with comma", () => {
|
|
200
|
+
expect(flattenForTable({ tags: ["a", "b", "c"] })).toEqual({
|
|
201
|
+
tags: "a, b, c",
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
it("stringifies non-primitive arrays", () => {
|
|
205
|
+
const result = flattenForTable({ items: [{ id: 1 }] });
|
|
206
|
+
expect(result.items).toBe(JSON.stringify([{ id: 1 }]));
|
|
207
|
+
});
|
|
208
|
+
it("stringifies nested objects", () => {
|
|
209
|
+
const result = flattenForTable({ nested: { x: 1 } });
|
|
210
|
+
expect(result.nested).toBe(JSON.stringify({ x: 1 }));
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
// ── toTableRows ──────────────────────────────────────────────────────
|
|
214
|
+
describe("toTableRows", () => {
|
|
215
|
+
it("handles array input", () => {
|
|
216
|
+
const rows = toTableRows([{ id: 1 }, { id: 2 }]);
|
|
217
|
+
expect(rows).toHaveLength(2);
|
|
218
|
+
expect(rows[0]).toEqual({ id: 1 });
|
|
219
|
+
});
|
|
220
|
+
it("handles object with items", () => {
|
|
221
|
+
const rows = toTableRows({ items: [{ id: 1 }] });
|
|
222
|
+
expect(rows).toHaveLength(1);
|
|
223
|
+
expect(rows[0]).toEqual({ id: 1 });
|
|
224
|
+
});
|
|
225
|
+
it("handles nested data.items", () => {
|
|
226
|
+
const rows = toTableRows({ data: { items: [{ id: 1 }] } });
|
|
227
|
+
expect(rows).toHaveLength(1);
|
|
228
|
+
expect(rows[0]).toEqual({ id: 1 });
|
|
229
|
+
});
|
|
230
|
+
it("falls back to single object row", () => {
|
|
231
|
+
const rows = toTableRows({ name: "test" });
|
|
232
|
+
expect(rows).toHaveLength(1);
|
|
233
|
+
expect(rows[0]).toEqual({ name: "test" });
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
// ── parseCommaSeparatedIds ───────────────────────────────────────────
|
|
237
|
+
describe("parseCommaSeparatedIds", () => {
|
|
238
|
+
it("parses normal CSV", () => {
|
|
239
|
+
expect(parseCommaSeparatedIds("--ids", "a,b,c")).toEqual(["a", "b", "c"]);
|
|
240
|
+
});
|
|
241
|
+
it("trims whitespace", () => {
|
|
242
|
+
expect(parseCommaSeparatedIds("--ids", " a , b , c ")).toEqual(["a", "b", "c"]);
|
|
243
|
+
});
|
|
244
|
+
it("throws on empty string", () => {
|
|
245
|
+
expect(() => parseCommaSeparatedIds("--ids", "")).toThrow(CliError);
|
|
246
|
+
expect(() => parseCommaSeparatedIds("--ids", "")).toThrow(/must not be empty/);
|
|
247
|
+
});
|
|
248
|
+
it("throws on whitespace-only input", () => {
|
|
249
|
+
expect(() => parseCommaSeparatedIds("--ids", " , , ")).toThrow(/must not be empty/);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
// ── CliError ─────────────────────────────────────────────────────────
|
|
253
|
+
describe("CliError", () => {
|
|
254
|
+
it.each(Object.entries(EXIT_CODES))("sets exitCode %d for code %s", (code, exitCode) => {
|
|
255
|
+
const err = new CliError(code, "msg");
|
|
256
|
+
expect(err.exitCode).toBe(exitCode);
|
|
257
|
+
});
|
|
258
|
+
it("stores details", () => {
|
|
259
|
+
const err = new CliError("NOT_FOUND", "gone", { id: "x" });
|
|
260
|
+
expect(err.details).toEqual({ id: "x" });
|
|
261
|
+
});
|
|
262
|
+
it("is an instance of Error", () => {
|
|
263
|
+
expect(new CliError("INTERNAL_ERROR", "x")).toBeInstanceOf(Error);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
// ── isCommanderHelpExit ──────────────────────────────────────────────
|
|
267
|
+
describe("isCommanderHelpExit", () => {
|
|
268
|
+
it("returns true for commander.helpDisplayed", () => {
|
|
269
|
+
expect(isCommanderHelpExit(new CommanderError(0, "commander.helpDisplayed", ""))).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
it("returns true for commander.help", () => {
|
|
272
|
+
expect(isCommanderHelpExit(new CommanderError(0, "commander.help", ""))).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
it("returns true for commander.version", () => {
|
|
275
|
+
expect(isCommanderHelpExit(new CommanderError(0, "commander.version", ""))).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
it("returns true for (outputHelp) message", () => {
|
|
278
|
+
expect(isCommanderHelpExit(new CommanderError(0, "other", "(outputHelp)"))).toBe(true);
|
|
279
|
+
});
|
|
280
|
+
it("returns false for other CommanderError codes", () => {
|
|
281
|
+
expect(isCommanderHelpExit(new CommanderError(1, "commander.missingArgument", "x"))).toBe(false);
|
|
282
|
+
});
|
|
283
|
+
it("returns false for non-CommanderError", () => {
|
|
284
|
+
expect(isCommanderHelpExit(new Error("help"))).toBe(false);
|
|
285
|
+
expect(isCommanderHelpExit("commander.helpDisplayed")).toBe(false);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
const postMock = vi.fn();
|
|
3
|
+
const createMock = vi.fn(() => ({
|
|
4
|
+
post: postMock,
|
|
5
|
+
defaults: {
|
|
6
|
+
headers: {
|
|
7
|
+
common: {},
|
|
8
|
+
},
|
|
9
|
+
},
|
|
10
|
+
}));
|
|
11
|
+
vi.mock("axios", () => {
|
|
12
|
+
const axiosDefault = {
|
|
13
|
+
create: createMock,
|
|
14
|
+
isAxiosError: () => false,
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
default: axiosDefault,
|
|
18
|
+
create: createMock,
|
|
19
|
+
AxiosError: class AxiosError extends Error {
|
|
20
|
+
},
|
|
21
|
+
AxiosHeaders: class AxiosHeaders {
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
const { DocmostClient } = await import("../client.js");
|
|
26
|
+
describe("DocmostClient paginateAll", () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
postMock.mockReset();
|
|
29
|
+
createMock.mockClear();
|
|
30
|
+
});
|
|
31
|
+
it("uses nextCursor when the API returns cursor pagination metadata", async () => {
|
|
32
|
+
postMock
|
|
33
|
+
.mockResolvedValueOnce({
|
|
34
|
+
data: {
|
|
35
|
+
data: {
|
|
36
|
+
items: [{ id: "1" }, { id: "2" }],
|
|
37
|
+
meta: {
|
|
38
|
+
hasNextPage: true,
|
|
39
|
+
nextCursor: "cursor-1",
|
|
40
|
+
prevCursor: null,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
.mockResolvedValueOnce({
|
|
46
|
+
data: {
|
|
47
|
+
data: {
|
|
48
|
+
items: [{ id: "3" }],
|
|
49
|
+
meta: {
|
|
50
|
+
hasNextPage: false,
|
|
51
|
+
nextCursor: null,
|
|
52
|
+
prevCursor: "cursor-1",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
const client = new DocmostClient("https://example.test", { token: "token" });
|
|
58
|
+
const result = await client.paginateAll("/pages/recent", { spaceId: "space-1" }, 2);
|
|
59
|
+
expect(postMock).toHaveBeenNthCalledWith(1, "/pages/recent", {
|
|
60
|
+
spaceId: "space-1",
|
|
61
|
+
limit: 2,
|
|
62
|
+
page: 1,
|
|
63
|
+
});
|
|
64
|
+
expect(postMock).toHaveBeenNthCalledWith(2, "/pages/recent", {
|
|
65
|
+
spaceId: "space-1",
|
|
66
|
+
limit: 2,
|
|
67
|
+
cursor: "cursor-1",
|
|
68
|
+
});
|
|
69
|
+
expect(result).toEqual({
|
|
70
|
+
items: [{ id: "1" }, { id: "2" }, { id: "3" }],
|
|
71
|
+
hasMore: false,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
it("fails fast when cursor pagination starts looping", async () => {
|
|
75
|
+
postMock
|
|
76
|
+
.mockResolvedValueOnce({
|
|
77
|
+
data: {
|
|
78
|
+
data: {
|
|
79
|
+
items: [{ id: "1" }],
|
|
80
|
+
meta: {
|
|
81
|
+
hasNextPage: true,
|
|
82
|
+
nextCursor: "cursor-1",
|
|
83
|
+
prevCursor: null,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
.mockResolvedValueOnce({
|
|
89
|
+
data: {
|
|
90
|
+
data: {
|
|
91
|
+
items: [{ id: "2" }],
|
|
92
|
+
meta: {
|
|
93
|
+
hasNextPage: true,
|
|
94
|
+
nextCursor: "cursor-1",
|
|
95
|
+
prevCursor: "cursor-1",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
const client = new DocmostClient("https://example.test", { token: "token" });
|
|
101
|
+
await expect(client.paginateAll("/pages/recent", {}, 1)).rejects.toThrow("Pagination loop detected for /pages/recent: repeated cursor:cursor-1");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { execFileSync } from "child_process";
|
|
3
|
+
import { resolve } from "path";
|
|
4
|
+
const CLI = resolve(import.meta.dirname, "../../build/index.js");
|
|
5
|
+
describe("commands discovery", () => {
|
|
6
|
+
it("returns envelope with all commands", () => {
|
|
7
|
+
const output = execFileSync("node", [CLI, "commands"], { encoding: "utf-8" });
|
|
8
|
+
const result = JSON.parse(output);
|
|
9
|
+
expect(result.ok).toBe(true);
|
|
10
|
+
expect(Array.isArray(result.data)).toBe(true);
|
|
11
|
+
expect(result.data.length).toBeGreaterThan(50);
|
|
12
|
+
expect(result.meta).toEqual({ count: result.data.length, hasMore: false });
|
|
13
|
+
});
|
|
14
|
+
it("each command has name, description, options", () => {
|
|
15
|
+
const output = execFileSync("node", [CLI, "commands"], { encoding: "utf-8" });
|
|
16
|
+
const result = JSON.parse(output);
|
|
17
|
+
for (const cmd of result.data) {
|
|
18
|
+
expect(cmd).toHaveProperty("name");
|
|
19
|
+
expect(cmd).toHaveProperty("description");
|
|
20
|
+
expect(cmd).toHaveProperty("options");
|
|
21
|
+
expect(Array.isArray(cmd.options)).toBe(true);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
it("does not include 'commands' itself", () => {
|
|
25
|
+
const output = execFileSync("node", [CLI, "commands"], { encoding: "utf-8" });
|
|
26
|
+
const result = JSON.parse(output);
|
|
27
|
+
const names = result.data.map((c) => c.name);
|
|
28
|
+
expect(names).not.toContain("commands");
|
|
29
|
+
});
|
|
30
|
+
it("options have flags and description", () => {
|
|
31
|
+
const output = execFileSync("node", [CLI, "commands"], { encoding: "utf-8" });
|
|
32
|
+
const result = JSON.parse(output);
|
|
33
|
+
const pageInfo = result.data.find((c) => c.name === "page-info");
|
|
34
|
+
expect(pageInfo).toBeDefined();
|
|
35
|
+
expect(pageInfo.options.length).toBeGreaterThan(0);
|
|
36
|
+
const pageIdOpt = pageInfo.options.find((o) => o.flags.includes("--page-id"));
|
|
37
|
+
expect(pageIdOpt).toBeDefined();
|
|
38
|
+
expect(pageIdOpt.required).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
// Must mock console before importing the module
|
|
3
|
+
const mockLog = vi.fn();
|
|
4
|
+
const mockError = vi.fn();
|
|
5
|
+
vi.stubGlobal("console", { ...console, log: mockLog, error: mockError, table: console.table });
|
|
6
|
+
const { printResult, printError, CliError } = await import("../lib/cli-utils.js");
|
|
7
|
+
describe("printResult envelope", () => {
|
|
8
|
+
const baseOpts = {
|
|
9
|
+
apiUrl: "http://localhost",
|
|
10
|
+
format: "json",
|
|
11
|
+
quiet: false,
|
|
12
|
+
limit: 100,
|
|
13
|
+
maxItems: Infinity,
|
|
14
|
+
auth: { token: "test" },
|
|
15
|
+
};
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mockLog.mockClear();
|
|
18
|
+
mockError.mockClear();
|
|
19
|
+
});
|
|
20
|
+
it("wraps single object in { ok: true, data }", () => {
|
|
21
|
+
const data = { id: "abc", title: "Test" };
|
|
22
|
+
printResult(data, baseOpts);
|
|
23
|
+
const output = JSON.parse(mockLog.mock.calls[0][0]);
|
|
24
|
+
expect(output).toEqual({ ok: true, data: { id: "abc", title: "Test" } });
|
|
25
|
+
expect(output.meta).toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
it("wraps array in { ok: true, data, meta }", () => {
|
|
28
|
+
const data = [{ id: "1" }, { id: "2" }];
|
|
29
|
+
printResult(data, baseOpts, { allowTable: true });
|
|
30
|
+
const output = JSON.parse(mockLog.mock.calls[0][0]);
|
|
31
|
+
expect(output.ok).toBe(true);
|
|
32
|
+
expect(output.data).toHaveLength(2);
|
|
33
|
+
expect(output.meta).toEqual({ count: 2, hasMore: false });
|
|
34
|
+
});
|
|
35
|
+
it("passes hasMore from options to meta", () => {
|
|
36
|
+
const data = [{ id: "1" }];
|
|
37
|
+
printResult(data, baseOpts, { allowTable: true, hasMore: true });
|
|
38
|
+
const output = JSON.parse(mockLog.mock.calls[0][0]);
|
|
39
|
+
expect(output.meta).toEqual({ count: 1, hasMore: true });
|
|
40
|
+
});
|
|
41
|
+
it("wraps empty array with meta count 0", () => {
|
|
42
|
+
printResult([], baseOpts, { allowTable: true });
|
|
43
|
+
const output = JSON.parse(mockLog.mock.calls[0][0]);
|
|
44
|
+
expect(output).toEqual({ ok: true, data: [], meta: { count: 0, hasMore: false } });
|
|
45
|
+
});
|
|
46
|
+
it("does not output when quiet", () => {
|
|
47
|
+
printResult({ id: "1" }, { ...baseOpts, quiet: true });
|
|
48
|
+
expect(mockLog).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
it("table format is not wrapped in envelope", () => {
|
|
51
|
+
const data = [{ id: "1", name: "Test" }];
|
|
52
|
+
printResult(data, { ...baseOpts, format: "table" }, { allowTable: true });
|
|
53
|
+
// table uses console.table, not console.log with JSON
|
|
54
|
+
expect(mockLog).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
it("text format is not wrapped in envelope", () => {
|
|
57
|
+
const mockWrite = vi.fn();
|
|
58
|
+
const original = process.stdout.write;
|
|
59
|
+
process.stdout.write = mockWrite;
|
|
60
|
+
printResult("hello", { ...baseOpts, format: "text" }, {
|
|
61
|
+
textExtractor: (d) => d,
|
|
62
|
+
});
|
|
63
|
+
process.stdout.write = original;
|
|
64
|
+
expect(mockLog).not.toHaveBeenCalled();
|
|
65
|
+
expect(mockWrite.mock.calls[0][0]).toBe("hello");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe("printError envelope", () => {
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
mockLog.mockClear();
|
|
71
|
+
mockError.mockClear();
|
|
72
|
+
});
|
|
73
|
+
it("wraps error in { ok: false, error } for json format", () => {
|
|
74
|
+
const error = new CliError("NOT_FOUND", "Page not found", { pageId: "abc" });
|
|
75
|
+
printError(error, "json");
|
|
76
|
+
const output = JSON.parse(mockError.mock.calls[0][0]);
|
|
77
|
+
expect(output).toEqual({
|
|
78
|
+
ok: false,
|
|
79
|
+
error: {
|
|
80
|
+
code: "NOT_FOUND",
|
|
81
|
+
message: "Page not found",
|
|
82
|
+
details: { pageId: "abc" },
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
it("uses plain text for non-json format", () => {
|
|
87
|
+
const error = new CliError("AUTH_ERROR", "Unauthorized");
|
|
88
|
+
printError(error, "text");
|
|
89
|
+
expect(mockError.mock.calls[0][0]).toBe("Error [AUTH_ERROR]: Unauthorized");
|
|
90
|
+
});
|
|
91
|
+
});
|