dsh-zen-remote 2.3.11 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/CHANGELOG.md +45 -78
  2. package/README.md +94 -84
  3. package/README.zh-CN.md +90 -81
  4. package/bin/zen-remote.mjs +5 -0
  5. package/docs/interface.md +6 -6
  6. package/docs/remote-access.en.md +6 -4
  7. package/docs/remote-access.md +6 -4
  8. package/dsh-push.mjs +96 -10
  9. package/lib/adapters/dsh-better-sidebar/index.js +7 -3
  10. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  11. package/lib/admin-routes.js +10 -2
  12. package/lib/admin-routes.js.map +1 -1
  13. package/lib/cli/args.js +251 -0
  14. package/lib/cli/args.js.map +1 -0
  15. package/lib/cli/bootstrap.js +449 -0
  16. package/lib/cli/bootstrap.js.map +1 -0
  17. package/lib/cli/command-setup.js +484 -0
  18. package/lib/cli/command-setup.js.map +1 -0
  19. package/lib/cli/commands-client.js +359 -0
  20. package/lib/cli/commands-client.js.map +1 -0
  21. package/lib/cli/commands-host.js +1108 -0
  22. package/lib/cli/commands-host.js.map +1 -0
  23. package/lib/cli/commands-status.js +279 -0
  24. package/lib/cli/commands-status.js.map +1 -0
  25. package/lib/cli/config-writer.js +334 -0
  26. package/lib/cli/config-writer.js.map +1 -0
  27. package/lib/cli/context.js +10 -0
  28. package/lib/cli/context.js.map +1 -0
  29. package/lib/cli/i18n.js +620 -0
  30. package/lib/cli/i18n.js.map +1 -0
  31. package/lib/cli/ipc-client.js +160 -0
  32. package/lib/cli/ipc-client.js.map +1 -0
  33. package/lib/cli/ipc-server.js +464 -0
  34. package/lib/cli/ipc-server.js.map +1 -0
  35. package/lib/cli/main.js +245 -0
  36. package/lib/cli/main.js.map +1 -0
  37. package/lib/cli/qr-terminal.js +48 -0
  38. package/lib/cli/qr-terminal.js.map +1 -0
  39. package/lib/client-data/qr.js +286 -0
  40. package/lib/client-data/qr.js.map +1 -0
  41. package/lib/client-routes.js +308 -25
  42. package/lib/client-routes.js.map +1 -1
  43. package/lib/client.js +1013 -229
  44. package/lib/config.js +50 -12
  45. package/lib/config.js.map +1 -1
  46. package/lib/debug-report.js +28 -1
  47. package/lib/debug-report.js.map +1 -1
  48. package/lib/fetch-route-intercept.js +334 -26
  49. package/lib/fetch-route-intercept.js.map +1 -1
  50. package/lib/index.js +608 -375
  51. package/lib/index.js.map +1 -1
  52. package/lib/intercept.js +1833 -280
  53. package/lib/intercept.js.map +1 -1
  54. package/lib/lan-gate-server.cjs +36 -10
  55. package/lib/merge-streams.js +103 -11
  56. package/lib/merge-streams.js.map +1 -1
  57. package/lib/plugin-route-intercept.js +93 -63
  58. package/lib/plugin-route-intercept.js.map +1 -1
  59. package/lib/relay-access.js +8 -1
  60. package/lib/relay-access.js.map +1 -1
  61. package/lib/relay-client.js +133 -4
  62. package/lib/relay-client.js.map +1 -1
  63. package/lib/relay-pool.js +275 -0
  64. package/lib/relay-pool.js.map +1 -0
  65. package/lib/relay-server.js +269 -17
  66. package/lib/relay-server.js.map +1 -1
  67. package/lib/restart-fields.js +6 -4
  68. package/lib/restart-fields.js.map +1 -1
  69. package/lib/share-ops.js +68 -0
  70. package/lib/share-ops.js.map +1 -1
  71. package/lib/share-store.js +65 -27
  72. package/lib/share-store.js.map +1 -1
  73. package/lib/state-store.js +400 -0
  74. package/lib/state-store.js.map +1 -0
  75. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
  76. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  77. package/lib/types/admin-routes.d.ts.map +1 -1
  78. package/lib/types/cli/args.d.ts +78 -0
  79. package/lib/types/cli/args.d.ts.map +1 -0
  80. package/lib/types/cli/bootstrap.d.ts +170 -0
  81. package/lib/types/cli/bootstrap.d.ts.map +1 -0
  82. package/lib/types/cli/command-setup.d.ts +72 -0
  83. package/lib/types/cli/command-setup.d.ts.map +1 -0
  84. package/lib/types/cli/commands-client.d.ts +36 -0
  85. package/lib/types/cli/commands-client.d.ts.map +1 -0
  86. package/lib/types/cli/commands-host.d.ts +82 -0
  87. package/lib/types/cli/commands-host.d.ts.map +1 -0
  88. package/lib/types/cli/commands-status.d.ts +46 -0
  89. package/lib/types/cli/commands-status.d.ts.map +1 -0
  90. package/lib/types/cli/config-writer.d.ts +92 -0
  91. package/lib/types/cli/config-writer.d.ts.map +1 -0
  92. package/lib/types/cli/context.d.ts +39 -0
  93. package/lib/types/cli/context.d.ts.map +1 -0
  94. package/lib/types/cli/i18n.d.ts +32 -0
  95. package/lib/types/cli/i18n.d.ts.map +1 -0
  96. package/lib/types/cli/ipc-client.d.ts +83 -0
  97. package/lib/types/cli/ipc-client.d.ts.map +1 -0
  98. package/lib/types/cli/ipc-server.d.ts +113 -0
  99. package/lib/types/cli/ipc-server.d.ts.map +1 -0
  100. package/lib/types/cli/main.d.ts +57 -0
  101. package/lib/types/cli/main.d.ts.map +1 -0
  102. package/lib/types/cli/qr-terminal.d.ts +17 -0
  103. package/lib/types/cli/qr-terminal.d.ts.map +1 -0
  104. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
  105. package/lib/types/client/current-session-report.d.ts +4 -0
  106. package/lib/types/client/current-session-report.d.ts.map +1 -1
  107. package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
  108. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  109. package/lib/types/client/locales.d.ts +47 -3
  110. package/lib/types/client/locales.d.ts.map +1 -1
  111. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  112. package/lib/types/client/settings/settings-css.d.ts +1 -1
  113. package/lib/types/client/settings/settings-css.d.ts.map +1 -1
  114. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  115. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  116. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
  117. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
  118. package/lib/types/client/wizard/wizard-css.d.ts +1 -1
  119. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
  120. package/lib/types/client-data/model-group-side.d.ts +23 -2
  121. package/lib/types/client-data/model-group-side.d.ts.map +1 -1
  122. package/lib/types/client-data/remote-status.d.ts +47 -1
  123. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  124. package/lib/types/client-data/settings-form.d.ts +85 -1
  125. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  126. package/lib/types/client-data/wizard.d.ts +56 -1
  127. package/lib/types/client-data/wizard.d.ts.map +1 -1
  128. package/lib/types/client-routes.d.ts +25 -1
  129. package/lib/types/client-routes.d.ts.map +1 -1
  130. package/lib/types/config.d.ts +40 -14
  131. package/lib/types/config.d.ts.map +1 -1
  132. package/lib/types/debug-report.d.ts.map +1 -1
  133. package/lib/types/fetch-route-intercept.d.ts +66 -8
  134. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  135. package/lib/types/index.d.ts +9 -2
  136. package/lib/types/index.d.ts.map +1 -1
  137. package/lib/types/intercept.d.ts +72 -10
  138. package/lib/types/intercept.d.ts.map +1 -1
  139. package/lib/types/merge-streams.d.ts +79 -1
  140. package/lib/types/merge-streams.d.ts.map +1 -1
  141. package/lib/types/plugin-route-intercept.d.ts +25 -9
  142. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  143. package/lib/types/relay-access.d.ts +3 -1
  144. package/lib/types/relay-access.d.ts.map +1 -1
  145. package/lib/types/relay-client.d.ts +23 -2
  146. package/lib/types/relay-client.d.ts.map +1 -1
  147. package/lib/types/relay-pool.d.ts +148 -0
  148. package/lib/types/relay-pool.d.ts.map +1 -0
  149. package/lib/types/relay-server.d.ts +30 -3
  150. package/lib/types/relay-server.d.ts.map +1 -1
  151. package/lib/types/restart-fields.d.ts +5 -5
  152. package/lib/types/restart-fields.d.ts.map +1 -1
  153. package/lib/types/share-ops.d.ts +47 -1
  154. package/lib/types/share-ops.d.ts.map +1 -1
  155. package/lib/types/share-store.d.ts.map +1 -1
  156. package/lib/types/state-store.d.ts +179 -0
  157. package/lib/types/state-store.d.ts.map +1 -0
  158. package/lib/types/upload-limits.d.ts +15 -0
  159. package/lib/types/upload-limits.d.ts.map +1 -1
  160. package/lib/types/virtual-id.d.ts +21 -0
  161. package/lib/types/virtual-id.d.ts.map +1 -1
  162. package/lib/types/wizard/probe.d.ts.map +1 -1
  163. package/lib/types/wizard/proxy-snippets.d.ts +3 -0
  164. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
  165. package/lib/types/wizard/routes.d.ts +1 -0
  166. package/lib/types/wizard/routes.d.ts.map +1 -1
  167. package/lib/types/wizard/status.d.ts +9 -0
  168. package/lib/types/wizard/status.d.ts.map +1 -1
  169. package/lib/types/wizard/verify.d.ts +3 -0
  170. package/lib/types/wizard/verify.d.ts.map +1 -1
  171. package/lib/upload-limits.js +15 -0
  172. package/lib/upload-limits.js.map +1 -1
  173. package/lib/virtual-id.js +60 -0
  174. package/lib/virtual-id.js.map +1 -1
  175. package/lib/wizard/probe.js +46 -1
  176. package/lib/wizard/probe.js.map +1 -1
  177. package/lib/wizard/proxy-snippets.js +14 -1
  178. package/lib/wizard/proxy-snippets.js.map +1 -1
  179. package/lib/wizard/routes.js +23 -4
  180. package/lib/wizard/routes.js.map +1 -1
  181. package/lib/wizard/status.js +21 -0
  182. package/lib/wizard/status.js.map +1 -1
  183. package/lib/wizard/verify.js +31 -8
  184. package/lib/wizard/verify.js.map +1 -1
  185. package/package.json +8 -4
  186. package/pwa/icons/badge-96.png +0 -0
  187. package/pwa/icons/badge.svg +1 -0
  188. package/pwa/manifest.json +2 -1
  189. package/pwa/sw.js +3 -1
  190. package/scripts/check-debug-report.mjs +41 -1
  191. package/scripts/check-settings-form.mjs +14 -3
  192. package/scripts/check-settings-view.mjs +247 -0
  193. package/scripts/check-wizard-ui.mjs +37 -0
  194. package/src/adapters/dsh-better-sidebar/index.ts +7 -3
  195. package/src/admin-routes.ts +10 -2
  196. package/src/cli/args.ts +289 -0
  197. package/src/cli/bootstrap.ts +569 -0
  198. package/src/cli/command-setup.ts +540 -0
  199. package/src/cli/commands-client.ts +369 -0
  200. package/src/cli/commands-host.ts +1127 -0
  201. package/src/cli/commands-status.ts +296 -0
  202. package/src/cli/config-writer.ts +354 -0
  203. package/src/cli/context.ts +38 -0
  204. package/src/cli/i18n.ts +645 -0
  205. package/src/cli/ipc-client.ts +216 -0
  206. package/src/cli/ipc-server.ts +531 -0
  207. package/src/cli/main.ts +285 -0
  208. package/src/cli/qr-terminal.ts +49 -0
  209. package/src/client/RemoteStatusIcon.tsx +4 -1
  210. package/src/client/current-session-report.ts +28 -4
  211. package/src/client/effects/model-group-side.ts +19 -5
  212. package/src/client/effects/remote-session.ts +26 -6
  213. package/src/client/locales.ts +96 -6
  214. package/src/client/settings/SettingsSection.tsx +287 -92
  215. package/src/client/settings/settings-css.ts +18 -0
  216. package/src/client/styles/remote-session.css.ts +15 -1
  217. package/src/client/wizard/GatewayWizard.tsx +470 -109
  218. package/src/client/wizard/cards/ProxyCard.tsx +2 -1
  219. package/src/client/wizard/wizard-css.ts +23 -0
  220. package/src/client-data/model-group-side.ts +37 -10
  221. package/src/client-data/remote-status.ts +112 -17
  222. package/src/client-data/settings-form.ts +164 -8
  223. package/src/client-data/wizard.ts +112 -2
  224. package/src/client-routes.ts +334 -26
  225. package/src/config.ts +62 -16
  226. package/src/debug-report.ts +27 -1
  227. package/src/fetch-route-intercept.ts +381 -32
  228. package/src/index.ts +608 -380
  229. package/src/intercept.ts +1866 -286
  230. package/src/merge-streams.ts +155 -10
  231. package/src/plugin-route-intercept.ts +120 -71
  232. package/src/relay-access.ts +14 -3
  233. package/src/relay-client.ts +163 -5
  234. package/src/relay-pool.ts +387 -0
  235. package/src/relay-server.ts +312 -16
  236. package/src/restart-fields.ts +6 -4
  237. package/src/share-ops.ts +82 -1
  238. package/src/share-store.ts +59 -26
  239. package/src/state-store.ts +428 -0
  240. package/src/upload-limits.ts +16 -0
  241. package/src/virtual-id.ts +57 -0
  242. package/src/wizard/probe.ts +54 -2
  243. package/src/wizard/proxy-snippets.ts +17 -2
  244. package/src/wizard/routes.ts +24 -5
  245. package/src/wizard/status.ts +28 -0
  246. package/src/wizard/verify.ts +33 -11
  247. package/AGENTS.md +0 -185
  248. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
  249. package/docs/adr/0002-relay-emulates-websocket.md +0 -8
