dsh-remote 0.8.37 → 0.8.38
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 +6 -2
- package/README.md +6 -2
- package/lib/archive.js +680 -0
- package/lib/backup.js +519 -0
- package/lib/client.js +220 -0
- package/lib/errors.js +9 -0
- package/lib/index.js +346 -52
- package/lib/pool.js +14 -1
- package/lib/routes-backup.js +313 -0
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -53,7 +53,13 @@ import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeM
|
|
|
53
53
|
import { registerHttpTransports } from './http-transport.js'
|
|
54
54
|
import { SshPool } from './pool.js'
|
|
55
55
|
import { createFsRoutes } from './routes-fs.js'
|
|
56
|
+
import { createBackupRoutes } from './routes-backup.js'
|
|
56
57
|
import { listDirStructured, removeRemoteTree } from './remote-fs.js'
|
|
58
|
+
import {
|
|
59
|
+
createBackup, listBackups, restoreBackup, describeCreate, localBackupDirFor, resolveRemoteHome,
|
|
60
|
+
pullBackup, pushBackup, deleteBackup, listLocalBackups,
|
|
61
|
+
} from './backup.js'
|
|
62
|
+
import { parseExcludes, resolveBackupDir, isSafeArchiveName, formatBytes, DEFAULT_MAX_TRANSFER_BYTES } from './archive.js'
|
|
57
63
|
|
|
58
64
|
export const name = 'dsh-remote'
|
|
59
65
|
|
|
@@ -192,6 +198,19 @@ export const Config = z.object({
|
|
|
192
198
|
/** npm registry to install from. Empty uses the remote's own npm config, which
|
|
193
199
|
* is what an internal mirror needs to stay in effect. */
|
|
194
200
|
webInstallRegistry: z.string().default(''),
|
|
201
|
+
/** Directory that workspace backups are written to on the REMOTE host. Empty
|
|
202
|
+
* means `$HOME/.dsh-remote/backups`, resolved on the remote (so a remote
|
|
203
|
+
* $HOME is never guessed from the local machine). */
|
|
204
|
+
backupDir: z.string().default(''),
|
|
205
|
+
/** Exclude patterns applied to backups by default (gitignore-ish, one per
|
|
206
|
+
* line). Deliberately EMPTY: a backup that silently omits directories is how
|
|
207
|
+
* a "restore" turns into silent data loss. Add patterns here (or per call)
|
|
208
|
+
* when you want a smaller archive. */
|
|
209
|
+
backupExcludes: z.array(z.string()).default([]),
|
|
210
|
+
/** Cap (bytes) on copying an archive between the remote and this machine.
|
|
211
|
+
* The archive on the remote is never truncated — this only bounds the
|
|
212
|
+
* optional cross-machine transfer. 0 = unlimited. */
|
|
213
|
+
maxBackupTransferBytes: z.number().step(1).min(0).default(DEFAULT_MAX_TRANSFER_BYTES),
|
|
195
214
|
})
|
|
196
215
|
|
|
197
216
|
// ── shell / path helpers (pure implementations in lib/paths.js) ───────────
|
|
@@ -1278,6 +1297,20 @@ export async function apply(ctx, config) {
|
|
|
1278
1297
|
return normalizeRemotePath(b.ws ? joinRemoteRel(b.ws, rel.replace(/\\/g, '/')) : rel)
|
|
1279
1298
|
}
|
|
1280
1299
|
|
|
1300
|
+
// ── backup helpers (shared by the tools and the /dsh-remote/backup routes) ─
|
|
1301
|
+
/** The remote directory backups land in: the configured override, else
|
|
1302
|
+
* `$HOME/.dsh-remote/backups` resolved ON THE REMOTE (never guessed from the
|
|
1303
|
+
* local machine, whose home has nothing to do with the remote one). */
|
|
1304
|
+
const backupDirForBinding = async (b) => {
|
|
1305
|
+
const override = String(config.backupDir || '').trim()
|
|
1306
|
+
if (override) return resolveBackupDir('', override)
|
|
1307
|
+
if (!b || !b.pool) return ''
|
|
1308
|
+
const home = await resolveRemoteHome(b.pool)
|
|
1309
|
+
return resolveBackupDir(home, '')
|
|
1310
|
+
}
|
|
1311
|
+
/** Local directory holding archives pulled from one machine. */
|
|
1312
|
+
const localBackupRootFor = (b) => localBackupDirFor(mirrorRootFor(b.host, b.username, b.port))
|
|
1313
|
+
|
|
1281
1314
|
// ── tools ─────────────────────────────────────────────────────────────────
|
|
1282
1315
|
|
|
1283
1316
|
const textOut = {
|
|
@@ -1345,84 +1378,160 @@ export async function apply(ctx, config) {
|
|
|
1345
1378
|
defineTool({
|
|
1346
1379
|
name: 'rw_connect',
|
|
1347
1380
|
description:
|
|
1348
|
-
'Connect SSH to a remote host for remote workspace work.
|
|
1381
|
+
'Connect SSH to a remote host for remote workspace work. Two ways (issue #48): (1) pass machineId to connect with a machine ALREADY SAVED in the registry (list them with rw_machines) — every stored setting applies: password/keychain password, privateKeyPath + passphrase, useAgent, keyboardInteractive, proxy, hostKeyMode, and ~/.ssh/config aliases; or (2) pass host plus any of username/port/password/privateKeyPath/passphrase/useAgent/keyboardInteractive/hostKeyMode/name. Defaults to saving the machine to the registry (save=false keeps it as a temporary connection). Once connected, call rw_pick_workspace to pick the workspace directory this session should work in. With useSshConfig=true, `host` is an ALIAS from ~/.ssh/config and every value (HostName/user/port/IdentityFile/ProxyJump) is read from that file at connect time. A failed connection is reported as an error message — it never takes the plugin down.',
|
|
1349
1382
|
parameters: {
|
|
1350
|
-
host: { type: 'string',
|
|
1383
|
+
host: { type: 'string', description: 'Remote host IP or hostname (or a ~/.ssh/config alias with useSshConfig=true). Required unless machineId is given.' },
|
|
1384
|
+
machineId: { type: 'string', description: 'Connect with a saved machine from the registry (see rw_machines): all its stored settings apply and it becomes the current machine. host/other fields are not needed.' },
|
|
1351
1385
|
username: { type: 'string', description: 'SSH user (default from config or root)' },
|
|
1352
1386
|
port: { type: 'integer', description: 'SSH port (default 22)' },
|
|
1353
1387
|
password: { type: 'string', description: 'SSH password (prefer SSH key when possible)' },
|
|
1354
1388
|
privateKeyPath: { type: 'string', description: 'Absolute private-key path' },
|
|
1389
|
+
passphrase: { type: 'string', description: 'Passphrase for an ENCRYPTED private key — required when the key is passphrase-protected, otherwise ssh2 cannot parse it (issue #48)' },
|
|
1390
|
+
useAgent: { type: 'boolean', description: 'Authenticate via the local SSH agent (SSH_AUTH_SOCK) instead of password/key' },
|
|
1391
|
+
keyboardInteractive: { type: 'boolean', description: 'Enable keyboard-interactive auth (OTP/动态码 prompts answered with the configured password)' },
|
|
1392
|
+
hostKeyMode: { type: 'string', description: 'Host-key policy for this machine: ask | strict | off (default from config)' },
|
|
1393
|
+
name: { type: 'string', description: 'Display name for a newly saved machine (default: the host)' },
|
|
1355
1394
|
useSshConfig: { type: 'boolean', description: 'Treat `host` as a ~/.ssh/config Host alias; resolve host/user/port/key/proxy from that file (issue #38)' },
|
|
1356
1395
|
save: { type: 'boolean', description: 'Save this machine to the registry and make it current (default true)' },
|
|
1357
1396
|
},
|
|
1358
1397
|
output: textOut,
|
|
1359
1398
|
async execute(args) {
|
|
1360
|
-
const
|
|
1361
|
-
if (
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
username: user,
|
|
1369
|
-
password: args.password !== undefined ? String(args.password) : '',
|
|
1370
|
-
privateKeyPath: args.privateKeyPath || '',
|
|
1371
|
-
}
|
|
1372
|
-
if (useSshConfig) rec.useSshConfig = true
|
|
1373
|
-
// An alias connection must dial the RESOLVED identity (~/.ssh/config),
|
|
1374
|
-
// while the registry keeps the alias itself.
|
|
1375
|
-
const eff = useSshConfig ? withSshConfig(rec) : rec
|
|
1376
|
-
const effUser = eff.username || user
|
|
1377
|
-
const effPort = Number(eff.port) || port || 22
|
|
1378
|
-
if (args.save !== false) {
|
|
1379
|
-
// Upsert into the registry and make it the current machine so the
|
|
1380
|
-
// settings UI and the tools always agree on who is active.
|
|
1381
|
-
const i = machines.findIndex((m) => m.host === rec.host && m.username === rec.username && Number(m.port) === rec.port)
|
|
1382
|
-
if (i >= 0) {
|
|
1383
|
-
machines[i] = { ...machines[i], ...rec, useSshConfig, id: machines[i].id, password: rec.password || machines[i].password || '' }
|
|
1384
|
-
store.currentId = machines[i].id
|
|
1385
|
-
saveMachines(machines, store.currentId)
|
|
1386
|
-
} else {
|
|
1387
|
-
const id = machineId()
|
|
1388
|
-
machines.push({ id, name: host, ...rec })
|
|
1389
|
-
store.currentId = id
|
|
1390
|
-
saveMachines(machines, store.currentId)
|
|
1391
|
-
}
|
|
1399
|
+
const mid = String(args.machineId || '').trim()
|
|
1400
|
+
if (mid) {
|
|
1401
|
+
// Connect with an ALREADY SAVED machine (issue #48): the registry
|
|
1402
|
+
// record carries everything the tool schema could not re-supply —
|
|
1403
|
+
// keychain-backed passwords, stored passphrases, proxies, aliases.
|
|
1404
|
+
const i = machineIndex(mid)
|
|
1405
|
+
if (i < 0) throw new Error(`rw_connect: no saved machine "${mid}" — list saved machines with rw_machines`)
|
|
1406
|
+
store.currentId = machines[i].id
|
|
1392
1407
|
ephemeral = null
|
|
1408
|
+
saveMachines(machines, store.currentId)
|
|
1393
1409
|
// rw_connect means "connect now": force a fresh dial even for an
|
|
1394
|
-
// identical target (
|
|
1395
|
-
// cached client — same reconnect-probe reasoning as /connect).
|
|
1410
|
+
// identical target (same reconnect-probe reasoning as below).
|
|
1396
1411
|
pool.invalidate()
|
|
1397
1412
|
await applyActiveMachine()
|
|
1398
1413
|
} else {
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
host
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1414
|
+
const host = String(args.host || '').trim()
|
|
1415
|
+
if (!host) throw new Error('rw_connect: host is required — or pass machineId to connect with a saved machine (list them with rw_machines)')
|
|
1416
|
+
const useSshConfig = !!args.useSshConfig
|
|
1417
|
+
const user = args.username || config.username || 'root'
|
|
1418
|
+
const port = Number(args.port) || undefined
|
|
1419
|
+
const rec = {
|
|
1420
|
+
host,
|
|
1421
|
+
port: port || 22,
|
|
1422
|
+
username: user,
|
|
1423
|
+
password: args.password !== undefined ? String(args.password) : '',
|
|
1424
|
+
privateKeyPath: args.privateKeyPath || '',
|
|
1425
|
+
}
|
|
1426
|
+
// Carry ONLY the fields the caller actually passed: the upsert below
|
|
1427
|
+
// merges rec over the stored record, and an always-present default
|
|
1428
|
+
// would clobber stored values (e.g. wiping a saved passphrase).
|
|
1429
|
+
if (args.passphrase !== undefined) rec.passphrase = String(args.passphrase)
|
|
1430
|
+
if (args.useAgent !== undefined) rec.useAgent = !!args.useAgent
|
|
1431
|
+
if (args.keyboardInteractive !== undefined) rec.keyboardInteractive = !!args.keyboardInteractive
|
|
1432
|
+
if (args.hostKeyMode) rec.hostKeyMode = String(args.hostKeyMode)
|
|
1433
|
+
if (args.name) rec.name = String(args.name)
|
|
1434
|
+
if (useSshConfig) rec.useSshConfig = true
|
|
1435
|
+
// An alias connection must dial the RESOLVED identity (~/.ssh/config),
|
|
1436
|
+
// while the registry keeps the alias itself.
|
|
1437
|
+
const eff = useSshConfig ? withSshConfig(rec) : rec
|
|
1438
|
+
if (args.save !== false) {
|
|
1439
|
+
// Upsert into the registry and make it the current machine so the
|
|
1440
|
+
// settings UI and the tools always agree on who is active.
|
|
1441
|
+
const i = machines.findIndex((m) => m.host === rec.host && m.username === rec.username && Number(m.port) === rec.port)
|
|
1442
|
+
if (i >= 0) {
|
|
1443
|
+
machines[i] = {
|
|
1444
|
+
...machines[i], ...rec, useSshConfig, id: machines[i].id,
|
|
1445
|
+
// Secrets not re-supplied keep their stored values.
|
|
1446
|
+
password: rec.password || machines[i].password || '',
|
|
1447
|
+
passphrase: rec.passphrase !== undefined ? rec.passphrase : (machines[i].passphrase || ''),
|
|
1448
|
+
}
|
|
1449
|
+
store.currentId = machines[i].id
|
|
1450
|
+
saveMachines(machines, store.currentId)
|
|
1451
|
+
} else {
|
|
1452
|
+
const id = machineId()
|
|
1453
|
+
machines.push({ id, name: rec.name || host, ...rec })
|
|
1454
|
+
store.currentId = id
|
|
1455
|
+
saveMachines(machines, store.currentId)
|
|
1456
|
+
}
|
|
1457
|
+
ephemeral = null
|
|
1458
|
+
// rw_connect means "connect now": force a fresh dial even for an
|
|
1459
|
+
// identical target (setTarget alone would reuse a possibly half-open
|
|
1460
|
+
// cached client — same reconnect-probe reasoning as /connect).
|
|
1461
|
+
pool.invalidate()
|
|
1462
|
+
await applyActiveMachine()
|
|
1463
|
+
} else {
|
|
1464
|
+
ephemeral = { id: machineId(), name: rec.name || host, ...rec }
|
|
1465
|
+
// rw_connect means "connect now": force a fresh dial even for an
|
|
1466
|
+
// identical target (setTarget alone would reuse a possibly half-open
|
|
1467
|
+
// cached client — same reconnect-probe reasoning as /connect).
|
|
1468
|
+
pool.invalidate()
|
|
1469
|
+
pool.setTarget({
|
|
1470
|
+
host: eff.host, port: Number(eff.port) || port || 22, username: eff.username || user,
|
|
1471
|
+
password: rec.password, privateKeyPath: eff.privateKeyPath || '',
|
|
1472
|
+
// Explicit (non-undefined) values so a stale passphrase/agent flag
|
|
1473
|
+
// from a previous target cannot leak into this connection.
|
|
1474
|
+
passphrase: eff.passphrase || '',
|
|
1475
|
+
useAgent: !!rec.useAgent,
|
|
1476
|
+
keyboardInteractive: !!rec.keyboardInteractive,
|
|
1477
|
+
hostKeyMode: rec.hostKeyMode || config.hostKeyMode,
|
|
1478
|
+
proxy: eff.proxy,
|
|
1479
|
+
workspace: config.workspace,
|
|
1480
|
+
})
|
|
1481
|
+
}
|
|
1410
1482
|
}
|
|
1483
|
+
// Shared probe tail: after either path the shared config holds the
|
|
1484
|
+
// effective dial identity (applyActiveMachine / setTarget wrote it).
|
|
1485
|
+
const effHost = config.host
|
|
1486
|
+
const effUser = config.username
|
|
1487
|
+
const effPort = Number(config.port) || 22
|
|
1411
1488
|
try {
|
|
1412
1489
|
const res = await pool.exec('echo ok', { timeoutMs: 8000 })
|
|
1413
1490
|
if (res.code !== 0 && !res.stdout) {
|
|
1414
|
-
audit('connect', `connect ${effUser}@${
|
|
1491
|
+
audit('connect', `connect ${effUser}@${effHost}:${effPort}`, res.code)
|
|
1415
1492
|
return { text: 'connect failed: ' + (res.stderr || 'exit ' + res.code) }
|
|
1416
1493
|
}
|
|
1417
|
-
audit('connect', `connect ${effUser}@${
|
|
1418
|
-
const
|
|
1419
|
-
|
|
1494
|
+
audit('connect', `connect ${effUser}@${effHost}:${effPort}`, 0)
|
|
1495
|
+
const active = activeMachine()
|
|
1496
|
+
const via = active && active.sshAlias
|
|
1497
|
+
? `\n(ssh-config alias "${active.sshAlias}" → ${effUser}@${effHost}:${effPort}` + (active.sshConfigWarnings && active.sshConfigWarnings.length ? `; ${active.sshConfigWarnings.join(' ')}` : '') + ')'
|
|
1498
|
+
: ''
|
|
1499
|
+
return { text: `Connected to ${effHost} as ${effUser}.${via}\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
|
|
1420
1500
|
} catch (err) {
|
|
1421
|
-
throw new Error(friendlyMessage(err, { host:
|
|
1501
|
+
throw new Error(friendlyMessage(err, { host: effHost, port: effPort }))
|
|
1422
1502
|
}
|
|
1423
1503
|
},
|
|
1424
1504
|
}),
|
|
1425
1505
|
|
|
1506
|
+
defineTool({
|
|
1507
|
+
name: 'rw_machines',
|
|
1508
|
+
description:
|
|
1509
|
+
'List the machines saved in the registry, secrets stripped: id, name, host/port/username, auth method flags (password / key + whether a passphrase is set / agent / keyboard-interactive), ssh-config alias resolution, workspace, and which one is current. Then pass rw_connect(machineId=<id>) to connect with a machine\'s full stored settings — the fix for "the agent cannot retype the whole config" (issue #48).',
|
|
1510
|
+
parameters: {},
|
|
1511
|
+
output: textOut,
|
|
1512
|
+
async execute() {
|
|
1513
|
+
if (!machines.length) {
|
|
1514
|
+
return { text: 'No saved machines yet — add one in Settings → 远程工作区, or connect with rw_connect(host=..., save defaults to true).' }
|
|
1515
|
+
}
|
|
1516
|
+
const lines = machines.map((m) => {
|
|
1517
|
+
const cur = store.currentId === m.id ? ' [CURRENT]' : ''
|
|
1518
|
+
const auth = []
|
|
1519
|
+
if (m.password || (m.credentialBackend && m.credentialBackend !== 'plain')) auth.push('password')
|
|
1520
|
+
if (m.privateKeyPath) auth.push(`key:${m.privateKeyPath}${m.passphrase ? ' (passphrase set)' : ''}`)
|
|
1521
|
+
if (m.useAgent) auth.push('agent')
|
|
1522
|
+
if (m.keyboardInteractive) auth.push('keyboard-interactive')
|
|
1523
|
+
let via = ''
|
|
1524
|
+
if (m.useSshConfig) {
|
|
1525
|
+
const eff = withSshConfig(m)
|
|
1526
|
+
via = ` alias→${eff.username || ''}@${eff.host}:${Number(eff.port) || 22}`
|
|
1527
|
+
}
|
|
1528
|
+
const ws = m.workspace ? ` workspace=${m.workspace}` : ''
|
|
1529
|
+
return `- ${m.id} "${m.name || m.host}" ${m.username || ''}@${m.host}:${Number(m.port) || 22}${via}${cur}\n auth: ${auth.join(' + ') || 'NONE (connect will fail — add credentials)'}${ws}`
|
|
1530
|
+
})
|
|
1531
|
+
return { text: lines.join('\n') + '\n\nconnect with rw_connect(machineId=<id>) to reuse a machine\'s full stored settings.' }
|
|
1532
|
+
},
|
|
1533
|
+
}),
|
|
1534
|
+
|
|
1426
1535
|
defineTool({
|
|
1427
1536
|
name: 'rw_pick_workspace',
|
|
1428
1537
|
description:
|
|
@@ -2149,6 +2258,159 @@ export async function apply(ctx, config) {
|
|
|
2149
2258
|
return { ok: true, text: `disconnected ${b.username}@${b.host}:${b.port} for this session` }
|
|
2150
2259
|
},
|
|
2151
2260
|
}),
|
|
2261
|
+
|
|
2262
|
+
defineTool({
|
|
2263
|
+
name: 'rw_backup',
|
|
2264
|
+
description:
|
|
2265
|
+
'Create a compressed (.tar.gz) backup of the remote workspace (or a subdirectory of it) on the remote host, with an optional copy pulled to this machine. Archives are written under $HOME/.dsh-remote/backups (config backupDir) with a sidecar .meta.json recording the workspace, entry count and sha256. Add exclude patterns when you want a smaller archive — by default EVERYTHING is archived, because silently omitting files is how a restore becomes silent data loss. Use rw_backup_list to see what exists and rw_restore to restore one.',
|
|
2266
|
+
parameters: {
|
|
2267
|
+
path: { type: 'string', description: 'Directory to back up (default and normally: the current remote workspace). Must be the workspace or a subdirectory of it.' },
|
|
2268
|
+
label: { type: 'string', description: 'Optional tag embedded in the archive filename (e.g. "before-refactor").' },
|
|
2269
|
+
excludes: { type: 'string', description: 'Comma- or newline-separated extra exclude patterns (gitignore-ish: node_modules, /build/, *.log). Relative paths inside the workspace.' },
|
|
2270
|
+
dest: { type: 'string', description: 'Where the archive ends up: remote (default), local (pulled here; the remote staging copy is removed), or both.' },
|
|
2271
|
+
},
|
|
2272
|
+
output: textOut,
|
|
2273
|
+
async execute(args, exec) {
|
|
2274
|
+
const b = requireBinding(exec, 'rw_backup')
|
|
2275
|
+
const dir = resolveRemoteArg(b, args.path || '')
|
|
2276
|
+
if (!dir || dir === '/') throw new Error('rw_backup: a directory to back up is required')
|
|
2277
|
+
const root = normalizeRemotePath(b.ws || '')
|
|
2278
|
+
if (root && dir !== root && !dir.startsWith(root.replace(/\/+$/, '') + '/')) {
|
|
2279
|
+
throw new Error(`rw_backup: ${dir} is outside this session's remote workspace (${root}) — backups are limited to the workspace tree`)
|
|
2280
|
+
}
|
|
2281
|
+
// Config defaults first, then this call's own patterns, so a caller can
|
|
2282
|
+
// extend the profile's excludes without restating them.
|
|
2283
|
+
const parsed = parseExcludes([
|
|
2284
|
+
...(Array.isArray(config.backupExcludes) ? config.backupExcludes : []),
|
|
2285
|
+
...String(args.excludes || '').split(/[\r\n,]+/),
|
|
2286
|
+
])
|
|
2287
|
+
const backupDir = await backupDirForBinding(b)
|
|
2288
|
+
if (!backupDir) throw new Error('rw_backup: cannot determine the remote $HOME — set the backupDir config option')
|
|
2289
|
+
const dest = ['remote', 'local', 'both'].includes(String(args.dest)) ? String(args.dest) : 'remote'
|
|
2290
|
+
const result = await createBackup(b.pool, {
|
|
2291
|
+
dir,
|
|
2292
|
+
backupDir,
|
|
2293
|
+
label: args.label ? String(args.label) : '',
|
|
2294
|
+
excludes: parsed.patterns,
|
|
2295
|
+
meta: { host: b.host, username: b.username, port: b.port, label: args.label ? String(args.label) : '' },
|
|
2296
|
+
})
|
|
2297
|
+
if (!result.ok) {
|
|
2298
|
+
audit('backup', `backup ${dir} failed: ${result.error}`, 1, b)
|
|
2299
|
+
throw new Error('rw_backup: ' + result.error)
|
|
2300
|
+
}
|
|
2301
|
+
audit('backup', `backup ${dir} → ${result.archive}`, 0, b)
|
|
2302
|
+
const lines = [describeCreate(result, { dest })]
|
|
2303
|
+
lines.push(`remote archive: ${result.archive}`)
|
|
2304
|
+
if (result.meta && result.meta.excludes && result.meta.excludes.length) lines.push(`excludes: ${result.meta.excludes.join(', ')}`)
|
|
2305
|
+
if (parsed.dropped.length) lines.push(`ignored (no usable pattern): ${parsed.dropped.join(', ')}`)
|
|
2306
|
+
if (dest === 'local' || dest === 'both') {
|
|
2307
|
+
const pulled = await pullBackup(b.pool, {
|
|
2308
|
+
archive: result.archive,
|
|
2309
|
+
localDir: localBackupDirFor(mirrorRootFor(b.host, b.username, b.port)),
|
|
2310
|
+
maxTransferBytes: Number(config.maxBackupTransferBytes) || 0,
|
|
2311
|
+
})
|
|
2312
|
+
if (pulled.ok) {
|
|
2313
|
+
lines.push(`local copy: ${pulled.localPath}`)
|
|
2314
|
+
if (dest === 'local') {
|
|
2315
|
+
const del = await deleteBackup(b.pool, { archive: result.archive })
|
|
2316
|
+
lines.push(del.ok ? 'remote staging copy removed (dest=local)' : `could NOT remove the remote copy: ${del.error || 'unknown error'}`)
|
|
2317
|
+
}
|
|
2318
|
+
} else {
|
|
2319
|
+
lines.push(`local copy FAILED: ${pulled.error}`)
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
return { text: lines.join('\n') }
|
|
2323
|
+
},
|
|
2324
|
+
}),
|
|
2325
|
+
|
|
2326
|
+
defineTool({
|
|
2327
|
+
name: 'rw_backup_list',
|
|
2328
|
+
description:
|
|
2329
|
+
"List the compressed backups of this session's remote workspace: name, size, entry count, and when it was taken. Shows both the archives stored on the remote host and any pulled into the local machine. Use the exact `name` with rw_restore.",
|
|
2330
|
+
parameters: {
|
|
2331
|
+
path: { type: 'string', description: "Directory whose backups to list (default: the workspace). Backups are named by workspace, so passing a subdirectory lists that subdirectory's backups." },
|
|
2332
|
+
},
|
|
2333
|
+
output: textOut,
|
|
2334
|
+
async execute(args, exec) {
|
|
2335
|
+
const b = requireBinding(exec, 'rw_backup_list')
|
|
2336
|
+
const backupDir = await backupDirForBinding(b)
|
|
2337
|
+
const remote = backupDir
|
|
2338
|
+
? await listBackups(b.pool, { dir: backupDir })
|
|
2339
|
+
: { exists: false, dir: '', entries: [], error: '' }
|
|
2340
|
+
const localDir = localBackupDirFor(mirrorRootFor(b.host, b.username, b.port))
|
|
2341
|
+
const local = listLocalBackups(localDir)
|
|
2342
|
+
const fmt = (e) => {
|
|
2343
|
+
const bits = [e.name, formatBytes(e.bytes)]
|
|
2344
|
+
if (e.members != null) bits.push(`${e.members} entries`)
|
|
2345
|
+
if (e.createdAt || e.mtime) bits.push(e.createdAt || e.mtime)
|
|
2346
|
+
if (!e.hasMeta) bits.push('NO SIDECAR (metadata unknown)')
|
|
2347
|
+
return ' ' + bits.join(' | ')
|
|
2348
|
+
}
|
|
2349
|
+
const lines = []
|
|
2350
|
+
if (!backupDir) {
|
|
2351
|
+
lines.push('(cannot resolve the remote backup directory — set the backupDir config option)')
|
|
2352
|
+
} else if (remote.error) {
|
|
2353
|
+
lines.push(`remote listing failed: ${remote.error}`)
|
|
2354
|
+
} else if (!remote.entries.length) {
|
|
2355
|
+
lines.push(`no backups yet in ${backupDir} (create one with rw_backup)`)
|
|
2356
|
+
} else {
|
|
2357
|
+
lines.push(`${remote.entries.length} remote backup(s) in ${backupDir}:`)
|
|
2358
|
+
lines.push(...remote.entries.map(fmt))
|
|
2359
|
+
}
|
|
2360
|
+
if (local.entries.length) {
|
|
2361
|
+
lines.push('', `${local.entries.length} local copy(ies) in ${localDir}:`)
|
|
2362
|
+
lines.push(...local.entries.map(fmt))
|
|
2363
|
+
}
|
|
2364
|
+
lines.push('', 'restore one with rw_restore(file=<name>).')
|
|
2365
|
+
return { text: lines.join('\n') }
|
|
2366
|
+
},
|
|
2367
|
+
}),
|
|
2368
|
+
|
|
2369
|
+
defineTool({
|
|
2370
|
+
name: 'rw_restore',
|
|
2371
|
+
description:
|
|
2372
|
+
'Restore a workspace from one of its compressed backups (list them with rw_backup_list). The archive is verified BEFORE anything is written; a corrupt or truncated archive is refused and the target is left untouched. mode=replace (default) swaps the target for the archive contents and removes files not present in the archive; mode=merge extracts over the existing tree and deletes nothing. Prefer replace for "give me back exactly the state I archived"; use merge only when the archive is known to be a partial overlay.',
|
|
2373
|
+
parameters: {
|
|
2374
|
+
file: { type: 'string', required: true, description: 'Backup archive filename from rw_backup_list (e.g. proj-abc-2026-10-08T11-22-33.tar.gz).' },
|
|
2375
|
+
target: { type: 'string', description: 'Directory to restore into (default: the current remote workspace). Usually left unset.' },
|
|
2376
|
+
mode: { type: 'string', description: 'replace (default) or merge.' },
|
|
2377
|
+
where: { type: 'string', description: 'Which copy to restore from: remote (default) or local (a pulled copy; it is uploaded back first).' },
|
|
2378
|
+
},
|
|
2379
|
+
output: textOut,
|
|
2380
|
+
async execute(args, exec) {
|
|
2381
|
+
const b = requireBinding(exec, 'rw_restore')
|
|
2382
|
+
const file = String(args.file || '').trim()
|
|
2383
|
+
if (!file) throw new Error('rw_restore: file is required (list them with rw_backup_list)')
|
|
2384
|
+
if (!isSafeArchiveName(file)) throw new Error(`rw_restore: ${file} is not a valid backup filename (expected a plain *.tar.gz basename)`)
|
|
2385
|
+
const target = args.target ? resolveRemoteArg(b, args.target) : (b.ws || '')
|
|
2386
|
+
if (!target || target === '/') throw new Error('rw_restore: no restore target — pick a remote workspace first')
|
|
2387
|
+
const root = normalizeRemotePath(b.ws || '')
|
|
2388
|
+
if (root && target !== root && !target.startsWith(root.replace(/\/+$/, '') + '/')) {
|
|
2389
|
+
throw new Error(`rw_restore: refusing to restore into ${target} — it is outside this session's remote workspace (${root})`)
|
|
2390
|
+
}
|
|
2391
|
+
const mode = args.mode === 'merge' ? 'merge' : 'replace'
|
|
2392
|
+
const backupDir = await backupDirForBinding(b)
|
|
2393
|
+
if (!backupDir) throw new Error('rw_restore: cannot determine the remote backup directory — set the backupDir config option')
|
|
2394
|
+
const where = args.where === 'local' ? 'local' : 'remote'
|
|
2395
|
+
let archive = `${backupDir.replace(/\/+$/, '')}/${file}`
|
|
2396
|
+
if (where === 'local') {
|
|
2397
|
+
const localDir = localBackupDirFor(mirrorRootFor(b.host, b.username, b.port))
|
|
2398
|
+
const up = await pushBackup(b.pool, {
|
|
2399
|
+
localPath: path.join(localDir, file),
|
|
2400
|
+
remoteDir: backupDir,
|
|
2401
|
+
maxTransferBytes: Number(config.maxBackupTransferBytes) || 0,
|
|
2402
|
+
})
|
|
2403
|
+
if (!up.ok) throw new Error('rw_restore: could not upload the local copy — ' + up.error)
|
|
2404
|
+
archive = up.remotePath
|
|
2405
|
+
}
|
|
2406
|
+
const r = await restoreBackup(b.pool, { archive, target, mode })
|
|
2407
|
+
audit('restore', `${mode} restore ${file} → ${target}`, r.ok ? 0 : 1, b)
|
|
2408
|
+
if (!r.ok) throw new Error('rw_restore: ' + (r.error || 'restore failed'))
|
|
2409
|
+
return {
|
|
2410
|
+
text: `${r.mode === 'merge' ? 'Merged into' : 'Replaced'} ${target} from ${file} (${r.members} file(s) now present; the archive was verified before anything was written)${where === 'local' ? ' [from the local copy, uploaded first]' : ''}.`,
|
|
2411
|
+
}
|
|
2412
|
+
},
|
|
2413
|
+
}),
|
|
2152
2414
|
]
|
|
2153
2415
|
|
|
2154
2416
|
for (const t of tools) {
|
|
@@ -2528,6 +2790,38 @@ export async function apply(ctx, config) {
|
|
|
2528
2790
|
sendJson, readBody, resolveRequestBinding, decodeBuf, encodeText, audit, config, mirrorDirFor,
|
|
2529
2791
|
}),
|
|
2530
2792
|
|
|
2793
|
+
...createBackupRoutes({
|
|
2794
|
+
sendJson,
|
|
2795
|
+
readBody,
|
|
2796
|
+
config,
|
|
2797
|
+
audit,
|
|
2798
|
+
resolveRequestBinding,
|
|
2799
|
+
// The settings panel may act on a machine that is not the current one; the
|
|
2800
|
+
// id is resolved to a real pool so a backup never runs against whichever
|
|
2801
|
+
// machine happened to be active.
|
|
2802
|
+
resolveMachineBinding: async (machineId) => {
|
|
2803
|
+
const m = machines.find((x) => x.id === machineId)
|
|
2804
|
+
if (!m) {
|
|
2805
|
+
const err = new Error(`机器不存在:${machineId}`)
|
|
2806
|
+
err.httpStatus = 404
|
|
2807
|
+
throw err
|
|
2808
|
+
}
|
|
2809
|
+
const target = poolForMachine(identityOf(m))
|
|
2810
|
+
return {
|
|
2811
|
+
pool: target,
|
|
2812
|
+
ws: String(m.workspace || ''),
|
|
2813
|
+
host: m.host,
|
|
2814
|
+
username: m.username,
|
|
2815
|
+
port: Number(m.port) || 22,
|
|
2816
|
+
alias: aliasOf(m),
|
|
2817
|
+
mirrorDir: m.workspace ? mirrorDirFor(m.workspace, m.host, m.username, m.port) : null,
|
|
2818
|
+
bound: false,
|
|
2819
|
+
local: false,
|
|
2820
|
+
}
|
|
2821
|
+
},
|
|
2822
|
+
localRootFor: localBackupRootFor,
|
|
2823
|
+
}),
|
|
2824
|
+
|
|
2531
2825
|
{
|
|
2532
2826
|
kind: 'exact',
|
|
2533
2827
|
path: '/dsh-remote/workspace',
|
package/lib/pool.js
CHANGED
|
@@ -213,7 +213,20 @@ export class SshPool {
|
|
|
213
213
|
finish(prompts.map(() => this.config.password || ''))
|
|
214
214
|
})
|
|
215
215
|
}
|
|
216
|
-
|
|
216
|
+
// ssh2 parses the private key SYNCHRONOUSLY inside connect() and
|
|
217
|
+
// throws on an unparseable key (the classic case: an encrypted
|
|
218
|
+
// OpenSSH key without a passphrase). Left uncaught, that throw
|
|
219
|
+
// rejects this orphaned .then chain — an unhandled rejection that
|
|
220
|
+
// crashes the whole host process (issue #48: "dsh: fatal load
|
|
221
|
+
// failure" on every boot, because boot-restore re-dials the bad
|
|
222
|
+
// machine). It also leaves the outer promise pending forever.
|
|
223
|
+
// Convert it into a normal rejection so every caller's try/catch —
|
|
224
|
+
// boot restore, test-connect, rw_connect — sees a regular failure.
|
|
225
|
+
try {
|
|
226
|
+
client.connect(opts)
|
|
227
|
+
} catch (err) {
|
|
228
|
+
rejectOnce(err)
|
|
229
|
+
}
|
|
217
230
|
},
|
|
218
231
|
(err) => rejectOnce(err),
|
|
219
232
|
)
|