nx-kv 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.
Files changed (48) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +21 -0
  3. package/README.md +133 -0
  4. package/assets/nx-kv/SKILL.md +99 -0
  5. package/assets/nx-kv/references/agent-workflow.md +178 -0
  6. package/assets/nx-kv/references/todo-commands.md +193 -0
  7. package/bin/cli.mjs +10 -0
  8. package/package.json +57 -0
  9. package/src/core/config.js +94 -0
  10. package/src/core/errors.js +81 -0
  11. package/src/core/frontmatter.js +65 -0
  12. package/src/core/fstree.js +74 -0
  13. package/src/core/kvapi.js +201 -0
  14. package/src/core/open.js +26 -0
  15. package/src/core/paths.js +46 -0
  16. package/src/core/prompt.js +60 -0
  17. package/src/index.js +9 -0
  18. package/src/modules/auth/index.js +98 -0
  19. package/src/modules/auth/view.jsx +78 -0
  20. package/src/modules/bundled/index.js +51 -0
  21. package/src/modules/bundled/service.js +80 -0
  22. package/src/modules/group/index.js +199 -0
  23. package/src/modules/group/view.jsx +159 -0
  24. package/src/modules/system/index.js +153 -0
  25. package/src/modules/todo/index.js +280 -0
  26. package/src/modules/todo/service.js +524 -0
  27. package/src/modules/todo/view.jsx +280 -0
  28. package/src/runtime/api.js +108 -0
  29. package/src/runtime/cli.js +299 -0
  30. package/src/runtime/registry.js +56 -0
  31. package/src/runtime/server.js +74 -0
  32. package/src/runtime/spec.js +147 -0
  33. package/src/web/frontend/App.jsx +74 -0
  34. package/src/web/frontend/api/client.js +39 -0
  35. package/src/web/frontend/components/CliHints.jsx +25 -0
  36. package/src/web/frontend/components/ui.jsx +197 -0
  37. package/src/web/frontend/index.html +13 -0
  38. package/src/web/frontend/main.jsx +17 -0
  39. package/src/web/frontend/registry.js +12 -0
  40. package/src/web/frontend/store.jsx +74 -0
  41. package/src/web/frontend/style.css +368 -0
  42. package/src/web/public/assets/CliHints-3H3KOoDZ.js +1 -0
  43. package/src/web/public/assets/index-BnWex75m.css +1 -0
  44. package/src/web/public/assets/index-jhTtYCb9.js +10 -0
  45. package/src/web/public/assets/view-CrL-DSJu.js +1 -0
  46. package/src/web/public/assets/view-Du7Wv_-h.js +1 -0
  47. package/src/web/public/assets/view-S0N5g6v1.js +2 -0
  48. package/src/web/public/index.html +13 -0
