@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,118 @@
1
+ /** Locate the DSH host package in CLI and packaged Desktop runtimes. */
2
+
3
+ import { readFileSync, realpathSync } from 'node:fs'
4
+ import { dirname, join, resolve } from 'node:path'
5
+
6
+ /** The entry with symlinks resolved, or unchanged when it cannot be read. */
7
+ function realpathOf(entry: string): string {
8
+ try {
9
+ return realpathSync(entry)
10
+ } catch {
11
+ // A path that does not exist, or one inside an ASAR archive, where
12
+ // realpath fails: the raw entry is still the best guess and the walk
13
+ // below simply answers null if it leads nowhere.
14
+ return entry
15
+ }
16
+ }
17
+
18
+ const DSH_PACKAGE = '@deepseek-ai/dsh'
19
+
20
+ /**
21
+ * The host package's own manifest, or null when `directory` is not it.
22
+ * @returns the parsed manifest of `@deepseek-ai/dsh`, or null.
23
+ */
24
+ function readDshManifest(directory: string): { name: string; version?: unknown } | null {
25
+ try {
26
+ const manifest = JSON.parse(
27
+ readFileSync(join(directory, 'package.json'), 'utf8'),
28
+ ) as { name?: unknown; version?: unknown }
29
+ return manifest.name === DSH_PACKAGE ? { name: DSH_PACKAGE, version: manifest.version } : null
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ function isDshPackage(directory: string): boolean {
36
+ return readDshManifest(directory) !== null
37
+ }
38
+
39
+ /**
40
+ * The version of the DSH host this market is running inside.
41
+ *
42
+ * Read from the same manifest `findDshInstallDir` already parses to identify
43
+ * the package — the version was sitting in that object and being discarded.
44
+ *
45
+ * Worth reporting because the host version has repeatedly been the thing
46
+ * neither side could see. #293 turned on it (the reporter was on
47
+ * 0.1.1-rc.2 while every attempt to reproduce had been on 0.1.0-rc.8, which
48
+ * nobody knew until three rounds in), and #404 is entirely about a plugin
49
+ * that requires a host newer than the Desktop build it was installed on.
50
+ *
51
+ * The directory comes back too, because WHERE it was found is the other half
52
+ * of the answer: a path under Electron's resources is a Desktop-bundled host,
53
+ * which #139 established can be older than whatever `npm ls` would report.
54
+ * Asking the user is not a substitute — that is the number they do not have.
55
+ * @returns the host version and the directory it was read from, or null when
56
+ * no host package is locatable (a plain `dsh web` from a global install can
57
+ * legitimately land here).
58
+ */
59
+ export function dshHostInfo(entry = process.argv[1]): { version: string; directory: string } | null {
60
+ const directory = findDshInstallDir(entry)
61
+ if (directory === null) return null
62
+ const manifest = readDshManifest(directory)
63
+ // Located but unversioned: report the directory anyway. "The host is here
64
+ // and declares no version" is a fact worth carrying, and it is not the
65
+ // same fact as "no host found".
66
+ const version = typeof manifest?.version === 'string' && manifest.version !== ''
67
+ ? manifest.version
68
+ : 'unknown'
69
+ return { version, directory }
70
+ }
71
+
72
+ /**
73
+ * Walk up from the CLI entry first, then inspect Electron's authoritative
74
+ * resources directory. Desktop distributions may keep node_modules outside
75
+ * the ASAR, expose them through ASAR's virtual filesystem, or disable ASAR.
76
+ *
77
+ * The entry is resolved through symlinks before the walk, because for a
78
+ * globally installed dsh it IS one. `npm i -g` and Homebrew both put a link
79
+ * in a `bin/` directory pointing at the real package, so `process.argv[1]`
80
+ * is `/opt/homebrew/bin/dsh` and walking up from there reaches `/` without
81
+ * ever passing the package. Measured: the same call answers `null` for the
82
+ * link and the correct directory for its target.
83
+ *
84
+ * That was not a cosmetic gap. Everything downstream reads the host version
85
+ * from here — the exported log's `dsh host:` line (#426), Discover's
86
+ * host-requirement column and filter (#473), and the pre-update check
87
+ * (#404) — and a null version makes every one of them answer "unknown",
88
+ * silently, on exactly the ordinary global install. A bundled Desktop host
89
+ * is reached by the resources branch below and was never affected, which is
90
+ * why this survived: the case that worked is the one that gets tested.
91
+ */
92
+ export function findDshInstallDir(entry = process.argv[1]): string | null {
93
+ if (entry !== undefined) {
94
+ let directory = resolve(dirname(realpathOf(entry)))
95
+ for (let depth = 0; depth < 10; depth += 1) {
96
+ if (isDshPackage(directory)) return directory
97
+ const parent = dirname(directory)
98
+ if (parent === directory) break
99
+ directory = parent
100
+ }
101
+ }
102
+
103
+ const electronProcess = process as NodeJS.Process & { resourcesPath?: unknown }
104
+ if (typeof electronProcess.resourcesPath !== 'string'
105
+ || electronProcess.resourcesPath.length === 0) return null
106
+
107
+ for (const applicationRoot of ['app.asar.unpacked', 'app.asar', 'app']) {
108
+ const candidate = join(
109
+ electronProcess.resourcesPath,
110
+ applicationRoot,
111
+ 'node_modules',
112
+ '@deepseek-ai',
113
+ 'dsh',
114
+ )
115
+ if (isDshPackage(candidate)) return candidate
116
+ }
117
+ return null
118
+ }
package/src/gist.ts ADDED
@@ -0,0 +1,362 @@
1
+ /**
2
+ * GitHub Gist transport for profile backups — the third backup channel next
3
+ * to the local download and WebDAV (issue #89).
4
+ *
5
+ * Security posture:
6
+ * - The API host is hard-coded to api.github.com, so there is no SSRF surface
7
+ * (unlike WebDAV, which accepts arbitrary user URLs).
8
+ * - The Gist id is validated against a strict character allowlist before it
9
+ * is interpolated into the request path.
10
+ * - Tokens are never persisted: the client sends one per request (session
11
+ * memory only) or the operator sets CHATCODE_CLI_MARKET_GITHUB_TOKEN on the
12
+ * host; nothing
13
+ * is written to disk by this module.
14
+ * - Downloaded content goes through `validatedBackup` before it is returned,
15
+ * mirroring downloadWebdav's strict restore-side validation.
16
+ *
17
+ * Timeouts and errors:
18
+ * - Every request carries an AbortSignal: the caller's (route-level timeout,
19
+ * so a wedged gh CLI or slow network yields a definite answer) merged with
20
+ * a 30 s hard ceiling. Errors are classified into machine-readable codes
21
+ * (`GistError`) so the UI can show friendly localized messages instead of
22
+ * raw DOMException/network noise like "TimeoutError: signal timed out".
23
+ */
24
+ import { spawn } from 'node:child_process'
25
+ import { request as httpsRequest } from 'node:https'
26
+ import { homedir } from 'node:os'
27
+ import { join } from 'node:path'
28
+ import { MAX_BACKUP_BYTES, validatedBackup, type ProfileBackup } from './backup.ts'
29
+ import { compatibilityEnv } from './home-paths.ts'
30
+
31
+ /** The single file every dshmarket backup Gist carries. */
32
+ export const GIST_FILENAME = 'dsh-profile-backup.json'
33
+
34
+ /** GitHub hard limit for one Gist file (1 MB); enforced before upload. */
35
+ export const GIST_MAX_BYTES = 1024 * 1024
36
+
37
+ /** Environment variable for a host-configured token (never read from disk). */
38
+ export const GIST_TOKEN_ENV = 'CHATCODE_CLI_MARKET_GITHUB_TOKEN'
39
+ export const LEGACY_GIST_TOKEN_ENV = 'DSH_GITHUB_TOKEN'
40
+
41
+ const GIST_API_HOST = 'api.github.com'
42
+ const GIST_ID_RE = /^[A-Za-z0-9_-]{1,64}$/
43
+ const REQUEST_TIMEOUT_MS = 30_000
44
+
45
+ /** Node network error codes that mean "GitHub is unreachable". */
46
+ const NETWORK_ERROR_CODES = new Set([
47
+ 'ENOTFOUND', 'EAI_AGAIN', 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT',
48
+ 'EPIPE', 'EHOSTUNREACH', 'ENETUNREACH', 'ECONNABORTED',
49
+ ])
50
+
51
+ /** Machine-readable error codes the client maps to localized messages. */
52
+ export type GistErrorCode = 'auth' | 'notfound' | 'rate-limit' | 'invalid' | 'timeout' | 'network' | 'other'
53
+
54
+ /** Error with a code for the UI; the message stays human-readable. */
55
+ export class GistError extends Error {
56
+ readonly code: GistErrorCode
57
+ constructor(message: string, code: GistErrorCode = 'other') {
58
+ super(message)
59
+ this.name = 'GistError'
60
+ this.code = code
61
+ }
62
+ }
63
+
64
+ /** Classify any thrown value into a stable GistErrorCode. */
65
+ export function gistErrorCode(error: unknown): GistErrorCode {
66
+ if (error instanceof GistError) return error.code
67
+ if (error instanceof Error) {
68
+ if (error.name === 'TimeoutError' || error.name === 'AbortError') return 'timeout'
69
+ const raw = (error as { code?: unknown }).code ?? (error as { cause?: { code?: unknown } }).cause?.code
70
+ if (typeof raw === 'string' && NETWORK_ERROR_CODES.has(raw)) return 'network'
71
+ }
72
+ return 'other'
73
+ }
74
+
75
+ export interface GistRef {
76
+ id: string
77
+ htmlUrl: string
78
+ }
79
+
80
+ /** Where the token used for a request came from (shown in the UI). */
81
+ export type GistTokenSource = 'token' | 'env' | 'gh'
82
+
83
+ /**
84
+ * Normalize a Gist id or a gist.github.com URL to a bare id.
85
+ * Anything else (paths, embedded slashes, oversize input) is rejected.
86
+ */
87
+ export function parseGistId(input: string): string {
88
+ const trimmed = input.trim()
89
+ if (trimmed === '') throw new Error('gist id is required')
90
+ let candidate = trimmed
91
+ try {
92
+ const url = new URL(trimmed)
93
+ if (url.protocol === 'https:' && (url.hostname === 'gist.github.com' || url.hostname.endsWith('.gist.github.com'))) {
94
+ const parts = url.pathname.split('/').filter(Boolean)
95
+ candidate = parts[parts.length - 1] ?? ''
96
+ }
97
+ } catch {
98
+ // Not a URL — treat the raw input as the candidate; the allowlist decides.
99
+ }
100
+ if (!GIST_ID_RE.test(candidate)) throw new Error('invalid gist id')
101
+ return candidate
102
+ }
103
+
104
+ /**
105
+ * Resolve the token for one request, in order of preference:
106
+ * 1. an explicitly supplied token (session memory only);
107
+ * 2. the host-configured CHATCODE_CLI_MARKET_GITHUB_TOKEN environment variable;
108
+ * 3. an already-logged-in GitHub CLI (`gh auth token`) — the token is used
109
+ * for this request only and never written to disk.
110
+ */
111
+ export async function resolveGistTokenSource(bodyToken: unknown): Promise<{ token: string; source: GistTokenSource }> {
112
+ if (typeof bodyToken === 'string' && bodyToken.trim() !== '') return { token: bodyToken.trim(), source: 'token' }
113
+ const configured = compatibilityEnv(process.env, GIST_TOKEN_ENV, LEGACY_GIST_TOKEN_ENV)
114
+ if (configured !== undefined) return { token: configured, source: 'env' }
115
+ const ghToken = await ghAuthToken()
116
+ if (ghToken !== null) return { token: ghToken, source: 'gh' }
117
+ throw new GistError(
118
+ 'GitHub token is required (enter it in the Backup tab, set CHATCODE_CLI_MARKET_GITHUB_TOKEN, or log in with the gh CLI)',
119
+ 'auth',
120
+ )
121
+ }
122
+
123
+ /** Resolve just the token (kept for callers that do not need the source). */
124
+ export async function resolveGistToken(bodyToken: unknown): Promise<string> {
125
+ return (await resolveGistTokenSource(bodyToken)).token
126
+ }
127
+
128
+ /** Short-lived in-memory cache for the gh-derived token (no disk, no browser). */
129
+ let ghTokenCache: { token: string | null; expires: number } | null = null
130
+
131
+ /** Test hook: drop the gh token cache between tests. */
132
+ export function resetGhTokenCache(): void {
133
+ ghTokenCache = null
134
+ }
135
+
136
+ /** Ask an already-authenticated GitHub CLI for its token, if available. */
137
+ async function ghAuthToken(): Promise<string | null> {
138
+ if (ghTokenCache !== null && Date.now() < ghTokenCache.expires) return ghTokenCache.token
139
+ const token = await fetchGhToken()
140
+ // Positive cache 10 min; negative cache 30 s so a wedged gh cannot make
141
+ // every click wait.
142
+ ghTokenCache = { token, expires: Date.now() + (token !== null ? 10 * 60_000 : 30_000) }
143
+ return token
144
+ }
145
+
146
+ /**
147
+ * Run `gh auth token` in a DETACHED child and give up after 8 s no matter
148
+ * what. The parent never waits on the child (unref), so even if gh.exe
149
+ * wedges under WSL interop the host event loop stays free and the request
150
+ * returns a definite answer. 8 s because a Windows gh.exe cold start through
151
+ * WSL interop is routinely slower than a native binary.
152
+ */
153
+ function fetchGhToken(): Promise<string | null> {
154
+ return new Promise<string | null>((resolve) => {
155
+ const candidates = ['gh', join(homedir(), '.local', 'bin', 'gh')]
156
+ let settled = false
157
+ const finish = (value: string | null): void => {
158
+ if (settled) return
159
+ settled = true
160
+ resolve(value)
161
+ }
162
+ const tryNext = (index: number): void => {
163
+ if (index >= candidates.length) {
164
+ finish(null)
165
+ return
166
+ }
167
+ const command = candidates[index]!
168
+ let child: ReturnType<typeof spawn> | null = null
169
+ try {
170
+ child = spawn(command, ['auth', 'token'], {
171
+ stdio: ['ignore', 'pipe', 'ignore'],
172
+ detached: true,
173
+ windowsHide: true,
174
+ })
175
+ } catch {
176
+ tryNext(index + 1)
177
+ return
178
+ }
179
+ // Defensive: a mocked/odd spawn can return nullish — never crash on it.
180
+ if (child == null) {
181
+ tryNext(index + 1)
182
+ return
183
+ }
184
+ let out = ''
185
+ child.stdout?.on('data', (chunk: Buffer) => { out += chunk.toString() })
186
+ const timer = setTimeout(() => {
187
+ // Parent gives up; the detached child may keep running as an orphan
188
+ // but cannot block this process or its event loop.
189
+ try { child?.kill('SIGKILL') } catch { /* already gone */ }
190
+ finish(out.trim() !== '' ? out.trim() : null)
191
+ }, 8_000)
192
+ // On spawn failure (e.g. ENOENT) Node emits 'error' AND THEN 'close'
193
+ // (code -2). The 'close' handler must not resolve from an errored
194
+ // child: when 'gh' is missing from PATH (typical for systemd services)
195
+ // that premature finish(null) would race ahead of the next candidate's
196
+ // successful token and lock the result to null.
197
+ let errored = false
198
+ child.on('error', () => {
199
+ if (errored) return
200
+ errored = true
201
+ clearTimeout(timer)
202
+ if (!settled) tryNext(index + 1)
203
+ })
204
+ child.on('close', () => {
205
+ if (errored) return // companion of 'error' — the next candidate owns the result
206
+ clearTimeout(timer)
207
+ finish(out.trim() !== '' ? out.trim() : null)
208
+ })
209
+ child.unref()
210
+ }
211
+ tryNext(0)
212
+ })
213
+ }
214
+
215
+ interface GistResponse {
216
+ status: number
217
+ body: string
218
+ }
219
+
220
+ /** Map a request-level failure (abort or network error) to a GistError. */
221
+ function classifyRequestError(error: unknown): Error {
222
+ if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
223
+ return new GistError('GitHub request timed out', 'timeout')
224
+ }
225
+ const raw = (error as { code?: unknown } | null)?.code ?? (error as { cause?: { code?: unknown } } | null)?.cause?.code
226
+ if (typeof raw === 'string' && NETWORK_ERROR_CODES.has(raw)) {
227
+ return new GistError(`GitHub is unreachable (${raw})`, 'network')
228
+ }
229
+ return error instanceof Error ? error : new GistError(String(error), 'other')
230
+ }
231
+
232
+ function gistRequest(token: string, method: 'GET' | 'POST' | 'PATCH', path: string, body?: string, signal?: AbortSignal): Promise<GistResponse> {
233
+ return new Promise<GistResponse>((resolve, reject) => {
234
+ const headers: Record<string, string> = {
235
+ authorization: `Bearer ${token}`,
236
+ 'user-agent': 'ChatCode-CLI-Market',
237
+ accept: 'application/vnd.github+json',
238
+ }
239
+ if (body !== undefined) {
240
+ headers['content-type'] = 'application/json'
241
+ headers['content-length'] = String(Buffer.byteLength(body))
242
+ }
243
+ // Route-level signal (when given) wins; the 30 s ceiling still applies
244
+ // as a hard fallback so a forgotten signal can never hang the server.
245
+ const hardCeiling = AbortSignal.timeout(REQUEST_TIMEOUT_MS)
246
+ const effectiveSignal = signal !== undefined ? AbortSignal.any([signal, hardCeiling]) : hardCeiling
247
+ const request = httpsRequest({
248
+ protocol: 'https:',
249
+ hostname: GIST_API_HOST,
250
+ path,
251
+ method,
252
+ headers,
253
+ signal: effectiveSignal,
254
+ }, (response) => {
255
+ const chunks: Buffer[] = []
256
+ let size = 0
257
+ // GET returns the whole Gist including metadata; POST/PATCH return the
258
+ // created/updated Gist object, which ECHOES the file content back — so
259
+ // a large backup makes the response as large as the upload. Cap all
260
+ // methods alike so a hostile Gist cannot OOM us.
261
+ const maxBytes = MAX_BACKUP_BYTES + 16 * 1024
262
+ response.once('error', reject)
263
+ const declared = Number(response.headers['content-length'])
264
+ if (Number.isFinite(declared) && declared > maxBytes) {
265
+ response.destroy(new Error('GitHub response is too large'))
266
+ return
267
+ }
268
+ response.on('data', (chunk: Buffer | string) => {
269
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
270
+ size += value.byteLength
271
+ if (size > maxBytes) {
272
+ response.destroy(new Error('GitHub response is too large'))
273
+ return
274
+ }
275
+ chunks.push(value)
276
+ })
277
+ response.once('end', () => resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }))
278
+ })
279
+ request.once('error', (error) => reject(classifyRequestError(error)))
280
+ if (body !== undefined) request.end(body)
281
+ else request.end()
282
+ })
283
+ }
284
+
285
+ function parseGistError(status: number, body: string, action: string): Error {
286
+ let message = body
287
+ try {
288
+ const parsed = JSON.parse(body) as { message?: unknown }
289
+ if (typeof parsed.message === 'string' && parsed.message !== '') message = parsed.message
290
+ } catch { /* keep the raw body */ }
291
+ if (status === 401) return new GistError('GitHub token is invalid or revoked', 'auth')
292
+ if (status === 403) return new GistError(`GitHub rejected the ${action} (rate limit or insufficient scope): ${message}`, 'rate-limit')
293
+ if (status === 404) return new GistError('Gist not found (check the id/URL)', 'notfound')
294
+ if (status === 422) return new GistError(`GitHub rejected the ${action}: ${message}`, 'invalid')
295
+ return new GistError(`GitHub ${action} failed: HTTP ${status} ${message}`, 'other')
296
+ }
297
+
298
+ async function sendGistRequest(token: string, method: 'GET' | 'POST' | 'PATCH', path: string, body: string | undefined, action: string, signal?: AbortSignal): Promise<GistResponse> {
299
+ const response = await gistRequest(token, method, path, body, signal)
300
+ const ok = method === 'POST' ? response.status === 201 : response.status === 200
301
+ if (!ok) throw parseGistError(response.status, response.body, action)
302
+ return response
303
+ }
304
+
305
+ /** Create a new private Gist carrying one backup file. */
306
+ export async function createGist(token: string, content: string, signal?: AbortSignal): Promise<GistRef> {
307
+ const body = JSON.stringify({
308
+ description: 'ChatCode CLI Market profile backup',
309
+ public: false,
310
+ files: { [GIST_FILENAME]: { content } },
311
+ })
312
+ const response = await sendGistRequest(token, 'POST', '/gists', body, 'Gist creation', signal)
313
+ const data = JSON.parse(response.body) as { id?: unknown; html_url?: unknown }
314
+ if (typeof data.id !== 'string' || data.id === '') throw new Error('GitHub returned an invalid Gist')
315
+ return { id: data.id, htmlUrl: typeof data.html_url === 'string' ? data.html_url : `https://gist.github.com/${data.id}` }
316
+ }
317
+
318
+ /** Overwrite the backup file inside an existing Gist (other files kept). */
319
+ export async function updateGist(token: string, gistId: string, content: string, signal?: AbortSignal): Promise<GistRef> {
320
+ const body = JSON.stringify({ files: { [GIST_FILENAME]: { content } } })
321
+ const response = await sendGistRequest(token, 'PATCH', `/gists/${gistId}`, body, 'Gist update', signal)
322
+ const data = JSON.parse(response.body) as { id?: unknown; html_url?: unknown }
323
+ return { id: typeof data.id === 'string' ? data.id : gistId, htmlUrl: typeof data.html_url === 'string' ? data.html_url : `https://gist.github.com/${gistId}` }
324
+ }
325
+
326
+ /** Download and strictly validate the backup file inside a Gist. */
327
+ export async function readGist(token: string, gistId: string, signal?: AbortSignal): Promise<ProfileBackup> {
328
+ const response = await sendGistRequest(token, 'GET', `/gists/${gistId}`, undefined, 'Gist read', signal)
329
+ let data: { files?: Record<string, unknown> }
330
+ try {
331
+ data = JSON.parse(response.body) as { files?: Record<string, unknown> }
332
+ } catch {
333
+ throw new GistError('GitHub returned an unreadable Gist payload', 'invalid')
334
+ }
335
+ const file = data.files?.[GIST_FILENAME]
336
+ const content = file !== null && typeof file === 'object' && !Array.isArray(file)
337
+ ? (file as { content?: unknown }).content
338
+ : undefined
339
+ if (typeof content !== 'string') throw new GistError(`Gist has no ${GIST_FILENAME} file`, 'invalid')
340
+ let parsed: unknown
341
+ try {
342
+ parsed = JSON.parse(content)
343
+ } catch {
344
+ throw new GistError('Gist backup is not valid JSON', 'invalid')
345
+ }
346
+ // Strict validation server-side: restore only accepts real backups.
347
+ try {
348
+ return validatedBackup(parsed)
349
+ } catch (error) {
350
+ throw new GistError(error instanceof Error ? error.message : String(error), 'invalid')
351
+ }
352
+ }
353
+
354
+ /** Confirm the token is usable (GET /user). */
355
+ export async function verifyGistToken(token: string, signal?: AbortSignal): Promise<void> {
356
+ await sendGistRequest(token, 'GET', '/user', undefined, 'token verification', signal)
357
+ }
358
+
359
+ /** True when the serialized backup fits inside a Gist file. */
360
+ export function fitsGistLimit(content: string): boolean {
361
+ return Buffer.byteLength(content) <= GIST_MAX_BYTES
362
+ }
package/src/groups.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Custom plugin groups (Roadmap #60): user-defined named collections of
3
+ * installed plugins whose enable/disable state can be switched as a unit —
4
+ * borrowing the "group by capability, toggle as one" idea from Claude
5
+ * Desktop's skill management. Membership lives in state.json and is the
6
+ * only durable truth: a group's switch state is always derived from its
7
+ * members and never persisted itself.
8
+ *
9
+ * Pure CRUD over the caller-owned state objects; routes.ts persists after
10
+ * each mutation and applies the live toggles for the batch action.
11
+ */
12
+
13
+ export type GroupAction = 'create' | 'rename' | 'delete' | 'set-members' | 'toggle'
14
+
15
+ /** The slices of market state group CRUD touches (routes.ts owns the rest). */
16
+ export interface GroupState {
17
+ groups: Record<string, string[]>
18
+ groupOrder: string[]
19
+ }
20
+
21
+ export interface GroupMutationResult {
22
+ ok: boolean
23
+ error?: string
24
+ }
25
+
26
+ /** Group names: letters/digits (incl. CJK), spaces, underscores, hyphens. */
27
+ const GROUP_NAME_RE = /^[\p{L}\p{N}_ -]{1,40}$/u
28
+
29
+ function isGroupName(value: unknown): value is string {
30
+ return typeof value === 'string' && GROUP_NAME_RE.test(value)
31
+ }
32
+
33
+ export function createGroup(state: GroupState, name: unknown): GroupMutationResult {
34
+ if (!isGroupName(name)) return { ok: false, error: 'invalid group name / 分组名称无效' }
35
+ if (state.groups[name] !== undefined) return { ok: false, error: 'group already exists / 分组已存在' }
36
+ state.groups[name] = []
37
+ state.groupOrder.push(name)
38
+ return { ok: true }
39
+ }
40
+
41
+ export function renameGroup(state: GroupState, name: unknown, newName: unknown): GroupMutationResult {
42
+ if (typeof name !== 'string' || state.groups[name] === undefined) {
43
+ return { ok: false, error: 'group not found / 分组不存在' }
44
+ }
45
+ if (!isGroupName(newName)) return { ok: false, error: 'invalid group name / 分组名称无效' }
46
+ if (newName !== name && state.groups[newName] !== undefined) {
47
+ return { ok: false, error: 'group already exists / 分组已存在' }
48
+ }
49
+ const members = state.groups[name]
50
+ delete state.groups[name]
51
+ state.groups[newName] = members
52
+ const index = state.groupOrder.indexOf(name)
53
+ if (index !== -1) state.groupOrder[index] = newName
54
+ return { ok: true }
55
+ }
56
+
57
+ export function deleteGroup(state: GroupState, name: unknown): GroupMutationResult {
58
+ if (typeof name !== 'string' || state.groups[name] === undefined) {
59
+ return { ok: false, error: 'group not found / 分组不存在' }
60
+ }
61
+ delete state.groups[name]
62
+ // Mutate in place: routes.ts hands over the LIVE groupOrder array, and the
63
+ // response serializes that same array — replacing it here would orphan it.
64
+ const index = state.groupOrder.indexOf(name)
65
+ if (index !== -1) state.groupOrder.splice(index, 1)
66
+ return { ok: true }
67
+ }
68
+
69
+ /**
70
+ * Replace a group's membership. Only currently installed plugins can be
71
+ * members — ghost names (uninstalled meanwhile) are dropped and duplicates
72
+ * collapse, so the persisted list stays clean. Themes are exclusive: a group
73
+ * may hold at most one theme plugin, mirroring the global one-active-theme
74
+ * rule (only one theme can be enabled at a time).
75
+ */
76
+ export function setGroupMembers(
77
+ state: GroupState,
78
+ name: unknown,
79
+ members: unknown,
80
+ installed: ReadonlySet<string>,
81
+ themes: ReadonlySet<string>,
82
+ ): GroupMutationResult {
83
+ if (typeof name !== 'string' || state.groups[name] === undefined) {
84
+ return { ok: false, error: 'group not found / 分组不存在' }
85
+ }
86
+ if (!Array.isArray(members)) return { ok: false, error: 'members must be an array / 成员必须是数组' }
87
+ const kept: string[] = []
88
+ const seen = new Set<string>()
89
+ for (const member of members) {
90
+ if (typeof member !== 'string' || member === '' || seen.has(member)) continue
91
+ // The market itself never participates in groups (#60 assumptions).
92
+ if (member === '@chatcode/cco-market' || member === 'dsh-market' || member === 'dshmarket') continue
93
+ seen.add(member)
94
+ if (installed.has(member)) kept.push(member)
95
+ }
96
+ let themeCount = 0
97
+ for (const member of kept) if (themes.has(member)) themeCount += 1
98
+ if (themeCount > 1) {
99
+ return { ok: false, error: 'a group can contain at most one theme / 每组最多一个主题' }
100
+ }
101
+ state.groups[name] = kept
102
+ return { ok: true }
103
+ }
104
+
105
+ /** Drop `name` from every group (called after a successful uninstall). */
106
+ export function removeFromGroups(state: GroupState, name: string): void {
107
+ for (const group of Object.keys(state.groups)) {
108
+ const members = state.groups[group]
109
+ if (members.includes(name)) state.groups[group] = members.filter(member => member !== name)
110
+ }
111
+ }
@@ -0,0 +1,53 @@
1
+ /** ChatCode CLI Home resolution with legacy Harness input compatibility. */
2
+
3
+ import { homedir } from 'node:os'
4
+ import { join, resolve } from 'node:path'
5
+
6
+ /** Default single-root ChatCode CLI home. */
7
+ export function defaultChatCodeHome(): string {
8
+ return join(homedir(), '.chatcode-cli')
9
+ }
10
+
11
+ /** @deprecated Compatibility alias for downstream code. */
12
+ export const defaultDshHome = defaultChatCodeHome
13
+
14
+ /** Expand the tilde forms supported by ChatCode CLI configuration. */
15
+ export function expandHomePath(path: string): string {
16
+ if (path === '~') return homedir()
17
+ if (path.startsWith('~/') || path.startsWith('~\\')) return join(homedir(), path.slice(2))
18
+ return path
19
+ }
20
+
21
+ /**
22
+ * Resolve an explicit home, `CHATCODE_CLI_HOME`, legacy `DSH_HOME`, or the
23
+ * default to one normalized absolute path. Blank environment values are unset.
24
+ */
25
+ export function resolveChatCodeHome(
26
+ configured?: string,
27
+ env: Record<string, string | undefined> = process.env,
28
+ ): string {
29
+ const chatCodeHome = env.CHATCODE_CLI_HOME?.trim()
30
+ const legacyHome = env.DSH_HOME?.trim()
31
+ const selected = configured
32
+ ?? (chatCodeHome !== undefined && chatCodeHome.length > 0
33
+ ? chatCodeHome
34
+ : legacyHome !== undefined && legacyHome.length > 0
35
+ ? legacyHome
36
+ : defaultChatCodeHome())
37
+ return resolve(expandHomePath(selected))
38
+ }
39
+
40
+ /** @deprecated Compatibility alias for callers that still import the old API. */
41
+ export const resolveDshHome = resolveChatCodeHome
42
+
43
+ /** Read a non-empty ChatCode variable before its legacy compatibility input. */
44
+ export function compatibilityEnv(
45
+ env: Record<string, string | undefined>,
46
+ currentName: string,
47
+ legacyName: string,
48
+ ): string | undefined {
49
+ const current = env[currentName]?.trim()
50
+ if (current !== undefined && current.length > 0) return current
51
+ const legacy = env[legacyName]?.trim()
52
+ return legacy !== undefined && legacy.length > 0 ? legacy : undefined
53
+ }