@supercakex/davnote 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/bin/davnote.js +16 -0
- package/package.json +21 -0
- package/src/app.js +663 -0
- package/src/config.js +250 -0
- package/src/errors.js +43 -0
- package/src/prompt.js +60 -0
- package/src/safety.js +42 -0
- package/src/skill.js +78 -0
- package/src/webdav.js +156 -0
package/bin/davnote.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { App } from "../src/app.js";
|
|
5
|
+
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const packageJson = require("../package.json");
|
|
8
|
+
|
|
9
|
+
const app = new App({
|
|
10
|
+
stdin: process.stdin,
|
|
11
|
+
stdout: process.stdout,
|
|
12
|
+
stderr: process.stderr,
|
|
13
|
+
version: packageJson.version,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
process.exitCode = await app.run(process.argv.slice(2));
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@supercakex/davnote",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Send explicitly selected Markdown notes to configured WebDAV storage.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"davnote": "./bin/davnote.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "node --test",
|
|
15
|
+
"check": "node --check bin/davnote.js && node --check src/*.js"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT"
|
|
21
|
+
}
|
package/src/app.js
ADDED
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_MAX_FILE_SIZE,
|
|
5
|
+
defaultConfigPath,
|
|
6
|
+
emptyConfig,
|
|
7
|
+
legacyConfigPath,
|
|
8
|
+
loadConfig,
|
|
9
|
+
permissionWarning,
|
|
10
|
+
saveConfig,
|
|
11
|
+
validateProfileName,
|
|
12
|
+
} from "./config.js";
|
|
13
|
+
import { DavnoteError, errorCode, errorMessage, exitCode } from "./errors.js";
|
|
14
|
+
import { confirm, isInteractive, readLine, readSecret } from "./prompt.js";
|
|
15
|
+
import { validateFile, validateRemoteName } from "./safety.js";
|
|
16
|
+
import { installerPrefix, installerSuffix, skillBody } from "./skill.js";
|
|
17
|
+
import { joinRemote, remoteParent, WebDAVClient } from "./webdav.js";
|
|
18
|
+
|
|
19
|
+
export class App {
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.stdin = options.stdin ?? process.stdin;
|
|
22
|
+
this.stdout = options.stdout ?? process.stdout;
|
|
23
|
+
this.stderr = options.stderr ?? process.stderr;
|
|
24
|
+
this.version = options.version ?? "dev";
|
|
25
|
+
this.configPath = options.configPath ?? defaultConfigPath();
|
|
26
|
+
this.legacyConfigPath =
|
|
27
|
+
options.legacyConfigPath ?? (options.configPath ? "" : legacyConfigPath());
|
|
28
|
+
this.isTTY = options.isTTY ?? (() => isInteractive(this.stdin, this.stdout));
|
|
29
|
+
this.createClient =
|
|
30
|
+
options.createClient ??
|
|
31
|
+
((url, username, password) => new WebDAVClient(url, username, password));
|
|
32
|
+
this.prompts = options.prompts ?? { readLine, readSecret, confirm };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async run(args) {
|
|
36
|
+
if (args.length === 0) {
|
|
37
|
+
this.printHelp();
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
if (args[0] === "--version" || args[0] === "version") {
|
|
41
|
+
this.writeOut(`${this.version}\n`);
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
switch (args[0]) {
|
|
45
|
+
case "help":
|
|
46
|
+
case "-h":
|
|
47
|
+
case "--help":
|
|
48
|
+
this.printHelp();
|
|
49
|
+
return 0;
|
|
50
|
+
case "setup":
|
|
51
|
+
return this.setup(args.slice(1));
|
|
52
|
+
case "push":
|
|
53
|
+
return this.push(args.slice(1));
|
|
54
|
+
case "profile":
|
|
55
|
+
return this.profile(args.slice(1));
|
|
56
|
+
case "config":
|
|
57
|
+
return this.configCommand(args.slice(1));
|
|
58
|
+
case "skill":
|
|
59
|
+
return this.skill(args.slice(1));
|
|
60
|
+
case "doctor":
|
|
61
|
+
return this.doctor(args.slice(1));
|
|
62
|
+
default:
|
|
63
|
+
return this.fail(false, "INVALID_ARGUMENT", `unknown command: ${args[0]}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async setup(args) {
|
|
68
|
+
if (args.length > 0) {
|
|
69
|
+
return this.fail(false, "INVALID_ARGUMENT", "setup does not accept arguments");
|
|
70
|
+
}
|
|
71
|
+
if (!this.isTTY()) {
|
|
72
|
+
return this.fail(false, "INTERACTIVE_REQUIRED", "setup must be run in an interactive terminal");
|
|
73
|
+
}
|
|
74
|
+
let config;
|
|
75
|
+
try {
|
|
76
|
+
config = await this.loadConfiguration();
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (errorCode(error) === "CONFIG_NOT_FOUND") config = emptyConfig();
|
|
79
|
+
else return this.fail(false, errorCode(error, "CONFIG_INVALID"), errorMessage(error));
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
let name = await this.prompts.readLine(this.stdin, this.stdout, "Profile name [personal]: ");
|
|
83
|
+
if (!name) name = "personal";
|
|
84
|
+
validateProfileName(name);
|
|
85
|
+
const profile = await this.promptProfile(defaultProfile(), true);
|
|
86
|
+
const client = this.createClient(profile.webdavUrl, profile.username, profile.password);
|
|
87
|
+
this.writeOut("Testing WebDAV connection and ensuring remote root...\n");
|
|
88
|
+
await client.test(profile.remoteRoot, true);
|
|
89
|
+
config.profiles[name] = profile;
|
|
90
|
+
config.defaultProfile = name;
|
|
91
|
+
await saveConfig(this.configPath, config);
|
|
92
|
+
this.writeOut(`Profile "${name}" saved to ${this.configPath}\n`);
|
|
93
|
+
return 0;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return this.fail(false, errorCode(error, "INPUT_ERROR"), errorMessage(error));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async promptProfile(existing, includePermissions) {
|
|
100
|
+
const profile = structuredClone(existing);
|
|
101
|
+
profile.webdavUrl = await this.promptDefault("WebDAV URL", profile.webdavUrl);
|
|
102
|
+
new WebDAVClient(profile.webdavUrl, "", "");
|
|
103
|
+
profile.username = await this.promptDefault("Username", profile.username);
|
|
104
|
+
const password = await this.prompts.readSecret(this.stdin, this.stdout, "Password: ");
|
|
105
|
+
if (password) profile.password = password;
|
|
106
|
+
profile.remoteRoot = await this.promptDefault("Remote root", profile.remoteRoot || "/Inbox");
|
|
107
|
+
profile.maxFileSize ||= DEFAULT_MAX_FILE_SIZE;
|
|
108
|
+
if (includePermissions) {
|
|
109
|
+
profile.permissions.allowOverwrite = await this.prompts.confirm(
|
|
110
|
+
this.stdin,
|
|
111
|
+
this.stdout,
|
|
112
|
+
"Allow overwriting existing remote files?",
|
|
113
|
+
false,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return profile;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async push(args) {
|
|
120
|
+
let options;
|
|
121
|
+
try {
|
|
122
|
+
options = parsePushArgs(args);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
return this.fail(args.includes("--json"), "INVALID_ARGUMENT", error.message);
|
|
125
|
+
}
|
|
126
|
+
let config;
|
|
127
|
+
try {
|
|
128
|
+
config = await this.loadConfiguration();
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return this.configFailure(options.json, error);
|
|
131
|
+
}
|
|
132
|
+
const profileName = options.profile || config.defaultProfile;
|
|
133
|
+
const profile = config.profiles[profileName];
|
|
134
|
+
if (!profile) return this.fail(options.json, "PROFILE_NOT_FOUND", "profile does not exist");
|
|
135
|
+
if (options.conflict === "overwrite" && !profile.permissions.allowOverwrite) {
|
|
136
|
+
return this.fail(
|
|
137
|
+
options.json,
|
|
138
|
+
"OVERWRITE_NOT_ALLOWED",
|
|
139
|
+
"overwrite is disabled by profile configuration",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
if (options.remoteName) {
|
|
144
|
+
if (options.files.length !== 1) {
|
|
145
|
+
throw new DavnoteError(
|
|
146
|
+
"INVALID_ARGUMENT",
|
|
147
|
+
"--remote-name requires exactly one file",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
validateRemoteName(options.remoteName);
|
|
151
|
+
}
|
|
152
|
+
const client = this.createClient(profile.webdavUrl, profile.username, profile.password);
|
|
153
|
+
const results = [];
|
|
154
|
+
for (const file of options.files) {
|
|
155
|
+
results.push(await this.pushOne(client, profileName, profile, file, options));
|
|
156
|
+
}
|
|
157
|
+
const output = { ok: results.every((item) => item.ok), results };
|
|
158
|
+
if (options.json) {
|
|
159
|
+
this.writeOut(`${JSON.stringify(output)}\n`);
|
|
160
|
+
} else {
|
|
161
|
+
for (const item of results) {
|
|
162
|
+
if (item.ok) {
|
|
163
|
+
this.writeOut(
|
|
164
|
+
`${item.dry_run ? "Would upload" : "Uploaded"} ${item.local_path} -> ${item.remote_path}\n`,
|
|
165
|
+
);
|
|
166
|
+
} else {
|
|
167
|
+
this.writeErr(`${item.local_path}: ${item.message} (${item.code})\n`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return output.ok ? 0 : 1;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return this.fail(options.json, errorCode(error), errorMessage(error));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async pushOne(client, profileName, profile, input, options) {
|
|
178
|
+
let local;
|
|
179
|
+
try {
|
|
180
|
+
local = await validateFile(input, profile.maxFileSize);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
code: errorCode(error),
|
|
185
|
+
message: errorMessage(error),
|
|
186
|
+
local_path: path.resolve(input),
|
|
187
|
+
profile: profileName,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const remotePath = joinRemote(
|
|
191
|
+
profile.remoteRoot,
|
|
192
|
+
options.remoteName || local.relativePath,
|
|
193
|
+
);
|
|
194
|
+
const result = {
|
|
195
|
+
ok: false,
|
|
196
|
+
local_path: local.path,
|
|
197
|
+
remote_path: remotePath,
|
|
198
|
+
bytes: local.size,
|
|
199
|
+
profile: profileName,
|
|
200
|
+
};
|
|
201
|
+
if (options.dryRun) result.dry_run = true;
|
|
202
|
+
try {
|
|
203
|
+
const exists = await client.exists(remotePath);
|
|
204
|
+
if (exists && options.conflict !== "overwrite") {
|
|
205
|
+
return {
|
|
206
|
+
...result,
|
|
207
|
+
code: "REMOTE_FILE_EXISTS",
|
|
208
|
+
message: "a remote file already exists at the target path",
|
|
209
|
+
allowed_actions: allowedActions(profile),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
if (options.dryRun) return { ...result, ok: true };
|
|
213
|
+
const data = await fs.readFile(local.path);
|
|
214
|
+
await client.ensureCollection(remoteParent(remotePath));
|
|
215
|
+
await client.put(remotePath, data);
|
|
216
|
+
return { ...result, ok: true };
|
|
217
|
+
} catch (error) {
|
|
218
|
+
return { ...result, code: errorCode(error), message: errorMessage(error) };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async profile(args) {
|
|
223
|
+
if (args.length === 0) {
|
|
224
|
+
return this.fail(false, "INVALID_ARGUMENT", "profile subcommand is required");
|
|
225
|
+
}
|
|
226
|
+
const json = args.includes("--json");
|
|
227
|
+
let config;
|
|
228
|
+
try {
|
|
229
|
+
config = await this.loadConfiguration();
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (args[0] === "add" && errorCode(error) === "CONFIG_NOT_FOUND") config = emptyConfig();
|
|
232
|
+
else return this.configFailure(json, error);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
switch (args[0]) {
|
|
236
|
+
case "list": {
|
|
237
|
+
const profiles = Object.keys(config.profiles).sort();
|
|
238
|
+
if (json) {
|
|
239
|
+
this.writeOut(`${JSON.stringify({ ok: true, default_profile: config.defaultProfile, profiles })}\n`);
|
|
240
|
+
} else {
|
|
241
|
+
for (const name of profiles) {
|
|
242
|
+
this.writeOut(`${name === config.defaultProfile ? "*" : " "} ${name}\n`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
case "show": {
|
|
248
|
+
const name = positional(args.slice(1))[0] || config.defaultProfile;
|
|
249
|
+
const profile = config.profiles[name];
|
|
250
|
+
if (!profile) return this.fail(json, "PROFILE_NOT_FOUND", "profile does not exist");
|
|
251
|
+
const view = profileView(name, profile);
|
|
252
|
+
if (json) this.writeOut(`${JSON.stringify({ ok: true, profile: view })}\n`);
|
|
253
|
+
else {
|
|
254
|
+
this.writeOut(
|
|
255
|
+
`name: ${name}\nwebdav_url: ${sanitizeUrl(profile.webdavUrl)}\nusername: ${profile.username}\nremote_root: ${profile.remoteRoot}\nallow_overwrite: ${profile.permissions.allowOverwrite}\n`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
case "add":
|
|
261
|
+
return this.profileAdd(config, args.slice(1));
|
|
262
|
+
case "edit":
|
|
263
|
+
return this.profileEdit(config, args.slice(1), false);
|
|
264
|
+
case "credentials":
|
|
265
|
+
return this.profileEdit(config, args.slice(1), true);
|
|
266
|
+
case "remove":
|
|
267
|
+
return this.profileRemove(config, args.slice(1));
|
|
268
|
+
case "default":
|
|
269
|
+
return this.profileDefault(config, args.slice(1));
|
|
270
|
+
case "test":
|
|
271
|
+
return this.profileTest(config, args.slice(1), json);
|
|
272
|
+
default:
|
|
273
|
+
return this.fail(json, "INVALID_ARGUMENT", `unknown profile subcommand: ${args[0]}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async profileAdd(config, args) {
|
|
278
|
+
if (!this.isTTY()) {
|
|
279
|
+
return this.fail(false, "INTERACTIVE_REQUIRED", "profile changes require an interactive terminal");
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
const name = positional(args)[0];
|
|
283
|
+
validateProfileName(name);
|
|
284
|
+
if (config.profiles[name]) throw new DavnoteError("INVALID_ARGUMENT", "profile already exists");
|
|
285
|
+
config.profiles[name] = await this.promptProfile(defaultProfile(), true);
|
|
286
|
+
config.defaultProfile ||= name;
|
|
287
|
+
await saveConfig(this.configPath, config);
|
|
288
|
+
this.writeOut(`Profile "${name}" added\n`);
|
|
289
|
+
return 0;
|
|
290
|
+
} catch (error) {
|
|
291
|
+
return this.fail(false, errorCode(error, "INPUT_ERROR"), errorMessage(error));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async profileEdit(config, args, credentialsOnly) {
|
|
296
|
+
if (!this.isTTY()) {
|
|
297
|
+
return this.fail(false, "INTERACTIVE_REQUIRED", "profile changes require an interactive terminal");
|
|
298
|
+
}
|
|
299
|
+
const name = positional(args)[0] || config.defaultProfile;
|
|
300
|
+
const profile = config.profiles[name];
|
|
301
|
+
if (!profile) return this.fail(false, "PROFILE_NOT_FOUND", "profile does not exist");
|
|
302
|
+
try {
|
|
303
|
+
if (credentialsOnly) {
|
|
304
|
+
profile.username = await this.promptDefault("Username", profile.username);
|
|
305
|
+
const password = await this.prompts.readSecret(this.stdin, this.stdout, "New password: ");
|
|
306
|
+
if (!password) throw new DavnoteError("INVALID_ARGUMENT", "password cannot be empty");
|
|
307
|
+
profile.password = password;
|
|
308
|
+
} else {
|
|
309
|
+
config.profiles[name] = await this.promptProfile(profile, false);
|
|
310
|
+
}
|
|
311
|
+
await saveConfig(this.configPath, config);
|
|
312
|
+
this.writeOut(`Profile "${name}" updated\n`);
|
|
313
|
+
return 0;
|
|
314
|
+
} catch (error) {
|
|
315
|
+
return this.fail(false, errorCode(error, "INPUT_ERROR"), errorMessage(error));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async profileRemove(config, args) {
|
|
320
|
+
if (!this.isTTY()) {
|
|
321
|
+
return this.fail(false, "INTERACTIVE_REQUIRED", "profile changes require an interactive terminal");
|
|
322
|
+
}
|
|
323
|
+
const name = positional(args)[0];
|
|
324
|
+
if (!name) return this.fail(false, "INVALID_ARGUMENT", "profile name is required");
|
|
325
|
+
if (!config.profiles[name]) return this.fail(false, "PROFILE_NOT_FOUND", "profile does not exist");
|
|
326
|
+
try {
|
|
327
|
+
const yes = await this.prompts.confirm(this.stdin, this.stdout, `Remove profile "${name}"?`, false);
|
|
328
|
+
if (!yes) {
|
|
329
|
+
this.writeOut("Cancelled\n");
|
|
330
|
+
return 0;
|
|
331
|
+
}
|
|
332
|
+
delete config.profiles[name];
|
|
333
|
+
if (config.defaultProfile === name) {
|
|
334
|
+
config.defaultProfile = Object.keys(config.profiles)[0] || "";
|
|
335
|
+
}
|
|
336
|
+
await saveConfig(this.configPath, config);
|
|
337
|
+
this.writeOut(`Profile "${name}" removed\n`);
|
|
338
|
+
return 0;
|
|
339
|
+
} catch (error) {
|
|
340
|
+
return this.fail(false, "INPUT_ERROR", errorMessage(error));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async profileDefault(config, args) {
|
|
345
|
+
if (!this.isTTY()) {
|
|
346
|
+
return this.fail(false, "INTERACTIVE_REQUIRED", "profile changes require an interactive terminal");
|
|
347
|
+
}
|
|
348
|
+
const name = positional(args)[0];
|
|
349
|
+
if (!config.profiles[name]) return this.fail(false, "PROFILE_NOT_FOUND", "profile does not exist");
|
|
350
|
+
config.defaultProfile = name;
|
|
351
|
+
await saveConfig(this.configPath, config);
|
|
352
|
+
this.writeOut(`Default profile set to "${name}"\n`);
|
|
353
|
+
return 0;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async profileTest(config, args, json) {
|
|
357
|
+
const name = positional(args)[0] || config.defaultProfile;
|
|
358
|
+
const profile = config.profiles[name];
|
|
359
|
+
if (!profile) return this.fail(json, "PROFILE_NOT_FOUND", "profile does not exist");
|
|
360
|
+
try {
|
|
361
|
+
const client = this.createClient(profile.webdavUrl, profile.username, profile.password);
|
|
362
|
+
await client.test(profile.remoteRoot, false);
|
|
363
|
+
if (json) this.writeOut(`${JSON.stringify({ ok: true, profile: name })}\n`);
|
|
364
|
+
else this.writeOut(`Profile "${name}" connection succeeded\n`);
|
|
365
|
+
return 0;
|
|
366
|
+
} catch (error) {
|
|
367
|
+
return this.fail(json, errorCode(error), errorMessage(error));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async configCommand(args) {
|
|
372
|
+
if (args.length === 0) {
|
|
373
|
+
return this.fail(false, "INVALID_ARGUMENT", "config subcommand is required");
|
|
374
|
+
}
|
|
375
|
+
const json = args.includes("--json");
|
|
376
|
+
if (args[0] === "path") {
|
|
377
|
+
this.writeOut(`${this.configPath}\n`);
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
380
|
+
let config;
|
|
381
|
+
try {
|
|
382
|
+
config = await this.loadConfiguration();
|
|
383
|
+
} catch (error) {
|
|
384
|
+
return this.configFailure(json, error);
|
|
385
|
+
}
|
|
386
|
+
if (args[0] === "show") {
|
|
387
|
+
const view = configView(config);
|
|
388
|
+
this.writeOut(`${JSON.stringify(json ? { ok: true, config: view } : view, null, json ? 0 : 2)}\n`);
|
|
389
|
+
return 0;
|
|
390
|
+
}
|
|
391
|
+
if (args[0] === "permissions") {
|
|
392
|
+
return this.configPermissions(config, args.slice(1));
|
|
393
|
+
}
|
|
394
|
+
return this.fail(json, "INVALID_ARGUMENT", `unknown config subcommand: ${args[0]}`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async configPermissions(config, args) {
|
|
398
|
+
const json = args.includes("--json");
|
|
399
|
+
if (json || args.includes("--non-interactive") || !this.isTTY()) {
|
|
400
|
+
return this.fail(json, "INTERACTIVE_REQUIRED", "permission changes require an interactive terminal");
|
|
401
|
+
}
|
|
402
|
+
let profileName;
|
|
403
|
+
try {
|
|
404
|
+
({ profile: profileName } = parseProfileOption(args));
|
|
405
|
+
} catch (error) {
|
|
406
|
+
return this.fail(false, "INVALID_ARGUMENT", error.message);
|
|
407
|
+
}
|
|
408
|
+
profileName ||= config.defaultProfile;
|
|
409
|
+
const profile = config.profiles[profileName];
|
|
410
|
+
if (!profile) return this.fail(false, "PROFILE_NOT_FOUND", "profile does not exist");
|
|
411
|
+
try {
|
|
412
|
+
const overwrite = await this.prompts.confirm(
|
|
413
|
+
this.stdin,
|
|
414
|
+
this.stdout,
|
|
415
|
+
"Allow overwriting existing remote files?",
|
|
416
|
+
profile.permissions.allowOverwrite,
|
|
417
|
+
);
|
|
418
|
+
if (overwrite && !profile.permissions.allowOverwrite) {
|
|
419
|
+
const confirmed = await this.prompts.confirm(
|
|
420
|
+
this.stdin,
|
|
421
|
+
this.stdout,
|
|
422
|
+
"This expands remote-write capabilities. Confirm?",
|
|
423
|
+
false,
|
|
424
|
+
);
|
|
425
|
+
if (!confirmed) {
|
|
426
|
+
this.writeOut("Cancelled\n");
|
|
427
|
+
return 0;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
profile.permissions.allowOverwrite = overwrite;
|
|
431
|
+
await saveConfig(this.configPath, config);
|
|
432
|
+
this.writeOut(`Permissions updated for profile "${profileName}"\n`);
|
|
433
|
+
return 0;
|
|
434
|
+
} catch (error) {
|
|
435
|
+
return this.fail(false, "INPUT_ERROR", errorMessage(error));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
skill(args) {
|
|
440
|
+
if (args.some((arg) => arg !== "--raw")) {
|
|
441
|
+
return this.fail(false, "INVALID_ARGUMENT", `unknown skill option: ${args.find((arg) => arg !== "--raw")}`);
|
|
442
|
+
}
|
|
443
|
+
this.writeOut(args.includes("--raw") ? skillBody : installerPrefix + skillBody + installerSuffix);
|
|
444
|
+
return 0;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async doctor(args) {
|
|
448
|
+
const json = args.includes("--json");
|
|
449
|
+
if (args.some((arg) => arg !== "--json")) {
|
|
450
|
+
return this.fail(json, "INVALID_ARGUMENT", `unknown doctor option: ${args.find((arg) => arg !== "--json")}`);
|
|
451
|
+
}
|
|
452
|
+
const checks = [];
|
|
453
|
+
let config;
|
|
454
|
+
try {
|
|
455
|
+
config = await this.loadConfiguration();
|
|
456
|
+
checks.push({ name: "configuration", ok: true, message: this.configPath });
|
|
457
|
+
const warning = await permissionWarning(this.configPath);
|
|
458
|
+
checks.push({
|
|
459
|
+
name: "configuration_permissions",
|
|
460
|
+
ok: !warning,
|
|
461
|
+
message: warning || (process.platform === "win32" ? "managed by Windows ACLs" : "0600"),
|
|
462
|
+
});
|
|
463
|
+
for (const [name, profile] of Object.entries(config.profiles)) {
|
|
464
|
+
try {
|
|
465
|
+
const client = this.createClient(profile.webdavUrl, profile.username, profile.password);
|
|
466
|
+
await client.test(profile.remoteRoot, false);
|
|
467
|
+
checks.push({ name: `webdav:${name}`, ok: true, message: "connection succeeded" });
|
|
468
|
+
} catch (error) {
|
|
469
|
+
checks.push({ name: `webdav:${name}`, ok: false, message: errorMessage(error) });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
} catch (error) {
|
|
473
|
+
checks.push({ name: "configuration", ok: false, message: errorMessage(error) });
|
|
474
|
+
}
|
|
475
|
+
const ok = checks.every((check) => check.ok);
|
|
476
|
+
if (json) this.writeOut(`${JSON.stringify({ ok, checks })}\n`);
|
|
477
|
+
else {
|
|
478
|
+
for (const check of checks) {
|
|
479
|
+
this.writeOut(`[${check.ok ? "OK" : "FAIL"}] ${check.name}: ${check.message}\n`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return ok ? 0 : 1;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async promptDefault(label, current) {
|
|
486
|
+
const prompt = current ? `${label} [${current}]: ` : `${label}: `;
|
|
487
|
+
const value = (await this.prompts.readLine(this.stdin, this.stdout, prompt)) || current;
|
|
488
|
+
if (!value) throw new Error(`${label.toLowerCase()} is required`);
|
|
489
|
+
return value;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
configFailure(json, error) {
|
|
493
|
+
if (errorCode(error) === "CONFIG_NOT_FOUND") {
|
|
494
|
+
return this.fail(
|
|
495
|
+
json,
|
|
496
|
+
"CONFIG_NOT_FOUND",
|
|
497
|
+
"configuration not found; run davnote setup in a terminal",
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
return this.fail(json, "CONFIG_INVALID", errorMessage(error));
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
fail(json, code, message, allowedActions) {
|
|
504
|
+
const safeMessage = sanitize(message);
|
|
505
|
+
if (json) {
|
|
506
|
+
const result = { ok: false, code, message: safeMessage };
|
|
507
|
+
if (allowedActions?.length) result.allowed_actions = allowedActions;
|
|
508
|
+
this.writeOut(`${JSON.stringify(result)}\n`);
|
|
509
|
+
} else {
|
|
510
|
+
this.writeErr(`${code}: ${safeMessage}\n`);
|
|
511
|
+
}
|
|
512
|
+
return exitCode(code);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
printHelp() {
|
|
516
|
+
this.writeOut(`davnote pushes explicitly selected Markdown notes to configured WebDAV storage.
|
|
517
|
+
|
|
518
|
+
Usage:
|
|
519
|
+
davnote setup
|
|
520
|
+
davnote push [options] <file.md> [more.md...]
|
|
521
|
+
davnote profile <list|add|edit|remove|show|test|default|credentials>
|
|
522
|
+
davnote config <path|show|permissions>
|
|
523
|
+
davnote skill [--raw]
|
|
524
|
+
davnote doctor [--json]
|
|
525
|
+
davnote version
|
|
526
|
+
|
|
527
|
+
Run setup and all configuration-changing commands directly in an interactive terminal.
|
|
528
|
+
`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async loadConfiguration() {
|
|
532
|
+
try {
|
|
533
|
+
return await loadConfig(this.configPath);
|
|
534
|
+
} catch (error) {
|
|
535
|
+
if (errorCode(error) !== "CONFIG_NOT_FOUND" || !this.legacyConfigPath) throw error;
|
|
536
|
+
let legacy;
|
|
537
|
+
try {
|
|
538
|
+
legacy = await loadConfig(this.legacyConfigPath);
|
|
539
|
+
} catch {
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
542
|
+
await saveConfig(this.configPath, legacy);
|
|
543
|
+
return legacy;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
writeOut(value) {
|
|
548
|
+
this.stdout.write(value);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
writeErr(value) {
|
|
552
|
+
this.stderr.write(value);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function parsePushArgs(args) {
|
|
557
|
+
const options = {
|
|
558
|
+
files: [],
|
|
559
|
+
profile: "",
|
|
560
|
+
json: false,
|
|
561
|
+
nonInteractive: false,
|
|
562
|
+
dryRun: false,
|
|
563
|
+
conflict: "error",
|
|
564
|
+
remoteName: "",
|
|
565
|
+
};
|
|
566
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
567
|
+
const arg = args[index];
|
|
568
|
+
if (arg === "--json") options.json = true;
|
|
569
|
+
else if (arg === "--non-interactive") options.nonInteractive = true;
|
|
570
|
+
else if (arg === "--dry-run") options.dryRun = true;
|
|
571
|
+
else if (["--profile", "--conflict", "--remote-name"].includes(arg)) {
|
|
572
|
+
if (index + 1 >= args.length) throw new Error(`${arg} requires a value`);
|
|
573
|
+
const value = args[++index];
|
|
574
|
+
if (arg === "--profile") options.profile = value;
|
|
575
|
+
else if (arg === "--conflict") options.conflict = value;
|
|
576
|
+
else options.remoteName = value;
|
|
577
|
+
} else if (arg.startsWith("-")) throw new Error(`unknown push option: ${arg}`);
|
|
578
|
+
else options.files.push(arg);
|
|
579
|
+
}
|
|
580
|
+
if (options.files.length === 0) throw new Error("at least one Markdown file is required");
|
|
581
|
+
if (!["error", "overwrite"].includes(options.conflict)) {
|
|
582
|
+
throw new Error("--conflict must be error or overwrite");
|
|
583
|
+
}
|
|
584
|
+
return options;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function parseProfileOption(args) {
|
|
588
|
+
let profile = "";
|
|
589
|
+
const rest = [];
|
|
590
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
591
|
+
if (args[index] === "--profile") {
|
|
592
|
+
if (index + 1 >= args.length) throw new Error("--profile requires a value");
|
|
593
|
+
profile = args[++index];
|
|
594
|
+
} else if (args[index] === "--json" || args[index] === "--non-interactive") {
|
|
595
|
+
continue;
|
|
596
|
+
} else if (args[index].startsWith("-")) {
|
|
597
|
+
throw new Error(`unknown option: ${args[index]}`);
|
|
598
|
+
} else rest.push(args[index]);
|
|
599
|
+
}
|
|
600
|
+
return { profile, rest };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function positional(args) {
|
|
604
|
+
return parseProfileOption(args).rest;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function defaultProfile() {
|
|
608
|
+
return {
|
|
609
|
+
webdavUrl: "",
|
|
610
|
+
username: "",
|
|
611
|
+
password: "",
|
|
612
|
+
remoteRoot: "/Inbox",
|
|
613
|
+
maxFileSize: DEFAULT_MAX_FILE_SIZE,
|
|
614
|
+
permissions: { allowOverwrite: false },
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function allowedActions(profile) {
|
|
619
|
+
return profile.permissions.allowOverwrite ? ["overwrite", "rename"] : ["rename"];
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function profileView(name, profile) {
|
|
623
|
+
return {
|
|
624
|
+
name,
|
|
625
|
+
webdav_url: sanitizeUrl(profile.webdavUrl),
|
|
626
|
+
username: profile.username,
|
|
627
|
+
remote_root: profile.remoteRoot,
|
|
628
|
+
max_file_size: profile.maxFileSize,
|
|
629
|
+
allow_overwrite: profile.permissions.allowOverwrite,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function configView(config) {
|
|
634
|
+
return {
|
|
635
|
+
default_profile: config.defaultProfile,
|
|
636
|
+
profiles: Object.fromEntries(
|
|
637
|
+
Object.entries(config.profiles).map(([name, profile]) => [name, profileView(name, profile)]),
|
|
638
|
+
),
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function sanitizeUrl(value) {
|
|
643
|
+
try {
|
|
644
|
+
const url = new URL(value);
|
|
645
|
+
url.username = "";
|
|
646
|
+
url.password = "";
|
|
647
|
+
return url.toString();
|
|
648
|
+
} catch {
|
|
649
|
+
return "<invalid URL>";
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function sanitize(value) {
|
|
654
|
+
const string = String(value);
|
|
655
|
+
try {
|
|
656
|
+
const url = new URL(string);
|
|
657
|
+
url.username = "";
|
|
658
|
+
url.password = "";
|
|
659
|
+
return url.toString();
|
|
660
|
+
} catch {
|
|
661
|
+
return string;
|
|
662
|
+
}
|
|
663
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { DavnoteError } from "./errors.js";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_MAX_FILE_SIZE = 20 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
export function defaultConfigPath(platform = process.platform, env = process.env, home = os.homedir()) {
|
|
9
|
+
return applicationConfigPath("davnote", platform, env, home);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function legacyConfigPath(platform = process.platform, env = process.env, home = os.homedir()) {
|
|
13
|
+
return applicationConfigPath("note-send", platform, env, home);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function applicationConfigPath(applicationName, platform, env, home) {
|
|
17
|
+
if (platform === "darwin") {
|
|
18
|
+
return path.join(home, "Library", "Application Support", applicationName, "config.toml");
|
|
19
|
+
}
|
|
20
|
+
if (platform === "win32") {
|
|
21
|
+
const base = env.APPDATA || path.join(home, "AppData", "Roaming");
|
|
22
|
+
return path.join(base, applicationName, "config.toml");
|
|
23
|
+
}
|
|
24
|
+
const base = env.XDG_CONFIG_HOME || path.join(home, ".config");
|
|
25
|
+
return path.join(base, applicationName, "config.toml");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function emptyConfig() {
|
|
29
|
+
return { defaultProfile: "", profiles: {} };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function loadConfig(configPath) {
|
|
33
|
+
let text;
|
|
34
|
+
try {
|
|
35
|
+
text = await fs.readFile(configPath, "utf8");
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error?.code === "ENOENT") {
|
|
38
|
+
throw new DavnoteError("CONFIG_NOT_FOUND", "configuration not found");
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return parseToml(text);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new DavnoteError("CONFIG_INVALID", error.message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function saveConfig(configPath, config) {
|
|
50
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 });
|
|
51
|
+
const tempPath = path.join(
|
|
52
|
+
path.dirname(configPath),
|
|
53
|
+
`.config-${process.pid}-${Date.now()}.tmp`,
|
|
54
|
+
);
|
|
55
|
+
try {
|
|
56
|
+
await fs.writeFile(tempPath, formatToml(config), { mode: 0o600 });
|
|
57
|
+
await fs.rename(tempPath, configPath);
|
|
58
|
+
if (process.platform !== "win32") {
|
|
59
|
+
await fs.chmod(configPath, 0o600);
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
await fs.rm(tempPath, { force: true }).catch(() => {});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function permissionWarning(configPath) {
|
|
67
|
+
if (process.platform === "win32") return "";
|
|
68
|
+
const stat = await fs.stat(configPath);
|
|
69
|
+
const permissions = stat.mode & 0o777;
|
|
70
|
+
if ((permissions & 0o077) !== 0) {
|
|
71
|
+
return `configuration permissions are ${permissions.toString(8).padStart(4, "0")}; expected 0600`;
|
|
72
|
+
}
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function validateProfileName(name) {
|
|
77
|
+
if (!name) throw new DavnoteError("INVALID_ARGUMENT", "profile name is required");
|
|
78
|
+
if (!/^[a-z0-9-]+$/.test(name)) {
|
|
79
|
+
throw new DavnoteError(
|
|
80
|
+
"INVALID_ARGUMENT",
|
|
81
|
+
"profile name must contain only lowercase letters, digits, and hyphens",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseToml(text) {
|
|
87
|
+
const config = emptyConfig();
|
|
88
|
+
let profileName = "";
|
|
89
|
+
let permissions = false;
|
|
90
|
+
const lines = text.split(/\r?\n/);
|
|
91
|
+
|
|
92
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
93
|
+
const lineNumber = index + 1;
|
|
94
|
+
const line = stripComment(lines[index]).trim();
|
|
95
|
+
if (!line) continue;
|
|
96
|
+
|
|
97
|
+
if (line.startsWith("[") && line.endsWith("]")) {
|
|
98
|
+
const section = line.slice(1, -1).trim();
|
|
99
|
+
const parts = section.split(".");
|
|
100
|
+
if (parts.length === 2 && parts[0] === "profiles") {
|
|
101
|
+
[, profileName] = parts;
|
|
102
|
+
permissions = false;
|
|
103
|
+
} else if (parts.length === 3 && parts[0] === "profiles" && parts[2] === "permissions") {
|
|
104
|
+
[, profileName] = parts;
|
|
105
|
+
permissions = true;
|
|
106
|
+
} else {
|
|
107
|
+
throw new Error(`line ${lineNumber}: unsupported section "${section}"`);
|
|
108
|
+
}
|
|
109
|
+
config.profiles[profileName] ??= defaultProfile();
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const separator = line.indexOf("=");
|
|
114
|
+
if (separator < 0) throw new Error(`line ${lineNumber}: expected key = value`);
|
|
115
|
+
const key = line.slice(0, separator).trim();
|
|
116
|
+
const value = line.slice(separator + 1).trim();
|
|
117
|
+
|
|
118
|
+
if (!profileName) {
|
|
119
|
+
if (key !== "default_profile") {
|
|
120
|
+
throw new Error(`line ${lineNumber}: unsupported top-level key "${key}"`);
|
|
121
|
+
}
|
|
122
|
+
config.defaultProfile = parseString(value, lineNumber);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const profile = config.profiles[profileName];
|
|
127
|
+
try {
|
|
128
|
+
if (permissions) {
|
|
129
|
+
if (key === "allow_overwrite") profile.permissions.allowOverwrite = parseBoolean(value);
|
|
130
|
+
else if (key === "allow_remote_rename") parseBoolean(value);
|
|
131
|
+
else throw new Error(`unsupported permissions key "${key}"`);
|
|
132
|
+
} else {
|
|
133
|
+
switch (key) {
|
|
134
|
+
case "webdav_url":
|
|
135
|
+
profile.webdavUrl = parseString(value);
|
|
136
|
+
break;
|
|
137
|
+
case "username":
|
|
138
|
+
profile.username = parseString(value);
|
|
139
|
+
break;
|
|
140
|
+
case "password":
|
|
141
|
+
profile.password = parseString(value);
|
|
142
|
+
break;
|
|
143
|
+
case "remote_root":
|
|
144
|
+
profile.remoteRoot = parseString(value);
|
|
145
|
+
break;
|
|
146
|
+
case "max_file_size":
|
|
147
|
+
profile.maxFileSize = parseInteger(value);
|
|
148
|
+
break;
|
|
149
|
+
case "allowed_roots":
|
|
150
|
+
parseStringArray(value);
|
|
151
|
+
break;
|
|
152
|
+
default:
|
|
153
|
+
throw new Error(`unsupported profile key "${key}"`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
throw new Error(`line ${lineNumber}: ${error.message}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const profile of Object.values(config.profiles)) {
|
|
162
|
+
if (!profile.maxFileSize) profile.maxFileSize = DEFAULT_MAX_FILE_SIZE;
|
|
163
|
+
}
|
|
164
|
+
return config;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function formatToml(config) {
|
|
168
|
+
const lines = [`default_profile = ${quote(config.defaultProfile)}`];
|
|
169
|
+
for (const name of Object.keys(config.profiles).sort()) {
|
|
170
|
+
const profile = config.profiles[name];
|
|
171
|
+
lines.push(
|
|
172
|
+
"",
|
|
173
|
+
`[profiles.${name}]`,
|
|
174
|
+
`webdav_url = ${quote(profile.webdavUrl)}`,
|
|
175
|
+
`username = ${quote(profile.username)}`,
|
|
176
|
+
`password = ${quote(profile.password)}`,
|
|
177
|
+
`remote_root = ${quote(profile.remoteRoot)}`,
|
|
178
|
+
`max_file_size = ${profile.maxFileSize || DEFAULT_MAX_FILE_SIZE}`,
|
|
179
|
+
"",
|
|
180
|
+
`[profiles.${name}.permissions]`,
|
|
181
|
+
`allow_overwrite = ${Boolean(profile.permissions?.allowOverwrite)}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return `${lines.join("\n")}\n`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function defaultProfile() {
|
|
188
|
+
return {
|
|
189
|
+
webdavUrl: "",
|
|
190
|
+
username: "",
|
|
191
|
+
password: "",
|
|
192
|
+
remoteRoot: "",
|
|
193
|
+
maxFileSize: DEFAULT_MAX_FILE_SIZE,
|
|
194
|
+
permissions: { allowOverwrite: false },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function stripComment(line) {
|
|
199
|
+
let inString = false;
|
|
200
|
+
let escaped = false;
|
|
201
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
202
|
+
const character = line[index];
|
|
203
|
+
if (escaped) {
|
|
204
|
+
escaped = false;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (character === "\\" && inString) {
|
|
208
|
+
escaped = true;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (character === '"') inString = !inString;
|
|
212
|
+
if (character === "#" && !inString) return line.slice(0, index);
|
|
213
|
+
}
|
|
214
|
+
return line;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function parseString(value) {
|
|
218
|
+
try {
|
|
219
|
+
const parsed = JSON.parse(value);
|
|
220
|
+
if (typeof parsed !== "string") throw new Error();
|
|
221
|
+
return parsed;
|
|
222
|
+
} catch {
|
|
223
|
+
throw new Error("expected quoted string");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function parseStringArray(value) {
|
|
228
|
+
try {
|
|
229
|
+
const parsed = JSON.parse(value);
|
|
230
|
+
if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) throw new Error();
|
|
231
|
+
return parsed;
|
|
232
|
+
} catch {
|
|
233
|
+
throw new Error("expected string array");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function parseBoolean(value) {
|
|
238
|
+
if (value === "true") return true;
|
|
239
|
+
if (value === "false") return false;
|
|
240
|
+
throw new Error("expected boolean");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function parseInteger(value) {
|
|
244
|
+
if (!/^-?\d+$/.test(value)) throw new Error("expected integer");
|
|
245
|
+
return Number.parseInt(value, 10);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function quote(value) {
|
|
249
|
+
return JSON.stringify(value ?? "");
|
|
250
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export class DavnoteError extends Error {
|
|
2
|
+
constructor(code, message, options = {}) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "DavnoteError";
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.statusCode = options.statusCode;
|
|
7
|
+
this.temporary = options.temporary ?? false;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function errorCode(error, fallback = "SERVER_ERROR") {
|
|
12
|
+
return error instanceof DavnoteError ? error.code : fallback;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function errorMessage(error) {
|
|
16
|
+
return error instanceof Error ? error.message : String(error);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function exitCode(code) {
|
|
20
|
+
switch (code) {
|
|
21
|
+
case "INVALID_ARGUMENT":
|
|
22
|
+
case "INPUT_ERROR":
|
|
23
|
+
return 2;
|
|
24
|
+
case "CONFIG_NOT_FOUND":
|
|
25
|
+
case "CONFIG_INVALID":
|
|
26
|
+
case "PROFILE_NOT_FOUND":
|
|
27
|
+
return 3;
|
|
28
|
+
case "AUTH_FAILED":
|
|
29
|
+
return 4;
|
|
30
|
+
case "INVALID_FILE_TYPE":
|
|
31
|
+
case "FILE_TOO_LARGE":
|
|
32
|
+
case "OVERWRITE_NOT_ALLOWED":
|
|
33
|
+
case "INTERACTIVE_REQUIRED":
|
|
34
|
+
return 5;
|
|
35
|
+
case "REMOTE_FILE_EXISTS":
|
|
36
|
+
return 6;
|
|
37
|
+
case "NETWORK_ERROR":
|
|
38
|
+
case "SERVER_ERROR":
|
|
39
|
+
return 7;
|
|
40
|
+
default:
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import readline from "node:readline/promises";
|
|
2
|
+
|
|
3
|
+
export function isInteractive(input, output) {
|
|
4
|
+
return Boolean(input?.isTTY && output?.isTTY);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function readLine(input, output, prompt) {
|
|
8
|
+
const terminal = readline.createInterface({ input, output });
|
|
9
|
+
try {
|
|
10
|
+
return (await terminal.question(prompt)).trim();
|
|
11
|
+
} finally {
|
|
12
|
+
terminal.close();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function confirm(input, output, prompt, defaultYes = false) {
|
|
17
|
+
const suffix = defaultYes ? " [Y/n]: " : " [y/N]: ";
|
|
18
|
+
const answer = (await readLine(input, output, prompt + suffix)).toLowerCase();
|
|
19
|
+
if (!answer) return defaultYes;
|
|
20
|
+
if (answer === "y" || answer === "yes") return true;
|
|
21
|
+
if (answer === "n" || answer === "no") return false;
|
|
22
|
+
throw new Error("expected yes or no");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function readSecret(input, output, prompt) {
|
|
26
|
+
if (!input?.isTTY || typeof input.setRawMode !== "function") {
|
|
27
|
+
throw new Error("secret input requires a terminal");
|
|
28
|
+
}
|
|
29
|
+
output.write(prompt);
|
|
30
|
+
input.setRawMode(true);
|
|
31
|
+
input.resume();
|
|
32
|
+
input.setEncoding("utf8");
|
|
33
|
+
let value = "";
|
|
34
|
+
try {
|
|
35
|
+
return await new Promise((resolve, reject) => {
|
|
36
|
+
const onData = (character) => {
|
|
37
|
+
if (character === "\r" || character === "\n") {
|
|
38
|
+
input.off("data", onData);
|
|
39
|
+
output.write("\n");
|
|
40
|
+
resolve(value);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (character === "\u0003") {
|
|
44
|
+
input.off("data", onData);
|
|
45
|
+
reject(new Error("input cancelled"));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (character === "\u007f" || character === "\b") {
|
|
49
|
+
value = value.slice(0, -1);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
value += character;
|
|
53
|
+
};
|
|
54
|
+
input.on("data", onData);
|
|
55
|
+
});
|
|
56
|
+
} finally {
|
|
57
|
+
input.setRawMode(false);
|
|
58
|
+
input.pause();
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/safety.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { DavnoteError } from "./errors.js";
|
|
4
|
+
|
|
5
|
+
export async function validateFile(input, maxFileSize) {
|
|
6
|
+
const absolutePath = path.resolve(input);
|
|
7
|
+
let realPath;
|
|
8
|
+
try {
|
|
9
|
+
realPath = await fs.realpath(absolutePath);
|
|
10
|
+
} catch {
|
|
11
|
+
throw new DavnoteError("FILE_NOT_FOUND", "local file does not exist");
|
|
12
|
+
}
|
|
13
|
+
let stat;
|
|
14
|
+
try {
|
|
15
|
+
stat = await fs.stat(realPath);
|
|
16
|
+
} catch {
|
|
17
|
+
throw new DavnoteError("FILE_NOT_FOUND", "cannot inspect local file");
|
|
18
|
+
}
|
|
19
|
+
if (!stat.isFile()) {
|
|
20
|
+
throw new DavnoteError("INVALID_FILE_TYPE", "local path is not a regular file");
|
|
21
|
+
}
|
|
22
|
+
if (path.extname(realPath).toLowerCase() !== ".md") {
|
|
23
|
+
throw new DavnoteError("INVALID_FILE_TYPE", "only Markdown (.md) files are allowed");
|
|
24
|
+
}
|
|
25
|
+
if (maxFileSize > 0 && stat.size > maxFileSize) {
|
|
26
|
+
throw new DavnoteError(
|
|
27
|
+
"FILE_TOO_LARGE",
|
|
28
|
+
`file exceeds maximum size of ${maxFileSize} bytes`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return { path: realPath, relativePath: path.basename(realPath), size: stat.size };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function validateRemoteName(name) {
|
|
35
|
+
if (!name) throw new DavnoteError("INVALID_ARGUMENT", "remote name cannot be empty");
|
|
36
|
+
if (name === "." || name === ".." || /[\\/]/.test(name)) {
|
|
37
|
+
throw new DavnoteError("INVALID_ARGUMENT", "remote name must be a single file name");
|
|
38
|
+
}
|
|
39
|
+
if (path.extname(name).toLowerCase() !== ".md") {
|
|
40
|
+
throw new DavnoteError("INVALID_ARGUMENT", "remote name must end in .md");
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/skill.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export const skillBody = `---
|
|
2
|
+
name: push-notes
|
|
3
|
+
description: Push explicitly requested Markdown notes to the user's configured WebDAV storage with the davnote CLI.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Send Notes
|
|
7
|
+
|
|
8
|
+
Use the installed \`davnote\` command. The CLI owns WebDAV configuration, credentials, file validation, and remote-write permissions.
|
|
9
|
+
|
|
10
|
+
## Workflow
|
|
11
|
+
|
|
12
|
+
1. Only send Markdown files explicitly requested by the user.
|
|
13
|
+
2. Resolve each requested note to an absolute path without moving or copying it.
|
|
14
|
+
3. Run:
|
|
15
|
+
|
|
16
|
+
\`davnote push "<absolute-path>" --json --non-interactive\`
|
|
17
|
+
|
|
18
|
+
\`--json\` requests structured output for reliable result handling. \`--non-interactive\` guarantees that the command will not prompt for input.
|
|
19
|
+
|
|
20
|
+
4. For multiple explicit files, pass all paths in one command.
|
|
21
|
+
5. Report each returned local path, remote path, and result.
|
|
22
|
+
6. If configuration is missing, tell the user to run \`davnote setup\` directly in a terminal.
|
|
23
|
+
|
|
24
|
+
## Remote file names
|
|
25
|
+
|
|
26
|
+
- By default, the remote file uses the local file's base name.
|
|
27
|
+
- The remote name may be chosen at upload time without changing the local file:
|
|
28
|
+
|
|
29
|
+
\`davnote push "<absolute-path>" --remote-name "<name>.md" --json --non-interactive\`
|
|
30
|
+
|
|
31
|
+
- \`--remote-name\` accepts one Markdown file at a time.
|
|
32
|
+
- Use a different remote name when the user requests one or explicitly chooses rename to resolve a conflict.
|
|
33
|
+
- Do not invent a different remote name without user intent or confirmation.
|
|
34
|
+
|
|
35
|
+
## Conflicts and errors
|
|
36
|
+
|
|
37
|
+
- Always inspect the JSON \`code\` and \`allowed_actions\` fields.
|
|
38
|
+
- For \`REMOTE_FILE_EXISTS\`, never overwrite or choose a different name automatically.
|
|
39
|
+
- Only offer actions present in \`allowed_actions\` and obtain explicit authorization for the affected file.
|
|
40
|
+
- After explicit overwrite authorization, use \`--conflict overwrite\`.
|
|
41
|
+
- Rename is always available; after the user explicitly supplies or confirms a different name, use \`--remote-name "<name>.md"\`.
|
|
42
|
+
- If overwrite is not listed, do not enable it yourself. Rename remains available, or tell the user they may run \`davnote config permissions --profile <name>\` in their terminal.
|
|
43
|
+
- For \`AUTH_FAILED\`, never request credentials in chat. Tell the user to run \`davnote profile credentials <name>\`.
|
|
44
|
+
- For \`CONFIG_NOT_FOUND\` or \`PROFILE_NOT_FOUND\`, tell the user to run \`davnote setup\`.
|
|
45
|
+
- For \`FILE_NOT_FOUND\`, do not choose a similarly named file without confirmation.
|
|
46
|
+
- Do not retry authentication, permission, validation, or conflict failures.
|
|
47
|
+
- The CLI may retry one temporary network failure. If it still fails, report the failure.
|
|
48
|
+
- For multi-file results, report successes and failures separately. Never apply one file's conflict decision to other files without explicit instruction.
|
|
49
|
+
|
|
50
|
+
## Safety
|
|
51
|
+
|
|
52
|
+
- Never ask for, read, display, store, or modify WebDAV credentials.
|
|
53
|
+
- Never read or edit the davnote configuration file.
|
|
54
|
+
- Never invoke setup, profile mutation, or permission mutation on the user's behalf.
|
|
55
|
+
- Never bypass file-type, size, or overwrite restrictions.
|
|
56
|
+
- Never send additional files, linked attachments, or directories unless a future CLI version explicitly supports them and the user requests them.
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
export const installerPrefix = `Create or update a Skill for yourself that uses the davnote CLI to push Markdown notes.
|
|
60
|
+
|
|
61
|
+
Requirements:
|
|
62
|
+
|
|
63
|
+
1. Determine your own Agent's Skill directory and required file format.
|
|
64
|
+
2. Create a Skill named \`push-notes\`.
|
|
65
|
+
3. Save the SKILL.md content between the markers below.
|
|
66
|
+
4. Generate any additional platform metadata required by your Agent.
|
|
67
|
+
5. Do not place WebDAV URLs, usernames, passwords, davnote configuration, or other credentials in the Skill.
|
|
68
|
+
6. Preserve the safety and error-handling rules.
|
|
69
|
+
7. Do not send any note during installation.
|
|
70
|
+
8. When finished, report the exact path where the Skill was saved.
|
|
71
|
+
|
|
72
|
+
--- BEGIN SKILL.md ---
|
|
73
|
+
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
export const installerSuffix = `
|
|
77
|
+
--- END SKILL.md ---
|
|
78
|
+
`;
|
package/src/webdav.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { DavnoteError } from "./errors.js";
|
|
5
|
+
|
|
6
|
+
export class WebDAVClient {
|
|
7
|
+
constructor(rawUrl, username, password, options = {}) {
|
|
8
|
+
let parsed;
|
|
9
|
+
try {
|
|
10
|
+
parsed = new URL(rawUrl);
|
|
11
|
+
} catch {
|
|
12
|
+
throw new DavnoteError("CONFIG_INVALID", "invalid WebDAV URL");
|
|
13
|
+
}
|
|
14
|
+
if (parsed.protocol !== "https:") {
|
|
15
|
+
throw new DavnoteError("CONFIG_INVALID", "WebDAV URL must use HTTPS");
|
|
16
|
+
}
|
|
17
|
+
if (!parsed.hostname) {
|
|
18
|
+
throw new DavnoteError("CONFIG_INVALID", "WebDAV URL must include a host");
|
|
19
|
+
}
|
|
20
|
+
if (parsed.username || parsed.password) {
|
|
21
|
+
throw new DavnoteError("CONFIG_INVALID", "WebDAV URL must not contain credentials");
|
|
22
|
+
}
|
|
23
|
+
parsed.search = "";
|
|
24
|
+
parsed.hash = "";
|
|
25
|
+
this.baseUrl = parsed;
|
|
26
|
+
this.username = username;
|
|
27
|
+
this.password = password;
|
|
28
|
+
this.timeout = options.timeout ?? 30_000;
|
|
29
|
+
this.agent = options.agent;
|
|
30
|
+
this.requestImpl = options.requestImpl;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async test(remoteRoot, create = false) {
|
|
34
|
+
if (create) await this.ensureCollection(remoteRoot);
|
|
35
|
+
if (!(await this.exists(remoteRoot))) {
|
|
36
|
+
throw new DavnoteError("SERVER_ERROR", "remote root does not exist");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async exists(remotePath) {
|
|
41
|
+
let response = await this.request("HEAD", remotePath);
|
|
42
|
+
if (response.statusCode === 405 || response.statusCode === 501) {
|
|
43
|
+
response = await this.request("PROPFIND", remotePath, undefined, { Depth: "0" });
|
|
44
|
+
}
|
|
45
|
+
if (response.statusCode >= 200 && response.statusCode < 300) return true;
|
|
46
|
+
if (response.statusCode === 404) return false;
|
|
47
|
+
throw classifyStatus(response.statusCode, "existence check");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async put(remotePath, body) {
|
|
51
|
+
const response = await this.request("PUT", remotePath, body, {
|
|
52
|
+
"Content-Type": "text/markdown; charset=utf-8",
|
|
53
|
+
});
|
|
54
|
+
if (response.statusCode >= 200 && response.statusCode < 300) return;
|
|
55
|
+
throw classifyStatus(response.statusCode, "upload");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async ensureCollection(remotePath) {
|
|
59
|
+
const clean = cleanRemote(remotePath);
|
|
60
|
+
if (clean === "/") return;
|
|
61
|
+
let current = "";
|
|
62
|
+
for (const segment of clean.slice(1).split("/")) {
|
|
63
|
+
current += `/${segment}`;
|
|
64
|
+
if (await this.exists(current)) continue;
|
|
65
|
+
const response = await this.request("MKCOL", current);
|
|
66
|
+
if (response.statusCode >= 200 && response.statusCode < 300) continue;
|
|
67
|
+
if (response.statusCode === 405) continue;
|
|
68
|
+
throw classifyStatus(response.statusCode, "MKCOL");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async request(method, remotePath, body, extraHeaders = {}) {
|
|
73
|
+
const url = requestUrl(this.baseUrl, remotePath);
|
|
74
|
+
const bodyBuffer = body === undefined ? undefined : Buffer.from(body);
|
|
75
|
+
const headers = {
|
|
76
|
+
Authorization: `Basic ${Buffer.from(`${this.username}:${this.password}`).toString("base64")}`,
|
|
77
|
+
"User-Agent": "davnote/1",
|
|
78
|
+
...extraHeaders,
|
|
79
|
+
};
|
|
80
|
+
if (bodyBuffer) headers["Content-Length"] = String(bodyBuffer.length);
|
|
81
|
+
|
|
82
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
83
|
+
try {
|
|
84
|
+
return await this.requestOnce(url, { method, headers }, bodyBuffer);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (attempt === 0 && isTemporary(error)) continue;
|
|
87
|
+
throw new DavnoteError("NETWORK_ERROR", "WebDAV network request failed", {
|
|
88
|
+
temporary: isTemporary(error),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
throw new DavnoteError("NETWORK_ERROR", "WebDAV network request failed");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
requestOnce(url, options, body) {
|
|
96
|
+
if (this.requestImpl) return this.requestImpl(url, options, body);
|
|
97
|
+
const requestFn = url.protocol === "https:" ? https.request : http.request;
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
const request = requestFn(
|
|
100
|
+
url,
|
|
101
|
+
{ ...options, agent: this.agent, timeout: this.timeout },
|
|
102
|
+
(response) => {
|
|
103
|
+
response.resume();
|
|
104
|
+
response.on("end", () => resolve({ statusCode: response.statusCode ?? 0 }));
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
request.on("timeout", () => request.destroy(Object.assign(new Error("request timed out"), { code: "ETIMEDOUT" })));
|
|
108
|
+
request.on("error", reject);
|
|
109
|
+
if (body) request.write(body);
|
|
110
|
+
request.end();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function joinRemote(root, relative) {
|
|
116
|
+
return cleanRemote(path.posix.join("/", root, relative));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function remoteParent(remotePath) {
|
|
120
|
+
return path.posix.dirname(cleanRemote(remotePath));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function requestUrl(baseUrl, remotePath) {
|
|
124
|
+
const url = new URL(baseUrl);
|
|
125
|
+
url.pathname = path.posix.join(baseUrl.pathname, cleanRemote(remotePath));
|
|
126
|
+
return url;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function cleanRemote(value) {
|
|
130
|
+
return path.posix.normalize(`/${String(value).trim()}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function classifyStatus(statusCode, operation) {
|
|
134
|
+
if (statusCode === 401 || statusCode === 403) {
|
|
135
|
+
return new DavnoteError(
|
|
136
|
+
"AUTH_FAILED",
|
|
137
|
+
"WebDAV authentication or authorization failed",
|
|
138
|
+
{ statusCode },
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (statusCode >= 500) {
|
|
142
|
+
return new DavnoteError("SERVER_ERROR", `WebDAV server returned HTTP ${statusCode}`, {
|
|
143
|
+
statusCode,
|
|
144
|
+
temporary: true,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return new DavnoteError(
|
|
148
|
+
"SERVER_ERROR",
|
|
149
|
+
`WebDAV ${operation} returned HTTP ${statusCode}`,
|
|
150
|
+
{ statusCode },
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isTemporary(error) {
|
|
155
|
+
return ["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EAI_AGAIN"].includes(error?.code);
|
|
156
|
+
}
|