dsh-remote 0.8.36 → 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/lib/index.js CHANGED
@@ -47,13 +47,19 @@ import {
47
47
  resolvePrefix, stepResult, DEFAULT_INSTALL_VERSION,
48
48
  } from './web-deploy.js'
49
49
  import { registerDeploySkill, buildInvestigatePrompt, SKILL_NAME } from './web-deploy-skill.js'
50
- import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode, reloadSelf, scheduleSelfReload, isInstalledCopy } from './update.js'
50
+ import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode, reloadSelf, scheduleSelfReload, lastSelfReload, isInstalledCopy } from './update.js'
51
51
  import { sendHeartbeat, pseudonym, heartbeatUrl, hasPersistedId, installIdPath } from './telemetry.js'
52
52
  import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId } from './registry.js'
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. Provide host (required), user, optional password or privateKeyPath/port. 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.',
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', required: true, description: 'Remote host IP or hostname (or a ~/.ssh/config alias with useSshConfig=true)' },
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 host = String(args.host || '').trim()
1361
- if (!host) throw new Error('rw_connect: host is required')
1362
- const useSshConfig = !!args.useSshConfig
1363
- const user = args.username || config.username || 'root'
1364
- const port = Number(args.port) || undefined
1365
- const rec = {
1366
- host,
1367
- port: port || 22,
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 (setTarget alone would reuse a possibly half-open
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
- ephemeral = { id: machineId(), name: host, ...rec }
1400
- // rw_connect means "connect now": force a fresh dial even for an
1401
- // identical target (setTarget alone would reuse a possibly half-open
1402
- // cached client — same reconnect-probe reasoning as /connect).
1403
- pool.invalidate()
1404
- pool.setTarget({
1405
- host: eff.host, port: effPort, username: effUser,
1406
- password: rec.password, privateKeyPath: eff.privateKeyPath || '',
1407
- proxy: eff.proxy,
1408
- workspace: config.workspace,
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}@${eff.host}:${effPort}`, res.code)
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}@${eff.host}:${effPort}`, 0)
1418
- const via = useSshConfig ? `\n(ssh-config alias "${host}" → ${effUser}@${eff.host}:${effPort}` + (eff.sshConfigWarnings && eff.sshConfigWarnings.length ? `; ${eff.sshConfigWarnings.join(' ')}` : '') + ')' : ''
1419
- return { text: `Connected to ${eff.host} as ${config.username}.${via}\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
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: eff.host, port: effPort }))
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',
@@ -3261,6 +3555,10 @@ export async function apply(ctx, config) {
3261
3555
  // Exposed so the settings page can explain why `auto` may not act:
3262
3556
  // a link:/dev install must not be overwritten by an npm publish.
3263
3557
  selfUpdateAllowed: isInstalledCopy(),
3558
+ // Outcome of the last hot-swap attempt (null if never tried). Without
3559
+ // this the swap could fail forever while the panel kept saying a
3560
+ // reload was merely pending — which is exactly what happened.
3561
+ lastReload: lastSelfReload(),
3264
3562
  }
3265
3563
  const probe = await fetchLatestVersion()
3266
3564
  // 即使查不到最新版,也把**本地状态**照常返回(ok:true),只把 registry
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
- client.connect(opts)
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
  )