dsh-router-core 0.3.11 → 0.3.12

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,143 @@
1
+ /**
2
+ * 扩展 (Ext) 页 —— 列出所有注册进 `router.ext` 的扩展插件,每个一个开关。
3
+ *
4
+ * 数据来自 `/router/api/ext`:核心把自己存的开关(`ext.json`)+ 扩展器报的
5
+ * 运行时事实(`ready` / `detail`)合并后给面板。扩展器只实现具体能力(如 RTK),
6
+ * 开关与渲染都在核心。
7
+ *
8
+ * 布局贴近 EndpointTab 的「Require API key」开关行:每个扩展器一张卡片。
9
+ * 未装任何扩展 → 空状态提示。
10
+ */
11
+ import { useEffect, useRef, useState } from 'react'
12
+ import { ROUTER_API_BASE, type RouterExtItem, type RouterExtResponse } from '../shared.ts'
13
+
14
+ function Icon({ d, size = 18 }: { d: string; size?: number }): JSX.Element {
15
+ return (
16
+ <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
17
+ <path d={d} />
18
+ </svg>
19
+ )
20
+ }
21
+
22
+ const I = {
23
+ bolt: 'M13 2L4 14h6l-1 8 9-12h-6l1-8z',
24
+ puzzle: 'M4 7h3a2 2 0 0 1 4 0h5a1 1 0 0 1 1 1v4a2 2 0 0 0 0 4v4H8v-3a2 2 0 0 1 0-4H4V7z',
25
+ }
26
+
27
+ export function ExtTab(): JSX.Element {
28
+ const [items, setItems] = useState<RouterExtItem[]>([])
29
+ const [loaded, setLoaded] = useState(false)
30
+ const [error, setError] = useState('')
31
+ const mounted = useRef(true)
32
+
33
+ useEffect(() => {
34
+ mounted.current = true
35
+ void load()
36
+ return () => { mounted.current = false }
37
+ }, [])
38
+
39
+ const load = async (): Promise<void> => {
40
+ try {
41
+ const response = await fetch(`${ROUTER_API_BASE}/ext`, { cache: 'no-store' })
42
+ const data = await response.json() as RouterExtResponse
43
+ if (mounted.current) {
44
+ setError(data.ok ? '' : (data.error ?? '加载扩展失败'))
45
+ setItems(data.enhancers ?? [])
46
+ setLoaded(true)
47
+ }
48
+ } catch (err) {
49
+ if (mounted.current) {
50
+ setError((err as Error).message)
51
+ setLoaded(true)
52
+ }
53
+ }
54
+ }
55
+
56
+ const toggle = async (item: RouterExtItem, value: boolean): Promise<void> => {
57
+ // 乐观更新
58
+ setItems(prev => prev.map(i => i.id === item.id ? { ...i, enabled: value } : i))
59
+ setError('')
60
+ try {
61
+ const response = await fetch(`${ROUTER_API_BASE}/ext`, {
62
+ method: 'PATCH',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify({ id: item.id, enabled: value }),
65
+ cache: 'no-store',
66
+ })
67
+ const data = await response.json() as RouterExtResponse
68
+ if (mounted.current && data.enhancers) {
69
+ setItems(data.enhancers)
70
+ } else if (mounted.current && !data.ok) {
71
+ setError(data.error ?? '切换失败')
72
+ setItems(prev => prev.map(i => i.id === item.id ? { ...i, enabled: !value } : i))
73
+ }
74
+ } catch (err) {
75
+ if (mounted.current) {
76
+ setError((err as Error).message)
77
+ setItems(prev => prev.map(i => i.id === item.id ? { ...i, enabled: !value } : i))
78
+ }
79
+ }
80
+ }
81
+
82
+ return (
83
+ <div className="dshr-tabBody">
84
+ {error !== '' && (
85
+ <div className="dshr-alert"><strong>出错了</strong><span>{error}</span></div>
86
+ )}
87
+
88
+ {!loaded ? (
89
+ <div className="dshr-empty">加载中…</div>
90
+ ) : items.length === 0 ? (
91
+ <div className="dshr-keyEmpty">
92
+ <span className="dshr-keyEmptyIcon"><Icon d={I.puzzle} size={30} /></span>
93
+ <p className="dshr-keyEmptyTitle">暂无扩展</p>
94
+ <p className="dshr-keyEmptyDesc">
95
+ 安装扩展插件(如 <code>dsh-router-ext-rtk</code>)后,这里会出现开关
96
+ </p>
97
+ </div>
98
+ ) : (
99
+ items.map(item => {
100
+ const notReady = item.ready === false
101
+ return (
102
+ <section key={item.id} className="dshr-card">
103
+ <div className="dshr-cardHead">
104
+ <span className="dshr-cardIcon"><Icon d={I.bolt} /></span>
105
+ <div className="dshr-cardTitle">{item.name}</div>
106
+ {/* 开关放标题行最右端:.dshr-cardAction 是现成的
107
+ margin-left:auto(EndpointTab 的「创建 Key」就靠它),复用不新增。
108
+ 不可用时禁开——禁了就无从开启成功。 */}
109
+ <button
110
+ type="button"
111
+ className={`dshr-toggle dshr-cardAction ${item.enabled ? 'dshr-toggle-on' : ''}`}
112
+ role="switch"
113
+ aria-checked={!!item.enabled}
114
+ disabled={notReady && !item.enabled}
115
+ onClick={() => void toggle(item, !item.enabled)}
116
+ title={notReady && !item.enabled
117
+ ? '当前不可用,无法开启'
118
+ : item.enabled ? `关闭 ${item.name}` : `开启 ${item.name}`}
119
+ >
120
+ <span className="dshr-toggleKnob" />
121
+ </button>
122
+ </div>
123
+
124
+ {/* 内容区:说明 + 未就绪红字(左对齐、与卡片图标对齐) */}
125
+ <div className="dshr-extBody">
126
+ {item.description !== undefined && item.description !== '' && (
127
+ <div className="dshr-muted">{item.description}</div>
128
+ )}
129
+ {notReady && (
130
+ <div className="dshr-ext-error">
131
+ {item.detail !== undefined && item.detail !== ''
132
+ ? item.detail
133
+ : '未就绪,无法开启'}
134
+ </div>
135
+ )}
136
+ </div>
137
+ </section>
138
+ )
139
+ })
140
+ )}
141
+ </div>
142
+ )
143
+ }
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * 路由系统 (Routing System) panel — a simplified 9router.
3
3
  *
