@dickpy/dsh-imagegen 1.0.0 → 1.0.2

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.
@@ -166,6 +166,56 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
166
166
  border-color: var(--dsw-alias-state-warn-primary);
167
167
  }
168
168
 
169
+ .updateBanner {
170
+ display: flex;
171
+ align-items: center;
172
+ justify-content: space-between;
173
+ gap: 12px;
174
+ flex: none;
175
+ padding: 7px 10px 7px 12px;
176
+ font-size: 12px;
177
+ line-height: 1.5;
178
+ border-radius: 10px;
179
+ border: 1px solid var(--dsw-alias-state-warn-primary);
180
+ color: var(--dsw-alias-state-warn-primary);
181
+ overflow-wrap: anywhere;
182
+ }
183
+
184
+ .updateBanner[data-kind='ok'] {
185
+ color: var(--dsw-alias-state-success-primary);
186
+ border-color: var(--dsw-alias-state-success-primary);
187
+ }
188
+
189
+ .updateText {
190
+ min-width: 0;
191
+ }
192
+
193
+ .updateActions {
194
+ display: inline-flex;
195
+ align-items: center;
196
+ gap: 10px;
197
+ flex: none;
198
+ }
199
+
200
+ .updateRelease {
201
+ color: inherit;
202
+ text-decoration: underline;
203
+ text-underline-offset: 2px;
204
+ white-space: nowrap;
205
+ }
206
+
207
+ @media (max-width: 700px) {
208
+ .updateBanner {
209
+ align-items: flex-start;
210
+ flex-direction: column;
211
+ }
212
+
213
+ .updateActions {
214
+ width: 100%;
215
+ justify-content: space-between;
216
+ }
217
+ }
218
+
169
219
  /* --- studio split: left config sidebar (narrow) + right canvas (wide) --------- */
170
220
 
