@mengruo/dsh-vision-toolkit 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +356 -0
  4. package/README.zh.md +354 -0
  5. package/assets/community-group-qr.png +0 -0
  6. package/assets/dsh-conversation-artifact.png +0 -0
  7. package/assets/dsh-conversation-image-qa-top.png +0 -0
  8. package/assets/dsh-conversation-image-qa.png +0 -0
  9. package/assets/dsh-conversation-pixel-diff.png +0 -0
  10. package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
  11. package/assets/dsh-conversation-screenshot-debugging.png +0 -0
  12. package/assets/dsh-conversation-tool-call.png +0 -0
  13. package/assets/dsh-conversation-vision-trace.png +0 -0
  14. package/assets/dsh-view-example.png +0 -0
  15. package/assets/hero-v2.png +0 -0
  16. package/assets/logo_aihubmix.png +0 -0
  17. package/assets/python-bootstrap.json +48 -0
  18. package/assets/skill/SKILL.md +329 -0
  19. package/assets/skill/UPSTREAM.json +71 -0
  20. package/assets/skill/references/gui.md +88 -0
  21. package/assets/skill/references/long-screenshot-ocr.md +77 -0
  22. package/assets/skill/references/restore-graphic.md +84 -0
  23. package/assets/skill/references/restore-structure.md +45 -0
  24. package/assets/skill/references/restore-ui.md +202 -0
  25. package/assets/social-preview.png +0 -0
  26. package/assets/upstream/README.md +18 -0
  27. package/assets/upstream/focus-hint-comparison-1.webp +0 -0
  28. package/assets/upstream/focus-hint-comparison-2.webp +0 -0
  29. package/assets/upstream/infographic-reference.webp +0 -0
  30. package/assets/upstream/infographic-result.webp +0 -0
  31. package/assets/upstream/ui-fast-restore-reference.webp +0 -0
  32. package/assets/upstream/ui-fast-restore-result.webp +0 -0
  33. package/assets/upstream/ui-result.webp +0 -0
  34. package/assets/upstream/ui-sketch.webp +0 -0
  35. package/assets/vision-model-test.png +0 -0
  36. package/assets/vision-settings.png +0 -0
  37. package/assets/wechat-reward.png +0 -0
  38. package/cordis.patch.yml +6 -0
  39. package/docs/aihubmix-gemini-vision.i18n.yaml +6 -0
  40. package/docs/aihubmix-gemini-vision.md +138 -0
  41. package/docs/aihubmix-gemini-vision.zh.md +138 -0
  42. package/docs/assets/aihubmix-api-keys.png +0 -0
  43. package/docs/assets/aihubmix-create-key.png +0 -0
  44. package/docs/assets/aihubmix-free-vision-model.png +0 -0
  45. package/docs/assets/aihubmix-home.png +0 -0
  46. package/docs/assets/aihubmix-sign-up.png +0 -0
  47. package/docs/assets/groq-console-home.png +0 -0
  48. package/docs/assets/groq-console-keys-login.png +0 -0
  49. package/docs/assets/groq-docs-quickstart.png +0 -0
  50. package/docs/assets/groq-docs-qwen3.6.png +0 -0
  51. package/docs/assets/groq-docs-vision.png +0 -0
  52. package/docs/assets/vision-settings.png +0 -0
  53. package/docs/dsh-desktop-install.md +67 -0
  54. package/docs/dsh-desktop-install.zh.md +67 -0
  55. package/docs/groq-qwen3.6-vision.i18n.yaml +6 -0
  56. package/docs/groq-qwen3.6-vision.md +236 -0
  57. package/docs/groq-qwen3.6-vision.zh.md +236 -0
  58. package/docs/python-runtime.i18n.yaml +6 -0
  59. package/docs/python-runtime.md +89 -0
  60. package/docs/python-runtime.zh.md +89 -0
  61. package/docs/requirements-traceability/README.i18n.yaml +6 -0
  62. package/docs/requirements-traceability/README.md +75 -0
  63. package/docs/requirements-traceability/README.zh.md +75 -0
  64. package/examples/ui-restoration/README.i18n.yaml +6 -0
  65. package/examples/ui-restoration/README.md +70 -0
  66. package/examples/ui-restoration/README.zh.md +70 -0
  67. package/examples/ui-restoration/assets/final-heatmap.png +0 -0
  68. package/examples/ui-restoration/assets/final-report.json +83 -0
  69. package/examples/ui-restoration/assets/implementation.png +0 -0
  70. package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
  71. package/examples/ui-restoration/assets/initial-report.json +83 -0
  72. package/examples/ui-restoration/assets/initial.png +0 -0
  73. package/examples/ui-restoration/assets/metrics.json +12 -0
  74. package/examples/ui-restoration/assets/reference.png +0 -0
  75. package/examples/ui-restoration/implementation.html +94 -0
  76. package/examples/ui-restoration/initial.html +57 -0
  77. package/lib/artifact-access.js +369 -0
  78. package/lib/artifact-access.js.map +1 -0
  79. package/lib/artifacts.js +56 -0
  80. package/lib/artifacts.js.map +1 -0
  81. package/lib/client.js +2099 -0
  82. package/lib/client.js.map +1 -0
  83. package/lib/config.js +172 -0
  84. package/lib/config.js.map +1 -0
  85. package/lib/defaults.js +6 -0
  86. package/lib/defaults.js.map +1 -0
  87. package/lib/errors.js +56 -0
  88. package/lib/errors.js.map +1 -0
  89. package/lib/evidence-cache.js +350 -0
  90. package/lib/evidence-cache.js.map +1 -0
  91. package/lib/exposure.js +247 -0
  92. package/lib/exposure.js.map +1 -0
  93. package/lib/image-input-variants.js +882 -0
  94. package/lib/image-input-variants.js.map +1 -0
  95. package/lib/index.js +105 -0
  96. package/lib/index.js.map +1 -0
  97. package/lib/paste-images.js +217 -0
  98. package/lib/paste-images.js.map +1 -0
  99. package/lib/paths.js +354 -0
  100. package/lib/paths.js.map +1 -0
  101. package/lib/plugin-update.js +1003 -0
  102. package/lib/plugin-update.js.map +1 -0
  103. package/lib/runtime-install.js +964 -0
  104. package/lib/runtime-install.js.map +1 -0
  105. package/lib/runtime-manager.js +133 -0
  106. package/lib/runtime-manager.js.map +1 -0
  107. package/lib/runtime.js +1678 -0
  108. package/lib/runtime.js.map +1 -0
  109. package/lib/skill.js +25 -0
  110. package/lib/skill.js.map +1 -0
  111. package/lib/tools.js +549 -0
  112. package/lib/tools.js.map +1 -0
  113. package/lib/types/artifact-access.d.ts +61 -0
  114. package/lib/types/artifact-access.d.ts.map +1 -0
  115. package/lib/types/artifacts.d.ts +42 -0
  116. package/lib/types/artifacts.d.ts.map +1 -0
  117. package/lib/types/client/display-config.d.ts +24 -0
  118. package/lib/types/client/display-config.d.ts.map +1 -0
  119. package/lib/types/client/index.d.ts +350 -0
  120. package/lib/types/client/index.d.ts.map +1 -0
  121. package/lib/types/client/model-variants-hider.d.ts +40 -0
  122. package/lib/types/client/model-variants-hider.d.ts.map +1 -0
  123. package/lib/types/client/paste-images.d.ts +126 -0
  124. package/lib/types/client/paste-images.d.ts.map +1 -0
  125. package/lib/types/config.d.ts +123 -0
  126. package/lib/types/config.d.ts.map +1 -0
  127. package/lib/types/defaults.d.ts +6 -0
  128. package/lib/types/defaults.d.ts.map +1 -0
  129. package/lib/types/errors.d.ts +35 -0
  130. package/lib/types/errors.d.ts.map +1 -0
  131. package/lib/types/evidence-cache.d.ts +91 -0
  132. package/lib/types/evidence-cache.d.ts.map +1 -0
  133. package/lib/types/exposure.d.ts +50 -0
  134. package/lib/types/exposure.d.ts.map +1 -0
  135. package/lib/types/image-input-variants.d.ts +144 -0
  136. package/lib/types/image-input-variants.d.ts.map +1 -0
  137. package/lib/types/index.d.ts +19 -0
  138. package/lib/types/index.d.ts.map +1 -0
  139. package/lib/types/paste-images.d.ts +73 -0
  140. package/lib/types/paste-images.d.ts.map +1 -0
  141. package/lib/types/paths.d.ts +117 -0
  142. package/lib/types/paths.d.ts.map +1 -0
  143. package/lib/types/plugin-update.d.ts +119 -0
  144. package/lib/types/plugin-update.d.ts.map +1 -0
  145. package/lib/types/runtime-install.d.ts +92 -0
  146. package/lib/types/runtime-install.d.ts.map +1 -0
  147. package/lib/types/runtime-manager.d.ts +60 -0
  148. package/lib/types/runtime-manager.d.ts.map +1 -0
  149. package/lib/types/runtime.d.ts +412 -0
  150. package/lib/types/runtime.d.ts.map +1 -0
  151. package/lib/types/skill.d.ts +14 -0
  152. package/lib/types/skill.d.ts.map +1 -0
  153. package/lib/types/tools.d.ts +35 -0
  154. package/lib/types/tools.d.ts.map +1 -0
  155. package/lib/types/upstream.d.ts +233 -0
  156. package/lib/types/upstream.d.ts.map +1 -0
  157. package/lib/types/version.d.ts +15 -0
  158. package/lib/types/version.d.ts.map +1 -0
  159. package/lib/types/web-request.d.ts +11 -0
  160. package/lib/types/web-request.d.ts.map +1 -0
  161. package/lib/types/web.d.ts +118 -0
  162. package/lib/types/web.d.ts.map +1 -0
  163. package/lib/upstream.js +860 -0
  164. package/lib/upstream.js.map +1 -0
  165. package/lib/version.js +18 -0
  166. package/lib/version.js.map +1 -0
  167. package/lib/web-request.js +29 -0
  168. package/lib/web-request.js.map +1 -0
  169. package/lib/web.js +462 -0
  170. package/lib/web.js.map +1 -0
  171. package/package.json +149 -0
  172. package/patches/vision-tools-dsh.patch +970 -0
  173. package/runtime/requirements.lock +3 -0
  174. package/src/artifact-access.ts +386 -0
  175. package/src/artifacts.ts +85 -0
  176. package/src/client/display-config.ts +62 -0
  177. package/src/client/index.tsx +1580 -0
  178. package/src/client/model-variants-hider.ts +159 -0
  179. package/src/client/paste-images.tsx +747 -0
  180. package/src/config.ts +290 -0
  181. package/src/defaults.ts +5 -0
  182. package/src/errors.ts +62 -0
  183. package/src/evidence-cache.ts +418 -0
  184. package/src/exposure.ts +265 -0
  185. package/src/image-input-variants.ts +996 -0
  186. package/src/index.ts +141 -0
  187. package/src/paste-images.ts +286 -0
  188. package/src/paths.ts +383 -0
  189. package/src/plugin-update.ts +1143 -0
  190. package/src/runtime-install.ts +1161 -0
  191. package/src/runtime-manager.ts +174 -0
  192. package/src/runtime.ts +2189 -0
  193. package/src/skill.ts +35 -0
  194. package/src/tools.ts +691 -0
  195. package/src/upstream.ts +1078 -0
  196. package/src/version.ts +37 -0
  197. package/src/web-request.ts +27 -0
  198. package/src/web.ts +594 -0
  199. package/vendor/agent-vision-toolkit/CHANGELOG.md +30 -0
  200. package/vendor/agent-vision-toolkit/LICENSE +21 -0
  201. package/vendor/agent-vision-toolkit/README.md +405 -0
  202. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +94 -0
  203. package/vendor/agent-vision-toolkit/bin/crop +90 -0
  204. package/vendor/agent-vision-toolkit/bin/detect +13 -0
  205. package/vendor/agent-vision-toolkit/bin/glance +93 -0
  206. package/vendor/agent-vision-toolkit/bin/ground +13 -0
  207. package/vendor/agent-vision-toolkit/bin/trace +129 -0
  208. package/vendor/agent-vision-toolkit/detect.py +60 -0
  209. package/vendor/agent-vision-toolkit/ground.py +267 -0
  210. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
  211. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
  212. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +420 -0
  213. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  214. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
  215. package/vendor/agent-vision-toolkit/tests/test_vision_client.py +450 -0
  216. package/vendor/agent-vision-toolkit/vision_client.py +310 -0
