dsh-model 0.0.1 → 0.14.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 (82) hide show
  1. package/README.en.md +123 -0
  2. package/README.md +92 -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 +178 -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 +207 -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 +77 -0
  27. package/dist/commands/remote.js +129 -0
  28. package/dist/commands/service.js +72 -0
  29. package/dist/commands/setup.js +216 -0
  30. package/dist/commands/sources.js +196 -0
  31. package/dist/commands/status.js +216 -0
  32. package/dist/commands/uninstall.js +97 -0
  33. package/dist/commands/workbuddy.js +117 -0
  34. package/dist/context.js +86 -0
  35. package/dist/daemon/client.js +27 -0
  36. package/dist/daemon/core.js +790 -0
  37. package/dist/daemon/service.js +33 -0
  38. package/dist/daemon/stats.js +126 -0
  39. package/dist/daemon/usage.js +213 -0
  40. package/dist/dsh/cli.js +72 -0
  41. package/dist/dsh/connect.js +257 -0
  42. package/dist/dsh/credentials.js +55 -0
  43. package/dist/dsh/locate.js +129 -0
  44. package/dist/dsh/patch.js +106 -0
  45. package/dist/dsh/pick.js +59 -0
  46. package/dist/engine/auth.js +40 -0
  47. package/dist/engine/client.js +104 -0
  48. package/dist/engine/compat.js +94 -0
  49. package/dist/engine/config.js +47 -0
  50. package/dist/engine/install.js +118 -0
  51. package/dist/engine/mgmt.js +99 -0
  52. package/dist/engine/sourcemap.js +48 -0
  53. package/dist/errors.js +14 -0
  54. package/dist/i18n.js +39 -0
  55. package/dist/integrations/dshplugin.js +42 -0
  56. package/dist/integrations/opencode.js +101 -0
  57. package/dist/integrations/workbuddy.js +114 -0
  58. package/dist/keys.js +54 -0
  59. package/dist/ops.js +178 -0
  60. package/dist/remote/public.js +109 -0
  61. package/dist/secrets.js +13 -0
  62. package/dist/service/index.js +46 -0
  63. package/dist/service/launchd.js +89 -0
  64. package/dist/service/systemd.js +83 -0
  65. package/dist/sources.js +36 -0
  66. package/dist/state.js +90 -0
  67. package/dist/upstreams.js +23 -0
  68. package/dist/util/diff.js +30 -0
  69. package/dist/util/exec.js +42 -0
  70. package/dist/util/fs.js +107 -0
  71. package/dist/util/output.js +50 -0
  72. package/dist/util/pkg.js +13 -0
  73. package/dist/util/port.js +16 -0
  74. package/dist/util/prompt.js +40 -0
  75. package/dist/util/proxy.js +73 -0
  76. package/dist/util/redact.js +7 -0
  77. package/engine-manifest.json +28 -0
  78. package/package.json +77 -7
  79. package/plugin/client.js +549 -0
  80. package/plugin/cordis.patch.yml +4 -0
  81. package/plugin/index.js +177 -0
  82. package/docs/DESIGN.md +0 -610
