dsh-vscode-mode 0.11.0 → 0.13.0

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.
Files changed (76) hide show
  1. package/README.md +158 -111
  2. package/assets/shell/dsh-open.cs +65 -149
  3. package/assets/shell/dsh-open.mjs +241 -0
  4. package/assets/shell/dsh-open.ps1 +28 -47
  5. package/assets/shell/dsh-open.sh +25 -66
  6. package/lib/client.js +13362 -11806
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +4110 -2507
  9. package/lib/index.js.map +1 -1
  10. package/package.json +9 -8
  11. package/skills/dsh-vscodemode-mcp/SKILL.md +94 -132
  12. package/src/childEnv.ts +19 -0
  13. package/src/client/activePathStore.ts +28 -0
  14. package/src/client/addToConversation.ts +4 -2
  15. package/src/client/ai/inlineProvider.ts +1 -8
  16. package/src/client/commandBridge.ts +14 -42
  17. package/src/client/externalOpen.ts +40 -19
  18. package/src/client/index.ts +101 -18
  19. package/src/client/keybindings.ts +36 -73
  20. package/src/client/monaco/loader.ts +3 -1
  21. package/src/client/openFlow.ts +36 -28
  22. package/src/client/openReceipt.ts +64 -0
  23. package/src/client/panelCommands.ts +101 -0
  24. package/src/client/pdf/pdfLoader.ts +4 -2
  25. package/src/client/receiptState.ts +120 -0
  26. package/src/client/rpc.ts +30 -4
  27. package/src/client/shortcutRecorder.ts +161 -0
  28. package/src/client/shortcutsOfficial.ts +460 -0
  29. package/src/client/sidebar/panels/RulesPanel.ts +15 -11
  30. package/src/client/sidebarBridge.ts +7 -0
  31. package/src/client/ui/DiffBadge.ts +2 -1
  32. package/src/client/ui/EditorView.ts +246 -137
  33. package/src/client/ui/McpSettings.ts +10 -8
  34. package/src/client/ui/OfficialSideTab.ts +5 -2
  35. package/src/client/ui/QuickOpen.ts +16 -16
  36. package/src/client/ui/ShortcutSettings.ts +274 -0
  37. package/src/client/ui/commandCatalog.ts +39 -43
  38. package/src/client/useOpenReceipt.ts +128 -0
  39. package/src/compat.ts +5 -3
  40. package/src/dap/manager.ts +46 -13
  41. package/src/devForm.ts +252 -92
  42. package/src/externalHandoff.ts +93 -52
  43. package/src/fileOpenSettings.ts +4 -24
  44. package/src/hostImport.ts +19 -0
  45. package/src/httpGuard.ts +101 -0
  46. package/src/index.ts +106 -97
  47. package/src/integrate.ts +56 -50
  48. package/src/lsp/transport.ts +84 -127
  49. package/src/mcp.ts +58 -9
  50. package/src/mcpIsolation.ts +211 -102
  51. package/src/mcpProject.ts +220 -133
  52. package/src/mcpRuntime.ts +343 -0
  53. package/src/openBridge.ts +69 -0
  54. package/src/openInbox.ts +207 -0
  55. package/src/paths.ts +8 -5
  56. package/src/processTree.ts +63 -0
  57. package/src/reveal.ts +5 -3
  58. package/src/routes.ts +156 -154
  59. package/src/rpc.ts +14 -13
  60. package/src/rules.ts +100 -44
  61. package/src/shared/appUrl.ts +19 -0
  62. package/src/shared/externalOpen.ts +71 -0
  63. package/src/shared/integration.ts +2 -0
  64. package/src/shared/keybindings.ts +256 -0
  65. package/src/shared/mcp.ts +4 -1
  66. package/src/shared/rpc.ts +7 -4
  67. package/src/shared/rules.ts +9 -1
  68. package/src/shared/svn.ts +6 -8
  69. package/src/svnPlanTool.ts +51 -0
  70. package/src/unityBridge.ts +26 -3
  71. package/unity/com.dsh.editor/Editor/DshCodeEditor.cs +99 -203
  72. package/unity/com.dsh.editor/Editor/DshOpenWorker.cs +166 -0
  73. package/unity/com.dsh.editor/Editor/DshOpenWorker.cs.meta +2 -0
  74. package/unity/com.dsh.editor/README.md +16 -25
  75. package/unity/com.dsh.editor/package.json +2 -2
  76. package/src/client/ui/KeybindingsPanel.ts +0 -190
