@hyzyn/dsh-tty 0.17.2 → 0.18.1

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.d.ts CHANGED
@@ -50,7 +50,8 @@
50
50
  * OSC 133/7 钩子(zsh ZDOTDIR 桩 / bash --rcfile 桩),输出流解析出命令
51
51
  * 边界(tty_capture{last} / tty_expect 早停)与实时 cwd(tty_list)。
52
52
  * 辅助路由:/api/dsh-tty/ssh-config(~/.ssh/config 导入候选)、
53
- * /api/dsh-tty/env-vars(SSH 对话框 env:VAR 下拉,仅变量名)、
53
+ * /api/dsh-tty/credential-refs(凭据存储里已知的引用名,SSH 对话框选择器候选,只要名字)、
54
+ * /api/dsh-tty/env-vars(env 插件托管变量名;SSH 对话框已改从连接簿 + 凭据存储取候选,此路由保留兼容)、
54
55
  * /api/dsh-tty/shells(设置卡片「Shell 路径」候选,仅路径)、
55
56
  * /api/dsh-tty/sftp/*(SFTP 文件传输 0.7.0:list/mkdir/rename/remove/
56
57
  * download/upload;0.8.0 起 mkdir 支持 parents 逐级补齐,spec 解析与
package/lib/index.js CHANGED
@@ -14,9 +14,9 @@ import xtermHeadless from '@xterm/headless';
14
14
  const HeadlessTerminal = xtermHeadless.Terminal;
15
15
  import { definePlugin } from '@hyzyn/dsh-kit';
16
16
  import { defineTool } from '@deepseek-ai/dsh-tools';
17
- import { spawnSsh, sshTarget, expandHome } from './ssh.js';
17
+ import { spawnSsh, sshTarget, expandHome, setCredentialResolver } from './ssh.js';
18
18
  import { probeSsh } from './probe.js';
19
- import { buildCommandSpawn, buildShellSpawn } from './shell-integration.js';
19
+ import { buildCommandSpawn, buildShellSpawn, defaultShellPath } from './shell-integration.js';
20
20
  import { parseSshConfig } from './ssh-config.js';
21
21
  import { parseKnownHosts } from './known-hosts.js';
22
22
  import { TunnelManager } from './tunnels.js';
@@ -1664,9 +1664,55 @@ function readManagedEnvKeys() {
1664
1664
  }
1665
1665
  }
1666
1666
  /**
1667
- * 设置卡片「Shell 路径」候选(可选可输入的数据源):/etc/shells + $SHELL +
1668
- * 常见安装路径,去重后过滤「存在且可执行」,$SHELL 排最前。只回路径,
1669
- * 不做任何执行。
1667
+ * 凭据存储里**已知的引用名**(~/.dsh/.credentials.yaml 的 `refs:` 块键),只读给
1668
+ * SSH 对话框的引用选择器当候选。
1669
+ *
1670
+ * 为什么必须读文件:官方把「引用半边」设计成**不可枚举**——
1671
+ * `CredentialProvider.listRecords` 的注释原话是 "Unlike the reference half, which has no
1672
+ * enumeration because configuration surfaces learn which references exist from settings
1673
+ * schemas",而浏览器侧 `ctx.remote.credentials`(dsh-api-settings-controller)只开
1674
+ * `describe` / `set` / `unset`,连 `listRecords` 都没开。所以要让用户在下拉里看见
1675
+ * "这本存储里已经有什么名字",宿主侧读文件是唯一出路;**只要键名、不取值**(值只在本函数
1676
+ * 的局部 `lines` 里路过,不进任何返回值,也不写日志)。
1677
+ *
1678
+ * 解析刻意最小(与上面 readManagedEnvKeys 同款,不为它引 YAML 依赖):只认 `refs:` 顶层
1679
+ * 区块内「恰好两个空格 + POSIX 标识符 + 冒号」的行。本地 provider 写入时用 `yaml` 严格
1680
+ * 校验过(version: 1 / 值必须非空字符串 / 键必须是标识符),所以这个格式是稳的;真被手改
1681
+ * 坏了也只是候选少几个 —— 引用最终仍由连接时的凭据层校验存在性。
1682
+ *
1683
+ * 路径解析与本地 provider 的默认一致($DSH_HOME 优先,空串视为未设,再退 ~/.dsh)。边界:
1684
+ * 若有人给 provider 配了自定义 `path` / `dshHome`,这里看不到那些引用(字段仍可手输名字)。
1685
+ */
1686
+ function readCredentialRefNames() {
1687
+ const dshHome = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh');
1688
+ const file = join(dshHome, '.credentials.yaml');
1689
+ try {
1690
+ const names = [];
1691
+ let inRefs = false;
1692
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
1693
+ // 顶层键(0 缩进)切换区块;`refs:` 之后的条目才是引用名
1694
+ if (/^[A-Za-z_][A-Za-z0-9_]*:/.test(line)) {
1695
+ inRefs = line.startsWith('refs:');
1696
+ continue;
1697
+ }
1698
+ if (!inRefs)
1699
+ continue;
1700
+ const match = line.match(/^ {2}([A-Za-z_][A-Za-z0-9_]*):/);
1701
+ if (match !== null)
1702
+ names.push(match[1]);
1703
+ }
1704
+ return [...new Set(names)].sort().slice(0, 500);
1705
+ }
1706
+ catch {
1707
+ // 没有存储文件(还没存过任何东西)/ 没有读权限:候选为空,不报错
1708
+ return [];
1709
+ }
1710
+ }
1711
+ /**
1712
+ * 设置卡片「Shell 路径」候选(可选可输入的数据源):POSIX 走 /etc/shells + $SHELL +
1713
+ * 常见安装路径;**Windows 走 %COMSPEC% + Windows PowerShell + PowerShell 7**(原先这套
1714
+ * 候选在 Windows 上恒为空——/bin/zsh 那批路径一个都不存在)。去重后过滤「存在且可执行」,
1715
+ * 默认 shell 排最前。只回路径,不做任何执行。
1670
1716
  */