@@ -0,0 +1,257 @@
1
+ // dsh/connect.ts — 往 dsh 写入 / 还原 dsh-model 拥有的配置项
2
+ //
3
+ // dsh-model 在 dsh 里可能拥有几样东西(state.dsh 记账):
4
+ // - profiles/<p>/cordis.patch.yml 的 llm-pi-ai providers.<id>(dsh-model 自己的端点、内置的 opencode 路由)
5
+ // - .credentials.yaml 的 refs.<NAME>(DSH_MODEL_API_KEY、OPENCODE_API_KEY)
6
+ // 首次写入前备份原件并记哈希;全部还原时:
7
+ // - 文件自我们最后一次写入后没被动过 → 用备份逐字节还原(原本不存在就删掉);
8
+ // - 被改过 → 只摘掉我们拥有的那几项,保留别人的改动。
9
+ import { readFile, rm, stat } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { DshModelError } from '../errors.js';
12
+ import { L } from '../i18n.js';
13
+ import { lineDiff } from '../util/diff.js';
14
+ import { atomicWrite, ensureDir, exists, readText, sha256, timestamp } from '../util/fs.js';
15
+ import { KEY_REF, readRef, removeRef, upsertRef } from './credentials.js';
16
+ import { inRecovery, isCompatible, locateDsh, DSH_COMPAT } from './locate.js';
17
+ import { readProvider, removeProvider, upsertProvider } from './patch.js';
18
+ export function providerSpec(port, models) {
19
+ return {
20
+ displayName: 'dsh-model',
21
+ apiKeyEnv: KEY_REF,
22
+ api: 'openai-completions',
23
+ baseURL: `http://127.0.0.1:${port}/v1`,
24
+ models,
25
+ };
26
+ }
27
+ async function ownerOf(file, fallback) {
28
+ try {
29
+ const s = await stat(file);
30
+ return { owner: fallback ?? { uid: s.uid, gid: s.gid }, mode: s.mode & 0o777 };
31
+ }
32
+ catch {
33
+ return { owner: fallback, mode: 0o600 };
34
+ }
35
+ }
36
+ async function checkLocation(state, location, force) {
37
+ const warnings = [];
38
+ if (state.dsh && (state.dsh.patchFile !== location.patchFile || state.dsh.credFile !== location.credFile)) {
39
+ throw new DshModelError('dsh_connected_elsewhere', L(`已经接在另一个 dsh profile 上:${state.dsh.patchFile}`, `Already connected to another dsh profile: ${state.dsh.patchFile}`), L('先执行 dsh-model disconnect-dsh', 'Run dsh-model disconnect-dsh first'));
40
+ }
41
+ if (location.version) {
42
+ if (!isCompatible(location.version) && !force) {
43
+ throw new DshModelError('dsh_version_unsupported', L(`dsh 版本 ${location.version} 不在验证过的区间(>=${DSH_COMPAT.min} <${DSH_COMPAT.maxExclusive})`, `dsh ${location.version} is outside the verified range (>=${DSH_COMPAT.min} <${DSH_COMPAT.maxExclusive})`), L('配置格式可能已变化。确认要继续请加 --force', 'The config format may have changed. Add --force to proceed anyway'));
44
+ }
45
+ }
46
+ else {
47
+ warnings.push(L('读不到 dsh 版本,跳过版本校验', 'Could not read the dsh version; skipping the version check'));
48
+ }
49
+ if (await inRecovery(location)) {
50
+ throw new DshModelError('dsh_in_recovery', L('dsh 正处于崩溃恢复状态(patch 文件被改名为 .bak-*)', 'dsh is in crash recovery (patch file renamed to .bak-*)'), L('先正常启动一次 dsh,让它恢复配置,再重试', 'Start dsh once so it restores its config, then retry'));
51
+ }
52
+ return warnings;
53
+ }
54
+ /** 写入 / 移除一组项。所有权记在 state.dsh.ownedProviders / ownedRefs */
55
+ export async function applyDsh(ctx, state, items, opts) {
56
+ const location = await locateDsh(ctx, opts.profile);
57
+ const warnings = await checkLocation(state, location, opts.force);
58
+ const ownedProviders = new Set(state.dsh?.ownedProviders ?? []);
59
+ const ownedRefs = new Set(state.dsh?.ownedRefs ?? []);
60
+ let createdLlmEntry = state.dsh?.createdLlmEntry ?? false;
61
+ const patchBefore = await readText(location.patchFile);
62
+ const credBefore = await readText(location.credFile);
63
+ // —— 凭据 ——
64
+ let credAfter = credBefore ?? '';
65
+ for (const [name, value] of Object.entries(items.refs ?? {})) {
66
+ const existing = readRef(credAfter === '' ? null : credAfter, name);
67
+ if (value === null) {
68
+ if (ownedRefs.has(name)) {
69
+ credAfter = removeRef(credAfter === '' ? null : credAfter, name).text ?? '';
70
+ ownedRefs.delete(name);
71
+ }
72
+ continue;
73
+ }
74
+ if (existing !== undefined && !ownedRefs.has(name) && !opts.isOwnRef?.(name, existing)) {
75
+ throw new DshModelError('credential_ref_conflict', L(`dsh 凭据里已有 ${name},且不是 dsh-model 写的`, `dsh credentials already contain ${name}, not written by dsh-model`), L('不会覆盖它。要让 dsh-model 接管,请先在 dsh 设置里删掉它再重试', 'It will not be overwritten. To let dsh-model manage it, remove it in dsh settings and retry'));
76
+ }
77
+ credAfter = upsertRef(credAfter === '' ? null : credAfter, value, name);
78
+ ownedRefs.add(name);
79
+ }
80
+ // —— patch ——
81
+ let patchAfter = patchBefore ?? '';
82
+ for (const [id, spec] of Object.entries(items.providers ?? {})) {
83
+ if (spec === null) {
84
+ if (ownedProviders.has(id)) {
85
+ if (patchAfter !== '')
86
+ patchAfter = removeProvider(patchAfter, id, createdLlmEntry).text;
87
+ ownedProviders.delete(id);
88
+ }
89
+ continue;
90
+ }
91
+ if (!ownedProviders.has(id) && readProvider(patchAfter === '' ? null : patchAfter, id)) {
92
+ throw new DshModelError('provider_conflict', L(`dsh 里已有 provider ${id},且不是 dsh-model 写的`, `dsh already has a provider ${id}, not written by dsh-model`), L('不会覆盖它', 'It will not be overwritten'));
93
+ }
94
+ const r = upsertProvider(patchAfter === '' ? null : patchAfter, id, spec);
95
+ patchAfter = r.text;
96
+ createdLlmEntry ||= r.createdLlmEntry;
97
+ ownedProviders.add(id);
98
+ }
99
+ const patchChanged = (patchBefore ?? '') !== patchAfter;
100
+ const credChanged = (credBefore ?? '') !== credAfter;
101
+ const changed = patchChanged || credChanged;
102
+ if (opts.dryRun) {
103
+ const parts = [];
104
+ if (patchChanged)
105
+ parts.push(lineDiff(patchBefore ?? '', patchAfter, location.patchFile));
106
+ if (credChanged)
107
+ parts.push(lineDiff(redactCred(credBefore ?? ''), redactCred(credAfter), location.credFile));
108
+ return { location, changed, diff: parts.join('\n\n'), warnings };
109
+ }
110
+ if (!state.dsh) {
111
+ if (!changed)
112
+ return { location, changed, warnings };
113
+ state.dsh = await firstBackup(ctx, location, patchBefore, credBefore);
114
+ }
115
+ // 先写凭据再写 patch:dsh 热加载 patch 时 ref 已经能解析
116
+ if (credChanged) {
117
+ const { owner } = await ownerOf(location.credFile, ctx.owner);
118
+ await atomicWrite(location.credFile, credAfter, { mode: 0o600, owner });
119
+ }
120
+ if (patchChanged) {
121
+ const { owner, mode } = await ownerOf(location.patchFile, ctx.owner);
122
+ await atomicWrite(location.patchFile, patchAfter, { mode: mode || 0o600, owner });
123
+ }
124
+ const dsh = state.dsh;
125
+ dsh.createdLlmEntry = createdLlmEntry;
126
+ dsh.ownedProviders = [...ownedProviders];
127
+ dsh.ownedRefs = [...ownedRefs];
128
+ const patchNow = await readText(location.patchFile);
129
+ const credNow = await readText(location.credFile);
130
+ dsh.writtenPatchSha = patchNow == null ? null : sha256(patchNow);
131
+ dsh.writtenCredSha = credNow == null ? null : sha256(credNow);
132
+ return { location, changed, warnings };
133
+ }
134
+ async function firstBackup(ctx, location, patchBefore, credBefore) {
135
+ const dir = join(ctx.paths.backups, timestamp());
136
+ await ensureDir(ctx.paths.backups, { owner: ctx.owner });
137
+ await ensureDir(dir, { owner: ctx.owner });
138
+ const patchBackup = patchBefore != null ? join(dir, 'cordis.patch.yml') : null;
139
+ const credBackup = credBefore != null ? join(dir, 'credentials.yaml') : null;
140
+ if (patchBackup)
141
+ await atomicWrite(patchBackup, patchBefore, { owner: ctx.owner });
142
+ if (credBackup)
143
+ await atomicWrite(credBackup, credBefore, { owner: ctx.owner });
144
+ return {
145
+ dshHome: location.dshHome,
146
+ profile: location.profile,
147
+ patchFile: location.patchFile,
148
+ credFile: location.credFile,
149
+ patchBackup,
150
+ credBackup,
151
+ patchExisted: patchBefore != null,
152
+ credExisted: credBefore != null,
153
+ createdLlmEntry: false,
154
+ ownedProviders: [],
155
+ ownedRefs: [],
156
+ writtenPatchSha: null,
157
+ writtenCredSha: null,
158
+ connectedAt: new Date().toISOString(),
159
+ };
160
+ }
161
+ export async function connectDsh(ctx, state, keys, input) {
162
+ const providerWritten = input.models.length > 0;
163
+ const r = await applyDsh(ctx, state, {
164
+ refs: { [KEY_REF]: input.key },
165
+ providers: { [input.providerId]: providerWritten ? providerSpec(input.port, input.models) : null },
166
+ }, { profile: input.profile, force: input.force, dryRun: input.dryRun, isOwnRef: (name, value) => name === KEY_REF && keys.keys.some((k) => k.key === value) });
167
+ return { ...r, providerWritten };
168
+ }
169
+ /**
170
+ * 写入一组 provider(都指向统一端点、同一把 key);以前写过、这次不再出现的 dsh-model* provider 一并移除。
171
+ */
172
+ export async function connectDshGroups(ctx, state, keys, input) {
173
+ const providers = {};
174
+ const live = input.groups.filter((g) => g.models.length);
175
+ for (const g of live)
176
+ providers[g.providerId] = { ...providerSpec(input.port, g.models), displayName: g.displayName };
177
+ for (const id of state.dsh?.ownedProviders ?? [])
178
+ if (id.startsWith('dsh-model') && !(id in providers))
179
+ providers[id] = null;
180
+ const r = await applyDsh(ctx, state, { refs: { [KEY_REF]: input.key }, providers }, {
181
+ profile: input.profile,
182
+ force: input.force,
183
+ dryRun: input.dryRun,
184
+ isOwnRef: (name, value) => name === KEY_REF && keys.keys.some((k) => k.key === value),
185
+ });
186
+ return { ...r, providers: live.map((g) => g.providerId) };
187
+ }
188
+ /** dsh 里有没有 dsh-model 写的任何 provider */
189
+ export async function anyProviderPresent(state) {
190
+ if (!state.dsh)
191
+ return false;
192
+ const text = await readText(state.dsh.patchFile);
193
+ return (state.dsh.ownedProviders ?? []).filter((id) => id.startsWith('dsh-model')).some((id) => readProvider(text, id) != null);
194
+ }
195
+ export async function disconnectDsh(ctx, state) {
196
+ const dsh = state.dsh;
197
+ if (!dsh)
198
+ return { patch: 'untouched', cred: 'untouched' };
199
+ const providers = dsh.ownedProviders ?? [];
200
+ const refs = dsh.ownedRefs ?? [];
201
+ const patch = await restoreFile(ctx, dsh.patchFile, dsh.writtenPatchSha, dsh.patchExisted, dsh.patchBackup, (text) => {
202
+ let changed = false;
203
+ for (const id of providers) {
204
+ const r = removeProvider(text, id, dsh.createdLlmEntry);
205
+ if (r.changed) {
206
+ text = r.text;
207
+ changed = true;
208
+ }
209
+ }
210
+ return changed ? text : null;
211
+ });
212
+ const cred = await restoreFile(ctx, dsh.credFile, dsh.writtenCredSha, dsh.credExisted, dsh.credBackup, (text) => {
213
+ let changed = false;
214
+ let cur = text;
215
+ for (const name of refs) {
216
+ const r = removeRef(cur, name);
217
+ if (r.changed) {
218
+ cur = r.text;
219
+ changed = true;
220
+ }
221
+ }
222
+ return changed ? cur : null;
223
+ });
224
+ delete state.dsh;
225
+ return { patch, cred };
226
+ }
227
+ async function restoreFile(ctx, file, writtenSha, existed, backup, surgical) {
228
+ const current = await readText(file);
229
+ if (current == null)
230
+ return 'missing';
231
+ if (writtenSha && sha256(current) === writtenSha) {
232
+ if (!existed) {
233
+ await rm(file, { force: true });
234
+ return 'restored';
235
+ }
236
+ if (backup && (await exists(backup))) {
237
+ const { owner, mode } = await ownerOf(file, ctx.owner);
238
+ await atomicWrite(file, await readFile(backup), { mode, owner });
239
+ return 'restored';
240
+ }
241
+ }
242
+ const next = surgical(current);
243
+ if (next == null)
244
+ return 'untouched';
245
+ const { owner, mode } = await ownerOf(file, ctx.owner);
246
+ await atomicWrite(file, next, { mode, owner });
247
+ return 'surgical';
248
+ }
249
+ /** dsh 里现在有没有某个 provider(doctor / status 用) */
250
+ export async function providerPresent(state, providerId) {
251
+ if (!state.dsh)
252
+ return false;
253
+ return readProvider(await readText(state.dsh.patchFile), providerId) != null;
254
+ }
255
+ function redactCred(text) {
256
+ return text.replace(/(dshm_)[A-Za-z0-9_-]{8,}([A-Za-z0-9_-]{4})/g, '$1…$2').replace(/(OPENCODE_API_KEY:\s*)\S+/g, '$1***');
257
+ }
@@ -0,0 +1,55 @@
1
+ // dsh/credentials.ts — $DSH_HOME/.credentials.yaml 的 refs.<NAME>
2
+ //
3
+ // dsh-credentials-local 的规则:0600,否则拒绝加载;不接受空值和未知顶层键;
4
+ // 格式 version: 1 / refs: {NAME: value} / records: {...}。provider 里用 apiKeyEnv: NAME 引用。
5
+ import YAML, { isMap } from 'yaml';
6
+ import { DshModelError } from '../errors.js';
7
+ import { L } from '../i18n.js';
8
+ export const KEY_REF = 'DSH_MODEL_API_KEY';
9
+ function parse(text) {
10
+ const src = text ?? '';
11
+ const doc = YAML.parseDocument(src.trim() ? src : 'version: 1\n');
12
+ if (doc.errors.length) {
13
+ throw new DshModelError('credentials_invalid', L(`dsh 的 .credentials.yaml 有 YAML 语法错误:${doc.errors[0]?.message}`, `dsh .credentials.yaml has a YAML error: ${doc.errors[0]?.message}`));
14
+ }
15
+ if (!isMap(doc.contents)) {
16
+ throw new DshModelError('credentials_invalid', L('dsh 的 .credentials.yaml 顶层不是映射,不敢改', 'dsh .credentials.yaml is not a top-level mapping; refusing to edit'));
17
+ }
18
+ return doc;
19
+ }
20
+ export function readRef(text, name = KEY_REF) {
21
+ if (text == null)
22
+ return undefined;
23
+ const v = parse(text).getIn(['refs', name]);
24
+ return typeof v === 'string' ? v : undefined;
25
+ }
26
+ export function upsertRef(text, value, name = KEY_REF) {
27
+ const doc = parse(text);
28
+ const root = doc.contents;
29
+ if (!root.has('version'))
30
+ root.set('version', 1);
31
+ let refs = root.get('refs', true);
32
+ if (refs == null) {
33
+ root.set('refs', doc.createNode({}));
34
+ refs = root.get('refs', true);
35
+ }
36
+ if (!isMap(refs))
37
+ throw new DshModelError('credentials_invalid', L('.credentials.yaml 的 refs 不是映射,不敢改', '.credentials.yaml refs is not a mapping; refusing to edit'));
38
+ refs.flow = false;
39
+ refs.set(name, value);
40
+ return doc.toString({ lineWidth: 0 });
41
+ }
42
+ /** 移除 refs.<NAME>;refs 因此变空就连 refs 键一起删(dsh 不接受空值) */
43
+ export function removeRef(text, name = KEY_REF) {
44
+ if (text == null)
45
+ return { text, changed: false };
46
+ const doc = parse(text);
47
+ const root = doc.contents;
48
+ const refs = root.get('refs', true);
49
+ if (!isMap(refs) || !refs.has(name))
50
+ return { text, changed: false };
51
+ refs.delete(name);
52
+ if (refs.items.length === 0)
53
+ root.delete('refs');
54
+ return { text: doc.toString({ lineWidth: 0 }), changed: true };
55
+ }
@@ -0,0 +1,129 @@
1
+ // dsh/locate.ts — 找到 dsh:DSH_HOME、profile、版本
2
+ import { readdir, readFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { VPS_ROOT } from '../context.js';
6
+ import { DshModelError } from '../errors.js';
7
+ import { L } from '../i18n.js';
8
+ import { run } from '../util/exec.js';
9
+ import { exists } from '../util/fs.js';
10
+ /** 验证过的 dsh 版本区间:>= MIN 且 < MAX_EXCLUSIVE */
11
+ export const DSH_COMPAT = { min: '0.2.0-rc.2', maxExclusive: '0.3.0' };
12
+ export async function resolveProfile(ctx, preferred) {
13
+ if (preferred)
14
+ return preferred;
15
+ if (ctx.mode === 'vps')
16
+ return 'web';
17
+ const dir = join(ctx.dshHome, 'profiles');
18
+ let names = [];
19
+ try {
20
+ names = (await readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
21
+ }
22
+ catch {
23
+ // 下面统一报错
24
+ }
25
+ if (names.includes('desktop'))
26
+ return 'desktop';
27
+ if (names.includes('web'))
28
+ return 'web';
29
+ if (names.length === 1)
30
+ return names[0];
31
+ if (names.length === 0) {
32
+ throw new DshModelError('dsh_not_found', L(`没找到 dsh 的 profile(${dir})`, `No dsh profile found (${dir})`), L('请先安装并启动一次 dsh;DSH_HOME 不在默认位置时设置环境变量 DSH_HOME。', 'Install and start dsh once first; set DSH_HOME if it is not in the default location.'));
33
+ }
34
+ throw new DshModelError('profile_ambiguous', L(`有多个 dsh profile:${names.join('、')}`, `Multiple dsh profiles: ${names.join(', ')}`), L('用 --profile <名字> 指定一个', 'Pick one with --profile <name>'));
35
+ }
36
+ export async function dshVersion(ctx) {
37
+ if (ctx.env.DSH_MODEL_DSH_VERSION)
38
+ return ctx.env.DSH_MODEL_DSH_VERSION;
39
+ if (ctx.mode === 'vps') {
40
+ try {
41
+ const pkg = JSON.parse(await readFile(join(VPS_ROOT, 'dsh/current/node_modules/@deepseek-ai/dsh/package.json'), 'utf8'));
42
+ return pkg.version ?? null;
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ if (ctx.platform === 'darwin') {
49
+ for (const app of ['/Applications/DeepSeek Harness.app', join(homedir(), 'Applications/DeepSeek Harness.app')]) {
50
+ const r = await run('plutil', ['-extract', 'CFBundleShortVersionString', 'raw', join(app, 'Contents/Info.plist')]);
51
+ if (r.code === 0 && r.stdout.trim())
52
+ return r.stdout.trim();
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+ export async function locateDsh(ctx, preferredProfile) {
58
+ if (!(await exists(ctx.dshHome))) {
59
+ throw new DshModelError('dsh_not_found', L(`没找到 dsh 的数据目录:${ctx.dshHome}`, `dsh data directory not found: ${ctx.dshHome}`), L('请先安装并启动一次 dsh;不在默认位置时设置环境变量 DSH_HOME。', 'Install and start dsh once first; set DSH_HOME if it is elsewhere.'));
60
+ }
61
+ const profile = await resolveProfile(ctx, preferredProfile);
62
+ const profileDir = join(ctx.dshHome, 'profiles', profile);
63
+ if (!(await exists(profileDir))) {
64
+ throw new DshModelError('profile_not_found', L(`dsh profile 不存在:${profileDir}`, `dsh profile does not exist: ${profileDir}`));
65
+ }
66
+ return {
67
+ dshHome: ctx.dshHome,
68
+ profile,
69
+ profileDir,
70
+ patchFile: join(profileDir, 'cordis.patch.yml'),
71
+ credFile: join(ctx.dshHome, '.credentials.yaml'),
72
+ version: await dshVersion(ctx),
73
+ };
74
+ }
75
+ /** dsh 崩溃恢复中:patch 被改名成 cordis.patch.yml.bak-<ms>,原文件不在 */
76
+ export async function inRecovery(loc) {
77
+ if (await exists(loc.patchFile))
78
+ return false;
79
+ try {
80
+ return (await readdir(loc.profileDir)).some((n) => n.startsWith('cordis.patch.yml.bak-'));
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }
86
+ // —— 版本比较(够用的 semver:x.y.z[-pre.n])——
87
+ function parseVer(v) {
88
+ const [core = '', pre] = v.replace(/^v/, '').split('-', 2);
89
+ return {
90
+ nums: core.split('.').map((n) => Number(n) || 0),
91
+ pre: pre ? pre.split('.').map((p) => (/^\d+$/.test(p) ? Number(p) : p)) : [],
92
+ };
93
+ }
94
+ export function compareVersions(a, b) {
95
+ const pa = parseVer(a);
96
+ const pb = parseVer(b);
97
+ for (let i = 0; i < 3; i++) {
98
+ const d = (pa.nums[i] ?? 0) - (pb.nums[i] ?? 0);
99
+ if (d)
100
+ return Math.sign(d);
101
+ }
102
+ if (!pa.pre.length && !pb.pre.length)
103
+ return 0;
104
+ if (!pa.pre.length)
105
+ return 1; // 正式版 > 预发布
106
+ if (!pb.pre.length)
107
+ return -1;
108
+ for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) {
109
+ const x = pa.pre[i];
110
+ const y = pb.pre[i];
111
+ if (x === undefined)
112
+ return -1;
113
+ if (y === undefined)
114
+ return 1;
115
+ if (x === y)
116
+ continue;
117
+ if (typeof x === 'number' && typeof y === 'number')
118
+ return Math.sign(x - y);
119
+ if (typeof x === 'number')
120
+ return -1;
121
+ if (typeof y === 'number')
122
+ return 1;
123
+ return x < y ? -1 : 1;
124
+ }
125
+ return 0;
126
+ }
127
+ export function isCompatible(version) {
128
+ return compareVersions(version, DSH_COMPAT.min) >= 0 && compareVersions(version, DSH_COMPAT.maxExclusive) < 0;
129
+ }
@@ -0,0 +1,106 @@
1
+ // dsh/patch.ts — 在 profiles/<p>/cordis.patch.yml 里合并 / 移除 llm-pi-ai 的 providers.<id>
2
+ //
3
+ // dsh 的规则(0.2.0-rc.2 实测):
4
+ // - patch 是顶层 YAML 数组;条目的 config 会整块替换,所以只能合并进已有的 llm-pi-ai 条目,不能另起一条;
5
+ // - 空文件或只有注释会让 dsh 启动失败,删空时至少留 [];
6
+ // - Web UI 保存可能丢掉 config 内的注释,所以识别靠 provider 键名,不靠注释标记。
7
+ // 这里全是纯函数(文本进、文本出),方便用 fixture 测。
8
+ import YAML, { isMap, isSeq } from 'yaml';
9
+ import { DshModelError } from '../errors.js';
10
+ import { L } from '../i18n.js';
11
+ export const LLM_ENTRY_ID = 'llm-pi-ai';
12
+ export const LLM_ENTRY_NAME = '@deepseek-ai/dsh-llm-pi-ai';
13
+ function parse(text) {
14
+ const src = text ?? '';
15
+ const doc = YAML.parseDocument(src.trim() ? src : '[]\n');
16
+ if (doc.errors.length) {
17
+ throw new DshModelError('patch_invalid', L(`dsh 的 cordis.patch.yml 有 YAML 语法错误:${doc.errors[0]?.message}`, `dsh cordis.patch.yml has a YAML error: ${doc.errors[0]?.message}`));
18
+ }
19
+ if (doc.contents == null)
20
+ doc.contents = doc.createNode([]);
21
+ if (!isSeq(doc.contents)) {
22
+ throw new DshModelError('patch_invalid', L('dsh 的 cordis.patch.yml 顶层不是数组,不敢改', 'dsh cordis.patch.yml is not a top-level array; refusing to edit'));
23
+ }
24
+ return doc;
25
+ }
26
+ function seqOf(doc) {
27
+ return doc.contents;
28
+ }
29
+ function findEntry(doc) {
30
+ const items = seqOf(doc).items;
31
+ for (let i = 0; i < items.length; i++) {
32
+ const item = items[i];
33
+ if (isMap(item) && item.get('id') === LLM_ENTRY_ID)
34
+ return { entry: item, index: i };
35
+ }
36
+ return null;
37
+ }
38
+ function stringify(doc) {
39
+ const out = doc.toString({ lineWidth: 0 });
40
+ return out.endsWith('\n') ? out : out + '\n';
41
+ }
42
+ /** 读出当前的 providers.<id>(没有返回 null) */
43
+ export function readProvider(text, providerId) {
44
+ const doc = parse(text);
45
+ const found = findEntry(doc);
46
+ const providers = found?.entry.getIn(['config', 'providers']);
47
+ if (!isMap(providers))
48
+ return null;
49
+ const node = providers.get(providerId, true);
50
+ return node ? node.toJSON() : null;
51
+ }
52
+ /** 写入(或替换)providers.<id>;llm-pi-ai 条目不存在就新建 */
53
+ export function upsertProvider(text, providerId, spec) {
54
+ const doc = parse(text);
55
+ let found = findEntry(doc);
56
+ let createdLlmEntry = false;
57
+ if (!found) {
58
+ seqOf(doc).add(doc.createNode({ id: LLM_ENTRY_ID, name: LLM_ENTRY_NAME, config: { providers: {} } }));
59
+ found = findEntry(doc);
60
+ createdLlmEntry = true;
61
+ }
62
+ const { entry } = found;
63
+ let cfg = entry.get('config', true);
64
+ if (cfg == null) {
65
+ entry.set('config', doc.createNode({ providers: {} }));
66
+ cfg = entry.get('config', true);
67
+ }
68
+ if (!isMap(cfg)) {
69
+ throw new DshModelError('patch_invalid', L('llm-pi-ai 条目的 config 不是普通映射(可能是 !!js 表达式),不敢改', 'The llm-pi-ai entry config is not a plain mapping (maybe a !!js expression); refusing to edit'));
70
+ }
71
+ let providers = cfg.get('providers', true);
72
+ if (providers == null) {
73
+ cfg.set('providers', doc.createNode({}));
74
+ providers = cfg.get('providers', true);
75
+ }
76
+ if (!isMap(providers)) {
77
+ throw new DshModelError('patch_invalid', L('llm-pi-ai 的 providers 不是映射,不敢改', 'llm-pi-ai providers is not a mapping; refusing to edit'));
78
+ }
79
+ // 空的 {} 是 flow 样式,写入内容后改成块样式,和 dsh 自己写的风格一致
80
+ ;
81
+ providers.flow = false;
82
+ providers.set(providerId, doc.createNode(spec));
83
+ return { text: stringify(doc), createdLlmEntry };
84
+ }
85
+ /**
86
+ * 移除 providers.<id>。dropEntryIfEmpty:llm-pi-ai 条目是我们建的、且移除后只剩空壳,就整条删掉。
87
+ */
88
+ export function removeProvider(text, providerId, dropEntryIfEmpty) {
89
+ const doc = parse(text);
90
+ const found = findEntry(doc);
91
+ const providers = found?.entry.getIn(['config', 'providers'], true);
92
+ if (!found || !isMap(providers) || !providers.has(providerId))
93
+ return { text: text ?? '[]\n', changed: false };
94
+ providers.delete(providerId);
95
+ if (dropEntryIfEmpty && providers.items.length === 0) {
96
+ const cfg = found.entry.get('config', true);
97
+ const cfgOnlyProviders = isMap(cfg) && cfg.items.length === 1;
98
+ const entryKeys = found.entry.items.map((p) => String(p.key?.value ?? p.key));
99
+ const onlyStdKeys = entryKeys.every((k) => k === 'id' || k === 'name' || k === 'config');
100
+ if (cfgOnlyProviders && onlyStdKeys)
101
+ seqOf(doc).items.splice(found.index, 1);
102
+ }
103
+ if (seqOf(doc).items.length === 0)
104
+ seqOf(doc).flow = true; // 写成 [],dsh 不接受空 patch
105
+ return { text: stringify(doc), changed: true };
106
+ }
@@ -0,0 +1,59 @@
1
+ // dsh/pick.ts — dsh 下拉框里每个来源默认显示哪些模型
2
+ //
3
+ // 统一端点照常提供全部模型(其他软件不受影响);只是写进 dsh 的模型要精简,不然几十个选起来太费事。
4
+ // 用户在管理页勾选过的来源按勾选;没勾选过的用这里的默认:
5
+ // 去掉画图 / 审查 / 自动路由这类不是对话用的 → 免费的先放(最多 2 个)→ 各系列轮流取最新版本,凑满 5 个。
6
+ export const DEFAULT_PICK = 5;
7
+ const MAX_FREE = 2;
8
+ const NON_CHAT = /image|embed|tts|whisper|audio|review|(^|\/)(auto|default-model)$/i;
9
+ /** 系列的先后:常用的大模型在前 */
10
+ const FAMILY_ORDER = ['gpt', 'claude', 'gemini', 'grok', 'kimi', 'glm', 'deepseek', 'qwen', 'minimax', 'hy'];
11
+ const bare = (id) => id.slice(id.lastIndexOf('/') + 1).toLowerCase();
12
+ const family = (id) => /^[a-z]+/.exec(bare(id))?.[0] ?? bare(id);
13
+ const version = (id) => (bare(id).match(/\d+(\.\d+)?/g) ?? []).map(Number);
14
+ function newerFirst(a, b) {
15
+ const va = version(a.id);
16
+ const vb = version(b.id);
17
+ for (let i = 0; i < Math.max(va.length, vb.length); i++) {
18
+ const d = (vb[i] ?? -1) - (va[i] ?? -1);
19
+ if (d)
20
+ return d;
21
+ }
22
+ // 同版本:flash / preview / mini 这类变体往后
23
+ const variant = (id) => (/flash|preview|mini|lite|turbo|fast/.test(bare(id)) ? 1 : 0);
24
+ return variant(a.id) - variant(b.id) || a.id.length - b.id.length;
25
+ }
26
+ export function defaultPick(models, limit = DEFAULT_PICK) {
27
+ const chat = models.filter((m) => !NON_CHAT.test(m.id));
28
+ const out = [];
29
+ for (const m of chat.filter((x) => x.rate === 0).sort(newerFirst).slice(0, MAX_FREE))
30
+ out.push(m.id);
31
+ const byFamily = new Map();
32
+ for (const m of chat) {
33
+ if (out.includes(m.id))
34
+ continue;
35
+ const f = family(m.id);
36
+ byFamily.set(f, [...(byFamily.get(f) ?? []), m]);
37
+ }
38
+ const rank = (f) => (FAMILY_ORDER.indexOf(f) === -1 ? FAMILY_ORDER.length : FAMILY_ORDER.indexOf(f));
39
+ const families = [...byFamily.keys()].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
40
+ for (const f of families)
41
+ byFamily.get(f).sort(newerFirst);
42
+ while (out.length < limit && families.some((f) => byFamily.get(f).length)) {
43
+ for (const f of families) {
44
+ const next = byFamily.get(f).shift();
45
+ if (next && out.length < limit)
46
+ out.push(next.id);
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+ /** 这个来源在 dsh 里显示哪些:勾选过就按勾选(只留现在还在的),否则默认 */
52
+ export function pickedFor(models, chosen) {
53
+ if (!chosen)
54
+ return defaultPick(models);
55
+ const have = new Set(models.map((m) => m.id));
56
+ const kept = chosen.filter((id) => have.has(id));
57
+ // 勾选的模型全都下线了:退回默认,免得这个来源整组消失
58
+ return kept.length || !chosen.length ? kept : defaultPick(models);
59
+ }
@@ -0,0 +1,40 @@
1
+ // engine/auth.ts — 引擎 auth-dir 里的凭据文件(只看文件名,不读内容)
2
+ import { chmod, chown, readdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { UPSTREAMS } from '../upstreams.js';
5
+ import { isNotFound } from '../util/fs.js';
6
+ export async function listAuthFiles(ctx) {
7
+ try {
8
+ const entries = await readdir(ctx.paths.auth, { withFileTypes: true });
9
+ return entries.filter((e) => e.isFile() && e.name.endsWith('.json')).map((e) => e.name).sort();
10
+ }
11
+ catch (error) {
12
+ if (isNotFound(error))
13
+ return [];
14
+ throw error;
15
+ }
16
+ }
17
+ export function filesFor(files, def) {
18
+ return files.filter((f) => f.startsWith(def.filePrefix));
19
+ }
20
+ /** 每个上游登录了几个账号;不认识前缀的文件归到 other */
21
+ export function summarize(files) {
22
+ const byUpstream = {};
23
+ const claimed = new Set();
24
+ for (const def of UPSTREAMS) {
25
+ const mine = filesFor(files, def);
26
+ if (mine.length)
27
+ byUpstream[def.id] = mine;
28
+ mine.forEach((f) => claimed.add(f));
29
+ }
30
+ return { byUpstream, other: files.filter((f) => !claimed.has(f)) };
31
+ }
32
+ /** 引擎写出的凭据可能是 0644(实测旧版本如此):统一收紧到 0600,vps 模式归 dsh */
33
+ export async function tightenAuthPerms(ctx) {
34
+ for (const name of await listAuthFiles(ctx)) {
35
+ const file = join(ctx.paths.auth, name);
36
+ await chmod(file, 0o600);
37
+ if (ctx.owner)
38
+ await chown(file, ctx.owner.uid, ctx.owner.gid);
39
+ }
40
+ }