@chatcode/cco-market 1.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/README.zh.md +143 -0
  4. package/UPDATE-API-V1.md +132 -0
  5. package/client/client.js +11534 -0
  6. package/cordis.patch.yml +5 -0
  7. package/lib/accelerate.js +184 -0
  8. package/lib/agents.js +36 -0
  9. package/lib/backup.js +572 -0
  10. package/lib/catalog-local-match.js +126 -0
  11. package/lib/catalog-npm.js +111 -0
  12. package/lib/changelog.js +242 -0
  13. package/lib/channels.js +64 -0
  14. package/lib/check.js +1067 -0
  15. package/lib/compatibility.js +198 -0
  16. package/lib/diagnostics.js +49 -0
  17. package/lib/discovery-compatibility.js +260 -0
  18. package/lib/dsh-cli.js +949 -0
  19. package/lib/dsh-install.js +108 -0
  20. package/lib/gist.js +352 -0
  21. package/lib/groups.js +97 -0
  22. package/lib/home-paths.js +42 -0
  23. package/lib/hot.js +519 -0
  24. package/lib/http.js +39 -0
  25. package/lib/index.js +97 -0
  26. package/lib/install.js +387 -0
  27. package/lib/log.js +197 -0
  28. package/lib/ndjson.js +154 -0
  29. package/lib/net.js +98 -0
  30. package/lib/order.js +284 -0
  31. package/lib/patch.js +521 -0
  32. package/lib/pnpm-compat.js +490 -0
  33. package/lib/presets.js +278 -0
  34. package/lib/profile.js +911 -0
  35. package/lib/region-probe.js +93 -0
  36. package/lib/regions.js +253 -0
  37. package/lib/registry.js +204 -0
  38. package/lib/restart.js +373 -0
  39. package/lib/routes.js +4372 -0
  40. package/lib/settings.js +109 -0
  41. package/lib/skill-market/api.js +119 -0
  42. package/lib/skill-market/config.js +29 -0
  43. package/lib/skill-market/installer.js +352 -0
  44. package/lib/skill-market/runtime.js +13 -0
  45. package/lib/skill-market/tui.js +119 -0
  46. package/lib/skill-market/types.js +1 -0
  47. package/lib/skill-market/web-routes.js +123 -0
  48. package/lib/snapshot.js +500 -0
  49. package/lib/source-migration.js +54 -0
  50. package/lib/sources.js +596 -0
  51. package/lib/store.js +91 -0
  52. package/lib/themes.js +102 -0
  53. package/lib/trial.js +116 -0
  54. package/lib/types/accelerate.d.ts +85 -0
  55. package/lib/types/agents.d.ts +22 -0
  56. package/lib/types/backup.d.ts +131 -0
  57. package/lib/types/catalog-local-match.d.ts +50 -0
  58. package/lib/types/catalog-npm.d.ts +47 -0
  59. package/lib/types/changelog.d.ts +102 -0
  60. package/lib/types/channels.d.ts +56 -0
  61. package/lib/types/check.d.ts +266 -0
  62. package/lib/types/compatibility.d.ts +64 -0
  63. package/lib/types/diagnostics.d.ts +29 -0
  64. package/lib/types/discovery-compatibility.d.ts +80 -0
  65. package/lib/types/dsh-cli.d.ts +324 -0
  66. package/lib/types/dsh-install.d.ts +46 -0
  67. package/lib/types/gist.d.ts +53 -0
  68. package/lib/types/groups.d.ts +34 -0
  69. package/lib/types/home-paths.d.ts +16 -0
  70. package/lib/types/hot.d.ts +221 -0
  71. package/lib/types/http.d.ts +12 -0
  72. package/lib/types/index.d.ts +14 -0
  73. package/lib/types/install.d.ts +172 -0
  74. package/lib/types/log.d.ts +43 -0
  75. package/lib/types/ndjson.d.ts +52 -0
  76. package/lib/types/net.d.ts +58 -0
  77. package/lib/types/order.d.ts +100 -0
  78. package/lib/types/patch.d.ts +129 -0
  79. package/lib/types/pnpm-compat.d.ts +93 -0
  80. package/lib/types/presets.d.ts +90 -0
  81. package/lib/types/profile.d.ts +253 -0
  82. package/lib/types/region-probe.d.ts +50 -0
  83. package/lib/types/regions.d.ts +122 -0
  84. package/lib/types/registry.d.ts +86 -0
  85. package/lib/types/restart.d.ts +194 -0
  86. package/lib/types/routes.d.ts +67 -0
  87. package/lib/types/settings.d.ts +78 -0
  88. package/lib/types/skill-market/api.d.ts +10 -0
  89. package/lib/types/skill-market/config.d.ts +3 -0
  90. package/lib/types/skill-market/installer.d.ts +23 -0
  91. package/lib/types/skill-market/runtime.d.ts +10 -0
  92. package/lib/types/skill-market/tui.d.ts +4 -0
  93. package/lib/types/skill-market/types.d.ts +71 -0
  94. package/lib/types/skill-market/web-routes.d.ts +19 -0
  95. package/lib/types/snapshot.d.ts +93 -0
  96. package/lib/types/source-migration.d.ts +11 -0
  97. package/lib/types/sources.d.ts +216 -0
  98. package/lib/types/store.d.ts +30 -0
  99. package/lib/types/themes.d.ts +40 -0
  100. package/lib/types/trial.d.ts +61 -0
  101. package/lib/types/update-api-v1.d.ts +66 -0
  102. package/lib/types/updates.d.ts +123 -0
  103. package/lib/types/verify.d.ts +139 -0
  104. package/lib/update-api-v1.js +215 -0
  105. package/lib/updates.js +361 -0
  106. package/lib/verify.js +453 -0
  107. package/package.json +121 -0
  108. package/src/accelerate.ts +213 -0
  109. package/src/agents.ts +43 -0
  110. package/src/backup.ts +583 -0
  111. package/src/catalog-local-match.ts +144 -0
  112. package/src/catalog-npm.ts +120 -0
  113. package/src/changelog.ts +282 -0
  114. package/src/channels.ts +70 -0
  115. package/src/check.ts +1239 -0
  116. package/src/client/CommentsModal.tsx +119 -0
  117. package/src/client/Diagnostics.tsx +907 -0
  118. package/src/client/ErrorBoundary.tsx +111 -0
  119. package/src/client/InstallToast.tsx +31 -0
  120. package/src/client/Market.module.css +830 -0
  121. package/src/client/MarketSection.tsx +5301 -0
  122. package/src/client/OperationsPanel.tsx +365 -0
  123. package/src/client/SettingsCard.tsx +621 -0
  124. package/src/client/SkillMarket.module.css +21 -0
  125. package/src/client/SkillMarketSection.tsx +162 -0
  126. package/src/client/comments.ts +54 -0
  127. package/src/client/globals.d.ts +13 -0
  128. package/src/client/index.ts +180 -0
  129. package/src/client/locales.ts +1112 -0
  130. package/src/client/market-data.ts +1321 -0
  131. package/src/client/operations.ts +201 -0
  132. package/src/client/preset-panel.tsx +263 -0
  133. package/src/client/primitives.d.ts +140 -0
  134. package/src/client/self-check.ts +147 -0
  135. package/src/client/snapshot-panel.tsx +244 -0
  136. package/src/compatibility.ts +237 -0
  137. package/src/diagnostics.ts +84 -0
  138. package/src/discovery-compatibility.ts +315 -0
  139. package/src/dsh-cli.ts +1126 -0
  140. package/src/dsh-install.ts +118 -0
  141. package/src/gist.ts +362 -0
  142. package/src/groups.ts +111 -0
  143. package/src/home-paths.ts +53 -0
  144. package/src/hot.ts +628 -0
  145. package/src/http.ts +41 -0
  146. package/src/index.ts +128 -0
  147. package/src/install.ts +420 -0
  148. package/src/log.ts +206 -0
  149. package/src/ndjson.ts +185 -0
  150. package/src/net.ts +106 -0
  151. package/src/order.ts +303 -0
  152. package/src/patch.ts +522 -0
  153. package/src/pnpm-compat.ts +527 -0
  154. package/src/presets.ts +344 -0
  155. package/src/profile.ts +940 -0
  156. package/src/region-probe.ts +97 -0
  157. package/src/regions.ts +310 -0
  158. package/src/registry.ts +250 -0
  159. package/src/restart.ts +396 -0
  160. package/src/routes.ts +4506 -0
  161. package/src/settings.ts +141 -0
  162. package/src/skill-market/api.ts +130 -0
  163. package/src/skill-market/config.ts +32 -0
  164. package/src/skill-market/installer.ts +337 -0
  165. package/src/skill-market/runtime.ts +14 -0
  166. package/src/skill-market/tui.ts +157 -0
  167. package/src/skill-market/types.ts +80 -0
  168. package/src/skill-market/web-routes.ts +132 -0
  169. package/src/snapshot.ts +532 -0
  170. package/src/source-migration.ts +61 -0
  171. package/src/sources.ts +565 -0
  172. package/src/store.ts +89 -0
  173. package/src/themes.ts +125 -0
  174. package/src/trial.ts +156 -0
  175. package/src/update-api-v1.ts +277 -0
  176. package/src/updates.ts +400 -0
  177. package/src/verify.ts +492 -0
