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 +78 -9
- package/lib/index.js +19 -0
- package/lib/official-desktop.js +131 -7
- package/lib/pending-updates.js +295 -0
- package/lib/routes.js +15 -1
- package/lib/types/dsh-cli.d.ts +12 -0
- package/lib/types/official-desktop.d.ts +15 -1
- package/lib/types/pending-updates.d.ts +146 -0
- package/lib/update-api-v1.js +4 -0
- package/package.json +1 -1
- package/src/client/MarketSection.tsx +76 -9
- package/src/client/locales.ts +10 -0
- package/src/client/market-data.ts +5 -0
- package/src/dsh-cli.ts +12 -0
- package/src/index.ts +19 -0
- package/src/official-desktop.ts +149 -7
- package/src/pending-updates.ts +356 -0
- package/src/routes.ts +16 -1
- package/src/update-api-v1.ts +4 -0
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(
|
|
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
|
-
}, [
|
|
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(
|
|
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(
|
|
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:
|
|
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();
|
package/lib/official-desktop.js
CHANGED
|
@@ -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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: () => {
|