dsh-model 0.15.0 → 0.16.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/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ import { repair, service } from './commands/service.js';
9
9
  import { setup } from './commands/setup.js';
10
10
  import { doctor, status } from './commands/status.js';
11
11
  import { uninstall } from './commands/uninstall.js';
12
- import { selfUpdate } from './commands/self.js';
12
+ import { adminRun, selfUpdate } from './commands/self.js';
13
13
  import { opencode } from './commands/opencode.js';
14
14
  import { workbuddy } from './commands/workbuddy.js';
15
15
  import { bridge } from './commands/bridge.js';
@@ -190,6 +190,8 @@ export async function main(argv) {
190
190
  return await logs(ctx, o.lines ? Number(o.lines) : undefined, o.bridge ? 'bridge' : 'engine');
191
191
  case 'uninstall':
192
192
  return await uninstall(ctx, { yes: o.yes, keepAuth: o['keep-auth'], removePlugin: o['remove-plugin'] });
193
+ case 'admin-run':
194
+ return await adminRun(ctx);
193
195
  case 'self-update':
194
196
  case 'update':
195
197
  return await selfUpdate(ctx, { to: o.to });
@@ -3,7 +3,9 @@
3
3
  // 1. 新版本装到原来的位置:npm 全局装的就 npm install -g;从 dsh 管理页一键安装的(程序就在插件目录里)跳过这步;
4
4
  // 2. dsh 里的插件换成同一版本(插件页的新代码要重启一次 dsh 才生效);
5
5
  // 3. 用新版本执行一次 repair:重写服务文件、重启引擎与守护进程、同步模型。
6
- import { realpath } from 'node:fs/promises';
6
+ import { spawn } from 'node:child_process';
7
+ import { closeSync, openSync } from 'node:fs';
8
+ import { chown, realpath } from 'node:fs/promises';
7
9
  import { dirname, join } from 'node:path';
8
10
  import { requireRootInVps } from '../context.js';
9
11
  import { DshModelError, isDshModelError } from '../errors.js';
@@ -15,6 +17,7 @@ import { run, runInherit } from '../util/exec.js';
15
17
  import { exists } from '../util/fs.js';
16
18
  import { info, next, ok, skip, warn } from '../util/output.js';
17
19
  import { PKG_ROOT, pkgVersion } from '../util/pkg.js';
20
+ import { takeAdminRequest } from '../service/admin.js';
18
21
  const REGISTRY = 'https://registry.npmjs.org/dsh-model';
19
22
  /** a 比 b 新(只比 major.minor.patch) */
20
23
  export function isNewer(a, b) {
@@ -114,3 +117,30 @@ export async function selfUpdate(ctx, opts = {}) {
114
117
  next(L('重启一次 dsh,管理页的新代码才会生效', 'Restart dsh once so the management page picks up the new code'));
115
118
  return 0;
116
119
  }
120
+ /** 管理页请求的日志:更新写在 home 里;卸载会删 home,写在它外面 */
121
+ export function selfJobLog(ctx, action) {
122
+ return action === 'uninstall' ? join(dirname(ctx.paths.home), 'dsh-model-uninstall.log') : join(ctx.paths.home, 'update.log');
123
+ }
124
+ /** 由 dsh-model-admin.service(root)执行:取出管理页的请求单,只做 update / uninstall */
125
+ export async function adminRun(ctx) {
126
+ if (!ctx.isRoot)
127
+ throw new DshModelError('needs_root', L('admin-run 只由 dsh-model-admin 服务以 root 执行', 'admin-run is run by the dsh-model-admin service as root'));
128
+ const req = await takeAdminRequest(ctx);
129
+ if (!req)
130
+ return 0;
131
+ const log = selfJobLog(ctx, req.action);
132
+ const out = openSync(log, 'w', 0o600);
133
+ if (ctx.owner)
134
+ await chown(log, ctx.owner.uid, ctx.owner.gid).catch(() => { });
135
+ const args = req.action === 'update' ? ['update'] : ['uninstall', '--yes', '--remove-plugin', ...(req.keepAuth ? ['--keep-auth'] : [])];
136
+ const code = await new Promise((resolve) => {
137
+ const child = spawn(process.execPath, [join(PKG_ROOT, 'bin', 'dsh-model.js'), ...args, '--lang', req.lang ?? 'zh'], {
138
+ stdio: ['ignore', out, out],
139
+ env: { ...process.env, NO_COLOR: '1', NODE_NO_WARNINGS: '1' },
140
+ });
141
+ child.on('error', () => resolve(-1));
142
+ child.on('exit', (c) => resolve(c ?? -1));
143
+ });
144
+ closeSync(out);
145
+ return code;
146
+ }
@@ -1,4 +1,5 @@
1
1
  // commands/service.ts — service install|start|stop|restart|status|uninstall,以及 repair
2
+ import { installAdmin } from '../service/admin.js';
2
3
  import { requireRootInVps } from '../context.js';
3
4
  import { DshModelError } from '../errors.js';
4
5
  import { L } from '../i18n.js';
@@ -65,6 +66,8 @@ export async function repair(ctx) {
65
66
  throw new DshModelError('engine_unhealthy', L('引擎没有启动,查看 dsh-model logs', 'Engine did not start; see dsh-model logs'));
66
67
  ok(L('服务已重装并重启', 'Service reinstalled and restarted'));
67
68
  await ensureDaemon(ctx, all);
69
+ if (ctx.mode === 'vps')
70
+ await installAdmin(ctx);
68
71
  }
69
72
  await syncAll(ctx, all);
70
73
  return 0;
@@ -22,6 +22,7 @@ import { opencodeConfigured } from '../integrations/opencode.js';
22
22
  import { ensureDaemon } from '../daemon/service.js';
23
23
  import { installDshPlugin } from '../integrations/dshplugin.js';
24
24
  import { enableCaddy } from './remote.js';
25
+ import { installAdmin } from '../service/admin.js';
25
26
  import { DEFAULT_PUBLIC_PORT, publicHost } from '../remote/public.js';
26
27
  export async function setup(ctx, opts) {
27
28
  requireRootInVps(ctx);
@@ -91,6 +92,9 @@ export async function setup(ctx, opts) {
91
92
  skip(L('已跳过(--skip-dsh-plugin)', 'Skipped (--skip-dsh-plugin)'));
92
93
  else
93
94
  await step('dsh-plugin', () => installDshPlugin(ctx, all));
95
+ // vps:让 dsh 管理页能直接点「更新 / 卸载」(root 执行,只认这两件事)
96
+ if (ctx.mode === 'vps' && !ctx.serviceDisabled)
97
+ await step('admin', () => installAdmin(ctx));
94
98
  // vps:自动在 dsh 的域名上开对外端点(没有域名就用 IP),自己的其他设备直接能用
95
99
  if (ctx.mode === 'vps') {
96
100
  info('');
@@ -14,6 +14,7 @@ import { fail, info, ok, warn } from '../util/output.js';
14
14
  import { disable as disableRemote } from './remote.js';
15
15
  import { disableWorkbuddy, removeWorkbuddyPlugin } from '../integrations/workbuddy.js';
16
16
  import { removeDshPlugin } from '../integrations/dshplugin.js';
17
+ import { removeAdmin } from '../service/admin.js';
17
18
  async function confirm(question) {
18
19
  if (!process.stdin.isTTY)
19
20
  return false;
@@ -73,6 +74,10 @@ export async function uninstall(ctx, opts) {
73
74
  ok(L(`dsh 配置已还原(patch:${r.patch},凭据:${r.cred})`, `dsh config restored (patch: ${r.patch}, credentials: ${r.cred})`));
74
75
  });
75
76
  await saveAll(ctx, all).catch(() => { });
77
+ await step('admin', async () => {
78
+ if (ctx.mode === 'vps')
79
+ await removeAdmin();
80
+ });
76
81
  await step('service', async () => {
77
82
  if (ctx.serviceDisabled)
78
83
  return;
@@ -7,9 +7,10 @@
7
7
  import { spawn } from 'node:child_process';
8
8
  import { closeSync, openSync } from 'node:fs';
9
9
  import { readFile } from 'node:fs/promises';
10
- import { homedir } from 'node:os';
11
10
  import { join } from 'node:path';
12
- import { latestVersion } from '../commands/self.js';
11
+ import { latestVersion, selfJobLog } from '../commands/self.js';
12
+ import { adminInstalled, adminRequestPath } from '../service/admin.js';
13
+ import { atomicWrite } from '../util/fs.js';
13
14
  import { lang } from '../i18n.js';
14
15
  import { which } from '../util/exec.js';
15
16
  import { PKG_ROOT, pkgVersion } from '../util/pkg.js';
@@ -642,7 +643,7 @@ export class Daemon {
642
643
  return {
643
644
  version: pkgVersion(),
644
645
  latest: await latestVersion(),
645
- canManage: !vps,
646
+ canManage: !vps || (await adminInstalled()),
646
647
  ...(this.selfJob ? { job: { action: this.selfJob.action, startedAt: this.selfJob.startedAt, log: log.replace(/\x1b\[[0-9;]*m/g, '').slice(-6000) } } : {}),
647
648
  commands: vps
648
649
  ? { update: 'sudo npm install -g dsh-model@latest && sudo dsh-model setup', uninstall: 'sudo dsh-model uninstall' }
@@ -654,10 +655,17 @@ export class Daemon {
654
655
  * 用 detached 让它脱离守护进程的进程组(launchd 重启服务时不会被一起杀掉)。
655
656
  */
656
657
  async startSelfJob(action, opts = {}) {
657
- if (this.ctx.mode === 'vps')
658
- throw new DshModelError('needs_root', L('服务器上要 root 权限,请在终端里执行命令', 'On the server this needs root; run the command in a terminal'));
659
- // 卸载会删掉 home,日志放在外面
660
- const log = action === 'uninstall' ? join(homedir(), 'dsh-model-uninstall.log') : join(this.ctx.paths.home, 'update.log');
658
+ const log = selfJobLog(this.ctx, action);
659
+ if (this.ctx.mode === 'vps') {
660
+ // 守护进程是 dsh 用户:写请求单,由 dsh-model-admin(root)执行
661
+ if (!(await adminInstalled()))
662
+ throw new DshModelError('needs_root', L('服务器上要 root 权限,请在终端里执行命令(重跑一次 sudo dsh-model setup 之后就能在页面上点)', 'On the server this needs root; run the command in a terminal (after re-running sudo dsh-model setup, the buttons work here)'));
663
+ const req = { action, keepAuth: opts.keepAuth === true, lang: lang() };
664
+ await atomicWrite(adminRequestPath(this.ctx), JSON.stringify(req), { mode: 0o600 });
665
+ this.selfJob = { action, startedAt: new Date().toISOString(), log };
666
+ log_(`daemon: requested ${action} from dsh-model-admin`);
667
+ return { started: true, log };
668
+ }
661
669
  const args = action === 'update' ? ['update'] : ['uninstall', '--yes', '--remove-plugin', ...(opts.keepAuth ? ['--keep-auth'] : [])];
662
670
  const out = openSync(log, 'w', 0o600);
663
671
  const cmd = [process.execPath, join(PKG_ROOT, 'bin', 'dsh-model.js'), ...args, '--lang', lang()];
@@ -0,0 +1,86 @@
1
+ // service/admin.ts — VPS 上让 dsh 管理页能「更新 / 卸载」(设计 §14.9)
2
+ //
3
+ // 守护进程以 dsh 用户运行,而更新(npm 全局目录、系统服务)和卸载(Caddy、防火墙、系统服务)要 root。
4
+ // 所以 setup(root)装一个 systemd path 单元:守护进程在 <home>/admin/request 写一张请求单,
5
+ // systemd 以 root 运行 `dsh-model admin-run`,它只认两件固定的事:更新到 npm 最新版、卸载。别的一律拒绝。
6
+ import { chown, mkdir, readFile, rm } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { run } from '../util/exec.js';
9
+ import { atomicWrite, exists } from '../util/fs.js';
10
+ import { PKG_ROOT } from '../util/pkg.js';
11
+ export const ADMIN_PATH_UNIT = '/etc/systemd/system/dsh-model-admin.path';
12
+ export const ADMIN_SERVICE_UNIT = '/etc/systemd/system/dsh-model-admin.service';
13
+ export const adminDir = (ctx) => join(ctx.paths.home, 'admin');
14
+ export const adminRequestPath = (ctx) => join(adminDir(ctx), 'request');
15
+ export async function adminInstalled() {
16
+ return exists(ADMIN_PATH_UNIT);
17
+ }
18
+ /** setup / repair(root)时装上;幂等 */
19
+ export async function installAdmin(ctx) {
20
+ const dir = adminDir(ctx);
21
+ await mkdir(dir, { recursive: true, mode: 0o700 });
22
+ if (ctx.owner)
23
+ await chown(dir, ctx.owner.uid, ctx.owner.gid);
24
+ const path = [
25
+ '# Generated by dsh-model. 由 dsh-model 生成:dsh 管理页的「更新 / 卸载」请求',
26
+ '[Unit]',
27
+ 'Description=dsh-model admin requests from the dsh page',
28
+ '',
29
+ '[Path]',
30
+ `PathExists=${adminRequestPath(ctx)}`,
31
+ 'Unit=dsh-model-admin.service',
32
+ '',
33
+ '[Install]',
34
+ 'WantedBy=multi-user.target',
35
+ '',
36
+ ].join('\n');
37
+ const service = [
38
+ '# Generated by dsh-model. 由 dsh-model 生成:以 root 执行管理页的请求(只认 update / uninstall)',
39
+ '[Unit]',
40
+ 'Description=dsh-model admin (update / uninstall from the dsh page)',
41
+ '',
42
+ '[Service]',
43
+ 'Type=oneshot',
44
+ `ExecStart=${process.execPath} ${join(PKG_ROOT, 'bin', 'dsh-model.js')} admin-run`,
45
+ `Environment=DSH_MODEL_HOME=${ctx.paths.home}`,
46
+ // 系统服务默认没有 HOME,npm 要用它放缓存
47
+ 'Environment=HOME=/root',
48
+ '',
49
+ ].join('\n');
50
+ const before = (await readFile(ADMIN_PATH_UNIT, 'utf8').catch(() => '')) + (await readFile(ADMIN_SERVICE_UNIT, 'utf8').catch(() => ''));
51
+ if (before === path + service)
52
+ return;
53
+ await atomicWrite(ADMIN_PATH_UNIT, path, { mode: 0o644 });
54
+ await atomicWrite(ADMIN_SERVICE_UNIT, service, { mode: 0o644 });
55
+ await run('systemctl', ['daemon-reload']);
56
+ await run('systemctl', ['enable', '--now', 'dsh-model-admin.path']);
57
+ }
58
+ export async function removeAdmin() {
59
+ if (!(await exists(ADMIN_PATH_UNIT)) && !(await exists(ADMIN_SERVICE_UNIT)))
60
+ return;
61
+ await run('systemctl', ['disable', '--now', 'dsh-model-admin.path']);
62
+ await rm(ADMIN_PATH_UNIT, { force: true });
63
+ await rm(ADMIN_SERVICE_UNIT, { force: true });
64
+ await run('systemctl', ['daemon-reload']);
65
+ }
66
+ /** 读并删掉请求单(先删,免得 path 单元反复触发);不合规的返回 null */
67
+ export async function takeAdminRequest(ctx) {
68
+ const file = adminRequestPath(ctx);
69
+ let raw = '';
70
+ try {
71
+ raw = await readFile(file, 'utf8');
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ await rm(file, { force: true });
77
+ try {
78
+ const r = JSON.parse(raw);
79
+ if (r.action !== 'update' && r.action !== 'uninstall')
80
+ return null;
81
+ return { action: r.action, keepAuth: r.keepAuth === true, lang: r.lang === 'en' ? 'en' : 'zh' };
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model",
3
- "version": "0.15.0",
3
+ "version": "0.16.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/client.js CHANGED
@@ -565,10 +565,21 @@ window.__ModuleLoader__.load({
565
565
  const [err, setErr] = useState('')
566
566
  useEffect(() => {
567
567
  let alive = true
568
- api('GET', '/self').then((r) => alive && setInfo(r), () => {})
568
+ // 服务比页面旧(没有 /self):也要给出办法,不能整块不显示
569
+ api('GET', '/self').then((r) => alive && setInfo(r), (e) => alive && setInfo({ old: e.code === 'not_found' }))
569
570
  return () => { alive = false }
570
571
  }, [])
571
572
  if (!info) return null
573
+ if (!info.version) {
574
+ const cmd = 'npm install -g dsh-model && dsh-model setup'
575
+ return h('div', { style: S.section },
576
+ h('div', { style: S.h }, L('关于', 'About')),
577
+ h('div', { style: { ...S.card, padding: '12px 14px' } },
578
+ h('div', { style: S.meta }, info.old
579
+ ? L('这台机器上的 dsh-model 服务比这个页面旧,不支持在页面上更新。在终端执行一次(服务器上加 sudo),之后就能在这里更新和卸载:', 'The dsh-model service on this machine is older than this page and cannot update from here. Run this once in a terminal (with sudo on a server); after that you can update and uninstall here:')
580
+ : L('暂时查不到版本信息。可以在终端执行(服务器上加 sudo):', 'Version info is unavailable right now. You can run in a terminal (with sudo on a server):')),
581
+ h('div', { style: { ...S.line, minHeight: 30, marginTop: 6 } }, h('code', { style: { ...S.mono, ...S.grow } }, cmd), h(CopyButton, { get: cmd }))))
582
+ }
572
583
  const hasUpdate = info.latest && newer(info.latest, info.version)
573
584
  const start = async (action) => {
574
585
  setErr('')