dsh-zen-remote 2.3.10 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -72
- package/README.md +94 -84
- package/README.zh-CN.md +90 -81
- package/bin/zen-remote.mjs +5 -0
- package/docs/interface.md +6 -6
- package/docs/remote-access.en.md +6 -4
- package/docs/remote-access.md +6 -4
- package/dsh-push.mjs +96 -10
- package/lib/adapters/dsh-better-sidebar/index.js +7 -3
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/admin-routes.js +10 -2
- package/lib/admin-routes.js.map +1 -1
- package/lib/cli/args.js +251 -0
- package/lib/cli/args.js.map +1 -0
- package/lib/cli/bootstrap.js +449 -0
- package/lib/cli/bootstrap.js.map +1 -0
- package/lib/cli/command-setup.js +484 -0
- package/lib/cli/command-setup.js.map +1 -0
- package/lib/cli/commands-client.js +359 -0
- package/lib/cli/commands-client.js.map +1 -0
- package/lib/cli/commands-host.js +1108 -0
- package/lib/cli/commands-host.js.map +1 -0
- package/lib/cli/commands-status.js +279 -0
- package/lib/cli/commands-status.js.map +1 -0
- package/lib/cli/config-writer.js +334 -0
- package/lib/cli/config-writer.js.map +1 -0
- package/lib/cli/context.js +10 -0
- package/lib/cli/context.js.map +1 -0
- package/lib/cli/i18n.js +620 -0
- package/lib/cli/i18n.js.map +1 -0
- package/lib/cli/ipc-client.js +160 -0
- package/lib/cli/ipc-client.js.map +1 -0
- package/lib/cli/ipc-server.js +464 -0
- package/lib/cli/ipc-server.js.map +1 -0
- package/lib/cli/main.js +245 -0
- package/lib/cli/main.js.map +1 -0
- package/lib/cli/qr-terminal.js +48 -0
- package/lib/cli/qr-terminal.js.map +1 -0
- package/lib/client-data/qr.js +286 -0
- package/lib/client-data/qr.js.map +1 -0
- package/lib/client-routes.js +308 -25
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +1013 -229
- package/lib/config.js +50 -12
- package/lib/config.js.map +1 -1
- package/lib/debug-report.js +28 -1
- package/lib/debug-report.js.map +1 -1
- package/lib/fetch-route-intercept.js +334 -26
- package/lib/fetch-route-intercept.js.map +1 -1
- package/lib/index.js +573 -366
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +1833 -280
- package/lib/intercept.js.map +1 -1
- package/lib/lan-gate-server.cjs +36 -10
- package/lib/merge-streams.js +103 -11
- package/lib/merge-streams.js.map +1 -1
- package/lib/plugin-route-intercept.js +93 -63
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/relay-access.js +8 -1
- package/lib/relay-access.js.map +1 -1
- package/lib/relay-client.js +133 -4
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-pool.js +275 -0
- package/lib/relay-pool.js.map +1 -0
- package/lib/relay-server.js +269 -17
- package/lib/relay-server.js.map +1 -1
- package/lib/restart-fields.js +6 -4
- package/lib/restart-fields.js.map +1 -1
- package/lib/share-store.js +65 -27
- package/lib/share-store.js.map +1 -1
- package/lib/state-store.js +400 -0
- package/lib/state-store.js.map +1 -0
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/cli/args.d.ts +78 -0
- package/lib/types/cli/args.d.ts.map +1 -0
- package/lib/types/cli/bootstrap.d.ts +170 -0
- package/lib/types/cli/bootstrap.d.ts.map +1 -0
- package/lib/types/cli/command-setup.d.ts +72 -0
- package/lib/types/cli/command-setup.d.ts.map +1 -0
- package/lib/types/cli/commands-client.d.ts +36 -0
- package/lib/types/cli/commands-client.d.ts.map +1 -0
- package/lib/types/cli/commands-host.d.ts +82 -0
- package/lib/types/cli/commands-host.d.ts.map +1 -0
- package/lib/types/cli/commands-status.d.ts +46 -0
- package/lib/types/cli/commands-status.d.ts.map +1 -0
- package/lib/types/cli/config-writer.d.ts +92 -0
- package/lib/types/cli/config-writer.d.ts.map +1 -0
- package/lib/types/cli/context.d.ts +39 -0
- package/lib/types/cli/context.d.ts.map +1 -0
- package/lib/types/cli/i18n.d.ts +32 -0
- package/lib/types/cli/i18n.d.ts.map +1 -0
- package/lib/types/cli/ipc-client.d.ts +83 -0
- package/lib/types/cli/ipc-client.d.ts.map +1 -0
- package/lib/types/cli/ipc-server.d.ts +113 -0
- package/lib/types/cli/ipc-server.d.ts.map +1 -0
- package/lib/types/cli/main.d.ts +57 -0
- package/lib/types/cli/main.d.ts.map +1 -0
- package/lib/types/cli/qr-terminal.d.ts +17 -0
- package/lib/types/cli/qr-terminal.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +4 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -1
- package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
- package/lib/types/client/effects/remote-session.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +47 -3
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/settings/settings-css.d.ts +1 -1
- package/lib/types/client/settings/settings-css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
- package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
- package/lib/types/client-data/model-group-side.d.ts +23 -2
- package/lib/types/client-data/model-group-side.d.ts.map +1 -1
- package/lib/types/client-data/remote-status.d.ts +47 -1
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +85 -1
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-data/wizard.d.ts +56 -1
- package/lib/types/client-data/wizard.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +25 -1
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/config.d.ts +40 -14
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/debug-report.d.ts.map +1 -1
- package/lib/types/fetch-route-intercept.d.ts +66 -8
- package/lib/types/fetch-route-intercept.d.ts.map +1 -1
- package/lib/types/index.d.ts +9 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +72 -10
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts +79 -1
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +25 -9
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/relay-access.d.ts +3 -1
- package/lib/types/relay-access.d.ts.map +1 -1
- package/lib/types/relay-client.d.ts +23 -2
- package/lib/types/relay-client.d.ts.map +1 -1
- package/lib/types/relay-pool.d.ts +148 -0
- package/lib/types/relay-pool.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +30 -3
- package/lib/types/relay-server.d.ts.map +1 -1
- package/lib/types/restart-fields.d.ts +5 -5
- package/lib/types/restart-fields.d.ts.map +1 -1
- package/lib/types/share-store.d.ts.map +1 -1
- package/lib/types/state-store.d.ts +179 -0
- package/lib/types/state-store.d.ts.map +1 -0
- package/lib/types/upload-limits.d.ts +15 -0
- package/lib/types/upload-limits.d.ts.map +1 -1
- package/lib/types/virtual-id.d.ts +21 -0
- package/lib/types/virtual-id.d.ts.map +1 -1
- package/lib/types/wizard/probe.d.ts.map +1 -1
- package/lib/types/wizard/proxy-snippets.d.ts +3 -0
- package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
- package/lib/types/wizard/routes.d.ts +1 -0
- package/lib/types/wizard/routes.d.ts.map +1 -1
- package/lib/types/wizard/status.d.ts +9 -0
- package/lib/types/wizard/status.d.ts.map +1 -1
- package/lib/types/wizard/verify.d.ts +3 -0
- package/lib/types/wizard/verify.d.ts.map +1 -1
- package/lib/upload-limits.js +15 -0
- package/lib/upload-limits.js.map +1 -1
- package/lib/virtual-id.js +60 -0
- package/lib/virtual-id.js.map +1 -1
- package/lib/wizard/probe.js +46 -1
- package/lib/wizard/probe.js.map +1 -1
- package/lib/wizard/proxy-snippets.js +14 -1
- package/lib/wizard/proxy-snippets.js.map +1 -1
- package/lib/wizard/routes.js +23 -4
- package/lib/wizard/routes.js.map +1 -1
- package/lib/wizard/status.js +21 -0
- package/lib/wizard/status.js.map +1 -1
- package/lib/wizard/verify.js +31 -8
- package/lib/wizard/verify.js.map +1 -1
- package/package.json +8 -4
- package/pwa/icons/badge-96.png +0 -0
- package/pwa/icons/badge.svg +1 -0
- package/pwa/manifest.json +2 -1
- package/pwa/sw.js +3 -1
- package/scripts/check-debug-report.mjs +41 -1
- package/scripts/check-settings-form.mjs +14 -3
- package/scripts/check-settings-view.mjs +247 -0
- package/scripts/check-wizard-ui.mjs +37 -0
- package/src/adapters/dsh-better-sidebar/index.ts +7 -3
- package/src/admin-routes.ts +10 -2
- package/src/cli/args.ts +289 -0
- package/src/cli/bootstrap.ts +569 -0
- package/src/cli/command-setup.ts +540 -0
- package/src/cli/commands-client.ts +369 -0
- package/src/cli/commands-host.ts +1127 -0
- package/src/cli/commands-status.ts +296 -0
- package/src/cli/config-writer.ts +354 -0
- package/src/cli/context.ts +38 -0
- package/src/cli/i18n.ts +645 -0
- package/src/cli/ipc-client.ts +216 -0
- package/src/cli/ipc-server.ts +531 -0
- package/src/cli/main.ts +285 -0
- package/src/cli/qr-terminal.ts +49 -0
- package/src/client/RemoteStatusIcon.tsx +4 -1
- package/src/client/current-session-report.ts +28 -4
- package/src/client/effects/model-group-side.ts +19 -5
- package/src/client/effects/remote-session.ts +26 -6
- package/src/client/locales.ts +96 -6
- package/src/client/settings/SettingsSection.tsx +287 -92
- package/src/client/settings/settings-css.ts +18 -0
- package/src/client/styles/remote-session.css.ts +15 -1
- package/src/client/wizard/GatewayWizard.tsx +470 -109
- package/src/client/wizard/cards/ProxyCard.tsx +2 -1
- package/src/client/wizard/wizard-css.ts +23 -0
- package/src/client-data/model-group-side.ts +37 -10
- package/src/client-data/remote-status.ts +112 -17
- package/src/client-data/settings-form.ts +164 -8
- package/src/client-data/wizard.ts +112 -2
- package/src/client-routes.ts +334 -26
- package/src/config.ts +62 -16
- package/src/debug-report.ts +27 -1
- package/src/fetch-route-intercept.ts +381 -32
- package/src/index.ts +575 -370
- package/src/intercept.ts +1866 -286
- package/src/merge-streams.ts +155 -10
- package/src/plugin-route-intercept.ts +120 -71
- package/src/relay-access.ts +14 -3
- package/src/relay-client.ts +163 -5
- package/src/relay-pool.ts +387 -0
- package/src/relay-server.ts +312 -16
- package/src/restart-fields.ts +6 -4
- package/src/share-store.ts +59 -26
- package/src/state-store.ts +428 -0
- package/src/upload-limits.ts +16 -0
- package/src/virtual-id.ts +57 -0
- package/src/wizard/probe.ts +54 -2
- package/src/wizard/proxy-snippets.ts +17 -2
- package/src/wizard/routes.ts +24 -5
- package/src/wizard/status.ts +28 -0
- package/src/wizard/verify.ts +33 -11
- package/AGENTS.md +0 -185
- package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
- 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
|
+
}
|