dsh-plugin-syncup 0.0.0-stage → 4.1.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/LICENSE +21 -0
- package/README.en.md +183 -0
- package/README.md +362 -2
- package/client.js +1506 -0
- package/cordis.patch.yml +6 -0
- package/icon.svg +26 -0
- package/index.js +354 -0
- package/lib/config.js +330 -0
- package/lib/errors.js +81 -0
- package/lib/github.js +305 -0
- package/lib/http.js +285 -0
- package/lib/jobs.js +107 -0
- package/lib/manifest.js +272 -0
- package/lib/manual.js +85 -0
- package/lib/preview.js +217 -0
- package/lib/pushplan.js +200 -0
- package/lib/spec.js +127 -0
- package/lib/state.js +110 -0
- package/lib/sync.js +1636 -0
- package/lib/versions.js +447 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +52 -3
package/lib/sync.js
ADDED
|
@@ -0,0 +1,1636 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { gistUrl, TOKEN_REF } from './config.js'
|
|
3
|
+
import { describeChangeError, managementCodeOf } from './errors.js'
|
|
4
|
+
import { createGitHubClient, resolveToken } from './github.js'
|
|
5
|
+
import { createJobStore } from './jobs.js'
|
|
6
|
+
import { emptyManifest, localHostEntry, mergeHost, pickInstallSpec, summarizeManifest } from './manifest.js'
|
|
7
|
+
import { buildPreview, selectPreviewEntries } from './preview.js'
|
|
8
|
+
import { applyPushPlan, buildPushPlan } from './pushplan.js'
|
|
9
|
+
import { manualUpdateInstructions, resolveProfileDir } from './manual.js'
|
|
10
|
+
import { parseVersion } from './spec.js'
|
|
11
|
+
import { readState, resolveStatePath, writeState } from './state.js'
|
|
12
|
+
import { createVersionChecker, gitHubTargetOf, isPrereleaseVersion, registryNameOf } from './versions.js'
|
|
13
|
+
|
|
14
|
+
const PREVIEW_TTL_MS = 10 * 60 * 1000
|
|
15
|
+
const MAX_HISTORY = 20
|
|
16
|
+
const SELF_NAMES = new Set(['dsh-plugin-syncup'])
|
|
17
|
+
|
|
18
|
+
function sleep(milliseconds) {
|
|
19
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function errorInfo(error) {
|
|
23
|
+
if (error === null || typeof error !== 'object') {
|
|
24
|
+
return { message: String(error) }
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
message: describeChangeError(error) ?? String(error),
|
|
28
|
+
...(typeof error.code === 'string' ? { code: error.code } : {}),
|
|
29
|
+
...(typeof error.status === 'number' ? { status: error.status } : {}),
|
|
30
|
+
...(error.retryable === true ? { retryable: true } : {}),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function bundleKey(bundle) {
|
|
35
|
+
return [
|
|
36
|
+
bundle.name,
|
|
37
|
+
bundle.spec,
|
|
38
|
+
bundle.version ?? '',
|
|
39
|
+
bundle.enabled === false ? '0' : '1',
|
|
40
|
+
].join('\u0000')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sameBundles(left, right) {
|
|
44
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false
|
|
45
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
46
|
+
if (bundleKey(left[index]) !== bundleKey(right[index])) return false
|
|
47
|
+
}
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function changeError(change) {
|
|
52
|
+
if (change === null || typeof change !== 'object' || change.error === undefined) return null
|
|
53
|
+
const message = describeChangeError(change.error)
|
|
54
|
+
if (message === null) return null
|
|
55
|
+
const code = managementCodeOf(change.error)
|
|
56
|
+
return {
|
|
57
|
+
message,
|
|
58
|
+
...(typeof code === 'string' ? { code } : {}),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function summarizeInstallChange(name, spec, change) {
|
|
63
|
+
const base = { name, spec }
|
|
64
|
+
if (change === null || typeof change !== 'object') {
|
|
65
|
+
return { ...base, status: 'failed', error: 'plugin manager returned no result' }
|
|
66
|
+
}
|
|
67
|
+
const failure = changeError(change)
|
|
68
|
+
if (failure !== null) return { ...base, status: 'failed', error: failure.message, ...(failure.code ? { code: failure.code } : {}) }
|
|
69
|
+
if (change.application === 'failed' || change.application === 'cancelled') {
|
|
70
|
+
return { ...base, status: 'failed', application: change.application }
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
...base,
|
|
74
|
+
status: change.changed === false ? 'unchanged' : 'installed',
|
|
75
|
+
...(typeof change.application === 'string' ? { application: change.application } : {}),
|
|
76
|
+
...(typeof change.version === 'string' ? { version: change.version } : {}),
|
|
77
|
+
...(change.application === 'restart-required' ? { restartRequired: true } : {}),
|
|
78
|
+
...(Array.isArray(change.warnings) && change.warnings.length > 0
|
|
79
|
+
? { warnings: change.warnings.slice(0, 10) }
|
|
80
|
+
: {}),
|
|
81
|
+
...(Array.isArray(change.pendingBuilds) && change.pendingBuilds.length > 0
|
|
82
|
+
? { pendingBuilds: change.pendingBuilds.slice(0, 10) }
|
|
83
|
+
: {}),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createSyncRuntime({
|
|
88
|
+
config,
|
|
89
|
+
pluginManager,
|
|
90
|
+
credentials = null,
|
|
91
|
+
credentialsProvider = null,
|
|
92
|
+
logger = console,
|
|
93
|
+
fetchImpl = globalThis.fetch,
|
|
94
|
+
now = () => new Date(),
|
|
95
|
+
statePath = resolveStatePath(config),
|
|
96
|
+
env = process.env,
|
|
97
|
+
}) {
|
|
98
|
+
let stateCache = null
|
|
99
|
+
let stateLoading = null
|
|
100
|
+
let operation = null
|
|
101
|
+
let previewCache = null
|
|
102
|
+
let latestCache = null
|
|
103
|
+
let planCache = null
|
|
104
|
+
const jobs = createJobStore({ now })
|
|
105
|
+
|
|
106
|
+
function jobLog(job, level, message) {
|
|
107
|
+
jobs.append(job.id, level, message)
|
|
108
|
+
log(level, message)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function log(level, message) {
|
|
112
|
+
const write = logger?.[level]
|
|
113
|
+
if (typeof write !== 'function') return
|
|
114
|
+
try {
|
|
115
|
+
write.call(logger, `plugin-sync: ${message}`)
|
|
116
|
+
} catch {
|
|
117
|
+
// logging must never break the operation
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function getState() {
|
|
122
|
+
if (stateCache !== null) return stateCache
|
|
123
|
+
if (stateLoading === null) {
|
|
124
|
+
stateLoading = readState(statePath, config.hostId).then((state) => {
|
|
125
|
+
stateCache = state
|
|
126
|
+
return state
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
return stateLoading
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function resolveCredential() {
|
|
133
|
+
const currentCredentials = typeof credentialsProvider === 'function'
|
|
134
|
+
? credentialsProvider()
|
|
135
|
+
: credentials
|
|
136
|
+
if (currentCredentials !== null && currentCredentials !== undefined && typeof currentCredentials.resolve === 'function') {
|
|
137
|
+
try {
|
|
138
|
+
const resolved = await currentCredentials.resolve(TOKEN_REF)
|
|
139
|
+
if (resolved !== undefined && typeof resolved.value === 'string' && resolved.value !== '') {
|
|
140
|
+
return {
|
|
141
|
+
token: resolved.value,
|
|
142
|
+
source: typeof resolved.source === 'string' ? resolved.source : 'credentials',
|
|
143
|
+
configured: true,
|
|
144
|
+
writable: true,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// Fall through to the legacy env/file sources.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const fallback = resolveToken({
|
|
152
|
+
token: config.token,
|
|
153
|
+
tokenEnv: config.tokenEnv,
|
|
154
|
+
tokenFile: config.tokenFile,
|
|
155
|
+
env,
|
|
156
|
+
})
|
|
157
|
+
return {
|
|
158
|
+
token: fallback.token,
|
|
159
|
+
source: fallback.source,
|
|
160
|
+
configured: fallback.token !== '',
|
|
161
|
+
writable: false,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function getClient() {
|
|
166
|
+
if (config.configError !== undefined) throw new Error(config.configError)
|
|
167
|
+
const token = await resolveCredential()
|
|
168
|
+
return createGitHubClient({
|
|
169
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
170
|
+
token: token.token,
|
|
171
|
+
timeoutMs: config.requestTimeoutMs,
|
|
172
|
+
fetchImpl,
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function resolveGistId(github) {
|
|
177
|
+
const state = await getState()
|
|
178
|
+
if (
|
|
179
|
+
typeof state.gistId === 'string' && state.gistId !== ''
|
|
180
|
+
&& (state.gistName === null || state.gistName === config.gistName)
|
|
181
|
+
) {
|
|
182
|
+
return state.gistId
|
|
183
|
+
}
|
|
184
|
+
const gists = await github.listGists()
|
|
185
|
+
const matches = gists
|
|
186
|
+
.filter((gist) => typeof gist.description === 'string' && gist.description.trim() === config.gistName)
|
|
187
|
+
.sort((left, right) => new Date(right.updatedAt ?? 0).getTime() - new Date(left.updatedAt ?? 0).getTime())
|
|
188
|
+
if (matches.length === 0) return ''
|
|
189
|
+
const match = matches[0]
|
|
190
|
+
await persist({
|
|
191
|
+
gistName: config.gistName,
|
|
192
|
+
gistId: match.gistId,
|
|
193
|
+
gistUrl: match.htmlUrl,
|
|
194
|
+
})
|
|
195
|
+
return match.gistId
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function runExclusive(name, work) {
|
|
199
|
+
if (operation !== null) {
|
|
200
|
+
throw new Error(`Another plugin-sync operation is already running (${name}).`)
|
|
201
|
+
}
|
|
202
|
+
const job = work()
|
|
203
|
+
operation = job
|
|
204
|
+
try {
|
|
205
|
+
return await job
|
|
206
|
+
} finally {
|
|
207
|
+
if (operation === job) operation = null
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function collectLocal() {
|
|
212
|
+
const bundles = await pluginManager.listBundles()
|
|
213
|
+
const entry = localHostEntry(bundles, {
|
|
214
|
+
hostId: config.hostId,
|
|
215
|
+
profile: config.profile,
|
|
216
|
+
platform: process.platform,
|
|
217
|
+
now: now().toISOString(),
|
|
218
|
+
exclude: config.excludeBundles,
|
|
219
|
+
})
|
|
220
|
+
return { bundles, entry }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function localSummary(bundles, entry) {
|
|
224
|
+
return {
|
|
225
|
+
profileBundles: bundles.filter((bundle) => bundle?.availability === 'profile' && bundle.installed === true).length,
|
|
226
|
+
portableBundles: entry.bundles.length,
|
|
227
|
+
...(entry.dshVersion === undefined ? {} : { dshVersion: entry.dshVersion }),
|
|
228
|
+
...(entry.profile === undefined ? {} : { profile: entry.profile }),
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Replace an installed registry bundle through the plugin manager's own update
|
|
234
|
+
* path, when this DSH version has one.
|
|
235
|
+
*
|
|
236
|
+
* dsh 0.2.2 added `updateBundle(name, version)`: it checks the target on the
|
|
237
|
+
* registry, removes the running copy under HMR, installs the new version with
|
|
238
|
+
* the same selection, and reinstalls the old version when that fails. That is
|
|
239
|
+
* exactly the remove-then-install dance this plugin used to perform by hand,
|
|
240
|
+
* but done atomically inside the manager, so it is preferred whenever it is
|
|
241
|
+
* available and applies.
|
|
242
|
+
*
|
|
243
|
+
* It only handles bundles installed from a registry under their own name, and
|
|
244
|
+
* requires a strictly newer semver. Anything else returns `null` and the caller
|
|
245
|
+
* falls back to the generic install path.
|
|
246
|
+
*/
|
|
247
|
+
function registryUpdateFor(name, version) {
|
|
248
|
+
if (typeof pluginManager?.updateBundle !== 'function') return null
|
|
249
|
+
const wanted = String(version ?? '').trim()
|
|
250
|
+
if (wanted === '' || parseVersion(wanted) === null || isPrereleaseVersion(wanted)) return null
|
|
251
|
+
return { name, version: wanted }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The exact version inside `name@1.2.3`, or `''` for any other spec shape. */
|
|
255
|
+
function versionFromSpec(spec) {
|
|
256
|
+
if (typeof spec !== 'string' || /[:\s]/.test(spec)) return ''
|
|
257
|
+
const at = spec.lastIndexOf('@')
|
|
258
|
+
if (at <= 0) return ''
|
|
259
|
+
const version = spec.slice(at + 1)
|
|
260
|
+
return parseVersion(version) === null ? '' : version
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function updateViaManager({ name, version, job }) {
|
|
264
|
+
jobLog(job, 'info', `${name}: updating to ${version} through the plugin manager`)
|
|
265
|
+
try {
|
|
266
|
+
const outcome = await pluginManager.updateBundle(name, version)
|
|
267
|
+
const step = outcome?.step ?? 'update'
|
|
268
|
+
const result = outcome?.result ?? outcome
|
|
269
|
+
const failure = step === 'install' || step === 'remove' || step === 'check'
|
|
270
|
+
? changeError(result ?? {})
|
|
271
|
+
: null
|
|
272
|
+
if (failure !== null) {
|
|
273
|
+
jobLog(job, 'error', `${name}: update failed — ${failure.message}`)
|
|
274
|
+
return {
|
|
275
|
+
name,
|
|
276
|
+
spec: `${name}@${version}`,
|
|
277
|
+
status: 'failed',
|
|
278
|
+
error: failure.message,
|
|
279
|
+
...(failure.code ? { code: failure.code } : {}),
|
|
280
|
+
...(outcome?.restore === undefined ? {} : { rollback: 'failed' }),
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (result?.application === 'failed' || result?.application === 'cancelled') {
|
|
284
|
+
jobLog(job, 'error', `${name}: update ${result.application}`)
|
|
285
|
+
return {
|
|
286
|
+
name,
|
|
287
|
+
spec: `${name}@${version}`,
|
|
288
|
+
status: 'failed',
|
|
289
|
+
error: `the plugin manager ${result.application} the update`,
|
|
290
|
+
...(outcome?.restore === undefined ? {} : { rollback: 'failed' }),
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const restored = outcome?.restore !== undefined && outcome.restore.application !== 'failed'
|
|
294
|
+
jobLog(
|
|
295
|
+
job,
|
|
296
|
+
'info',
|
|
297
|
+
`${name}: ${restored ? 'restored the previous version' : 'updated'} to ${version}`
|
|
298
|
+
+ `${result?.application === 'restart-required' ? ' (restart required)' : ''}`,
|
|
299
|
+
)
|
|
300
|
+
return {
|
|
301
|
+
name,
|
|
302
|
+
spec: `${name}@${version}`,
|
|
303
|
+
status: 'installed',
|
|
304
|
+
version,
|
|
305
|
+
...(outcome?.from === undefined ? {} : { replaced: outcome.from }),
|
|
306
|
+
...(outcome?.from === undefined ? {} : { upgraded: true }),
|
|
307
|
+
...(restored ? { rollback: 'restored' } : {}),
|
|
308
|
+
...(result?.application === 'restart-required' ? { restartRequired: true } : {}),
|
|
309
|
+
...(Array.isArray(result?.warnings) && result.warnings.length > 0
|
|
310
|
+
? { warnings: result.warnings.slice(0, 10) }
|
|
311
|
+
: {}),
|
|
312
|
+
}
|
|
313
|
+
} catch (error) {
|
|
314
|
+
const info = errorInfo(error)
|
|
315
|
+
jobLog(job, 'error', `${name}: update failed — ${info.message}`)
|
|
316
|
+
return { name, spec: `${name}@${version}`, status: 'failed', ...info }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Install a bundle, replacing an installed copy when the manager refuses it.
|
|
322
|
+
*
|
|
323
|
+
* dsh 0.2.1's plugin manager answers `installBundle` with `already-installed`
|
|
324
|
+
* whenever a live `hmr` service is present and the package is already a profile
|
|
325
|
+
* dependency, so an in-place upgrade does not exist. The Plugins page and
|
|
326
|
+
* dshmarket both work around this by removing first and installing again.
|
|
327
|
+
*
|
|
328
|
+
* That two-step is not atomic, and `removeBundle` deselects the bundle BEFORE
|
|
329
|
+
* it can fail with `bundle-in-use` — which leaves the bundle installed but
|
|
330
|
+
* dropped from `dsh.profile.bundles`, i.e. silently disabled and gone from the
|
|
331
|
+
* Loader. So this path:
|
|
332
|
+
*
|
|
333
|
+
* 1. probes first: deselect, then verify the Loader really released it;
|
|
334
|
+
* 2. reselects immediately when the probe proves the bundle cannot be unloaded;
|
|
335
|
+
* 3. records such bundles in `state.manualUpdates` and skips them next time;
|
|
336
|
+
* 4. reselects after any failed removal, so a failure never strands the bundle.
|
|
337
|
+
*/
|
|
338
|
+
async function installOrUpgrade({ name, spec, previous, job }) {
|
|
339
|
+
// `previous` is either a raw listBundles() row (spec lives in
|
|
340
|
+
// installTarget.spec or source) or a preview local view (already resolved).
|
|
341
|
+
const fromSpec = previous === null || previous === undefined
|
|
342
|
+
? ''
|
|
343
|
+
: String(pickInstallSpec(previous) || previous.spec || '').trim()
|
|
344
|
+
const record = fromSpec === ''
|
|
345
|
+
? null
|
|
346
|
+
: {
|
|
347
|
+
name,
|
|
348
|
+
fromSpec,
|
|
349
|
+
fromVersion: typeof previous?.version === 'string' ? previous.version : '',
|
|
350
|
+
toSpec: spec,
|
|
351
|
+
startedAt: now().toISOString(),
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const first = await safeInstall(job, name, spec)
|
|
355
|
+
|
|
356
|
+
if (first.status !== 'failed' || first.code !== 'already-installed') return first
|
|
357
|
+
|
|
358
|
+
if (record === null) {
|
|
359
|
+
return {
|
|
360
|
+
...first,
|
|
361
|
+
error: `${first.error ?? 'already-installed'} The installed spec is unknown, so it cannot be replaced automatically.`,
|
|
362
|
+
hint: 'plugins-page',
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const known = await findManualUpdate(name)
|
|
367
|
+
if (known !== null) {
|
|
368
|
+
// A remembered failure predates this DSH's own update path. When the manager
|
|
369
|
+
// can now replace the bundle itself, that memory is stale — retry instead of
|
|
370
|
+
// skipping forever.
|
|
371
|
+
const retry = registryUpdateFor(name, versionFromSpec(spec))
|
|
372
|
+
if (retry === null) {
|
|
373
|
+
jobLog(job, 'warn', `${name}: previously required a manual update; skipping`)
|
|
374
|
+
return {
|
|
375
|
+
...first,
|
|
376
|
+
code: 'manual-update',
|
|
377
|
+
error: `${first.error ?? 'already-installed'} This bundle could not be unloaded by HMR before (${known.at}), so it was left untouched.`,
|
|
378
|
+
hint: 'manual-terminal',
|
|
379
|
+
manual: instructionsFor(name, spec),
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
jobLog(job, 'info', `${name}: the plugin manager can update it now; retrying instead of skipping`)
|
|
383
|
+
await forgetManualUpdate(name)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// The manager's own update path handles the swap atomically; prefer it.
|
|
387
|
+
const native = registryUpdateFor(name, versionFromSpec(spec))
|
|
388
|
+
if (native !== null) {
|
|
389
|
+
const updated = await updateViaManager({ ...native, job })
|
|
390
|
+
if (updated.status !== 'failed' && updated.rollback === undefined) {
|
|
391
|
+
await clearPendingUpgrade(name)
|
|
392
|
+
await forgetManualUpdate(name)
|
|
393
|
+
return { ...updated, upgraded: updated.rollback === undefined, replaced: record.fromVersion || record.fromSpec }
|
|
394
|
+
}
|
|
395
|
+
// A failed native update already restored the previous version; report it
|
|
396
|
+
// rather than trying the manual dance on top.
|
|
397
|
+
return updated
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
jobLog(job, 'warn', `${name}: already installed; replacing ${record.fromVersion || record.fromSpec} with ${spec}`)
|
|
401
|
+
|
|
402
|
+
// Step 1: probe with a deselect, then check whether the Loader let it go.
|
|
403
|
+
const probe = await probeUnload(job, name)
|
|
404
|
+
if (probe.released === false) {
|
|
405
|
+
await reselect(job, name)
|
|
406
|
+
await rememberManualUpdate(name, { reason: 'bundle-in-use', toSpec: spec })
|
|
407
|
+
return {
|
|
408
|
+
...first,
|
|
409
|
+
code: 'manual-update',
|
|
410
|
+
error: `${first.error ?? 'already-installed'} HMR cannot unload this bundle while DSH runs (${probe.reason ?? 'still loaded'}), so it was left untouched.`,
|
|
411
|
+
hint: 'manual-terminal',
|
|
412
|
+
manual: instructionsFor(name, spec),
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
await persist({ pendingUpgrades: [...(await getPendingUpgrades()).filter((item) => item.name !== name), record] })
|
|
417
|
+
|
|
418
|
+
// Step 2: the real removal, then reselect if it refuses.
|
|
419
|
+
const removal = await safeRemove(job, name)
|
|
420
|
+
if (removal.status === 'failed') {
|
|
421
|
+
await reselect(job, name)
|
|
422
|
+
await clearPendingUpgrade(name)
|
|
423
|
+
if (removal.code === 'bundle-in-use') {
|
|
424
|
+
await rememberManualUpdate(name, { reason: 'bundle-in-use', toSpec: spec })
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
...removal,
|
|
428
|
+
error: `Could not replace the installed copy: ${removal.error ?? 'remove failed'}`,
|
|
429
|
+
reselected: true,
|
|
430
|
+
...(removal.code === 'bundle-in-use'
|
|
431
|
+
? { hint: 'manual-terminal', manual: instructionsFor(name, spec) }
|
|
432
|
+
: {}),
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const second = await safeInstall(job, name, spec)
|
|
437
|
+
if (second.status !== 'failed') {
|
|
438
|
+
await clearPendingUpgrade(name)
|
|
439
|
+
await forgetManualUpdate(name)
|
|
440
|
+
return { ...second, replaced: record.fromVersion || record.fromSpec, upgraded: true }
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
jobLog(job, 'error', `${name}: install failed after removal; restoring ${record.fromSpec}`)
|
|
444
|
+
const restore = await safeInstall(job, name, record.fromSpec)
|
|
445
|
+
if (restore.status === 'failed') {
|
|
446
|
+
jobLog(job, 'error', `${name}: could not restore ${record.fromSpec} — ${restore.error ?? 'unknown error'}`)
|
|
447
|
+
return {
|
|
448
|
+
...second,
|
|
449
|
+
error: `${second.error ?? 'install failed'} The previous copy could not be restored either; reinstall ${record.fromSpec} manually.`,
|
|
450
|
+
rollback: 'failed',
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
await clearPendingUpgrade(name)
|
|
454
|
+
jobLog(job, 'warn', `${name}: restored ${record.fromVersion || record.fromSpec}`)
|
|
455
|
+
return {
|
|
456
|
+
...second,
|
|
457
|
+
error: `${second.error ?? 'install failed'} Restored the previous copy (${record.fromVersion || record.fromSpec}).`,
|
|
458
|
+
rollback: 'restored',
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Deselect a bundle and report whether the Loader actually dropped its rows.
|
|
464
|
+
*
|
|
465
|
+
* `setBundleEnabled(name, false)` is the only public way to ask HMR to stop a
|
|
466
|
+
* bundle. When it refuses outright it throws, and the bundle stays selected, so
|
|
467
|
+
* the caller can simply stop. When it succeeds, `listBundles()` tells us whether
|
|
468
|
+
* the bundle is still marked enabled: a bundle HMR refused to unload keeps its
|
|
469
|
+
* row, which is exactly the state `removeBundle` turns into `bundle-in-use`.
|
|
470
|
+
*/
|
|
471
|
+
async function probeUnload(job, name) {
|
|
472
|
+
jobLog(job, 'info', `${name}: checking whether HMR can unload it`)
|
|
473
|
+
try {
|
|
474
|
+
const change = await pluginManager.setBundleEnabled(name, false)
|
|
475
|
+
const summary = summarizeInstallChange(name, '', change)
|
|
476
|
+
if (summary.status === 'failed') {
|
|
477
|
+
jobLog(job, 'warn', `${name}: cannot be disabled (${summary.error ?? 'refused'}); treating as still loaded`)
|
|
478
|
+
return { released: false, reason: summary.error ?? 'disable refused' }
|
|
479
|
+
}
|
|
480
|
+
if (summary.application === 'restart-required' || summary.restartRequired === true) {
|
|
481
|
+
jobLog(job, 'warn', `${name}: needs a restart before it can be unloaded`)
|
|
482
|
+
return { released: false, reason: 'restart required' }
|
|
483
|
+
}
|
|
484
|
+
} catch (error) {
|
|
485
|
+
const info = errorInfo(error)
|
|
486
|
+
jobLog(job, 'warn', `${name}: cannot be disabled — ${info.message}`)
|
|
487
|
+
return { released: false, reason: info.message }
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const current = await findBundle(name)
|
|
491
|
+
if (current === null) return { released: true }
|
|
492
|
+
if (current.enabled === false) {
|
|
493
|
+
jobLog(job, 'info', `${name}: HMR released it`)
|
|
494
|
+
return { released: true }
|
|
495
|
+
}
|
|
496
|
+
return { released: false, reason: 'still enabled after deselect' }
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function findBundle(name) {
|
|
500
|
+
try {
|
|
501
|
+
const bundles = await pluginManager.listBundles()
|
|
502
|
+
for (const bundle of bundles) {
|
|
503
|
+
if (bundle !== null && typeof bundle === 'object' && bundle.name === name) return bundle
|
|
504
|
+
}
|
|
505
|
+
} catch (error) {
|
|
506
|
+
log('warn', `could not list bundles: ${describeChangeError(error) ?? String(error)}`)
|
|
507
|
+
}
|
|
508
|
+
return null
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Put a bundle back in the profile selection after a refused removal. */
|
|
512
|
+
async function reselect(job, name) {
|
|
513
|
+
try {
|
|
514
|
+
const change = await pluginManager.setBundleEnabled(name, true)
|
|
515
|
+
const summary = summarizeInstallChange(name, '', change)
|
|
516
|
+
if (summary.status === 'failed') {
|
|
517
|
+
jobLog(job, 'error', `${name}: could not restore its profile selection — ${summary.error ?? 'unknown error'}`)
|
|
518
|
+
return false
|
|
519
|
+
}
|
|
520
|
+
jobLog(job, 'warn', `${name}: kept selected (removal was refused)`)
|
|
521
|
+
return true
|
|
522
|
+
} catch (error) {
|
|
523
|
+
const info = errorInfo(error)
|
|
524
|
+
jobLog(job, 'error', `${name}: could not restore its profile selection — ${info.message}`)
|
|
525
|
+
return false
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function safeInstall(job, name, spec) {
|
|
530
|
+
try {
|
|
531
|
+
const change = await pluginManager.installBundle(spec, { enabled: true })
|
|
532
|
+
const summary = summarizeInstallChange(name, spec, change)
|
|
533
|
+
if (summary.status === 'failed') {
|
|
534
|
+
jobLog(job, 'error', `${name}: install failed — ${summary.error ?? 'unknown error'}`)
|
|
535
|
+
}
|
|
536
|
+
return summary
|
|
537
|
+
} catch (error) {
|
|
538
|
+
const info = errorInfo(error)
|
|
539
|
+
jobLog(job, 'error', `${name}: install failed — ${info.message}`)
|
|
540
|
+
return { name, spec, status: 'failed', error: info.message, ...(info.code ? { code: info.code } : {}) }
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function safeRemove(job, name) {
|
|
545
|
+
try {
|
|
546
|
+
const change = await pluginManager.removeBundle(name)
|
|
547
|
+
const summary = summarizeInstallChange(name, '', change)
|
|
548
|
+
if (summary.status === 'failed') {
|
|
549
|
+
jobLog(job, 'error', `${name}: remove failed — ${summary.error ?? 'unknown error'}`)
|
|
550
|
+
}
|
|
551
|
+
return summary
|
|
552
|
+
} catch (error) {
|
|
553
|
+
const info = errorInfo(error)
|
|
554
|
+
jobLog(job, 'error', `${name}: remove failed — ${info.message}`)
|
|
555
|
+
return { name, status: 'failed', error: info.message, ...(info.code ? { code: info.code } : {}) }
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function getPendingUpgrades() {
|
|
560
|
+
const state = await getState()
|
|
561
|
+
return Array.isArray(state.pendingUpgrades) ? state.pendingUpgrades : []
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Terminal steps for a bundle HMR will not let go of. */
|
|
565
|
+
function instructionsFor(name, spec) {
|
|
566
|
+
return manualUpdateInstructions({
|
|
567
|
+
name,
|
|
568
|
+
spec,
|
|
569
|
+
profileDir: resolveProfileDir(config),
|
|
570
|
+
packHome: config.packHome,
|
|
571
|
+
})
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function clearPendingUpgrade(name) {
|
|
575
|
+
const pending = (await getPendingUpgrades()).filter((item) => item.name !== name)
|
|
576
|
+
await persist({ pendingUpgrades: pending })
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function getManualUpdates() {
|
|
580
|
+
const state = await getState()
|
|
581
|
+
return Array.isArray(state.manualUpdates) ? state.manualUpdates : []
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function findManualUpdate(name) {
|
|
585
|
+
const entries = await getManualUpdates()
|
|
586
|
+
return entries.find((item) => item.name === name) ?? null
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function rememberManualUpdate(name, { reason, toSpec }) {
|
|
590
|
+
const entries = (await getManualUpdates()).filter((item) => item.name !== name)
|
|
591
|
+
entries.push({ name, reason, toSpec, at: now().toISOString(), attempts: 1 })
|
|
592
|
+
await persist({ manualUpdates: entries })
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async function forgetManualUpdate(name) {
|
|
596
|
+
const entries = await getManualUpdates()
|
|
597
|
+
if (!entries.some((item) => item.name === name)) return
|
|
598
|
+
await persist({ manualUpdates: entries.filter((item) => item.name !== name) })
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function pushWithRetry(github, gistId, hostEntry, signal, { planId = null, selections = {} } = {}) {
|
|
602
|
+
let lastError = null
|
|
603
|
+
let report = null
|
|
604
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
605
|
+
if (signal?.aborted) throw new Error('operation was aborted')
|
|
606
|
+
const remote = await github.readGist(gistId, config.fileName, { extraSignal: signal })
|
|
607
|
+
|
|
608
|
+
// Without a confirmed plan the push keeps the old behaviour: replace this
|
|
609
|
+
// host's record and leave other hosts alone.
|
|
610
|
+
let merged
|
|
611
|
+
if (planId === null) {
|
|
612
|
+
if (remote.exists && sameBundles(remote.manifest.hosts[config.hostId]?.bundles, hostEntry.bundles)) {
|
|
613
|
+
return {
|
|
614
|
+
status: 'unchanged',
|
|
615
|
+
attempts: attempt,
|
|
616
|
+
version: remote.version,
|
|
617
|
+
htmlUrl: remote.htmlUrl,
|
|
618
|
+
manifest: remote.manifest,
|
|
619
|
+
report: null,
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
merged = mergeHost(remote.manifest, config.hostId, hostEntry, now().toISOString())
|
|
623
|
+
} else {
|
|
624
|
+
// Re-derive the plan from the freshly read manifest so a concurrent push
|
|
625
|
+
// cannot be silently overwritten with a stale comparison.
|
|
626
|
+
const plan = buildPushPlan({
|
|
627
|
+
manifest: remote.manifest,
|
|
628
|
+
hostEntry,
|
|
629
|
+
hostId: config.hostId,
|
|
630
|
+
now: now().toISOString(),
|
|
631
|
+
})
|
|
632
|
+
const applied = applyPushPlan({
|
|
633
|
+
manifest: remote.manifest,
|
|
634
|
+
hostEntry,
|
|
635
|
+
hostId: config.hostId,
|
|
636
|
+
plan,
|
|
637
|
+
selections,
|
|
638
|
+
now: now().toISOString(),
|
|
639
|
+
})
|
|
640
|
+
merged = applied.manifest
|
|
641
|
+
report = applied.report
|
|
642
|
+
if (remote.exists && JSON.stringify(remote.manifest) === JSON.stringify(merged)) {
|
|
643
|
+
return {
|
|
644
|
+
status: 'unchanged',
|
|
645
|
+
attempts: attempt,
|
|
646
|
+
version: remote.version,
|
|
647
|
+
htmlUrl: remote.htmlUrl,
|
|
648
|
+
manifest: remote.manifest,
|
|
649
|
+
report,
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
try {
|
|
655
|
+
const updated = await github.updateGist(gistId, {
|
|
656
|
+
description: config.gistName,
|
|
657
|
+
fileName: config.fileName,
|
|
658
|
+
manifest: merged,
|
|
659
|
+
expectedVersion: remote.version,
|
|
660
|
+
extraSignal: signal,
|
|
661
|
+
})
|
|
662
|
+
return {
|
|
663
|
+
status: 'pushed',
|
|
664
|
+
attempts: attempt,
|
|
665
|
+
version: updated.version,
|
|
666
|
+
htmlUrl: updated.htmlUrl,
|
|
667
|
+
manifest: merged,
|
|
668
|
+
report,
|
|
669
|
+
}
|
|
670
|
+
} catch (error) {
|
|
671
|
+
lastError = error
|
|
672
|
+
if (error?.code === 'conflict' && attempt < 3) {
|
|
673
|
+
await sleep(150 * attempt)
|
|
674
|
+
continue
|
|
675
|
+
}
|
|
676
|
+
throw error
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
throw lastError ?? new Error('could not update the Gist')
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function persist(patch) {
|
|
683
|
+
try {
|
|
684
|
+
const current = await getState()
|
|
685
|
+
const history = Array.isArray(patch.history)
|
|
686
|
+
? patch.history.slice(0, MAX_HISTORY)
|
|
687
|
+
: Array.isArray(current.history) ? current.history.slice(0, MAX_HISTORY) : []
|
|
688
|
+
const next = {
|
|
689
|
+
...current,
|
|
690
|
+
...patch,
|
|
691
|
+
hostId: config.hostId,
|
|
692
|
+
history,
|
|
693
|
+
}
|
|
694
|
+
stateCache = await writeState(statePath, next)
|
|
695
|
+
} catch (error) {
|
|
696
|
+
log('warn', `could not save state: ${error instanceof Error ? error.message : String(error)}`)
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function status() {
|
|
701
|
+
let local = null
|
|
702
|
+
let localError = null
|
|
703
|
+
try {
|
|
704
|
+
const snapshot = await collectLocal()
|
|
705
|
+
local = localSummary(snapshot.bundles, snapshot.entry)
|
|
706
|
+
} catch (error) {
|
|
707
|
+
localError = errorInfo(error)
|
|
708
|
+
}
|
|
709
|
+
const state = await getState()
|
|
710
|
+
const gistName = config.gistName
|
|
711
|
+
const stateMatchesGist = state.gistName === gistName || (state.gistName === null && typeof state.gistId === 'string')
|
|
712
|
+
const gistId = stateMatchesGist && typeof state.gistId === 'string' ? state.gistId : ''
|
|
713
|
+
const effectiveGistUrl = stateMatchesGist
|
|
714
|
+
? (state.gistUrl ?? (gistId === '' ? null : gistUrl(gistId)))
|
|
715
|
+
: null
|
|
716
|
+
const token = await resolveCredential()
|
|
717
|
+
return {
|
|
718
|
+
ok: true,
|
|
719
|
+
configured: gistId !== '',
|
|
720
|
+
gistName,
|
|
721
|
+
gistId,
|
|
722
|
+
gistUrl: effectiveGistUrl,
|
|
723
|
+
...(config.configError === undefined ? {} : { configError: config.configError }),
|
|
724
|
+
config: {
|
|
725
|
+
gistName,
|
|
726
|
+
fileName: config.fileName,
|
|
727
|
+
description: config.description,
|
|
728
|
+
hostId: config.hostId,
|
|
729
|
+
maxInstallsPerRun: config.maxInstallsPerRun,
|
|
730
|
+
},
|
|
731
|
+
token: {
|
|
732
|
+
configured: token.configured,
|
|
733
|
+
source: token.source,
|
|
734
|
+
writable: token.writable,
|
|
735
|
+
},
|
|
736
|
+
local,
|
|
737
|
+
...(localError === null ? {} : { localError }),
|
|
738
|
+
state: {
|
|
739
|
+
...(state.lastPreviewAt === null ? {} : { lastPreviewAt: state.lastPreviewAt }),
|
|
740
|
+
...(state.lastApplyAt === null ? {} : { lastApplyAt: state.lastApplyAt }),
|
|
741
|
+
...(state.lastPushAt === null ? {} : { lastPushAt: state.lastPushAt }),
|
|
742
|
+
...(state.remote === null || state.remote === undefined ? {} : { remote: state.remote }),
|
|
743
|
+
lastResults: Array.isArray(state.lastResults) ? state.lastResults : [],
|
|
744
|
+
...(Array.isArray(state.pendingUpgrades) && state.pendingUpgrades.length > 0
|
|
745
|
+
? { pendingUpgrades: state.pendingUpgrades }
|
|
746
|
+
: {}),
|
|
747
|
+
...(Array.isArray(state.manualUpdates) && state.manualUpdates.length > 0
|
|
748
|
+
? { manualUpdates: state.manualUpdates }
|
|
749
|
+
: {}),
|
|
750
|
+
...(state.loadError === undefined ? {} : { loadError: state.loadError }),
|
|
751
|
+
},
|
|
752
|
+
previewAvailable: previewCache !== null,
|
|
753
|
+
// Lets a freshly mounted page learn that work is already running, instead
|
|
754
|
+
// of assuming an idle Host and reporting a contradictory error.
|
|
755
|
+
busy: operation !== null,
|
|
756
|
+
latestCheckedAt: latestCache === null ? null : new Date(latestCache.at).toISOString(),
|
|
757
|
+
statePath,
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function preview({ signal } = {}) {
|
|
762
|
+
return runExclusive('preview', async () => {
|
|
763
|
+
const job = jobs.start('preview')
|
|
764
|
+
jobs.progress(job.id, 0, 3)
|
|
765
|
+
jobLog(job, 'info', `reading Gist "${config.gistName}"`)
|
|
766
|
+
try {
|
|
767
|
+
const github = await getClient()
|
|
768
|
+
jobs.progress(job.id, 1, 3)
|
|
769
|
+
const gistId = await resolveGistId(github)
|
|
770
|
+
if (gistId === '') {
|
|
771
|
+
throw new Error(`No Gist named "${config.gistName}" was found. Push the local inventory to create it.`)
|
|
772
|
+
}
|
|
773
|
+
const remote = await github.readGist(gistId, config.fileName, { extraSignal: signal })
|
|
774
|
+
jobLog(job, 'info', `Gist ${gistId} read (${remote.exists ? 'manifest found' : 'no manifest file yet'})`)
|
|
775
|
+
jobs.progress(job.id, 2, 3)
|
|
776
|
+
const { bundles, entry } = await collectLocal()
|
|
777
|
+
const previewData = buildPreview({
|
|
778
|
+
manifest: remote.manifest,
|
|
779
|
+
localBundles: bundles,
|
|
780
|
+
hostId: config.hostId,
|
|
781
|
+
exclude: config.excludeBundles,
|
|
782
|
+
now: now().toISOString(),
|
|
783
|
+
})
|
|
784
|
+
// Bundles HMR could not unload before keep failing on remove-then-install;
|
|
785
|
+
// surface that on the row so the choice is made with open eyes.
|
|
786
|
+
const manualNames = new Set((await getManualUpdates()).map((item) => item.name))
|
|
787
|
+
if (manualNames.size > 0) {
|
|
788
|
+
for (const entry of previewData.entries) {
|
|
789
|
+
if (manualNames.has(entry.name) && entry.kind !== 'current') entry.manualUpdate = true
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
jobs.progress(job.id, 3, 3)
|
|
793
|
+
jobLog(
|
|
794
|
+
job,
|
|
795
|
+
'info',
|
|
796
|
+
`preview built: ${previewData.counts.actionable} actionable, ${previewData.counts.conflicts} conflicts, `
|
|
797
|
+
+ `${previewData.counts.localOnly} local-only`,
|
|
798
|
+
)
|
|
799
|
+
const previewId = randomUUID()
|
|
800
|
+
previewCache = {
|
|
801
|
+
id: previewId,
|
|
802
|
+
createdAt: Date.now(),
|
|
803
|
+
expiresAt: Date.now() + PREVIEW_TTL_MS,
|
|
804
|
+
gistId,
|
|
805
|
+
version: remote.version,
|
|
806
|
+
preview: previewData,
|
|
807
|
+
}
|
|
808
|
+
const state = await getState()
|
|
809
|
+
await persist({
|
|
810
|
+
gistName: config.gistName,
|
|
811
|
+
gistId,
|
|
812
|
+
gistUrl: remote.htmlUrl ?? gistUrl(gistId),
|
|
813
|
+
lastPreviewAt: now().toISOString(),
|
|
814
|
+
remote: summarizeManifest(remote.manifest),
|
|
815
|
+
history: [
|
|
816
|
+
{
|
|
817
|
+
at: now().toISOString(),
|
|
818
|
+
action: 'preview',
|
|
819
|
+
ok: true,
|
|
820
|
+
conflicts: previewData.conflicts.length,
|
|
821
|
+
actionable: previewData.counts.actionable,
|
|
822
|
+
},
|
|
823
|
+
...(Array.isArray(state.history) ? state.history : []),
|
|
824
|
+
].slice(0, MAX_HISTORY),
|
|
825
|
+
})
|
|
826
|
+
const local = localSummary(bundles, entry)
|
|
827
|
+
const result = {
|
|
828
|
+
ok: true,
|
|
829
|
+
previewId,
|
|
830
|
+
jobId: job.id,
|
|
831
|
+
gistName: config.gistName,
|
|
832
|
+
gistId,
|
|
833
|
+
gistUrl: remote.htmlUrl ?? gistUrl(gistId),
|
|
834
|
+
...previewData,
|
|
835
|
+
remote: {
|
|
836
|
+
...previewData.remote,
|
|
837
|
+
version: remote.version,
|
|
838
|
+
exists: remote.exists,
|
|
839
|
+
htmlUrl: remote.htmlUrl,
|
|
840
|
+
},
|
|
841
|
+
local: {
|
|
842
|
+
...previewData.local,
|
|
843
|
+
profileBundles: local.profileBundles,
|
|
844
|
+
portableBundles: local.portableBundles,
|
|
845
|
+
...(local.dshVersion === undefined ? {} : { dshVersion: local.dshVersion }),
|
|
846
|
+
},
|
|
847
|
+
expiresAt: new Date(previewCache.expiresAt).toISOString(),
|
|
848
|
+
}
|
|
849
|
+
jobs.finish(job.id, { result: { actionable: previewData.counts.actionable } })
|
|
850
|
+
return result
|
|
851
|
+
} catch (error) {
|
|
852
|
+
const info = errorInfo(error)
|
|
853
|
+
jobLog(job, 'error', `preview failed: ${info.message}`)
|
|
854
|
+
jobs.finish(job.id, { error: info.message })
|
|
855
|
+
throw error
|
|
856
|
+
}
|
|
857
|
+
})
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Ask each package source what its newest release actually is.
|
|
862
|
+
*
|
|
863
|
+
* The Gist manifest only knows what some host last pushed, so it can lag the
|
|
864
|
+
* registry — `dshmarket@1.70.2` in the Gist while npm already serves 1.73.3.
|
|
865
|
+
* This look-up answers "what is the real latest", separately from "what does
|
|
866
|
+
* the manifest say", so the page can offer updating past the manifest.
|
|
867
|
+
*/
|
|
868
|
+
async function checkLatest({ signal } = {}) {
|
|
869
|
+
return runExclusive('check', () => runLatestCheck({ signal }))
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* The body of a latest-version check, without taking the operation lock.
|
|
874
|
+
*
|
|
875
|
+
* `applyLatest` needs a fresh check while it already holds the lock, so the
|
|
876
|
+
* two cannot both call `runExclusive` — that would reject its own operation.
|
|
877
|
+
*
|
|
878
|
+
* `only` narrows the check to the named bundles. Updating a selection must not
|
|
879
|
+
* re-check the whole profile: the user picked a few rows, and the log and the
|
|
880
|
+
* progress bar are expected to describe exactly those rows.
|
|
881
|
+
*/
|
|
882
|
+
async function runLatestCheck({ signal, only = null, job = null } = {}) {
|
|
883
|
+
const wanted = Array.isArray(only) ? new Set(only) : null
|
|
884
|
+
// A scoped check reports into the caller's job so the page shows one log for
|
|
885
|
+
// one action; a full check owns a job of its own.
|
|
886
|
+
const ownJob = job === null
|
|
887
|
+
const checkJob = ownJob ? jobs.start('check') : job
|
|
888
|
+
try {
|
|
889
|
+
const { bundles } = await collectLocal()
|
|
890
|
+
const credential = await resolveCredential()
|
|
891
|
+
const checker = createVersionChecker({
|
|
892
|
+
fetchImpl,
|
|
893
|
+
token: credential.token,
|
|
894
|
+
timeoutMs: Math.max(config.requestTimeoutMs, 15_000),
|
|
895
|
+
// Surface the tag fallback: it means the answer is a tagged commit, not a
|
|
896
|
+
// published release, and the user should know which one they will install.
|
|
897
|
+
onReleaseFallback: (repo, info) => {
|
|
898
|
+
jobLog(checkJob, 'warn', `${repo.owner}/${repo.repo}: no stable release (${info.reason}); falling back to tags`)
|
|
899
|
+
},
|
|
900
|
+
})
|
|
901
|
+
const candidates = []
|
|
902
|
+
for (const bundle of bundles) {
|
|
903
|
+
if (bundle === null || typeof bundle !== 'object' || typeof bundle.name !== 'string') continue
|
|
904
|
+
if (bundle.installed !== true && bundle.availability !== 'profile') continue
|
|
905
|
+
if (wanted !== null && !wanted.has(bundle.name)) continue
|
|
906
|
+
const spec = pickInstallSpec(bundle)
|
|
907
|
+
if (spec === '') continue
|
|
908
|
+
candidates.push({
|
|
909
|
+
name: bundle.name,
|
|
910
|
+
spec,
|
|
911
|
+
version: typeof bundle.version === 'string' ? bundle.version : '',
|
|
912
|
+
enabled: bundle.enabled !== false,
|
|
913
|
+
removable: bundle.removable !== false,
|
|
914
|
+
})
|
|
915
|
+
}
|
|
916
|
+
jobs.progress(checkJob.id, 0, candidates.length)
|
|
917
|
+
jobLog(
|
|
918
|
+
checkJob,
|
|
919
|
+
'info',
|
|
920
|
+
wanted === null
|
|
921
|
+
? `checking the newest release of ${candidates.length} bundle(s)`
|
|
922
|
+
: `checking ${candidates.length} selected bundle(s)`,
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
// Log each result as it arrives so the page shows live progress instead of
|
|
926
|
+
// one burst once every lookup has finished.
|
|
927
|
+
const results = []
|
|
928
|
+
let done = 0
|
|
929
|
+
for (const candidate of candidates) {
|
|
930
|
+
if (signal?.aborted === true) break
|
|
931
|
+
const [found] = await checker.checkAll([candidate], { upstream: signal })
|
|
932
|
+
done += 1
|
|
933
|
+
jobs.progress(checkJob.id, done, candidates.length)
|
|
934
|
+
if (found === undefined) continue
|
|
935
|
+
results.push(found)
|
|
936
|
+
const current = candidate.version
|
|
937
|
+
if (found.outdated === true) {
|
|
938
|
+
jobLog(checkJob, 'info', `${candidate.name}: ${current} -> ${found.latest}`)
|
|
939
|
+
} else if (found.error !== undefined) {
|
|
940
|
+
jobLog(checkJob, 'warn', `${candidate.name}: could not check (${found.error})`)
|
|
941
|
+
} else {
|
|
942
|
+
jobLog(checkJob, 'info', `${candidate.name}: ${current} is current`)
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const byName = new Map(candidates.map((item) => [item.name, item]))
|
|
947
|
+
const entries = results.map((result) => {
|
|
948
|
+
const local = byName.get(result.name)
|
|
949
|
+
return {
|
|
950
|
+
id: result.name,
|
|
951
|
+
name: result.name,
|
|
952
|
+
kind: 'latest-check',
|
|
953
|
+
local: {
|
|
954
|
+
version: local?.version ?? '',
|
|
955
|
+
spec: local?.spec ?? '',
|
|
956
|
+
enabled: local?.enabled !== false,
|
|
957
|
+
removable: local?.removable !== false,
|
|
958
|
+
},
|
|
959
|
+
source: result.kind,
|
|
960
|
+
// Which source actually won the comparison: npm, a release, or a tag.
|
|
961
|
+
resolvedFrom: result.source ?? result.kind,
|
|
962
|
+
latest: result.latest,
|
|
963
|
+
outdated: result.outdated === true,
|
|
964
|
+
...(result.channels === undefined ? {} : { channels: result.channels }),
|
|
965
|
+
...(result.repository === undefined ? {} : { repository: result.repository }),
|
|
966
|
+
...(result.publishedAt === undefined ? {} : { publishedAt: result.publishedAt }),
|
|
967
|
+
...(result.npmLatest === undefined ? {} : { npmLatest: result.npmLatest }),
|
|
968
|
+
...(result.githubLatest === undefined ? {} : { githubLatest: result.githubLatest }),
|
|
969
|
+
...(result.latestTag === undefined ? {} : { latestTag: result.latestTag }),
|
|
970
|
+
...(result.error === undefined ? {} : { error: result.error }),
|
|
971
|
+
...(result.distTags === undefined ? {} : { distTags: result.distTags }),
|
|
972
|
+
}
|
|
973
|
+
})
|
|
974
|
+
const outdated = entries.filter((entry) => entry.outdated === true)
|
|
975
|
+
const unsupported = entries.filter((entry) => entry.error === 'unsupported')
|
|
976
|
+
for (const entry of outdated) entry.target = buildLatestSpec(entry)
|
|
977
|
+
jobLog(
|
|
978
|
+
checkJob,
|
|
979
|
+
'info',
|
|
980
|
+
`checked: ${outdated.length} outdated, ${entries.length - outdated.length - unsupported.length} current`
|
|
981
|
+
+ `${unsupported.length === 0 ? '' : `, ${unsupported.length} not registry-backed`}`,
|
|
982
|
+
)
|
|
983
|
+
const result = {
|
|
984
|
+
ok: true,
|
|
985
|
+
jobId: checkJob.id,
|
|
986
|
+
generatedAt: now().toISOString(),
|
|
987
|
+
entries,
|
|
988
|
+
counts: {
|
|
989
|
+
entries: entries.length,
|
|
990
|
+
outdated: outdated.length,
|
|
991
|
+
current: entries.length - outdated.length - unsupported.length,
|
|
992
|
+
unsupported: unsupported.length,
|
|
993
|
+
failed: entries.filter((entry) => entry.error !== undefined && entry.error !== 'unsupported').length,
|
|
994
|
+
},
|
|
995
|
+
}
|
|
996
|
+
// A scoped check (an update) must not replace the profile-wide result the
|
|
997
|
+
// page restores after a remount.
|
|
998
|
+
if (wanted === null) latestCache = { at: Date.now(), result }
|
|
999
|
+
if (ownJob) jobs.finish(checkJob.id, { result: { outdated: outdated.length } })
|
|
1000
|
+
return result
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
const info = errorInfo(error)
|
|
1003
|
+
jobLog(checkJob, 'error', `check failed: ${info.message}`)
|
|
1004
|
+
if (ownJob) jobs.finish(checkJob.id, { error: info.message })
|
|
1005
|
+
throw error
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Turn a checked entry into an install spec, optionally for one named channel.
|
|
1011
|
+
*
|
|
1012
|
+
* `channel` lets the user override the automatic pick: `npm` installs the
|
|
1013
|
+
* registry version, `github` installs the repository release. Without it the
|
|
1014
|
+
* entry's own resolution (`source`) decides.
|
|
1015
|
+
*/
|
|
1016
|
+
/**
|
|
1017
|
+
* Turn a checked entry into an install spec, optionally for one named channel.
|
|
1018
|
+
*
|
|
1019
|
+
* `channel` lets the user override the automatic pick: `npm` installs the
|
|
1020
|
+
* registry version, `github` installs the repository release. Without it the
|
|
1021
|
+
* entry's own resolution (`source`) decides.
|
|
1022
|
+
*
|
|
1023
|
+
* Each channel uses its *own* version. Falling back to `entry.latest` for the
|
|
1024
|
+
* other channel would ask npm for a version only GitHub published (or the
|
|
1025
|
+
* reverse), which pnpm rejects.
|
|
1026
|
+
*/
|
|
1027
|
+
function buildLatestSpec(entry, channel = null) {
|
|
1028
|
+
const target = channel ?? (entry.resolvedFrom === 'tag' || entry.resolvedFrom === 'release' ? 'github' : entry.source)
|
|
1029
|
+
const npm = registryNameOf(entry.local.spec)
|
|
1030
|
+
const git = gitHubTargetOf(entry.local.spec)
|
|
1031
|
+
|
|
1032
|
+
if (target === 'npm') {
|
|
1033
|
+
// Prefer the npm channel's own version; `entry.channels.npm === null` means
|
|
1034
|
+
// the registry has nothing, and asking for GitHub's number would fail with
|
|
1035
|
+
// "no matching version". Without a `channels` object at all (older result),
|
|
1036
|
+
// the single known version is the best available answer.
|
|
1037
|
+
const version = String(entry.channels?.npm ?? entry.npmLatest ?? entry.latest ?? '').trim()
|
|
1038
|
+
if (npm === null || version === '' || entry.channels?.npm === null) return ''
|
|
1039
|
+
return `${npm.name}@${version}`
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const version = String(entry.channels?.github ?? entry.githubLatest ?? entry.latest ?? '').trim()
|
|
1043
|
+
if (version === '' || entry.channels?.github === null) return ''
|
|
1044
|
+
const tag = entry.latestTag !== undefined && /^v/i.test(entry.latestTag) ? entry.latestTag : `v${version}`
|
|
1045
|
+
if (git !== null) return `github:${git.owner}/${git.repo}#${tag}`
|
|
1046
|
+
// An npm-spec bundle can still be installed from its repository.
|
|
1047
|
+
if (npm !== null) {
|
|
1048
|
+
const repo = entry.repository ?? null
|
|
1049
|
+
return repo === null ? '' : `github:${repo.owner}/${repo.repo}#${tag}`
|
|
1050
|
+
}
|
|
1051
|
+
return ''
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
async function apply(previewId, selections = {}, { signal } = {}) {
|
|
1055
|
+
return runExclusive('apply', async () => {
|
|
1056
|
+
if (previewCache === null || previewCache.id !== previewId) {
|
|
1057
|
+
throw new Error('The preview expired or no longer belongs to this Host. Pull the manifest again.')
|
|
1058
|
+
}
|
|
1059
|
+
if (Date.now() > previewCache.expiresAt) {
|
|
1060
|
+
previewCache = null
|
|
1061
|
+
throw new Error('The preview expired. Pull the manifest again.')
|
|
1062
|
+
}
|
|
1063
|
+
const chosen = selectPreviewEntries(previewCache.preview, selections)
|
|
1064
|
+
const max = config.maxInstallsPerRun
|
|
1065
|
+
const allowed = chosen.slice(0, max)
|
|
1066
|
+
const skipped = chosen.slice(max).map(({ entry }) => ({
|
|
1067
|
+
name: entry.name,
|
|
1068
|
+
reason: 'max-installs-per-run',
|
|
1069
|
+
}))
|
|
1070
|
+
const job = jobs.start('apply', { previewId, total: allowed.length })
|
|
1071
|
+
jobs.progress(job.id, 0, allowed.length)
|
|
1072
|
+
jobLog(job, 'info', `apply started with ${allowed.length} selected change(s)`)
|
|
1073
|
+
|
|
1074
|
+
const applied = []
|
|
1075
|
+
const failed = []
|
|
1076
|
+
const removed = []
|
|
1077
|
+
const fresh = await pluginManager.listBundles()
|
|
1078
|
+
const installed = new Map()
|
|
1079
|
+
for (const bundle of fresh) {
|
|
1080
|
+
if (bundle !== null && typeof bundle === 'object' && typeof bundle.name === 'string') {
|
|
1081
|
+
installed.set(bundle.name, bundle)
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
let done = 0
|
|
1086
|
+
for (const { entry, choice } of allowed) {
|
|
1087
|
+
done += 1
|
|
1088
|
+
jobs.progress(job.id, done, allowed.length)
|
|
1089
|
+
if (signal?.aborted) {
|
|
1090
|
+
jobs.finish(job.id, { error: 'operation was aborted' })
|
|
1091
|
+
throw new Error('operation was aborted')
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
if (choice === 'remove') {
|
|
1095
|
+
const current = installed.get(entry.name)
|
|
1096
|
+
if (current === undefined) {
|
|
1097
|
+
skipped.push({ name: entry.name, reason: 'not-installed' })
|
|
1098
|
+
jobLog(job, 'info', `${entry.name}: already absent, skipped`)
|
|
1099
|
+
continue
|
|
1100
|
+
}
|
|
1101
|
+
if (SELF_NAMES.has(entry.name)) {
|
|
1102
|
+
failed.push({
|
|
1103
|
+
name: entry.name,
|
|
1104
|
+
error: 'This plugin cannot remove itself while it is running. Remove it from the Plugins page instead.',
|
|
1105
|
+
code: 'self-removal',
|
|
1106
|
+
})
|
|
1107
|
+
jobLog(job, 'error', `${entry.name}: refusing to remove the running plugin itself`)
|
|
1108
|
+
continue
|
|
1109
|
+
}
|
|
1110
|
+
if (current.removable === false) {
|
|
1111
|
+
failed.push({ name: entry.name, error: 'This bundle cannot be removed.', code: 'not-removable' })
|
|
1112
|
+
jobLog(job, 'warn', `${entry.name}: bundle is not removable`)
|
|
1113
|
+
continue
|
|
1114
|
+
}
|
|
1115
|
+
jobLog(job, 'info', `${entry.name}: removing local bundle`)
|
|
1116
|
+
const summary = await safeRemove(job, entry.name)
|
|
1117
|
+
if (summary.status === 'failed') {
|
|
1118
|
+
failed.push({ name: entry.name, error: summary.error ?? 'remove failed', ...(summary.code ? { code: summary.code } : {}) })
|
|
1119
|
+
} else {
|
|
1120
|
+
removed.push({ ...summary, kind: 'remove' })
|
|
1121
|
+
installed.delete(entry.name)
|
|
1122
|
+
jobLog(job, 'info', `${entry.name}: removed`)
|
|
1123
|
+
}
|
|
1124
|
+
continue
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
if (entry.kind === 'current' || entry.kind === 'local-only' || entry.kind === 'remote-disabled') continue
|
|
1128
|
+
const current = installed.get(entry.name)
|
|
1129
|
+
if (entry.kind === 'install' && current !== undefined && (current.installed === true || current.availability === 'profile')) {
|
|
1130
|
+
skipped.push({ name: entry.name, reason: 'already-installed' })
|
|
1131
|
+
jobLog(job, 'info', `${entry.name}: already installed, skipped`)
|
|
1132
|
+
continue
|
|
1133
|
+
}
|
|
1134
|
+
if (entry.kind === 'enable') {
|
|
1135
|
+
if (current?.enabled === true) {
|
|
1136
|
+
skipped.push({ name: entry.name, reason: 'already-enabled' })
|
|
1137
|
+
jobLog(job, 'info', `${entry.name}: already enabled, skipped`)
|
|
1138
|
+
continue
|
|
1139
|
+
}
|
|
1140
|
+
jobLog(job, 'info', `${entry.name}: enabling bundle`)
|
|
1141
|
+
try {
|
|
1142
|
+
const change = await pluginManager.setBundleEnabled(entry.name, true)
|
|
1143
|
+
const summary = summarizeInstallChange(entry.name, entry.remote?.spec ?? '', change)
|
|
1144
|
+
if (summary.status === 'failed') {
|
|
1145
|
+
failed.push({ name: entry.name, error: summary.error ?? 'could not enable bundle', ...(summary.code ? { code: summary.code } : {}) })
|
|
1146
|
+
jobLog(job, 'error', `${entry.name}: enable failed — ${summary.error ?? 'unknown error'}`)
|
|
1147
|
+
} else {
|
|
1148
|
+
applied.push({ ...summary, kind: 'enable' })
|
|
1149
|
+
jobLog(job, 'info', `${entry.name}: enabled`)
|
|
1150
|
+
}
|
|
1151
|
+
} catch (error) {
|
|
1152
|
+
const info = errorInfo(error)
|
|
1153
|
+
failed.push({ name: entry.name, ...info })
|
|
1154
|
+
jobLog(job, 'error', `${entry.name}: ${info.message}`)
|
|
1155
|
+
}
|
|
1156
|
+
continue
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const spec = entry.remote?.spec
|
|
1160
|
+
if (typeof spec !== 'string' || spec === '') {
|
|
1161
|
+
skipped.push({ name: entry.name, reason: 'no-install-spec' })
|
|
1162
|
+
jobLog(job, 'warn', `${entry.name}: no install spec, skipped`)
|
|
1163
|
+
continue
|
|
1164
|
+
}
|
|
1165
|
+
if (SELF_NAMES.has(entry.name)) {
|
|
1166
|
+
failed.push({
|
|
1167
|
+
name: entry.name,
|
|
1168
|
+
spec,
|
|
1169
|
+
error: 'This plugin cannot replace itself while it is running. Update it from the Plugins page instead.',
|
|
1170
|
+
code: 'self-update',
|
|
1171
|
+
})
|
|
1172
|
+
jobLog(job, 'warn', `${entry.name}: skipping self-update; update it from the Plugins page`)
|
|
1173
|
+
continue
|
|
1174
|
+
}
|
|
1175
|
+
jobLog(job, 'info', `${entry.name}: installing ${spec}`)
|
|
1176
|
+
const summary = await installOrUpgrade({
|
|
1177
|
+
name: entry.name,
|
|
1178
|
+
spec,
|
|
1179
|
+
previous: current ?? entry.local ?? null,
|
|
1180
|
+
job,
|
|
1181
|
+
})
|
|
1182
|
+
if (summary.status === 'failed') {
|
|
1183
|
+
failed.push({
|
|
1184
|
+
name: entry.name,
|
|
1185
|
+
spec,
|
|
1186
|
+
error: summary.error ?? 'install failed',
|
|
1187
|
+
...(summary.code ? { code: summary.code } : {}),
|
|
1188
|
+
...(summary.rollback ? { rollback: summary.rollback } : {}),
|
|
1189
|
+
...(summary.reselected === true ? { reselected: true } : {}),
|
|
1190
|
+
...(summary.manual === undefined ? {} : { hint: summary.hint, manual: summary.manual }),
|
|
1191
|
+
})
|
|
1192
|
+
} else {
|
|
1193
|
+
applied.push({ ...summary, kind: entry.kind })
|
|
1194
|
+
installed.set(entry.name, {
|
|
1195
|
+
name: entry.name,
|
|
1196
|
+
installed: true,
|
|
1197
|
+
availability: 'profile',
|
|
1198
|
+
enabled: true,
|
|
1199
|
+
})
|
|
1200
|
+
jobLog(
|
|
1201
|
+
job,
|
|
1202
|
+
'info',
|
|
1203
|
+
`${entry.name}: ${summary.rollback === 'restored' ? 'kept' : summary.upgraded === true ? 'upgraded' : summary.status === 'unchanged' ? 'already current' : 'installed'}`
|
|
1204
|
+
+ `${summary.restartRequired === true ? ' (restart required)' : ''}`,
|
|
1205
|
+
)
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
previewCache = null
|
|
1210
|
+
const at = now().toISOString()
|
|
1211
|
+
const state = await getState()
|
|
1212
|
+
await persist({
|
|
1213
|
+
lastApplyAt: at,
|
|
1214
|
+
lastResults: [...applied, ...removed, ...failed].slice(0, 50),
|
|
1215
|
+
history: [
|
|
1216
|
+
{
|
|
1217
|
+
at,
|
|
1218
|
+
action: 'apply',
|
|
1219
|
+
ok: failed.length === 0,
|
|
1220
|
+
applied: applied.length,
|
|
1221
|
+
removed: removed.length,
|
|
1222
|
+
failed: failed.length,
|
|
1223
|
+
skipped: skipped.length,
|
|
1224
|
+
},
|
|
1225
|
+
...(Array.isArray(state.history) ? state.history : []),
|
|
1226
|
+
].slice(0, MAX_HISTORY),
|
|
1227
|
+
})
|
|
1228
|
+
const result = {
|
|
1229
|
+
ok: failed.length === 0,
|
|
1230
|
+
previewId,
|
|
1231
|
+
jobId: job.id,
|
|
1232
|
+
applied,
|
|
1233
|
+
failed,
|
|
1234
|
+
removed,
|
|
1235
|
+
skipped,
|
|
1236
|
+
totalSelected: chosen.length,
|
|
1237
|
+
totalApplied: applied.length,
|
|
1238
|
+
totalRemoved: removed.length,
|
|
1239
|
+
}
|
|
1240
|
+
jobLog(
|
|
1241
|
+
job,
|
|
1242
|
+
failed.length === 0 ? 'info' : 'warn',
|
|
1243
|
+
`apply finished: ${applied.length} installed/enabled, ${removed.length} removed, ${failed.length} failed, ${skipped.length} skipped`,
|
|
1244
|
+
)
|
|
1245
|
+
jobs.finish(job.id, {
|
|
1246
|
+
result: {
|
|
1247
|
+
applied: applied.length,
|
|
1248
|
+
removed: removed.length,
|
|
1249
|
+
failed: failed.length,
|
|
1250
|
+
skipped: skipped.length,
|
|
1251
|
+
},
|
|
1252
|
+
})
|
|
1253
|
+
return result
|
|
1254
|
+
})
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* Install the newest release of the selected bundles, ignoring what the Gist says.
|
|
1259
|
+
*
|
|
1260
|
+
* The manifest can lag the registry, so "update to latest" derives the target
|
|
1261
|
+
* spec from the live lookup (`target`) rather than from `state.remote`.
|
|
1262
|
+
*
|
|
1263
|
+
* The check is scoped to `names`: reusing a profile-wide result here would
|
|
1264
|
+
* re-verify every installed bundle and make the progress bar and log describe
|
|
1265
|
+
* rows the user never selected.
|
|
1266
|
+
*/
|
|
1267
|
+
async function applyLatest(names, { signal, channel = null, channels = {} } = {}) {
|
|
1268
|
+
return runExclusive('apply-latest', async () => {
|
|
1269
|
+
const selected = Array.isArray(names) ? names.filter((name) => typeof name === 'string' && name !== '') : []
|
|
1270
|
+
if (selected.length === 0) throw new Error('Select at least one bundle to update.')
|
|
1271
|
+
// A per-bundle channel choice: `channels['pkg'] = 'npm' | 'github'`.
|
|
1272
|
+
const channelFor = (name) => {
|
|
1273
|
+
const raw = channels?.[name] ?? channel
|
|
1274
|
+
return raw === 'npm' || raw === 'github' ? raw : null
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// One job for the whole action: the check writes its lines into it, so the
|
|
1278
|
+
// page shows a single log whose counts are the selected bundles.
|
|
1279
|
+
const job = jobs.start('apply-latest', { total: selected.length })
|
|
1280
|
+
// Reuse the result the user just looked at. Re-checking here made an update
|
|
1281
|
+
// look like "check again, change nothing": the registry was queried a second
|
|
1282
|
+
// time and the row's outcome was recomputed instead of acted on. Only ask
|
|
1283
|
+
// for entries the cache does not already cover.
|
|
1284
|
+
const cached = latestCache === null ? null : latestCache.result
|
|
1285
|
+
const covered = new Set((cached?.entries ?? []).map((entry) => entry.name))
|
|
1286
|
+
const missing = selected.filter((name) => !covered.has(name))
|
|
1287
|
+
const checked = missing.length === 0 && cached !== null
|
|
1288
|
+
? cached
|
|
1289
|
+
: await runLatestCheck({ signal, only: missing.length === 0 ? selected : missing, job })
|
|
1290
|
+
const byName = new Map([...(cached?.entries ?? []), ...checked.entries].map((entry) => [entry.name, entry]))
|
|
1291
|
+
const targeted = []
|
|
1292
|
+
const skipped = []
|
|
1293
|
+
for (const name of selected) {
|
|
1294
|
+
const entry = byName.get(name)
|
|
1295
|
+
if (entry === undefined) {
|
|
1296
|
+
skipped.push({ name, reason: 'not-installed' })
|
|
1297
|
+
continue
|
|
1298
|
+
}
|
|
1299
|
+
if (entry.outdated !== true) {
|
|
1300
|
+
skipped.push({ name, reason: entry.error ?? 'already-current' })
|
|
1301
|
+
continue
|
|
1302
|
+
}
|
|
1303
|
+
const wanted = channelFor(name)
|
|
1304
|
+
// Rebuild the spec when a channel was named, so the choice overrides the
|
|
1305
|
+
// automatic pick instead of being ignored.
|
|
1306
|
+
const target = wanted === null
|
|
1307
|
+
? (typeof entry.target === 'string' && entry.target !== '' ? entry.target : buildLatestSpec(entry))
|
|
1308
|
+
: buildLatestSpec(entry, wanted)
|
|
1309
|
+
if (target === '') {
|
|
1310
|
+
skipped.push({ name, reason: wanted === null ? 'no-latest-spec' : `no-${wanted}-spec` })
|
|
1311
|
+
continue
|
|
1312
|
+
}
|
|
1313
|
+
targeted.push({ entry, target, channel: wanted })
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
const max = config.maxInstallsPerRun
|
|
1317
|
+
const allowed = targeted.slice(0, max)
|
|
1318
|
+
for (const item of targeted.slice(max)) skipped.push({ name: item.entry.name, reason: 'max-installs-per-run' })
|
|
1319
|
+
|
|
1320
|
+
// Progress restarts at zero for the install phase; the check already
|
|
1321
|
+
// advanced it once, so its counts stay visible in the log above.
|
|
1322
|
+
jobs.progress(job.id, 0, allowed.length)
|
|
1323
|
+
jobLog(job, 'info', `installing ${allowed.length} bundle(s)`)
|
|
1324
|
+
|
|
1325
|
+
const applied = []
|
|
1326
|
+
const failed = []
|
|
1327
|
+
const fresh = await pluginManager.listBundles()
|
|
1328
|
+
const installed = new Map()
|
|
1329
|
+
for (const bundle of fresh) {
|
|
1330
|
+
if (bundle !== null && typeof bundle === 'object' && typeof bundle.name === 'string') {
|
|
1331
|
+
installed.set(bundle.name, bundle)
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
let done = 0
|
|
1336
|
+
for (const { entry, target, channel: chosenChannel } of allowed) {
|
|
1337
|
+
done += 1
|
|
1338
|
+
jobs.progress(job.id, done, allowed.length)
|
|
1339
|
+
if (signal?.aborted) {
|
|
1340
|
+
jobs.finish(job.id, { error: 'operation was aborted' })
|
|
1341
|
+
throw new Error('operation was aborted')
|
|
1342
|
+
}
|
|
1343
|
+
const current = installed.get(entry.name) ?? null
|
|
1344
|
+
if (SELF_NAMES.has(entry.name)) {
|
|
1345
|
+
failed.push({
|
|
1346
|
+
name: entry.name,
|
|
1347
|
+
spec: target,
|
|
1348
|
+
error: 'This plugin cannot replace itself while it is running. Update it from the Plugins page instead.',
|
|
1349
|
+
code: 'self-update',
|
|
1350
|
+
})
|
|
1351
|
+
jobLog(job, 'warn', `${entry.name}: skipping self-update; update it from the Plugins page`)
|
|
1352
|
+
continue
|
|
1353
|
+
}
|
|
1354
|
+
jobLog(job, 'info', `${entry.name}: installing ${target} (${chosenChannel ?? 'auto'})`)
|
|
1355
|
+
// Prefer the manager's own update path: it removes, installs, and restores
|
|
1356
|
+
// on failure inside one call, which is exactly what the manual dance below
|
|
1357
|
+
// approximates. It only applies to a registry package updated to a strictly
|
|
1358
|
+
// newer version, so anything else falls through.
|
|
1359
|
+
const nativeUpdate = chosenChannel === null || chosenChannel === 'npm'
|
|
1360
|
+
? registryUpdateFor(entry.name, entry.channels?.npm ?? entry.npmLatest ?? null)
|
|
1361
|
+
: null
|
|
1362
|
+
const summary = nativeUpdate !== null
|
|
1363
|
+
? await updateViaManager({ ...nativeUpdate, job })
|
|
1364
|
+
: await installOrUpgrade({ name: entry.name, spec: target, previous: current, job })
|
|
1365
|
+
if (summary.status === 'failed') {
|
|
1366
|
+
failed.push({
|
|
1367
|
+
name: entry.name,
|
|
1368
|
+
spec: target,
|
|
1369
|
+
channel: chosenChannel ?? entry.resolvedFrom ?? 'auto',
|
|
1370
|
+
error: summary.error ?? 'install failed',
|
|
1371
|
+
...(summary.code ? { code: summary.code } : {}),
|
|
1372
|
+
...(summary.rollback ? { rollback: summary.rollback } : {}),
|
|
1373
|
+
...(summary.reselected === true ? { reselected: true } : {}),
|
|
1374
|
+
...(summary.manual === undefined ? {} : { hint: summary.hint, manual: summary.manual }),
|
|
1375
|
+
})
|
|
1376
|
+
} else {
|
|
1377
|
+
applied.push({
|
|
1378
|
+
...summary,
|
|
1379
|
+
kind: 'latest',
|
|
1380
|
+
latest: entry.latest,
|
|
1381
|
+
channel: chosenChannel ?? entry.resolvedFrom ?? 'auto',
|
|
1382
|
+
})
|
|
1383
|
+
jobLog(
|
|
1384
|
+
job,
|
|
1385
|
+
'info',
|
|
1386
|
+
`${entry.name}: ${summary.rollback === 'restored' ? 'kept' : summary.upgraded === true ? 'upgraded' : 'installed'} to ${entry.latest}`,
|
|
1387
|
+
)
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Keep the profile-wide cache: the scoped check above deliberately did not
|
|
1392
|
+
// touch it, and the page restores it after a remount.
|
|
1393
|
+
const at = now().toISOString()
|
|
1394
|
+
const state = await getState()
|
|
1395
|
+
await persist({
|
|
1396
|
+
lastApplyAt: at,
|
|
1397
|
+
lastResults: [...applied, ...failed].slice(0, 50),
|
|
1398
|
+
history: [
|
|
1399
|
+
{
|
|
1400
|
+
at,
|
|
1401
|
+
action: 'apply-latest',
|
|
1402
|
+
ok: failed.length === 0,
|
|
1403
|
+
applied: applied.length,
|
|
1404
|
+
failed: failed.length,
|
|
1405
|
+
skipped: skipped.length,
|
|
1406
|
+
},
|
|
1407
|
+
...(Array.isArray(state.history) ? state.history : []),
|
|
1408
|
+
].slice(0, MAX_HISTORY),
|
|
1409
|
+
})
|
|
1410
|
+
jobLog(
|
|
1411
|
+
job,
|
|
1412
|
+
failed.length === 0 ? 'info' : 'warn',
|
|
1413
|
+
`update-to-latest finished: ${applied.length} updated, ${failed.length} failed, ${skipped.length} skipped`,
|
|
1414
|
+
)
|
|
1415
|
+
jobs.finish(job.id, { result: { applied: applied.length, failed: failed.length } })
|
|
1416
|
+
return {
|
|
1417
|
+
ok: failed.length === 0,
|
|
1418
|
+
jobId: job.id,
|
|
1419
|
+
applied,
|
|
1420
|
+
failed,
|
|
1421
|
+
skipped,
|
|
1422
|
+
updatedList: applied.map((item) => ({
|
|
1423
|
+
name: item.name,
|
|
1424
|
+
version: item.version,
|
|
1425
|
+
replaced: item.replaced,
|
|
1426
|
+
latest: item.latest,
|
|
1427
|
+
})),
|
|
1428
|
+
totalSelected: selected.length,
|
|
1429
|
+
totalApplied: applied.length,
|
|
1430
|
+
}
|
|
1431
|
+
})
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Work out what a push would change, without writing anything.
|
|
1436
|
+
*
|
|
1437
|
+
* The Gist may already describe bundles this host does not have, and the same
|
|
1438
|
+
* bundle may exist on both sides at different versions. The page needs that
|
|
1439
|
+
* comparison before it can ask the user anything.
|
|
1440
|
+
*/
|
|
1441
|
+
async function planPush({ signal } = {}) {
|
|
1442
|
+
return runExclusive('plan-push', async () => {
|
|
1443
|
+
const job = jobs.start('plan-push')
|
|
1444
|
+
try {
|
|
1445
|
+
const github = await getClient()
|
|
1446
|
+
const { bundles, entry } = await collectLocal()
|
|
1447
|
+
const local = localSummary(bundles, entry)
|
|
1448
|
+
jobLog(job, 'info', `${local.profileBundles} profile bundle(s), ${local.portableBundles} portable`)
|
|
1449
|
+
const gistId = await resolveGistId(github)
|
|
1450
|
+
let manifest = emptyManifest(now().toISOString())
|
|
1451
|
+
let exists = false
|
|
1452
|
+
let version = null
|
|
1453
|
+
let htmlUrl = null
|
|
1454
|
+
if (gistId !== '') {
|
|
1455
|
+
jobLog(job, 'info', `reading Gist ${gistId}`)
|
|
1456
|
+
const remote = await github.readGist(gistId, config.fileName, { extraSignal: signal })
|
|
1457
|
+
manifest = remote.manifest
|
|
1458
|
+
exists = remote.exists
|
|
1459
|
+
version = remote.version
|
|
1460
|
+
htmlUrl = remote.htmlUrl
|
|
1461
|
+
} else {
|
|
1462
|
+
jobLog(job, 'info', `no Gist yet; a push creates "${config.gistName}"`)
|
|
1463
|
+
}
|
|
1464
|
+
const plan = buildPushPlan({
|
|
1465
|
+
manifest,
|
|
1466
|
+
hostEntry: entry,
|
|
1467
|
+
hostId: config.hostId,
|
|
1468
|
+
now: now().toISOString(),
|
|
1469
|
+
})
|
|
1470
|
+
const planId = randomUUID()
|
|
1471
|
+
planCache = { id: planId, at: Date.now(), gistId, version, plan, hostEntry: entry }
|
|
1472
|
+
jobLog(
|
|
1473
|
+
job,
|
|
1474
|
+
'info',
|
|
1475
|
+
`${plan.counts.conflict} conflict(s), ${plan.counts.remoteOnly} remote-only, `
|
|
1476
|
+
+ `${plan.counts.localOnly} local-only, ${plan.counts.same} unchanged`,
|
|
1477
|
+
)
|
|
1478
|
+
const result = {
|
|
1479
|
+
ok: true,
|
|
1480
|
+
planId,
|
|
1481
|
+
jobId: job.id,
|
|
1482
|
+
gistName: config.gistName,
|
|
1483
|
+
gistId,
|
|
1484
|
+
gistUrl: htmlUrl,
|
|
1485
|
+
remoteExists: exists,
|
|
1486
|
+
remoteVersion: version,
|
|
1487
|
+
...plan,
|
|
1488
|
+
local: {
|
|
1489
|
+
profileBundles: local.profileBundles,
|
|
1490
|
+
portableBundles: local.portableBundles,
|
|
1491
|
+
...(local.dshVersion === undefined ? {} : { dshVersion: local.dshVersion }),
|
|
1492
|
+
},
|
|
1493
|
+
}
|
|
1494
|
+
jobs.finish(job.id, { result: { conflicts: plan.counts.conflict } })
|
|
1495
|
+
return result
|
|
1496
|
+
} catch (error) {
|
|
1497
|
+
const info = errorInfo(error)
|
|
1498
|
+
jobLog(job, 'error', `plan failed: ${info.message}`)
|
|
1499
|
+
jobs.finish(job.id, { error: info.message })
|
|
1500
|
+
throw error
|
|
1501
|
+
}
|
|
1502
|
+
})
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
async function push({ planId = null, selections = {}, signal } = {}) {
|
|
1506
|
+
return runExclusive('push', async () => {
|
|
1507
|
+
const job = jobs.start('push')
|
|
1508
|
+
jobs.progress(job.id, 0, 2)
|
|
1509
|
+
try {
|
|
1510
|
+
const github = await getClient()
|
|
1511
|
+
jobLog(job, 'info', 'collecting local bundle inventory')
|
|
1512
|
+
const { bundles, entry } = await collectLocal()
|
|
1513
|
+
const local = localSummary(bundles, entry)
|
|
1514
|
+
jobLog(job, 'info', `${local.profileBundles} profile bundle(s), ${local.portableBundles} portable`)
|
|
1515
|
+
const gistId = await resolveGistId(github)
|
|
1516
|
+
jobs.progress(job.id, 1, 2)
|
|
1517
|
+
|
|
1518
|
+
if (gistId === '') {
|
|
1519
|
+
jobLog(job, 'info', `creating secret Gist "${config.gistName}"`)
|
|
1520
|
+
const manifest = mergeHost(emptyManifest(now().toISOString()), config.hostId, entry, now().toISOString())
|
|
1521
|
+
const created = await github.createGist({
|
|
1522
|
+
description: config.gistName,
|
|
1523
|
+
fileName: config.fileName,
|
|
1524
|
+
manifest,
|
|
1525
|
+
extraSignal: signal,
|
|
1526
|
+
})
|
|
1527
|
+
const at = now().toISOString()
|
|
1528
|
+
const state = await getState()
|
|
1529
|
+
await persist({
|
|
1530
|
+
gistName: config.gistName,
|
|
1531
|
+
gistId: created.gistId,
|
|
1532
|
+
gistUrl: created.htmlUrl,
|
|
1533
|
+
lastPushAt: at,
|
|
1534
|
+
remote: summarizeManifest(manifest),
|
|
1535
|
+
history: [
|
|
1536
|
+
{ at, action: 'push', ok: true, status: 'created', gistId: created.gistId },
|
|
1537
|
+
...(Array.isArray(state.history) ? state.history : []),
|
|
1538
|
+
].slice(0, MAX_HISTORY),
|
|
1539
|
+
})
|
|
1540
|
+
planCache = null
|
|
1541
|
+
const result = {
|
|
1542
|
+
ok: true,
|
|
1543
|
+
created: true,
|
|
1544
|
+
status: 'created',
|
|
1545
|
+
jobId: job.id,
|
|
1546
|
+
gistName: config.gistName,
|
|
1547
|
+
gistId: created.gistId,
|
|
1548
|
+
gistUrl: created.htmlUrl,
|
|
1549
|
+
version: created.version,
|
|
1550
|
+
remote: summarizeManifest(manifest),
|
|
1551
|
+
local,
|
|
1552
|
+
}
|
|
1553
|
+
jobs.progress(job.id, 2, 2)
|
|
1554
|
+
jobLog(job, 'info', `secret Gist created (${created.gistId})`)
|
|
1555
|
+
jobs.finish(job.id, { result: { status: 'created', gistId: created.gistId } })
|
|
1556
|
+
return result
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
jobLog(job, 'info', `updating Gist ${gistId}`)
|
|
1560
|
+
const pushResult = await pushWithRetry(github, gistId, entry, signal, { planId, selections })
|
|
1561
|
+
const summary = summarizeManifest(pushResult.manifest)
|
|
1562
|
+
const at = now().toISOString()
|
|
1563
|
+
const state = await getState()
|
|
1564
|
+
await persist({
|
|
1565
|
+
gistName: config.gistName,
|
|
1566
|
+
gistId,
|
|
1567
|
+
gistUrl: pushResult.htmlUrl ?? state.gistUrl ?? gistUrl(gistId),
|
|
1568
|
+
lastPushAt: pushResult.status === 'pushed' ? at : state.lastPushAt,
|
|
1569
|
+
remote: summary,
|
|
1570
|
+
history: [
|
|
1571
|
+
{
|
|
1572
|
+
at,
|
|
1573
|
+
action: 'push',
|
|
1574
|
+
ok: true,
|
|
1575
|
+
status: pushResult.status,
|
|
1576
|
+
version: pushResult.version,
|
|
1577
|
+
},
|
|
1578
|
+
...(Array.isArray(state.history) ? state.history : []),
|
|
1579
|
+
].slice(0, MAX_HISTORY),
|
|
1580
|
+
})
|
|
1581
|
+
jobs.progress(job.id, 2, 2)
|
|
1582
|
+
jobLog(
|
|
1583
|
+
job,
|
|
1584
|
+
'info',
|
|
1585
|
+
pushResult.status === 'pushed'
|
|
1586
|
+
? `pushed in ${pushResult.attempts} attempt(s)`
|
|
1587
|
+
: 'manifest already up to date',
|
|
1588
|
+
)
|
|
1589
|
+
jobs.finish(job.id, { result: { status: pushResult.status } })
|
|
1590
|
+
planCache = null
|
|
1591
|
+
return {
|
|
1592
|
+
ok: true,
|
|
1593
|
+
created: false,
|
|
1594
|
+
status: pushResult.status,
|
|
1595
|
+
attempts: pushResult.attempts,
|
|
1596
|
+
jobId: job.id,
|
|
1597
|
+
gistName: config.gistName,
|
|
1598
|
+
gistId,
|
|
1599
|
+
gistUrl: pushResult.htmlUrl ?? gistUrl(gistId),
|
|
1600
|
+
version: pushResult.version,
|
|
1601
|
+
remote: summary,
|
|
1602
|
+
local,
|
|
1603
|
+
...(pushResult.report === null ? {} : { report: pushResult.report }),
|
|
1604
|
+
}
|
|
1605
|
+
} catch (error) {
|
|
1606
|
+
const info = errorInfo(error)
|
|
1607
|
+
jobLog(job, 'error', `push failed: ${info.message}`)
|
|
1608
|
+
jobs.finish(job.id, { error: info.message })
|
|
1609
|
+
throw error
|
|
1610
|
+
}
|
|
1611
|
+
})
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
return {
|
|
1615
|
+
config,
|
|
1616
|
+
status,
|
|
1617
|
+
preview,
|
|
1618
|
+
apply,
|
|
1619
|
+
planPush,
|
|
1620
|
+
checkLatest,
|
|
1621
|
+
applyLatest,
|
|
1622
|
+
push,
|
|
1623
|
+
isBusy: () => operation !== null,
|
|
1624
|
+
hasPreview: () => previewCache !== null,
|
|
1625
|
+
statePath,
|
|
1626
|
+
job: (jobId) => jobs.get(jobId),
|
|
1627
|
+
latestJob: (kind) => jobs.latest(kind),
|
|
1628
|
+
jobs: (kind) => jobs.list(kind),
|
|
1629
|
+
// Lets a remounted page restore the last check instead of showing an empty tab.
|
|
1630
|
+
cachedLatest: () => (latestCache === null ? null : latestCache.result),
|
|
1631
|
+
publicConfig: () => ({
|
|
1632
|
+
gistName: config.gistName,
|
|
1633
|
+
}),
|
|
1634
|
+
log,
|
|
1635
|
+
}
|
|
1636
|
+
}
|