@chatcode/cco-market 1.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/README.zh.md +143 -0
  4. package/UPDATE-API-V1.md +132 -0
  5. package/client/client.js +11534 -0
  6. package/cordis.patch.yml +5 -0
  7. package/lib/accelerate.js +184 -0
  8. package/lib/agents.js +36 -0
  9. package/lib/backup.js +572 -0
  10. package/lib/catalog-local-match.js +126 -0
  11. package/lib/catalog-npm.js +111 -0
  12. package/lib/changelog.js +242 -0
  13. package/lib/channels.js +64 -0
  14. package/lib/check.js +1067 -0
  15. package/lib/compatibility.js +198 -0
  16. package/lib/diagnostics.js +49 -0
  17. package/lib/discovery-compatibility.js +260 -0
  18. package/lib/dsh-cli.js +949 -0
  19. package/lib/dsh-install.js +108 -0
  20. package/lib/gist.js +352 -0
  21. package/lib/groups.js +97 -0
  22. package/lib/home-paths.js +42 -0
  23. package/lib/hot.js +519 -0
  24. package/lib/http.js +39 -0
  25. package/lib/index.js +97 -0
  26. package/lib/install.js +387 -0
  27. package/lib/log.js +197 -0
  28. package/lib/ndjson.js +154 -0
  29. package/lib/net.js +98 -0
  30. package/lib/order.js +284 -0
  31. package/lib/patch.js +521 -0
  32. package/lib/pnpm-compat.js +490 -0
  33. package/lib/presets.js +278 -0
  34. package/lib/profile.js +911 -0
  35. package/lib/region-probe.js +93 -0
  36. package/lib/regions.js +253 -0
  37. package/lib/registry.js +204 -0
  38. package/lib/restart.js +373 -0
  39. package/lib/routes.js +4372 -0
  40. package/lib/settings.js +109 -0
  41. package/lib/skill-market/api.js +119 -0
  42. package/lib/skill-market/config.js +29 -0
  43. package/lib/skill-market/installer.js +352 -0
  44. package/lib/skill-market/runtime.js +13 -0
  45. package/lib/skill-market/tui.js +119 -0
  46. package/lib/skill-market/types.js +1 -0
  47. package/lib/skill-market/web-routes.js +123 -0
  48. package/lib/snapshot.js +500 -0
  49. package/lib/source-migration.js +54 -0
  50. package/lib/sources.js +596 -0
  51. package/lib/store.js +91 -0
  52. package/lib/themes.js +102 -0
  53. package/lib/trial.js +116 -0
  54. package/lib/types/accelerate.d.ts +85 -0
  55. package/lib/types/agents.d.ts +22 -0
  56. package/lib/types/backup.d.ts +131 -0
  57. package/lib/types/catalog-local-match.d.ts +50 -0
  58. package/lib/types/catalog-npm.d.ts +47 -0
  59. package/lib/types/changelog.d.ts +102 -0
  60. package/lib/types/channels.d.ts +56 -0
  61. package/lib/types/check.d.ts +266 -0
  62. package/lib/types/compatibility.d.ts +64 -0
  63. package/lib/types/diagnostics.d.ts +29 -0
  64. package/lib/types/discovery-compatibility.d.ts +80 -0
  65. package/lib/types/dsh-cli.d.ts +324 -0
  66. package/lib/types/dsh-install.d.ts +46 -0
  67. package/lib/types/gist.d.ts +53 -0
  68. package/lib/types/groups.d.ts +34 -0
  69. package/lib/types/home-paths.d.ts +16 -0
  70. package/lib/types/hot.d.ts +221 -0
  71. package/lib/types/http.d.ts +12 -0
  72. package/lib/types/index.d.ts +14 -0
  73. package/lib/types/install.d.ts +172 -0
  74. package/lib/types/log.d.ts +43 -0
  75. package/lib/types/ndjson.d.ts +52 -0
  76. package/lib/types/net.d.ts +58 -0
  77. package/lib/types/order.d.ts +100 -0
  78. package/lib/types/patch.d.ts +129 -0
  79. package/lib/types/pnpm-compat.d.ts +93 -0
  80. package/lib/types/presets.d.ts +90 -0
  81. package/lib/types/profile.d.ts +253 -0
  82. package/lib/types/region-probe.d.ts +50 -0
  83. package/lib/types/regions.d.ts +122 -0
  84. package/lib/types/registry.d.ts +86 -0
  85. package/lib/types/restart.d.ts +194 -0
  86. package/lib/types/routes.d.ts +67 -0
  87. package/lib/types/settings.d.ts +78 -0
  88. package/lib/types/skill-market/api.d.ts +10 -0
  89. package/lib/types/skill-market/config.d.ts +3 -0
  90. package/lib/types/skill-market/installer.d.ts +23 -0
  91. package/lib/types/skill-market/runtime.d.ts +10 -0
  92. package/lib/types/skill-market/tui.d.ts +4 -0
  93. package/lib/types/skill-market/types.d.ts +71 -0
  94. package/lib/types/skill-market/web-routes.d.ts +19 -0
  95. package/lib/types/snapshot.d.ts +93 -0
  96. package/lib/types/source-migration.d.ts +11 -0
  97. package/lib/types/sources.d.ts +216 -0
  98. package/lib/types/store.d.ts +30 -0
  99. package/lib/types/themes.d.ts +40 -0
  100. package/lib/types/trial.d.ts +61 -0
  101. package/lib/types/update-api-v1.d.ts +66 -0
  102. package/lib/types/updates.d.ts +123 -0
  103. package/lib/types/verify.d.ts +139 -0
  104. package/lib/update-api-v1.js +215 -0
  105. package/lib/updates.js +361 -0
  106. package/lib/verify.js +453 -0
  107. package/package.json +121 -0
  108. package/src/accelerate.ts +213 -0
  109. package/src/agents.ts +43 -0
  110. package/src/backup.ts +583 -0
  111. package/src/catalog-local-match.ts +144 -0
  112. package/src/catalog-npm.ts +120 -0
  113. package/src/changelog.ts +282 -0
  114. package/src/channels.ts +70 -0
  115. package/src/check.ts +1239 -0
  116. package/src/client/CommentsModal.tsx +119 -0
  117. package/src/client/Diagnostics.tsx +907 -0
  118. package/src/client/ErrorBoundary.tsx +111 -0
  119. package/src/client/InstallToast.tsx +31 -0
  120. package/src/client/Market.module.css +830 -0
  121. package/src/client/MarketSection.tsx +5301 -0
  122. package/src/client/OperationsPanel.tsx +365 -0
  123. package/src/client/SettingsCard.tsx +621 -0
  124. package/src/client/SkillMarket.module.css +21 -0
  125. package/src/client/SkillMarketSection.tsx +162 -0
  126. package/src/client/comments.ts +54 -0
  127. package/src/client/globals.d.ts +13 -0
  128. package/src/client/index.ts +180 -0
  129. package/src/client/locales.ts +1112 -0
  130. package/src/client/market-data.ts +1321 -0
  131. package/src/client/operations.ts +201 -0
  132. package/src/client/preset-panel.tsx +263 -0
  133. package/src/client/primitives.d.ts +140 -0
  134. package/src/client/self-check.ts +147 -0
  135. package/src/client/snapshot-panel.tsx +244 -0
  136. package/src/compatibility.ts +237 -0
  137. package/src/diagnostics.ts +84 -0
  138. package/src/discovery-compatibility.ts +315 -0
  139. package/src/dsh-cli.ts +1126 -0
  140. package/src/dsh-install.ts +118 -0
  141. package/src/gist.ts +362 -0
  142. package/src/groups.ts +111 -0
  143. package/src/home-paths.ts +53 -0
  144. package/src/hot.ts +628 -0
  145. package/src/http.ts +41 -0
  146. package/src/index.ts +128 -0
  147. package/src/install.ts +420 -0
  148. package/src/log.ts +206 -0
  149. package/src/ndjson.ts +185 -0
  150. package/src/net.ts +106 -0
  151. package/src/order.ts +303 -0
  152. package/src/patch.ts +522 -0
  153. package/src/pnpm-compat.ts +527 -0
  154. package/src/presets.ts +344 -0
  155. package/src/profile.ts +940 -0
  156. package/src/region-probe.ts +97 -0
  157. package/src/regions.ts +310 -0
  158. package/src/registry.ts +250 -0
  159. package/src/restart.ts +396 -0
  160. package/src/routes.ts +4506 -0
  161. package/src/settings.ts +141 -0
  162. package/src/skill-market/api.ts +130 -0
  163. package/src/skill-market/config.ts +32 -0
  164. package/src/skill-market/installer.ts +337 -0
  165. package/src/skill-market/runtime.ts +14 -0
  166. package/src/skill-market/tui.ts +157 -0
  167. package/src/skill-market/types.ts +80 -0
  168. package/src/skill-market/web-routes.ts +132 -0
  169. package/src/snapshot.ts +532 -0
  170. package/src/source-migration.ts +61 -0
  171. package/src/sources.ts +565 -0
  172. package/src/store.ts +89 -0
  173. package/src/themes.ts +125 -0
  174. package/src/trial.ts +156 -0
  175. package/src/update-api-v1.ts +277 -0
  176. package/src/updates.ts +400 -0
  177. package/src/verify.ts +492 -0
