@anionex/dsh-vision-toolkit 0.1.11 → 0.1.13

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