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