nx-sk 0.3.0

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 (60) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/LICENSE +21 -0
  3. package/README.md +222 -0
  4. package/assets/nx-sk/SKILL.md +102 -0
  5. package/assets/nx-sk/references/00-design.md +116 -0
  6. package/assets/nx-sk/references/10-sections-entries.md +165 -0
  7. package/assets/nx-sk/references/20-secrets.md +170 -0
  8. package/assets/nx-sk/references/30-export-backup.md +80 -0
  9. package/assets/nx-sk/references/40-extend.md +75 -0
  10. package/bin/nx-sk.mjs +11 -0
  11. package/package.json +56 -0
  12. package/src/core/crypto.js +73 -0
  13. package/src/core/errors.js +52 -0
  14. package/src/core/fields.js +345 -0
  15. package/src/core/fsx.js +103 -0
  16. package/src/core/ids.js +19 -0
  17. package/src/core/open.js +32 -0
  18. package/src/core/paths.js +141 -0
  19. package/src/core/render.js +144 -0
  20. package/src/core/store.js +317 -0
  21. package/src/core/vault.js +109 -0
  22. package/src/index.js +14 -0
  23. package/src/modules/entries/index.js +152 -0
  24. package/src/modules/entries/kvTable.jsx +158 -0
  25. package/src/modules/entries/service.js +539 -0
  26. package/src/modules/entries/view.jsx +240 -0
  27. package/src/modules/export/index.js +39 -0
  28. package/src/modules/export/service.js +192 -0
  29. package/src/modules/export/view.jsx +155 -0
  30. package/src/modules/sections/index.js +104 -0
  31. package/src/modules/sections/service.js +337 -0
  32. package/src/modules/sections/view.jsx +311 -0
  33. package/src/modules/settings/index.js +34 -0
  34. package/src/modules/settings/service.js +78 -0
  35. package/src/modules/settings/view.jsx +193 -0
  36. package/src/modules/skill/index.js +51 -0
  37. package/src/modules/skill/service.js +200 -0
  38. package/src/modules/system/index.js +42 -0
  39. package/src/modules/system/service.js +155 -0
  40. package/src/runtime/api.js +101 -0
  41. package/src/runtime/cli.js +266 -0
  42. package/src/runtime/registry.js +65 -0
  43. package/src/runtime/server.js +80 -0
  44. package/src/runtime/spec.js +158 -0
  45. package/src/web/frontend/App.jsx +108 -0
  46. package/src/web/frontend/api/client.js +26 -0
  47. package/src/web/frontend/components/fieldEditor.jsx +85 -0
  48. package/src/web/frontend/components/ui.jsx +143 -0
  49. package/src/web/frontend/index.html +12 -0
  50. package/src/web/frontend/main.jsx +13 -0
  51. package/src/web/frontend/registry.js +20 -0
  52. package/src/web/frontend/store.jsx +87 -0
  53. package/src/web/frontend/styles.css +301 -0
  54. package/src/web/public/assets/index-BWGLG7Qa.js +40 -0
  55. package/src/web/public/assets/index-CwF5hM2v.css +1 -0
  56. package/src/web/public/assets/view-B6gPh1NQ.js +1 -0
  57. package/src/web/public/assets/view-CfXXYwW2.js +1 -0
  58. package/src/web/public/assets/view-CxzKrdA3.js +7 -0
  59. package/src/web/public/assets/view-DjO1C54o.js +7 -0
  60. package/src/web/public/index.html +13 -0
