dsh-zen-remote 2.3.11 → 2.4.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 (249) hide show
  1. package/CHANGELOG.md +45 -78
  2. package/README.md +94 -84
  3. package/README.zh-CN.md +90 -81
  4. package/bin/zen-remote.mjs +5 -0
  5. package/docs/interface.md +6 -6
  6. package/docs/remote-access.en.md +6 -4
  7. package/docs/remote-access.md +6 -4
  8. package/dsh-push.mjs +96 -10
  9. package/lib/adapters/dsh-better-sidebar/index.js +7 -3
  10. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  11. package/lib/admin-routes.js +10 -2
  12. package/lib/admin-routes.js.map +1 -1
  13. package/lib/cli/args.js +251 -0
  14. package/lib/cli/args.js.map +1 -0
  15. package/lib/cli/bootstrap.js +449 -0
  16. package/lib/cli/bootstrap.js.map +1 -0
  17. package/lib/cli/command-setup.js +484 -0
  18. package/lib/cli/command-setup.js.map +1 -0
  19. package/lib/cli/commands-client.js +359 -0
  20. package/lib/cli/commands-client.js.map +1 -0
  21. package/lib/cli/commands-host.js +1108 -0
  22. package/lib/cli/commands-host.js.map +1 -0
  23. package/lib/cli/commands-status.js +279 -0
  24. package/lib/cli/commands-status.js.map +1 -0
  25. package/lib/cli/config-writer.js +334 -0
  26. package/lib/cli/config-writer.js.map +1 -0
  27. package/lib/cli/context.js +10 -0
  28. package/lib/cli/context.js.map +1 -0
  29. package/lib/cli/i18n.js +620 -0
  30. package/lib/cli/i18n.js.map +1 -0
  31. package/lib/cli/ipc-client.js +160 -0
  32. package/lib/cli/ipc-client.js.map +1 -0
  33. package/lib/cli/ipc-server.js +464 -0
  34. package/lib/cli/ipc-server.js.map +1 -0
  35. package/lib/cli/main.js +245 -0
  36. package/lib/cli/main.js.map +1 -0
  37. package/lib/cli/qr-terminal.js +48 -0
  38. package/lib/cli/qr-terminal.js.map +1 -0
  39. package/lib/client-data/qr.js +286 -0
  40. package/lib/client-data/qr.js.map +1 -0
  41. package/lib/client-routes.js +308 -25
  42. package/lib/client-routes.js.map +1 -1
  43. package/lib/client.js +1013 -229
  44. package/lib/config.js +50 -12
  45. package/lib/config.js.map +1 -1
  46. package/lib/debug-report.js +28 -1
  47. package/lib/debug-report.js.map +1 -1
  48. package/lib/fetch-route-intercept.js +334 -26
  49. package/lib/fetch-route-intercept.js.map +1 -1
  50. package/lib/index.js +608 -375
  51. package/lib/index.js.map +1 -1
  52. package/lib/intercept.js +1833 -280
  53. package/lib/intercept.js.map +1 -1
  54. package/lib/lan-gate-server.cjs +36 -10
  55. package/lib/merge-streams.js +103 -11
  56. package/lib/merge-streams.js.map +1 -1
  57. package/lib/plugin-route-intercept.js +93 -63
  58. package/lib/plugin-route-intercept.js.map +1 -1
  59. package/lib/relay-access.js +8 -1
  60. package/lib/relay-access.js.map +1 -1
  61. package/lib/relay-client.js +133 -4
  62. package/lib/relay-client.js.map +1 -1
  63. package/lib/relay-pool.js +275 -0
  64. package/lib/relay-pool.js.map +1 -0
  65. package/lib/relay-server.js +269 -17
  66. package/lib/relay-server.js.map +1 -1
  67. package/lib/restart-fields.js +6 -4
  68. package/lib/restart-fields.js.map +1 -1
  69. package/lib/share-ops.js +68 -0
  70. package/lib/share-ops.js.map +1 -1
  71. package/lib/share-store.js +65 -27
  72. package/lib/share-store.js.map +1 -1
  73. package/lib/state-store.js +400 -0
  74. package/lib/state-store.js.map +1 -0
  75. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
  76. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  77. package/lib/types/admin-routes.d.ts.map +1 -1
  78. package/lib/types/cli/args.d.ts +78 -0
  79. package/lib/types/cli/args.d.ts.map +1 -0
  80. package/lib/types/cli/bootstrap.d.ts +170 -0
  81. package/lib/types/cli/bootstrap.d.ts.map +1 -0
  82. package/lib/types/cli/command-setup.d.ts +72 -0
  83. package/lib/types/cli/command-setup.d.ts.map +1 -0
  84. package/lib/types/cli/commands-client.d.ts +36 -0
  85. package/lib/types/cli/commands-client.d.ts.map +1 -0
  86. package/lib/types/cli/commands-host.d.ts +82 -0
  87. package/lib/types/cli/commands-host.d.ts.map +1 -0
  88. package/lib/types/cli/commands-status.d.ts +46 -0
  89. package/lib/types/cli/commands-status.d.ts.map +1 -0
  90. package/lib/types/cli/config-writer.d.ts +92 -0
  91. package/lib/types/cli/config-writer.d.ts.map +1 -0
  92. package/lib/types/cli/context.d.ts +39 -0
  93. package/lib/types/cli/context.d.ts.map +1 -0
  94. package/lib/types/cli/i18n.d.ts +32 -0
  95. package/lib/types/cli/i18n.d.ts.map +1 -0
  96. package/lib/types/cli/ipc-client.d.ts +83 -0
  97. package/lib/types/cli/ipc-client.d.ts.map +1 -0
  98. package/lib/types/cli/ipc-server.d.ts +113 -0
  99. package/lib/types/cli/ipc-server.d.ts.map +1 -0
  100. package/lib/types/cli/main.d.ts +57 -0
  101. package/lib/types/cli/main.d.ts.map +1 -0
  102. package/lib/types/cli/qr-terminal.d.ts +17 -0
  103. package/lib/types/cli/qr-terminal.d.ts.map +1 -0
  104. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
  105. package/lib/types/client/current-session-report.d.ts +4 -0
  106. package/lib/types/client/current-session-report.d.ts.map +1 -1
  107. package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
  108. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  109. package/lib/types/client/locales.d.ts +47 -3
  110. package/lib/types/client/locales.d.ts.map +1 -1
  111. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  112. package/lib/types/client/settings/settings-css.d.ts +1 -1
  113. package/lib/types/client/settings/settings-css.d.ts.map +1 -1
  114. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  115. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  116. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
  117. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
  118. package/lib/types/client/wizard/wizard-css.d.ts +1 -1
  119. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
  120. package/lib/types/client-data/model-group-side.d.ts +23 -2
  121. package/lib/types/client-data/model-group-side.d.ts.map +1 -1
  122. package/lib/types/client-data/remote-status.d.ts +47 -1
  123. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  124. package/lib/types/client-data/settings-form.d.ts +85 -1
  125. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  126. package/lib/types/client-data/wizard.d.ts +56 -1
  127. package/lib/types/client-data/wizard.d.ts.map +1 -1
  128. package/lib/types/client-routes.d.ts +25 -1
  129. package/lib/types/client-routes.d.ts.map +1 -1
  130. package/lib/types/config.d.ts +40 -14
  131. package/lib/types/config.d.ts.map +1 -1
  132. package/lib/types/debug-report.d.ts.map +1 -1
  133. package/lib/types/fetch-route-intercept.d.ts +66 -8
  134. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  135. package/lib/types/index.d.ts +9 -2
  136. package/lib/types/index.d.ts.map +1 -1
  137. package/lib/types/intercept.d.ts +72 -10
  138. package/lib/types/intercept.d.ts.map +1 -1
  139. package/lib/types/merge-streams.d.ts +79 -1
  140. package/lib/types/merge-streams.d.ts.map +1 -1
  141. package/lib/types/plugin-route-intercept.d.ts +25 -9
  142. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  143. package/lib/types/relay-access.d.ts +3 -1
  144. package/lib/types/relay-access.d.ts.map +1 -1
  145. package/lib/types/relay-client.d.ts +23 -2
  146. package/lib/types/relay-client.d.ts.map +1 -1
  147. package/lib/types/relay-pool.d.ts +148 -0
  148. package/lib/types/relay-pool.d.ts.map +1 -0
  149. package/lib/types/relay-server.d.ts +30 -3
  150. package/lib/types/relay-server.d.ts.map +1 -1
  151. package/lib/types/restart-fields.d.ts +5 -5
  152. package/lib/types/restart-fields.d.ts.map +1 -1
  153. package/lib/types/share-ops.d.ts +47 -1
  154. package/lib/types/share-ops.d.ts.map +1 -1
  155. package/lib/types/share-store.d.ts.map +1 -1
  156. package/lib/types/state-store.d.ts +179 -0
  157. package/lib/types/state-store.d.ts.map +1 -0
  158. package/lib/types/upload-limits.d.ts +15 -0
  159. package/lib/types/upload-limits.d.ts.map +1 -1
  160. package/lib/types/virtual-id.d.ts +21 -0
  161. package/lib/types/virtual-id.d.ts.map +1 -1
  162. package/lib/types/wizard/probe.d.ts.map +1 -1
  163. package/lib/types/wizard/proxy-snippets.d.ts +3 -0
  164. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
  165. package/lib/types/wizard/routes.d.ts +1 -0
  166. package/lib/types/wizard/routes.d.ts.map +1 -1
  167. package/lib/types/wizard/status.d.ts +9 -0
  168. package/lib/types/wizard/status.d.ts.map +1 -1
  169. package/lib/types/wizard/verify.d.ts +3 -0
  170. package/lib/types/wizard/verify.d.ts.map +1 -1
  171. package/lib/upload-limits.js +15 -0
  172. package/lib/upload-limits.js.map +1 -1
  173. package/lib/virtual-id.js +60 -0
  174. package/lib/virtual-id.js.map +1 -1
  175. package/lib/wizard/probe.js +46 -1
  176. package/lib/wizard/probe.js.map +1 -1
  177. package/lib/wizard/proxy-snippets.js +14 -1
  178. package/lib/wizard/proxy-snippets.js.map +1 -1
  179. package/lib/wizard/routes.js +23 -4
  180. package/lib/wizard/routes.js.map +1 -1
  181. package/lib/wizard/status.js +21 -0
  182. package/lib/wizard/status.js.map +1 -1
  183. package/lib/wizard/verify.js +31 -8
  184. package/lib/wizard/verify.js.map +1 -1
  185. package/package.json +8 -4
  186. package/pwa/icons/badge-96.png +0 -0
  187. package/pwa/icons/badge.svg +1 -0
  188. package/pwa/manifest.json +2 -1
  189. package/pwa/sw.js +3 -1
  190. package/scripts/check-debug-report.mjs +41 -1
  191. package/scripts/check-settings-form.mjs +14 -3
  192. package/scripts/check-settings-view.mjs +247 -0
  193. package/scripts/check-wizard-ui.mjs +37 -0
  194. package/src/adapters/dsh-better-sidebar/index.ts +7 -3
  195. package/src/admin-routes.ts +10 -2
  196. package/src/cli/args.ts +289 -0
  197. package/src/cli/bootstrap.ts +569 -0
  198. package/src/cli/command-setup.ts +540 -0
  199. package/src/cli/commands-client.ts +369 -0
  200. package/src/cli/commands-host.ts +1127 -0
  201. package/src/cli/commands-status.ts +296 -0
  202. package/src/cli/config-writer.ts +354 -0
  203. package/src/cli/context.ts +38 -0
  204. package/src/cli/i18n.ts +645 -0
  205. package/src/cli/ipc-client.ts +216 -0
  206. package/src/cli/ipc-server.ts +531 -0
  207. package/src/cli/main.ts +285 -0
  208. package/src/cli/qr-terminal.ts +49 -0
  209. package/src/client/RemoteStatusIcon.tsx +4 -1
  210. package/src/client/current-session-report.ts +28 -4
  211. package/src/client/effects/model-group-side.ts +19 -5
  212. package/src/client/effects/remote-session.ts +26 -6
  213. package/src/client/locales.ts +96 -6
  214. package/src/client/settings/SettingsSection.tsx +287 -92
  215. package/src/client/settings/settings-css.ts +18 -0
  216. package/src/client/styles/remote-session.css.ts +15 -1
  217. package/src/client/wizard/GatewayWizard.tsx +470 -109
  218. package/src/client/wizard/cards/ProxyCard.tsx +2 -1
  219. package/src/client/wizard/wizard-css.ts +23 -0
  220. package/src/client-data/model-group-side.ts +37 -10
  221. package/src/client-data/remote-status.ts +112 -17
  222. package/src/client-data/settings-form.ts +164 -8
  223. package/src/client-data/wizard.ts +112 -2
  224. package/src/client-routes.ts +334 -26
  225. package/src/config.ts +62 -16
  226. package/src/debug-report.ts +27 -1
  227. package/src/fetch-route-intercept.ts +381 -32
  228. package/src/index.ts +608 -380
  229. package/src/intercept.ts +1866 -286
  230. package/src/merge-streams.ts +155 -10
  231. package/src/plugin-route-intercept.ts +120 -71
  232. package/src/relay-access.ts +14 -3
  233. package/src/relay-client.ts +163 -5
  234. package/src/relay-pool.ts +387 -0
  235. package/src/relay-server.ts +312 -16
  236. package/src/restart-fields.ts +6 -4
  237. package/src/share-ops.ts +82 -1
  238. package/src/share-store.ts +59 -26
  239. package/src/state-store.ts +428 -0
  240. package/src/upload-limits.ts +16 -0
  241. package/src/virtual-id.ts +57 -0
  242. package/src/wizard/probe.ts +54 -2
  243. package/src/wizard/proxy-snippets.ts +17 -2
  244. package/src/wizard/routes.ts +24 -5
  245. package/src/wizard/status.ts +28 -0
  246. package/src/wizard/verify.ts +33 -11
  247. package/AGENTS.md +0 -185
  248. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
  249. package/docs/adr/0002-relay-emulates-websocket.md +0 -8
