dsh-remote 0.8.49 → 0.8.50

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.md CHANGED
@@ -53,6 +53,7 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
53
53
  - **Windows 主机** —— 自动探测平台并定位 Git Bash,命令经 `bash -s` 走 stdin 执行,不受引号/反斜杠转义困扰(`config.shell` 可指定或设 `native` 关闭);`C:\Users\dev` 与 `/c/Users/dev` 两种写法都接受。
54
54
  - **长任务异步化** —— `rw_sync`/`rw_push` 传 `async: true` 返回 `taskId`,可查询进度/结果/取消。
55
55
  - **数据跟随 Harness 根目录** —— 机器清单与镜像在 `$DSH_HOME/remote-workspaces`;0.6 之前的数据首次启动自动迁移。
56
+ - **`~/.ssh/config` 是连接四字段的唯一来源**(`0.8.50+`,issue #50)—— 保存机器时把 主机/用户/端口/私钥 写入 `~/.ssh/config` 的受管块(`# dsh-remote: managed` 标记,只动自己的块;你手写的同名 Host 块绝不覆盖,会提示冲突)。之后**改这个文件立即生效**:任何 Host 块点名的主机(含字面 IP),连接都按文件解析(裸 `Host *` 是通用默认,不算点名);机器记录里的副本只做兜底,插件自己的状态(最近工作区/延迟等)仍存注册表。
56
57
  - **不改动 `dsh-workspace` 官方代码** —— 全部作为普通插件实现。
57
58
 
58
59
  ## 安装
package/lib/client.js CHANGED
@@ -73,6 +73,7 @@ window.__ModuleLoader__.load({
73
73
  'settings.sshAliasHint': '别名模式:机器只记住「{alias}」这个名字,主机/用户/端口/私钥/跳板每次连接都从 ~/.ssh/config 实时解析(改配置立刻生效)。',
74
74
  'settings.useSshConfig': '使用 ~/.ssh/config 别名(只存别名,不存副本)',
75
75
  'settings.useSshConfigHint': '留空或保持默认的字段(用户 root、端口 22、私钥、跳板)将从 ~/.ssh/config 实时解析;填了非默认值则覆盖。',
76
+ 'settings.saveWritesSshConfig': '保存时会把 主机/用户/端口/私钥 写入 ~/.ssh/config 的受管块(# dsh-remote: managed 标记);此后该文件优先——直接改它立即生效。',
76
77
  'settings.sshResolvedTo': '→ {user}@{host}:{port}',
77
78
  'settings.sshResolvedNone': '~/.ssh/config 中没有匹配「{alias}」的 Host 条目',
78
79
  'settings.sshResolvedWarn': '注意:{msg}',
@@ -360,6 +361,7 @@ window.__ModuleLoader__.load({
360
361
  'settings.sshAliasHint': 'Alias mode: the machine remembers only the name "{alias}"; host/user/port/key/jump host are resolved from ~/.ssh/config at every connect, so editing that file takes effect immediately.',
361
362
  'settings.useSshConfig': 'Use a ~/.ssh/config alias (keep the alias, not a copy)',
362
363
  'settings.useSshConfigHint': 'Fields left empty or at their default (user root, port 22, key, jump host) come from ~/.ssh/config at connect time; a non-default value overrides it.',
364
+ 'settings.saveWritesSshConfig': 'Saving writes host/user/port/key into a managed block in ~/.ssh/config (marked # dsh-remote: managed); the file then takes precedence — edit it directly and it takes effect at once.',
363
365
  'settings.sshResolvedTo': '→ {user}@{host}:{port}',
364
366
  'settings.sshResolvedNone': 'no ~/.ssh/config block matches "{alias}"',
365
367
  'settings.sshResolvedWarn': 'Heads-up: {msg}',
@@ -1269,9 +1271,12 @@ window.__ModuleLoader__.load({
1269
1271
  React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' } },
1270
1272
  React.createElement('span', { style: { fontWeight: 600 } }, m.name),
1271
1273
  React.createElement('code', { style: { fontSize: 12, opacity: 0.8 } },
1272
- m.useSshConfig ? (m.host + (m.sshConfigResolved && m.sshConfigResolved.matched ? ' ' + tr('settings.sshResolvedTo', { user: m.sshConfigResolved.username || '?', host: m.sshConfigResolved.host, port: m.sshConfigResolved.port }) : ''))
1273
- : (m.username + '@' + m.host + ':' + m.port)),
1274
- m.useSshConfig ? pill('', tr('settings.badgeSshConfig')) : null,
1274
+ (m.sshConfigResolved && m.sshConfigResolved.matched)
1275
+ // Issue #50: shown for literal machines too — a Host
1276
+ // block naming this machine's host now drives it.
1277
+ ? (m.host + ' ' + tr('settings.sshResolvedTo', { user: m.sshConfigResolved.username || m.username || '?', host: m.sshConfigResolved.host, port: m.sshConfigResolved.port || m.port }))
1278
+ : (m.useSshConfig ? m.host : (m.username + '@' + m.host + ':' + m.port))),
1279
+ (m.useSshConfig || (m.sshConfigResolved && m.sshConfigResolved.matched)) ? pill('', tr('settings.badgeSshConfig')) : null,
1275
1280
  // Issue #51: a machine on the system-OpenSSH backend behaves
1276
1281
  // differently enough (auth from ~/.ssh/config, GSSAPI) that
1277
1282
  // the row must say so.
@@ -1314,6 +1319,9 @@ window.__ModuleLoader__.load({
1314
1319
  row(tr('settings.host'), React.createElement('input', { className: 'dsh-rw-input', value: form.host, onChange: setF('host'), placeholder: form.useSshConfig ? tr('settings.sshImport') + ' Host 别名' : 'IP or hostname', style: inputS }), 'h'),
1315
1320
  row(tr('settings.useSshConfig'), React.createElement('input', { type: 'checkbox', checked: !!form.useSshConfig, onChange: setFb('useSshConfig') }), 'sshc'),
1316
1321
  form.useSshConfig ? React.createElement('div', { style: { fontSize: 11, opacity: 0.8, marginBottom: 8, lineHeight: 1.5 } }, tr('settings.useSshConfigHint')) : null,
1322
+ // Issue #50: a literal save also touches ~/.ssh/config (managed
1323
+ // block) — say so where the choice is made.
1324
+ !form.useSshConfig ? React.createElement('div', { style: { fontSize: 11, opacity: 0.8, marginBottom: 8, lineHeight: 1.5 } }, tr('settings.saveWritesSshConfig')) : null,
1317
1325
  form.useSshConfig ? (() => {
1318
1326
  // Preview where the alias points, from the same resolution the
1319
1327
  // connect path uses (the host list carries it — issue #38).
package/lib/index.js CHANGED
@@ -34,7 +34,7 @@ import {
34
34
  import { createHostKeyGuard, isHostKeyKnown as _isHostKeyKnown } from './hostkey.js'
35
35
  import { compileIgnore, ignorePatternsFor, DEFAULT_IGNORE } from './ignore.js'
36
36
  import { friendlyMessage } from './errors.js'
37
- import { importableEntries, sshConfigPath, readSshConfigText, resolveMachineSshConfig } from './sshconfig.js'
37
+ import { importableEntries, sshConfigPath, readSshConfigText, resolveMachineSshConfig, resolveLiteralSshConfig, readRawSshConfig, upsertManagedHostBlock, removeManagedHostBlock, writeSshConfig } from './sshconfig.js'
38
38
  import { createRemoteFileReference, installFileReferenceOverlay, DEFAULT_EXCLUDED_DIRECTORIES } from './file-reference.js'
39
39
  import { getSecret, deleteSecret, platformBackend, persistPassword } from './credential.js'
40
40
  import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from './sync.js'
@@ -431,25 +431,49 @@ export async function apply(ctx, config) {
431
431
  * keychain lookups and the settings UI keep working.
432
432
  */
433
433
  const loggedSshWarnings = new Map()
434
- const withSshConfig = (m) => {
435
- // Short-circuit BEFORE reading ~/.ssh/config: a deployment with no alias
436
- // machine must never touch that file, and the 2s text memo must not be
437
- // primed by unrelated machines.
438
- if (!m || !m.useSshConfig) return m
439
- const resolved = resolveMachineSshConfig(m, { text: sshConfigText() })
440
- if (!resolved) return m
441
- // Warn once per distinct warning set: this runs on every tool call, and an
442
- // unmatched alias would otherwise fill the log with the same line.
443
- const signature = resolved.warnings.join(' | ')
444
- if (signature && loggedSshWarnings.get(resolved.alias) !== signature) {
445
- loggedSshWarnings.set(resolved.alias, signature)
434
+ const warnSshOnce = (alias, warnings) => {
435
+ // Warn once per distinct warning set: withSshConfig runs on every tool
436
+ // call, and an unmatched alias would otherwise fill the log with the same
437
+ // line.
438
+ const signature = warnings.join(' | ')
439
+ if (signature && loggedSshWarnings.get(alias) !== signature) {
440
+ loggedSshWarnings.set(alias, signature)
446
441
  try {
447
442
  const logger = typeof ctx.get === 'function' ? ctx.get('logger') : undefined
448
443
  if (logger && typeof logger.warn === 'function') {
449
- logger.warn(`[dsh-remote] ssh-config alias "${resolved.alias}": ${resolved.warnings.join(' ')}`)
444
+ logger.warn(`[dsh-remote] ssh-config alias "${alias}": ${warnings.join(' ')}`)
450
445
  }
451
446
  } catch { /* logging is best-effort */ }
452
447
  }
448
+ }
449
+ const withSshConfig = (m) => {
450
+ if (!m) return m
451
+ // Issue #50: ~/.ssh/config wins for ANY host it names, not only alias
452
+ // machines. A literal machine's stored host/user/port/key is a COPY that
453
+ // goes stale the moment the file is edited; when a Host block names the
454
+ // machine's host (a bare `Host *` does not name it), the file's values
455
+ // take over and the record fills the gaps.
456
+ if (!m.useSshConfig) {
457
+ const lit = resolveLiteralSshConfig(m, { text: sshConfigText() })
458
+ if (!lit) return m
459
+ warnSshOnce(lit.alias, lit.warnings)
460
+ return {
461
+ ...m,
462
+ sshConfigMatched: true,
463
+ sshConfigWarnings: lit.warnings,
464
+ // The name the user typed, kept for the system-OpenSSH backend: ssh
465
+ // must match its Host blocks against exactly this (connectTargetFor).
466
+ sshConfigName: m.host,
467
+ host: lit.host || m.host,
468
+ port: lit.port || m.port,
469
+ username: lit.username || m.username,
470
+ privateKeyPath: lit.privateKeyPath || m.privateKeyPath,
471
+ proxy: lit.proxy || m.proxy,
472
+ }
473
+ }
474
+ const resolved = resolveMachineSshConfig(m, { text: sshConfigText() })
475
+ if (!resolved) return m
476
+ warnSshOnce(resolved.alias, resolved.warnings)
453
477
  return {
454
478
  ...m,
455
479
  sshAlias: resolved.alias,
@@ -483,7 +507,10 @@ export async function apply(ctx, config) {
483
507
  * @returns {{host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, aliasNative: boolean}}
484
508
  */
485
509
  const connectTargetFor = (eff) => {
486
- const aliasNative = !!(eff && eff.useSshConfig && isSystemOpenSshBackend(eff))
510
+ // Issue #50: a LITERAL machine whose host a Host block names is in the same
511
+ // position as an alias machine on the OpenSSH backend — ssh must match its
512
+ // blocks against the ORIGINAL name, so it is handed over verbatim too.
513
+ const aliasNative = !!(eff && isSystemOpenSshBackend(eff) && (eff.useSshConfig || eff.sshConfigMatched))
487
514
  if (!aliasNative) {
488
515
  return {
489
516
  host: String((eff && eff.host) || ''),
@@ -494,7 +521,7 @@ export async function apply(ctx, config) {
494
521
  aliasNative: false,
495
522
  }
496
523
  }
497
- const alias = String((eff && (eff.sshAlias || eff.host)) || '')
524
+ const alias = String((eff && (eff.sshAlias || eff.sshConfigName || eff.host)) || '')
498
525
  return {
499
526
  // The alias itself; ssh expands HostName/User/Port/IdentityFile and every
500
527
  // other directive while matching that Host block.
@@ -511,13 +538,14 @@ export async function apply(ctx, config) {
511
538
  const aliasOf = (m) => (m && m.sshAlias) || (m && m.useSshConfig ? String(m.host || '') : '')
512
539
 
513
540
  /** Machines as the settings UI sees them: secrets stripped, plus the
514
- * ~/.ssh/config resolution of an alias machine so a row can show where the
515
- * alias actually points without duplicating those values in the registry
516
- * (issue #38). */
541
+ * ~/.ssh/config resolution of an alias machine (issue #38) — and, since
542
+ * issue #50, of a LITERAL machine whose host a Host block names, so the row
543
+ * can show that the file now drives it. */
517
544
  const machinesForClient = () => machines.map((m) => ({
518
545
  ...sanitizeMachine(m),
519
- // Read ~/.ssh/config only when a machine actually uses an alias.
520
- sshConfigResolved: m.useSshConfig ? resolveMachineSshConfig(m, { text: sshConfigText() }) : null,
546
+ sshConfigResolved: m.useSshConfig
547
+ ? resolveMachineSshConfig(m, { text: sshConfigText() })
548
+ : resolveLiteralSshConfig(m, { text: sshConfigText() }),
521
549
  }))
522
550
 
523
551
  /**
@@ -3090,12 +3118,49 @@ export async function apply(ctx, config) {
3090
3118
  // alone, so a fresh registry stays "no choice yet" (not an
3091
3119
  // explicit none) and a previously-cleared registry stays cleared.
3092
3120
  saveMachines(machines, store.currentId, false)
3121
+ // Issue #50: host/user/port/key have ONE home — ~/.ssh/config.
3122
+ // Saving a literal machine upserts a marker-tagged Host block,
3123
+ // so the file and the registry can never drift; the record's
3124
+ // copy remains only as a fallback for a missing/renamed block.
3125
+ // A Host block the USER wrote for the same name is never
3126
+ // touched — the conflict is reported instead. Failures here are
3127
+ // best-effort: the registry copy is already saved.
3128
+ let sshConfigWarning = ''
3129
+ if (!rec.useSshConfig) {
3130
+ try {
3131
+ const raw = readRawSshConfig(sshConfigPath())
3132
+ let next = upsertManagedHostBlock(raw, {
3133
+ alias: rec.host,
3134
+ hostName: rec.host,
3135
+ user: rec.username,
3136
+ port: rec.port,
3137
+ identityFile: rec.privateKeyPath,
3138
+ })
3139
+ // A RENAMED machine leaves the old managed block behind, and
3140
+ // that block is the plugin's own artifact of the edit —
3141
+ // retire it. (Deleting a machine removes nothing: the Host
3142
+ // block is the user's ssh access, not plugin state.)
3143
+ if (prev && prev.host && prev.host !== rec.host && !prev.useSshConfig) {
3144
+ const rm = removeManagedHostBlock(next.text, prev.host)
3145
+ next = { status: rm.status === 'removed' && next.status === 'unchanged' ? 'updated' : next.status, text: rm.text }
3146
+ }
3147
+ if (next.status === 'conflict') {
3148
+ sshConfigWarning = `~/.ssh/config already names "${rec.host}" in a Host block that is not managed by dsh-remote — it was left untouched, and it now wins over the saved copy.`
3149
+ } else if (next.status === 'appended' || next.status === 'updated') {
3150
+ writeSshConfig(next.text, sshConfigPath())
3151
+ sshConfigCache = { text: '', at: 0 }
3152
+ }
3153
+ } catch (sshErr) {
3154
+ sshConfigWarning = `could not update ~/.ssh/config (${String((sshErr && sshErr.message) || sshErr)}); the registry copy was saved.`
3155
+ }
3156
+ }
3093
3157
  return sendJson(res, 200, {
3094
3158
  ok: true,
3095
3159
  machine: sanitizeMachine(rec),
3096
3160
  machines: machinesForClient(),
3097
3161
  currentId: store.currentId,
3098
3162
  ...(warning ? { warning, warningDetail } : {}),
3163
+ ...(sshConfigWarning ? { sshConfigWarning } : {}),
3099
3164
  })
3100
3165
  }
3101
3166
  if (action === 'delete') {
@@ -3154,6 +3219,12 @@ export async function apply(ctx, config) {
3154
3219
  useSshConfig: true,
3155
3220
  }, { text: sshConfigText() })
3156
3221
  : null
3222
+ // Issue #50: a LITERAL host named by a Host block resolves through
3223
+ // the file as well — the probe must dial what a real connect would.
3224
+ const literalTarget = !body.useSshConfig
3225
+ ? resolveLiteralSshConfig({ host: String(body.host || '') }, { text: sshConfigText() })
3226
+ : null
3227
+ const probeTarget = aliasTarget || literalTarget
3157
3228
  // Issue #51: on the system-OpenSSH backend an alias is handed to `ssh`
3158
3229
  // VERBATIM (see connectTargetFor) — resolving it here would stop the
3159
3230
  // `Host` block from matching, so the ~/.ssh/config settings the probe
@@ -3161,13 +3232,13 @@ export async function apply(ctx, config) {
3161
3232
  const probe = createPool({
3162
3233
  ...config,
3163
3234
  backend: probeBackend,
3164
- host: aliasTarget && !probeIsOpenssh ? aliasTarget.host : String(body.host || config.host),
3165
- port: aliasTarget && !probeIsOpenssh ? aliasTarget.port : (Number(body.port) || config.port),
3166
- username: aliasTarget && !probeIsOpenssh ? aliasTarget.username : String(body.username || config.username),
3235
+ host: probeTarget && !probeIsOpenssh ? probeTarget.host : String(body.host || config.host),
3236
+ port: (probeTarget && !probeIsOpenssh && probeTarget.port) ? probeTarget.port : (Number(body.port) || config.port),
3237
+ username: (probeTarget && !probeIsOpenssh && probeTarget.username) ? probeTarget.username : String(body.username || config.username),
3167
3238
  password: String(body.password || ''),
3168
- privateKeyPath: (aliasTarget && !probeIsOpenssh) ? aliasTarget.privateKeyPath : String(body.privateKeyPath || config.privateKeyPath),
3239
+ privateKeyPath: (probeTarget && !probeIsOpenssh && probeTarget.privateKeyPath) ? probeTarget.privateKeyPath : String(body.privateKeyPath || config.privateKeyPath),
3169
3240
  passphrase: String(body.passphrase || ''),
3170
- proxy: (aliasTarget && !probeIsOpenssh && aliasTarget.proxy) ? aliasTarget.proxy : (body.proxy && body.proxy.host ? {
3241
+ proxy: (probeTarget && !probeIsOpenssh && probeTarget.proxy) ? probeTarget.proxy : (body.proxy && body.proxy.host ? {
3171
3242
  host: String(body.proxy.host),
3172
3243
  port: Number(body.proxy.port) || 22,
3173
3244
  username: String(body.proxy.username || ''),
package/lib/sshconfig.js CHANGED
@@ -28,7 +28,7 @@
28
28
  // The module is pure (I/O is injectable), so it is unit-testable without a real
29
29
  // home directory — see test/sshconfig.test.js.
30
30
 
31
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
31
+ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs'
32
32
  import { homedir } from 'node:os'
33
33
  import path from 'node:path'
34
34
 
@@ -362,23 +362,14 @@ export function parseProxyJump(value) {
362
362
  }
363
363
 
364
364
  /**
365
- * Connect target for an ssh-config alias: the shape the plugin's pool expects.
365
+ * Shared proxy/warning shaping for alias and literal resolutions (issue #50).
366
366
  *
367
367
  * `ProxyJump` maps onto the plugin's single jump host. A multi-hop chain cannot
368
368
  * be represented by one `proxy`, so the FIRST hop is used and `warnings` says so
369
369
  * — silently connecting through the wrong path would be worse. `ProxyCommand`
370
370
  * (an arbitrary local command) is reported but never executed.
371
- *
372
- * @param {string} alias
373
- * @param {object} [opts]
374
- * @param {string} [opts.text] - config text (else the real file is read).
375
- * @param {string} [opts.file]
376
- * @param {object} [opts.io]
377
- * @returns {{alias: string, matched: boolean, host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, proxyJump: string, warnings: string[]}}
378
371
  */
379
- export function sshAliasTarget(alias, opts = {}) {
380
- const text = opts.text !== undefined ? String(opts.text) : loadSshConfigText(opts.file || sshConfigPath(), opts.io || {})
381
- const resolved = resolveSshHost(alias, text)
372
+ function targetFromResolved(resolved, text, { defaultPort }) {
382
373
  const warnings = []
383
374
  let proxy
384
375
  const hops = parseProxyJump(resolved.proxyJump)
@@ -400,10 +391,10 @@ export function sshAliasTarget(alias, opts = {}) {
400
391
  warnings.push('ProxyCommand is not supported by dsh-remote (no jump host was applied).')
401
392
  }
402
393
  return {
403
- alias,
394
+ alias: resolved.alias,
404
395
  matched: resolved.matched,
405
- host: resolved.hostName || alias,
406
- port: resolved.port || 22,
396
+ host: resolved.hostName || resolved.alias,
397
+ port: resolved.port || defaultPort,
407
398
  username: resolved.user || '',
408
399
  privateKeyPath: resolved.identityFile ? expandHome(resolved.identityFile) : '',
409
400
  proxy,
@@ -412,6 +403,72 @@ export function sshAliasTarget(alias, opts = {}) {
412
403
  }
413
404
  }
414
405
 
406
+ /**
407
+ * Connect target for an ssh-config alias: the shape the plugin's pool expects.
408
+ *
409
+ * @param {string} alias
410
+ * @param {object} [opts]
411
+ * @param {string} [opts.text] - config text (else the real file is read).
412
+ * @param {string} [opts.file]
413
+ * @param {object} [opts.io]
414
+ * @returns {{alias: string, matched: boolean, host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, proxyJump: string, warnings: string[]}}
415
+ */
416
+ export function sshAliasTarget(alias, opts = {}) {
417
+ const text = opts.text !== undefined ? String(opts.text) : loadSshConfigText(opts.file || sshConfigPath(), opts.io || {})
418
+ return targetFromResolved(resolveSshHost(alias, text), text, { defaultPort: 22 })
419
+ }
420
+
421
+ /**
422
+ * Effective connect identity for a LITERAL machine (issue #50).
423
+ *
424
+ * A machine saved with literal host/user/port/key holds a COPY of what may also
425
+ * be written in ~/.ssh/config, and the copy goes stale the moment the file is
426
+ * edited. The file is the single source for any host it NAMES: when a Host
427
+ * block matches the machine's literal host — with at least one positive pattern
428
+ * that is not the bare `*` — the resolved values win per field and the record
429
+ * fills the gaps. A bare `Host *` block deliberately does NOT name a host:
430
+ * treating universal defaults as an override would hijack every saved machine
431
+ * at once. Group patterns (`*.corp`, `web-?`) DO name hosts, exactly as ssh
432
+ * itself applies them to a literal dial.
433
+ *
434
+ * `port` is 0 (not 22) when the file says nothing, so the caller falls back to
435
+ * the record instead of the default.
436
+ *
437
+ * @param {object} machine - a registry record with a literal `host`.
438
+ * @param {{text?: string, file?: string, io?: object}} [opts]
439
+ * @returns {null | {alias: string, matched: true, host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, proxyJump: string, warnings: string[]}}
440
+ * null when no Host block names the machine's host.
441
+ */
442
+ export function resolveLiteralSshConfig(machine, opts = {}) {
443
+ if (!machine || machine.useSshConfig) return null
444
+ const name = String(machine.host || '').trim()
445
+ if (!name) return null
446
+ const text = opts.text !== undefined ? String(opts.text) : loadSshConfigText(opts.file || sshConfigPath(), opts.io || {})
447
+ const resolved = {
448
+ alias: name, matched: false, hostName: '', user: '', port: 0,
449
+ identityFile: '', proxyJump: '', proxyCommand: '', identitiesOnly: '',
450
+ }
451
+ for (const block of parseHostBlocks(text)) {
452
+ if (!hostBlockMatches(block.patterns, name)) continue
453
+ const positive = block.patterns.filter((p) => !p.startsWith('!'))
454
+ // A block whose only positive pattern is the bare `*` is a defaults block,
455
+ // not a block that names this host.
456
+ if (positive.length && positive.every((p) => p === '*')) continue
457
+ resolved.matched = true
458
+ for (const [key, value] of Object.entries(block.values)) {
459
+ if (key === 'hostname') { if (!resolved.hostName) resolved.hostName = String(value) }
460
+ else if (key === 'user') { if (!resolved.user) resolved.user = String(value) }
461
+ else if (key === 'port') { if (!resolved.port) resolved.port = Number(value) || 0 }
462
+ else if (key === 'identityfile') { if (!resolved.identityFile) resolved.identityFile = String(value) }
463
+ else if (key === 'proxyjump') { if (!resolved.proxyJump) resolved.proxyJump = String(value) }
464
+ else if (key === 'proxycommand') { if (!resolved.proxyCommand) resolved.proxyCommand = String(value) }
465
+ else if (key === 'identitiesonly') { if (!resolved.identitiesOnly) resolved.identitiesOnly = String(value) }
466
+ }
467
+ }
468
+ if (!resolved.matched) return null
469
+ return targetFromResolved(resolved, text, { defaultPort: 0 })
470
+ }
471
+
415
472
  /**
416
473
  * Effective connect identity for a saved machine.
417
474
  *
@@ -452,3 +509,187 @@ export function resolveMachineSshConfig(machine, opts = {}) {
452
509
  function compareText(left, right) {
453
510
  return left < right ? -1 : left > right ? 1 : 0
454
511
  }
512
+
513
+ // ── Writing: the managed Host block (issue #50) ─────────────────────────────
514
+ //
515
+ // The four connection fields (host/user/port/key) have ONE home: ~/.ssh/config.
516
+ // When the plugin saves a literal machine it upserts a Host block tagged with
517
+ // MANAGED_MARK, so the file and the registry can never drift — and so the
518
+ // plugin can tell ITS OWN block apart from one the user wrote. Two rules keep
519
+ // the file safe:
520
+ // • a Host block without the marker is never touched — a same-named user
521
+ // block is a CONFLICT, reported to the caller instead of overwritten;
522
+ // • inside a managed block, lines the plugin does not own (a user-added
523
+ // `ForwardAgent yes`, a comment) survive an update — only the four known
524
+ // parameters are rewritten.
525
+
526
+ /** Marker comment that tags a Host block as owned by dsh-remote. */
527
+ export const MANAGED_MARK = '# dsh-remote: managed'
528
+
529
+ /** Parameters the managed block rewrites (lowercase). */
530
+ const MANAGED_PARAMS = new Set(['hostname', 'user', 'port', 'identityfile'])
531
+
532
+ /** Read the RAW config text — no Include expansion (a write targets ONE file). */
533
+ export function readRawSshConfig(filePath = sshConfigPath()) {
534
+ try {
535
+ const text = readFileSync(filePath, 'utf8')
536
+ return typeof text === 'string' ? text : ''
537
+ } catch {
538
+ return ''
539
+ }
540
+ }
541
+
542
+ /** Quote one value for a config line when it contains whitespace. */
543
+ function quoteCfg(value) {
544
+ const s = String(value)
545
+ return /[\s#]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s
546
+ }
547
+
548
+ /** The values a managed block must carry for these fields ('' = omit). */
549
+ function desiredValues(fields) {
550
+ const alias = String(fields.alias || '').trim()
551
+ const hostName = String(fields.hostName || '').trim()
552
+ const port = Number(fields.port) || 0
553
+ return {
554
+ hostname: hostName && hostName !== alias ? hostName : '',
555
+ user: String(fields.user || '').trim(),
556
+ port: port && port !== 22 ? port : '',
557
+ identityfile: String(fields.identityFile || '').trim(),
558
+ }
559
+ }
560
+
561
+ /** Render the owned body lines of a managed block. */
562
+ function desiredLines(fields) {
563
+ const want = desiredValues(fields)
564
+ const out = []
565
+ if (want.hostname) out.push(` HostName ${quoteCfg(want.hostname)}`)
566
+ if (want.user) out.push(` User ${quoteCfg(want.user)}`)
567
+ if (want.port) out.push(` Port ${want.port}`)
568
+ if (want.identityfile) out.push(` IdentityFile ${quoteCfg(want.identityfile)}`)
569
+ return out
570
+ }
571
+
572
+ /**
573
+ * Locate the MANAGED Host block for `alias` in raw config lines.
574
+ * The marker must be the nearest non-blank line above the Host line; a block
575
+ * that names the alias without the marker is the user's, not ours.
576
+ */
577
+ function locateManagedBlock(lines, alias) {
578
+ const wanted = alias.toLowerCase()
579
+ for (let i = 0; i < lines.length; i += 1) {
580
+ const m = lines[i].match(/^\s*host\s+(.+?)\s*$/i)
581
+ if (!m) continue
582
+ const names = splitValue(m[1]).map((p) => p.toLowerCase())
583
+ if (!names.includes(wanted)) continue
584
+ let j = i - 1
585
+ while (j >= 0 && lines[j].trim() === '') j -= 1
586
+ if (j < 0 || lines[j].trim().toLowerCase() !== MANAGED_MARK) continue
587
+ let end = lines.length
588
+ for (let k = i + 1; k < lines.length; k += 1) {
589
+ if (/^\s*(host|match)\s/i.test(lines[k])) { end = k; break }
590
+ }
591
+ return { markerLine: j, hostLine: i, end }
592
+ }
593
+ return null
594
+ }
595
+
596
+ /** Whether an UNMANAGED Host block names `alias` (exact token, case-insensitive). */
597
+ function hasUnmanagedConflict(lines, alias) {
598
+ const wanted = alias.toLowerCase()
599
+ for (let i = 0; i < lines.length; i += 1) {
600
+ const m = lines[i].match(/^\s*host\s+(.+?)\s*$/i)
601
+ if (!m) continue
602
+ const names = splitValue(m[1]).map((p) => p.toLowerCase())
603
+ if (!names.includes(wanted)) continue
604
+ let j = i - 1
605
+ while (j >= 0 && lines[j].trim() === '') j -= 1
606
+ if (j >= 0 && lines[j].trim().toLowerCase() === MANAGED_MARK) continue // ours
607
+ return true
608
+ }
609
+ return false
610
+ }
611
+
612
+ /**
613
+ * Insert or refresh the managed Host block for a saved machine.
614
+ *
615
+ * @param {string} rawText - the RAW ~/.ssh/config text ('' when absent).
616
+ * @param {{alias: string, hostName?: string, user?: string, port?: number|string, identityFile?: string}} fields
617
+ * @returns {{status: 'appended'|'updated'|'unchanged'|'conflict'|'invalid', text: string}}
618
+ * 'conflict' — an unmanaged block already names the alias (text untouched);
619
+ * 'invalid' — the alias can never be a safe Host token (space/wildcard).
620
+ */
621
+ export function upsertManagedHostBlock(rawText, fields) {
622
+ const text = String(rawText || '')
623
+ const alias = String((fields && fields.alias) || '').trim()
624
+ if (!alias || /[\s*?!,]/.test(alias)) return { status: 'invalid', text }
625
+ const lines = text.split('\n')
626
+ if (hasUnmanagedConflict(lines, alias)) return { status: 'conflict', text }
627
+
628
+ const want = desiredValues({ ...fields, alias })
629
+ const found = locateManagedBlock(lines, alias)
630
+ if (!found) {
631
+ const block = [MANAGED_MARK, `Host ${alias}`, ...desiredLines({ ...fields, alias })]
632
+ const trimmedEnd = text.replace(/\s+$/, '')
633
+ const prefix = trimmedEnd === '' ? '' : trimmedEnd + '\n\n'
634
+ return { status: 'appended', text: prefix + block.join('\n') + '\n' }
635
+ }
636
+
637
+ // Compare what the block HAS with what it MUST have; a rewrite that changes
638
+ // nothing would only cost the file's mtime.
639
+ const current = parseHostBlocks(lines.slice(found.hostLine, found.end).join('\n'))[0]
640
+ const cur = (current && current.values) || {}
641
+ const same = ['hostname', 'user', 'port', 'identityfile']
642
+ .every((k) => String(cur[k] ?? '') === String(want[k] ?? ''))
643
+ if (same) return { status: 'unchanged', text }
644
+
645
+ // Rewrite only the four owned parameters; user lines inside OUR block stay.
646
+ const body = lines.slice(found.hostLine + 1, found.end)
647
+ const kept = body.filter((l) => {
648
+ const m = l.match(/^\s*(\S+)\s/)
649
+ return !m || !MANAGED_PARAMS.has(m[1].toLowerCase())
650
+ })
651
+ const next = [
652
+ ...lines.slice(0, found.hostLine + 1),
653
+ ...desiredLines({ ...fields, alias }),
654
+ ...kept,
655
+ ...lines.slice(found.end),
656
+ ]
657
+ return { status: 'updated', text: next.join('\n') }
658
+ }
659
+
660
+ /**
661
+ * Remove the managed block for `alias` (used when a machine is RENAMED away:
662
+ * the old block is the plugin's own artifact of that edit). Deleting a machine
663
+ * deliberately does NOT call this — the Host block is the user's ssh access,
664
+ * not plugin state. Unmanaged blocks are never removed.
665
+ *
666
+ * @returns {{status: 'removed'|'absent', text: string}}
667
+ */
668
+ export function removeManagedHostBlock(rawText, alias) {
669
+ const text = String(rawText || '')
670
+ const name = String(alias || '').trim()
671
+ if (!name) return { status: 'absent', text }
672
+ const lines = text.split('\n')
673
+ const found = locateManagedBlock(lines, name)
674
+ if (!found) return { status: 'absent', text }
675
+ let start = found.markerLine
676
+ while (start > 0 && lines[start - 1].trim() === '') start -= 1
677
+ const next = [...lines.slice(0, start), ...lines.slice(found.end)]
678
+ return { status: 'removed', text: next.join('\n') }
679
+ }
680
+
681
+ /**
682
+ * Persist a config text, creating ~/.ssh first. Permissions follow OpenSSH's
683
+ * expectations on POSIX (dir 0700, file 0600) and are a no-op elsewhere.
684
+ */
685
+ export function writeSshConfig(text, filePath = sshConfigPath(), io = {}) {
686
+ const mkdir = io.mkdir || ((p) => mkdirSync(p, { recursive: true }))
687
+ const write = io.write || ((p, data) => writeFileSync(p, data, 'utf8'))
688
+ const chmod = io.chmod || ((p, mode) => { try { chmodSync(p, mode) } catch { /* no modes here */ } })
689
+ mkdir(path.dirname(filePath))
690
+ write(filePath, String(text))
691
+ if (process.platform !== 'win32') {
692
+ chmod(path.dirname(filePath), 0o700)
693
+ chmod(filePath, 0o600)
694
+ }
695
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.8.49",
3
+ "version": "0.8.50",
4
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>",