dsh-model 0.0.1 → 0.15.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 (83) hide show
  1. package/README.en.md +127 -0
  2. package/README.md +96 -43
  3. package/bin/dsh-model.js +8 -0
  4. package/dist/bridge/aggregate.js +91 -0
  5. package/dist/bridge/login.js +127 -0
  6. package/dist/bridge/runtime.js +136 -0
  7. package/dist/bridge/server.js +183 -0
  8. package/dist/bridge/workbuddy/_compat.js +52 -0
  9. package/dist/bridge/workbuddy/app-version.js +141 -0
  10. package/dist/bridge/workbuddy/auth.js +548 -0
  11. package/dist/bridge/workbuddy/catalog.js +154 -0
  12. package/dist/bridge/workbuddy/client-identity.js +234 -0
  13. package/dist/bridge/workbuddy/desktop-credential-protection.js +992 -0
  14. package/dist/bridge/workbuddy/loopback.js +52 -0
  15. package/dist/bridge/workbuddy/probe.js +153 -0
  16. package/dist/bridge/workbuddy/status-paths.js +49 -0
  17. package/dist/bridge/workbuddy/upstream.js +1180 -0
  18. package/dist/bridge/workbuddy/variants.js +101 -0
  19. package/dist/cli.js +215 -0
  20. package/dist/commands/bridge.js +68 -0
  21. package/dist/commands/connect.js +67 -0
  22. package/dist/commands/engine.js +122 -0
  23. package/dist/commands/key.js +75 -0
  24. package/dist/commands/login.js +113 -0
  25. package/dist/commands/logs.js +19 -0
  26. package/dist/commands/opencode.js +83 -0
  27. package/dist/commands/remote.js +129 -0
  28. package/dist/commands/self.js +116 -0
  29. package/dist/commands/service.js +72 -0
  30. package/dist/commands/setup.js +219 -0
  31. package/dist/commands/sources.js +196 -0
  32. package/dist/commands/status.js +216 -0
  33. package/dist/commands/uninstall.js +97 -0
  34. package/dist/commands/workbuddy.js +117 -0
  35. package/dist/context.js +86 -0
  36. package/dist/daemon/client.js +27 -0
  37. package/dist/daemon/core.js +850 -0
  38. package/dist/daemon/service.js +33 -0
  39. package/dist/daemon/stats.js +126 -0
  40. package/dist/daemon/usage.js +213 -0
  41. package/dist/dsh/cli.js +90 -0
  42. package/dist/dsh/connect.js +257 -0
  43. package/dist/dsh/credentials.js +55 -0
  44. package/dist/dsh/locate.js +129 -0
  45. package/dist/dsh/patch.js +106 -0
  46. package/dist/dsh/pick.js +59 -0
  47. package/dist/engine/auth.js +40 -0
  48. package/dist/engine/client.js +104 -0
  49. package/dist/engine/compat.js +94 -0
  50. package/dist/engine/config.js +47 -0
  51. package/dist/engine/install.js +118 -0
  52. package/dist/engine/mgmt.js +99 -0
  53. package/dist/engine/sourcemap.js +48 -0
  54. package/dist/errors.js +14 -0
  55. package/dist/i18n.js +39 -0
  56. package/dist/integrations/dshplugin.js +89 -0
  57. package/dist/integrations/opencode.js +101 -0
  58. package/dist/integrations/workbuddy.js +114 -0
  59. package/dist/keys.js +54 -0
  60. package/dist/ops.js +178 -0
  61. package/dist/remote/public.js +109 -0
  62. package/dist/secrets.js +13 -0
  63. package/dist/service/index.js +48 -0
  64. package/dist/service/launchd.js +89 -0
  65. package/dist/service/systemd.js +83 -0
  66. package/dist/sources.js +36 -0
  67. package/dist/state.js +90 -0
  68. package/dist/upstreams.js +23 -0
  69. package/dist/util/diff.js +30 -0
  70. package/dist/util/exec.js +42 -0
  71. package/dist/util/fs.js +107 -0
  72. package/dist/util/output.js +50 -0
  73. package/dist/util/pkg.js +13 -0
  74. package/dist/util/port.js +16 -0
  75. package/dist/util/prompt.js +40 -0
  76. package/dist/util/proxy.js +73 -0
  77. package/dist/util/redact.js +7 -0
  78. package/engine-manifest.json +28 -0
  79. package/package.json +77 -7
  80. package/plugin/client.js +688 -0
  81. package/plugin/cordis.patch.yml +4 -0
  82. package/plugin/index.js +231 -0
  83. package/docs/DESIGN.md +0 -610
