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,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,83 @@
|
|
|
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
|
+
/** 交互地问 OpenCode key(回车跳过 → '';不能交互 → undefined) */
|
|
11
|
+
export async function askOpencodeKey() {
|
|
12
|
+
if (!canPrompt())
|
|
13
|
+
return undefined;
|
|
14
|
+
info(L(`OpenCode Zen 的 API key(${OPENCODE_KEY_PAGE},要先充值;没有就直接回车跳过,以后在 dsh 的管理页填)。输入不会显示。`, `Your OpenCode Zen API key (${OPENCODE_KEY_PAGE}; needs a top-up. Press Enter to skip and add it later in dsh's management page). Input is hidden.`));
|
|
15
|
+
return promptSecret('OpenCode Zen API key: ');
|
|
16
|
+
}
|
|
17
|
+
/** 拿 key → 实测 → 存进 secrets.json → 拉模型目录。返回是否有变化(调用方随后 syncAll) */
|
|
18
|
+
export async function configureOpencode(ctx, all, opts = {}) {
|
|
19
|
+
if ((await opencodeConfigured(ctx)) && !opts.replace) {
|
|
20
|
+
const n = await refreshOpencodeModels(ctx, all.config.proxy).catch(() => 0);
|
|
21
|
+
skip(L(`已配置${n ? `(${n} 个模型)` : ''};换 key:dsh-model opencode key`, `Configured${n ? ` (${n} models)` : ''}; to change the key: dsh-model opencode key`));
|
|
22
|
+
return n > 0;
|
|
23
|
+
}
|
|
24
|
+
let key;
|
|
25
|
+
if (opts.stdin) {
|
|
26
|
+
key = await readStdin();
|
|
27
|
+
}
|
|
28
|
+
else if (opts.key !== undefined || canPrompt()) {
|
|
29
|
+
key = opts.key ?? (await askOpencodeKey()) ?? '';
|
|
30
|
+
if (!key) {
|
|
31
|
+
skip(L('已跳过;之后可执行 dsh-model opencode key', 'Skipped; later run dsh-model opencode key'));
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
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)'));
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
validateKeyShape(key);
|
|
40
|
+
let verifiedModel;
|
|
41
|
+
if (!opts.skipVerify) {
|
|
42
|
+
info(L('正在用一个免费模型实测这把 key…', 'Testing the key against a free model…'));
|
|
43
|
+
const v = await verifyOpencodeKey(key, all.config.proxy);
|
|
44
|
+
if (!v.ok) {
|
|
45
|
+
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'));
|
|
46
|
+
}
|
|
47
|
+
verifiedModel = v.model;
|
|
48
|
+
ok(L(`key 可用(${v.model} 实测通过${v.detail !== 'ok' ? `;${v.detail}` : ''})`, `Key works (${v.model} test passed${v.detail !== 'ok' ? `; ${v.detail}` : ''})`));
|
|
49
|
+
}
|
|
50
|
+
await saveOpencodeKey(ctx, key, verifiedModel);
|
|
51
|
+
const n = await refreshOpencodeModels(ctx, all.config.proxy);
|
|
52
|
+
ok(L(`OpenCode Zen 已接入统一端点(${n} 个模型,名字前缀 opencode/)`, `OpenCode Zen connected to the unified endpoint (${n} models, prefixed opencode/)`));
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
export async function opencode(ctx, sub, opts) {
|
|
56
|
+
if (!sub || sub === 'status') {
|
|
57
|
+
const configured = await opencodeConfigured(ctx);
|
|
58
|
+
if (isJsonMode())
|
|
59
|
+
printJson({ configured });
|
|
60
|
+
else
|
|
61
|
+
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)'));
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
requireRootInVps(ctx);
|
|
65
|
+
return withLock(ctx, async () => {
|
|
66
|
+
const all = await loadAll(ctx);
|
|
67
|
+
if (sub === 'key') {
|
|
68
|
+
if (await configureOpencode(ctx, all, { stdin: opts.stdin, skipVerify: opts.skipVerify, replace: true }))
|
|
69
|
+
await syncAll(ctx, all);
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
if (sub === 'remove') {
|
|
73
|
+
if (!(await removeOpencodeKey(ctx))) {
|
|
74
|
+
skip(L('OpenCode 本来就没配置', 'OpenCode was not configured'));
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
await syncAll(ctx, all, { quiet: true });
|
|
78
|
+
ok(L('已移除 OpenCode Zen(key 与模型)', 'Removed OpenCode Zen (key and models)'));
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
throw new DshModelError('unknown_command', L(`未知子命令:opencode ${sub}`, `Unknown subcommand: opencode ${sub}`));
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -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,116 @@
|
|
|
1
|
+
// commands/self.ts — 更新 dsh-model 自己(命令行与 dsh 管理页的「更新」共用)
|
|
2
|
+
//
|
|
3
|
+
// 1. 新版本装到原来的位置:npm 全局装的就 npm install -g;从 dsh 管理页一键安装的(程序就在插件目录里)跳过这步;
|
|
4
|
+
// 2. dsh 里的插件换成同一版本(插件页的新代码要重启一次 dsh 才生效);
|
|
5
|
+
// 3. 用新版本执行一次 repair:重写服务文件、重启引擎与守护进程、同步模型。
|
|
6
|
+
import { realpath } from 'node:fs/promises';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { requireRootInVps } from '../context.js';
|
|
9
|
+
import { DshModelError, isDshModelError } from '../errors.js';
|
|
10
|
+
import { L, lang } from '../i18n.js';
|
|
11
|
+
import { installDshPlugin, pluginRoot } from '../integrations/dshplugin.js';
|
|
12
|
+
import { loadAll, saveAll } from '../ops.js';
|
|
13
|
+
import { withLock } from '../state.js';
|
|
14
|
+
import { run, runInherit } from '../util/exec.js';
|
|
15
|
+
import { exists } from '../util/fs.js';
|
|
16
|
+
import { info, next, ok, skip, warn } from '../util/output.js';
|
|
17
|
+
import { PKG_ROOT, pkgVersion } from '../util/pkg.js';
|
|
18
|
+
const REGISTRY = 'https://registry.npmjs.org/dsh-model';
|
|
19
|
+
/** a 比 b 新(只比 major.minor.patch) */
|
|
20
|
+
export function isNewer(a, b) {
|
|
21
|
+
const pa = a.split(/[.-]/).map((x) => parseInt(x, 10) || 0);
|
|
22
|
+
const pb = b.split(/[.-]/).map((x) => parseInt(x, 10) || 0);
|
|
23
|
+
for (let i = 0; i < 3; i++)
|
|
24
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0))
|
|
25
|
+
return (pa[i] ?? 0) > (pb[i] ?? 0);
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
export async function latestVersion() {
|
|
29
|
+
try {
|
|
30
|
+
const r = await fetch(`${REGISTRY}/latest`, { signal: AbortSignal.timeout(10_000) });
|
|
31
|
+
if (!r.ok)
|
|
32
|
+
return null;
|
|
33
|
+
return String((await r.json()).version ?? '') || null;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** 和当前 node 同目录的 npm,以及让它能找到 node 的环境 */
|
|
40
|
+
async function npmBeside() {
|
|
41
|
+
if (process.versions.electron)
|
|
42
|
+
return null;
|
|
43
|
+
const dir = dirname(process.execPath);
|
|
44
|
+
const npm = join(dir, 'npm');
|
|
45
|
+
if (!(await exists(npm)))
|
|
46
|
+
return null;
|
|
47
|
+
return { npm, env: { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` } };
|
|
48
|
+
}
|
|
49
|
+
/** 当前程序是不是 npm 全局装的(是就能用 npm install -g 原地更新) */
|
|
50
|
+
async function npmGlobalInstall() {
|
|
51
|
+
const n = await npmBeside();
|
|
52
|
+
if (!n)
|
|
53
|
+
return null;
|
|
54
|
+
const r = await run(n.npm, ['root', '-g'], { env: n.env, timeoutMs: 20_000 });
|
|
55
|
+
if (r.code !== 0)
|
|
56
|
+
return null;
|
|
57
|
+
try {
|
|
58
|
+
return (await realpath(join(r.stdout.trim(), 'dsh-model'))) === (await realpath(PKG_ROOT)) ? n : null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function selfUpdate(ctx, opts = {}) {
|
|
65
|
+
requireRootInVps(ctx);
|
|
66
|
+
const current = pkgVersion();
|
|
67
|
+
const target = opts.to ?? (await latestVersion());
|
|
68
|
+
if (!target)
|
|
69
|
+
throw new DshModelError('no_latest', L('查不到 npm 上的最新版本(网络?)', 'Could not get the latest version from npm (network?)'), L('稍后再试,或指定:dsh-model self-update --to <版本>', 'Try later, or pass one: dsh-model self-update --to <version>'));
|
|
70
|
+
if (!opts.to && !isNewer(target, current)) {
|
|
71
|
+
ok(L(`已是最新(${current};npm 上最新 ${target})`, `Up to date (${current}; latest on npm is ${target})`));
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
info(L(`当前 ${current},目标 ${target}`, `Current ${current}, target ${target}`));
|
|
75
|
+
// 1. 程序本身
|
|
76
|
+
let root = PKG_ROOT;
|
|
77
|
+
const npm = await npmGlobalInstall();
|
|
78
|
+
if (target === current)
|
|
79
|
+
skip(L('程序已是这个版本', 'The program is already at this version'));
|
|
80
|
+
else if (npm) {
|
|
81
|
+
info(L(`npm install -g dsh-model@${target}`, `npm install -g dsh-model@${target}`));
|
|
82
|
+
const code = await runInherit(npm.npm, ['install', '-g', `dsh-model@${target}`], { env: npm.env });
|
|
83
|
+
if (code !== 0)
|
|
84
|
+
throw new DshModelError('update_failed', L(`npm 安装失败(退出码 ${code})`, `npm install failed (exit code ${code})`));
|
|
85
|
+
ok(L(`程序已更新到 ${target}`, `Program updated to ${target}`));
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
skip(L('程序在 dsh 的插件目录里,随插件一起更新', 'The program lives in the dsh plugin directory and is updated with it'));
|
|
89
|
+
}
|
|
90
|
+
// 2. dsh 里的插件
|
|
91
|
+
// 插件没换成也继续:服务照样要用新版本重装,插件之后可以在 dsh 的插件市场更新
|
|
92
|
+
try {
|
|
93
|
+
await withLock(ctx, async () => {
|
|
94
|
+
const all = await loadAll(ctx);
|
|
95
|
+
await installDshPlugin(ctx, all, { version: target, replaceUser: true });
|
|
96
|
+
await saveAll(ctx, all);
|
|
97
|
+
if (!npm)
|
|
98
|
+
root = (await pluginRoot(ctx, all.config.dsh.profile)) ?? root;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
warn(`${error.message}${isDshModelError(error) && error.hint ? ` — ${error.hint}` : ''}`);
|
|
103
|
+
}
|
|
104
|
+
// 3. 用新版本重装服务并重启
|
|
105
|
+
info(L('用新版本重装服务…', 'Reinstalling the services with the new version…'));
|
|
106
|
+
const code = await runInherit(process.execPath, [join(root, 'bin', 'dsh-model.js'), 'repair', '--lang', lang()], {
|
|
107
|
+
env: { ...process.env, ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}) },
|
|
108
|
+
});
|
|
109
|
+
if (code !== 0) {
|
|
110
|
+
warn(L(`repair 没有完成(退出码 ${code}):执行 dsh-model repair 重试`, `repair did not finish (exit code ${code}); run dsh-model repair to retry`));
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
ok(L(`已更新到 ${target}`, `Updated to ${target}`));
|
|
114
|
+
next(L('重启一次 dsh,管理页的新代码才会生效', 'Restart dsh once so the management page picks up the new code'));
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
@@ -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
|
+
}
|