dshmarket 1.68.0 → 1.69.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/README.md +4 -1
- package/README.zh.md +3 -1
- package/client/client.js +44 -0
- package/lib/pending-rows.js +141 -0
- package/lib/routes.js +41 -0
- package/lib/types/pending-rows.d.ts +81 -0
- package/package.json +1 -1
- package/src/client/MarketSection.tsx +21 -0
- package/src/client/host-navigation.ts +44 -0
- package/src/client/index.ts +20 -0
- package/src/client/locales.ts +4 -0
- package/src/client/market-element.ts +7 -0
- package/src/pending-rows.ts +178 -0
- package/src/routes.ts +35 -0
package/README.md
CHANGED
|
@@ -29,7 +29,10 @@ desktop app needed). The market is also still a section of **Settings → Plugin
|
|
|
29
29
|
Market**: on dsh 0.2.0 and older that is the only way in, and from 0.2.1 the
|
|
30
30
|
Settings dialog lives in the account menu. If you would rather reach it in one
|
|
31
31
|
click from anywhere, the market can also put its own **Plugin Market** row in
|
|
32
|
-
the sidebar; turn it on from the market's card under **Plugins**.
|
|
32
|
+
the sidebar; turn it on from the market's card under **Plugins**. Going the
|
|
33
|
+
other way, an installed plugin's **···** menu offers **View in Plugins** on
|
|
34
|
+
dsh 0.2.1+: it opens the host's own page for that plugin, which is the only
|
|
35
|
+
place its individual rows can be switched one by one.
|
|
33
36
|
|
|
34
37
|
A host that runs under another profile name takes the same command with that
|
|
35
38
|
name instead — `dsh plugin --profile <profile> add dshmarket`. On the official
|
package/README.zh.md
CHANGED
|
@@ -27,7 +27,9 @@ dsh plugin --profile web add dshmarket
|
|
|
27
27
|
(dsh 0.2.1 及以上;普通的 `dsh web` 就有,不需要桌面端)。市场仍然也是
|
|
28
28
|
**设置 → 插件市场** 里的一个分区:dsh 0.2.0 及以前只有这个入口,0.2.1 起设置
|
|
29
29
|
窗口挪进了账号菜单。想在任何地方一键进入的话,市场也可以在侧边栏放一行
|
|
30
|
-
**插件市场**,在 **插件**
|
|
30
|
+
**插件市场**,在 **插件** 页市场的卡片上打开即可。反过来,dsh 0.2.1 起,已装
|
|
31
|
+
插件的 **···** 菜单里有 **在插件页查看**:它会打开宿主里这个插件自己的页面,
|
|
32
|
+
那里是唯一能逐个开关它的各个组件(行)的地方。
|
|
31
33
|
|
|
32
34
|
如果宿主用的不是 `web` 这个 profile,把它的名字写进命令即可:`dsh plugin
|
|
33
35
|
--profile <profile> add dshmarket`。官方 DSH Desktop 桌面端请在应用自己的
|
package/client/client.js
CHANGED
|
@@ -582,6 +582,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
582
582
|
installedDependencyNote: "另有 {0} 个包是插件带进来的依赖,不是插件,未列在上方。",
|
|
583
583
|
heldReleaseAction: "仍安装 {0}",
|
|
584
584
|
ignoreUpdateNotice: "重启前不再提醒",
|
|
585
|
+
openInHostPage: "在插件页查看",
|
|
585
586
|
ignoreAllUpdateNotices: "重启前都不再提醒",
|
|
586
587
|
updateNoticeIgnored: "重启前不再提醒",
|
|
587
588
|
updateExemptAdd: "一直不提醒更新",
|
|
@@ -1329,6 +1330,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
1329
1330
|
installedDependencyNote: "{0} more packages came in as dependencies of plugins — they are not plugins, so they are not listed above.",
|
|
1330
1331
|
heldReleaseAction: "Install {0} anyway",
|
|
1331
1332
|
ignoreUpdateNotice: "Don't remind until restart",
|
|
1333
|
+
openInHostPage: "View in Plugins",
|
|
1332
1334
|
ignoreAllUpdateNotices: "Don't remind any until restart",
|
|
1333
1335
|
updateNoticeIgnored: "No reminders until restart",
|
|
1334
1336
|
updateExemptAdd: "Never remind about updates",
|
|
@@ -8632,6 +8634,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
8632
8634
|
const lang = String(localeSnap.active).toLowerCase().startsWith("zh") ? "zh" : "en";
|
|
8633
8635
|
const surface = props.surface ?? "settings";
|
|
8634
8636
|
const sidebar = props.sidebar;
|
|
8637
|
+
const hostNavigation = props.hostNavigation;
|
|
8638
|
+
const openInHost = (0, react.useSyncExternalStore)((cb) => hostNavigation?.subscribe(cb) ?? (() => {}), () => hostNavigation?.get());
|
|
8635
8639
|
const [sidebarSnap, setSidebarSnap] = (0, react.useState)(() => ({
|
|
8636
8640
|
supported: sidebar?.supported() === true,
|
|
8637
8641
|
enabled: sidebar?.enabled() === true,
|
|
@@ -11854,6 +11858,9 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
11854
11858
|
});
|
|
11855
11859
|
};
|
|
11856
11860
|
const renderInstalledBlockMenu = (name) => {
|
|
11861
|
+
const hostPage = openInHost === void 0 ? null : () => {
|
|
11862
|
+
openInHost.openBundle(name);
|
|
11863
|
+
};
|
|
11857
11864
|
const entry = data === null ? void 0 : catalogEntryForInstalled(data.plugins, name, String(installed[name] ?? ""), repoIdentities[name], repoHints[name]);
|
|
11858
11865
|
const aliases = entry === void 0 ? [name] : blockAliases(entry, name);
|
|
11859
11866
|
const hidden = aliases.some((alias) => blockedNameSet.has(alias));
|
|
@@ -11879,6 +11886,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
11879
11886
|
if (id === "block") toggleBlock(blockToggleName(aliases));
|
|
11880
11887
|
if (id === "update-exempt") toggleUpdateExempt(name);
|
|
11881
11888
|
if (id === "ignore-boot") ignoreUpdateNotices([name]);
|
|
11889
|
+
if (id === "host-page") hostPage?.();
|
|
11882
11890
|
if (id === "uninstall" && !uninstallBusy) setRemoveConfirm(name);
|
|
11883
11891
|
},
|
|
11884
11892
|
align: "end",
|
|
@@ -11908,6 +11916,10 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
11908
11916
|
id: "ignore-boot",
|
|
11909
11917
|
label: t("ignoreUpdateNotice")
|
|
11910
11918
|
}] : [],
|
|
11919
|
+
...hostPage === null ? [] : [{
|
|
11920
|
+
id: "host-page",
|
|
11921
|
+
label: t("openInHostPage")
|
|
11922
|
+
}],
|
|
11911
11923
|
...showExempt ? [{
|
|
11912
11924
|
id: "update-exempt",
|
|
11913
11925
|
label: exempt ? t("updateExemptRemove") : t("updateExemptAdd")
|
|
@@ -15410,6 +15422,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
15410
15422
|
themeStore: props.themeStore,
|
|
15411
15423
|
preferredSubsectionId: props.preferredSubsectionId,
|
|
15412
15424
|
sidebar: props.sidebar,
|
|
15425
|
+
hostNavigation: props.hostNavigation,
|
|
15413
15426
|
surface: props.surface
|
|
15414
15427
|
}));
|
|
15415
15428
|
}
|
|
@@ -16156,6 +16169,25 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
16156
16169
|
}, "dsh-market: settings nav icon");
|
|
16157
16170
|
}
|
|
16158
16171
|
//#endregion
|
|
16172
|
+
//#region src/client/host-navigation.ts
|
|
16173
|
+
function createHostNavigation() {
|
|
16174
|
+
let navigation;
|
|
16175
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
16176
|
+
return {
|
|
16177
|
+
get: () => navigation,
|
|
16178
|
+
subscribe: (listener) => {
|
|
16179
|
+
listeners.add(listener);
|
|
16180
|
+
return () => {
|
|
16181
|
+
listeners.delete(listener);
|
|
16182
|
+
};
|
|
16183
|
+
},
|
|
16184
|
+
attach: (next) => {
|
|
16185
|
+
navigation = next;
|
|
16186
|
+
for (const listener of [...listeners]) listener();
|
|
16187
|
+
}
|
|
16188
|
+
};
|
|
16189
|
+
}
|
|
16190
|
+
//#endregion
|
|
16159
16191
|
//#region src/client/sidebar-entry.ts
|
|
16160
16192
|
/**
|
|
16161
16193
|
* The market's own row in the sidebar (#846), beside the host's Plugins page.
|
|
@@ -16333,6 +16365,12 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
16333
16365
|
installSettingsNavIcon(ctx, () => t("nav"));
|
|
16334
16366
|
let sidebar;
|
|
16335
16367
|
/**
|
|
16368
|
+
* The host's `pluginNavigation` (dsh 0.2.1+), published as a store so the
|
|
16369
|
+
* panel's menus can offer "在插件页查看" whether the service is there before
|
|
16370
|
+
* or after this package mounts. See host-navigation.ts.
|
|
16371
|
+
*/
|
|
16372
|
+
const hostNavigation = createHostNavigation();
|
|
16373
|
+
/**
|
|
16336
16374
|
* The market's own panel, as an element — one builder for the settings
|
|
16337
16375
|
* section this package registers and for `market.render()` (#602). Built
|
|
16338
16376
|
* per call: the props are live (locale, theme, the host's preferred
|
|
@@ -16357,6 +16395,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
16357
16395
|
},
|
|
16358
16396
|
preferredSubsectionId: ownerProps.preferredSubsectionId,
|
|
16359
16397
|
sidebar,
|
|
16398
|
+
hostNavigation,
|
|
16360
16399
|
surface: ownerProps.surface ?? "settings"
|
|
16361
16400
|
});
|
|
16362
16401
|
const sectionGate = createSectionGate(() => {
|
|
@@ -16415,6 +16454,11 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
16415
16454
|
ctx.inject(["layout"], (scoped) => {
|
|
16416
16455
|
layout = scoped.layout;
|
|
16417
16456
|
});
|
|
16457
|
+
ctx.inject(["pluginNavigation"], (scoped) => {
|
|
16458
|
+
hostNavigation.attach({ openBundle: (name) => {
|
|
16459
|
+
scoped.pluginNavigation.openBundle(name);
|
|
16460
|
+
} });
|
|
16461
|
+
});
|
|
16418
16462
|
const retire = () => {
|
|
16419
16463
|
sectionGate.retire();
|
|
16420
16464
|
sidebarGate.retire();
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patch rows an uninstall was going to orphan, recorded before the remove.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists (#987): an uninstall removes the package first and clears the
|
|
5
|
+
* rows its bundle patch declares second — `rowIdsForPackage` has to be asked
|
|
6
|
+
* BEFORE the remove, because the remove deletes the patch it reads (#799). On
|
|
7
|
+
* a host that unloads this plugin in the middle of the operation (the official
|
|
8
|
+
* Desktop replays its plugin set after a profile change, #962), the second half
|
|
9
|
+
* never runs and the rows outlive their package. A `- id: pet` insert for a
|
|
10
|
+
* package that is no longer installed is not cosmetic: DSH's boot is
|
|
11
|
+
* all-or-nothing, so one row naming a missing module can keep the whole host
|
|
12
|
+
* from starting, and the way out was editing cordis.patch.yml by hand.
|
|
13
|
+
*
|
|
14
|
+
* So the intent is written down before the destructive step, exactly like the
|
|
15
|
+
* interrupted-update journal next door, and the next start settles it against
|
|
16
|
+
* disk truth: rows whose package is gone are removed, and a record whose
|
|
17
|
+
* package is still installed is dropped untouched (the remove never happened,
|
|
18
|
+
* or the user put it back — either way the rows are exactly as configuration
|
|
19
|
+
* should be).
|
|
20
|
+
*
|
|
21
|
+
* The rows this settles are the ones the market writes and `removeRowBlocks`
|
|
22
|
+
* understands — `- id: X` + `disabled:` blocks. Rows that declare a package's
|
|
23
|
+
* own config live in that package's patch; a copy of one in the profile patch
|
|
24
|
+
* is the host's (the manager's install dialog writes those) and is not this
|
|
25
|
+
* module's to interpret.
|
|
26
|
+
*
|
|
27
|
+
* What this cannot do is run when the host never starts: the repair happens
|
|
28
|
+
* inside the host, after composition. It covers the case that produced the
|
|
29
|
+
* report — a host that stayed up while this plugin was unloaded — and the next
|
|
30
|
+
* ordinary start after a restart.
|
|
31
|
+
*/
|
|
32
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import { dirname, join } from 'node:path';
|
|
34
|
+
import { removeRowBlocks } from './patch.js';
|
|
35
|
+
import { readInstalled } from './profile.js';
|
|
36
|
+
const STATE_VERSION = 1;
|
|
37
|
+
/** Same directory as the other durable per-profile state (see pending-updates.ts). */
|
|
38
|
+
export function pendingRowsFile(profileDirectory) {
|
|
39
|
+
return join(profileDirectory, '.dsh-market', 'pending-rows.json');
|
|
40
|
+
}
|
|
41
|
+
export class PendingRowJournal {
|
|
42
|
+
file;
|
|
43
|
+
constructor(file) {
|
|
44
|
+
this.file = file;
|
|
45
|
+
}
|
|
46
|
+
static forProfile(profileDirectory) {
|
|
47
|
+
return new PendingRowJournal(pendingRowsFile(profileDirectory));
|
|
48
|
+
}
|
|
49
|
+
list() {
|
|
50
|
+
return this.read().operations;
|
|
51
|
+
}
|
|
52
|
+
/** Record what a remove is about to orphan. Upserts by profile+name. */
|
|
53
|
+
begin(input) {
|
|
54
|
+
if (input.rows.length === 0)
|
|
55
|
+
return;
|
|
56
|
+
const state = this.read();
|
|
57
|
+
const existing = state.operations.find(entry => entry.profile === input.profile && entry.name === input.name);
|
|
58
|
+
const fields = {
|
|
59
|
+
profile: input.profile,
|
|
60
|
+
name: input.name,
|
|
61
|
+
patchPath: input.patchPath,
|
|
62
|
+
rows: [...input.rows],
|
|
63
|
+
startedAt: new Date().toISOString(),
|
|
64
|
+
};
|
|
65
|
+
if (existing !== undefined)
|
|
66
|
+
Object.assign(existing, fields);
|
|
67
|
+
else
|
|
68
|
+
state.operations.push({ id: `${input.profile}:${input.name}`, ...fields });
|
|
69
|
+
this.write(state);
|
|
70
|
+
}
|
|
71
|
+
/** The rows were cleaned (or the remove never happened): nothing left to settle. */
|
|
72
|
+
finish(profile, name) {
|
|
73
|
+
const state = this.read();
|
|
74
|
+
const kept = state.operations.filter(entry => !(entry.profile === profile && entry.name === name));
|
|
75
|
+
if (kept.length === state.operations.length)
|
|
76
|
+
return;
|
|
77
|
+
this.write({ version: STATE_VERSION, operations: kept });
|
|
78
|
+
}
|
|
79
|
+
read() {
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
82
|
+
const operations = Array.isArray(parsed.operations) ? parsed.operations : [];
|
|
83
|
+
// Storage is not trusted: this file is user-writable and outlives
|
|
84
|
+
// versions of this code, and a malformed entry must cost its own record,
|
|
85
|
+
// never the whole repair.
|
|
86
|
+
return {
|
|
87
|
+
version: STATE_VERSION,
|
|
88
|
+
operations: operations.filter((entry) => entry !== null && typeof entry === 'object'
|
|
89
|
+
&& typeof entry.profile === 'string' && typeof entry.name === 'string'
|
|
90
|
+
&& typeof entry.patchPath === 'string'
|
|
91
|
+
&& Array.isArray(entry.rows) && entry.rows.every(row => typeof row === 'string')
|
|
92
|
+
&& typeof entry.id === 'string' && typeof entry.startedAt === 'string'),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return { version: STATE_VERSION, operations: [] };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
write(state) {
|
|
100
|
+
try {
|
|
101
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
102
|
+
writeFileSync(this.file, JSON.stringify(state));
|
|
103
|
+
}
|
|
104
|
+
catch { /* storage unavailable — the next start just has nothing to settle */ }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Settle every recorded remove against what the profile actually holds now.
|
|
109
|
+
*
|
|
110
|
+
* @param options.profile - Profile name, for reading the installed map.
|
|
111
|
+
* @param options.profileDirectory - Its directory, when the host named one.
|
|
112
|
+
* @returns what happened, per record.
|
|
113
|
+
*/
|
|
114
|
+
export function repairOrphanedRows(options) {
|
|
115
|
+
const directory = options.profileDirectory;
|
|
116
|
+
if (directory === undefined)
|
|
117
|
+
return { removed: [], kept: [], failed: [] };
|
|
118
|
+
const journal = PendingRowJournal.forProfile(directory);
|
|
119
|
+
const records = journal.list();
|
|
120
|
+
const report = { removed: [], kept: [], failed: [] };
|
|
121
|
+
if (records.length === 0)
|
|
122
|
+
return report;
|
|
123
|
+
const installed = readInstalled(options.profile, directory);
|
|
124
|
+
for (const record of records) {
|
|
125
|
+
if (Object.hasOwn(installed, record.name)) {
|
|
126
|
+
// The remove never landed, or the user reinstalled it. Either way these
|
|
127
|
+
// rows are configuration for a package that is here.
|
|
128
|
+
journal.finish(record.profile, record.name);
|
|
129
|
+
report.kept.push(record.name);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const cleaned = removeRowBlocks(record.patchPath, record.rows);
|
|
133
|
+
if (!cleaned.ok) {
|
|
134
|
+
report.failed.push(record.name);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
journal.finish(record.profile, record.name);
|
|
138
|
+
report.removed.push(record.name);
|
|
139
|
+
}
|
|
140
|
+
return report;
|
|
141
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -22,6 +22,7 @@ import { marketFetch } from './net.js';
|
|
|
22
22
|
import { diagnosePackageManifests } from './diagnostics.js';
|
|
23
23
|
import { BOOT_ID, cancelActive, probePnpm, progress, provisionPnpm, runDshPlugin, setBuildEnvSource, TARGET_RE, } from './dsh-cli.js';
|
|
24
24
|
import { packageOfEntryName } from './entry-identity.js';
|
|
25
|
+
import { PendingRowJournal, repairOrphanedRows } from './pending-rows.js';
|
|
25
26
|
import { addProfileBundle, bundleOptOutRowIds, bundlePatchInsertedIds, bundlesDroppedFromProfile, declareProfileDependency, dropFromManifest, generationManagedNames, hasLoadableEntry, holdsNativeAddon, INBOX_BUNDLES, isDshProfileName, profileDir, readDependencyOwners, readGitResolutionCommit, readInstalled, readInstalledManifest, readInstalledPackageName, readInstalledRepoEvidence, readInstalledVersion, readLockCommits, readProfileBundles, readProfileManifestSnapshot, removeProfileBundle, restoreProfileManifest, setAllowBuilds } from './profile.js';
|
|
26
27
|
import { assessProfile, classifyPeer, introducedDuplicateNames, introducedRisks } from './compatibility.js';
|
|
27
28
|
import { runningAgentIds } from './agents.js';
|
|
@@ -367,6 +368,21 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
367
368
|
throw new Error(message);
|
|
368
369
|
}
|
|
369
370
|
const activeProfileDir = profileDir(config.profile, config.profileDirectory);
|
|
371
|
+
// #987: a remove that died between deleting the package and clearing the
|
|
372
|
+
// rows its bundle patch declares leaves an insert naming a missing module —
|
|
373
|
+
// and DSH's boot is all-or-nothing, so that row can keep the host from
|
|
374
|
+
// starting at all. The intent was journaled before the destructive step;
|
|
375
|
+
// settle it now, against what the profile actually holds.
|
|
376
|
+
try {
|
|
377
|
+
const settled = repairOrphanedRows({ profile: config.profile, profileDirectory: activeProfileDir });
|
|
378
|
+
if (settled.removed.length > 0)
|
|
379
|
+
logEvent('info', 'uninstall', `cleared patch rows left by an interrupted uninstall: ${settled.removed.join(', ')}`);
|
|
380
|
+
if (settled.failed.length > 0)
|
|
381
|
+
logEvent('warn', 'uninstall', `patch rows from an interrupted uninstall could not be cleared, will retry: ${settled.failed.join(', ')}`);
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
logEvent('warn', 'uninstall', `could not settle interrupted uninstall rows — ${error instanceof Error ? error.message : String(error)}`);
|
|
385
|
+
}
|
|
370
386
|
const analyzeActiveProfile = () => analyzeProfile(activeProfileDir, {
|
|
371
387
|
...(config.dshInstallDir === undefined ? {} : { dshInstallDir: config.dshInstallDir }),
|
|
372
388
|
});
|
|
@@ -1449,8 +1465,15 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
1449
1465
|
// deletes. Read afterwards this returns [] and the rows survive the
|
|
1450
1466
|
// package as boot-time orphans (#799).
|
|
1451
1467
|
const ownedRows = rowIdsForPackage(host, activeProfileDir, name);
|
|
1468
|
+
// #987: written down before the destructive step, because the process can
|
|
1469
|
+
// be unloaded between the remove and the cleanup below — and a row whose
|
|
1470
|
+
// package is gone can stop the next boot.
|
|
1471
|
+
const rowJournal = PendingRowJournal.forProfile(activeProfileDir);
|
|
1472
|
+
rowJournal.begin({ profile: config.profile, name, patchPath: userPatchPath, rows: ownedRows });
|
|
1452
1473
|
const result = await runPlugin(config.profile, ['remove', name]);
|
|
1453
1474
|
if (result.exitCode !== 0 || result.timedOut || result.cancelled) {
|
|
1475
|
+
// Nothing was removed: the rows are still configuration, not orphans.
|
|
1476
|
+
rowJournal.finish(config.profile, name);
|
|
1454
1477
|
return { ok: false, hot: false, detail: failureDetail(result) };
|
|
1455
1478
|
}
|
|
1456
1479
|
// A host that owns the composition replays it instead of being
|
|
@@ -1479,6 +1502,8 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
1479
1502
|
const cleanedOwned = removeRowBlocks(userPatchPath, ownedRows);
|
|
1480
1503
|
if (!cleanedOwned.ok)
|
|
1481
1504
|
logEvent('warn', 'uninstall', `patch rows could not be cleared — ${cleanedOwned.reason}`);
|
|
1505
|
+
else
|
|
1506
|
+
rowJournal.finish(config.profile, name);
|
|
1482
1507
|
disabled.delete(name);
|
|
1483
1508
|
replacedWhileLive.delete(name);
|
|
1484
1509
|
removeFromGroups({ groups, groupOrder }, name);
|
|
@@ -5805,6 +5830,13 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
5805
5830
|
// interrupted mid-remove leaves the same "where did it go" with no
|
|
5806
5831
|
// entry saying what was running.
|
|
5807
5832
|
logEvent('info', 'uninstall-start', `${name} (profile ${config.profile})`);
|
|
5833
|
+
// #987: the rows are journaled BEFORE the remove for the same
|
|
5834
|
+
// reason they are read before it — and because this process can be
|
|
5835
|
+
// unloaded in between (the official Desktop replays its plugin set
|
|
5836
|
+
// after a profile change), which used to leave an insert naming a
|
|
5837
|
+
// package that is no longer installed.
|
|
5838
|
+
const rowJournal = PendingRowJournal.forProfile(activeProfileDir);
|
|
5839
|
+
rowJournal.begin({ profile: config.profile, name, patchPath: userPatchPath, rows: ownedRows });
|
|
5808
5840
|
const result = await runPlugin(config.profile, ['remove', name]);
|
|
5809
5841
|
const cancelled = result.cancelled;
|
|
5810
5842
|
let ok = result.exitCode === 0 && !result.timedOut && !cancelled;
|
|
@@ -5829,6 +5861,10 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
5829
5861
|
if (isHostPortCollision(result))
|
|
5830
5862
|
ok = true;
|
|
5831
5863
|
}
|
|
5864
|
+
// The remove did not land and nothing half-gone was found: the
|
|
5865
|
+
// package (and its rows) are exactly as they were.
|
|
5866
|
+
if (!ok && !halfGone && !cancelled)
|
|
5867
|
+
rowJournal.finish(config.profile, name);
|
|
5832
5868
|
let hot = false;
|
|
5833
5869
|
if (ok || halfGone) {
|
|
5834
5870
|
invalidateUpdates();
|
|
@@ -5876,6 +5912,11 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
5876
5912
|
const cleanedOwned = removeRowBlocks(userPatchPath, ownedRows);
|
|
5877
5913
|
if (!cleanedOwned.ok)
|
|
5878
5914
|
logEvent('warn', 'uninstall', `patch rows could not be cleared — ${cleanedOwned.reason}`);
|
|
5915
|
+
// Either way the record is settled: cleaned means nothing is
|
|
5916
|
+
// left to do, and an unclean file is reported above rather than
|
|
5917
|
+
// turned into a silent retry loop on every start.
|
|
5918
|
+
else
|
|
5919
|
+
rowJournal.finish(config.profile, name);
|
|
5879
5920
|
// The disable list must not keep a removed plugin: a later
|
|
5880
5921
|
// reinstall starts enabled. Group memberships follow the same
|
|
5881
5922
|
// rule so no group toggle ever targets a ghost member.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patch rows an uninstall was going to orphan, recorded before the remove.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists (#987): an uninstall removes the package first and clears the
|
|
5
|
+
* rows its bundle patch declares second — `rowIdsForPackage` has to be asked
|
|
6
|
+
* BEFORE the remove, because the remove deletes the patch it reads (#799). On
|
|
7
|
+
* a host that unloads this plugin in the middle of the operation (the official
|
|
8
|
+
* Desktop replays its plugin set after a profile change, #962), the second half
|
|
9
|
+
* never runs and the rows outlive their package. A `- id: pet` insert for a
|
|
10
|
+
* package that is no longer installed is not cosmetic: DSH's boot is
|
|
11
|
+
* all-or-nothing, so one row naming a missing module can keep the whole host
|
|
12
|
+
* from starting, and the way out was editing cordis.patch.yml by hand.
|
|
13
|
+
*
|
|
14
|
+
* So the intent is written down before the destructive step, exactly like the
|
|
15
|
+
* interrupted-update journal next door, and the next start settles it against
|
|
16
|
+
* disk truth: rows whose package is gone are removed, and a record whose
|
|
17
|
+
* package is still installed is dropped untouched (the remove never happened,
|
|
18
|
+
* or the user put it back — either way the rows are exactly as configuration
|
|
19
|
+
* should be).
|
|
20
|
+
*
|
|
21
|
+
* The rows this settles are the ones the market writes and `removeRowBlocks`
|
|
22
|
+
* understands — `- id: X` + `disabled:` blocks. Rows that declare a package's
|
|
23
|
+
* own config live in that package's patch; a copy of one in the profile patch
|
|
24
|
+
* is the host's (the manager's install dialog writes those) and is not this
|
|
25
|
+
* module's to interpret.
|
|
26
|
+
*
|
|
27
|
+
* What this cannot do is run when the host never starts: the repair happens
|
|
28
|
+
* inside the host, after composition. It covers the case that produced the
|
|
29
|
+
* report — a host that stayed up while this plugin was unloaded — and the next
|
|
30
|
+
* ordinary start after a restart.
|
|
31
|
+
*/
|
|
32
|
+
export interface PendingRowCleanup {
|
|
33
|
+
id: string;
|
|
34
|
+
profile: string;
|
|
35
|
+
/** The package being removed; also what decides whether the rows are orphans. */
|
|
36
|
+
name: string;
|
|
37
|
+
/** The profile patch layer the rows live in, absolute. */
|
|
38
|
+
patchPath: string;
|
|
39
|
+
/** Loader row ids the package's own bundle patch declares. */
|
|
40
|
+
rows: string[];
|
|
41
|
+
startedAt: string;
|
|
42
|
+
}
|
|
43
|
+
export interface PendingRowInput {
|
|
44
|
+
profile: string;
|
|
45
|
+
name: string;
|
|
46
|
+
patchPath: string;
|
|
47
|
+
rows: readonly string[];
|
|
48
|
+
}
|
|
49
|
+
/** Same directory as the other durable per-profile state (see pending-updates.ts). */
|
|
50
|
+
export declare function pendingRowsFile(profileDirectory: string): string;
|
|
51
|
+
export declare class PendingRowJournal {
|
|
52
|
+
readonly file: string;
|
|
53
|
+
constructor(file: string);
|
|
54
|
+
static forProfile(profileDirectory: string): PendingRowJournal;
|
|
55
|
+
list(): PendingRowCleanup[];
|
|
56
|
+
/** Record what a remove is about to orphan. Upserts by profile+name. */
|
|
57
|
+
begin(input: PendingRowInput): void;
|
|
58
|
+
/** The rows were cleaned (or the remove never happened): nothing left to settle. */
|
|
59
|
+
finish(profile: string, name: string): void;
|
|
60
|
+
private read;
|
|
61
|
+
private write;
|
|
62
|
+
}
|
|
63
|
+
export interface OrphanRowReport {
|
|
64
|
+
/** Packages whose rows were removed this start. */
|
|
65
|
+
removed: string[];
|
|
66
|
+
/** Records dropped because the package is still installed. */
|
|
67
|
+
kept: string[];
|
|
68
|
+
/** Records whose rows could not be removed; they are retried next start. */
|
|
69
|
+
failed: string[];
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Settle every recorded remove against what the profile actually holds now.
|
|
73
|
+
*
|
|
74
|
+
* @param options.profile - Profile name, for reading the installed map.
|
|
75
|
+
* @param options.profileDirectory - Its directory, when the host named one.
|
|
76
|
+
* @returns what happened, per record.
|
|
77
|
+
*/
|
|
78
|
+
export declare function repairOrphanedRows(options: {
|
|
79
|
+
profile: string;
|
|
80
|
+
profileDirectory?: string;
|
|
81
|
+
}): OrphanRowReport;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dshmarket",
|
|
3
3
|
"description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.69.0",
|
|
5
5
|
"icon": "assets/logo.svg",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
|
@@ -50,6 +50,7 @@ import { clearSettled, drop, enqueue, patch as patchRecord, recordForUrl, restor
|
|
|
50
50
|
import type { OperationRecord } from './operations.ts'
|
|
51
51
|
import { Diagnostics } from './Diagnostics.tsx'
|
|
52
52
|
import { exportMarketLog } from './self-check.ts'
|
|
53
|
+
import type { HostNavigationStore } from './host-navigation.ts'
|
|
53
54
|
import type { SidebarEntry } from './sidebar-entry.ts'
|
|
54
55
|
import {
|
|
55
56
|
installCommandFor,
|
|
@@ -1811,6 +1812,11 @@ export interface MarketSectionProps {
|
|
|
1811
1812
|
preferredSubsectionId?: string
|
|
1812
1813
|
/** The opt-in sidebar row. Absent, the page offers no shortcut tip. */
|
|
1813
1814
|
sidebar?: SidebarEntry
|
|
1815
|
+
/**
|
|
1816
|
+
* The host's `pluginNavigation` (dsh 0.2.1+). Absent, installed plugins
|
|
1817
|
+
* offer no link to the host's own page for them.
|
|
1818
|
+
*/
|
|
1819
|
+
hostNavigation?: HostNavigationStore
|
|
1814
1820
|
/**
|
|
1815
1821
|
* Which mount this is. Only the settings page may offer the sidebar
|
|
1816
1822
|
* shortcut; the sidebar page must not ask to open itself, and the Plugins
|
|
@@ -1893,6 +1899,14 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
1893
1899
|
const lang = String(localeSnap.active).toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
|
1894
1900
|
const surface = props.surface ?? 'settings'
|
|
1895
1901
|
const sidebar = props.sidebar
|
|
1902
|
+
// The host's own page for a bundle (dsh 0.2.1+). Subscribed rather than read
|
|
1903
|
+
// once: the service arrives with the host's plugin manager, which may load
|
|
1904
|
+
// after this panel is already on screen.
|
|
1905
|
+
const hostNavigation = props.hostNavigation
|
|
1906
|
+
const openInHost = useSyncExternalStore(
|
|
1907
|
+
cb => hostNavigation?.subscribe(cb) ?? (() => {}),
|
|
1908
|
+
() => hostNavigation?.get(),
|
|
1909
|
+
)
|
|
1896
1910
|
const [sidebarSnap, setSidebarSnap] = useState(() => ({
|
|
1897
1911
|
supported: sidebar?.supported() === true,
|
|
1898
1912
|
enabled: sidebar?.enabled() === true,
|
|
@@ -5334,6 +5348,11 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
5334
5348
|
}
|
|
5335
5349
|
|
|
5336
5350
|
const renderInstalledBlockMenu = (name: string) => {
|
|
5351
|
+
// The host's page for this bundle, when it offers one. It is the only
|
|
5352
|
+
// place a bundle's ROWS can be switched one by one, which is the reason
|
|
5353
|
+
// to leave a page that already installs, updates and removes.
|
|
5354
|
+
const hostPage = openInHost === undefined ? null : () => { openInHost.openBundle(name) }
|
|
5355
|
+
|
|
5337
5356
|
const entry = data === null ? undefined : catalogEntryForInstalled(data.plugins, name, String(installed[name] ?? ''), repoIdentities[name], repoHints[name])
|
|
5338
5357
|
const aliases = entry === undefined ? [name] : blockAliases(entry, name)
|
|
5339
5358
|
const hidden = aliases.some(alias => blockedNameSet.has(alias))
|
|
@@ -5369,6 +5388,7 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
5369
5388
|
if (id === 'block') toggleBlock(blockToggleName(aliases))
|
|
5370
5389
|
if (id === 'update-exempt') toggleUpdateExempt(name)
|
|
5371
5390
|
if (id === 'ignore-boot') ignoreUpdateNotices([name])
|
|
5391
|
+
if (id === 'host-page') hostPage?.()
|
|
5372
5392
|
if (id === 'uninstall' && !uninstallBusy) setRemoveConfirm(name)
|
|
5373
5393
|
}}
|
|
5374
5394
|
align="end"
|
|
@@ -5390,6 +5410,7 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
5390
5410
|
...(favoriteUrl === undefined ? [] : [{ id: 'favorite', label: favorited ? t('favoriteRemove') : t('favoriteAdd') }]),
|
|
5391
5411
|
{ id: 'block', label: hidden ? t('blockRemove') : t('blockAdd') },
|
|
5392
5412
|
...(showBootIgnore ? [{ id: 'ignore-boot', label: t('ignoreUpdateNotice') }] : []),
|
|
5413
|
+
...(hostPage === null ? [] : [{ id: 'host-page', label: t('openInHostPage') }]),
|
|
5393
5414
|
...(showExempt ? [{ id: 'update-exempt', label: exempt ? t('updateExemptRemove') : t('updateExemptAdd') }] : []),
|
|
5394
5415
|
{ type: 'separator' as const, id: 'uninstall-sep' },
|
|
5395
5416
|
{ id: 'uninstall', label: removing ? t('uninstalling') : t('uninstall'), danger: true, disabled: uninstallBusy },
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host's own "open this bundle's page" entry (dsh 0.2.1+).
|
|
3
|
+
*
|
|
4
|
+
* The Plugins page documents `pluginNavigation.openBundle(packageName)` for
|
|
5
|
+
* other client plugins: it selects that page and shows the bundle, without
|
|
6
|
+
* touching the running Session. The market uses it from an installed plugin's
|
|
7
|
+
* ··· menu, because the host's page is the only place a bundle's ROWS can be
|
|
8
|
+
* switched on and off one by one — the market draws the bundle's own switch
|
|
9
|
+
* and nothing below it.
|
|
10
|
+
*
|
|
11
|
+
* A store rather than a plain value: the service arrives with the host's
|
|
12
|
+
* plugin manager, which may load after this package, and a menu drawn from a
|
|
13
|
+
* snapshot taken at setup would never offer the action on a host that has it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** What the host offers, as much of it as this package uses. */
|
|
17
|
+
export interface HostNavigation {
|
|
18
|
+
openBundle(packageName: string): void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface HostNavigationStore {
|
|
22
|
+
/** The service, or undefined on a host that never provides it. */
|
|
23
|
+
get(): HostNavigation | undefined
|
|
24
|
+
/** Re-render hook for the menus that offer the action. */
|
|
25
|
+
subscribe(listener: () => void): () => void
|
|
26
|
+
/** The host provided the service. */
|
|
27
|
+
attach(navigation: HostNavigation): void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createHostNavigation(): HostNavigationStore {
|
|
31
|
+
let navigation: HostNavigation | undefined
|
|
32
|
+
const listeners = new Set<() => void>()
|
|
33
|
+
return {
|
|
34
|
+
get: () => navigation,
|
|
35
|
+
subscribe: (listener) => {
|
|
36
|
+
listeners.add(listener)
|
|
37
|
+
return () => { listeners.delete(listener) }
|
|
38
|
+
},
|
|
39
|
+
attach: (next) => {
|
|
40
|
+
navigation = next
|
|
41
|
+
for (const listener of [...listeners]) listener()
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { createSectionGate } from './section-gate.ts'
|
|
|
18
18
|
import { exportMarketLog } from './self-check.ts'
|
|
19
19
|
import { SettingsCard } from './SettingsCard.tsx'
|
|
20
20
|
import { installSettingsNavIcon } from './settings-nav-icon.ts'
|
|
21
|
+
import { createHostNavigation } from './host-navigation.ts'
|
|
21
22
|
import { createSidebarEntry, SIDEBAR_PANEL_ID, SIDEBAR_PANEL_ORDER } from './sidebar-entry.ts'
|
|
22
23
|
import type { SidebarEntry } from './sidebar-entry.ts'
|
|
23
24
|
import { MarketLogo } from './MarketSection.tsx'
|
|
@@ -172,6 +173,12 @@ function mountMarket(ctx: MarketClientContext): void {
|
|
|
172
173
|
// buildMarketElement run after that assignment — a render during setup
|
|
173
174
|
// would see no controller, and the page simply offers no tip.
|
|
174
175
|
let sidebar: SidebarEntry | undefined
|
|
176
|
+
/**
|
|
177
|
+
* The host's `pluginNavigation` (dsh 0.2.1+), published as a store so the
|
|
178
|
+
* panel's menus can offer "在插件页查看" whether the service is there before
|
|
179
|
+
* or after this package mounts. See host-navigation.ts.
|
|
180
|
+
*/
|
|
181
|
+
const hostNavigation = createHostNavigation()
|
|
175
182
|
/**
|
|
176
183
|
* The market's own panel, as an element — one builder for the settings
|
|
177
184
|
* section this package registers and for `market.render()` (#602). Built
|
|
@@ -195,6 +202,7 @@ function mountMarket(ctx: MarketClientContext): void {
|
|
|
195
202
|
exportLog: () => { void exportMarketLog().catch(() => {}) },
|
|
196
203
|
preferredSubsectionId: ownerProps.preferredSubsectionId,
|
|
197
204
|
sidebar,
|
|
205
|
+
hostNavigation,
|
|
198
206
|
surface: ownerProps.surface ?? 'settings',
|
|
199
207
|
})
|
|
200
208
|
|
|
@@ -264,6 +272,18 @@ function mountMarket(ctx: MarketClientContext): void {
|
|
|
264
272
|
setTimeout(() => { sidebar?.refresh() }, 0)
|
|
265
273
|
;(ctx as unknown as { inject(services: string[], callback: (scoped: { layout: LayoutService }) => void): void })
|
|
266
274
|
.inject(['layout'], (scoped) => { layout = scoped.layout })
|
|
275
|
+
|
|
276
|
+
// The host's own page for a bundle (dsh 0.2.1+). Its Plugins page documents
|
|
277
|
+
// `pluginNavigation.openBundle(packageName)` for other client plugins: the
|
|
278
|
+
// bundle's page is where its rows are switched one by one, which no public
|
|
279
|
+
// contract lets a third party draw. Nested inject, like the settings card
|
|
280
|
+
// below: naming the service in the module-level `inject` would keep the
|
|
281
|
+
// whole plugin unmounted on every host without it, and this one is young.
|
|
282
|
+
;(ctx as unknown as {
|
|
283
|
+
inject(services: string[], callback: (scoped: { pluginNavigation: { openBundle(name: string): void } }) => void): void
|
|
284
|
+
}).inject(['pluginNavigation'], (scoped) => {
|
|
285
|
+
hostNavigation.attach({ openBundle: (name) => { scoped.pluginNavigation.openBundle(name) } })
|
|
286
|
+
})
|
|
267
287
|
const retire = (): void => {
|
|
268
288
|
sectionGate.retire()
|
|
269
289
|
sidebarGate.retire()
|
package/src/client/locales.ts
CHANGED
|
@@ -393,6 +393,9 @@ export const zh = {
|
|
|
393
393
|
installedDependencyNote: '另有 {0} 个包是插件带进来的依赖,不是插件,未列在上方。',
|
|
394
394
|
heldReleaseAction: '仍安装 {0}',
|
|
395
395
|
ignoreUpdateNotice: '重启前不再提醒',
|
|
396
|
+
// The host's own page for a plugin. Named for what the user gets there —
|
|
397
|
+
// the switches below the plugin (行), which the market does not draw.
|
|
398
|
+
openInHostPage: '在插件页查看',
|
|
396
399
|
ignoreAllUpdateNotices: '重启前都不再提醒',
|
|
397
400
|
updateNoticeIgnored: '重启前不再提醒',
|
|
398
401
|
updateExemptAdd: '一直不提醒更新',
|
|
@@ -1160,6 +1163,7 @@ export const en: Record<MarketKey, string> = {
|
|
|
1160
1163
|
installedDependencyNote: '{0} more packages came in as dependencies of plugins — they are not plugins, so they are not listed above.',
|
|
1161
1164
|
heldReleaseAction: 'Install {0} anyway',
|
|
1162
1165
|
ignoreUpdateNotice: "Don't remind until restart",
|
|
1166
|
+
openInHostPage: 'View in Plugins',
|
|
1163
1167
|
ignoreAllUpdateNotices: "Don't remind any until restart",
|
|
1164
1168
|
updateNoticeIgnored: 'No reminders until restart',
|
|
1165
1169
|
updateExemptAdd: 'Never remind about updates',
|
|
@@ -19,6 +19,7 @@ import { MarketErrorBoundary } from './ErrorBoundary.tsx'
|
|
|
19
19
|
import { MarketSection } from './MarketSection.tsx'
|
|
20
20
|
import type { MarketSectionProps } from './MarketSection.tsx'
|
|
21
21
|
import type { Translate } from './market-data.ts'
|
|
22
|
+
import type { HostNavigationStore } from './host-navigation.ts'
|
|
22
23
|
import type { SidebarEntry } from './sidebar-entry.ts'
|
|
23
24
|
|
|
24
25
|
/** Everything the panel needs from the client context, plus its own actions. */
|
|
@@ -35,6 +36,11 @@ export interface MarketElementProps {
|
|
|
35
36
|
preferredSubsectionId?: string
|
|
36
37
|
/** The opt-in sidebar row. Absent, the panel offers no shortcut tip. */
|
|
37
38
|
sidebar?: SidebarEntry
|
|
39
|
+
/**
|
|
40
|
+
* The host's `pluginNavigation`, when it provides one. Absent, an installed
|
|
41
|
+
* plugin's menu offers no way to the host's own page for it.
|
|
42
|
+
*/
|
|
43
|
+
hostNavigation?: HostNavigationStore
|
|
38
44
|
/**
|
|
39
45
|
* Where this panel is mounted. Only the settings page offers the sidebar
|
|
40
46
|
* shortcut; the sidebar page must not offer itself, and the Plugins page's
|
|
@@ -68,6 +74,7 @@ export function marketElement(props: MarketElementProps): unknown {
|
|
|
68
74
|
themeStore: props.themeStore,
|
|
69
75
|
preferredSubsectionId: props.preferredSubsectionId,
|
|
70
76
|
sidebar: props.sidebar,
|
|
77
|
+
hostNavigation: props.hostNavigation,
|
|
71
78
|
surface: props.surface,
|
|
72
79
|
}))
|
|
73
80
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patch rows an uninstall was going to orphan, recorded before the remove.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists (#987): an uninstall removes the package first and clears the
|
|
5
|
+
* rows its bundle patch declares second — `rowIdsForPackage` has to be asked
|
|
6
|
+
* BEFORE the remove, because the remove deletes the patch it reads (#799). On
|
|
7
|
+
* a host that unloads this plugin in the middle of the operation (the official
|
|
8
|
+
* Desktop replays its plugin set after a profile change, #962), the second half
|
|
9
|
+
* never runs and the rows outlive their package. A `- id: pet` insert for a
|
|
10
|
+
* package that is no longer installed is not cosmetic: DSH's boot is
|
|
11
|
+
* all-or-nothing, so one row naming a missing module can keep the whole host
|
|
12
|
+
* from starting, and the way out was editing cordis.patch.yml by hand.
|
|
13
|
+
*
|
|
14
|
+
* So the intent is written down before the destructive step, exactly like the
|
|
15
|
+
* interrupted-update journal next door, and the next start settles it against
|
|
16
|
+
* disk truth: rows whose package is gone are removed, and a record whose
|
|
17
|
+
* package is still installed is dropped untouched (the remove never happened,
|
|
18
|
+
* or the user put it back — either way the rows are exactly as configuration
|
|
19
|
+
* should be).
|
|
20
|
+
*
|
|
21
|
+
* The rows this settles are the ones the market writes and `removeRowBlocks`
|
|
22
|
+
* understands — `- id: X` + `disabled:` blocks. Rows that declare a package's
|
|
23
|
+
* own config live in that package's patch; a copy of one in the profile patch
|
|
24
|
+
* is the host's (the manager's install dialog writes those) and is not this
|
|
25
|
+
* module's to interpret.
|
|
26
|
+
*
|
|
27
|
+
* What this cannot do is run when the host never starts: the repair happens
|
|
28
|
+
* inside the host, after composition. It covers the case that produced the
|
|
29
|
+
* report — a host that stayed up while this plugin was unloaded — and the next
|
|
30
|
+
* ordinary start after a restart.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
34
|
+
import { dirname, join } from 'node:path'
|
|
35
|
+
import { removeRowBlocks } from './patch.ts'
|
|
36
|
+
import { readInstalled } from './profile.ts'
|
|
37
|
+
|
|
38
|
+
export interface PendingRowCleanup {
|
|
39
|
+
id: string
|
|
40
|
+
profile: string
|
|
41
|
+
/** The package being removed; also what decides whether the rows are orphans. */
|
|
42
|
+
name: string
|
|
43
|
+
/** The profile patch layer the rows live in, absolute. */
|
|
44
|
+
patchPath: string
|
|
45
|
+
/** Loader row ids the package's own bundle patch declares. */
|
|
46
|
+
rows: string[]
|
|
47
|
+
startedAt: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PendingRowInput {
|
|
51
|
+
profile: string
|
|
52
|
+
name: string
|
|
53
|
+
patchPath: string
|
|
54
|
+
rows: readonly string[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface PendingRowState {
|
|
58
|
+
version: 1
|
|
59
|
+
operations: PendingRowCleanup[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const STATE_VERSION = 1 as const
|
|
63
|
+
|
|
64
|
+
/** Same directory as the other durable per-profile state (see pending-updates.ts). */
|
|
65
|
+
export function pendingRowsFile(profileDirectory: string): string {
|
|
66
|
+
return join(profileDirectory, '.dsh-market', 'pending-rows.json')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class PendingRowJournal {
|
|
70
|
+
constructor(readonly file: string) {}
|
|
71
|
+
|
|
72
|
+
static forProfile(profileDirectory: string): PendingRowJournal {
|
|
73
|
+
return new PendingRowJournal(pendingRowsFile(profileDirectory))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
list(): PendingRowCleanup[] {
|
|
77
|
+
return this.read().operations
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Record what a remove is about to orphan. Upserts by profile+name. */
|
|
81
|
+
begin(input: PendingRowInput): void {
|
|
82
|
+
if (input.rows.length === 0) return
|
|
83
|
+
const state = this.read()
|
|
84
|
+
const existing = state.operations.find(entry => entry.profile === input.profile && entry.name === input.name)
|
|
85
|
+
const fields = {
|
|
86
|
+
profile: input.profile,
|
|
87
|
+
name: input.name,
|
|
88
|
+
patchPath: input.patchPath,
|
|
89
|
+
rows: [...input.rows],
|
|
90
|
+
startedAt: new Date().toISOString(),
|
|
91
|
+
}
|
|
92
|
+
if (existing !== undefined) Object.assign(existing, fields)
|
|
93
|
+
else state.operations.push({ id: `${input.profile}:${input.name}`, ...fields })
|
|
94
|
+
this.write(state)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The rows were cleaned (or the remove never happened): nothing left to settle. */
|
|
98
|
+
finish(profile: string, name: string): void {
|
|
99
|
+
const state = this.read()
|
|
100
|
+
const kept = state.operations.filter(entry => !(entry.profile === profile && entry.name === name))
|
|
101
|
+
if (kept.length === state.operations.length) return
|
|
102
|
+
this.write({ version: STATE_VERSION, operations: kept })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private read(): PendingRowState {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(readFileSync(this.file, 'utf8')) as Partial<PendingRowState>
|
|
108
|
+
const operations = Array.isArray(parsed.operations) ? parsed.operations : []
|
|
109
|
+
// Storage is not trusted: this file is user-writable and outlives
|
|
110
|
+
// versions of this code, and a malformed entry must cost its own record,
|
|
111
|
+
// never the whole repair.
|
|
112
|
+
return {
|
|
113
|
+
version: STATE_VERSION,
|
|
114
|
+
operations: operations.filter((entry): entry is PendingRowCleanup =>
|
|
115
|
+
entry !== null && typeof entry === 'object'
|
|
116
|
+
&& typeof entry.profile === 'string' && typeof entry.name === 'string'
|
|
117
|
+
&& typeof entry.patchPath === 'string'
|
|
118
|
+
&& Array.isArray(entry.rows) && entry.rows.every(row => typeof row === 'string')
|
|
119
|
+
&& typeof entry.id === 'string' && typeof entry.startedAt === 'string'),
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
return { version: STATE_VERSION, operations: [] }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private write(state: PendingRowState): void {
|
|
127
|
+
try {
|
|
128
|
+
mkdirSync(dirname(this.file), { recursive: true })
|
|
129
|
+
writeFileSync(this.file, JSON.stringify(state))
|
|
130
|
+
} catch { /* storage unavailable — the next start just has nothing to settle */ }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface OrphanRowReport {
|
|
135
|
+
/** Packages whose rows were removed this start. */
|
|
136
|
+
removed: string[]
|
|
137
|
+
/** Records dropped because the package is still installed. */
|
|
138
|
+
kept: string[]
|
|
139
|
+
/** Records whose rows could not be removed; they are retried next start. */
|
|
140
|
+
failed: string[]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Settle every recorded remove against what the profile actually holds now.
|
|
145
|
+
*
|
|
146
|
+
* @param options.profile - Profile name, for reading the installed map.
|
|
147
|
+
* @param options.profileDirectory - Its directory, when the host named one.
|
|
148
|
+
* @returns what happened, per record.
|
|
149
|
+
*/
|
|
150
|
+
export function repairOrphanedRows(options: {
|
|
151
|
+
profile: string
|
|
152
|
+
profileDirectory?: string
|
|
153
|
+
}): OrphanRowReport {
|
|
154
|
+
const directory = options.profileDirectory
|
|
155
|
+
if (directory === undefined) return { removed: [], kept: [], failed: [] }
|
|
156
|
+
const journal = PendingRowJournal.forProfile(directory)
|
|
157
|
+
const records = journal.list()
|
|
158
|
+
const report: OrphanRowReport = { removed: [], kept: [], failed: [] }
|
|
159
|
+
if (records.length === 0) return report
|
|
160
|
+
const installed = readInstalled(options.profile, directory)
|
|
161
|
+
for (const record of records) {
|
|
162
|
+
if (Object.hasOwn(installed, record.name)) {
|
|
163
|
+
// The remove never landed, or the user reinstalled it. Either way these
|
|
164
|
+
// rows are configuration for a package that is here.
|
|
165
|
+
journal.finish(record.profile, record.name)
|
|
166
|
+
report.kept.push(record.name)
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
const cleaned = removeRowBlocks(record.patchPath, record.rows)
|
|
170
|
+
if (!cleaned.ok) {
|
|
171
|
+
report.failed.push(record.name)
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
journal.finish(record.profile, record.name)
|
|
175
|
+
report.removed.push(record.name)
|
|
176
|
+
}
|
|
177
|
+
return report
|
|
178
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type PluginCommandRuntime,
|
|
32
32
|
} from './dsh-cli.ts'
|
|
33
33
|
import { packageOfEntryName } from './entry-identity.ts'
|
|
34
|
+
import { PendingRowJournal, repairOrphanedRows } from './pending-rows.ts'
|
|
34
35
|
import { addProfileBundle, bundleOptOutRowIds, bundlePatchInsertedIds, bundlesDroppedFromProfile, declareProfileDependency, dropFromManifest, generationManagedNames, hasLoadableEntry, holdsNativeAddon, INBOX_BUNDLES, isDshProfileName, profileDir, readDependencyOwners, readGitResolutionCommit, readInstalled, readInstalledManifest, readInstalledPackageName, readInstalledRepoEvidence, readInstalledVersion, readLockCommits, readProfileBundles, readProfileManifestSnapshot, removeProfileBundle, restoreProfileManifest, setAllowBuilds, type ProfileManifestSnapshot } from './profile.ts'
|
|
35
36
|
import { assessProfile, classifyPeer, introducedDuplicateNames, introducedRisks, type CompatibilityRisk } from './compatibility.ts'
|
|
36
37
|
import { runningAgentIds, type AgentsLookup } from './agents.ts'
|
|
@@ -492,6 +493,18 @@ export function mountMarketRoutes(
|
|
|
492
493
|
throw new Error(message)
|
|
493
494
|
}
|
|
494
495
|
const activeProfileDir = profileDir(config.profile, config.profileDirectory)
|
|
496
|
+
// #987: a remove that died between deleting the package and clearing the
|
|
497
|
+
// rows its bundle patch declares leaves an insert naming a missing module —
|
|
498
|
+
// and DSH's boot is all-or-nothing, so that row can keep the host from
|
|
499
|
+
// starting at all. The intent was journaled before the destructive step;
|
|
500
|
+
// settle it now, against what the profile actually holds.
|
|
501
|
+
try {
|
|
502
|
+
const settled = repairOrphanedRows({ profile: config.profile, profileDirectory: activeProfileDir })
|
|
503
|
+
if (settled.removed.length > 0) logEvent('info', 'uninstall', `cleared patch rows left by an interrupted uninstall: ${settled.removed.join(', ')}`)
|
|
504
|
+
if (settled.failed.length > 0) logEvent('warn', 'uninstall', `patch rows from an interrupted uninstall could not be cleared, will retry: ${settled.failed.join(', ')}`)
|
|
505
|
+
} catch (error) {
|
|
506
|
+
logEvent('warn', 'uninstall', `could not settle interrupted uninstall rows — ${error instanceof Error ? error.message : String(error)}`)
|
|
507
|
+
}
|
|
495
508
|
const analyzeActiveProfile = () => analyzeProfile(activeProfileDir, {
|
|
496
509
|
...(config.dshInstallDir === undefined ? {} : { dshInstallDir: config.dshInstallDir }),
|
|
497
510
|
})
|
|
@@ -1635,8 +1648,15 @@ export function mountMarketRoutes(
|
|
|
1635
1648
|
// deletes. Read afterwards this returns [] and the rows survive the
|
|
1636
1649
|
// package as boot-time orphans (#799).
|
|
1637
1650
|
const ownedRows = rowIdsForPackage(host, activeProfileDir, name)
|
|
1651
|
+
// #987: written down before the destructive step, because the process can
|
|
1652
|
+
// be unloaded between the remove and the cleanup below — and a row whose
|
|
1653
|
+
// package is gone can stop the next boot.
|
|
1654
|
+
const rowJournal = PendingRowJournal.forProfile(activeProfileDir)
|
|
1655
|
+
rowJournal.begin({ profile: config.profile, name, patchPath: userPatchPath, rows: ownedRows })
|
|
1638
1656
|
const result = await runPlugin(config.profile, ['remove', name])
|
|
1639
1657
|
if (result.exitCode !== 0 || result.timedOut || result.cancelled) {
|
|
1658
|
+
// Nothing was removed: the rows are still configuration, not orphans.
|
|
1659
|
+
rowJournal.finish(config.profile, name)
|
|
1640
1660
|
return { ok: false, hot: false, detail: failureDetail(result) }
|
|
1641
1661
|
}
|
|
1642
1662
|
// A host that owns the composition replays it instead of being
|
|
@@ -1664,6 +1684,7 @@ export function mountMarketRoutes(
|
|
|
1664
1684
|
}
|
|
1665
1685
|
const cleanedOwned = removeRowBlocks(userPatchPath, ownedRows)
|
|
1666
1686
|
if (!cleanedOwned.ok) logEvent('warn', 'uninstall', `patch rows could not be cleared — ${cleanedOwned.reason}`)
|
|
1687
|
+
else rowJournal.finish(config.profile, name)
|
|
1667
1688
|
disabled.delete(name)
|
|
1668
1689
|
replacedWhileLive.delete(name)
|
|
1669
1690
|
removeFromGroups({ groups, groupOrder }, name)
|
|
@@ -6041,6 +6062,13 @@ sendJson(response, 200, { updates })
|
|
|
6041
6062
|
// interrupted mid-remove leaves the same "where did it go" with no
|
|
6042
6063
|
// entry saying what was running.
|
|
6043
6064
|
logEvent('info', 'uninstall-start', `${name} (profile ${config.profile})`)
|
|
6065
|
+
// #987: the rows are journaled BEFORE the remove for the same
|
|
6066
|
+
// reason they are read before it — and because this process can be
|
|
6067
|
+
// unloaded in between (the official Desktop replays its plugin set
|
|
6068
|
+
// after a profile change), which used to leave an insert naming a
|
|
6069
|
+
// package that is no longer installed.
|
|
6070
|
+
const rowJournal = PendingRowJournal.forProfile(activeProfileDir)
|
|
6071
|
+
rowJournal.begin({ profile: config.profile, name, patchPath: userPatchPath, rows: ownedRows })
|
|
6044
6072
|
const result = await runPlugin(config.profile, ['remove', name])
|
|
6045
6073
|
const cancelled = result.cancelled
|
|
6046
6074
|
let ok = result.exitCode === 0 && !result.timedOut && !cancelled
|
|
@@ -6064,6 +6092,9 @@ sendJson(response, 200, { updates })
|
|
|
6064
6092
|
// client does not treat a finished uninstall as a failure.
|
|
6065
6093
|
if (isHostPortCollision(result)) ok = true
|
|
6066
6094
|
}
|
|
6095
|
+
// The remove did not land and nothing half-gone was found: the
|
|
6096
|
+
// package (and its rows) are exactly as they were.
|
|
6097
|
+
if (!ok && !halfGone && !cancelled) rowJournal.finish(config.profile, name)
|
|
6067
6098
|
let hot = false
|
|
6068
6099
|
if (ok || halfGone) {
|
|
6069
6100
|
invalidateUpdates()
|
|
@@ -6110,6 +6141,10 @@ sendJson(response, 200, { updates })
|
|
|
6110
6141
|
// mounts is a boot-time orphan (port of dsh-plugin-hub).
|
|
6111
6142
|
const cleanedOwned = removeRowBlocks(userPatchPath, ownedRows)
|
|
6112
6143
|
if (!cleanedOwned.ok) logEvent('warn', 'uninstall', `patch rows could not be cleared — ${cleanedOwned.reason}`)
|
|
6144
|
+
// Either way the record is settled: cleaned means nothing is
|
|
6145
|
+
// left to do, and an unclean file is reported above rather than
|
|
6146
|
+
// turned into a silent retry loop on every start.
|
|
6147
|
+
else rowJournal.finish(config.profile, name)
|
|
6113
6148
|
// The disable list must not keep a removed plugin: a later
|
|
6114
6149
|
// reinstall starts enabled. Group memberships follow the same
|
|
6115
6150
|
// rule so no group toggle ever targets a ghost member.
|