remote-cmd-manager 2.1.0__tar.gz → 2.2.0__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 (81) hide show
  1. {remote_cmd_manager-2.1.0/remote_cmd_manager.egg-info → remote_cmd_manager-2.2.0}/PKG-INFO +37 -30
  2. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/README.md +29 -25
  3. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/pyproject.toml +10 -5
  4. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/__init__.py +1 -1
  5. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/_version.py +1 -1
  6. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/core/async_connection_pool.py +5 -3
  7. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/core/ssh_client.py +8 -5
  8. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/core/sync_connection_pool.py +5 -3
  9. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/repository/sqlite_host_repository.py +50 -7
  10. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/service/async_batch_executor.py +96 -85
  11. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/service/batch_executor.py +120 -113
  12. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/service/retry_policy.py +14 -7
  13. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/utils/exceptions.py +28 -3
  14. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0/remote_cmd_manager.egg-info}/PKG-INFO +37 -30
  15. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd_manager.egg-info/SOURCES.txt +1 -0
  16. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd_manager.egg-info/requires.txt +1 -1
  17. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_async_batch_executor.py +142 -6
  18. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_batch_executor.py +182 -1
  19. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_crypto.py +7 -1
  20. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_exceptions.py +16 -0
  21. remote_cmd_manager-2.2.0/tests/test_release_metadata.py +69 -0
  22. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_retry_policy.py +17 -1
  23. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_sqlite_repository.py +167 -0
  24. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_sync_connection_pool.py +19 -7
  25. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/LICENSE +0 -0
  26. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/MANIFEST.in +0 -0
  27. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/config.example.yaml +0 -0
  28. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/examples/basic_usage.py +0 -0
  29. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/examples/deploy_script.py +0 -0
  30. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/examples/nginx_batch_update.py +0 -0
  31. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/examples/system_health_check.py +0 -0
  32. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/cli/__init__.py +0 -0
  33. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/cli/main.py +0 -0
  34. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/core/__init__.py +0 -0
  35. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/core/async_ssh_client.py +0 -0
  36. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/core/host.py +0 -0
  37. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/core/host_manager.py +0 -0
  38. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/repository/__init__.py +0 -0
  39. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/repository/host_repository.py +0 -0
  40. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/repository/json_host_repository.py +0 -0
  41. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/service/__init__.py +0 -0
  42. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/service/_host_runner.py +0 -0
  43. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/service/_pool_policy.py +0 -0
  44. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/service/_types.py +0 -0
  45. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/service/credential_provider.py +0 -0
  46. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/service/host_service.py +0 -0
  47. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/service/ssh_service.py +0 -0
  48. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/service/storage_factory.py +0 -0
  49. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/service/task_runner.py +0 -0
  50. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/utils/__init__.py +0 -0
  51. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/utils/config.py +0 -0
  52. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/utils/credential_guard.py +0 -0
  53. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/utils/crypto.py +0 -0
  54. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd/utils/logging_utils.py +0 -0
  55. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd_manager.egg-info/dependency_links.txt +0 -0
  56. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd_manager.egg-info/entry_points.txt +0 -0
  57. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/remote_cmd_manager.egg-info/top_level.txt +0 -0
  58. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/requirements.txt +0 -0
  59. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/setup.cfg +0 -0
  60. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/__init__.py +0 -0
  61. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/conftest.py +0 -0
  62. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/integration/conftest.py +0 -0
  63. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/integration/test_ssh_connection.py +0 -0
  64. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/performance/__init__.py +0 -0
  65. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/performance/conftest.py +0 -0
  66. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/performance/test_benchmarks.py +0 -0
  67. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_async_ssh_client.py +0 -0
  68. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_cli.py +0 -0
  69. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_config.py +0 -0
  70. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_credential_provider.py +0 -0
  71. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_host.py +0 -0
  72. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_host_manager.py +0 -0
  73. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_host_service.py +0 -0
  74. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_keyring_provider.py +0 -0
  75. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_logging_utils.py +0 -0
  76. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_pool_policy.py +0 -0
  77. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_repository.py +0 -0
  78. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_ssh_client.py +0 -0
  79. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_ssh_service.py +0 -0
  80. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_storage_factory.py +0 -0
  81. {remote_cmd_manager-2.1.0 → remote_cmd_manager-2.2.0}/tests/test_task_runner.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remote_cmd_manager
3
- Version: 2.1.0
4
- Summary: SSH 远程服务器管理工具 / Python SSH remote server management tool
3
+ Version: 2.2.0
4
+ Summary: Cross-platform SSH remote server management tool (Linux/macOS/Windows) — lightweight, zero-configuration CLI and Python API for managing servers over SSH. Supports host CRUD, batch commands, file transfer, credential encryption, async execution, and more.
5
5
  Author-email: Vae-Scrooge <vaescrooge@gmail.com>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/Vae-Scrooge/remote-cmd