@@ -0,0 +1,51 @@
1
+ import * as service from './service.js';
2
+
3
+ export default {
4
+ id: 'skill',
5
+ title: 'Skill',
6
+ order: 800,
7
+ // 用户明确要求:skill 不呈现在 Web 面板里(CLI 的 skill install / skill get 保持不变)。
8
+ // 视图文件已删除 —— 声明了 view 却没有文件会让 vite build 直接报错,两边必须同时改。
9
+ view: null,
10
+ actions: [
11
+ {
12
+ id: 'skill.list',
13
+ cli: ['skill', 'list'],
14
+ http: ['GET', '/api/skills'],
15
+ summary: '列出随包分发的内置 skill 与其 ref 文档',
16
+ flags: {},
17
+ run: () => service.listSkills(),
18
+ render: (d) => service.renderSkillList(d),
19
+ },
20
+ {
21
+ id: 'skill.install',
22
+ cli: ['skill', 'install'],
23
+ http: ['POST', '/api/skills/install'],
24
+ summary: '把内置 skill 装到 ~/.claude/skills(三态:安装 / 已最新 / 冲突需 --force);--mode symlink 用软链挂过去,改完立刻生效',
25
+ args: [{ name: 'name', required: false }],
26
+ flags: {
27
+ to: { type: 'string', hint: '安装目录,缺省 ~/.claude/skills' },
28
+ mode: { type: 'string', enum: ['copy', 'symlink'], hint: 'copy=装副本(默认,可分发);symlink=软链回项目源码(本机开发,改完立刻生效)' },
29
+ force: { type: 'boolean', hint: '目标存在且内容不同时覆盖' },
30
+ 'dry-run': { type: 'boolean' },
31
+ },
32
+ run: (ctx) => service.installSkill(ctx),
33
+ render: (d) => service.renderInstall(d),
34
+ },
35
+ {
36
+ id: 'skill.get',
37
+ cli: ['skill', 'get'],
38
+ http: ['POST', '/api/skills/get'],
39
+ summary: '输出 SKILL.md 或 references/<ref> 全文(prefix + 文档 + install 状态三段),同时按 install 逻辑装好',
40
+ args: [{ name: 'name', required: false }, { name: 'ref', required: false }],
41
+ flags: {
42
+ to: { type: 'string' },
43
+ mode: { type: 'string', enum: ['copy', 'symlink'] },
44
+ force: { type: 'boolean' },
45
+ 'dry-run': { type: 'boolean', hint: '只输出文档,不装到本机' },
46
+ },
47
+ run: (ctx) => service.getSkill(ctx),
48
+ render: (d) => service.renderSkillContext(d),
49
+ },
50
+ ],
51
+ };
@@ -0,0 +1,200 @@
1
+ // skill 分发:`skill install` 让识别 ~/.claude/skills 的 agent 学会用 nx-sk;
2
+ // `skill get` 让**不读那个目录**的外部 agent 一次性拿到全上下文(prefix + 文档 + install 状态)。
3
+ // 两条解决的是不同场景,缺一即单边能力归零——所以它们平级,不是补充关系。
4
+ import fsp from 'node:fs/promises';
5
+ import { homedir } from 'node:os';
6
+ import { isAbsolute, join, resolve } from 'node:path';
7
+ import { ASSETS_DIR, SKILL_NAME, assertSafeName, displayPath } from '../../core/paths.js';
8
+ import { copyTree, diffTrees, ensureDir, listFiles, pathExists, readFileText, removeTree } from '../../core/fsx.js';
9
+ import { badInput, notFound } from '../../core/errors.js';
10
+
11
+ export const DEFAULT_SKILLS_DIR = join(homedir(), '.claude', 'skills');
12
+
13
+ export async function listSkills() {
14
+ let names = [];
15
+ try {
16
+ const items = await fsp.readdir(ASSETS_DIR, { withFileTypes: true });
17
+ names = items.filter((i) => i.isDirectory()).map((i) => i.name).sort();
18
+ } catch { /* assets 缺失 */ }
19
+ const out = [];
20
+ for (const n of names) {
21
+ const src = join(ASSETS_DIR, n);
22
+ const files = await listFiles(src);
23
+ const refs = files.filter((f) => f.startsWith('references/'));
24
+ out.push({
25
+ name: n,
26
+ assetsPath: displayPath(src),
27
+ files: files.length,
28
+ refs: refs.map((r) => r.replace(/^references\//, '').replace(/\.md$/, '')),
29
+ hasSkillMd: files.includes('SKILL.md'),
30
+ });
31
+ }
32
+ return { count: out.length, defaultTarget: displayPath(DEFAULT_SKILLS_DIR), skills: out };
33
+ }
34
+
35
+ async function resolveSkillSource(name) {
36
+ const n = assertSafeName(name || SKILL_NAME, 'skill 名');
37
+ const src = join(ASSETS_DIR, n);
38
+ if (!(await pathExists(join(src, 'SKILL.md')))) {
39
+ const { skills } = await listSkills();
40
+ throw notFound(`未找到内置 skill: ${n}(可用: ${skills.map((s) => s.name).join(', ') || '(无)'})`);
41
+ }
42
+ return { name: n, src };
43
+ }
44
+
45
+ /**
46
+ * 三态安装,**不静默覆盖**用户目录里的东西。
47
+ *
48
+ * 两种模式(骨架 ref 的 `--mode <symlink|copy>`):
49
+ * - `copy`(默认,**规范默认**):把 `assets/<name>` 复制过去。装出来的是副本,
50
+ * 不受项目目录移动影响,适合 `npx` 分发到别人机器上;代价是改了 `assets/` 不会跟着走。
51
+ * - `symlink`:把目标**软链**到项目里的源 —— 这就是「shim 到全局」。改代码立刻生效、
52
+ * 永不漂移,适合本机开发(用户目录里那些 skill 本来就是这么挂的)。
53
+ */
54
+ export async function installSkill({ name, to, force, mode, 'dry-run': dryRun } = {}) {
55
+ const { name: n, src } = await resolveSkillSource(name);
56
+ const dst = join(resolve(to || DEFAULT_SKILLS_DIR), n);
57
+ const wantsLink = mode === 'symlink';
58
+
59
+ if (wantsLink) return installAsLink({ src, dst, force, dryRun });
60
+
61
+ const files = await diffTrees(src, dst);
62
+ if (!files.length) return { status: 'ok', skipped: true, path: displayPath(dst), pathRaw: dst, files: 0 };
63
+
64
+ const exists = await pathExists(dst);
65
+ if (exists && !force) {
66
+ return {
67
+ status: 'conflict', path: displayPath(dst), pathRaw: dst, files, count: files.length,
68
+ hint: '目标已存在且内容不同。确认要覆盖就加 --force(内容一致时不会走到这里)。',
69
+ };
70
+ }
71
+ if (dryRun) {
72
+ return { status: 'skipped', dryRun: true, wouldInstall: { path: displayPath(dst), replaced: exists, files: files.length, diff: files } };
73
+ }
74
+
75
+ if (exists) await removeTree(dst);
76
+ await copyTree(src, dst);
77
+ return { status: 'ok', installed: !exists, replaced: exists, path: displayPath(dst), pathRaw: dst, files: files.length };
78
+ }
79
+
80
+ /** 软链模式:目标是项目里的源,改完立刻生效。三态照旧(不静默覆盖)。 */
81
+ async function installAsLink({ src, dst, force, dryRun }) {
82
+ const st = await fsp.lstat(dst).catch(() => null);
83
+ const pointsAtSrc = st && st.isSymbolicLink() && resolve(await fsp.realpath(dst).catch(() => '')) === resolve(src);
84
+
85
+ if (pointsAtSrc) {
86
+ return {
87
+ status: 'ok', skipped: true, mode: 'symlink', files: 0,
88
+ path: displayPath(dst), pathRaw: dst, target: displayPath(src),
89
+ };
90
+ }
91
+
92
+ const kind = st ? (st.isSymbolicLink() ? '软链(指向别处)' : '真实目录') : null;
93
+ if (st && !force) {
94
+ return {
95
+ status: 'conflict', mode: 'symlink', path: displayPath(dst), pathRaw: dst,
96
+ existing: kind, target: displayPath(src),
97
+ hint: `目标已存在(${kind})。改成软链会动它,确认就加 --force。`,
98
+ };
99
+ }
100
+ if (dryRun) {
101
+ return {
102
+ status: 'skipped', dryRun: true, wouldInstall: { mode: 'symlink', path: displayPath(dst), target: displayPath(src), replaced: !!st },
103
+ };
104
+ }
105
+
106
+ if (st) await removeTree(dst);
107
+ await ensureDir(join(dst, '..')); // symlink 不会自建父目录(copyTree 会),不补这步就 ENOENT
108
+ // Windows 上用 junction:目录软链需要特权(或开发者模式),junction 不需要。
109
+ await fsp.symlink(src, dst, process.platform === 'win32' ? 'junction' : 'dir');
110
+ return {
111
+ status: 'ok', mode: 'symlink', installed: !st, replaced: !!st, files: 0,
112
+ path: displayPath(dst), pathRaw: dst, target: displayPath(src),
113
+ };
114
+ }
115
+
116
+ /**
117
+ * ref 解析规则(agent 一键输入,输入门槛最低优先):
118
+ * 缺省 → SKILL.md
119
+ * 含分隔符或 ./\ 开头 → 相对 assets/<name>/ 解析
120
+ * 裸名 → 先 references/<名>.md,再 <名>.md,再 <名>
121
+ * `..` 或绝对路径 → 拒绝
122
+ */
123
+ export async function resolveRef(name, ref) {
124
+ const raw = String(ref ?? '').trim();
125
+ if (raw.includes('..')) throw badInput("ref 路径不允许包含 '..'");
126
+ if (isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw)) throw badInput(`ref 越界: ${raw}`);
127
+
128
+ const base = join(ASSETS_DIR, name);
129
+ if (!raw) return { rel: 'SKILL.md', abs: join(base, 'SKILL.md') };
130
+ if (raw.includes('/') || raw.includes('\\') || raw.startsWith('.')) {
131
+ const rel = raw.replace(/^\.\//, '').replace(/\\/g, '/');
132
+ if (rel.includes('..')) throw badInput("ref 路径不允许包含 '..'");
133
+ return { rel, abs: join(base, rel) };
134
+ }
135
+ const candidates = [`references/${raw}.md`, `${raw}.md`, raw];
136
+ for (const c of candidates) {
137
+ const abs = join(base, c);
138
+ if (await pathExists(abs)) return { rel: c, abs };
139
+ }
140
+ const refs = (await listFiles(join(base, 'references'))).map((f) => f.replace(/\.md$/, ''));
141
+ throw badInput(`未知 ref: ${raw}(可用: ${refs.join(', ') || '(无)'};也可直接写 references/xxx.md)`);
142
+ }
143
+
144
+ export async function getSkill({ name, ref, to, force, mode, 'dry-run': dryRun } = {}) {
145
+ const { name: n } = await resolveSkillSource(name);
146
+ const { rel, abs } = await resolveRef(n, ref);
147
+ const content = await readFileText(abs);
148
+ // `skill get` 永远给文档——哪怕目标是 conflict 状态,文档也照常输出。
149
+ // 「目标端有 diff」是用户的决策范畴,不是 agent 的中止信号。
150
+ const install = await installSkill({ name: n, to, force, mode, 'dry-run': dryRun });
151
+ return {
152
+ skillName: n,
153
+ ref: rel,
154
+ content,
155
+ contentBytes: Buffer.byteLength(content, 'utf8'),
156
+ install,
157
+ };
158
+ }
159
+
160
+ // —— 人读渲染:prefix → 文档 → install 状态,**三段顺序固定** ──
161
+ // 顺序为什么重要:部分 agent 输出过长会自动截断——prefix 必须在最前告诉它
162
+ // 「这是哪个 skill、建议把内容复制到自己可访问的路径」,否则它拿到一坨文档不知道要干什么。
163
+ export function renderSkillContext(d) {
164
+ const sentinel = '# --- begin skill content (do not modify this line) ---';
165
+ const prefix = [
166
+ `# === nx-sk skill context ===`,
167
+ `# 以下内容来自 nx-sk skill \`${d.skillName}\` 的 ${d.ref}。`,
168
+ '# nx-sk 是本机个人资源管理器(栏目化存个人信息与大模型密钥,CLI 与 Web 同源)。',
169
+ '# 如果你(agent)不能直接读取 ~/.claude/skills 目录,请把下面 sentinel 之间、',
170
+ '# 到下一处 "-- install 状态 --" 之前的内容**完整复制**到你自己的上下文或可访问的文件里,',
171
+ '# 然后按文档里的命令操作用户的本机数据。',
172
+ sentinel,
173
+ ].join('\n');
174
+ const install = renderInstall(d.install);
175
+ return `${prefix}\n\n${d.content}\n\n-- install 状态 --\n${install}`;
176
+ }
177
+
178
+ export function renderInstall(r) {
179
+ if (!r) return '(未知)';
180
+ if (r.status === 'skipped' && r.dryRun) return `试运行:将装到 ${r.wouldInstall.path}(${r.wouldInstall.files} 个文件,替换已有: ${r.wouldInstall.replaced ? '是' : '否'})`;
181
+ if (r.skipped) return `已是最新: ${r.path}${r.mode === 'symlink' ? `(已软链到 ${r.target})` : '(与内置内容一致,无差异)'}`;
182
+ if (r.status === 'conflict' && r.mode === 'symlink') {
183
+ return `冲突: ${r.path} 已存在(${r.existing})。改软链会动它,确认:nx-sk skill install ${SKILL_NAME} --mode symlink --force`;
184
+ }
185
+ if (r.status === 'conflict') return `冲突: ${r.path} 已存在且内容不同(${r.count} 个文件有差异)。确认覆盖:nx-sk skill install ${SKILL_NAME} --force`;
186
+ if (r.mode === 'symlink') return `已软链: ${r.path} → ${r.target}(改项目里的源码立刻生效)`;
187
+ if (r.installed) return `已安装: ${r.path}(${r.files} 个文件)`;
188
+ if (r.replaced) return `已替换: ${r.path}(${r.files} 个文件)`;
189
+ return `完成: ${r.path}`;
190
+ }
191
+
192
+ export function renderSkillList(d) {
193
+ const lines = [`内置 skill ${d.count} 个 默认安装位置: ${d.defaultTarget}`];
194
+ for (const s of d.skills) {
195
+ lines.push(` ${s.name.padEnd(12)} ${s.files} 个文件 refs: ${s.refs.join(', ') || '(无)'}`);
196
+ }
197
+ lines.push('', `装到本机: nx-sk skill install ${SKILL_NAME}`);
198
+ lines.push(`给外部 agent 取上下文: nx-sk skill get ${SKILL_NAME} --json`);
199
+ return lines.join('\n');
200
+ }
@@ -0,0 +1,42 @@
1
+ import * as service from './service.js';
2
+
3
+ export default {
4
+ id: 'system',
5
+ title: '系统',
6
+ order: 100,
7
+ // 聚合模块:只读聚合,没有面板(面板里没有「系统」这个 tab)
8
+ view: null,
9
+ actions: [
10
+ {
11
+ id: 'system.bootstrap',
12
+ cli: ['bootstrap'],
13
+ http: ['GET', '/api/bootstrap'],
14
+ summary: '一次性拿齐上下文:版本 / 存储路径 / 栏目 / 设置 / 命令表',
15
+ flags: {},
16
+ run: () => service.bootstrapInfo(),
17
+ render: (d) => service.renderBootstrap(d),
18
+ },
19
+ {
20
+ id: 'system.health',
21
+ cli: ['health'],
22
+ http: ['GET', '/api/health'],
23
+ summary: '存活与存储可达性检查',
24
+ flags: {},
25
+ run: () => service.healthInfo(),
26
+ render: (d) => service.renderHealth(d),
27
+ },
28
+ {
29
+ id: 'system.routes',
30
+ cli: ['routes'],
31
+ http: ['GET', '/api/routes'],
32
+ summary: '命令与 HTTP 路由的双向对照表',
33
+ flags: {
34
+ // 读命令的过滤 flag 一律不带 default:「不传」本身代表「全部」
35
+ module: { type: 'string', hint: '模块 id 或命令组' },
36
+ http: { type: 'string', hint: '"POST /api/entries" 形式的路由' },
37
+ },
38
+ run: (ctx) => service.routesInfo(ctx),
39
+ render: (d) => service.renderRoutes(d),
40
+ },
41
+ ],
42
+ };
@@ -0,0 +1,155 @@
1
+ // system 是聚合模块:它读各处的状态拼上下文,不产生业务实体,所以没有视图。
2
+ import fsp from 'node:fs/promises';
3
+ import { constants } from 'node:fs';
4
+ import { APP_LABEL, APP_NAME, ENV_HOME, ENV_PASSPHRASE, ENV_STORE, VERSION, appHome, displayPath, storeFile } from '../../core/paths.js';
5
+ import { loadStore, corruptNote, listSnapshots, migrationNote, storePathInUse } from '../../core/store.js';
6
+ import { vaultStatus } from '../../core/vault.js';
7
+ import { templateSummaries } from '../../core/fields.js';
8
+ import { blocked } from '../../core/errors.js';
9
+ import { compileRoute, usageOf } from '../../runtime/spec.js';
10
+
11
+ /**
12
+ * 命令表在 runtime/registry.js,而分层规则说模块不能向上依赖 runtime。
13
+ * 用**函数体内的动态 import** 破环:静态 import 会成环,动态 import 不会
14
+ * (调用发生在启动完成之后,registry 早已求值完毕)。
15
+ */
16
+ async function commandTable() {
17
+ const { ALL_COMMANDS, commandEntry } = await import('../../runtime/cli.js');
18
+ return ALL_COMMANDS.map(commandEntry);
19
+ }
20
+
21
+ export async function bootstrapInfo() {
22
+ const store = await loadStore();
23
+ const p = storePathInUse();
24
+ const commands = await commandTable();
25
+ const bySection = {};
26
+ for (const e of store.entries) bySection[e.section] = (bySection[e.section] || 0) + 1;
27
+ return {
28
+ app: { name: APP_NAME, label: APP_LABEL, version: VERSION, node: process.version, platform: process.platform },
29
+ home: displayPath(appHome()),
30
+ storePath: displayPath(p),
31
+ storePathRaw: p,
32
+ envOverrides: {
33
+ [ENV_HOME]: process.env[ENV_HOME] || null,
34
+ [ENV_STORE]: process.env[ENV_STORE] || null,
35
+ [ENV_PASSPHRASE]: process.env[ENV_PASSPHRASE] ? '已设置(口令模式)' : null,
36
+ },
37
+ settings: store.settings,
38
+ // kv 必须带上:bootstrap 是 agent 的首选入口,它得能一眼看出「哪个栏目是 KV 表」
39
+ // (缺这字段时 agent 只能猜 `/api/sections` 再去查,等于多一次往返)。
40
+ sections: store.sections.map((s) => ({
41
+ id: s.id, title: s.title, description: s.description, order: s.order, template: s.template,
42
+ kv: s.kv === true, titleLabel: s.titleLabel, fields: s.fields.length, entries: bySection[s.id] || 0,
43
+ })),
44
+ counts: { sections: store.sections.length, entries: store.entries.length, commands: commands.length },
45
+ vault: await vaultStatus(),
46
+ templates: templateSummaries(),
47
+ snapshots: (await listSnapshots()).slice(0, 5),
48
+ corrupt: corruptNote(),
49
+ migration: migrationNote(),
50
+ commands,
51
+ };
52
+ }
53
+
54
+ export async function healthInfo() {
55
+ const home = appHome();
56
+ const checks = [];
57
+
58
+ let dirOk = true;
59
+ let dirError = '';
60
+ try {
61
+ await fsp.mkdir(home, { recursive: true });
62
+ await fsp.access(home, constants.W_OK);
63
+ } catch (e) {
64
+ dirOk = false;
65
+ dirError = String(e && e.message);
66
+ }
67
+ checks.push({ name: 'store-dir', path: displayPath(home), ok: dirOk, detail: dirError });
68
+
69
+ let storeOk = true;
70
+ let storeDetail = '';
71
+ let sections = 0;
72
+ let entries = 0;
73
+ try {
74
+ const s = await loadStore();
75
+ sections = s.sections.length;
76
+ entries = s.entries.length;
77
+ storeDetail = `${sections} 栏目 / ${entries} 条目`;
78
+ } catch (e) {
79
+ storeOk = false;
80
+ storeDetail = String(e && e.message);
81
+ }
82
+ checks.push({ name: 'store', path: displayPath(storeFile()), ok: storeOk, detail: storeDetail });
83
+
84
+ let vault = null;
85
+ try {
86
+ vault = await vaultStatus();
87
+ checks.push({ name: 'vault', path: vault.keyFile || vault.saltFile || '', ok: vault.ready, detail: vault.source });
88
+ } catch (e) {
89
+ checks.push({ name: 'vault', path: '', ok: false, detail: String(e && e.message) });
90
+ }
91
+
92
+ const failing = checks.filter((c) => !c.ok);
93
+ if (failing.length) {
94
+ throw blocked(
95
+ `健康检查未通过:${failing.map((c) => `${c.name}(${c.detail})`).join(';')}`,
96
+ { checks },
97
+ );
98
+ }
99
+ return { status: 'ok', app: APP_NAME, version: VERSION, cwd: process.cwd(), sections, entries, vault, checks };
100
+ }
101
+
102
+ export async function routesInfo({ module, http } = {}) {
103
+ const { ALL_COMMANDS, commandEntry } = await import('../../runtime/cli.js');
104
+ let entries = ALL_COMMANDS.map(commandEntry);
105
+ if (module) entries = entries.filter((e) => e.module === module || e.id === module);
106
+ if (http) {
107
+ // 反向查找:有端点,查该敲哪条命令。复用路由编译时的正则,所以带参路由也能匹配实例。
108
+ const raw = String(http).trim().replace(/\s+/g, ' ');
109
+ const sp = raw.indexOf(' ');
110
+ const method = (sp > 0 ? raw.slice(0, sp) : 'GET').toUpperCase();
111
+ const path = (sp > 0 ? raw.slice(sp + 1) : raw).split('?')[0];
112
+ entries = entries.filter((e) => {
113
+ if (!e.http) return false;
114
+ const route = compileRoute(e.http.split(' '));
115
+ return route.method === method && route.regex.test(path);
116
+ });
117
+ }
118
+ return {
119
+ count: entries.length,
120
+ total: (await commandTable()).length,
121
+ entries: entries.map((e) => ({ ...e, usage: e.usage })),
122
+ };
123
+ }
124
+
125
+ export function renderBootstrap(info) {
126
+ const lines = [
127
+ `${info.app.label} v${info.app.version}(node ${info.app.node} · ${info.app.platform})`,
128
+ `存储: ${info.storePath}`,
129
+ `栏目: ${info.counts.sections} 个 · 条目 ${info.counts.entries} 条 · 命令 ${info.counts.commands} 条`,
130
+ `密钥: ${info.vault.source === 'env' ? '口令模式(NX_SK_PASSPHRASE)' : `本机密钥文件 ${info.vault.keyFile}`}`,
131
+ ];
132
+ if (info.corrupt) lines.push(`注意: 上次读取时发现损坏文件,已挪到 ${info.corrupt.to}`);
133
+ lines.push('', '栏目一览:');
134
+ for (const s of info.sections) lines.push(` ${s.id.padEnd(12)} ${s.title} ${s.entries} 条 / ${s.fields} 字段`);
135
+ lines.push('', `提示: ${APP_NAME} help 看全部命令;加 --json 得机器可读输出。`);
136
+ return lines.join('\n');
137
+ }
138
+
139
+ export function renderHealth(h) {
140
+ const lines = [`${h.status === 'ok' ? '正常' : '异常'} · ${h.app} v${h.version}`, `cwd: ${h.cwd}`];
141
+ for (const c of h.checks) lines.push(` [${c.ok ? 'ok' : '!!'}] ${c.name.padEnd(12)} ${c.detail}`);
142
+ return lines.join('\n');
143
+ }
144
+
145
+ export function renderRoutes(r) {
146
+ const lines = [`共 ${r.count} / ${r.total} 条命令`];
147
+ for (const e of r.entries) {
148
+ lines.push(` ${e.command}${e.http ? ` <=> ${e.http}` : ''}`);
149
+ lines.push(` ${e.summary}`);
150
+ }
151
+ lines.push('', '用法串见: nx-sk help <主题>');
152
+ return lines.join('\n');
153
+ }
154
+
155
+ export { usageOf };
@@ -0,0 +1,101 @@
1
+ // HTTP 路由:由 action.http 编译而来,与 CLI **同源**。
2
+ // ctx 拼装顺序固定为「路径占位符 → query(GET/HEAD)或 body(其余)」,
3
+ // 之后交给**同一个** applySpec 做校验与强转——两端不会各写一套。
4
+ import { AppError, CODES, httpStatusOf, toErrorShape } from '../core/errors.js';
5
+ import { ACTIONS } from './registry.js';
6
+ import { applySpec, compareRoutes, compileRoute, usageOf } from './spec.js';
7
+
8
+ const MAX_BODY = 4 * 1024 * 1024;
9
+
10
+ export const ROUTES = ACTIONS
11
+ .filter((a) => Array.isArray(a.http))
12
+ .map((a) => ({ action: a, ...compileRoute(a.http) }))
13
+ // 字面量段优先:否则 GET /api/sections/templates 会被 GET /api/sections/:ref 遮蔽,
14
+ // 命中哪条取决于 actions 的声明顺序——有人调整顺序就静默错乱。
15
+ .sort(compareRoutes);
16
+
17
+ export function routeTable() {
18
+ return ROUTES.map((r) => ({ method: r.method, path: r.path, action: r.action.id, summary: r.action.summary }));
19
+ }
20
+
21
+ export function matchRoute(method, pathname) {
22
+ for (const r of ROUTES) {
23
+ if (r.method !== method) continue;
24
+ const m = r.regex.exec(pathname);
25
+ if (m) return { route: r, params: m.slice(1).map((x) => decodeURIComponent(x)) };
26
+ }
27
+ return null;
28
+ }
29
+
30
+ async function readBody(req) {
31
+ const chunks = [];
32
+ let size = 0;
33
+ for await (const c of req) {
34
+ size += c.length;
35
+ if (size > MAX_BODY) throw new AppError(CODES.INVALID_INPUT, '请求体过大');
36
+ chunks.push(c);
37
+ }
38
+ const raw = Buffer.concat(chunks).toString('utf8').trim();
39
+ if (!raw) return {};
40
+ try {
41
+ const parsed = JSON.parse(raw);
42
+ return parsed && typeof parsed === 'object' ? parsed : {};
43
+ } catch {
44
+ throw new AppError(CODES.INVALID_INPUT, '请求体不是合法 JSON');
45
+ }
46
+ }
47
+
48
+ /** 服务在 127.0.0.1,但用户浏览器里的任意页面都能向它发请求——写操作必须校验 Origin。 */
49
+ function originAllowed(req) {
50
+ const origin = req.headers.origin;
51
+ if (!origin) return true; // 非浏览器客户端(curl / agent / 测试)不带 Origin,放行
52
+ try {
53
+ return ['127.0.0.1', 'localhost', '::1'].includes(new URL(origin).hostname);
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ function send(res, status, payload) {
60
+ const body = JSON.stringify(payload);
61
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body) });
62
+ res.end(body);
63
+ }
64
+
65
+ export async function handleApi(req, res, url) {
66
+ const method = String(req.method || 'GET').toUpperCase();
67
+ const write = method !== 'GET' && method !== 'HEAD';
68
+
69
+ if (write && !originAllowed(req)) {
70
+ return send(res, 403, { ok: false, error: '跨站写操作被拒绝(Origin 不在本机)', code: CODES.BLOCKED });
71
+ }
72
+
73
+ const hit = matchRoute(method, url.pathname);
74
+ if (!hit) {
75
+ return send(res, 404, { ok: false, error: `未知接口: ${method} ${url.pathname}`, code: CODES.NOT_FOUND });
76
+ }
77
+
78
+ const { route, params } = hit;
79
+ const raw = {};
80
+ route.keys.forEach((k, i) => { raw[k] = params[i]; }); // 路径占位符
81
+ if (!write) {
82
+ for (const [k, v] of url.searchParams) raw[k] = v; // query
83
+ } else {
84
+ // ⚠️ 无条件合并 body,而不是「只有 body 里有才覆盖」——否则无 body 的 DELETE
85
+ // 永远绑不上参数(args 与路由占位符靠「同名」绑定,名字对不上就静默传 undefined)
86
+ Object.assign(raw, await readBody(req).catch((err) => { throw err; }));
87
+ }
88
+
89
+ try {
90
+ const ctx = applySpec(route.action, raw);
91
+ const data = await route.action.run(ctx, { transport: 'http' });
92
+ return send(res, 200, { ok: true, data: data === undefined ? null : data });
93
+ } catch (err) {
94
+ const e = toErrorShape(err);
95
+ // 与 CLI 同一个约定:参数类错误必须带用法串(两端错误码与文本锚点一致)
96
+ if (e.code === CODES.INVALID_INPUT && !e.message.includes('用法:')) {
97
+ e.message += `\n用法: ${usageOf(route.action)}`;
98
+ }
99
+ return send(res, httpStatusOf(e.code), { ok: false, error: e.message, code: e.code });
100
+ }
101
+ }