@chatcode/cco-market 1.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/README.zh.md +143 -0
  4. package/UPDATE-API-V1.md +132 -0
  5. package/client/client.js +11534 -0
  6. package/cordis.patch.yml +5 -0
  7. package/lib/accelerate.js +184 -0
  8. package/lib/agents.js +36 -0
  9. package/lib/backup.js +572 -0
  10. package/lib/catalog-local-match.js +126 -0
  11. package/lib/catalog-npm.js +111 -0
  12. package/lib/changelog.js +242 -0
  13. package/lib/channels.js +64 -0
  14. package/lib/check.js +1067 -0
  15. package/lib/compatibility.js +198 -0
  16. package/lib/diagnostics.js +49 -0
  17. package/lib/discovery-compatibility.js +260 -0
  18. package/lib/dsh-cli.js +949 -0
  19. package/lib/dsh-install.js +108 -0
  20. package/lib/gist.js +352 -0
  21. package/lib/groups.js +97 -0
  22. package/lib/home-paths.js +42 -0
  23. package/lib/hot.js +519 -0
  24. package/lib/http.js +39 -0
  25. package/lib/index.js +97 -0
  26. package/lib/install.js +387 -0
  27. package/lib/log.js +197 -0
  28. package/lib/ndjson.js +154 -0
  29. package/lib/net.js +98 -0
  30. package/lib/order.js +284 -0
  31. package/lib/patch.js +521 -0
  32. package/lib/pnpm-compat.js +490 -0
  33. package/lib/presets.js +278 -0
  34. package/lib/profile.js +911 -0
  35. package/lib/region-probe.js +93 -0
  36. package/lib/regions.js +253 -0
  37. package/lib/registry.js +204 -0
  38. package/lib/restart.js +373 -0
  39. package/lib/routes.js +4372 -0
  40. package/lib/settings.js +109 -0
  41. package/lib/skill-market/api.js +119 -0
  42. package/lib/skill-market/config.js +29 -0
  43. package/lib/skill-market/installer.js +352 -0
  44. package/lib/skill-market/runtime.js +13 -0
  45. package/lib/skill-market/tui.js +119 -0
  46. package/lib/skill-market/types.js +1 -0
  47. package/lib/skill-market/web-routes.js +123 -0
  48. package/lib/snapshot.js +500 -0
  49. package/lib/source-migration.js +54 -0
  50. package/lib/sources.js +596 -0
  51. package/lib/store.js +91 -0
  52. package/lib/themes.js +102 -0
  53. package/lib/trial.js +116 -0
  54. package/lib/types/accelerate.d.ts +85 -0
  55. package/lib/types/agents.d.ts +22 -0
  56. package/lib/types/backup.d.ts +131 -0
  57. package/lib/types/catalog-local-match.d.ts +50 -0
  58. package/lib/types/catalog-npm.d.ts +47 -0
  59. package/lib/types/changelog.d.ts +102 -0
  60. package/lib/types/channels.d.ts +56 -0
  61. package/lib/types/check.d.ts +266 -0
  62. package/lib/types/compatibility.d.ts +64 -0
  63. package/lib/types/diagnostics.d.ts +29 -0
  64. package/lib/types/discovery-compatibility.d.ts +80 -0
  65. package/lib/types/dsh-cli.d.ts +324 -0
  66. package/lib/types/dsh-install.d.ts +46 -0
  67. package/lib/types/gist.d.ts +53 -0
  68. package/lib/types/groups.d.ts +34 -0
  69. package/lib/types/home-paths.d.ts +16 -0
  70. package/lib/types/hot.d.ts +221 -0
  71. package/lib/types/http.d.ts +12 -0
  72. package/lib/types/index.d.ts +14 -0
  73. package/lib/types/install.d.ts +172 -0
  74. package/lib/types/log.d.ts +43 -0
  75. package/lib/types/ndjson.d.ts +52 -0
  76. package/lib/types/net.d.ts +58 -0
  77. package/lib/types/order.d.ts +100 -0
  78. package/lib/types/patch.d.ts +129 -0
  79. package/lib/types/pnpm-compat.d.ts +93 -0
  80. package/lib/types/presets.d.ts +90 -0
  81. package/lib/types/profile.d.ts +253 -0
  82. package/lib/types/region-probe.d.ts +50 -0
  83. package/lib/types/regions.d.ts +122 -0
  84. package/lib/types/registry.d.ts +86 -0
  85. package/lib/types/restart.d.ts +194 -0
  86. package/lib/types/routes.d.ts +67 -0
  87. package/lib/types/settings.d.ts +78 -0
  88. package/lib/types/skill-market/api.d.ts +10 -0
  89. package/lib/types/skill-market/config.d.ts +3 -0
  90. package/lib/types/skill-market/installer.d.ts +23 -0
  91. package/lib/types/skill-market/runtime.d.ts +10 -0
  92. package/lib/types/skill-market/tui.d.ts +4 -0
  93. package/lib/types/skill-market/types.d.ts +71 -0
  94. package/lib/types/skill-market/web-routes.d.ts +19 -0
  95. package/lib/types/snapshot.d.ts +93 -0
  96. package/lib/types/source-migration.d.ts +11 -0
  97. package/lib/types/sources.d.ts +216 -0
  98. package/lib/types/store.d.ts +30 -0
  99. package/lib/types/themes.d.ts +40 -0
  100. package/lib/types/trial.d.ts +61 -0
  101. package/lib/types/update-api-v1.d.ts +66 -0
  102. package/lib/types/updates.d.ts +123 -0
  103. package/lib/types/verify.d.ts +139 -0
  104. package/lib/update-api-v1.js +215 -0
  105. package/lib/updates.js +361 -0
  106. package/lib/verify.js +453 -0
  107. package/package.json +121 -0
  108. package/src/accelerate.ts +213 -0
  109. package/src/agents.ts +43 -0
  110. package/src/backup.ts +583 -0
  111. package/src/catalog-local-match.ts +144 -0
  112. package/src/catalog-npm.ts +120 -0
  113. package/src/changelog.ts +282 -0
  114. package/src/channels.ts +70 -0
  115. package/src/check.ts +1239 -0
  116. package/src/client/CommentsModal.tsx +119 -0
  117. package/src/client/Diagnostics.tsx +907 -0
  118. package/src/client/ErrorBoundary.tsx +111 -0
  119. package/src/client/InstallToast.tsx +31 -0
  120. package/src/client/Market.module.css +830 -0
  121. package/src/client/MarketSection.tsx +5301 -0
  122. package/src/client/OperationsPanel.tsx +365 -0
  123. package/src/client/SettingsCard.tsx +621 -0
  124. package/src/client/SkillMarket.module.css +21 -0
  125. package/src/client/SkillMarketSection.tsx +162 -0
  126. package/src/client/comments.ts +54 -0
  127. package/src/client/globals.d.ts +13 -0
  128. package/src/client/index.ts +180 -0
  129. package/src/client/locales.ts +1112 -0
  130. package/src/client/market-data.ts +1321 -0
  131. package/src/client/operations.ts +201 -0
  132. package/src/client/preset-panel.tsx +263 -0
  133. package/src/client/primitives.d.ts +140 -0
  134. package/src/client/self-check.ts +147 -0
  135. package/src/client/snapshot-panel.tsx +244 -0
  136. package/src/compatibility.ts +237 -0
  137. package/src/diagnostics.ts +84 -0
  138. package/src/discovery-compatibility.ts +315 -0
  139. package/src/dsh-cli.ts +1126 -0
  140. package/src/dsh-install.ts +118 -0
  141. package/src/gist.ts +362 -0
  142. package/src/groups.ts +111 -0
  143. package/src/home-paths.ts +53 -0
  144. package/src/hot.ts +628 -0
  145. package/src/http.ts +41 -0
  146. package/src/index.ts +128 -0
  147. package/src/install.ts +420 -0
  148. package/src/log.ts +206 -0
  149. package/src/ndjson.ts +185 -0
  150. package/src/net.ts +106 -0
  151. package/src/order.ts +303 -0
  152. package/src/patch.ts +522 -0
  153. package/src/pnpm-compat.ts +527 -0
  154. package/src/presets.ts +344 -0
  155. package/src/profile.ts +940 -0
  156. package/src/region-probe.ts +97 -0
  157. package/src/regions.ts +310 -0
  158. package/src/registry.ts +250 -0
  159. package/src/restart.ts +396 -0
  160. package/src/routes.ts +4506 -0
  161. package/src/settings.ts +141 -0
  162. package/src/skill-market/api.ts +130 -0
  163. package/src/skill-market/config.ts +32 -0
  164. package/src/skill-market/installer.ts +337 -0
  165. package/src/skill-market/runtime.ts +14 -0
  166. package/src/skill-market/tui.ts +157 -0
  167. package/src/skill-market/types.ts +80 -0
  168. package/src/skill-market/web-routes.ts +132 -0
  169. package/src/snapshot.ts +532 -0
  170. package/src/source-migration.ts +61 -0
  171. package/src/sources.ts +565 -0
  172. package/src/store.ts +89 -0
  173. package/src/themes.ts +125 -0
  174. package/src/trial.ts +156 -0
  175. package/src/update-api-v1.ts +277 -0
  176. package/src/updates.ts +400 -0
  177. package/src/verify.ts +492 -0
