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