@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/lib/dsh-cli.js ADDED
@@ -0,0 +1,949 @@
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
+ import { spawn } from 'node:child_process';
10
+ import { existsSync } from 'node:fs';
11
+ import { homedir } from 'node:os';
12
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
13
+ import { logEvent } from './log.js';
14
+ import { createProgressTracker } from './ndjson.js';
15
+ import { pluginArgsFor } from './pnpm-compat.js';
16
+ import { isDshProfileName, profileDir } from './profile.js';
17
+ import { activeRegion, DEFAULT_NPM_REGISTRY, routesFor } from './regions.js';
18
+ import { NPM_NAME_RE } from './sources.js';
19
+ import { fetchNpmLatest } from './updates.js';
20
+ import { compatibilityEnv } from './home-paths.js';
21
+ // 15 min default (slow networks + git installs), overridable for CI/tests.
22
+ // (#6 by @qichuang321.)
23
+ /**
24
+ * macOS apps launched from Finder/Dock inherit a minimal PATH without the
25
+ * shell profile — Homebrew/npm/corepack all vanish and every install dies
26
+ * with ENOENT/127 (#32, #38). Append the well-known bin directories so the
27
+ * market's children find their tools regardless of how dsh was started.
28
+ */
29
+ /**
30
+ * Directories discovered at runtime that hold a usable pnpm — currently
31
+ * npm's global bin, learned after a successful one-click setup (#149).
32
+ * Every later spawn sees them, so the market does not have to be restarted
33
+ * for the pnpm it just installed to become visible.
34
+ */
35
+ const extraPathDirs = [];
36
+ /**
37
+ * The real Node executable for spawning children. On Android the kernel runs
38
+ * node through the dynamic linker, so `process.execPath` is
39
+ * `/apex/.../linker64` — spawning IT with `--expose-internals` makes the
40
+ * linker treat the flag as the program path and die with
41
+ * `error: expected absolute path: "--expose-internals"`. `process.argv0`
42
+ * carries the real node binary; prefer it whenever it is an existing
43
+ * absolute path, and fall back to execPath everywhere else.
44
+ * @param argv0 - `process.argv0`, injectable for tests.
45
+ * @param execPath - `process.execPath`, injectable for tests.
46
+ */
47
+ export function nodeExecutable(argv0 = process.argv0, execPath = process.execPath) {
48
+ if (argv0 !== undefined && argv0 !== '' && isAbsolute(argv0) && existsSync(argv0))
49
+ return argv0;
50
+ return execPath;
51
+ }
52
+ /**
53
+ * The directory holding the Node binary running this process. `npm`,
54
+ * `npm.cmd` and `corepack` are installed alongside it by every official Node
55
+ * distribution, so it is the one place the toolchain can be looked for
56
+ * without guessing — and unlike a PATH entry it cannot be absent, because
57
+ * this process is executing out of it.
58
+ *
59
+ * #167: a Windows desktop host spawned dsh without the Node install
60
+ * directory on PATH. Node itself was running (v24.18.1 in the log) while
61
+ * both `corepack` and `npm` came back "not recognized as an internal or
62
+ * external command", so the one-click setup had no way to succeed.
63
+ */
64
+ export const nodeBinDir = dirname(nodeExecutable());
65
+ /**
66
+ * Translate the machine's proxy environment into the ONE form pnpm reads.
67
+ *
68
+ * `HTTPS_PROXY` / `http_proxy` are what every other tool honours, and what
69
+ * `net.ts` already routes the market's own catalog fetches through — but
70
+ * pnpm ignores them completely. It reads npm config, so a proxy reaches it
71
+ * only as `npm_config_https_proxy` / `npm_config_proxy` (or an .npmrc entry,
72
+ * which is the user's file and not ours to rewrite).
73
+ *
74
+ * That gap is why the market could load its catalog through a proxy and
75
+ * then hang installing anything at all — reported four separate times
76
+ * (#148, #161, #188, #232), always from a network that needs one.
77
+ *
78
+ * An `npm_config_*` value the caller already set always wins: it is the more
79
+ * specific statement of intent, and on Windows env keys are case-insensitive
80
+ * so the check has to be too. NO_PROXY is forwarded verbatim because pnpm
81
+ * reads `npm_config_noproxy` and a host excluding its own registry mirror
82
+ * must keep excluding it.
83
+ */
84
+ export function proxyEnvForPnpm(env = process.env, region = 'global') {
85
+ const has = (name) => {
86
+ const wanted = name.toLowerCase();
87
+ return Object.keys(env).some(key => key.toLowerCase() === wanted && (env[key] ?? '').trim() !== '');
88
+ };
89
+ const pick = (...names) => {
90
+ for (const name of names) {
91
+ const raw = env[name];
92
+ if (raw !== undefined && raw.trim() !== '')
93
+ return raw.trim();
94
+ }
95
+ return null;
96
+ };
97
+ const out = {};
98
+ // Three consumers, three vocabularies, one proxy. The market's own fetch
99
+ // reads the standard vars (and, since #263, npm config too); pnpm reads
100
+ // ONLY npm config; and `git` — which pnpm shells out to for every
101
+ // git-hosted plugin — reads only the standard vars and never npm config.
102
+ // Translating one direction left the third out: registry installs went
103
+ // through the proxy while git installs went direct and failed with
104
+ // "Failed to connect to github.com:443" (#274 by @rucsocial).
105
+ //
106
+ // Same precedence as undici's EnvHttpProxyAgent (lowercase over
107
+ // uppercase, https falling back to http).
108
+ const stdHttps = pick('https_proxy', 'HTTPS_PROXY') ?? pick('http_proxy', 'HTTP_PROXY');
109
+ const stdHttp = pick('http_proxy', 'HTTP_PROXY') ?? stdHttps;
110
+ if (stdHttps !== null && !has('npm_config_https_proxy'))
111
+ out.npm_config_https_proxy = stdHttps;
112
+ if (stdHttp !== null && !has('npm_config_proxy'))
113
+ out.npm_config_proxy = stdHttp;
114
+ const stdNoProxy = pick('no_proxy', 'NO_PROXY');
115
+ if (stdNoProxy !== null && !has('npm_config_noproxy'))
116
+ out.npm_config_noproxy = stdNoProxy;
117
+ // The other direction, and ONLY when the standard vocabulary is empty.
118
+ // A proxy known solely to npm config is the case that stranded git; if
119
+ // the caller has said anything in the standard vars, that is their
120
+ // statement about what git should do and copying npm's answer over it
121
+ // would invent a setting they did not make — notably an HTTP_PROXY for
122
+ // someone who deliberately proxied https only.
123
+ if (stdHttps === null && stdHttp === null) {
124
+ const npmHttps = pick('npm_config_https_proxy') ?? pick('npm_config_proxy');
125
+ const npmHttp = pick('npm_config_proxy') ?? npmHttps;
126
+ if (npmHttps !== null)
127
+ out.HTTPS_PROXY = npmHttps;
128
+ if (npmHttp !== null)
129
+ out.HTTP_PROXY = npmHttp;
130
+ const npmNoProxy = pick('npm_config_noproxy');
131
+ if (npmNoProxy !== null && stdNoProxy === null)
132
+ 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
+ * Directories to append to PATH so a spawned pnpm can be found (#32, #38,
151
+ * #167, #292).
152
+ *
153
+ * A GUI or desktop launch inherits none of the shell profile, so PATH holds
154
+ * whatever the launcher had — usually not the directory the user's package
155
+ * manager lives in. The market appends the places it is actually installed
156
+ * to rather than telling the user to fix their environment.
157
+ *
158
+ * Windows used to get only the Node directory, which made the market's own
159
+ * advice unfollowable: the error it prints recommends installing pnpm with
160
+ * `iwr https://get.pnpm.io/install.ps1`, and then it did not look where that
161
+ * installer puts it (#292). Both Windows layouts are covered now — the
162
+ * standalone installer's `%LOCALAPPDATA%\pnpm`, and `%APPDATA%\npm` where
163
+ * `npm i -g pnpm` writes `pnpm.cmd`.
164
+ *
165
+ * `PNPM_HOME` comes first on every platform: the installer sets it, so it is
166
+ * the one answer that is right even when the layout is not the default one.
167
+ *
168
+ * @param platform - `process.platform`, injectable for tests.
169
+ * @param env - environment, for PNPM_HOME and the Windows app-data roots.
170
+ * @param home - home directory, injectable for tests.
171
+ */
172
+ export function toolSearchDirs(platform = process.platform, env = process.env, home = homedir()) {
173
+ const dirs = [];
174
+ const pnpmHome = (env.PNPM_HOME ?? '').trim();
175
+ if (pnpmHome !== '')
176
+ dirs.push(pnpmHome);
177
+ if (platform === 'win32') {
178
+ const local = (env.LOCALAPPDATA ?? '').trim();
179
+ const roaming = (env.APPDATA ?? '').trim();
180
+ if (local !== '')
181
+ dirs.push(join(local, 'pnpm'));
182
+ if (roaming !== '')
183
+ dirs.push(join(roaming, 'npm'));
184
+ }
185
+ else {
186
+ dirs.push('/opt/homebrew/bin', '/usr/local/bin', join(home, '.local', 'bin'));
187
+ // Where the standalone installer lands when PNPM_HOME is unset.
188
+ dirs.push(join(home, 'Library', 'pnpm'), join(home, '.local', 'share', 'pnpm'));
189
+ }
190
+ dirs.push(nodeBinDir, ...extraPathDirs);
191
+ // Deduped: PNPM_HOME usually names one of the defaults below it, and a
192
+ // list that says the same directory twice reads as carelessness in the
193
+ // one place a user goes looking for an answer.
194
+ return [...new Set(dirs.filter(dir => dir.trim() !== ''))];
195
+ }
196
+ function spawnEnv() {
197
+ // pnpm v10+ blocks forever on a silent interactive prompt without a TTY;
198
+ // CI mode forces it to act or fail instead of asking.
199
+ const separator = process.platform === 'win32' ? ';' : ':';
200
+ const parts = (process.env.PATH ?? '').split(separator).filter(part => part !== '');
201
+ for (const bin of toolSearchDirs()) {
202
+ if (!parts.includes(bin))
203
+ parts.push(bin);
204
+ }
205
+ return { ...process.env, ...proxyEnvForPnpm(process.env, activeRegion()), CI: 'true', PATH: parts.join(separator) };
206
+ }
207
+ const INSTALL_TIMEOUT_MS = Number(compatibilityEnv(process.env, 'CHATCODE_CLI_MARKET_INSTALL_TIMEOUT_MS', 'DSH_MARKET_INSTALL_TIMEOUT_MS')) || 15 * 60 * 1000;
208
+ /**
209
+ * Windows npm/corepack/pnpm are `.cmd` shims. Node's `spawn` without a shell
210
+ * cannot start them (ENOENT / EINVAL). Same pattern as dsh's `plugin` forwarder.
211
+ */
212
+ export const winCmdShim = process.platform === 'win32';
213
+ /** Characters cmd.exe treats as syntax even inside a token. */
214
+ const CMD_METACHARS = /[\s"&|<>^()%!]/;
215
+ /**
216
+ * Quote one argv token for a cmd.exe `/c` command line. cmd only groups with
217
+ * double quotes, so a token that needs quoting gets wrapped and embedded
218
+ * quotes are doubled.
219
+ */
220
+ export function quoteCmdArg(arg) {
221
+ if (!CMD_METACHARS.test(arg))
222
+ return arg;
223
+ return `"${arg.replace(/"/g, '""')}"`;
224
+ }
225
+ /**
226
+ * Build a cmd.exe command line from argv. Only the Windows shim path uses
227
+ * this: cmd re-parses the joined string, so every token is quoted before
228
+ * joining.
229
+ */
230
+ export function cmdCommandLine(argv) {
231
+ return argv.map(quoteCmdArg).join(' ');
232
+ }
233
+ /**
234
+ * Whether a profile name can cross the rare Windows `dsh.cmd` fallback.
235
+ *
236
+ * cmd.exe expands percent-delimited environment variables even inside a
237
+ * quoted argument. Keep that fallback to names made only of letters, marks,
238
+ * numbers, spaces, dots, underscores, and hyphens. The normal direct-Node
239
+ * launcher remains argv-safe and accepts every DSH-valid profile name.
240
+ */
241
+ export function isCmdSafeProfileName(profile) {
242
+ return isDshProfileName(profile) && /^[\p{L}\p{M}\p{N}._ -]+$/u.test(profile);
243
+ }
244
+ /** cmd.exe resolved once; the Windows shim path only. */
245
+ const COMSPEC = process.env.ComSpec ?? 'cmd.exe';
246
+ /**
247
+ * Spawn a command, avoiding Node's deprecated `shell: true` + argv
248
+ * combination (DEP0190). Windows `.cmd` shims cannot start without a shell,
249
+ * so the shim path routes through `cmd.exe /d /s /c` with an explicitly
250
+ * built, quoted command line; every other invocation spawns directly with
251
+ * `shell: false`. Hide consoles when the host itself has no console (#530).
252
+ */
253
+ function spawnShim(file, args, options) {
254
+ const { viaShell = false, ...spawnOptions } = options;
255
+ if (!viaShell) {
256
+ return spawn(file, [...args], { ...spawnOptions, shell: false, windowsHide: true });
257
+ }
258
+ if (process.platform !== 'win32') {
259
+ return spawn(file, [...args], { ...spawnOptions, shell: false, windowsHide: true });
260
+ }
261
+ return spawn(COMSPEC, ['/d', '/s', '/c', `"${cmdCommandLine([file, ...args])}"`], {
262
+ ...spawnOptions,
263
+ shell: false,
264
+ windowsVerbatimArguments: true,
265
+ windowsHide: true,
266
+ });
267
+ }
268
+ /**
269
+ * Argv re-invoking the CLI that launched this host process, so installs work
270
+ * whether ChatCode CLI runs from a global bin, a local install, or repo source
271
+ * (`node --import tsx/esm .../bin.ts`). Falls back to PATH `chatcode-cli`.
272
+ */
273
+ export function dshArgv() {
274
+ const entry = process.argv[1];
275
+ if (entry !== undefined && /[\\/](?:bin\.(?:js|ts)|chatcode-cli|cco|dsh)$/.test(entry)) {
276
+ // Absolute paths are required: source launches pass a
277
+ // relative entry, which the child resolves against its OWN cwd and dies
278
+ // with MODULE_NOT_FOUND (#13). cwd near the entry keeps execArgv imports
279
+ // (tsx/esm) resolvable on source launches.
280
+ const abs = resolve(entry);
281
+ return { file: nodeExecutable(), args: [...process.execArgv, abs], cwd: dirname(abs), viaShell: false };
282
+ }
283
+ // Bare `chatcode-cli` is a .cmd shim on Windows that only a shell can start (#13).
284
+ return { file: 'chatcode-cli', args: [], cwd: undefined, viaShell: winCmdShim };
285
+ }
286
+ /** An npm name with a fully pinned version — the only target their boundary takes. */
287
+ const EXACT_NPM_TARGET_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
288
+ /**
289
+ * Rewrite an `add` argv into the shape Anywhere Labs' install boundary
290
+ * accepts, or null when it cannot be expressed there.
291
+ *
292
+ * Their validator wants exactly one target of the form `name@1.2.3` — not a
293
+ * bare name, not `@latest`, and not a `github:` source (read from
294
+ * `validateExternalMarketInstallArgs`, dsh-plugin-desktop/src/pnpm.ts). The
295
+ * market prefers to send an already-resolved `name@x.y.z` for npm updates
296
+ * (#496); a bare name or dist-tag still needs resolving here when some other
297
+ * path hands one over.
298
+ *
299
+ * Returning null is a normal outcome, not a failure: a github-sourced plugin
300
+ * has no `name@version` spelling at all. The caller falls back to the
301
+ * ordinary path, which on that host reports their own refusal — an accurate
302
+ * message about their contract, rather than one this package invented.
303
+ */
304
+ /**
305
+ * Added to a refusal from a host whose install boundary only takes
306
+ * `name@exact.version`. Their message is accurate and stays first; this says
307
+ * which property of the plugin put it out of reach, because the user picked a
308
+ * card and has no way to know the difference from the outside (#138).
309
+ */
310
+ const NPM_ONLY_HOST_NOTE = '这个桌面客户端只能安装已发布到 npm 的插件,而该插件仅提供 GitHub 源,因此装不了——这是客户端的安装边界,不是插件或市场的问题。'
311
+ + '可以改用普通 chatcode-cli web 安装,或请插件作者发布 npm 包。 / '
312
+ + 'This desktop client can only install plugins published to npm, and this one is GitHub-only, so it cannot be installed here. '
313
+ + 'That is the client\'s install boundary, not a fault in the plugin or the market. '
314
+ + 'Install it from plain chatcode-cli web instead, or ask the author to publish to npm.';
315
+ async function exactNpmArgs(args) {
316
+ const targets = args.slice(1).filter(argument => !argument.startsWith('-'));
317
+ const target = targets[0];
318
+ if (targets.length !== 1 || target === undefined)
319
+ return null;
320
+ if (EXACT_NPM_TARGET_RE.test(target)) {
321
+ // Already exact — still report the pin so update verification can align
322
+ // with what this host will install, not with a separate `latest` fetch.
323
+ return { args: [...args], resolvedNpmVersion: target.slice(target.lastIndexOf('@') + 1) };
324
+ }
325
+ // A bare name, or one pinned to a dist-tag. Only a registry package can be
326
+ // resolved; `github:owner/repo` and file paths stop here.
327
+ const at = target.lastIndexOf('@');
328
+ const name = at > 0 ? target.slice(0, at) : target;
329
+ if (!NPM_NAME_RE.test(name))
330
+ return null;
331
+ const version = await fetchNpmLatest(name);
332
+ if (version === null)
333
+ return null;
334
+ const rewritten = `${name}@${version}`;
335
+ if (!EXACT_NPM_TARGET_RE.test(rewritten))
336
+ return null;
337
+ logEvent('info', 'install', `desktop install boundary needs an exact version: ${target} -> ${rewritten}`);
338
+ return {
339
+ args: args.map(argument => (argument === target ? rewritten : argument)),
340
+ resolvedNpmVersion: version,
341
+ };
342
+ }
343
+ /**
344
+ * Kill a spawned child and, on Windows, its whole process tree — `kill()`
345
+ * there only terminates the wrapper, leaving pnpm children running.
346
+ * (Contributed in #7 by @mraing.)
347
+ */
348
+ export function killChild(child) {
349
+ if (process.platform === 'win32' && child.pid !== undefined) {
350
+ try {
351
+ spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true });
352
+ return;
353
+ }
354
+ catch { /* fall through */ }
355
+ }
356
+ child.kill('SIGKILL');
357
+ }
358
+ /** The child of the operation currently running, for /dsh-market/cancel. */
359
+ let activeChild = null;
360
+ let cancelRequested = false;
361
+ let activeDesktopOperation = null;
362
+ /**
363
+ * Kill a child and its whole tree, gracefully where the platform allows:
364
+ * taskkill /T /F on Windows (plain kill() leaves pnpm children running),
365
+ * SIGTERM with a 5s SIGKILL escalation elsewhere so pnpm can clean up.
366
+ * (Cancel flow contributed in #6 by @qichuang321.)
367
+ */
368
+ function killTree(child) {
369
+ if (process.platform === 'win32' && child.pid !== undefined) {
370
+ try {
371
+ spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true });
372
+ return;
373
+ }
374
+ catch { /* fall through */ }
375
+ }
376
+ // POSIX: the dsh wrapper runs pnpm as a grandchild (spawnSync), which a
377
+ // plain child.kill() leaves running — it keeps our stdio pipes open, so
378
+ // the close event never fires and the market looks stuck "installing".
379
+ // The child is spawned detached as its own process GROUP; kill the group.
380
+ const signalTree = (signal) => {
381
+ if (child.pid === undefined)
382
+ return;
383
+ try {
384
+ process.kill(-child.pid, signal);
385
+ }
386
+ catch {
387
+ try {
388
+ child.kill(signal);
389
+ }
390
+ catch { /* already gone */ }
391
+ }
392
+ };
393
+ signalTree('SIGTERM');
394
+ const escalate = setTimeout(() => signalTree('SIGKILL'), 5000);
395
+ escalate.unref?.();
396
+ }
397
+ /**
398
+ * Cancel the plugin command currently running.
399
+ * @returns true when there was one to cancel.
400
+ */
401
+ export function cancelActive() {
402
+ if (activeDesktopOperation !== null) {
403
+ activeDesktopOperation.userCancelled = true;
404
+ progress.cancelling = true;
405
+ activeDesktopOperation.cancel();
406
+ return true;
407
+ }
408
+ if (activeChild === null)
409
+ return false;
410
+ cancelRequested = true;
411
+ progress.cancelling = true;
412
+ killTree(activeChild);
413
+ return true;
414
+ }
415
+ /** Whether `pnpm` resolves on PATH; success is cached, absence is re-probed. */
416
+ let pnpmReady = false;
417
+ /**
418
+ * Why the last probe said no.
419
+ *
420
+ * `missing` and `failed` are different problems with different fixes, and
421
+ * collapsing both into `false` made the market give one answer to both: it
422
+ * told a user whose pnpm ran perfectly from their shell to go set PNPM_HOME
423
+ * (#228). A binary that IS on the path and exits non-zero — a corepack shim
424
+ * that cannot reach the network to fetch pnpm itself is the common one —
425
+ * needs its own output shown, not a path to fix that is already right.
426
+ */
427
+ let pnpmProbeFailure = null;
428
+ /** Why `pnpm --version` last failed, or null when it has not failed. */
429
+ export function lastPnpmProbeFailure() {
430
+ return pnpmProbeFailure;
431
+ }
432
+ /** Probe `pnpm --version` on PATH. */
433
+ export function probePnpm() {
434
+ if (pnpmReady)
435
+ return Promise.resolve(true);
436
+ return new Promise((resolvePromise) => {
437
+ // Piped, not ignored: the output of a pnpm that exists but will not run
438
+ // IS the explanation, and throwing it away is what left #228 with a
439
+ // failure nobody could act on.
440
+ const child = spawnShim('pnpm', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'], viaShell: winCmdShim, env: spawnEnv() });
441
+ let output = '';
442
+ const collect = (chunk) => { output = (output + chunk.toString()).slice(-2000); };
443
+ child.stdout?.on('data', collect);
444
+ child.stderr?.on('data', collect);
445
+ child.on('error', (error) => {
446
+ pnpmProbeFailure = { kind: 'missing', output: error.message };
447
+ resolvePromise(false);
448
+ });
449
+ child.on('close', (code) => {
450
+ pnpmReady = code === 0;
451
+ pnpmProbeFailure = pnpmReady ? null : { kind: 'failed', output: output.trim() };
452
+ resolvePromise(pnpmReady);
453
+ });
454
+ });
455
+ }
456
+ function runQuiet(file, args, timeoutMs) {
457
+ return new Promise((resolvePromise) => {
458
+ const child = spawnShim(file, args, {
459
+ env: spawnEnv(),
460
+ stdio: ['ignore', 'pipe', 'pipe'],
461
+ viaShell: winCmdShim,
462
+ });
463
+ let output = '';
464
+ const timer = setTimeout(() => killChild(child), timeoutMs);
465
+ const collect = (chunk) => { output = (output + chunk.toString()).slice(-8 * 1024); };
466
+ child.stdout?.on('data', collect);
467
+ child.stderr?.on('data', collect);
468
+ child.on('error', (error) => { clearTimeout(timer); resolvePromise({ code: 127, output: error.message }); });
469
+ child.on('close', (code) => { clearTimeout(timer); resolvePromise({ code, output }); });
470
+ });
471
+ }
472
+ /**
473
+ * Provision pnpm without user involvement: corepack (ships with Node) first,
474
+ * a global npm install as fallback.
475
+ * @returns true when `pnpm --version` succeeds afterwards.
476
+ */
477
+ export async function provisionPnpm() {
478
+ const corepack = await runQuiet('corepack', ['enable', 'pnpm'], 60 * 1000);
479
+ logEvent(corepack.code === 0 ? 'info' : 'warn', 'setup-pnpm', `corepack enable: exit=${String(corepack.code)} ${corepack.output.slice(-200)}`);
480
+ if (await probePnpm())
481
+ return { ok: true };
482
+ const npm = await runQuiet('npm', ['install', '-g', 'pnpm'], 3 * 60 * 1000);
483
+ logEvent(npm.code === 0 ? 'info' : 'error', 'setup-pnpm', `npm -g: exit=${String(npm.code)} ${npm.output.slice(-200)}`);
484
+ if (await probePnpm())
485
+ return { ok: true };
486
+ // The install SUCCEEDED but the new binary is somewhere this process does
487
+ // not look (#149: corepack exit=0, npm -g exit=0, and the market still
488
+ // said "setup failed"). npm knows where it just put it, so ask — and if
489
+ // pnpm runs from there, remember that directory for every later spawn
490
+ // instead of telling the user a successful install failed.
491
+ if (npm.code === 0 || corepack.code === 0) {
492
+ const prefix = await runQuiet('npm', ['prefix', '-g'], 30 * 1000);
493
+ const root = prefix.code === 0 ? prefix.output.trim().split('\n').pop() ?? '' : '';
494
+ // `npm prefix -g` already is the executable directory on Windows
495
+ // (`pnpm.cmd` lives directly under it). Unix keeps shims in `bin/`.
496
+ const bin = root === '' ? '' : process.platform === 'win32' ? root : join(root, 'bin');
497
+ if (bin !== '' && isAbsolute(bin) && !extraPathDirs.includes(bin)) {
498
+ extraPathDirs.push(bin);
499
+ logEvent('info', 'setup-pnpm', `added npm's global bin to the probe path: ${bin}`);
500
+ if (await probePnpm())
501
+ return { ok: true };
502
+ extraPathDirs.pop();
503
+ }
504
+ }
505
+ const npmFound = toolOnPath('npm');
506
+ if (!npmFound)
507
+ logEvent('warn', 'setup-pnpm', `npm is not on any searched path (node lives in ${nodeBinDir})`);
508
+ return { ok: false, hint: provisionHint(corepack.output, npm.output, npmFound, lastPnpmProbeFailure()) };
509
+ }
510
+ /** Executable suffixes a bare command name can carry on this platform. */
511
+ const EXECUTABLE_SUFFIXES = process.platform === 'win32'
512
+ ? (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(part => part !== '')
513
+ : [''];
514
+ /**
515
+ * Whether a bare command name resolves to a file on the PATH the market
516
+ * hands its children.
517
+ *
518
+ * The market cannot read the reason a spawn failed out of the child's
519
+ * message: cmd.exe reports a missing command in the console's ANSI codepage
520
+ * ("'npm' 不是内部或外部命令" on a Chinese Windows), which is neither the
521
+ * string `ENOENT` nor even valid UTF-8 — so the #32 hint, written against
522
+ * Node's own ENOENT wording, could never fire on Windows and the user was
523
+ * left with no guidance at all (#167). Looking on disk answers the same
524
+ * question in every locale.
525
+ */
526
+ export function toolOnPath(name) {
527
+ const separator = process.platform === 'win32' ? ';' : ':';
528
+ for (const dir of (spawnEnv().PATH ?? '').split(separator)) {
529
+ if (dir === '')
530
+ continue;
531
+ for (const suffix of EXECUTABLE_SUFFIXES) {
532
+ if (existsSync(join(dir, name + suffix)))
533
+ return true;
534
+ }
535
+ }
536
+ return false;
537
+ }
538
+ /**
539
+ * Why the one-click pnpm setup failed, in terms the user can act on.
540
+ *
541
+ * Every one of these was a real report where the market said only "自动准备
542
+ * 没成功" while the log held the actual cause: EEXIST (#142 — corepack had
543
+ * already placed a pnpm shim, so `npm -g` refused to overwrite it), EPERM
544
+ * (#108 — Node installed somewhere the user cannot write), ENOENT (#32 —
545
+ * a GUI launch with no Node on PATH at all).
546
+ * @returns a bilingual, actionable hint, or undefined when unrecognized.
547
+ */
548
+ export function provisionHint(corepackOutput, npmOutput, npmFound = true, probeFailure = null) {
549
+ // Node itself unreachable: pointing the user back at this same button
550
+ // would be a dead end (#32). `npmFound` answers this from disk, so it
551
+ // holds on a Windows console that reports the same thing in a codepage we
552
+ // cannot read (#167); the ENOENT match stays for callers without it.
553
+ if (!npmFound || (/ENOENT/.test(corepackOutput) && /ENOENT/.test(npmOutput))) {
554
+ // The searched list is spelled out because the previous wording named
555
+ // only the Node directory, which was both incomplete and unhelpful: a
556
+ // user who HAD installed pnpm could not tell whether we looked in the
557
+ // right place (#292). And the restart note matters — the installer sets
558
+ // PNPM_HOME for new sessions, so a dsh already running cannot see it.
559
+ const searched = toolSearchDirs().join(process.platform === 'win32' ? ' ; ' : ' : ');
560
+ 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`;
561
+ }
562
+ if (/EEXIST|already exists|--force to overwrite/i.test(npmOutput)) {
563
+ 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`';
564
+ }
565
+ if (/EPERM|EACCES|permission denied|as root\/Administrator/i.test(`${corepackOutput}\n${npmOutput}`)) {
566
+ 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)';
567
+ }
568
+ // Network-shaped failures: the corepack shim downloads pnpm on first run,
569
+ // so a blocked registry or proxy leaves a shim that never works. The
570
+ // button cannot fix that; a full install (or a mirror) can.
571
+ if (/ETIMEDOUT|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|network|proxy|certificate/i.test(`${corepackOutput}\n${npmOutput}`)) {
572
+ 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>`';
573
+ }
574
+ // Everything reported success and pnpm still will not run (#228 by
575
+ // @ZhengXin1023: corepack exit=0, npm -g exit=0, npm found — and the
576
+ // install button stayed locked with nothing said).
577
+ //
578
+ // This used to return undefined, which left the case that most needs an
579
+ // explanation with none: the user is told "setup failed" while every step
580
+ // they can see succeeded, and their complaint was exactly that — "又不告诉
581
+ // 我怎么手动配置". Whatever the cause, the actionable question is the same
582
+ // one, so ask it: where is pnpm, and is that anywhere this process looks?
583
+ // pnpm IS on the path and exits non-zero. Telling this user to fix PNPM_HOME
584
+ // would be advice for the opposite problem — theirs runs fine from a shell,
585
+ // which is exactly what #228 reported. Its own output is the explanation.
586
+ if (probeFailure?.kind === 'failed') {
587
+ const detail = probeFailure.output === '' ? '' : `\n\n${probeFailure.output}`;
588
+ 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}`;
589
+ }
590
+ const searched = toolSearchDirs().join(process.platform === 'win32' ? ' ; ' : ' : ');
591
+ const locate = process.platform === 'win32' ? 'where pnpm' : 'which pnpm';
592
+ 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`;
593
+ }
594
+ /** Singleton progress state; the status route reads it, runDshPlugin writes it. */
595
+ export const progress = {
596
+ active: false,
597
+ target: '',
598
+ startedAt: 0,
599
+ lastLine: '',
600
+ phase: null,
601
+ done: 0,
602
+ total: null,
603
+ currentPackage: null,
604
+ downloaded: null,
605
+ size: null,
606
+ ndjson: false,
607
+ error: null,
608
+ cancelling: false,
609
+ };
610
+ /** Identifies this host process; the client scopes its pending-restart flags to it. */
611
+ export const BOOT_ID = `${String(process.pid)}-${String(Date.now())}`;
612
+ /**
613
+ * Central allowlist for every spawn target, regardless of which route built
614
+ * it (defense in depth on top of per-route validation — the win32 bare-dsh
615
+ * fallback runs through a shell). Suggested in #16 by @anupamme.
616
+ *
617
+ * `^`, `~` and `=` are intentionally allowed: restore/install flows turn
618
+ * manifest specs such as "dsh-better-sidebar": "^0.14.0" into targets like
619
+ * `dsh-better-sidebar@^0.14.0`, and regex-valid semver ranges must not be
620
+ * mistaken for shell injection (whitespace and shell metacharacters remain
621
+ * rejected — the win32 bare-dsh fallback is the reason to keep them out).
622
+ */
623
+ export const TARGET_RE = /^[A-Za-z0-9@:./_#+~^=-]+$/;
624
+ /** Mutating pnpm commands get the structured reporter appended. */
625
+ const NDJSON_COMMANDS = new Set(['add', 'remove', 'install']);
626
+ /** Apply profile-specific pnpm compatibility and the structured reporter. */
627
+ function preparePluginArgs(profileDirectory, pluginArgs) {
628
+ let args = pluginArgsFor(profileDirectory, [...pluginArgs]);
629
+ const target = args[args.length - 1] ?? '';
630
+ if (!TARGET_RE.test(target)) {
631
+ return { error: `unsafe plugin target rejected: ${JSON.stringify(target)}` };
632
+ }
633
+ if (NDJSON_COMMANDS.has(args[0]))
634
+ args = [...args, '--reporter=ndjson'];
635
+ return { args, target };
636
+ }
637
+ /** Reset the singleton status snapshot before one operation starts. */
638
+ function beginProgress(target) {
639
+ progress.active = true;
640
+ progress.target = target;
641
+ progress.startedAt = Date.now();
642
+ progress.lastLine = '';
643
+ progress.phase = null;
644
+ progress.done = 0;
645
+ progress.total = null;
646
+ progress.currentPackage = null;
647
+ progress.downloaded = null;
648
+ progress.size = null;
649
+ progress.ndjson = false;
650
+ progress.error = null;
651
+ progress.cancelling = false;
652
+ return createProgressTracker();
653
+ }
654
+ /**
655
+ * Line-buffered progress feed: pnpm's ndjson reporter emits one JSON object
656
+ * per line on stdout, and chunk boundaries can split a line. Human fallback
657
+ * lines (older pnpm without structured events) still update `lastLine`.
658
+ */
659
+ function makeProgressFeeder(tracker) {
660
+ let lineBuffer = '';
661
+ return (chunk) => {
662
+ lineBuffer += chunk;
663
+ let nl;
664
+ while ((nl = lineBuffer.indexOf('\n')) !== -1) {
665
+ const line = lineBuffer.slice(0, nl);
666
+ lineBuffer = lineBuffer.slice(nl + 1);
667
+ const trimmed = line.trim();
668
+ if (trimmed === '')
669
+ continue;
670
+ tracker.feed(trimmed);
671
+ // Human lines never start with '{'; JSON lines are consumed by the tracker.
672
+ if (!trimmed.startsWith('{'))
673
+ progress.lastLine = trimmed.slice(0, 200);
674
+ }
675
+ };
676
+ }
677
+ /** Run one `chatcode-cli plugin --profile <p> …` command with timeout and progress tracking. */
678
+ export function runDshPlugin(profile, pluginArgs) {
679
+ const { file, args, cwd, viaShell } = dshArgv();
680
+ if (viaShell && !isCmdSafeProfileName(profile)) {
681
+ 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`;
682
+ logEvent('error', 'install', error);
683
+ return Promise.resolve({ exitCode: 1, timedOut: false, stdout: '', stderr: error, cancelled: false });
684
+ }
685
+ const prepared = preparePluginArgs(profileDir(profile), pluginArgs);
686
+ if ('error' in prepared) {
687
+ logEvent('error', 'install', prepared.error);
688
+ return Promise.resolve({ exitCode: 1, timedOut: false, stdout: '', stderr: prepared.error, cancelled: false });
689
+ }
690
+ pluginArgs = prepared.args;
691
+ const tracker = beginProgress(prepared.target);
692
+ const feed = makeProgressFeeder(tracker);
693
+ return new Promise((resolvePromise) => {
694
+ const child = spawnShim(file, [...args, 'plugin', '--profile', profile, ...pluginArgs], {
695
+ cwd,
696
+ // pnpm v10 blocks forever on a silent interactive prompt without a TTY
697
+ // (observed on re-add over a pinned git spec); CI mode forces it to act
698
+ // or fail instead of asking.
699
+ env: spawnEnv(),
700
+ stdio: ['ignore', 'pipe', 'pipe'],
701
+ viaShell,
702
+ // Own process group on POSIX so cancel/timeout can kill the whole
703
+ // tree (dsh wrapper + pnpm grandchild) with one group signal.
704
+ detached: process.platform !== 'win32',
705
+ });
706
+ activeChild = child;
707
+ cancelRequested = false;
708
+ let stdout = '';
709
+ let stderr = '';
710
+ let timedOut = false;
711
+ const timer = setTimeout(() => {
712
+ timedOut = true;
713
+ killTree(child);
714
+ }, INSTALL_TIMEOUT_MS);
715
+ child.stdout?.on('data', (chunk) => {
716
+ const text = chunk.toString();
717
+ stdout = (stdout + text).slice(-256 * 1024);
718
+ feed(text);
719
+ syncProgress(tracker);
720
+ });
721
+ child.stderr?.on('data', (chunk) => {
722
+ const text = chunk.toString();
723
+ stderr = (stderr + text).slice(-64 * 1024);
724
+ feed(text);
725
+ syncProgress(tracker);
726
+ });
727
+ child.on('error', (error) => {
728
+ clearTimeout(timer);
729
+ progress.active = false;
730
+ progress.cancelling = false;
731
+ if (activeChild === child)
732
+ activeChild = null;
733
+ resolvePromise({ exitCode: 127, timedOut: false, stdout, stderr: `${stderr}\n${error.message}`, cancelled: false });
734
+ });
735
+ child.on('close', (code) => {
736
+ clearTimeout(timer);
737
+ progress.active = false;
738
+ progress.cancelling = false;
739
+ if (activeChild === child)
740
+ activeChild = null;
741
+ const failed = code !== 0 || timedOut;
742
+ if (failed)
743
+ progress.error = tracker.snapshot.error;
744
+ const ignoredBuilds = tracker.snapshot.ignoredBuilds;
745
+ const { error: pnpmError, errorCode: pnpmErrorCode } = tracker.snapshot;
746
+ resolvePromise({
747
+ exitCode: code,
748
+ timedOut,
749
+ stdout,
750
+ stderr,
751
+ cancelled: cancelRequested,
752
+ ...(pnpmError !== null ? { pnpmError } : {}),
753
+ ...(pnpmErrorCode !== null ? { pnpmErrorCode } : {}),
754
+ ...(ignoredBuilds.length > 0 ? { ignoredBuilds } : {}),
755
+ });
756
+ });
757
+ });
758
+ }
759
+ /**
760
+ * Adapt DSH Desktop's generation-scoped package manager to the existing
761
+ * market runner. There is no runtime import or dependency on Desktop: the
762
+ * Host supplies this public service only when the package is mounted there.
763
+ */
764
+ export function createDesktopPluginRuntime(service, activeProfileDir, invokingDir = process.cwd(), timeoutMs = INSTALL_TIMEOUT_MS) {
765
+ if (!isAbsolute(activeProfileDir) || activeProfileDir.includes('\0')) {
766
+ throw new Error('ChatCode CLI Market: Desktop profile directory must be an absolute path without NUL');
767
+ }
768
+ if (!isAbsolute(invokingDir) || invokingDir.includes('\0')) {
769
+ throw new Error('ChatCode CLI Market: Desktop invoking directory must be an absolute path without NUL');
770
+ }
771
+ const owner = Symbol('dsh-market desktop runtime');
772
+ let closed = false;
773
+ const runPlugin = async (_profile, pluginArgs) => {
774
+ if (closed) {
775
+ return {
776
+ exitCode: 127,
777
+ timedOut: false,
778
+ stdout: '',
779
+ stderr: 'ChatCode CLI Market: Desktop package runtime is disposed',
780
+ cancelled: false,
781
+ };
782
+ }
783
+ const prepared = preparePluginArgs(activeProfileDir, pluginArgs);
784
+ if ('error' in prepared) {
785
+ logEvent('error', 'install', prepared.error);
786
+ return { exitCode: 1, timedOut: false, stdout: '', stderr: prepared.error, cancelled: false };
787
+ }
788
+ const abort = new AbortController();
789
+ let handle;
790
+ /** Set when this host only installs npm packages and the target is not one. */
791
+ let boundaryRefusesTarget = false;
792
+ /** Exact npm pin the install boundary actually sent (#496). */
793
+ let resolvedNpmVersion;
794
+ try {
795
+ // `add` goes through Anywhere Labs' install boundary when that host
796
+ // publishes one, because their Desktop rejects `add` on `runPlugin`
797
+ // outright. Feature-detected, never assumed: this method is theirs
798
+ // alone, and on every other client — including the other desktop app
799
+ // in #292 — the ordinary call below is what runs, unchanged.
800
+ const boundary = prepared.args[0] === 'add' ? service.runExternalMarketPluginInstall : undefined;
801
+ const viaBoundary = boundary === undefined ? null : await exactNpmArgs(prepared.args);
802
+ // A host that publishes the boundary accepts ONLY `name@exact.version`
803
+ // through it, so a github-sourced plugin has nowhere to go: the
804
+ // fallback below is a call that host refuses outright. Their refusal is
805
+ // accurate but says nothing about why THIS plugin, and roughly half the
806
+ // catalog has no npm package — reported in #138 after the user found
807
+ // out by clicking Install and reading `exit 127`.
808
+ boundaryRefusesTarget = boundary !== undefined && viaBoundary === null;
809
+ if (viaBoundary !== null)
810
+ resolvedNpmVersion = viaBoundary.resolvedNpmVersion;
811
+ handle = boundary === undefined || viaBoundary === null
812
+ ? service.runPlugin(prepared.args, invokingDir, abort.signal)
813
+ : boundary.call(service, viaBoundary.args, invokingDir, abort.signal);
814
+ }
815
+ catch (error) {
816
+ const message = error instanceof Error ? error.message : String(error);
817
+ const busy = /another desktop pnpm operation is already running/i.test(message);
818
+ return {
819
+ exitCode: 127,
820
+ timedOut: false,
821
+ stdout: '',
822
+ stderr: boundaryRefusesTarget ? `${message}\n${NPM_ONLY_HOST_NOTE}` : message,
823
+ cancelled: false,
824
+ ...(busy ? { busy: true } : {}),
825
+ };
826
+ }
827
+ const tracker = beginProgress(prepared.target);
828
+ const feed = makeProgressFeeder(tracker);
829
+ let stdout = '';
830
+ let stderr = '';
831
+ let timedOut = false;
832
+ const collectStdout = (chunk) => {
833
+ const text = chunk.toString();
834
+ stdout = (stdout + text).slice(-256 * 1024);
835
+ feed(text);
836
+ syncProgress(tracker);
837
+ };
838
+ const collectStderr = (chunk) => {
839
+ const text = chunk.toString();
840
+ stderr = (stderr + text).slice(-64 * 1024);
841
+ feed(text);
842
+ syncProgress(tracker);
843
+ };
844
+ handle.stdout.on('data', collectStdout);
845
+ handle.stderr.on('data', collectStderr);
846
+ let active;
847
+ let timer;
848
+ const done = (async () => {
849
+ try {
850
+ const outcome = await handle.done;
851
+ const failed = outcome.exitCode !== 0 || outcome.signal !== null || timedOut;
852
+ if (failed)
853
+ progress.error = tracker.snapshot.error;
854
+ const ignoredBuilds = tracker.snapshot.ignoredBuilds;
855
+ const { error: pnpmError, errorCode: pnpmErrorCode } = tracker.snapshot;
856
+ return {
857
+ exitCode: outcome.exitCode,
858
+ timedOut,
859
+ stdout,
860
+ stderr,
861
+ cancelled: active.userCancelled,
862
+ ...(ignoredBuilds.length > 0 ? { ignoredBuilds } : {}),
863
+ ...(pnpmError !== null ? { pnpmError } : {}),
864
+ ...(pnpmErrorCode !== null ? { pnpmErrorCode } : {}),
865
+ ...(resolvedNpmVersion !== undefined ? { resolvedNpmVersion } : {}),
866
+ };
867
+ }
868
+ catch (error) {
869
+ const message = error instanceof Error ? error.message : String(error);
870
+ progress.error = tracker.snapshot.error;
871
+ const detail = `${stderr}${stderr === '' ? '' : '\n'}${message}`;
872
+ return {
873
+ exitCode: 127,
874
+ timedOut,
875
+ stdout,
876
+ stderr: boundaryRefusesTarget ? `${detail}\n${NPM_ONLY_HOST_NOTE}` : detail,
877
+ cancelled: active.userCancelled,
878
+ ...(resolvedNpmVersion !== undefined ? { resolvedNpmVersion } : {}),
879
+ };
880
+ }
881
+ finally {
882
+ if (timer !== undefined)
883
+ clearTimeout(timer);
884
+ progress.active = false;
885
+ progress.cancelling = false;
886
+ handle.stdout.off('data', collectStdout);
887
+ handle.stderr.off('data', collectStderr);
888
+ if (activeDesktopOperation === active)
889
+ activeDesktopOperation = null;
890
+ }
891
+ })();
892
+ active = { owner, cancel: () => { handle.cancel(); }, done, userCancelled: false };
893
+ activeDesktopOperation = active;
894
+ timer = setTimeout(() => {
895
+ timedOut = true;
896
+ abort.abort(new Error('ChatCode CLI Market: Desktop package operation timed out'));
897
+ // The public handle owns an explicit process-tree cancellation path.
898
+ // Use it as well as AbortSignal so a structurally compatible provider
899
+ // that does not observe the signal cannot strand the route or teardown.
900
+ handle.cancel();
901
+ }, timeoutMs);
902
+ timer.unref?.();
903
+ return done;
904
+ };
905
+ const cancelOwned = (userCancelled) => {
906
+ const active = activeDesktopOperation;
907
+ if (active?.owner !== owner)
908
+ return false;
909
+ if (userCancelled)
910
+ active.userCancelled = true;
911
+ progress.cancelling = true;
912
+ active.cancel();
913
+ return true;
914
+ };
915
+ return {
916
+ runPlugin,
917
+ // Anywhere Labs' optional external boundary accepts exact npm targets
918
+ // only. Every Desktop host without that boundary retains the ordinary
919
+ // CLI grammar, including immutable Git and archive targets.
920
+ supportsExactRollbackTarget: target => TARGET_RE.test(target)
921
+ && (service.runExternalMarketPluginInstall === undefined || EXACT_NPM_TARGET_RE.test(target)),
922
+ // The service is backed by Desktop's packaged pnpm; system discovery and
923
+ // global provisioning are neither needed nor allowed in this mode.
924
+ probePnpm: () => Promise.resolve(true),
925
+ provisionPnpm: () => Promise.resolve({ ok: true }),
926
+ cancelActive: () => cancelOwned(true),
927
+ dispose: async () => {
928
+ closed = true;
929
+ const active = activeDesktopOperation;
930
+ if (active?.owner !== owner)
931
+ return;
932
+ cancelOwned(false);
933
+ await active.done.catch(() => { });
934
+ },
935
+ };
936
+ }
937
+ /** Copy the tracker's snapshot into the singleton the status route reads. */
938
+ function syncProgress(tracker) {
939
+ const snap = tracker.snapshot;
940
+ progress.phase = snap.phase;
941
+ progress.done = snap.done;
942
+ progress.total = snap.total;
943
+ progress.currentPackage = snap.currentPackage;
944
+ progress.downloaded = snap.downloaded;
945
+ progress.size = snap.size;
946
+ progress.ndjson = snap.seen;
947
+ if (snap.error !== null)
948
+ progress.error = snap.error;
949
+ }