@chatcode/cco-market 1.45.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/README.zh.md +143 -0
- package/UPDATE-API-V1.md +132 -0
- package/client/client.js +11534 -0
- package/cordis.patch.yml +5 -0
- package/lib/accelerate.js +184 -0
- package/lib/agents.js +36 -0
- package/lib/backup.js +572 -0
- package/lib/catalog-local-match.js +126 -0
- package/lib/catalog-npm.js +111 -0
- package/lib/changelog.js +242 -0
- package/lib/channels.js +64 -0
- package/lib/check.js +1067 -0
- package/lib/compatibility.js +198 -0
- package/lib/diagnostics.js +49 -0
- package/lib/discovery-compatibility.js +260 -0
- package/lib/dsh-cli.js +949 -0
- package/lib/dsh-install.js +108 -0
- package/lib/gist.js +352 -0
- package/lib/groups.js +97 -0
- package/lib/home-paths.js +42 -0
- package/lib/hot.js +519 -0
- package/lib/http.js +39 -0
- package/lib/index.js +97 -0
- package/lib/install.js +387 -0
- package/lib/log.js +197 -0
- package/lib/ndjson.js +154 -0
- package/lib/net.js +98 -0
- package/lib/order.js +284 -0
- package/lib/patch.js +521 -0
- package/lib/pnpm-compat.js +490 -0
- package/lib/presets.js +278 -0
- package/lib/profile.js +911 -0
- package/lib/region-probe.js +93 -0
- package/lib/regions.js +253 -0
- package/lib/registry.js +204 -0
- package/lib/restart.js +373 -0
- package/lib/routes.js +4372 -0
- package/lib/settings.js +109 -0
- package/lib/skill-market/api.js +119 -0
- package/lib/skill-market/config.js +29 -0
- package/lib/skill-market/installer.js +352 -0
- package/lib/skill-market/runtime.js +13 -0
- package/lib/skill-market/tui.js +119 -0
- package/lib/skill-market/types.js +1 -0
- package/lib/skill-market/web-routes.js +123 -0
- package/lib/snapshot.js +500 -0
- package/lib/source-migration.js +54 -0
- package/lib/sources.js +596 -0
- package/lib/store.js +91 -0
- package/lib/themes.js +102 -0
- package/lib/trial.js +116 -0
- package/lib/types/accelerate.d.ts +85 -0
- package/lib/types/agents.d.ts +22 -0
- package/lib/types/backup.d.ts +131 -0
- package/lib/types/catalog-local-match.d.ts +50 -0
- package/lib/types/catalog-npm.d.ts +47 -0
- package/lib/types/changelog.d.ts +102 -0
- package/lib/types/channels.d.ts +56 -0
- package/lib/types/check.d.ts +266 -0
- package/lib/types/compatibility.d.ts +64 -0
- package/lib/types/diagnostics.d.ts +29 -0
- package/lib/types/discovery-compatibility.d.ts +80 -0
- package/lib/types/dsh-cli.d.ts +324 -0
- package/lib/types/dsh-install.d.ts +46 -0
- package/lib/types/gist.d.ts +53 -0
- package/lib/types/groups.d.ts +34 -0
- package/lib/types/home-paths.d.ts +16 -0
- package/lib/types/hot.d.ts +221 -0
- package/lib/types/http.d.ts +12 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/install.d.ts +172 -0
- package/lib/types/log.d.ts +43 -0
- package/lib/types/ndjson.d.ts +52 -0
- package/lib/types/net.d.ts +58 -0
- package/lib/types/order.d.ts +100 -0
- package/lib/types/patch.d.ts +129 -0
- package/lib/types/pnpm-compat.d.ts +93 -0
- package/lib/types/presets.d.ts +90 -0
- package/lib/types/profile.d.ts +253 -0
- package/lib/types/region-probe.d.ts +50 -0
- package/lib/types/regions.d.ts +122 -0
- package/lib/types/registry.d.ts +86 -0
- package/lib/types/restart.d.ts +194 -0
- package/lib/types/routes.d.ts +67 -0
- package/lib/types/settings.d.ts +78 -0
- package/lib/types/skill-market/api.d.ts +10 -0
- package/lib/types/skill-market/config.d.ts +3 -0
- package/lib/types/skill-market/installer.d.ts +23 -0
- package/lib/types/skill-market/runtime.d.ts +10 -0
- package/lib/types/skill-market/tui.d.ts +4 -0
- package/lib/types/skill-market/types.d.ts +71 -0
- package/lib/types/skill-market/web-routes.d.ts +19 -0
- package/lib/types/snapshot.d.ts +93 -0
- package/lib/types/source-migration.d.ts +11 -0
- package/lib/types/sources.d.ts +216 -0
- package/lib/types/store.d.ts +30 -0
- package/lib/types/themes.d.ts +40 -0
- package/lib/types/trial.d.ts +61 -0
- package/lib/types/update-api-v1.d.ts +66 -0
- package/lib/types/updates.d.ts +123 -0
- package/lib/types/verify.d.ts +139 -0
- package/lib/update-api-v1.js +215 -0
- package/lib/updates.js +361 -0
- package/lib/verify.js +453 -0
- package/package.json +121 -0
- package/src/accelerate.ts +213 -0
- package/src/agents.ts +43 -0
- package/src/backup.ts +583 -0
- package/src/catalog-local-match.ts +144 -0
- package/src/catalog-npm.ts +120 -0
- package/src/changelog.ts +282 -0
- package/src/channels.ts +70 -0
- package/src/check.ts +1239 -0
- package/src/client/CommentsModal.tsx +119 -0
- package/src/client/Diagnostics.tsx +907 -0
- package/src/client/ErrorBoundary.tsx +111 -0
- package/src/client/InstallToast.tsx +31 -0
- package/src/client/Market.module.css +830 -0
- package/src/client/MarketSection.tsx +5301 -0
- package/src/client/OperationsPanel.tsx +365 -0
- package/src/client/SettingsCard.tsx +621 -0
- package/src/client/SkillMarket.module.css +21 -0
- package/src/client/SkillMarketSection.tsx +162 -0
- package/src/client/comments.ts +54 -0
- package/src/client/globals.d.ts +13 -0
- package/src/client/index.ts +180 -0
- package/src/client/locales.ts +1112 -0
- package/src/client/market-data.ts +1321 -0
- package/src/client/operations.ts +201 -0
- package/src/client/preset-panel.tsx +263 -0
- package/src/client/primitives.d.ts +140 -0
- package/src/client/self-check.ts +147 -0
- package/src/client/snapshot-panel.tsx +244 -0
- package/src/compatibility.ts +237 -0
- package/src/diagnostics.ts +84 -0
- package/src/discovery-compatibility.ts +315 -0
- package/src/dsh-cli.ts +1126 -0
- package/src/dsh-install.ts +118 -0
- package/src/gist.ts +362 -0
- package/src/groups.ts +111 -0
- package/src/home-paths.ts +53 -0
- package/src/hot.ts +628 -0
- package/src/http.ts +41 -0
- package/src/index.ts +128 -0
- package/src/install.ts +420 -0
- package/src/log.ts +206 -0
- package/src/ndjson.ts +185 -0
- package/src/net.ts +106 -0
- package/src/order.ts +303 -0
- package/src/patch.ts +522 -0
- package/src/pnpm-compat.ts +527 -0
- package/src/presets.ts +344 -0
- package/src/profile.ts +940 -0
- package/src/region-probe.ts +97 -0
- package/src/regions.ts +310 -0
- package/src/registry.ts +250 -0
- package/src/restart.ts +396 -0
- package/src/routes.ts +4506 -0
- package/src/settings.ts +141 -0
- package/src/skill-market/api.ts +130 -0
- package/src/skill-market/config.ts +32 -0
- package/src/skill-market/installer.ts +337 -0
- package/src/skill-market/runtime.ts +14 -0
- package/src/skill-market/tui.ts +157 -0
- package/src/skill-market/types.ts +80 -0
- package/src/skill-market/web-routes.ts +132 -0
- package/src/snapshot.ts +532 -0
- package/src/source-migration.ts +61 -0
- package/src/sources.ts +565 -0
- package/src/store.ts +89 -0
- package/src/themes.ts +125 -0
- package/src/trial.ts +156 -0
- package/src/update-api-v1.ts +277 -0
- package/src/updates.ts +400 -0
- package/src/verify.ts +492 -0
package/src/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
|
+
}
|