@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.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +356 -0
- package/README.zh.md +354 -0
- package/assets/community-group-qr.png +0 -0
- package/assets/dsh-conversation-artifact.png +0 -0
- package/assets/dsh-conversation-image-qa-top.png +0 -0
- package/assets/dsh-conversation-image-qa.png +0 -0
- package/assets/dsh-conversation-pixel-diff.png +0 -0
- package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
- package/assets/dsh-conversation-screenshot-debugging.png +0 -0
- package/assets/dsh-conversation-tool-call.png +0 -0
- package/assets/dsh-conversation-vision-trace.png +0 -0
- package/assets/dsh-view-example.png +0 -0
- package/assets/hero-v2.png +0 -0
- package/assets/logo_aihubmix.png +0 -0
- package/assets/python-bootstrap.json +48 -0
- package/assets/skill/SKILL.md +329 -0
- package/assets/skill/UPSTREAM.json +71 -0
- package/assets/skill/references/gui.md +88 -0
- package/assets/skill/references/long-screenshot-ocr.md +77 -0
- package/assets/skill/references/restore-graphic.md +84 -0
- package/assets/skill/references/restore-structure.md +45 -0
- package/assets/skill/references/restore-ui.md +202 -0
- package/assets/social-preview.png +0 -0
- package/assets/upstream/README.md +18 -0
- package/assets/upstream/focus-hint-comparison-1.webp +0 -0
- package/assets/upstream/focus-hint-comparison-2.webp +0 -0
- package/assets/upstream/infographic-reference.webp +0 -0
- package/assets/upstream/infographic-result.webp +0 -0
- package/assets/upstream/ui-fast-restore-reference.webp +0 -0
- package/assets/upstream/ui-fast-restore-result.webp +0 -0
- package/assets/upstream/ui-result.webp +0 -0
- package/assets/upstream/ui-sketch.webp +0 -0
- package/assets/vision-model-test.png +0 -0
- package/assets/vision-settings.png +0 -0
- package/assets/wechat-reward.png +0 -0
- package/cordis.patch.yml +6 -0
- package/docs/aihubmix-gemini-vision.i18n.yaml +6 -0
- package/docs/aihubmix-gemini-vision.md +138 -0
- package/docs/aihubmix-gemini-vision.zh.md +138 -0
- package/docs/assets/aihubmix-api-keys.png +0 -0
- package/docs/assets/aihubmix-create-key.png +0 -0
- package/docs/assets/aihubmix-free-vision-model.png +0 -0
- package/docs/assets/aihubmix-home.png +0 -0
- package/docs/assets/aihubmix-sign-up.png +0 -0
- package/docs/assets/groq-console-home.png +0 -0
- package/docs/assets/groq-console-keys-login.png +0 -0
- package/docs/assets/groq-docs-quickstart.png +0 -0
- package/docs/assets/groq-docs-qwen3.6.png +0 -0
- package/docs/assets/groq-docs-vision.png +0 -0
- package/docs/assets/vision-settings.png +0 -0
- package/docs/dsh-desktop-install.md +67 -0
- package/docs/dsh-desktop-install.zh.md +67 -0
- package/docs/groq-qwen3.6-vision.i18n.yaml +6 -0
- package/docs/groq-qwen3.6-vision.md +236 -0
- package/docs/groq-qwen3.6-vision.zh.md +236 -0
- package/docs/python-runtime.i18n.yaml +6 -0
- package/docs/python-runtime.md +89 -0
- package/docs/python-runtime.zh.md +89 -0
- package/docs/requirements-traceability/README.i18n.yaml +6 -0
- package/docs/requirements-traceability/README.md +75 -0
- package/docs/requirements-traceability/README.zh.md +75 -0
- package/examples/ui-restoration/README.i18n.yaml +6 -0
- package/examples/ui-restoration/README.md +70 -0
- package/examples/ui-restoration/README.zh.md +70 -0
- package/examples/ui-restoration/assets/final-heatmap.png +0 -0
- package/examples/ui-restoration/assets/final-report.json +83 -0
- package/examples/ui-restoration/assets/implementation.png +0 -0
- package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
- package/examples/ui-restoration/assets/initial-report.json +83 -0
- package/examples/ui-restoration/assets/initial.png +0 -0
- package/examples/ui-restoration/assets/metrics.json +12 -0
- package/examples/ui-restoration/assets/reference.png +0 -0
- package/examples/ui-restoration/implementation.html +94 -0
- package/examples/ui-restoration/initial.html +57 -0
- package/lib/artifact-access.js +369 -0
- package/lib/artifact-access.js.map +1 -0
- package/lib/artifacts.js +56 -0
- package/lib/artifacts.js.map +1 -0
- package/lib/client.js +2099 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +172 -0
- package/lib/config.js.map +1 -0
- package/lib/defaults.js +6 -0
- package/lib/defaults.js.map +1 -0
- package/lib/errors.js +56 -0
- package/lib/errors.js.map +1 -0
- package/lib/evidence-cache.js +350 -0
- package/lib/evidence-cache.js.map +1 -0
- package/lib/exposure.js +247 -0
- package/lib/exposure.js.map +1 -0
- package/lib/image-input-variants.js +882 -0
- package/lib/image-input-variants.js.map +1 -0
- package/lib/index.js +105 -0
- package/lib/index.js.map +1 -0
- package/lib/paste-images.js +217 -0
- package/lib/paste-images.js.map +1 -0
- package/lib/paths.js +354 -0
- package/lib/paths.js.map +1 -0
- package/lib/plugin-update.js +1003 -0
- package/lib/plugin-update.js.map +1 -0
- package/lib/runtime-install.js +964 -0
- package/lib/runtime-install.js.map +1 -0
- package/lib/runtime-manager.js +133 -0
- package/lib/runtime-manager.js.map +1 -0
- package/lib/runtime.js +1678 -0
- package/lib/runtime.js.map +1 -0
- package/lib/skill.js +25 -0
- package/lib/skill.js.map +1 -0
- package/lib/tools.js +549 -0
- package/lib/tools.js.map +1 -0
- package/lib/types/artifact-access.d.ts +61 -0
- package/lib/types/artifact-access.d.ts.map +1 -0
- package/lib/types/artifacts.d.ts +42 -0
- package/lib/types/artifacts.d.ts.map +1 -0
- package/lib/types/client/display-config.d.ts +24 -0
- package/lib/types/client/display-config.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +350 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/model-variants-hider.d.ts +40 -0
- package/lib/types/client/model-variants-hider.d.ts.map +1 -0
- package/lib/types/client/paste-images.d.ts +126 -0
- package/lib/types/client/paste-images.d.ts.map +1 -0
- package/lib/types/config.d.ts +123 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/defaults.d.ts +6 -0
- package/lib/types/defaults.d.ts.map +1 -0
- package/lib/types/errors.d.ts +35 -0
- package/lib/types/errors.d.ts.map +1 -0
- package/lib/types/evidence-cache.d.ts +91 -0
- package/lib/types/evidence-cache.d.ts.map +1 -0
- package/lib/types/exposure.d.ts +50 -0
- package/lib/types/exposure.d.ts.map +1 -0
- package/lib/types/image-input-variants.d.ts +144 -0
- package/lib/types/image-input-variants.d.ts.map +1 -0
- package/lib/types/index.d.ts +19 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/paste-images.d.ts +73 -0
- package/lib/types/paste-images.d.ts.map +1 -0
- package/lib/types/paths.d.ts +117 -0
- package/lib/types/paths.d.ts.map +1 -0
- package/lib/types/plugin-update.d.ts +119 -0
- package/lib/types/plugin-update.d.ts.map +1 -0
- package/lib/types/runtime-install.d.ts +92 -0
- package/lib/types/runtime-install.d.ts.map +1 -0
- package/lib/types/runtime-manager.d.ts +60 -0
- package/lib/types/runtime-manager.d.ts.map +1 -0
- package/lib/types/runtime.d.ts +412 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/skill.d.ts +14 -0
- package/lib/types/skill.d.ts.map +1 -0
- package/lib/types/tools.d.ts +35 -0
- package/lib/types/tools.d.ts.map +1 -0
- package/lib/types/upstream.d.ts +233 -0
- package/lib/types/upstream.d.ts.map +1 -0
- package/lib/types/version.d.ts +15 -0
- package/lib/types/version.d.ts.map +1 -0
- package/lib/types/web-request.d.ts +11 -0
- package/lib/types/web-request.d.ts.map +1 -0
- package/lib/types/web.d.ts +118 -0
- package/lib/types/web.d.ts.map +1 -0
- package/lib/upstream.js +860 -0
- package/lib/upstream.js.map +1 -0
- package/lib/version.js +18 -0
- package/lib/version.js.map +1 -0
- package/lib/web-request.js +29 -0
- package/lib/web-request.js.map +1 -0
- package/lib/web.js +462 -0
- package/lib/web.js.map +1 -0
- package/package.json +149 -0
- package/patches/vision-tools-dsh.patch +970 -0
- package/runtime/requirements.lock +3 -0
- package/src/artifact-access.ts +386 -0
- package/src/artifacts.ts +85 -0
- package/src/client/display-config.ts +62 -0
- package/src/client/index.tsx +1580 -0
- package/src/client/model-variants-hider.ts +159 -0
- package/src/client/paste-images.tsx +747 -0
- package/src/config.ts +290 -0
- package/src/defaults.ts +5 -0
- package/src/errors.ts +62 -0
- package/src/evidence-cache.ts +418 -0
- package/src/exposure.ts +265 -0
- package/src/image-input-variants.ts +996 -0
- package/src/index.ts +141 -0
- package/src/paste-images.ts +286 -0
- package/src/paths.ts +383 -0
- package/src/plugin-update.ts +1143 -0
- package/src/runtime-install.ts +1161 -0
- package/src/runtime-manager.ts +174 -0
- package/src/runtime.ts +2189 -0
- package/src/skill.ts +35 -0
- package/src/tools.ts +691 -0
- package/src/upstream.ts +1078 -0
- package/src/version.ts +37 -0
- package/src/web-request.ts +27 -0
- package/src/web.ts +594 -0
- package/vendor/agent-vision-toolkit/CHANGELOG.md +30 -0
- package/vendor/agent-vision-toolkit/LICENSE +21 -0
- package/vendor/agent-vision-toolkit/README.md +405 -0
- package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +94 -0
- package/vendor/agent-vision-toolkit/bin/crop +90 -0
- package/vendor/agent-vision-toolkit/bin/detect +13 -0
- package/vendor/agent-vision-toolkit/bin/glance +93 -0
- package/vendor/agent-vision-toolkit/bin/ground +13 -0
- package/vendor/agent-vision-toolkit/bin/trace +129 -0
- package/vendor/agent-vision-toolkit/detect.py +60 -0
- package/vendor/agent-vision-toolkit/ground.py +267 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +420 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
- package/vendor/agent-vision-toolkit/tests/test_vision_client.py +450 -0
- 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
|
+
}
|