package/lib/hot.js ADDED
@@ -0,0 +1,519 @@
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
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
19
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
20
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
21
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
22
+ });
23
+ }
24
+ return path;
25
+ };
26
+ import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import { pathToFileURL } from 'node:url';
29
+ import { asChannel } from './channels.js';
30
+ import { asRegion, normalizeGithubProxy } from './regions.js';
31
+ import { logEvent } from './log.js';
32
+ import { compatibilityEnv } from './home-paths.js';
33
+ const HOT_DIR = '.dsh-market';
34
+ /**
35
+ * Ceiling for one hot-mount activation, env-overridable like the install
36
+ * timeout in dsh-cli.ts. An activation that exceeds it is treated as wedged
37
+ * (typically a plugin pending on a service nothing provides) and falls back
38
+ * to restart activation.
39
+ */
40
+ const HOT_MOUNT_TIMEOUT_MS = Number(compatibilityEnv(process.env, 'CHATCODE_CLI_MARKET_HOT_MOUNT_TIMEOUT_MS', 'DSH_MARKET_HOT_MOUNT_TIMEOUT_MS')) || 10000;
41
+ let hotTreeClass;
42
+ /**
43
+ * The Include subclass, built once per process; null when the loader's include
44
+ * plugin is not importable (older harness) — callers fall back to restart.
45
+ */
46
+ /**
47
+ * Packages whose host import is replaced by a no-op shim. Client-only plugins
48
+ * (`dsh.client` without `dsh.bundle`) have no importable host half, but
49
+ * client-modules only serves bundles for packages with a live loader entry —
50
+ * the shim fiber exists purely to satisfy that registration.
51
+ */
52
+ const shimNames = new Set();
53
+ async function loadHotTreeClass() {
54
+ if (hotTreeClass !== undefined)
55
+ return hotTreeClass;
56
+ try {
57
+ // Computed specifier: the include plugin ships with the harness (vendored,
58
+ // unpublished), so it resolves at runtime through the profile fallback but
59
+ // is not typecheckable as a dependency.
60
+ const specifier = '@deepseek-ai/cordis-plugin-include';
61
+ const mod = (await import(__rewriteRelativeImportExtension(specifier)));
62
+ const Include = mod.Include;
63
+ if (Include === undefined)
64
+ throw new Error('no Include export');
65
+ class MarketHotTree extends Include {
66
+ /** Runtime-only mount list; the bundle layer owns persistence. */
67
+ write() { }
68
+ import(name, getOuterStack) {
69
+ if (shimNames.has(name))
70
+ return { name, apply: () => { } };
71
+ return super.import(name, getOuterStack);
72
+ }
73
+ }
74
+ hotTreeClass = MarketHotTree;
75
+ }
76
+ catch {
77
+ hotTreeClass = null;
78
+ }
79
+ return hotTreeClass;
80
+ }
81
+ /** The `dsh` declaration block of an installed package, or null when unreadable. */
82
+ function readPkgDsh(profileDir, packageName) {
83
+ try {
84
+ const manifest = JSON.parse(readFileSync(join(profileDir, 'node_modules', packageName, 'package.json'), 'utf8'));
85
+ return manifest.dsh ?? {};
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ /**
92
+ * Insert rows of a plugin's bundle patch, or null when the patch contains
93
+ * anything beyond plain `id`/`name` insert rows (config blocks, disables,
94
+ * expressions) — those compositions fall back to restart activation.
95
+ */
96
+ export function parseSimplePatch(patchText) {
97
+ const rows = [];
98
+ let pending = null;
99
+ // Split on CRLF too. `#.*$` cannot strip a comment that ends in \r —
100
+ // JS treats \r as a line terminator, so `.` will not cross it and `$`
101
+ // only anchors at the very end — leaving the comment text in place,
102
+ // matching none of the row shapes, and failing the whole patch. Any
103
+ // plugin whose patch was authored on Windows then reads as
104
+ // "contains config/expression rows" and can never hot-mount.
105
+ for (const raw of patchText.split(/\r?\n/)) {
106
+ const line = raw.replace(/#.*$/, '').trimEnd();
107
+ if (line.trim() === '')
108
+ continue;
109
+ if (/^-\s+insert:\s*$/.test(line))
110
+ continue;
111
+ const id = /^\s+-\s+id:\s*(\S+)\s*$/.exec(line);
112
+ if (id !== null) {
113
+ if (pending !== null)
114
+ return null;
115
+ pending = id[1];
116
+ continue;
117
+ }
118
+ const name = /^\s+name:\s*['"]?([^'"\s]+)['"]?\s*$/.exec(line);
119
+ if (name !== null && pending !== null) {
120
+ rows.push({ id: pending, name: name[1] });
121
+ pending = null;
122
+ continue;
123
+ }
124
+ return null;
125
+ }
126
+ if (pending !== null || rows.length === 0)
127
+ return null;
128
+ return rows;
129
+ }
130
+ /**
131
+ * Wipe leftover hot-mount inputs; call once when the market host starts.
132
+ * `state.json` (disable choices + groups) deliberately survives.
133
+ */
134
+ export function cleanHotDir(profileDir) {
135
+ const dir = join(profileDir, HOT_DIR);
136
+ let entries;
137
+ try {
138
+ entries = readdirSync(dir);
139
+ }
140
+ catch {
141
+ return;
142
+ }
143
+ for (const name of entries) {
144
+ if (/^hot-\d+\.yml$/.test(name))
145
+ rmSync(join(dir, name), { force: true });
146
+ }
147
+ }
148
+ function stateFile(profileDir) {
149
+ return join(profileDir, HOT_DIR, 'state.json');
150
+ }
151
+ /** Unique non-empty strings in `value`, order preserved. */
152
+ function uniqueStrings(value) {
153
+ if (!Array.isArray(value))
154
+ return [];
155
+ const seen = new Set();
156
+ const out = [];
157
+ for (const item of value) {
158
+ if (typeof item !== 'string' || item === '' || seen.has(item))
159
+ continue;
160
+ seen.add(item);
161
+ out.push(item);
162
+ }
163
+ return out;
164
+ }
165
+ /** Upper bound on bookmarked catalog URLs kept in state.json (#414). */
166
+ export const MAX_FAVORITES = 500;
167
+ /** Catalog URLs the user may favorite; http(s) only, order preserved. */
168
+ function favoriteUrls(value) {
169
+ return uniqueStrings(value).filter(url => url.startsWith('http://') || url.startsWith('https://'));
170
+ }
171
+ /**
172
+ * Read the whole market state. Legacy `disabledSkins` (the pre-#60
173
+ * theme-only key) still loads; every new write uses the generic `disabled`
174
+ * key (#60).
175
+ */
176
+ /** A note is a label, not a document: one line, bounded so state.json cannot
177
+ * grow without limit from a paste. */
178
+ export const MAX_NOTE = 200;
179
+ export function readMarketState(profileDir) {
180
+ try {
181
+ const state = JSON.parse(readFileSync(stateFile(profileDir), 'utf8'));
182
+ const disabled = uniqueStrings(state.disabled !== undefined ? state.disabled : state.disabledSkins);
183
+ const groups = {};
184
+ if (state.groups !== null && typeof state.groups === 'object' && !Array.isArray(state.groups)) {
185
+ for (const [name, members] of Object.entries(state.groups)) {
186
+ groups[name] = uniqueStrings(members);
187
+ }
188
+ }
189
+ const notes = {};
190
+ if (state.notes !== null && typeof state.notes === 'object' && !Array.isArray(state.notes)) {
191
+ for (const [name, text] of Object.entries(state.notes)) {
192
+ // Empty is the same as absent: clearing a note must not leave a row
193
+ // claiming to carry one.
194
+ if (typeof text === 'string' && text.trim() !== '')
195
+ notes[name] = text.slice(0, MAX_NOTE);
196
+ }
197
+ }
198
+ const githubProxy = normalizeGithubProxy(state.githubProxy);
199
+ return {
200
+ disabled: new Set(disabled),
201
+ groups,
202
+ notes,
203
+ groupOrder: uniqueStrings(state.groupOrder),
204
+ channel: asChannel(state.channel) ?? undefined,
205
+ region: asRegion(state.region) ?? undefined,
206
+ // Only meaningful beside a region, and only when true: a stray flag
207
+ // with no region would promise a notice about a choice nobody made.
208
+ regionAuto: state.regionAuto === true && asRegion(state.region) !== null ? true : undefined,
209
+ favorites: favoriteUrls(state.favorites),
210
+ ...(githubProxy === null ? {} : { githubProxy }),
211
+ };
212
+ }
213
+ catch {
214
+ return { disabled: new Set(), groups: {}, groupOrder: [], notes: {}, favorites: [] };
215
+ }
216
+ }
217
+ /**
218
+ * Persist the whole market state.
219
+ *
220
+ * Every field a caller does not carry forward is taken from disk rather than
221
+ * dropped. Several callers legitimately know about only one part of the
222
+ * state — `writeMarketState(dir, { disabled, groups, groupOrder })` appears
223
+ * at five call sites in routes.ts — and before #435 that shape silently
224
+ * erased whatever else the user had chosen:
225
+ *
226
+ * - `channel` and `region` had no fallback at all, so toggling any plugin
227
+ * threw away the user's update channel and download region. Both are
228
+ * deliberate choices made through the settings card, and neither has a
229
+ * "clear it" path: once picked they only ever move to another value. So
230
+ * an absent one always means "the caller has nothing to say", never
231
+ * "the user unchose it".
232
+ * `notes` keeps its original rule — an explicit object wins, including an
233
+ * empty one, because deleting the last note has to be expressible. What made
234
+ * #435 lose notes was not this function but a caller: the note route wrote
235
+ * through a fresh read while the long-lived `marketState` in routes.ts still
236
+ * carried `notes: {}` from boot, and the next write from that object put the
237
+ * empty one back. The fix for that belongs at the call site, where the two
238
+ * copies are, not here — see the note route.
239
+ *
240
+ * Reading before writing costs one small JSON parse on an operation that is
241
+ * already doing filesystem work, and it is what makes "this function writes
242
+ * the whole document" safe for callers that only hold part of it.
243
+ */
244
+ export function writeMarketState(profileDir, state) {
245
+ mkdirSync(join(profileDir, HOT_DIR), { recursive: true, mode: 0o700 });
246
+ const onDisk = readMarketState(profileDir);
247
+ // `?? {}` only for the type: MarketState declares notes optional, while
248
+ // readMarketState always returns an object.
249
+ const notes = state.notes ?? onDisk.notes ?? {};
250
+ const channel = state.channel ?? onDisk.channel;
251
+ const region = state.region ?? onDisk.region;
252
+ // Unlike channel and region, regionAuto has an explicit clear path: a
253
+ // manual region choice owns this field and sets it to undefined. Preserve
254
+ // the disk value only when the caller omitted the property entirely.
255
+ const regionAuto = Object.prototype.hasOwnProperty.call(state, 'regionAuto')
256
+ ? state.regionAuto
257
+ : onDisk.regionAuto;
258
+ const favorites = state.favorites ?? onDisk.favorites ?? [];
259
+ // This field does have a clear action ("restore automatic"). As with
260
+ // regionAuto, omission preserves while an explicit undefined removes it.
261
+ const githubProxy = Object.prototype.hasOwnProperty.call(state, 'githubProxy')
262
+ ? state.githubProxy
263
+ : onDisk.githubProxy;
264
+ writeFileSync(stateFile(profileDir), JSON.stringify({
265
+ disabled: [...state.disabled],
266
+ groups: state.groups,
267
+ groupOrder: state.groupOrder,
268
+ ...(favorites.length > 0 ? { favorites } : {}),
269
+ ...(Object.keys(notes).length > 0 ? { notes } : {}),
270
+ // Omitted while unchosen, so "never picked" survives a round trip and
271
+ // keeps deriving from the running build — but only when disk has not
272
+ // recorded a choice either.
273
+ ...(channel === undefined ? {} : { channel }),
274
+ // Same reasoning, different consequence: an absent region is what makes
275
+ // the probe run, so writing a default here would mean it never does.
276
+ ...(region === undefined ? {} : { region }),
277
+ ...(regionAuto === true ? { regionAuto: true } : {}),
278
+ ...(githubProxy === undefined ? {} : { githubProxy }),
279
+ }));
280
+ }
281
+ /** Plugins the user switched off; skipped by the boot re-mount. */
282
+ export function readDisabled(profileDir) {
283
+ return readMarketState(profileDir).disabled;
284
+ }
285
+ /** Persist just the disable list, preserving groups and order. */
286
+ export function writeDisabled(profileDir, disabled) {
287
+ const state = readMarketState(profileDir);
288
+ state.disabled = new Set(disabled);
289
+ writeMarketState(profileDir, state);
290
+ }
291
+ /** @deprecated theme-specific alias — kept for pre-#60 callers. */
292
+ export function readDisabledThemes(profileDir) {
293
+ return readDisabled(profileDir);
294
+ }
295
+ /** @deprecated theme-specific alias — kept for pre-#60 callers. */
296
+ export function writeDisabledThemes(profileDir, disabled) {
297
+ writeDisabled(profileDir, disabled);
298
+ }
299
+ /** Package names currently live through a market hot mount (patch or shim). */
300
+ export function listHotMounts() {
301
+ return [...hotHandles.keys()];
302
+ }
303
+ let hotSequence = 0;
304
+ const hotHandles = new Map();
305
+ /** Activation did not settle within HOT_MOUNT_TIMEOUT_MS. */
306
+ class ActivationTimeout extends Error {
307
+ }
308
+ /**
309
+ * Race an activation awaitable against the hot-mount ceiling. The handlers
310
+ * stay attached to the original promise, so a late rejection after a timeout
311
+ * can never surface as an unhandled rejection.
312
+ */
313
+ function raceActivationTimeout(awaitable) {
314
+ return new Promise((resolve, reject) => {
315
+ const timer = setTimeout(() => {
316
+ 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`));
317
+ }, HOT_MOUNT_TIMEOUT_MS);
318
+ Promise.resolve(awaitable).then(value => { clearTimeout(timer); resolve(value); }, error => { clearTimeout(timer); reject(error); });
319
+ });
320
+ }
321
+ /**
322
+ * Dispose a plugin hot-mounted earlier in this session, removing it from the
323
+ * running composition immediately.
324
+ * @param packageName - package to unmount.
325
+ * @returns true when a live hot mount was found and disposed.
326
+ */
327
+ export async function hotUnmount(packageName) {
328
+ const handle = hotHandles.get(packageName);
329
+ if (handle === undefined)
330
+ return false;
331
+ hotHandles.delete(packageName);
332
+ shimNames.delete(packageName);
333
+ try {
334
+ await handle.dispose();
335
+ logEvent('info', 'hot-unmount', `${packageName}: removed live`);
336
+ return true;
337
+ }
338
+ catch (error) {
339
+ logEvent('warn', 'hot-unmount', `${packageName}: dispose failed — ${error instanceof Error ? error.message : String(error)}`);
340
+ return false;
341
+ }
342
+ }
343
+ /**
344
+ * Mount `packageName` (just installed into the profile) into the running
345
+ * composition.
346
+ * @param ctx - market host context; the subtree unwinds with the market's fiber.
347
+ * @param profileDir - profile the package was installed into.
348
+ * @param packageName - installed package to activate.
349
+ * @returns whether the plugin is live without a restart, plus the reason
350
+ * when it is not (P0-2: the UI must distinguish "restart will fix it" from
351
+ * "this package can never hot-mount").
352
+ */
353
+ export async function hotMount(ctx, profileDir, packageName) {
354
+ try {
355
+ const HotTree = await loadHotTreeClass();
356
+ if (HotTree === null) {
357
+ return {
358
+ ok: false,
359
+ reason: '宿主不支持热挂载(include 插件不可导入),需重启 / the host cannot hot-mount (include plugin unavailable); restart required',
360
+ };
361
+ }
362
+ let patchText;
363
+ try {
364
+ patchText = readFileSync(join(profileDir, 'node_modules', packageName, 'cordis.patch.yml'), 'utf8');
365
+ }
366
+ catch {
367
+ patchText = null;
368
+ }
369
+ let rows;
370
+ if (patchText !== null) {
371
+ rows = parseSimplePatch(patchText);
372
+ if (rows === null) {
373
+ return {
374
+ ok: false,
375
+ reason: 'bundle patch 含配置行/表达式,热挂载仅支持纯 insert,重启后生效 / the bundle patch contains config/expression rows; hot-mount only supports plain inserts — it activates on restart',
376
+ };
377
+ }
378
+ }
379
+ else {
380
+ // No host patch. Client-only packages (dsh.client, no dsh.bundle) never
381
+ // get a loader entry — not even after a restart — so client-modules
382
+ // would never serve their bundle. A shim entry under the package's name
383
+ // is the whole activation.
384
+ const dsh = readPkgDsh(profileDir, packageName);
385
+ if (dsh === null || dsh.client === undefined || dsh.bundle !== undefined) {
386
+ return {
387
+ ok: false,
388
+ reason: '该包无 bundle patch 且未声明 dsh.client,没有可热挂载的内容 / no bundle patch and no dsh.client surface — nothing to hot-mount',
389
+ };
390
+ }
391
+ shimNames.add(packageName);
392
+ rows = [{ id: `client-${packageName.replace(/[^A-Za-z0-9_.-]/g, '-')}`, name: packageName }];
393
+ }
394
+ const dir = join(profileDir, HOT_DIR);
395
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
396
+ hotSequence += 1;
397
+ const file = join(dir, `hot-${String(hotSequence)}.yml`);
398
+ const yml = rows
399
+ .map(row => `- id: 'mkt-${row.id}'\n name: '${row.name}'\n`)
400
+ .join('');
401
+ writeFileSync(file, yml);
402
+ const handle = ctx.plugin(HotTree, { path: pathToFileURL(file).href });
403
+ try {
404
+ await raceActivationTimeout(handle.await());
405
+ }
406
+ catch (error) {
407
+ if (error instanceof ActivationTimeout) {
408
+ // A wedged activation would otherwise hold this request open forever:
409
+ // the route's `finally { installing = false }` never runs, so every
410
+ // later install/update/uninstall gets 409'd until a host restart.
411
+ // Unwind the half-mounted subtree best-effort; disposal never blocks
412
+ // the reply, and the caller falls back to restart activation.
413
+ try {
414
+ Promise.resolve(handle.dispose()).catch(() => { });
415
+ }
416
+ catch { /* best effort */ }
417
+ }
418
+ throw error;
419
+ }
420
+ hotHandles.set(packageName, handle);
421
+ ctx.logger?.info?.(`[ChatCode CLI Market] hot-mounted ${packageName}`);
422
+ logEvent('info', 'hot-mount', `${packageName}: live${shimNames.has(packageName) ? ' (client-only shim)' : ''}`);
423
+ return { ok: true, reason: null };
424
+ }
425
+ catch (error) {
426
+ const message = error instanceof Error ? error.message : String(error);
427
+ ctx.logger?.warn(`[ChatCode CLI Market] hot mount of ${packageName} failed, restart required: ${message}`);
428
+ logEvent('warn', 'hot-mount', `${packageName}: fell back to restart — ${message}`);
429
+ return { ok: false, reason: `热挂载失败,重启后生效 — ${message} / hot-mount failed — restart required: ${message}` };
430
+ }
431
+ }
432
+ /**
433
+ * Mount every installed client-only package (`dsh.client` without
434
+ * `dsh.bundle`) at market startup. The bundle reconcile skips these packages
435
+ * entirely, so without the market's shim their client bundles are unreachable
436
+ * in every boot — this is what makes them behave like normal plugins.
437
+ * @returns names that were mounted.
438
+ */
439
+ export async function mountClientOnlyDeps(ctx, profileDir) {
440
+ let deps;
441
+ try {
442
+ const manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'));
443
+ const bundles = new Set(manifest.dsh?.profile?.bundles ?? []);
444
+ deps = Object.keys(manifest.dependencies ?? {}).filter(name => !bundles.has(name));
445
+ }
446
+ catch {
447
+ return [];
448
+ }
449
+ const disabled = readDisabled(profileDir);
450
+ const userManaged = readUserPatchControls(profileDir);
451
+ const mounted = [];
452
+ for (const name of deps) {
453
+ if (hotHandles.has(name) || disabled.has(name))
454
+ continue;
455
+ // Packages the USER's patch layer already manages (insert or disable
456
+ // rows in cordis.patch.yml, e.g. via dsh-web-plugin-manager) are theirs
457
+ // to control — a shim here would override a user's "disabled" choice on
458
+ // every restart (#58 by @vikna919).
459
+ if (patchLayerManages(userManaged, name))
460
+ continue;
461
+ const dsh = readPkgDsh(profileDir, name);
462
+ if (dsh === null || dsh.client === undefined || dsh.bundle !== undefined)
463
+ continue;
464
+ if ((await hotMount(ctx, profileDir, name)).ok)
465
+ mounted.push(name);
466
+ }
467
+ return mounted;
468
+ }
469
+ /**
470
+ * Row ids and package names the user's own patch layer (cordis.patch.yml)
471
+ * already contains. Line-wise scan on purpose: the file may hold structures
472
+ * the market's strict patch parser rejects, but any mention of a row id or
473
+ * package name is enough to know the user manages it (#58).
474
+ */
475
+ export function readUserPatchControls(profileDir) {
476
+ const ids = new Set();
477
+ const names = new Set();
478
+ try {
479
+ const text = readFileSync(join(profileDir, 'cordis.patch.yml'), 'utf8');
480
+ for (const line of text.split(/\r?\n/)) {
481
+ const id = /^\s*-?\s*id:\s*['"]?([A-Za-z0-9._/@-]+)/.exec(line);
482
+ if (id !== null)
483
+ ids.add(id[1]);
484
+ const name = /^\s*name:\s*['"]?([^'"\s]+)/.exec(line);
485
+ if (name !== null)
486
+ names.add(name[1]);
487
+ }
488
+ }
489
+ catch { /* no user patch file — nothing is user-managed */ }
490
+ return { ids, names };
491
+ }
492
+ /**
493
+ * Whether the user patch layer manages `name` — matched by exact package
494
+ * name or by the plugin-manager row-id convention (strip the leading @,
495
+ * non-alphanumerics to '-', lowercase).
496
+ */
497
+ export function patchLayerManages(controls, name) {
498
+ const rowId = name.replace(/^@/, '').replace(/[^a-z0-9-]/gi, '-').toLowerCase();
499
+ return controls.ids.has(rowId) || controls.names.has(name);
500
+ }
501
+ /**
502
+ * Delete the market's own state directory.
503
+ *
504
+ * `cleanHotDir` wipes the ephemeral hot-mount inputs on every boot but
505
+ * deliberately preserves `state.json` — the disable list and custom groups
506
+ * are the user's durable choices. Uninstalling the market is the one moment
507
+ * where removing them is the right thing, and only when the user asked.
508
+ * @returns true when a directory was there to remove.
509
+ */
510
+ export function purgeMarketState(profileDir) {
511
+ const dir = join(profileDir, HOT_DIR);
512
+ try {
513
+ rmSync(dir, { recursive: true, force: true });
514
+ return true;
515
+ }
516
+ catch {
517
+ return false;
518
+ }
519
+ }
package/lib/http.js ADDED
@@ -0,0 +1,39 @@
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
+ /** Write a JSON payload with no-store caching. */
7
+ export function sendJson(response, status, payload) {
8
+ response.writeHead(status, {
9
+ 'cache-control': 'no-store',
10
+ 'content-type': 'application/json; charset=utf-8',
11
+ });
12
+ response.end(JSON.stringify(payload));
13
+ }
14
+ /** True when the request's Origin matches its Host — required on every POST route. */
15
+ export function sameOrigin(request) {
16
+ const origin = request.headers.origin;
17
+ const host = request.headers.host;
18
+ if (origin === undefined || host === undefined)
19
+ return false;
20
+ try {
21
+ return new URL(origin).host === host;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ /** Read and parse a JSON request body, rejecting anything over 4 KiB. */
28
+ export async function readJsonBody(request, maxBytes = 4096) {
29
+ const chunks = [];
30
+ let size = 0;
31
+ for await (const chunk of request) {
32
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
33
+ size += buffer.length;
34
+ if (size > maxBytes)
35
+ throw new Error('request body too large');
36
+ chunks.push(buffer);
37
+ }
38
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
39
+ }
package/lib/index.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * dsh-market host entry: mounts the market's HTTP routes once the profile
3
+ * composes the webServer and shell services.
4
+ */
5
+ import { createDesktopPluginRuntime } from './dsh-cli.js';
6
+ import { mountMarketRoutes } from './routes.js';
7
+ import { installMarketSettings } from './settings.js';
8
+ import { resolveSkillMarketConfig } from './skill-market/config.js';
9
+ import { SkillMarketRuntime } from './skill-market/runtime.js';
10
+ import { mountSkillMarketRoutes } from './skill-market/web-routes.js';
11
+ import { installSkillMarketTui } from './skill-market/tui.js';
12
+ export const name = 'dsh-market';
13
+ /**
14
+ * Register the market against the host context.
15
+ * @param ctx - Host context that may acquire webServer and shell services.
16
+ * @param config - Optional profile override from the loader.
17
+ */
18
+ /**
19
+ * The profile this host process actually booted (`--profile <name>` on the
20
+ * dsh CLI invocation). Without it the market would default to `web` and
21
+ * installs from a test/secondary profile would mutate the real one.
22
+ */
23
+ function argvProfile() {
24
+ const argv = process.argv;
25
+ const flag = argv.indexOf('--profile');
26
+ if (flag !== -1 && flag + 1 < argv.length && !argv[flag + 1].startsWith('-'))
27
+ return argv[flag + 1];
28
+ return undefined;
29
+ }
30
+ /**
31
+ * Resolve the host's `agents` inventory lazily — at request time, not at
32
+ * market startup, so the guard sees whichever agents exist by the time an
33
+ * update is asked for. Hosts without the service return undefined and the
34
+ * update route stays open (see src/agents.ts).
35
+ */
36
+ function agentsLookupOf(ctx) {
37
+ return () => ctx.get('agents');
38
+ }
39
+ export function apply(ctx, config) {
40
+ const skillMarketConfig = resolveSkillMarketConfig(config?.skillMarket);
41
+ const skillMarket = new SkillMarketRuntime(skillMarketConfig);
42
+ ctx.inject(['webServer', 'loader'], (hostCtx) => {
43
+ const host = hostCtx;
44
+ const desktopProfiles = ctx.get('desktopProfiles');
45
+ if (desktopProfiles === undefined) {
46
+ const resolved = {
47
+ profile: config?.profile ?? argvProfile() ?? 'web',
48
+ // Left UNDEFINED when unconfigured, deliberately: `?? true` here
49
+ // would turn "the operator said nothing" into "the operator said
50
+ // yes", and restartAllowed() could no longer tell them apart — which
51
+ // is exactly the distinction supervisor detection needs (#229).
52
+ allowRestart: config?.allowRestart,
53
+ maxSnapshots: config?.maxSnapshots,
54
+ };
55
+ // Offer allowRestart as a switch on the settings page. Deliberately
56
+ // NOT in the Desktop branch below: there the shell owns the process
57
+ // lifecycle and the value is forced false, so it is not the user's to
58
+ // choose. No-ops on a host without a settings service.
59
+ installMarketSettings(ctx, resolved);
60
+ host.effect(() => {
61
+ return mountMarketRoutes(host, resolved, undefined, agentsLookupOf(ctx));
62
+ }, 'dsh-market: http routes');
63
+ return;
64
+ }
65
+ // Desktop's supported cross-environment contract guarantees that
66
+ // desktopProfiles exists before Loader entries mount, and prescribes this
67
+ // presence check plus a nested desktopPnpm injection:
68
+ // https://github.com/anywhere-labs/deepseek-harness-desktop/blob/4f68147091e585aaa1d815f99d30a657b3842d7c/dsh-plugin-desktop/docs/plugin-services.md#L190-L243
69
+ // Ordinary DSH keeps the existing CLI path above.
70
+ hostCtx.inject(['desktopPnpm'], (desktopCtx) => {
71
+ const current = desktopProfiles.current;
72
+ const service = desktopCtx.desktopPnpm;
73
+ const runtime = createDesktopPluginRuntime(service, current.dir);
74
+ const resolved = {
75
+ profile: current.name,
76
+ profileDirectory: current.dir,
77
+ // Relaunching a raw Electron process would bypass Desktop's launcher
78
+ // lifecycle. The shell remains responsible for restart in this mode.
79
+ allowRestart: false,
80
+ maxSnapshots: config?.maxSnapshots,
81
+ };
82
+ const desktopHost = desktopCtx;
83
+ desktopHost.effect(() => {
84
+ const disposeRoutes = mountMarketRoutes(host, resolved, runtime, agentsLookupOf(ctx));
85
+ return async () => {
86
+ disposeRoutes();
87
+ await runtime.dispose();
88
+ };
89
+ }, 'dsh-market: Desktop http routes and package operations');
90
+ });
91
+ });
92
+ installSkillMarketTui(ctx, skillMarket);
93
+ ctx.inject(['webServer', 'sessions'], (hostCtx) => {
94
+ const host = hostCtx;
95
+ host.effect(() => mountSkillMarketRoutes(host, skillMarket), 'dsh-market: skill market http routes');
96
+ });
97
+ }