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.
- package/CHANGELOG.md +133 -0
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/assets/nx-sk/SKILL.md +102 -0
- package/assets/nx-sk/references/00-design.md +116 -0
- package/assets/nx-sk/references/10-sections-entries.md +165 -0
- package/assets/nx-sk/references/20-secrets.md +170 -0
- package/assets/nx-sk/references/30-export-backup.md +80 -0
- package/assets/nx-sk/references/40-extend.md +75 -0
- package/bin/nx-sk.mjs +11 -0
- package/package.json +56 -0
- package/src/core/crypto.js +73 -0
- package/src/core/errors.js +52 -0
- package/src/core/fields.js +345 -0
- package/src/core/fsx.js +103 -0
- package/src/core/ids.js +19 -0
- package/src/core/open.js +32 -0
- package/src/core/paths.js +141 -0
- package/src/core/render.js +144 -0
- package/src/core/store.js +317 -0
- package/src/core/vault.js +109 -0
- package/src/index.js +14 -0
- package/src/modules/entries/index.js +152 -0
- package/src/modules/entries/kvTable.jsx +158 -0
- package/src/modules/entries/service.js +539 -0
- package/src/modules/entries/view.jsx +240 -0
- package/src/modules/export/index.js +39 -0
- package/src/modules/export/service.js +192 -0
- package/src/modules/export/view.jsx +155 -0
- package/src/modules/sections/index.js +104 -0
- package/src/modules/sections/service.js +337 -0
- package/src/modules/sections/view.jsx +311 -0
- package/src/modules/settings/index.js +34 -0
- package/src/modules/settings/service.js +78 -0
- package/src/modules/settings/view.jsx +193 -0
- package/src/modules/skill/index.js +51 -0
- package/src/modules/skill/service.js +200 -0
- package/src/modules/system/index.js +42 -0
- package/src/modules/system/service.js +155 -0
- package/src/runtime/api.js +101 -0
- package/src/runtime/cli.js +266 -0
- package/src/runtime/registry.js +65 -0
- package/src/runtime/server.js +80 -0
- package/src/runtime/spec.js +158 -0
- package/src/web/frontend/App.jsx +108 -0
- package/src/web/frontend/api/client.js +26 -0
- package/src/web/frontend/components/fieldEditor.jsx +85 -0
- package/src/web/frontend/components/ui.jsx +143 -0
- package/src/web/frontend/index.html +12 -0
- package/src/web/frontend/main.jsx +13 -0
- package/src/web/frontend/registry.js +20 -0
- package/src/web/frontend/store.jsx +87 -0
- package/src/web/frontend/styles.css +301 -0
- package/src/web/public/assets/index-BWGLG7Qa.js +40 -0
- package/src/web/public/assets/index-CwF5hM2v.css +1 -0
- package/src/web/public/assets/view-B6gPh1NQ.js +1 -0
- package/src/web/public/assets/view-CfXXYwW2.js +1 -0
- package/src/web/public/assets/view-CxzKrdA3.js +7 -0
- package/src/web/public/assets/view-DjO1C54o.js +7 -0
- package/src/web/public/index.html +13 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// 通用 CLI 运行器:解析 → 匹配 → 校验 → 渲染 → help。
|
|
2
|
+
// 命令表由 action 声明派生,所以「Web 上能做的 CLI 都能做」是结构保证,不靠人记。
|
|
3
|
+
import { resolve as resolvePath } from 'node:path';
|
|
4
|
+
import { APP_NAME, DEFAULT_PORT, ENV_STORE, VERSION } from '../core/paths.js';
|
|
5
|
+
import { badInput, external, toErrorShape } from '../core/errors.js';
|
|
6
|
+
import { ACTIONS, MODULES, MODULE_TITLES, commandEntry } from './registry.js';
|
|
7
|
+
import { applySpec, argSpecsOf, cliPathsOf, flagSpecsOf, usageOf } from './spec.js';
|
|
8
|
+
|
|
9
|
+
// `--json` 需要被 serve 这类长驻命令看到,所以用一个模块级开关而不是往 meta 里塞字段
|
|
10
|
+
// (meta 的契约只有 { transport },别把两端形态差异以外的东西塞进去)。
|
|
11
|
+
let OUTPUT_JSON = false;
|
|
12
|
+
|
|
13
|
+
async function cmdServe(ctx) {
|
|
14
|
+
const { startServer } = await import('./server.js');
|
|
15
|
+
const { openBrowser } = await import('../core/open.js');
|
|
16
|
+
const { loadStore, storePathInUse } = await import('../core/store.js');
|
|
17
|
+
const { appHome, displayPath } = await import('../core/paths.js');
|
|
18
|
+
|
|
19
|
+
await loadStore(); // 触发首次播种:面板一打开就该有「求职」栏目
|
|
20
|
+
let server;
|
|
21
|
+
try {
|
|
22
|
+
server = await startServer({ port: ctx.port });
|
|
23
|
+
} catch (e) {
|
|
24
|
+
if (e && e.code === 'EADDRINUSE') throw external(`端口 ${ctx.port} 已被占用;换一个:nx-sk serve --port ${ctx.port + 1}(或在 nxSk.port 里改默认端口)`);
|
|
25
|
+
throw external(`启动服务失败: ${e && e.message ? e.message : String(e)}`);
|
|
26
|
+
}
|
|
27
|
+
// --port 0 = 让 OS 挑一个空闲端口(测试用),所以真实端口要回读,不能信入参
|
|
28
|
+
const realPort = server.address() && typeof server.address() === 'object' ? server.address().port : ctx.port;
|
|
29
|
+
const url = `http://127.0.0.1:${realPort}`;
|
|
30
|
+
if (OUTPUT_JSON) {
|
|
31
|
+
process.stdout.write(JSON.stringify({
|
|
32
|
+
url, port: realPort, host: '127.0.0.1', pid: process.pid,
|
|
33
|
+
storePath: displayPath(storePathInUse()), storePathRaw: storePathInUse(), home: displayPath(appHome()),
|
|
34
|
+
}) + '\n');
|
|
35
|
+
} else {
|
|
36
|
+
process.stdout.write([
|
|
37
|
+
`面板: ${url}`,
|
|
38
|
+
`存储: ${displayPath(storePathInUse())}`,
|
|
39
|
+
'退出: Ctrl-C',
|
|
40
|
+
'机器可读: nx-sk serve --json(先打一行 JSON,再继续常驻)',
|
|
41
|
+
].join('\n') + '\n');
|
|
42
|
+
}
|
|
43
|
+
if (!ctx['no-open']) await openBrowser(url);
|
|
44
|
+
|
|
45
|
+
await new Promise((done) => {
|
|
46
|
+
let closed = false;
|
|
47
|
+
const stop = () => {
|
|
48
|
+
if (closed) return;
|
|
49
|
+
closed = true;
|
|
50
|
+
server.close(() => done());
|
|
51
|
+
setTimeout(done, 800).unref?.();
|
|
52
|
+
};
|
|
53
|
+
process.once('SIGINT', stop);
|
|
54
|
+
process.once('SIGTERM', stop);
|
|
55
|
+
});
|
|
56
|
+
return { status: 'ok', stopped: true, url };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const BUILTINS = [
|
|
60
|
+
{
|
|
61
|
+
id: 'serve',
|
|
62
|
+
module: 'system',
|
|
63
|
+
cli: ['serve'],
|
|
64
|
+
http: null,
|
|
65
|
+
summary: '启动 Web 面板(唯一常驻命令;--no-open 给开发与 CI 用)',
|
|
66
|
+
flags: { port: { type: 'number', default: DEFAULT_PORT }, 'no-open': { type: 'boolean' } },
|
|
67
|
+
run: (ctx) => cmdServe(ctx),
|
|
68
|
+
render: () => '',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'help',
|
|
72
|
+
module: 'system',
|
|
73
|
+
cli: ['help'],
|
|
74
|
+
http: null,
|
|
75
|
+
summary: '命令表;可带主题(模块 id / 命令组 / action id)',
|
|
76
|
+
args: [{ name: 'topic', required: false }],
|
|
77
|
+
run: (ctx) => helpEntries(ctx.topic),
|
|
78
|
+
render: (d, ctx) => renderHelp(d, ctx.topic),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'version',
|
|
82
|
+
module: 'system',
|
|
83
|
+
cli: ['version'],
|
|
84
|
+
http: null,
|
|
85
|
+
summary: '输出版本号(裸输出,便于 V=$(nx-sk version))',
|
|
86
|
+
run: () => VERSION,
|
|
87
|
+
render: (v) => v,
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
export const ALL_COMMANDS = [...BUILTINS, ...ACTIONS];
|
|
92
|
+
|
|
93
|
+
// 转出去给 system 模块用:它必须在**函数体内**动态 import 这里(静态 import registry 会成环)。
|
|
94
|
+
// 不转这一行,system 就得去静态 import registry —— 那正是分层规则要禁止的环。
|
|
95
|
+
export { commandEntry };
|
|
96
|
+
|
|
97
|
+
export function helpEntries(topic) {
|
|
98
|
+
const all = ALL_COMMANDS.map(commandEntry);
|
|
99
|
+
if (!topic) return all;
|
|
100
|
+
const t = String(topic);
|
|
101
|
+
if (MODULES.some((m) => m.id === t)) return all.filter((e) => e.module === t);
|
|
102
|
+
const byRoot = all.filter((e) => e.command.split(' ')[1] === t);
|
|
103
|
+
if (byRoot.length) return byRoot;
|
|
104
|
+
const byId = all.filter((e) => e.id === t);
|
|
105
|
+
if (byId.length) return byId;
|
|
106
|
+
throw badInput(`未知帮助主题: ${t}(可用模块: ${MODULES.map((m) => m.id).join(' / ')};或命令组如 section / entry)`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function renderHelp(entries, topic) {
|
|
110
|
+
const lines = [`${APP_NAME} v${VERSION} · 本机个人资源管理器`, ''];
|
|
111
|
+
if (topic) lines.push(`主题: ${topic}`, '');
|
|
112
|
+
lines.push('用法: nx-sk <命令组> <子命令> [参数] [--flags] 任何命令都支持 --json', '');
|
|
113
|
+
const groups = new Map();
|
|
114
|
+
for (const e of entries) {
|
|
115
|
+
if (!groups.has(e.module)) groups.set(e.module, []);
|
|
116
|
+
groups.get(e.module).push(e);
|
|
117
|
+
}
|
|
118
|
+
for (const [mod, list] of groups) {
|
|
119
|
+
lines.push(`【${MODULE_TITLES[mod] || mod}】`);
|
|
120
|
+
for (const e of list) {
|
|
121
|
+
lines.push(` ${e.command}`);
|
|
122
|
+
lines.push(` ${e.summary}`);
|
|
123
|
+
if (e.usage !== e.command) lines.push(` 用法: ${e.usage}`);
|
|
124
|
+
if (e.http) lines.push(` 接口: ${e.http}`);
|
|
125
|
+
}
|
|
126
|
+
lines.push('');
|
|
127
|
+
}
|
|
128
|
+
lines.push('常用姿势:');
|
|
129
|
+
lines.push(' nx-sk bootstrap --json 一次拿齐上下文(agent 首选入口)');
|
|
130
|
+
lines.push(' nx-sk section dump job --json 一个栏目的全部信息(字段 + 全部条目 + 未填清单)');
|
|
131
|
+
lines.push(' nx-sk entry fields --section job 字段字典:填之前先看,别猜字段名');
|
|
132
|
+
lines.push(' nx-sk entry update 赵刘学 --set 微信=zhaoliuxue 改一个字段(PATCH 语义)');
|
|
133
|
+
lines.push(' nx-sk export run --format both 整体导出到 ~/nx-sk/export');
|
|
134
|
+
lines.push(' nx-sk serve 打开 Web 面板');
|
|
135
|
+
return lines.join('\n');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 最长前缀匹配:命令路径里可以含 flag(那是一条命令路径,不是「带 flag 的命令」)。
|
|
140
|
+
* 导出是为了让一致性测试能「只走匹配、零副作用」地验证每条 action 都能被解析回自己。
|
|
141
|
+
*/
|
|
142
|
+
export function resolveCommand(tokens, commands = ALL_COMMANDS) {
|
|
143
|
+
let best = null;
|
|
144
|
+
let bestLen = -1;
|
|
145
|
+
for (const action of commands) {
|
|
146
|
+
for (const path of cliPathsOf(action)) {
|
|
147
|
+
if (path.length <= bestLen || path.length > tokens.length) continue;
|
|
148
|
+
if (path.every((seg, i) => tokens[i] === seg)) { best = action; bestLen = path.length; }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!best) return { action: null, rest: [], tried: tokens.slice(0, 2) };
|
|
152
|
+
return { action: best, rest: tokens.slice(bestLen), tried: tokens.slice(0, bestLen) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function pushFlag(raw, name, value, spec) {
|
|
156
|
+
if (spec.type === 'array' || spec.type === 'kv') {
|
|
157
|
+
raw[name] = raw[name] === undefined ? [value] : [].concat(raw[name], value);
|
|
158
|
+
} else {
|
|
159
|
+
raw[name] = value;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseArgs(action, tokens) {
|
|
164
|
+
const flagSpecs = new Map(flagSpecsOf(action).map((s) => [s.name, s]));
|
|
165
|
+
const argSpecs = argSpecsOf(action);
|
|
166
|
+
const flags = {};
|
|
167
|
+
const positional = [];
|
|
168
|
+
|
|
169
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
170
|
+
const t = tokens[i];
|
|
171
|
+
if (t === '--') { positional.push(...tokens.slice(i + 1)); break; }
|
|
172
|
+
if (t.startsWith('--')) {
|
|
173
|
+
const eq = t.indexOf('=');
|
|
174
|
+
const name = eq >= 0 ? t.slice(2, eq) : t.slice(2);
|
|
175
|
+
const spec = flagSpecs.get(name);
|
|
176
|
+
if (!spec) throw badInput(`用法: ${usageOf(action)} —— 未知参数 --${name}`);
|
|
177
|
+
if (eq >= 0) { pushFlag(flags, name, t.slice(eq + 1), spec); continue; }
|
|
178
|
+
if (spec.type === 'boolean') { pushFlag(flags, name, true, spec); continue; }
|
|
179
|
+
const next = tokens[i + 1];
|
|
180
|
+
if (next === undefined || next.startsWith('--')) {
|
|
181
|
+
// 注意:`--depth` 无值时报错,不要静默转换——Number(true) 会变成「深度 1」这种另一个合法值
|
|
182
|
+
throw badInput(`用法: ${usageOf(action)} —— 参数 --${name} 缺少取值`);
|
|
183
|
+
}
|
|
184
|
+
i++;
|
|
185
|
+
pushFlag(flags, name, next, spec);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (t.startsWith('-') && t.length > 1) {
|
|
189
|
+
throw badInput(`用法: ${usageOf(action)} —— 未知参数 ${t}(本 CLI 只使用 --长参数)`);
|
|
190
|
+
}
|
|
191
|
+
positional.push(t);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (positional.length > argSpecs.length) {
|
|
195
|
+
throw badInput(`用法: ${usageOf(action)} —— 多余的位置参数: ${positional.slice(argSpecs.length).join(' ')}`);
|
|
196
|
+
}
|
|
197
|
+
const raw = {};
|
|
198
|
+
for (let i = 0; i < positional.length; i++) raw[argSpecs[i].name] = positional[i];
|
|
199
|
+
// 显式 flag 覆盖位置参数:`key set K --value=<值>` 与 `key set K <值>` 必须等价,
|
|
200
|
+
// 而后者在值以 `-` 开头时会被当成 flag(那时只能走前者)。
|
|
201
|
+
return { ...raw, ...flags };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const STATUS_NOTE = {
|
|
205
|
+
skipped: '(未做改动——目标已是期望状态,或用 --dry-run 试运行)',
|
|
206
|
+
conflict: '(需要你决策后才能继续,不要自动重试)',
|
|
207
|
+
blocked: '(被业务规则挡住:先去满足前置条件)',
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
async function emit(action, data, ctx) {
|
|
211
|
+
if (OUTPUT_JSON) {
|
|
212
|
+
process.stdout.write(JSON.stringify(data === undefined ? null : data) + '\n');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
let text;
|
|
216
|
+
if (typeof action.render === 'function') text = await action.render(data, ctx);
|
|
217
|
+
else text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
|
218
|
+
if (text === undefined || text === null || text === '') return;
|
|
219
|
+
const status = data && typeof data === 'object' ? data.status : undefined;
|
|
220
|
+
if (status && status !== 'ok') text = `${text}\n状态: ${status} ${STATUS_NOTE[status] || ''}`.trimEnd();
|
|
221
|
+
process.stdout.write(`${text}\n`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function runCli(argv) {
|
|
225
|
+
const opts = { json: false, store: null };
|
|
226
|
+
const tokens = [];
|
|
227
|
+
for (let i = 0; i < argv.length; i++) {
|
|
228
|
+
const t = argv[i];
|
|
229
|
+
if (t === '--json') { opts.json = true; continue; }
|
|
230
|
+
if (t === '--store') {
|
|
231
|
+
const v = argv[++i];
|
|
232
|
+
if (v === undefined) { process.stderr.write('nx-sk: --store 缺少取值\n'); process.exitCode = 1; return; }
|
|
233
|
+
opts.store = v;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (t.startsWith('--store=')) { opts.store = t.slice('--store='.length); continue; }
|
|
237
|
+
tokens.push(t);
|
|
238
|
+
}
|
|
239
|
+
// 全局 flag 先摘掉再匹配命令;--store 通过环境变量下传,服务层就不必到处接一个路径参数
|
|
240
|
+
if (opts.store) process.env[ENV_STORE] = resolvePath(opts.store);
|
|
241
|
+
OUTPUT_JSON = opts.json;
|
|
242
|
+
|
|
243
|
+
let lastAction = null;
|
|
244
|
+
try {
|
|
245
|
+
if (!tokens.length) {
|
|
246
|
+
await emit({ render: (d, c) => renderHelp(d, c) }, helpEntries(), {});
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const { action, rest, tried } = resolveCommand(tokens);
|
|
250
|
+
if (!action) throw badInput(`未知命令: ${tried.join(' ')} —— 用 nx-sk help 看全部命令`);
|
|
251
|
+
lastAction = action;
|
|
252
|
+
const ctx = applySpec(action, parseArgs(action, rest));
|
|
253
|
+
const data = await action.run(ctx, { transport: 'cli' });
|
|
254
|
+
await emit(action, data, ctx);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
const e = toErrorShape(err);
|
|
257
|
+
// 参数/用法类错误必须带完整用法串——agent 靠「用法:」这个锚点判断「该改参数而不是重试」。
|
|
258
|
+
// 放在运行器里统一补,比要求每个 service 自己记得写更可靠。
|
|
259
|
+
if (e.code === 'INVALID_INPUT' && !e.message.includes('用法:') && lastAction) {
|
|
260
|
+
e.message += `\n用法: ${usageOf(lastAction)}`;
|
|
261
|
+
}
|
|
262
|
+
if (opts.json) process.stdout.write(JSON.stringify({ ok: false, error: e.message, code: e.code }) + '\n');
|
|
263
|
+
else process.stderr.write(`${APP_NAME}: ${e.message}\n`);
|
|
264
|
+
process.exitCode = 1;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// 模块注册表 + **装载期自检**:重复的 action id / CLI 路径 / HTTP 路由在启动瞬间就炸,
|
|
2
|
+
// 而不是等某个用户敲到那条命令才发现。
|
|
3
|
+
import { APP_NAME } from '../core/paths.js';
|
|
4
|
+
import { cliPathsOf, usageOf } from './spec.js';
|
|
5
|
+
|
|
6
|
+
import sections from '../modules/sections/index.js';
|
|
7
|
+
import entries from '../modules/entries/index.js';
|
|
8
|
+
import exportModule from '../modules/export/index.js';
|
|
9
|
+
import skill from '../modules/skill/index.js';
|
|
10
|
+
import settings from '../modules/settings/index.js';
|
|
11
|
+
import system from '../modules/system/index.js';
|
|
12
|
+
|
|
13
|
+
// 顺序 = 面板 tab 顺序与 help 分组顺序(system 放最后,它是聚合模块)
|
|
14
|
+
export const MODULES = [sections, entries, exportModule, skill, settings, system]
|
|
15
|
+
.slice()
|
|
16
|
+
.sort((a, b) => (a.order || 50) - (b.order || 50));
|
|
17
|
+
|
|
18
|
+
export const MODULE_TITLES = Object.fromEntries(MODULES.map((m) => [m.id, m.title]));
|
|
19
|
+
|
|
20
|
+
export const ACTIONS = MODULES.flatMap((m) => (m.actions || []).map((a) => ({ ...a, module: m.id })));
|
|
21
|
+
|
|
22
|
+
/** 命令表的一行——`help` / `routes` / bootstrap 共用同一份,不存在「第二张命令表」。 */
|
|
23
|
+
export function commandEntry(a) {
|
|
24
|
+
const paths = cliPathsOf(a);
|
|
25
|
+
return {
|
|
26
|
+
id: a.id,
|
|
27
|
+
module: a.module,
|
|
28
|
+
command: `${APP_NAME} ${paths[0].join(' ')}`,
|
|
29
|
+
usage: usageOf(a),
|
|
30
|
+
summary: a.summary || '',
|
|
31
|
+
http: Array.isArray(a.http) ? a.http.join(' ') : null,
|
|
32
|
+
aliases: paths.slice(1).map((p) => `${APP_NAME} ${p.join(' ')}`),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 装载期自检。导出成纯函数,好让测试能用一份**故意写坏的清单**验证它真的会红。 */
|
|
37
|
+
export function selfCheckActions(actions) {
|
|
38
|
+
const ids = new Map();
|
|
39
|
+
const cliKeys = new Map();
|
|
40
|
+
const httpKeys = new Map();
|
|
41
|
+
for (const a of actions) {
|
|
42
|
+
if (!a.id) throw new Error(`action 缺少 id(模块 ${a.module})`);
|
|
43
|
+
if (ids.has(a.id)) throw new Error(`action id 重复: ${a.id}`);
|
|
44
|
+
ids.set(a.id, a);
|
|
45
|
+
|
|
46
|
+
const paths = cliPathsOf(a);
|
|
47
|
+
if (!paths.length) throw new Error(`action 没有 CLI 命令: ${a.id}`);
|
|
48
|
+
for (const p of paths) {
|
|
49
|
+
const k = p.join(' ');
|
|
50
|
+
if (cliKeys.has(k)) throw new Error(`CLI 路径重复: ${k}(${a.id} 与 ${cliKeys.get(k)})`);
|
|
51
|
+
cliKeys.set(k, a.id);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (a.http !== null && a.http !== undefined) {
|
|
55
|
+
if (!Array.isArray(a.http) || a.http.length !== 2) throw new Error(`http 既不是数组也不是 null: ${a.id}`);
|
|
56
|
+
const k = `${a.http[0]} ${a.http[1]}`;
|
|
57
|
+
if (httpKeys.has(k)) throw new Error(`HTTP 路由重复: ${k}(${a.id} 与 ${httpKeys.get(k)})`);
|
|
58
|
+
httpKeys.set(k, a.id);
|
|
59
|
+
}
|
|
60
|
+
if (typeof a.run !== 'function') throw new Error(`action 缺少 run: ${a.id}`);
|
|
61
|
+
}
|
|
62
|
+
return { ids, cliKeys, httpKeys };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const SELF_CHECK = selfCheckActions(ACTIONS);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// 零依赖静态 + /api 委派。对构建工具**零感知**——它只服务 src/web/public/,
|
|
2
|
+
// 不管是 dev 还是 prod,所以「dev 能跑、打包后白屏」这类问题不会出现。
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { extname, join, resolve, sep } from 'node:path';
|
|
6
|
+
import { DEFAULT_PORT, DEV_VITE_PORT, PUBLIC_DIR } from '../core/paths.js';
|
|
7
|
+
import { handleApi } from './api.js';
|
|
8
|
+
|
|
9
|
+
const MIME = {
|
|
10
|
+
'.html': 'text/html; charset=utf-8',
|
|
11
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
12
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
13
|
+
'.css': 'text/css; charset=utf-8',
|
|
14
|
+
'.json': 'application/json; charset=utf-8',
|
|
15
|
+
'.svg': 'image/svg+xml',
|
|
16
|
+
'.png': 'image/png',
|
|
17
|
+
'.jpg': 'image/jpeg',
|
|
18
|
+
'.jpeg': 'image/jpeg',
|
|
19
|
+
'.webp': 'image/webp',
|
|
20
|
+
'.ico': 'image/x-icon',
|
|
21
|
+
'.woff2': 'font/woff2',
|
|
22
|
+
'.map': 'application/json; charset=utf-8',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function sendText(res, status, text) {
|
|
26
|
+
res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' });
|
|
27
|
+
res.end(text);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function sendFile(res, file) {
|
|
31
|
+
try {
|
|
32
|
+
const buf = await readFile(file);
|
|
33
|
+
res.writeHead(200, { 'content-type': MIME[extname(file).toLowerCase()] || 'application/octet-stream' });
|
|
34
|
+
res.end(buf);
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const NOT_BUILT = [
|
|
42
|
+
'面板还没有构建产物。',
|
|
43
|
+
'',
|
|
44
|
+
`开发模式: pnpm run dev (vite :${DEV_VITE_PORT} + serve :${DEFAULT_PORT})`,
|
|
45
|
+
'生产模式: pnpm start (先 vite build,再 serve)',
|
|
46
|
+
].join('\n');
|
|
47
|
+
|
|
48
|
+
async function serveStatic(pathname, res) {
|
|
49
|
+
const rel = decodeURIComponent(pathname).replace(/^\/+/, '');
|
|
50
|
+
// 静态服务做 resolve 后前缀校验,别用正则剥 '..'
|
|
51
|
+
let file = resolve(PUBLIC_DIR, rel);
|
|
52
|
+
if (file !== resolve(PUBLIC_DIR) && !file.startsWith(resolve(PUBLIC_DIR) + sep)) {
|
|
53
|
+
return sendText(res, 403, '禁止访问');
|
|
54
|
+
}
|
|
55
|
+
if (!rel) file = join(PUBLIC_DIR, 'index.html');
|
|
56
|
+
if (await sendFile(res, file)) return;
|
|
57
|
+
// SPA 回退:非 /api 的未命中路径交给前端 hash 路由
|
|
58
|
+
if (await sendFile(res, join(PUBLIC_DIR, 'index.html'))) return;
|
|
59
|
+
return sendText(res, 503, NOT_BUILT);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function startServer({ port = DEFAULT_PORT, host = '127.0.0.1' } = {}) {
|
|
63
|
+
const server = http.createServer((req, res) => {
|
|
64
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
65
|
+
if (url.pathname === '/api' || url.pathname.startsWith('/api/')) {
|
|
66
|
+
handleApi(req, res, url).catch((err) => {
|
|
67
|
+
sendText(res, 500, `内部错误: ${err && err.message ? err.message : String(err)}`);
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
serveStatic(url.pathname, res).catch((err) => {
|
|
72
|
+
sendText(res, 500, `内部错误: ${err && err.message ? err.message : String(err)}`);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
return new Promise((ok, no) => {
|
|
76
|
+
server.once('error', no);
|
|
77
|
+
// 默认只绑 127.0.0.1:这是本机工具,不要暴露到局域网。
|
|
78
|
+
server.listen(port, host, () => ok(server));
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// action 规格:校验 / 强转 / 路由编译 / 用法串。
|
|
2
|
+
//
|
|
3
|
+
// 为什么 flag 要随 action 声明、而不是用全局白名单:
|
|
4
|
+
// `entry update --mask x` 里 `--mask` 是布尔型(不取值),全局白名单猜不出来;
|
|
5
|
+
// 只有 action 自己知道。同理 `type:'number'` 让两端都得到数字,不必各写一遍 parseInt。
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { APP_NAME } from '../core/paths.js';
|
|
8
|
+
import { badInput } from '../core/errors.js';
|
|
9
|
+
import { BOOL_FALSE, BOOL_TRUE } from '../core/fields.js';
|
|
10
|
+
|
|
11
|
+
export function cliPathsOf(action) {
|
|
12
|
+
const c = action.cli;
|
|
13
|
+
// `cli: []` 也要算「没有 CLI 命令」——否则会得到一个空路径,装载期自检形同虚设
|
|
14
|
+
if (!c || !c.length) return [];
|
|
15
|
+
return Array.isArray(c[0]) ? c : [c];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function argSpecsOf(action) {
|
|
19
|
+
return (action.args || []).map((a) => (typeof a === 'string'
|
|
20
|
+
? { name: a, required: true }
|
|
21
|
+
: { name: a.name, required: a.required !== false }));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function flagSpecsOf(action) {
|
|
25
|
+
return Object.entries(action.flags || {}).map(([name, spec]) => ({ name, type: 'string', required: false, ...spec }));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 用法串:`nx-sk entry update <id> [--section <section>] [--mask]` */
|
|
29
|
+
export function usageOf(action) {
|
|
30
|
+
const paths = cliPathsOf(action);
|
|
31
|
+
const parts = [APP_NAME, ...(paths[0] || [action.id])];
|
|
32
|
+
for (const a of argSpecsOf(action)) parts.push(a.required ? `<${a.name}>` : `[${a.name}]`);
|
|
33
|
+
for (const f of flagSpecsOf(action)) {
|
|
34
|
+
if (f.type === 'boolean') { parts.push(f.required ? `--${f.name}` : `[--${f.name}]`); continue; }
|
|
35
|
+
// 值的占位符优先用 enum 展开,其次显式 hint,最后才退回 flag 名——
|
|
36
|
+
// `--side <side>` 这种读起来没有信息量
|
|
37
|
+
const hint = f.enum ? f.enum.join('|') : f.hint || f.name;
|
|
38
|
+
const token = `--${f.name} <${hint}>`;
|
|
39
|
+
parts.push(f.required ? token : `[${token}]`);
|
|
40
|
+
}
|
|
41
|
+
return parts.join(' ');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function coerce(spec, value) {
|
|
45
|
+
const type = spec.type || 'string';
|
|
46
|
+
if (type === 'string') return String(value);
|
|
47
|
+
if (type === 'number') {
|
|
48
|
+
const n = typeof value === 'number' ? value : Number(String(value).trim());
|
|
49
|
+
if (!Number.isFinite(n)) throw badInput(`--${spec.name} 需要数字,收到 ${JSON.stringify(value)}`);
|
|
50
|
+
return n;
|
|
51
|
+
}
|
|
52
|
+
if (type === 'boolean') {
|
|
53
|
+
if (typeof value === 'boolean') return value;
|
|
54
|
+
if (typeof value === 'number') return value !== 0;
|
|
55
|
+
const s = String(value).trim().toLowerCase();
|
|
56
|
+
if (BOOL_TRUE.includes(s)) return true;
|
|
57
|
+
if (BOOL_FALSE.includes(s) || s === '') return false;
|
|
58
|
+
throw badInput(`--${spec.name} 需要是/否,收到 ${JSON.stringify(value)}`);
|
|
59
|
+
}
|
|
60
|
+
if (type === 'array') {
|
|
61
|
+
const list = Array.isArray(value) ? value : String(value).split(',');
|
|
62
|
+
return list.map((x) => String(x).trim()).filter((x) => x !== '');
|
|
63
|
+
}
|
|
64
|
+
if (type === 'json') {
|
|
65
|
+
if (typeof value === 'object' && value !== null) return value;
|
|
66
|
+
const s = String(value).trim();
|
|
67
|
+
if (s.startsWith('@')) {
|
|
68
|
+
// 命令行塞不下大 JSON 时用 `--data @file.json`——agent 的常用姿势
|
|
69
|
+
return readFileTextSync(s.slice(1), spec.name);
|
|
70
|
+
}
|
|
71
|
+
try { return JSON.parse(s); } catch (e) { throw badInput(`--${spec.name} 不是合法 JSON: ${e.message}`); }
|
|
72
|
+
}
|
|
73
|
+
if (type === 'kv') {
|
|
74
|
+
// CLI: `--set name=值`(可重复);HTTP: `{set: {name: '值'}}` 或 `{set: ['name=值']}`
|
|
75
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) return { ...value };
|
|
76
|
+
const list = Array.isArray(value) ? value : [value];
|
|
77
|
+
const out = {};
|
|
78
|
+
for (const item of list) {
|
|
79
|
+
const s = String(item);
|
|
80
|
+
const eq = s.indexOf('=');
|
|
81
|
+
if (eq <= 0) throw badInput(`--${spec.name} 需要 key=value 形式,收到 ${JSON.stringify(item)}`);
|
|
82
|
+
out[s.slice(0, eq).trim()] = s.slice(eq + 1);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 同步读文件(`@file` 展开)。用 readFileSync 是因为 applySpec 在两端都是同步契约的一部分。
|
|
90
|
+
function readFileTextSync(p, flagName) {
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
93
|
+
} catch (e) {
|
|
94
|
+
throw badInput(`--${flagName} 读取文件失败: ${p}(${e.message})`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 把「两端拼出来的扁平对象」变成 ctx:只保留声明的键,做 required 校验与强转。
|
|
100
|
+
* 严格过滤是有意的——HTTP body 里的多余字段不该悄悄流进 service。
|
|
101
|
+
*/
|
|
102
|
+
export function applySpec(action, raw = {}) {
|
|
103
|
+
const usage = usageOf(action);
|
|
104
|
+
const ctx = {};
|
|
105
|
+
for (const a of argSpecsOf(action)) {
|
|
106
|
+
const v = raw[a.name];
|
|
107
|
+
if (v === undefined || v === null || v === '') {
|
|
108
|
+
if (a.required) throw badInput(`用法: ${usage} —— 缺少参数 <${a.name}>`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
ctx[a.name] = String(v);
|
|
112
|
+
}
|
|
113
|
+
for (const s of flagSpecsOf(action)) {
|
|
114
|
+
const v = raw[s.name];
|
|
115
|
+
if (v === undefined || v === null || v === '') {
|
|
116
|
+
// ⚠️ 读命令的过滤 flag **不要声明 default**——参数层无条件注入默认值会让
|
|
117
|
+
// 「不传 = 全部」的分支永远走不到(不报错、不崩,只是永远返回半个结果)
|
|
118
|
+
if (s.default !== undefined) { ctx[s.name] = s.default; continue; }
|
|
119
|
+
if (s.required) throw badInput(`用法: ${usage} —— 缺少参数 --${s.name}`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const coerced = coerce(s, v);
|
|
123
|
+
if (s.enum && !s.enum.includes(coerced)) {
|
|
124
|
+
throw badInput(`--${s.name} 只能是 ${s.enum.join(' / ')},收到 ${JSON.stringify(coerced)}`);
|
|
125
|
+
}
|
|
126
|
+
ctx[s.name] = coerced;
|
|
127
|
+
}
|
|
128
|
+
return ctx;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function compileRoute(http) { const [method, path] = http;
|
|
132
|
+
const segs = String(path).split('/').filter(Boolean);
|
|
133
|
+
const keys = [];
|
|
134
|
+
const parts = segs.map((s) => {
|
|
135
|
+
if (s.startsWith(':')) { keys.push(s.slice(1)); return '([^/]+)'; }
|
|
136
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
137
|
+
});
|
|
138
|
+
return { method: String(method).toUpperCase(), path, segs, keys, regex: new RegExp(`^/${parts.join('/')}/?$`) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 路由按「字面量段优先」排序,让**声明顺序不影响匹配**。
|
|
143
|
+
* 否则 `GET /api/sections/templates` 会被 `GET /api/sections/:id` 遮蔽,
|
|
144
|
+
* 而命中哪条取决于 actions 数组的顺序——有人调整顺序就静默错乱。
|
|
145
|
+
*/
|
|
146
|
+
export function compareRoutes(a, b) {
|
|
147
|
+
const n = Math.max(a.segs.length, b.segs.length);
|
|
148
|
+
for (let i = 0; i < n; i++) {
|
|
149
|
+
const x = a.segs[i];
|
|
150
|
+
const y = b.segs[i];
|
|
151
|
+
if (x === undefined) return 1; // 段数少的排后面(更笼统)
|
|
152
|
+
if (y === undefined) return -1;
|
|
153
|
+
const xp = x.startsWith(':') ? 1 : 0;
|
|
154
|
+
const yp = y.startsWith(':') ? 1 : 0;
|
|
155
|
+
if (xp !== yp) return xp - yp; // 字面量优先
|
|
156
|
+
}
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Suspense, useEffect, useMemo } from 'react';
|
|
2
|
+
import { SECTION_VIEW, VIEWS } from './registry.js';
|
|
3
|
+
import { Copyable, DialogHost, ErrorBoundary, Toasts } from './components/ui.jsx';
|
|
4
|
+
import { useStore } from './store.jsx';
|
|
5
|
+
|
|
6
|
+
function viewFromHash() {
|
|
7
|
+
const m = /^#\/?([^?/]*)/.exec(window.location.hash || '');
|
|
8
|
+
return m ? decodeURIComponent(m[1]) : '';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function App() {
|
|
12
|
+
const { boot, bootError, ui, patchUi, reload } = useStore();
|
|
13
|
+
|
|
14
|
+
// 栏目 tab 是**数据驱动**的(来自 bootstrap.sections),静态 tab 来自前端注册表。
|
|
15
|
+
// 两者的 id 可能撞车时静态 tab 不参与第一优先级的排序——栏目 order 是 10/20,静态是 500+。
|
|
16
|
+
const tabs = useMemo(() => {
|
|
17
|
+
const dyn = (boot?.sections || []).map((s) => ({
|
|
18
|
+
id: s.id, title: s.title, order: s.order, kind: 'section', count: s.entries,
|
|
19
|
+
}));
|
|
20
|
+
const stat = VIEWS.filter((v) => v.tab !== false).map((v) => ({
|
|
21
|
+
id: v.id, title: v.title, order: v.order, kind: 'view', component: v.component,
|
|
22
|
+
}));
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
return [...dyn, ...stat]
|
|
25
|
+
.sort((a, b) => (a.order ?? 50) - (b.order ?? 50))
|
|
26
|
+
.filter((t) => (seen.has(t.id) ? false : (seen.add(t.id), true)));
|
|
27
|
+
}, [boot]);
|
|
28
|
+
|
|
29
|
+
const current = tabs.find((t) => t.id === ui.view) || tabs[0] || null;
|
|
30
|
+
|
|
31
|
+
// hash 与 localStorage 双写对齐:只在挂载时对齐一次,之后交给 hashchange
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const fromHash = viewFromHash();
|
|
34
|
+
if (fromHash && fromHash !== ui.view) patchUi({ view: fromHash });
|
|
35
|
+
else if (!window.location.hash) window.location.hash = `#/${ui.view}`;
|
|
36
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const on = () => { const v = viewFromHash(); if (v) patchUi({ view: v }); };
|
|
41
|
+
window.addEventListener('hashchange', on);
|
|
42
|
+
return () => window.removeEventListener('hashchange', on);
|
|
43
|
+
}, [patchUi]);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (current && current.id !== ui.view) patchUi({ view: current.id });
|
|
47
|
+
}, [current, ui.view, patchUi]);
|
|
48
|
+
|
|
49
|
+
const go = (id) => {
|
|
50
|
+
patchUi({ view: id });
|
|
51
|
+
window.location.hash = `#/${id}`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="app">
|
|
56
|
+
<header className="top">
|
|
57
|
+
<span className="brand">nx-sk</span>
|
|
58
|
+
<span className="meta">本机个人资源管理器{boot ? ` · v${boot.app.version}` : ''}</span>
|
|
59
|
+
{boot ? (
|
|
60
|
+
<span className="meta">
|
|
61
|
+
存储 <Copyable className="mono" text={boot.storePathRaw}>{boot.storePath}</Copyable>
|
|
62
|
+
</span>
|
|
63
|
+
) : null}
|
|
64
|
+
<span className="right rowgap">
|
|
65
|
+
<button className="btn small ghost" onClick={() => reload()}>刷新数据</button>
|
|
66
|
+
</span>
|
|
67
|
+
</header>
|
|
68
|
+
|
|
69
|
+
<nav className="tabs">
|
|
70
|
+
{tabs.map((t) => (
|
|
71
|
+
<button key={t.id} className={`tab ${t.id === current?.id ? 'active' : ''}`} onClick={() => go(t.id)}>
|
|
72
|
+
{t.title}
|
|
73
|
+
{typeof t.count === 'number' ? <span className="count">{t.count}</span> : null}
|
|
74
|
+
</button>
|
|
75
|
+
))}
|
|
76
|
+
{!tabs.length ? <span className="meta">正在装载…</span> : null}
|
|
77
|
+
</nav>
|
|
78
|
+
|
|
79
|
+
<main className="body">
|
|
80
|
+
{bootError ? (
|
|
81
|
+
<div className="pad stack">
|
|
82
|
+
<div className="bad"><strong>读不到后端上下文</strong></div>
|
|
83
|
+
<pre className="block">{bootError}</pre>
|
|
84
|
+
<div className="hint">确认 nx-sk serve 正在运行;或点右上角「刷新数据」重试。</div>
|
|
85
|
+
</div>
|
|
86
|
+
) : null}
|
|
87
|
+
{!bootError && current ? (
|
|
88
|
+
<ErrorBoundary key={current.id}>
|
|
89
|
+
<Suspense fallback={<div className="pad muted">加载中…</div>}>
|
|
90
|
+
{current.kind === 'section'
|
|
91
|
+
? <SectionPane sectionId={current.id} />
|
|
92
|
+
: <current.component />}
|
|
93
|
+
</Suspense>
|
|
94
|
+
</ErrorBoundary>
|
|
95
|
+
) : null}
|
|
96
|
+
</main>
|
|
97
|
+
|
|
98
|
+
<Toasts />
|
|
99
|
+
<DialogHost />
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 栏目 tab 的动态挂载点:共用 entries 模块的视图,把 sectionId 传进去。 */
|
|
105
|
+
function SectionPane({ sectionId }) {
|
|
106
|
+
const View = SECTION_VIEW;
|
|
107
|
+
return <View sectionId={sectionId} />;
|
|
108
|
+
}
|