@@ -0,0 +1,4 @@
1
+ # dsh-model 的 dsh 插件:在「设置」里加一个 dsh-model 分区(来源开关、订阅用量、key 状态)
2
+ - insert:
3
+ - id: dsh-model
4
+ name: dsh-model
@@ -0,0 +1,231 @@
1
+ // plugin/index.js — dsh-model 的 dsh 插件(宿主端)
2
+ //
3
+ // 只做一件事:把页面的请求转给 dsh-model 守护进程的 /control/*(设计 §14.1)。
4
+ // - 路由 /api-dsh-model/call 与 /api-dsh-model/token,挂在 dsh 自己的 webServer 上,所以先过 dsh 的登录校验;
5
+ // - 只收同源的 POST JSON,并且要带页面令牌(宿主经 index-inject / tapIndex 注入页面,跨站网页读不到);
6
+ // - 守护进程的地址与控制密钥从 ~/.dsh-model/bridge.json 读(dsh 与守护进程是同一个用户),浏览器永远拿不到;
7
+ // - 只转发白名单里的控制接口。
8
+ // 写法照 dsh-vps-manager(同作者)的 routes.js。
9
+
10
+ import { spawn } from 'node:child_process'
11
+ import { randomBytes } from 'node:crypto'
12
+ import { access, readFile } from 'node:fs/promises'
13
+ import { homedir } from 'node:os'
14
+ import { join } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+
17
+ /** 本包根目录:插件就是完整的 dsh-model 包,自带命令行 */
18
+ const PKG_ROOT = fileURLToPath(new URL('..', import.meta.url))
19
+ const VPS_ROOT = '/opt/dsh-vps'
20
+
21
+ export const name = 'dsh-model'
22
+ export const inject = []
23
+
24
+ const TOKEN_HEADER = 'x-dsh-model-token'
25
+
26
+ /** 允许页面调用的守护进程接口:[方法, 路径正则] */
27
+ const ALLOWED = [
28
+ ['GET', /^\/status$/],
29
+ ['GET', /^\/sources(\?refresh=1)?$/],
30
+ ['GET', /^\/usage(\?refresh=1)?$/],
31
+ ['GET', /^\/stats$/],
32
+ ['GET', /^\/keys$/],
33
+ ['POST', /^\/keys$/],
34
+ ['DELETE', /^\/keys\/[A-Za-z0-9._-]{1,40}$/],
35
+ ['POST', /^\/keys\/[A-Za-z0-9._-]{1,40}\/rotate$/],
36
+ ['POST', /^\/keys\/[A-Za-z0-9._-]{1,40}\/reveal$/],
37
+ ['GET', /^\/endpoints$/],
38
+ ['GET', /^\/self$/],
39
+ ['POST', /^\/self\/(update|uninstall)$/],
40
+ ['GET', /^\/sources\/[a-z-]{2,20}\/models$/],
41
+ ['POST', /^\/sources\/[a-z-]{2,20}\/models$/],
42
+ ['POST', /^\/sources\/[a-z-]{2,20}\/(enable|disable|logout)$/],
43
+ ['POST', /^\/opencode\/key$/],
44
+ ['GET', /^\/login\/[A-Za-z0-9._-]{1,120}$/],
45
+ ['POST', /^\/login\/[A-Za-z0-9._-]{1,120}\/callback$/],
46
+ ['DELETE', /^\/login\/[A-Za-z0-9._-]{1,120}$/],
47
+ ]
48
+
49
+ function home() {
50
+ return process.env.DSH_MODEL_HOME || join(homedir(), '.dsh-model')
51
+ }
52
+
53
+ async function daemonConfig() {
54
+ return JSON.parse(await readFile(join(home(), 'bridge.json'), 'utf8'))
55
+ }
56
+
57
+ function sameOrigin(req) {
58
+ const origin = req.headers?.origin
59
+ if (!origin) return true
60
+ try {
61
+ return new URL(origin).host === req.headers.host
62
+ } catch {
63
+ return false
64
+ }
65
+ }
66
+
67
+ function json(res, code, data) {
68
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
69
+ res.end(JSON.stringify(data))
70
+ }
71
+
72
+ function readBody(req) {
73
+ return new Promise((resolve, reject) => {
74
+ let size = 0
75
+ const chunks = []
76
+ req.on('data', (c) => {
77
+ size += c.length
78
+ if (size > 1024 * 1024) {
79
+ reject(new Error('body too large'))
80
+ req.destroy()
81
+ return
82
+ }
83
+ chunks.push(c)
84
+ })
85
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
86
+ req.on('error', reject)
87
+ })
88
+ }
89
+
90
+ function check(req, token, needToken = true) {
91
+ if (req.method !== 'POST') return { code: 405, error: 'POST only' }
92
+ if (!String(req.headers['content-type'] ?? '').includes('application/json')) return { code: 415, error: 'application/json only' }
93
+ if (!sameOrigin(req)) return { code: 403, error: 'cross-site request refused' }
94
+ if (needToken && String(req.headers[TOKEN_HEADER] ?? '') !== token) return { code: 403, error: 'wrong token' }
95
+ return null
96
+ }
97
+
98
+ const exists = (p) => access(p).then(() => true, () => false)
99
+
100
+ /** 从页面一键执行 setup / repair(只在本机模式:VPS 上要 root,页面只给命令)。同一时间只跑一个 */
101
+ let running = null
102
+ function runCli(args) {
103
+ if (running) return running
104
+ running = new Promise((resolve) => {
105
+ let out = ''
106
+ const child = spawn(process.execPath, [join(PKG_ROOT, 'bin/dsh-model.js'), ...args], {
107
+ // dsh 桌面版可能跑在 Electron 里:让它当普通 Node 用
108
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', NO_COLOR: '1', FORCE_COLOR: '0' },
109
+ stdio: ['ignore', 'pipe', 'pipe'],
110
+ })
111
+ const add = (b) => { out = (out + b.toString()).slice(-20000) }
112
+ child.stdout.on('data', add)
113
+ child.stderr.on('data', add)
114
+ const timer = setTimeout(() => child.kill('SIGTERM'), 10 * 60 * 1000)
115
+ child.on('error', (e) => { clearTimeout(timer); resolve({ code: -1, output: `${out}\n${e.message}` }) })
116
+ child.on('exit', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, output: out.replace(/\x1b\[[0-9;]*m/g, '') }) })
117
+ }).finally(() => { running = null })
118
+ return running
119
+ }
120
+
121
+ export function apply(ctx) {
122
+ const token = randomBytes(24).toString('hex')
123
+ ctx.inject(['webServer'], (webCtx) => {
124
+ const ws = webCtx.webServer
125
+ if (!ws || typeof ws.register !== 'function') return
126
+ const disposers = []
127
+ const track = (d) => disposers.push(d)
128
+
129
+ track(
130
+ ws.register({
131
+ kind: 'exact',
132
+ path: '/api-dsh-model/call',
133
+ handler: async (req, res) => {
134
+ const bad = check(req, token)
135
+ if (bad) return json(res, bad.code, { error: { code: 'refused', message: bad.error } })
136
+ let call
137
+ try {
138
+ call = JSON.parse((await readBody(req)) || '{}')
139
+ } catch {
140
+ return json(res, 400, { error: { code: 'invalid_json', message: 'invalid JSON' } })
141
+ }
142
+ const method = String(call.method ?? 'GET').toUpperCase()
143
+ const path = String(call.path ?? '')
144
+ if (!ALLOWED.some(([m, re]) => m === method && re.test(path))) {
145
+ return json(res, 403, { error: { code: 'not_allowed', message: `${method} ${path}` } })
146
+ }
147
+ let cfg
148
+ try {
149
+ cfg = await daemonConfig()
150
+ } catch {
151
+ return json(res, 503, { error: { code: 'daemon_not_configured', message: 'dsh-model daemon not configured (run dsh-model setup)' } })
152
+ }
153
+ try {
154
+ const r = await fetch(`http://127.0.0.1:${cfg.port}/control${path}`, {
155
+ method,
156
+ headers: { Authorization: `Bearer ${cfg.secret}`, 'x-dsh-model-lang': call.lang === 'en' ? 'en' : 'zh', ...(call.body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
157
+ body: call.body !== undefined && method !== 'GET' ? JSON.stringify(call.body) : undefined,
158
+ signal: AbortSignal.timeout(90_000),
159
+ })
160
+ const text = await r.text()
161
+ res.writeHead(r.status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
162
+ res.end(text || '{}')
163
+ } catch {
164
+ return json(res, 503, { error: { code: 'daemon_unreachable', message: 'dsh-model daemon is not running (run dsh-model repair)' } })
165
+ }
166
+ },
167
+ }),
168
+ )
169
+
170
+ // 页面上的「一键安装 / 修复」
171
+ track(
172
+ ws.register({
173
+ kind: 'exact',
174
+ path: '/api-dsh-model/setup',
175
+ handler: async (req, res) => {
176
+ const bad = check(req, token)
177
+ if (bad) return json(res, bad.code, { error: { code: 'refused', message: bad.error } })
178
+ let body
179
+ try {
180
+ body = JSON.parse((await readBody(req)) || '{}')
181
+ } catch {
182
+ return json(res, 400, { error: { code: 'invalid_json', message: 'invalid JSON' } })
183
+ }
184
+ if (await exists(VPS_ROOT)) return json(res, 200, { vps: true })
185
+ const action = body.action === 'repair' ? 'repair' : 'setup'
186
+ const lang = body.lang === 'en' ? 'en' : 'zh'
187
+ const r = await runCli(action === 'repair' ? ['repair', '--lang', lang] : ['setup', '--skip-dsh-plugin', '--lang', lang])
188
+ return json(res, 200, r)
189
+ },
190
+ }),
191
+ )
192
+
193
+ // 令牌对不上时(dsh 重启、插件热更新)页面悄悄来换:只认同源 JSON,并且先过 dsh 自己的登录
194
+ track(
195
+ ws.register({
196
+ kind: 'exact',
197
+ path: '/api-dsh-model/token',
198
+ handler: (req, res) => {
199
+ const bad = check(req, token, false)
200
+ if (bad) return json(res, bad.code, { error: { code: 'refused', message: bad.error } })
201
+ return json(res, 200, { token })
202
+ },
203
+ }),
204
+ )
205
+
206
+ if (typeof webCtx.on === 'function') {
207
+ track(webCtx.on('webserver/index-inject', (table) => {
208
+ if (Array.isArray(table)) table.push({ kind: 'global', name: '__DSH_MODEL_TOKEN__', value: token })
209
+ }))
210
+ }
211
+ if (typeof ws.tapIndex === 'function') {
212
+ track(ws.tapIndex((html) => html.replace('</head>', `<script>window.__DSH_MODEL_TOKEN__=${JSON.stringify(token)}</script></head>`)))
213
+ }
214
+
215
+ try {
216
+ webCtx.effect?.(() => () => {
217
+ for (const d of disposers.reverse()) {
218
+ try {
219
+ d?.()
220
+ } catch {
221
+ // 反注册失败不影响卸载
222
+ }
223
+ }
224
+ }, 'dsh-model: routes')
225
+ } catch {
226
+ // 宿主不支持就算了
227
+ }
228
+ })
229
+ }
230
+
231
+ export default { name, inject, apply }