@@ -9,8 +9,8 @@ Project-URL: Repository, https://github.com/Vae-Scrooge/remote-cmd
9
9
  Project-URL: Documentation, https://github.com/Vae-Scrooge/remote-cmd#readme
10
10
  Project-URL: Bug Tracker, https://github.com/Vae-Scrooge/remote-cmd/issues
11
11
  Project-URL: Download, https://pypi.org/project/remote_cmd_manager/
12
- Keywords: ssh,remote,server-management,sysadmin,devops
13
- Classifier: Development Status :: 4 - Beta
12
+ Keywords: ssh,remote,server-management,sysadmin,devops,cross-platform
13
+ Classifier: Development Status :: 5 - Production/Stable
14
14
  Classifier: Intended Audience :: Developers
15
15
  Classifier: Intended Audience :: System Administrators
16
16
  Classifier: License :: OSI Approved :: MIT License
@@ -21,6 +21,9 @@ Classifier: Programming Language :: Python :: 3.11
21
21
  Classifier: Topic :: System :: Systems Administration
22
22
  Classifier: Topic :: System :: Networking
23
23
  Classifier: Environment :: Console
24
+ Classifier: Operating System :: POSIX :: Linux
25
+ Classifier: Operating System :: MacOS
26
+ Classifier: Operating System :: Microsoft :: Windows
24
27
  Requires-Python: >=3.9
25
28
  Description-Content-Type: text/markdown
26
29
  License-File: LICENSE
@@ -42,7 +45,7 @@ Requires-Dist: ruff>=0.4; extra == "dev"
42
45
  Requires-Dist: mypy>=1.10; extra == "dev"
43
46
  Requires-Dist: keyring>=24.0; extra == "dev"
44
47
  Provides-Extra: docs
45
- Requires-Dist: pdoc<16,>=15; extra == "docs"
48
+ Requires-Dist: pdoc==15.0.4; extra == "docs"
46
49
  Dynamic: license-file
47
50
 
48
51
  <p align="center">
@@ -54,6 +57,7 @@ Dynamic: license-file
54
57
  <img src="https://img.shields.io/github/actions/workflow/status/Vae-Scrooge/remote-cmd/ci.yml?style=for-the-badge&logo=githubactions&label=CI" alt="CI">
55
58
  </p>
56
59
 
60
+ <p align="center"><strong>Tested on Linux, macOS, and Windows</strong></p>
57
61
  <h1 align="center">Remote CMD — SSH Server Management<br><small>Without the Overhead</small></h1>
58
62
 
59
63
  <p align="center">
@@ -71,12 +75,6 @@ Dynamic: license-file
71
75
  <a href="#contributing">Contributing</a>
72
76
  </p>
73
77
 
74
- <p align="center">
75
- <a href="https://asciinema.org/a/9yLeYj73muPUuAQY" target="_blank">
76
- <img src="https://asciinema.org/a/9yLeYj73muPUuAQY.svg" width="720" alt="Demo">
77
- </a>
78
- </p>
79
-
80
78
  ---
81
79
 
82
80
  **Remote CMD** is a lightweight Python CLI + API for managing servers over SSH. Add hosts, run commands, transfer files, and organize hosts with tags — no Ansible DSL or shell loops required.
@@ -88,32 +86,27 @@ pip install remote_cmd_manager && remote-cmd host add web-01 192.168.1.10 ubuntu
88
86
 
89
87
  ---
90
88
 
91
- ## v2.1.0 Release Highlights
89
+ ## v2.2.0 Release Highlights
92
90
 
93
- v2.1.0 contains both major implementation changes and a final release-hardening pass. See the
91
+ v2.2.0 bounds batch-execution resource usage, makes concurrent SQLite writers safe, tightens
92
+ retry classification, and hardens the release pipeline. See the
94
93
  [full migration notes](./CHANGELOG.md) before upgrading automated callers.
95
94
 
96
- ### Major Implementation Changes
95
+ ### Resource & Reliability
97
96
 