@@ -0,0 +1,296 @@
1
+ /**
2
+ * CLI-3: the `status` command — one identity read (ping) plus whatever the
3
+ * role actually answers, rendered either as pretty JSON (`--json`) or as
4
+ * bilingual terminal lines. `--debug` is its own branch: fetch the plugin
5
+ * process's redacted diagnostic report over IPC and write it to a file
6
+ * (explicit path or a `zen-remote-debug-<role>-<yyyymmdd-hhmm>.json` in the
7
+ * working directory).
8
+ *
9
+ * Per SPEC the IPC channel only carries structured data — every terminal
10
+ * string is rendered here through the translator. The per-server line
11
+ * renderer is shared with `commands-client.ts` (`client list` prints the
12
+ * same rows), so the two surfaces cannot drift.
13
+ */
14
+
15
+ import { writeFileSync } from 'node:fs'
16
+ import { join } from 'node:path'
17
+ import { ADMIN_STATUS_ROUTE, ADMIN_SHARES_ROUTE } from '../admin-routes.js'
18
+ import { WIZARD_STATUS_ROUTE } from '../wizard/routes.js'
19
+ import { CLIENT_STATUS_ROUTE } from '../client-routes.js'
20
+ import type { CliIpcRequestResult } from './ipc-client.js'
21
+ import type { CliPingResponse } from './ipc-client.js'
22
+ import type { CliTranslator } from './i18n.js'
23
+ import type { CliExecutionContext } from './context.js'
24
+
25
+ /** One pool `servers` row (client/status), defensively typed — the command
26
+ * layer renders whatever shape arrived, it never trusts it. */
27
+ export type ServerRow = Record<string, unknown>
28
+
29
+ /**
30
+ * Fetch one JSON object over IPC; every failure mode (transport error,
31
+ * error status, non-object body) is `null` — a broken surface means "no
32
+ * information" for a status view, never a failed command.
33
+ */
34
+ export async function fetchBody(run: () => Promise<CliIpcRequestResult>): Promise<ServerRow | null> {
35
+ try {
36
+ const result = await run()
37
+ if (result.status < 200 || result.status >= 300) return null
38
+ return plainOf(result.body)
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+
44
+ /** One plain JSON object, or null. */
45
+ export function plainOf(value: unknown): ServerRow | null {
46
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
47
+ ? (value as ServerRow)
48
+ : null
49
+ }
50
+
51
+ function stringOf(value: unknown): string {
52
+ return typeof value === 'string' ? value : ''
53
+ }
54
+
55
+ function arrayOf(value: unknown): unknown[] {
56
+ return Array.isArray(value) ? value : []
57
+ }
58
+
59
+ // ===========================================================================
60
+ // Shared row rendering (status + client list)
61
+ // ===========================================================================
62
+
63
+ /** The connection-state label: the three common states translated, the rest
64
+ * (revoked / incompatible / unpaired) each with their own word. */
65
+ export function stateLabelOf(t: CliTranslator, value: unknown): string {
66
+ switch (value) {
67
+ case 'online': return t('status.online')
68
+ case 'connecting': return t('status.connecting')
69
+ case 'offline': return t('status.offline')
70
+ case 'revoked': return t('status.state.revoked')
71
+ case 'incompatible': return t('status.state.incompatible')
72
+ case 'unpaired': return t('status.unpaired')
73
+ default: return stringOf(value) === '' ? t('status.offline') : String(value)
74
+ }
75
+ }
76
+
77
+ /** The fingerprint-compat half of the compat/bridge column. */
78
+ export function compatLabelOf(t: CliTranslator, value: unknown): string {
79
+ const compat = plainOf(value)
80
+ if (compat === null) return t('status.client.compatUnavailable')
81
+ const different = arrayOf(compat.different).length
82
+ if (different > 0) return t('status.client.compatDiff', { count: different })
83
+ if (arrayOf(compat.unavailable).length > 0) return t('status.client.compatUnavailable')
84
+ return t('status.client.compatOk')
85
+ }
86
+
87
+ /** The bridged-plugin half: how many of the row's plugins are bridged. */
88
+ export function bridgeLabelOf(t: CliTranslator, value: unknown): string {
89
+ const plugins = arrayOf(value)
90
+ const bridged = plugins.filter((entry) => plainOf(entry)?.bridged === true).length
91
+ return t('status.client.bridgeCount', { count: bridged })
92
+ }
93
+
94
+ /** One paired-host row, the exact shape both surfaces print:
95
+ * `[entryId] displayName serverUrl state compat/bridge`. */
96
+ export function serverLineText(t: CliTranslator, row: ServerRow): string {
97
+ const name = stringOf(row.displayName) !== '' ? stringOf(row.displayName) : stringOf(row.serverName)
98
+ return `[${stringOf(row.entryId)}] ${name} ${stringOf(row.serverUrl)} ${stateLabelOf(t, row.state)} ${compatLabelOf(t, row.compat)} · ${bridgeLabelOf(t, row.pluginBridge)}`
99
+ }
100
+
101
+ // ===========================================================================
102
+ // The status command
103
+ // ===========================================================================
104
+
105
+ function defaultDebugPath(role: string, now: Date, cwd: string): string {
106
+ const pad = (value: number): string => String(value).padStart(2, '0')
107
+ const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`
108
+ return join(cwd, `zen-remote-debug-${role}-${stamp}.json`)
109
+ }
110
+
111
+ /** The `--debug` branch: fetch the redacted report and land it in a file.
112
+ * With `--json` the answer also prints as `{ ok, path, report }`; the
113
+ * bilingual completion line prints either way. */
114
+ async function debugReportFlow(ctx: CliExecutionContext, ping: CliPingResponse): Promise<number> {
115
+ const { t, ipc, args } = ctx
116
+ const out = ctx.stdout
117
+ let report: unknown
118
+ try {
119
+ report = await ipc.getDebugReport()
120
+ } catch (error) {
121
+ out.write(`${t('status.debugFailed', { error: error instanceof Error ? error.message : String(error) })}\n`)
122
+ return 1
123
+ }
124
+ // The route answers HTTP error statuses as JSON envelopes ({ ok: false,
125
+ // error }) — a 503 no-handler or a 404 must never land in a file dressed
126
+ // up as a report (CLI-3-fix). Only a real report shape proceeds to disk.
127
+ const reportRecord = plainOf(report)
128
+ if (reportRecord === null || reportRecord.ok === false || !('_meta' in reportRecord)) {
129
+ const error = plainOf(reportRecord?.error)
130
+ const detail = typeof error?.code === 'string'
131
+ ? error.code
132
+ : reportRecord === null ? 'not an object' : 'missing _meta'
133
+ out.write(`${t('status.debugFailed', { error: detail })}\n`)
134
+ return 1
135
+ }
136
+ const outPath = args.options.debugPath ?? defaultDebugPath(ping.role, ctx.now?.() ?? new Date(), ctx.cwd ?? process.cwd())
137
+ try {
138
+ writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
139
+ } catch (error) {
140
+ out.write(`${t('status.debugWriteFailed', { error: error instanceof Error ? error.message : String(error) })}\n`)
141
+ return 1
142
+ }
143
+ if (args.options.json) {
144
+ out.write(`${JSON.stringify({ ok: true, path: outPath, report }, null, 2)}\n`)
145
+ }
146
+ out.write(`${t('status.debugWritten', { path: outPath })}\n`)
147
+ return 0
148
+ }
149
+
150
+ /** The `host` JSON section: sanitized summary of admin/status + wizard/status
151
+ * + the share count. */
152
+ function hostJsonSection(admin: ServerRow | null, wizard: ServerRow | null, shares: ServerRow | null): ServerRow {
153
+ const gateway = admin === null ? null : plainOf(admin.gateway)
154
+ const reachable = admin?.gatewayReachable === true
155
+ const port = typeof gateway?.port === 'number'
156
+ ? gateway.port
157
+ : typeof wizard?.gatewayActualPort === 'number' ? wizard.gatewayActualPort : null
158
+ const devices = gateway === null ? [] : arrayOf(gateway.devices)
159
+ const deviceList = devices
160
+ .map((device) => plainOf(device))
161
+ .filter((device): device is ServerRow => device !== null)
162
+ .map((device) => ({ name: stringOf(device.name), role: stringOf(device.role) }))
163
+ return {
164
+ gateway: { reachable, port },
165
+ ingress: {
166
+ mode: stringOf(wizard?.mode) === '' ? 'off' : stringOf(wizard?.mode),
167
+ publicUrl: stringOf(wizard?.publicUrl),
168
+ caddyRunning: wizard?.caddyRunning === true,
169
+ cloudflaredRunning: wizard?.cloudflaredRunning === true,
170
+ },
171
+ devices: { count: deviceList.length, list: deviceList },
172
+ sharedSessions: arrayOf(shares?.shares).length,
173
+ }
174
+ }
175
+
176
+ /** The terminal host section. Every read is defensive: a surface that failed
177
+ * to answer simply prints nothing instead of a broken line. */
178
+ function renderHostSection(t: CliTranslator, admin: ServerRow | null, wizard: ServerRow | null, shares: ServerRow | null): string[] {
179
+ const lines: string[] = [t('status.host.title')]
180
+ const gateway = admin === null ? null : plainOf(admin.gateway)
181
+ if (admin?.gatewayReachable === true && gateway !== null) {
182
+ const port = typeof gateway.port === 'number' ? gateway.port : wizard?.gatewayActualPort
183
+ lines.push(` ${typeof port === 'number' ? t('status.host.gatewayUp', { port }) : t('status.host.gatewayUpNoPort')}`)
184
+ } else {
185
+ lines.push(` ${t('status.host.gatewayDown')}`)
186
+ }
187
+ const mode = stringOf(wizard?.mode) === '' ? 'off' : stringOf(wizard?.mode)
188
+ lines.push(` ${t('status.host.ingress', { mode })}`)
189
+ const publicUrl = stringOf(wizard?.publicUrl)
190
+ if (publicUrl !== '') lines.push(` ${t('status.host.publicUrl', { publicUrl })}`)
191
+ if (mode === 'caddy') {
192
+ lines.push(` ${t('status.host.processCaddy', { state: wizard?.caddyRunning === true ? t('status.process.running') : t('status.process.stopped') })}`)
193
+ }
194
+ if (mode === 'cloudflared') {
195
+ lines.push(` ${t('status.host.processTunnel', { state: wizard?.cloudflaredRunning === true ? t('status.process.running') : t('status.process.stopped') })}`)
196
+ }
197
+ const devices = gateway === null ? [] : arrayOf(gateway.devices)
198
+ lines.push(` ${t('status.host.devices', { count: devices.length })}`)
199
+ for (const device of devices) {
200
+ const record = plainOf(device)
201
+ if (record === null) continue
202
+ const name = stringOf(record.name)
203
+ if (name === '') continue
204
+ lines.push(t('status.host.deviceLine', { name, deviceRole: stringOf(record.role) }))
205
+ }
206
+ lines.push(` ${t('status.host.sharedSessions', { count: arrayOf(shares?.shares).length })}`)
207
+ return lines
208
+ }
209
+
210
+ /** The terminal client section: the count line, the limit warning at 8/8,
211
+ * one row per paired host, and the last error of a troubled row. */
212
+ function renderClientSection(t: CliTranslator, client: ServerRow): string[] {
213
+ const lines: string[] = [t('status.client.title')]
214
+ const servers = arrayOf(client.servers)
215
+ .map((row) => plainOf(row))
216
+ .filter((row): row is ServerRow => row !== null)
217
+ const max = typeof client.maxServers === 'number' ? client.maxServers : 8
218
+ lines.push(` ${t('status.client.pairedCount', { count: servers.length, max })}`)
219
+ if (servers.length >= max) lines.push(` ${t('client.limitReached')}`)
220
+ for (const row of servers) {
221
+ lines.push(` ${serverLineText(t, row)}`)
222
+ const lastError = stringOf(row.lastError)
223
+ if (lastError !== '') lines.push(` ${t('status.client.lastError', { error: lastError })}`)
224
+ }
225
+ return lines
226
+ }
227
+
228
+ /** The `--json` body: identity always, the sections the role answers. */
229
+ function statusJsonBody(
230
+ ping: CliPingResponse,
231
+ admin: ServerRow | null,
232
+ wizard: ServerRow | null,
233
+ shares: ServerRow | null,
234
+ client: ServerRow | null,
235
+ ): ServerRow {
236
+ const body: ServerRow = {
237
+ experimental: true,
238
+ role: ping.role,
239
+ pluginVersion: ping.pluginVersion,
240
+ pid: ping.pid,
241
+ serverName: ping.serverName,
242
+ }
243
+ if (ping.serves) body.host = hostJsonSection(admin, wizard, shares)
244
+ if (ping.connects && client !== null) {
245
+ body.client = { maxServers: typeof client.maxServers === 'number' ? client.maxServers : 8, servers: arrayOf(client.servers) }
246
+ }
247
+ return body
248
+ }
249
+
250
+ /**
251
+ * `dsh-zen-remote status [--json] [--debug [outPath]]`. Identity from one
252
+ * ping; the host surfaces (admin status, wizard status, share list) and the
253
+ * client pool status each fetched only when the role answers them. Exit
254
+ * codes: 0 on any answered status, 1 when the ping itself or the debug
255
+ * round-trip failed.
256
+ */
257
+ export async function runStatusCommand(ctx: CliExecutionContext): Promise<number> {
258
+ const { t, ipc, args } = ctx
259
+ const out = ctx.stdout
260
+ let ping: CliPingResponse
261
+ try {
262
+ ping = await ipc.ping()
263
+ } catch {
264
+ out.write(`${t('status.notRunning')}\n`)
265
+ return 1
266
+ }
267
+
268
+ if (args.options.debug || args.options.debugPath !== undefined) {
269
+ return await debugReportFlow(ctx, ping)
270
+ }
271
+
272
+ // The role decides which surfaces exist; fetches run concurrently and any
273
+ // failure degrades to "no information" rather than failing the command.
274
+ const [admin, wizard, shares, client] = await Promise.all([
275
+ ping.serves ? fetchBody(() => ipc.request('GET', ADMIN_STATUS_ROUTE)) : Promise.resolve(null),
276
+ ping.serves ? fetchBody(() => ipc.request('GET', WIZARD_STATUS_ROUTE)) : Promise.resolve(null),
277
+ ping.serves ? fetchBody(() => ipc.request('GET', ADMIN_SHARES_ROUTE)) : Promise.resolve(null),
278
+ ping.connects ? fetchBody(() => ipc.request('GET', CLIENT_STATUS_ROUTE)) : Promise.resolve(null),
279
+ ])
280
+
281
+ if (args.options.json) {
282
+ out.write(`${JSON.stringify(statusJsonBody(ping, admin, wizard, shares, client), null, 2)}\n`)
283
+ return 0
284
+ }
285
+
286
+ const lines = [
287
+ t('status.line.role', { role: t(`status.role.${ping.role}`) }),
288
+ t('status.machineName', { name: ping.serverName }),
289
+ t('status.pluginVersion', { version: ping.pluginVersion }),
290
+ ]
291
+ if (ping.profileName !== undefined) lines.push(t('status.profile', { profile: ping.profileName }))
292
+ if (ping.serves) lines.push(...renderHostSection(t, admin, wizard, shares))
293
+ if (ping.connects && client !== null) lines.push(...renderClientSection(t, client))
294
+ out.write(`${lines.join('\n')}\n`)
295
+ return 0
296
+ }
@@ -0,0 +1,354 @@
1
+ /**
2
+ * The CLI's anti-shadowing config writer (CLI-1).
3
+ *
4
+ * `resolveConfig` reads four layers in the order env > row > file > default
5
+ * (src/config.ts). A CLI write lands in the FILE layer
6
+ * (`<dshHome>/lan-gate.config.json`), so two layers can silently swallow it:
7
+ * an environment variable set at the top, and a value the loader put on the
8
+ * plugin row (persisted in the current profile's `cordis.patch.yml`). The
9
+ * writer refuses the first case with the exact variable name, and REMOVES the
10
+ * shadowing keys in the other two — the in-memory row object `apply()` still
11
+ * holds, and the `dsh-zen-remote` `config:` block of every profile patch file
12
+ * it can find — so the just-written file value is what the next
13
+ * `resolveConfig` answers. `onAfterWrite` then lets the caller (the IPC
14
+ * delegate in src/index.ts) trigger the restart watcher immediately, so a
15
+ * restart-required field reloads the row without waiting out the 2s poll.
16
+ *
17
+ * The file is read from `dshHome` DIRECTLY, not through `readFileConfig(env)`:
18
+ * the latter resolves `<env.DSH_HOME ?? ~/.dsh>` and would silently read the
19
+ * developer's real file when a test passes an explicit dshHome with an empty
20
+ * env — resolution and the write must never disagree about which file is the
21
+ * truth.
22
+ *
23
+ * The `cordis.patch.yml` edit is line-based on purpose: the files are
24
+ * user-owned YAML full of comments and hand-tuned formatting that a
25
+ * parse-serialize round trip would destroy, and the edit itself (delete a
26
+ * key line and its indented children from one plugin's config block) is a
27
+ * purely textual operation.
28
+ */
29
+
30
+ import { chmodSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs'
31
+ import { join } from 'node:path'
32
+ import { resolveConfig } from '../config.js'
33
+ import type { ConfigSource, ZenRemoteConfig } from '../config.js'
34
+
35
+ /**
36
+ * The env variable each config field answers to, for fields that HAVE one.
37
+ * Fields without an entry (role, serverName, idleHours, the wizard's ingress
38
+ * fields, pluginBridgeConsent — src/config.ts explains each absence) can
39
+ * never be env-shadowed, so they are deliberately missing: `writeCliConfig`
40
+ * falls back to the field name itself when asked for one.
41
+ */
42
+ export const ENV_VAR_BY_FIELD: Readonly<Partial<Record<keyof ZenRemoteConfig, string>>> = {
43
+ port: 'LAN_GATE_PORT',
44
+ host: 'LAN_GATE_HOST',
45
+ targetPort: 'LAN_GATE_TARGET_PORT',
46
+ rateLimit: 'LAN_GATE_RATE_LIMIT',
47
+ trustedProxies: 'LAN_GATE_TRUSTED_PROXIES',
48
+ vapidSubject: 'LAN_GATE_VAPID_SUBJECT',
49
+ lang: 'LAN_GATE_LANG',
50
+ pushEvents: 'DSH_PUSH_EVENTS',
51
+ pushDebounceMs: 'DSH_PUSH_DEBOUNCE_MS',
52
+ pushSummary: 'DSH_PUSH_SUMMARY',
53
+ pushTurnEnd: 'DSH_PUSH_TURN_END',
54
+ pushTool: 'DSH_PUSH_TOOL',
55
+ pushEnabled: 'DSH_PUSH_ENABLED',
56
+ pushSessionName: 'DSH_PUSH_SESSION_NAME',
57
+ }
58
+
59
+ export interface WriteCliConfigOptions {
60
+ /** The DSH home the config files live under (`lan-gate.config.json`,
61
+ * `profiles/<name>/cordis.patch.yml`). Authoritative — env.DSH_HOME is
62
+ * not consulted. */
63
+ dshHome: string
64
+ /** The profile whose `cordis.patch.yml` is scanned FIRST; the fallback
65
+ * scan over every `<dshHome>/profiles/<name>/cordis.patch.yml` runs
66
+ * besides it, so an un-named profile carrying a stale key is un-shadowed
67
+ * too. */
68
+ profileName?: string | undefined
69
+ /** The plugin row as `apply()` received it (volatile `{ get() }` wrappers
70
+ * included). Shadowing keys on it are DELETED so the file layer shows
71
+ * through; omit on a composition without a row. */
72
+ row?: Record<string, unknown> | undefined
73
+ /** The environment carrying `LAN_GATE_*` / `DSH_PUSH_*`; defaults to
74
+ * `process.env`. */
75
+ env?: NodeJS.ProcessEnv | undefined
76
+ /** Keys to merge into `lan-gate.config.json` (and un-shadow everywhere). */
77
+ set?: Record<string, unknown> | undefined
78
+ /** Keys to delete from `lan-gate.config.json` (and un-shadow everywhere). */
79
+ unset?: readonly string[] | undefined
80
+ /** Called AFTER every successful write (never on a refusal and never for
81
+ * an empty write) — the IPC delegate passes the restart watcher's
82
+ * `checkNow`, which reloads the row at once when a restart-required field
83
+ * moved. */
84
+ onAfterWrite?: (() => void) | undefined
85
+ }
86
+
87
+ export type WriteCliConfigResult =
88
+ | { ok: true; values: ZenRemoteConfig }
89
+ | { ok: false; code: 'env-shadowed'; field: string; envVar: string }
90
+
91
+ /**
92
+ * Keys that CLI explicitly wrote to `lan-gate.config.json` and unshadowed in
93
+ * `cordis.patch.yml` during this process lifetime, keyed by `dshHome`. When
94
+ * `fiber.restart()` reloads the plugin row, `cordis-plugin-loader` re-hydrates
95
+ * the new `config` object from its in-memory patch snapshot (without
96
+ * re-reading `cordis.patch.yml` from disk), which would otherwise re-shadow
97
+ * the file layer with stale `{ get() }` wrappers. `applyUnshadowedRowKeys`
98
+ * strips those stale wrappers on the reloaded `apply()`, and a subsequent
99
+ * Web UI save (`loader/volatile-update`) clears them via
100
+ * `clearUnshadowedRowKeys`.
101
+ */
102
+ const unshadowedByHome = new Map<string, Set<string>>()
103
+
104
+ export function applyUnshadowedRowKeys(dshHome: string, row: Record<string, unknown> | undefined): void {
105
+ if (row === undefined || row === null || typeof row !== 'object') return
106
+ const keys = unshadowedByHome.get(dshHome)
107
+ if (keys === undefined) return
108
+ for (const key of keys) {
109
+ const val = row[key]
110
+ if (val !== null && typeof val === 'object' && typeof (val as { get?: unknown }).get === 'function') {
111
+ delete row[key]
112
+ }
113
+ }
114
+ }
115
+
116
+ export function clearUnshadowedRowKeys(dshHome: string, keys: readonly string[]): void {
117
+ const set = unshadowedByHome.get(dshHome)
118
+ if (set === undefined) return
119
+ for (const key of keys) set.delete(key)
120
+ }
121
+
122
+ /** Read `<dshHome>/lan-gate.config.json` — `{}` when absent or unparsable,
123
+ * the same degrade-not-throw contract as src/config.ts's readFileConfig. */
124
+ function readFileAt(dshHome: string): Record<string, unknown> {
125
+ try {
126
+ const parsed: unknown = JSON.parse(readFileSync(join(dshHome, 'lan-gate.config.json'), 'utf8'))
127
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
128
+ return parsed as Record<string, unknown>
129
+ } catch {
130
+ return {}
131
+ }
132
+ }
133
+
134
+ /** Atomic 0600 rewrite: tmp + explicit chmod + rename, so a crash mid-write
135
+ * can never leave a half file behind (the discipline of the wizard's
136
+ * trust-proxy persister). */
137
+ function writeJsonAtomic(dshHome: string, body: Record<string, unknown>): void {
138
+ const file = join(dshHome, 'lan-gate.config.json')
139
+ try {
140
+ mkdirSync(dshHome, { recursive: true })
141
+ } catch { /* an existing directory or an unwritable one — the write below reports it */ }
142
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
143
+ writeFileSync(tmp, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 })
144
+ chmodSync(tmp, 0o600)
145
+ renameSync(tmp, file)
146
+ }
147
+
148
+ /** The candidate `cordis.patch.yml` paths: the named profile first (when
149
+ * given), then every profile's patch file found on disk — deduplicated, and
150
+ * only files that actually exist (the editor never CREATES one). */
151
+ function patchFileCandidates(dshHome: string, profileName: string | undefined): string[] {
152
+ const out: string[] = []
153
+ const push = (candidate: string): void => {
154
+ if (!out.includes(candidate) && existsQuiet(candidate)) out.push(candidate)
155
+ }
156
+ if (profileName !== undefined && profileName !== '') {
157
+ push(join(dshHome, 'profiles', profileName, 'cordis.patch.yml'))
158
+ }
159
+ const profilesDir = join(dshHome, 'profiles')
160
+ try {
161
+ for (const entry of readdirSync(profilesDir, { withFileTypes: true })) {
162
+ if (!entry.isDirectory()) continue
163
+ if (entry.name === 'node_modules' || entry.name.startsWith('.') || entry.name === 'desktop') continue
164
+ push(join(profilesDir, entry.name, 'cordis.patch.yml'))
165
+ }
166
+ } catch { /* no profiles directory — nothing to scan */ }
167
+ return out
168
+ }
169
+
170
+ function existsQuiet(path: string): boolean {
171
+ try {
172
+ statSync(path)
173
+ return true
174
+ } catch {
175
+ return false
176
+ }
177
+ }
178
+
179
+ function leadingSpaces(line: string): number {
180
+ const match = /^[ \t]*/u.exec(line)
181
+ return match === null ? 0 : match[0].length
182
+ }
183
+
184
+ function escapeRegExp(text: string): string {
185
+ return text.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
186
+ }
187
+
188
+ /**
189
+ * Remove `keys` from EVERY `- id: dsh-zen-remote` (or bare `id:`) entry's
190
+ * `config:` block, purely textually: a key line and its deeper-indented
191
+ * children disappear, every other line — comments, other plugins' blocks,
192
+ * formatting — survives byte for byte. A block emptied by the removal keeps
193
+ * a `config: {}` line (an entry with no config key at all changes loader
194
+ * semantics). No match, no change: the same string comes back.
195
+ */
196
+ export function unshadowPatchYml(text: string, keys: readonly string[]): string {
197
+ if (keys.length === 0) return text
198
+ const lines = text.split('\n')
199
+ const toDelete = new Set<number>()
200
+ const toReplace = new Map<number, string>()
201
+
202
+ for (let i = 0; i < lines.length; i += 1) {
203
+ // The id line: an optional dash + `id: dsh-zen-remote`, trailing comment
204
+ // allowed (`- id: agent-default-model # magpie` shows the shape). The
205
+ // id KEY's column is the prefix length — `config:` sits at that column.
206
+ const idMatch = /^([ \t]*(?:-[ \t]+)?)(id):[ \t]*dsh-zen-remote[ \t]*(?:#.*)?$/u.exec(lines[i])
207
+ if (idMatch === null) continue
208
+ const keyIndent = idMatch[1].length
209
+
210
+ // The entry's `config:` line: the first `config:` at the id column
211
+ // before the entry ends (a shallower line starts the next entry).
212
+ let configLine = -1
213
+ let configIndent = -1
214
+ for (let j = i + 1; j < lines.length; j += 1) {
215
+ const line = lines[j]
216
+ if (line.trim() === '') continue
217
+ const indent = leadingSpaces(line)
218
+ if (indent < keyIndent) break
219
+ if (indent === keyIndent && /^[ \t]*config[ \t]*:(\s|$|#)/u.test(line)) {
220
+ configLine = j
221
+ configIndent = indent
222
+ break
223
+ }
224
+ }
225
+ if (configLine === -1) continue
226
+
227
+ // The config block: every following line deeper than the config column
228
+ // (blank lines skipped — they belong to no key). The shallowest indent
229
+ // inside the block is where its top-level keys sit.
230
+ let blockEnd = configLine + 1
231
+ let minIndent = Number.POSITIVE_INFINITY
232
+ while (blockEnd < lines.length) {
233
+ const line = lines[blockEnd]
234
+ if (line.trim() === '') {
235
+ blockEnd += 1
236
+ continue
237
+ }
238
+ const indent = leadingSpaces(line)
239
+ if (indent <= configIndent) break
240
+ if (indent < minIndent) minIndent = indent
241
+ blockEnd += 1
242
+ }
243
+ if (!Number.isFinite(minIndent)) continue
244
+
245
+ for (const key of keys) {
246
+ const keyLine = new RegExp(`^([ \\t]*)${escapeRegExp(key)}[ \\t]*:(\\s|$|#)`, 'u')
247
+ for (let j = configLine + 1; j < blockEnd; j += 1) {
248
+ if (toDelete.has(j)) continue
249
+ const line = lines[j]
250
+ if (line.trim() === '') continue
251
+ const indent = leadingSpaces(line)
252
+ if (indent !== minIndent || !keyLine.test(line)) continue
253
+ toDelete.add(j)
254
+ // The key's children (deeper-indented lists/maps) go with it.
255
+ for (let q = j + 1; q < blockEnd; q += 1) {
256
+ const child = lines[q]
257
+ if (child.trim() === '') continue
258
+ if (leadingSpaces(child) <= indent) break
259
+ toDelete.add(q)
260
+ }
261
+ }
262
+ }
263
+ // Emptied block → keep a `config: {}` line so the entry's shape survives.
264
+ let remains = false
265
+ for (let j = configLine + 1; j < blockEnd; j += 1) {
266
+ if (!toDelete.has(j) && lines[j].trim() !== '') {
267
+ remains = true
268
+ break
269
+ }
270
+ }
271
+ if (!remains) toReplace.set(configLine, `${' '.repeat(configIndent)}config: {}`)
272
+ }
273
+
274
+ if (toDelete.size === 0 && toReplace.size === 0) return text
275
+ const out: string[] = []
276
+ for (let i = 0; i < lines.length; i += 1) {
277
+ const replacement = toReplace.get(i)
278
+ if (replacement !== undefined) out.push(replacement)
279
+ else if (!toDelete.has(i)) out.push(lines[i])
280
+ }
281
+ return out.join('\n')
282
+ }
283
+
284
+ /**
285
+ * Write CLI config changes through the anti-shadowing pipeline. Returns the
286
+ * refused `env-shadowed` verdict (with the exact field and its variable
287
+ * name) WITHOUT touching any file, or the freshly resolved effective values
288
+ * after the write, the row-key deletions and the patch-file un-shadowing.
289
+ */
290
+ export function writeCliConfig(options: WriteCliConfigOptions): WriteCliConfigResult {
291
+ const env = options.env ?? process.env
292
+ const row = options.row ?? {}
293
+ const set = options.set ?? {}
294
+ const unset = options.unset ?? []
295
+ const setKeys = Object.keys(set)
296
+ const targetKeys = [...setKeys, ...unset]
297
+
298
+ if (targetKeys.length > 0) {
299
+ // Step 1 — the env layer is untouchable from here: a field whose CURRENT
300
+ // effective source is `env` (a legal value lives in a LAN_GATE_* /
301
+ // DSH_PUSH_* variable) refuses the write outright, naming the variable.
302
+ const snapshot = resolveConfig(row, readFileAt(options.dshHome), env)
303
+ const sources = snapshot.sources as Record<string, ConfigSource>
304
+ for (const key of targetKeys) {
305
+ if (sources[key] !== 'env') continue
306
+ return { ok: false, code: 'env-shadowed', field: key, envVar: ENV_VAR_BY_FIELD[key as keyof ZenRemoteConfig] ?? key }
307
+ }
308
+
309
+ // Step 2 — the file layer: merge / delete the keys atomically (0600).
310
+ const merged: Record<string, unknown> = { ...readFileAt(options.dshHome) }
311
+ for (const key of setKeys) merged[key] = set[key]
312
+ for (const key of unset) delete merged[key]
313
+ writeJsonAtomic(options.dshHome, merged)
314
+
315
+ // Step 3a — the row layer: the loader wrapped the row's volatile fields
316
+ // in `{ get() }` references, but they are still plain own properties of
317
+ // the same object apply() holds — a delete makes the NEXT resolveConfig
318
+ // fall through this layer to the freshly written file. Record the keys
319
+ // for this `dshHome` so a subsequent `fiber.restart()` also strips the
320
+ // loader's re-hydrated in-memory `{ get() }` wrappers.
321
+ let remembered = unshadowedByHome.get(options.dshHome)
322
+ if (remembered === undefined) {
323
+ remembered = new Set<string>()
324
+ unshadowedByHome.set(options.dshHome, remembered)
325
+ }
326
+ for (const key of targetKeys) {
327
+ remembered.add(key)
328
+ if (key in row) delete row[key]
329
+ }
330
+
331
+ // Step 3b — the persisted row layer: the profile patch files the loader
332
+ // itself writes when a settings save pins a key on the plugin row.
333
+ for (const file of patchFileCandidates(options.dshHome, options.profileName)) {
334
+ try {
335
+ const before = readFileSync(file, 'utf8')
336
+ const after = unshadowPatchYml(before, targetKeys)
337
+ if (after === before) continue
338
+ let mode = 0o644
339
+ try {
340
+ mode = statSync(file).mode & 0o777
341
+ } catch { /* keep the default */ }
342
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
343
+ writeFileSync(tmp, after, { mode })
344
+ renameSync(tmp, file)
345
+ } catch { /* one unreadable profile never blocks the others */ }
346
+ }
347
+
348
+ options.onAfterWrite?.()
349
+ }
350
+
351
+ // Step 4 — the caller's reload hook fired; answer the values the layers
352
+ // resolve to NOW (the file layer just moved; the row keys just left).
353
+ return { ok: true, values: resolveConfig(row, readFileAt(options.dshHome), env).values }
354
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * CLI-3: the execution context every command function receives. One bag with
3
+ * everything a command needs but never constructs itself — the parsed
4
+ * grammar, the live IPC client, the translator, and the interactivity
5
+ * verdict — plus the seams tests inject (a clock, a poll interval, an
6
+ * alternate working directory). Pure types: the real builder is CLI-5's
7
+ * main.ts; the tests assemble the object literal directly.
8
+ */
9
+
10
+ import type { ParsedCliArgs } from './args.js'
11
+ import type { CliIpcClient } from './ipc-client.js'
12
+ import type { CliTranslator } from './i18n.js'
13
+ import type { CliPrompter } from './bootstrap.js'
14
+
15
+ export interface CliExecutionContext {
16
+ /** The command and options this run parsed. */
17
+ args: ParsedCliArgs
18
+ /** A client bound to the discovered live IPC socket. */
19
+ ipc: CliIpcClient
20
+ /** The translator for this run's language. */
21
+ t: CliTranslator
22
+ /** TTY && !`--yes`, decided once by the entry point (isInteractiveSession). */
23
+ interactive: boolean
24
+ /** The ask-everything funnel; present whenever the run may prompt. */
25
+ prompter?: CliPrompter
26
+ stdout: { write(text: string): void }
27
+ /** Clock for the `status --debug` default filename; default `new Date()`. */
28
+ now?: () => Date
29
+ /** Working directory for that default path; default `process.cwd()`. */
30
+ cwd?: string
31
+ /** `client pair`'s online-wait poll interval (default 250ms; tests shrink
32
+ * it — the 10s budget itself stays fixed). */
33
+ pollIntervalMs?: number
34
+ /** `host ingress verify`'s Ctrl-C seam: the entry point (CLI-5's main)
35
+ * aborts it on SIGINT so the phone-pairing wait can be skipped gracefully;
36
+ * tests abort it directly. */
37
+ abortSignal?: AbortSignal
38
+ }