@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/install.js ADDED
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Install orchestration: collection-repo retargeting, post-install
3
+ * validation that keeps broken pieces from bricking the next boot, and
4
+ * update staleness detection. Every function takes the plugin runner as a
5
+ * parameter so tests can substitute a recording fake.
6
+ */
7
+ import { existsSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { classifyPnpmFailure, HOST_NAMESPACE_RE } from './pnpm-compat.js';
10
+ import { conflictingEntryIds, dropFromManifest, hasDshManifest, hasLoadableEntry, pluginSubdirs, profileDir, readInstalled, readManifestDeps, readProfileBundles } from './profile.js';
11
+ import { logEvent } from './log.js';
12
+ import { cleanOrphanedStore } from './store.js';
13
+ /** One-shot bypass for pnpm's fresh-release hold; scoped to a single command. */
14
+ export const RELEASE_AGE_OVERRIDE = '--config.minimumReleaseAge=0';
15
+ /**
16
+ * Longer per-request fetch timeout for one retried command. pnpm's default
17
+ * 60-second limit aborts large tarball downloads (github: sources fetch the
18
+ * WHOLE repo even for a `#path:` subdirectory plugin) on slow networks; a
19
+ * plain retry fails again at the same limit, so the recovery re-runs with
20
+ * this override once. Scoped to a single command like RELEASE_AGE_OVERRIDE.
21
+ */
22
+ export const FETCH_TIMEOUT_OVERRIDE = '--config.fetchTimeout=600000';
23
+ /**
24
+ * Stop pnpm downloading a plugin's peer dependencies (#289 by @00080000).
25
+ *
26
+ * The last resort for a peer that cannot be downloaded because it does not
27
+ * exist on any registry: the dsh runtime injects several `@deepseek-ai/*`
28
+ * packages and never publishes them, and since pnpm 8 `auto-install-peers`
29
+ * defaults on, so pnpm walks the peer list and 404s on one.
30
+ *
31
+ * Only on the retry, never by default. Turning it off wholesale would also
32
+ * stop pnpm installing the peers a plugin legitimately needs from npm, and
33
+ * that failure would surface much later — as a missing module at runtime
34
+ * rather than a clear error at install time. Narrow beats early here.
35
+ *
36
+ * Verified against pnpm 10.29.3: `peerDependencyRules.ignoreMissing` does
37
+ * NOT prevent the fetch (it only silences the warning), so this flag is the
38
+ * only lever that actually works.
39
+ */
40
+ export const AUTO_INSTALL_PEERS_OFF = '--config.auto-install-peers=false';
41
+ /**
42
+ * Whether an unresolvable package is a host peer pnpm went looking for on
43
+ * its own, rather than something the profile actually asks for.
44
+ *
45
+ * The same 404 means two different things and wants two different answers.
46
+ * A `@deepseek-ai/*` package that IS in the profile manifest is a ghost
47
+ * entry — left by an earlier failed operation, or hand-added — and the user
48
+ * has to remove that line; retrying would only fail again. One that is NOT
49
+ * in the manifest was never asked for by anybody: pnpm reached it by walking
50
+ * an installed plugin's peerDependencies, which in this ecosystem name what
51
+ * the runtime provides rather than what npm carries.
52
+ *
53
+ * Reading the manifest is what separates them, so this cannot live in the
54
+ * pure classifier.
55
+ */
56
+ export function isUnpublishedHostPeer(pkg, profile, explicitDir) {
57
+ if (pkg === undefined || !HOST_NAMESPACE_RE.test(pkg))
58
+ return false;
59
+ return !Object.hasOwn(readManifestDeps(profile, explicitDir), pkg);
60
+ }
61
+ /**
62
+ * Run one plugin command with automatic recovery from three known pnpm traps:
63
+ *
64
+ * - pnpm-major drift (#20 bug 2): a modules directory built by a different
65
+ * pnpm major fails mutation; pnpm's documented remedy is one `install` to
66
+ * recreate it — do that silently and retry the original command once.
67
+ * - release-age lockfile lock (#39): once a too-young release is in the
68
+ * lockfile, pnpm 11 rejects EVERY later add/remove during verification —
69
+ * retry once with the one-shot minimumReleaseAge bypass (safe: the young
70
+ * package is already installed; the bypass only lets pnpm touch the
71
+ * lockfile again).
72
+ * - per-request fetch timeout: large tarballs (github: sources fetch the
73
+ * whole repo even for a `#path:` subdirectory) on slow networks blow
74
+ * pnpm's default 60-second limit; a plain retry fails again at the same
75
+ * limit, so retry once with a longer fetchTimeout.
76
+ *
77
+ * Any recognized failure that survives gets its bilingual explanation
78
+ * appended to stderr so the UI shows an actionable message instead of a
79
+ * wall of text (#20 bug 3). Cancelled runs are never recovered.
80
+ */
81
+ export async function withHoistRecovery(run, profile, pluginArgs, profileDirectory) {
82
+ let result = await run(profile, pluginArgs);
83
+ const ok = (r) => r.exitCode === 0 && !r.timedOut && !r.cancelled;
84
+ if (!ok(result) && !result.cancelled) {
85
+ const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode);
86
+ if (failure?.code === 'hoist-pattern-diff') {
87
+ logEvent('warn', 'install', `modules dir was built by a different pnpm major — rebuilding (pnpm install) and retrying once`);
88
+ // --no-frozen-lockfile: the market runs pnpm with CI=true (TTY hangs),
89
+ // where a lockfile written by the old major would otherwise be refused.
90
+ const rebuild = await run(profile, ['install', '--no-frozen-lockfile']);
91
+ if (ok(rebuild))
92
+ result = await run(profile, pluginArgs);
93
+ }
94
+ else if (failure?.code === 'release-age-violation'
95
+ && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
96
+ && !pluginArgs.includes(RELEASE_AGE_OVERRIDE)) {
97
+ logEvent('warn', 'install', `a too-young release blocks pnpm's lockfile verification (#39) — retrying once with ${RELEASE_AGE_OVERRIDE}`);
98
+ result = await run(profile, [pluginArgs[0], RELEASE_AGE_OVERRIDE, ...pluginArgs.slice(1)]);
99
+ }
100
+ else if (failure?.code === 'fetch-404'
101
+ && isUnpublishedHostPeer(failure.pkg, profile, profileDirectory)
102
+ && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
103
+ && !pluginArgs.includes(AUTO_INSTALL_PEERS_OFF)) {
104
+ // The plugin is fine; pnpm went looking for a package the host injects
105
+ // and npm has never carried. Every fresh profile hits this, whatever
106
+ // the plugin, so failing here would be failing for something the user
107
+ // cannot fix and did not cause.
108
+ logEvent('warn', 'install', `${failure.pkg ?? 'a host package'} is a peer the runtime provides and npm does not carry (#289) — retrying once with ${AUTO_INSTALL_PEERS_OFF}`);
109
+ result = await run(profile, [pluginArgs[0], AUTO_INSTALL_PEERS_OFF, ...pluginArgs.slice(1)]);
110
+ }
111
+ else if (failure?.code === 'transient-network'
112
+ && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')) {
113
+ // #83: pnpm replays the whole tree, so any existing dependency's
114
+ // momentary network hiccup fails the run — and a plain retry succeeds.
115
+ // Do that retry ourselves instead of reporting a false failure.
116
+ logEvent('warn', 'install', `transient network failure while pnpm replayed the dependency tree (#83) — retrying once`);
117
+ result = await run(profile, pluginArgs);
118
+ }
119
+ else if (failure?.code === 'fetch-timeout'
120
+ && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
121
+ && !pluginArgs.includes(FETCH_TIMEOUT_OVERRIDE)) {
122
+ // Large tarball / slow network: pnpm's default 60s per-request limit
123
+ // aborted the download. A plain retry fails again at the same limit,
124
+ // so retry once with a longer fetchTimeout.
125
+ logEvent('warn', 'install', `pnpm's per-request fetch timeout aborted a large download — retrying once with ${FETCH_TIMEOUT_OVERRIDE}`);
126
+ result = await run(profile, [pluginArgs[0], FETCH_TIMEOUT_OVERRIDE, ...pluginArgs.slice(1)]);
127
+ }
128
+ }
129
+ if (!ok(result) && !result.cancelled) {
130
+ // A failed, timed-out, or killed run never finishes pnpm's staging, so
131
+ // its store tmp dirs (the WHOLE repo tarball for github: sources) are
132
+ // orphaned — reclaim them now that no pnpm is running. Safe by
133
+ // construction: directories are only removed when their owning pid is
134
+ // gone (the name carries it), so a live download is never touched.
135
+ await cleanOrphanedStore(run, profile);
136
+ const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode);
137
+ if (failure !== null) {
138
+ result = {
139
+ ...result,
140
+ stderr: failure.replaceOutput === true ? failure.message : `${result.stderr}\n\n${failure.message}`,
141
+ ...(failure.replaceOutput === true ? { stdout: '' } : {}),
142
+ };
143
+ }
144
+ else if (result.pnpmError !== undefined && result.pnpmError !== '') {
145
+ // Nothing matched, but pnpm DID say what went wrong — in its ndjson
146
+ // stream, which never reaches stderr. Without this the user is shown
147
+ // the tail of dsh's wrapper output ("pnpm failed in profile
148
+ // directory …"), which is byte-identical for every possible cause and
149
+ // is why #244, #192 and #138 all read as "the UI shows a stack tail".
150
+ //
151
+ // An unrecognized error is exactly the case where the raw text is
152
+ // worth the most: a classified one has a written explanation, this one
153
+ // has only pnpm's own words, and hiding them leaves nothing at all.
154
+ const code = result.pnpmErrorCode === undefined ? '' : `${result.pnpmErrorCode}: `;
155
+ result = { ...result, stderr: `${result.stderr}\n\n${code}${result.pnpmError}` };
156
+ }
157
+ }
158
+ return result;
159
+ }
160
+ /**
161
+ * Whether pnpm never started at all, so the profile cannot have been touched.
162
+ *
163
+ * Worth its own question because the update route answers a failed run by
164
+ * reinstalling the previous build and reporting loudly when it cannot verify
165
+ * that (#502 by @Ztyss): three updates in a row told the user their profile
166
+ * might be broken and to inspect it before restarting, when in fact nothing
167
+ * had been written — the command line could not launch pnpm, so package.json
168
+ * and node_modules were exactly as they had been.
169
+ * @param result - the failed run.
170
+ * @returns true when the failure happened before pnpm could run.
171
+ */
172
+ export function pnpmNeverStarted(result) {
173
+ return classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)?.code === 'pnpm-unusable';
174
+ }
175
+ /**
176
+ * The most specific description of a failed run available, for logs.
177
+ *
178
+ * pnpm's structured error beats the stderr tail whenever there is one — see
179
+ * withHoistRecovery above for why the tail is nearly worthless here.
180
+ */
181
+ export function failureDetail(result, limit = 300) {
182
+ if (result.pnpmError !== undefined && result.pnpmError !== '') {
183
+ const code = result.pnpmErrorCode === undefined ? '' : `${result.pnpmErrorCode}: `;
184
+ return `${code}${result.pnpmError}`.slice(0, limit);
185
+ }
186
+ return (result.stderr || result.stdout).slice(-limit);
187
+ }
188
+ /**
189
+ * Some registry entries point at collection repos whose actual plugin lives
190
+ * in a subdirectory — the root has no package.json (or a workspace root with
191
+ * no dsh surface), and pnpm installs the bare fileset with exit 0. Detect
192
+ * that junk install, drop it, and re-add each plugin subdirectory through
193
+ * pnpm's `#path:` selector (#18).
194
+ * @returns overall success (true when nothing needed retargeting).
195
+ */
196
+ export async function retargetCollections(run, profile, before, target, explicitDir) {
197
+ if (!target.startsWith('github:'))
198
+ return true;
199
+ const dir = profileDir(profile, explicitDir);
200
+ const junk = Object.keys(readInstalled(profile, dir)).filter((name) => {
201
+ if (before.has(name))
202
+ return false;
203
+ const root = join(dir, 'node_modules', name);
204
+ if (!existsSync(join(root, 'package.json')))
205
+ return true;
206
+ return !hasDshManifest(root);
207
+ });
208
+ let allOk = true;
209
+ for (const name of junk) {
210
+ const root = join(dir, 'node_modules', name);
211
+ const candidates = pluginSubdirs(root);
212
+ logEvent('info', 'install', `${name}: collection repo (root declares no dsh manifest); plugins inside: ${candidates.join(', ') || 'none'}`);
213
+ await run(profile, ['remove', name]);
214
+ if (candidates.length === 0) {
215
+ allOk = false;
216
+ continue;
217
+ }
218
+ for (const sub of candidates) {
219
+ // A China-region root may already carry the commit resolved through
220
+ // the mirror (`#<sha>`). pnpm's fragment grammar joins the subpath as
221
+ // another selector with `&`; a second `#` produces an invalid target.
222
+ const subTarget = `${target}${target.includes('#') ? '&' : '#'}path:/${sub}`;
223
+ const result = await run(profile, ['add', subTarget]);
224
+ if (result.exitCode !== 0 || result.timedOut) {
225
+ allOk = false;
226
+ logEvent('error', 'install', `${subTarget}: exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''} — ${(result.stderr || result.stdout).slice(-220)}`);
227
+ }
228
+ }
229
+ }
230
+ return allOk;
231
+ }
232
+ /**
233
+ * Fake-success guard (#18): validate every package the install added. A
234
+ * piece without a dsh manifest or without its declared entry artifact
235
+ * (source-only checkout, build blocked by pnpm allowBuilds) would brick the
236
+ * next boot, so it is removed on the spot.
237
+ *
238
+ * Since #122 this also covers duplicate loader entry ids: cordis refuses to
239
+ * load a tree containing two entries with one id, so a TUI bundle landing in
240
+ * a web profile (both declare `id: storage`) leaves DSH unable to START —
241
+ * an error naming neither plugin, from which the market's own page is
242
+ * unreachable. Such a package is removed like any other bricking piece.
243
+ * @returns names added by this run, names kept, names removed as broken,
244
+ * and the id conflicts found. `added` is reported separately from `keep`
245
+ * because an EMPTY `added` is a different failure from "everything added was
246
+ * unloadable": it means the install reported success without touching the
247
+ * profile at all, which is a broken plugin-command channel rather than
248
+ * anything wrong with the plugin (#258).
249
+ */
250
+ export async function validateAddedPlugins(run, profile, before, explicitDir) {
251
+ const dir = profileDir(profile, explicitDir);
252
+ const addedNow = Object.keys(readInstalled(profile, dir)).filter(n => !before.has(n));
253
+ const keep = [];
254
+ const removedBroken = [];
255
+ const conflicts = [];
256
+ // Compare against what the profile already loads, minus this install's own
257
+ // additions — two pieces of one plugin are not "already installed".
258
+ const existingBundles = readProfileBundles(dir).filter(name => !addedNow.includes(name));
259
+ for (const n of addedNow) {
260
+ const packageDir = join(dir, 'node_modules', n);
261
+ // hasLoadableEntry, not entryArtifactExists: carrier bundles legitimately
262
+ // ship no entry of their own (#103) and must not be uninstalled here.
263
+ if (!hasDshManifest(packageDir) || !hasLoadableEntry(dir, n)) {
264
+ removedBroken.push(n);
265
+ await removeAndReconcile(run, profile, dir, n);
266
+ continue;
267
+ }
268
+ const clash = conflictingEntryIds(dir, n, existingBundles);
269
+ if (clash.length > 0) {
270
+ // Keeping it would make the NEXT BOOT fail outright (#122).
271
+ conflicts.push(...clash.map(hit => ({ name: n, ...hit })));
272
+ removedBroken.push(n);
273
+ logEvent('error', 'install', `${n}: loader entry id conflict with ${clash[0].owner} (${clash.map(hit => hit.id).join(', ')}) — removing, it would break the next boot`);
274
+ await removeAndReconcile(run, profile, dir, n);
275
+ continue;
276
+ }
277
+ keep.push(n);
278
+ }
279
+ return { added: addedNow, keep, removedBroken, conflicts };
280
+ }
281
+ /**
282
+ * Run one removal this validation triggered and reconcile the manifest by
283
+ * disk truth afterwards.
284
+ *
285
+ * The plugin command reconciles `dsh.profile.bundles` only when pnpm exits
286
+ * 0, and a remove can fail AFTER completing every persistent step — the #65
287
+ * write-order family — or exit 0 with the reconcile still not reflected in
288
+ * the manifest. Either way the bundle row left behind names a package the
289
+ * next boot cannot resolve, and the loader dies on the first such row: the
290
+ * whole profile, not just this plugin, refuses to start, with the market's
291
+ * own page unreachable. Disk truth decides the repair, deliberately: a
292
+ * package that is gone gets its manifest rows dropped so the boot stays
293
+ * loadable, while a package still on disk keeps them, because a retry needs
294
+ * something to retry against.
295
+ * @param run - the plugin runner, as the caller received it.
296
+ * @param profile - the profile name for manifest writes.
297
+ * @param dir - the profile directory the validation reads.
298
+ * @param name - the package being removed.
299
+ */
300
+ async function removeAndReconcile(run, profile, dir, name) {
301
+ const result = await run(profile, ['remove', name]);
302
+ const gone = !existsSync(join(dir, 'node_modules', name, 'package.json'));
303
+ if (gone) {
304
+ if (dropFromManifest(profile, name, dir)) {
305
+ logEvent('error', 'install', `${name}: remove ${result.exitCode === 0 ? 'skipped the manifest reconcile' : `failed (exit ${String(result.exitCode)})`} but the package is gone from disk — dropped its dependency/bundle rows so the next boot stays loadable`);
306
+ }
307
+ return;
308
+ }
309
+ if (result.exitCode !== 0 || result.timedOut || result.cancelled) {
310
+ logEvent('error', 'install', `${name}: remove failed (exit ${String(result.exitCode)})${result.timedOut ? ' timed out' : ''}${result.cancelled ? ' cancelled' : ''} and the package is still installed — its rows stay in the manifest; retry the uninstall`);
311
+ }
312
+ }
313
+ /**
314
+ * Group flat `{id, owner}` conflict hits by the installed plugin that owns
315
+ * them. What the user has to decide is which PLUGINS to uninstall, not which
316
+ * ids to resolve, so one row per owner is the unit the market renders and
317
+ * acts on. Flattening the other way (one row per id) also misattributes when
318
+ * a candidate clashes with several installed plugins at once.
319
+ * @param conflicts flat hits as returned by {@link validateAddedPlugins}.
320
+ * @returns one entry per owner, owners and ids both in first-seen order.
321
+ */
322
+ export function groupConflictsByOwner(conflicts) {
323
+ const byOwner = new Map();
324
+ for (const hit of conflicts) {
325
+ const ids = byOwner.get(hit.owner);
326
+ if (ids === undefined)
327
+ byOwner.set(hit.owner, [hit.id]);
328
+ else if (!ids.includes(hit.id))
329
+ ids.push(hit.id);
330
+ }
331
+ return [...byOwner].map(([owner, ids]) => ({ owner, ids }));
332
+ }
333
+ /**
334
+ * Whether a clean-exit update actually changed nothing — pnpm's
335
+ * minimumReleaseAge silently keeps the old version and exits 0 when the new
336
+ * release is "too young" (#13, #22), so a clean exit alone does not mean the
337
+ * update happened.
338
+ */
339
+ export function isStaleUpdate(check) {
340
+ return check.isGit
341
+ ? check.beforeCommit !== null && check.afterCommit === check.beforeCommit
342
+ : check.beforeVersion !== null && check.afterVersion === check.beforeVersion;
343
+ }
344
+ /**
345
+ * The package pnpm's fetcher refused to prepare because its build script is
346
+ * not allowlisted — `The git-hosted package "name@2.8.0" needs to execute
347
+ * build scripts but is not in the "allowBuilds" allowlist.` Null when the
348
+ * output is not this failure. Unlike ignored-builds, the package is NOT in
349
+ * node_modules yet (the fetcher rejects before materialization, #68).
350
+ */
351
+ export function parsePrepareNotAllowed(stdout, stderr) {
352
+ // The market always runs pnpm with --reporter=ndjson, so this sentence
353
+ // usually arrives inside a JSON string with its quotes escaped (#113):
354
+ // … git-hosted package \"pkg@1.0.0\" needs to execute build scripts …
355
+ // Unescape before matching, or the ndjson path — the ONLY path in
356
+ // production — silently returns null and the approve banner never shows.
357
+ const text = `${stdout}\n${stderr}`.replace(/\\"/g, '"');
358
+ const m = /git-hosted package "([^"]+)" needs to execute build scripts/.exec(text);
359
+ if (m === null)
360
+ return null;
361
+ // Strip the trailing @version — the name itself may be scoped (@scope/pkg).
362
+ const raw = m[1].trim();
363
+ const at = raw.lastIndexOf('@');
364
+ return at > 0 ? raw.slice(0, at) : raw;
365
+ }
366
+ /**
367
+ * Package names pnpm reported as having their build scripts ignored
368
+ * ("Ignored build scripts: esbuild, koffi."). Empty when none.
369
+ * (#6 by @qichuang321.)
370
+ */
371
+ export function parseIgnoredBuilds(stdout, stderr) {
372
+ const m = /Ignored build scripts:?\s*([^\n]+)/i.exec(`${stdout}\n${stderr}`);
373
+ if (m === null)
374
+ return [];
375
+ const found = [];
376
+ for (const chunk of m[1].split(',')) {
377
+ // Entries may carry a version suffix and the sentence's final period.
378
+ const trimmed = chunk.trim().replace(/\.$/, '');
379
+ if (trimmed === '')
380
+ continue;
381
+ const at = trimmed.lastIndexOf('@');
382
+ const name = at > 0 ? trimmed.slice(0, at) : trimmed;
383
+ if (name !== '' && !found.includes(name))
384
+ found.push(name);
385
+ }
386
+ return found;
387
+ }
package/lib/log.js ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Event log for issue reports: what the market did and how it failed,
3
+ * exportable as plain text from `/dsh-market/logs`.
4
+ *
5
+ * Privacy: entries are sanitized on write — the home directory collapses to
6
+ * `~`, and common credential shapes (API keys, GitHub/npm tokens, bearer
7
+ * headers) are masked. The in-memory buffer dies with the process and holds
8
+ * at most {@link MAX_ENTRIES} entries; a process that also configures a
9
+ * persistent sink appends every event there, capped at
10
+ * {@link PERSISTENT_MAX_BYTES}, because the failures worth reporting most —
11
+ * the ones that only appear after a restart — used to take their story with
12
+ * them when the process died (#341).
13
+ */
14
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
15
+ import { dirname } from 'node:path';
16
+ import { homedir } from 'node:os';
17
+ const MAX_ENTRIES = 200;
18
+ const DETAIL_MAX = 600;
19
+ const PERSISTENT_MAX_BYTES = 256 * 1024;
20
+ const entries = [];
21
+ let persistentFile = null;
22
+ /** Bytes in the sink file, tracked so the cap holds without a stat per event. */
23
+ let persistentBytes = 0;
24
+ function sanitize(text) {
25
+ return text
26
+ .replaceAll(homedir(), '~')
27
+ // Log-injection guard: control characters (newlines above all) would
28
+ // forge extra lines in the exported log file. The #98 routes pass
29
+ // user-supplied names into logEvent (bundle-order order entries, trial
30
+ // messages), so strip them at the single choke point (issue #98
31
+ // analysis: log filtering).
32
+ .replace(/[\u0000-\u001f\u007f]/g, '')
33
+ .replace(/sk-[A-Za-z0-9_-]{8,}/g, 'sk-***')
34
+ .replace(/gh[pousr]_[A-Za-z0-9]{16,}/g, 'gh*_***')
35
+ .replace(/npm_[A-Za-z0-9]{16,}/g, 'npm_***')
36
+ .replace(/bearer\s+\S+/gi, 'Bearer ***')
37
+ .replace(/(authorization|token|apikey|api-key|password)(["':=\s]+)\S+/gi, '$1$2***');
38
+ }
39
+ /**
40
+ * Append one event, sanitized and truncated.
41
+ * @param level - severity for the export listing.
42
+ * @param event - short machine-ish event name (e.g. `install`, `hot-mount`).
43
+ * @param detail - free-form context; credentials and home paths are masked.
44
+ */
45
+ export function logEvent(level, event, detail) {
46
+ const entry = {
47
+ at: new Date().toISOString(),
48
+ level,
49
+ event,
50
+ detail: sanitize(detail).slice(0, DETAIL_MAX),
51
+ };
52
+ entries.push(entry);
53
+ if (entries.length > MAX_ENTRIES)
54
+ entries.splice(0, entries.length - MAX_ENTRIES);
55
+ if (persistentFile === null)
56
+ return;
57
+ try {
58
+ const line = `${JSON.stringify(entry)}\n`;
59
+ appendFileSync(persistentFile, line);
60
+ persistentBytes += line.length;
61
+ // Trimming only on mount left the cap unenforced for the life of a
62
+ // process: 20k events grew the file to 3.2 MB in a measurement, and a
63
+ // retry loop is exactly the situation that both logs hardest and never
64
+ // restarts. Re-trim in place once the ceiling is crossed.
65
+ if (persistentBytes > PERSISTENT_MAX_BYTES)
66
+ trimPersistentLog(persistentFile);
67
+ }
68
+ catch {
69
+ // Only append failures reach this: a read-only or full disk. The
70
+ // in-memory log still serves this session's export; persistence
71
+ // disables itself so one bad write cannot break every future event.
72
+ persistentFile = null;
73
+ }
74
+ }
75
+ /**
76
+ * Rewrite the sink keeping only its newest half, and resync the byte count.
77
+ * @param file - the sink file to trim in place.
78
+ */
79
+ function trimPersistentLog(file) {
80
+ const lines = readFileSync(file, 'utf8').split('\n').filter(line => line !== '');
81
+ const kept = [];
82
+ let bytes = 0;
83
+ for (let index = lines.length - 1; index >= 0 && bytes <= PERSISTENT_MAX_BYTES / 2; index -= 1) {
84
+ kept.unshift(`${lines[index]}\n`);
85
+ bytes += lines[index].length + 1;
86
+ }
87
+ writeFileSync(file, kept.join(''));
88
+ persistentBytes = bytes;
89
+ }
90
+ /**
91
+ * Append events to a profile-owned file, or stop doing so.
92
+ *
93
+ * Called once per mount with `<profile>/.dsh-market/log.ndjson` and with
94
+ * `null` on dispose. An oversized file is trimmed to its newest half on
95
+ * configure, so one long-lived profile cannot grow it without bound.
96
+ * @param file - the sink file, or null to disable persistence.
97
+ */
98
+ export function configurePersistentLog(file) {
99
+ persistentFile = file;
100
+ if (file === null)
101
+ return;
102
+ try {
103
+ mkdirSync(dirname(file), { recursive: true });
104
+ persistentBytes = existsSync(file) ? statSync(file).size : 0;
105
+ if (persistentBytes <= PERSISTENT_MAX_BYTES)
106
+ return;
107
+ trimPersistentLog(file);
108
+ }
109
+ catch {
110
+ // Only configure-time filesystem failures reach this (the directory
111
+ // cannot be created, the file cannot be read or rewritten). The market
112
+ // must mount regardless; the session simply runs memory-only.
113
+ persistentFile = null;
114
+ }
115
+ }
116
+ /**
117
+ * The newest persisted lines, for the export's prior-session section.
118
+ * @param file - the sink file to read.
119
+ * @param maxLines - how many trailing lines to return.
120
+ * @returns parsed-or-raw lines, newest last; empty when nothing is readable.
121
+ */
122
+ export function readPersistentLog(file, maxLines = 80) {
123
+ try {
124
+ return readFileSync(file, 'utf8').split('\n').filter(line => line !== '').slice(-maxLines);
125
+ }
126
+ catch {
127
+ // Only a missing or unreadable file reaches this: there are no prior
128
+ // sessions to show, which is the empty answer.
129
+ return [];
130
+ }
131
+ }
132
+ /**
133
+ * How to read the `at` fields, said once at the top.
134
+ *
135
+ * Every line is stamped in UTC and stays that way: this file is written to
136
+ * be attached to an issue, and a maintainer comparing two reporters' logs
137
+ * needs one clock, not each machine's. Localizing the lines would make the
138
+ * artifact worse at the only job it has.
139
+ *
140
+ * But the reader is looking at their own wall clock, and #449 is what that
141
+ * costs when nobody says so — a UTC+8 user saw `07:32` for something they
142
+ * watched happen at `15:32` and could not tell whether the log was skewed
143
+ * or their own machine was. So the offset is stated, with the arithmetic
144
+ * already done: "add 8h" is a fact they can use without converting
145
+ * anything, and it is the one line that turns the timestamps from
146
+ * suspicious into usable.
147
+ * @returns the note, or null when this machine is already on UTC.
148
+ */
149
+ function timezoneNote(now = new Date()) {
150
+ // getTimezoneOffset is minutes to ADD to local to reach UTC, so it is
151
+ // positive west of Greenwich — the opposite sign from how offsets are
152
+ // written. Negate it once here rather than at each use.
153
+ const minutes = -now.getTimezoneOffset();
154
+ if (minutes === 0)
155
+ return null;
156
+ const sign = minutes < 0 ? '-' : '+';
157
+ const abs = Math.abs(minutes);
158
+ const hh = String(Math.floor(abs / 60)).padStart(2, '0');
159
+ const mm = String(abs % 60).padStart(2, '0');
160
+ let zone = '';
161
+ try {
162
+ const named = Intl.DateTimeFormat().resolvedOptions().timeZone;
163
+ zone = typeof named === 'string' && named !== '' ? ` (${named})` : '';
164
+ }
165
+ catch { /* a runtime without full ICU still gets the offset */ }
166
+ const shift = mm === '00' ? `${String(Math.floor(abs / 60))}h` : `${String(Math.floor(abs / 60))}h${mm}m`;
167
+ const direction = minutes < 0 ? 'subtract' : 'add';
168
+ return `timestamps: UTC — this machine is UTC${sign}${hh}:${mm}${zone}, so ${direction} ${shift} for local time`;
169
+ }
170
+ /**
171
+ * The export document for bug reports.
172
+ * @param header - environment lines to prepend (version, platform — no paths).
173
+ * @returns plain text, newest entry last.
174
+ */
175
+ export function exportLogs(header, snapshot = [], priorSessions = []) {
176
+ const note = timezoneNote();
177
+ const head = [
178
+ ...Object.entries(header).map(([key, value]) => `${key}: ${sanitize(value)}`),
179
+ ...(note === null ? [] : [note]),
180
+ ];
181
+ const lines = entries.map(e => `${e.at} [${e.level}] ${e.event}: ${e.detail}`);
182
+ return [
183
+ '# ChatCode CLI Market log export',
184
+ ...head,
185
+ '',
186
+ // The state of the profile RIGHT NOW, which does not depend on anything
187
+ // having been recorded this session (#341). The buffer dies with the
188
+ // process, so the failures worth reporting most — the ones that only
189
+ // appear after a restart — were exactly the ones whose export said
190
+ // "(no events this session)". This part still answers.
191
+ ...(snapshot.length > 0 ? ['## profile state', ...snapshot.map(line => sanitize(line)), ''] : []),
192
+ ...(priorSessions.length > 0 ? ['## previous sessions (persisted log)', ...priorSessions.map(line => sanitize(line)), ''] : []),
193
+ '## events this session',
194
+ ...(lines.length > 0 ? lines : ['(none — the buffer starts empty on every start)']),
195
+ '',
196
+ ].join('\n');
197
+ }