@@ -0,0 +1,128 @@
1
+ /** Correlated editor readiness for external file opens. @author ddj 2026年09月28号 */
2
+ import React from 'react'
3
+ import { finishOpen, hasOpen, peekOpen } from './openReceipt.js'
4
+ import type { EditorRequest } from './openReceipt.js'
5
+
6
+ export interface ReceiptHost {
7
+ sessionId: string
8
+ open(request: EditorRequest): void | Promise<void>
9
+ ready(request: EditorRequest): boolean
10
+ error(request: EditorRequest): string | null
11
+ }
12
+
13
+ /** One mounted editor owns at most one claimed external request. */
14
+ export class OpenMonitor {
15
+ private pending: EditorRequest | undefined
16
+ private frame: number | undefined
17
+ private stopped = false
18
+ private opening = false
19
+ /** @private @author ddj 2026年09月28号 Claim a request arriving after mount. */
20
+ private listener = (): void => { this.claim() }
21
+
22
+ /** @author ddj 2026年09月28号 @param read Read current component state without a stale closure. */
23
+ constructor(private readonly read: () => ReceiptHost) {}
24
+
25
+ /** @public @author ddj 2026年09月28号 Attach the listener and claim a pre-mount request. */
26
+ start(): void {
27
+ window.addEventListener('edrv:open-request', this.listener)
28
+ this.claim()
29
+ }
30
+
31
+ /** @public @author ddj 2026年09月28号 Fail claimed work and cancel the frame on unmount. */
32
+ stop(): void {
33
+ this.stopped = true
34
+ window.removeEventListener('edrv:open-request', this.listener)
35
+ if (this.frame !== undefined) cancelAnimationFrame(this.frame)
36
+ this.frame = undefined
37
+ if (this.pending) finishOpen(this.pending.requestId, false, '编辑器已关闭')
38
+ this.pending = undefined
39
+ }
40
+
41
+ /** @private @author ddj 2026年09月28号 Schedule at most one readiness/queue check. */
42
+ private arm(): void {
43
+ if (!this.stopped && this.frame === undefined) this.frame = requestAnimationFrame(this.check)
44
+ }
45
+
46
+ /**
47
+ * Settle only the request this instance still owns; queued work runs next frame.
48
+ * @private @author ddj 2026年09月28号
49
+ * @param request Claimed request, captured before asynchronous work.
50
+ * @param success Whether actual readiness was confirmed.
51
+ * @param error Failure description.
52
+ */
53
+ private finish(request: EditorRequest, success: boolean, error?: string): void {
54
+ if (this.stopped || this.pending !== request) return
55
+ this.pending = undefined
56
+ this.opening = false
57
+ if (hasOpen(request.requestId)) finishOpen(request.requestId, success, error)
58
+ this.arm()
59
+ }
60
+
61
+ /**
62
+ * Observe startup without letting a late completion affect a new claim.
63
+ * @private @author ddj 2026年09月28号
64
+ * @param request Startup's request identity.
65
+ * @param operation Optional asynchronous editor startup.
66
+ */
67
+ private watchOpen(request: EditorRequest, operation: void | Promise<void>): void {
68
+ if (!operation) return
69
+ this.opening = true
70
+ /** @author ddj 2026年09月28号 Release readiness only for this live claim. */
71
+ const ready = (): void => {
72
+ if (!this.stopped && this.pending === request) this.opening = false
73
+ }
74
+ /** @author ddj 2026年09月28号 @param error Correlated startup rejection. */
75
+ const fail = (error: unknown): void => this.finish(request, false, String(error))
76
+ void Promise.resolve(operation).then(ready, fail)
77
+ }
78
+
79
+ /** @private @author ddj 2026年09月28号 Claim this session; exceptions fail the exact request. */
80
+ private claim(): void {
81
+ if (this.stopped || (this.pending && hasOpen(this.pending.requestId))) return
82
+ this.pending = undefined
83
+ this.opening = false
84
+ try {
85
+ const host = this.read()
86
+ const request = peekOpen(host.sessionId)
87
+ if (!request) return
88
+ this.pending = request
89
+ this.watchOpen(request, host.open(request))
90
+ this.arm()
91
+ } catch (error) {
92
+ if (this.pending) this.finish(this.pending, false, String(error))
93
+ }
94
+ }
95
+
96
+ /** @private @author ddj 2026年09月28号 Check readiness; expired claims and exceptions cannot ACK later work. */
97
+ private check = (): void => {
98
+ this.frame = undefined
99
+ if (this.stopped) return
100
+ const request = this.pending
101
+ if (!request || !hasOpen(request.requestId)) { this.pending = undefined; this.claim(); return }
102
+ try {
103
+ const host = this.read()
104
+ if (request.sessionId !== host.sessionId) { this.finish(request, false, '会话已切换'); return }
105
+ if (this.opening) { this.arm(); return }
106
+ const error = host.error(request)
107
+ if (error || host.ready(request)) { this.finish(request, !error, error ?? undefined); return }
108
+ this.arm()
109
+ } catch (error) { this.finish(request, false, String(error)) }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Bind a component while reading its latest model state.
115
+ * @author ddj 2026年09月28号
116
+ * @param host Mounted editor actions and readiness checks.
117
+ */
118
+ export function useOpenReceipt(host: ReceiptHost): void {
119
+ const latest = React.useRef(host)
120
+ latest.current = host
121
+ /** @author ddj 2026年09月28号 @returns Listener and animation-frame cleanup. */
122
+ function mount(): () => void {
123
+ const monitor = new OpenMonitor(() => latest.current)
124
+ monitor.start()
125
+ return () => monitor.stop()
126
+ }
127
+ React.useEffect(mount, [host.sessionId])
128
+ }
package/src/compat.ts CHANGED
@@ -132,8 +132,10 @@ export function detectGuards(ctx: Ctx): CompatAdapter[] {
132
132
  ]
133
133
  }
134
134
 
135
- /** 已实测覆盖的最高 DSH 版本(适配矩阵上界,超过则提示,见 buildReport)。 */
136
- const TESTED_DSH_MAX = '0.1.7-alpha.2'
135
+ /** 已实测覆盖的最高 DSH 版本(适配矩阵上界,超过则提示,见 buildReport)。
136
+ * 0.1.7-rc.2 已在官方已签名打包 Desktop 上实机装配(认证路由、同源 RPC、项目 MCP
137
+ * 与私有打开队列均生效);更高版本仍按能力探测运行并提示。 */
138
+ const TESTED_DSH_MAX = '0.1.7-rc.2'
137
139
 
138
140
  /** Config volatile 标记起效的版本线(此前设置走 section 安装,与 volatile 无关)。 */
139
141
  const CONFIG_VOLATILE_MIN = '0.1.7-alpha.1'
@@ -210,7 +212,7 @@ export async function buildReport(
210
212
  guards,
211
213
  adapters,
212
214
  warnings,
213
- devForm: readDevForm(),
215
+ devForm: readDevForm(ctx),
214
216
  dshVersion,
215
217
  }
216
218
  }
