dsh-connect-trae 2.2.0 → 2.3.1
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/CHANGELOG.md +116 -2
- package/README.en.md +66 -18
- package/README.md +66 -18
- package/icons/dsh-connect-trae-128.png +0 -0
- package/icons/dsh-connect-trae-64.png +0 -0
- package/lib/client.js +124 -37
- package/lib/index.d.ts +210 -34
- package/lib/index.js +280 -62
- package/package.json +29 -26
- package/scripts/verify-windows.mjs +311 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows 真机验证脚本 —— 直接跑本插件的**构建产物**,不是重写一份逻辑。
|
|
3
|
+
*
|
|
4
|
+
* 为什么必须跑真产物:本仓库的单元测试只能证明「代码在 Windows 上不崩」,
|
|
5
|
+
* 因为 CI 的 windows-latest 上并没有装 Trae。真机验证要回答的是另一个问题:
|
|
6
|
+
* 「这台机器上的 Trae 登录文件,插件到底读不读得到」——只有把真机上的真实
|
|
7
|
+
* 文件交给真代码才成立。
|
|
8
|
+
*
|
|
9
|
+
* 用法(在 Windows 上,装好插件或 clone 本仓后):
|
|
10
|
+
*
|
|
11
|
+
* node scripts/verify-windows.mjs
|
|
12
|
+
*
|
|
13
|
+
* 安全性:本脚本只输出**脱敏后的路径、形态描述与判定结果**。
|
|
14
|
+
* 它不会打印 token、refreshToken、密文原文、真实账号名或设备号 —— 用户名
|
|
15
|
+
* 一律替换为 `<user>`,账号名与设备号只给长度/是否纯数字。可以放心把输出
|
|
16
|
+
* 贴到公开 issue。
|
|
17
|
+
*
|
|
18
|
+
* 脱敏助手来自 `src/redact.ts`(有单元测试),而不是本脚本自己实现一份:
|
|
19
|
+
* 隐私逻辑最怕「两个地方各写一版,改了一处漏了另一处」。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
23
|
+
import { dirname, join } from 'node:path'
|
|
24
|
+
import { existsSync } from 'node:fs'
|
|
25
|
+
|
|
26
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Locate the plugin entry point: this checkout's build output first, then an
|
|
30
|
+
* installed copy.
|
|
31
|
+
*
|
|
32
|
+
* `lib/` is gitignored, so a fresh clone has no build output at all. That is the
|
|
33
|
+
* expected first state after `git clone`, and the failure it produces must be an
|
|
34
|
+
* instruction rather than a raw `ERR_MODULE_NOT_FOUND` stack — a user following
|
|
35
|
+
* the README should never have to read a Node resolution trace to learn they
|
|
36
|
+
* need to build.
|
|
37
|
+
*/
|
|
38
|
+
async function loadPlugin() {
|
|
39
|
+
const local = join(here, '..', 'lib', 'index.js')
|
|
40
|
+
// `import()` takes a URL, not a path. A bare `C:\...\lib\index.js` is parsed as
|
|
41
|
+
// the URL scheme `c:` and throws ERR_UNSUPPORTED_ESM_URL_SCHEME on Windows —
|
|
42
|
+
// i.e. exactly on the platform this script exists to verify, before a single
|
|
43
|
+
// check runs. pathToFileURL is the conversion that makes the local hit work.
|
|
44
|
+
if (existsSync(local)) return import(pathToFileURL(local).href)
|
|
45
|
+
try {
|
|
46
|
+
return await import('dsh-connect-trae')
|
|
47
|
+
} catch (error) {
|
|
48
|
+
const fromCheckout = existsSync(join(here, '..', 'src', 'index.ts'))
|
|
49
|
+
console.error('')
|
|
50
|
+
console.error('无法加载插件代码。')
|
|
51
|
+
console.error('')
|
|
52
|
+
if (fromCheckout) {
|
|
53
|
+
console.error('当前目录看起来是一份源码 checkout,但缺少构建产物 lib/。')
|
|
54
|
+
console.error('lib/ 不进版本库(见 .gitignore),所以拉取代码后必须先构建:')
|
|
55
|
+
console.error('')
|
|
56
|
+
console.error(' pnpm install')
|
|
57
|
+
console.error(' pnpm run build # 或 npm install && npm run build')
|
|
58
|
+
console.error('')
|
|
59
|
+
console.error('然后再运行本脚本。')
|
|
60
|
+
} else {
|
|
61
|
+
console.error('既没有找到同级的 lib/index.js,也无法从已安装的包解析 dsh-connect-trae。')
|
|
62
|
+
console.error('请在本插件目录内运行,或先安装:dsh plugin --profile desktop add dsh-connect-trae')
|
|
63
|
+
}
|
|
64
|
+
console.error('')
|
|
65
|
+
console.error(`(原始错误:${error instanceof Error ? error.message : String(error)})`)
|
|
66
|
+
process.exit(1)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ok = (yes) => (yes ? 'OK ' : 'FAIL')
|
|
71
|
+
let failures = 0
|
|
72
|
+
const failedLabels = []
|
|
73
|
+
function check(label, passed, detail = '') {
|
|
74
|
+
if (!passed) {
|
|
75
|
+
failures += 1
|
|
76
|
+
failedLabels.push(label)
|
|
77
|
+
}
|
|
78
|
+
console.log(` [${ok(passed)}] ${label}${detail === '' ? '' : ` — ${detail}`}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log('='.repeat(72))
|
|
82
|
+
console.log('dsh-connect-trae · Windows 真机验证')
|
|
83
|
+
console.log('='.repeat(72))
|
|
84
|
+
console.log(` node : ${process.version}`)
|
|
85
|
+
console.log(` platform : ${process.platform}`)
|
|
86
|
+
if (process.platform !== 'win32') {
|
|
87
|
+
console.log('\n ⚠ 当前不是 Windows(platform !== win32)。')
|
|
88
|
+
console.log(' 本脚本在其它平台上仍会运行,但只能验证「win32 分支的路径拼接」,')
|
|
89
|
+
console.log(' 无法验证真机上的目录名与登录文件。')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const plugin = await loadPlugin()
|
|
93
|
+
|
|
94
|
+
// 脱敏助手取自插件自身的导出(`src/redact.ts`,有单元测试覆盖),
|
|
95
|
+
// 保证脚本与产品代码用的是同一套规则。
|
|
96
|
+
const { maskUserPath, describeNameShape, describeIdShape } = plugin
|
|
97
|
+
for (const [name, fn] of [['maskUserPath', maskUserPath], ['describeNameShape', describeNameShape], ['describeIdShape', describeIdShape]]) {
|
|
98
|
+
if (typeof fn !== 'function') {
|
|
99
|
+
console.error(`\n 插件入口没有导出 ${name}(脱敏无法进行)。`)
|
|
100
|
+
console.error(' 请先构建:pnpm run build,或升级到包含 src/redact.ts 的版本。')
|
|
101
|
+
console.error(' 为避免泄露本机信息,脚本在此中止。')
|
|
102
|
+
process.exit(1)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------- 1. 探测清单
|
|
107
|
+
console.log('\n[1] 插件在 Windows 上会探测哪些路径')
|
|
108
|
+
console.log(' (这是 src/paths.ts 用 platform=win32 算出来的真实清单)')
|
|
109
|
+
|
|
110
|
+
const candidates = plugin.traeStorageCandidates('win32', undefined, process.env)
|
|
111
|
+
const desktop = candidates.filter((item) => item.source === 'desktop')
|
|
112
|
+
const cli = candidates.filter((item) => item.source === 'cli')
|
|
113
|
+
|
|
114
|
+
for (const item of desktop) {
|
|
115
|
+
const exists = existsSync(item.path)
|
|
116
|
+
console.log(` [${exists ? '有' : ' '}] ${item.edition.padEnd(8)} ${maskUserPath(item.path)}`)
|
|
117
|
+
}
|
|
118
|
+
for (const item of cli) {
|
|
119
|
+
const exists = existsSync(item.path)
|
|
120
|
+
console.log(` [${exists ? '有' : ' '}] ${item.edition.padEnd(8)} ${maskUserPath(item.path)} (CLI)`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const foundDesktop = desktop.filter((item) => existsSync(item.path))
|
|
124
|
+
const foundCli = cli.filter((item) => existsSync(item.path))
|
|
125
|
+
check('至少找到一个 storage.json 或 CLI token', foundDesktop.length + foundCli.length > 0,
|
|
126
|
+
foundDesktop.length + foundCli.length === 0
|
|
127
|
+
? '这条最关键:一个都没找到,说明目录名或 %APPDATA% 位置与插件假设不符'
|
|
128
|
+
: `${foundDesktop.length} 个桌面 + ${foundCli.length} 个 CLI`)
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------- 2. 账号识别
|
|
131
|
+
console.log('\n[2] 插件能否从中解出账号(跑真代码的诊断路径)')
|
|
132
|
+
|
|
133
|
+
// Walk the candidates in the SAME order section [1] printed, and report the
|
|
134
|
+
// first that actually yields credentials.
|
|
135
|
+
//
|
|
136
|
+
// Every store is pinned to ONE explicit path. That is load-bearing, not tidiness:
|
|
137
|
+
// `TraeCredentialStore` with no `storagePath` scans the HOST's own directories
|
|
138
|
+
// by `process.platform`, so on a non-Windows host it would probe a different set
|
|
139
|
+
// than [1] listed and its `accounts()` could resolve a credential from the
|
|
140
|
+
// developer's OWN machine — which is exactly how a deliberately empty HOME once
|
|
141
|
+
// reported "解出 1 个账号". Claiming Trae was found where none exists is the most
|
|
142
|
+
// misleading output this script could produce, so each candidate is resolved
|
|
143
|
+
// from its own path and nothing else.
|
|
144
|
+
const OWN_SCRATCH = join(here, '..', '.verify-windows-unused.json')
|
|
145
|
+
|
|
146
|
+
// Walk the candidates in the SAME order section [1] printed, so the report
|
|
147
|
+
// explains every path that was tried. Going through the store per candidate —
|
|
148
|
+
// rather than rescanning with a bare store — keeps parsing and decryption
|
|
149
|
+
// identical to the plugin's real path AND keeps every probe pinned to ONE path.
|
|
150
|
+
//
|
|
151
|
+
// That pinning is load-bearing, not tidiness: `TraeCredentialStore` with no
|
|
152
|
+
// `storagePath` scans the HOST's own directories by `process.platform`, so on a
|
|
153
|
+
// non-Windows host it would probe a different set than [1] listed and its
|
|
154
|
+
// `accounts()` could resolve a credential from the developer's OWN machine —
|
|
155
|
+
// which is exactly how a deliberately empty HOME once reported "解出 1 个账号".
|
|
156
|
+
// Claiming Trae was found where none exists is the most misleading output this
|
|
157
|
+
// script could produce, so each candidate is resolved from its own path only.
|
|
158
|
+
//
|
|
159
|
+
// This is ONE pass over the candidates. `accounts()` and `diagnose()` read the
|
|
160
|
+
// same files, so probing twice would double the work for no extra information.
|
|
161
|
+
let accounts = []
|
|
162
|
+
let hitPath
|
|
163
|
+
for (const candidate of candidates) {
|
|
164
|
+
const probe = new plugin.TraeCredentialStore({
|
|
165
|
+
storagePath: candidate.path,
|
|
166
|
+
edition: candidate.edition,
|
|
167
|
+
ownPath: OWN_SCRATCH,
|
|
168
|
+
refresh: async () => { throw new Error('verify-windows: refresh not needed') },
|
|
169
|
+
})
|
|
170
|
+
const found = await probe.accounts().catch(() => [])
|
|
171
|
+
const { failures } = await probe.diagnose()
|
|
172
|
+
|
|
173
|
+
// One line per CANDIDATE, not per failure. A store pinned to one path answers
|
|
174
|
+
// with that path probed as BOTH a desktop document and a CLI token file (an
|
|
175
|
+
// override's shape is not known ahead of time — see TraeCredentialStore
|
|
176
|
+
// .candidates()), so printing `failures` verbatim would list every path twice
|
|
177
|
+
// and, worse, would mark the very file that DID supply the account as
|
|
178
|
+
// `invalid` — once as "not a storage document", once as "not a CLI token".
|
|
179
|
+
// On a machine whose only credential was just found successfully, that reads
|
|
180
|
+
// like a defect. So the shapes are merged into one verdict per path, and a
|
|
181
|
+
// path that produced an account is reported as resolved.
|
|
182
|
+
const own = failures.filter(failure => failure.path === candidate.path)
|
|
183
|
+
const sourceLabel = candidate.source === 'cli' ? 'CLI' : '桌面'
|
|
184
|
+
if (found.length > 0) {
|
|
185
|
+
console.log(` ${'已解出'.padEnd(10)} [${candidate.edition}] ${maskUserPath(candidate.path)} (${sourceLabel})`)
|
|
186
|
+
if (accounts.length === 0) { accounts = found; hitPath = candidate }
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
// Prefer the more specific reason: `missing` is what both shapes report for an
|
|
190
|
+
// absent file, so a non-missing reason means at least one shape read the file
|
|
191
|
+
// and rejected its contents — which is the informative case.
|
|
192
|
+
const specific = own.find(failure => failure.reason !== 'missing')
|
|
193
|
+
const reason = specific?.reason ?? own[0]?.reason ?? 'missing'
|
|
194
|
+
console.log(` ${reason.padEnd(10)} [${candidate.edition}] ${maskUserPath(candidate.path)} (${sourceLabel})`)
|
|
195
|
+
if (specific?.message !== undefined) {
|
|
196
|
+
// 只打印错误类型,避免把任何密文内容带出来
|
|
197
|
+
console.log(` ${String(specific.message).slice(0, 120)}`)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 账号名只报形态:本脚本的输出会被贴到公开 issue,而账号名往往含真实姓名或
|
|
202
|
+
// 手机号(实测本机就有真实姓名与「用户<手机号>」两类)。形态足以判断「解出来了」。
|
|
203
|
+
check('解出至少一个账号', accounts.length > 0,
|
|
204
|
+
accounts.length === 0
|
|
205
|
+
? (foundDesktop.length + foundCli.length > 0
|
|
206
|
+
? '找到了登录文件但解不出账号 —— 多半是加密 header 变了,见下'
|
|
207
|
+
: '没有任何登录文件,因而不可能解出账号(与上一项一致)')
|
|
208
|
+
: `${accounts.map((a) => `${describeNameShape(a.accountName)} (${a.edition}/${a.region})`).join(', ')} ← ${maskUserPath(hitPath.path)}`)
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------- 3. 身份头
|
|
211
|
+
console.log('\n[3] 设备指纹(这些会作为请求头发给 Trae)')
|
|
212
|
+
|
|
213
|
+
if (accounts.length > 0) {
|
|
214
|
+
try {
|
|
215
|
+
const { resolveTraeIdentity, readTraeCliIdentity, identityHeaders } = plugin
|
|
216
|
+
// 桌面候选优先;没有则回退 CLI 候选(WSL2 / traecli 的常见形态)。
|
|
217
|
+
// 两条路都走 resolveTraeIdentity——它本来就把 CLI 兜底封装在内,这里只是
|
|
218
|
+
// 把它指向正确的候选,而不是自己重写一遍回退逻辑。
|
|
219
|
+
const desktopCandidate = candidates.find((item) => existsSync(item.path) && item.source === 'desktop')
|
|
220
|
+
const cliCandidate = candidates.find((item) => existsSync(item.path) && item.source === 'cli')
|
|
221
|
+
const picked = desktopCandidate ?? cliCandidate
|
|
222
|
+
if (picked === undefined) {
|
|
223
|
+
console.log(' (跳过:没有可读的候选文件)')
|
|
224
|
+
} else {
|
|
225
|
+
// 两条路必须分开调用,不能一律交给 resolveTraeIdentity:
|
|
226
|
+
// 它的 CLI 兜底只在「桌面文件**不存在**」时触发,而这里的情况是「桌面候选
|
|
227
|
+
// 存在但其实是 CLI token 文件」。把它当 storage.json 解析会抛 JSON 错,
|
|
228
|
+
// 那个错误不是 STORAGE_MISSING_PREFIX,于是兜底不生效、直接冒出来。
|
|
229
|
+
let identity
|
|
230
|
+
if (desktopCandidate === undefined) {
|
|
231
|
+
console.log(' (本机只命中 CLI 候选:设备指纹走 CLI 的确定性标识,属受支持场景)')
|
|
232
|
+
identity = await readTraeCliIdentity(picked.edition)
|
|
233
|
+
} else {
|
|
234
|
+
identity = await resolveTraeIdentity([picked], picked.edition)
|
|
235
|
+
}
|
|
236
|
+
const headers = identityHeaders(identity)
|
|
237
|
+
console.log(` x-device-type : ${headers['x-device-type']}`)
|
|
238
|
+
console.log(` x-os-version : ${headers['x-os-version']}`)
|
|
239
|
+
// 设备号完全不打印:它是这台机器的稳定标识,而本脚本的输出要被贴到
|
|
240
|
+
// 公开 issue。形态就足以判断解析路径是否正确(真实 icube-dc 为 15 位
|
|
241
|
+
// 数字,兜底哈希为 32 位十六进制)。
|
|
242
|
+
const deviceId = String(headers['x-device-id'] ?? '')
|
|
243
|
+
const deviceShape = describeIdShape(deviceId)
|
|
244
|
+
console.log(` x-device-id : ${deviceShape}`)
|
|
245
|
+
console.log(` x-app-version : ${headers['x-app-version'] ?? (desktopCandidate === undefined ? '(未发送 —— CLI 候选未提供)' : '(未发送 —— product.json 没读到)')}`)
|
|
246
|
+
// 这两条断言的是「本机就是 Windows」。在别的平台上跑脚本时它们必然失败,
|
|
247
|
+
// 那是预期结果而非缺陷,故补一句说明,避免读者误判成兼容性问题。
|
|
248
|
+
const onWindows = process.platform === 'win32'
|
|
249
|
+
const platformNote = onWindows ? '' : `(当前平台是 ${process.platform},非 Windows,此项预期失败)`
|
|
250
|
+
check('x-device-type 为 windows', headers['x-device-type'] === 'windows', `${String(headers['x-device-type'])}${platformNote}`)
|
|
251
|
+
check('x-os-version 以 Windows 开头', String(headers['x-os-version']).startsWith('Windows'), `${String(headers['x-os-version'])}${platformNote}`)
|
|
252
|
+
check('x-device-id 非空', deviceId !== '', deviceId === '' ? '设备号为空,签到与聊天都会受影响' : deviceShape)
|
|
253
|
+
// 桌面版从 product.json 取 appVersion,CLI 从 ide_version.json 取。所以
|
|
254
|
+
// 「没读到」对 CLI 候选是另一件事,不能按桌面版的结论去报——否则 CLI
|
|
255
|
+
// 用户会被引去查一个根本不存在的 product.json。
|
|
256
|
+
const appVersionNote = headers['x-app-version'] === undefined
|
|
257
|
+
? (desktopCandidate === undefined
|
|
258
|
+
? 'CLI 候选未提供版本,不影响主流程'
|
|
259
|
+
: 'product.json 路径可能不对(不影响主流程,但请回报)')
|
|
260
|
+
: String(headers['x-app-version'])
|
|
261
|
+
// 这一项**不算失败**:缺 x-app-version 只是少一个请求头,登录、聊天与
|
|
262
|
+
// 签到都不受影响(已在上游验证过)。把它计入 failures 会让一份完全可用
|
|
263
|
+
// 的机器报「未通过」,那正是最容易被误读成「Windows 不支持」的结果。
|
|
264
|
+
if (headers['x-app-version'] === undefined) {
|
|
265
|
+
console.log(` [INFO] 未发送 x-app-version — ${appVersionNote}`)
|
|
266
|
+
} else {
|
|
267
|
+
check('读到了 x-app-version', true, appVersionNote)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} catch (error) {
|
|
271
|
+
check('解析设备指纹', false, String(error).slice(0, 160))
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
console.log(' (跳过:上一步没有解出账号,无法解析设备指纹)')
|
|
275
|
+
console.log(' 这一节的结论取决于 [1] 和 [2]:先把登录文件找到并解出账号,再回来看这里。')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------------- 4. 结论
|
|
279
|
+
//
|
|
280
|
+
// 退出码反映「这台机器是否真的可用」,以便脚本能进自动化:
|
|
281
|
+
// 0 = 全部通过
|
|
282
|
+
// 1 = 有真实失败
|
|
283
|
+
// 2 = 未通过,但失败全部来自「不是在 Windows 上跑」——不算机器的错
|
|
284
|
+
//
|
|
285
|
+
// 区分 0/2 很重要:本脚本检查的是「Windows 上能不能读到 Trae」,所以在
|
|
286
|
+
// macOS / Linux 上跑,失败是必然的——那里既没有 %APPDATA%\Trae CN,平台断言
|
|
287
|
+
// 也不成立。一律返回 1 会让 CI 或批量巡检把这种预期失败当成真问题;一律返回 0
|
|
288
|
+
// 又让脚本无法用于判断。故非 Windows 主机上的失败统一记为「不适用」(2)。
|
|
289
|
+
const platformOnly = failures > 0 && process.platform !== 'win32'
|
|
290
|
+
|
|
291
|
+
console.log(`\n${'='.repeat(72)}`)
|
|
292
|
+
if (failures === 0) {
|
|
293
|
+
console.log('结论:全部通过 ✅')
|
|
294
|
+
console.log(' 这台机器上的插件路径与设备指纹均正常。')
|
|
295
|
+
console.log(' 请把上面完整输出贴到 issue,即可关闭「Windows 未验证」这一项:')
|
|
296
|
+
console.log(' https://github.com/dingminhua/dsh-connect-trae/issues')
|
|
297
|
+
} else if (platformOnly) {
|
|
298
|
+
console.log('结论:未通过,但只因为当前不是 Windows(预期)')
|
|
299
|
+
console.log(' 平台相关的断言在非 Windows 主机上必然失败,这不代表任何东西坏了。')
|
|
300
|
+
console.log(' 本报告只有在该脚本于 Windows 真机上运行时才具有验收意义。')
|
|
301
|
+
} else {
|
|
302
|
+
console.log(`结论:${failures} 项未通过 ❌`)
|
|
303
|
+
if (process.platform !== 'win32') {
|
|
304
|
+
console.log(' 注意:当前不是 Windows,其中「平台相关」的失败属预期。')
|
|
305
|
+
console.log(' 本报告只有在该脚本于 Windows 真机上运行时才具有验收意义。')
|
|
306
|
+
}
|
|
307
|
+
console.log(' 请把上面完整输出贴到 issue(本脚本不打印 token,可安全粘贴):')
|
|
308
|
+
console.log(' https://github.com/dingminhua/dsh-connect-trae/issues')
|
|
309
|
+
}
|
|
310
|
+
console.log('='.repeat(72))
|
|
311
|
+
process.exit(failures === 0 ? 0 : platformOnly ? 2 : 1)
|