@chatcode/cco-market 1.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/README.zh.md +143 -0
  4. package/UPDATE-API-V1.md +132 -0
  5. package/client/client.js +11534 -0
  6. package/cordis.patch.yml +5 -0
  7. package/lib/accelerate.js +184 -0
  8. package/lib/agents.js +36 -0
  9. package/lib/backup.js +572 -0
  10. package/lib/catalog-local-match.js +126 -0
  11. package/lib/catalog-npm.js +111 -0
  12. package/lib/changelog.js +242 -0
  13. package/lib/channels.js +64 -0
  14. package/lib/check.js +1067 -0
  15. package/lib/compatibility.js +198 -0
  16. package/lib/diagnostics.js +49 -0
  17. package/lib/discovery-compatibility.js +260 -0
  18. package/lib/dsh-cli.js +949 -0
  19. package/lib/dsh-install.js +108 -0
  20. package/lib/gist.js +352 -0
  21. package/lib/groups.js +97 -0
  22. package/lib/home-paths.js +42 -0
  23. package/lib/hot.js +519 -0
  24. package/lib/http.js +39 -0
  25. package/lib/index.js +97 -0
  26. package/lib/install.js +387 -0
  27. package/lib/log.js +197 -0
  28. package/lib/ndjson.js +154 -0
  29. package/lib/net.js +98 -0
  30. package/lib/order.js +284 -0
  31. package/lib/patch.js +521 -0
  32. package/lib/pnpm-compat.js +490 -0
  33. package/lib/presets.js +278 -0
  34. package/lib/profile.js +911 -0
  35. package/lib/region-probe.js +93 -0
  36. package/lib/regions.js +253 -0
  37. package/lib/registry.js +204 -0
  38. package/lib/restart.js +373 -0
  39. package/lib/routes.js +4372 -0
  40. package/lib/settings.js +109 -0
  41. package/lib/skill-market/api.js +119 -0
  42. package/lib/skill-market/config.js +29 -0
  43. package/lib/skill-market/installer.js +352 -0
  44. package/lib/skill-market/runtime.js +13 -0
  45. package/lib/skill-market/tui.js +119 -0
  46. package/lib/skill-market/types.js +1 -0
  47. package/lib/skill-market/web-routes.js +123 -0
  48. package/lib/snapshot.js +500 -0
  49. package/lib/source-migration.js +54 -0
  50. package/lib/sources.js +596 -0
  51. package/lib/store.js +91 -0
  52. package/lib/themes.js +102 -0
  53. package/lib/trial.js +116 -0
  54. package/lib/types/accelerate.d.ts +85 -0
  55. package/lib/types/agents.d.ts +22 -0
  56. package/lib/types/backup.d.ts +131 -0
  57. package/lib/types/catalog-local-match.d.ts +50 -0
  58. package/lib/types/catalog-npm.d.ts +47 -0
  59. package/lib/types/changelog.d.ts +102 -0
  60. package/lib/types/channels.d.ts +56 -0
  61. package/lib/types/check.d.ts +266 -0
  62. package/lib/types/compatibility.d.ts +64 -0
  63. package/lib/types/diagnostics.d.ts +29 -0
  64. package/lib/types/discovery-compatibility.d.ts +80 -0
  65. package/lib/types/dsh-cli.d.ts +324 -0
  66. package/lib/types/dsh-install.d.ts +46 -0
  67. package/lib/types/gist.d.ts +53 -0
  68. package/lib/types/groups.d.ts +34 -0
  69. package/lib/types/home-paths.d.ts +16 -0
  70. package/lib/types/hot.d.ts +221 -0
  71. package/lib/types/http.d.ts +12 -0
  72. package/lib/types/index.d.ts +14 -0
  73. package/lib/types/install.d.ts +172 -0
  74. package/lib/types/log.d.ts +43 -0
  75. package/lib/types/ndjson.d.ts +52 -0
  76. package/lib/types/net.d.ts +58 -0
  77. package/lib/types/order.d.ts +100 -0
  78. package/lib/types/patch.d.ts +129 -0
  79. package/lib/types/pnpm-compat.d.ts +93 -0
  80. package/lib/types/presets.d.ts +90 -0
  81. package/lib/types/profile.d.ts +253 -0
  82. package/lib/types/region-probe.d.ts +50 -0
  83. package/lib/types/regions.d.ts +122 -0
  84. package/lib/types/registry.d.ts +86 -0
  85. package/lib/types/restart.d.ts +194 -0
  86. package/lib/types/routes.d.ts +67 -0
  87. package/lib/types/settings.d.ts +78 -0
  88. package/lib/types/skill-market/api.d.ts +10 -0
  89. package/lib/types/skill-market/config.d.ts +3 -0
  90. package/lib/types/skill-market/installer.d.ts +23 -0
  91. package/lib/types/skill-market/runtime.d.ts +10 -0
  92. package/lib/types/skill-market/tui.d.ts +4 -0
  93. package/lib/types/skill-market/types.d.ts +71 -0
  94. package/lib/types/skill-market/web-routes.d.ts +19 -0
  95. package/lib/types/snapshot.d.ts +93 -0
  96. package/lib/types/source-migration.d.ts +11 -0
  97. package/lib/types/sources.d.ts +216 -0
  98. package/lib/types/store.d.ts +30 -0
  99. package/lib/types/themes.d.ts +40 -0
  100. package/lib/types/trial.d.ts +61 -0
  101. package/lib/types/update-api-v1.d.ts +66 -0
  102. package/lib/types/updates.d.ts +123 -0
  103. package/lib/types/verify.d.ts +139 -0
  104. package/lib/update-api-v1.js +215 -0
  105. package/lib/updates.js +361 -0
  106. package/lib/verify.js +453 -0
  107. package/package.json +121 -0
  108. package/src/accelerate.ts +213 -0
  109. package/src/agents.ts +43 -0
  110. package/src/backup.ts +583 -0
  111. package/src/catalog-local-match.ts +144 -0
  112. package/src/catalog-npm.ts +120 -0
  113. package/src/changelog.ts +282 -0
  114. package/src/channels.ts +70 -0
  115. package/src/check.ts +1239 -0
  116. package/src/client/CommentsModal.tsx +119 -0
  117. package/src/client/Diagnostics.tsx +907 -0
  118. package/src/client/ErrorBoundary.tsx +111 -0
  119. package/src/client/InstallToast.tsx +31 -0
  120. package/src/client/Market.module.css +830 -0
  121. package/src/client/MarketSection.tsx +5301 -0
  122. package/src/client/OperationsPanel.tsx +365 -0
  123. package/src/client/SettingsCard.tsx +621 -0
  124. package/src/client/SkillMarket.module.css +21 -0
  125. package/src/client/SkillMarketSection.tsx +162 -0
  126. package/src/client/comments.ts +54 -0
  127. package/src/client/globals.d.ts +13 -0
  128. package/src/client/index.ts +180 -0
  129. package/src/client/locales.ts +1112 -0
  130. package/src/client/market-data.ts +1321 -0
  131. package/src/client/operations.ts +201 -0
  132. package/src/client/preset-panel.tsx +263 -0
  133. package/src/client/primitives.d.ts +140 -0
  134. package/src/client/self-check.ts +147 -0
  135. package/src/client/snapshot-panel.tsx +244 -0
  136. package/src/compatibility.ts +237 -0
  137. package/src/diagnostics.ts +84 -0
  138. package/src/discovery-compatibility.ts +315 -0
  139. package/src/dsh-cli.ts +1126 -0
  140. package/src/dsh-install.ts +118 -0
  141. package/src/gist.ts +362 -0
  142. package/src/groups.ts +111 -0
  143. package/src/home-paths.ts +53 -0
  144. package/src/hot.ts +628 -0
  145. package/src/http.ts +41 -0
  146. package/src/index.ts +128 -0
  147. package/src/install.ts +420 -0
  148. package/src/log.ts +206 -0
  149. package/src/ndjson.ts +185 -0
  150. package/src/net.ts +106 -0
  151. package/src/order.ts +303 -0
  152. package/src/patch.ts +522 -0
  153. package/src/pnpm-compat.ts +527 -0
  154. package/src/presets.ts +344 -0
  155. package/src/profile.ts +940 -0
  156. package/src/region-probe.ts +97 -0
  157. package/src/regions.ts +310 -0
  158. package/src/registry.ts +250 -0
  159. package/src/restart.ts +396 -0
  160. package/src/routes.ts +4506 -0
  161. package/src/settings.ts +141 -0
  162. package/src/skill-market/api.ts +130 -0
  163. package/src/skill-market/config.ts +32 -0
  164. package/src/skill-market/installer.ts +337 -0
  165. package/src/skill-market/runtime.ts +14 -0
  166. package/src/skill-market/tui.ts +157 -0
  167. package/src/skill-market/types.ts +80 -0
  168. package/src/skill-market/web-routes.ts +132 -0
  169. package/src/snapshot.ts +532 -0
  170. package/src/source-migration.ts +61 -0
  171. package/src/sources.ts +565 -0
  172. package/src/store.ts +89 -0
  173. package/src/themes.ts +125 -0
  174. package/src/trial.ts +156 -0
  175. package/src/update-api-v1.ts +277 -0
  176. package/src/updates.ts +400 -0
  177. package/src/verify.ts +492 -0
