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,569 @@
1
+ /**
2
+ * CLI-2 bootstrap: everything a CLI session needs BEFORE any subcommand
3
+ * logic runs — interactive prompting, candidate-profile scanning with the
4
+ * multi-select plugin install, and bringing a DSH instance up.
5
+ *
6
+ * Three injectable seams keep this testable without a real terminal or a
7
+ * real daemon (SPEC's test seam three): a `CliPrompter` the tests replace
8
+ * with fakes, a `spawnCommand` the tests record instead of running
9
+ * `dsh plugin add`, and an `execProbe` the tests substitute for systemctl /
10
+ * launchctl / open. The real implementations sit next to them in this file
11
+ * and exercise the very same code paths.
12
+ *
13
+ * The non-interactive iron rule lives here: with `!isTTY` or `--yes`, a
14
+ * question with no command-line answer throws `MissingRequiredOptionError`
15
+ * IMMEDIATELY — a headless run must fail fast and legibly, never hang on a
16
+ * stdin read nothing will ever answer.
17
+ */
18
+
19
+ import { execFile } from 'node:child_process'
20
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
21
+ import { join } from 'node:path'
22
+ import { createInterface } from 'node:readline/promises'
23
+ import type { Interface as ReadlineInterface } from 'node:readline/promises'
24
+ import type { Readable, Writable } from 'node:stream'
25
+ import { createCliIpcClient, discoverCliSockets } from './ipc-client.js'
26
+ import type { CliIpcClient, CliPingResponse, DiscoveredLive, DiscoveredSockets } from './ipc-client.js'
27
+ import { createTranslator, resolveCliLang } from './i18n.js'
28
+ import type { CliTranslator } from './i18n.js'
29
+
30
+ // ===========================================================================
31
+ // Interactive prompting
32
+ // ===========================================================================
33
+
34
+ export interface CliMultiSelection {
35
+ /** Indices into the `choices` array, in the order the user entered them. */
36
+ indices: number[]
37
+ /** Names typed via the manual-input entry (`a`). */
38
+ customNames: string[]
39
+ }
40
+
41
+ export interface CliPrompter {
42
+ selectOne(question: string, choices: readonly string[], defaultIndex: number): Promise<number>
43
+ selectMulti(question: string, choices: readonly string[], defaultIndices: readonly number[], allowCustomInput?: boolean): Promise<CliMultiSelection>
44
+ inputText(question: string, defaultValue?: string, validate?: (value: string) => string | undefined): Promise<string>
45
+ confirm(question: string, defaultYes?: boolean): Promise<boolean>
46
+ close(): void
47
+ }
48
+
49
+ /** Raised when a required value is missing in a run that must not prompt
50
+ * (`!isTTY` or `--yes`). Carries the option name so the top level can render
51
+ * one precise bilingual line instead of a stack trace. */
52
+ export class MissingRequiredOptionError extends Error {
53
+ readonly optionName: string
54
+
55
+ constructor(optionName: string) {
56
+ super(`missing required option: --${optionName}`)
57
+ this.name = 'MissingRequiredOptionError'
58
+ this.optionName = optionName
59
+ }
60
+ }
61
+
62
+ /** The interactivity verdict every prompt flows through: a TTY and no
63
+ * `--yes`. One function so the rule has exactly one home. */
64
+ export function isInteractiveSession(args: { options: { yes: boolean } }, isTty: boolean): boolean {
65
+ return isTty && !args.options.yes
66
+ }
67
+
68
+ /**
69
+ * One ask-everything funnel: a command-line-provided (or caller-default)
70
+ * value answers directly; otherwise interactive runs prompt, and
71
+ * non-interactive runs throw {@link MissingRequiredOptionError} naming the
72
+ * option — before any await that could hang.
73
+ */
74
+ export async function promptOrFail<T>(params: {
75
+ optionName: string
76
+ interactive: boolean
77
+ provided: T | undefined
78
+ ask: () => Promise<T>
79
+ }): Promise<T> {
80
+ if (params.provided !== undefined) return params.provided
81
+ if (!params.interactive) throw new MissingRequiredOptionError(params.optionName)
82
+ return params.ask()
83
+ }
84
+
85
+ /**
86
+ * The default `CliPrompter` over `node:readline/promises`. Scaffold strings
87
+ * (the "pick a number" hints) go through the translator like everything
88
+ * else; questions and choices arrive pre-translated from the callers.
89
+ */
90
+ export function createReadlinePrompter(input?: Readable, output?: Writable, t?: CliTranslator): CliPrompter {
91
+ const translator = t ?? createTranslator(resolveCliLang(undefined))
92
+ const out = output ?? process.stdout
93
+ const rl: ReadlineInterface = createInterface({ input: input ?? process.stdin, output: out })
94
+ const write = (text: string): void => { out.write(text) }
95
+ const ask = async (prompt: string): Promise<string> => (await rl.question(prompt)).trim()
96
+
97
+ return {
98
+ async selectOne(question, choices, defaultIndex) {
99
+ write(`\n? ${question}\n`)
100
+ choices.forEach((choice, i) => write(` ${i + 1}. ${choice}${i === defaultIndex ? `(${translator('prompt.defaultLabel')})` : ''}\n`))
101
+ while (true) {
102
+ const line = await ask(translator('prompt.selectOne', { max: choices.length, default: defaultIndex + 1 }))
103
+ if (line === '') return defaultIndex
104
+ const picked = Number.parseInt(line, 10)
105
+ if (Number.isInteger(picked) && picked >= 1 && picked <= choices.length) return picked - 1
106
+ write(`${translator('prompt.invalid', { value: line })}\n`)
107
+ }
108
+ },
109
+
110
+ async selectMulti(question, choices, defaultIndices, allowCustomInput = false) {
111
+ write(`\n? ${question}\n`)
112
+ const defaults = new Set(defaultIndices)
113
+ choices.forEach((choice, i) => write(` ${defaults.has(i) ? '[x]' : '[ ]'} ${i + 1}. ${choice}\n`))
114
+ if (allowCustomInput) write(`${translator('prompt.customEntryLine', { entry: translator('install.customEntry') })}\n`)
115
+ while (true) {
116
+ const line = await ask(allowCustomInput ? translator('prompt.multiInputCustom') : translator('prompt.multiInputPlain'))
117
+ if (line === '') return { indices: [...defaultIndices], customNames: [] }
118
+ const indices: number[] = []
119
+ let wantsCustom = false
120
+ let valid = true
121
+ for (const token of line.split(/[,\s]+/u).filter((part) => part !== '')) {
122
+ if (allowCustomInput && /^[aA]$/u.test(token)) {
123
+ wantsCustom = true
124
+ continue
125
+ }
126
+ const picked = Number.parseInt(token, 10)
127
+ if (!Number.isInteger(picked) || picked < 1 || picked > choices.length) {
128
+ valid = false
129
+ break
130
+ }
131
+ if (!indices.includes(picked - 1)) indices.push(picked - 1)
132
+ }
133
+ if (!valid) {
134
+ write(`${translator('prompt.invalid', { value: line })}\n`)
135
+ continue
136
+ }
137
+ const customNames: string[] = []
138
+ if (wantsCustom) {
139
+ while (true) {
140
+ const name = await ask(translator('prompt.customName'))
141
+ if (name === '') break
142
+ if (!customNames.includes(name)) customNames.push(name)
143
+ }
144
+ }
145
+ return { indices, customNames }
146
+ }
147
+ },
148
+
149
+ async inputText(question, defaultValue, validate) {
150
+ write('\n')
151
+ while (true) {
152
+ const suffix = defaultValue === undefined ? '' : `(${translator('prompt.defaultLabel')}:${defaultValue})`
153
+ const line = await ask(`? ${question}${suffix} `)
154
+ const value = line === '' ? (defaultValue ?? '') : line
155
+ const problem = validate?.(value)
156
+ if (problem !== undefined) {
157
+ write(`${problem}\n`)
158
+ continue
159
+ }
160
+ if (value === '' && defaultValue === undefined) {
161
+ write(`${translator('prompt.requiredEmpty')}\n`)
162
+ continue
163
+ }
164
+ return value
165
+ }
166
+ },
167
+
168
+ async confirm(question, defaultYes = true) {
169
+ write('\n')
170
+ while (true) {
171
+ const line = await ask(`? ${question} ${defaultYes ? translator('prompt.confirmY') : translator('prompt.confirmN')} `)
172
+ if (line === '') return defaultYes
173
+ const lower = line.toLowerCase()
174
+ if (lower === 'y' || lower === 'yes') return true
175
+ if (lower === 'n' || lower === 'no') return false
176
+ write(`${translator('prompt.invalid', { value: line })}\n`)
177
+ }
178
+ },
179
+
180
+ close() {
181
+ rl.close()
182
+ },
183
+ }
184
+ }
185
+
186
+ // ===========================================================================
187
+ // Candidate profiles & multi-select plugin install
188
+ // ===========================================================================
189
+
190
+ export interface CandidateProfile {
191
+ name: string
192
+ installed: boolean
193
+ }
194
+
195
+ /** Does this profile's package.json carry the plugin — either as a plain
196
+ * dependency or in `dsh.profile.bundles` (array or name-keyed map)? A
197
+ * missing or unparsable package.json is simply "not installed". */
198
+ function profileHasPlugin(profileDir: string): boolean {
199
+ try {
200
+ const parsed: unknown = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))
201
+ if (parsed === null || typeof parsed !== 'object') return false
202
+ const pkg = parsed as {
203
+ dependencies?: Record<string, unknown>
204
+ dsh?: { profile?: { bundles?: unknown } }
205
+ }
206
+ if (pkg.dependencies !== undefined && typeof pkg.dependencies === 'object' && 'dsh-zen-remote' in pkg.dependencies) return true
207
+ const bundles = pkg.dsh?.profile?.bundles
208
+ if (Array.isArray(bundles) && bundles.includes('dsh-zen-remote')) return true
209
+ if (bundles !== null && typeof bundles === 'object' && !Array.isArray(bundles) && 'dsh-zen-remote' in bundles) return true
210
+ } catch { /* no package.json or unreadable — not installed */ }
211
+ return false
212
+ }
213
+
214
+ /**
215
+ * The installable profiles under `<dshHome>/profiles/`: real directories,
216
+ * excluding `desktop` (the DSH app manages it and `dsh plugin` must not
217
+ * touch it), `node_modules`, and dot-hidden entries. `web` is ALWAYS at
218
+ * index 0 — synthesized when the directory does not exist yet — so both the
219
+ * prompt's default check and the preferred-profile rule can rely on it.
220
+ */
221
+ export function scanCandidateProfiles(dshHome: string): CandidateProfile[] {
222
+ const found: CandidateProfile[] = []
223
+ let entries: ReadonlyArray<{ name: string; isDirectory(): boolean }> = []
224
+ try {
225
+ entries = readdirSync(join(dshHome, 'profiles'), { withFileTypes: true })
226
+ } catch { /* no profiles directory — only the synthesized web remains */ }
227
+ for (const entry of entries) {
228
+ if (!entry.isDirectory()) continue
229
+ if (entry.name === 'desktop' || entry.name === 'node_modules' || entry.name.startsWith('.')) continue
230
+ found.push({ name: entry.name, installed: profileHasPlugin(join(dshHome, 'profiles', entry.name)) })
231
+ }
232
+ const webIndex = found.findIndex((profile) => profile.name === 'web')
233
+ if (webIndex > 0) {
234
+ const [web] = found.splice(webIndex, 1)
235
+ if (web !== undefined) found.unshift(web)
236
+ } else if (webIndex === -1) {
237
+ found.unshift({ name: 'web', installed: false })
238
+ }
239
+ return found
240
+ }
241
+
242
+ function pickPreferredProfile(profiles: readonly string[]): string | undefined {
243
+ return profiles.includes('web') ? 'web' : profiles[0]
244
+ }
245
+
246
+ /** The real `dsh plugin add` runner: exit code as a number, spawn failures
247
+ * (ENOENT — no `dsh` on PATH) count as code 1. */
248
+ function spawnDshPluginAdd(command: string, args: readonly string[]): Promise<number> {
249
+ return new Promise((resolve) => {
250
+ execFile(command, [...args], { timeout: 120_000 }, (error) => {
251
+ if (error === undefined) {
252
+ resolve(0)
253
+ return
254
+ }
255
+ const code = (error as NodeJS.ErrnoException & { code?: number | string }).code
256
+ resolve(typeof code === 'number' ? code : 1)
257
+ })
258
+ })
259
+ }
260
+
261
+ export interface EnsurePluginInstalledOptions {
262
+ dshHome: string
263
+ /** Explicit `--profile p1,p2` targets; skips the scan-and-ask flow and
264
+ * installs even over already-installed profiles (idempotent add). */
265
+ profileFlag?: readonly string[]
266
+ /** TTY && !`--yes`, decided by the caller (see isInteractiveSession). */
267
+ interactive?: boolean
268
+ prompter?: CliPrompter
269
+ /** Injectable runner for `dsh plugin … add` — tests record instead of run. */
270
+ spawnCommand?: (command: string, args: readonly string[]) => Promise<number>
271
+ stdout?: { write(text: string): void }
272
+ t?: CliTranslator
273
+ }
274
+
275
+ export interface EnsurePluginInstalledResult {
276
+ ok: boolean
277
+ code: 'ok' | 'desktop-refused' | 'install-failed' | 'no-profiles'
278
+ /** Every profile this step decided is (or should now be) installed. */
279
+ targets: string[]
280
+ /** `web` when the targets include it, else the first target. */
281
+ preferredProfile: string | undefined
282
+ /** Profiles the install actually ran for, in order. */
283
+ installedNow: string[]
284
+ selectedBy: 'flag' | 'prompt' | 'default' | 'already-installed'
285
+ failedProfile?: string
286
+ manualCommand?: string
287
+ }
288
+
289
+ /**
290
+ * Make sure `dsh-zen-remote` is installed into at least one usable profile:
291
+ * explicit `--profile` wins; otherwise an already-installed profile ends the
292
+ * flow; otherwise an interactive TTY multi-selects from the candidate scan
293
+ * (default-checked `web`, manual names welcome) and a non-interactive run
294
+ * defaults to `web`. Each selected profile gets
295
+ * `dsh plugin --profile <name> add dsh-zen-remote`; a failure prints the
296
+ * verbatim manual command and reports `install-failed`. `desktop` is
297
+ * refused everywhere — the DeepSeek Harness app owns that profile.
298
+ */
299
+ export async function ensurePluginInstalled(options: EnsurePluginInstalledOptions): Promise<EnsurePluginInstalledResult> {
300
+ const t = options.t ?? createTranslator(resolveCliLang(undefined))
301
+ const out = options.stdout ?? process.stdout
302
+ const spawn = options.spawnCommand ?? spawnDshPluginAdd
303
+ const interactive = options.interactive ?? false
304
+
305
+ const desktopIn = (names: readonly string[]): string[] => names.filter((name) => name === 'desktop')
306
+ const refused = (targets: string[], selectedBy: EnsurePluginInstalledResult['selectedBy']): EnsurePluginInstalledResult => {
307
+ const offending = desktopIn(targets)
308
+ out.write(`${t('err.desktopProfile', { profiles: offending.join(', ') })}\n`)
309
+ return { ok: false, code: 'desktop-refused', targets, preferredProfile: pickPreferredProfile(targets), installedNow: [], selectedBy }
310
+ }
311
+
312
+ let targets: string[]
313
+ let selectedBy: EnsurePluginInstalledResult['selectedBy']
314
+ if (options.profileFlag !== undefined) {
315
+ targets = options.profileFlag.map((name) => name.trim()).filter((name) => name !== '')
316
+ selectedBy = 'flag'
317
+ if (desktopIn(targets).length > 0) return refused(targets, selectedBy)
318
+ } else {
319
+ const candidates = scanCandidateProfiles(options.dshHome)
320
+ const installed = candidates.filter((profile) => profile.installed).map((profile) => profile.name)
321
+ if (installed.length > 0) {
322
+ out.write(`${t('install.installed', { profiles: installed.join(', ') })}\n`)
323
+ return { ok: true, code: 'ok', targets: installed, preferredProfile: pickPreferredProfile(installed), installedNow: [], selectedBy: 'already-installed' }
324
+ }
325
+ out.write(`${t('install.noneInstalled')}\n`)
326
+ if (interactive && options.prompter !== undefined) {
327
+ const selection = await options.prompter.selectMulti(
328
+ t('install.selectQuestion'),
329
+ candidates.map((profile) => profile.name),
330
+ [0],
331
+ true,
332
+ )
333
+ const chosen = selection.indices
334
+ .map((index) => candidates[index]?.name)
335
+ .filter((name): name is string => name !== undefined)
336
+ const custom = selection.customNames.map((name) => name.trim()).filter((name) => name !== '')
337
+ targets = [...chosen, ...custom]
338
+ selectedBy = 'prompt'
339
+ } else {
340
+ targets = ['web']
341
+ selectedBy = 'default'
342
+ }
343
+ if (desktopIn(targets).length > 0) return refused(targets, selectedBy)
344
+ if (targets.length === 0) {
345
+ return { ok: false, code: 'no-profiles', targets, preferredProfile: undefined, installedNow: [], selectedBy }
346
+ }
347
+ }
348
+
349
+ const installedNow: string[] = []
350
+ for (const profile of targets) {
351
+ out.write(`${t('install.running', { profile })}\n`)
352
+ let code: number
353
+ try {
354
+ code = await spawn('dsh', ['plugin', '--profile', profile, 'add', 'dsh-zen-remote'])
355
+ } catch {
356
+ code = 1
357
+ }
358
+ if (code !== 0) {
359
+ const manualCommand = `dsh plugin --profile ${profile} add dsh-zen-remote`
360
+ out.write(`${t('install.failed', { profile, code, command: manualCommand })}\n`)
361
+ return { ok: false, code: 'install-failed', targets, preferredProfile: pickPreferredProfile(targets), installedNow, selectedBy, failedProfile: profile, manualCommand }
362
+ }
363
+ installedNow.push(profile)
364
+ }
365
+ out.write(`${t('install.done', { profiles: installedNow.join(', ') })}\n`)
366
+ return { ok: true, code: 'ok', targets, preferredProfile: pickPreferredProfile(targets), installedNow, selectedBy }
367
+ }
368
+
369
+ // ===========================================================================
370
+ // DSH discovery & daemon launch
371
+ // ===========================================================================
372
+
373
+ export interface ExecProbeResult {
374
+ code: number
375
+ stdout: string
376
+ }
377
+
378
+ export type ExecProbe = (command: string, args: readonly string[]) => Promise<ExecProbeResult>
379
+
380
+ export interface EnsureDshRunningOptions {
381
+ dshHome: string
382
+ /** Among several live instances prefer the one serving this role. */
383
+ targetRole?: 'host' | 'client'
384
+ /** Feeds the manual-start guidance and (nothing spawns daemons per-profile
385
+ * today) future per-profile daemons. */
386
+ preferredProfile?: string
387
+ /** Poll budget after a daemon trigger; default 20s. */
388
+ waitSeconds?: number
389
+ /** Probe only this socket instead of the candidate walk. */
390
+ explicitSocket?: string
391
+ /** Platform override (tests pin every branch on one machine). */
392
+ platform?: NodeJS.Platform
393
+ /** uid for the launchd `gui/<uid>/…` target; default process.getuid(). */
394
+ uid?: number
395
+ /** Injectable command runner for the daemon probes/triggers. */
396
+ execProbe?: ExecProbe
397
+ /** Injectable discovery (tests can stage socket states per call). */
398
+ discover?: typeof discoverCliSockets
399
+ /** macOS app bundle path; tests relocate it off the real /Applications. */
400
+ macosAppPath?: string
401
+ /** System systemd unit dir; tests relocate it off the real /etc. */
402
+ systemUnitDir?: string
403
+ stdout?: { write(text: string): void }
404
+ t?: CliTranslator
405
+ }
406
+
407
+ export type EnsureDshRunningResult =
408
+ | { started: true; client: CliIpcClient; socketPath: string; ping: CliPingResponse }
409
+ | { started: false; outcome: 'permission-denied' | 'no-daemon' | 'timeout'; message: string }
410
+
411
+ const DEFAULT_WAIT_SECONDS = 20
412
+ const POLL_INTERVAL_MS = 300
413
+ const DEFAULT_MACOS_APP_PATH = '/Applications/DeepSeek Harness.app'
414
+ const DEFAULT_SYSTEM_UNIT_DIR = '/etc/systemd/system'
415
+ const MACOS_LAUNCHD_LABEL = 'ai.deepseek.dsh.web'
416
+
417
+ function execProbeReal(command: string, args: readonly string[]): Promise<ExecProbeResult> {
418
+ return new Promise((resolve) => {
419
+ execFile(command, [...args], { timeout: 10_000 }, (error, stdout) => {
420
+ if (error === undefined) {
421
+ resolve({ code: 0, stdout: String(stdout ?? '') })
422
+ return
423
+ }
424
+ const code = (error as NodeJS.ErrnoException & { code?: number | string }).code
425
+ resolve({ code: typeof code === 'number' ? code : 127, stdout: String((error as { stdout?: unknown }).stdout ?? '') })
426
+ })
427
+ })
428
+ }
429
+
430
+ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
431
+
432
+ /** First `dsh*.service` name in a systemd unit directory, or `undefined`. */
433
+ function listSystemUnits(dir: string): string | undefined {
434
+ try {
435
+ for (const name of readdirSync(dir)) {
436
+ if (/^dsh[\w@.-]*\.service$/u.test(name)) return name
437
+ }
438
+ } catch { /* no system unit dir on this machine */ }
439
+ return undefined
440
+ }
441
+
442
+ interface DaemonTriggerContext {
443
+ execProbe: ExecProbe
444
+ uid: number
445
+ preferredProfile: string
446
+ macosAppPath: string
447
+ systemUnitDir: string
448
+ out: { write(text: string): void }
449
+ t: CliTranslator
450
+ }
451
+
452
+ /**
453
+ * Try to bring a daemon-managed DSH up, strictly by EXISTING service units —
454
+ * the CLI never daemonizes on its own (a second supervisor would fight the
455
+ * user's own setup). Returns whether a start command ran and succeeded.
456
+ * Linux: systemd user unit `dsh*.service`, else a system-level one.
457
+ * macOS: the launchd label, else the app bundle in /Applications.
458
+ * Windows (and everything unmatched): nothing to try — manual guidance.
459
+ */
460
+ async function triggerDaemon(platform: NodeJS.Platform, ctx: DaemonTriggerContext): Promise<boolean> {
461
+ if (platform === 'linux') {
462
+ const userUnits = await ctx.execProbe('systemctl', ['--user', 'list-unit-files', 'dsh*'])
463
+ const unit = userUnits.code === 0
464
+ ? /(?:^|\s)(dsh[\w@.-]*\.service)\b/u.exec(userUnits.stdout)?.[1]
465
+ : undefined
466
+ if (unit !== undefined) {
467
+ ctx.out.write(`${ctx.t('launch.linuxUserUnit', { unit })}\n`)
468
+ const started = await ctx.execProbe('systemctl', ['--user', 'start', unit])
469
+ return started.code === 0
470
+ }
471
+ const systemUnit = listSystemUnits(ctx.systemUnitDir)
472
+ if (systemUnit !== undefined) {
473
+ ctx.out.write(`${ctx.t('launch.linuxSystemUnit', { unit: systemUnit })}\n`)
474
+ const started = await ctx.execProbe('systemctl', ['start', systemUnit])
475
+ return started.code === 0
476
+ }
477
+ return false
478
+ }
479
+ if (platform === 'darwin') {
480
+ const target = `gui/${ctx.uid}/${MACOS_LAUNCHD_LABEL}`
481
+ const printed = await ctx.execProbe('launchctl', ['print', target])
482
+ if (printed.code === 0) {
483
+ ctx.out.write(`${ctx.t('launch.macosService')}\n`)
484
+ const kicked = await ctx.execProbe('launchctl', ['kickstart', '-k', target])
485
+ return kicked.code === 0
486
+ }
487
+ if (existsSync(ctx.macosAppPath)) {
488
+ ctx.out.write(`${ctx.t('launch.macosApp')}\n`)
489
+ const opened = await ctx.execProbe('open', ['-a', ctx.macosAppPath])
490
+ return opened.code === 0
491
+ }
492
+ return false
493
+ }
494
+ return false
495
+ }
496
+
497
+ /**
498
+ * Find (or launch) a DSH this CLI can drive: discovery first — a live
499
+ * version-matching socket answers immediately (preferring the target role
500
+ * among multiple instances); EACCES / EPERM reports the cross-user hint and
501
+ * stops; otherwise the platform's daemon triggers fire and discovery polls
502
+ * until a socket appears or `waitSeconds` run out. Windows and daemon-less
503
+ * machines get the manual-start guidance and a structured `no-daemon` result.
504
+ */
505
+ export async function ensureDshRunning(options: EnsureDshRunningOptions): Promise<EnsureDshRunningResult> {
506
+ const t = options.t ?? createTranslator(resolveCliLang(undefined))
507
+ const out = options.stdout ?? process.stdout
508
+ const execProbe = options.execProbe ?? execProbeReal
509
+ const discover = options.discover ?? discoverCliSockets
510
+ const platform = options.platform ?? process.platform
511
+ const waitSeconds = options.waitSeconds ?? DEFAULT_WAIT_SECONDS
512
+ const discoverOptions = {
513
+ dshHome: options.dshHome,
514
+ ...(options.explicitSocket === undefined ? {} : { explicitSocket: options.explicitSocket }),
515
+ platform,
516
+ }
517
+
518
+ const pickLive = (found: DiscoveredSockets): DiscoveredLive | undefined => {
519
+ if (options.targetRole !== undefined) {
520
+ const match = found.live.find((entry) => entry.role === options.targetRole)
521
+ if (match !== undefined) return match
522
+ }
523
+ return found.live[0]
524
+ }
525
+ const liveResult = (entry: DiscoveredLive): EnsureDshRunningResult => {
526
+ out.write(`${t('launch.found', { role: entry.role, socketPath: entry.socketPath })}\n`)
527
+ return { started: true, client: createCliIpcClient(entry.socketPath), socketPath: entry.socketPath, ping: entry }
528
+ }
529
+ const deniedResult = (): EnsureDshRunningResult => {
530
+ const message = t('err.crossUserSocket')
531
+ out.write(`${message}\n`)
532
+ return { started: false, outcome: 'permission-denied', message }
533
+ }
534
+
535
+ const first = await discover(discoverOptions)
536
+ const firstLive = pickLive(first)
537
+ if (firstLive !== undefined) return liveResult(firstLive)
538
+ if (first.permissionDenied.length > 0) return deniedResult()
539
+
540
+ out.write(`${t('launch.probing')}\n`)
541
+ const triggered = await triggerDaemon(platform, {
542
+ execProbe,
543
+ uid: options.uid ?? (typeof process.getuid === 'function' ? process.getuid() : 501),
544
+ preferredProfile: options.preferredProfile ?? 'web',
545
+ macosAppPath: options.macosAppPath ?? DEFAULT_MACOS_APP_PATH,
546
+ systemUnitDir: options.systemUnitDir ?? DEFAULT_SYSTEM_UNIT_DIR,
547
+ out,
548
+ t,
549
+ })
550
+
551
+ if (!triggered) {
552
+ const message = t('launch.manual', { profile: options.preferredProfile ?? 'web' })
553
+ out.write(`${message}\n`)
554
+ return { started: false, outcome: 'no-daemon', message }
555
+ }
556
+
557
+ out.write(`${t('launch.waiting', { seconds: waitSeconds })}\n`)
558
+ const deadline = Date.now() + waitSeconds * 1000
559
+ while (Date.now() < deadline) {
560
+ await sleep(POLL_INTERVAL_MS)
561
+ const found = await discover(discoverOptions)
562
+ const live = pickLive(found)
563
+ if (live !== undefined) return liveResult(live)
564
+ if (found.permissionDenied.length > 0) return deniedResult()
565
+ }
566
+ const message = t('launch.timeout', { seconds: waitSeconds })
567
+ out.write(`${message}\n`)
568
+ return { started: false, outcome: 'timeout', message }
569
+ }