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,247 @@
1
+ // Behaviour check for the MS-6 browser data layer — the multi-server view
2
+ // pieces across src/client-data/{settings-form,wizard,remote-status}.ts,
3
+ // driven against the REAL bodies src/client-routes.ts answers since MS-5:
4
+ //
5
+ // - `client/status` carries `servers: ClientServerStatusItem[]` (one row per
6
+ // paired pool entry) plus `maxServers` and the FIRST entry's top-level
7
+ // compatibility fields;
8
+ // - `remote-status` carries `servers: Record<serverId, …>` keyed by the
9
+ // handshake serverId plus the FIRST entry's top-level mirror;
10
+ // - the pairing gate judges "new address at the cap" against "existing
11
+ // address overwrite" (the cap never blocks a re-pair).
12
+ //
13
+ // Run: node scripts/check-settings-view.mjs (needs Node >= 23.6 type stripping)
14
+ import assert from 'node:assert/strict'
15
+ import {
16
+ canSubmitClientPairing,
17
+ CLIENT_UNPAIR_ROUTE,
18
+ deriveClientStatusView,
19
+ MAX_PAIRED_SERVERS,
20
+ normalizePairingCode,
21
+ } from '../src/client-data/settings-form.ts'
22
+ import { deriveClientOverviewSummary, isWizardConfigured } from '../src/client-data/wizard.ts'
23
+ import {
24
+ bannerText,
25
+ BRIDGE_STAMP_PLUGIN,
26
+ describeRemoteStatus,
27
+ parseRemoteStatusBody,
28
+ resolveServerStatusView,
29
+ shouldStampBridgeForSession,
30
+ } from '../src/client-data/remote-status.ts'
31
+
32
+ // Tiny sequential runner so the file stays plain node:assert (no node:test import).
33
+ let testChain = Promise.resolve()
34
+ function test(name, fn) {
35
+ testChain = testChain
36
+ .then(fn)
37
+ .then(() => { console.log(`ok - ${name}`) })
38
+ .catch((error) => {
39
+ console.error(`not ok - ${name}`)
40
+ throw error
41
+ })
42
+ return testChain
43
+ }
44
+
45
+ const t = (key) => `#${key}`
46
+
47
+ /** One paired-server row exactly as client-routes.ts's poolStatusBodyOf
48
+ * serves it (no token anywhere). */
49
+ function serverRow(over = {}) {
50
+ return {
51
+ entryId: over.serverUrl ?? 'http://a.local:3088',
52
+ serverId: 'aaaa1111',
53
+ serverUrl: over.serverUrl ?? 'http://a.local:3088',
54
+ serverName: 'A-main',
55
+ displayName: 'A-main',
56
+ state: 'online',
57
+ nextRetryAt: null,
58
+ lastError: 'offline',
59
+ compat: { identical: ['session'], different: ['workspace'], unavailable: [] },
60
+ pluginBridge: [{ plugin: BRIDGE_STAMP_PLUGIN, bridged: true, localVersion: '1.0', remoteVersion: '1.0', reason: undefined }],
61
+ ...over,
62
+ }
63
+ }
64
+
65
+ await test('the MS-6 constants name the cap and the unpair route', () => {
66
+ assert.equal(MAX_PAIRED_SERVERS, 8)
67
+ assert.equal(CLIENT_UNPAIR_ROUTE, '/_dsh/zen-remote/client/unpair')
68
+ })
69
+
70
+ await test('deriveClientStatusView parses the pool list: 0 … 8 rows, limitReached only at the cap', () => {
71
+ // An old single-server body (no servers field): empty list, default cap.
72
+ const single = deriveClientStatusView({ state: 'online', serverUrl: 'http://a.local:3088', serverName: 'A-main' })
73
+ assert.deepEqual(single.servers, [])
74
+ assert.equal(single.maxServers, 8)
75
+ assert.equal(single.limitReached, false)
76
+
77
+ // A real pool answer: rows ride through, top-level compat still parses.
78
+ const two = deriveClientStatusView({
79
+ state: 'online', serverUrl: 'http://a.local:3088', serverName: 'A-main', maxServers: 8,
80
+ servers: [
81
+ serverRow(),
82
+ serverRow({ serverId: 'bbbb2222', serverUrl: 'http://b.local:3088', serverName: 'B-backup', displayName: 'B-backup', state: 'offline', nextRetryAt: 5_000 }),
83
+ ],
84
+ })
85
+ assert.equal(two.servers.length, 2)
86
+ assert.deepEqual(two.servers[0], {
87
+ entryId: 'http://a.local:3088', serverId: 'aaaa1111', serverUrl: 'http://a.local:3088',
88
+ serverName: 'A-main', displayName: 'A-main', state: 'connected', nextRetryAt: undefined,
89
+ lastError: 'offline',
90
+ compat: { mismatchedGroups: ['workspace'], recentCalls: [] },
91
+ pluginBridge: [{ plugin: BRIDGE_STAMP_PLUGIN, localVersion: '1.0', stateKey: 'settings.bridge.stateBridged', serverVersion: undefined }],
92
+ })
93
+ assert.equal(two.servers[1].state, 'unreachable', 'the wire offline word maps like the top level does')
94
+ assert.equal(two.servers[1].nextRetryAt, 5_000)
95
+ assert.equal(two.limitReached, false)
96
+
97
+ // Eight rows: the cap is reached.
98
+ const full = deriveClientStatusView({
99
+ state: 'online', maxServers: 8,
100
+ servers: Array.from({ length: 8 }, (_, i) => serverRow({ serverId: `aaaa111${i}`, serverUrl: `http://${i}.local` })),
101
+ })
102
+ assert.equal(full.servers.length, 8)
103
+ assert.equal(full.maxServers, 8)
104
+ assert.equal(full.limitReached, true)
105
+
106
+ // The body's own maxServers decides when it carries one.
107
+ assert.equal(deriveClientStatusView({ state: 'online', maxServers: 4, servers: [serverRow(), serverRow({ serverUrl: 'b' }), serverRow({ serverUrl: 'c' }), serverRow({ serverUrl: 'd' })] }).limitReached, true)
108
+
109
+ // Garbage degrades: junk rows drop, junk fields default. A PRESENT but
110
+ // garbage compat still renders the empty group (the presence gate, like
111
+ // the top level); only an absent one stays undefined.
112
+ const junk = deriveClientStatusView({
113
+ state: 'online',
114
+ servers: ['garbage', null, { entryId: 'keep', serverId: 'aaaa1111', state: 'revoked', nextRetryAt: 'soon', compat: 'x', pluginBridge: 'x' }, 42],
115
+ })
116
+ assert.equal(junk.servers.length, 1)
117
+ assert.equal(junk.servers[0].entryId, 'keep')
118
+ assert.equal(junk.servers[0].state, 'revoked')
119
+ assert.equal(junk.servers[0].nextRetryAt, undefined)
120
+ assert.deepEqual(junk.servers[0].compat, { mismatchedGroups: [], recentCalls: [] })
121
+ assert.deepEqual(junk.servers[0].pluginBridge, [])
122
+ assert.equal(deriveClientStatusView({ state: 'online', servers: [{ entryId: 'quiet', serverId: 'aaaa1111' }] }).servers[0].compat, undefined)
123
+ assert.equal(deriveClientStatusView({ state: 'online', servers: 'garbage' }).servers.length, 0)
124
+ assert.equal(deriveClientStatusView({ state: 'online', servers: 'garbage' }).limitReached, false)
125
+ assert.equal(deriveClientStatusView({ state: 'online', maxServers: 'x' }).maxServers, 8)
126
+ })
127
+
128
+ await test('canSubmitClientPairing: new addresses respect the cap, existing ones overwrite through it', () => {
129
+ const one = [serverRow()]
130
+ const eight = Array.from({ length: 8 }, (_, i) => serverRow({ serverId: `aaaa111${i}`, serverUrl: `http://${i}.local` }))
131
+
132
+ // A clean new pairing under the cap.
133
+ assert.deepEqual(
134
+ canSubmitClientPairing({ pairUrl: 'http://new.local:3088', pairCode: 'ABCD2345', servers: one }),
135
+ { allowed: true, isOverwrite: false, blockedByLimit: false },
136
+ )
137
+
138
+ // Refusals that have nothing to do with the cap.
139
+ assert.equal(canSubmitClientPairing({ pairUrl: '', pairCode: 'ABCD2345', servers: [] }).allowed, false, 'no address, no pairing')
140
+ assert.equal(canSubmitClientPairing({ pairUrl: 'http://new.local', pairCode: '', servers: [] }).allowed, false, 'no code, no pairing')
141
+ assert.equal(canSubmitClientPairing({ pairUrl: 'http://new.local', pairCode: 'ABCD23', servers: [] }).allowed, false, 'a partial code is not a code')
142
+ assert.equal(canSubmitClientPairing({ pairUrl: 'http://new.local', pairCode: normalizePairingCode('ab-23'), servers: [] }).allowed, false)
143
+ assert.equal(canSubmitClientPairing({ pairUrl: 'http://new.local', pairCode: 'ABCD2345', servers: [], busy: true }).allowed, false, 'a claim in flight blocks a second')
144
+ assert.equal(canSubmitClientPairing({ pairUrl: 'http://new.local', pairCode: 'ABCD2345', servers: [], deviceNameInvalid: true }).allowed, false, 'an invalid device name blocks the claim (T60)')
145
+
146
+ // The cap blocks only NEW addresses.
147
+ assert.deepEqual(
148
+ canSubmitClientPairing({ pairUrl: 'http://9.local', pairCode: 'ABCD2345', servers: eight }),
149
+ { allowed: false, isOverwrite: false, blockedByLimit: true },
150
+ 'a ninth new address is refused at 8/8',
151
+ )
152
+ // Overwrite: the SAME (normalized) address pairs straight through the cap.
153
+ assert.deepEqual(
154
+ canSubmitClientPairing({ pairUrl: 'http://3.local', pairCode: 'ABCD2345', servers: eight }),
155
+ { allowed: true, isOverwrite: true, blockedByLimit: false },
156
+ 'an existing address re-pairs even at 8/8',
157
+ )
158
+ // Trailing slashes and padding do not make a new address.
159
+ assert.equal(canSubmitClientPairing({ pairUrl: ' http://3.local/ ', pairCode: 'ABCD2345', servers: eight }).isOverwrite, true)
160
+ // An explicit maxServers decides like the body's does.
161
+ assert.equal(canSubmitClientPairing({ pairUrl: 'http://z.local', pairCode: 'ABCD2345', servers: one, maxServers: 1 }).blockedByLimit, true)
162
+ assert.equal(canSubmitClientPairing({ pairUrl: 'http://a.local:3088/', pairCode: 'ABCD2345', servers: one, maxServers: 1 }).allowed, true)
163
+ })
164
+
165
+ await test('isWizardConfigured: pairedServerCount > 0 alone configures the overview', () => {
166
+ const nothingElse = {
167
+ effectiveClient: true,
168
+ wizardStatus: undefined,
169
+ pairedDeviceCount: 0,
170
+ hasSavedHostConfig: false,
171
+ tokenConfigured: false,
172
+ clientServerUrl: '',
173
+ clientState: undefined,
174
+ }
175
+ assert.equal(isWizardConfigured({ ...nothingElse, pairedServerCount: 2 }), true)
176
+ assert.equal(isWizardConfigured({ ...nothingElse, pairedServerCount: 0 }), false)
177
+ // Omitted keeps the pre-MS-6 verdict.
178
+ assert.equal(isWizardConfigured({ ...nothingElse }), false)
179
+ })
180
+
181
+ await test('deriveClientOverviewSummary: counts, cap and the two lights', () => {
182
+ // The summary consumes the DERIVED view list — the view vocabulary's state
183
+ // words (connected / unreachable / revoked), not the wire's.
184
+ const row = (state, url) => serverRow({ state, serverUrl: url, entryId: url })
185
+ // Empty pool.
186
+ assert.deepEqual(deriveClientOverviewSummary([]), {
187
+ totalCount: 0, onlineCount: 0, offlineCount: 0, revokedCount: 0,
188
+ maxServers: 8, limitReached: false, connectionLight: 'pending', serversLight: 'pending',
189
+ })
190
+ // All connected: both green.
191
+ assert.deepEqual(deriveClientOverviewSummary([row('connected', 'a'), row('connected', 'b')]), {
192
+ totalCount: 2, onlineCount: 2, offlineCount: 0, revokedCount: 0,
193
+ maxServers: 8, limitReached: false, connectionLight: 'pass', serversLight: 'pass',
194
+ })
195
+ // Mixed: offline stays amber unless a dead verdict rides along.
196
+ const mixed = deriveClientOverviewSummary([row('connected', 'a'), row('unreachable', 'b'), row('revoked', 'c')])
197
+ assert.equal(mixed.onlineCount, 1)
198
+ assert.equal(mixed.offlineCount, 1)
199
+ assert.equal(mixed.revokedCount, 1)
200
+ assert.equal(mixed.connectionLight, 'fail')
201
+ assert.equal(deriveClientOverviewSummary([row('connected', 'a'), row('unreachable', 'b')]).connectionLight, 'pending')
202
+ // The 8/8 cap is a servers fact: red servers light, green connection light.
203
+ const full = deriveClientOverviewSummary(Array.from({ length: 8 }, (_, i) => row('connected', `s${i}`)))
204
+ assert.equal(full.limitReached, true)
205
+ assert.equal(full.serversLight, 'fail')
206
+ assert.equal(full.connectionLight, 'pass')
207
+ // Seven of eight is under the cap.
208
+ assert.equal(deriveClientOverviewSummary(Array.from({ length: 7 }, (_, i) => row('connected', `s${i}`))).limitReached, false)
209
+ // An explicit maxServers.
210
+ assert.equal(deriveClientOverviewSummary([row('connected', 'a')], 1).limitReached, true)
211
+ })
212
+
213
+ await test('remote-status per-server acceptance: A online / B offline through the icon and banner', () => {
214
+ const view = parseRemoteStatusBody({
215
+ state: 'online', versionMismatch: false, serverName: 'A-main', closed: {},
216
+ bridgedPlugins: [BRIDGE_STAMP_PLUGIN],
217
+ servers: {
218
+ aaaa1111: { state: 'online', versionMismatch: false, serverName: 'A-main', bridgedPlugins: [BRIDGE_STAMP_PLUGIN] },
219
+ bbbb2222: { state: 'offline', versionMismatch: false, serverName: 'B-backup', bridgedPlugins: [] },
220
+ },
221
+ }, 1)
222
+
223
+ // The icon: A lit, B grey.
224
+ assert.deepEqual(describeRemoteStatus(view, t, 'zr~aaaa1111~s1'), { state: 'online', hoverText: '#remoteStatusOnline' })
225
+ assert.deepEqual(describeRemoteStatus(view, t, 'zr~bbbb2222~s2'), { state: 'offline', hoverText: '#remoteStatusOffline' })
226
+ // The banner: A clean, B temporarily readonly.
227
+ assert.equal(bannerText(view, 'zr~aaaa1111~s1', t), undefined)
228
+ assert.equal(bannerText(view, 'zr~bbbb2222~s2', t), '#remoteBannerOffline')
229
+ // The resolve seam the parts share: per-server entry, top-level fallback.
230
+ assert.equal(resolveServerStatusView(view, 'zr~aaaa1111~s1'), view.servers.aaaa1111)
231
+ assert.equal(resolveServerStatusView(view, 'zr~cccc3333~x'), view)
232
+ })
233
+
234
+ await test('remote-status per-server acceptance: the bridge stamp follows the session\u2019s server', () => {
235
+ const view = parseRemoteStatusBody({
236
+ state: 'online', versionMismatch: false, serverName: 'A-main', closed: {},
237
+ bridgedPlugins: [BRIDGE_STAMP_PLUGIN],
238
+ servers: {
239
+ aaaa1111: { state: 'online', versionMismatch: false, serverName: 'A-main', bridgedPlugins: [BRIDGE_STAMP_PLUGIN] },
240
+ bbbb2222: { state: 'offline', versionMismatch: false, serverName: 'B-backup', bridgedPlugins: [] },
241
+ },
242
+ }, 1)
243
+ assert.equal(shouldStampBridgeForSession(view, 'zr~aaaa1111~s1', BRIDGE_STAMP_PLUGIN), true)
244
+ assert.equal(shouldStampBridgeForSession(view, 'zr~bbbb2222~s2', BRIDGE_STAMP_PLUGIN), false)
245
+ })
246
+
247
+ console.log('check-settings-view: ok')
@@ -24,6 +24,7 @@ import {
24
24
  tunnelCheckCopy,
25
25
  tunnelFailureCopy,
26
26
  caddySnippetText,
27
+ deriveClientServerLights,
27
28
  deriveClientWizardLights,
28
29
  deriveWizardLights,
29
30
  formSeedOf,
@@ -229,6 +230,30 @@ await test('deriveClientWizardLights: reflects token and client connection state
229
230
  )
230
231
  })
231
232
 
233
+ await test('deriveClientServerLights: one paired server row drives the wizard step-3 lights (MS-8)', () => {
234
+ // A minimal pool row: the view shape deriveClientStatusView answers.
235
+ const row = (state, extra = {}) => ({
236
+ entryId: 'e1', serverId: 's1', serverUrl: 'http://x.local:3088', serverName: 'srv',
237
+ displayName: 'srv', state, nextRetryAt: undefined, lastError: '', compat: undefined, pluginBridge: [],
238
+ ...extra,
239
+ })
240
+ // Nothing selected (no pool rows at all): all three stay amber.
241
+ assert.deepEqual(deriveClientServerLights(undefined), { credential: 'pending', connection: 'pending', sync: 'pending' })
242
+ // A healthy connected row: everything green.
243
+ assert.deepEqual(deriveClientServerLights(row('connected')), { credential: 'pass', connection: 'pass', sync: 'pass' })
244
+ // Connected but the interface groups differ from the server's: sync red.
245
+ assert.deepEqual(
246
+ deriveClientServerLights(row('connected', { compat: { mismatchedGroups: ['session'], recentCalls: [] } })),
247
+ { credential: 'pass', connection: 'pass', sync: 'fail' },
248
+ )
249
+ // Revoked: the credential AND the connection are both dead, sync stays amber.
250
+ assert.deepEqual(deriveClientServerLights(row('revoked')), { credential: 'fail', connection: 'fail', sync: 'pending' })
251
+ // Offline mid-retry: the token is fine, the connection is red, sync waits.
252
+ assert.deepEqual(deriveClientServerLights(row('unreachable')), { credential: 'pass', connection: 'fail', sync: 'pending' })
253
+ // Incompatible: connection red, credential still fine.
254
+ assert.deepEqual(deriveClientServerLights(row('incompatible')), { credential: 'pass', connection: 'fail', sync: 'pending' })
255
+ })
256
+
232
257
  await test('formSeedOf: the configure forms seed from the wizard projection, not admin/status (GW-fix-3/M1)', () => {
233
258
  // The projection carries the ingress fields; admin/status does not (GW-1
234
259
  // strips them) — seeding from there would silently never backfill.
@@ -415,6 +440,14 @@ const WIZARD_KEYS = [
415
440
  'wizard.modal.saveAndNext', 'wizard.modal.finish',
416
441
  'wizard.light.probing', 'wizard.overview.manageDevices',
417
442
  'wizard.devicesModal.title', 'wizard.devicesModal.subtitle', 'wizard.devicesModal.retest',
443
+ 'wizard.overview.clientSyncLabel', 'wizard.overview.syncOk', 'wizard.overview.syncBad', 'wizard.overview.syncPending',
444
+ 'wizard.overview.clientOnlineCount', 'wizard.overview.clientOfflineCount', 'wizard.overview.clientRevokedCount',
445
+ 'wizard.overview.clientAllOnline', 'wizard.overview.clientServersLabel', 'wizard.overview.clientPairedFull',
446
+ 'wizard.overview.clientPairedNames', 'wizard.overview.limitNote',
447
+ 'wizard.overview.manageServers', 'wizard.overview.addServer',
448
+ 'wizard.serversModal.title', 'wizard.serversModal.subtitle', 'wizard.serversModal.viewStatus',
449
+ 'wizard.client.poolCapNote', 'wizard.client.continueAdd', 'wizard.client.serverPicker',
450
+ 'wizard.desktop.addressFallback', 'wizard.desktop.copiedText',
418
451
  ]
419
452
 
420
453
  // The cards' step counts are part of the walkthrough contract (GW-11).
@@ -452,6 +485,10 @@ await test('the browser snippet mirrors are byte-identical to the host templates
452
485
  assert.equal(nginxSnippetText(SNIPPET_INPUT), nginxSnippet(SNIPPET_INPUT).text)
453
486
  assert.equal(caddySnippetText(SNIPPET_INPUT), caddySnippet(SNIPPET_INPUT).text)
454
487
  assert.deepEqual(luckyStepTexts(SNIPPET_INPUT), luckySteps(SNIPPET_INPUT).steps)
488
+ const enInput = { ...SNIPPET_INPUT, lang: 'en' }
489
+ assert.equal(nginxSnippetText(enInput), nginxSnippet(enInput).text)
490
+ assert.equal(caddySnippetText(enInput), caddySnippet(enInput).text)
491
+ assert.deepEqual(luckyStepTexts(enInput), luckySteps(enInput).steps)
455
492
  // The mirrors refuse the same inputs the host refuses.
456
493
  for (const bad of [{ ...SNIPPET_INPUT, domain: 'a b' }, { ...SNIPPET_INPUT, upstreamHost: 'x/y' }, { ...SNIPPET_INPUT, upstreamPort: 0 }]) {
457
494
  assert.equal(nginxSnippetText(bad), null)
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * The dsh-better-sidebar adapter (PB-12), written against EXACTLY
3
- * versions 0.25.0, 0.26.0, 0.26.1, 0.28.0-alpha.0, 0.28.0, 0.28.1, 0.28.2 and 0.30.0 (PB-15 widened the list; 0.26.1 is a
3
+ * versions 0.25.0, 0.26.0, 0.26.1, 0.28.0-alpha.0, 0.28.0, 0.28.1, 0.28.2,
4
+ * 0.30.0 and 0.30.1 (PB-15 widened the list; 0.26.1 is a
4
5
  * perf-only patch with the same wire surface as 0.26.0; 0.26.0 is a JSON-api
5
6
  * superset of 0.25.0 — it grows fs.readPage and the sidechat dequeue/model/
6
7
  * models methods, and moves fs.write's content onto the new raw-text
7
- * /sidebar/write route). Everything the adapter knows about the plugin came
8
+ * /sidebar/write route; 0.30.1 is a wire-identical patch of 0.30.0). Everything the adapter knows about the plugin came
8
9
  * off `/tmp/bs-versions/{0.25.0,0.26.0}/lib/index.js`; the method table
9
10
  * lives in `methods.ts` beside this file, and the test suite re-extracts
10
11
  * the plugin's real method-name set (the two-version UNION) to catch a
@@ -85,7 +86,10 @@ export const dshBetterSidebarAdapter: BridgeAdapter = {
85
86
  // below on its `childId`. `sidechat.prompt` grows an `images` array (base64,
86
87
  // up to ~27.7 MiB body) — the api route cap widens to that. GenUI, quote
87
88
  // chips and editor draft backup are client-only (no new host routes).
88
- versions: ['0.25.0', '0.26.0', '0.26.1', '0.28.0-alpha.0', '0.28.0', '0.28.1', '0.28.2', '0.30.0'],
89
+ // 0.30.1: a patch release whose wire surface is byte-for-byte the 0.30.0
90
+ // generation's (digest `394de9f588d37092` — same JSON-api method set, same
91
+ // routes and WebSockets), so it lists under the same surface digest.
92
+ versions: ['0.25.0', '0.26.0', '0.26.1', '0.28.0-alpha.0', '0.28.0', '0.28.1', '0.28.2', '0.30.0', '0.30.1'],
89
93
  // PB-19 (ADR-0003): the wire-surface whitelist. Keys are digest16 over the
90
94
  // PB-18 extractor's reduction (`surface.ts` beside this file) of the npm
91
95
  // builds' lib/index.js, computed off /tmp/bs-versions/{version}/lib/index.js:
@@ -653,9 +653,13 @@ export function createAdminHandler(options: AdminHandlerOptions): AdminHandler {
653
653
  // through the gateway is refused — consent is granted on the server
654
654
  // machine, by its owner, period.
655
655
  if (req.headers['x-zen-remote-via'] !== undefined) {
656
+ const isEn = options.getConfig().values.lang === 'en'
656
657
  responseJson(res, 403, {
657
658
  ok: false,
658
- error: { code: 'via-gateway', message: '插件桥设置只能在服务端本机操作' },
659
+ error: {
660
+ code: 'via-gateway',
661
+ message: isEn ? 'Plugin bridge settings can only be modified on the server machine' : '插件桥设置只能在服务端本机操作',
662
+ },
659
663
  })
660
664
  return
661
665
  }
@@ -693,9 +697,13 @@ export function createAdminHandler(options: AdminHandlerOptions): AdminHandler {
693
697
  // same-origin (the gateway rewrites Origin/Host), so the marker is
694
698
  // the only signal that says "this did not start on this machine".
695
699
  if (req.headers['x-zen-remote-via'] !== undefined) {
700
+ const isEn = options.getConfig().values.lang === 'en'
696
701
  responseJson(res, 403, {
697
702
  ok: false,
698
- error: { code: 'via-gateway', message: '配对与设备管理只能在服务端本机操作' },
703
+ error: {
704
+ code: 'via-gateway',
705
+ message: isEn ? 'Pairing and device management are only available on the server machine' : '配对与设备管理只能在服务端本机操作',
706
+ },
699
707
  })
700
708
  return
701
709
  }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * CLI-2 command-line parsing: the whole `dsh-zen-remote` grammar in one pure
3
+ * function. It answers WHAT the user asked for — command routing, option
4
+ * values, targets — and deliberately does not VALIDATE cross-field rules
5
+ * (role gates, required targets): the command layer renders those verdicts
6
+ * through the i18n dictionary. Two things the parser does decide on its own:
7
+ * `--role peer` (and any unknown role value) is flagged `roleIssue` because
8
+ * SPEC pins the "peer is not enabled" refusal at every entry point, and
9
+ * `--debug`'s optional path argument is consumed only when it does not look
10
+ * like another flag.
11
+ *
12
+ * Global flags may appear before or after the command word; the leading
13
+ * positional tokens ARE the command (`host device revoke dev-7` → three
14
+ * command words + one target), everything flag-like is an option.
15
+ */
16
+
17
+ export type CliCommand =
18
+ | 'help'
19
+ | 'version'
20
+ | 'unknown'
21
+ | 'setup'
22
+ | 'status'
23
+ | 'host.device.pair'
24
+ | 'host.device.list'
25
+ | 'host.device.revoke'
26
+ | 'host.ingress.start'
27
+ | 'host.ingress.stop'
28
+ | 'host.ingress.verify'
29
+ | 'host.ingress.snippet'
30
+ | 'client.pair'
31
+ | 'client.unpair'
32
+ | 'client.list'
33
+
34
+ /** Why a `--role` value cannot be used as-is. `peer-not-enabled` covers the
35
+ * SPEC's reserved role; `invalid` covers everything the vocabulary does not
36
+ * know. Absent when the value is `host` or `client`. */
37
+ export type CliRoleIssue = 'peer-not-enabled' | 'invalid'
38
+
39
+ export interface CliOptions {
40
+ /** Raw `--lang` value — `resolveCliLang` (i18n.ts) interprets it later. */
41
+ lang?: string
42
+ json: boolean
43
+ yes: boolean
44
+ noWait: boolean
45
+ /** Explicit IPC socket path; discovery probes only this when present. */
46
+ socket?: string
47
+ /** Seconds to wait for DSH to come up; default 20. */
48
+ wait: number
49
+ /** `status --debug` without a path: write the default filename. */
50
+ debug: boolean
51
+ /** `status --debug <outPath>`: the exact output file. */
52
+ debugPath?: string
53
+ /** Raw `--role` value, preserved verbatim. */
54
+ role?: string
55
+ roleIssue?: CliRoleIssue
56
+ /** `--profile p1,p2` split on commas, trimmed, empties dropped. */
57
+ profile?: string[]
58
+ /** `--ingress` value (caddy | cloudflared | external | lan). */
59
+ ingress?: string
60
+ /** `--mode` — a synonym of `--ingress`, kept separately for callers that
61
+ * want to know which spelling the user used. */
62
+ mode?: string
63
+ domain?: string
64
+ cfToken?: string
65
+ email?: string
66
+ httpsPort?: number
67
+ publicUrl?: string
68
+ tunnelId?: string
69
+ proxy?: string
70
+ deviceRole?: string
71
+ hostUrl?: string
72
+ code?: string
73
+ name?: string
74
+ all: boolean
75
+ /** Unrecognized `--flags`, collected instead of crashing — the command
76
+ * layer decides whether to warn. */
77
+ unknownFlags: string[]
78
+ }
79
+
80
+ export interface ParsedCliArgs {
81
+ command: CliCommand
82
+ options: CliOptions
83
+ /** The positional argument after the command words (a device id, a host
84
+ * URL, an entry id). `--all` as the revoke/unpair target becomes
85
+ * `options.all` instead. */
86
+ target?: string
87
+ /** The unrecognized command words, joined for the error message. */
88
+ unknownCommand?: string
89
+ }
90
+
91
+ /** `--flag value` options, mapped onto CliOptions field names. */
92
+ const VALUE_OPTIONS: Readonly<Record<string, 'lang' | 'socket' | 'wait' | 'role' | 'profile' | 'ingress' | 'mode' | 'domain' | 'cfToken' | 'email' | 'httpsPort' | 'publicUrl' | 'tunnelId' | 'proxy' | 'deviceRole' | 'hostUrl' | 'code' | 'name'>> = {
93
+ '--lang': 'lang',
94
+ '--socket': 'socket',
95
+ '--wait': 'wait',
96
+ '--role': 'role',
97
+ '--profile': 'profile',
98
+ '--ingress': 'ingress',
99
+ '--mode': 'mode',
100
+ '--domain': 'domain',
101
+ '--cf-token': 'cfToken',
102
+ '--email': 'email',
103
+ '--https-port': 'httpsPort',
104
+ '--public-url': 'publicUrl',
105
+ '--tunnel-id': 'tunnelId',
106
+ '--proxy': 'proxy',
107
+ '--device-role': 'deviceRole',
108
+ '--host-url': 'hostUrl',
109
+ '--code': 'code',
110
+ '--name': 'name',
111
+ }
112
+
113
+ /** Boolean switches. `-y` is the only short alias (the one SPEC names). */
114
+ const BOOLEAN_OPTIONS: Readonly<Record<string, 'json' | 'yes' | 'noWait' | 'all'>> = {
115
+ '--json': 'json',
116
+ '--yes': 'yes',
117
+ '-y': 'yes',
118
+ '--no-wait': 'noWait',
119
+ '--all': 'all',
120
+ }
121
+
122
+ const KNOWN_ROLES = new Set(['host', 'client', 'peer'])
123
+
124
+ function defaultOptions(): CliOptions {
125
+ return {
126
+ json: false,
127
+ yes: false,
128
+ noWait: false,
129
+ wait: 20,
130
+ debug: false,
131
+ all: false,
132
+ unknownFlags: [],
133
+ }
134
+ }
135
+
136
+ function assignValue(field: (typeof VALUE_OPTIONS)[string], raw: string, options: CliOptions): void {
137
+ switch (field) {
138
+ case 'wait': {
139
+ const seconds = Number(raw)
140
+ if (Number.isFinite(seconds) && seconds >= 0) options.wait = seconds
141
+ // A garbage value keeps the 20s default instead of poisoning the wait.
142
+ return
143
+ }
144
+ case 'profile': {
145
+ const names = raw.split(',').map((name) => name.trim()).filter((name) => name !== '')
146
+ options.profile = names
147
+ return
148
+ }
149
+ case 'httpsPort': {
150
+ const port = Number(raw)
151
+ if (Number.isInteger(port) && port > 0) options.httpsPort = port
152
+ return
153
+ }
154
+ case 'role': {
155
+ options.role = raw
156
+ if (raw === 'peer') options.roleIssue = 'peer-not-enabled'
157
+ else if (!KNOWN_ROLES.has(raw)) options.roleIssue = 'invalid'
158
+ return
159
+ }
160
+ case 'lang': options.lang = raw; return
161
+ case 'socket': options.socket = raw; return
162
+ case 'ingress': options.ingress = raw; return
163
+ case 'mode': options.mode = raw; return
164
+ case 'domain': options.domain = raw; return
165
+ case 'cfToken': options.cfToken = raw; return
166
+ case 'email': options.email = raw; return
167
+ case 'publicUrl': options.publicUrl = raw; return
168
+ case 'tunnelId': options.tunnelId = raw; return
169
+ case 'proxy': options.proxy = raw; return
170
+ case 'deviceRole': options.deviceRole = raw; return
171
+ case 'hostUrl': options.hostUrl = raw; return
172
+ case 'code': options.code = raw; return
173
+ case 'name': options.name = raw; return
174
+ }
175
+ }
176
+
177
+ function setBooleanFlag(field: (typeof BOOLEAN_OPTIONS)[string], options: CliOptions): void {
178
+ switch (field) {
179
+ case 'json': options.json = true; return
180
+ case 'yes': options.yes = true; return
181
+ case 'noWait': options.noWait = true; return
182
+ case 'all': options.all = true; return
183
+ }
184
+ }
185
+
186
+ /** Route the leading positional words into a command. Returns `undefined`
187
+ * when the words name a valid command with a target slot still open
188
+ * (`host device revoke …`), and a full verdict otherwise. */
189
+ function routeCommand(positionals: readonly string[], options: CliOptions): ParsedCliArgs {
190
+ const c1 = positionals[0]
191
+ const c2 = positionals[1]
192
+ const c3 = positionals[2]
193
+ const unknown = (words: readonly (string | undefined)[]): ParsedCliArgs => ({
194
+ command: 'unknown',
195
+ options,
196
+ unknownCommand: words.filter((word) => word !== undefined).join(' '),
197
+ })
198
+
199
+ if (c1 === 'setup') {
200
+ // `setup <hostUrl>` — the client branch's pairing address as a positional
201
+ // (equivalent to `--host-url`; the flag still wins when both appear).
202
+ return { command: 'setup', options, ...(c2 === undefined ? {} : { target: c2 }) }
203
+ }
204
+ if (c1 === 'status') return { command: 'status', options }
205
+
206
+ if (c1 === 'host') {
207
+ if (c2 === 'device' && (c3 === 'pair' || c3 === 'list' || c3 === 'revoke')) {
208
+ // `revoke <id|--all>` is the only device verb with a target slot; an
209
+ // `--all` positional means the flag, not a device literally named
210
+ // "--all".
211
+ let target = c3 === 'revoke' ? positionals[3] : undefined
212
+ if (target === '--all') {
213
+ options.all = true
214
+ target = undefined
215
+ }
216
+ return { command: `host.device.${c3}`, options, ...(target === undefined ? {} : { target }) }
217
+ }
218
+ if (c2 === 'ingress' && (c3 === 'start' || c3 === 'stop' || c3 === 'verify' || c3 === 'snippet')) {
219
+ return { command: `host.ingress.${c3}`, options }
220
+ }
221
+ return unknown([c1, c2, c3])
222
+ }
223
+
224
+ if (c1 === 'client') {
225
+ if (c2 === 'pair' || c2 === 'unpair') {
226
+ const target = positionals[2]
227
+ return { command: `client.${c2}`, options, ...(target === undefined ? {} : { target }) }
228
+ }
229
+ if (c2 === 'list') return { command: 'client.list', options }
230
+ return unknown([c1, c2])
231
+ }
232
+
233
+ return unknown([c1])
234
+ }
235
+
236
+ /**
237
+ * Parse `argv` (without the node binary and script path) into a structured
238
+ * command. `--help` / `-h` anywhere wins over everything, then `--version` /
239
+ * `-v`; flags with no command still route to `help` (running the binary bare
240
+ * must not fall into the interactive wizard).
241
+ */
242
+ export function parseCliArgs(argv: readonly string[]): ParsedCliArgs {
243
+ const options = defaultOptions()
244
+ const positionals: string[] = []
245
+ let index = 0
246
+ while (index < argv.length) {
247
+ const token = argv[index]
248
+ if (token === undefined) break
249
+ index += 1
250
+ if (token === '--help' || token === '-h' || token === '--version' || token === '-v') {
251
+ continue
252
+ }
253
+ if (token === '--debug') {
254
+ // `--debug [outPath]`: a following non-flag token is the output path,
255
+ // anything else (nothing, another flag) means the default filename.
256
+ const next = argv[index]
257
+ if (next !== undefined && !next.startsWith('-')) {
258
+ options.debugPath = next
259
+ index += 1
260
+ } else {
261
+ options.debug = true
262
+ }
263
+ continue
264
+ }
265
+ const booleanField = BOOLEAN_OPTIONS[token]
266
+ if (booleanField !== undefined) {
267
+ setBooleanFlag(booleanField, options)
268
+ continue
269
+ }
270
+ const valueField = VALUE_OPTIONS[token]
271
+ if (valueField !== undefined) {
272
+ const next = argv[index]
273
+ if (next === undefined || next.startsWith('-')) continue // missing value — leave the field unset
274
+ index += 1
275
+ assignValue(valueField, next, options)
276
+ continue
277
+ }
278
+ if (token.startsWith('-') && token.length > 1) {
279
+ options.unknownFlags.push(token)
280
+ continue
281
+ }
282
+ positionals.push(token)
283
+ }
284
+
285
+ if (argv.includes('--help') || argv.includes('-h')) return { command: 'help', options }
286
+ if (argv.includes('--version') || argv.includes('-v')) return { command: 'version', options }
287
+ if (positionals.length === 0) return { command: 'help', options }
288
+ return routeCommand(positionals, options)
289
+ }