98
- - Paramiko command execution drains stdout and stderr before retrieving the exit status, preventing SSH channel-window deadlocks on large output.
99
- - Paramiko command timeouts are wall-clock enforced; a silent or hung command closes its channel and raises `SSHCommandTimeoutError`.
100
- - `AsyncBatchExecutor` now uses `AsyncConnectionPool` for multi-host and retry workloads, reusing connections across attempts.
101
- - `pool_factory` enables caller-supplied pools; external pools are caller-owned and never closed by either executor, while internally created pools are closed automatically after `execute()`.
102
- - Retries classify permanent failures (authentication, credentials, configuration, validation, and programming errors) as non-retryable; unknown `Exception` subclasses remain retryable for backward compatibility. `retry_delay` is now the exponential-backoff base and full jitter is applied with a 60-second cap.
103
- - Unknown hosts produce per-host failure results instead of aborting a multi-host batch, and duplicate host names are executed once.
104
- - The exception hierarchy adds `SSHAuthenticationError`, `SSHTimeoutError`, `SSHCommandTimeoutError`, `CredentialError`, and the `ConfigurationError` alias without removing existing catch paths.
105
- - Environment-variable names are validated before shell interpolation, and command text is excluded from library logs and execution errors.
106
- - `remote-cmd run` supports `--timeout/-T` for command execution limits.
97
+ - Internal batch pools are now created lazily per host, capped at one connection, and closed as soon as that host (including all retries) finishes; batch-wide live internal connections are bounded by `max_concurrency` instead of retaining one idle connection per host until the batch ends.
98
+ - SQLite writes now use `BEGIN IMMEDIATE` with a configurable `busy_timeout` (default 5000 ms), and the initial `journal_mode=WAL` switch is retried; concurrent `remote-cmd` processes writing the same `hosts.db` no longer fail with `database is locked` or lose updates. Schema and `db_version` are unchanged.
99
+ - Pool closure raises the new `PoolClosedError` (still catchable as `RuntimeError`); retry classification is corrected so bare `RuntimeError` is retryable again (v2.0 behavior) while pool closure remains non-retryable. Typed permanent and transient errors are unchanged.
107
100
 
108
- ### Final Release Hardening
101
+ ### Release Engineering
109
102
 
110
- - Sync and async pools re-check their closed state after semaphore acquisition, preventing a shutdown race from issuing a connection from a closed pool.
111
- - `BatchExecutor(use_async=True)` now reports an actionable error when called from an active event loop; use `AsyncBatchExecutor.execute()` there instead.
112
- - The Paramiko stderr drain join is bounded to prevent an exceptional reader path from blocking the caller indefinitely.
103
+ - Publish workflow: artifact actions aligned, run-scoped artifact names, duplicate version publication now fails instead of being skipped, `twine check` validates distributions, and the release tag is verified against the package version. Trusted Publishing and the GitHub Release → PyPI flow are unchanged.
104
+ - CI documentation drift detection: `docs/api` is regenerated and compared on relevant changes, failing when tracked generated docs are stale (`pdoc` pinned to `15.0.4` on Python 3.12).
105
+ - Release metadata validation: a lightweight gate checks the single-source version, the `pyproject.toml` dynamic-version attribute, `[Unreleased]` and matching `[x.y.z]` CHANGELOG headings, and (on release) that the git tag matches the package version.
113
106
 
114
107
  ## Table of Contents
115
108
 