171
221
  .studio {
@@ -17,6 +17,16 @@ const HISTORY_DIR = path.join(homedir(), '.dsh', 'dsh-imagegen')
17
17
  const INDEX_PATH = path.join(HISTORY_DIR, 'index.json')
18
18
  const IMAGES_DIR = path.join(HISTORY_DIR, 'images')
19
19
 
20
+ // History mutations read and replace one shared index. Serialize them so
21
+ // overlapping requests cannot each read an old index and lose the other's row.
22
+ let pendingMutation: Promise<void> = Promise.resolve()
23
+
24
+ function mutateHistory<T>(operation: () => Promise<T>): Promise<T> {
25
+ const next = pendingMutation.then(operation, operation)
26
+ pendingMutation = next.then(() => undefined, () => undefined)
27
+ return next
28
+ }
29
+
20
30
  /** One image's on-disk record (file name + mime, never base64). */
21
31
  interface StoredImage {
22
32
  file: string
@@ -152,55 +162,66 @@ export async function listHistory(): Promise<HistoryEntry[]> {
152
162
 
153
163
  /** Append one generation, evicting the oldest beyond HISTORY_MAX. */
154
164
  export async function appendHistory(input: HistoryEntryInput): Promise<HistoryEntry[]> {
155
- await ensureDirs()
156
- const prefix = safeId(input.id)
157
- const storedImages: StoredImage[] = []
158
- for (let index = 0; index < input.images.length; index++) {
159
- const image = input.images[index]!
160
- const file = `${prefix}-${index}.${extensionOf(image.mime)}`
161
- await fs.writeFile(path.join(IMAGES_DIR, file), Buffer.from(image.b64, 'base64'))
162
- storedImages.push({
163
- file,
164
- mime: image.mime,
165
- ...image.revisedPrompt === undefined ? {} : { revisedPrompt: image.revisedPrompt },
166
- })
167
- }
168
- const entry: StoredEntry = {
169
- id: input.id,
170
- createdAt: input.createdAt,
171
- mode: input.mode,
172
- model: input.model,
173
- prompt: input.prompt,
174
- size: input.size,
175
- quality: input.quality,
176
- detail: input.detail,
177
- n: input.n,
178
- images: storedImages,
179
- ...input.refName === undefined ? {} : { refName: input.refName },
180
- }
181
- const merged = [entry, ...await readIndex()]
182
- const kept = merged.slice(0, HISTORY_MAX)
183
- for (const dropped of merged.slice(HISTORY_MAX)) await removeEntryFiles(dropped)
184
- await writeIndex(kept)
185
- return kept.map(toWire)
165
+ return mutateHistory(async () => {
166
+ await ensureDirs()
167
+ const prefix = safeId(input.id)
168
+ const storedImages: StoredImage[] = []
169
+ try {
170
+ for (let index = 0; index < input.images.length; index++) {
171
+ const image = input.images[index]!
172
+ const file = `${prefix}-${index}.${extensionOf(image.mime)}`
173
+ await fs.writeFile(path.join(IMAGES_DIR, file), Buffer.from(image.b64, 'base64'))
174
+ storedImages.push({
175
+ file,
176
+ mime: image.mime,
177
+ ...image.revisedPrompt === undefined ? {} : { revisedPrompt: image.revisedPrompt },
178
+ })
179
+ }
180
+ } catch (error) {
181
+ await removeEntryFiles({ images: storedImages } as StoredEntry)
182
+ throw error
183
+ }
184
+ const entry: StoredEntry = {
185
+ id: input.id,
186
+ createdAt: input.createdAt,
187
+ mode: input.mode,
188
+ model: input.model,
189
+ prompt: input.prompt,
190
+ size: input.size,
191
+ quality: input.quality,
192
+ detail: input.detail,
193
+ n: input.n,
194
+ images: storedImages,
195
+ ...input.refName === undefined ? {} : { refName: input.refName },
196
+ }
197
+ const merged = [entry, ...await readIndex()]
198
+ const kept = merged.slice(0, HISTORY_MAX)
199
+ for (const dropped of merged.slice(HISTORY_MAX)) await removeEntryFiles(dropped)
200
+ await writeIndex(kept)
201
+ return kept.map(toWire)
202
+ })
186
203
  }
187
204
 
188
205
  /** Remove one entry (and its image files). */
189
206
  export async function removeHistory(id: string): Promise<HistoryEntry[]> {
190
- const previous = await readIndex()
191
- const target = previous.find(entry => entry.id === id)
192
- if (target !== undefined) await removeEntryFiles(target)
193
- const kept = previous.filter(entry => entry.id !== id)
194
- await writeIndex(kept)
195
- return kept.map(toWire)
207
+ return mutateHistory(async () => {
208
+ const previous = await readIndex()
209
+ const target = previous.find(entry => entry.id === id)
210
+ if (target !== undefined) await removeEntryFiles(target)
211
+ const kept = previous.filter(entry => entry.id !== id)
212
+ await writeIndex(kept)
213
+ return kept.map(toWire)
214
+ })
196
215
  }
197
216
 
198
217
  /** Remove every entry (and all image files). */
199
218
  export async function clearHistory(): Promise<HistoryEntry[]> {
200
- const previous = await readIndex()
201
- for (const entry of previous) await removeEntryFiles(entry)
202
- await writeIndex([])
203
- return []
219
+ return mutateHistory(async () => {
220
+ const previous = await readIndex()
221
+ for (const entry of previous) await removeEntryFiles(entry)
222
+ await writeIndex([])
223
+ return []
224
+ })
204
225
  }
205
226
 
206
227
  /** Read one stored image file by its (validated) file name. */
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export const inject = ['webServer', 'systemPrompt']
26
26
  // contract only requires name / inject / Config / apply.
27
27
  export { makeRoutes } from './routes.ts'
28
28
  export { generateImage, ImageGenError } from './engine.ts'
29
+ export { checkForUpdate, clearUpdateCache, compareVersions, CURRENT_VERSION, installUpdate, profileFromProcess } from './updater.ts'
29
30
 
30
31
  /** The branded settings namespace of this plugin (the card edits it). */
31
32
  export const ImageGenSettingsNamespace = settingsNamespace(IMAGEGEN_SETTINGS_NAMESPACE)
@@ -57,7 +58,7 @@ const DEFAULT_ANNOUNCE = true
57
58
  const SECTION_ORDER = 150
58
59
 
59
60
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
60
- export const IMAGEGEN_GUIDANCE = '本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口;本地插件(源码位于 E:\\dsh-plugin,独立于 dsh-web-ui 插件全家桶)。能力:对接 OpenAI 兼容图像生成 API(模型 gpt-image-2),支持文生图(/images/generations)与图生图(/images/edits,上传参考图);API 地址与密钥在 GUI「设置 → 插件 → 可配置」中配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务。用户提到「生图 / 绘画 / 生成图片 / gpt-image-2 / 文生图 / 图生图」时即指本插件,请据此协作。'
61
+ export const IMAGEGEN_GUIDANCE = '本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口。能力:对接 OpenAI 兼容图像生成 API(模型 gpt-image-2),支持文生图(/images/generations)与图生图(/images/edits,上传参考图);API 地址与密钥在 GUI「设置 → 插件 → 可配置」中配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务。用户提到「生图 / 绘画 / 生成图片 / gpt-image-2 / 文生图 / 图生图」时即指本插件,请据此协作。'
61
62
 
62
63
  /** Effective config (schema defaults applied). */
63
64
  interface EffectiveConfig {
package/src/protocol.ts CHANGED
@@ -16,6 +16,12 @@ export const SETTINGS_API = {
16
16
  /** The image-generation proxy route. */
17
17
  export const GENERATE_API = '/api/dsh-imagegen/generate'
18
18
 
19
+ /** Host-mediated GitHub Release update routes. */
20
+ export const UPDATE_API = {
21
+ check: '/api/dsh-imagegen/update/check',
22
+ apply: '/api/dsh-imagegen/update/apply',
23
+ } as const
24
+
19
25
  /**
20
26
  * Same-origin route family for the host-persisted generation history. Images
21
27
  * live as files under ~/.dsh/dsh-imagegen/images/ and are served back through
@@ -58,6 +64,8 @@ export interface GenerateRequest {
58
64
  detail: string
59
65
  /** Reference image as a data URL (edit mode only). */
60
66
  image?: string
67
+ /** Original reference-image name, retained in the history entry. */
68
+ refName?: string
61
69
  }
62
70
 
63
71
  /** One generated image, normalized host-side to base64 so the browser never
@@ -74,6 +82,19 @@ export interface GeneratedImage {
74
82
  /** Successful generate outcome. */
75
83
  export interface GenerateResult {
76
84
  images: GeneratedImage[]
85
+ /** Updated host-persisted history, when returned by the generate route. */
86
+ history?: HistoryEntry[]
87
+ /** Persistence failure after images were successfully generated. */
88
+ historyError?: string
89
+ }
90
+
91
+ /** GitHub Release update information shown by the client. */
92
+ export interface UpdateInfo {
93
+ currentVersion: string
94
+ latestVersion: string
95
+ updateAvailable: boolean
96
+ releaseUrl: string
97
+ publishedAt?: string
77
98
  }
78
99
 
79
100
  /** One history image reference as the browser consumes it (a served URL). */
package/src/routes.ts CHANGED
@@ -6,11 +6,13 @@
6
6
  */
7
7
 
8
8
  import type { IncomingMessage, ServerResponse } from 'node:http'
9
+ import { randomUUID } from 'node:crypto'
9
10
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
10
11
  import { SettingsConflictError, settingsNamespace, type SettingsDescriptor } from '@deepseek-ai/dsh-settings'
11
12
  import { generateImage, type UpstreamConfig } from './engine.ts'
12
13
  import { appendHistory, clearHistory, listHistory, readHistoryImage, removeHistory } from './history-store.ts'
13
- import { GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, SETTINGS_API, type GeneratedImage, type GenerateRequest, type HistoryEntryInput } from './protocol.ts'
14
+ import { checkForUpdate, CURRENT_VERSION, installUpdate } from './updater.ts'
15
+ import { GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, SETTINGS_API, UPDATE_API, type GeneratedImage, type GenerateRequest, type HistoryEntry, type HistoryEntryInput } from './protocol.ts'
14
16
 
15
17
  /** Cap on JSON request bodies (settings ops and generate payloads are small). */
16
18
  const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024
@@ -31,6 +33,14 @@ export interface ImageGenRoutesDeps {
31
33
  settings: SettingsSeam
32
34
  /** Resolve the current upstream config (composition entry + settings). */
33
35
  resolve: () => UpstreamConfig
36
+ /** Overrideable history backend, primarily for host integration tests. */
37
+ history?: {
38
+ list: () => Promise<HistoryEntry[]>
39
+ append: (entry: HistoryEntryInput) => Promise<HistoryEntry[]>
40
+ remove: (id: string) => Promise<HistoryEntry[]>
41
+ clear: () => Promise<HistoryEntry[]>
42
+ readImage: (file: string) => Promise<{ data: Buffer; mime: string } | undefined>
43
+ }
34
44
  }
35
45
 
36
46
  /** Loopback literal check plus browser same-origin markers (mirrors dsh-ssh). */
@@ -166,6 +176,13 @@ function failureOf(error: unknown): { ok: false; code: string; message: string }
166
176
  * @returns the route registrations.
167
177
  */
168
178
  export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
179
+ const history = deps.history ?? {
180
+ list: listHistory,
181
+ append: appendHistory,
182
+ remove: removeHistory,
183
+ clear: clearHistory,
184
+ readImage: readHistoryImage,
185
+ }
169
186
  const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
170
187
  if (!isLoopbackRequest(req)) {
171
188
  writeJson(res, 403, { error: 'forbidden: loopback-only' })
@@ -257,10 +274,28 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
257
274
  n: typeof body.n === 'number' ? body.n : 1,
258
275
  detail: typeof body.detail === 'string' ? body.detail : '',
259
276
  ...typeof body.image === 'string' && body.image !== '' ? { image: body.image } : {},
277
+ ...typeof body.refName === 'string' && body.refName !== '' ? { refName: body.refName } : {},
260
278
  }
261
279
  try {
262
280
  const result = await generateImage(deps.resolve(), request)
263
- writeJson(res, 200, { ok: true, ...result })
281
+ try {
282
+ const entries = await history.append({
283
+ id: randomUUID(),
284
+ createdAt: Date.now(),
285
+ mode: request.mode,
286
+ model: request.model,
287
+ prompt: request.prompt,
288
+ size: request.size,
289
+ quality: request.quality,
290
+ detail: request.detail,
291
+ n: request.n,
292
+ images: result.images,
293
+ ...request.refName === undefined ? {} : { refName: request.refName },
294
+ })
295
+ writeJson(res, 200, { ok: true, ...result, history: entries })
296
+ } catch (error) {
297
+ writeJson(res, 200, { ok: true, ...result, historyError: messageOf(error) })
298
+ }
264
299
  } catch (error) {
265
300
  const message = error instanceof Error ? error.message : String(error)
266
301
  const code = error instanceof Error && 'code' in error && typeof (error as { code?: unknown }).code === 'string'
@@ -270,6 +305,44 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
270
305
  }
271
306
  },
272
307
  },
308
+ // ----------------------------------------------- update check
309
+ {
310
+ kind: 'exact',
311
+ path: UPDATE_API.check,
312
+ handler: async (req, res) => {
313
+ if (!guard(req, res, 'POST')) return
314
+ try {
315
+ writeJson(res, 200, { ok: true, update: await checkForUpdate() })
316
+ } catch (error) {
317
+ writeJson(res, 200, { ok: false, code: 'update-check-failed', message: messageOf(error) })
318
+ }
319
+ },
320
+ },
321
+ // ----------------------------------------------- update apply
322
+ {
323
+ kind: 'exact',
324
+ path: UPDATE_API.apply,
325
+ handler: async (req, res) => {
326
+ if (!guard(req, res, 'POST')) return
327
+ const body = await readJsonBody(req)
328
+ const version = body !== undefined && typeof body.version === 'string' ? body.version.trim() : ''
329
+ if (version === '') {
330
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'update version is required' })
331
+ return
332
+ }
333
+ try {
334
+ const latest = await checkForUpdate()
335
+ if (!latest.updateAvailable || latest.latestVersion !== version) {
336
+ writeJson(res, 200, { ok: false, code: 'update-not-available', message: `version ${version} is not the latest available release` })
337
+ return
338
+ }
339
+ await installUpdate(version)
340
+ writeJson(res, 200, { ok: true, currentVersion: CURRENT_VERSION, updatedVersion: version, restartRequired: true })
341
+ } catch (error) {
342
+ writeJson(res, 200, { ok: false, code: 'update-failed', message: messageOf(error) })
343
+ }
344
+ },
345
+ },
273
346
  // ----------------------------------------------------- history list
