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/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
+ }