@linxin666/dsh-ssh 0.1.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.
Files changed (52) hide show
  1. package/README.md +75 -0
  2. package/cordis.patch.yml +12 -0
  3. package/lib/client.js +15638 -0
  4. package/lib/index.js +3297 -0
  5. package/lib/invariant.js +6 -0
  6. package/lib/types/client/api.d.ts +72 -0
  7. package/lib/types/client/index.d.ts +37 -0
  8. package/lib/types/client/locales.d.ts +137 -0
  9. package/lib/types/client/mount.d.ts +12 -0
  10. package/lib/types/client/panel/ClusterTab.d.ts +7 -0
  11. package/lib/types/client/panel/HostFormDialog.d.ts +12 -0
  12. package/lib/types/client/panel/HostsTab.d.ts +9 -0
  13. package/lib/types/client/panel/SshPanel.d.ts +13 -0
  14. package/lib/types/client/panel/TerminalTab.d.ts +11 -0
  15. package/lib/types/client/panel/TransferTab.d.ts +7 -0
  16. package/lib/types/client/panel/TunnelsTab.d.ts +7 -0
  17. package/lib/types/client/panel/controller.d.ts +22 -0
  18. package/lib/types/client/panel/helpers.d.ts +14 -0
  19. package/lib/types/client/panel/xterm.css.d.ts +2 -0
  20. package/lib/types/client/sidebar-entry.d.ts +24 -0
  21. package/lib/types/engine.d.ts +135 -0
  22. package/lib/types/index.d.ts +40 -0
  23. package/lib/types/invariant.d.ts +3 -0
  24. package/lib/types/protocol.d.ts +189 -0
  25. package/lib/types/routes.d.ts +29 -0
  26. package/lib/types/store.d.ts +54 -0
  27. package/lib/types/tools.d.ts +18 -0
  28. package/package.json +106 -0
  29. package/src/client/api.ts +363 -0
  30. package/src/client/css-modules.d.ts +5 -0
  31. package/src/client/index.ts +68 -0
  32. package/src/client/locales.ts +284 -0
  33. package/src/client/mount.tsx +83 -0
  34. package/src/client/panel/ClusterTab.tsx +123 -0
  35. package/src/client/panel/HostFormDialog.tsx +221 -0
  36. package/src/client/panel/HostsTab.tsx +166 -0
  37. package/src/client/panel/SshPanel.tsx +77 -0
  38. package/src/client/panel/TerminalTab.tsx +176 -0
  39. package/src/client/panel/TransferTab.tsx +232 -0
  40. package/src/client/panel/TunnelsTab.tsx +177 -0
  41. package/src/client/panel/controller.ts +48 -0
  42. package/src/client/panel/helpers.ts +26 -0
  43. package/src/client/panel/panel.module.css +833 -0
  44. package/src/client/panel/xterm.css.ts +2 -0
  45. package/src/client/sidebar-entry.ts +123 -0
  46. package/src/engine.ts +780 -0
  47. package/src/index.ts +155 -0
  48. package/src/invariant.ts +4 -0
  49. package/src/protocol.ts +188 -0
  50. package/src/routes.ts +540 -0
  51. package/src/store.ts +344 -0
  52. package/src/tools.ts +371 -0
