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,540 @@
1
+ /**
2
+ * CLI-5: the `setup` end-to-end wizard — the web three-step wizard
3
+ * (`GatewayWizard.tsx`) rendered onto a terminal, in one interactive /
4
+ * parametric state machine: command-line-provided answers are taken as-is,
5
+ * missing ones are asked in a TTY run and raise MissingRequiredOptionError
6
+ * in a `--yes` / headless run.
7
+ *
8
+ * Preflight (SPEC §4) runs FIRST and always: discover the plugin's IPC
9
+ * socket — when nothing live answers (or `--profile` was given explicitly)
10
+ * the multi-select profile install runs, then `ensureDshRunning` brings a
11
+ * DSH up (daemon trigger + bounded poll). Everything after that goes through
12
+ * THAT live IPC client.
13
+ *
14
+ * Step 1 is the role. Switching mirrors GatewayWizard.tsx's discipline
15
+ * exactly: host → client stops the managed Caddy / Cloudflare tunnel through
16
+ * their IPC stop routes BEFORE writing `role: 'client'`, then polls ping
17
+ * until `connects`; client → host writes `role: 'host'` and polls until
18
+ * `serves` (the row reloads under the CLI — the hot-swap socket never
19
+ * drops).
20
+ *
21
+ * Steps 2 & 3 are NOT reimplementations: the host branch drives
22
+ * `runHostIngressStartCommand` / `runHostIngressVerifyCommand` (mode
23
+ * switch cleanup, snippets, three lights, QR) and the client branch drives
24
+ * `runClientPairCommand` / `runClientUnpairCommand` (claim, handshake lights,
25
+ * removal) — the same code `host *` / `client *` use, over a synthesized
26
+ * argv. `setup`'s own additions are only the flow around them: the
27
+ * keep-current-config choice, the two-question recommendation funnel, the
28
+ * retry menu after a failed verification, the desktop-client pairing
29
+ * follow-up, and the paired-host management menu.
30
+ */
31
+
32
+ import { parseCliArgs } from './args.js'
33
+ import type { CliOptions, ParsedCliArgs } from './args.js'
34
+ import { ensureDshRunning, ensurePluginInstalled, MissingRequiredOptionError } from './bootstrap.js'
35
+ import type { CliPrompter, ExecProbe } from './bootstrap.js'
36
+ import { discoverCliSockets } from './ipc-client.js'
37
+ import { CLI_PROTOCOL_VERSION } from './ipc-server.js'
38
+ import type { CliExecutionContext } from './context.js'
39
+ import type { CliIpcClient, CliPingResponse } from './ipc-client.js'
40
+ import type { CliTranslator } from './i18n.js'
41
+ import { fetchBody, plainOf, serverLineText } from './commands-status.js'
42
+ import type { ServerRow } from './commands-status.js'
43
+ import {
44
+ runHostDevicePairCommand,
45
+ runHostIngressStartCommand,
46
+ runHostIngressVerifyCommand,
47
+ writeConfigOrReport,
48
+ } from './commands-host.js'
49
+ import { runClientPairCommand, runClientUnpairCommand } from './commands-client.js'
50
+ import { WIZARD_STATUS_ROUTE, WIZARD_CADDY_STOP_ROUTE, WIZARD_TUNNEL_STOP_ROUTE } from '../wizard/routes.js'
51
+ import { CLIENT_STATUS_ROUTE } from '../client-routes.js'
52
+
53
+ /** Everything `runSetupCommand` needs beyond a plain command context: the
54
+ * preflight owns discovery and launch, so it takes the same injectable
55
+ * seams `main.ts` collected (SPEC's test seam three). */
56
+ export interface SetupCommandContext {
57
+ args: ParsedCliArgs
58
+ dshHome: string
59
+ /** TTY && !`--yes`, decided once by the entry point. */
60
+ interactive: boolean
61
+ t: CliTranslator
62
+ stdout: { write(text: string): void }
63
+ /** Present whenever the run may prompt. */
64
+ prompter?: CliPrompter
65
+ /** Poll-interval seam (role-switch wait, shared with the driven commands). */
66
+ pollIntervalMs?: number
67
+ /** Ctrl-C seam — forwarded into the verify wait. */
68
+ abortSignal?: AbortSignal
69
+ now?: () => Date
70
+ cwd?: string
71
+ /** Discover seam (tests stage socket states per call). */
72
+ discover?: typeof discoverCliSockets
73
+ /** `dsh plugin add` runner seam. */
74
+ spawnCommand?: (command: string, args: readonly string[]) => Promise<number>
75
+ /** Daemon probe/trigger seam. */
76
+ execProbe?: ExecProbe
77
+ platform?: NodeJS.Platform
78
+ uid?: number
79
+ macosAppPath?: string
80
+ systemUnitDir?: string
81
+ }
82
+
83
+ const INGRESS_MODES = ['caddy', 'cloudflared', 'external', 'lan'] as const
84
+
85
+ /** The role-switch ping poll: frequent by default, tests shrink it. */
86
+ const ROLE_POLL_INTERVAL_DEFAULT_MS = 300
87
+
88
+ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
89
+
90
+ // ===========================================================================
91
+ // Small helpers
92
+ // ===========================================================================
93
+
94
+ /** A command context for one of the driven subcommands: same translator,
95
+ * interactivity, prompter and seams, fresh args from a synthesized argv. */
96
+ function commandCtx(ctx: SetupCommandContext, argv: string[], ipc: CliIpcClient): CliExecutionContext {
97
+ return {
98
+ args: parseCliArgs(argv),
99
+ ipc,
100
+ t: ctx.t,
101
+ interactive: ctx.interactive,
102
+ ...(ctx.prompter === undefined ? {} : { prompter: ctx.prompter }),
103
+ stdout: ctx.stdout,
104
+ ...(ctx.now === undefined ? {} : { now: ctx.now }),
105
+ ...(ctx.cwd === undefined ? {} : { cwd: ctx.cwd }),
106
+ ...(ctx.pollIntervalMs === undefined ? {} : { pollIntervalMs: ctx.pollIntervalMs }),
107
+ ...(ctx.abortSignal === undefined ? {} : { abortSignal: ctx.abortSignal }),
108
+ }
109
+ }
110
+
111
+ /** The user's setup options, re-issued as argv for the driven subcommands —
112
+ * the inner flows read the SAME values (`--domain`, `--cf-token`,
113
+ * `--no-wait`, …) they would have read from a direct `host` / `client`
114
+ * invocation. */
115
+ function carryOptionArgs(options: CliOptions): string[] {
116
+ const argv: string[] = []
117
+ if (options.wait !== 20) argv.push('--wait', String(options.wait))
118
+ if (options.noWait) argv.push('--no-wait')
119
+ if (options.domain !== undefined) argv.push('--domain', options.domain)
120
+ if (options.cfToken !== undefined) argv.push('--cf-token', options.cfToken)
121
+ if (options.email !== undefined) argv.push('--email', options.email)
122
+ if (options.httpsPort !== undefined) argv.push('--https-port', String(options.httpsPort))
123
+ if (options.publicUrl !== undefined) argv.push('--public-url', options.publicUrl)
124
+ if (options.tunnelId !== undefined) argv.push('--tunnel-id', options.tunnelId)
125
+ if (options.proxy !== undefined) argv.push('--proxy', options.proxy)
126
+ if (options.hostUrl !== undefined) argv.push('--host-url', options.hostUrl)
127
+ if (options.code !== undefined) argv.push('--code', options.code)
128
+ if (options.name !== undefined) argv.push('--name', options.name)
129
+ return argv
130
+ }
131
+
132
+ /** The pool rows of a client/status answer, defensively filtered. */
133
+ function serversOfRow(body: ServerRow | null): ServerRow[] {
134
+ const rows = body === null ? [] : Array.isArray(body.servers) ? body.servers : []
135
+ return rows
136
+ .map((row) => plainOf(row))
137
+ .filter((row): row is ServerRow => row !== null)
138
+ }
139
+
140
+ function poolMaxOf(body: ServerRow | null): number {
141
+ return typeof body?.maxServers === 'number' ? body.maxServers : 8
142
+ }
143
+
144
+ function textOf(row: ServerRow | null, field: string): string {
145
+ const value = row?.[field]
146
+ return typeof value === 'string' ? value : ''
147
+ }
148
+
149
+ // ===========================================================================
150
+ // Preflight: install + launch
151
+ // ===========================================================================
152
+
153
+ /**
154
+ * SPEC §4: discovery first; install (multi-select / `--profile`) only when
155
+ * nothing live answers or `--profile` was given; then bring DSH up and hand
156
+ * back the live IPC client plus its first ping. All failures print their own
157
+ * bilingual guidance and come back as `undefined`.
158
+ */
159
+ async function preflight(ctx: SetupCommandContext): Promise<{ ipc: CliIpcClient; ping: CliPingResponse } | undefined> {
160
+ const { t, args } = ctx
161
+ const out = ctx.stdout
162
+ const options = args.options
163
+ const discover = ctx.discover ?? discoverCliSockets
164
+ const discoverOptions = {
165
+ dshHome: ctx.dshHome,
166
+ ...(options.socket === undefined ? {} : { explicitSocket: options.socket }),
167
+ platform: ctx.platform ?? process.platform,
168
+ }
169
+
170
+ const first = await discover(discoverOptions)
171
+ if (first.live.length === 0 && first.versionMismatch.length > 0) {
172
+ const mismatched = first.versionMismatch[0]
173
+ out.write(`${t('err.protocolVersion', {
174
+ found: String(mismatched?.cliProtocolVersion ?? '?'),
175
+ expected: CLI_PROTOCOL_VERSION,
176
+ })}\n`)
177
+ return undefined
178
+ }
179
+ // The profile the install decided on feeds the launch: with no daemon to
180
+ // auto-start, the manual guidance must name the profile the user actually
181
+ // picked, not the synthesized `web` default.
182
+ let preferredProfile = 'web'
183
+ if (first.live.length === 0 || options.profile !== undefined) {
184
+ const install = await ensurePluginInstalled({
185
+ dshHome: ctx.dshHome,
186
+ ...(options.profile === undefined ? {} : { profileFlag: options.profile }),
187
+ interactive: ctx.interactive,
188
+ ...(ctx.prompter === undefined ? {} : { prompter: ctx.prompter }),
189
+ ...(ctx.spawnCommand === undefined ? {} : { spawnCommand: ctx.spawnCommand }),
190
+ stdout: out,
191
+ t,
192
+ })
193
+ if (!install.ok) return undefined
194
+ if (install.preferredProfile !== undefined) preferredProfile = install.preferredProfile
195
+ }
196
+
197
+ const up = await ensureDshRunning({
198
+ dshHome: ctx.dshHome,
199
+ preferredProfile,
200
+ waitSeconds: options.wait,
201
+ ...(options.socket === undefined ? {} : { explicitSocket: options.socket }),
202
+ ...(ctx.platform === undefined ? {} : { platform: ctx.platform }),
203
+ ...(ctx.uid === undefined ? {} : { uid: ctx.uid }),
204
+ ...(ctx.execProbe === undefined ? {} : { execProbe: ctx.execProbe }),
205
+ discover,
206
+ ...(ctx.macosAppPath === undefined ? {} : { macosAppPath: ctx.macosAppPath }),
207
+ ...(ctx.systemUnitDir === undefined ? {} : { systemUnitDir: ctx.systemUnitDir }),
208
+ stdout: out,
209
+ t,
210
+ })
211
+ if (!up.started) return undefined
212
+ return { ipc: up.client, ping: up.ping }
213
+ }
214
+
215
+ // ===========================================================================
216
+ // Step 1: the role
217
+ // ===========================================================================
218
+
219
+ function printStepHeader(ctx: SetupCommandContext, titleKey: string): void {
220
+ const { t, stdout } = ctx
221
+ stdout.write(`\n${t('setup.divider')}\n${t(titleKey)}\n${t('setup.divider')}\n`)
222
+ }
223
+
224
+ /** Resolve the target role: the flag wins (peer / garbage answered with the
225
+ * bilingual refusal), an interactive run asks with the current role
226
+ * highlighted, a headless run fails fast on the missing option. */
227
+ async function resolveTargetRole(ctx: SetupCommandContext, ping: CliPingResponse): Promise<'host' | 'client' | undefined> {
228
+ const { t, args } = ctx
229
+ const requested = args.options.role
230
+ if (requested === 'host' || requested === 'client') return requested
231
+ if (requested !== undefined) {
232
+ const message = requested === 'peer'
233
+ ? t('err.role.peerNotEnabled')
234
+ : t('err.role.invalid', { value: requested })
235
+ ctx.stdout.write(`${message}\n`)
236
+ return undefined
237
+ }
238
+ if (ctx.interactive && ctx.prompter !== undefined) {
239
+ printStepHeader(ctx, 'setup.step1Title')
240
+ const picked = await ctx.prompter.selectOne(
241
+ t('setup.roleQuestion'),
242
+ [t('setup.roleHost'), t('setup.roleClient')],
243
+ ping.role === 'client' ? 1 : 0,
244
+ )
245
+ return picked === 1 ? 'client' : 'host'
246
+ }
247
+ throw new MissingRequiredOptionError('role')
248
+ }
249
+
250
+ // ===========================================================================
251
+ // Steps 2 & 3, host branch
252
+ // ===========================================================================
253
+
254
+ /** The four modes' label keys, in INGRESS_MODES order. */
255
+ const MODE_LABEL_KEYS: Readonly<Record<(typeof INGRESS_MODES)[number], string>> = {
256
+ caddy: 'setup.ingressCaddy',
257
+ cloudflared: 'setup.ingressCloudflared',
258
+ external: 'setup.ingressExternal',
259
+ lan: 'setup.ingressLan',
260
+ }
261
+
262
+ /** The two-question funnel: public IP + Cloudflare-hosted domain map onto
263
+ * the four modes (caddy needs both; cloudflared replaces the public IP;
264
+ * external takes a public IP with the proxy the user already owns; lan
265
+ * covers everything else). */
266
+ function recommendMode(publicIp: boolean, onCloudflare: boolean): string {
267
+ if (onCloudflare) return publicIp ? 'caddy' : 'cloudflared'
268
+ return publicIp ? 'external' : 'lan'
269
+ }
270
+
271
+ async function runHostBranch(ctx: SetupCommandContext, ipc: CliIpcClient): Promise<number> {
272
+ const { t, args } = ctx
273
+ const out = ctx.stdout
274
+ const options = args.options
275
+
276
+ // Step 2: the ingress mode. Explicit --ingress/--mode wins; otherwise the
277
+ // current effective config offers "keep and verify" as choice 0 (in a
278
+ // headless run that keep IS the answer — re-running setup on a configured
279
+ // host must not blow away the working ingress).
280
+ printStepHeader(ctx, 'setup.step2HostTitle')
281
+ const wizard = await fetchBody(() => ipc.request('GET', WIZARD_STATUS_ROUTE))
282
+ const hasIngress = (textOf(wizard, 'mode') !== '' && textOf(wizard, 'mode') !== 'off')
283
+ || textOf(wizard, 'publicUrl') !== ''
284
+ let mode: string | undefined = options.ingress ?? options.mode
285
+ let keep = false
286
+ if (mode === undefined) {
287
+ if (ctx.interactive && ctx.prompter !== undefined) {
288
+ const values: string[] = []
289
+ const labels: string[] = []
290
+ if (hasIngress) {
291
+ values.push('keep')
292
+ labels.push(t('setup.ingressKeep'))
293
+ }
294
+ for (const ingressMode of INGRESS_MODES) {
295
+ values.push(ingressMode)
296
+ labels.push(t(MODE_LABEL_KEYS[ingressMode]))
297
+ }
298
+ values.push('recommend')
299
+ labels.push(t('setup.ingressRecommend'))
300
+ const picked = await ctx.prompter.selectOne(t('setup.ingressQuestion'), labels, 0)
301
+ const value = values[picked]
302
+ if (value === 'keep') keep = true
303
+ else if (value === 'recommend') {
304
+ const publicIp = await ctx.prompter.confirm(t('setup.recommend.publicIp'), false)
305
+ const onCloudflare = await ctx.prompter.confirm(t('setup.recommend.cfDomain'), false)
306
+ mode = recommendMode(publicIp, onCloudflare)
307
+ out.write(`${t('setup.recommend.result', { mode })}\n`)
308
+ } else {
309
+ mode = value
310
+ }
311
+ } else if (hasIngress) {
312
+ keep = true
313
+ } else {
314
+ throw new MissingRequiredOptionError('mode')
315
+ }
316
+ }
317
+
318
+ if (!keep && mode !== undefined) {
319
+ const startCode = await runHostIngressStartCommand(
320
+ commandCtx(ctx, ['host', 'ingress', 'start', '--mode', mode, ...carryOptionArgs(options)], ipc),
321
+ )
322
+ if (startCode !== 0) return startCode
323
+ }
324
+
325
+ // Step 3: the three-light verification, with the interactive retry menu
326
+ // when the first two lights fail (a headless run just reports).
327
+ printStepHeader(ctx, 'setup.step3HostTitle')
328
+ while (true) {
329
+ const verifyCode = await runHostIngressVerifyCommand(
330
+ commandCtx(ctx, ['host', 'ingress', 'verify', ...carryOptionArgs(options)], ipc),
331
+ )
332
+ if (verifyCode === 0) break
333
+ if (!ctx.interactive || ctx.prompter === undefined) return verifyCode
334
+ const picked = await ctx.prompter.selectOne(
335
+ t('setup.verifyFailedOptions'),
336
+ [t('setup.verifyRetry'), t('setup.verifyKeepExit')],
337
+ 0,
338
+ )
339
+ if (picked !== 0) return verifyCode
340
+ }
341
+
342
+ // The desktop-client follow-up: one confirm, one minted desktop code.
343
+ if (ctx.interactive && ctx.prompter !== undefined && !options.noWait) {
344
+ if (await ctx.prompter.confirm(t('setup.desktopPairQuestion'), false)) {
345
+ await runHostDevicePairCommand(commandCtx(ctx, ['host', 'device', 'pair', '--device-role', 'desktop-client'], ipc))
346
+ }
347
+ }
348
+
349
+ // Completion summary: the wizard status as it stands NOW.
350
+ const settled = await fetchBody(() => ipc.request('GET', WIZARD_STATUS_ROUTE))
351
+ const settledMode = textOf(settled, 'mode') === '' ? 'off' : textOf(settled, 'mode')
352
+ const settledUrl = textOf(settled, 'publicUrl')
353
+ out.write(`\n${t('setup.divider')}\n`)
354
+ out.write(`${t('setup.summary.title')}\n`)
355
+ out.write(`${t('setup.summary.role', { role: 'host' })}\n`)
356
+ out.write(`${t('setup.summary.ingress', { mode: settledMode, publicUrl: settledUrl === '' ? '—' : settledUrl })}\n`)
357
+ out.write(`${t('setup.summary.hint')}\n`)
358
+ out.write(`${t('setup.divider')}\n`)
359
+ return 0
360
+ }
361
+
362
+ // ===========================================================================
363
+ // Steps 2 & 3, client branch
364
+ // ===========================================================================
365
+
366
+ function printClientList(ctx: SetupCommandContext, servers: readonly ServerRow[], max: number): void {
367
+ const { t } = ctx
368
+ ctx.stdout.write(`${t('clientList.header', { count: servers.length, max })}\n`)
369
+ if (servers.length >= max) ctx.stdout.write(` ${t('client.limitReached')}\n`)
370
+ for (const row of servers) ctx.stdout.write(` ${serverLineText(t, row)}\n`)
371
+ }
372
+
373
+ async function runClientBranch(ctx: SetupCommandContext, ipc: CliIpcClient): Promise<number> {
374
+ const { t, args } = ctx
375
+ const out = ctx.stdout
376
+ const options = args.options
377
+
378
+ printStepHeader(ctx, 'setup.step2ClientTitle')
379
+
380
+ // Command-line pairing answers short-circuit the menu entirely. The address
381
+ // may come as the positional (`setup <hostUrl>`) or the flag — the flag
382
+ // wins when both appear.
383
+ const hostUrl = options.hostUrl ?? args.target
384
+ const providedPair = hostUrl !== undefined && options.code !== undefined
385
+ const pairArgv = ['client', 'pair', ...carryOptionArgs(options)]
386
+ if (options.hostUrl === undefined && hostUrl !== undefined) pairArgv.push('--host-url', hostUrl)
387
+ // The straight-into-pairing rule (N === 0) describes the ENTRY state; after
388
+ // a menu action the menu itself is the place to be — exiting stays the
389
+ // user's explicit choice, never an uninvited question flow.
390
+ let firstLap = true
391
+
392
+ while (true) {
393
+ const status = await fetchBody(() => ipc.request('GET', CLIENT_STATUS_ROUTE))
394
+ const servers = serversOfRow(status)
395
+ const max = poolMaxOf(status)
396
+
397
+ // N === 0 on entry (or a fully parametric pair): straight into pairing.
398
+ if (providedPair || (firstLap && servers.length === 0)) {
399
+ const pairCode = await runClientPairCommand(commandCtx(ctx, pairArgv, ipc))
400
+ if (pairCode !== 0) return pairCode
401
+ if (ctx.interactive && ctx.prompter !== undefined) {
402
+ if (await ctx.prompter.confirm(t('client.addAnother'), false)) {
403
+ firstLap = false
404
+ continue
405
+ }
406
+ }
407
+ break
408
+ }
409
+ firstLap = false
410
+
411
+ printClientList(ctx, servers, max)
412
+ // N > 0 headless (no answers, nothing to do): show the list and finish.
413
+ if (!ctx.interactive || ctx.prompter === undefined) break
414
+
415
+ // The menu: an empty pool (everything was removed) offers add + done; a
416
+ // full pool (8/8) tops with repair / remove per SPEC; otherwise the
417
+ // four-way menu with add as the default first choice.
418
+ const atLimit = servers.length >= max
419
+ const values = servers.length === 0 ? ['add', 'done'] : atLimit ? ['repair', 'remove', 'done'] : ['add', 'repair', 'remove', 'done']
420
+ const labelKeys = servers.length === 0
421
+ ? (['client.menuAdd', 'client.menuDone'] as const)
422
+ : atLimit
423
+ ? (['client.menuRepair', 'client.menuRemove', 'client.menuDone'] as const)
424
+ : (['client.menuAdd', 'client.menuRepair', 'client.menuRemove', 'client.menuDone'] as const)
425
+ const labels = labelKeys.map((key) => t(key))
426
+ const picked = await ctx.prompter.selectOne(t('client.menuTitle'), labels, 0)
427
+ const value = values[picked] ?? 'done'
428
+
429
+ if (value === 'done') break
430
+ if (value === 'add') {
431
+ const pairCode = await runClientPairCommand(commandCtx(ctx, pairArgv, ipc))
432
+ if (pairCode !== 0) return pairCode
433
+ // One more only while the pool still has room — at 8/8 the menu's
434
+ // limit branch takes over on the next lap anyway.
435
+ if (servers.length + 1 < max && ctx.interactive && ctx.prompter !== undefined) {
436
+ if (!(await ctx.prompter.confirm(t('client.addAnother'), false))) break
437
+ }
438
+ continue
439
+ }
440
+ if (value === 'repair') {
441
+ const rowIndex = await ctx.prompter.selectOne(
442
+ t('setup.clientRepairQuestion'),
443
+ servers.map((row) => serverLineText(t, row)),
444
+ 0,
445
+ )
446
+ const row = servers[rowIndex]
447
+ const repairUrl = textOf(row, 'serverUrl')
448
+ if (repairUrl === '') continue
449
+ const repairCode = await runClientPairCommand(
450
+ commandCtx(ctx, [...pairArgv, '--host-url', repairUrl], ipc),
451
+ )
452
+ if (repairCode !== 0) return repairCode
453
+ continue
454
+ }
455
+ // remove
456
+ const removeCode = await runClientUnpairCommand(commandCtx(ctx, ['client', 'unpair'], ipc))
457
+ if (removeCode !== 0) return removeCode
458
+ }
459
+
460
+ // Completion summary: the pool as it stands NOW.
461
+ const settled = await fetchBody(() => ipc.request('GET', CLIENT_STATUS_ROUTE))
462
+ const settledServers = serversOfRow(settled)
463
+ out.write(`\n${ctx.t('setup.divider')}\n`)
464
+ out.write(`${ctx.t('setup.summary.title')}\n`)
465
+ out.write(`${ctx.t('setup.summary.role', { role: 'client' })}\n`)
466
+ out.write(`${ctx.t('setup.summary.client', { count: settledServers.length, max: poolMaxOf(settled) })}\n`)
467
+ out.write(`${ctx.t('setup.summary.hint')}\n`)
468
+ out.write(`${ctx.t('setup.divider')}\n`)
469
+ return 0
470
+ }
471
+
472
+ // ===========================================================================
473
+ // The whole wizard
474
+ // ===========================================================================
475
+
476
+ /**
477
+ * `dsh-zen-remote setup` — preflight, step 1 (role + switch sync), then the
478
+ * role's steps 2 & 3. Missing required answers in a headless run throw
479
+ * MissingRequiredOptionError for the entry point to render.
480
+ */
481
+ export async function runSetupCommand(ctx: SetupCommandContext): Promise<number> {
482
+ const up = await preflight(ctx)
483
+ if (up === undefined) return 1
484
+ const { ipc } = up
485
+ const { t } = ctx
486
+ const waitSeconds = Math.max(1, ctx.args.options.wait)
487
+
488
+ const target = await resolveTargetRole(ctx, up.ping)
489
+ if (target === undefined) return 1
490
+
491
+ // Step 1's switch sync (GatewayWizard.tsx's discipline): the managed
492
+ // ingress processes belong to the outgoing role and are stopped before
493
+ // the config write; the polls ride out the restart-watcher reload over
494
+ // the hot-swap socket.
495
+ const pollUntil = async (want: (ping: CliPingResponse) => boolean): Promise<boolean> => {
496
+ const interval = ctx.pollIntervalMs ?? ROLE_POLL_INTERVAL_DEFAULT_MS
497
+ const deadline = Date.now() + waitSeconds * 1000
498
+ while (true) {
499
+ try {
500
+ const current = await ipc.ping()
501
+ if (want(current)) return true
502
+ } catch {
503
+ // A reload window can drop one ping — the deadline decides.
504
+ }
505
+ if (Date.now() >= deadline) return false
506
+ await sleep(interval)
507
+ }
508
+ }
509
+
510
+ if (target === 'host') {
511
+ if (!up.ping.serves) {
512
+ ctx.stdout.write(`${t('setup.switchingToHost')}\n`)
513
+ if (!(await writeConfigOrReport(commandCtx(ctx, ['status'], ipc), { role: 'host' }))) return 1
514
+ if (!(await pollUntil((ping) => ping.serves === true))) {
515
+ ctx.stdout.write(`${t('setup.roleSwitchTimeout', { seconds: waitSeconds })}\n`)
516
+ return 1
517
+ }
518
+ }
519
+ return await runHostBranch(ctx, ipc)
520
+ }
521
+
522
+ if (up.ping.serves) {
523
+ ctx.stdout.write(`${t('setup.switchingToClient')}\n`)
524
+ // Best-effort stops: a refused stop must not block the role switch.
525
+ try {
526
+ await ipc.request('POST', WIZARD_CADDY_STOP_ROUTE)
527
+ } catch { /* transport error — the stop route is gone with the process */ }
528
+ try {
529
+ await ipc.request('POST', WIZARD_TUNNEL_STOP_ROUTE)
530
+ } catch { /* same */ }
531
+ }
532
+ if (up.ping.role !== 'client') {
533
+ if (!(await writeConfigOrReport(commandCtx(ctx, ['status'], ipc), { role: 'client' }))) return 1
534
+ if (!(await pollUntil((ping) => ping.connects === true))) {
535
+ ctx.stdout.write(`${t('setup.roleSwitchTimeout', { seconds: waitSeconds })}\n`)
536
+ return 1
537
+ }
538
+ }
539
+ return await runClientBranch(ctx, ipc)
540
+ }