@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.4
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/CLA.md +79 -0
- package/LICENSE-COMMERCIAL +78 -0
- package/LICENSE-CORE +661 -0
- package/LICENSE-SDK +21 -0
- package/LICENSES/cordis-MIT.txt +39 -0
- package/NOTICE +95 -0
- package/README.md +158 -2
- package/client.js +7287 -0
- package/cordis.patch.yml +163 -0
- package/defaults/umbrella-rules.yaml +113 -0
- package/docs/CONTRACT-INTEROP.md +77 -0
- package/docs/CRASH-SAFETY.md +69 -0
- package/docs/DECISION-RELAY.md +46 -0
- package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
- package/docs/FORK-SEED.md +64 -0
- package/docs/MAIN-CITY-GATE.md +58 -0
- package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
- package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
- package/docs/PM-DECISION-POLICY.md +61 -0
- package/docs/WORKFLOW-UI.md +86 -0
- package/icon.svg +8 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -4
- package/sdk/README.md +65 -0
- package/sdk/examples/status-badge/README.md +16 -0
- package/sdk/examples/status-badge/client.js +60 -0
- package/sdk/examples/status-badge/cordis.patch.yml +7 -0
- package/sdk/examples/status-badge/index.js +12 -0
- package/sdk/examples/status-badge/package.json +24 -0
- package/sdk/templates/plugin/README.md +36 -0
- package/sdk/templates/plugin/client.js +35 -0
- package/sdk/templates/plugin/cordis.patch.yml +8 -0
- package/sdk/templates/plugin/icon.svg +5 -0
- package/sdk/templates/plugin/index.js +23 -0
- package/sdk/templates/plugin/locale/en.json +6 -0
- package/sdk/templates/plugin/package.json +36 -0
- package/src/compose.mjs +67 -0
- package/src/contract/VENDOR.json +16 -0
- package/src/contract/client.js +93 -0
- package/src/contract/constants.js +94 -0
- package/src/contract/envelope.js +83 -0
- package/src/contract/index.js +20 -0
- package/src/contract/negotiate.js +106 -0
- package/src/contract/relay.js +223 -0
- package/src/contract/server.js +77 -0
- package/src/contract/transports.js +319 -0
- package/src/index.mjs +11 -0
- package/src/kernel/core.mjs +100 -0
- package/src/kernel/extensions.mjs +61 -0
- package/src/kernel/fsx.mjs +349 -0
- package/src/kernel/index.mjs +21 -0
- package/src/kernel/ledger.mjs +94 -0
- package/src/kernel/paths.mjs +182 -0
- package/src/kernel/role.mjs +68 -0
- package/src/kernel/rounds.mjs +172 -0
- package/src/kernel/schema.mjs +5 -0
- package/src/kernel/types.mjs +266 -0
- package/src/kernel/vendor/cosmokit.mjs +421 -0
- package/src/kernel/vendor/schemastery.mjs +638 -0
- package/src/lib/adapter-client.mjs +383 -0
- package/src/lib/adapter-protocol.mjs +243 -0
- package/src/lib/ai-secret-store.mjs +202 -0
- package/src/lib/boot-guard.mjs +186 -0
- package/src/lib/branch-store.mjs +84 -0
- package/src/lib/branch.mjs +74 -0
- package/src/lib/bridge-client.mjs +133 -0
- package/src/lib/broker-adapter.mjs +67 -0
- package/src/lib/broker.mjs +45 -0
- package/src/lib/business-producers.mjs +109 -0
- package/src/lib/command-cache.mjs +209 -0
- package/src/lib/compat.mjs +170 -0
- package/src/lib/composer-journal.mjs +128 -0
- package/src/lib/container/index.mjs +806 -0
- package/src/lib/decision-events.mjs +129 -0
- package/src/lib/decision-ingest-client.mjs +41 -0
- package/src/lib/decision-outbox.mjs +129 -0
- package/src/lib/decision-policy.mjs +52 -0
- package/src/lib/default-essence.mjs +61 -0
- package/src/lib/desktop-launch.mjs +138 -0
- package/src/lib/essence.mjs +1211 -0
- package/src/lib/fork-client.mjs +410 -0
- package/src/lib/fork-config-lock.mjs +166 -0
- package/src/lib/fork-seed.mjs +304 -0
- package/src/lib/fork-sessions.mjs +183 -0
- package/src/lib/fork-supervision-settings.mjs +58 -0
- package/src/lib/fork-supervisor.mjs +122 -0
- package/src/lib/fs-guard.mjs +28 -0
- package/src/lib/http-auth.mjs +162 -0
- package/src/lib/idle-audit.mjs +178 -0
- package/src/lib/interaction-poke.mjs +61 -0
- package/src/lib/main-gate.mjs +579 -0
- package/src/lib/mirror-etag.mjs +92 -0
- package/src/lib/mirror-official-source.mjs +93 -0
- package/src/lib/mirror.mjs +478 -0
- package/src/lib/mother-link.mjs +156 -0
- package/src/lib/mutex.mjs +31 -0
- package/src/lib/permissions.mjs +115 -0
- package/src/lib/pnpm-store.mjs +189 -0
- package/src/lib/preferences.mjs +212 -0
- package/src/lib/producer-contract.mjs +162 -0
- package/src/lib/reflow.mjs +41 -0
- package/src/lib/remote.mjs +408 -0
- package/src/lib/restart-contract.mjs +752 -0
- package/src/lib/roots.mjs +30 -0
- package/src/lib/rule-registry.mjs +1127 -0
- package/src/lib/runtime-capture.mjs +307 -0
- package/src/lib/semaphore.mjs +34 -0
- package/src/lib/session-bundle.mjs +138 -0
- package/src/lib/session-format.mjs +672 -0
- package/src/lib/sse-parser.mjs +102 -0
- package/src/lib/ssrf-guard.mjs +170 -0
- package/src/lib/status-store.mjs +257 -0
- package/src/lib/task-handle.mjs +622 -0
- package/src/lib/ui-actions.mjs +55 -0
- package/src/lib/umbrella.mjs +1661 -0
- package/src/lib/upgrade-coordinator.mjs +107 -0
- package/src/lib/upgrade-status.mjs +43 -0
- package/src/plugins/adapter.mjs +1468 -0
- package/src/plugins/approver.mjs +1210 -0
- package/src/plugins/auditor.mjs +330 -0
- package/src/plugins/buffer-keeper.mjs +522 -0
- package/src/plugins/decision-center.mjs +2581 -0
- package/src/plugins/forker.mjs +1610 -0
- package/src/plugins/healer.mjs +702 -0
- package/src/plugins/hot-reloader.mjs +274 -0
- package/src/plugins/imports.mjs +247 -0
- package/src/plugins/orchestrator.mjs +3938 -0
- package/src/plugins/restart-guard.mjs +1022 -0
- package/src/plugins/router.mjs +714 -0
- package/src/plugins/scorer.mjs +661 -0
- package/src/plugins/session-bridge.mjs +166 -0
- package/src/plugins/session-catalog.mjs +771 -0
- package/src/plugins/session-router.mjs +125 -0
- package/src/plugins/session-sync.mjs +171 -0
- package/src/plugins/umbrella.mjs +419 -0
- package/tools/boot-guard.mjs +95 -0
- package/tools/decision-ingest.mjs +53 -0
- package/tools/desktop-launch.mjs +71 -0
- package/tools/fangctl.mjs +408 -0
- package/tools/gate/main-city.mjs +204 -0
- package/tools/mcp/server.mjs +121 -0
- package/tools/mcp/tools.mjs +65 -0
- package/tools/recover-queue.mjs +133 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
//
|
|
4
|
+
// ai-secret-store — AI advisor 的 API Key 持久化:只走 OS 钥匙串 / DPAPI,绝不落明文。
|
|
5
|
+
//
|
|
6
|
+
// 支持的 backend:
|
|
7
|
+
// * win32 : DPAPI(用户级)——PowerShell 调 ProtectedData.Protect/Unprotect,secret 走 stdin
|
|
8
|
+
// * linux : secret-tool(Secret Service / libsecret)——secret 走 stdin
|
|
9
|
+
// * darwin : 暂未实现(明确返回 unavailable,不假装成功)
|
|
10
|
+
// * 其它 : unavailable
|
|
11
|
+
//
|
|
12
|
+
// 磁盘上只存:
|
|
13
|
+
// <dataDir>/decision-ai-secret.json { version, backend, provider, model, baseUrl, updatedAt }
|
|
14
|
+
// <dataDir>/decision-ai-secret.enc backend 加密后的 blob(DPAPI 是 base64 密文;secret-tool 不落盘)
|
|
15
|
+
// 若 backend 不可用 => available=false,调用方必须退回「仅内存」,绝不写明文。
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { spawnSync } from 'node:child_process'
|
|
19
|
+
|
|
20
|
+
import { ensureDir, writeJsonAtomic } from '../kernel/index.mjs'
|
|
21
|
+
|
|
22
|
+
export const SECRET_META_FILE = 'decision-ai-secret.json'
|
|
23
|
+
export const SECRET_BLOB_FILE = 'decision-ai-secret.enc'
|
|
24
|
+
export const DEFAULT_SERVICE = 'dsh-fang-decision-ai'
|
|
25
|
+
|
|
26
|
+
const PS_PROTECT = [
|
|
27
|
+
'Add-Type -AssemblyName System.Security',
|
|
28
|
+
'$b64=[Console]::In.ReadToEnd()',
|
|
29
|
+
'$bytes=[Convert]::FromBase64String($b64)',
|
|
30
|
+
'$enc=[System.Security.Cryptography.ProtectedData]::Protect($bytes,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser)',
|
|
31
|
+
'[Console]::Out.Write([Convert]::ToBase64String($enc))',
|
|
32
|
+
].join('; ')
|
|
33
|
+
|
|
34
|
+
const PS_UNPROTECT = [
|
|
35
|
+
'Add-Type -AssemblyName System.Security',
|
|
36
|
+
'$b64=[Console]::In.ReadToEnd()',
|
|
37
|
+
'$bytes=[Convert]::FromBase64String($b64)',
|
|
38
|
+
'$raw=[System.Security.Cryptography.ProtectedData]::Unprotect($bytes,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser)',
|
|
39
|
+
'[Console]::Out.Write([Convert]::ToBase64String($raw))',
|
|
40
|
+
].join('; ')
|
|
41
|
+
|
|
42
|
+
/** 默认 runner:同步执行,stdin 传 secret,stdout 回结果;不把 secret 放进 argv。 */
|
|
43
|
+
export function defaultRunner(command, args, input = '') {
|
|
44
|
+
const result = spawnSync(command, args, { input, encoding: 'utf8', windowsHide: true, timeout: 15000 })
|
|
45
|
+
return {
|
|
46
|
+
ok: result.status === 0,
|
|
47
|
+
status: result.status ?? -1,
|
|
48
|
+
stdout: String(result.stdout ?? '').trim(),
|
|
49
|
+
stderr: String(result.stderr ?? '').trim(),
|
|
50
|
+
error: result.error ? String(result.error?.message ?? result.error) : '',
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function detectSecretBackend({ platform = process.platform, runner = defaultRunner } = {}) {
|
|
55
|
+
if (platform === 'win32') {
|
|
56
|
+
const probe = runner('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Add-Type -AssemblyName System.Security; "ok"'])
|
|
57
|
+
return probe.ok ? { backend: 'dpapi', available: true } : { backend: 'unavailable', available: false, reason: 'dpapi-probe-failed' }
|
|
58
|
+
}
|
|
59
|
+
if (platform === 'linux') {
|
|
60
|
+
const probe = runner('secret-tool', ['--version'])
|
|
61
|
+
return probe.ok ? { backend: 'secret-service', available: true } : { backend: 'unavailable', available: false, reason: 'secret-tool-missing' }
|
|
62
|
+
}
|
|
63
|
+
return { backend: 'unavailable', available: false, reason: platform === 'darwin' ? 'keychain-not-implemented' : 'platform-unsupported' }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {{dataDir:string, platform?:string, runner?:Function, service?:string, logger?:object}} options
|
|
68
|
+
*/
|
|
69
|
+
export function createAiSecretStore(options = {}) {
|
|
70
|
+
const dataDir = String(options.dataDir ?? '')
|
|
71
|
+
if (!dataDir) throw new Error('createAiSecretStore: dataDir required')
|
|
72
|
+
const platform = options.platform ?? process.platform
|
|
73
|
+
const runner = typeof options.runner === 'function' ? options.runner : defaultRunner
|
|
74
|
+
const service = String(options.service ?? DEFAULT_SERVICE)
|
|
75
|
+
const log = (level, message) => { try { options.logger?.[level]?.(`[ai-secret-store] ${message}`) } catch { /* ignore */ } }
|
|
76
|
+
const metaFile = path.join(dataDir, SECRET_META_FILE)
|
|
77
|
+
const blobFile = path.join(dataDir, SECRET_BLOB_FILE)
|
|
78
|
+
// 懒探测:只有真正需要持久化/读取能力时才执行后端探测(测试/未配置 AI 时零开销)。
|
|
79
|
+
let detection = null
|
|
80
|
+
const ensureBackend = () => {
|
|
81
|
+
if (detection === null) detection = detectSecretBackend({ platform, runner })
|
|
82
|
+
return detection
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const readJson = (file) => {
|
|
86
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null }
|
|
87
|
+
}
|
|
88
|
+
const removeIfExists = (file) => { try { if (fs.existsSync(file)) fs.unlinkSync(file) } catch { /* ignore */ } }
|
|
89
|
+
|
|
90
|
+
function encrypt(secretJson) {
|
|
91
|
+
const detection = ensureBackend()
|
|
92
|
+
const plain = Buffer.from(secretJson, 'utf8').toString('base64')
|
|
93
|
+
if (detection.backend === 'dpapi') {
|
|
94
|
+
const out = runner('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', PS_PROTECT], plain)
|
|
95
|
+
if (!out.ok || !out.stdout) return { ok: false, reason: 'dpapi-protect-failed', detail: out.stderr || out.error }
|
|
96
|
+
return { ok: true, payload: out.stdout }
|
|
97
|
+
}
|
|
98
|
+
if (detection.backend === 'secret-service') {
|
|
99
|
+
const out = runner('secret-tool', ['store', '--label', 'dsh-fang decision AI key', 'service', service, 'slot', 'api-key'], secretJson)
|
|
100
|
+
if (!out.ok) return { ok: false, reason: 'secret-tool-store-failed', detail: out.stderr || out.error }
|
|
101
|
+
return { ok: true, payload: '' }
|
|
102
|
+
}
|
|
103
|
+
return { ok: false, reason: 'secret-backend-unavailable' }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function decrypt(payload) {
|
|
107
|
+
const detection = ensureBackend()
|
|
108
|
+
if (detection.backend === 'dpapi') {
|
|
109
|
+
const out = runner('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', PS_UNPROTECT], String(payload ?? ''))
|
|
110
|
+
if (!out.ok || !out.stdout) return { ok: false, reason: 'dpapi-unprotect-failed', detail: out.stderr || out.error }
|
|
111
|
+
try { return { ok: true, secretJson: Buffer.from(out.stdout, 'base64').toString('utf8') } } catch { return { ok: false, reason: 'dpapi-decode-failed' } }
|
|
112
|
+
}
|
|
113
|
+
if (detection.backend === 'secret-service') {
|
|
114
|
+
const out = runner('secret-tool', ['lookup', 'service', service, 'slot', 'api-key'])
|
|
115
|
+
if (!out.ok || !out.stdout) return { ok: false, reason: 'secret-tool-lookup-failed', detail: out.stderr || out.error }
|
|
116
|
+
return { ok: true, secretJson: out.stdout }
|
|
117
|
+
}
|
|
118
|
+
return { ok: false, reason: 'secret-backend-unavailable' }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
get backend() { return ensureBackend().backend },
|
|
123
|
+
get available() { return ensureBackend().available === true },
|
|
124
|
+
get reason() { return ensureBackend().reason ?? null },
|
|
125
|
+
service,
|
|
126
|
+
capabilities() {
|
|
127
|
+
const detection = ensureBackend()
|
|
128
|
+
return { backend: detection.backend, available: detection.available === true, reason: detection.reason ?? null, encryptedAtRest: detection.backend === 'dpapi' ? 'dpapi-user' : detection.backend === 'secret-service' ? 'os-secret-service' : 'none' }
|
|
129
|
+
},
|
|
130
|
+
/** 保存 { provider, model, baseUrl, apiKey };失败返回 ok:false,调用方保持仅内存。 */
|
|
131
|
+
save(config = {}) {
|
|
132
|
+
const detection = ensureBackend()
|
|
133
|
+
if (!detection.available) return { ok: false, reason: detection.reason ?? 'secret-backend-unavailable' }
|
|
134
|
+
const apiKey = String(config.apiKey ?? '').trim()
|
|
135
|
+
if (!apiKey) return { ok: false, reason: 'api-key-required' }
|
|
136
|
+
const meta = {
|
|
137
|
+
version: 1,
|
|
138
|
+
backend: detection.backend,
|
|
139
|
+
provider: String(config.provider ?? '').slice(0, 40),
|
|
140
|
+
model: String(config.model ?? '').slice(0, 120),
|
|
141
|
+
baseUrl: String(config.baseUrl ?? '').slice(0, 500),
|
|
142
|
+
updatedAt: Date.now(),
|
|
143
|
+
}
|
|
144
|
+
const encrypted = encrypt(JSON.stringify({
|
|
145
|
+
apiKey,
|
|
146
|
+
...(Array.isArray(config.profiles) ? { profiles: config.profiles } : {}),
|
|
147
|
+
...(config.routes && typeof config.routes === 'object' ? { routes: config.routes } : {}),
|
|
148
|
+
...(config.modelByLevel && typeof config.modelByLevel === 'object' ? { modelByLevel: config.modelByLevel } : {}),
|
|
149
|
+
}))
|
|
150
|
+
if (!encrypted.ok) return encrypted
|
|
151
|
+
try {
|
|
152
|
+
ensureDir(dataDir)
|
|
153
|
+
if (encrypted.payload) {
|
|
154
|
+
fs.writeFileSync(blobFile, encrypted.payload, { encoding: 'utf8', mode: 0o600 })
|
|
155
|
+
try { fs.chmodSync(blobFile, 0o600) } catch { /* windows */ }
|
|
156
|
+
}
|
|
157
|
+
writeJsonAtomic(metaFile, meta)
|
|
158
|
+
return { ok: true, backend: detection.backend, meta }
|
|
159
|
+
} catch (error) {
|
|
160
|
+
log('warn', `save failed: ${String(error?.message ?? error)}`)
|
|
161
|
+
return { ok: false, reason: 'persist-failed', detail: String(error?.message ?? error) }
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
/** 读取已持久化配置;不存在/backend 不匹配/解密失败 => null(不抛)。 */
|
|
165
|
+
load() {
|
|
166
|
+
// 没有落盘元数据时不要探测 backend(避免无 AI 配置的宿主每次启动都拉起 PowerShell)。
|
|
167
|
+
if (!fs.existsSync(metaFile)) return null
|
|
168
|
+
const detection = ensureBackend()
|
|
169
|
+
if (!detection.available) return null
|
|
170
|
+
const meta = readJson(metaFile)
|
|
171
|
+
if (!meta || meta.backend !== detection.backend) return null
|
|
172
|
+
let payload = ''
|
|
173
|
+
try { payload = fs.existsSync(blobFile) ? fs.readFileSync(blobFile, 'utf8') : '' } catch { payload = '' }
|
|
174
|
+
if (detection.backend === 'dpapi' && !payload) return null
|
|
175
|
+
const decrypted = decrypt(payload)
|
|
176
|
+
if (!decrypted.ok) { log('warn', `load failed: ${decrypted.reason}`); return null }
|
|
177
|
+
let secret = null
|
|
178
|
+
try { secret = JSON.parse(decrypted.secretJson) } catch { return null }
|
|
179
|
+
const apiKey = String(secret?.apiKey ?? '').trim()
|
|
180
|
+
if (!apiKey) return null
|
|
181
|
+
return {
|
|
182
|
+
provider: String(meta.provider ?? 'custom'),
|
|
183
|
+
model: String(meta.model ?? ''),
|
|
184
|
+
baseUrl: String(meta.baseUrl ?? ''),
|
|
185
|
+
apiKey,
|
|
186
|
+
...(Array.isArray(secret.profiles) ? { profiles: secret.profiles } : {}),
|
|
187
|
+
...(secret.routes && typeof secret.routes === 'object' ? { routes: secret.routes } : {}),
|
|
188
|
+
...(secret.modelByLevel && typeof secret.modelByLevel === 'object' ? { modelByLevel: secret.modelByLevel } : {}),
|
|
189
|
+
source: `os:${detection.backend}`,
|
|
190
|
+
backend: detection.backend,
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
clear() {
|
|
194
|
+
removeIfExists(metaFile)
|
|
195
|
+
removeIfExists(blobFile)
|
|
196
|
+
if (ensureBackend().backend === 'secret-service') {
|
|
197
|
+
try { runner('secret-tool', ['clear', 'service', service, 'slot', 'api-key']) } catch { /* ignore */ }
|
|
198
|
+
}
|
|
199
|
+
return { ok: true }
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
//
|
|
4
|
+
// boot-guard — 启动前/发布前插件树自检(fail-loud 之前拦住):
|
|
5
|
+
// * 解析 fang bundle 的 cordis.patch.yml 行(id/name);
|
|
6
|
+
// * 检查 id 重复、模块可解析、子进程 isolation import() 且导出 apply();
|
|
7
|
+
// * 可选:把失败行 disabled:true 合并进 profile 的 cordis.patch.yml(安全模式)。
|
|
8
|
+
// 原则:任何一项失败都只报告/禁用,不修改 DSH 核心文件;写 profile 前必须备份。
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { spawnSync } from 'node:child_process'
|
|
12
|
+
import { pathToFileURL } from 'node:url'
|
|
13
|
+
import { createRequire } from 'node:module'
|
|
14
|
+
|
|
15
|
+
import { parseYamlSubset } from './rule-registry.mjs'
|
|
16
|
+
|
|
17
|
+
export const FANG_PACKAGE_NAMES = Object.freeze(['@twinsearth/fang-dsh-plugin', '@twinsearth/dsh-fang', '@twinsearth/fang-dsh-plugin'])
|
|
18
|
+
export const SMOKE_TIMEOUT_MS = 8000
|
|
19
|
+
|
|
20
|
+
/** 找到 profile 里已安装的 fang 包目录(发布名优先,其次本地 link 名)。 */
|
|
21
|
+
export function findFangPackageDir(profileDir) {
|
|
22
|
+
for (const name of FANG_PACKAGE_NAMES) {
|
|
23
|
+
const candidate = path.join(String(profileDir ?? ''), 'node_modules', ...name.split('/'))
|
|
24
|
+
try { if (fs.statSync(candidate).isDirectory()) return candidate } catch { /* next */ }
|
|
25
|
+
}
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 解析 bundle patch → 行数组 [{id, name}]。 */
|
|
30
|
+
export function readBundleRows(pkgDir) {
|
|
31
|
+
const file = path.join(String(pkgDir ?? ''), 'cordis.patch.yml')
|
|
32
|
+
let text = ''
|
|
33
|
+
try { text = fs.readFileSync(file, 'utf8') } catch { return { ok: false, error: 'patch-missing', rows: [] } }
|
|
34
|
+
const parsed = parseYamlSubset(text)
|
|
35
|
+
const value = parsed?.value
|
|
36
|
+
const insert = Array.isArray(value) ? value.flatMap((item) => Array.isArray(item?.insert) ? item.insert : []) : (Array.isArray(value?.insert) ? value.insert : [])
|
|
37
|
+
const rows = insert.filter((row) => row && typeof row === 'object' && row.id).map((row) => ({
|
|
38
|
+
id: String(row.id),
|
|
39
|
+
name: row.name === undefined ? null : String(row.name),
|
|
40
|
+
disabled: row.disabled === true,
|
|
41
|
+
}))
|
|
42
|
+
return { ok: parsed.errors.length === 0, error: parsed.errors.length ? `patch-parse:${parsed.errors[0]}` : null, rows, parseErrors: parsed.errors }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 在隔离子进程 import 模块,确认可加载且导出 apply。 */
|
|
46
|
+
export function smokeModule(moduleUrl, { timeoutMs = SMOKE_TIMEOUT_MS } = {}) {
|
|
47
|
+
const script = `
|
|
48
|
+
const url = process.argv[1];
|
|
49
|
+
import(url).then((m) => {
|
|
50
|
+
process.stdout.write(JSON.stringify({ ok: typeof m.apply === 'function', hasApply: typeof m.apply === 'function', name: m.name ?? null }));
|
|
51
|
+
}).catch((e) => {
|
|
52
|
+
process.stdout.write(JSON.stringify({ ok: false, hasApply: false, error: String(e && e.message || e).slice(0, 500) }));
|
|
53
|
+
});
|
|
54
|
+
`
|
|
55
|
+
const result = spawnSync(process.execPath, ['--input-type=module', '-e', script, moduleUrl], {
|
|
56
|
+
encoding: 'utf8', timeout: timeoutMs, windowsHide: true,
|
|
57
|
+
})
|
|
58
|
+
if (result.error?.code === 'ETIMEDOUT' || result.signal) return { ok: false, error: 'timeout' }
|
|
59
|
+
const line = String(result.stdout ?? '').trim().split('\n').filter(Boolean).pop()
|
|
60
|
+
if (!line) return { ok: false, error: String(result.stderr ?? '').trim().slice(0, 500) || `exit=${result.status}` }
|
|
61
|
+
try { return JSON.parse(line) } catch { return { ok: false, error: line.slice(0, 500) } }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 读取已安装 fang 包的 package.json name(用于区分“本包行”和可选子插件行)。 */
|
|
65
|
+
export function readPackageName(pkgDir) {
|
|
66
|
+
try { return String(JSON.parse(fs.readFileSync(path.join(String(pkgDir ?? ''), 'package.json'), 'utf8')).name ?? '') } catch { return '' }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 读取 package 的入口(exports["."]/main/ src/index.mjs),用于更新候选包冒烟。 */
|
|
70
|
+
export function readPackageEntry(packageDir) {
|
|
71
|
+
const dir = String(packageDir ?? '')
|
|
72
|
+
try {
|
|
73
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
|
|
74
|
+
const exported = pkg?.exports?.['.']
|
|
75
|
+
const candidate = typeof exported === 'string'
|
|
76
|
+
? exported
|
|
77
|
+
: (exported && typeof exported === 'object' && typeof exported.default === 'string' ? exported.default : null)
|
|
78
|
+
const entry = candidate || pkg.main || './src/index.mjs'
|
|
79
|
+
return path.resolve(dir, entry)
|
|
80
|
+
} catch { return null }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 更新候选包冒烟:入口存在 + 隔离子进程 import + 导出 apply;失败即禁止转正。 */
|
|
84
|
+
export function smokePackage(packageDir, { timeoutMs = SMOKE_TIMEOUT_MS } = {}) {
|
|
85
|
+
const dir = String(packageDir ?? '')
|
|
86
|
+
const entry = readPackageEntry(dir)
|
|
87
|
+
if (!entry) return { ok: false, error: 'package-json-missing', packageDir: dir }
|
|
88
|
+
try { if (!fs.statSync(entry).isFile()) return { ok: false, error: 'entry-missing', entry } } catch { return { ok: false, error: 'entry-missing', entry } }
|
|
89
|
+
const result = smokeModule(pathToFileURL(entry).href, { timeoutMs })
|
|
90
|
+
return { ...result, entry, packageDir: dir }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 检查行:重复 id、模块解析、隔离 import 冒烟。
|
|
95
|
+
* 本包行失败 = block;可选子插件(如 @twinsearth/dsh-fang-web-entry)缺失/失败 = warn。
|
|
96
|
+
* @returns {{ok:boolean, findings:Array<{code,level,id?,detail?}>, checked:number}}
|
|
97
|
+
*/
|
|
98
|
+
export function checkBundleRows(rows, { profileDir, ownPackage = '', timeoutMs = SMOKE_TIMEOUT_MS } = {}) {
|
|
99
|
+
const findings = []
|
|
100
|
+
const seen = new Set()
|
|
101
|
+
let checked = 0
|
|
102
|
+
const requireFrom = createRequire(path.join(String(profileDir ?? ''), 'package.json'))
|
|
103
|
+
const isOwnRow = (name) => {
|
|
104
|
+
const own = String(ownPackage ?? '').trim()
|
|
105
|
+
if (!own) return true
|
|
106
|
+
return name === own || name.startsWith(`${own}/`)
|
|
107
|
+
}
|
|
108
|
+
for (const row of rows) {
|
|
109
|
+
if (seen.has(row.id)) {
|
|
110
|
+
findings.push({ code: 'duplicate-id', level: 'block', id: row.id, detail: row.id })
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
seen.add(row.id)
|
|
114
|
+
// v3.3.3:disabled 行不会加载;不因可选子插件未安装/未启用而阻塞启动。
|
|
115
|
+
if (row.disabled === true) continue
|
|
116
|
+
if (!row.name) {
|
|
117
|
+
findings.push({ code: 'row-name-missing', level: 'warn', id: row.id })
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
checked += 1
|
|
121
|
+
const optional = !isOwnRow(row.name)
|
|
122
|
+
let resolved = null
|
|
123
|
+
try { resolved = requireFrom.resolve(row.name) } catch (error) {
|
|
124
|
+
findings.push({
|
|
125
|
+
code: 'module-unresolved', level: optional ? 'warn' : 'block', id: row.id,
|
|
126
|
+
detail: `${row.name}: ${String(error?.message ?? error).slice(0, 200)}`,
|
|
127
|
+
})
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
const smoke = smokeModule(pathToFileURL(resolved).href, { timeoutMs })
|
|
131
|
+
if (!smoke.ok) {
|
|
132
|
+
findings.push({ code: 'module-smoke-failed', level: optional ? 'warn' : 'block', id: row.id, detail: String(smoke.error ?? 'no apply()').slice(0, 300) })
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { ok: findings.every((finding) => finding.level !== 'block'), findings, checked }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const jsonPatchEntries = (text) => {
|
|
139
|
+
const trimmed = String(text ?? '').trim()
|
|
140
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return null
|
|
141
|
+
try {
|
|
142
|
+
const value = JSON.parse(trimmed)
|
|
143
|
+
if (Array.isArray(value)) return value
|
|
144
|
+
if (value && typeof value === 'object' && Array.isArray(value.insert)) return value.insert
|
|
145
|
+
} catch { /* yaml */ }
|
|
146
|
+
return null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 把 disabled 行合并进 profile 的 cordis.patch.yml;先写 .bak-<ts> 备份。
|
|
151
|
+
* 只接受 id(禁用行);不会删除/改写已有行。
|
|
152
|
+
*/
|
|
153
|
+
export function applyDisabledRows(profileDir, ids) {
|
|
154
|
+
const dir = String(profileDir ?? '')
|
|
155
|
+
if (!dir) return { ok: false, error: 'profile-dir-required' }
|
|
156
|
+
const wanted = [...new Set((Array.isArray(ids) ? ids : []).map(String).filter(Boolean))]
|
|
157
|
+
if (!wanted.length) return { ok: false, error: 'no-ids' }
|
|
158
|
+
const file = path.join(dir, 'cordis.patch.yml')
|
|
159
|
+
let text = ''
|
|
160
|
+
try { text = fs.readFileSync(file, 'utf8') } catch { text = '' }
|
|
161
|
+
try { fs.mkdirSync(dir, { recursive: true }); if (text) fs.writeFileSync(`${file}.bak-${Date.now()}`, text, 'utf8') } catch { /* backup best-effort */ }
|
|
162
|
+
const asJson = jsonPatchEntries(text)
|
|
163
|
+
if (asJson !== null) {
|
|
164
|
+
const existing = new Set(asJson.map((entry) => entry && typeof entry === 'object' ? String(entry.id ?? '') : ''))
|
|
165
|
+
const merged = asJson.slice()
|
|
166
|
+
let added = 0
|
|
167
|
+
for (const id of wanted) {
|
|
168
|
+
if (existing.has(id)) continue
|
|
169
|
+
merged.push({ id, disabled: true })
|
|
170
|
+
added += 1
|
|
171
|
+
}
|
|
172
|
+
fs.writeFileSync(file, `${JSON.stringify(merged, null, 2)}\n`, 'utf8')
|
|
173
|
+
return { ok: true, added, file, format: 'json' }
|
|
174
|
+
}
|
|
175
|
+
const kept = String(text ?? '').replace(/^\uFEFF/, '').split(/\r?\n/).filter((line) => !/^\s*\[\s*\]\s*(#.*)?$/.test(line))
|
|
176
|
+
const body = kept.join('\n')
|
|
177
|
+
const lines = []
|
|
178
|
+
for (const id of wanted) {
|
|
179
|
+
if (new RegExp(`(^|\\n)\\s*-\\s*id:\\s*${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'm').test(body)) continue
|
|
180
|
+
lines.push(`- id: ${id}`, ' disabled: true')
|
|
181
|
+
}
|
|
182
|
+
if (!lines.length) return { ok: true, added: 0, file, format: 'yaml' }
|
|
183
|
+
const prefix = body.trim().length === 0 ? '' : (body.endsWith('\n') ? body : body + '\n')
|
|
184
|
+
fs.writeFileSync(file, `${prefix}${lines.join('\n')}\n`, 'utf8')
|
|
185
|
+
return { ok: true, added: wanted.length, file, format: 'yaml' }
|
|
186
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// v3.0.8 branch persistence: one JSON file per task under <dataDir>/tasks/<taskId>/branches.json.
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { activateBranch, createBranch, createSession, setBranchStatus } from './branch.mjs'
|
|
6
|
+
|
|
7
|
+
/** taskId is a path segment under <dataDir>/tasks; reject separators and traversal. */
|
|
8
|
+
const TASK_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
|
9
|
+
|
|
10
|
+
export class BranchStore {
|
|
11
|
+
constructor(dataDir) {
|
|
12
|
+
this.dataDir = dataDir
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
file(taskId) {
|
|
16
|
+
const id = String(taskId ?? '')
|
|
17
|
+
if (!TASK_ID_RE.test(id)) throw new Error('invalid-task-id')
|
|
18
|
+
return path.join(this.dataDir, 'tasks', id, 'branches.json')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
load(taskId) {
|
|
22
|
+
const file = this.file(taskId) // validate outside the read fallback
|
|
23
|
+
try {
|
|
24
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
25
|
+
return {
|
|
26
|
+
sessionId: raw.sessionId,
|
|
27
|
+
activeBranchId: raw.activeBranchId,
|
|
28
|
+
branches: new Map(Object.entries(raw.branches || {})),
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
return createSession(String(taskId))
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
save(taskId, session) {
|
|
36
|
+
const file = this.file(taskId)
|
|
37
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
38
|
+
const payload = {
|
|
39
|
+
sessionId: session.sessionId,
|
|
40
|
+
activeBranchId: session.activeBranchId,
|
|
41
|
+
branches: Object.fromEntries(session.branches),
|
|
42
|
+
}
|
|
43
|
+
const tmp = file + '.tmp'
|
|
44
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf8')
|
|
45
|
+
fs.renameSync(tmp, file)
|
|
46
|
+
return payload
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
list(taskId) {
|
|
50
|
+
const session = this.load(taskId)
|
|
51
|
+
return { ok: true, sessionId: session.sessionId, activeBranchId: session.activeBranchId, branches: [...session.branches.values()] }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
create(taskId, input) {
|
|
55
|
+
const session = this.load(taskId)
|
|
56
|
+
const branch = createBranch(session, input)
|
|
57
|
+
this.save(taskId, session)
|
|
58
|
+
return { ok: true, branch }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 把分支库的 sessionId 绑定到该分身真实会话(启动/还原时由单会话守卫调用)。 */
|
|
62
|
+
bindSession(taskId, sessionId) {
|
|
63
|
+
const session = this.load(taskId)
|
|
64
|
+
const next = String(sessionId ?? '').trim()
|
|
65
|
+
if (!next || session.sessionId === next) return { ok: true, sessionId: session.sessionId, changed: false }
|
|
66
|
+
session.sessionId = next
|
|
67
|
+
this.save(taskId, session)
|
|
68
|
+
return { ok: true, sessionId: session.sessionId, changed: true }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
activate(taskId, branchId) {
|
|
72
|
+
const session = this.load(taskId)
|
|
73
|
+
const branch = activateBranch(session, branchId)
|
|
74
|
+
this.save(taskId, session)
|
|
75
|
+
return { ok: true, branch, activeBranchId: session.activeBranchId }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setStatus(taskId, branchId, status) {
|
|
79
|
+
const session = this.load(taskId)
|
|
80
|
+
const branch = setBranchStatus(session, branchId, status)
|
|
81
|
+
this.save(taskId, session)
|
|
82
|
+
return { ok: true, branch }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// v3.0.8 branch model: one session, many branch logs, one writer per branch, no merge.
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
/** Branch/session ids become directory names; keep them to safe single segments. */
|
|
7
|
+
export const BRANCH_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
8
|
+
export const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
|
9
|
+
|
|
10
|
+
function assertBranchId(branchId) {
|
|
11
|
+
const id = String(branchId ?? '')
|
|
12
|
+
if (!BRANCH_ID_RE.test(id)) throw new Error('invalid-branch-id')
|
|
13
|
+
return id
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function assertSessionId(sessionId) {
|
|
17
|
+
const id = String(sessionId ?? '')
|
|
18
|
+
if (!SESSION_ID_RE.test(id)) throw new Error('invalid-session-id')
|
|
19
|
+
return id
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createSession(sessionId) {
|
|
23
|
+
const main = { branchId: 'main', parentBranchId: null, baseTurn: 0, status: 'active', createdAt: Date.now() }
|
|
24
|
+
return { sessionId, activeBranchId: 'main', branches: new Map([['main', main]]) }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createBranch(session, { branchId, parentBranchId = session.activeBranchId, baseTurn = 0, sessionId = null }) {
|
|
28
|
+
const id = assertBranchId(branchId)
|
|
29
|
+
if (id === 'main') throw new Error('invalid-branch-id')
|
|
30
|
+
if (session.branches.has(id)) throw new Error('branch-exists')
|
|
31
|
+
if (!session.branches.has(parentBranchId)) throw new Error('parent-missing')
|
|
32
|
+
session.branches.set(id, {
|
|
33
|
+
branchId: id, parentBranchId, baseTurn, status: 'active', createdAt: Date.now(),
|
|
34
|
+
sessionId: typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : null,
|
|
35
|
+
})
|
|
36
|
+
return session.branches.get(id)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function activateBranch(session, branchId) {
|
|
40
|
+
const id = assertBranchId(branchId)
|
|
41
|
+
const branch = session.branches.get(id)
|
|
42
|
+
if (!branch) throw new Error('branch-missing')
|
|
43
|
+
if (branch.status !== 'active') throw new Error('branch-not-active')
|
|
44
|
+
session.activeBranchId = id
|
|
45
|
+
return branch
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function setBranchStatus(session, branchId, status) {
|
|
49
|
+
const id = assertBranchId(branchId)
|
|
50
|
+
if (!['sealed', 'archived'].includes(status)) throw new Error('invalid-status')
|
|
51
|
+
const branch = session.branches.get(id)
|
|
52
|
+
if (!branch) throw new Error('branch-missing')
|
|
53
|
+
if (id === session.activeBranchId) throw new Error('cannot-seal-active')
|
|
54
|
+
branch.status = status
|
|
55
|
+
return branch
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function branchPath(root, sessionId, branchId) {
|
|
59
|
+
return path.join(root, assertSessionId(sessionId), 'branches', assertBranchId(branchId))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function acquireWriter(root, sessionId, branchId) {
|
|
63
|
+
const dir = branchPath(root, sessionId, branchId)
|
|
64
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
65
|
+
const lock = path.join(dir, 'writer.lock')
|
|
66
|
+
try {
|
|
67
|
+
fs.writeFileSync(lock, String(process.pid), { flag: 'wx' })
|
|
68
|
+
} catch {
|
|
69
|
+
const error = new Error('branch-busy')
|
|
70
|
+
error.code = '409'
|
|
71
|
+
throw error
|
|
72
|
+
}
|
|
73
|
+
return { release() { try { fs.rmSync(lock, { force: true }) } catch { /* ignore */ } } }
|
|
74
|
+
}
|