1671
1717
  function listCandidateShells() {
1672
1718
  const candidates = [];
@@ -1675,24 +1721,40 @@ function listCandidateShells() {
1675
1721
  if (path !== '' && !candidates.includes(path))
1676
1722
  candidates.push(path);
1677
1723
  };
1678
- try {
1679
- for (const line of readFileSync('/etc/shells', 'utf8').split('\n')) {
1680
- const path = line.trim();
1681
- if (path !== '' && !path.startsWith('#'))
1682
- push(path);
1724
+ const fallback = defaultShellPath();
1725
+ if (process.platform === 'win32') {
1726
+ const systemRoot = process.env.SystemRoot?.trim() || 'C:\\Windows';
1727
+ const programFiles = process.env.ProgramFiles?.trim() || 'C:\\Program Files';
1728
+ const localAppData = process.env.LOCALAPPDATA?.trim() || '';
1729
+ // 全给**绝对路径**:候选的过滤口径是「存在且可执行」,裸命令名(pwsh.exe)在这里判不了,
1730
+ // 而这三处覆盖了 Windows 上实际存在的 shell(%COMSPEC% 必在,Windows PowerShell 必在,
1731
+ // PowerShell 7 装了才有)。
1732
+ push(process.env.COMSPEC);
1733
+ push(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'));
1734
+ push(join(programFiles, 'PowerShell', '7', 'pwsh.exe'));
1735
+ if (localAppData !== '')
1736
+ push(join(localAppData, 'Microsoft', 'WindowsApps', 'pwsh.exe'));
1737
+ }
1738
+ else {
1739
+ try {
1740
+ for (const line of readFileSync('/etc/shells', 'utf8').split('\n')) {
1741
+ const path = line.trim();
1742
+ if (path !== '' && !path.startsWith('#'))
1743
+ push(path);
1744
+ }
1683
1745
  }
1746
+ catch {
1747
+ /* 无 /etc/shells 时跳过 */
1748
+ }
1749
+ push(process.env.SHELL);
1750
+ for (const path of [
1751
+ '/bin/zsh', '/usr/bin/zsh', '/usr/local/bin/zsh', '/opt/homebrew/bin/zsh',
1752
+ '/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash', '/opt/homebrew/bin/bash',
1753
+ '/bin/fish', '/usr/bin/fish', '/usr/local/bin/fish', '/opt/homebrew/bin/fish',
1754
+ '/bin/sh', '/bin/dash', '/bin/ksh', '/bin/tcsh', '/bin/csh',
1755
+ ])
1756
+ push(path);
1684
1757
  }
1685
- catch {
1686
- /* 无 /etc/shells(如 Windows)时跳过 */
1687
- }
1688
- push(process.env.SHELL);
1689
- for (const path of [
1690
- '/bin/zsh', '/usr/bin/zsh', '/usr/local/bin/zsh', '/opt/homebrew/bin/zsh',
1691
- '/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash', '/opt/homebrew/bin/bash',
1692
- '/bin/fish', '/usr/bin/fish', '/usr/local/bin/fish', '/opt/homebrew/bin/fish',
1693
- '/bin/sh', '/bin/dash', '/bin/ksh', '/bin/tcsh', '/bin/csh',
1694
- ])
1695
- push(path);
1696
1758
  const usable = candidates.filter((path) => {
1697
1759
  try {
1698
1760
  accessSync(path, fsConstants.X_OK);
@@ -1702,8 +1764,7 @@ function listCandidateShells() {
1702
1764
  return false;
1703
1765
  }
1704
1766
  });
1705
- const shell = process.env.SHELL?.trim() ?? '';
1706
- usable.sort((a, b) => (a === shell ? -1 : b === shell ? 1 : a.localeCompare(b)));
1767
+ usable.sort((a, b) => (a === fallback ? -1 : b === fallback ? 1 : a.localeCompare(b)));
1707
1768
  return usable;
1708
1769
  }
1709
1770
  /**
@@ -1845,14 +1906,19 @@ const plugin = definePlugin({
1845
1906
  if (config?.enabled === false)
1846
1907
  return;
1847
1908
  const live = new LiveConfig({
1848
- shell: config?.shell?.trim() || process.env.SHELL || '/bin/zsh',
1909
+ // 默认 shell 按平台取(Windows 没有 $SHELL,回落 /bin/zsh 会让本地终端一条都开不起来;
1910
+ // 见 defaultShellPath)。用户显式填了「Shell 路径」就照用。
1911
+ shell: config?.shell?.trim() || defaultShellPath(),
1849
1912
  term: config?.term?.trim() || 'xterm-256color',
1850
1913
  colorTerm: config?.colorTerm?.trim() || 'truecolor',
1851
1914
  cwd: config?.cwd?.trim() || process.cwd(),
1852
1915
  reconnectGraceSec: typeof config?.reconnectGraceSec === 'number' && Number.isInteger(config.reconnectGraceSec) && config.reconnectGraceSec >= 0 ? config.reconnectGraceSec : DEFAULT_RECONNECT_GRACE_SEC,
1853
1916
  sshHosts: Array.isArray(config?.sshHosts) ? config.sshHosts : [],
1854
1917
  hostKeys: Array.isArray(config?.hostKeys) ? config.hostKeys : [],
1855
- shellIntegration: config?.shellIntegration !== false,
1918
+ // shell 集成(OSC 133/7)靠 POSIX rc 注入 + `-c` 包装层:Windows 上的 cmd / PowerShell
1919
+ // 两者都不成立(实测 cmd 忽略 -c 空跑、PowerShell 报 export 不存在),所以恒关。
1920
+ // 配置项照旧留着(快照里会显示 false),界面上也说明原因。
1921
+ shellIntegration: process.platform !== 'win32' && config?.shellIntegration !== false,
1856
1922
  tunnels: Array.isArray(config?.tunnels) ? config.tunnels : [],
1857
1923
  persistence: config?.persistence === 'tmux' ? 'tmux' : 'off',
1858
1924
  endOnPageClose: config?.endOnPageClose === true,
@@ -1922,6 +1988,11 @@ const plugin = definePlugin({
1922
1988
  statsEnabled: live.statsEnabled,
1923
1989
  sftpLimits: live.sftpLimits,
1924
1990
  toolsRegistered: stateRef.toolsRegistered,
1991
+ /**
1992
+ * 宿主平台(`process.platform`):客户端据此把「Shell 路径 / shell 集成」的说明与候选
1993
+ * 按平台写(Windows 上不提 zsh/bash 那套,也不摆一个恒关闭的集成开关)。
1994
+ */
1995
+ platform: process.platform,
1925
1996
  });
1926
1997
  /** 规范化并应用一份配置补丁(settings/updated 事件与 HTTP POST 共用;幂等)。 */
1927
1998
  const applyPatch = (section) => {
@@ -2075,6 +2146,22 @@ const plugin = definePlugin({
2075
2146
  }
2076
2147
  return { patch };
2077
2148
  };
2149
+ /*
2150
+ * credentials(**可选**依赖):连接簿里的 `env:NAME` 是**引用**,值归官方凭据 provider ——
2151
+ * 它自己叠 `file`(`$DSH_HOME/.credentials.yaml`)/ `env` / `project-env` / `user-env` 各层,
2152
+ * 并保证「每次操作重新解析」(改完下一个操作即生效,不必重启宿主)。
2153
+ *
2154
+ * 为什么必须走它:凭据存储里的值**永远不会 materialize 进环境**(provider README 原话),
2155
+ * 所以只读 `process.env` 等于"存进凭据存储的密码连接时读不到" —— 「保存时存入凭据存储」
2156
+ * 那条链此前就是断在这里(客户端那半切好了、宿主这半没切)。
2157
+ *
2158
+ * 服务缺失(老宿主 / 未装该 bundle)时不注册,`resolveSecret` 自己退回 `process.env`,行为与
2159
+ * 从前一致 —— 所以这是**可选**依赖,不抬高 engines 下限。
2160
+ */
2161
+ ctx.inject(['credentials'], (credCtx) => {
2162
+ setCredentialResolver(credCtx.credentials ?? null);
2163
+ return () => { setCredentialResolver(null); };
2164
+ });
2078
2165
  // webServer:WS upgrade 路由 + 配置读写路由(/api/dsh-tty/config)
2079
2166
  ctx.inject(['webServer'], (webCtx) => {
2080
2167
  webCtx.effect(() => {
@@ -2179,6 +2266,25 @@ const plugin = definePlugin({
2179
2266
  writeJson(res, 200, { ok: true, names: readManagedEnvKeys() });
2180
2267
  },
2181
2268
  });
2269
+ // 凭据存储里已知的引用名(SSH 对话框选择器候选):只回**名字**,绝不回值。
2270
+ // 为什么不像其它设置界面那样只列"自己 schema 里的引用":官方那条路对这本存储
2271
+ // 来说列不全(引用半边不可枚举,见 readCredentialRefNames),而这个选择器的用途
2272
+ // 恰恰就是"我存过什么、能不能复用"。
2273
+ registerGated({
2274
+ kind: 'exact',
2275
+ path: '/api/dsh-tty/credential-refs',
2276
+ handler: async (req, res) => {
2277
+ if (!isLoopbackHttp(req)) {
2278
+ writeJson(res, 403, { error: 'forbidden: loopback-only' });
2279
+ return;
2280
+ }
2281
+ if (req.method !== 'GET') {
2282
+ writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
2283
+ return;
2284
+ }
2285
+ writeJson(res, 200, { ok: true, names: readCredentialRefNames() });
2286
+ },
2287
+ });
2182
2288
  // known_hosts 指纹导入候选(TOFU 预填充):hashed 条目用连接簿主机名还原
2183
2289
  registerGated({
2184
2290
  kind: 'exact',