dsh-plugin-syncup 4.0.0 → 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.
Files changed (2) hide show
  1. package/lib/sync.js +142 -13
  2. package/package.json +1 -1
package/lib/sync.js CHANGED
@@ -7,8 +7,9 @@ import { emptyManifest, localHostEntry, mergeHost, pickInstallSpec, summarizeMan
7
7
  import { buildPreview, selectPreviewEntries } from './preview.js'
8
8
  import { applyPushPlan, buildPushPlan } from './pushplan.js'
9
9
  import { manualUpdateInstructions, resolveProfileDir } from './manual.js'
10
+ import { parseVersion } from './spec.js'
10
11
  import { readState, resolveStatePath, writeState } from './state.js'
11
- import { createVersionChecker, gitHubTargetOf, registryNameOf } from './versions.js'
12
+ import { createVersionChecker, gitHubTargetOf, isPrereleaseVersion, registryNameOf } from './versions.js'
12
13
 
13
14
  const PREVIEW_TTL_MS = 10 * 60 * 1000
14
15
  const MAX_HISTORY = 20
@@ -228,6 +229,94 @@ export function createSyncRuntime({
228
229
  }
229
230
  }
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
+
231
320
  /**
232
321
  * Install a bundle, replacing an installed copy when the manager refuses it.
233
322
  *
@@ -276,14 +365,36 @@ export function createSyncRuntime({
276
365
 
277
366
  const known = await findManualUpdate(name)
278
367
  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),
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 }
286
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
287
398
  }
288
399
 
289
400
  jobLog(job, 'warn', `${name}: already installed; replacing ${record.fromVersion || record.fromSpec} with ${spec}`)
@@ -1163,11 +1274,20 @@ export function createSyncRuntime({
1163
1274
  return raw === 'npm' || raw === 'github' ? raw : null
1164
1275
  }
1165
1276
 
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.
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.
1168
1279
  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]))
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]))
1171
1291
  const targeted = []
1172
1292
  const skipped = []
1173
1293
  for (const name of selected) {
@@ -1232,7 +1352,16 @@ export function createSyncRuntime({
1232
1352
  continue
1233
1353
  }
1234
1354
  jobLog(job, 'info', `${entry.name}: installing ${target} (${chosenChannel ?? 'auto'})`)
1235
- const summary = await installOrUpgrade({ name: entry.name, spec: target, previous: current, job })
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 })
1236
1365
  if (summary.status === 'failed') {
1237
1366
  failed.push({
1238
1367
  name: entry.name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-syncup",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Sync plugin inventories through a secret GitHub Gist and update every plugin to its newest release, with conflict-aware preview and an install log.",
5
5
  "type": "module",
6
6
  "icon": "icon.svg",