@ai-wendao/ant 0.0.0-stage → 0.3.9
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/MIGRATION.md +83 -0
- package/README.md +8 -2
- package/client.js +729 -0
- package/cordis.patch.yml +3 -0
- package/index.js +678 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +30 -4
- package/tools/migrate-legacy.mjs +639 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
5
|
+
import { chmod, lstat, mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import { delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
|
|
10
|
+
const PACKAGE_NAME = '@ai-wendao/ant'
|
|
11
|
+
const ALIAS_NAME = 'ant'
|
|
12
|
+
const PROFILE_NAME = 'desktop'
|
|
13
|
+
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
14
|
+
const PACKAGE_VERSION = JSON.parse(await readFile(join(PACKAGE_ROOT, 'package.json'), 'utf8')).version
|
|
15
|
+
const PROFILE_FILES = ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']
|
|
16
|
+
|
|
17
|
+
function usage() {
|
|
18
|
+
process.stdout.write(`ANT 存量 Bundle 迁移工具
|
|
19
|
+
|
|
20
|
+
用法:
|
|
21
|
+
ant-migrate inspect [--dsh-home <路径>]
|
|
22
|
+
ant-migrate migrate [--apply] [--dsh-cli <路径>] [--dsh-home <路径>]
|
|
23
|
+
ant-migrate rollback --backup <目录> [--apply] [--dsh-cli <路径>]
|
|
24
|
+
|
|
25
|
+
migrate 和 rollback 默认只预检;只有显式传入 --apply 才会修改 DSH profile。
|
|
26
|
+
`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseArgs(argv) {
|
|
30
|
+
const [command = 'inspect', ...rest] = argv
|
|
31
|
+
if (command === '--help' || command === '-h' || command === 'help') return { help: true }
|
|
32
|
+
if (!['inspect', 'migrate', 'rollback'].includes(command)) throw new Error(`未知命令:${command}`)
|
|
33
|
+
const options = { command, apply: false }
|
|
34
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
35
|
+
const item = rest[index]
|
|
36
|
+
if (item === '--apply') options.apply = true
|
|
37
|
+
else if (item === '--dsh-home' || item === '--dsh-cli' || item === '--backup') {
|
|
38
|
+
const value = rest[index + 1]
|
|
39
|
+
if (!value || value.startsWith('--')) throw new Error(`${item} 需要一个路径`)
|
|
40
|
+
options[item.slice(2).replaceAll('-', '_')] = value
|
|
41
|
+
index += 1
|
|
42
|
+
} else throw new Error(`未知参数:${item}`)
|
|
43
|
+
}
|
|
44
|
+
if (command === 'rollback' && !options.backup) throw new Error('rollback 需要 --backup <备份目录>')
|
|
45
|
+
if (command !== 'rollback' && options.backup) throw new Error('--backup 仅用于 rollback')
|
|
46
|
+
return options
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveDshHome(configured) {
|
|
50
|
+
const candidate = configured?.trim() || process.env.DSH_HOME?.trim() || join(homedir(), '.dsh')
|
|
51
|
+
const expanded = candidate === '~' ? homedir()
|
|
52
|
+
: candidate.startsWith('~/') || candidate.startsWith('~\\')
|
|
53
|
+
? join(homedir(), candidate.slice(2))
|
|
54
|
+
: candidate
|
|
55
|
+
return resolve(expanded)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function aliasPointsToAnt(spec) {
|
|
59
|
+
return typeof spec === 'string' && /^npm:@ai-wendao\/ant(?:@[^\s]+)?$/u.test(spec)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function directSpecIsAnt(spec) {
|
|
63
|
+
return typeof spec === 'string' && /^(?:=|\^|~)?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(spec)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function directSpecMatchesTarget(spec, version = PACKAGE_VERSION) {
|
|
67
|
+
return spec === version
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function readProfile(dshHome) {
|
|
71
|
+
const dir = join(dshHome, 'profiles', PROFILE_NAME)
|
|
72
|
+
const manifestPath = join(dir, 'package.json')
|
|
73
|
+
let manifest
|
|
74
|
+
try {
|
|
75
|
+
manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error?.code === 'ENOENT') throw new Error(`找不到 DSH Desktop profile:${manifestPath}。请先打开并初始化 DSH。`)
|
|
78
|
+
throw new Error(`无法读取 DSH profile 清单:${error instanceof Error ? error.message : String(error)}`)
|
|
79
|
+
}
|
|
80
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
81
|
+
throw new Error('DSH profile 清单不是 JSON 对象;工具不会修改。')
|
|
82
|
+
}
|
|
83
|
+
const dependencies = manifest.dependencies ?? {}
|
|
84
|
+
if (typeof dependencies !== 'object' || Array.isArray(dependencies)) {
|
|
85
|
+
throw new Error('DSH profile dependencies 格式异常;工具不会修改。')
|
|
86
|
+
}
|
|
87
|
+
const aliasSpec = dependencies[ALIAS_NAME]
|
|
88
|
+
const directSpec = dependencies[PACKAGE_NAME]
|
|
89
|
+
const bundles = manifest.dsh?.profile?.bundles ?? []
|
|
90
|
+
if (!Array.isArray(bundles) || !bundles.every((name) => typeof name === 'string')) {
|
|
91
|
+
throw new Error('DSH profile Bundle 列表格式异常;工具不会修改。')
|
|
92
|
+
}
|
|
93
|
+
if (aliasSpec !== undefined && directSpec !== undefined) {
|
|
94
|
+
if (aliasPointsToAnt(aliasSpec) && directSpecIsAnt(directSpec)
|
|
95
|
+
&& bundles.includes(ALIAS_NAME) && bundles.includes(PACKAGE_NAME)) {
|
|
96
|
+
return { state: 'both', dir, manifestPath, manifest, aliasSpec, directSpec, bundles }
|
|
97
|
+
}
|
|
98
|
+
return { state: 'conflict', dir, manifestPath, manifest, aliasSpec, directSpec, bundles }
|
|
99
|
+
}
|
|
100
|
+
if (directSpec !== undefined) {
|
|
101
|
+
if (!bundles.includes(PACKAGE_NAME)) return { state: 'inconsistent', dir, manifestPath, manifest, directSpec, bundles }
|
|
102
|
+
return { state: 'current', dir, manifestPath, manifest, directSpec, bundles }
|
|
103
|
+
}
|
|
104
|
+
if (aliasSpec !== undefined) {
|
|
105
|
+
if (!aliasPointsToAnt(aliasSpec)) {
|
|
106
|
+
return { state: 'unknown-alias', dir, manifestPath, manifest, aliasSpec, bundles }
|
|
107
|
+
}
|
|
108
|
+
if (!bundles.includes(ALIAS_NAME)) {
|
|
109
|
+
return { state: 'inconsistent', dir, manifestPath, manifest, aliasSpec, bundles }
|
|
110
|
+
}
|
|
111
|
+
return { state: 'legacy-alias', dir, manifestPath, manifest, aliasSpec, bundles }
|
|
112
|
+
}
|
|
113
|
+
if (bundles.includes(ALIAS_NAME) || bundles.includes(PACKAGE_NAME)) {
|
|
114
|
+
return { state: 'inconsistent', dir, manifestPath, manifest, bundles }
|
|
115
|
+
}
|
|
116
|
+
return { state: 'not-installed', dir, manifestPath, manifest, bundles }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function findOnPath(command) {
|
|
120
|
+
const pathEntries = (process.env.PATH || '').split(delimiter)
|
|
121
|
+
const extensions = process.platform === 'win32'
|
|
122
|
+
? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';')
|
|
123
|
+
: ['']
|
|
124
|
+
for (const entry of pathEntries) {
|
|
125
|
+
for (const extension of extensions) {
|
|
126
|
+
const candidate = join(entry, `${command}${extension}`)
|
|
127
|
+
try {
|
|
128
|
+
if (process.platform === 'win32') {
|
|
129
|
+
const result = spawnSync('where.exe', [candidate], { encoding: 'utf8', windowsHide: true })
|
|
130
|
+
if (result.status === 0) return result.stdout.trim().split(/\r?\n/u)[0]
|
|
131
|
+
} else {
|
|
132
|
+
const result = spawnSync('test', ['-x', candidate])
|
|
133
|
+
if (result.status === 0) return candidate
|
|
134
|
+
}
|
|
135
|
+
} catch {}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return undefined
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function findDshCli(explicit) {
|
|
142
|
+
const candidates = [explicit, process.env.DSH_DESKTOP_CLI]
|
|
143
|
+
if (process.platform === 'darwin') {
|
|
144
|
+
candidates.push(
|
|
145
|
+
'/Applications/DeepSeek Harness.app/Contents/Resources/runtime/cli/bin/dsh',
|
|
146
|
+
join(homedir(), 'Applications/DeepSeek Harness.app/Contents/Resources/runtime/cli/bin/dsh'),
|
|
147
|
+
)
|
|
148
|
+
} else if (process.platform === 'win32') {
|
|
149
|
+
const local = process.env.LOCALAPPDATA
|
|
150
|
+
const programFiles = process.env.ProgramFiles
|
|
151
|
+
if (local) candidates.push(join(local, 'Programs/DeepSeek Harness/resources/runtime/cli/bin/dsh.cmd'))
|
|
152
|
+
if (programFiles) candidates.push(join(programFiles, 'DeepSeek Harness/resources/runtime/cli/bin/dsh.cmd'))
|
|
153
|
+
}
|
|
154
|
+
candidates.push(findOnPath(process.platform === 'win32' ? 'dsh.cmd' : 'dsh'), findOnPath('dsh'))
|
|
155
|
+
for (const candidate of candidates) {
|
|
156
|
+
if (!candidate) continue
|
|
157
|
+
try {
|
|
158
|
+
if ((await stat(candidate)).isFile() && await isDesktopCli(candidate)) return resolve(candidate)
|
|
159
|
+
} catch {}
|
|
160
|
+
}
|
|
161
|
+
throw new Error('找不到可确认属于 DSH Desktop 的 dsh 命令。请在 DSH「更多 → 管理 dsh 命令」安装命令,或用 --dsh-cli 指定 Desktop CLI 完整路径。')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function isDesktopCli(candidate) {
|
|
165
|
+
const source = await readFile(candidate, 'utf8')
|
|
166
|
+
return source.includes('ELECTRON_RUN_AS_NODE=1')
|
|
167
|
+
&& source.includes('--expose-internals')
|
|
168
|
+
&& source.includes('dsh-desktop-host')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function powershellQuote(value) {
|
|
172
|
+
return `'${String(value).replaceAll("'", "''")}'`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function runDsh(cli, args, dshHome) {
|
|
176
|
+
const env = { ...process.env, DSH_HOME: dshHome }
|
|
177
|
+
if (process.platform !== 'win32' || extname(cli).toLowerCase() !== '.cmd') {
|
|
178
|
+
return spawnSync(cli, args, { encoding: 'utf8', windowsHide: true, maxBuffer: 8 * 1024 * 1024, env })
|
|
179
|
+
}
|
|
180
|
+
const body = [
|
|
181
|
+
'$ErrorActionPreference = \'Stop\'',
|
|
182
|
+
`& ${powershellQuote(cli)} ${args.map(powershellQuote).join(' ')}`,
|
|
183
|
+
'exit $LASTEXITCODE',
|
|
184
|
+
].join('\n')
|
|
185
|
+
return spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', body], {
|
|
186
|
+
encoding: 'utf8', windowsHide: true, maxBuffer: 8 * 1024 * 1024, env,
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function assertDesktopClosed() {
|
|
191
|
+
if (process.platform === 'darwin') {
|
|
192
|
+
const result = spawnSync('ps', ['-A', '-o', 'comm=,args='], { encoding: 'utf8' })
|
|
193
|
+
if (result.error || result.status !== 0) throw new Error('无法确认 DSH 是否已退出;请完全退出 DSH 后再运行。')
|
|
194
|
+
const running = result.stdout.split(/\r?\n/u).some((line) =>
|
|
195
|
+
line.includes('DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness')
|
|
196
|
+
|| /^\s*DeepSeek Harness(?: Helper)?(?:\s|$)/u.test(line))
|
|
197
|
+
if (running) throw new Error('请先完全退出 DSH,再从独立终端重新运行迁移命令。')
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
if (process.platform === 'win32') {
|
|
201
|
+
const script = "$p = Get-CimInstance Win32_Process | Where-Object { $_.Name -like 'DeepSeek Harness*.exe' }; if ($p) { exit 3 } else { exit 0 }"
|
|
202
|
+
const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
203
|
+
encoding: 'utf8', windowsHide: true,
|
|
204
|
+
})
|
|
205
|
+
if (result.error || result.status === null) throw new Error('无法确认 DSH 是否已退出;请完全退出 DSH 后再运行。')
|
|
206
|
+
if (result.status !== 0) throw new Error('请先完全退出 DSH,再从独立终端重新运行迁移命令。')
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
throw new Error('目前仅支持 macOS 和 Windows 上的 DSH Desktop profile。')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function printJson(value) {
|
|
213
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function stateMessage(profile) {
|
|
217
|
+
const messages = {
|
|
218
|
+
'legacy-alias': '发现旧 ant alias,可以迁移到直接安装的 @ai-wendao/ant。',
|
|
219
|
+
both: '发现新旧 ANT 包都在 profile 中;只有存在有效迁移记录时才会续跑。',
|
|
220
|
+
current: '已经直接安装 @ai-wendao/ant,无需迁移。',
|
|
221
|
+
'not-installed': '这个 DSH profile 没有安装 ANT。',
|
|
222
|
+
conflict: 'profile 同时包含旧 alias 和 scoped 包;工具不会自动修改。',
|
|
223
|
+
'unknown-alias': 'ant 依赖指向非 ANT 包;工具不会修改。',
|
|
224
|
+
inconsistent: '依赖清单与 DSH Bundle 列表不一致;工具不会修改。',
|
|
225
|
+
}
|
|
226
|
+
return messages[profile.state] || 'profile 状态无法识别;工具不会修改。'
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function inspect(options) {
|
|
230
|
+
const dshHome = resolveDshHome(options.dsh_home)
|
|
231
|
+
const profile = await readProfile(dshHome)
|
|
232
|
+
printJson({
|
|
233
|
+
platform: process.platform,
|
|
234
|
+
profile: PROFILE_NAME,
|
|
235
|
+
dshHome,
|
|
236
|
+
state: profile.state,
|
|
237
|
+
aliasSpec: profile.aliasSpec ?? null,
|
|
238
|
+
directSpec: profile.directSpec ?? null,
|
|
239
|
+
target: `${PACKAGE_NAME}@${PACKAGE_VERSION}`,
|
|
240
|
+
message: stateMessage(profile),
|
|
241
|
+
})
|
|
242
|
+
if (profile.state === 'conflict' || profile.state === 'unknown-alias' || profile.state === 'inconsistent') process.exitCode = 2
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function createBackup(dshHome, profile) {
|
|
246
|
+
const backupDir = join(dshHome, 'ant-migration-backups', `${new Date().toISOString().replaceAll(':', '').replaceAll('.', '-')}-${randomUUID()}`)
|
|
247
|
+
await mkdir(backupDir, { recursive: true, mode: 0o700 })
|
|
248
|
+
const fileRows = []
|
|
249
|
+
for (const name of PROFILE_FILES) {
|
|
250
|
+
const source = join(profile.dir, name)
|
|
251
|
+
try {
|
|
252
|
+
const contents = await readFile(source)
|
|
253
|
+
await writeFile(join(backupDir, name), contents, { mode: 0o600, flag: 'wx' })
|
|
254
|
+
fileRows.push({ name, existed: true, sha256: createHash('sha256').update(contents).digest('hex') })
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (error?.code !== 'ENOENT') throw error
|
|
257
|
+
fileRows.push({ name, existed: false })
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const record = {
|
|
261
|
+
format: 1,
|
|
262
|
+
createdAt: new Date().toISOString(),
|
|
263
|
+
dshHome: resolve(dshHome),
|
|
264
|
+
profileDir: resolve(profile.dir),
|
|
265
|
+
profile: PROFILE_NAME,
|
|
266
|
+
originalAliasSpec: profile.aliasSpec,
|
|
267
|
+
targetSpec: `${PACKAGE_NAME}@${PACKAGE_VERSION}`,
|
|
268
|
+
targetVersion: PACKAGE_VERSION,
|
|
269
|
+
status: 'pending',
|
|
270
|
+
phase: 'prepared',
|
|
271
|
+
files: fileRows,
|
|
272
|
+
}
|
|
273
|
+
await writeFile(join(backupDir, 'migration.json'), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: 'wx' })
|
|
274
|
+
if (process.platform !== 'win32') await chmod(backupDir, 0o700)
|
|
275
|
+
return { backupDir, record }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function saveBackupRecord(backupDir, record) {
|
|
279
|
+
const temporary = join(backupDir, `.migration-${randomUUID()}.tmp`)
|
|
280
|
+
await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: 'wx' })
|
|
281
|
+
await rename(temporary, join(backupDir, 'migration.json'))
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function validateBackup(backupDir, dshHome) {
|
|
285
|
+
const backupRoot = resolve(dshHome, 'ant-migration-backups')
|
|
286
|
+
const absolute = resolve(backupDir)
|
|
287
|
+
const backupRelative = relative(backupRoot, absolute)
|
|
288
|
+
if (!backupRelative || backupRelative === '..' || backupRelative.startsWith(`..${sep}`) || isAbsolute(backupRelative)) {
|
|
289
|
+
throw new Error('备份目录不属于当前 DSH home;没有修改任何文件。')
|
|
290
|
+
}
|
|
291
|
+
const record = JSON.parse(await readFile(join(absolute, 'migration.json'), 'utf8'))
|
|
292
|
+
if (record.format !== 1 || record.profile !== PROFILE_NAME || record.dshHome !== resolve(dshHome)
|
|
293
|
+
|| record.profileDir !== resolve(dshHome, 'profiles', PROFILE_NAME)
|
|
294
|
+
|| !aliasPointsToAnt(record.originalAliasSpec) || !directSpecIsAnt(record.targetVersion)
|
|
295
|
+
|| record.targetSpec !== `${PACKAGE_NAME}@${record.targetVersion}`
|
|
296
|
+
|| !['pending', 'completed', 'rolled-back'].includes(record.status)
|
|
297
|
+
|| !['prepared', 'installing-scoped', 'scoped-installed', 'removing-alias', 'complete', 'rolled-back'].includes(record.phase)
|
|
298
|
+
|| !Array.isArray(record.files)) {
|
|
299
|
+
throw new Error('备份归属、格式或原 alias 无效;没有修改任何文件。')
|
|
300
|
+
}
|
|
301
|
+
const seen = new Set()
|
|
302
|
+
for (const file of record.files) {
|
|
303
|
+
if (!PROFILE_FILES.includes(file.name) || seen.has(file.name) || typeof file.existed !== 'boolean') {
|
|
304
|
+
throw new Error('备份文件清单异常;没有修改任何文件。')
|
|
305
|
+
}
|
|
306
|
+
seen.add(file.name)
|
|
307
|
+
if (file.existed) {
|
|
308
|
+
if (typeof file.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(file.sha256)) {
|
|
309
|
+
throw new Error(`备份文件 ${file.name} 校验值异常;没有修改任何文件。`)
|
|
310
|
+
}
|
|
311
|
+
const contents = await readFile(join(absolute, file.name))
|
|
312
|
+
const digest = createHash('sha256').update(contents).digest('hex')
|
|
313
|
+
if (digest !== file.sha256) throw new Error(`备份文件 ${file.name} 校验失败;没有修改任何文件。`)
|
|
314
|
+
} else if (file.sha256 !== undefined) {
|
|
315
|
+
throw new Error(`备份文件 ${file.name} 元数据异常;没有修改任何文件。`)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (seen.size !== PROFILE_FILES.length) throw new Error('备份文件清单不完整;没有修改任何文件。')
|
|
319
|
+
return { absolute, record }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function findPendingBackup(dshHome) {
|
|
323
|
+
const root = join(dshHome, 'ant-migration-backups')
|
|
324
|
+
let entries
|
|
325
|
+
try { entries = await readdir(root, { withFileTypes: true }) } catch (error) {
|
|
326
|
+
if (error?.code === 'ENOENT') return undefined
|
|
327
|
+
throw error
|
|
328
|
+
}
|
|
329
|
+
const pending = []
|
|
330
|
+
for (const entry of entries) {
|
|
331
|
+
if (!entry.isDirectory()) continue
|
|
332
|
+
const filename = join(root, entry.name, 'migration.json')
|
|
333
|
+
let record
|
|
334
|
+
try { record = JSON.parse(await readFile(filename, 'utf8')) } catch (error) {
|
|
335
|
+
if (error?.code === 'ENOENT') continue
|
|
336
|
+
throw new Error(`迁移记录无法读取:${filename}。请勿手动修改 profile。`)
|
|
337
|
+
}
|
|
338
|
+
if (record.status === 'pending' && record.dshHome === resolve(dshHome)) {
|
|
339
|
+
pending.push(await validateBackup(join(root, entry.name), dshHome))
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (pending.length > 1) throw new Error('发现多条未完成迁移记录;工具不会猜测目标。请保留备份并联系管理员。')
|
|
343
|
+
return pending[0]
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function processIsAlive(pid) {
|
|
347
|
+
try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function acquireMigrationLock(dshHome) {
|
|
351
|
+
const lockPath = join(dshHome, 'ant-migration.lock')
|
|
352
|
+
const token = randomUUID()
|
|
353
|
+
await mkdir(dshHome, { recursive: true })
|
|
354
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
355
|
+
let handle
|
|
356
|
+
try {
|
|
357
|
+
handle = await open(lockPath, 'wx', 0o600)
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (error?.code !== 'EEXIST') throw error
|
|
360
|
+
let current
|
|
361
|
+
try { current = JSON.parse(await readFile(lockPath, 'utf8')) } catch {
|
|
362
|
+
throw new Error(`发现无法识别的迁移锁:${lockPath}。确认没有迁移进程运行后,请联系管理员处理。`)
|
|
363
|
+
}
|
|
364
|
+
if (!Number.isSafeInteger(current.pid) || current.pid < 1 || processIsAlive(current.pid)) {
|
|
365
|
+
throw new Error(`另一个迁移进程正在运行(PID ${String(current.pid)});请等待它结束后重试。`)
|
|
366
|
+
}
|
|
367
|
+
const stalePath = `${lockPath}.${randomUUID()}.stale`
|
|
368
|
+
try { await rename(lockPath, stalePath); await unlink(stalePath) } catch (renameError) {
|
|
369
|
+
if (renameError?.code !== 'ENOENT') throw renameError
|
|
370
|
+
}
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
await handle.writeFile(`${JSON.stringify({ pid: process.pid, token, createdAt: new Date().toISOString() })}\n`)
|
|
375
|
+
await handle.sync()
|
|
376
|
+
await handle.close()
|
|
377
|
+
} catch (error) {
|
|
378
|
+
await handle.close().catch(() => undefined)
|
|
379
|
+
await unlink(lockPath).catch(() => undefined)
|
|
380
|
+
throw error
|
|
381
|
+
}
|
|
382
|
+
return async () => {
|
|
383
|
+
try {
|
|
384
|
+
const current = JSON.parse(await readFile(lockPath, 'utf8'))
|
|
385
|
+
if (current.token === token) await unlink(lockPath)
|
|
386
|
+
} catch (error) { if (error?.code !== 'ENOENT') throw error }
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
throw new Error('无法取得迁移锁;请稍后重试。')
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function acquireDesktopProfileLock(dshHome) {
|
|
393
|
+
const profileDir = join(dshHome, 'profiles', PROFILE_NAME)
|
|
394
|
+
const lockPath = join(profileDir, 'lock')
|
|
395
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
396
|
+
let handle
|
|
397
|
+
try {
|
|
398
|
+
handle = await open(lockPath, 'wx', 0o600)
|
|
399
|
+
} catch (error) {
|
|
400
|
+
if (error?.code !== 'EEXIST') throw error
|
|
401
|
+
const details = await lstat(lockPath)
|
|
402
|
+
if (details.isSymbolicLink() || !details.isFile()) {
|
|
403
|
+
throw new Error('DSH Desktop profile lock 不是普通文件;工具不会修改。')
|
|
404
|
+
}
|
|
405
|
+
const rawOwner = await readFile(lockPath, 'utf8')
|
|
406
|
+
const owner = Number.parseInt(rawOwner, 10)
|
|
407
|
+
if (!Number.isSafeInteger(owner) || owner < 1 || processIsAlive(owner)) {
|
|
408
|
+
throw new Error(`DSH 正在启动或另一个 profile 操作正在运行(PID ${String(owner)});请等待并确认 DSH 完全退出后重试。`)
|
|
409
|
+
}
|
|
410
|
+
await unlink(lockPath)
|
|
411
|
+
continue
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
await handle.writeFile(`${process.pid}\n`)
|
|
415
|
+
await handle.sync()
|
|
416
|
+
} catch (error) {
|
|
417
|
+
await handle.close().catch(() => undefined)
|
|
418
|
+
await unlink(lockPath).catch(() => undefined)
|
|
419
|
+
throw error
|
|
420
|
+
}
|
|
421
|
+
return async () => {
|
|
422
|
+
try {
|
|
423
|
+
const current = Number.parseInt(await readFile(lockPath, 'utf8'), 10)
|
|
424
|
+
if (current === process.pid) await unlink(lockPath)
|
|
425
|
+
} catch (error) { if (error?.code !== 'ENOENT') throw error }
|
|
426
|
+
finally { await handle.close() }
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
throw new Error('无法锁定 DSH Desktop profile;请稍后重试。')
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function showDshOutput(result) {
|
|
433
|
+
if (result.stdout) process.stdout.write(result.stdout)
|
|
434
|
+
if (result.stderr) process.stderr.write(result.stderr)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function invokeDsh(cli, args, label, dshHome) {
|
|
438
|
+
await assertDesktopClosed()
|
|
439
|
+
process.stdout.write(`执行:dsh plugin --profile ${PROFILE_NAME} ${args.at(-1)}(${label})\n`)
|
|
440
|
+
const result = await runDsh(cli, ['plugin', '--profile', PROFILE_NAME, ...args], dshHome)
|
|
441
|
+
showDshOutput(result)
|
|
442
|
+
if (result.error) throw new Error(`${label}失败:${result.error.message}`)
|
|
443
|
+
if (result.status !== 0) throw new Error(`${label}失败,退出码 ${String(result.status)}`)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function rollbackWithCli(cli, profile, aliasSpec) {
|
|
447
|
+
let current = await readProfile(profile.dshHome)
|
|
448
|
+
if (current.state === 'both') {
|
|
449
|
+
await invokeDsh(cli, ['remove', PACKAGE_NAME], '移除未完成的 scoped Bundle', profile.dshHome)
|
|
450
|
+
}
|
|
451
|
+
current = await readProfile(profile.dshHome)
|
|
452
|
+
if (current.aliasSpec === undefined) {
|
|
453
|
+
await invokeDsh(cli, ['add', `${ALIAS_NAME}@${aliasSpec}`], '恢复旧 ANT alias', profile.dshHome)
|
|
454
|
+
}
|
|
455
|
+
current = await readProfile(profile.dshHome)
|
|
456
|
+
if (current.aliasSpec !== aliasSpec || current.directSpec !== undefined
|
|
457
|
+
|| !current.bundles.includes(ALIAS_NAME) || current.bundles.includes(PACKAGE_NAME)) {
|
|
458
|
+
throw new Error('旧 alias 回退后状态未通过检查;请保留备份并联系管理员。')
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function migrate(options) {
|
|
463
|
+
const dshHome = resolveDshHome(options.dsh_home)
|
|
464
|
+
if (!options.apply) {
|
|
465
|
+
const profile = await readProfile(dshHome)
|
|
466
|
+
process.stdout.write(`${stateMessage(profile)}\n`)
|
|
467
|
+
if (profile.state === 'current' || profile.state === 'not-installed') return
|
|
468
|
+
if (profile.state !== 'legacy-alias') throw new Error('检测到非标准 profile 状态;工具不会修改。')
|
|
469
|
+
process.stdout.write(`预检完成。执行迁移前请完全退出 DSH;确认后运行:ant-migrate migrate --apply${options.dsh_home ? ` --dsh-home "${options.dsh_home}"` : ''}${options.dsh_cli ? ` --dsh-cli "${options.dsh_cli}"` : ''}\n`)
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
const releaseLock = await acquireMigrationLock(dshHome)
|
|
473
|
+
let releaseProfileLock
|
|
474
|
+
try {
|
|
475
|
+
await assertDesktopClosed()
|
|
476
|
+
releaseProfileLock = await acquireDesktopProfileLock(dshHome)
|
|
477
|
+
await assertDesktopClosed()
|
|
478
|
+
let profile = await readProfile(dshHome)
|
|
479
|
+
let pending = await findPendingBackup(dshHome)
|
|
480
|
+
process.stdout.write(`${stateMessage(profile)}\n`)
|
|
481
|
+
if (!pending && (profile.state === 'current' || profile.state === 'not-installed')) return
|
|
482
|
+
if (!pending && profile.state !== 'legacy-alias') {
|
|
483
|
+
throw new Error('profile 不是可识别的旧 ANT alias;工具不会修改。')
|
|
484
|
+
}
|
|
485
|
+
if (!pending) {
|
|
486
|
+
const initial = profile
|
|
487
|
+
const backup = await createBackup(dshHome, initial)
|
|
488
|
+
pending = { absolute: backup.backupDir, record: backup.record }
|
|
489
|
+
process.stdout.write(`profile 备份已保存:${pending.absolute}\n`)
|
|
490
|
+
}
|
|
491
|
+
const { absolute: backupDir, record } = pending
|
|
492
|
+
const targetVersion = record.targetVersion || PACKAGE_VERSION
|
|
493
|
+
const targetSpec = `${PACKAGE_NAME}@${targetVersion}`
|
|
494
|
+
const cli = await findDshCli(options.dsh_cli)
|
|
495
|
+
await assertDesktopClosed()
|
|
496
|
+
const listResult = await runDsh(cli, ['plugin', '--profile', PROFILE_NAME, 'list'], dshHome)
|
|
497
|
+
showDshOutput(listResult)
|
|
498
|
+
if (listResult.error || listResult.status !== 0) {
|
|
499
|
+
throw new Error('DSH Desktop CLI 未能读取 desktop profile;请确认 CLI 路径正确且 profile 已初始化。')
|
|
500
|
+
}
|
|
501
|
+
profile = await readProfile(dshHome)
|
|
502
|
+
if (['conflict', 'unknown-alias', 'inconsistent'].includes(profile.state)) {
|
|
503
|
+
throw new Error('预检后 profile 状态与迁移记录不一致;迁移尚未继续,备份已保留。')
|
|
504
|
+
}
|
|
505
|
+
if (profile.state === 'current') {
|
|
506
|
+
if (!directSpecMatchesTarget(profile.directSpec, targetVersion)) {
|
|
507
|
+
throw new Error(`已发现直接安装的 ANT,但版本范围与迁移记录目标 ${targetSpec} 不符;工具不会覆盖。`)
|
|
508
|
+
}
|
|
509
|
+
record.status = 'completed'
|
|
510
|
+
record.phase = 'complete'
|
|
511
|
+
record.completedAt = new Date().toISOString()
|
|
512
|
+
await saveBackupRecord(backupDir, record)
|
|
513
|
+
printJson({ state: 'migrated', profile: PROFILE_NAME, package: PACKAGE_NAME, version: targetVersion, backupDir,
|
|
514
|
+
next: '请重新打开 DSH;检查 ANT 设置页、连接状态和模型目录。' })
|
|
515
|
+
return
|
|
516
|
+
}
|
|
517
|
+
if (profile.state === 'legacy-alias' || profile.state === 'not-installed') {
|
|
518
|
+
record.phase = 'installing-scoped'
|
|
519
|
+
await saveBackupRecord(backupDir, record)
|
|
520
|
+
try {
|
|
521
|
+
await invokeDsh(cli, ['add', '--save-exact', targetSpec], '安装 scoped ANT Bundle', dshHome)
|
|
522
|
+
} catch (error) {
|
|
523
|
+
const afterAttempt = await readProfile(dshHome)
|
|
524
|
+
if (afterAttempt.state !== 'both' && afterAttempt.state !== 'current') throw error
|
|
525
|
+
}
|
|
526
|
+
profile = await readProfile(dshHome)
|
|
527
|
+
}
|
|
528
|
+
if (profile.state === 'current' && directSpecMatchesTarget(profile.directSpec, targetVersion)) {
|
|
529
|
+
record.status = 'completed'
|
|
530
|
+
record.phase = 'complete'
|
|
531
|
+
record.completedAt = new Date().toISOString()
|
|
532
|
+
await saveBackupRecord(backupDir, record)
|
|
533
|
+
printJson({ state: 'migrated', profile: PROFILE_NAME, package: PACKAGE_NAME, version: targetVersion, backupDir,
|
|
534
|
+
next: '请重新打开 DSH;检查 ANT 设置页、连接状态和模型目录。' })
|
|
535
|
+
return
|
|
536
|
+
}
|
|
537
|
+
if (profile.state !== 'both' || !directSpecMatchesTarget(profile.directSpec, targetVersion)
|
|
538
|
+
|| profile.aliasSpec !== record.originalAliasSpec) {
|
|
539
|
+
throw new Error('scoped 包未按预期安装;旧 alias 保留,备份已保留。关闭 DSH 后重试 migrate --apply。')
|
|
540
|
+
}
|
|
541
|
+
record.phase = 'scoped-installed'
|
|
542
|
+
await saveBackupRecord(backupDir, record)
|
|
543
|
+
record.phase = 'removing-alias'
|
|
544
|
+
await saveBackupRecord(backupDir, record)
|
|
545
|
+
try {
|
|
546
|
+
await invokeDsh(cli, ['remove', ALIAS_NAME], '移除旧 ANT alias', dshHome)
|
|
547
|
+
} catch (error) {
|
|
548
|
+
const afterAttempt = await readProfile(dshHome)
|
|
549
|
+
if (afterAttempt.state !== 'current') throw error
|
|
550
|
+
}
|
|
551
|
+
const finalProfile = await readProfile(dshHome)
|
|
552
|
+
if (finalProfile.state !== 'current' || !directSpecMatchesTarget(finalProfile.directSpec, targetVersion)) {
|
|
553
|
+
throw new Error('移除旧 alias 后 profile 未达到预期状态;请保持 DSH 关闭,再运行 migrate --apply 续跑。')
|
|
554
|
+
}
|
|
555
|
+
record.status = 'completed'
|
|
556
|
+
record.phase = 'complete'
|
|
557
|
+
record.completedAt = new Date().toISOString()
|
|
558
|
+
await saveBackupRecord(backupDir, record)
|
|
559
|
+
printJson({ state: 'migrated', profile: PROFILE_NAME, package: PACKAGE_NAME, version: targetVersion, backupDir,
|
|
560
|
+
next: '请重新打开 DSH;检查 ANT 设置页、连接状态和模型目录。' })
|
|
561
|
+
} catch (error) {
|
|
562
|
+
const pending = await findPendingBackup(dshHome).catch(() => undefined)
|
|
563
|
+
if (pending) {
|
|
564
|
+
process.stderr.write(`迁移未完成:${error instanceof Error ? error.message : String(error)}\n`)
|
|
565
|
+
process.stderr.write(`旧 alias 会保留到新版安装成功。保持 DSH 关闭,检查 profile 后可重试 ant-migrate migrate --apply。备份:${pending.absolute}\n`)
|
|
566
|
+
process.exitCode = 1
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
throw error
|
|
570
|
+
} finally {
|
|
571
|
+
try { if (releaseProfileLock) await releaseProfileLock() }
|
|
572
|
+
finally { await releaseLock() }
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function rollback(options) {
|
|
577
|
+
const dshHome = resolveDshHome(options.dsh_home)
|
|
578
|
+
const releaseLock = await acquireMigrationLock(dshHome)
|
|
579
|
+
let releaseProfileLock
|
|
580
|
+
try {
|
|
581
|
+
const { absolute, record } = await validateBackup(options.backup, dshHome)
|
|
582
|
+
const profile = await readProfile(dshHome)
|
|
583
|
+
if (record.status !== 'pending') throw new Error('该迁移记录已结束;rollback 只用于取消尚未完成的迁移。')
|
|
584
|
+
if (profile.state === 'legacy-alias' && profile.aliasSpec === record.originalAliasSpec) {
|
|
585
|
+
if (options.apply) {
|
|
586
|
+
record.status = 'rolled-back'
|
|
587
|
+
record.phase = 'rolled-back'
|
|
588
|
+
await saveBackupRecord(absolute, record)
|
|
589
|
+
process.stdout.write('旧 alias 仍完整存在;已将迁移标记为取消。\n')
|
|
590
|
+
} else {
|
|
591
|
+
process.stdout.write('预检完成:旧 alias 仍完整存在,无需回退。运行 rollback --apply 可取消这条迁移记录。\n')
|
|
592
|
+
}
|
|
593
|
+
return
|
|
594
|
+
}
|
|
595
|
+
if (profile.state !== 'both' || profile.aliasSpec !== record.originalAliasSpec) {
|
|
596
|
+
throw new Error('当前状态无法无损取消迁移;该工具不会重建旧 alias 或覆盖现有依赖。优先继续 migrate --apply。')
|
|
597
|
+
}
|
|
598
|
+
if (!options.apply) {
|
|
599
|
+
process.stdout.write(`预检完成。确认 DSH 已完全退出后运行:ant-migrate rollback --backup "${absolute}" --apply\n`)
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
await assertDesktopClosed()
|
|
603
|
+
releaseProfileLock = await acquireDesktopProfileLock(dshHome)
|
|
604
|
+
await assertDesktopClosed()
|
|
605
|
+
const cli = await findDshCli(options.dsh_cli)
|
|
606
|
+
const listResult = await runDsh(cli, ['plugin', '--profile', PROFILE_NAME, 'list'], dshHome)
|
|
607
|
+
showDshOutput(listResult)
|
|
608
|
+
if (listResult.error || listResult.status !== 0) throw new Error('DSH Desktop CLI 未能读取 desktop profile。')
|
|
609
|
+
const beforeRollback = await readProfile(dshHome)
|
|
610
|
+
if (beforeRollback.state !== 'both' || beforeRollback.aliasSpec !== record.originalAliasSpec) {
|
|
611
|
+
throw new Error('执行回退前 profile 已变化;工具不会继续。')
|
|
612
|
+
}
|
|
613
|
+
await rollbackWithCli(cli, { dshHome }, record.originalAliasSpec)
|
|
614
|
+
record.status = 'rolled-back'
|
|
615
|
+
record.phase = 'rolled-back'
|
|
616
|
+
await saveBackupRecord(absolute, record)
|
|
617
|
+
printJson({ state: 'rolled-back', profile: PROFILE_NAME, backupDir: absolute,
|
|
618
|
+
next: '新包已移除,原 alias 恢复。它会恢复迁移前的加载限制;优先修复并继续迁移。' })
|
|
619
|
+
} finally {
|
|
620
|
+
try { if (releaseProfileLock) await releaseProfileLock() }
|
|
621
|
+
finally { await releaseLock() }
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async function main() {
|
|
626
|
+
const options = parseArgs(process.argv.slice(2))
|
|
627
|
+
if (options.help) return usage()
|
|
628
|
+
const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10)
|
|
629
|
+
if (!Number.isInteger(nodeMajor) || nodeMajor < 20) throw new Error('迁移工具需要 Node.js 20 或更高版本。')
|
|
630
|
+
if (!['darwin', 'win32'].includes(process.platform)) throw new Error('目前仅支持 macOS 和 Windows 上的 DSH Desktop。')
|
|
631
|
+
if (options.command === 'inspect') return inspect(options)
|
|
632
|
+
if (options.command === 'migrate') return migrate(options)
|
|
633
|
+
return rollback(options)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
main().catch((error) => {
|
|
637
|
+
process.stderr.write(`ant-migrate:${error instanceof Error ? error.message : String(error)}\n`)
|
|
638
|
+
process.exitCode = 1
|
|
639
|
+
})
|