116
- - [v2.1.0 Release Highlights](#v210-release-highlights)
109
+ - [v2.2.0 Release Highlights](#v220-release-highlights)
117
110
  - [Why Remote CMD?](#why-remote-cmd)
118
111
  - [Quick Start](#quick-start)
119
112
  - [Use Cases](#use-cases)
@@ -270,6 +263,20 @@ with SSHClient(config) as client:
270
263
 
271
264
  ---
272
265
 
266
+ ## Cross-Platform Support
267
+
268
+ `remote-cmd` is a pure-Python client, **tested and supported on Linux, macOS, and Windows**. No native dependencies or platform-specific builds are required.
269
+
270
+ | Platform | Status | Notes |
271
+ |----------|--------|-------|
272
+ | Linux | ✅ Fully tested | Primary development platform |
273
+ | macOS | ✅ Supported | Use `brew install openssh` for a newer OpenSSH |
274
+ | Windows | ✅ Supported | PowerShell / CMD / WSL all work |
275
+
276
+ > **Note on Windows file permissions:** Credential encryption keys are protected with `0600` permissions on Unix. On Windows this restriction is not enforced by the filesystem — the key file is still encrypted at rest. For production Windows use, consider adding filesystem-level ACLs.
277
+
278
+ ---
279
+
273
280
  ## Installation
274
281
 
275
282
  ```bash
@@ -309,14 +316,14 @@ are simply not exported.
309
316
  | [Changelog](./CHANGELOG.md) | Release history |
310
317
  | [Mobile Remote Guide](./MOBILE-REMOTE-GUIDE.md) | Manage servers from your phone |
311
318
 
312
- > **Note:** The documentation center and tutorials are maintained in **Chinese**. See
319
+ > **Note:** The documentation center and tutorials are maintained in **English**. See
313
320
  > [README.zh-CN.md](./README.zh-CN.md) for the Chinese version of this page.
314
321
 
315
322
  ---
316
323
 
317
324
  ## Project Status
318
325
 
319
- **Beta.** The core API is stable. Breaking changes will be communicated via semantic versioning.
326
+ **Stable.** The core API is stable and versioned under semantic versioning. Breaking changes are communicated via major-version bumps, and the public API surface has been stable since the 1.x line.
320
327
 
321
328
  **Roadmap:**
322
329
  - [x] Async SSH operations (parallel execution) — v1.1.0
@@ -7,6 +7,7 @@
7
7
  <img src="https://img.shields.io/github/actions/workflow/status/Vae-Scrooge/remote-cmd/ci.yml?style=for-the-badge&logo=githubactions&label=CI" alt="CI">
8
8
  </p>
9
9
 
10
+ <p align="center"><strong>Tested on Linux, macOS, and Windows</strong></p>
10
11
  <h1 align="center">Remote CMD — SSH Server Management<br><small>Without the Overhead</small></h1>
11
12
 
12
13
  <p align="center">
@@ -24,12 +25,6 @@
24
25
  <a href="#contributing">Contributing</a>
25
26
  </p>
26
27
 
27
- <p align="center">
28
- <a href="https://asciinema.org/a/9yLeYj73muPUuAQY" target="_blank">
29
- <img src="https://asciinema.org/a/9yLeYj73muPUuAQY.svg" width="720" alt="Demo">
30
- </a>
31
- </p>
32
-
33
28
  ---
34
29
 
35
30
  **Remote CMD** is a lightweight Python CLI + API for managing servers over SSH. Add hosts, run commands, transfer files, and organize hosts with tags — no Ansible DSL or shell loops required.
@@ -41,32 +36,27 @@ pip install remote_cmd_manager && remote-cmd host add web-01 192.168.1.10 ubuntu
41
36
 
42
37
  ---
43
38
 
44
- ## v2.1.0 Release Highlights
39
+ ## v2.2.0 Release Highlights
45
40
 
46
- v2.1.0 contains both major implementation changes and a final release-hardening pass. See the
41
+ v2.2.0 bounds batch-execution resource usage, makes concurrent SQLite writers safe, tightens
42
+ retry classification, and hardens the release pipeline. See the
47
43
  [full migration notes](./CHANGELOG.md) before upgrading automated callers.
48
44
 
49
- ### Major Implementation Changes
45
+ ### Resource & Reliability
50
46
 
51
- - Paramiko command execution drains stdout and stderr before retrieving the exit status, preventing SSH channel-window deadlocks on large output.
52
- - Paramiko command timeouts are wall-clock enforced; a silent or hung command closes its channel and raises `SSHCommandTimeoutError`.
53
- - `AsyncBatchExecutor` now uses `AsyncConnectionPool` for multi-host and retry workloads, reusing connections across attempts.
54
- - `pool_factory` enables caller-supplied pools; external pools are caller-owned and never closed by either executor, while internally created pools are closed automatically after `execute()`.
55
- - Retries classify permanent failures (authentication, credentials, configuration, validation, and programming errors) as non-retryable; unknown `Exception` subclasses remain retryable for backward compatibility. `retry_delay` is now the exponential-backoff base and full jitter is applied with a 60-second cap.
56
- - Unknown hosts produce per-host failure results instead of aborting a multi-host batch, and duplicate host names are executed once.
57
- - The exception hierarchy adds `SSHAuthenticationError`, `SSHTimeoutError`, `SSHCommandTimeoutError`, `CredentialError`, and the `ConfigurationError` alias without removing existing catch paths.
58
- - Environment-variable names are validated before shell interpolation, and command text is excluded from library logs and execution errors.
59
- - `remote-cmd run` supports `--timeout/-T` for command execution limits.
47
+ - Internal batch pools are now created lazily per host, capped at one connection, and closed as soon as that host (including all retries) finishes; batch-wide live internal connections are bounded by `max_concurrency` instead of retaining one idle connection per host until the batch ends.
48
+ - SQLite writes now use `BEGIN IMMEDIATE` with a configurable `busy_timeout` (default 5000 ms), and the initial `journal_mode=WAL` switch is retried; concurrent `remote-cmd` processes writing the same `hosts.db` no longer fail with `database is locked` or lose updates. Schema and `db_version` are unchanged.
49
+ - Pool closure raises the new `PoolClosedError` (still catchable as `RuntimeError`); retry classification is corrected so bare `RuntimeError` is retryable again (v2.0 behavior) while pool closure remains non-retryable. Typed permanent and transient errors are unchanged.
60
50
 
61
- ### Final Release Hardening
51
+ ### Release Engineering
62
52
 
63
- - Sync and async pools re-check their closed state after semaphore acquisition, preventing a shutdown race from issuing a connection from a closed pool.
64
- - `BatchExecutor(use_async=True)` now reports an actionable error when called from an active event loop; use `AsyncBatchExecutor.execute()` there instead.
65
- - The Paramiko stderr drain join is bounded to prevent an exceptional reader path from blocking the caller indefinitely.
53
+ - Publish workflow: artifact actions aligned, run-scoped artifact names, duplicate version publication now fails instead of being skipped, `twine check` validates distributions, and the release tag is verified against the package version. Trusted Publishing and the GitHub Release → PyPI flow are unchanged.
54
+ - CI documentation drift detection: `docs/api` is regenerated and compared on relevant changes, failing when tracked generated docs are stale (`pdoc` pinned to `15.0.4` on Python 3.12).
55
+ - Release metadata validation: a lightweight gate checks the single-source version, the `pyproject.toml` dynamic-version attribute, `[Unreleased]` and matching `[x.y.z]` CHANGELOG headings, and (on release) that the git tag matches the package version.
66
56
 
67
57
  ## Table of Contents
68
58
 
69
- - [v2.1.0 Release Highlights](#v210-release-highlights)
59
+ - [v2.2.0 Release Highlights](#v220-release-highlights)
70
60
  - [Why Remote CMD?](#why-remote-cmd)
71
61
  - [Quick Start](#quick-start)
72
62
  - [Use Cases](#use-cases)
@@ -223,6 +213,20 @@ with SSHClient(config) as client:
223
213
 
224
214
  ---
225
215
 
216
+ ## Cross-Platform Support
217
+
218
+ `remote-cmd` is a pure-Python client, **tested and supported on Linux, macOS, and Windows**. No native dependencies or platform-specific builds are required.
219
+
220
+ | Platform | Status | Notes |
221
+ |----------|--------|-------|
222
+ | Linux | ✅ Fully tested | Primary development platform |
223
+ | macOS | ✅ Supported | Use `brew install openssh` for a newer OpenSSH |
224
+ | Windows | ✅ Supported | PowerShell / CMD / WSL all work |
225
+
226
+ > **Note on Windows file permissions:** Credential encryption keys are protected with `0600` permissions on Unix. On Windows this restriction is not enforced by the filesystem — the key file is still encrypted at rest. For production Windows use, consider adding filesystem-level ACLs.
227
+
228
+ ---
229
+
226
230
  ## Installation
227
231
 
228
232
  ```bash
@@ -262,14 +266,14 @@ are simply not exported.
262
266
  | [Changelog](./CHANGELOG.md) | Release history |
263
267
  | [Mobile Remote Guide](./MOBILE-REMOTE-GUIDE.md) | Manage servers from your phone |
264
268
 
265
- > **Note:** The documentation center and tutorials are maintained in **Chinese**. See
269
+ > **Note:** The documentation center and tutorials are maintained in **English**. See
266
270
  > [README.zh-CN.md](./README.zh-CN.md) for the Chinese version of this page.
267
271
 
268
272
  ---
269
273
 
270
274
  ## Project Status
271
275
 
272
- **Beta.** The core API is stable. Breaking changes will be communicated via semantic versioning.
276
+ **Stable.** The core API is stable and versioned under semantic versioning. Breaking changes are communicated via major-version bumps, and the public API surface has been stable since the 1.x line.
273
277
 
274
278
  **Roadmap:**
275
279
  - [x] Async SSH operations (parallel execution) — v1.1.0
@@ -5,16 +5,16 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "remote_cmd_manager"
7
7
  dynamic = ["version"]
8
- description = "SSH 远程服务器管理工具 / Python SSH remote server management tool"
8
+ description = "Cross-platform SSH remote server management tool (Linux/macOS/Windows) — lightweight, zero-configuration CLI and Python API for managing servers over SSH. Supports host CRUD, batch commands, file transfer, credential encryption, async execution, and more."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
11
11
  license = { text = "MIT" }
12
12
  authors = [
13
13
  { name = "Vae-Scrooge", email = "vaescrooge@gmail.com" },
14
14
  ]
15
- keywords = ["ssh", "remote", "server-management", "sysadmin", "devops"]
15
+ keywords = ["ssh", "remote", "server-management", "sysadmin", "devops", "cross-platform"]
16
16
  classifiers = [
17
- "Development Status :: 4 - Beta",
17
+ "Development Status :: 5 - Production/Stable",
18
18
  "Intended Audience :: Developers",
19
19
  "Intended Audience :: System Administrators",
20
20
  "License :: OSI Approved :: MIT License",
@@ -25,6 +25,9 @@ classifiers = [
25
25
  "Topic :: System :: Systems Administration",
26
26
  "Topic :: System :: Networking",
27
27
  "Environment :: Console",
28
+ "Operating System :: POSIX :: Linux",
29
+ "Operating System :: MacOS",
30
+ "Operating System :: Microsoft :: Windows",
28
31
  ]
29
32
  dependencies = [
30
33
  "paramiko>=3.0",
@@ -50,8 +53,10 @@ dev = [
50
53
  # 测试 keyring 凭据提供者(生产为可选依赖,测试环境固定安装)
51
54
  "keyring>=24.0",
52
55
  ]
53
- # 文档生成(pdoc>=16 需 Python>=3.10,固定到 15.x 以兼容 requires-python>=3.9)
54
- docs = ["pdoc>=15,<16"]
56
+ # 文档生成(pdoc>=16 需 Python>=3.10,固定到 15.x 以兼容 requires-python>=3.9)。
57
+ # 精确 pin 15.0.4:pdoc HTML 输出随版本/解释器变化,docs/api 漂移门禁
58
+ # (scripts/check_docs_drift.py)要求生成结果可复现。
59
+ docs = ["pdoc==15.0.4"]
55
60
 
56
61
  [project.urls]
57
62
  Homepage = "https://github.com/Vae-Scrooge/remote-cmd"
@@ -48,7 +48,7 @@ Remote CMD - SSH 远程服务器管理工具
48
48
  - 文档: 参见 docs/ 目录
49
49
 
50
50
  Author: Vae-Scrooge
51
- Version: 2.1.0(单一真相源见 remote_cmd._version)
51
+ Version: 2.2.0(单一真相源见 remote_cmd._version)
52
52
  License: MIT
53
53
  """
54
54
 
@@ -9,4 +9,4 @@
9
9
  - ``__version__`` 由 ``remote_cmd/__init__.py`` 导入并继续作为公共 API 导出。
10
10
  """
11
11
 
12
- __version__ = "2.1.0"
12
+ __version__ = "2.2.0"
@@ -26,6 +26,7 @@ from remote_cmd.service._pool_policy import (
26
26
  lifetime_expired,
27
27
  should_close,
28
28
  )
29
+ from remote_cmd.utils.exceptions import PoolClosedError
29
30
 
30
31
  logger = logging.getLogger(__name__)
31
32
 
@@ -120,17 +121,18 @@ class AsyncConnectionPool:
120
121
 
121
122
  Raises:
122
123
  SSHConnectionError: 创建连接失败
123
- RuntimeError: 连接池已关闭(close_all 之后)
124
+ PoolClosedError: 连接池已关闭(close_all 之后),
125
+ 同时是 RuntimeError 子类(既有捕获行为不变)
124
126
  """
125
127
  if self._closed:
126
- raise RuntimeError("connection pool is closed")
128
+ raise PoolClosedError("connection pool is closed")
127
129
  await self._semaphore.acquire()
128
130
  # 竞态守卫:等待信号量期间 close_all() 可能已完成——
129
131
  # 取得槽位后必须复查,已关闭则归还槽位并抛出既有错误,
130
132
  # 否则会向调用方发放来自已关闭池的连接
131
133
  if self._closed:
132
134
  self._semaphore.release()
133
- raise RuntimeError("connection pool is closed")
135
+ raise PoolClosedError("connection pool is closed")
134
136
  try:
135
137
  # 优先复用空闲连接
136
138
  while not self._free.empty():
@@ -20,7 +20,7 @@ import socket
20
20
  import stat
21
21
  import threading
22
22
  from dataclasses import dataclass
23
- from pathlib import Path
23
+ from pathlib import Path, PurePosixPath
24
24
  from typing import Any, Optional
25
25
 
26
26
  import paramiko
@@ -729,13 +729,16 @@ class SSHClient:
729
729
  sftp = self._get_sftp()
730
730
 
731
731
  def _makedirs(sftp_client: paramiko.SFTPClient, remote_path: str) -> None:
732
- if remote_path == "/":
732
+ # 远端路径始终是 POSIX 语义,必须用 PurePosixPath 处理,
733
+ # 不能用本地 Path(在 Windows 上 WindowsPath 对 "/" 的处理会导致无限递归)。
734
+ p = PurePosixPath(remote_path)
735
+ if p == PurePosixPath("/") or p == PurePosixPath("."):
733
736
  return
734
737
  try:
735
- sftp_client.stat(remote_path)
738
+ sftp_client.stat(str(p))
736
739
  except OSError:
737
- _makedirs(sftp_client, str(Path(remote_path).parent))
738
- sftp_client.mkdir(remote_path)
740
+ _makedirs(sftp_client, str(p.parent))
741
+ sftp_client.mkdir(str(p))
739
742
 
740
743
  try:
741
744
  _makedirs(sftp, path)
@@ -26,6 +26,7 @@ from remote_cmd.service._pool_policy import (
26
26
  lifetime_expired,
27
27
  should_close,
28
28
  )
29
+ from remote_cmd.utils.exceptions import PoolClosedError
29
30
 
30
31
  logger = logging.getLogger(__name__)
31
32
 
@@ -111,17 +112,18 @@ class SyncConnectionPool:
111
112
 
112
113
  Raises:
113
114
  SSHConnectionError: 创建连接失败
114
- RuntimeError: 连接池已关闭(close_all 之后)
115
+ PoolClosedError: 连接池已关闭(close_all 之后),
116
+ 同时是 RuntimeError 子类(既有捕获行为不变)
115
117
  """
116
118
  if self._closed:
117
- raise RuntimeError("connection pool is closed")
119
+ raise PoolClosedError("connection pool is closed")
118
120
  self._semaphore.acquire()
119
121
  # 竞态守卫:等待信号量期间 close_all() 可能已完成——
120
122
  # 取得槽位后必须复查,已关闭则归还槽位并抛出既有错误,
121
123
  # 否则会向调用方发放来自已关闭池的连接
122
124
  if self._closed:
123
125
  self._semaphore.release()
124
- raise RuntimeError("connection pool is closed")
126
+ raise PoolClosedError("connection pool is closed")
125
127
  try:
126
128
  # 优先复用空闲连接
127
129
  while not self._free.empty():
@@ -21,6 +21,7 @@ import json
21
21
  import logging
22
22
  import sqlite3
23
23
  import threading
24
+ import time
24
25
  from typing import Optional
25
26
 
26
27
  from remote_cmd.core.host import Host
@@ -64,6 +65,9 @@ CREATE TABLE IF NOT EXISTS meta (
64
65
  );
65
66
  """
66
67
 
68
+ # 写锁等待上限(毫秒):多进程并发写同一数据库时,等待对方短事务提交/回滚
69
+ DEFAULT_BUSY_TIMEOUT_MS = 5000
70
+
67
71
 
68
72
  class SqliteHostRepository(HostRepository):
69
73
  """
@@ -74,6 +78,7 @@ class SqliteHostRepository(HostRepository):
74
78
  migrate_from: JSON 文件路径,用于自动迁移(仅首次使用)
75
79
  auto_create: 是否自动创建表和数据库,默认 True
76
80
  encryption: 可选的凭据加密器(设置后 save() 自动加密 password)
81
+ busy_timeout_ms: 写锁等待上限(毫秒),默认 5000
77
82
 
78
83
  注意: 密码的加密依赖传入 encryption。若直接以明文密码调用 save()
79
84
  且未提供 encryption,明文会被持久化到数据库。请勿绕过 HostService。
@@ -85,11 +90,13 @@ class SqliteHostRepository(HostRepository):
85
90
  migrate_from: Optional[str] = None,
86
91
  auto_create: bool = True,
87
92
  encryption: Optional[CredentialEncryption] = None,
93
+ busy_timeout_ms: int = DEFAULT_BUSY_TIMEOUT_MS,
88
94
  ) -> None:
89
95
  self._db_path = db_path
90
96
  self._lock = threading.Lock()
91
97
  self._encryption = encryption
92
98
  self._guard = PasswordGuard(encryption)
99
+ self._busy_timeout_ms = busy_timeout_ms
93
100
 
94
101
  if auto_create:
95
102
  self._init_db()
@@ -103,7 +110,7 @@ class SqliteHostRepository(HostRepository):
103
110
 
104
111
  def _init_db(self) -> None:
105
112
  """初始化数据库:创建表和索引"""
106
- with self._txn() as conn:
113
+ with self._txn(write=True) as conn:
107
114
  conn.execute(CREATE_TABLE_SQL)
108
115
  conn.execute(CREATE_META_SQL)
109
116
  for idx_sql in CREATE_INDEXES_SQL:
@@ -117,30 +124,66 @@ class SqliteHostRepository(HostRepository):
117
124
  logger.debug(f"SQLite database initialized: {self._db_path}")
118
125
 
119
126
  def _get_conn(self) -> sqlite3.Connection:
120
- """获取数据库连接(线程安全)"""
127
+ """获取数据库连接(线程安全)。
128
+
129
+ PRAGMA 顺序约定:``busy_timeout`` 必须最先设置——首次并发打开同一
130
+ 数据库文件时,``journal_mode=WAL`` 本身需要短暂排他锁,若 busy
131
+ handler 尚未生效会立即抛 ``database is locked``(v2.2 多进程回归)。
132
+
133
+ 注意:SQLite 的 journal_mode 切换不经过 busy handler,即使设置了
134
+ ``busy_timeout`` 仍会直接返回 SQLITE_BUSY;因此在首次并发切换到
135
+ WAL 时使用有界重试(上限即 busy_timeout)等待其他连接的短事务结束。
136
+ """
121
137
  conn = sqlite3.connect(self._db_path, check_same_thread=False)
122
138
  conn.row_factory = sqlite3.Row
123
- conn.execute("PRAGMA journal_mode=WAL;")
139
+ # 多进程/多连接写入争用:等待对方事务结束而不是立即失败
140
+ conn.execute(f"PRAGMA busy_timeout={self._busy_timeout_ms};")
141
+ self._enable_wal(conn)
124
142
  conn.execute("PRAGMA foreign_keys=ON;")
125
143
  return conn
126
144
 
145
+ def _enable_wal(self, conn: sqlite3.Connection) -> None:
146
+ """启用 WAL;对 journal_mode 切换的 SQLITE_BUSY 做有界重试。
147
+
148
+ 数据库已是 WAL 时该 PRAGMA 只读取模式、不取排他锁,直接返回;
149
+ 仅首次从其他模式切换到 WAL 的竞态需要重试。
150
+ """
151
+ deadline = time.monotonic() + self._busy_timeout_ms / 1000.0
152
+ while True:
153
+ try:
154
+ conn.execute("PRAGMA journal_mode=WAL;")
155
+ return
156
+ except sqlite3.OperationalError as e:
157
+ message = str(e).lower()
158
+ if "locked" not in message and "busy" not in message:
159
+ raise
160
+ if time.monotonic() >= deadline:
161
+ raise
162
+ time.sleep(0.05)
163
+
127
164
  @contextlib.contextmanager
128
- def _txn(self):
165
+ def _txn(self, write: bool = False):
129
166
  """
130
167
  事务 + 连接生命周期上下文
131
168
 
132
169
  包装 ``with conn:`` 与 ``conn.close()`` 为单一上下文:
133
170
  - 进入时打开新连接并执行 PRAGMA
171
+ - ``write=True`` 时以 ``BEGIN IMMEDIATE`` 预先取得写锁(等待受
172
+ ``busy_timeout`` 约束);避免 deferred 事务在写升级时因快照过期
173
+ 直接返回 SQLITE_BUSY(busy handler 不适用该场景)
134
174
  - 退出时先 ``conn.__exit__`` 提交/回滚,再 ``conn.close()`` 释放 fd
135
175
 
136
176
  解决 ``with self._get_conn() as conn:`` 不自动 close 导致的 fd 累积泄漏
137
177
  (sqlite3.Connection.__exit__ 仅管理事务边界,不释放连接句柄)。
138
178
 
139
179
  所有读写操作都应通过 ``with self._lock, self._txn() as conn:`` 使用,
140
- 保证 ``self._lock`` 串行化的同时每次操作后释放 fd。
180
+ 保证 ``self._lock`` 串行化的同时每次操作后释放 fd;写操作使用
181
+ ``self._txn(write=True)``。
141
182
  """
142
183
  conn = self._get_conn()
143
184
  try:
185
+ if write:
186
+ conn.execute("BEGIN IMMEDIATE")
144
187
  with conn: # 事务:commit 或 rollback
145
188
  yield conn
146
189
  finally:
@@ -204,7 +247,7 @@ class SqliteHostRepository(HostRepository):
204
247
 
205
248
  def save(self, host: Host) -> None:
206
249
  """保存或更新主机"""
207
- with self._lock, self._txn() as conn:
250
+ with self._lock, self._txn(write=True) as conn:
208
251
  tags_json = json.dumps(host.tags or [], ensure_ascii=False)
209
252
  # 配置了加密器时,明文密码先加密再落库
210
253
  password = self._guard.encrypt(host.password)
@@ -248,7 +291,7 @@ class SqliteHostRepository(HostRepository):
248
291
 
249
292
  def delete(self, name: str) -> None:
250
293
  """按名称删除主机"""
251
- with self._lock, self._txn() as conn:
294
+ with self._lock, self._txn(write=True) as conn:
252
295
  cursor = conn.execute("DELETE FROM hosts WHERE name = ?", (name,))
253
296
  conn.commit()
254
297