dsh-plugin-tlrp 0.0.0-stage → 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.
@@ -0,0 +1,445 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 客户端 bundle 的结构冒烟检查。
4
+ *
5
+ * 目的:在没有浏览器、没有真 React 的环境里,验证 client.js
6
+ * 1) 能被 window.__ModuleLoader__.load({id,factory}) 正常注册;
7
+ * 2) factory(require) 能求值出 {inject, apply, TlrpSettingsCard};
8
+ * 3) apply(ctx) 在 configForms 服务存在/缺失两种宿主下都不抛错;
9
+ * 4) TlrpSettingsCard 在 summary / page 两种视图、四种快照状态
10
+ * (ready / loading / unavailable / 只读)下都能渲染出树,
11
+ * 且写操作确实转发给 controller.set / unset。
12
+ *
13
+ * 这里故意用 React 桩(createElement 直接产对象、hooks 走假实现),
14
+ * 所以结论只覆盖「结构正确、不抛错、文案与控件在位」,不覆盖真实
15
+ * 视觉渲染 —— 后者只能在浏览器里由人眼确认。
16
+ */
17
+
18
+ import { readFileSync } from 'node:fs';
19
+ import { dirname, join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import vm from 'node:vm';
22
+
23
+ const here = dirname(fileURLToPath(import.meta.url));
24
+ const clientPath = join(here, '..', 'client.js');
25
+ const source = readFileSync(clientPath, 'utf8');
26
+
27
+ let failures = 0;
28
+ const results = [];
29
+
30
+ /**
31
+ * 记录一条断言。
32
+ * @param {string} name - 断言名。
33
+ * @param {boolean} ok - 是否通过。
34
+ * @param {string} [detail] - 失败时的补充信息。
35
+ */
36
+ function check(name, ok, detail) {
37
+ if (ok) results.push(` PASS ${name}`);
38
+ else {
39
+ failures += 1;
40
+ results.push(` FAIL ${name}${detail === undefined ? '' : ` — ${detail}`}`);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * React 桩:只保证 createElement 与常用 hooks 的调用形状。
46
+ * @returns {object} react 命名空间。
47
+ */
48
+ function createReactStub() {
49
+ const createElement = (type, props, ...children) => ({
50
+ type,
51
+ props: { ...(props ?? {}), children: children.length <= 1 ? children[0] : children }
52
+ });
53
+ return {
54
+ createElement,
55
+ Fragment: 'Fragment',
56
+ useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}],
57
+ useEffect: () => {},
58
+ useMemo: (factory) => factory(),
59
+ useCallback: (fn) => fn,
60
+ useRef: (initial) => ({ current: initial })
61
+ };
62
+ }
63
+
64
+ /**
65
+ * 展开渲染树里的函数组件(React 桩不会自己调用它们)。
66
+ * @param {*} node - 渲染树节点。
67
+ * @returns {*} 展开后的树。
68
+ */
69
+ function expand(node) {
70
+ if (node === undefined || node === null || node === false) return node;
71
+ if (Array.isArray(node)) return node.map(expand);
72
+ if (typeof node !== 'object' || node.props === undefined) return node;
73
+ if (typeof node.type === 'function') return expand(node.type(node.props));
74
+ return { type: node.type, props: { ...node.props, children: expand(node.props.children) } };
75
+ }
76
+
77
+ /**
78
+ * 从渲染树里收集全部字符串节点。
79
+ * @param {*} node - createElement 产物或标量。
80
+ * @param {string[]} [acc] - 累积数组。
81
+ * @returns {string[]} 文本节点。
82
+ */
83
+ function collectText(node, acc = []) {
84
+ if (node === undefined || node === null || node === false) return acc;
85
+ if (typeof node === 'string' || typeof node === 'number') {
86
+ acc.push(String(node));
87
+ return acc;
88
+ }
89
+ if (Array.isArray(node)) {
90
+ for (const item of node) collectText(item, acc);
91
+ return acc;
92
+ }
93
+ if (typeof node === 'object' && node.props !== undefined) collectText(node.props.children, acc);
94
+ return acc;
95
+ }
96
+
97
+ /**
98
+ * 按谓词遍历渲染树。
99
+ * @param {*} node - 渲染树根。
100
+ * @param {(node: object) => boolean} predicate - 匹配条件。
101
+ * @param {object[]} [acc] - 累积数组。
102
+ * @returns {object[]} 命中的节点。
103
+ */
104
+ function findAll(node, predicate, acc = []) {
105
+ if (node === undefined || node === null || node === false) return acc;
106
+ if (Array.isArray(node)) {
107
+ for (const item of node) findAll(item, predicate, acc);
108
+ return acc;
109
+ }
110
+ if (typeof node === 'object' && node.props !== undefined) {
111
+ if (predicate(node)) acc.push(node);
112
+ findAll(node.props.children, predicate, acc);
113
+ }
114
+ return acc;
115
+ }
116
+
117
+ /**
118
+ * 加载客户端 bundle,返回注册载荷与模块导出。
119
+ * @returns {{loaded: object, exports: object}} 加载结果。
120
+ */
121
+ function loadClient() {
122
+ let loaded;
123
+ const window = {
124
+ __ModuleLoader__: {
125
+ load(payload) {
126
+ loaded = payload;
127
+ }
128
+ }
129
+ };
130
+ const context = vm.createContext({
131
+ window,
132
+ document: {
133
+ createElement: () => ({ setAttribute() {}, appendChild() {}, dataset: {} }),
134
+ head: { appendChild() {} },
135
+ querySelector: () => null
136
+ },
137
+ console
138
+ });
139
+ vm.runInContext(source, context, { filename: 'client.js' });
140
+ if (loaded === undefined) throw new Error('client.js 没有调用 window.__ModuleLoader__.load');
141
+ const react = createReactStub();
142
+ const exports = loaded.factory((specifier) => {
143
+ if (specifier === 'react') return react;
144
+ throw new Error(`unexpected require(${specifier})`);
145
+ });
146
+ return { loaded, exports, react };
147
+ }
148
+
149
+ /**
150
+ * 造一个假宿主上下文。
151
+ * @param {object} options - 配置项。
152
+ * @param {object|null} options.formController - configForms 控制器(null 表示宿主没有该服务)。
153
+ * @returns {object} 上下文与捕获记录。
154
+ */
155
+ let dictionaryHost;
156
+
157
+ function createHost({ formController }) {
158
+ const record = { slots: [], registrations: [], writes: [], dictionary: undefined };
159
+ dictionaryHost = record;
160
+ const locale = {
161
+ register(namespace, dictionaries) {
162
+ record.dictionary = { namespace, dictionaries };
163
+ return () => {};
164
+ },
165
+ bind() {
166
+ return (key, params) => {
167
+ const raw = record.dictionary?.dictionaries.zh[key] ?? key;
168
+ if (params === undefined) return raw;
169
+ return Object.entries(params).reduce((text, [name, value]) => text.replace(`{${name}}`, String(value)), raw);
170
+ };
171
+ }
172
+ };
173
+ const ctx = {
174
+ logger: { error() {} },
175
+ effect(factory) {
176
+ const disposer = factory();
177
+ return typeof disposer === 'function' ? disposer : () => {};
178
+ },
179
+ locale,
180
+ inject(names, callback) {
181
+ if (formController === null) return () => {};
182
+ // cordis 的 inject 交给回调的是子上下文,服务挂在 ctx.<name> 上。
183
+ callback({
184
+ configForms: {
185
+ describe() {
186
+ return {
187
+ getSnapshot: () => ({ view: { namespaces: [{ ns: 'include:tlrp' }, { ns: 'other' }] } }),
188
+ subscribe: () => () => {},
189
+ load: () => Promise.resolve()
190
+ };
191
+ },
192
+ whileServed(namespaces, register) {
193
+ record.servedNamespaces = namespaces;
194
+ return register(new Set(['include:tlrp']));
195
+ },
196
+ get(namespace) {
197
+ record.requestedNamespace = namespace;
198
+ return {
199
+ getSnapshot: () => formController.snapshot,
200
+ subscribe: () => () => {},
201
+ set(field, value) {
202
+ record.writes.push({ op: 'set', field, value });
203
+ return Promise.resolve({ ok: true });
204
+ },
205
+ unset(field) {
206
+ record.writes.push({ op: 'unset', field });
207
+ return Promise.resolve({ ok: true });
208
+ }
209
+ };
210
+ }
211
+ }
212
+ });
213
+ return () => {};
214
+ },
215
+ slots: {
216
+ inject(name, factory) {
217
+ record.slots.push(name);
218
+ return factory();
219
+ },
220
+ register(definition, component) {
221
+ record.registrations.push({ definition, component });
222
+ return () => {};
223
+ }
224
+ }
225
+ };
226
+ return { ctx, record };
227
+ }
228
+
229
+ /** 一份典型 ready 快照。 */
230
+ const readyValue = {
231
+ enabled: true,
232
+ mode: 'work',
233
+ activeCharacterId: 'firefly',
234
+ customSceneOverride: '',
235
+ blockShellToolsInRp: true,
236
+ enableStatusFooter: true,
237
+ workModeKeywords: '端口|报错|日志',
238
+ characters: [
239
+ { id: 'firefly', name: '流萤 (Firefly)', userTitle: '开拓者' },
240
+ { id: 'sam_tactical', name: '萨姆 · 战术 (SAM)', userTitle: '指挥官' }
241
+ ],
242
+ modes: [
243
+ { id: 'rp', name: '少女陪伴态', commandAliases: ['/rp'], autoKeywords: '想你', autoPriority: 10, toolPolicy: 'whitelist_only', allowedTools: 'tlmemory', injectCharacterPersona: true, enableStatusFooter: true, isDefaultFallback: true },
244
+ { id: 'work', name: '战术工作态', commandAliases: ['/work'], autoKeywords: '端口|报错', autoPriority: 50, toolPolicy: 'allow_all', injectCharacterPersona: false, enableStatusFooter: false },
245
+ { id: 'sam', name: '萨姆强袭态', commandAliases: ['/sam', '【萨姆】'], autoKeywords: '萨姆|机甲', autoPriority: 80, toolPolicy: 'allow_all', injectCharacterPersona: true, enableStatusFooter: true },
246
+ { id: 'sleep', name: '医疗舱微凉态', commandAliases: ['/sleep'], autoKeywords: '晚安', autoPriority: 60, toolPolicy: 'whitelist_only', allowedTools: 'tlmemory', injectCharacterPersona: true, enableStatusFooter: true },
247
+ { id: 'debug', name: '排障态', commandAliases: ['/debug', '【排障】'], autoKeywords: '堆栈|崩溃', autoPriority: 99, directivePrompt: '只输出根因。', toolPolicy: 'block_all', injectCharacterPersona: false, enableStatusFooter: false }
248
+ ],
249
+ triggers: [
250
+ { id: 'oak-cake', enabled: true },
251
+ { id: 'glamoth', enabled: true },
252
+ { id: 'entropy', enabled: false }
253
+ ]
254
+ };
255
+
256
+ /**
257
+ * 用给定快照渲染一次卡片。
258
+ * @param {object} args - 组件与快照。
259
+ * @returns {*} 渲染树。
260
+ */
261
+ function render({ component, registration, snapshot, view }) {
262
+ const face = typeof registration.definition.inject === 'function' ? registration.definition.inject() : registration.definition.inject;
263
+ const t = (key, params) => {
264
+ const raw = dictionaryHost?.dictionary?.dictionaries.zh?.[key] ?? {
265
+ title: '角色扮演与双态路由',
266
+ mode: '当前模式',
267
+ modeAuto: '自动',
268
+ modeRp: '陪伴态',
269
+ modeWork: '工作态',
270
+ character: '激活角色',
271
+ scene: '当前情景',
272
+ triggers: '关键词触发器',
273
+ triggersCount: '已启用 {count} 组',
274
+ loading: '正在读取 tlrp 配置…',
275
+ unavailable: '本部署没有服出 tlrp 的设置命名空间,配置卡暂时不可用。',
276
+ readOnly: '本部署的设置为只读。',
277
+ apply: '应用',
278
+ clear: '清空'
279
+ }[key] ?? key;
280
+ return params === undefined ? raw : Object.entries(params).reduce((text, [name, value]) => text.replace(`{${name}}`, String(value)), raw);
281
+ };
282
+ return expand(component({
283
+ view,
284
+ t,
285
+ useTlrp: (selector) => selector(snapshot),
286
+ ...face
287
+ }));
288
+ }
289
+
290
+ // ── 1. 加载与导出面 ────────────────────────────────────────────────
291
+ const { loaded, exports } = loadClient();
292
+ check('window.__ModuleLoader__.load 被调用', loaded !== undefined);
293
+ check('注册 id 是 dsh-plugin-tlrp', loaded?.id === 'dsh-plugin-tlrp', String(loaded?.id));
294
+ check('exports.inject 含 slots 与 locale', Array.isArray(exports.inject) && exports.inject.includes('slots') && exports.inject.includes('locale'), JSON.stringify(exports.inject));
295
+ check('exports.apply 是函数', typeof exports.apply === 'function');
296
+ check('exports.TlrpSettingsCard 是函数', typeof exports.TlrpSettingsCard === 'function');
297
+
298
+ // ── 2. 正常宿主:注册配置卡 ───────────────────────────────────────
299
+ const readySnapshot = { status: 'ready', value: readyValue, revision: 7, writable: true };
300
+ const host = createHost({ formController: { snapshot: readySnapshot } });
301
+ let applyThrew;
302
+ try {
303
+ exports.apply(host.ctx);
304
+ } catch (error) {
305
+ applyThrew = error;
306
+ }
307
+ check('apply(ctx) 在正常宿主上不抛错', applyThrew === undefined, applyThrew?.message);
308
+ check('注册了字典命名空间 settings.tlrp', host.record.dictionary?.namespace === 'settings.tlrp', String(host.record.dictionary?.namespace));
309
+ check('字典含 zh 与 en 且键集一致', (() => {
310
+ const zh = host.record.dictionary?.dictionaries.zh;
311
+ const en = host.record.dictionary?.dictionaries.en;
312
+ if (zh === undefined || en === undefined) return false;
313
+ const zhKeys = Object.keys(zh).sort().join(',');
314
+ const enKeys = Object.keys(en).sort().join(',');
315
+ return zhKeys === enKeys;
316
+ })());
317
+ check('请求了三种槽位(bundle / 行配置 / 设置栏目)', host.record.slots.includes('plugins.bundle.config') && host.record.slots.includes('plugins.row.config') && host.record.slots.includes('settings.section'), JSON.stringify(host.record.slots));
318
+ const registration = host.record.registrations.find((item) => item.definition.name === 'plugins.row.config');
319
+ const sectionRegistration = host.record.registrations.find((item) => item.definition.name === 'settings.section');
320
+ check('两处配置槽 + 一处设置栏目各注册一次', host.record.registrations.length === 3, String(host.record.registrations.length));
321
+ check('行配置槽键为 dsh-plugin-tlrp#tlrp', registration?.definition.key === 'dsh-plugin-tlrp#tlrp', String(registration?.definition.key));
322
+ check('bundle 槽键为包名', host.record.registrations.some((item) => item.definition.name === 'plugins.bundle.config' && item.definition.key === 'dsh-plugin-tlrp'), JSON.stringify(host.record.registrations.map((item) => item.definition.key)));
323
+ check('键控槽位不再传 id/order', registration?.definition.id === undefined && registration?.definition.order === undefined, JSON.stringify({ id: registration?.definition.id, order: registration?.definition.order }));
324
+ check('设置栏目 id 为 tlrp', sectionRegistration?.definition.id === 'tlrp', String(sectionRegistration?.definition.id));
325
+ check('设置栏目 order 为 45', sectionRegistration?.definition.order === 45, String(sectionRegistration?.definition.order));
326
+ check('设置栏目 label 是 thunk 且不抛错', (() => {
327
+ if (typeof sectionRegistration?.definition.label !== 'function') return false;
328
+ try {
329
+ return typeof sectionRegistration.definition.label() === 'string';
330
+ } catch {
331
+ return false;
332
+ }
333
+ })());
334
+ check('设置栏目自带 locale 与 inject 面', sectionRegistration?.definition.locale === 'settings.tlrp' && typeof sectionRegistration?.definition.inject === 'function');
335
+ check('设置栏目挂的是包装组件', sectionRegistration?.component === exports.TlrpSettingsSection);
336
+ check('候选命名空间命中 include:tlrp', host.record.requestedNamespace === 'include:tlrp', String(host.record.requestedNamespace));
337
+
338
+ // ── 3. summary 视图 ──────────────────────────────────────────────
339
+ let summary;
340
+ try {
341
+ summary = render({ component: registration.component, registration, snapshot: readySnapshot, view: 'summary' });
342
+ } catch (error) {
343
+ summary = error;
344
+ }
345
+ check('summary 视图渲染不抛错', !(summary instanceof Error), summary?.message);
346
+ check('summary 视图有输出', collectText(summary).join('').length > 0, JSON.stringify(collectText(summary)));
347
+
348
+ // ── 4. page 视图:控件在位 ────────────────────────────────────────
349
+ let page;
350
+ try {
351
+ page = render({ component: registration.component, registration, snapshot: readySnapshot, view: 'page' });
352
+ } catch (error) {
353
+ page = error;
354
+ }
355
+ check('page 视图渲染不抛错', !(page instanceof Error), page?.message);
356
+ const pageText = collectText(page).join(' ');
357
+ check('page 根节点带 tlrp-root', findAll(page, (n) => n.props.className === 'tlrp-root').length === 1);
358
+ check('四个分区都在位', findAll(page, (n) => n.props.className === 'tlrp-section').length === 4,
359
+ String(findAll(page, (n) => n.props.className === 'tlrp-section').length));
360
+ check(
361
+ '切换栏按 modes 动态渲染(含自动路由与全部形态名)',
362
+ ['自动路由 (auto)', '少女陪伴态', '战术工作态', '萨姆强袭态', '医疗舱微凉态'].every((label) => pageText.includes(label)),
363
+ pageText.slice(0, 240)
364
+ );
365
+ check('自定义第 5 个形态也出现在切换栏', pageText.includes('排障态'));
366
+ check(
367
+ '形态 CRUD 按钮齐全',
368
+ ['新建形态', '复制当前形态', '删除当前形态', '设为 Auto 默认兜底形态', '保存形态修改'].every((label) => pageText.includes(label)),
369
+ ['新建形态', '复制当前形态', '删除当前形态', '设为 Auto 默认兜底形态', '保存形态修改'].filter((label) => !pageText.includes(label)).join(',')
370
+ );
371
+ check(
372
+ '形态编辑器字段齐全(准则 / 状态栏覆盖 / 工具策略)',
373
+ ['聊天触发指令别名', '工具放行策略', '该形态专属行为准则', '该形态专属状态栏模板覆盖'].every((label) => pageText.includes(label))
374
+ );
375
+ const activeMode = findAll(page, (n) => n.type === 'button' && n.props["data-active"] === 'true');
376
+ check('当前模式 work 高亮', activeMode.length === 1, `active=${activeMode.length}`);
377
+ const optionValues = findAll(page, (n) => n.type === 'option').map((n) => n.props.value);
378
+ check('角色下拉含内置两角色', optionValues.includes('firefly') && optionValues.includes('sam_tactical'), JSON.stringify(optionValues));
379
+ check('情景输入框在位', findAll(page, (n) => n.props.placeholder === '例如:现实宇宙·星舰医疗舱外').length === 1,
380
+ String(findAll(page, (n) => n.props.placeholder === '例如:现实宇宙·星舰医疗舱外').length));
381
+ check('三个开关在位', findAll(page, (n) => n.type === 'input' && n.props.type === 'checkbox').length >= 3,
382
+ String(findAll(page, (n) => n.type === 'input' && n.props.type === 'checkbox').length));
383
+ check('角色工作室 11 个字段全部可编辑', findAll(page, (n) => n.props.className === 'tlrp-field').length >= 11,
384
+ String(findAll(page, (n) => n.props.className === 'tlrp-field').length));
385
+ check('触发器数量按启用数计算', pageText.includes('共 3 条') && pageText.includes('启用 2 条'), pageText.slice(0, 240));
386
+ check('角色工作室四个操作按钮在位', ['新建空白角色', '复制当前角色', '删除当前角色', '保存角色修改'].every((label) => pageText.includes(label)), pageText.slice(0, 240));
387
+ check('触发词映射区有新增与删除按钮', pageText.includes('新增触发词映射') && pageText.includes('删除此条'), pageText.slice(0, 240));
388
+ check('导入导出与预览区在位', pageText.includes('从 JSON 导入角色/触发器') && pageText.includes('预览当前合成 Prompt'), pageText.slice(0, 240));
389
+
390
+ // ── 4b. 设置栏目整页 ──────────────────────────────────────────────
391
+ let section;
392
+ try {
393
+ section = render({ component: exports.TlrpSettingsSection, registration: sectionRegistration, snapshot: readySnapshot, view: undefined });
394
+ } catch (error) {
395
+ section = error;
396
+ }
397
+ check('设置栏目渲染不抛错', !(section instanceof Error), section?.message);
398
+ check('设置栏目外层是 tlrp-root', !(section instanceof Error) && findAll(section, (n) => n.props.className === 'tlrp-root').length === 1);
399
+ check('设置栏目里有四个分区', !(section instanceof Error) && findAll(section, (n) => n.props.className === 'tlrp-section').length === 4,
400
+ String(findAll(section, (n) => n.props.className === 'tlrp-section').length));
401
+ check('设置栏目有标题文本', !(section instanceof Error) && collectText(section).join(' ').includes('TLRP 角色与双态引擎'));
402
+
403
+ // ── 5. 写操作确实转发 ────────────────────────────────────────────
404
+ const face = typeof registration.definition.inject === 'function' ? registration.definition.inject() : registration.definition.inject;
405
+ face.set('mode', 'rp');
406
+ face.unset('customSceneOverride');
407
+ check('set 转发到 controller', host.record.writes.some((w) => w.op === 'set' && w.field === 'mode' && w.value === 'rp'), JSON.stringify(host.record.writes));
408
+ check('unset 转发到 controller', host.record.writes.some((w) => w.op === 'unset' && w.field === 'customSceneOverride'), JSON.stringify(host.record.writes));
409
+
410
+ // ── 6. 其它快照状态 ──────────────────────────────────────────────
411
+ const states = [
412
+ ['loading', { status: 'loading', writable: false }],
413
+ ['unavailable', { status: 'unavailable', writable: false }],
414
+ ['ready-但只读', { status: 'ready', value: readyValue, writable: false }]
415
+ ];
416
+ for (const [label, snapshot] of states) {
417
+ for (const view of ['summary', 'page']) {
418
+ let thrown;
419
+ try {
420
+ render({ component: registration.component, registration, snapshot, view });
421
+ } catch (error) {
422
+ thrown = error;
423
+ }
424
+ check(`${label} 快照的 ${view} 视图不抛错`, thrown === undefined, thrown?.message);
425
+ }
426
+ }
427
+ const readOnlyPage = render({ component: registration.component, registration, snapshot: { status: 'ready', value: readyValue, writable: false }, view: 'page' });
428
+ check('只读快照下控件被禁用', findAll(readOnlyPage, (n) => n.props.disabled === true).length > 0);
429
+ check('只读快照给出提示', collectText(readOnlyPage).join(' ').includes('只读'), collectText(readOnlyPage).join(' ').slice(0, 160));
430
+
431
+ // ── 7. 旧宿主:没有 configForms 服务 ─────────────────────────────
432
+ const legacy = createHost({ formController: null });
433
+ let legacyThrew;
434
+ try {
435
+ exports.apply(legacy.ctx);
436
+ } catch (error) {
437
+ legacyThrew = error;
438
+ }
439
+ check('没有 configForms 时 apply 仍然不抛错', legacyThrew === undefined, legacyThrew?.message);
440
+ check('没有 configForms 时不注册卡片', legacy.record.registrations.length === 0, String(legacy.record.registrations.length));
441
+
442
+ // ── 输出 ─────────────────────────────────────────────────────────
443
+ console.log(results.join('\n'));
444
+ console.log(failures === 0 ? `\nRESULT: ALL PASS (${results.length} checks)` : `\nRESULT: ${failures} FAILED of ${results.length} checks`);
445
+ process.exitCode = failures === 0 ? 0 : 1;
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 把一个 profile 目录装成「tlrp 可用」状态(幂等)。
4
+ *
5
+ * 做三件事(都带幂等判断,可反复运行):
6
+ * 1. 把包文件复制到 <profile>/node_modules/dsh-plugin-tlrp(实体目录,
7
+ * 这样插件才能从 profile 级 node_modules 解析到 @deepseek-ai/schemastery);
8
+ * 2. 在 <profile>/package.json 里补 dependencies 条目与 dsh.profile.bundles 条目;
9
+ * 3. 在 <profile>/cordis.patch.yml 末尾补 `- id: tlrp` 配置块。
10
+ *
11
+ * 为什么不用 `dsh plugin add` / pnpm:profile 目录里跑 pnpm 会被正在运行的宿主
12
+ * 锁住文件(Windows 上表现为 ERR_PNPM_EPERM importPackage),而 tlrp 没有任何
13
+ * 运行期依赖需要 pnpm 解析 —— 它只用宿主已有的 @deepseek-ai/schemastery。
14
+ * 需要 lockfile 一致时,停掉宿主后再跑一次 `pnpm install` 即可。
15
+ *
16
+ * 用法:
17
+ * node scripts/install-into-profile.mjs --profile-dir ~/.dsh/profiles/desktop
18
+ * node scripts/install-into-profile.mjs --profile-dir /root/.dsh/profiles/web
19
+ * node scripts/install-into-profile.mjs --profile-dir ... --check # 只报告,不写
20
+ */
21
+
22
+ import { createRequire } from 'node:module'
23
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
24
+ import { homedir } from 'node:os'
25
+ import path from 'node:path'
26
+ import { fileURLToPath } from 'node:url'
27
+
28
+ const require = createRequire(import.meta.url)
29
+ const here = path.dirname(fileURLToPath(import.meta.url))
30
+ const packageRoot = path.resolve(here, '..')
31
+ const manifest = JSON.parse(readFileSync(path.join(packageRoot, 'package.json'), 'utf8'))
32
+
33
+ const PACKAGE_NAME = manifest.name
34
+ const ENTRY_ID = 'tlrp'
35
+ // package.json 是 bundle 解析的锚点,无论 files 列表怎么写都必须复制
36
+ const FILES = ['package.json', ...(manifest.files ?? [
37
+ 'package.json',
38
+ 'index.js',
39
+ 'index.cjs',
40
+ 'lib/core.cjs',
41
+ 'lib/schema.cjs',
42
+ 'lib/resolve.cjs',
43
+ 'lib/memory-bridge.cjs',
44
+ 'cordis.patch.yml',
45
+ 'locale/*.json',
46
+ 'icon.svg',
47
+ 'README.md',
48
+ ])]
49
+
50
+ const args = process.argv.slice(2)
51
+ const readFlag = (name) => {
52
+ const index = args.indexOf(name)
53
+ return index >= 0 ? args[index + 1] : undefined
54
+ }
55
+ const checkOnly = args.includes('--check')
56
+ const profileDirArg = readFlag('--profile-dir') ?? readFlag('-p')
57
+
58
+ if (!profileDirArg) {
59
+ console.error('用法:node scripts/install-into-profile.mjs --profile-dir <profile 目录> [--check]')
60
+ process.exit(2)
61
+ }
62
+
63
+ const profileDir = path.resolve(profileDirArg.replace(/^~(?=$|\/)/, homedir()))
64
+ if (!existsSync(path.join(profileDir, 'package.json'))) {
65
+ console.error(`不是 profile 目录(缺少 package.json):${profileDir}`)
66
+ process.exit(2)
67
+ }
68
+
69
+ const report = []
70
+ const note = (line) => {
71
+ report.push(line)
72
+ console.log(line)
73
+ }
74
+
75
+ /* ── 1. 复制包文件 ── */
76
+ const targetDir = path.join(profileDir, 'node_modules', PACKAGE_NAME)
77
+ if (!checkOnly) {
78
+ rmSync(targetDir, { recursive: true, force: true })
79
+ mkdirSync(targetDir, { recursive: true })
80
+ const missing = []
81
+ for (const entry of FILES) {
82
+ if (entry.includes('*')) {
83
+ const dir = path.dirname(entry)
84
+ const base = path.join(packageRoot, dir)
85
+ if (!existsSync(base)) continue
86
+ for (const name of require('node:fs').readdirSync(base)) {
87
+ const rel = path.join(dir, name)
88
+ const to = path.join(targetDir, rel)
89
+ mkdirSync(path.dirname(to), { recursive: true })
90
+ cpSync(path.join(packageRoot, rel), to)
91
+ }
92
+ continue
93
+ }
94
+ const from = path.join(packageRoot, entry)
95
+ if (!existsSync(from)) {
96
+ missing.push(entry)
97
+ continue
98
+ }
99
+ const to = path.join(targetDir, entry)
100
+ mkdirSync(path.dirname(to), { recursive: true })
101
+ cpSync(from, to)
102
+ }
103
+ note(`[1/3] 已复制包文件 → ${targetDir}${missing.length ? `(缺失:${missing.join(', ')})` : ''}`)
104
+ } else {
105
+ note(`[1/3] 跳过复制(--check):目标 ${targetDir}${existsSync(targetDir) ? ' 已存在' : ' 不存在'}`)
106
+ }
107
+
108
+ /* ── 2. package.json:dependencies + dsh.profile.bundles ── */
109
+ const profileJsonPath = path.join(profileDir, 'package.json')
110
+ const profileJsonText = readFileSync(profileJsonPath, 'utf8')
111
+ const profileJson = JSON.parse(profileJsonText)
112
+ const indentMatch = /^([ \t]+)"/m.exec(profileJsonText)
113
+ const indent = indentMatch ? indentMatch[1] : ' '
114
+ const dependencySpec = `file:${packageRoot.replace(/\\/g, '/')}`
115
+ profileJson.dependencies ??= {}
116
+ profileJson.dsh ??= {}
117
+ profileJson.dsh.profile ??= {}
118
+ profileJson.dsh.profile.bundles ??= []
119
+
120
+ const depChanged = profileJson.dependencies[PACKAGE_NAME] !== dependencySpec
121
+ const bundleChanged = !profileJson.dsh.profile.bundles.includes(PACKAGE_NAME)
122
+ profileJson.dependencies[PACKAGE_NAME] = dependencySpec
123
+ if (bundleChanged) profileJson.dsh.profile.bundles.push(PACKAGE_NAME)
124
+
125
+ if (!checkOnly && (depChanged || bundleChanged)) {
126
+ // 改前留一份带时间戳的备份(profile 可能被 plugin manager 同时管理)
127
+ const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
128
+ writeFileSync(`${profileJsonPath}.bak-tlrp-${stamp}`, profileJsonText, 'utf8')
129
+ writeFileSync(profileJsonPath, `${JSON.stringify(profileJson, null, indent)}\n`, 'utf8')
130
+ }
131
+ note(
132
+ `[2/3] package.json ${depChanged || bundleChanged ? '已更新' : '无需改动'}:dependencies["${PACKAGE_NAME}"]="${dependencySpec}",bundles ${bundleChanged ? '已追加' : '已包含'}`,
133
+ )
134
+
135
+ /* ── 3. cordis.patch.yml:追加 - id: tlrp ── */
136
+ const patchPath = path.join(profileDir, 'cordis.patch.yml')
137
+ let patchText = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : ''
138
+ const alreadyPatched = new RegExp(`^- id:\\s*${ENTRY_ID}\\s*$`, 'm').test(patchText)
139
+
140
+ const block = [
141
+ '',
142
+ `# ── ${ENTRY_ID}:双态路由与角色扮演核心(install-into-profile.mjs 写入) ──`,
143
+ '# 行 id 必须是 tlrp:它同时是设置命名空间(settings.mutate("tlrp", ops))与',
144
+ '# 插件配置 activeProfileEntryId 的默认值。',
145
+ `- id: ${ENTRY_ID}`,
146
+ ' config:',
147
+ ' mode: auto',
148
+ ' activeCharacterId: firefly',
149
+ ' enableStatusFooter: true',
150
+ ' blockShellToolsInRp: true',
151
+ ' timeZone: Asia/Shanghai',
152
+ ' coreSectionOrder: 10150',
153
+ ' contextOrder: 300',
154
+ '',
155
+ ].join('\n')
156
+
157
+ if (!checkOnly && !alreadyPatched) {
158
+ if (patchText.length > 0 && !patchText.endsWith('\n')) patchText += '\n'
159
+ writeFileSync(patchPath, patchText + block, 'utf8')
160
+ }
161
+ note(`[3/3] cordis.patch.yml ${alreadyPatched ? '已包含 tlrp 行(未改动)' : checkOnly ? '需要追加 tlrp 行' : '已追加 tlrp 行'}`)
162
+
163
+ console.log('')
164
+ console.log(`profile:${profileDir}`)
165
+ console.log(`条目 id:${ENTRY_ID}(设置命名空间同名)`)
166
+ console.log('下一步:重启该 profile 的宿主进程(插件 JS 不做热更新),然后:')
167
+ console.log(` dsh --profile <name> --dump-config | grep -A 3 "${ENTRY_ID}"`)