4
- * Four tabs fed by the host half (/router/api/*), mirroring 9router's
4
+ * Five tabs fed by the host half (/router/api/*), mirroring 9router's
5
5
  * dashboard: 概览 (usage), 供应商 (supplier cards → detail: accounts +
6
6
  * add-account + models), 组合 (combo fallback chains),
7
+ * 扩展 (extension plugins — one toggle each, self-checked),
7
8
  * 端点与密钥 (endpoint URL + API keys + auth).
8
9
  *
9
10
  * **挂载在「设置」里**(`settings.section` 座位),所以布局规则跟中心栏劫持时期不同:
@@ -27,8 +28,9 @@ import { SupplierDetail } from './SupplierDetail.tsx'
27
28
  import { EndpointTab } from './EndpointTab.tsx'
28
29
  import { CombosTab } from './CombosTab.tsx'
29
30
  import { StatsTab } from './StatsTab.tsx'
31
+ import { ExtTab } from './ExtTab.tsx'
30
32
 
31
- type TabId = 'overview' | 'suppliers' | 'combos' | 'endpoint'
33
+ type TabId = 'overview' | 'suppliers' | 'combos' | 'endpoint' | 'ext'
32
34
 
33
35
  interface Snapshot {
34
36
  health: RouterHealthResponse | null
@@ -142,6 +144,9 @@ export function RouterView({ onBack }: RouterViewProps): JSX.Element {
142
144
  <button type="button" role="tab" aria-selected={tab === 'combos'} className={`dshr-tab${tab === 'combos' ? ' dshr-tab-on' : ''}`} onClick={() => setTab('combos')}>
143
145
  组合 <span className="dshr-navCount">{combos.length}</span>
144
146
  </button>
147
+ <button type="button" role="tab" aria-selected={tab === 'ext'} className={`dshr-tab${tab === 'ext' ? ' dshr-tab-on' : ''}`} onClick={() => setTab('ext')}>
148
+ 扩展
149
+ </button>
145
150
  <button type="button" role="tab" aria-selected={tab === 'endpoint'} className={`dshr-tab${tab === 'endpoint' ? ' dshr-tab-on' : ''}`} onClick={() => setTab('endpoint')}>
146
151
  端点与密钥
147
152
  </button>
@@ -205,6 +210,9 @@ export function RouterView({ onBack }: RouterViewProps): JSX.Element {
205
210
 
206
211
  {/* ---------------- 端点与密钥 ---------------- */}
