dsh-foreman-node 0.1.0 → 0.2.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 +64 -3
- package/LICENSE +21 -21
- package/README.md +29 -0
- package/cordis.patch.yml +0 -0
- package/lib/client.js +611 -0
- package/lib/index.d.ts +245 -28
- package/lib/index.js +620 -16
- package/package.json +27 -3
- package/src/client/index.tsx +211 -0
- package/src/client/locales.ts +95 -0
- package/src/client/page-state.ts +99 -0
- package/src/client.ts +0 -0
- package/src/config.ts +100 -0
- package/src/index.ts +141 -33
- package/src/keys.ts +49 -0
- package/src/protocol.ts +0 -0
- package/src/report-tool.ts +0 -0
- package/src/routes.ts +157 -0
- package/src/session.ts +0 -0
- package/src/tunnel.ts +267 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-foreman-node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Device side of dsh-foreman: connects out to a foreman hub, takes a job into a local DSH session, reports back. No ledger, no policy, no database.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"default": "./lib/protocol.js"
|
|
16
16
|
},
|
|
17
17
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
18
|
-
"./package.json": "./package.json"
|
|
18
|
+
"./package.json": "./package.json",
|
|
19
|
+
"./client": "./lib/client.js"
|
|
19
20
|
},
|
|
20
21
|
"files": [
|
|
21
22
|
"lib",
|
|
@@ -34,6 +35,15 @@
|
|
|
34
35
|
},
|
|
35
36
|
"engines": {
|
|
36
37
|
"dsh": "^0.2.1-alpha.2"
|
|
38
|
+
},
|
|
39
|
+
"client": {
|
|
40
|
+
"platform": "web",
|
|
41
|
+
"inject": [
|
|
42
|
+
"@deepseek-ai/dsh-client-locale",
|
|
43
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
44
|
+
"@deepseek-ai/dsh-client-ui-plugin-manager",
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-renderer"
|
|
46
|
+
]
|
|
37
47
|
}
|
|
38
48
|
},
|
|
39
49
|
"scripts": {
|
|
@@ -47,6 +57,10 @@
|
|
|
47
57
|
"@deepseek-ai/cosmokit": "1.8.5",
|
|
48
58
|
"@deepseek-ai/dsh-agent": "0.2.1-alpha.2",
|
|
49
59
|
"@deepseek-ai/dsh-api-session-controller": "0.2.1-alpha.2",
|
|
60
|
+
"@deepseek-ai/dsh-client-locale": "0.2.1-alpha.2",
|
|
61
|
+
"@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.1-alpha.2",
|
|
62
|
+
"@deepseek-ai/dsh-client-ui-renderer": "0.2.1-alpha.2",
|
|
63
|
+
"@deepseek-ai/dsh-client-ui-settings": "0.2.1-alpha.2",
|
|
50
64
|
"@deepseek-ai/dsh-llm": "0.2.1-alpha.2",
|
|
51
65
|
"@deepseek-ai/dsh-permission-presets": "0.2.1-alpha.2",
|
|
52
66
|
"@deepseek-ai/dsh-session": "0.2.1-alpha.2",
|
|
@@ -55,6 +69,10 @@
|
|
|
55
69
|
"@deepseek-ai/dsh-workspace": "0.2.1-alpha.2",
|
|
56
70
|
"@deepseek-ai/schemastery": "3.18.5-alpha.1",
|
|
57
71
|
"@types/node": "22.20.0",
|
|
72
|
+
"@types/react": "18.3.28",
|
|
73
|
+
"@types/react-dom": "18.3.7",
|
|
74
|
+
"react": "18.3.1",
|
|
75
|
+
"react-dom": "18.3.1",
|
|
58
76
|
"tsdown": "0.22.14",
|
|
59
77
|
"typescript": "6.0.3",
|
|
60
78
|
"vitest": "4.1.8"
|
|
@@ -64,13 +82,19 @@
|
|
|
64
82
|
"@deepseek-ai/cosmokit": "1.8.5",
|
|
65
83
|
"@deepseek-ai/dsh-agent": "^0.2.1-alpha.2",
|
|
66
84
|
"@deepseek-ai/dsh-api-session-controller": "^0.2.1-alpha.2",
|
|
85
|
+
"@deepseek-ai/dsh-client-locale": "^0.2.1-alpha.2",
|
|
86
|
+
"@deepseek-ai/dsh-client-ui-plugin-manager": "^0.2.1-alpha.2",
|
|
87
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.2.1-alpha.2",
|
|
88
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.2.1-alpha.2",
|
|
67
89
|
"@deepseek-ai/dsh-llm": "^0.2.1-alpha.2",
|
|
68
90
|
"@deepseek-ai/dsh-permission-presets": "^0.2.1-alpha.2",
|
|
69
91
|
"@deepseek-ai/dsh-session": "^0.2.1-alpha.2",
|
|
70
92
|
"@deepseek-ai/dsh-session-persistence": "^0.2.1-alpha.2",
|
|
71
93
|
"@deepseek-ai/dsh-tools": "^0.2.1-alpha.2",
|
|
72
94
|
"@deepseek-ai/dsh-workspace": "^0.2.1-alpha.2",
|
|
73
|
-
"@deepseek-ai/schemastery": "3.18.5-alpha.1"
|
|
95
|
+
"@deepseek-ai/schemastery": "3.18.5-alpha.1",
|
|
96
|
+
"react": "^18.3.1",
|
|
97
|
+
"react-dom": "^18.3.1"
|
|
74
98
|
},
|
|
75
99
|
"license": "MIT",
|
|
76
100
|
"author": "niechao136",
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The device's configuration page, mounted in the DSH Plugin Manager.
|
|
3
|
+
*
|
|
4
|
+
* It does two things the page alone cannot: it lists the keys this machine has (through the device's own route, because
|
|
5
|
+
* a browser cannot read `~/.ssh`) and it tests one authentication (also through the device, because a browser cannot
|
|
6
|
+
* start ssh). Everything it *writes* goes through DSH's own configuration form, so the saved values land in the
|
|
7
|
+
* profile's patch exactly as if they had been typed there by hand.
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useState } from 'react'
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
11
|
+
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
12
|
+
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
|
13
|
+
import type { ConfigPageForm, PluginConfigViewProps } from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
|
|
14
|
+
import { configured, type NodeConfig } from '../config.ts'
|
|
15
|
+
import type { SshKey } from '../keys.ts'
|
|
16
|
+
import type { TestResult } from '../routes.ts'
|
|
17
|
+
import { addressPreview, DEFAULT_SSH, editsOf, emptyFields, keyLabel, testMessage } from './page-state.ts'
|
|
18
|
+
import { en, zh } from './locales.ts'
|
|
19
|
+
|
|
20
|
+
/** The prefix the device's own routes answer under. */
|
|
21
|
+
const API = '/foreman-node/api'
|
|
22
|
+
|
|
23
|
+
/** A key list answer, as `GET /foreman-node/api/keys` sends it. */
|
|
24
|
+
interface KeysAnswer { keys: SshKey[]; directory: string }
|
|
25
|
+
|
|
26
|
+
/** Fetches a key list or runs one test; both need the marker header the device's same-origin check looks for. */
|
|
27
|
+
async function ask<T>(path: string, init?: RequestInit): Promise<T> {
|
|
28
|
+
const response = await fetch(new URL(`${API}/${path}`, window.location.href), {
|
|
29
|
+
...init, headers: { 'x-orchestrator-request': '1', ...(init?.body === undefined ? {} : { 'content-type': 'application/json' }), ...init?.headers } })
|
|
30
|
+
if (!response.ok) throw new Error(`the device answered ${response.status}`)
|
|
31
|
+
return await response.json() as T
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const inject = ['locale', 'slots']
|
|
35
|
+
export const name = 'dsh-foreman-node'
|
|
36
|
+
|
|
37
|
+
/** The locale namespace this page registers its dictionaries under. */
|
|
38
|
+
const NS = 'dshNode'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The page itself. `t` comes from the host's locale service, like every other plugin's page.
|
|
42
|
+
*
|
|
43
|
+
* `initialKeys` and `keysFailed` exist so a test can render a page without a network or a web server. Production never
|
|
44
|
+
* passes them, so the page fetches as it otherwise would — they change nothing about how it behaves.
|
|
45
|
+
*/
|
|
46
|
+
function DevicePage({ form, t, initialKeys, keysFailed: prefailed }: {
|
|
47
|
+
t: (key: keyof typeof zh) => string
|
|
48
|
+
form: ConfigPageForm | undefined
|
|
49
|
+
initialKeys?: readonly SshKey[]
|
|
50
|
+
keysFailed?: boolean
|
|
51
|
+
}) {
|
|
52
|
+
// The draft starts from the saved value when there is one, so the first render already shows the configuration
|
|
53
|
+
// rather than a loading line that only an effect would replace.
|
|
54
|
+
// The host hands over the section's raw value; every field is volatile, so it is unwrapped once here.
|
|
55
|
+
const saved = form?.state.value === undefined ? undefined : configured(form.state.value as Partial<NodeConfig>)
|
|
56
|
+
const editable = form?.state.status === 'ready' && form.state.writable
|
|
57
|
+
// The draft starts from the saved value when there is one, so the first render already shows the configuration
|
|
58
|
+
// rather than a loading line that only an effect would replace.
|
|
59
|
+
const [draft, setDraft] = useState<NodeConfig | undefined>(() =>
|
|
60
|
+
saved === undefined ? undefined : { ...saved, token: '', ssh: { ...DEFAULT_SSH, ...saved.ssh } })
|
|
61
|
+
// The stored token never reaches the page (it is a secret), so the box starts empty and is only written when
|
|
62
|
+
// someone types one. `keepsToken` is what tells the page it does not have to ask.
|
|
63
|
+
// A stored token cannot be detected: it is stripped before it reaches the page, so `saved.token` is always
|
|
64
|
+
// undefined. What can be known is that the section already exists, which is what the hint is based on.
|
|
65
|
+
const keepsToken = saved !== undefined
|
|
66
|
+
const [keys, setKeys] = useState<SshKey[] | undefined>(initialKeys === undefined ? undefined : [...initialKeys])
|
|
67
|
+
const [keysFailed, setKeysFailed] = useState(prefailed === true)
|
|
68
|
+
const [tested, setTested] = useState<TestResult>()
|
|
69
|
+
const [testing, setTesting] = useState(false)
|
|
70
|
+
const [notice, setNotice] = useState<string>()
|
|
71
|
+
|
|
72
|
+
// The form's value is the saved truth; the draft is what a person is editing on top of it, so it is only adopted
|
|
73
|
+
// when nothing has been typed yet — a form that reloads must not overwrite a half-finished edit.
|
|
74
|
+
useEffect(() => { if (saved !== undefined) setDraft(current => current ?? { ...saved, ssh: { ...DEFAULT_SSH, ...saved.ssh } }) }, [saved])
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (initialKeys !== undefined || prefailed === true) return
|
|
77
|
+
ask<KeysAnswer>('keys').then(answer => setKeys(answer.keys), () => setKeysFailed(true))
|
|
78
|
+
}, [initialKeys, prefailed])
|
|
79
|
+
|
|
80
|
+
const patch = (change: Partial<NodeConfig>) => setDraft(current => current === undefined ? current : { ...current, ...change })
|
|
81
|
+
const patchSsh = (change: Partial<NodeConfig['ssh']>) =>
|
|
82
|
+
setDraft(current => current === undefined ? current : { ...current, ssh: { ...current.ssh, ...change } })
|
|
83
|
+
|
|
84
|
+
const test = async () => {
|
|
85
|
+
if (draft === undefined) return
|
|
86
|
+
setTesting(true); setTested(undefined)
|
|
87
|
+
try {
|
|
88
|
+
setTested(await ask<TestResult>('test', { method: 'POST', body: JSON.stringify({ host: draft.ssh.host.trim(),
|
|
89
|
+
port: draft.ssh.port, user: draft.ssh.user.trim(), keyPath: draft.ssh.keyPath.trim() }) }))
|
|
90
|
+
} catch (error) { setTested({ ok: false, output: String(error) }) } finally { setTesting(false) }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const save = async () => {
|
|
94
|
+
if (draft === undefined || form === undefined) return
|
|
95
|
+
setNotice(undefined)
|
|
96
|
+
try {
|
|
97
|
+
const ok = await form.mutate(editsOf(draft), form.state.revision)
|
|
98
|
+
setNotice(ok ? t('saved') : t('saveFailed'))
|
|
99
|
+
} catch { setNotice(t('saveFailed')) }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (draft === undefined) return <div data-foreman-node-page="loading">{t('loading')}</div>
|
|
103
|
+
const address = addressPreview(draft)
|
|
104
|
+
const missing = emptyFields(draft, keepsToken)
|
|
105
|
+
return <div data-foreman-node-page="ready">
|
|
106
|
+
<h2>{t('title')}</h2>
|
|
107
|
+
<p className="dt-hint">{t('intro')}</p>
|
|
108
|
+
|
|
109
|
+
<section data-section="keys">
|
|
110
|
+
<h3>{t('keysSection')}</h3>
|
|
111
|
+
<p className="dt-hint">{t('keysHint')}</p>
|
|
112
|
+
{keysFailed
|
|
113
|
+
? <p data-keys="unavailable">{t('keysUnavailable')}</p>
|
|
114
|
+
: keys === undefined ? <p>{t('loading')}</p>
|
|
115
|
+
: keys.length === 0 ? <p data-keys="empty">{t('keysEmpty')}</p>
|
|
116
|
+
: <select aria-label={t('keysSection')} data-node-key value={draft.ssh.keyPath}
|
|
117
|
+
onChange={event => patchSsh({ keyPath: event.target.value })}>
|
|
118
|
+
<option value="">—</option>
|
|
119
|
+
{keys.map(key => <option key={key.path} value={key.path}>{keyLabel(key)}</option>)}
|
|
120
|
+
</select>}
|
|
121
|
+
<label>{t('keyPath')}
|
|
122
|
+
<input data-node-key-path value={draft.ssh.keyPath} disabled={!editable}
|
|
123
|
+
onChange={event => patchSsh({ keyPath: event.target.value })} />
|
|
124
|
+
</label>
|
|
125
|
+
</section>
|
|
126
|
+
|
|
127
|
+
<section data-section="ssh">
|
|
128
|
+
<h3>{t('sshSection')}</h3>
|
|
129
|
+
<label><input type="checkbox" data-node-ssh-enabled checked={draft.ssh.enabled} disabled={!editable}
|
|
130
|
+
onChange={event => patchSsh({ enabled: event.target.checked })} />{t('sshEnabled')}</label>
|
|
131
|
+
<p className="dt-hint">{t('sshEnabledHint')}</p>
|
|
132
|
+
{draft.ssh.enabled && <>
|
|
133
|
+
<label>{t('host')}<input data-node-host value={draft.ssh.host} disabled={!editable}
|
|
134
|
+
onChange={event => patchSsh({ host: event.target.value })} /></label>
|
|
135
|
+
<label>{t('port')}<input type="number" data-node-port value={draft.ssh.port} disabled={!editable}
|
|
136
|
+
onChange={event => patchSsh({ port: Number(event.target.value) || DEFAULT_SSH.port })} /></label>
|
|
137
|
+
<label>{t('user')}<input data-node-user value={draft.ssh.user} disabled={!editable}
|
|
138
|
+
onChange={event => patchSsh({ user: event.target.value })} /></label>
|
|
139
|
+
<label>{t('remotePort')}<input type="number" data-node-remote-port value={draft.ssh.remotePort} disabled={!editable}
|
|
140
|
+
onChange={event => patchSsh({ remotePort: Number(event.target.value) || DEFAULT_SSH.remotePort })} /></label>
|
|
141
|
+
<label>{t('localPort')}<input type="number" data-node-local-port value={draft.ssh.localPort} disabled={!editable}
|
|
142
|
+
onChange={event => patchSsh({ localPort: Number(event.target.value) || DEFAULT_SSH.localPort })} /></label>
|
|
143
|
+
<button type="button" data-node-test data-testing={testing ? 'true' : undefined}
|
|
144
|
+
disabled={!draft.ssh.host.trim() || !draft.ssh.user.trim() || !draft.ssh.keyPath.trim()}
|
|
145
|
+
onClick={() => { void test() }}>{testing ? t('testing') : t('test')}</button>
|
|
146
|
+
{tested !== undefined && <p data-test-result={testMessage(tested)}>
|
|
147
|
+
{tested.ok ? t('testOk')
|
|
148
|
+
: testMessage(tested) === 'passphrase' ? t('testPassphrase')
|
|
149
|
+
: testMessage(tested) === 'unresolved' ? t('testUnresolved')
|
|
150
|
+
: testMessage(tested) === 'timeout' ? t('testTimeout')
|
|
151
|
+
: `${t('testRefused')} ${tested.output}`}
|
|
152
|
+
</p>}
|
|
153
|
+
</>}
|
|
154
|
+
</section>
|
|
155
|
+
|
|
156
|
+
<section data-section="hub">
|
|
157
|
+
<h3>{t('hubSection')}</h3>
|
|
158
|
+
<label>{t('address')}
|
|
159
|
+
<input data-node-url value={address.address} readOnly={address.derived} disabled={!editable || address.derived}
|
|
160
|
+
onChange={event => patch({ url: event.target.value })} />
|
|
161
|
+
</label>
|
|
162
|
+
{address.derived && <p className="dt-hint">{t('addressDerived')}</p>}
|
|
163
|
+
<label>{t('deviceId')}<input data-node-device value={draft.deviceId} disabled={!editable}
|
|
164
|
+
onChange={event => patch({ deviceId: event.target.value })} /></label>
|
|
165
|
+
<label>{t('token')}<input type="password" data-node-token value={draft.token} disabled={!editable}
|
|
166
|
+
autoComplete="new-password" placeholder={keepsToken ? t('tokenStored') : ''}
|
|
167
|
+
onChange={event => patch({ token: event.target.value })} /></label>
|
|
168
|
+
<p className="dt-hint">{keepsToken ? t('tokenKeep') : t('tokenHint')}</p>
|
|
169
|
+
</section>
|
|
170
|
+
|
|
171
|
+
<section data-section="roots">
|
|
172
|
+
<h3>{t('rootsSection')}</h3>
|
|
173
|
+
<p className="dt-hint">{t('rootsHint')}</p>
|
|
174
|
+
{draft.roots.map((root, index) => <div key={index} data-root={index}>
|
|
175
|
+
<label>{t('project')}<input value={root.project} disabled={!editable}
|
|
176
|
+
onChange={event => patch({ roots: draft.roots.map((item, at) => at === index ? { ...item, project: event.target.value } : item) })} /></label>
|
|
177
|
+
<label>{t('path')}<input value={root.path} disabled={!editable}
|
|
178
|
+
onChange={event => patch({ roots: draft.roots.map((item, at) => at === index ? { ...item, path: event.target.value } : item) })} /></label>
|
|
179
|
+
</div>)}
|
|
180
|
+
<button type="button" disabled={!editable}
|
|
181
|
+
onClick={() => patch({ roots: [...draft.roots, { project: '', path: '' }] })}>{t('addRoot')}</button>
|
|
182
|
+
</section>
|
|
183
|
+
|
|
184
|
+
{missing.length > 0 && <p data-missing="true">{t('missing')}{missing.join(', ')}</p>}
|
|
185
|
+
{editable
|
|
186
|
+
? <button type="button" data-node-save onClick={() => { void save() }}>{t('save')}</button>
|
|
187
|
+
: <p data-read-only="true">{t('readOnly')}</p>}
|
|
188
|
+
{notice !== undefined && <p data-notice>{notice}</p>}
|
|
189
|
+
</div>
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The page as the Plugin Manager mounts it: locale and form come from the host. */
|
|
193
|
+
interface PageProps extends PluginConfigViewProps {
|
|
194
|
+
t: (key: keyof typeof zh) => string
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function Mounted({ t, view, form = undefined }: PageProps) {
|
|
198
|
+
// A summary row gets nothing: the full page belongs to the page view, and DSH's own config form shows the summary.
|
|
199
|
+
return view === 'summary' ? null : <DevicePage t={t} form={form} />
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Registers the page for the `dsh-foreman-node` row. */
|
|
203
|
+
export function apply(ctx: Context): void {
|
|
204
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-foreman-node: dictionaries')
|
|
205
|
+
const t = ctx.locale.bind(NS) as unknown as (key: keyof typeof zh) => string
|
|
206
|
+
ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
|
|
207
|
+
name: 'plugins.row.config', key: 'dsh-foreman-node#dsh-foreman-node', locale: NS },
|
|
208
|
+
(props: PluginConfigViewProps) => <Mounted {...props} t={(props as { t?: typeof t }).t ?? t} />))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export { DevicePage }
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** The device page's own words; the host renders them through its locale service. */
|
|
2
|
+
export const zh = {
|
|
3
|
+
title: '设备连接',
|
|
4
|
+
intro: '这台机器用自己的密钥连上枢纽,隧道也由它自己开。填完保存后重启一次 DSH 生效。',
|
|
5
|
+
keysSection: '密钥',
|
|
6
|
+
keysHint: '从这台机器 ~/.ssh 里的密钥中挑一把。枢纽只认公钥,私钥不离开这里。',
|
|
7
|
+
keysEmpty: '~/.ssh 里没有可用的密钥(需要私钥和同名的 .pub 一起在)。',
|
|
8
|
+
keysUnavailable: '读不到密钥列表:配置页需要 DSH 的 Web 界面。',
|
|
9
|
+
keyPath: '私钥路径',
|
|
10
|
+
sshSection: 'SSH',
|
|
11
|
+
sshEnabled: '走 SSH 隧道',
|
|
12
|
+
sshEnabledHint: '枢纽只监听 127.0.0.1,所以别的机器要连它就得走隧道。关掉表示枢纽地址本身可达。',
|
|
13
|
+
host: '枢纽机器',
|
|
14
|
+
port: 'SSH 端口',
|
|
15
|
+
user: 'SSH 用户',
|
|
16
|
+
remotePort: '枢纽端口',
|
|
17
|
+
localPort: '本机端口',
|
|
18
|
+
test: '测试连接',
|
|
19
|
+
testing: '测试中…',
|
|
20
|
+
testPassphrase: '这台机器拒绝了这把钥匙。如果它有口令,请改用 ssh-agent,或换一把没有口令的。',
|
|
21
|
+
testUnresolved: '找不到这台枢纽机器,检查主机名。',
|
|
22
|
+
testTimeout: '超时了,网络不通或被防火墙挡住。',
|
|
23
|
+
testRefused: '连不上。',
|
|
24
|
+
testOk: '连上了。',
|
|
25
|
+
hubSection: '枢纽',
|
|
26
|
+
address: '设备将连接的地址',
|
|
27
|
+
addressDerived: '(由隧道推导,上面的地址不用于连接)',
|
|
28
|
+
deviceId: '设备 id',
|
|
29
|
+
token: '令牌',
|
|
30
|
+
tokenHint: '在枢纽的「设置 › 设备」里登记设备时得到,只显示那一次。',
|
|
31
|
+
tokenStored: '留空即保留已存的令牌',
|
|
32
|
+
tokenKeep: '已存的令牌不会显示在这里。留空表示不动它;只有要换一个时才需要填。',
|
|
33
|
+
rootsSection: '项目路径',
|
|
34
|
+
rootsHint: '枢纽把工单派到项目名;这里的路径是它在您这台机器上的位置。',
|
|
35
|
+
addRoot: '加一个项目',
|
|
36
|
+
project: '项目名',
|
|
37
|
+
path: '本机路径',
|
|
38
|
+
save: '保存',
|
|
39
|
+
saved: '已保存,重启 DSH 后生效。',
|
|
40
|
+
saveFailed: '保存失败。',
|
|
41
|
+
readOnly: '这个宿主不提供配置写入,请手工编辑 profile 的 cordis.patch.yml。',
|
|
42
|
+
loading: '读取配置…',
|
|
43
|
+
missing: '还需要填:',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const en: typeof zh = {
|
|
47
|
+
title: 'Device connection',
|
|
48
|
+
intro: 'This machine reaches the hub with its own key and opens its own tunnel. Save, then restart DSH once.',
|
|
49
|
+
keysSection: 'Key',
|
|
50
|
+
keysHint: "Pick one of the keys in this machine's ~/.ssh. The hub only ever sees the public half.",
|
|
51
|
+
keysEmpty: 'No usable key in ~/.ssh: a private key needs its .pub beside it.',
|
|
52
|
+
keysUnavailable: 'The key list needs the DSH web interface.',
|
|
53
|
+
keyPath: 'Private key',
|
|
54
|
+
sshSection: 'SSH',
|
|
55
|
+
sshEnabled: 'Reach the hub through an SSH tunnel',
|
|
56
|
+
sshEnabledHint: 'The hub listens on 127.0.0.1 only, so another machine reaches it through a tunnel. Off means the hub address is already reachable.',
|
|
57
|
+
host: 'Hub machine',
|
|
58
|
+
port: 'SSH port',
|
|
59
|
+
user: 'SSH user',
|
|
60
|
+
remotePort: 'Hub port',
|
|
61
|
+
localPort: 'Local port',
|
|
62
|
+
test: 'Test connection',
|
|
63
|
+
testing: 'Testing…',
|
|
64
|
+
testPassphrase: 'This machine refused the key. If it has a passphrase, use ssh-agent or pick one without.',
|
|
65
|
+
testUnresolved: 'The hub machine does not resolve; check the host name.',
|
|
66
|
+
testTimeout: 'Timed out: the network is blocked or unreachable.',
|
|
67
|
+
testRefused: 'Could not connect.',
|
|
68
|
+
testOk: 'Connected.',
|
|
69
|
+
hubSection: 'Hub',
|
|
70
|
+
address: 'The address this device will dial',
|
|
71
|
+
addressDerived: ' (derived from the tunnel; the address above is not used)',
|
|
72
|
+
deviceId: 'Device id',
|
|
73
|
+
token: 'Token',
|
|
74
|
+
tokenHint: "Shown once when the device was registered, on the hub's Settings › Devices.",
|
|
75
|
+
tokenStored: 'leave empty to keep the stored token',
|
|
76
|
+
tokenKeep: 'The stored token is never shown here. Leave this empty to keep it; type a new one only to replace it.',
|
|
77
|
+
rootsSection: 'Project paths',
|
|
78
|
+
rootsHint: 'The hub assigns work by project name; this is where that project lives on this machine.',
|
|
79
|
+
addRoot: 'Add a project',
|
|
80
|
+
path: 'Local path',
|
|
81
|
+
project: 'Project',
|
|
82
|
+
save: 'Save',
|
|
83
|
+
saved: 'Saved. Restart DSH for it to take effect.',
|
|
84
|
+
saveFailed: 'Could not save.',
|
|
85
|
+
readOnly: "This host does not accept configuration writes; edit the profile's cordis.patch.yml by hand.",
|
|
86
|
+
loading: 'Reading configuration…',
|
|
87
|
+
missing: 'Still needed: ',
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The page's key union, so a missing translation is a compile error rather than a raw key on screen. */
|
|
91
|
+
export type DshNodeKey = keyof typeof zh
|
|
92
|
+
|
|
93
|
+
declare module '@deepseek-ai/dsh-client-ui-renderer/client' {
|
|
94
|
+
interface LocaleNamespaceMap { dshNode: DshNodeKey }
|
|
95
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the device's configuration page decides, as pure functions.
|
|
3
|
+
*
|
|
4
|
+
* The page itself is thin React: it renders these results and sends the field edits back through DSH's configuration
|
|
5
|
+
* form. Keeping the decisions here — which keys to offer, what the address becomes, what to say about a failed test —
|
|
6
|
+
* means they are provable without a DOM, which matters for a package this small.
|
|
7
|
+
*/
|
|
8
|
+
import type { NodeConfig, SshConfig } from '../config.ts'
|
|
9
|
+
import type { SshKey } from '../keys.ts'
|
|
10
|
+
import type { TestResult } from '../routes.ts'
|
|
11
|
+
|
|
12
|
+
/** A key as one line of the picker: the filename and, when it has one, the comment that tells it apart. */
|
|
13
|
+
export function keyLabel(key: SshKey): string {
|
|
14
|
+
return key.comment === '' ? key.name : `${key.name} · ${key.comment}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The address the device will dial, spelled out for the page.
|
|
19
|
+
*
|
|
20
|
+
* With the tunnel on this is the local end of it and `url` is not used — which is the point a person most easily
|
|
21
|
+
* gets wrong, so the page says the address rather than leaving them to infer it.
|
|
22
|
+
*/
|
|
23
|
+
export function addressPreview(config: Pick<NodeConfig, 'url' | 'ssh'>): { address: string; derived: boolean } {
|
|
24
|
+
return config.ssh.enabled
|
|
25
|
+
? { address: `ws://127.0.0.1:${config.ssh.localPort}`, derived: true }
|
|
26
|
+
: { address: config.url, derived: false }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The names of the fields that still have to be filled in, in the order the page shows them.
|
|
31
|
+
*
|
|
32
|
+
* This mirrors `missingConfiguration` for the page's benefit: the host reports the same thing in one sentence, and a
|
|
33
|
+
* form that highlights the empty field is the difference between reading a sentence and fixing it.
|
|
34
|
+
*/
|
|
35
|
+
export function emptyFields(config: NodeConfig, tokenPresent = true): string[] {
|
|
36
|
+
const empty: string[] = []
|
|
37
|
+
if (config.ssh.enabled) {
|
|
38
|
+
if (config.ssh.host.trim() === '') empty.push('ssh.host')
|
|
39
|
+
if (config.ssh.port === 0) empty.push('ssh.port')
|
|
40
|
+
if (config.ssh.user.trim() === '') empty.push('ssh.user')
|
|
41
|
+
if (config.ssh.keyPath.trim() === '') empty.push('ssh.keyPath')
|
|
42
|
+
} else if (config.url.trim() === '') empty.push('url')
|
|
43
|
+
if (config.deviceId.trim() === '') empty.push('deviceId')
|
|
44
|
+
// The token is a secret: it never reaches the page, so an absent value means "already stored", not "missing".
|
|
45
|
+
// A page that asked for it here would nag about a field it cannot see and cannot show.
|
|
46
|
+
if (!tokenPresent) empty.push('token')
|
|
47
|
+
return empty
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What to say about one authentication test.
|
|
52
|
+
*
|
|
53
|
+
* A passphrase-protected key fails with the same words as a wrong one, so the message names both possibilities; the
|
|
54
|
+
* other failures are ssh's own and are shown as they are, which is enough for someone who can read them.
|
|
55
|
+
*/
|
|
56
|
+
/** Whether the page should ask for a token: only when nothing is stored yet. */
|
|
57
|
+
export function needsToken(config: Pick<NodeConfig, 'token'>): boolean {
|
|
58
|
+
return typeof config.token !== 'string' || config.token.trim() === ''
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function testMessage(result: TestResult): string {
|
|
62
|
+
if (result.ok) return 'ok'
|
|
63
|
+
if (/Permission denied \(publickey/.test(result.output)) return 'passphrase'
|
|
64
|
+
if (/Could not resolve hostname/.test(result.output)) return 'unresolved'
|
|
65
|
+
if (/timed out/i.test(result.output)) return 'timeout'
|
|
66
|
+
return result.output.trim() === '' ? 'refused' : result.output
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One field write, in the shape DSH's configuration form takes. */
|
|
70
|
+
export interface Edit { op: 'set'; path: (string | number)[]; value: unknown }
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The edits the page sends, one `set` per field it actually holds.
|
|
74
|
+
*
|
|
75
|
+
* This is deliberately not one `set` at the root. A root write replaces the whole section, and the page never
|
|
76
|
+
* receives the token — the host strips it as a secret — so a root write erased the stored token on every save and
|
|
77
|
+
* left the device unable to authenticate. It did exactly that on a real machine: one save took the device offline.
|
|
78
|
+
*
|
|
79
|
+
* Writing only the fields this page holds leaves every other key untouched, the token included. An empty token box
|
|
80
|
+
* means "keep whatever is stored", and that is one value the page cannot read at all.
|
|
81
|
+
*/
|
|
82
|
+
export function editsOf(draft: NodeConfig): Edit[] {
|
|
83
|
+
const edits: Edit[] = [
|
|
84
|
+
{ op: 'set', path: ['url'], value: draft.url.trim() },
|
|
85
|
+
{ op: 'set', path: ['deviceId'], value: draft.deviceId.trim() },
|
|
86
|
+
{ op: 'set', path: ['roots'], value: draft.roots.map(root => ({ project: root.project.trim(), path: root.path.trim() })) },
|
|
87
|
+
]
|
|
88
|
+
// The ssh block is written field by field: a whole-block set would replace its default with the page's view.
|
|
89
|
+
for (const key of ['enabled', 'host', 'port', 'user', 'keyPath', 'remotePort', 'localPort'] as const) {
|
|
90
|
+
const value = draft.ssh[key]
|
|
91
|
+
edits.push({ op: 'set', path: ['ssh', key], value: typeof value === 'string' ? value.trim() : value })
|
|
92
|
+
}
|
|
93
|
+
const token = typeof draft.token === 'string' ? draft.token.trim() : ''
|
|
94
|
+
if (token !== '') edits.push({ op: 'set', path: ['token'], value: token })
|
|
95
|
+
return edits
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The SSH block a fresh page starts from, matching the schema's defaults so an untouched field is not a change. */
|
|
99
|
+
export const DEFAULT_SSH: SshConfig = { enabled: false, host: '', port: 22, user: '', keyPath: '', remotePort: 8790, localPort: 8790 }
|
package/src/client.ts
CHANGED
|
File without changes
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The device's configuration shape, with no Node imports.
|
|
3
|
+
*
|
|
4
|
+
* This module exists so the browser page and the Node plugin can share one definition. The page must not import
|
|
5
|
+
* `index.ts`: that file pulls in `node:net`, `node:child_process` and the job-session code, and a browser bundle that
|
|
6
|
+
* reaches them fails to load at boot — which is exactly how the first 0.2.0 build broke.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** One project this device can work on: the hub's logical name and this machine's own absolute path. */
|
|
10
|
+
export interface NodeRoot { project: string; path: string }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* How the device reaches a hub it cannot reach directly.
|
|
14
|
+
*
|
|
15
|
+
* The hub listens on loopback only — a deliberate constraint, because the ledger is the one thing worth protecting —
|
|
16
|
+
* so a device on another machine reaches it through a tunnel. With `enabled` the device opens that tunnel itself and
|
|
17
|
+
* dials its own end of it; `url` is then unused, not overridden, so switching back to a direct address restores it.
|
|
18
|
+
*/
|
|
19
|
+
export interface SshConfig {
|
|
20
|
+
/** Open a tunnel to the hub instead of dialling `url` directly. */
|
|
21
|
+
enabled: boolean
|
|
22
|
+
/** The hub machine: an address or a `~/.ssh/config` alias. */
|
|
23
|
+
host: string
|
|
24
|
+
/** The hub machine's SSH port. */
|
|
25
|
+
port: number
|
|
26
|
+
/** The SSH user on the hub machine. */
|
|
27
|
+
user: string
|
|
28
|
+
/** The private key this device authenticates with. The configuration page lists these from `~/.ssh`. */
|
|
29
|
+
keyPath: string
|
|
30
|
+
/** The port the hub listens on, on the hub machine. */
|
|
31
|
+
remotePort: number
|
|
32
|
+
/** The local port the tunnel listens on; the device dials this. */
|
|
33
|
+
localPort: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What the `dsh-foreman-node` entry in a profile's patch configures. */
|
|
37
|
+
export interface NodeConfig {
|
|
38
|
+
/** The hub's stable address; the device dials out to it. Blank until someone fills it in. */
|
|
39
|
+
url: string
|
|
40
|
+
/** This device's id, as registered on the hub with `Devices.add`. Blank until someone fills it in. */
|
|
41
|
+
deviceId: string
|
|
42
|
+
/** This device's token; sent in the hello and nowhere else. Blank until someone fills it in. */
|
|
43
|
+
token: string
|
|
44
|
+
/** The projects this device can work on. */
|
|
45
|
+
roots: NodeRoot[]
|
|
46
|
+
/** How often to tell the hub this device is alive, in milliseconds. */
|
|
47
|
+
heartbeatMs: number
|
|
48
|
+
/** Delay before the first reconnect attempt, in milliseconds. */
|
|
49
|
+
reconnectMs: number
|
|
50
|
+
/** How to reach a hub that is not directly reachable. */
|
|
51
|
+
ssh: SshConfig
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The SSH defaults, spread over whatever the host hands in, so a partial block is still complete. */
|
|
55
|
+
export const SSH_DEFAULTS: SshConfig = { enabled: false, host: '', port: 22, user: '', keyPath: '', remotePort: 8790, localPort: 8790 }
|
|
56
|
+
|
|
57
|
+
/** A volatile field arrives wrapped; this reads it either way, so callers do not have to know which. */
|
|
58
|
+
const valueOf = <T>(field: T | { get(): T } | undefined, fallback: T): T => {
|
|
59
|
+
if (field === undefined) return fallback
|
|
60
|
+
if (typeof field === 'object' && field !== null && 'get' in field && typeof (field as { get?: unknown }).get === 'function') {
|
|
61
|
+
return (field as { get(): T }).get()
|
|
62
|
+
}
|
|
63
|
+
return field as T
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Unwrap a whole configuration: every field may be a volatile wrapper, a plain value, or absent.
|
|
68
|
+
*
|
|
69
|
+
* `Config` already applies defaults, so `apply` sees a complete object. This is for the paths that do not go through
|
|
70
|
+
* the schema: the configuration page's read, and a `cordis.patch.yml` written before a field existed.
|
|
71
|
+
*/
|
|
72
|
+
/**
|
|
73
|
+
* Unwrap the `ssh` block.
|
|
74
|
+
*
|
|
75
|
+
* Its fields are volatile for the same reason the top level is, so each may arrive wrapped. Reading them all through
|
|
76
|
+
* `valueOf` keeps one unwrapping rule instead of two, and it means a plain partial block (an older patch file) works
|
|
77
|
+
* just as well as a wrapped one.
|
|
78
|
+
*/
|
|
79
|
+
function unwrapSsh(block: unknown): Partial<SshConfig> {
|
|
80
|
+
if (block === null || typeof block !== 'object') return {}
|
|
81
|
+
const source = block as Record<string, unknown>
|
|
82
|
+
return {
|
|
83
|
+
enabled: valueOf(source['enabled'] as boolean, SSH_DEFAULTS.enabled),
|
|
84
|
+
host: valueOf(source['host'] as string, SSH_DEFAULTS.host),
|
|
85
|
+
port: valueOf(source['port'] as number, SSH_DEFAULTS.port),
|
|
86
|
+
user: valueOf(source['user'] as string, SSH_DEFAULTS.user),
|
|
87
|
+
keyPath: valueOf(source['keyPath'] as string, SSH_DEFAULTS.keyPath),
|
|
88
|
+
remotePort: valueOf(source['remotePort'] as number, SSH_DEFAULTS.remotePort),
|
|
89
|
+
localPort: valueOf(source['localPort'] as number, SSH_DEFAULTS.localPort),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function configured(config: object | undefined): NodeConfig {
|
|
94
|
+
const source = (config ?? {}) as Record<string, unknown>
|
|
95
|
+
return { url: valueOf(source['url'] as string, ''), deviceId: valueOf(source['deviceId'] as string, ''),
|
|
96
|
+
token: valueOf(source['token'] as string, ''), roots: valueOf(source['roots'] as NodeRoot[], []),
|
|
97
|
+
heartbeatMs: valueOf(source['heartbeatMs'] as number, 15_000), reconnectMs: valueOf(source['reconnectMs'] as number, 1_000),
|
|
98
|
+
// `ssh` holds volatile fields too, so each one is unwrapped like the top level.
|
|
99
|
+
ssh: { ...SSH_DEFAULTS, ...unwrapSsh(source['ssh']) } }
|
|
100
|
+
}
|