dsh-ntr 0.2.0 → 0.3.0
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.en.md +9 -6
- package/README.md +9 -6
- package/cordis.patch.yml +12 -0
- package/lib/bridge.js +654 -85
- package/lib/client/panel.js +224 -78
- package/lib/client/store.js +133 -1
- package/lib/client.js +702 -304
- package/lib/client.js.map +1 -1
- package/lib/impl.js +47 -0
- package/lib/index.js +15 -1
- package/lib/types/bridge.d.ts +208 -6
- package/lib/types/client/panel.d.ts +0 -1
- package/lib/types/client/store.d.ts +54 -1
- package/lib/types/impl.d.ts +26 -1
- package/lib/types/protocol.d.ts +73 -0
- package/lib/types/update.d.ts +183 -0
- package/lib/update.js +681 -0
- package/package.json +5 -1
- package/src/bridge.ts +700 -83
- package/src/client/panel.tsx +406 -205
- package/src/client/store.ts +146 -2
- package/src/impl.ts +66 -1
- package/src/index.ts +16 -1
- package/src/protocol.ts +76 -0
- package/src/update.ts +699 -0
package/src/update.ts
ADDED
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side built-in update for dsh-ntr.
|
|
3
|
+
*
|
|
4
|
+
* The plugin NEVER downloads a package, swaps files, or restarts itself. The
|
|
5
|
+
* harness plugin manager owns installation (profile bookkeeping, npm, the
|
|
6
|
+
* restart contract); this module only asks it what is installed, asks an npm
|
|
7
|
+
* registry what is published, and delegates the install back to the manager.
|
|
8
|
+
* Every method therefore reports a {@link PluginUpdateState} instead of
|
|
9
|
+
* throwing; version syntax is validated at the RPC boundary (`impl.ts`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { PluginUpdateState } from './protocol.ts'
|
|
13
|
+
|
|
14
|
+
/** The npm package this plugin is published as, and the bundle name to look for. */
|
|
15
|
+
export const UPDATE_PACKAGE = 'dsh-ntr'
|
|
16
|
+
|
|
17
|
+
/** One npm registry request must answer within this window before the next is tried. */
|
|
18
|
+
const REGISTRY_TIMEOUT_MS = 8_000
|
|
19
|
+
/** A successful packument answer is reused for this long (per registry base). */
|
|
20
|
+
const CACHE_TTL_MS = 10 * 60 * 1000
|
|
21
|
+
/** Only stable releases are offered for install. */
|
|
22
|
+
const STABLE_VERSION = /^\d+\.\d+\.\d+$/
|
|
23
|
+
/** npm's public registry; the only base a private `resolved` may be widened to. */
|
|
24
|
+
export const NPM_PUBLIC_REGISTRY = 'https://registry.npmjs.org'
|
|
25
|
+
/** A version spec that resolves through a registry (as opposed to a link/tarball/git). */
|
|
26
|
+
const REGISTRY_VERSION_SPEC = /^(?:[\^~]|>=|<=|>|<|=)?\s*v?\d+(?:\.(?:\d+|[xX*])){0,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
|
|
27
|
+
/** Dist-tags that name a published release line rather than a local path. */
|
|
28
|
+
const REGISTRY_TAGS = new Set(['latest', 'next', 'beta', 'alpha', 'rc', 'stable'])
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Structural view of one row from the harness plugin manager's
|
|
32
|
+
* `listBundles()`. Deliberately local: importing the host package would tie
|
|
33
|
+
* this module to a runtime version it must merely probe structurally.
|
|
34
|
+
*/
|
|
35
|
+
export interface UpdateBundleInfo {
|
|
36
|
+
name: string; installed: boolean; removable?: boolean; enabled?: boolean
|
|
37
|
+
version?: string; source?: string; readOnlyReason?: string
|
|
38
|
+
error?: { code?: string; message?: string; diagnostic?: string }
|
|
39
|
+
}
|
|
40
|
+
/** Structural view of `pluginManager.registries()`. */
|
|
41
|
+
export interface UpdateRegistryPlan {
|
|
42
|
+
registry?: string | null; fallbackRegistries?: readonly string[]; resolved?: string | null
|
|
43
|
+
}
|
|
44
|
+
/** Structural view of the manager's `installBundle()` / `removeBundle()` result (a ChangeResult). */
|
|
45
|
+
export interface UpdateInstallResult {
|
|
46
|
+
application?: string; version?: string
|
|
47
|
+
error?: { code?: string; message?: string; diagnostic?: string }
|
|
48
|
+
}
|
|
49
|
+
/** The plugin-manager face this service needs. Every member is probed, never assumed. */
|
|
50
|
+
export interface UpdateManagerLike {
|
|
51
|
+
listBundles?(): Promise<readonly UpdateBundleInfo[]>
|
|
52
|
+
registries?(): Promise<UpdateRegistryPlan>
|
|
53
|
+
installBundle?(spec: string, options?: { enabled?: boolean; registry?: string }): Promise<UpdateInstallResult | undefined>
|
|
54
|
+
/**
|
|
55
|
+
* Native-update half of the harness: `installBundle` refuses to replace an
|
|
56
|
+
* already-installed bundle while HMR holds it, so the host removes first.
|
|
57
|
+
* Optional — older hosts cannot install at all and answer `already-installed`.
|
|
58
|
+
*/
|
|
59
|
+
removeBundle?(name: string): Promise<UpdateInstallResult | undefined>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The shared eligibility verdict. `ok` carries the disk version (when the host
|
|
64
|
+
* reports one); the failure arm is already a full state, so check/apply can
|
|
65
|
+
* return it verbatim.
|
|
66
|
+
*/
|
|
67
|
+
export type BundleEligibility =
|
|
68
|
+
| { ok: true; installed?: string }
|
|
69
|
+
| { ok: false; state: PluginUpdateState }
|
|
70
|
+
|
|
71
|
+
function messageOf(error: unknown): string {
|
|
72
|
+
if (error instanceof Error) return error.message
|
|
73
|
+
if (typeof error === 'string') return error
|
|
74
|
+
if (typeof error === 'object' && error !== null) {
|
|
75
|
+
const message = (error as Record<string, unknown>).message
|
|
76
|
+
if (typeof message === 'string') return message
|
|
77
|
+
}
|
|
78
|
+
return String(error)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The host's ChangeResult carries the human-readable cause in `diagnostic`
|
|
83
|
+
* (a `message` field is not always populated). Prefer it, then `message`, and
|
|
84
|
+
* keep the code prefix because the UI shows this verbatim.
|
|
85
|
+
*/
|
|
86
|
+
function describeFieldError(error: { code?: string; message?: string; diagnostic?: string }): string {
|
|
87
|
+
const text = [error.diagnostic, error.message]
|
|
88
|
+
.find((part): part is string => typeof part === 'string' && part !== '')
|
|
89
|
+
const parts = [error.code, text]
|
|
90
|
+
.filter((part): part is string => typeof part === 'string' && part !== '')
|
|
91
|
+
return parts.length > 0 ? parts.join(': ') : 'the host reported a bundle error'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether a `listBundles().source` value names a registry-resolvable version
|
|
96
|
+
* spec — the only install shape this updater may replace. The test is positive
|
|
97
|
+
* on purpose: link/file/git/workspace/URL/tarball/relative forms all carry a
|
|
98
|
+
* `:` / `/` / `\` or start with `.`, so they fall out without an ever-growing
|
|
99
|
+
* blacklist (a new source scheme must not become silently updatable).
|
|
100
|
+
*/
|
|
101
|
+
export function isRegistrySpec(source: string): boolean {
|
|
102
|
+
const value = typeof source === 'string' ? source.trim() : ''
|
|
103
|
+
if (value === '') return false
|
|
104
|
+
if (value.startsWith('.')) return false
|
|
105
|
+
if (value.includes(':') || value.includes('/') || value.includes('\\')) return false
|
|
106
|
+
const lowered = value.toLowerCase()
|
|
107
|
+
if (lowered === '*' || lowered === 'x') return true
|
|
108
|
+
if (REGISTRY_TAGS.has(lowered)) return true
|
|
109
|
+
return REGISTRY_VERSION_SPEC.test(value)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Compare two semver strings (build metadata ignored, prerelease precedence per
|
|
114
|
+
* semver §11). Malformed segments count as `0`, so the function never throws on
|
|
115
|
+
* a value read off a registry.
|
|
116
|
+
*/
|
|
117
|
+
export function compareVersions(a: string, b: string): number {
|
|
118
|
+
const parse = (raw: string): { core: number[]; pre?: string[] } => {
|
|
119
|
+
let value = raw.trim()
|
|
120
|
+
if (value.startsWith('v') || value.startsWith('V')) value = value.slice(1)
|
|
121
|
+
const plus = value.indexOf('+')
|
|
122
|
+
if (plus >= 0) value = value.slice(0, plus)
|
|
123
|
+
const dash = value.indexOf('-')
|
|
124
|
+
const corePart = dash >= 0 ? value.slice(0, dash) : value
|
|
125
|
+
const prePart = dash >= 0 ? value.slice(dash + 1) : undefined
|
|
126
|
+
const core = corePart.split('.').map(segment => {
|
|
127
|
+
const parsed = Number.parseInt(segment, 10)
|
|
128
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
129
|
+
})
|
|
130
|
+
return { core, pre: prePart === undefined || prePart === '' ? undefined : prePart.split('.') }
|
|
131
|
+
}
|
|
132
|
+
const left = parse(a)
|
|
133
|
+
const right = parse(b)
|
|
134
|
+
const length = Math.max(left.core.length, right.core.length, 3)
|
|
135
|
+
for (let i = 0; i < length; i += 1) {
|
|
136
|
+
const l = left.core[i] ?? 0
|
|
137
|
+
const r = right.core[i] ?? 0
|
|
138
|
+
if (l !== r) return l < r ? -1 : 1
|
|
139
|
+
}
|
|
140
|
+
if (left.pre === undefined && right.pre === undefined) return 0
|
|
141
|
+
if (left.pre === undefined) return 1
|
|
142
|
+
if (right.pre === undefined) return -1
|
|
143
|
+
const preLength = Math.max(left.pre.length, right.pre.length)
|
|
144
|
+
for (let i = 0; i < preLength; i += 1) {
|
|
145
|
+
const l = left.pre[i]
|
|
146
|
+
const r = right.pre[i]
|
|
147
|
+
if (l === undefined) return -1
|
|
148
|
+
if (r === undefined) return 1
|
|
149
|
+
const lNumeric = /^\d+$/.test(l)
|
|
150
|
+
const rNumeric = /^\d+$/.test(r)
|
|
151
|
+
if (lNumeric && rNumeric) {
|
|
152
|
+
const lNum = Number(l)
|
|
153
|
+
const rNum = Number(r)
|
|
154
|
+
if (lNum !== rNum) return lNum < rNum ? -1 : 1
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
if (lNumeric !== rNumeric) return lNumeric ? -1 : 1
|
|
158
|
+
if (l !== r) return l < r ? -1 : 1
|
|
159
|
+
}
|
|
160
|
+
return 0
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Turn the manager's registry plan into the ordered list this service will
|
|
165
|
+
* actually query, following the host's privacy rule: a *private* `resolved`
|
|
166
|
+
* registry must not be widened with the shared fallback chain.
|
|
167
|
+
*
|
|
168
|
+
* - No configured registry: query `resolved`; append fallbacks only when it is
|
|
169
|
+
* npm's public registry or one of the fallbacks themselves.
|
|
170
|
+
* - Configured registry: query it first, then `resolved` under the same
|
|
171
|
+
* condition, then the fallbacks.
|
|
172
|
+
*
|
|
173
|
+
* http(s) only (a private http registry is legitimate), trailing slashes
|
|
174
|
+
* normalized, duplicates dropped. A base that carries credentials in its URL is
|
|
175
|
+
* skipped: a bare GET cannot replay `.npmrc` auth, so it could only fail with a
|
|
176
|
+
* 401 that reads as "unreachable".
|
|
177
|
+
*/
|
|
178
|
+
export function registryPlan(plan: UpdateRegistryPlan): string[] {
|
|
179
|
+
const normalize = (value: unknown): string | undefined => {
|
|
180
|
+
if (typeof value !== 'string') return undefined
|
|
181
|
+
const trimmed = value.trim().replace(/\/+$/, '')
|
|
182
|
+
if (!/^https?:\/\//i.test(trimmed)) return undefined
|
|
183
|
+
// Credentials in the URL cannot be replayed here: a bare GET would need the
|
|
184
|
+
// `.npmrc` auth the host keeps to itself, so such a base is never queried.
|
|
185
|
+
if (/^https?:\/\/[^/]*@/i.test(trimmed)) return undefined
|
|
186
|
+
return trimmed
|
|
187
|
+
}
|
|
188
|
+
const fallbacks: string[] = []
|
|
189
|
+
for (const fallback of plan.fallbackRegistries ?? []) {
|
|
190
|
+
const normalized = normalize(fallback)
|
|
191
|
+
if (normalized !== undefined && !fallbacks.includes(normalized)) fallbacks.push(normalized)
|
|
192
|
+
}
|
|
193
|
+
const resolved = normalize(plan.resolved)
|
|
194
|
+
const configured = normalize(plan.registry)
|
|
195
|
+
const shared = resolved !== undefined
|
|
196
|
+
&& (resolved.toLowerCase() === NPM_PUBLIC_REGISTRY || fallbacks.includes(resolved))
|
|
197
|
+
const out: string[] = []
|
|
198
|
+
const add = (value: string | undefined): void => {
|
|
199
|
+
if (value === undefined || out.includes(value)) return
|
|
200
|
+
out.push(value)
|
|
201
|
+
}
|
|
202
|
+
if (configured !== undefined) {
|
|
203
|
+
add(configured)
|
|
204
|
+
if (shared) add(resolved)
|
|
205
|
+
for (const fallback of fallbacks) add(fallback)
|
|
206
|
+
} else if (resolved !== undefined) {
|
|
207
|
+
add(resolved)
|
|
208
|
+
if (shared) for (const fallback of fallbacks) add(fallback)
|
|
209
|
+
}
|
|
210
|
+
return out
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Pick the version an update should offer.
|
|
215
|
+
*
|
|
216
|
+
* `dist-tags.latest` leads when it is a stable release newer than `current`
|
|
217
|
+
* (that is the line npm itself blesses). Otherwise the highest stable,
|
|
218
|
+
* non-deprecated published version wins — a lagging `latest` tag must not hide
|
|
219
|
+
* a newer release. When nothing is newer, the stable `latest` tag is still
|
|
220
|
+
* returned so the UI can say "you are on the latest release".
|
|
221
|
+
*/
|
|
222
|
+
export function pickUpdateTarget(packument: unknown, current: string): { version?: string; publishedAt?: string } {
|
|
223
|
+
const row = typeof packument === 'object' && packument !== null ? packument as Record<string, unknown> : {}
|
|
224
|
+
const time = typeof row.time === 'object' && row.time !== null ? row.time as Record<string, unknown> : {}
|
|
225
|
+
const publishedAt = (version: string): string | undefined => {
|
|
226
|
+
const value = time[version]
|
|
227
|
+
return typeof value === 'string' ? value : undefined
|
|
228
|
+
}
|
|
229
|
+
const tags = typeof row['dist-tags'] === 'object' && row['dist-tags'] !== null
|
|
230
|
+
? row['dist-tags'] as Record<string, unknown>
|
|
231
|
+
: undefined
|
|
232
|
+
const versions = typeof row.versions === 'object' && row.versions !== null
|
|
233
|
+
? row.versions as Record<string, unknown>
|
|
234
|
+
: undefined
|
|
235
|
+
// A string `deprecated` is npm's tombstone; never offer that release.
|
|
236
|
+
const deprecated = (version: string): boolean => {
|
|
237
|
+
const meta = versions?.[version]
|
|
238
|
+
return typeof meta === 'object' && meta !== null
|
|
239
|
+
&& typeof (meta as Record<string, unknown>).deprecated === 'string'
|
|
240
|
+
}
|
|
241
|
+
// `latest` only leads when the registry did not tombstone that very release.
|
|
242
|
+
const latest = typeof tags?.latest === 'string' && STABLE_VERSION.test(tags.latest) && !deprecated(tags.latest)
|
|
243
|
+
? tags.latest
|
|
244
|
+
: undefined
|
|
245
|
+
if (latest !== undefined && compareVersions(latest, current) > 0) {
|
|
246
|
+
return { version: latest, publishedAt: publishedAt(latest) }
|
|
247
|
+
}
|
|
248
|
+
let best: string | undefined
|
|
249
|
+
for (const [version, meta] of Object.entries(versions ?? {})) {
|
|
250
|
+
if (!STABLE_VERSION.test(version)) continue
|
|
251
|
+
if (typeof meta === 'object' && meta !== null && typeof (meta as Record<string, unknown>).deprecated === 'string') continue
|
|
252
|
+
if (best === undefined || compareVersions(version, best) > 0) best = version
|
|
253
|
+
}
|
|
254
|
+
if (best !== undefined && compareVersions(best, current) > 0) {
|
|
255
|
+
return { version: best, publishedAt: publishedAt(best) }
|
|
256
|
+
}
|
|
257
|
+
if (latest !== undefined) return { version: latest, publishedAt: publishedAt(latest) }
|
|
258
|
+
return {}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Pure eligibility matrix, shared by check and apply.
|
|
263
|
+
*
|
|
264
|
+
* `undefined` means the manager (or one of its methods) is unavailable; an
|
|
265
|
+
* empty list means the manager answered but does not carry this plugin.
|
|
266
|
+
*/
|
|
267
|
+
export function bundleEligibility(
|
|
268
|
+
bundles: readonly UpdateBundleInfo[] | undefined,
|
|
269
|
+
currentVersion: string,
|
|
270
|
+
): BundleEligibility {
|
|
271
|
+
if (bundles === undefined) {
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
state: {
|
|
275
|
+
status: 'unsupported',
|
|
276
|
+
current: currentVersion,
|
|
277
|
+
message: 'host does not expose the plugin manager',
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const bundle = bundles.find(entry => entry.name === UPDATE_PACKAGE)
|
|
282
|
+
if (bundle === undefined || bundle.installed !== true) {
|
|
283
|
+
return { ok: false, state: { status: 'not-installed', current: currentVersion } }
|
|
284
|
+
}
|
|
285
|
+
const source = typeof bundle.source === 'string' ? bundle.source.trim() : ''
|
|
286
|
+
if (source === '') {
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
state: {
|
|
290
|
+
status: 'unsupported',
|
|
291
|
+
current: currentVersion,
|
|
292
|
+
message: 'the host does not report where this plugin was installed from',
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (!isRegistrySpec(source)) {
|
|
297
|
+
// link: / file: / git+ / git@ / github: / workspace: / https tarball /
|
|
298
|
+
// relative path — the disk payload is not what the registry publishes.
|
|
299
|
+
return { ok: false, state: { status: 'source-install', current: currentVersion } }
|
|
300
|
+
}
|
|
301
|
+
if (bundle.removable === false) {
|
|
302
|
+
// The host refuses to unload it, so the remove-then-install path can only fail.
|
|
303
|
+
return {
|
|
304
|
+
ok: false,
|
|
305
|
+
state: {
|
|
306
|
+
status: 'managed',
|
|
307
|
+
current: currentVersion,
|
|
308
|
+
message: 'the host marks this bundle as not removable',
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const readOnlyReason = typeof bundle.readOnlyReason === 'string' ? bundle.readOnlyReason.trim() : ''
|
|
313
|
+
if (readOnlyReason !== '') {
|
|
314
|
+
return {
|
|
315
|
+
ok: false,
|
|
316
|
+
state: { status: 'managed', current: currentVersion, message: readOnlyReason },
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (bundle.error !== undefined) {
|
|
320
|
+
return {
|
|
321
|
+
ok: false,
|
|
322
|
+
state: { status: 'failed', current: currentVersion, message: describeFieldError(bundle.error) },
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const diskVersion = typeof bundle.version === 'string' && bundle.version.trim() !== ''
|
|
326
|
+
? bundle.version.trim()
|
|
327
|
+
: undefined
|
|
328
|
+
if (diskVersion !== undefined && compareVersions(diskVersion, currentVersion) !== 0) {
|
|
329
|
+
// Installed on disk but the running host still has the old code loaded.
|
|
330
|
+
// Compare semantically: `v1.1.0` / `1.1.0+build` are the same release.
|
|
331
|
+
return {
|
|
332
|
+
ok: false,
|
|
333
|
+
state: {
|
|
334
|
+
status: 'restart-required',
|
|
335
|
+
current: currentVersion,
|
|
336
|
+
installed: diskVersion,
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return { ok: true, ...(diskVersion === undefined ? {} : { installed: diskVersion }) }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Unwrap the manager's ChangeResult from the remote `{ok, value}` envelope. */
|
|
344
|
+
function unwrapInstallResult(raw: unknown): UpdateInstallResult | undefined {
|
|
345
|
+
if (raw === undefined || raw === null || typeof raw !== 'object') return undefined
|
|
346
|
+
const row = raw as Record<string, unknown>
|
|
347
|
+
if (typeof row.ok === 'boolean') {
|
|
348
|
+
if (!row.ok) {
|
|
349
|
+
const error = row.error
|
|
350
|
+
const shaped = typeof error === 'object' && error !== null ? error as Record<string, unknown> : {}
|
|
351
|
+
const message = typeof shaped.message === 'string'
|
|
352
|
+
? shaped.message
|
|
353
|
+
: typeof error === 'string' ? error : undefined
|
|
354
|
+
return {
|
|
355
|
+
error: {
|
|
356
|
+
...(typeof shaped.code === 'string' ? { code: shaped.code } : {}),
|
|
357
|
+
...(typeof shaped.diagnostic === 'string' ? { diagnostic: shaped.diagnostic } : {}),
|
|
358
|
+
...(message === undefined ? {} : { message }),
|
|
359
|
+
},
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return unwrapInstallResult(row.value)
|
|
363
|
+
}
|
|
364
|
+
return row as unknown as UpdateInstallResult
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Whether a ChangeResult means the requested tree is installed (restart or hot). */
|
|
368
|
+
function isInstallSuccess(result: UpdateInstallResult | undefined): result is UpdateInstallResult & { application: 'applied' | 'restart-required' } {
|
|
369
|
+
return result !== undefined && result.error === undefined
|
|
370
|
+
&& (result.application === 'applied' || result.application === 'restart-required')
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Whether a `removeBundle` ChangeResult really unloaded the tree. Only "no
|
|
375
|
+
* error and nothing contradicting the removal" counts: a `cancelled` /
|
|
376
|
+
* `overridden` / `failed` application without an `error` still leaves the
|
|
377
|
+
* bundle on disk, so retrying the install would hit `already-installed` again.
|
|
378
|
+
*/
|
|
379
|
+
function isRemoveSuccess(result: UpdateInstallResult | undefined): boolean {
|
|
380
|
+
if (result === undefined) return true
|
|
381
|
+
if (result.error !== undefined) return false
|
|
382
|
+
return result.application === undefined
|
|
383
|
+
|| result.application === 'applied'
|
|
384
|
+
|| result.application === 'restart-required'
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** A registry URL fit for a message: credentials stripped, no trailing slash. */
|
|
388
|
+
function displayRegistry(base: string): string {
|
|
389
|
+
try {
|
|
390
|
+
const url = new URL(base)
|
|
391
|
+
url.username = ''
|
|
392
|
+
url.password = ''
|
|
393
|
+
return url.toString().replace(/\/+$/, '')
|
|
394
|
+
} catch {
|
|
395
|
+
return base.replace(/\/\/[^/@]*@/, '//')
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export interface UpdateServiceOptions {
|
|
400
|
+
resolveManager(): UpdateManagerLike | undefined
|
|
401
|
+
currentVersion: string
|
|
402
|
+
isBusy(): boolean
|
|
403
|
+
fetchImpl?: typeof fetch
|
|
404
|
+
now?: () => number
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Update checks and installs, memoized per registry base.
|
|
409
|
+
*
|
|
410
|
+
* Caching exists because the settings page polls: a 10-minute window keeps the
|
|
411
|
+
* check honest without turning every mount into a registry round trip. Apply
|
|
412
|
+
* clears the cache so the next check sees the freshly installed version.
|
|
413
|
+
*/
|
|
414
|
+
export class UpdateService {
|
|
415
|
+
private readonly resolveManager: () => UpdateManagerLike | undefined
|
|
416
|
+
private readonly currentVersion: string
|
|
417
|
+
private readonly isBusy: () => boolean
|
|
418
|
+
private readonly fetchImpl: typeof fetch
|
|
419
|
+
private readonly now: () => number
|
|
420
|
+
private readonly cache = new Map<string, { at: number; target: { version?: string; publishedAt?: string } }>()
|
|
421
|
+
private applying = false
|
|
422
|
+
/** Registry that answered the last `available` check, passed through to apply. */
|
|
423
|
+
private lastTarget?: { version: string; registry: string }
|
|
424
|
+
|
|
425
|
+
constructor(options: UpdateServiceOptions) {
|
|
426
|
+
this.resolveManager = options.resolveManager
|
|
427
|
+
this.currentVersion = options.currentVersion
|
|
428
|
+
this.isBusy = options.isBusy
|
|
429
|
+
this.fetchImpl = options.fetchImpl ?? fetch
|
|
430
|
+
this.now = options.now ?? Date.now
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** The manager surface plus the disk-version verdict, or a ready-to-return failure state. */
|
|
434
|
+
private async eligibility(): Promise<BundleEligibility> {
|
|
435
|
+
const manager = this.resolveManager()
|
|
436
|
+
if (manager === undefined
|
|
437
|
+
|| typeof manager.listBundles !== 'function'
|
|
438
|
+
|| typeof manager.registries !== 'function'
|
|
439
|
+
|| typeof manager.installBundle !== 'function') {
|
|
440
|
+
return bundleEligibility(undefined, this.currentVersion)
|
|
441
|
+
}
|
|
442
|
+
try {
|
|
443
|
+
return bundleEligibility(await manager.listBundles(), this.currentVersion)
|
|
444
|
+
} catch (error) {
|
|
445
|
+
return { ok: false, state: { status: 'failed', current: this.currentVersion, message: messageOf(error) } }
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private async fetchTarget(base: string): Promise<{ version?: string; publishedAt?: string }> {
|
|
450
|
+
const response = await this.fetchImpl(`${base}/${UPDATE_PACKAGE}`, {
|
|
451
|
+
headers: { accept: 'application/json' },
|
|
452
|
+
signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
|
|
453
|
+
})
|
|
454
|
+
if (!response.ok) throw new Error(`registry ${base} answered ${String(response.status)}`)
|
|
455
|
+
return pickUpdateTarget(await response.json() as unknown, this.currentVersion)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** One registry's packument, via the 10-minute cache. Throws when unreachable. */
|
|
459
|
+
private async targetFor(base: string): Promise<{ version?: string; publishedAt?: string }> {
|
|
460
|
+
const cached = this.cache.get(base)
|
|
461
|
+
if (cached !== undefined && this.now() - cached.at < CACHE_TTL_MS) return cached.target
|
|
462
|
+
const target = await this.fetchTarget(base)
|
|
463
|
+
this.cache.set(base, { at: this.now(), target })
|
|
464
|
+
return target
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Ask the manager's registry chain what is published; never installs.
|
|
469
|
+
*
|
|
470
|
+
* A mirror may lag behind the rest of the chain, so a base that only knows
|
|
471
|
+
* the running (or an older) release does not end the walk — the next base
|
|
472
|
+
* gets its turn. `busy` is deliberately not checked here: reading a registry
|
|
473
|
+
* costs no ACP work, and the settings card should still be able to say what
|
|
474
|
+
* is out there while a turn runs.
|
|
475
|
+
*/
|
|
476
|
+
async check(): Promise<PluginUpdateState> {
|
|
477
|
+
const current = this.currentVersion
|
|
478
|
+
if (this.applying) return { status: 'applying', current }
|
|
479
|
+
this.lastTarget = undefined
|
|
480
|
+
const eligible = await this.eligibility()
|
|
481
|
+
if (!eligible.ok) return eligible.state
|
|
482
|
+
const installed = eligible.installed === undefined ? {} : { installed: eligible.installed }
|
|
483
|
+
const manager = this.resolveManager()
|
|
484
|
+
let plan: UpdateRegistryPlan
|
|
485
|
+
try {
|
|
486
|
+
plan = (await manager?.registries?.()) ?? {}
|
|
487
|
+
} catch (error) {
|
|
488
|
+
return { status: 'failed', current, message: messageOf(error), ...installed }
|
|
489
|
+
}
|
|
490
|
+
const bases = registryPlan(plan)
|
|
491
|
+
const tried: string[] = []
|
|
492
|
+
let best: { version: string; publishedAt?: string } | undefined
|
|
493
|
+
let reached = 0
|
|
494
|
+
for (const base of bases) {
|
|
495
|
+
tried.push(base)
|
|
496
|
+
let target: { version?: string; publishedAt?: string }
|
|
497
|
+
try {
|
|
498
|
+
target = await this.targetFor(base)
|
|
499
|
+
} catch {
|
|
500
|
+
// Unreachable registry: the next one in the chain gets its turn.
|
|
501
|
+
continue
|
|
502
|
+
}
|
|
503
|
+
reached += 1
|
|
504
|
+
const latest = target.version
|
|
505
|
+
if (latest === undefined) continue
|
|
506
|
+
if (best === undefined || compareVersions(latest, best.version) > 0) {
|
|
507
|
+
best = { version: latest, ...(target.publishedAt === undefined ? {} : { publishedAt: target.publishedAt }) }
|
|
508
|
+
}
|
|
509
|
+
if (compareVersions(latest, current) > 0) {
|
|
510
|
+
if (this.applying) return { status: 'applying', current }
|
|
511
|
+
this.lastTarget = { version: latest, registry: base }
|
|
512
|
+
return {
|
|
513
|
+
status: 'available',
|
|
514
|
+
current,
|
|
515
|
+
latest,
|
|
516
|
+
...(target.publishedAt === undefined ? {} : { publishedAt: target.publishedAt }),
|
|
517
|
+
registries: tried.map(displayRegistry),
|
|
518
|
+
...installed,
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
// An install may have started while the registry walk was in flight; a late
|
|
523
|
+
// `available` must never reach the client after that.
|
|
524
|
+
if (this.applying) return { status: 'applying', current }
|
|
525
|
+
if (reached === 0) {
|
|
526
|
+
return {
|
|
527
|
+
status: 'failed',
|
|
528
|
+
current,
|
|
529
|
+
message: tried.length === 0
|
|
530
|
+
? 'no npm registry is configured'
|
|
531
|
+
: `could not reach an npm registry (tried ${tried.map(displayRegistry).join(', ')})`,
|
|
532
|
+
...(tried.length === 0 ? {} : { registries: tried.map(displayRegistry) }),
|
|
533
|
+
...installed,
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return {
|
|
537
|
+
status: 'current',
|
|
538
|
+
current,
|
|
539
|
+
...(best === undefined
|
|
540
|
+
? {}
|
|
541
|
+
: { latest: best.version, ...(best.publishedAt === undefined ? {} : { publishedAt: best.publishedAt }) }),
|
|
542
|
+
registries: tried.map(displayRegistry),
|
|
543
|
+
...installed,
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Install one exact version through the harness plugin manager.
|
|
549
|
+
*
|
|
550
|
+
* Re-entry while an install is running answers `applying` instead of
|
|
551
|
+
* stacking a second npm operation; the install itself is the host's job.
|
|
552
|
+
*
|
|
553
|
+
* With HMR alive the manager refuses to replace a bundle already in the
|
|
554
|
+
* profile (`already-installed`), so the host's own update semantics are
|
|
555
|
+
* remove-then-install. That is NOT self-replacement: a human gesture started
|
|
556
|
+
* it, the manager still performs every file operation, and it unloads the
|
|
557
|
+
* live module first — exactly what the host does for any bundle update. If
|
|
558
|
+
* the reinstall fails, the previous version is put back through the same
|
|
559
|
+
* `installBundle` path.
|
|
560
|
+
*/
|
|
561
|
+
async apply(version: string): Promise<PluginUpdateState> {
|
|
562
|
+
const current = this.currentVersion
|
|
563
|
+
if (compareVersions(version, current) <= 0) {
|
|
564
|
+
// A downgrade (or the running version) is never an update. Version
|
|
565
|
+
// syntax is already enforced at the RPC boundary; answer instead of
|
|
566
|
+
// throwing so a stale UI click cannot surface as an RPC fault.
|
|
567
|
+
return {
|
|
568
|
+
status: 'failed',
|
|
569
|
+
current,
|
|
570
|
+
message: 'the requested version is not newer than the running plugin',
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (this.applying) return { status: 'applying', current }
|
|
574
|
+
this.applying = true
|
|
575
|
+
try {
|
|
576
|
+
const eligible = await this.eligibility()
|
|
577
|
+
if (!eligible.ok) return eligible.state
|
|
578
|
+
const installed = eligible.installed === undefined ? {} : { installed: eligible.installed }
|
|
579
|
+
if (this.isBusy()) return { status: 'busy', current, ...installed }
|
|
580
|
+
const manager = this.resolveManager()
|
|
581
|
+
const installBundle = manager?.installBundle
|
|
582
|
+
if (manager === undefined || installBundle === undefined) {
|
|
583
|
+
return { status: 'unsupported', current, message: 'host does not expose the plugin manager' }
|
|
584
|
+
}
|
|
585
|
+
const removeBundle = typeof manager.removeBundle === 'function' ? manager.removeBundle : undefined
|
|
586
|
+
const registry = this.lastTarget?.version === version ? this.lastTarget.registry : undefined
|
|
587
|
+
const options: { enabled: boolean; registry?: string } = {
|
|
588
|
+
enabled: true,
|
|
589
|
+
...(registry === undefined ? {} : { registry }),
|
|
590
|
+
}
|
|
591
|
+
const install = async (target: string, extra: { enabled?: boolean; registry?: string }): Promise<UpdateInstallResult | undefined> =>
|
|
592
|
+
unwrapInstallResult(await installBundle.call(manager, `${UPDATE_PACKAGE}@${target}`, extra))
|
|
593
|
+
const rollback = async (): Promise<boolean> => {
|
|
594
|
+
// The failed install may already have written the target version into
|
|
595
|
+
// the profile, in which case reinstalling the running version answers
|
|
596
|
+
// `already-installed`. Native update semantics: unload first, ignore
|
|
597
|
+
// failures (the target may never have landed), then install through the
|
|
598
|
+
// host's default chain — the target's registry is not ours to reuse.
|
|
599
|
+
if (removeBundle !== undefined) {
|
|
600
|
+
try {
|
|
601
|
+
await removeBundle.call(manager, UPDATE_PACKAGE)
|
|
602
|
+
} catch {
|
|
603
|
+
// Best effort: a bundle that was never installed cannot be removed.
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
try {
|
|
607
|
+
return isInstallSuccess(await install(current, { enabled: true }))
|
|
608
|
+
} catch {
|
|
609
|
+
return false
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
let result: UpdateInstallResult | undefined
|
|
613
|
+
try {
|
|
614
|
+
result = await install(version, options)
|
|
615
|
+
} catch (error) {
|
|
616
|
+
return { status: 'failed', current, ...installed, message: messageOf(error) }
|
|
617
|
+
}
|
|
618
|
+
if (result?.error?.code === 'already-installed' && removeBundle !== undefined) {
|
|
619
|
+
let removed: UpdateInstallResult | undefined
|
|
620
|
+
try {
|
|
621
|
+
removed = unwrapInstallResult(await removeBundle.call(manager, UPDATE_PACKAGE))
|
|
622
|
+
} catch (error) {
|
|
623
|
+
return {
|
|
624
|
+
status: 'failed',
|
|
625
|
+
current,
|
|
626
|
+
...installed,
|
|
627
|
+
message: `could not remove the running version: ${messageOf(error)}`,
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (!isRemoveSuccess(removed)) {
|
|
631
|
+
return {
|
|
632
|
+
status: 'failed',
|
|
633
|
+
current,
|
|
634
|
+
...installed,
|
|
635
|
+
message: `could not remove the running version: ${removed?.error !== undefined
|
|
636
|
+
? describeFieldError(removed.error)
|
|
637
|
+
: `the plugin manager answered ${removed?.application ?? 'no result'}`}`,
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
let retried: UpdateInstallResult | undefined
|
|
641
|
+
let retryFailure: string | undefined
|
|
642
|
+
try {
|
|
643
|
+
retried = await install(version, options)
|
|
644
|
+
} catch (error) {
|
|
645
|
+
retryFailure = messageOf(error)
|
|
646
|
+
}
|
|
647
|
+
if (retryFailure === undefined && isInstallSuccess(retried)) {
|
|
648
|
+
result = retried
|
|
649
|
+
} else {
|
|
650
|
+
const reason = retryFailure
|
|
651
|
+
?? (retried?.error === undefined
|
|
652
|
+
? retried?.application === undefined
|
|
653
|
+
? 'the plugin manager returned no install result'
|
|
654
|
+
: `the plugin manager answered ${retried.application}`
|
|
655
|
+
: describeFieldError(retried.error))
|
|
656
|
+
const rolledBack = await rollback()
|
|
657
|
+
return {
|
|
658
|
+
status: 'failed',
|
|
659
|
+
current,
|
|
660
|
+
...(rolledBack ? { installed: current } : {}),
|
|
661
|
+
message: `installing ${version} failed: ${reason}; ${rolledBack ? `rolled back to ${current}` : `rolling back to ${current} also failed`}`,
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (result !== undefined && result.error !== undefined) {
|
|
666
|
+
// Never report the target as installed here: the disk still holds the
|
|
667
|
+
// previous version.
|
|
668
|
+
return { status: 'failed', current, ...installed, message: describeFieldError(result.error) }
|
|
669
|
+
}
|
|
670
|
+
const application = result?.application
|
|
671
|
+
if (application === 'applied' || application === 'restart-required') {
|
|
672
|
+
this.cache.clear()
|
|
673
|
+
this.lastTarget = undefined
|
|
674
|
+
return { status: application, current, installed: result?.version ?? version }
|
|
675
|
+
}
|
|
676
|
+
if (application === 'overridden') {
|
|
677
|
+
return {
|
|
678
|
+
status: 'failed',
|
|
679
|
+
current,
|
|
680
|
+
...installed,
|
|
681
|
+
message: 'the installation was overridden by a higher-level configuration',
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (application === 'cancelled') {
|
|
685
|
+
return { status: 'failed', current, ...installed, message: 'installation was cancelled' }
|
|
686
|
+
}
|
|
687
|
+
return {
|
|
688
|
+
status: 'failed',
|
|
689
|
+
current,
|
|
690
|
+
...installed,
|
|
691
|
+
message: application === undefined
|
|
692
|
+
? 'the plugin manager returned no install result'
|
|
693
|
+
: `the plugin manager returned an unexpected application state: ${application}`,
|
|
694
|
+
}
|
|
695
|
+
} finally {
|
|
696
|
+
this.applying = false
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|