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,52 @@
1
+ // AppError + code → HTTP 状态 / CLI 退出码的唯一映射。
2
+ // 上层拿 err.code 分支,**不要对错误文本做字符串匹配**。
3
+
4
+ export const CODES = {
5
+ INVALID_INPUT: 'INVALID_INPUT',
6
+ NOT_FOUND: 'NOT_FOUND',
7
+ CONFLICT: 'CONFLICT',
8
+ BLOCKED: 'BLOCKED',
9
+ EXTERNAL: 'EXTERNAL',
10
+ INTERNAL: 'INTERNAL',
11
+ };
12
+
13
+ export class AppError extends Error {
14
+ constructor(code, message, details) {
15
+ super(message);
16
+ this.name = 'AppError';
17
+ this.code = code || CODES.INTERNAL;
18
+ if (details !== undefined) this.details = details;
19
+ }
20
+ }
21
+
22
+ export const badInput = (msg, details) => new AppError(CODES.INVALID_INPUT, msg, details);
23
+ export const notFound = (msg, details) => new AppError(CODES.NOT_FOUND, msg, details);
24
+ export const conflict = (msg, details) => new AppError(CODES.CONFLICT, msg, details);
25
+ export const blocked = (msg, details) => new AppError(CODES.BLOCKED, msg, details);
26
+ export const external = (msg, details) => new AppError(CODES.EXTERNAL, msg, details);
27
+ export const internal = (msg, details) => new AppError(CODES.INTERNAL, msg, details);
28
+
29
+ const HTTP = {
30
+ [CODES.INVALID_INPUT]: 400,
31
+ [CODES.NOT_FOUND]: 404,
32
+ [CODES.CONFLICT]: 409,
33
+ [CODES.BLOCKED]: 409,
34
+ [CODES.EXTERNAL]: 502,
35
+ [CODES.INTERNAL]: 500,
36
+ };
37
+
38
+ /** CONFLICT 与 BLOCKED 都映射 409:前者是「你选哪边」,后者是「你先去满足前置条件」。 */
39
+ export function httpStatusOf(code) {
40
+ return HTTP[code] || 500;
41
+ }
42
+
43
+ /** 失败一律退出码 1。冲突是**业务结果**不是失败——那种情况根本不会走到这里。 */
44
+ export function exitCodeOf(_code) {
45
+ return 1;
46
+ }
47
+
48
+ /** 把任意 throw 出来的东西归一成 { code, message },供 CLI / API 统一渲染。 */
49
+ export function toErrorShape(err) {
50
+ if (err instanceof AppError) return { code: err.code, message: err.message, details: err.details };
51
+ return { code: CODES.INTERNAL, message: err && err.message ? err.message : String(err) };
52
+ }
@@ -0,0 +1,345 @@
1
+ // 字段字典:栏目的字段模板是**数据**,不是代码分支。
2
+ // agent 与面板都从同一份声明渲染,所以「CLI 能填的字段」与「面板能填的字段」不可能分叉。
3
+ //
4
+ // type 取值:text | textarea | number | date | month | bool | select | tags | secret
5
+
6
+ export const FIELD_TYPES = ['text', 'textarea', 'number', 'date', 'month', 'bool', 'select', 'tags', 'secret'];
7
+
8
+ /**
9
+ * 字段的**填写策略**——回答「这个字段值不值得填」,与 type 正交。
10
+ *
11
+ * normal 照常填(默认;字段定义上不写 `fill` 就是它)
12
+ * optional 不必填:填了不亏,但没填也不该被算成「缺失」
13
+ * avoid 回避不填:填了可能反而减分(应届生的「上一家公司」、群众的「入党时间」)
14
+ *
15
+ * 后两者都**不计入完整度的分子与分母**。理由:完整度这个数字是用来导航
16
+ * 「还该补什么」的,如果一批自己决定不填的字段长期压着它,导航就废了。
17
+ * 被排除的字段不会消失——`completeness()` 会单独把它们列在 `excluded` 里。
18
+ */
19
+ export const FILL_POLICIES = ['normal', 'optional', 'avoid'];
20
+
21
+ export const FILL_LABELS = { normal: '照常填', optional: '不必填', avoid: '不填' };
22
+
23
+ /** 取字段的填写策略,缺省与非法值都归到 `normal`(宽松读,旧数据不用迁移)。 */
24
+ export function fillPolicy(def) {
25
+ const v = def && def.fill;
26
+ return FILL_POLICIES.includes(v) ? v : 'normal';
27
+ }
28
+
29
+ /** 是否被排除在完整度之外(optional / avoid 都是)。 */
30
+ export function isExcludedField(def) {
31
+ return fillPolicy(def) !== 'normal';
32
+ }
33
+
34
+ export const BOOL_TRUE = ['是', 'true', '1', 'yes', 'y'];
35
+ export const BOOL_FALSE = ['否', 'false', '0', 'no', 'n'];
36
+
37
+ const f = (key, label, type, group, extra = {}) => ({ key, label, type, group, ...extra });
38
+
39
+ const JOB_FIELDS = [
40
+ // —— 基本信息 ——
41
+ f('name', '姓名', 'text', 'basic'),
42
+ f('namePinyin', '姓名拼音', 'text', 'basic', { hint: '姓名拼音,便于英文表单填写' }),
43
+ f('englishName', '英文姓名', 'text', 'basic', { hint: '完整英文姓名' }),
44
+ f('gender', '性别', 'select', 'basic', { options: ['男', '女'] }),
45
+ f('birthDate', '出生日期', 'date', 'basic'),
46
+ f('age', '年龄', 'number', 'basic', { hint: '可不填:留空时按出生日期推算' }),
47
+ f('nationality', '国籍', 'text', 'basic', { hint: '默认中国' }),
48
+ f('ethnicity', '民族', 'text', 'basic', { hint: '民族信息' }),
49
+ f('maritalStatus', '婚姻状况', 'select', 'basic', { options: ['未婚', '已婚', '离异', '丧偶'] }),
50
+ f('hasChildren', '有无子女', 'bool', 'basic'),
51
+ f('politicalStatus', '政治面貌', 'select', 'basic', { options: ['群众', '共青团员', '中共预备党员', '中共党员', '民主党派', '其他'] }),
52
+ f('partyJoinDate', '入党时间', 'date', 'basic', { hint: '加入党组织的时间' }),
53
+ f('leagueJoinDate', '入团时间', 'date', 'basic', { hint: '加入共青团的时间' }),
54
+ f('idType', '证件类型', 'select', 'basic', { options: ['身份证', '护照', '港澳居民来往内地通行证', '台湾居民来往大陆通行证', '其他'] }),
55
+ f('idNumber', '身份证号', 'text', 'basic', { hint: '证件号码' }),
56
+
57
+ // —— 联系方式 ——
58
+ f('phone', '电话', 'text', 'contact', { hint: '手机号码' }),
59
+ f('email', '邮箱', 'text', 'contact', { hint: '联系邮箱' }),
60
+ f('wechat', '微信号', 'text', 'contact', { hint: '即时通讯微信号' }),
61
+ f('qq', 'QQ号', 'text', 'contact', { hint: 'QQ 号码' }),
62
+ f('homepage', '个人主页/作品链接', 'text', 'contact', { hint: '个人主页、作品集或代码仓库链接' }),
63
+ f('postalCode', '邮政编码', 'text', 'contact', { hint: '通讯地址邮编(家庭/收件地址,可与现居住地不同)' }),
64
+
65
+ // —— 教育背景 ——
66
+ f('education', '最高学历', 'select', 'education', { options: ['大专', '本科', '硕士', '博士', '其他'] }),
67
+ f('degree', '学位', 'select', 'education', { options: ['学士', '硕士', '博士', '无'] }),
68
+ f('school', '毕业院校', 'text', 'education'),
69
+ f('major', '毕业专业', 'text', 'education'),
70
+ f('studyMode', '学习形式', 'select', 'education', { options: ['全国普通高等院校全日制', '非全日制', '成人教育', '自学考试', '其他'] }),
71
+ f('enrollDate', '入学时间', 'month', 'education'),
72
+ f('graduateDate', '毕业时间', 'month', 'education'),
73
+ f('englishLevel', '英语等级', 'select', 'education', { options: ['无', 'CET-3', 'CET-4', 'CET-6', '专业四级', '专业八级', '雅思', '托福'] }),
74
+ f('englishScore', '英语等级分数', 'number', 'education', { hint: '等级考试的具体分数,如 CET-4 492 / CET-6 425' }),
75
+ f('gaokaoDate', '高考时间', 'month', 'education', { hint: '高考参加时间' }),
76
+ f('gaokaoScore', '高考分数', 'number', 'education', { hint: '高考成绩' }),
77
+ f('gaokaoSubjects', '高考科目', 'text', 'education', { hint: '高考选科组合' }),
78
+ f('isFreshGraduate', '是否应届生', 'bool', 'education'),
79
+ f('isBaoyan', '是否保研', 'bool', 'education', { hint: '是否推荐免试攻读研究生' }),
80
+ f('hasOverseasStudy', '是否有留学经历', 'bool', 'education'),
81
+ f('certificates', '技能证书', 'textarea', 'education', { hint: '每行一条证书或获奖' }),
82
+ // 实习/项目/校内职务不再放这里——它们各自是独立栏目(projects / internships / campus),
83
+ // 每个项目/一段经历一个条目,角色、时间、技术栈都是独立字段,不再挤 textarea。
84
+ f('skills', '专业技能', 'textarea', 'education', { hint: '每行一条:方向——具体技术点' }),
85
+
86
+ // —— 户籍与居住 ——
87
+ f('hukouPlace', '户口所在地', 'text', 'household', { hint: '省市' }),
88
+ f('hukouType', '户口性质', 'select', 'household', { options: ['农业户口', '城镇户口', '其他'] }),
89
+ f('nativePlace', '籍贯', 'text', 'household', { hint: '籍贯(省市)' }),
90
+ f('studentOrigin', '生源地', 'text', 'household', { hint: '毕业生生源地' }),
91
+ f('archiveOrg', '档案存放机构', 'text', 'household'),
92
+ f('archivePlace', '档案所在地', 'text', 'household', { hint: '人事档案所在地区' }),
93
+ f('address', '现居住地址', 'textarea', 'household', { hint: '省市 + 详细地址' }),
94
+
95
+ // —— 身体信息 ——
96
+ f('height', '身高', 'number', 'body', { hint: '身高(cm)' }),
97
+ f('weight', '体重', 'number', 'body', { hint: '体重(kg)' }),
98
+ f('bloodType', '血型', 'select', 'body', { options: ['A', 'B', 'AB', 'O', '未知'] }),
99
+ f('healthStatus', '健康状况', 'select', 'body', { options: ['良好', '一般', '较差'] }),
100
+ f('hasChronic', '是否有长期病史', 'bool', 'body'),
101
+ f('isColorWeak', '是否色弱', 'bool', 'body', { hint: '是否存在色觉弱' }),
102
+ f('hasCriminalRecord', '是否有犯罪记录', 'bool', 'body'),
103
+
104
+ // —— 紧急联系 ——
105
+ f('emergencyName', '紧急联系人姓名', 'text', 'emergency'),
106
+ f('emergencyRelation', '紧急联系人关系', 'select', 'emergency', { options: ['父亲', '母亲', '配偶', '子女', '兄弟姐妹', '朋友', '其他'] }),
107
+ f('emergencyPhone', '紧急联系电话', 'text', 'emergency'),
108
+
109
+ // —— 自我描述 ——
110
+ f('selfIntro', '自我介绍', 'textarea', 'about', { hint: '个人自我介绍' }),
111
+ f('advantages', '个人优势', 'textarea', 'about', { hint: '个人优势和特长' }),
112
+ f('hobbies', '兴趣爱好', 'textarea', 'about'),
113
+
114
+ // —— 求职意向 ——
115
+ f('targetPosition', '目标岗位', 'text', 'job'),
116
+ f('availableFrom', '预计入职时间', 'text', 'job', { hint: '预计到岗时间,如「随时到岗」「2027-07」' }),
117
+ f('expectCities', '期望城市', 'tags', 'job', { hint: '期望工作城市,最多 5 个', maxItems: 5 }),
118
+ f('interviewCities', '面试城市', 'tags', 'job', { hint: '可参与面试城市' }),
119
+ f('expectSalaryMonthly', '期望薪资', 'number', 'job', { hint: '期望税前月薪(元)' }),
120
+ f('expectSalaryYearly', '期望年薪', 'number', 'job', { hint: '期望年薪,仅填写数字' }),
121
+ f('currentSalary', '当前薪资', 'number', 'job', { hint: '当前税前月薪(元)' }),
122
+ f('workYears', '工作年限', 'number', 'job', { hint: '总工作年限(在读或应届写 0)' }),
123
+ f('workStatus', '工作状态', 'select', 'job', { options: ['在读', '应届毕业生', '在职', '离职', '待业'] }),
124
+ f('lastCompany', '上一家公司', 'text', 'job', { hint: '最近工作单位' }),
125
+ f('techLevel', '专业技术职级', 'text', 'job', { hint: '专业技术资格等级' }),
126
+ f('hasRelativesInCompany', '是否有亲人在当前求职公司', 'bool', 'job', { hint: '是否有亲属在当前应聘的公司任职' }),
127
+ f('sourceChannel', '招聘信息来源', 'select', 'job', { options: ['公司招聘官网', 'BOSS直聘', '智联招聘', '前程无忧', '校园招聘', '内推', '猎头', '社交媒体', '其他'] }),
128
+ f('acceptAdjustment', '是否接受调剂', 'bool', 'job', { hint: '是否接受岗位或城市调剂' }),
129
+ f('jobNote', '求职备注', 'textarea', 'job', { hint: '补充说明' }),
130
+
131
+ // —— 其他 ——
132
+ f('religion', '宗教信仰', 'text', 'extra', { hint: '没有就写「无」' }),
133
+ f('unionVolunteer', '是否自愿加入公司工会', 'bool', 'extra'),
134
+ ];
135
+
136
+ // 密钥栏目刻意只有**一个**字段:名称(条目名)+ 值。
137
+ // 之前做过 8 个字段(服务商/接口地址/模型/用途/有效期/额度/备注),实测是负担——
138
+ // 存一个 key 不该先填一张表。要记「这个 key 干什么用」,把它写进名称里就够。
139
+ const SECRET_FIELDS = [
140
+ f('value', '密钥值', 'secret', 'key', { hint: 'API Key 本身。落盘是密文,读出来默认是原文(--mask 才打码)' }),
141
+ ];
142
+
143
+ // —— 经历类模板:一个项目 / 一段实习 / 一条职务 = 一个条目 ——
144
+ // 为什么是独立栏目而不是 job 里的 textarea:经历天然是多字段、多条目的结构化数据,
145
+ // 「每行一条」挤在一个格子里既没法填也没法按角色/时间取数。
146
+ const PROJECT_FIELDS = [
147
+ f('name', '项目名称', 'text', 'main'),
148
+ f('role', '担任角色', 'text', 'main'),
149
+ f('period', '起止时间', 'text', 'main', { hint: '如 2025.05-2025.07' }),
150
+ f('company', '所属单位', 'text', 'main', { hint: '公司/学校/个人项目,没有就不填', fill: 'optional' }),
151
+ f('stack', '技术栈', 'textarea', 'main', { hint: '逗号分隔或每行一个' }),
152
+ f('summary', '项目简介', 'textarea', 'main'),
153
+ f('highlights', '职责与亮点', 'textarea', 'main', { hint: '每行一条' }),
154
+ f('link', '项目链接', 'text', 'main', { fill: 'optional' }),
155
+ ];
156
+
157
+ const INTERNSHIP_FIELDS = [
158
+ f('company', '公司名称', 'text', 'main'),
159
+ f('role', '岗位', 'text', 'main'),
160
+ f('period', '起止时间', 'text', 'main', { hint: '如 2026.01-2026.04' }),
161
+ f('summary', '工作概述', 'textarea', 'main'),
162
+ f('duties', '工作内容', 'textarea', 'main', { hint: '每行一条' }),
163
+ f('stack', '技术栈', 'textarea', 'main', { hint: '逗号分隔或每行一个' }),
164
+ ];
165
+
166
+ const CAMPUS_FIELDS = [
167
+ f('role', '职务名称', 'text', 'main'),
168
+ f('org', '所属组织', 'text', 'main', { fill: 'optional' }),
169
+ f('period', '起止时间', 'text', 'main', { fill: 'optional', hint: '如 2024.09-至今' }),
170
+ f('duties', '职责描述', 'textarea', 'main'),
171
+ ];
172
+
173
+ const TEMPLATES = {
174
+ job: {
175
+ id: 'job',
176
+ label: '求职 / 个人信息',
177
+ description: '求职用的个人信息档案:一次填好,所有网申表单都能从这里取数。',
178
+ titleField: 'name',
179
+ titleLabel: '档案名',
180
+ groups: [
181
+ { id: 'basic', title: '基本信息' },
182
+ { id: 'contact', title: '联系方式' },
183
+ { id: 'education', title: '教育背景' },
184
+ { id: 'household', title: '户籍与居住' },
185
+ { id: 'body', title: '身体信息' },
186
+ { id: 'emergency', title: '紧急联系' },
187
+ { id: 'about', title: '自我描述' },
188
+ { id: 'job', title: '求职意向' },
189
+ { id: 'extra', title: '其他' },
190
+ ],
191
+ fields: JOB_FIELDS,
192
+ },
193
+ secret: {
194
+ id: 'secret',
195
+ label: '密钥 / 凭据',
196
+ description: '极简 KV:名称 → 值。值以密文落盘,读出来是原文。',
197
+ // kv: true = 这个栏目**就是一张 KV 表**:条目名 = 键名,唯一那个字段 = 值。
198
+ // 面板据此渲染表格(而不是「条目列表 + 表单 + 完整性」那套);
199
+ // key set/get/list/remove 默认作用在它上面。字段字典对它没有意义,只有一个 value。
200
+ kv: true,
201
+ titleField: null,
202
+ titleLabel: '密钥名',
203
+ groups: [{ id: 'key', title: '密钥信息' }],
204
+ fields: SECRET_FIELDS,
205
+ },
206
+ projects: {
207
+ id: 'projects',
208
+ label: '项目经历',
209
+ description: '每个项目一个条目:名称、角色、时间、技术栈、职责与亮点。',
210
+ titleField: 'name',
211
+ titleLabel: '条目名',
212
+ groups: [{ id: 'main', title: '项目信息' }],
213
+ fields: PROJECT_FIELDS,
214
+ },
215
+ internships: {
216
+ id: 'internships',
217
+ label: '实习/工作经历',
218
+ description: '每段实习或工作一个条目:公司、岗位、时间、职责。',
219
+ titleField: 'company',
220
+ titleLabel: '条目名',
221
+ groups: [{ id: 'main', title: '经历信息' }],
222
+ fields: INTERNSHIP_FIELDS,
223
+ },
224
+ campus: {
225
+ id: 'campus',
226
+ label: '校内职务/实践',
227
+ description: '每条职务或实践一个条目:职务、组织、时间、职责。',
228
+ titleField: 'role',
229
+ titleLabel: '条目名',
230
+ groups: [{ id: 'main', title: '职务信息' }],
231
+ fields: CAMPUS_FIELDS,
232
+ },
233
+ };
234
+
235
+ export function templateIds() {
236
+ return Object.keys(TEMPLATES);
237
+ }
238
+
239
+ export function getTemplate(id) {
240
+ return TEMPLATES[String(id || '')] || null;
241
+ }
242
+
243
+ /** 模板摘要,给 `section templates` 与面板的「新建栏目」用。 */
244
+ export function templateSummaries() {
245
+ return Object.values(TEMPLATES).map((t) => ({
246
+ id: t.id,
247
+ label: t.label,
248
+ description: t.description,
249
+ groups: t.groups.length,
250
+ fields: t.fields.length,
251
+ }));
252
+ }
253
+
254
+ /** 由模板实例化一个栏目。深拷贝 —— 栏目自己的字段集从此独立演化。 */
255
+ export function instantiateTemplate(templateId, overrides = {}) {
256
+ const t = getTemplate(templateId);
257
+ if (!t) return null;
258
+ return {
259
+ id: overrides.id,
260
+ title: overrides.title || t.label,
261
+ description: overrides.description || t.description,
262
+ order: Number.isFinite(overrides.order) ? overrides.order : 50,
263
+ template: t.id,
264
+ kv: t.kv === true,
265
+ titleField: overrides.titleField || t.titleField,
266
+ titleLabel: overrides.titleLabel || t.titleLabel,
267
+ groups: t.groups.map((g) => ({ ...g })),
268
+ fields: t.fields.map((x) => ({ ...x })),
269
+ createdAt: overrides.createdAt || null,
270
+ updatedAt: overrides.updatedAt || null,
271
+ };
272
+ }
273
+
274
+ /** 首次运行(store 文件不存在)时的默认栏目——第一个就是「求职」。 */
275
+ export function seedSections(stamp) {
276
+ return [
277
+ { ...instantiateTemplate('job', { id: 'job', title: '求职', order: 10 }), createdAt: stamp, updatedAt: stamp },
278
+ { ...instantiateTemplate('projects', { id: 'projects', title: '项目经历', order: 11 }), createdAt: stamp, updatedAt: stamp },
279
+ { ...instantiateTemplate('internships', { id: 'internships', title: '实习/工作经历', order: 12 }), createdAt: stamp, updatedAt: stamp },
280
+ { ...instantiateTemplate('campus', { id: 'campus', title: '校内职务/实践', order: 13 }), createdAt: stamp, updatedAt: stamp },
281
+ { ...instantiateTemplate('secret', { id: 'secret', title: '密钥', order: 20 }), createdAt: stamp, updatedAt: stamp },
282
+ ];
283
+ }
284
+
285
+ /** 由「字段定义 + 值」推导栏目的分组结构,供 render/面板用。 */
286
+ export function groupFields(fields) {
287
+ const order = [];
288
+ const map = new Map();
289
+ for (const x of fields || []) {
290
+ const g = x.group || 'other';
291
+ if (!map.has(g)) { map.set(g, []); order.push(g); }
292
+ map.get(g).push(x);
293
+ }
294
+ return order.map((id) => ({ id, fields: map.get(id) }));
295
+ }
296
+
297
+ export function fieldByKey(section, key) {
298
+ return (section?.fields || []).find((x) => x.key === key) || null;
299
+ }
300
+
301
+ export function fieldByLabel(section, label) {
302
+ const s = String(label || '').trim();
303
+ return (section?.fields || []).find((x) => x.label === s || x.key === s) || null;
304
+ }
305
+
306
+ export function isSensitiveField(section, key) {
307
+ const def = fieldByKey(section, key);
308
+ return !!(def && (def.sensitive || def.type === 'secret'));
309
+ }
310
+
311
+ /**
312
+ * 栏目定位:**id 或标题都接受**。
313
+ * 放宽的理由:agent 手里往往是人读的名字(「求职」),而不是内部 id。
314
+ * 放 core 是因为每个模块都要用它,而模块之间不许互相 import。
315
+ */
316
+ export function findSection(store, ref) {
317
+ const s = String(ref ?? '').trim();
318
+ if (!s) return null;
319
+ const lower = s.toLowerCase();
320
+ return (store?.sections || []).find((x) => x.id === lower)
321
+ || (store?.sections || []).find((x) => x.title === s)
322
+ || null;
323
+ }
324
+
325
+ export function sectionNames(store) {
326
+ const names = (store?.sections || []).map((x) => `${x.id}(${x.title})`).join('、');
327
+ return names || '(还没有栏目)';
328
+ }
329
+
330
+ /**
331
+ * KV 栏目:`kv: true` 标记的栏目(密钥就是)。它是**一张表**,不是一族条目。
332
+ * `key set/get/list/remove` 默认作用在它上面;面板也据此切换成表格布局。
333
+ */
334
+ export function findKvSection(store, ref) {
335
+ const all = (store?.sections || []).filter((s) => s.kv);
336
+ if (ref) {
337
+ const hit = findSection(store, ref);
338
+ return hit || null;
339
+ }
340
+ return all[0] || null;
341
+ }
342
+
343
+ export function kvSections(store) {
344
+ return (store?.sections || []).filter((s) => s.kv);
345
+ }
@@ -0,0 +1,103 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import fsp from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { toPosix } from './paths.js';
6
+
7
+ export async function pathExists(p) {
8
+ try { await fsp.access(p); return true; } catch { return false; }
9
+ }
10
+
11
+ export async function isDir(p) {
12
+ try { return (await fsp.stat(p)).isDirectory(); } catch { return false; }
13
+ }
14
+
15
+ export async function ensureDir(p) {
16
+ await fsp.mkdir(p, { recursive: true });
17
+ }
18
+
19
+ /** 原子写:临时文件 + rename。rename 在同一文件系统上是原子的。 */
20
+ export async function writeAtomic(p, content) {
21
+ await ensureDir(dirname(p));
22
+ const tmp = `${p}.${process.pid}.tmp`;
23
+ await fsp.writeFile(tmp, content);
24
+ await fsp.rename(tmp, p);
25
+ }
26
+
27
+ export async function writeJsonAtomic(p, data) {
28
+ await writeAtomic(p, JSON.stringify(data, null, 2) + '\n');
29
+ }
30
+
31
+ export async function readFileText(p) {
32
+ return fsp.readFile(p, 'utf8');
33
+ }
34
+
35
+ /** 读 JSON;失败返回 fallback(数据损坏时工具不该启动即崩)。 */
36
+ export async function readJson(p, fallback = null) {
37
+ try {
38
+ return JSON.parse(await fsp.readFile(p, 'utf8'));
39
+ } catch {
40
+ return fallback;
41
+ }
42
+ }
43
+
44
+ export function sha256(text) {
45
+ return createHash('sha256').update(text).digest('hex');
46
+ }
47
+
48
+ export async function hashFile(p) {
49
+ return new Promise((ok, no) => {
50
+ const h = createHash('sha256');
51
+ createReadStream(p).on('error', no).on('data', (c) => h.update(c)).on('end', () => ok(h.digest('hex')));
52
+ });
53
+ }
54
+
55
+ /** 递归列出目录下所有文件的 posix 相对路径(升序),不含目录本身。 */
56
+ export async function listFiles(dir, base = dir) {
57
+ const out = [];
58
+ let items = [];
59
+ try { items = await fsp.readdir(dir, { withFileTypes: true }); } catch { return out; }
60
+ for (const it of items.sort((a, b) => a.name.localeCompare(b.name))) {
61
+ const abs = join(dir, it.name);
62
+ if (it.isDirectory()) out.push(...(await listFiles(abs, base)));
63
+ else out.push(toPosix(abs.slice(base.length + 1)));
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /**
69
+ * 逐文件 sha256 比对两棵树,返回**内容不同 / 只在一侧存在**的相对路径。
70
+ * 空数组 = 完全一致。用于 skill install 的三态判定。
71
+ */
72
+ export async function diffTrees(src, dst) {
73
+ const srcFiles = await listFiles(src);
74
+ const dstFiles = await listFiles(dst);
75
+ const all = [...new Set([...srcFiles, ...dstFiles])].sort();
76
+ const diff = [];
77
+ for (const rel of all) {
78
+ const inSrc = srcFiles.includes(rel);
79
+ const inDst = dstFiles.includes(rel);
80
+ if (!inSrc || !inDst) { diff.push(rel); continue; }
81
+ const [a, b] = await Promise.all([hashFile(join(src, rel)), hashFile(join(dst, rel))]);
82
+ if (a !== b) diff.push(rel);
83
+ }
84
+ return diff;
85
+ }
86
+
87
+ export async function copyTree(src, dst) {
88
+ await ensureDir(dirname(dst));
89
+ await fsp.cp(src, dst, { recursive: true, dereference: true, force: true });
90
+ }
91
+
92
+ export async function removeTree(p) {
93
+ await fsp.rm(p, { recursive: true, force: true });
94
+ }
95
+
96
+ export async function fileSize(p) {
97
+ try { return (await fsp.stat(p)).size; } catch { return 0; }
98
+ }
99
+
100
+ /** 尽力收紧权限(Windows 上是 no-op,POSIX 上 0600)。 */
101
+ export async function chmodPrivate(p) {
102
+ try { await fsp.chmod(p, 0o600); } catch { /* Windows / 文件系统不支持 */ }
103
+ }
@@ -0,0 +1,19 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ /** 短、可读、可排序的实体 id:`e_lx3k9f2a1b3c4d`。 */
4
+ export function newId(prefix, bytes = 4) {
5
+ const time = Date.now().toString(36);
6
+ const rand = randomBytes(bytes).toString('hex');
7
+ return `${prefix}_${time}${rand}`;
8
+ }
9
+
10
+ /** 时间戳文件名:`20260924-144238`,本地时区,方便人读。 */
11
+ export function stamp(date = new Date()) {
12
+ const p = (n, w = 2) => String(n).padStart(w, '0');
13
+ return `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}-` +
14
+ `${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`;
15
+ }
16
+
17
+ export function nowIso() {
18
+ return new Date().toISOString();
19
+ }
@@ -0,0 +1,32 @@
1
+ // 交给 OS 的动作:开浏览器 / 开文件管理器。纯尽力而为,失败绝不影响主流程。
2
+ import { spawn } from 'node:child_process';
3
+
4
+ function launch(cmd, args) {
5
+ return new Promise((resolve) => {
6
+ let done = false;
7
+ const finish = (v) => { if (!done) { done = true; resolve(v); } };
8
+ try {
9
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true });
10
+ child.once('error', () => finish(false));
11
+ child.unref();
12
+ setTimeout(() => finish(true), 300);
13
+ } catch {
14
+ finish(false);
15
+ }
16
+ });
17
+ }
18
+
19
+ export async function openBrowser(url) {
20
+ if (process.platform === 'win32') {
21
+ // `start` 是 cmd 内建命令,第一个空串参数是窗口标题占位,不能省
22
+ return launch('cmd', ['/c', 'start', '', url]);
23
+ }
24
+ if (process.platform === 'darwin') return launch('open', [url]);
25
+ return launch('xdg-open', [url]);
26
+ }
27
+
28
+ export async function openPath(target) {
29
+ if (process.platform === 'win32') return launch('explorer', [target]);
30
+ if (process.platform === 'darwin') return launch('open', [target]);
31
+ return launch('xdg-open', [target]);
32
+ }