@chatcode/cco-market 1.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/README.zh.md +143 -0
  4. package/UPDATE-API-V1.md +132 -0
  5. package/client/client.js +11534 -0
  6. package/cordis.patch.yml +5 -0
  7. package/lib/accelerate.js +184 -0
  8. package/lib/agents.js +36 -0
  9. package/lib/backup.js +572 -0
  10. package/lib/catalog-local-match.js +126 -0
  11. package/lib/catalog-npm.js +111 -0
  12. package/lib/changelog.js +242 -0
  13. package/lib/channels.js +64 -0
  14. package/lib/check.js +1067 -0
  15. package/lib/compatibility.js +198 -0
  16. package/lib/diagnostics.js +49 -0
  17. package/lib/discovery-compatibility.js +260 -0
  18. package/lib/dsh-cli.js +949 -0
  19. package/lib/dsh-install.js +108 -0
  20. package/lib/gist.js +352 -0
  21. package/lib/groups.js +97 -0
  22. package/lib/home-paths.js +42 -0
  23. package/lib/hot.js +519 -0
  24. package/lib/http.js +39 -0
  25. package/lib/index.js +97 -0
  26. package/lib/install.js +387 -0
  27. package/lib/log.js +197 -0
  28. package/lib/ndjson.js +154 -0
  29. package/lib/net.js +98 -0
  30. package/lib/order.js +284 -0
  31. package/lib/patch.js +521 -0
  32. package/lib/pnpm-compat.js +490 -0
  33. package/lib/presets.js +278 -0
  34. package/lib/profile.js +911 -0
  35. package/lib/region-probe.js +93 -0
  36. package/lib/regions.js +253 -0
  37. package/lib/registry.js +204 -0
  38. package/lib/restart.js +373 -0
  39. package/lib/routes.js +4372 -0
  40. package/lib/settings.js +109 -0
  41. package/lib/skill-market/api.js +119 -0
  42. package/lib/skill-market/config.js +29 -0
  43. package/lib/skill-market/installer.js +352 -0
  44. package/lib/skill-market/runtime.js +13 -0
  45. package/lib/skill-market/tui.js +119 -0
  46. package/lib/skill-market/types.js +1 -0
  47. package/lib/skill-market/web-routes.js +123 -0
  48. package/lib/snapshot.js +500 -0
  49. package/lib/source-migration.js +54 -0
  50. package/lib/sources.js +596 -0
  51. package/lib/store.js +91 -0
  52. package/lib/themes.js +102 -0
  53. package/lib/trial.js +116 -0
  54. package/lib/types/accelerate.d.ts +85 -0
  55. package/lib/types/agents.d.ts +22 -0
  56. package/lib/types/backup.d.ts +131 -0
  57. package/lib/types/catalog-local-match.d.ts +50 -0
  58. package/lib/types/catalog-npm.d.ts +47 -0
  59. package/lib/types/changelog.d.ts +102 -0
  60. package/lib/types/channels.d.ts +56 -0
  61. package/lib/types/check.d.ts +266 -0
  62. package/lib/types/compatibility.d.ts +64 -0
  63. package/lib/types/diagnostics.d.ts +29 -0
  64. package/lib/types/discovery-compatibility.d.ts +80 -0
  65. package/lib/types/dsh-cli.d.ts +324 -0
  66. package/lib/types/dsh-install.d.ts +46 -0
  67. package/lib/types/gist.d.ts +53 -0
  68. package/lib/types/groups.d.ts +34 -0
  69. package/lib/types/home-paths.d.ts +16 -0
  70. package/lib/types/hot.d.ts +221 -0
  71. package/lib/types/http.d.ts +12 -0
  72. package/lib/types/index.d.ts +14 -0
  73. package/lib/types/install.d.ts +172 -0
  74. package/lib/types/log.d.ts +43 -0
  75. package/lib/types/ndjson.d.ts +52 -0
  76. package/lib/types/net.d.ts +58 -0
  77. package/lib/types/order.d.ts +100 -0
  78. package/lib/types/patch.d.ts +129 -0
  79. package/lib/types/pnpm-compat.d.ts +93 -0
  80. package/lib/types/presets.d.ts +90 -0
  81. package/lib/types/profile.d.ts +253 -0
  82. package/lib/types/region-probe.d.ts +50 -0
  83. package/lib/types/regions.d.ts +122 -0
  84. package/lib/types/registry.d.ts +86 -0
  85. package/lib/types/restart.d.ts +194 -0
  86. package/lib/types/routes.d.ts +67 -0
  87. package/lib/types/settings.d.ts +78 -0
  88. package/lib/types/skill-market/api.d.ts +10 -0
  89. package/lib/types/skill-market/config.d.ts +3 -0
  90. package/lib/types/skill-market/installer.d.ts +23 -0
  91. package/lib/types/skill-market/runtime.d.ts +10 -0
  92. package/lib/types/skill-market/tui.d.ts +4 -0
  93. package/lib/types/skill-market/types.d.ts +71 -0
  94. package/lib/types/skill-market/web-routes.d.ts +19 -0
  95. package/lib/types/snapshot.d.ts +93 -0
  96. package/lib/types/source-migration.d.ts +11 -0
  97. package/lib/types/sources.d.ts +216 -0
  98. package/lib/types/store.d.ts +30 -0
  99. package/lib/types/themes.d.ts +40 -0
  100. package/lib/types/trial.d.ts +61 -0
  101. package/lib/types/update-api-v1.d.ts +66 -0
  102. package/lib/types/updates.d.ts +123 -0
  103. package/lib/types/verify.d.ts +139 -0
  104. package/lib/update-api-v1.js +215 -0
  105. package/lib/updates.js +361 -0
  106. package/lib/verify.js +453 -0
  107. package/package.json +121 -0
  108. package/src/accelerate.ts +213 -0
  109. package/src/agents.ts +43 -0
  110. package/src/backup.ts +583 -0
  111. package/src/catalog-local-match.ts +144 -0
  112. package/src/catalog-npm.ts +120 -0
  113. package/src/changelog.ts +282 -0
  114. package/src/channels.ts +70 -0
  115. package/src/check.ts +1239 -0
  116. package/src/client/CommentsModal.tsx +119 -0
  117. package/src/client/Diagnostics.tsx +907 -0
  118. package/src/client/ErrorBoundary.tsx +111 -0
  119. package/src/client/InstallToast.tsx +31 -0
  120. package/src/client/Market.module.css +830 -0
  121. package/src/client/MarketSection.tsx +5301 -0
  122. package/src/client/OperationsPanel.tsx +365 -0
  123. package/src/client/SettingsCard.tsx +621 -0
  124. package/src/client/SkillMarket.module.css +21 -0
  125. package/src/client/SkillMarketSection.tsx +162 -0
  126. package/src/client/comments.ts +54 -0
  127. package/src/client/globals.d.ts +13 -0
  128. package/src/client/index.ts +180 -0
  129. package/src/client/locales.ts +1112 -0
  130. package/src/client/market-data.ts +1321 -0
  131. package/src/client/operations.ts +201 -0
  132. package/src/client/preset-panel.tsx +263 -0
  133. package/src/client/primitives.d.ts +140 -0
  134. package/src/client/self-check.ts +147 -0
  135. package/src/client/snapshot-panel.tsx +244 -0
  136. package/src/compatibility.ts +237 -0
  137. package/src/diagnostics.ts +84 -0
  138. package/src/discovery-compatibility.ts +315 -0
  139. package/src/dsh-cli.ts +1126 -0
  140. package/src/dsh-install.ts +118 -0
  141. package/src/gist.ts +362 -0
  142. package/src/groups.ts +111 -0
  143. package/src/home-paths.ts +53 -0
  144. package/src/hot.ts +628 -0
  145. package/src/http.ts +41 -0
  146. package/src/index.ts +128 -0
  147. package/src/install.ts +420 -0
  148. package/src/log.ts +206 -0
  149. package/src/ndjson.ts +185 -0
  150. package/src/net.ts +106 -0
  151. package/src/order.ts +303 -0
  152. package/src/patch.ts +522 -0
  153. package/src/pnpm-compat.ts +527 -0
  154. package/src/presets.ts +344 -0
  155. package/src/profile.ts +940 -0
  156. package/src/region-probe.ts +97 -0
  157. package/src/regions.ts +310 -0
  158. package/src/registry.ts +250 -0
  159. package/src/restart.ts +396 -0
  160. package/src/routes.ts +4506 -0
  161. package/src/settings.ts +141 -0
  162. package/src/skill-market/api.ts +130 -0
  163. package/src/skill-market/config.ts +32 -0
  164. package/src/skill-market/installer.ts +337 -0
  165. package/src/skill-market/runtime.ts +14 -0
  166. package/src/skill-market/tui.ts +157 -0
  167. package/src/skill-market/types.ts +80 -0
  168. package/src/skill-market/web-routes.ts +132 -0
  169. package/src/snapshot.ts +532 -0
  170. package/src/source-migration.ts +61 -0
  171. package/src/sources.ts +565 -0
  172. package/src/store.ts +89 -0
  173. package/src/themes.ts +125 -0
  174. package/src/trial.ts +156 -0
  175. package/src/update-api-v1.ts +277 -0
  176. package/src/updates.ts +400 -0
  177. package/src/verify.ts +492 -0
package/src/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
+ }