package/src/verify.ts ADDED
@@ -0,0 +1,492 @@
1
+ /**
2
+ * Post-install activation verification (P0-2): what "installed" actually
3
+ * means for a package in a dsh profile.
4
+ *
5
+ * Two sources of truth, in strict order of authority:
6
+ *
7
+ * 1. The LOADER INVENTORY (observed): whatever the loader is running right
8
+ * now is live, full stop. A plain library with no `dsh` field can be
9
+ * loaded by name from someone else's bundle patch — the official
10
+ * dsh-base patch loads `@deepseek-ai/dsh-tools`, which has no `dsh`
11
+ * field at all — so no manifest check may overrule it (#135).
12
+ * 2. The profile manifest (inferred): `<profile>/package.json` →
13
+ * `dsh.profile.bundles`, what the dsh CLI reconciled. This predicts what
14
+ * the NEXT boot will load, and is the only evidence available for a
15
+ * package that is not currently running.
16
+ *
17
+ * State taxonomy (IMPROVEMENT-PLAN P0-2):
18
+ * live – running in the current composition (hot mount or loader entry)
19
+ * restart – installed and will activate on the next boot, but not live now
20
+ * inert – installed but not a profile-layer plugin (plain dependency, or
21
+ * client-only — the market shim-mounts those at boot)
22
+ * broken – would fail to load: listed as a bundle without a dsh surface,
23
+ * or a declared entry artifact that is missing
24
+ * missing – not present in node_modules
25
+ */
26
+
27
+ import { readFileSync } from 'node:fs'
28
+ import { Script } from 'node:vm'
29
+ import { join } from 'node:path'
30
+ import { listHotMounts, parseSimplePatch } from './hot.ts'
31
+ import { userPatchPackageReferences } from './patch.ts'
32
+ import { bundlePatchInsertedIds, hasDshManifest, hasLoadableEntry, profileDir, readInstalled } from './profile.ts'
33
+
34
+ export type ActivationState = 'live' | 'restart' | 'inert' | 'broken' | 'missing' | 'disabled'
35
+
36
+ export interface ActivationResult {
37
+ state: ActivationState
38
+ /** Bilingual, user-facing explanations (zh / en joined with " / "). */
39
+ reasons: string[]
40
+ /** True when the package is in the profile's `dsh.profile.bundles`. */
41
+ bundle: boolean
42
+ /** True when the package is live in the running composition. */
43
+ hot: boolean
44
+ }
45
+
46
+ /** The profile manifest's `dsh.profile.bundles` — what the CLI reconciled. */
47
+ function readBundles(profile: string, explicitDir?: string): Set<string> {
48
+ try {
49
+ const manifest = JSON.parse(
50
+ readFileSync(join(profileDir(profile, explicitDir), 'package.json'), 'utf8'),
51
+ ) as { dsh?: { profile?: { bundles?: unknown } } }
52
+ const bundles = manifest.dsh?.profile?.bundles
53
+ return new Set(Array.isArray(bundles) ? bundles.filter((n): n is string => typeof n === 'string') : [])
54
+ } catch {
55
+ return new Set()
56
+ }
57
+ }
58
+
59
+ interface PkgDsh {
60
+ bundle?: unknown
61
+ client?: unknown
62
+ }
63
+
64
+ /**
65
+ * True when `live` contains the package itself or a subpath entry of it.
66
+ *
67
+ * The live set (see `liveNames` in routes.ts) holds loader entry names — the
68
+ * `name:` field of each bundle patch row. Bundles usually name the bare
69
+ * package (`dshmarket`, `@scope/pkg`), but may point at a subpath entry
70
+ * (`@vectorize-io/hindsight-coding-agents/dsh`, `aegis/extensions/dsh/index.js`).
71
+ * Either form means the package's fiber is up and it must read as live;
72
+ * a different package sharing a name prefix (`@scope/pkg2` vs `@scope/pkg`)
73
+ * must not — the `/` bound keeps the match a real subpath.
74
+ */
75
+ function liveIncludes(live: ReadonlySet<string>, packageName: string): boolean {
76
+ if (live.has(packageName)) return true
77
+ const prefix = `${packageName}/`
78
+ for (const name of live) if (name.startsWith(prefix)) return true
79
+ return false
80
+ }
81
+
82
+ /**
83
+ * True when a loader entry this package's OWN bundle patch inserts is up.
84
+ *
85
+ * A carrier bundle (#103) ships no plugin of its own: its patch mounts
86
+ * ANOTHER package with configuration, so the live entry carries that other
87
+ * package's name and `liveIncludes` can never match. The entry ID is the
88
+ * part that belongs to this package — its patch created it — which is why
89
+ * matching on it is both sufficient and precise: a neighbour that happens
90
+ * to mount the same package does so under a different id.
91
+ *
92
+ * Without this the market kept telling users to restart for a plugin that
93
+ * had been running since the restart (#156).
94
+ */
95
+ function carriedRowLive(live: ReadonlySet<string>, profileDirectory: string, packageName: string): boolean {
96
+ try {
97
+ return bundlePatchInsertedIds(join(profileDirectory, 'node_modules', packageName))
98
+ .some(id => live.has(`#${id}`))
99
+ } catch {
100
+ return false
101
+ }
102
+ }
103
+
104
+ function readPkgDsh(profile: string, name: string, explicitDir?: string): PkgDsh | null {
105
+ try {
106
+ const manifest = JSON.parse(
107
+ readFileSync(join(profileDir(profile, explicitDir), 'node_modules', name, 'package.json'), 'utf8'),
108
+ ) as { dsh?: PkgDsh }
109
+ return manifest.dsh ?? {}
110
+ } catch {
111
+ return null
112
+ }
113
+ }
114
+
115
+ function patchTextOf(profile: string, name: string, explicitDir?: string): string | null {
116
+ try {
117
+ return readFileSync(join(profileDir(profile, explicitDir), 'node_modules', name, 'cordis.patch.yml'), 'utf8')
118
+ } catch {
119
+ return null
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Verify the activation state of one installed package.
125
+ * @param live - names live in the current composition; defaults to the
126
+ * market's hot-mount table (injectable for tests).
127
+ */
128
+ /**
129
+ * Whether the profile's OWN `cordis.patch.yml` inserts this package by name.
130
+ *
131
+ * A third evidence source beside the loader inventory and the package's own
132
+ * manifest, and the one that was missing (#165): a plugin the user wired up
133
+ * themselves declares nothing, is not hot-mounted until the next boot, and so
134
+ * fell through to `broken` — the market told them the install had failed
135
+ * verification while the plugin was, in fact, working.
136
+ *
137
+ * Read with the same parser the uninstall guard uses. Unreadable returns
138
+ * null, which is treated here as NO evidence rather than as evidence: this
139
+ * only ever upgrades a verdict away from `broken`, so being unsure has to
140
+ * leave the stricter answer standing.
141
+ */
142
+ function patchLoads(activeProfileDir: string, name: string): boolean {
143
+ const references = userPatchPackageReferences(join(activeProfileDir, 'cordis.patch.yml'), name)
144
+ return references !== null && references.length > 0
145
+ }
146
+
147
+ export function verifyActivation(
148
+ profile: string,
149
+ name: string,
150
+ live: ReadonlySet<string> = new Set(listHotMounts()),
151
+ explicitDir?: string,
152
+ isDisabled = false,
153
+ ): ActivationResult {
154
+ const activeProfileDir = profileDir(profile, explicitDir)
155
+ const bundles = readBundles(profile, activeProfileDir)
156
+ const inBundles = bundles.has(name)
157
+ const dsh = readPkgDsh(profile, name, activeProfileDir)
158
+
159
+ if (dsh === null) {
160
+ return { state: 'missing', reasons: ['未安装 / not installed'], bundle: inBundles, hot: false }
161
+ }
162
+
163
+ // A user-disabled plugin reads as disabled, never as "restart to apply":
164
+ // the switch state (market disable list or the user patch layer) is the
165
+ // dominant fact, and the loader keeps it off on every boot.
166
+ if (isDisabled) {
167
+ return {
168
+ state: 'disabled',
169
+ reasons: ['已停用(市场开关或补丁层),重启后保持关闭 / disabled (market toggle or the patch layer) — stays off across restarts'],
170
+ bundle: inBundles,
171
+ hot: false,
172
+ }
173
+ }
174
+
175
+ const dir = join(activeProfileDir, 'node_modules', name)
176
+ // OBSERVED beats INFERRED (#135): the loader inventory is ground truth, so
177
+ // a package the loader is running is live no matter what its manifest says.
178
+ // Plain library packages legitimately carry no `dsh` field and are still
179
+ // loaded by name from a bundle patch — @deepseek-ai/dsh-tools is loaded by
180
+ // the official dsh-base patch and has no `dsh` field at all — so this check
181
+ // has to come before any manifest-based verdict.
182
+ const loaderLive = liveIncludes(live, name) || carriedRowLive(live, activeProfileDir, name)
183
+ if (!hasDshManifest(dir)) {
184
+ if (loaderLive) {
185
+ return {
186
+ state: 'live',
187
+ reasons: ['已由 Loader 加载(该包未声明 dsh 元数据,由某个 bundle patch 按名加载)/ loaded by the loader (no dsh metadata of its own — a bundle patch loads it by name)'],
188
+ bundle: inBundles,
189
+ hot: true,
190
+ }
191
+ }
192
+ // Not live and no dsh surface: for a package the profile lists as a
193
+ // BUNDLE this is a real defect; for a plain dependency it is normal —
194
+ // most dependencies are libraries, not plugins (#135).
195
+ if (inBundles && !patchLoads(activeProfileDir, name)) {
196
+ return {
197
+ state: 'broken',
198
+ reasons: ['已列入 profile bundle 层但未声明 dsh 元数据,加载会失败 / listed in the profile bundle layer but declares no dsh metadata — loading it fails'],
199
+ bundle: true,
200
+ hot: false,
201
+ }
202
+ }
203
+ return inBundles
204
+ ? {
205
+ state: 'restart',
206
+ reasons: ['由你自己的 cordis.patch.yml 按名加载,重启后生效 / loaded by name from your own cordis.patch.yml — live after a restart'],
207
+ bundle: true,
208
+ hot: false,
209
+ }
210
+ : {
211
+ state: 'inert',
212
+ reasons: ['普通依赖(未声明 dsh 元数据),不是 profile 层插件;若它由某个 bundle patch 按名加载,启动后会显示为已加载 / a plain dependency with no dsh metadata — not a profile-layer plugin; if some bundle patch loads it by name it will read as live once running'],
213
+ bundle: false,
214
+ hot: false,
215
+ }
216
+ }
217
+ // Carrier bundles (#103) ship no entry of their own — what they mount is
218
+ // the point — so judge by "is anything loadable", not by this package's
219
+ // own artifact.
220
+ if (!loaderLive && !hasLoadableEntry(activeProfileDir, name) && !patchLoads(activeProfileDir, name)) {
221
+ return {
222
+ state: 'broken',
223
+ reasons: [
224
+ '声明的入口产物缺失(源码检出或构建被拦),下次启动会失败 / the declared entry artifact is missing (source-only checkout or blocked build) — the next boot would fail',
225
+ ],
226
+ bundle: inBundles,
227
+ hot: false,
228
+ }
229
+ }
230
+
231
+ if (loaderLive) {
232
+ const clientOnly = dsh.bundle === undefined && dsh.client !== undefined
233
+ return {
234
+ state: 'live',
235
+ reasons: [
236
+ clientOnly
237
+ ? '已热加载(纯客户端插件 shim)/ live via the client-only shim'
238
+ : '已热加载(bundle patch)/ live via its bundle patch',
239
+ ],
240
+ bundle: inBundles,
241
+ hot: true,
242
+ }
243
+ }
244
+
245
+ if (inBundles) {
246
+ const patch = patchTextOf(profile, name, activeProfileDir)
247
+ const complex = patch !== null && parseSimplePatch(patch) === null
248
+ return {
249
+ state: 'restart',
250
+ reasons: [
251
+ complex
252
+ ? 'bundle patch 含配置/表达式,热挂载仅支持纯 insert;重启后由 bundle 层生效 / the bundle patch contains config/expression rows; hot-mount only supports plain inserts — it activates on restart'
253
+ : '已进入 profile bundle 层但本次未能热挂载;重启后生效 / in the bundle layer but not hot-mounted this session — it activates on restart',
254
+ ],
255
+ bundle: true,
256
+ hot: false,
257
+ }
258
+ }
259
+
260
+ // Not a profile-layer plugin. Client-only packages never enter bundles
261
+ // (the dsh CLI skips them), so the market shim-mounts them at boot —
262
+ // they still work, but "installed" never means "bundle layer".
263
+ if (dsh.client !== undefined) {
264
+ return {
265
+ state: 'inert',
266
+ reasons: [
267
+ '未声明 dsh.bundle,不会进入 profile bundle 层(纯客户端插件);重启后由市场自动挂载生效 / no dsh.bundle — client-only plugins never enter the bundle layer; the market shim-mounts them at the next boot',
268
+ ],
269
+ bundle: false,
270
+ hot: false,
271
+ }
272
+ }
273
+
274
+ return {
275
+ state: 'inert',
276
+ reasons: [
277
+ '未声明 dsh.bundle,已作为普通依赖安装,不会成为 profile 层 / no dsh.bundle — installed as a plain dependency, never a profile-layer plugin',
278
+ ],
279
+ bundle: false,
280
+ hot: false,
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Correct a post-UPDATE verdict for a plugin that was already running.
286
+ *
287
+ * `verifyActivation` answers "is this name in the live loader inventory".
288
+ * That is the right question after an install and the wrong one after an
289
+ * update: the plugin was already live, so the answer stays "live" while the
290
+ * process keeps serving the module it imported at boot. Replacing files under
291
+ * a running composition does not re-import anything.
292
+ *
293
+ * Measured on a real host rather than reasoned about — updating the market
294
+ * from 1.11.3 to 1.12.2 left `/dsh-market/status` reporting 1.11.3 with an
295
+ * unchanged boot id, while the update route called it hot-loaded in the same
296
+ * response. The browser half genuinely does refresh (the host re-serves the
297
+ * client bundle from disk), which is what makes the wrong verdict credible:
298
+ * the UI visibly becomes the new version while the server half does not.
299
+ *
300
+ * Only a plugin that was ALREADY live is affected. One that was missing,
301
+ * broken or disabled beforehand has nothing loaded to shadow the new build,
302
+ * so its fresh mount really does run the new code.
303
+ *
304
+ * Client-only packages are excluded for the same reason from the other end:
305
+ * they have no host half to go stale, and the browser fetches their bundle
306
+ * from disk on the next page load. Telling their users to restart would be
307
+ * #156 again, in a narrower place — see `hasHostHalf`.
308
+ * @param result the verdict computed from the loader inventory
309
+ * @param hostHalfWasLive whether a HOST half was live BEFORE the replacement
310
+ */
311
+ export function activationAfterReplace(result: ActivationResult, hostHalfWasLive: boolean): ActivationResult {
312
+ if (!hostHalfWasLive || result.state !== 'live') return result
313
+ return {
314
+ ...result,
315
+ state: 'restart',
316
+ hot: false,
317
+ reasons: ['新版本已就位,但运行中的进程仍在使用启动时加载的旧模块——重启后生效(页面本身会立即变成新版,服务端不会) / the new build is in place, but the running process still serves the module it imported at boot — restart to apply (the page itself updates immediately; the server half does not)'],
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Whether a package has a host (Node) half at all.
323
+ *
324
+ * A `dsh.client`-only package — themes, skins, most pure-UI plugins — runs
325
+ * no server code: the market shim-mounts it so the loader has a live row,
326
+ * and the browser re-fetches its bundle from disk on the next page load. An
327
+ * update to one takes effect on refresh, with no restart to ask for.
328
+ */
329
+ export function hasHostHalf(profile: string, name: string, explicitDir?: string): boolean {
330
+ const dsh = readPkgDsh(profile, name, explicitDir)
331
+ if (dsh === null) return false
332
+ // Only a DEFINITE client-only package is excluded — the same test
333
+ // verifyActivation uses for its own verdict. Testing `dsh.bundle` on its
334
+ // own would read a package that declares neither key (`"dsh": {}`, which
335
+ // the bundle layer still loads) as client-only, and quietly disable the
336
+ // correction for it.
337
+ return !(dsh.bundle === undefined && dsh.client !== undefined)
338
+ }
339
+
340
+ /**
341
+ * The client bundle path a package's `exports["./client"]` names, relative
342
+ * to the package root — or `null` when it cannot be resolved CONFIDENTLY.
343
+ *
344
+ * Returning null is the important half. This feeds a post-install check
345
+ * whose only job is to catch a corrupt bundle, and a resolver that guessed
346
+ * wrong would report a healthy plugin as broken — worse than the silence it
347
+ * replaces. So every shape this does not fully understand resolves to null
348
+ * and the check simply does not run: unresolvable is not evidence of damage.
349
+ *
350
+ * Handles the two shapes real plugins ship: a plain string, and a
351
+ * conditional object. For the object, only `browser` and `default` are
352
+ * consulted — those are the conditions the host's client loader actually
353
+ * activates; `import`/`require` describe a Node resolution this file is not
354
+ * modelling, and picking one of those could name a different artifact.
355
+ * Nested conditions recurse; anything else (arrays, non-relative targets)
356
+ * gives up.
357
+ */
358
+ export function clientBundlePath(exportsField: unknown, depth = 0): string | null {
359
+ if (depth > 4) return null
360
+ if (typeof exportsField === 'string') {
361
+ // Only a relative in-package path. A bare specifier or URL is a shape
362
+ // this resolver does not model.
363
+ return exportsField.startsWith('./') ? exportsField : null
364
+ }
365
+ if (exportsField === null || typeof exportsField !== 'object' || Array.isArray(exportsField)) return null
366
+ const conditions = exportsField as Record<string, unknown>
367
+ for (const key of ['browser', 'default']) {
368
+ if (conditions[key] === undefined) continue
369
+ const resolved = clientBundlePath(conditions[key], depth + 1)
370
+ if (resolved !== null) return resolved
371
+ }
372
+ return null
373
+ }
374
+
375
+ /** What a bundle check concluded. `ok` covers "fine" AND "could not tell". */
376
+ export interface BundleCheck {
377
+ ok: boolean
378
+ /** Populated only when the file was found AND failed to parse. */
379
+ reason: string | null
380
+ }
381
+
382
+ /**
383
+ * Whether a package's client bundle still parses as JavaScript (#222).
384
+ *
385
+ * pnpm can leave a half-written or patch-mangled bundle behind — the report
386
+ * describes a profile whose client bundle was broken after an update. The
387
+ * browser is where that surfaces today, as a blank settings page long after
388
+ * the operation reported success, with nothing connecting the two.
389
+ *
390
+ * `vm.Script` COMPILES without executing: it catches the syntax damage this
391
+ * is looking for and never runs plugin code, so a hostile bundle gains
392
+ * nothing. A missing `dsh.client`, an unresolvable exports field, or a file
393
+ * that is simply absent all return ok — this check only ever fires on a file
394
+ * it actually read and actually failed to parse. Everything ambiguous stays
395
+ * silent, because a false "your plugin is corrupt" is the one outcome worse
396
+ * than not checking.
397
+ */
398
+ /**
399
+ * V8's wording when a classic-script parse trips over module syntax.
400
+ *
401
+ * Matched on the message because there is no flag-free way to compile a
402
+ * module here (`SourceTextModule` needs --experimental-vm-modules, which the
403
+ * host process does not set). If V8 ever rewords these, the failure mode is
404
+ * the false positive we had before rather than a missed real break — the
405
+ * safer direction of the two.
406
+ */
407
+ const MODULE_SYNTAX_ERROR = /Unexpected token 'export'|Cannot use import statement outside a module|await is only valid in async functions and the top level bodies of modules/
408
+
409
+ /**
410
+ * Every installed plugin whose client bundle will not parse (#222 by
411
+ * @MicroMilo).
412
+ *
413
+ * The per-package check above only ever looked at what an operation added,
414
+ * which misses the failure that was actually reported: pnpm re-extracts the
415
+ * WHOLE tree on any install, so updating one plugin can restore another
416
+ * plugin's pristine — and broken — bundle, or fail to re-apply a patch that
417
+ * was holding it together. The damage then surfaces at the next boot as
418
+ * "failed to load plugins", with nothing connecting it to the install that
419
+ * caused it.
420
+ *
421
+ * Cheap enough to run on every operation: 0.40ms per plugin including the
422
+ * read (measured on a 385KB bundle), so a 30-plugin profile costs ~12ms
423
+ * against an install that takes seconds.
424
+ *
425
+ * Silent on ESM bundles, like the per-package check it calls — see there for
426
+ * why. Widening the sweep is exactly what would have turned that one false
427
+ * "corrupt" into one per ESM plugin in the profile.
428
+ *
429
+ * Callers compare a before-list with an after-list rather than reporting
430
+ * this one directly — a profile can carry a broken bundle indefinitely, and
431
+ * re-reporting a problem the user already had would put them in front of
432
+ * something this operation did not cause and cannot undo.
433
+ */
434
+ export function brokenClientBundles(profile: string, explicitDir?: string): { name: string; reason: string }[] {
435
+ const broken: { name: string; reason: string }[] = []
436
+ for (const name of Object.keys(readInstalled(profile, explicitDir))) {
437
+ const check = checkClientBundle(profile, name, explicitDir)
438
+ if (!check.ok) broken.push({ name, reason: check.reason ?? 'parse failed' })
439
+ }
440
+ return broken
441
+ }
442
+
443
+ /** Bundles broken after an operation that were intact before it. */
444
+ export function newlyBrokenBundles(
445
+ before: readonly { name: string; reason: string }[],
446
+ after: readonly { name: string; reason: string }[],
447
+ ): { name: string; reason: string }[] {
448
+ const seen = new Set(before.map(entry => entry.name))
449
+ return after.filter(entry => !seen.has(entry.name))
450
+ }
451
+
452
+ export function checkClientBundle(profile: string, name: string, explicitDir?: string): BundleCheck {
453
+ const root = join(profileDir(profile, explicitDir), 'node_modules', name)
454
+ let manifest: { dsh?: { client?: unknown }; exports?: unknown }
455
+ try {
456
+ manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as typeof manifest
457
+ } catch {
458
+ return { ok: true, reason: null }
459
+ }
460
+ if (manifest.dsh?.client === undefined) return { ok: true, reason: null }
461
+ const exportsField = manifest.exports
462
+ const relative = exportsField !== null && typeof exportsField === 'object' && !Array.isArray(exportsField)
463
+ ? clientBundlePath((exportsField as Record<string, unknown>)['./client'])
464
+ : null
465
+ if (relative === null) return { ok: true, reason: null }
466
+ let source: string
467
+ try {
468
+ source = readFileSync(join(root, relative), 'utf8')
469
+ } catch {
470
+ // Declared but absent is verifyActivation's territory (a missing entry
471
+ // artifact is already `broken` there); duplicating it here would report
472
+ // one problem twice in two different vocabularies.
473
+ return { ok: true, reason: null }
474
+ }
475
+ try {
476
+ new Script(source, { filename: relative })
477
+ return { ok: true, reason: null }
478
+ } catch (error) {
479
+ const message = error instanceof Error ? error.message : String(error)
480
+ // `new Script` compiles a CLASSIC script, so perfectly valid module
481
+ // syntax is a SyntaxError to it: `export`, `import`, and top-level
482
+ // `await` all throw. This package ships CJS, which is why nobody noticed
483
+ // — but a plugin whose client bundle is ESM was being told its file was
484
+ // corrupt, and offered a rollback for a file that is fine.
485
+ //
486
+ // That is the exact outcome this check's own contract forbids: a false
487
+ // "your plugin is corrupt" is worse than not checking. Module syntax
488
+ // means "cannot judge with this parser", not "broken".
489
+ if (MODULE_SYNTAX_ERROR.test(message)) return { ok: true, reason: null }
490
+ return { ok: false, reason: message }
491
+ }
492
+ }