dshmarket 1.66.46 → 1.66.47

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/client/client.js CHANGED
@@ -348,6 +348,9 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
348
348
  empty: "没有匹配的插件",
349
349
  clearFilters: "清除筛选",
350
350
  installedEmpty: "尚未安装社区插件,可前往「发现」页浏览",
351
+ marketGoneTitle: "插件市场已停止运行",
352
+ marketGoneBody: "DSH 重新加载插件时把市场一起卸载了。重启 DSH 即可恢复;刚才的操作可能已经完成。",
353
+ marketGoneRecord: "市场已从 DSH 卸载,这次操作没有结果。重启 DSH 后可在「已安装」里确认。",
351
354
  loadFail: "插件目录加载失败,请稍后重试",
352
355
  loadRetry: "重试",
353
356
  installFail: "安装失败",
@@ -1092,6 +1095,9 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1092
1095
  empty: "No plugins match",
1093
1096
  clearFilters: "Clear filters",
1094
1097
  installedEmpty: "No community plugins yet — browse the Discover tab",
1098
+ marketGoneTitle: "The plugin market stopped running",
1099
+ marketGoneBody: "DSH unloaded the market while reloading plugins. Restart DSH to bring it back. The last operation may already have finished.",
1100
+ marketGoneRecord: "The market was unloaded from DSH, so this operation has no result. After restarting, check Installed.",
1095
1101
  loadFail: "The plugin catalog failed to load. Try again later.",
1096
1102
  loadRetry: "Retry",
1097
1103
  installFail: "Install failed",
@@ -8519,6 +8525,16 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
8519
8525
  const themeSnap = (0, react.useSyncExternalStore)(props.themeStore.subscribe, props.themeStore.getSnapshot);
8520
8526
  const [data, setData] = (0, react.useState)(cachedRegistry);
8521
8527
  const [loadError, setLoadError] = (0, react.useState)(null);
8528
+ /**
8529
+ * The host answered 404 on one of the market's own routes (#962).
8530
+ *
8531
+ * That is not a failed request: it means DSH reloaded its plugins and did
8532
+ * not mount the market again, so nothing on this page can reach the host —
8533
+ * not the catalog, not the operation lock, not the restart route. The page
8534
+ * already on screen keeps rendering (it is a client-side app), which is
8535
+ * exactly why it owes the user this sentence instead of a spinner.
8536
+ */
8537
+ const [marketGone, setMarketGone] = (0, react.useState)(false);
8522
8538
  const [installed, setInstalledState] = (0, react.useState)(cachedInstalled ?? {});
8523
8539
  const setInstalled = (0, react.useCallback)((value) => {
8524
8540
  cachedInstalled = value;
@@ -9239,10 +9255,50 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
9239
9255
  window.addEventListener("keydown", onKey, true);
9240
9256
  return () => window.removeEventListener("keydown", onKey, true);
9241
9257
  }, [lightbox, themesFullscreen]);
