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.
- package/README.en.md +127 -0
- package/README.md +96 -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 +183 -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 +215 -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 +83 -0
- package/dist/commands/remote.js +129 -0
- package/dist/commands/self.js +116 -0
- package/dist/commands/service.js +72 -0
- package/dist/commands/setup.js +219 -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 +850 -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 +90 -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 +89 -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 +48 -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 +688 -0
- package/plugin/cordis.patch.yml +4 -0
- package/plugin/index.js +231 -0
- package/docs/DESIGN.md +0 -610
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Ported from dsh-workbuddy-connect v0.7.1 (commit 390582e04da4e8e8bf746d71997440e4b716ec47),
|
|
2
|
+
// https://github.com/corrinehu/dsh-workbuddy-connect — MIT License, Copyright (c) 2026 Corrine Hu.
|
|
3
|
+
// See LICENSE.dsh-workbuddy-connect in this directory. Changes: imports adapted to dsh-model (./_compat.js).
|
|
4
|
+
/**
|
|
5
|
+
* The two WorkBuddy desktop apps this one plugin serves.
|
|
6
|
+
*
|
|
7
|
+
* Both products are the same client framework in different regions, and both
|
|
8
|
+
* write their sign-in into the *same* shared `CodeBuddyExtension` auth
|
|
9
|
+
* directory — they differ by file basename, base URL, catalog endpoint, and
|
|
10
|
+
* display identity. Everything that varies between them is collected here as
|
|
11
|
+
* one descriptor, so no module has to carry its own `if (international)`
|
|
12
|
+
* branch and a third variant would be a data change rather than a refactor.
|
|
13
|
+
*
|
|
14
|
+
* This module is host-side (it names files and env vars). The browser half
|
|
15
|
+
* takes the same ids and routes from the Node-free `status-paths.ts`, which
|
|
16
|
+
* stays the single source shared by both halves.
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-workbuddy-connect/variants
|
|
19
|
+
*/
|
|
20
|
+
import { WORKBUDDY_AI_STATUS_PATH, WORKBUDDY_AI_PROBE_PATH, WORKBUDDY_PROBE_PATH, WORKBUDDY_STATUS_PATH } from './status-paths.js';
|
|
21
|
+
/** CN WorkBuddy first: the existing provider keeps its id, paths, and copy. */
|
|
22
|
+
export const WORKBUDDY_VARIANTS = [
|
|
23
|
+
{
|
|
24
|
+
id: 'workbuddy',
|
|
25
|
+
displayName: 'WorkBuddy',
|
|
26
|
+
appName: 'WorkBuddy',
|
|
27
|
+
region: 'cn',
|
|
28
|
+
env: 'WORKBUDDY_AUTH_FILE',
|
|
29
|
+
electron: {
|
|
30
|
+
productName: 'WorkBuddy',
|
|
31
|
+
envVar: 'WORKBUDDY_ELECTRON_BIN',
|
|
32
|
+
macOS: {
|
|
33
|
+
bundleId: 'com.tencent.workbuddy.mac',
|
|
34
|
+
defaultPath: '/Applications/WorkBuddy.app/Contents/MacOS/Electron',
|
|
35
|
+
},
|
|
36
|
+
windows: {
|
|
37
|
+
displayNamePattern: /^WorkBuddy(?:\s+\d+(?:\.\d+)+(?:[-+][0-9A-Za-z.-]+)?)?$/u,
|
|
38
|
+
exeBasename: 'workbuddy.exe',
|
|
39
|
+
defaultPathSegments: ['Programs', 'WorkBuddy', 'WorkBuddy.exe'],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
desktopFilename: 'workbuddy-desktop.info',
|
|
43
|
+
ownFilename: '.workbuddy-auth.json',
|
|
44
|
+
probeFilename: '.workbuddy-probe.json',
|
|
45
|
+
catalogFilename: '.workbuddy-catalog.json',
|
|
46
|
+
visibilityFilename: '.workbuddy-model-visibility.json',
|
|
47
|
+
statusPath: WORKBUDDY_STATUS_PATH,
|
|
48
|
+
probePath: WORKBUDDY_PROBE_PATH,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'workbuddy-ai',
|
|
52
|
+
displayName: 'WorkBuddy AI',
|
|
53
|
+
appName: 'WorkBuddy AI',
|
|
54
|
+
region: 'global',
|
|
55
|
+
env: 'WORKBUDDY_AI_AUTH_FILE',
|
|
56
|
+
electron: {
|
|
57
|
+
productName: 'WorkBuddy AI',
|
|
58
|
+
envVar: 'WORKBUDDY_AI_ELECTRON_BIN',
|
|
59
|
+
macOS: {
|
|
60
|
+
bundleId: 'com.workbuddy.workbuddy-ai',
|
|
61
|
+
defaultPath: '/Applications/WorkBuddy AI.app/Contents/MacOS/Electron',
|
|
62
|
+
},
|
|
63
|
+
windows: {
|
|
64
|
+
// Measured in #60: `WorkBuddy AI 5.6.2`. The CN pattern cannot match
|
|
65
|
+
// this value ("AI" is not a version) and this pattern cannot match
|
|
66
|
+
// `WorkBuddy 5.6.2` (missing the literal " AI"), so the two records
|
|
67
|
+
// never feed each other's discovery.
|
|
68
|
+
displayNamePattern: /^WorkBuddy AI(?:\s+\d+(?:\.\d+)+(?:[-+][0-9A-Za-z.-]+)?)?$/u,
|
|
69
|
+
exeBasename: 'workbuddyai.exe',
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
desktopFilename: 'workbuddy-desktop-ai.info',
|
|
73
|
+
ownFilename: '.workbuddy-ai-auth.json',
|
|
74
|
+
probeFilename: '.workbuddy-ai-probe.json',
|
|
75
|
+
catalogFilename: '.workbuddy-ai-catalog.json',
|
|
76
|
+
visibilityFilename: '.workbuddy-ai-model-visibility.json',
|
|
77
|
+
statusPath: WORKBUDDY_AI_STATUS_PATH,
|
|
78
|
+
probePath: WORKBUDDY_AI_PROBE_PATH,
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
/** The CN variant; the plugin's long-standing default and compatibility anchor. */
|
|
82
|
+
export const CN_VARIANT = WORKBUDDY_VARIANTS[0];
|
|
83
|
+
/** The international variant. */
|
|
84
|
+
export const AI_VARIANT = WORKBUDDY_VARIANTS[1];
|
|
85
|
+
/** Look up a variant by provider id. */
|
|
86
|
+
export function variantFor(id) {
|
|
87
|
+
return WORKBUDDY_VARIANTS.find(variant => variant.id === id);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The Electron profile a variant resolves with.
|
|
91
|
+
*
|
|
92
|
+
* Callers inside the plugin pass their known-complete variants; descriptors
|
|
93
|
+
* assembled outside (the type is public and predates the profile) fall back
|
|
94
|
+
* by variant id, so an id-less or unknown custom variant stays on the CN
|
|
95
|
+
* product — the same default the store's other legacy fields assume.
|
|
96
|
+
*/
|
|
97
|
+
export function electronProfileFor(variant) {
|
|
98
|
+
// Both shipped variants always carry their profile; the optional marker
|
|
99
|
+
// exists only for externally assembled descriptors, hence the assertion.
|
|
100
|
+
return variant?.electron ?? (variant?.id === AI_VARIANT.id ? AI_VARIANT : CN_VARIANT).electron;
|
|
101
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// cli.ts — 参数解析与分发;统一错误输出(中英双语)与退出码
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { connect, disconnect, models } from './commands/connect.js';
|
|
4
|
+
import { engine } from './commands/engine.js';
|
|
5
|
+
import { key } from './commands/key.js';
|
|
6
|
+
import { logs } from './commands/logs.js';
|
|
7
|
+
import { remote } from './commands/remote.js';
|
|
8
|
+
import { repair, service } from './commands/service.js';
|
|
9
|
+
import { setup } from './commands/setup.js';
|
|
10
|
+
import { doctor, status } from './commands/status.js';
|
|
11
|
+
import { uninstall } from './commands/uninstall.js';
|
|
12
|
+
import { selfUpdate } from './commands/self.js';
|
|
13
|
+
import { opencode } from './commands/opencode.js';
|
|
14
|
+
import { workbuddy } from './commands/workbuddy.js';
|
|
15
|
+
import { bridge } from './commands/bridge.js';
|
|
16
|
+
import { enableInteractive, source, sources, stats } from './commands/sources.js';
|
|
17
|
+
import { createContext } from './context.js';
|
|
18
|
+
import { isDshModelError } from './errors.js';
|
|
19
|
+
import { L, initLang } from './i18n.js';
|
|
20
|
+
import { UPSTREAMS } from './upstreams.js';
|
|
21
|
+
import { dim, fail, isJsonMode, printJson, setJsonMode } from './util/output.js';
|
|
22
|
+
import { pkgVersion } from './util/pkg.js';
|
|
23
|
+
const OPTIONS = {
|
|
24
|
+
lang: { type: 'string' },
|
|
25
|
+
json: { type: 'boolean' },
|
|
26
|
+
help: { type: 'boolean', short: 'h' },
|
|
27
|
+
version: { type: 'boolean', short: 'v' },
|
|
28
|
+
port: { type: 'string' },
|
|
29
|
+
profile: { type: 'string' },
|
|
30
|
+
force: { type: 'boolean' },
|
|
31
|
+
'dry-run': { type: 'boolean' },
|
|
32
|
+
yes: { type: 'boolean', short: 'y' },
|
|
33
|
+
'keep-auth': { type: 'boolean' },
|
|
34
|
+
device: { type: 'boolean' },
|
|
35
|
+
replace: { type: 'boolean' },
|
|
36
|
+
'accept-risk': { type: 'boolean' },
|
|
37
|
+
via: { type: 'string' },
|
|
38
|
+
domain: { type: 'string' },
|
|
39
|
+
e2e: { type: 'boolean' },
|
|
40
|
+
all: { type: 'boolean' },
|
|
41
|
+
model: { type: 'string' },
|
|
42
|
+
lines: { type: 'string' },
|
|
43
|
+
proxy: { type: 'string' },
|
|
44
|
+
bridge: { type: 'boolean' },
|
|
45
|
+
'skip-opencode': { type: 'boolean' },
|
|
46
|
+
'skip-workbuddy': { type: 'boolean' },
|
|
47
|
+
'skip-dsh-plugin': { type: 'boolean' },
|
|
48
|
+
stdin: { type: 'boolean' },
|
|
49
|
+
'skip-verify': { type: 'boolean' },
|
|
50
|
+
to: { type: 'string' },
|
|
51
|
+
'remove-plugin': { type: 'boolean' },
|
|
52
|
+
};
|
|
53
|
+
function help() {
|
|
54
|
+
const ups = UPSTREAMS.map((u) => u.id).join('|');
|
|
55
|
+
return L(`dsh-model ${pkgVersion()} — 把你自己的 CLI 订阅接进 dsh(本机 OpenAI 兼容端点,默认鉴权,可干净卸载)
|
|
56
|
+
|
|
57
|
+
用法:dsh-model <命令> [选项]
|
|
58
|
+
|
|
59
|
+
setup [--profile P] [--proxy URL|none] [--skip-opencode] [--skip-workbuddy]
|
|
60
|
+
一键安装统一端点,接入 OpenCode Zen(你的 key)与 WorkBuddy(自有 bridge),并接进 dsh;可重复执行
|
|
61
|
+
opencode [status|key|remove] [--stdin] [--skip-verify]
|
|
62
|
+
OpenCode Zen:设置 / 更换 key,或移除
|
|
63
|
+
workbuddy [status|login [cn|ai]|logout [cn|ai]|enable|refresh|disable]
|
|
64
|
+
WorkBuddy:桌面 App 登录态,或 login 打印链接、在任意浏览器授权(服务器可用)
|
|
65
|
+
sources 来源列表:开关、登录状态、账号、模型数
|
|
66
|
+
source enable|disable|logout <来源> [--accept-risk]
|
|
67
|
+
打开(未登录就打印链接登录)/ 关闭(保留登录)/ 退出登录
|
|
68
|
+
login <来源> / logout <来源> 同 source enable / source logout
|
|
69
|
+
stats 按 key / 来源 / 模型的成功率、延迟、速度
|
|
70
|
+
status 一屏总览
|
|
71
|
+
doctor [--e2e] [--all] [--model M] 逐项自检;--e2e 实测流式与工具调用
|
|
72
|
+
connect-dsh [--dry-run] [--force] 写入 / 刷新 dsh 配置
|
|
73
|
+
disconnect-dsh 还原 dsh 配置
|
|
74
|
+
models [sync] 列出模型 / 同步到 dsh
|
|
75
|
+
key list|add|revoke|rotate <名称> 管理访问 key(每台设备一把)
|
|
76
|
+
remote enable --via ssh|tailscale|caddy [--domain D] / remote disable
|
|
77
|
+
让你自己的其他设备访问
|
|
78
|
+
engine version|upgrade [版本]|rollback 引擎版本管理
|
|
79
|
+
service status|install|start|stop|restart|uninstall
|
|
80
|
+
repair 按台账修复配置、服务与接线
|
|
81
|
+
logs [--lines N] [--bridge] 引擎 / bridge 日志
|
|
82
|
+
update [--to 版本] 更新到 npm 上的最新版(程序、dsh 插件、服务一起)
|
|
83
|
+
uninstall [--yes] [--keep-auth] 干净卸载
|
|
84
|
+
|
|
85
|
+
通用选项:--lang zh|en --json --help --version
|
|
86
|
+
环境变量:DSH_HOME、DSH_MODEL_HOME、DSH_MODEL_LANG`, `dsh-model ${pkgVersion()} — wire your own CLI subscriptions into dsh (local OpenAI-compatible endpoint, auth by default, clean uninstall)
|
|
87
|
+
|
|
88
|
+
Usage: dsh-model <command> [options]
|
|
89
|
+
|
|
90
|
+
setup [--profile P] [--proxy URL|none] [--skip-opencode] [--skip-workbuddy]
|
|
91
|
+
Install the unified endpoint, connect OpenCode Zen (your key) and WorkBuddy (own bridge), wire into dsh; idempotent
|
|
92
|
+
opencode [status|key|remove] [--stdin] [--skip-verify]
|
|
93
|
+
OpenCode Zen: set / change the key, or remove
|
|
94
|
+
workbuddy [status|login [cn|ai]|logout [cn|ai]|enable|refresh|disable]
|
|
95
|
+
WorkBuddy: desktop app sign-in, or login prints a link to approve in any browser (works on servers)
|
|
96
|
+
sources Sources: switch, sign-in state, account, models
|
|
97
|
+
source enable|disable|logout <source> [--accept-risk]
|
|
98
|
+
Turn on (prints a login link if needed) / off (keeps sign-in) / sign out
|
|
99
|
+
login <source> / logout <source> Same as source enable / source logout
|
|
100
|
+
stats Success rate, latency and speed by key / source / model
|
|
101
|
+
status Overview
|
|
102
|
+
doctor [--e2e] [--all] [--model M] Health checks; --e2e tests streaming and tool calls
|
|
103
|
+
connect-dsh [--dry-run] [--force] Write / refresh dsh config
|
|
104
|
+
disconnect-dsh Restore dsh config
|
|
105
|
+
models [sync] List models / sync them to dsh
|
|
106
|
+
key list|add|revoke|rotate <name> Manage access keys (one per device)
|
|
107
|
+
remote enable --via ssh|tailscale|caddy [--domain D] / remote disable
|
|
108
|
+
Access from your other devices
|
|
109
|
+
engine version|upgrade [ver]|rollback Engine version management
|
|
110
|
+
service status|install|start|stop|restart|uninstall
|
|
111
|
+
repair Repair config, service and wiring from the ledger
|
|
112
|
+
logs [--lines N] [--bridge] Engine / bridge logs
|
|
113
|
+
update [--to ver] Update to the latest npm version (program, dsh plugin and services)
|
|
114
|
+
uninstall [--yes] [--keep-auth] Clean uninstall
|
|
115
|
+
|
|
116
|
+
Global: --lang zh|en --json --help --version
|
|
117
|
+
Env: DSH_HOME, DSH_MODEL_HOME, DSH_MODEL_LANG`);
|
|
118
|
+
}
|
|
119
|
+
export async function main(argv) {
|
|
120
|
+
let parsed;
|
|
121
|
+
try {
|
|
122
|
+
parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true });
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
initLang();
|
|
126
|
+
fail(String(error.message));
|
|
127
|
+
return 2;
|
|
128
|
+
}
|
|
129
|
+
const { values: o, positionals: [cmd, a1, a2] } = parsed;
|
|
130
|
+
initLang(o.lang);
|
|
131
|
+
setJsonMode(Boolean(o.json));
|
|
132
|
+
if (o.version) {
|
|
133
|
+
console.log(pkgVersion());
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
if (o.help || !cmd || cmd === 'help') {
|
|
137
|
+
console.log(help());
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const ctx = await createContext();
|
|
142
|
+
const port = o.port ? Number(o.port) : undefined;
|
|
143
|
+
if (port !== undefined && !(Number.isInteger(port) && port > 0 && port < 65536))
|
|
144
|
+
throw new Error(L(`端口不合法:${o.port}`, `Invalid port: ${o.port}`));
|
|
145
|
+
switch (cmd) {
|
|
146
|
+
case 'setup':
|
|
147
|
+
return await setup(ctx, { port, profile: o.profile, force: o.force, proxy: o.proxy, skipOpencode: o['skip-opencode'], skipWorkbuddy: o['skip-workbuddy'], skipDshPlugin: o['skip-dsh-plugin'] });
|
|
148
|
+
case 'opencode':
|
|
149
|
+
return await opencode(ctx, a1, { stdin: o.stdin, skipVerify: o['skip-verify'] });
|
|
150
|
+
case 'workbuddy':
|
|
151
|
+
return await workbuddy(ctx, a1, a2);
|
|
152
|
+
case 'bridge':
|
|
153
|
+
return await bridge(ctx, a1);
|
|
154
|
+
case 'login':
|
|
155
|
+
// 统一走守护进程:链接 + 码 / 贴回跳转地址,服务器上不需要浏览器(设计 §14.2)
|
|
156
|
+
if (!a1)
|
|
157
|
+
throw new Error(L('用法:dsh-model login <来源>(dsh-model sources 查看)', 'Usage: dsh-model login <source> (see dsh-model sources)'));
|
|
158
|
+
return await enableInteractive(ctx, a1, { acceptRisk: o['accept-risk'] });
|
|
159
|
+
case 'logout':
|
|
160
|
+
if (!a1)
|
|
161
|
+
throw new Error(L('用法:dsh-model logout <来源>', 'Usage: dsh-model logout <source>'));
|
|
162
|
+
return await source(ctx, 'logout', a1, {});
|
|
163
|
+
case 'sources':
|
|
164
|
+
return await sources(ctx, { all: o.all });
|
|
165
|
+
case 'source':
|
|
166
|
+
return await source(ctx, a1, a2, { acceptRisk: o['accept-risk'] });
|
|
167
|
+
case 'stats':
|
|
168
|
+
return await stats(ctx);
|
|
169
|
+
case 'status':
|
|
170
|
+
return await status(ctx);
|
|
171
|
+
case 'doctor':
|
|
172
|
+
return await doctor(ctx, { e2e: o.e2e, all: o.all, model: o.model });
|
|
173
|
+
case 'connect-dsh':
|
|
174
|
+
return await connect(ctx, { dryRun: o['dry-run'], profile: o.profile, force: o.force });
|
|
175
|
+
case 'disconnect-dsh':
|
|
176
|
+
return await disconnect(ctx);
|
|
177
|
+
case 'models':
|
|
178
|
+
return await models(ctx, a1);
|
|
179
|
+
case 'key':
|
|
180
|
+
return await key(ctx, a1, a2);
|
|
181
|
+
case 'remote':
|
|
182
|
+
return await remote(ctx, a1, { via: o.via, domain: o.domain });
|
|
183
|
+
case 'engine':
|
|
184
|
+
return await engine(ctx, a1, a2);
|
|
185
|
+
case 'service':
|
|
186
|
+
return await service(ctx, a1);
|
|
187
|
+
case 'repair':
|
|
188
|
+
return await repair(ctx);
|
|
189
|
+
case 'logs':
|
|
190
|
+
return await logs(ctx, o.lines ? Number(o.lines) : undefined, o.bridge ? 'bridge' : 'engine');
|
|
191
|
+
case 'uninstall':
|
|
192
|
+
return await uninstall(ctx, { yes: o.yes, keepAuth: o['keep-auth'], removePlugin: o['remove-plugin'] });
|
|
193
|
+
case 'self-update':
|
|
194
|
+
case 'update':
|
|
195
|
+
return await selfUpdate(ctx, { to: o.to });
|
|
196
|
+
default:
|
|
197
|
+
fail(L(`未知命令:${cmd}`, `Unknown command: ${cmd}`));
|
|
198
|
+
console.log(dim(L('查看帮助:dsh-model --help', 'See: dsh-model --help')));
|
|
199
|
+
return 2;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (isJsonMode()) {
|
|
204
|
+
printJson({ ok: false, code: isDshModelError(error) ? error.code : 'error', message: error.message, hint: isDshModelError(error) ? error.hint : undefined });
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
fail(error.message);
|
|
208
|
+
if (isDshModelError(error) && error.hint)
|
|
209
|
+
console.error(dim(` ${error.hint}`));
|
|
210
|
+
if (!isDshModelError(error) && process.env.DSH_MODEL_DEBUG)
|
|
211
|
+
console.error(error.stack);
|
|
212
|
+
}
|
|
213
|
+
return 1;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// commands/bridge.ts — dsh-model bridge run(守护进程入口,系统服务调用,常驻)与 bridge status
|
|
2
|
+
//
|
|
3
|
+
// 守护进程 = WorkBuddy bridge(OpenAI 兼容转发)+ 控制接口(来源开关、登录、key、统计,设计 §14)。
|
|
4
|
+
import { createBridge } from '../bridge/server.js';
|
|
5
|
+
import { loadBridgeConfig } from '../bridge/runtime.js';
|
|
6
|
+
import { Daemon } from '../daemon/core.js';
|
|
7
|
+
import { DshModelError } from '../errors.js';
|
|
8
|
+
import { L } from '../i18n.js';
|
|
9
|
+
import { info, isJsonMode, printJson } from '../util/output.js';
|
|
10
|
+
const log = (m) => console.error(`[${new Date().toISOString()}] ${m}`);
|
|
11
|
+
export async function bridge(ctx, sub) {
|
|
12
|
+
if (sub === 'run')
|
|
13
|
+
return run(ctx);
|
|
14
|
+
if (!sub || sub === 'status')
|
|
15
|
+
return status(ctx);
|
|
16
|
+
throw new DshModelError('unknown_command', L(`未知子命令:bridge ${sub}`, `Unknown subcommand: bridge ${sub}`));
|
|
17
|
+
}
|
|
18
|
+
async function status(ctx) {
|
|
19
|
+
const cfg = await loadBridgeConfig(ctx.paths.home);
|
|
20
|
+
if (!cfg) {
|
|
21
|
+
info(L('守护进程未配置(dsh-model setup)', 'Daemon not configured (dsh-model setup)'));
|
|
22
|
+
return 1;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const r = await fetch(`http://127.0.0.1:${cfg.port}/status`, { headers: { Authorization: `Bearer ${cfg.secret}` }, signal: AbortSignal.timeout(5000) });
|
|
26
|
+
const body = await r.json();
|
|
27
|
+
if (isJsonMode())
|
|
28
|
+
printJson(body);
|
|
29
|
+
else
|
|
30
|
+
info(JSON.stringify(body, null, 2));
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
info(L(`守护进程没在 127.0.0.1:${cfg.port} 上运行`, `Daemon is not running on 127.0.0.1:${cfg.port}`));
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function run(ctx) {
|
|
39
|
+
const cfg = await loadBridgeConfig(ctx.paths.home);
|
|
40
|
+
if (!cfg)
|
|
41
|
+
throw new DshModelError('bridge_not_configured', L('守护进程未配置,先执行 dsh-model setup', 'Daemon not configured; run dsh-model setup first'));
|
|
42
|
+
const daemon = new Daemon(ctx);
|
|
43
|
+
await daemon.start();
|
|
44
|
+
log(`daemon: products ${daemon.runtimes.map((r) => r.label).join(', ') || '(none)'}`);
|
|
45
|
+
const server = createBridge({
|
|
46
|
+
port: cfg.port,
|
|
47
|
+
secret: cfg.secret,
|
|
48
|
+
variants: daemon.runtimes,
|
|
49
|
+
log,
|
|
50
|
+
onRefresh: async () => {
|
|
51
|
+
const changed = await daemon.refreshCatalogs();
|
|
52
|
+
if (changed)
|
|
53
|
+
await daemon.resync();
|
|
54
|
+
return { changed };
|
|
55
|
+
},
|
|
56
|
+
onControl: (method, path, body, query) => daemon.handle(method, path, body, query),
|
|
57
|
+
});
|
|
58
|
+
await server.ready;
|
|
59
|
+
log(`daemon: listening on 127.0.0.1:${cfg.port}`);
|
|
60
|
+
await new Promise((resolve) => {
|
|
61
|
+
const stop = () => {
|
|
62
|
+
void daemon.stop().then(() => server.close()).then(resolve);
|
|
63
|
+
};
|
|
64
|
+
process.once('SIGTERM', stop);
|
|
65
|
+
process.once('SIGINT', stop);
|
|
66
|
+
});
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// commands/connect.ts — connect-dsh / disconnect-dsh / models
|
|
2
|
+
import { requireRootInVps } from '../context.js';
|
|
3
|
+
import { connectDsh, disconnectDsh } from '../dsh/connect.js';
|
|
4
|
+
import { listModels } from '../engine/client.js';
|
|
5
|
+
import { L } from '../i18n.js';
|
|
6
|
+
import { dshKey, loadAll, saveAll, syncModels } from '../ops.js';
|
|
7
|
+
import { withLock } from '../state.js';
|
|
8
|
+
import { info, isJsonMode, ok, printJson, skip } from '../util/output.js';
|
|
9
|
+
export async function connect(ctx, opts) {
|
|
10
|
+
requireRootInVps(ctx);
|
|
11
|
+
return withLock(ctx, async () => {
|
|
12
|
+
const all = await loadAll(ctx);
|
|
13
|
+
if (opts.dryRun) {
|
|
14
|
+
const key = dshKey(all.keys);
|
|
15
|
+
const ids = (await listModels(all.config.port, key)).map((m) => m.id);
|
|
16
|
+
const r = await connectDsh(ctx, structuredClone(all.state), all.keys, {
|
|
17
|
+
providerId: all.config.dsh.providerId,
|
|
18
|
+
port: all.config.port,
|
|
19
|
+
key,
|
|
20
|
+
models: ids.map((id) => ({ id, name: id })),
|
|
21
|
+
profile: opts.profile ?? all.config.dsh.profile,
|
|
22
|
+
force: opts.force,
|
|
23
|
+
dryRun: true,
|
|
24
|
+
});
|
|
25
|
+
info(r.changed ? r.diff : L('没有变化', 'No changes'));
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
await syncModels(ctx, all, { profile: opts.profile ?? null, force: opts.force });
|
|
29
|
+
return 0;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export async function disconnect(ctx) {
|
|
33
|
+
requireRootInVps(ctx);
|
|
34
|
+
return withLock(ctx, async () => {
|
|
35
|
+
const all = await loadAll(ctx);
|
|
36
|
+
if (!all.state.dsh) {
|
|
37
|
+
skip(L('没有接入过 dsh', 'Not connected to dsh'));
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
const r = await disconnectDsh(ctx, all.state);
|
|
41
|
+
await saveAll(ctx, all);
|
|
42
|
+
const say = (what, how) => how === 'restored'
|
|
43
|
+
? ok(L(`${what}:已逐字节还原`, `${what}: restored byte-for-byte`))
|
|
44
|
+
: how === 'surgical'
|
|
45
|
+
? ok(L(`${what}:期间被修改过,只移除了 dsh-model 写入的部分`, `${what}: modified meanwhile; removed only dsh-model's part`))
|
|
46
|
+
: skip(L(`${what}:无需改动`, `${what}: nothing to change`));
|
|
47
|
+
say('cordis.patch.yml', r.patch);
|
|
48
|
+
say('.credentials.yaml', r.cred);
|
|
49
|
+
return 0;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
export async function models(ctx, sub) {
|
|
53
|
+
const all = await loadAll(ctx);
|
|
54
|
+
if (sub === 'sync') {
|
|
55
|
+
requireRootInVps(ctx);
|
|
56
|
+
return withLock(ctx, async () => {
|
|
57
|
+
await syncModels(ctx, all);
|
|
58
|
+
return 0;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const list = await listModels(all.config.port, dshKey(all.keys));
|
|
62
|
+
if (isJsonMode())
|
|
63
|
+
printJson(list);
|
|
64
|
+
else
|
|
65
|
+
list.forEach((m) => info(`${m.id}${m.owned_by ? ` (${m.owned_by})` : ''}`));
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// commands/engine.ts — engine version / upgrade / rollback
|
|
2
|
+
//
|
|
3
|
+
// 升级:装新版本 → 在临时端口、用 auth 的副本起一个试运行实例 → healthz 与 /v1/models 通过才切换 → 重启服务 → 失败自动切回
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { cp, mkdir, rm } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import YAML from 'yaml';
|
|
8
|
+
import { requireRootInVps } from '../context.js';
|
|
9
|
+
import { listModels, waitHealthy } from '../engine/client.js';
|
|
10
|
+
import { renderEngineConfig } from '../engine/config.js';
|
|
11
|
+
import { activateVersion, currentVersion, fetchUpstreamAsset, installVersion, loadManifest, platformTarget, probeBinaryVersion, versionBinary, currentBinary } from '../engine/install.js';
|
|
12
|
+
import { DshModelError } from '../errors.js';
|
|
13
|
+
import { L } from '../i18n.js';
|
|
14
|
+
import { dshKey, loadAll, saveAll } from '../ops.js';
|
|
15
|
+
import { serviceFor } from '../service/index.js';
|
|
16
|
+
import { withLock } from '../state.js';
|
|
17
|
+
import { atomicWrite } from '../util/fs.js';
|
|
18
|
+
import { info, ok, warn } from '../util/output.js';
|
|
19
|
+
import { findFreePort } from '../util/port.js';
|
|
20
|
+
export async function engine(ctx, sub, arg) {
|
|
21
|
+
if (!sub || sub === 'version') {
|
|
22
|
+
const v = await currentVersion(ctx);
|
|
23
|
+
const probed = v ? await probeBinaryVersion(currentBinary(ctx)) : null;
|
|
24
|
+
const manifest = await loadManifest();
|
|
25
|
+
info(L(`在用:${v ?? '未安装'}${probed && probed !== v ? `(二进制报告 ${probed})` : ''};本版 dsh-model 锁定:${manifest.version}`, `Active: ${v ?? 'not installed'}${probed && probed !== v ? ` (binary reports ${probed})` : ''}; pinned by this dsh-model: ${manifest.version}`));
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
requireRootInVps(ctx);
|
|
29
|
+
if (sub === 'upgrade')
|
|
30
|
+
return withLock(ctx, () => upgrade(ctx, arg));
|
|
31
|
+
if (sub === 'rollback')
|
|
32
|
+
return withLock(ctx, () => rollback(ctx));
|
|
33
|
+
throw new DshModelError('unknown_command', L(`未知子命令:engine ${sub}`, `Unknown subcommand: engine ${sub}`));
|
|
34
|
+
}
|
|
35
|
+
async function upgrade(ctx, requested) {
|
|
36
|
+
const all = await loadAll(ctx);
|
|
37
|
+
const manifest = await loadManifest();
|
|
38
|
+
const target = platformTarget();
|
|
39
|
+
const version = (requested ?? manifest.version).replace(/^v/, '');
|
|
40
|
+
const from = await currentVersion(ctx);
|
|
41
|
+
if (from === version) {
|
|
42
|
+
ok(L(`已经是 v${version}`, `Already on v${version}`));
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
let asset = version === manifest.version ? manifest.assets[target] : undefined;
|
|
46
|
+
if (!asset) {
|
|
47
|
+
warn(L(`v${version} 不在本版 dsh-model 的锁定清单里,校验值取自上游发布的 checksums.txt`, `v${version} is not pinned by this dsh-model; using the checksum from the upstream checksums.txt`));
|
|
48
|
+
asset = await fetchUpstreamAsset(manifest, version, target);
|
|
49
|
+
}
|
|
50
|
+
await installVersion(ctx, version, asset);
|
|
51
|
+
ok(L(`v${version} 已下载并校验`, `v${version} downloaded and verified`));
|
|
52
|
+
await trialRun(ctx, all, versionBinary(ctx, version));
|
|
53
|
+
ok(L('试运行通过', 'Trial run passed'));
|
|
54
|
+
all.state.engine.previous = from ?? undefined;
|
|
55
|
+
if (!all.state.engine.versions.includes(version))
|
|
56
|
+
all.state.engine.versions.push(version);
|
|
57
|
+
await activateVersion(ctx, version);
|
|
58
|
+
all.config.engine.version = version;
|
|
59
|
+
await saveAll(ctx, all);
|
|
60
|
+
if (!(await restartAndCheck(ctx, all.config.port))) {
|
|
61
|
+
warn(L('新版本启动失败,正在切回旧版本', 'New version failed to start; switching back'));
|
|
62
|
+
if (from) {
|
|
63
|
+
await activateVersion(ctx, from);
|
|
64
|
+
all.config.engine.version = from;
|
|
65
|
+
await saveAll(ctx, all);
|
|
66
|
+
await restartAndCheck(ctx, all.config.port);
|
|
67
|
+
}
|
|
68
|
+
throw new DshModelError('engine_upgrade_failed', L('升级失败,已回滚', 'Upgrade failed and was rolled back'));
|
|
69
|
+
}
|
|
70
|
+
ok(L(`引擎已升级:${from ?? '-'} → v${version}`, `Engine upgraded: ${from ?? '-'} → v${version}`));
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
async function rollback(ctx) {
|
|
74
|
+
const all = await loadAll(ctx);
|
|
75
|
+
const prev = all.state.engine.previous;
|
|
76
|
+
if (!prev)
|
|
77
|
+
throw new DshModelError('no_previous_engine', L('没有可回滚的旧版本', 'No previous engine version to roll back to'));
|
|
78
|
+
const from = await currentVersion(ctx);
|
|
79
|
+
await activateVersion(ctx, prev);
|
|
80
|
+
all.config.engine.version = prev;
|
|
81
|
+
all.state.engine.previous = from ?? undefined;
|
|
82
|
+
await saveAll(ctx, all);
|
|
83
|
+
if (!(await restartAndCheck(ctx, all.config.port)))
|
|
84
|
+
throw new DshModelError('engine_unhealthy', L('回滚后引擎没有启动', 'Engine did not start after rollback'));
|
|
85
|
+
ok(L(`已回滚到 v${prev}`, `Rolled back to v${prev}`));
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
async function restartAndCheck(ctx, port) {
|
|
89
|
+
if (ctx.serviceDisabled)
|
|
90
|
+
return true;
|
|
91
|
+
await serviceFor(ctx).restart();
|
|
92
|
+
return waitHealthy(port, 15_000);
|
|
93
|
+
}
|
|
94
|
+
/** 用 auth 的副本在临时端口起新二进制,确认能启动、鉴权、列模型 */
|
|
95
|
+
async function trialRun(ctx, all, bin) {
|
|
96
|
+
const dir = join(ctx.paths.tmp, 'trial');
|
|
97
|
+
await rm(dir, { recursive: true, force: true });
|
|
98
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
99
|
+
try {
|
|
100
|
+
await cp(ctx.paths.auth, join(dir, 'auth'), { recursive: true, filter: (src) => !src.includes('/logs') });
|
|
101
|
+
const port = await findFreePort(all.config.port + 100);
|
|
102
|
+
const doc = YAML.parse(renderEngineConfig(ctx, all.config, all.keys, port));
|
|
103
|
+
doc.oauth['auth-dir'] = join(dir, 'auth');
|
|
104
|
+
const cfg = join(dir, 'engine.yaml');
|
|
105
|
+
await atomicWrite(cfg, YAML.stringify(doc));
|
|
106
|
+
const child = spawn(bin, ['-config', cfg], { cwd: dir, stdio: 'ignore' });
|
|
107
|
+
try {
|
|
108
|
+
if (!(await waitHealthy(port, 15_000)))
|
|
109
|
+
throw new DshModelError('engine_trial_failed', L('新版本试运行没有启动', 'New version did not start in trial run'));
|
|
110
|
+
const before = await listModels(all.config.port, dshKey(all.keys)).catch(() => []);
|
|
111
|
+
const models = await listModels(port, dshKey(all.keys));
|
|
112
|
+
if (before.length && !models.length)
|
|
113
|
+
throw new DshModelError('engine_trial_failed', L('新版本试运行列不出模型', 'New version lists no models in trial run'));
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
child.kill('SIGTERM');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await rm(dir, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// commands/key.ts — key add / list / revoke / rotate
|
|
2
|
+
import { requireRootInVps } from '../context.js';
|
|
3
|
+
import { DshModelError } from '../errors.js';
|
|
4
|
+
import { L } from '../i18n.js';
|
|
5
|
+
import { DSH_KEY_NAME, addKey, revokeKey, rotateKey, saveKeys } from '../keys.js';
|
|
6
|
+
import { applyEngineConfig, dshKey, loadAll, syncModels } from '../ops.js';
|
|
7
|
+
import { withLock } from '../state.js';
|
|
8
|
+
import { baseUrl } from '../engine/client.js';
|
|
9
|
+
import { bold, info, isJsonMode, ok, printJson, table, warn } from '../util/output.js';
|
|
10
|
+
import { redactKey } from '../util/redact.js';
|
|
11
|
+
export async function key(ctx, sub, name) {
|
|
12
|
+
if (!sub || sub === 'list')
|
|
13
|
+
return list(ctx);
|
|
14
|
+
requireRootInVps(ctx);
|
|
15
|
+
if (!name)
|
|
16
|
+
throw new DshModelError('missing_arg', L(`用法:dsh-model key ${sub} <名称>`, `Usage: dsh-model key ${sub} <name>`));
|
|
17
|
+
return withLock(ctx, async () => {
|
|
18
|
+
const all = await loadAll(ctx);
|
|
19
|
+
dshKey(all.keys); // 没 setup 过就报错
|
|
20
|
+
if (sub === 'add') {
|
|
21
|
+
const entry = addKey(all.keys, name);
|
|
22
|
+
await saveKeys(ctx, all.keys);
|
|
23
|
+
await applyEngineConfig(ctx, all);
|
|
24
|
+
if (isJsonMode())
|
|
25
|
+
printJson({ ok: true, name, key: entry.key, baseURL: `${baseUrl(all.config.port)}/v1` });
|
|
26
|
+
ok(L(`已新增 key:${name}(只显示这一次,请妥善保存)`, `Key added: ${name} (shown only once; keep it safe)`));
|
|
27
|
+
info(`\n ${bold(entry.key)}\n`);
|
|
28
|
+
info(L(` Base URL:${baseUrl(all.config.port)}/v1(远程访问见 dsh-model remote enable)`, ` Base URL: ${baseUrl(all.config.port)}/v1 (for remote access see dsh-model remote enable)`));
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
if (sub === 'revoke') {
|
|
32
|
+
if (name === DSH_KEY_NAME) {
|
|
33
|
+
throw new DshModelError('cannot_revoke_dsh', L('dsh 用的 key 不能吊销,只能轮换', 'The dsh key cannot be revoked, only rotated'), L('dsh-model key rotate dsh', 'dsh-model key rotate dsh'));
|
|
34
|
+
}
|
|
35
|
+
revokeKey(all.keys, name);
|
|
36
|
+
await saveKeys(ctx, all.keys);
|
|
37
|
+
await applyEngineConfig(ctx, all);
|
|
38
|
+
ok(L(`已吊销 key:${name}`, `Key revoked: ${name}`));
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
if (sub === 'rotate') {
|
|
42
|
+
const entry = rotateKey(all.keys, name);
|
|
43
|
+
await saveKeys(ctx, all.keys);
|
|
44
|
+
await applyEngineConfig(ctx, all);
|
|
45
|
+
if (name === DSH_KEY_NAME) {
|
|
46
|
+
// 引擎热重载约 1s:等新 key 生效后再改 dsh
|
|
47
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
48
|
+
await syncModels(ctx, all, { quiet: true });
|
|
49
|
+
ok(L('已轮换 dsh 用的 key,并同步到 dsh', 'Rotated the dsh key and updated dsh'));
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
if (isJsonMode())
|
|
53
|
+
printJson({ ok: true, name, key: entry.key });
|
|
54
|
+
ok(L(`已轮换 key:${name}`, `Key rotated: ${name}`));
|
|
55
|
+
info(`\n ${bold(entry.key)}\n`);
|
|
56
|
+
}
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
throw new DshModelError('unknown_command', L(`未知子命令:key ${sub}`, `Unknown subcommand: key ${sub}`));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
async function list(ctx) {
|
|
63
|
+
const { keys } = await loadAll(ctx);
|
|
64
|
+
const active = keys.keys.filter((k) => !k.revokedAt);
|
|
65
|
+
if (isJsonMode()) {
|
|
66
|
+
printJson(active.map((k) => ({ name: k.name, key: redactKey(k.key), createdAt: k.createdAt })));
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
if (!active.length) {
|
|
70
|
+
warn(L('还没有 key,先执行 dsh-model setup', 'No keys yet; run dsh-model setup first'));
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
info(table([[L('名称', 'Name'), 'Key', L('创建时间', 'Created')], ...active.map((k) => [k.name, redactKey(k.key), k.createdAt.slice(0, 19).replace('T', ' ')])]));
|
|
74
|
+
return 0;
|
|
75
|
+
}
|