@@ -0,0 +1,531 @@
1
+ /**
2
+ * The CLI's local IPC control server (CLI-1): a small HTTP server over a
3
+ * 0600 Unix domain socket (`<dshHome>/zen-remote-cli.sock`, Windows: a named
4
+ * pipe), owned by the DSH plugin process. Every DSH `webServer` route sits
5
+ * behind signed-cookie auth, and the pieces the CLI must drive (the relay
6
+ * pool, the managed Caddy / cloudflared children, the wizard state) follow
7
+ * the plugin's single-in-process-owner discipline — so the CLI talks to THIS
8
+ * process over THIS socket instead of a second entry point. Same user, same
9
+ * machine: the 0600 socket IS the authentication.
10
+ *
11
+ * Lifecycle: the server is module-level (one per process, per dshHome). A
12
+ * plugin row reload — including the role flips `restart-watcher` triggers —
13
+ * re-acquires it and swaps the request DELEGATE in place; the underlying
14
+ * socket never unbinds, so a CLI session survives every reload. When the
15
+ * last owning row released it and a short grace period passes without a new
16
+ * acquire, the server closes and the socket file is removed (the grace
17
+ * covers the cordis dispose order, where the OLD row's disposer runs after
18
+ * the NEW row's apply — a plain refcount would tear down between them).
19
+ *
20
+ * The wiring side (src/index.ts) folds the acquire/release into the existing
21
+ * `'dsh-zen-remote: restart watcher'` effect on purpose: test/
22
+ * role-wiring.test.cjs pins the exact number of top-level `ctx.effect`
23
+ * registrations per role, so the IPC lifecycle may not add one.
24
+ */
25
+
26
+ import { createServer, get } from 'node:http'
27
+ import type { IncomingMessage, RequestListener, Server, ServerResponse } from 'node:http'
28
+ import { chmodSync, existsSync, unlinkSync } from 'node:fs'
29
+ import { join } from 'node:path'
30
+ import { connectsRole, servesRole } from '../config.js'
31
+ import type { ZenRemoteConfig } from '../config.js'
32
+ import { responseJson } from '../http.js'
33
+ import { ADMIN_ROUTE_PREFIX } from '../admin-routes.js'
34
+ import { CLIENT_ROUTE_PREFIX } from '../client-routes.js'
35
+ import { WIZARD_ROUTE_PREFIX } from '../wizard/routes.js'
36
+
37
+ /** The CLI protocol's major version. A client asking for a different one is
38
+ * talking to a build it cannot drive (SPEC: refuse and tell the user to
39
+ * upgrade the plugin). */
40
+ export const CLI_PROTOCOL_VERSION = 1
41
+
42
+ /** The protocol handshake route. */
43
+ export const CLI_PING_ROUTE = '/_dsh/zen-remote/cli/ping'
44
+
45
+ /** The config-write route (POST `{set, unset}` → the anti-shadowing writer). */
46
+ export const CLI_CONFIG_ROUTE = '/_dsh/zen-remote/cli/config'
47
+
48
+ /** Socket file / pipe basename: `zen-remote-cli.sock`, `-2.sock`, … */
49
+ export const CLI_SOCKET_BASENAME = 'zen-remote-cli'
50
+
51
+ /** How long a fully-released IPC server waits for a re-acquire (the hot
52
+ * reload window: cordis runs the OLD row's disposer AFTER the new apply, so
53
+ * refcount 0 is momentary during every reload). Exported for tests. */
54
+ export const CLI_CLOSE_GRACE_MS = 250
55
+
56
+ /** Inbound config body cap — the CLI writes a few fields at a time. */
57
+ const MAX_BODY_BYTES = 16 * 1024
58
+
59
+ /** Socket paths the process still holds, for the exit-time cleanup. */
60
+ const boundSocketFiles = new Set<string>()
61
+ let exitHookInstalled = false
62
+
63
+ function ensureExitHook(): void {
64
+ if (exitHookInstalled) return
65
+ exitHookInstalled = true
66
+ process.on('exit', () => {
67
+ for (const path of boundSocketFiles) {
68
+ try {
69
+ unlinkSync(path)
70
+ } catch { /* already gone */ }
71
+ }
72
+ })
73
+ }
74
+
75
+ /**
76
+ * The bind candidates, in backoff order: `<dshHome>/zen-remote-cli.sock`,
77
+ * then `-2.sock` … `-8.sock` (two DSH instances on one machine — a web
78
+ * profile and a headless one — each get their own channel). Windows has no
79
+ * filesystem sockets: named pipes under the package's pipe name
80
+ * `dsh-zen-remote-cli` with the same numbering.
81
+ */
82
+ export function candidateSocketPaths(dshHome: string, platform: NodeJS.Platform = process.platform): string[] {
83
+ const out: string[] = []
84
+ for (let n = 1; n <= 8; n += 1) {
85
+ if (platform === 'win32') {
86
+ out.push(n === 1 ? `\\\\.\\pipe\\dsh-${CLI_SOCKET_BASENAME}` : `\\\\.\\pipe\\dsh-${CLI_SOCKET_BASENAME}-${n}`)
87
+ } else {
88
+ out.push(join(dshHome, n === 1 ? `${CLI_SOCKET_BASENAME}.sock` : `${CLI_SOCKET_BASENAME}-${n}.sock`))
89
+ }
90
+ }
91
+ return out
92
+ }
93
+
94
+ /** One role-gated handler set the IPC server forwards to — the row-scoped
95
+ * closures src/index.ts builds per apply (per role branch). */
96
+ export interface CliIpcDelegate {
97
+ /** The CURRENT effective role, re-read per request (a volatile row field). */
98
+ role(): 'host' | 'client' | 'peer'
99
+ /** The row's display name (this device's name on a client). */
100
+ serverName(): string
101
+ /** The profile context's name, when the composition has one. */
102
+ profileName(): string | undefined
103
+ /** This package's version, for the handshake header and ping body. */
104
+ pluginVersion(): string
105
+ /** The anti-shadowing config write (src/cli/config-writer.ts, wired with
106
+ * the row object and the restart watcher's checkNow). */
107
+ writeConfig(input: { set?: Record<string, unknown>; unset?: readonly string[] }):
108
+ | { ok: true; values: ZenRemoteConfig }
109
+ | { ok: false; code: 'env-shadowed'; field: string; envVar: string }
110
+ /** Host-role handler sets; absent means the composition never built them
111
+ * (the wrong-role gate answers before any consumer reaches here). */
112
+ wizardHandler?: CliRouteHandler | undefined
113
+ adminHandler?: CliRouteHandler | undefined
114
+ /** Both roles mount a client handler (remote-status / debug-report are
115
+ * role-agnostic); the wrong-role gate covers the pairing routes. */
116
+ clientHandler?: CliRouteHandler | undefined
117
+ }
118
+
119
+ export type CliRouteHandler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
120
+
121
+ export interface CliIpcServerOptions {
122
+ dshHome: string
123
+ delegate: CliIpcDelegate
124
+ /** Bind-shape override (tests pin both platforms' candidate lists). */
125
+ platform?: NodeJS.Platform
126
+ }
127
+
128
+ export interface CliIpcServer {
129
+ /** The candidate path this instance actually bound. */
130
+ readonly socketPath: string
131
+ /** Hot reload: replace the delegate without touching the socket. */
132
+ swapDelegate(delegate: CliIpcDelegate): void
133
+ /** Close the server, clean the socket file, and answer once drained. */
134
+ close(): Promise<void>
135
+ }
136
+
137
+ /** Is something ANSWERING on this socket path? A stale file (a dead
138
+ * instance's leftover, or a plain file that merely squats the name) answers
139
+ * nothing and may be cleaned; a live DSH instance answers its ping and must
140
+ * be left alone. */
141
+ function probeAlive(socketPath: string): Promise<boolean> {
142
+ return new Promise((resolve) => {
143
+ let settled = false
144
+ const done = (value: boolean): void => {
145
+ if (settled) return
146
+ settled = true
147
+ resolve(value)
148
+ }
149
+ const req = get({ socketPath, path: CLI_PING_ROUTE, timeout: 150 }, (res) => {
150
+ res.resume()
151
+ done(true)
152
+ })
153
+ req.on('timeout', () => {
154
+ req.destroy()
155
+ done(false)
156
+ })
157
+ req.on('error', () => done(false))
158
+ })
159
+ }
160
+
161
+ /** Read the whole body under the cap and demand one JSON object. Same
162
+ * contract as every route module's reader: empty body → `{}`, malformed or
163
+ * oversized → null. */
164
+ async function readJsonObject(req: IncomingMessage): Promise<Record<string, unknown> | null> {
165
+ const declared = req.headers['content-length']
166
+ const expected = declared === undefined ? undefined : Number(declared)
167
+ if (expected !== undefined && Number.isSafeInteger(expected) && expected > MAX_BODY_BYTES) return null
168
+ const chunks: Buffer[] = []
169
+ let size = 0
170
+ for await (const chunk of req) {
171
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array)
172
+ size += bytes.length
173
+ if (size > MAX_BODY_BYTES) return null
174
+ chunks.push(bytes)
175
+ }
176
+ const text = Buffer.concat(chunks).toString('utf8')
177
+ if (text.trim() === '') return {}
178
+ try {
179
+ const parsed: unknown = JSON.parse(text)
180
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
181
+ return parsed as Record<string, unknown>
182
+ } catch {
183
+ return null
184
+ }
185
+ }
186
+
187
+ function plainObject(value: unknown): Record<string, unknown> | undefined {
188
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
189
+ ? (value as Record<string, unknown>)
190
+ : undefined
191
+ }
192
+
193
+ function isErrnoCode(error: unknown, code: string): boolean {
194
+ return error instanceof Error && 'code' in error && (error as { code?: unknown }).code === code
195
+ }
196
+
197
+ /**
198
+ * Bind one CLI IPC server: walk the candidate paths, probing occupied files
199
+ * for a live owner (skip those), cleaning stale ones, and answering with the
200
+ * bound handle. Throws when every candidate is taken by a live instance.
201
+ */
202
+ export async function createCliIpcServer(options: CliIpcServerOptions): Promise<CliIpcServer> {
203
+ const platform = options.platform ?? process.platform
204
+ const isWin = platform === 'win32'
205
+ let delegate = options.delegate
206
+ let current: Server | undefined
207
+ let socketPath = ''
208
+
209
+ const routeListener: RequestListener = (req, res) => {
210
+ void handleCliRequest(req, res, () => delegate)
211
+ }
212
+
213
+ for (const candidate of candidateSocketPaths(options.dshHome, platform)) {
214
+ if (!isWin && existsSync(candidate)) {
215
+ if (await probeAlive(candidate)) continue
216
+ try {
217
+ unlinkSync(candidate)
218
+ } catch {
219
+ continue // unwritable squatter — try the next name
220
+ }
221
+ }
222
+ const server = createServer(routeListener)
223
+ try {
224
+ await new Promise<void>((resolve, reject) => {
225
+ server.once('error', reject)
226
+ server.listen(candidate, () => resolve())
227
+ })
228
+ current = server
229
+ socketPath = candidate
230
+ break
231
+ } catch (error) {
232
+ // EADDRINUSE (another process won the race) or any other bind failure
233
+ // — the remaining candidates are the fallback, never a crash.
234
+ server.close()
235
+ if (!isErrnoCode(error, 'EADDRINUSE')) void error
236
+ continue
237
+ }
238
+ }
239
+ if (current === undefined) {
240
+ throw new Error(`every ${CLI_SOCKET_BASENAME} candidate is occupied by a live instance (${options.dshHome})`)
241
+ }
242
+ if (!isWin) {
243
+ try {
244
+ chmodSync(socketPath, 0o600)
245
+ } catch { /* exotic filesystems — the 0700 home is the outer wall */ }
246
+ } else {
247
+ // Named pipes carry no unlinkable file; nothing to register for exit.
248
+ }
249
+ if (!isWin) {
250
+ boundSocketFiles.add(socketPath)
251
+ ensureExitHook()
252
+ }
253
+ // The listen-phase `once('error')` is spent; later server-level errors
254
+ // (accept failures on a dying socket) must not crash the host process.
255
+ current.on('error', () => {})
256
+ // The listening handle must not hold the event loop open (the same
257
+ // discipline as the restart watcher's poll and the fetch-route fallback
258
+ // timer): the DSH host process stays alive on its own services anyway,
259
+ // and a composition whose everything-else has shut down should still be
260
+ // able to exit. The exit hook above still cleans the socket file.
261
+ if (typeof current.unref === 'function') current.unref()
262
+
263
+ let closed = false
264
+ return {
265
+ socketPath,
266
+ swapDelegate(next: CliIpcDelegate): void {
267
+ delegate = next
268
+ },
269
+ close(): Promise<void> {
270
+ if (closed) return Promise.resolve()
271
+ closed = true
272
+ boundSocketFiles.delete(socketPath)
273
+ return new Promise((resolve) => {
274
+ const finish = (): void => {
275
+ if (!isWin) {
276
+ try {
277
+ unlinkSync(socketPath)
278
+ } catch { /* the server's own close unlink usually got here first */ }
279
+ }
280
+ resolve()
281
+ }
282
+ current!.close(() => finish())
283
+ if (typeof current!.closeAllConnections === 'function') current!.closeAllConnections()
284
+ })
285
+ },
286
+ }
287
+ }
288
+
289
+ /** The per-request dispatch: version headers on EVERY answer first, then the
290
+ * two CLI routes, then the role-gated forwards into the row's handlers. */
291
+ async function handleCliRequest(
292
+ req: IncomingMessage,
293
+ res: ServerResponse,
294
+ getDelegate: () => CliIpcDelegate,
295
+ ): Promise<void> {
296
+ try {
297
+ const delegate = getDelegate()
298
+ res.setHeader('x-zr-cli-version', String(CLI_PROTOCOL_VERSION))
299
+ res.setHeader('x-zr-plugin-version', delegate.pluginVersion())
300
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
301
+ const route = url.pathname
302
+ const method = req.method ?? 'GET'
303
+
304
+ if (route === CLI_PING_ROUTE) {
305
+ if (method !== 'GET') {
306
+ res.setHeader('Allow', 'GET')
307
+ responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET' } })
308
+ return
309
+ }
310
+ const role = delegate.role()
311
+ const profileName = delegate.profileName()
312
+ responseJson(res, 200, {
313
+ ok: true,
314
+ cliProtocolVersion: CLI_PROTOCOL_VERSION,
315
+ pluginVersion: delegate.pluginVersion(),
316
+ pid: process.pid,
317
+ role,
318
+ serves: servesRole(role),
319
+ connects: connectsRole(role),
320
+ serverName: delegate.serverName(),
321
+ ...(profileName === undefined ? {} : { profileName }),
322
+ })
323
+ return
324
+ }
325
+
326
+ if (route === CLI_CONFIG_ROUTE) {
327
+ if (method !== 'POST') {
328
+ res.setHeader('Allow', 'POST')
329
+ responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use POST' } })
330
+ return
331
+ }
332
+ const body = await readJsonObject(req)
333
+ if (body === null) {
334
+ responseJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'The request body must be one JSON object' } })
335
+ return
336
+ }
337
+ const set = body.set === undefined ? undefined : plainObject(body.set)
338
+ const rawUnset = body.unset === undefined ? undefined : body.unset
339
+ const unset = Array.isArray(rawUnset) && rawUnset.every((item) => typeof item === 'string')
340
+ ? (rawUnset as string[])
341
+ : undefined
342
+ if ((body.set !== undefined && set === undefined) || (body.unset !== undefined && unset === undefined)) {
343
+ responseJson(res, 400, { ok: false, error: { code: 'bad-request', message: '`set` must be an object and `unset` a string array' } })
344
+ return
345
+ }
346
+ let result: { ok: true; values: ZenRemoteConfig } | { ok: false; code: 'env-shadowed'; field: string; envVar: string }
347
+ try {
348
+ result = delegate.writeConfig({
349
+ ...(set === undefined ? {} : { set }),
350
+ ...(unset === undefined ? {} : { unset }),
351
+ })
352
+ } catch (error) {
353
+ responseJson(res, 500, { ok: false, error: { code: 'internal', message: error instanceof Error ? error.message : String(error) } })
354
+ return
355
+ }
356
+ if (!result.ok) {
357
+ responseJson(res, 409, { ok: false, error: { code: result.code, field: result.field, envVar: result.envVar } })
358
+ return
359
+ }
360
+ responseJson(res, 200, { ok: true, values: result.values })
361
+ return
362
+ }
363
+
364
+ if (route === WIZARD_ROUTE_PREFIX || route.startsWith(`${WIZARD_ROUTE_PREFIX}/`)) {
365
+ await forwardIfRole(req, res, delegate, delegate.wizardHandler)
366
+ return
367
+ }
368
+ if (route === ADMIN_ROUTE_PREFIX || route.startsWith(`${ADMIN_ROUTE_PREFIX}/`)) {
369
+ await forwardIfRole(req, res, delegate, delegate.adminHandler)
370
+ return
371
+ }
372
+ if (route === CLIENT_ROUTE_PREFIX || route.startsWith(`${CLIENT_ROUTE_PREFIX}/`)) {
373
+ // The pairing surfaces belong to a dialing role; remote-status and the
374
+ // debug report are every role's own (the host answers its empty
375
+ // conclusion). SPEC pins exactly this split.
376
+ const sub = route.slice(CLIENT_ROUTE_PREFIX.length) || '/'
377
+ const pairingOnly = new Set(['/status', '/claim', '/unpair', '/reconnect'])
378
+ const required: 'client' | undefined = pairingOnly.has(sub) ? 'client' : undefined
379
+ if (required !== undefined && !connectsRole(delegate.role())) {
380
+ wrongRole(res, delegate.role(), required)
381
+ return
382
+ }
383
+ if (delegate.clientHandler === undefined) {
384
+ responseJson(res, 503, { ok: false, error: { code: 'unavailable', message: 'no client handler in this row generation' } })
385
+ return
386
+ }
387
+ await delegate.clientHandler(req, res)
388
+ return
389
+ }
390
+
391
+ responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'Unknown CLI route' } })
392
+ } catch (error) {
393
+ // A malformed req.url (`new URL` throwing) or a broken delegate must
394
+ // never escape as an unhandled rejection — the handler owns the whole
395
+ // response lifecycle.
396
+ if (!res.headersSent) {
397
+ responseJson(res, 500, { ok: false, error: { code: 'internal', message: error instanceof Error ? error.message : String(error) } })
398
+ } else {
399
+ res.destroy()
400
+ }
401
+ }
402
+ }
403
+
404
+ /** The host-side role gate: answer 409 wrong-role (or 503 when the row never
405
+ * built the handler) and stop there, or forward into the row's handler. */
406
+ async function forwardIfRole(
407
+ req: IncomingMessage,
408
+ res: ServerResponse,
409
+ delegate: CliIpcDelegate,
410
+ handler: CliRouteHandler | undefined,
411
+ ): Promise<void> {
412
+ if (!servesRole(delegate.role())) {
413
+ wrongRole(res, delegate.role(), 'host')
414
+ return
415
+ }
416
+ if (handler === undefined) {
417
+ responseJson(res, 503, { ok: false, error: { code: 'unavailable', message: 'no host handler in this row generation' } })
418
+ return
419
+ }
420
+ await handler(req, res)
421
+ }
422
+
423
+ function wrongRole(res: ServerResponse, role: 'host' | 'client' | 'peer', required: 'host' | 'client'): void {
424
+ responseJson(res, 409, { ok: false, error: { code: 'wrong-role', role, required } })
425
+ }
426
+
427
+ // --- module-level reuse coordinator (hot reload) -----------------------------
428
+ //
429
+ // One server per process per dshHome. apply() acquires with ITS delegate;
430
+ // the next apply swaps; the refcount only reaches zero across a reload
431
+ // momentarily, and the grace period eats that moment instead of tearing the
432
+ // socket down between the two rows.
433
+
434
+ interface AcquireEntry {
435
+ starting: Promise<CliIpcServer>
436
+ server: CliIpcServer | undefined
437
+ pendingDelegate: CliIpcDelegate | undefined
438
+ refCount: number
439
+ closed: boolean
440
+ graceTimer: ReturnType<typeof setTimeout> | undefined
441
+ }
442
+
443
+ const acquisitions = new Map<string, AcquireEntry>()
444
+
445
+ /**
446
+ * A stand-in delegate for the bind window: between `listen` succeeding and
447
+ * the acquiring row's swap landing (one microtask later), a request would
448
+ * otherwise meet no delegate at all. It answers EVERYTHING through the
449
+ * entry's pending delegate — which `acquireCliIpcServer` sets synchronously
450
+ * — so the stub is only visible when literally no row ever acquired, and
451
+ * its write refuses rather than pretending.
452
+ */
453
+ function bootstrapDelegateFor(getEntry: () => AcquireEntry | undefined): CliIpcDelegate {
454
+ return {
455
+ role: () => getEntry()?.pendingDelegate?.role() ?? 'host',
456
+ serverName: () => getEntry()?.pendingDelegate?.serverName() ?? '',
457
+ profileName: () => getEntry()?.pendingDelegate?.profileName(),
458
+ pluginVersion: () => getEntry()?.pendingDelegate?.pluginVersion() ?? 'unknown',
459
+ writeConfig: (input) => {
460
+ const current = getEntry()?.pendingDelegate
461
+ if (current === undefined) throw new Error('no plugin row has acquired the CLI IPC server yet')
462
+ return current.writeConfig(input)
463
+ },
464
+ wizardHandler: undefined,
465
+ adminHandler: undefined,
466
+ clientHandler: undefined,
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Acquire (or hot-swap into) this process's IPC server for `dshHome`.
472
+ * Synchronous on purpose: apply() runs inside cordis and cannot await. The
473
+ * returned handle's `release()` is the row disposer; a release that hits
474
+ * refcount 0 closes the server after {@link CLI_CLOSE_GRACE_MS} unless a new
475
+ * row acquires first.
476
+ */
477
+ export function acquireCliIpcServer(dshHome: string, delegate: CliIpcDelegate): { release(): void } {
478
+ let entry = acquisitions.get(dshHome)
479
+ if (entry === undefined) {
480
+ // The entry the bind closure reads through `getEntry` — assigned right
481
+ // after the (synchronous) object literal completes, long before any
482
+ // request callback can run.
483
+ let creating: AcquireEntry | undefined
484
+ const created: AcquireEntry = {
485
+ starting: createCliIpcServer({ dshHome, delegate: bootstrapDelegateFor(() => creating) }),
486
+ server: undefined,
487
+ pendingDelegate: undefined,
488
+ refCount: 0,
489
+ closed: false,
490
+ graceTimer: undefined,
491
+ }
492
+ creating = created
493
+ entry = created
494
+ acquisitions.set(dshHome, created)
495
+ void created.starting
496
+ .then((server) => {
497
+ created.server = server
498
+ if (created.closed) {
499
+ void server.close()
500
+ return
501
+ }
502
+ if (created.pendingDelegate !== undefined) server.swapDelegate(created.pendingDelegate)
503
+ })
504
+ .catch(() => {
505
+ // Every candidate was taken by live instances — drop the slot so a
506
+ // later acquire can retry from scratch.
507
+ if (acquisitions.get(dshHome) === created) acquisitions.delete(dshHome)
508
+ })
509
+ }
510
+ if (entry.graceTimer !== undefined) {
511
+ clearTimeout(entry.graceTimer)
512
+ entry.graceTimer = undefined
513
+ }
514
+ entry.refCount += 1
515
+ entry.pendingDelegate = delegate
516
+ if (entry.server !== undefined && !entry.closed) entry.server.swapDelegate(delegate)
517
+ return {
518
+ release() {
519
+ if (entry!.refCount > 0) entry!.refCount -= 1
520
+ if (entry!.refCount > 0 || entry!.closed || entry!.graceTimer !== undefined) return
521
+ entry!.graceTimer = setTimeout(() => {
522
+ entry!.graceTimer = undefined
523
+ if (entry!.refCount > 0 || entry!.closed) return
524
+ acquisitions.delete(dshHome)
525
+ entry!.closed = true
526
+ void entry!.starting.then((server) => server.close()).catch(() => {})
527
+ }, CLI_CLOSE_GRACE_MS)
528
+ if (typeof entry!.graceTimer.unref === 'function') entry!.graceTimer.unref()
529
+ },
530
+ }
531
+ }