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/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
- const pout = await target.exec(buildProbeCommand({ prefix, installedCommand, defaultPrefixFor: config.webInstallPrefix ? '' : 'home' }), { timeoutMs: 30000 })
3406
- const facts = parseProbe(pout && pout.stdout)
3407
- const verdict = judgeProbe(facts, { installedCommand })
3408
- if (!verdict.canAutoInstall) {
3409
- const why = verdict.findings.filter((x) => x.severity === 'blocker').map((x) => x.summary).join(';')
3410
- audit('web-deploy', `install ${machine.host} refused: ${why}`, 1, machine)
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
- const installPrefix = facts.resolvedPrefix || resolvePrefix(facts, { prefix: config.webInstallPrefix || '' })
3414
- // A registry the user set explicitly wins; otherwise the remote's own
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 failed = results.find((r) => !r.ok)
3457
- audit('web-deploy', `install ${machine.host} failed at ${failed && failed.id}`, 1, machine)
3458
- // A failure that looks like a network/registry problem gets the AI
3459
- // escape hatch suggested, since that is precisely the long tail the
3460
- // deterministic path cannot classify.
3461
- const looksNetworky = /ETIMEDOUT|ENOTFOUND|ECONNREFUSED|network|proxy|403|404/i.test(String(failed && failed.output))
3462
- return sendJson(res, 500, {
3463
- ok: false,
3464
- error: `安装失败于「${failed ? failed.title : '未知步骤'}」`,
3465
- steps: results,
3466
- prefix: installPrefix,
3467
- suggestInvestigate: true,
3468
- ...(looksNetworky ? { hint: '看起来是网络或源的问题,可以让 AI 排查。' } : {}),
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
- if (s.length <= max) return s
207
- return s.slice(0, max) + `\n…[truncated: ${s.length - max} more chars]`
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.40",
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 25 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, one-click backup/restore of every sidebar workspace, remote dsh deploy, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
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",