@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/hot.ts ADDED
@@ -0,0 +1,628 @@
1
+ /**
2
+ * Restart-free installs: mount a freshly installed plugin into the running
3
+ * composition through a market-owned Include subtree.
4
+ *
5
+ * Durable state stays with the profile's `dsh.profile.bundles` (reconciled by
6
+ * the dsh CLI at install time), so the next boot loads the plugin through the
7
+ * normal bundle layer. The subtree here exists only for the current process:
8
+ * its input files live under `<profile>/.dsh-market/` and are wiped on every
9
+ * boot, so a crash can never leave a file that collides with the bundle layer
10
+ * (inserting an id the bundle layer also inserts is a hard boot failure).
11
+ * `state.json` in the same directory is the market's own durable state
12
+ * (disable list + custom groups) and deliberately survives the wipe.
13
+ *
14
+ * The Include subclass suppresses `write()` — the loader otherwise persists
15
+ * tree changes back to the file it read (see dsh's agent-presets PresetTree
16
+ * for the in-tree precedent).
17
+ */
18
+
19
+ import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+ import { pathToFileURL } from 'node:url'
22
+ import { asChannel, type Channel } from './channels.ts'
23
+ import { asRegion, normalizeGithubProxy, type Region } from './regions.ts'
24
+ import { logEvent } from './log.ts'
25
+ import { compatibilityEnv } from './home-paths.ts'
26
+
27
+ interface HotRow {
28
+ id: string
29
+ name: string
30
+ }
31
+
32
+ interface PluginHandle {
33
+ await(): Promise<unknown>
34
+ dispose(): Promise<unknown> | void
35
+ }
36
+
37
+ interface HotContext {
38
+ plugin(plugin: unknown, config: unknown): PluginHandle
39
+ logger?: { info?(message: string): void; warn(message: string): void }
40
+ }
41
+
42
+ const HOT_DIR = '.dsh-market'
43
+
44
+ /**
45
+ * Ceiling for one hot-mount activation, env-overridable like the install
46
+ * timeout in dsh-cli.ts. An activation that exceeds it is treated as wedged
47
+ * (typically a plugin pending on a service nothing provides) and falls back
48
+ * to restart activation.
49
+ */
50
+ const HOT_MOUNT_TIMEOUT_MS = Number(compatibilityEnv(
51
+ process.env,
52
+ 'CHATCODE_CLI_MARKET_HOT_MOUNT_TIMEOUT_MS',
53
+ 'DSH_MARKET_HOT_MOUNT_TIMEOUT_MS',
54
+ )) || 10000
55
+
56
+ let hotTreeClass: unknown | null | undefined
57
+
58
+ /**
59
+ * The Include subclass, built once per process; null when the loader's include
60
+ * plugin is not importable (older harness) — callers fall back to restart.
61
+ */
62
+ /**
63
+ * Packages whose host import is replaced by a no-op shim. Client-only plugins
64
+ * (`dsh.client` without `dsh.bundle`) have no importable host half, but
65
+ * client-modules only serves bundles for packages with a live loader entry —
66
+ * the shim fiber exists purely to satisfy that registration.
67
+ */
68
+ const shimNames = new Set<string>()
69
+
70
+ async function loadHotTreeClass(): Promise<unknown | null> {
71
+ if (hotTreeClass !== undefined) return hotTreeClass
72
+ try {
73
+ // Computed specifier: the include plugin ships with the harness (vendored,
74
+ // unpublished), so it resolves at runtime through the profile fallback but
75
+ // is not typecheckable as a dependency.
76
+ const specifier = '@deepseek-ai/cordis-plugin-include'
77
+ const mod = (await import(specifier)) as {
78
+ Include?: new (...args: never[]) => {
79
+ write(): void
80
+ import(name: string, getOuterStack?: () => string[]): unknown
81
+ }
82
+ }
83
+ const Include = mod.Include
84
+ if (Include === undefined) throw new Error('no Include export')
85
+ class MarketHotTree extends Include {
86
+ /** Runtime-only mount list; the bundle layer owns persistence. */
87
+ override write(): void {}
88
+ override import(name: string, getOuterStack?: () => string[]): unknown {
89
+ if (shimNames.has(name)) return { name, apply: () => {} }
90
+ return super.import(name, getOuterStack)
91
+ }
92
+ }
93
+ hotTreeClass = MarketHotTree
94
+ } catch {
95
+ hotTreeClass = null
96
+ }
97
+ return hotTreeClass
98
+ }
99
+
100
+ /** The `dsh` declaration block of an installed package, or null when unreadable. */
101
+ function readPkgDsh(profileDir: string, packageName: string): { client?: unknown; bundle?: unknown } | null {
102
+ try {
103
+ const manifest = JSON.parse(
104
+ readFileSync(join(profileDir, 'node_modules', packageName, 'package.json'), 'utf8'),
105
+ ) as { dsh?: { client?: unknown; bundle?: unknown } }
106
+ return manifest.dsh ?? {}
107
+ } catch {
108
+ return null
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Insert rows of a plugin's bundle patch, or null when the patch contains
114
+ * anything beyond plain `id`/`name` insert rows (config blocks, disables,
115
+ * expressions) — those compositions fall back to restart activation.
116
+ */
117
+ export function parseSimplePatch(patchText: string): HotRow[] | null {
118
+ const rows: HotRow[] = []
119
+ let pending: string | null = null
120
+ // Split on CRLF too. `#.*$` cannot strip a comment that ends in \r —
121
+ // JS treats \r as a line terminator, so `.` will not cross it and `$`
122
+ // only anchors at the very end — leaving the comment text in place,
123
+ // matching none of the row shapes, and failing the whole patch. Any
124
+ // plugin whose patch was authored on Windows then reads as
125
+ // "contains config/expression rows" and can never hot-mount.
126
+ for (const raw of patchText.split(/\r?\n/)) {
127
+ const line = raw.replace(/#.*$/, '').trimEnd()
128
+ if (line.trim() === '') continue
129
+ if (/^-\s+insert:\s*$/.test(line)) continue
130
+ const id = /^\s+-\s+id:\s*(\S+)\s*$/.exec(line)
131
+ if (id !== null) {
132
+ if (pending !== null) return null
133
+ pending = id[1]
134
+ continue
135
+ }
136
+ const name = /^\s+name:\s*['"]?([^'"\s]+)['"]?\s*$/.exec(line)
137
+ if (name !== null && pending !== null) {
138
+ rows.push({ id: pending, name: name[1] })
139
+ pending = null
140
+ continue
141
+ }
142
+ return null
143
+ }
144
+ if (pending !== null || rows.length === 0) return null
145
+ return rows
146
+ }
147
+
148
+ /**
149
+ * Wipe leftover hot-mount inputs; call once when the market host starts.
150
+ * `state.json` (disable choices + groups) deliberately survives.
151
+ */
152
+ export function cleanHotDir(profileDir: string): void {
153
+ const dir = join(profileDir, HOT_DIR)
154
+ let entries: string[]
155
+ try {
156
+ entries = readdirSync(dir)
157
+ } catch {
158
+ return
159
+ }
160
+ for (const name of entries) {
161
+ if (/^hot-\d+\.yml$/.test(name)) rmSync(join(dir, name), { force: true })
162
+ }
163
+ }
164
+
165
+ function stateFile(profileDir: string): string {
166
+ return join(profileDir, HOT_DIR, 'state.json')
167
+ }
168
+
169
+ /** Persisted market state: the generic disable list plus custom groups. */
170
+ export interface MarketState {
171
+ /** Plugins the user switched off; replayed at every boot. */
172
+ disabled: Set<string>
173
+ /** User-defined plugin groups: group name → member package names. */
174
+ groups: Record<string, string[]>
175
+ /** Display order of group names; "ungrouped" is implicit and never listed. */
176
+ groupOrder: string[]
177
+ /**
178
+ * The user's own one-line note per installed plugin (#347).
179
+ *
180
+ * A catalog description answers "what is this", written by its author for
181
+ * strangers and often in a language the reader did not pick. It cannot
182
+ * answer "why did I install this" — which is the question someone with
183
+ * forty plugins is actually asking. So a note REPLACES the description on
184
+ * that row, and the original stays one click away.
185
+ *
186
+ * Local state like the disable list and the groups beside it: never sent
187
+ * anywhere, and carried by a backup because it is part of how this profile
188
+ * is set up.
189
+ *
190
+ * Optional on the way IN: several callers build a state object from the
191
+ * few fields they own and hand it to writeMarketState. Requiring this one
192
+ * would make every such call a silent way to erase every note — the exact
193
+ * shape of #339, where a partial snapshot dropped a field nobody was
194
+ * thinking about. Omitting it means "leave them alone" instead.
195
+ */
196
+ notes?: Record<string, string>
197
+ /**
198
+ * The release channel the user PICKED, absent until they pick one.
199
+ *
200
+ * Absent is not the same as 'stable': with no choice on record the channel
201
+ * is derived from the running build, so installing a prerelease by hand
202
+ * puts you on the beta channel without a second step. Once chosen, the
203
+ * choice is the answer — including "stable" while a beta is running, which
204
+ * is how someone gets back off the channel.
205
+ */
206
+ channel?: Channel
207
+ /**
208
+ * The download region in force, absent until something has decided one.
209
+ *
210
+ * Absent means "nobody has decided yet", which is what triggers the
211
+ * one-time network probe. Once a value is here — whether the probe wrote
212
+ * it or the user picked it — no further probing happens, so the market
213
+ * does not silently change routes between runs.
214
+ */
215
+ region?: Region
216
+ /**
217
+ * Whether `region` was chosen by the probe rather than by the user.
218
+ *
219
+ * Only drives a one-time notice explaining why the market picked what it
220
+ * picked. A user who never learns a route was chosen for them has no way
221
+ * to know the setting exists, and no reason to look for it when something
222
+ * downloads oddly.
223
+ */
224
+ regionAuto?: boolean
225
+ /**
226
+ * Catalog entry URLs the user bookmarked for later install (#414).
227
+ * Keys are registry `url` strings, not package names — favorites are a
228
+ * pre-install list, unlike groups/notes which target installed packages.
229
+ */
230
+ favorites?: string[]
231
+ /** User-supplied HTTPS prefix used when the built-in GitHub routes fail. */
232
+ githubProxy?: string
233
+ }
234
+
235
+ /** Unique non-empty strings in `value`, order preserved. */
236
+ function uniqueStrings(value: unknown): string[] {
237
+ if (!Array.isArray(value)) return []
238
+ const seen = new Set<string>()
239
+ const out: string[] = []
240
+ for (const item of value) {
241
+ if (typeof item !== 'string' || item === '' || seen.has(item)) continue
242
+ seen.add(item)
243
+ out.push(item)
244
+ }
245
+ return out
246
+ }
247
+
248
+ /** Upper bound on bookmarked catalog URLs kept in state.json (#414). */
249
+ export const MAX_FAVORITES = 500
250
+
251
+ /** Catalog URLs the user may favorite; http(s) only, order preserved. */
252
+ function favoriteUrls(value: unknown): string[] {
253
+ return uniqueStrings(value).filter(url => url.startsWith('http://') || url.startsWith('https://'))
254
+ }
255
+
256
+ /**
257
+ * Read the whole market state. Legacy `disabledSkins` (the pre-#60
258
+ * theme-only key) still loads; every new write uses the generic `disabled`
259
+ * key (#60).
260
+ */
261
+ /** A note is a label, not a document: one line, bounded so state.json cannot
262
+ * grow without limit from a paste. */
263
+ export const MAX_NOTE = 200
264
+
265
+ export function readMarketState(profileDir: string): MarketState {
266
+ try {
267
+ const state = JSON.parse(readFileSync(stateFile(profileDir), 'utf8')) as {
268
+ disabled?: unknown
269
+ disabledSkins?: unknown
270
+ groups?: unknown
271
+ groupOrder?: unknown
272
+ channel?: unknown
273
+ region?: unknown
274
+ regionAuto?: unknown
275
+ githubProxy?: unknown
276
+ notes?: unknown
277
+ favorites?: unknown
278
+ }
279
+ const disabled = uniqueStrings(state.disabled !== undefined ? state.disabled : state.disabledSkins)
280
+ const groups: Record<string, string[]> = {}
281
+ if (state.groups !== null && typeof state.groups === 'object' && !Array.isArray(state.groups)) {
282
+ for (const [name, members] of Object.entries(state.groups)) {
283
+ groups[name] = uniqueStrings(members)
284
+ }
285
+ }
286
+ const notes: Record<string, string> = {}
287
+ if (state.notes !== null && typeof state.notes === 'object' && !Array.isArray(state.notes)) {
288
+ for (const [name, text] of Object.entries(state.notes)) {
289
+ // Empty is the same as absent: clearing a note must not leave a row
290
+ // claiming to carry one.
291
+ if (typeof text === 'string' && text.trim() !== '') notes[name] = text.slice(0, MAX_NOTE)
292
+ }
293
+ }
294
+ const githubProxy = normalizeGithubProxy(state.githubProxy)
295
+ return {
296
+ disabled: new Set(disabled),
297
+ groups,
298
+ notes,
299
+ groupOrder: uniqueStrings(state.groupOrder),
300
+ channel: asChannel(state.channel) ?? undefined,
301
+ region: asRegion(state.region) ?? undefined,
302
+ // Only meaningful beside a region, and only when true: a stray flag
303
+ // with no region would promise a notice about a choice nobody made.
304
+ regionAuto: state.regionAuto === true && asRegion(state.region) !== null ? true : undefined,
305
+ favorites: favoriteUrls(state.favorites),
306
+ ...(githubProxy === null ? {} : { githubProxy }),
307
+ }
308
+ } catch {
309
+ return { disabled: new Set(), groups: {}, groupOrder: [], notes: {}, favorites: [] }
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Persist the whole market state.
315
+ *
316
+ * Every field a caller does not carry forward is taken from disk rather than
317
+ * dropped. Several callers legitimately know about only one part of the
318
+ * state — `writeMarketState(dir, { disabled, groups, groupOrder })` appears
319
+ * at five call sites in routes.ts — and before #435 that shape silently
320
+ * erased whatever else the user had chosen:
321
+ *
322
+ * - `channel` and `region` had no fallback at all, so toggling any plugin
323
+ * threw away the user's update channel and download region. Both are
324
+ * deliberate choices made through the settings card, and neither has a
325
+ * "clear it" path: once picked they only ever move to another value. So
326
+ * an absent one always means "the caller has nothing to say", never
327
+ * "the user unchose it".
328
+ * `notes` keeps its original rule — an explicit object wins, including an
329
+ * empty one, because deleting the last note has to be expressible. What made
330
+ * #435 lose notes was not this function but a caller: the note route wrote
331
+ * through a fresh read while the long-lived `marketState` in routes.ts still
332
+ * carried `notes: {}` from boot, and the next write from that object put the
333
+ * empty one back. The fix for that belongs at the call site, where the two
334
+ * copies are, not here — see the note route.
335
+ *
336
+ * Reading before writing costs one small JSON parse on an operation that is
337
+ * already doing filesystem work, and it is what makes "this function writes
338
+ * the whole document" safe for callers that only hold part of it.
339
+ */
340
+ export function writeMarketState(profileDir: string, state: MarketState): void {
341
+ mkdirSync(join(profileDir, HOT_DIR), { recursive: true, mode: 0o700 })
342
+ const onDisk = readMarketState(profileDir)
343
+ // `?? {}` only for the type: MarketState declares notes optional, while
344
+ // readMarketState always returns an object.
345
+ const notes = state.notes ?? onDisk.notes ?? {}
346
+ const channel = state.channel ?? onDisk.channel
347
+ const region = state.region ?? onDisk.region
348
+ // Unlike channel and region, regionAuto has an explicit clear path: a
349
+ // manual region choice owns this field and sets it to undefined. Preserve
350
+ // the disk value only when the caller omitted the property entirely.
351
+ const regionAuto = Object.prototype.hasOwnProperty.call(state, 'regionAuto')
352
+ ? state.regionAuto
353
+ : onDisk.regionAuto
354
+ const favorites = state.favorites ?? onDisk.favorites ?? []
355
+ // This field does have a clear action ("restore automatic"). As with
356
+ // regionAuto, omission preserves while an explicit undefined removes it.
357
+ const githubProxy = Object.prototype.hasOwnProperty.call(state, 'githubProxy')
358
+ ? state.githubProxy
359
+ : onDisk.githubProxy
360
+ writeFileSync(stateFile(profileDir), JSON.stringify({
361
+ disabled: [...state.disabled],
362
+ groups: state.groups,
363
+ groupOrder: state.groupOrder,
364
+ ...(favorites.length > 0 ? { favorites } : {}),
365
+ ...(Object.keys(notes).length > 0 ? { notes } : {}),
366
+ // Omitted while unchosen, so "never picked" survives a round trip and
367
+ // keeps deriving from the running build — but only when disk has not
368
+ // recorded a choice either.
369
+ ...(channel === undefined ? {} : { channel }),
370
+ // Same reasoning, different consequence: an absent region is what makes
371
+ // the probe run, so writing a default here would mean it never does.
372
+ ...(region === undefined ? {} : { region }),
373
+ ...(regionAuto === true ? { regionAuto: true } : {}),
374
+ ...(githubProxy === undefined ? {} : { githubProxy }),
375
+ }))
376
+ }
377
+
378
+ /** Plugins the user switched off; skipped by the boot re-mount. */
379
+ export function readDisabled(profileDir: string): Set<string> {
380
+ return readMarketState(profileDir).disabled
381
+ }
382
+
383
+ /** Persist just the disable list, preserving groups and order. */
384
+ export function writeDisabled(profileDir: string, disabled: Set<string>): void {
385
+ const state = readMarketState(profileDir)
386
+ state.disabled = new Set(disabled)
387
+ writeMarketState(profileDir, state)
388
+ }
389
+
390
+ /** @deprecated theme-specific alias — kept for pre-#60 callers. */
391
+ export function readDisabledThemes(profileDir: string): Set<string> {
392
+ return readDisabled(profileDir)
393
+ }
394
+
395
+ /** @deprecated theme-specific alias — kept for pre-#60 callers. */
396
+ export function writeDisabledThemes(profileDir: string, disabled: Set<string>): void {
397
+ writeDisabled(profileDir, disabled)
398
+ }
399
+
400
+ /** Package names currently live through a market hot mount (patch or shim). */
401
+ export function listHotMounts(): string[] {
402
+ return [...hotHandles.keys()]
403
+ }
404
+
405
+ let hotSequence = 0
406
+
407
+ const hotHandles = new Map<string, PluginHandle>()
408
+
409
+ /** Activation did not settle within HOT_MOUNT_TIMEOUT_MS. */
410
+ class ActivationTimeout extends Error {}
411
+
412
+ /**
413
+ * Race an activation awaitable against the hot-mount ceiling. The handlers
414
+ * stay attached to the original promise, so a late rejection after a timeout
415
+ * can never surface as an unhandled rejection.
416
+ */
417
+ function raceActivationTimeout<T>(awaitable: T | Promise<T>): Promise<T> {
418
+ return new Promise<T>((resolve, reject) => {
419
+ const timer = setTimeout(() => {
420
+ reject(new ActivationTimeout(`activation did not settle within ${HOT_MOUNT_TIMEOUT_MS / 1000}s — the plugin may be waiting on a service that never arrives`))
421
+ }, HOT_MOUNT_TIMEOUT_MS)
422
+ Promise.resolve(awaitable).then(
423
+ value => { clearTimeout(timer); resolve(value) },
424
+ error => { clearTimeout(timer); reject(error) },
425
+ )
426
+ })
427
+ }
428
+
429
+ /** Outcome of one hot-mount attempt; `reason` explains non-`ok` results. */
430
+ export interface HotMountResult {
431
+ ok: boolean
432
+ /** Bilingual reason shown to the user instead of a bare restart banner. */
433
+ reason: string | null
434
+ }
435
+
436
+ /**
437
+ * Dispose a plugin hot-mounted earlier in this session, removing it from the
438
+ * running composition immediately.
439
+ * @param packageName - package to unmount.
440
+ * @returns true when a live hot mount was found and disposed.
441
+ */
442
+ export async function hotUnmount(packageName: string): Promise<boolean> {
443
+ const handle = hotHandles.get(packageName)
444
+ if (handle === undefined) return false
445
+ hotHandles.delete(packageName)
446
+ shimNames.delete(packageName)
447
+ try {
448
+ await handle.dispose()
449
+ logEvent('info', 'hot-unmount', `${packageName}: removed live`)
450
+ return true
451
+ } catch (error) {
452
+ logEvent('warn', 'hot-unmount', `${packageName}: dispose failed — ${error instanceof Error ? error.message : String(error)}`)
453
+ return false
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Mount `packageName` (just installed into the profile) into the running
459
+ * composition.
460
+ * @param ctx - market host context; the subtree unwinds with the market's fiber.
461
+ * @param profileDir - profile the package was installed into.
462
+ * @param packageName - installed package to activate.
463
+ * @returns whether the plugin is live without a restart, plus the reason
464
+ * when it is not (P0-2: the UI must distinguish "restart will fix it" from
465
+ * "this package can never hot-mount").
466
+ */
467
+ export async function hotMount(ctx: HotContext, profileDir: string, packageName: string): Promise<HotMountResult> {
468
+ try {
469
+ const HotTree = await loadHotTreeClass()
470
+ if (HotTree === null) {
471
+ return {
472
+ ok: false,
473
+ reason: '宿主不支持热挂载(include 插件不可导入),需重启 / the host cannot hot-mount (include plugin unavailable); restart required',
474
+ }
475
+ }
476
+ let patchText: string | null
477
+ try {
478
+ patchText = readFileSync(
479
+ join(profileDir, 'node_modules', packageName, 'cordis.patch.yml'),
480
+ 'utf8',
481
+ )
482
+ } catch {
483
+ patchText = null
484
+ }
485
+ let rows: HotRow[] | null
486
+ if (patchText !== null) {
487
+ rows = parseSimplePatch(patchText)
488
+ if (rows === null) {
489
+ return {
490
+ ok: false,
491
+ reason: 'bundle patch 含配置行/表达式,热挂载仅支持纯 insert,重启后生效 / the bundle patch contains config/expression rows; hot-mount only supports plain inserts — it activates on restart',
492
+ }
493
+ }
494
+ } else {
495
+ // No host patch. Client-only packages (dsh.client, no dsh.bundle) never
496
+ // get a loader entry — not even after a restart — so client-modules
497
+ // would never serve their bundle. A shim entry under the package's name
498
+ // is the whole activation.
499
+ const dsh = readPkgDsh(profileDir, packageName)
500
+ if (dsh === null || dsh.client === undefined || dsh.bundle !== undefined) {
501
+ return {
502
+ ok: false,
503
+ reason: '该包无 bundle patch 且未声明 dsh.client,没有可热挂载的内容 / no bundle patch and no dsh.client surface — nothing to hot-mount',
504
+ }
505
+ }
506
+ shimNames.add(packageName)
507
+ rows = [{ id: `client-${packageName.replace(/[^A-Za-z0-9_.-]/g, '-')}`, name: packageName }]
508
+ }
509
+ const dir = join(profileDir, HOT_DIR)
510
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
511
+ hotSequence += 1
512
+ const file = join(dir, `hot-${String(hotSequence)}.yml`)
513
+ const yml = rows
514
+ .map(row => `- id: 'mkt-${row.id}'\n name: '${row.name}'\n`)
515
+ .join('')
516
+ writeFileSync(file, yml)
517
+ const handle = ctx.plugin(HotTree, { path: pathToFileURL(file).href })
518
+ try {
519
+ await raceActivationTimeout(handle.await())
520
+ } catch (error) {
521
+ if (error instanceof ActivationTimeout) {
522
+ // A wedged activation would otherwise hold this request open forever:
523
+ // the route's `finally { installing = false }` never runs, so every
524
+ // later install/update/uninstall gets 409'd until a host restart.
525
+ // Unwind the half-mounted subtree best-effort; disposal never blocks
526
+ // the reply, and the caller falls back to restart activation.
527
+ try { Promise.resolve(handle.dispose()).catch(() => {}) } catch { /* best effort */ }
528
+ }
529
+ throw error
530
+ }
531
+ hotHandles.set(packageName, handle)
532
+ ctx.logger?.info?.(`[ChatCode CLI Market] hot-mounted ${packageName}`)
533
+ logEvent('info', 'hot-mount', `${packageName}: live${shimNames.has(packageName) ? ' (client-only shim)' : ''}`)
534
+ return { ok: true, reason: null }
535
+ } catch (error) {
536
+ const message = error instanceof Error ? error.message : String(error)
537
+ ctx.logger?.warn(`[ChatCode CLI Market] hot mount of ${packageName} failed, restart required: ${message}`)
538
+ logEvent('warn', 'hot-mount', `${packageName}: fell back to restart — ${message}`)
539
+ return { ok: false, reason: `热挂载失败,重启后生效 — ${message} / hot-mount failed — restart required: ${message}` }
540
+ }
541
+ }
542
+
543
+ /**
544
+ * Mount every installed client-only package (`dsh.client` without
545
+ * `dsh.bundle`) at market startup. The bundle reconcile skips these packages
546
+ * entirely, so without the market's shim their client bundles are unreachable
547
+ * in every boot — this is what makes them behave like normal plugins.
548
+ * @returns names that were mounted.
549
+ */
550
+ export async function mountClientOnlyDeps(ctx: HotContext, profileDir: string): Promise<string[]> {
551
+ let deps: string[]
552
+ try {
553
+ const manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as {
554
+ dependencies?: Record<string, string>
555
+ dsh?: { profile?: { bundles?: string[] } }
556
+ }
557
+ const bundles = new Set(manifest.dsh?.profile?.bundles ?? [])
558
+ deps = Object.keys(manifest.dependencies ?? {}).filter(name => !bundles.has(name))
559
+ } catch {
560
+ return []
561
+ }
562
+ const disabled = readDisabled(profileDir)
563
+ const userManaged = readUserPatchControls(profileDir)
564
+ const mounted: string[] = []
565
+ for (const name of deps) {
566
+ if (hotHandles.has(name) || disabled.has(name)) continue
567
+ // Packages the USER's patch layer already manages (insert or disable
568
+ // rows in cordis.patch.yml, e.g. via dsh-web-plugin-manager) are theirs
569
+ // to control — a shim here would override a user's "disabled" choice on
570
+ // every restart (#58 by @vikna919).
571
+ if (patchLayerManages(userManaged, name)) continue
572
+ const dsh = readPkgDsh(profileDir, name)
573
+ if (dsh === null || dsh.client === undefined || dsh.bundle !== undefined) continue
574
+ if ((await hotMount(ctx, profileDir, name)).ok) mounted.push(name)
575
+ }
576
+ return mounted
577
+ }
578
+
579
+ /**
580
+ * Row ids and package names the user's own patch layer (cordis.patch.yml)
581
+ * already contains. Line-wise scan on purpose: the file may hold structures
582
+ * the market's strict patch parser rejects, but any mention of a row id or
583
+ * package name is enough to know the user manages it (#58).
584
+ */
585
+ export function readUserPatchControls(profileDir: string): { ids: Set<string>; names: Set<string> } {
586
+ const ids = new Set<string>()
587
+ const names = new Set<string>()
588
+ try {
589
+ const text = readFileSync(join(profileDir, 'cordis.patch.yml'), 'utf8')
590
+ for (const line of text.split(/\r?\n/)) {
591
+ const id = /^\s*-?\s*id:\s*['"]?([A-Za-z0-9._/@-]+)/.exec(line)
592
+ if (id !== null) ids.add(id[1])
593
+ const name = /^\s*name:\s*['"]?([^'"\s]+)/.exec(line)
594
+ if (name !== null) names.add(name[1])
595
+ }
596
+ } catch { /* no user patch file — nothing is user-managed */ }
597
+ return { ids, names }
598
+ }
599
+
600
+ /**
601
+ * Whether the user patch layer manages `name` — matched by exact package
602
+ * name or by the plugin-manager row-id convention (strip the leading @,
603
+ * non-alphanumerics to '-', lowercase).
604
+ */
605
+ export function patchLayerManages(controls: { ids: Set<string>; names: Set<string> }, name: string): boolean {
606
+ const rowId = name.replace(/^@/, '').replace(/[^a-z0-9-]/gi, '-').toLowerCase()
607
+ return controls.ids.has(rowId) || controls.names.has(name)
608
+ }
609
+
610
+
611
+ /**
612
+ * Delete the market's own state directory.
613
+ *
614
+ * `cleanHotDir` wipes the ephemeral hot-mount inputs on every boot but
615
+ * deliberately preserves `state.json` — the disable list and custom groups
616
+ * are the user's durable choices. Uninstalling the market is the one moment
617
+ * where removing them is the right thing, and only when the user asked.
618
+ * @returns true when a directory was there to remove.
619
+ */
620
+ export function purgeMarketState(profileDir: string): boolean {
621
+ const dir = join(profileDir, HOT_DIR)
622
+ try {
623
+ rmSync(dir, { recursive: true, force: true })
624
+ return true
625
+ } catch {
626
+ return false
627
+ }
628
+ }
package/src/http.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Minimal HTTP helpers shared by every market route: JSON serialization,
3
+ * same-origin enforcement for mutating endpoints, and a size-capped JSON
4
+ * body reader.
5
+ */
6
+
7
+ import type { IncomingMessage, ServerResponse } from 'node:http'
8
+
9
+ /** Write a JSON payload with no-store caching. */
10
+ export function sendJson(response: ServerResponse, status: number, payload: unknown): void {
11
+ response.writeHead(status, {
12
+ 'cache-control': 'no-store',
13
+ 'content-type': 'application/json; charset=utf-8',
14
+ })
15
+ response.end(JSON.stringify(payload))
16
+ }
17
+
18
+ /** True when the request's Origin matches its Host — required on every POST route. */
19
+ export function sameOrigin(request: IncomingMessage): boolean {
20
+ const origin = request.headers.origin
21
+ const host = request.headers.host
22
+ if (origin === undefined || host === undefined) return false
23
+ try {
24
+ return new URL(origin).host === host
25
+ } catch {
26
+ return false
27
+ }
28
+ }
29
+
30
+ /** Read and parse a JSON request body, rejecting anything over 4 KiB. */
31
+ export async function readJsonBody(request: IncomingMessage, maxBytes = 4096): Promise<unknown> {
32
+ const chunks: Buffer[] = []
33
+ let size = 0
34
+ for await (const chunk of request) {
35
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
36
+ size += buffer.length
37
+ if (size > maxBytes) throw new Error('request body too large')
38
+ chunks.push(buffer)
39
+ }
40
+ return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown
41
+ }