@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.
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/README.zh.md +143 -0
- package/UPDATE-API-V1.md +132 -0
- package/client/client.js +11534 -0
- package/cordis.patch.yml +5 -0
- package/lib/accelerate.js +184 -0
- package/lib/agents.js +36 -0
- package/lib/backup.js +572 -0
- package/lib/catalog-local-match.js +126 -0
- package/lib/catalog-npm.js +111 -0
- package/lib/changelog.js +242 -0
- package/lib/channels.js +64 -0
- package/lib/check.js +1067 -0
- package/lib/compatibility.js +198 -0
- package/lib/diagnostics.js +49 -0
- package/lib/discovery-compatibility.js +260 -0
- package/lib/dsh-cli.js +949 -0
- package/lib/dsh-install.js +108 -0
- package/lib/gist.js +352 -0
- package/lib/groups.js +97 -0
- package/lib/home-paths.js +42 -0
- package/lib/hot.js +519 -0
- package/lib/http.js +39 -0
- package/lib/index.js +97 -0
- package/lib/install.js +387 -0
- package/lib/log.js +197 -0
- package/lib/ndjson.js +154 -0
- package/lib/net.js +98 -0
- package/lib/order.js +284 -0
- package/lib/patch.js +521 -0
- package/lib/pnpm-compat.js +490 -0
- package/lib/presets.js +278 -0
- package/lib/profile.js +911 -0
- package/lib/region-probe.js +93 -0
- package/lib/regions.js +253 -0
- package/lib/registry.js +204 -0
- package/lib/restart.js +373 -0
- package/lib/routes.js +4372 -0
- package/lib/settings.js +109 -0
- package/lib/skill-market/api.js +119 -0
- package/lib/skill-market/config.js +29 -0
- package/lib/skill-market/installer.js +352 -0
- package/lib/skill-market/runtime.js +13 -0
- package/lib/skill-market/tui.js +119 -0
- package/lib/skill-market/types.js +1 -0
- package/lib/skill-market/web-routes.js +123 -0
- package/lib/snapshot.js +500 -0
- package/lib/source-migration.js +54 -0
- package/lib/sources.js +596 -0
- package/lib/store.js +91 -0
- package/lib/themes.js +102 -0
- package/lib/trial.js +116 -0
- package/lib/types/accelerate.d.ts +85 -0
- package/lib/types/agents.d.ts +22 -0
- package/lib/types/backup.d.ts +131 -0
- package/lib/types/catalog-local-match.d.ts +50 -0
- package/lib/types/catalog-npm.d.ts +47 -0
- package/lib/types/changelog.d.ts +102 -0
- package/lib/types/channels.d.ts +56 -0
- package/lib/types/check.d.ts +266 -0
- package/lib/types/compatibility.d.ts +64 -0
- package/lib/types/diagnostics.d.ts +29 -0
- package/lib/types/discovery-compatibility.d.ts +80 -0
- package/lib/types/dsh-cli.d.ts +324 -0
- package/lib/types/dsh-install.d.ts +46 -0
- package/lib/types/gist.d.ts +53 -0
- package/lib/types/groups.d.ts +34 -0
- package/lib/types/home-paths.d.ts +16 -0
- package/lib/types/hot.d.ts +221 -0
- package/lib/types/http.d.ts +12 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/install.d.ts +172 -0
- package/lib/types/log.d.ts +43 -0
- package/lib/types/ndjson.d.ts +52 -0
- package/lib/types/net.d.ts +58 -0
- package/lib/types/order.d.ts +100 -0
- package/lib/types/patch.d.ts +129 -0
- package/lib/types/pnpm-compat.d.ts +93 -0
- package/lib/types/presets.d.ts +90 -0
- package/lib/types/profile.d.ts +253 -0
- package/lib/types/region-probe.d.ts +50 -0
- package/lib/types/regions.d.ts +122 -0
- package/lib/types/registry.d.ts +86 -0
- package/lib/types/restart.d.ts +194 -0
- package/lib/types/routes.d.ts +67 -0
- package/lib/types/settings.d.ts +78 -0
- package/lib/types/skill-market/api.d.ts +10 -0
- package/lib/types/skill-market/config.d.ts +3 -0
- package/lib/types/skill-market/installer.d.ts +23 -0
- package/lib/types/skill-market/runtime.d.ts +10 -0
- package/lib/types/skill-market/tui.d.ts +4 -0
- package/lib/types/skill-market/types.d.ts +71 -0
- package/lib/types/skill-market/web-routes.d.ts +19 -0
- package/lib/types/snapshot.d.ts +93 -0
- package/lib/types/source-migration.d.ts +11 -0
- package/lib/types/sources.d.ts +216 -0
- package/lib/types/store.d.ts +30 -0
- package/lib/types/themes.d.ts +40 -0
- package/lib/types/trial.d.ts +61 -0
- package/lib/types/update-api-v1.d.ts +66 -0
- package/lib/types/updates.d.ts +123 -0
- package/lib/types/verify.d.ts +139 -0
- package/lib/update-api-v1.js +215 -0
- package/lib/updates.js +361 -0
- package/lib/verify.js +453 -0
- package/package.json +121 -0
- package/src/accelerate.ts +213 -0
- package/src/agents.ts +43 -0
- package/src/backup.ts +583 -0
- package/src/catalog-local-match.ts +144 -0
- package/src/catalog-npm.ts +120 -0
- package/src/changelog.ts +282 -0
- package/src/channels.ts +70 -0
- package/src/check.ts +1239 -0
- package/src/client/CommentsModal.tsx +119 -0
- package/src/client/Diagnostics.tsx +907 -0
- package/src/client/ErrorBoundary.tsx +111 -0
- package/src/client/InstallToast.tsx +31 -0
- package/src/client/Market.module.css +830 -0
- package/src/client/MarketSection.tsx +5301 -0
- package/src/client/OperationsPanel.tsx +365 -0
- package/src/client/SettingsCard.tsx +621 -0
- package/src/client/SkillMarket.module.css +21 -0
- package/src/client/SkillMarketSection.tsx +162 -0
- package/src/client/comments.ts +54 -0
- package/src/client/globals.d.ts +13 -0
- package/src/client/index.ts +180 -0
- package/src/client/locales.ts +1112 -0
- package/src/client/market-data.ts +1321 -0
- package/src/client/operations.ts +201 -0
- package/src/client/preset-panel.tsx +263 -0
- package/src/client/primitives.d.ts +140 -0
- package/src/client/self-check.ts +147 -0
- package/src/client/snapshot-panel.tsx +244 -0
- package/src/compatibility.ts +237 -0
- package/src/diagnostics.ts +84 -0
- package/src/discovery-compatibility.ts +315 -0
- package/src/dsh-cli.ts +1126 -0
- package/src/dsh-install.ts +118 -0
- package/src/gist.ts +362 -0
- package/src/groups.ts +111 -0
- package/src/home-paths.ts +53 -0
- package/src/hot.ts +628 -0
- package/src/http.ts +41 -0
- package/src/index.ts +128 -0
- package/src/install.ts +420 -0
- package/src/log.ts +206 -0
- package/src/ndjson.ts +185 -0
- package/src/net.ts +106 -0
- package/src/order.ts +303 -0
- package/src/patch.ts +522 -0
- package/src/pnpm-compat.ts +527 -0
- package/src/presets.ts +344 -0
- package/src/profile.ts +940 -0
- package/src/region-probe.ts +97 -0
- package/src/regions.ts +310 -0
- package/src/registry.ts +250 -0
- package/src/restart.ts +396 -0
- package/src/routes.ts +4506 -0
- package/src/settings.ts +141 -0
- package/src/skill-market/api.ts +130 -0
- package/src/skill-market/config.ts +32 -0
- package/src/skill-market/installer.ts +337 -0
- package/src/skill-market/runtime.ts +14 -0
- package/src/skill-market/tui.ts +157 -0
- package/src/skill-market/types.ts +80 -0
- package/src/skill-market/web-routes.ts +132 -0
- package/src/snapshot.ts +532 -0
- package/src/source-migration.ts +61 -0
- package/src/sources.ts +565 -0
- package/src/store.ts +89 -0
- package/src/themes.ts +125 -0
- package/src/trial.ts +156 -0
- package/src/update-api-v1.ts +277 -0
- package/src/updates.ts +400 -0
- package/src/verify.ts +492 -0
package/src/hot.ts
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restart-free installs: mount a freshly installed plugin into the running
|
|
3
|
+
* composition through a market-owned Include subtree.
|
|
4
|
+
*
|
|
5
|
+
* Durable state stays with the profile's `dsh.profile.bundles` (reconciled by
|
|
6
|
+
* the dsh CLI at install time), so the next boot loads the plugin through the
|
|
7
|
+
* normal bundle layer. The subtree here exists only for the current process:
|
|
8
|
+
* its input files live under `<profile>/.dsh-market/` and are wiped on every
|
|
9
|
+
* boot, so a crash can never leave a file that collides with the bundle layer
|
|
10
|
+
* (inserting an id the bundle layer also inserts is a hard boot failure).
|
|
11
|
+
* `state.json` in the same directory is the market's own durable state
|
|
12
|
+
* (disable list + custom groups) and deliberately survives the wipe.
|
|
13
|
+
*
|
|
14
|
+
* The Include subclass suppresses `write()` — the loader otherwise persists
|
|
15
|
+
* tree changes back to the file it read (see dsh's agent-presets PresetTree
|
|
16
|
+
* for the in-tree precedent).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import { pathToFileURL } from 'node:url'
|
|
22
|
+
import { asChannel, type Channel } from './channels.ts'
|
|
23
|
+
import { asRegion, normalizeGithubProxy, type Region } from './regions.ts'
|
|
24
|
+
import { logEvent } from './log.ts'
|
|
25
|
+
import { compatibilityEnv } from './home-paths.ts'
|
|
26
|
+
|
|
27
|
+
interface HotRow {
|
|
28
|
+
id: string
|
|
29
|
+
name: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface PluginHandle {
|
|
33
|
+
await(): Promise<unknown>
|
|
34
|
+
dispose(): Promise<unknown> | void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface HotContext {
|
|
38
|
+
plugin(plugin: unknown, config: unknown): PluginHandle
|
|
39
|
+
logger?: { info?(message: string): void; warn(message: string): void }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const HOT_DIR = '.dsh-market'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Ceiling for one hot-mount activation, env-overridable like the install
|
|
46
|
+
* timeout in dsh-cli.ts. An activation that exceeds it is treated as wedged
|
|
47
|
+
* (typically a plugin pending on a service nothing provides) and falls back
|
|
48
|
+
* to restart activation.
|
|
49
|
+
*/
|
|
50
|
+
const HOT_MOUNT_TIMEOUT_MS = Number(compatibilityEnv(
|
|
51
|
+
process.env,
|
|
52
|
+
'CHATCODE_CLI_MARKET_HOT_MOUNT_TIMEOUT_MS',
|
|
53
|
+
'DSH_MARKET_HOT_MOUNT_TIMEOUT_MS',
|
|
54
|
+
)) || 10000
|
|
55
|
+
|
|
56
|
+
let hotTreeClass: unknown | null | undefined
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The Include subclass, built once per process; null when the loader's include
|
|
60
|
+
* plugin is not importable (older harness) — callers fall back to restart.
|
|
61
|
+
*/
|
|
62
|
+
/**
|
|
63
|
+
* Packages whose host import is replaced by a no-op shim. Client-only plugins
|
|
64
|
+
* (`dsh.client` without `dsh.bundle`) have no importable host half, but
|
|
65
|
+
* client-modules only serves bundles for packages with a live loader entry —
|
|
66
|
+
* the shim fiber exists purely to satisfy that registration.
|
|
67
|
+
*/
|
|
68
|
+
const shimNames = new Set<string>()
|
|
69
|
+
|
|
70
|
+
async function loadHotTreeClass(): Promise<unknown | null> {
|
|
71
|
+
if (hotTreeClass !== undefined) return hotTreeClass
|
|
72
|
+
try {
|
|
73
|
+
// Computed specifier: the include plugin ships with the harness (vendored,
|
|
74
|
+
// unpublished), so it resolves at runtime through the profile fallback but
|
|
75
|
+
// is not typecheckable as a dependency.
|
|
76
|
+
const specifier = '@deepseek-ai/cordis-plugin-include'
|
|
77
|
+
const mod = (await import(specifier)) as {
|
|
78
|
+
Include?: new (...args: never[]) => {
|
|
79
|
+
write(): void
|
|
80
|
+
import(name: string, getOuterStack?: () => string[]): unknown
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const Include = mod.Include
|
|
84
|
+
if (Include === undefined) throw new Error('no Include export')
|
|
85
|
+
class MarketHotTree extends Include {
|
|
86
|
+
/** Runtime-only mount list; the bundle layer owns persistence. */
|
|
87
|
+
override write(): void {}
|
|
88
|
+
override import(name: string, getOuterStack?: () => string[]): unknown {
|
|
89
|
+
if (shimNames.has(name)) return { name, apply: () => {} }
|
|
90
|
+
return super.import(name, getOuterStack)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
hotTreeClass = MarketHotTree
|
|
94
|
+
} catch {
|
|
95
|
+
hotTreeClass = null
|
|
96
|
+
}
|
|
97
|
+
return hotTreeClass
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The `dsh` declaration block of an installed package, or null when unreadable. */
|
|
101
|
+
function readPkgDsh(profileDir: string, packageName: string): { client?: unknown; bundle?: unknown } | null {
|
|
102
|
+
try {
|
|
103
|
+
const manifest = JSON.parse(
|
|
104
|
+
readFileSync(join(profileDir, 'node_modules', packageName, 'package.json'), 'utf8'),
|
|
105
|
+
) as { dsh?: { client?: unknown; bundle?: unknown } }
|
|
106
|
+
return manifest.dsh ?? {}
|
|
107
|
+
} catch {
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Insert rows of a plugin's bundle patch, or null when the patch contains
|
|
114
|
+
* anything beyond plain `id`/`name` insert rows (config blocks, disables,
|
|
115
|
+
* expressions) — those compositions fall back to restart activation.
|
|
116
|
+
*/
|
|
117
|
+
export function parseSimplePatch(patchText: string): HotRow[] | null {
|
|
118
|
+
const rows: HotRow[] = []
|
|
119
|
+
let pending: string | null = null
|
|
120
|
+
// Split on CRLF too. `#.*$` cannot strip a comment that ends in \r —
|
|
121
|
+
// JS treats \r as a line terminator, so `.` will not cross it and `$`
|
|
122
|
+
// only anchors at the very end — leaving the comment text in place,
|
|
123
|
+
// matching none of the row shapes, and failing the whole patch. Any
|
|
124
|
+
// plugin whose patch was authored on Windows then reads as
|
|
125
|
+
// "contains config/expression rows" and can never hot-mount.
|
|
126
|
+
for (const raw of patchText.split(/\r?\n/)) {
|
|
127
|
+
const line = raw.replace(/#.*$/, '').trimEnd()
|
|
128
|
+
if (line.trim() === '') continue
|
|
129
|
+
if (/^-\s+insert:\s*$/.test(line)) continue
|
|
130
|
+
const id = /^\s+-\s+id:\s*(\S+)\s*$/.exec(line)
|
|
131
|
+
if (id !== null) {
|
|
132
|
+
if (pending !== null) return null
|
|
133
|
+
pending = id[1]
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
const name = /^\s+name:\s*['"]?([^'"\s]+)['"]?\s*$/.exec(line)
|
|
137
|
+
if (name !== null && pending !== null) {
|
|
138
|
+
rows.push({ id: pending, name: name[1] })
|
|
139
|
+
pending = null
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
if (pending !== null || rows.length === 0) return null
|
|
145
|
+
return rows
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Wipe leftover hot-mount inputs; call once when the market host starts.
|
|
150
|
+
* `state.json` (disable choices + groups) deliberately survives.
|
|
151
|
+
*/
|
|
152
|
+
export function cleanHotDir(profileDir: string): void {
|
|
153
|
+
const dir = join(profileDir, HOT_DIR)
|
|
154
|
+
let entries: string[]
|
|
155
|
+
try {
|
|
156
|
+
entries = readdirSync(dir)
|
|
157
|
+
} catch {
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
for (const name of entries) {
|
|
161
|
+
if (/^hot-\d+\.yml$/.test(name)) rmSync(join(dir, name), { force: true })
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function stateFile(profileDir: string): string {
|
|
166
|
+
return join(profileDir, HOT_DIR, 'state.json')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Persisted market state: the generic disable list plus custom groups. */
|
|
170
|
+
export interface MarketState {
|
|
171
|
+
/** Plugins the user switched off; replayed at every boot. */
|
|
172
|
+
disabled: Set<string>
|
|
173
|
+
/** User-defined plugin groups: group name → member package names. */
|
|
174
|
+
groups: Record<string, string[]>
|
|
175
|
+
/** Display order of group names; "ungrouped" is implicit and never listed. */
|
|
176
|
+
groupOrder: string[]
|
|
177
|
+
/**
|
|
178
|
+
* The user's own one-line note per installed plugin (#347).
|
|
179
|
+
*
|
|
180
|
+
* A catalog description answers "what is this", written by its author for
|
|
181
|
+
* strangers and often in a language the reader did not pick. It cannot
|
|
182
|
+
* answer "why did I install this" — which is the question someone with
|
|
183
|
+
* forty plugins is actually asking. So a note REPLACES the description on
|
|
184
|
+
* that row, and the original stays one click away.
|
|
185
|
+
*
|
|
186
|
+
* Local state like the disable list and the groups beside it: never sent
|
|
187
|
+
* anywhere, and carried by a backup because it is part of how this profile
|
|
188
|
+
* is set up.
|
|
189
|
+
*
|
|
190
|
+
* Optional on the way IN: several callers build a state object from the
|
|
191
|
+
* few fields they own and hand it to writeMarketState. Requiring this one
|
|
192
|
+
* would make every such call a silent way to erase every note — the exact
|
|
193
|
+
* shape of #339, where a partial snapshot dropped a field nobody was
|
|
194
|
+
* thinking about. Omitting it means "leave them alone" instead.
|
|
195
|
+
*/
|
|
196
|
+
notes?: Record<string, string>
|
|
197
|
+
/**
|
|
198
|
+
* The release channel the user PICKED, absent until they pick one.
|
|
199
|
+
*
|
|
200
|
+
* Absent is not the same as 'stable': with no choice on record the channel
|
|
201
|
+
* is derived from the running build, so installing a prerelease by hand
|
|
202
|
+
* puts you on the beta channel without a second step. Once chosen, the
|
|
203
|
+
* choice is the answer — including "stable" while a beta is running, which
|
|
204
|
+
* is how someone gets back off the channel.
|
|
205
|
+
*/
|
|
206
|
+
channel?: Channel
|
|
207
|
+
/**
|
|
208
|
+
* The download region in force, absent until something has decided one.
|
|
209
|
+
*
|
|
210
|
+
* Absent means "nobody has decided yet", which is what triggers the
|
|
211
|
+
* one-time network probe. Once a value is here — whether the probe wrote
|
|
212
|
+
* it or the user picked it — no further probing happens, so the market
|
|
213
|
+
* does not silently change routes between runs.
|
|
214
|
+
*/
|
|
215
|
+
region?: Region
|
|
216
|
+
/**
|
|
217
|
+
* Whether `region` was chosen by the probe rather than by the user.
|
|
218
|
+
*
|
|
219
|
+
* Only drives a one-time notice explaining why the market picked what it
|
|
220
|
+
* picked. A user who never learns a route was chosen for them has no way
|
|
221
|
+
* to know the setting exists, and no reason to look for it when something
|
|
222
|
+
* downloads oddly.
|
|
223
|
+
*/
|
|
224
|
+
regionAuto?: boolean
|
|
225
|
+
/**
|
|
226
|
+
* Catalog entry URLs the user bookmarked for later install (#414).
|
|
227
|
+
* Keys are registry `url` strings, not package names — favorites are a
|
|
228
|
+
* pre-install list, unlike groups/notes which target installed packages.
|
|
229
|
+
*/
|
|
230
|
+
favorites?: string[]
|
|
231
|
+
/** User-supplied HTTPS prefix used when the built-in GitHub routes fail. */
|
|
232
|
+
githubProxy?: string
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Unique non-empty strings in `value`, order preserved. */
|
|
236
|
+
function uniqueStrings(value: unknown): string[] {
|
|
237
|
+
if (!Array.isArray(value)) return []
|
|
238
|
+
const seen = new Set<string>()
|
|
239
|
+
const out: string[] = []
|
|
240
|
+
for (const item of value) {
|
|
241
|
+
if (typeof item !== 'string' || item === '' || seen.has(item)) continue
|
|
242
|
+
seen.add(item)
|
|
243
|
+
out.push(item)
|
|
244
|
+
}
|
|
245
|
+
return out
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Upper bound on bookmarked catalog URLs kept in state.json (#414). */
|
|
249
|
+
export const MAX_FAVORITES = 500
|
|
250
|
+
|
|
251
|
+
/** Catalog URLs the user may favorite; http(s) only, order preserved. */
|
|
252
|
+
function favoriteUrls(value: unknown): string[] {
|
|
253
|
+
return uniqueStrings(value).filter(url => url.startsWith('http://') || url.startsWith('https://'))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Read the whole market state. Legacy `disabledSkins` (the pre-#60
|
|
258
|
+
* theme-only key) still loads; every new write uses the generic `disabled`
|
|
259
|
+
* key (#60).
|
|
260
|
+
*/
|
|
261
|
+
/** A note is a label, not a document: one line, bounded so state.json cannot
|
|
262
|
+
* grow without limit from a paste. */
|
|
263
|
+
export const MAX_NOTE = 200
|
|
264
|
+
|
|
265
|
+
export function readMarketState(profileDir: string): MarketState {
|
|
266
|
+
try {
|
|
267
|
+
const state = JSON.parse(readFileSync(stateFile(profileDir), 'utf8')) as {
|
|
268
|
+
disabled?: unknown
|
|
269
|
+
disabledSkins?: unknown
|
|
270
|
+
groups?: unknown
|
|
271
|
+
groupOrder?: unknown
|
|
272
|
+
channel?: unknown
|
|
273
|
+
region?: unknown
|
|
274
|
+
regionAuto?: unknown
|
|
275
|
+
githubProxy?: unknown
|
|
276
|
+
notes?: unknown
|
|
277
|
+
favorites?: unknown
|
|
278
|
+
}
|
|
279
|
+
const disabled = uniqueStrings(state.disabled !== undefined ? state.disabled : state.disabledSkins)
|
|
280
|
+
const groups: Record<string, string[]> = {}
|
|
281
|
+
if (state.groups !== null && typeof state.groups === 'object' && !Array.isArray(state.groups)) {
|
|
282
|
+
for (const [name, members] of Object.entries(state.groups)) {
|
|
283
|
+
groups[name] = uniqueStrings(members)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const notes: Record<string, string> = {}
|
|
287
|
+
if (state.notes !== null && typeof state.notes === 'object' && !Array.isArray(state.notes)) {
|
|
288
|
+
for (const [name, text] of Object.entries(state.notes)) {
|
|
289
|
+
// Empty is the same as absent: clearing a note must not leave a row
|
|
290
|
+
// claiming to carry one.
|
|
291
|
+
if (typeof text === 'string' && text.trim() !== '') notes[name] = text.slice(0, MAX_NOTE)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const githubProxy = normalizeGithubProxy(state.githubProxy)
|
|
295
|
+
return {
|
|
296
|
+
disabled: new Set(disabled),
|
|
297
|
+
groups,
|
|
298
|
+
notes,
|
|
299
|
+
groupOrder: uniqueStrings(state.groupOrder),
|
|
300
|
+
channel: asChannel(state.channel) ?? undefined,
|
|
301
|
+
region: asRegion(state.region) ?? undefined,
|
|
302
|
+
// Only meaningful beside a region, and only when true: a stray flag
|
|
303
|
+
// with no region would promise a notice about a choice nobody made.
|
|
304
|
+
regionAuto: state.regionAuto === true && asRegion(state.region) !== null ? true : undefined,
|
|
305
|
+
favorites: favoriteUrls(state.favorites),
|
|
306
|
+
...(githubProxy === null ? {} : { githubProxy }),
|
|
307
|
+
}
|
|
308
|
+
} catch {
|
|
309
|
+
return { disabled: new Set(), groups: {}, groupOrder: [], notes: {}, favorites: [] }
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Persist the whole market state.
|
|
315
|
+
*
|
|
316
|
+
* Every field a caller does not carry forward is taken from disk rather than
|
|
317
|
+
* dropped. Several callers legitimately know about only one part of the
|
|
318
|
+
* state — `writeMarketState(dir, { disabled, groups, groupOrder })` appears
|
|
319
|
+
* at five call sites in routes.ts — and before #435 that shape silently
|
|
320
|
+
* erased whatever else the user had chosen:
|
|
321
|
+
*
|
|
322
|
+
* - `channel` and `region` had no fallback at all, so toggling any plugin
|
|
323
|
+
* threw away the user's update channel and download region. Both are
|
|
324
|
+
* deliberate choices made through the settings card, and neither has a
|
|
325
|
+
* "clear it" path: once picked they only ever move to another value. So
|
|
326
|
+
* an absent one always means "the caller has nothing to say", never
|
|
327
|
+
* "the user unchose it".
|
|
328
|
+
* `notes` keeps its original rule — an explicit object wins, including an
|
|
329
|
+
* empty one, because deleting the last note has to be expressible. What made
|
|
330
|
+
* #435 lose notes was not this function but a caller: the note route wrote
|
|
331
|
+
* through a fresh read while the long-lived `marketState` in routes.ts still
|
|
332
|
+
* carried `notes: {}` from boot, and the next write from that object put the
|
|
333
|
+
* empty one back. The fix for that belongs at the call site, where the two
|
|
334
|
+
* copies are, not here — see the note route.
|
|
335
|
+
*
|
|
336
|
+
* Reading before writing costs one small JSON parse on an operation that is
|
|
337
|
+
* already doing filesystem work, and it is what makes "this function writes
|
|
338
|
+
* the whole document" safe for callers that only hold part of it.
|
|
339
|
+
*/
|
|
340
|
+
export function writeMarketState(profileDir: string, state: MarketState): void {
|
|
341
|
+
mkdirSync(join(profileDir, HOT_DIR), { recursive: true, mode: 0o700 })
|
|
342
|
+
const onDisk = readMarketState(profileDir)
|
|
343
|
+
// `?? {}` only for the type: MarketState declares notes optional, while
|
|
344
|
+
// readMarketState always returns an object.
|
|
345
|
+
const notes = state.notes ?? onDisk.notes ?? {}
|
|
346
|
+
const channel = state.channel ?? onDisk.channel
|
|
347
|
+
const region = state.region ?? onDisk.region
|
|
348
|
+
// Unlike channel and region, regionAuto has an explicit clear path: a
|
|
349
|
+
// manual region choice owns this field and sets it to undefined. Preserve
|
|
350
|
+
// the disk value only when the caller omitted the property entirely.
|
|
351
|
+
const regionAuto = Object.prototype.hasOwnProperty.call(state, 'regionAuto')
|
|
352
|
+
? state.regionAuto
|
|
353
|
+
: onDisk.regionAuto
|
|
354
|
+
const favorites = state.favorites ?? onDisk.favorites ?? []
|
|
355
|
+
// This field does have a clear action ("restore automatic"). As with
|
|
356
|
+
// regionAuto, omission preserves while an explicit undefined removes it.
|
|
357
|
+
const githubProxy = Object.prototype.hasOwnProperty.call(state, 'githubProxy')
|
|
358
|
+
? state.githubProxy
|
|
359
|
+
: onDisk.githubProxy
|
|
360
|
+
writeFileSync(stateFile(profileDir), JSON.stringify({
|
|
361
|
+
disabled: [...state.disabled],
|
|
362
|
+
groups: state.groups,
|
|
363
|
+
groupOrder: state.groupOrder,
|
|
364
|
+
...(favorites.length > 0 ? { favorites } : {}),
|
|
365
|
+
...(Object.keys(notes).length > 0 ? { notes } : {}),
|
|
366
|
+
// Omitted while unchosen, so "never picked" survives a round trip and
|
|
367
|
+
// keeps deriving from the running build — but only when disk has not
|
|
368
|
+
// recorded a choice either.
|
|
369
|
+
...(channel === undefined ? {} : { channel }),
|
|
370
|
+
// Same reasoning, different consequence: an absent region is what makes
|
|
371
|
+
// the probe run, so writing a default here would mean it never does.
|
|
372
|
+
...(region === undefined ? {} : { region }),
|
|
373
|
+
...(regionAuto === true ? { regionAuto: true } : {}),
|
|
374
|
+
...(githubProxy === undefined ? {} : { githubProxy }),
|
|
375
|
+
}))
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Plugins the user switched off; skipped by the boot re-mount. */
|
|
379
|
+
export function readDisabled(profileDir: string): Set<string> {
|
|
380
|
+
return readMarketState(profileDir).disabled
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Persist just the disable list, preserving groups and order. */
|
|
384
|
+
export function writeDisabled(profileDir: string, disabled: Set<string>): void {
|
|
385
|
+
const state = readMarketState(profileDir)
|
|
386
|
+
state.disabled = new Set(disabled)
|
|
387
|
+
writeMarketState(profileDir, state)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** @deprecated theme-specific alias — kept for pre-#60 callers. */
|
|
391
|
+
export function readDisabledThemes(profileDir: string): Set<string> {
|
|
392
|
+
return readDisabled(profileDir)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** @deprecated theme-specific alias — kept for pre-#60 callers. */
|
|
396
|
+
export function writeDisabledThemes(profileDir: string, disabled: Set<string>): void {
|
|
397
|
+
writeDisabled(profileDir, disabled)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Package names currently live through a market hot mount (patch or shim). */
|
|
401
|
+
export function listHotMounts(): string[] {
|
|
402
|
+
return [...hotHandles.keys()]
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
let hotSequence = 0
|
|
406
|
+
|
|
407
|
+
const hotHandles = new Map<string, PluginHandle>()
|
|
408
|
+
|
|
409
|
+
/** Activation did not settle within HOT_MOUNT_TIMEOUT_MS. */
|
|
410
|
+
class ActivationTimeout extends Error {}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Race an activation awaitable against the hot-mount ceiling. The handlers
|
|
414
|
+
* stay attached to the original promise, so a late rejection after a timeout
|
|
415
|
+
* can never surface as an unhandled rejection.
|
|
416
|
+
*/
|
|
417
|
+
function raceActivationTimeout<T>(awaitable: T | Promise<T>): Promise<T> {
|
|
418
|
+
return new Promise<T>((resolve, reject) => {
|
|
419
|
+
const timer = setTimeout(() => {
|
|
420
|
+
reject(new ActivationTimeout(`activation did not settle within ${HOT_MOUNT_TIMEOUT_MS / 1000}s — the plugin may be waiting on a service that never arrives`))
|
|
421
|
+
}, HOT_MOUNT_TIMEOUT_MS)
|
|
422
|
+
Promise.resolve(awaitable).then(
|
|
423
|
+
value => { clearTimeout(timer); resolve(value) },
|
|
424
|
+
error => { clearTimeout(timer); reject(error) },
|
|
425
|
+
)
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Outcome of one hot-mount attempt; `reason` explains non-`ok` results. */
|
|
430
|
+
export interface HotMountResult {
|
|
431
|
+
ok: boolean
|
|
432
|
+
/** Bilingual reason shown to the user instead of a bare restart banner. */
|
|
433
|
+
reason: string | null
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Dispose a plugin hot-mounted earlier in this session, removing it from the
|
|
438
|
+
* running composition immediately.
|
|
439
|
+
* @param packageName - package to unmount.
|
|
440
|
+
* @returns true when a live hot mount was found and disposed.
|
|
441
|
+
*/
|
|
442
|
+
export async function hotUnmount(packageName: string): Promise<boolean> {
|
|
443
|
+
const handle = hotHandles.get(packageName)
|
|
444
|
+
if (handle === undefined) return false
|
|
445
|
+
hotHandles.delete(packageName)
|
|
446
|
+
shimNames.delete(packageName)
|
|
447
|
+
try {
|
|
448
|
+
await handle.dispose()
|
|
449
|
+
logEvent('info', 'hot-unmount', `${packageName}: removed live`)
|
|
450
|
+
return true
|
|
451
|
+
} catch (error) {
|
|
452
|
+
logEvent('warn', 'hot-unmount', `${packageName}: dispose failed — ${error instanceof Error ? error.message : String(error)}`)
|
|
453
|
+
return false
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Mount `packageName` (just installed into the profile) into the running
|
|
459
|
+
* composition.
|
|
460
|
+
* @param ctx - market host context; the subtree unwinds with the market's fiber.
|
|
461
|
+
* @param profileDir - profile the package was installed into.
|
|
462
|
+
* @param packageName - installed package to activate.
|
|
463
|
+
* @returns whether the plugin is live without a restart, plus the reason
|
|
464
|
+
* when it is not (P0-2: the UI must distinguish "restart will fix it" from
|
|
465
|
+
* "this package can never hot-mount").
|
|
466
|
+
*/
|
|
467
|
+
export async function hotMount(ctx: HotContext, profileDir: string, packageName: string): Promise<HotMountResult> {
|
|
468
|
+
try {
|
|
469
|
+
const HotTree = await loadHotTreeClass()
|
|
470
|
+
if (HotTree === null) {
|
|
471
|
+
return {
|
|
472
|
+
ok: false,
|
|
473
|
+
reason: '宿主不支持热挂载(include 插件不可导入),需重启 / the host cannot hot-mount (include plugin unavailable); restart required',
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
let patchText: string | null
|
|
477
|
+
try {
|
|
478
|
+
patchText = readFileSync(
|
|
479
|
+
join(profileDir, 'node_modules', packageName, 'cordis.patch.yml'),
|
|
480
|
+
'utf8',
|
|
481
|
+
)
|
|
482
|
+
} catch {
|
|
483
|
+
patchText = null
|
|
484
|
+
}
|
|
485
|
+
let rows: HotRow[] | null
|
|
486
|
+
if (patchText !== null) {
|
|
487
|
+
rows = parseSimplePatch(patchText)
|
|
488
|
+
if (rows === null) {
|
|
489
|
+
return {
|
|
490
|
+
ok: false,
|
|
491
|
+
reason: 'bundle patch 含配置行/表达式,热挂载仅支持纯 insert,重启后生效 / the bundle patch contains config/expression rows; hot-mount only supports plain inserts — it activates on restart',
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
} else {
|
|
495
|
+
// No host patch. Client-only packages (dsh.client, no dsh.bundle) never
|
|
496
|
+
// get a loader entry — not even after a restart — so client-modules
|
|
497
|
+
// would never serve their bundle. A shim entry under the package's name
|
|
498
|
+
// is the whole activation.
|
|
499
|
+
const dsh = readPkgDsh(profileDir, packageName)
|
|
500
|
+
if (dsh === null || dsh.client === undefined || dsh.bundle !== undefined) {
|
|
501
|
+
return {
|
|
502
|
+
ok: false,
|
|
503
|
+
reason: '该包无 bundle patch 且未声明 dsh.client,没有可热挂载的内容 / no bundle patch and no dsh.client surface — nothing to hot-mount',
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
shimNames.add(packageName)
|
|
507
|
+
rows = [{ id: `client-${packageName.replace(/[^A-Za-z0-9_.-]/g, '-')}`, name: packageName }]
|
|
508
|
+
}
|
|
509
|
+
const dir = join(profileDir, HOT_DIR)
|
|
510
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
511
|
+
hotSequence += 1
|
|
512
|
+
const file = join(dir, `hot-${String(hotSequence)}.yml`)
|
|
513
|
+
const yml = rows
|
|
514
|
+
.map(row => `- id: 'mkt-${row.id}'\n name: '${row.name}'\n`)
|
|
515
|
+
.join('')
|
|
516
|
+
writeFileSync(file, yml)
|
|
517
|
+
const handle = ctx.plugin(HotTree, { path: pathToFileURL(file).href })
|
|
518
|
+
try {
|
|
519
|
+
await raceActivationTimeout(handle.await())
|
|
520
|
+
} catch (error) {
|
|
521
|
+
if (error instanceof ActivationTimeout) {
|
|
522
|
+
// A wedged activation would otherwise hold this request open forever:
|
|
523
|
+
// the route's `finally { installing = false }` never runs, so every
|
|
524
|
+
// later install/update/uninstall gets 409'd until a host restart.
|
|
525
|
+
// Unwind the half-mounted subtree best-effort; disposal never blocks
|
|
526
|
+
// the reply, and the caller falls back to restart activation.
|
|
527
|
+
try { Promise.resolve(handle.dispose()).catch(() => {}) } catch { /* best effort */ }
|
|
528
|
+
}
|
|
529
|
+
throw error
|
|
530
|
+
}
|
|
531
|
+
hotHandles.set(packageName, handle)
|
|
532
|
+
ctx.logger?.info?.(`[ChatCode CLI Market] hot-mounted ${packageName}`)
|
|
533
|
+
logEvent('info', 'hot-mount', `${packageName}: live${shimNames.has(packageName) ? ' (client-only shim)' : ''}`)
|
|
534
|
+
return { ok: true, reason: null }
|
|
535
|
+
} catch (error) {
|
|
536
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
537
|
+
ctx.logger?.warn(`[ChatCode CLI Market] hot mount of ${packageName} failed, restart required: ${message}`)
|
|
538
|
+
logEvent('warn', 'hot-mount', `${packageName}: fell back to restart — ${message}`)
|
|
539
|
+
return { ok: false, reason: `热挂载失败,重启后生效 — ${message} / hot-mount failed — restart required: ${message}` }
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Mount every installed client-only package (`dsh.client` without
|
|
545
|
+
* `dsh.bundle`) at market startup. The bundle reconcile skips these packages
|
|
546
|
+
* entirely, so without the market's shim their client bundles are unreachable
|
|
547
|
+
* in every boot — this is what makes them behave like normal plugins.
|
|
548
|
+
* @returns names that were mounted.
|
|
549
|
+
*/
|
|
550
|
+
export async function mountClientOnlyDeps(ctx: HotContext, profileDir: string): Promise<string[]> {
|
|
551
|
+
let deps: string[]
|
|
552
|
+
try {
|
|
553
|
+
const manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as {
|
|
554
|
+
dependencies?: Record<string, string>
|
|
555
|
+
dsh?: { profile?: { bundles?: string[] } }
|
|
556
|
+
}
|
|
557
|
+
const bundles = new Set(manifest.dsh?.profile?.bundles ?? [])
|
|
558
|
+
deps = Object.keys(manifest.dependencies ?? {}).filter(name => !bundles.has(name))
|
|
559
|
+
} catch {
|
|
560
|
+
return []
|
|
561
|
+
}
|
|
562
|
+
const disabled = readDisabled(profileDir)
|
|
563
|
+
const userManaged = readUserPatchControls(profileDir)
|
|
564
|
+
const mounted: string[] = []
|
|
565
|
+
for (const name of deps) {
|
|
566
|
+
if (hotHandles.has(name) || disabled.has(name)) continue
|
|
567
|
+
// Packages the USER's patch layer already manages (insert or disable
|
|
568
|
+
// rows in cordis.patch.yml, e.g. via dsh-web-plugin-manager) are theirs
|
|
569
|
+
// to control — a shim here would override a user's "disabled" choice on
|
|
570
|
+
// every restart (#58 by @vikna919).
|
|
571
|
+
if (patchLayerManages(userManaged, name)) continue
|
|
572
|
+
const dsh = readPkgDsh(profileDir, name)
|
|
573
|
+
if (dsh === null || dsh.client === undefined || dsh.bundle !== undefined) continue
|
|
574
|
+
if ((await hotMount(ctx, profileDir, name)).ok) mounted.push(name)
|
|
575
|
+
}
|
|
576
|
+
return mounted
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Row ids and package names the user's own patch layer (cordis.patch.yml)
|
|
581
|
+
* already contains. Line-wise scan on purpose: the file may hold structures
|
|
582
|
+
* the market's strict patch parser rejects, but any mention of a row id or
|
|
583
|
+
* package name is enough to know the user manages it (#58).
|
|
584
|
+
*/
|
|
585
|
+
export function readUserPatchControls(profileDir: string): { ids: Set<string>; names: Set<string> } {
|
|
586
|
+
const ids = new Set<string>()
|
|
587
|
+
const names = new Set<string>()
|
|
588
|
+
try {
|
|
589
|
+
const text = readFileSync(join(profileDir, 'cordis.patch.yml'), 'utf8')
|
|
590
|
+
for (const line of text.split(/\r?\n/)) {
|
|
591
|
+
const id = /^\s*-?\s*id:\s*['"]?([A-Za-z0-9._/@-]+)/.exec(line)
|
|
592
|
+
if (id !== null) ids.add(id[1])
|
|
593
|
+
const name = /^\s*name:\s*['"]?([^'"\s]+)/.exec(line)
|
|
594
|
+
if (name !== null) names.add(name[1])
|
|
595
|
+
}
|
|
596
|
+
} catch { /* no user patch file — nothing is user-managed */ }
|
|
597
|
+
return { ids, names }
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Whether the user patch layer manages `name` — matched by exact package
|
|
602
|
+
* name or by the plugin-manager row-id convention (strip the leading @,
|
|
603
|
+
* non-alphanumerics to '-', lowercase).
|
|
604
|
+
*/
|
|
605
|
+
export function patchLayerManages(controls: { ids: Set<string>; names: Set<string> }, name: string): boolean {
|
|
606
|
+
const rowId = name.replace(/^@/, '').replace(/[^a-z0-9-]/gi, '-').toLowerCase()
|
|
607
|
+
return controls.ids.has(rowId) || controls.names.has(name)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Delete the market's own state directory.
|
|
613
|
+
*
|
|
614
|
+
* `cleanHotDir` wipes the ephemeral hot-mount inputs on every boot but
|
|
615
|
+
* deliberately preserves `state.json` — the disable list and custom groups
|
|
616
|
+
* are the user's durable choices. Uninstalling the market is the one moment
|
|
617
|
+
* where removing them is the right thing, and only when the user asked.
|
|
618
|
+
* @returns true when a directory was there to remove.
|
|
619
|
+
*/
|
|
620
|
+
export function purgeMarketState(profileDir: string): boolean {
|
|
621
|
+
const dir = join(profileDir, HOT_DIR)
|
|
622
|
+
try {
|
|
623
|
+
rmSync(dir, { recursive: true, force: true })
|
|
624
|
+
return true
|
|
625
|
+
} catch {
|
|
626
|
+
return false
|
|
627
|
+
}
|
|
628
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal HTTP helpers shared by every market route: JSON serialization,
|
|
3
|
+
* same-origin enforcement for mutating endpoints, and a size-capped JSON
|
|
4
|
+
* body reader.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
8
|
+
|
|
9
|
+
/** Write a JSON payload with no-store caching. */
|
|
10
|
+
export function sendJson(response: ServerResponse, status: number, payload: unknown): void {
|
|
11
|
+
response.writeHead(status, {
|
|
12
|
+
'cache-control': 'no-store',
|
|
13
|
+
'content-type': 'application/json; charset=utf-8',
|
|
14
|
+
})
|
|
15
|
+
response.end(JSON.stringify(payload))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** True when the request's Origin matches its Host — required on every POST route. */
|
|
19
|
+
export function sameOrigin(request: IncomingMessage): boolean {
|
|
20
|
+
const origin = request.headers.origin
|
|
21
|
+
const host = request.headers.host
|
|
22
|
+
if (origin === undefined || host === undefined) return false
|
|
23
|
+
try {
|
|
24
|
+
return new URL(origin).host === host
|
|
25
|
+
} catch {
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Read and parse a JSON request body, rejecting anything over 4 KiB. */
|
|
31
|
+
export async function readJsonBody(request: IncomingMessage, maxBytes = 4096): Promise<unknown> {
|
|
32
|
+
const chunks: Buffer[] = []
|
|
33
|
+
let size = 0
|
|
34
|
+
for await (const chunk of request) {
|
|
35
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
36
|
+
size += buffer.length
|
|
37
|
+
if (size > maxBytes) throw new Error('request body too large')
|
|
38
|
+
chunks.push(buffer)
|
|
39
|
+
}
|
|
40
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown
|
|
41
|
+
}
|