@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/patch.js ADDED
@@ -0,0 +1,521 @@
1
+ /**
2
+ * Patch-layer plugin toggles — hot disable/enable through the profile's
3
+ * user patch layer (cordis.patch.yml), the mechanism ported from
4
+ * Noob-stupid/dsh-plugin-hub's plugin console.
5
+ *
6
+ * DSH composes a web profile from the bundle layers + the user patch layer
7
+ * (`$CHATCODE_CLI_HOME/profiles/<name>/cordis.patch.yml`), with per-key override
8
+ * semantics: a patch row `- id: X` + `disabled: true` stops that loader
9
+ * entry, and `disabled: false` force-enables one a lower layer disabled.
10
+ * The profile's config-file watcher (HMR) re-composes within ~1s of the
11
+ * save — no restart — and the loader re-applies the same file on every
12
+ * boot, so the choice survives restarts through the official mechanism.
13
+ *
14
+ * The market ALSO keeps its own in-memory/state.json bookkeeping (hot-mount
15
+ * shims have no bundle row to patch, and the client's disable list drives
16
+ * the switches); this module is the durable, HMR-driven layer on top.
17
+ *
18
+ * Safety (borrowed from the plugin-hub implementation):
19
+ * - writes are serialized so concurrent toggles cannot interleave a
20
+ * read-modify-write;
21
+ * - an append is REFUSED when the patch file is not a valid entry list —
22
+ * a malformed file (e.g. a stray `[]` followed by items) is never made
23
+ * worse, the market reports it instead;
24
+ * - host infrastructure rows (transport / hot-reload / storage / settings
25
+ * chains) are protected and refuse to toggle.
26
+ */
27
+ import { readFileSync, writeFileSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { logEvent } from './log.js';
31
+ import { parsePatchFile, parsePatchText } from './check.js';
32
+ import { bundlePatchInsertedIds, parsePatchRows } from './profile.js';
33
+ /**
34
+ * Host infrastructure rows: disabling any of these breaks the very chain
35
+ * the patch layer runs on (e.g. timer → HMR, webserver → the page itself),
36
+ * so they refuse to toggle. Same list the plugin-hub console uses — the
37
+ * target host is the same DSH.
38
+ */
39
+ const PROTECTED_MODULE_PATTERNS = [
40
+ /^cordis:/u,
41
+ /^@deepseek-ai\/cordis-plugin-/u,
42
+ /^@deepseek-ai\/dsh-host-/u,
43
+ /^@deepseek-ai\/dsh-client-modules$/u,
44
+ /^@deepseek-ai\/dsh-client-connection$/u,
45
+ /^@deepseek-ai\/dsh-client-hmr$/u,
46
+ /^@deepseek-ai\/dsh-client-runtime$/u,
47
+ /^@deepseek-ai\/dsh-client-locale$/u,
48
+ /^@deepseek-ai\/dsh-client-web/u,
49
+ /^@deepseek-ai\/dsh-web-frontend$/u,
50
+ /^@deepseek-ai\/dsh-web-app$/u,
51
+ /^@deepseek-ai\/dsh-settings/u,
52
+ /^@deepseek-ai\/dsh-credentials/u,
53
+ /^@deepseek-ai\/dsh-session/u,
54
+ /^@deepseek-ai\/dsh-storage/u,
55
+ /^@deepseek-ai\/dsh-typert/u,
56
+ /^@deepseek-ai\/dsh-api-remotes$/u,
57
+ /^@deepseek-ai\/dsh-tools$/u,
58
+ /^@deepseek-ai\/dsh-system-prompt$/u,
59
+ /^@deepseek-ai\/dsh-agent/u,
60
+ /^@deepseek-ai\/dsh-llm/u,
61
+ /^@deepseek-ai\/dsh-persona$/u,
62
+ /^@deepseek-ai\/dsh-scope$/u,
63
+ /^@deepseek-ai\/dsh-launch-environment$/u,
64
+ /^@deepseek-ai\/dsh-shell$/u,
65
+ /^@deepseek-ai\/dsh-subprocess/u,
66
+ /^@deepseek-ai\/dsh-fs/u,
67
+ /^@deepseek-ai\/dsh-sandbox/u,
68
+ /^@deepseek-ai\/dsh-jobs/u,
69
+ /^@deepseek-ai\/dsh-skill/u,
70
+ /^@deepseek-ai\/dsh-goal/u,
71
+ /^@deepseek-ai\/dsh-workflow/u,
72
+ /^@deepseek-ai\/dsh-subagent/u,
73
+ /^@deepseek-ai\/dsh-web$/u,
74
+ /^@deepseek-ai\/dsh-workspace/u,
75
+ /^@deepseek-ai\/dsh-user-approval$/u,
76
+ /^@deepseek-ai\/dsh-user-questions$/u,
77
+ /^@deepseek-ai\/dsh-commands$/u,
78
+ /^@deepseek-ai\/dsh-hook/u,
79
+ /^@deepseek-ai\/dsh-spill/u,
80
+ /^@deepseek-ai\/dsh-guard/u,
81
+ /^@deepseek-ai\/dsh-tool-call-timeout-policy$/u,
82
+ /^@deepseek-ai\/dsh-repeat-tool-reminder$/u,
83
+ ];
84
+ /** True when the module name sits on the host infrastructure chain. */
85
+ export function isProtectedModule(moduleName) {
86
+ return typeof moduleName === 'string' && PROTECTED_MODULE_PATTERNS.some(pattern => pattern.test(moduleName));
87
+ }
88
+ /**
89
+ * Resolve the profile's user patch layer. Prefers the path the loader's
90
+ * cordis:include entry actually read (authoritative under hosts that own
91
+ * the profile directory, like DSH Desktop); falls back to the conventional
92
+ * `<profile>/cordis.patch.yml`.
93
+ */
94
+ export function findUserPatchPath(host, profileDir) {
95
+ for (const entry of host.loader.entries()) {
96
+ const cfg = entry.options?.config;
97
+ if (entry.options?.name !== 'cordis:include' || cfg == null || typeof cfg.path !== 'string')
98
+ continue;
99
+ if (!cfg.path.includes('cordis.yml'))
100
+ continue;
101
+ let includePath = cfg.path;
102
+ if (includePath.startsWith('file://')) {
103
+ try {
104
+ includePath = fileURLToPath(includePath);
105
+ }
106
+ catch {
107
+ // fileURLToPath rejects POSIX-style URLs on Windows; the scheme is
108
+ // all we need to strip for the path to be usable.
109
+ includePath = includePath.replace(/^file:\/\//u, '');
110
+ }
111
+ }
112
+ return includePath.replace(/cordis\.yml$/u, 'cordis.patch.yml');
113
+ }
114
+ return join(profileDir, 'cordis.patch.yml');
115
+ }
116
+ /** Row ids the market is allowed to write: plain unquoted YAML scalars. */
117
+ const ROW_ID_RE = /^[A-Za-z0-9_.-]+$/u;
118
+ /**
119
+ * Line-wise scan of one patch file — the plugin-hub shapes. Deliberately
120
+ * not a YAML parse: the file may hold structures the market's dialect
121
+ * rejects, but a plain `- id: X` + `disabled: true|false` pair is enough
122
+ * to know what the user patch layer says.
123
+ */
124
+ export function readUserPatchState(patchPath) {
125
+ const disables = [];
126
+ const forced = [];
127
+ const inserts = [];
128
+ let text = '';
129
+ try {
130
+ text = readFileSync(patchPath, 'utf8');
131
+ }
132
+ catch { /* no patch file — empty state */ }
133
+ const lines = text.split(/\r?\n/u);
134
+ let inInsert = false;
135
+ for (let index = 0; index < lines.length; index += 1) {
136
+ const line = lines[index] ?? '';
137
+ if (/^- insert:\s*$/u.test(line)) {
138
+ inInsert = true;
139
+ continue;
140
+ }
141
+ if (/^- /u.test(line))
142
+ inInsert = false;
143
+ if (inInsert) {
144
+ const insertRow = /^ {4}- id: ([A-Za-z0-9_.-]+)/u.exec(line);
145
+ if (insertRow !== null)
146
+ inserts.push(insertRow[1]);
147
+ continue;
148
+ }
149
+ const disableRow = /^- id: ([A-Za-z0-9_.-]+)\s*$/u.exec(line);
150
+ if (disableRow === null)
151
+ continue;
152
+ const next = lines[index + 1] ?? '';
153
+ if (/^ {2}disabled: true\s*$/u.test(next))
154
+ disables.push(disableRow[1]);
155
+ else if (/^ {2}disabled: false\s*$/u.test(next))
156
+ forced.push(disableRow[1]);
157
+ }
158
+ return { disables, forced, inserts };
159
+ }
160
+ /**
161
+ * User-authored insert rows that still load `packageName` (or one of its
162
+ * exported subpaths). This evidence deliberately stays separate from the
163
+ * package's own bundle declaration: uninstall may clean market-owned rows,
164
+ * but it must never rewrite an insert the user owns.
165
+ *
166
+ * A missing patch is the ordinary empty-profile shape. `null` means the
167
+ * existing file could not be read as DSH's patch dialect; callers must treat
168
+ * that as indeterminate and refuse the destructive step.
169
+ */
170
+ export function userPatchPackageReferences(patchPath, packageName) {
171
+ let source;
172
+ try {
173
+ source = readFileSync(patchPath, 'utf8');
174
+ }
175
+ catch (error) {
176
+ const code = error !== null && typeof error === 'object' && 'code' in error
177
+ ? error.code
178
+ : undefined;
179
+ if (code === 'ENOENT')
180
+ return [];
181
+ return null;
182
+ }
183
+ // Use the same schema as profile composition, including !!js scalars. A
184
+ // line scanner cannot distinguish flow-style entries or nested loader
185
+ // groups from arbitrary config keys in a hand-written user patch.
186
+ const rows = parsePatchText(source);
187
+ if (rows === null)
188
+ return null;
189
+ const insertedNames = new Set();
190
+ const visiting = new Set();
191
+ const visited = new Set();
192
+ const collect = (entries) => {
193
+ if (visited.has(entries))
194
+ return true;
195
+ if (visiting.has(entries))
196
+ return false;
197
+ visiting.add(entries);
198
+ for (const entry of entries) {
199
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
200
+ visiting.delete(entries);
201
+ return false;
202
+ }
203
+ const row = entry;
204
+ if ('name' in row && typeof row.name !== 'string') {
205
+ visiting.delete(entries);
206
+ return false;
207
+ }
208
+ if (typeof row.name === 'string')
209
+ insertedNames.add(row.name);
210
+ // Only group rows treat an array-valued config as child loader rows.
211
+ // Other plugins may use arrays of `{ name: ... }` as ordinary options,
212
+ // and those must not become false package references.
213
+ if (row.group === true && Array.isArray(row.config)) {
214
+ if (!collect(row.config)) {
215
+ visiting.delete(entries);
216
+ return false;
217
+ }
218
+ }
219
+ }
220
+ visiting.delete(entries);
221
+ visited.add(entries);
222
+ return true;
223
+ };
224
+ for (const patch of rows) {
225
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch))
226
+ return null;
227
+ const patchRow = patch;
228
+ if (!('insert' in patchRow))
229
+ continue;
230
+ if (!Array.isArray(patchRow.insert) || !collect(patchRow.insert))
231
+ return null;
232
+ }
233
+ return [...insertedNames].filter(reference => reference === packageName || reference.startsWith(`${packageName}/`));
234
+ }
235
+ /** The include entry's id prefix (loader entry ids look like `include:X`). */
236
+ function includePrefix(host) {
237
+ for (const entry of host.loader.entries()) {
238
+ if (entry.options?.name === 'cordis:include' && typeof entry.options.id === 'string') {
239
+ return `${entry.options.id}:`;
240
+ }
241
+ }
242
+ return '';
243
+ }
244
+ /**
245
+ * The user-patch row ids one installed package owns: its bundle patch's
246
+ * insert rows, plus the loader entries currently carrying its name.
247
+ * Empty for client-only packages (no bundle rows) — the market's own
248
+ * state.json mechanism covers those, and there is nothing to patch.
249
+ * Market-owned namespaces (hot-mount `mkt-*`, shim `client-*`) are
250
+ * excluded: their rows live in the market's own include subtree, and a
251
+ * permanent patch row targeting them would be a boot-time orphan.
252
+ */
253
+ export function rowIdsForPackage(host, profileDirectory, packageName) {
254
+ const ids = new Set();
255
+ const packageDir = join(profileDirectory, 'node_modules', packageName);
256
+ // ONLY the rows this package inserts (#147). A bundle patch also carries
257
+ // rows that merely reconfigure OTHER plugins — dsh-vision-router tunes the
258
+ // official `attachment-local` row — and writing `disabled: true` onto
259
+ // those took down attachments and the DeepSeek model along with it.
260
+ try {
261
+ for (const id of bundlePatchInsertedIds(packageDir))
262
+ ids.add(id);
263
+ }
264
+ catch { /* package not installed — loader side may still know it */ }
265
+ // The conventional location too: a package may ship cordis.patch.yml at
266
+ // its root without declaring dsh.bundle.patch (the loader probes it too).
267
+ // Same parser, deliberately: this used to be a second hand-rolled scan
268
+ // that closed the insert block only on `id:` lines, so `- disable:` with
269
+ // nested ids under it claimed the neighbour's rows — #147 all over again
270
+ // on the path #147 did not touch.
271
+ try {
272
+ for (const id of parsePatchRows(readFileSync(join(packageDir, 'cordis.patch.yml'), 'utf8')).insertedIds) {
273
+ ids.add(id);
274
+ }
275
+ }
276
+ catch { /* no conventional patch — nothing more to add */ }
277
+ const prefix = includePrefix(host);
278
+ for (const entry of host.loader.entries()) {
279
+ if (entry.options?.name !== packageName)
280
+ continue;
281
+ let id = entry.options.id ?? '';
282
+ if (id === '')
283
+ continue;
284
+ if (prefix !== '' && id.startsWith(prefix))
285
+ id = id.slice(prefix.length);
286
+ if (/^(?:mkt-|client-)/u.test(id))
287
+ continue;
288
+ ids.add(id);
289
+ }
290
+ return [...ids];
291
+ }
292
+ /**
293
+ * Top-level patch rows that DISABLE another plugin: a row carrying both an
294
+ * `id` and `disabled: true`. Rows nested under an `insert:` block are separate
295
+ * array elements shaped `{ insert: [...] }`, so anything here is by definition
296
+ * a sibling row targeting a plugin this package does not own.
297
+ */
298
+ function foreignDisableIds(rows) {
299
+ const ids = [];
300
+ for (const row of rows) {
301
+ if (row === null || typeof row !== 'object' || Array.isArray(row))
302
+ continue;
303
+ const record = row;
304
+ const id = record.id;
305
+ if (typeof id === 'string' && record.disabled === true && !ids.includes(id))
306
+ ids.push(id);
307
+ }
308
+ return ids;
309
+ }
310
+ /**
311
+ * The ids of OTHER plugins a package's bundle patch DISABLES — top-level
312
+ * `- id: X` + `disabled: true` rows targeting plugins it does not own. This is
313
+ * the precise marker of a bundle whose toggle-off can brick the boot (#224):
314
+ * dsh-postgres-backends disables session-persistence-jsonl, so once the market
315
+ * also disables the postgres backends nothing provides sessionPersistence.
316
+ *
317
+ * A bundle that merely RECONFIGURES a neighbour is deliberately NOT counted:
318
+ * the e2e fixture-cross tweaks dshm-fixture-b's config, and #147 requires
319
+ * disabling it to leave that neighbour live — dropping such a bundle from the
320
+ * stack broke its re-enable. Config-only side effects stay on the normal #147
321
+ * path; only a foreign `disabled: true` triggers the bundle removal. Removing
322
+ * the bundle still neutralizes any config side effects it carries, since its
323
+ * whole patch stops applying.
324
+ *
325
+ * Reads both patch sources like rowIdsForPackage — the declared dsh.bundle.patch
326
+ * and the conventional root cordis.patch.yml — so either form is detected.
327
+ */
328
+ export function carrierDisableIds(profileDirectory, packageName) {
329
+ const packageDir = join(profileDirectory, 'node_modules', packageName);
330
+ const disabled = new Set();
331
+ const collectFromFile = (patchPath) => {
332
+ const rows = parsePatchFile(patchPath);
333
+ if (rows === null)
334
+ return;
335
+ for (const id of foreignDisableIds(rows))
336
+ disabled.add(id);
337
+ };
338
+ try {
339
+ const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'));
340
+ const declared = manifest.dsh?.bundle?.patch;
341
+ if (typeof declared === 'string' && declared !== '')
342
+ collectFromFile(join(packageDir, declared));
343
+ }
344
+ catch { /* package not installed — nothing to attribute */ }
345
+ collectFromFile(join(packageDir, 'cordis.patch.yml'));
346
+ return [...disabled];
347
+ }
348
+ /**
349
+ * Per-package patch-layer flags for the installed list: names whose rows the
350
+ * user patch layer disables / force-enables. These cover toggles made
351
+ * OUTSIDE the market (hand-edited cordis.patch.yml, dsh-web-plugin-manager,
352
+ * the dsh CLI), which the market's own state.json never sees.
353
+ */
354
+ export function packagePatchFlags(host, profileDirectory, names, state) {
355
+ const disabled = [];
356
+ const forced = [];
357
+ for (const name of names) {
358
+ const rows = rowIdsForPackage(host, profileDirectory, name);
359
+ if (rows.some(id => state.disables.includes(id)))
360
+ disabled.push(name);
361
+ if (rows.some(id => state.forced.includes(id)))
362
+ forced.push(name);
363
+ }
364
+ return { disabled, forced };
365
+ }
366
+ /** Serialize patch-file writes: concurrent toggles must not interleave. */
367
+ let writeQueue = Promise.resolve();
368
+ function queuedWrite(fn) {
369
+ const run = writeQueue.then(fn, fn);
370
+ writeQueue = run.then(() => undefined, () => undefined);
371
+ return run;
372
+ }
373
+ function escapeRegExp(value) {
374
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
375
+ }
376
+ function rowBlock(rowId, disabled) {
377
+ return `- id: ${rowId}\n disabled: ${disabled ? 'true' : 'false'}\n`;
378
+ }
379
+ /**
380
+ * Append one top-level patch entry, refusing when the file is not a valid
381
+ * entry list. The refusal is the point: a malformed patch layer (the
382
+ * `[]` + items mistake, or any broken YAML) must never be made worse —
383
+ * the market reports it and keeps its own live/state.json toggle instead.
384
+ */
385
+ function appendPatchEntry(patchPath, block) {
386
+ let text = '';
387
+ try {
388
+ text = readFileSync(patchPath, 'utf8');
389
+ }
390
+ catch { /* created below */ }
391
+ const core = text.trim();
392
+ if (core === '') {
393
+ writeFileSync(patchPath, block);
394
+ return { ok: true, reason: null };
395
+ }
396
+ // The dsh profile template ships an empty `[]` placeholder (a valid
397
+ // empty list). Appending after it would create TWO top-level elements in
398
+ // one document — the exact YAML error a hand-edit runs into. Comment the
399
+ // placeholder out (keeping the template header) and append the entry.
400
+ const withoutComments = text.replace(/^[ \t]*#.*$/gmu, '').trim();
401
+ // Comments only (the user dropped the placeholder): append after them.
402
+ if (withoutComments === '') {
403
+ const next = text.endsWith('\n') ? text : `${text}\n`;
404
+ writeFileSync(patchPath, `${next}${block}`);
405
+ return { ok: true, reason: null };
406
+ }
407
+ if (withoutComments === '[]' || withoutComments === '[ ]') {
408
+ const commented = text.replace(/^[ \t]*\[[ \t]*\][ \t]*(?:#.*)?(?:\r?\n|$)/mu, '# []\n');
409
+ const next = commented.endsWith('\n') ? commented : `${commented}\n`;
410
+ writeFileSync(patchPath, `${next}${block}`);
411
+ return { ok: true, reason: null };
412
+ }
413
+ // A top-level flow LIST (other than the placeholder above) cannot be
414
+ // appended to: `- id: X` after `[1, 2]` would create two top-level
415
+ // elements in one document. Block items whose VALUE is flow style (e.g.
416
+ // `- config: [1, 2]`) are fine — their last line starts with `- `.
417
+ const lastContentLine = text.split(/\r?\n/u)
418
+ .map(line => line.trim())
419
+ .filter(line => line !== '' && !line.startsWith('#'))
420
+ .pop() ?? '';
421
+ if (/^[[{]/u.test(lastContentLine)) {
422
+ return {
423
+ ok: false,
424
+ reason: '补丁层以顶层流式结构结尾,不支持自动追加;请先整理为条目列表 / the patch layer ends in a top-level flow structure; refusing to append — tidy the file into an entry list first',
425
+ };
426
+ }
427
+ // The dsh entry-list dialect decides: anything that does not parse as a
428
+ // top-level array is left untouched (appending would deepen the break).
429
+ if (parsePatchFile(patchPath) === null) {
430
+ return {
431
+ ok: false,
432
+ reason: '补丁层不是合法的条目数组,已拒绝追加以免破坏;请先修正 YAML / the patch layer is not a valid entry list; refused to append — fix the YAML first',
433
+ };
434
+ }
435
+ const next = text.endsWith('\n') ? text : `${text}\n`;
436
+ writeFileSync(patchPath, `${next}${block}`);
437
+ return { ok: true, reason: null };
438
+ }
439
+ /** Disable one row: append `- id: X` + `disabled: true` (idempotent). */
440
+ export function disableRow(patchPath, rowId) {
441
+ return queuedWrite(async () => {
442
+ if (!ROW_ID_RE.test(rowId)) {
443
+ return { ok: false, reason: `行 id 含特殊字符,不支持写入补丁层 / row id ${rowId} cannot be written to the patch layer` };
444
+ }
445
+ const state = readUserPatchState(patchPath);
446
+ if (state.disables.includes(rowId))
447
+ return { ok: true, reason: null };
448
+ const result = appendPatchEntry(patchPath, rowBlock(rowId, true));
449
+ if (result.ok)
450
+ logEvent('info', 'patch', `disabled row ${rowId} in ${patchPath}`);
451
+ return result;
452
+ });
453
+ }
454
+ /** Enable one row: remove the `disabled: true` block; force-enable with
455
+ * `disabled: false` when a lower layer (bundle/home patch) holds it down. */
456
+ export function enableRow(patchPath, rowId) {
457
+ return queuedWrite(async () => {
458
+ if (!ROW_ID_RE.test(rowId)) {
459
+ return { ok: false, reason: `行 id 含特殊字符,不支持写入补丁层 / row id ${rowId} cannot be written to the patch layer` };
460
+ }
461
+ const state = readUserPatchState(patchPath);
462
+ const blockRe = new RegExp(`^- id: ['\"]?${escapeRegExp(rowId)}['\"]?\\r?\\n disabled: true\\r?\\n`, 'mu');
463
+ const text = (() => {
464
+ try {
465
+ return readFileSync(patchPath, 'utf8');
466
+ }
467
+ catch {
468
+ return '';
469
+ }
470
+ })();
471
+ if (blockRe.test(text)) {
472
+ writeFileSync(patchPath, withPlaceholderRestored(text.replace(blockRe, '')));
473
+ logEvent('info', 'patch', `enabled row ${rowId} in ${patchPath}`);
474
+ return { ok: true, reason: null };
475
+ }
476
+ if (state.forced.includes(rowId))
477
+ return { ok: true, reason: null };
478
+ const result = appendPatchEntry(patchPath, rowBlock(rowId, false));
479
+ if (result.ok)
480
+ logEvent('info', 'patch', `force-enabled row ${rowId} in ${patchPath}`);
481
+ return result;
482
+ });
483
+ }
484
+ /**
485
+ * Put the empty-list placeholder back when nothing else is left.
486
+ *
487
+ * Appending the first row comments the template's `[]` out (see
488
+ * appendPatchEntry), so removing the LAST row leaves a file of pure
489
+ * comments. That is not a top-level array, and dsh refuses to boot the
490
+ * profile at all — "must be a top-level YAML array of loader patch
491
+ * entries". Disable a plugin, enable it again, and the profile is bricked.
492
+ */
493
+ function withPlaceholderRestored(text) {
494
+ if (text.replace(/^[ \t]*#.*$/gmu, '').trim() !== '')
495
+ return text;
496
+ const uncommented = text.replace(/^[ \t]*#[ \t]*\[[ \t]*\][ \t]*(?:\r?\n|$)/mu, '[]\n');
497
+ if (uncommented !== text)
498
+ return uncommented;
499
+ // No commented placeholder to revive (hand-written file): add one.
500
+ return text === '' || text.endsWith('\n') ? `${text}[]\n` : `${text}\n[]\n`;
501
+ }
502
+ /** Remove every disable/force block the market (or the user) wrote for a
503
+ * row — the uninstall cleanup, so a removed plugin leaves no orphan rows. */
504
+ export function removeRowBlocks(patchPath, rowIds) {
505
+ let text = '';
506
+ try {
507
+ text = readFileSync(patchPath, 'utf8');
508
+ }
509
+ catch {
510
+ return;
511
+ }
512
+ let next = text;
513
+ for (const rowId of rowIds) {
514
+ const blockRe = new RegExp(`^- id: ['\"]?${escapeRegExp(rowId)}['\"]?\\r?\\n disabled: (?:true|false)\\r?\\n`, 'mu');
515
+ next = next.replace(blockRe, '');
516
+ }
517
+ if (next !== text) {
518
+ writeFileSync(patchPath, withPlaceholderRestored(next));
519
+ logEvent('info', 'patch', `removed patch rows for ${rowIds.join(', ')}`);
520
+ }
521
+ }