yaf-cli 0.0.0-stage → 0.1.1
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/README.md +96 -2
- package/dist/index.js +249 -0
- package/package.json +27 -4
package/README.md
CHANGED
|
@@ -1,3 +1,97 @@
|
|
|
1
|
-
#
|
|
1
|
+
# yaf-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[English](#english) · [中文](#中文)
|
|
4
|
+
|
|
5
|
+
Command-line client for [yaf](https://github.com/uxgnod/yaf), a self-hosted, flomo-style memo app that runs on Cloudflare Workers.
|
|
6
|
+
|
|
7
|
+
yaf 的命令行客户端。yaf 是一个跑在 Cloudflare Workers 上、可自部署的 flomo 风格笔记应用。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## English
|
|
12
|
+
|
|
13
|
+
> You need your own yaf deployment. This CLI has no built-in server address. See the [yaf repository](https://github.com/uxgnod/yaf) to deploy one.
|
|
14
|
+
|
|
15
|
+
### Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install -g yaf-cli
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Requires Node.js 20 or later. Once installed, the command is `yaf`.
|
|
22
|
+
|
|
23
|
+
### Quick start
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
yaf login --host https://yaf.example.com # confirm in the browser; asks for the address if --host is omitted
|
|
27
|
+
yaf add "Weekly reports could be generated from memos #idea"
|
|
28
|
+
echo "Notes from a pipe #inbox" | yaf add
|
|
29
|
+
yaf search "weekly report"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Commands
|
|
33
|
+
|
|
34
|
+
| Command | Description |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `yaf login [--host <url>]` | Sign in through the browser (device code). The address is saved after the first login. |
|
|
37
|
+
| `yaf add [text]` | Create a memo. Reads stdin when no text is given. `#tags` (including `#a/b`) are recognized. |
|
|
38
|
+
| `yaf search [query] [-t <tag>] [-n <limit>] [--json]` | Search memos (hybrid keyword + semantic). Default limit is 20. |
|
|
39
|
+
| `yaf tags` | List tags with counts. |
|
|
40
|
+
| `yaf export [-t <tag>] [-f md\|json] [-o <file>]` | Export memos as Markdown (default) or JSON, to stdout or a file. |
|
|
41
|
+
| `yaf whoami` | Show the signed-in account and server. |
|
|
42
|
+
| `yaf logout` | Delete the locally saved token. |
|
|
43
|
+
|
|
44
|
+
### Configuration
|
|
45
|
+
|
|
46
|
+
- The server address and token are stored in `~/.config/yaf/config.json` (file mode `600`). `XDG_CONFIG_HOME` is respected.
|
|
47
|
+
- `YAF_HOST` and `YAF_TOKEN` override the config file, which is useful for scripts and CI. Create a token in **Settings › API Token** of your yaf instance.
|
|
48
|
+
- `yaf logout` only deletes the local copy. To revoke a token, use **Settings › API Token**.
|
|
49
|
+
- Memos created by the CLI are marked with a CLI source icon. To tag them automatically, turn on **Settings › API Token › Auto tags › CLI**.
|
|
50
|
+
|
|
51
|
+
Messages printed by the CLI are currently in Chinese.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 中文
|
|
56
|
+
|
|
57
|
+
> 需要先有自己部署的 yaf 服务,CLI 不内置任何服务地址。部署方法见 [yaf 仓库](https://github.com/uxgnod/yaf)。
|
|
58
|
+
|
|
59
|
+
### 安装
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
npm install -g yaf-cli
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
需要 Node.js 20 或更高版本。安装后使用的命令是 `yaf`。
|
|
66
|
+
|
|
67
|
+
### 快速开始
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
yaf login --host https://yaf.example.com # 在浏览器里确认;不带 --host 时会询问地址
|
|
71
|
+
yaf add "周报可以直接从 memo 里生成 #想法"
|
|
72
|
+
echo "从管道写入 #inbox" | yaf add
|
|
73
|
+
yaf search 周报
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 命令
|
|
77
|
+
|
|
78
|
+
| 命令 | 说明 |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| `yaf login [--host <url>]` | 在浏览器里授权登录(设备码)。地址在第一次登录后保存。 |
|
|
81
|
+
| `yaf add [内容]` | 新增 memo;不带内容时读取标准输入。支持 `#标签` 和层级 `#a/b`。 |
|
|
82
|
+
| `yaf search [关键词] [-t <标签>] [-n <条数>] [--json]` | 搜索 memo(关键词 + 语义混合检索),默认 20 条。 |
|
|
83
|
+
| `yaf tags` | 列出标签和数量。 |
|
|
84
|
+
| `yaf export [-t <标签>] [-f md\|json] [-o <文件>]` | 导出为 Markdown(默认)或 JSON,输出到终端或文件。 |
|
|
85
|
+
| `yaf whoami` | 查看当前登录的账号和服务地址。 |
|
|
86
|
+
| `yaf logout` | 删除本地保存的 Token。 |
|
|
87
|
+
|
|
88
|
+
### 配置
|
|
89
|
+
|
|
90
|
+
- 服务地址和 Token 保存在 `~/.config/yaf/config.json`(权限 `600`),支持 `XDG_CONFIG_HOME`。
|
|
91
|
+
- 环境变量 `YAF_HOST`、`YAF_TOKEN` 优先于配置文件,适合脚本和 CI。Token 在 yaf 的「设置 › API Token」里创建。
|
|
92
|
+
- `yaf logout` 只删除本地副本。要让 Token 失效,请在「设置 › API Token」里撤销。
|
|
93
|
+
- CLI 写入的 memo 会显示 CLI 来源图标。想自动加标签,在「设置 › API Token › 自动标签 › CLI」里开启。
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// yaf CLI:yaf login / add / search / tags / export / whoami / logout
|
|
3
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir, hostname } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { exec } from "node:child_process";
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "yaf");
|
|
10
|
+
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
11
|
+
const HELP = `yaf — 在终端里记录和搜索 memo
|
|
12
|
+
|
|
13
|
+
用法:
|
|
14
|
+
yaf login [--host <url>] 打开浏览器授权,保存 Token;第一次登录需填写服务地址
|
|
15
|
+
yaf add [内容] 新增 memo;不带内容时读取标准输入(支持管道)
|
|
16
|
+
yaf search [关键词] [--tag <t>] 搜索,可加 --limit <n>、--json
|
|
17
|
+
yaf tags 列出标签和数量
|
|
18
|
+
yaf export [--tag <t>] [--format md|json] [-o <文件>]
|
|
19
|
+
yaf whoami 查看当前登录
|
|
20
|
+
yaf logout 删除本地保存的 Token
|
|
21
|
+
|
|
22
|
+
环境变量:YAF_HOST、YAF_TOKEN 可覆盖配置文件(${CONFIG_FILE})。`;
|
|
23
|
+
async function loadConfig() {
|
|
24
|
+
let file = {};
|
|
25
|
+
try {
|
|
26
|
+
file = JSON.parse(await readFile(CONFIG_FILE, "utf8"));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* 没有配置文件 */
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
host: normalizeHost(process.env.YAF_HOST || file.host || ""),
|
|
33
|
+
token: process.env.YAF_TOKEN || file.token,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
async function saveConfig(cfg) {
|
|
37
|
+
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
38
|
+
await writeFile(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
|
|
39
|
+
}
|
|
40
|
+
function die(msg) {
|
|
41
|
+
console.error(`yaf: ${msg}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
/** 补全协议、去掉末尾斜杠;空字符串表示未设置 */
|
|
45
|
+
function normalizeHost(raw) {
|
|
46
|
+
const v = raw.trim().replace(/\/+$/, "");
|
|
47
|
+
if (!v)
|
|
48
|
+
return "";
|
|
49
|
+
// 没写协议时默认 https;本机地址默认 http(本地开发)
|
|
50
|
+
const local = /^(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/i.test(v);
|
|
51
|
+
const withScheme = /^https?:\/\//i.test(v) ? v : `${local ? "http" : "https"}://${v}`;
|
|
52
|
+
try {
|
|
53
|
+
const u = new URL(withScheme);
|
|
54
|
+
return u.origin + u.pathname.replace(/\/+$/, "");
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
die(`服务地址无效:${raw}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** 第一次登录且没给 --host 时,在终端里询问服务地址 */
|
|
61
|
+
async function askHost() {
|
|
62
|
+
if (!process.stdin.isTTY)
|
|
63
|
+
die("未设置服务地址,请运行 yaf login --host <你的 yaf 地址>");
|
|
64
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
65
|
+
try {
|
|
66
|
+
const answer = await rl.question("yaf 服务地址(例如 https://yaf.example.workers.dev):");
|
|
67
|
+
const host = normalizeHost(answer);
|
|
68
|
+
if (!host)
|
|
69
|
+
die("未填写服务地址");
|
|
70
|
+
return host;
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
rl.close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function api(cfg, path, init = {}) {
|
|
77
|
+
if (!cfg.host)
|
|
78
|
+
die("未设置服务地址,请先运行 yaf login --host <你的 yaf 地址>");
|
|
79
|
+
if (!cfg.token)
|
|
80
|
+
die("未登录,请先运行 yaf login");
|
|
81
|
+
const res = await fetch(cfg.host + "/api" + path, {
|
|
82
|
+
...init,
|
|
83
|
+
headers: {
|
|
84
|
+
Authorization: `Bearer ${cfg.token}`,
|
|
85
|
+
"Content-Type": "application/json",
|
|
86
|
+
"X-Yaf-Source": "cli",
|
|
87
|
+
...init.headers,
|
|
88
|
+
},
|
|
89
|
+
}).catch(() => die(`无法连接 ${cfg.host},请检查网络或重新运行 yaf login --host <地址>`));
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
const data = (await res.json().catch(() => ({})));
|
|
92
|
+
if (res.status === 401)
|
|
93
|
+
die(`${data.error ?? "未授权"},请重新运行 yaf login`);
|
|
94
|
+
die(data.error ?? `${res.status} ${res.statusText}`);
|
|
95
|
+
}
|
|
96
|
+
return res;
|
|
97
|
+
}
|
|
98
|
+
async function json(cfg, path, init) {
|
|
99
|
+
const res = await api(cfg, path, init);
|
|
100
|
+
return (await res.json());
|
|
101
|
+
}
|
|
102
|
+
async function readStdin() {
|
|
103
|
+
if (process.stdin.isTTY)
|
|
104
|
+
return "";
|
|
105
|
+
const chunks = [];
|
|
106
|
+
for await (const c of process.stdin)
|
|
107
|
+
chunks.push(c);
|
|
108
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
109
|
+
}
|
|
110
|
+
function openBrowser(url) {
|
|
111
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? 'start ""' : "xdg-open";
|
|
112
|
+
exec(`${cmd} "${url}"`, () => {
|
|
113
|
+
/* 打不开就让用户手动访问 */
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
117
|
+
function formatTime(ts) {
|
|
118
|
+
const d = new Date(ts);
|
|
119
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
120
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
121
|
+
}
|
|
122
|
+
const dim = (s) => (process.stdout.isTTY ? `\x1b[2m${s}\x1b[0m` : s);
|
|
123
|
+
function printMemo(m) {
|
|
124
|
+
console.log(dim(`${formatTime(m.createdAt)} ${m.id.slice(0, 8)}`));
|
|
125
|
+
console.log(m.content);
|
|
126
|
+
console.log();
|
|
127
|
+
}
|
|
128
|
+
// ---------- 命令 ----------
|
|
129
|
+
async function login(args) {
|
|
130
|
+
const { values } = parseArgs({ args, options: { host: { type: "string" } } });
|
|
131
|
+
const cfg = await loadConfig();
|
|
132
|
+
const host = normalizeHost(values.host ?? "") || cfg.host || (await askHost());
|
|
133
|
+
const res = await fetch(`${host}/api/device/code`, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: { "Content-Type": "application/json" },
|
|
136
|
+
body: JSON.stringify({ client_name: `CLI · ${hostname()}` }),
|
|
137
|
+
}).catch(() => die(`无法连接 ${host},请检查地址是否正确`));
|
|
138
|
+
if (!res.ok)
|
|
139
|
+
die(`无法连接 ${host}(${res.status}),请检查地址是否正确`);
|
|
140
|
+
const d = (await res.json());
|
|
141
|
+
console.log(`请在浏览器中确认登录,代码:${d.user_code}`);
|
|
142
|
+
console.log(d.verification_uri_complete);
|
|
143
|
+
openBrowser(d.verification_uri_complete);
|
|
144
|
+
const deadline = Date.now() + d.expires_in * 1000;
|
|
145
|
+
while (Date.now() < deadline) {
|
|
146
|
+
await sleep(d.interval * 1000);
|
|
147
|
+
const r = await fetch(`${host}/api/device/token`, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: { "Content-Type": "application/json" },
|
|
150
|
+
body: JSON.stringify({ device_code: d.device_code }),
|
|
151
|
+
});
|
|
152
|
+
const body = (await r.json().catch(() => ({})));
|
|
153
|
+
if (body.access_token) {
|
|
154
|
+
await saveConfig({ host, token: body.access_token });
|
|
155
|
+
console.log(`已登录 ${host},Token 保存在 ${CONFIG_FILE}`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (body.error === "access_denied")
|
|
159
|
+
die("登录请求被拒绝");
|
|
160
|
+
if (body.error === "expired_token")
|
|
161
|
+
die("代码已过期,请重新运行 yaf login");
|
|
162
|
+
}
|
|
163
|
+
die("等待超时,请重新运行 yaf login");
|
|
164
|
+
}
|
|
165
|
+
async function add(args) {
|
|
166
|
+
const cfg = await loadConfig();
|
|
167
|
+
const content = (args.join(" ") || (await readStdin())).trim();
|
|
168
|
+
if (!content)
|
|
169
|
+
die("内容为空。用法:yaf add <内容> 或 echo 内容 | yaf add");
|
|
170
|
+
const { memo } = await json(cfg, "/memos", { method: "POST", body: JSON.stringify({ content }) });
|
|
171
|
+
console.log(`已记录 ${memo.id.slice(0, 8)}${memo.tags.length ? " · " + memo.tags.map((t) => "#" + t).join(" ") : ""}`);
|
|
172
|
+
}
|
|
173
|
+
async function search(args) {
|
|
174
|
+
const { values, positionals } = parseArgs({
|
|
175
|
+
args,
|
|
176
|
+
allowPositionals: true,
|
|
177
|
+
options: { tag: { type: "string", short: "t" }, limit: { type: "string", short: "n" }, json: { type: "boolean" } },
|
|
178
|
+
});
|
|
179
|
+
const cfg = await loadConfig();
|
|
180
|
+
const qs = new URLSearchParams();
|
|
181
|
+
if (positionals.length)
|
|
182
|
+
qs.set("q", positionals.join(" "));
|
|
183
|
+
if (values.tag)
|
|
184
|
+
qs.set("tag", values.tag.replace(/^#/, ""));
|
|
185
|
+
qs.set("limit", values.limit || "20");
|
|
186
|
+
const r = await json(cfg, `/memos?${qs}`);
|
|
187
|
+
if (values.json)
|
|
188
|
+
return console.log(JSON.stringify(r.memos, null, 2));
|
|
189
|
+
if (!r.memos.length)
|
|
190
|
+
return console.log("没有匹配的 memo");
|
|
191
|
+
for (const m of r.memos)
|
|
192
|
+
printMemo(m);
|
|
193
|
+
if (r.nextCursor)
|
|
194
|
+
console.log(dim("(还有更多,可用 --limit 增加条数)"));
|
|
195
|
+
}
|
|
196
|
+
async function tags() {
|
|
197
|
+
const cfg = await loadConfig();
|
|
198
|
+
const r = await json(cfg, "/tags");
|
|
199
|
+
if (!r.tags.length)
|
|
200
|
+
return console.log("还没有标签");
|
|
201
|
+
for (const t of r.tags)
|
|
202
|
+
console.log(`#${t.tag} ${dim(String(t.count))}`);
|
|
203
|
+
}
|
|
204
|
+
async function exportCmd(args) {
|
|
205
|
+
const { values } = parseArgs({
|
|
206
|
+
args,
|
|
207
|
+
options: { tag: { type: "string", short: "t" }, format: { type: "string", short: "f" }, output: { type: "string", short: "o" } },
|
|
208
|
+
});
|
|
209
|
+
const cfg = await loadConfig();
|
|
210
|
+
const format = values.format === "json" ? "json" : "md";
|
|
211
|
+
const qs = new URLSearchParams({ format });
|
|
212
|
+
if (values.tag)
|
|
213
|
+
qs.set("tag", values.tag.replace(/^#/, ""));
|
|
214
|
+
const res = await api(cfg, `/export?${qs}`);
|
|
215
|
+
const body = await res.text();
|
|
216
|
+
if (values.output) {
|
|
217
|
+
await writeFile(values.output, body);
|
|
218
|
+
console.error(`已导出到 ${values.output}`);
|
|
219
|
+
}
|
|
220
|
+
else
|
|
221
|
+
process.stdout.write(body);
|
|
222
|
+
}
|
|
223
|
+
async function whoami() {
|
|
224
|
+
const cfg = await loadConfig();
|
|
225
|
+
const { user } = await json(cfg, "/auth/me");
|
|
226
|
+
console.log(`${user.name ? user.name + " " : ""}<${user.email}> @ ${cfg.host}`);
|
|
227
|
+
}
|
|
228
|
+
async function logout() {
|
|
229
|
+
await rm(CONFIG_FILE, { force: true });
|
|
230
|
+
console.log("已删除本地 Token。如需让它彻底失效,请在 设置 › API Token 中撤销。");
|
|
231
|
+
}
|
|
232
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
233
|
+
const commands = {
|
|
234
|
+
login,
|
|
235
|
+
add,
|
|
236
|
+
search,
|
|
237
|
+
tags,
|
|
238
|
+
export: exportCmd,
|
|
239
|
+
whoami,
|
|
240
|
+
logout,
|
|
241
|
+
};
|
|
242
|
+
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h")
|
|
243
|
+
console.log(HELP);
|
|
244
|
+
else if (cmd === "--version" || cmd === "-v")
|
|
245
|
+
console.log(JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")).version);
|
|
246
|
+
else if (!commands[cmd])
|
|
247
|
+
die(`未知命令 ${cmd}\n\n${HELP}`);
|
|
248
|
+
else
|
|
249
|
+
commands[cmd](rest).catch((e) => die(e instanceof Error ? e.message : String(e)));
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yaf-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Command line client for yaf (Yet Another Flomo)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"yaf": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/uxgnod/yaf.git",
|
|
19
|
+
"directory": "packages/cli"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -p .",
|
|
23
|
+
"typecheck": "tsc -p . --noEmit",
|
|
24
|
+
"prepublishOnly": "npm run build"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^24.19.2"
|
|
28
|
+
}
|
|
29
|
+
}
|