package/lib/routes.js ADDED
@@ -0,0 +1,4372 @@
1
+ /**
2
+ * HTTP routes bridging the browser market UI to the host. This layer only
3
+ * parses requests, calls the service modules, and serializes responses —
4
+ * process spawning lives in dsh-cli.ts, filesystem reads in profile.ts,
5
+ * orchestration in install.ts / themes.ts / updates.ts.
6
+ *
7
+ * Security: the install route executes a shell command, so it accepts only
8
+ * same-origin POSTs and only sources present in the curated registry.
9
+ */
10
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { Readable } from 'node:stream';
13
+ import { load as loadYaml } from 'js-yaml';
14
+ import { forgetCatalog, loadRegistry, pluginCategories } from './registry.js';
15
+ import { cleanHotDir, hotMount, hotUnmount, listHotMounts, MAX_FAVORITES, MAX_NOTE, mountClientOnlyDeps, purgeMarketState, readMarketState, writeMarketState, } from './hot.js';
16
+ import { createGroup, deleteGroup, removeFromGroups, renameGroup, setGroupMembers } from './groups.js';
17
+ import { dshHostInfo } from './dsh-install.js';
18
+ import { deriveHostCompatibility, DiscoveryManifestIndex } from './discovery-compatibility.js';
19
+ import { configurePersistentLog, exportLogs, logEvent, readPersistentLog } from './log.js';
20
+ import { marketFetch } from './net.js';
21
+ import { diagnosePackageManifests } from './diagnostics.js';
22
+ import { BOOT_ID, cancelActive, probePnpm, progress, provisionPnpm, runDshPlugin, TARGET_RE, } from './dsh-cli.js';
23
+ import { addProfileBundle, dropFromManifest, hasLoadableEntry, holdsNativeAddon, INBOX_BUNDLES, isDshProfileName, profileDir, readInstalled, readInstalledManifest, readInstalledRepoEvidence, readInstalledVersion, readLockCommits, readProfileBundles, readProfileManifestSnapshot, removeProfileBundle, restoreProfileManifest, setAllowBuilds } from './profile.js';
24
+ import { assessProfile, classifyPeer, introducedDuplicateNames, introducedRisks } from './compatibility.js';
25
+ import { runningAgentIds } from './agents.js';
26
+ import { analyzeProfile, corePackageNames } from './check.js';
27
+ import { applyBundleOrder, mergeOrder, readBundleRules, readBundleStack, validateOrder } from './order.js';
28
+ import { applyPreset, deletePreset, listPresets, previewPreset, savePreset } from './presets.js';
29
+ import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnapshots, restoreSnapshot } from './snapshot.js';
30
+ import { trialValidate } from './trial.js';
31
+ import { codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitUpdateTarget, installTargetFor, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.js';
32
+ import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuilds, parsePrepareNotAllowed, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
33
+ import { asChannel, CHANNELS, DIST_TAG, resolveChannel } from './channels.js';
34
+ import { asRegion, githubProxyManaged, normalizeGithubProxy, REGIONS, routesFor, setActiveRegion, setCustomGithubProxy, } from './regions.js';
35
+ import { resolveRegion } from './region-probe.js';
36
+ import { acceleratedTarget, resolveHeadCommit } from './accelerate.js';
37
+ import { updateNotesFor } from './changelog.js';
38
+ import { checkUpdates, compareVersions, fetchNpmLatest, invalidateUpdates, isUpgrade, latestPublishedRecently, setUpdateRegistry, versionOnChannel } from './updates.js';
39
+ import { createThemeManager } from './themes.js';
40
+ import { readJsonBody, sameOrigin, sendJson } from './http.js';
41
+ import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest } from './restart.js';
42
+ import { activationAfterReplace, brokenClientBundles, checkClientBundle, hasHostHalf, newlyBrokenBundles, verifyActivation } from './verify.js';
43
+ import { carrierDisableIds, disableRow, enableRow, findUserPatchPath, isProtectedModule, packagePatchFlags, readUserPatchState, removeRowBlocks, rowIdsForPackage, userPatchPackageReferences, } from './patch.js';
44
+ import { createProfileBackup, downloadWebdav, MAX_BACKUP_BYTES, mergeRestoreManifest, restoreProfileBackup, unportableDeps, uploadWebdav, } from './backup.js';
45
+ import { createGist, fitsGistLimit, GistError, gistErrorCode, parseGistId, readGist, resolveGistTokenSource, updateGist, verifyGistToken, } from './gist.js';
46
+ import { MAX_UPDATE_OPERATIONS_V1, UpdateOperationStoreV1, UPDATE_API_V1_SCHEMA } from './update-api-v1.js';
47
+ import { findGitToNpmMigration } from './source-migration.js';
48
+ /**
49
+ * Recognize the documented GitHub Release-download target shape. This does
50
+ * not authorize a new URL: install trust remains catalog-bound in sources.ts,
51
+ * while rollback only re-adds the exact direct URL already present in this
52
+ * profile before the update. Keep it route-local and independent of any
53
+ * unverified Desktop sidecar.
54
+ */
55
+ function isGitHubReleaseTarballSpec(spec) {
56
+ try {
57
+ const url = new URL(spec);
58
+ if (url.protocol !== 'https:' || url.hostname !== 'github.com')
59
+ return false;
60
+ const segments = url.pathname.split('/').filter(segment => segment !== '');
61
+ return segments.length >= 6
62
+ && segments[2] === 'releases'
63
+ && segments[3] === 'download'
64
+ && (url.pathname.endsWith('.tgz') || url.pathname.endsWith('.tar.gz'));
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
70
+ /**
71
+ * The market's own version, read once from its installed package.json.
72
+ *
73
+ * The UI puts this in the page heading so a user's screenshot carries it:
74
+ * most bug reports arrive as a photo of the screen, and without a version
75
+ * in frame the first reply always has to ask which one it was.
76
+ */
77
+ let cachedVersion = null;
78
+ export function marketVersion() {
79
+ if (cachedVersion !== null)
80
+ return cachedVersion;
81
+ try {
82
+ const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
83
+ cachedVersion = manifest.version ?? 'unknown';
84
+ }
85
+ catch {
86
+ cachedVersion = 'unknown';
87
+ }
88
+ return cachedVersion;
89
+ }
90
+ /** The current package name plus legacy names accepted from existing profiles. */
91
+ const SELF_NAME_ORDER = ['@chatcode/cco-market', 'dshmarket', 'dsh-market'];
92
+ const SELF_NAMES = new Set(SELF_NAME_ORDER);
93
+ const isSelfName = (name) => SELF_NAMES.has(name);
94
+ /**
95
+ * Rebuild a GitHub target for an update.
96
+ *
97
+ * A commit pin is dropped so pnpm resolves the repository again — that is the
98
+ * whole point of asking for an update. One valid `path:` selector is kept
99
+ * because it identifies the package inside a monorepo; pnpm permits both in
100
+ * one fragment (`#main&path:/packages/plugin`).
101
+ *
102
+ * A BRANCH or tag is kept, which used to be the same case as a commit and
103
+ * was not (#446 by @Dave-12138). `github:owner/repo#publish` names the line
104
+ * of development the user installed from; dropping it silently moved them to
105
+ * the default branch on the next update — a source change wearing the word
106
+ * "update". A 40-character hex selector is a pin worth discarding, and
107
+ * anything else is a choice worth preserving. A short hex string stays too:
108
+ * it is indistinguishable from a branch named `abc1234`, and keeping a pin
109
+ * by mistake only means the update is a no-op, while dropping a branch by
110
+ * mistake reinstalls different code.
111
+ */
112
+ function githubUpdateTarget(spec) {
113
+ const fragmentAt = spec.indexOf('#');
114
+ if (fragmentAt === -1)
115
+ return spec;
116
+ const repo = spec.slice(0, fragmentAt);
117
+ let subpath = null;
118
+ let ref = null;
119
+ for (const selector of spec.slice(fragmentAt + 1).split('&')) {
120
+ if (!selector.startsWith('path:/')) {
121
+ // `semver:<range>` selects a release line, so it is preserved for the
122
+ // same reason a branch is.
123
+ const isCommitPin = /^[0-9a-f]{40}$/i.test(selector);
124
+ if (selector !== '' && !isCommitPin) {
125
+ // Two refs in one fragment is not a shape pnpm produces; refuse to
126
+ // guess which one the user meant and fall back to the bare repo.
127
+ if (ref !== null)
128
+ return repo;
129
+ ref = selector;
130
+ }
131
+ continue;
132
+ }
133
+ const candidate = selector.slice('path:/'.length);
134
+ const valid = /^[A-Za-z0-9_./-]+$/.test(candidate)
135
+ && !candidate.split('/').some(segment => segment === '' || segment === '.' || segment === '..');
136
+ // Multiple path selectors are ambiguous; an invalid selector is never
137
+ // forwarded to the package manager from a hand-edited profile.
138
+ if (subpath !== null || !valid)
139
+ return repo;
140
+ subpath = candidate;
141
+ }
142
+ const selectors = [...(ref === null ? [] : [ref]), ...(subpath === null ? [] : [`path:/${subpath}`])];
143
+ return selectors.length === 0 ? repo : `${repo}#${selectors.join('&')}`;
144
+ }
145
+ /**
146
+ * Whether an installed package declares a client part (`dsh.client`). Its UI
147
+ * is injected into the page, so toggling it needs a browser refresh to show
148
+ * the change — the install flow prompts the same way via the hot banner.
149
+ */
150
+ function packageHasClientPart(profileDirectory, name) {
151
+ try {
152
+ const manifest = JSON.parse(readFileSync(join(profileDirectory, 'node_modules', name, 'package.json'), 'utf8'));
153
+ return manifest.dsh?.client !== undefined;
154
+ }
155
+ catch {
156
+ return false;
157
+ }
158
+ }
159
+ /**
160
+ * Packages whose build scripts pnpm refused to run, from any of its three
161
+ * reporting shapes: the structured ndjson event (pnpm 11), the human
162
+ * "Ignored build scripts:" line, or the fetcher's git-prepare rejection —
163
+ * which fires BEFORE the package lands in node_modules (#68). Undefined when
164
+ * none, so the field can be spread straight into a JSON response.
165
+ */
166
+ function blockedBuilds(result) {
167
+ if (Array.isArray(result.ignoredBuilds) && result.ignoredBuilds.length > 0)
168
+ return result.ignoredBuilds;
169
+ const list = parseIgnoredBuilds(result.stdout, result.stderr);
170
+ if (list.length > 0)
171
+ return list;
172
+ const pending = parsePrepareNotAllowed(result.stdout, result.stderr);
173
+ return pending !== null ? [pending] : undefined;
174
+ }
175
+ /**
176
+ * Register the market's HTTP routes.
177
+ * @param host - Acquired webServer + shell services.
178
+ * @param config - Validated market configuration.
179
+ * @returns Disposer removing every registered route.
180
+ */
181
+ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
182
+ let disposed = false;
183
+ // An ordinary profile must resolve under DSH_HOME by the same rules as the
184
+ // DSH CLI. A host-authoritative explicit directory (DSH Desktop) does not
185
+ // derive a path from this display/profile name.
186
+ if (config.profileDirectory === undefined && !isDshProfileName(config.profile)) {
187
+ // Loud on the way out. This throw happens inside a cordis effect, which
188
+ // swallows it: the routes silently never mount and EVERY /dsh-market/*
189
+ // request answers 404 with nothing anywhere saying why — the market
190
+ // simply looks broken (#260 by @realguan). The log line is the only
191
+ // thing that turns that into something diagnosable, so it is written
192
+ // before the throw rather than left to a handler that never runs.
193
+ const message = `ChatCode CLI Market: invalid profile name ${JSON.stringify(config.profile)}; the market's routes were not mounted and every /dsh-market/* request will answer 404. Use the same non-empty, non-traversal profile name accepted by ChatCode CLI, or pass an explicit profile directory.`;
194
+ host.logger?.warn(`[ChatCode CLI Market] ${message}`);
195
+ logEvent('error', 'mount', message);
196
+ throw new Error(message);
197
+ }
198
+ const activeProfileDir = profileDir(config.profile, config.profileDirectory);
199
+ const persistentLogFile = join(activeProfileDir, '.dsh-market', 'log.ndjson');
200
+ const discoveryManifests = new DiscoveryManifestIndex(join(activeProfileDir, '.dsh-market', 'discovery-compatibility-v1.json'));
201
+ configurePersistentLog(persistentLogFile);
202
+ let agentGuardUnavailableLogged = false;
203
+ /** Running-agent ids for the mutation gate; logs once when the host exposes no agents service. */
204
+ const runningAgentsForGuard = () => {
205
+ const service = agentsLookup?.();
206
+ const ids = runningAgentIds(service);
207
+ if (service === undefined && !agentGuardUnavailableLogged) {
208
+ agentGuardUnavailableLogged = true;
209
+ logEvent('warn', 'agent-guard', 'host exposes no agents service — mutations are not guarded while agents run');
210
+ }
211
+ return ids;
212
+ };
213
+ /** Whether the host exposes a usable agents service (readable in /status). */
214
+ const agentsGuardAvailable = () => {
215
+ const service = agentsLookup?.();
216
+ if (service === undefined)
217
+ return false;
218
+ try {
219
+ return Array.isArray(service.list());
220
+ }
221
+ catch {
222
+ return false;
223
+ }
224
+ };
225
+ // The profile's user patch layer (cordis.patch.yml): toggles are written
226
+ // here so DSH's own HMR re-composes the tree (no restart) and the loader
227
+ // re-applies the same choice on every boot (ported from dsh-plugin-hub).
228
+ const userPatchPath = findUserPatchPath(host, activeProfileDir);
229
+ const commands = commandRuntime ?? { runPlugin: runDshPlugin, probePnpm, provisionPnpm, cancelActive };
230
+ const supportsExactRollbackTarget = (target) => commands.supportsExactRollbackTarget?.(target) ?? TARGET_RE.test(target);
231
+ // Snapshot retention cap (issue #98 supplement): a finite positive number
232
+ // from the market config wins; anything else falls back to the default.
233
+ const maxSnapshots = typeof config.maxSnapshots === 'number' && Number.isFinite(config.maxSnapshots) && config.maxSnapshots >= 1
234
+ ? Math.floor(config.maxSnapshots)
235
+ : DEFAULT_MAX_SNAPSHOTS;
236
+ // Boot-time wipe: stale hot-mount inputs from a previous session must never
237
+ // survive into a composition where the bundle layer already covers them.
238
+ cleanHotDir(activeProfileDir);
239
+ // The user's persisted choices: the generic disable list (legacy
240
+ // disabledSkins loads transparently) plus custom groups. Every toggle,
241
+ // group, install and uninstall mutates this shared state and persists it.
242
+ const marketState = readMarketState(activeProfileDir);
243
+ setCustomGithubProxy(marketState.githubProxy ?? null);
244
+ const disabled = marketState.disabled;
245
+ /**
246
+ * Packages whose files were replaced while their host half was already
247
+ * running, this process only.
248
+ *
249
+ * Replacing a package on disk does not unload the module Node has already
250
+ * imported — measured against a real host in tests/web/update.e2e.ts, where
251
+ * 2.0.0 is on disk and the process keeps answering as 1.0.0. The update
252
+ * REPLY says so, but the installed listing recomputed activation from the
253
+ * loader's inventory alone, and the loader still lists the name: so the
254
+ * moment the page refreshed, a plugin serving its old build read as `live`
255
+ * and the restart notice vanished.
256
+ *
257
+ * Deliberately not persisted. What it records is a fact about THIS
258
+ * process, and a restart — the thing that resolves it — ends the process.
259
+ */
260
+ const replacedWhileLive = new Set();
261
+ const groups = marketState.groups;
262
+ const groupOrder = marketState.groupOrder;
263
+ // A choice made in a previous session outranks whatever the entry layer
264
+ // composed, which is only ever a default.
265
+ if (marketState.channel !== undefined)
266
+ config.channel = marketState.channel;
267
+ const activeChannel = () => resolveChannel(config.channel, marketVersion());
268
+ // The download region: which mirrors every outbound request uses.
269
+ //
270
+ // `global` until something decides otherwise, so nothing waits on the
271
+ // network to start serving. A machine with no region on record gets one
272
+ // probed in the background below; a machine that already has one is
273
+ // routed immediately.
274
+ if (marketState.region !== undefined)
275
+ config.region = marketState.region;
276
+ let region = config.region ?? 'global';
277
+ let regionAuto = marketState.regionAuto === true;
278
+ const applyRegion = (next) => {
279
+ region = next;
280
+ // The shared holder every reader consults, plus the one consumer that
281
+ // must also DROP state on a change: update answers gathered from the
282
+ // other registry are not this registry's answers.
283
+ setActiveRegion(next);
284
+ setUpdateRegistry(routesFor(next).npmRegistry);
285
+ };
286
+ applyRegion(region);
287
+ // Probe only when NOTHING has decided a region — not the saved state, and
288
+ // not the composition either. An operator who wrote `region:` into their
289
+ // profile has answered the question the probe exists to ask, and measuring
290
+ // over the top of that answer would quietly override a deliberate choice a
291
+ // few seconds after boot.
292
+ if (config.region === undefined) {
293
+ void resolveRegion(undefined).then(({ region: probed }) => {
294
+ // A manual choice made while the probe was pending, or a replacement
295
+ // mount created after this one was disposed, owns the region now.
296
+ if (disposed || config.region !== undefined)
297
+ return;
298
+ applyRegion(probed);
299
+ regionAuto = true;
300
+ // Persisted as the decision, not re-probed each boot: a market that
301
+ // silently changes routes between runs makes "it was fast yesterday"
302
+ // impossible to investigate.
303
+ marketState.region = probed;
304
+ marketState.regionAuto = true;
305
+ config.region = probed;
306
+ writeMarketState(activeProfileDir, marketState);
307
+ // The listing was fetched before the region was known.
308
+ forgetCatalog();
309
+ invalidateUpdates();
310
+ }).catch(() => { });
311
+ }
312
+ const themes = createThemeManager(host, config.profile, disabled, activeProfileDir);
313
+ /**
314
+ * Re-sync the live closure state from disk. Snapshot restore writes
315
+ * state.json directly (it must, to survive the next boot), which would
316
+ * leave this in-memory `disabled`/`groups`/`groupOrder` stale —
317
+ * the next toggle/groups write would then overwrite the restored values.
318
+ * The objects are mutated in place (clear + refill) so every captured
319
+ * reference (themes manager, live handlers) sees the fresh state (issue
320
+ * #98 review M2).
321
+ */
322
+ function refreshMarketState() {
323
+ const fresh = readMarketState(activeProfileDir);
324
+ disabled.clear();
325
+ for (const name of fresh.disabled)
326
+ disabled.add(name);
327
+ for (const key of Object.keys(groups))
328
+ delete groups[key];
329
+ Object.assign(groups, fresh.groups);
330
+ groupOrder.length = 0;
331
+ groupOrder.push(...fresh.groupOrder);
332
+ // The three above are aliased objects other closures hold, so they are
333
+ // mutated in place. These three are read off `marketState` itself and
334
+ // were not being refreshed at all — which is #435: a note written
335
+ // through this route reached disk, but `marketState.notes` still held
336
+ // the empty object from boot, and the next write from that object put
337
+ // the empty one back. The note survived a page reload (disk was right)
338
+ // and vanished later, which is exactly what the reporter described.
339
+ marketState.notes = fresh.notes;
340
+ marketState.channel = fresh.channel;
341
+ marketState.region = fresh.region;
342
+ marketState.regionAuto = fresh.regionAuto;
343
+ marketState.favorites = fresh.favorites;
344
+ marketState.githubProxy = fresh.githubProxy;
345
+ setCustomGithubProxy(fresh.githubProxy ?? null);
346
+ }
347
+ // Client-only packages (dsh.client without dsh.bundle) are invisible to the
348
+ // bundle layer in every boot; the market shim-mounts them so their client
349
+ // bundles are actually served.
350
+ void mountClientOnlyDeps(host, activeProfileDir).then(async (mounted) => {
351
+ if (mounted.length > 0)
352
+ logEvent('info', 'boot', `client-only shims mounted: ${mounted.join(', ')}`);
353
+ // Replay the persisted disable list: bundle-layer plugins the user
354
+ // switched away from get live-disabled again (bundle trees are
355
+ // in-memory, so the disable never persists on its own). Client-only
356
+ // shims for disabled plugins were already skipped by mountClientOnlyDeps.
357
+ for (const name of disabled) {
358
+ if (await themes.setEntryDisabled(name, true))
359
+ logEvent('info', 'boot', `plugin kept off: ${name}`);
360
+ }
361
+ });
362
+ // Self-healing guard: dsh's own patch overlay can re-update entries during
363
+ // activation and wipe the runtime disabled flag — whenever a fiber comes
364
+ // up for a plugin the user switched off, put it back down.
365
+ host.on?.('internal/plugin', (fiber) => {
366
+ const name = fiber.entry?.options?.name;
367
+ if (name !== undefined && disabled.has(name))
368
+ void themes.setEntryDisabled(name, true);
369
+ });
370
+ let installing = false;
371
+ let restarting = false;
372
+ // UI-state flags ONLY: mutual exclusion is enforced by withMutationLock
373
+ // below (one promise chain every mutating route appends to), never by
374
+ // these booleans — a promise-chain serialization cannot be raced by
375
+ // interleaved awaits, and a second mutating request answers 409
376
+ // immediately instead of queueing (issue #125 review).
377
+ let writing = false;
378
+ let mutationBusy = false;
379
+ /** The shared mutation chain: every mutating operation appends to it. */
380
+ let mutationChain = Promise.resolve();
381
+ /**
382
+ * Append a lightweight state write to the mutation chain without answering
383
+ * 409 when another operation is in flight. Favorites are catalog bookmarks
384
+ * only — they must stay editable while an install runs (#414).
385
+ */
386
+ async function withMutationQueued(fn) {
387
+ const run = mutationChain.then(async () => fn());
388
+ mutationChain = run.catch(() => undefined);
389
+ return await run;
390
+ }
391
+ /**
392
+ * Run a mutating operation under the shared mutation lock. `kind` selects
393
+ * the UI busy flag (`install` = pnpm operation, `write` = direct profile
394
+ * write) and the 409 message. The operation runs only after every earlier
395
+ * mutation settled (promise chain); while one is in flight a second
396
+ * mutating request answers 409 immediately — the UI polls /status for the
397
+ * busy flag instead of queueing (issue #125 review).
398
+ * @returns the operation's value, or null when the lock was busy (409 sent).
399
+ */
400
+ async function withMutationLock(response, kind, fn) {
401
+ if (mutationBusy) {
402
+ sendJson(response, 409, {
403
+ error: kind === 'install' ? 'another install is already running' : 'another plugin operation is running',
404
+ });
405
+ return null;
406
+ }
407
+ mutationBusy = true;
408
+ if (kind === 'install')
409
+ installing = true;
410
+ else
411
+ writing = true;
412
+ try {
413
+ const run = mutationChain.then(async () => fn());
414
+ mutationChain = run.catch(() => undefined);
415
+ return await run;
416
+ }
417
+ finally {
418
+ mutationBusy = false;
419
+ if (kind === 'install')
420
+ installing = false;
421
+ else
422
+ writing = false;
423
+ }
424
+ }
425
+ /** Dependency diff vs. a pre-operation snapshot (cancel aftermath). */
426
+ function changedSince(before) {
427
+ const now = readInstalled(config.profile, activeProfileDir);
428
+ const changed = new Set();
429
+ for (const [name, spec] of Object.entries(now))
430
+ if (before[name] !== spec)
431
+ changed.add(name);
432
+ for (const name of Object.keys(before))
433
+ if (now[name] === undefined)
434
+ changed.add(name);
435
+ return { changed: [...changed], partial: changed.size > 0 };
436
+ }
437
+ /**
438
+ * Apply one enable/disable request: persist the choice in state.json, then
439
+ * drive the live composition. Covers every mount form — hot mounts and
440
+ * client-only shims go through hotUnmount/hotMount, bundle-layer entries
441
+ * through setEntryDisabled. Enabling a THEME goes through the caller's
442
+ * activateTheme instead so the Themes tab's exclusivity stays intact.
443
+ */
444
+ async function setPluginEnabled(name, enabled) {
445
+ const dir = activeProfileDir;
446
+ if (enabled)
447
+ disabled.delete(name);
448
+ else
449
+ disabled.add(name);
450
+ let ok;
451
+ let reason;
452
+ if (enabled) {
453
+ if (listHotMounts().includes(name)) {
454
+ ok = true;
455
+ }
456
+ else if (await themes.setEntryDisabled(name, false)) {
457
+ ok = true;
458
+ }
459
+ else {
460
+ const result = await hotMount(host, dir, name);
461
+ ok = result.ok;
462
+ reason = result.reason ?? undefined;
463
+ // A mount that succeeded imported the module as it is on disk NOW,
464
+ // so whatever was replaced under the old instance is no longer what
465
+ // this process is serving. Off-and-on is a real way out of the
466
+ // restart notice, and holding it after that would be wrong.
467
+ if (result.ok)
468
+ replacedWhileLive.delete(name);
469
+ }
470
+ }
471
+ else {
472
+ ok = await hotUnmount(name) || await themes.setEntryDisabled(name, true);
473
+ if (!ok) {
474
+ // Nothing was live (boot-skipped client shim, user-patch-managed
475
+ // entry, or already off): the persisted flag is the contract.
476
+ ok = true;
477
+ }
478
+ }
479
+ writeMarketState(dir, { disabled, groups, groupOrder });
480
+ return { ok, reason };
481
+ }
482
+ /**
483
+ * Everything live in the running composition: market hot mounts plus
484
+ * bundle-layer loader entries whose fiber is up (loaded at boot). This is
485
+ * the source of truth for verifyActivation's `live` state — without the
486
+ * loader side, every boot-loaded bundle plugin would read as "restart".
487
+ */
488
+ function liveNames() {
489
+ const live = new Set(listHotMounts());
490
+ for (const entry of host.loader.entries()) {
491
+ if (entry.fiber === undefined)
492
+ continue;
493
+ if (entry.options.name !== undefined)
494
+ live.add(entry.options.name);
495
+ // Entry IDS too, under a `#` prefix that cannot collide with a package
496
+ // name. A CARRIER bundle's row names the package it mounts, not
497
+ // itself (#156: @tt-a1i/archify-dsh inserts an entry named
498
+ // @deepseek-ai/dsh-skill-filesystem), so its own name never appears
499
+ // here — but the id it created does, and that id is unique to its
500
+ // patch. Verification needs both, and putting them in one set means
501
+ // no call site can pass the names and forget the ids.
502
+ if (entry.options.id !== undefined && entry.options.id !== '') {
503
+ live.add(`#${entry.options.id}`);
504
+ // Loader ids may carry an include prefix (`include:archify-…`).
505
+ const bare = entry.options.id.split(':').pop();
506
+ if (bare !== undefined && bare !== entry.options.id)
507
+ live.add(`#${bare}`);
508
+ }
509
+ }
510
+ return live;
511
+ }
512
+ /**
513
+ * Drop live hot mounts whose package was removed outside the market
514
+ * (e.g. `dsh plugin remove` in a terminal): the stale mount would keep
515
+ * serving a client bundle that 404s after refresh, wedging the page
516
+ * until a restart (#29 by @SunYanbox).
517
+ */
518
+ async function dropStaleHotMounts() {
519
+ for (const name of listHotMounts()) {
520
+ if (existsSync(join(activeProfileDir, 'node_modules', name, 'package.json')))
521
+ continue;
522
+ await hotUnmount(name);
523
+ logEvent('warn', 'hot-sweep', `${name}: package removed outside the market — live mount dropped`);
524
+ }
525
+ }
526
+ /** Every plugin command goes through the pnpm-drift recovery wrapper (#20). */
527
+ const runPlugin = (profile, args) => withHoistRecovery(commands.runPlugin, profile, args, activeProfileDir);
528
+ /**
529
+ * Undo a clean-exit update whose new build cannot boot. Restoring only the
530
+ * manifest pin (the original #159 behavior) leaves the bad package files
531
+ * on disk, and the boot resolves bundle patches from node_modules — the
532
+ * next start still fails. Re-run pnpm install against the restored
533
+ * manifest to rematerialize the previous build's files.
534
+ */
535
+ async function rollbackUpdateBuild(name, manifestBefore, rematerializeWhenManifestUnchanged = false) {
536
+ const rolledBack = restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
537
+ if (rolledBack.length === 0 && !rematerializeWhenManifestUnchanged)
538
+ return { ok: true, detail: null };
539
+ // CI=true (the market always runs pnpm that way) turns frozen-lockfile
540
+ // on, and the restored manifest pin now disagrees with the lockfile the
541
+ // bad add just wrote — without the flag this restore run fails with
542
+ // ERR_PNPM_OUTDATED_LOCKFILE (measured). The age override lets pnpm
543
+ // re-resolve a previous release that is still inside its fresh window.
544
+ // Flags come BEFORE the command: preparePluginArgs treats the last arg as
545
+ // the package target and rejects a trailing flag, while pnpm accepts the
546
+ // same flags in front of `install`.
547
+ const reinstall = await runPlugin(config.profile, ['--no-frozen-lockfile', RELEASE_AGE_OVERRIDE, 'install']);
548
+ const ok = reinstall.exitCode === 0 && !reinstall.timedOut && !reinstall.cancelled;
549
+ if (ok)
550
+ logEvent('info', 'update', `${name}: previous build rematerialized (${rolledBack.join(', ')})`);
551
+ return { ok, detail: ok ? null : failureDetail(reinstall) };
552
+ }
553
+ /**
554
+ * Discriminated preflight for rollback state. readProfileManifestSnapshot
555
+ * intentionally degrades read/parse failures to an empty profile for
556
+ * diagnostics callers; an update must never mistake that fabricated empty
557
+ * value for a rollback snapshot and erase real dependencies.
558
+ */
559
+ function captureUpdateManifest() {
560
+ const file = join(activeProfileDir, 'package.json');
561
+ try {
562
+ const value = JSON.parse(readFileSync(file, 'utf8'));
563
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
564
+ return { ok: false, detail: 'the profile package.json root is not an object' };
565
+ }
566
+ const manifest = value;
567
+ if (manifest.dependencies !== undefined && (typeof manifest.dependencies !== 'object'
568
+ || manifest.dependencies === null
569
+ || Array.isArray(manifest.dependencies)
570
+ || Object.values(manifest.dependencies).some(spec => typeof spec !== 'string'))) {
571
+ return { ok: false, detail: 'the profile package.json dependency map is malformed' };
572
+ }
573
+ if (manifest.dsh !== undefined && (typeof manifest.dsh !== 'object'
574
+ || manifest.dsh === null
575
+ || Array.isArray(manifest.dsh))) {
576
+ return { ok: false, detail: 'the profile package.json dsh field is malformed' };
577
+ }
578
+ const dsh = typeof manifest.dsh === 'object' && manifest.dsh !== null && !Array.isArray(manifest.dsh)
579
+ ? manifest.dsh
580
+ : undefined;
581
+ if (dsh?.profile !== undefined && (typeof dsh.profile !== 'object'
582
+ || dsh.profile === null
583
+ || Array.isArray(dsh.profile))) {
584
+ return { ok: false, detail: 'the profile package.json dsh.profile field is malformed' };
585
+ }
586
+ const profile = typeof dsh?.profile === 'object' && dsh.profile !== null && !Array.isArray(dsh.profile)
587
+ ? dsh.profile
588
+ : undefined;
589
+ return {
590
+ ok: true,
591
+ snapshot: {
592
+ dependencies: { ...manifest.dependencies },
593
+ profileBundles: profile !== undefined && Object.hasOwn(profile, 'bundles')
594
+ ? { present: true, value: structuredClone(profile.bundles) }
595
+ : { present: false },
596
+ },
597
+ };
598
+ }
599
+ catch (error) {
600
+ return { ok: false, detail: `the profile package.json could not be read: ${error instanceof Error ? error.message : String(error)}` };
601
+ }
602
+ }
603
+ /** Exact pnpm importer state paired with one pre-update manifest snapshot. */
604
+ function captureProfileLockfile() {
605
+ const file = join(activeProfileDir, 'pnpm-lock.yaml');
606
+ try {
607
+ return { ok: true, snapshot: { present: true, contents: readFileSync(file) } };
608
+ }
609
+ catch (error) {
610
+ if (error.code === 'ENOENT') {
611
+ return { ok: true, snapshot: { present: false } };
612
+ }
613
+ const detail = error instanceof Error ? error.message : String(error);
614
+ return {
615
+ ok: false,
616
+ detail: `更新前无法读取 pnpm-lock.yaml,因此自动回滚不可用:${detail} / The pre-update pnpm-lock.yaml could not be read, so automatic rollback is unavailable: ${detail}`,
617
+ };
618
+ }
619
+ }
620
+ /** Resolved npm version for one dependency in a captured pnpm v9 importer. */
621
+ function capturedNpmVersion(snapshot, name) {
622
+ if (!snapshot.present)
623
+ return null;
624
+ try {
625
+ const parsed = loadYaml(snapshot.contents.toString('utf8'));
626
+ const dependency = parsed?.importers?.['.']?.dependencies?.[name];
627
+ const raw = typeof dependency === 'string' ? dependency : dependency?.version;
628
+ if (typeof raw !== 'string')
629
+ return null;
630
+ return /^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?:\(|$)/.exec(raw)?.[1] ?? null;
631
+ }
632
+ catch {
633
+ return null;
634
+ }
635
+ }
636
+ /** Restore lockfile bytes atomically, or restore the fact it was absent. */
637
+ function restoreProfileLockfile(snapshot) {
638
+ const file = join(activeProfileDir, 'pnpm-lock.yaml');
639
+ if (!snapshot.present) {
640
+ try {
641
+ rmSync(file, { force: true });
642
+ return { ok: true, detail: null };
643
+ }
644
+ catch (error) {
645
+ return { ok: false, detail: `the newly created lockfile could not be removed: ${error instanceof Error ? error.message : String(error)}` };
646
+ }
647
+ }
648
+ const temp = `${file}.dsh-market-rollback-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
649
+ try {
650
+ writeFileSync(temp, snapshot.contents);
651
+ renameSync(temp, file);
652
+ return { ok: true, detail: null };
653
+ }
654
+ catch (error) {
655
+ try {
656
+ rmSync(temp, { force: true });
657
+ }
658
+ catch { /* best-effort temp cleanup */ }
659
+ return { ok: false, detail: `the pre-update lockfile could not be restored: ${error instanceof Error ? error.message : String(error)}` };
660
+ }
661
+ }
662
+ /**
663
+ * Re-add one immutable source while restoring both durable manifest spelling
664
+ * and the exact pre-update importer/lock resolution around the command.
665
+ * The second lock restore is load-bearing for floating tags: real pnpm 11
666
+ * rewrites `latest` to an exact specifier during the add, and putting only
667
+ * package.json back makes the next frozen install reject the profile.
668
+ */
669
+ async function rollbackExactTarget(name, manifestBefore, lockfileBefore, target, keepRepairedLock = false) {
670
+ restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
671
+ const preparedLock = restoreProfileLockfile(lockfileBefore);
672
+ if (!preparedLock.ok)
673
+ return preparedLock;
674
+ let finalLockError = null;
675
+ // The lock can already name the old identity while pnpm's package bytes
676
+ // were replaced before the rejected update failed. A normal exact add is
677
+ // then an "already up to date" no-op; --force is what rematerializes the
678
+ // captured version/commit/archive instead of blessing corrupted bytes.
679
+ const add = await runPlugin(config.profile, ['add', '--force', RELEASE_AGE_OVERRIDE, target]);
680
+ // Exact recovery targets deliberately pin versions/commits. Keep the
681
+ // user's durable range, tag, floating github shortcut, or release URL.
682
+ restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
683
+ // When an independently authoritative old identity (an installed npm
684
+ // version or pinned Git manifest) disagrees with a missing/stale lock,
685
+ // keep the exact add's repaired OLD resolution. Floating sources instead
686
+ // need their captured importer/lock bytes back.
687
+ if (!keepRepairedLock || add.exitCode !== 0 || add.timedOut || add.cancelled) {
688
+ const restoredLock = restoreProfileLockfile(lockfileBefore);
689
+ finalLockError = restoredLock.detail;
690
+ }
691
+ if (finalLockError !== null)
692
+ return { ok: false, detail: finalLockError };
693
+ if (add.exitCode !== 0 || add.timedOut || add.cancelled) {
694
+ return { ok: false, detail: failureDetail(add) };
695
+ }
696
+ if (!hasLoadableEntry(activeProfileDir, name)) {
697
+ return { ok: false, detail: 'the previous source was reinstalled without a loadable entry' };
698
+ }
699
+ return { ok: true, detail: null };
700
+ }
701
+ /** Restore the exact npm build that was on disk before the update. */
702
+ async function rollbackNpmBuild(name, manifestBefore, beforeVersion, lockfileBefore) {
703
+ const rollback = await rollbackExactTarget(name, manifestBefore, lockfileBefore, `${name}@${beforeVersion}`);
704
+ if (!rollback.ok)
705
+ return rollback;
706
+ const restoredVersion = readInstalledVersion(config.profile, name, activeProfileDir);
707
+ if (restoredVersion !== beforeVersion) {
708
+ return { ok: false, detail: `expected v${beforeVersion} after rollback, found v${restoredVersion ?? 'unknown'}` };
709
+ }
710
+ logEvent('info', 'update-rollback', `${name}: restored npm build v${beforeVersion}`);
711
+ return { ok: true, detail: null };
712
+ }
713
+ function exactGitRollbackTarget(target, beforeCommit) {
714
+ return githubCommitOfTarget(target) === beforeCommit
715
+ ? target
716
+ : githubTargetAtCommit(target, beforeCommit);
717
+ }
718
+ /** Restore a github: update by re-adding the commit captured before it. */
719
+ async function rollbackGitBuild(name, manifestBefore, target, beforeCommit, lockfileBefore, keepRepairedLock) {
720
+ // Preserve an already immutable durable spelling (including a pinned
721
+ // codeload URL). Rewriting that source to github: while keeping the
722
+ // repaired lock would make its importer disagree with package.json.
723
+ // Floating shortcuts still need to be converted to an exact commit.
724
+ const rollbackTarget = exactGitRollbackTarget(target, beforeCommit);
725
+ if (rollbackTarget === null) {
726
+ return { ok: false, detail: 'the previous github target is invalid; nothing to roll back to' };
727
+ }
728
+ const rollback = await rollbackExactTarget(name, manifestBefore, lockfileBefore, rollbackTarget, keepRepairedLock);
729
+ if (!rollback.ok)
730
+ return rollback;
731
+ const repoKey = repoOfTarget(rollbackTarget)?.split('#')[0] ?? null;
732
+ const restoredCommit = repoKey === null
733
+ ? null
734
+ : readLockCommits(config.profile, activeProfileDir).get(repoKey.toLowerCase()) ?? null;
735
+ if (restoredCommit !== beforeCommit) {
736
+ return { ok: false, detail: `expected commit ${beforeCommit} after rollback, found ${restoredCommit ?? 'unknown'}` };
737
+ }
738
+ logEvent('info', 'update-rollback', `${name}: restored github build at ${beforeCommit}`);
739
+ return { ok: true, detail: null };
740
+ }
741
+ async function executeUpdateRollback(name, manifestBefore, source) {
742
+ if (source.kind === 'npm') {
743
+ return rollbackNpmBuild(name, manifestBefore, source.beforeVersion, source.lockfileBefore);
744
+ }
745
+ if (source.kind === 'github') {
746
+ return rollbackGitBuild(name, manifestBefore, source.target, source.beforeCommit, source.lockfileBefore, source.keepRepairedLock);
747
+ }
748
+ return rollbackUpdateBuild(name, manifestBefore, true);
749
+ }
750
+ const pendingRollbacks = new Map();
751
+ let rollbackSequence = 0;
752
+ function captureProfileStateFingerprint() {
753
+ try {
754
+ const packageJson = readFileSync(join(activeProfileDir, 'package.json'));
755
+ const lockfile = captureProfileLockfile();
756
+ return lockfile.ok ? { packageJson, lockfile: lockfile.snapshot } : null;
757
+ }
758
+ catch {
759
+ return null;
760
+ }
761
+ }
762
+ function sameProfileLockfile(left, right) {
763
+ if (left.present !== right.present)
764
+ return false;
765
+ return !left.present || (right.present && left.contents.equals(right.contents));
766
+ }
767
+ function profileStateMatches(expected) {
768
+ const current = captureProfileStateFingerprint();
769
+ return current !== null
770
+ && current.packageJson.equals(expected.packageJson)
771
+ && sameProfileLockfile(current.lockfile, expected.lockfile);
772
+ }
773
+ function savePendingRollback(record) {
774
+ const expectedState = captureProfileStateFingerprint();
775
+ if (expectedState === null)
776
+ return null;
777
+ const id = `rollback-${String(rollbackSequence++)}`;
778
+ pendingRollbacks.set(id, { ...record, id, expectedState });
779
+ return id;
780
+ }
781
+ async function removeInstalledPackage(name) {
782
+ // Asked BEFORE the removal, while the files are still there to look at.
783
+ // A native addon is never released by unloading (#441): Node has no
784
+ // dlclose, so the process keeps the `.node` open until it exits, and on
785
+ // Windows the next install of the same plugin fails renaming over it.
786
+ // Reporting this uninstall as `hot` would be claiming it took effect
787
+ // without a restart, which for these is exactly what did not happen —
788
+ // and the page then tells the user to refresh, which is the one thing
789
+ // that cannot help.
790
+ const native = holdsNativeAddon(config.profile, name, activeProfileDir);
791
+ const result = await runPlugin(config.profile, ['remove', name]);
792
+ if (result.exitCode !== 0 || result.timedOut || result.cancelled) {
793
+ return { ok: false, hot: false, detail: failureDetail(result) };
794
+ }
795
+ // Both cleanups run — see the uninstall route's note on #213: a package
796
+ // with two activation sources must not have the second one skipped
797
+ // because the first succeeded.
798
+ const unmounted = await hotUnmount(name);
799
+ const entryDisabled = await themes.setEntryDisabled(name, true);
800
+ const hot = (unmounted || entryDisabled) && !native;
801
+ if (native) {
802
+ logEvent('info', 'uninstall', `${name} ships or depends on a native addon; a restart is needed before it can be installed again`);
803
+ }
804
+ removeRowBlocks(userPatchPath, rowIdsForPackage(host, activeProfileDir, name));
805
+ disabled.delete(name);
806
+ replacedWhileLive.delete(name);
807
+ removeFromGroups({ groups, groupOrder }, name);
808
+ writeMarketState(activeProfileDir, { disabled, groups, groupOrder });
809
+ return { ok: true, hot, detail: null };
810
+ }
811
+ /**
812
+ * Errors the profile analysis reports about the restored composition —
813
+ * a bundle or a user-patch insert naming a package that is not in
814
+ * node_modules. #205: those surfaced only at the NEXT boot, as a Loader
815
+ * ERR_MODULE_NOT_FOUND with nothing tying it to the restore that caused it.
816
+ *
817
+ * Reported, never rolled back. A restore undone halfway can leave someone
818
+ * worse off than the state they were trying to leave, and after a
819
+ * cross-machine restore they still have the old machine to compare against.
820
+ * Naming the packages is what they cannot do for themselves.
821
+ *
822
+ * An analysis that throws is not allowed to fail a restore that already
823
+ * succeeded — the profile is on disk either way.
824
+ */
825
+ function restoredBootErrors() {
826
+ try {
827
+ return analyzeProfile(activeProfileDir).summary.errors;
828
+ }
829
+ catch (error) {
830
+ logEvent('warn', 'restore', `post-restore analysis failed: ${error instanceof Error ? error.message : String(error)}`);
831
+ return [];
832
+ }
833
+ }
834
+ /**
835
+ * Bundles the profile declares that will not resolve at boot.
836
+ *
837
+ * The boot loader reads `dsh.profile.bundles` and dies on the first name it
838
+ * cannot resolve — the whole profile, not just that plugin (#339). The
839
+ * rollback that leaves such a row behind is fixed, but the market issues
840
+ * one call and the host owns both writes, so this is the net under any
841
+ * write path that does the same thing next: check at the end of an
842
+ * operation instead of letting the next restart be the one to find out.
843
+ *
844
+ * Judged by the SAME analysis the diagnostics page uses, deliberately. A
845
+ * bundle can legitimately live in the dsh installation rather than the
846
+ * profile's node_modules (#316), and reimplementing that resolution here
847
+ * would call those orphans.
848
+ */
849
+ function orphanBundles() {
850
+ try {
851
+ return analyzeProfile(activeProfileDir).bundles
852
+ // Not an in-box bundle we merely could not locate (#369): those are
853
+ // supplied by the dsh installation, and failing to find one is a gap
854
+ // in what this process can see rather than a profile that will not
855
+ // start. Reporting them here rolled back a good update.
856
+ .filter(layer => layer.directory === null && layer.unresolvedInbox !== true)
857
+ .map(layer => layer.name);
858
+ }
859
+ catch (error) {
860
+ logEvent('warn', 'install', `bundle resolution check failed: ${error instanceof Error ? error.message : String(error)}`);
861
+ return [];
862
+ }
863
+ }
864
+ /**
865
+ * Whether a `#path:` target still points at a directory that exists.
866
+ *
867
+ * A catalog entry can name a monorepo subpackage that the author has since
868
+ * moved or renamed. pnpm's failure for that is unrecognisable — the user
869
+ * sees a resolver error and no reason to suspect the entry rather than
870
+ * their own machine (#346). Audited the live catalog while looking into
871
+ * it: 8 of the 224 subpath entries point at a directory that is gone.
872
+ *
873
+ * Only ever called AFTER an install has already failed, so the happy path
874
+ * pays nothing, and a network problem here just means no extra sentence.
875
+ */
876
+ async function staleSubpath(target) {
877
+ const match = /^github:([^#]+)#path:\/(.+)$/.exec(target);
878
+ if (match === null)
879
+ return null;
880
+ const [, repo, subpath] = match;
881
+ try {
882
+ const res = await marketFetch(`https://raw.githubusercontent.com/${repo}/HEAD/${subpath}/package.json`, { signal: AbortSignal.timeout(6000) });
883
+ if (res.ok)
884
+ return null;
885
+ if (res.status !== 404)
886
+ return null;
887
+ }
888
+ catch {
889
+ return null;
890
+ }
891
+ return `目录条目指向的子目录在仓库里已不存在(${repo} 的 ${subpath}),多半是作者改名或移动了它——这不是你的环境的问题。请到 awesome-dsh-plugin 反馈这条收录已失效。 / This catalog entry points at a subdirectory that no longer exists in the repository (${subpath} in ${repo}); the author most likely renamed or moved it. Nothing is wrong with your setup — please report the stale entry to awesome-dsh-plugin.`;
892
+ }
893
+ async function restoreBackup(value) {
894
+ if (!await probePnpm())
895
+ throw new Error('pnpm is required to restore plugins');
896
+ // Snapshot the target's manifest BEFORE the backup files overwrite it, so
897
+ // the restore can merge rather than replace: plugins the target already
898
+ // has that are NOT in the backup stay installed instead of silently
899
+ // dropping off the manifest (partial exports, issue #89). The mutation
900
+ // lock is owned by withMutationLock now, so no `installing` flag here.
901
+ const manifestBefore = JSON.parse(readFileSync(join(activeProfileDir, 'package.json'), 'utf8'));
902
+ const restored = restoreProfileBackup(config.profile, value, activeProfileDir);
903
+ try {
904
+ // Merge: current deps stay, backup specs win on name conflicts; bundle
905
+ // lists are unioned. Full exports merge to the backup view unchanged.
906
+ const mergedManifest = mergeRestoreManifest(JSON.parse(readFileSync(join(activeProfileDir, 'package.json'), 'utf8')), manifestBefore);
907
+ writeFileSync(join(activeProfileDir, 'package.json'), `${JSON.stringify(mergedManifest, null, 2)}\n`);
908
+ // Named BEFORE the install runs, because that is the install this
909
+ // will make fail: a `link:/Users/…` spec from another machine points
910
+ // at a path that does not exist here (#205). Reported rather than
911
+ // rewritten — where those files should live is the operator's call.
912
+ const unportable = unportableDeps(mergedManifest.dependencies);
913
+ if (unportable.length > 0) {
914
+ logEvent('warn', 'restore', `machine-specific dependency paths in the restored manifest — ${unportable.map(dep => `${dep.name}: ${dep.spec}`).join('; ')}`);
915
+ }
916
+ const result = await runPlugin(config.profile, ['install']);
917
+ if (result.exitCode === 0 && !result.timedOut && !result.cancelled) {
918
+ invalidateUpdates();
919
+ const bootErrors = restoredBootErrors();
920
+ if (bootErrors.length > 0) {
921
+ logEvent('warn', 'restore', `restored profile will not boot as-is — ${bootErrors.join('; ')}`);
922
+ }
923
+ return { files: restored.files, errors: [], unportable, ...(bootErrors.length > 0 ? { bootErrors } : {}) };
924
+ }
925
+ // A bad dependency makes pnpm abort the whole install. Retry from an
926
+ // empty dependency list so one broken plugin cannot block the rest.
927
+ // activeProfileDir, NOT profileDir(config.profile): in DSH Desktop the
928
+ // profile directory is host-authoritative (#72) and the ambient
929
+ // derivation would edit the WRONG profile's manifest.
930
+ const manifestFile = join(activeProfileDir, 'package.json');
931
+ const manifest = JSON.parse(readFileSync(manifestFile, 'utf8'));
932
+ const dependencies = Object.entries(manifest.dependencies ?? {});
933
+ const desiredBundles = [...(manifest.dsh?.profile?.bundles ?? [])];
934
+ const dependencyNames = new Set(dependencies.map(([name]) => name));
935
+ manifest.dependencies = {};
936
+ if (Array.isArray(manifest.dsh?.profile?.bundles)) {
937
+ manifest.dsh.profile.bundles = desiredBundles.filter(bundle => !dependencyNames.has(bundle));
938
+ }
939
+ writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
940
+ const errors = [];
941
+ let installed = 0;
942
+ for (const [name, spec] of dependencies) {
943
+ const target = /^(?:file|link|github|git\+|https?):/.test(spec) ? spec : `${name}@${spec}`;
944
+ try {
945
+ const item = await runPlugin(config.profile, ['add', target]);
946
+ if (item.exitCode === 0 && !item.timedOut && !item.cancelled
947
+ && existsSync(join(activeProfileDir, 'node_modules', name, 'package.json'))) {
948
+ installed += 1;
949
+ if (desiredBundles.includes(name)) {
950
+ const current = JSON.parse(readFileSync(manifestFile, 'utf8'));
951
+ current.dsh ??= {};
952
+ current.dsh.profile ??= {};
953
+ current.dsh.profile.bundles ??= [];
954
+ if (!current.dsh.profile.bundles.includes(name))
955
+ current.dsh.profile.bundles.push(name);
956
+ writeFileSync(manifestFile, `${JSON.stringify(current, null, 2)}\n`);
957
+ }
958
+ continue;
959
+ }
960
+ errors.push({ name, error: failureDetail(item).trim() || 'pnpm failed' });
961
+ }
962
+ catch (error) {
963
+ errors.push({ name, error: error instanceof Error ? error.message : String(error) });
964
+ }
965
+ const current = JSON.parse(readFileSync(manifestFile, 'utf8'));
966
+ if (current.dependencies !== undefined)
967
+ delete current.dependencies[name];
968
+ writeFileSync(manifestFile, `${JSON.stringify(current, null, 2)}\n`);
969
+ }
970
+ if (installed === 0 && dependencies.length > 0) {
971
+ restored.rollback();
972
+ }
973
+ invalidateUpdates();
974
+ const bootErrors = restoredBootErrors();
975
+ if (bootErrors.length > 0) {
976
+ logEvent('warn', 'restore', `restored profile will not boot as-is — ${bootErrors.join('; ')}`);
977
+ }
978
+ return {
979
+ files: restored.files,
980
+ errors,
981
+ unportable: unportableDeps(manifest.dependencies),
982
+ ...(bootErrors.length > 0 ? { bootErrors } : {}),
983
+ };
984
+ }
985
+ catch (error) {
986
+ restored.rollback();
987
+ throw error;
988
+ }
989
+ }
990
+ const legacyHandlers = new Map();
991
+ const captureLegacy = (path, route) => {
992
+ legacyHandlers.set(path, route.handler);
993
+ return route;
994
+ };
995
+ const operationsV1 = new UpdateOperationStoreV1(BOOT_ID);
996
+ /** Invoke one existing route in memory so v1 reuses the battle-tested executor. */
997
+ async function invokeLegacy(path, source, method, body, url = path) {
998
+ const handler = legacyHandlers.get(path);
999
+ if (handler === undefined)
1000
+ throw new Error(`legacy route is unavailable: ${path}`);
1001
+ const chunks = body === undefined ? [] : [Buffer.from(JSON.stringify(body))];
1002
+ const replay = Readable.from(chunks);
1003
+ Object.assign(replay, {
1004
+ method,
1005
+ url,
1006
+ headers: { ...source.headers },
1007
+ socket: source.socket,
1008
+ });
1009
+ let status = 200;
1010
+ let text = '';
1011
+ const captured = {
1012
+ writeHead(code) { status = code; return this; },
1013
+ end(chunk) {
1014
+ if (chunk !== undefined)
1015
+ text += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk;
1016
+ return this;
1017
+ },
1018
+ };
1019
+ await handler(replay, captured);
1020
+ let payload = null;
1021
+ try {
1022
+ payload = text === '' ? null : JSON.parse(text);
1023
+ }
1024
+ catch {
1025
+ payload = { error: text };
1026
+ }
1027
+ return { status, payload };
1028
+ }
1029
+ const packageNameFrom = (request) => {
1030
+ try {
1031
+ return new URL(request.url ?? '', 'http://localhost').searchParams.get('name') ?? '';
1032
+ }
1033
+ catch {
1034
+ return '';
1035
+ }
1036
+ };
1037
+ const operationIdFrom = (request) => {
1038
+ try {
1039
+ return new URL(request.url ?? '', 'http://localhost').searchParams.get('operationId') ?? '';
1040
+ }
1041
+ catch {
1042
+ return '';
1043
+ }
1044
+ };
1045
+ const forceCheckFrom = (request) => {
1046
+ try {
1047
+ return new URL(request.url ?? '', 'http://localhost').searchParams.get('force') === '1';
1048
+ }
1049
+ catch {
1050
+ return false;
1051
+ }
1052
+ };
1053
+ const disposers = [
1054
+ host.webServer.register({
1055
+ kind: 'exact',
1056
+ path: '/dsh-market/api/v1/capabilities',
1057
+ handler: (request, response) => {
1058
+ if (request.method !== 'GET') {
1059
+ response.writeHead(405, { allow: 'GET' });
1060
+ response.end();
1061
+ return;
1062
+ }
1063
+ const canRestart = restartAllowed(config);
1064
+ sendJson(response, 200, {
1065
+ schema: UPDATE_API_V1_SCHEMA,
1066
+ apiVersion: 1,
1067
+ // Machine-readable, because a policy that lives only in a markdown
1068
+ // file is one a client never reads. `beta` says the shape may still
1069
+ // change; it becomes `stable` once a release stops moving it, and
1070
+ // that is the point at which the compatibility promise starts.
1071
+ stability: 'beta',
1072
+ marketVersion: marketVersion(),
1073
+ profile: config.profile,
1074
+ bootId: BOOT_ID,
1075
+ runtime: config.profileDirectory === undefined ? 'web' : 'desktop',
1076
+ features: {
1077
+ check: true,
1078
+ update: true,
1079
+ progress: true,
1080
+ rollback: true,
1081
+ restart: canRestart,
1082
+ },
1083
+ restart: {
1084
+ supported: canRestart,
1085
+ managedBy: canRestart ? 'market' : config.profileDirectory === undefined ? 'operator' : 'desktop-host',
1086
+ supervisor: detectedSupervisor(),
1087
+ debugger: detectedDebugger(),
1088
+ },
1089
+ operationRetention: 'current-process',
1090
+ operationLimit: MAX_UPDATE_OPERATIONS_V1,
1091
+ endpoints: {
1092
+ updates: '/dsh-market/api/v1/updates',
1093
+ operations: '/dsh-market/api/v1/operations',
1094
+ rollback: '/dsh-market/api/v1/rollback',
1095
+ restart: '/dsh-market/api/v1/restart',
1096
+ },
1097
+ });
1098
+ },
1099
+ }),
1100
+ host.webServer.register({
1101
+ kind: 'exact',
1102
+ path: '/dsh-market/api/v1/updates',
1103
+ handler: async (request, response) => {
1104
+ if (request.method === 'GET') {
1105
+ const name = packageNameFrom(request);
1106
+ if (!NPM_NAME_RE.test(name)) {
1107
+ sendJson(response, 400, { schema: UPDATE_API_V1_SCHEMA, error: 'a valid package name is required' });
1108
+ return;
1109
+ }
1110
+ try {
1111
+ const force = forceCheckFrom(request);
1112
+ const channel = activeChannel();
1113
+ const channelFor = SELF_NAMES.has(name) ? new Map([[name, channel]]) : undefined;
1114
+ const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor))[name];
1115
+ if (update === undefined) {
1116
+ sendJson(response, 404, { schema: UPDATE_API_V1_SCHEMA, error: 'plugin is not installed' });
1117
+ return;
1118
+ }
1119
+ sendJson(response, 200, {
1120
+ schema: UPDATE_API_V1_SCHEMA,
1121
+ package: {
1122
+ name,
1123
+ source: update.kind,
1124
+ installedVersion: update.current ?? update.version,
1125
+ latestVersion: update.latest,
1126
+ updateAvailable: update.updateAvailable,
1127
+ channelSwitch: update.channelSwitch ?? null,
1128
+ },
1129
+ });
1130
+ }
1131
+ catch (error) {
1132
+ sendJson(response, 500, {
1133
+ schema: UPDATE_API_V1_SCHEMA,
1134
+ error: error instanceof Error ? error.message : String(error),
1135
+ });
1136
+ }
1137
+ return;
1138
+ }
1139
+ if (request.method !== 'POST') {
1140
+ response.writeHead(405, { allow: 'GET, POST' });
1141
+ response.end();
1142
+ return;
1143
+ }
1144
+ if (!sameOrigin(request)) {
1145
+ sendJson(response, 403, { schema: UPDATE_API_V1_SCHEMA, error: 'untrusted origin' });
1146
+ return;
1147
+ }
1148
+ try {
1149
+ const body = (await readJsonBody(request));
1150
+ const packageName = typeof body.packageName === 'string' ? body.packageName : '';
1151
+ if (!NPM_NAME_RE.test(packageName)) {
1152
+ sendJson(response, 400, { schema: UPDATE_API_V1_SCHEMA, error: 'a valid package name is required' });
1153
+ return;
1154
+ }
1155
+ if (operationsV1.hasActive()) {
1156
+ sendJson(response, 409, {
1157
+ schema: UPDATE_API_V1_SCHEMA,
1158
+ error: 'another public update operation is already running',
1159
+ failure: {
1160
+ code: 'OPERATION_BUSY',
1161
+ message: 'another public update operation is already running',
1162
+ retryable: true,
1163
+ },
1164
+ });
1165
+ return;
1166
+ }
1167
+ const installedVersion = readInstalledVersion(config.profile, packageName, activeProfileDir);
1168
+ if (installedVersion === null) {
1169
+ sendJson(response, 404, {
1170
+ schema: UPDATE_API_V1_SCHEMA,
1171
+ error: 'plugin is not installed',
1172
+ failure: {
1173
+ code: 'PLUGIN_NOT_INSTALLED',
1174
+ message: 'plugin is not installed in this profile',
1175
+ retryable: false,
1176
+ },
1177
+ });
1178
+ return;
1179
+ }
1180
+ const operation = operationsV1.create(packageName, installedVersion);
1181
+ operationsV1.start(operation.operationId);
1182
+ void invokeLegacy('/dsh-market/update', request, 'POST', {
1183
+ name: packageName,
1184
+ ...(body.force === true ? { force: true } : {}),
1185
+ }).then(({ status, payload }) => {
1186
+ operationsV1.finish(operation.operationId, status, payload, readInstalledVersion(config.profile, packageName, activeProfileDir));
1187
+ }).catch((error) => {
1188
+ operationsV1.finish(operation.operationId, 500, { error: error instanceof Error ? error.message : String(error) }, readInstalledVersion(config.profile, packageName, activeProfileDir));
1189
+ });
1190
+ sendJson(response, 202, {
1191
+ schema: UPDATE_API_V1_SCHEMA,
1192
+ operation: operationsV1.get(operation.operationId),
1193
+ });
1194
+ }
1195
+ catch (error) {
1196
+ sendJson(response, 400, {
1197
+ schema: UPDATE_API_V1_SCHEMA,
1198
+ error: error instanceof Error ? error.message : String(error),
1199
+ });
1200
+ }
1201
+ },
1202
+ }),
1203
+ host.webServer.register({
1204
+ kind: 'exact',
1205
+ path: '/dsh-market/api/v1/operations',
1206
+ handler: (request, response) => {
1207
+ if (request.method !== 'GET') {
1208
+ response.writeHead(405, { allow: 'GET' });
1209
+ response.end();
1210
+ return;
1211
+ }
1212
+ const operation = operationsV1.get(operationIdFrom(request), progress);
1213
+ if (operation === null) {
1214
+ sendJson(response, 404, { schema: UPDATE_API_V1_SCHEMA, error: 'operation not found in this host process' });
1215
+ return;
1216
+ }
1217
+ sendJson(response, 200, { schema: UPDATE_API_V1_SCHEMA, operation });
1218
+ },
1219
+ }),
1220
+ host.webServer.register({
1221
+ kind: 'exact',
1222
+ path: '/dsh-market/api/v1/rollback',
1223
+ handler: async (request, response) => {
1224
+ if (request.method !== 'POST') {
1225
+ response.writeHead(405, { allow: 'POST' });
1226
+ response.end();
1227
+ return;
1228
+ }
1229
+ if (!sameOrigin(request)) {
1230
+ sendJson(response, 403, { schema: UPDATE_API_V1_SCHEMA, error: 'untrusted origin' });
1231
+ return;
1232
+ }
1233
+ try {
1234
+ const body = (await readJsonBody(request));
1235
+ const operationId = typeof body.operationId === 'string' ? body.operationId : '';
1236
+ const trackedOperation = operationsV1.get(operationId);
1237
+ const legacyRollbackId = operationsV1.beginRollback(operationId);
1238
+ if (legacyRollbackId === null || trackedOperation === null) {
1239
+ sendJson(response, 409, { schema: UPDATE_API_V1_SCHEMA, error: 'rollback is not available for this operation' });
1240
+ return;
1241
+ }
1242
+ const result = await invokeLegacy('/dsh-market/rollback', request, 'POST', { rollbackId: legacyRollbackId });
1243
+ const installedVersion = readInstalledVersion(config.profile, trackedOperation.packageName, activeProfileDir);
1244
+ const operation = operationsV1.finishRollback(operationId, result.status, result.payload, installedVersion);
1245
+ sendJson(response, 200, { schema: UPDATE_API_V1_SCHEMA, operation });
1246
+ }
1247
+ catch (error) {
1248
+ sendJson(response, 400, {
1249
+ schema: UPDATE_API_V1_SCHEMA,
1250
+ error: error instanceof Error ? error.message : String(error),
1251
+ });
1252
+ }
1253
+ },
1254
+ }),
1255
+ host.webServer.register({
1256
+ kind: 'exact',
1257
+ path: '/dsh-market/api/v1/restart',
1258
+ handler: async (request, response) => {
1259
+ if (request.method !== 'POST') {
1260
+ response.writeHead(405, { allow: 'POST' });
1261
+ response.end();
1262
+ return;
1263
+ }
1264
+ const result = await invokeLegacy('/dsh-market/restart', request, 'POST', {});
1265
+ sendJson(response, result.status, { schema: UPDATE_API_V1_SCHEMA, result: result.payload });
1266
+ },
1267
+ }),
1268
+ host.webServer.register({
1269
+ kind: 'exact',
1270
+ path: '/dsh-market/backup',
1271
+ handler: (request, response) => {
1272
+ if (request.method !== 'GET') {
1273
+ response.writeHead(405, { allow: 'GET' });
1274
+ response.end();
1275
+ return;
1276
+ }
1277
+ // Profile exports carry configuration that may include credentials
1278
+ // (config.toml, .env, …), so they stay limited to loopback peers
1279
+ // without proxy forwarding (review #63). Unlike process control,
1280
+ // browsers omit the Origin header on `<a download>` GET navigations,
1281
+ // so a missing Origin passes; a present one must still match Host.
1282
+ if (!trustedDownloadRequest(request)) {
1283
+ sendJson(response, 403, { error: 'backup export is limited to same-origin loopback requests' });
1284
+ return;
1285
+ }
1286
+ try {
1287
+ const data = createProfileBackup(config.profile, activeProfileDir);
1288
+ const backup = JSON.stringify(data, null, 2);
1289
+ const timestamp = new Date(data.createdAt).toLocaleString('sv-SE').replace(/\D/g, '');
1290
+ response.writeHead(200, {
1291
+ 'cache-control': 'no-store',
1292
+ 'content-type': 'application/json; charset=utf-8',
1293
+ 'content-disposition': `attachment; filename="chatcode-cli-market-backup-${timestamp}.json"`,
1294
+ });
1295
+ response.end(backup);
1296
+ }
1297
+ catch (error) {
1298
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
1299
+ }
1300
+ },
1301
+ }),
1302
+ host.webServer.register({
1303
+ kind: 'exact',
1304
+ path: '/dsh-market/restore',
1305
+ handler: async (request, response) => {
1306
+ if (request.method !== 'POST') {
1307
+ response.writeHead(405, { allow: 'POST' });
1308
+ response.end();
1309
+ return;
1310
+ }
1311
+ if (!sameOrigin(request))
1312
+ return sendJson(response, 403, { error: 'untrusted origin' });
1313
+ try {
1314
+ const body = await readJsonBody(request, MAX_BACKUP_BYTES + 4096);
1315
+ await withMutationLock(response, 'install', async () => {
1316
+ pendingRollbacks.clear();
1317
+ sendJson(response, 200, { ok: true, ...await restoreBackup(body.backup) });
1318
+ });
1319
+ }
1320
+ catch (error) {
1321
+ sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) });
1322
+ }
1323
+ },
1324
+ }),
1325
+ host.webServer.register({
1326
+ kind: 'exact',
1327
+ path: '/dsh-market/webdav',
1328
+ handler: async (request, response) => {
1329
+ if (request.method !== 'POST') {
1330
+ response.writeHead(405, { allow: 'POST' });
1331
+ response.end();
1332
+ return;
1333
+ }
1334
+ if (!sameOrigin(request))
1335
+ return sendJson(response, 403, { error: 'untrusted origin' });
1336
+ try {
1337
+ const body = await readJsonBody(request);
1338
+ const url = typeof body.url === 'string' ? body.url : '';
1339
+ const username = typeof body.username === 'string' ? body.username : '';
1340
+ const password = typeof body.password === 'string' ? body.password : '';
1341
+ if (body.action === 'backup') {
1342
+ await uploadWebdav(url, username, password, createProfileBackup(config.profile, activeProfileDir));
1343
+ sendJson(response, 200, { ok: true });
1344
+ }
1345
+ else if (body.action === 'restore') {
1346
+ // The preview flow first returns the downloaded backup so the
1347
+ // client can show what will be restored; the real restore then
1348
+ // posts it to /dsh-market/restore, where downloadWebdav's strict
1349
+ // validation guarantees the fetch result is never blindly echoed
1350
+ // (review #63).
1351
+ sendJson(response, 200, { ok: true, backup: await downloadWebdav(url, username, password) });
1352
+ }
1353
+ else
1354
+ sendJson(response, 400, { error: 'invalid WebDAV action' });
1355
+ }
1356
+ catch (error) {
1357
+ sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) });
1358
+ }
1359
+ },
1360
+ }),
1361
+ host.webServer.register({
1362
+ kind: 'exact',
1363
+ path: '/dsh-market/gist',
1364
+ handler: async (request, response) => {
1365
+ if (request.method !== 'POST') {
1366
+ response.writeHead(405, { allow: 'POST' });
1367
+ response.end();
1368
+ return;
1369
+ }
1370
+ if (!sameOrigin(request))
1371
+ return sendJson(response, 403, { error: 'untrusted origin' });
1372
+ // 25 s route-level ceiling: abort the underlying GitHub request too,
1373
+ // so the client always gets a definite, structured answer and a
1374
+ // wedged gh CLI / slow network can never leave a request running in
1375
+ // the background (issue #89; the error carries a code for the UI).
1376
+ const controller = new AbortController();
1377
+ const timer = setTimeout(() => controller.abort(new GistError('Gist operation timed out', 'timeout')), 25_000);
1378
+ try {
1379
+ const body = await readJsonBody(request);
1380
+ const { token, source } = await resolveGistTokenSource(body.token);
1381
+ if (body.action === 'export') {
1382
+ const gistIdInput = typeof body.gistId === 'string' ? body.gistId.trim() : '';
1383
+ const includeDeps = Array.isArray(body.includeDeps)
1384
+ ? body.includeDeps.filter((name) => typeof name === 'string' && name !== '')
1385
+ : undefined;
1386
+ const backup = createProfileBackup(config.profile, activeProfileDir, includeDeps !== undefined
1387
+ ? { includeDeps, includeConfig: body.includeConfig === true }
1388
+ : undefined);
1389
+ const content = JSON.stringify(backup, null, 2);
1390
+ if (!fitsGistLimit(content))
1391
+ throw new Error('backup exceeds the GitHub Gist 1 MB limit');
1392
+ const ref = gistIdInput === ''
1393
+ ? await createGist(token, content, controller.signal)
1394
+ : await updateGist(token, parseGistId(gistIdInput), content, controller.signal);
1395
+ sendJson(response, 200, { ok: true, gistId: ref.id, gistUrl: ref.htmlUrl });
1396
+ }
1397
+ else if (body.action === 'import') {
1398
+ if (typeof body.gistId !== 'string' || body.gistId.trim() === '')
1399
+ throw new Error('gist id is required');
1400
+ const backup = await readGist(token, parseGistId(body.gistId), controller.signal);
1401
+ // Preview flow, same as WebDAV: the client reviews the backup and
1402
+ // posts it to /dsh-market/restore; readGist's strict validation
1403
+ // guarantees the fetch result is never blindly echoed.
1404
+ sendJson(response, 200, { ok: true, backup });
1405
+ }
1406
+ else if (body.action === 'verify') {
1407
+ await verifyGistToken(token, controller.signal);
1408
+ sendJson(response, 200, { ok: true, source });
1409
+ }
1410
+ else
1411
+ sendJson(response, 400, { error: 'invalid Gist action' });
1412
+ }
1413
+ catch (error) {
1414
+ sendJson(response, 400, { error: error instanceof Error ? error.message : String(error), code: gistErrorCode(error) });
1415
+ }
1416
+ finally {
1417
+ clearTimeout(timer);
1418
+ }
1419
+ },
1420
+ }),
1421
+ host.webServer.register({
1422
+ kind: 'exact',
1423
+ path: '/dsh-market/registry',
1424
+ handler: async (request, response) => {
1425
+ if (request.method !== 'GET') {
1426
+ response.writeHead(405, { allow: 'GET' });
1427
+ response.end();
1428
+ return;
1429
+ }
1430
+ try {
1431
+ try {
1432
+ const registry = await loadRegistry();
1433
+ sendJson(response, 200, {
1434
+ registry,
1435
+ hostVersion: dshHostInfo()?.version ?? null,
1436
+ });
1437
+ }
1438
+ catch (error) {
1439
+ // Say what went wrong. The market used to substitute a bundled
1440
+ // copy here, so an unreachable registry looked exactly like a
1441
+ // reachable one with fewer plugins in it.
1442
+ const message = error instanceof Error ? error.message : String(error);
1443
+ logEvent('warn', 'registry', `catalog fetch failed: ${message}`);
1444
+ sendJson(response, 502, { error: message });
1445
+ }
1446
+ }
1447
+ catch (error) {
1448
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
1449
+ }
1450
+ },
1451
+ }),
1452
+ host.webServer.register({
1453
+ kind: 'exact',
1454
+ path: '/dsh-market/discovery-compatibility',
1455
+ handler: async (request, response) => {
1456
+ if (request.method !== 'POST') {
1457
+ response.writeHead(405, { allow: 'POST' });
1458
+ response.end();
1459
+ return;
1460
+ }
1461
+ if (!sameOrigin(request)) {
1462
+ sendJson(response, 403, { error: 'untrusted origin' });
1463
+ return;
1464
+ }
1465
+ let body;
1466
+ try {
1467
+ body = await readJsonBody(request, 32 * 1024);
1468
+ }
1469
+ catch (error) {
1470
+ sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) });
1471
+ return;
1472
+ }
1473
+ const requested = body !== null && typeof body === 'object' && !Array.isArray(body)
1474
+ ? body.packages
1475
+ : undefined;
1476
+ if (!Array.isArray(requested) || requested.length > 64
1477
+ || !requested.every(name => typeof name === 'string' && NPM_NAME_RE.test(name))) {
1478
+ sendJson(response, 400, { error: 'packages must be an array of at most 64 npm package names' });
1479
+ return;
1480
+ }
1481
+ const packages = [...new Set(requested)];
1482
+ try {
1483
+ const host = dshHostInfo();
1484
+ const hostVersion = host?.version ?? null;
1485
+ const hostPackages = corePackageNames(host?.directory ?? null);
1486
+ const facts = await discoveryManifests.lookup(packages, routesFor(region).npmRegistry);
1487
+ const plugins = Object.fromEntries(packages.map(name => [
1488
+ name,
1489
+ deriveHostCompatibility(facts[name] ?? null, hostVersion, hostPackages),
1490
+ ]));
1491
+ sendJson(response, 200, { hostVersion, plugins });
1492
+ }
1493
+ catch (error) {
1494
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
1495
+ }
1496
+ },
1497
+ }),
1498
+ host.webServer.register({
1499
+ kind: 'exact',
1500
+ path: '/dsh-market/installed',
1501
+ handler: async (request, response) => {
1502
+ if (request.method !== 'GET') {
1503
+ response.writeHead(405, { allow: 'GET' });
1504
+ response.end();
1505
+ return;
1506
+ }
1507
+ await dropStaleHotMounts();
1508
+ const installed = readInstalled(config.profile, activeProfileDir);
1509
+ const repoIdentities = {};
1510
+ const repoHints = {};
1511
+ for (const [name, spec] of Object.entries(installed)) {
1512
+ const evidence = readInstalledRepoEvidence(config.profile, name, spec, activeProfileDir);
1513
+ if (evidence.identities.length > 0)
1514
+ repoIdentities[name] = evidence.identities;
1515
+ if (evidence.hints.length > 0)
1516
+ repoHints[name] = evidence.hints;
1517
+ }
1518
+ const present = Object.keys(installed).filter(name => readInstalledVersion(config.profile, name, activeProfileDir) !== null);
1519
+ // User-patch-layer state (port of dsh-plugin-hub): rows the user
1520
+ // patch disables/force-enables, plus per-package flags so the UI can
1521
+ // show toggles made OUTSIDE the market (hand-edited cordis.patch.yml,
1522
+ // the dsh CLI) that state.json never sees.
1523
+ const patch = readUserPatchState(userPatchPath);
1524
+ const patchFlags = packagePatchFlags(host, activeProfileDir, Object.keys(installed), patch);
1525
+ const activation = {};
1526
+ const live = liveNames();
1527
+ for (const name of Object.keys(installed)) {
1528
+ activation[name] = activationAfterReplace(verifyActivation(config.profile, name, live, activeProfileDir, disabled.has(name) || patchFlags.disabled.includes(name)), replacedWhileLive.has(name));
1529
+ }
1530
+ const diagnostics = diagnosePackageManifests(Object.keys(installed).map(packageName => ({
1531
+ packageName,
1532
+ manifest: readInstalledManifest(config.profile, packageName, activeProfileDir),
1533
+ })));
1534
+ sendJson(response, 200, {
1535
+ profile: config.profile,
1536
+ installed,
1537
+ repoIdentities,
1538
+ repoHints,
1539
+ present,
1540
+ activation,
1541
+ diagnostics,
1542
+ live: listHotMounts(),
1543
+ disabled: [...disabled],
1544
+ groups,
1545
+ groupOrder,
1546
+ notes: readMarketState(activeProfileDir).notes ?? {},
1547
+ favorites: readMarketState(activeProfileDir).favorites ?? [],
1548
+ patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts },
1549
+ patchDisabled: patchFlags.disabled,
1550
+ patchForced: patchFlags.forced,
1551
+ bundles: readProfileBundles(activeProfileDir).filter(name => !INBOX_BUNDLES.has(name)),
1552
+ });
1553
+ },
1554
+ }),
1555
+ host.webServer.register({
1556
+ kind: 'exact',
1557
+ path: '/dsh-market/check',
1558
+ handler: (request, response) => {
1559
+ if (request.method !== 'GET') {
1560
+ response.writeHead(405, { allow: 'GET' });
1561
+ response.end();
1562
+ return;
1563
+ }
1564
+ try {
1565
+ const report = analyzeProfile(activeProfileDir);
1566
+ // #201: attach the #200 directional verdict to every peer row so the
1567
+ // diagnostics UI can tier risk / warning / info without recomputing
1568
+ // (the client cannot see peerDependenciesMeta on disk).
1569
+ for (const row of report.peerMismatches) {
1570
+ row.verdict = row.satisfied === false
1571
+ // `optional` is absent rather than false on a row the plugin
1572
+ // did not mark (#275), and absent means not optional.
1573
+ ? classifyPeer(row.plugin, row.name, row.range, row.resolved, row.optional === true)
1574
+ : { kind: 'none' };
1575
+ }
1576
+ sendJson(response, 200, report);
1577
+ }
1578
+ catch (error) {
1579
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
1580
+ }
1581
+ },
1582
+ }),
1583
+ // Issue #98 phase 2: reorder the community bundles. Official bundles are
1584
+ // fixed; the candidate is trial-validated (dry-run composition replay)
1585
+ // before the manifest is written — a broken order is refused and the
1586
+ // profile is never touched.
1587
+ host.webServer.register({
1588
+ kind: 'exact',
1589
+ path: '/dsh-market/bundle-order',
1590
+ handler: async (request, response) => {
1591
+ if (request.method !== 'POST') {
1592
+ response.writeHead(405, { allow: 'POST' });
1593
+ response.end();
1594
+ return;
1595
+ }
1596
+ if (!sameOrigin(request)) {
1597
+ sendJson(response, 403, { error: 'untrusted origin' });
1598
+ return;
1599
+ }
1600
+ // Mutex with pnpm operations AND other direct writes (issue #98
1601
+ // analysis): reordering writes package.json directly; racing an
1602
+ // install/update/uninstall — or another direct write — would
1603
+ // corrupt the manifest (backup restore uses the same guard). The
1604
+ // lock is taken BEFORE the body is read so a slow/pending request
1605
+ // cannot interleave with another write either.
1606
+ // #125 hardening (lesson from #122: a bad order write can stop DSH
1607
+ // from starting): keep a pre-write profile backup and restore it
1608
+ // automatically if the write throws mid-flight, and persist a profile
1609
+ // snapshot before the write (issue #126) so the change is recoverable
1610
+ // from the snapshots tab — the backup is the immediate rollback net.
1611
+ let backup = null;
1612
+ try {
1613
+ await withMutationLock(response, 'write', async () => {
1614
+ const body = (await readJsonBody(request));
1615
+ if (body === null || typeof body !== 'object') {
1616
+ sendJson(response, 400, { error: 'JSON body is required / 需要 JSON body' });
1617
+ return;
1618
+ }
1619
+ if (!Array.isArray(body.order) || !body.order.every(item => typeof item === 'string')) {
1620
+ sendJson(response, 400, { error: 'order must be an array of bundle names / order 必须是 bundle 名称数组' });
1621
+ return;
1622
+ }
1623
+ const order = body.order;
1624
+ // Before/after rules (issue #98 phase 2): the merged stack must
1625
+ // satisfy every rule the bundles declare. Enforced BEFORE the
1626
+ // trial/write so a rule-breaking order is refused outright.
1627
+ const stack = readBundleStack(activeProfileDir);
1628
+ const merged = mergeOrder(stack.bundles, order);
1629
+ if (merged.ok) {
1630
+ const conflicts = validateOrder(merged.bundles, readBundleRules(activeProfileDir));
1631
+ if (conflicts.length > 0) {
1632
+ logEvent('warn', 'bundle-order', `rejected by before/after rules: ${conflicts.map(c => c.reason).join('; ')}`);
1633
+ sendJson(response, 422, {
1634
+ error: 'the order violates declared before/after rules / 该顺序违反了插件声明的 before/after 规则',
1635
+ conflicts,
1636
+ });
1637
+ return;
1638
+ }
1639
+ }
1640
+ const trial = trialValidate(activeProfileDir, order);
1641
+ if (!trial.ok) {
1642
+ const first = trial.errors[0];
1643
+ logEvent('warn', 'bundle-order', `rejected by trial validation: ${first?.message ?? 'unknown'}`);
1644
+ sendJson(response, 422, {
1645
+ error: `trial validation failed — ${first?.message ?? 'this order would not boot'} / 试启动校验失败:${first?.message ?? '该顺序无法启动'}`,
1646
+ trial: { errors: trial.errors, warnings: trial.warnings, diff: trial.diff },
1647
+ });
1648
+ return;
1649
+ }
1650
+ backup = createProfileBackup(config.profile, activeProfileDir);
1651
+ // yzke review point 4 (issue #126): persist a profile snapshot BEFORE
1652
+ // the write (subject to the maxSnapshots quota), so the change is
1653
+ // recoverable from the snapshots tab; the in-process backup above
1654
+ // stays as the immediate rollback net (double protection).
1655
+ const captured = createProfileSnapshot(activeProfileDir, maxSnapshots);
1656
+ if (!captured.ok) {
1657
+ sendJson(response, 400, { error: captured.error });
1658
+ return;
1659
+ }
1660
+ const snapshot = captured.snapshot;
1661
+ pendingRollbacks.clear();
1662
+ const applied = applyBundleOrder(activeProfileDir, order);
1663
+ if (!applied.ok) {
1664
+ sendJson(response, 400, { error: applied.error });
1665
+ return;
1666
+ }
1667
+ invalidateUpdates();
1668
+ logEvent('info', 'bundle-order', `applied new community order (snapshot ${snapshot.id})`);
1669
+ sendJson(response, 200, { ok: true, bundles: applied.bundles, snapshot: snapshot.id });
1670
+ });
1671
+ }
1672
+ catch (error) {
1673
+ // The write threw mid-flight: restore the pre-write profile so a
1674
+ // broken manifest can never stop DSH from starting (issue #125,
1675
+ // lesson from #122). Best-effort — a failing restore must not mask
1676
+ // the original error.
1677
+ if (backup !== null) {
1678
+ try {
1679
+ restoreProfileBackup(config.profile, backup, activeProfileDir);
1680
+ logEvent('error', 'bundle-order', `write failed — profile restored from pre-write backup: ${error instanceof Error ? error.message : String(error)}`);
1681
+ }
1682
+ catch {
1683
+ logEvent('error', 'bundle-order', 'write failed AND automatic rollback failed');
1684
+ }
1685
+ }
1686
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
1687
+ }
1688
+ },
1689
+ }),
1690
+ // Issue #98 phase 3: named plugin presets (bundle order + disable list).
1691
+ host.webServer.register({
1692
+ kind: 'exact',
1693
+ path: '/dsh-market/presets',
1694
+ handler: async (request, response) => {
1695
+ if (request.method === 'GET') {
1696
+ sendJson(response, 200, { presets: listPresets(activeProfileDir) });
1697
+ return;
1698
+ }
1699
+ if (request.method !== 'POST') {
1700
+ response.writeHead(405, { allow: 'GET, POST' });
1701
+ response.end();
1702
+ return;
1703
+ }
1704
+ if (!sameOrigin(request)) {
1705
+ sendJson(response, 403, { error: 'untrusted origin' });
1706
+ return;
1707
+ }
1708
+ try {
1709
+ // save carries the FULL community order + disabled list, which can
1710
+ // exceed the 4KiB default (CJK names are 3 bytes/char).
1711
+ const body = (await readJsonBody(request, 256 * 1024));
1712
+ if (body === null || typeof body !== 'object') {
1713
+ sendJson(response, 400, { error: 'JSON body is required / 需要 JSON body' });
1714
+ return;
1715
+ }
1716
+ const name = body.name;
1717
+ // Preview is a pure read; save/apply/delete write presets.json,
1718
+ // package.json and state.json, so they take the direct-write lock —
1719
+ // a concurrent pnpm run or another direct write must not interleave
1720
+ // (issue #98 analysis: write-route mutual exclusion).
1721
+ if (body.action === 'preview') {
1722
+ const previewed = previewPreset(activeProfileDir, name);
1723
+ sendJson(response, previewed.ok ? 200 : 422, previewed);
1724
+ return;
1725
+ }
1726
+ await withMutationLock(response, 'write', async () => {
1727
+ switch (body.action) {
1728
+ case 'save': {
1729
+ const saved = savePreset(activeProfileDir, name, body.bundleOrder, body.disabled);
1730
+ sendJson(response, saved.ok ? 200 : 400, saved);
1731
+ return;
1732
+ }
1733
+ case 'apply': {
1734
+ pendingRollbacks.clear();
1735
+ const applied = applyPreset(activeProfileDir, name, maxSnapshots);
1736
+ if (applied.ok) {
1737
+ invalidateUpdates();
1738
+ refreshMarketState();
1739
+ }
1740
+ sendJson(response, applied.ok ? 200 : 422, applied);
1741
+ return;
1742
+ }
1743
+ case 'delete': {
1744
+ const deleted = deletePreset(activeProfileDir, name);
1745
+ sendJson(response, deleted.ok ? 200 : 400, deleted);
1746
+ return;
1747
+ }
1748
+ default:
1749
+ sendJson(response, 400, { error: 'action must be save | preview | apply | delete / action 必须是 save | preview | apply | delete' });
1750
+ }
1751
+ });
1752
+ }
1753
+ catch (error) {
1754
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
1755
+ }
1756
+ },
1757
+ }),
1758
+ // Issue #98 phase 3 (#19): profile snapshots — list, create, restore.
1759
+ host.webServer.register({
1760
+ kind: 'exact',
1761
+ path: '/dsh-market/snapshots',
1762
+ handler: async (request, response) => {
1763
+ if (request.method === 'GET') {
1764
+ sendJson(response, 200, { snapshots: listSnapshots(activeProfileDir) });
1765
+ return;
1766
+ }
1767
+ if (request.method === 'POST') {
1768
+ if (!sameOrigin(request)) {
1769
+ sendJson(response, 403, { error: 'untrusted origin' });
1770
+ return;
1771
+ }
1772
+ try {
1773
+ await withMutationLock(response, 'write', async () => {
1774
+ const captured = createProfileSnapshot(activeProfileDir, maxSnapshots);
1775
+ if (captured.ok)
1776
+ sendJson(response, 200, { ok: true, snapshot: captured.snapshot });
1777
+ else
1778
+ sendJson(response, 400, captured);
1779
+ });
1780
+ }
1781
+ catch (error) {
1782
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
1783
+ }
1784
+ return;
1785
+ }
1786
+ response.writeHead(405, { allow: 'GET, POST' });
1787
+ response.end();
1788
+ },
1789
+ }),
1790
+ host.webServer.register({
1791
+ kind: 'exact',
1792
+ path: '/dsh-market/restore-snapshot',
1793
+ handler: async (request, response) => {
1794
+ if (request.method !== 'POST') {
1795
+ response.writeHead(405, { allow: 'POST' });
1796
+ response.end();
1797
+ return;
1798
+ }
1799
+ if (!sameOrigin(request)) {
1800
+ sendJson(response, 403, { error: 'untrusted origin' });
1801
+ return;
1802
+ }
1803
+ try {
1804
+ await withMutationLock(response, 'write', async () => {
1805
+ const body = (await readJsonBody(request));
1806
+ if (body === null || typeof body !== 'object' || typeof body.snapshot !== 'string' || body.snapshot === '') {
1807
+ sendJson(response, 400, { error: 'snapshot id is required / 需要快照 id' });
1808
+ return;
1809
+ }
1810
+ pendingRollbacks.clear();
1811
+ const restored = restoreSnapshot(activeProfileDir, body.snapshot);
1812
+ if (restored.ok) {
1813
+ invalidateUpdates();
1814
+ refreshMarketState();
1815
+ }
1816
+ sendJson(response, restored.ok ? 200 : 400, restored);
1817
+ });
1818
+ }
1819
+ catch (error) {
1820
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
1821
+ }
1822
+ },
1823
+ }),
1824
+ // Issue #98 supplement: delete one snapshot (the cap also prunes old ones
1825
+ // automatically, but the user may want to drop a specific snapshot).
1826
+ host.webServer.register({
1827
+ kind: 'exact',
1828
+ path: '/dsh-market/delete-snapshot',
1829
+ handler: async (request, response) => {
1830
+ if (request.method !== 'POST') {
1831
+ response.writeHead(405, { allow: 'POST' });
1832
+ response.end();
1833
+ return;
1834
+ }
1835
+ if (!sameOrigin(request)) {
1836
+ sendJson(response, 403, { error: 'untrusted origin' });
1837
+ return;
1838
+ }
1839
+ try {
1840
+ await withMutationLock(response, 'write', async () => {
1841
+ const body = (await readJsonBody(request));
1842
+ if (body === null || typeof body !== 'object' || typeof body.snapshot !== 'string' || body.snapshot === '') {
1843
+ sendJson(response, 400, { error: 'snapshot id is required / 需要快照 id' });
1844
+ return;
1845
+ }
1846
+ // deleteSnapshot refuses traversal-shaped ids before touching the
1847
+ // filesystem (same discipline as restore); a false result means the
1848
+ // id is malformed or no such snapshot exists.
1849
+ const deleted = deleteSnapshot(activeProfileDir, body.snapshot);
1850
+ if (!deleted) {
1851
+ sendJson(response, 400, { ok: false, error: 'snapshot not found / 快照不存在' });
1852
+ return;
1853
+ }
1854
+ logEvent('info', 'snapshot', `deleted ${body.snapshot}`);
1855
+ sendJson(response, 200, { ok: true, snapshot: body.snapshot });
1856
+ });
1857
+ }
1858
+ catch (error) {
1859
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
1860
+ }
1861
+ },
1862
+ }),
1863
+ host.webServer.register({
1864
+ kind: 'exact',
1865
+ path: '/dsh-market/use-skin',
1866
+ handler: async (request, response) => {
1867
+ if (request.method !== 'POST') {
1868
+ response.writeHead(405, { allow: 'POST' });
1869
+ response.end();
1870
+ return;
1871
+ }
1872
+ if (!sameOrigin(request)) {
1873
+ sendJson(response, 403, { error: 'untrusted origin' });
1874
+ return;
1875
+ }
1876
+ try {
1877
+ const body = (await readJsonBody(request));
1878
+ const name = typeof body.name === 'string' ? body.name : '';
1879
+ const installed = readInstalled(config.profile, activeProfileDir);
1880
+ const themeNames = await themes.installedThemeNames();
1881
+ if (installed[name] === undefined || !themeNames.has(name)) {
1882
+ sendJson(response, 400, { error: 'not an installed theme' });
1883
+ return;
1884
+ }
1885
+ pendingRollbacks.clear();
1886
+ const activated = await themes.activateTheme(name);
1887
+ logEvent(activated ? 'info' : 'error', 'use-skin', `${name}: ${activated ? 'active' : 'failed'}`);
1888
+ sendJson(response, activated ? 200 : 502, { ok: activated, live: listHotMounts() });
1889
+ }
1890
+ catch (error) {
1891
+ const message = error instanceof Error ? error.message : String(error);
1892
+ logEvent('error', 'use-skin', `route error: ${message}`);
1893
+ sendJson(response, 500, { error: message });
1894
+ }
1895
+ },
1896
+ }),
1897
+ host.webServer.register({
1898
+ kind: 'exact',
1899
+ path: '/dsh-market/toggle',
1900
+ handler: async (request, response) => {
1901
+ if (request.method !== 'POST') {
1902
+ response.writeHead(405, { allow: 'POST' });
1903
+ response.end();
1904
+ return;
1905
+ }
1906
+ if (!sameOrigin(request)) {
1907
+ sendJson(response, 403, { error: 'untrusted origin' });
1908
+ return;
1909
+ }
1910
+ try {
1911
+ await withMutationLock(response, 'write', async () => {
1912
+ const body = (await readJsonBody(request));
1913
+ const name = typeof body.name === 'string' ? body.name : '';
1914
+ const enabled = body.enabled === true;
1915
+ if (isSelfName(name)) {
1916
+ sendJson(response, 400, { error: 'the market cannot be disabled from its own page; use ChatCode CLI' });
1917
+ return;
1918
+ }
1919
+ if (readInstalled(config.profile, activeProfileDir)[name] === undefined) {
1920
+ sendJson(response, 400, { error: 'plugin is not installed' });
1921
+ return;
1922
+ }
1923
+ // Host infrastructure (port of dsh-plugin-hub): switching off the
1924
+ // timer/hmr/webserver/storage chain would break the very HMR the
1925
+ // patch layer relies on, so those rows refuse to toggle.
1926
+ if (isProtectedModule(name)) {
1927
+ sendJson(response, 403, {
1928
+ error: `${name} 属于宿主基础设施,禁止开关(会破坏热加载/传输/存储链) / ${name} is host infrastructure and cannot be toggled (it would break the hot-reload/transport/storage chain)`,
1929
+ });
1930
+ return;
1931
+ }
1932
+ pendingRollbacks.clear();
1933
+ let ok;
1934
+ let reason;
1935
+ if (enabled && (await themes.installedThemeNames()).has(name)) {
1936
+ // Theme exclusivity stays a Themes-page concern: enabling a theme
1937
+ // deactivates the previously active one, so only the last-enabled
1938
+ // theme is live (same semantics as use-skin).
1939
+ ok = await themes.activateTheme(name);
1940
+ if (!ok)
1941
+ reason = 'theme activation failed — restart required / 主题启用失败,需要重启';
1942
+ }
1943
+ else {
1944
+ const result = await setPluginEnabled(name, enabled);
1945
+ ok = result.ok;
1946
+ reason = result.reason;
1947
+ }
1948
+ // Durable patch-layer write (port of dsh-plugin-hub): the package's
1949
+ // bundle rows get 'disabled: true|false' in the user patch layer,
1950
+ // which DSH's HMR applies within ~1s AND the loader re-applies on
1951
+ // every boot. Client-only packages have no bundle rows — the
1952
+ // market's own state.json replay covers those.
1953
+ const patchRows = rowIdsForPackage(host, activeProfileDir, name);
1954
+ // Disable-carrier (#224): a bundle whose patch DISABLES a plugin it
1955
+ // does not own (dsh-postgres-backends disables session-persistence-jsonl).
1956
+ // Disabling only its inserted rows leaves that foreign disable applying
1957
+ // on every boot — the bundle stays in the stack — so drop it from
1958
+ // dsh.profile.bundles entirely, which stops its whole patch at once
1959
+ // (including any config side effects it carries). Enabling re-adds it.
1960
+ // A bundle that merely reconfigures a neighbour (config without
1961
+ // disabled) is NOT dropped: #147 requires disabling it to leave the
1962
+ // neighbour live, and the e2e fixture-cross re-enable breaks otherwise.
1963
+ const disablesOthers = carrierDisableIds(activeProfileDir, name);
1964
+ const isCarrier = disablesOthers.length > 0;
1965
+ let bundleSwitch = { ok: true, reason: null };
1966
+ if (isCarrier) {
1967
+ try {
1968
+ if (enabled)
1969
+ addProfileBundle(activeProfileDir, name);
1970
+ else
1971
+ removeProfileBundle(activeProfileDir, name);
1972
+ logEvent('info', 'toggle', `${name}: disable-carrier ${enabled ? 're-added to' : 'removed from'} dsh.profile.bundles (disables: ${disablesOthers.join(', ')})`);
1973
+ }
1974
+ catch (error) {
1975
+ bundleSwitch = { ok: false, reason: error instanceof Error ? error.message : String(error) };
1976
+ logEvent('warn', 'toggle', `${name}: carrier bundle switch failed — ${bundleSwitch.reason}`);
1977
+ }
1978
+ }
1979
+ let patchWrite = null;
1980
+ if (patchRows.length > 0) {
1981
+ for (const rowId of patchRows) {
1982
+ const result = enabled ? await enableRow(userPatchPath, rowId) : await disableRow(userPatchPath, rowId);
1983
+ if (!result.ok && patchWrite === null)
1984
+ patchWrite = result;
1985
+ }
1986
+ if (patchWrite === null) {
1987
+ logEvent('info', 'toggle', `${name}: patch layer ${enabled ? 'enabled' : 'disabled'} rows ${patchRows.join(', ')}`);
1988
+ }
1989
+ else {
1990
+ logEvent('warn', 'toggle', `${name}: patch layer write refused — ${patchWrite.reason}`);
1991
+ }
1992
+ }
1993
+ logEvent(ok ? 'info' : 'error', 'toggle', `${name}: ${enabled ? 'on' : 'off'} ok=${String(ok)}`);
1994
+ // Activation reads the post-write truth: the switch state OR the
1995
+ // patch layer, so a disabled plugin never reports "restart to
1996
+ // apply".
1997
+ const patchNow = readUserPatchState(userPatchPath);
1998
+ const offNow = disabled.has(name) || patchRows.some(id => patchNow.disables.includes(id));
1999
+ // When the live composition does not match the requested state
2000
+ // (enable failed to hot-mount / disable left the fiber up), the
2001
+ // change lands on the next boot via the patch layer + state.json —
2002
+ // the client reuses the market's pending-restart banner for it.
2003
+ const liveAfter = liveNames().has(name);
2004
+ // A carrier toggle moves the bundle in/out of dsh.profile.bundles,
2005
+ // which only takes effect on the next composition — always a restart.
2006
+ // Non-carrier plugins keep the live-mount based decision.
2007
+ const restart = isCarrier ? true : enabled ? !liveAfter : liveAfter;
2008
+ // A client-part plugin's UI is in the page already — toggling it
2009
+ // needs a browser refresh to show the change (same signal the
2010
+ // install flow uses for the hot banner).
2011
+ const refresh = packageHasClientPart(activeProfileDir, name);
2012
+ sendJson(response, ok ? 200 : 502, {
2013
+ ok,
2014
+ name,
2015
+ enabled,
2016
+ disabled: [...disabled],
2017
+ live: listHotMounts(),
2018
+ activation: { [name]: verifyActivation(config.profile, name, liveNames(), activeProfileDir, offNow) },
2019
+ reason,
2020
+ patchRows,
2021
+ patchWrite: patchWrite ?? { ok: true, reason: null },
2022
+ carrier: disablesOthers,
2023
+ bundleSwitch,
2024
+ restart,
2025
+ refresh,
2026
+ });
2027
+ });
2028
+ }
2029
+ catch (error) {
2030
+ const message = error instanceof Error ? error.message : String(error);
2031
+ logEvent('error', 'toggle', `route error: ${message}`);
2032
+ sendJson(response, 500, { error: message });
2033
+ }
2034
+ },
2035
+ }),
2036
+ host.webServer.register({
2037
+ kind: 'exact',
2038
+ path: '/dsh-market/note',
2039
+ handler: async (request, response) => {
2040
+ if (request.method !== 'POST') {
2041
+ response.writeHead(405, { allow: 'POST' });
2042
+ response.end();
2043
+ return;
2044
+ }
2045
+ if (!sameOrigin(request)) {
2046
+ sendJson(response, 403, { error: 'untrusted origin' });
2047
+ return;
2048
+ }
2049
+ try {
2050
+ await withMutationLock(response, 'write', async () => {
2051
+ const body = (await readJsonBody(request));
2052
+ const name = typeof body?.name === 'string' ? body.name : '';
2053
+ if (name === '') {
2054
+ sendJson(response, 400, { error: 'name is required / 需要 name' });
2055
+ return;
2056
+ }
2057
+ const state = readMarketState(activeProfileDir);
2058
+ const notes = { ...state.notes };
2059
+ const text = typeof body?.text === 'string' ? body.text.trim().slice(0, MAX_NOTE) : '';
2060
+ // Empty clears rather than storing a blank: a row must not claim
2061
+ // to carry a note the user just erased.
2062
+ if (text === '')
2063
+ delete notes[name];
2064
+ else
2065
+ notes[name] = text;
2066
+ writeMarketState(activeProfileDir, { ...state, notes });
2067
+ refreshMarketState();
2068
+ sendJson(response, 200, { ok: true, notes });
2069
+ });
2070
+ }
2071
+ catch (error) {
2072
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
2073
+ }
2074
+ },
2075
+ }),
2076
+ host.webServer.register({
2077
+ kind: 'exact',
2078
+ path: '/dsh-market/favorite',
2079
+ handler: async (request, response) => {
2080
+ if (request.method !== 'POST') {
2081
+ response.writeHead(405, { allow: 'POST' });
2082
+ response.end();
2083
+ return;
2084
+ }
2085
+ if (!sameOrigin(request)) {
2086
+ sendJson(response, 403, { error: 'untrusted origin' });
2087
+ return;
2088
+ }
2089
+ try {
2090
+ await withMutationQueued(async () => {
2091
+ const body = (await readJsonBody(request));
2092
+ const url = typeof body?.url === 'string' ? body.url.trim() : '';
2093
+ if (url === '' || (!url.startsWith('http://') && !url.startsWith('https://'))) {
2094
+ sendJson(response, 400, { error: 'url is required / 需要有效的 http(s) url' });
2095
+ return;
2096
+ }
2097
+ const state = readMarketState(activeProfileDir);
2098
+ const favorites = [...(state.favorites ?? [])];
2099
+ const favorited = body?.favorited === true;
2100
+ if (favorited) {
2101
+ if (favorites.includes(url)) {
2102
+ sendJson(response, 200, { ok: true, favorites });
2103
+ return;
2104
+ }
2105
+ if (favorites.length >= MAX_FAVORITES) {
2106
+ sendJson(response, 400, {
2107
+ error: `favorites limit reached (${String(MAX_FAVORITES)}) / 收藏已达上限(${String(MAX_FAVORITES)})`,
2108
+ });
2109
+ return;
2110
+ }
2111
+ favorites.push(url);
2112
+ }
2113
+ else {
2114
+ const index = favorites.indexOf(url);
2115
+ if (index !== -1)
2116
+ favorites.splice(index, 1);
2117
+ }
2118
+ // Re-read immediately before write so a concurrent install cannot
2119
+ // leave us holding a stale disabled/groups snapshot (#414).
2120
+ const fresh = readMarketState(activeProfileDir);
2121
+ writeMarketState(activeProfileDir, { ...fresh, favorites });
2122
+ refreshMarketState();
2123
+ sendJson(response, 200, { ok: true, favorites });
2124
+ });
2125
+ }
2126
+ catch (error) {
2127
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
2128
+ }
2129
+ },
2130
+ }),
2131
+ host.webServer.register({
2132
+ kind: 'exact',
2133
+ path: '/dsh-market/groups',
2134
+ handler: async (request, response) => {
2135
+ if (request.method !== 'POST') {
2136
+ response.writeHead(405, { allow: 'POST' });
2137
+ response.end();
2138
+ return;
2139
+ }
2140
+ if (!sameOrigin(request)) {
2141
+ sendJson(response, 403, { error: 'untrusted origin' });
2142
+ return;
2143
+ }
2144
+ try {
2145
+ const body = (await readJsonBody(request));
2146
+ const action = typeof body.action === 'string' ? body.action : '';
2147
+ const known = action === 'create' || action === 'rename' || action === 'delete'
2148
+ || action === 'set-members' || action === 'toggle';
2149
+ if (!known) {
2150
+ sendJson(response, 400, { ok: false, error: 'unknown group action' });
2151
+ return;
2152
+ }
2153
+ const installed = new Set(Object.keys(readInstalled(config.profile, activeProfileDir)));
2154
+ // Theme members follow the global one-active-theme rule: a group
2155
+ // holds at most one, and enabling one deactivates every other.
2156
+ const themeNames = await themes.installedThemeNames();
2157
+ let ok = true;
2158
+ let error;
2159
+ let restartMembers = [];
2160
+ let refreshMembers = [];
2161
+ if (action === 'toggle') {
2162
+ const name = typeof body.name === 'string' ? body.name : '';
2163
+ const enabled = body.enabled === true;
2164
+ if (groups[name] === undefined) {
2165
+ sendJson(response, 400, { ok: false, error: 'group not found / 分组不存在' });
2166
+ return;
2167
+ }
2168
+ pendingRollbacks.clear();
2169
+ // Batch toggle: on = every installed member enabled, off = every
2170
+ // member disabled. Each member keeps its own persisted flag, so
2171
+ // later individual toggles still work (the group switch itself is
2172
+ // derived state and never stored).
2173
+ const failures = [];
2174
+ for (const member of groups[name]) {
2175
+ if (!installed.has(member))
2176
+ continue;
2177
+ const result = enabled && themeNames.has(member)
2178
+ ? { ok: await themes.activateTheme(member), reason: undefined }
2179
+ : await setPluginEnabled(member, enabled);
2180
+ if (!result.ok)
2181
+ failures.push(member);
2182
+ // Same live-mismatch signal as the single toggle: a member
2183
+ // whose fiber did not follow the switch needs a boot.
2184
+ const liveAfter = liveNames().has(member);
2185
+ if ((enabled && !liveAfter) || (!enabled && liveAfter))
2186
+ restartMembers.push(member);
2187
+ // Client-part members need a page refresh to show the change.
2188
+ if (packageHasClientPart(activeProfileDir, member))
2189
+ refreshMembers.push(member);
2190
+ }
2191
+ ok = failures.length === 0;
2192
+ if (!ok)
2193
+ error = `failed to ${enabled ? 'enable' : 'disable'}: ${failures.join(', ')}`;
2194
+ }
2195
+ else {
2196
+ const state = { groups, groupOrder };
2197
+ const result = action === 'create' ? createGroup(state, body.name)
2198
+ : action === 'rename' ? renameGroup(state, body.name, body.newName)
2199
+ : action === 'delete' ? deleteGroup(state, body.name)
2200
+ : setGroupMembers(state, body.name, body.members, installed, themeNames);
2201
+ ok = result.ok;
2202
+ error = result.error;
2203
+ }
2204
+ if (ok)
2205
+ writeMarketState(activeProfileDir, { disabled, groups, groupOrder });
2206
+ logEvent(ok ? 'info' : 'warn', 'groups', `${action}${typeof body.name === 'string' ? ' ' + body.name : ''}${ok ? '' : ` — ${error ?? ''}`}`);
2207
+ sendJson(response, ok ? 200 : 400, {
2208
+ ok,
2209
+ error,
2210
+ groups,
2211
+ groupOrder,
2212
+ disabled: [...disabled],
2213
+ restartMembers,
2214
+ refreshMembers,
2215
+ });
2216
+ }
2217
+ catch (error) {
2218
+ const message = error instanceof Error ? error.message : String(error);
2219
+ logEvent('error', 'groups', `route error: ${message}`);
2220
+ sendJson(response, 500, { error: message });
2221
+ }
2222
+ },
2223
+ }),
2224
+ host.webServer.register({
2225
+ kind: 'exact',
2226
+ path: '/dsh-market/status',
2227
+ handler: async (request, response) => {
2228
+ if (request.method !== 'GET') {
2229
+ response.writeHead(405, { allow: 'GET' });
2230
+ response.end();
2231
+ return;
2232
+ }
2233
+ await dropStaleHotMounts();
2234
+ const installed = readInstalled(config.profile, activeProfileDir);
2235
+ sendJson(response, 200, {
2236
+ active: progress.active,
2237
+ target: progress.target,
2238
+ seconds: progress.active ? Math.round((Date.now() - progress.startedAt) / 1000) : 0,
2239
+ lastLine: progress.lastLine,
2240
+ phase: progress.phase,
2241
+ done: progress.done,
2242
+ total: progress.total,
2243
+ currentPackage: progress.currentPackage,
2244
+ downloaded: progress.downloaded,
2245
+ size: progress.size,
2246
+ ndjson: progress.ndjson,
2247
+ error: progress.error,
2248
+ cancelling: progress.cancelling,
2249
+ // The route-level operation flag, NOT progress.active: after pnpm
2250
+ // exits, install post-processing (retarget, validation, hot-mount)
2251
+ // still holds the operation lock for a moment — the exact window
2252
+ // where clicking the restart banner used to bounce off a 409 (#91).
2253
+ busy: installing,
2254
+ pnpm: await commands.probePnpm(),
2255
+ boot: BOOT_ID,
2256
+ agentGuardAvailable: agentsGuardAvailable(),
2257
+ // Shown in the page heading so screenshots carry it (#159).
2258
+ version: marketVersion(),
2259
+ channel: activeChannel(),
2260
+ channels: CHANNELS,
2261
+ region,
2262
+ regions: REGIONS,
2263
+ // The prefix the BROWSER should put in front of github.com URLs
2264
+ // (avatars, README images). Sent resolved rather than derived from
2265
+ // `region` on the client, so the routing table has one home and a
2266
+ // change to it cannot leave the two halves disagreeing.
2267
+ githubProxy: routesFor(region).githubProxy,
2268
+ // New clients use per-service candidates. Keep githubProxy above
2269
+ // for older bundles that understand only one prefix.
2270
+ githubRoutes: routesFor(region).githubRoutes,
2271
+ githubProxyCustom: marketState.githubProxy ?? null,
2272
+ githubProxyManaged: githubProxyManaged(),
2273
+ // Whether the region was decided by the network check rather than
2274
+ // by the user — the card explains a choice it made on their behalf
2275
+ // exactly once, so nobody has to wonder why downloads moved.
2276
+ regionAuto,
2277
+ restart: restartAllowed(config),
2278
+ // Named so the UI can say WHY the button is gone. A blank
2279
+ // "no restart button" is the state #229 reported as broken.
2280
+ supervisor: detectedSupervisor(),
2281
+ debugger: detectedDebugger(),
2282
+ selfManaged: SELF_NAME_ORDER.some(name => installed[name] !== undefined),
2283
+ installed,
2284
+ });
2285
+ },
2286
+ }),
2287
+ host.webServer.register({
2288
+ kind: 'exact',
2289
+ path: '/dsh-market/logs',
2290
+ handler: (request, response) => {
2291
+ if (request.method !== 'GET') {
2292
+ response.writeHead(405, { allow: 'GET' });
2293
+ response.end();
2294
+ return;
2295
+ }
2296
+ const version = marketVersion();
2297
+ response.writeHead(200, {
2298
+ 'cache-control': 'no-store',
2299
+ 'content-type': 'text/plain; charset=utf-8',
2300
+ 'content-disposition': 'attachment; filename="dsh-market-log.txt"',
2301
+ });
2302
+ // Built here, not in log.ts: this is the composition, and the log
2303
+ // module deliberately knows nothing about profiles. Each declared
2304
+ // bundle is marked with whether it actually resolves, because an
2305
+ // unresolvable one is what stops the next boot (#339, #341) and it
2306
+ // is invisible in a manifest listing on its own.
2307
+ const snapshot = [];
2308
+ try {
2309
+ const report = analyzeProfile(activeProfileDir);
2310
+ const installed = readInstalled(config.profile, activeProfileDir);
2311
+ snapshot.push(`dependencies (${String(Object.keys(installed).length)}):`);
2312
+ for (const [name, spec] of Object.entries(installed))
2313
+ snapshot.push(` ${name}: ${spec}`);
2314
+ snapshot.push(`bundles (${String(report.bundles.length)}):`);
2315
+ for (const layer of report.bundles) {
2316
+ const state = layer.directory !== null
2317
+ ? 'ok'
2318
+ : layer.unresolvedInbox === true
2319
+ ? 'supplied by the dsh installation (not locatable from here)'
2320
+ : 'NOT RESOLVED — the next start fails here';
2321
+ snapshot.push(` ${layer.name}: ${state}`);
2322
+ }
2323
+ if (report.summary.errors.length > 0) {
2324
+ snapshot.push('errors:');
2325
+ for (const line of report.summary.errors)
2326
+ snapshot.push(` ${line}`);
2327
+ }
2328
+ }
2329
+ catch (error) {
2330
+ snapshot.push(`profile state unavailable: ${error instanceof Error ? error.message : String(error)}`);
2331
+ }
2332
+ // The host version, and where it was found. Absent until now, and
2333
+ // it is the field investigations kept stalling on: #293 spent three
2334
+ // rounds before it emerged that the reporter's host was newer than
2335
+ // every attempt to reproduce, and a path under Electron's resources
2336
+ // is how a Desktop-bundled (possibly older, #139) host announces
2337
+ // itself. sanitize() rewrites the home prefix in the value.
2338
+ const host = dshHostInfo();
2339
+ response.end(exportLogs({
2340
+ 'ChatCode CLI Market': version,
2341
+ 'ChatCode CLI host': host === null
2342
+ ? 'not locatable from this process'
2343
+ : `${host.version} (${host.directory})`,
2344
+ platform: `${process.platform} ${process.arch}`,
2345
+ node: process.version,
2346
+ profile: config.profile,
2347
+ }, snapshot, readPersistentLog(persistentLogFile)));
2348
+ },
2349
+ }),
2350
+ host.webServer.register({
2351
+ kind: 'exact',
2352
+ path: '/dsh-market/updates',
2353
+ handler: async (request, response) => {
2354
+ if (request.method !== 'GET') {
2355
+ response.writeHead(405, { allow: 'GET' });
2356
+ response.end();
2357
+ return;
2358
+ }
2359
+ try {
2360
+ const force = (request.url ?? '').includes('force=1');
2361
+ // Only the market itself follows the channel setting (see
2362
+ // MarketSettings.channel): a user opting into betas is volunteering
2363
+ // to try THIS plugin early, not to be handed every other author's
2364
+ // unreleased work.
2365
+ const channel = activeChannel();
2366
+ const installed = readInstalled(config.profile, activeProfileDir);
2367
+ const channelFor = new Map(Object.keys(installed)
2368
+ .filter(name => SELF_NAMES.has(name))
2369
+ .map(name => [name, channel]));
2370
+ const onlineSourceFor = new Map();
2371
+ const sourceMigrationFor = new Map();
2372
+ try {
2373
+ const registry = await loadRegistry();
2374
+ for (const [name, spec] of Object.entries(installed)) {
2375
+ const migration = findGitToNpmMigration(registry.plugins, spec);
2376
+ if (migration !== null)
2377
+ sourceMigrationFor.set(name, migration);
2378
+ if (!spec.toLowerCase().startsWith('file:'))
2379
+ continue;
2380
+ const evidence = readInstalledRepoEvidence(config.profile, name, spec, activeProfileDir);
2381
+ const entry = findCatalogEntryForLocal(registry.plugins, name, evidence.identities, evidence.hints);
2382
+ const target = entry === null ? null : restoreTargetForLocal(entry, evidence.identities);
2383
+ if (target !== null && NPM_NAME_RE.test(target))
2384
+ onlineSourceFor.set(name, target);
2385
+ }
2386
+ }
2387
+ catch (error) {
2388
+ logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`);
2389
+ }
2390
+ const updates = await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor);
2391
+ for (const [name, migration] of sourceMigrationFor) {
2392
+ const status = updates[name];
2393
+ if (status !== undefined)
2394
+ updates[name] = { ...status, sourceMigration: migration };
2395
+ }
2396
+ sendJson(response, 200, { updates });
2397
+ }
2398
+ catch (error) {
2399
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
2400
+ }
2401
+ },
2402
+ }),
2403
+ // Read-only notes for the update dialog (#294): release body and/or a
2404
+ // commit tail sliced at the installed sha, resolved from the catalog
2405
+ // side's daily probe — no GitHub API call is made here, ever. The handler
2406
+ // itself does not throw (every failure degrades to `kind: 'none'`), so a
2407
+ // dialog that cannot load its data shows a neutral statement rather than
2408
+ // an error banner.
2409
+ host.webServer.register({
2410
+ kind: 'exact',
2411
+ path: '/dsh-market/changelog',
2412
+ handler: async (request, response) => {
2413
+ if (request.method !== 'GET') {
2414
+ response.writeHead(405, { allow: 'GET' });
2415
+ response.end();
2416
+ return;
2417
+ }
2418
+ try {
2419
+ const name = new URL(request.url ?? '/', 'http://localhost').searchParams.get('name') ?? '';
2420
+ if (name === '') {
2421
+ sendJson(response, 400, { error: 'name query parameter is required' });
2422
+ return;
2423
+ }
2424
+ sendJson(response, 200, await updateNotesFor(config.profile, activeProfileDir, name));
2425
+ }
2426
+ catch (error) {
2427
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
2428
+ }
2429
+ },
2430
+ }),
2431
+ host.webServer.register({
2432
+ kind: 'exact',
2433
+ path: '/dsh-market/migrate-source',
2434
+ handler: async (request, response) => {
2435
+ if (request.method !== 'POST') {
2436
+ response.writeHead(405, { allow: 'POST' });
2437
+ response.end();
2438
+ return;
2439
+ }
2440
+ if (!sameOrigin(request)) {
2441
+ sendJson(response, 403, { error: 'untrusted origin' });
2442
+ return;
2443
+ }
2444
+ try {
2445
+ await withMutationLock(response, 'install', async () => {
2446
+ const body = (await readJsonBody(request));
2447
+ const name = typeof body.name === 'string' ? body.name : '';
2448
+ if (!NPM_NAME_RE.test(name) || INBOX_BUNDLES.has(name)) {
2449
+ sendJson(response, 400, { error: 'plugin is not installed' });
2450
+ return;
2451
+ }
2452
+ const manifestCapture = captureUpdateManifest();
2453
+ if (!manifestCapture.ok) {
2454
+ sendJson(response, 500, {
2455
+ error: `迁移前无法安全读取 profile package.json,未执行任何修改(${manifestCapture.detail})。 / The profile package.json could not be captured safely before migration; nothing was changed (${manifestCapture.detail}).`,
2456
+ });
2457
+ return;
2458
+ }
2459
+ const spec = manifestCapture.snapshot.dependencies[name];
2460
+ if (spec === undefined) {
2461
+ sendJson(response, 400, { error: 'plugin is not installed' });
2462
+ return;
2463
+ }
2464
+ let migration = null;
2465
+ try {
2466
+ const registry = await loadRegistry();
2467
+ migration = findGitToNpmMigration(registry.plugins, spec);
2468
+ }
2469
+ catch (error) {
2470
+ logEvent('warn', 'source-migration', `${name}: catalog lookup failed — ${error instanceof Error ? error.message : String(error)}`);
2471
+ }
2472
+ if (migration === null) {
2473
+ sendJson(response, 400, {
2474
+ error: '当前 Git 来源无法唯一核验到一个 npm 包,或包含显式 branch/tag/commit/ref;未执行迁移。 / This Git source cannot be uniquely verified to one npm package, or it carries an explicit branch/tag/commit/ref; migration was not performed.',
2475
+ });
2476
+ return;
2477
+ }
2478
+ const targetName = migration.target;
2479
+ if (targetName !== name && manifestCapture.snapshot.dependencies[targetName] !== undefined) {
2480
+ sendJson(response, 409, {
2481
+ error: `目标 npm 包 ${targetName} 已经作为独立依赖安装;为避免覆盖现有安装,未执行迁移。 / The target npm package ${targetName} is already installed as a separate dependency; migration was not performed to avoid overwriting it.`,
2482
+ });
2483
+ return;
2484
+ }
2485
+ const busyAgents = runningAgentsForGuard();
2486
+ if (busyAgents.length > 0) {
2487
+ sendJson(response, 409, {
2488
+ error: `有 agent 正在运行(${busyAgents.join(', ')})。来源迁移会替换插件文件,请等它完成或取消后再迁移。 / ${busyAgents.length === 1 ? 'An agent is running' : 'Agents are running'} (${busyAgents.join(', ')}). Source migration replaces plugin files; wait for the running work to finish (or cancel it) before migrating.`,
2489
+ agentsBusy: true,
2490
+ runningAgents: busyAgents,
2491
+ });
2492
+ return;
2493
+ }
2494
+ const lockfileCapture = captureProfileLockfile();
2495
+ if (!lockfileCapture.ok) {
2496
+ sendJson(response, 500, { error: lockfileCapture.detail });
2497
+ return;
2498
+ }
2499
+ const wasLive = verifyActivation(config.profile, name, liveNames(), activeProfileDir, disabled.has(name)).state === 'live'
2500
+ && hasHostHalf(config.profile, name, activeProfileDir);
2501
+ const oldRows = rowIdsForPackage(host, activeProfileDir, name);
2502
+ const patchFlags = packagePatchFlags(host, activeProfileDir, [name], readUserPatchState(userPatchPath));
2503
+ const wasDisabled = disabled.has(name);
2504
+ const manifestBefore = manifestCapture.snapshot;
2505
+ const lockfileBefore = lockfileCapture.snapshot;
2506
+ const rollbackMigration = async () => {
2507
+ restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
2508
+ const prepared = restoreProfileLockfile(lockfileBefore);
2509
+ if (!prepared.ok)
2510
+ return prepared;
2511
+ const reinstall = await runPlugin(config.profile, ['--no-frozen-lockfile', RELEASE_AGE_OVERRIDE, 'install']);
2512
+ restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
2513
+ const finalLock = restoreProfileLockfile(lockfileBefore);
2514
+ if (!finalLock.ok)
2515
+ return finalLock;
2516
+ if (reinstall.exitCode !== 0 || reinstall.timedOut || reinstall.cancelled) {
2517
+ return { ok: false, detail: failureDetail(reinstall) };
2518
+ }
2519
+ return hasLoadableEntry(activeProfileDir, name)
2520
+ ? { ok: true, detail: null }
2521
+ : { ok: false, detail: 'the previous Git source was restored without a loadable entry' };
2522
+ };
2523
+ const failWithRollback = async (detail, extra = {}) => {
2524
+ const rollback = await rollbackMigration();
2525
+ logEvent(rollback.ok ? 'warn' : 'error', 'source-migration', `${name}: migration failed — ${detail}; ${rollback.ok ? 'previous Git source restored' : `rollback failed: ${rollback.detail ?? 'unknown'}`}`);
2526
+ sendJson(response, 500, {
2527
+ ok: false,
2528
+ error: rollback.ok
2529
+ ? `${detail};已恢复原 Git 来源。 / ${detail}; the previous Git source was restored.`
2530
+ : `${detail};且原 Git 来源未能验证恢复(${rollback.detail ?? 'unknown'})。 / ${detail}; restoration of the previous Git source could not be verified (${rollback.detail ?? 'unknown'}).`,
2531
+ rollback: rollback.ok,
2532
+ ...extra,
2533
+ });
2534
+ };
2535
+ const remove = await runPlugin(config.profile, ['remove', name]);
2536
+ if (remove.exitCode !== 0 || remove.timedOut || remove.cancelled) {
2537
+ await failWithRollback(failureDetail(remove));
2538
+ return;
2539
+ }
2540
+ const add = await runPlugin(config.profile, ['add', `${targetName}@latest`]);
2541
+ if (add.exitCode !== 0 || add.timedOut || add.cancelled) {
2542
+ await failWithRollback(failureDetail(add), {
2543
+ ignoredBuilds: blockedBuilds(add),
2544
+ cancelled: add.cancelled,
2545
+ });
2546
+ return;
2547
+ }
2548
+ const after = readInstalled(config.profile, activeProfileDir);
2549
+ if (after[targetName] === undefined || (targetName !== name && after[name] !== undefined) || !hasLoadableEntry(activeProfileDir, targetName)) {
2550
+ await failWithRollback('npm 目标安装完成后未形成可加载且唯一的依赖。 / The npm target did not produce one loadable replacement dependency.');
2551
+ return;
2552
+ }
2553
+ const stack = readBundleStack(activeProfileDir);
2554
+ const trial = trialValidate(activeProfileDir, stack.community);
2555
+ if (!trial.ok) {
2556
+ await failWithRollback(`迁移后的 profile 无法通过启动校验(${trial.errors[0]?.message ?? 'unknown'})。 / The migrated profile failed boot validation (${trial.errors[0]?.message ?? 'unknown'}).`);
2557
+ return;
2558
+ }
2559
+ if (targetName !== name) {
2560
+ if (wasDisabled) {
2561
+ disabled.delete(name);
2562
+ disabled.add(targetName);
2563
+ }
2564
+ else {
2565
+ disabled.delete(name);
2566
+ }
2567
+ for (const [group, members] of Object.entries(groups)) {
2568
+ const next = [];
2569
+ for (const member of members) {
2570
+ const mapped = member === name ? targetName : member;
2571
+ if (!next.includes(mapped))
2572
+ next.push(mapped);
2573
+ }
2574
+ groups[group] = next;
2575
+ }
2576
+ const marketNotes = marketState.notes ?? (marketState.notes = {});
2577
+ if (marketNotes[name] !== undefined) {
2578
+ if (marketNotes[targetName] === undefined)
2579
+ marketNotes[targetName] = marketNotes[name];
2580
+ delete marketNotes[name];
2581
+ }
2582
+ }
2583
+ let stateWarning = null;
2584
+ try {
2585
+ writeMarketState(activeProfileDir, marketState);
2586
+ }
2587
+ catch (error) {
2588
+ stateWarning = `市场状态未能持久化:${error instanceof Error ? error.message : String(error)} / Market state could not be persisted: ${error instanceof Error ? error.message : String(error)}`;
2589
+ logEvent('warn', 'source-migration', `${name}: ${stateWarning}`);
2590
+ }
2591
+ removeRowBlocks(userPatchPath, oldRows);
2592
+ const patchDisabled = wasDisabled || patchFlags.disabled.includes(name);
2593
+ const patchForced = !patchDisabled && patchFlags.forced.includes(name);
2594
+ const patchWarnings = [];
2595
+ for (const rowId of rowIdsForPackage(host, activeProfileDir, targetName)) {
2596
+ const changed = patchDisabled
2597
+ ? await disableRow(userPatchPath, rowId)
2598
+ : patchForced
2599
+ ? await enableRow(userPatchPath, rowId)
2600
+ : { ok: true, reason: null };
2601
+ if (!changed.ok && changed.reason !== null)
2602
+ patchWarnings.push(changed.reason);
2603
+ }
2604
+ if (patchDisabled)
2605
+ await themes.setEntryDisabled(targetName, true);
2606
+ invalidateUpdates();
2607
+ if (wasLive)
2608
+ replacedWhileLive.add(targetName);
2609
+ const activation = {
2610
+ [targetName]: activationAfterReplace(verifyActivation(config.profile, targetName, liveNames(), activeProfileDir, disabled.has(targetName)), wasLive),
2611
+ };
2612
+ logEvent('info', 'source-migration', `${name}: ${spec} -> ${targetName}`);
2613
+ sendJson(response, 200, {
2614
+ ok: true,
2615
+ from: { name, source: spec },
2616
+ to: { name: targetName, source: 'npm' },
2617
+ activation,
2618
+ warnings: [stateWarning, ...patchWarnings].filter((value) => value !== null && value !== ''),
2619
+ });
2620
+ });
2621
+ }
2622
+ catch (error) {
2623
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
2624
+ }
2625
+ },
2626
+ }),
2627
+ host.webServer.register(captureLegacy('/dsh-market/update', {
2628
+ kind: 'exact',
2629
+ path: '/dsh-market/update',
2630
+ handler: async (request, response) => {
2631
+ if (request.method !== 'POST') {
2632
+ response.writeHead(405, { allow: 'POST' });
2633
+ response.end();
2634
+ return;
2635
+ }
2636
+ if (!sameOrigin(request)) {
2637
+ sendJson(response, 403, { error: 'untrusted origin' });
2638
+ return;
2639
+ }
2640
+ try {
2641
+ await withMutationLock(response, 'install', async () => {
2642
+ const body = (await readJsonBody(request));
2643
+ const name = typeof body.name === 'string' ? body.name : '';
2644
+ const force = body.force === true;
2645
+ const restore = body.restore === true;
2646
+ const manifestCapture = captureUpdateManifest();
2647
+ if (!manifestCapture.ok) {
2648
+ sendJson(response, 500, {
2649
+ error: `更新前无法安全读取 profile package.json,未执行任何修改(${manifestCapture.detail})。 / The profile package.json could not be captured safely before update; nothing was changed (${manifestCapture.detail}).`,
2650
+ });
2651
+ return;
2652
+ }
2653
+ let spec = manifestCapture.snapshot.dependencies[name];
2654
+ if (spec === undefined || INBOX_BUNDLES.has(name)) {
2655
+ sendJson(response, 400, { error: 'plugin is not installed' });
2656
+ return;
2657
+ }
2658
+ if (restore && !isLocalSpec(spec)) {
2659
+ sendJson(response, 400, { error: 'restore 只适用于 link:/file: 的本地开发安装。 / Restore only applies to locally developed link:/file: installs.' });
2660
+ return;
2661
+ }
2662
+ if (restore && SELF_NAMES.has(name) && spec.toLowerCase().startsWith('link:')) {
2663
+ sendJson(response, 400, { error: '市场的本地开发链接不会被线上版本替换。 / The market\'s local development link is never replaced by an online release.' });
2664
+ return;
2665
+ }
2666
+ if (isLocalSpec(spec)) {
2667
+ if (!restore) {
2668
+ sendJson(response, 400, { error: 'locally linked plugins update from their checkout' });
2669
+ return;
2670
+ }
2671
+ // Restore replaces the local checkout with the curated source
2672
+ // so the ordinary update check can see it again. Same add path
2673
+ // as a registry update — only the resolved target changes.
2674
+ let catalogTarget = null;
2675
+ try {
2676
+ const registry = await loadRegistry();
2677
+ const evidence = readInstalledRepoEvidence(config.profile, name, spec, activeProfileDir);
2678
+ const entry = findCatalogEntryForLocal(registry.plugins, name, evidence.identities, evidence.hints);
2679
+ catalogTarget = entry === null ? null : restoreTargetForLocal(entry, evidence.identities);
2680
+ const workspaceDeps = workspaceProtocolDeps(readInstalledManifest(config.profile, name, activeProfileDir));
2681
+ if (catalogTarget !== null && restoreBlockedByWorkspace(catalogTarget, workspaceDeps)) {
2682
+ sendJson(response, 400, {
2683
+ error: `该插件依赖 monorepo workspace 包(${workspaceDeps.join(', ')}),无法从 Git 子目录单独恢复。请继续用本地开发,或等作者发布 npm 后再恢复。 / This plugin depends on monorepo workspace packages (${workspaceDeps.join(', ')}); a git subdirectory install cannot resolve workspace: protocol. Keep the local checkout, or restore after the author publishes to npm.`,
2684
+ });
2685
+ return;
2686
+ }
2687
+ }
2688
+ catch (error) {
2689
+ logEvent('warn', 'update', `${name}: restore catalog lookup failed — ${error instanceof Error ? error.message : String(error)}`);
2690
+ }
2691
+ if (catalogTarget === null) {
2692
+ sendJson(response, 400, {
2693
+ error: '目录里找不到对应的线上版本,无法从本地开发恢复。 / No catalog entry matches this local plugin, so it cannot be restored to a registry install.',
2694
+ });
2695
+ return;
2696
+ }
2697
+ spec = catalogTarget;
2698
+ }
2699
+ // Replacing a package on disk under a live agent is a mixed-state
2700
+ // hazard the "restart" verdict cannot fix: the running module keeps
2701
+ // executing while its files change under it, so lazily imported
2702
+ // assets and data reads can fail or change version mid-turn.
2703
+ // No bypass is offered — the user can wait or cancel the agent.
2704
+ const busyAgents = runningAgentsForGuard();
2705
+ if (busyAgents.length > 0) {
2706
+ logEvent('warn', 'update-blocked', `${name}: refused while agents are running — ${busyAgents.join(', ')}`);
2707
+ sendJson(response, 409, {
2708
+ error: `有 agent 正在运行(${busyAgents.join(', ')})。更新会直接替换插件文件,正在工作的 agent 可能在执行中途读到缺失或新版本的文件而报错;请等它完成或取消后再更新。 / ${busyAgents.length === 1 ? 'An agent is running' : 'Agents are running'} (${busyAgents.join(', ')}). Updating replaces plugin files in place, so a working agent can fail or mix versions mid-turn; wait for it to finish (or cancel it) before updating.`,
2709
+ agentsBusy: true,
2710
+ runningAgents: busyAgents,
2711
+ });
2712
+ return;
2713
+ }
2714
+ const beforeInstalled = readInstalled(config.profile, activeProfileDir);
2715
+ // Re-running add re-resolves the source: git HEAD for github specs,
2716
+ // dist-tag latest for registry installs.
2717
+ // A GitHub source in EITHER spelling. A legacy regional install
2718
+ // can carry a proxied codeload URL rather than the `github:`
2719
+ // shortcut, and asking only about the shortcut sent those down
2720
+ // the npm path below —
2721
+ // where `name@latest` either fails or, far worse, installs an
2722
+ // unrelated package that happens to share the plugin's name.
2723
+ // The `github:` shortcut keeps its own handling, fragments and
2724
+ // all — `githubUpdateTarget` is what preserves a monorepo
2725
+ // `#path:` while dropping revision selectors (#281).
2726
+ //
2727
+ // A proxied codeload URL is the legacy spelling of the same
2728
+ // source. It has no fragment to preserve (subpath entries
2729
+ // are never accelerated), so the canonical shortcut is rebuilt
2730
+ // from it. Without this branch these fell through to the npm path
2731
+ // below, where `name@latest` either fails or — far worse —
2732
+ // installs an unrelated package that shares the plugin's name.
2733
+ const codeloadRepo = spec.startsWith('github:') ? null : repoOfTarget(spec);
2734
+ const gitSpec = spec.startsWith('github:')
2735
+ ? githubUpdateTarget(spec)
2736
+ : codeloadRepo === null ? null : `github:${codeloadRepo}`;
2737
+ // Non-GitHub git remotes (Gitea / git+https): keep the remote URL
2738
+ // as the add target. Falling through to name@latest is what #525
2739
+ // reported — a colliding npm package replaced the private install.
2740
+ const genericGitTarget = gitSpec === null ? gitUpdateTarget(spec) : null;
2741
+ const isGit = gitSpec !== null || genericGitTarget !== null;
2742
+ const isReleaseTarball = !restore && isGitHubReleaseTarballSpec(spec);
2743
+ const isNpmRollbackSource = !restore && !isGit && !isReleaseTarball
2744
+ // Market-managed npm installs persist only a range, version, or
2745
+ // dist-tag. Any protocol/path/manual shorthand needs its own
2746
+ // proven source-preserving rollback rather than being guessed
2747
+ // into name@<installed-version>.
2748
+ && !/[:/\\]/.test(spec);
2749
+ // Every ordinary non-Git update still installs name@<tag>, even
2750
+ // when its PREVIOUS source was a release URL. Source
2751
+ // classification chooses rollback mechanics; it must not weaken
2752
+ // the existing registry target/downgrade validation.
2753
+ const usesNpmUpdateTarget = !restore && !isGit;
2754
+ // `@latest` was hardcoded, so a beta subscriber would have been
2755
+ // told an update existed and then handed the stable build. The
2756
+ // dist-tag has to follow the same setting the offer came from.
2757
+ // The market follows its channel; everything else is `latest`.
2758
+ const selfChannel = SELF_NAMES.has(name) ? activeChannel() : null;
2759
+ const tag = selfChannel === null ? 'latest' : DIST_TAG[selfChannel];
2760
+ let expectedNpmVersion = null;
2761
+ // Resolve the registry pin BEFORE building the add target (#496).
2762
+ // Desktop's install boundary otherwise fetches `latest` again to
2763
+ // rewrite `@latest` into `name@x.y.z`; when the two views drift,
2764
+ // verification against the first fetch rolls back a correct
2765
+ // install. Pinning the already-resolved version here makes the
2766
+ // boundary a no-op rewrite and keeps one source of truth.
2767
+ //
2768
+ // The pin also decides what pnpm 11's fresh-release hold does,
2769
+ // which is the whole of #531 (@Astro-Han). Measured against real
2770
+ // pnpm 11.7.0 with `minimumReleaseAge: 1440` and a registry whose
2771
+ // publish times the measurement controlled — `latest` moved to a
2772
+ // version published five minutes earlier:
2773
+ //
2774
+ // add pkg@latest → exit 0, installs the OLDER version, writes
2775
+ // ^older into the manifest, and says NOTHING
2776
+ // about having skipped one.
2777
+ // add pkg@2.0.0 → ERR_PNPM_NO_MATURE_MATCHING_VERSION,
2778
+ // nothing installed.
2779
+ //
2780
+ // The first is a silent downgrade the market can only notice
2781
+ // afterwards, by which point its own verification calls the
2782
+ // result a RESOLVED_VERSION_MISMATCH and rolls a real upgrade
2783
+ // back to where the user started — every day, for as long as
2784
+ // releases are daily. The second is an error the market already
2785
+ // recovers from: classifyPnpmFailure reads it as
2786
+ // release-age-violation and withHoistRecovery retries once with
2787
+ // --config.minimumReleaseAge=0 (#39).
2788
+ //
2789
+ // So a version resolved BEFORE the add is not only about the
2790
+ // Desktop boundary; it is what turns a silent skip into a
2791
+ // failure with a name.
2792
+ if (usesNpmUpdateTarget) {
2793
+ const installedVersion = readInstalledVersion(config.profile, name, activeProfileDir);
2794
+ const registryLatest = selfChannel === null
2795
+ ? await fetchNpmLatest(name)
2796
+ : await versionOnChannel(name, selfChannel, await fetchNpmLatest(name));
2797
+ expectedNpmVersion = registryLatest;
2798
+ // Never let `@latest` walk a profile BACKWARDS (#64 by @ZeroOrigin64):
2799
+ // a package whose latest dist-tag was left on an older release turns
2800
+ // this update into a downgrade that also rewrites an exact pin to
2801
+ // `@latest`. Detection already hides the button; this guards the
2802
+ // route itself. Unreadable versions fall through and update as before.
2803
+ //
2804
+ // A channel-following package is exempt from the DIRECTION, not
2805
+ // from the check. Going backwards is exactly what "put me back on
2806
+ // stable" means, and #64 is about a downgrade nobody asked for —
2807
+ // so here the guard only refuses when the channel already points
2808
+ // at what is installed, and it compares against the target tag
2809
+ // rather than `latest`, which is not the tag being installed.
2810
+ const refuse = selfChannel === null
2811
+ ? installedVersion !== null && registryLatest !== null && !isUpgrade(installedVersion, registryLatest)
2812
+ : installedVersion !== null && registryLatest !== null && installedVersion === registryLatest;
2813
+ // "Already there" is not a failure (#495 by @Ztyss). The two
2814
+ // halves of this guard are different events wearing one reply:
2815
+ // the registry pointing at an OLDER release is a downgrade the
2816
+ // user must see, while it pointing at exactly what is installed
2817
+ // means the request is a no-op — usually because the page's
2818
+ // updatable list was snapshotted before an earlier round of the
2819
+ // same batch updated this plugin. Answering that with a 400 made
2820
+ // a batch that did everything right report "update failed" for
2821
+ // three plugins that were already on the version they asked for.
2822
+ //
2823
+ // 200 with `skipped`, so the page can drop the row and re-read
2824
+ // the list rather than counting a failure; no restart is owed,
2825
+ // because nothing on disk changed.
2826
+ if (refuse && installedVersion === registryLatest) {
2827
+ logEvent('info', 'update', `${name} already current at ${installedVersion}; nothing to do`);
2828
+ // The listing that offered this update is provably behind the
2829
+ // profile, so drop it rather than serve the same wrong row for
2830
+ // the rest of the TTL.
2831
+ invalidateUpdates();
2832
+ sendJson(response, 200, { ok: true, skipped: 'current', name, version: installedVersion });
2833
+ return;
2834
+ }
2835
+ if (refuse) {
2836
+ logEvent('info', 'update', `${name} refused: latest=${registryLatest} is not newer than installed=${installedVersion}`);
2837
+ sendJson(response, 400, {
2838
+ error: `更新会降级:registry 上的最新版是 ${registryLatest},比已装的 ${installedVersion} 还旧,已停止,插件保持不变。 / Updating would downgrade this plugin: the registry's latest (${registryLatest}) is older than the installed ${installedVersion}, so nothing was changed.`,
2839
+ });
2840
+ return;
2841
+ }
2842
+ // Ask BEFORE installing whether this release says it needs a
2843
+ // newer host than the one running (#404 by @Ztyss).
2844
+ //
2845
+ // The report: a caret range floated a plugin onto a release
2846
+ // that had migrated to a host API the user's bundled runtime
2847
+ // did not have. It installed silently and broke the panel; the
2848
+ // only way back was a manual downgrade. The market noticed
2849
+ // nothing, because at the time it only compared what a plugin
2850
+ // DECLARED, and almost nothing declared anything — the reason
2851
+ // this was left open rather than built.
2852
+ //
2853
+ // #473 changed that premise. Reading `engines.dsh` together
2854
+ // with the lockstep `@deepseek-ai/dsh-*` peers took catalog
2855
+ // coverage from about 2% to about 60%, so for most plugins
2856
+ // there is now a claim to check. Discover already shows it;
2857
+ // this is the same derivation applied at the moment it can
2858
+ // still prevent something.
2859
+ //
2860
+ // Only a CONFIRMED mismatch stops here: `incompatible` means a
2861
+ // declaration was read and is not satisfied. Undeclared,
2862
+ // unreadable, and unknown host versions all go through, which
2863
+ // is most of the ecosystem and the same stance Discover takes
2864
+ // — absence of a claim is not a verdict.
2865
+ //
2866
+ // `force` is the way past it, because the market can be the
2867
+ // wrong one here: a bundled host that misreports its version
2868
+ // makes a satisfiable requirement look unsatisfied, and the
2869
+ // user who knows that must not be locked out of their own
2870
+ // profile. Refused with 400 and the facts, so the page can ask
2871
+ // rather than dead-end.
2872
+ if (selfChannel === null && !force && registryLatest !== null) {
2873
+ const host = dshHostInfo();
2874
+ const verdict = deriveHostCompatibility((await discoveryManifests.lookup([name], routesFor(region).npmRegistry))[name] ?? null, host?.version ?? null, corePackageNames(host?.directory ?? null));
2875
+ if (verdict.status === 'incompatible') {
2876
+ logEvent('warn', 'update-compat', `${name}@${registryLatest} declares ${verdict.requirement ?? 'a host requirement'}; this host is ${host?.version ?? 'unknown'} — refused before installing`);
2877
+ sendJson(response, 400, {
2878
+ hostIncompatible: {
2879
+ name,
2880
+ version: registryLatest,
2881
+ requirement: verdict.requirement,
2882
+ hostVersion: host?.version ?? null,
2883
+ },
2884
+ error: `${name} ${registryLatest} 要求的 DSH 版本是 ${verdict.requirement ?? '未知'},而当前运行的是 ${host?.version ?? '未知版本'},装上多半会直接报错。已停止,插件保持在原来的版本。 / ${name} ${registryLatest} declares it needs DSH ${verdict.requirement ?? '(unknown)'}, and this host is ${host?.version ?? 'unknown'}; installing it would most likely break the plugin. Nothing was changed.`,
2885
+ });
2886
+ return;
2887
+ }
2888
+ }
2889
+ }
2890
+ // Re-accelerated from the unpinned shortcut, never from the
2891
+ // installed URL: that one names the commit already on disk, so
2892
+ // reusing it would be an update that can never move.
2893
+ //
2894
+ // A restore is not an update. `spec` by now IS the catalog target
2895
+ // the checkout is being put back onto, and it is already exact: a
2896
+ // `#path:` on it selects which package, not which version, and a
2897
+ // prebuilt Release tarball (#250) is a URL that `@latest` must not
2898
+ // be glued onto — only a bare npm name wants the dist-tag.
2899
+ // acceleratedTarget returns anything that is not a bare
2900
+ // `github:owner/repo` untouched, so passing a restore through it
2901
+ // still gets a China-region mirror where one applies and changes
2902
+ // nothing where one does not.
2903
+ const target = restore
2904
+ ? (NPM_NAME_RE.test(spec) ? `${spec}@${tag}` : await acceleratedTarget(spec, region))
2905
+ : usesNpmUpdateTarget
2906
+ ? (expectedNpmVersion !== null ? `${name}@${expectedNpmVersion}` : `${name}@${tag}`)
2907
+ : genericGitTarget !== null
2908
+ ? genericGitTarget
2909
+ : await acceleratedTarget(gitSpec, region);
2910
+ const repoIdentity = isGit ? repoOfTarget(spec) : null;
2911
+ const repoKey = repoIdentity?.split('#')[0] ?? null;
2912
+ // dsh-cli's deliberately narrow target grammar rejects the `&`
2913
+ // required to combine an exact commit and a monorepo path. Do not
2914
+ // weaken that command boundary or offer a rollback action that
2915
+ // the real host can never execute.
2916
+ const hasGitSubpath = repoIdentity?.includes('#path:/') ?? false;
2917
+ // Captured BEFORE pnpm replaces the files: afterwards the loader
2918
+ // inventory reads exactly the same, because replacing a package
2919
+ // on disk does not unload the module the process already imported.
2920
+ // A client-only package has no host half to go stale: its bundle
2921
+ // is re-fetched from disk on the next page load, so an update to
2922
+ // one needs a refresh, not a restart.
2923
+ const wasLive = verifyActivation(config.profile, name, liveNames(), activeProfileDir, disabled.has(name)).state === 'live'
2924
+ && hasHostHalf(config.profile, name, activeProfileDir);
2925
+ const beforeVersion = readInstalledVersion(config.profile, name, activeProfileDir);
2926
+ // A durable manifest pin is independently authoritative. When its
2927
+ // captured lock is missing or stale, the exact OLD re-add repairs
2928
+ // that lock and rollback must keep the repair. Floating Git specs
2929
+ // still derive identity from the captured lock, so their exact
2930
+ // importer bytes remain the authority after rematerialization.
2931
+ const manifestPinnedCommit = repoKey !== null ? githubCommitOfTarget(spec) : null;
2932
+ const capturedLockCommit = repoKey !== null
2933
+ ? readLockCommits(config.profile, activeProfileDir).get(repoKey) ?? null
2934
+ : null;
2935
+ const beforeCommit = manifestPinnedCommit ?? capturedLockCommit;
2936
+ const keepRepairedGitLock = manifestPinnedCommit !== null
2937
+ && capturedLockCommit !== manifestPinnedCommit;
2938
+ const gitRollbackTarget = beforeCommit === null
2939
+ ? null
2940
+ : exactGitRollbackTarget(spec, beforeCommit);
2941
+ // force: the user chose to install a fresh release without the
2942
+ // default one-day safety wait; scoped to this single command.
2943
+ const addArgs = force ? ['add', RELEASE_AGE_OVERRIDE, target] : ['add', target];
2944
+ // Exact manifest snapshot for failure rollback (#65, #339) — the
2945
+ // host can write dependencies AND dsh.profile.bundles before a
2946
+ // hard-failed add, leaving residue that breaks the next boot.
2947
+ pendingRollbacks.clear();
2948
+ const compatibilityBefore = assessProfile(config.profile, activeProfileDir);
2949
+ // pnpm re-extracts the whole tree on any operation, so a plugin
2950
+ // nobody touched can come back pristine-and-broken, or lose a
2951
+ // patch that was holding it together (#222). Only what THIS run
2952
+ // broke is attributable to it, so the profile is swept before as
2953
+ // well as after.
2954
+ const bundlesBefore = brokenClientBundles(config.profile, activeProfileDir);
2955
+ const manifestBefore = manifestCapture.snapshot;
2956
+ const lockfileCapture = captureProfileLockfile();
2957
+ const previousVersionZh = beforeVersion === null ? '更新前版本未知' : `更新前版本为 v${beforeVersion}`;
2958
+ const previousVersionEn = beforeVersion === null ? 'the previous version is unknown' : `the previous version was v${beforeVersion}`;
2959
+ const rollbackPlan = restore
2960
+ ? { available: true, source: { kind: 'manifest' } }
2961
+ : !lockfileCapture.ok
2962
+ ? { available: false, detail: lockfileCapture.detail }
2963
+ : isGit
2964
+ ? hasGitSubpath
2965
+ ? {
2966
+ available: false,
2967
+ detail: `更新前的 GitHub 来源使用 monorepo 子目录${beforeCommit === null ? '' : `(提交 ${beforeCommit})`},当前 ChatCode CLI 命令无法表达该精确目标,因此自动回滚不可用;需要时请手工重新安装该提交。 / The previous GitHub source uses a monorepo subpath${beforeCommit === null ? '' : ` at commit ${beforeCommit}`}; the current ChatCode CLI command cannot express that exact target, so automatic rollback is unavailable. Reinstall that commit manually if needed.`,
2968
+ lockfileBefore: lockfileCapture.snapshot,
2969
+ }
2970
+ : beforeCommit === null
2971
+ ? {
2972
+ available: false,
2973
+ detail: '未能确认更新前的 GitHub 提交,因此自动回滚不可用;需要时请从可信来源手工重新安装先前版本。 / The previous GitHub commit could not be verified, so automatic rollback is unavailable. Reinstall the prior version manually from a trusted source if needed.',
2974
+ lockfileBefore: lockfileCapture.snapshot,
2975
+ }
2976
+ : gitRollbackTarget === null || !supportsExactRollbackTarget(gitRollbackTarget)
2977
+ ? {
2978
+ available: false,
2979
+ detail: `当前宿主无法安装更新前的精确 GitHub 提交 ${beforeCommit},因此自动回滚不可用;需要时请手工重新安装该提交。 / This host cannot install the exact previous GitHub commit ${beforeCommit}, so automatic rollback is unavailable. Reinstall that commit manually if needed.`,
2980
+ lockfileBefore: lockfileCapture.snapshot,
2981
+ }
2982
+ : {
2983
+ available: true,
2984
+ source: {
2985
+ kind: 'github',
2986
+ target: spec,
2987
+ beforeCommit,
2988
+ lockfileBefore: lockfileCapture.snapshot,
2989
+ keepRepairedLock: keepRepairedGitLock,
2990
+ },
2991
+ }
2992
+ : isReleaseTarball
2993
+ // A release download URL is not a content identity: GitHub
2994
+ // assets can be replaced unless immutable releases are
2995
+ // enabled. Re-adding the same URL could bless different
2996
+ // bytes, so restore durable state but never claim an exact
2997
+ // build rollback without a captured content binding.
2998
+ ? {
2999
+ available: false,
3000
+ detail: `${previousVersionZh},但先前的 Release 归档没有经过验证的不可变内容标识;同一链接以后可能返回不同文件,因此自动回滚不可用。需要时请从可信来源手工重新安装${beforeVersion === null ? '先前版本' : ` v${beforeVersion}`}。 / ${previousVersionEn}, but the previous Release archive has no verified immutable content identity; the same URL may later return different bytes, so automatic rollback is unavailable. Reinstall ${beforeVersion === null ? 'the prior version' : `v${beforeVersion}`} manually from a trusted source if needed.`,
3001
+ lockfileBefore: lockfileCapture.snapshot,
3002
+ }
3003
+ : isNpmRollbackSource
3004
+ ? beforeVersion === null
3005
+ ? {
3006
+ available: false,
3007
+ detail: '未能确认更新前安装的 npm 版本,因此自动回滚不可用;需要时请从可信来源手工重新安装先前版本。 / The previously installed npm version could not be verified, so automatic rollback is unavailable. Reinstall the prior version manually from a trusted source if needed.',
3008
+ lockfileBefore: lockfileCapture.snapshot,
3009
+ }
3010
+ : lockfileCapture.snapshot.present
3011
+ && capturedNpmVersion(lockfileCapture.snapshot, name) !== beforeVersion
3012
+ ? {
3013
+ available: false,
3014
+ detail: `更新前安装的是 v${beforeVersion},但 pnpm-lock.yaml 中的版本与它不一致,因此无法证明精确来源,自动回滚不可用。需要时请手工重新安装 ${name}@${beforeVersion}。 / The installed version before the update was v${beforeVersion}, but pnpm-lock.yaml does not match it, so the exact source cannot be proven and automatic rollback is unavailable. Reinstall ${name}@${beforeVersion} manually if needed.`,
3015
+ lockfileBefore: lockfileCapture.snapshot,
3016
+ }
3017
+ : !supportsExactRollbackTarget(`${name}@${beforeVersion}`)
3018
+ ? {
3019
+ available: false,
3020
+ detail: `当前宿主无法安装更新前的精确 npm 目标 ${name}@${beforeVersion}(v${beforeVersion}),因此自动回滚不可用;需要时请手工重新安装该版本。 / This host cannot install the exact previous npm target ${name}@${beforeVersion} (v${beforeVersion}), so automatic rollback is unavailable. Reinstall that version manually if needed.`,
3021
+ lockfileBefore: lockfileCapture.snapshot,
3022
+ }
3023
+ : { available: true, source: { kind: 'npm', beforeVersion, lockfileBefore: lockfileCapture.snapshot } }
3024
+ : {
3025
+ available: false,
3026
+ detail: `更新前的来源 ${spec} 不是受支持的精确回滚目标(${previousVersionZh}),因此自动回滚不可用;需要时请从可信来源手工重新安装先前版本。 / The previous source ${spec} is not a supported exact rollback target (${previousVersionEn}), so automatic rollback is unavailable. Reinstall the prior version manually from a trusted source if needed.`,
3027
+ lockfileBefore: lockfileCapture.snapshot,
3028
+ };
3029
+ const result = await runPlugin(config.profile, addArgs);
3030
+ const cancelled = result.cancelled;
3031
+ const rollbackAttemptBuild = async () => {
3032
+ if (rollbackPlan.available) {
3033
+ return executeUpdateRollback(name, manifestBefore, rollbackPlan.source);
3034
+ }
3035
+ restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
3036
+ const lockRestore = rollbackPlan.lockfileBefore === undefined
3037
+ ? { ok: true, detail: null }
3038
+ : restoreProfileLockfile(rollbackPlan.lockfileBefore);
3039
+ return {
3040
+ ok: false,
3041
+ detail: lockRestore.ok ? rollbackPlan.detail : `${rollbackPlan.detail}; ${lockRestore.detail ?? 'the lockfile could not be restored'}`,
3042
+ };
3043
+ };
3044
+ let rollbackOk = true;
3045
+ let rollbackDetail = null;
3046
+ let hardFailureRollbackError = null;
3047
+ // A non-zero exit or timeout can happen after pnpm has replaced
3048
+ // both package.json and node_modules. Restoring the manifest alone
3049
+ // leaves the rejected build running after restart. Reinstall the
3050
+ // exact prior source identity unless the host rejected the start
3051
+ // as busy or the user deliberately cancelled and chose to inspect
3052
+ // the resulting partial state.
3053
+ // pnpm never launched (#502): nothing was written, so there is
3054
+ // nothing to restore — and the notice this block produces when a
3055
+ // rollback cannot be verified ("inspect this profile before
3056
+ // restarting") would be alarm over an untouched profile, on top
3057
+ // of a failure the user already cannot act on from here.
3058
+ if ((result.exitCode !== 0 || result.timedOut) && !cancelled && result.busy !== true
3059
+ && !pnpmNeverStarted(result)) {
3060
+ const rollback = await rollbackAttemptBuild();
3061
+ rollbackOk = rollback.ok;
3062
+ rollbackDetail = rollback.detail;
3063
+ if (rollback.ok) {
3064
+ logEvent('warn', 'update', `${name}: failed update command; previous build restored and verified`);
3065
+ }
3066
+ else {
3067
+ hardFailureRollbackError = `${name} 更新失败,且更新前的构建未能验证恢复(${rollback.detail ?? 'unknown'});请先检查该 profile,再重新启动。 / ${name} update failed and restoration of the previous build could not be verified (${rollback.detail ?? 'unknown'}); inspect this profile before restarting.`;
3068
+ logEvent('error', 'update-rollback', `${name}: failed update command and restoration of the previous build could not be verified — ${rollback.detail ?? 'unknown'}`);
3069
+ }
3070
+ }
3071
+ let ok = result.exitCode === 0 && !result.timedOut && !cancelled;
3072
+ let stale = false;
3073
+ let versionFailureCode = null;
3074
+ let versionFailureError = null;
3075
+ let activation;
3076
+ if (ok) {
3077
+ if (restore) {
3078
+ // Restore succeeds when the spec is no longer local, even if
3079
+ // the checkout already sat on the same version as latest.
3080
+ const afterSpec = readInstalled(config.profile, activeProfileDir)[name];
3081
+ const stillLocal = afterSpec !== undefined && isLocalSpec(afterSpec);
3082
+ if (stillLocal)
3083
+ ok = false;
3084
+ }
3085
+ else {
3086
+ stale = isStaleUpdate({
3087
+ isGit,
3088
+ beforeVersion,
3089
+ afterVersion: readInstalledVersion(config.profile, name, activeProfileDir),
3090
+ beforeCommit,
3091
+ afterCommit: repoKey !== null
3092
+ ? readLockCommits(config.profile, activeProfileDir).get(repoKey) ?? null
3093
+ : null,
3094
+ });
3095
+ if (stale)
3096
+ ok = false;
3097
+ }
3098
+ }
3099
+ // Verify the bytes that landed against the pin this run asked for.
3100
+ // When the route already sent an exact `name@x.y.z` (#496), that
3101
+ // pin is authoritative: Desktop's install boundary must not be
3102
+ // allowed to lower the bar by reporting a different
3103
+ // `resolvedNpmVersion`. Only a floating dist-tag target (registry
3104
+ // metadata unavailable, so the add still says `@latest`/`@beta`)
3105
+ // adopts the boundary's reported pin — that is the version the
3106
+ // host actually handed to pnpm.
3107
+ //
3108
+ // Getting LESS than the pin is still a mismatch (including the
3109
+ // historical `@latest` + minimumReleaseAge silent-hold shape,
3110
+ // when a floating tag is what was sent). A version above the pin
3111
+ // is only possible on a floating target whose resolver moved
3112
+ // forward mid-download; that stays accepted.
3113
+ if (ok && usesNpmUpdateTarget) {
3114
+ const floatingDistTag = expectedNpmVersion === null;
3115
+ if (floatingDistTag
3116
+ && typeof result.resolvedNpmVersion === 'string'
3117
+ && result.resolvedNpmVersion !== '') {
3118
+ expectedNpmVersion = result.resolvedNpmVersion;
3119
+ }
3120
+ const afterVersion = readInstalledVersion(config.profile, name, activeProfileDir);
3121
+ const direction = beforeVersion !== null && afterVersion !== null
3122
+ ? compareVersions(afterVersion, beforeVersion)
3123
+ : null;
3124
+ const unexpectedDowngrade = selfChannel === null && direction !== null && direction < 0;
3125
+ // Only a version BELOW the target is a mismatch. `latest` can move
3126
+ // forward while pnpm is still downloading — a large plugin gives
3127
+ // the author minutes of window — and rejecting the newer release
3128
+ // that arrives would roll back a good update and report it as a
3129
+ // failure. Getting less than we asked for is the actual symptom.
3130
+ const target = expectedNpmVersion;
3131
+ const targetOrder = target !== null && afterVersion !== null
3132
+ ? compareVersions(afterVersion, target)
3133
+ : null;
3134
+ const targetMismatch = target !== null && (afterVersion === null
3135
+ || (targetOrder !== null
3136
+ // Comparable: getting LESS than we asked for is the symptom.
3137
+ // A version above the target is `latest` moving forward while
3138
+ // pnpm was still downloading, which is a good update.
3139
+ ? targetOrder < 0
3140
+ // Not comparable as semver. With no way to tell forward from
3141
+ // back, keep the exact check this replaced.
3142
+ : afterVersion !== target));
3143
+ if (unexpectedDowngrade || targetMismatch) {
3144
+ versionFailureCode = unexpectedDowngrade ? 'DOWNGRADE_DETECTED' : 'RESOLVED_VERSION_MISMATCH';
3145
+ ok = false;
3146
+ const rollback = await rollbackAttemptBuild();
3147
+ rollbackOk = rollback.ok;
3148
+ rollbackDetail = rollback.detail;
3149
+ const mismatchZh = unexpectedDowngrade
3150
+ ? `${name} 更新实际解析为 v${afterVersion ?? 'unknown'},低于更新前的 v${beforeVersion ?? 'unknown'};已拒绝降级`
3151
+ : `${name} 更新目标为 v${expectedNpmVersion ?? 'unknown'},但实际安装为 v${afterVersion ?? 'unknown'}`;
3152
+ const mismatchEn = unexpectedDowngrade
3153
+ ? `${name} resolved to v${afterVersion ?? 'unknown'}, below the installed v${beforeVersion ?? 'unknown'}; the downgrade was rejected`
3154
+ : `${name} targeted v${expectedNpmVersion ?? 'unknown'} but installed v${afterVersion ?? 'unknown'}`;
3155
+ versionFailureError = rollback.ok
3156
+ ? `${mismatchZh};已自动恢复原版本。 / ${mismatchEn}; the previous build was restored.`
3157
+ : `${mismatchZh};回滚未能验证恢复原版本(${rollback.detail ?? 'unknown'})。 / ${mismatchEn}; restoration of the previous build could not be verified (${rollback.detail ?? 'unknown'}).`;
3158
+ logEvent('error', 'update-version', `${name}: ${versionFailureCode} before=${beforeVersion ?? 'unknown'} expected=${expectedNpmVersion ?? 'unknown'} actual=${afterVersion ?? 'unknown'}${rollback.ok ? '; previous build restored' : `; rollback failed: ${rollback.detail ?? 'unknown'}`}`);
3159
+ }
3160
+ }
3161
+ // The new build has to be loadable (#159). pnpm exits 0 for any
3162
+ // tarball it can extract, and the version really did change, so
3163
+ // nothing above notices a package that arrived without its entry
3164
+ // artifact — a registry mirror serving a source-only tarball for
3165
+ // a just-published version is the reported case, a plugin author
3166
+ // shipping a broken `files` list is the other one.
3167
+ //
3168
+ // Activation cannot stand in for this check: a package updating
3169
+ // ITSELF still reports live, because the running fiber belongs to
3170
+ // the OLD code that is already in memory. The failure only
3171
+ // surfaces on the next boot, as a profile that will not start.
3172
+ let brokenEntry = false;
3173
+ if (ok && !hasLoadableEntry(activeProfileDir, name)) {
3174
+ brokenEntry = true;
3175
+ ok = false;
3176
+ const rollback = await rollbackAttemptBuild();
3177
+ rollbackOk = rollback.ok;
3178
+ rollbackDetail = rollback.detail;
3179
+ logEvent('error', 'update', `${name}: updated build has no loadable entry — ${rollback.ok ? 'previous build restored' : `could not restore previous files: ${rollback.detail ?? 'unknown'}`}`);
3180
+ }
3181
+ // Composition-level boot check for the remaining brick shapes:
3182
+ // duplicate loader entry ids, unparseable bundle patches, or
3183
+ // bundle layers that no longer resolve. hasLoadableEntry cannot
3184
+ // see these because the entry file exists — the profile still
3185
+ // cannot boot until the next start.
3186
+ let trialError = null;
3187
+ if (ok) {
3188
+ const stack = readBundleStack(activeProfileDir);
3189
+ const trial = trialValidate(activeProfileDir, stack.community);
3190
+ if (!trial.ok) {
3191
+ ok = false;
3192
+ const first = trial.errors[0]?.message ?? 'the composition would not boot';
3193
+ const rollback = await rollbackAttemptBuild();
3194
+ rollbackOk = rollback.ok;
3195
+ rollbackDetail = rollback.detail;
3196
+ trialError = rollback.ok
3197
+ ? `${name} 更新后的组合无法启动(${first}),已自动回滚并恢复原版本文件。 / ${name} updated to a composition that cannot boot (${first}); the previous build was restored.`
3198
+ : `${name} 更新后的组合无法启动(${first}),回滚未能恢复原版本文件(${rollback.detail ?? 'unknown'});请运行 chatcode-cli plugin --profile ${config.profile} install 手工恢复。 / ${name} updated to a composition that cannot boot (${first}); the previous files could not be restored (${rollback.detail ?? 'unknown'}) — run 'chatcode-cli plugin --profile ${config.profile} install' to recover manually.`;
3199
+ logEvent('error', 'update', `${name}: trial validation failed — ${first}${rollback.ok ? '; previous build restored' : `; could not restore previous files: ${rollback.detail ?? 'unknown'}`}`);
3200
+ }
3201
+ }
3202
+ let compatibility;
3203
+ if (ok) {
3204
+ invalidateUpdates();
3205
+ // Remembered, not just reported: the listing recomputes
3206
+ // activation on every page load and would otherwise call this
3207
+ // live again the moment the user refreshed.
3208
+ if (wasLive)
3209
+ replacedWhileLive.add(name);
3210
+ activation = {
3211
+ [name]: activationAfterReplace(verifyActivation(config.profile, name, liveNames(), activeProfileDir, disabled.has(name)), wasLive),
3212
+ };
3213
+ const after = assessProfile(config.profile, activeProfileDir);
3214
+ const risks = introducedRisks(compatibilityBefore, after);
3215
+ // An update can introduce shadowing too: a bundle migration
3216
+ // moves a plugin between layers, which is exactly the shape
3217
+ // #230 reported (bundle layer vs user patch layer).
3218
+ const shadowed = introducedDuplicateNames(compatibilityBefore, after);
3219
+ // See the install route: an update is the operation the #222
3220
+ // report actually hit.
3221
+ const bundleCheck = checkClientBundle(config.profile, name, activeProfileDir);
3222
+ const brokenBundles = newlyBrokenBundles(bundlesBefore, [
3223
+ ...(bundleCheck.ok ? [] : [{ name, reason: bundleCheck.reason ?? 'parse failed' }]),
3224
+ ...brokenClientBundles(config.profile, activeProfileDir),
3225
+ ].filter((entry, index, all) => all.findIndex(other => other.name === entry.name) === index));
3226
+ if (risks.length > 0 || shadowed.length > 0 || brokenBundles.length > 0) {
3227
+ const rollbackId = rollbackPlan.available
3228
+ ? savePendingRollback({
3229
+ kind: 'update',
3230
+ names: [name],
3231
+ manifestBefore,
3232
+ updateSource: rollbackPlan.source,
3233
+ })
3234
+ : null;
3235
+ compatibility = {
3236
+ code: 'soft-incompatible',
3237
+ risks,
3238
+ shadowedNames: shadowed.length > 0 ? shadowed : undefined,
3239
+ brokenBundles: brokenBundles.length > 0 ? brokenBundles : undefined,
3240
+ ...(rollbackId !== null
3241
+ ? { rollbackId }
3242
+ : {
3243
+ rollbackUnavailable: rollbackPlan.available
3244
+ ? `更新完成后无法安全捕获 profile 状态(${previousVersionZh}),因此自动回滚不可用;需要时请从可信来源手工重新安装先前版本。 / The post-update profile state could not be captured safely (${previousVersionEn}), so automatic rollback is unavailable. Reinstall the prior version manually from a trusted source if needed.`
3245
+ : rollbackPlan.detail,
3246
+ }),
3247
+ };
3248
+ if (brokenBundles.length > 0) {
3249
+ logEvent('error', 'update-bundle', `${brokenBundles.map(entry => `${entry.name}: ${entry.reason}`).join('; ')}`);
3250
+ }
3251
+ if (risks.length > 0) {
3252
+ logEvent('warn', 'update-compat', `${name}: introduced host-compatibility risks — ${risks.map(risk => `${risk.peer}@${risk.range} vs ${risk.resolved}`).join('; ')}`);
3253
+ }
3254
+ if (shadowed.length > 0) {
3255
+ logEvent('warn', 'update-shadow', `${name}: introduced cross-layer duplicate loader names — ${shadowed.map(entry => `${entry.name} (${entry.layers.join(' + ')})`).join('; ')}`);
3256
+ }
3257
+ }
3258
+ }
3259
+ // Diagnose the stale outcome with EVIDENCE (#45 by @ayingQAQ):
3260
+ // only blame pnpm's fresh-release wait when the target's latest
3261
+ // release really is young; otherwise be honest that the cause is
3262
+ // unconfirmed. Git installs never hit the age gate.
3263
+ const youngRelease = stale && !isGit ? await latestPublishedRecently(name) : false;
3264
+ const staleReason = stale ? (youngRelease === true ? 'release-age' : 'unknown') : null;
3265
+ const staleError = !stale
3266
+ ? null
3267
+ : staleReason === 'release-age'
3268
+ ? '这个新版本刚发布不久。为了安全,系统默认会等它发布满一天后再安装——刚发布的版本偶尔会被发现问题然后撤回。可以明天再试,或点「立即更新」不再等待。 / This version was just released; for safety, installs normally wait about a day after a release. Try again tomorrow, or click "Update now" to install it right away.'
3269
+ : '更新命令执行完成,但版本没有变化,原因未能确认。点「立即更新」重试通常能解决;若仍不行,请导出日志反馈。 / The update command completed but the version did not change; the cause could not be confirmed. Clicking "Update now" to retry usually resolves it — if not, export the log and report it.';
3270
+ // Actionable, because the user's own recovery is the right one:
3271
+ // the bad artifact is cached under its integrity hash, so a plain
3272
+ // re-add reuses it — the package has to be removed first.
3273
+ const brokenEntryError = !brokenEntry ? null
3274
+ : rollbackOk
3275
+ ? `${name} 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),已自动回滚并重新安装原版本文件,下次启动不受影响。这通常是镜像源在新版本刚发布时同步不完整;若仍需这个版本,请先卸载再从官方源重装。 / ${name} arrived without the entry file its package.json points at; the previous build was restored, so the next boot is unaffected. A registry mirror serving an incomplete tarball for a just-published version is the usual cause — remove the package and reinstall from the official registry if you still want this version.`
3276
+ : `${name} 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),且未能验证恢复原版本文件(${rollbackDetail ?? 'unknown'});请先检查该 profile,再重新启动。 / ${name} arrived without the entry file its package.json points at, and restoration of the previous build could not be verified (${rollbackDetail ?? 'unknown'}); inspect this profile before restarting.`;
3277
+ const cancelDiff = cancelled ? changedSince(beforeInstalled) : null;
3278
+ // Build-script blocks hit updates too (#69): a leftover invalid
3279
+ // allowBuilds entry (pnpm's placeholder bug, #56) or a newly
3280
+ // build-required dep fails the add with ERR_PNPM_IGNORED_BUILDS.
3281
+ // Reporting the blocked packages here gives the client the same
3282
+ // approve-and-retry banner the install flow has had since #6.
3283
+ const ignoredBuilds = ok || cancelled ? undefined : blockedBuilds(result);
3284
+ logEvent(ok || cancelled ? 'info' : 'error', 'update', `${name} -> ${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${stale ? ` STALE(${staleReason ?? 'unknown'})` : ''}${ok || cancelled ? '' : ` err=${failureDetail(result)}`}`);
3285
+ // A user-cancelled run is a quiet outcome, not an error.
3286
+ sendJson(response, ok || cancelled ? 200 : result.busy === true ? 409 : 502, {
3287
+ ok,
3288
+ cancelled: cancelled || undefined,
3289
+ busy: result.busy || undefined,
3290
+ stale: stale || undefined,
3291
+ partial: cancelDiff?.partial,
3292
+ changed: cancelDiff?.changed,
3293
+ activation,
3294
+ compatibility,
3295
+ ignoredBuilds,
3296
+ // Named here rather than left for the next restart to find
3297
+ // (#339). Empty on every healthy operation, so the client only
3298
+ // ever sees this when something really is unbootable.
3299
+ ...(() => { const orphans = orphanBundles(); return orphans.length > 0 ? { orphanBundles: orphans } : {}; })(),
3300
+ staleReason: staleReason ?? undefined,
3301
+ failureCode: versionFailureCode ?? undefined,
3302
+ error: versionFailureError ?? trialError ?? brokenEntryError ?? hardFailureRollbackError ?? staleError ?? undefined,
3303
+ exitCode: result.exitCode,
3304
+ timedOut: result.timedOut,
3305
+ stdout: result.stdout,
3306
+ stderr: result.stderr,
3307
+ installed: readInstalled(config.profile, activeProfileDir),
3308
+ });
3309
+ });
3310
+ }
3311
+ catch (error) {
3312
+ const message = error instanceof Error ? error.message : String(error);
3313
+ host.logger?.warn(`[ChatCode CLI Market] update failed: ${message}`);
3314
+ logEvent('error', 'update', `route error: ${message}`);
3315
+ sendJson(response, 500, { error: message });
3316
+ }
3317
+ },
3318
+ })),
3319
+ host.webServer.register({
3320
+ kind: 'exact',
3321
+ path: '/dsh-market/setup-pnpm',
3322
+ handler: async (request, response) => {
3323
+ if (request.method !== 'POST') {
3324
+ response.writeHead(405, { allow: 'POST' });
3325
+ response.end();
3326
+ return;
3327
+ }
3328
+ if (!sameOrigin(request)) {
3329
+ sendJson(response, 403, { error: 'untrusted origin' });
3330
+ return;
3331
+ }
3332
+ try {
3333
+ const result = await commands.provisionPnpm();
3334
+ sendJson(response, 200, { ok: result.ok, error: result.hint });
3335
+ }
3336
+ catch (error) {
3337
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
3338
+ }
3339
+ },
3340
+ }),
3341
+ /**
3342
+ * Remove the market itself, from its card on the plugin configuration
3343
+ * page. Deliberately NOT the generic uninstall route, which keeps
3344
+ * refusing the market: a destructive action on the thing serving the
3345
+ * request should be reachable only from the surface built for it, and
3346
+ * never as a stray `{ name: "dshmarket" }` on the ordinary path.
3347
+ *
3348
+ * Removing itself is safe, which is not obvious and was measured before
3349
+ * this was written: an already-imported module does not vanish with its
3350
+ * files, so the process keeps serving and the response completes
3351
+ * normally. The profile boots clean afterwards, with the market's rows
3352
+ * gone from `dependencies` and `dsh.profile.bundles`.
3353
+ */
3354
+ /**
3355
+ * Which release channel the market offers ITSELF from.
3356
+ *
3357
+ * Writable from the card because the settings scope is host-mode only —
3358
+ * a browser that is not on loopback never gets one, and the choice would
3359
+ * be unreachable there. Same-origin POST, like every other mutation.
3360
+ */
3361
+ host.webServer.register({
3362
+ kind: 'exact',
3363
+ path: '/dsh-market/channel',
3364
+ handler: async (request, response) => {
3365
+ if (request.method !== 'POST') {
3366
+ response.writeHead(405, { allow: 'POST' });
3367
+ response.end();
3368
+ return;
3369
+ }
3370
+ if (!sameOrigin(request)) {
3371
+ sendJson(response, 403, { error: 'untrusted origin' });
3372
+ return;
3373
+ }
3374
+ try {
3375
+ const body = (await readJsonBody(request));
3376
+ const wanted = asChannel(body.channel);
3377
+ if (wanted === null) {
3378
+ sendJson(response, 400, { error: 'channel must be "stable", "beta" or "dev"' });
3379
+ return;
3380
+ }
3381
+ config.channel = wanted;
3382
+ // Persisted with the market's own durable state, so the choice
3383
+ // survives a restart — a setting that forgets is a setting the
3384
+ // user has to make again every boot.
3385
+ marketState.channel = wanted;
3386
+ writeMarketState(activeProfileDir, marketState);
3387
+ // The cached listing was computed for the old channel, so the very
3388
+ // next check would answer for a setting that no longer applies.
3389
+ invalidateUpdates();
3390
+ logEvent('info', 'channel', `release channel set to ${wanted}`);
3391
+ sendJson(response, 200, { ok: true, channel: wanted });
3392
+ }
3393
+ catch (error) {
3394
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
3395
+ }
3396
+ },
3397
+ }),
3398
+ /**
3399
+ * Which mirrors every outbound request uses.
3400
+ *
3401
+ * Beside the channel route rather than in the settings namespace, and
3402
+ * for the reason recorded there: a value the market stores in its own
3403
+ * state.json cannot also be owned by the settings schema without the two
3404
+ * writing over each other.
3405
+ */
3406
+ host.webServer.register({
3407
+ kind: 'exact',
3408
+ path: '/dsh-market/region',
3409
+ handler: async (request, response) => {
3410
+ if (request.method !== 'POST') {
3411
+ response.writeHead(405, { allow: 'POST' });
3412
+ response.end();
3413
+ return;
3414
+ }
3415
+ if (!sameOrigin(request)) {
3416
+ sendJson(response, 403, { error: 'untrusted origin' });
3417
+ return;
3418
+ }
3419
+ try {
3420
+ const body = (await readJsonBody(request));
3421
+ const wanted = asRegion(body.region);
3422
+ if (wanted === null) {
3423
+ sendJson(response, 400, { error: 'region must be "global" or "china"' });
3424
+ return;
3425
+ }
3426
+ applyRegion(wanted);
3427
+ config.region = wanted;
3428
+ marketState.region = wanted;
3429
+ // A choice made by hand is no longer the probe's choice, so the
3430
+ // one-time explanation stops being offered.
3431
+ marketState.regionAuto = undefined;
3432
+ regionAuto = false;
3433
+ writeMarketState(activeProfileDir, marketState);
3434
+ // Both caches were filled from the other region's origins. The
3435
+ // catalog validator in particular is scoped to the URL that issued
3436
+ // it and would be meaningless against the new one.
3437
+ forgetCatalog();
3438
+ invalidateUpdates();
3439
+ logEvent('info', 'region', `download region set to ${wanted}`);
3440
+ sendJson(response, 200, { ok: true, region: wanted });
3441
+ }
3442
+ catch (error) {
3443
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
3444
+ }
3445
+ },
3446
+ }),
3447
+ /**
3448
+ * Last-resort GitHub prefix for networks where every built-in route is
3449
+ * unavailable. It is one escape hatch, not three service-level knobs;
3450
+ * the service ordering itself remains maintained by the routing table.
3451
+ */
3452
+ host.webServer.register({
3453
+ kind: 'exact',
3454
+ path: '/dsh-market/github-proxy',
3455
+ handler: async (request, response) => {
3456
+ if (request.method !== 'POST') {
3457
+ response.writeHead(405, { allow: 'POST' });
3458
+ response.end();
3459
+ return;
3460
+ }
3461
+ if (!sameOrigin(request)) {
3462
+ sendJson(response, 403, { error: 'untrusted origin' });
3463
+ return;
3464
+ }
3465
+ if (githubProxyManaged()) {
3466
+ sendJson(response, 409, { error: 'GitHub proxy is managed by CHATCODE_CLI_MARKET_GITHUB_PROXY' });
3467
+ return;
3468
+ }
3469
+ try {
3470
+ const body = (await readJsonBody(request));
3471
+ const wanted = body.proxy === null ? null : normalizeGithubProxy(body.proxy);
3472
+ if (body.proxy !== null && wanted === null) {
3473
+ sendJson(response, 400, {
3474
+ error: 'proxy must be an HTTPS prefix without credentials, query parameters, or a fragment',
3475
+ });
3476
+ return;
3477
+ }
3478
+ setCustomGithubProxy(wanted);
3479
+ marketState.githubProxy = wanted ?? undefined;
3480
+ writeMarketState(activeProfileDir, marketState);
3481
+ invalidateUpdates();
3482
+ logEvent('info', 'region', wanted === null
3483
+ ? 'custom GitHub route cleared; automatic routing restored'
3484
+ : 'custom GitHub route updated');
3485
+ sendJson(response, 200, { ok: true, githubProxyCustom: wanted });
3486
+ }
3487
+ catch (error) {
3488
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
3489
+ }
3490
+ },
3491
+ }),
3492
+ host.webServer.register({
3493
+ kind: 'exact',
3494
+ path: '/dsh-market/self-uninstall',
3495
+ handler: async (request, response) => {
3496
+ if (request.method !== 'POST') {
3497
+ response.writeHead(405, { allow: 'POST' });
3498
+ response.end();
3499
+ return;
3500
+ }
3501
+ // The same single door the restart route uses — and only it. Both
3502
+ // end the market's life in this process, so neither may be driven by
3503
+ // a remote or forwarded client. A separate `sameOrigin` call would
3504
+ // read as an extra guard while testing nothing: origin-matches-host
3505
+ // is already part of what this checks, so no request can fail one
3506
+ // and pass the other.
3507
+ if (!trustedRestartRequest(request)) {
3508
+ sendJson(response, 403, { error: 'self-uninstall is limited to same-origin loopback requests' });
3509
+ return;
3510
+ }
3511
+ try {
3512
+ await withMutationLock(response, 'install', async () => {
3513
+ const body = (await readJsonBody(request));
3514
+ // An explicit flag, not merely reaching the endpoint: this is the
3515
+ // one route whose accidental success cannot be undone from the UI
3516
+ // that would have undone it.
3517
+ if (body.confirm !== true) {
3518
+ sendJson(response, 400, { error: 'self-uninstall requires an explicit confirmation' });
3519
+ return;
3520
+ }
3521
+ const installed = readInstalled(config.profile, activeProfileDir);
3522
+ const selfName = SELF_NAME_ORDER.find(candidate => installed[candidate] !== undefined);
3523
+ if (selfName === undefined) {
3524
+ sendJson(response, 400, { error: 'the market is not an installed dependency of this profile' });
3525
+ return;
3526
+ }
3527
+ pendingRollbacks.clear();
3528
+ const result = await runPlugin(config.profile, ['remove', selfName]);
3529
+ const ok = result.exitCode === 0 && !result.timedOut && !result.cancelled;
3530
+ if (!ok) {
3531
+ // Report what pnpm actually said. A bare "removal failed" on
3532
+ // the one action the user cannot retry from a UI that is
3533
+ // still there would leave them with nothing to act on.
3534
+ const said = (result.stderr.trim() || result.stdout.trim()).slice(-800);
3535
+ sendJson(response, 502, {
3536
+ ok: false,
3537
+ error: said === '' ? 'removing the market failed' : said,
3538
+ timedOut: result.timedOut,
3539
+ cancelled: result.cancelled,
3540
+ });
3541
+ return;
3542
+ }
3543
+ // Opt-in cleanup. Rows the market wrote to the USER patch layer
3544
+ // outlive it: a plugin switched off here stays off after the
3545
+ // market is gone, and the only UI that could switch it back on
3546
+ // has just been removed. Only rows belonging to packages on the
3547
+ // market's own disable list are touched — a hand-written row is
3548
+ // the user's, not ours.
3549
+ const purge = body.purge === true;
3550
+ const restored = [];
3551
+ if (purge) {
3552
+ for (const name of disabled) {
3553
+ const ids = rowIdsForPackage(host, activeProfileDir, name);
3554
+ if (ids.length > 0) {
3555
+ removeRowBlocks(userPatchPath, ids);
3556
+ restored.push(name);
3557
+ }
3558
+ }
3559
+ purgeMarketState(activeProfileDir);
3560
+ }
3561
+ logEvent('info', 'self-uninstall', `removed ${selfName}${purge ? `; purged state, restored ${String(restored.length)} disabled plugin(s)` : '; state kept'}`);
3562
+ sendJson(response, 200, {
3563
+ ok: true,
3564
+ removed: selfName,
3565
+ purged: purge,
3566
+ restored,
3567
+ restart: restartAllowed(config),
3568
+ });
3569
+ // AFTER the response. The package is gone from disk, so the host
3570
+ // now 404s on this plugin's client bundle while the loader entry
3571
+ // is still live — the shape that wedges the whole page on the
3572
+ // next refresh (#37). Disabling our own entry composes the page
3573
+ // without the market instead. Deferred because it disposes the
3574
+ // context this handler runs in.
3575
+ //
3576
+ // This is also why nothing here schedules a restart. An earlier
3577
+ // version offered one, first as a button in the end state (which
3578
+ // could only answer 405, since the disable takes the restart
3579
+ // route with it) and then as a checkbox in the confirmation. Both
3580
+ // were asking the user to arrange a consequence rather than
3581
+ // stating it: the browser drops the market the moment this runs,
3582
+ // and the leftover disabled entry is cleared by whatever restart
3583
+ // happens next. There is no decision to offer.
3584
+ setTimeout(() => {
3585
+ void themes.setEntryDisabled(selfName, true).catch(() => { });
3586
+ }, 0);
3587
+ });
3588
+ }
3589
+ catch (error) {
3590
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
3591
+ }
3592
+ },
3593
+ }),
3594
+ host.webServer.register(captureLegacy('/dsh-market/restart', {
3595
+ kind: 'exact',
3596
+ path: '/dsh-market/restart',
3597
+ handler: (request, response) => {
3598
+ if (request.method !== 'POST') {
3599
+ response.writeHead(405, { allow: 'POST' });
3600
+ response.end();
3601
+ return;
3602
+ }
3603
+ // One-click restart contributed in #14 by @ysyyhhh.
3604
+ if (!restartAllowed(config)) {
3605
+ sendJson(response, 403, { error: 'self-restart is disabled for this host' });
3606
+ return;
3607
+ }
3608
+ if (detectedDebugger() !== null) {
3609
+ sendJson(response, 403, { error: 'self-restart is disabled while the host is under a debugger' });
3610
+ return;
3611
+ }
3612
+ if (!trustedRestartRequest(request)) {
3613
+ sendJson(response, 403, { error: 'restart is limited to same-origin loopback requests' });
3614
+ return;
3615
+ }
3616
+ if (writing || installing) {
3617
+ sendJson(response, 409, { error: 'cannot restart while a plugin operation is running' });
3618
+ return;
3619
+ }
3620
+ if (restarting) {
3621
+ sendJson(response, 409, { error: 'restart already scheduled' });
3622
+ return;
3623
+ }
3624
+ restarting = true;
3625
+ try {
3626
+ const result = scheduleRestart(servingPort(request));
3627
+ logEvent('info', 'restart', `scheduled pid=${String(result.pid)} helper=${String(result.helperPid)}`);
3628
+ sendJson(response, 202, { ok: true, boot: BOOT_ID, ...result });
3629
+ }
3630
+ catch (error) {
3631
+ restarting = false;
3632
+ const message = error instanceof Error ? error.message : String(error);
3633
+ logEvent('error', 'restart', message);
3634
+ sendJson(response, 500, { error: message });
3635
+ }
3636
+ },
3637
+ })),
3638
+ host.webServer.register({
3639
+ kind: 'exact',
3640
+ path: '/dsh-market/approve-builds',
3641
+ handler: async (request, response) => {
3642
+ if (request.method !== 'POST') {
3643
+ response.writeHead(405, { allow: 'POST' });
3644
+ response.end();
3645
+ return;
3646
+ }
3647
+ if (!sameOrigin(request)) {
3648
+ sendJson(response, 403, { error: 'untrusted origin' });
3649
+ return;
3650
+ }
3651
+ try {
3652
+ // One-click build-script approval (#6 by @qichuang321): only
3653
+ // packages physically present in the profile's installed tree can
3654
+ // be allowed — the list is not free input. Presence is checked in
3655
+ // node_modules, NOT the dependencies map: pnpm's blocked build
3656
+ // scripts are usually TRANSITIVE deps (cloudflared, ssh2,
3657
+ // cpu-features…), which never appear in package.json (#56 by
3658
+ // @walnut1218).
3659
+ // pnpm 11's ndjson `ignored-scripts` event reports version-qualified
3660
+ // names (cloudflared@0.7.3); strip the @version suffix so the
3661
+ // allowlist keys and node_modules lookups use bare package names.
3662
+ const stripVersion = (name) => {
3663
+ const at = name.lastIndexOf('@');
3664
+ return at > 0 ? name.slice(0, at) : name;
3665
+ };
3666
+ const PKG_RE = /^(@[A-Za-z0-9-~][A-Za-z0-9._~-]*\/)?[A-Za-z0-9-~][A-Za-z0-9._~-]*$/;
3667
+ const body = (await readJsonBody(request));
3668
+ const requested = (Array.isArray(body.packages) ? body.packages.map(String).map(stripVersion) : [])
3669
+ .filter(name => PKG_RE.test(name));
3670
+ const installed = requested
3671
+ .filter(name => existsSync(join(activeProfileDir, 'node_modules', name, 'package.json')));
3672
+ // Git-hosted plugins rejected by pnpm's FETCHER (#68) exist in
3673
+ // neither node_modules nor package.json — the only trusted anchor
3674
+ // left is the curated registry itself: a name that resolves to a
3675
+ // github-sourced catalog entry may be approved pre-materialization.
3676
+ //
3677
+ // pnpm only matches a git-hosted dep's allowBuilds entry under its
3678
+ // stable `name@git+https://…` key (#68/#69) — a bare name entry is
3679
+ // ignored (verified against pnpm 11.21). Derive that key wherever
3680
+ // the github source is known: from the profile spec for installed
3681
+ // deps, from the curated registry for pending ones. The bare name
3682
+ // is kept alongside — it authorizes the npm-sourced case.
3683
+ const specs = readInstalled(config.profile, activeProfileDir);
3684
+ const packages = [];
3685
+ /**
3686
+ * Both key forms for one github source (#285).
3687
+ *
3688
+ * pnpm 11.21+ matches the stable `git+https://…` key; 11.8.0 — what
3689
+ * DSH Desktop bundles — matches only a commit-pinned codeload URL,
3690
+ * so on those versions the approval button wrote a key pnpm would
3691
+ * never read and could never work. The pin is resolved here rather
3692
+ * than assumed: `github:owner/repo` names no commit, and the one
3693
+ * pnpm will fetch is whatever HEAD is at install time.
3694
+ *
3695
+ * A pin that cannot be resolved is simply omitted. The stable key
3696
+ * still covers modern pnpm, and an approval that authorizes less
3697
+ * than hoped is better than one that fails.
3698
+ */
3699
+ const buildKeys = async (name, spec) => {
3700
+ const stable = gitAllowBuildsKey(name, spec);
3701
+ if (stable === null)
3702
+ return [];
3703
+ const repo = repoOfTarget(spec)?.split('#')[0] ?? null;
3704
+ // A proxied legacy install and the mirror-resolved github form
3705
+ // both already carry their commit; only a bare shortcut asks.
3706
+ const pinned = githubCommitOfTarget(spec)
3707
+ ?? (repo === null ? null : await resolveHeadCommit(repo, region));
3708
+ const codeload = pinned === null || pinned === undefined
3709
+ ? null
3710
+ : codeloadAllowBuildsKey(name, spec, pinned);
3711
+ return codeload === null ? [stable] : [stable, codeload];
3712
+ };
3713
+ for (const name of requested) {
3714
+ if (installed.includes(name)) {
3715
+ packages.push(name, ...await buildKeys(name, String(specs[name] ?? '')));
3716
+ continue;
3717
+ }
3718
+ if (specs[name] !== undefined)
3719
+ continue;
3720
+ // The catalog can now FAIL rather than quietly serving a bundled
3721
+ // copy, and this key is an optimisation, not a requirement: the
3722
+ // bare name already authorizes the npm-sourced case, and a git
3723
+ // source that misses its key simply prompts again. Losing the
3724
+ // catalog must not turn "allow this build" into a 500.
3725
+ let entry;
3726
+ try {
3727
+ entry = (await loadRegistry()).plugins.find(p => p.name === name || p.npm === name);
3728
+ }
3729
+ catch (error) {
3730
+ logEvent('warn', 'approve-builds', `catalog unavailable, authorizing ${name} by name only: ${error instanceof Error ? error.message : String(error)}`);
3731
+ packages.push(name);
3732
+ continue;
3733
+ }
3734
+ const target = entry === undefined ? null : installTargetFor(entry);
3735
+ const keys = target === null ? [] : await buildKeys(name, target);
3736
+ if (keys.length > 0) {
3737
+ packages.push(name, ...keys);
3738
+ }
3739
+ }
3740
+ if (packages.length === 0) {
3741
+ sendJson(response, 400, { error: 'no installed packages given' });
3742
+ return;
3743
+ }
3744
+ pendingRollbacks.clear();
3745
+ const approved = setAllowBuilds(config.profile, packages, activeProfileDir);
3746
+ logEvent('info', 'approve-builds', `allowed build scripts: ${approved.join(', ')}`);
3747
+ sendJson(response, 200, { ok: true, approved });
3748
+ }
3749
+ catch (error) {
3750
+ const message = error instanceof Error ? error.message : String(error);
3751
+ logEvent('error', 'approve-builds', `route error: ${message}`);
3752
+ sendJson(response, 500, { error: message });
3753
+ }
3754
+ },
3755
+ }),
3756
+ host.webServer.register({
3757
+ kind: 'exact',
3758
+ path: '/dsh-market/cancel',
3759
+ handler: async (request, response) => {
3760
+ if (request.method !== 'POST') {
3761
+ response.writeHead(405, { allow: 'POST' });
3762
+ response.end();
3763
+ return;
3764
+ }
3765
+ if (!sameOrigin(request)) {
3766
+ sendJson(response, 403, { error: 'untrusted origin' });
3767
+ return;
3768
+ }
3769
+ // Cancel flow contributed in #6 by @qichuang321.
3770
+ if (!commands.cancelActive()) {
3771
+ sendJson(response, 400, { error: 'no operation is running' });
3772
+ return;
3773
+ }
3774
+ logEvent('info', 'cancel', `cancelled ${progress.target || 'operation'}`);
3775
+ sendJson(response, 200, { ok: true, cancelled: true, target: progress.target });
3776
+ },
3777
+ }),
3778
+ host.webServer.register({
3779
+ kind: 'exact',
3780
+ path: '/dsh-market/uninstall',
3781
+ handler: async (request, response) => {
3782
+ if (request.method !== 'POST') {
3783
+ response.writeHead(405, { allow: 'POST' });
3784
+ response.end();
3785
+ return;
3786
+ }
3787
+ if (!sameOrigin(request)) {
3788
+ sendJson(response, 403, { error: 'untrusted origin' });
3789
+ return;
3790
+ }
3791
+ try {
3792
+ await withMutationLock(response, 'install', async () => {
3793
+ const body = (await readJsonBody(request));
3794
+ const name = typeof body.name === 'string' ? body.name : '';
3795
+ // Only the INDETERMINATE patch case is forceable, below. A patch
3796
+ // that definitely names the package stays refused: there the user
3797
+ // has a concrete thing to go fix, so an override would only help
3798
+ // them break their next boot.
3799
+ const force = body.force === true;
3800
+ if (isSelfName(name)) {
3801
+ sendJson(response, 400, { error: 'the market cannot uninstall itself; use ChatCode CLI' });
3802
+ return;
3803
+ }
3804
+ if (readInstalled(config.profile, activeProfileDir)[name] === undefined) {
3805
+ sendJson(response, 400, { error: 'plugin is not installed' });
3806
+ return;
3807
+ }
3808
+ const userPatchReferences = userPatchPackageReferences(userPatchPath, name);
3809
+ if (userPatchReferences === null && !force) {
3810
+ // Refusing here is right — an unreadable patch might still load
3811
+ // the package, and removing it would break the next boot. But
3812
+ // refusing with NO way through is the wrong shape: the market
3813
+ // cannot say which row to fix, and the moment someone wants to
3814
+ // uninstall is usually the moment something is already broken.
3815
+ // So this one is forceable, and says so.
3816
+ logEvent('warn', 'uninstall-blocked', `${name}: user cordis.patch.yml could not be inspected safely`);
3817
+ sendJson(response, 409, {
3818
+ error: `无法安全卸载 ${name}:当前 profile 的 cordis.patch.yml 无法读取为有效的补丁列表,因此无法排除它仍在引用该包。请先检查补丁文件;确认无关后可强制卸载。 / Cannot safely uninstall ${name}: this profile's cordis.patch.yml could not be read as a valid patch list, so the market cannot rule out a remaining package reference. Check the patch file; you can force the uninstall once you are sure it is unrelated.`,
3819
+ userPatchInspectionFailed: true,
3820
+ forceable: true,
3821
+ });
3822
+ return;
3823
+ }
3824
+ if (userPatchReferences === null) {
3825
+ logEvent('warn', 'uninstall', `${name}: forced past an unreadable user cordis.patch.yml`);
3826
+ }
3827
+ if (userPatchReferences !== null && userPatchReferences.length > 0) {
3828
+ const listed = userPatchReferences.join(', ');
3829
+ logEvent('warn', 'uninstall-blocked', `${name}: user cordis.patch.yml still inserts ${listed}`);
3830
+ sendJson(response, 409, {
3831
+ error: `无法卸载 ${name}:当前 profile 的 cordis.patch.yml 仍通过 insert 引用 ${listed}。请先移除这些用户补丁引用再重试;市场不会自动改写用户补丁。 / Cannot uninstall ${name}: this profile's cordis.patch.yml still inserts ${listed}. Remove those user-owned patch references first and retry; the market will not rewrite the user patch automatically.`,
3832
+ userPatchReferenced: true,
3833
+ patchReferences: userPatchReferences,
3834
+ });
3835
+ return;
3836
+ }
3837
+ const busyAgents = runningAgentsForGuard();
3838
+ if (busyAgents.length > 0) {
3839
+ logEvent('warn', 'uninstall-blocked', `${name}: refused while agents are running — ${busyAgents.join(', ')}`);
3840
+ sendJson(response, 409, {
3841
+ error: `有 agent 正在运行(${busyAgents.join(', ')})。卸载会修改插件文件,正在工作的 agent 可能在中途报错;请等它完成或取消后再卸载。 / ${busyAgents.length === 1 ? 'An agent is running' : 'Agents are running'} (${busyAgents.join(', ')}). Uninstalling changes plugin files, so a working agent can fail mid-turn; wait for it to finish (or cancel it) before uninstalling.`,
3842
+ agentsBusy: true,
3843
+ runningAgents: busyAgents,
3844
+ });
3845
+ return;
3846
+ }
3847
+ pendingRollbacks.clear();
3848
+ const beforeInstalled = readInstalled(config.profile, activeProfileDir);
3849
+ // isDisabled comes from the patch layer (#130) — keep it while the
3850
+ // lock moves into withMutationLock (#125).
3851
+ const activation = {
3852
+ [name]: verifyActivation(config.profile, name, liveNames(), activeProfileDir, disabled.has(name)),
3853
+ };
3854
+ // Capture whether the plugin has a client part BEFORE removal — after
3855
+ // runPlugin the package may be gone from node_modules, so a post-hoc
3856
+ // check would always return false on a successful uninstall.
3857
+ const hadClientPart = packageHasClientPart(activeProfileDir, name);
3858
+ // Also captured before the removal, and for the same reason: a
3859
+ // native addon in this plugin or one of its dependencies is not
3860
+ // released by unloading it (#441). Node has no dlclose, so the
3861
+ // process holds the `.node` until it exits — and on Windows the
3862
+ // next install of the same plugin then fails renaming over the
3863
+ // copy this process is still holding. Calling such an uninstall
3864
+ // `hot` would send the user to a page refresh, which is the one
3865
+ // thing that cannot help.
3866
+ const heldNativeAddon = holdsNativeAddon(config.profile, name, activeProfileDir);
3867
+ const result = await runPlugin(config.profile, ['remove', name]);
3868
+ const cancelled = result.cancelled;
3869
+ const ok = result.exitCode === 0 && !result.timedOut && !cancelled;
3870
+ const cancelDiff = cancelled ? changedSince(beforeInstalled) : null;
3871
+ // Half-uninstall guard: pnpm can fail a remove AFTER deleting
3872
+ // node_modules but BEFORE saving package.json (#65's write-order
3873
+ // mirror image — a file locked mid-unlink aborts the run). The
3874
+ // manifest would then reference a package that no longer exists,
3875
+ // and the next boot fails to activate the ghost dependency.
3876
+ // Reconcile from disk truth: when the package is gone, finish
3877
+ // the removal the CLI could not; when it is intact, keep the
3878
+ // manifest so the user can simply retry.
3879
+ const halfGone = !ok && !cancelled
3880
+ && !existsSync(join(activeProfileDir, 'node_modules', name, 'package.json'));
3881
+ let reconciled = false;
3882
+ if (halfGone) {
3883
+ reconciled = dropFromManifest(config.profile, name, activeProfileDir);
3884
+ logEvent('warn', 'uninstall', `${name}: remove failed (exit ${String(result.exitCode)}) but the package is gone from disk; ${reconciled ? 'reconciled manifest lists to match' : 'manifest lists already clean'}`);
3885
+ }
3886
+ let hot = false;
3887
+ if (ok || halfGone) {
3888
+ invalidateUpdates();
3889
+ hot = await hotUnmount(name);
3890
+ // Bundle-layer plugins never hot-mount, but their loader entry
3891
+ // is still LIVE in this process — after the remove deleted the
3892
+ // package, the next refresh would 404 on its client bundle and
3893
+ // wedge the whole page until a dsh restart (#37 by
3894
+ // @1123762794). Live-disable the entry so the refresh composes
3895
+ // without it; after a real restart the entry is gone anyway.
3896
+ //
3897
+ // Both run, unconditionally. This used to short-circuit on the
3898
+ // hot unmount, which is right only while a package has ONE
3899
+ // activation source — a package that is both hot-mounted AND
3900
+ // reachable through the bundle layer got half its cleanup, and
3901
+ // the surviving half is exactly the 404-on-refresh wedge above
3902
+ // (#213). setEntryDisabled just scans entries by name and
3903
+ // returns false when none match, so calling it after a
3904
+ // successful unmount costs a lookup and nothing else.
3905
+ const entryDisabled = await themes.setEntryDisabled(name, true);
3906
+ hot = hot || entryDisabled;
3907
+ if (heldNativeAddon && hot) {
3908
+ logEvent('info', 'uninstall', `${name} ships or depends on a native addon, which this process cannot release — reporting the uninstall as needing a restart`);
3909
+ }
3910
+ hot = hot && !heldNativeAddon;
3911
+ // Patch-layer rows must not survive the remove either: a
3912
+ // `- id: X` + `disabled: true` row for a package that no longer
3913
+ // mounts is a boot-time orphan (port of dsh-plugin-hub).
3914
+ removeRowBlocks(userPatchPath, rowIdsForPackage(host, activeProfileDir, name));
3915
+ // The disable list must not keep a removed plugin: a later
3916
+ // reinstall starts enabled. Group memberships follow the same
3917
+ // rule so no group toggle ever targets a ghost member.
3918
+ disabled.delete(name);
3919
+ removeFromGroups({ groups, groupOrder }, name);
3920
+ writeMarketState(activeProfileDir, { disabled, groups, groupOrder });
3921
+ }
3922
+ logEvent(ok || cancelled ? 'info' : 'error', 'uninstall', `${name} exit=${String(result.exitCode)}${cancelled ? ' CANCELLED' : ''}${ok ? ` live-removed=${String(hot)}` : cancelled ? '' : ` err=${failureDetail(result)}`}`);
3923
+ sendJson(response, ok || cancelled ? 200 : result.busy === true ? 409 : 502, {
3924
+ ok,
3925
+ cancelled: cancelled || undefined,
3926
+ busy: result.busy || undefined,
3927
+ // A failed remove whose package vanished from disk was
3928
+ // reconciled: the manifest lists match disk truth again, the
3929
+ // removal is final (a retry would 400 on "not installed").
3930
+ reconciled: reconciled || undefined,
3931
+ hot,
3932
+ // A client-part plugin's UI is already injected into the page; after
3933
+ // uninstall the injected bundle stays live until a refresh, so the
3934
+ // same banner as enable/disable prompts the user to reload.
3935
+ // Gate on hot: non-hot uninstalls already show the restart banner,
3936
+ // and adding a refresh banner there would double-banner (#213's
3937
+ // pendingRefreshNames merge exists specifically to avoid that).
3938
+ refresh: ok && hot && hadClientPart,
3939
+ partial: cancelDiff?.partial,
3940
+ changed: cancelDiff?.changed,
3941
+ // The state of the package that was just removed (captured pre-op).
3942
+ activation,
3943
+ exitCode: result.exitCode,
3944
+ stdout: result.stdout,
3945
+ stderr: result.stderr,
3946
+ installed: readInstalled(config.profile, activeProfileDir),
3947
+ });
3948
+ });
3949
+ }
3950
+ catch (error) {
3951
+ const message = error instanceof Error ? error.message : String(error);
3952
+ host.logger?.warn(`[ChatCode CLI Market] uninstall failed: ${message}`);
3953
+ logEvent('error', 'uninstall', `route error: ${message}`);
3954
+ sendJson(response, 500, { error: message });
3955
+ }
3956
+ },
3957
+ }),
3958
+ host.webServer.register(captureLegacy('/dsh-market/rollback', {
3959
+ kind: 'exact',
3960
+ path: '/dsh-market/rollback',
3961
+ handler: async (request, response) => {
3962
+ if (request.method !== 'POST') {
3963
+ response.writeHead(405, { allow: 'POST' });
3964
+ response.end();
3965
+ return;
3966
+ }
3967
+ if (!sameOrigin(request)) {
3968
+ sendJson(response, 403, { error: 'untrusted origin' });
3969
+ return;
3970
+ }
3971
+ try {
3972
+ await withMutationLock(response, 'install', async () => {
3973
+ const body = (await readJsonBody(request));
3974
+ const id = typeof body.rollbackId === 'string' ? body.rollbackId : '';
3975
+ const pending = pendingRollbacks.get(id);
3976
+ if (pending === undefined) {
3977
+ sendJson(response, 400, { error: 'rollback is not available (it may have been superseded by another operation) / 回滚已不可用(可能已被后续操作覆盖)' });
3978
+ return;
3979
+ }
3980
+ // The token captures whole-profile manifest and lock state. A
3981
+ // terminal-side pnpm/dsh command is outside this route's mutation
3982
+ // lock, so internal invalidation alone cannot prevent an old token
3983
+ // from overwriting a newer external edit. Refuse unless the exact
3984
+ // post-operation state that the user was shown is still current.
3985
+ if (!profileStateMatches(pending.expectedState)) {
3986
+ pendingRollbacks.delete(id);
3987
+ sendJson(response, 400, {
3988
+ error: 'rollback is not available because the profile changed after this operation / 操作后配置已发生变化,回滚不可用',
3989
+ });
3990
+ return;
3991
+ }
3992
+ let ok = true;
3993
+ let hot = false;
3994
+ let detail = null;
3995
+ if (pending.kind === 'update') {
3996
+ const name = pending.names[0];
3997
+ const result = pending.updateSource === undefined
3998
+ ? { ok: false, detail: 'the saved update rollback source is unavailable' }
3999
+ : await executeUpdateRollback(name, pending.manifestBefore, pending.updateSource);
4000
+ ok = result.ok;
4001
+ detail = result.detail;
4002
+ }
4003
+ else {
4004
+ for (const name of pending.names) {
4005
+ const result = await removeInstalledPackage(name);
4006
+ hot ||= result.hot;
4007
+ if (!result.ok) {
4008
+ ok = false;
4009
+ detail = result.detail;
4010
+ break;
4011
+ }
4012
+ }
4013
+ }
4014
+ if (ok) {
4015
+ pendingRollbacks.delete(id);
4016
+ invalidateUpdates();
4017
+ logEvent('info', 'rollback', `${pending.kind}: ${pending.names.join(', ')} restored`);
4018
+ }
4019
+ else {
4020
+ logEvent('error', 'rollback', `${pending.kind}: ${pending.names.join(', ')} failed — ${detail ?? 'unknown'}`);
4021
+ }
4022
+ sendJson(response, ok ? 200 : 502, {
4023
+ ok,
4024
+ rolledBack: ok,
4025
+ hot,
4026
+ detail: detail ?? undefined,
4027
+ installed: readInstalled(config.profile, activeProfileDir),
4028
+ });
4029
+ });
4030
+ }
4031
+ catch (error) {
4032
+ const message = error instanceof Error ? error.message : String(error);
4033
+ host.logger?.warn(`[ChatCode CLI Market] rollback failed: ${message}`);
4034
+ logEvent('error', 'rollback', `route error: ${message}`);
4035
+ sendJson(response, 500, { error: message });
4036
+ }
4037
+ },
4038
+ })),
4039
+ host.webServer.register({
4040
+ kind: 'exact',
4041
+ path: '/dsh-market/install',
4042
+ handler: async (request, response) => {
4043
+ if (request.method !== 'POST') {
4044
+ response.writeHead(405, { allow: 'POST' });
4045
+ response.end();
4046
+ return;
4047
+ }
4048
+ if (!sameOrigin(request)) {
4049
+ sendJson(response, 403, { error: 'untrusted origin' });
4050
+ return;
4051
+ }
4052
+ try {
4053
+ await withMutationLock(response, 'install', async () => {
4054
+ const body = (await readJsonBody(request));
4055
+ const busyAgents = runningAgentsForGuard();
4056
+ if (busyAgents.length > 0) {
4057
+ logEvent('warn', 'install-blocked', `refused while agents are running — ${busyAgents.join(', ')}`);
4058
+ sendJson(response, 409, {
4059
+ error: `有 agent 正在运行(${busyAgents.join(', ')})。安装会修改插件文件,正在工作的 agent 可能在中途报错;请等它完成或取消后再安装。 / ${busyAgents.length === 1 ? 'An agent is running' : 'Agents are running'} (${busyAgents.join(', ')}). Installing changes plugin files, so a working agent can fail mid-turn; wait for it to finish (or cancel it) before installing.`,
4060
+ agentsBusy: true,
4061
+ runningAgents: busyAgents,
4062
+ });
4063
+ return;
4064
+ }
4065
+ const url = typeof body.url === 'string' ? body.url : '';
4066
+ const registry = await loadRegistry();
4067
+ const entry = registry.plugins.find(p => p.url.toLowerCase() === url.toLowerCase());
4068
+ if (entry === undefined) {
4069
+ logEvent('warn', 'install-rejected', `not in curated registry: ${url.slice(0, 120)}`);
4070
+ sendJson(response, 400, { error: 'plugin is not in the curated registry' });
4071
+ return;
4072
+ }
4073
+ const plainTarget = installTargetFor(entry);
4074
+ if (plainTarget === null) {
4075
+ sendJson(response, 400, { error: 'unsupported source url' });
4076
+ return;
4077
+ }
4078
+ // Resolve GitHub HEAD through the region's available routes, then
4079
+ // let pnpm fetch the canonical commit-pinned target.
4080
+ // Applied HERE, before the guards below, so every step downstream
4081
+ // reasons about the exact spec that will be installed. Returns
4082
+ // the original on any lookup failure (see accelerate.ts).
4083
+ const target = await acceleratedTarget(plainTarget, region);
4084
+ if (target !== plainTarget) {
4085
+ logEvent('info', 'region', `${entry.name}: resolved HEAD through an available ${region} route; downloading the commit-pinned GitHub target directly for pnpm integrity`);
4086
+ }
4087
+ // Duplicate guard (#27): the same plugin listed under another name
4088
+ // (an alias entry pointing at the same repo) must never install
4089
+ // twice — two loader entries with one id brick the next boot.
4090
+ // Monorepo subpath entries (distinct plugins in one repo) pass:
4091
+ // their entry urls differ by subpath and identity is name-based.
4092
+ // A dependency left in package.json by a FAILED install (blocked
4093
+ // build scripts: pnpm writes the manifest, then exits 1) is NOT a
4094
+ // duplicate — it was never activated. Blocking the retry would
4095
+ // make the approve-builds flow dead-end, so a leftover that is the
4096
+ // SAME package/source (not a repo-only alias of a different entry)
4097
+ // and is not yet active (bundle layer or live mount) may be retried.
4098
+ const installedNow = readInstalled(config.profile, activeProfileDir);
4099
+ const aliasOf = findInstalledAlias(entry, installedNow);
4100
+ // When the duplicate guard allows a retry of a leftover dep, that
4101
+ // name must be treated as "newly added" by the post-install
4102
+ // validation and hot-mount below (it IS in package.json from the
4103
+ // failed attempt, so the plain before/after diff would miss it).
4104
+ let retryAlias = null;
4105
+ if (aliasOf !== null) {
4106
+ // Same install? The leftover's own name/spec must match what we
4107
+ // are about to add — an npm entry retries under its npm name; a
4108
+ // github entry's package.json spec equals the target.
4109
+ // Compared as IDENTITIES, not as strings. One GitHub plugin has
4110
+ // several historical spellings — mutable or pinned `github:`
4111
+ // shortcuts and legacy proxied codeload tarballs — so a literal
4112
+ // comparison would call a leftover from before an upgrade or
4113
+ // region switch "a different source" and refuse the retry it
4114
+ // exists to allow. `repoOfTarget` returns null for npm names and file
4115
+ // links, which fall through to the string comparison below.
4116
+ const installedSpec = String(installedNow[aliasOf] ?? '').replace(/^file:/, '');
4117
+ const wantedSpec = String(target).replace(/^file:/, '');
4118
+ const installedRepo = repoOfTarget(installedSpec);
4119
+ const wantedRepo = repoOfTarget(wantedSpec);
4120
+ const sameSource = aliasOf.toLowerCase() === (entry.npm ?? '').toLowerCase()
4121
+ || (installedRepo !== null && installedRepo === wantedRepo)
4122
+ || installedSpec.toLowerCase() === wantedSpec.toLowerCase();
4123
+ let active = false;
4124
+ try {
4125
+ const manifest = JSON.parse(readFileSync(join(activeProfileDir, 'package.json'), 'utf8'));
4126
+ active = (manifest.dsh?.profile?.bundles ?? []).includes(aliasOf) || liveNames().has(aliasOf);
4127
+ }
4128
+ catch {
4129
+ // unreadable manifest — treat as active to stay safe
4130
+ active = true;
4131
+ }
4132
+ if (active || !sameSource) {
4133
+ logEvent('warn', 'install-rejected', `${entry.name}: same plugin already installed as ${aliasOf}`);
4134
+ sendJson(response, 400, { error: `已以「${aliasOf}」安装过同一个插件,无需重复安装 / this plugin is already installed as "${aliasOf}"` });
4135
+ return;
4136
+ }
4137
+ retryAlias = aliasOf;
4138
+ logEvent('info', 'install', `${entry.name}: ${aliasOf} present but inactive (leftover of a failed install) — retrying`);
4139
+ }
4140
+ // Name-collision guard (#66): the curated registry lists DISTINCT
4141
+ // plugins sharing one name (both dsh-usage-stats, four dsh-memory…).
4142
+ // The alias guard above no longer cross-matches them (repo evidence
4143
+ // decides), but two packages with one name still cannot coexist —
4144
+ // pnpm would silently REPLACE the installed one's dependency entry.
4145
+ // Refuse with the honest reason instead.
4146
+ if (aliasOf === null) {
4147
+ const clashName = [entry.npm, entry.name].find((n) => typeof n === 'string' && n !== '' && installedNow[n] !== undefined);
4148
+ if (clashName !== undefined) {
4149
+ logEvent('warn', 'install-rejected', `${entry.name}: name collision with installed ${clashName} (${installedNow[clashName]}) from a different source`);
4150
+ sendJson(response, 400, {
4151
+ error: `同名冲突:已安装的「${clashName}」来自其他来源,两个同名插件无法共存于一个 profile,请先卸载再安装 / name conflict: an installed plugin already uses the name "${clashName}" but comes from a different source; two plugins with the same name cannot coexist in one profile — uninstall it first`,
4152
+ });
4153
+ return;
4154
+ }
4155
+ }
4156
+ const beforeSpecs = readInstalled(config.profile, activeProfileDir);
4157
+ const before = new Set(Object.keys(beforeSpecs));
4158
+ if (retryAlias !== null)
4159
+ before.delete(retryAlias);
4160
+ pendingRollbacks.clear();
4161
+ const compatibilityBefore = assessProfile(config.profile, activeProfileDir);
4162
+ // pnpm re-extracts the whole tree on any operation, so a plugin
4163
+ // nobody touched can come back pristine-and-broken, or lose a
4164
+ // patch that was holding it together (#222). Only what THIS run
4165
+ // broke is attributable to it, so the profile is swept before as
4166
+ // well as after.
4167
+ const bundlesBefore = brokenClientBundles(config.profile, activeProfileDir);
4168
+ // Exact manifest snapshot for failure rollback (#65, #339): the
4169
+ // host writes dependencies and dsh.profile.bundles before the
4170
+ // build-script check / registry fetches run. Either residue can
4171
+ // break every later operation or the next boot. Cancelled runs
4172
+ // keep their partial state on purpose (the user sees the diff
4173
+ // and decides).
4174
+ const manifestBefore = readProfileManifestSnapshot(config.profile, activeProfileDir);
4175
+ const result = await runPlugin(config.profile, ['add', target]);
4176
+ const cancelled = result.cancelled;
4177
+ if ((result.exitCode !== 0 || result.timedOut) && !cancelled) {
4178
+ const rolledBack = restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
4179
+ if (rolledBack.length > 0)
4180
+ logEvent('warn', 'install', `${target}: rolled back manifest residue of the failed run: ${rolledBack.join(', ')}`);
4181
+ }
4182
+ let ok = result.exitCode === 0 && !result.timedOut && !cancelled;
4183
+ const cancelDiff = cancelled ? changedSince(beforeSpecs) : null;
4184
+ if (ok)
4185
+ invalidateUpdates();
4186
+ if (ok) {
4187
+ // Collection repos (e.g. skin monorepos) install as a junk
4188
+ // fileset with no root package.json; retarget to the real
4189
+ // plugin subdirectories via pnpm's #path: selector.
4190
+ ok = await retargetCollections(runPlugin, config.profile, before, target, activeProfileDir);
4191
+ }
4192
+ // Fake-success guard (#18): a clean exit that added nothing
4193
+ // installable must not read as success. Runs even when
4194
+ // retargeting partially failed — a broken piece that slipped in
4195
+ // must never survive to brick the next boot.
4196
+ let notAPlugin = false;
4197
+ // pnpm exited 0 and the profile did not change at all — a
4198
+ // different failure from "what it added was unusable" (#258).
4199
+ let addedNothing = false;
4200
+ let removedBroken = [];
4201
+ let conflicts = [];
4202
+ if (result.exitCode === 0 && !result.timedOut && !cancelled) {
4203
+ const validated = await validateAddedPlugins(runPlugin, config.profile, before, activeProfileDir);
4204
+ removedBroken = validated.removedBroken;
4205
+ conflicts = validated.conflicts;
4206
+ if (removedBroken.length > 0) {
4207
+ logEvent('warn', 'install', `${target}: removed uninstallable pieces (no dsh manifest or missing build artifacts): ${removedBroken.join(', ')}`);
4208
+ }
4209
+ if (validated.keep.length === 0) {
4210
+ ok = false;
4211
+ notAPlugin = true;
4212
+ addedNothing = validated.added.length === 0;
4213
+ logEvent('error', 'install', addedNothing
4214
+ ? `${target}: the plugin command reported success but added nothing to the profile`
4215
+ : `${target}: nothing installable survived validation (added: ${validated.added.join(', ')})`);
4216
+ }
4217
+ else {
4218
+ // Partial success across a collection still counts as success.
4219
+ ok = true;
4220
+ }
4221
+ }
4222
+ const conflictGroups = groupConflictsByOwner(conflicts);
4223
+ const installed = readInstalled(config.profile, activeProfileDir);
4224
+ let hot = false;
4225
+ let activation;
4226
+ let compatibility;
4227
+ let addedPackages = [];
4228
+ if (ok) {
4229
+ const added = Object.keys(installed).filter(name => !before.has(name));
4230
+ addedPackages = added;
4231
+ if (added.length > 0) {
4232
+ // Fresh installs start enabled: drop any stale disable flag
4233
+ // (e.g. reinstall after an uninstall while this process kept
4234
+ // running) and persist before the activation loop.
4235
+ for (const name of added)
4236
+ disabled.delete(name);
4237
+ writeMarketState(activeProfileDir, { disabled, groups, groupOrder });
4238
+ // Theme installs auto-activate (and deactivate the previous
4239
+ // theme) so the result is visible right after the refresh.
4240
+ hot = true;
4241
+ for (const name of added) {
4242
+ const live = pluginCategories(entry).includes('theme')
4243
+ ? await themes.activateTheme(name)
4244
+ : (await hotMount(host, activeProfileDir, name)).ok;
4245
+ if (!live)
4246
+ hot = false;
4247
+ }
4248
+ activation = {};
4249
+ const live = liveNames();
4250
+ for (const name of added) {
4251
+ activation[name] = verifyActivation(config.profile, name, live, activeProfileDir, disabled.has(name));
4252
+ }
4253
+ }
4254
+ }
4255
+ if (ok && addedPackages.length > 0) {
4256
+ const after = assessProfile(config.profile, activeProfileDir);
4257
+ const risks = introducedRisks(compatibilityBefore, after);
4258
+ // Cross-layer name shadowing this install introduced (#230).
4259
+ // Shares the rollback id with the peer risks when both fire:
4260
+ // one operation, one thing to undo.
4261
+ const shadowed = introducedDuplicateNames(compatibilityBefore, after);
4262
+ // A client bundle that no longer parses (#222): pnpm can leave
4263
+ // one half-written or patch-mangled, and today that surfaces
4264
+ // as a blank settings page long after the install reported
4265
+ // success, with nothing connecting the two.
4266
+ const brokenBundles = newlyBrokenBundles(bundlesBefore, [
4267
+ ...addedPackages
4268
+ .map(pkg => ({ name: pkg, check: checkClientBundle(config.profile, pkg, activeProfileDir) }))
4269
+ .filter(entry => !entry.check.ok)
4270
+ .map(entry => ({ name: entry.name, reason: entry.check.reason ?? 'parse failed' })),
4271
+ ...brokenClientBundles(config.profile, activeProfileDir),
4272
+ ].filter((entry, index, all) => all.findIndex(other => other.name === entry.name) === index));
4273
+ if (risks.length > 0 || shadowed.length > 0 || brokenBundles.length > 0) {
4274
+ const rollbackId = savePendingRollback({ kind: 'install', names: addedPackages });
4275
+ compatibility = {
4276
+ code: 'soft-incompatible',
4277
+ risks,
4278
+ shadowedNames: shadowed.length > 0 ? shadowed : undefined,
4279
+ brokenBundles: brokenBundles.length > 0 ? brokenBundles : undefined,
4280
+ ...(rollbackId !== null
4281
+ ? { rollbackId }
4282
+ : { rollbackUnavailable: '安装完成后无法安全捕获 profile 状态,因此自动回滚不可用;需要时请手工卸载新安装的插件。 / The post-install profile state could not be captured safely, so automatic rollback is unavailable. Remove the newly installed plugin manually if needed.' }),
4283
+ };
4284
+ if (brokenBundles.length > 0) {
4285
+ logEvent('error', 'install-bundle', `${brokenBundles.map(entry => `${entry.name}: ${entry.reason}`).join('; ')}`);
4286
+ }
4287
+ if (risks.length > 0) {
4288
+ logEvent('warn', 'install-compat', `${addedPackages.join(', ')}: introduced host-compatibility risks — ${risks.map(risk => `${risk.peer}@${risk.range} vs ${risk.resolved}`).join('; ')}`);
4289
+ }
4290
+ if (shadowed.length > 0) {
4291
+ logEvent('warn', 'install-shadow', `${addedPackages.join(', ')}: introduced cross-layer duplicate loader names — ${shadowed.map(entry => `${entry.name} (${entry.layers.join(' + ')})`).join('; ')}`);
4292
+ }
4293
+ }
4294
+ }
4295
+ logEvent(ok || cancelled ? 'info' : 'error', 'install', `${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${ok ? ` hot=${String(hot)}` : cancelled ? '' : ` err=${failureDetail(result)}`}`);
4296
+ const ignoredBuilds = blockedBuilds(result);
4297
+ sendJson(response, ok || cancelled ? 200 : result.busy === true ? 409 : 502, {
4298
+ ok,
4299
+ cancelled: cancelled || undefined,
4300
+ busy: result.busy || undefined,
4301
+ hot,
4302
+ partial: cancelDiff?.partial,
4303
+ changed: cancelDiff?.changed,
4304
+ activation,
4305
+ compatibility,
4306
+ ignoredBuilds,
4307
+ // Named here rather than left for the next restart to find
4308
+ // (#339). Empty on every healthy operation, so the client only
4309
+ // ever sees this when something really is unbootable.
4310
+ ...(() => { const orphans = orphanBundles(); return orphans.length > 0 ? { orphanBundles: orphans } : {}; })(),
4311
+ // Only on a failure, and only for a subpath target: a stale
4312
+ // catalog entry produces a pnpm error that reads like the
4313
+ // user's fault (#346).
4314
+ ...(ok || cancelled ? {} : await (async () => {
4315
+ const stale = await staleSubpath(plainTarget ?? '');
4316
+ return stale === null ? {} : { staleEntry: stale };
4317
+ })()),
4318
+ // Blocked build scripts are expected (pnpm >= 10 blocks them by
4319
+ // default): surface the approve-builds banner instead of scaring
4320
+ // the user with pnpm's raw stack.
4321
+ // A loader-id clash is the most actionable failure of all: the
4322
+ // plugin is fine, it just cannot coexist with this profile (#122).
4323
+ // The UI renders `conflictGroups`; this string is the fallback
4324
+ // for logs and non-UI callers. It attributes each id to the
4325
+ // owner that actually declares it — a candidate can clash with
4326
+ // several installed plugins at once, and naming only the first
4327
+ // owner while listing every id blamed one plugin for another's
4328
+ // ids.
4329
+ conflictGroups: conflictGroups.length > 0 ? conflictGroups : undefined,
4330
+ error: conflictGroups.length > 0
4331
+ ? `「${conflicts[0].name}」与已安装的 ${conflictGroups.map(group => `「${group.owner}」(${group.ids.join('、')})`).join('、')} 占用相同的 loader 条目 id,无法在同一环境中共存——保留会导致 ChatCode CLI 下次启动失败,因此已自动移除。 / "${conflicts[0].name}" declares the same loader entry id(s) as the installed ${conflictGroups.map(group => `"${group.owner}" (${group.ids.join(', ')})`).join(', ')}; they cannot coexist in one environment — keeping it would stop ChatCode CLI from starting, so it was removed.`
4332
+ : addedNothing
4333
+ // Blaming allowBuilds here sent a reporter chasing a build
4334
+ // step for a plugin that ships a complete lib/ (#258). If
4335
+ // the profile did not change, the plugin is not the thing
4336
+ // that failed — the command that should have installed it
4337
+ // is.
4338
+ ? '安装命令报告成功,但 profile 没有任何变化——插件本身没问题,是执行安装的通道没有真正运行。若使用桌面端,请改用命令行 chatcode-cli plugin add 验证,并把导出日志附在 issue 中 / the install command reported success but the profile did not change — the plugin is not at fault, the channel that should have installed it did not actually run. On a desktop build, verify with `chatcode-cli plugin add` from a terminal and attach the exported log'
4339
+ : notAPlugin
4340
+ ? 'nothing installable: the plugin(s) need a build step (blocked by default, see allowBuilds) or ship no prebuilt artifacts / 没有可安装的内容:插件需要构建授权(allowBuilds,默认拦截)或未附带构建产物,详见导出日志'
4341
+ : Array.isArray(ignoredBuilds) && ignoredBuilds.length > 0
4342
+ // Names the button but NOT where it is: it was "above",
4343
+ // and this sentence is read inside the operations panel
4344
+ // where the button is not (#314). The panel now carries
4345
+ // the action on this very row, so the text can just say
4346
+ // what to press.
4347
+ ? `构建脚本被 pnpm 默认拦截(${ignoredBuilds.join(', ')}),点击「放行构建脚本并重试」即可放行并重装 / build scripts are blocked by pnpm by default (${ignoredBuilds.join(', ')}); use "Allow build scripts and retry" to approve and reinstall`
4348
+ : undefined,
4349
+ exitCode: result.exitCode,
4350
+ timedOut: result.timedOut,
4351
+ stdout: result.stdout,
4352
+ stderr: result.stderr,
4353
+ installed,
4354
+ });
4355
+ });
4356
+ }
4357
+ catch (error) {
4358
+ const message = error instanceof Error ? error.message : String(error);
4359
+ host.logger?.warn(`[ChatCode CLI Market] install failed: ${message}`);
4360
+ logEvent('error', 'install', `route error: ${message}`);
4361
+ sendJson(response, 500, { error: message });
4362
+ }
4363
+ },
4364
+ }),
4365
+ ];
4366
+ return () => {
4367
+ disposed = true;
4368
+ configurePersistentLog(null);
4369
+ for (const dispose of disposers)
4370
+ dispose();
4371
+ };
4372
+ }