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.
Files changed (83) hide show
  1. package/README.en.md +127 -0
  2. package/README.md +96 -43
  3. package/bin/dsh-model.js +8 -0
  4. package/dist/bridge/aggregate.js +91 -0
  5. package/dist/bridge/login.js +127 -0
  6. package/dist/bridge/runtime.js +136 -0
  7. package/dist/bridge/server.js +183 -0
  8. package/dist/bridge/workbuddy/_compat.js +52 -0
  9. package/dist/bridge/workbuddy/app-version.js +141 -0
  10. package/dist/bridge/workbuddy/auth.js +548 -0
  11. package/dist/bridge/workbuddy/catalog.js +154 -0
  12. package/dist/bridge/workbuddy/client-identity.js +234 -0
  13. package/dist/bridge/workbuddy/desktop-credential-protection.js +992 -0
  14. package/dist/bridge/workbuddy/loopback.js +52 -0
  15. package/dist/bridge/workbuddy/probe.js +153 -0
  16. package/dist/bridge/workbuddy/status-paths.js +49 -0
  17. package/dist/bridge/workbuddy/upstream.js +1180 -0
  18. package/dist/bridge/workbuddy/variants.js +101 -0
  19. package/dist/cli.js +215 -0
  20. package/dist/commands/bridge.js +68 -0
  21. package/dist/commands/connect.js +67 -0
  22. package/dist/commands/engine.js +122 -0
  23. package/dist/commands/key.js +75 -0
  24. package/dist/commands/login.js +113 -0
  25. package/dist/commands/logs.js +19 -0
  26. package/dist/commands/opencode.js +83 -0
  27. package/dist/commands/remote.js +129 -0
  28. package/dist/commands/self.js +116 -0
  29. package/dist/commands/service.js +72 -0
  30. package/dist/commands/setup.js +219 -0
  31. package/dist/commands/sources.js +196 -0
  32. package/dist/commands/status.js +216 -0
  33. package/dist/commands/uninstall.js +97 -0
  34. package/dist/commands/workbuddy.js +117 -0
  35. package/dist/context.js +86 -0
  36. package/dist/daemon/client.js +27 -0
  37. package/dist/daemon/core.js +850 -0
  38. package/dist/daemon/service.js +33 -0
  39. package/dist/daemon/stats.js +126 -0
  40. package/dist/daemon/usage.js +213 -0
  41. package/dist/dsh/cli.js +90 -0
  42. package/dist/dsh/connect.js +257 -0
  43. package/dist/dsh/credentials.js +55 -0
  44. package/dist/dsh/locate.js +129 -0
  45. package/dist/dsh/patch.js +106 -0
  46. package/dist/dsh/pick.js +59 -0
  47. package/dist/engine/auth.js +40 -0
  48. package/dist/engine/client.js +104 -0
  49. package/dist/engine/compat.js +94 -0
  50. package/dist/engine/config.js +47 -0
  51. package/dist/engine/install.js +118 -0
  52. package/dist/engine/mgmt.js +99 -0
  53. package/dist/engine/sourcemap.js +48 -0
  54. package/dist/errors.js +14 -0
  55. package/dist/i18n.js +39 -0
  56. package/dist/integrations/dshplugin.js +89 -0
  57. package/dist/integrations/opencode.js +101 -0
  58. package/dist/integrations/workbuddy.js +114 -0
  59. package/dist/keys.js +54 -0
  60. package/dist/ops.js +178 -0
  61. package/dist/remote/public.js +109 -0
  62. package/dist/secrets.js +13 -0
  63. package/dist/service/index.js +48 -0
  64. package/dist/service/launchd.js +89 -0
  65. package/dist/service/systemd.js +83 -0
  66. package/dist/sources.js +36 -0
  67. package/dist/state.js +90 -0
  68. package/dist/upstreams.js +23 -0
  69. package/dist/util/diff.js +30 -0
  70. package/dist/util/exec.js +42 -0
  71. package/dist/util/fs.js +107 -0
  72. package/dist/util/output.js +50 -0
  73. package/dist/util/pkg.js +13 -0
  74. package/dist/util/port.js +16 -0
  75. package/dist/util/prompt.js +40 -0
  76. package/dist/util/proxy.js +73 -0
  77. package/dist/util/redact.js +7 -0
  78. package/engine-manifest.json +28 -0
  79. package/package.json +77 -7
  80. package/plugin/client.js +688 -0
  81. package/plugin/cordis.patch.yml +4 -0
  82. package/plugin/index.js +231 -0
  83. package/docs/DESIGN.md +0 -610