274
347
  {
275
348
  kind: 'exact',
@@ -277,7 +350,7 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
277
350
  handler: async (req, res) => {
278
351
  if (!guard(req, res, 'POST')) return
279
352
  try {
280
- writeJson(res, 200, { ok: true, entries: await listHistory() })
353
+ writeJson(res, 200, { ok: true, entries: await history.list() })
281
354
  } catch (error) {
282
355
  writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
283
356
  }
@@ -300,7 +373,7 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
300
373
  return
301
374
  }
302
375
  try {
303
- writeJson(res, 200, { ok: true, entries: await appendHistory(entry) })
376
+ writeJson(res, 200, { ok: true, entries: await history.append(entry) })
304
377
  } catch (error) {
305
378
  writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
306
379
  }
@@ -319,7 +392,7 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
319
392
  return
320
393
  }
321
394
  try {
322
- writeJson(res, 200, { ok: true, entries: await removeHistory(id) })
395
+ writeJson(res, 200, { ok: true, entries: await history.remove(id) })
323
396
  } catch (error) {
324
397
  writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
325
398
  }
@@ -332,7 +405,7 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
332
405
  handler: async (req, res) => {
333
406
  if (!guard(req, res, 'POST')) return
334
407
  try {
335
- writeJson(res, 200, { ok: true, entries: await clearHistory() })
408
+ writeJson(res, 200, { ok: true, entries: await history.clear() })
336
409
  } catch (error) {
337
410
  writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
338
411
  }
@@ -356,7 +429,7 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
356
429
  writeJson(res, 404, { error: 'not found' })
357
430
  return
358
431
  }
