@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.
- package/README.md +75 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +15638 -0
- package/lib/index.js +3297 -0
- package/lib/invariant.js +6 -0
- package/lib/types/client/api.d.ts +72 -0
- package/lib/types/client/index.d.ts +37 -0
- package/lib/types/client/locales.d.ts +137 -0
- package/lib/types/client/mount.d.ts +12 -0
- package/lib/types/client/panel/ClusterTab.d.ts +7 -0
- package/lib/types/client/panel/HostFormDialog.d.ts +12 -0
- package/lib/types/client/panel/HostsTab.d.ts +9 -0
- package/lib/types/client/panel/SshPanel.d.ts +13 -0
- package/lib/types/client/panel/TerminalTab.d.ts +11 -0
- package/lib/types/client/panel/TransferTab.d.ts +7 -0
- package/lib/types/client/panel/TunnelsTab.d.ts +7 -0
- package/lib/types/client/panel/controller.d.ts +22 -0
- package/lib/types/client/panel/helpers.d.ts +14 -0
- package/lib/types/client/panel/xterm.css.d.ts +2 -0
- package/lib/types/client/sidebar-entry.d.ts +24 -0
- package/lib/types/engine.d.ts +135 -0
- package/lib/types/index.d.ts +40 -0
- package/lib/types/invariant.d.ts +3 -0
- package/lib/types/protocol.d.ts +189 -0
- package/lib/types/routes.d.ts +29 -0
- package/lib/types/store.d.ts +54 -0
- package/lib/types/tools.d.ts +18 -0
- package/package.json +106 -0
- package/src/client/api.ts +363 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/index.ts +68 -0
- package/src/client/locales.ts +284 -0
- package/src/client/mount.tsx +83 -0
- package/src/client/panel/ClusterTab.tsx +123 -0
- package/src/client/panel/HostFormDialog.tsx +221 -0
- package/src/client/panel/HostsTab.tsx +166 -0
- package/src/client/panel/SshPanel.tsx +77 -0
- package/src/client/panel/TerminalTab.tsx +176 -0
- package/src/client/panel/TransferTab.tsx +232 -0
- package/src/client/panel/TunnelsTab.tsx +177 -0
- package/src/client/panel/controller.ts +48 -0
- package/src/client/panel/helpers.ts +26 -0
- package/src/client/panel/panel.module.css +833 -0
- package/src/client/panel/xterm.css.ts +2 -0
- package/src/client/sidebar-entry.ts +123 -0
- package/src/engine.ts +780 -0
- package/src/index.ts +155 -0
- package/src/invariant.ts +4 -0
- package/src/protocol.ts +188 -0
- package/src/routes.ts +540 -0
- package/src/store.ts +344 -0
- 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
|
+
}
|