remote-cmd-manager 2.8.0__tar.gz → 2.9.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 (107) hide show
  1. {remote_cmd_manager-2.8.0/remote_cmd_manager.egg-info → remote_cmd_manager-2.9.0}/PKG-INFO +18 -22
  2. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/README.md +17 -21
  3. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/__init__.py +11 -1
  4. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/_version.py +1 -1
  5. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/cli/main.py +291 -33
  6. remote_cmd_manager-2.9.0/remote_cmd/core/recipe.py +259 -0
  7. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/repository/json_host_repository.py +37 -0
  8. remote_cmd_manager-2.9.0/remote_cmd/repository/recipe_store.py +43 -0
  9. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/repository/sqlite_host_repository.py +225 -34
  10. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/async_batch_executor.py +7 -0
  11. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/batch_executor.py +24 -4
  12. remote_cmd_manager-2.9.0/remote_cmd/service/recipe_service.py +113 -0
  13. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0/remote_cmd_manager.egg-info}/PKG-INFO +18 -22
  14. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd_manager.egg-info/SOURCES.txt +6 -0
  15. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_async_batch_executor.py +22 -0
  16. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_batch_executor.py +46 -2
  17. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_cli_profile.py +28 -0
  18. remote_cmd_manager-2.9.0/tests/test_cli_recipe.py +175 -0
  19. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_profile_merge.py +8 -4
  20. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_profile_service.py +21 -0
  21. remote_cmd_manager-2.9.0/tests/test_recipe.py +231 -0
  22. remote_cmd_manager-2.9.0/tests/test_recipe_service.py +134 -0
  23. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_release_metadata.py +12 -0
  24. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_sqlite_repository.py +181 -2
  25. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/LICENSE +0 -0
  26. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/MANIFEST.in +0 -0
  27. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/config.example.yaml +0 -0
  28. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/examples/basic_usage.py +0 -0
  29. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/examples/deploy_script.py +0 -0
  30. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/examples/nginx_batch_update.py +0 -0
  31. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/examples/system_health_check.py +0 -0
  32. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/pyproject.toml +0 -0
  33. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/api/__init__.py +0 -0
  34. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/api/host_manager.py +0 -0
  35. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/cli/__init__.py +0 -0
  36. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/cli/formatters/__init__.py +0 -0
  37. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/cli/formatters/base.py +0 -0
  38. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/cli/formatters/json.py +0 -0
  39. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/cli/formatters/table.py +0 -0
  40. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/core/__init__.py +0 -0
  41. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/core/async_connection_pool.py +0 -0
  42. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/core/async_ssh_client.py +0 -0
  43. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/core/budget.py +0 -0
  44. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/core/host.py +0 -0
  45. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/core/host_manager.py +0 -0
  46. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/core/pool_policy.py +0 -0
  47. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/core/profile.py +0 -0
  48. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/core/ssh_client.py +0 -0
  49. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/core/sync_connection_pool.py +0 -0
  50. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/repository/__init__.py +0 -0
  51. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/repository/host_repository.py +0 -0
  52. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/repository/profile_store.py +0 -0
  53. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/__init__.py +0 -0
  54. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/_host_runner.py +0 -0
  55. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/_pool_policy.py +0 -0
  56. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/_types.py +0 -0
  57. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/credential_provider.py +0 -0
  58. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/host_service.py +0 -0
  59. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/profile_service.py +0 -0
  60. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/retry_policy.py +0 -0
  61. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/ssh_service.py +0 -0
  62. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/storage_factory.py +0 -0
  63. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/task_runner.py +0 -0
  64. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/utils/__init__.py +0 -0
  65. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/utils/config.py +0 -0
  66. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/utils/credential_guard.py +0 -0
  67. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/utils/crypto.py +0 -0
  68. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/utils/exceptions.py +0 -0
  69. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/utils/logging_utils.py +0 -0
  70. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd_manager.egg-info/dependency_links.txt +0 -0
  71. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd_manager.egg-info/entry_points.txt +0 -0
  72. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd_manager.egg-info/requires.txt +0 -0
  73. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd_manager.egg-info/top_level.txt +0 -0
  74. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/requirements.txt +0 -0
  75. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/setup.cfg +0 -0
  76. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/__init__.py +0 -0
  77. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/conftest.py +0 -0
  78. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/integration/conftest.py +0 -0
  79. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/integration/test_ssh_connection.py +0 -0
  80. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/performance/__init__.py +0 -0
  81. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/performance/conftest.py +0 -0
  82. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/performance/test_benchmarks.py +0 -0
  83. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/performance/test_paramiko_threads.py +0 -0
  84. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_async_ssh_client.py +0 -0
  85. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_cli.py +0 -0
  86. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_config.py +0 -0
  87. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_connection_budget.py +0 -0
  88. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_credential_provider.py +0 -0
  89. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_crypto.py +0 -0
  90. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_exceptions.py +0 -0
  91. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_formatters.py +0 -0
  92. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_host.py +0 -0
  93. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_host_manager.py +0 -0
  94. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_host_service.py +0 -0
  95. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_keyring_provider.py +0 -0
  96. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_logging_utils.py +0 -0
  97. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_output_policy.py +0 -0
  98. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_package_import.py +0 -0
  99. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_pool_policy.py +0 -0
  100. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_profile.py +0 -0
  101. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_repository.py +0 -0
  102. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_retry_policy.py +0 -0
  103. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_ssh_client.py +0 -0
  104. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_ssh_service.py +0 -0
  105. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_storage_factory.py +0 -0
  106. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_sync_connection_pool.py +0 -0
  107. {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_task_runner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remote_cmd_manager
3
- Version: 2.8.0
3
+ Version: 2.9.0
4
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
@@ -89,37 +89,32 @@ pip install remote_cmd_manager && remote-cmd host add web-01 192.168.1.10 ubuntu
89
89
 
90
90
  ---
91
91
 
92
- ## v2.8.0 Release Highlights
92
+ ## v2.9.0 Release Highlights
93
93
 
94
- v2.8.0 adds the first P3 user-facing features: **connection profiles** and
95
- **machine-readable CLI output**. Public APIs remain backward compatible; the
96
- default CLI output and all existing commands behave exactly as before. See the
97
- [full migration notes](./CHANGELOG.md).
94
+ v2.9.0 adds the **secure Recipe engine** and makes **SQLite profile deletion
95
+ atomic**. Public APIs remain backward compatible; existing stores upgrade in
96
+ place. See the [full migration notes](./CHANGELOG.md).
98
97
 
99
- ### Connection Profiles
98
+ ### Secure Recipes
100
99
 
101
- - New `HostProfile` (exported from `remote_cmd`): reusable connection defaults — username, port, key file, tags, description — stored by both JSON and SQLite repositories via the optional `ProfileStore` capability (the `HostRepository` ABC is unchanged).
102
- - Hosts reference a profile (`host add --profile aws`); values merge at connect time (reference model): port when the host uses the default 22, key file when unset, description when empty, tags as a union, username when the host stores none (live default — later profile username changes propagate).
103
- - Changing a profile applies to every referencing host; unknown profiles fail with a clear `ConfigError` (batch execution reports them per host).
104
- - `ProfileService` CRUD + `remote-cmd profile add/list/show/remove`, with deletion protection for profiles still referenced by hosts.
100
+ - New `Recipe` / `RecipeVariable` (exported from `remote_cmd`): parameterized command templates with **typed variables** — `shell_arg` values are `shlex.quote`d, `env` values are exported as remote environment variables. Raw string interpolation is intentionally unsupported.
101
+ - Placeholders must be declared; undeclared placeholders fail at creation, and missing/unknown values fail before any connection is made. Rendering is single-pass, so `{{ ... }}` inside values stays literal.
102
+ - Recipes persist through the optional `RecipeStore` capability (JSON `recipes` section / SQLite `recipes` table) and run via `remote-cmd recipe run ...` or `RecipeService` + the batch executors.
105
103
 
106
- ### Machine-Readable Output
104
+ ### Atomic Profile Deletion
107
105
 
108
- - `remote-cmd run` and `remote-cmd batch-run` accept `--format rich|json|table` (default `rich` = byte-compatible with previous output).
109
- - JSON uses a stable schema with sorted result keys; table output is plain aligned text. Machine formats suppress the progress bar/header so stdout stays parseable.
110
- - The formatter layer lives in `remote_cmd/cli/formatters/` and never touches the execution kernels.
106
+ - SQLite `hosts.profile` now carries `FOREIGN KEY ... ON DELETE RESTRICT` (`DB_VERSION` 2 → 3); existing databases are rebuilt automatically on open with data preserved. The service-level reference check stays as a friendly fast path.
111
107
 
112
108
  ### Try It
113
109
 
114
110
  ```bash
115
- remote-cmd profile add aws -u ec2-user -k ~/.ssh/aws.pem -t cloud
116
- remote-cmd host add web-01 10.0.0.10 --profile aws
117
- remote-cmd batch-run web-01 web-02 "uptime" --format json | jq .
111
+ remote-cmd recipe add deploy --command 'deploy {{ package }}' -V package=app
112
+ remote-cmd recipe run deploy web-01 web-02 -V package=api-2026.09 --format json | jq .
118
113
  ```
119
114
 
120
115
  ## Table of Contents
121
116
 
122
- - [v2.8.0 Release Highlights](#v280-release-highlights)
117
+ - [v2.9.0 Release Highlights](#v290-release-highlights)
123
118
  - [Why Remote CMD?](#why-remote-cmd)
124
119
  - [Quick Start](#quick-start)
125
120
  - [Use Cases](#use-cases)
@@ -267,6 +262,7 @@ with SSHClient(config) as client:
267
262
  | **File Transfer** | Upload/download via SFTP (`remote-cmd upload/download`) |
268
263
  | **Host Management** | CRUD with pluggable JSON or **SQLite** persistence |
269
264
  | **Connection Profiles** | Reusable username/port/key/tag defaults referenced by hosts (`ProfileService`, `--profile`); never stores credentials |
265
+ | **Secure Recipes** | Typed command templates (`shell_arg` auto-quoted / `env` exported), stored in the repository and run across hosts; no raw interpolation |
270
266
  | **Output Formats** | `--format rich\|json\|table` for `run` / `batch-run` (stable JSON schema, parseable stdout) |
271
267
  | **Tag System** | Filter hosts by tag (e.g., `production`, `web`, `db`); profile tags participate in filtering |
272
268
  | **Batch Ops** | Run commands across any host group, synchronously or asynchronously; optional per-host retained-output cap (`max_output_bytes`) |
@@ -345,9 +341,9 @@ are simply not exported.
345
341
  - [x] Async SSH operations (parallel execution) — v1.1.0
346
342
  - [x] Pluggable storage backends (JSON + SQLite) — v1.2.x
347
343
  - [x] Chainable credential providers + at-rest encryption — v1.2.x
348
- - [ ] Configuration profiles (AWS, GCP, custom)
349
- - [ ] Output formatting (JSON, table)
350
- - [ ] Templated command recipes
344
+ - [x] Configuration profiles (AWS, GCP, custom) — v2.8.0
345
+ - [x] Output formatting (JSON, table) — v2.8.0
346
+ - [x] Templated command recipes (typed variables, shell-quoted) — v2.9.0
351
347
 
352
348
  Good first issues are labelled `good first issue` in the
353
349
  [issue tracker](https://github.com/Vae-Scrooge/remote-cmd/issues) — contributions welcome.
@@ -36,37 +36,32 @@ pip install remote_cmd_manager && remote-cmd host add web-01 192.168.1.10 ubuntu
36
36
 
37
37
  ---
38
38
 
39
- ## v2.8.0 Release Highlights
39
+ ## v2.9.0 Release Highlights
40
40
 
41
- v2.8.0 adds the first P3 user-facing features: **connection profiles** and
42
- **machine-readable CLI output**. Public APIs remain backward compatible; the
43
- default CLI output and all existing commands behave exactly as before. See the
44
- [full migration notes](./CHANGELOG.md).
41
+ v2.9.0 adds the **secure Recipe engine** and makes **SQLite profile deletion
42
+ atomic**. Public APIs remain backward compatible; existing stores upgrade in
43
+ place. See the [full migration notes](./CHANGELOG.md).
45
44
 
46
- ### Connection Profiles
45
+ ### Secure Recipes
47
46
 
48
- - New `HostProfile` (exported from `remote_cmd`): reusable connection defaults — username, port, key file, tags, description — stored by both JSON and SQLite repositories via the optional `ProfileStore` capability (the `HostRepository` ABC is unchanged).
49
- - Hosts reference a profile (`host add --profile aws`); values merge at connect time (reference model): port when the host uses the default 22, key file when unset, description when empty, tags as a union, username when the host stores none (live default — later profile username changes propagate).
50
- - Changing a profile applies to every referencing host; unknown profiles fail with a clear `ConfigError` (batch execution reports them per host).
51
- - `ProfileService` CRUD + `remote-cmd profile add/list/show/remove`, with deletion protection for profiles still referenced by hosts.
47
+ - New `Recipe` / `RecipeVariable` (exported from `remote_cmd`): parameterized command templates with **typed variables** — `shell_arg` values are `shlex.quote`d, `env` values are exported as remote environment variables. Raw string interpolation is intentionally unsupported.
48
+ - Placeholders must be declared; undeclared placeholders fail at creation, and missing/unknown values fail before any connection is made. Rendering is single-pass, so `{{ ... }}` inside values stays literal.
49
+ - Recipes persist through the optional `RecipeStore` capability (JSON `recipes` section / SQLite `recipes` table) and run via `remote-cmd recipe run ...` or `RecipeService` + the batch executors.
52
50
 
53
- ### Machine-Readable Output
51
+ ### Atomic Profile Deletion
54
52
 
55
- - `remote-cmd run` and `remote-cmd batch-run` accept `--format rich|json|table` (default `rich` = byte-compatible with previous output).
56
- - JSON uses a stable schema with sorted result keys; table output is plain aligned text. Machine formats suppress the progress bar/header so stdout stays parseable.
57
- - The formatter layer lives in `remote_cmd/cli/formatters/` and never touches the execution kernels.
53
+ - SQLite `hosts.profile` now carries `FOREIGN KEY ... ON DELETE RESTRICT` (`DB_VERSION` 2 → 3); existing databases are rebuilt automatically on open with data preserved. The service-level reference check stays as a friendly fast path.
58
54
 
59
55
  ### Try It
60
56
 
61
57
  ```bash
62
- remote-cmd profile add aws -u ec2-user -k ~/.ssh/aws.pem -t cloud
63
- remote-cmd host add web-01 10.0.0.10 --profile aws
64
- remote-cmd batch-run web-01 web-02 "uptime" --format json | jq .
58
+ remote-cmd recipe add deploy --command 'deploy {{ package }}' -V package=app
59
+ remote-cmd recipe run deploy web-01 web-02 -V package=api-2026.09 --format json | jq .
65
60
  ```
66
61
 
67
62
  ## Table of Contents
68
63
 
69
- - [v2.8.0 Release Highlights](#v280-release-highlights)
64
+ - [v2.9.0 Release Highlights](#v290-release-highlights)
70
65
  - [Why Remote CMD?](#why-remote-cmd)
71
66
  - [Quick Start](#quick-start)
72
67
  - [Use Cases](#use-cases)
@@ -214,6 +209,7 @@ with SSHClient(config) as client:
214
209
  | **File Transfer** | Upload/download via SFTP (`remote-cmd upload/download`) |
215
210
  | **Host Management** | CRUD with pluggable JSON or **SQLite** persistence |
216
211
  | **Connection Profiles** | Reusable username/port/key/tag defaults referenced by hosts (`ProfileService`, `--profile`); never stores credentials |
212
+ | **Secure Recipes** | Typed command templates (`shell_arg` auto-quoted / `env` exported), stored in the repository and run across hosts; no raw interpolation |
217
213
  | **Output Formats** | `--format rich\|json\|table` for `run` / `batch-run` (stable JSON schema, parseable stdout) |
218
214
  | **Tag System** | Filter hosts by tag (e.g., `production`, `web`, `db`); profile tags participate in filtering |
219
215
  | **Batch Ops** | Run commands across any host group, synchronously or asynchronously; optional per-host retained-output cap (`max_output_bytes`) |
@@ -292,9 +288,9 @@ are simply not exported.
292
288
  - [x] Async SSH operations (parallel execution) — v1.1.0
293
289
  - [x] Pluggable storage backends (JSON + SQLite) — v1.2.x
294
290
  - [x] Chainable credential providers + at-rest encryption — v1.2.x
295
- - [ ] Configuration profiles (AWS, GCP, custom)
296
- - [ ] Output formatting (JSON, table)
297
- - [ ] Templated command recipes
291
+ - [x] Configuration profiles (AWS, GCP, custom) — v2.8.0
292
+ - [x] Output formatting (JSON, table) — v2.8.0
293
+ - [x] Templated command recipes (typed variables, shell-quoted) — v2.9.0
298
294
 
299
295
  Good first issues are labelled `good first issue` in the
300
296
  [issue tracker](https://github.com/Vae-Scrooge/remote-cmd/issues) — contributions welcome.
@@ -48,7 +48,7 @@ Remote CMD - SSH 远程服务器管理工具
48
48
  - 文档: 参见 docs/ 目录
49
49
 
50
50
  Author: Vae-Scrooge
51
- Version: 2.8.0(单一真相源见 remote_cmd._version)
51
+ Version: 2.9.0(单一真相源见 remote_cmd._version)
52
52
  License: MIT
53
53
  """
54
54
 
@@ -79,6 +79,7 @@ from remote_cmd.api.host_manager import HostManager
79
79
  from remote_cmd.core.budget import ConnectionBudget
80
80
  from remote_cmd.core.host import Host
81
81
  from remote_cmd.core.profile import HostProfile
82
+ from remote_cmd.core.recipe import Recipe, RecipeVariable, RenderedRecipe
82
83
  from remote_cmd.core.ssh_client import SSHClient
83
84
  from remote_cmd.core.sync_connection_pool import SyncConnectionPool
84
85
 
@@ -107,6 +108,7 @@ from remote_cmd.service.batch_executor import (
107
108
  )
108
109
  from remote_cmd.service.credential_provider import KeyringCredentialProvider
109
110
  from remote_cmd.service.profile_service import ProfileService
111
+ from remote_cmd.service.recipe_service import RecipeService
110
112
  from remote_cmd.service.task_runner import Task, TaskRunner, TaskStatus
111
113
  from remote_cmd.utils.crypto import CredentialEncryption
112
114
  from remote_cmd.utils.exceptions import BudgetTimeoutError, PlaintextCredentialWarning
@@ -127,12 +129,16 @@ if _HAS_ASYNC:
127
129
  "AsyncBatchExecutor",
128
130
  "Host",
129
131
  "HostProfile",
132
+ "Recipe",
133
+ "RecipeVariable",
134
+ "RenderedRecipe",
130
135
  "HostManager",
131
136
  # 新架构导出
132
137
  "HostRepository",
133
138
  "JsonHostRepository",
134
139
  "HostService",
135
140
  "ProfileService",
141
+ "RecipeService",
136
142
  "SSHService",
137
143
  "CredentialProvider",
138
144
  "EnvCredentialProvider",
@@ -166,12 +172,16 @@ else:
166
172
  "SSHClient",
167
173
  "Host",
168
174
  "HostProfile",
175
+ "Recipe",
176
+ "RecipeVariable",
177
+ "RenderedRecipe",
169
178
  "HostManager",
170
179
  # 新架构导出
171
180
  "HostRepository",
172
181
  "JsonHostRepository",
173
182
  "HostService",
174
183
  "ProfileService",
184
+ "RecipeService",
175
185
  "SSHService",
176
186
  "CredentialProvider",
177
187
  "EnvCredentialProvider",
@@ -9,4 +9,4 @@
9
9
  - ``__version__`` 由 ``remote_cmd/__init__.py`` 导入并继续作为公共 API 导出。
10
10
  """
11
11
 
12
- __version__ = "2.8.0"
12
+ __version__ = "2.9.0"
@@ -30,7 +30,9 @@ from remote_cmd.cli.formatters import format_batch_result, format_single_result
30
30
  from remote_cmd.cli.formatters.base import FORMAT_CHOICES
31
31
  from remote_cmd.core.host import Host
32
32
  from remote_cmd.core.profile import HostProfile
33
+ from remote_cmd.core.recipe import Recipe, RecipeVariable
33
34
  from remote_cmd.repository.profile_store import ProfileStore
35
+ from remote_cmd.repository.recipe_store import RecipeStore
34
36
  from remote_cmd.service.batch_executor import BatchExecutor
35
37
  from remote_cmd.service.credential_provider import (
36
38
  ChainCredentialProvider,
@@ -39,6 +41,7 @@ from remote_cmd.service.credential_provider import (
39
41
  )
40
42
  from remote_cmd.service.host_service import HostService
41
43
  from remote_cmd.service.profile_service import ProfileService
44
+ from remote_cmd.service.recipe_service import RecipeService
42
45
  from remote_cmd.service.storage_factory import build_repository
43
46
  from remote_cmd.utils.config import get_default_config_path, load_config
44
47
  from remote_cmd.utils.crypto import CredentialEncryption
@@ -47,7 +50,7 @@ from remote_cmd.utils.exceptions import ValidationError
47
50
 
48
51
  def _build_service(
49
52
  config_file: str, storage_backend: Optional[str] = None
50
- ) -> tuple[HostService, ProfileService]:
53
+ ) -> tuple[HostService, ProfileService, RecipeService]:
51
54
  """Build a HostService and ProfileService from a config file.
52
55
 
53
56
  Credential chain order: env var -> encrypted file storage.
@@ -75,10 +78,13 @@ def _build_service(
75
78
  ]
76
79
  )
77
80
  service = HostService(repository=repo, credential_provider=cred_provider)
78
- # 内置仓库均实现 ProfileStore(v2.8);assert 同时用于类型收窄
81
+ # 内置仓库均实现 ProfileStore(v2.8)/ RecipeStore(v2.9);
82
+ # assert 同时用于类型收窄
79
83
  assert isinstance(repo, ProfileStore)
84
+ assert isinstance(repo, RecipeStore)
80
85
  profile_service = ProfileService(store=repo, host_repository=repo)
81
- return service, profile_service
86
+ recipe_service = RecipeService(store=repo)
87
+ return service, profile_service, recipe_service
82
88
 
83
89
 
84
90
  @click.group()
@@ -129,9 +135,10 @@ def cli(
129
135
  # (扩展名推断覆盖 .json/.db/.sqlite 常见场景)
130
136
  hosts_file = hosts_file_override or ctx.obj["config"].get("hosts_file", "hosts.json")
131
137
  storage_backend = ctx.obj["config"].get("storage_backend")
132
- service, profile_service = _build_service(hosts_file, storage_backend)
138
+ service, profile_service, recipe_service = _build_service(hosts_file, storage_backend)
133
139
  ctx.obj["service"] = service
134
140
  ctx.obj["profile_service"] = profile_service
141
+ ctx.obj["recipe_service"] = recipe_service
135
142
 
136
143
  if verbose:
137
144
  click.echo(f"Using config file: {config_path}")
@@ -478,6 +485,233 @@ def profile_remove(ctx: click.Context, name: str, force: bool) -> None:
478
485
  ctx.exit(1)
479
486
 
480
487
 
488
+ @cli.group()
489
+ def recipe() -> None:
490
+ """
491
+ Recipe management and safe execution (v2.9)
492
+
493
+ Recipes are parameterized command templates with **typed** variables:
494
+ ``shell_arg`` values are ``shlex.quote``-escaped before substitution and
495
+ ``env`` values are exported as remote environment variables. Raw string
496
+ interpolation is intentionally not supported.
497
+
498
+ Commands:
499
+ add Create a recipe
500
+ list List recipes
501
+ show Show recipe details
502
+ remove Remove a recipe
503
+ run Render and execute a recipe across hosts
504
+ """
505
+ pass
506
+
507
+
508
+ @recipe.command("add")
509
+ @click.argument("name", required=True)
510
+ @click.option(
511
+ "--command",
512
+ "-c",
513
+ "command_template",
514
+ required=True,
515
+ help="Command template containing {{ var }} placeholders",
516
+ )
517
+ @click.option(
518
+ "--var",
519
+ "-V",
520
+ "shell_vars",
521
+ multiple=True,
522
+ help="shell_arg variable declaration: NAME or NAME=DEFAULT (repeatable)",
523
+ )
524
+ @click.option(
525
+ "--env",
526
+ "-E",
527
+ "env_vars",
528
+ multiple=True,
529
+ help="env variable declaration: NAME or NAME=DEFAULT (repeatable)",
530
+ )
531
+ @click.option("--description", "-d", default="", help="Description")
532
+ @click.option("--tag", "-t", multiple=True, help="Tag (may be repeated)")
533
+ @click.pass_context
534
+ def recipe_add(
535
+ ctx: click.Context,
536
+ name: str,
537
+ command_template: str,
538
+ shell_vars: tuple,
539
+ env_vars: tuple,
540
+ description: str,
541
+ tag: tuple,
542
+ ) -> None:
543
+ """Create a recipe (typed variables; no raw interpolation)."""
544
+ recipes: RecipeService = ctx.obj["recipe_service"]
545
+ try:
546
+ shell_variables = _parse_var_declarations(shell_vars, "shell_arg")
547
+ env_variables = _parse_var_declarations(env_vars, "env")
548
+
549
+ # 同名变量不得跨类型声明(-V TOKEN -E TOKEN):静默覆盖会改变
550
+ # 变量的安全语义(内联转义 vs 环境导出),必须显式拒绝。
551
+ overlap = shell_variables.keys() & env_variables.keys()
552
+ if overlap:
553
+ raise ValidationError(
554
+ "variable(s) declared as both shell_arg and env: " + ", ".join(sorted(overlap))
555
+ )
556
+
557
+ variables = {**shell_variables, **env_variables}
558
+ recipes.add_recipe(
559
+ Recipe(
560
+ name=name,
561
+ command=command_template,
562
+ variables=variables,
563
+ description=description,
564
+ tags=list(tag),
565
+ )
566
+ )
567
+ click.echo(f"✓ Recipe '{name}' added successfully")
568
+ except (Exit, click.Abort):
569
+ raise
570
+ except (ValueError, ValidationError) as e:
571
+ click.echo(f"✗ Error: {e}", err=True)
572
+ ctx.exit(1)
573
+
574
+
575
+ @recipe.command("list")
576
+ @click.pass_context
577
+ def recipe_list(ctx: click.Context) -> None:
578
+ """List all recipes."""
579
+ recipes: RecipeService = ctx.obj["recipe_service"]
580
+ items = recipes.list_recipes()
581
+ if not items:
582
+ click.echo("No recipes.")
583
+ return
584
+ click.echo(f"{'NAME':<24} {'VARIABLES':<32} TAGS")
585
+ for r in items:
586
+ var_desc = ", ".join(
587
+ f"{v.name}:{v.type}" for v in r.variables.values()
588
+ )
589
+ click.echo(f"{r.name:<24} {var_desc:<32} {','.join(r.tags)}")
590
+
591
+
592
+ @recipe.command("show")
593
+ @click.argument("name", required=True)
594
+ @click.pass_context
595
+ def recipe_show(ctx: click.Context, name: str) -> None:
596
+ """Show one recipe's details."""
597
+ recipes: RecipeService = ctx.obj["recipe_service"]
598
+ try:
599
+ r = recipes.get_recipe(name)
600
+ except KeyError:
601
+ click.echo(f"✗ Error: recipe '{name}' not found", err=True)
602
+ ctx.exit(1)
603
+ click.echo(f"Name: {r.name}")
604
+ click.echo(f"Command: {r.command}")
605
+ click.echo(f"Description: {r.description or '-'}")
606
+ click.echo(f"Tags: {', '.join(r.tags) if r.tags else '-'}")
607
+ if r.variables:
608
+ click.echo("Variables:")
609
+ for v in r.variables.values():
610
+ default = f", default={v.default!r}" if v.default is not None else ""
611
+ required = "required" if v.required and v.default is None else "optional"
612
+ click.echo(f" - {v.name} ({v.type}, {required}{default})")
613
+ else:
614
+ click.echo("Variables: -")
615
+
616
+
617
+ @recipe.command("remove")
618
+ @click.argument("name", required=True)
619
+ @click.confirmation_option(prompt="Are you sure you want to remove this recipe?")
620
+ @click.pass_context
621
+ def recipe_remove(ctx: click.Context, name: str) -> None:
622
+ """Remove a recipe."""
623
+ recipes: RecipeService = ctx.obj["recipe_service"]
624
+ try:
625
+ recipes.remove_recipe(name)
626
+ click.echo(f"✓ Recipe '{name}' removed")
627
+ except KeyError:
628
+ click.echo(f"✗ Error: recipe '{name}' not found", err=True)
629
+ ctx.exit(1)
630
+
631
+
632
+ @recipe.command("run")
633
+ @click.argument("name", required=True)
634
+ @click.argument("host_names", nargs=-1, required=True)
635
+ @click.option(
636
+ "--var",
637
+ "-V",
638
+ "var_values",
639
+ multiple=True,
640
+ help="Variable value NAME=VALUE (repeatable; type comes from the recipe)",
641
+ )
642
+ @click.option("--concurrency", "-C", default=10, help="Max concurrency (default: 10)")
643
+ @click.option("--timeout", "-T", default=30, help="Command timeout in seconds (default: 30)")
644
+ @click.option("--retry", "-r", default=0, help="Failure retry count (default: 0)")
645
+ @click.option("--async", "use_async", is_flag=True, help="Use async execution engine")
646
+ @click.option("--show-failures", is_flag=True, help="Show only failed hosts")
647
+ @click.option(
648
+ "--format",
649
+ "output_format",
650
+ type=click.Choice(FORMAT_CHOICES),
651
+ default="rich",
652
+ help="Output format: rich (default) or json/table for scripting",
653
+ )
654
+ @click.pass_context
655
+ def recipe_run(
656
+ ctx: click.Context,
657
+ name: str,
658
+ host_names: tuple,
659
+ var_values: tuple,
660
+ concurrency: int,
661
+ timeout: int,
662
+ retry: int,
663
+ use_async: bool,
664
+ show_failures: bool,
665
+ output_format: str,
666
+ ) -> None:
667
+ """Render a recipe safely and run it across hosts.
668
+
669
+ Rendering is type-aware: shell_arg values are shell-quoted, env values
670
+ are exported as environment variables. Missing required variables and
671
+ unknown variables fail before any connection is made.
672
+ """
673
+ recipes: RecipeService = ctx.obj["recipe_service"]
674
+ service: HostService = ctx.obj["service"]
675
+
676
+ try:
677
+ values = _parse_var_values(var_values)
678
+ rendered = recipes.render(name, values)
679
+ except KeyError:
680
+ click.echo(f"✗ Error: recipe '{name}' not found", err=True)
681
+ ctx.exit(1)
682
+ except (ValueError, ValidationError) as e:
683
+ click.echo(f"✗ Error: {e}", err=True)
684
+ ctx.exit(1)
685
+
686
+ if output_format == "rich":
687
+ click.echo(
688
+ f"Recipe '{name}' on {len(host_names)} hosts, "
689
+ f"command={rendered.command!r}"
690
+ )
691
+
692
+ executor = BatchExecutor(
693
+ host_service=service,
694
+ max_concurrency=concurrency,
695
+ command_timeout=timeout,
696
+ use_async=use_async,
697
+ )
698
+ result = executor.execute(
699
+ host_names=list(host_names),
700
+ command=rendered.command,
701
+ retry_count=retry,
702
+ retry_delay=1.0,
703
+ environment=rendered.environment or None,
704
+ )
705
+
706
+ if output_format != "rich":
707
+ click.echo(format_batch_result(result, output_format, show_failures=show_failures))
708
+ else:
709
+ _echo_batch_rich(result, show_failures)
710
+
711
+ if result.failed > 0:
712
+ ctx.exit(1)
713
+
714
+
481
715
  @cli.command()
482
716
  @click.argument("host_name", required=True)
483
717
  @click.argument("command", required=True)
@@ -590,6 +824,58 @@ def download(ctx: click.Context, host_name: str, local_path: str, remote_path: s
590
824
  ctx.exit(1)
591
825
 
592
826
 
827
+ def _echo_batch_rich(result: Any, show_failures: bool) -> None:
828
+ """rich(默认)批量结果渲染:摘要 + 失败/成功主机列表。"""
829
+ click.echo()
830
+ click.echo("=" * 50)
831
+ click.echo(" Batch result summary")
832
+ click.echo("=" * 50)
833
+ click.echo(f" Total: {result.total}")
834
+ click.echo(f" Succeeded: {result.success}")
835
+ click.echo(f" Failed: {result.failed}")
836
+ click.echo(f" Duration: {result.duration:.1f}s")
837
+ click.echo(f" Success: {result.success_rate:.1%}")
838
+ click.echo("=" * 50)
839
+
840
+ if result.failed_hosts:
841
+ click.echo()
842
+ click.echo(click.style("Failed hosts:", fg="red"))
843
+ for host in result.failed_hosts:
844
+ host_result = result.results[host]
845
+ error_msg = host_result.error or f"exit_code={host_result.exit_code}"
846
+ click.echo(click.style(f" ✗ {host}: {error_msg}", fg="red"))
847
+
848
+ if not show_failures and result.success_hosts:
849
+ click.echo()
850
+ click.echo(click.style("Successful hosts:", fg="green"))
851
+ for host in result.success_hosts:
852
+ click.echo(click.style(f" ✓ {host}", fg="green"))
853
+
854
+
855
+ def _parse_var_declarations(items: tuple, var_type: str) -> dict:
856
+ """解析 CLI 变量声明(NAME 或 NAME=DEFAULT)。"""
857
+ variables: dict = {}
858
+ for item in items:
859
+ name, sep, default = item.partition("=")
860
+ if not name:
861
+ raise ValidationError(f"invalid variable declaration: {item!r}")
862
+ variables[name] = RecipeVariable(
863
+ name=name, type=var_type, default=default if sep else None
864
+ )
865
+ return variables
866
+
867
+
868
+ def _parse_var_values(items: tuple) -> dict:
869
+ """解析 CLI 变量取值(必须 NAME=VALUE)。"""
870
+ values: dict = {}
871
+ for item in items:
872
+ name, sep, value = item.partition("=")
873
+ if not sep or not name:
874
+ raise ValidationError(f"expected NAME=VALUE, got: {item!r}")
875
+ values[name] = value
876
+ return values
877
+
878
+
593
879
  @cli.command()
594
880
  @click.argument("host_names", nargs=-1, required=True)
595
881
  @click.argument("command", required=True)
@@ -683,35 +969,7 @@ def batch_run(
683
969
  progress_callback=progress,
684
970
  )
685
971
 
686
- click.echo()
687
- click.echo("=" * 50)
688
- click.echo(" Batch result summary")
689
- click.echo("=" * 50)
690
- click.echo(f" Total: {result.total}")
691
- click.echo(f" Succeeded: {result.success}")
692
- click.echo(f" Failed: {result.failed}")
693
- click.echo(f" Duration: {result.duration:.1f}s")
694
- click.echo(f" Success: {result.success_rate:.1%}")
695
- click.echo("=" * 50)
696
-
697
- if result.failed_hosts:
698
- click.echo()
699
- click.echo(click.style("Failed hosts:", fg="red"))
700
- for host in result.failed_hosts:
701
- host_result = result.results[host]
702
- error_msg = host_result.error or f"exit_code={host_result.exit_code}"
703
- click.echo(
704
- click.style(
705
- f" ✗ {host}: {error_msg}",
706
- fg="red",
707
- )
708
- )
709
-
710
- if not show_failures and result.success_hosts:
711
- click.echo()
712
- click.echo(click.style("Successful hosts:", fg="green"))
713
- for host in result.success_hosts:
714
- click.echo(click.style(f" ✓ {host}", fg="green"))
972
+ _echo_batch_rich(result, show_failures)
715
973
 
716
974
  if result.failed > 0:
717
975
  ctx.exit(1)