dsh-remote 0.8.40 → 0.8.42
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 +2 -6
- package/README.md +2 -6
- package/lib/client.js +130 -191
- package/lib/deploy-tasks.js +142 -0
- package/lib/index.js +111 -337
- package/lib/paths.js +10 -2
- package/lib/web-attach.js +12 -0
- package/package.json +2 -2
- package/lib/archive.js +0 -1005
- package/lib/backup.js +0 -711
- package/lib/routes-backup.js +0 -283
package/lib/index.js
CHANGED
|
@@ -40,6 +40,7 @@ import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from '.
|
|
|
40
40
|
import { searchRemote } from './search.js'
|
|
41
41
|
import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessionHeaderCwd, requestSessionHint } from './binding.js'
|
|
42
42
|
import { TaskManager } from './tasks.js'
|
|
43
|
+
import { DeployTasks } from './deploy-tasks.js'
|
|
43
44
|
import { ForwardManager } from './forwards.js'
|
|
44
45
|
import { WebAttach } from './web-attach.js'
|
|
45
46
|
import {
|
|
@@ -53,13 +54,7 @@ import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeM
|
|
|
53
54
|
import { registerHttpTransports } from './http-transport.js'
|
|
54
55
|
import { SshPool } from './pool.js'
|
|
55
56
|
import { createFsRoutes } from './routes-fs.js'
|
|
56
|
-
import { createBackupRoutes } from './routes-backup.js'
|
|
57
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'
|
|
63
58
|
|
|
64
59
|
export const name = 'dsh-remote'
|
|
65
60
|
|
|
@@ -198,19 +193,6 @@ export const Config = z.object({
|
|
|
198
193
|
/** npm registry to install from. Empty uses the remote's own npm config, which
|
|
199
194
|
* is what an internal mirror needs to stay in effect. */
|
|
200
195
|
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),
|
|
214
196
|
})
|
|
215
197
|
|
|
216
198
|
// ── shell / path helpers (pure implementations in lib/paths.js) ───────────
|
|
@@ -868,6 +850,10 @@ export async function apply(ctx, config) {
|
|
|
868
850
|
// starting a second remote server. Lifecycle is owned by the plugin fiber, so
|
|
869
851
|
// unloading the plugin tears every tunnel down.
|
|
870
852
|
const webAttaches = new Map() // machineId → WebAttach
|
|
853
|
+
// Deploy tasks run on the HOST, detached from any request: closing the browser
|
|
854
|
+
// window mid-install must not lose the progress, and a re-opened page must be
|
|
855
|
+
// able to re-attach and see the live state (high-availability requirement).
|
|
856
|
+
const deployTasks = new DeployTasks()
|
|
871
857
|
ctx.effect(() => () => {
|
|
872
858
|
for (const attach of webAttaches.values()) { try { attach.close() } catch { /* already gone */ } }
|
|
873
859
|
webAttaches.clear()
|
|
@@ -1297,74 +1283,6 @@ export async function apply(ctx, config) {
|
|
|
1297
1283
|
return normalizeRemotePath(b.ws ? joinRemoteRel(b.ws, rel.replace(/\\/g, '/')) : rel)
|
|
1298
1284
|
}
|
|
1299
1285
|
|
|
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
|
-
|
|
1314
|
-
/**
|
|
1315
|
-
* Every REMOTE workspace the sidebar knows about for one machine.
|
|
1316
|
-
*
|
|
1317
|
-
* "Back up all my workspaces" has to mean what the user sees in the sidebar, so
|
|
1318
|
-
* this walks the mirror registry rather than the machine's recent-workspaces
|
|
1319
|
-
* list: each mirror dir carries `.dsh-remote-meta.json` with the remote origin,
|
|
1320
|
-
* which is authoritative and survives the sidebar's own storage being reset.
|
|
1321
|
-
* The machine's remembered workspaces are unioned in as a fallback for
|
|
1322
|
-
* workspaces that have never been mirrored on this machine.
|
|
1323
|
-
*
|
|
1324
|
-
* @param {{host: string, username: string, port: number}} machine
|
|
1325
|
-
* @returns {string[]} absolute REMOTE directory paths, de-duplicated.
|
|
1326
|
-
*/
|
|
1327
|
-
const remoteWorkspacesFor = (machine) => {
|
|
1328
|
-
const out = new Set()
|
|
1329
|
-
const host = String((machine && machine.host) || '')
|
|
1330
|
-
const username = String((machine && machine.username) || '')
|
|
1331
|
-
const port = Number((machine && machine.port) || 22)
|
|
1332
|
-
if (!host) return []
|
|
1333
|
-
// 1) Mirror dirs for THIS machine (the sidebar's remote entries).
|
|
1334
|
-
try {
|
|
1335
|
-
const hostDir = mirrorRootFor(host, username, port)
|
|
1336
|
-
if (existsSync(hostDir)) {
|
|
1337
|
-
for (const entry of readdirSync(hostDir)) {
|
|
1338
|
-
const metaPath = path.join(hostDir, entry, '.dsh-remote-meta.json')
|
|
1339
|
-
if (!existsSync(metaPath)) continue
|
|
1340
|
-
try {
|
|
1341
|
-
const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
|
|
1342
|
-
if (!meta || !meta.remotePath) continue
|
|
1343
|
-
// Only mirrors that unambiguously belong to this machine.
|
|
1344
|
-
if (String(meta.host || '') !== host) continue
|
|
1345
|
-
if (Number(meta.port || 22) !== port) continue
|
|
1346
|
-
if (username && String(meta.username || '') !== username) continue
|
|
1347
|
-
out.add(normalizeRemotePath(meta.remotePath))
|
|
1348
|
-
} catch { /* unreadable meta → not a usable workspace */ }
|
|
1349
|
-
}
|
|
1350
|
-
}
|
|
1351
|
-
} catch { /* registry unreadable */ }
|
|
1352
|
-
// 2) Workspaces the registry remembers for this machine, even if unmirrored.
|
|
1353
|
-
const rec = machines.find((m) => m.host === host
|
|
1354
|
-
&& Number(m.port || 22) === port
|
|
1355
|
-
&& (!username || m.username === username))
|
|
1356
|
-
const remembered = [rec && rec.workspace, ...((rec && rec.recentWorkspaces) || [])]
|
|
1357
|
-
for (const p of remembered) {
|
|
1358
|
-
const norm = p ? normalizeRemotePath(String(p)) : ''
|
|
1359
|
-
if (norm && norm !== '/') out.add(norm)
|
|
1360
|
-
}
|
|
1361
|
-
// 3) The mirror the calling session is bound to, if any (a session can be
|
|
1362
|
-
// bound to a workspace the registry has not recorded yet).
|
|
1363
|
-
const bound = wsPath()
|
|
1364
|
-
if (bound) out.add(normalizeRemotePath(bound))
|
|
1365
|
-
return [...out]
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
1286
|
// ── tools ─────────────────────────────────────────────────────────────────
|
|
1369
1287
|
|
|
1370
1288
|
const textOut = {
|
|
@@ -2313,158 +2231,6 @@ export async function apply(ctx, config) {
|
|
|
2313
2231
|
},
|
|
2314
2232
|
}),
|
|
2315
2233
|
|
|
2316
|
-
defineTool({
|
|
2317
|
-
name: 'rw_backup',
|
|
2318
|
-
description:
|
|
2319
|
-
'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.',
|
|
2320
|
-
parameters: {
|
|
2321
|
-
path: { type: 'string', description: 'Directory to back up (default and normally: the current remote workspace). Must be the workspace or a subdirectory of it.' },
|
|
2322
|
-
label: { type: 'string', description: 'Optional tag embedded in the archive filename (e.g. "before-refactor").' },
|
|
2323
|
-
excludes: { type: 'string', description: 'Comma- or newline-separated extra exclude patterns (gitignore-ish: node_modules, /build/, *.log). Relative paths inside the workspace.' },
|
|
2324
|
-
dest: { type: 'string', description: 'Where the archive ends up: remote (default), local (pulled here; the remote staging copy is removed), or both.' },
|
|
2325
|
-
},
|
|
2326
|
-
output: textOut,
|
|
2327
|
-
async execute(args, exec) {
|
|
2328
|
-
const b = requireBinding(exec, 'rw_backup')
|
|
2329
|
-
const dir = resolveRemoteArg(b, args.path || '')
|
|
2330
|
-
if (!dir || dir === '/') throw new Error('rw_backup: a directory to back up is required')
|
|
2331
|
-
const root = normalizeRemotePath(b.ws || '')
|
|
2332
|
-
if (root && dir !== root && !dir.startsWith(root.replace(/\/+$/, '') + '/')) {
|
|
2333
|
-
throw new Error(`rw_backup: ${dir} is outside this session's remote workspace (${root}) — backups are limited to the workspace tree`)
|
|
2334
|
-
}
|
|
2335
|
-
// Config defaults first, then this call's own patterns, so a caller can
|
|
2336
|
-
// extend the profile's excludes without restating them.
|
|
2337
|
-
const parsed = parseExcludes([
|
|
2338
|
-
...(Array.isArray(config.backupExcludes) ? config.backupExcludes : []),
|
|
2339
|
-
...String(args.excludes || '').split(/[\r\n,]+/),
|
|
2340
|
-
])
|
|
2341
|
-
const backupDir = await backupDirForBinding(b)
|
|
2342
|
-
if (!backupDir) throw new Error('rw_backup: cannot determine the remote $HOME — set the backupDir config option')
|
|
2343
|
-
const dest = ['remote', 'local', 'both'].includes(String(args.dest)) ? String(args.dest) : 'remote'
|
|
2344
|
-
const result = await createBackup(b.pool, {
|
|
2345
|
-
dir,
|
|
2346
|
-
backupDir,
|
|
2347
|
-
label: args.label ? String(args.label) : '',
|
|
2348
|
-
excludes: parsed.patterns,
|
|
2349
|
-
meta: { host: b.host, username: b.username, port: b.port, label: args.label ? String(args.label) : '' },
|
|
2350
|
-
})
|
|
2351
|
-
if (!result.ok) {
|
|
2352
|
-
audit('backup', `backup ${dir} failed: ${result.error}`, 1, b)
|
|
2353
|
-
throw new Error('rw_backup: ' + result.error)
|
|
2354
|
-
}
|
|
2355
|
-
audit('backup', `backup ${dir} → ${result.archive}`, 0, b)
|
|
2356
|
-
const lines = [describeCreate(result, { dest })]
|
|
2357
|
-
lines.push(`remote archive: ${result.archive}`)
|
|
2358
|
-
if (result.meta && result.meta.excludes && result.meta.excludes.length) lines.push(`excludes: ${result.meta.excludes.join(', ')}`)
|
|
2359
|
-
if (parsed.dropped.length) lines.push(`ignored (no usable pattern): ${parsed.dropped.join(', ')}`)
|
|
2360
|
-
if (dest === 'local' || dest === 'both') {
|
|
2361
|
-
const pulled = await pullBackup(b.pool, {
|
|
2362
|
-
archive: result.archive,
|
|
2363
|
-
localDir: localBackupDirFor(mirrorRootFor(b.host, b.username, b.port)),
|
|
2364
|
-
maxTransferBytes: Number(config.maxBackupTransferBytes) || 0,
|
|
2365
|
-
})
|
|
2366
|
-
if (pulled.ok) {
|
|
2367
|
-
lines.push(`local copy: ${pulled.localPath}`)
|
|
2368
|
-
if (dest === 'local') {
|
|
2369
|
-
const del = await deleteBackup(b.pool, { archive: result.archive })
|
|
2370
|
-
lines.push(del.ok ? 'remote staging copy removed (dest=local)' : `could NOT remove the remote copy: ${del.error || 'unknown error'}`)
|
|
2371
|
-
}
|
|
2372
|
-
} else {
|
|
2373
|
-
lines.push(`local copy FAILED: ${pulled.error}`)
|
|
2374
|
-
}
|
|
2375
|
-
}
|
|
2376
|
-
return { text: lines.join('\n') }
|
|
2377
|
-
},
|
|
2378
|
-
}),
|
|
2379
|
-
|
|
2380
|
-
defineTool({
|
|
2381
|
-
name: 'rw_backup_list',
|
|
2382
|
-
description:
|
|
2383
|
-
"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.",
|
|
2384
|
-
parameters: {
|
|
2385
|
-
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." },
|
|
2386
|
-
},
|
|
2387
|
-
output: textOut,
|
|
2388
|
-
async execute(args, exec) {
|
|
2389
|
-
const b = requireBinding(exec, 'rw_backup_list')
|
|
2390
|
-
const backupDir = await backupDirForBinding(b)
|
|
2391
|
-
const remote = backupDir
|
|
2392
|
-
? await listBackups(b.pool, { dir: backupDir })
|
|
2393
|
-
: { exists: false, dir: '', entries: [], error: '' }
|
|
2394
|
-
const localDir = localBackupDirFor(mirrorRootFor(b.host, b.username, b.port))
|
|
2395
|
-
const local = listLocalBackups(localDir)
|
|
2396
|
-
const fmt = (e) => {
|
|
2397
|
-
const bits = [e.name, formatBytes(e.bytes)]
|
|
2398
|
-
if (e.members != null) bits.push(`${e.members} entries`)
|
|
2399
|
-
if (e.createdAt || e.mtime) bits.push(e.createdAt || e.mtime)
|
|
2400
|
-
if (!e.hasMeta) bits.push('NO SIDECAR (metadata unknown)')
|
|
2401
|
-
return ' ' + bits.join(' | ')
|
|
2402
|
-
}
|
|
2403
|
-
const lines = []
|
|
2404
|
-
if (!backupDir) {
|
|
2405
|
-
lines.push('(cannot resolve the remote backup directory — set the backupDir config option)')
|
|
2406
|
-
} else if (remote.error) {
|
|
2407
|
-
lines.push(`remote listing failed: ${remote.error}`)
|
|
2408
|
-
} else if (!remote.entries.length) {
|
|
2409
|
-
lines.push(`no backups yet in ${backupDir} (create one with rw_backup)`)
|
|
2410
|
-
} else {
|
|
2411
|
-
lines.push(`${remote.entries.length} remote backup(s) in ${backupDir}:`)
|
|
2412
|
-
lines.push(...remote.entries.map(fmt))
|
|
2413
|
-
}
|
|
2414
|
-
if (local.entries.length) {
|
|
2415
|
-
lines.push('', `${local.entries.length} local copy(ies) in ${localDir}:`)
|
|
2416
|
-
lines.push(...local.entries.map(fmt))
|
|
2417
|
-
}
|
|
2418
|
-
lines.push('', 'restore one with rw_restore(file=<name>).')
|
|
2419
|
-
return { text: lines.join('\n') }
|
|
2420
|
-
},
|
|
2421
|
-
}),
|
|
2422
|
-
|
|
2423
|
-
defineTool({
|
|
2424
|
-
name: 'rw_restore',
|
|
2425
|
-
description:
|
|
2426
|
-
'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.',
|
|
2427
|
-
parameters: {
|
|
2428
|
-
file: { type: 'string', required: true, description: 'Backup archive filename from rw_backup_list (e.g. proj-abc-2026-10-08T11-22-33.tar.gz).' },
|
|
2429
|
-
target: { type: 'string', description: 'Directory to restore into (default: the current remote workspace). Usually left unset.' },
|
|
2430
|
-
mode: { type: 'string', description: 'replace (default) or merge.' },
|
|
2431
|
-
where: { type: 'string', description: 'Which copy to restore from: remote (default) or local (a pulled copy; it is uploaded back first).' },
|
|
2432
|
-
},
|
|
2433
|
-
output: textOut,
|
|
2434
|
-
async execute(args, exec) {
|
|
2435
|
-
const b = requireBinding(exec, 'rw_restore')
|
|
2436
|
-
const file = String(args.file || '').trim()
|
|
2437
|
-
if (!file) throw new Error('rw_restore: file is required (list them with rw_backup_list)')
|
|
2438
|
-
if (!isSafeArchiveName(file)) throw new Error(`rw_restore: ${file} is not a valid backup filename (expected a plain *.tar.gz basename)`)
|
|
2439
|
-
const target = args.target ? resolveRemoteArg(b, args.target) : (b.ws || '')
|
|
2440
|
-
if (!target || target === '/') throw new Error('rw_restore: no restore target — pick a remote workspace first')
|
|
2441
|
-
const root = normalizeRemotePath(b.ws || '')
|
|
2442
|
-
if (root && target !== root && !target.startsWith(root.replace(/\/+$/, '') + '/')) {
|
|
2443
|
-
throw new Error(`rw_restore: refusing to restore into ${target} — it is outside this session's remote workspace (${root})`)
|
|
2444
|
-
}
|
|
2445
|
-
const mode = args.mode === 'merge' ? 'merge' : 'replace'
|
|
2446
|
-
const backupDir = await backupDirForBinding(b)
|
|
2447
|
-
if (!backupDir) throw new Error('rw_restore: cannot determine the remote backup directory — set the backupDir config option')
|
|
2448
|
-
const where = args.where === 'local' ? 'local' : 'remote'
|
|
2449
|
-
let archive = `${backupDir.replace(/\/+$/, '')}/${file}`
|
|
2450
|
-
if (where === 'local') {
|
|
2451
|
-
const localDir = localBackupDirFor(mirrorRootFor(b.host, b.username, b.port))
|
|
2452
|
-
const up = await pushBackup(b.pool, {
|
|
2453
|
-
localPath: path.join(localDir, file),
|
|
2454
|
-
remoteDir: backupDir,
|
|
2455
|
-
maxTransferBytes: Number(config.maxBackupTransferBytes) || 0,
|
|
2456
|
-
})
|
|
2457
|
-
if (!up.ok) throw new Error('rw_restore: could not upload the local copy — ' + up.error)
|
|
2458
|
-
archive = up.remotePath
|
|
2459
|
-
}
|
|
2460
|
-
const r = await restoreBackup(b.pool, { archive, target, mode })
|
|
2461
|
-
audit('restore', `${mode} restore ${file} → ${target}`, r.ok ? 0 : 1, b)
|
|
2462
|
-
if (!r.ok) throw new Error('rw_restore: ' + (r.error || 'restore failed'))
|
|
2463
|
-
return {
|
|
2464
|
-
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]' : ''}.`,
|
|
2465
|
-
}
|
|
2466
|
-
},
|
|
2467
|
-
}),
|
|
2468
2234
|
]
|
|
2469
2235
|
|
|
2470
2236
|
for (const t of tools) {
|
|
@@ -2844,41 +2610,6 @@ export async function apply(ctx, config) {
|
|
|
2844
2610
|
sendJson, readBody, resolveRequestBinding, decodeBuf, encodeText, audit, config, mirrorDirFor,
|
|
2845
2611
|
}),
|
|
2846
2612
|
|
|
2847
|
-
...createBackupRoutes({
|
|
2848
|
-
sendJson,
|
|
2849
|
-
readBody,
|
|
2850
|
-
config,
|
|
2851
|
-
audit,
|
|
2852
|
-
resolveRequestBinding,
|
|
2853
|
-
// The settings panel may act on a machine that is not the current one; the
|
|
2854
|
-
// id is resolved to a real pool so a backup never runs against whichever
|
|
2855
|
-
// machine happened to be active.
|
|
2856
|
-
resolveMachineBinding: async (machineId) => {
|
|
2857
|
-
const m = machines.find((x) => x.id === machineId)
|
|
2858
|
-
if (!m) {
|
|
2859
|
-
const err = new Error(`机器不存在:${machineId}`)
|
|
2860
|
-
err.httpStatus = 404
|
|
2861
|
-
throw err
|
|
2862
|
-
}
|
|
2863
|
-
const target = poolForMachine(identityOf(m))
|
|
2864
|
-
return {
|
|
2865
|
-
pool: target,
|
|
2866
|
-
ws: String(m.workspace || ''),
|
|
2867
|
-
host: m.host,
|
|
2868
|
-
username: m.username,
|
|
2869
|
-
port: Number(m.port) || 22,
|
|
2870
|
-
alias: aliasOf(m),
|
|
2871
|
-
mirrorDir: m.workspace ? mirrorDirFor(m.workspace, m.host, m.username, m.port) : null,
|
|
2872
|
-
bound: false,
|
|
2873
|
-
local: false,
|
|
2874
|
-
}
|
|
2875
|
-
},
|
|
2876
|
-
localRootFor: localBackupRootFor,
|
|
2877
|
-
// "Back up all my workspaces" means what the SIDEBAR shows, so the route
|
|
2878
|
-
// asks the same collection the panel displays.
|
|
2879
|
-
workspacesFor: (b) => remoteWorkspacesFor({ host: b.host, username: b.username, port: b.port }),
|
|
2880
|
-
}),
|
|
2881
|
-
|
|
2882
2613
|
{
|
|
2883
2614
|
kind: 'exact',
|
|
2884
2615
|
path: '/dsh-remote/workspace',
|
|
@@ -3350,10 +3081,15 @@ export async function apply(ctx, config) {
|
|
|
3350
3081
|
const q = new URL(req.url, 'http://localhost').searchParams
|
|
3351
3082
|
const id = q.get('machineId') || ''
|
|
3352
3083
|
const attach = id ? webAttaches.get(id) : undefined
|
|
3084
|
+
// Deploy tasks ride along so a REOPENED window sees an in-flight
|
|
3085
|
+
// install immediately, without a separate poll's first tick.
|
|
3086
|
+
const deploy = id ? deployTasks.forMachine(id) : null
|
|
3353
3087
|
return sendJson(res, 200, {
|
|
3354
3088
|
ok: true,
|
|
3355
3089
|
attach: attach ? attach.describe() : null,
|
|
3356
3090
|
attaches: [...webAttaches.values()].map((a) => a.describe()),
|
|
3091
|
+
deploys: deployTasks.list(),
|
|
3092
|
+
deploy: deploy ? deployTasks.describe(deploy) : null,
|
|
3357
3093
|
})
|
|
3358
3094
|
}
|
|
3359
3095
|
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
@@ -3398,75 +3134,113 @@ export async function apply(ctx, config) {
|
|
|
3398
3134
|
})
|
|
3399
3135
|
}
|
|
3400
3136
|
|
|
3401
|
-
// install
|
|
3137
|
+
// install — runs as a HOST-SIDE BACKGROUND TASK.
|
|
3138
|
+
//
|
|
3139
|
+
// It used to be one long HTTP request; npm can legitimately take
|
|
3140
|
+
// minutes, and closing the tab mid-deploy lost the response AND
|
|
3141
|
+
// every trace of the progress — the user could not tell whether it
|
|
3142
|
+
// succeeded, and a second click started a CONCURRENT npm install
|
|
3143
|
+
// into the same prefix. Now the work runs detached from the request
|
|
3144
|
+
// lifecycle, every step's outcome is recorded as it happens, and
|
|
3145
|
+
// the UI re-attaches by polling install-status (or by clicking the
|
|
3146
|
+
// button again, which resumes rather than restarts).
|
|
3402
3147
|
if (body.confirm !== true) {
|
|
3403
3148
|
return sendJson(res, 400, { ok: false, error: '安装需要在请求里显式 confirm: true' })
|
|
3404
3149
|
}
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
return sendJson(res, 400, { ok: false, error: `无法自动安装:${why || '远端缺少前提条件'}`, verdict })
|
|
3150
|
+
// RESUME: a running/queued task for this machine is returned as-is.
|
|
3151
|
+
// One deploy per machine at a time — two concurrent `npm --prefix`
|
|
3152
|
+
// into the same directory corrupt the tree.
|
|
3153
|
+
const already = deployTasks.forMachine(machineId)
|
|
3154
|
+
if (already && (already.status === 'queued' || already.status === 'running') && body.force !== true) {
|
|
3155
|
+
return sendJson(res, 200, { ok: true, resumed: true, task: deployTasks.describe(already) })
|
|
3412
3156
|
}
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
// npm config is left alone, which is what an internal mirror needs.
|
|
3416
|
-
// `--registry` is only added when configured — overriding a working
|
|
3417
|
-
// corporate mirror with the public one would be a regression.
|
|
3418
|
-
const registry = String(body.registry || config.webInstallRegistry || '').trim()
|
|
3419
|
-
// Proxy comes from the remote's own environment; the operator may
|
|
3420
|
-
// override it per-install when the inherited one is wrong.
|
|
3421
|
-
const env = body.proxy && typeof body.proxy === 'object'
|
|
3422
|
-
? Object.fromEntries(Object.entries(body.proxy).filter(([, v]) => typeof v === 'string'))
|
|
3423
|
-
: undefined
|
|
3424
|
-
const plan = buildInstallPlan({
|
|
3425
|
-
facts,
|
|
3426
|
-
prefix: installPrefix,
|
|
3427
|
-
version: String(body.version || config.webInstallVersion || DEFAULT_INSTALL_VERSION),
|
|
3428
|
-
registry,
|
|
3429
|
-
env,
|
|
3430
|
-
})
|
|
3431
|
-
const results = []
|
|
3432
|
-
for (const step of plan.steps) {
|
|
3433
|
-
let res2
|
|
3434
|
-
try {
|
|
3435
|
-
res2 = await target.exec(step.command, {
|
|
3436
|
-
timeoutMs: step.timeoutMs || 120000,
|
|
3437
|
-
// Per-step env exists for the proxy override (P4); pool.exec
|
|
3438
|
-
// passes it through to the remote process.
|
|
3439
|
-
...(step.env ? { env: step.env } : {}),
|
|
3440
|
-
})
|
|
3441
|
-
} catch (err) {
|
|
3442
|
-
results.push({ id: step.id, title: step.title, ok: false, code: -1, output: String((err && err.message) || err) })
|
|
3443
|
-
break
|
|
3444
|
-
}
|
|
3445
|
-
const r = stepResult(step, res2)
|
|
3446
|
-
results.push(r)
|
|
3447
|
-
if (!r.ok) break
|
|
3448
|
-
}
|
|
3449
|
-
const ok = results.length === plan.steps.length && results.every((r) => r.ok)
|
|
3450
|
-
if (ok) {
|
|
3451
|
-
// Persist so the attach flow uses the working build from now on.
|
|
3452
|
-
persistWebAttachCommand(machine, plan.command)
|
|
3453
|
-
audit('web-deploy', `install ${machine.host} → ${plan.command}`, 0, machine)
|
|
3454
|
-
return sendJson(res, 200, { ok: true, steps: results, command: plan.command, prefix: installPrefix })
|
|
3157
|
+
if (already && body.force !== true && already.status !== 'done' && already.status !== 'failed' && already.status !== 'cancelled') {
|
|
3158
|
+
return sendJson(res, 200, { ok: true, resumed: true, task: deployTasks.describe(already) })
|
|
3455
3159
|
}
|
|
3456
|
-
const
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3160
|
+
const { task } = deployTasks.start(machineId, `deploy ${machine.host}`, async (ctx) => {
|
|
3161
|
+
// Everything below is the SAME plan as before, but progress goes
|
|
3162
|
+
// to the task (visible while it runs) instead of one response.
|
|
3163
|
+
const pout = await target.exec(buildProbeCommand({ prefix, installedCommand, defaultPrefixFor: config.webInstallPrefix ? '' : 'home' }), { timeoutMs: 30000 })
|
|
3164
|
+
const facts = parseProbe(pout && pout.stdout)
|
|
3165
|
+
const verdict = judgeProbe(facts, { installedCommand })
|
|
3166
|
+
if (!verdict.canAutoInstall) {
|
|
3167
|
+
const why = verdict.findings.filter((x) => x.severity === 'blocker').map((x) => x.summary).join(';')
|
|
3168
|
+
audit('web-deploy', `install ${machine.host} refused: ${why}`, 1, machine)
|
|
3169
|
+
throw new Error(`无法自动安装:${why || '远端缺少前提条件'}`)
|
|
3170
|
+
}
|
|
3171
|
+
const installPrefix = facts.resolvedPrefix || resolvePrefix(facts, { prefix: config.webInstallPrefix || '' })
|
|
3172
|
+
// A registry the user set explicitly wins; otherwise the remote's
|
|
3173
|
+
// own npm config is left alone (internal mirrors keep working).
|
|
3174
|
+
const registry = String(body.registry || config.webInstallRegistry || '').trim()
|
|
3175
|
+
const env = body.proxy && typeof body.proxy === 'object'
|
|
3176
|
+
? Object.fromEntries(Object.entries(body.proxy).filter(([, v]) => typeof v === 'string'))
|
|
3177
|
+
: undefined
|
|
3178
|
+
const plan = buildInstallPlan({
|
|
3179
|
+
facts,
|
|
3180
|
+
prefix: installPrefix,
|
|
3181
|
+
version: String(body.version || config.webInstallVersion || DEFAULT_INSTALL_VERSION),
|
|
3182
|
+
registry,
|
|
3183
|
+
env,
|
|
3184
|
+
})
|
|
3185
|
+
ctx.progress({ totalSteps: plan.steps.length, prefix: installPrefix })
|
|
3186
|
+
const results = []
|
|
3187
|
+
for (const step of plan.steps) {
|
|
3188
|
+
if (ctx.cancelled) break
|
|
3189
|
+
task.current = step.title
|
|
3190
|
+
ctx.progress({ doneSteps: results.length })
|
|
3191
|
+
let res2
|
|
3192
|
+
try {
|
|
3193
|
+
res2 = await target.exec(step.command, {
|
|
3194
|
+
timeoutMs: step.timeoutMs || 120000,
|
|
3195
|
+
...(step.env ? { env: step.env } : {}),
|
|
3196
|
+
})
|
|
3197
|
+
} catch (err) {
|
|
3198
|
+
results.push({ id: step.id, title: step.title, ok: false, code: -1, output: String((err && err.message) || err) })
|
|
3199
|
+
break
|
|
3200
|
+
}
|
|
3201
|
+
const r = stepResult(step, res2)
|
|
3202
|
+
results.push(r)
|
|
3203
|
+
task.steps = [...results]
|
|
3204
|
+
if (!r.ok) break
|
|
3205
|
+
}
|
|
3206
|
+
task.steps = [...results]
|
|
3207
|
+
const ok = results.length === plan.steps.length && results.every((r) => r.ok)
|
|
3208
|
+
if (ok) {
|
|
3209
|
+
// Persist so the attach flow uses the working build from now on.
|
|
3210
|
+
persistWebAttachCommand(machine, plan.command)
|
|
3211
|
+
audit('web-deploy', `install ${machine.host} → ${plan.command}`, 0, machine)
|
|
3212
|
+
return { ok: true, steps: results, command: plan.command, prefix: installPrefix }
|
|
3213
|
+
}
|
|
3214
|
+
const failed = results.find((r) => !r.ok)
|
|
3215
|
+
audit('web-deploy', `install ${machine.host} failed at ${failed && failed.id}`, 1, machine)
|
|
3216
|
+
const looksNetworky = /ETIMEDOUT|ENOTFOUND|ECONNREFUSED|network|proxy|403|404/i.test(String(failed && failed.output))
|
|
3217
|
+
const err = new Error(`安装失败于「${failed ? failed.title : '未知步骤'}」`)
|
|
3218
|
+
err.detail = {
|
|
3219
|
+
steps: results,
|
|
3220
|
+
prefix: installPrefix,
|
|
3221
|
+
suggestInvestigate: true,
|
|
3222
|
+
...(looksNetworky ? { hint: '看起来是网络或源的问题,可以让 AI 排查。' } : {}),
|
|
3223
|
+
}
|
|
3224
|
+
throw err
|
|
3469
3225
|
})
|
|
3226
|
+
// Answer immediately: the task object IS the handle the UI polls.
|
|
3227
|
+
return sendJson(res, 200, { ok: true, started: true, task: deployTasks.describe(task) })
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
// install-status: re-attach to a deploy at ANY time (window reopened,
|
|
3231
|
+
// page refreshed). Also carried by GET /dsh-remote/web-attach.
|
|
3232
|
+
if (action === 'install-status') {
|
|
3233
|
+
const t = deployTasks.forMachine(machineId)
|
|
3234
|
+
if (!t) return sendJson(res, 200, { ok: true, task: null })
|
|
3235
|
+
return sendJson(res, 200, { ok: true, task: deployTasks.describe(t) })
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
// install-cancel: cooperative cancel between steps.
|
|
3239
|
+
if (action === 'install-cancel') {
|
|
3240
|
+
const t = deployTasks.forMachine(machineId)
|
|
3241
|
+
if (!t) return sendJson(res, 200, { ok: true, cancelled: false })
|
|
3242
|
+
const did = deployTasks.cancel(machineId)
|
|
3243
|
+
return sendJson(res, 200, { ok: true, cancelled: did, task: deployTasks.describe(t) })
|
|
3470
3244
|
}
|
|
3471
3245
|
|
|
3472
3246
|
if (action === 'open') {
|
package/lib/paths.js
CHANGED
|
@@ -203,8 +203,16 @@ export function relPathUnder(root, p) {
|
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
export function truncate(s, max) {
|
|
206
|
-
|
|
207
|
-
|
|
206
|
+
// A missing/NaN cap is a CONFIG BUG (measured: a bare `new SshPool({...})`
|
|
207
|
+
// with no maxOutputChars turned EVERY exec result into a 29-char truncation
|
|
208
|
+
// marker — `'x'.length <= undefined` is false, so the "cap" branch always ran
|
|
209
|
+
// and `s.length - undefined` printed NaN — silently destroying every remote
|
|
210
|
+
// output, including the web-attach startup token). A bad cap must disable
|
|
211
|
+
// truncation, not destroy the string.
|
|
212
|
+
const limit = Number(max)
|
|
213
|
+
if (!Number.isFinite(limit) || limit <= 0) return s
|
|
214
|
+
if (s.length <= limit) return s
|
|
215
|
+
return s.slice(0, limit) + `\n…[truncated: ${s.length - limit} more chars]`
|
|
208
216
|
}
|
|
209
217
|
|
|
210
218
|
/** 32-bit string hash rendered in base36 (short, collision-safe enough). */
|
package/lib/web-attach.js
CHANGED
|
@@ -301,12 +301,24 @@ export class WebAttach {
|
|
|
301
301
|
// Both failure shapes carry the same diagnostics: the process either died
|
|
302
302
|
// (its output is the whole story) or is still booting after the deadline.
|
|
303
303
|
if (launchFailed(out)) {
|
|
304
|
+
// The remote process is already gone — nothing to clean up.
|
|
304
305
|
throw new Error('web-attach: the remote DSH exited during startup.'
|
|
305
306
|
+ explainLaunchFailure(tail || out)
|
|
306
307
|
+ ` ${tail || 'no output'}`)
|
|
307
308
|
}
|
|
308
309
|
const parsed = parseWebStart(out)
|
|
309
310
|
if (!parsed) {
|
|
311
|
+
// A timed-out launch may STILL be booting: the deadline expiring is not
|
|
312
|
+
// evidence the process died. Kill it before throwing, or every retry
|
|
313
|
+
// leaks another remote server (measured: three leftover `dsh --port 0`
|
|
314
|
+
// processes after three failed attaches). A PID of 0 means the launcher
|
|
315
|
+
// could not report one — nothing to target.
|
|
316
|
+
if (this.remotePid > 0) {
|
|
317
|
+
try {
|
|
318
|
+
await exec(buildStopCommand(this.remotePid, logPath), { timeoutMs: 20000 })
|
|
319
|
+
this.remotePid = 0
|
|
320
|
+
} catch { /* the process may already be gone; a leftover is worse than a failed kill attempt only if it repeats, and the next attach replaces this session */ }
|
|
321
|
+
}
|
|
310
322
|
throw new Error(
|
|
311
323
|
`web-attach: the remote DSH did not report a startup token within ${waitSeconds}s.`
|
|
312
324
|
+ ` Check that \`${command}\` exists on the remote and can boot a web profile.`
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.8.
|
|
4
|
-
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with
|
|
3
|
+
"version": "0.8.42",
|
|
4
|
+
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 22 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, one-click remote dsh deploy + web UI attach, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "flymysql <flyphp@outlook.com>",
|
|
7
7
|
"readme": "README.md",
|