9258
+ /**
9259
+ * The host is no longer serving the market's routes (#962).
9260
+ *
9261
+ * Only a 404 says this: any other status means the route ran and answered.
9262
+ * When it happens mid-operation, the response that would have settled the
9263
+ * install was lost with the routes, so the spinner has to be released and
9264
+ * the row told what became of it — otherwise it turns until DSH restarts,
9265
+ * with the user left to guess whether the plugin landed.
9266
+ */
9267
+ const noteMarketGone = (0, react.useCallback)(() => {
9268
+ setMarketGone(true);
9269
+ setBusyUrl(null);
9270
+ setUpdatingName(null);
9271
+ setRemovingName(null);
9272
+ setRecords((list) => list.map((record) => record.state === "running" || record.state === "queued" ? {
9273
+ ...record,
9274
+ state: "failed",
9275
+ reason: t("marketGoneRecord")
9276
+ } : record));
9277
+ try {
9278
+ sessionStorage.removeItem("dshm-pending");
9279
+ sessionStorage.removeItem("dshm-updating");
9280
+ } catch {}
9281
+ }, [t]);
9282
+ /**
9283
+ * The status payload, or `null` when the route answered 404 (#962). Every
9284
+ * poll that reads this stops on the null: the market is no longer mounted,
9285
+ * so there is no status to read and nothing left to poll for.
9286
+ */
9287
+ const readMarketStatus = (0, react.useCallback)(async (res) => {
9288
+ if (res.status === 404) {
9289
+ noteMarketGone();
9290
+ return null;
9291
+ }
9292
+ return await res.json();
9293
+ }, [noteMarketGone]);
9242
9294
  const loadCatalog = (0, react.useCallback)(() => {
9243
9295
  setLoadError(null);
9244
9296
  return fetch(api("/dsh-market/registry"), { cache: "no-store" }).then(async (res) => {
9245
9297
  const body = await res.json().catch(() => ({}));
9298
+ if (res.status === 404) {
9299
+ noteMarketGone();
9300
+ throw new Error(t("marketGoneTitle"));
9301
+ }
9246
9302
  if (!res.ok) throw new Error(typeof body.error === "string" ? body.error : `HTTP ${String(res.status)}`);
9247
9303
  return body;
9248
9304
  }).then((body) => {
@@ -9260,7 +9316,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
9260
9316
  }).catch((error) => {
9261
9317
  setLoadError(error instanceof Error ? error.message : String(error));
9262
9318
  });
9263
- }, []);
9319
+ }, [noteMarketGone, t]);
9264
9320
  const loadHostCompatibility = (0, react.useCallback)(async (names) => {
9265
9321
  const unique = [...new Set(names)].filter((name) => {
9266
9322
  if (name === "" || hostCompatibilitySettled(name) || hostCompatibilityInFlight.has(name)) return false;
@@ -9308,7 +9364,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
9308
9364
  }, [setHostVersion]);
9309
9365
  (0, react.useEffect)(() => {
9310
9366
  loadCatalog();
9311
- fetch(api("/dsh-market/status"), { cache: "no-store" }).then((res) => res.json()).then((status) => {
9367
+ fetch(api("/dsh-market/status"), { cache: "no-store" }).then(readMarketStatus).then((status) => {
9368
+ if (status === null) return;
9312
9369
  statusRef.current = {
9313
9370
  busy: status.busy === true,
9314
9371
  runningAgents: Array.isArray(status.runningAgents) ? status.runningAgents.map(String) : []
@@ -9329,7 +9386,11 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
9329
9386
  if (typeof status.version === "string" && status.version !== "") setVersion(status.version);
9330
9387
  }).catch(() => {});
9331
9388
  refreshInstalled();
9332
- }, [refreshInstalled, loadCatalog]);
9389
+ }, [
9390
+ refreshInstalled,
9391
+ loadCatalog,
9392
+ readMarketStatus
9393
+ ]);
9333
9394
  (0, react.useEffect)(() => {
9334
9395
  if (bootId === null) return;
9335
9396
  const saved = readSession("dshm-restart");
@@ -9433,7 +9494,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
9433
9494
  return;
9434
9495
  }
9435
9496
  const timer = setInterval(() => {
9436
- fetch(api("/dsh-market/status"), { cache: "no-store" }).then((res) => res.json()).then((status) => {
9497
+ fetch(api("/dsh-market/status"), { cache: "no-store" }).then(readMarketStatus).then((status) => {
9498
+ if (status === null) return;
9437
9499
  statusRef.current = {
9438
9500
  busy: status.busy === true,
9439
9501
  runningAgents: Array.isArray(status.runningAgents) ? status.runningAgents.map(String) : []
@@ -10887,8 +10949,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
10887
10949
  if (drainingRef.current) return;
10888
10950
  if (operationInFlight()) return;
10889
10951
  if (!recordsRef.current.some((record) => record.state === "queued")) return;
10890
- fetch(api("/dsh-market/status"), { cache: "no-store" }).then((res) => res.json()).then((status) => {
10891
- if (disposed) return;
10952
+ fetch(api("/dsh-market/status"), { cache: "no-store" }).then(readMarketStatus).then((status) => {
10953
+ if (disposed || status === null) return;
10892
10954
  const runningAgents = Array.isArray(status.runningAgents) ? status.runningAgents.map(String) : [];
10893
10955
  const busy = status.busy === true;
10894
10956
  statusRef.current = {
@@ -10916,7 +10978,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
10916
10978
  disposed = true;
10917
10979
  clearInterval(timer);
10918
10980
  };
10919
- }, []);
10981
+ }, [readMarketStatus]);
10920
10982
  /** A queued record's "run now": retry immediately instead of waiting for idle. */
10921
10983
  const runQueuedNow = (0, react.useCallback)((record, confirmAgentsRunning = false) => {
10922
10984
  if (!recordsRef.current.some((item) => item.id === record.id && item.state === "queued")) return;
@@ -12733,7 +12795,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
12733
12795
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconQuestionOutline14, { size: 14 })
12734
12796
  })
12735
12797
  }),
12736
- restartEnabled && restartReachable && debuggerLatch === null && recovery === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
12798
+ restartEnabled && restartReachable && debuggerLatch === null && recovery === null && !marketGone && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
12737
12799
  variant: "primary",
12738
12800
  size: "sm",
12739
12801
  disabled: restarting || hostBusy || busyUrl !== null || updatingName !== null || removingName !== null,
@@ -12937,7 +12999,14 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
12937
12999
  setGroupMenuFor(null);
12938
13000
  setAssignFor(null);
12939
13001
  },
12940
- children: tab === "backup" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
13002
+ children: marketGone ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
13003
+ className: Market_module_css_default.empty,
13004
+ role: "alert",
13005
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", { children: t("marketGoneTitle") }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
13006
+ className: Market_module_css_default.spec,
13007
+ children: t("marketGoneBody")
13008
+ })]
13009
+ }) : tab === "backup" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
12941
13010
  className: Market_module_css_default.backupGrid,
12942
13011
  children: [
12943
13012
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
package/lib/index.js CHANGED
@@ -10,6 +10,16 @@ import { setRequestGateSource, setTrustedHostsSource } from './http.js';
10
10
  import { mountMarketRoutes } from './routes.js';
11
11
  import { installDesktopMarketSettings, installMarketSettings } from './settings.js';
12
12
  export const name = 'dsh-market';
13
+ /**
14
+ * How long after the market mounts an interrupted update is repaired.
15
+ *
16
+ * The market mounts while the composition is still starting its entries, and
17
+ * an install at that moment would reconcile the profile underneath the boot.
18
+ * The desktop host's own replay wait is the same order of magnitude (8 s), so
19
+ * this waits that long once, then fixes what a previous start left behind
20
+ * (#928). Nothing happens when the journal is empty, which is the normal case.
21
+ */
22
+ const PENDING_UPDATE_REPAIR_DELAY_MS = 8_000;
13
23
  /**
14
24
  * Register the market against the host context.
15
25
  * @param ctx - Host context that may acquire webServer and shell services.
@@ -232,7 +242,16 @@ export function apply(ctx, config) {
232
242
  host.effect(() => {
233
243
  const restoreTrustedHosts = useTrustedHosts(ctx);
234
244
  const disposeRoutes = mountMarketRoutes(host, resolved, runtime, agentsLookupOf(ctx));
245
+ const repairTimer = setTimeout(() => {
246
+ void runtime.repairPendingUpdates().then(report => {
247
+ if (report.reinstalled.length === 0 && report.failed.length === 0)
248
+ return;
249
+ console.warn(`dsh-market: ${report.reinstalled.length} interrupted update(s) reinstalled, ${report.failed.length} still pending`);
250
+ });
251
+ }, PENDING_UPDATE_REPAIR_DELAY_MS);
252
+ repairTimer.unref?.();
235
253
  return async () => {
254
+ clearTimeout(repairTimer);
236
255
  disposeRoutes();
237
256
  restoreTrustedHosts();
238
257
  await runtime.dispose();
@@ -1,7 +1,22 @@
1
1
  /** The official Electron profile is owned by DSH's in-process plugin manager. */
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { progress, TARGET_RE } from './dsh-cli.js';
4
+ import { DeadlineExceeded, PendingUpdateJournal, repairInterruptedUpdates, withDeadline, } from './pending-updates.js';
5
+ import { profileDir, readInstalledVersion } from './profile.js';
4
6
  import { activeRegion, DEFAULT_NPM_REGISTRY, routesFor } from './regions.js';
7
+ /**
8
+ * How long the desktop manager gets before the market stops waiting.
9
+ *
10
+ * The host's reconciliation has no deadline of its own; when a plugin's
11
+ * teardown or start never settles, `removeBundle`/`installBundle` never
12
+ * resolve and the operation stays "updating" forever while holding the
13
+ * profile's write lock. These deadlines cannot cancel the host, but they end
14
+ * the market's wait, and the journal (`./pending-updates.ts`) makes the next
15
+ * start finish the job. Removal is the short step (pnpm unlink + unload);
16
+ * installation may legitimately download and build, so it gets the longer one.
17
+ */
18
+ const REMOVE_DEADLINE_MS = 90_000;
19
+ const INSTALL_DEADLINE_MS = 300_000;
5
20
  /**
6
21
  * What a user can actually do when the market cannot perform an operation on
7
22
  * the official Desktop profile: the app's own Plugins page runs the same
@@ -95,6 +110,10 @@ const REFUSAL_COPY = {
95
110
  zh: '这个版本与当前 DSH 不兼容,宿主拒绝安装',
96
111
  en: 'this version is not compatible with the running DSH, so the host refused it',
97
112
  },
113
+ 'update-incomplete': {
114
+ zh: '桌面端没有在限定时间内完成这次更新:插件可能已经被移除,新版本却还没装上。重新打开 DSH 后市场会自动补装;也可以现在从「设置 → 插件」重试',
115
+ en: 'the desktop app did not finish this update in time: the plugin may already be removed while its replacement never installed. Reopen DSH and the market will finish the install, or retry from Settings → Plugins now',
116
+ },
98
117
  };
99
118
  /**
100
119
  * The market's own package names. A desktop profile installs it from npm as
@@ -164,6 +183,18 @@ export function registryNameOf(spec) {
164
183
  const name = at === -1 ? spec : spec.slice(0, at);
165
184
  return /^(@[A-Za-z0-9._~-]+\/)?[A-Za-z0-9._~-]+$/.test(name) ? name : undefined;
166
185
  }
186
+ /** Exact version from a registry spec, or '' when the spec is a range or tag. */
187
+ function targetVersionOf(spec) {
188
+ if (registryNameOf(spec) === undefined)
189
+ return '';
190
+ const at = spec.indexOf('@', 1);
191
+ const version = at === -1 ? '' : spec.slice(at + 1);
192
+ return /^\d+\.\d+\.\d+(?:[-+].*)?$/.test(version) ? version : '';
193
+ }
194
+ /** A manager call that never settled: the profile may be half-updated. */
195
+ function interrupted() {
196
+ return { application: 'failed', error: { code: 'update-incomplete' }, repairOnRestart: true };
197
+ }
167
198
  /**
168
199
  * Install a spec, and when the manager refuses it because that package is
169
200
  * already in the profile, remove the installed copy first and install again.
@@ -174,8 +205,13 @@ export function registryNameOf(spec) {
174
205
  * `removeBundle` then install — because its manager has no in-place update.
175
206
  * Without this the market could not update ANY installed plugin on that host,
176
207
  * itself included: users stuck on a broken build could not fetch the fix.
208
+ *
209
+ * The two steps are not atomic and the first one writes the profile, so the
210
+ * pair is journaled before it starts and bounded by a deadline (#928). If the
211
+ * host never answers, the market stops waiting and the next start finishes or
212
+ * undoes the removal from that record instead of leaving the plugin gone.
177
213
  */
178
- async function installOrReinstall(manager, spec, options) {
214
+ async function installOrReinstall(manager, spec, options, context) {
179
215
  const install = () => manager.installBundle(spec, options);
180
216
  let first;
181
217
  try {
@@ -197,12 +233,63 @@ async function installOrReinstall(manager, spec, options) {
197
233
  if (SELF_NAMES.has(name)) {
198
234
  return { application: 'failed', error: { code: 'market-self-update', message: selfUpdateMessage(spec) } };
199
235
  }
236
+ const record = context === undefined
237
+ ? undefined
238
+ : context.journal.begin({
239
+ profile: context.profile,
240
+ name,
241
+ from: readInstalledVersion(context.profile, name, context.profileDirectory) ?? '',
242
+ to: targetVersionOf(spec),
243
+ spec,
244
+ });
200
245
  progress.lastLine = 'Removing the installed copy so the desktop app can install the new version';
201
- const removed = await manager.removeBundle(name);
202
- if (removed.application === 'failed' || removed.application === 'cancelled')
246
+ let removed;
247
+ try {
248
+ removed = await withDeadline(manager.removeBundle(name), REMOVE_DEADLINE_MS, `remove ${name}`);
249
+ }
250
+ catch (error) {
251
+ if (error instanceof DeadlineExceeded)
252
+ return interrupted();
253
+ if (record !== undefined)
254
+ context?.journal.finish(record.id);
255
+ throw error;
256
+ }
257
+ if (removed.application === 'failed' || removed.application === 'cancelled') {
258
+ // Nothing was unloaded, so there is nothing for a later start to repair.
259
+ if (record !== undefined)
260
+ context?.journal.finish(record.id);
203
261
  return removed;
262
+ }
263
+ if (record !== undefined)
264
+ context?.journal.mark(record.id, 'installing');
204
265
  progress.lastLine = 'Installing the new version';
205
- return install();
266
+ try {
267
+ const installed = await withDeadline(install(), INSTALL_DEADLINE_MS, `install ${spec}`);
268
+ if (installed.application === 'failed' || installed.application === 'cancelled') {
269
+ // The replacement did not land; the record stays so the next start can
270
+ // put it back rather than leaving the package gone.
271
+ return installed;
272
+ }
273
+ if (record !== undefined)
274
+ context?.journal.finish(record.id);
275
+ return installed;
276
+ }
277
+ catch (error) {
278
+ if (error instanceof DeadlineExceeded)
279
+ return interrupted();
280
+ throw error;
281
+ }
282
+ }
283
+ /** Resolve the profile directory without making an invalid name a construction failure. */
284
+ function safeProfileDirectory(profile, explicit) {
285
+ if (explicit !== undefined)
286
+ return explicit;
287
+ try {
288
+ return profileDir(profile);
289
+ }
290
+ catch {
291
+ return undefined;
292
+ }
206
293
  }
207
294
  /**
208
295
  * Never fall back to `dsh plugin --profile desktop`.
@@ -220,13 +307,21 @@ export function createOfficialDesktopRuntime(managerLookup, profileName,
220
307
  // that names this profile, with no launcher directory to go with it (#744).
221
308
  _profileDirectory) {
222
309
  let disposed = false;
310
+ let repairing = false;
223
311
  let active;
312
+ const directory = safeProfileDirectory(profileName, _profileDirectory);
313
+ const journal = directory === undefined ? undefined : PendingUpdateJournal.forProfile(directory);
314
+ const updateContext = directory === undefined || journal === undefined
315
+ ? undefined
316
+ : { profile: profileName, profileDirectory: directory, journal };
224
317
  const runPlugin = (profile, argv) => {
225
318
  if (disposed)
226
319
  return Promise.resolve(failure('official desktop plugin runtime is disposed', 127));
227
320
  if (profile !== profileName)
228
321
  return Promise.resolve(failure('refusing to change a different profile', 127));
229
- if (active !== undefined)
322
+ // A repair uses the same manager; overlapping the two would race the
323
+ // profile's write lock and the route's single-operation rule.
324
+ if (active !== undefined || repairing)
230
325
  return Promise.resolve({ ...failure('another plugin operation is running', 127), busy: true });
231
326
  const manager = managerLookup();
232
327
  if (manager === undefined
@@ -275,9 +370,13 @@ _profileDirectory) {
275
370
  // The default route leaves the host's own npm configuration in charge.
276
371
  const registry = routesFor(activeRegion()).npmRegistry;
277
372
  const options = { requestId, ...(registry === DEFAULT_NPM_REGISTRY ? {} : { registry }) };
373
+ // An explicit uninstall supersedes an interrupted update for the same
374
+ // package: the journal must not put back what the user just removed.
375
+ if (command === 'remove')
376
+ updateContext?.journal.forget(profileName, target);
278
377
  operation = command === 'remove'
279
378
  ? manager.removeBundle(target)
280
- : installOrReinstall(manager, spec, options);
379
+ : installOrReinstall(manager, spec, options, updateContext);
281
380
  }
282
381
  catch (error) {
283
382
  progress.active = false;
@@ -300,6 +399,10 @@ _profileDirectory) {
300
399
  const message = ok ? '' : detail(result.error);
301
400
  if (!ok)
302
401
  progress.error = message;
402
+ // The manager never settled: the profile may be half-updated, so this
403
+ // is neither "nothing happened" nor something the route can restore
404
+ // through the same stuck manager (#928).
405
+ const repairOnRestart = !ok && result.repairOnRestart === true;
303
406
  return {
304
407
  // `||`, not `??`: a failure at stage `enable` follows a SUCCESSFUL
305
408
  // pnpm step, so packageResult.exitCode is 0 there — and every route
@@ -313,7 +416,8 @@ _profileDirectory) {
313
416
  // profile is untouched, and the route must not try to "restore" it.
314
417
  // A pre-install host check (#912) is the same shape even when the
315
418
  // manager attached a package result to its refusal.
316
- wroteNothing: !ok && (result.packageResult === undefined || prePnpmHostFailure(result)),
419
+ wroteNothing: !ok && !repairOnRestart && (result.packageResult === undefined || prePnpmHostFailure(result)),
420
+ ...(repairOnRestart ? { repairOnRestart: true } : {}),
317
421
  // The manager's own code, kept machine-readable (#909): a client can
318
422
  // tell a self-update refusal from an ordinary failure.
319
423
  ...(ok ? {} : { refusalCode: managementCode(result.error) }),
@@ -334,8 +438,28 @@ _profileDirectory) {
334
438
  });
335
439
  return current.done;
336
440
  };
441
+ const repairPendingUpdates = async () => {
442
+ const empty = { reinstalled: [], finished: [], failed: [] };
443
+ if (disposed || repairing || active !== undefined)
444
+ return empty;
445
+ const manager = managerLookup();
446
+ if (manager === undefined || directory === undefined || journal === undefined)
447
+ return empty;
448
+ repairing = true;
449
+ try {
450
+ return await repairInterruptedUpdates({ profile: profileName, profileDirectory: directory, manager, journal });
451
+ }
452
+ catch (error) {
453
+ console.warn('dsh-market: could not repair an interrupted update', error);
454
+ return empty;
455
+ }
456
+ finally {
457
+ repairing = false;
458
+ }
459
+ };
337
460
  return {
338
461
  runPlugin,
462
+ repairPendingUpdates,
339
463
  probePnpm: async () => managerLookup() !== undefined,
340
464
  provisionPnpm: async () => ({ ok: managerLookup() !== undefined, hint: 'Use the official desktop plugin manager' }),
341
465
  cancelActive: () => {