@dickpy/dsh-imagegen 1.0.1 → 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.
package/lib/index.js CHANGED
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { promises } from "node:fs";
5
5
  import { homedir } from "node:os";
6
6
  import path from "node:path";
7
+ import { spawn } from "node:child_process";
7
8
  //#region src/protocol.ts
8
9
  /**
9
10
  * Wire contract shared by the host and client halves of dsh-imagegen: the
@@ -19,6 +20,11 @@ const SETTINGS_API = {
19
20
  };
20
21
  /** The image-generation proxy route. */
21
22
  const GENERATE_API = "/api/dsh-imagegen/generate";
23
+ /** Host-mediated GitHub Release update routes. */
24
+ const UPDATE_API = {
25
+ check: "/api/dsh-imagegen/update/check",
26
+ apply: "/api/dsh-imagegen/update/apply"
27
+ };
22
28
  /**
23
29
  * Same-origin route family for the host-persisted generation history. Images
24
30
  * live as files under ~/.dsh/dsh-imagegen/images/ and are served back through
@@ -429,6 +435,106 @@ async function readHistoryImage(file) {
429
435
  }
430
436
  }
431
437
  //#endregion
438
+ //#region src/updater.ts
439
+ /** GitHub Release discovery and explicit, user-triggered plugin updates. */
440
+ /** Keep this in sync with package.json for each published release. */
441
+ const CURRENT_VERSION = "1.0.2";
442
+ const PACKAGE_NAME = "@dickpy/dsh-imagegen";
443
+ const RELEASES_URL = "https://api.github.com/repos/dickpy/dsh-imagegen/releases/latest";
444
+ const CHECK_TIMEOUT_MS = 1e4;
445
+ const CACHE_TTL_MS = 15 * 6e4;
446
+ let cached;
447
+ /** Compare stable semver triples; returns positive when `left` is newer. */
448
+ function compareVersions(left, right) {
449
+ const parse = (value) => {
450
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(value.trim());
451
+ if (match === null) return [
452
+ 0,
453
+ 0,
454
+ 0
455
+ ];
456
+ return [
457
+ Number(match[1]),
458
+ Number(match[2]),
459
+ Number(match[3])
460
+ ];
461
+ };
462
+ const a = parse(left);
463
+ const b = parse(right);
464
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
465
+ }
466
+ function normalizedReleaseVersion(tag) {
467
+ if (typeof tag !== "string" || !/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(tag.trim())) return void 0;
468
+ return tag.trim().replace(/^v/, "");
469
+ }
470
+ /** Read the latest stable GitHub Release, with a short host-side cache. */
471
+ async function checkForUpdate(fetchFn = fetch, now = Date.now()) {
472
+ if (cached !== void 0 && cached.expiresAt > now) return cached.value;
473
+ const response = await fetchFn(RELEASES_URL, {
474
+ headers: {
475
+ accept: "application/vnd.github+json",
476
+ "user-agent": "dsh-imagegen-update-check"
477
+ },
478
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS)
479
+ });
480
+ if (!response.ok) throw new Error(`GitHub Releases returned HTTP ${response.status}`);
481
+ const payload = await response.json();
482
+ if (payload === null || typeof payload !== "object") throw new Error("GitHub Releases returned malformed JSON");
483
+ const release = payload;
484
+ if (release.draft === true || release.prerelease === true) throw new Error("latest GitHub Release is not stable");
485
+ const latestVersion = normalizedReleaseVersion(release.tag_name);
486
+ if (latestVersion === void 0) throw new Error("latest GitHub Release has an invalid version tag");
487
+ const releaseUrl = typeof release.html_url === "string" ? release.html_url : "https://github.com/dickpy/dsh-imagegen/releases";
488
+ const value = {
489
+ currentVersion: CURRENT_VERSION,
490
+ latestVersion,
491
+ updateAvailable: compareVersions(latestVersion, CURRENT_VERSION) > 0,
492
+ releaseUrl,
493
+ ...typeof release.published_at === "string" ? { publishedAt: release.published_at } : {}
494
+ };
495
+ cached = {
496
+ expiresAt: now + CACHE_TTL_MS,
497
+ value
498
+ };
499
+ return value;
500
+ }
501
+ /** Resolve the profile that launched the current DSH process. */
502
+ function profileFromProcess(argv = process.argv, env = process.env) {
503
+ const envProfile = env.DSH_PROFILE?.trim();
504
+ if (envProfile !== void 0 && /^[a-zA-Z0-9_-]+$/.test(envProfile)) return envProfile;
505
+ const profileIndex = argv.indexOf("--profile");
506
+ const explicit = profileIndex >= 0 ? argv[profileIndex + 1]?.trim() : void 0;
507
+ if (explicit !== void 0 && /^[a-zA-Z0-9_-]+$/.test(explicit)) return explicit;
508
+ if (argv.includes("web")) return "web";
509
+ return "web";
510
+ }
511
+ /** Run the same official command documented for plugin installation. */
512
+ function installUpdate(version, spawnFn = spawn, argv = process.argv, env = process.env) {
513
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) return Promise.reject(/* @__PURE__ */ new Error("invalid update version"));
514
+ const profile = profileFromProcess(argv, env);
515
+ const child = spawnFn(process.platform === "win32" ? "dsh.cmd" : "dsh", [
516
+ "plugin",
517
+ "--profile",
518
+ profile,
519
+ "add",
520
+ `${PACKAGE_NAME}@${version}`
521
+ ], {
522
+ shell: process.platform === "win32",
523
+ stdio: "ignore"
524
+ });
525
+ return new Promise((resolve, reject) => {
526
+ child.once("error", reject);
527
+ child.once("exit", (code, signal) => {
528
+ if (code === 0) resolve();
529
+ else reject(/* @__PURE__ */ new Error(signal === null ? `plugin update exited with code ${code ?? "unknown"}` : `plugin update terminated by ${signal}`));
530
+ });
531
+ });
532
+ }
533
+ /** Test helper: clear the host-side Release cache. */
534
+ function clearUpdateCache() {
535
+ cached = void 0;
536
+ }
537
+ //#endregion
432
538
  //#region src/routes.ts
