dsh-crossterm 0.3.34 → 0.3.36

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.md CHANGED
@@ -226,6 +226,24 @@ dsh plugin --profile desktop remove dsh-crossterm
226
226
  | `DSH_CROSSTERM_NAME` | 本终端的显示名(多开时区分) |
227
227
  | `DSH_CROSSTERM_AUTODELIVER` | `0/false/off/no` = 只进收件箱不自动注入;`1/true/on/yes` = 强制自动注入 |
228
228
 
229
+ ### 一键配置云盘(0.3.35,挂记事本)
230
+
231
+ 跨机器互通有两条路:共享目录(把 `busDir` 指到 `\\nas\dsh-bus` 之类,最简单),或走自己的网盘云端目录。走网盘时**账号由你自己配**,插件只认本机这两个文件:
232
+
233
+ <DSH_HOME>\crossterm\baidu-app.json { appKey, appSecret, redirectUri }
234
+ <DSH_HOME>\crossterm\baidu-auth.json { appKey, accessToken, refreshToken, expiresAt, ... }
235
+
236
+ 一键流程(密钥不经过聊天,只在记事本里输入):
237
+
238
+ 1. 对助手说「配置云盘」,或直接调 `term_cloud action="setup"`;插件页卡片在「未连接」状态也有 **配置云盘** 按钮。
239
+ 2. 插件写出模板并用**记事本**打开 `baidu-app.json` —— 把开放平台应用的 AppKey / AppSecret 填进引号。
240
+ `redirectUri` 默认 `http://127.0.0.1:19388/oauth/callback`,必须和开放平台「回调地址」完全一致。
241
+ 3. 保存并关闭记事本 → 插件校验 → 起本地回调 → 自动打开浏览器授权页 → 拿到 code 换 token → 写 `baidu-auth.json` → 打开 `cloudEnabled` 并启动同步。
242
+ 4. 浏览器没能回调时:`term_cloud action="setup" code="<授权 URL 里的 code>"` 手动收口;失败输出里也会带一条手动授权链接。
243
+ 5. 重复执行是安全的:已授权且 appKey 没变时直接返回 `step: "already"`,不会再弹记事本。
244
+
245
+ > 插件、npm 包、B 机安装包**都不携带任何凭证**;换机器要各自配自己的。
246
+
229
247
  ### 手动 / 自动注入开关(三层)
230
248
 
231
249
  | 层 | 开关 | 粒度 |
package/client/client.js CHANGED
@@ -198,6 +198,37 @@ window.__ModuleLoader__.load({
198
198
  }
199
199
  };
200
200
 
201
+ const onSetupCloud = async () => {
202
+ setBusy("setup");
203
+ setMsg("正在打开记事本…把 AppKey / AppSecret 填进引号里,保存并关闭记事本");
204
+ try {
205
+ const data = await call("/crossterm/api/cloud/setup", "POST");
206
+ setMsg((data && data.message) || "已开始配置");
207
+ if (!(data && data.ok)) {
208
+ setBusy("");
209
+ return;
210
+ }
211
+ // 记事本关闭后会打开浏览器授权,这里每 3 秒看一次状态,最多等 2 分钟
212
+ for (let i = 0; i < 40; i += 1) {
213
+ await new Promise((resolve) => setTimeout(resolve, 3000));
214
+ const fresh = await call("/crossterm/api/status");
215
+ if (fresh && fresh.cloud) {
216
+ setStatus(fresh);
217
+ if (fresh.cloud.configured) {
218
+ setMsg("云盘已配置,云同步已开启");
219
+ setBusy("");
220
+ return;
221
+ }
222
+ }
223
+ }
224
+ setMsg("还没等到授权完成:如果记事本或浏览器还开着,按提示填完保存关闭即可,授权结束后同步会自动打开");
225
+ } catch (error) {
226
+ setMsg("配置失败:" + String((error && error.message) || error));
227
+ } finally {
228
+ setBusy("");
229
+ }
230
+ };
231
+
201
232
  const version = status ? "v" + status.version : "";
202
233
  const mode = status && status.install ? status.install.mode : "";