359
- const found = await readHistoryImage(file)
432
+ const found = await history.readImage(file)
360
433
  if (found === undefined) {
361
434
  writeJson(res, 404, { error: 'not found' })
362
435
  return
package/src/updater.ts ADDED
@@ -0,0 +1,116 @@
1
+ /** GitHub Release discovery and explicit, user-triggered plugin updates. */
2
+
3
+ import { spawn, type ChildProcess } from 'node:child_process'
4
+
5
+ /** Keep this in sync with package.json for each published release. */
6
+ export const CURRENT_VERSION = '1.0.2'
7
+ export const PACKAGE_NAME = '@dickpy/dsh-imagegen'
8
+ export const RELEASES_URL = 'https://api.github.com/repos/dickpy/dsh-imagegen/releases/latest'
9
+
10
+ const CHECK_TIMEOUT_MS = 10_000
11
+ const CACHE_TTL_MS = 15 * 60_000
12
+
13
+ export interface UpdateInfo {
14
+ currentVersion: string
15
+ latestVersion: string
16
+ updateAvailable: boolean
17
+ releaseUrl: string
18
+ publishedAt?: string
19
+ }
20
+
21
+ interface GitHubRelease {
22
+ tag_name?: unknown
23
+ html_url?: unknown
24
+ published_at?: unknown
25
+ draft?: unknown
26
+ prerelease?: unknown
27
+ }
28
+
29
+ let cached: { expiresAt: number; value: UpdateInfo } | undefined
30
+
31
+ /** Compare stable semver triples; returns positive when `left` is newer. */
32
+ export function compareVersions(left: string, right: string): number {
33
+ const parse = (value: string): [number, number, number] => {
34
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(value.trim())
35
+ if (match === null) return [0, 0, 0]
36
+ return [Number(match[1]), Number(match[2]), Number(match[3])]
37
+ }
38
+ const a = parse(left)
39
+ const b = parse(right)
40
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]
41
+ }
42
+
43
+ function normalizedReleaseVersion(tag: unknown): string | undefined {
44
+ if (typeof tag !== 'string' || !/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(tag.trim())) return undefined
45
+ return tag.trim().replace(/^v/, '')
46
+ }
47
+
48
+ /** Read the latest stable GitHub Release, with a short host-side cache. */
49
+ export async function checkForUpdate(fetchFn: typeof fetch = fetch, now = Date.now()): Promise<UpdateInfo> {
50
+ if (cached !== undefined && cached.expiresAt > now) return cached.value
51
+ const response = await fetchFn(RELEASES_URL, {
52
+ headers: {
53
+ accept: 'application/vnd.github+json',
54
+ 'user-agent': 'dsh-imagegen-update-check',
55
+ },
56
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
57
+ })
58
+ if (!response.ok) throw new Error(`GitHub Releases returned HTTP ${response.status}`)
59
+ const payload: unknown = await response.json()
60
+ if (payload === null || typeof payload !== 'object') throw new Error('GitHub Releases returned malformed JSON')
61
+ const release = payload as GitHubRelease
62
+ if (release.draft === true || release.prerelease === true) throw new Error('latest GitHub Release is not stable')
63
+ const latestVersion = normalizedReleaseVersion(release.tag_name)
64
+ if (latestVersion === undefined) throw new Error('latest GitHub Release has an invalid version tag')
65
+ const releaseUrl = typeof release.html_url === 'string' ? release.html_url : 'https://github.com/dickpy/dsh-imagegen/releases'
66
+ const value: UpdateInfo = {
67
+ currentVersion: CURRENT_VERSION,
68
+ latestVersion,
69
+ updateAvailable: compareVersions(latestVersion, CURRENT_VERSION) > 0,
70
+ releaseUrl,
71
+ ...typeof release.published_at === 'string' ? { publishedAt: release.published_at } : {},
72
+ }
73
+ cached = { expiresAt: now + CACHE_TTL_MS, value }
74
+ return value
75
+ }
76
+
77
+ /** Resolve the profile that launched the current DSH process. */
78
+ export function profileFromProcess(argv: readonly string[] = process.argv, env: NodeJS.ProcessEnv = process.env): string {
79
+ const envProfile = env.DSH_PROFILE?.trim()
80
+ if (envProfile !== undefined && /^[a-zA-Z0-9_-]+$/.test(envProfile)) return envProfile
81
+ const profileIndex = argv.indexOf('--profile')
82
+ const explicit = profileIndex >= 0 ? argv[profileIndex + 1]?.trim() : undefined
83
+ if (explicit !== undefined && /^[a-zA-Z0-9_-]+$/.test(explicit)) return explicit
84
+ if (argv.includes('web')) return 'web'
85
+ return 'web'
86
+ }
87
+
88
+ /** Run the same official command documented for plugin installation. */
89
+ export function installUpdate(
90
+ version: string,
91
+ spawnFn: typeof spawn = spawn,
92
+ argv: readonly string[] = process.argv,
93
+ env: NodeJS.ProcessEnv = process.env,
94
+ ): Promise<void> {
95
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
96
+ return Promise.reject(new Error('invalid update version'))
97
+ }
98
+ const profile = profileFromProcess(argv, env)
99
+ const command = process.platform === 'win32' ? 'dsh.cmd' : 'dsh'
100
+ const child = spawnFn(command, ['plugin', '--profile', profile, 'add', `${PACKAGE_NAME}@${version}`], {
101
+ shell: process.platform === 'win32',
102
+ stdio: 'ignore',
103
+ }) as ChildProcess
104
+ return new Promise((resolve, reject) => {
105
+ child.once('error', reject)
106
+ child.once('exit', (code, signal) => {
107
+ if (code === 0) resolve()
108
+ else reject(new Error(signal === null ? `plugin update exited with code ${code ?? 'unknown'}` : `plugin update terminated by ${signal}`))
109
+ })
110
+ })
111
+ }
112
+
113
+ /** Test helper: clear the host-side Release cache. */
114
+ export function clearUpdateCache(): void {
115
+ cached = undefined
116
+ }