package/src/dsh-cli.ts ADDED
@@ -0,0 +1,1126 @@
1
+ /**
2
+ * Process layer: re-invoking the ChatCode CLI launcher that started this host,
3
+ * spawning plugin commands with timeouts and live progress, and provisioning
4
+ * pnpm. This is the only module that starts child processes.
5
+ *
6
+ * Installs run through node:child_process, not ctx.shell: the shell service is
7
+ * the agent's sandboxed executor and denies writes to the profile directory.
8
+ */
9
+
10
+ import { spawn } from 'node:child_process'
11
+ import type { ChildProcess, SpawnOptions } from 'node:child_process'
12
+ import { existsSync } from 'node:fs'
13
+ import { homedir } from 'node:os'
14
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
15
+ import { logEvent } from './log.ts'
16
+ import { createProgressTracker, type ProgressPhase } from './ndjson.ts'
17
+ import { pluginArgsFor } from './pnpm-compat.ts'
18
+ import { isDshProfileName, profileDir } from './profile.ts'
19
+ import { activeRegion, DEFAULT_NPM_REGISTRY, routesFor, type Region } from './regions.ts'
20
+ import { NPM_NAME_RE } from './sources.ts'
21
+ import { fetchNpmLatest } from './updates.ts'
22
+ import { compatibilityEnv } from './home-paths.ts'
23
+
24
+ // 15 min default (slow networks + git installs), overridable for CI/tests.
25
+ // (#6 by @qichuang321.)
26
+ /**
27
+ * macOS apps launched from Finder/Dock inherit a minimal PATH without the
28
+ * shell profile — Homebrew/npm/corepack all vanish and every install dies
29
+ * with ENOENT/127 (#32, #38). Append the well-known bin directories so the
30
+ * market's children find their tools regardless of how dsh was started.
31
+ */
32
+ /**
33
+ * Directories discovered at runtime that hold a usable pnpm — currently
34
+ * npm's global bin, learned after a successful one-click setup (#149).
35
+ * Every later spawn sees them, so the market does not have to be restarted
36
+ * for the pnpm it just installed to become visible.
37
+ */
38
+ const extraPathDirs: string[] = []
39
+
40
+ /**
41
+ * The real Node executable for spawning children. On Android the kernel runs
42
+ * node through the dynamic linker, so `process.execPath` is
43
+ * `/apex/.../linker64` — spawning IT with `--expose-internals` makes the
44
+ * linker treat the flag as the program path and die with
45
+ * `error: expected absolute path: "--expose-internals"`. `process.argv0`
46
+ * carries the real node binary; prefer it whenever it is an existing
47
+ * absolute path, and fall back to execPath everywhere else.
48
+ * @param argv0 - `process.argv0`, injectable for tests.
49
+ * @param execPath - `process.execPath`, injectable for tests.
50
+ */
51
+ export function nodeExecutable(argv0: string | undefined = process.argv0, execPath: string = process.execPath): string {
52
+ if (argv0 !== undefined && argv0 !== '' && isAbsolute(argv0) && existsSync(argv0))
53
+ return argv0
54
+ return execPath
55
+ }
56
+
57
+ /**
58
+ * The directory holding the Node binary running this process. `npm`,
59
+ * `npm.cmd` and `corepack` are installed alongside it by every official Node
60
+ * distribution, so it is the one place the toolchain can be looked for
61
+ * without guessing — and unlike a PATH entry it cannot be absent, because
62
+ * this process is executing out of it.
63
+ *
64
+ * #167: a Windows desktop host spawned dsh without the Node install
65
+ * directory on PATH. Node itself was running (v24.18.1 in the log) while
66
+ * both `corepack` and `npm` came back "not recognized as an internal or
67
+ * external command", so the one-click setup had no way to succeed.
68
+ */
69
+ export const nodeBinDir = dirname(nodeExecutable())
70
+
71
+ /**
72
+ * Translate the machine's proxy environment into the ONE form pnpm reads.
73
+ *
74
+ * `HTTPS_PROXY` / `http_proxy` are what every other tool honours, and what
75
+ * `net.ts` already routes the market's own catalog fetches through — but
76
+ * pnpm ignores them completely. It reads npm config, so a proxy reaches it
77
+ * only as `npm_config_https_proxy` / `npm_config_proxy` (or an .npmrc entry,
78
+ * which is the user's file and not ours to rewrite).
79
+ *
80
+ * That gap is why the market could load its catalog through a proxy and
81
+ * then hang installing anything at all — reported four separate times
82
+ * (#148, #161, #188, #232), always from a network that needs one.
83
+ *
84
+ * An `npm_config_*` value the caller already set always wins: it is the more
85
+ * specific statement of intent, and on Windows env keys are case-insensitive
86
+ * so the check has to be too. NO_PROXY is forwarded verbatim because pnpm
87
+ * reads `npm_config_noproxy` and a host excluding its own registry mirror
88
+ * must keep excluding it.
89
+ */
90
+ export function proxyEnvForPnpm(env: NodeJS.ProcessEnv = process.env, region: Region = 'global'): NodeJS.ProcessEnv {
91
+ const has = (name: string): boolean => {
92
+ const wanted = name.toLowerCase()
93
+ return Object.keys(env).some(key => key.toLowerCase() === wanted && (env[key] ?? '').trim() !== '')
94
+ }
95
+ const pick = (...names: string[]): string | null => {
96
+ for (const name of names) {
97
+ const raw = env[name]
98
+ if (raw !== undefined && raw.trim() !== '') return raw.trim()
99
+ }
100
+ return null
101
+ }
102
+ const out: NodeJS.ProcessEnv = {}
103
+ // Three consumers, three vocabularies, one proxy. The market's own fetch
104
+ // reads the standard vars (and, since #263, npm config too); pnpm reads
105
+ // ONLY npm config; and `git` — which pnpm shells out to for every
106
+ // git-hosted plugin — reads only the standard vars and never npm config.
107
+ // Translating one direction left the third out: registry installs went
108
+ // through the proxy while git installs went direct and failed with
109
+ // "Failed to connect to github.com:443" (#274 by @rucsocial).
110
+ //
111
+ // Same precedence as undici's EnvHttpProxyAgent (lowercase over
112
+ // uppercase, https falling back to http).
113
+ const stdHttps = pick('https_proxy', 'HTTPS_PROXY') ?? pick('http_proxy', 'HTTP_PROXY')
114
+ const stdHttp = pick('http_proxy', 'HTTP_PROXY') ?? stdHttps
115
+ if (stdHttps !== null && !has('npm_config_https_proxy')) out.npm_config_https_proxy = stdHttps
116
+ if (stdHttp !== null && !has('npm_config_proxy')) out.npm_config_proxy = stdHttp
117
+ const stdNoProxy = pick('no_proxy', 'NO_PROXY')
118
+ if (stdNoProxy !== null && !has('npm_config_noproxy')) out.npm_config_noproxy = stdNoProxy
119
+
120
+ // The other direction, and ONLY when the standard vocabulary is empty.
121
+ // A proxy known solely to npm config is the case that stranded git; if
122
+ // the caller has said anything in the standard vars, that is their
123
+ // statement about what git should do and copying npm's answer over it
124
+ // would invent a setting they did not make — notably an HTTP_PROXY for
125
+ // someone who deliberately proxied https only.
126
+ if (stdHttps === null && stdHttp === null) {
127
+ const npmHttps = pick('npm_config_https_proxy') ?? pick('npm_config_proxy')
128
+ const npmHttp = pick('npm_config_proxy') ?? npmHttps
129
+ if (npmHttps !== null) out.HTTPS_PROXY = npmHttps
130
+ if (npmHttp !== null) out.HTTP_PROXY = npmHttp
131
+ const npmNoProxy = pick('npm_config_noproxy')
132
+ if (npmNoProxy !== null && stdNoProxy === null) out.NO_PROXY = npmNoProxy
133
+ }
134
+ // The download region's npm mirror, when it has one.
135
+ //
136
+ // Last, and conditionally: a registry the caller already named is their
137
+ // statement about where packages come from, and a region setting must not
138
+ // overrule it. Same rule the proxy translation above follows, for the same
139
+ // reason — this function's job is to fill silence, not to overwrite speech.
140
+ const mirror = routesFor(region).npmRegistry
141
+ if (mirror !== DEFAULT_NPM_REGISTRY && !has('npm_config_registry')) {
142
+ // npm's own config convention terminates the registry with a slash;
143
+ // pnpm accepts either, but writing it the conventional way keeps the
144
+ // value recognizable to anyone reading the spawned process's env.
145
+ out.npm_config_registry = `${mirror}/`
146
+ }
147
+ return out
148
+ }
149
+
150
+ /**
151
+ * Directories to append to PATH so a spawned pnpm can be found (#32, #38,
152
+ * #167, #292).
153
+ *
154
+ * A GUI or desktop launch inherits none of the shell profile, so PATH holds
155
+ * whatever the launcher had — usually not the directory the user's package
156
+ * manager lives in. The market appends the places it is actually installed
157
+ * to rather than telling the user to fix their environment.
158
+ *
159
+ * Windows used to get only the Node directory, which made the market's own
160
+ * advice unfollowable: the error it prints recommends installing pnpm with
161
+ * `iwr https://get.pnpm.io/install.ps1`, and then it did not look where that
162
+ * installer puts it (#292). Both Windows layouts are covered now — the
163
+ * standalone installer's `%LOCALAPPDATA%\pnpm`, and `%APPDATA%\npm` where
164
+ * `npm i -g pnpm` writes `pnpm.cmd`.
165
+ *
166
+ * `PNPM_HOME` comes first on every platform: the installer sets it, so it is
167
+ * the one answer that is right even when the layout is not the default one.
168
+ *
169
+ * @param platform - `process.platform`, injectable for tests.
170
+ * @param env - environment, for PNPM_HOME and the Windows app-data roots.
171
+ * @param home - home directory, injectable for tests.
172
+ */
173
+ export function toolSearchDirs(
174
+ platform: string = process.platform,
175
+ env: NodeJS.ProcessEnv = process.env,
176
+ home: string = homedir(),
177
+ ): string[] {
178
+ const dirs: string[] = []
179
+ const pnpmHome = (env.PNPM_HOME ?? '').trim()
180
+ if (pnpmHome !== '') dirs.push(pnpmHome)
181
+ if (platform === 'win32') {
182
+ const local = (env.LOCALAPPDATA ?? '').trim()
183
+ const roaming = (env.APPDATA ?? '').trim()
184
+ if (local !== '') dirs.push(join(local, 'pnpm'))
185
+ if (roaming !== '') dirs.push(join(roaming, 'npm'))
186
+ } else {
187
+ dirs.push('/opt/homebrew/bin', '/usr/local/bin', join(home, '.local', 'bin'))
188
+ // Where the standalone installer lands when PNPM_HOME is unset.
189
+ dirs.push(join(home, 'Library', 'pnpm'), join(home, '.local', 'share', 'pnpm'))
190
+ }
191
+ dirs.push(nodeBinDir, ...extraPathDirs)
192
+ // Deduped: PNPM_HOME usually names one of the defaults below it, and a
193
+ // list that says the same directory twice reads as carelessness in the
194
+ // one place a user goes looking for an answer.
195
+ return [...new Set(dirs.filter(dir => dir.trim() !== ''))]
196
+ }
197
+
198
+ function spawnEnv(): NodeJS.ProcessEnv {
199
+ // pnpm v10+ blocks forever on a silent interactive prompt without a TTY;
200
+ // CI mode forces it to act or fail instead of asking.
201
+ const separator = process.platform === 'win32' ? ';' : ':'
202
+ const parts = (process.env.PATH ?? '').split(separator).filter(part => part !== '')
203
+ for (const bin of toolSearchDirs()) {
204
+ if (!parts.includes(bin)) parts.push(bin)
205
+ }
206
+ return { ...process.env, ...proxyEnvForPnpm(process.env, activeRegion()), CI: 'true', PATH: parts.join(separator) }
207
+ }
208
+
209
+ const INSTALL_TIMEOUT_MS = Number(compatibilityEnv(
210
+ process.env,
211
+ 'CHATCODE_CLI_MARKET_INSTALL_TIMEOUT_MS',
212
+ 'DSH_MARKET_INSTALL_TIMEOUT_MS',
213
+ )) || 15 * 60 * 1000
214
+
215
+ /**
216
+ * Windows npm/corepack/pnpm are `.cmd` shims. Node's `spawn` without a shell
217
+ * cannot start them (ENOENT / EINVAL). Same pattern as dsh's `plugin` forwarder.
218
+ */
219
+ export const winCmdShim = process.platform === 'win32'
220
+
221
+ /** Characters cmd.exe treats as syntax even inside a token. */
222
+ const CMD_METACHARS = /[\s"&|<>^()%!]/
223
+
224
+ /**
225
+ * Quote one argv token for a cmd.exe `/c` command line. cmd only groups with
226
+ * double quotes, so a token that needs quoting gets wrapped and embedded
227
+ * quotes are doubled.
228
+ */
229
+ export function quoteCmdArg(arg: string): string {
230
+ if (!CMD_METACHARS.test(arg)) return arg
231
+ return `"${arg.replace(/"/g, '""')}"`
232
+ }
233
+
234
+ /**
235
+ * Build a cmd.exe command line from argv. Only the Windows shim path uses
236
+ * this: cmd re-parses the joined string, so every token is quoted before
237
+ * joining.
238
+ */
239
+ export function cmdCommandLine(argv: readonly string[]): string {
240
+ return argv.map(quoteCmdArg).join(' ')
241
+ }
242
+
243
+ /**
244
+ * Whether a profile name can cross the rare Windows `dsh.cmd` fallback.
245
+ *
246
+ * cmd.exe expands percent-delimited environment variables even inside a
247
+ * quoted argument. Keep that fallback to names made only of letters, marks,
248
+ * numbers, spaces, dots, underscores, and hyphens. The normal direct-Node
249
+ * launcher remains argv-safe and accepts every DSH-valid profile name.
250
+ */
251
+ export function isCmdSafeProfileName(profile: string): boolean {
252
+ return isDshProfileName(profile) && /^[\p{L}\p{M}\p{N}._ -]+$/u.test(profile)
253
+ }
254
+
255
+ /** cmd.exe resolved once; the Windows shim path only. */
256
+ const COMSPEC = process.env.ComSpec ?? 'cmd.exe'
257
+
258
+ /** Spawn options plus the explicit shim switch used by callers. */
259
+ type SpawnShimOptions = SpawnOptions & { viaShell?: boolean }
260
+
261
+ /**
262
+ * Spawn a command, avoiding Node's deprecated `shell: true` + argv
263
+ * combination (DEP0190). Windows `.cmd` shims cannot start without a shell,
264
+ * so the shim path routes through `cmd.exe /d /s /c` with an explicitly
265
+ * built, quoted command line; every other invocation spawns directly with
266
+ * `shell: false`. Hide consoles when the host itself has no console (#530).
267
+ */
268
+ function spawnShim(file: string, args: readonly string[], options: SpawnShimOptions): ChildProcess {
269
+ const { viaShell = false, ...spawnOptions } = options
270
+ if (!viaShell) {
271
+ return spawn(file, [...args], { ...spawnOptions, shell: false, windowsHide: true })
272
+ }
273
+ if (process.platform !== 'win32') {
274
+ return spawn(file, [...args], { ...spawnOptions, shell: false, windowsHide: true })
275
+ }
276
+ return spawn(COMSPEC, ['/d', '/s', '/c', `"${cmdCommandLine([file, ...args])}"`], {
277
+ ...spawnOptions,
278
+ shell: false,
279
+ windowsVerbatimArguments: true,
280
+ windowsHide: true,
281
+ })
282
+ }
283
+
284
+ /**
285
+ * Argv re-invoking the CLI that launched this host process, so installs work
286
+ * whether ChatCode CLI runs from a global bin, a local install, or repo source
287
+ * (`node --import tsx/esm .../bin.ts`). Falls back to PATH `chatcode-cli`.
288
+ */
289
+ export function dshArgv(): { file: string; args: string[]; cwd: string | undefined; viaShell: boolean } {
290
+ const entry = process.argv[1]
291
+ if (entry !== undefined && /[\\/](?:bin\.(?:js|ts)|chatcode-cli|cco|dsh)$/.test(entry)) {
292
+ // Absolute paths are required: source launches pass a
293
+ // relative entry, which the child resolves against its OWN cwd and dies
294
+ // with MODULE_NOT_FOUND (#13). cwd near the entry keeps execArgv imports
295
+ // (tsx/esm) resolvable on source launches.
296
+ const abs = resolve(entry)
297
+ return { file: nodeExecutable(), args: [...process.execArgv, abs], cwd: dirname(abs), viaShell: false }
298
+ }
299
+ // Bare `chatcode-cli` is a .cmd shim on Windows that only a shell can start (#13).
300
+ return { file: 'chatcode-cli', args: [], cwd: undefined, viaShell: winCmdShim }
301
+ }
302
+
303
+ /** Outcome of one spawned plugin command. */
304
+ export interface InstallResult {
305
+ exitCode: number | null
306
+ timedOut: boolean
307
+ stdout: string
308
+ stderr: string
309
+ /** True when the run ended because the user cancelled it. */
310
+ cancelled: boolean
311
+ /** Desktop's generation-wide package-operation gate rejected the start. */
312
+ busy?: boolean
313
+ /** Package names pnpm reported as having ignored build scripts (ndjson). */
314
+ ignoredBuilds?: string[]
315
+ /**
316
+ * pnpm's OWN error message and code, from its structured ndjson stream
317
+ * (#244).
318
+ *
319
+ * Without this the only thing a failure could report was the tail of
320
+ * stderr — which for a market install is dsh's wrapper line, "pnpm failed
321
+ * in profile directory …", identical for every possible cause. pnpm's
322
+ * real error never went to stderr at all; it goes to the ndjson stdout
323
+ * this already parses for progress, and was being thrown away on the way
324
+ * out. Three separate reports (#244, #192, #138) are all "the UI shows a
325
+ * stack tail and nothing else".
326
+ */
327
+ pnpmError?: string
328
+ pnpmErrorCode?: string
329
+ /**
330
+ * Exact npm version this Desktop run actually handed to the host (#496).
331
+ *
332
+ * Anywhere Labs' install boundary rewrites a floating dist-tag (or bare
333
+ * name) to `name@x.y.z` with its own registry fetch. The update route
334
+ * normally pins that version itself before `add`, so this field matches
335
+ * the request and is unused for verification. It matters when registry
336
+ * metadata was unavailable and the add still carried `@latest`/`@beta`:
337
+ * verification then adopts this pin instead of leaving the expected
338
+ * version unset. When the route already sent an exact pin, callers must
339
+ * keep that pin authoritative and must not let this field lower it.
340
+ */
341
+ resolvedNpmVersion?: string
342
+ }
343
+
344
+ /** The shape every orchestration function takes to run plugin commands (injectable in tests). */
345
+ export type PluginRunner = (profile: string, pluginArgs: string[]) => Promise<InstallResult>
346
+
347
+ /** Package-operation boundary consumed by the HTTP route layer. */
348
+ export interface PluginCommandRuntime {
349
+ runPlugin: PluginRunner
350
+ probePnpm(): Promise<boolean>
351
+ provisionPnpm(): Promise<{ ok: boolean; hint?: string }>
352
+ cancelActive(): boolean
353
+ /** Whether this host can execute an immutable rollback add target. */
354
+ supportsExactRollbackTarget?(target: string): boolean
355
+ }
356
+
357
+ /** One running package operation, however it was started. */
358
+ export interface DesktopPnpmHandleLike {
359
+ readonly stdout: NodeJS.ReadableStream
360
+ readonly stderr: NodeJS.ReadableStream
361
+ readonly done: Promise<{
362
+ readonly exitCode: number | null
363
+ readonly signal: NodeJS.Signals | null
364
+ }>
365
+ cancel(): void
366
+ }
367
+
368
+ /**
369
+ * Structural subset of DSH Desktop's public `desktopPnpm` contract.
370
+ *
371
+ * Anywhere Labs' DSH Desktop is ONE third-party client among several, and
372
+ * this interface exists only for it. Nothing here is part of the official
373
+ * DSH protocol — `desktopPnpm`, `installPlugin` and the install boundary
374
+ * below appear nowhere in `@deepseek-ai/*`. Every other client the market
375
+ * runs under, including other desktop apps, installs through the ordinary
376
+ * `dsh plugin --profile <p> add` CLI, and so does the market itself when
377
+ * none of these services are present.
378
+ *
379
+ * That is why every member past `runPlugin` is optional and reached by
380
+ * feature detection. A host that does not publish one simply never enters
381
+ * the branch, and the ordinary path it already used stays untouched — the
382
+ * cost of accommodating one vendor must not be paid by the others, or by
383
+ * the far larger number of people on plain `dsh web`.
384
+ */
385
+ export interface DesktopPnpmLike {
386
+ runPlugin(
387
+ args: readonly string[],
388
+ invokingDir: string,
389
+ signal?: AbortSignal,
390
+ ): DesktopPnpmHandleLike
391
+
392
+ /**
393
+ * Desktop 2.x refuses `add` through `runPlugin` — "plugin add must use the
394
+ * recoverable install boundary" (#215, #219, #272) — and offers this
395
+ * instead, which their launcher enables only for the selected market
396
+ * provider. Same arguments, same handle, no recovery receipt and no
397
+ * write-ahead log for the caller to reconcile.
398
+ *
399
+ * Optional because it is theirs: absent on every other host, including
400
+ * the other third-party desktop client in #292, which installs perfectly
401
+ * well through the ordinary CLI.
402
+ *
403
+ * Read from their published source rather than assumed: it accepts ONLY
404
+ * `add` with exactly one target of the form `name@exact.version`
405
+ * (`validateExternalMarketInstallArgs` in dsh-plugin-desktop/src/pnpm.ts).
406
+ * A `github:owner/repo` target is rejected before any process starts, so
407
+ * the 1085 catalog entries with no npm package — 57% of it — cannot be
408
+ * installed on that host by any spelling this market could send. That is
409
+ * a gap in their contract, not something to work around here.
410
+ */
411
+ runExternalMarketPluginInstall?(
412
+ args: readonly string[],
413
+ invokingDir: string,
414
+ signal?: AbortSignal,
415
+ ): DesktopPnpmHandleLike
416
+ }
417
+
418
+ /** An npm name with a fully pinned version — the only target their boundary takes. */
419
+ const EXACT_NPM_TARGET_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
420
+
421
+ /**
422
+ * Rewrite an `add` argv into the shape Anywhere Labs' install boundary
423
+ * accepts, or null when it cannot be expressed there.
424
+ *
425
+ * Their validator wants exactly one target of the form `name@1.2.3` — not a
426
+ * bare name, not `@latest`, and not a `github:` source (read from
427
+ * `validateExternalMarketInstallArgs`, dsh-plugin-desktop/src/pnpm.ts). The
428
+ * market prefers to send an already-resolved `name@x.y.z` for npm updates
429
+ * (#496); a bare name or dist-tag still needs resolving here when some other
430
+ * path hands one over.
431
+ *
432
+ * Returning null is a normal outcome, not a failure: a github-sourced plugin
433
+ * has no `name@version` spelling at all. The caller falls back to the
434
+ * ordinary path, which on that host reports their own refusal — an accurate
435
+ * message about their contract, rather than one this package invented.
436
+ */
437
+ /**
438
+ * Added to a refusal from a host whose install boundary only takes
439
+ * `name@exact.version`. Their message is accurate and stays first; this says
440
+ * which property of the plugin put it out of reach, because the user picked a
441
+ * card and has no way to know the difference from the outside (#138).
442
+ */
443
+ const NPM_ONLY_HOST_NOTE
444
+ = '这个桌面客户端只能安装已发布到 npm 的插件,而该插件仅提供 GitHub 源,因此装不了——这是客户端的安装边界,不是插件或市场的问题。'
445
+ + '可以改用普通 chatcode-cli web 安装,或请插件作者发布 npm 包。 / '
446
+ + 'This desktop client can only install plugins published to npm, and this one is GitHub-only, so it cannot be installed here. '
447
+ + 'That is the client\'s install boundary, not a fault in the plugin or the market. '
448
+ + 'Install it from plain chatcode-cli web instead, or ask the author to publish to npm.'
449
+
450
+ async function exactNpmArgs(args: readonly string[]): Promise<{
451
+ args: string[]
452
+ resolvedNpmVersion: string
453
+ } | null> {
454
+ const targets = args.slice(1).filter(argument => !argument.startsWith('-'))
455
+ const target = targets[0]
456
+ if (targets.length !== 1 || target === undefined) return null
457
+ if (EXACT_NPM_TARGET_RE.test(target)) {
458
+ // Already exact — still report the pin so update verification can align
459
+ // with what this host will install, not with a separate `latest` fetch.
460
+ return { args: [...args], resolvedNpmVersion: target.slice(target.lastIndexOf('@') + 1) }
461
+ }
462
+ // A bare name, or one pinned to a dist-tag. Only a registry package can be
463
+ // resolved; `github:owner/repo` and file paths stop here.
464
+ const at = target.lastIndexOf('@')
465
+ const name = at > 0 ? target.slice(0, at) : target
466
+ if (!NPM_NAME_RE.test(name)) return null
467
+ const version = await fetchNpmLatest(name)
468
+ if (version === null) return null
469
+ const rewritten = `${name}@${version}`
470
+ if (!EXACT_NPM_TARGET_RE.test(rewritten)) return null
471
+ logEvent('info', 'install', `desktop install boundary needs an exact version: ${target} -> ${rewritten}`)
472
+ return {
473
+ args: args.map(argument => (argument === target ? rewritten : argument)),
474
+ resolvedNpmVersion: version,
475
+ }
476
+ }
477
+
478
+ /** Desktop runtime also owns cleanup of any operation started by this fiber. */
479
+ export interface DesktopPluginRuntime extends PluginCommandRuntime {
480
+ dispose(): Promise<void>
481
+ }
482
+
483
+ /**
484
+ * Kill a spawned child and, on Windows, its whole process tree — `kill()`
485
+ * there only terminates the wrapper, leaving pnpm children running.
486
+ * (Contributed in #7 by @mraing.)
487
+ */
488
+ export function killChild(child: ChildProcess): void {
489
+ if (process.platform === 'win32' && child.pid !== undefined) {
490
+ try {
491
+ spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true })
492
+ return
493
+ } catch { /* fall through */ }
494
+ }
495
+ child.kill('SIGKILL')
496
+ }
497
+
498
+ /** The child of the operation currently running, for /dsh-market/cancel. */
499
+ let activeChild: ChildProcess | null = null
500
+ let cancelRequested = false
501
+
502
+ interface ActiveDesktopOperation {
503
+ readonly owner: symbol
504
+ readonly cancel: () => void
505
+ readonly done: Promise<InstallResult>
506
+ userCancelled: boolean
507
+ }
508
+
509
+ let activeDesktopOperation: ActiveDesktopOperation | null = null
510
+
511
+ /**
512
+ * Kill a child and its whole tree, gracefully where the platform allows:
513
+ * taskkill /T /F on Windows (plain kill() leaves pnpm children running),
514
+ * SIGTERM with a 5s SIGKILL escalation elsewhere so pnpm can clean up.
515
+ * (Cancel flow contributed in #6 by @qichuang321.)
516
+ */
517
+ function killTree(child: ChildProcess): void {
518
+ if (process.platform === 'win32' && child.pid !== undefined) {
519
+ try {
520
+ spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true })
521
+ return
522
+ } catch { /* fall through */ }
523
+ }
524
+ // POSIX: the dsh wrapper runs pnpm as a grandchild (spawnSync), which a
525
+ // plain child.kill() leaves running — it keeps our stdio pipes open, so
526
+ // the close event never fires and the market looks stuck "installing".
527
+ // The child is spawned detached as its own process GROUP; kill the group.
528
+ const signalTree = (signal: NodeJS.Signals): void => {
529
+ if (child.pid === undefined) return
530
+ try { process.kill(-child.pid, signal) } catch {
531
+ try { child.kill(signal) } catch { /* already gone */ }
532
+ }
533
+ }
534
+ signalTree('SIGTERM')
535
+ const escalate = setTimeout(() => signalTree('SIGKILL'), 5000)
536
+ escalate.unref?.()
537
+ }
538
+
539
+ /**
540
+ * Cancel the plugin command currently running.
541
+ * @returns true when there was one to cancel.
542
+ */
543
+ export function cancelActive(): boolean {
544
+ if (activeDesktopOperation !== null) {
545
+ activeDesktopOperation.userCancelled = true
546
+ progress.cancelling = true
547
+ activeDesktopOperation.cancel()
548
+ return true
549
+ }
550
+ if (activeChild === null) return false
551
+ cancelRequested = true
552
+ progress.cancelling = true
553
+ killTree(activeChild)
554
+ return true
555
+ }
556
+
557
+ /** Whether `pnpm` resolves on PATH; success is cached, absence is re-probed. */
558
+ let pnpmReady = false
559
+
560
+ /**
561
+ * Why the last probe said no.
562
+ *
563
+ * `missing` and `failed` are different problems with different fixes, and
564
+ * collapsing both into `false` made the market give one answer to both: it
565
+ * told a user whose pnpm ran perfectly from their shell to go set PNPM_HOME
566
+ * (#228). A binary that IS on the path and exits non-zero — a corepack shim
567
+ * that cannot reach the network to fetch pnpm itself is the common one —
568
+ * needs its own output shown, not a path to fix that is already right.
569
+ */
570
+ let pnpmProbeFailure: { kind: 'missing' | 'failed'; output: string } | null = null
571
+
572
+ /** Why `pnpm --version` last failed, or null when it has not failed. */
573
+ export function lastPnpmProbeFailure(): { kind: 'missing' | 'failed'; output: string } | null {
574
+ return pnpmProbeFailure
575
+ }
576
+
577
+ /** Probe `pnpm --version` on PATH. */
578
+ export function probePnpm(): Promise<boolean> {
579
+ if (pnpmReady) return Promise.resolve(true)
580
+ return new Promise((resolvePromise) => {
581
+ // Piped, not ignored: the output of a pnpm that exists but will not run
582
+ // IS the explanation, and throwing it away is what left #228 with a
583
+ // failure nobody could act on.
584
+ const child = spawnShim('pnpm', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'], viaShell: winCmdShim, env: spawnEnv() })
585
+ let output = ''
586
+ const collect = (chunk: Buffer): void => { output = (output + chunk.toString()).slice(-2000) }
587
+ child.stdout?.on('data', collect)
588
+ child.stderr?.on('data', collect)
589
+ child.on('error', (error) => {
590
+ pnpmProbeFailure = { kind: 'missing', output: error.message }
591
+ resolvePromise(false)
592
+ })
593
+ child.on('close', (code) => {
594
+ pnpmReady = code === 0
595
+ pnpmProbeFailure = pnpmReady ? null : { kind: 'failed', output: output.trim() }
596
+ resolvePromise(pnpmReady)
597
+ })
598
+ })
599
+ }
600
+
601
+ function runQuiet(file: string, args: string[], timeoutMs: number): Promise<{ code: number | null; output: string }> {
602
+ return new Promise((resolvePromise) => {
603
+ const child = spawnShim(file, args, {
604
+ env: spawnEnv(),
605
+ stdio: ['ignore', 'pipe', 'pipe'],
606
+ viaShell: winCmdShim,
607
+ })
608
+ let output = ''
609
+ const timer = setTimeout(() => killChild(child), timeoutMs)
610
+ const collect = (chunk: Buffer): void => { output = (output + chunk.toString()).slice(-8 * 1024) }
611
+ child.stdout?.on('data', collect)
612
+ child.stderr?.on('data', collect)
613
+ child.on('error', (error) => { clearTimeout(timer); resolvePromise({ code: 127, output: error.message }) })
614
+ child.on('close', (code) => { clearTimeout(timer); resolvePromise({ code, output }) })
615
+ })
616
+ }
617
+
618
+ /**
619
+ * Provision pnpm without user involvement: corepack (ships with Node) first,
620
+ * a global npm install as fallback.
621
+ * @returns true when `pnpm --version` succeeds afterwards.
622
+ */
623
+ export async function provisionPnpm(): Promise<{ ok: boolean; hint?: string }> {
624
+ const corepack = await runQuiet('corepack', ['enable', 'pnpm'], 60 * 1000)
625
+ logEvent(corepack.code === 0 ? 'info' : 'warn', 'setup-pnpm', `corepack enable: exit=${String(corepack.code)} ${corepack.output.slice(-200)}`)
626
+ if (await probePnpm()) return { ok: true }
627
+ const npm = await runQuiet('npm', ['install', '-g', 'pnpm'], 3 * 60 * 1000)
628
+ logEvent(npm.code === 0 ? 'info' : 'error', 'setup-pnpm', `npm -g: exit=${String(npm.code)} ${npm.output.slice(-200)}`)
629
+ if (await probePnpm()) return { ok: true }
630
+ // The install SUCCEEDED but the new binary is somewhere this process does
631
+ // not look (#149: corepack exit=0, npm -g exit=0, and the market still
632
+ // said "setup failed"). npm knows where it just put it, so ask — and if
633
+ // pnpm runs from there, remember that directory for every later spawn
634
+ // instead of telling the user a successful install failed.
635
+ if (npm.code === 0 || corepack.code === 0) {
636
+ const prefix = await runQuiet('npm', ['prefix', '-g'], 30 * 1000)
637
+ const root = prefix.code === 0 ? prefix.output.trim().split('\n').pop() ?? '' : ''
638
+ // `npm prefix -g` already is the executable directory on Windows
639
+ // (`pnpm.cmd` lives directly under it). Unix keeps shims in `bin/`.
640
+ const bin = root === '' ? '' : process.platform === 'win32' ? root : join(root, 'bin')
641
+ if (bin !== '' && isAbsolute(bin) && !extraPathDirs.includes(bin)) {
642
+ extraPathDirs.push(bin)
643
+ logEvent('info', 'setup-pnpm', `added npm's global bin to the probe path: ${bin}`)
644
+ if (await probePnpm()) return { ok: true }
645
+ extraPathDirs.pop()
646
+ }
647
+ }
648
+ const npmFound = toolOnPath('npm')
649
+ if (!npmFound) logEvent('warn', 'setup-pnpm', `npm is not on any searched path (node lives in ${nodeBinDir})`)
650
+ return { ok: false, hint: provisionHint(corepack.output, npm.output, npmFound, lastPnpmProbeFailure()) }
651
+ }
652
+
653
+ /** Executable suffixes a bare command name can carry on this platform. */
654
+ const EXECUTABLE_SUFFIXES = process.platform === 'win32'
655
+ ? (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(part => part !== '')
656
+ : ['']
657
+
658
+ /**
659
+ * Whether a bare command name resolves to a file on the PATH the market
660
+ * hands its children.
661
+ *
662
+ * The market cannot read the reason a spawn failed out of the child's
663
+ * message: cmd.exe reports a missing command in the console's ANSI codepage
664
+ * ("'npm' 不是内部或外部命令" on a Chinese Windows), which is neither the
665
+ * string `ENOENT` nor even valid UTF-8 — so the #32 hint, written against
666
+ * Node's own ENOENT wording, could never fire on Windows and the user was
667
+ * left with no guidance at all (#167). Looking on disk answers the same
668
+ * question in every locale.
669
+ */
670
+ export function toolOnPath(name: string): boolean {
671
+ const separator = process.platform === 'win32' ? ';' : ':'
672
+ for (const dir of (spawnEnv().PATH ?? '').split(separator)) {
673
+ if (dir === '') continue
674
+ for (const suffix of EXECUTABLE_SUFFIXES) {
675
+ if (existsSync(join(dir, name + suffix))) return true
676
+ }
677
+ }
678
+ return false
679
+ }
680
+
681
+ /**
682
+ * Why the one-click pnpm setup failed, in terms the user can act on.
683
+ *
684
+ * Every one of these was a real report where the market said only "自动准备
685
+ * 没成功" while the log held the actual cause: EEXIST (#142 — corepack had
686
+ * already placed a pnpm shim, so `npm -g` refused to overwrite it), EPERM
687
+ * (#108 — Node installed somewhere the user cannot write), ENOENT (#32 —
688
+ * a GUI launch with no Node on PATH at all).
689
+ * @returns a bilingual, actionable hint, or undefined when unrecognized.
690
+ */
691
+ export function provisionHint(
692
+ corepackOutput: string,
693
+ npmOutput: string,
694
+ npmFound = true,
695
+ probeFailure: { kind: 'missing' | 'failed'; output: string } | null = null,
696
+ ): string | undefined {
697
+ // Node itself unreachable: pointing the user back at this same button
698
+ // would be a dead end (#32). `npmFound` answers this from disk, so it
699
+ // holds on a Windows console that reports the same thing in a codepage we
700
+ // cannot read (#167); the ENOENT match stays for callers without it.
701
+ if (!npmFound || (/ENOENT/.test(corepackOutput) && /ENOENT/.test(npmOutput))) {
702
+ // The searched list is spelled out because the previous wording named
703
+ // only the Node directory, which was both incomplete and unhelpful: a
704
+ // user who HAD installed pnpm could not tell whether we looked in the
705
+ // right place (#292). And the restart note matters — the installer sets
706
+ // PNPM_HOME for new sessions, so a dsh already running cannot see it.
707
+ const searched = toolSearchDirs().join(process.platform === 'win32' ? ' ; ' : ' : ')
708
+ return `这台机器的 dsh 进程找不到 npm/corepack(图形界面或桌面端启动时不继承终端 PATH)——多半是宿主内置的 Node 运行时不带 npm。已找过:${searched}。请改从终端启动 dsh,或单独装一个 pnpm:Windows 用 iwr https://get.pnpm.io/install.ps1 -useb | iex,macOS/Linux 用 brew install pnpm。装完后请重启 dsh——安装器只对新开的会话生效,正在运行的进程看不到它 / This dsh process cannot find npm/corepack (GUI and desktop launches skip your shell PATH); a bundled Node runtime without npm is the usual cause. Searched: ${searched}. Start dsh from a terminal, or install pnpm on its own: \`iwr https://get.pnpm.io/install.ps1 -useb | iex\` (Windows) or \`brew install pnpm\` (macOS/Linux). Restart dsh afterwards — the installer only affects new sessions, so an already-running process cannot see it`
709
+ }
710
+ if (/EEXIST|already exists|--force to overwrite/i.test(npmOutput)) {
711
+ return 'pnpm 的可执行文件已存在(通常是 corepack 先放好了同名 shim),npm 拒绝覆盖。在终端里执行其一即可:corepack prepare pnpm@latest --activate(推荐,直接激活已有 shim)或 npm i -g pnpm --force / A pnpm executable already exists (usually a corepack shim), so npm refused to overwrite it. Run one of these in a terminal: `corepack prepare pnpm@latest --activate` (preferred — activates the shim already there) or `npm i -g pnpm --force`'
712
+ }
713
+ if (/EPERM|EACCES|permission denied|as root\/Administrator/i.test(`${corepackOutput}\n${npmOutput}`)) {
714
+ return '没有权限写入 Node 的安装目录。请用管理员/sudo 执行一次 npm i -g pnpm,或改用无需写系统目录的安装方式:macOS/Linux 用 brew install pnpm,Windows 用 iwr https://get.pnpm.io/install.ps1 -useb | iex / No permission to write into the Node install directory. Run `npm i -g pnpm` once as Administrator/sudo, or install pnpm without touching system dirs: `brew install pnpm` (macOS/Linux) or `iwr https://get.pnpm.io/install.ps1 -useb | iex` (Windows)'
715
+ }
716
+ // Network-shaped failures: the corepack shim downloads pnpm on first run,
717
+ // so a blocked registry or proxy leaves a shim that never works. The
718
+ // button cannot fix that; a full install (or a mirror) can.
719
+ if (/ETIMEDOUT|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|network|proxy|certificate/i.test(`${corepackOutput}\n${npmOutput}`)) {
720
+ return '装 pnpm 时网络失败。若你在受限网络下,corepack 的 shim 也下载不到 pnpm 本体——请改用完整安装或指定镜像:brew install pnpm(macOS/Linux),或 npm i -g pnpm --registry <你的镜像> / Network failure while installing pnpm. On a restricted network the corepack shim cannot download pnpm either — install it fully or point at a mirror: `brew install pnpm`, or `npm i -g pnpm --registry <your mirror>`'
721
+ }
722
+ // Everything reported success and pnpm still will not run (#228 by
723
+ // @ZhengXin1023: corepack exit=0, npm -g exit=0, npm found — and the
724
+ // install button stayed locked with nothing said).
725
+ //
726
+ // This used to return undefined, which left the case that most needs an
727
+ // explanation with none: the user is told "setup failed" while every step
728
+ // they can see succeeded, and their complaint was exactly that — "又不告诉
729
+ // 我怎么手动配置". Whatever the cause, the actionable question is the same
730
+ // one, so ask it: where is pnpm, and is that anywhere this process looks?
731
+ // pnpm IS on the path and exits non-zero. Telling this user to fix PNPM_HOME
732
+ // would be advice for the opposite problem — theirs runs fine from a shell,
733
+ // which is exactly what #228 reported. Its own output is the explanation.
734
+ if (probeFailure?.kind === 'failed') {
735
+ const detail = probeFailure.output === '' ? '' : `\n\n${probeFailure.output}`
736
+ return `找到 pnpm 了,但运行 \`pnpm --version\` 失败——所以问题不在路径上,设 PNPM_HOME 没有用。最常见的原因是 corepack 的 shim 需要联网下载 pnpm 本体,而这台机器下不到。请在终端执行一次 \`pnpm --version\`:如果同样失败,按它的提示修(受限网络可用 \`brew install pnpm\` 或 \`npm i -g pnpm --registry <你的镜像>\` 装一个完整的 pnpm,绕开 shim);如果在终端里正常,说明 dsh 进程的环境和你的终端不同,请从该终端启动 dsh。pnpm 的原始输出:${detail} / pnpm was found, but \`pnpm --version\` fails — so this is not a path problem and PNPM_HOME will not help. The usual cause is a corepack shim that has to download pnpm itself and cannot reach the network. Run \`pnpm --version\` in a terminal: if it fails the same way, follow what it says (on a restricted network install a real pnpm with \`brew install pnpm\` or \`npm i -g pnpm --registry <your mirror>\` to bypass the shim); if it works there, the dsh process has a different environment than your shell — start dsh from that terminal. pnpm's own output:${detail}`
737
+ }
738
+ const searched = toolSearchDirs().join(process.platform === 'win32' ? ' ; ' : ' : ')
739
+ const locate = process.platform === 'win32' ? 'where pnpm' : 'which pnpm'
740
+ return `pnpm 装好了,但这个 dsh 进程仍然启动不了它——安装步骤都成功,只是装到的位置不在它搜索的范围内。已找过:${searched}。请在终端执行 \`${locate}\` 看 pnpm 实际在哪:如果它不在上面这些目录里,把该目录设为 PNPM_HOME 后重启 dsh(\`export PNPM_HOME=<那个目录>\`),或者干脆从一个能直接运行 pnpm 的终端里启动 dsh。注意必须重启——正在运行的进程读不到新设的环境变量 / pnpm is installed but this dsh process still cannot start it: every step succeeded, the binary just landed somewhere this process does not look. Searched: ${searched}. Run \`${locate}\` in a terminal to see where pnpm actually is; if that directory is not in the list above, set PNPM_HOME to it and restart dsh (\`export PNPM_HOME=<that directory>\`), or simply start dsh from a terminal where \`pnpm\` already runs. The restart matters — a running process cannot see a newly set variable`
741
+ }
742
+
743
+ /** Live progress of the running plugin command, for the status route. */
744
+ export interface InstallProgress {
745
+ active: boolean
746
+ target: string
747
+ startedAt: number
748
+ lastLine: string
749
+ /** Parsed from pnpm's ndjson stage events; null when none arrived. */
750
+ phase: ProgressPhase
751
+ /** Distinct packages resolved/fetched so far. */
752
+ done: number
753
+ total: number | null
754
+ currentPackage: string | null
755
+ downloaded: number | null
756
+ size: number | null
757
+ /** True when structured ndjson progress has been observed. */
758
+ ndjson: boolean
759
+ /** Last fatal error from the stream (only meaningful after a failure). */
760
+ error: string | null
761
+ /** True from the moment the user asks to cancel until the run ends. */
762
+ cancelling: boolean
763
+ }
764
+
765
+ /** Singleton progress state; the status route reads it, runDshPlugin writes it. */
766
+ export const progress: InstallProgress = {
767
+ active: false,
768
+ target: '',
769
+ startedAt: 0,
770
+ lastLine: '',
771
+ phase: null,
772
+ done: 0,
773
+ total: null,
774
+ currentPackage: null,
775
+ downloaded: null,
776
+ size: null,
777
+ ndjson: false,
778
+ error: null,
779
+ cancelling: false,
780
+ }
781
+
782
+ /** Identifies this host process; the client scopes its pending-restart flags to it. */
783
+ export const BOOT_ID = `${String(process.pid)}-${String(Date.now())}`
784
+
785
+ /**
786
+ * Central allowlist for every spawn target, regardless of which route built
787
+ * it (defense in depth on top of per-route validation — the win32 bare-dsh
788
+ * fallback runs through a shell). Suggested in #16 by @anupamme.
789
+ *
790
+ * `^`, `~` and `=` are intentionally allowed: restore/install flows turn
791
+ * manifest specs such as "dsh-better-sidebar": "^0.14.0" into targets like
792
+ * `dsh-better-sidebar@^0.14.0`, and regex-valid semver ranges must not be
793
+ * mistaken for shell injection (whitespace and shell metacharacters remain
794
+ * rejected — the win32 bare-dsh fallback is the reason to keep them out).
795
+ */
796
+ export const TARGET_RE = /^[A-Za-z0-9@:./_#+~^=-]+$/
797
+
798
+ /** Mutating pnpm commands get the structured reporter appended. */
799
+ const NDJSON_COMMANDS = new Set(['add', 'remove', 'install'])
800
+
801
+ /** Apply profile-specific pnpm compatibility and the structured reporter. */
802
+ function preparePluginArgs(profileDirectory: string, pluginArgs: readonly string[]): {
803
+ args: string[]
804
+ target: string
805
+ } | { error: string } {
806
+ let args = pluginArgsFor(profileDirectory, [...pluginArgs])
807
+ const target = args[args.length - 1] ?? ''
808
+ if (!TARGET_RE.test(target)) {
809
+ return { error: `unsafe plugin target rejected: ${JSON.stringify(target)}` }
810
+ }
811
+ if (NDJSON_COMMANDS.has(args[0])) args = [...args, '--reporter=ndjson']
812
+ return { args, target }
813
+ }
814
+
815
+ /** Reset the singleton status snapshot before one operation starts. */
816
+ function beginProgress(target: string): ReturnType<typeof createProgressTracker> {
817
+ progress.active = true
818
+ progress.target = target
819
+ progress.startedAt = Date.now()
820
+ progress.lastLine = ''
821
+ progress.phase = null
822
+ progress.done = 0
823
+ progress.total = null
824
+ progress.currentPackage = null
825
+ progress.downloaded = null
826
+ progress.size = null
827
+ progress.ndjson = false
828
+ progress.error = null
829
+ progress.cancelling = false
830
+ return createProgressTracker()
831
+ }
832
+
833
+ /**
834
+ * Line-buffered progress feed: pnpm's ndjson reporter emits one JSON object
835
+ * per line on stdout, and chunk boundaries can split a line. Human fallback
836
+ * lines (older pnpm without structured events) still update `lastLine`.
837
+ */
838
+ function makeProgressFeeder(tracker: ReturnType<typeof createProgressTracker>): (chunk: string) => void {
839
+ let lineBuffer = ''
840
+ return (chunk: string): void => {
841
+ lineBuffer += chunk
842
+ let nl: number
843
+ while ((nl = lineBuffer.indexOf('\n')) !== -1) {
844
+ const line = lineBuffer.slice(0, nl)
845
+ lineBuffer = lineBuffer.slice(nl + 1)
846
+ const trimmed = line.trim()
847
+ if (trimmed === '') continue
848
+ tracker.feed(trimmed)
849
+ // Human lines never start with '{'; JSON lines are consumed by the tracker.
850
+ if (!trimmed.startsWith('{')) progress.lastLine = trimmed.slice(0, 200)
851
+ }
852
+ }
853
+ }
854
+
855
+ /** Run one `chatcode-cli plugin --profile <p> …` command with timeout and progress tracking. */
856
+ export function runDshPlugin(profile: string, pluginArgs: string[]): Promise<InstallResult> {
857
+ const { file, args, cwd, viaShell } = dshArgv()
858
+ if (viaShell && !isCmdSafeProfileName(profile)) {
859
+ const error = `ChatCode CLI Market: profile name ${JSON.stringify(profile)} cannot cross the Windows cmd.exe fallback safely; relaunch ChatCode CLI through its Node entry point, or use a profile name containing only letters, numbers, spaces, dots, underscores, and hyphens`
860
+ logEvent('error', 'install', error)
861
+ return Promise.resolve({ exitCode: 1, timedOut: false, stdout: '', stderr: error, cancelled: false })
862
+ }
863
+ const prepared = preparePluginArgs(profileDir(profile), pluginArgs)
864
+ if ('error' in prepared) {
865
+ logEvent('error', 'install', prepared.error)
866
+ return Promise.resolve({ exitCode: 1, timedOut: false, stdout: '', stderr: prepared.error, cancelled: false })
867
+ }
868
+ pluginArgs = prepared.args
869
+ const tracker = beginProgress(prepared.target)
870
+ const feed = makeProgressFeeder(tracker)
871
+ return new Promise((resolvePromise) => {
872
+ const child = spawnShim(file, [...args, 'plugin', '--profile', profile, ...pluginArgs], {
873
+ cwd,
874
+ // pnpm v10 blocks forever on a silent interactive prompt without a TTY
875
+ // (observed on re-add over a pinned git spec); CI mode forces it to act
876
+ // or fail instead of asking.
877
+ env: spawnEnv(),
878
+ stdio: ['ignore', 'pipe', 'pipe'],
879
+ viaShell,
880
+ // Own process group on POSIX so cancel/timeout can kill the whole
881
+ // tree (dsh wrapper + pnpm grandchild) with one group signal.
882
+ detached: process.platform !== 'win32',
883
+ })
884
+ activeChild = child
885
+ cancelRequested = false
886
+ let stdout = ''
887
+ let stderr = ''
888
+ let timedOut = false
889
+ const timer = setTimeout(() => {
890
+ timedOut = true
891
+ killTree(child)
892
+ }, INSTALL_TIMEOUT_MS)
893
+ child.stdout?.on('data', (chunk: Buffer) => {
894
+ const text = chunk.toString()
895
+ stdout = (stdout + text).slice(-256 * 1024)
896
+ feed(text)
897
+ syncProgress(tracker)
898
+ })
899
+ child.stderr?.on('data', (chunk: Buffer) => {
900
+ const text = chunk.toString()
901
+ stderr = (stderr + text).slice(-64 * 1024)
902
+ feed(text)
903
+ syncProgress(tracker)
904
+ })
905
+ child.on('error', (error) => {
906
+ clearTimeout(timer)
907
+ progress.active = false
908
+ progress.cancelling = false
909
+ if (activeChild === child) activeChild = null
910
+ resolvePromise({ exitCode: 127, timedOut: false, stdout, stderr: `${stderr}\n${error.message}`, cancelled: false })
911
+ })
912
+ child.on('close', (code) => {
913
+ clearTimeout(timer)
914
+ progress.active = false
915
+ progress.cancelling = false
916
+ if (activeChild === child) activeChild = null
917
+ const failed = code !== 0 || timedOut
918
+ if (failed) progress.error = tracker.snapshot.error
919
+ const ignoredBuilds = tracker.snapshot.ignoredBuilds
920
+ const { error: pnpmError, errorCode: pnpmErrorCode } = tracker.snapshot
921
+ resolvePromise({
922
+ exitCode: code,
923
+ timedOut,
924
+ stdout,
925
+ stderr,
926
+ cancelled: cancelRequested,
927
+ ...(pnpmError !== null ? { pnpmError } : {}),
928
+ ...(pnpmErrorCode !== null ? { pnpmErrorCode } : {}),
929
+ ...(ignoredBuilds.length > 0 ? { ignoredBuilds } : {}),
930
+ })
931
+ })
932
+ })
933
+ }
934
+
935
+ /**
936
+ * Adapt DSH Desktop's generation-scoped package manager to the existing
937
+ * market runner. There is no runtime import or dependency on Desktop: the
938
+ * Host supplies this public service only when the package is mounted there.
939
+ */
940
+ export function createDesktopPluginRuntime(
941
+ service: DesktopPnpmLike,
942
+ activeProfileDir: string,
943
+ invokingDir = process.cwd(),
944
+ timeoutMs = INSTALL_TIMEOUT_MS,
945
+ ): DesktopPluginRuntime {
946
+ if (!isAbsolute(activeProfileDir) || activeProfileDir.includes('\0')) {
947
+ throw new Error('ChatCode CLI Market: Desktop profile directory must be an absolute path without NUL')
948
+ }
949
+ if (!isAbsolute(invokingDir) || invokingDir.includes('\0')) {
950
+ throw new Error('ChatCode CLI Market: Desktop invoking directory must be an absolute path without NUL')
951
+ }
952
+ const owner = Symbol('dsh-market desktop runtime')
953
+ let closed = false
954
+
955
+ const runPlugin: PluginRunner = async (_profile, pluginArgs) => {
956
+ if (closed) {
957
+ return {
958
+ exitCode: 127,
959
+ timedOut: false,
960
+ stdout: '',
961
+ stderr: 'ChatCode CLI Market: Desktop package runtime is disposed',
962
+ cancelled: false,
963
+ }
964
+ }
965
+ const prepared = preparePluginArgs(activeProfileDir, pluginArgs)
966
+ if ('error' in prepared) {
967
+ logEvent('error', 'install', prepared.error)
968
+ return { exitCode: 1, timedOut: false, stdout: '', stderr: prepared.error, cancelled: false }
969
+ }
970
+
971
+ const abort = new AbortController()
972
+ let handle: DesktopPnpmHandleLike
973
+ /** Set when this host only installs npm packages and the target is not one. */
974
+ let boundaryRefusesTarget = false
975
+ /** Exact npm pin the install boundary actually sent (#496). */
976
+ let resolvedNpmVersion: string | undefined
977
+ try {
978
+ // `add` goes through Anywhere Labs' install boundary when that host
979
+ // publishes one, because their Desktop rejects `add` on `runPlugin`
980
+ // outright. Feature-detected, never assumed: this method is theirs
981
+ // alone, and on every other client — including the other desktop app
982
+ // in #292 — the ordinary call below is what runs, unchanged.
983
+ const boundary = prepared.args[0] === 'add' ? service.runExternalMarketPluginInstall : undefined
984
+ const viaBoundary = boundary === undefined ? null : await exactNpmArgs(prepared.args)
985
+ // A host that publishes the boundary accepts ONLY `name@exact.version`
986
+ // through it, so a github-sourced plugin has nowhere to go: the
987
+ // fallback below is a call that host refuses outright. Their refusal is
988
+ // accurate but says nothing about why THIS plugin, and roughly half the
989
+ // catalog has no npm package — reported in #138 after the user found
990
+ // out by clicking Install and reading `exit 127`.
991
+ boundaryRefusesTarget = boundary !== undefined && viaBoundary === null
992
+ if (viaBoundary !== null) resolvedNpmVersion = viaBoundary.resolvedNpmVersion
993
+ handle = boundary === undefined || viaBoundary === null
994
+ ? service.runPlugin(prepared.args, invokingDir, abort.signal)
995
+ : boundary.call(service, viaBoundary.args, invokingDir, abort.signal)
996
+ } catch (error) {
997
+ const message = error instanceof Error ? error.message : String(error)
998
+ const busy = /another desktop pnpm operation is already running/i.test(message)
999
+ return {
1000
+ exitCode: 127,
1001
+ timedOut: false,
1002
+ stdout: '',
1003
+ stderr: boundaryRefusesTarget ? `${message}\n${NPM_ONLY_HOST_NOTE}` : message,
1004
+ cancelled: false,
1005
+ ...(busy ? { busy: true } : {}),
1006
+ }
1007
+ }
1008
+
1009
+ const tracker = beginProgress(prepared.target)
1010
+ const feed = makeProgressFeeder(tracker)
1011
+ let stdout = ''
1012
+ let stderr = ''
1013
+ let timedOut = false
1014
+ const collectStdout = (chunk: string | Buffer): void => {
1015
+ const text = chunk.toString()
1016
+ stdout = (stdout + text).slice(-256 * 1024)
1017
+ feed(text)
1018
+ syncProgress(tracker)
1019
+ }
1020
+ const collectStderr = (chunk: string | Buffer): void => {
1021
+ const text = chunk.toString()
1022
+ stderr = (stderr + text).slice(-64 * 1024)
1023
+ feed(text)
1024
+ syncProgress(tracker)
1025
+ }
1026
+ handle.stdout.on('data', collectStdout)
1027
+ handle.stderr.on('data', collectStderr)
1028
+
1029
+ let active!: ActiveDesktopOperation
1030
+ let timer: NodeJS.Timeout | undefined
1031
+ const done = (async (): Promise<InstallResult> => {
1032
+ try {
1033
+ const outcome = await handle.done
1034
+ const failed = outcome.exitCode !== 0 || outcome.signal !== null || timedOut
1035
+ if (failed) progress.error = tracker.snapshot.error
1036
+ const ignoredBuilds = tracker.snapshot.ignoredBuilds
1037
+ const { error: pnpmError, errorCode: pnpmErrorCode } = tracker.snapshot
1038
+ return {
1039
+ exitCode: outcome.exitCode,
1040
+ timedOut,
1041
+ stdout,
1042
+ stderr,
1043
+ cancelled: active.userCancelled,
1044
+ ...(ignoredBuilds.length > 0 ? { ignoredBuilds } : {}),
1045
+ ...(pnpmError !== null ? { pnpmError } : {}),
1046
+ ...(pnpmErrorCode !== null ? { pnpmErrorCode } : {}),
1047
+ ...(resolvedNpmVersion !== undefined ? { resolvedNpmVersion } : {}),
1048
+ }
1049
+ } catch (error) {
1050
+ const message = error instanceof Error ? error.message : String(error)
1051
+ progress.error = tracker.snapshot.error
1052
+ const detail = `${stderr}${stderr === '' ? '' : '\n'}${message}`
1053
+ return {
1054
+ exitCode: 127,
1055
+ timedOut,
1056
+ stdout,
1057
+ stderr: boundaryRefusesTarget ? `${detail}\n${NPM_ONLY_HOST_NOTE}` : detail,
1058
+ cancelled: active.userCancelled,
1059
+ ...(resolvedNpmVersion !== undefined ? { resolvedNpmVersion } : {}),
1060
+ }
1061
+ } finally {
1062
+ if (timer !== undefined) clearTimeout(timer)
1063
+ progress.active = false
1064
+ progress.cancelling = false
1065
+ handle.stdout.off('data', collectStdout)
1066
+ handle.stderr.off('data', collectStderr)
1067
+ if (activeDesktopOperation === active) activeDesktopOperation = null
1068
+ }
1069
+ })()
1070
+ active = { owner, cancel: () => { handle.cancel() }, done, userCancelled: false }
1071
+ activeDesktopOperation = active
1072
+ timer = setTimeout(() => {
1073
+ timedOut = true
1074
+ abort.abort(new Error('ChatCode CLI Market: Desktop package operation timed out'))
1075
+ // The public handle owns an explicit process-tree cancellation path.
1076
+ // Use it as well as AbortSignal so a structurally compatible provider
1077
+ // that does not observe the signal cannot strand the route or teardown.
1078
+ handle.cancel()
1079
+ }, timeoutMs)
1080
+ timer.unref?.()
1081
+ return done
1082
+ }
1083
+
1084
+ const cancelOwned = (userCancelled: boolean): boolean => {
1085
+ const active = activeDesktopOperation
1086
+ if (active?.owner !== owner) return false
1087
+ if (userCancelled) active.userCancelled = true
1088
+ progress.cancelling = true
1089
+ active.cancel()
1090
+ return true
1091
+ }
1092
+
1093
+ return {
1094
+ runPlugin,
1095
+ // Anywhere Labs' optional external boundary accepts exact npm targets
1096
+ // only. Every Desktop host without that boundary retains the ordinary
1097
+ // CLI grammar, including immutable Git and archive targets.
1098
+ supportsExactRollbackTarget: target => TARGET_RE.test(target)
1099
+ && (service.runExternalMarketPluginInstall === undefined || EXACT_NPM_TARGET_RE.test(target)),
1100
+ // The service is backed by Desktop's packaged pnpm; system discovery and
1101
+ // global provisioning are neither needed nor allowed in this mode.
1102
+ probePnpm: () => Promise.resolve(true),
1103
+ provisionPnpm: () => Promise.resolve({ ok: true }),
1104
+ cancelActive: () => cancelOwned(true),
1105
+ dispose: async () => {
1106
+ closed = true
1107
+ const active = activeDesktopOperation
1108
+ if (active?.owner !== owner) return
1109
+ cancelOwned(false)
1110
+ await active.done.catch(() => {})
1111
+ },
1112
+ }
1113
+ }
1114
+
1115
+ /** Copy the tracker's snapshot into the singleton the status route reads. */
1116
+ function syncProgress(tracker: ReturnType<typeof createProgressTracker>): void {
1117
+ const snap = tracker.snapshot
1118
+ progress.phase = snap.phase
1119
+ progress.done = snap.done
1120
+ progress.total = snap.total
1121
+ progress.currentPackage = snap.currentPackage
1122
+ progress.downloaded = snap.downloaded
1123
+ progress.size = snap.size
1124
+ progress.ndjson = snap.seen
1125
+ if (snap.error !== null) progress.error = snap.error
1126
+ }