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