207
212
  {tab === 'endpoint' && <EndpointTab />}
213
+
214
+ {/* ---------------- 增强 (扩展插件) ---------------- */}
215
+ {tab === 'ext' && <ExtTab />}
208
216
  </div>
209
217
  </div>
210
218
  )
@@ -910,6 +910,25 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-
910
910
 
911
911
  .dshr-toggle-on .dshr-toggleKnob { transform: translateX(14px); }
912
912
 
913
+ /* 开关禁用态:不可用(未就绪)且未开启时,灰显 + 不可点 */
914
+ .dshr-toggle:disabled {
915
+ cursor: default;
916
+ opacity: .45;
917
+ }
918
+
919
+ /* 扩展页:未就绪问题描述(红字) */
920
+ .dshr-ext-error {
921
+ color: var(--rs-danger);
922
+ font-size: 11px;
923
+ margin-top: 4px;
924
+ }
925
+
926
+ /* 扩展卡片内容区:文字左对齐,左侧留白与标题行的图标位对齐 */
927
+ .dshr-extBody {
928
+ padding: 10px 14px 12px 48px;
929
+ text-align: left;
930
+ }
931
+
913
932
  /* 操作按钮组 */
914
933
  .dshr-ops {
915
934
  display: flex;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * 扩展 (Ext) 通用契约 —— dsh-router 的「扩展插件」扩展点(不是供应商)。
3
+ *
4
+ * 分工(2026-09 重构:核心只做管理,不做拦截):
5
+ * - **dsh-router 核心 = 管理面**:持有 `router.ext` 注册表(发现)、渲染面板与
6
+ * `/router/api/ext`(展示)、代存开关与插件数据(`router.extStore` →
7
+ * `<dataDir>/ext.json`)。核心**不挂 `tools/execute`、不裁决、不改写**。
8
+ * - **扩展插件 = 执行面**:自己挂监听、自己问 `isEnabled`、自己按 ready 裁决、
9
+ * 自己短路执行。怎么改一条命令是插件的**私事**,不在本契约里。
10
+ *
11
+ * 为什么把拦截从核心挪走:
12
+ * `tools/execute` 是任何插件都能自己挂的 around-dispatch waterfall(插件 ctx 不在
13
+ * agent scope 下就能收到全部派发)。核心代挂只在一个消费者(rtk)时是纯亏损:为留
14
+ * 40 行委派逻辑,付了共享表 + inject 广播 + 契约两处同步的代价。核心归位成管理面
15
+ * 后,扩展插件与 dsh-router 只在**注册表 + 存储**两处耦合。
16
+ *
17
+ * ponytail: 天花板 —— 核心代挂原本顺带解决「多个扩展插件各自挂监听会互相踩 / 顺序
18
+ * 不可控」。这个保护现在没了;目前只有 rtk 一家消费,无所谓。**第二家 ext 出现时
19
+ * 要重新收敛顺序**(升级路径:核心暴露一个按顺序委派的共享工具方法,而不是收回拦截)。
20
+ *
21
+ * 契约要点:
22
+ * - 扩展器只有**声明**(id / name / description)+ `getState()`;核心拿这些渲染面板。
23
+ * - `getState()` 只报**运行时事实**(ready 是否可用、detail 说明),不报开关。
24
+ * - **开关与数据归核心持久化**(`router.extStore` → `<dataDir>/ext.json`),插件不自己
25
+ * file IO —— 落盘位置由核心锚定(不跟 cwd 跑),读写经核心,插件无状态。
26
+ */
27
+ import type { Context } from '@deepseek-ai/cordis'
28
+
29
+ /** 扩展器唯一 id(注册键)。 */
30
+ export type ExtId = string
31
+
32
+ /**
33
+ * 扩展器当前状态(**运行时事实**,由插件现算,不持久化)。
34
+ *
35
+ * 注意:`enabled` 不在这里 —— 开关状态归核心持久化(`<dataDir>/ext.json`),
36
+ * 插件只负责做事 + 报自己是否可用。核心把两者合并后再给面板用。
37
+ */
38
+ export interface ExtState {
39
+ /** 运行时是否就绪(如 RTK 二进制是否可用);false 时即使开启也不生效。 */
40
+ ready: boolean
41
+ /** 不就绪时的一句话说明(如「本机未装 rtk」),面板红字显示。 */
42
+ detail?: string
43
+ }
44
+
45
+ /**
46
+ * 注册到 `router.ext` 表的扩展器实现。核心(面板/API)只认这几个成员,
47
+ * 插件可以携带更多内部状态。
48
+ */
49
+ export interface RouterExt {
50
+ /** 扩展器 id,也是 `router.ext` 表里的注册键。 */
51
+ readonly id: ExtId
52
+ /** 面板显示名(如「RTK」)。 */
53
+ readonly name: string
54
+ /** 面板副标题说明。 */
55
+ readonly description?: string
56
+ /** 当前运行时状态(ready + 不就绪时的说明)。 */
57
+ getState(): ExtState
58
+ /** 卸载清理(表删除时由 dsh-router 调用,可选)。 */
59
+ dispose?(): void
60
+ }
61
+
62
+ /** 面板/API 用的一条扩展器信息 = 核心存的开关 + 插件报的运行时事实。 */
63
+ export interface ExtInfo {
64
+ id: string
65
+ name: string
66
+ description?: string
67
+ /** 核心持久化的开关(默认关)。 */
68
+ enabled: boolean
69
+ /** 插件报的运行时就绪状态。 */
70
+ ready: boolean
71
+ /** 不就绪时的说明(面板红字)。 */
72
+ detail?: string
73
+ }
74
+
75
+ /** `router.ext` 共享聚合表:`{ [extId]: RouterExt }`(同一 live 对象可追加)。 */
76
+ export interface RouterExtService {
77
+ [extId: string]: RouterExt
78
+ }
79
+
80
+ /**
81
+ * 扩展的存储面(核心 provide,插件 inject)。
82
+ *
83
+ * 插件**不自己 file IO**:落盘位置由核心用 `dataDirOf(ctx.baseUrl)` 锚定(不跟
84
+ * process.cwd() 跑),读写都经核心,同一文件、同一次原子写。
85
+ *
86
+ * 落盘形状:`<dataDir>/ext.json` = `{ "<id>": { "enabled": boolean, "data": unknown } }`
87
+ */
88
+ export interface ExtStoreService {
89
+ /** 是否开启(未记录过 = 默认关)。 */
90
+ isEnabled(id: string): boolean
91
+ /** 置开关(面板/API 调)。 */
92
+ setEnabled(id: string, enabled: boolean): void
93
+ /** 读插件自己的数据块(没有 = undefined)。 */
94
+ readData<T = unknown>(id: string): T | undefined
95
+ /** 写插件自己的数据块(覆盖;与 enabled 同文件原子落盘)。 */
96
+ writeData(id: string, value: unknown): void
97
+ }
98
+
99
+ /** 读取当前 router.ext 表(同一 live 对象,可追加)——扩展插件用它注册自己。 */
100
+ export function currentExts(ctx: Context): RouterExtService | undefined {
101
+ const c = ctx as unknown as {
102
+ get?: (key: string) => unknown
103
+ router?: { ext?: RouterExtService }
104
+ }
105
+ return (c.get?.('router.ext') ?? c.router?.ext) as RouterExtService | undefined
106
+ }
107
+
108
+ /** 读取扩展存储服务——扩展插件用它读开关、读写自己存的数据。 */
109
+ export function currentExtStore(ctx: Context): ExtStoreService | undefined {
110
+ const c = ctx as unknown as {
111
+ get?: (key: string) => unknown
112
+ router?: { extStore?: ExtStoreService }
113
+ }
114
+ return (c.get?.('router.extStore') ?? c.router?.extStore) as ExtStoreService | undefined
115
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * 扩展开关存储测试 —— 开关状态归核心持久化(`<dataDir>/ext.json`)。
3
+ *
4
+ * 锁定的契约:
5
+ * - 默认关(没记录过 = 关)
6
+ * - 开启 → 落盘,新实例(模拟重启)读到
7
+ * - 按 id 隔离,互不影响
8
+ * - 坏数据读盘不炸
9
+ * - 重复设同值不写盘
10
+ */
11
+ import test from 'node:test'
12
+ import assert from 'node:assert/strict'
13
+ import { mkdtempSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
14
+ import { tmpdir } from 'node:os'
15
+ import { join } from 'node:path'
16
+ import { ExtStore } from './store.ts'
17
+
18
+ /** 每个用例一个独立数据目录(stateFile 推导出 ext.json)。 */
19
+ function tempStore(preload?: unknown): { store: ExtStore; file: string } {
20
+ const dir = mkdtempSync(join(tmpdir(), 'dshr-ext-'))
21
+ const stateFile = join(dir, 'state.json')
22
+ if (preload !== undefined) writeFileSync(join(dir, 'ext.json'), JSON.stringify(preload))
23
+ return { store: new ExtStore(stateFile), file: join(dir, 'ext.json') }
24
+ }
25
+
26
+ test('默认关', () => {
27
+ const { store } = tempStore()
28
+ assert.equal(store.isEnabled('rtk'), false)
29
+ })
30
+
31
+ test('开启 → 落盘,重开还在', () => {
32
+ const { store, file } = tempStore()
33
+ store.setEnabled('rtk', true)
34
+ const raw = JSON.parse(readFileSync(file, 'utf8')) as { rtk?: { enabled: boolean } }
35
+ assert.equal(raw.rtk?.enabled, true)
36
+ // 新实例 = 模拟重启
37
+ assert.equal(new ExtStore(join(file, '..', 'state.json')).isEnabled('rtk'), true)
38
+ })
39
+
40
+ test('关回去 → 落盘 false', () => {
41
+ const { store, file } = tempStore()
42
+ store.setEnabled('rtk', true)
43
+ store.setEnabled('rtk', false)
44
+ assert.equal(new ExtStore(join(file, '..', 'state.json')).isEnabled('rtk'), false)
45
+ })
46
+
47
+ test('按 id 隔离,互不影响', () => {
48
+ const { store } = tempStore()
49
+ store.setEnabled('rtk', true)
50
+ assert.equal(store.isEnabled('rtk'), true)
51
+ assert.equal(store.isEnabled('other'), false)
52
+ })
53
+
54
+ test('坏数据读盘不炸,当没配置过', () => {
55
+ const { store: s1 } = tempStore({ rtk: { enabled: 'yes' } })
56
+ assert.equal(s1.isEnabled('rtk'), false)
57
+ const { store: s2 } = tempStore({ rtk: null })
58
+ assert.equal(s2.isEnabled('rtk'), false)
59
+ const { store: s3 } = tempStore([])
60
+ assert.equal(s3.isEnabled('rtk'), false)
61
+ })
62
+
63
+ /** 造一个带旧 enhance.json 的目录(模拟升级前的状态)。 */
64
+ function legacyStore(enabled: boolean): { store: ExtStore; file: string } {
65
+ const dir = mkdtempSync(join(tmpdir(), 'dshr-ext-legacy-'))
66
+ writeFileSync(join(dir, 'enhance.json'), JSON.stringify({ enabled }))
67
+ const stateFile = join(dir, 'state.json')
68
+ return { store: new ExtStore(stateFile), file: join(dir, 'ext.json') }
69
+ }
70
+
71
+ test('迁移:旧 enhance.json 开着 → 新 ext.json 里 rtk 开', () => {
72
+ const { store, file } = legacyStore(true)
73
+ assert.equal(store.isEnabled('rtk'), true)
74
+ const raw = JSON.parse(readFileSync(file, 'utf8')) as { rtk?: { enabled: boolean } }
75
+ assert.equal(raw.rtk?.enabled, true)
76
+ })
77
+
78
+ test('迁移:旧文件关着 → 不迁移(保持默认关)', () => {
79
+ const { store, file } = legacyStore(false)
80
+ assert.equal(store.isEnabled('rtk'), false)
81
+ assert.equal(existsSync(file), false)
82
+ })
83
+
84
+ test('迁移:已有新配置时不覆盖', () => {
85
+ const { store: s1, file } = tempStore()
86
+ s1.setEnabled('rtk', false)
87
+ // 往同目录塞一个「开着」的旧文件,不应覆盖已经明确存过的新配置
88
+ writeFileSync(join(file, '..', 'enhance.json'), JSON.stringify({ enabled: true }))
89
+ const s2 = new ExtStore(join(file, '..', 'state.json'))
90
+ assert.equal(s2.isEnabled('rtk'), false)
91
+ })
92
+
93
+ test('空 id 不落盘(连文件都不建)', () => {
94
+ const { store, file } = tempStore()
95
+ store.setEnabled('', true)
96
+ assert.equal(store.isEnabled(''), false)
97
+ assert.equal(existsSync(file), false, '空 id 应直接拒绝,不创建文件')
98
+ })
99
+
100
+ // ---- data 槽:给插件的通用抽屉(2026-09 加) ----
101
+
102
+ test('data:没存过 → undefined;写后能读回', () => {
103
+ const { store } = tempStore()
104
+ assert.equal(store.readData('rtk'), undefined)
105
+ store.writeData('rtk', { hits: 3 })
106
+ assert.deepEqual(store.readData('rtk'), { hits: 3 })
107
+ })
108
+
109
+ test('data:落盘 + 重启还在(与 enabled 同一文件)', () => {
110
+ const { store, file } = tempStore()
111
+ store.setEnabled('rtk', true)
112
+ store.writeData('rtk', { hits: 3 })
113
+ const raw = JSON.parse(readFileSync(file, 'utf8')) as { rtk?: { enabled: boolean; data?: { hits: number } } }
114
+ assert.equal(raw.rtk?.enabled, true)
115
+ assert.deepEqual(raw.rtk?.data, { hits: 3 })
116
+ const revived = new ExtStore(join(file, '..', 'state.json'))
117
+ assert.equal(revived.isEnabled('rtk'), true)
118
+ assert.deepEqual(revived.readData('rtk'), { hits: 3 })
119
+ })
120
+
121
+ test('data:写 data 不冲掉 enabled,置 enabled 不冲掉 data', () => {
122
+ const { store, file } = tempStore()
123
+ store.setEnabled('rtk', true)
124
+ store.writeData('rtk', { hits: 1 })
125
+ assert.equal(store.isEnabled('rtk'), true, '写 data 不能把开关冲掉')
126
+ store.setEnabled('rtk', false)
127
+ assert.deepEqual(store.readData('rtk'), { hits: 1 }, '置开关不能把 data 冲掉')
128
+ const revived = new ExtStore(join(file, '..', 'state.json'))
129
+ assert.equal(revived.isEnabled('rtk'), false)
130
+ assert.deepEqual(revived.readData('rtk'), { hits: 1 })
131
+ })
132
+
133
+ test('data:按 id 隔离', () => {
134
+ const { store } = tempStore()
135
+ store.writeData('rtk', { hits: 1 })
136
+ assert.deepEqual(store.readData('other'), undefined)
137
+ })
138
+
139
+ test('data:空 id 不落盘', () => {
140
+ const { store, file } = tempStore()
141
+ store.writeData('', { x: 1 })
142
+ assert.equal(store.readData(''), undefined)
143
+ assert.equal(existsSync(file), false)
144
+ })
@@ -0,0 +1,125 @@
1
+ /**
2
+ * 扩展存储 —— **开关状态与插件数据都归核心持久化**,插件不自己 file IO。
3
+ *
4
+ * 落盘 `<dataDir>/ext.json`,按扩展器 id 存:
5
+ * `{ "rtk": { "enabled": true, "data": { ...插件自己的东西... } } }`
6
+ *
7
+ * 为什么归核心:跟 `supplier-config.json` 一个道理——开关是用户对**核心面板**的操作,
8
+ * 不该由扩展插件各自决定存哪、怎么存(否则每个插件长一份互相不一致的实现,核心也无
9
+ * 从统一裁决「能不能开」)。插件变薄:只报自己是否可用(`ready`)+ 做事。
10
+ *
11
+ * `data` 槽是给插件的通用抽屉(2026-09 加):插件不再为了存一点状态而自己开数据目录,
12
+ * 统一经 `router.extStore` service 读写,落盘位置仍由核心锚定(不跟 cwd 跑)。
13
+ *
14
+ * 写法与核心其他存储一致:先写 `.tmp` 再 `rename` 原子覆盖(避免写一半崩掉留坏文件),
15
+ * 权限 600;持久化失败不阻断。
16
+ */
17
+ import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
18
+ import { dirname, join } from 'node:path'
19
+
20
+ interface ExtFile {
21
+ [extId: string]: { enabled: boolean; data?: unknown }
22
+ }
23
+
24
+ interface Entry {
25
+ enabled: boolean
26
+ data: unknown
27
+ }
28
+
29
+ /** 扩展开关 + 插件数据存储。 */
30
+ export class ExtStore {
31
+ private fp = ''
32
+ private byId: Record<string, Entry> = {}
33
+
34
+ constructor(stateFile: string) {
35
+ this.fp = stateFile ? join(dirname(stateFile), 'ext.json') : ''
36
+ this.load()
37
+ // 一次性迁移:开关早期由扩展插件自己写在 `enhance.json`(单文件、无法按 id 区分),
38
+ // 现在归核心按 id 存 `ext.json`。把旧值搬过来,避免用户已开的开关掉回关。
39
+ // 旧文件保留不删(不可逆操作留给人工),迁移只发生在新文件还没有时。
40
+ this.migrateLegacy()
41
+ }
42
+
43
+ private migrateLegacy(): void {
44
+ if (this.fp === '') return
45
+ if (Object.keys(this.byId).length > 0) return // 已有新配置,不覆盖
46
+ const legacy = join(dirname(this.fp), 'enhance.json')
47
+ let enabled = false
48
+ try {
49
+ const f = JSON.parse(readFileSync(legacy, 'utf8')) as { enabled?: unknown }
50
+ enabled = f.enabled === true
51
+ } catch {
52
+ return // 没有旧文件 / 读不了 → 无需迁移
53
+ }
54
+ if (!enabled) return
55
+ // 旧格式只有一个开关,当时唯一的扩展器就是 rtk。
56
+ this.byId.rtk = { enabled: true, data: undefined }
57
+ this.save()
58
+ }
59
+
60
+ private load(): void {
61
+ if (this.fp === '') return
62
+ let raw: string
63
+ try {
64
+ raw = readFileSync(this.fp, 'utf8')
65
+ } catch {
66
+ return
67
+ }
68
+ try {
69
+ const f = JSON.parse(raw) as ExtFile
70
+ for (const [id, v] of Object.entries(f)) {
71
+ if (id === '' || v === null || typeof v !== 'object') continue
72
+ if (typeof v?.enabled !== 'boolean') continue
73
+ this.byId[id] = { enabled: v.enabled, data: v.data }
74
+ }
75
+ } catch {
76
+ // 损坏则用默认关
77
+ }
78
+ }
79
+
80
+ private save(): void {
81
+ if (this.fp === '') return
82
+ try {
83
+ const dir = dirname(this.fp)
84
+ if (dir !== '' && dir !== '.') mkdirSync(dir, { recursive: true })
85
+ const out: ExtFile = {}
86
+ for (const [id, e] of Object.entries(this.byId)) {
87
+ // 没数据的不写 data 键,保持文件干净(旧格式兼容)。
88
+ out[id] = e.data === undefined ? { enabled: e.enabled } : { enabled: e.enabled, data: e.data }
89
+ }
90
+ const raw = JSON.stringify(out, null, 2)
91
+ const tmp = this.fp + '.tmp'
92
+ writeFileSync(tmp, raw, { mode: 0o600 })
93
+ renameSync(tmp, this.fp)
94
+ } catch {
95
+ // 持久化失败不阻断
96
+ }
97
+ }
98
+
99
+ /** 是否开启(未记录过 = 默认关)。 */
100
+ isEnabled(id: string): boolean {
101
+ return this.byId[id]?.enabled === true
102
+ }
103
+
104
+ setEnabled(id: string, enabled: boolean): void {
105
+ if (id === '') return
106
+ const cur = this.byId[id]
107
+ if (cur?.enabled === enabled) return
108
+ this.byId[id] = { enabled, data: cur?.data }
109
+ this.save()
110
+ }
111
+
112
+ /** 读插件自己存的数据块(没存过 = undefined)。 */
113
+ readData<T = unknown>(id: string): T | undefined {
114
+ if (id === '') return undefined
115
+ return this.byId[id]?.data as T | undefined
116
+ }
117
+
118
+ /** 写插件自己的数据块(覆盖;与 enabled 同一文件落盘)。 */
119
+ writeData(id: string, value: unknown): void {
120
+ if (id === '') return
121
+ const cur = this.byId[id]
122
+ this.byId[id] = { enabled: cur?.enabled === true, data: value }
123
+ this.save()
124
+ }
125
+ }