Files

385 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bun
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { homedir } from "node:os";
const CONFIG_PATH = process.env.EDGEEVER_CONFIG || join(homedir(), ".edgeever", "config.json");
const DEFAULT_URL = "http://127.0.0.1:8787";
const rawArgs = process.argv.slice(2);
const globalOptions = parseOptions(rawArgs);
const commandArgs = stripGlobalOptions(rawArgs, new Set(["profile"]));
const [command, ...argv] = commandArgs;
const usage = `EdgeEver CLI
Usage:
bun run cli -- profile set <name> --url <url> --token <token>
bun run cli -- profile list
bun run cli -- --profile <name> notebooks
bun run cli -- --profile <name> tags
bun run cli -- --profile <name> search <query>
bun run cli -- --profile <name> get <memoId>
bun run cli -- --profile <name> create --notebook <id> [--title <title>] [--body <markdown> | --body-file <path>] [--tags a,b]
bun run cli -- --profile <name> update <memoId> [--title <title>] [--body <markdown> | --body-file <path>] [--tags a,b] [--notebook <id>]
bun run cli -- --profile <name> move --notebook <id> <memoId...>
bun run cli -- --profile <name> merge [--notebook <id>] [--title <title>] <memoId...>
bun run cli -- --profile <name> upload --memo <id> --file <path> [--type <mime>]
bun run cli -- --profile <name> export <memoId> [--format markdown|json] [--out <path>]
Env fallback:
EDGEEVER_URL=http://127.0.0.1:8787
EDGEEVER_TOKEN=...
`;
if (!command || command === "help" || command === "--help" || command === "-h") {
console.log(usage);
process.exit(0);
}
const main = async () => {
if (command === "profile") {
return handleProfileCommand(argv);
}
const client = await createClient(globalOptions.profile);
switch (command) {
case "notebooks":
return printJson(await client.request("/api/v1/notebooks"));
case "tags":
return printJson(await client.request("/api/v1/tags"));
case "search": {
const query = argv.join(" ").trim();
const params = new URLSearchParams();
if (query) {
params.set("q", query);
}
return printJson(await client.request(`/api/v1/memos?${params.toString()}`));
}
case "get": {
const memoId = requireValue(argv[0], "memoId");
return printJson(await client.request(`/api/v1/memos/${encodeURIComponent(memoId)}`));
}
case "create": {
const options = parseOptions(argv);
const notebookId = requireValue(options.notebook, "--notebook");
const body = await readBodyOption(options);
return printJson(
await client.request("/api/v1/memos", {
method: "POST",
body: {
notebookId,
title: options.title,
contentMarkdown: body,
tags: parseTags(options.tags),
},
})
);
}
case "update": {
const memoId = requireValue(argv[0], "memoId");
const options = parseOptions(argv.slice(1));
const body = options.body || options["body-file"] ? await readBodyOption(options) : undefined;
const payload = removeUndefined({
title: options.title,
notebookId: options.notebook,
contentMarkdown: body,
tags: options.tags === undefined ? undefined : parseTags(options.tags),
});
return printJson(
await client.request(`/api/v1/memos/${encodeURIComponent(memoId)}`, {
method: "PATCH",
body: payload,
})
);
}
case "move": {
const options = parseOptions(argv);
const memoIds = positionalArgs(argv);
return printJson(
await client.request("/api/v1/memos/batch/move", {
method: "POST",
body: {
notebookId: requireValue(options.notebook, "--notebook"),
memoIds,
},
})
);
}
case "merge": {
const options = parseOptions(argv);
const memoIds = positionalArgs(argv);
return printJson(
await client.request("/api/v1/memos/merge", {
method: "POST",
body: removeUndefined({
notebookId: options.notebook,
title: options.title,
memoIds,
}),
})
);
}
case "upload": {
const options = parseOptions(argv);
const memoId = requireValue(options.memo, "--memo");
const filePath = requireValue(options.file, "--file");
return printJson(await client.uploadMemoResource(memoId, filePath, options.type));
}
case "export": {
const memoId = requireValue(argv[0], "memoId");
const options = parseOptions(argv.slice(1));
const format = options.format === "json" ? "json" : "markdown";
const data = await client.request(`/api/v1/memos/${encodeURIComponent(memoId)}`);
const output = format === "json" ? `${JSON.stringify(data.memo, null, 2)}\n` : data.memo.contentMarkdown;
if (options.out) {
await writeFile(options.out, output);
return;
}
process.stdout.write(output);
return;
}
default:
console.error(`Unknown command: ${command}\n`);
console.error(usage);
process.exit(1);
}
};
const createClient = async (profileName) => {
const config = await readConfig();
const profile = profileName ? config.profiles?.[profileName] : undefined;
const baseUrl = (process.env.EDGEEVER_URL || profile?.url || DEFAULT_URL).replace(/\/+$/, "");
const token = process.env.EDGEEVER_TOKEN || profile?.token;
if (!token) {
throw new Error("EDGEEVER_TOKEN is required, or configure a profile with `edgeever profile set`.");
}
return {
request: (path, init = {}) => request(baseUrl, token, path, init),
uploadMemoResource: (memoId, filePath, mimeType) => uploadMemoResource(baseUrl, token, memoId, filePath, mimeType),
};
};
const request = async (baseUrl, token, path, init = {}) => {
const response = await fetch(`${baseUrl}${path}`, {
method: init.method || "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: init.body === undefined ? undefined : JSON.stringify(init.body),
});
const body = await response.json().catch(() => null);
if (!response.ok) {
const message = body?.error?.message || response.statusText;
throw new Error(`${response.status} ${message}`);
}
return body;
};
const uploadMemoResource = async (baseUrl, token, memoId, filePath, mimeType) => {
const bytes = await readFile(filePath);
const form = new FormData();
form.append("file", new File([bytes], basename(filePath), { type: mimeType || inferMimeType(filePath) }));
const response = await fetch(`${baseUrl}/api/v1/memos/${encodeURIComponent(memoId)}/resources`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: form,
});
const body = await response.json().catch(() => null);
if (!response.ok) {
const message = body?.error?.message || response.statusText;
throw new Error(`${response.status} ${message}`);
}
return body;
};
const handleProfileCommand = async (args) => {
const [subcommand, name, ...rest] = args;
if (subcommand === "list") {
const config = await readConfig();
return printJson({
profiles: Object.entries(config.profiles || {}).map(([profileName, profile]) => ({
name: profileName,
url: profile.url,
hasToken: Boolean(profile.token),
})),
});
}
if (subcommand === "set") {
const profileName = requireValue(name, "profile name");
const options = parseOptions(rest);
const config = await readConfig();
config.profiles = config.profiles || {};
config.profiles[profileName] = {
url: requireValue(options.url, "--url").replace(/\/+$/, ""),
token: requireValue(options.token, "--token"),
};
await writeConfig(config);
return printJson({ ok: true, profile: profileName, path: CONFIG_PATH });
}
throw new Error("Unknown profile command.");
};
const readConfig = async () => {
try {
const value = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
return value && typeof value === "object" ? value : {};
} catch {
return {};
}
};
const writeConfig = async (config) => {
await mkdir(dirname(CONFIG_PATH), { recursive: true, mode: 0o700 });
await writeFile(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
};
function parseOptions(args) {
const options = {};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg.startsWith("--")) {
continue;
}
const key = arg.slice(2);
const next = args[index + 1];
if (!next || next.startsWith("--")) {
options[key] = "true";
continue;
}
options[key] = next;
index += 1;
}
return options;
}
function positionalArgs(args) {
const values = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg.startsWith("--")) {
values.push(arg);
continue;
}
const next = args[index + 1];
if (next && !next.startsWith("--")) {
index += 1;
}
}
return values;
}
function stripGlobalOptions(args, globalKeys) {
const result = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg.startsWith("--") || !globalKeys.has(arg.slice(2))) {
result.push(arg);
continue;
}
if (args[index + 1] && !args[index + 1].startsWith("--")) {
index += 1;
}
}
return result;
}
const readBodyOption = async (options) => {
if (options["body-file"]) {
return readFile(options["body-file"], "utf8");
}
return options.body || "";
};
const parseTags = (value) =>
value
? value
.split(/[,,\s]+/)
.map((tag) => tag.trim().replace(/^#/, ""))
.filter(Boolean)
: [];
const inferMimeType = (filePath) => {
const normalized = filePath.toLowerCase();
if (normalized.endsWith(".png")) {
return "image/png";
}
if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg")) {
return "image/jpeg";
}
if (normalized.endsWith(".gif")) {
return "image/gif";
}
if (normalized.endsWith(".webp")) {
return "image/webp";
}
if (normalized.endsWith(".avif")) {
return "image/avif";
}
return "application/octet-stream";
};
const removeUndefined = (value) =>
Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
const requireValue = (value, name) => {
if (!value) {
throw new Error(`${name} is required.`);
}
return value;
};
const printJson = (value) => {
console.log(JSON.stringify(value, null, 2));
};
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});