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.
Files changed (53) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.en.md +52 -21
  3. package/README.md +57 -20
  4. package/lib/client.js +959 -0
  5. package/lib/host/bundle.d.ts +52 -0
  6. package/lib/host/bundle.d.ts.map +1 -0
  7. package/lib/host/bundle.js +73 -0
  8. package/lib/host/bundle.js.map +1 -0
  9. package/lib/host/http.d.ts +46 -0
  10. package/lib/host/http.d.ts.map +1 -0
  11. package/lib/host/http.js +104 -0
  12. package/lib/host/http.js.map +1 -0
  13. package/lib/host/install.d.ts +71 -0
  14. package/lib/host/install.d.ts.map +1 -0
  15. package/lib/host/install.js +161 -0
  16. package/lib/host/install.js.map +1 -0
  17. package/lib/host/manager.d.ts +151 -0
  18. package/lib/host/manager.d.ts.map +1 -0
  19. package/lib/host/manager.js +28 -0
  20. package/lib/host/manager.js.map +1 -0
  21. package/lib/host/overview.d.ts +52 -0
  22. package/lib/host/overview.d.ts.map +1 -0
  23. package/lib/host/overview.js +109 -0
  24. package/lib/host/overview.js.map +1 -0
  25. package/lib/host/registry.d.ts +116 -0
  26. package/lib/host/registry.d.ts.map +1 -0
  27. package/lib/host/registry.js +177 -0
  28. package/lib/host/registry.js.map +1 -0
  29. package/lib/host/repo.d.ts +62 -0
  30. package/lib/host/repo.d.ts.map +1 -0
  31. package/lib/host/repo.js +102 -0
  32. package/lib/host/repo.js.map +1 -0
  33. package/lib/host/version.d.ts +67 -0
  34. package/lib/host/version.d.ts.map +1 -0
  35. package/lib/host/version.js +114 -0
  36. package/lib/host/version.js.map +1 -0
  37. package/lib/index.d.ts +12 -0
  38. package/lib/index.d.ts.map +1 -0
  39. package/lib/index.js +254 -0
  40. package/lib/index.js.map +1 -0
  41. package/package.json +32 -14
  42. package/screenshots/history.png +0 -0
  43. package/screenshots/selection.png +0 -0
  44. package/screenshots.json +0 -4
  45. package/src/client.js +0 -1136
  46. package/src/index.js +0 -275
  47. package/src/lib/bundle.js +0 -64
  48. package/src/lib/http.js +0 -93
  49. package/src/lib/install.js +0 -163
  50. package/src/lib/overview.js +0 -108
  51. package/src/lib/registry.js +0 -188
  52. package/src/lib/repo.js +0 -91
  53. 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
- }
@@ -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'])