dsh-plugin-upgrader 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/CHANGELOG.md +28 -0
- package/README.en.md +52 -21
- package/README.md +57 -20
- package/lib/client.js +959 -0
- package/lib/host/bundle.d.ts +52 -0
- package/lib/host/bundle.d.ts.map +1 -0
- package/lib/host/bundle.js +73 -0
- package/lib/host/bundle.js.map +1 -0
- package/lib/host/http.d.ts +46 -0
- package/lib/host/http.d.ts.map +1 -0
- package/lib/host/http.js +104 -0
- package/lib/host/http.js.map +1 -0
- package/lib/host/install.d.ts +71 -0
- package/lib/host/install.d.ts.map +1 -0
- package/lib/host/install.js +161 -0
- package/lib/host/install.js.map +1 -0
- package/lib/host/manager.d.ts +151 -0
- package/lib/host/manager.d.ts.map +1 -0
- package/lib/host/manager.js +28 -0
- package/lib/host/manager.js.map +1 -0
- package/lib/host/overview.d.ts +52 -0
- package/lib/host/overview.d.ts.map +1 -0
- package/lib/host/overview.js +109 -0
- package/lib/host/overview.js.map +1 -0
- package/lib/host/registry.d.ts +116 -0
- package/lib/host/registry.d.ts.map +1 -0
- package/lib/host/registry.js +177 -0
- package/lib/host/registry.js.map +1 -0
- package/lib/host/repo.d.ts +62 -0
- package/lib/host/repo.d.ts.map +1 -0
- package/lib/host/repo.js +102 -0
- package/lib/host/repo.js.map +1 -0
- package/lib/host/version.d.ts +67 -0
- package/lib/host/version.d.ts.map +1 -0
- package/lib/host/version.js +114 -0
- package/lib/host/version.js.map +1 -0
- package/lib/index.d.ts +12 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +254 -0
- package/lib/index.js.map +1 -0
- package/package.json +32 -14
- package/screenshots/history.png +0 -0
- package/screenshots/selection.png +0 -0
- package/screenshots.json +0 -4
- package/src/client.js +0 -1136
- package/src/index.js +0 -275
- package/src/lib/bundle.js +0 -64
- package/src/lib/http.js +0 -93
- package/src/lib/install.js +0 -163
- package/src/lib/overview.js +0 -108
- package/src/lib/registry.js +0 -188
- package/src/lib/repo.js +0 -91
- package/src/lib/version.js +0 -107
package/src/index.js
DELETED
|
@@ -1,275 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Host half of the dsh-plugin-upgrader bundle.
|
|
3
|
-
*
|
|
4
|
-
* The Plugins page's own tabs run in the browser, and the browser cannot read
|
|
5
|
-
* the profile's dependency list or reach the npm registry reliably (CORS,
|
|
6
|
-
* private registries, tokens). This half owns the four things the page needs:
|
|
7
|
-
*
|
|
8
|
-
* GET /api/plugin-upgrader/overview installed bundles + published versions
|
|
9
|
-
* POST /api/plugin-upgrader/job start a batch of upgrade/switch steps
|
|
10
|
-
* GET /api/plugin-upgrader/job?id=<id> poll that batch
|
|
11
|
-
* POST /api/plugin-upgrader/cancel stop the in-flight step
|
|
12
|
-
*
|
|
13
|
-
* Every route is loopback-only: a batch installs packages, so a paired remote
|
|
14
|
-
* device or a cross-site page must never reach it.
|
|
15
|
-
*
|
|
16
|
-
* Upgrade semantics follow the profile manager itself: a different version of
|
|
17
|
-
* an already-installed package is a removal plus an installation, because
|
|
18
|
-
* `installBundle` refuses a spec that names an installed dependency
|
|
19
|
-
* (`already-installed`). The old version is reported back so the caller can
|
|
20
|
-
* offer a restore.
|
|
21
|
-
*
|
|
22
|
-
* @module dsh-plugin-upgrader
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import { writeJson, isLoopbackRequest, readJsonBody } from './lib/http.js'
|
|
26
|
-
import { buildOverview } from './lib/overview.js'
|
|
27
|
-
import { runStep, validateStep } from './lib/install.js'
|
|
28
|
-
|
|
29
|
-
/** Stable plugin name; matches the cordis.patch.yml insert id. */
|
|
30
|
-
export const name = 'plugin-upgrader'
|
|
31
|
-
|
|
32
|
-
/** The web server is the only hard dependency; pluginManager is read lazily. */
|
|
33
|
-
export const inject = ['webServer']
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Prefix every route this bundle owns. The prefix is registered without a
|
|
37
|
-
* trailing slash because the carrier matches `path` exactly or `path + '/'`;
|
|
38
|
-
* a trailing slash here would only ever match the bare prefix itself.
|
|
39
|
-
*/
|
|
40
|
-
const API_PREFIX = '/api/plugin-upgrader'
|
|
41
|
-
|
|
42
|
-
/** Path segments are sliced off after the registered prefix. */
|
|
43
|
-
const API_BASE = `${API_PREFIX}/`
|
|
44
|
-
|
|
45
|
-
/** Finished jobs are kept only long enough for the last poll to read them. */
|
|
46
|
-
const JOB_RETENTION_MS = 10 * 60 * 1000
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* A job that stops reporting progress for this long is treated as abandoned
|
|
50
|
-
* and no longer blocks new work. Installs report progress per step, so a step
|
|
51
|
-
* that legitimately runs longer than this is a hung child process, not work.
|
|
52
|
-
*/
|
|
53
|
-
const STALE_JOB_MS = 15 * 60 * 1000
|
|
54
|
-
|
|
55
|
-
/** How many log lines one job keeps for the panel. */
|
|
56
|
-
const JOB_LOG_LIMIT = 200
|
|
57
|
-
|
|
58
|
-
/** How many log lines the overview ships for a job the tab adopts. */
|
|
59
|
-
const ADOPTED_LOG_LIMIT = 40
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Mount the host half.
|
|
63
|
-
* @param {any} ctx - the plugin context.
|
|
64
|
-
*/
|
|
65
|
-
export function apply(ctx) {
|
|
66
|
-
/** @type {Map<string, { at: number, data: any }>} */
|
|
67
|
-
const packumentCache = new Map()
|
|
68
|
-
/** @type {Map<string, { at: number, tags?: Set<string>, error?: string }>} */
|
|
69
|
-
const tagCache = new Map()
|
|
70
|
-
/** @type {Map<string, any>} */
|
|
71
|
-
const jobs = new Map()
|
|
72
|
-
/** @type {{ requestId: string } | null} */
|
|
73
|
-
let inFlight = null
|
|
74
|
-
|
|
75
|
-
const pruneJobs = () => {
|
|
76
|
-
const cutoff = Date.now() - JOB_RETENTION_MS
|
|
77
|
-
for (const [id, job] of jobs) {
|
|
78
|
-
if (job.finishedAt !== undefined && job.finishedAt < cutoff) jobs.delete(id)
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Run a batch job to completion in the background.
|
|
84
|
-
* @param {any} job - the mutable job record.
|
|
85
|
-
* @param {any[]} steps - the requested steps.
|
|
86
|
-
*/
|
|
87
|
-
const runJob = async (job, steps) => {
|
|
88
|
-
const manager = ctx.get('pluginManager', false)
|
|
89
|
-
job.status = 'running'
|
|
90
|
-
job.heartbeatAt = Date.now()
|
|
91
|
-
for (const step of steps) {
|
|
92
|
-
if (job.cancelled === true) break
|
|
93
|
-
inFlight = { requestId: step.requestId ?? '' }
|
|
94
|
-
job.current = `${step.name}@${step.version}`
|
|
95
|
-
job.log.push(`→ ${job.current}`)
|
|
96
|
-
const outcome = await runStep(manager, step, (message) => {
|
|
97
|
-
job.log.push(message)
|
|
98
|
-
if (job.log.length > JOB_LOG_LIMIT) job.log.splice(0, job.log.length - JOB_LOG_LIMIT)
|
|
99
|
-
})
|
|
100
|
-
inFlight = null
|
|
101
|
-
job.heartbeatAt = Date.now()
|
|
102
|
-
job.results.push(outcome)
|
|
103
|
-
job.done = job.results.length
|
|
104
|
-
if (outcome.ok !== true) job.failed += 1
|
|
105
|
-
job.log.push(
|
|
106
|
-
outcome.ok === true
|
|
107
|
-
? `✓ ${job.current} ${outcome.application ?? 'applied'}`
|
|
108
|
-
: `✗ ${job.current} ${outcome.message ?? 'failed'}`,
|
|
109
|
-
)
|
|
110
|
-
}
|
|
111
|
-
job.status = job.cancelled === true ? 'cancelled' : 'finished'
|
|
112
|
-
job.current = undefined
|
|
113
|
-
job.finishedAt = Date.now()
|
|
114
|
-
job.cacheDirty = true
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* A running job is only blocking while it is actually making progress.
|
|
119
|
-
* @returns {any | undefined} the live job, if one is genuinely running.
|
|
120
|
-
*/
|
|
121
|
-
const activeJob = () => {
|
|
122
|
-
for (const job of jobs.values()) {
|
|
123
|
-
if (job.status !== 'running' && job.status !== 'queued') continue
|
|
124
|
-
const seen = job.heartbeatAt ?? job.startedAt ?? 0
|
|
125
|
-
if (Date.now() - seen < STALE_JOB_MS) return job
|
|
126
|
-
job.status = 'finished'
|
|
127
|
-
job.abandoned = true
|
|
128
|
-
job.current = undefined
|
|
129
|
-
job.finishedAt = Date.now()
|
|
130
|
-
job.log.push('! job abandoned: no progress for 15 minutes')
|
|
131
|
-
}
|
|
132
|
-
return undefined
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* One dispatcher for every route under the prefix.
|
|
137
|
-
* @param {import('node:http').IncomingMessage} req - the request.
|
|
138
|
-
* @param {import('node:http').ServerResponse} res - the response.
|
|
139
|
-
*/
|
|
140
|
-
const handler = async (req, res) => {
|
|
141
|
-
if (!isLoopbackRequest(req)) {
|
|
142
|
-
writeJson(res, 403, { error: 'loopback-only' })
|
|
143
|
-
return
|
|
144
|
-
}
|
|
145
|
-
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
146
|
-
const pathname = url.pathname.endsWith('/') && url.pathname.length > API_BASE.length
|
|
147
|
-
? url.pathname.slice(0, -1)
|
|
148
|
-
: url.pathname
|
|
149
|
-
const route = pathname.slice(API_BASE.length)
|
|
150
|
-
try {
|
|
151
|
-
if (req.method === 'GET' && route === 'overview') {
|
|
152
|
-
const force = url.searchParams.get('refresh') === '1'
|
|
153
|
-
const locale = url.searchParams.get('locale') === 'zh' ? 'zh' : 'en'
|
|
154
|
-
pruneJobs()
|
|
155
|
-
const live = activeJob()
|
|
156
|
-
writeJson(res, 200, {
|
|
157
|
-
...(await buildOverview(ctx, packumentCache, tagCache, { force, locale })),
|
|
158
|
-
/**
|
|
159
|
-
* The tab is remounted whenever the Plugins page is reopened, and the
|
|
160
|
-
* job map only lives in this process. Reporting the live job lets a
|
|
161
|
-
* fresh tab adopt work that is still running instead of firing a
|
|
162
|
-
* second batch into a 409.
|
|
163
|
-
*/
|
|
164
|
-
active: live === undefined
|
|
165
|
-
? null
|
|
166
|
-
: {
|
|
167
|
-
id: live.id,
|
|
168
|
-
status: live.status,
|
|
169
|
-
total: live.total,
|
|
170
|
-
done: live.done,
|
|
171
|
-
failed: live.failed,
|
|
172
|
-
current: live.current,
|
|
173
|
-
startedAt: live.startedAt,
|
|
174
|
-
log: live.log.slice(-ADOPTED_LOG_LIMIT),
|
|
175
|
-
},
|
|
176
|
-
jobs: [...jobs.entries()]
|
|
177
|
-
.filter(([, job]) => job.finishedAt !== undefined)
|
|
178
|
-
.map(([id, job]) => ({
|
|
179
|
-
id,
|
|
180
|
-
status: job.status,
|
|
181
|
-
failed: job.failed,
|
|
182
|
-
done: job.done,
|
|
183
|
-
total: job.total,
|
|
184
|
-
finishedAt: job.finishedAt,
|
|
185
|
-
})),
|
|
186
|
-
})
|
|
187
|
-
return
|
|
188
|
-
}
|
|
189
|
-
if (req.method === 'POST' && route === 'job') {
|
|
190
|
-
const body = await readJsonBody(req)
|
|
191
|
-
const steps = Array.isArray(body?.steps) ? body.steps : []
|
|
192
|
-
if (steps.length === 0) {
|
|
193
|
-
writeJson(res, 400, { error: 'no-steps' })
|
|
194
|
-
return
|
|
195
|
-
}
|
|
196
|
-
for (const step of steps) {
|
|
197
|
-
const problem = validateStep(step)
|
|
198
|
-
if (problem !== undefined) {
|
|
199
|
-
writeJson(res, 400, { error: problem })
|
|
200
|
-
return
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
const running = activeJob()
|
|
204
|
-
if (running !== undefined) {
|
|
205
|
-
writeJson(res, 409, {
|
|
206
|
-
error: 'busy',
|
|
207
|
-
id: running.id,
|
|
208
|
-
current: running.current,
|
|
209
|
-
done: running.done,
|
|
210
|
-
total: running.total,
|
|
211
|
-
})
|
|
212
|
-
return
|
|
213
|
-
}
|
|
214
|
-
const id = globalThis.crypto?.randomUUID?.() ?? `job-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
215
|
-
const job = {
|
|
216
|
-
id,
|
|
217
|
-
status: 'queued',
|
|
218
|
-
total: steps.length,
|
|
219
|
-
done: 0,
|
|
220
|
-
failed: 0,
|
|
221
|
-
results: [],
|
|
222
|
-
log: [],
|
|
223
|
-
cancelled: false,
|
|
224
|
-
startedAt: Date.now(),
|
|
225
|
-
}
|
|
226
|
-
jobs.set(id, job)
|
|
227
|
-
void runJob(job, steps).catch((error) => {
|
|
228
|
-
job.status = 'finished'
|
|
229
|
-
job.finishedAt = Date.now()
|
|
230
|
-
job.log.push(`job failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
231
|
-
})
|
|
232
|
-
writeJson(res, 202, { id })
|
|
233
|
-
return
|
|
234
|
-
}
|
|
235
|
-
if (req.method === 'GET' && route === 'job') {
|
|
236
|
-
const id = url.searchParams.get('id')
|
|
237
|
-
const job = id === null ? undefined : jobs.get(id)
|
|
238
|
-
if (job === undefined) {
|
|
239
|
-
writeJson(res, 404, { error: 'unknown-job' })
|
|
240
|
-
return
|
|
241
|
-
}
|
|
242
|
-
writeJson(res, 200, job)
|
|
243
|
-
return
|
|
244
|
-
}
|
|
245
|
-
if (req.method === 'POST' && route === 'cancel') {
|
|
246
|
-
const running = [...jobs.values()].find((job) => job.status === 'running')
|
|
247
|
-
if (running === undefined) {
|
|
248
|
-
writeJson(res, 200, { status: 'not-running' })
|
|
249
|
-
return
|
|
250
|
-
}
|
|
251
|
-
running.cancelled = true
|
|
252
|
-
const manager = ctx.get('pluginManager', false)
|
|
253
|
-
const target = inFlight
|
|
254
|
-
if (manager !== undefined && manager !== null && target !== null && target.requestId !== '') {
|
|
255
|
-
try {
|
|
256
|
-
await manager.cancelInstall(target.requestId)
|
|
257
|
-
} catch {
|
|
258
|
-
/* the step settles on its own; the flag above already stops the batch */
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
writeJson(res, 200, { status: 'cancelling' })
|
|
262
|
-
return
|
|
263
|
-
}
|
|
264
|
-
writeJson(res, 404, { error: 'not-found' })
|
|
265
|
-
} catch (error) {
|
|
266
|
-
const status = typeof error?.status === 'number' ? error.status : 500
|
|
267
|
-
writeJson(res, status, { error: error instanceof Error ? error.message : String(error) })
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
ctx.effect(
|
|
272
|
-
() => ctx.webServer.register({ kind: 'prefix', path: API_PREFIX, handler }),
|
|
273
|
-
'plugin-upgrader: routes',
|
|
274
|
-
)
|
|
275
|
-
}
|
package/src/lib/bundle.js
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bundle metadata helpers.
|
|
3
|
-
*
|
|
4
|
-
* These translate what the profile manager reports into the fields the tab
|
|
5
|
-
* renders. Pure functions again: given the same bundle they answer the same
|
|
6
|
-
* thing, which is what makes them testable.
|
|
7
|
-
*
|
|
8
|
-
* @module dsh-plugin-upgrader/lib/bundle
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Classify how a profile dependency was declared.
|
|
13
|
-
* @param {string | undefined} source - `BundleInfo.source`.
|
|
14
|
-
* @returns {'registry' | 'path' | 'git' | 'tarball' | 'unknown'} the source family.
|
|
15
|
-
*/
|
|
16
|
-
export function sourceKind(source) {
|
|
17
|
-
if (typeof source !== 'string' || source.trim() === '') return 'unknown'
|
|
18
|
-
const value = source.trim()
|
|
19
|
-
if (value.startsWith('file:') || value.startsWith('link:') || value.startsWith('.') || value.startsWith('/')) {
|
|
20
|
-
return 'path'
|
|
21
|
-
}
|
|
22
|
-
if (/^(git|git\+|github:|https?:\/\/[^/]*\.git)/.test(value)) return 'git'
|
|
23
|
-
if (/\.(tgz|tar\.gz)$/.test(value)) return 'tarball'
|
|
24
|
-
if (/^(npm:|[\^~]?\d|[\^~]?(latest|next|beta|canary|rc)\b)/.test(value)) return 'registry'
|
|
25
|
-
return 'unknown'
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Localized metadata value for the active display language.
|
|
30
|
-
* @param {unknown} text - a string or `{ en, zh, … }`.
|
|
31
|
-
* @param {'zh' | 'en'} locale - the requested language.
|
|
32
|
-
* @returns {string | undefined} the best available text.
|
|
33
|
-
*/
|
|
34
|
-
export function pickText(text, locale) {
|
|
35
|
-
if (typeof text === 'string') return text
|
|
36
|
-
if (text !== null && typeof text === 'object') {
|
|
37
|
-
const record = /** @type {Record<string, string>} */ (text)
|
|
38
|
-
return record[locale] ?? record.en ?? record.zh ?? Object.values(record)[0]
|
|
39
|
-
}
|
|
40
|
-
return undefined
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Keep an icon only when it is cheap to send: bundle metadata often embeds a
|
|
45
|
-
* base64 data URL, which would bloat every overview response by ~150 KB.
|
|
46
|
-
* @param {unknown} icon - the manifest icon.
|
|
47
|
-
* @param {number} limit - the largest inline data URL worth sending.
|
|
48
|
-
* @returns {string | undefined} a usable reference.
|
|
49
|
-
*/
|
|
50
|
-
export function inlineIcon(icon, limit) {
|
|
51
|
-
if (typeof icon !== 'string' || icon === '') return undefined
|
|
52
|
-
return icon.startsWith('data:') && icon.length > limit ? undefined : icon
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Registry base URL: `null` is pnpm's own configured registry.
|
|
57
|
-
* @param {{ registry?: string | null, resolved?: string | null } | undefined} registries - manager answer.
|
|
58
|
-
* @returns {string} a URL without a trailing slash.
|
|
59
|
-
*/
|
|
60
|
-
export function registryBase(registries) {
|
|
61
|
-
const candidate = registries?.registry ?? registries?.resolved ?? null
|
|
62
|
-
const value = typeof candidate === 'string' && candidate !== '' ? candidate : 'https://registry.npmjs.org/'
|
|
63
|
-
return value.replace(/\/+$/, '')
|
|
64
|
-
}
|
package/src/lib/http.js
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTP helpers shared by every route.
|
|
3
|
-
*
|
|
4
|
-
* The host half answers a small, fixed set of JSON endpoints, so these helpers
|
|
5
|
-
* stay deliberately narrow: one trust fence, one bounded body reader, one
|
|
6
|
-
* response writer.
|
|
7
|
-
*
|
|
8
|
-
* @module dsh-plugin-upgrader/lib/http
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Whether a socket address names the loopback range.
|
|
13
|
-
* @param {string | undefined} address - `req.socket.remoteAddress`.
|
|
14
|
-
* @returns {boolean} true for 127/8 and ::1.
|
|
15
|
-
*/
|
|
16
|
-
export function isLoopbackAddress(address) {
|
|
17
|
-
if (address === undefined) return false
|
|
18
|
-
const normalized = address.toLowerCase()
|
|
19
|
-
if (normalized === '::1') return true
|
|
20
|
-
const v4 = normalized.startsWith('::ffff:') ? normalized.slice(7) : normalized
|
|
21
|
-
const parts = v4.split('.')
|
|
22
|
-
return parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part))
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Request-level trust fence for a route that can install packages.
|
|
27
|
-
* @param {import('node:http').IncomingMessage} req - the incoming request.
|
|
28
|
-
* @returns {boolean} true when the request comes from this machine and origin.
|
|
29
|
-
*/
|
|
30
|
-
export function isLoopbackRequest(req) {
|
|
31
|
-
if (!isLoopbackAddress(req.socket?.remoteAddress)) return false
|
|
32
|
-
const host = req.headers.host
|
|
33
|
-
if (typeof host !== 'string') return false
|
|
34
|
-
if (req.headers['sec-fetch-site'] === 'cross-site') return false
|
|
35
|
-
const origin = req.headers.origin
|
|
36
|
-
if (origin === undefined) return true
|
|
37
|
-
try {
|
|
38
|
-
return new URL(origin).host === new URL(`http://${host}`).host
|
|
39
|
-
} catch {
|
|
40
|
-
return false
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Read a bounded JSON object body.
|
|
46
|
-
* @param {import('node:http').IncomingMessage} req - the request.
|
|
47
|
-
* @param {number} maxBytes - cap before the body is dropped.
|
|
48
|
-
* @returns {Promise<Record<string, unknown> | null>} the parsed object, or null.
|
|
49
|
-
*/
|
|
50
|
-
export async function readJsonBody(req, maxBytes = 256 * 1024) {
|
|
51
|
-
const chunks = []
|
|
52
|
-
let size = 0
|
|
53
|
-
try {
|
|
54
|
-
for await (const chunk of req) {
|
|
55
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
56
|
-
size += buffer.length
|
|
57
|
-
if (size > maxBytes) {
|
|
58
|
-
req.destroy()
|
|
59
|
-
return null
|
|
60
|
-
}
|
|
61
|
-
chunks.push(buffer)
|
|
62
|
-
}
|
|
63
|
-
} catch {
|
|
64
|
-
return null
|
|
65
|
-
}
|
|
66
|
-
let text = Buffer.concat(chunks).toString('utf8')
|
|
67
|
-
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1)
|
|
68
|
-
if (text === '') return null
|
|
69
|
-
let parsed
|
|
70
|
-
try {
|
|
71
|
-
parsed = JSON.parse(text)
|
|
72
|
-
} catch {
|
|
73
|
-
return null
|
|
74
|
-
}
|
|
75
|
-
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Write one JSON response.
|
|
80
|
-
* @param {import('node:http').ServerResponse} res - the response.
|
|
81
|
-
* @param {number} status - HTTP status.
|
|
82
|
-
* @param {unknown} body - JSON payload.
|
|
83
|
-
*/
|
|
84
|
-
export function writeJson(res, status, body) {
|
|
85
|
-
const payload = JSON.stringify(body)
|
|
86
|
-
res.writeHead(status, {
|
|
87
|
-
'content-type': 'application/json; charset=utf-8',
|
|
88
|
-
'cache-control': 'no-store',
|
|
89
|
-
'referrer-policy': 'no-referrer',
|
|
90
|
-
'content-length': Buffer.byteLength(payload),
|
|
91
|
-
})
|
|
92
|
-
res.end(payload)
|
|
93
|
-
}
|
package/src/lib/install.js
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Installing one version, and putting the plugin's state back afterwards.
|
|
3
|
-
*
|
|
4
|
-
* `installBundle` refuses a spec that names an already-installed dependency, so
|
|
5
|
-
* replacing a version means removing first. These helpers wrap that two-step in
|
|
6
|
-
* one call and undo the side effects a reinstall has on the enabled flag.
|
|
7
|
-
*
|
|
8
|
-
* @module dsh-plugin-upgrader/lib/install
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Re-apply the enabled state a step asked for after the package was replaced.
|
|
13
|
-
* `installBundle` activates what it installs, so a disabled plugin would
|
|
14
|
-
* otherwise come back enabled; the bundle switch is the persistent lever.
|
|
15
|
-
* @param {any} manager - the plugin manager.
|
|
16
|
-
* @param {{ name: string }} step - the step that just ran.
|
|
17
|
-
* @param {boolean | undefined} wanted - the state to restore, if any.
|
|
18
|
-
* @param {(message: string) => void} log - progress sink.
|
|
19
|
-
* @returns {Promise<boolean | undefined>} the state now in effect, if known.
|
|
20
|
-
*/
|
|
21
|
-
export async function restoreEnabledState(manager, step, wanted, log) {
|
|
22
|
-
if (wanted === undefined) return undefined
|
|
23
|
-
let current
|
|
24
|
-
try {
|
|
25
|
-
const bundles = await manager.listBundles()
|
|
26
|
-
const entry = Array.isArray(bundles) ? bundles.find((item) => item?.name === step.name) : undefined
|
|
27
|
-
current = entry?.enabled
|
|
28
|
-
} catch {
|
|
29
|
-
current = undefined
|
|
30
|
-
}
|
|
31
|
-
if (current === wanted) return current
|
|
32
|
-
log(`${wanted ? 'enable' : 'disable'} ${step.name} (restoring the previous state)`)
|
|
33
|
-
try {
|
|
34
|
-
const outcome = await manager.setBundleEnabled(step.name, wanted)
|
|
35
|
-
if (outcome?.error !== undefined) {
|
|
36
|
-
log(` ! could not restore the enabled state: ${outcome.error.code}`)
|
|
37
|
-
return current
|
|
38
|
-
}
|
|
39
|
-
return wanted
|
|
40
|
-
} catch (error) {
|
|
41
|
-
log(` ! could not restore the enabled state: ${error instanceof Error ? error.message : String(error)}`)
|
|
42
|
-
return current
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Run one step: install the exact version, falling back to remove + install,
|
|
48
|
-
* then restore the plugin's previous enabled state.
|
|
49
|
-
*
|
|
50
|
-
* Every outcome — including a thrown manager call — comes back as one record so
|
|
51
|
-
* the job log can report per-plugin success without a second error channel.
|
|
52
|
-
*
|
|
53
|
-
* @param {any} manager - the plugin manager.
|
|
54
|
-
* @param {{ name: string, version: string, enabled?: boolean, approveBuilds?: string[] }} step - the requested change.
|
|
55
|
-
* @param {(message: string) => void} log - progress sink.
|
|
56
|
-
* @returns {Promise<any>} the outcome record.
|
|
57
|
-
*/
|
|
58
|
-
export async function runStep(manager, step, log) {
|
|
59
|
-
const spec = `${step.name}@${step.version}`
|
|
60
|
-
const requestId = globalThis.crypto?.randomUUID?.() ?? `pu-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
61
|
-
const buildApproval = Array.isArray(step.approveBuilds) && step.approveBuilds.length > 0
|
|
62
|
-
? { approvedBuilds: step.approveBuilds }
|
|
63
|
-
: {}
|
|
64
|
-
const base = { name: step.name, version: step.version, spec, requestId }
|
|
65
|
-
const restoreEnabled = typeof step.enabled === 'boolean' ? step.enabled : undefined
|
|
66
|
-
log(`install ${spec}`)
|
|
67
|
-
|
|
68
|
-
let result
|
|
69
|
-
try {
|
|
70
|
-
result = await manager.installBundle(spec, {
|
|
71
|
-
saveExact: true,
|
|
72
|
-
requestId,
|
|
73
|
-
...(restoreEnabled === undefined ? {} : { enabled: restoreEnabled }),
|
|
74
|
-
...buildApproval,
|
|
75
|
-
})
|
|
76
|
-
} catch (error) {
|
|
77
|
-
return { ...base, ok: false, stage: 'install', message: error instanceof Error ? error.message : String(error) }
|
|
78
|
-
}
|
|
79
|
-
if (result?.error?.code === 'already-installed') {
|
|
80
|
-
log(`remove ${step.name} (replacing an installed dependency)`)
|
|
81
|
-
let removed
|
|
82
|
-
try {
|
|
83
|
-
removed = await manager.removeBundle(step.name)
|
|
84
|
-
} catch (error) {
|
|
85
|
-
return {
|
|
86
|
-
...base,
|
|
87
|
-
ok: false,
|
|
88
|
-
stage: 'remove',
|
|
89
|
-
application: 'failed',
|
|
90
|
-
message: `removal failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
if (removed?.application === 'failed' || removed?.error !== undefined) {
|
|
94
|
-
return {
|
|
95
|
-
...base,
|
|
96
|
-
ok: false,
|
|
97
|
-
stage: 'remove',
|
|
98
|
-
application: removed?.application,
|
|
99
|
-
message: removed?.error?.code ?? 'removal refused',
|
|
100
|
-
detail: removed?.error?.diagnostic,
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
try {
|
|
104
|
-
result = await manager.installBundle(spec, {
|
|
105
|
-
saveExact: true,
|
|
106
|
-
requestId: undefined,
|
|
107
|
-
...(restoreEnabled === undefined ? {} : { enabled: restoreEnabled }),
|
|
108
|
-
...buildApproval,
|
|
109
|
-
})
|
|
110
|
-
} catch (error) {
|
|
111
|
-
return {
|
|
112
|
-
...base,
|
|
113
|
-
ok: false,
|
|
114
|
-
stage: 'install',
|
|
115
|
-
application: 'failed',
|
|
116
|
-
message: `install after removal failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
const enabled = await restoreEnabledState(manager, step, restoreEnabled, log)
|
|
121
|
-
return {
|
|
122
|
-
...base,
|
|
123
|
-
...(enabled === undefined ? {} : { enabled }),
|
|
124
|
-
ok: result?.application === 'applied' || result?.application === 'restart-required',
|
|
125
|
-
application: result?.application,
|
|
126
|
-
changed: result?.changed === true,
|
|
127
|
-
stage: result?.stage,
|
|
128
|
-
restartReason: result?.restartReason,
|
|
129
|
-
version: result?.version ?? step.version,
|
|
130
|
-
message: result?.error === undefined ? undefined : result.error.code,
|
|
131
|
-
detail: result?.error?.diagnostic,
|
|
132
|
-
pendingBuilds: result?.pendingBuilds,
|
|
133
|
-
warnings: result?.warnings,
|
|
134
|
-
packageResult: result?.packageResult === undefined
|
|
135
|
-
? undefined
|
|
136
|
-
: {
|
|
137
|
-
exitCode: result.packageResult.exitCode,
|
|
138
|
-
kind: result.packageResult.kind,
|
|
139
|
-
timedOut: result.packageResult.timedOut,
|
|
140
|
-
tail: typeof result.packageResult.output === 'string'
|
|
141
|
-
? result.packageResult.output.slice(-1600)
|
|
142
|
-
: undefined,
|
|
143
|
-
},
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Whether a step names something this plugin is willing to install.
|
|
149
|
-
* @param {unknown} step - one element of the request body.
|
|
150
|
-
* @returns {string | undefined} an error code, or undefined when valid.
|
|
151
|
-
*/
|
|
152
|
-
export function validateStep(step) {
|
|
153
|
-
if (step === null || typeof step !== 'object') return 'invalid-step'
|
|
154
|
-
const record = /** @type {Record<string, unknown>} */ (step)
|
|
155
|
-
if (typeof record.name !== 'string' || typeof record.version !== 'string') return 'invalid-step'
|
|
156
|
-
if (record.action !== undefined && !ACTIONS.has(/** @type {string} */ (record.action))) {
|
|
157
|
-
return 'invalid-action'
|
|
158
|
-
}
|
|
159
|
-
return undefined
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** Allowed per-step actions. */
|
|
163
|
-
export const ACTIONS = new Set(['upgrade', 'switch'])
|