compshare-cli 0.3.0__tar.gz → 0.3.2__tar.gz

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 (49) hide show
  1. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/PKG-INFO +23 -3
  2. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/README.md +22 -2
  3. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/pyproject.toml +1 -1
  4. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/__init__.py +1 -1
  5. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/commands/doctor.py +13 -1
  6. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/commands/instance.py +143 -5
  7. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/i18n.py +12 -0
  8. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/output.py +18 -2
  9. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/ssh.py +90 -3
  10. compshare_cli-0.3.2/src/compshare_cli/ssh_cache.py +218 -0
  11. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/PKG-INFO +23 -3
  12. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/SOURCES.txt +3 -1
  13. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_cli.py +144 -1
  14. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_output.py +13 -0
  15. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_ssh.py +58 -0
  16. compshare_cli-0.3.2/tests/test_ssh_cache.py +47 -0
  17. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/LICENSE +0 -0
  18. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/setup.cfg +0 -0
  19. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/__main__.py +0 -0
  20. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/actions.py +0 -0
  21. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/api.py +0 -0
  22. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/cli.py +0 -0
  23. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/commands/__init__.py +0 -0
  24. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/commands/ask.py +0 -0
  25. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/commands/common.py +0 -0
  26. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/commands/feedback.py +0 -0
  27. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/commands/image.py +0 -0
  28. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/commands/storage.py +0 -0
  29. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/commands/team.py +0 -0
  30. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/config.py +0 -0
  31. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/errors.py +0 -0
  32. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/insights.py +0 -0
  33. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/location.py +0 -0
  34. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/parsing.py +0 -0
  35. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/qa.py +0 -0
  36. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/runtime.py +0 -0
  37. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/sdk.py +0 -0
  38. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/dependency_links.txt +0 -0
  39. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/entry_points.txt +0 -0
  40. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/requires.txt +0 -0
  41. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/top_level.txt +0 -0
  42. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_actions.py +0 -0
  43. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_api.py +0 -0
  44. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_config.py +0 -0
  45. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_feedback.py +0 -0
  46. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_location.py +0 -0
  47. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_parsing.py +0 -0
  48. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_qa.py +0 -0
  49. {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_sdk.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compshare-cli
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Command-line client for CompShare GPU compute
5
5
  Author: CompShare
6
6
  License-Expression: Apache-2.0
@@ -35,7 +35,7 @@ Dynamic: license-file
35
35
  - 可通过 `compshare feedback` 反馈 CLI 问题和建议
36
36
  - 基于官方 [`ucloud-sdk-python3`](https://github.com/ucloud/ucloud-sdk-python3)
37
37
 
38
- 当前版本:`0.3.0`,要求 Python 3.9 或更高版本。
38
+ 当前版本:`0.3.1`,要求 Python 3.9 或更高版本。
39
39
 
40
40
  ## 安装
41
41
 
@@ -54,12 +54,23 @@ source .venv/bin/activate
54
54
  python -m pip install -e '.[dev]'
55
55
  ```
56
56
 
57
- Windows 激活虚拟环境时使用:
57
+ Windows PowerShell 中可先激活虚拟环境,再直接调用 `compshare`:
58
58
 
59
59
  ```powershell
60
60
  .\.venv\Scripts\Activate.ps1
61
+ compshare -h
62
+ compshare config --name default
61
63
  ```
62
64
 
65
+ 不激活虚拟环境时,使用 PowerShell 调用运算符 `&` 直接执行虚拟环境内的入口:
66
+
67
+ ```powershell
68
+ & '.\.venv\Scripts\compshare.exe' -h
69
+ & '.\.venv\Scripts\compshare.exe' config --name default
70
+ ```
71
+
72
+ PowerShell 会把单引号中的路径解析为字符串,所以带参数执行时不能省略 `&`。路径包含空格时必须保留引号;从当前目录运行可执行文件时要使用 `.\` 前缀。若 `Activate.ps1` 被本机执行策略拦截,可直接使用上述不激活的写法,无需修改系统策略。
73
+
63
74
  ## 配置
64
75
 
65
76
  ```bash
@@ -105,6 +116,11 @@ compshare instance ssh INSTANCE_ID --no-auto-password
105
116
  # 自动登录后执行远程命令;远程参数用 -- 与 CLI 选项分隔
106
117
  compshare instance ssh INSTANCE_ID -- nvidia-smi --query-gpu=name
107
118
  compshare instance ssh INSTANCE_ID -- 'cd /workspace && python train.py'
119
+ # Agent/脚本:执行并返回 ok、phase、exit_code、stdout、stderr、error
120
+ compshare --json instance ssh INSTANCE_ID -- nvidia-smi
121
+ # 强制刷新或禁用本地 SSH 连接缓存
122
+ compshare instance ssh INSTANCE_ID --refresh
123
+ compshare instance ssh INSTANCE_ID --no-cache
108
124
  # 上传本地文件或目录,目录会自动递归复制
109
125
  compshare instance scp INSTANCE_ID ./model.bin /workspace/model.bin
110
126
  compshare instance scp INSTANCE_ID ./dataset /workspace/dataset
@@ -219,6 +235,10 @@ compshare lang # 查看当前语言
219
235
  - `instance ssh` 在支持的交互式终端中通过伪终端自动填写登录密码,不会打印密码或将其放入进程参数。
220
236
  - 自动登录会接受首次出现的 SSH 主机密钥;已记录主机的密钥发生变化时仍会拒绝连接。
221
237
  - `instance ssh INSTANCE_ID -- COMMAND` 可非交互执行远程命令,透传命令输出和退出码。
238
+ - `--json instance ssh INSTANCE_ID -- COMMAND` 会真实执行命令,并结构化返回退出码、标准输出、标准错误及连接/认证错误阶段。
239
+ - `--json` 始终以 UTF-8 字节输出,不依赖 Windows 活动代码页或终端编码。
240
+ - `instance ssh` 默认将 API 返回的 SSH 命令和密码按 profile/实例缓存 1 小时;Windows 使用当前用户的 DPAPI 加密密码,其他平台将缓存文件权限限制为当前用户。可用 `--refresh` 强制更新、`--no-cache` 禁用;重置密码和重装实例会清除对应缓存。
241
+ - 实例创建是异步操作;`instance create` 默认等待到 `Running`,显式使用 `--no-wait` 才会在创建接口返回后立即退出。`instance ssh` 也会默认等待实例运行后再连接。
222
242
  - `instance scp INSTANCE_ID LOCAL_PATH REMOTE_PATH` 可自动认证并上传本地文件或目录。
223
243
  - `--show-sensitive` 会恢复这些字段的原始值;请勿在共享终端、CI 日志或 Agent 会话中使用。
224
244
  - 删除、关机、重启、重装和改配等操作默认要求确认。
@@ -10,7 +10,7 @@
10
10
  - 可通过 `compshare feedback` 反馈 CLI 问题和建议
11
11
  - 基于官方 [`ucloud-sdk-python3`](https://github.com/ucloud/ucloud-sdk-python3)
12
12
 
13
- 当前版本:`0.3.0`,要求 Python 3.9 或更高版本。
13
+ 当前版本:`0.3.1`,要求 Python 3.9 或更高版本。
14
14
 
15
15
  ## 安装
16
16
 
@@ -29,12 +29,23 @@ source .venv/bin/activate
29
29
  python -m pip install -e '.[dev]'
30
30
  ```
31
31
 
32
- Windows 激活虚拟环境时使用:
32
+ Windows PowerShell 中可先激活虚拟环境,再直接调用 `compshare`:
33
33
 
34
34
  ```powershell
35
35
  .\.venv\Scripts\Activate.ps1
36
+ compshare -h
37
+ compshare config --name default
36
38
  ```
37
39
 
40
+ 不激活虚拟环境时,使用 PowerShell 调用运算符 `&` 直接执行虚拟环境内的入口:
41
+
42
+ ```powershell
43
+ & '.\.venv\Scripts\compshare.exe' -h
44
+ & '.\.venv\Scripts\compshare.exe' config --name default
45
+ ```
46
+
47
+ PowerShell 会把单引号中的路径解析为字符串,所以带参数执行时不能省略 `&`。路径包含空格时必须保留引号;从当前目录运行可执行文件时要使用 `.\` 前缀。若 `Activate.ps1` 被本机执行策略拦截,可直接使用上述不激活的写法,无需修改系统策略。
48
+
38
49
  ## 配置
39
50
 
40
51
  ```bash
@@ -80,6 +91,11 @@ compshare instance ssh INSTANCE_ID --no-auto-password
80
91
  # 自动登录后执行远程命令;远程参数用 -- 与 CLI 选项分隔
81
92
  compshare instance ssh INSTANCE_ID -- nvidia-smi --query-gpu=name
82
93
  compshare instance ssh INSTANCE_ID -- 'cd /workspace && python train.py'
94
+ # Agent/脚本:执行并返回 ok、phase、exit_code、stdout、stderr、error
95
+ compshare --json instance ssh INSTANCE_ID -- nvidia-smi
96
+ # 强制刷新或禁用本地 SSH 连接缓存
97
+ compshare instance ssh INSTANCE_ID --refresh
98
+ compshare instance ssh INSTANCE_ID --no-cache
83
99
  # 上传本地文件或目录,目录会自动递归复制
84
100
  compshare instance scp INSTANCE_ID ./model.bin /workspace/model.bin
85
101
  compshare instance scp INSTANCE_ID ./dataset /workspace/dataset
@@ -194,6 +210,10 @@ compshare lang # 查看当前语言
194
210
  - `instance ssh` 在支持的交互式终端中通过伪终端自动填写登录密码,不会打印密码或将其放入进程参数。
195
211
  - 自动登录会接受首次出现的 SSH 主机密钥;已记录主机的密钥发生变化时仍会拒绝连接。
196
212
  - `instance ssh INSTANCE_ID -- COMMAND` 可非交互执行远程命令,透传命令输出和退出码。
213
+ - `--json instance ssh INSTANCE_ID -- COMMAND` 会真实执行命令,并结构化返回退出码、标准输出、标准错误及连接/认证错误阶段。
214
+ - `--json` 始终以 UTF-8 字节输出,不依赖 Windows 活动代码页或终端编码。
215
+ - `instance ssh` 默认将 API 返回的 SSH 命令和密码按 profile/实例缓存 1 小时;Windows 使用当前用户的 DPAPI 加密密码,其他平台将缓存文件权限限制为当前用户。可用 `--refresh` 强制更新、`--no-cache` 禁用;重置密码和重装实例会清除对应缓存。
216
+ - 实例创建是异步操作;`instance create` 默认等待到 `Running`,显式使用 `--no-wait` 才会在创建接口返回后立即退出。`instance ssh` 也会默认等待实例运行后再连接。
197
217
  - `instance scp INSTANCE_ID LOCAL_PATH REMOTE_PATH` 可自动认证并上传本地文件或目录。
198
218
  - `--show-sensitive` 会恢复这些字段的原始值;请勿在共享终端、CI 日志或 Agent 会话中使用。
199
219
  - 删除、关机、重启、重装和改配等操作默认要求确认。
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "compshare-cli"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "Command-line client for CompShare GPU compute"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """CompShare command-line client."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.2"
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ import platform
4
5
  import shutil
5
6
  import stat
6
7
  import sys
@@ -61,7 +62,18 @@ def run(state: Runtime) -> None:
61
62
  )
62
63
  )
63
64
 
64
- if store.path.exists():
65
+ if store.path.exists() and platform.system() == "Windows":
66
+ checks.append(
67
+ _check(
68
+ "Permissions",
69
+ "Warning",
70
+ tr(
71
+ "Config {path}: Windows ACLs apply; POSIX permission check skipped.",
72
+ path=store.path,
73
+ ),
74
+ )
75
+ )
76
+ elif store.path.exists():
65
77
  file_mode = stat.S_IMODE(store.path.stat().st_mode)
66
78
  directory_mode = stat.S_IMODE(store.path.parent.stat().st_mode)
67
79
  permissions_ok = file_mode & 0o077 == 0 and directory_mode & 0o077 == 0
@@ -1,10 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import shlex
4
5
  import subprocess
5
6
  import sys
6
7
  import time
7
8
  from decimal import Decimal, InvalidOperation
9
+ from hashlib import sha256
8
10
  from pathlib import Path
9
11
  from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, TypeVar
10
12
 
@@ -12,6 +14,7 @@ import typer
12
14
 
13
15
  from compshare_cli.api import call, call_captured, collect_pages, invoke
14
16
  from compshare_cli.commands.common import confirm, confirm_details, request, runtime
17
+ from compshare_cli.config import ConfigStore
15
18
  from compshare_cli.errors import UsageError
16
19
  from compshare_cli.i18n import tr
17
20
  from compshare_cli.location import instance_location, locate_instance, supported_locations
@@ -30,9 +33,12 @@ from compshare_cli.ssh import (
30
33
  PasswordAutomationUnavailable,
31
34
  connect_with_password,
32
35
  copy_with_password,
36
+ execute_captured,
37
+ execute_captured_with_password,
33
38
  execute_with_password,
34
39
  scp_upload_command,
35
40
  )
41
+ from compshare_cli.ssh_cache import DEFAULT_TTL, SSHCredentialCache
36
42
 
37
43
  app = typer.Typer(help="Manage GPU instances.", no_args_is_help=True)
38
44
  ports_app = typer.Typer(help="Manage container port mappings.", no_args_is_help=True)
@@ -203,6 +209,41 @@ def _wait_enabled(state: Runtime, value: Optional[bool]) -> bool:
203
209
  return not state.json_output and sys.stdout.isatty()
204
210
 
205
211
 
212
+ def _create_wait_enabled(value: Optional[bool]) -> bool:
213
+ """Creation is asynchronous, so wait by default in both human and Agent modes."""
214
+ return True if value is None else value
215
+
216
+
217
+ def _ssh_cache_profile(state: Runtime) -> str:
218
+ name = (
219
+ state.profile_name or os.environ.get("COMPSHARE_PROFILE") or ConfigStore().current_profile()
220
+ )
221
+ credential = sha256(state.profile.public_key.encode("utf-8")).hexdigest()[:16]
222
+ return f"{name}:{credential}"
223
+
224
+
225
+ def _locate_ssh_instance(
226
+ state: Runtime,
227
+ instance: str,
228
+ *,
229
+ use_cache: bool,
230
+ refresh: bool,
231
+ cache_ttl: int,
232
+ ) -> Tuple[str, str, Dict[str, Any], str]:
233
+ cache = SSHCredentialCache()
234
+ profile = _ssh_cache_profile(state)
235
+ if use_cache and not refresh:
236
+ host = cache.get(profile, instance, ttl=cache_ttl)
237
+ if host is not None:
238
+ region, zone = instance_location(host, instance)
239
+ return region, zone, host, "cache"
240
+
241
+ region, zone, host = locate_instance(state, instance)
242
+ if use_cache:
243
+ cache.put(profile, instance, host)
244
+ return region, zone, host, "api"
245
+
246
+
206
247
  def _wait_for_instance(
207
248
  state: Runtime,
208
249
  instance: str,
@@ -937,9 +978,16 @@ def create(
937
978
  ids = created.get("UHostIds") or created.get("UHostId") or []
938
979
  if isinstance(ids, str):
939
980
  ids = [ids]
940
- if _wait_enabled(state, wait):
981
+ if _create_wait_enabled(wait):
941
982
  result["final"] = [
942
- _wait_for_instance(state, item, region=region, timeout=timeout) for item in ids
983
+ _wait_for_instance(
984
+ state,
985
+ item,
986
+ region=region,
987
+ desired={"Running"},
988
+ timeout=timeout,
989
+ )
990
+ for item in ids
943
991
  ]
944
992
  Renderer(state.json_output, state.show_sensitive).details(
945
993
  "Operation completed",
@@ -1286,6 +1334,7 @@ def password(
1286
1334
  yes=yes,
1287
1335
  extra={"Password": encode_password(secret)},
1288
1336
  )
1337
+ SSHCredentialCache().delete(_ssh_cache_profile(runtime(ctx)), instance)
1289
1338
 
1290
1339
 
1291
1340
  @app.command("reinstall", help="Reinstall an instance from an image.")
@@ -1318,6 +1367,7 @@ def reinstall(
1318
1367
  wait=wait,
1319
1368
  timeout=timeout,
1320
1369
  )
1370
+ SSHCredentialCache().delete(_ssh_cache_profile(runtime(ctx)), instance)
1321
1371
 
1322
1372
 
1323
1373
  @app.command("resize", help="Change instance CPU, memory, GPU or disk size.")
@@ -1758,16 +1808,65 @@ def ssh(
1758
1808
  "--auto-password/--no-auto-password",
1759
1809
  help="Automatically enter the password returned by the API.",
1760
1810
  ),
1811
+ wait: bool = typer.Option(
1812
+ True,
1813
+ "--wait/--no-wait",
1814
+ help="Wait for the instance to be running before connecting.",
1815
+ ),
1816
+ timeout: int = typer.Option(600, "--timeout", min=1, help="Maximum wait time in seconds."),
1817
+ connect_timeout: int = typer.Option(
1818
+ 30,
1819
+ "--connect-timeout",
1820
+ min=1,
1821
+ help="Maximum SSH connection time in seconds.",
1822
+ ),
1823
+ use_cache: bool = typer.Option(
1824
+ True,
1825
+ "--cache/--no-cache",
1826
+ help="Cache SSH connection data to avoid repeated instance queries.",
1827
+ ),
1828
+ cache_ttl: int = typer.Option(
1829
+ DEFAULT_TTL,
1830
+ "--cache-ttl",
1831
+ min=1,
1832
+ help="SSH connection cache lifetime in seconds.",
1833
+ ),
1834
+ refresh: bool = typer.Option(
1835
+ False,
1836
+ "--refresh",
1837
+ help="Refresh SSH connection data from the API before connecting.",
1838
+ ),
1761
1839
  ) -> None:
1762
1840
  state = runtime(ctx)
1763
- region, zone, host = locate_instance(state, instance)
1841
+ region, zone, host, credential_source = _locate_ssh_instance(
1842
+ state,
1843
+ instance,
1844
+ use_cache=use_cache,
1845
+ refresh=refresh,
1846
+ cache_ttl=cache_ttl,
1847
+ )
1848
+ if wait and host.get("State") and str(host.get("State")) != "Running":
1849
+ ready = _wait_for_instance(
1850
+ state,
1851
+ instance,
1852
+ region=region,
1853
+ desired={"Running"},
1854
+ timeout=timeout,
1855
+ )
1856
+ hosts = ready.get("UHostSet") or []
1857
+ if hosts:
1858
+ host = hosts[0]
1859
+ if use_cache:
1860
+ SSHCredentialCache().put(_ssh_cache_profile(state), instance, host)
1764
1861
  command = host.get("SshLoginCommand")
1765
1862
  raw_password = host.get("Password")
1766
1863
  password = decode_password(str(raw_password)) if raw_password is not None else None
1767
1864
  if not command:
1768
1865
  raise UsageError(tr("Instance {instance} has no SSH login command.", instance=instance))
1769
1866
  argv = [*shlex.split(command), *(remote_command or [])]
1770
- if print_only or state.json_output:
1867
+ if remote_command:
1868
+ argv[1:1] = ["-o", f"ConnectTimeout={connect_timeout}"]
1869
+ if print_only or (state.json_output and not remote_command):
1771
1870
  Renderer(state.json_output, state.show_sensitive).data(
1772
1871
  {
1773
1872
  "instance": instance,
@@ -1775,9 +1874,43 @@ def ssh(
1775
1874
  if not remote_command
1776
1875
  else f"{command} {shlex.join(remote_command)}",
1777
1876
  "password": password,
1877
+ "credential_source": credential_source,
1778
1878
  }
1779
1879
  )
1780
1880
  return
1881
+ if remote_command and state.json_output:
1882
+ execution = (
1883
+ execute_captured_with_password(argv, str(password))
1884
+ if password and auto_password
1885
+ else execute_captured(argv)
1886
+ )
1887
+ error = None
1888
+ if not execution.ok:
1889
+ message = execution.stderr.strip() or f"SSH exited with status {execution.exit_code}."
1890
+ error = {
1891
+ "phase": execution.phase,
1892
+ "code": execution.error_code,
1893
+ "message": message,
1894
+ }
1895
+ if credential_source == "cache" and execution.phase in {
1896
+ "authentication",
1897
+ "connection",
1898
+ "ssh",
1899
+ }:
1900
+ SSHCredentialCache().delete(_ssh_cache_profile(state), instance)
1901
+ Renderer(True, state.show_sensitive).data(
1902
+ {
1903
+ "instance": instance,
1904
+ "ok": execution.ok,
1905
+ "phase": execution.phase,
1906
+ "exit_code": execution.exit_code,
1907
+ "stdout": execution.stdout,
1908
+ "stderr": execution.stderr,
1909
+ "error": error,
1910
+ "credential_source": credential_source,
1911
+ }
1912
+ )
1913
+ raise typer.Exit(execution.exit_code)
1781
1914
  if password and state.show_sensitive:
1782
1915
  typer.echo(f"Password: {password}")
1783
1916
  if password and auto_password:
@@ -1795,6 +1928,8 @@ def ssh(
1795
1928
  err=True,
1796
1929
  )
1797
1930
  else:
1931
+ if exit_code == 255 and credential_source == "cache":
1932
+ SSHCredentialCache().delete(_ssh_cache_profile(state), instance)
1798
1933
  raise typer.Exit(exit_code)
1799
1934
  if password and not state.show_sensitive:
1800
1935
  typer.echo(tr("Password hidden; rerun with --show-sensitive to display it."))
@@ -1806,7 +1941,10 @@ def ssh(
1806
1941
  instance=instance,
1807
1942
  )
1808
1943
  )
1809
- raise typer.Exit(subprocess.call(argv))
1944
+ exit_code = subprocess.call(argv)
1945
+ if exit_code == 255 and credential_source == "cache":
1946
+ SSHCredentialCache().delete(_ssh_cache_profile(state), instance)
1947
+ raise typer.Exit(exit_code)
1810
1948
 
1811
1949
 
1812
1950
  @app.command("scp", help="Copy a local file or directory to an instance.")
@@ -93,6 +93,15 @@ ZH_TRANSLATIONS: Dict[str, str] = {
93
93
  "Month, Day, Dynamic or Postpay.": "目标计费方式:Month、Day、Dynamic 或 Postpay。",
94
94
  "Print instead of executing SSH.": "只显示 SSH 命令,不执行连接。",
95
95
  "Automatically enter the password returned by the API.": "自动填写 API 返回的登录密码。",
96
+ "Wait for the instance to be running before connecting.": "连接前等待实例进入运行状态。",
97
+ "Maximum SSH connection time in seconds.": "SSH 建立连接的最长时间(秒)。",
98
+ "Cache SSH connection data to avoid repeated instance queries.": (
99
+ "缓存 SSH 连接信息,避免重复查询实例。"
100
+ ),
101
+ "SSH connection cache lifetime in seconds.": "SSH 连接缓存的有效期(秒)。",
102
+ "Refresh SSH connection data from the API before connecting.": (
103
+ "连接前从 API 强制刷新 SSH 连接信息。"
104
+ ),
96
105
  "Remote command and arguments; use -- before command options.": (
97
106
  "要在实例中执行的命令及参数;命令包含选项时请先使用 -- 分隔。"
98
107
  ),
@@ -618,6 +627,9 @@ ZH_TRANSLATIONS: Dict[str, str] = {
618
627
  "Config {path}: file {file_mode}, directory {directory_mode}": (
619
628
  "配置 {path}:文件权限 {file_mode},目录权限 {directory_mode}"
620
629
  ),
630
+ "Config {path}: Windows ACLs apply; POSIX permission check skipped.": (
631
+ "配置 {path}:由 Windows ACL 管理访问权限,已跳过 POSIX 权限位检查"
632
+ ),
621
633
  "Using environment credentials": "正在使用环境变量凭证",
622
634
  "Config file does not exist: {path}": "配置文件不存在:{path}",
623
635
  "Connected; {count} availability zones returned.": "连接成功,返回 {count} 个可用区。",
@@ -65,6 +65,22 @@ def sanitized(value: Any, *, show_sensitive: bool = False) -> Any:
65
65
  return value
66
66
 
67
67
 
68
+ def _write_json(payload: Dict[str, Any]) -> None:
69
+ """Write one UTF-8 JSON document independently of the console code page."""
70
+ document = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
71
+ stream = sys.stdout
72
+ binary = getattr(stream, "buffer", None)
73
+ if binary is not None:
74
+ stream.flush()
75
+ binary.write(document.encode("utf-8"))
76
+ binary.flush()
77
+ return
78
+ # String-only test/application streams have no byte encoding contract. Escaping
79
+ # non-ASCII keeps this fallback valid UTF-8 under every ASCII-compatible encoding.
80
+ stream.write(json.dumps(payload, ensure_ascii=True, separators=(",", ":")) + "\n")
81
+ stream.flush()
82
+
83
+
68
84
  class Renderer:
69
85
  def __init__(self, json_output: bool, show_sensitive: bool = False) -> None:
70
86
  self.json_output = json_output
@@ -80,7 +96,7 @@ class Renderer:
80
96
  ) -> None:
81
97
  safe = sanitized(response, show_sensitive=self.show_sensitive)
82
98
  if self.json_output:
83
- sys.stdout.write(json.dumps(safe, ensure_ascii=False, separators=(",", ":")) + "\n")
99
+ _write_json(safe)
84
100
  return
85
101
  if rows is not None and columns:
86
102
  self.table(rows, columns)
@@ -139,7 +155,7 @@ class Renderer:
139
155
  details,
140
156
  show_sensitive=self.show_sensitive,
141
157
  )
142
- sys.stdout.write(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n")
158
+ _write_json(payload)
143
159
  else:
144
160
  Console(stderr=True).print(f"[red]{tr('Error')}:[/red] {message}")
145
161
 
@@ -7,6 +7,7 @@ import signal
7
7
  import subprocess
8
8
  import sys
9
9
  import tempfile
10
+ from dataclasses import dataclass
10
11
  from typing import Any, List, Optional
11
12
 
12
13
  _ASKPASS_PASSWORD_FILE_ENV = "COMPSHARE_INTERNAL_SSH_PASSWORD_FILE"
@@ -40,6 +41,25 @@ class PasswordAutomationUnavailable(RuntimeError):
40
41
  """Raised when the current terminal cannot safely automate an SSH password."""
41
42
 
42
43
 
44
+ @dataclass(frozen=True)
45
+ class RemoteExecutionResult:
46
+ """Captured output and an Agent-friendly classification of an SSH execution."""
47
+
48
+ exit_code: int
49
+ stdout: str
50
+ stderr: str
51
+ phase: str
52
+ error_code: Optional[str] = None
53
+
54
+ @property
55
+ def ok(self) -> bool:
56
+ return self.exit_code == 0
57
+
58
+
59
+ def _is_windows() -> bool:
60
+ return os.name == "nt"
61
+
62
+
43
63
  def _password_authentication(argv: List[str], *, command_mode: bool = False) -> List[str]:
44
64
  executable = os.path.basename(argv[0]).casefold()
45
65
  if executable not in {"ssh", "ssh.exe"}:
@@ -133,7 +153,7 @@ def scp_upload_command(
133
153
 
134
154
 
135
155
  def _askpass_executable() -> str:
136
- name = "compshare-ssh-askpass.exe" if os.name == "nt" else "compshare-ssh-askpass"
156
+ name = "compshare-ssh-askpass.exe" if _is_windows() else "compshare-ssh-askpass"
137
157
  sibling = os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), name)
138
158
  if os.path.isfile(sibling) and os.access(sibling, os.X_OK):
139
159
  return sibling
@@ -167,13 +187,70 @@ def execute_with_password(argv: List[str], password: str) -> int:
167
187
  return _run_with_askpass(command, password)
168
188
 
169
189
 
190
+ def execute_captured_with_password(argv: List[str], password: str) -> RemoteExecutionResult:
191
+ """Run and capture a non-interactive SSH command without exposing its password."""
192
+ command = _password_authentication(argv, command_mode=True)
193
+ completed = _run_with_askpass(command, password, capture=True)
194
+ assert isinstance(completed, subprocess.CompletedProcess)
195
+ return remote_execution_result(completed.returncode, completed.stdout, completed.stderr)
196
+
197
+
198
+ def execute_captured(argv: List[str]) -> RemoteExecutionResult:
199
+ """Run and capture a non-interactive SSH command using normal OpenSSH auth."""
200
+ executable = os.path.basename(argv[0]).casefold()
201
+ if executable not in {"ssh", "ssh.exe"}:
202
+ raise PasswordAutomationUnavailable
203
+ command = [
204
+ argv[0],
205
+ "-o",
206
+ "StrictHostKeyChecking=accept-new",
207
+ "-n",
208
+ "-T",
209
+ *argv[1:],
210
+ ]
211
+ completed = subprocess.run(command, capture_output=True, text=True, check=False)
212
+ return remote_execution_result(completed.returncode, completed.stdout, completed.stderr)
213
+
214
+
215
+ def remote_execution_result(exit_code: int, stdout: str, stderr: str) -> RemoteExecutionResult:
216
+ """Classify common OpenSSH failures while preserving the original diagnostics."""
217
+ if exit_code == 0:
218
+ return RemoteExecutionResult(exit_code, stdout, stderr, "completed")
219
+
220
+ diagnostic = stderr.casefold()
221
+ patterns = (
222
+ ("could not resolve hostname", "connection", "dns_resolution_failed"),
223
+ ("name or service not known", "connection", "dns_resolution_failed"),
224
+ ("connection timed out", "connection", "connection_timeout"),
225
+ ("operation timed out", "connection", "connection_timeout"),
226
+ ("no route to host", "connection", "network_unreachable"),
227
+ ("network is unreachable", "connection", "network_unreachable"),
228
+ ("connection refused", "connection", "connection_refused"),
229
+ ("host key verification failed", "connection", "host_key_verification_failed"),
230
+ ("permission denied", "authentication", "authentication_failed"),
231
+ ("authentication failed", "authentication", "authentication_failed"),
232
+ ("too many authentication failures", "authentication", "authentication_failed"),
233
+ )
234
+ for marker, phase, error_code in patterns:
235
+ if marker in diagnostic:
236
+ return RemoteExecutionResult(exit_code, stdout, stderr, phase, error_code)
237
+ if exit_code == 255:
238
+ return RemoteExecutionResult(exit_code, stdout, stderr, "ssh", "ssh_failed")
239
+ return RemoteExecutionResult(exit_code, stdout, stderr, "remote_command", "remote_exit_nonzero")
240
+
241
+
170
242
  def copy_with_password(argv: List[str], password: str) -> int:
171
243
  """Run an SCP upload using the API-provided password."""
172
244
  command = _scp_password_authentication(argv)
173
245
  return _run_with_askpass(command, password)
174
246
 
175
247
 
176
- def _run_with_askpass(command: List[str], password: str) -> int:
248
+ def _run_with_askpass(
249
+ command: List[str],
250
+ password: str,
251
+ *,
252
+ capture: bool = False,
253
+ ) -> Any:
177
254
  with tempfile.TemporaryDirectory(prefix="compshare-ssh-") as temporary_directory:
178
255
  password_file = os.path.join(temporary_directory, "password")
179
256
  descriptor = os.open(password_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
@@ -188,12 +265,22 @@ def _run_with_askpass(command: List[str], password: str) -> int:
188
265
  }
189
266
  )
190
267
  environment.setdefault("DISPLAY", "compshare-ssh")
268
+ if capture:
269
+ return subprocess.run(
270
+ command,
271
+ env=environment,
272
+ capture_output=True,
273
+ text=True,
274
+ check=False,
275
+ )
191
276
  return subprocess.call(command, env=environment)
192
277
 
193
278
 
194
279
  def connect_with_password(argv: List[str], password: str) -> int:
195
280
  """Run SSH interactively and answer its first password prompt."""
196
- if os.name == "nt" or not sys.stdin.isatty() or not sys.stdout.isatty():
281
+ if _is_windows():
282
+ return _run_with_askpass(_password_authentication(argv), password)
283
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
197
284
  raise PasswordAutomationUnavailable
198
285
 
199
286
  try: