dsh-zen-remote 2.3.11 → 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 +39 -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 +564 -363
  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 +566 -367
  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,369 @@
1
+ /**
2
+ * CLI-3: the `client` subcommand family — `pair`, `unpair`, `list`. Every
3
+ * entry point passes the connectsRole gate FIRST (the task discipline: a
4
+ * host never dials a remote host; the message names the `setup --role
5
+ * client` switch), then drives the plugin's own pairing routes over IPC —
6
+ * the CLI never touches the relay pool or the state file directly, the
7
+ * plugin process stays the single owner.
8
+ *
9
+ * Interactivity follows the bootstrap iron rule: a missing value prompts in
10
+ * a TTY run, and a non-interactive run throws MissingRequiredOptionError
11
+ * immediately — except the one retry the SPEC pins: a WRONG pairing code in
12
+ * an interactive run falls back to re-asking ONLY the code, the already
13
+ * entered address stays put.
14
+ */
15
+
16
+ import { promptOrFail, MissingRequiredOptionError } from './bootstrap.js'
17
+ import { normalizeServerUrl } from '../client-pairing.js'
18
+ import { CLIENT_CLAIM_ROUTE, CLIENT_UNPAIR_ROUTE, CLIENT_STATUS_ROUTE } from '../client-routes.js'
19
+ import type { CliExecutionContext } from './context.js'
20
+ import type { CliTranslator } from './i18n.js'
21
+ import { fetchBody, plainOf, serverLineText } from './commands-status.js'
22
+ import type { ServerRow } from './commands-status.js'
23
+
24
+ /** `client pair`'s online wait: the 10s budget is fixed, the interval is a
25
+ * test seam. */
26
+ const POLL_BUDGET_MS = 10_000
27
+ const POLL_INTERVAL_DEFAULT_MS = 250
28
+
29
+ const CODE_PATTERN = /^[A-Za-z0-9]{8}$/u
30
+
31
+ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
32
+
33
+ /** The servers rows of a client/status answer, defensively filtered. */
34
+ function serversOf(body: ServerRow | null): ServerRow[] {
35
+ const rows = body === null ? [] : Array.isArray(body.servers) ? body.servers : []
36
+ return rows
37
+ .map((row) => plainOf(row))
38
+ .filter((row): row is ServerRow => row !== null)
39
+ }
40
+
41
+ async function fetchStatus(ctx: CliExecutionContext): Promise<ServerRow | null> {
42
+ return await fetchBody(() => ctx.ipc.request('GET', CLIENT_STATUS_ROUTE))
43
+ }
44
+
45
+ /**
46
+ * The role gate every `client *` command passes first: fresh ping (the role
47
+ * is a volatile row field), and a host/peer answers the bilingual refusal.
48
+ */
49
+ async function requireConnectsRole(ctx: CliExecutionContext): Promise<boolean> {
50
+ let connects = false
51
+ let role = 'host'
52
+ try {
53
+ const ping = await ctx.ipc.ping()
54
+ connects = ping.connects
55
+ role = ping.role
56
+ } catch {
57
+ // No live plugin is no role that connects either — same refusal text.
58
+ }
59
+ if (connects) return true
60
+ ctx.stdout.write(`${ctx.t('err.roleGate.clientCommands', { role })}\n`)
61
+ return false
62
+ }
63
+
64
+ /** One ask-text-or-throw: interactive runs prompt through the prompter,
65
+ * anything else raises the structured missing-option error. */
66
+ async function askText(ctx: CliExecutionContext, optionName: string, question: string, validate?: (value: string) => string | undefined): Promise<string> {
67
+ return await promptOrFail({
68
+ optionName,
69
+ interactive: ctx.interactive,
70
+ provided: undefined,
71
+ ask: async () => {
72
+ if (ctx.prompter === undefined) throw new MissingRequiredOptionError(optionName)
73
+ return await ctx.prompter.inputText(question, undefined, validate)
74
+ },
75
+ })
76
+ }
77
+
78
+ // ===========================================================================
79
+ // client list
80
+ // ===========================================================================
81
+
82
+ /**
83
+ * `client list [--json]` — the paired-host table. `--json` echoes the pool
84
+ * status's own servers array verbatim (the machine-readable truth), the
85
+ * terminal mode prints the header count and one line per host.
86
+ */
87
+ export async function runClientListCommand(ctx: CliExecutionContext): Promise<number> {
88
+ if (!(await requireConnectsRole(ctx))) return 1
89
+ const { t } = ctx
90
+ const body = await fetchStatus(ctx)
91
+ if (body === null) {
92
+ ctx.stdout.write(`${t('clientList.fetchFailed', { error: t('status.notRunning') })}\n`)
93
+ return 1
94
+ }
95
+ const servers = serversOf(body)
96
+ const max = typeof body.maxServers === 'number' ? body.maxServers : 8
97
+ if (ctx.args.options.json) {
98
+ ctx.stdout.write(`${JSON.stringify({ ok: true, count: servers.length, maxServers: max, servers }, null, 2)}\n`)
99
+ return 0
100
+ }
101
+ ctx.stdout.write(`${t('clientList.header', { count: servers.length, max })}\n`)
102
+ if (servers.length >= max) ctx.stdout.write(` ${t('client.limitReached')}\n`)
103
+ if (servers.length === 0) {
104
+ ctx.stdout.write(` ${t('clientList.empty')}\n`)
105
+ return 0
106
+ }
107
+ for (const row of servers) {
108
+ ctx.stdout.write(` ${serverLineText(t, row)}\n`)
109
+ const lastError = typeof row.lastError === 'string' ? row.lastError : ''
110
+ if (lastError !== '') ctx.stdout.write(` ${t('status.client.lastError', { error: lastError })}\n`)
111
+ }
112
+ return 0
113
+ }
114
+
115
+ // ===========================================================================
116
+ // client pair
117
+ // ===========================================================================
118
+
119
+ /** Local pre-validation of the address (SPEC: the claim route would refuse
120
+ * it anyway — failing here saves the round-trip). Interactive runs re-ask;
121
+ * a refused PROVIDED address ends the run with the reason on record. */
122
+ async function resolveHostUrl(ctx: CliExecutionContext): Promise<string | null> {
123
+ const { t } = ctx
124
+ let provided = ctx.args.target ?? ctx.args.options.hostUrl
125
+ while (true) {
126
+ const raw = provided ?? await askText(ctx, 'host-url', t('client.prompt.hostUrl'), (value) => {
127
+ const normalized = normalizeServerUrl(value)
128
+ if (normalized.ok) return undefined
129
+ return t(normalized.reason === 'insecure-http' ? 'client.error.insecureHttp' : 'client.error.urlInvalid')
130
+ })
131
+ const normalized = normalizeServerUrl(raw)
132
+ if (normalized.ok) return normalized.url
133
+ ctx.stdout.write(`${t(normalized.reason === 'insecure-http' ? 'client.error.insecureHttp' : 'client.error.urlInvalid')}\n`)
134
+ if (!ctx.interactive || provided !== undefined) return null
135
+ provided = undefined // interactive: back to the address question
136
+ }
137
+ }
138
+
139
+ /** The 8-character pairing code, same provided / prompt / refuse split.
140
+ * `ignoreProvided` is the retry path's lever: a `--code` that already failed
141
+ * one claim must never answer the re-ask — that would replay the same wrong
142
+ * code forever (the CLI-3-fix loop). */
143
+ async function resolveCode(ctx: CliExecutionContext, ignoreProvided = false): Promise<string | null> {
144
+ const { t } = ctx
145
+ let provided = ignoreProvided ? undefined : ctx.args.options.code
146
+ while (true) {
147
+ const raw = provided ?? await askText(ctx, 'code', t('client.prompt.code'), (value) => (
148
+ CODE_PATTERN.test(value) ? undefined : t('client.error.codeFormat')
149
+ ))
150
+ if (CODE_PATTERN.test(raw)) return raw
151
+ ctx.stdout.write(`${t('client.error.codeFormat')}\n`)
152
+ if (!ctx.interactive || provided !== undefined) return null
153
+ provided = undefined
154
+ }
155
+ }
156
+
157
+ /** The shared tail of a successful claim: wait (bounded) for the new entry
158
+ * to reach `online`, then the three lights and the peer's name. */
159
+ async function finishPair(ctx: CliExecutionContext, serverUrl: string, claimBody: ServerRow): Promise<number> {
160
+ const { t } = ctx
161
+ const out = ctx.stdout
162
+ out.write(`${t('client.claimOk')}\n`)
163
+
164
+ const entryId = typeof claimBody.entryId === 'string' ? claimBody.entryId : ''
165
+ let entry: ServerRow | null = null
166
+ if (entryId !== '') {
167
+ const interval = ctx.pollIntervalMs ?? POLL_INTERVAL_DEFAULT_MS
168
+ const deadline = Date.now() + POLL_BUDGET_MS
169
+ while (true) {
170
+ const status = await fetchStatus(ctx)
171
+ entry = serversOf(status).find((row) => row.entryId === entryId) ?? null
172
+ if (entry?.state === 'online') break
173
+ if (Date.now() >= deadline) break
174
+ await sleep(interval)
175
+ }
176
+ }
177
+ const online = entry?.state === 'online'
178
+ const linkLabel = online ? t('status.online') : t('status.connecting')
179
+ out.write(`${t('client.lightCredentials', { state: t('common.done') })}\n`)
180
+ out.write(`${t('client.lightConnection', { state: linkLabel })}\n`)
181
+ out.write(`${t('client.lightSync', { state: linkLabel })}\n`)
182
+ const peerName = typeof entry?.serverName === 'string' && entry.serverName !== ''
183
+ ? entry.serverName
184
+ : typeof entry?.displayName === 'string' && entry.displayName !== '' ? entry.displayName : serverUrl
185
+ out.write(`${t('client.pairedWith', { name: peerName })}\n`)
186
+ if (ctx.args.options.json) {
187
+ out.write(`${JSON.stringify({
188
+ ok: true,
189
+ entryId,
190
+ serverUrl,
191
+ serverName: peerName,
192
+ state: online ? 'online' : typeof entry?.state === 'string' ? entry.state : 'connecting',
193
+ }, null, 2)}\n`)
194
+ }
195
+ return 0
196
+ }
197
+
198
+ /**
199
+ * `client pair [hostUrl] [--code <8位码>] [--name <本机名>] [--json]` — the
200
+ * full pairing flow: local address validation, name sync through the
201
+ * anti-shadowing config writer, the claim round-trip (with the SPEC's
202
+ * code-only retry in interactive runs), and the bounded wait for `online`.
203
+ */
204
+ export async function runClientPairCommand(ctx: CliExecutionContext): Promise<number> {
205
+ if (!(await requireConnectsRole(ctx))) return 1
206
+ const { t, ipc, args } = ctx
207
+ const out = ctx.stdout
208
+ try {
209
+ const serverUrl = await resolveHostUrl(ctx)
210
+ if (serverUrl === null) return 1
211
+ let code = await resolveCode(ctx)
212
+ if (code === null) return 1
213
+
214
+ if (args.options.name !== undefined) {
215
+ const written = await ipc.writeConfig({ set: { serverName: args.options.name } })
216
+ const body = plainOf(written.body)
217
+ if (written.status !== 200 || body?.ok !== true) {
218
+ const error = plainOf(body?.error)
219
+ if (error?.code === 'env-shadowed') {
220
+ out.write(`${t('err.envShadowed', { field: String(error.field), envVar: String(error.envVar) })}\n`)
221
+ } else {
222
+ out.write(`${t('client.error.nameWriteFailed')}\n`)
223
+ }
224
+ return 1
225
+ }
226
+ }
227
+
228
+ out.write(`${t('client.claiming')}\n`)
229
+ while (true) {
230
+ const result = await ipc.request('POST', CLIENT_CLAIM_ROUTE, { serverUrl, code })
231
+ const body = plainOf(result.body) ?? {}
232
+ if (result.status >= 200 && result.status < 300 && body.ok === true) {
233
+ return await finishPair(ctx, serverUrl, body)
234
+ }
235
+ out.write(`${claimFailureText(t, result.status, body)}\n`)
236
+ // SPEC: only a refused CODE retries, and only the code — the address
237
+ // stays; everything else is a final verdict in any mode. role-mismatch
238
+ // belongs here too: a web-role code is not consumed by the gateway, so
239
+ // re-asking with the desktop code on the SAME address is the fix.
240
+ const retryable = body.code === 'bad-code' || body.code === 'locked' || body.code === 'role-mismatch'
241
+ if (!ctx.interactive || !retryable) return 1
242
+ // The failed flag value may not answer again (CLI-3-fix: the infinite
243
+ // loop) — the retry always asks the user.
244
+ code = await resolveCode(ctx, true)
245
+ if (code === null) return 1
246
+ }
247
+ } catch (error) {
248
+ if (error instanceof MissingRequiredOptionError) {
249
+ out.write(`${t('err.missingOption', { option: error.optionName })}\n`)
250
+ return 1
251
+ }
252
+ throw error
253
+ }
254
+ }
255
+
256
+ /** One classified claim refusal → the bilingual line. */
257
+ function claimFailureText(t: CliTranslator, status: number, body: ServerRow): string {
258
+ switch (body.code) {
259
+ case 'limit-reached': return t('client.limitReached')
260
+ case 'role-mismatch': {
261
+ const message = typeof body.message === 'string' && body.message !== '' ? body.message : undefined
262
+ return message ?? t('client.error.roleMismatch')
263
+ }
264
+ case 'bad-code': return t('client.error.badCode')
265
+ case 'locked': {
266
+ const seconds = Math.ceil(typeof body.retryAfterMs === 'number' ? body.retryAfterMs / 1000 : 0)
267
+ return t('client.error.locked', { seconds })
268
+ }
269
+ case 'unreachable': return t('client.error.unreachable')
270
+ case 'invalid': return t('client.error.urlInvalid')
271
+ case 'insecure-http': return t('client.error.insecureHttp')
272
+ default: return status >= 200 && status < 300 ? t('client.error.unexpected') : t('client.error.unreachable')
273
+ }
274
+ }
275
+
276
+ // ===========================================================================
277
+ // client unpair
278
+ // ===========================================================================
279
+
280
+ /** POST one removal and report it. `removed: 0` is the not-found verdict. */
281
+ async function unpairEntry(ctx: CliExecutionContext, entry: ServerRow): Promise<number> {
282
+ const { t } = ctx
283
+ const entryId = typeof entry.entryId === 'string' ? entry.entryId : ''
284
+ const result = await ctx.ipc.request('POST', CLIENT_UNPAIR_ROUTE, { entryId })
285
+ const body = plainOf(result.body)
286
+ if (result.status !== 200 || body?.removed !== 1) {
287
+ ctx.stdout.write(`${t('clientUnpair.notFound', { target: entryId })}\n`)
288
+ return 1
289
+ }
290
+ if (ctx.args.options.json) {
291
+ ctx.stdout.write(`${JSON.stringify({ ok: true, removed: 1, entryId }, null, 2)}\n`)
292
+ } else {
293
+ const name = typeof entry.displayName === 'string' && entry.displayName !== '' ? entry.displayName : entryId
294
+ ctx.stdout.write(`${t('clientUnpair.removed', { name })}\n`)
295
+ }
296
+ return 0
297
+ }
298
+
299
+ /**
300
+ * `client unpair [<hostUrl|entryId>|--all] [--yes] [--json]` — a target is
301
+ * matched against the pool's own status rows (entry id first, then the
302
+ * normalized address); `--all` needs `--yes` outside a TTY confirm; no
303
+ * target at all in an interactive run picks from the current list.
304
+ */
305
+ export async function runClientUnpairCommand(ctx: CliExecutionContext): Promise<number> {
306
+ if (!(await requireConnectsRole(ctx))) return 1
307
+ const { t, args } = ctx
308
+ const out = ctx.stdout
309
+ const status = await fetchStatus(ctx)
310
+ const servers = serversOf(status)
311
+
312
+ if (args.options.all) {
313
+ if (!args.options.yes) {
314
+ if (!ctx.interactive || ctx.prompter === undefined) {
315
+ // Non-interactive without --yes must not destroy anything — the
316
+ // missing confirmation IS the missing required option.
317
+ out.write(`${t('err.missingOption', { option: 'yes' })}\n`)
318
+ return 1
319
+ }
320
+ const sure = await ctx.prompter.confirm(t('clientUnpair.confirmAll', { count: servers.length }), false)
321
+ if (!sure) {
322
+ out.write(`${t('common.cancel')}\n`)
323
+ return 0
324
+ }
325
+ }
326
+ const result = await ctx.ipc.request('POST', CLIENT_UNPAIR_ROUTE, { all: true })
327
+ const body = plainOf(result.body)
328
+ if (result.status !== 200 || body?.ok !== true) {
329
+ out.write(`${t('clientUnpair.failed')}\n`)
330
+ return 1
331
+ }
332
+ const removed = typeof body.removed === 'number' ? body.removed : servers.length
333
+ if (args.options.json) {
334
+ out.write(`${JSON.stringify({ ok: true, removed }, null, 2)}\n`)
335
+ } else {
336
+ out.write(`${t('clientUnpair.removedAll', { count: removed })}\n`)
337
+ }
338
+ return 0
339
+ }
340
+
341
+ const target = args.target
342
+ if (target === undefined) {
343
+ if (!ctx.interactive || ctx.prompter === undefined) {
344
+ out.write(`${t('clientUnpair.missingTarget')}\n`)
345
+ return 1
346
+ }
347
+ if (servers.length === 0) {
348
+ out.write(` ${t('clientList.empty')}\n`)
349
+ return 0
350
+ }
351
+ const picked = await ctx.prompter.selectOne(
352
+ t('clientUnpair.selectQuestion'),
353
+ servers.map((row) => serverLineText(t, row)),
354
+ 0,
355
+ )
356
+ const entry = servers[picked]
357
+ return entry === undefined ? 1 : await unpairEntry(ctx, entry)
358
+ }
359
+
360
+ const normalizedTarget = normalizeServerUrl(target)
361
+ const matched = servers.find((row) => row.entryId === target)
362
+ ?? servers.find((row) => normalizedTarget.ok && row.serverUrl === normalizedTarget.url)
363
+ ?? servers.find((row) => row.serverUrl === target)
364
+ if (matched === undefined) {
365
+ out.write(`${t('clientUnpair.notFound', { target })}\n`)
366
+ return 1
367
+ }
368
+ return await unpairEntry(ctx, matched)
369
+ }