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
package/bin/cli.mjs ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ // nx-kv 唯一入口。
3
+ // 设计约定:Web 面板上每个按钮,都对应这里的一条 CLI 命令——二者由同一份 action
4
+ // 声明派生(见 src/modules/*/index.js),不是靠人维护两份清单。
5
+ import { runCli } from '../src/runtime/cli.js';
6
+
7
+ runCli(process.argv.slice(2)).catch((err) => {
8
+ console.error(err && err.stack ? err.stack : String(err));
9
+ process.exitCode = 1;
10
+ });
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "nx-kv",
3
+ "version": "0.1.1",
4
+ "description": "nx-kv — KV 清单管理中枢:serve 驱动 CLI 与 Web 面板,同源同构,附带可安装 skill 供 agent 领取主题任务",
5
+ "type": "module",
6
+ "bin": {
7
+ "nx-kv": "bin/cli.mjs"
8
+ },
9
+ "main": "src/index.js",
10
+ "exports": {
11
+ ".": "./src/index.js"
12
+ },
13
+ "scripts": {
14
+ "build": "vite build",
15
+ "dev": "vite",
16
+ "dev:serve": "node bin/cli.mjs serve --no-open",
17
+ "start": "pnpm run build && node bin/cli.mjs serve",
18
+ "lint": "eslint .",
19
+ "test": "pnpm run lint && pnpm run build && pnpm run test:smoke && pnpm run test:unit",
20
+ "test:smoke": "node tests/smoke.mjs",
21
+ "test:unit": "node --test tests/unit/*.test.mjs"
22
+ },
23
+ "packageManager": "pnpm@10.33.0",
24
+ "engines": {
25
+ "node": ">=18.17"
26
+ },
27
+ "files": [
28
+ "bin/",
29
+ "src/",
30
+ "assets/",
31
+ "README.md",
32
+ "CHANGELOG.md"
33
+ ],
34
+ "keywords": [
35
+ "cli",
36
+ "web-panel",
37
+ "kv",
38
+ "todo",
39
+ "agent-tools",
40
+ "skills",
41
+ "npx"
42
+ ],
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "https://github.com/On-DevPlan/nx-kv.git"
46
+ },
47
+ "license": "MIT",
48
+ "devDependencies": {
49
+ "@vitejs/plugin-react": "^6.1.1",
50
+ "eslint": "^10.10.0",
51
+ "vite": "^8.3.0"
52
+ },
53
+ "dependencies": {
54
+ "react": "^19.3.0",
55
+ "react-dom": "^19.3.0"
56
+ }
57
+ }
@@ -0,0 +1,94 @@
1
+ // 本机配置:后端地址 + 登录态。
2
+ //
3
+ // 与业务数据(KV 清单)分开存放:清单在服务端,这里只存「怎么连过去」。
4
+ // 复用 store.js 的原子写语义,但结构独立——配置是单例,不是集合。
5
+ import fsp from 'node:fs/promises';
6
+ import { dirname, join } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+ import { badInput } from './errors.js';
9
+
10
+ export const CONFIG_PATH = join(homedir(), '.nx-kv', 'config.json');
11
+
12
+ // 测试与多实例覆盖:NX_KV_CONFIG 环境变量优先,其次是 NX_KV_STORE 所在目录
13
+ export function configPathFromEnv() {
14
+ return process.env.NX_KV_CONFIG || CONFIG_PATH;
15
+ }
16
+
17
+ export const DEFAULT_BASE_URL = 'http://47.110.80.47:8988';
18
+
19
+ const EMPTY = () => ({
20
+ baseUrl: process.env.NX_KV_BASE_URL || DEFAULT_BASE_URL,
21
+ token: '',
22
+ email: '',
23
+ userId: 0,
24
+ // 当前工作空间:0 = 不传 groupId(后端回落默认组)
25
+ groupId: 0,
26
+ });
27
+
28
+ let cache = null;
29
+ let cacheMtime = -1;
30
+
31
+ function normalize(data) {
32
+ const base = EMPTY();
33
+ if (!data || typeof data !== 'object') return base;
34
+ return {
35
+ baseUrl: String(data.baseUrl || base.baseUrl),
36
+ token: String(data.token || ''),
37
+ email: String(data.email || ''),
38
+ userId: Number(data.userId) || 0,
39
+ groupId: Number(data.groupId) || 0,
40
+ };
41
+ }
42
+
43
+ export async function loadConfig(explicitPath) {
44
+ const p = explicitPath || configPathFromEnv();
45
+ try {
46
+ const st = await fsp.stat(p);
47
+ if (cache && cacheMtime === st.mtimeMs) return cache;
48
+ cache = normalize(JSON.parse(await fsp.readFile(p, 'utf8')));
49
+ cacheMtime = st.mtimeMs;
50
+ return cache;
51
+ } catch {
52
+ // 不存在或损坏:返回默认结构(首次运行;也允许外部修好后自动恢复)
53
+ cache = normalize(null);
54
+ cacheMtime = -1;
55
+ return cache;
56
+ }
57
+ }
58
+
59
+ export async function saveConfig(next, explicitPath) {
60
+ const p = explicitPath || configPathFromEnv();
61
+ const data = normalize(next);
62
+ await fsp.mkdir(dirname(p), { recursive: true });
63
+ const tmp = p + '.tmp';
64
+ await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
65
+ await fsp.rename(tmp, p);
66
+ cache = data;
67
+ try {
68
+ cacheMtime = (await fsp.stat(p)).mtimeMs;
69
+ } catch {
70
+ cacheMtime = -1;
71
+ }
72
+ return data;
73
+ }
74
+
75
+ export async function updateConfig(patch) {
76
+ const cur = await loadConfig();
77
+ return saveConfig({ ...cur, ...patch });
78
+ }
79
+
80
+ // 读配置并要求已登录。所有需要 token 的 action 都先过这里,
81
+ // 报错文案里的「未登录」是 agent 的失败分类锚点(与 kvcli 保持一致)。
82
+ export async function requireAuth() {
83
+ const cfg = await loadConfig();
84
+ if (!cfg.token) {
85
+ throw badInput('未登录(先执行 nx-kv auth login <email>)');
86
+ }
87
+ return cfg;
88
+ }
89
+
90
+ // groupId 归一:0 / 负数 / 空 → undefined(不传参,由后端回落默认组)
91
+ export function normalizeGroupId(v) {
92
+ const n = Number(v);
93
+ return Number.isFinite(n) && n > 0 ? n : undefined;
94
+ }
@@ -0,0 +1,81 @@
1
+ // 错误契约:**失败抛 AppError,业务结果返回 {status}**。
2
+ //
3
+ // 这条边界是整个项目最重要的一条约定,判定标准是「调用方要不要处理它」:
4
+ //
5
+ // - 失败(参数非法、目标不存在、外部命令挂掉、数据损坏)
6
+ // → throw。调用方无从处理,只能中断当前操作并上报。API 映射成 4xx/5xx,
7
+ // CLI 映射成 exit 1 + 可解析文本。
8
+ // - 业务结果(同步遇到冲突等待选侧、幂等跳过、规则阻止删除)
9
+ // → return { status: 'ok' | 'skipped' | 'conflict' | 'blocked', ... }。
10
+ // 这不是错误——UI 要拿它弹窗让用户决策,所以绝不能抛。
11
+ //
12
+ // 历史教训:改造前同一层里并存 throw / {ok:false} / {status} / {available} 四种
13
+ // 表达,上层只能靠字符串猜,无法程序化分支。
14
+ //
15
+ // 兼容约束:assets/repo-hub/references/agent-workflow.md 教 agent 用错误文本里的
16
+ // 「用法:」「未设置」「不存在」三个子串分类失败。message 可以追加内容,但不得删除它们。
17
+
18
+ export const CODES = {
19
+ INVALID_INPUT: 'INVALID_INPUT', // 参数缺失/非法/越界
20
+ NOT_FOUND: 'NOT_FOUND', // 目标不存在
21
+ CONFLICT: 'CONFLICT', // 状态冲突,需调用方决策
22
+ BLOCKED: 'BLOCKED', // 业务规则主动阻止
23
+ EXTERNAL: 'EXTERNAL', // 外部命令(git / gh)失败
24
+ INTERNAL: 'INTERNAL', // 兜底
25
+ };
26
+
27
+ // code -> HTTP 状态。只在这里映射一次,api.js 不再自行判断。
28
+ const HTTP_STATUS = {
29
+ [CODES.INVALID_INPUT]: 400,
30
+ [CODES.NOT_FOUND]: 404,
31
+ [CODES.CONFLICT]: 409,
32
+ [CODES.BLOCKED]: 409,
33
+ [CODES.EXTERNAL]: 502,
34
+ [CODES.INTERNAL]: 500,
35
+ };
36
+
37
+ // code -> 进程退出码。CLI 只区分「成功 / 失败」,保持 agent-workflow.md 声明的契约。
38
+ const EXIT_CODE = {
39
+ [CODES.INVALID_INPUT]: 1,
40
+ [CODES.NOT_FOUND]: 1,
41
+ [CODES.CONFLICT]: 1,
42
+ [CODES.BLOCKED]: 1,
43
+ [CODES.EXTERNAL]: 1,
44
+ [CODES.INTERNAL]: 1,
45
+ };
46
+
47
+ export class AppError extends Error {
48
+ constructor(code, message, details) {
49
+ super(message);
50
+ this.name = 'AppError';
51
+ this.code = CODES[code] ? code : CODES.INTERNAL;
52
+ if (details !== undefined) this.details = details;
53
+ }
54
+ }
55
+
56
+ export function httpStatusOf(code) {
57
+ return HTTP_STATUS[code] || 500;
58
+ }
59
+
60
+ export function exitCodeOf(code) {
61
+ return EXIT_CODE[code] || 1;
62
+ }
63
+
64
+ // 把任意抛出物归一成 { code, message, details },供 API / CLI 统一序列化。
65
+ // 非 AppError 一律归为 INTERNAL——不把内部堆栈细节泄漏给 agent。
66
+ export function toErrorPayload(err) {
67
+ if (err instanceof AppError) {
68
+ const out = { code: err.code, message: err.message };
69
+ if (err.details !== undefined) out.details = err.details;
70
+ return out;
71
+ }
72
+ return { code: CODES.INTERNAL, message: String((err && err.message) || err) };
73
+ }
74
+
75
+ // ---- 构造快捷方式:让 service 层读起来是「为什么失败」而不是「怎么构造」 ----
76
+
77
+ export const badInput = (message, details) => new AppError(CODES.INVALID_INPUT, message, details);
78
+ export const notFound = (message, details) => new AppError(CODES.NOT_FOUND, message, details);
79
+ export const conflict = (message, details) => new AppError(CODES.CONFLICT, message, details);
80
+ export const blocked = (message, details) => new AppError(CODES.BLOCKED, message, details);
81
+ export const external = (message, details) => new AppError(CODES.EXTERNAL, message, details);
@@ -0,0 +1,65 @@
1
+ // SKILL.md frontmatter 解析(与 fileops.go / gitimporter.go 同语义,另修 CRLF)。
2
+ //
3
+ // 关键点:Windows 上 SKILL.md 通常是 CRLF,必须先归一化换行再逐行解析,
4
+ // 否则 JS 正则的 `$`(非 multiline)无法匹配行尾残留的 \r,name/description 会全部丢失。
5
+ //
6
+ // 只解析 name / description 两个字段——不引入 YAML 依赖,手写够用的子集:
7
+ // 单行值、成对引号值、| / > 块标量、缩进续行。
8
+
9
+ // 返回 { name, description };无 frontmatter 或字段缺失时为空串(调用方自行兜底)
10
+ export function parseFrontmatter(raw) {
11
+ // 去 BOM:用 charCodeAt 判定而非正则字面量,避免源码里出现不可见的 BOM 字符
12
+ let content = String(raw ?? '');
13
+ if (content.charCodeAt(0) === 0xfeff) content = content.slice(1);
14
+ content = content.replace(/\r\n?/g, '\n');
15
+ const m = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*(\n|$)/.exec(content);
16
+ if (!m) return { name: '', description: '' };
17
+ const lines = m[1].split('\n');
18
+ return {
19
+ name: readField(lines, 'name').trim(),
20
+ description: readField(lines, 'description').trim(),
21
+ };
22
+ }
23
+
24
+ // 去掉包裹的成对引号
25
+ function unquote(v) {
26
+ const s = v.trim();
27
+ if (s.length >= 2 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))) {
28
+ return s.slice(1, -1).trim();
29
+ }
30
+ return s;
31
+ }
32
+
33
+ // 读取 frontmatter 字段:支持单行值、引号值、| / > 块标量、以及缩进续行
34
+ function readField(lines, key) {
35
+ const re = new RegExp('^' + key + ':[ \\t]*(.*)$');
36
+ for (let i = 0; i < lines.length; i++) {
37
+ const m = re.exec(lines[i]);
38
+ if (!m) continue;
39
+ const rawVal = m[1].trim();
40
+ const isBlock = /^[|>][-+]?$/.test(rawVal);
41
+ if (!isBlock && rawVal !== '') return unquote(rawVal);
42
+
43
+ // 块标量或空值:收集后续缩进行(YAML 多行标量)
44
+ const block = [];
45
+ for (let j = i + 1; j < lines.length; j++) {
46
+ const l = lines[j];
47
+ if (l.trim() === '') {
48
+ block.push('');
49
+ continue;
50
+ }
51
+ if (!/^[ \t]/.test(l)) break;
52
+ block.push(stripIndent(l));
53
+ }
54
+ const text = block.join('\n').trim();
55
+ if (text) return rawVal.startsWith('>') ? text.replace(/\s*\n\s*/g, ' ').trim() : text;
56
+ return unquote(rawVal);
57
+ }
58
+ return '';
59
+ }
60
+
61
+ function stripIndent(l) {
62
+ if (l.startsWith(' ')) return l.slice(2);
63
+ if (l.startsWith('\t')) return l.slice(1);
64
+ return l.replace(/^[ \t]+/, '');
65
+ }
@@ -0,0 +1,74 @@
1
+ // 文件系统原语:存在性判定、内容摘要、目录树遍历与逐文件差异。
2
+ // 零业务语义——skill 同步(两侧目录比较)与内置包安装(包内目录比较)共用同一套。
3
+ //
4
+ // 从 services/skills.js 下沉而来:这些函数被 bundled.js 跨模块使用,
5
+ // 原本造成 service 之间的横向依赖,属于通用能力。
6
+ import { join } from 'node:path';
7
+ import fsp from 'node:fs/promises';
8
+ import { createHash } from 'node:crypto';
9
+
10
+ // access 判存在。**是否穿透链接由操作系统决定**,不是一个可靠的判据:
11
+ // POSIX 上悬空 symlink 返回 false,而 Windows 上悬空 junction 返回 true。
12
+ // 因此凡是「必须认出悬空链接」的场合一律用 pathExists,不要用这个。
13
+ export async function exists(p) {
14
+ try {
15
+ await fsp.access(p);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ // lstat 判存在:不跟随链接,看的是链接本身。
23
+ // 悬空链接(目标已被删)也算「存在」——这正是它被需要的原因:
24
+ // 中心侧删掉 skill 后,项目侧的链接要能被列出来并删除,否则就成了删不掉的幽灵。
25
+ export async function pathExists(p) {
26
+ try {
27
+ await fsp.lstat(p);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ export function md5Of(buf) {
35
+ return createHash('md5').update(buf).digest('hex');
36
+ }
37
+
38
+ // 递归收集目录下所有文件:rel 路径 -> { abs, md5, size }
39
+ export async function walkFiles(root) {
40
+ const files = new Map();
41
+ async function walk(dir, rel) {
42
+ const entries = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []);
43
+ for (const e of entries) {
44
+ const abs = join(dir, e.name);
45
+ const r = rel ? `${rel}/${e.name}` : e.name;
46
+ if (e.isDirectory()) await walk(abs, r);
47
+ else if (e.isFile()) {
48
+ const buf = await fsp.readFile(abs).catch(() => null);
49
+ if (buf === null) continue;
50
+ files.set(r, { abs, md5: md5Of(buf), size: buf.length });
51
+ }
52
+ }
53
+ }
54
+ await walk(root, '');
55
+ return files;
56
+ }
57
+
58
+ // 两棵目录树的差异:逐文件 md5 比较。
59
+ // side 语义固定为 a 侧视角:only-central=仅 a 有 / only-project=仅 b 有 / both-differ=两侧都有但不同。
60
+ // 命名沿用「中心 vs 项目」场景,调用方按 a=基准侧 理解即可。
61
+ // 注意:bRoot 不存在时视为空树(bundled.js 用不存在的路径当哨兵来列出全部文件)。
62
+ export async function diffTrees(aRoot, bRoot) {
63
+ const [a, b] = await Promise.all([walkFiles(aRoot), walkFiles(bRoot)]);
64
+ const out = [];
65
+ for (const [rel, fa] of a) {
66
+ const fb = b.get(rel);
67
+ if (!fb) out.push({ file: rel, side: 'only-central' });
68
+ else if (fa.md5 !== fb.md5) out.push({ file: rel, side: 'both-differ' });
69
+ }
70
+ for (const [rel] of b) {
71
+ if (!a.has(rel)) out.push({ file: rel, side: 'only-project' });
72
+ }
73
+ return out;
74
+ }
@@ -0,0 +1,201 @@
1
+ // 后端 KV 客户端 —— 对接 47 服务器的 goframe 接口。
2
+ //
3
+ // 信封约定:**HTTP 状态恒为 200,成败看 body 里的 `code`**(0 = 成功)。
4
+ // 这一点与常见的 REST 直觉相反,所以统一收在本文件里判断;
5
+ // 上层 service 只看到「成功返回 data / 失败抛异常」,不必各自解析信封。
6
+ //
7
+ // 端点(来自 Flutter 端 api/goframe/kv/kv_endpoint.dart 与 user/user_auth_service.dart):
8
+ // POST /api/v1/user/login { email, password } → { token, userId }
9
+ // GET /api/v1/kv/{key}[?groupId=N] → { key, value, expires_at, tags }
10
+ // POST /api/v1/kv { key, value, ttl, groupId }
11
+ // DELETE /api/v1/kv/{key}[?groupId=N]
12
+ // GET /api/v1/kv?limit=&offset=[&groupId=N] → { items, total }
13
+ // GET /api/v1/groups → { groups: [...] }
14
+ import { badInput, external, notFound } from './errors.js';
15
+ import { loadConfig, normalizeGroupId } from './config.js';
16
+
17
+ const TIMEOUT_MS = 20000;
18
+
19
+ async function call({ method, path, body, token, baseUrl, allowMissing = false }) {
20
+ const cfg = baseUrl ? { baseUrl, token } : await loadConfig();
21
+ const url = cfg.baseUrl.replace(/\/$/, '') + path;
22
+
23
+ const headers = {};
24
+ if (cfg.token) headers.authorization = `Bearer ${cfg.token}`;
25
+ if (body !== undefined) headers['content-type'] = 'application/json';
26
+
27
+ const ctrl = new AbortController();
28
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
29
+ let res;
30
+ try {
31
+ res = await fetch(url, {
32
+ method,
33
+ headers,
34
+ body: body === undefined ? undefined : JSON.stringify(body),
35
+ signal: ctrl.signal,
36
+ });
37
+ } catch (e) {
38
+ if (e && e.name === 'AbortError') throw external(`请求超时(${TIMEOUT_MS}ms): ${method} ${path}`);
39
+ throw external(`无法连接后端 ${cfg.baseUrl}: ${e.message}`);
40
+ } finally {
41
+ clearTimeout(timer);
42
+ }
43
+
44
+ let envelope;
45
+ try {
46
+ envelope = JSON.parse(await res.text());
47
+ } catch {
48
+ throw external(`后端返回了非 JSON(HTTP ${res.status}): ${method} ${path}`);
49
+ }
50
+
51
+ const code = envelope.code ?? res.status;
52
+ if (code !== 0) {
53
+ // 401 单独识别成「未登录」——这是 agent 最需要一眼看懂的失败
54
+ if (code === 401) throw badInput('未登录或登录已过期(执行 nx-kv auth login <email>)');
55
+ // 业务上"这个 key 不存在"不是错误,由调用方决定怎么处理
56
+ if (allowMissing && (code === 404 || /not\s*found|不存在/i.test(envelope.message || ''))) {
57
+ return null;
58
+ }
59
+ throw external(`后端 code=${code}: ${envelope.message || '(无消息)'}`, { code, path });
60
+ }
61
+ return envelope.data ?? null;
62
+ }
63
+
64
+ const gid = (groupId) => {
65
+ const n = normalizeGroupId(groupId);
66
+ return n === undefined ? '' : `?groupId=${n}`;
67
+ };
68
+
69
+ // ---- 认证 ----
70
+
71
+ export async function login(email, password, { baseUrl } = {}) {
72
+ if (!email || !password) throw badInput('邮箱与密码不能为空');
73
+ const data = await call({
74
+ method: 'POST',
75
+ path: '/api/v1/user/login',
76
+ body: { email, password },
77
+ baseUrl,
78
+ token: '', // 登录本身不需要 token(显式传空,避免用旧 token)
79
+ });
80
+ if (!data || !data.token) throw external('登录成功但未返回 token');
81
+ return { token: data.token, userId: Number(data.userId ?? data.user?.id ?? 0) };
82
+ }
83
+
84
+ export async function userInfo() {
85
+ return call({ method: 'GET', path: '/api/v1/user/info' });
86
+ }
87
+
88
+ // ---- 工作空间 ----
89
+
90
+ function normalizeGroup(g) {
91
+ return {
92
+ id: Number(g.id) || 0,
93
+ name: g.name ?? '',
94
+ description: g.description ?? '',
95
+ myRole: g.myRole ?? '',
96
+ memberCount: Number(g.memberCount) || 0,
97
+ ownerId: Number(g.ownerId) || 0,
98
+ createdAt: g.createdAt ?? '',
99
+ };
100
+ }
101
+
102
+ export async function listGroups() {
103
+ const data = await call({ method: 'GET', path: '/api/v1/groups' });
104
+ return (data?.groups ?? []).map(normalizeGroup);
105
+ }
106
+
107
+ // 建组。返回后端给的完整对象——建完立刻能拿到 id,不必再列一次。
108
+ export async function createGroup({ name, description } = {}) {
109
+ const n = String(name || '').trim();
110
+ if (!n) throw badInput('工作空间名称不能为空');
111
+ const data = await call({
112
+ method: 'POST',
113
+ path: '/api/v1/groups',
114
+ body: { name: n, description: String(description ?? '') },
115
+ });
116
+ return normalizeGroup(data?.group ?? {});
117
+ }
118
+
119
+ // 改名 / 改描述。只发传入的字段(PATCH 语义)。
120
+ export async function updateGroup(id, { name, description } = {}) {
121
+ const body = {};
122
+ if (name !== undefined) {
123
+ const n = String(name).trim();
124
+ if (!n) throw badInput('工作空间名称不能为空');
125
+ body.name = n;
126
+ }
127
+ if (description !== undefined) body.description = String(description);
128
+ if (!Object.keys(body).length) throw badInput('至少要给出 --name 或 --description');
129
+ const data = await call({ method: 'PATCH', path: `/api/v1/groups/${Number(id)}`, body });
130
+ return normalizeGroup(data?.group ?? {});
131
+ }
132
+
133
+ // 解散工作空间。后端有前置条件:组内不能还有 KV,
134
+ // 报错文案会把原因带回来(例如「组内存在 KV,请先删除或转移后再解散组」),
135
+ // 这里原样透出——调用方需要知道到底卡在哪一步。
136
+ export async function deleteGroup(id) {
137
+ await call({ method: 'DELETE', path: `/api/v1/groups/${Number(id)}` });
138
+ return { id: Number(id), deleted: true };
139
+ }
140
+
141
+ export async function listGroupMembers(id) {
142
+ const data = await call({ method: 'GET', path: `/api/v1/groups/${Number(id)}/members` });
143
+ return (data?.members ?? []).map((m) => ({
144
+ userId: Number(m.userId) || 0,
145
+ email: m.email ?? '',
146
+ nickname: m.nickname ?? '',
147
+ role: m.role ?? m.myRole ?? '',
148
+ }));
149
+ }
150
+
151
+ // ---- KV 读写 ----
152
+ //
153
+ // 注意:后端**没有「单条更新」接口**。任何写都是「读整把 key → 改数组 → 整把覆盖」,
154
+ // 所以上层必须先读后写,且两把 key 要一起写(见 modules/todo/service.js 的契约注释)。
155
+
156
+ // 读一个 key。key 不存在时返回 null(不是错误)。
157
+ export async function kvGet(key, groupId) {
158
+ const data = await call({
159
+ method: 'GET',
160
+ path: '/api/v1/kv/' + encodeURIComponent(key) + gid(groupId),
161
+ allowMissing: true,
162
+ });
163
+ if (!data) return null;
164
+ return {
165
+ key: data.key ?? key,
166
+ value: data.value ?? '',
167
+ expiresAt: data.expires_at ?? data.expiresAt ?? null,
168
+ tags: Array.isArray(data.tags) ? data.tags.map(String) : [],
169
+ };
170
+ }
171
+
172
+ export async function kvSet({ key, value, ttl = 0, groupId }) {
173
+ const n = normalizeGroupId(groupId);
174
+ const body = { key, value, ttl };
175
+ if (n !== undefined) body.groupId = n;
176
+ await call({ method: 'POST', path: '/api/v1/kv', body });
177
+ return { key, bytes: Buffer.byteLength(String(value), 'utf8') };
178
+ }
179
+
180
+ export async function kvDelete(key, groupId) {
181
+ await call({ method: 'DELETE', path: '/api/v1/kv/' + encodeURIComponent(key) + gid(groupId) });
182
+ return { key, deleted: true };
183
+ }
184
+
185
+ export async function kvList({ limit = 50, offset = 0, groupId } = {}) {
186
+ const n = normalizeGroupId(groupId);
187
+ const q = `?limit=${limit}&offset=${offset}` + (n === undefined ? '' : `&groupId=${n}`);
188
+ const data = await call({ method: 'GET', path: '/api/v1/kv' + q });
189
+ return {
190
+ total: Number(data?.total) || 0,
191
+ items: (data?.items ?? []).map((it) => ({
192
+ key: it.key ?? '',
193
+ value: it.value ?? '',
194
+ expiresAt: it.expires_at ?? it.expiresAt ?? null,
195
+ tags: Array.isArray(it.tags) ? it.tags.map(String) : [],
196
+ })),
197
+ };
198
+ }
199
+
200
+ // 供 service 层判断「key 不存在」时抛的错(保持错误码语义)
201
+ export { notFound };
@@ -0,0 +1,26 @@
1
+ // 交给操作系统的两个动作:打开浏览器、在文件管理器里打开目录。
2
+ // 零业务语义,且刻意不抛错——打不开就算了,调用方已经打印了地址。
3
+ import { spawn } from 'node:child_process';
4
+
5
+ function open(target) {
6
+ try {
7
+ const plat = process.platform;
8
+ if (plat === 'win32') {
9
+ spawn('cmd', ['/c', 'start', '', target], { detached: true, stdio: 'ignore' }).unref();
10
+ } else if (plat === 'darwin') {
11
+ spawn('open', [target], { detached: true, stdio: 'ignore' }).unref();
12
+ } else {
13
+ spawn('xdg-open', [target], { detached: true, stdio: 'ignore' }).unref();
14
+ }
15
+ } catch {
16
+ // 静默降级:调用方已经把 URL / 路径打印到终端
17
+ }
18
+ }
19
+
20
+ export function openBrowser(url) {
21
+ open(url);
22
+ }
23
+
24
+ export function openPath(dir) {
25
+ open(dir);
26
+ }
@@ -0,0 +1,46 @@
1
+ // 路径与常量的唯一权威来源。
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { badInput } from './errors.js';
5
+
6
+ export const APP_NAME = 'nx-kv';
7
+ export const APP_DIR = join(homedir(), '.nx-kv');
8
+ export const STORE_PATH = join(APP_DIR, 'store.json');
9
+ export const DEFAULT_PORT = 7820;
10
+
11
+ // 允许测试与多实例覆盖存储位置:NX_KV_STORE 环境变量优先
12
+ export function storePathFromEnv() {
13
+ return process.env.NX_KV_STORE || STORE_PATH;
14
+ }
15
+
16
+ // 名称安全校验:拒绝路径穿越(保留中文等合法命名,与 fileops.go 语义一致)。
17
+ //
18
+ // 用于「这个字符串会被用作**目录名**」的场景:skill 名、内置包名、适配器 id。
19
+ // 因此拒绝前导点——避免意外生成隐藏目录。
20
+ // 注意:文件路径不要用这个,用下面的 assertSafeRelPath——像 .gitignore 这样的
21
+ // 文件名是合法且常见的,用名称规则去卡它会把正常文件挡在门外。
22
+ export function assertSafeName(name, label = 'skill 名称') {
23
+ if (!name || typeof name !== 'string' || /[\\/]/.test(name) || name.includes('..') || name.startsWith('.')) {
24
+ throw badInput('非法 ' + label + ': ' + name);
25
+ }
26
+ return name;
27
+ }
28
+
29
+ // 相对路径校验:一切来自外部的「某个目录内的文件路径」都要过这里。
30
+ // 允许子目录(git 的 --file 常带路径)与前导点(.gitignore / .github/workflows/x.yml)。
31
+ // 拒绝绝对路径与 .. 段——历史上 applySkillSide 做了这类校验而 gitResolve/gitDiff 没做,
32
+ // 同一类输入校验强度不一致,本身就是可乘之口。
33
+ //
34
+ // 返回**归一化后**的路径(折叠重复分隔符、去掉 './' 段),调用方应使用返回值。
35
+ export function assertSafeRelPath(input, { label = '文件路径', allowSubdir = true } = {}) {
36
+ if (!input || typeof input !== 'string') throw badInput(label + '不能为空');
37
+ if (/^([a-zA-Z]:|[\\/])/.test(input)) throw badInput(label + '必须是相对路径: ' + input);
38
+
39
+ const segments = input.split(/[\\/]+/).filter((x) => x !== '' && x !== '.');
40
+ if (!segments.length) throw badInput('非法 ' + label + ': ' + input);
41
+ if (segments.includes('..')) throw badInput(label + '不得包含 ..: ' + input);
42
+ if (!allowSubdir && segments.length > 1) {
43
+ throw badInput(label + '不能包含路径分隔符: ' + input);
44
+ }
45
+ return segments.join('/');
46
+ }