dsh-remote 0.8.48 → 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.
Files changed (132) hide show
  1. package/README.md +1 -0
  2. package/lib/client.js +100 -19
  3. package/lib/index.js +134 -26
  4. package/lib/remote-shell-child.cjs +305 -0
  5. package/lib/remote-shell.js +518 -0
  6. package/lib/sshconfig.js +256 -15
  7. package/package.json +1 -2
  8. package/node_modules/buildcheck/.eslintrc.js +0 -5
  9. package/node_modules/buildcheck/.github/workflows/ci.yml +0 -88
  10. package/node_modules/buildcheck/.github/workflows/lint.yml +0 -27
  11. package/node_modules/buildcheck/.npmignore +0 -3
  12. package/node_modules/buildcheck/LICENSE +0 -19
  13. package/node_modules/buildcheck/README.md +0 -106
  14. package/node_modules/buildcheck/deps/Find-VisualStudio.cs +0 -250
  15. package/node_modules/buildcheck/lib/findvs.js +0 -361
  16. package/node_modules/buildcheck/lib/index.js +0 -738
  17. package/node_modules/buildcheck/package.json +0 -36
  18. package/node_modules/buildcheck/test/test.js +0 -8
  19. package/node_modules/cpu-features/.eslintrc.js +0 -5
  20. package/node_modules/cpu-features/.github/workflows/ci.yml +0 -74
  21. package/node_modules/cpu-features/.github/workflows/lint.yml +0 -27
  22. package/node_modules/cpu-features/LICENSE +0 -19
  23. package/node_modules/cpu-features/README.md +0 -59
  24. package/node_modules/cpu-features/binding.gyp +0 -16
  25. package/node_modules/cpu-features/buildcheck.js +0 -32
  26. package/node_modules/cpu-features/deps/cpu_features/.clang-format +0 -4
  27. package/node_modules/cpu-features/deps/cpu_features/.dockerignore +0 -31
  28. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/Dockerfile +0 -5
  29. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/aarch64_linux_cmake.yml +0 -30
  30. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/amd64_freebsd_cmake.yml +0 -22
  31. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/amd64_linux_bazel.yml +0 -26
  32. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/amd64_linux_cmake.yml +0 -31
  33. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/amd64_macos_cmake.yml +0 -43
  34. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/amd64_windows_cmake.yml +0 -25
  35. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/arm_linux_cmake.yml +0 -31
  36. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/clang_format.yml +0 -24
  37. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/mips_linux_cmake.yml +0 -30
  38. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/power_linux_cmake.yml +0 -29
  39. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/riscv_linux_cmake.yml +0 -28
  40. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/s390x_linux_cmake.yml +0 -27
  41. package/node_modules/cpu-features/deps/cpu_features/.grenrc.yml +0 -21
  42. package/node_modules/cpu-features/deps/cpu_features/BUILD.bazel +0 -329
  43. package/node_modules/cpu-features/deps/cpu_features/CMakeLists.txt +0 -261
  44. package/node_modules/cpu-features/deps/cpu_features/CONTRIBUTING.md +0 -23
  45. package/node_modules/cpu-features/deps/cpu_features/LICENSE +0 -230
  46. package/node_modules/cpu-features/deps/cpu_features/README.md +0 -272
  47. package/node_modules/cpu-features/deps/cpu_features/WORKSPACE +0 -19
  48. package/node_modules/cpu-features/deps/cpu_features/bazel/ci/README.md +0 -5
  49. package/node_modules/cpu-features/deps/cpu_features/bazel/platforms.bzl +0 -11
  50. package/node_modules/cpu-features/deps/cpu_features/cmake/CpuFeaturesConfig.cmake.in +0 -3
  51. package/node_modules/cpu-features/deps/cpu_features/cmake/CpuFeaturesNdkCompatConfig.cmake.in +0 -3
  52. package/node_modules/cpu-features/deps/cpu_features/cmake/README.md +0 -30
  53. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/Makefile +0 -252
  54. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/README.md +0 -40
  55. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/doc/docker.dot +0 -64
  56. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/doc/docker.svg +0 -312
  57. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/doc/generate_image.sh +0 -7
  58. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/docker/amd64/Dockerfile +0 -48
  59. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/docker/toolchain/Dockerfile +0 -34
  60. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/sample/CMakeLists.txt +0 -22
  61. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/sample/main.cpp +0 -11
  62. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/vagrant/freebsd/Vagrantfile +0 -107
  63. package/node_modules/cpu-features/deps/cpu_features/cmake/googletest.CMakeLists.txt.in +0 -15
  64. package/node_modules/cpu-features/deps/cpu_features/cpu_features.gyp +0 -105
  65. package/node_modules/cpu-features/deps/cpu_features/include/cpu_features_cache_info.h +0 -54
  66. package/node_modules/cpu-features/deps/cpu_features/include/cpu_features_macros.h +0 -384
  67. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_aarch64.h +0 -259
  68. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_arm.h +0 -121
  69. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_mips.h +0 -74
  70. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_ppc.h +0 -149
  71. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_riscv.h +0 -72
  72. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_s390x.h +0 -108
  73. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_x86.h +0 -288
  74. package/node_modules/cpu-features/deps/cpu_features/include/internal/bit_utils.h +0 -40
  75. package/node_modules/cpu-features/deps/cpu_features/include/internal/cpuid_x86.h +0 -37
  76. package/node_modules/cpu-features/deps/cpu_features/include/internal/filesystem.h +0 -39
  77. package/node_modules/cpu-features/deps/cpu_features/include/internal/hwcaps.h +0 -240
  78. package/node_modules/cpu-features/deps/cpu_features/include/internal/stack_line_reader.h +0 -49
  79. package/node_modules/cpu-features/deps/cpu_features/include/internal/string_view.h +0 -110
  80. package/node_modules/cpu-features/deps/cpu_features/include/internal/windows_utils.h +0 -70
  81. package/node_modules/cpu-features/deps/cpu_features/ndk_compat/CMakeLists.txt +0 -60
  82. package/node_modules/cpu-features/deps/cpu_features/ndk_compat/README.md +0 -4
  83. package/node_modules/cpu-features/deps/cpu_features/ndk_compat/cpu-features.c +0 -205
  84. package/node_modules/cpu-features/deps/cpu_features/ndk_compat/cpu-features.h +0 -320
  85. package/node_modules/cpu-features/deps/cpu_features/ndk_compat/ndk-compat-test.c +0 -12
  86. package/node_modules/cpu-features/deps/cpu_features/patches/0001-Add-Apple-Silicon-Support.patch +0 -456
  87. package/node_modules/cpu-features/deps/cpu_features/scripts/generate_badges.d +0 -165
  88. package/node_modules/cpu-features/deps/cpu_features/scripts/make_release.sh +0 -75
  89. package/node_modules/cpu-features/deps/cpu_features/scripts/run_integration.sh +0 -490
  90. package/node_modules/cpu-features/deps/cpu_features/scripts/test_integration.sh +0 -79
  91. package/node_modules/cpu-features/deps/cpu_features/src/copy.inl +0 -19
  92. package/node_modules/cpu-features/deps/cpu_features/src/define_introspection.inl +0 -86
  93. package/node_modules/cpu-features/deps/cpu_features/src/define_introspection_and_hwcaps.inl +0 -26
  94. package/node_modules/cpu-features/deps/cpu_features/src/equals.inl +0 -22
  95. package/node_modules/cpu-features/deps/cpu_features/src/filesystem.c +0 -62
  96. package/node_modules/cpu-features/deps/cpu_features/src/hwcaps.c +0 -169
  97. package/node_modules/cpu-features/deps/cpu_features/src/impl_aarch64__base_implementation.inl +0 -88
  98. package/node_modules/cpu-features/deps/cpu_features/src/impl_aarch64_linux_or_android.c +0 -79
  99. package/node_modules/cpu-features/deps/cpu_features/src/impl_aarch64_macos_or_iphone.c +0 -82
  100. package/node_modules/cpu-features/deps/cpu_features/src/impl_aarch64_windows.c +0 -138
  101. package/node_modules/cpu-features/deps/cpu_features/src/impl_arm_linux_or_android.c +0 -212
  102. package/node_modules/cpu-features/deps/cpu_features/src/impl_mips_linux_or_android.c +0 -93
  103. package/node_modules/cpu-features/deps/cpu_features/src/impl_ppc_linux.c +0 -163
  104. package/node_modules/cpu-features/deps/cpu_features/src/impl_riscv_linux.c +0 -111
  105. package/node_modules/cpu-features/deps/cpu_features/src/impl_s390x_linux.c +0 -120
  106. package/node_modules/cpu-features/deps/cpu_features/src/impl_x86__base_implementation.inl +0 -2055
  107. package/node_modules/cpu-features/deps/cpu_features/src/impl_x86_freebsd.c +0 -68
  108. package/node_modules/cpu-features/deps/cpu_features/src/impl_x86_linux_or_android.c +0 -58
  109. package/node_modules/cpu-features/deps/cpu_features/src/impl_x86_macos.c +0 -57
  110. package/node_modules/cpu-features/deps/cpu_features/src/impl_x86_windows.c +0 -58
  111. package/node_modules/cpu-features/deps/cpu_features/src/stack_line_reader.c +0 -132
  112. package/node_modules/cpu-features/deps/cpu_features/src/string_view.c +0 -192
  113. package/node_modules/cpu-features/deps/cpu_features/src/utils/list_cpu_features.c +0 -461
  114. package/node_modules/cpu-features/deps/cpu_features/test/CMakeLists.txt +0 -114
  115. package/node_modules/cpu-features/deps/cpu_features/test/bit_utils_test.cc +0 -53
  116. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_aarch64_test.cc +0 -396
  117. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_arm_test.cc +0 -384
  118. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_mips_test.cc +0 -176
  119. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_ppc_test.cc +0 -131
  120. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_riscv_test.cc +0 -180
  121. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_s390x_test.cc +0 -82
  122. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_x86_test.cc +0 -1729
  123. package/node_modules/cpu-features/deps/cpu_features/test/filesystem_for_testing.cc +0 -103
  124. package/node_modules/cpu-features/deps/cpu_features/test/filesystem_for_testing.h +0 -61
  125. package/node_modules/cpu-features/deps/cpu_features/test/hwcaps_for_testing.cc +0 -52
  126. package/node_modules/cpu-features/deps/cpu_features/test/hwcaps_for_testing.h +0 -31
  127. package/node_modules/cpu-features/deps/cpu_features/test/stack_line_reader_test.cc +0 -132
  128. package/node_modules/cpu-features/deps/cpu_features/test/string_view_test.cc +0 -202
  129. package/node_modules/cpu-features/lib/index.js +0 -5
  130. package/node_modules/cpu-features/package.json +0 -40
  131. package/node_modules/cpu-features/src/binding.cc +0 -151
  132. package/node_modules/cpu-features/test/test.js +0 -12
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).
@@ -2382,8 +2390,9 @@ window.__ModuleLoader__.load({
2382
2390
  }
2383
2391
 
2384
2392
  // ── better-sidebar integration (optional) ────────────────────────────────
2393
+ // One descriptor only: the explorer page. The per-file viewer is a native
2394
+ // resource type, never a better-sidebar card (see registerSidebarIntegration).
2385
2395
  const SIDEBAR_EXPLORER_ID = 'dsh-remote:explorer'
2386
- const SIDEBAR_FILE_ID = 'dsh-remote:file'
2387
2396
 
2388
2397
  function fetchRemoteStatus(sessionId) {
2389
2398
  return api('GET', withSessionQuery('/dsh-remote/status', sessionId)).catch(() => null)
@@ -2989,10 +2998,27 @@ window.__ModuleLoader__.load({
2989
2998
  return () => { clearTimeout(tid); document.removeEventListener('mousedown', close); host.remove() }
2990
2999
  }, [menu])
2991
3000
 
3001
+ // Open one remote file. The viewer is a RESOURCE tab type: the address
3002
+ // names the file, and the HOST's tab registry picks the type that claims
3003
+ // it (ours, `dsh-resource://dsh-remote/**`). That is the same call the
3004
+ // native explorer makes through `onOpenFile`, so one file behaves
3005
+ // identically in both compositions — and it needs no better-sidebar
3006
+ // descriptor, whose only effect would have been a switch that unregisters
3007
+ // the viewer.
2992
3008
  const openFile = (p) => {
2993
- const name = String(p).split(/[\\/]/).pop() || p
2994
- if (props.onOpenFile) props.onOpenFile(p)
2995
- else props.ctx.betterSidebar.openTab({ type: SIDEBAR_FILE_ID, title: name, path: p }, props.scope)
3009
+ if (props.onOpenFile) { props.onOpenFile(p); return }
3010
+ const sessionId = (props.scope && props.scope.sessionId) || ''
3011
+ try {
3012
+ const right = props.ctx && props.ctx.get && props.ctx.get('sidebarRight')
3013
+ if (right && typeof right.openResource === 'function') {
3014
+ right.openResource(remoteFileAddress(sessionId, p))
3015
+ return
3016
+ }
3017
+ } catch (e) {
3018
+ console.warn('[dsh-remote] remote file open failed:', e)
3019
+ return
3020
+ }
3021
+ console.warn('[dsh-remote] no right-sidebar resource opener; remote file not opened')
2996
3022
  }
2997
3023
 
2998
3024
  const doFs = (op, payload, refreshParent) => {
@@ -3302,13 +3328,14 @@ window.__ModuleLoader__.load({
3302
3328
  // Issue #32: own the expansion state (see RemoteExplorerTabOwned).
3303
3329
  component: RemoteExplorerTabOwned,
3304
3330
  }))
3305
- disposers.push(bs.registerTab({
3306
- id: SIDEBAR_FILE_ID,
3307
- title: () => tr('explorer.tabTitle'),
3308
- hidden: true, // not in the + menu; opened from the explorer tree
3309
- dedupeKey: (tab) => tab.path,
3310
- component: (props) => React.createElement(RemoteFileTab, props),
3311
- }))
3331
+ // No descriptor for the FILE viewer on purpose. It is a resource tab
3332
+ // type (`dsh-remote/file`, registered with `patterns` on the native
3333
+ // registry), not a page: it only means something once an address names
3334
+ // the file, so it has no card to draw and no page to open. A
3335
+ // descriptor here would put a card in better-sidebar's 侧边栏内容
3336
+ // inventory whose switch could only ever unregister the viewer — the
3337
+ // "switch does nothing" report this replaced. The tree hands the HOST
3338
+ // an address instead and the host routes it to that type (openFile).
3312
3339
  } catch (e) {
3313
3340
  console.warn('[dsh-remote] better-sidebar integration skipped:', e)
3314
3341
  disposers.forEach((d) => { try { d() } catch {} })
@@ -3522,11 +3549,65 @@ window.__ModuleLoader__.load({
3522
3549
  // built-in local preview. This is a warm-up, not a dependency: a failure
3523
3550
  // just leaves the conservative behaviour in place.
3524
3551
  try { ensureMirrors() } catch { /* the registry is best-effort */ }
3525
- inner.effect(() => tabs.register({
3526
- id: NATIVE_EXPLORER, kind: NATIVE_EXPLORER,
3527
- title: () => tr('explorer.tabTitle'),
3528
- guide: [{ order: 55, title: () => tr('explorer.tabTitle') }],
3529
- }), 'dsh-remote.native-explorer-type')
3552
+ // ── Who owns the 开始 card ────────────────────────────────────────────
3553
+ // The 远程文件 card on the right sidebar's start page (开始) is the
3554
+ // `guide` entry of a native tab type, and TWO providers can publish one:
3555
+ // this plugin (below) and dsh-better-sidebar, which registers a native
3556
+ // type per tab descriptor it hosts — our `dsh-remote:explorer` gets a
3557
+ // guide row of its own there, governed by its side-card settings switch.
3558
+ //
3559
+ // Publishing both is what made that switch look broken: turning the type
3560
+ // off removed better-sidebar's row and left ours, so the card never went
3561
+ // away. The card therefore belongs to exactly ONE provider:
3562
+ // · better-sidebar in the composition → ITS descriptor owns the card
3563
+ // (its switch IS the switch for it), and this type is registered
3564
+ // without a guide entry. The type and its body stay: a tab opened by
3565
+ // kind still renders, and nothing else about the panel changes.
3566
+ // · plain install → the card is ours, always, exactly as before.
3567
+ //
3568
+ // `guide` is captured at registration time and an id may not be
3569
+ // registered twice, so a handover means dispose-then-register inside one
3570
+ // synchronous block: no render can observe the gap.
3571
+ let betterSidebar = false
3572
+ let disposed = false
3573
+ let disposeExplorer = null
3574
+ let guideListed = null
3575
+ const syncExplorerType = () => {
3576
+ const withGuide = !betterSidebar
3577
+ if (guideListed === withGuide && disposeExplorer) return
3578
+ const previous = disposeExplorer
3579
+ disposeExplorer = null
3580
+ if (previous) { try { previous() } catch { /* already released */ } }
3581
+ disposeExplorer = tabs.register({
3582
+ id: NATIVE_EXPLORER, kind: NATIVE_EXPLORER,
3583
+ title: () => tr('explorer.tabTitle'),
3584
+ ...(withGuide ? { guide: [{ order: 55, title: () => tr('explorer.tabTitle') }] } : {}),
3585
+ })
3586
+ guideListed = withGuide
3587
+ }
3588
+ inner.effect(() => {
3589
+ syncExplorerType()
3590
+ return () => {
3591
+ disposed = true
3592
+ const dispose = disposeExplorer
3593
+ disposeExplorer = null
3594
+ guideListed = null
3595
+ if (dispose) { try { dispose() } catch { /* already released */ } }
3596
+ }
3597
+ }, 'dsh-remote.native-explorer-type')
3598
+ // The two plugins may activate in EITHER order, so the service has to be
3599
+ // followed rather than read once: the card moves to better-sidebar when
3600
+ // it arrives, and comes back here if it goes away.
3601
+ inner.inject(['betterSidebar'], (withSidebar) => {
3602
+ betterSidebar = true
3603
+ try { syncExplorerType() } catch (e) { console.warn('[dsh-remote] start-page card handover skipped:', e) }
3604
+ if (!withSidebar || typeof withSidebar.effect !== 'function') return
3605
+ withSidebar.effect(() => () => {
3606
+ betterSidebar = false
3607
+ if (disposed) return
3608
+ try { syncExplorerType() } catch (e) { console.warn('[dsh-remote] start-page card handover skipped:', e) }
3609
+ }, 'dsh-remote.start-card-owner')
3610
+ })
3530
3611
  inner.effect(() => tabs.register({
3531
3612
  id: NATIVE_FILE, kind: NATIVE_FILE,
3532
3613
  patterns: ['dsh-resource://dsh-remote/**'],
package/lib/index.js CHANGED
@@ -24,6 +24,7 @@ import { execFile } from 'node:child_process'
24
24
  import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, appendFileSync, watch, rmSync } from 'node:fs'
25
25
  import { homedir } from 'node:os'
26
26
  import path from 'node:path'
27
+ import { fileURLToPath } from 'node:url'
27
28
  import iconv from 'iconv-lite'
28
29
 
29
30
  import {
@@ -33,11 +34,12 @@ import {
33
34
  import { createHostKeyGuard, isHostKeyKnown as _isHostKeyKnown } from './hostkey.js'
34
35
  import { compileIgnore, ignorePatternsFor, DEFAULT_IGNORE } from './ignore.js'
35
36
  import { friendlyMessage } from './errors.js'
36
- import { importableEntries, sshConfigPath, readSshConfigText, resolveMachineSshConfig } from './sshconfig.js'
37
+ import { importableEntries, sshConfigPath, readSshConfigText, resolveMachineSshConfig, resolveLiteralSshConfig, readRawSshConfig, upsertManagedHostBlock, removeManagedHostBlock, writeSshConfig } from './sshconfig.js'
37
38
  import { createRemoteFileReference, installFileReferenceOverlay, DEFAULT_EXCLUDED_DIRECTORIES } from './file-reference.js'
38
39
  import { getSecret, deleteSecret, platformBackend, persistPassword } from './credential.js'
39
40
  import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from './sync.js'
40
41
  import { searchRemote } from './search.js'
42
+ import { installTerminalShell } from './remote-shell.js'
41
43
  import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessionHeaderCwd, requestSessionHint } from './binding.js'
42
44
  import { TaskManager } from './tasks.js'
43
45
  import { DeployTasks } from './deploy-tasks.js'
@@ -256,6 +258,30 @@ function mirrorRootFor(host, user, port) {
256
258
  return path.join(remoteWorkspacesRoot(), tag)
257
259
  }
258
260
 
261
+ /** Generated terminal launchers. Deliberately NOT under `remote-workspaces`: a
262
+ * launcher there would sit next to the mirrors, where anything that scans the
263
+ * root for checkouts could pick it up as one. */
264
+ function remoteTerminalRoot() {
265
+ return path.join(dshBase(), 'remote-terminal')
266
+ }
267
+
268
+ /**
269
+ * Every path the remote-shell contribution needs, resolved for this install.
270
+ *
271
+ * Split out from the registration itself so the wiring at the bottom of the
272
+ * plugin stays one readable line, and so the launcher path is a pure function
273
+ * of where the plugin lives.
274
+ *
275
+ * @returns {{ dir: string, childPath: string, machinesFile: string }}
276
+ */
277
+ function remoteShellSpec() {
278
+ return {
279
+ dir: remoteTerminalRoot(),
280
+ childPath: fileURLToPath(new URL('./remote-shell-child.cjs', import.meta.url)),
281
+ machinesFile: machinesFile(),
282
+ }
283
+ }
284
+
259
285
  /** Local mirror dir for a specific remote path (idempotent → returns same dir). */
260
286
  function mirrorDirFor(remotePath, host, user, port) {
261
287
  const base = remotePathBase(remotePath)
@@ -405,25 +431,49 @@ export async function apply(ctx, config) {
405
431
  * keychain lookups and the settings UI keep working.
406
432
  */
407
433
  const loggedSshWarnings = new Map()
408
- const withSshConfig = (m) => {
409
- // Short-circuit BEFORE reading ~/.ssh/config: a deployment with no alias
410
- // machine must never touch that file, and the 2s text memo must not be
411
- // primed by unrelated machines.
412
- if (!m || !m.useSshConfig) return m
413
- const resolved = resolveMachineSshConfig(m, { text: sshConfigText() })
414
- if (!resolved) return m
415
- // Warn once per distinct warning set: this runs on every tool call, and an
416
- // unmatched alias would otherwise fill the log with the same line.
417
- const signature = resolved.warnings.join(' | ')
418
- if (signature && loggedSshWarnings.get(resolved.alias) !== signature) {
419
- 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)
420
441
  try {
421
442
  const logger = typeof ctx.get === 'function' ? ctx.get('logger') : undefined
422
443
  if (logger && typeof logger.warn === 'function') {
423
- logger.warn(`[dsh-remote] ssh-config alias "${resolved.alias}": ${resolved.warnings.join(' ')}`)
444
+ logger.warn(`[dsh-remote] ssh-config alias "${alias}": ${warnings.join(' ')}`)
424
445
  }
425
446
  } catch { /* logging is best-effort */ }
426
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)
427
477
  return {
428
478
  ...m,
429
479
  sshAlias: resolved.alias,
@@ -457,7 +507,10 @@ export async function apply(ctx, config) {
457
507
  * @returns {{host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, aliasNative: boolean}}
458
508
  */
459
509
  const connectTargetFor = (eff) => {
460
- 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))
461
514
  if (!aliasNative) {
462
515
  return {
463
516
  host: String((eff && eff.host) || ''),
@@ -468,7 +521,7 @@ export async function apply(ctx, config) {
468
521
  aliasNative: false,
469
522
  }
470
523
  }
471
- const alias = String((eff && (eff.sshAlias || eff.host)) || '')
524
+ const alias = String((eff && (eff.sshAlias || eff.sshConfigName || eff.host)) || '')
472
525
  return {
473
526
  // The alias itself; ssh expands HostName/User/Port/IdentityFile and every
474
527
  // other directive while matching that Host block.
@@ -485,13 +538,14 @@ export async function apply(ctx, config) {
485
538
  const aliasOf = (m) => (m && m.sshAlias) || (m && m.useSshConfig ? String(m.host || '') : '')
486
539
 
487
540
  /** Machines as the settings UI sees them: secrets stripped, plus the
488
- * ~/.ssh/config resolution of an alias machine so a row can show where the
489
- * alias actually points without duplicating those values in the registry
490
- * (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. */
491
544
  const machinesForClient = () => machines.map((m) => ({
492
545
  ...sanitizeMachine(m),
493
- // Read ~/.ssh/config only when a machine actually uses an alias.
494
- sshConfigResolved: m.useSshConfig ? resolveMachineSshConfig(m, { text: sshConfigText() }) : null,
546
+ sshConfigResolved: m.useSshConfig
547
+ ? resolveMachineSshConfig(m, { text: sshConfigText() })
548
+ : resolveLiteralSshConfig(m, { text: sshConfigText() }),
495
549
  }))
496
550
 
497
551
  /**
@@ -3064,12 +3118,49 @@ export async function apply(ctx, config) {
3064
3118
  // alone, so a fresh registry stays "no choice yet" (not an
3065
3119
  // explicit none) and a previously-cleared registry stays cleared.
3066
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
+ }
3067
3157
  return sendJson(res, 200, {
3068
3158
  ok: true,
3069
3159
  machine: sanitizeMachine(rec),
3070
3160
  machines: machinesForClient(),
3071
3161
  currentId: store.currentId,
3072
3162
  ...(warning ? { warning, warningDetail } : {}),
3163
+ ...(sshConfigWarning ? { sshConfigWarning } : {}),
3073
3164
  })
3074
3165
  }
3075
3166
  if (action === 'delete') {
@@ -3128,6 +3219,12 @@ export async function apply(ctx, config) {
3128
3219
  useSshConfig: true,
3129
3220
  }, { text: sshConfigText() })
3130
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
3131
3228
  // Issue #51: on the system-OpenSSH backend an alias is handed to `ssh`
3132
3229
  // VERBATIM (see connectTargetFor) — resolving it here would stop the
3133
3230
  // `Host` block from matching, so the ~/.ssh/config settings the probe
@@ -3135,13 +3232,13 @@ export async function apply(ctx, config) {
3135
3232
  const probe = createPool({
3136
3233
  ...config,
3137
3234
  backend: probeBackend,
3138
- host: aliasTarget && !probeIsOpenssh ? aliasTarget.host : String(body.host || config.host),
3139
- port: aliasTarget && !probeIsOpenssh ? aliasTarget.port : (Number(body.port) || config.port),
3140
- 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),
3141
3238
  password: String(body.password || ''),
3142
- privateKeyPath: (aliasTarget && !probeIsOpenssh) ? aliasTarget.privateKeyPath : String(body.privateKeyPath || config.privateKeyPath),
3239
+ privateKeyPath: (probeTarget && !probeIsOpenssh && probeTarget.privateKeyPath) ? probeTarget.privateKeyPath : String(body.privateKeyPath || config.privateKeyPath),
3143
3240
  passphrase: String(body.passphrase || ''),
3144
- 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 ? {
3145
3242
  host: String(body.proxy.host),
3146
3243
  port: Number(body.proxy.port) || 22,
3147
3244
  username: String(body.proxy.username || ''),
@@ -3731,6 +3828,17 @@ export async function apply(ctx, config) {
3731
3828
  registerDeploySkill(skillCtx)
3732
3829
  })
3733
3830
 
3831
+ // ── remote terminal: one extra shell in the host terminal's own list ──────
3832
+ // Additive and optional: a composition without `terminalController` simply
3833
+ // keeps the terminal it already had.
3834
+ //
3835
+ // The callback argument is a *new context*, not the service — reading the
3836
+ // service off it (as if it were the controller) is a silent no-op, because a
3837
+ // context has no `config.shellCandidates`.
3838
+ ctx.inject(['terminalController'], (inner) => {
3839
+ installTerminalShell(ctx, inner.get('terminalController'), remoteShellSpec())
3840
+ })
3841
+
3734
3842
  // ── auto-update: check on load + on an interval; apply silently ───────────
3735
3843
  // Failures are swallowed (never break the plugin). The persisted override
3736
3844
  // (settings UI) wins over the profile-config default, which wins over