dsh-model 0.20.0 → 0.21.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.
@@ -1,4 +1,4 @@
1
- // checkin/index.ts — 每日签到(设计 §14.15):WorkBuddy 国内版、Qoder;以后加 Trae、WPS 灵犀
1
+ // checkin/index.ts — 每日签到(设计 §14.15):WorkBuddy 国内版、Qoder、Trae;以后加 WPS 灵犀
2
2
  //
3
3
  // 「今天」按北京时间算:这几家都在北京时间零点换天(VPS 的系统时间是 UTC)。
4
4
  // 自动签到默认打开:守护进程每 15 分钟看一眼,过了当天随机的几分钟后签一次;失败的隔一小时再试;错过了下次开机补。
@@ -22,6 +22,8 @@ export function todayResults(file, now = new Date()) {
22
22
  /** 失败原因 → 该怎么办 */
23
23
  export function checkinAdvice(id, message) {
24
24
  if (/无效|过期|401|403|unauthor|expired/i.test(message)) {
25
+ if (id === 'trae')
26
+ return L('Trae 登录过期:打开 Trae 重新登录一次', 'Trae sign-in expired: open Trae and sign in again');
25
27
  return id === 'qoder' ? L('Qoder 个人访问令牌失效:在签到卡片里重新填一个', 'The Qoder access token no longer works: enter a new one in the check-in card') : L('WorkBuddy 登录过期:在来源里重新登录 WorkBuddy', 'WorkBuddy sign-in expired: sign in to WorkBuddy again under Sources');
26
28
  }
27
29
  if (/timeout|timed out|fetch failed|network|ECONN/i.test(message))
@@ -0,0 +1,147 @@
1
+ // checkin/trae.ts — Trae(国内版 / SOLO)每日签到
2
+ //
3
+ // 登录信息复用本机已登录的 Trae 桌面 App 或 CLI(只读,不落盘,只在内存里用):
4
+ // 1) 桌面 App:<应用数据>/{TRAE SOLO CN | Trae CN | Trae | TRAE SOLO}/User/globalStorage/storage.json
5
+ // 里的 iCubeAuthInfo://icube.cloudide(加密信封)
6
+ // 2) CLI:~/.trae-cn/trae-jwt-token、~/.trae/trae-jwt-token
7
+ // 解密算法、接口与请求头照 G57651/dsh-dual-checkin 的 trae 适配器(MIT),它又照 dsh-connect-trae 的 decrypt.ts。
8
+ // 2026-10-14 在 Trae CN 3.4.2 上核对:存储位置和格式没变。
9
+ // POST https://api.trae.cn/trae/api/v2/ug/checkin_credits/status 今天签没签、能领多少
10
+ // POST https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim 签到
11
+ import { createDecipheriv, createHash, randomUUID } from 'node:crypto';
12
+ import { readFile } from 'node:fs/promises';
13
+ import { homedir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ const API = 'https://api.trae.cn';
16
+ const AUTH_KEY = 'iCubeAuthInfo://icube.cloudide';
17
+ const DEVICE_PREFIX = 'iCubeAuthInfo://icube-dc:';
18
+ const APPS = ['TRAE SOLO CN', 'Trae CN', 'Trae', 'TRAE SOLO'];
19
+ const SALT_A = Uint8Array.from([82, 9, 106, 213, 48, 54, 165, 56, 191, 64, 163, 158, 129, 243, 215, 251, 124, 227, 57, 130, 155, 47, 255, 135, 52, 142, 67, 68, 196, 222, 233, 203, 84, 123, 148, 50, 166, 194, 35, 61, 238, 76, 149, 11, 66, 250, 195, 78, 8, 46, 161, 102, 40, 217, 36, 178, 118, 91, 162, 73, 109, 139, 209, 37]);
20
+ const SALT_B = Uint8Array.from([31, 221, 168, 51, 136, 7, 199, 49, 177, 18, 16, 89, 39, 128, 236, 95, 96, 81, 127, 169, 25, 181, 74, 13, 45, 229, 122, 159, 147, 201, 156, 239, 160, 224, 59, 77, 174, 42, 245, 176, 200, 235, 187, 60, 131, 83, 153, 97, 23, 43, 4, 126, 186, 119, 214, 38, 225, 105, 20, 99, 85, 33, 12, 125]);
21
+ const SALT_C = Uint8Array.from([191, 192, 216, 250, 122, 246, 220, 97, 31, 254, 98, 27, 8, 72, 71, 176, 135, 99, 96, 18, 127, 101, 203, 104, 211, 102, 191, 125, 37, 72, 150, 156, 51, 229, 121, 35, 17, 153, 141, 177, 110, 131, 150, 128, 172, 255, 254, 6, 18, 140, 55, 62, 236, 249, 135, 64, 135, 12, 117, 4, 89, 149, 168, 209]);
22
+ const SALT_D = Uint8Array.from([246, 204, 26, 232, 232, 70, 129, 109, 223, 146, 169, 242, 23, 241, 105, 145, 50, 196, 165, 42, 254, 120, 3, 54, 244, 207, 209, 85, 53, 6, 138, 106, 175, 148, 31, 204, 186, 186, 165, 182, 87, 142, 49, 10, 39, 110, 26, 154, 86, 56, 173, 125, 18, 64, 198, 225, 99, 99, 83, 82, 191, 134, 76, 170]);
23
+ const xor = (a, b) => Buffer.from(a.map((v, i) => v ^ (b[i] ?? 0)));
24
+ /** 解开 storage.json 里的加密信封。导出给测试 */
25
+ export function decryptStorageValue(encoded) {
26
+ const buf = Buffer.from(encoded, 'base64');
27
+ if (buf.length <= 102)
28
+ throw new Error('Trae 登录信息太短');
29
+ const header = buf.subarray(0, 6);
30
+ const kind = header.equals(Buffer.from([116, 99, 5, 16, 0, 0])) ? 'aes' : header.equals(Buffer.from([18, 57, 32, 32, 2, 3])) ? 'aes-private' : null;
31
+ if (!kind)
32
+ throw new Error('Trae 登录信息的加密格式不认识(Trae 可能又改版了)');
33
+ const salt = kind === 'aes-private' ? xor(SALT_C, SALT_D) : xor(SALT_A, SALT_B);
34
+ const first = createHash('sha512').update(buf.subarray(6, 38)).digest();
35
+ const derived = createHash('sha512').update(Buffer.concat([first, salt])).digest();
36
+ const decipher = createDecipheriv('aes-128-cbc', derived.subarray(0, 16), derived.subarray(16, 32));
37
+ const out = Buffer.concat([decipher.update(buf.subarray(38)), decipher.final()]);
38
+ if (out.length < 64)
39
+ throw new Error('Trae 登录信息解开后太短');
40
+ const plain = out.subarray(64);
41
+ if (!out.subarray(0, 64).equals(createHash('sha512').update(plain).digest()))
42
+ throw new Error('Trae 登录信息校验不通过');
43
+ return plain.toString('utf8');
44
+ }
45
+ /** 各平台的应用数据目录 */
46
+ function appDataRoots(home, platform = process.platform, env = process.env) {
47
+ if (platform === 'darwin')
48
+ return [join(home, 'Library', 'Application Support')];
49
+ if (platform === 'win32')
50
+ return [...new Set([env.APPDATA, join(home, 'AppData', 'Roaming')].filter((v) => Boolean(v)))];
51
+ return [env.XDG_CONFIG_HOME || join(home, '.config')];
52
+ }
53
+ /** 找本机的 Trae 登录:先桌面 App,再 CLI。找不到返回 null(不报错:没装 Trae 就不显示) */
54
+ export async function findTraeCredential(home = homedir()) {
55
+ // 测试环境不看本机真装了什么(与不发现桌面 App 同一个开关)
56
+ if (process.env.DSH_MODEL_NO_APP_DISCOVERY && home === homedir())
57
+ return null;
58
+ for (const root of appDataRoots(home)) {
59
+ for (const app of APPS) {
60
+ const file = join(root, app, 'User', 'globalStorage', 'storage.json');
61
+ let storage;
62
+ try {
63
+ storage = JSON.parse(await readFile(file, 'utf8'));
64
+ }
65
+ catch {
66
+ continue;
67
+ }
68
+ const raw = storage[AUTH_KEY];
69
+ if (typeof raw !== 'string' || !raw.trim())
70
+ continue;
71
+ const text = raw.trim().startsWith('{') ? raw : decryptStorageValue(raw);
72
+ const auth = JSON.parse(text);
73
+ const token = auth.token || auth.accessToken;
74
+ if (!token)
75
+ continue;
76
+ const dc = Object.keys(storage).find((k) => k.startsWith(DEVICE_PREFIX));
77
+ return { token, deviceId: dc ? dc.slice(DEVICE_PREFIX.length) : String(storage['telemetry.devDeviceId'] ?? ''), source: app };
78
+ }
79
+ }
80
+ for (const dir of ['.trae-cn', '.trae']) {
81
+ try {
82
+ const text = (await readFile(join(home, dir, 'trae-jwt-token'), 'utf8')).trim();
83
+ const token = text.startsWith('{') ? String(JSON.parse(text).token ?? '') : text;
84
+ if (token.split('.').length === 3)
85
+ return { token, deviceId: '', source: `~/${dir}` };
86
+ }
87
+ catch {
88
+ // 没有
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+ /** 令牌还剩几天(读 JWT 的 exp);读不出来返回 null */
94
+ export function tokenDaysLeft(token, now = Date.now()) {
95
+ try {
96
+ const exp = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString()).exp;
97
+ return typeof exp === 'number' ? Math.floor((exp * 1000 - now) / 86_400_000) : null;
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ function headers(c) {
104
+ return {
105
+ authorization: `Cloud-IDE-JWT ${c.token}`,
106
+ 'content-type': 'application/json',
107
+ 'user-agent': 'VSCode 1.107.1 (TRAE SOLO CN)',
108
+ 'x-user-region': 'CN',
109
+ 'package-type': 'stable_cn',
110
+ 'x-lscbd-aid': '787976',
111
+ 'x-lscbd-platform': 'windows',
112
+ 'app-version': '0.1.51',
113
+ accept: '*/*',
114
+ 'x-request-id': randomUUID().replace(/-/g, ''),
115
+ 'x-device-brand': 'To be filled by O.E.M.',
116
+ 'x-device-type': 'windows',
117
+ 'x-lgw-req-sdk-type': '3',
118
+ ...(c.deviceId ? { 'x-device-id': c.deviceId } : {}),
119
+ };
120
+ }
121
+ async function post(c, path) {
122
+ const res = await fetch(`${API}${path}`, { method: 'POST', headers: headers(c), body: '{}', signal: AbortSignal.timeout(20_000) });
123
+ const text = await res.text();
124
+ if (res.status === 401 || res.status === 403)
125
+ throw new Error(`Trae 登录无效或已过期(HTTP ${res.status})`);
126
+ if (!res.ok)
127
+ throw new Error(`Trae HTTP ${res.status}: ${text.slice(0, 160)}`);
128
+ return text ? JSON.parse(text) : {};
129
+ }
130
+ /** 今天签没签、签到送多少 */
131
+ export async function traeStatus(c) {
132
+ const r = await post(c, '/trae/api/v2/ug/checkin_credits/status');
133
+ if (r.code && r.code !== 0)
134
+ throw new Error(`Trae 查询签到状态失败(code ${String(r.code)}:${String(r.message ?? '')})${/1001|authent/i.test(String(r.message ?? r.code)) ? ',令牌可能过期,请重新登录 Trae' : ''}`);
135
+ const d = (typeof r.data === 'object' && r.data !== null ? r.data : r);
136
+ const credits = (typeof d.credits === 'number' ? d.credits : 0) + (typeof d.extra_credits === 'number' ? d.extra_credits : 0);
137
+ return { checkedIn: d.checked_in === true, ...(credits ? { credits } : {}) };
138
+ }
139
+ export async function traeCheckin(c) {
140
+ const st = await traeStatus(c);
141
+ if (st.checkedIn)
142
+ return { state: 'already' };
143
+ const r = await post(c, '/trae/api/v2/ug/checkin_credits/claim');
144
+ if (r.code !== undefined && r.code !== 0)
145
+ throw new Error(`Trae 签到失败(code ${String(r.code)}:${String(r.message ?? '')})`);
146
+ return { state: 'signed', ...(st.credits ? { credit: st.credits } : {}) };
147
+ }
@@ -5,7 +5,7 @@
5
5
  // 3. 用新版本执行一次 repair:重写服务文件、重启引擎与守护进程、同步模型。
6
6
  import { spawn } from 'node:child_process';
7
7
  import { closeSync, openSync } from 'node:fs';
8
- import { chown, realpath } from 'node:fs/promises';
8
+ import { chown, realpath, writeFile } from 'node:fs/promises';
9
9
  import { delimiter, dirname, join } from 'node:path';
10
10
  import { requireRootInVps } from '../context.js';
11
11
  import { DshModelError, isDshModelError } from '../errors.js';
@@ -120,8 +120,12 @@ export async function selfUpdate(ctx, opts = {}) {
120
120
  }
121
121
  /** 管理页请求的日志:更新写在 home 里;卸载会删 home,写在它外面 */
122
122
  export function selfJobLog(ctx, action) {
123
- return action === 'uninstall' ? join(dirname(ctx.paths.home), 'dsh-model-uninstall.log') : join(ctx.paths.home, 'update.log');
123
+ if (action === 'uninstall')
124
+ return join(dirname(ctx.paths.home), 'dsh-model-uninstall.log');
125
+ return join(ctx.paths.home, action === 'repair' ? 'repair.log' : 'update.log');
124
126
  }
127
+ /** admin-run 跑完在日志最后写的一行:页面据此知道结束了、成没成 */
128
+ export const ADMIN_DONE_RE = /^\[dsh-model\] exit (-?\d+)$/m;
125
129
  /** 由 dsh-model-admin.service(root)执行:取出管理页的请求单,只做 update / uninstall */
126
130
  export async function adminRun(ctx) {
127
131
  if (!ctx.isRoot)
@@ -138,7 +142,8 @@ export async function adminRun(ctx) {
138
142
  const out = openSync(log, 'w', 0o600);
139
143
  if (ctx.owner)
140
144
  await chown(log, ctx.owner.uid, ctx.owner.gid).catch(() => { });
141
- const args = req.action === 'update' ? ['update'] : ['uninstall', '--yes', '--remove-plugin', ...(req.keepAuth ? ['--keep-auth'] : [])];
145
+ // repair 就是重跑 setup(幂等,什么缺了补什么;比 repair 命令能修的多)
146
+ const args = req.action === 'update' ? ['update'] : req.action === 'repair' ? ['setup', '--skip-dsh-plugin'] : ['uninstall', '--yes', '--remove-plugin', ...(req.keepAuth ? ['--keep-auth'] : [])];
142
147
  const code = await new Promise((resolve) => {
143
148
  const child = spawn(process.execPath, [join(PKG_ROOT, 'bin', 'dsh-model.js'), ...args, '--lang', req.lang ?? 'zh'], {
144
149
  stdio: ['ignore', out, out],
@@ -149,5 +154,6 @@ export async function adminRun(ctx) {
149
154
  child.on('exit', (c) => resolve(c ?? -1));
150
155
  });
151
156
  closeSync(out);
157
+ await writeFile(log, `\n[dsh-model] exit ${code}\n`, { flag: 'a' }).catch(() => { });
152
158
  return code;
153
159
  }
@@ -57,10 +57,15 @@ export async function uninstall(ctx, opts) {
57
57
  if (all.state.remote && all.state.remote.mode !== 'off')
58
58
  await disableRemote(ctx, all);
59
59
  });
60
- await step('dsh-plugin', async () => {
60
+ // 移除插件只是尽力而为:dsh 的 pnpm 可能因"新版本要等一天"的规则拒绝改动(2026-10-14 实测,连删除也会被拦)。
61
+ // 这一步失败不该让卸载停在半路:服务、配置照样清掉,home 照样删,插件请用户在 dsh 的插件页手动删。
62
+ try {
61
63
  if (await removeDshPlugin(ctx, all, { force: opts.removePlugin }))
62
64
  ok(L('已从 dsh 移除 dsh-model 插件', 'Removed the dsh-model plugin from dsh'));
63
- });
65
+ }
66
+ catch (error) {
67
+ warn(L(`没能自动从 dsh 移除 dsh-model 插件(${error.message}):请在 dsh 的插件页里手动删掉它`, `Could not remove the dsh-model plugin from dsh automatically (${error.message}); remove it on the dsh Plugins page`));
68
+ }
64
69
  await step('workbuddy', async () => {
65
70
  if (await removeWorkbuddyPlugin(ctx, all))
66
71
  ok(L('已移除上一版安装的 WorkBuddy 插件', 'Removed the WorkBuddy plugin installed by a previous version'));
@@ -18,6 +18,7 @@ import { spawnDetached } from '../util/detach.js';
18
18
  import { classify, recentFailures } from './failures.js';
19
19
  import { autoDue, beijingNow, checkinAdvice, ensureJitter, loadCheckin, runCheckin, setAuto, todayResults } from '../checkin/index.js';
20
20
  import { QODER_REGIONS, exchangePat, qoderCheckin } from '../checkin/qoder.js';
21
+ import { findTraeCredential, traeCheckin } from '../checkin/trae.js';
21
22
  import { PKG_ROOT, pkgVersion } from '../util/pkg.js';
22
23
  import { randomBytes } from 'node:crypto';
23
24
  import { currentBinary } from '../engine/install.js';
@@ -725,6 +726,10 @@ export class Daemon {
725
726
  const q = (await loadSecrets(this.ctx)).checkin?.qoder;
726
727
  if (q?.pat)
727
728
  out.push({ id: 'qoder', label: QODER_REGIONS[q.region].label, run: () => qoderCheckin(q.pat, q.region) });
729
+ // Trae:本机装了并登录过 Trae 才有(读它自己的登录信息,不需要另外设置;VPS 上一般没有)
730
+ const trae = await findTraeCredential().catch(() => null);
731
+ if (trae)
732
+ out.push({ id: 'trae', label: 'Trae', run: async () => traeCheckin((await findTraeCredential()) ?? trae) });
728
733
  return out;
729
734
  }
730
735
  async checkinStatus() {
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // 守护进程以 dsh 用户运行,而更新(npm 全局目录、系统服务)和卸载(Caddy、防火墙、系统服务)要 root。
4
4
  // 所以 setup(root)装一个 systemd path 单元:守护进程在 <home>/admin/request 写一张请求单,
5
- // systemd 以 root 运行 `dsh-model admin-run`,它只认三件固定的事:更新到 npm 最新版、卸载、重启 dsh(插件更新后要重启才生效)。别的一律拒绝。
5
+ // systemd 以 root 运行 `dsh-model admin-run`,它只认四件固定的事:更新到 npm 最新版、卸载、重启 dsh(插件更新后要重启才生效)、修复(重跑 setup)。别的一律拒绝。
6
6
  import { chown, mkdir, readFile, rm } from 'node:fs/promises';
7
7
  import { join } from 'node:path';
8
8
  import { run } from '../util/exec.js';
@@ -76,7 +76,7 @@ export async function takeAdminRequest(ctx) {
76
76
  await rm(file, { force: true });
77
77
  try {
78
78
  const r = JSON.parse(raw);
79
- if (r.action !== 'update' && r.action !== 'uninstall' && r.action !== 'restart-dsh')
79
+ if (r.action !== 'update' && r.action !== 'uninstall' && r.action !== 'restart-dsh' && r.action !== 'repair')
80
80
  return null;
81
81
  return { action: r.action, keepAuth: r.keepAuth === true, lang: r.lang === 'en' ? 'en' : 'zh' };
82
82
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model",
3
- "version": "0.20.0",
3
+ "version": "0.21.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
@@ -614,7 +614,8 @@ window.__ModuleLoader__.load({
614
614
  }
615
615
  }
616
616
  const cmds = notInstalled ? ['npm install -g dsh-model', 'dsh-model setup'] : ['dsh-model repair']
617
- const vpsCmds = notInstalled ? ['sudo npm install -g dsh-model', 'sudo dsh-model setup'] : ['sudo dsh-model repair']
617
+ // setup 什么缺了补什么(卸载到一半、服务被删了也能救回来),比 repair 稳
618
+ const vpsCmds = notInstalled ? ['sudo npm install -g dsh-model', 'sudo dsh-model setup'] : ['sudo dsh-model setup']
618
619
  const cmdBlock = (list) => h('div', { style: { ...S.card, padding: '8px 12px', marginTop: 8 } }, list.map((c) => h('div', { key: c, style: { ...S.line, minHeight: 30 } }, h('code', { style: { ...S.mono, ...S.grow } }, c), h(CopyButton, { get: c }))))
619
620
  return h('div', { style: { color: C.text, maxWidth: 820 } },
620
621
  h('div', { style: S.section },
@@ -623,7 +624,7 @@ window.__ModuleLoader__.load({
623
624
  ? L('这个页面只是管理界面;模型由 dsh-model 在本机运行的服务提供(统一端点、WorkBuddy bridge、登录与用量)。装好后,来源、key 和模型都在这里管理。', 'This page is only the control panel; models are served by dsh-model\'s local service (the unified endpoint, WorkBuddy bridge, sign-ins and usage). Once it is set up, manage sources, keys and models here.')
624
625
  : L('可能是电脑重启后服务没起来,或者刚升级。修复会按记录重新注册并启动服务。', 'The service may not have started after a reboot, or was just upgraded. Repair re-registers and starts it from the saved records.')),
625
626
  res?.vps ? h('div', null,
626
- h('p', { style: { ...S.note, color: C.text } }, L('这是 dsh-vps 服务器:安装要 root 权限,请在服务器终端里执行:', 'This is a dsh-vps server: setup needs root, so run this in a server terminal:')),
627
+ h('p', { style: { ...S.note, color: C.text } }, L('这是 dsh-vps 服务器:这一步要 root 权限,而这台机器上还没有 dsh-model 的管理员服务。请在服务器终端里执行一次(之后页面上的按钮就能用):', 'This is a dsh-vps server: this needs root, and dsh-model\'s admin service is not on this machine yet. Run this once in a server terminal (the page buttons work afterwards):')),
627
628
  cmdBlock(vpsCmds),
628
629
  h('p', { style: { ...S.note, marginTop: 8 } }, L('装好后刷新本页。', 'Reload this page when done.'))) : h('div', null,
629
630
  h('div', { style: S.line },
@@ -852,7 +853,9 @@ window.__ModuleLoader__.load({
852
853
  try { await api('POST', '/checkin/qoder', { pat: pat.trim(), region }); onDone() } catch (e) { setMsg(e.message); setBusy(false) }
853
854
  }
854
855
  return h(Modal, { title: L('添加 Qoder 签到', 'Add Qoder check-in'), onClose },
855
- h('p', { style: S.note }, L('在 Qoder 的账号设置里创建一个个人访问令牌(Personal Access Token),粘贴到这里。只用来每天领签到积分,保存在本机。', 'Create a Personal Access Token in your Qoder account settings and paste it here. It is only used to claim the daily credits, and stays on this machine.')),
856
+ h('p', { style: S.note },
857
+ L('登录 Qoder 后,在「账号 → 集成」里创建一个个人访问令牌(Personal Access Token,以 pt- 开头,只在创建时显示一次),粘贴到这里。只用来每天领签到积分,保存在本机。', 'Sign in to Qoder and create a Personal Access Token under Account → Integrations (it starts with pt- and is shown only once), then paste it here. It is only used to claim the daily credits, and stays on this machine.'),
858
+ h('a', { href: region === 'cn' ? 'https://qoder.com.cn/account/integrations' : 'https://qoder.com/account/integrations', target: '_blank', rel: 'noopener noreferrer', style: { color: C.accent, marginLeft: 6 } }, region === 'cn' ? L('去 Qoder 国内版创建 ↗', 'Create on Qoder China ↗') : L('去 Qoder 国际版创建 ↗', 'Create on Qoder ↗'))),
856
859
  h('div', { style: { ...S.line, marginBottom: 8 } },
857
860
  ['cn', 'intl'].map((r) => h('label', { key: r, style: { ...S.meta, display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' } },
858
861
  h('input', { type: 'radio', name: 'qoder-region', checked: region === r, onChange: () => setRegion(r) }), r === 'cn' ? L('国内版', 'China') : L('国际版', 'International')))),
package/plugin/index.js CHANGED
@@ -238,7 +238,19 @@ export function apply(ctx) {
238
238
  } catch {
239
239
  return json(res, 400, { error: { code: 'invalid_json', message: 'invalid JSON' } })
240
240
  }
241
- if (await exists(VPS_ROOT)) return json(res, 200, { vps: true })
241
+ if (await exists(VPS_ROOT)) {
242
+ // dsh-vps:安装要 root。管理员小服务还在(setup 装的)就交给它跑 setup,页面照样看进度;不在就只能给命令
243
+ if (!(await exists(ADMIN_PATH_UNIT))) return json(res, 200, { vps: true })
244
+ const log = join(home(), 'repair.log')
245
+ if (body.poll) {
246
+ const text = await readFile(log, 'utf8').catch(() => '')
247
+ const m = /^\[dsh-model\] exit (-?\d+)$/m.exec(text)
248
+ return json(res, 200, { running: !m, output: text.replace(/\n?\[dsh-model\] exit -?\d+\n?$/, '').replace(/\x1b\[[0-9;]*m/g, ''), code: m ? Number(m[1]) : null, action: 'repair' })
249
+ }
250
+ await writeFile(log, '', { mode: 0o600 }).catch(() => {})
251
+ await writeFile(join(home(), 'admin', 'request'), JSON.stringify({ action: 'repair', lang: body.lang === 'en' ? 'en' : 'zh' }), { mode: 0o600 })
252
+ return json(res, 200, { running: true, output: '', code: null, action: 'repair' })
253
+ }
242
254
  // { poll: true }:只取进度;否则开始(已经在跑就接着看同一个)
243
255
  if (body.poll) return json(res, 200, job ? { running: job.running, output: job.output, code: job.code, action: job.action } : { running: false, output: '', code: null })
244
256
  const action = body.action === 'repair' ? 'repair' : 'setup'