@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
@@ -0,0 +1,147 @@
1
+ /**
2
+ * What the browser can see about the market that the server cannot.
3
+ *
4
+ * This exists because of a pattern, not a hypothesis. #293 ("市场显示空白")
5
+ * and #384 ("lightbox cannot be closed") are both reproducible for their
6
+ * reporters and for nobody else, on clean profiles, across host versions.
7
+ * Both investigations then stalled in the same place: I asked the reporter to
8
+ * open a console and paste the result of an expression. That costs a day per
9
+ * question, asks a non-developer to run code from a stranger, and is the one
10
+ * step where a report goes quiet.
11
+ *
12
+ * Meanwhile the exported log — which the issue template already asks for, and
13
+ * which reporters do send — describes only the server: dependencies, bundle
14
+ * resolution, events. Everything it says was already true of a machine where
15
+ * the bug does not happen. The interesting half was in a page nobody looked
16
+ * at.
17
+ *
18
+ * So the browser answers the questions I have actually had to ask, in the
19
+ * artefact people already know how to produce. Nothing here diagnoses
20
+ * anything on its own; it is evidence, collected before it is needed.
21
+ */
22
+
23
+ import { api } from './market-data.ts'
24
+ import { lastMarketCrash } from './ErrorBoundary.tsx'
25
+
26
+ /**
27
+ * Download the exported log: the server's account plus what only the browser
28
+ * can see.
29
+ *
30
+ * A free function rather than a hook, because the market's error boundary
31
+ * has to offer it too — a crashed market is exactly when the log matters,
32
+ * and for months it was exactly when the button did not exist (#293).
33
+ * @throws when the server half cannot be fetched; callers show their own state.
34
+ */
35
+ export async function exportMarketLog(): Promise<void> {
36
+ const res = await fetch(api('/dsh-market/logs'))
37
+ if (!res.ok) throw new Error(`HTTP ${String(res.status)}`)
38
+ const serverText = await res.text()
39
+ const browser = clientDiagnostics()
40
+ const blob = new Blob(
41
+ [serverText, ...(browser.length > 0 ? ['## browser\n', browser.join('\n'), '\n'] : [])],
42
+ { type: 'text/plain;charset=utf-8' },
43
+ )
44
+ const url = URL.createObjectURL(blob)
45
+ const anchor = document.createElement('a')
46
+ anchor.href = url
47
+ anchor.download = 'chatcode-cli-market-log.txt'
48
+ document.body.appendChild(anchor)
49
+ anchor.click()
50
+ anchor.remove()
51
+ URL.revokeObjectURL(url)
52
+ }
53
+
54
+ /**
55
+ * How many times this module has been evaluated in this page.
56
+ *
57
+ * Module scope runs once per module instance, so >1 means the market's client
58
+ * bundle was loaded twice — two React copies, two of every module singleton,
59
+ * two portal containers. That is a specific hypothesis for #384: two React
60
+ * roots each attach delegated listeners to their own portal container, and a
61
+ * click lands on whichever container is last in `body` while the handlers
62
+ * live on the other one, so the buttons look present and do nothing.
63
+ *
64
+ * Counted on `window` rather than in a module variable for the obvious
65
+ * reason: a module variable would be duplicated along with everything else
66
+ * and each copy would confidently report 1.
67
+ */
68
+ const globals = globalThis as typeof globalThis & { __dshmarketClientLoads?: number }
69
+ globals.__dshmarketClientLoads = (globals.__dshmarketClientLoads ?? 0) + 1
70
+
71
+ /**
72
+ * The marks Chrome and Edge leave on `<html>` when they translate a page.
73
+ *
74
+ * Both add a `translated-*` class for the direction they rendered into; Edge
75
+ * also flags the document it worked on. Reported verbatim rather than
76
+ * reduced to a boolean, because which engine did it is the next question
77
+ * after "was it translated at all".
78
+ * @returns the marks found, or null when there are none.
79
+ */
80
+ function translatedMarks(): string | null {
81
+ const root = document.documentElement
82
+ const marks = [
83
+ ...[...root.classList].filter(name => name.startsWith('translated')),
84
+ ...(root.hasAttribute('_msthash') ? ['edge (_msthash)'] : []),
85
+ ...(root.hasAttribute('_msttexthash') ? ['edge (_msttexthash)'] : []),
86
+ ]
87
+ return marks.length === 0 ? null : marks.join(', ')
88
+ }
89
+
90
+ /** The market crash the error boundary caught, if there was one. */
91
+ function crashLines(): string[] {
92
+ const crash = lastMarketCrash()
93
+ if (crash === null) return []
94
+ return [
95
+ `market UI crashed at: ${crash.at}`,
96
+ `market crash message: ${crash.message}`,
97
+ ...(crash.stack === null ? [] : [`market crash component stack:${crash.stack}`]),
98
+ ]
99
+ }
100
+
101
+ /** Whether `value` looks like a browser environment worth inspecting. */
102
+ const hasDom = (): boolean => typeof document !== 'undefined' && document.body !== null
103
+
104
+ /**
105
+ * The browser-side section of the exported log.
106
+ *
107
+ * Deliberately facts, not verdicts. "portal containers: 2" is something a
108
+ * reporter can paste without judging it, and something I can act on; "your
109
+ * bundle is double-loaded" would be a guess printed in the user's face, and
110
+ * wrong the first time a host legitimately renders two markets.
111
+ * @returns the lines to append, or an empty array outside a browser.
112
+ */
113
+ export function clientDiagnostics(): string[] {
114
+ if (!hasDom()) return []
115
+ const portals = document.querySelectorAll('[data-dsh-market-portal]')
116
+ const roots = document.querySelectorAll('[data-dsh-market-root]')
117
+ const last = portals.length > 0 ? portals[portals.length - 1] : null
118
+ return [
119
+ // >1 is the double-load signal above. Reported always, so that a normal
120
+ // page proves the number is being read rather than defaulting.
121
+ `client bundle evaluations: ${String(globals.__dshmarketClientLoads ?? 0)}`,
122
+ `market roots in the document: ${String(roots.length)}`,
123
+ // Asked by hand in #384. A container that is not body's last child sits
124
+ // under whatever was appended after it, which is the other way a button
125
+ // can be visible and unclickable.
126
+ `portal containers: ${String(portals.length)}`
127
+ + (last === null ? '' : ` (last one is body's last child: ${String(last === document.body.lastElementChild)})`),
128
+ // Asked by hand in #293: "blank" can mean the section never mounted, or
129
+ // mounted and rendered nothing. Those have different causes.
130
+ `plugin cards rendered: ${String(document.querySelectorAll('[data-dsh-market-root] [class*="_card"]').length)}`,
131
+ // The mount point every /dsh-market/* request is resolved against (#345).
132
+ // A surprising value here explains a whole class of "nothing loads".
133
+ // #293: browser page translation replaces text nodes underneath React,
134
+ // which is the one reported cause of the blank market. Chrome and Edge
135
+ // mark the document when they do it, so a report can now say so without
136
+ // the reporter having to notice. A fact, not a verdict — a translated
137
+ // page is not by itself a fault.
138
+ `page translated by the browser: ${translatedMarks() ?? 'no'}`,
139
+ `document baseURI: ${document.baseURI}`,
140
+ `page URL: ${location.origin}${location.pathname}`,
141
+ // A crash the error boundary caught. Reported after the DOM facts
142
+ // because it is the one line that already IS a diagnosis — and when it
143
+ // is present, it is the first thing worth reading.
144
+ ...crashLines(),
145
+ `user agent: ${navigator.userAgent}`,
146
+ ]
147
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Diagnostics sub-panel — issue #98 phase 3 (snapshots & rollback).
3
+ *
4
+ * Rendered at the bottom of the Diagnostics tab inside a collapsible section
5
+ * (the parent keeps it mounted and passes `open`, so it lazy-loads on first
6
+ * expand and keeps its state across collapses). Lists profile snapshots
7
+ * (GET /dsh-market/snapshots), creates one (POST /dsh-market/snapshots) and
8
+ * restores one after an inline double confirmation
9
+ * (POST /dsh-market/restore-snapshot {snapshot}).
10
+ *
11
+ * The list payload is read defensively (bare array or {snapshots: [...]});
12
+ * `files` entries are {path, json|lines} objects, from which only `path` is
13
+ * displayed.
14
+ */
15
+ import { useCallback, useEffect, useRef, useState } from 'react'
16
+ import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
17
+ import css from './Market.module.css'
18
+ import { api } from './market-data.ts'
19
+ import type { Translate } from './market-data.ts'
20
+
21
+ /** One snapshot from GET /dsh-market/snapshots. */
22
+ export interface Snapshot {
23
+ id: string
24
+ createdAt: string | number
25
+ files: string[]
26
+ }
27
+
28
+ interface SnapshotPanelProps {
29
+ t: Translate
30
+ /** True while the parent collapsible section is expanded (lazy-load gate). */
31
+ open: boolean
32
+ /** Re-fetch the check report after a successful restore. */
33
+ onRefresh: () => void
34
+ }
35
+
36
+ interface JsonBody { ok?: unknown; error?: unknown }
37
+
38
+ async function postJson(url: string, body: unknown): Promise<{ status: number; body: JsonBody | null }> {
39
+ let res: Response
40
+ try {
41
+ res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
42
+ } catch {
43
+ return { status: 0, body: null }
44
+ }
45
+ let payload: JsonBody | null = null
46
+ try { payload = (await res.json()) as JsonBody } catch { /* non-JSON body */ }
47
+ return { status: res.status, body: payload }
48
+ }
49
+
50
+ /** Snapshot file entries may be plain names or {path, json|lines} objects. */
51
+ function fileNames(files: unknown): string[] {
52
+ if (!Array.isArray(files)) return []
53
+ const names: string[] = []
54
+ for (const file of files) {
55
+ if (typeof file === 'string') {
56
+ names.push(file)
57
+ } else if (file !== null && typeof file === 'object') {
58
+ const path = (file as Record<string, unknown>).path
59
+ if (typeof path === 'string') names.push(path)
60
+ }
61
+ }
62
+ return names
63
+ }
64
+
65
+ function snapshotOf(value: unknown): Snapshot | null {
66
+ if (value === null || typeof value !== 'object') return null
67
+ const item = value as Record<string, unknown>
68
+ if (typeof item.id !== 'string' && typeof item.id !== 'number') return null
69
+ return {
70
+ id: String(item.id),
71
+ createdAt: typeof item.createdAt === 'string' || typeof item.createdAt === 'number' ? item.createdAt : 0,
72
+ files: fileNames(item.files),
73
+ }
74
+ }
75
+
76
+ function formatTime(value: string | number): string {
77
+ const date = new Date(value)
78
+ return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString()
79
+ }
80
+
81
+ export function SnapshotPanel(props: SnapshotPanelProps) {
82
+ const { t, open, onRefresh } = props
83
+ const [snapshots, setSnapshots] = useState<Snapshot[] | null>(null)
84
+ const [error, setError] = useState<string | null>(null)
85
+ const [msg, setMsg] = useState<string | null>(null)
86
+ const [busy, setBusy] = useState<string | null>(null)
87
+ /** Snapshot id awaiting the second confirm click (restore), or null. */
88
+ const [confirmId, setConfirmId] = useState<string | null>(null)
89
+ /** Snapshot id awaiting the second confirm click (delete), or null. */
90
+ const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
91
+ const loaded = useRef(false)
92
+
93
+ const load = useCallback(() => {
94
+ fetch(api('/dsh-market/snapshots'), { cache: 'no-store' })
95
+ .then(res => res.json())
96
+ .then(body => {
97
+ const list: unknown[] = Array.isArray(body) ? body : Array.isArray(body.snapshots) ? body.snapshots : []
98
+ setSnapshots(list.map(snapshotOf).filter((snap): snap is Snapshot => snap !== null))
99
+ setError(null)
100
+ // Mark loaded only on success so a transient failure retries on the
101
+ // next expand (review M3).
102
+ loaded.current = true
103
+ })
104
+ .catch(() => setError(t('snapListFail') + 'network'))
105
+ }, [t])
106
+
107
+ useEffect(() => {
108
+ if (open && !loaded.current) {
109
+ load()
110
+ }
111
+ }, [open, load])
112
+
113
+ const create = useCallback(() => {
114
+ if (busy !== null) return
115
+ setBusy('create')
116
+ setMsg(null)
117
+ setError(null)
118
+ postJson(api('/dsh-market/snapshots'), {})
119
+ .then(({ status, body }) => {
120
+ if (status >= 200 && status < 300 && body?.ok === true) {
121
+ setMsg(t('snapCreated'))
122
+ load()
123
+ } else {
124
+ const detail = body !== null && typeof body.error === 'string'
125
+ ? body.error
126
+ : status === 0 ? 'network error' : `HTTP ${String(status)}`
127
+ setError(t('snapCreateFail') + detail)
128
+ }
129
+ })
130
+ .catch(() => setError(t('snapCreateFail') + 'network'))
131
+ .finally(() => setBusy(null))
132
+ }, [busy, load, t])
133
+
134
+ const restore = useCallback((id: string) => {
135
+ if (busy !== null) return
136
+ setBusy('restore')
137
+ setMsg(null)
138
+ setError(null)
139
+ postJson(api('/dsh-market/restore-snapshot'), { snapshot: id })
140
+ .then(({ status, body }) => {
141
+ if (status >= 200 && status < 300 && body?.ok === true) {
142
+ setConfirmId(null)
143
+ setMsg(t('snapRestored'))
144
+ onRefresh()
145
+ } else {
146
+ const detail = body !== null && typeof body.error === 'string'
147
+ ? body.error
148
+ : status === 0 ? 'network error' : `HTTP ${String(status)}`
149
+ setError(t('snapRestoreFail') + detail)
150
+ }
151
+ })
152
+ .catch(() => setError(t('snapRestoreFail') + 'network'))
153
+ .finally(() => setBusy(null))
154
+ }, [busy, onRefresh, t])
155
+
156
+ /** Delete one snapshot after inline double confirmation. */
157
+ const remove = useCallback((id: string) => {
158
+ if (busy !== null) return
159
+ setBusy('delete')
160
+ setMsg(null)
161
+ setError(null)
162
+ postJson(api('/dsh-market/delete-snapshot'), { snapshot: id })
163
+ .then(({ status, body }) => {
164
+ if (status >= 200 && status < 300 && body?.ok === true) {
165
+ setConfirmDeleteId(null)
166
+ setMsg(t('snapDeleted'))
167
+ load()
168
+ } else {
169
+ const detail = body !== null && typeof body.error === 'string'
170
+ ? body.error
171
+ : status === 0 ? 'network error' : `HTTP ${String(status)}`
172
+ setError(t('snapDeleteFail') + detail)
173
+ }
174
+ })
175
+ .catch(() => setError(t('snapDeleteFail') + 'network'))
176
+ .finally(() => setBusy(null))
177
+ }, [busy, load, t])
178
+
179
+ return (
180
+ <div className={css.orderPanel}>
181
+ <p className={css.panelNote}>{t('snapHint')}</p>
182
+
183
+ <div className={css.panelActions}>
184
+ <Button variant="primary" size="sm" disabled={busy !== null} onClick={create}>
185
+ {busy === 'create' ? t('snapCreating') : t('snapCreate')}
186
+ </Button>
187
+ </div>
188
+
189
+ {error !== null && <div className={css.err}>{error}</div>}
190
+
191
+ {snapshots === null || snapshots.length === 0
192
+ ? <div className={css.diagEmpty}>{t('snapEmpty')}</div>
193
+ : (
194
+ <div className={css.snapList}>
195
+ {snapshots.map(snap => (
196
+ <div key={snap.id} className={css.snapRow}>
197
+ {confirmId === snap.id ? (
198
+ <>
199
+ <p className={css.snapConfirmText}>{t('snapRestoreConfirmText')}</p>
200
+ <div className={css.confirmRow}>
201
+ <Button variant="primary" size="sm" disabled={busy !== null} onClick={() => restore(snap.id)}>
202
+ {busy === 'restore' ? t('snapRestoring') : t('snapRestoreConfirm')}
203
+ </Button>
204
+ <Button variant="ghost" size="sm" disabled={busy !== null} onClick={() => setConfirmId(null)}>{t('cancel')}</Button>
205
+ </div>
206
+ </>
207
+ ) : confirmDeleteId === snap.id ? (
208
+ <>
209
+ <p className={css.snapConfirmText}>{t('snapDeleteConfirmText')}</p>
210
+ <div className={css.confirmRow}>
211
+ <Button variant="primary" size="sm" disabled={busy !== null} onClick={() => remove(snap.id)}>
212
+ {busy === 'delete' ? t('snapDeleting') : t('snapDeleteConfirm')}
213
+ </Button>
214
+ <Button variant="ghost" size="sm" disabled={busy !== null} onClick={() => setConfirmDeleteId(null)}>{t('cancel')}</Button>
215
+ </div>
216
+ </>
217
+ ) : (
218
+ <>
219
+ <div className={css.snapMeta}>
220
+ <span className={css.diagVal}>{formatTime(snap.createdAt)}</span>
221
+ <span className={css.spec}>{snap.id}</span>
222
+ {snap.files.length > 0 && (
223
+ <span className={css.spec}>{t('snapFiles')}: {snap.files.join(', ')}</span>
224
+ )}
225
+ </div>
226
+ <div className={css.confirmRow}>
227
+ <Button variant="outline" size="sm" disabled={busy !== null} onClick={() => setConfirmId(snap.id)}>
228
+ {t('snapRestore')}
229
+ </Button>
230
+ <Button variant="ghost" size="sm" disabled={busy !== null} onClick={() => setConfirmDeleteId(snap.id)}>
231
+ {t('snapDelete')}
232
+ </Button>
233
+ </div>
234
+ </>
235
+ )}
236
+ </div>
237
+ ))}
238
+ </div>
239
+ )}
240
+
241
+ {msg !== null && <div className={css.okState}>{msg}</div>}
242
+ </div>
243
+ )
244
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Host-contract compatibility preflight for #195.
3
+ *
4
+ * Pure evaluation of what `analyzeProfile()` already reports: a confirmed
5
+ * peer mismatch (`satisfied === false`) is translated into a directional
6
+ * verdict:
7
+ *
8
+ * - `belowMin`: the resolved version is older than every alternative's lower
9
+ * bound — the environment is too old for the plugin's declared contract.
10
+ * - `aboveMax`: the resolved version is newer than every alternative's upper
11
+ * bound (or the exact pin). This is only a risk when the author expressed
12
+ * an explicit upper bound or exact pin; otherwise it is a warning, because
13
+ * the ecosystem currently has many sloppy `^0.0.1`-style declarations that
14
+ * work in practice.
15
+ *
16
+ * Everything else stays informational: `*`, prerelease-vs-`*` artifacts,
17
+ * unparseable ranges, and optional peers never produce a risk here.
18
+ */
19
+
20
+ import { analyzeProfile, compareSemver, type CheckOptions, type DuplicateName, type PeerMismatch, type PeerRisk, type PeerWarning, type PeerVerdict } from './check.ts'
21
+ import { profileDir, readInstalledManifest } from './profile.ts'
22
+
23
+ export type CompatibilityRisk = PeerRisk
24
+ export type CompatibilityWarning = PeerWarning
25
+ /** Re-exported so consumers can keep importing PeerVerdict from this module. */
26
+ export type { PeerVerdict }
27
+
28
+ export interface CompatibilityAssessment {
29
+ risks: CompatibilityRisk[]
30
+ warnings: CompatibilityWarning[]
31
+ /**
32
+ * Cross-layer duplicate loader NAMES, carried through from the same
33
+ * `analyzeProfile` run the peer checks already pay for (#230).
34
+ *
35
+ * The report has always computed these and deliberately kept them out of
36
+ * `summary.warnings`, because flagging an already-messy but working
37
+ * profile is a false positive nobody can act on. Diffing before against
38
+ * after is what makes them actionable: a collision this operation
39
+ * INTRODUCED is one the operation can also undo.
40
+ */
41
+ duplicateNames: DuplicateName[]
42
+ }
43
+
44
+ interface ParsedComparator {
45
+ op: 'exact' | '^' | '~' | '>=' | '>' | '<=' | '<'
46
+ target: string
47
+ }
48
+
49
+ interface AlternativeBounds {
50
+ lower: ParsedComparator | null
51
+ upper: ParsedComparator | null
52
+ /** The author wrote an explicit `<`/`<=` bound or an exact pin. */
53
+ explicitUpper: boolean
54
+ exact: string | null
55
+ }
56
+
57
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
58
+
59
+ function parseComparator(part: string): ParsedComparator | null {
60
+ const p = part.trim()
61
+ const m = /^(\^|~|>=|<=|>|<)?(.*)$/.exec(p)
62
+ if (m === null) return null
63
+ const target = (m[2] ?? '').trim()
64
+ if (!SEMVER.test(target)) return null
65
+ const raw = m[1] ?? ''
66
+ return { op: raw === '' ? 'exact' : raw as ParsedComparator['op'], target }
67
+ }
68
+
69
+ /** Next breaking bump for `^` / `~`, using `-0` so prereleases compare consistently. */
70
+ function nextBound(target: string, kind: '^' | '~'): string | null {
71
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(target)
72
+ if (m === null) return null
73
+ const major = Number(m[1])
74
+ const minor = Number(m[2])
75
+ const patch = Number(m[3])
76
+ if (kind === '^') {
77
+ if (major > 0) return `${major + 1}.0.0-0`
78
+ if (minor > 0) return `0.${minor + 1}.0-0`
79
+ return `0.0.${patch + 1}-0`
80
+ }
81
+ return `${major}.${minor + 1}.0-0`
82
+ }
83
+
84
+ function boundsFor(range: string): AlternativeBounds[] | null {
85
+ const alternatives: AlternativeBounds[] = []
86
+ for (const rawAlternative of range.split('||')) {
87
+ const parts = rawAlternative.trim().split(/\s+/).filter(Boolean)
88
+ const parsed = parts.map(parseComparator)
89
+ if (parsed.some(part => part === null)) return null
90
+ let lower: ParsedComparator | null = null
91
+ let upper: ParsedComparator | null = null
92
+ let explicitUpper = false
93
+ let exact: string | null = null
94
+ for (const part of parsed) {
95
+ const p = part as ParsedComparator
96
+ if (p.op === 'exact') {
97
+ exact = p.target
98
+ continue
99
+ }
100
+ if (p.op === '^' || p.op === '~') {
101
+ const bound = nextBound(p.target, p.op)
102
+ if (bound === null) return null
103
+ if (lower === null || compareSemver(p.target, lower.target) > 0) lower = p
104
+ if (upper === null || compareSemver(bound, upper.target) < 0) upper = { op: '<=', target: bound }
105
+ continue
106
+ }
107
+ if (p.op === '>=' || p.op === '>') {
108
+ if (lower === null || compareSemver(p.target, lower.target) > 0) lower = p
109
+ } else {
110
+ if (upper === null || compareSemver(p.target, upper.target) < 0) upper = p
111
+ explicitUpper = true
112
+ }
113
+ }
114
+ alternatives.push({ lower, upper, explicitUpper, exact })
115
+ }
116
+ return alternatives
117
+ }
118
+
119
+ function belowAllMins(resolved: string, bounds: AlternativeBounds[]): boolean {
120
+ return bounds.every(alternative => {
121
+ if (alternative.exact !== null) return compareSemver(resolved, alternative.exact) < 0
122
+ if (alternative.lower === null) return false
123
+ const lower = alternative.lower
124
+ return lower.op === '>' ? compareSemver(resolved, lower.target) <= 0 : compareSemver(resolved, lower.target) < 0
125
+ })
126
+ }
127
+
128
+ function aboveAllMaxes(resolved: string, bounds: AlternativeBounds[]): boolean {
129
+ return bounds.every(alternative => {
130
+ if (alternative.exact !== null) return compareSemver(resolved, alternative.exact) > 0
131
+ if (alternative.upper === null) return false
132
+ const upper = alternative.upper
133
+ return upper.op === '<' ? compareSemver(resolved, upper.target) >= 0 : compareSemver(resolved, upper.target) > 0
134
+ })
135
+ }
136
+
137
+ function hasExplicitUpperOrExact(bounds: AlternativeBounds[]): boolean {
138
+ return bounds.every(alternative => alternative.exact !== null || alternative.explicitUpper)
139
+ }
140
+
141
+ /** Translate one confirmed peer mismatch into a directional verdict. */
142
+ export function classifyPeer(
143
+ plugin: string,
144
+ peer: string,
145
+ range: string,
146
+ resolved: string | null,
147
+ optional: boolean,
148
+ ): PeerVerdict {
149
+ if (resolved === null) return { kind: 'none' }
150
+ if (optional) {
151
+ return {
152
+ kind: 'warning',
153
+ warning: { plugin, peer, range, resolved, reason: 'optional' },
154
+ }
155
+ }
156
+ const bounds = boundsFor(range)
157
+ if (bounds === null) return { kind: 'none' }
158
+ if (belowAllMins(resolved, bounds)) {
159
+ return {
160
+ kind: 'risk',
161
+ risk: { plugin, peer, range, resolved, direction: 'belowMin' },
162
+ }
163
+ }
164
+ if (aboveAllMaxes(resolved, bounds)) {
165
+ return hasExplicitUpperOrExact(bounds)
166
+ ? {
167
+ kind: 'risk',
168
+ risk: { plugin, peer, range, resolved, direction: 'aboveMax' },
169
+ }
170
+ : {
171
+ kind: 'warning',
172
+ warning: { plugin, peer, range, resolved, reason: 'aboveMax' },
173
+ }
174
+ }
175
+ return { kind: 'none' }
176
+ }
177
+
178
+ /** Whether a peer is declared optional in the installed plugin manifest. */
179
+ export function isOptionalPeer(profileDirectory: string, plugin: string, peer: string): boolean {
180
+ const manifest = readInstalledManifest('web', plugin, profileDirectory) as {
181
+ peerDependenciesMeta?: Record<string, { optional?: unknown }>
182
+ } | null
183
+ return manifest?.peerDependenciesMeta?.[peer]?.optional === true
184
+ }
185
+
186
+ /** Evaluate the current profile with the same machinery `/dsh-market/check` uses. */
187
+ export function assessCompatibility(profileDirectory: string, options?: CheckOptions): CompatibilityAssessment {
188
+ const report = analyzeProfile(profileDirectory, options)
189
+ const risks: CompatibilityRisk[] = []
190
+ const warnings: CompatibilityWarning[] = []
191
+ for (const mismatch of report.peerMismatches) {
192
+ if (mismatch.satisfied !== false) continue
193
+ const optional = isOptionalPeer(profileDirectory, mismatch.plugin, mismatch.name)
194
+ const verdict = classifyPeer(mismatch.plugin, mismatch.name, mismatch.range, mismatch.resolved, optional)
195
+ if (verdict.kind === 'risk') risks.push(verdict.risk)
196
+ else if (verdict.kind === 'warning') warnings.push(verdict.warning)
197
+ }
198
+ return { risks, warnings, duplicateNames: report.duplicateNames }
199
+ }
200
+
201
+ function riskId(risk: CompatibilityRisk): string {
202
+ return `${risk.plugin}\u0000${risk.peer}\u0000${risk.direction}`
203
+ }
204
+
205
+ /** Risks present after a mutation but absent before it. */
206
+ export function introducedRisks(before: CompatibilityAssessment, after: CompatibilityAssessment): CompatibilityRisk[] {
207
+ const seen = new Set(before.risks.map(riskId))
208
+ return after.risks.filter(risk => !seen.has(riskId(risk)))
209
+ }
210
+
211
+ /**
212
+ * Cross-layer name collisions present after a mutation but absent before it
213
+ * (#230 by @dxc-dxc).
214
+ *
215
+ * Keyed by NAME alone, not by the layer set: a collision the operation made
216
+ * worse — same name, now shadowing across one more layer — is still the same
217
+ * collision the profile already had, and re-reporting it would put the
218
+ * operator back in front of a problem they did not just cause.
219
+ *
220
+ * This is what makes surfacing these safe at all. The underlying
221
+ * `duplicateNames` is informational precisely because a healthy-but-messy
222
+ * profile can carry collisions indefinitely; only the newly introduced ones
223
+ * are attributable to the install that just ran, and therefore undoable by
224
+ * rolling it back.
225
+ */
226
+ export function introducedDuplicateNames(
227
+ before: CompatibilityAssessment,
228
+ after: CompatibilityAssessment,
229
+ ): DuplicateName[] {
230
+ const seen = new Set(before.duplicateNames.map(entry => entry.name))
231
+ return after.duplicateNames.filter(entry => !seen.has(entry.name))
232
+ }
233
+
234
+ /** Convenience wrapper matching the profile helper signature. */
235
+ export function assessProfile(profile: string, explicitDir?: string): CompatibilityAssessment {
236
+ return assessCompatibility(profileDir(profile, explicitDir))
237
+ }