@ai-wendao/ant 0.3.8 → 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 ADDED
@@ -0,0 +1,83 @@
1
+ # DSH Agent 操作说明:迁移旧 ANT alias
2
+
3
+ 本说明用于指导已安装旧 `ant` alias 的成员迁移,不代表允许静默修改 profile。修改前必须向成员说明操作并取得确认。
4
+
5
+ ## 适用范围
6
+
7
+ 仅迁移 DSH Desktop 的 `desktop` profile 中以依赖键 `ant` 安装、指向 `@ai-wendao/ant` 的旧 npm alias。已经直接安装 `@ai-wendao/ant` 的 profile 无需迁移。检测到其他来源、重复依赖、异常清单时停止,不要自行修复。
8
+
9
+ 目标 DSH RC2 要求 Bundle 客户端模块身份与 npm 包名称一致。旧 alias 会让冷启动时的客户端身份不匹配。新版应直接安装 `@ai-wendao/ant`。**普通 Desktop profile 优先使用 DSH 自带的插件管理界面;不需要系统 Node.js 或 npm。**旧 alias 先卸载,新包安装完成后再启用。不要删除整个 DSH profile。
10
+
11
+ ## 推荐方式:在 DSH 插件页操作
12
+
13
+ 此方式适用于 DSH Desktop RC2 使用默认、启用 HMR 的 Desktop profile,且目标 ANT 版本已发布到 npm:
14
+
15
+ 1. 打开 **插件 → ANT**,点击 **卸载** 并确认。
16
+ 2. 回到插件列表,点击 **添加插件**,输入已发布的直接包名,例如 `@ai-wendao/ant@0.3.9`,然后点击 **安装**。
17
+ 3. 安装完成后启用 ANT,打开设置页,检查服务连接与获准模型。
18
+
19
+ RC2 的 Desktop 基础 profile 默认启用 HMR;官方管理器在该模式下会先卸载 Bundle,再用随 DSH 提供的包管理器移除依赖。包卸载本身不会调用 ANT 的“断开设备”操作,也不会清空 DSH 凭证存储。若 profile 禁用了 HMR,管理器会提示先停止 profile;此时改用下方 CLI 备用流程。真实成员 profile 的凭证保留仍需在目标 RC2 客户端上验收。
20
+
21
+ ## CLI 备用方式
22
+
23
+ 仅当 DSH 插件页提示需要停止 profile,或需要管理员批量、受控处理时使用。该 CLI 是同一份跨 macOS/Windows 的 JavaScript 文件,不是双击式安装器;要求 Node.js 20+、npm 和 DSH Desktop CLI,并需要在终端执行。若团队要求成员不安装 Node/npm 且不使用终端,这条备用路径不满足该体验,需要另行选定免安装分发方式。
24
+
25
+ ### 执行规程
26
+
27
+ 1. 仅当决定使用 CLI 备用路径时,运行 `ant-migrate inspect`。它只检查 `DSH_HOME/profiles/desktop/package.json`,不读取凭证文件或把密钥复制到命令参数、日志或备份。
28
+ 2. 若状态是 `current` 或 `not-installed`,结束流程。若是 `conflict`、`unknown-alias` 或 `inconsistent`,停止并报告诊断,不要尝试清理。
29
+ 3. 对 `legacy-alias`,向成员解释需要完全退出并重新打开 DSH、迁移期间不会运行 DSH,以及失败会保留备份;确认成员没有需要继续运行的任务,并取得明确确认。
30
+ 4. 先运行 `ant-migrate migrate` 查看预检结果。让成员完全退出 DSH,再从独立终端运行下方的 `--apply` 命令。迁移助手确认 DSH 已退出、CLI 能读取 Desktop profile 后,会备份依赖清单和锁文件;随后先安装 scoped 包,再移除旧 alias。
31
+ 5. 记录命令输出的备份目录。成功后重新打开 DSH,检查 ANT 设置页、连接状态和获准模型;如需重新验证邮箱,先检查凭证状态,不要自动清空或重建 profile。
32
+ 6. 安装新版失败时,旧 alias 会保留。若在“新旧包都已安装”的中间状态中断,保持 DSH 关闭并重跑 `migrate --apply` 续跑;`rollback` 仅用于在该中间态取消迁移。完成迁移后不提供回到旧 alias 的自动回滚,因为这会恢复已知的加载故障。不要手工改 `package.json`、`pnpm-lock.yaml`,不要复制 `node_modules`。
33
+
34
+ DSH 必须完全退出后才能运行包变更命令。迁移工具是独立进程,不依赖 DSH 退出后仍有子进程驻留;它不会静默关闭 DSH、终止任务或自动重启应用。迁移期间工具会持有 RC2 Desktop profile 启动锁,防止并发打开 profile;请等命令结束后再启动 DSH。过程若被中断,备份目录中的状态记录用于安全续跑,不应手工编辑 profile 清单。
35
+
36
+ ## 权限与数据
37
+
38
+ 迁移工具不会提权。若读取 profile 或创建备份时收到权限错误,它会停止;先为运行迁移命令的终端授予访问 DSH profile 与备份目录的权限,再重试。不要用 `sudo`,也不要把 profile 复制到其他位置来绕过系统权限。
39
+
40
+ 成员凭证、ANT 服务地址和模型设置保存在 DSH/ANT 的独立存储键中,不属于 npm 包目录。卸载旧 Bundle 不会执行“断开账号”或删除这些凭证;活动会话会在 Bundle 退出时结束,重新加载后由保留的凭证恢复。工具只备份包清单和锁文件,不读取或复制密钥。
41
+
42
+ ## 运行方式
43
+
44
+ 要求 Node.js 20+,并需要 DSH Desktop 自带的 CLI。工具只有一份跨平台 JavaScript 实现,不需要分别签名、打包 macOS 与 Windows 应用。若终端找不到 `dsh`,在 DSH 的“更多 → 管理 dsh 命令”安装官方命令,或通过 `--dsh-cli` 传入 Desktop CLI 完整路径。Windows 使用 DSH 安装目录中的 `resources/runtime/cli/bin/dsh.cmd`;macOS 使用应用包中的 `Contents/Resources/runtime/cli/bin/dsh`。
45
+
46
+ 如果从仓库运行:
47
+
48
+ ```sh
49
+ node packages/ant-bundle/tools/migrate-legacy.mjs inspect
50
+ node packages/ant-bundle/tools/migrate-legacy.mjs migrate
51
+ ```
52
+
53
+ 成员完全退出 DSH 后再应用:
54
+
55
+ ```sh
56
+ node packages/ant-bundle/tools/migrate-legacy.mjs migrate --apply
57
+ ```
58
+
59
+ 安装了系统 Node.js 的成员也可以从已发布 npm 包运行:
60
+
61
+ ```sh
62
+ npm exec --yes --package @ai-wendao/ant@0.3.9 -- ant-migrate inspect
63
+ npm exec --yes --package @ai-wendao/ant@0.3.9 -- ant-migrate migrate
64
+ ```
65
+
66
+ 退出 DSH 后执行变更:
67
+
68
+ ```sh
69
+ npm exec --yes --package @ai-wendao/ant@0.3.9 -- ant-migrate migrate --apply
70
+ ```
71
+
72
+ 若 CLI 不在 PATH,可追加 `--dsh-cli "<Desktop CLI 完整路径>"`;若 DSH 使用自定义数据目录,可追加 `--dsh-home "<DSH_HOME 路径>"`。
73
+
74
+ ## 回退
75
+
76
+ `migrate --apply` 会输出受限权限的备份目录,备份包含 profile 的 `package.json`、`pnpm-lock.yaml`、`pnpm-workspace.yaml`(若存在)及其 SHA-256 校验值,不包含密钥库。备份仅用于迁移状态核验和保留原始清单,不等于离线恢复包。确认 DSH 已退出后,通过同一工具调用:
77
+
78
+ ```sh
79
+ npm exec --yes --package @ai-wendao/ant@0.3.9 -- ant-migrate rollback --backup "<备份目录>"
80
+ npm exec --yes --package @ai-wendao/ant@0.3.9 -- ant-migrate rollback --backup "<备份目录>" --apply
81
+ ```
82
+
83
+ 回退只在 scoped 包已安装、旧 alias 尚未移除的中间态工作;它会卸载 scoped 包并保留旧 alias。已完成迁移的 profile 不接受该回退,避免自动恢复已知有加载问题的 alias。若安装包时 npm registry 不可用,保持 DSH 关闭,稍后重新运行 `migrate --apply`。
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # ANT for DeepSeek Harness
2
+
3
+ ANT connects a DeepSeek Harness Desktop profile to its team's approved models and collaboration services.
4
+
5
+ ## Existing `ant` alias installations
6
+
7
+ DSH RC2 requires a Bundle's client module identity to match its npm package name. Profiles that installed `ant` as an npm alias for `@ai-wendao/ant` need a one-time package-key migration. Use DSH's Plugins page for the normal Desktop profile; the cross-platform migration CLI is a fallback.
8
+
9
+ For the normal Desktop profile, migrate from DSH's **Plugins** page by uninstalling the old ANT package and installing `@ai-wendao/ant` directly. This uses DSH's bundled package manager and does not require system Node.js/npm. Read [MIGRATION.md](./MIGRATION.md) first. The `ant-migrate` CLI is a fallback for profiles where the UI cannot unload the running bundle; it requires Node.js 20+, npm, and the DSH Desktop CLI. Neither path clears ANT credentials or model settings by design. This package does not publish or deploy ANT.
package/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  window.__ModuleLoader__.load({
2
- id: 'ant',
2
+ id: '@ai-wendao/ant',
3
3
  factory: (require) => {
4
4
  var module = { exports: {} }
5
5
  var exports = module.exports
@@ -7,6 +7,7 @@ window.__ModuleLoader__.load({
7
7
  const h = createElement
8
8
  const SERVICE_URL_STORAGE_KEY = 'ant-team-service-url'
9
9
  const DEVICE_ID_STORAGE_KEY = 'ant-team-device-id'
10
+ const ANT_BUNDLE_PACKAGE_NAME = '@ai-wendao/ant'
10
11
  const ANT_API_PROTOCOL_VERSIONS = [1]
11
12
  const panelStyle = {
12
13
  display: 'grid', alignContent: 'start', gap: 14, padding: 18,
@@ -61,6 +62,31 @@ window.__ModuleLoader__.load({
61
62
  }
62
63
  const inject = ['slots', 'remote', 'remote.credentials', 'remote.settings', 'remote.pluginManager']
63
64
 
65
+ async function readLocalJsonResponse(response) {
66
+ const contentType = response.headers?.get?.('content-type') || ''
67
+ const isJson = /^(?:application|text)\/(?:[a-z0-9!#$&^_.+-]+\+)?json(?:\s*;|$)/iu.test(contentType.trim())
68
+ if (contentType && !isJson) {
69
+ if (response.status === 404) {
70
+ throw new Error('DSH 未找到 ANT 本地接口。请确认 ANT Bundle 已更新,并完全退出后重新启动 DSH。')
71
+ }
72
+ throw new Error(`DSH 返回了非 JSON 响应(HTTP ${response.status})。`)
73
+ }
74
+ try {
75
+ return await response.json()
76
+ } catch {
77
+ if (response.status === 404) {
78
+ throw new Error('DSH 未找到 ANT 本地接口。请确认 ANT Bundle 已更新,并完全退出后重新启动 DSH。')
79
+ }
80
+ throw new Error(`DSH 返回的 ANT 响应格式无效(HTTP ${response.status})。`)
81
+ }
82
+ }
83
+
84
+ function directInstallSpec(release) {
85
+ return release?.version && release?.package_name
86
+ ? `${release.package_name}@${release.version}`
87
+ : ''
88
+ }
89
+
64
90
  function loadServiceUrl() {
65
91
  try { return normalizeServiceUrl(localStorage.getItem(SERVICE_URL_STORAGE_KEY) || '') || '' } catch { return '' }
66
92
  }
@@ -152,7 +178,7 @@ window.__ModuleLoader__.load({
152
178
  if (typeof manager?.listBundles !== 'function') throw new Error('当前 DSH 未提供 Bundle 版本读取接口。')
153
179
  const listed = await manager.listBundles()
154
180
  if (!listed.ok) throw new Error(listed.error?.message || '无法读取已安装的 ANT Bundle 版本。')
155
- const installed = listed.value.find((bundle) => bundle.name === 'ant' && bundle.installed)
181
+ const installed = listed.value.find((bundle) => bundle.name === ANT_BUNDLE_PACKAGE_NAME && bundle.installed)
156
182
  if (typeof installed?.version !== 'string') throw new Error('无法确定当前 ANT Bundle 版本。')
157
183
  const response = await fetch('/api/ant-teams/service-info', {
158
184
  method: 'POST',
@@ -163,7 +189,7 @@ window.__ModuleLoader__.load({
163
189
  api_protocol_versions: ANT_API_PROTOCOL_VERSIONS,
164
190
  }),
165
191
  })
166
- const result = await response.json()
192
+ const result = await readLocalJsonResponse(response)
167
193
  if (!response.ok) throw new Error(result.message || `版本检查失败(${response.status})`)
168
194
  const serverProtocols = Array.isArray(result.server_api_protocol_versions) ? result.server_api_protocol_versions : []
169
195
  const sharedProtocols = Array.isArray(result.shared_api_protocol_versions) ? result.shared_api_protocol_versions : []
@@ -234,7 +260,7 @@ window.__ModuleLoader__.load({
234
260
  try {
235
261
  await requireCompatibleService(serviceUrl, true)
236
262
  const response = await fetch('/api/ant-teams/models', { method: 'POST' })
237
- const result = await response.json()
263
+ const result = await readLocalJsonResponse(response)
238
264
  if (!response.ok) {
239
265
  if (response.status === 401) setNeedsReconnect(true)
240
266
  throw new Error(result.message || `刷新失败(${response.status})`)
@@ -255,9 +281,23 @@ window.__ModuleLoader__.load({
255
281
  setBundleUpdate({ state: 'checking', currentVersion: '', message: '正在检查 ANT 更新…' })
256
282
  setApprovedBuilds([])
257
283
  try {
258
- const response = await fetch('/api/ant-teams/bundle-release', { cache: 'no-store' })
259
- const release = await response.json()
284
+ const response = await fetch('/api/ant-teams/bundle-release', {
285
+ cache: 'no-store', headers: { 'x-ant-bundle-capabilities': 'direct-package-install' },
286
+ })
287
+ const release = await readLocalJsonResponse(response)
260
288
  if (!response.ok) throw new Error(release.message || `检查失败(${response.status})`)
289
+ const legacyInstallSpec = `ant@npm:${ANT_BUNDLE_PACKAGE_NAME}@${release.version}`
290
+ const directSpec = directInstallSpec(release)
291
+ const validPackageName = release.package_name === ANT_BUNDLE_PACKAGE_NAME
292
+ const validVersion = typeof release.version === 'string'
293
+ && (release.version === '' || /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/u.test(release.version))
294
+ if (release.bundle_name !== 'ant' || !validPackageName || !validVersion
295
+ || typeof release.install_spec !== 'string' || typeof release.release_notes !== 'string'
296
+ || (release.version && release.install_spec !== legacyInstallSpec)
297
+ || (!release.version && release.install_spec !== '')
298
+ || (release.direct_install_spec !== undefined && release.direct_install_spec !== directSpec)) {
299
+ throw new Error('ANT 更新信息字段无效。')
300
+ }
261
301
  setBundleRelease(release)
262
302
  if (!release.version) {
263
303
  setBundleUpdate({ state: 'none', currentVersion: '', message: '管理员尚未发布 ANT 更新。' })
@@ -267,9 +307,17 @@ window.__ModuleLoader__.load({
267
307
  if (typeof manager?.listBundles !== 'function') throw new Error('当前 DSH 版本未提供官方 Bundle 管理器。')
268
308
  const listed = await manager.listBundles()
269
309
  if (!listed.ok) throw new Error(listed.error?.message || '无法读取 DSH 已安装 Bundle。')
270
- const installed = listed.value.find((bundle) => bundle.name === release.bundle_name && bundle.installed)
310
+ const installed = listed.value.find((bundle) => bundle.name === release.package_name && bundle.installed)
271
311
  if (!installed || typeof installed.version !== 'string') {
272
- throw new Error(`当前 DSH 未找到已安装的 ${release.bundle_name} Bundle。`)
312
+ const legacy = listed.value.find((bundle) => bundle.name === 'ant' && bundle.installed)
313
+ if (legacy) {
314
+ setBundleUpdate({
315
+ state: 'migration-required', currentVersion: legacy.version || '',
316
+ message: `当前安装使用旧 alias(${legacy.name})。请在 DSH 插件管理器卸载旧 ANT,再直接安装 ${release.package_name}@${release.version} 并重启 DSH。若管理器无法完成迁移,请参考 ANT 包内的备用迁移说明。`,
317
+ })
318
+ return
319
+ }
320
+ throw new Error(`当前 DSH 未找到已安装的 ${release.package_name} Bundle。`)
273
321
  }
274
322
  const available = newerVersion(release.version, installed.version)
275
323
  setBundleUpdate({
@@ -283,7 +331,8 @@ window.__ModuleLoader__.load({
283
331
  }
284
332
 
285
333
  const installBundleUpdate = async (allowScripts = false) => {
286
- if (!bundleRelease?.install_spec
334
+ const installSpec = directInstallSpec(bundleRelease)
335
+ if (!installSpec
287
336
  || (!allowScripts && bundleUpdate.state !== 'available')
288
337
  || (allowScripts && approvedBuilds.length === 0)) return
289
338
  const manager = remote?.pluginManager
@@ -297,7 +346,7 @@ window.__ModuleLoader__.load({
297
346
  setApprovedBuilds([])
298
347
  setBundleUpdate((current) => ({ ...current, state: 'installing', message: '正在委托 DSH 官方管理器安装更新…' }))
299
348
  try {
300
- const result = await manager.installBundle(bundleRelease.install_spec, {
349
+ const result = await manager.installBundle(installSpec, {
301
350
  enabled: true,
302
351
  ...(scripts.length ? { approvedBuilds: scripts } : {}),
303
352
  })
@@ -323,15 +372,15 @@ window.__ModuleLoader__.load({
323
372
  setBundleUpdate({ state: 'failed', currentVersion: bundleUpdate.currentVersion, message: 'DSH 已记录更新,但其他插件配置覆盖了 ANT。请检查 DSH 插件配置后再重启。' })
324
373
  return
325
374
  }
326
- if (outcome.bundle !== bundleRelease.bundle_name) {
375
+ if (outcome.bundle !== bundleRelease.package_name) {
327
376
  setBundleUpdate({ state: 'failed', currentVersion: bundleUpdate.currentVersion, message: 'DSH 返回的安装包名称与 ANT 发布信息不一致。请联系管理员核对发布包。' })
328
377
  return
329
378
  }
330
379
  setBundleUpdate({
331
380
  state: 'done', currentVersion: bundleRelease.version,
332
381
  message: outcome.application === 'restart-required'
333
- ? `DSH 已安装 ${bundleRelease.install_spec}。请完全退出并重新启动 DSH 后生效。`
334
- : `DSH 已安装 ${bundleRelease.install_spec}。`,
382
+ ? `DSH 已安装 ${installSpec}。请完全退出并重新启动 DSH 后生效。`
383
+ : `DSH 已安装 ${installSpec}。`,
335
384
  })
336
385
  } catch (error) {
337
386
  setBundleUpdate({ state: 'failed', currentVersion: bundleUpdate.currentVersion, message: error instanceof Error ? error.message : 'DSH 未能安装 ANT 更新。' })
@@ -358,7 +407,7 @@ window.__ModuleLoader__.load({
358
407
  let serverRevoked = false
359
408
  try {
360
409
  const response = await fetch('/api/ant-teams/disconnect', { method: 'POST' })
361
- const result = await response.json()
410
+ const result = await readLocalJsonResponse(response)
362
411
  serverRevoked = response.ok && result.revoked === true
363
412
  } catch {}
364
413
  let credentialRemoved = false
@@ -422,7 +471,7 @@ window.__ModuleLoader__.load({
422
471
  headers: { 'Content-Type': 'application/json' },
423
472
  body: JSON.stringify({ service_url: normalized, email: normalizedEmail }),
424
473
  })
425
- const result = await response.json()
474
+ const result = await readLocalJsonResponse(response)
426
475
  if (!response.ok) throw new Error(result.message || `请求失败(${response.status})`)
427
476
  setEmail(normalizedEmail)
428
477
  setCodeRequested(true)
@@ -458,7 +507,7 @@ window.__ModuleLoader__.load({
458
507
  device_id: loadDeviceId(),
459
508
  }),
460
509
  })
461
- const result = await response.json()
510
+ const result = await readLocalJsonResponse(response)
462
511
  if (!response.ok) throw new Error(result.message || `请求失败(${response.status})`)
463
512
  if (typeof result.token !== 'string' || !result.token.startsWith('antm_') || !Array.isArray(result.models)) {
464
513
  throw new Error('ANT 返回的连接信息格式无效。')
@@ -490,7 +539,7 @@ window.__ModuleLoader__.load({
490
539
  headers: { 'Content-Type': 'application/json' },
491
540
  body: JSON.stringify(body),
492
541
  })
493
- const result = await response.json()
542
+ const result = await readLocalJsonResponse(response)
494
543
  if (!response.ok) throw new Error(result.message || `飞书操作失败(${response.status})`)
495
544
  return result
496
545
  }
@@ -593,7 +642,7 @@ window.__ModuleLoader__.load({
593
642
  h('button', { type: 'button', disabled: busy || bundleUpdate.state === 'checking', onClick: () => void checkBundleUpdate(), style: secondaryButtonStyle }, bundleUpdate.state === 'checking' ? '检查中…' : '检查更新')),
594
643
  bundleUpdate.message ? h('p', { role: 'status', style: mutedTextStyle }, bundleUpdate.message) : null,
595
644
  bundleRelease?.version && bundleUpdate.state === 'available' ? h('div', { style: { display: 'grid', gap: 8 } },
596
- h('p', { style: mutedTextStyle }, `发布版本:${bundleRelease.version} · npm:${bundleRelease.install_spec}`),
645
+ h('p', { style: mutedTextStyle }, `发布版本:${bundleRelease.version} · npm:${directInstallSpec(bundleRelease)}`),
597
646
  bundleRelease.release_notes ? h('p', { style: mutedTextStyle }, bundleRelease.release_notes) : null,
598
647
  h('button', { type: 'button', disabled: busy, onClick: () => void installBundleUpdate(), style: primaryButtonStyle }, busy ? '安装中…' : '由 DSH 安装并更新')) : null,
599
648
  approvedBuilds.length ? h('div', { style: { display: 'grid', gap: 8 } },
@@ -667,7 +716,7 @@ window.__ModuleLoader__.load({
667
716
  }
668
717
  ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
669
718
  name: 'plugins.bundle.config',
670
- key: 'ant',
719
+ key: ANT_BUNDLE_PACKAGE_NAME,
671
720
  inject: () => ({ remote }),
672
721
  }, ({ view, remote: injectedRemote }) => view === 'summary'
673
722
  ? h('p', null, 'ANT 团队服务入口。')
package/cordis.patch.yml CHANGED
@@ -1,3 +1,3 @@
1
1
  - insert:
2
2
  - id: ant
3
- name: ant
3
+ name: '@ai-wendao/ant'
package/index.js CHANGED
@@ -14,6 +14,7 @@ const FEISHU_CLAIM_PATH = '/api/ant-teams/feishu/tasks/claim'
14
14
  const FEISHU_HEARTBEAT_PATH = '/api/ant-teams/feishu/tasks/heartbeat'
15
15
  const FEISHU_COMPLETE_PATH = '/api/ant-teams/feishu/tasks/complete'
16
16
  const FEISHU_FINISH_PATH = '/api/ant-teams/feishu/tasks/finish'
17
+ const ANT_NPM_PACKAGE_NAME = '@ai-wendao/ant'
17
18
  const MAX_MODEL_RESPONSE_BYTES = 1024 * 1024
18
19
  const MODEL_TOKEN_REF = 'ANT_MODEL_API_KEY'
19
20
  const SERVICE_RECORD_KEY = 'ant/team-service'
@@ -245,28 +246,38 @@ async function fetchBundleRelease(request, credentials, settings) {
245
246
  const session = await updateModelSession(connection.serviceUrl, connection.token, 'heartbeat')
246
247
  if (session.status === 401 || session.status === 403) return jsonResponse(401, { message: 'ANT 成员凭证已失效。' })
247
248
  if (!session.ok) return jsonResponse(502, { message: '无法激活 ANT 模型会话。' })
249
+ const headers = { authorization: `Bearer ${connection.token}`, accept: 'application/json' }
250
+ if (request.headers.get('x-ant-bundle-capabilities') === 'direct-package-install') {
251
+ headers['x-ant-bundle-capabilities'] = 'direct-package-install'
252
+ }
248
253
  const upstream = await fetch(new URL('/api/me/bundle-release', connection.serviceUrl), {
249
- headers: { authorization: `Bearer ${connection.token}`, accept: 'application/json' },
254
+ headers,
250
255
  redirect: 'manual',
251
256
  signal: AbortSignal.timeout(10_000),
252
257
  })
253
258
  const text = await readLimitedBody(upstream)
254
259
  if (text === null) return jsonResponse(502, { message: 'ANT 更新信息响应过大。' })
255
260
  if (upstream.status === 401 || upstream.status === 403) return jsonResponse(401, { message: 'ANT 成员凭证已失效。' })
261
+ if (upstream.status === 426) {
262
+ return jsonResponse(426, { message: '当前 ANT 插件版本不能安全自动安装 scoped Bundle。请通过 DSH 官方插件管理器完成一次包名迁移。' })
263
+ }
256
264
  if (!upstream.ok) return jsonResponse(502, { message: 'ANT 暂时无法提供插件更新信息。' })
257
265
  let release
258
266
  try { release = JSON.parse(text) } catch { return jsonResponse(502, { message: 'ANT 更新信息格式无效。' }) }
259
- const validPackageName = typeof release?.package_name === 'string'
260
- && (release.package_name === 'ant' || /^@[a-z0-9][a-z0-9._-]*\/ant$/u.test(release.package_name))
267
+ const validPackageName = release?.package_name === ANT_NPM_PACKAGE_NAME
261
268
  const validVersion = typeof release?.version === 'string'
262
269
  && (release.version === '' || /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/u.test(release.version))
263
270
  const installSpec = release?.package_name === 'ant'
264
271
  ? `ant@${release?.version}`
265
272
  : `ant@npm:${release?.package_name}@${release?.version}`
273
+ const directInstallSpec = release?.package_name && release?.version
274
+ ? `${release.package_name}@${release.version}`
275
+ : ''
266
276
  if (release?.bundle_name !== 'ant' || !validPackageName || !validVersion
267
277
  || typeof release?.install_spec !== 'string' || typeof release?.release_notes !== 'string'
268
278
  || (release.version && release.install_spec !== installSpec)
269
- || (!release.version && release.install_spec !== '')) {
279
+ || (!release.version && release.install_spec !== '')
280
+ || (release.direct_install_spec !== undefined && release.direct_install_spec !== directInstallSpec)) {
270
281
  return jsonResponse(502, { message: 'ANT 更新信息字段无效。' })
271
282
  }
272
283
  return jsonResponse(200, release)
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@ai-wendao/ant",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "AI-native team service for DeepSeek Harness",
5
5
  "type": "module",
6
6
  "scripts": {
7
- "test": "node --test client.test.js index.test.js"
7
+ "test": "node --test client.test.js index.test.js tools/migrate-legacy.test.mjs"
8
+ },
9
+ "bin": {
10
+ "ant-migrate": "tools/migrate-legacy.mjs"
8
11
  },
9
12
  "main": "index.js",
10
13
  "exports": {
@@ -13,7 +16,7 @@
13
16
  "./client": "./client.js",
14
17
  "./locale/*.json": "./locale/*.json"
15
18
  },
16
- "files": ["index.js", "client.js", "cordis.patch.yml", "locale/*.json"],
19
+ "files": ["index.js", "client.js", "cordis.patch.yml", "locale/*.json", "tools/migrate-legacy.mjs", "MIGRATION.md", "README.md"],
17
20
  "dsh": {
18
21
  "bundle": {
19
22
  "patch": "./cordis.patch.yml"
@@ -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
+ })