dsh-zen-remote 2.3.10 → 2.4.0

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