@@ -0,0 +1,1143 @@
1
+ /**
2
+ * Profile-scoped self-update support for the Web Settings page.
3
+ *
4
+ * Only registry-installed copies are mutable. Local `link:`, `file:`, git,
5
+ * URL, and workspace installs stay developer-owned and are reported as
6
+ * unsupported instead of being replaced behind the user's back.
7
+ * @module dsh-vision-toolkit/plugin-update
8
+ */
9
+
10
+ import { spawn } from 'node:child_process'
11
+ import { randomUUID } from 'node:crypto'
12
+ import { closeSync, constants as fsConstants, fchmodSync, mkdirSync, openSync } from 'node:fs'
13
+ import { access, mkdir, open as openFile, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
14
+ import { homedir } from 'node:os'
15
+ import { dirname, join } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+ import type { Context } from '@deepseek-ai/cordis'
18
+
19
+ export const VISION_TOOLKIT_PACKAGE = '@mengruo/dsh-vision-toolkit'
20
+
21
+ const CHECK_TIMEOUT_MS = 20_000
22
+ const UPDATE_TIMEOUT_MS = 180_000
23
+ const RESTART_ROLLBACK_TIMEOUT_MS = 120_000
24
+ const OLD_PROCESS_EXIT_TIMEOUT_MS = 30_000
25
+ const RESTART_DELAY_MS = 750
26
+ const RESTART_RETRY_AFTER_MS = 1_200
27
+ const COMMAND_OUTPUT_BYTES = 128 * 1024
28
+ const SETTINGS_ROUTE = '/_dsh/vision-toolkit/settings'
29
+ const UPDATE_LOCK_FILE = '.dsh-vision-toolkit-update.lock'
30
+
31
+ export type PluginUpdateUnavailableReason =
32
+ | 'profile-not-found'
33
+ | 'not-direct-dependency'
34
+ | 'unsupported-install-source'
35
+ | 'profile-read-only'
36
+ | 'pnpm-unavailable'
37
+ | 'unsupported-platform'
38
+ | 'restart-unmanaged'
39
+ | 'restart-address-unavailable'
40
+
41
+ export interface PluginUpdateCapability {
42
+ supported: boolean
43
+ checkSupported?: boolean
44
+ profile?: string
45
+ dependencySpec?: string
46
+ reason?: PluginUpdateUnavailableReason
47
+ }
48
+
49
+ export interface PluginUpdateCheck extends PluginUpdateCapability {
50
+ currentVersion: string
51
+ latestVersion?: string
52
+ updateAvailable: boolean
53
+ checkedAt: string
54
+ }
55
+
56
+ interface PluginUpdateResultBase {
57
+ fromVersion: string
58
+ toVersion: string
59
+ profile: string
60
+ }
61
+
62
+ export type PluginUpdateResult = PluginUpdateResultBase & ({
63
+ restarting: true
64
+ manualRestartRequired?: false
65
+ retryAfterMs: number
66
+ } | {
67
+ restarting: false
68
+ manualRestartRequired: true
69
+ retryAfterMs?: undefined
70
+ })
71
+
72
+ export class PluginUpdateError extends Error {
73
+ constructor(
74
+ readonly code: string,
75
+ message: string,
76
+ options?: ErrorOptions,
77
+ ) {
78
+ super(message, options)
79
+ this.name = 'PluginUpdateError'
80
+ }
81
+ }
82
+
83
+ interface ProfileInstall {
84
+ profile: string
85
+ profileDir: string
86
+ installedDir: string
87
+ dependencySpec: string
88
+ }
89
+
90
+ interface CommandResult {
91
+ stdout: string
92
+ stderr: string
93
+ exitCode: number | null
94
+ timedOut: boolean
95
+ }
96
+
97
+ export interface RestartRequest {
98
+ pid: number
99
+ execPath: string
100
+ args: readonly string[]
101
+ cwd: string
102
+ logPath: string
103
+ lockPath: string
104
+ lockToken: string
105
+ backupDir: string
106
+ handoffPath: string
107
+ profileDir: string
108
+ pnpmPath: string
109
+ packageName: string
110
+ fromVersion: string
111
+ toVersion: string
112
+ healthUrl: string
113
+ baselineRuntimeReady: boolean
114
+ rollbackTimeoutMs: number
115
+ processKillGraceMs: number
116
+ readinessTimeoutMs: number
117
+ oldProcessExitTimeoutMs: number
118
+ }
119
+
120
+ export interface PluginUpdateServiceOptions {
121
+ packageRoot?: string
122
+ profileDir?: string
123
+ dshHome?: string
124
+ argv?: readonly string[]
125
+ now?: () => Date
126
+ prepareRestart?: (request: RestartRequest) => void | Promise<void>
127
+ terminateCurrent?: () => void
128
+ schedule?: (callback: () => void, delayMs: number) => void
129
+ allowDetachedRestart?: boolean
130
+ healthUrl?: string
131
+ runtimeReady?: () => boolean
132
+ platform?: NodeJS.Platform
133
+ }
134
+
135
+ interface PackageManifest {
136
+ name?: unknown
137
+ version?: unknown
138
+ dependencies?: Record<string, string>
139
+ }
140
+
141
+ interface ParsedVersion {
142
+ major: number
143
+ minor: number
144
+ patch: number
145
+ prerelease: readonly (number | string)[]
146
+ }
147
+
148
+ interface UpdateBackup {
149
+ dir: string
150
+ hadLockfile: boolean
151
+ }
152
+
153
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
154
+ return error instanceof Error && 'code' in error
155
+ }
156
+
157
+ /** @internal Restart helper source exported for lifecycle integration tests. */
158
+ export const PLUGIN_RESTART_HELPER_SOURCE = String.raw`
159
+ const { spawn } = require('node:child_process')
160
+ const { chmodSync, existsSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } = require('node:fs')
161
+ const payload = JSON.parse(Buffer.from(process.argv[1], 'base64url').toString('utf8'))
162
+ const sleep = delay => new Promise(resolve => setTimeout(resolve, delay))
163
+ const log = message => console.log('[dsh-vision-toolkit]', message)
164
+ let lockTransferred = false
165
+ const alive = pid => {
166
+ try { process.kill(pid, 0); return true }
167
+ catch (error) { return Boolean(error && error.code === 'EPERM') }
168
+ }
169
+ const lockOwner = () => {
170
+ try { return JSON.parse(readFileSync(payload.lockPath, 'utf8')) }
171
+ catch { return undefined }
172
+ }
173
+ const ownsLock = () => lockOwner()?.token === payload.lockToken
174
+ const writeLock = owner => {
175
+ const temp = payload.lockPath + '.' + process.pid + '.tmp'
176
+ writeFileSync(temp, JSON.stringify(owner), { mode: 0o600 })
177
+ renameSync(temp, payload.lockPath)
178
+ }
179
+ const transferLock = () => {
180
+ if (!ownsLock()) return false
181
+ writeLock({ pid: process.pid, token: payload.lockToken, startedAt: new Date().toISOString(), role: 'restart-helper' })
182
+ lockTransferred = true
183
+ return true
184
+ }
185
+ const removeLock = () => {
186
+ if (!ownsLock()) return
187
+ try { unlinkSync(payload.lockPath) }
188
+ catch (error) { if (!error || error.code !== 'ENOENT') console.error('[dsh-vision-toolkit] lock cleanup failed:', error) }
189
+ }
190
+ const cleanupBackup = () => {
191
+ try { rmSync(payload.backupDir, { recursive: true, force: true }) }
192
+ catch (error) { console.error('[dsh-vision-toolkit] backup cleanup failed:', error) }
193
+ }
194
+ const preserveRecovery = message => {
195
+ console.error('[dsh-vision-toolkit]', message + '; recovery files preserved at ' + payload.backupDir
196
+ + ' and update lock preserved at ' + payload.lockPath)
197
+ }
198
+ const atomicWrite = (path, contents, mode) => {
199
+ const temp = path + '.' + process.pid + '.tmp'
200
+ writeFileSync(temp, contents, { mode })
201
+ chmodSync(temp, mode)
202
+ renameSync(temp, path)
203
+ }
204
+ const validateBackup = () => {
205
+ const metadata = JSON.parse(readFileSync(payload.backupDir + '/metadata.json', 'utf8'))
206
+ readFileSync(payload.backupDir + '/package.json')
207
+ if (metadata.hadLockfile) readFileSync(payload.backupDir + '/pnpm-lock.yaml')
208
+ return metadata
209
+ }
210
+ const acknowledgeHandoff = () => {
211
+ atomicWrite(payload.handoffPath, JSON.stringify({ pid: process.pid, token: payload.lockToken }), 0o600)
212
+ }
213
+ const restoreProfileFiles = () => {
214
+ const manifest = readFileSync(payload.backupDir + '/package.json')
215
+ const metadata = JSON.parse(readFileSync(payload.backupDir + '/metadata.json', 'utf8'))
216
+ atomicWrite(payload.profileDir + '/package.json', manifest, metadata.manifestMode)
217
+ const lockfile = payload.profileDir + '/pnpm-lock.yaml'
218
+ if (metadata.hadLockfile) {
219
+ atomicWrite(lockfile, readFileSync(payload.backupDir + '/pnpm-lock.yaml'), metadata.lockfileMode)
220
+ }
221
+ else if (existsSync(lockfile)) unlinkSync(lockfile)
222
+ }
223
+ const killChild = (child, signal) => {
224
+ try {
225
+ if (process.platform === 'win32') child.kill(signal)
226
+ else process.kill(-child.pid, signal)
227
+ } catch {}
228
+ }
229
+ const runPnpm = args => new Promise(resolve => {
230
+ let settled = false
231
+ let killTimer
232
+ let finalTimer
233
+ const finish = value => {
234
+ if (settled) return
235
+ settled = true
236
+ clearTimeout(timeout)
237
+ clearTimeout(killTimer)
238
+ clearTimeout(finalTimer)
239
+ resolve(value)
240
+ }
241
+ const child = spawn(payload.pnpmPath, args, {
242
+ cwd: payload.profileDir,
243
+ env: process.env,
244
+ stdio: 'ignore',
245
+ detached: process.platform !== 'win32',
246
+ windowsHide: true,
247
+ })
248
+ child.once('error', error => {
249
+ console.error('[dsh-vision-toolkit] rollback pnpm failed:', error)
250
+ finish(false)
251
+ })
252
+ child.once('exit', code => { finish(code === 0) })
253
+ const timeout = setTimeout(() => {
254
+ console.error('[dsh-vision-toolkit] rollback pnpm timed out')
255
+ killChild(child, 'SIGTERM')
256
+ killTimer = setTimeout(() => {
257
+ killChild(child, 'SIGKILL')
258
+ finalTimer = setTimeout(() => { finish(false) }, payload.processKillGraceMs)
259
+ }, payload.processKillGraceMs)
260
+ }, payload.rollbackTimeoutMs)
261
+ })
262
+ const launch = () => spawn(payload.execPath, payload.args, {
263
+ cwd: payload.cwd,
264
+ env: process.env,
265
+ stdio: 'ignore',
266
+ detached: true,
267
+ windowsHide: true,
268
+ })
269
+ const ready = async (child, version, timeoutMs) => {
270
+ const deadline = Date.now() + timeoutMs
271
+ while (Date.now() < deadline) {
272
+ if (child.exitCode !== null || child.signalCode !== null) return false
273
+ try {
274
+ const response = await fetch(payload.healthUrl, { signal: AbortSignal.timeout(2000) })
275
+ const body = await response.json()
276
+ const runtimeReady = body && body.value && body.value.runtime && body.value.runtime.ready === true
277
+ if (response.ok && body && body.ok && body.value && body.value.release
278
+ && body.value.release.pluginVersion === version
279
+ && (!payload.baselineRuntimeReady || runtimeReady)) return true
280
+ } catch {}
281
+ await sleep(500)
282
+ }
283
+ return false
284
+ }
285
+ const stop = async child => {
286
+ if (child.exitCode !== null || child.signalCode !== null) return
287
+ try { child.kill('SIGTERM') } catch {}
288
+ const deadline = Date.now() + 5000
289
+ while (Date.now() < deadline && child.exitCode === null && child.signalCode === null) await sleep(100)
290
+ if (child.exitCode === null && child.signalCode === null) {
291
+ try { child.kill('SIGKILL') } catch {}
292
+ }
293
+ }
294
+ const installedVersion = () => {
295
+ const manifest = JSON.parse(readFileSync(payload.profileDir + '/node_modules/'
296
+ + payload.packageName + '/package.json', 'utf8'))
297
+ return manifest && manifest.version
298
+ }
299
+ const rollbackInstall = async metadata => {
300
+ try { restoreProfileFiles() }
301
+ catch (error) {
302
+ console.error('[dsh-vision-toolkit] profile file restore before rollback failed:', error)
303
+ return false
304
+ }
305
+ const args = metadata.hadLockfile
306
+ ? ['install', '--frozen-lockfile', '--reporter=append-only']
307
+ : ['add', payload.packageName + '@' + payload.fromVersion, '--save-exact', '--yes', '--reporter=append-only']
308
+ const installed = await runPnpm(args)
309
+ try { restoreProfileFiles() }
310
+ catch (error) {
311
+ console.error('[dsh-vision-toolkit] profile file restore after rollback failed:', error)
312
+ return false
313
+ }
314
+ if (!installed) {
315
+ console.error('[dsh-vision-toolkit] rollback pnpm failed')
316
+ return false
317
+ }
318
+ try {
319
+ const version = installedVersion()
320
+ if (version !== payload.fromVersion) console.error('[dsh-vision-toolkit] rollback installed ' + version + ' instead of ' + payload.fromVersion)
321
+ return version === payload.fromVersion
322
+ }
323
+ catch (error) {
324
+ console.error('[dsh-vision-toolkit] rollback version verification failed:', error)
325
+ return false
326
+ }
327
+ }
328
+ const restore = async metadata => {
329
+ log('replacement did not become ready; restoring ' + payload.fromVersion)
330
+ if (!await rollbackInstall(metadata)) return false
331
+ const child = launch()
332
+ child.once('error', error => { console.error('[dsh-vision-toolkit] rollback launch failed:', error) })
333
+ if (!await ready(child, payload.fromVersion, payload.readinessTimeoutMs)) {
334
+ console.error('[dsh-vision-toolkit] rollback replacement did not become ready')
335
+ await stop(child)
336
+ return false
337
+ }
338
+ child.unref()
339
+ log('rollback is serving ' + payload.fromVersion)
340
+ return true
341
+ }
342
+ const main = async () => {
343
+ const metadata = validateBackup()
344
+ if (!transferLock()) {
345
+ console.error('[dsh-vision-toolkit] restart helper could not take ownership of the update lock')
346
+ process.exit(1)
347
+ }
348
+ acknowledgeHandoff()
349
+ const exitDeadline = Date.now() + payload.oldProcessExitTimeoutMs
350
+ while (alive(payload.pid) && Date.now() < exitDeadline) await sleep(100)
351
+ if (alive(payload.pid)) {
352
+ log('old process did not exit; restoring package files without replacing the running process')
353
+ const installed = await rollbackInstall(metadata)
354
+ if (installed) {
355
+ removeLock()
356
+ cleanupBackup()
357
+ } else preserveRecovery('old process did not exit and automatic package recovery failed')
358
+ process.exit(1)
359
+ }
360
+ const child = launch()
361
+ child.once('error', error => { console.error('[dsh-vision-toolkit] replacement launch failed:', error) })
362
+ if (await ready(child, payload.toVersion, payload.readinessTimeoutMs)) {
363
+ child.unref()
364
+ removeLock()
365
+ cleanupBackup()
366
+ log('replacement is serving ' + payload.toVersion)
367
+ process.exit(0)
368
+ }
369
+ await stop(child)
370
+ const restored = await restore(metadata)
371
+ if (restored) {
372
+ removeLock()
373
+ cleanupBackup()
374
+ } else preserveRecovery('replacement and automatic rollback both failed')
375
+ process.exit(restored ? 2 : 1)
376
+ }
377
+ main().catch(error => {
378
+ console.error('[dsh-vision-toolkit] restart helper failed:', error)
379
+ if (lockTransferred) preserveRecovery('restart helper failed before recovery completed')
380
+ process.exit(1)
381
+ })
382
+ `
383
+
384
+ function parseSemver(value: string): ParsedVersion | undefined {
385
+ const match = /^(?:v)?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(value)
386
+ if (match === null) return undefined
387
+ const prerelease = match[4] === undefined
388
+ ? []
389
+ : match[4].split('.').map(part => /^\d+$/u.test(part) ? Number(part) : part)
390
+ return {
391
+ major: Number(match[1]),
392
+ minor: Number(match[2]),
393
+ patch: Number(match[3]),
394
+ prerelease,
395
+ }
396
+ }
397
+
398
+ /** Compare two strict SemVer versions. */
399
+ export function compareVersions(left: string, right: string): number {
400
+ const a = parseSemver(left)
401
+ const b = parseSemver(right)
402
+ if (a === undefined || b === undefined) return left.localeCompare(right)
403
+ for (const key of ['major', 'minor', 'patch'] as const) {
404
+ if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1
405
+ }
406
+ if (a.prerelease.length === 0 || b.prerelease.length === 0) {
407
+ if (a.prerelease.length === b.prerelease.length) return 0
408
+ return a.prerelease.length === 0 ? 1 : -1
409
+ }
410
+ const count = Math.max(a.prerelease.length, b.prerelease.length)
411
+ for (let index = 0; index < count; index += 1) {
412
+ const x = a.prerelease[index]
413
+ const y = b.prerelease[index]
414
+ if (x === undefined || y === undefined) return x === undefined ? -1 : 1
415
+ if (x === y) continue
416
+ if (typeof x === 'number' && typeof y === 'number') return x < y ? -1 : 1
417
+ if (typeof x === 'number') return -1
418
+ if (typeof y === 'number') return 1
419
+ return x.localeCompare(y)
420
+ }
421
+ return 0
422
+ }
423
+
424
+ function registryInstallSpec(spec: string): boolean {
425
+ const normalized = spec.trim().toLowerCase()
426
+ return normalized.length > 0
427
+ && !/^[a-z][a-z0-9+.-]*:/u.test(normalized)
428
+ && !normalized.includes('/')
429
+ && !normalized.includes('\\')
430
+ }
431
+
432
+ function profileHint(argv: readonly string[]): string | undefined {
433
+ if (argv[0] === 'web') return 'web'
434
+ for (let index = 0; index < argv.length; index += 1) {
435
+ const value = argv[index]
436
+ if (value?.startsWith('--profile=')) return value.slice('--profile='.length)
437
+ if (value === '--profile') return argv[index + 1]
438
+ }
439
+ return undefined
440
+ }
441
+
442
+ async function jsonFile(path: string): Promise<PackageManifest> {
443
+ return JSON.parse(await readFile(path, 'utf8')) as PackageManifest
444
+ }
445
+
446
+ async function sameRealPath(left: string, right: string): Promise<boolean> {
447
+ try {
448
+ return await realpath(left) === await realpath(right)
449
+ } catch {
450
+ return false
451
+ }
452
+ }
453
+
454
+ function defaultPackageRoot(): string {
455
+ return dirname(fileURLToPath(new URL('../package.json', import.meta.url)))
456
+ }
457
+
458
+ function defaultDshHome(): string {
459
+ return process.env.DSH_HOME?.trim() || join(homedir(), '.dsh')
460
+ }
461
+
462
+ function optionValue(argv: readonly string[], name: string): string | undefined {
463
+ for (let index = 0; index < argv.length; index += 1) {
464
+ const value = argv[index]
465
+ if (value?.startsWith(`${name}=`)) return value.slice(name.length + 1)
466
+ if (value === name) return argv[index + 1]
467
+ }
468
+ return undefined
469
+ }
470
+
471
+ function defaultHealthUrl(argv: readonly string[]): string | undefined {
472
+ const configuredHost = optionValue(argv, '--host')?.trim() || '127.0.0.1'
473
+ const host = configuredHost === '0.0.0.0' || configuredHost === '::' ? '127.0.0.1' : configuredHost
474
+ const port = optionValue(argv, '--port')?.trim() || '3080'
475
+ if (!/^\d+$/u.test(port) || Number(port) < 1 || Number(port) > 65_535) return undefined
476
+ const authority = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host
477
+ return `http://${authority}:${port}${SETTINGS_ROUTE}`
478
+ }
479
+
480
+ async function defaultPrepareRestart(request: RestartRequest): Promise<void> {
481
+ const payload = Buffer.from(JSON.stringify(request)).toString('base64url')
482
+ mkdirSync(dirname(request.logPath), { recursive: true })
483
+ const log = openSync(request.logPath, 'w', 0o600)
484
+ let helper: ReturnType<typeof spawn> | undefined
485
+ let handoffAcknowledged = false
486
+ const stopHelper = async (): Promise<void> => {
487
+ if (helper === undefined || helper.exitCode !== null || helper.signalCode !== null) return
488
+ try {
489
+ if (process.platform === 'win32') helper.kill('SIGTERM')
490
+ else if (helper.pid !== undefined) process.kill(-helper.pid, 'SIGTERM')
491
+ } catch {}
492
+ const stopDeadline = Date.now() + 1_000
493
+ while (Date.now() < stopDeadline && helper.exitCode === null && helper.signalCode === null) {
494
+ await new Promise(resolve => { setTimeout(resolve, 50) })
495
+ }
496
+ if (helper.exitCode === null && helper.signalCode === null) {
497
+ try {
498
+ if (process.platform === 'win32') helper.kill('SIGKILL')
499
+ else if (helper.pid !== undefined) process.kill(-helper.pid, 'SIGKILL')
500
+ } catch {}
501
+ const killDeadline = Date.now() + 1_000
502
+ while (Date.now() < killDeadline && helper.exitCode === null && helper.signalCode === null) {
503
+ await new Promise(resolve => { setTimeout(resolve, 50) })
504
+ }
505
+ }
506
+ }
507
+ try {
508
+ fchmodSync(log, 0o600)
509
+ helper = spawn(process.execPath, ['-e', PLUGIN_RESTART_HELPER_SOURCE, payload], {
510
+ cwd: request.cwd,
511
+ env: process.env,
512
+ stdio: ['ignore', log, log],
513
+ detached: true,
514
+ windowsHide: true,
515
+ })
516
+ if (helper.pid === undefined) throw new Error('restart helper did not publish a process id')
517
+ let spawnError: Error | undefined
518
+ helper.once('error', (error) => { spawnError = error })
519
+ const deadline = Date.now() + 5_000
520
+ while (Date.now() < deadline) {
521
+ if (spawnError !== undefined) throw spawnError
522
+ if (helper.exitCode !== null || helper.signalCode !== null) {
523
+ throw new Error(`restart helper exited before handoff with code ${String(helper.exitCode)}`)
524
+ }
525
+ try {
526
+ const handoff = JSON.parse(await readFile(request.handoffPath, 'utf8')) as { pid?: unknown; token?: unknown }
527
+ if (handoff.pid === helper.pid && handoff.token === request.lockToken) {
528
+ handoffAcknowledged = true
529
+ helper.unref()
530
+ return
531
+ }
532
+ } catch (error) {
533
+ if (!isNodeError(error) || error.code !== 'ENOENT') throw error
534
+ }
535
+ await new Promise(resolve => { setTimeout(resolve, 50) })
536
+ }
537
+ throw new Error('restart helper did not acknowledge lock and backup handoff')
538
+ } catch (error) {
539
+ if (!handoffAcknowledged) await stopHelper()
540
+ throw error
541
+ } finally {
542
+ closeSync(log)
543
+ }
544
+ }
545
+
546
+ function defaultSchedule(callback: () => void, delayMs: number): void {
547
+ const timer = setTimeout(callback, delayMs)
548
+ timer.unref()
549
+ }
550
+
551
+ async function createUpdateBackup(profileDir: string, token: string): Promise<UpdateBackup> {
552
+ const dir = join(profileDir, `.dsh-vision-toolkit-update-backup-${token}`)
553
+ try {
554
+ await mkdir(dir, { mode: 0o700 })
555
+ const manifestPath = join(profileDir, 'package.json')
556
+ const manifest = await readFile(manifestPath)
557
+ const manifestMode = (await stat(manifestPath)).mode & 0o777
558
+ await writeFile(join(dir, 'package.json'), manifest, { mode: 0o600 })
559
+ let hadLockfile = true
560
+ let lockfileMode: number | undefined
561
+ try {
562
+ const lockfilePath = join(profileDir, 'pnpm-lock.yaml')
563
+ const lockfile = await readFile(lockfilePath)
564
+ lockfileMode = (await stat(lockfilePath)).mode & 0o777
565
+ await writeFile(join(dir, 'pnpm-lock.yaml'), lockfile, { mode: 0o600 })
566
+ } catch (error) {
567
+ if (!isNodeError(error) || error.code !== 'ENOENT') throw error
568
+ hadLockfile = false
569
+ }
570
+ await writeFile(join(dir, 'metadata.json'), JSON.stringify({ hadLockfile, manifestMode, lockfileMode }), { mode: 0o600 })
571
+ return { dir, hadLockfile }
572
+ } catch (error) {
573
+ await rm(dir, { recursive: true, force: true }).catch(() => {})
574
+ throw error
575
+ }
576
+ }
577
+
578
+ async function atomicRestore(path: string, contents: Buffer, mode: number): Promise<void> {
579
+ const staging = `${path}.${process.pid}.${randomUUID()}.restore`
580
+ try {
581
+ await writeFile(staging, contents, { mode })
582
+ await rename(staging, path)
583
+ } finally {
584
+ await unlink(staging).catch(() => {})
585
+ }
586
+ }
587
+
588
+ async function restoreUpdateBackup(profileDir: string, backup: UpdateBackup): Promise<void> {
589
+ const metadata = JSON.parse(await readFile(join(backup.dir, 'metadata.json'), 'utf8')) as {
590
+ hadLockfile?: unknown
591
+ manifestMode?: unknown
592
+ lockfileMode?: unknown
593
+ }
594
+ if (typeof metadata.manifestMode !== 'number') throw new Error('update backup is missing the manifest mode')
595
+ await atomicRestore(
596
+ join(profileDir, 'package.json'),
597
+ await readFile(join(backup.dir, 'package.json')),
598
+ metadata.manifestMode,
599
+ )
600
+ const lockfilePath = join(profileDir, 'pnpm-lock.yaml')
601
+ if (metadata.hadLockfile === true) {
602
+ if (typeof metadata.lockfileMode !== 'number') throw new Error('update backup is missing the lockfile mode')
603
+ await atomicRestore(lockfilePath, await readFile(join(backup.dir, 'pnpm-lock.yaml')), metadata.lockfileMode)
604
+ } else {
605
+ await unlink(lockfilePath).catch((error: unknown) => {
606
+ if (!isNodeError(error) || error.code !== 'ENOENT') throw error
607
+ })
608
+ }
609
+ }
610
+
611
+ async function cleanupUpdateBackup(backup: UpdateBackup): Promise<void> {
612
+ await rm(backup.dir, { recursive: true, force: true })
613
+ }
614
+
615
+ function publicCommandFailure(result: CommandResult, fallback: string): string {
616
+ const detail = (result.stderr.trim() || result.stdout.trim())
617
+ .replaceAll(homedir(), '~')
618
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/giu, '$1***@')
619
+ .replace(/([?&](?:access[_-]?token|api[_-]?key|auth|key|password|token)=)[^&#\s]+/giu, '$1***')
620
+ .replace(/((?:authorization|proxy-authorization)\s*[:=]\s*(?:bearer|basic)\s+)\S+/giu, '$1***')
621
+ .replace(/((?:_auth(?:Token)?|token|password|_password)\s*[:=]\s*)\S+/giu, '$1***')
622
+ .replace(/\bnpm_[A-Za-z0-9_-]+\b/gu, 'npm_***')
623
+ if (result.timedOut) return `${fallback}: command timed out`
624
+ if (detail.length === 0) return `${fallback}: pnpm exited with code ${String(result.exitCode)}`
625
+ return `${fallback}: ${detail.slice(-1_000)}`
626
+ }
627
+
628
+ /** Profile-aware updater used by the same-origin Settings backend. */
629
+ export class VisionToolkitPluginUpdateService {
630
+ private readonly packageRoot: string
631
+ private readonly profileDir: string | undefined
632
+ private readonly dshHome: string
633
+ private readonly argv: readonly string[]
634
+ private readonly now: () => Date
635
+ private readonly prepareRestart: (request: RestartRequest) => void | Promise<void>
636
+ private readonly terminateCurrent: () => void
637
+ private readonly schedule: (callback: () => void, delayMs: number) => void
638
+ private readonly allowDetachedRestart: boolean
639
+ private healthUrl: string | undefined
640
+ private readonly runtimeReady: () => boolean
641
+ private readonly platform: NodeJS.Platform
642
+ private updating = false
643
+
644
+ constructor(
645
+ private readonly ctx: Pick<Context, 'subprocess'>,
646
+ private readonly currentVersion: string,
647
+ options: PluginUpdateServiceOptions = {},
648
+ ) {
649
+ this.packageRoot = options.packageRoot ?? defaultPackageRoot()
650
+ this.profileDir = options.profileDir
651
+ this.dshHome = options.dshHome ?? defaultDshHome()
652
+ this.argv = options.argv ?? process.argv.slice(2)
653
+ this.now = options.now ?? (() => new Date())
654
+ this.prepareRestart = options.prepareRestart ?? defaultPrepareRestart
655
+ this.terminateCurrent = options.terminateCurrent ?? (() => { process.kill(process.pid, 'SIGTERM') })
656
+ this.schedule = options.schedule ?? defaultSchedule
657
+ this.allowDetachedRestart = options.allowDetachedRestart
658
+ ?? process.env.DSH_VISION_TOOLKIT_ALLOW_DETACHED_RESTART === '1'
659
+ this.healthUrl = options.healthUrl ?? defaultHealthUrl(this.argv)
660
+ this.runtimeReady = options.runtimeReady ?? (() => true)
661
+ this.platform = options.platform ?? process.platform
662
+ }
663
+
664
+ /** Bind readiness checks to the active WebServer and reject ports that cannot be reproduced on restart. */
665
+ configureWebServer(host: string, port: number): void {
666
+ const explicitPort = optionValue(this.argv, '--port')?.trim()
667
+ const stable = explicitPort === undefined ? port === 3080 : Number(explicitPort) === port && port > 0
668
+ if (!stable || !Number.isInteger(port) || port < 1 || port > 65_535) {
669
+ this.healthUrl = undefined
670
+ return
671
+ }
672
+ const probeHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host
673
+ const authority = probeHost.includes(':') && !probeHost.startsWith('[') ? `[${probeHost}]` : probeHost
674
+ this.healthUrl = `http://${authority}:${port}${SETTINGS_ROUTE}`
675
+ }
676
+
677
+ private async inspectProfile(
678
+ profileDir: string,
679
+ profile: string,
680
+ ): Promise<ProfileInstall | PluginUpdateCapability | undefined> {
681
+ const manifestPath = join(profileDir, 'package.json')
682
+ const installedDir = join(profileDir, 'node_modules', ...VISION_TOOLKIT_PACKAGE.split('/'))
683
+ let manifest: PackageManifest
684
+ try {
685
+ manifest = await jsonFile(manifestPath)
686
+ } catch {
687
+ return undefined
688
+ }
689
+ if (!(await sameRealPath(installedDir, this.packageRoot))) return undefined
690
+ const dependencySpec = manifest.dependencies?.[VISION_TOOLKIT_PACKAGE]
691
+ if (dependencySpec === undefined) {
692
+ return { supported: false, profile, reason: 'not-direct-dependency' }
693
+ }
694
+ return { profile, profileDir, installedDir, dependencySpec }
695
+ }
696
+
697
+ private async locateProfile(): Promise<ProfileInstall | PluginUpdateCapability> {
698
+ const hint = profileHint(this.argv)
699
+ if (this.profileDir !== undefined) {
700
+ const inspected = await this.inspectProfile(this.profileDir, hint ?? 'web')
701
+ if (inspected === undefined) return { supported: false, reason: 'profile-not-found' }
702
+ return inspected
703
+ }
704
+
705
+ const profilesDir = join(this.dshHome, 'profiles')
706
+ const names = new Set<string>()
707
+ if (hint !== undefined && hint.length > 0) names.add(hint)
708
+ try {
709
+ for (const entry of await readdir(profilesDir, { withFileTypes: true })) {
710
+ if (entry.isDirectory()) names.add(entry.name)
711
+ }
712
+ } catch {
713
+ return { supported: false, reason: 'profile-not-found' }
714
+ }
715
+
716
+ let found: ProfileInstall | PluginUpdateCapability | undefined
717
+ for (const name of names) {
718
+ const inspected = await this.inspectProfile(join(profilesDir, name), name)
719
+ if (inspected === undefined) continue
720
+ if ('supported' in inspected) return inspected
721
+ if (found !== undefined && hint === undefined) return { supported: false, reason: 'profile-not-found' }
722
+ found = inspected
723
+ if (name === hint) break
724
+ }
725
+ return found ?? { supported: false, reason: 'profile-not-found' }
726
+ }
727
+
728
+ private async profile(): Promise<ProfileInstall | PluginUpdateCapability> {
729
+ return await this.locateProfile()
730
+ }
731
+
732
+ private async evaluate(): Promise<{
733
+ capability: PluginUpdateCapability
734
+ profile?: ProfileInstall
735
+ pnpmPath?: string
736
+ }> {
737
+ const checked = await this.checkContext()
738
+ if (checked.profile === undefined || checked.pnpmPath === undefined) return { capability: checked.capability }
739
+ const { profile, pnpmPath } = checked
740
+ if (!registryInstallSpec(profile.dependencySpec)) {
741
+ return {
742
+ capability: {
743
+ supported: false,
744
+ checkSupported: true,
745
+ profile: profile.profile,
746
+ dependencySpec: profile.dependencySpec,
747
+ reason: 'unsupported-install-source',
748
+ },
749
+ }
750
+ }
751
+ try {
752
+ await access(profile.profileDir, fsConstants.W_OK)
753
+ await access(join(profile.profileDir, 'package.json'), fsConstants.W_OK)
754
+ await access(dirname(profile.installedDir), fsConstants.W_OK)
755
+ try {
756
+ await access(join(profile.profileDir, 'pnpm-lock.yaml'), fsConstants.W_OK)
757
+ } catch (error) {
758
+ if (!isNodeError(error) || error.code !== 'ENOENT') throw error
759
+ }
760
+ } catch {
761
+ return {
762
+ capability: {
763
+ supported: false,
764
+ checkSupported: true,
765
+ profile: profile.profile,
766
+ dependencySpec: profile.dependencySpec,
767
+ reason: 'profile-read-only',
768
+ },
769
+ }
770
+ }
771
+ // Replacing the registry package is safe even when this Web process cannot
772
+ // restart itself. In that case the new code becomes active after the user
773
+ // restarts DSH Web through their usual command or process manager.
774
+ return {
775
+ capability: { supported: true, checkSupported: true, profile: profile.profile, dependencySpec: profile.dependencySpec },
776
+ profile,
777
+ pnpmPath,
778
+ }
779
+ }
780
+
781
+ private async checkContext(): Promise<{
782
+ capability: PluginUpdateCapability
783
+ profile?: ProfileInstall
784
+ pnpmPath?: string
785
+ }> {
786
+ const profile = await this.profile()
787
+ if ('supported' in profile) return { capability: { ...profile, checkSupported: false } }
788
+ let pnpmPath: string
789
+ try {
790
+ pnpmPath = await this.ctx.subprocess.resolveExecutable('pnpm')
791
+ } catch {
792
+ return {
793
+ capability: {
794
+ supported: false,
795
+ checkSupported: false,
796
+ profile: profile.profile,
797
+ dependencySpec: profile.dependencySpec,
798
+ reason: 'pnpm-unavailable',
799
+ },
800
+ }
801
+ }
802
+ return {
803
+ capability: {
804
+ supported: false,
805
+ checkSupported: true,
806
+ profile: profile.profile,
807
+ dependencySpec: profile.dependencySpec,
808
+ },
809
+ profile,
810
+ pnpmPath,
811
+ }
812
+ }
813
+
814
+ /** Report whether the current installation can be safely replaced in place. */
815
+ async capability(): Promise<PluginUpdateCapability> {
816
+ return (await this.evaluate()).capability
817
+ }
818
+
819
+ private async runPnpm(
820
+ args: readonly string[],
821
+ timeoutMs: number,
822
+ profile: ProfileInstall,
823
+ pnpmPath: string,
824
+ ): Promise<CommandResult> {
825
+ // Windows batch shims cannot be spawned directly; route them through cmd.exe.
826
+ const program = this.platform === 'win32' && /\.(?:cmd|bat)$/i.test(pnpmPath)
827
+ ? process.env.COMSPEC ?? 'cmd.exe'
828
+ : pnpmPath
829
+ const argv = program === pnpmPath
830
+ ? [pnpmPath, ...args]
831
+ : [program, '/d', '/s', '/c', pnpmPath, ...args]
832
+ const controller = new AbortController()
833
+ let timedOut = false
834
+ const timeout = setTimeout(() => {
835
+ timedOut = true
836
+ controller.abort()
837
+ }, timeoutMs)
838
+ try {
839
+ const handle = this.ctx.subprocess.spawn({
840
+ argv,
841
+ cwd: profile.profileDir,
842
+ stdio: {
843
+ stdin: 'ignore',
844
+ stdout: { maxBytes: COMMAND_OUTPUT_BYTES },
845
+ stderr: { maxBytes: COMMAND_OUTPUT_BYTES },
846
+ },
847
+ graceMs: 5_000,
848
+ signal: controller.signal,
849
+ })
850
+ const outcome = await handle.done
851
+ return {
852
+ stdout: handle.collected.stdout?.readFrom(0).text ?? '',
853
+ stderr: handle.collected.stderr?.readFrom(0).text ?? '',
854
+ exitCode: outcome.exitCode,
855
+ timedOut,
856
+ }
857
+ } catch (error) {
858
+ if (timedOut) return { stdout: '', stderr: '', exitCode: null, timedOut: true }
859
+ throw new PluginUpdateError('pnpm-failed', 'Could not start pnpm', { cause: error })
860
+ } finally {
861
+ clearTimeout(timeout)
862
+ }
863
+ }
864
+
865
+ private async rollbackInstall(
866
+ backup: UpdateBackup,
867
+ profile: ProfileInstall,
868
+ pnpmPath: string,
869
+ ): Promise<boolean> {
870
+ await restoreUpdateBackup(profile.profileDir, backup)
871
+ const args = backup.hadLockfile
872
+ ? ['install', '--frozen-lockfile', '--reporter=append-only']
873
+ : ['add', `${VISION_TOOLKIT_PACKAGE}@${this.currentVersion}`, '--save-exact', '--yes', '--reporter=append-only']
874
+ const result = await this.runPnpm(args, UPDATE_TIMEOUT_MS, profile, pnpmPath)
875
+ let restored = true
876
+ try { await restoreUpdateBackup(profile.profileDir, backup) }
877
+ catch { restored = false }
878
+ if (result.exitCode !== 0 || !restored) return false
879
+ try {
880
+ const restored = await jsonFile(join(profile.installedDir, 'package.json'))
881
+ return restored.version === this.currentVersion
882
+ } catch {
883
+ return false
884
+ }
885
+ }
886
+
887
+ private async acquireLock(profileDir: string): Promise<{
888
+ path: string
889
+ token: string
890
+ release: () => Promise<void>
891
+ }> {
892
+ const path = join(profileDir, UPDATE_LOCK_FILE)
893
+ const token = randomUUID()
894
+ try {
895
+ const handle = await openFile(path, 'wx', 0o600)
896
+ try {
897
+ await handle.writeFile(JSON.stringify({
898
+ pid: process.pid,
899
+ token,
900
+ startedAt: this.now().toISOString(),
901
+ role: 'updater',
902
+ }))
903
+ } finally {
904
+ await handle.close()
905
+ }
906
+ } catch (error) {
907
+ if (isNodeError(error) && error.code === 'EEXIST') {
908
+ throw new PluginUpdateError(
909
+ 'update-in-progress',
910
+ `Another process owns the DSH profile update lock; remove ${UPDATE_LOCK_FILE} manually only after confirming no update or restart helper is running`,
911
+ )
912
+ }
913
+ throw error
914
+ }
915
+ return {
916
+ path,
917
+ token,
918
+ release: async () => {
919
+ let owner: { token?: unknown }
920
+ try {
921
+ owner = JSON.parse(await readFile(path, 'utf8')) as { token?: unknown }
922
+ } catch (error) {
923
+ if (isNodeError(error) && error.code === 'ENOENT') return
924
+ throw error
925
+ }
926
+ if (owner.token !== token) {
927
+ throw new PluginUpdateError('update-lock-release-failed', 'The profile update lock owner changed before release')
928
+ }
929
+ await unlink(path)
930
+ },
931
+ }
932
+ }
933
+
934
+ /** Query the configured npm registry without mutating the profile. */
935
+ async check(): Promise<PluginUpdateCheck> {
936
+ const context = await this.checkContext()
937
+ if (context.profile === undefined || context.pnpmPath === undefined) {
938
+ return {
939
+ ...context.capability,
940
+ currentVersion: this.currentVersion,
941
+ updateAvailable: false,
942
+ checkedAt: this.now().toISOString(),
943
+ }
944
+ }
945
+ const capability = (await this.evaluate()).capability
946
+ const result = await this.runPnpm(
947
+ ['view', VISION_TOOLKIT_PACKAGE, 'version', '--json'],
948
+ CHECK_TIMEOUT_MS,
949
+ context.profile,
950
+ context.pnpmPath,
951
+ )
952
+ if (result.exitCode !== 0) {
953
+ throw new PluginUpdateError('update-check-failed', publicCommandFailure(result, 'Could not check the npm registry'))
954
+ }
955
+ let latestVersion: string
956
+ try {
957
+ const parsed = JSON.parse(result.stdout.trim()) as unknown
958
+ if (typeof parsed !== 'string' || parseSemver(parsed) === undefined) throw new Error('invalid version')
959
+ latestVersion = parsed
960
+ } catch (error) {
961
+ throw new PluginUpdateError('update-check-failed', 'The npm registry returned an invalid plugin version', { cause: error })
962
+ }
963
+ return {
964
+ ...capability,
965
+ currentVersion: this.currentVersion,
966
+ latestVersion,
967
+ updateAvailable: compareVersions(latestVersion, this.currentVersion) > 0,
968
+ checkedAt: this.now().toISOString(),
969
+ }
970
+ }
971
+
972
+ /** Install the currently published version, then restart when this process can do so safely. */
973
+ async installAndRestart(expectedVersion: string): Promise<PluginUpdateResult> {
974
+ if (this.updating) throw new PluginUpdateError('update-in-progress', 'A plugin update is already in progress')
975
+ this.updating = true
976
+ let locked: { path: string; token: string; release: () => Promise<void> } | undefined
977
+ let updateContext: { profile: ProfileInstall; pnpmPath: string } | undefined
978
+ let updateBackup: UpdateBackup | undefined
979
+ let updateAttempted = false
980
+ try {
981
+ const initial = await this.evaluate()
982
+ if (!initial.capability.supported || initial.profile === undefined || initial.pnpmPath === undefined) {
983
+ throw new PluginUpdateError('update-unavailable', 'Plugin update is unavailable for this installation')
984
+ }
985
+ locked = await this.acquireLock(initial.profile.profileDir)
986
+ const check = await this.check()
987
+ if (!check.supported || check.latestVersion === undefined) {
988
+ throw new PluginUpdateError('update-unavailable', 'Plugin update is unavailable for this installation')
989
+ }
990
+ if (check.latestVersion !== expectedVersion) {
991
+ throw new PluginUpdateError(
992
+ 'update-stale',
993
+ `The latest version changed from ${expectedVersion} to ${check.latestVersion}; check again before updating`,
994
+ )
995
+ }
996
+ if (!check.updateAvailable) {
997
+ throw new PluginUpdateError('already-current', `Plugin ${this.currentVersion} is already up to date`)
998
+ }
999
+ const final = await this.evaluate()
1000
+ if (!final.capability.supported || final.profile === undefined || final.pnpmPath === undefined
1001
+ || final.profile.profileDir !== initial.profile.profileDir) {
1002
+ throw new PluginUpdateError('update-unavailable', 'The plugin installation changed while preparing the update')
1003
+ }
1004
+ updateContext = { profile: final.profile, pnpmPath: final.pnpmPath }
1005
+ updateBackup = await createUpdateBackup(final.profile.profileDir, locked.token)
1006
+ updateAttempted = true
1007
+ const result = await this.runPnpm([
1008
+ 'add', `${VISION_TOOLKIT_PACKAGE}@${expectedVersion}`, '--save-exact', '--yes', '--reporter=append-only',
1009
+ ], UPDATE_TIMEOUT_MS, final.profile, final.pnpmPath)
1010
+ if (result.exitCode !== 0) {
1011
+ throw new PluginUpdateError('update-failed', publicCommandFailure(result, 'Plugin update failed'))
1012
+ }
1013
+ let installedVersion: string
1014
+ try {
1015
+ const installed = await jsonFile(join(final.profile.installedDir, 'package.json'))
1016
+ if (typeof installed.version !== 'string') throw new Error('missing version')
1017
+ installedVersion = installed.version
1018
+ } catch (error) {
1019
+ throw new PluginUpdateError('update-verify-failed', 'The updated package version could not be verified', { cause: error })
1020
+ }
1021
+ if (installedVersion !== expectedVersion) {
1022
+ throw new PluginUpdateError(
1023
+ 'update-verify-failed',
1024
+ `pnpm completed, but installed ${installedVersion} instead of ${expectedVersion}`,
1025
+ )
1026
+ }
1027
+
1028
+ const healthUrl = this.healthUrl
1029
+ if (this.platform === 'win32' || !this.allowDetachedRestart || healthUrl === undefined) {
1030
+ await cleanupUpdateBackup(updateBackup)
1031
+ updateBackup = undefined
1032
+ await locked.release()
1033
+ locked = undefined
1034
+ this.updating = false
1035
+ return {
1036
+ fromVersion: this.currentVersion,
1037
+ toVersion: installedVersion,
1038
+ profile: final.profile.profile,
1039
+ restarting: false,
1040
+ manualRestartRequired: true,
1041
+ }
1042
+ }
1043
+
1044
+ try {
1045
+ await this.prepareRestart({
1046
+ pid: process.pid,
1047
+ execPath: process.execPath,
1048
+ args: [...process.execArgv, ...process.argv.slice(1)],
1049
+ cwd: process.cwd(),
1050
+ logPath: join(this.dshHome, 'logs', 'vision-toolkit-restart.log'),
1051
+ lockPath: locked.path,
1052
+ lockToken: locked.token,
1053
+ backupDir: updateBackup.dir,
1054
+ handoffPath: join(updateBackup.dir, 'handoff.json'),
1055
+ profileDir: final.profile.profileDir,
1056
+ pnpmPath: final.pnpmPath,
1057
+ packageName: VISION_TOOLKIT_PACKAGE,
1058
+ fromVersion: this.currentVersion,
1059
+ toVersion: installedVersion,
1060
+ healthUrl,
1061
+ baselineRuntimeReady: this.runtimeReady(),
1062
+ rollbackTimeoutMs: RESTART_ROLLBACK_TIMEOUT_MS,
1063
+ processKillGraceMs: 5_000,
1064
+ readinessTimeoutMs: 60_000,
1065
+ oldProcessExitTimeoutMs: OLD_PROCESS_EXIT_TIMEOUT_MS,
1066
+ })
1067
+ } catch (error) {
1068
+ throw new PluginUpdateError(
1069
+ 'restart-failed',
1070
+ `Plugin ${installedVersion} was installed, but automatic restart could not be prepared`,
1071
+ { cause: error },
1072
+ )
1073
+ }
1074
+ this.schedule(this.terminateCurrent, RESTART_DELAY_MS)
1075
+ updateBackup = undefined
1076
+ return {
1077
+ fromVersion: this.currentVersion,
1078
+ toVersion: installedVersion,
1079
+ profile: final.profile.profile,
1080
+ restarting: true,
1081
+ retryAfterMs: RESTART_RETRY_AFTER_MS,
1082
+ }
1083
+ } catch (error) {
1084
+ if (updateAttempted && updateContext !== undefined && updateBackup !== undefined) {
1085
+ let rollbackFailure: PluginUpdateError | undefined
1086
+ try {
1087
+ if (!await this.rollbackInstall(updateBackup, updateContext.profile, updateContext.pnpmPath)) {
1088
+ rollbackFailure = new PluginUpdateError(
1089
+ 'update-rollback-failed',
1090
+ 'Plugin update failed and the previous version could not be restored',
1091
+ { cause: error },
1092
+ )
1093
+ }
1094
+ } catch (rollbackError) {
1095
+ rollbackFailure = new PluginUpdateError(
1096
+ 'update-rollback-failed',
1097
+ 'Plugin update failed and the previous version could not be restored',
1098
+ { cause: rollbackError },
1099
+ )
1100
+ }
1101
+ try {
1102
+ await restoreUpdateBackup(updateContext.profile.profileDir, updateBackup)
1103
+ } catch (restoreError) {
1104
+ rollbackFailure = new PluginUpdateError(
1105
+ 'update-rollback-failed',
1106
+ 'Plugin update failed and the original profile files could not be restored',
1107
+ { cause: restoreError },
1108
+ )
1109
+ }
1110
+ if (rollbackFailure === undefined) {
1111
+ try {
1112
+ await cleanupUpdateBackup(updateBackup)
1113
+ } catch (cleanupError) {
1114
+ rollbackFailure = new PluginUpdateError(
1115
+ 'update-rollback-failed',
1116
+ 'Plugin update failed and its completed recovery backup could not be removed',
1117
+ { cause: cleanupError },
1118
+ )
1119
+ }
1120
+ } else {
1121
+ rollbackFailure = new PluginUpdateError(
1122
+ rollbackFailure.code,
1123
+ `${rollbackFailure.message}; recovery files preserved at ${updateBackup.dir}`,
1124
+ { cause: rollbackFailure },
1125
+ )
1126
+ }
1127
+ if (rollbackFailure !== undefined) error = rollbackFailure
1128
+ }
1129
+ const preserveLock = error instanceof PluginUpdateError
1130
+ && error.code === 'update-rollback-failed'
1131
+ && updateBackup !== undefined
1132
+ if (locked !== undefined && !preserveLock) {
1133
+ try { await locked.release() } catch (releaseError) {
1134
+ error = new PluginUpdateError('update-lock-release-failed', 'The profile update lock could not be released', {
1135
+ cause: releaseError,
1136
+ })
1137
+ }
1138
+ }
1139
+ this.updating = false
1140
+ throw error
1141
+ }
1142
+ }
1143
+ }