dsh-model 0.17.0 → 0.18.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 +4 -3
- package/README.md +4 -3
- package/dist/cli.js +4 -0
- package/dist/commands/engine.js +1 -1
- package/dist/commands/self.js +5 -3
- package/dist/commands/status.js +2 -1
- package/dist/commands/uninstall.js +2 -1
- package/dist/context.js +2 -2
- package/dist/daemon/core.js +15 -16
- package/dist/dsh/cli.js +60 -14
- package/dist/dsh/locate.js +6 -0
- package/dist/engine/install.js +35 -10
- package/dist/service/index.js +5 -0
- package/dist/service/windows.js +139 -0
- package/dist/util/detach.js +28 -0
- package/dist/util/exec.js +22 -2
- package/dist/util/fs.js +18 -1
- package/dist/util/proxy.js +27 -1
- package/engine-manifest.json +10 -0
- package/package.json +1 -1
- package/plugin/index.js +1 -0
package/README.en.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<a href="LICENSE"><img src="https://img.shields.io/github/license/AIcivilization/dsh-model" alt="MIT license"></a>
|
|
9
9
|
<a href="https://www.npmjs.com/package/dsh-model"><img src="https://img.shields.io/npm/v/dsh-model?color=cb3837&logo=npm" alt="npm version"></a>
|
|
10
10
|
<img src="https://img.shields.io/badge/DeepSeek%20Harness-%E2%89%A5%200.2.0--rc.2-4176E6" alt="DeepSeek Harness ≥ 0.2.0-rc.2">
|
|
11
|
-
<img src="https://img.shields.io/badge/platform-
|
|
11
|
+
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-blue" alt="Platform: Windows / macOS / Linux">
|
|
12
12
|
<img src="https://img.shields.io/badge/node-%E2%89%A5%2020-339933?logo=node.js&logoColor=white" alt="Node ≥ 20">
|
|
13
13
|
<a href="https://github.com/router-for-me/CLIProxyAPI"><img src="https://img.shields.io/badge/engine-CLIProxyAPI%208.0.13-555" alt="Engine: CLIProxyAPI 8.0.13"></a>
|
|
14
14
|
</p>
|
|
@@ -37,7 +37,7 @@ npm install -g dsh-model
|
|
|
37
37
|
dsh-model setup
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
On a dsh-vps server, use `sudo dsh-model setup`.
|
|
40
|
+
On a dsh-vps server, use `sudo dsh-model setup`. On Windows, run it in a normal PowerShell or Command Prompt; no administrator rights needed.
|
|
41
41
|
|
|
42
42
|
`setup` is safe to re-run. It:
|
|
43
43
|
|
|
@@ -121,7 +121,8 @@ See the [design doc](docs/DESIGN.md) (Chinese).
|
|
|
121
121
|
## Notes
|
|
122
122
|
|
|
123
123
|
- Using subscription and WorkBuddy credentials outside the official clients may break the providers' terms and put the account at risk. Decide for yourself.
|
|
124
|
-
- macOS and Linux, Node ≥ 20.
|
|
124
|
+
- Windows 10 / 11 (x64, ARM64), macOS and Linux, Node ≥ 20.
|
|
125
|
+
- Windows: the background services start at sign-in with no window. If an antivirus blocks them (the program is gone or will not start), trust `%USERPROFILE%\.dsh-model` and run `dsh-model repair`.
|
|
125
126
|
|
|
126
127
|
## License
|
|
127
128
|
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<a href="LICENSE"><img src="https://img.shields.io/github/license/AIcivilization/dsh-model" alt="MIT license"></a>
|
|
9
9
|
<a href="https://www.npmjs.com/package/dsh-model"><img src="https://img.shields.io/npm/v/dsh-model?color=cb3837&logo=npm" alt="npm version"></a>
|
|
10
10
|
<img src="https://img.shields.io/badge/DeepSeek%20Harness-%E2%89%A5%200.2.0--rc.2-4176E6" alt="DeepSeek Harness ≥ 0.2.0-rc.2">
|
|
11
|
-
<img src="https://img.shields.io/badge/platform-
|
|
11
|
+
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-blue" alt="Platform: Windows / macOS / Linux">
|
|
12
12
|
<img src="https://img.shields.io/badge/node-%E2%89%A5%2020-339933?logo=node.js&logoColor=white" alt="Node ≥ 20">
|
|
13
13
|
<a href="https://github.com/router-for-me/CLIProxyAPI"><img src="https://img.shields.io/badge/engine-CLIProxyAPI%208.0.13-555" alt="Engine: CLIProxyAPI 8.0.13"></a>
|
|
14
14
|
</p>
|
|
@@ -37,7 +37,7 @@ npm install -g dsh-model
|
|
|
37
37
|
dsh-model setup
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
服务器上(装了 dsh-vps 的 VPS)用 `sudo dsh-model setup`。
|
|
40
|
+
服务器上(装了 dsh-vps 的 VPS)用 `sudo dsh-model setup`。Windows 上在普通的 PowerShell 或命令提示符里执行即可,不需要管理员。
|
|
41
41
|
|
|
42
42
|
`setup` 可以重复执行,它会依次:
|
|
43
43
|
|
|
@@ -121,7 +121,8 @@ dsh · 编辑器 · 脚本 ──Bearer key──▶ 引擎 127.0.0.1:8317/v1(
|
|
|
121
121
|
## 注意
|
|
122
122
|
|
|
123
123
|
- 订阅和 WorkBuddy 的凭据在官方客户端之外使用,可能不符合服务商的条款,账号有风险,请自行判断。
|
|
124
|
-
- 支持
|
|
124
|
+
- 支持 Windows 10 / 11(x64、ARM64)、macOS、Linux,需要 Node ≥ 20。
|
|
125
|
+
- Windows:后台服务登录后自动运行、不弹窗口。如果被杀毒软件拦下(程序不见了或启动不了),把 `%USERPROFILE%\.dsh-model` 加入信任,再执行 `dsh-model repair`。
|
|
125
126
|
|
|
126
127
|
## License
|
|
127
128
|
|
package/dist/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import { setup } from './commands/setup.js';
|
|
|
10
10
|
import { doctor, status } from './commands/status.js';
|
|
11
11
|
import { uninstall } from './commands/uninstall.js';
|
|
12
12
|
import { adminRun, selfUpdate } from './commands/self.js';
|
|
13
|
+
import { supervise } from './service/windows.js';
|
|
13
14
|
import { opencode } from './commands/opencode.js';
|
|
14
15
|
import { workbuddy } from './commands/workbuddy.js';
|
|
15
16
|
import { bridge } from './commands/bridge.js';
|
|
@@ -183,6 +184,9 @@ export async function main(argv) {
|
|
|
183
184
|
case 'engine':
|
|
184
185
|
return await engine(ctx, a1, a2);
|
|
185
186
|
case 'service':
|
|
187
|
+
// Windows 后台服务的常驻进程(由隐藏窗口脚本启动,不是给人用的)
|
|
188
|
+
if (a1 === 'supervise' && a2)
|
|
189
|
+
return await supervise(a2);
|
|
186
190
|
return await service(ctx, a1);
|
|
187
191
|
case 'repair':
|
|
188
192
|
return await repair(ctx);
|
package/dist/commands/engine.js
CHANGED
|
@@ -103,7 +103,7 @@ async function trialRun(ctx, all, bin) {
|
|
|
103
103
|
doc.oauth['auth-dir'] = join(dir, 'auth');
|
|
104
104
|
const cfg = join(dir, 'engine.yaml');
|
|
105
105
|
await atomicWrite(cfg, YAML.stringify(doc));
|
|
106
|
-
const child = spawn(bin, ['-config', cfg], { cwd: dir, stdio: 'ignore' });
|
|
106
|
+
const child = spawn(bin, ['-config', cfg], { cwd: dir, stdio: 'ignore', windowsHide: true });
|
|
107
107
|
try {
|
|
108
108
|
if (!(await waitHealthy(port, 15_000)))
|
|
109
109
|
throw new DshModelError('engine_trial_failed', L('新版本试运行没有启动', 'New version did not start in trial run'));
|
package/dist/commands/self.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
7
|
import { closeSync, openSync } from 'node:fs';
|
|
8
8
|
import { chown, realpath } from 'node:fs/promises';
|
|
9
|
-
import { dirname, join } from 'node:path';
|
|
9
|
+
import { delimiter, dirname, join } from 'node:path';
|
|
10
10
|
import { requireRootInVps } from '../context.js';
|
|
11
11
|
import { DshModelError, isDshModelError } from '../errors.js';
|
|
12
12
|
import { L, lang } from '../i18n.js';
|
|
@@ -44,10 +44,11 @@ async function npmBeside() {
|
|
|
44
44
|
if (process.versions.electron)
|
|
45
45
|
return null;
|
|
46
46
|
const dir = dirname(process.execPath);
|
|
47
|
-
|
|
47
|
+
// Windows 上 npm 是 npm.cmd(exec.ts 会经 cmd.exe 调它);PATH 分隔符也不同
|
|
48
|
+
const npm = join(dir, process.platform === 'win32' ? 'npm.cmd' : 'npm');
|
|
48
49
|
if (!(await exists(npm)))
|
|
49
50
|
return null;
|
|
50
|
-
return { npm, env: { ...process.env, PATH: `${dir}
|
|
51
|
+
return { npm, env: { ...process.env, PATH: `${dir}${delimiter}${process.env.PATH ?? ''}` } };
|
|
51
52
|
}
|
|
52
53
|
/** 当前程序是不是 npm 全局装的(是就能用 npm install -g 原地更新) */
|
|
53
54
|
async function npmGlobalInstall() {
|
|
@@ -136,6 +137,7 @@ export async function adminRun(ctx) {
|
|
|
136
137
|
const code = await new Promise((resolve) => {
|
|
137
138
|
const child = spawn(process.execPath, [join(PKG_ROOT, 'bin', 'dsh-model.js'), ...args, '--lang', req.lang ?? 'zh'], {
|
|
138
139
|
stdio: ['ignore', out, out],
|
|
140
|
+
windowsHide: true,
|
|
139
141
|
env: { ...process.env, NO_COLOR: '1', NODE_NO_WARNINGS: '1' },
|
|
140
142
|
});
|
|
141
143
|
child.on('error', () => resolve(-1));
|
package/dist/commands/status.js
CHANGED
|
@@ -173,7 +173,8 @@ export async function doctor(ctx, opts) {
|
|
|
173
173
|
const credText = await readText(d.credFile);
|
|
174
174
|
add('dsh-key', readRef(credText) === key ? 'ok' : 'fail', readRef(credText) === key ? L(`${KEY_REF} 与 key 一致`, `${KEY_REF} matches`) : L(`${KEY_REF} 与 key 不一致(dsh-model repair)`, `${KEY_REF} does not match (dsh-model repair)`));
|
|
175
175
|
const cm = await fileMode(d.credFile);
|
|
176
|
-
|
|
176
|
+
// Windows 没有 Unix 权限位(读出来总是 666),权限靠用户目录本身的访问控制,不查
|
|
177
|
+
if (cm != null && ctx.platform !== 'win32')
|
|
177
178
|
add('dsh-cred-perms', cm === 0o600 ? 'ok' : 'fail', L(`.credentials.yaml 权限 ${cm.toString(8)}(dsh 要求 600)`, `.credentials.yaml mode ${cm.toString(8)} (dsh requires 600)`));
|
|
178
179
|
const patchText = await readText(d.patchFile);
|
|
179
180
|
if (patchText != null && d.writtenPatchSha && sha256(patchText) !== d.writtenPatchSha) {
|
|
@@ -95,7 +95,8 @@ export async function uninstall(ctx, opts) {
|
|
|
95
95
|
warn(L(`有 ${errors.length} 步失败,保留了 ${ctx.paths.home} 以便重试(再次执行 uninstall)`, `${errors.length} step(s) failed; kept ${ctx.paths.home} so you can retry (run uninstall again)`));
|
|
96
96
|
return 1;
|
|
97
97
|
}
|
|
98
|
-
|
|
98
|
+
// Windows:刚结束的进程可能还没完全退出、还占着目录,删除会短暂报 EBUSY / EPERM,重试一会儿
|
|
99
|
+
await rm(ctx.paths.home, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 });
|
|
99
100
|
ok(L(`已删除 ${ctx.paths.home},卸载完成`, `Deleted ${ctx.paths.home}; uninstall complete`));
|
|
100
101
|
info(L('最后可执行:npm uninstall -g dsh-model', 'Finally you can run: npm uninstall -g dsh-model'));
|
|
101
102
|
return 0;
|
package/dist/context.js
CHANGED
|
@@ -41,8 +41,8 @@ export function makePaths(home) {
|
|
|
41
41
|
}
|
|
42
42
|
export async function createContext(env = process.env) {
|
|
43
43
|
const platform = process.platform;
|
|
44
|
-
if (platform !== 'darwin' && platform !== 'linux') {
|
|
45
|
-
throw new DshModelError('unsupported_platform', L(`暂不支持这个平台:${platform}
|
|
44
|
+
if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32') {
|
|
45
|
+
throw new DshModelError('unsupported_platform', L(`暂不支持这个平台:${platform}(支持 macOS、Linux、Windows)`, `Unsupported platform: ${platform} (macOS, Linux and Windows are supported)`));
|
|
46
46
|
}
|
|
47
47
|
const mode = detectMode(env, platform);
|
|
48
48
|
const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
package/dist/daemon/core.js
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
// 职责:来源状态与开关、登录会话(引擎 OAuth / WorkBuddy 自有登录 / OpenCode key)、key 管理、用量统计。
|
|
6
6
|
// CLI 与 dsh 插件都只通过 /control/* 调这里,逻辑只有一份。
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
|
-
import { closeSync, openSync } from 'node:fs';
|
|
9
8
|
import { readFile } from 'node:fs/promises';
|
|
10
9
|
import { join } from 'node:path';
|
|
11
10
|
import { latestVersion, selfJobLog } from '../commands/self.js';
|
|
@@ -14,6 +13,7 @@ import { adminInstalled, adminRequestPath } from '../service/admin.js';
|
|
|
14
13
|
import { atomicWrite } from '../util/fs.js';
|
|
15
14
|
import { lang } from '../i18n.js';
|
|
16
15
|
import { which } from '../util/exec.js';
|
|
16
|
+
import { spawnDetached } from '../util/detach.js';
|
|
17
17
|
import { PKG_ROOT, pkgVersion } from '../util/pkg.js';
|
|
18
18
|
import { randomBytes } from 'node:crypto';
|
|
19
19
|
import { currentBinary } from '../engine/install.js';
|
|
@@ -461,7 +461,7 @@ export class Daemon {
|
|
|
461
461
|
const m = await this.mgmt();
|
|
462
462
|
const before = credsFor(await m.credentials(), def).map((c) => c.name);
|
|
463
463
|
const abort = new AbortController();
|
|
464
|
-
const child = spawn(currentBinary(this.ctx), ['-config', this.ctx.paths.engineYaml, def.deviceCliFlag, '-no-browser'], { cwd: this.ctx.paths.home, stdio: ['ignore', 'pipe', 'pipe'], signal: abort.signal });
|
|
464
|
+
const child = spawn(currentBinary(this.ctx), ['-config', this.ctx.paths.engineYaml, def.deviceCliFlag, '-no-browser'], { cwd: this.ctx.paths.home, stdio: ['ignore', 'pipe', 'pipe'], signal: abort.signal, windowsHide: true });
|
|
465
465
|
let out = '';
|
|
466
466
|
const s = {
|
|
467
467
|
id: randomBytes(12).toString('hex'),
|
|
@@ -710,21 +710,20 @@ export class Daemon {
|
|
|
710
710
|
return { started: true, log };
|
|
711
711
|
}
|
|
712
712
|
const args = action === 'update' ? ['update'] : ['uninstall', '--yes', '--remove-plugin', ...(opts.keepAuth ? ['--keep-auth'] : [])];
|
|
713
|
-
const out = openSync(log, 'w', 0o600);
|
|
714
713
|
const cmd = [process.execPath, join(PKG_ROOT, 'bin', 'dsh-model.js'), ...args, '--lang', lang()];
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
stdio:
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
this.selfJob = { action, startedAt: new Date().toISOString(), log, ...(
|
|
727
|
-
log_(`daemon: started ${action} (pid ${
|
|
714
|
+
const env = { ...process.env, NO_COLOR: '1', NODE_NO_WARNINGS: '1', ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}) };
|
|
715
|
+
let pid;
|
|
716
|
+
if (this.ctx.platform === 'linux' && (await which('systemd-run'))) {
|
|
717
|
+
// Linux 的 systemd --user 重启服务会杀掉整个 cgroup:交给 systemd-run 另起一个
|
|
718
|
+
const keepEnv = ['PATH', 'HOME', 'LANG', 'DSH_HOME', 'DSH_MODEL_HOME', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY'].filter((k) => process.env[k]).map((k) => `--setenv=${k}=${process.env[k]}`);
|
|
719
|
+
const viaSystemd = ['--user', '--collect', '--quiet', ...keepEnv, '--setenv=NO_COLOR=1', '/bin/sh', '-c', 'log="$1"; shift; exec "$@" >"$log" 2>&1', 'sh', log, ...cmd];
|
|
720
|
+
spawn('systemd-run', viaSystemd, { detached: true, stdio: 'ignore', env }).unref();
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
pid = spawnDetached(cmd[0], cmd.slice(1), { log, env });
|
|
724
|
+
}
|
|
725
|
+
this.selfJob = { action, startedAt: new Date().toISOString(), log, ...(pid ? { pid } : {}) };
|
|
726
|
+
log_(`daemon: started ${action}${pid ? ` (pid ${pid})` : ''}`);
|
|
728
727
|
return { started: true, log };
|
|
729
728
|
}
|
|
730
729
|
/** 这个来源的全部模型,以及哪些显示在 dsh 里 */
|
package/dist/dsh/cli.js
CHANGED
|
@@ -1,22 +1,55 @@
|
|
|
1
1
|
// dsh/cli.ts — 找到并调用 dsh 自己的命令行(用它的 `dsh plugin` 装 / 卸插件:带兼容性检查,会自动登记 bundles)
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { join, win32 } from 'node:path';
|
|
5
5
|
import { VPS_ROOT } from '../context.js';
|
|
6
6
|
import { DshModelError } from '../errors.js';
|
|
7
7
|
import { L } from '../i18n.js';
|
|
8
8
|
import { run, runInherit, which } from '../util/exec.js';
|
|
9
9
|
import { exists } from '../util/fs.js';
|
|
10
10
|
const MAC_APPS = ['/Applications/DeepSeek Harness.app', join(homedir(), 'Applications/DeepSeek Harness.app')];
|
|
11
|
+
/** Windows 上 DeepSeek Harness 桌面版可能装在的目录 */
|
|
12
|
+
export function windowsAppDirs(env, execPath = process.execPath, electron = Boolean(process.versions.electron)) {
|
|
13
|
+
const dirs = [];
|
|
14
|
+
// 从 dsh 管理页一键安装时,本进程就是 DeepSeek Harness.exe(Electron):它所在的目录最准
|
|
15
|
+
// 只在 Windows 上用:一律按 Windows 的路径规则拼(在别的平台上跑测试也对)
|
|
16
|
+
if (electron)
|
|
17
|
+
dirs.push(win32.dirname(execPath));
|
|
18
|
+
for (const base of [env.LOCALAPPDATA && win32.join(env.LOCALAPPDATA, 'Programs'), env.ProgramFiles, env['ProgramFiles(x86)']]) {
|
|
19
|
+
if (base)
|
|
20
|
+
dirs.push(win32.join(base, 'DeepSeek Harness'));
|
|
21
|
+
}
|
|
22
|
+
return [...new Set(dirs)];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Windows:桌面版的命令行就是 DeepSeek Harness.exe 以 Node 模式(ELECTRON_RUN_AS_NODE=1)运行
|
|
26
|
+
* app.asar 里的 dsh-desktop-host/lib/cli.js(Mac 上 runtime/cli/bin/dsh 这个启动脚本做的正是这件事)。
|
|
27
|
+
* 直接这样调,不依赖启动脚本叫什么名字。
|
|
28
|
+
*/
|
|
29
|
+
async function windowsDshCli(ctx) {
|
|
30
|
+
for (const dir of windowsAppDirs(ctx.env)) {
|
|
31
|
+
for (const name of ['dsh.cmd', 'dsh.exe']) {
|
|
32
|
+
const bin = join(dir, 'resources', 'runtime', 'cli', 'bin', name);
|
|
33
|
+
if (await exists(bin))
|
|
34
|
+
return { cmd: bin, pre: [] };
|
|
35
|
+
}
|
|
36
|
+
const exe = join(dir, 'DeepSeek Harness.exe');
|
|
37
|
+
const asar = join(dir, 'resources', 'app.asar');
|
|
38
|
+
if ((await exists(exe)) && (await exists(asar))) {
|
|
39
|
+
return { cmd: exe, pre: ['--expose-internals', join(asar, 'dsh', 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'cli.js')], env: { ELECTRON_RUN_AS_NODE: '1' } };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
11
44
|
export async function locateDshCli(ctx) {
|
|
12
45
|
if (ctx.env.DSH_MODEL_DSH_BIN)
|
|
13
|
-
return ctx.env.DSH_MODEL_DSH_BIN;
|
|
46
|
+
return { cmd: ctx.env.DSH_MODEL_DSH_BIN, pre: [] };
|
|
14
47
|
if (ctx.mode === 'vps') {
|
|
15
48
|
try {
|
|
16
49
|
const env = await readFile(join(VPS_ROOT, 'state/gate.env'), 'utf8');
|
|
17
50
|
const bin = env.match(/^DSH_BIN=(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '');
|
|
18
51
|
if (bin && (await exists(bin)))
|
|
19
|
-
return bin;
|
|
52
|
+
return { cmd: bin, pre: [] };
|
|
20
53
|
}
|
|
21
54
|
catch {
|
|
22
55
|
// 往下找
|
|
@@ -26,24 +59,29 @@ export async function locateDshCli(ctx) {
|
|
|
26
59
|
for (const app of MAC_APPS) {
|
|
27
60
|
const bin = join(app, 'Contents/Resources/runtime/cli/bin/dsh');
|
|
28
61
|
if (await exists(bin))
|
|
29
|
-
return bin;
|
|
62
|
+
return { cmd: bin, pre: [] };
|
|
30
63
|
}
|
|
31
64
|
}
|
|
65
|
+
if (ctx.platform === 'win32') {
|
|
66
|
+
const w = await windowsDshCli(ctx);
|
|
67
|
+
if (w)
|
|
68
|
+
return w;
|
|
69
|
+
}
|
|
32
70
|
const onPath = await which('dsh');
|
|
33
71
|
if (onPath)
|
|
34
|
-
return onPath;
|
|
72
|
+
return { cmd: onPath, pre: [] };
|
|
35
73
|
throw new DshModelError('dsh_cli_not_found', L('找不到 dsh 的命令行', 'Could not find the dsh command line'), L('请确认 dsh 已安装;或设置 DSH_MODEL_DSH_BIN 指向 dsh 可执行文件', 'Make sure dsh is installed, or set DSH_MODEL_DSH_BIN to the dsh executable'));
|
|
36
74
|
}
|
|
37
75
|
function dshEnv(ctx, proxy) {
|
|
38
76
|
return { ...ctx.env, DSH_HOME: ctx.dshHome, ...(proxy ? { HTTPS_PROXY: proxy, HTTP_PROXY: proxy } : {}) };
|
|
39
77
|
}
|
|
40
78
|
/** vps 模式下 dsh 的文件归 dsh 用户,插件操作要以它的身份跑 */
|
|
41
|
-
function wrap(ctx,
|
|
79
|
+
function wrap(ctx, cli, args, proxy) {
|
|
42
80
|
if (ctx.runAs && ctx.isRoot) {
|
|
43
81
|
const keep = ['DSH_HOME', ...(proxy ? ['HTTPS_PROXY', 'HTTP_PROXY'] : [])].join(',');
|
|
44
|
-
return { cmd: 'sudo', args: ['-u', ctx.runAs, '-H', `--preserve-env=${keep}`,
|
|
82
|
+
return { cmd: 'sudo', args: ['-u', ctx.runAs, '-H', `--preserve-env=${keep}`, cli.cmd, ...cli.pre, ...args] };
|
|
45
83
|
}
|
|
46
|
-
return { cmd:
|
|
84
|
+
return { cmd: cli.cmd, args: [...cli.pre, ...args] };
|
|
47
85
|
}
|
|
48
86
|
/**
|
|
49
87
|
* 这个 profile 的 node_modules 当初链接的 pnpm 仓库:沿用它。
|
|
@@ -52,11 +90,18 @@ function wrap(ctx, bin, args, proxy) {
|
|
|
52
90
|
async function profileStoreDir(ctx, profile) {
|
|
53
91
|
try {
|
|
54
92
|
const text = await readFile(join(ctx.dshHome, 'profiles', profile, 'node_modules', '.modules.yaml'), 'utf8');
|
|
55
|
-
|
|
93
|
+
// 这个文件实际是 JSON(Windows 路径里的反斜杠是转义过的),解析不了再退回正则
|
|
94
|
+
let dir;
|
|
95
|
+
try {
|
|
96
|
+
dir = JSON.parse(text).storeDir;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
dir = /"?storeDir"?\s*:\s*"?([^"\n,]+)"?/.exec(text)?.[1]?.trim();
|
|
100
|
+
}
|
|
56
101
|
if (!dir)
|
|
57
102
|
return null;
|
|
58
103
|
// 记录的是带版本的子目录(…/store/v11),store-dir 要它的上一级
|
|
59
|
-
return dir.replace(
|
|
104
|
+
return dir.replace(/[\\/]v\d+$/, '');
|
|
60
105
|
}
|
|
61
106
|
catch {
|
|
62
107
|
return null;
|
|
@@ -64,10 +109,10 @@ async function profileStoreDir(ctx, profile) {
|
|
|
64
109
|
}
|
|
65
110
|
/** dsh plugin --profile <p> <pnpm-args...>,输出直接给用户看(pnpm 进度) */
|
|
66
111
|
export async function dshPlugin(ctx, profile, pnpmArgs, proxy) {
|
|
67
|
-
const
|
|
112
|
+
const cli = await locateDshCli(ctx);
|
|
68
113
|
const store = await profileStoreDir(ctx, profile);
|
|
69
|
-
const w = wrap(ctx,
|
|
70
|
-
return runInherit(w.cmd, w.args, { env: dshEnv(ctx, proxy) });
|
|
114
|
+
const w = wrap(ctx, cli, ['plugin', '--profile', profile, ...pnpmArgs, ...(store ? [`--config.store-dir=${store}`] : [])], proxy);
|
|
115
|
+
return runInherit(w.cmd, w.args, { env: { ...dshEnv(ctx, proxy), ...cli.env } });
|
|
71
116
|
}
|
|
72
117
|
/** profile 的 package.json 里有没有这个依赖(有就是用户或 dsh 已经装了) */
|
|
73
118
|
export async function profileHasDependency(ctx, profile, name) {
|
|
@@ -81,7 +126,8 @@ export async function profileHasDependency(ctx, profile, name) {
|
|
|
81
126
|
}
|
|
82
127
|
export async function dshCliVersion(ctx) {
|
|
83
128
|
try {
|
|
84
|
-
const
|
|
129
|
+
const cli = await locateDshCli(ctx);
|
|
130
|
+
const r = await run(cli.cmd, [...cli.pre, '--version'], { timeoutMs: 20_000, env: { ...process.env, ...cli.env } });
|
|
85
131
|
return r.code === 0 ? r.stdout.trim() || null : null;
|
|
86
132
|
}
|
|
87
133
|
catch {
|
package/dist/dsh/locate.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// dsh/locate.ts — 找到 dsh:DSH_HOME、profile、版本
|
|
2
2
|
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { dshCliVersion } from './cli.js';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
import { VPS_ROOT } from '../context.js';
|
|
@@ -52,6 +53,11 @@ export async function dshVersion(ctx) {
|
|
|
52
53
|
return r.stdout.trim();
|
|
53
54
|
}
|
|
54
55
|
}
|
|
56
|
+
// Windows 没有 Info.plist:问 dsh 命令行自己(输出可能是 "dsh 0.2.0-rc.2",取其中的版本号)
|
|
57
|
+
if (ctx.platform === 'win32') {
|
|
58
|
+
const out = await dshCliVersion(ctx);
|
|
59
|
+
return out?.match(/\d+\.\d+\.\d+(?:-[\w.]+)?/)?.[0] ?? null;
|
|
60
|
+
}
|
|
55
61
|
return null;
|
|
56
62
|
}
|
|
57
63
|
export async function locateDsh(ctx, preferredProfile) {
|
package/dist/engine/install.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 只信任仓库里 engine-manifest.json 的 sha256;校验不过直接失败。
|
|
4
4
|
// 版本目录 engine/versions/<v>/,current 软链指向在用版本,升级与回滚只切软链。
|
|
5
|
+
// Windows:发布包是 zip、程序叫 cli-proxy-api.exe;建软链要管理员,所以 current 用目录联接(junction),不需要权限。
|
|
5
6
|
import { createWriteStream } from 'node:fs';
|
|
6
|
-
import { chmod, mkdir, readFile, readlink, rename, rm, symlink } from 'node:fs/promises';
|
|
7
|
-
import { join } from 'node:path';
|
|
7
|
+
import { chmod, mkdir, readFile, readlink, rename, rm, symlink, unlink } from 'node:fs/promises';
|
|
8
|
+
import { join, resolve } from 'node:path';
|
|
8
9
|
import { Readable } from 'node:stream';
|
|
9
10
|
import { pipeline } from 'node:stream/promises';
|
|
10
11
|
import { DshModelError } from '../errors.js';
|
|
@@ -12,14 +13,19 @@ import { L } from '../i18n.js';
|
|
|
12
13
|
import { run } from '../util/exec.js';
|
|
13
14
|
import { ensureDir, exists, sha256 } from '../util/fs.js';
|
|
14
15
|
import { PKG_ROOT } from '../util/pkg.js';
|
|
15
|
-
export const ENGINE_BIN = 'cli-proxy-api';
|
|
16
|
+
export const ENGINE_BIN = process.platform === 'win32' ? 'cli-proxy-api.exe' : 'cli-proxy-api';
|
|
16
17
|
export async function loadManifest() {
|
|
17
18
|
return JSON.parse(await readFile(join(PKG_ROOT, 'engine-manifest.json'), 'utf8'));
|
|
18
19
|
}
|
|
19
|
-
/** darwin-aarch64 / linux-amd64 …(与上游发布包命名一致) */
|
|
20
|
+
/** darwin-aarch64 / linux-amd64 / windows-amd64 …(与上游发布包命名一致) */
|
|
20
21
|
export function platformTarget(platform = process.platform, arch = process.arch) {
|
|
21
22
|
const a = arch === 'arm64' ? 'aarch64' : arch === 'x64' ? 'amd64' : arch;
|
|
22
|
-
return `${platform}-${a}`;
|
|
23
|
+
return `${platform === 'win32' ? 'windows' : platform}-${a}`;
|
|
24
|
+
}
|
|
25
|
+
/** 上游发布包的文件名:Windows 是 zip,其余 tar.gz */
|
|
26
|
+
export function assetFileName(version, target) {
|
|
27
|
+
const [os, arch] = target.split('-');
|
|
28
|
+
return `CLIProxyAPI_${version}_${os}_${arch}.${os === 'windows' ? 'zip' : 'tar.gz'}`;
|
|
23
29
|
}
|
|
24
30
|
export function versionBinary(ctx, version) {
|
|
25
31
|
return join(ctx.paths.versionsDir, version, ENGINE_BIN);
|
|
@@ -29,8 +35,9 @@ export function currentBinary(ctx) {
|
|
|
29
35
|
}
|
|
30
36
|
export async function currentVersion(ctx) {
|
|
31
37
|
try {
|
|
38
|
+
// 软链是相对路径 versions/<v>;Windows 的目录联接读出来是绝对路径、反斜杠
|
|
32
39
|
const target = await readlink(ctx.paths.current);
|
|
33
|
-
return target.split(
|
|
40
|
+
return target.split(/[\\/]/).filter(Boolean).pop() ?? null;
|
|
34
41
|
}
|
|
35
42
|
catch {
|
|
36
43
|
return null;
|
|
@@ -38,9 +45,8 @@ export async function currentVersion(ctx) {
|
|
|
38
45
|
}
|
|
39
46
|
/** 从上游发布的 checksums.txt 取某版本的资产(engine upgrade <ver> 指定了清单外版本时用) */
|
|
40
47
|
export async function fetchUpstreamAsset(manifest, version, target) {
|
|
41
|
-
const [os, arch] = target.split('-');
|
|
42
48
|
const base = `https://github.com/${manifest.repo}/releases/download/v${version}`;
|
|
43
|
-
const file =
|
|
49
|
+
const file = assetFileName(version, target);
|
|
44
50
|
const res = await fetch(`${base}/checksums.txt`);
|
|
45
51
|
if (!res.ok)
|
|
46
52
|
throw new DshModelError('engine_version_not_found', L(`找不到引擎版本 v${version}(HTTP ${res.status})`, `Engine version v${version} not found (HTTP ${res.status})`));
|
|
@@ -85,12 +91,13 @@ export async function installVersion(ctx, version, asset) {
|
|
|
85
91
|
}
|
|
86
92
|
await rm(partial, { recursive: true, force: true });
|
|
87
93
|
await mkdir(partial, { recursive: true });
|
|
88
|
-
const tar = await
|
|
94
|
+
const tar = await extract(archive, partial);
|
|
89
95
|
if (tar.code !== 0)
|
|
90
96
|
throw new DshModelError('engine_extract_failed', L(`解压引擎失败:${tar.stderr.trim()}`, `Failed to extract engine: ${tar.stderr.trim()}`));
|
|
91
97
|
if (!(await exists(join(partial, ENGINE_BIN))))
|
|
92
98
|
throw new DshModelError('engine_extract_failed', L(`压缩包里没有 ${ENGINE_BIN}`, `${ENGINE_BIN} not found in archive`));
|
|
93
|
-
|
|
99
|
+
if (process.platform !== 'win32')
|
|
100
|
+
await chmod(join(partial, ENGINE_BIN), 0o755);
|
|
94
101
|
await rename(partial, join(ctx.paths.versionsDir, version));
|
|
95
102
|
if (ctx.owner)
|
|
96
103
|
await run('chown', ['-R', `${ctx.owner.uid}:${ctx.owner.gid}`, ctx.paths.engineDir]);
|
|
@@ -101,8 +108,26 @@ export async function installVersion(ctx, version, asset) {
|
|
|
101
108
|
await rm(partial, { recursive: true, force: true });
|
|
102
109
|
}
|
|
103
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* 解压。tar.gz 用系统 tar;Windows 的 zip 也用系统自带的 tar(Windows 10 1803 起有 System32\\tar.exe,bsdtar,认 zip),
|
|
113
|
+
* 写全路径,免得 PATH 里先找到 Git Bash 的 GNU tar(不认 zip)。
|
|
114
|
+
*/
|
|
115
|
+
async function extract(archive, dest) {
|
|
116
|
+
if (archive.endsWith('.zip')) {
|
|
117
|
+
const tar = process.platform === 'win32' ? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe') : 'tar';
|
|
118
|
+
return run(tar, ['-xf', archive, '-C', dest]);
|
|
119
|
+
}
|
|
120
|
+
return run('tar', ['-xzf', archive, '-C', dest]);
|
|
121
|
+
}
|
|
104
122
|
/** 原子切换 current 软链:先建临时软链再 rename 覆盖 */
|
|
105
123
|
export async function activateVersion(ctx, version) {
|
|
124
|
+
if (process.platform === 'win32') {
|
|
125
|
+
// 目录联接要绝对路径;rename 不能覆盖已有的目录,所以先删旧的再建(瞬间切换,不是原子的)
|
|
126
|
+
// unlink 只删联接本身,不会动到它指向的版本目录
|
|
127
|
+
await unlink(ctx.paths.current).catch(() => { });
|
|
128
|
+
await symlink(resolve(ctx.paths.versionsDir, version), ctx.paths.current, 'junction');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
106
131
|
const tmp = `${ctx.paths.current}.tmp`;
|
|
107
132
|
await rm(tmp, { force: true });
|
|
108
133
|
await symlink(join('versions', version), tmp);
|
package/dist/service/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { currentBinary } from '../engine/install.js';
|
|
|
5
5
|
import { PKG_ROOT } from '../util/pkg.js';
|
|
6
6
|
import { launchd } from './launchd.js';
|
|
7
7
|
import { systemd } from './systemd.js';
|
|
8
|
+
import { windowsService } from './windows.js';
|
|
8
9
|
export const SERVICE_LABEL = 'com.dsh-model.engine';
|
|
9
10
|
export const UNIT_NAME = 'dsh-model-engine.service';
|
|
10
11
|
export const BRIDGE_LABEL = 'com.dsh-model.bridge';
|
|
@@ -12,6 +13,8 @@ export const BRIDGE_UNIT = 'dsh-model-bridge.service';
|
|
|
12
13
|
export function serviceKind(ctx) {
|
|
13
14
|
if (ctx.mode === 'vps')
|
|
14
15
|
return 'systemd-system';
|
|
16
|
+
if (ctx.platform === 'win32')
|
|
17
|
+
return 'windows';
|
|
15
18
|
return ctx.platform === 'darwin' ? 'launchd' : 'systemd-user';
|
|
16
19
|
}
|
|
17
20
|
/** bridge 是 Node 进程:用当前 node 的绝对路径,加上本包的入口 */
|
|
@@ -40,6 +43,8 @@ export function serviceFor(ctx, name = 'engine', proxy) {
|
|
|
40
43
|
home: ctx.paths.home,
|
|
41
44
|
...(name === 'bridge' ? { env: bridgeEnv(ctx, proxy) } : {}),
|
|
42
45
|
};
|
|
46
|
+
if (kind === 'windows')
|
|
47
|
+
return windowsService({ ...common, label: name === 'engine' ? 'dsh-model-engine' : 'dsh-model-bridge', file: join(ctx.paths.home, 'services', `${name === 'engine' ? 'dsh-model-engine' : 'dsh-model-bridge'}.json`) });
|
|
43
48
|
if (kind === 'launchd')
|
|
44
49
|
return launchd({ ...common, label, file: join(homedir(), 'Library/LaunchAgents', `${label}.plist`) });
|
|
45
50
|
if (kind === 'systemd-user')
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// service/windows.ts — Windows:开机登录后自动运行、不弹窗口、崩溃自动重启,都不需要管理员(设计 §16)
|
|
2
|
+
//
|
|
3
|
+
// - 自启:写进当前用户的 Run 键(HKCU\Software\Microsoft\Windows\CurrentVersion\Run);
|
|
4
|
+
// - 不弹黑窗口:引擎和 node 都是控制台程序,直接运行会弹窗。用 wscript 跑一个隐藏窗口的小脚本(.vbs,style 0)启动;
|
|
5
|
+
// - 崩溃重启:小脚本启动的是 `dsh-model service supervise <spec>`,它用 windowsHide 拉起真正的程序,退出了就隔几秒再拉;
|
|
6
|
+
// - 停止:supervise 把自己的 pid 写进 <label>.pid,taskkill /T /F 连同子进程一起结束。
|
|
7
|
+
// wscript 启动完就退出,所以 supervise 不挂在调用者的进程树下(taskkill /T 调用者时不会被连带杀掉)。
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { DshModelError } from '../errors.js';
|
|
12
|
+
import { L } from '../i18n.js';
|
|
13
|
+
import { run } from '../util/exec.js';
|
|
14
|
+
import { atomicWrite, exists } from '../util/fs.js';
|
|
15
|
+
import { PKG_ROOT } from '../util/pkg.js';
|
|
16
|
+
export const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
|
17
|
+
const paths = (spec) => {
|
|
18
|
+
const base = spec.file.replace(/\.json$/i, '');
|
|
19
|
+
return { json: spec.file, vbs: `${base}.vbs`, pid: `${base}.pid` };
|
|
20
|
+
};
|
|
21
|
+
/** VBScript 字符串里的双引号要写两遍 */
|
|
22
|
+
const vbq = (s) => `"${s.replace(/"/g, '""')}"`;
|
|
23
|
+
/** 隐藏窗口启动 supervise 的小脚本。存成 UTF-16LE(带 BOM),路径里有中文也不乱码 */
|
|
24
|
+
export function renderVbs(spec, node = process.execPath, electron = Boolean(process.versions.electron)) {
|
|
25
|
+
const cmd = [node, join(PKG_ROOT, 'bin', 'dsh-model.js'), 'service', 'supervise', spec.file].map((a) => `""${a.replace(/"/g, '""""')}""`).join(' ');
|
|
26
|
+
return [
|
|
27
|
+
"' Generated by dsh-model. 由 dsh-model 生成:隐藏窗口启动后台服务,卸载请执行 dsh-model uninstall",
|
|
28
|
+
'Set sh = CreateObject("WScript.Shell")',
|
|
29
|
+
// 从 dsh 桌面版一键安装时 node 就是 DeepSeek Harness.exe:让它当 Node 跑
|
|
30
|
+
...(electron ? ['sh.Environment("Process")("ELECTRON_RUN_AS_NODE") = "1"'] : []),
|
|
31
|
+
`sh.Run "${cmd}", 0, False`,
|
|
32
|
+
'',
|
|
33
|
+
].join('\r\n');
|
|
34
|
+
}
|
|
35
|
+
export function renderSupervise(spec) {
|
|
36
|
+
return {
|
|
37
|
+
label: spec.label,
|
|
38
|
+
program: spec.program,
|
|
39
|
+
cwd: spec.workingDirectory,
|
|
40
|
+
env: { ...spec.env, ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}) },
|
|
41
|
+
stdout: spec.stdoutPath,
|
|
42
|
+
pidFile: paths(spec).pid,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function alive(pid) {
|
|
46
|
+
try {
|
|
47
|
+
process.kill(pid, 0);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function readPid(file) {
|
|
55
|
+
const n = Number((await readFile(file, 'utf8').catch(() => '')).trim());
|
|
56
|
+
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
57
|
+
}
|
|
58
|
+
export function windowsService(spec) {
|
|
59
|
+
const p = paths(spec);
|
|
60
|
+
const runValue = `wscript.exe //B //Nologo "${p.vbs}"`;
|
|
61
|
+
const running = async () => {
|
|
62
|
+
const pid = await readPid(p.pid);
|
|
63
|
+
return pid && alive(pid) ? pid : undefined;
|
|
64
|
+
};
|
|
65
|
+
const start = async () => {
|
|
66
|
+
if (await running())
|
|
67
|
+
return;
|
|
68
|
+
const child = spawn('wscript.exe', ['//B', '//Nologo', p.vbs], { detached: true, stdio: 'ignore', windowsHide: true });
|
|
69
|
+
child.unref();
|
|
70
|
+
const deadline = Date.now() + 10_000;
|
|
71
|
+
while (Date.now() < deadline && !(await running()))
|
|
72
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
73
|
+
if (!(await running()))
|
|
74
|
+
throw new DshModelError('service_failed', L(`后台服务 ${spec.label} 没有启动(看日志:${spec.stdoutPath})`, `Background service ${spec.label} did not start (log: ${spec.stdoutPath})`), L('可能被杀毒软件拦截:把 dsh-model 的目录加入信任后执行 dsh-model repair', 'An antivirus may have blocked it: trust the dsh-model folder, then run dsh-model repair'));
|
|
75
|
+
};
|
|
76
|
+
const stop = async () => {
|
|
77
|
+
const pid = await readPid(p.pid);
|
|
78
|
+
if (pid && alive(pid))
|
|
79
|
+
await run('taskkill', ['/PID', String(pid), '/T', '/F']);
|
|
80
|
+
await rm(p.pid, { force: true });
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
kind: 'windows',
|
|
84
|
+
spec,
|
|
85
|
+
render: () => `${JSON.stringify(renderSupervise(spec), null, 2)}\n\n${renderVbs(spec)}`,
|
|
86
|
+
async install() {
|
|
87
|
+
await atomicWrite(p.json, JSON.stringify(renderSupervise(spec), null, 2), { mode: 0o600 });
|
|
88
|
+
await writeFile(p.vbs, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(renderVbs(spec), 'utf16le')]));
|
|
89
|
+
const r = await run('reg', ['add', RUN_KEY, '/v', spec.label, '/t', 'REG_SZ', '/d', runValue, '/f']);
|
|
90
|
+
if (r.code !== 0)
|
|
91
|
+
throw new DshModelError('service_failed', L(`写开机自启失败:${r.stderr.trim()}`, `Failed to register autostart: ${r.stderr.trim()}`));
|
|
92
|
+
await stop();
|
|
93
|
+
await start();
|
|
94
|
+
},
|
|
95
|
+
start,
|
|
96
|
+
stop,
|
|
97
|
+
async restart() {
|
|
98
|
+
await stop();
|
|
99
|
+
await start();
|
|
100
|
+
},
|
|
101
|
+
async status() {
|
|
102
|
+
const installed = await exists(p.json);
|
|
103
|
+
const pid = await running();
|
|
104
|
+
return { installed, running: Boolean(pid), ...(pid ? { pid } : {}), detail: pid ? 'running' : installed ? 'stopped' : 'not installed' };
|
|
105
|
+
},
|
|
106
|
+
async uninstall() {
|
|
107
|
+
await stop();
|
|
108
|
+
await run('reg', ['delete', RUN_KEY, '/v', spec.label, '/f']);
|
|
109
|
+
await rm(p.json, { force: true });
|
|
110
|
+
await rm(p.vbs, { force: true });
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* `dsh-model service supervise <spec.json>`:由隐藏窗口脚本启动,常驻。
|
|
116
|
+
* 拉起程序(不弹窗,输出追加到日志);程序退出就隔几秒重拉(太频繁就拉长间隔)。被 taskkill /T 时连同子进程一起结束。
|
|
117
|
+
*/
|
|
118
|
+
export async function supervise(specFile) {
|
|
119
|
+
const s = JSON.parse(await readFile(specFile, 'utf8'));
|
|
120
|
+
const { closeSync, openSync } = await import('node:fs');
|
|
121
|
+
await atomicWrite(s.pidFile, String(process.pid), { mode: 0o600 });
|
|
122
|
+
let backoff = 2_000;
|
|
123
|
+
for (;;) {
|
|
124
|
+
const startedAt = Date.now();
|
|
125
|
+
const out = openSync(s.stdout, 'a');
|
|
126
|
+
const code = await new Promise((resolve) => {
|
|
127
|
+
const child = spawn(s.program[0], s.program.slice(1), { cwd: s.cwd, env: { ...process.env, ...s.env }, stdio: ['ignore', out, out], windowsHide: true });
|
|
128
|
+
child.on('error', () => resolve(-1));
|
|
129
|
+
child.on('exit', (c) => resolve(c ?? -1));
|
|
130
|
+
});
|
|
131
|
+
closeSync(out);
|
|
132
|
+
await writeFile(s.stdout, `[${new Date().toISOString()}] dsh-model supervise: ${s.label} exited (${code}); restarting\n`, { flag: 'a' }).catch(() => { });
|
|
133
|
+
// 跑了一分钟以上才退出的算正常崩溃,间隔恢复到 2 秒;一启动就退出的逐步拉长到 60 秒,免得空转
|
|
134
|
+
backoff = Date.now() - startedAt > 60_000 ? 2_000 : Math.min(backoff * 2, 60_000);
|
|
135
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
136
|
+
if (!(await exists(dirname(s.pidFile))))
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// util/detach.ts — 起一个和自己脱钩的后台进程(更新、卸载这类会重启或移除守护进程本身的任务)
|
|
2
|
+
//
|
|
3
|
+
// Windows 上 taskkill /T 按"父进程"找整棵进程树:直接 spawn 出来的子进程,在守护进程被重启时会被连带杀掉。
|
|
4
|
+
// 所以 Windows 上先起一个只活几毫秒的中转进程,由它再拉起真正的任务并立刻退出;任务的父进程不在了,就不在任何人的树下。
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { closeSync, openSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
const RELAY = `
|
|
9
|
+
const { spawn } = require('node:child_process');
|
|
10
|
+
const { openSync } = require('node:fs');
|
|
11
|
+
const job = JSON.parse(process.argv[1]);
|
|
12
|
+
const out = openSync(job.log, 'w');
|
|
13
|
+
spawn(job.cmd, job.args, { detached: true, windowsHide: true, cwd: job.cwd, stdio: ['ignore', out, out] }).unref();
|
|
14
|
+
`;
|
|
15
|
+
export function spawnDetached(cmd, args, options) {
|
|
16
|
+
if (process.platform === 'win32') {
|
|
17
|
+
// 中转进程用同一个 node(dsh 桌面版里是 Electron,env 里已带 ELECTRON_RUN_AS_NODE)
|
|
18
|
+
// 工作目录放在用户主目录:卸载要删 ~/.dsh-model,Windows 上进程的工作目录在里面时删不掉
|
|
19
|
+
const relay = spawn(process.execPath, ['-e', RELAY, JSON.stringify({ cmd, args, log: options.log, cwd: homedir() })], { detached: true, windowsHide: true, cwd: homedir(), stdio: 'ignore', env: options.env });
|
|
20
|
+
relay.unref();
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
const out = openSync(options.log, 'w', 0o600);
|
|
24
|
+
const child = spawn(cmd, args, { detached: true, cwd: homedir(), stdio: ['ignore', out, out], env: options.env });
|
|
25
|
+
child.unref();
|
|
26
|
+
closeSync(out);
|
|
27
|
+
return child.pid;
|
|
28
|
+
}
|
package/dist/util/exec.js
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
// util/exec.ts — 子进程
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
+
const WIN = process.platform === 'win32';
|
|
4
|
+
/** cmd.exe 的参数加引号:整段包在双引号里,里面的双引号写两遍 */
|
|
5
|
+
export function quoteWinArg(a) {
|
|
6
|
+
return /^[\w@%+=:,./\\-]+$/.test(a) ? a : `"${a.replace(/"/g, '""')}"`;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* 统一处理 Windows 的两个坑:
|
|
10
|
+
* - .cmd / .bat(npm.cmd、dsh.cmd)不能直接 spawn(Node 18.20 / 20.12 起直接报 EINVAL),要经 cmd.exe;
|
|
11
|
+
* - 子进程默认会弹一个黑色控制台窗口:一律 windowsHide。
|
|
12
|
+
*/
|
|
13
|
+
export function spawnArgs(cmd, args, options) {
|
|
14
|
+
if (WIN && /\.(cmd|bat)$/i.test(cmd)) {
|
|
15
|
+
return [[cmd, ...args].map(quoteWinArg).join(' '), [], { ...options, shell: true, windowsHide: true }];
|
|
16
|
+
}
|
|
17
|
+
return [cmd, args, WIN ? { ...options, windowsHide: true } : options];
|
|
18
|
+
}
|
|
3
19
|
/** 跑一个命令并收集输出;不抛错,调用方看 code */
|
|
4
20
|
export function run(cmd, args, options = {}) {
|
|
5
21
|
return new Promise((resolve) => {
|
|
6
22
|
// 不需要输入时 stdin 直接 ignore;需要时子进程可能不读就退出(或命令不存在),写入会 EPIPE,必须吞掉(服务器上实测会崩)
|
|
7
23
|
const hasInput = options.input !== undefined;
|
|
8
|
-
const child = spawn(cmd, args, { cwd: options.cwd, env: options.env ?? process.env, stdio: [hasInput ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
|
|
24
|
+
const child = spawn(...spawnArgs(cmd, args, { cwd: options.cwd, env: options.env ?? process.env, stdio: [hasInput ? 'pipe' : 'ignore', 'pipe', 'pipe'] }));
|
|
9
25
|
let stdout = '';
|
|
10
26
|
let stderr = '';
|
|
11
27
|
const timer = options.timeoutMs ? setTimeout(() => child.kill('SIGKILL'), options.timeoutMs) : null;
|
|
@@ -30,13 +46,17 @@ export function run(cmd, args, options = {}) {
|
|
|
30
46
|
/** 交互式运行:stdio 直接交给用户(登录流程要用户看 URL、粘贴回调) */
|
|
31
47
|
export function runInherit(cmd, args, options = {}) {
|
|
32
48
|
return new Promise((resolve) => {
|
|
33
|
-
const child = spawn(cmd, args, { cwd: options.cwd, env: options.env ?? process.env, stdio: 'inherit' });
|
|
49
|
+
const child = spawn(...spawnArgs(cmd, args, { cwd: options.cwd, env: options.env ?? process.env, stdio: 'inherit' }));
|
|
34
50
|
child.on('error', () => resolve(127));
|
|
35
51
|
child.on('close', (code) => resolve(code ?? 1));
|
|
36
52
|
});
|
|
37
53
|
}
|
|
38
54
|
/** 在 PATH 里找命令 */
|
|
39
55
|
export async function which(cmd) {
|
|
56
|
+
if (WIN) {
|
|
57
|
+
const r = await run('where', [cmd]);
|
|
58
|
+
return r.code === 0 ? r.stdout.split(/\r?\n/)[0]?.trim() || null : null;
|
|
59
|
+
}
|
|
40
60
|
const r = await run('sh', ['-c', `command -v ${cmd}`]);
|
|
41
61
|
return r.code === 0 ? r.stdout.trim() || null : null;
|
|
42
62
|
}
|
package/dist/util/fs.js
CHANGED
|
@@ -14,13 +14,30 @@ export async function atomicWrite(file, data, options = {}) {
|
|
|
14
14
|
await chmod(tmp, mode); // umask 可能削掉 writeFile 的 mode
|
|
15
15
|
if (options.owner)
|
|
16
16
|
await chown(tmp, options.owner.uid, options.owner.gid);
|
|
17
|
-
await
|
|
17
|
+
await renameRetry(tmp, file);
|
|
18
18
|
}
|
|
19
19
|
catch (error) {
|
|
20
20
|
await rm(tmp, { force: true });
|
|
21
21
|
throw error;
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Windows 上目标文件正被别的进程打开(杀毒扫描、引擎在读配置)时,rename 会短暂报 EACCES / EBUSY / EPERM:
|
|
26
|
+
* 退避重试一会儿(dsh 自己的 atomic-write 也这么做)。其他平台直接 rename。
|
|
27
|
+
*/
|
|
28
|
+
async function renameRetry(from, to) {
|
|
29
|
+
for (let i = 0;; i++) {
|
|
30
|
+
try {
|
|
31
|
+
return await rename(from, to);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
const code = error.code;
|
|
35
|
+
if (process.platform !== 'win32' || i >= 8 || !['EACCES', 'EBUSY', 'EPERM'].includes(code ?? ''))
|
|
36
|
+
throw error;
|
|
37
|
+
await new Promise((r) => setTimeout(r, 25 * 2 ** i));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
24
41
|
/**
|
|
25
42
|
* 原地覆盖写(保留 inode):给"被别的进程用 fsnotify 盯着的文件"用。
|
|
26
43
|
* atomicWrite 的 rename 会换掉 inode,Linux 上盯着旧 inode 的监听就再也收不到变化(引擎热重载实测失效)。
|
package/dist/util/proxy.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// util/proxy.ts — 出站代理:引擎作为后台服务运行时拿不到终端里的 HTTPS_PROXY,
|
|
2
2
|
// 所以 setup 时把代理写进 engine.yaml 的 requests.proxy-url。
|
|
3
|
-
// 检测顺序:HTTPS_PROXY / ALL_PROXY 环境变量 → macOS
|
|
3
|
+
// 检测顺序:HTTPS_PROXY / ALL_PROXY 环境变量 → 系统代理(macOS:scutil --proxy;Windows:注册表 Internet Settings,Clash 等"系统代理"写在这里)
|
|
4
4
|
import { connect } from 'node:net';
|
|
5
5
|
import { DshModelError } from '../errors.js';
|
|
6
6
|
import { L } from '../i18n.js';
|
|
@@ -41,8 +41,34 @@ export async function detectProxy(env = process.env, platform = process.platform
|
|
|
41
41
|
return { url: `socks5://${get('SOCKSProxy')}:${get('SOCKSPort')}`, source: 'macOS system proxy' };
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
if (platform === 'win32') {
|
|
45
|
+
const key = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings';
|
|
46
|
+
const [en, srv] = await Promise.all([run('reg', ['query', key, '/v', 'ProxyEnable']), run('reg', ['query', key, '/v', 'ProxyServer'])]);
|
|
47
|
+
const url = en.code === 0 && srv.code === 0 ? parseWindowsProxy(en.stdout, srv.stdout) : null;
|
|
48
|
+
if (url)
|
|
49
|
+
return { url, source: 'Windows system proxy' };
|
|
50
|
+
}
|
|
44
51
|
return null;
|
|
45
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* 解析 reg query 的输出。ProxyServer 两种写法:
|
|
55
|
+
* "127.0.0.1:7890"(所有协议共用)或 "http=127.0.0.1:7890;https=127.0.0.1:7890;socks=127.0.0.1:7891"
|
|
56
|
+
*/
|
|
57
|
+
export function parseWindowsProxy(enableOut, serverOut) {
|
|
58
|
+
if (!/ProxyEnable\s+REG_DWORD\s+0x0*1\b/i.test(enableOut))
|
|
59
|
+
return null;
|
|
60
|
+
const raw = /ProxyServer\s+REG_SZ\s+(\S+)/i.exec(serverOut)?.[1];
|
|
61
|
+
if (!raw)
|
|
62
|
+
return null;
|
|
63
|
+
if (!raw.includes('='))
|
|
64
|
+
return /^[a-z]+:\/\//i.test(raw) ? raw : `http://${raw}`;
|
|
65
|
+
const parts = new Map(raw.split(';').map((p) => p.split('=')).filter(([k, v]) => k && v).map(([k, v]) => [k.toLowerCase(), v]));
|
|
66
|
+
const pick = parts.get('https') ?? parts.get('http');
|
|
67
|
+
if (pick)
|
|
68
|
+
return /^[a-z]+:\/\//i.test(pick) ? pick : `http://${pick}`;
|
|
69
|
+
const socks = parts.get('socks');
|
|
70
|
+
return socks ? `socks5://${socks.replace(/^[a-z0-9]+:\/\//i, '')}` : null;
|
|
71
|
+
}
|
|
46
72
|
/** 代理端口能不能连上(doctor 用) */
|
|
47
73
|
export function proxyReachable(url, timeoutMs = 2000) {
|
|
48
74
|
return new Promise((resolve) => {
|
package/engine-manifest.json
CHANGED
|
@@ -23,6 +23,16 @@
|
|
|
23
23
|
"file": "CLIProxyAPI_8.0.13_linux_amd64.tar.gz",
|
|
24
24
|
"url": "https://github.com/router-for-me/CLIProxyAPI/releases/download/v8.0.13/CLIProxyAPI_8.0.13_linux_amd64.tar.gz",
|
|
25
25
|
"sha256": "50ecffb47fdd81c8c5a9825a73a7a905ab66342337e274f39c4276b92d3533f3"
|
|
26
|
+
},
|
|
27
|
+
"windows-aarch64": {
|
|
28
|
+
"file": "CLIProxyAPI_8.0.13_windows_aarch64.zip",
|
|
29
|
+
"url": "https://github.com/router-for-me/CLIProxyAPI/releases/download/v8.0.13/CLIProxyAPI_8.0.13_windows_aarch64.zip",
|
|
30
|
+
"sha256": "ec1ea1cc76964d0a9d68fd0c989a9d9f579911f08fa902663259adb1e9e6df4c"
|
|
31
|
+
},
|
|
32
|
+
"windows-amd64": {
|
|
33
|
+
"file": "CLIProxyAPI_8.0.13_windows_amd64.zip",
|
|
34
|
+
"url": "https://github.com/router-for-me/CLIProxyAPI/releases/download/v8.0.13/CLIProxyAPI_8.0.13_windows_amd64.zip",
|
|
35
|
+
"sha256": "7064ec8150511d0ece145a492968b476aa5abae3e1c7ef385852631918086bdd"
|
|
26
36
|
}
|
|
27
37
|
}
|
|
28
38
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-model",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Many model sources behind one authenticated OpenAI-compatible endpoint for DeepSeek Harness (dsh) and your tools: WorkBuddy, OpenCode Zen and your CLI subscriptions (Codex, Kimi…), with a management page inside dsh. 多家模型汇成一个 OpenAI 兼容地址,dsh 和其他软件都能直接用。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|
package/plugin/index.js
CHANGED
|
@@ -104,6 +104,7 @@ function runCli(args) {
|
|
|
104
104
|
running = new Promise((resolve) => {
|
|
105
105
|
let out = ''
|
|
106
106
|
const child = spawn(process.execPath, [join(PKG_ROOT, 'bin/dsh-model.js'), ...args], {
|
|
107
|
+
windowsHide: true,
|
|
107
108
|
// dsh 桌面版可能跑在 Electron 里:让它当普通 Node 用
|
|
108
109
|
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', NO_COLOR: '1', FORCE_COLOR: '0' },
|
|
109
110
|
stdio: ['ignore', 'pipe', 'pipe'],
|