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,113 @@
1
+ // commands/login.ts — 引导登录上游(交互交给引擎),之后同步模型到 dsh;以及 logout
2
+ import { mkdir, rename, rm } from 'node:fs/promises';
3
+ import { hostname, userInfo } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { requireRootInVps } from '../context.js';
6
+ import { filesFor, listAuthFiles, tightenAuthPerms } from '../engine/auth.js';
7
+ import { listModels } from '../engine/client.js';
8
+ import { currentBinary } from '../engine/install.js';
9
+ import { DshModelError } from '../errors.js';
10
+ import { L } from '../i18n.js';
11
+ import { dshKey, loadAll, saveAll, syncModels } from '../ops.js';
12
+ import { withLock } from '../state.js';
13
+ import { ensureEngine } from './setup.js';
14
+ import { getUpstream, riskNotice } from '../upstreams.js';
15
+ import { runInherit } from '../util/exec.js';
16
+ import { exists } from '../util/fs.js';
17
+ import { bold, info, ok } from '../util/output.js';
18
+ function isHeadless(ctx) {
19
+ if (ctx.mode === 'vps' || ctx.env.SSH_CONNECTION || ctx.env.SSH_TTY)
20
+ return true;
21
+ if (ctx.platform === 'linux')
22
+ return !ctx.env.DISPLAY && !ctx.env.WAYLAND_DISPLAY;
23
+ return false;
24
+ }
25
+ /** 远端主机名:优先 SSH_CONNECTION 里的服务器地址 */
26
+ function sshTarget(ctx) {
27
+ const serverIp = ctx.env.SSH_CONNECTION?.split(' ')[2];
28
+ const user = ctx.env.SUDO_USER || userInfo().username;
29
+ return `${user}@${serverIp || hostname()}`;
30
+ }
31
+ export async function login(ctx, upstream, opts) {
32
+ requireRootInVps(ctx);
33
+ const def = getUpstream(upstream);
34
+ return withLock(ctx, async () => {
35
+ const all = await loadAll(ctx);
36
+ const bin = currentBinary(ctx);
37
+ if (!(await exists(bin)) || !(await exists(ctx.paths.engineYaml))) {
38
+ info(L('首次使用订阅上游,先安装引擎…', 'First subscription login: installing the engine…'));
39
+ await ensureEngine(ctx, all);
40
+ }
41
+ if (def.requiresRiskAck) {
42
+ if (!opts.acceptRisk) {
43
+ info(riskNotice(def));
44
+ return 2;
45
+ }
46
+ all.config.upstreams[def.id] = { ...all.config.upstreams[def.id], riskAcceptedAt: new Date().toISOString() };
47
+ }
48
+ // 单账号:已有凭据要 --replace;先挪走,成功后删,失败挪回
49
+ const existing = filesFor(await listAuthFiles(ctx), def);
50
+ if (existing.length && !opts.replace) {
51
+ throw new DshModelError('already_logged_in', L(`${def.label} 已经登录(${existing.join(', ')})`, `${def.label} is already logged in (${existing.join(', ')})`), L(`换账号:dsh-model login ${def.id} --replace`, `To switch accounts: dsh-model login ${def.id} --replace`));
52
+ }
53
+ if (existing.length) {
54
+ await mkdir(ctx.paths.replaced, { recursive: true, mode: 0o700 });
55
+ for (const f of existing)
56
+ await rename(join(ctx.paths.auth, f), join(ctx.paths.replaced, f));
57
+ }
58
+ const before = await listModels(all.config.port, dshKey(all.keys)).then((m) => m.map((x) => x.id)).catch(() => []);
59
+ const headless = isHeadless(ctx);
60
+ const flag = opts.device && def.deviceFlag ? def.deviceFlag : def.flag;
61
+ const args = ['-config', ctx.paths.engineYaml, flag, ...(headless ? ['-no-browser'] : [])];
62
+ if (headless && def.callbackPort && flag === def.flag) {
63
+ info(bold(L('这台机器没有浏览器。两种方式任选其一:', 'No browser on this machine. Pick either way:')));
64
+ info(L(` 1. 在你自己的电脑上另开终端执行:ssh -N -L ${def.callbackPort}:127.0.0.1:${def.callbackPort} ${sshTarget(ctx)}`, ` 1. On your own computer, in another terminal: ssh -N -L ${def.callbackPort}:127.0.0.1:${def.callbackPort} ${sshTarget(ctx)}`));
65
+ info(L(' 然后在本地浏览器打开下面打印的授权链接。', ' then open the authorization URL printed below in your local browser.'));
66
+ info(L(' 2. 直接在本地浏览器打开授权链接,授权后把浏览器跳转到的地址(打不开也没关系)粘贴回这里。', ' 2. Open the URL in your local browser; after approving, paste the address it redirects to (even if the page fails to load) back here.'));
67
+ if (def.deviceFlag)
68
+ info(L(` 也可以改用 device-code:dsh-model login ${def.id} --device`, ` Or use device code instead: dsh-model login ${def.id} --device`));
69
+ info('');
70
+ }
71
+ const cmd = ctx.runAs && ctx.isRoot ? 'sudo' : bin;
72
+ const cmdArgs = ctx.runAs && ctx.isRoot ? ['-u', ctx.runAs, '-H', bin, ...args] : args;
73
+ const env = { ...ctx.env, ...(all.config.proxy ? { HTTPS_PROXY: all.config.proxy, HTTP_PROXY: all.config.proxy } : {}) };
74
+ const code = await runInherit(cmd, all.config.proxy && cmd === 'sudo' ? ['--preserve-env=HTTPS_PROXY,HTTP_PROXY', ...cmdArgs] : cmdArgs, { cwd: ctx.paths.home, env });
75
+ const after = filesFor(await listAuthFiles(ctx), def);
76
+ if (code !== 0 || after.length === 0) {
77
+ for (const f of existing)
78
+ await rename(join(ctx.paths.replaced, f), join(ctx.paths.auth, f)).catch(() => { });
79
+ // 引擎等授权回调超时(5 分钟)或被取消时也返回 0,所以按"没拿到凭据"来提示
80
+ const hint = def.deviceFlag && flag !== def.deviceFlag
81
+ ? L(`没收到授权回调。网页报错或超时多半是网络 / 代理节点问题:换个节点重试,或改用 device-code:dsh-model login ${def.id} --device`, `No authorization callback received. A web error or timeout is usually a network / proxy-node issue: retry on another node, or use device code: dsh-model login ${def.id} --device`)
82
+ : L('没收到授权结果,请重试;引擎日志:dsh-model logs', 'No authorization result; please retry. Engine log: dsh-model logs');
83
+ const message = code === 0
84
+ ? L(`${def.label} 登录没有完成(超时或已取消)`, `${def.label} login did not complete (timed out or cancelled)`)
85
+ : L(`${def.label} 登录失败(退出码 ${code})`, `${def.label} login failed (exit code ${code})`);
86
+ throw new DshModelError('login_failed', message, hint);
87
+ }
88
+ await rm(ctx.paths.replaced, { recursive: true, force: true });
89
+ await tightenAuthPerms(ctx);
90
+ ok(L(`${def.label} 已登录`, `${def.label} logged in`));
91
+ await saveAll(ctx, all);
92
+ await syncModels(ctx, all, { before });
93
+ return 0;
94
+ });
95
+ }
96
+ export async function logout(ctx, upstream) {
97
+ requireRootInVps(ctx);
98
+ const def = getUpstream(upstream);
99
+ return withLock(ctx, async () => {
100
+ const all = await loadAll(ctx);
101
+ const files = filesFor(await listAuthFiles(ctx), def);
102
+ if (!files.length) {
103
+ info(L(`${def.label} 没有登录`, `${def.label} is not logged in`));
104
+ return 0;
105
+ }
106
+ const before = await listModels(all.config.port, dshKey(all.keys)).then((m) => m.map((x) => x.id)).catch(() => []);
107
+ for (const f of files)
108
+ await rm(join(ctx.paths.auth, f), { force: true });
109
+ ok(L(`${def.label} 已退出登录`, `${def.label} logged out`));
110
+ await syncModels(ctx, all, { before });
111
+ return 0;
112
+ });
113
+ }
@@ -0,0 +1,19 @@
1
+ // commands/logs.ts — 打印引擎日志末尾
2
+ import { join } from 'node:path';
3
+ import { L } from '../i18n.js';
4
+ import { readText } from '../util/fs.js';
5
+ import { info, warn } from '../util/output.js';
6
+ export async function logs(ctx, lines = 80, which = 'engine') {
7
+ const files = which === 'bridge' ? [join(ctx.paths.home, 'bridge.stdout.log')] : [join(ctx.paths.logs, 'main.log'), join(ctx.paths.home, 'engine.stdout.log')];
8
+ for (const file of files) {
9
+ const text = await readText(file);
10
+ if (text == null)
11
+ continue;
12
+ info(`==> ${file}`);
13
+ info(text.trimEnd().split('\n').slice(-lines).join('\n'));
14
+ info('');
15
+ return 0;
16
+ }
17
+ warn(L('还没有日志', 'No logs yet'));
18
+ return 0;
19
+ }
@@ -0,0 +1,77 @@
1
+ // commands/opencode.ts — dsh-model opencode [status|key|remove],以及 setup 里的 OpenCode 步骤
2
+ import { requireRootInVps } from '../context.js';
3
+ import { DshModelError } from '../errors.js';
4
+ import { L } from '../i18n.js';
5
+ import { OPENCODE_KEY_PAGE, opencodeConfigured, refreshOpencodeModels, removeOpencodeKey, saveOpencodeKey, validateKeyShape, verifyOpencodeKey, } from '../integrations/opencode.js';
6
+ import { loadAll, syncAll } from '../ops.js';
7
+ import { withLock } from '../state.js';
8
+ import { info, isJsonMode, ok, printJson, skip } from '../util/output.js';
9
+ import { canPrompt, promptSecret, readStdin } from '../util/prompt.js';
10
+ /** 拿 key → 实测 → 存进 secrets.json → 拉模型目录。返回是否有变化(调用方随后 syncAll) */
11
+ export async function configureOpencode(ctx, all, opts = {}) {
12
+ if ((await opencodeConfigured(ctx)) && !opts.replace) {
13
+ const n = await refreshOpencodeModels(ctx, all.config.proxy).catch(() => 0);
14
+ skip(L(`已配置${n ? `(${n} 个模型)` : ''};换 key:dsh-model opencode key`, `Configured${n ? ` (${n} models)` : ''}; to change the key: dsh-model opencode key`));
15
+ return n > 0;
16
+ }
17
+ let key;
18
+ if (opts.stdin) {
19
+ key = await readStdin();
20
+ }
21
+ else if (canPrompt()) {
22
+ info(L(`需要你的 OpenCode Zen API key(免费注册:${OPENCODE_KEY_PAGE})。输入不会显示,直接回车跳过。`, `Needs your OpenCode Zen API key (free sign-up: ${OPENCODE_KEY_PAGE}). Input is hidden; press Enter to skip.`));
23
+ key = await promptSecret('OpenCode Zen API key: ');
24
+ if (!key) {
25
+ skip(L('已跳过;之后可执行 dsh-model opencode key', 'Skipped; later run dsh-model opencode key'));
26
+ return false;
27
+ }
28
+ }
29
+ else {
30
+ skip(L('非交互环境,跳过;之后执行 dsh-model opencode key(或 echo <key> | dsh-model opencode key --stdin)', 'Non-interactive; skipped. Later run dsh-model opencode key (or echo <key> | dsh-model opencode key --stdin)'));
31
+ return false;
32
+ }
33
+ validateKeyShape(key);
34
+ let verifiedModel;
35
+ if (!opts.skipVerify) {
36
+ info(L('正在用一个免费模型实测这把 key…', 'Testing the key against a free model…'));
37
+ const v = await verifyOpencodeKey(key, all.config.proxy);
38
+ if (!v.ok) {
39
+ throw new DshModelError('opencode_key_rejected', L(`实测失败(${v.model ?? '-'}):${v.detail}`, `Test failed (${v.model ?? '-'}): ${v.detail}`), L('确认 key 正确后重试;确定要先写入可加 --skip-verify', 'Check the key and retry; to write it anyway add --skip-verify'));
40
+ }
41
+ verifiedModel = v.model;
42
+ ok(L(`key 可用(${v.model} 实测通过${v.detail !== 'ok' ? `;${v.detail}` : ''})`, `Key works (${v.model} test passed${v.detail !== 'ok' ? `; ${v.detail}` : ''})`));
43
+ }
44
+ await saveOpencodeKey(ctx, key, verifiedModel);
45
+ const n = await refreshOpencodeModels(ctx, all.config.proxy);
46
+ ok(L(`OpenCode Zen 已接入统一端点(${n} 个模型,名字前缀 opencode/)`, `OpenCode Zen connected to the unified endpoint (${n} models, prefixed opencode/)`));
47
+ return true;
48
+ }
49
+ export async function opencode(ctx, sub, opts) {
50
+ if (!sub || sub === 'status') {
51
+ const configured = await opencodeConfigured(ctx);
52
+ if (isJsonMode())
53
+ printJson({ configured });
54
+ else
55
+ info(configured ? L('已配置(key 在 dsh-model 的 secrets.json,由引擎持有)', 'Configured (key in dsh-model secrets.json, held by the engine)') : L('未配置(dsh-model opencode key)', 'Not configured (dsh-model opencode key)'));
56
+ return 0;
57
+ }
58
+ requireRootInVps(ctx);
59
+ return withLock(ctx, async () => {
60
+ const all = await loadAll(ctx);
61
+ if (sub === 'key') {
62
+ if (await configureOpencode(ctx, all, { stdin: opts.stdin, skipVerify: opts.skipVerify, replace: true }))
63
+ await syncAll(ctx, all);
64
+ return 0;
65
+ }
66
+ if (sub === 'remove') {
67
+ if (!(await removeOpencodeKey(ctx))) {
68
+ skip(L('OpenCode 本来就没配置', 'OpenCode was not configured'));
69
+ return 0;
70
+ }
71
+ await syncAll(ctx, all, { quiet: true });
72
+ ok(L('已移除 OpenCode Zen(key 与模型)', 'Removed OpenCode Zen (key and models)'));
73
+ return 0;
74
+ }
75
+ throw new DshModelError('unknown_command', L(`未知子命令:opencode ${sub}`, `Unknown subcommand: opencode ${sub}`));
76
+ });
77
+ }
@@ -0,0 +1,129 @@
1
+ // commands/remote.ts — 让本人其他设备访问引擎。引擎始终只绑 127.0.0.1,入口只有三种:
2
+ // ssh(默认,服务端零改动)/ tailscale serve / caddy(仅 vps,复用 dsh-vps 的 Caddy)
3
+ import { readFile, rm } from 'node:fs/promises';
4
+ import { hostname, userInfo } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { requireRootInVps } from '../context.js';
7
+ import { DshModelError } from '../errors.js';
8
+ import { L } from '../i18n.js';
9
+ import { loadAll, saveAll } from '../ops.js';
10
+ import { withLock } from '../state.js';
11
+ import { run, which } from '../util/exec.js';
12
+ import { atomicWrite, exists, timestamp } from '../util/fs.js';
13
+ import { bold, info, next, ok, skip } from '../util/output.js';
14
+ import { DEFAULT_PUBLIC_PORT, closeFirewall, openFirewall, publicHost, siteBlock } from '../remote/public.js';
15
+ const CADDYFILE = '/etc/caddy/Caddyfile';
16
+ const CADDY_SNIPPET = '/etc/caddy/dsh-model.conf';
17
+ const IMPORT_LINE = `import ${CADDY_SNIPPET}`;
18
+ const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
19
+ export async function remote(ctx, sub, opts) {
20
+ requireRootInVps(ctx);
21
+ return withLock(ctx, async () => {
22
+ const all = await loadAll(ctx);
23
+ if (sub === 'disable') {
24
+ await disable(ctx, all);
25
+ await saveAll(ctx, all);
26
+ return 0;
27
+ }
28
+ if (sub !== 'enable')
29
+ throw new DshModelError('unknown_command', L('用法:dsh-model remote enable --via ssh|tailscale|caddy / remote disable', 'Usage: dsh-model remote enable --via ssh|tailscale|caddy / remote disable'));
30
+ const via = (opts.via ?? 'ssh');
31
+ if (all.state.remote && all.state.remote.mode !== 'off' && all.state.remote.mode !== via)
32
+ await disable(ctx, all);
33
+ const port = all.config.port;
34
+ if (via === 'ssh') {
35
+ all.state.remote = { mode: 'ssh', files: [] };
36
+ const target = `${ctx.env.SUDO_USER || userInfo().username}@${ctx.env.SSH_CONNECTION?.split(' ')[2] || hostname()}`;
37
+ info(bold(L('在你自己的设备上执行(保持运行):', 'On your own device, run (keep it open):')));
38
+ info(` ssh -N -L ${port}:127.0.0.1:${port} ${target}`);
39
+ info(L(`然后在那台设备的软件里填:Base URL http://127.0.0.1:${port}/v1`, `Then in software on that device use: Base URL http://127.0.0.1:${port}/v1`));
40
+ }
41
+ else if (via === 'tailscale') {
42
+ if (!(await which('tailscale')))
43
+ throw new DshModelError('tailscale_missing', L('没找到 tailscale 命令', 'tailscale command not found'), L('先安装并登录 Tailscale', 'Install and log in to Tailscale first'));
44
+ const r = await run('tailscale', ['serve', '--bg', `--https=${port}`, `http://127.0.0.1:${port}`]);
45
+ if (r.code !== 0)
46
+ throw new DshModelError('tailscale_failed', L(`tailscale serve 失败:${r.stderr.trim()}`, `tailscale serve failed: ${r.stderr.trim()}`));
47
+ all.state.remote = { mode: 'tailscale', files: [], tailscaleHttpsPort: port };
48
+ const dns = await run('tailscale', ['status', '--json']);
49
+ const name = (() => {
50
+ try {
51
+ return String(JSON.parse(dns.stdout).Self?.DNSName ?? '').replace(/\.$/, '');
52
+ }
53
+ catch {
54
+ return '';
55
+ }
56
+ })();
57
+ ok(L('已通过 tailscale serve 开放给你的 tailnet', 'Exposed to your tailnet via tailscale serve'));
58
+ info(L(` Base URL:https://${name || '<本机 tailnet 域名>'}:${port}/v1`, ` Base URL: https://${name || '<this machine tailnet name>'}:${port}/v1`));
59
+ }
60
+ else if (via === 'caddy') {
61
+ if (ctx.mode !== 'vps')
62
+ throw new DshModelError('caddy_vps_only', L('caddy 方式只用于 dsh-vps 所在的服务器', 'The caddy method is only for the dsh-vps server'));
63
+ if (opts.domain && !DOMAIN_RE.test(opts.domain))
64
+ throw new DshModelError('invalid_domain', L(`域名不合法:${opts.domain}`, `Invalid domain: ${opts.domain}`));
65
+ // 默认用 dsh 自己的域名(只有 IP 时用 <ip>.sslip.io),另开一个端口
66
+ const host = await publicHost(opts.domain ?? all.config.remote.domain);
67
+ const publicPort = all.config.remote.publicPort ?? DEFAULT_PUBLIC_PORT;
68
+ await enableCaddy(ctx, all, host, publicPort);
69
+ all.config.remote.domain = host;
70
+ all.config.remote.publicPort = publicPort;
71
+ ok(L(`对外端点已开通:https://${host}:${publicPort}/v1(只认 dsh-model 的 key)`, `Public endpoint enabled: https://${host}:${publicPort}/v1 (dsh-model keys only)`));
72
+ }
73
+ else {
74
+ throw new DshModelError('unknown_remote', L(`不支持的方式:${via}`, `Unsupported method: ${via}`));
75
+ }
76
+ all.config.remote.mode = via;
77
+ await saveAll(ctx, all);
78
+ next(L('为每台设备单独发一把 key:dsh-model key add <设备名>(丢了就 key revoke)', 'Issue one key per device: dsh-model key add <device> (revoke it if lost)'));
79
+ return 0;
80
+ });
81
+ }
82
+ export async function enableCaddy(ctx, all, domain, publicPort = DEFAULT_PUBLIC_PORT) {
83
+ if (!(await exists(CADDYFILE)))
84
+ throw new DshModelError('caddy_missing', L(`没找到 ${CADDYFILE}`, `${CADDYFILE} not found`));
85
+ const original = await readFile(CADDYFILE, 'utf8');
86
+ const backup = join(ctx.paths.backups, `Caddyfile.${timestamp()}`);
87
+ await atomicWrite(backup, original);
88
+ await atomicWrite(CADDY_SNIPPET, siteBlock(domain, publicPort, all.config.port), { mode: 0o644 });
89
+ const hasImport = original.split('\n').some((l) => l.trim() === IMPORT_LINE);
90
+ if (!hasImport)
91
+ await atomicWrite(CADDYFILE, original.replace(/\n*$/, '\n') + IMPORT_LINE + '\n', { mode: 0o644 });
92
+ const v = await run('caddy', ['validate', '--config', CADDYFILE, '--adapter', 'caddyfile']);
93
+ if (v.code !== 0) {
94
+ await atomicWrite(CADDYFILE, original, { mode: 0o644 });
95
+ await rm(CADDY_SNIPPET, { force: true });
96
+ throw new DshModelError('caddy_invalid', L(`Caddy 配置校验失败,已回滚:${v.stderr.trim().slice(-300)}`, `Caddy config validation failed; rolled back: ${v.stderr.trim().slice(-300)}`));
97
+ }
98
+ const r = await run('systemctl', ['reload', 'caddy']);
99
+ if (r.code !== 0)
100
+ throw new DshModelError('caddy_reload_failed', L(`Caddy 重载失败:${r.stderr.trim()}`, `Caddy reload failed: ${r.stderr.trim()}`));
101
+ await openFirewall(publicPort);
102
+ all.state.remote = { mode: 'caddy', files: [CADDY_SNIPPET], caddyImportLine: hasImport ? undefined : IMPORT_LINE, caddyfile: CADDYFILE, publicUrl: `https://${domain}:${publicPort}/v1`, firewallPort: publicPort };
103
+ all.config.remote.mode = 'caddy';
104
+ }
105
+ export async function disable(ctx, all) {
106
+ const r = all.state.remote;
107
+ if (!r || r.mode === 'off') {
108
+ skip(L('远程访问本来就是关闭的', 'Remote access is already off'));
109
+ return;
110
+ }
111
+ if (r.mode === 'tailscale' && r.tailscaleHttpsPort) {
112
+ await run('tailscale', ['serve', `--https=${r.tailscaleHttpsPort}`, 'off']);
113
+ }
114
+ if (r.mode === 'caddy') {
115
+ for (const f of r.files)
116
+ await rm(f, { force: true });
117
+ if (r.caddyImportLine && r.caddyfile && (await exists(r.caddyfile))) {
118
+ const text = await readFile(r.caddyfile, 'utf8');
119
+ const kept = text.split('\n').filter((l) => l.trim() !== r.caddyImportLine).join('\n');
120
+ await atomicWrite(r.caddyfile, kept, { mode: 0o644 });
121
+ }
122
+ await run('systemctl', ['reload', 'caddy']);
123
+ if (r.firewallPort)
124
+ await closeFirewall(r.firewallPort);
125
+ }
126
+ all.state.remote = { mode: 'off', files: [] };
127
+ all.config.remote.mode = 'off';
128
+ ok(L('已关闭远程访问', 'Remote access disabled'));
129
+ }
@@ -0,0 +1,72 @@
1
+ // commands/service.ts — service install|start|stop|restart|status|uninstall,以及 repair
2
+ import { requireRootInVps } from '../context.js';
3
+ import { DshModelError } from '../errors.js';
4
+ import { L } from '../i18n.js';
5
+ import { applyEngineConfig, loadAll, saveAll, syncAll } from '../ops.js';
6
+ import { serviceFor } from '../service/index.js';
7
+ import { withLock } from '../state.js';
8
+ import { ensureDaemon } from '../daemon/service.js';
9
+ import { waitHealthy } from '../engine/client.js';
10
+ import { info, isJsonMode, ok, printJson } from '../util/output.js';
11
+ export async function service(ctx, sub) {
12
+ const svc = serviceFor(ctx);
13
+ if (!sub || sub === 'status') {
14
+ const s = await svc.status();
15
+ if (isJsonMode())
16
+ printJson({ kind: svc.kind, file: svc.spec.file, ...s });
17
+ else
18
+ info(`${svc.kind} ${svc.spec.file}\n${L('已安装', 'installed')}: ${s.installed} ${L('运行中', 'running')}: ${s.running}${s.pid ? ` pid ${s.pid}` : ''}${s.detail ? ` (${s.detail})` : ''}`);
19
+ return 0;
20
+ }
21
+ requireRootInVps(ctx);
22
+ return withLock(ctx, async () => {
23
+ const all = await loadAll(ctx);
24
+ switch (sub) {
25
+ case 'install':
26
+ await svc.install();
27
+ all.state.service = { kind: svc.kind, file: svc.spec.file, label: svc.spec.label };
28
+ await saveAll(ctx, all);
29
+ break;
30
+ case 'start':
31
+ await svc.start();
32
+ break;
33
+ case 'stop':
34
+ await svc.stop();
35
+ break;
36
+ case 'restart':
37
+ await svc.restart();
38
+ break;
39
+ case 'uninstall':
40
+ await svc.uninstall();
41
+ delete all.state.service;
42
+ await saveAll(ctx, all);
43
+ break;
44
+ default:
45
+ throw new DshModelError('unknown_command', L(`未知子命令:service ${sub}`, `Unknown subcommand: service ${sub}`));
46
+ }
47
+ ok(`service ${sub}`);
48
+ return 0;
49
+ });
50
+ }
51
+ /** 按台账把一切拉回应有状态:engine.yaml、服务文件、dsh 接线 */
52
+ export async function repair(ctx) {
53
+ requireRootInVps(ctx);
54
+ return withLock(ctx, async () => {
55
+ const all = await loadAll(ctx);
56
+ await applyEngineConfig(ctx, all);
57
+ ok(L('engine.yaml 已重新生成', 'engine.yaml regenerated'));
58
+ if (!ctx.serviceDisabled) {
59
+ const svc = serviceFor(ctx);
60
+ await svc.install();
61
+ all.state.service = { kind: svc.kind, file: svc.spec.file, label: svc.spec.label };
62
+ await saveAll(ctx, all);
63
+ await svc.restart();
64
+ if (!(await waitHealthy(all.config.port, 15_000)))
65
+ throw new DshModelError('engine_unhealthy', L('引擎没有启动,查看 dsh-model logs', 'Engine did not start; see dsh-model logs'));
66
+ ok(L('服务已重装并重启', 'Service reinstalled and restarted'));
67
+ await ensureDaemon(ctx, all);
68
+ }
69
+ await syncAll(ctx, all);
70
+ return 0;
71
+ });
72
+ }
@@ -0,0 +1,216 @@
1
+ // commands/setup.ts — 一条命令:引擎(统一端点)+ OpenCode Zen + WorkBuddy bridge → 全部接进 dsh。可重复执行
2
+ //
3
+ // 设计 §1.3:所有模型都从引擎 127.0.0.1:8317/v1 出去,dsh 只认 dsh-model 一个 provider。
4
+ import { requireRootInVps } from '../context.js';
5
+ import { locateDsh } from '../dsh/locate.js';
6
+ import { listModels, unauthStatus, waitHealthy } from '../engine/client.js';
7
+ import { activateVersion, currentVersion, fetchUpstreamAsset, installVersion, loadManifest, platformTarget } from '../engine/install.js';
8
+ import { DshModelError, isDshModelError } from '../errors.js';
9
+ import { L } from '../i18n.js';
10
+ import { migrateDshOpencode } from '../integrations/opencode.js';
11
+ import { enableWorkbuddy, removeWorkbuddyPlugin, userPluginInstalled } from '../integrations/workbuddy.js';
12
+ import { ensureDshKey, saveKeys } from '../keys.js';
13
+ import { applyEngineConfig, loadAll, saveAll, syncAll } from '../ops.js';
14
+ import { serviceFor } from '../service/index.js';
15
+ import { withLock } from '../state.js';
16
+ import { ensureDir } from '../util/fs.js';
17
+ import { bold, info, next, ok, skip, warn } from '../util/output.js';
18
+ import { findFreePort, isPortFree } from '../util/port.js';
19
+ import { detectProxy, normalizeProxyUrl, redactProxy } from '../util/proxy.js';
20
+ import { configureOpencode } from './opencode.js';
21
+ import { ensureDaemon } from '../daemon/service.js';
22
+ import { installDshPlugin } from '../integrations/dshplugin.js';
23
+ import { enableCaddy } from './remote.js';
24
+ import { DEFAULT_PUBLIC_PORT, publicHost } from '../remote/public.js';
25
+ export async function setup(ctx, opts) {
26
+ requireRootInVps(ctx);
27
+ return withLock(ctx, async () => {
28
+ const all = await loadAll(ctx);
29
+ await ensureDir(ctx.paths.home, { owner: ctx.owner });
30
+ resolveProxy(all, opts, await detectProxy(ctx.env, ctx.platform));
31
+ if (opts.profile)
32
+ all.config.dsh.profile = opts.profile;
33
+ const loc = await locateDsh(ctx, all.config.dsh.profile);
34
+ all.config.dsh.profile = loc.profile;
35
+ ok(L(`dsh ${loc.version ?? ''}(profile:${loc.profile})`, `dsh ${loc.version ?? ''} (profile: ${loc.profile})`));
36
+ await saveAll(ctx, all);
37
+ const failures = [];
38
+ const step = async (name, fn) => {
39
+ try {
40
+ await fn();
41
+ }
42
+ catch (error) {
43
+ if (!isDshModelError(error))
44
+ throw error;
45
+ failures.push(name);
46
+ warn(`${error.message}${error.hint ? `\n ${error.hint}` : ''}`);
47
+ }
48
+ await saveAll(ctx, all);
49
+ };
50
+ // v0.2.0 的做法(dsh 内置 opencode 路由、dsh-workbuddy-connect 插件)迁到统一端点
51
+ await step('migrate', async () => {
52
+ if (await migrateDshOpencode(ctx, all))
53
+ ok(L('已把 OpenCode 从 dsh 内置路由迁到统一端点', 'Moved OpenCode from the dsh built-in route to the unified endpoint'));
54
+ if (await removeWorkbuddyPlugin(ctx, all))
55
+ ok(L('已移除上一版安装的 dsh-workbuddy-connect 插件,改用 dsh-model 自己的 bridge', 'Removed the dsh-workbuddy-connect plugin installed by the previous version; using dsh-model\'s own bridge'));
56
+ });
57
+ info('');
58
+ info(bold(L('统一端点(引擎)', 'Unified endpoint (engine)')));
59
+ let engineRunning = false;
60
+ await step('engine', async () => {
61
+ engineRunning = await ensureEngine(ctx, all, { port: opts.port });
62
+ });
63
+ if (failures.includes('engine')) {
64
+ warn(L('引擎没装好,后面的步骤都依赖它,先停在这里', 'The engine is not ready and everything else depends on it; stopping here'));
65
+ return 1;
66
+ }
67
+ info('');
68
+ info(bold(L('守护进程', 'Daemon')));
69
+ await step('daemon', () => ensureDaemon(ctx, all));
70
+ info('');
71
+ info(bold('OpenCode Zen'));
72
+ if (opts.skipOpencode)
73
+ skip(L('已跳过(--skip-opencode)', 'Skipped (--skip-opencode)'));
74
+ else
75
+ await step('opencode', () => configureOpencode(ctx, all));
76
+ info('');
77
+ info(bold('WorkBuddy'));
78
+ if (opts.skipWorkbuddy)
79
+ skip(L('已跳过(--skip-workbuddy)', 'Skipped (--skip-workbuddy)'));
80
+ else {
81
+ await step('workbuddy', () => enableWorkbuddy(ctx, all));
82
+ if (await userPluginInstalled(ctx, all))
83
+ warn(L('你自己装了 dsh-workbuddy-connect 插件:dsh 里会有两组 WorkBuddy 模型。不需要的话可在 dsh 的插件页移除它', 'You installed the dsh-workbuddy-connect plugin yourself: dsh will show two WorkBuddy groups. Remove it from the dsh Plugins page if you do not need it'));
84
+ }
85
+ info('');
86
+ info(bold(L('dsh 插件页', 'dsh plugin page')));
87
+ if (opts.skipDshPlugin)
88
+ skip(L('已跳过(--skip-dsh-plugin)', 'Skipped (--skip-dsh-plugin)'));
89
+ else
90
+ await step('dsh-plugin', () => installDshPlugin(ctx, all));
91
+ // vps:自动在 dsh 的域名上开对外端点(没有域名就用 IP),自己的其他设备直接能用
92
+ if (ctx.mode === 'vps') {
93
+ info('');
94
+ info(bold(L('对外端点', 'Public endpoint')));
95
+ if (all.state.remote?.mode && all.state.remote.mode !== 'off' && all.state.remote.mode !== 'caddy')
96
+ skip(L(`已用 ${all.state.remote.mode} 方式开放,保持不变`, `Already exposed via ${all.state.remote.mode}; unchanged`));
97
+ else
98
+ await step('public-endpoint', async () => {
99
+ const host = await publicHost(all.config.remote.domain);
100
+ const port = all.config.remote.publicPort ?? DEFAULT_PUBLIC_PORT;
101
+ await enableCaddy(ctx, all, host, port);
102
+ all.config.remote.domain = host;
103
+ all.config.remote.publicPort = port;
104
+ ok(`https://${host}:${port}/v1`);
105
+ });
106
+ }
107
+ info('');
108
+ info(bold(L('同步到 dsh', 'Sync to dsh')));
109
+ let ids = [];
110
+ if (engineRunning) {
111
+ await step('sync', async () => {
112
+ ids = await syncAll(ctx, all);
113
+ });
114
+ }
115
+ else {
116
+ await applyEngineConfig(ctx, all);
117
+ skip(L('引擎没在运行,跳过同步到 dsh(启动后执行 dsh-model models sync)', 'Engine not running; skipped syncing to dsh (run dsh-model models sync once it is up)'));
118
+ }
119
+ const groups = new Map();
120
+ for (const id of ids) {
121
+ const g = id.includes('/') ? id.split('/')[0] : L('订阅', 'subscription');
122
+ groups.set(g, (groups.get(g) ?? 0) + 1);
123
+ }
124
+ if (groups.size)
125
+ info(` ${[...groups].map(([g, n]) => `${g} ${n}`).join(' · ')}`);
126
+ info('');
127
+ if (failures.length)
128
+ warn(L(`有 ${failures.length} 项没完成:${failures.join('、')}。修好后重新执行 dsh-model setup 即可`, `${failures.length} item(s) not done: ${failures.join(', ')}. Fix them and re-run dsh-model setup`));
129
+ const base = all.state.remote?.mode === 'caddy' && all.state.remote.publicUrl ? all.state.remote.publicUrl : `http://127.0.0.1:${all.config.port}/v1`;
130
+ next(L(`在 dsh 的模型列表里选 dsh-model 下的模型。其他软件:Base URL ${base},key 用 dsh-model key add <名称> 领取`, `Pick a dsh-model model in dsh. Other software: Base URL ${base}, get a key with dsh-model key add <name>`));
131
+ info(L(' 订阅上游(codex 等):dsh-model login <上游>', ' Subscription upstreams (codex etc.): dsh-model login <upstream>'));
132
+ return failures.length ? 1 : 0;
133
+ });
134
+ }
135
+ function resolveProxy(all, opts, found) {
136
+ if (opts.proxy !== undefined) {
137
+ all.config.proxy = opts.proxy === 'none' ? null : normalizeProxyUrl(opts.proxy);
138
+ }
139
+ else if (all.config.proxy === undefined) {
140
+ all.config.proxy = found?.url ?? null;
141
+ if (found)
142
+ ok(L(`检测到代理 ${redactProxy(found.url)}(来自 ${found.source})`, `Detected proxy ${redactProxy(found.url)} (from ${found.source})`));
143
+ }
144
+ if (all.config.proxy)
145
+ skip(L(`出站代理:${redactProxy(all.config.proxy)}(改用 --proxy <地址> 或 --proxy none)`, `Outbound proxy: ${redactProxy(all.config.proxy)} (change with --proxy <url> or --proxy none)`));
146
+ }
147
+ /** 订阅引擎:下载校验 → key → 端口 → engine.yaml → 服务 → 鉴权自检 → 同步模型到 dsh。setup --engine 与首次 login 共用 */
148
+ /** 返回引擎是否在运行(DSH_MODEL_SERVICE=none 时可能没跑) */
149
+ export async function ensureEngine(ctx, all, opts = {}) {
150
+ const { config, state, keys } = all;
151
+ if (config.proxy === undefined) {
152
+ const found = await detectProxy(ctx.env, ctx.platform);
153
+ config.proxy = found?.url ?? null;
154
+ }
155
+ await ensureDir(ctx.paths.auth, { owner: ctx.owner });
156
+ const manifest = await loadManifest();
157
+ const target = platformTarget();
158
+ const version = config.engine.version ?? manifest.version;
159
+ const asset = version === manifest.version ? manifest.assets[target] : await fetchUpstreamAsset(manifest, version, target);
160
+ if (!asset)
161
+ throw new DshModelError('unsupported_platform', L(`引擎没有 ${target} 的发布包`, `No engine build for ${target}`));
162
+ const installed = await installVersion(ctx, version, asset);
163
+ if ((await currentVersion(ctx)) !== version)
164
+ await activateVersion(ctx, version);
165
+ config.engine.version = version;
166
+ if (!state.engine.versions.includes(version))
167
+ state.engine.versions.push(version);
168
+ (installed ? ok : skip)(L(`引擎 CLIProxyAPI v${version}${installed ? ' 已下载并校验' : ' 已安装'}`, `Engine CLIProxyAPI v${version} ${installed ? 'downloaded and verified' : 'already installed'}`));
169
+ const { created } = ensureDshKey(keys);
170
+ if (created)
171
+ await saveKeys(ctx, keys);
172
+ if (opts.port)
173
+ config.port = opts.port;
174
+ if (!(await isPortFree(config.port)) && !(await portIsOurs(config.port, keys))) {
175
+ const free = await findFreePort(config.port + 1);
176
+ warn(L(`端口 ${config.port} 被其他程序占用,改用 ${free}`, `Port ${config.port} is taken by another program; using ${free}`));
177
+ config.port = free;
178
+ }
179
+ await saveAll(ctx, all);
180
+ await applyEngineConfig(ctx, all);
181
+ if (ctx.serviceDisabled) {
182
+ skip(L('已跳过系统服务(DSH_MODEL_SERVICE=none)', 'Skipped system service (DSH_MODEL_SERVICE=none)'));
183
+ }
184
+ else {
185
+ const svc = serviceFor(ctx);
186
+ await svc.install();
187
+ state.service = { kind: svc.kind, file: svc.spec.file, label: svc.spec.label };
188
+ await saveAll(ctx, all);
189
+ if (installed)
190
+ await svc.restart();
191
+ }
192
+ if (!(await waitHealthy(config.port, ctx.serviceDisabled ? 1500 : 15_000))) {
193
+ if (ctx.serviceDisabled) {
194
+ warn(L('引擎未运行(服务已跳过)', 'Engine not running (service skipped)'));
195
+ return false;
196
+ }
197
+ throw new DshModelError('engine_unhealthy', L(`引擎没有在 127.0.0.1:${config.port} 上启动`, `Engine did not come up on 127.0.0.1:${config.port}`), L('查看日志:dsh-model logs', 'Check logs: dsh-model logs'));
198
+ }
199
+ const unauth = await unauthStatus(config.port);
200
+ if (unauth !== 401)
201
+ throw new DshModelError('auth_not_enforced', L(`不带 key 的请求返回了 ${unauth}(应为 401),已停止`, `Unauthenticated request returned ${unauth} (expected 401); stopping`));
202
+ ok(L(`引擎已在 127.0.0.1:${config.port} 运行,鉴权已生效`, `Engine running on 127.0.0.1:${config.port} with auth enforced`));
203
+ return true;
204
+ }
205
+ async function portIsOurs(port, keys) {
206
+ for (const k of keys.keys.filter((x) => !x.revokedAt)) {
207
+ try {
208
+ await listModels(port, k.key);
209
+ return true;
210
+ }
211
+ catch {
212
+ // 不是我们
213
+ }
214
+ }
215
+ return false;
216
+ }