@@ -0,0 +1,280 @@
1
+ // 清单页:三个桶(待办 / 已完成 / 冻结)+ 主题筛选 + 增删改查与状态流转。
2
+ // 每个按钮 = 一条 /api 路由 = 一条 CLI 命令(三者同源于模块的 action 声明)。
3
+ import { useCallback, useEffect, useMemo, useState } from 'react';
4
+ import { api } from '../../web/frontend/api/client.js';
5
+ import { useStore } from '../../web/frontend/store.jsx';
6
+ import { useToast, useGuard, useDialog, Modal, Copyable } from '../../web/frontend/components/ui.jsx';
7
+ import { CliHints } from '../../web/frontend/components/CliHints.jsx';
8
+
9
+ const BUCKETS = [
10
+ ['open', '待办'],
11
+ ['done', '已完成'],
12
+ ['freeze', '冻结'],
13
+ ];
14
+
15
+ const shortTime = (s) => (s ? String(s).slice(0, 16).replace('T', ' ') : '');
16
+
17
+ // 单个桶超过这个条数就折叠。已完成常年上百条,全铺出来会把待办挤到看不见。
18
+ const COLLAPSE_AT = 15;
19
+
20
+ // 一个任务在哪些桶里可能重名 —— id 不是唯一键,UI 必须能显示出来
21
+ const taskKey = (bucket, t) => `${bucket}:${t.id}`;
22
+
23
+ function TaskRow({ bucket, task, dupCount, onAct }) {
24
+ const isDone = bucket === 'done';
25
+ const isFrozen = bucket === 'freeze';
26
+ const dt = isDone ? task.doneAt : isFrozen ? task.frozenAt : task.createdAt;
27
+
28
+ return (
29
+ <div className="row">
30
+ <span className="tid">#{task.id}</span>
31
+ <Copyable className="topic" text={task.topic} title="点击复制主题">{task.topic}</Copyable>
32
+ <span className="text" title={task.text}>{task.text}</span>
33
+ {dupCount > 1 ? (
34
+ <span className="tag" title={`同 id 有 ${dupCount} 条,操作时需要消歧`}>id×{dupCount}</span>
35
+ ) : null}
36
+ <span className="muted time">{shortTime(dt)}</span>
37
+ <span className="acts">
38
+ {bucket === 'open' ? (
39
+ <>
40
+ <button className="btn small" onClick={() => onAct('done', task)}>完成</button>
41
+ <button className="btn small ghost" onClick={() => onAct('freeze', task)}>冻结</button>
42
+ </>
43
+ ) : null}
44
+ {isFrozen ? (
45
+ <button className="btn small" onClick={() => onAct('unfreeze', task)}>解冻</button>
46
+ ) : null}
47
+ <button className="btn small ghost" onClick={() => onAct('edit', task, bucket)}>编辑</button>
48
+ <button className="btn small ghost danger" onClick={() => onAct('remove', task, bucket)}>删除</button>
49
+ </span>
50
+ {task.note ? <div className="note">{task.note}</div> : null}
51
+ </div>
52
+ );
53
+ }
54
+
55
+ export default function TodoView() {
56
+ const { boot, ui, patchUi, refreshBoot } = useStore();
57
+ const toast = useToast();
58
+ const guard = useGuard();
59
+ const { dialog, node: dialogNode } = useDialog();
60
+
61
+ const [data, setData] = useState(null);
62
+ const [busy, setBusy] = useState(false);
63
+ const [form, setForm] = useState({ topic: '', text: '' });
64
+ const [modal, setModal] = useState(null);
65
+ // 已完成常常上百条,默认只展示前 N 条,避免整页全是历史噪音
66
+ const [expanded, setExpanded] = useState({});
67
+
68
+ const group = boot?.groupId ?? 0;
69
+ const groupName = (boot?.groups || []).find((g) => g.id === group)?.name || (group ? `#${group}` : '默认组');
70
+
71
+ const load = useCallback(async () => {
72
+ if (!boot?.loggedIn) return;
73
+ try {
74
+ setData(await api('/api/todo'));
75
+ } catch (e) {
76
+ toast(e.message);
77
+ }
78
+ }, [boot?.loggedIn, toast]);
79
+
80
+ useEffect(() => {
81
+ load();
82
+ // groupId 变化后必须重取 —— 否则界面还显示上一个工作空间的数据
83
+ }, [load, group]);
84
+
85
+ const reload = useCallback(async () => {
86
+ setBusy(true);
87
+ try {
88
+ await load();
89
+ } finally {
90
+ setBusy(false);
91
+ }
92
+ }, [load]);
93
+
94
+ // 同 id 出现几次 —— 让用户在点「删除」前就看见歧义风险
95
+ const dupCounts = useMemo(() => {
96
+ const m = new Map();
97
+ if (!data) return m;
98
+ for (const [key] of BUCKETS) {
99
+ for (const t of data[key] || []) m.set(t.id, (m.get(t.id) || 0) + 1);
100
+ }
101
+ return m;
102
+ }, [data]);
103
+
104
+ const filtered = useMemo(() => {
105
+ if (!data) return null;
106
+ const topic = ui.topic;
107
+ const pick = (arr) => (arr ? (topic ? arr.filter((t) => t.topic === topic) : arr) : null);
108
+ return { open: pick(data.open), done: pick(data.done), freeze: pick(data.freeze) };
109
+ }, [data, ui.topic]);
110
+
111
+ if (!boot?.loggedIn) {
112
+ return (
113
+ <div className="card" style={{ padding: 16 }}>
114
+ <div className="colhead"><h3>请先登录</h3></div>
115
+ <div className="muted">在「登录」页填写邮箱与密码,或执行 <code>nx-kv auth login &lt;email&gt;</code>。</div>
116
+ </div>
117
+ );
118
+ }
119
+
120
+ // 消歧:同 id 多条时让用户选一条(与 CLI 的 --pick 对应)。
121
+ //
122
+ // 返回的 `pick` 是**在候选列表里的下标**,必须原样回传给后端 ——
123
+ // 否则后端会再次面对「同 id 多条」而拒绝执行(这是刻意的:
124
+ // 不替调用方猜)。候选顺序与服务端 locateAll 一致:
125
+ // 按 open → done → freeze、桶内按数组顺序。
126
+ const pickOne = async (task, bucket) => {
127
+ const candidates = [];
128
+ for (const [key] of BUCKETS) {
129
+ for (const t of data[key] || []) if (t.id === task.id) candidates.push({ bucket: key, task: t });
130
+ }
131
+ if (candidates.length <= 1) return { bucket, task, pick: undefined };
132
+
133
+ const self = candidates.findIndex((c) => c.bucket === bucket && c.task.text === task.text);
134
+ const answer = await dialog({
135
+ title: `id #${task.id} 有 ${candidates.length} 条,选哪一条?`,
136
+ message: candidates
137
+ .map((c, i) => `[${i}] ${c.bucket} ${shortTime(c.task.createdAt)} ${c.task.text.slice(0, 40)}`)
138
+ .join('\n'),
139
+ input: true,
140
+ placeholder: `输入 0..${candidates.length - 1}`,
141
+ value: String(self >= 0 ? self : 0),
142
+ });
143
+ if (answer === null) return null;
144
+ const i = Number(answer);
145
+ if (!Number.isInteger(i) || !candidates[i]) {
146
+ toast(`请输入 0..${candidates.length - 1}`);
147
+ return null;
148
+ }
149
+ return { ...candidates[i], pick: i };
150
+ };
151
+
152
+ const onAct = (act, task, bucket) => guard(async () => {
153
+ // 会改动「某一条」的动作都要先消歧;状态流转只作用于待办/冻结,那里 id 唯一
154
+ const needsPick = act === 'remove' || act === 'edit';
155
+ const target = needsPick ? await pickOne(task, bucket) : { bucket, task, pick: undefined };
156
+ if (!target) return;
157
+ const body = { group, pick: target.pick };
158
+
159
+ if (act === 'done') {
160
+ const result = await dialog({ title: '完成结果', message: `完成「${task.text.slice(0, 40)}」`, input: true, placeholder: '可选:写一句完成结果' });
161
+ if (result === null) return;
162
+ await api(`/api/todo/${task.id}/done`, { method: 'POST', body: { ...body, result: result || undefined } });
163
+ toast('已完成');
164
+ } else if (act === 'freeze') {
165
+ await api(`/api/todo/${task.id}/freeze`, { method: 'POST', body });
166
+ toast('已冻结');
167
+ } else if (act === 'unfreeze') {
168
+ await api(`/api/todo/${task.id}/unfreeze`, { method: 'POST', body });
169
+ toast('已解冻');
170
+ } else if (act === 'remove') {
171
+ const ok = await dialog({ message: `删除 #${task.id}「${task.text.slice(0, 40)}」?`, danger: true });
172
+ if (!ok) return;
173
+ await api(`/api/todo/${target.task.id}`, { method: 'DELETE', body });
174
+ toast('已删除');
175
+ } else if (act === 'edit') {
176
+ const text = await dialog({ title: '编辑内容', input: true, value: target.task.text });
177
+ if (text === null || !text) return;
178
+ await api(`/api/todo/${target.task.id}`, {
179
+ method: 'PATCH',
180
+ body: { ...body, text, matchTopic: target.task.topic },
181
+ });
182
+ toast('已更新');
183
+ }
184
+ await reload();
185
+ await refreshBoot();
186
+ });
187
+
188
+ // 归档:把已完成里较旧的条目移到冷 key(todo:done:cold:<日期>)。
189
+ // 后端没有「批量删」接口,所以这是清理已完成列表的唯一手段。
190
+ const archive = () => guard(async () => {
191
+ const before = await dialog({
192
+ title: '归档已完成的旧记录',
193
+ message: '把「完成时间早于该日期」的记录移到冷 key(默认 30 天前)。留空用默认值。',
194
+ input: true,
195
+ placeholder: 'YYYY-MM-DD,留空 = 30 天前',
196
+ });
197
+ if (before === null) return;
198
+ const r = await api('/api/todo/archive', { method: 'POST', body: { before: before.trim() || undefined, group } });
199
+ toast(r.moved ? `已归档 ${r.moved} 条 → ${r.coldKey}` : '没有需要归档的记录');
200
+ await reload();
201
+ });
202
+
203
+ const add = () => guard(async () => {
204
+ const topic = form.topic.trim();
205
+ const text = form.text.trim();
206
+ if (!topic) { toast('请填主题'); return; }
207
+ if (!text) { toast('请填内容'); return; }
208
+ const r = await api('/api/todo', { method: 'POST', body: { topic, text, group } });
209
+ setForm({ topic, text: '' });
210
+ toast(`已加入待办 #${r.id}${r.topicAdded ? `(新主题 ${topic})` : ''}`);
211
+ await reload();
212
+ });
213
+
214
+ const visible = (key, items) => (expanded[key] ? items : items.slice(0, COLLAPSE_AT));
215
+
216
+ const topics = data?.topics || [];
217
+ const counts = filtered
218
+ ? BUCKETS.map(([k, label]) => `${label} ${(filtered[k] || []).length}`).join(' · ')
219
+ : '';
220
+
221
+ return (
222
+ <>
223
+ <div className="toolbar">
224
+ <label>工作空间</label>
225
+ <span className="pill on">{groupName}</span>
226
+ <span className="sep"></span>
227
+ <label>主题</label>
228
+ <select value={ui.topic} onChange={(e) => patchUi({ topic: e.target.value })}>
229
+ <option value="">全部</option>
230
+ {topics.map((t) => (
231
+ <option key={t} value={t}>{t}</option>
232
+ ))}
233
+ </select>
234
+ <span className="sep"></span>
235
+ <input placeholder="主题" size="8" value={form.topic}
236
+ onChange={(e) => setForm({ ...form, topic: e.target.value })} />
237
+ <input placeholder="要做什么…" size="30" value={form.text}
238
+ onChange={(e) => setForm({ ...form, text: e.target.value })}
239
+ onKeyDown={(e) => { if (e.key === 'Enter') add(); }} />
240
+ <button className="btn" onClick={add}>加入待办</button>
241
+ <button className="btn ghost" onClick={reload} disabled={busy}>{busy ? '刷新中…' : '刷新'}</button>
242
+ </div>
243
+
244
+ <div className="muted" style={{ margin: '0 0 8px 2px', fontSize: 12 }}>{counts}</div>
245
+
246
+ <div className="cols">
247
+ {BUCKETS.map(([key, label]) => (
248
+ <div className="col" key={key}>
249
+ <div className="colhead">
250
+ <h3>{label}</h3>
251
+ <span className="muted">{(filtered?.[key] || []).length}</span>
252
+ {key === 'done' && (filtered?.done || []).length ? (
253
+ <button className="btn small ghost" style={{ marginLeft: 'auto' }} onClick={archive}
254
+ title="把较旧的完成记录移到冷 key">归档旧记录</button>
255
+ ) : null}
256
+ </div>
257
+ <div className="card list">
258
+ {(filtered?.[key] || []).length
259
+ ? visible(key, filtered[key]).map((t) => (
260
+ <TaskRow key={taskKey(key, t)} bucket={key} task={t}
261
+ dupCount={dupCounts.get(t.id) || 1} onAct={onAct} />
262
+ ))
263
+ : <div className="row muted">(空)</div>}
264
+ {(filtered?.[key] || []).length > COLLAPSE_AT ? (
265
+ <div className="row muted" style={{ cursor: 'pointer', justifyContent: 'center' }}
266
+ onClick={() => setExpanded((e) => ({ ...e, [key]: !e[key] }))}>
267
+ {expanded[key] ? '收起' : `还有 ${filtered[key].length - COLLAPSE_AT} 条,点击展开`}
268
+ </div>
269
+ ) : null}
270
+ </div>
271
+ </div>
272
+ ))}
273
+ </div>
274
+
275
+ <CliHints module="todo" />
276
+ {modal ? <Modal title={modal.title} onClose={() => setModal(null)}>{modal.node}</Modal> : null}
277
+ {dialogNode}
278
+ </>
279
+ );
280
+ }
@@ -0,0 +1,108 @@
1
+ // HTTP 路由表:由 action.http 编译而来,与 CLI 同源。
2
+ //
3
+ // 这里没有手写路由 —— 每条路由都来自某个模块的 action 声明,
4
+ // 所以「面板上有按钮、CLI 里没命令」在结构上不可能发生。
5
+ import { ACTIONS } from './registry.js';
6
+ import { compileRoute, applySpec } from './spec.js';
7
+ import { toErrorPayload, httpStatusOf, CODES } from '../core/errors.js';
8
+
9
+ // 逐段比较两条模式,决定谁该先匹配:**字面量段优先于参数段**,段数多的优先。
10
+ //
11
+ // 为什么要排序而不是按声明顺序:`GET /api/repos/status` 与 `GET /api/repos/:id`
12
+ // 都能匹配 `/api/repos/status`,谁先声明谁赢。一旦有人调整 actions 顺序,
13
+ // 前者就会被后者抢走——这种 bug 只在运行时、且只在特定路径上出现,极难排查。
14
+ // 排序后,声明顺序不再影响匹配结果。
15
+ function routeSpecificity([, pattern]) {
16
+ return pattern.split('/').filter(Boolean);
17
+ }
18
+
19
+ function compareRoutes(a, b) {
20
+ const pa = routeSpecificity(a.action.http);
21
+ const pb = routeSpecificity(b.action.http);
22
+ for (let i = 0; i < Math.min(pa.length, pb.length); i++) {
23
+ const litA = !pa[i].startsWith(':');
24
+ const litB = !pb[i].startsWith(':');
25
+ if (litA !== litB) return litA ? -1 : 1; // 字面量在前
26
+ }
27
+ return pb.length - pa.length; // 更长的(更具体)在前
28
+ }
29
+
30
+ const ROUTES = ACTIONS.filter((a) => a.http)
31
+ .map((action) => ({ action, route: compileRoute(action) }))
32
+ .sort(compareRoutes);
33
+
34
+ export function sendJson(res, status, obj) {
35
+ res.writeHead(status, {
36
+ 'content-type': 'application/json; charset=utf-8',
37
+ 'cache-control': 'no-store',
38
+ });
39
+ res.end(JSON.stringify(obj));
40
+ }
41
+
42
+ async function readBody(req) {
43
+ const chunks = [];
44
+ let size = 0;
45
+ for await (const c of req) {
46
+ size += c.length;
47
+ if (size > 4 * 1024 * 1024) throw new Error('请求体过大');
48
+ chunks.push(c);
49
+ }
50
+ if (!chunks.length) return {};
51
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
52
+ }
53
+
54
+ // 跨站防护。服务虽然只绑 127.0.0.1,但用户浏览器里的任意页面都能向它发起请求——
55
+ // 少了这道校验,一个恶意网页就能 POST /api/repos/scan 或 DELETE /api/repos/:id。
56
+ // 浏览器发跨域请求必带 Origin,非浏览器客户端(curl / agent / 测试)不带,故放行。
57
+ function originAllowed(req) {
58
+ const origin = req.headers.origin;
59
+ if (!origin) return true;
60
+ try {
61
+ const h = new URL(origin).hostname;
62
+ return h === '127.0.0.1' || h === 'localhost' || h === '::1';
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ export async function handleApi(req, res, url) {
69
+ const method = (req.method || 'GET').toUpperCase();
70
+
71
+ for (const { action, route } of ROUTES) {
72
+ if (route.method !== method) continue;
73
+ const m = route.regex.exec(url.pathname);
74
+ if (!m) continue;
75
+
76
+ if (method !== 'GET' && method !== 'HEAD' && !originAllowed(req)) {
77
+ sendJson(res, 403, {
78
+ ok: false,
79
+ error: '跨站请求被拒绝(面板仅接受本机来源)',
80
+ code: CODES.BLOCKED,
81
+ });
82
+ return;
83
+ }
84
+
85
+ try {
86
+ const raw = {};
87
+ route.keys.forEach((k, i) => {
88
+ raw[k] = decodeURIComponent(m[i + 1]);
89
+ });
90
+ if (method === 'GET' || method === 'HEAD') {
91
+ for (const [k, v] of url.searchParams) raw[k] = v;
92
+ } else {
93
+ Object.assign(raw, await readBody(req).catch(() => ({})));
94
+ }
95
+ const data = await action.run(applySpec(action, raw), { transport: 'http' });
96
+ sendJson(res, 200, { ok: true, data });
97
+ } catch (err) {
98
+ // 错误码 → HTTP 状态只在这一处映射(core/errors.js 提供表)
99
+ const p = toErrorPayload(err);
100
+ const payload = { ok: false, error: p.message, code: p.code };
101
+ if (p.details !== undefined) payload.details = p.details;
102
+ sendJson(res, httpStatusOf(p.code), payload);
103
+ }
104
+ return;
105
+ }
106
+
107
+ sendJson(res, 404, { ok: false, error: '接口不存在: ' + method + ' ' + url.pathname });
108
+ }
@@ -0,0 +1,299 @@
1
+ // 通用 CLI 运行器:解析 argv → 匹配 action → 校验 → 执行 → 渲染。
2
+ //
3
+ // 改造前这里是一个 400 行的 switch,每条命令要在 BOOL_FLAGS / helpText / case /
4
+ // renderXxx 四处重复登记。现在命令表、help 文本、参数校验全部由 action 声明派生,
5
+ // 新增命令只需在所属模块的 actions 里加一条。
6
+ import { readFileSync } from 'node:fs';
7
+ import { DEFAULT_PORT } from '../core/paths.js';
8
+ import { configPathFromEnv } from '../core/config.js';
9
+ import { toErrorPayload, exitCodeOf, badInput } from '../core/errors.js';
10
+ import { ACTIONS, MODULES } from './registry.js';
11
+ import { cliPathsOf, argSpecsOf, flagSpecsOf, booleanFlagNames, applySpec, usageOf } from './spec.js';
12
+
13
+ const VERSION = JSON.parse(
14
+ readFileSync(new URL('../../package.json', import.meta.url), 'utf8')
15
+ ).version;
16
+
17
+ // ---- 平台命令:不属于任何功能域,不参与 action 表 ----
18
+ // 用与 action 相同的形状声明,好让参数解析与 help 生成保持同一套逻辑。
19
+ const BUILTINS = [
20
+ {
21
+ id: 'serve',
22
+ cli: ['serve'],
23
+ summary: '启动 Web 面板',
24
+ flags: { port: { type: 'number', default: DEFAULT_PORT }, 'no-open': { type: 'boolean' } },
25
+ run: (ctx) => cmdServe(ctx),
26
+ },
27
+ {
28
+ id: 'help',
29
+ cli: ['help'],
30
+ summary: '显示帮助(可跟模块名或命令组,如 nx-kv help todo)',
31
+ args: [{ name: 'topic', required: false }],
32
+ // 走标准 action 形态:--json 时 emit 会序列化返回的条目,
33
+ // 于是 `help --json` 输出的是可解析的命令表而非帮助文本
34
+ run: (ctx) => helpEntries(ctx.topic),
35
+ render: (entries, ctx) => renderHelp(entries, ctx.topic),
36
+ },
37
+ // render 让 `nx-kv version` 输出裸版本号(脚本里可 `V=$(nx-kv version)`),
38
+ // 而 `--json` 仍走序列化,保持机器可读
39
+ { id: 'version', cli: ['version'], summary: '显示版本', run: () => VERSION, render: (v) => v },
40
+ ];
41
+
42
+ export const ALL_COMMANDS = [...BUILTINS, ...ACTIONS];
43
+
44
+ // ---- 参数解析 ----
45
+
46
+ // 全局 flag 先摘掉:它们可以出现在 argv 任意位置,不属于任何 action 的声明。
47
+ function splitGlobals(argv) {
48
+ const rest = [];
49
+ let json = false;
50
+ let cfgPath;
51
+ for (let i = 0; i < argv.length; i++) {
52
+ const a = argv[i];
53
+ if (a === '--json') { json = true; continue; }
54
+ if (a === '--config') { cfgPath = argv[++i]; continue; }
55
+ if (a.startsWith('--config=')) { cfgPath = a.slice('--config='.length); continue; }
56
+ rest.push(a);
57
+ }
58
+ return { rest, json, cfgPath };
59
+ }
60
+
61
+ // 匹配命令:把声明路径与 argv 前缀逐 token 比较,选最长匹配。
62
+ //
63
+ // 逐 token 比较(而非只取「开头连续的非 flag token」)是必需的——有些命令路径
64
+ // 本身就含 flag,例如 `skill install --list` 是「列出内置包」而非「安装」。
65
+ // 最长优先则让 ['skill','central','add'] 压过 ['skill','central'],子命令与别名因此共存。
66
+ export function resolveCommand(argv) {
67
+ let best = null;
68
+ let bestLen = -1;
69
+ for (const cmd of ALL_COMMANDS) {
70
+ for (const path of cliPathsOf(cmd)) {
71
+ if (path.length > argv.length || path.length <= bestLen) continue;
72
+ if (path.every((seg, i) => seg === argv[i])) {
73
+ best = { cmd, path };
74
+ bestLen = path.length;
75
+ }
76
+ }
77
+ }
78
+ return best ? { ...best, rest: argv.slice(bestLen) } : null;
79
+ }
80
+
81
+ // 解析剩余 token。布尔 flag 不吞下一个 token —— 否则 `skill install --force demo`
82
+ // 会把 force 解析成字符串 'demo',而 demo 本该是位置参数。
83
+ function parseRest(rest, cmd) {
84
+ const bools = booleanFlagNames(cmd);
85
+ const positionals = [];
86
+ const flags = {};
87
+ for (let i = 0; i < rest.length; i++) {
88
+ const a = rest[i];
89
+ if (!a.startsWith('--')) { positionals.push(a); continue; }
90
+ const eq = a.indexOf('=');
91
+ if (eq > 2) { flags[a.slice(2, eq)] = a.slice(eq + 1); continue; }
92
+ const key = a.slice(2);
93
+ const next = rest[i + 1];
94
+ if (!bools.has(key) && next !== undefined && !next.startsWith('--')) {
95
+ flags[key] = next;
96
+ i++;
97
+ } else {
98
+ flags[key] = true;
99
+ }
100
+ }
101
+ return { positionals, flags };
102
+ }
103
+
104
+ // 位置参数按声明顺序映射成具名输入;末尾声明 { rest: true } 的收集剩余全部
105
+ function mapPositionals(positionals, argSpecs) {
106
+ const out = {};
107
+ let i = 0;
108
+ for (const spec of argSpecs) {
109
+ if (spec.rest) { out[spec.name] = positionals.slice(i); i = positionals.length; break; }
110
+ if (positionals[i] !== undefined) out[spec.name] = positionals[i];
111
+ i++;
112
+ }
113
+ if (i < positionals.length) {
114
+ throw badInput(`多余的参数: ${positionals.slice(i).join(' ')}(用法见 nx-kv help)`);
115
+ }
116
+ return out;
117
+ }
118
+
119
+ // 未知 flag 直接报错——`--fiile` 这类拼写错误应该立刻被指出来,而不是被静默忽略
120
+ function assertKnownFlags(flags, cmd) {
121
+ const known = new Set(flagSpecsOf(cmd).map((f) => f.name));
122
+ for (const k of Object.keys(flags)) {
123
+ if (!known.has(k)) throw badInput(`未知参数 --${k}(用法见 nx-kv help)`);
124
+ }
125
+ }
126
+
127
+ // ---- 输出 ----
128
+
129
+ // render 拿到 (data, ctx),可以是 async——有些渲染需要补一点上下文才能标注
130
+ // (例如候选清单要标出哪一项是「当前」),而 `--json` 必须保持纯数据的旧形状。
131
+ async function emit(cmd, data, json, ctx) {
132
+ // 无返回值的命令(help / serve 这类)不该打印任何东西:落进 JSON.stringify(undefined)
133
+ // 会打出字面量 "undefined",让 `help --json` 的 stdout 不再是合法 JSON。
134
+ if (data === undefined && !cmd.render) return;
135
+
136
+ if (json) {
137
+ if (data === undefined) return;
138
+ console.log(JSON.stringify(data, null, 2));
139
+ return;
140
+ }
141
+
142
+ if (cmd.render) {
143
+ const text = await cmd.render(data, ctx);
144
+ if (text !== undefined && text !== null && text !== '') console.log(text);
145
+ return;
146
+ }
147
+ console.log(JSON.stringify(data, null, 2));
148
+ }
149
+
150
+ // 失败一律 exit 1;--json 下输出可解析的错误对象。
151
+ // 注意 error 保持字符串(历史契约),code 是新增字段——agent 可据此分支,
152
+ // 老消费方按字符串匹配「用法:」「未设置」「不存在」仍然可用。
153
+ function fail(err, json) {
154
+ const p = toErrorPayload(err);
155
+ if (json) {
156
+ const payload = { ok: false, error: p.message, code: p.code };
157
+ if (p.details !== undefined) payload.details = p.details;
158
+ console.log(JSON.stringify(payload, null, 2));
159
+ } else {
160
+ console.error('错误: ' + p.message);
161
+ }
162
+ process.exitCode = exitCodeOf(p.code);
163
+ }
164
+
165
+ // ---- help:完全由命令表生成 ----
166
+
167
+ // 命令表的一行。`routes` 与 `help` 共用它,因此两张表必然同源同长——
168
+ // 面板底部的「CLI 等价」提示用的也是这个形状(经 bootstrap 下发)。
169
+ export function commandEntry(c) {
170
+ return {
171
+ id: c.id,
172
+ module: c.module || 'platform',
173
+ command: 'nx-kv ' + cliPathsOf(c)[0].join(' '),
174
+ usage: usageOf(c),
175
+ http: c.http ? { method: c.http[0], path: c.http[1] } : null,
176
+ summary: c.summary,
177
+ };
178
+ }
179
+
180
+ // 解析帮助主题。既接受模块 id(repos),也接受命令组(repo)——
181
+ // 用户脑子里想的是「repo 相关的东西」,不会去记内部模块名。
182
+ // 这条路径不能报错退出:`nx-kv help <随便什么>` 失败会让最需要帮助的人卡住。
183
+ function helpEntries(topic) {
184
+ const all = ALL_COMMANDS.map(commandEntry);
185
+ if (!topic) return all;
186
+
187
+ const t = String(topic);
188
+ if (MODULES.some((m) => m.id === t)) return all.filter((e) => e.module === t);
189
+
190
+ const byRoot = all.filter((e) => e.command.split(' ')[1] === t);
191
+ if (byRoot.length) return byRoot;
192
+
193
+ // 命令本身的 id 也认(如 `nx-kv help repo.add`)
194
+ const byId = all.filter((e) => e.id === t);
195
+ if (byId.length) return byId;
196
+
197
+ const topics = [...new Set(all.map((e) => e.module))].join(', ');
198
+ throw badInput(`未知帮助主题: ${t}(可用模块: ${topics};或命令组如 repo / skill / gh)`);
199
+ }
200
+
201
+ function renderHelp(entries, topic) {
202
+ const lines = [];
203
+ if (!topic) {
204
+ lines.push(`nx-kv · nx-kv v${VERSION}`);
205
+ lines.push('KV 清单管理中枢:serve 驱动 CLI 与 Web,同源同构。');
206
+ lines.push(`配置: ${configPathFromEnv()} (环境变量 NX_KV_CONFIG 或 --config 覆盖)`);
207
+ lines.push('');
208
+ }
209
+
210
+ const groups = new Map();
211
+ for (const e of entries) {
212
+ if (!groups.has(e.module)) groups.set(e.module, []);
213
+ groups.get(e.module).push(e);
214
+ }
215
+
216
+ for (const [mod, list] of groups) {
217
+ const title =
218
+ MODULES.find((m) => m.id === mod)?.title || (mod === 'platform' ? '平台命令' : mod);
219
+ lines.push(title + ':');
220
+ const width = Math.max(...list.map((e) => e.usage.length));
221
+ for (const e of list) lines.push(' ' + e.usage.padEnd(width + 2) + e.summary);
222
+ lines.push('');
223
+ }
224
+
225
+ lines.push('通用:');
226
+ lines.push(' --json 机器可读输出(agent 模式)');
227
+ lines.push(' --config 本次运行覆盖配置文件路径');
228
+ return lines.join('\n');
229
+ }
230
+
231
+ // `nx-kv <命令> --help`:只打印这一条,而不是整个模块——
232
+ // 之前这里传的是 cmd.cli[0],对别名 action(cli 是数组的数组)会拼出 "bundled,list"。
233
+ function printCommandHelp(cmd) {
234
+ const lines = [usageOf(cmd)];
235
+ if (cmd.summary) lines.push(' ' + cmd.summary);
236
+ lines.push('');
237
+
238
+ const aliases = cliPathsOf(cmd).slice(1).map((p) => 'nx-kv ' + p.join(' '));
239
+ if (aliases.length) lines.push('别名: ' + aliases.join(' · '));
240
+ if (cmd.http) lines.push('对应路由: ' + cmd.http[0] + ' ' + cmd.http[1]);
241
+ lines.push('所属模块: ' + (cmd.module || 'platform'));
242
+ console.log(lines.join('\n'));
243
+ }
244
+
245
+ async function cmdServe(ctx) {
246
+ const { startServer } = await import('./server.js');
247
+ const { openBrowser } = await import('../core/open.js');
248
+ const port = ctx.port || DEFAULT_PORT;
249
+ const server = await startServer({ port, host: '127.0.0.1' });
250
+ const addr = `http://127.0.0.1:${server.address().port}`;
251
+
252
+ console.log('nx-kv · nx-kv v' + VERSION);
253
+ console.log(`面板: ${addr}`);
254
+ console.log(`配置: ${configPathFromEnv()}`);
255
+ console.log('CLI: nx-kv help(每个按钮都有对应命令,agent 可加 --json)');
256
+ console.log('按 Ctrl+C 停止');
257
+
258
+ if (!ctx['no-open']) openBrowser(addr);
259
+
260
+ const shutdown = () => {
261
+ console.log('\n正在停止...');
262
+ server.close(() => process.exit(0));
263
+ };
264
+ process.on('SIGINT', shutdown);
265
+ process.on('SIGTERM', shutdown);
266
+ }
267
+
268
+ // ---- 主入口 ----
269
+
270
+ export async function runCli(argv) {
271
+ const { rest, json, cfgPath } = splitGlobals(argv);
272
+ if (cfgPath) process.env.NX_KV_CONFIG = cfgPath;
273
+
274
+ const hit = resolveCommand(rest);
275
+
276
+ // 无参数、或只有全局 flag:显示帮助(与历史行为一致,退出码 0)
277
+ if (!hit) {
278
+ if (!rest.length) { console.log(renderHelp(helpEntries(), '')); return; }
279
+ console.error(`未知命令: ${rest.join(' ')}`);
280
+ console.error('运行 nx-kv help 查看用法');
281
+ process.exitCode = 1;
282
+ return;
283
+ }
284
+
285
+ const { cmd, rest: tail } = hit;
286
+
287
+ try {
288
+ if (tail.includes('--help')) { printCommandHelp(cmd); return; }
289
+
290
+ const { positionals, flags } = parseRest(tail, cmd);
291
+ assertKnownFlags(flags, cmd);
292
+ const input = { ...mapPositionals(positionals, argSpecsOf(cmd)), ...flags };
293
+ const ctx = applySpec(cmd, input);
294
+ const data = await cmd.run(ctx, { transport: 'cli' });
295
+ await emit(cmd, data, json, ctx);
296
+ } catch (err) {
297
+ fail(err, json);
298
+ }
299
+ }