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 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.68.0",
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
+ }
@@ -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()
@@ -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.