@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,141 @@
1
+ /**
2
+ * The market's own settings namespace: the half that makes `allowRestart`
3
+ * a switch on the plugin configuration page instead of a line the user has
4
+ * to hand-write into cordis.yml.
5
+ *
6
+ * `allowRestart: false` is the documented answer for a host owned by
7
+ * systemd, launchd or pm2 — a supervisor restarts it, so the market's
8
+ * one-click restart must not launch a second one. Until now the only way to
9
+ * say that was editing YAML in the right place with the right indentation,
10
+ * where a stray space stops the profile booting.
11
+ *
12
+ * Only `allowRestart` is exposed. `profile` names which profile this
13
+ * instance manages: it is decided at mount from the composition or the
14
+ * command line, and a running instance cannot switch to another one, so
15
+ * offering it as a field would promise something the write cannot deliver.
16
+ *
17
+ * The release channel is NOT here either, and that is a correction rather
18
+ * than an omission. It was, briefly, and it made this namespace a second
19
+ * writer for a value the market already stores in its own state.json: the
20
+ * mount read the user's saved channel off disk, then `onChange` assigned
21
+ * `source().channel` — which knows nothing about that file — straight back
22
+ * over it. The choice survived exactly until the next settings event.
23
+ *
24
+ * Only a real host could show that; the unit lane mounts the routes without
25
+ * this layer at all. `allowRestart` needs this door because its only other
26
+ * one is hand-edited YAML. The channel has a control of its own on the
27
+ * plugin configuration page, so a second door bought nothing and cost the
28
+ * setting its memory.
29
+ *
30
+ * The wiring rides the scoped fiber, so a host with no settings service —
31
+ * every dsh before 0.1.0-rc.7 — simply never runs any of this and the entry
32
+ * configuration stands as composed. That is why this needs no version check
33
+ * of its own.
34
+ *
35
+ * It depends on the SERVICE and nothing else, which is the whole point of
36
+ * the shape below. This module used to import two convenience helpers,
37
+ * `installSettingsSection` and `settingsNamespace`, from
38
+ * `@deepseek-ai/dsh-settings`. dsh 0.1.2-alpha.1 deleted both — and a
39
+ * missing NAMED EXPORT is not a missing service. `ctx.inject` degrades
40
+ * quietly; an ESM named import that resolves to nothing is a SyntaxError at
41
+ * module evaluation, which cordis's loader reports as a failed entry and the
42
+ * host exits 1. Installing the market stopped the host from booting at all:
43
+ *
44
+ * SyntaxError: The requested module '@deepseek-ai/dsh-settings' does not
45
+ * provide an export named 'installSettingsSection'
46
+ *
47
+ * The service itself never changed — `sctx.settings.register(ns, schema,
48
+ * { base })` is identical in 0.1.0-rc.7 and 0.1.2-alpha.2. Only the two
49
+ * wrappers went away. So this inlines what the wrapper did (verified against
50
+ * its source: an inject, a register, a watch, and an unload effect) and
51
+ * validates the namespace here. Nothing about the graceful-degradation story
52
+ * changes; it just stops being conditional on an export that upstream is
53
+ * free to move.
54
+ */
55
+
56
+ import type { Context } from '@deepseek-ai/cordis'
57
+ import z from '@deepseek-ai/schemastery'
58
+ import { restartAllowed } from './restart.ts'
59
+
60
+ /**
61
+ * The namespace pattern `settingsNamespace` enforced before it was removed.
62
+ * Kept as a literal check rather than an import for the reason above.
63
+ */
64
+ const NAMESPACE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
65
+
66
+ /** Namespace the card on the browser side keys itself to. */
67
+ export const MARKET_SETTINGS_NS = 'dsh-market'
68
+
69
+ if (!NAMESPACE_PATTERN.test(MARKET_SETTINGS_NS)) {
70
+ throw new TypeError(`settings namespace "${MARKET_SETTINGS_NS}" must match ${String(NAMESPACE_PATTERN)}`)
71
+ }
72
+
73
+ /**
74
+ * The slice of the host's `settings` service this module uses.
75
+ *
76
+ * Declared structurally rather than imported: the package no longer exports
77
+ * a type for it on every supported host, and naming only what is called
78
+ * keeps this from breaking again when a neighbouring field moves.
79
+ */
80
+ interface SettingsScope {
81
+ get: () => MarketSettings
82
+ watch: (listener: () => void) => void
83
+ }
84
+ interface SettingsService {
85
+ register: (
86
+ ns: string,
87
+ schema: z<MarketSettings>,
88
+ options: { base: MarketSettings },
89
+ ) => SettingsScope
90
+ }
91
+
92
+ /** The market settings a user may edit at runtime. */
93
+ export interface MarketSettings {
94
+ allowRestart: boolean
95
+ }
96
+
97
+ export const MarketSettings: z<MarketSettings> = z.object({
98
+ allowRestart: z.boolean().default(true),
99
+ })
100
+
101
+ /**
102
+ * Wire the namespace so a saved change reaches the routes immediately.
103
+ *
104
+ * The routes read `allowRestart` off this object on every request (the
105
+ * status route reports the capability, the restart route enforces it), so
106
+ * updating it in place is what makes a toggle take effect without a
107
+ * restart — which would be a poor thing to require of a setting whose whole
108
+ * subject is restarting.
109
+ *
110
+ * @param ctx - the plugin context owning the wiring.
111
+ * @param resolved - the live config object the routes read.
112
+ */
113
+ export function installMarketSettings(ctx: Context, resolved: { allowRestart?: boolean }): void {
114
+ // The switch must show what the routes will actually DO, which since #229
115
+ // is not simply "unset means on": under a detected supervisor an unset
116
+ // value means off, because the supervisor owns restarts. Asking
117
+ // restartAllowed() rather than re-deriving it here is what keeps the two
118
+ // from drifting — a switch showing On beside a hidden button is the same
119
+ // class of confusion the detection exists to end.
120
+ const entry = { allowRestart: restartAllowed(resolved) }
121
+ let source = (): MarketSettings => entry
122
+ // Assigns ONLY what this namespace owns. Writing back a field the market
123
+ // stores elsewhere is how the channel lost its memory.
124
+ const apply = (): void => { resolved.allowRestart = source().allowRestart }
125
+
126
+ // `inject` is the graceful-degradation boundary: on a host with no
127
+ // settings service the callback never runs and the composed entry stands.
128
+ ctx.inject(['settings'], (scopedCtx: Context) => {
129
+ const scoped = scopedCtx as unknown as Context & { settings: SettingsService }
130
+ const scope = scoped.settings.register(MARKET_SETTINGS_NS, MarketSettings, { base: entry })
131
+ source = () => scope.get()
132
+ // Unload restores the composed entry, so a disabled section cannot leave
133
+ // the routes reading a value nobody can see or change any more.
134
+ scoped.effect(() => () => {
135
+ source = () => entry
136
+ apply()
137
+ })
138
+ apply()
139
+ scope.watch(apply)
140
+ })
141
+ }
@@ -0,0 +1,130 @@
1
+ import { marketFetch } from '../net.ts'
2
+ import type { ResolvedSkillMarketConfig, SkillListQuery, SkillListResult, SkillMarketItem } from './types.ts'
3
+
4
+ interface ApiEnvelope {
5
+ readonly code?: unknown
6
+ readonly message?: unknown
7
+ readonly data?: unknown
8
+ }
9
+
10
+ /** Client for the public skill catalog and archive endpoints. */
11
+ export class SkillMarketApi {
12
+ constructor(private readonly config: ResolvedSkillMarketConfig) {}
13
+
14
+ /** Fetch one validated catalog page. */
15
+ async list(query: SkillListQuery = {}, signal?: AbortSignal): Promise<SkillListResult> {
16
+ const page = positiveInteger(query.page, 1, 'page')
17
+ const pageSize = positiveInteger(query.pageSize, this.config.defaultPageSize, 'pageSize')
18
+ const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
19
+ for (const key of ['search', 'category', 'sort', 'tag'] as const) {
20
+ const value = query[key]
21
+ if (value !== undefined && value.trim().length > 0) params.set(key, value.trim())
22
+ }
23
+ const response = await fetchWithTimeout(
24
+ `${this.config.baseUrl}/scm/inner/skillMarketplace/open/list?${params.toString()}`,
25
+ this.config.requestTimeoutMs,
26
+ signal,
27
+ )
28
+ if (!response.ok) throw new Error(`技能列表请求失败: HTTP ${response.status}`)
29
+ const envelope = await response.json() as ApiEnvelope
30
+ if (envelope.code !== 200 && envelope.code !== '200') {
31
+ throw new Error(`技能列表请求失败: ${typeof envelope.message === 'string' ? envelope.message : `code=${String(envelope.code)}`}`)
32
+ }
33
+ return parseListResult(envelope.data)
34
+ }
35
+
36
+ /** Download a skill archive; the caller consumes and size-caps the body. */
37
+ async download(id: number, signal?: AbortSignal): Promise<Response> {
38
+ if (!Number.isSafeInteger(id) || id <= 0) throw new TypeError('skill id must be a positive integer')
39
+ const response = await fetchWithTimeout(
40
+ `${this.config.baseUrl}/scm/inner/skillMarketplace/open/download?id=${id}`,
41
+ this.config.downloadTimeoutMs,
42
+ signal,
43
+ )
44
+ if (response.status === 404) throw new Error('技能不存在或无权下载')
45
+ if (!response.ok) throw new Error(`技能下载失败: HTTP ${response.status}`)
46
+ const length = Number(response.headers.get('content-length'))
47
+ if (Number.isFinite(length) && length > this.config.maxDownloadBytes) {
48
+ throw new Error(`技能包超过 ${this.config.maxDownloadBytes} 字节限制`)
49
+ }
50
+ return response
51
+ }
52
+ }
53
+
54
+ async function fetchWithTimeout(url: string, timeoutMs: number, signal: AbortSignal | undefined): Promise<Response> {
55
+ const controller = new AbortController()
56
+ const abort = () => controller.abort(signal?.reason)
57
+ signal?.addEventListener('abort', abort, { once: true })
58
+ const timeout = setTimeout(() => controller.abort(new Error(`request timed out after ${timeoutMs}ms`)), timeoutMs)
59
+ try {
60
+ return await marketFetch(url, { signal: controller.signal, headers: { accept: 'application/json' } })
61
+ } catch (error) {
62
+ if (controller.signal.aborted && !signal?.aborted) throw new Error(`技能市场请求超时(${timeoutMs}ms)`, { cause: error })
63
+ throw error
64
+ } finally {
65
+ clearTimeout(timeout)
66
+ signal?.removeEventListener('abort', abort)
67
+ }
68
+ }
69
+
70
+ function positiveInteger(value: number | undefined, fallback: number, name: string): number {
71
+ const resolved = value ?? fallback
72
+ if (!Number.isSafeInteger(resolved) || resolved <= 0) throw new TypeError(`${name} must be a positive integer`)
73
+ return resolved
74
+ }
75
+
76
+ function parseListResult(value: unknown): SkillListResult {
77
+ if (!isRecord(value) || !Array.isArray(value.items)) throw new Error('技能列表响应格式异常:缺少 items 字段')
78
+ const total = readNonNegativeInteger(value.total, 'total')
79
+ const page = readPositiveInteger(value.page, 'page')
80
+ const pageSize = readPositiveInteger(value.pageSize, 'pageSize')
81
+ return { items: value.items.map(parseItem), total, page, pageSize }
82
+ }
83
+
84
+ function parseItem(value: unknown): SkillMarketItem {
85
+ if (!isRecord(value)) throw new Error('技能列表响应格式异常:技能条目不是对象')
86
+ return {
87
+ id: readPositiveInteger(value.id, 'item.id'),
88
+ name: readString(value.name, 'item.name'),
89
+ description: optionalString(value.description),
90
+ category: optionalString(value.category),
91
+ status: optionalString(value.status),
92
+ downloads: optionalNonNegativeInteger(value.downloads),
93
+ views: optionalNonNegativeInteger(value.views),
94
+ readme: optionalString(value.readme),
95
+ version: optionalString(value.version),
96
+ author: optionalString(value.author),
97
+ creatorName: optionalString(value.creatorName),
98
+ tags: optionalString(value.tags),
99
+ createTime: optionalString(value.createTime),
100
+ updateTime: optionalString(value.updateTime),
101
+ visibility: optionalString(value.visibility),
102
+ }
103
+ }
104
+
105
+ function isRecord(value: unknown): value is Record<string, unknown> {
106
+ return typeof value === 'object' && value !== null
107
+ }
108
+
109
+ function readString(value: unknown, name: string): string {
110
+ if (typeof value !== 'string' || value.trim().length === 0) throw new Error(`技能列表响应格式异常:${name}`)
111
+ return value
112
+ }
113
+
114
+ function optionalString(value: unknown): string {
115
+ return typeof value === 'string' ? value : ''
116
+ }
117
+
118
+ function readPositiveInteger(value: unknown, name: string): number {
119
+ if (!Number.isSafeInteger(value) || (value as number) <= 0) throw new Error(`技能列表响应格式异常:${name}`)
120
+ return value as number
121
+ }
122
+
123
+ function readNonNegativeInteger(value: unknown, name: string): number {
124
+ if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`技能列表响应格式异常:${name}`)
125
+ return value as number
126
+ }
127
+
128
+ function optionalNonNegativeInteger(value: unknown): number {
129
+ return Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 0
130
+ }
@@ -0,0 +1,32 @@
1
+ import type { ResolvedSkillMarketConfig, SkillMarketConfig } from './types.ts'
2
+
3
+ const DEFAULTS: ResolvedSkillMarketConfig = Object.freeze({
4
+ baseUrl: 'https://devops.chinaunicom.cn/inner_source_scm',
5
+ requestTimeoutMs: 15_000,
6
+ downloadTimeoutMs: 60_000,
7
+ defaultPageSize: 10,
8
+ maxDownloadBytes: 32 * 1024 * 1024,
9
+ maxArchiveEntries: 256,
10
+ maxUncompressedBytes: 64 * 1024 * 1024,
11
+ })
12
+
13
+ /** Resolve and validate deployment-varying skill-market settings. */
14
+ export function resolveSkillMarketConfig(input: SkillMarketConfig | undefined): ResolvedSkillMarketConfig {
15
+ const resolved = { ...DEFAULTS, ...input, baseUrl: (input?.baseUrl ?? DEFAULTS.baseUrl).replace(/\/+$/u, '') }
16
+ if (resolved.baseUrl.length === 0 || !/^https?:\/\//u.test(resolved.baseUrl)) {
17
+ throw new TypeError('dsh-market: skillMarket.baseUrl must be an HTTP(S) URL')
18
+ }
19
+ for (const [name, value] of Object.entries({
20
+ requestTimeoutMs: resolved.requestTimeoutMs,
21
+ downloadTimeoutMs: resolved.downloadTimeoutMs,
22
+ defaultPageSize: resolved.defaultPageSize,
23
+ maxDownloadBytes: resolved.maxDownloadBytes,
24
+ maxArchiveEntries: resolved.maxArchiveEntries,
25
+ maxUncompressedBytes: resolved.maxUncompressedBytes,
26
+ })) {
27
+ if (!Number.isSafeInteger(value) || value <= 0) {
28
+ throw new TypeError(`dsh-market: skillMarket.${name} must be a positive integer`)
29
+ }
30
+ }
31
+ return Object.freeze(resolved)
32
+ }
@@ -0,0 +1,337 @@
1
+ import { createWriteStream } from 'node:fs'
2
+ import { cp, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path'
5
+ import { pipeline } from 'node:stream/promises'
6
+ import { Transform } from 'node:stream'
7
+ import { load as loadYaml } from 'js-yaml'
8
+ import yauzl, { type Entry, type ZipFile } from 'yauzl'
9
+ import type { ResolvedSkillMarketConfig, InstalledSkill, SkillInstallLocation, SkillInstallResult, SkillMarketMeta } from './types.ts'
10
+ import type { SkillMarketApi } from './api.ts'
11
+ import { resolveChatCodeHome } from '../home-paths.ts'
12
+
13
+ export const SKILL_MARKET_META_FILE = '.skill-market.json'
14
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u
15
+
16
+ /** Installs validated catalog archives into ChatCode CLI skill roots. */
17
+ export class SkillMarketInstaller {
18
+ constructor(
19
+ private readonly api: SkillMarketApi,
20
+ private readonly config: ResolvedSkillMarketConfig,
21
+ ) {}
22
+
23
+ /** Install or update one catalog skill for a local session workspace. */
24
+ async install(options: {
25
+ readonly id: number
26
+ readonly name: string
27
+ readonly version?: string
28
+ readonly location: SkillInstallLocation
29
+ readonly cwd: string
30
+ readonly signal?: AbortSignal
31
+ }): Promise<SkillInstallResult> {
32
+ assertSkillName(options.name)
33
+ const roots = await resolveSkillRoots(options.cwd)
34
+ const skillsDir = roots[options.location]
35
+ const target = join(skillsDir, options.name)
36
+ const temp = await mkdtemp(join(tmpdir(), 'dsh-skill-market-'))
37
+ const archive = join(temp, 'skill.zip')
38
+ const extracted = join(temp, 'extracted')
39
+ let staged: string | undefined
40
+ let backup: string | undefined
41
+ try {
42
+ await this.downloadArchive(options.id, archive, options.signal)
43
+ await mkdir(extracted, { recursive: true })
44
+ await extractZip(archive, extracted, this.config, options.signal)
45
+ const contentRoot = await findSingleSkillRoot(extracted)
46
+ const manifest = await parseSkillManifest(join(contentRoot, 'SKILL.md'))
47
+ if (manifest.name !== options.name) {
48
+ throw new Error(`技能包名称不匹配:目录请求 ${options.name},SKILL.md 声明 ${manifest.name}`)
49
+ }
50
+
51
+ await mkdir(skillsDir, { recursive: true })
52
+ const existing = await installedMeta(target)
53
+ const updated = existing !== undefined
54
+ if (updated && existing.id !== options.id) {
55
+ throw new Error(`目标 ${target} 已存在,且不属于技能市场条目 ${options.id}`)
56
+ }
57
+ if (!updated && await pathExists(target)) {
58
+ throw new Error(`目标 ${target} 已存在;不会覆盖非市场安装的技能`)
59
+ }
60
+
61
+ staged = join(skillsDir, `.${options.name}.install-${crypto.randomUUID()}`)
62
+ await cp(contentRoot, staged, { recursive: true, errorOnExist: true, force: false })
63
+ const meta: SkillMarketMeta = { id: options.id, name: options.name, version: options.version }
64
+ await writeFile(join(staged, SKILL_MARKET_META_FILE), `${JSON.stringify(meta, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' })
65
+
66
+ if (updated) {
67
+ backup = join(skillsDir, `.${options.name}.backup-${crypto.randomUUID()}`)
68
+ await rename(target, backup)
69
+ }
70
+ try {
71
+ await rename(staged, target)
72
+ staged = undefined
73
+ } catch (error) {
74
+ if (backup !== undefined) await rename(backup, target)
75
+ backup = undefined
76
+ throw error
77
+ }
78
+ if (backup !== undefined) {
79
+ await rm(backup, { recursive: true, force: true })
80
+ backup = undefined
81
+ }
82
+ return { skillName: options.name, installPath: target, location: options.location, updated }
83
+ } finally {
84
+ if (staged !== undefined) await rm(staged, { recursive: true, force: true })
85
+ if (backup !== undefined) {
86
+ if (!await pathExists(target)) await rename(backup, target)
87
+ else await rm(backup, { recursive: true, force: true })
88
+ }
89
+ await rm(temp, { recursive: true, force: true })
90
+ }
91
+ }
92
+
93
+ private async downloadArchive(id: number, destination: string, signal: AbortSignal | undefined): Promise<void> {
94
+ const controller = new AbortController()
95
+ const abort = () => controller.abort(signal?.reason)
96
+ signal?.addEventListener('abort', abort, { once: true })
97
+ const timeout = setTimeout(
98
+ () => controller.abort(new Error(`技能下载超时(${this.config.downloadTimeoutMs}ms)`)),
99
+ this.config.downloadTimeoutMs,
100
+ )
101
+ try {
102
+ const response = await this.api.download(id, controller.signal)
103
+ await writeCappedResponse(response, destination, this.config.maxDownloadBytes, controller.signal)
104
+ } catch (error) {
105
+ if (controller.signal.aborted && !signal?.aborted) {
106
+ throw new Error(`技能下载超时(${this.config.downloadTimeoutMs}ms)`, { cause: error })
107
+ }
108
+ throw error
109
+ } finally {
110
+ clearTimeout(timeout)
111
+ signal?.removeEventListener('abort', abort)
112
+ }
113
+ }
114
+
115
+ /** List market-managed skills visible from a local session workspace. */
116
+ async installed(cwd: string): Promise<readonly InstalledSkill[]> {
117
+ const roots = await resolveSkillRoots(cwd)
118
+ const found = await Promise.all((['project', 'user'] as const).map(async location => {
119
+ const root = roots[location]
120
+ let entries
121
+ try {
122
+ entries = await readdir(root, { withFileTypes: true })
123
+ } catch (error) {
124
+ if (isMissing(error)) return []
125
+ throw error
126
+ }
127
+ const installed = await Promise.all(entries.filter(entry => entry.isDirectory()).map(async (entry): Promise<InstalledSkill | undefined> => {
128
+ const path = join(root, entry.name)
129
+ if (!await pathExists(join(path, 'SKILL.md'))) return undefined
130
+ const meta = await installedMeta(path)
131
+ return {
132
+ name: entry.name,
133
+ location,
134
+ path,
135
+ ...(meta?.id === undefined ? {} : { marketId: meta.id }),
136
+ ...(meta?.version === undefined ? {} : { version: meta.version }),
137
+ }
138
+ }))
139
+ return installed.filter((item): item is InstalledSkill => item !== undefined)
140
+ }))
141
+ return found.flat().sort((a, b) => a.name.localeCompare(b.name) || a.location.localeCompare(b.location))
142
+ }
143
+ }
144
+
145
+ /** Resolve the two skill roots visible to a session workspace. */
146
+ export async function resolveSkillRoots(cwd: string): Promise<Record<SkillInstallLocation, string>> {
147
+ if (!isAbsolute(cwd)) throw new Error('技能市场仅支持本地绝对工作目录')
148
+ const cwdStat = await stat(cwd).catch(error => {
149
+ if (isMissing(error)) throw new Error(`工作目录不存在:${cwd}`, { cause: error })
150
+ throw error
151
+ })
152
+ if (!cwdStat.isDirectory()) throw new Error(`工作目录不是目录:${cwd}`)
153
+ let projectRoot = resolve(cwd)
154
+ while (true) {
155
+ if (await pathExists(join(projectRoot, '.git'))) break
156
+ const parent = dirname(projectRoot)
157
+ if (parent === projectRoot) {
158
+ projectRoot = resolve(cwd)
159
+ break
160
+ }
161
+ projectRoot = parent
162
+ }
163
+ return {
164
+ project: join(projectRoot, '.chatcode-cli', 'skills'),
165
+ user: join(resolveChatCodeHome(), 'skills'),
166
+ }
167
+ }
168
+
169
+ async function writeCappedResponse(response: Response, destination: string, limit: number, signal: AbortSignal | undefined): Promise<void> {
170
+ if (response.body === null) throw new Error('技能下载响应无内容')
171
+ let bytes = 0
172
+ const counter = new Transform({
173
+ transform(chunk: Buffer, _encoding, callback) {
174
+ bytes += chunk.length
175
+ if (bytes > limit) callback(new Error(`技能包超过 ${limit} 字节限制`))
176
+ else callback(null, chunk)
177
+ },
178
+ })
179
+ await pipeline(response.body as never, counter, createWriteStream(destination, { flags: 'wx', mode: 0o600 }), { signal })
180
+ }
181
+
182
+ async function extractZip(zipPath: string, target: string, config: ResolvedSkillMarketConfig, signal: AbortSignal | undefined): Promise<void> {
183
+ const zip = await openZip(zipPath)
184
+ let entries = 0
185
+ let totalBytes = 0
186
+ let abort: (() => void) | undefined
187
+ try {
188
+ await new Promise<void>((resolvePromise, rejectPromise) => {
189
+ let settled = false
190
+ const fail = (error: unknown) => {
191
+ if (settled) return
192
+ settled = true
193
+ rejectPromise(error)
194
+ }
195
+ abort = () => fail(signal?.reason ?? new Error('技能安装已取消'))
196
+ signal?.addEventListener('abort', abort, { once: true })
197
+ zip.on('error', fail)
198
+ zip.on('end', () => {
199
+ if (settled) return
200
+ settled = true
201
+ resolvePromise()
202
+ })
203
+ zip.on('entry', (entry: Entry) => {
204
+ void extractEntry(zip, entry, target, {
205
+ addEntry: () => {
206
+ entries += 1
207
+ if (entries > config.maxArchiveEntries) throw new Error(`技能包文件数超过 ${config.maxArchiveEntries} 个限制`)
208
+ },
209
+ addBytes: (count: number) => {
210
+ totalBytes += count
211
+ if (totalBytes > config.maxUncompressedBytes) throw new Error(`技能包解压后超过 ${config.maxUncompressedBytes} 字节限制`)
212
+ },
213
+ }, signal).then(() => zip.readEntry(), fail)
214
+ })
215
+ zip.readEntry()
216
+ })
217
+ } finally {
218
+ if (abort !== undefined) signal?.removeEventListener('abort', abort)
219
+ zip.close()
220
+ }
221
+ }
222
+
223
+ async function extractEntry(
224
+ zip: ZipFile,
225
+ entry: Entry,
226
+ target: string,
227
+ limits: { addEntry(): void; addBytes(count: number): void },
228
+ signal: AbortSignal | undefined,
229
+ ): Promise<void> {
230
+ limits.addEntry()
231
+ const name = entry.fileName
232
+ if (name.includes('\\') || name.includes('\0') || isAbsolute(name)) throw new Error(`技能包包含非法路径:${name}`)
233
+ const normalized = normalize(name)
234
+ if (normalized === '..' || normalized.startsWith(`..${sep}`)) throw new Error(`技能包路径越界:${name}`)
235
+ const output = resolve(target, normalized)
236
+ const inside = relative(target, output)
237
+ if (inside.startsWith('..') || isAbsolute(inside)) throw new Error(`技能包路径越界:${name}`)
238
+ const mode = (entry.externalFileAttributes >>> 16) & 0o170000
239
+ if (mode === 0o120000) throw new Error(`技能包不允许符号链接:${name}`)
240
+ if (name.endsWith('/')) {
241
+ await mkdir(output, { recursive: true })
242
+ return
243
+ }
244
+ limits.addBytes(entry.uncompressedSize)
245
+ await mkdir(dirname(output), { recursive: true })
246
+ const input = await openReadStream(zip, entry)
247
+ let actualBytes = 0
248
+ const counter = new Transform({
249
+ transform(chunk: Buffer, _encoding, callback) {
250
+ actualBytes += chunk.length
251
+ if (actualBytes > entry.uncompressedSize || signal?.aborted) callback(new Error('技能包内容长度异常或安装已取消'))
252
+ else callback(null, chunk)
253
+ },
254
+ })
255
+ await pipeline(input, counter, createWriteStream(output, { flags: 'wx', mode: 0o600 }), { signal })
256
+ }
257
+
258
+ function openZip(path: string): Promise<ZipFile> {
259
+ return new Promise((resolvePromise, rejectPromise) => {
260
+ yauzl.open(path, { lazyEntries: true, strictFileNames: true, validateEntrySizes: true }, (error, zip) => {
261
+ if (error !== null) rejectPromise(error)
262
+ else if (zip === undefined) rejectPromise(new Error('无法打开技能包'))
263
+ else resolvePromise(zip)
264
+ })
265
+ })
266
+ }
267
+
268
+ function openReadStream(zip: ZipFile, entry: Entry): Promise<NodeJS.ReadableStream> {
269
+ return new Promise((resolvePromise, rejectPromise) => {
270
+ zip.openReadStream(entry, (error, stream) => {
271
+ if (error !== null) rejectPromise(error)
272
+ else if (stream === undefined) rejectPromise(new Error(`无法读取技能包条目:${entry.fileName}`))
273
+ else resolvePromise(stream)
274
+ })
275
+ })
276
+ }
277
+
278
+ async function findSingleSkillRoot(root: string): Promise<string> {
279
+ const found: string[] = []
280
+ async function walk(directory: string): Promise<void> {
281
+ const entries = await readdir(directory, { withFileTypes: true })
282
+ if (entries.some(entry => entry.isFile() && entry.name === 'SKILL.md')) found.push(directory)
283
+ for (const entry of entries) {
284
+ if (!entry.isDirectory() || entry.name.toLowerCase() === '__macosx') continue
285
+ await walk(join(directory, entry.name))
286
+ }
287
+ }
288
+ await walk(root)
289
+ if (found.length === 0) throw new Error('无效的技能包:缺少 SKILL.md')
290
+ if (found.length > 1) throw new Error('无效的技能包:包含多个 SKILL.md 根目录')
291
+ return found[0]
292
+ }
293
+
294
+ async function parseSkillManifest(path: string): Promise<{ name: string }> {
295
+ const content = await readFile(path, 'utf8')
296
+ const match = /^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(content)
297
+ if (match === null) throw new Error('无效的 SKILL.md:缺少 YAML frontmatter')
298
+ const data = loadYaml(match[1])
299
+ if (typeof data !== 'object' || data === null) throw new Error('无效的 SKILL.md:frontmatter 不是对象')
300
+ const name = (data as Record<string, unknown>).name
301
+ const description = (data as Record<string, unknown>).description
302
+ if (typeof name !== 'string') throw new Error('无效的 SKILL.md:缺少 name')
303
+ assertSkillName(name)
304
+ if (typeof description !== 'string' || description.trim().length === 0) throw new Error('无效的 SKILL.md:缺少 description')
305
+ return { name }
306
+ }
307
+
308
+ function assertSkillName(name: string): void {
309
+ if (!SKILL_NAME.test(name)) throw new TypeError(`无效的技能名称:${name}`)
310
+ }
311
+
312
+ async function installedMeta(target: string): Promise<SkillMarketMeta | undefined> {
313
+ try {
314
+ const parsed = JSON.parse(await readFile(join(target, SKILL_MARKET_META_FILE), 'utf8')) as unknown
315
+ if (typeof parsed !== 'object' || parsed === null) return undefined
316
+ const value = parsed as Record<string, unknown>
317
+ if (!Number.isSafeInteger(value.id) || typeof value.name !== 'string') return undefined
318
+ return { id: value.id as number, name: value.name, version: typeof value.version === 'string' ? value.version : undefined }
319
+ } catch (error) {
320
+ if (isMissing(error) || error instanceof SyntaxError) return undefined
321
+ throw error
322
+ }
323
+ }
324
+
325
+ async function pathExists(path: string): Promise<boolean> {
326
+ try {
327
+ await stat(path)
328
+ return true
329
+ } catch (error) {
330
+ if (isMissing(error)) return false
331
+ throw error
332
+ }
333
+ }
334
+
335
+ function isMissing(error: unknown): boolean {
336
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'
337
+ }
@@ -0,0 +1,14 @@
1
+ import { SkillMarketApi } from './api.ts'
2
+ import { SkillMarketInstaller } from './installer.ts'
3
+ import type { ResolvedSkillMarketConfig } from './types.ts'
4
+
5
+ /** Shared skill-market operations consumed by Web routes and the terminal scene. */
6
+ export class SkillMarketRuntime {
7
+ readonly api: SkillMarketApi
8
+ readonly installer: SkillMarketInstaller
9
+
10
+ constructor(readonly config: ResolvedSkillMarketConfig) {
11
+ this.api = new SkillMarketApi(config)
12
+ this.installer = new SkillMarketInstaller(this.api, config)
13
+ }
14
+ }