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