mdpockla 0.1.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 +72 -0
- package/bin/mdpockla.mjs +7 -0
- package/package.json +47 -0
- package/src/commands/auth.mjs +80 -0
- package/src/commands/note.mjs +178 -0
- package/src/index.mjs +119 -0
- package/src/lib/args.mjs +49 -0
- package/src/lib/config.mjs +59 -0
- package/src/lib/http.mjs +75 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pockla Ltd
|
|
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,72 @@
|
|
|
1
|
+
## mdpockla
|
|
2
|
+
|
|
3
|
+
CLI for [md.pockla.com](https://md.pockla.com). Save and edit markdown
|
|
4
|
+
or HTML notes from your shell, CI pipelines, or agent runtimes that
|
|
5
|
+
can't speak MCP.
|
|
6
|
+
|
|
7
|
+
### Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install -g mdpockla
|
|
11
|
+
mdpockla login
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Or without installing:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npx mdpockla login
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Quick start
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
# Create a note and capture its URL.
|
|
24
|
+
URL=$(mdpockla note create ./post.md --public)
|
|
25
|
+
open "$URL"
|
|
26
|
+
|
|
27
|
+
# Edit an existing note.
|
|
28
|
+
mdpockla note edit "$URL" --content ./post.v2.md
|
|
29
|
+
|
|
30
|
+
# Pipe a note's body somewhere else.
|
|
31
|
+
mdpockla note get https://md.pockla.com/n/abc12345 | pandoc -t docx -o post.docx
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Commands
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
mdpockla login | logout | whoami
|
|
38
|
+
mdpockla note create <file> [--title T] [--public] [--tag NAME ...] [--json]
|
|
39
|
+
mdpockla note edit <id|url> [--content FILE] [--title T] [--visibility public|private] [--tag NAME ...]
|
|
40
|
+
mdpockla note get <id|url> [--output FILE] [--json]
|
|
41
|
+
mdpockla note list [--mine|--shared] [--query Q] [--limit N] [--json]
|
|
42
|
+
mdpockla note delete <id|url>
|
|
43
|
+
mdpockla note share <id|url> --visibility public|private
|
|
44
|
+
mdpockla note collaborator add|remove <id|url> <email>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Configuration
|
|
48
|
+
|
|
49
|
+
| Env var | Purpose |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `MDPOCKLA_URL` | Override the API base URL (default `https://md.pockla.com`). |
|
|
52
|
+
| `MDPOCKLA_TOKEN` | Use a specific token instead of the saved login. Useful for CI. |
|
|
53
|
+
| `NO_COLOR` | Disable colorised output. |
|
|
54
|
+
|
|
55
|
+
Credentials are saved at `~/.mdpockla/config.json` (mode 0600).
|
|
56
|
+
|
|
57
|
+
### Headless / CI
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
export MDPOCKLA_TOKEN=mdp_pat_...
|
|
61
|
+
mdpockla note create ./CHANGELOG.md --title "Release $TAG" --public
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Issue a long-lived PAT from the dashboard at
|
|
65
|
+
[md.pockla.com/settings/agents](https://md.pockla.com/settings/agents).
|
|
66
|
+
|
|
67
|
+
### How it talks to md pockla
|
|
68
|
+
|
|
69
|
+
Every command is a thin wrapper around the HTTP API at `/api/v1/*` —
|
|
70
|
+
the same endpoints the [remote MCP server](https://mcp.md.pockla.com)
|
|
71
|
+
uses. If you outgrow this CLI, the MCP route works in Claude, Cursor,
|
|
72
|
+
Codex, and ChatGPT Custom Connectors with one-click install.
|
package/bin/mdpockla.mjs
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mdpockla",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for md.pockla.com — save and edit markdown notes from your shell or CI. Pairs with the md pockla MCP server for agent-native sharing.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mdpockla": "./bin/mdpockla.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"start": "node bin/mdpockla.mjs"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"markdown",
|
|
23
|
+
"md-pockla",
|
|
24
|
+
"mdpockla",
|
|
25
|
+
"pockla",
|
|
26
|
+
"mcp",
|
|
27
|
+
"agent",
|
|
28
|
+
"notes",
|
|
29
|
+
"share",
|
|
30
|
+
"cli"
|
|
31
|
+
],
|
|
32
|
+
"author": "Jeremy Dsouza <jeremy@pockla.com>",
|
|
33
|
+
"homepage": "https://md.pockla.com",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/jeremydsz/md-viewer.git",
|
|
37
|
+
"directory": "cli"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/jeremydsz/md-viewer/issues"
|
|
41
|
+
},
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public",
|
|
45
|
+
"registry": "https://registry.npmjs.org/"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { setTimeout as wait } from "node:timers/promises";
|
|
2
|
+
import { saveConfig, clearConfig } from "../lib/config.mjs";
|
|
3
|
+
import { api, rawFetch } from "../lib/http.mjs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Device authorization flow (RFC 8628):
|
|
7
|
+
*
|
|
8
|
+
* 1. POST /api/oauth/device_authorization -> { device_code, user_code, verification_uri, interval, expires_in }
|
|
9
|
+
* 2. Print the URL + user_code to the user.
|
|
10
|
+
* 3. Poll POST /api/oauth/token with the device_code until the user
|
|
11
|
+
* approves on the website. Then store the returned PAT.
|
|
12
|
+
*/
|
|
13
|
+
export async function login() {
|
|
14
|
+
const startRes = await rawFetch("/api/oauth/device_authorization", {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
17
|
+
body: "client_id=mdpockla-cli&scope=notes:read%20notes:write",
|
|
18
|
+
});
|
|
19
|
+
if (!startRes.ok) {
|
|
20
|
+
throw exit(`Could not start login: HTTP ${startRes.status}`);
|
|
21
|
+
}
|
|
22
|
+
const start = await startRes.json();
|
|
23
|
+
const { device_code, user_code, verification_uri, interval, expires_in } = start;
|
|
24
|
+
const deadline = Date.now() + expires_in * 1000;
|
|
25
|
+
|
|
26
|
+
process.stdout.write(`Visit ${verification_uri}?code=${user_code}\n`);
|
|
27
|
+
process.stdout.write(`Or open ${verification_uri} and enter: ${user_code}\n`);
|
|
28
|
+
process.stdout.write("Waiting for approval...\n");
|
|
29
|
+
|
|
30
|
+
const pollIntervalMs = Math.max(1, interval || 3) * 1000;
|
|
31
|
+
while (Date.now() < deadline) {
|
|
32
|
+
await wait(pollIntervalMs);
|
|
33
|
+
const tokenRes = await rawFetch("/api/oauth/token", {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
36
|
+
body: new URLSearchParams({
|
|
37
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
38
|
+
device_code,
|
|
39
|
+
client_id: "mdpockla-cli",
|
|
40
|
+
}).toString(),
|
|
41
|
+
});
|
|
42
|
+
if (tokenRes.ok) {
|
|
43
|
+
const tokens = await tokenRes.json();
|
|
44
|
+
await saveConfig({ token: tokens.access_token });
|
|
45
|
+
const me = await api("GET", "/api/v1/whoami");
|
|
46
|
+
process.stdout.write(`Logged in as ${me.email}.\n`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (tokenRes.status === 400) {
|
|
50
|
+
const body = await tokenRes.json().catch(() => ({}));
|
|
51
|
+
if (body.error === "authorization_pending") continue;
|
|
52
|
+
if (body.error === "slow_down") {
|
|
53
|
+
await wait(pollIntervalMs);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (body.error === "expired_token") {
|
|
57
|
+
throw exit("Login timed out. Run `mdpockla login` again.");
|
|
58
|
+
}
|
|
59
|
+
throw exit(body?.error?.message ?? body.error ?? "Login failed");
|
|
60
|
+
}
|
|
61
|
+
throw exit(`Unexpected token response: HTTP ${tokenRes.status}`);
|
|
62
|
+
}
|
|
63
|
+
throw exit("Login timed out. Run `mdpockla login` again.");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function logout() {
|
|
67
|
+
await clearConfig();
|
|
68
|
+
process.stdout.write("Logged out.\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function whoami() {
|
|
72
|
+
const me = await api("GET", "/api/v1/whoami");
|
|
73
|
+
process.stdout.write(`${me.email} (${me.user_id})\n`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function exit(message, code = 1) {
|
|
77
|
+
const err = new Error(message);
|
|
78
|
+
err.exitCode = code;
|
|
79
|
+
return err;
|
|
80
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { api } from "../lib/http.mjs";
|
|
4
|
+
import { parseArgs } from "../lib/args.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Each command does its own argv parsing (via parseArgs) so the
|
|
8
|
+
* --flag set is documented at the call site. The bodies are
|
|
9
|
+
* intentionally one-liners over `api()` — the HTTP API is the source
|
|
10
|
+
* of truth.
|
|
11
|
+
*
|
|
12
|
+
* Output convention: human-readable to stdout, share URL on the last
|
|
13
|
+
* line so piping into another tool yields the URL alone.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export async function noteCreate(argv) {
|
|
17
|
+
const args = parseArgs(argv, {
|
|
18
|
+
title: { type: "string" },
|
|
19
|
+
public: { type: "boolean" },
|
|
20
|
+
tag: { type: "string", repeatable: true },
|
|
21
|
+
json: { type: "boolean" },
|
|
22
|
+
});
|
|
23
|
+
const file = args._[0];
|
|
24
|
+
if (!file) throw exit("Usage: mdpockla note create <file>");
|
|
25
|
+
const abs = path.resolve(file);
|
|
26
|
+
const content = await fs.readFile(abs, "utf8");
|
|
27
|
+
const ext = path.extname(abs).toLowerCase();
|
|
28
|
+
const content_type = ext === ".html" || ext === ".htm" ? "html" : "markdown";
|
|
29
|
+
|
|
30
|
+
const note = await api("POST", "/api/v1/notes", {
|
|
31
|
+
body: {
|
|
32
|
+
title: args.title,
|
|
33
|
+
content,
|
|
34
|
+
content_type,
|
|
35
|
+
visibility: args.public ? "public" : "private",
|
|
36
|
+
tags: args.tag.length ? args.tag : undefined,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
if (args.json) process.stdout.write(JSON.stringify(note, null, 2) + "\n");
|
|
40
|
+
else process.stdout.write(note.url + "\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function noteEdit(argv) {
|
|
44
|
+
const args = parseArgs(argv, {
|
|
45
|
+
content: { type: "string" },
|
|
46
|
+
title: { type: "string" },
|
|
47
|
+
visibility: { type: "string" },
|
|
48
|
+
tag: { type: "string", repeatable: true },
|
|
49
|
+
json: { type: "boolean" },
|
|
50
|
+
});
|
|
51
|
+
const target = args._[0];
|
|
52
|
+
if (!target) throw exit("Usage: mdpockla note edit <id|url> [...]");
|
|
53
|
+
const body = {};
|
|
54
|
+
if (args.title) body.title = args.title;
|
|
55
|
+
if (args.visibility) body.visibility = args.visibility;
|
|
56
|
+
if (args.tag.length) body.tags = args.tag;
|
|
57
|
+
if (args.content) {
|
|
58
|
+
const abs = path.resolve(args.content);
|
|
59
|
+
body.content = await fs.readFile(abs, "utf8");
|
|
60
|
+
const ext = path.extname(abs).toLowerCase();
|
|
61
|
+
body.content_type = ext === ".html" || ext === ".htm" ? "html" : "markdown";
|
|
62
|
+
}
|
|
63
|
+
if (Object.keys(body).length === 0) {
|
|
64
|
+
throw exit("Nothing to update — pass at least one of --content, --title, --visibility, --tag");
|
|
65
|
+
}
|
|
66
|
+
const note = await api("PATCH", `/api/v1/notes/${encodeURIComponent(target)}`, {
|
|
67
|
+
body,
|
|
68
|
+
});
|
|
69
|
+
if (args.json) process.stdout.write(JSON.stringify(note, null, 2) + "\n");
|
|
70
|
+
else process.stdout.write(note.url + "\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function noteGet(argv) {
|
|
74
|
+
const args = parseArgs(argv, {
|
|
75
|
+
output: { type: "string" },
|
|
76
|
+
json: { type: "boolean" },
|
|
77
|
+
});
|
|
78
|
+
const target = args._[0];
|
|
79
|
+
if (!target) throw exit("Usage: mdpockla note get <id|url> [--output FILE]");
|
|
80
|
+
const note = await api("GET", `/api/v1/notes/${encodeURIComponent(target)}`);
|
|
81
|
+
if (args.json) {
|
|
82
|
+
process.stdout.write(JSON.stringify(note, null, 2) + "\n");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (args.output) {
|
|
86
|
+
await fs.writeFile(path.resolve(args.output), note.content);
|
|
87
|
+
process.stdout.write(`Wrote ${args.output}\n`);
|
|
88
|
+
} else {
|
|
89
|
+
process.stdout.write(note.content);
|
|
90
|
+
if (!note.content.endsWith("\n")) process.stdout.write("\n");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function noteList(argv) {
|
|
95
|
+
const args = parseArgs(argv, {
|
|
96
|
+
mine: { type: "boolean" },
|
|
97
|
+
shared: { type: "boolean" },
|
|
98
|
+
query: { type: "string" },
|
|
99
|
+
limit: { type: "string" },
|
|
100
|
+
json: { type: "boolean" },
|
|
101
|
+
});
|
|
102
|
+
const filter = args.mine ? "mine" : args.shared ? "shared" : "all";
|
|
103
|
+
const limit = args.limit ? Number.parseInt(args.limit, 10) : undefined;
|
|
104
|
+
const res = await api("GET", "/api/v1/notes", {
|
|
105
|
+
query: { filter, query: args.query, limit },
|
|
106
|
+
});
|
|
107
|
+
if (args.json) {
|
|
108
|
+
process.stdout.write(JSON.stringify(res, null, 2) + "\n");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (res.notes.length === 0) {
|
|
112
|
+
process.stdout.write("No notes.\n");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
for (const n of res.notes) {
|
|
116
|
+
process.stdout.write(
|
|
117
|
+
`${n.url} ${n.visibility.padEnd(7)} ${truncate(n.title, 60)}\n`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function noteDelete(argv) {
|
|
123
|
+
const target = argv[0];
|
|
124
|
+
if (!target) throw exit("Usage: mdpockla note delete <id|url>");
|
|
125
|
+
await api("DELETE", `/api/v1/notes/${encodeURIComponent(target)}`);
|
|
126
|
+
process.stdout.write("Deleted.\n");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function noteShare(argv) {
|
|
130
|
+
const args = parseArgs(argv, {
|
|
131
|
+
visibility: { type: "string" },
|
|
132
|
+
});
|
|
133
|
+
const target = args._[0];
|
|
134
|
+
if (!target || !args.visibility) {
|
|
135
|
+
throw exit("Usage: mdpockla note share <id|url> --visibility public|private");
|
|
136
|
+
}
|
|
137
|
+
const note = await api("POST", `/api/v1/notes/${encodeURIComponent(target)}/share`, {
|
|
138
|
+
body: { visibility: args.visibility },
|
|
139
|
+
});
|
|
140
|
+
process.stdout.write(note.url + "\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function noteCollaboratorAdd(argv) {
|
|
144
|
+
const [target, email] = argv;
|
|
145
|
+
if (!target || !email) {
|
|
146
|
+
throw exit("Usage: mdpockla note collaborator add <id|url> <email>");
|
|
147
|
+
}
|
|
148
|
+
const note = await api(
|
|
149
|
+
"POST",
|
|
150
|
+
`/api/v1/notes/${encodeURIComponent(target)}/collaborators`,
|
|
151
|
+
{ body: { email } }
|
|
152
|
+
);
|
|
153
|
+
process.stdout.write(note.url + "\n");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function noteCollaboratorRemove(argv) {
|
|
157
|
+
const [target, email] = argv;
|
|
158
|
+
if (!target || !email) {
|
|
159
|
+
throw exit("Usage: mdpockla note collaborator remove <id|url> <email>");
|
|
160
|
+
}
|
|
161
|
+
const note = await api(
|
|
162
|
+
"DELETE",
|
|
163
|
+
`/api/v1/notes/${encodeURIComponent(target)}/collaborators`,
|
|
164
|
+
{ query: { email } }
|
|
165
|
+
);
|
|
166
|
+
process.stdout.write(note.url + "\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function truncate(s, n) {
|
|
170
|
+
if (!s) return "";
|
|
171
|
+
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function exit(message, code = 2) {
|
|
175
|
+
const err = new Error(message);
|
|
176
|
+
err.exitCode = code;
|
|
177
|
+
return err;
|
|
178
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { login, logout, whoami } from "./commands/auth.mjs";
|
|
2
|
+
import {
|
|
3
|
+
noteCreate,
|
|
4
|
+
noteEdit,
|
|
5
|
+
noteGet,
|
|
6
|
+
noteList,
|
|
7
|
+
noteDelete,
|
|
8
|
+
noteShare,
|
|
9
|
+
noteCollaboratorAdd,
|
|
10
|
+
noteCollaboratorRemove,
|
|
11
|
+
} from "./commands/note.mjs";
|
|
12
|
+
|
|
13
|
+
const HELP = `mdpockla — md.pockla.com from your shell.
|
|
14
|
+
|
|
15
|
+
Authentication
|
|
16
|
+
mdpockla login Start device-flow login.
|
|
17
|
+
mdpockla logout Forget the local token.
|
|
18
|
+
mdpockla whoami Identify the active session.
|
|
19
|
+
|
|
20
|
+
Notes
|
|
21
|
+
mdpockla note create <file> Create from a markdown or .html file.
|
|
22
|
+
--title <s> Title (defaults to first heading).
|
|
23
|
+
--public Make the share link public.
|
|
24
|
+
--tag <name> ... Add a tag (repeatable).
|
|
25
|
+
--json Print the full note JSON.
|
|
26
|
+
|
|
27
|
+
mdpockla note edit <id|url> Update an existing note.
|
|
28
|
+
--content <file> Replace body from file.
|
|
29
|
+
--title <s> Set title.
|
|
30
|
+
--visibility <public|private> Change visibility.
|
|
31
|
+
--tag <name> ... Replace tag set.
|
|
32
|
+
|
|
33
|
+
mdpockla note get <id|url> Fetch a note.
|
|
34
|
+
--output <file> Write body to a file (else stdout).
|
|
35
|
+
|
|
36
|
+
mdpockla note list List your notes.
|
|
37
|
+
--mine | --shared Filter set.
|
|
38
|
+
--json Print JSON instead of a table.
|
|
39
|
+
|
|
40
|
+
mdpockla note delete <id|url> Delete (owner only, no undo).
|
|
41
|
+
|
|
42
|
+
mdpockla note share <id|url> Change visibility.
|
|
43
|
+
--visibility <public|private>
|
|
44
|
+
|
|
45
|
+
mdpockla note collaborator add <id|url> <email>
|
|
46
|
+
mdpockla note collaborator remove <id|url> <email>
|
|
47
|
+
|
|
48
|
+
Environment
|
|
49
|
+
MDPOCKLA_URL Override the API base URL.
|
|
50
|
+
MDPOCKLA_TOKEN Use this token instead of the saved one.
|
|
51
|
+
NO_COLOR Disable colorised output.
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
export async function run(argv) {
|
|
55
|
+
if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") {
|
|
56
|
+
process.stdout.write(HELP);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (argv[0] === "--version" || argv[0] === "-V") {
|
|
60
|
+
process.stdout.write("0.1.0\n");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const [cmd, ...rest] = argv;
|
|
64
|
+
switch (cmd) {
|
|
65
|
+
case "login":
|
|
66
|
+
return login(rest);
|
|
67
|
+
case "logout":
|
|
68
|
+
return logout();
|
|
69
|
+
case "whoami":
|
|
70
|
+
return whoami();
|
|
71
|
+
case "note":
|
|
72
|
+
return dispatchNote(rest);
|
|
73
|
+
default:
|
|
74
|
+
throw exit(`Unknown command: ${cmd}\n\n${HELP}`, 2);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function dispatchNote(argv) {
|
|
79
|
+
const [sub, ...rest] = argv;
|
|
80
|
+
switch (sub) {
|
|
81
|
+
case "create":
|
|
82
|
+
return noteCreate(rest);
|
|
83
|
+
case "edit":
|
|
84
|
+
return noteEdit(rest);
|
|
85
|
+
case "get":
|
|
86
|
+
return noteGet(rest);
|
|
87
|
+
case "list":
|
|
88
|
+
return noteList(rest);
|
|
89
|
+
case "delete":
|
|
90
|
+
return noteDelete(rest);
|
|
91
|
+
case "share":
|
|
92
|
+
return noteShare(rest);
|
|
93
|
+
case "collaborator":
|
|
94
|
+
return dispatchCollaborator(rest);
|
|
95
|
+
default:
|
|
96
|
+
throw exit(`Unknown note subcommand: ${sub}\n\n${HELP}`, 2);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function dispatchCollaborator(argv) {
|
|
101
|
+
const [sub, ...rest] = argv;
|
|
102
|
+
switch (sub) {
|
|
103
|
+
case "add":
|
|
104
|
+
return noteCollaboratorAdd(rest);
|
|
105
|
+
case "remove":
|
|
106
|
+
return noteCollaboratorRemove(rest);
|
|
107
|
+
default:
|
|
108
|
+
throw exit(
|
|
109
|
+
`Unknown collaborator subcommand: ${sub}\n\n${HELP}`,
|
|
110
|
+
2
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function exit(message, code = 1) {
|
|
116
|
+
const err = new Error(message);
|
|
117
|
+
err.exitCode = code;
|
|
118
|
+
return err;
|
|
119
|
+
}
|
package/src/lib/args.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny argv parser. Matches the conventions in the help text:
|
|
3
|
+
*
|
|
4
|
+
* --flag bool true
|
|
5
|
+
* --opt value string
|
|
6
|
+
* --opt=value string
|
|
7
|
+
* --tag x --tag y string[] (repeatable)
|
|
8
|
+
* <positional> positional
|
|
9
|
+
*
|
|
10
|
+
* Anything not declared in `schema` is rejected so typos surface fast.
|
|
11
|
+
*/
|
|
12
|
+
export function parseArgs(argv, schema) {
|
|
13
|
+
const out = { _: [] };
|
|
14
|
+
for (const flag of Object.keys(schema)) {
|
|
15
|
+
if (schema[flag].repeatable) out[flag] = [];
|
|
16
|
+
else if (schema[flag].type === "boolean") out[flag] = false;
|
|
17
|
+
else out[flag] = undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < argv.length; i++) {
|
|
21
|
+
const token = argv[i];
|
|
22
|
+
if (!token.startsWith("--")) {
|
|
23
|
+
out._.push(token);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const eqIdx = token.indexOf("=");
|
|
27
|
+
const name = eqIdx > 0 ? token.slice(2, eqIdx) : token.slice(2);
|
|
28
|
+
const inline = eqIdx > 0 ? token.slice(eqIdx + 1) : undefined;
|
|
29
|
+
const spec = schema[name];
|
|
30
|
+
if (!spec) {
|
|
31
|
+
const err = new Error(`Unknown flag: --${name}`);
|
|
32
|
+
err.exitCode = 2;
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
if (spec.type === "boolean") {
|
|
36
|
+
out[name] = true;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const value = inline ?? argv[++i];
|
|
40
|
+
if (value === undefined) {
|
|
41
|
+
const err = new Error(`--${name} requires a value`);
|
|
42
|
+
err.exitCode = 2;
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
if (spec.repeatable) out[name].push(value);
|
|
46
|
+
else out[name] = value;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = path.join(homedir(), ".mdpockla");
|
|
6
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
7
|
+
const DEFAULT_URL = "https://md.pockla.com";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Local credential storage. We stash the PAT plus the chosen API base
|
|
11
|
+
* URL in ~/.mdpockla/config.json with 0600 perms.
|
|
12
|
+
*
|
|
13
|
+
* The plan calls for keychain storage (keytar / DPAPI), and that's the
|
|
14
|
+
* right move for a v2 once we accept a native dependency. For now a
|
|
15
|
+
* mode-protected file gets us the same threat-model coverage on a
|
|
16
|
+
* single-user developer machine without a Node-gyp build hop.
|
|
17
|
+
*/
|
|
18
|
+
export async function loadConfig() {
|
|
19
|
+
try {
|
|
20
|
+
const buf = await fs.readFile(CONFIG_FILE, "utf8");
|
|
21
|
+
return JSON.parse(buf);
|
|
22
|
+
} catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function saveConfig(partial) {
|
|
28
|
+
const current = await loadConfig();
|
|
29
|
+
const next = { ...current, ...partial };
|
|
30
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
31
|
+
await fs.writeFile(CONFIG_FILE, JSON.stringify(next, null, 2), {
|
|
32
|
+
mode: 0o600,
|
|
33
|
+
});
|
|
34
|
+
// mkdir + writeFile don't guarantee the directory itself is 0700.
|
|
35
|
+
try {
|
|
36
|
+
await fs.chmod(CONFIG_DIR, 0o700);
|
|
37
|
+
} catch {
|
|
38
|
+
/* best effort */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function clearConfig() {
|
|
43
|
+
try {
|
|
44
|
+
await fs.unlink(CONFIG_FILE);
|
|
45
|
+
} catch {
|
|
46
|
+
/* not present */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resolveApiUrl(saved) {
|
|
51
|
+
return (process.env.MDPOCKLA_URL ?? saved.apiUrl ?? DEFAULT_URL).replace(
|
|
52
|
+
/\/$/,
|
|
53
|
+
""
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function resolveToken(saved) {
|
|
58
|
+
return process.env.MDPOCKLA_TOKEN ?? saved.token ?? null;
|
|
59
|
+
}
|
package/src/lib/http.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { loadConfig, resolveApiUrl, resolveToken } from "./config.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Thin wrapper around fetch() that injects the bearer token and
|
|
5
|
+
* normalises the project's error envelope into `Error.message`.
|
|
6
|
+
*
|
|
7
|
+
* All call sites should go through this — the API server is the source
|
|
8
|
+
* of truth, the CLI is a UI layer over its HTTP responses.
|
|
9
|
+
*/
|
|
10
|
+
export async function api(method, pathname, { body, query, requireAuth = true } = {}) {
|
|
11
|
+
const saved = await loadConfig();
|
|
12
|
+
const base = resolveApiUrl(saved);
|
|
13
|
+
const token = resolveToken(saved);
|
|
14
|
+
if (requireAuth && !token) {
|
|
15
|
+
const err = new Error("Not logged in. Run `mdpockla login` first.");
|
|
16
|
+
err.exitCode = 4;
|
|
17
|
+
throw err;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const url = new URL(pathname.startsWith("/") ? pathname : `/${pathname}`, base);
|
|
21
|
+
if (query) {
|
|
22
|
+
for (const [k, v] of Object.entries(query)) {
|
|
23
|
+
if (v == null) continue;
|
|
24
|
+
url.searchParams.set(k, String(v));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const init = {
|
|
29
|
+
method,
|
|
30
|
+
headers: {
|
|
31
|
+
Accept: "application/json",
|
|
32
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
33
|
+
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
34
|
+
},
|
|
35
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
let res;
|
|
39
|
+
try {
|
|
40
|
+
res = await fetch(url, init);
|
|
41
|
+
} catch (cause) {
|
|
42
|
+
const err = new Error(`Network error: ${cause.message ?? cause}`);
|
|
43
|
+
err.cause = cause;
|
|
44
|
+
err.exitCode = 5;
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (res.status === 204) return null;
|
|
49
|
+
const text = await res.text();
|
|
50
|
+
let parsed = null;
|
|
51
|
+
if (text) {
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(text);
|
|
54
|
+
} catch {
|
|
55
|
+
parsed = { raw: text };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
const message =
|
|
60
|
+
parsed?.error?.message ??
|
|
61
|
+
parsed?.error_description ??
|
|
62
|
+
parsed?.error ??
|
|
63
|
+
`HTTP ${res.status}`;
|
|
64
|
+
const err = new Error(message);
|
|
65
|
+
err.exitCode = res.status === 401 ? 4 : 1;
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
return parsed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function rawFetch(pathname, init) {
|
|
72
|
+
const saved = await loadConfig();
|
|
73
|
+
const base = resolveApiUrl(saved);
|
|
74
|
+
return fetch(new URL(pathname, base), init);
|
|
75
|
+
}
|