@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.
- package/README.md +36 -22
- package/docs/images/image-generation-studio.png +0 -0
- package/docs/images/plugin-settings.png +0 -0
- package/lib/client.js +187 -112
- package/lib/client.js.map +1 -1
- package/lib/index.js +267 -52
- package/package.json +3 -2
- package/src/client/ImageGenPanel.tsx +51 -19
- package/src/client/api.ts +25 -14
- package/src/client/locales.ts +14 -1
- package/src/client/panel.module.css +50 -0
- package/src/history-store.ts +62 -41
- package/src/index.ts +2 -1
- package/src/protocol.ts +21 -0
- package/src/routes.ts +80 -7
- package/src/updater.ts +116 -0
|
@@ -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/history-store.ts
CHANGED
|
@@ -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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|