203
234
  const cloud = (status && status.cloud) || {};
@@ -251,7 +282,8 @@ window.__ModuleLoader__.load({
251
282
  : "—",
252
283
  ),
253
284
  status
254
- ? h(
285
+ ? cloud.configured
286
+ ? h(
255
287
  "button",
256
288
  {
257
289
  style: cloud.running ? S.switchOn : S.switchOff,
@@ -260,15 +292,24 @@ window.__ModuleLoader__.load({
260
292
  void onToggleCloud();
261
293
  },
262
294
  disabled: busy !== "",
263
- title: !cloud.configured
264
- ? "还没配置云盘,点击查看说明"
265
- : cloud.running
266
- ? "点击关闭云同步"
267
- : "点击开启云同步",
268
- "aria-label": !cloud.configured ? "云盘未配置" : cloud.running ? "云同步开启中" : "云同步已关闭",
295
+ title: cloud.running ? "点击关闭云同步" : "点击开启云同步",
296
+ "aria-label": cloud.running ? "云同步开启中" : "云同步已关闭",
269
297
  },
270
298
  h("span", { style: cloud.running ? S.knobOn : S.knobOff }),
271
299
  )
300
+ : h(
301
+ "button",
302
+ {
303
+ style: S.btn,
304
+ onClick: (event) => {
305
+ event.stopPropagation();
306
+ void onSetupCloud();
307
+ },
308
+ disabled: busy !== "",
309
+ title: "打开记事本填 AppKey / AppSecret,然后浏览器授权",
310
+ },
311
+ busy === "setup" ? "等待授权…" : "配置云盘",
312
+ )
272
313
  : null,
273
314
  ),
274
315
  ),
package/lib/bus.js CHANGED
@@ -22,7 +22,7 @@ import os from 'node:os'
22
22
  import path from 'node:path'
23
23
  import crypto from 'node:crypto'
24
24
 
25
- export const VERSION = '0.3.34'
25
+ export const VERSION = '0.3.36'
26
26
 
27
27
  /** DSH 的家目录:与宿主其它状态(sessions / profiles)同一个根,多终端天然共享 */
28
28
  export function dshHome() {
@@ -0,0 +1,306 @@
1
+ // 一键配置云盘:模板 → 记事本 → 校验 → 浏览器授权 → 落 token
2
+ //
3
+ // 设计目标(对应 README「云端配置」与 AGENTS 第 9 条待办):
4
+ // 1. 第一次跑:把 baidu-app.json 模板写到数据目录,用记事本打开让用户填 AppKey / AppSecret;
5
+ // 2. 用户保存并关闭记事本后,这里自动校验并起本地回调服务器(默认 127.0.0.1:19388/oauth/callback);
6
+ // 3. 自动打开浏览器授权页,拿到 code 后换 access/refresh token,写 baidu-auth.json;
7
+ // 4. 调用方(term_cloud setup)再把 config.json 的 cloudEnabled 打开。
8
+ // 全程只写本机文件,不上传任何凭证。
9
+ import fs from 'node:fs'
10
+ import http from 'node:http'
11
+ import path from 'node:path'
12
+ import { spawn } from 'node:child_process'
13
+
14
+ const DEFAULT_REDIRECT = 'http://127.0.0.1:19388/oauth/callback'
15
+ const TOKEN_URL = 'https://openapi.baidu.com/oauth/2.0/token'
16
+ const AUTHORIZE_URL = 'https://openapi.baidu.com/oauth/2.0/authorize'
17
+
18
+ const TEMPLATE_HELP = [
19
+ '把百度开放平台应用的 AppKey / AppSecret 填到下面两个字段的引号里,保存后关闭记事本,插件会自动继续。',
20
+ 'redirectUri 必须和开放平台「应用信息 → 回调地址」完全一致;默认 http://127.0.0.1:19388/oauth/callback。',
21
+ '这是你自己的账号:这两个值只会写在本机这个文件里,插件不会把它上传到任何地方,也不会放进安装包。',
22
+ '填好后关闭记事本即可;关掉记事本但没填,重新调 term_cloud setup 会再打开。',
23
+ ]
24
+
25
+ function readJson(file) {
26
+ try {
27
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
28
+ } catch {
29
+ return null
30
+ }
31
+ }
32
+
33
+ function writeJson(file, data) {
34
+ fs.mkdirSync(path.dirname(file), { recursive: true })
35
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf8')
36
+ }
37
+
38
+ /** 模板:空字段 + 说明,用户名下第一眼就能看懂要填什么 */
39
+ function ensureTemplate(appFile) {
40
+ if (fs.existsSync(appFile)) return { created: false }
41
+ writeJson(appFile, {
42
+ _说明: TEMPLATE_HELP,
43
+ appKey: '',
44
+ appSecret: '',
45
+ redirectUri: DEFAULT_REDIRECT,
46
+ })
47
+ return { created: true }
48
+ }
49
+
50
+ /** 校验 app 文件:两个字段都得是填好的真值(模板里的空串/占位符不算) */
51
+ function readApp(appFile) {
52
+ const raw = readJson(appFile)
53
+ if (!raw || typeof raw !== 'object') return { ok: false, detail: `读不出 ${appFile}(不是合法 JSON)` }
54
+ const appKey = String(raw.appKey || '').trim()
55
+ const appSecret = String(raw.appSecret || '').trim()
56
+ const redirectUri = String(raw.redirectUri || '').trim() || DEFAULT_REDIRECT
57
+ const placeholder = (v) => !v || /^(your|填写|appkey|appsecret|xxx|todo)/i.test(v) || v.includes('填')
58
+ if (placeholder(appKey)) return { ok: false, detail: 'appKey 还没填(打开记事本填好再保存)', appFile }
59
+ if (placeholder(appSecret)) return { ok: false, detail: 'appSecret 还没填(打开记事本填好再保存)', appFile }
60
+ let url
61
+ try {
62
+ url = new URL(redirectUri)
63
+ } catch {
64
+ return { ok: false, detail: `redirectUri 不是合法 URL:${redirectUri}` }
65
+ }
66
+ if (url.hostname !== '127.0.0.1' && url.hostname !== 'localhost') {
67
+ return { ok: false, detail: `redirectUri 必须指向本机(当前 ${redirectUri});百度要求回调能落到本机才能自动收 code` }
68
+ }
69
+ return { ok: true, appKey, appSecret, redirectUri: url.toString() }
70
+ }
71
+
72
+ /** 用记事本打开模板文件,等用户关掉(或超时) */
73
+ function openNotepad(file, timeoutMs, log) {
74
+ return new Promise((resolve) => {
75
+ if (process.platform !== 'win32') {
76
+ resolve({ opened: false, detail: '当前不是 Windows,请手动编辑这个文件后重跑:' + file })
77
+ return
78
+ }
79
+ let child
80
+ try {
81
+ child = spawn('notepad.exe', [file], { stdio: 'ignore', windowsHide: false })
82
+ } catch (error) {
83
+ resolve({ opened: false, detail: '打不开记事本:' + String((error && error.message) || error) })
84
+ return
85
+ }
86
+ if (log) log('已用记事本打开 ' + file + ',填好 AppKey/AppSecret 后保存并关闭')
87
+ let done = false
88
+ const finish = (r) => {
89
+ if (done) return
90
+ done = true
91
+ clearTimeout(timer)
92
+ resolve(r)
93
+ }
94
+ const timer = setTimeout(() => finish({ opened: true, closed: false }), Math.max(5000, timeoutMs || 300000))
95
+ child.on('error', (error) => finish({ opened: false, detail: '记事本启动失败:' + String((error && error.message) || error) }))
96
+ child.on('close', () => finish({ opened: true, closed: true }))
97
+ })
98
+ }
99
+
100
+ function openBrowser(url, log) {
101
+ const opts = { stdio: 'ignore', detached: true, windowsHide: true }
102
+ try {
103
+ if (process.platform === 'win32') spawn('cmd', ['/c', 'start', '', url], opts).unref()
104
+ else if (process.platform === 'darwin') spawn('open', [url], opts).unref()
105
+ else spawn('xdg-open', [url], opts).unref()
106
+ } catch (error) {
107
+ if (log) log('打开浏览器失败:' + String((error && error.message) || error) + ',请手动访问授权链接')
108
+ return false
109
+ }
110
+ return true
111
+ }
112
+
113
+ function authorizeUrl(app, state) {
114
+ const params = new URLSearchParams({
115
+ response_type: 'code',
116
+ client_id: app.appKey,
117
+ redirect_uri: app.redirectUri,
118
+ scope: 'basic,netdisk',
119
+ display: 'page',
120
+ state,
121
+ })
122
+ return `${AUTHORIZE_URL}?${params.toString()}`
123
+ }
124
+
125
+ async function exchange(app, code) {
126
+ const body = new URLSearchParams({
127
+ grant_type: 'authorization_code',
128
+ code: String(code).trim(),
129
+ client_id: app.appKey,
130
+ client_secret: app.appSecret,
131
+ redirect_uri: app.redirectUri,
132
+ })
133
+ const res = await fetch(TOKEN_URL, {
134
+ method: 'POST',
135
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
136
+ body,
137
+ })
138
+ const text = await res.text()
139
+ let data
140
+ try {
141
+ data = JSON.parse(text)
142
+ } catch {
143
+ data = { raw: text }
144
+ }
145
+ return { status: res.status, data }
146
+ }
147
+
148
+ function saveAuth(authFile, app, data) {
149
+ const out = {
150
+ appKey: app.appKey,
151
+ accessToken: data.access_token,
152
+ refreshToken: data.refresh_token,
153
+ expiresIn: data.expires_in,
154
+ expiresAt: Date.now() + (Number(data.expires_in) || 0) * 1000,
155
+ scope: data.scope,
156
+ updatedAt: new Date().toISOString(),
157
+ }
158
+ writeJson(authFile, out)
159
+ return out
160
+ }
161
+
162
+ function htmlPage(title, body) {
163
+ return `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font-family:system-ui;padding:40px;line-height:1.6"><h2>${title}</h2>${body}</body>`
164
+ }
165
+
166
+ /** 起本地回调服务器等 code(超时自动关掉,不占端口) */
167
+ function waitForCode(app, { log, timeoutMs }) {
168
+ return new Promise((resolve) => {
169
+ const url = new URL(app.redirectUri)
170
+ const port = Number(url.port || 80)
171
+ const expectPath = url.pathname || '/oauth/callback'
172
+ const state = 'dshcrossterm' + Date.now().toString(36)
173
+ let settled = false
174
+ const finish = (r) => {
175
+ if (settled) return
176
+ settled = true
177
+ clearTimeout(timer)
178
+ try {
179
+ server.close()
180
+ } catch {
181
+ /* ignore */
182
+ }
183
+ resolve(r)
184
+ }
185
+ const server = http.createServer(async (req, res) => {
186
+ let u
187
+ try {
188
+ u = new URL(req.url, `http://${url.host}`)
189
+ } catch {
190
+ res.writeHead(400).end('bad request')
191
+ return
192
+ }
193
+ if (u.pathname !== expectPath) {
194
+ res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' }).end(htmlPage('404', '<p>这不是授权回调地址。</p>'))
195
+ return
196
+ }
197
+ const code = u.searchParams.get('code')
198
+ const err = u.searchParams.get('error')
199
+ if (!code) {
200
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
201
+ res.end(htmlPage('授权失败', `<pre>${u.search.replace(/&/g, '&amp;')}</pre>`))
202
+ finish({ ok: false, detail: '授权回调没有 code:' + (err || u.search) })
203
+ return
204
+ }
205
+ if (log) log('收到授权回调,正在换 token')
206
+ const { status, data } = await exchange(app, code)
207
+ if (data && data.access_token) {
208
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
209
+ res.end(htmlPage('授权成功', '<p>可以关闭这个页面了,回到 DSH 看结果。</p>'))
210
+ finish({ ok: true, data, status })
211
+ } else {
212
+ res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' })
213
+ res.end(htmlPage('换 token 失败', `<pre>${JSON.stringify(data).replace(/</g, '&lt;')}</pre>`))
214
+ finish({ ok: false, detail: `换 token 失败 HTTP ${status}:${JSON.stringify(data)}` })
215
+ }
216
+ })
217
+ server.on('error', (error) => finish({ ok: false, detail: `本地回调服务器起不来(端口 ${port} 被占用?):${String((error && error.message) || error)}` }))
218
+ const timer = setTimeout(() => finish({ ok: false, detail: `等了 ${Math.round((timeoutMs || 180000) / 1000)} 秒没收到授权回调,已放弃(端口 ${port} 已释放)` }), Math.max(30000, timeoutMs || 180000))
219
+ const target = authorizeUrl(app, state)
220
+ // 先记下来:即使端口起不来,调用方也能把链接给用户手动打开
221
+ waitForCode.lastUrl = target
222
+ server.listen(port, url.hostname === 'localhost' ? '127.0.0.1' : url.hostname, () => {
223
+ if (log) log(`回调服务器已起 127.0.0.1:${port}${expectPath},正在打开浏览器授权`)
224
+ openBrowser(target, log)
225
+ })
226
+ })
227
+ }
228
+
229
+ /**
230
+ * 跑整套一键配置。
231
+ * @param {object} cfg readConfig() 的结果(用到 busDir / baiduAppFile / baiduAuthFile)
232
+ * @param {object} opts { code?: 手动授权码, notepad?: 是否开记事本, timeoutMs?, log? }
233
+ */
234
+ export async function runSetup(cfg, opts = {}) {
235
+ const appFile = cfg.baiduAppFile || path.join(cfg.busDir, 'baidu-app.json')
236
+ const authFile = cfg.baiduAuthFile || path.join(cfg.busDir, 'baidu-auth.json')
237
+ const log = typeof opts.log === 'function' ? opts.log : null
238
+ const steps = []
239
+ waitForCode.lastUrl = ''
240
+
241
+ // 0) 已经授权过且 appKey 没变:直接当成功(重复调用不会反复弹记事本)
242
+ const existingAuth = readJson(authFile)
243
+ const existingApp = readJson(appFile)
244
+ if (existingAuth && existingAuth.refreshToken && existingAuth.appKey && existingApp && existingAuth.appKey === String(existingApp.appKey || '').trim() && opts.force !== true) {
245
+ steps.push('已授权(复用现有 token)')
246
+ return { ok: true, step: 'already', appFile, authFile, scope: existingAuth.scope || '', steps }
247
+ }
248
+
249
+ // 1) 模板
250
+ const tpl = ensureTemplate(appFile)
251
+ if (tpl.created) {
252
+ steps.push('已生成模板 ' + appFile)
253
+ if (log) log('已生成云盘配置模板:' + appFile)
254
+ }
255
+
256
+ // 2) 记事本(只有字段没填时才开)
257
+ let app = readApp(appFile)
258
+ if (!app.ok && opts.notepad !== false) {
259
+ const r = await openNotepad(appFile, opts.notepadTimeoutMs || 300000, log)
260
+ if (!r.opened) return { ok: false, step: 'notepad', detail: r.detail, appFile, authFile, steps }
261
+ steps.push(r.closed ? '记事本已关闭' : '记事本仍开着')
262
+ if (!r.closed) {
263
+ return {
264
+ ok: false,
265
+ step: 'notepad-open',
266
+ detail: '记事本还开着:填好 appKey / appSecret 后保存并关闭,再调一次 term_cloud setup 继续',
267
+ appFile,
268
+ authFile,
269
+ steps,
270
+ }
271
+ }
272
+ app = readApp(appFile)
273
+ }
274
+ if (!app.ok) return { ok: false, step: 'validate', detail: app.detail, appFile, authFile, steps }
275
+ steps.push('已读到 appKey ' + app.appKey.slice(0, 4) + '…(' + app.redirectUri + ')')
276
+
277
+ // 3) 授权:手动 code 优先,否则起本地回调
278
+ let data
279
+ if (opts.code) {
280
+ const r = await exchange(app, opts.code)
281
+ if (!r.data || !r.data.access_token) {
282
+ return { ok: false, step: 'exchange', detail: `换 token 失败 HTTP ${r.status}:${JSON.stringify(r.data)}`, appFile, authFile, steps }
283
+ }
284
+ data = r.data
285
+ steps.push('用手动 code 换到 token')
286
+ } else {
287
+ const r = await waitForCode(app, { log, timeoutMs: opts.timeoutMs || 180000 })
288
+ if (!r.ok) {
289
+ return { ok: false, step: 'oauth', detail: r.detail, authorizeUrl: waitForCode.lastUrl || '', appFile, authFile, steps }
290
+ }
291
+ data = r.data
292
+ steps.push('浏览器授权成功')
293
+ }
294
+
295
+ const saved = saveAuth(authFile, app, data)
296
+ steps.push('已写入 ' + authFile)
297
+ return {
298
+ ok: true,
299
+ step: 'done',
300
+ appFile,
301
+ authFile,
302
+ scope: saved.scope || '',
303
+ expiresAt: saved.expiresAt,
304
+ steps,
305
+ }
306
+ }
package/lib/index.js CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  import { deriveNameFromFile, findTranscript } from './names.js'
37
37
  import { createPan } from './cloud/pan.js'
38
38
  import { createCloud } from './cloud/sync.js'
39
+ import { runSetup } from './cloud/setup.js'
39
40
  import { registerWeb } from './web.js'
40
41
 
41
42
  const PLUGIN = 'dsh-crossterm'
@@ -1616,13 +1617,26 @@ function buildTools(term, agent, part = 'all') {
1616
1617
 
1617
1618
  const cloud = defineTool({
1618
1619
  name: 'term_cloud',
1619
- description: '云总线同步:查看/开启/关闭本机总线与云端目录的镜像同步,或立刻同步一次。跨设备互通靠它把邮件、收件箱、链、链接、在线状态搬到云端。',
1620
+ description: '云总线同步:查看/开启/关闭本机总线与云端目录的镜像同步,或立刻同步一次;setup 可一键配置自己的云盘(弹出记事本填 AppKey/AppSecret → 浏览器授权 → 自动开启同步)。',
1620
1621
  parameters: {
1621
- action: { type: 'string', enum: ['status', 'start', 'stop', 'once'], description: 'status=查看状态;start=开启(本进程内生效);stop=关闭;once=立刻同步一次' },
1622
+ action: { type: 'string', enum: ['status', 'start', 'stop', 'once', 'setup'], description: 'status=查看状态;start=开启(本进程内生效);stop=关闭;once=立刻同步一次;setup=一键配置自己的云盘' },
1623
+ code: { type: 'string', description: '仅 setup 用:授权回调 URL 里的 code(浏览器没自动回调时手动粘贴);不填则插件自己起本地回调等待' },
1622
1624
  },
1623
1625
  output: {
1624
1626
  schema: { type: 'json' },
1625
1627
  render: textRender((v) => {
1628
+ if (v && v.setup) {
1629
+ const s = v.setup
1630
+ const lines = []
1631
+ lines.push(s.ok ? `云盘配置:成功${s.step === 'already' ? '(已有授权,直接复用)' : '(已拿到新 token,云同步已开启)'}` : `云盘配置:未完成(${s.step || '未知'}):${s.detail || ''}`)
1632
+ if (s.appFile) lines.push(`应用配置:${s.appFile}`)
1633
+ if (s.authFile) lines.push(`授权文件:${s.authFile}`)
1634
+ if (s.scope) lines.push(`授权范围:${s.scope}`)
1635
+ if (s.expiresAt) lines.push(`到期时间:${new Date(s.expiresAt).toISOString()}`)
1636
+ if (s.authorizeUrl) lines.push(`手动授权链接:${s.authorizeUrl}`)
1637
+ if (Array.isArray(s.steps) && s.steps.length) lines.push(`过程:${s.steps.join(' → ')}`)
1638
+ return lines.join('\n')
1639
+ }
1626
1640
  if (!v || !v.ok) return errorText('云同步失败:')(v)
1627
1641
  const s = v.status || {}
1628
1642
  const lines = [
@@ -1641,6 +1655,17 @@ function buildTools(term, agent, part = 'all') {
1641
1655
  try {
1642
1656
  if (action === 'start') term.enableCloud()
1643
1657
  else if (action === 'stop') term.disableCloud()
1658
+ else if (action === 'setup') {
1659
+ const ret = await runSetup(term.cfg, {
1660
+ code: typeof args?.code === 'string' && args.code.trim() ? args.code.trim() : '',
1661
+ log: (line) => void term.warn(line),
1662
+ })
1663
+ if (!ret.ok) return { ok: false, detail: ret.detail || '云盘配置未完成', setup: ret }
1664
+ await writeConfig({ cloudEnabled: true })
1665
+ term.cfg = readConfig()
1666
+ term.enableCloud()
1667
+ return { ok: true, status: term.cloud ? term.cloud.status() : undefined, setup: ret }
1668
+ }
1644
1669
  else if (action === 'once') {
1645
1670
  const c = term.cloud || term.enableCloud()
1646
1671
  const result = await c.once()
package/lib/web.js CHANGED
@@ -4,12 +4,14 @@ import fs from 'node:fs'
4
4
  import os from 'node:os'
5
5
  import path from 'node:path'
6
6
  import { spawn } from 'node:child_process'
7
- import { VERSION } from './bus.js'
7
+ import { VERSION, readConfig, writeConfig } from './bus.js'
8
+ import { runSetup } from './cloud/setup.js'
8
9
 
9
10
  const PKG = 'dsh-crossterm'
10
11
  const NPM_REGISTRIES = ['https://registry.npmmirror.com', 'https://registry.npmjs.org']
11
12
 
12
13
  let installed = false
14
+ let setupRunning = false
13
15
 
14
16
  function json(res, code, data) {
15
17
  const body = JSON.stringify(data)
@@ -127,9 +129,28 @@ async function latestVersion() {
127
129
  throw new Error(last || 'unknown')
128
130
  }
129
131
 
132
+ // 子进程输出解码:中文 Windows 控制台默认 CP936(GBK),直接当 UTF-8 读会变成 ����。
133
+ // 先按整段 UTF-8 严格解码,失败再退回 GBK;顺带清掉 ANSI 控制符与 \r 进度残留。
134
+ function decodeOutput(buffer) {
135
+ if (!buffer || buffer.length === 0) return ''
136
+ let text
137
+ try {
138
+ text = new TextDecoder('utf-8', { fatal: true }).decode(buffer)
139
+ } catch {
140
+ try {
141
+ text = new TextDecoder('gbk').decode(buffer)
142
+ } catch {
143
+ text = buffer.toString('utf8')
144
+ }
145
+ }
146
+ // eslint-disable-next-line no-control-regex
147
+ return text.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '').replace(/\r/g, '\n')
148
+ }
149
+
130
150
  function run(cmd, args, cwd) {
131
151
  return new Promise((resolve) => {
132
- let out = ''
152
+ const chunks = []
153
+ let size = 0
133
154
  let child
134
155
  try {
135
156
  child = spawn(cmd, args, {
@@ -142,16 +163,34 @@ function run(cmd, args, cwd) {
142
163
  return
143
164
  }
144
165
  const push = (chunk) => {
145
- out += String(chunk)
146
- if (out.length > 20000) out = out.slice(-20000)
166
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8')
167
+ chunks.push(buf)
168
+ size += buf.length
169
+ // 只留最后 64 KB,免得长日志把内存和卡片撑爆
170
+ while (size > 65536 && chunks.length > 1) size -= chunks.shift().length
147
171
  }
148
172
  child.stdout && child.stdout.on('data', push)
149
173
  child.stderr && child.stderr.on('data', push)
150
- child.on('error', (error) => resolve({ code: -1, out: out + String((error && error.message) || error) }))
151
- child.on('close', (code) => resolve({ code, out }))
174
+ child.on('error', (error) =>
175
+ resolve({ code: -1, out: decodeOutput(Buffer.concat(chunks)) + String((error && error.message) || error) }),
176
+ )
177
+ child.on('close', (code) => resolve({ code, out: decodeOutput(Buffer.concat(chunks)) }))
152
178
  })
153
179
  }
154
180
 
181
+ // 更新失败时给人的提示:命令找不到就明说,别把一行乱码甩到卡片上
182
+ function updateErrorText(result) {
183
+ const raw = (result.out || '').trim()
184
+ if (!raw) {
185
+ return `自动更新没跑起来(dsh 退出码 ${result.code}):本机找不到可用的 dsh 命令,请在命令行手动执行 dsh plugin add ${PKG}@latest,然后重启 DSH`
186
+ }
187
+ const tail = raw.slice(-1200)
188
+ if (/not recognized|不是内部或外部命令|无法将.{0,12}项识别|command not found|ENOENT/i.test(raw)) {
189
+ return `${tail}\n\n提示:PATH 里没有 dsh 命令,请在命令行手动执行 dsh plugin add ${PKG}@latest,然后重启 DSH`
190
+ }
191
+ return tail
192
+ }
193
+
155
194
  function statusOf(term) {
156
195
  const cfg = term.cfg || {}
157
196
  const info = profileInfo(cfg.busDir)
@@ -190,6 +229,7 @@ function statusOf(term) {
190
229
  * 只注册一次;行被关闭时随 fiber 一起注销,重新启用会再注册。
191
230
  */
192
231
  export function registerWeb(ctx, term, log) {
232
+ if (typeof log !== 'function') log = () => {}
193
233
  if (installed) return
194
234
  installed = true
195
235
  try {
@@ -258,6 +298,42 @@ export function registerWeb(ctx, term, log) {
258
298
  },
259
299
  }),
260
300
  )
301
+ disposers.push(
302
+ webCtx.webServer.register({
303
+ kind: 'exact',
304
+ path: '/crossterm/api/cloud/setup',
305
+ handler: (req, res) => {
306
+ if (setupRunning) {
307
+ json(res, 200, { ok: false, running: true, message: '配置流程已经在进行中(看任务栏里的记事本)' })
308
+ return
309
+ }
310
+ const cfg = term.cfg
311
+ setupRunning = true
312
+ void (async () => {
313
+ try {
314
+ const r = await runSetup(cfg, { log: (line) => void log(line) })
315
+ if (r.ok) {
316
+ await writeConfig({ cloudEnabled: true })
317
+ term.cfg = readConfig()
318
+ term.enableCloud()
319
+ log(`cloud setup: ${r.step === 'already' ? '已有授权,直接复用' : '授权完成,云同步已开启'}`)
320
+ } else {
321
+ log(`cloud setup: 停在 ${r.step || 'unknown'} — ${r.detail || ''}`)
322
+ }
323
+ } catch (error) {
324
+ log('cloud setup 失败:' + String((error && error.message) || error))
325
+ } finally {
326
+ setupRunning = false
327
+ }
328
+ })()
329
+ json(res, 200, {
330
+ ok: true,
331
+ running: true,
332
+ message: '已打开记事本:把 AppKey / AppSecret 填进引号里,保存并关闭记事本,浏览器会自动打开授权页',
333
+ })
334
+ },
335
+ }),
336
+ )
261
337
  disposers.push(
262
338
  webCtx.webServer.register({
263
339
  kind: 'exact',
@@ -284,7 +360,7 @@ export function registerWeb(ctx, term, log) {
284
360
  message:
285
361
  result.code === 0
286
362
  ? '已更新,重启 DSH 后生效'
287
- : (result.out || `dsh 退出码 ${result.code}`).slice(-1200),
363
+ : updateErrorText(result),
288
364
  })
289
365
  },
290
366
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crossterm",
3
- "version": "0.3.34",
3
+ "version": "0.3.36",
4
4
  "description": "DSH 跨终端互联:同一账号下的多个 DSH 终端/进程互发邮件与文件(主题内联、正文落盘,收件方自己读)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",