dsh-remote 0.8.73 → 0.8.75
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.en.md +85 -0
- package/README.md +76 -5
- package/lib/client.js +19 -2
- package/lib/host-api.js +808 -0
- package/lib/index.js +692 -310
- package/lib/remote-shell.js +39 -1
- package/lib/tui/flow.js +130 -0
- package/lib/tui/format.js +332 -0
- package/lib/tui/i18n.js +344 -0
- package/lib/tui/index.js +322 -0
- package/lib/tui/provider.js +327 -0
- package/lib/tui/scene.js +996 -0
- package/lib/workspace-cleanup.js +337 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -56,6 +56,7 @@ import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, ap
|
|
|
56
56
|
import { sendHeartbeat, pseudonym, heartbeatUrl, hasPersistedId, installIdPath } from './telemetry.js'
|
|
57
57
|
import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId, normalizeMachineGroup, filterMachinesByGroup, resolveProbeCredentials } from './registry.js'
|
|
58
58
|
import { registerHttpTransports } from './http-transport.js'
|
|
59
|
+
import { createHostApi } from './host-api.js'
|
|
59
60
|
import { SshPool, ActivePool, createPool } from './pool.js'
|
|
60
61
|
import { isSystemOpenSshBackend, sshBinaryAvailable, resolveSshBinary } from './openssh.js'
|
|
61
62
|
import { createFsRoutes } from './routes-fs.js'
|
|
@@ -283,6 +284,29 @@ export const Config = z.object({
|
|
|
283
284
|
* plaintext is the one outcome you did not ask for.
|
|
284
285
|
* `plain`: the pre-0.8.54 fallback (write it and warn). */
|
|
285
286
|
credentialFallback: z.string().default('refuse'),
|
|
287
|
+
/** Make the "remote ssh" launcher the terminal's DEFAULT shell when neither
|
|
288
|
+
* the host admin (config.shell) nor the user (remembered menu choice) has
|
|
289
|
+
* picked one. The launcher is cwd-aware: a session rooted in a remote mirror
|
|
290
|
+
* gets a terminal already sshed into the remote workspace; any other session
|
|
291
|
+
* transparently falls back to a local shell. `false` keeps "remote ssh"
|
|
292
|
+
* menu-only. */
|
|
293
|
+
remoteTerminalDefault: z.boolean().default(true),
|
|
294
|
+
/** TUI integration layer (docs/tui.md): register the `/dsh-remote` scene,
|
|
295
|
+
* the workspace provider and the `/dsh-remote` slash command when a
|
|
296
|
+
* dsh-tui profile is present. `false` disables the whole layer — on a Web or
|
|
297
|
+
* Desktop profile it already degrades to a no-op by soft-probing its seams. */
|
|
298
|
+
tui: z.boolean().default(true),
|
|
299
|
+
/** List each machine's recent workspaces in the TUI workspace picker. */
|
|
300
|
+
tuiProviderListRecent: z.boolean().default(true),
|
|
301
|
+
/** How many recent workspaces per machine the picker lists (1–20). */
|
|
302
|
+
tuiProviderRecentLimit: z.number().step(1).min(1).max(20).default(5),
|
|
303
|
+
/** Switch the session to a remote workspace right after adopting it in the
|
|
304
|
+
* TUI scene. */
|
|
305
|
+
tuiAutoSwitchWorkspace: z.boolean().default(true),
|
|
306
|
+
/** Language for the TUI layer's own copy: `''` = auto (zh, or `en` when
|
|
307
|
+
* `DSH_TUI_LANG=en`), `zh` / `en` pins it. The TUI's own screens keep their
|
|
308
|
+
* own language; this only affects `/dsh-remote` and its toasts. */
|
|
309
|
+
tuiLang: z.string().default(''),
|
|
286
310
|
})
|
|
287
311
|
|
|
288
312
|
// ── shell / path helpers (pure implementations in lib/paths.js) ───────────
|
|
@@ -1447,6 +1471,63 @@ export async function apply(ctx, config) {
|
|
|
1447
1471
|
* another machine. Tool calls must never route through this — they resolve
|
|
1448
1472
|
* their own session through `bindingFor()`, which is authoritative. */
|
|
1449
1473
|
const sessionsSvc = () => (ctx && typeof ctx.get === 'function' ? ctx.get('sessions') : null)
|
|
1474
|
+
|
|
1475
|
+
/** Whether one session is still open somewhere.
|
|
1476
|
+
*
|
|
1477
|
+
* In-process: the `sessions` service knows every live (including parked)
|
|
1478
|
+
* session of THIS TUI, so the cleanup page can refuse to delete its own.
|
|
1479
|
+
* Cross-process: DSH holds a kernel `flock` on `<session dir>/session.lock`
|
|
1480
|
+
* for the life of the write handle, so a successful `lsof` names the holder.
|
|
1481
|
+
* `deep: false` skips the spawn — list views call it for every row.
|
|
1482
|
+
*/
|
|
1483
|
+
const isSessionLive = async (sessionId, dir, opts = {}) => {
|
|
1484
|
+
try {
|
|
1485
|
+
const live = sessionsSvc()
|
|
1486
|
+
if (live && typeof live.get === 'function' && live.get(sessionId)) return true
|
|
1487
|
+
} catch { /* no sessions service (minimal embedding) */ }
|
|
1488
|
+
if (opts && opts.deep === true && process.platform !== 'win32') {
|
|
1489
|
+
const lock = path.join(String(dir || ''), 'session.lock')
|
|
1490
|
+
if (!existsSync(lock)) return false
|
|
1491
|
+
return await new Promise((resolve) => {
|
|
1492
|
+
try {
|
|
1493
|
+
execFile('lsof', ['-t', '--', lock], { timeout: 3000 }, (error, stdout) => {
|
|
1494
|
+
resolve(String(stdout || '').trim().length > 0)
|
|
1495
|
+
})
|
|
1496
|
+
} catch {
|
|
1497
|
+
resolve(false)
|
|
1498
|
+
}
|
|
1499
|
+
})
|
|
1500
|
+
}
|
|
1501
|
+
return false
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/** Drop one remote path from every machine's recent list and current workspace.
|
|
1505
|
+
*
|
|
1506
|
+
* Deleting a workspace must also forget its remote path, otherwise the TUI
|
|
1507
|
+
* provider re-creates the mirror from `recentWorkspaces` on the next start.
|
|
1508
|
+
*
|
|
1509
|
+
* @param {string} remotePath - remote workspace path to forget.
|
|
1510
|
+
* @returns {{recents: number, current: number}} how many fields changed.
|
|
1511
|
+
*/
|
|
1512
|
+
const forgetRemotePath = (remotePath) => {
|
|
1513
|
+
const target = String(remotePath || '').trim()
|
|
1514
|
+
let recents = 0
|
|
1515
|
+
let current = 0
|
|
1516
|
+
if (!target) return { recents, current }
|
|
1517
|
+
for (const machine of machines) {
|
|
1518
|
+
if (Array.isArray(machine.recentWorkspaces)) {
|
|
1519
|
+
const before = machine.recentWorkspaces.length
|
|
1520
|
+
machine.recentWorkspaces = machine.recentWorkspaces.filter((entry) => String(entry) !== target)
|
|
1521
|
+
recents += before - machine.recentWorkspaces.length
|
|
1522
|
+
}
|
|
1523
|
+
if (String(machine.workspace || '') === target) {
|
|
1524
|
+
machine.workspace = ''
|
|
1525
|
+
current += 1
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
if (recents > 0 || current > 0) saveMachines(machines, store.currentId, false)
|
|
1529
|
+
return { recents, current }
|
|
1530
|
+
}
|
|
1450
1531
|
const sessionCwd = (sessionId) => {
|
|
1451
1532
|
const sessions = sessionsSvc()
|
|
1452
1533
|
if (sessionId) return lookupSessionCwd(sessionId, { sessions, dshHome: dshBase() })
|
|
@@ -3305,6 +3386,533 @@ export async function apply(ctx, config) {
|
|
|
3305
3386
|
}
|
|
3306
3387
|
}
|
|
3307
3388
|
|
|
3389
|
+
/** Add/update one machine. Shared by POST /dsh-remote/machines and the
|
|
3390
|
+
* in-process API (`upsertMachine`). Returns the shape the route sends minus
|
|
3391
|
+
* `ok`; throws HOST_REQUIRED (httpStatus 400) for a missing host, which the
|
|
3392
|
+
* route maps back to its historical 400 response. */
|
|
3393
|
+
const upsertMachineRecord = async (body) => {
|
|
3394
|
+
let warning = ''
|
|
3395
|
+
let warningDetail = ''
|
|
3396
|
+
const host = String(body.host || '').trim()
|
|
3397
|
+
if (!host) throw codedError('HOST_REQUIRED', 'host required', { httpStatus: 400 })
|
|
3398
|
+
const id = body.id || machineId()
|
|
3399
|
+
const i = machineIndex(id)
|
|
3400
|
+
const prev = i >= 0 ? machines[i] : null
|
|
3401
|
+
const credBackend = body.credentialBackend === 'plain' ? 'plain'
|
|
3402
|
+
: (body.encryptPassword ? platformBackend() : (prev && prev.credentialBackend ? prev.credentialBackend : 'plain'))
|
|
3403
|
+
const rec = {
|
|
3404
|
+
id,
|
|
3405
|
+
name: String(body.name || '').trim() || host,
|
|
3406
|
+
host,
|
|
3407
|
+
// Issue #38: an ALIAS machine stores only the Host line of
|
|
3408
|
+
// ~/.ssh/config — HostName/user/port/key are resolved live at
|
|
3409
|
+
// every connect instead of being copied into the registry.
|
|
3410
|
+
useSshConfig: !!body.useSshConfig,
|
|
3411
|
+
port: Number(body.port) || 22,
|
|
3412
|
+
username: String(body.username || '').trim() || 'root',
|
|
3413
|
+
password: '',
|
|
3414
|
+
// An OMITTED `privateKeyPath` keeps the stored one; an explicit
|
|
3415
|
+
// empty string clears it (that is what the form sends when the user
|
|
3416
|
+
// empties the box). A partial update from an API/agent caller used
|
|
3417
|
+
// to silently wipe the key path, leaving a machine that can only
|
|
3418
|
+
// fail to authenticate — the same data-loss class as the passphrase.
|
|
3419
|
+
privateKeyPath: body.privateKeyPath === undefined
|
|
3420
|
+
? String((prev && prev.privateKeyPath) || '')
|
|
3421
|
+
: String(body.privateKeyPath || '').trim(),
|
|
3422
|
+
// An EMPTY passphrase from the form means "unchanged", not
|
|
3423
|
+
// "delete it": the value is never echoed back to the browser, so
|
|
3424
|
+
// an edit that touched any other field used to silently wipe a
|
|
3425
|
+
// saved private-key passphrase (and the next connect then failed
|
|
3426
|
+
// with "no passphrase given" for a key that had been working).
|
|
3427
|
+
passphrase: String(body.passphrase || (prev && prev.passphrase) || ''),
|
|
3428
|
+
workspace: String(body.workspace || '').trim(),
|
|
3429
|
+
hostKeyMode: body.hostKeyMode || '',
|
|
3430
|
+
useAgent: !!body.useAgent,
|
|
3431
|
+
keyboardInteractive: !!body.keyboardInteractive,
|
|
3432
|
+
// Issue #51: the transport backend is per machine. `native` is
|
|
3433
|
+
// the default, so an unknown value is not persisted as-is.
|
|
3434
|
+
backend: body.backend === 'openssh' ? 'openssh' : 'native',
|
|
3435
|
+
sshBinary: String(body.sshBinary || '').trim(),
|
|
3436
|
+
// Issue #54: the per-machine `dsh web` launch command was only
|
|
3437
|
+
// ever writable by the deploy flow or by hand-editing
|
|
3438
|
+
// machines.json — the README told users to set it and the
|
|
3439
|
+
// settings UI offered nowhere to type it. It is an ordinary
|
|
3440
|
+
// machine field now; empty means "fall back to the global
|
|
3441
|
+
// webAttachCommand".
|
|
3442
|
+
webAttachCommand: body.webAttachCommand === undefined
|
|
3443
|
+
? String((prev && prev.webAttachCommand) || '')
|
|
3444
|
+
: String(body.webAttachCommand || '').trim(),
|
|
3445
|
+
// Group labels (host organisation). Merge-only: a request that
|
|
3446
|
+
// omits them keeps the stored values instead of wiping them.
|
|
3447
|
+
...normalizeMachineGroup({
|
|
3448
|
+
environment: body.environment === undefined ? (prev && prev.environment) : body.environment,
|
|
3449
|
+
location: body.location === undefined ? (prev && prev.location) : body.location,
|
|
3450
|
+
description: body.description === undefined ? (prev && prev.description) : body.description,
|
|
3451
|
+
tags: body.tags === undefined ? (prev && prev.tags) : body.tags,
|
|
3452
|
+
}),
|
|
3453
|
+
// A jump host the form did not mention is KEPT (omitted ≠
|
|
3454
|
+
// removed), and a jump PASSWORD the form did not re-supply is
|
|
3455
|
+
// kept too: the settings page never receives it back, so an edit
|
|
3456
|
+
// used to strip the bastion credential and the machine could
|
|
3457
|
+
// only fail at connect time.
|
|
3458
|
+
proxy: body.proxy === undefined
|
|
3459
|
+
? (prev && prev.proxy && prev.proxy.host ? prev.proxy : undefined)
|
|
3460
|
+
: (body.proxy && body.proxy.host ? {
|
|
3461
|
+
host: String(body.proxy.host),
|
|
3462
|
+
port: Number(body.proxy.port) || 22,
|
|
3463
|
+
username: String(body.proxy.username || '').trim(),
|
|
3464
|
+
password: String(body.proxy.password || (prev && prev.proxy && prev.proxy.password) || ''),
|
|
3465
|
+
privateKeyPath: String(body.proxy.privateKeyPath || '').trim(),
|
|
3466
|
+
} : undefined),
|
|
3467
|
+
credentialBackend: credBackend,
|
|
3468
|
+
recentWorkspaces: prev && prev.recentWorkspaces ? prev.recentWorkspaces : [],
|
|
3469
|
+
lastConnectedAt: prev && prev.lastConnectedAt ? prev.lastConnectedAt : null,
|
|
3470
|
+
latencyMs: prev && prev.latencyMs ? prev.latencyMs : null,
|
|
3471
|
+
}
|
|
3472
|
+
if (body.password) {
|
|
3473
|
+
// Issue #30: `saveSecret()` is best-effort — the old code threw
|
|
3474
|
+
// its result away and kept `password: ''`, so on Windows (DPAPI
|
|
3475
|
+
// missing the System.Security assembly) the machine was saved
|
|
3476
|
+
// WITHOUT any credential and every later connect failed with no
|
|
3477
|
+
// visible reason. Now a failed OS-store write falls back to
|
|
3478
|
+
// plaintext and is reported to the UI.
|
|
3479
|
+
const persisted = await persistPassword({
|
|
3480
|
+
backend: credBackend,
|
|
3481
|
+
machineId: id,
|
|
3482
|
+
password: body.password,
|
|
3483
|
+
secretsDir: secretsDir(),
|
|
3484
|
+
onStoreFailure: config.credentialFallback === 'plain' ? 'plain' : 'refuse',
|
|
3485
|
+
})
|
|
3486
|
+
rec.credentialBackend = persisted.credentialBackend
|
|
3487
|
+
rec.password = persisted.password
|
|
3488
|
+
// Record WHERE the secret ended up, so the settings page can say
|
|
3489
|
+
// "已保存" for a keychain machine too (its record carries an
|
|
3490
|
+
// empty `password` by design).
|
|
3491
|
+
rec.secretStored = persisted.stored === 'secret'
|
|
3492
|
+
if (persisted.warning) {
|
|
3493
|
+
warning = persisted.warning
|
|
3494
|
+
warningDetail = persisted.error
|
|
3495
|
+
}
|
|
3496
|
+
} else if (prev) {
|
|
3497
|
+
// No password typed: keep whatever the machine already had —
|
|
3498
|
+
// the plaintext copy AND the keychain flag. Turning the
|
|
3499
|
+
// "加密保存密码" checkbox OFF is handled next: it must actually
|
|
3500
|
+
// MOVE the secret, or the checkbox is a lie.
|
|
3501
|
+
rec.password = prev.password || ''
|
|
3502
|
+
rec.secretStored = prev.secretStored
|
|
3503
|
+
const prevKeychain = prev.credentialBackend && prev.credentialBackend !== 'plain'
|
|
3504
|
+
if (credBackend === 'plain' && prevKeychain) {
|
|
3505
|
+
try {
|
|
3506
|
+
const existing = await machinePassword(prev)
|
|
3507
|
+
if (existing) {
|
|
3508
|
+
rec.password = existing
|
|
3509
|
+
rec.secretStored = false
|
|
3510
|
+
rec.credentialBackend = 'plain'
|
|
3511
|
+
await deleteSecret(id, secretsDir()).catch(() => {})
|
|
3512
|
+
}
|
|
3513
|
+
} catch { /* keep the keychain copy; the next connect still works */ }
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
if (i >= 0) machines[i] = rec; else machines.push(rec)
|
|
3517
|
+
// Issue #13: saving a machine must NOT make it the active
|
|
3518
|
+
// remote context. `currentId` stays untouched on add/update —
|
|
3519
|
+
// only an explicit "设为当前" (or rw_connect) activates one.
|
|
3520
|
+
// keepCurrentKey:false leaves the stored `currentId` byte-for-byte
|
|
3521
|
+
// alone, so a fresh registry stays "no choice yet" (not an
|
|
3522
|
+
// explicit none) and a previously-cleared registry stays cleared.
|
|
3523
|
+
saveMachines(machines, store.currentId, false)
|
|
3524
|
+
// Issue #67: an EDITED connection identity (host/user/port) must
|
|
3525
|
+
// carry the machine's existing workspaces over — without this the
|
|
3526
|
+
// mirrors stayed bound to the old address and the user had to
|
|
3527
|
+
// create (and re-sync) a brand-new workspace for the new IP.
|
|
3528
|
+
let reboundMirrors = 0
|
|
3529
|
+
if (i >= 0 && prev) {
|
|
3530
|
+
try { reboundMirrors = rebindMirrors(prev, rec) } catch { /* best effort */ }
|
|
3531
|
+
}
|
|
3532
|
+
// Issue #50: host/user/port/key have ONE home — ~/.ssh/config.
|
|
3533
|
+
// Saving a literal machine upserts a marker-tagged Host block,
|
|
3534
|
+
// so the file and the registry can never drift; the record's
|
|
3535
|
+
// copy remains only as a fallback for a missing/renamed block.
|
|
3536
|
+
// A Host block the USER wrote for the same name is never
|
|
3537
|
+
// touched — the conflict is reported instead. Failures here are
|
|
3538
|
+
// best-effort: the registry copy is already saved.
|
|
3539
|
+
let sshConfigWarning = ''
|
|
3540
|
+
if (!rec.useSshConfig) {
|
|
3541
|
+
try {
|
|
3542
|
+
const raw = readRawSshConfig(sshConfigPath())
|
|
3543
|
+
let next = upsertManagedHostBlock(raw, {
|
|
3544
|
+
alias: rec.host,
|
|
3545
|
+
hostName: rec.host,
|
|
3546
|
+
user: rec.username,
|
|
3547
|
+
port: rec.port,
|
|
3548
|
+
identityFile: rec.privateKeyPath,
|
|
3549
|
+
})
|
|
3550
|
+
// A RENAMED machine leaves the old managed block behind, and
|
|
3551
|
+
// that block is the plugin's own artifact of the edit —
|
|
3552
|
+
// retire it. (Deleting a machine removes nothing: the Host
|
|
3553
|
+
// block is the user's ssh access, not plugin state.)
|
|
3554
|
+
if (prev && prev.host && prev.host !== rec.host && !prev.useSshConfig) {
|
|
3555
|
+
const rm = removeManagedHostBlock(next.text, prev.host)
|
|
3556
|
+
next = { status: rm.status === 'removed' && next.status === 'unchanged' ? 'updated' : next.status, text: rm.text }
|
|
3557
|
+
}
|
|
3558
|
+
if (next.status === 'conflict') {
|
|
3559
|
+
sshConfigWarning = `~/.ssh/config already names "${rec.host}" in a Host block that is not managed by dsh-remote — it was left untouched, and it now wins over the saved copy.`
|
|
3560
|
+
} else if (next.status === 'appended' || next.status === 'updated') {
|
|
3561
|
+
writeSshConfig(next.text, sshConfigPath())
|
|
3562
|
+
sshConfigCache = { text: '', at: 0 }
|
|
3563
|
+
}
|
|
3564
|
+
} catch (sshErr) {
|
|
3565
|
+
sshConfigWarning = `could not update ~/.ssh/config (${String((sshErr && sshErr.message) || sshErr)}); the registry copy was saved.`
|
|
3566
|
+
}
|
|
3567
|
+
}
|
|
3568
|
+
return {
|
|
3569
|
+
machine: sanitizeMachine(rec),
|
|
3570
|
+
machines: machinesForClient(),
|
|
3571
|
+
currentId: store.currentId,
|
|
3572
|
+
...(reboundMirrors ? { reboundMirrors } : {}),
|
|
3573
|
+
...(warning ? { warning, warningDetail } : {}),
|
|
3574
|
+
...(sshConfigWarning ? { sshConfigWarning } : {}),
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
/** Delete one machine (never auto-promotes a sibling). Shared by the
|
|
3579
|
+
* delete route and the in-process API; throws MACHINE_NOT_FOUND.
|
|
3580
|
+
* @param {string} id - machine id. */
|
|
3581
|
+
const removeMachineRecord = async (id) => {
|
|
3582
|
+
const i = machineIndex(String(id || ''))
|
|
3583
|
+
if (i < 0) throw codedError('MACHINE_NOT_FOUND', 'machine not found', { httpStatus: 404 })
|
|
3584
|
+
const m = machines[i]
|
|
3585
|
+
if (m.credentialBackend && m.credentialBackend !== 'plain') {
|
|
3586
|
+
await deleteSecret(m.id, secretsDir()).catch(() => {})
|
|
3587
|
+
}
|
|
3588
|
+
machines.splice(i, 1)
|
|
3589
|
+
const wasCurrent = store.currentId === m.id
|
|
3590
|
+
if (wasCurrent) {
|
|
3591
|
+
// Issue #13: deleting the current machine leaves NO active
|
|
3592
|
+
// remote context (never auto-promote a sibling — that would
|
|
3593
|
+
// make an unrelated machine leak into the session again).
|
|
3594
|
+
store.currentId = null
|
|
3595
|
+
saveMachines(machines, null)
|
|
3596
|
+
await clearActiveMachine()
|
|
3597
|
+
} else {
|
|
3598
|
+
saveMachines(machines, store.currentId)
|
|
3599
|
+
}
|
|
3600
|
+
return { machines: machinesForClient(), currentId: store.currentId }
|
|
3601
|
+
}
|
|
3602
|
+
|
|
3603
|
+
/** Unpersisted connectivity probe: dial once with the credentials a REAL
|
|
3604
|
+
* connect would use (form values override, blanks fall back to the stored
|
|
3605
|
+
* record), report latency/platform/shell. Shared by POST
|
|
3606
|
+
* /dsh-remote/test-connect and the in-process API; throws on failure. */
|
|
3607
|
+
const probeMachineRecord = async (body) => {
|
|
3608
|
+
// ── The probe must dial with the credentials a REAL connect would use.
|
|
3609
|
+
//
|
|
3610
|
+
// Stored secrets never travel back to the browser (see
|
|
3611
|
+
// sanitizeMachine), so "编辑一台已经连接成功的机器 → 点测试连接" posted an
|
|
3612
|
+
// EMPTY password while the machine itself connected fine through the
|
|
3613
|
+
// pool's keychain/plaintext resolver. Every such probe died with
|
|
3614
|
+
// `no credentials: set a password, a privateKeyPath, or enable
|
|
3615
|
+
// useAgent to connect` — the error said "you configured nothing" about
|
|
3616
|
+
// a machine that was visibly working.
|
|
3617
|
+
//
|
|
3618
|
+
// So: resolve the saved record (by id when the form is editing one,
|
|
3619
|
+
// else by identity) and merge — form values override, blanks fall back
|
|
3620
|
+
// to the stored record. The resolved password is then ALSO exposed as a
|
|
3621
|
+
// lazy resolver so an OS-keychain machine behaves exactly like a real
|
|
3622
|
+
// connect (read on demand, never logged).
|
|
3623
|
+
const savedRec = (() => {
|
|
3624
|
+
const byId = body.id ? machines[machineIndex(String(body.id))] : null
|
|
3625
|
+
if (byId) return byId
|
|
3626
|
+
// No id (a fresh form that happens to match a saved machine): match on
|
|
3627
|
+
// the identity the form is about to dial.
|
|
3628
|
+
const hit = machineRecordFor({
|
|
3629
|
+
host: String(body.host || ''),
|
|
3630
|
+
port: Number(body.port) || 22,
|
|
3631
|
+
username: String(body.username || ''),
|
|
3632
|
+
})
|
|
3633
|
+
return hit || null
|
|
3634
|
+
})()
|
|
3635
|
+
const savedPassword = savedRec ? await machinePassword(savedRec) : ''
|
|
3636
|
+
const cred = resolveProbeCredentials(body, savedRec, savedPassword)
|
|
3637
|
+
// Issue #38: "test" on an ssh-config ALIAS resolves the alias first —
|
|
3638
|
+
// the form holds the alias name, not the host it points at.
|
|
3639
|
+
const probeBackend = String(cred.backend || config.backend || 'native')
|
|
3640
|
+
const probeIsOpenssh = isSystemOpenSshBackend({ backend: probeBackend })
|
|
3641
|
+
const aliasTarget = body.useSshConfig
|
|
3642
|
+
? resolveMachineSshConfig({
|
|
3643
|
+
host: String(body.host || ''),
|
|
3644
|
+
port: Number(body.port) || 22,
|
|
3645
|
+
username: String(body.username || ''),
|
|
3646
|
+
privateKeyPath: String(cred.privateKeyPath || ''),
|
|
3647
|
+
proxy: cred.proxy,
|
|
3648
|
+
useSshConfig: true,
|
|
3649
|
+
}, { text: sshConfigText() })
|
|
3650
|
+
: null
|
|
3651
|
+
// Issue #50: a LITERAL host named by a Host block resolves through
|
|
3652
|
+
// the file as well — the probe must dial what a real connect would.
|
|
3653
|
+
const literalTarget = !body.useSshConfig
|
|
3654
|
+
? resolveLiteralSshConfig({ host: String(body.host || '') }, { text: sshConfigText() })
|
|
3655
|
+
: null
|
|
3656
|
+
const probeTarget = aliasTarget || literalTarget
|
|
3657
|
+
// Issue #51: on the system-OpenSSH backend an alias is handed to `ssh`
|
|
3658
|
+
// VERBATIM (see connectTargetFor) — resolving it here would stop the
|
|
3659
|
+
// `Host` block from matching, so the ~/.ssh/config settings the probe
|
|
3660
|
+
// is supposed to validate would be silently skipped.
|
|
3661
|
+
const probe = createPool({
|
|
3662
|
+
...config,
|
|
3663
|
+
backend: probeBackend,
|
|
3664
|
+
host: probeTarget && !probeIsOpenssh ? probeTarget.host : String(body.host || config.host),
|
|
3665
|
+
port: (probeTarget && !probeIsOpenssh && probeTarget.port) ? probeTarget.port : (Number(body.port) || config.port),
|
|
3666
|
+
username: (probeTarget && !probeIsOpenssh && probeTarget.username) ? probeTarget.username : String(body.username || config.username),
|
|
3667
|
+
password: cred.password,
|
|
3668
|
+
privateKeyPath: (probeTarget && !probeIsOpenssh && probeTarget.privateKeyPath) ? probeTarget.privateKeyPath : String(cred.privateKeyPath || config.privateKeyPath),
|
|
3669
|
+
passphrase: cred.passphrase,
|
|
3670
|
+
// Auth METHOD flags come from the merge too: a machine that
|
|
3671
|
+
// authenticates through an agent or keyboard-interactive used to be
|
|
3672
|
+
// probed with `useAgent: false`, i.e. with no auth method at all.
|
|
3673
|
+
useAgent: cred.useAgent,
|
|
3674
|
+
agentSocket: (probeTarget && !probeIsOpenssh && probeTarget.agentSocket) || '',
|
|
3675
|
+
identitiesOnly: !!(probeTarget && !probeIsOpenssh && probeTarget.identitiesOnly),
|
|
3676
|
+
keyboardInteractive: cred.keyboardInteractive,
|
|
3677
|
+
...(cred.hostKeyMode ? { hostKeyMode: cred.hostKeyMode } : {}),
|
|
3678
|
+
...(cred.sshBinary ? { sshBinary: cred.sshBinary } : {}),
|
|
3679
|
+
proxy: (probeTarget && !probeIsOpenssh && probeTarget.proxy) ? probeTarget.proxy : cred.proxy,
|
|
3680
|
+
connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
|
|
3681
|
+
commandTimeoutMs: 10000,
|
|
3682
|
+
}, { knownHostsFile })
|
|
3683
|
+
// Same lazy resolution the active pool uses: if the merged password was
|
|
3684
|
+
// empty because the secret lives in the OS keychain, the pool asks again
|
|
3685
|
+
// at dial time instead of failing with "no credentials".
|
|
3686
|
+
probe.passwordResolver = async () => machinePassword(savedRec || {})
|
|
3687
|
+
const started = Date.now()
|
|
3688
|
+
await probe.connect()
|
|
3689
|
+
await probe.exec('true', { timeoutMs: 10000 })
|
|
3690
|
+
probe.close()
|
|
3691
|
+
const latencyMs = Date.now() - started
|
|
3692
|
+
// Alias-aware: the probe connects to the RESOLVED host, while the
|
|
3693
|
+
// registry stores the alias, so matching must go through the identity.
|
|
3694
|
+
const mi = machines.findIndex((m) => identityMatches(m, { host: probe.config.host, port: probe.config.port, username: probe.config.username }))
|
|
3695
|
+
if (mi >= 0) {
|
|
3696
|
+
machines[mi].lastConnectedAt = new Date().toISOString()
|
|
3697
|
+
machines[mi].latencyMs = latencyMs
|
|
3698
|
+
saveMachines(machines, store.currentId)
|
|
3699
|
+
}
|
|
3700
|
+
return {
|
|
3701
|
+
ok: true,
|
|
3702
|
+
host: probe.config.host,
|
|
3703
|
+
user: probe.config.username,
|
|
3704
|
+
latencyMs,
|
|
3705
|
+
lastConnectedAt: new Date().toISOString(),
|
|
3706
|
+
platform: probe.platform,
|
|
3707
|
+
shell: probe.shellMode,
|
|
3708
|
+
gitBash: probe.gitBashPath || '',
|
|
3709
|
+
// Tell the UI whether the probe leaned on the SAVED credential: with
|
|
3710
|
+
// an empty password box, "测试成功" otherwise looks impossible.
|
|
3711
|
+
usedSavedCredential: cred.usedSavedCredential,
|
|
3712
|
+
// Issue #38: tell the settings page where an alias resolved to (and
|
|
3713
|
+
// anything the plugin could not honour, e.g. a multi-hop ProxyJump).
|
|
3714
|
+
alias: aliasTarget ? { name: aliasTarget.alias, host: aliasTarget.host, port: aliasTarget.port, user: aliasTarget.username, warnings: aliasTarget.warnings } : null,
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
// ── in-process host API (ctx.dshRemote) building blocks ───────────────────
|
|
3718
|
+
// lib/host-api.js owns the contract shaping (pure data, error codes,
|
|
3719
|
+
// subscribe); the functions below are the only bridge to this closure's
|
|
3720
|
+
// state. ONE implementation is the point: a second reader of machines.json
|
|
3721
|
+
// would see a stale snapshot, a second SSH pool would double every
|
|
3722
|
+
// connection, and two writers of saveMachines() race its whole-file rewrite.
|
|
3723
|
+
/** Log through the optional harness logger; never lets logging fail a call. */
|
|
3724
|
+
const hostLog = (level, message) => {
|
|
3725
|
+
try {
|
|
3726
|
+
const logger = ctx && typeof ctx.get === 'function' ? ctx.get('logger') : undefined
|
|
3727
|
+
if (logger && typeof logger[level] === 'function') logger[level](message)
|
|
3728
|
+
} catch { /* logging is best-effort */ }
|
|
3729
|
+
}
|
|
3730
|
+
|
|
3731
|
+
/** One coded error for the in-process API. Host-side callers get a stable
|
|
3732
|
+
* `.code` (contract vocabulary) plus `httpStatus` where the HTTP route has
|
|
3733
|
+
* a historical status to preserve. */
|
|
3734
|
+
const codedError = (code, message, extra = {}) => {
|
|
3735
|
+
const e = new Error(message)
|
|
3736
|
+
e.code = code
|
|
3737
|
+
if (extra && typeof extra === 'object') Object.assign(e, extra)
|
|
3738
|
+
return e
|
|
3739
|
+
}
|
|
3740
|
+
|
|
3741
|
+
/** Resolve a machine for the API: by id when given, else the ACTIVE machine
|
|
3742
|
+
* (explicit current > ephemeral > config default, exactly like status()).
|
|
3743
|
+
* Returns the RESOLVED connect identity — ssh-config aliases expanded, and
|
|
3744
|
+
* folded back to the alias for the system-OpenSSH backend (which dials the
|
|
3745
|
+
* alias itself), i.e. what applyActiveMachine writes into the shared config. */
|
|
3746
|
+
const resolveMachineForApi = (machineId) => {
|
|
3747
|
+
const id = String(machineId || '').trim()
|
|
3748
|
+
if (id) {
|
|
3749
|
+
const i = machineIndex(id)
|
|
3750
|
+
if (i < 0) throw codedError('MACHINE_NOT_FOUND', `machine not found: ${id}`)
|
|
3751
|
+
const rec = machines[i]
|
|
3752
|
+
const eff = withSshConfig(rec)
|
|
3753
|
+
const t = connectTargetFor(eff)
|
|
3754
|
+
return {
|
|
3755
|
+
record: rec,
|
|
3756
|
+
machineId: rec.id,
|
|
3757
|
+
name: String(rec.name || rec.host || ''),
|
|
3758
|
+
host: String(t.aliasNative ? t.host : (eff.host || '')),
|
|
3759
|
+
port: t.aliasNative ? 22 : (Number(eff.port) || 22),
|
|
3760
|
+
username: t.aliasNative ? '' : String(eff.username || ''),
|
|
3761
|
+
alias: aliasOf(eff),
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
const rec = store.currentId && machineIndex(store.currentId) >= 0 ? machines[machineIndex(store.currentId)] : null
|
|
3765
|
+
const m = activeMachine()
|
|
3766
|
+
if (!m || !m.host) {
|
|
3767
|
+
throw codedError('NO_ACTIVE_MACHINE', '当前没有活动远程机:请先在「远程工作区」设置里选择或连接一台机器。(保存的机器只是备用连接,不会自动成为当前机器。)')
|
|
3768
|
+
}
|
|
3769
|
+
const t = connectTargetFor(m)
|
|
3770
|
+
return {
|
|
3771
|
+
record: rec,
|
|
3772
|
+
machineId: rec ? rec.id : '',
|
|
3773
|
+
name: String((rec && rec.name) || m.name || m.host || ''),
|
|
3774
|
+
host: String(t.aliasNative ? t.host : (m.host || '')),
|
|
3775
|
+
port: t.aliasNative ? 22 : (Number(m.port) || 22),
|
|
3776
|
+
username: t.aliasNative ? '' : String(m.username || ''),
|
|
3777
|
+
alias: aliasOf(m),
|
|
3778
|
+
}
|
|
3779
|
+
}
|
|
3780
|
+
|
|
3781
|
+
/** Binding for the API's listDir. Addressing priority (contract):
|
|
3782
|
+
* machineId (that machine's own pool, no side effects) > the active machine
|
|
3783
|
+
* (the same fallback the /ls route gets from resolveRequestBinding). */
|
|
3784
|
+
const listDirBindingForApi = async (opts = {}) => {
|
|
3785
|
+
const id = String((opts && opts.machineId) || '').trim()
|
|
3786
|
+
if (id) {
|
|
3787
|
+
const m = resolveMachineForApi(id)
|
|
3788
|
+
// The per-machine pool keeps browsing off the shared active pool: a
|
|
3789
|
+
// directory listing must never switch the user's current machine.
|
|
3790
|
+
const targetPool = poolForMachine({ host: m.host, port: m.port, username: m.username, alias: m.alias })
|
|
3791
|
+
const tgt = (targetPool && targetPool.config) || {}
|
|
3792
|
+
return {
|
|
3793
|
+
pool: targetPool,
|
|
3794
|
+
ws: '',
|
|
3795
|
+
bound: false,
|
|
3796
|
+
machine: {
|
|
3797
|
+
id: m.machineId,
|
|
3798
|
+
name: m.name,
|
|
3799
|
+
host: String(tgt.host || m.host),
|
|
3800
|
+
port: Number(tgt.port) || m.port,
|
|
3801
|
+
username: String(tgt.username != null ? tgt.username : m.username),
|
|
3802
|
+
},
|
|
3803
|
+
}
|
|
3804
|
+
}
|
|
3805
|
+
// No hint: the /ls route's fallback. resolveRequestBinding throws a 409 with
|
|
3806
|
+
// the actionable Chinese message when no machine is active; host-api maps
|
|
3807
|
+
// that to NO_ACTIVE_MACHINE.
|
|
3808
|
+
const b = await resolveRequestBinding({ url: '/' }, {})
|
|
3809
|
+
let name = ''
|
|
3810
|
+
try {
|
|
3811
|
+
const am = activeMachine()
|
|
3812
|
+
name = String((am && (am.name || am.host)) || b.host || '')
|
|
3813
|
+
} catch { /* display only */ }
|
|
3814
|
+
const tgt = (b.pool && b.pool.config) || {}
|
|
3815
|
+
return {
|
|
3816
|
+
pool: b.pool,
|
|
3817
|
+
ws: b.ws,
|
|
3818
|
+
bound: !!b.bound,
|
|
3819
|
+
machine: {
|
|
3820
|
+
id: store.currentId || '',
|
|
3821
|
+
name,
|
|
3822
|
+
host: String(tgt.host || b.host || ''),
|
|
3823
|
+
port: Number(tgt.port) || Number(b.port) || 22,
|
|
3824
|
+
username: String(tgt.username != null ? tgt.username : (b.username || '')),
|
|
3825
|
+
},
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
|
|
3829
|
+
/** Every local mirror + the registry machine its meta resolves to. Pure
|
|
3830
|
+
* filesystem read (mirrorRegistry), so it never dials. */
|
|
3831
|
+
const listRemoteProjectsForApi = () => mirrorRegistry().map((p) => {
|
|
3832
|
+
const rec = machineRecordFor({ host: p.host, port: p.port, username: p.username, alias: p.alias })
|
|
3833
|
+
return { ...p, machineId: rec && rec.id ? String(rec.id) : null }
|
|
3834
|
+
})
|
|
3835
|
+
|
|
3836
|
+
/** SSH `stat` validation used by pickRemoteWorkspace. With a machineId it
|
|
3837
|
+
* runs on that machine's OWN pool (validation must not switch the active
|
|
3838
|
+
* machine); without one it behaves like the /mirror route (active pool). */
|
|
3839
|
+
const checkRemoteDirForApi = async (remotePath, machineId) => {
|
|
3840
|
+
const id = String(machineId || '').trim()
|
|
3841
|
+
if (id) {
|
|
3842
|
+
const m = resolveMachineForApi(id)
|
|
3843
|
+
return isRemoteDir(remotePath, poolForMachine({ host: m.host, port: m.port, username: m.username, alias: m.alias }))
|
|
3844
|
+
}
|
|
3845
|
+
if (!String(config.host || '').trim()) {
|
|
3846
|
+
const m = activeMachine()
|
|
3847
|
+
if (m && m.host) {
|
|
3848
|
+
try { await applyActiveMachine() } catch { /* reported by the probe below */ }
|
|
3849
|
+
}
|
|
3850
|
+
}
|
|
3851
|
+
if (!String(config.host || '').trim()) {
|
|
3852
|
+
throw codedError('NO_ACTIVE_MACHINE', '当前没有活动远程机:请先在「远程工作区」设置里选择或连接一台机器。(保存的机器只是备用连接,不会自动成为当前机器。)')
|
|
3853
|
+
}
|
|
3854
|
+
return isRemoteDir(remotePath)
|
|
3855
|
+
}
|
|
3856
|
+
|
|
3857
|
+
/** Persist "this machine is current" for the API (null = explicit none). */
|
|
3858
|
+
const setCurrentForApi = async (id) => {
|
|
3859
|
+
const clean = id == null ? '' : String(id).trim()
|
|
3860
|
+
if (!clean) {
|
|
3861
|
+
await setCurrent('')
|
|
3862
|
+
return
|
|
3863
|
+
}
|
|
3864
|
+
if (machineIndex(clean) < 0) throw codedError('MACHINE_NOT_FOUND', `machine not found: ${clean}`)
|
|
3865
|
+
if (!(await setCurrent(clean))) throw codedError('MACHINE_NOT_FOUND', `machine not found: ${clean}`)
|
|
3866
|
+
}
|
|
3867
|
+
|
|
3868
|
+
/** Connect (activate) a machine and probe a real SSH connection.
|
|
3869
|
+
* `persist:false` keeps the registry's currentId untouched (rw_connect
|
|
3870
|
+
* save:false semantics): the target is applied as an EPHEMERAL machine. */
|
|
3871
|
+
const connectMachineForApi = async (opts = {}) => {
|
|
3872
|
+
const id = String((opts && opts.machineId) || '').trim()
|
|
3873
|
+
const persist = !(opts && opts.persist === false)
|
|
3874
|
+
let rec = null
|
|
3875
|
+
if (id) {
|
|
3876
|
+
const i = machineIndex(id)
|
|
3877
|
+
if (i < 0) throw codedError('MACHINE_NOT_FOUND', `machine not found: ${id}`)
|
|
3878
|
+
rec = machines[i]
|
|
3879
|
+
} else if (store.currentId && machineIndex(store.currentId) >= 0) {
|
|
3880
|
+
rec = machines[machineIndex(store.currentId)]
|
|
3881
|
+
}
|
|
3882
|
+
if (!rec) {
|
|
3883
|
+
const m = activeMachine()
|
|
3884
|
+
if (!m || !m.host) {
|
|
3885
|
+
throw codedError('NO_ACTIVE_MACHINE', '当前没有活动远程机:请先在「远程工作区」设置里选择或连接一台机器。(保存的机器只是备用连接,不会自动成为当前机器。)')
|
|
3886
|
+
}
|
|
3887
|
+
}
|
|
3888
|
+
if (persist) {
|
|
3889
|
+
ephemeral = null
|
|
3890
|
+
if (rec) await setCurrent(rec.id)
|
|
3891
|
+
else await applyActiveMachine()
|
|
3892
|
+
} else {
|
|
3893
|
+
ephemeral = rec ? { ...rec } : { ...activeMachine() }
|
|
3894
|
+
await applyActiveMachine()
|
|
3895
|
+
}
|
|
3896
|
+
// "Connect" means dial NOW: drop a cached (possibly half-open) client so the
|
|
3897
|
+
// probe below really reconnects — the same reasoning as the /connect route.
|
|
3898
|
+
pool.invalidate()
|
|
3899
|
+
const connectHost = config.host
|
|
3900
|
+
const connectPort = Number(config.port) || 22
|
|
3901
|
+
try {
|
|
3902
|
+
await pool.exec('echo ok', { timeoutMs: Math.min(config.commandTimeoutMs || 20000, 8000) })
|
|
3903
|
+
} catch (err) {
|
|
3904
|
+
if (err && typeof err === 'object') {
|
|
3905
|
+
err.host = connectHost
|
|
3906
|
+
err.port = connectPort
|
|
3907
|
+
}
|
|
3908
|
+
audit('connect', `connect ${config.username}@${connectHost}:${connectPort}`, (err && typeof err.code === 'number') ? err.code : -1)
|
|
3909
|
+
throw err
|
|
3910
|
+
}
|
|
3911
|
+
audit('connect', `connect ${config.username}@${connectHost}:${connectPort}`, 0)
|
|
3912
|
+
return status()
|
|
3913
|
+
}
|
|
3914
|
+
|
|
3915
|
+
|
|
3308
3916
|
// ── route helpers ─────────────────────────────────────────────────────────
|
|
3309
3917
|
const routes = [
|
|
3310
3918
|
{
|
|
@@ -3668,190 +4276,12 @@ export async function apply(ctx, config) {
|
|
|
3668
4276
|
try {
|
|
3669
4277
|
const body = JSON.parse((await readBody(req)) || '{}')
|
|
3670
4278
|
const action = body.action || 'add'
|
|
3671
|
-
let warning = ''
|
|
3672
|
-
let warningDetail = ''
|
|
3673
4279
|
if (action === 'add' || action === 'update') {
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
const prev = i >= 0 ? machines[i] : null
|
|
3679
|
-
const credBackend = body.credentialBackend === 'plain' ? 'plain'
|
|
3680
|
-
: (body.encryptPassword ? platformBackend() : (prev && prev.credentialBackend ? prev.credentialBackend : 'plain'))
|
|
3681
|
-
const rec = {
|
|
3682
|
-
id,
|
|
3683
|
-
name: String(body.name || '').trim() || host,
|
|
3684
|
-
host,
|
|
3685
|
-
// Issue #38: an ALIAS machine stores only the Host line of
|
|
3686
|
-
// ~/.ssh/config — HostName/user/port/key are resolved live at
|
|
3687
|
-
// every connect instead of being copied into the registry.
|
|
3688
|
-
useSshConfig: !!body.useSshConfig,
|
|
3689
|
-
port: Number(body.port) || 22,
|
|
3690
|
-
username: String(body.username || '').trim() || 'root',
|
|
3691
|
-
password: '',
|
|
3692
|
-
// An OMITTED `privateKeyPath` keeps the stored one; an explicit
|
|
3693
|
-
// empty string clears it (that is what the form sends when the user
|
|
3694
|
-
// empties the box). A partial update from an API/agent caller used
|
|
3695
|
-
// to silently wipe the key path, leaving a machine that can only
|
|
3696
|
-
// fail to authenticate — the same data-loss class as the passphrase.
|
|
3697
|
-
privateKeyPath: body.privateKeyPath === undefined
|
|
3698
|
-
? String((prev && prev.privateKeyPath) || '')
|
|
3699
|
-
: String(body.privateKeyPath || '').trim(),
|
|
3700
|
-
// An EMPTY passphrase from the form means "unchanged", not
|
|
3701
|
-
// "delete it": the value is never echoed back to the browser, so
|
|
3702
|
-
// an edit that touched any other field used to silently wipe a
|
|
3703
|
-
// saved private-key passphrase (and the next connect then failed
|
|
3704
|
-
// with "no passphrase given" for a key that had been working).
|
|
3705
|
-
passphrase: String(body.passphrase || (prev && prev.passphrase) || ''),
|
|
3706
|
-
workspace: String(body.workspace || '').trim(),
|
|
3707
|
-
hostKeyMode: body.hostKeyMode || '',
|
|
3708
|
-
useAgent: !!body.useAgent,
|
|
3709
|
-
keyboardInteractive: !!body.keyboardInteractive,
|
|
3710
|
-
// Issue #51: the transport backend is per machine. `native` is
|
|
3711
|
-
// the default, so an unknown value is not persisted as-is.
|
|
3712
|
-
backend: body.backend === 'openssh' ? 'openssh' : 'native',
|
|
3713
|
-
sshBinary: String(body.sshBinary || '').trim(),
|
|
3714
|
-
// Issue #54: the per-machine `dsh web` launch command was only
|
|
3715
|
-
// ever writable by the deploy flow or by hand-editing
|
|
3716
|
-
// machines.json — the README told users to set it and the
|
|
3717
|
-
// settings UI offered nowhere to type it. It is an ordinary
|
|
3718
|
-
// machine field now; empty means "fall back to the global
|
|
3719
|
-
// webAttachCommand".
|
|
3720
|
-
webAttachCommand: body.webAttachCommand === undefined
|
|
3721
|
-
? String((prev && prev.webAttachCommand) || '')
|
|
3722
|
-
: String(body.webAttachCommand || '').trim(),
|
|
3723
|
-
// Group labels (host organisation). Merge-only: a request that
|
|
3724
|
-
// omits them keeps the stored values instead of wiping them.
|
|
3725
|
-
...normalizeMachineGroup({
|
|
3726
|
-
environment: body.environment === undefined ? (prev && prev.environment) : body.environment,
|
|
3727
|
-
location: body.location === undefined ? (prev && prev.location) : body.location,
|
|
3728
|
-
description: body.description === undefined ? (prev && prev.description) : body.description,
|
|
3729
|
-
tags: body.tags === undefined ? (prev && prev.tags) : body.tags,
|
|
3730
|
-
}),
|
|
3731
|
-
// A jump host the form did not mention is KEPT (omitted ≠
|
|
3732
|
-
// removed), and a jump PASSWORD the form did not re-supply is
|
|
3733
|
-
// kept too: the settings page never receives it back, so an edit
|
|
3734
|
-
// used to strip the bastion credential and the machine could
|
|
3735
|
-
// only fail at connect time.
|
|
3736
|
-
proxy: body.proxy === undefined
|
|
3737
|
-
? (prev && prev.proxy && prev.proxy.host ? prev.proxy : undefined)
|
|
3738
|
-
: (body.proxy && body.proxy.host ? {
|
|
3739
|
-
host: String(body.proxy.host),
|
|
3740
|
-
port: Number(body.proxy.port) || 22,
|
|
3741
|
-
username: String(body.proxy.username || '').trim(),
|
|
3742
|
-
password: String(body.proxy.password || (prev && prev.proxy && prev.proxy.password) || ''),
|
|
3743
|
-
privateKeyPath: String(body.proxy.privateKeyPath || '').trim(),
|
|
3744
|
-
} : undefined),
|
|
3745
|
-
credentialBackend: credBackend,
|
|
3746
|
-
recentWorkspaces: prev && prev.recentWorkspaces ? prev.recentWorkspaces : [],
|
|
3747
|
-
lastConnectedAt: prev && prev.lastConnectedAt ? prev.lastConnectedAt : null,
|
|
3748
|
-
latencyMs: prev && prev.latencyMs ? prev.latencyMs : null,
|
|
3749
|
-
}
|
|
3750
|
-
if (body.password) {
|
|
3751
|
-
// Issue #30: `saveSecret()` is best-effort — the old code threw
|
|
3752
|
-
// its result away and kept `password: ''`, so on Windows (DPAPI
|
|
3753
|
-
// missing the System.Security assembly) the machine was saved
|
|
3754
|
-
// WITHOUT any credential and every later connect failed with no
|
|
3755
|
-
// visible reason. Now a failed OS-store write falls back to
|
|
3756
|
-
// plaintext and is reported to the UI.
|
|
3757
|
-
const persisted = await persistPassword({
|
|
3758
|
-
backend: credBackend,
|
|
3759
|
-
machineId: id,
|
|
3760
|
-
password: body.password,
|
|
3761
|
-
secretsDir: secretsDir(),
|
|
3762
|
-
onStoreFailure: config.credentialFallback === 'plain' ? 'plain' : 'refuse',
|
|
3763
|
-
})
|
|
3764
|
-
rec.credentialBackend = persisted.credentialBackend
|
|
3765
|
-
rec.password = persisted.password
|
|
3766
|
-
// Record WHERE the secret ended up, so the settings page can say
|
|
3767
|
-
// "已保存" for a keychain machine too (its record carries an
|
|
3768
|
-
// empty `password` by design).
|
|
3769
|
-
rec.secretStored = persisted.stored === 'secret'
|
|
3770
|
-
if (persisted.warning) {
|
|
3771
|
-
warning = persisted.warning
|
|
3772
|
-
warningDetail = persisted.error
|
|
3773
|
-
}
|
|
3774
|
-
} else if (prev) {
|
|
3775
|
-
// No password typed: keep whatever the machine already had —
|
|
3776
|
-
// the plaintext copy AND the keychain flag. Turning the
|
|
3777
|
-
// "加密保存密码" checkbox OFF is handled next: it must actually
|
|
3778
|
-
// MOVE the secret, or the checkbox is a lie.
|
|
3779
|
-
rec.password = prev.password || ''
|
|
3780
|
-
rec.secretStored = prev.secretStored
|
|
3781
|
-
const prevKeychain = prev.credentialBackend && prev.credentialBackend !== 'plain'
|
|
3782
|
-
if (credBackend === 'plain' && prevKeychain) {
|
|
3783
|
-
try {
|
|
3784
|
-
const existing = await machinePassword(prev)
|
|
3785
|
-
if (existing) {
|
|
3786
|
-
rec.password = existing
|
|
3787
|
-
rec.secretStored = false
|
|
3788
|
-
rec.credentialBackend = 'plain'
|
|
3789
|
-
await deleteSecret(id, secretsDir()).catch(() => {})
|
|
3790
|
-
}
|
|
3791
|
-
} catch { /* keep the keychain copy; the next connect still works */ }
|
|
3792
|
-
}
|
|
3793
|
-
}
|
|
3794
|
-
if (i >= 0) machines[i] = rec; else machines.push(rec)
|
|
3795
|
-
// Issue #13: saving a machine must NOT make it the active
|
|
3796
|
-
// remote context. `currentId` stays untouched on add/update —
|
|
3797
|
-
// only an explicit "设为当前" (or rw_connect) activates one.
|
|
3798
|
-
// keepCurrentKey:false leaves the stored `currentId` byte-for-byte
|
|
3799
|
-
// alone, so a fresh registry stays "no choice yet" (not an
|
|
3800
|
-
// explicit none) and a previously-cleared registry stays cleared.
|
|
3801
|
-
saveMachines(machines, store.currentId, false)
|
|
3802
|
-
// Issue #67: an EDITED connection identity (host/user/port) must
|
|
3803
|
-
// carry the machine's existing workspaces over — without this the
|
|
3804
|
-
// mirrors stayed bound to the old address and the user had to
|
|
3805
|
-
// create (and re-sync) a brand-new workspace for the new IP.
|
|
3806
|
-
let reboundMirrors = 0
|
|
3807
|
-
if (i >= 0 && prev) {
|
|
3808
|
-
try { reboundMirrors = rebindMirrors(prev, rec) } catch { /* best effort */ }
|
|
3809
|
-
}
|
|
3810
|
-
// Issue #50: host/user/port/key have ONE home — ~/.ssh/config.
|
|
3811
|
-
// Saving a literal machine upserts a marker-tagged Host block,
|
|
3812
|
-
// so the file and the registry can never drift; the record's
|
|
3813
|
-
// copy remains only as a fallback for a missing/renamed block.
|
|
3814
|
-
// A Host block the USER wrote for the same name is never
|
|
3815
|
-
// touched — the conflict is reported instead. Failures here are
|
|
3816
|
-
// best-effort: the registry copy is already saved.
|
|
3817
|
-
let sshConfigWarning = ''
|
|
3818
|
-
if (!rec.useSshConfig) {
|
|
3819
|
-
try {
|
|
3820
|
-
const raw = readRawSshConfig(sshConfigPath())
|
|
3821
|
-
let next = upsertManagedHostBlock(raw, {
|
|
3822
|
-
alias: rec.host,
|
|
3823
|
-
hostName: rec.host,
|
|
3824
|
-
user: rec.username,
|
|
3825
|
-
port: rec.port,
|
|
3826
|
-
identityFile: rec.privateKeyPath,
|
|
3827
|
-
})
|
|
3828
|
-
// A RENAMED machine leaves the old managed block behind, and
|
|
3829
|
-
// that block is the plugin's own artifact of the edit —
|
|
3830
|
-
// retire it. (Deleting a machine removes nothing: the Host
|
|
3831
|
-
// block is the user's ssh access, not plugin state.)
|
|
3832
|
-
if (prev && prev.host && prev.host !== rec.host && !prev.useSshConfig) {
|
|
3833
|
-
const rm = removeManagedHostBlock(next.text, prev.host)
|
|
3834
|
-
next = { status: rm.status === 'removed' && next.status === 'unchanged' ? 'updated' : next.status, text: rm.text }
|
|
3835
|
-
}
|
|
3836
|
-
if (next.status === 'conflict') {
|
|
3837
|
-
sshConfigWarning = `~/.ssh/config already names "${rec.host}" in a Host block that is not managed by dsh-remote — it was left untouched, and it now wins over the saved copy.`
|
|
3838
|
-
} else if (next.status === 'appended' || next.status === 'updated') {
|
|
3839
|
-
writeSshConfig(next.text, sshConfigPath())
|
|
3840
|
-
sshConfigCache = { text: '', at: 0 }
|
|
3841
|
-
}
|
|
3842
|
-
} catch (sshErr) {
|
|
3843
|
-
sshConfigWarning = `could not update ~/.ssh/config (${String((sshErr && sshErr.message) || sshErr)}); the registry copy was saved.`
|
|
3844
|
-
}
|
|
4280
|
+
try {
|
|
4281
|
+
return sendJson(res, 200, { ok: true, ...(await upsertMachineRecord(body)) })
|
|
4282
|
+
} catch (err) {
|
|
4283
|
+
return sendJson(res, (err && err.httpStatus) || 500, { ok: false, error: String((err && err.message) || err) })
|
|
3845
4284
|
}
|
|
3846
|
-
return sendJson(res, 200, {
|
|
3847
|
-
ok: true,
|
|
3848
|
-
machine: sanitizeMachine(rec),
|
|
3849
|
-
machines: machinesForClient(),
|
|
3850
|
-
currentId: store.currentId,
|
|
3851
|
-
...(reboundMirrors ? { reboundMirrors } : {}),
|
|
3852
|
-
...(warning ? { warning, warningDetail } : {}),
|
|
3853
|
-
...(sshConfigWarning ? { sshConfigWarning } : {}),
|
|
3854
|
-
})
|
|
3855
4285
|
}
|
|
3856
4286
|
// Issue #73: one click on a host listed from ~/.ssh/config. The settings
|
|
3857
4287
|
// page no longer asks the user to understand "alias" vs "copy": it lists
|
|
@@ -3910,25 +4340,11 @@ export async function apply(ctx, config) {
|
|
|
3910
4340
|
})
|
|
3911
4341
|
}
|
|
3912
4342
|
if (action === 'delete') {
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
await deleteSecret(m.id, secretsDir()).catch(() => {})
|
|
3918
|
-
}
|
|
3919
|
-
machines.splice(i, 1)
|
|
3920
|
-
const wasCurrent = store.currentId === m.id
|
|
3921
|
-
if (wasCurrent) {
|
|
3922
|
-
// Issue #13: deleting the current machine leaves NO active
|
|
3923
|
-
// remote context (never auto-promote a sibling — that would
|
|
3924
|
-
// make an unrelated machine leak into the session again).
|
|
3925
|
-
store.currentId = null
|
|
3926
|
-
saveMachines(machines, null)
|
|
3927
|
-
await clearActiveMachine()
|
|
3928
|
-
} else {
|
|
3929
|
-
saveMachines(machines, store.currentId)
|
|
4343
|
+
try {
|
|
4344
|
+
return sendJson(res, 200, { ok: true, ...(await removeMachineRecord(String(body.id || ''))) })
|
|
4345
|
+
} catch (err) {
|
|
4346
|
+
return sendJson(res, (err && err.httpStatus) || 500, { ok: false, error: String((err && err.message) || err) })
|
|
3930
4347
|
}
|
|
3931
|
-
return sendJson(res, 200, { ok: true, machines: machinesForClient(), currentId: store.currentId })
|
|
3932
4348
|
}
|
|
3933
4349
|
return sendJson(res, 400, { ok: false, error: 'unknown action' })
|
|
3934
4350
|
} catch (err) {
|
|
@@ -3951,114 +4367,7 @@ export async function apply(ctx, config) {
|
|
|
3951
4367
|
let body = {}
|
|
3952
4368
|
try {
|
|
3953
4369
|
body = parseJsonObject(await readBody(req))
|
|
3954
|
-
|
|
3955
|
-
//
|
|
3956
|
-
// Stored secrets never travel back to the browser (see
|
|
3957
|
-
// sanitizeMachine), so "编辑一台已经连接成功的机器 → 点测试连接" posted an
|
|
3958
|
-
// EMPTY password while the machine itself connected fine through the
|
|
3959
|
-
// pool's keychain/plaintext resolver. Every such probe died with
|
|
3960
|
-
// `no credentials: set a password, a privateKeyPath, or enable
|
|
3961
|
-
// useAgent to connect` — the error said "you configured nothing" about
|
|
3962
|
-
// a machine that was visibly working.
|
|
3963
|
-
//
|
|
3964
|
-
// So: resolve the saved record (by id when the form is editing one,
|
|
3965
|
-
// else by identity) and merge — form values override, blanks fall back
|
|
3966
|
-
// to the stored record. The resolved password is then ALSO exposed as a
|
|
3967
|
-
// lazy resolver so an OS-keychain machine behaves exactly like a real
|
|
3968
|
-
// connect (read on demand, never logged).
|
|
3969
|
-
const savedRec = (() => {
|
|
3970
|
-
const byId = body.id ? machines[machineIndex(String(body.id))] : null
|
|
3971
|
-
if (byId) return byId
|
|
3972
|
-
// No id (a fresh form that happens to match a saved machine): match on
|
|
3973
|
-
// the identity the form is about to dial.
|
|
3974
|
-
const hit = machineRecordFor({
|
|
3975
|
-
host: String(body.host || ''),
|
|
3976
|
-
port: Number(body.port) || 22,
|
|
3977
|
-
username: String(body.username || ''),
|
|
3978
|
-
})
|
|
3979
|
-
return hit || null
|
|
3980
|
-
})()
|
|
3981
|
-
const savedPassword = savedRec ? await machinePassword(savedRec) : ''
|
|
3982
|
-
const cred = resolveProbeCredentials(body, savedRec, savedPassword)
|
|
3983
|
-
// Issue #38: "test" on an ssh-config ALIAS resolves the alias first —
|
|
3984
|
-
// the form holds the alias name, not the host it points at.
|
|
3985
|
-
const probeBackend = String(cred.backend || config.backend || 'native')
|
|
3986
|
-
const probeIsOpenssh = isSystemOpenSshBackend({ backend: probeBackend })
|
|
3987
|
-
const aliasTarget = body.useSshConfig
|
|
3988
|
-
? resolveMachineSshConfig({
|
|
3989
|
-
host: String(body.host || ''),
|
|
3990
|
-
port: Number(body.port) || 22,
|
|
3991
|
-
username: String(body.username || ''),
|
|
3992
|
-
privateKeyPath: String(cred.privateKeyPath || ''),
|
|
3993
|
-
proxy: cred.proxy,
|
|
3994
|
-
useSshConfig: true,
|
|
3995
|
-
}, { text: sshConfigText() })
|
|
3996
|
-
: null
|
|
3997
|
-
// Issue #50: a LITERAL host named by a Host block resolves through
|
|
3998
|
-
// the file as well — the probe must dial what a real connect would.
|
|
3999
|
-
const literalTarget = !body.useSshConfig
|
|
4000
|
-
? resolveLiteralSshConfig({ host: String(body.host || '') }, { text: sshConfigText() })
|
|
4001
|
-
: null
|
|
4002
|
-
const probeTarget = aliasTarget || literalTarget
|
|
4003
|
-
// Issue #51: on the system-OpenSSH backend an alias is handed to `ssh`
|
|
4004
|
-
// VERBATIM (see connectTargetFor) — resolving it here would stop the
|
|
4005
|
-
// `Host` block from matching, so the ~/.ssh/config settings the probe
|
|
4006
|
-
// is supposed to validate would be silently skipped.
|
|
4007
|
-
const probe = createPool({
|
|
4008
|
-
...config,
|
|
4009
|
-
backend: probeBackend,
|
|
4010
|
-
host: probeTarget && !probeIsOpenssh ? probeTarget.host : String(body.host || config.host),
|
|
4011
|
-
port: (probeTarget && !probeIsOpenssh && probeTarget.port) ? probeTarget.port : (Number(body.port) || config.port),
|
|
4012
|
-
username: (probeTarget && !probeIsOpenssh && probeTarget.username) ? probeTarget.username : String(body.username || config.username),
|
|
4013
|
-
password: cred.password,
|
|
4014
|
-
privateKeyPath: (probeTarget && !probeIsOpenssh && probeTarget.privateKeyPath) ? probeTarget.privateKeyPath : String(cred.privateKeyPath || config.privateKeyPath),
|
|
4015
|
-
passphrase: cred.passphrase,
|
|
4016
|
-
// Auth METHOD flags come from the merge too: a machine that
|
|
4017
|
-
// authenticates through an agent or keyboard-interactive used to be
|
|
4018
|
-
// probed with `useAgent: false`, i.e. with no auth method at all.
|
|
4019
|
-
useAgent: cred.useAgent,
|
|
4020
|
-
agentSocket: (probeTarget && !probeIsOpenssh && probeTarget.agentSocket) || '',
|
|
4021
|
-
identitiesOnly: !!(probeTarget && !probeIsOpenssh && probeTarget.identitiesOnly),
|
|
4022
|
-
keyboardInteractive: cred.keyboardInteractive,
|
|
4023
|
-
...(cred.hostKeyMode ? { hostKeyMode: cred.hostKeyMode } : {}),
|
|
4024
|
-
...(cred.sshBinary ? { sshBinary: cred.sshBinary } : {}),
|
|
4025
|
-
proxy: (probeTarget && !probeIsOpenssh && probeTarget.proxy) ? probeTarget.proxy : cred.proxy,
|
|
4026
|
-
connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
|
|
4027
|
-
commandTimeoutMs: 10000,
|
|
4028
|
-
}, { knownHostsFile })
|
|
4029
|
-
// Same lazy resolution the active pool uses: if the merged password was
|
|
4030
|
-
// empty because the secret lives in the OS keychain, the pool asks again
|
|
4031
|
-
// at dial time instead of failing with "no credentials".
|
|
4032
|
-
probe.passwordResolver = async () => machinePassword(savedRec || {})
|
|
4033
|
-
const started = Date.now()
|
|
4034
|
-
await probe.connect()
|
|
4035
|
-
await probe.exec('true', { timeoutMs: 10000 })
|
|
4036
|
-
probe.close()
|
|
4037
|
-
const latencyMs = Date.now() - started
|
|
4038
|
-
// Alias-aware: the probe connects to the RESOLVED host, while the
|
|
4039
|
-
// registry stores the alias, so matching must go through the identity.
|
|
4040
|
-
const mi = machines.findIndex((m) => identityMatches(m, { host: probe.config.host, port: probe.config.port, username: probe.config.username }))
|
|
4041
|
-
if (mi >= 0) {
|
|
4042
|
-
machines[mi].lastConnectedAt = new Date().toISOString()
|
|
4043
|
-
machines[mi].latencyMs = latencyMs
|
|
4044
|
-
saveMachines(machines, store.currentId)
|
|
4045
|
-
}
|
|
4046
|
-
return sendJson(res, 200, {
|
|
4047
|
-
ok: true,
|
|
4048
|
-
host: probe.config.host,
|
|
4049
|
-
user: probe.config.username,
|
|
4050
|
-
latencyMs,
|
|
4051
|
-
lastConnectedAt: new Date().toISOString(),
|
|
4052
|
-
platform: probe.platform,
|
|
4053
|
-
shell: probe.shellMode,
|
|
4054
|
-
gitBash: probe.gitBashPath || '',
|
|
4055
|
-
// Tell the UI whether the probe leaned on the SAVED credential: with
|
|
4056
|
-
// an empty password box, "测试成功" otherwise looks impossible.
|
|
4057
|
-
usedSavedCredential: cred.usedSavedCredential,
|
|
4058
|
-
// Issue #38: tell the settings page where an alias resolved to (and
|
|
4059
|
-
// anything the plugin could not honour, e.g. a multi-hop ProxyJump).
|
|
4060
|
-
alias: aliasTarget ? { name: aliasTarget.alias, host: aliasTarget.host, port: aliasTarget.port, user: aliasTarget.username, warnings: aliasTarget.warnings } : null,
|
|
4061
|
-
})
|
|
4370
|
+
return sendJson(res, 200, await probeMachineRecord(body))
|
|
4062
4371
|
} catch (err) {
|
|
4063
4372
|
return sendJson(res, 200, { ok: false, error: friendlyMessage(err, { host: body.host, port: body.port }) })
|
|
4064
4373
|
}
|
|
@@ -4500,7 +4809,10 @@ export async function apply(ctx, config) {
|
|
|
4500
4809
|
// service off it (as if it were the controller) is a silent no-op, because a
|
|
4501
4810
|
// context has no `config.shellCandidates`.
|
|
4502
4811
|
ctx.inject(['terminalController'], (inner) => {
|
|
4503
|
-
installTerminalShell(ctx, inner.get('terminalController'),
|
|
4812
|
+
installTerminalShell(ctx, inner.get('terminalController'), {
|
|
4813
|
+
...remoteShellSpec(),
|
|
4814
|
+
claimDefault: config.remoteTerminalDefault !== false,
|
|
4815
|
+
})
|
|
4504
4816
|
})
|
|
4505
4817
|
|
|
4506
4818
|
// ── auto-update: check on load + on an interval; apply silently ───────────
|
|
@@ -4548,4 +4860,74 @@ export async function apply(ctx, config) {
|
|
|
4548
4860
|
if (typeof updateTimer.unref === 'function') updateTimer.unref()
|
|
4549
4861
|
ctx.effect(() => () => clearInterval(updateTimer), 'dsh-remote.update-timer')
|
|
4550
4862
|
}
|
|
4551
|
-
|
|
4863
|
+
|
|
4864
|
+
// ── in-process API for other plugins (TUI) ────────────────────────────────
|
|
4865
|
+
// Same closure state the HTTP routes use. A second implementation would hold
|
|
4866
|
+
// a stale `machines` snapshot, open a second SSH connection per host, and
|
|
4867
|
+
// race saveMachines()'s whole-file rewrite against this one. `lib/host-api.js`
|
|
4868
|
+
// only shapes these functions into pure data + coded errors.
|
|
4869
|
+
let hostApi
|
|
4870
|
+
try {
|
|
4871
|
+
hostApi = createHostApi({
|
|
4872
|
+
listMachinesView: () => ({ machines: machinesForClient(), currentId: store.currentId || null }),
|
|
4873
|
+
upsertMachineRecord,
|
|
4874
|
+
removeMachineRecord,
|
|
4875
|
+
setCurrentForApi,
|
|
4876
|
+
connectMachine: connectMachineForApi,
|
|
4877
|
+
probeMachineRecord,
|
|
4878
|
+
statusSnapshot: () => status(),
|
|
4879
|
+
resolveDirBinding: listDirBindingForApi,
|
|
4880
|
+
listRemoteProjects: listRemoteProjectsForApi,
|
|
4881
|
+
resolveMachine: resolveMachineForApi,
|
|
4882
|
+
ensureMirror,
|
|
4883
|
+
registerMirroredWorkspace,
|
|
4884
|
+
checkRemoteDir: checkRemoteDirForApi,
|
|
4885
|
+
workspaceRoot: remoteWorkspacesRoot,
|
|
4886
|
+
sessionsRoot: () => path.join(dshBase(), 'sessions'),
|
|
4887
|
+
workspaceRegistry: () => (ctx && typeof ctx.get === 'function' ? ctx.get('workspaceRegistry') : undefined),
|
|
4888
|
+
forgetRemotePath,
|
|
4889
|
+
isSessionLive,
|
|
4890
|
+
})
|
|
4891
|
+
// cordis 4: `ctx.provide(name, value)` is the raw service registry (the
|
|
4892
|
+
// `Service` base class is only sugar around it). Guarded because a minimal
|
|
4893
|
+
// embedding — and the test harness before it grew `provide` — has no
|
|
4894
|
+
// context service registry; a bare call would abort apply.
|
|
4895
|
+
if (ctx && typeof ctx.provide === 'function') {
|
|
4896
|
+
try {
|
|
4897
|
+
ctx.provide('dshRemote', hostApi)
|
|
4898
|
+
hostLog('info', '[dsh-remote] in-process API available as ctx.dshRemote (apiVersion 1)')
|
|
4899
|
+
} catch (err) {
|
|
4900
|
+
hostLog('warn', `[dsh-remote] could not register ctx.dshRemote: ${String((err && err.message) || err)}`)
|
|
4901
|
+
}
|
|
4902
|
+
}
|
|
4903
|
+
} catch (err) {
|
|
4904
|
+
// The API is additive: never let it break the plugin.
|
|
4905
|
+
hostLog('warn', `[dsh-remote] in-process API unavailable: ${String((err && err.message) || err)}`)
|
|
4906
|
+
}
|
|
4907
|
+
|
|
4908
|
+
// ── TUI integration layer (dsh-tui) ───────────────────────────────────────
|
|
4909
|
+
// Additive and optional: lib/tui/index.js probes its seams with
|
|
4910
|
+
// `ctx.get(name, false)` and is a no-op on a Web/Desktop profile. A broken
|
|
4911
|
+
// TUI layer must never take the host down, hence the guard here too.
|
|
4912
|
+
//
|
|
4913
|
+
// Loaded LAZILY on purpose: `lib/tui/` is the only SUBDIRECTORY under lib/,
|
|
4914
|
+
// and a deployment that copies just `lib/*.js` (a hand-rolled sync, a partial
|
|
4915
|
+
// unpacks) would otherwise fail at module-import time and take the whole
|
|
4916
|
+
// plugin down in the Web profile as well. A missing TUI directory now means
|
|
4917
|
+
// "no terminal layer", which is exactly the pre-TUI behaviour.
|
|
4918
|
+
if (config.tui !== false && hostApi) {
|
|
4919
|
+
import('./tui/index.js').then((mod) => {
|
|
4920
|
+
if (typeof mod.installTui !== 'function') {
|
|
4921
|
+
hostLog('warn', '[dsh-remote] TUI layer has no installTui export; skipped')
|
|
4922
|
+
return
|
|
4923
|
+
}
|
|
4924
|
+
try {
|
|
4925
|
+
mod.installTui(ctx, hostApi, config)
|
|
4926
|
+
} catch (err) {
|
|
4927
|
+
hostLog('warn', `[dsh-remote] TUI layer failed to install: ${String((err && err.message) || err)}`)
|
|
4928
|
+
}
|
|
4929
|
+
}).catch((err) => {
|
|
4930
|
+
hostLog('warn', `[dsh-remote] TUI layer unavailable: ${String((err && err.message) || err)}`)
|
|
4931
|
+
})
|
|
4932
|
+
}
|
|
4933
|
+
}
|