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,1127 @@
1
+ /**
2
+ * CLI-4: the `host` subcommand family — `device pair|list|revoke` and
3
+ * `ingress start|stop|verify|snippet`. Every entry point passes the
4
+ * servesRole gate FIRST (the task discipline: a client never drives the
5
+ * server-owner surfaces; the message names the `setup` switch), then drives
6
+ * the plugin's own admin/wizard routes over IPC.
7
+ *
8
+ * The iron rule for ingress: the CLI NEVER spawns `caddy` or `cloudflared`
9
+ * itself — download, start, stop, status and the three-light verification
10
+ * all happen inside the plugin process through the
11
+ * `/_dsh/zen-remote/admin/wizard/*` routes (SPEC §1). Mode switches mirror
12
+ * `GatewayWizard.tsx`'s discipline: any managed ingress process that does
13
+ * not belong to the new mode is stopped through its stop route before the
14
+ * new mode's flow runs, managed caddy/cloudflared reset `host` back to
15
+ * `127.0.0.1`, and lan rebinds it to `0.0.0.0`.
16
+ *
17
+ * Interactivity follows the bootstrap iron rule: a missing value prompts in
18
+ * a TTY run and throws MissingRequiredOptionError otherwise — except the
19
+ * values with a SAVED fallback (domain, public URL), which the saved wizard
20
+ * status answers before any prompt.
21
+ */
22
+
23
+ import { hostOf } from '../config.js'
24
+ import { normalizeServerUrl } from '../client-pairing.js'
25
+ import { ADMIN_STATUS_ROUTE, ADMIN_PAIR_ROUTE, ADMIN_ACTION_ROUTE } from '../admin-routes.js'
26
+ import {
27
+ WIZARD_STATUS_ROUTE,
28
+ WIZARD_TRUST_PROXY_ROUTE,
29
+ WIZARD_VERIFY_ROUTE,
30
+ WIZARD_CF_TOKEN_ROUTE,
31
+ WIZARD_CADDY_START_ROUTE,
32
+ WIZARD_CADDY_STOP_ROUTE,
33
+ WIZARD_TUNNEL_CHECK_ROUTE,
34
+ WIZARD_TUNNEL_SETUP_ROUTE,
35
+ WIZARD_TUNNEL_STOP_ROUTE,
36
+ } from '../wizard/routes.js'
37
+ import { nginxSnippet, caddySnippet, luckySteps } from '../wizard/proxy-snippets.js'
38
+ import { renderTerminalQr } from './qr-terminal.js'
39
+ import { promptOrFail, MissingRequiredOptionError } from './bootstrap.js'
40
+ import { fetchBody, plainOf } from './commands-status.js'
41
+ import type { ServerRow } from './commands-status.js'
42
+ import type { CliExecutionContext } from './context.js'
43
+ import type { CliTranslator } from './i18n.js'
44
+
45
+ const DEVICE_ROLES = ['web', 'desktop-client'] as const
46
+ const INGRESS_MODES = ['caddy', 'cloudflared', 'external', 'lan'] as const
47
+ const PROXY_KINDS = ['nginx', 'caddy', 'lucky'] as const
48
+ /** The default gateway port, same as config.ts's own default. */
49
+ const DEFAULT_GATEWAY_PORT = 3088
50
+
51
+ /** `host ingress verify`'s phone-pairing poll interval: a poll a second
52
+ * keeps the tunnel quiet; tests shrink it via the seam. */
53
+ const VERIFY_POLL_INTERVAL_DEFAULT_MS = 1000
54
+ /** `lan`'s gateway-restart readiness poll: frequent, because the reload is
55
+ * fast (config-writer triggers checkNow immediately). */
56
+ const LAN_POLL_INTERVAL_DEFAULT_MS = 300
57
+
58
+ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
59
+
60
+ // ===========================================================================
61
+ // Shared helpers
62
+ // ===========================================================================
63
+
64
+ /**
65
+ * The role gate every `host *` command passes first: a fresh ping (the role
66
+ * is a volatile row field), and anything that does not serve answers the
67
+ * bilingual refusal naming the current role.
68
+ */
69
+ async function requireServesRole(ctx: CliExecutionContext): Promise<boolean> {
70
+ let serves = false
71
+ let role: 'host' | 'client' | 'peer' = 'client'
72
+ try {
73
+ const ping = await ctx.ipc.ping()
74
+ serves = ping.serves
75
+ role = ping.role
76
+ } catch {
77
+ // No live plugin is no role that serves either — same refusal text.
78
+ }
79
+ if (serves) return true
80
+ ctx.stdout.write(`${ctx.t('err.roleGate.hostRequired', { role })}\n`)
81
+ return false
82
+ }
83
+
84
+ /** One JSON object over IPC; every failure mode is `null` — "no
85
+ * information", never a thrown command. */
86
+ async function fetchRecord(ctx: CliExecutionContext, method: string, path: string, body?: unknown): Promise<ServerRow | null> {
87
+ return await fetchBody(() => ctx.ipc.request(method, path, body))
88
+ }
89
+
90
+ /** The anti-shadowing config write, reporting `env-shadowed` and generic
91
+ * failures through the translator. Shared with `command-setup.ts`'s role
92
+ * switch, which writes the same way. */
93
+ export async function writeConfigOrReport(ctx: CliExecutionContext, set: Record<string, unknown>): Promise<boolean> {
94
+ const { t } = ctx
95
+ const result = await ctx.ipc.writeConfig({ set })
96
+ const body = plainOf(result.body)
97
+ if (result.status !== 200 || body?.ok !== true) {
98
+ const error = plainOf(body?.error)
99
+ if (error?.code === 'env-shadowed') {
100
+ ctx.stdout.write(`${t('err.envShadowed', { field: String(error.field), envVar: String(error.envVar) })}\n`)
101
+ } else {
102
+ ctx.stdout.write(`${t('host.ingress.configWriteFailed')}\n`)
103
+ }
104
+ return false
105
+ }
106
+ return true
107
+ }
108
+
109
+ /** One ask-text-or-throw: interactive runs prompt through the prompter,
110
+ * anything else raises the structured missing-option error. */
111
+ async function askText(ctx: CliExecutionContext, optionName: string, question: string): Promise<string> {
112
+ return await promptOrFail({
113
+ optionName,
114
+ interactive: ctx.interactive,
115
+ provided: undefined,
116
+ ask: async () => {
117
+ if (ctx.prompter === undefined) throw new MissingRequiredOptionError(optionName)
118
+ return await ctx.prompter.inputText(question, undefined, undefined)
119
+ },
120
+ })
121
+ }
122
+
123
+ /** The gateway child's ACTUAL port (it may have retried past the configured
124
+ * one): the wizard status's live fact, else the admin status's gateway
125
+ * payload, else the configured `port`, else the 3088 default. */
126
+ function gatewayPortOf(wizard: ServerRow | null, admin: ServerRow | null): number {
127
+ if (typeof wizard?.gatewayActualPort === 'number') return wizard.gatewayActualPort
128
+ const gateway = plainOf(admin?.gateway)
129
+ if (typeof gateway?.port === 'number') return gateway.port
130
+ const config = plainOf(admin?.config)
131
+ const values = plainOf(config?.values)
132
+ if (typeof values?.port === 'number') return values.port
133
+ return DEFAULT_GATEWAY_PORT
134
+ }
135
+
136
+ /** The address a phone / other machine can reach this host at: the saved
137
+ * public URL first, then the LAN address assembled with the gateway's
138
+ * actual port. Empty when neither exists. */
139
+ function publicUrlOf(wizard: ServerRow | null, admin: ServerRow | null): string {
140
+ const saved = typeof wizard?.publicUrl === 'string' ? wizard.publicUrl : ''
141
+ if (saved !== '') return saved
142
+ const lan = typeof wizard?.lanAddress === 'string' ? wizard.lanAddress : ''
143
+ if (lan === '') return ''
144
+ return `http://${lan}:${gatewayPortOf(wizard, admin)}`
145
+ }
146
+
147
+ /** `YYYY-MM-DD HH:mm` in the local timezone, or a dash for absent stamps. */
148
+ function formatStamp(value: unknown): string {
149
+ if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
150
+ const d = new Date(value)
151
+ const pad = (n: number): string => String(n).padStart(2, '0')
152
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
153
+ }
154
+
155
+ /** The paired-device rows of an admin/status answer, defensively filtered. */
156
+ function devicesOf(admin: ServerRow | null): ServerRow[] {
157
+ const gateway = plainOf(admin?.gateway)
158
+ if (!Array.isArray(gateway?.devices)) return []
159
+ return (gateway.devices as unknown[])
160
+ .map((device) => plainOf(device))
161
+ .filter((device): device is ServerRow => device !== null)
162
+ }
163
+
164
+ function deviceLineText(t: CliTranslator, device: ServerRow): string {
165
+ return t('host.device.deviceLine', {
166
+ id: String(device.id ?? ''),
167
+ name: String(device.name ?? ''),
168
+ deviceRole: String(device.role ?? ''),
169
+ kind: String(device.kind ?? ''),
170
+ })
171
+ }
172
+
173
+ // ===========================================================================
174
+ // host device pair
175
+ // ===========================================================================
176
+
177
+ /**
178
+ * `host device pair [--device-role web|desktop-client] [--json]` — mint one
179
+ * pairing code through the admin pair route and print it with everything
180
+ * the receiving side needs: the reachable URL (public URL, or the LAN
181
+ * address assembled with the gateway's actual port), a terminal QR for web
182
+ * devices, the copy-paste `client pair` command for desktop clients, and
183
+ * the code with its validity window (the gateway mints 10-minute codes).
184
+ */
185
+ export async function runHostDevicePairCommand(ctx: CliExecutionContext): Promise<number> {
186
+ if (!(await requireServesRole(ctx))) return 1
187
+ const { t, args, ipc } = ctx
188
+ const out = ctx.stdout
189
+ let deviceRole = args.options.deviceRole
190
+ if (deviceRole === undefined) {
191
+ if (ctx.interactive && ctx.prompter !== undefined) {
192
+ const picked = await ctx.prompter.selectOne(
193
+ t('host.device.prompt.role'),
194
+ [t('host.device.role.web'), t('host.device.role.desktop')],
195
+ 0,
196
+ )
197
+ deviceRole = picked === 1 ? 'desktop-client' : 'web'
198
+ } else {
199
+ // Non-interactive runs default to the web device — the common case.
200
+ deviceRole = 'web'
201
+ }
202
+ } else if (!(DEVICE_ROLES as readonly string[]).includes(deviceRole)) {
203
+ out.write(`${t('host.device.error.badRole', { value: deviceRole })}\n`)
204
+ return 1
205
+ }
206
+
207
+ if (args.options.json) {
208
+ // --json is a machine-readable run: the structured answer only.
209
+ const pairJson = await ipc.request('POST', ADMIN_PAIR_ROUTE, { role: deviceRole })
210
+ const pairJsonBody = plainOf(pairJson.body)
211
+ const jsonCode = typeof pairJsonBody?.code === 'string' ? pairJsonBody.code : ''
212
+ const jsonExpiresAt = typeof pairJsonBody?.expiresAt === 'number' ? pairJsonBody.expiresAt : 0
213
+ if (pairJson.status < 200 || pairJson.status >= 300 || pairJsonBody?.ok !== true || jsonCode === '') {
214
+ out.write(`${JSON.stringify({ ok: false }, null, 2)}\n`)
215
+ return 1
216
+ }
217
+ const [jsonWizard, jsonAdmin] = await Promise.all([
218
+ fetchRecord(ctx, 'GET', WIZARD_STATUS_ROUTE),
219
+ fetchRecord(ctx, 'GET', ADMIN_STATUS_ROUTE),
220
+ ])
221
+ const jsonUrl = publicUrlOf(jsonWizard, jsonAdmin)
222
+ out.write(`${JSON.stringify({
223
+ ok: true,
224
+ deviceRole,
225
+ code: jsonCode,
226
+ expiresAt: jsonExpiresAt,
227
+ role: deviceRole,
228
+ ...(jsonUrl === '' ? {} : { url: jsonUrl }),
229
+ }, null, 2)}\n`)
230
+ return 0
231
+ }
232
+
233
+ out.write(`${t('host.device.minting')}\n`)
234
+ const pairResult = await ipc.request('POST', ADMIN_PAIR_ROUTE, { role: deviceRole })
235
+ const pairBody = plainOf(pairResult.body)
236
+ const code = typeof pairBody?.code === 'string' ? pairBody.code : ''
237
+ const expiresAt = typeof pairBody?.expiresAt === 'number' ? pairBody.expiresAt : 0
238
+ if (pairResult.status < 200 || pairResult.status >= 300 || pairBody?.ok !== true || code === '') {
239
+ out.write(`${t('host.device.pairFailed')}\n`)
240
+ return 1
241
+ }
242
+ const minutes = Math.max(1, Math.round((expiresAt - Date.now()) / 60_000))
243
+
244
+ const [wizard, admin] = await Promise.all([
245
+ fetchRecord(ctx, 'GET', WIZARD_STATUS_ROUTE),
246
+ fetchRecord(ctx, 'GET', ADMIN_STATUS_ROUTE),
247
+ ])
248
+ const url = publicUrlOf(wizard, admin)
249
+ const desktopCode = typeof pairBody?.desktopCode === 'string' ? pairBody.desktopCode : ''
250
+
251
+ out.write(`${t('host.device.pairTitle')}\n`)
252
+ if (deviceRole === 'web') {
253
+ if (url !== '') {
254
+ out.write(`${t('setup.pairingQr')}\n`)
255
+ const qr = renderTerminalQr(url)
256
+ if (qr !== null) out.write(`${qr}\n`)
257
+ out.write(`${url}\n`)
258
+ } else {
259
+ out.write(`${t('host.device.noAddress')}\n`)
260
+ }
261
+ } else if (url !== '') {
262
+ out.write(`${t('host.device.desktopCommand')}\n`)
263
+ out.write(` dsh-zen-remote client pair ${url} --code ${code}\n`)
264
+ out.write(`${t('host.device.desktopWizard')}\n`)
265
+ } else {
266
+ out.write(`${t('host.device.noAddress')}\n`)
267
+ }
268
+ out.write(`${t('host.device.pairingCode', { code, minutes })}\n`)
269
+ if (deviceRole === 'web' && desktopCode !== '') {
270
+ out.write(`${t('setup.desktopPairingCode', { code: desktopCode })}\n`)
271
+ if (url !== '') {
272
+ out.write(` ${t('setup.desktopPairingCmd', { url, code: desktopCode })}\n`)
273
+ }
274
+ }
275
+ return 0
276
+ }
277
+
278
+ // ===========================================================================
279
+ // host device list
280
+ // ===========================================================================
281
+
282
+ /**
283
+ * `host device list [--json]` — the gateway's device table. `--json` echoes
284
+ * the gateway's own devices array verbatim (the machine-readable truth);
285
+ * the terminal mode prints one line per device with its id, name, role,
286
+ * kind and the created / last-seen stamps.
287
+ */
288
+ export async function runHostDeviceListCommand(ctx: CliExecutionContext): Promise<number> {
289
+ if (!(await requireServesRole(ctx))) return 1
290
+ const { t, args } = ctx
291
+ const out = ctx.stdout
292
+ const admin = await fetchRecord(ctx, 'GET', ADMIN_STATUS_ROUTE)
293
+ const gateway = plainOf(admin?.gateway)
294
+ if (admin === null || gateway === null) {
295
+ out.write(`${t('host.device.listFailed')}\n`)
296
+ return 1
297
+ }
298
+ if (args.options.json) {
299
+ out.write(`${JSON.stringify({ ok: true, devices: gateway.devices ?? [] }, null, 2)}\n`)
300
+ return 0
301
+ }
302
+ const devices = devicesOf(admin)
303
+ out.write(`${t('host.device.listTitle')}\n`)
304
+ if (devices.length === 0) {
305
+ out.write(` ${t('host.device.listEmpty')}\n`)
306
+ return 0
307
+ }
308
+ for (const device of devices) {
309
+ out.write(`${deviceLineText(t, device)}\n`)
310
+ out.write(`${t('host.device.deviceTiming', {
311
+ created: formatStamp(device.createdAt),
312
+ lastSeen: formatStamp(device.lastSeen),
313
+ })}\n`)
314
+ }
315
+ return 0
316
+ }
317
+
318
+ // ===========================================================================
319
+ // host device revoke
320
+ // ===========================================================================
321
+
322
+ /**
323
+ * `host device revoke [<id>|--all] [--yes] [--json]` — one revoke POST per
324
+ * invocation. `--all` needs `--yes` outside a TTY confirm (the missing
325
+ * confirmation IS the missing required option); a missing target in an
326
+ * interactive run picks from the current list.
327
+ */
328
+ export async function runHostDeviceRevokeCommand(ctx: CliExecutionContext): Promise<number> {
329
+ if (!(await requireServesRole(ctx))) return 1
330
+ const { t, args, ipc } = ctx
331
+ const out = ctx.stdout
332
+
333
+ if (args.options.all) {
334
+ const admin = await fetchRecord(ctx, 'GET', ADMIN_STATUS_ROUTE)
335
+ const count = devicesOf(admin).length
336
+ if (!args.options.yes) {
337
+ if (!ctx.interactive || ctx.prompter === undefined) {
338
+ out.write(`${t('err.missingOption', { option: 'yes' })}\n`)
339
+ return 1
340
+ }
341
+ const sure = await ctx.prompter.confirm(t('host.device.revokeConfirmAll'), false)
342
+ if (!sure) {
343
+ out.write(`${t('common.cancel')}\n`)
344
+ return 0
345
+ }
346
+ }
347
+ const result = await ipc.request('POST', ADMIN_ACTION_ROUTE, { action: 'revoke-all' })
348
+ const body = plainOf(result.body)
349
+ if (result.status !== 200 || body?.ok !== true) {
350
+ out.write(`${t('host.device.revokeFailed')}\n`)
351
+ return 1
352
+ }
353
+ if (args.options.json) {
354
+ out.write(`${JSON.stringify({ ok: true, action: 'revoke-all', count }, null, 2)}\n`)
355
+ } else {
356
+ out.write(`${t('host.device.revoked', { count })}\n`)
357
+ }
358
+ return 0
359
+ }
360
+
361
+ let target = args.target
362
+ if (target === undefined) {
363
+ if (!ctx.interactive || ctx.prompter === undefined) {
364
+ out.write(`${t('host.device.revokeMissingTarget')}\n`)
365
+ return 1
366
+ }
367
+ const admin = await fetchRecord(ctx, 'GET', ADMIN_STATUS_ROUTE)
368
+ const devices = devicesOf(admin)
369
+ if (devices.length === 0) {
370
+ out.write(` ${t('host.device.listEmpty')}\n`)
371
+ return 0
372
+ }
373
+ const picked = await ctx.prompter.selectOne(
374
+ t('host.device.selectQuestion'),
375
+ devices.map((device) => deviceLineText(t, device)),
376
+ 0,
377
+ )
378
+ target = String(devices[picked]?.id ?? '')
379
+ if (target === '') return 1
380
+ }
381
+
382
+ const result = await ipc.request('POST', ADMIN_ACTION_ROUTE, { action: 'revoke', id: target })
383
+ const body = plainOf(result.body)
384
+ if (result.status !== 200 || body?.ok !== true) {
385
+ out.write(`${t('host.device.revokeFailed')}\n`)
386
+ return 1
387
+ }
388
+ if (args.options.json) {
389
+ out.write(`${JSON.stringify({ ok: true, action: 'revoke', id: target }, null, 2)}\n`)
390
+ } else {
391
+ out.write(`${t('host.device.revokedOne', { id: target })}\n`)
392
+ }
393
+ return 0
394
+ }
395
+
396
+ // ===========================================================================
397
+ // ingress snippet rendering (shared with `ingress start external`)
398
+ // ===========================================================================
399
+
400
+ /** Print one reverse-proxy flavor's snippet. Returns false when the
401
+ * generator refused an input (a broken wizard status), true otherwise. */
402
+ function printSnippet(ctx: CliExecutionContext, kind: string, domain: string, port: number): boolean {
403
+ const { t } = ctx
404
+ const lang: 'zh' | 'en' = t('common.done') === 'Done' ? 'en' : 'zh'
405
+ const out = ctx.stdout
406
+ const upstreamHost = '127.0.0.1'
407
+ if (kind === 'nginx') {
408
+ const snippet = nginxSnippet({ domain, upstreamHost, upstreamPort: port, lang })
409
+ if (!snippet.ok) {
410
+ out.write(`${t('host.ingress.error.badDomain')}\n`)
411
+ return false
412
+ }
413
+ out.write(`${t('host.ingress.snippetTitle', { proxy: 'Nginx', port })}\n`)
414
+ out.write(`${snippet.text}\n`)
415
+ return true
416
+ }
417
+ if (kind === 'caddy') {
418
+ const snippet = caddySnippet({ domain, upstreamHost, upstreamPort: port, lang })
419
+ if (!snippet.ok) {
420
+ out.write(`${t('host.ingress.error.badDomain')}\n`)
421
+ return false
422
+ }
423
+ out.write(`${t('host.ingress.snippetTitle', { proxy: 'Caddy', port })}\n`)
424
+ out.write(`${snippet.text}\n`)
425
+ return true
426
+ }
427
+ const steps = luckySteps({ upstreamHost, upstreamPort: port, lang })
428
+ if (!steps.ok) {
429
+ out.write(`${t('host.ingress.error.badDomain')}\n`)
430
+ return false
431
+ }
432
+ out.write(`${t('host.ingress.snippetTitle', { proxy: 'Lucky', port })}\n`)
433
+ for (const step of steps.steps) out.write(`${step}\n`)
434
+ return true
435
+ }
436
+
437
+ /** Which flavors to print: an explicit `--proxy`, one prompted in an
438
+ * interactive run, or all three headlessly. */
439
+ async function resolveSnippetKinds(ctx: CliExecutionContext): Promise<string[] | null> {
440
+ const { t, args } = ctx
441
+ const proxy = args.options.proxy
442
+ if (proxy !== undefined) {
443
+ if (!(PROXY_KINDS as readonly string[]).includes(proxy)) {
444
+ ctx.stdout.write(`${t('host.ingress.error.badProxy', { value: proxy })}\n`)
445
+ return null
446
+ }
447
+ return [proxy]
448
+ }
449
+ if (ctx.interactive && ctx.prompter !== undefined) {
450
+ const picked = await ctx.prompter.selectOne(
451
+ t('host.ingress.snippetQuestion'),
452
+ ['Nginx', 'Caddy', 'Lucky'],
453
+ 0,
454
+ )
455
+ const kind = ['nginx', 'caddy', 'lucky'][picked]
456
+ return kind === undefined ? null : [kind]
457
+ }
458
+ return ['nginx', 'caddy', 'lucky']
459
+ }
460
+
461
+ /**
462
+ * `host ingress snippet [--proxy nginx|caddy|lucky]` — the copy-paste
463
+ * reverse-proxy material for the external ingress mode. The upstream port
464
+ * is the gateway's ACTUAL port (a gateway that retried past its configured
465
+ * port must not hand out snippets pointing at the wrong place); the domain
466
+ * falls back to the example placeholder.
467
+ */
468
+ export async function runHostIngressSnippetCommand(ctx: CliExecutionContext): Promise<number> {
469
+ if (!(await requireServesRole(ctx))) return 1
470
+ const { t } = ctx
471
+ const kinds = await resolveSnippetKinds(ctx)
472
+ if (kinds === null) return 1
473
+ const [wizard, admin] = await Promise.all([
474
+ fetchRecord(ctx, 'GET', WIZARD_STATUS_ROUTE),
475
+ fetchRecord(ctx, 'GET', ADMIN_STATUS_ROUTE),
476
+ ])
477
+ const port = gatewayPortOf(wizard, admin)
478
+ const domain = typeof wizard?.domain === 'string' && wizard.domain !== '' ? wizard.domain : 'dsh.example.com'
479
+ for (const kind of kinds) {
480
+ if (!printSnippet(ctx, kind, domain, port)) return 1
481
+ }
482
+ return 0
483
+ }
484
+
485
+ // ===========================================================================
486
+ // host ingress start
487
+ // ===========================================================================
488
+
489
+ /** Save a command-line-provided Cloudflare token through the 0600 token
490
+ * route. Absent flags are a no-op; a refused save is the failure. */
491
+ async function saveCfTokenIfProvided(ctx: CliExecutionContext): Promise<boolean> {
492
+ const provided = ctx.args.options.cfToken
493
+ if (provided === undefined || provided === '') return true
494
+ const result = await ctx.ipc.request('POST', WIZARD_CF_TOKEN_ROUTE, { token: provided })
495
+ const body = plainOf(result.body)
496
+ if (result.status !== 200 || body?.ok !== true) {
497
+ ctx.stdout.write(`${ctx.t('host.ingress.cfTokenFailed')}\n`)
498
+ return false
499
+ }
500
+ return true
501
+ }
502
+
503
+ /** Ask for the token exactly when none is saved yet (interactive runs
504
+ * only) — a token already on disk must never be re-asked. Prints the
505
+ * mode-specific Cloudflare permission checklist right before the prompt. */
506
+ async function askCfTokenIfUnconfigured(
507
+ ctx: CliExecutionContext,
508
+ wizard: ServerRow | null,
509
+ mode: 'caddy' | 'cloudflared' = 'caddy',
510
+ ): Promise<boolean> {
511
+ if (ctx.args.options.cfToken !== undefined && ctx.args.options.cfToken !== '') return true
512
+ if (wizard?.cfTokenConfigured === true) return true
513
+ if (!ctx.interactive || ctx.prompter === undefined) return true
514
+ ctx.stdout.write(`\n${ctx.t('setup.cfTokenGuide.title')}\n`)
515
+ ctx.stdout.write(`${ctx.t(mode === 'cloudflared' ? 'setup.cfTokenGuide.tunnel' : 'setup.cfTokenGuide.caddy')}\n`)
516
+ const token = await ctx.prompter.inputText(ctx.t('setup.cfTokenQuestion'), undefined, undefined)
517
+ if (token.trim() === '') return true
518
+ const result = await ctx.ipc.request('POST', WIZARD_CF_TOKEN_ROUTE, { token: token.trim() })
519
+ const body = plainOf(result.body)
520
+ if (result.status !== 200 || body?.ok !== true) {
521
+ ctx.stdout.write(`${ctx.t('host.ingress.cfTokenFailed')}\n`)
522
+ return false
523
+ }
524
+ return true
525
+ }
526
+
527
+ /** The domain: the flag wins, then the saved config, then the prompt. The
528
+ * value must pass config.ts's bare-hostname gate (the same admission the
529
+ * wizard cards apply). */
530
+ async function resolveDomain(ctx: CliExecutionContext, wizard: ServerRow | null): Promise<string | null> {
531
+ const { t, args } = ctx
532
+ let provided = args.options.domain
533
+ if (provided === undefined || provided === '') {
534
+ const saved = typeof wizard?.domain === 'string' && wizard.domain !== '' ? wizard.domain : undefined
535
+ if (saved !== undefined) return hostOf(saved) ?? null
536
+ provided = await askText(ctx, 'domain', t('setup.domainQuestion'))
537
+ }
538
+ const domain = hostOf(provided)
539
+ if (domain === undefined) {
540
+ ctx.stdout.write(`${t('host.ingress.error.badDomain')}\n`)
541
+ return null
542
+ }
543
+ return domain
544
+ }
545
+
546
+ interface IngressSources {
547
+ wizard: ServerRow | null
548
+ admin: ServerRow | null
549
+ }
550
+
551
+ function startFailedCaddy(t: CliTranslator, body: ServerRow | null): string {
552
+ const error = typeof body?.error === 'string' ? body.error : ''
553
+ const detail = typeof body?.detail === 'string' ? body.detail : ''
554
+ const installUrl = typeof body?.installUrl === 'string' ? body.installUrl : ''
555
+ switch (error) {
556
+ case 'already-running': return t('host.ingress.caddyError.alreadyRunning')
557
+ case 'missing-token': return t('host.ingress.caddyError.missingToken')
558
+ case 'bad-config': return t('host.ingress.caddyError.badConfig', { detail })
559
+ case 'download-failed': return t('host.ingress.caddyError.downloadFailed', { detail, installUrl })
560
+ case 'validate-failed': return t('host.ingress.caddyError.validateFailed', { detail })
561
+ case 'spawn-failed': return t('host.ingress.caddyError.spawnFailed', { detail })
562
+ default: return t('host.ingress.caddyError.generic', { error: error === '' ? 'unknown' : error, detail })
563
+ }
564
+ }
565
+
566
+ /** Managed Caddy: token → saved config (loopback host, mode + domain +
567
+ * port + optional ACME email) → the plugin's start route, which downloads
568
+ * and validates the binary inside the plugin process. */
569
+ async function startCaddy(ctx: CliExecutionContext, sources: IngressSources): Promise<number> {
570
+ const { t, args, ipc } = ctx
571
+ const out = ctx.stdout
572
+ const domain = await resolveDomain(ctx, sources.wizard)
573
+ if (domain === null) return 1
574
+ const httpsPort = args.options.httpsPort ?? (typeof sources.wizard?.httpsPort === 'number' ? sources.wizard.httpsPort : 443)
575
+ const email = args.options.email
576
+ if (!(await saveCfTokenIfProvided(ctx))) return 1
577
+ if (!(await askCfTokenIfUnconfigured(ctx, sources.wizard))) return 1
578
+ if (!(await writeConfigOrReport(ctx, {
579
+ role: 'host',
580
+ host: '127.0.0.1',
581
+ ingressMode: 'caddy',
582
+ ingressDomain: domain,
583
+ ingressHttpsPort: httpsPort,
584
+ ...(email !== undefined && email !== '' ? { ingressAcmeEmail: email } : {}),
585
+ }))) return 1
586
+ const start = await ipc.request('POST', WIZARD_CADDY_START_ROUTE)
587
+ const body = plainOf(start.body)
588
+ if (start.status !== 200 || body?.ok !== true) {
589
+ out.write(`${startFailedCaddy(t, body)}\n`)
590
+ return 1
591
+ }
592
+ if (args.options.json) {
593
+ out.write(`${JSON.stringify({ ok: true, mode: 'caddy' }, null, 2)}\n`)
594
+ return 0
595
+ }
596
+ out.write(`${t('host.ingress.started', { mode: 'caddy' })}\n`)
597
+ return 0
598
+ }
599
+
600
+ function tunnelFailureLine(t: CliTranslator, key: 'host.ingress.tunnelCheckFailed' | 'host.ingress.tunnelSetupFailed', body: ServerRow | null): string {
601
+ const reason = typeof body?.reason === 'string' && body.reason !== '' ? body.reason : 'unknown'
602
+ return t(key, { reason })
603
+ }
604
+
605
+ /** Managed Cloudflare Tunnel: token → zone check → saved config → the
606
+ * tunnel setup (create, or adopt an existing same-name tunnel after an
607
+ * interactive confirm). */
608
+ async function startCloudflared(ctx: CliExecutionContext, sources: IngressSources): Promise<number> {
609
+ const { t, args, ipc } = ctx
610
+ const out = ctx.stdout
611
+ const domain = await resolveDomain(ctx, sources.wizard)
612
+ if (domain === null) return 1
613
+ if (!(await saveCfTokenIfProvided(ctx))) return 1
614
+ if (!(await askCfTokenIfUnconfigured(ctx, sources.wizard, 'cloudflared'))) return 1
615
+
616
+ const check = await ipc.request('POST', WIZARD_TUNNEL_CHECK_ROUTE, { domain })
617
+ const checkBody = plainOf(check.body)
618
+ if (check.status !== 200 || checkBody?.ok !== true) {
619
+ out.write(`${tunnelFailureLine(t, 'host.ingress.tunnelCheckFailed', checkBody)}\n`)
620
+ if (checkBody?.reason === 'auth' || checkBody?.reason === 'zone-not-found') {
621
+ out.write(`${t('setup.cfTokenGuide.title')}\n${t('setup.cfTokenGuide.tunnel')}\n`)
622
+ }
623
+ return 1
624
+ }
625
+
626
+ if (!(await writeConfigOrReport(ctx, {
627
+ role: 'host',
628
+ host: '127.0.0.1',
629
+ ingressMode: 'cloudflared',
630
+ ingressDomain: domain,
631
+ }))) return 1
632
+
633
+ const tunnelId = args.options.tunnelId
634
+ let setup = await ipc.request('POST', WIZARD_TUNNEL_SETUP_ROUTE, {
635
+ domain,
636
+ ...(tunnelId !== undefined && tunnelId !== '' ? { tunnelId } : {}),
637
+ })
638
+ let setupBody = plainOf(setup.body)
639
+ if (setup.status !== 200 || setupBody?.ok !== true) {
640
+ if (setupBody?.reason === 'tunnel-conflict') {
641
+ const existing = Array.isArray(setupBody.existing)
642
+ ? (setupBody.existing as unknown[]).map((entry) => plainOf(entry)).filter((entry): entry is ServerRow => entry !== null)
643
+ : []
644
+ const existingId = typeof existing[0]?.id === 'string' ? existing[0].id : ''
645
+ if (existingId !== '') {
646
+ if (ctx.interactive && ctx.prompter !== undefined) {
647
+ const reuse = await ctx.prompter.confirm(t('setup.tunnelConflict', { tunnelId: existingId }), true)
648
+ if (reuse) {
649
+ setup = await ipc.request('POST', WIZARD_TUNNEL_SETUP_ROUTE, { domain, tunnelId: existingId })
650
+ setupBody = plainOf(setup.body)
651
+ }
652
+ } else {
653
+ out.write(`${t('host.ingress.tunnelConflictNonInteractive', { tunnelId: existingId })}\n`)
654
+ }
655
+ }
656
+ }
657
+ if (setup.status !== 200 || setupBody?.ok !== true) {
658
+ out.write(`${tunnelFailureLine(t, 'host.ingress.tunnelSetupFailed', setupBody)}\n`)
659
+ if (setupBody?.reason === 'auth' || setupBody?.reason === 'zone-not-found') {
660
+ out.write(`${t('setup.cfTokenGuide.title')}\n${t('setup.cfTokenGuide.tunnel')}\n`)
661
+ }
662
+ return 1
663
+ }
664
+ }
665
+ const settled = setupBody as ServerRow
666
+ const startedTunnelId = typeof settled.tunnelId === 'string' ? settled.tunnelId : ''
667
+ const tunnelPublicUrl = typeof settled.publicUrl === 'string' ? settled.publicUrl : ''
668
+ if (args.options.json) {
669
+ out.write(`${JSON.stringify({
670
+ ok: true,
671
+ mode: 'cloudflared',
672
+ ...(startedTunnelId === '' ? {} : { tunnelId: startedTunnelId }),
673
+ ...(tunnelPublicUrl === '' ? {} : { publicUrl: tunnelPublicUrl }),
674
+ }, null, 2)}\n`)
675
+ return 0
676
+ }
677
+ out.write(`${t('host.ingress.started', { mode: 'cloudflared' })}\n`)
678
+ return 0
679
+ }
680
+
681
+ /** External reverse proxy: validate the https origin, save it, print the
682
+ * snippets, and offer the detected untrusted-proxy IP to the trust list
683
+ * (automatically headlessly, confirmed interactively). */
684
+ async function startExternal(ctx: CliExecutionContext, sources: IngressSources): Promise<number> {
685
+ const { t, args, ipc } = ctx
686
+ const out = ctx.stdout
687
+ let provided = args.options.publicUrl
688
+ if (provided === undefined || provided === '') {
689
+ const saved = typeof sources.wizard?.publicUrl === 'string' && sources.wizard.publicUrl !== '' ? sources.wizard.publicUrl : undefined
690
+ if (saved !== undefined) provided = saved
691
+ }
692
+ if (provided === undefined || provided === '') {
693
+ provided = await askText(ctx, 'public-url', t('setup.publicUrlQuestion'))
694
+ }
695
+ const normalized = normalizeServerUrl(provided)
696
+ if (!normalized.ok) {
697
+ out.write(`${t(normalized.reason === 'insecure-http' ? 'client.error.insecureHttp' : 'client.error.urlInvalid')}\n`)
698
+ return 1
699
+ }
700
+ // External means a real reverse proxy in front: plain http has no
701
+ // X-Forwarded-Proto story at all.
702
+ if (!normalized.url.startsWith('https://')) {
703
+ out.write(`${t('client.error.insecureHttp')}\n`)
704
+ return 1
705
+ }
706
+ if (!(await writeConfigOrReport(ctx, {
707
+ role: 'host',
708
+ ingressMode: 'external',
709
+ ingressPublicUrl: normalized.url,
710
+ }))) return 1
711
+
712
+ // The external flow prints material for ALL three proxies (unless the
713
+ // user named one) — a mid-flow flavor question would just be noise.
714
+ const proxy = args.options.proxy
715
+ if (proxy !== undefined && !(PROXY_KINDS as readonly string[]).includes(proxy)) {
716
+ out.write(`${t('host.ingress.error.badProxy', { value: proxy })}\n`)
717
+ return 1
718
+ }
719
+ const kinds = proxy !== undefined ? [proxy] : ['nginx', 'caddy', 'lucky']
720
+ const snippetDomain = typeof sources.wizard?.domain === 'string' && sources.wizard.domain !== '' ? sources.wizard.domain : 'dsh.example.com'
721
+ for (const kind of kinds) {
722
+ if (!printSnippet(ctx, kind, snippetDomain, gatewayPortOf(sources.wizard, sources.admin))) return 1
723
+ }
724
+
725
+ const forwarded = plainOf(sources.wizard?.lastForwardedSource)
726
+ const ip = typeof forwarded?.ip === 'string' ? forwarded.ip : ''
727
+ if (ip !== '') {
728
+ const trust = async (): Promise<boolean> => {
729
+ const result = await ipc.request('POST', WIZARD_TRUST_PROXY_ROUTE, { ip })
730
+ const body = plainOf(result.body)
731
+ return result.status === 200 && body?.ok === true
732
+ }
733
+ if (ctx.interactive && ctx.prompter !== undefined) {
734
+ const sure = await ctx.prompter.confirm(t('host.ingress.trustProxyQuestion', { ip }), true)
735
+ if (sure) {
736
+ if (!(await trust())) {
737
+ out.write(`${t('host.ingress.trustProxyFailed')}\n`)
738
+ return 1
739
+ }
740
+ out.write(`${t('host.ingress.trustProxyDone', { ip })}\n`)
741
+ }
742
+ } else {
743
+ // Headless / --yes: the detection is not actionable any other way —
744
+ // trust the proxy automatically and say so.
745
+ if (!(await trust())) {
746
+ out.write(`${t('host.ingress.trustProxyFailed')}\n`)
747
+ return 1
748
+ }
749
+ out.write(`${t('host.ingress.trustProxyAuto', { ip })}\n`)
750
+ }
751
+ }
752
+
753
+ if (args.options.json) {
754
+ out.write(`${JSON.stringify({ ok: true, mode: 'external', publicUrl: normalized.url }, null, 2)}\n`)
755
+ return 0
756
+ }
757
+ out.write(`${t('host.ingress.started', { mode: 'external' })}\n`)
758
+ return 0
759
+ }
760
+
761
+ /** LAN / Tailscale direct: rebind the gateway to 0.0.0.0, clear the
762
+ * managed ingress mode, and wait (bounded) for the restarted gateway to
763
+ * answer the admin status again on 0.0.0.0. */
764
+ async function startLan(ctx: CliExecutionContext, sources: IngressSources): Promise<number> {
765
+ const { t, args } = ctx
766
+ const out = ctx.stdout
767
+ const port = gatewayPortOf(sources.wizard, sources.admin)
768
+ let lanUrl = args.options.publicUrl
769
+ if (lanUrl === undefined || lanUrl === '') {
770
+ const rawCandidates = Array.isArray(sources.wizard?.lanAddresses) ? sources.wizard.lanAddresses : []
771
+ const candidates = rawCandidates
772
+ .map((item) => plainOf(item))
773
+ .filter((item): item is ServerRow => item !== null && typeof item.ip === 'string' && item.ip !== '')
774
+ if (candidates.length > 1 && ctx.interactive && ctx.prompter !== undefined) {
775
+ const labels = [
776
+ ...candidates.map((c) => {
777
+ const tag = c.kind === 'tailscale' ? 'Tailscale' : 'LAN'
778
+ const iface = typeof c.iface === 'string' && c.iface !== '' ? ` · ${c.iface}` : ''
779
+ return `http://${String(c.ip)}:${port} (${tag}${iface})`
780
+ }),
781
+ t('host.ingress.lanPickCustom'),
782
+ ]
783
+ const picked = await ctx.prompter.selectOne(t('host.ingress.lanPickQuestion'), labels, 0)
784
+ const chosen = candidates[picked]
785
+ if (chosen !== undefined) {
786
+ lanUrl = `http://${String(chosen.ip)}:${port}`
787
+ } else {
788
+ lanUrl = await askText(ctx, 'public-url', t('setup.publicUrlQuestion'))
789
+ }
790
+ } else if (candidates.length === 1) {
791
+ lanUrl = `http://${String(candidates[0].ip)}:${port}`
792
+ } else {
793
+ const lan = typeof sources.wizard?.lanAddress === 'string' && sources.wizard.lanAddress !== '' ? sources.wizard.lanAddress : ''
794
+ if (lan !== '') {
795
+ lanUrl = `http://${lan}:${port}`
796
+ } else {
797
+ lanUrl = await askText(ctx, 'public-url', t('setup.publicUrlQuestion'))
798
+ }
799
+ }
800
+ }
801
+ if (!(await writeConfigOrReport(ctx, {
802
+ role: 'host',
803
+ host: '0.0.0.0',
804
+ ingressMode: 'off',
805
+ ingressPublicUrl: lanUrl,
806
+ }))) return 1
807
+
808
+ const waitSeconds = Math.max(1, args.options.wait)
809
+ out.write(`${t('host.ingress.lanWaiting', { seconds: waitSeconds })}\n`)
810
+ const interval = ctx.pollIntervalMs ?? LAN_POLL_INTERVAL_DEFAULT_MS
811
+ const deadline = Date.now() + waitSeconds * 1000
812
+ const checkHostField = typeof sources.wizard?.gatewayActualHost === 'string'
813
+ await sleep(interval) // the restart watcher needs a beat to see the rebind
814
+ while (true) {
815
+ const admin = await fetchRecord(ctx, 'GET', ADMIN_STATUS_ROUTE)
816
+ if (admin?.gatewayReachable === true) {
817
+ if (!checkHostField) break
818
+ const latestWizard = await fetchRecord(ctx, 'GET', WIZARD_STATUS_ROUTE)
819
+ const actualHost = latestWizard?.gatewayActualHost
820
+ if (actualHost === undefined || actualHost === '0.0.0.0' || actualHost === '::') break
821
+ }
822
+ if (Date.now() >= deadline) {
823
+ out.write(`${t('host.ingress.lanTimeout', { seconds: waitSeconds })}\n`)
824
+ return 1
825
+ }
826
+ await sleep(interval)
827
+ }
828
+
829
+ if (args.options.json) {
830
+ out.write(`${JSON.stringify({ ok: true, mode: 'lan', publicUrl: lanUrl }, null, 2)}\n`)
831
+ return 0
832
+ }
833
+ out.write(`${t('host.ingress.started', { mode: 'lan' })}\n`)
834
+ return 0
835
+ }
836
+
837
+ /**
838
+ * `host ingress start [--mode …] …` — the four ingress modes, all driven
839
+ * through the plugin's wizard routes. The mode comes from the flags, then
840
+ * the saved config, then the interactive question; before the new mode's
841
+ * flow runs, every managed process that does not belong to it is stopped
842
+ * through its own stop route (GatewayWizard.tsx's discipline).
843
+ */
844
+ export async function runHostIngressStartCommand(ctx: CliExecutionContext): Promise<number> {
845
+ if (!(await requireServesRole(ctx))) return 1
846
+ const { t, args } = ctx
847
+ const out = ctx.stdout
848
+ try {
849
+ const sources: IngressSources = {
850
+ wizard: await fetchRecord(ctx, 'GET', WIZARD_STATUS_ROUTE),
851
+ admin: await fetchRecord(ctx, 'GET', ADMIN_STATUS_ROUTE),
852
+ }
853
+
854
+ let mode = args.options.mode ?? args.options.ingress
855
+ if (mode === undefined) {
856
+ const saved = typeof sources.wizard?.mode === 'string' && (INGRESS_MODES as readonly string[]).includes(sources.wizard.mode)
857
+ ? sources.wizard.mode
858
+ : undefined
859
+ if (saved !== undefined) mode = saved
860
+ }
861
+ if (mode === undefined) {
862
+ if (ctx.interactive && ctx.prompter !== undefined) {
863
+ const picked = await ctx.prompter.selectOne(
864
+ t('setup.ingressQuestion'),
865
+ [t('setup.ingressCaddy'), t('setup.ingressCloudflared'), t('setup.ingressExternal'), t('setup.ingressLan')],
866
+ 0,
867
+ )
868
+ mode = ['caddy', 'cloudflared', 'external', 'lan'][picked]
869
+ } else {
870
+ throw new MissingRequiredOptionError('mode')
871
+ }
872
+ }
873
+ if (!(INGRESS_MODES as readonly string[]).includes(mode)) {
874
+ out.write(`${t('host.ingress.error.badMode', { value: mode })}\n`)
875
+ return 1
876
+ }
877
+
878
+ // Mode-switch cleanup: an old tunnel or Caddy child never keeps
879
+ // running across a mode change.
880
+ if (mode !== 'caddy') await ctx.ipc.request('POST', WIZARD_CADDY_STOP_ROUTE)
881
+ if (mode !== 'cloudflared') await ctx.ipc.request('POST', WIZARD_TUNNEL_STOP_ROUTE)
882
+
883
+ if (mode === 'caddy') return await startCaddy(ctx, sources)
884
+ if (mode === 'cloudflared') return await startCloudflared(ctx, sources)
885
+ if (mode === 'external') return await startExternal(ctx, sources)
886
+ return await startLan(ctx, sources)
887
+ } catch (error) {
888
+ if (error instanceof MissingRequiredOptionError) {
889
+ out.write(`${t('err.missingOption', { option: error.optionName })}\n`)
890
+ return 1
891
+ }
892
+ throw error
893
+ }
894
+ }
895
+
896
+ // ===========================================================================
897
+ // host ingress stop
898
+ // ===========================================================================
899
+
900
+ /**
901
+ * `host ingress stop [--yes] [--json]` — stop both managed ingress
902
+ * processes through their stop routes (the CLI owns no process handles to
903
+ * reach for). `--yes` outside a TTY confirm, same discipline as revoke.
904
+ */
905
+ export async function runHostIngressStopCommand(ctx: CliExecutionContext): Promise<number> {
906
+ if (!(await requireServesRole(ctx))) return 1
907
+ const { t, args, ipc } = ctx
908
+ const out = ctx.stdout
909
+ if (!args.options.yes) {
910
+ if (!ctx.interactive || ctx.prompter === undefined) {
911
+ out.write(`${t('err.missingOption', { option: 'yes' })}\n`)
912
+ return 1
913
+ }
914
+ const sure = await ctx.prompter.confirm(t('host.ingress.confirmStop'), false)
915
+ if (!sure) {
916
+ out.write(`${t('common.cancel')}\n`)
917
+ return 0
918
+ }
919
+ }
920
+ const caddy = await ipc.request('POST', WIZARD_CADDY_STOP_ROUTE)
921
+ const tunnel = await ipc.request('POST', WIZARD_TUNNEL_STOP_ROUTE)
922
+ const caddyBody = plainOf(caddy.body)
923
+ const tunnelBody = plainOf(tunnel.body)
924
+ if (caddy.status !== 200 || caddyBody?.ok !== true || tunnel.status !== 200 || tunnelBody?.ok !== true) {
925
+ out.write(`${t('host.ingress.stopFailed')}\n`)
926
+ return 1
927
+ }
928
+ if (args.options.json) {
929
+ out.write(`${JSON.stringify({ ok: true }, null, 2)}\n`)
930
+ return 0
931
+ }
932
+ out.write(`${t('host.ingress.stopped')}\n`)
933
+ return 0
934
+ }
935
+
936
+ // ===========================================================================
937
+ // host ingress verify
938
+ // ===========================================================================
939
+
940
+ /** One verify-start failure's two lights: which of the first two checks
941
+ * the failure belongs to. `probe-failed` happens with the gateway already
942
+ * reachable (light 1 on, light 2 off); `pair-refused` happens with the
943
+ * probe already passed (both on). */
944
+ function renderVerifyFailure(ctx: CliExecutionContext, reason: string, detail: unknown): void {
945
+ const { t } = ctx
946
+ const out = ctx.stdout
947
+ const pass = t('common.done')
948
+ const fail = t('host.ingress.lightFail')
949
+ if (reason === 'probe-failed') {
950
+ out.write(`${t('setup.verifyLight1', { state: pass })}\n`)
951
+ out.write(`${t('setup.verifyLight2', { state: fail })}\n`)
952
+ } else if (reason === 'pair-refused') {
953
+ out.write(`${t('setup.verifyLight1', { state: pass })}\n`)
954
+ out.write(`${t('setup.verifyLight2', { state: pass })}\n`)
955
+ } else {
956
+ out.write(`${t('setup.verifyLight1', { state: fail })}\n`)
957
+ out.write(`${t('setup.verifyLight2', { state: fail })}\n`)
958
+ }
959
+ switch (reason) {
960
+ case 'no-public-url': out.write(`${t('host.ingress.verifyReason.noPublicUrl')}\n`); break
961
+ case 'bad-public-url': out.write(`${t('host.ingress.verifyReason.badPublicUrl')}\n`); break
962
+ case 'runtime-not-ready': out.write(`${t('host.ingress.verifyReason.runtimeNotReady')}\n`); break
963
+ case 'gateway-unreachable': out.write(`${t('host.ingress.verifyReason.gatewayUnreachable')}\n`); break
964
+ case 'probe-failed': out.write(`${t('host.ingress.verifyReason.probeFailed')}\n`); break
965
+ case 'pair-refused': {
966
+ const status = typeof detail === 'number' || typeof detail === 'string' ? String(detail) : ''
967
+ out.write(`${t('host.ingress.verifyReason.pairRefused', { detail: status })}\n`)
968
+ break
969
+ }
970
+ default: out.write(`${t('host.ingress.verifyReason.generic', { reason })}\n`); break
971
+ }
972
+ }
973
+
974
+ /**
975
+ * `host ingress verify [--public-url <origin>] [--no-wait] [--json]` — the
976
+ * three-light check, driven entirely by the plugin's verify orchestration:
977
+ * POST mints the code and walks the public URL (lights 1 + 2), GET polls
978
+ * for the real phone pairing (light 3). `--no-wait` / `--json` stop after
979
+ * the first two lights; an aborted signal (the entry point's Ctrl-C seam)
980
+ * skips the wait gracefully with exit code 0 — the first two lights'
981
+ * verdict is already on the record.
982
+ */
983
+ export async function runHostIngressVerifyCommand(ctx: CliExecutionContext): Promise<number> {
984
+ if (!(await requireServesRole(ctx))) return 1
985
+ const { t, args, ipc } = ctx
986
+ const out = ctx.stdout
987
+ try {
988
+ const [wizard, admin] = await Promise.all([
989
+ fetchRecord(ctx, 'GET', WIZARD_STATUS_ROUTE),
990
+ fetchRecord(ctx, 'GET', ADMIN_STATUS_ROUTE),
991
+ ])
992
+
993
+ let publicUrl = args.options.publicUrl
994
+ if (publicUrl === undefined || publicUrl === '') publicUrl = publicUrlOf(wizard, admin)
995
+ if (publicUrl === undefined || publicUrl === '') {
996
+ publicUrl = await askText(ctx, 'public-url', t('setup.publicUrlQuestion'))
997
+ }
998
+
999
+ let start = await ipc.request('POST', WIZARD_VERIFY_ROUTE, { publicUrl })
1000
+ let startBody = plainOf(start.body)
1001
+ let started = start.status === 200 && startBody?.ok === true
1002
+ // In LAN / Tailscale mode (`http://` private/Tailscale origin), the original
1003
+ // DSH Web UI only listens on 127.0.0.1, so the user cannot open the Web UI
1004
+ // to see a pairing code — the CLI is the ONLY place that can output it. If
1005
+ // the local gateway is up (Light 1 ready) but the machine's self-probe of
1006
+ // its own LAN/Tailscale IP hit a virtual-interface hairpin or routing
1007
+ // restriction (`probe-failed`), fall back to `allowUnprobed: true` so the
1008
+ // pairing code and QR are still minted for real device verification.
1009
+ if (!started && startBody?.reason === 'probe-failed' && publicUrl.startsWith('http://')) {
1010
+ const retry = await ipc.request('POST', WIZARD_VERIFY_ROUTE, { publicUrl, allowUnprobed: true })
1011
+ const retryBody = plainOf(retry.body)
1012
+ if (retry.status === 200 && retryBody?.ok === true) {
1013
+ start = retry
1014
+ startBody = retryBody
1015
+ started = true
1016
+ }
1017
+ }
1018
+
1019
+ if (args.options.json) {
1020
+ // --json is a machine-readable run: the structured verdict, no
1021
+ // interactive wait either way.
1022
+ if (!started || startBody === null) {
1023
+ const reason = typeof startBody?.reason === 'string' && startBody.reason !== '' ? startBody.reason : 'internal'
1024
+ out.write(`${JSON.stringify({ ok: false, reason }, null, 2)}\n`)
1025
+ return 1
1026
+ }
1027
+ out.write(`${JSON.stringify({
1028
+ ok: true,
1029
+ publicUrl: typeof startBody.publicUrl === 'string' && startBody.publicUrl !== '' ? startBody.publicUrl : publicUrl,
1030
+ pairingCode: typeof startBody.pairingCode === 'string' ? startBody.pairingCode : '',
1031
+ expiresAt: typeof startBody.expiresAt === 'number' ? startBody.expiresAt : 0,
1032
+ }, null, 2)}\n`)
1033
+ return 0
1034
+ }
1035
+
1036
+ out.write(`${t('host.ingress.verifyTitle', { publicUrl })}\n`)
1037
+ if (!started || startBody === null) {
1038
+ const reason = typeof startBody?.reason === 'string' && startBody.reason !== '' ? startBody.reason : 'internal'
1039
+ renderVerifyFailure(ctx, reason, startBody?.detail)
1040
+ return 1
1041
+ }
1042
+ const code = typeof startBody.pairingCode === 'string' ? startBody.pairingCode : ''
1043
+ const desktopCode = typeof startBody.desktopPairingCode === 'string' ? startBody.desktopPairingCode : ''
1044
+ const verifiedUrl = typeof startBody.publicUrl === 'string' && startBody.publicUrl !== '' ? startBody.publicUrl : publicUrl
1045
+
1046
+ out.write(`${t('setup.verifyLight1', { state: t('common.done') })}\n`)
1047
+ if (startBody.probeWarning !== undefined) {
1048
+ out.write(`${t('host.ingress.verifyLanProbeWarn')}\n`)
1049
+ } else {
1050
+ out.write(`${t('setup.verifyLight2', { state: t('common.done') })}\n`)
1051
+ }
1052
+ if (verifiedUrl !== '') {
1053
+ out.write(`\n${t('setup.pairingQr')}\n`)
1054
+ const qr = renderTerminalQr(verifiedUrl)
1055
+ if (qr !== null) out.write(`${qr}\n`)
1056
+ out.write(` ${verifiedUrl}\n`)
1057
+ }
1058
+ out.write(`\n${t('setup.divider')}\n`)
1059
+ out.write(` ${t('setup.pairingCode', { code })}\n`)
1060
+ if (desktopCode !== '') {
1061
+ out.write(` ${t('setup.desktopPairingCode', { code: desktopCode })}\n`)
1062
+ if (verifiedUrl !== '') {
1063
+ out.write(` ${t('setup.desktopPairingCmd', { url: verifiedUrl, code: desktopCode })}\n`)
1064
+ }
1065
+ }
1066
+ out.write(`${t('setup.divider')}\n`)
1067
+
1068
+ if (args.options.noWait) {
1069
+ out.write(`${t('host.ingress.verifySkipped')}\n`)
1070
+ return 0
1071
+ }
1072
+
1073
+ out.write(`${t('setup.pairingWait')}\n`)
1074
+ const interval = ctx.pollIntervalMs ?? VERIFY_POLL_INTERVAL_DEFAULT_MS
1075
+ while (true) {
1076
+ if (ctx.abortSignal?.aborted) {
1077
+ out.write(`${t('host.ingress.verifySkipped')}\n`)
1078
+ return 0
1079
+ }
1080
+ await sleep(interval)
1081
+ let checkBody: ServerRow | null = null
1082
+ try {
1083
+ const check = await ipc.request('GET', WIZARD_VERIFY_ROUTE)
1084
+ checkBody = plainOf(check.body)
1085
+ } catch {
1086
+ continue // one lost poll is not a verdict — keep waiting
1087
+ }
1088
+ if (ctx.abortSignal?.aborted) {
1089
+ out.write(`${t('host.ingress.verifySkipped')}\n`)
1090
+ return 0
1091
+ }
1092
+ const result = typeof checkBody?.result === 'string' ? checkBody.result : ''
1093
+ if (result === 'pass') {
1094
+ out.write(`${t('setup.verifyLight3', { state: t('common.done') })}\n`)
1095
+ out.write(`${t('setup.pairingDone')}\n`)
1096
+ // Persist the verified URL so a later `status` shows it (a failed
1097
+ // write is a warning — the verification itself already passed).
1098
+ const written = await ctx.ipc.writeConfig({ set: { ingressPublicUrl: verifiedUrl } })
1099
+ const writtenBody = plainOf(written.body)
1100
+ if (written.status !== 200 || writtenBody?.ok !== true) {
1101
+ out.write(`${t('host.ingress.configWriteFailed')}\n`)
1102
+ }
1103
+ return 0
1104
+ }
1105
+ if (result === 'cookie-insecure') {
1106
+ out.write(`${t('host.ingress.verifyCookieInsecure')}\n`)
1107
+ return 1
1108
+ }
1109
+ if (result === 'code-expired') {
1110
+ out.write(`${t('host.ingress.verifyExpired')}\n`)
1111
+ return 1
1112
+ }
1113
+ if (result === 'stale') {
1114
+ out.write(`${t('host.ingress.verifyStale')}\n`)
1115
+ return 1
1116
+ }
1117
+ // pending / not-started / unrecognized: keep polling until the code's
1118
+ // own expiry (the orchestration answers code-expired then).
1119
+ }
1120
+ } catch (error) {
1121
+ if (error instanceof MissingRequiredOptionError) {
1122
+ out.write(`${t('err.missingOption', { option: error.optionName })}\n`)
1123
+ return 1
1124
+ }
1125
+ throw error
1126
+ }
1127
+ }