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.
- package/README.en.md +123 -0
- package/README.md +92 -43
- package/bin/dsh-model.js +8 -0
- package/dist/bridge/aggregate.js +91 -0
- package/dist/bridge/login.js +127 -0
- package/dist/bridge/runtime.js +136 -0
- package/dist/bridge/server.js +178 -0
- package/dist/bridge/workbuddy/_compat.js +52 -0
- package/dist/bridge/workbuddy/app-version.js +141 -0
- package/dist/bridge/workbuddy/auth.js +548 -0
- package/dist/bridge/workbuddy/catalog.js +154 -0
- package/dist/bridge/workbuddy/client-identity.js +234 -0
- package/dist/bridge/workbuddy/desktop-credential-protection.js +992 -0
- package/dist/bridge/workbuddy/loopback.js +52 -0
- package/dist/bridge/workbuddy/probe.js +153 -0
- package/dist/bridge/workbuddy/status-paths.js +49 -0
- package/dist/bridge/workbuddy/upstream.js +1180 -0
- package/dist/bridge/workbuddy/variants.js +101 -0
- package/dist/cli.js +207 -0
- package/dist/commands/bridge.js +68 -0
- package/dist/commands/connect.js +67 -0
- package/dist/commands/engine.js +122 -0
- package/dist/commands/key.js +75 -0
- package/dist/commands/login.js +113 -0
- package/dist/commands/logs.js +19 -0
- package/dist/commands/opencode.js +77 -0
- package/dist/commands/remote.js +129 -0
- package/dist/commands/service.js +72 -0
- package/dist/commands/setup.js +216 -0
- package/dist/commands/sources.js +196 -0
- package/dist/commands/status.js +216 -0
- package/dist/commands/uninstall.js +97 -0
- package/dist/commands/workbuddy.js +117 -0
- package/dist/context.js +86 -0
- package/dist/daemon/client.js +27 -0
- package/dist/daemon/core.js +790 -0
- package/dist/daemon/service.js +33 -0
- package/dist/daemon/stats.js +126 -0
- package/dist/daemon/usage.js +213 -0
- package/dist/dsh/cli.js +72 -0
- package/dist/dsh/connect.js +257 -0
- package/dist/dsh/credentials.js +55 -0
- package/dist/dsh/locate.js +129 -0
- package/dist/dsh/patch.js +106 -0
- package/dist/dsh/pick.js +59 -0
- package/dist/engine/auth.js +40 -0
- package/dist/engine/client.js +104 -0
- package/dist/engine/compat.js +94 -0
- package/dist/engine/config.js +47 -0
- package/dist/engine/install.js +118 -0
- package/dist/engine/mgmt.js +99 -0
- package/dist/engine/sourcemap.js +48 -0
- package/dist/errors.js +14 -0
- package/dist/i18n.js +39 -0
- package/dist/integrations/dshplugin.js +42 -0
- package/dist/integrations/opencode.js +101 -0
- package/dist/integrations/workbuddy.js +114 -0
- package/dist/keys.js +54 -0
- package/dist/ops.js +178 -0
- package/dist/remote/public.js +109 -0
- package/dist/secrets.js +13 -0
- package/dist/service/index.js +46 -0
- package/dist/service/launchd.js +89 -0
- package/dist/service/systemd.js +83 -0
- package/dist/sources.js +36 -0
- package/dist/state.js +90 -0
- package/dist/upstreams.js +23 -0
- package/dist/util/diff.js +30 -0
- package/dist/util/exec.js +42 -0
- package/dist/util/fs.js +107 -0
- package/dist/util/output.js +50 -0
- package/dist/util/pkg.js +13 -0
- package/dist/util/port.js +16 -0
- package/dist/util/prompt.js +40 -0
- package/dist/util/proxy.js +73 -0
- package/dist/util/redact.js +7 -0
- package/engine-manifest.json +28 -0
- package/package.json +77 -7
- package/plugin/client.js +549 -0
- package/plugin/cordis.patch.yml +4 -0
- package/plugin/index.js +177 -0
- package/docs/DESIGN.md +0 -610
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// commands/sources.ts — 来源开关与登录(CLI 版;dsh 插件页调同一套守护进程接口)
|
|
2
|
+
// dsh-model sources
|
|
3
|
+
// dsh-model source enable|disable|logout <来源> [--accept-risk]
|
|
4
|
+
// dsh-model stats
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
import { control } from '../daemon/client.js';
|
|
7
|
+
import { DshModelError } from '../errors.js';
|
|
8
|
+
import { L } from '../i18n.js';
|
|
9
|
+
import { bold, dim, green, info, isJsonMode, ok, printJson, red, table, warn, yellow } from '../util/output.js';
|
|
10
|
+
import { canPrompt } from '../util/prompt.js';
|
|
11
|
+
/** "2 小时 13 分后重置" */
|
|
12
|
+
function untilText(iso) {
|
|
13
|
+
if (!iso)
|
|
14
|
+
return '';
|
|
15
|
+
const ms = Date.parse(iso) - Date.now();
|
|
16
|
+
if (!(ms > 0))
|
|
17
|
+
return L('即将重置', 'resetting');
|
|
18
|
+
const m = Math.round(ms / 60000);
|
|
19
|
+
const d = Math.floor(m / 1440);
|
|
20
|
+
const h = Math.floor((m % 1440) / 60);
|
|
21
|
+
const mm = m % 60;
|
|
22
|
+
const t = d ? L(`${d} 天 ${h} 小时`, `${d}d ${h}h`) : h ? L(`${h} 小时 ${mm} 分`, `${h}h ${mm}m`) : L(`${mm} 分`, `${mm}m`);
|
|
23
|
+
return L(`${t}后重置`, `resets in ${t}`);
|
|
24
|
+
}
|
|
25
|
+
function bar(pct) {
|
|
26
|
+
const n = Math.max(0, Math.min(10, Math.round(pct / 10)));
|
|
27
|
+
const s = '█'.repeat(n) + '░'.repeat(10 - n);
|
|
28
|
+
return pct >= 90 ? red(s) : pct >= 70 ? yellow(s) : green(s);
|
|
29
|
+
}
|
|
30
|
+
/** 每个来源的用量行(设计 §14.6) */
|
|
31
|
+
export function usageLines(s) {
|
|
32
|
+
const u = s.usage;
|
|
33
|
+
if (!u)
|
|
34
|
+
return [];
|
|
35
|
+
if (u.noAccess)
|
|
36
|
+
return [yellow(L(` 当前账号没有可用订阅(调用会被拒绝、不扣费),模型已在 dsh 中隐藏${s.subscribeUrl ? `;开通:${s.subscribeUrl}` : ''}`, ` No usable subscription on this account (calls refused, no charge); its models are hidden in dsh${s.subscribeUrl ? `; subscribe: ${s.subscribeUrl}` : ''}`))];
|
|
37
|
+
if (u.unsupported)
|
|
38
|
+
return [dim(L(' 已实测可以调用;用量暂不支持查询', ' tested OK; usage not available yet'))];
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const w of u.windows) {
|
|
41
|
+
const pct = w.usedPercent;
|
|
42
|
+
const amount = w.used !== undefined && w.limit !== undefined ? ` ${w.used}/${w.limit}` : '';
|
|
43
|
+
out.push(` ${w.label.padEnd(8)} ${pct !== undefined ? `${bar(pct)} ${Math.round(pct)}%` : ''}${amount} ${dim(untilText(w.resetAt))}`);
|
|
44
|
+
}
|
|
45
|
+
if (u.credits) {
|
|
46
|
+
const c = u.credits;
|
|
47
|
+
out.push(` ${L('积分', 'credits')} ${c.unlimited ? L('不限量', 'unlimited') : L(`剩余 ${c.remaining.toLocaleString()}${c.total ? ` / ${c.total.toLocaleString()}` : ''}`, `${c.remaining.toLocaleString()} left${c.total ? ` of ${c.total.toLocaleString()}` : ''}`)}`);
|
|
48
|
+
}
|
|
49
|
+
if (u.plan)
|
|
50
|
+
out.unshift(dim(` ${L('套餐', 'plan')}:${u.plan}`));
|
|
51
|
+
if (u.error)
|
|
52
|
+
out.push(yellow(L(` 用量暂不可用:${u.error}`, ` usage unavailable: ${u.error}`)));
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
export function pricingTag(s) {
|
|
56
|
+
const t = s.pricing?.tier;
|
|
57
|
+
return t === 'free' ? green(L('免费', 'free')) : t === 'limited' ? yellow(L('免费·额度少', 'free·limited')) : t === 'paid' ? red(L('需付费', 'paid')) : '-';
|
|
58
|
+
}
|
|
59
|
+
export async function sources(ctx, opts = {}) {
|
|
60
|
+
// 高风险来源(Claude、Antigravity)默认不列出;已登录的照常显示,方便退出登录
|
|
61
|
+
const list = (await control(ctx, 'GET', '/sources?refresh=1')).filter((s) => opts.all || !s.risky || s.loggedIn);
|
|
62
|
+
if (isJsonMode()) {
|
|
63
|
+
printJson(list);
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
info(table([
|
|
67
|
+
[L('开关', 'On'), L('来源', 'Source'), L('费用', 'Cost'), L('状态', 'State'), L('账号', 'Account'), L('模型', 'Models')],
|
|
68
|
+
...list.map((s) => [
|
|
69
|
+
s.enabled ? green('●') : dim('○'),
|
|
70
|
+
s.id,
|
|
71
|
+
pricingTag(s),
|
|
72
|
+
s.loggedIn ? (s.usage?.noAccess ? yellow(L('已登录·无订阅', 'signed in · no plan')) : s.enabled ? green(L('已接入', 'connected')) : yellow(L('已登录·关闭', 'signed in · off'))) : dim(L('未登录', 'signed out')),
|
|
73
|
+
s.account ?? (s.detail ? dim(s.detail.slice(0, 40)) : '-'),
|
|
74
|
+
s.models ? String(s.models) : '-',
|
|
75
|
+
]),
|
|
76
|
+
]));
|
|
77
|
+
const unpaid = list.filter((s) => !s.loggedIn && s.pricing);
|
|
78
|
+
if (unpaid.length) {
|
|
79
|
+
info(bold(L('\n费用说明', '\nCost')));
|
|
80
|
+
for (const s of unpaid)
|
|
81
|
+
info(` ${s.id.padEnd(13)}${L(s.pricing.zh, s.pricing.en)}${s.subscribeUrl ? dim(` ${s.subscribeUrl}`) : ''}`);
|
|
82
|
+
}
|
|
83
|
+
const withUsage = list.filter((s) => s.usage);
|
|
84
|
+
if (withUsage.length) {
|
|
85
|
+
info(bold(L('\n订阅用量', '\nSubscription usage')));
|
|
86
|
+
for (const s of withUsage) {
|
|
87
|
+
info(` ${s.label}${s.account ? dim(` ${s.account}`) : ''}`);
|
|
88
|
+
for (const line of usageLines(s))
|
|
89
|
+
info(line);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
info(dim(L('\n打开:dsh-model source enable <来源> 关闭:dsh-model source disable <来源> 高风险来源(Claude、Antigravity):dsh-model sources --all', '\nTurn on: dsh-model source enable <source> off: dsh-model source disable <source> High-risk sources (Claude, Antigravity): dsh-model sources --all')));
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
export async function source(ctx, sub, id, opts) {
|
|
96
|
+
if (!sub || !id || !['enable', 'disable', 'logout', 'on', 'off'].includes(sub)) {
|
|
97
|
+
throw new DshModelError('usage', L('用法:dsh-model source enable|disable|logout <来源>', 'Usage: dsh-model source enable|disable|logout <source>'));
|
|
98
|
+
}
|
|
99
|
+
if (sub === 'disable' || sub === 'off') {
|
|
100
|
+
await control(ctx, 'POST', `/sources/${id}/disable`);
|
|
101
|
+
ok(L(`${id} 已关闭(登录保留)`, `${id} turned off (sign-in kept)`));
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
if (sub === 'logout') {
|
|
105
|
+
await control(ctx, 'POST', `/sources/${id}/logout`);
|
|
106
|
+
ok(L(`${id} 已退出登录`, `${id} signed out`));
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
return enableInteractive(ctx, id, opts);
|
|
110
|
+
}
|
|
111
|
+
/** 打开一个来源;需要登录就在终端里完成(打印链接 / 码,回调式等你贴回地址) */
|
|
112
|
+
export async function enableInteractive(ctx, id, opts = {}) {
|
|
113
|
+
const r = await control(ctx, 'POST', `/sources/${id}/enable`, { acceptRisk: Boolean(opts.acceptRisk) });
|
|
114
|
+
if (r.riskNotice) {
|
|
115
|
+
info(r.riskNotice);
|
|
116
|
+
return 2;
|
|
117
|
+
}
|
|
118
|
+
if (r.enabled) {
|
|
119
|
+
ok(L(`${id} 已接入`, `${id} connected`));
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
if (!r.login)
|
|
123
|
+
throw new DshModelError('enable_failed', L(`${id} 没能打开`, `Could not turn on ${id}`));
|
|
124
|
+
return runLogin(ctx, id, r.login);
|
|
125
|
+
}
|
|
126
|
+
async function runLogin(ctx, id, s) {
|
|
127
|
+
info('');
|
|
128
|
+
info(bold(L('在任意设备的浏览器里打开下面的链接并授权:', 'Open this link in a browser on any device and approve:')));
|
|
129
|
+
info(`\n ${s.url}\n`);
|
|
130
|
+
if (s.userCode)
|
|
131
|
+
info(L(` 授权页要求输入的码:${bold(s.userCode)}\n`, ` Code to enter on that page: ${bold(s.userCode)}\n`));
|
|
132
|
+
let rl;
|
|
133
|
+
if (s.needsPaste) {
|
|
134
|
+
info(L('授权后浏览器会跳到一个打不开的 localhost 地址——把地址栏里的完整地址粘贴到这里,回车。', 'After approving, the browser lands on a localhost address that will not load — paste that full address here and press Enter.'));
|
|
135
|
+
if (canPrompt()) {
|
|
136
|
+
rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
137
|
+
rl.question(L('跳转地址:', 'Redirected address: '), (answer) => {
|
|
138
|
+
const url = answer.trim();
|
|
139
|
+
if (url) {
|
|
140
|
+
void control(ctx, 'POST', `/login/${s.id}/callback`, { redirectUrl: url }).catch((e) => warn(String(e.message)));
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
info(L('等待授权中(Ctrl+C 取消)…', 'Waiting for approval (Ctrl+C to cancel)…'));
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
for (;;) {
|
|
150
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
151
|
+
const st = await control(ctx, 'GET', `/login/${s.id}`);
|
|
152
|
+
if (st.status === 'ok') {
|
|
153
|
+
ok(L(`${id} 已登录并接入`, `${id} signed in and connected`));
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
if (st.status === 'error' || st.status === 'cancelled') {
|
|
157
|
+
warn(L(`登录没有完成:${st.error ?? st.status}`, `Login did not complete: ${st.error ?? st.status}`));
|
|
158
|
+
return 1;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
rl?.close();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const pct = (w) => (w.successRate == null ? '-' : `${Math.round(w.successRate * 100)}%`);
|
|
167
|
+
const ms = (w) => (w.avgLatencyMs == null ? '-' : w.avgLatencyMs >= 1000 ? `${(w.avgLatencyMs / 1000).toFixed(1)}s` : `${w.avgLatencyMs}ms`);
|
|
168
|
+
const tps = (w) => (w.tokensPerSec == null ? '-' : `${w.tokensPerSec}`);
|
|
169
|
+
const rate = (w) => {
|
|
170
|
+
const p = w.successRate;
|
|
171
|
+
const s = pct(w);
|
|
172
|
+
return p == null ? s : p >= 0.98 ? green(s) : p >= 0.9 ? yellow(s) : red(s);
|
|
173
|
+
};
|
|
174
|
+
export async function stats(ctx) {
|
|
175
|
+
const snap = await control(ctx, 'GET', '/stats');
|
|
176
|
+
if (isJsonMode()) {
|
|
177
|
+
printJson(snap);
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
const rows = (title, m) => {
|
|
181
|
+
const entries = Object.entries(m).sort((a, b) => b[1].d1.requests - a[1].d1.requests);
|
|
182
|
+
if (!entries.length)
|
|
183
|
+
return;
|
|
184
|
+
info(bold(`\n${title}`));
|
|
185
|
+
info(table([
|
|
186
|
+
['', L('请求(1h/24h)', 'Req (1h/24h)'), L('成功率', 'Success'), L('平均延迟', 'Avg latency'), 'tokens/s', L('最近失败', 'Last failure')],
|
|
187
|
+
...entries.map(([k, v]) => [k, `${v.h1.requests}/${v.d1.requests}`, rate(v.d1), ms(v.d1), tps(v.d1), v.d1.lastFailureAt ? v.d1.lastFailureAt.slice(5, 16).replace('T', ' ') : '-']),
|
|
188
|
+
]));
|
|
189
|
+
};
|
|
190
|
+
rows(L('按 key', 'By key'), snap.byKey);
|
|
191
|
+
rows(L('按来源', 'By source'), snap.bySource);
|
|
192
|
+
rows(L('按模型', 'By model'), snap.byModel);
|
|
193
|
+
if (!Object.keys(snap.byKey).length)
|
|
194
|
+
info(dim(L('还没有请求记录', 'No requests recorded yet')));
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// commands/status.ts — status(一屏总览)与 doctor(逐项检查,--e2e 实测每个模型)
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { availableVariants, bridgeConfigPath, loadBridgeConfig, loadCatalogs } from '../bridge/runtime.js';
|
|
4
|
+
import { anyProviderPresent } from '../dsh/connect.js';
|
|
5
|
+
import { KEY_REF, readRef } from '../dsh/credentials.js';
|
|
6
|
+
import { listAuthFiles, summarize } from '../engine/auth.js';
|
|
7
|
+
import { healthy, listModels, probeStream, probeToolCall, unauthStatus } from '../engine/client.js';
|
|
8
|
+
import { currentBinary, currentVersion } from '../engine/install.js';
|
|
9
|
+
import { L } from '../i18n.js';
|
|
10
|
+
import { opencodeConfigured } from '../integrations/opencode.js';
|
|
11
|
+
import { bridgeHealthy, userPluginInstalled } from '../integrations/workbuddy.js';
|
|
12
|
+
import { findActive, DSH_KEY_NAME } from '../keys.js';
|
|
13
|
+
import { loadAll } from '../ops.js';
|
|
14
|
+
import { serviceFor } from '../service/index.js';
|
|
15
|
+
import { UPSTREAMS } from '../upstreams.js';
|
|
16
|
+
import { exists, fileMode, readText, sha256 } from '../util/fs.js';
|
|
17
|
+
import { dim, green, info, isJsonMode, printJson, red, table, yellow } from '../util/output.js';
|
|
18
|
+
import { proxyReachable, redactProxy } from '../util/proxy.js';
|
|
19
|
+
/** 模型 id 的分组:opencode/… workbuddy/… 其余算订阅 */
|
|
20
|
+
function groupCounts(models) {
|
|
21
|
+
const g = new Map();
|
|
22
|
+
for (const m of models) {
|
|
23
|
+
const k = m.id.includes('/') ? m.id.split('/')[0] : 'subscription';
|
|
24
|
+
g.set(k, (g.get(k) ?? 0) + 1);
|
|
25
|
+
}
|
|
26
|
+
return g;
|
|
27
|
+
}
|
|
28
|
+
export async function status(ctx) {
|
|
29
|
+
const all = await loadAll(ctx);
|
|
30
|
+
const key = findActive(all.keys, DSH_KEY_NAME)?.key;
|
|
31
|
+
const engineInstalled = await exists(currentBinary(ctx));
|
|
32
|
+
const version = engineInstalled ? await currentVersion(ctx) : null;
|
|
33
|
+
const svc = engineInstalled && !ctx.serviceDisabled ? await serviceFor(ctx).status() : null;
|
|
34
|
+
const up = engineInstalled ? await healthy(all.config.port) : false;
|
|
35
|
+
const models = up && key ? await listModels(all.config.port, key).catch(() => []) : [];
|
|
36
|
+
const groups = groupCounts(models);
|
|
37
|
+
const { byUpstream } = summarize(await listAuthFiles(ctx));
|
|
38
|
+
const dshOk = await anyProviderPresent(all.state).catch(() => false);
|
|
39
|
+
const oc = await opencodeConfigured(ctx);
|
|
40
|
+
const bridgeCfg = await loadBridgeConfig(ctx.paths.home);
|
|
41
|
+
const bridgeUp = bridgeCfg ? await bridgeHealthy(bridgeCfg.port, bridgeCfg.secret) : false;
|
|
42
|
+
const cats = await loadCatalogs(ctx.paths.home);
|
|
43
|
+
const apps = await availableVariants(ctx.paths.home);
|
|
44
|
+
const logged = UPSTREAMS.filter((u) => byUpstream[u.id]?.length).map((u) => u.id);
|
|
45
|
+
const data = {
|
|
46
|
+
mode: ctx.mode,
|
|
47
|
+
home: ctx.paths.home,
|
|
48
|
+
endpoint: { version, running: up, port: all.config.port, service: svc, models: Object.fromEntries(groups) },
|
|
49
|
+
opencode: { configured: oc, models: groups.get('opencode') ?? 0 },
|
|
50
|
+
workbuddy: { apps: apps.map((v) => v.displayName), bridge: bridgeCfg ? { port: bridgeCfg.port, running: bridgeUp } : null, catalogs: cats.map((c) => ({ label: c.label, signedIn: c.signedIn, models: c.models.length, error: c.error })) },
|
|
51
|
+
subscriptions: logged,
|
|
52
|
+
dsh: all.state.dsh ? { profile: all.state.dsh.profile, providerPresent: dshOk } : null,
|
|
53
|
+
remote: all.config.remote.mode,
|
|
54
|
+
proxy: all.config.proxy ? redactProxy(all.config.proxy) : null,
|
|
55
|
+
keys: all.keys.keys.filter((k) => !k.revokedAt).map((k) => k.name),
|
|
56
|
+
};
|
|
57
|
+
if (isJsonMode()) {
|
|
58
|
+
printJson(data);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
const yes = (b, t, f) => (b ? green(t) : red(f));
|
|
62
|
+
const wbLine = !apps.length
|
|
63
|
+
? dim(L('未登录(dsh-model workbuddy login)', 'not signed in (dsh-model workbuddy login)'))
|
|
64
|
+
: !bridgeCfg
|
|
65
|
+
? yellow(L('未启用(dsh-model workbuddy enable)', 'not enabled (dsh-model workbuddy enable)'))
|
|
66
|
+
: !bridgeUp
|
|
67
|
+
? red(L(`bridge 没在运行(127.0.0.1:${bridgeCfg.port},dsh-model repair)`, `bridge not running (127.0.0.1:${bridgeCfg.port}, dsh-model repair)`))
|
|
68
|
+
: cats.map((c) => (c.signedIn ? green(`${c.label} ${c.models.length}`) : yellow(L(`${c.label} 未登录`, `${c.label} signed out`)))).join(' · ') || yellow(L('目录还没读到', 'catalog not read yet'));
|
|
69
|
+
info(table([
|
|
70
|
+
[L('统一端点', 'Endpoint'), engineInstalled ? yes(up, `127.0.0.1:${all.config.port}`, L('未运行(dsh-model repair)', 'not running (dsh-model repair)')) + dim(` v${version ?? '-'}${svc ? ` · ${svc.detail ?? '-'}` : ''}`) : red(L('未安装(dsh-model setup)', 'not installed (dsh-model setup)'))],
|
|
71
|
+
[L('模型', 'Models'), models.length ? [...groups].map(([g, n]) => `${g === 'subscription' ? L('订阅', 'subscription') : g} ${n}`).join(' · ') : yellow('0')],
|
|
72
|
+
['OpenCode Zen', oc ? green(L(`已配置 · ${groups.get('opencode') ?? 0} 个模型`, `configured · ${groups.get('opencode') ?? 0} models`)) : yellow(L('未配置(dsh-model opencode key)', 'not configured (dsh-model opencode key)'))],
|
|
73
|
+
['WorkBuddy', wbLine],
|
|
74
|
+
[L('订阅', 'Subscriptions'), logged.length ? logged.join(', ') : dim(L('无(dsh-model login <上游>)', 'none (dsh-model login <upstream>)'))],
|
|
75
|
+
['dsh', data.dsh ? yes(dshOk, `${data.dsh.profile} ✓`, L(`${data.dsh.profile}:provider 不在了(dsh-model repair)`, `${data.dsh.profile}: provider missing (dsh-model repair)`)) : yellow(L('未接入', 'not connected'))],
|
|
76
|
+
[L('远程访问', 'Remote'), all.config.remote.mode],
|
|
77
|
+
[L('出站代理', 'Proxy'), data.proxy ?? L('不使用', 'none')],
|
|
78
|
+
['Keys', data.keys.join(', ') || '-'],
|
|
79
|
+
]));
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
export async function doctor(ctx, opts) {
|
|
83
|
+
const all = await loadAll(ctx);
|
|
84
|
+
const checks = [];
|
|
85
|
+
const add = (id, level, message) => checks.push({ id, level, message });
|
|
86
|
+
const key = findActive(all.keys, DSH_KEY_NAME)?.key;
|
|
87
|
+
const homeMode = await fileMode(ctx.paths.home);
|
|
88
|
+
if (homeMode == null)
|
|
89
|
+
add('home', 'fail', L(`${ctx.paths.home} 不存在(先 setup)`, `${ctx.paths.home} missing (run setup)`));
|
|
90
|
+
else
|
|
91
|
+
add('home', homeMode & 0o077 ? 'warn' : 'ok', L(`home 权限 ${homeMode.toString(8)}`, `home mode ${homeMode.toString(8)}`));
|
|
92
|
+
for (const [id, file] of [['keys', ctx.paths.keys], ['engine-yaml', ctx.paths.engineYaml], ['secrets', join(ctx.paths.home, 'secrets.json')], ['bridge-config', bridgeConfigPath(ctx.paths.home)]]) {
|
|
93
|
+
const m = await fileMode(file);
|
|
94
|
+
if (m != null)
|
|
95
|
+
add(id, m & 0o077 ? 'warn' : 'ok', `${file.split('/').pop()} ${m.toString(8)}`);
|
|
96
|
+
}
|
|
97
|
+
const authFiles = await listAuthFiles(ctx);
|
|
98
|
+
const loose = [];
|
|
99
|
+
for (const f of authFiles)
|
|
100
|
+
if (((await fileMode(join(ctx.paths.auth, f))) ?? 0) & 0o077)
|
|
101
|
+
loose.push(f);
|
|
102
|
+
if (authFiles.length)
|
|
103
|
+
add('auth-perms', loose.length ? 'warn' : 'ok', loose.length ? L(`凭据文件权限过宽:${loose.join(', ')}`, `Credential files too permissive: ${loose.join(', ')}`) : L(`订阅凭据 ${authFiles.length} 个,权限正常`, `${authFiles.length} subscription credentials, permissions OK`));
|
|
104
|
+
const { byUpstream, other } = summarize(authFiles);
|
|
105
|
+
const multi = Object.entries(byUpstream).filter(([, f]) => f.length > 1);
|
|
106
|
+
if (multi.length)
|
|
107
|
+
add('single-account', 'warn', L(`同一上游有多个账号:${multi.map(([u]) => u).join(', ')}`, `Multiple accounts for: ${multi.map(([u]) => u).join(', ')}`));
|
|
108
|
+
if (other.length)
|
|
109
|
+
add('unknown-auth', 'warn', L(`不认识的凭据文件:${other.join(', ')}`, `Unrecognized credential files: ${other.join(', ')}`));
|
|
110
|
+
// —— 统一端点 ——
|
|
111
|
+
const engineInstalled = await exists(currentBinary(ctx));
|
|
112
|
+
const version = engineInstalled ? await currentVersion(ctx) : null;
|
|
113
|
+
add('engine', engineInstalled ? (version === all.config.engine.version ? 'ok' : 'warn') : 'fail', engineInstalled ? `v${version}` : L('未安装(dsh-model setup)', 'not installed (dsh-model setup)'));
|
|
114
|
+
if (engineInstalled && !ctx.serviceDisabled) {
|
|
115
|
+
const s = await serviceFor(ctx).status();
|
|
116
|
+
add('engine-service', s.running ? 'ok' : 'fail', `${serviceFor(ctx).kind}: ${s.detail ?? '-'}`);
|
|
117
|
+
}
|
|
118
|
+
if (all.config.proxy) {
|
|
119
|
+
const reach = await proxyReachable(all.config.proxy);
|
|
120
|
+
add('proxy', reach ? 'ok' : 'fail', reach ? L(`代理 ${redactProxy(all.config.proxy)} 可连接`, `Proxy ${redactProxy(all.config.proxy)} reachable`) : L(`代理 ${redactProxy(all.config.proxy)} 连不上(代理软件没开?dsh-model setup --proxy <地址>|none)`, `Proxy ${redactProxy(all.config.proxy)} unreachable (proxy app not running? dsh-model setup --proxy <url>|none)`));
|
|
121
|
+
}
|
|
122
|
+
const up = engineInstalled ? await healthy(all.config.port) : false;
|
|
123
|
+
if (engineInstalled)
|
|
124
|
+
add('healthz', up ? 'ok' : 'fail', `127.0.0.1:${all.config.port}`);
|
|
125
|
+
if (up) {
|
|
126
|
+
const code = await unauthStatus(all.config.port);
|
|
127
|
+
add('auth-enforced', code === 401 ? 'ok' : 'fail', L(`不带 key 请求返回 ${code}`, `unauthenticated request → ${code}`));
|
|
128
|
+
}
|
|
129
|
+
let models = [];
|
|
130
|
+
if (up && key) {
|
|
131
|
+
try {
|
|
132
|
+
models = await listModels(all.config.port, key);
|
|
133
|
+
const g = groupCounts(models);
|
|
134
|
+
add('models', models.length ? 'ok' : 'warn', models.length ? [...g].map(([k, n]) => `${k} ${n}`).join(' · ') : L('0 个模型', '0 models'));
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
add('models', 'fail', String(error.message));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// —— OpenCode / WorkBuddy ——
|
|
141
|
+
const oc = await opencodeConfigured(ctx);
|
|
142
|
+
const ocModels = models.filter((m) => m.id.startsWith('opencode/')).length;
|
|
143
|
+
add('opencode', !oc ? 'warn' : up && !ocModels ? 'fail' : 'ok', !oc ? L('OpenCode Zen 未配置(dsh-model opencode key)', 'OpenCode Zen not configured (dsh-model opencode key)') : up && !ocModels ? L('已配置但引擎里没有 opencode/ 模型(dsh-model repair)', 'configured but no opencode/ models in the engine (dsh-model repair)') : L(`OpenCode Zen:${ocModels} 个模型`, `OpenCode Zen: ${ocModels} models`));
|
|
144
|
+
const apps = await availableVariants(ctx.paths.home);
|
|
145
|
+
const bridgeCfg = await loadBridgeConfig(ctx.paths.home);
|
|
146
|
+
// 守护进程(来源开关、登录、统计、WorkBuddy 转发)总要在跑;只有刻意跳过系统服务时不查
|
|
147
|
+
if (!ctx.serviceDisabled) {
|
|
148
|
+
if (!bridgeCfg)
|
|
149
|
+
add('daemon', 'fail', L('守护进程没配置(dsh-model setup)', 'Daemon not configured (dsh-model setup)'));
|
|
150
|
+
else {
|
|
151
|
+
const bUp = await bridgeHealthy(bridgeCfg.port, bridgeCfg.secret);
|
|
152
|
+
add('daemon', bUp ? 'ok' : 'fail', bUp ? L(`守护进程 127.0.0.1:${bridgeCfg.port} 运行中`, `daemon 127.0.0.1:${bridgeCfg.port} running`) : L('守护进程没在运行(dsh-model logs --bridge;dsh-model repair)', 'daemon not running (dsh-model logs --bridge; dsh-model repair)'));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (apps.length && bridgeCfg) {
|
|
156
|
+
{
|
|
157
|
+
for (const c of await loadCatalogs(ctx.paths.home)) {
|
|
158
|
+
const inEngine = models.filter((m) => m.id.startsWith(`${c.prefix}/`)).length;
|
|
159
|
+
if (!c.signedIn)
|
|
160
|
+
add(`workbuddy-${c.key}`, 'warn', L(`${c.label}:未登录${c.error ? `(${c.error})` : ''}——在 App 里登录后执行 dsh-model workbuddy refresh`, `${c.label}: signed out${c.error ? ` (${c.error})` : ''} — sign in to the app, then dsh-model workbuddy refresh`));
|
|
161
|
+
else
|
|
162
|
+
add(`workbuddy-${c.key}`, up && !inEngine ? 'fail' : 'ok', L(`${c.label}:已登录,${c.models.length} 个模型${up ? `(引擎里 ${inEngine} 个)` : ''}${c.error ? `;${c.error}` : ''}`, `${c.label}: signed in, ${c.models.length} models${up ? ` (${inEngine} in engine)` : ''}${c.error ? `; ${c.error}` : ''}`));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (await userPluginInstalled(ctx, all))
|
|
166
|
+
add('workbuddy-plugin', 'warn', L('同时装了 dsh-workbuddy-connect 插件:dsh 里会有两组 WorkBuddy 模型', 'dsh-workbuddy-connect is also installed: dsh shows two WorkBuddy groups'));
|
|
167
|
+
}
|
|
168
|
+
// —— dsh 接线 ——
|
|
169
|
+
if (all.state.dsh) {
|
|
170
|
+
const d = all.state.dsh;
|
|
171
|
+
const present = await anyProviderPresent(all.state).catch(() => false);
|
|
172
|
+
add('dsh-provider', present ? 'ok' : models.length ? 'fail' : 'warn', present ? L(`dsh(${d.profile})里有 dsh-model`, `dsh-model present in dsh (${d.profile})`) : models.length ? L('dsh 里没有 dsh-model(dsh-model repair)', 'dsh-model missing in dsh (dsh-model repair)') : L('dsh 里还没有 dsh-model:引擎里还没有模型', 'dsh-model not in dsh yet: the engine has no models'));
|
|
173
|
+
const credText = await readText(d.credFile);
|
|
174
|
+
add('dsh-key', readRef(credText) === key ? 'ok' : 'fail', readRef(credText) === key ? L(`${KEY_REF} 与 key 一致`, `${KEY_REF} matches`) : L(`${KEY_REF} 与 key 不一致(dsh-model repair)`, `${KEY_REF} does not match (dsh-model repair)`));
|
|
175
|
+
const cm = await fileMode(d.credFile);
|
|
176
|
+
if (cm != null)
|
|
177
|
+
add('dsh-cred-perms', cm === 0o600 ? 'ok' : 'fail', L(`.credentials.yaml 权限 ${cm.toString(8)}(dsh 要求 600)`, `.credentials.yaml mode ${cm.toString(8)} (dsh requires 600)`));
|
|
178
|
+
const patchText = await readText(d.patchFile);
|
|
179
|
+
if (patchText != null && d.writtenPatchSha && sha256(patchText) !== d.writtenPatchSha) {
|
|
180
|
+
add('dsh-patch-edited', 'ok', L('cordis.patch.yml 在接线后被修改过(卸载时只会移除 dsh-model 的部分)', 'cordis.patch.yml edited since connect (uninstall will remove only dsh-model parts)'));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
add('dsh-provider', 'warn', L('还没接入 dsh(dsh-model setup)', 'Not connected to dsh (dsh-model setup)'));
|
|
185
|
+
}
|
|
186
|
+
if (ctx.env[KEY_REF])
|
|
187
|
+
add('env-override', 'warn', L(`环境变量 ${KEY_REF} 会覆盖 dsh 凭据里的值`, `Env var ${KEY_REF} overrides the value in dsh credentials`));
|
|
188
|
+
if ((await exists('/etc/caddy/dsh-model.conf')) && all.config.remote.mode !== 'caddy')
|
|
189
|
+
add('caddy-orphan', 'warn', L('发现残留的 /etc/caddy/dsh-model.conf', 'Found leftover /etc/caddy/dsh-model.conf'));
|
|
190
|
+
// —— e2e:默认每组(订阅按 owned_by,opencode/workbuddy 按前缀)取一个模型 ——
|
|
191
|
+
const e2e = [];
|
|
192
|
+
if (opts.e2e && key && models.length) {
|
|
193
|
+
const groupOf = (m) => (m.id.includes('/') ? m.id.split('/')[0] : (m.owned_by ?? m.id));
|
|
194
|
+
const pick = opts.model ? models.filter((m) => m.id === opts.model) : opts.all ? models : [...new Map(models.map((m) => [groupOf(m), m])).values()];
|
|
195
|
+
for (const m of pick) {
|
|
196
|
+
const s = await probeStream(all.config.port, key, m.id);
|
|
197
|
+
const t = await probeToolCall(all.config.port, key, m.id);
|
|
198
|
+
e2e.push({ model: m.id, stream: s.detail, tool: t.detail, ok: s.ok && t.ok });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const failed = checks.some((c) => c.level === 'fail') || e2e.some((r) => !r.ok);
|
|
202
|
+
if (isJsonMode()) {
|
|
203
|
+
printJson({ ok: !failed, checks, e2e });
|
|
204
|
+
return failed ? 1 : 0;
|
|
205
|
+
}
|
|
206
|
+
const mark = (l) => (l === 'ok' ? green('✓') : l === 'warn' ? yellow('!') : red('✗'));
|
|
207
|
+
info(table(checks.map((c) => [mark(c.level), c.id, c.message])));
|
|
208
|
+
if (e2e.length) {
|
|
209
|
+
info('');
|
|
210
|
+
info(table([[L('模型', 'Model'), L('流式', 'Stream'), L('工具调用', 'Tool call')], ...e2e.map((r) => [`${r.ok ? green('✓') : red('✗')} ${r.model}`, r.stream, r.tool])]));
|
|
211
|
+
}
|
|
212
|
+
else if (opts.e2e) {
|
|
213
|
+
info(yellow(L('没有可测的模型', 'No models to test')));
|
|
214
|
+
}
|
|
215
|
+
return failed ? 1 : 0;
|
|
216
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// commands/uninstall.ts — 按台账逆序拆除:remote → dsh 接线 → 服务 → home。每步失败不中断,最后汇总
|
|
2
|
+
import { cp, rm } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createInterface } from 'node:readline/promises';
|
|
6
|
+
import { requireRootInVps } from '../context.js';
|
|
7
|
+
import { disconnectDsh } from '../dsh/connect.js';
|
|
8
|
+
import { listAuthFiles } from '../engine/auth.js';
|
|
9
|
+
import { L } from '../i18n.js';
|
|
10
|
+
import { loadAll, saveAll } from '../ops.js';
|
|
11
|
+
import { serviceFor } from '../service/index.js';
|
|
12
|
+
import { exists, timestamp } from '../util/fs.js';
|
|
13
|
+
import { fail, info, ok, warn } from '../util/output.js';
|
|
14
|
+
import { disable as disableRemote } from './remote.js';
|
|
15
|
+
import { disableWorkbuddy, removeWorkbuddyPlugin } from '../integrations/workbuddy.js';
|
|
16
|
+
import { removeDshPlugin } from '../integrations/dshplugin.js';
|
|
17
|
+
async function confirm(question) {
|
|
18
|
+
if (!process.stdin.isTTY)
|
|
19
|
+
return false;
|
|
20
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
21
|
+
try {
|
|
22
|
+
return /^(y|yes|是)$/i.test((await rl.question(`${question} [y/N] `)).trim());
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
rl.close();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function uninstall(ctx, opts) {
|
|
29
|
+
requireRootInVps(ctx);
|
|
30
|
+
if (!(await exists(ctx.paths.home))) {
|
|
31
|
+
info(L('没有安装过 dsh-model(或已卸载)', 'dsh-model is not installed (or already removed)'));
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
const all = await loadAll(ctx);
|
|
35
|
+
const authCount = (await listAuthFiles(ctx)).length;
|
|
36
|
+
if (!opts.yes) {
|
|
37
|
+
info(L('将要:关闭远程访问 → 移除 WorkBuddy bridge → 还原 dsh 配置 → 移除引擎服务 → 删除 ' + ctx.paths.home + '(含 OpenCode key 与 WorkBuddy 令牌副本)', 'This will: disable remote access → remove the WorkBuddy bridge → restore dsh config → remove the engine service → delete ' + ctx.paths.home + ' (incl. the OpenCode key and WorkBuddy token copies)'));
|
|
38
|
+
if (authCount && !opts.keepAuth)
|
|
39
|
+
info(L(`其中包括 ${authCount} 个上游登录凭据(加 --keep-auth 可先备份出来)`, `including ${authCount} upstream login credentials (add --keep-auth to back them up first)`));
|
|
40
|
+
if (!(await confirm(L('确认卸载?', 'Proceed with uninstall?')))) {
|
|
41
|
+
info(L('已取消', 'Cancelled'));
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const errors = [];
|
|
46
|
+
const step = async (name, fn) => {
|
|
47
|
+
try {
|
|
48
|
+
await fn();
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
errors.push(`${name}: ${error.message}`);
|
|
52
|
+
fail(`${name}: ${error.message}`);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
await step('remote', async () => {
|
|
56
|
+
if (all.state.remote && all.state.remote.mode !== 'off')
|
|
57
|
+
await disableRemote(ctx, all);
|
|
58
|
+
});
|
|
59
|
+
await step('dsh-plugin', async () => {
|
|
60
|
+
if (await removeDshPlugin(ctx, all))
|
|
61
|
+
ok(L('已从 dsh 移除 dsh-model 插件', 'Removed the dsh-model plugin from dsh'));
|
|
62
|
+
});
|
|
63
|
+
await step('workbuddy', async () => {
|
|
64
|
+
if (await removeWorkbuddyPlugin(ctx, all))
|
|
65
|
+
ok(L('已移除上一版安装的 WorkBuddy 插件', 'Removed the WorkBuddy plugin installed by a previous version'));
|
|
66
|
+
if (await disableWorkbuddy(ctx, all))
|
|
67
|
+
ok(L('WorkBuddy bridge 服务已移除', 'WorkBuddy bridge service removed'));
|
|
68
|
+
});
|
|
69
|
+
await step('dsh', async () => {
|
|
70
|
+
if (!all.state.dsh)
|
|
71
|
+
return;
|
|
72
|
+
const r = await disconnectDsh(ctx, all.state);
|
|
73
|
+
ok(L(`dsh 配置已还原(patch:${r.patch},凭据:${r.cred})`, `dsh config restored (patch: ${r.patch}, credentials: ${r.cred})`));
|
|
74
|
+
});
|
|
75
|
+
await saveAll(ctx, all).catch(() => { });
|
|
76
|
+
await step('service', async () => {
|
|
77
|
+
if (ctx.serviceDisabled)
|
|
78
|
+
return;
|
|
79
|
+
await serviceFor(ctx).uninstall();
|
|
80
|
+
ok(L('系统服务已移除', 'Service removed'));
|
|
81
|
+
});
|
|
82
|
+
await step('auth-backup', async () => {
|
|
83
|
+
if (!opts.keepAuth || !authCount)
|
|
84
|
+
return;
|
|
85
|
+
const dest = join(homedir(), `dsh-model-auth-backup-${timestamp()}`);
|
|
86
|
+
await cp(ctx.paths.auth, dest, { recursive: true, filter: (src) => !src.includes('/logs') });
|
|
87
|
+
ok(L(`登录凭据已备份到 ${dest}`, `Credentials backed up to ${dest}`));
|
|
88
|
+
});
|
|
89
|
+
if (errors.length) {
|
|
90
|
+
warn(L(`有 ${errors.length} 步失败,保留了 ${ctx.paths.home} 以便重试(再次执行 uninstall)`, `${errors.length} step(s) failed; kept ${ctx.paths.home} so you can retry (run uninstall again)`));
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
await rm(ctx.paths.home, { recursive: true, force: true });
|
|
94
|
+
ok(L(`已删除 ${ctx.paths.home},卸载完成`, `Deleted ${ctx.paths.home}; uninstall complete`));
|
|
95
|
+
info(L('最后可执行:npm uninstall -g dsh-model', 'Finally you can run: npm uninstall -g dsh-model'));
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// commands/workbuddy.ts — dsh-model workbuddy [status|login|logout|enable|refresh|disable]
|
|
2
|
+
import { requireRootInVps } from '../context.js';
|
|
3
|
+
import { availableVariants, loadCatalogs } from '../bridge/runtime.js';
|
|
4
|
+
import { DshModelError } from '../errors.js';
|
|
5
|
+
import { L } from '../i18n.js';
|
|
6
|
+
import { disableWorkbuddy, enableWorkbuddy, refreshBridge, riskNotice } from '../integrations/workbuddy.js';
|
|
7
|
+
import { workbuddyLogin, workbuddyLogout } from '../bridge/login.js';
|
|
8
|
+
import { WORKBUDDY_VARIANTS } from '../bridge/workbuddy/variants.js';
|
|
9
|
+
import { bold } from '../util/output.js';
|
|
10
|
+
import { loadAll, saveAll, syncAll } from '../ops.js';
|
|
11
|
+
import { withLock } from '../state.js';
|
|
12
|
+
import { info, isJsonMode, ok, printJson, skip, warn } from '../util/output.js';
|
|
13
|
+
/** cn / ai(也认 workbuddy / workbuddy-ai),默认国内版 */
|
|
14
|
+
function pickVariant(arg) {
|
|
15
|
+
const a = (arg ?? 'cn').toLowerCase();
|
|
16
|
+
const id = a === 'ai' || a === 'workbuddy-ai' || a === 'global' ? 'workbuddy-ai' : a === 'cn' || a === 'workbuddy' ? 'workbuddy' : '';
|
|
17
|
+
const v = WORKBUDDY_VARIANTS.find((x) => x.id === id);
|
|
18
|
+
if (!v)
|
|
19
|
+
throw new DshModelError('unknown_workbuddy_product', L(`不认识的产品:${arg}(cn 或 ai)`, `Unknown product: ${arg} (cn or ai)`));
|
|
20
|
+
return v;
|
|
21
|
+
}
|
|
22
|
+
export async function workbuddy(ctx, sub, arg) {
|
|
23
|
+
if (sub === 'login')
|
|
24
|
+
return login(ctx, pickVariant(arg));
|
|
25
|
+
if (sub === 'logout')
|
|
26
|
+
return logout(ctx, pickVariant(arg));
|
|
27
|
+
if (!sub || sub === 'status') {
|
|
28
|
+
const [apps, cats] = await Promise.all([availableVariants(ctx.paths.home), loadCatalogs(ctx.paths.home)]);
|
|
29
|
+
if (isJsonMode()) {
|
|
30
|
+
printJson({ apps: apps.map((v) => v.displayName), catalogs: cats.map(({ models, ...c }) => ({ ...c, models: models.length })) });
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
info(L(`App:${apps.length ? apps.map((v) => v.displayName).join('、') : '未检测到'}`, `App: ${apps.length ? apps.map((v) => v.displayName).join(', ') : 'not found'}`));
|
|
34
|
+
for (const c of cats) {
|
|
35
|
+
info(` ${c.label}: ${c.signedIn ? L(`已登录${c.nickname ? `(${c.nickname})` : ''},${c.models.length} 个模型`, `signed in${c.nickname ? ` (${c.nickname})` : ''}, ${c.models.length} models`) : L('未登录', 'not signed in')}${c.error ? ` — ${c.error}` : ''}`);
|
|
36
|
+
}
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
requireRootInVps(ctx);
|
|
40
|
+
if (sub === 'refresh') {
|
|
41
|
+
if (await refreshBridge(ctx))
|
|
42
|
+
ok(L('bridge 已重读 WorkBuddy 登录态与目录,并同步到引擎和 dsh', 'bridge re-read WorkBuddy sign-in and catalog and synced the engine and dsh'));
|
|
43
|
+
else
|
|
44
|
+
warn(L('bridge 没在运行(dsh-model workbuddy enable)', 'bridge is not running (dsh-model workbuddy enable)'));
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
return withLock(ctx, async () => {
|
|
48
|
+
const all = await loadAll(ctx);
|
|
49
|
+
if (sub === 'enable') {
|
|
50
|
+
await enableWorkbuddy(ctx, all);
|
|
51
|
+
await saveAll(ctx, all);
|
|
52
|
+
await syncAll(ctx, all);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
if (sub === 'disable') {
|
|
56
|
+
if (!(await disableWorkbuddy(ctx, all))) {
|
|
57
|
+
skip(L('WorkBuddy 本来就没启用', 'WorkBuddy was not enabled'));
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
delete all.config.bridge;
|
|
61
|
+
await saveAll(ctx, all);
|
|
62
|
+
// bridge 不在了,目录文件也作废:删掉后重新生成引擎配置
|
|
63
|
+
const { rm } = await import('node:fs/promises');
|
|
64
|
+
const { bridgeDir, bridgeConfigPath } = await import('../bridge/runtime.js');
|
|
65
|
+
await rm(bridgeDir(ctx.paths.home), { recursive: true, force: true });
|
|
66
|
+
await rm(bridgeConfigPath(ctx.paths.home), { force: true });
|
|
67
|
+
await syncAll(ctx, all, { quiet: true });
|
|
68
|
+
ok(L('已停用 WorkBuddy(bridge 服务、目录与登录副本已移除)', 'WorkBuddy disabled (bridge service, catalogs and token copies removed)'));
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
throw new DshModelError('unknown_command', L(`未知子命令:workbuddy ${sub}`, `Unknown subcommand: workbuddy ${sub}`));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* 登录:轮询期间不持锁(最长 10 分钟),拿到令牌后再持锁启用 / 重启 bridge 并同步。
|
|
76
|
+
*/
|
|
77
|
+
async function login(ctx, variant) {
|
|
78
|
+
requireRootInVps(ctx);
|
|
79
|
+
const all = await loadAll(ctx);
|
|
80
|
+
if (!all.config.bridge?.riskNoticeAt)
|
|
81
|
+
warn(riskNotice());
|
|
82
|
+
info(L(`正在向 ${variant.displayName} 申请授权链接…`, `Requesting an authorization link from ${variant.displayName}…`));
|
|
83
|
+
const credential = await workbuddyLogin(variant, ctx.paths.home, {
|
|
84
|
+
proxy: all.config.proxy,
|
|
85
|
+
owner: ctx.owner,
|
|
86
|
+
onUrl: (url) => {
|
|
87
|
+
info('');
|
|
88
|
+
info(bold(L('在任意设备的浏览器里打开下面的链接并授权(服务器上不需要浏览器):', 'Open this link in a browser on any device and approve (no browser needed on the server):')));
|
|
89
|
+
info(`\n ${url}\n`);
|
|
90
|
+
info(L('等待授权中(最长 10 分钟,Ctrl+C 取消)…', 'Waiting for approval (up to 10 minutes, Ctrl+C to cancel)…'));
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
ok(L(`${variant.displayName} 已登录${credential.nickname ? `(${credential.nickname})` : ''};令牌只存在 dsh-model 自己的目录(0600)`, `${variant.displayName} signed in${credential.nickname ? ` (${credential.nickname})` : ''}; the token is kept only in dsh-model's own directory (0600)`));
|
|
94
|
+
return withLock(ctx, async () => {
|
|
95
|
+
const fresh = await loadAll(ctx);
|
|
96
|
+
await enableWorkbuddy(ctx, fresh);
|
|
97
|
+
await saveAll(ctx, fresh);
|
|
98
|
+
await syncAll(ctx, fresh);
|
|
99
|
+
return 0;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
async function logout(ctx, variant) {
|
|
103
|
+
requireRootInVps(ctx);
|
|
104
|
+
if (!(await workbuddyLogout(variant, ctx.paths.home))) {
|
|
105
|
+
skip(L(`${variant.displayName} 没有用 dsh-model 登录过(桌面 App 的登录请在 App 里退出)`, `${variant.displayName} was not signed in through dsh-model (sign out of the desktop app in the app itself)`));
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
ok(L(`已删除 dsh-model 保存的 ${variant.displayName} 令牌`, `Deleted the ${variant.displayName} token saved by dsh-model`));
|
|
109
|
+
return withLock(ctx, async () => {
|
|
110
|
+
const all = await loadAll(ctx);
|
|
111
|
+
if (all.state.bridgeService)
|
|
112
|
+
await enableWorkbuddy(ctx, all); // 重启 bridge,让它不再服务这个产品
|
|
113
|
+
await saveAll(ctx, all);
|
|
114
|
+
await syncAll(ctx, all, { quiet: true });
|
|
115
|
+
return 0;
|
|
116
|
+
});
|
|
117
|
+
}
|