@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/restart.ts
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-restart: relaunch the exact ChatCode CLI invocation that booted this host so
|
|
3
|
+
* pending (non-hot) plugin changes take effect without the user leaving the
|
|
4
|
+
* UI. Contributed in #14 by @ysyyhhh; ported onto the layered architecture.
|
|
5
|
+
*
|
|
6
|
+
* Safety model: the endpoint accepts only direct same-origin loopback
|
|
7
|
+
* requests (no forwarding headers), refuses while a plugin operation runs,
|
|
8
|
+
* and deployments under a supervisor (systemd/launchd/pm2) can disable the
|
|
9
|
+
* whole feature with `allowRestart: false` — the supervisor owns restarts.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
13
|
+
import { readFileSync } from 'node:fs'
|
|
14
|
+
import inspector from 'node:inspector'
|
|
15
|
+
import { tmpdir } from 'node:os'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import type { IncomingMessage } from 'node:http'
|
|
18
|
+
import { dshArgv, nodeExecutable } from './dsh-cli.ts'
|
|
19
|
+
|
|
20
|
+
/** Vitest / flows can pin detection without opening a real inspector port. */
|
|
21
|
+
let debuggerOverride: 'inspector' | null | undefined
|
|
22
|
+
|
|
23
|
+
/** @internal test hook — production callers use detectedDebugger() only. */
|
|
24
|
+
export function setDetectedDebuggerOverride(value: 'inspector' | null | undefined): void {
|
|
25
|
+
debuggerOverride = value
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const INSPECT_ARG_PREFIXES = ['--inspect', '--inspect-brk', '--inspect-port', '--inspect-wait'] as const
|
|
29
|
+
|
|
30
|
+
function tokenHasInspectFlag(token: string): boolean {
|
|
31
|
+
for (const prefix of INSPECT_ARG_PREFIXES) {
|
|
32
|
+
if (token === prefix || token.startsWith(`${prefix}=`)) return true
|
|
33
|
+
}
|
|
34
|
+
if (token === '--debug-brk' || token.startsWith('--debug-brk=')) return true
|
|
35
|
+
if (token === '--debug' || token.startsWith('--debug=')) return true
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function argvHasInspectFlag(tokens: readonly string[]): boolean {
|
|
40
|
+
for (const token of tokens) {
|
|
41
|
+
if (tokenHasInspectFlag(token)) return true
|
|
42
|
+
}
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The process supervisor running this host, when one can be identified —
|
|
48
|
+
* `null` when nothing says so.
|
|
49
|
+
*
|
|
50
|
+
* This exists because the failure it prevents is the worst one the market
|
|
51
|
+
* can cause. Under systemd's default `KillMode=control-group`, everything in
|
|
52
|
+
* the unit's cgroup dies with the main process — including the detached
|
|
53
|
+
* helper that was supposed to bring the replacement up. So "restart" killed
|
|
54
|
+
* a production service and nothing came back (#229 by @SkillBase-Al: "杀死了
|
|
55
|
+
* 服务但是无法重复启动服务"). `allowRestart: false` was always the documented
|
|
56
|
+
* answer, but it is opt-in, and nothing told the operator to opt in until
|
|
57
|
+
* after they had already lost the service.
|
|
58
|
+
*
|
|
59
|
+
* TWO signals are required, and the second is the whole reason this function
|
|
60
|
+
* is not a one-line env check. `INVOCATION_ID` is INHERITED: every
|
|
61
|
+
* descendant of a systemd unit carries it, which on Linux includes an
|
|
62
|
+
* ordinary desktop terminal (its shell descends from a user-session unit)
|
|
63
|
+
* and a CI runner (the agent is a unit — this repo's own smoke test caught
|
|
64
|
+
* that). Treating inheritance as ownership would disable the button for a
|
|
65
|
+
* large population of hosts where it works fine, which is a worse bug than
|
|
66
|
+
* the one being fixed.
|
|
67
|
+
*
|
|
68
|
+
* The parent test is what distinguishes being the unit's own main process
|
|
69
|
+
* from merely descending from one: a terminal's node has the shell as its
|
|
70
|
+
* parent and a runner's has the agent. It used to be `ppid === 1`, which is
|
|
71
|
+
* only true for SYSTEM units. A per-user unit's service is forked by that
|
|
72
|
+
* user's manager instance — `systemd --user`, an ordinary PID — so user-unit
|
|
73
|
+
* hosts read as unsupervised, and one-click restart killed them for good
|
|
74
|
+
* (#471 by @automagik-genie, with the journal to prove the whole chain:
|
|
75
|
+
* clean SIGTERM exit → `Restart=on-failure` does not fire → `KillMode=mixed`
|
|
76
|
+
* SIGKILLs the detached helper before it can spawn the replacement). So the
|
|
77
|
+
* parent counts as a manager when it is PID 1 or its comm is `systemd` —
|
|
78
|
+
* the one name both manager instances share and the false-positive parents
|
|
79
|
+
* (shells, CI agents) never carry. `/proc` is Linux-only, which is exactly
|
|
80
|
+
* as wide as systemd itself; anywhere it cannot be read the answer stays
|
|
81
|
+
* "not a manager".
|
|
82
|
+
*
|
|
83
|
+
* Scoped to systemd on purpose. pm2 sets `pm_id`, but it is inherited the
|
|
84
|
+
* same way and pm2's God daemon — not PID 1 — is the parent, so there is no
|
|
85
|
+
* equivalent second signal; a guess there would reintroduce exactly the
|
|
86
|
+
* false positive this pair exists to avoid. launchd has no marker at all.
|
|
87
|
+
* Both still need the explicit setting: detection is a safety net over the
|
|
88
|
+
* documented option, never a replacement for it.
|
|
89
|
+
*/
|
|
90
|
+
export function detectedSupervisor(
|
|
91
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
92
|
+
ppid: number = process.ppid,
|
|
93
|
+
parentComm: (pid: number) => string | null = readParentComm,
|
|
94
|
+
): string | null {
|
|
95
|
+
const set = (name: string): boolean => (env[name] ?? '') !== ''
|
|
96
|
+
if (!set('INVOCATION_ID') && !set('JOURNAL_STREAM')) return null
|
|
97
|
+
if (ppid === 1 || parentComm(ppid) === 'systemd') return 'systemd'
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The comm of `pid` from /proc, or null wherever that cannot be read. */
|
|
102
|
+
function readParentComm(pid: number): string | null {
|
|
103
|
+
try {
|
|
104
|
+
return readFileSync(`/proc/${String(pid)}/comm`, 'utf8').trim()
|
|
105
|
+
} catch {
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Whether this host process is under a debugger, when one can be identified —
|
|
112
|
+
* `'inspector'` or `null`.
|
|
113
|
+
*
|
|
114
|
+
* Parallel to `detectedSupervisor()` (#229): a runtime latch for the restart
|
|
115
|
+
* route and status poll, NOT an entry in `restartAllowed()`. Folding it into
|
|
116
|
+
* `restartAllowed()` would write `allowRestart: false` through the settings
|
|
117
|
+
* page while a debug session is open, and the switch would stay off after
|
|
118
|
+
* the debugger detaches (#447).
|
|
119
|
+
*
|
|
120
|
+
* An explicit `allowRestart: true` does NOT override this latch either —
|
|
121
|
+
* unlike systemd, there is no documented deployment shape where killing a
|
|
122
|
+
* debug-attached host from the market UI is the right answer.
|
|
123
|
+
*
|
|
124
|
+
* Primary signal: `inspector.url()` is set (covers `--inspect` at boot,
|
|
125
|
+
* `inspector.open()`, and SIGUSR1 attach). Secondary: inspect-family flags
|
|
126
|
+
* in `execArgv` and `NODE_OPTIONS`, matched by token prefix — not a
|
|
127
|
+
* `/inspect/` substring, so script paths like `.../inspect-tool.js` do not
|
|
128
|
+
* false-positive. Includes `--inspect-wait` for Node versions that expose it
|
|
129
|
+
* as a distinct flag before `inspector.url()` is populated.
|
|
130
|
+
*
|
|
131
|
+
* Accepted false positive (same trade as #229 not guessing pm2): a host left
|
|
132
|
+
* listening on an inspector port — including `NODE_OPTIONS=--inspect` with
|
|
133
|
+
* nobody attached — is treated as debug-owned and gets no one-click restart.
|
|
134
|
+
*/
|
|
135
|
+
export function detectedDebugger(
|
|
136
|
+
inspectorUrl: string | undefined = inspector.url(),
|
|
137
|
+
execArgv: readonly string[] = process.execArgv,
|
|
138
|
+
nodeOptions: string = process.env.NODE_OPTIONS ?? '',
|
|
139
|
+
): 'inspector' | null {
|
|
140
|
+
if (debuggerOverride !== undefined) return debuggerOverride
|
|
141
|
+
if (inspectorUrl !== undefined && inspectorUrl !== '') return 'inspector'
|
|
142
|
+
if (argvHasInspectFlag(execArgv)) return 'inspector'
|
|
143
|
+
const options = nodeOptions.trim()
|
|
144
|
+
if (options !== '' && argvHasInspectFlag(options.split(/\s+/u))) return 'inspector'
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Self-restart is enabled by default, disabled by an explicit false — and
|
|
150
|
+
* disabled by DEFAULT under a detected supervisor, which owns restarts and
|
|
151
|
+
* whose process group would take the replacement helper down with it.
|
|
152
|
+
*
|
|
153
|
+
* An explicit `true` still wins: an operator who has configured their unit
|
|
154
|
+
* for it (`KillMode=process`, or a wrapper that survives) is making a
|
|
155
|
+
* statement about their own deployment, and this should not overrule it.
|
|
156
|
+
*/
|
|
157
|
+
export function restartAllowed(
|
|
158
|
+
config: { allowRestart?: boolean },
|
|
159
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
160
|
+
ppid: number = process.ppid,
|
|
161
|
+
): boolean {
|
|
162
|
+
if (config.allowRestart !== undefined) return config.allowRestart
|
|
163
|
+
return detectedSupervisor(env, ppid) === null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The port this process is serving on, read off the request that asked for
|
|
168
|
+
* the restart.
|
|
169
|
+
*
|
|
170
|
+
* The alternative is to parse it out of the launch argv, which is wrong for
|
|
171
|
+
* every host that binds from config or an env var. The Host header is what
|
|
172
|
+
* the browser actually reached us on, so it is the port the replacement has
|
|
173
|
+
* to take over — and it is already validated against Origin by the guard
|
|
174
|
+
* below before any of this runs.
|
|
175
|
+
* @returns the port, or null when the header carries none (a default port).
|
|
176
|
+
*/
|
|
177
|
+
export function servingPort(request: Pick<IncomingMessage, 'headers'>): number | null {
|
|
178
|
+
const host = request.headers.host
|
|
179
|
+
if (host === undefined) return null
|
|
180
|
+
const match = /:(\d{1,5})$/u.exec(host)
|
|
181
|
+
if (match === null) return null
|
|
182
|
+
const port = Number(match[1])
|
|
183
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Whether a process-control request came from this Web host on loopback. */
|
|
187
|
+
export function trustedRestartRequest(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean {
|
|
188
|
+
const address = request.socket.remoteAddress
|
|
189
|
+
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
|
|
190
|
+
// Any forwarding trace means the loopback peer is a proxy, not the user.
|
|
191
|
+
if (request.headers.forwarded !== undefined
|
|
192
|
+
|| request.headers['x-forwarded-for'] !== undefined
|
|
193
|
+
|| request.headers['x-real-ip'] !== undefined) return false
|
|
194
|
+
const origin = request.headers.origin
|
|
195
|
+
const host = request.headers.host
|
|
196
|
+
if (origin === undefined || host === undefined) return false
|
|
197
|
+
try {
|
|
198
|
+
const parsed = new URL(origin)
|
|
199
|
+
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === host
|
|
200
|
+
} catch {
|
|
201
|
+
return false
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Whether a download navigation may fetch a sensitive GET export.
|
|
207
|
+
* Browsers do NOT send an Origin header on same-origin GET navigations
|
|
208
|
+
* (`<a href="/..." download>`), so unlike process-control requests a missing
|
|
209
|
+
* Origin is the NORMAL shape of a user-initiated download and must pass.
|
|
210
|
+
* Keep the rest of the posture: loopback peer only, no proxy forwarding
|
|
211
|
+
* headers, and — when an Origin IS present (fetch/CORS attempts) — it must
|
|
212
|
+
* still match Host so a cross-origin page cannot read the export.
|
|
213
|
+
*/
|
|
214
|
+
export function trustedDownloadRequest(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean {
|
|
215
|
+
const address = request.socket.remoteAddress
|
|
216
|
+
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
|
|
217
|
+
if (request.headers.forwarded !== undefined
|
|
218
|
+
|| request.headers['x-forwarded-for'] !== undefined
|
|
219
|
+
|| request.headers['x-real-ip'] !== undefined) return false
|
|
220
|
+
const origin = request.headers.origin
|
|
221
|
+
const host = request.headers.host
|
|
222
|
+
if (host === undefined) return false
|
|
223
|
+
if (origin === undefined) return true // plain browser download navigation
|
|
224
|
+
try {
|
|
225
|
+
const parsed = new URL(origin)
|
|
226
|
+
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === host
|
|
227
|
+
} catch {
|
|
228
|
+
return false
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The exact boot invocation the detached restart helper replays. */
|
|
233
|
+
export function restartLaunch(): { file: string; args: string[]; cwd: string; viaShell: boolean } {
|
|
234
|
+
const launch = dshArgv()
|
|
235
|
+
return {
|
|
236
|
+
...launch,
|
|
237
|
+
args: [...launch.args, ...process.argv.slice(2)],
|
|
238
|
+
cwd: launch.cwd ?? process.cwd(),
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Platform-correct spawn invocation for the replacement host (#40 by
|
|
244
|
+
* @1123762794): on Windows a `detached` spawn maps to DETACHED_PROCESS — the
|
|
245
|
+
* new host gets NO console, and every console child it later spawns (e.g.
|
|
246
|
+
* ChatCode CLI sandbox tool runners) pops a visible node window. Wrapping the launch
|
|
247
|
+
* in `powershell -WindowStyle Hidden` gives the host a HIDDEN console that
|
|
248
|
+
* children inherit instead. POSIX keeps the plain detached spawn.
|
|
249
|
+
*/
|
|
250
|
+
export function respawnInvocation(
|
|
251
|
+
launch: { file: string; args: string[]; viaShell: boolean },
|
|
252
|
+
platform: NodeJS.Platform = process.platform,
|
|
253
|
+
): { file: string; args: string[]; viaShell: boolean; detached: boolean } {
|
|
254
|
+
if (platform !== 'win32') {
|
|
255
|
+
return { file: launch.file, args: launch.args, viaShell: launch.viaShell, detached: true }
|
|
256
|
+
}
|
|
257
|
+
// PowerShell single-quoting: only embedded single quotes need escaping
|
|
258
|
+
// (doubled). Name the cmd shim explicitly: invoking a bare npm bin lets
|
|
259
|
+
// PowerShell prefer its .ps1 shim, which a default Restricted execution policy
|
|
260
|
+
// refuses before the replacement can start (#397). The .cmd shim is produced
|
|
261
|
+
// by the same npm install and is not governed by PowerShell script policy.
|
|
262
|
+
const quote = (part: string): string => `'${part.replace(/'/g, "''")}'`
|
|
263
|
+
const file = launch.viaShell && !/\.(?:cmd|bat)$/iu.test(launch.file)
|
|
264
|
+
? `${launch.file}.cmd`
|
|
265
|
+
: launch.file
|
|
266
|
+
return {
|
|
267
|
+
file: 'powershell.exe',
|
|
268
|
+
args: ['-NoProfile', '-WindowStyle', 'Hidden', '-Command',
|
|
269
|
+
[`& ${quote(file)}`, ...launch.args.map(quote)].join(' ')],
|
|
270
|
+
viaShell: false,
|
|
271
|
+
detached: false,
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** What scheduleRestart reports back to the caller for logging/response. */
|
|
276
|
+
export interface RestartResult {
|
|
277
|
+
pid: number
|
|
278
|
+
helperPid: number | undefined
|
|
279
|
+
logOut: string
|
|
280
|
+
logErr: string
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Source for the detached helper that outlives this process and brings the
|
|
285
|
+
* replacement up.
|
|
286
|
+
*
|
|
287
|
+
* Extracted so the waiting can be tested by RUNNING it, which is the only
|
|
288
|
+
* way this class of bug shows itself: every part of the old helper looked
|
|
289
|
+
* right in isolation.
|
|
290
|
+
*
|
|
291
|
+
* What it fixes (#177, reported on Windows 11, reproducible every time): the
|
|
292
|
+
* helper slept a flat 1500ms and spawned. The old process had exited, but
|
|
293
|
+
* the listening socket had not been released yet, so the replacement died
|
|
294
|
+
* instantly with EADDRINUSE — and the spawn was wrapped in `catch {}`, so
|
|
295
|
+
* nothing was written anywhere. The user saw a restart button that did
|
|
296
|
+
* nothing. The docstring above it even claimed the helper "waits for our
|
|
297
|
+
* port to free up"; it never did.
|
|
298
|
+
*
|
|
299
|
+
* So: wait for the port to actually go quiet, then start, then CHECK that
|
|
300
|
+
* something came up, and write a diagnosis when it did not. A restart that
|
|
301
|
+
* fails must leave evidence — this one is invisible by construction, since
|
|
302
|
+
* the process that would have logged it is the one that just exited.
|
|
303
|
+
* @param port - the port the replacement must bind; when unknown, the helper
|
|
304
|
+
* falls back to the old fixed delay, which is better than nothing.
|
|
305
|
+
*/
|
|
306
|
+
export function restartHelperSource(
|
|
307
|
+
spawned: { file: string; args: string[]; viaShell: boolean; detached: boolean },
|
|
308
|
+
launch: { cwd: string },
|
|
309
|
+
logs: { out: string; err: string },
|
|
310
|
+
port: number | null,
|
|
311
|
+
): string {
|
|
312
|
+
return [
|
|
313
|
+
"const { spawn } = require('node:child_process')",
|
|
314
|
+
"const fs = require('node:fs')",
|
|
315
|
+
"const net = require('node:net')",
|
|
316
|
+
`const file = ${JSON.stringify(spawned.file)}`,
|
|
317
|
+
`const args = ${JSON.stringify(spawned.args)}`,
|
|
318
|
+
`const cwd = ${JSON.stringify(launch.cwd)}`,
|
|
319
|
+
`const viaShell = ${JSON.stringify(spawned.viaShell)}`,
|
|
320
|
+
`const detached = ${JSON.stringify(spawned.detached)}`,
|
|
321
|
+
`const logOut = ${JSON.stringify(logs.out)}`,
|
|
322
|
+
`const logErr = ${JSON.stringify(logs.err)}`,
|
|
323
|
+
`const port = ${JSON.stringify(port)}`,
|
|
324
|
+
'const sleep = (ms) => new Promise(r => setTimeout(r, ms))',
|
|
325
|
+
'const note = (line) => { try { fs.appendFileSync(logErr, `[ChatCode CLI Market] ${line}\n`) } catch {} }',
|
|
326
|
+
// "Free" means nothing accepts a connection. Checked by connecting rather
|
|
327
|
+
// than by binding: binding to test would itself hold the port for the
|
|
328
|
+
// moment the replacement needs it.
|
|
329
|
+
'const listening = () => new Promise((resolve) => {',
|
|
330
|
+
' const probe = net.connect({ host: "127.0.0.1", port })',
|
|
331
|
+
' const done = (value) => { probe.destroy(); resolve(value) }',
|
|
332
|
+
' probe.on("connect", () => done(true))',
|
|
333
|
+
' probe.on("error", () => done(false))',
|
|
334
|
+
' setTimeout(() => done(false), 500)',
|
|
335
|
+
'})',
|
|
336
|
+
'const main = async () => {',
|
|
337
|
+
' if (port) {',
|
|
338
|
+
' const until = Date.now() + 30000',
|
|
339
|
+
' while (Date.now() < until && await listening()) await sleep(250)',
|
|
340
|
+
' if (await listening()) note(`port ${port} was still in use after 30s; starting anyway`)',
|
|
341
|
+
// A released socket can still be in TIME_WAIT for a moment on Windows.
|
|
342
|
+
' await sleep(300)',
|
|
343
|
+
' } else {',
|
|
344
|
+
' await sleep(1500)',
|
|
345
|
+
' }',
|
|
346
|
+
' let child',
|
|
347
|
+
' try {',
|
|
348
|
+
' const out = fs.openSync(logOut, "a")',
|
|
349
|
+
' const err = fs.openSync(logErr, "a")',
|
|
350
|
+
' child = spawn(file, args, { cwd, detached, stdio: ["ignore", out, err], env: process.env, shell: viaShell })',
|
|
351
|
+
// spawn reports a missing or unexecutable file ASYNCHRONOUSLY; the
|
|
352
|
+
// try/catch below only covers the synchronous throw, so without this
|
|
353
|
+
// listener that failure is exactly as silent as the bug being fixed.
|
|
354
|
+
' child.on("error", (error) => note(`could not start the replacement: ${error && error.message ? error.message : error}`))',
|
|
355
|
+
' child.unref()',
|
|
356
|
+
' } catch (error) {',
|
|
357
|
+
' note(`could not start the replacement: ${error && error.message ? error.message : error}`)',
|
|
358
|
+
' return',
|
|
359
|
+
' }',
|
|
360
|
+
// Outliving the spawn matters on Windows: a helper that exits the
|
|
361
|
+
// instant it has spawned can take the replacement with it, because the
|
|
362
|
+
// child is in its process group and has not detached yet. The port path
|
|
363
|
+
// below already lingers while it polls; this is the same guarantee for
|
|
364
|
+
// the path that has no port to poll. CI on windows-latest caught it —
|
|
365
|
+
// locally it passes either way.
|
|
366
|
+
" if (!port) { await sleep(3000); return }",
|
|
367
|
+
' const upBy = Date.now() + 20000',
|
|
368
|
+
' while (Date.now() < upBy && !(await listening())) await sleep(500)',
|
|
369
|
+
' if (!(await listening())) note(`the replacement did not bind port ${port} within 20s — see the output log beside this one`)',
|
|
370
|
+
'}',
|
|
371
|
+
'main()',
|
|
372
|
+
].join('\n')
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Relaunch this exact ChatCode CLI entry after a detached handoff, then stop this
|
|
377
|
+
* process. The helper outlives us (detached + unref), waits for our port to
|
|
378
|
+
* be released before starting the replacement, and logs under tmpdir.
|
|
379
|
+
* @param port - the port this process is serving on, so the helper can wait
|
|
380
|
+
* for it rather than guessing at a delay.
|
|
381
|
+
*/
|
|
382
|
+
export function scheduleRestart(port: number | null = null): RestartResult {
|
|
383
|
+
const launch = restartLaunch()
|
|
384
|
+
const spawned = respawnInvocation(launch)
|
|
385
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
|
386
|
+
const logOut = join(tmpdir(), `chatcode-cli-market-restart-${stamp}.out.log`)
|
|
387
|
+
const logErr = join(tmpdir(), `chatcode-cli-market-restart-${stamp}.err.log`)
|
|
388
|
+
const helper = spawn(nodeExecutable(), ['-e', restartHelperSource(spawned, launch, { out: logOut, err: logErr }, port)], {
|
|
389
|
+
detached: true,
|
|
390
|
+
stdio: 'ignore',
|
|
391
|
+
env: process.env,
|
|
392
|
+
})
|
|
393
|
+
helper.unref()
|
|
394
|
+
setTimeout(() => process.kill(process.pid, 'SIGTERM'), 500)
|
|
395
|
+
return { pid: process.pid, helperPid: helper.pid, logOut, logErr }
|
|
396
|
+
}
|