mdpockla 0.1.1 → 0.3.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/src/index.mjs CHANGED
@@ -1,119 +1,162 @@
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
- }
1
+ import { readFileSync } from "node:fs";
2
+ import { login, logout, whoami } from "./commands/auth.mjs";
3
+ import {
4
+ noteCreate,
5
+ noteEdit,
6
+ noteGet,
7
+ noteList,
8
+ noteDelete,
9
+ noteShare,
10
+ noteCollaboratorAdd,
11
+ noteCollaboratorRemove,
12
+ noteVersionSave,
13
+ noteVersionList,
14
+ noteVersionGet,
15
+ noteVersionRestore,
16
+ noteVersionDelete,
17
+ } from "./commands/note.mjs";
18
+
19
+ // Single source of truth for the version — read from package.json so
20
+ // `--version` can never drift from the published package again.
21
+ const VERSION = JSON.parse(
22
+ readFileSync(new URL("../package.json", import.meta.url), "utf8")
23
+ ).version;
24
+
25
+ const HELP = `mdpockla — md.pockla.com from your shell.
26
+
27
+ Authentication
28
+ mdpockla login Start device-flow login.
29
+ mdpockla logout Forget the local token.
30
+ mdpockla whoami Identify the active session.
31
+
32
+ Notes
33
+ mdpockla note create <file> Create from a markdown or .html file.
34
+ --title <s> Title (defaults to first heading).
35
+ --public Make the share link public.
36
+ --tag <name> ... Add a tag (repeatable).
37
+ --json Print the full note JSON.
38
+
39
+ mdpockla note edit <id|url> Update an existing note.
40
+ --content <file> Replace body from file.
41
+ --title <s> Set title.
42
+ --visibility <public|private> Change visibility.
43
+ --tag <name> ... Replace tag set.
44
+ --save-version Save the edit as a new version
45
+ (baselines the original first).
46
+ --label <s> Label for the new version.
47
+
48
+ mdpockla note get <id|url> Fetch a note.
49
+ --output <file> Write body to a file (else stdout).
50
+ --version <n> Fetch a specific historical version.
51
+
52
+ mdpockla note list List your notes.
53
+ --mine | --shared Filter set.
54
+ --json Print JSON instead of a table.
55
+
56
+ mdpockla note delete <id|url> Delete (owner only, no undo).
57
+
58
+ mdpockla note share <id|url> Change visibility.
59
+ --visibility <public|private>
60
+
61
+ mdpockla note collaborator add <id|url> <email>
62
+ mdpockla note collaborator remove <id|url> <email>
63
+
64
+ Versions
65
+ mdpockla note version save <id|url> [--label TEXT] [--json]
66
+ mdpockla note version list <id|url> [--json]
67
+ mdpockla note version get <id|url> <version> [--output FILE] [--json]
68
+ mdpockla note version restore <id|url> <version> [--json]
69
+ mdpockla note version delete <id|url> <version>
70
+
71
+ Environment
72
+ MDPOCKLA_URL Override the API base URL.
73
+ MDPOCKLA_TOKEN Use this token instead of the saved one.
74
+ NO_COLOR Disable colorised output.
75
+ `;
76
+
77
+ export async function run(argv) {
78
+ if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") {
79
+ process.stdout.write(HELP);
80
+ return;
81
+ }
82
+ if (argv[0] === "--version" || argv[0] === "-V") {
83
+ process.stdout.write(VERSION + "\n");
84
+ return;
85
+ }
86
+ const [cmd, ...rest] = argv;
87
+ switch (cmd) {
88
+ case "login":
89
+ return login(rest);
90
+ case "logout":
91
+ return logout();
92
+ case "whoami":
93
+ return whoami();
94
+ case "note":
95
+ return dispatchNote(rest);
96
+ default:
97
+ throw exit(`Unknown command: ${cmd}\n\n${HELP}`, 2);
98
+ }
99
+ }
100
+
101
+ function dispatchNote(argv) {
102
+ const [sub, ...rest] = argv;
103
+ switch (sub) {
104
+ case "create":
105
+ return noteCreate(rest);
106
+ case "edit":
107
+ return noteEdit(rest);
108
+ case "get":
109
+ return noteGet(rest);
110
+ case "list":
111
+ return noteList(rest);
112
+ case "delete":
113
+ return noteDelete(rest);
114
+ case "share":
115
+ return noteShare(rest);
116
+ case "collaborator":
117
+ return dispatchCollaborator(rest);
118
+ case "version":
119
+ return dispatchVersion(rest);
120
+ default:
121
+ throw exit(`Unknown note subcommand: ${sub}\n\n${HELP}`, 2);
122
+ }
123
+ }
124
+
125
+ function dispatchCollaborator(argv) {
126
+ const [sub, ...rest] = argv;
127
+ switch (sub) {
128
+ case "add":
129
+ return noteCollaboratorAdd(rest);
130
+ case "remove":
131
+ return noteCollaboratorRemove(rest);
132
+ default:
133
+ throw exit(
134
+ `Unknown collaborator subcommand: ${sub}\n\n${HELP}`,
135
+ 2
136
+ );
137
+ }
138
+ }
139
+
140
+ function dispatchVersion(argv) {
141
+ const [sub, ...rest] = argv;
142
+ switch (sub) {
143
+ case "save":
144
+ return noteVersionSave(rest);
145
+ case "list":
146
+ return noteVersionList(rest);
147
+ case "get":
148
+ return noteVersionGet(rest);
149
+ case "restore":
150
+ return noteVersionRestore(rest);
151
+ case "delete":
152
+ return noteVersionDelete(rest);
153
+ default:
154
+ throw exit(`Unknown version subcommand: ${sub}\n\n${HELP}`, 2);
155
+ }
156
+ }
157
+
158
+ function exit(message, code = 1) {
159
+ const err = new Error(message);
160
+ err.exitCode = code;
161
+ return err;
162
+ }
package/src/lib/args.mjs CHANGED
@@ -1,49 +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
- }
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
+ }
@@ -1,59 +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
- }
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 CHANGED
@@ -1,75 +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
- }
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
+ }