@@ -18,6 +18,8 @@
18
18
  * 作者 ddj 2026年09月29号 / 2026年09月21号
19
19
  */
20
20
  import { spawn, type ChildProcess } from 'node:child_process'
21
+ import { childEnv } from '../childEnv.js'
22
+ import { stopTree } from '../processTree.js'
21
23
  import { join } from 'node:path'
22
24
  import { createFrameParser, encodeMessage } from '../lsp/jsonrpc.js'
23
25
  import { sourceBaseOf, sourceKeyOf, sourcePathOf, normalizeSourcePath } from './sourcePath.js'
@@ -288,9 +290,18 @@ export class DapSession {
288
290
 
289
291
  // ---------------------------------------------------------------- 内部实现
290
292
 
291
- /** spawn 适配器进程并接好帧解析。 */
293
+ /**
294
+ * 启动独立适配器进程组并接入协议,清理隐式宿主凭据。
295
+ * @private
296
+ * @author ddj 2026年09月28号
297
+ * @param spec 适配器规格
298
+ * @throws spawn 同步失败
299
+ */
292
300
  private spawnAdapter(spec: DapAdapterSpec): void {
293
- const child = spawn(spec.command, spec.args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
301
+ const child = spawn(spec.command, spec.args, {
302
+ stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
303
+ detached: process.platform !== 'win32', env: this.adapterEnv(),
304
+ })
294
305
  this.child = child
295
306
  const parser = createFrameParser()
296
307
  child.stdout?.on('data', (chunk: Buffer) => {
@@ -300,11 +311,16 @@ export class DapSession {
300
311
  log.debug('[dap-adapter] ' + chunk.toString('utf8').trim())
301
312
  this.stderrTail = (this.stderrTail + chunk.toString('utf8')).slice(-STDERR_TAIL_CAP)
302
313
  })
303
- child.on('error', (error) => this.fail('适配器进程错误:' + String(error)))
314
+ child.on('error', (error) => {
315
+ if (this.child === child) this.fail('适配器进程错误:' + String(error))
316
+ })
317
+ child.stdin?.on('error', (error) => {
318
+ if (this.child === child) this.fail('适配器 stdin 错误:' + String(error))
319
+ })
304
320
  child.on('exit', (code) => {
305
- if (this.child === child && this.phase !== 'idle' && this.phase !== 'terminated') {
306
- this.fail('适配器退出(code=' + code + ')' + this.stderrSuffix())
307
- }
321
+ if (this.child !== child) return
322
+ if (this.phase === 'idle' || this.phase === 'terminated') this.killChild()
323
+ else this.fail('适配器退出(code=' + code + ')' + this.stderrSuffix())
308
324
  })
309
325
  }
310
326
 
@@ -529,18 +545,35 @@ export class DapSession {
529
545
  this.setPhase('terminated')
530
546
  }
531
547
 
532
- /** 杀适配器进程树(Windows taskkill /T /F,同 LSP transport 口径)。 */
548
+ /**
549
+ * 清理父环境后合并 launch 配置显式 env,null 表示移除该项。
550
+ * @private
551
+ * @author ddj 2026年09月28号
552
+ * @returns 适配器的环境副本
553
+ */
554
+ private adapterEnv(): NodeJS.ProcessEnv {
555
+ const explicit: NodeJS.ProcessEnv = {}
556
+ const raw = this.config?.raw?.env
557
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
558
+ for (const [key, value] of Object.entries(raw)) {
559
+ if (typeof value === 'string' || value === null) explicit[key] = value ?? undefined
560
+ }
561
+ }
562
+ return childEnv(explicit)
563
+ }
564
+
565
+ /**
566
+ * 解除会话拥有权并终止适配器进程树;不先杀根进程。
567
+ * @private
568
+ * @author ddj 2026年09月28号
569
+ * @description 同时清理所有待处理请求与计时器。
570
+ */
533
571
  private killChild(): void {
534
572
  const child = this.child
535
573
  this.child = null
536
574
  for (const entry of this.pending.values()) { clearTimeout(entry.timer); entry.resolve(null) }
537
575
  this.pending.clear()
538
- if (!child) return
539
- try { child.kill() } catch { /* 忽略 */ }
540
- if (process.platform === 'win32' && child.pid) {
541
- try { spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }) } catch { /* 忽略 */ }
542
- }
543
- setTimeout(() => { try { child.kill('SIGKILL') } catch { /* 忽略 */ } }, 1500).unref?.()
576
+ if (child) stopTree(child, (line) => log.warn('[dap] ' + line))
544
577
  }
