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.
- package/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/assets/nx-kv/SKILL.md +99 -0
- package/assets/nx-kv/references/agent-workflow.md +178 -0
- package/assets/nx-kv/references/todo-commands.md +193 -0
- package/bin/cli.mjs +10 -0
- package/package.json +57 -0
- package/src/core/config.js +94 -0
- package/src/core/errors.js +81 -0
- package/src/core/frontmatter.js +65 -0
- package/src/core/fstree.js +74 -0
- package/src/core/kvapi.js +201 -0
- package/src/core/open.js +26 -0
- package/src/core/paths.js +46 -0
- package/src/core/prompt.js +60 -0
- package/src/index.js +9 -0
- package/src/modules/auth/index.js +98 -0
- package/src/modules/auth/view.jsx +78 -0
- package/src/modules/bundled/index.js +51 -0
- package/src/modules/bundled/service.js +80 -0
- package/src/modules/group/index.js +199 -0
- package/src/modules/group/view.jsx +159 -0
- package/src/modules/system/index.js +153 -0
- package/src/modules/todo/index.js +280 -0
- package/src/modules/todo/service.js +524 -0
- package/src/modules/todo/view.jsx +280 -0
- package/src/runtime/api.js +108 -0
- package/src/runtime/cli.js +299 -0
- package/src/runtime/registry.js +56 -0
- package/src/runtime/server.js +74 -0
- package/src/runtime/spec.js +147 -0
- package/src/web/frontend/App.jsx +74 -0
- package/src/web/frontend/api/client.js +39 -0
- package/src/web/frontend/components/CliHints.jsx +25 -0
- package/src/web/frontend/components/ui.jsx +197 -0
- package/src/web/frontend/index.html +13 -0
- package/src/web/frontend/main.jsx +17 -0
- package/src/web/frontend/registry.js +12 -0
- package/src/web/frontend/store.jsx +74 -0
- package/src/web/frontend/style.css +368 -0
- package/src/web/public/assets/CliHints-3H3KOoDZ.js +1 -0
- package/src/web/public/assets/index-BnWex75m.css +1 -0
- package/src/web/public/assets/index-jhTtYCb9.js +10 -0
- package/src/web/public/assets/view-CrL-DSJu.js +1 -0
- package/src/web/public/assets/view-Du7Wv_-h.js +1 -0
- package/src/web/public/assets/view-S0N5g6v1.js +2 -0
- package/src/web/public/index.html +13 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// 终端交互输入。
|
|
2
|
+
//
|
|
3
|
+
// 只做一件 store 层不该管、但 CLI 必须有的事:**隐藏式读取密码**。
|
|
4
|
+
// 不用第三方库——把 stdin 切到 raw 模式、逐字符读、自己回显掩码即可。
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
|
|
7
|
+
// 读取一行可见输入(用于非敏感内容)
|
|
8
|
+
export async function ask(question) {
|
|
9
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
rl.question(question, (answer) => {
|
|
12
|
+
rl.close();
|
|
13
|
+
resolve(answer.trim());
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 读取一行**不回显**的输入(密码)。
|
|
19
|
+
// stdin 不是 TTY(管道 / CI)时无法切 raw 模式,退回普通读取——
|
|
20
|
+
// 此时密码会显示在终端里,但至少流程不中断。
|
|
21
|
+
export async function askHidden(question) {
|
|
22
|
+
if (!process.stdin.isTTY) return ask(question);
|
|
23
|
+
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
process.stdout.write(question);
|
|
26
|
+
const stdin = process.stdin;
|
|
27
|
+
stdin.setRawMode(true);
|
|
28
|
+
stdin.resume();
|
|
29
|
+
stdin.setEncoding('utf8');
|
|
30
|
+
|
|
31
|
+
let buf = '';
|
|
32
|
+
const onData = (ch) => {
|
|
33
|
+
switch (ch) {
|
|
34
|
+
case '\r':
|
|
35
|
+
case '\n':
|
|
36
|
+
case '': // Ctrl-D
|
|
37
|
+
stdin.setRawMode(false);
|
|
38
|
+
stdin.pause();
|
|
39
|
+
stdin.removeListener('data', onData);
|
|
40
|
+
process.stdout.write('\n');
|
|
41
|
+
resolve(buf.trim());
|
|
42
|
+
break;
|
|
43
|
+
case '': // Ctrl-C
|
|
44
|
+
stdin.setRawMode(false);
|
|
45
|
+
stdin.pause();
|
|
46
|
+
process.stdout.write('\n');
|
|
47
|
+
process.exit(130);
|
|
48
|
+
break;
|
|
49
|
+
case '': // Backspace
|
|
50
|
+
case '\b':
|
|
51
|
+
buf = buf.slice(0, -1);
|
|
52
|
+
break;
|
|
53
|
+
default:
|
|
54
|
+
// 忽略其他控制字符,避免方向键之类混进密码
|
|
55
|
+
if (ch >= ' ') buf += ch;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
stdin.on('data', onData);
|
|
59
|
+
});
|
|
60
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// 编程入口:供其他工具 / agent 以库方式调用。
|
|
2
|
+
// 导出的是各模块的 service(业务真相源),不是 CLI/HTTP 壳。
|
|
3
|
+
export * as config from './core/config.js';
|
|
4
|
+
export * as kvapi from './core/kvapi.js';
|
|
5
|
+
export * as errors from './core/errors.js';
|
|
6
|
+
export * as todo from './modules/todo/service.js';
|
|
7
|
+
|
|
8
|
+
export { ACTIONS, MODULES } from './runtime/registry.js';
|
|
9
|
+
export { startServer } from './runtime/server.js';
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// 认证模块:登录 / 登出 / 查看登录态。
|
|
2
|
+
//
|
|
3
|
+
// 登录成功后**只把 token 写进本机配置**,密码不落盘、不进日志。
|
|
4
|
+
// 所有需要 token 的模块都通过 core/config.js 的 requireAuth() 取,不各自读配置。
|
|
5
|
+
import { loadConfig, updateConfig, saveConfig, requireAuth, configPathFromEnv } from '../../core/config.js';
|
|
6
|
+
import { login as apiLogin, userInfo } from '../../core/kvapi.js';
|
|
7
|
+
import { askHidden } from '../../core/prompt.js';
|
|
8
|
+
import { badInput } from '../../core/errors.js';
|
|
9
|
+
|
|
10
|
+
async function doLogin(ctx) {
|
|
11
|
+
const email = String(ctx.email || '').trim();
|
|
12
|
+
if (!email) throw badInput('邮箱不能为空');
|
|
13
|
+
|
|
14
|
+
// 密码来源优先级:--password(脚本用)> 交互式隐藏输入
|
|
15
|
+
let password = ctx.password;
|
|
16
|
+
if (!password) {
|
|
17
|
+
password = await askHidden(`请输入 ${email} 的密码: `);
|
|
18
|
+
}
|
|
19
|
+
if (!password) throw badInput('密码不能为空');
|
|
20
|
+
|
|
21
|
+
const { token, userId } = await apiLogin(email, password, { baseUrl: ctx.baseUrl });
|
|
22
|
+
await updateConfig({
|
|
23
|
+
token,
|
|
24
|
+
userId,
|
|
25
|
+
email,
|
|
26
|
+
...(ctx.baseUrl ? { baseUrl: ctx.baseUrl } : {}),
|
|
27
|
+
});
|
|
28
|
+
return { email, userId, tokenSaved: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default {
|
|
32
|
+
id: 'auth',
|
|
33
|
+
title: '认证(登录态)',
|
|
34
|
+
order: 10,
|
|
35
|
+
view: () => import('./view.jsx'),
|
|
36
|
+
|
|
37
|
+
actions: [
|
|
38
|
+
{
|
|
39
|
+
id: 'auth.login',
|
|
40
|
+
cli: ['auth', 'login'],
|
|
41
|
+
http: ['POST', '/api/auth/login'],
|
|
42
|
+
summary: '登录并保存 token 到本机配置',
|
|
43
|
+
args: ['email'],
|
|
44
|
+
flags: {
|
|
45
|
+
password: { type: 'string', hint: '密码(省略则交互式隐藏输入)' },
|
|
46
|
+
baseUrl: { type: 'string', hint: '后端地址,覆盖默认' },
|
|
47
|
+
},
|
|
48
|
+
run: doLogin,
|
|
49
|
+
render: (d) => `已登录: ${d.email}(userId=${d.userId})\n配置: ${configPathFromEnv()}`,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'auth.logout',
|
|
53
|
+
cli: ['auth', 'logout'],
|
|
54
|
+
http: ['POST', '/api/auth/logout'],
|
|
55
|
+
summary: '清除本机保存的 token',
|
|
56
|
+
run: async () => {
|
|
57
|
+
const before = await loadConfig();
|
|
58
|
+
// 只清登录态,保留 baseUrl 与当前工作空间选择
|
|
59
|
+
await saveConfig({ ...before, token: '', email: '', userId: 0 });
|
|
60
|
+
return { loggedOut: true, was: before.email || '' };
|
|
61
|
+
},
|
|
62
|
+
render: (d) => (d.was ? `已登出(原账号 ${d.was})` : '本就未登录'),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'auth.status',
|
|
66
|
+
cli: ['auth', 'status'],
|
|
67
|
+
http: ['GET', '/api/auth'],
|
|
68
|
+
summary: '查看本机登录态(不请求后端,离线可用)',
|
|
69
|
+
run: async () => {
|
|
70
|
+
const cfg = await loadConfig();
|
|
71
|
+
return {
|
|
72
|
+
loggedIn: !!cfg.token,
|
|
73
|
+
email: cfg.email,
|
|
74
|
+
userId: cfg.userId,
|
|
75
|
+
baseUrl: cfg.baseUrl,
|
|
76
|
+
groupId: cfg.groupId,
|
|
77
|
+
configPath: configPathFromEnv(),
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
render: (d) =>
|
|
81
|
+
d.loggedIn
|
|
82
|
+
? `已登录: ${d.email}(userId=${d.userId})\n后端: ${d.baseUrl}\n工作空间: ${d.groupId > 0 ? d.groupId : '默认组'}`
|
|
83
|
+
: `未登录(nx-kv auth login <email>)\n后端: ${d.baseUrl}`,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'auth.me',
|
|
87
|
+
cli: ['auth', 'me'],
|
|
88
|
+
http: ['GET', '/api/auth/me'],
|
|
89
|
+
summary: '向后端确认身份(验证 token 是否仍有效)',
|
|
90
|
+
run: async () => {
|
|
91
|
+
await requireAuth();
|
|
92
|
+
const info = await userInfo();
|
|
93
|
+
return info ?? { ok: true };
|
|
94
|
+
},
|
|
95
|
+
render: (d) => JSON.stringify(d, null, 2),
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// 登录页:填邮箱密码登录,显示当前登录态与后端地址。
|
|
2
|
+
// 密码只在提交时用一次,不进 localStorage、不写日志。
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { api } from '../../web/frontend/api/client.js';
|
|
5
|
+
import { useStore } from '../../web/frontend/store.jsx';
|
|
6
|
+
import { useToast, useGuard, Copyable } from '../../web/frontend/components/ui.jsx';
|
|
7
|
+
import { CliHints } from '../../web/frontend/components/CliHints.jsx';
|
|
8
|
+
|
|
9
|
+
export default function AuthView() {
|
|
10
|
+
const { boot, refreshBoot } = useStore();
|
|
11
|
+
const toast = useToast();
|
|
12
|
+
const guard = useGuard();
|
|
13
|
+
const [email, setEmail] = useState(boot?.email || '');
|
|
14
|
+
const [password, setPassword] = useState('');
|
|
15
|
+
const [busy, setBusy] = useState(false);
|
|
16
|
+
|
|
17
|
+
const login = () => guard(async () => {
|
|
18
|
+
if (!email.trim()) { toast('请填邮箱'); return; }
|
|
19
|
+
if (!password) { toast('请填密码'); return; }
|
|
20
|
+
setBusy(true);
|
|
21
|
+
try {
|
|
22
|
+
const r = await api('/api/auth/login', { method: 'POST', body: { email: email.trim(), password } });
|
|
23
|
+
setPassword(''); // 用完即清,不留在组件状态里
|
|
24
|
+
toast(`已登录: ${r.email}`);
|
|
25
|
+
await refreshBoot();
|
|
26
|
+
} finally {
|
|
27
|
+
setBusy(false);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const logout = () => guard(async () => {
|
|
32
|
+
await api('/api/auth/logout', { method: 'POST' });
|
|
33
|
+
toast('已登出');
|
|
34
|
+
await refreshBoot();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<>
|
|
39
|
+
<div className="card">
|
|
40
|
+
<div className="colhead">
|
|
41
|
+
<h3>登录态</h3>
|
|
42
|
+
<span className={boot?.loggedIn ? 'tag strong' : 'tag bad'}>
|
|
43
|
+
{boot?.loggedIn ? '已登录' : '未登录'}
|
|
44
|
+
</span>
|
|
45
|
+
</div>
|
|
46
|
+
<div className="settings">
|
|
47
|
+
<dt>账号</dt>
|
|
48
|
+
<dd>{boot?.email || <span className="muted">(未登录)</span>}</dd>
|
|
49
|
+
<dt>userId</dt>
|
|
50
|
+
<dd>{boot?.userId || '-'}</dd>
|
|
51
|
+
<dt>后端</dt>
|
|
52
|
+
<dd className="mono"><Copyable text={boot?.baseUrl}>{boot?.baseUrl}</Copyable></dd>
|
|
53
|
+
<dt>本机配置</dt>
|
|
54
|
+
<dd className="mono"><Copyable text={boot?.configPath}>{boot?.configPath}</Copyable></dd>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div className="card">
|
|
59
|
+
<div className="colhead"><h3>{boot?.loggedIn ? '切换账号' : '登录'}</h3></div>
|
|
60
|
+
<div className="row-inline" style={{ padding: 12 }}>
|
|
61
|
+
<input placeholder="邮箱" size="24" spellCheck="false" value={email}
|
|
62
|
+
onChange={(e) => setEmail(e.target.value)} />
|
|
63
|
+
<input placeholder="密码" type="password" size="18" value={password}
|
|
64
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
65
|
+
onKeyDown={(e) => { if (e.key === 'Enter') login(); }} />
|
|
66
|
+
<button className="btn" onClick={login} disabled={busy}>{busy ? '登录中…' : '登录'}</button>
|
|
67
|
+
{boot?.loggedIn ? <button className="btn ghost danger" onClick={logout}>登出</button> : null}
|
|
68
|
+
</div>
|
|
69
|
+
<div className="muted" style={{ padding: '0 12px 12px', fontSize: 12 }}>
|
|
70
|
+
密码只用于本次请求,不写入本机配置;配置里只保存 token。
|
|
71
|
+
也可以用 CLI:<code>nx-kv auth login <email></code>(会交互式隐藏输入密码)。
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<CliHints module="auth" />
|
|
76
|
+
</>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// 内置 skill 包模块:把随包发布的 nx-kv 说明装到用户的 skills 目录。
|
|
2
|
+
// 没有面板视图——安装入口在 Skill 页,列出/安装都通过 action 暴露。
|
|
3
|
+
import * as service from './service.js';
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
id: 'bundled',
|
|
7
|
+
title: '内置 skill 包',
|
|
8
|
+
order: 50,
|
|
9
|
+
view: null,
|
|
10
|
+
|
|
11
|
+
actions: [
|
|
12
|
+
{
|
|
13
|
+
id: 'bundled.list',
|
|
14
|
+
// 第二条是历史别名:`skill install --list` 早于 `bundled list` 存在,
|
|
15
|
+
// 命令路径里带 flag,因此匹配是按 token 前缀而非「非 flag 前缀」。
|
|
16
|
+
cli: [['bundled', 'list'], ['skill', 'install', '--list']],
|
|
17
|
+
http: ['GET', '/api/bundled'],
|
|
18
|
+
summary: '列出包内自带 skill(含文件数与描述)',
|
|
19
|
+
// 两端返回结构刻意不同:HTTP 供面板顺带拿到默认安装目录;
|
|
20
|
+
// CLI 的 --json 保持纯数组的历史形状,不破坏已在消费它的 agent。
|
|
21
|
+
run: async (_ctx, meta) => {
|
|
22
|
+
const skills = await service.listBundledSkills();
|
|
23
|
+
return meta.transport === 'http'
|
|
24
|
+
? { defaultDir: service.DEFAULT_SKILLS_DIR, skills }
|
|
25
|
+
: skills;
|
|
26
|
+
},
|
|
27
|
+
render: (l) =>
|
|
28
|
+
l.length
|
|
29
|
+
? l.map((s) => `${s.name.padEnd(14)} ${s.files} 个文件 ${s.description.slice(0, 50)}`).join('\n')
|
|
30
|
+
: '(包内无内置 skill)',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'bundled.install',
|
|
34
|
+
cli: ['skill', 'install'],
|
|
35
|
+
http: ['POST', '/api/bundled/install'],
|
|
36
|
+
summary: '安装内置 skill 到 skills 目录(默认 nx-kv → ~/.claude/skills)',
|
|
37
|
+
args: [{ name: 'name', required: false }],
|
|
38
|
+
flags: { to: { type: 'string' }, force: { type: 'boolean' } },
|
|
39
|
+
run: (ctx) =>
|
|
40
|
+
service.installBundledSkill({ name: ctx.name || 'nx-kv', to: ctx.to, force: ctx.force }),
|
|
41
|
+
render: (d) => {
|
|
42
|
+
if (d.status === 'conflict') {
|
|
43
|
+
const files = d.files.map((f) => ` ${f.file} ${f.side}`).join('\n');
|
|
44
|
+
return `目标已存在且内容不同(${d.count} 个文件): ${d.path}\n${files}\n用 --force 覆盖,或先删除目标目录`;
|
|
45
|
+
}
|
|
46
|
+
if (d.skipped) return `已是最新,无需安装: ${d.path}`;
|
|
47
|
+
return `${d.replaced ? '已更新' : '已安装'} ${d.name}(${d.files} 个文件)-> ${d.path}`;
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// 内置 skill 包:随 nx-kv 一起发布的 assets/<name>/(SKILL.md + references/…)
|
|
2
|
+
// 负责列出与安装到用户/项目的 skills 目录,供 agent 一键获得 nx-kv 的使用说明。
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import fsp from 'node:fs/promises';
|
|
7
|
+
import { diffTrees } from '../../core/fstree.js';
|
|
8
|
+
import { parseFrontmatter } from '../../core/frontmatter.js';
|
|
9
|
+
import { assertSafeName } from '../../core/paths.js';
|
|
10
|
+
import { notFound } from '../../core/errors.js';
|
|
11
|
+
|
|
12
|
+
// assets/ 在包根,src/modules/bundled/ 的上三级
|
|
13
|
+
const ASSETS_DIR = fileURLToPath(new URL('../../../assets/', import.meta.url));
|
|
14
|
+
|
|
15
|
+
// 默认安装到 Claude Code 的用户级 skills 目录
|
|
16
|
+
export const DEFAULT_SKILLS_DIR = join(homedir(), '.claude', 'skills');
|
|
17
|
+
|
|
18
|
+
async function pathExists(p) {
|
|
19
|
+
try {
|
|
20
|
+
await fsp.access(p);
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function assetsDir() {
|
|
28
|
+
return ASSETS_DIR;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 列出包内自带的所有 skill(含文件数与描述)
|
|
32
|
+
export async function listBundledSkills() {
|
|
33
|
+
const out = [];
|
|
34
|
+
const entries = await fsp.readdir(ASSETS_DIR, { withFileTypes: true }).catch(() => []);
|
|
35
|
+
for (const e of entries) {
|
|
36
|
+
if (!e.isDirectory()) continue;
|
|
37
|
+
const dir = join(ASSETS_DIR, e.name);
|
|
38
|
+
// 用不存在的路径当哨兵,diffTrees 会把包内文件全列为 only-central,等于列出文件清单
|
|
39
|
+
const files = await diffTrees(dir, join(dir, '__nx_rh_missing__'));
|
|
40
|
+
if (!files.length) continue;
|
|
41
|
+
|
|
42
|
+
// 描述解析复用 core/frontmatter.js —— 这里原本抄了一份简化正则,
|
|
43
|
+
// 于是 CRLF / 块标量 / 引号的处理与 skills 侧并不一致。
|
|
44
|
+
const md = await fsp.readFile(join(dir, 'SKILL.md'), 'utf8').catch(() => null);
|
|
45
|
+
const description = md ? parseFrontmatter(md).description : '';
|
|
46
|
+
out.push({ name: e.name, dir, files: files.length, description });
|
|
47
|
+
}
|
|
48
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 安装内置 skill 到目标 skills 目录(默认 ~/.claude/skills)
|
|
52
|
+
// 返回 { status: 'ok' | 'conflict', ... }——已存在且内容不同是**业务结果**,
|
|
53
|
+
// 需要用户决定是否覆盖,不是错误。
|
|
54
|
+
export async function installBundledSkill({ name = 'nx-kv', to, force } = {}) {
|
|
55
|
+
name = assertSafeName(name);
|
|
56
|
+
const src = join(ASSETS_DIR, name);
|
|
57
|
+
if (!(await pathExists(join(src, 'SKILL.md')))) {
|
|
58
|
+
const available = (await listBundledSkills()).map((s) => s.name).join(', ') || '(无)';
|
|
59
|
+
throw notFound(`未找到内置 skill: ${name}(可用: ${available})`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const targetRoot = resolve(to || DEFAULT_SKILLS_DIR);
|
|
63
|
+
const dst = join(targetRoot, name);
|
|
64
|
+
const files = await diffTrees(src, dst); // side: only-central=新增, both-differ=不同, only-project=多余
|
|
65
|
+
|
|
66
|
+
if (!files.length) {
|
|
67
|
+
return { status: 'ok', skipped: true, name, path: dst, files: 0 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const exists = await pathExists(dst);
|
|
71
|
+
if (exists && !force) {
|
|
72
|
+
return { status: 'conflict', name, path: dst, files, count: files.length };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (exists) await fsp.rm(dst, { recursive: true, force: true });
|
|
76
|
+
await fsp.mkdir(targetRoot, { recursive: true });
|
|
77
|
+
await fsp.cp(src, dst, { recursive: true, dereference: true, force: true });
|
|
78
|
+
|
|
79
|
+
return { status: 'ok', installed: !exists, replaced: exists, name, path: dst, files: files.length };
|
|
80
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// 工作空间(group)模块:完整 CRUD + 切换 + 成员查看。
|
|
2
|
+
//
|
|
3
|
+
// 后端实际支持:`POST /groups`(建)、`GET /groups`(列)、`PATCH /groups/:id`(改)、
|
|
4
|
+
// `DELETE /groups/:id`(解散)、`GET /groups/:id/members`(成员)。
|
|
5
|
+
// 所以这是一个**集合资源**,声明 `resource: 'group'` —— 五个 CRUD 操作齐备且两端可达,
|
|
6
|
+
// tests/unit/registry.test.mjs 会据此断言。
|
|
7
|
+
//
|
|
8
|
+
// `current` / `use` / `members` 不是 CRUD,用动作动词命名。
|
|
9
|
+
//
|
|
10
|
+
// 切换是**本机态**:只改 ~/.nx-kv/config.json 的 groupId,不动服务端任何数据。
|
|
11
|
+
// 之后所有 todo 读写都带上它,后端按 groupId 隔离。
|
|
12
|
+
import { loadConfig, updateConfig, requireAuth } from '../../core/config.js';
|
|
13
|
+
import {
|
|
14
|
+
listGroups,
|
|
15
|
+
createGroup,
|
|
16
|
+
updateGroup,
|
|
17
|
+
deleteGroup,
|
|
18
|
+
listGroupMembers,
|
|
19
|
+
} from '../../core/kvapi.js';
|
|
20
|
+
import { badInput, notFound } from '../../core/errors.js';
|
|
21
|
+
|
|
22
|
+
// 纯文本渲染用:把一行的关键信息摆齐
|
|
23
|
+
const renderGroupLine = (g, current) =>
|
|
24
|
+
`${current ? '*' : ' '} ${String(g.id).padStart(4)} ${g.name.padEnd(14)} ${(g.myRole || '').padEnd(6)} ${g.memberCount} 人 ${g.description || ''}`;
|
|
25
|
+
|
|
26
|
+
// 按 id 或名字定位。后端没有「取单个组」的接口,所以从列表里找。
|
|
27
|
+
async function findGroup(locator) {
|
|
28
|
+
const s = String(locator ?? '').trim();
|
|
29
|
+
if (!s) throw badInput('必须给出工作空间 id 或名称(nx-kv group list 可查看)');
|
|
30
|
+
const groups = await listGroups();
|
|
31
|
+
const hit = /^\d+$/.test(s)
|
|
32
|
+
? groups.find((g) => g.id === Number(s))
|
|
33
|
+
: groups.find((g) => g.name.toLowerCase() === s.toLowerCase());
|
|
34
|
+
if (!hit) {
|
|
35
|
+
throw notFound(`工作空间不存在或无权访问: ${s}(nx-kv group list 查看可用项)`);
|
|
36
|
+
}
|
|
37
|
+
return hit;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default {
|
|
41
|
+
id: 'group',
|
|
42
|
+
title: '工作空间(= Web「工作空间」页)',
|
|
43
|
+
order: 20,
|
|
44
|
+
view: () => import('./view.jsx'),
|
|
45
|
+
|
|
46
|
+
// 集合资源:建 / 查(列表、单条)/ 改 / 散 都齐备
|
|
47
|
+
resource: 'group',
|
|
48
|
+
|
|
49
|
+
actions: [
|
|
50
|
+
// ─── CRUD 五操作 ───────────────────────────────────────────
|
|
51
|
+
{
|
|
52
|
+
id: 'group.list',
|
|
53
|
+
cli: ['group', 'list'],
|
|
54
|
+
http: ['GET', '/api/groups'],
|
|
55
|
+
summary: '列出我的工作空间(* 为当前)',
|
|
56
|
+
run: async () => {
|
|
57
|
+
await requireAuth();
|
|
58
|
+
const [groups, cfg] = await Promise.all([listGroups(), loadConfig()]);
|
|
59
|
+
return {
|
|
60
|
+
current: cfg.groupId,
|
|
61
|
+
groups: groups.map((g) => ({ ...g, current: g.id === cfg.groupId })),
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
render: (d) => {
|
|
65
|
+
if (!d.groups.length) return '(没有可用工作空间)';
|
|
66
|
+
return d.groups.map((g) => renderGroupLine(g, g.current)).join('\n');
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'group.get',
|
|
71
|
+
cli: ['group', 'get'],
|
|
72
|
+
http: ['GET', '/api/groups/:id'],
|
|
73
|
+
summary: '查看单个工作空间',
|
|
74
|
+
args: ['id'],
|
|
75
|
+
run: async (ctx) => {
|
|
76
|
+
await requireAuth();
|
|
77
|
+
const g = await findGroup(ctx.id);
|
|
78
|
+
const cfg = await loadConfig();
|
|
79
|
+
return { ...g, current: g.id === cfg.groupId };
|
|
80
|
+
},
|
|
81
|
+
render: (g) =>
|
|
82
|
+
[
|
|
83
|
+
`${g.name}${g.current ? ' ← 当前' : ''}`,
|
|
84
|
+
` id: ${g.id}`,
|
|
85
|
+
` 角色: ${g.myRole || '-'}`,
|
|
86
|
+
` 成员: ${g.memberCount} 人`,
|
|
87
|
+
` 描述: ${g.description || '-'}`,
|
|
88
|
+
` 创建: ${String(g.createdAt || '').slice(0, 10) || '-'}`,
|
|
89
|
+
].join('\n'),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: 'group.add',
|
|
93
|
+
cli: ['group', 'add'],
|
|
94
|
+
http: ['POST', '/api/groups'],
|
|
95
|
+
summary: '新建工作空间',
|
|
96
|
+
args: ['name'],
|
|
97
|
+
flags: { description: { type: 'string', hint: '描述' } },
|
|
98
|
+
run: async (ctx) => {
|
|
99
|
+
await requireAuth();
|
|
100
|
+
return createGroup({ name: ctx.name, description: ctx.description });
|
|
101
|
+
},
|
|
102
|
+
render: (g) => `已新建工作空间: ${g.name}(id=${g.id})\n用 nx-kv group use ${g.id} 切过去`,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: 'group.update',
|
|
106
|
+
cli: ['group', 'update'],
|
|
107
|
+
http: ['PATCH', '/api/groups/:id'],
|
|
108
|
+
summary: '改名 / 改描述(只改传入的字段)',
|
|
109
|
+
args: ['id'],
|
|
110
|
+
flags: {
|
|
111
|
+
name: { type: 'string', hint: '新名称' },
|
|
112
|
+
description: { type: 'string', hint: '新描述' },
|
|
113
|
+
},
|
|
114
|
+
run: async (ctx) => {
|
|
115
|
+
await requireAuth();
|
|
116
|
+
const g = await findGroup(ctx.id); // 支持按名字定位,再拿真实 id
|
|
117
|
+
return updateGroup(g.id, { name: ctx.name, description: ctx.description });
|
|
118
|
+
},
|
|
119
|
+
render: (g) => `已更新工作空间: ${g.name}(id=${g.id})`,
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: 'group.remove',
|
|
123
|
+
cli: ['group', 'remove'],
|
|
124
|
+
http: ['DELETE', '/api/groups/:id'],
|
|
125
|
+
summary: '解散工作空间(后端要求组内已无 KV)',
|
|
126
|
+
args: ['id'],
|
|
127
|
+
run: async (ctx) => {
|
|
128
|
+
await requireAuth();
|
|
129
|
+
const g = await findGroup(ctx.id);
|
|
130
|
+
const cfg = await loadConfig();
|
|
131
|
+
if (g.id === cfg.groupId) {
|
|
132
|
+
// 解散当前空间会导致后续所有读写打到一个不存在的组上,先拦住
|
|
133
|
+
throw badInput(`不能解散当前正在使用的工作空间(${g.name}),先 nx-kv group use default 切走`);
|
|
134
|
+
}
|
|
135
|
+
await deleteGroup(g.id);
|
|
136
|
+
return { id: g.id, name: g.name };
|
|
137
|
+
},
|
|
138
|
+
render: (d) => `已解散工作空间: ${d.name}(id=${d.id})`,
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
// ─── 非 CRUD:切换与成员 ───────────────────────────────────
|
|
142
|
+
{
|
|
143
|
+
id: 'group.current',
|
|
144
|
+
cli: ['group', 'current'],
|
|
145
|
+
http: ['GET', '/api/group'],
|
|
146
|
+
summary: '查看当前工作空间',
|
|
147
|
+
run: async () => {
|
|
148
|
+
const cfg = await loadConfig();
|
|
149
|
+
if (!cfg.groupId) return { id: 0, name: '默认组' };
|
|
150
|
+
// 名字是尽力而为:后端不可达时不该让「看当前是什么」失败
|
|
151
|
+
const groups = await listGroups().catch(() => []);
|
|
152
|
+
const hit = groups.find((g) => g.id === cfg.groupId);
|
|
153
|
+
return { id: cfg.groupId, name: hit ? hit.name : `#${cfg.groupId}` };
|
|
154
|
+
},
|
|
155
|
+
render: (d) => `当前工作空间: ${d.name}(${d.id === 0 ? '默认组,不传 groupId' : 'id=' + d.id})`,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: 'group.use',
|
|
159
|
+
cli: [['group', 'use'], ['group', 'switch']],
|
|
160
|
+
http: ['POST', '/api/group'],
|
|
161
|
+
summary: '切换当前工作空间(id 或名称;0 / default 回默认组)',
|
|
162
|
+
args: ['locator'],
|
|
163
|
+
run: async (ctx) => {
|
|
164
|
+
await requireAuth();
|
|
165
|
+
const s = String(ctx.locator ?? '').trim();
|
|
166
|
+
if (s === '0' || s.toLowerCase() === 'default') {
|
|
167
|
+
await updateConfig({ groupId: 0 });
|
|
168
|
+
return { id: 0, name: '默认组' };
|
|
169
|
+
}
|
|
170
|
+
const g = await findGroup(s);
|
|
171
|
+
await updateConfig({ groupId: g.id });
|
|
172
|
+
return g;
|
|
173
|
+
},
|
|
174
|
+
render: (d) =>
|
|
175
|
+
d.id === 0
|
|
176
|
+
? '已切回默认组(后续读写不传 groupId)'
|
|
177
|
+
: `已切换到工作空间: ${d.name}(id=${d.id})`,
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: 'group.members',
|
|
181
|
+
cli: ['group', 'members'],
|
|
182
|
+
http: ['GET', '/api/groups/:id/members'],
|
|
183
|
+
summary: '查看工作空间成员',
|
|
184
|
+
args: ['id'],
|
|
185
|
+
run: async (ctx) => {
|
|
186
|
+
await requireAuth();
|
|
187
|
+
const g = await findGroup(ctx.id);
|
|
188
|
+
return { group: { id: g.id, name: g.name }, members: await listGroupMembers(g.id) };
|
|
189
|
+
},
|
|
190
|
+
render: (d) => {
|
|
191
|
+
if (!d.members.length) return `${d.group.name}: (没有成员)`;
|
|
192
|
+
const w = Math.max(...d.members.map((m) => m.email.length));
|
|
193
|
+
return d.members
|
|
194
|
+
.map((m) => ` ${m.email.padEnd(w)} ${m.role.padEnd(8)} ${m.nickname}`)
|
|
195
|
+
.join('\n');
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
};
|