433
539
  /** Cap on JSON request bodies (settings ops and generate payloads are small). */
434
540
  const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024;
@@ -727,6 +833,66 @@ function makeRoutes(deps) {
727
833
  }
728
834
  }
729
835
  },
836
+ {
837
+ kind: "exact",
838
+ path: UPDATE_API.check,
839
+ handler: async (req, res) => {
840
+ if (!guard(req, res, "POST")) return;
841
+ try {
842
+ writeJson(res, 200, {
843
+ ok: true,
844
+ update: await checkForUpdate()
845
+ });
846
+ } catch (error) {
847
+ writeJson(res, 200, {
848
+ ok: false,
849
+ code: "update-check-failed",
850
+ message: messageOf(error)
851
+ });
852
+ }
853
+ }
854
+ },
855
+ {
856
+ kind: "exact",
857
+ path: UPDATE_API.apply,
858
+ handler: async (req, res) => {
859
+ if (!guard(req, res, "POST")) return;
860
+ const body = await readJsonBody(req);
861
+ const version = body !== void 0 && typeof body.version === "string" ? body.version.trim() : "";
862
+ if (version === "") {
863
+ writeJson(res, 200, {
864
+ ok: false,
865
+ code: "bad-request",
866
+ message: "update version is required"
867
+ });
868
+ return;
869
+ }
870
+ try {
871
+ const latest = await checkForUpdate();
872
+ if (!latest.updateAvailable || latest.latestVersion !== version) {
873
+ writeJson(res, 200, {
874
+ ok: false,
875
+ code: "update-not-available",
876
+ message: `version ${version} is not the latest available release`
877
+ });
878
+ return;
879
+ }
880
+ await installUpdate(version);
881
+ writeJson(res, 200, {
882
+ ok: true,
883
+ currentVersion: CURRENT_VERSION,
884
+ updatedVersion: version,
885
+ restartRequired: true
886
+ });
887
+ } catch (error) {
888
+ writeJson(res, 200, {
889
+ ok: false,
890
+ code: "update-failed",
891
+ message: messageOf(error)
892
+ });
893
+ }
894
+ }
895
+ },
730
896
  {
731
897
  kind: "exact",
732
898
  path: HISTORY_API.list,
@@ -942,4 +1108,4 @@ function apply(ctx, config) {
942
1108
  sync();
943
1109
  }
944
1110
  //#endregion
945
- export { Config, IMAGEGEN_GUIDANCE, ImageGenError, ImageGenSettingsNamespace, apply, generateImage, inject, makeRoutes, name };
1111
+ export { CURRENT_VERSION, Config, IMAGEGEN_GUIDANCE, ImageGenError, ImageGenSettingsNamespace, apply, checkForUpdate, clearUpdateCache, compareVersions, generateImage, inject, installUpdate, makeRoutes, name, profileFromProcess };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dickpy/dsh-imagegen",
3
3
  "description": "AI 生图 (image generation) plugin for the dsh web GUI: text-to-image and image-to-image through a configurable OpenAI-compatible endpoint (gpt-image-2 / gpt-image-1 / dall-e-3), with a settings card for api_url / api_key and a sidebar entry opening a split-pane generation studio.",
4
- "version": "1.0.1",
4
+ "version": "1.0.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -13,7 +13,7 @@ import { createPortal } from 'react-dom'
13
13
  import { Button, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
14
14
  import type { ImageGenApi } from './api.ts'
15
15
  import { errorMessage, tt } from './helpers.ts'
16
- import type { GeneratedImage, GenerateMode, GenerateRequest, HistoryEntry, HistoryImageRef } from '../protocol.ts'
16
+ import type { GeneratedImage, GenerateMode, GenerateRequest, HistoryEntry, HistoryImageRef, UpdateInfo } from '../protocol.ts'
17
17
  import type { ImageGenConfig, ImageGenScope } from './settings-scope.ts'
18
18
  import css from './panel.module.css'
19
19
 
@@ -124,6 +124,10 @@ export function ImageGenPanel(props: {
124
124
  const [history, setHistory] = useState<HistoryEntry[]>([])
125
125
  const [viewingHistoryId, setViewingHistoryId] = useState<string | null>(null)
126
126
  const [preview, setPreview] = useState<{ images: GeneratedImage[]; index: number } | null>(null)
127
+ const [update, setUpdate] = useState<UpdateInfo | null>(null)
128
+ const [updating, setUpdating] = useState(false)
129
+ const [updateMessage, setUpdateMessage] = useState<string | null>(null)
130
+ const [updateResult, setUpdateResult] = useState<'success' | 'failed' | null>(null)
127
131
  const fileInput = useRef<HTMLInputElement>(null)
128
132
  const elapsed = useElapsed(generating, startedAt)
129
133
 
@@ -137,6 +141,35 @@ export function ImageGenPanel(props: {
137
141
  return () => { disposed = true }
138
142
  }, [api])
139
143
 
144
+ // Release checks are host-mediated and intentionally best-effort: a GitHub
145
+ // outage must never make the image-generation studio unavailable.
146
+ useEffect(() => {
147
+ let disposed = false
148
+ api.updateCheck()
149
+ .then(info => {
150
+ if (!disposed && info.updateAvailable) setUpdate(info)
151
+ })
152
+ .catch(() => { /* update discovery is optional */ })
153
+ return () => { disposed = true }
154
+ }, [api])
155
+
156
+ const applyUpdate = async (): Promise<void> => {
157
+ if (update === null || updating) return
158
+ setUpdating(true)
159
+ setUpdateMessage(null)
160
+ setUpdateResult(null)
161
+ try {
162
+ const result = await api.updateApply(update.latestVersion)
163
+ setUpdateMessage(tt('update.success', { version: result.updatedVersion }))
164
+ setUpdateResult('success')
165
+ } catch {
166
+ setUpdateMessage(tt('update.failed'))
167
+ setUpdateResult('failed')
168
+ } finally {
169
+ setUpdating(false)
170
+ }
171
+ }
172
+
140
173
  /** Read an uploaded reference image into a data URL. */
141
174
  const acceptFile = (file: File | undefined): void => {
142
175
  if (file === undefined) return
@@ -293,6 +326,20 @@ export function ImageGenPanel(props: {
293
326
  ? <div className={css.banner} data-kind="warn">{tt('config.missing')}</div>
294
327
  : <div className={css.banner} data-kind="ok">{tt('config.configured', { url: apiUrl })}</div>}
295
328
 
329
+ {update !== null ? (
330
+ <div className={css.updateBanner} data-kind={updateResult === 'success' ? 'ok' : 'warn'}>
331
+ <span className={css.updateText}>
332
+ {updateMessage ?? tt('update.available', { version: update.latestVersion })}
333
+ </span>
334
+ <span className={css.updateActions}>
335
+ <a className={css.updateRelease} href={update.releaseUrl} target="_blank" rel="noreferrer">{tt('update.release')}</a>
336
+ <Button variant="primary" size="sm" disabled={updating || updateMessage !== null} onClick={() => { void applyUpdate() }}>
337
+ {updating ? tt('update.installing') : tt('update.install')}
338
+ </Button>
339
+ </span>
340
+ </div>
341
+ ) : null}
342
+
296
343
  <div className={css.studio}>
297
344
  {/* ---------------------------------------------------- config sidebar */}
298
345
  <aside className={css.config}>
package/src/client/api.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * data access path the panel uses — plain fetch, same origin.
4
4
  */
5
5
 
6
- import { GENERATE_API, HISTORY_API, type GenerateRequest, type GenerateResult, type HistoryEntry } from '../protocol.ts'
6
+ import { GENERATE_API, HISTORY_API, UPDATE_API, type GenerateRequest, type GenerateResult, type HistoryEntry, type UpdateInfo } from '../protocol.ts'
7
7
 
8
8
  /** Error carrying the route's JSON error message. */
9
9
  export class ImageGenApiError extends Error {
@@ -40,6 +40,24 @@ async function readEnvelope<T>(response: Response): Promise<T> {
40
40
 
41
41
  /** The browser half's data entry point. */
42
42
  export class ImageGenApi {
43
+ /** Ask the host to check the latest stable GitHub Release. */
44
+ async updateCheck(): Promise<UpdateInfo> {
45
+ const response = await fetch(UPDATE_API.check, { method: 'POST' })
46
+ const body = await readEnvelope<{ ok: true; update: UpdateInfo }>(response)
47
+ return body.update
48
+ }
49
+
50
+ /** Ask the host to install a previously discovered Release. */
51
+ async updateApply(version: string): Promise<{ updatedVersion: string; restartRequired: boolean }> {
52
+ const response = await fetch(UPDATE_API.apply, {
53
+ method: 'POST',
54
+ headers: { 'content-type': 'application/json' },
55
+ body: JSON.stringify({ version }),
56
+ })
57
+ const body = await readEnvelope<{ ok: true; updatedVersion: string; restartRequired: boolean }>(response)
58
+ return { updatedVersion: body.updatedVersion, restartRequired: body.restartRequired }
59
+ }
60
+
43
61
  /** Forward one generate request to the host proxy. */
44
62
  async generate(request: GenerateRequest): Promise<GenerateResult> {
45
63
  const response = await fetch(GENERATE_API, {
@@ -76,6 +76,13 @@ export const zh = {
76
76
  'config.missing': '尚未配置 API:请前往「设置 → 插件 → 可配置」为 AI 生图填写 api_url 与 api_key。',
77
77
  'config.configured': '已连接 {url}',
78
78
  'config.disabled': '插件已停用,请在设置中重新启用。',
79
+ // plugin update
80
+ 'update.available': '检测到新版本:{version}',
81
+ 'update.install': '在线更新',
82
+ 'update.installing': '更新中…',
83
+ 'update.success': '已更新到 {version},请重启 DSH',
84
+ 'update.failed': '更新失败,请重试',
85
+ 'update.release': '查看 Release',
79
86
  // settings card
80
87
  'settings.title': 'AI 生图(dsh-imagegen)',
81
88
  'settings.description': '配置图像生成 API 地址与密钥',
@@ -171,6 +178,12 @@ export const en: Record<keyof typeof zh, string> = {
171
178
  'config.missing': 'API not configured: open "Settings → Plugins → Configurable" and fill in api_url and api_key for AI Image.',
172
179
  'config.configured': 'Connected to {url}',
173
180
  'config.disabled': 'The plugin is disabled — re-enable it in Settings.',
181
+ 'update.available': 'A new version is available: {version}',
182
+ 'update.install': 'Update online',
183
+ 'update.installing': 'Updating…',
184
+ 'update.success': 'Updated to {version}; restart DSH to load it',
185
+ 'update.failed': 'Update failed; please try again',
186
+ 'update.release': 'View Release',
174
187
  'settings.title': 'AI Image (dsh-imagegen)',
175
188
  'settings.description': 'Configure the image generation API endpoint and key',
176
189
  'settings.apiUrl': 'API URL (api_url)',
@@ -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 {
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)
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
@@ -82,6 +88,15 @@ export interface GenerateResult {
82
88
  historyError?: string
83
89
  }
84
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
98
+ }
99
+
85
100
  /** One history image reference as the browser consumes it (a served URL). */
86
101
  export interface HistoryImageRef {
87
102
  /** Same-origin URL: `${HISTORY_API.image}/<file>`. */
package/src/routes.ts CHANGED
@@ -11,7 +11,8 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
11
11
  import { SettingsConflictError, settingsNamespace, type SettingsDescriptor } from '@deepseek-ai/dsh-settings'
12
12
  import { generateImage, type UpstreamConfig } from './engine.ts'
13
13
  import { appendHistory, clearHistory, listHistory, readHistoryImage, removeHistory } from './history-store.ts'
14
- import { GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, SETTINGS_API, type GeneratedImage, type GenerateRequest, type HistoryEntry, 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'
15
16
 
16
17
  /** Cap on JSON request bodies (settings ops and generate payloads are small). */
17
18
  const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024
@@ -304,6 +305,44 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
304
305
  }
305
306
  },
306
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
+ },
307
346
  // ----------------------------------------------------- history list
308
347
  {
309
348
  kind: 'exact',
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
+ }