545
578
 
546
579
  /** 相位切换(running→running 等幂等跳过)。 */
package/src/devForm.ts CHANGED
@@ -1,133 +1,293 @@
1
- /**
2
- * dsh-vscode-mode host — 开发形态管理(安装形态切换)。
3
- * 开发形态 = profile 中以 link: 依赖 + junction 指向工作区的安装(用于插件开发);
4
- * 关闭 = 改回版本依赖 + 删 junction + pnpm install 装配正式版。
5
- * 切换后需重启 DSH 生效(运行中的代码仍是旧形态)。
6
- * 与 compat.ts 存在模块环(devForm 用 pluginVersionOf、compat 用 readDevForm),
7
- * 双方仅在函数体内交叉使用,模块求值期无依赖,ESM 安全。
8
- * 作者 ddj 2026-08-24
9
- */
10
- import { existsSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
11
- import { dirname, join } from 'node:path'
12
- import { homedir } from 'node:os'
1
+ /** 当前运行 profile 的开发形态切换;失败恢复 manifest、lockfile 与原安装入口。 */
2
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
3
+ import { isAbsolute, join, relative, resolve } from 'node:path'
13
4
  import { PLUGIN_NAME, pluginVersionOf } from './compat.js'
5
+ import { dshHome } from './paths.js'
6
+ import { childEnv } from './childEnv.js'
14
7
  import type { DevFormInfo } from './shared/compat.js'
15
8
  import type { Ctx } from './store.js'
16
9
 
17
- /** 扫描 $DSH_HOME/profiles 找到依赖本插件的 profile 目录(无 env 依赖,多 profile 安全)。 */
18
- export function findProfileDir(): string | undefined {
19
- const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
10
+ /** 官方 dsh profile-boot 提供的当前运行事实;不从 inventory 猜测。 */
11
+ interface ProfileContext {
12
+ dir: string
13
+ packageManager?: { command: string; args?: string[]; env?: NodeJS.ProcessEnv }
14
+ }
15
+ type SwitchResult = { ok: boolean; error?: string; restart: boolean }
16
+ interface ProfileBackup {
17
+ dir: string
18
+ link: string
19
+ savedLink: string
20
+ hadLink: boolean
21
+ files: { path: string; text: Buffer | undefined }[]
22
+ }
23
+ const switching = new Set<string>()
24
+
25
+ // --region 运行上下文
26
+ /**
27
+ * 获取官方 profileContext 服务;服务存在但无有效目录时拒绝猜测。
28
+ * @private
29
+ * @author ddj 2026年09月28号
30
+ * @param ctx 可选运行上下文
31
+ * @returns 官方服务对象或缺失
32
+ * @throws 服务对象目录无效
33
+ */
34
+ function profileOf(ctx?: Ctx): ProfileContext | undefined {
35
+ const profile = ctx?.get?.('profileContext')
36
+ if (profile === undefined || profile === null) return undefined
37
+ if (typeof profile.dir !== 'string' || !isAbsolute(profile.dir)) throw new Error('当前 profileContext.dir 无效')
38
+ return profile as ProfileContext
39
+ }
40
+
41
+ /**
42
+ * 检查 manifest 是否明确依赖本插件;坏 manifest 不作候选。
43
+ * @private
44
+ * @author ddj 2026年09月28号
45
+ * @param dir profile 目录
46
+ * @returns 是否包含插件依赖
47
+ */
48
+ function ownsPlugin(dir: string): boolean {
20
49
  try {
21
- for (const name of readdirSync(join(home, 'profiles'))) {
22
- const pkgFile = join(home, 'profiles', name, 'package.json')
23
- if (!existsSync(pkgFile)) continue
24
- try {
25
- const pkg = JSON.parse(readFileSync(pkgFile, 'utf8')) as { dependencies?: Record<string, unknown> } | undefined
26
- if (pkg?.dependencies?.[PLUGIN_NAME]) return dirname(pkgFile)
27
- } catch {
28
- /* 坏 manifest 跳过该 profile */
29
- }
30
- }
31
- } catch {
32
- /* profiles 目录不存在时放弃 */
33
- }
34
- return undefined
50
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8').replace(/^\uFEFF/, ''))
51
+ return typeof pkg?.dependencies?.[PLUGIN_NAME] === 'string'
52
+ } catch { return false }
35
53
  }
36
54
 
37
55
  /**
38
- * 读取当前开发形态(manifest link: 判定)。
39
- * @author ddj 2026年08月24号
40
- * @returns 开发形态状态
56
+ * 优先使用真实运行 profile;旧宿主仅允许唯一候选回退,多候选拒绝。
57
+ * @public
58
+ * @author ddj 2026年09月28号
59
+ * @param ctx DSH host 上下文(可选,旧宿主兼容)
60
+ * @returns 当前 profile 目录;无有效或唯一候选时 undefined
41
61
  */
42
- export function readDevForm(): DevFormInfo {
43
- const profileDir = findProfileDir()
44
- if (!profileDir) return { enabled: false }
62
+ export function findProfileDir(ctx?: Ctx): string | undefined {
45
63
  try {
46
- const text = readFileSync(join(profileDir, 'package.json'), 'utf8').replace(/^\uFEFF/, '')
47
- const pkg = JSON.parse(text) as { dependencies?: Record<string, unknown> } | undefined
64
+ const profile = profileOf(ctx)
65
+ if (profile) return ownsPlugin(profile.dir) ? profile.dir : undefined
66
+ const homePath = ctx?.get?.('dshHomePath')
67
+ const root = join(typeof homePath === 'function' ? homePath() : dshHome(), 'profiles')
68
+ const candidates = readdirSync(root).map((name) => join(root, name)).filter(ownsPlugin)
69
+ return candidates.length === 1 ? candidates[0] : undefined
70
+ } catch { return undefined }
71
+ }
72
+
73
+ /**
74
+ * 读取选中 profile 的 link 依赖状态。
75
+ * @private
76
+ * @author ddj 2026年09月28号
77
+ * @param dir 已选定目录
78
+ * @returns 开发形态;无法读取时未开启
79
+ */
80
+ function readProfileForm(dir: string): DevFormInfo {
81
+ try {
82
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8').replace(/^\uFEFF/, ''))
48
83
  const spec = pkg?.dependencies?.[PLUGIN_NAME]
49
- if (typeof spec === 'string' && spec.startsWith('link:')) {
50
- return { enabled: true, path: spec.slice('link:'.length) }
51
- }
52
- } catch {
53
- /* manifest 不可读视为非开发形态 */
54
- }
84
+ if (typeof spec === 'string' && spec.startsWith('link:')) return { enabled: true, path: spec.slice(5) }
85
+ } catch { /* 只读探测失败 */ }
55
86
  return { enabled: false }
56
87
  }
57
88
 
58
89
  /**
59
- * 规划切换后的 manifest 文本(纯函数,可单测;非法 JSON 抛错)。
60
- * @author ddj 2026年08月24号
90
+ * 读取当前运行 profile 的开发形态。
91
+ * @public
92
+ * @author ddj 2026年09月28号
93
+ * @param ctx DSH host 上下文
94
+ * @returns 开发形态状态
95
+ */
96
+ export function readDevForm(ctx?: Ctx): DevFormInfo {
97
+ const dir = findProfileDir(ctx)
98
+ return dir ? readProfileForm(dir) : { enabled: false }
99
+ }
100
+ // --endregion
101
+
102
+ /**
103
+ * 规划切换后的 manifest,保留其它字段。
104
+ * @public
105
+ * @author ddj 2026年09月28号
61
106
  * @param text 原 manifest 文本
62
- * @param enabled 目标形态(true=link:,false=版本)
63
- * @param path 工作区路径(开启时必填)
64
- * @param version 版本号(关闭时使用,写入 ^<version>)
65
- * @returns 新 manifest 文本(无 BOM,2 空格缩进)
107
+ * @param enabled true 为开发链接
108
+ * @param path 开发目录
109
+ * @param version 正式版本号
110
+ * @returns 新 manifest 文本
111
+ * @throws JSON 或 dependencies 无效
66
112
  */
67
113
  export function planManifest(text: string, enabled: boolean, path: string, version: string): string {
68
114
  const data = JSON.parse(text.replace(/^\uFEFF/, '')) as { dependencies?: Record<string, unknown> }
69
- if (!data.dependencies || typeof data.dependencies !== 'object') {
115
+ if (!data.dependencies || typeof data.dependencies !== 'object' || Array.isArray(data.dependencies)) {
70
116
  throw new Error('profile package.json 缺少 dependencies 字段')
71
117
  }
72
118
  data.dependencies[PLUGIN_NAME] = enabled ? 'link:' + path.replace(/\\/g, '/') : '^' + version
73
119
  return JSON.stringify(data, null, 2) + '\n'
74
120
  }
75
121
 
76
- /** 建立或移除 node_modules 下的开发 junction(目标不存在时开启失败)。 */
77
- function applyJunction(profileDir: string, enabled: boolean, target: string): void {
78
- const linkDir = join(profileDir, 'node_modules', PLUGIN_NAME)
79
- if (existsSync(linkDir)) rmSync(linkDir, { recursive: true, force: true })
80
- if (!enabled) return
81
- if (!existsSync(target)) throw new Error('工作区路径不存在:' + target)
82
- symlinkSync(target, linkDir, 'junction')
122
+ // --region 切换事务
123
+ /**
124
+ * 检查开发目录属于本插件且不位于会被替换的 node_modules 内。
125
+ * @private
126
+ * @author ddj 2026年09月28号
127
+ * @param dir profile 目录
128
+ * @param target 用户目标路径
129
+ * @returns 真实绝对目录
130
+ * @throws 目录、包身份或位置不安全
131
+ */
132
+ function checkTarget(dir: string, target: string): string {
133
+ if (!target || !isAbsolute(target)) throw new Error('开启开发形态需要提供工作区绝对路径')
134
+ const full = realpathSync(target)
135
+ if (!statSync(full).isDirectory()) throw new Error('工作区路径不是目录:' + target)
136
+ const pkg = JSON.parse(readFileSync(join(full, 'package.json'), 'utf8').replace(/^\uFEFF/, ''))
137
+ if (pkg?.name !== PLUGIN_NAME) throw new Error('工作区 package.json.name 必须是 ' + PLUGIN_NAME)
138
+ const modules = join(dir, 'node_modules')
139
+ const moduleRoot = existsSync(modules) ? realpathSync(modules) : resolve(realpathSync(dir), 'node_modules')
140
+ const inside = relative(moduleRoot, full)
141
+ if (!inside || (!inside.startsWith('..' + (process.platform === 'win32' ? '\\' : '/')) && !isAbsolute(inside))) {
142
+ throw new Error('开发源码不能位于当前 profile 的 node_modules 内')
143
+ }
144
+ return full
145
+ }
146
+
147
+ /**
148
+ * 删除安装入口;链接仅 unlink,绝不递归其源码目标。
149
+ * @private
150
+ * @author ddj 2026年09月28号
151
+ * @param path 安装入口或备份入口
152
+ * @throws 删除失败
153
+ */
154
+ function removeEntry(path: string): void {
155
+ const info = lstatSync(path, { throwIfNoEntry: false })
156
+ if (!info) return
157
+ if (info.isSymbolicLink()) unlinkSync(path)
158
+ else rmSync(path, { recursive: true, force: true })
159
+ }
160
+
161
+ /**
162
+ * 在首次写入前保存 manifest/lockfile 原字节,并移动原安装入口保留链接身份。
163
+ * @private
164
+ * @author ddj 2026年09月28号
165
+ * @param dir profile 目录
166
+ * @returns 可回滚的备份
167
+ * @throws 备份失败;尚未修改 manifest
168
+ */
169
+ function backupProfile(dir: string): ProfileBackup {
170
+ const files = ['package.json', 'pnpm-lock.yaml'].map((name) => {
171
+ const path = join(dir, name)
172
+ return { path, text: existsSync(path) ? readFileSync(path) : undefined }
173
+ })
174
+ const backupDir = mkdtempSync(join(dir, '.vscode-mode-backup-'))
175
+ const link = join(dir, 'node_modules', PLUGIN_NAME)
176
+ const savedLink = join(backupDir, 'module')
177
+ const hadLink = !!lstatSync(link, { throwIfNoEntry: false })
178
+ try {
179
+ for (const file of files) {
180
+ if (file.text !== undefined) writeFileSync(join(backupDir, relative(dir, file.path)), file.text)
181
+ }
182
+ if (hadLink) renameSync(link, savedLink)
183
+ return { dir: backupDir, link, savedLink, hadLink, files }
184
+ } catch (error) {
185
+ rmSync(backupDir, { recursive: true, force: true })
186
+ throw error
187
+ }
83
188
  }
84
189
 
85
190
  /**
86
- * 切换开发形态:改 manifest → 建/删 junction → pnpm install(subprocess)。
87
- * @author ddj 2026年08月24号
88
- * @param ctx DSH host 上下文(subprocess 服务)
191
+ * 恢复原安装入口和配置文件;恢复失败时保留备份供恢复。
192
+ * @private
193
+ * @author ddj 2026年09月28号
194
+ * @param saved 原状态备份
195
+ * @throws 回滚失败,错误包含备份位置
196
+ */
197
+ function restoreProfile(saved: ProfileBackup): void {
198
+ const failures: string[] = []
199
+ try {
200
+ removeEntry(saved.link)
201
+ if (saved.hadLink) renameSync(saved.savedLink, saved.link)
202
+ } catch (error) { failures.push(String(error)) }
203
+ for (const file of saved.files) {
204
+ try {
205
+ if (file.text === undefined) rmSync(file.path, { force: true })
206
+ else writeFileSync(file.path, file.text)
207
+ } catch (error) { failures.push(String(error)) }
208
+ }
209
+ if (failures.length) throw new Error('回滚失败,备份保留于 ' + saved.dir + ':' + failures.join('; '))
210
+ rmSync(saved.dir, { recursive: true, force: true })
211
+ }
212
+
213
+ /**
214
+ * 执行已预检的切换;安装失败时恢复原入口及 manifest/lockfile。
215
+ * @private
216
+ * @author ddj 2026年09月28号
217
+ * @param ctx host 上下文
218
+ * @param dir 已确定的 profile
89
219
  * @param enabled 目标形态
90
- * @param path 工作区绝对路径(开启时必填)
91
- * @returns 切换结果(restart=true 表示需重启 DSH 生效)
220
+ * @param target 已预检目标
221
+ * @returns 事务结果;成功需重启
92
222
  */
93
- export async function setDevForm(ctx: Ctx, enabled: boolean, path?: string): Promise<{ ok: boolean; error?: string; restart: boolean }> {
94
- const profileDir = findProfileDir()
95
- if (!profileDir) return { ok: false, error: '未找到依赖本插件的 profile(检查 DSH_HOME/profiles)', restart: false }
96
- const current = readDevForm()
97
- const target = (path ?? current.path ?? '').replace(/\\/g, '/')
98
- if (enabled && !target) return { ok: false, error: '开启开发形态需要提供工作区路径(path 参数)', restart: false }
99
- if (enabled === current.enabled && (!enabled || target === current.path)) {
100
- return { ok: true, restart: false }
223
+ async function switchProfile(ctx: Ctx, dir: string, enabled: boolean, target: string): Promise<SwitchResult> {
224
+ const version = pluginVersionOf()
225
+ if (!version) throw new Error('读取插件版本失败,无法写入版本依赖')
226
+ const next = planManifest(readFileSync(join(dir, 'package.json'), 'utf8'), enabled, target, version)
227
+ const saved = backupProfile(dir)
228
+ try {
229
+ writeFileSync(join(dir, 'package.json'), next, 'utf8')
230
+ mkdirSync(join(dir, 'node_modules'), { recursive: true })
231
+ if (enabled) symlinkSync(target, saved.link, 'junction')
232
+ await runPnpmInstall(ctx, dir)
233
+ } catch (error) {
234
+ restoreProfile(saved)
235
+ throw error
101
236
  }
102
237
  try {
103
- const pkgFile = join(profileDir, 'package.json')
104
- const version = pluginVersionOf()
105
- if (!version) return { ok: false, error: '读取插件版本失败,无法写入版本依赖', restart: false }
106
- const next = planManifest(readFileSync(pkgFile, 'utf8').replace(/^\uFEFF/, ''), enabled, target, version)
107
- writeFileSync(pkgFile, next, 'utf8')
108
- applyJunction(profileDir, enabled, target)
109
- const installError = await runPnpmInstall(ctx, profileDir)
110
- return { ok: !installError, error: installError, restart: true }
238
+ removeEntry(saved.savedLink)
239
+ rmSync(saved.dir, { recursive: true, force: true })
111
240
  } catch (error) {
112
- return { ok: false, error: String(error), restart: false }
241
+ return { ok: true, error: '切换成功,旧安装备份清理失败:' + saved.dir + ':' + String(error), restart: true }
113
242
  }
243
+ return { ok: true, restart: true }
114
244
  }
115
245
 
116
- /** 在 profile 目录执行 pnpm install(subprocess 契约:spawn({argv,cwd,stdio,graceMs}))。 */
117
- async function runPnpmInstall(ctx: Ctx, profileDir: string): Promise<string | undefined> {
118
- const sub = ctx.get('subprocess')
119
- if (!sub || typeof sub.spawn !== 'function') return 'manifest/junction 已更新,但缺少 subprocess:请手动在 profile 目录运行 pnpm install'
120
- const handle = sub.spawn({
121
- argv: ['pnpm', 'install'],
246
+ /**
247
+ * 预检运行 profile、目标与安装服务,再串行执行可回滚切换。
248
+ * @public
249
+ * @author ddj 2026年09月28号
250
+ * @param ctx DSH host 上下文
251
+ * @param enabled 目标形态
252
+ * @param path 开发工作区绝对路径
253
+ * @returns 结果与是否需重启;失败不提交切换
254
+ */
255
+ export async function setDevForm(ctx: Ctx, enabled: boolean, path?: string): Promise<SwitchResult> {
256
+ const dir = findProfileDir(ctx)
257
+ if (!dir) return { ok: false, error: '无法唯一定位当前 profile;请检查 profileContext 或 DSH_HOME/profiles', restart: false }
258
+ const key = process.platform === 'win32' ? resolve(dir).toLowerCase() : resolve(dir)
259
+ if (switching.has(key)) return { ok: false, error: '该 profile 正在切换开发形态', restart: false }
260
+ switching.add(key)
261
+ try {
262
+ const current = readProfileForm(dir)
263
+ const raw = path ?? current.path ?? ''
264
+ const target = enabled ? checkTarget(dir, raw) : ''
265
+ if (enabled === current.enabled && (!enabled || resolve(dir, current.path ?? '') === target)) return { ok: true, restart: false }
266
+ if (typeof ctx?.get?.('subprocess')?.spawn !== 'function') throw new Error('缺少 subprocess 服务,未修改 profile')
267
+ return await switchProfile(ctx, dir, enabled, target)
268
+ } catch (error) {
269
+ return { ok: false, error: String(error), restart: false }
270
+ } finally { switching.delete(key) }
271
+ }
272
+
273
+ /**
274
+ * 使用官方 profileContext.packageManager 运行 Desktop 内置 pnpm;旧宿主使用 PATH。
275
+ * @private
276
+ * @author ddj 2026年09月28号
277
+ * @param ctx host 上下文
278
+ * @param profileDir profile 工作目录
279
+ * @throws 启动失败或非零退出;只需退出码,stdio 使用 inherit
280
+ */
281
+ async function runPnpmInstall(ctx: Ctx, profileDir: string): Promise<void> {
282
+ const runner = profileOf(ctx)?.packageManager
283
+ const handle = ctx.get('subprocess').spawn({
284
+ argv: [runner?.command ?? 'pnpm', ...runner?.args ?? [], 'install'],
122
285
  cwd: profileDir,
123
- stdio: { stdout: { maxBytes: 1 << 16 }, stderr: { maxBytes: 1 << 16 }, stdin: 'ignore' },
286
+ env: childEnv(runner?.env),
287
+ stdio: { stdout: 'inherit', stderr: 'inherit', stdin: 'ignore' },
124
288
  graceMs: 120000,
125
289
  })
126
290
  const outcome = await handle.done
127
- const code = outcome?.exitCode ?? outcome?.code
128
- if (code !== 0) {
129
- const stderr = handle.collected?.stderr?.readFrom(0).text ?? ''
130
- return 'pnpm install 失败(exit ' + code + '):' + stderr.slice(0, 500)
131
- }
132
- return undefined
291
+ if (outcome?.exitCode !== 0) throw new Error('pnpm install 失败(exit ' + String(outcome?.exitCode) + ')')
133
292
  }
293
+ // --endregion