package/src/store.ts ADDED
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Host config store: one JSON file (`~/.dsh/dsh-ssh.json`) holding every
3
+ * SSH host entry, written atomically (tmp + rename). Also parses the user's
4
+ * standard `~/.ssh/config` for one-shot import. Secrets (passwords,
5
+ * passphrases) live in this user-owned file in plaintext — same trust model
6
+ * as ssh-skill's annotated ssh-config comments; document it, never log it.
7
+ */
8
+
9
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
10
+ import { homedir } from 'node:os'
11
+ import { dirname, join, resolve } from 'node:path'
12
+ import type { HostPayload, ImportResult, SshHostEntry, SshHostSummary } from './protocol.ts'
13
+
14
+ /** File format version. */
15
+ const FORMAT_VERSION = 1
16
+
17
+ /** Store file location: <home>/.dsh/dsh-ssh.json. */
18
+ export function storePath(): string {
19
+ return join(homedir(), '.dsh', 'dsh-ssh.json')
20
+ }
21
+
22
+ /** The user's standard OpenSSH config path. */
23
+ export function sshConfigPath(): string {
24
+ return join(homedir(), '.ssh', 'config')
25
+ }
26
+
27
+ interface StoreFile {
28
+ version: number
29
+ hosts: SshHostEntry[]
30
+ }
31
+
32
+ /** Validate the wire shape of a host payload; returns a message or undefined. */
33
+ export function validateHostPayload(payload: unknown): string | undefined {
34
+ if (typeof payload !== 'object' || payload === null) return 'body must be a JSON object'
35
+ const p = payload as Record<string, unknown>
36
+ if (typeof p.host !== 'string' || p.host.trim() === '') return 'host is required'
37
+ if (typeof p.user !== 'string' || p.user.trim() === '') return 'user is required'
38
+ const auth = p.auth as Record<string, unknown> | undefined
39
+ if (auth !== undefined) {
40
+ if (typeof auth !== 'object' || auth === null) return 'auth must be an object'
41
+ if (auth.kind !== 'key' && auth.kind !== 'password') return 'auth.kind must be key or password'
42
+ if (auth.kind === 'key' && (typeof auth.keyPath !== 'string' || auth.keyPath.trim() === '')) {
43
+ return 'auth.keyPath is required for key auth'
44
+ }
45
+ if (auth.kind === 'password' && (typeof auth.password !== 'string' || auth.password === '')) {
46
+ return 'auth.password is required for password auth'
47
+ }
48
+ }
49
+ if (p.port !== undefined && (typeof p.port !== 'number' || !Number.isInteger(p.port) || p.port < 1 || p.port > 65535)) {
50
+ return 'port must be an integer in 1..65535'
51
+ }
52
+ if (p.proxyJump !== undefined && (!Array.isArray(p.proxyJump) || p.proxyJump.some(x => typeof x !== 'string' || x === ''))) {
53
+ return 'proxyJump must be an array of alias strings'
54
+ }
55
+ if (p.tags !== undefined && (!Array.isArray(p.tags) || p.tags.some(x => typeof x !== 'string'))) {
56
+ return 'tags must be an array of strings'
57
+ }
58
+ return undefined
59
+ }
60
+
61
+ /** The alias grammar (same as the scaffold rule: lowercase, digits, single hyphens). */
62
+ const ALIAS_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/
63
+
64
+ /** Validate an alias for creation. */
65
+ export function validateAlias(alias: string): string | undefined {
66
+ if (!ALIAS_RE.test(alias)) return 'alias must be lowercase letters, digits and single hyphens'
67
+ return undefined
68
+ }
69
+
70
+ /**
71
+ * The host store. Pure file I/O — no cordis dependency, unit-testable.
72
+ */
73
+ export class HostStore {
74
+ /** The JSON file path. */
75
+ readonly path: string
76
+ /** Optional override of the ~/.ssh/config path (tests). */
77
+ private readonly sshConfigOverride: string | undefined
78
+
79
+ /**
80
+ * @param path - store file path (defaults to the standard location).
81
+ * @param sshConfigOverride - ssh config path override (tests only).
82
+ */
83
+ constructor(path?: string, sshConfigOverride?: string) {
84
+ this.path = resolve(path ?? storePath())
85
+ this.sshConfigOverride = sshConfigOverride
86
+ }
87
+
88
+ /** Load all entries (empty store when the file is absent). */
89
+ list(): SshHostEntry[] {
90
+ const file = this.load()
91
+ return file.hosts
92
+ }
93
+
94
+ /** Find one entry by alias. */
95
+ find(alias: string): SshHostEntry | undefined {
96
+ return this.list().find(entry => entry.alias === alias)
97
+ }
98
+
99
+ /** Secret-free projection for the browser and agent surfaces. */
100
+ summarize(entry: SshHostEntry): SshHostSummary {
101
+ let keyReady = true
102
+ if (entry.auth.kind === 'key' && entry.auth.keyPath) {
103
+ keyReady = existsSync(expandHome(entry.auth.keyPath))
104
+ }
105
+ return {
106
+ alias: entry.alias,
107
+ host: entry.host,
108
+ port: entry.port,
109
+ user: entry.user,
110
+ auth: entry.auth.kind,
111
+ keyReady,
112
+ proxyJump: [...entry.proxyJump],
113
+ // Optional fields are spread conditionally: the tool bridge rejects
114
+ // undefined-valued properties as non-lossless JSON.
115
+ ...(entry.description !== undefined ? { description: entry.description } : {}),
116
+ ...(entry.environment !== undefined ? { environment: entry.environment } : {}),
117
+ tags: [...entry.tags],
118
+ ...(entry.location !== undefined ? { location: entry.location } : {}),
119
+ createdAt: entry.createdAt,
120
+ updatedAt: entry.updatedAt,
121
+ }
122
+ }
123
+
124
+ /** Create one entry. Throws on alias collision or invalid payload. */
125
+ create(payload: HostPayload): SshHostEntry {
126
+ const alias = payload.alias?.trim()
127
+ if (!alias) throw new Error('alias is required')
128
+ const aliasError = validateAlias(alias)
129
+ if (aliasError !== undefined) throw new Error(aliasError)
130
+ const bodyError = validateHostPayload(payload)
131
+ if (bodyError !== undefined) throw new Error(bodyError)
132
+ if (payload.auth === undefined) throw new Error('auth is required')
133
+ const file = this.load()
134
+ if (file.hosts.some(entry => entry.alias === alias)) throw new Error(`alias '${alias}' already exists`)
135
+ const now = Date.now()
136
+ const entry: SshHostEntry = {
137
+ alias,
138
+ host: payload.host.trim(),
139
+ port: payload.port ?? 22,
140
+ user: payload.user.trim(),
141
+ auth: {
142
+ kind: payload.auth.kind,
143
+ keyPath: payload.auth.kind === 'key' ? expandHome(payload.auth.keyPath?.trim() ?? '') : undefined,
144
+ passphrase: payload.auth.kind === 'key' ? payload.auth.passphrase ?? undefined : undefined,
145
+ password: payload.auth.kind === 'password' ? payload.auth.password : undefined,
146
+ },
147
+ proxyJump: [...(payload.proxyJump ?? [])],
148
+ description: payload.description?.trim() || undefined,
149
+ environment: payload.environment?.trim() || undefined,
150
+ tags: [...(payload.tags ?? [])].map(tag => tag.trim()).filter(tag => tag !== ''),
151
+ location: payload.location?.trim() || undefined,
152
+ createdAt: now,
153
+ updatedAt: now,
154
+ }
155
+ file.hosts.push(entry)
156
+ this.save(file)
157
+ return entry
158
+ }
159
+
160
+ /** Update the fields present in `patch`; unknown aliases throw. */
161
+ update(alias: string, patch: Partial<HostPayload>): SshHostEntry {
162
+ const file = this.load()
163
+ const entry = file.hosts.find(candidate => candidate.alias === alias)
164
+ if (entry === undefined) throw new Error(`alias '${alias}' not found`)
165
+ // Partial-patch validation: only the present fields are checked (a
166
+ // caller may update just tags without resending host/user).
167
+ if (patch.host !== undefined && (typeof patch.host !== 'string' || patch.host.trim() === '')) {
168
+ throw new Error('host is required')
169
+ }
170
+ if (patch.user !== undefined && (typeof patch.user !== 'string' || patch.user.trim() === '')) {
171
+ throw new Error('user is required')
172
+ }
173
+ if (patch.port !== undefined && (typeof patch.port !== 'number' || !Number.isInteger(patch.port) || patch.port < 1 || patch.port > 65535)) {
174
+ throw new Error('port must be an integer in 1..65535')
175
+ }
176
+ if (patch.proxyJump !== undefined && (!Array.isArray(patch.proxyJump) || patch.proxyJump.some(x => typeof x !== 'string' || x === ''))) {
177
+ throw new Error('proxyJump must be an array of alias strings')
178
+ }
179
+ if (patch.tags !== undefined && (!Array.isArray(patch.tags) || patch.tags.some(x => typeof x !== 'string'))) {
180
+ throw new Error('tags must be an array of strings')
181
+ }
182
+ if (patch.host !== undefined) entry.host = patch.host.trim()
183
+ if (patch.port !== undefined) entry.port = patch.port
184
+ if (patch.user !== undefined) entry.user = patch.user.trim()
185
+ if (patch.auth !== undefined) {
186
+ const auth = patch.auth
187
+ if (auth.kind !== 'key' && auth.kind !== 'password') throw new Error('auth.kind must be key or password')
188
+ if (auth.kind === 'key' && (typeof auth.keyPath !== 'string' || auth.keyPath.trim() === '')) {
189
+ throw new Error('auth.keyPath is required for key auth')
190
+ }
191
+ if (auth.kind === 'password' && (typeof auth.password !== 'string' || auth.password === '')) {
192
+ throw new Error('auth.password is required for password auth')
193
+ }
194
+ // A changed key path with no passphrase means the new key has none;
195
+ // only keep the old passphrase when the key path is unchanged.
196
+ const keyChanged = auth.kind === 'key'
197
+ && auth.keyPath !== undefined
198
+ && expandHome(auth.keyPath.trim()) !== entry.auth.keyPath
199
+ entry.auth = {
200
+ kind: auth.kind,
201
+ keyPath: auth.kind === 'key' ? expandHome(auth.keyPath?.trim() ?? '') : undefined,
202
+ passphrase: auth.kind === 'key'
203
+ ? (auth.passphrase !== undefined ? auth.passphrase : (keyChanged ? undefined : entry.auth.passphrase))
204
+ : undefined,
205
+ password: auth.kind === 'password' ? auth.password : undefined,
206
+ }
207
+ }
208
+ if (patch.proxyJump !== undefined) entry.proxyJump = [...patch.proxyJump]
209
+ if (patch.description !== undefined) entry.description = patch.description.trim() || undefined
210
+ if (patch.environment !== undefined) entry.environment = patch.environment.trim() || undefined
211
+ if (patch.tags !== undefined) entry.tags = [...patch.tags].map(tag => tag.trim()).filter(tag => tag !== '')
212
+ if (patch.location !== undefined) entry.location = patch.location.trim() || undefined
213
+ entry.updatedAt = Date.now()
214
+ this.save(file)
215
+ return entry
216
+ }
217
+
218
+ /** Remove one entry. */
219
+ delete(alias: string): void {
220
+ const file = this.load()
221
+ const index = file.hosts.findIndex(candidate => candidate.alias === alias)
222
+ if (index < 0) throw new Error(`alias '${alias}' not found`)
223
+ file.hosts.splice(index, 1)
224
+ this.save(file)
225
+ }
226
+
227
+ /**
228
+ * Import hosts from `~/.ssh/config`: Host blocks with a single non-wildcard
229
+ * pattern and a HostName become entries (key auth via IdentityFile, jump
230
+ * hosts via ProxyJump). Existing aliases are skipped.
231
+ * @returns import statistics.
232
+ */
233
+ importFromSshConfig(): ImportResult {
234
+ // Per-run statistics: a later import must not report earlier runs' skips.
235
+ this.skippedNames = new Set<string>()
236
+ const configPath = this.sshConfigOverride ?? sshConfigPath()
237
+ if (!existsSync(configPath)) return { parsed: 0, added: 0, skipped: 0, skippedNames: [] }
238
+ const lines = readFileSync(configPath, 'utf8').split(/\r?\n/)
239
+ const blocks: { pattern: string; props: Record<string, string> }[] = []
240
+ let current: { pattern: string; props: Record<string, string> } | undefined
241
+ const skip = (name: string, seen: Set<string>): void => {
242
+ if (name !== '' && !seen.has(name)) {
243
+ seen.add(name)
244
+ this.skippedNames.add(name)
245
+ }
246
+ }
247
+ for (const raw of lines) {
248
+ const line = raw.trim()
249
+ if (line === '' || line.startsWith('#')) continue
250
+ const match = /^([A-Za-z0-9_\-]+)\s+(.+)$/.exec(line)
251
+ if (match === null) continue
252
+ const key = match[1].toLowerCase()
253
+ const value = match[2].trim()
254
+ if (key === 'host') {
255
+ current = { pattern: value, props: {} }
256
+ blocks.push(current)
257
+ } else if (current !== undefined) {
258
+ current.props[key] = value
259
+ }
260
+ }
261
+ let added = 0
262
+ for (const block of blocks) {
263
+ const pattern = block.pattern.split(/\s+/)[0]
264
+ if (pattern.includes('*') || pattern.includes('?')) {
265
+ skip(pattern, this.skippedNames)
266
+ continue
267
+ }
268
+ const hostName = block.props.hostname
269
+ if (hostName === undefined || hostName === '') {
270
+ skip(pattern, this.skippedNames)
271
+ continue
272
+ }
273
+ const existing = this.list().some(entry => entry.alias === pattern)
274
+ if (existing) {
275
+ skip(pattern, this.skippedNames)
276
+ continue
277
+ }
278
+ const payload: HostPayload = {
279
+ alias: pattern,
280
+ host: hostName,
281
+ port: block.props.port !== undefined ? Number.parseInt(block.props.port, 10) : 22,
282
+ user: block.props.user ?? process.env.USER ?? 'root',
283
+ auth: {
284
+ kind: block.props.identityfile !== undefined ? 'key' : 'password',
285
+ keyPath: block.props.identityfile,
286
+ password: block.props.password,
287
+ },
288
+ proxyJump: block.props.proxyjump !== undefined
289
+ ? block.props.proxyjump.split(',').map(hop => hop.trim()).filter(hop => hop !== '')
290
+ : [],
291
+ description: block.props.description,
292
+ environment: block.props.environment,
293
+ tags: (block.props.tags ?? '').split(',').map(tag => tag.trim()).filter(tag => tag !== ''),
294
+ location: block.props.location,
295
+ }
296
+ try {
297
+ this.create(payload)
298
+ added += 1
299
+ } catch {
300
+ // Unusable entry (bad alias grammar etc.) — count as skipped.
301
+ skip(pattern, this.skippedNames)
302
+ }
303
+ }
304
+ return { parsed: blocks.length, added, skipped: this.skippedNames.size, skippedNames: [...this.skippedNames] }
305
+ }
306
+
307
+ private skippedNames = new Set<string>()
308
+
309
+ private load(): StoreFile {
310
+ if (!existsSync(this.path)) return { version: FORMAT_VERSION, hosts: [] }
311
+ try {
312
+ const parsed = JSON.parse(readFileSync(this.path, 'utf8')) as StoreFile
313
+ if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.hosts)) {
314
+ throw new Error('store file shape invalid')
315
+ }
316
+ return parsed
317
+ } catch {
318
+ // A corrupt store must not brick the plugin — and must not be silently
319
+ // overwritten by the next save: rename it aside for manual recovery
320
+ // (the plugin then starts from an empty list).
321
+ try {
322
+ renameSync(this.path, `${this.path}.corrupt-${Date.now()}`)
323
+ } catch { /* best effort */ }
324
+ return { version: FORMAT_VERSION, hosts: [] }
325
+ }
326
+ }
327
+
328
+ private save(file: StoreFile): void {
329
+ const dir = dirname(this.path)
330
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
331
+ const tmp = this.path + '.tmp'
332
+ // Secrets live in this file: keep it readable by the owner only. The
333
+ // tmp file carries the 0600 mode through the rename.
334
+ writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
335
+ renameSync(tmp, this.path)
336
+ }
337
+ }
338
+
339
+ /** Expand a leading `~` in a filesystem path. */
340
+ export function expandHome(path: string): string {
341
+ if (path === '~') return homedir()
342
+ if (path.startsWith('~/')) return join(homedir(), path.slice(2))
343
+ return path
344
+ }