dsh-crossterm 0.3.33 → 0.3.35
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 +18 -0
- package/client/client.js +53 -2
- package/lib/bus.js +1 -1
- package/lib/cloud/setup.js +306 -0
- package/lib/index.js +27 -2
- package/lib/web.js +64 -9
- package/package.json +1 -1
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
|
@@ -181,6 +181,10 @@ window.__ModuleLoader__.load({
|
|
|
181
181
|
|
|
182
182
|
const onToggleCloud = async () => {
|
|
183
183
|
const running = Boolean((status && status.cloud && status.cloud.running) || false);
|
|
184
|
+
if (!(status && status.cloud && status.cloud.configured)) {
|
|
185
|
+
setMsg("这台机器还没连接云端:先按 README「云端配置」配好网盘授权(baidu-app.json + baidu-auth.json)");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
184
188
|
setBusy("cloud");
|
|
185
189
|
setMsg(running ? "正在关闭云同步…" : "正在开启云同步…");
|
|
186
190
|
try {
|
|
@@ -194,6 +198,37 @@ window.__ModuleLoader__.load({
|
|
|
194
198
|
}
|
|
195
199
|
};
|
|
196
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
|
+
|
|
197
232
|
const version = status ? "v" + status.version : "";
|
|
198
233
|
const mode = status && status.install ? status.install.mode : "";
|
|
199
234
|
const cloud = (status && status.cloud) || {};
|
|
@@ -241,11 +276,14 @@ window.__ModuleLoader__.load({
|
|
|
241
276
|
"span",
|
|
242
277
|
null,
|
|
243
278
|
status
|
|
244
|
-
?
|
|
279
|
+
? cloud.configured
|
|
280
|
+
? `${(cloud.provider && cloud.provider.name) || "云端"} · ${cloud.root || "—"}${cloud.running ? "(同步中)" : "(未同步)"}`
|
|
281
|
+
: "未连接(还没配置云盘)"
|
|
245
282
|
: "—",
|
|
246
283
|
),
|
|
247
284
|
status
|
|
248
|
-
?
|
|
285
|
+
? cloud.configured
|
|
286
|
+
? h(
|
|
249
287
|
"button",
|
|
250
288
|
{
|
|
251
289
|
style: cloud.running ? S.switchOn : S.switchOff,
|
|
@@ -259,6 +297,19 @@ window.__ModuleLoader__.load({
|
|
|
259
297
|
},
|
|
260
298
|
h("span", { style: cloud.running ? S.knobOn : S.knobOff }),
|
|
261
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
|
+
)
|
|
262
313
|
: null,
|
|
263
314
|
),
|
|
264
315
|
),
|
package/lib/bus.js
CHANGED
|
@@ -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, '&')}</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, '<')}</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)
|
|
@@ -79,10 +81,14 @@ function installMode(spec) {
|
|
|
79
81
|
|
|
80
82
|
/** 云端用哪家网盘:现在只接了百度,靠凭证文件认;以后加别家在这里扩展 */
|
|
81
83
|
function cloudProvider(cfg) {
|
|
82
|
-
if (!cfg) return { id: '', name: '' }
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
if (!cfg) return { id: '', name: '', configured: false }
|
|
85
|
+
// readConfig 会把 baiduAppFile / baiduAuthFile 补成默认路径(非空字符串),
|
|
86
|
+
// 所以「配置了路径」不等于「已经授权」——必须两个凭证文件真的存在才算连上云端。
|
|
87
|
+
const exists = (file) => Boolean(file) && fs.existsSync(file)
|
|
88
|
+
if (exists(cfg.baiduAppFile) && exists(cfg.baiduAuthFile)) {
|
|
89
|
+
return { id: 'baidu', name: '百度网盘', configured: true }
|
|
90
|
+
}
|
|
91
|
+
return { id: 'none', name: '未连接', configured: false }
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
/** 语义化版本比较:只比 主.次.补(忽略预发布尾巴),a>b 返回正数 */
|
|
@@ -159,6 +165,7 @@ function statusOf(term) {
|
|
|
159
165
|
/* ignore */
|
|
160
166
|
}
|
|
161
167
|
const cloud = term.cloud
|
|
168
|
+
const provider = cloudProvider(cfg)
|
|
162
169
|
return {
|
|
163
170
|
ok: true,
|
|
164
171
|
version: VERSION,
|
|
@@ -169,11 +176,13 @@ function statusOf(term) {
|
|
|
169
176
|
workspace: cfg.workspace || '',
|
|
170
177
|
install: { profile: (info && info.name) || '', spec: (info && info.spec) || '', mode },
|
|
171
178
|
cloud: {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
179
|
+
// 没配云盘时不要把默认路径当成「已连接的云端」显示出来
|
|
180
|
+
configured: provider.configured,
|
|
181
|
+
enabled: cfg.cloudEnabled === true && provider.configured,
|
|
182
|
+
running: provider.configured && Boolean(cloud && cloud.running),
|
|
183
|
+
root: provider.configured ? cfg.cloudRoot || '' : '',
|
|
175
184
|
intervalMs: cfg.cloudIntervalMs || 0,
|
|
176
|
-
provider
|
|
185
|
+
provider,
|
|
177
186
|
},
|
|
178
187
|
chains,
|
|
179
188
|
}
|
|
@@ -183,6 +192,7 @@ function statusOf(term) {
|
|
|
183
192
|
* 只注册一次;行被关闭时随 fiber 一起注销,重新启用会再注册。
|
|
184
193
|
*/
|
|
185
194
|
export function registerWeb(ctx, term, log) {
|
|
195
|
+
if (typeof log !== 'function') log = () => {}
|
|
186
196
|
if (installed) return
|
|
187
197
|
installed = true
|
|
188
198
|
try {
|
|
@@ -220,6 +230,15 @@ export function registerWeb(ctx, term, log) {
|
|
|
220
230
|
path: '/crossterm/api/cloud/start',
|
|
221
231
|
handler: (req, res) => {
|
|
222
232
|
try {
|
|
233
|
+
const before = statusOf(term).cloud
|
|
234
|
+
if (!before.configured) {
|
|
235
|
+
json(res, 200, {
|
|
236
|
+
ok: false,
|
|
237
|
+
message: '这台机器还没连接云盘:先配置自己的网盘授权(需要 baidu-app.json + baidu-auth.json),配置步骤见 README「云端配置」',
|
|
238
|
+
cloud: before,
|
|
239
|
+
})
|
|
240
|
+
return
|
|
241
|
+
}
|
|
223
242
|
term.enableCloud()
|
|
224
243
|
json(res, 200, { ok: true, message: '云同步已开启', cloud: statusOf(term).cloud })
|
|
225
244
|
} catch (error) {
|
|
@@ -242,6 +261,42 @@ export function registerWeb(ctx, term, log) {
|
|
|
242
261
|
},
|
|
243
262
|
}),
|
|
244
263
|
)
|
|
264
|
+
disposers.push(
|
|
265
|
+
webCtx.webServer.register({
|
|
266
|
+
kind: 'exact',
|
|
267
|
+
path: '/crossterm/api/cloud/setup',
|
|
268
|
+
handler: (req, res) => {
|
|
269
|
+
if (setupRunning) {
|
|
270
|
+
json(res, 200, { ok: false, running: true, message: '配置流程已经在进行中(看任务栏里的记事本)' })
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
const cfg = term.cfg
|
|
274
|
+
setupRunning = true
|
|
275
|
+
void (async () => {
|
|
276
|
+
try {
|
|
277
|
+
const r = await runSetup(cfg, { log: (line) => void log(line) })
|
|
278
|
+
if (r.ok) {
|
|
279
|
+
await writeConfig({ cloudEnabled: true })
|
|
280
|
+
term.cfg = readConfig()
|
|
281
|
+
term.enableCloud()
|
|
282
|
+
log(`cloud setup: ${r.step === 'already' ? '已有授权,直接复用' : '授权完成,云同步已开启'}`)
|
|
283
|
+
} else {
|
|
284
|
+
log(`cloud setup: 停在 ${r.step || 'unknown'} — ${r.detail || ''}`)
|
|
285
|
+
}
|
|
286
|
+
} catch (error) {
|
|
287
|
+
log('cloud setup 失败:' + String((error && error.message) || error))
|
|
288
|
+
} finally {
|
|
289
|
+
setupRunning = false
|
|
290
|
+
}
|
|
291
|
+
})()
|
|
292
|
+
json(res, 200, {
|
|
293
|
+
ok: true,
|
|
294
|
+
running: true,
|
|
295
|
+
message: '已打开记事本:把 AppKey / AppSecret 填进引号里,保存并关闭记事本,浏览器会自动打开授权页',
|
|
296
|
+
})
|
|
297
|
+
},
|
|
298
|
+
}),
|
|
299
|
+
)
|
|
245
300
|
disposers.push(
|
|
246
301
|
webCtx.webServer.register({
|
|
247
302
|
kind: 'exact',
|