@@ -0,0 +1,89 @@
1
+ // service/launchd.ts — macOS 用户级 LaunchAgent(RunAtLoad + KeepAlive)
2
+ import { rm } from 'node:fs/promises';
3
+ import { DshModelError } from '../errors.js';
4
+ import { L } from '../i18n.js';
5
+ import { run } from '../util/exec.js';
6
+ import { atomicWrite, exists } from '../util/fs.js';
7
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
8
+ export function renderPlist(spec) {
9
+ const args = spec.program.map((a) => ` <string>${esc(a)}</string>`).join('\n');
10
+ const envEntries = Object.entries(spec.env ?? {});
11
+ const envBlock = envEntries.length
12
+ ? ` <key>EnvironmentVariables</key>\n <dict>\n${envEntries.map(([k, v]) => ` <key>${esc(k)}</key>\n <string>${esc(v)}</string>`).join('\n')}\n </dict>\n`
13
+ : '';
14
+ return `<?xml version="1.0" encoding="UTF-8"?>
15
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
16
+ <!-- Generated by dsh-model. 由 dsh-model 生成,卸载请执行 dsh-model uninstall -->
17
+ <plist version="1.0">
18
+ <dict>
19
+ <key>Label</key>
20
+ <string>${esc(spec.label)}</string>
21
+ <key>ProgramArguments</key>
22
+ <array>
23
+ ${args}
24
+ </array>
25
+ ${envBlock} <key>WorkingDirectory</key>
26
+ <string>${esc(spec.workingDirectory)}</string>
27
+ <key>RunAtLoad</key>
28
+ <true/>
29
+ <key>KeepAlive</key>
30
+ <true/>
31
+ <key>ThrottleInterval</key>
32
+ <integer>10</integer>
33
+ <key>StandardOutPath</key>
34
+ <string>${esc(spec.stdoutPath)}</string>
35
+ <key>StandardErrorPath</key>
36
+ <string>${esc(spec.stdoutPath)}</string>
37
+ </dict>
38
+ </plist>
39
+ `;
40
+ }
41
+ export function launchd(spec) {
42
+ const domain = `gui/${process.getuid?.() ?? 501}`;
43
+ const target = `${domain}/${spec.label}`;
44
+ const bootstrap = async () => {
45
+ const r = await run('launchctl', ['bootstrap', domain, spec.file]);
46
+ // 已加载时返回 5 / 37("service already loaded"),当成功
47
+ if (r.code !== 0 && !/already|in progress|5:|37:/i.test(r.stderr)) {
48
+ throw new DshModelError('service_failed', L(`launchctl bootstrap 失败:${r.stderr.trim()}`, `launchctl bootstrap failed: ${r.stderr.trim()}`));
49
+ }
50
+ };
51
+ const bootout = async () => {
52
+ await run('launchctl', ['bootout', target]);
53
+ };
54
+ return {
55
+ kind: 'launchd',
56
+ spec,
57
+ render: () => renderPlist(spec),
58
+ async install() {
59
+ await atomicWrite(spec.file, renderPlist(spec), { mode: 0o644 });
60
+ await bootout();
61
+ await bootstrap();
62
+ },
63
+ async start() {
64
+ await bootstrap();
65
+ await run('launchctl', ['kickstart', target]);
66
+ },
67
+ async stop() {
68
+ await bootout();
69
+ },
70
+ async restart() {
71
+ const r = await run('launchctl', ['kickstart', '-k', target]);
72
+ if (r.code !== 0)
73
+ await bootstrap();
74
+ },
75
+ async status() {
76
+ const installed = await exists(spec.file);
77
+ const r = await run('launchctl', ['print', target]);
78
+ if (r.code !== 0)
79
+ return { installed, running: false, detail: installed ? 'not loaded' : 'not installed' };
80
+ const pid = Number(r.stdout.match(/\bpid = (\d+)/)?.[1]) || undefined;
81
+ const state = r.stdout.match(/\bstate = (\w+)/)?.[1];
82
+ return { installed, running: state === 'running', pid, detail: state };
83
+ },
84
+ async uninstall() {
85
+ await bootout();
86
+ await rm(spec.file, { force: true });
87
+ },
88
+ };
89
+ }
@@ -0,0 +1,83 @@
1
+ // service/systemd.ts — systemd 用户级(local/Linux)与系统级(vps,User=dsh)
2
+ import { rm } from 'node:fs/promises';
3
+ import { userInfo } from 'node:os';
4
+ import { DshModelError } from '../errors.js';
5
+ import { L } from '../i18n.js';
6
+ import { run } from '../util/exec.js';
7
+ import { atomicWrite, exists } from '../util/fs.js';
8
+ import { warn } from '../util/output.js';
9
+ const quote = (a) => (/[\s"\\]/.test(a) ? `"${a.replace(/(["\\])/g, '\\$1')}"` : a);
10
+ export function renderUnit(spec, system) {
11
+ const lines = [
12
+ '# Generated by dsh-model. 由 dsh-model 生成,卸载请执行 dsh-model uninstall',
13
+ '[Unit]',
14
+ 'Description=dsh-model engine (CLIProxyAPI)',
15
+ 'After=network-online.target',
16
+ 'Wants=network-online.target',
17
+ '',
18
+ '[Service]',
19
+ 'Type=simple',
20
+ `ExecStart=${spec.program.map(quote).join(' ')}`,
21
+ `WorkingDirectory=${spec.workingDirectory}`,
22
+ 'Restart=on-failure',
23
+ 'RestartSec=5',
24
+ `StandardOutput=append:${spec.stdoutPath}`,
25
+ `StandardError=append:${spec.stdoutPath}`,
26
+ ...Object.entries(spec.env ?? {}).map(([k, v]) => `Environment=${quote(`${k}=${v}`)}`),
27
+ ];
28
+ if (system) {
29
+ lines.push(`User=${spec.user}`, `Group=${spec.user}`, 'NoNewPrivileges=yes', 'PrivateTmp=yes', 'ProtectSystem=strict', `ReadWritePaths=${spec.home}`);
30
+ }
31
+ lines.push('', '[Install]', `WantedBy=${system ? 'multi-user.target' : 'default.target'}`, '');
32
+ return lines.join('\n');
33
+ }
34
+ export function systemd(kind, spec) {
35
+ const system = kind === 'systemd-system';
36
+ const sc = (...args) => run('systemctl', system ? args : ['--user', ...args]);
37
+ const must = async (...args) => {
38
+ const r = await sc(...args);
39
+ if (r.code !== 0)
40
+ throw new DshModelError('service_failed', L(`systemctl ${args.join(' ')} 失败:${r.stderr.trim()}`, `systemctl ${args.join(' ')} failed: ${r.stderr.trim()}`));
41
+ };
42
+ return {
43
+ kind,
44
+ spec,
45
+ render: () => renderUnit(spec, system),
46
+ async install() {
47
+ await atomicWrite(spec.file, renderUnit(spec, system), { mode: 0o644 });
48
+ await must('daemon-reload');
49
+ await must('enable', '--now', spec.label);
50
+ if (!system)
51
+ await checkLinger();
52
+ },
53
+ async start() {
54
+ await must('start', spec.label);
55
+ },
56
+ async stop() {
57
+ await sc('stop', spec.label);
58
+ },
59
+ async restart() {
60
+ await must('restart', spec.label);
61
+ },
62
+ async status() {
63
+ const installed = await exists(spec.file);
64
+ const r = await sc('show', spec.label, '-p', 'ActiveState', '-p', 'MainPID');
65
+ const state = r.stdout.match(/ActiveState=(\w+)/)?.[1];
66
+ const pid = Number(r.stdout.match(/MainPID=(\d+)/)?.[1]) || undefined;
67
+ return { installed, running: state === 'active', pid, detail: state };
68
+ },
69
+ async uninstall() {
70
+ await sc('disable', '--now', spec.label);
71
+ await rm(spec.file, { force: true });
72
+ await sc('daemon-reload');
73
+ },
74
+ };
75
+ }
76
+ /** 用户级服务要开 linger 才能在退出登录后继续跑 */
77
+ async function checkLinger() {
78
+ const user = userInfo().username;
79
+ const r = await run('loginctl', ['show-user', user, '-p', 'Linger']);
80
+ if (r.code === 0 && /Linger=no/.test(r.stdout)) {
81
+ warn(L(`用户 ${user} 没开 linger:退出登录后引擎会停止。需要的话请执行 sudo loginctl enable-linger ${user}`, `Linger is off for ${user}: the engine stops when you log out. If needed run: sudo loginctl enable-linger ${user}`));
82
+ }
83
+ }
@@ -0,0 +1,36 @@
1
+ // sources.ts — 可接入的来源(设计 §14.2)。界面上每个来源一个开关。
2
+ //
3
+ // kind:engine = 引擎原生 OAuth;workbuddy = 我们自己的 bridge;opencode = key。
4
+ // login:device = 链接 + 码(自动轮询);paste = 授权后把跳转地址贴回来;link = 链接(自动轮询);key = 粘贴 key。
5
+ // 登录方式对照引擎管理接口 v8.0.13 实测:codex / claude 在管理接口里是回调式(需要贴回地址),kimi / xai / meta 是 device。
6
+ export const SOURCES = [
7
+ { id: 'workbuddy', label: 'WorkBuddy', kind: 'workbuddy', login: 'link', variant: 'workbuddy', pricing: { tier: 'free', zh: '账号自带免费积分(剩余见用量)', en: 'Free credits on the account (see usage)' } },
8
+ { id: 'workbuddy-ai', label: 'WorkBuddy AI', kind: 'workbuddy', login: 'link', variant: 'workbuddy-ai', pricing: { tier: 'free', zh: '账号自带免费积分(剩余见用量)', en: 'Free credits on the account (see usage)' } },
9
+ { id: 'codex', label: 'Codex (ChatGPT)', kind: 'engine', login: 'device', deviceCliFlag: '-codex-device-login', engineProvider: 'codex', filePrefix: 'codex-', subscribeUrl: 'https://chatgpt.com/pricing', pricing: { tier: 'paid', zh: '需要 ChatGPT Plus / Pro 订阅', en: 'Needs a ChatGPT Plus / Pro subscription' } },
10
+ { id: 'claude', label: 'Claude', kind: 'engine', login: 'paste', engineProvider: 'claude', filePrefix: 'claude-', riskAck: true, risky: true, subscribeUrl: 'https://claude.com/pricing', pricing: { tier: 'paid', zh: '需要 Claude Pro / Max 订阅', en: 'Needs a Claude Pro / Max subscription' } },
11
+ { id: 'kimi', label: 'Kimi', kind: 'engine', login: 'device', engineProvider: 'kimi', filePrefix: 'kimi-', subscribeUrl: 'https://www.kimi.com/code/#pricing', pricing: { tier: 'paid', zh: '需要 Kimi Code 套餐(普通 Kimi 账号调用会被拒)', en: 'Needs a Kimi Code plan (a regular Kimi account is refused)' } },
12
+ { id: 'xai', label: 'Grok (xAI)', kind: 'engine', login: 'device', engineProvider: 'xai', filePrefix: 'xai-', subscribeUrl: 'https://grok.com/plans', pricing: { tier: 'paid', zh: '需要 SuperGrok 或 X Premium+ 订阅', en: 'Needs SuperGrok or X Premium+' } },
13
+ { id: 'meta', label: 'Muse (Meta)', kind: 'engine', login: 'device', engineProvider: 'meta', filePrefix: 'meta-', pricing: { tier: 'paid', zh: '需要 Muse Code 套餐($5 / 月起),meta.ai 的免费版接不进来', en: 'Needs a Muse Code plan (from $5/mo); the free meta.ai tier cannot be used here' } },
14
+ { id: 'antigravity', label: 'Antigravity', kind: 'engine', login: 'paste', engineProvider: 'antigravity', filePrefix: 'antigravity-', riskAck: true, risky: true, pricing: { tier: 'limited', zh: 'Google 账号有免费额度,额度有限', en: 'Free quota with a Google account, limited' } },
15
+ { id: 'devin', label: 'Devin', kind: 'engine', login: 'paste', engineProvider: 'devin', filePrefix: 'devin-', subscribeUrl: 'https://devin.ai/pricing', probeModel: 'devin/swe-1-7-lightning', pricing: { tier: 'paid', zh: '需要 Devin Pro($20 / 月);免费档实测调用被拒(insufficient_quota)', en: 'Needs Devin Pro ($20/mo); the free tier is refused in testing (insufficient_quota)' } },
16
+ { id: 'opencode', label: 'OpenCode Zen', kind: 'opencode', login: 'key', subscribeUrl: 'https://opencode.ai/zen', pricing: { tier: 'paid', zh: '要先充值拿 key 按量计费;它的免费模型只能在 OpenCode 软件里用', en: 'Top up for a pay-as-you-go key; its free models only work inside the OpenCode app' } },
17
+ ];
18
+ const ALIASES = { grok: 'xai', muse: 'meta', chatgpt: 'codex', openai: 'codex', 'workbuddy-cn': 'workbuddy', wb: 'workbuddy', 'wb-ai': 'workbuddy-ai' };
19
+ export function findSource(id) {
20
+ const k = ALIASES[id.toLowerCase()] ?? id.toLowerCase();
21
+ return SOURCES.find((s) => s.id === k);
22
+ }
23
+ /** 凭据属于哪个来源:优先看 provider / type 字段,否则看文件名前缀 */
24
+ export function credsFor(creds, def) {
25
+ return creds.filter((c) => {
26
+ const p = String(c.provider ?? c.type ?? '').toLowerCase();
27
+ if (p)
28
+ return p === def.engineProvider;
29
+ return Boolean(def.filePrefix && c.name.startsWith(def.filePrefix));
30
+ });
31
+ }
32
+ /** 引擎因 403 payment_required 把这个凭据冷却了(实测:Kimi 没有 Kimi Code 订阅时如此)*/
33
+ export function paymentRequired(cred) {
34
+ const cooldowns = cred.cooldowns;
35
+ return Array.isArray(cooldowns) && cooldowns.some((c) => c?.reason === 'payment_required');
36
+ }
package/dist/state.js ADDED
@@ -0,0 +1,90 @@
1
+ // state.ts — config.json(用户意图)、state.json(安装台账)、.lock(命令互斥)
2
+ //
3
+ // state.json 是卸载与 repair 的唯一依据:dsh-model 写到 home 之外的每个文件都登记在这里。
4
+ import { open, readFile, rm } from 'node:fs/promises';
5
+ import { DshModelError } from './errors.js';
6
+ import { L } from './i18n.js';
7
+ import { ensureDir, readJson, writeJson } from './util/fs.js';
8
+ export const DEFAULT_PORT = 8317;
9
+ export const PROVIDER_ID = 'dsh-model';
10
+ export function defaultConfig() {
11
+ return {
12
+ port: DEFAULT_PORT,
13
+ engine: { version: null },
14
+ dsh: { profile: null, providerId: PROVIDER_ID },
15
+ upstreams: {},
16
+ remote: { mode: 'off' },
17
+ };
18
+ }
19
+ export async function loadConfig(ctx) {
20
+ const stored = await readJson(ctx.paths.config);
21
+ const base = defaultConfig();
22
+ return {
23
+ ...base,
24
+ ...stored,
25
+ engine: { ...base.engine, ...stored?.engine },
26
+ dsh: { ...base.dsh, ...stored?.dsh },
27
+ upstreams: { ...stored?.upstreams },
28
+ remote: { ...base.remote, ...stored?.remote },
29
+ };
30
+ }
31
+ export async function saveConfig(ctx, config) {
32
+ await ensureHome(ctx);
33
+ await writeJson(ctx.paths.config, config, { owner: ctx.owner });
34
+ }
35
+ export async function loadState(ctx) {
36
+ const stored = await readJson(ctx.paths.state);
37
+ const state = { ...stored, engine: { versions: [], ...stored?.engine } };
38
+ // 0.1.0 的台账没有所有权字段:那时只会写 dsh-model 端点与 DSH_MODEL_API_KEY
39
+ if (state.dsh && !state.dsh.ownedProviders)
40
+ state.dsh.ownedProviders = [PROVIDER_ID];
41
+ if (state.dsh && !state.dsh.ownedRefs)
42
+ state.dsh.ownedRefs = ['DSH_MODEL_API_KEY'];
43
+ return state;
44
+ }
45
+ export async function saveState(ctx, state) {
46
+ await ensureHome(ctx);
47
+ await writeJson(ctx.paths.state, state, { owner: ctx.owner });
48
+ }
49
+ export async function ensureHome(ctx) {
50
+ await ensureDir(ctx.paths.home, { owner: ctx.owner });
51
+ }
52
+ /**
53
+ * 命令互斥:两个 setup 同时跑会互相踩配置。锁文件记 pid,持锁进程已不在就当过期锁清掉。
54
+ */
55
+ export async function withLock(ctx, fn) {
56
+ await ensureHome(ctx);
57
+ const acquire = async () => {
58
+ const handle = await open(ctx.paths.lock, 'wx', 0o600);
59
+ await handle.writeFile(String(process.pid));
60
+ await handle.close();
61
+ };
62
+ try {
63
+ await acquire();
64
+ }
65
+ catch (error) {
66
+ if (error.code !== 'EEXIST')
67
+ throw error;
68
+ const pid = Number((await readFile(ctx.paths.lock, 'utf8').catch(() => '')).trim());
69
+ if (pid && pid !== process.pid && isAlive(pid)) {
70
+ throw new DshModelError('locked', L(`另一个 dsh-model 命令正在运行(pid ${pid}),等它结束再试`, `Another dsh-model command is running (pid ${pid}); try again when it finishes`), L('如果是终端里的命令停在等你输入,回到终端完成它', 'If a command in a terminal is waiting for your input, finish it there'));
71
+ }
72
+ await rm(ctx.paths.lock, { force: true });
73
+ await acquire();
74
+ }
75
+ try {
76
+ return await fn();
77
+ }
78
+ finally {
79
+ await rm(ctx.paths.lock, { force: true });
80
+ }
81
+ }
82
+ function isAlive(pid) {
83
+ try {
84
+ process.kill(pid, 0);
85
+ return true;
86
+ }
87
+ catch (error) {
88
+ return error.code === 'EPERM';
89
+ }
90
+ }
@@ -0,0 +1,23 @@
1
+ // upstreams.ts — 上游与引擎登录参数的映射(对照 CLIProxyAPI v8.0.13 -h 实测)
2
+ import { DshModelError } from './errors.js';
3
+ import { L } from './i18n.js';
4
+ export const UPSTREAMS = [
5
+ { id: 'codex', label: 'Codex (ChatGPT)', flag: '-codex-login', deviceFlag: '-codex-device-login', callbackPort: 1455, filePrefix: 'codex-' },
6
+ { id: 'kimi', label: 'Kimi', flag: '-kimi-login', filePrefix: 'kimi-' },
7
+ { id: 'xai', label: 'Grok (xAI)', flag: '-xai-login', filePrefix: 'xai-' },
8
+ { id: 'meta', label: 'Muse (Meta)', flag: '-meta-login', filePrefix: 'meta-' },
9
+ { id: 'claude', label: 'Claude (Pro/Max)', flag: '-claude-login', callbackPort: 54545, filePrefix: 'claude-', requiresRiskAck: true },
10
+ { id: 'antigravity', label: 'Antigravity', flag: '-antigravity-login', callbackPort: 51121, filePrefix: 'antigravity-', requiresRiskAck: true },
11
+ ];
12
+ const ALIASES = { grok: 'xai', muse: 'meta', chatgpt: 'codex', openai: 'codex' };
13
+ export function getUpstream(id) {
14
+ const key = ALIASES[id.toLowerCase()] ?? id.toLowerCase();
15
+ const def = UPSTREAMS.find((u) => u.id === key);
16
+ if (!def) {
17
+ throw new DshModelError('unknown_upstream', L(`不认识的上游:${id}`, `Unknown upstream: ${id}`), L(`可用:${UPSTREAMS.map((u) => u.id).join('、')}`, `Available: ${UPSTREAMS.map((u) => u.id).join(', ')}`));
18
+ }
19
+ return def;
20
+ }
21
+ export function riskNotice(def) {
22
+ return L(`${def.label} 的服务条款限定订阅凭据只能在其官方客户端中使用。通过第三方工具调用可能违反条款,账号存在被限制或封禁的风险。dsh-model 仅供个人自用,风险由你自行承担。\n确认后请加 --accept-risk 重新执行:dsh-model login ${def.id} --accept-risk`, `${def.label}'s terms limit subscription credentials to its official clients. Using them through a third-party tool may violate those terms and risks account restriction or suspension. dsh-model is for personal use only; you accept this risk yourself.\nTo proceed, re-run with --accept-risk: dsh-model login ${def.id} --accept-risk`);
23
+ }
@@ -0,0 +1,30 @@
1
+ // util/diff.ts — 给 --dry-run 用的简单行 diff(LCS)
2
+ export function lineDiff(before, after, label) {
3
+ const a = before.split('\n');
4
+ const b = after.split('\n');
5
+ const n = a.length;
6
+ const m = b.length;
7
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
8
+ for (let i = n - 1; i >= 0; i--)
9
+ for (let j = m - 1; j >= 0; j--)
10
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
11
+ const out = [`--- ${label}`, `+++ ${label}`];
12
+ let i = 0;
13
+ let j = 0;
14
+ while (i < n || j < m) {
15
+ if (i < n && j < m && a[i] === b[j]) {
16
+ out.push(` ${a[i]}`);
17
+ i++;
18
+ j++;
19
+ }
20
+ else if (j < m && (i >= n || dp[i][j + 1] >= dp[i + 1][j])) {
21
+ out.push(`+ ${b[j]}`);
22
+ j++;
23
+ }
24
+ else {
25
+ out.push(`- ${a[i]}`);
26
+ i++;
27
+ }
28
+ }
29
+ return out.join('\n');
30
+ }
@@ -0,0 +1,42 @@
1
+ // util/exec.ts — 子进程
2
+ import { spawn } from 'node:child_process';
3
+ /** 跑一个命令并收集输出;不抛错,调用方看 code */
4
+ export function run(cmd, args, options = {}) {
5
+ return new Promise((resolve) => {
6
+ // 不需要输入时 stdin 直接 ignore;需要时子进程可能不读就退出(或命令不存在),写入会 EPIPE,必须吞掉(服务器上实测会崩)
7
+ 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'] });
9
+ let stdout = '';
10
+ let stderr = '';
11
+ const timer = options.timeoutMs ? setTimeout(() => child.kill('SIGKILL'), options.timeoutMs) : null;
12
+ child.stdout?.on('data', (d) => (stdout += d));
13
+ child.stderr?.on('data', (d) => (stderr += d));
14
+ child.on('error', (error) => {
15
+ if (timer)
16
+ clearTimeout(timer);
17
+ resolve({ code: 127, stdout, stderr: stderr + String(error.message) });
18
+ });
19
+ child.on('close', (code) => {
20
+ if (timer)
21
+ clearTimeout(timer);
22
+ resolve({ code: code ?? 1, stdout, stderr });
23
+ });
24
+ if (hasInput && child.stdin) {
25
+ child.stdin.on('error', () => { });
26
+ child.stdin.end(options.input);
27
+ }
28
+ });
29
+ }
30
+ /** 交互式运行:stdio 直接交给用户(登录流程要用户看 URL、粘贴回调) */
31
+ export function runInherit(cmd, args, options = {}) {
32
+ return new Promise((resolve) => {
33
+ const child = spawn(cmd, args, { cwd: options.cwd, env: options.env ?? process.env, stdio: 'inherit' });
34
+ child.on('error', () => resolve(127));
35
+ child.on('close', (code) => resolve(code ?? 1));
36
+ });
37
+ }
38
+ /** 在 PATH 里找命令 */
39
+ export async function which(cmd) {
40
+ const r = await run('sh', ['-c', `command -v ${cmd}`]);
41
+ return r.code === 0 ? r.stdout.trim() || null : null;
42
+ }
@@ -0,0 +1,107 @@
1
+ // util/fs.ts — 原子写、哈希、属主
2
+ //
3
+ // 所有落盘都走 atomicWrite:先写同目录临时文件再 rename,中途断电也不会留下半个文件。
4
+ // vps 模式下 dsh-model 以 root 运行,但文件要归 dsh 用户,所以写入时可带 owner。
5
+ import { createHash, randomBytes } from 'node:crypto';
6
+ import { chown, chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
7
+ import { dirname, join } from 'node:path';
8
+ export async function atomicWrite(file, data, options = {}) {
9
+ const mode = options.mode ?? 0o600;
10
+ await mkdir(dirname(file), { recursive: true });
11
+ const tmp = join(dirname(file), `.${randomBytes(6).toString('hex')}.tmp`);
12
+ try {
13
+ await writeFile(tmp, data, { mode });
14
+ await chmod(tmp, mode); // umask 可能削掉 writeFile 的 mode
15
+ if (options.owner)
16
+ await chown(tmp, options.owner.uid, options.owner.gid);
17
+ await rename(tmp, file);
18
+ }
19
+ catch (error) {
20
+ await rm(tmp, { force: true });
21
+ throw error;
22
+ }
23
+ }
24
+ /**
25
+ * 原地覆盖写(保留 inode):给"被别的进程用 fsnotify 盯着的文件"用。
26
+ * atomicWrite 的 rename 会换掉 inode,Linux 上盯着旧 inode 的监听就再也收不到变化(引擎热重载实测失效)。
27
+ * 文件不存在时退回 atomicWrite。代价是极短时间内可能读到半个文件——引擎会按 SHA 去抖并在解析失败时保留旧配置。
28
+ */
29
+ export async function writeInPlace(file, data, options = {}) {
30
+ const mode = options.mode ?? 0o600;
31
+ try {
32
+ await stat(file);
33
+ }
34
+ catch (error) {
35
+ if (isNotFound(error))
36
+ return atomicWrite(file, data, options);
37
+ throw error;
38
+ }
39
+ await writeFile(file, data);
40
+ await chmod(file, mode);
41
+ if (options.owner)
42
+ await chown(file, options.owner.uid, options.owner.gid);
43
+ }
44
+ export async function ensureDir(dir, options = {}) {
45
+ await mkdir(dir, { recursive: true, mode: options.mode ?? 0o700 });
46
+ await chmod(dir, options.mode ?? 0o700);
47
+ if (options.owner)
48
+ await chown(dir, options.owner.uid, options.owner.gid);
49
+ }
50
+ export function sha256(data) {
51
+ return createHash('sha256').update(data).digest('hex');
52
+ }
53
+ export async function sha256File(file) {
54
+ try {
55
+ return sha256(await readFile(file));
56
+ }
57
+ catch (error) {
58
+ if (isNotFound(error))
59
+ return null;
60
+ throw error;
61
+ }
62
+ }
63
+ export async function readText(file) {
64
+ try {
65
+ return await readFile(file, 'utf8');
66
+ }
67
+ catch (error) {
68
+ if (isNotFound(error))
69
+ return null;
70
+ throw error;
71
+ }
72
+ }
73
+ export async function readJson(file) {
74
+ const text = await readText(file);
75
+ return text == null ? null : JSON.parse(text);
76
+ }
77
+ export async function writeJson(file, value, options = {}) {
78
+ await atomicWrite(file, JSON.stringify(value, null, 2) + '\n', options);
79
+ }
80
+ export async function exists(path) {
81
+ try {
82
+ await stat(path);
83
+ return true;
84
+ }
85
+ catch (error) {
86
+ if (isNotFound(error))
87
+ return false;
88
+ throw error;
89
+ }
90
+ }
91
+ /** 文件权限位(如 0o600);不存在返回 null */
92
+ export async function fileMode(path) {
93
+ try {
94
+ return (await stat(path)).mode & 0o777;
95
+ }
96
+ catch (error) {
97
+ if (isNotFound(error))
98
+ return null;
99
+ throw error;
100
+ }
101
+ }
102
+ export function isNotFound(error) {
103
+ return error?.code === 'ENOENT';
104
+ }
105
+ export function timestamp(date = new Date()) {
106
+ return date.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
107
+ }
@@ -0,0 +1,50 @@
1
+ // util/output.ts — 终端输出。--json 时人读的输出全部静默,最后由命令打印一个 JSON
2
+ let jsonMode = false;
3
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
4
+ export function setJsonMode(on) {
5
+ jsonMode = on;
6
+ }
7
+ export function isJsonMode() {
8
+ return jsonMode;
9
+ }
10
+ const paint = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
11
+ export const green = (s) => paint('32', s);
12
+ export const yellow = (s) => paint('33', s);
13
+ export const red = (s) => paint('31', s);
14
+ export const dim = (s) => paint('2', s);
15
+ export const bold = (s) => paint('1', s);
16
+ export function info(msg) {
17
+ if (!jsonMode)
18
+ console.log(msg);
19
+ }
20
+ export function ok(msg) {
21
+ if (!jsonMode)
22
+ console.log(`${green('✓')} ${msg}`);
23
+ }
24
+ export function skip(msg) {
25
+ if (!jsonMode)
26
+ console.log(`${dim('·')} ${dim(msg)}`);
27
+ }
28
+ export function warn(msg) {
29
+ if (!jsonMode)
30
+ console.error(`${yellow('!')} ${msg}`);
31
+ }
32
+ export function fail(msg) {
33
+ console.error(`${red('✗')} ${msg}`);
34
+ }
35
+ export function next(msg) {
36
+ if (!jsonMode)
37
+ console.log(`${bold('→')} ${msg}`);
38
+ }
39
+ export function printJson(value) {
40
+ console.log(JSON.stringify(value, null, 2));
41
+ }
42
+ // 东亚宽字符(CJK、全角符号)占两列;✓ ✗ 等符号在终端里是单列
43
+ const WIDE = /[\u1100-\u115f\u2e80-\u303e\u3041-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/;
44
+ /** 简单对齐表格(按显示宽度处理 CJK) */
45
+ export function table(rows) {
46
+ const width = (s) => [...s.replace(/\x1b\[[0-9;]*m/g, '')].reduce((n, ch) => n + (WIDE.test(ch) ? 2 : 1), 0);
47
+ const cols = Math.max(...rows.map((r) => r.length));
48
+ const w = Array.from({ length: cols }, (_, i) => Math.max(...rows.map((r) => width(r[i] ?? ''))));
49
+ return rows.map((r) => r.map((c, i) => (i === r.length - 1 ? c : c + ' '.repeat((w[i] ?? 0) - width(c)))).join(' ')).join('\n');
50
+ }
@@ -0,0 +1,13 @@
1
+ // util/pkg.ts — 包根目录(dist/util/pkg.js 或 src/util/pkg.ts 往上两级)
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ export const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
6
+ export function pkgVersion() {
7
+ try {
8
+ return JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8')).version;
9
+ }
10
+ catch {
11
+ return '0.0.0';
12
+ }
13
+ }
@@ -0,0 +1,16 @@
1
+ // util/port.ts — 端口占用检测
2
+ import { createServer } from 'node:net';
3
+ export function isPortFree(port, host = '127.0.0.1') {
4
+ return new Promise((resolve) => {
5
+ const server = createServer();
6
+ server.once('error', () => resolve(false));
7
+ server.listen(port, host, () => server.close(() => resolve(true)));
8
+ });
9
+ }
10
+ /** 从 start 起找第一个空闲端口(最多试 50 个) */
11
+ export async function findFreePort(start, host = '127.0.0.1') {
12
+ for (let p = start; p < start + 50; p++)
13
+ if (await isPortFree(p, host))
14
+ return p;
15
+ throw new Error(`no free port in ${start}..${start + 49}`);
16
+ }
@@ -0,0 +1,40 @@
1
+ // util/prompt.ts — 交互输入。secret 不回显(key 不进终端回滚、不进 shell 历史)
2
+ import { createInterface } from 'node:readline';
3
+ export function canPrompt() {
4
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
5
+ }
6
+ export function promptLine(question) {
7
+ return new Promise((resolve) => {
8
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
9
+ rl.question(question, (answer) => {
10
+ rl.close();
11
+ resolve(answer.trim());
12
+ });
13
+ });
14
+ }
15
+ export function promptSecret(question) {
16
+ return new Promise((resolve) => {
17
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
18
+ const out = rl;
19
+ let asked = false;
20
+ out._writeToOutput = (s) => {
21
+ // 只把提问本身写出去,之后的按键一律不回显
22
+ if (!asked) {
23
+ out.output.write(s);
24
+ asked = true;
25
+ }
26
+ };
27
+ rl.question(question, (answer) => {
28
+ rl.close();
29
+ process.stdout.write('\n');
30
+ resolve(answer.trim());
31
+ });
32
+ });
33
+ }
34
+ /** 非交互:从管道读完整 stdin(dsh-model opencode key --stdin) */
35
+ export async function readStdin() {
36
+ let data = '';
37
+ for await (const chunk of process.stdin)
38
+ data += chunk;
39
+ return data.trim();
40
+ }