keywharf 1.0.1__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.
- keywharf-1.0.1/LICENSE +21 -0
- keywharf-1.0.1/PKG-INFO +155 -0
- keywharf-1.0.1/README.md +127 -0
- keywharf-1.0.1/pyproject.toml +57 -0
- keywharf-1.0.1/setup.cfg +4 -0
- keywharf-1.0.1/src/keywharf/__init__.py +3 -0
- keywharf-1.0.1/src/keywharf/__main__.py +11 -0
- keywharf-1.0.1/src/keywharf/cli.py +75 -0
- keywharf-1.0.1/src/keywharf/commands/__init__.py +15 -0
- keywharf-1.0.1/src/keywharf/commands/_invocation.py +154 -0
- keywharf-1.0.1/src/keywharf/commands/_privilege.py +96 -0
- keywharf-1.0.1/src/keywharf/commands/apply.py +81 -0
- keywharf-1.0.1/src/keywharf/commands/context.py +87 -0
- keywharf-1.0.1/src/keywharf/commands/deselect.py +47 -0
- keywharf-1.0.1/src/keywharf/commands/init.py +65 -0
- keywharf-1.0.1/src/keywharf/commands/install_include.py +61 -0
- keywharf-1.0.1/src/keywharf/commands/local.py +121 -0
- keywharf-1.0.1/src/keywharf/commands/output.py +125 -0
- keywharf-1.0.1/src/keywharf/commands/pull.py +45 -0
- keywharf-1.0.1/src/keywharf/commands/remote/__init__.py +3 -0
- keywharf-1.0.1/src/keywharf/commands/remote/group.py +22 -0
- keywharf-1.0.1/src/keywharf/commands/remote/helpers.py +43 -0
- keywharf-1.0.1/src/keywharf/commands/remote/host.py +195 -0
- keywharf-1.0.1/src/keywharf/commands/remote/output.py +71 -0
- keywharf-1.0.1/src/keywharf/commands/render.py +21 -0
- keywharf-1.0.1/src/keywharf/commands/select.py +63 -0
- keywharf-1.0.1/src/keywharf/commands/validate.py +21 -0
- keywharf-1.0.1/src/keywharf/config/__init__.py +3 -0
- keywharf-1.0.1/src/keywharf/config/loader.py +91 -0
- keywharf-1.0.1/src/keywharf/config/merge.py +26 -0
- keywharf-1.0.1/src/keywharf/config/models.py +65 -0
- keywharf-1.0.1/src/keywharf/config/resolver.py +107 -0
- keywharf-1.0.1/src/keywharf/config/resources.py +60 -0
- keywharf-1.0.1/src/keywharf/config_defaults/manager.json +8 -0
- keywharf-1.0.1/src/keywharf/domain/__init__.py +3 -0
- keywharf-1.0.1/src/keywharf/domain/errors.py +15 -0
- keywharf-1.0.1/src/keywharf/domain/models.py +354 -0
- keywharf-1.0.1/src/keywharf/domain/results.py +187 -0
- keywharf-1.0.1/src/keywharf/runtime/__init__.py +3 -0
- keywharf-1.0.1/src/keywharf/runtime/paths.py +130 -0
- keywharf-1.0.1/src/keywharf/services/__init__.py +3 -0
- keywharf-1.0.1/src/keywharf/services/apply.py +124 -0
- keywharf-1.0.1/src/keywharf/services/init.py +212 -0
- keywharf-1.0.1/src/keywharf/services/install_include.py +38 -0
- keywharf-1.0.1/src/keywharf/services/local_view.py +94 -0
- keywharf-1.0.1/src/keywharf/services/managed_config_applier.py +47 -0
- keywharf-1.0.1/src/keywharf/services/managed_config_renderer.py +10 -0
- keywharf-1.0.1/src/keywharf/services/managed_hosts.py +31 -0
- keywharf-1.0.1/src/keywharf/services/privilege.py +56 -0
- keywharf-1.0.1/src/keywharf/services/pull.py +28 -0
- keywharf-1.0.1/src/keywharf/services/remote_host_editor.py +339 -0
- keywharf-1.0.1/src/keywharf/services/remote_hosts.py +293 -0
- keywharf-1.0.1/src/keywharf/services/render.py +97 -0
- keywharf-1.0.1/src/keywharf/services/selections.py +88 -0
- keywharf-1.0.1/src/keywharf/services/validate.py +79 -0
- keywharf-1.0.1/src/keywharf/ssh_config/__init__.py +3 -0
- keywharf-1.0.1/src/keywharf/ssh_config/builder.py +96 -0
- keywharf-1.0.1/src/keywharf/ssh_config/parser.py +47 -0
- keywharf-1.0.1/src/keywharf/ssh_config/render.py +52 -0
- keywharf-1.0.1/src/keywharf/storage/__init__.py +3 -0
- keywharf-1.0.1/src/keywharf/storage/git_repo.py +81 -0
- keywharf-1.0.1/src/keywharf/storage/json_store.py +47 -0
- keywharf-1.0.1/src/keywharf/storage/managed_files.py +184 -0
- keywharf-1.0.1/src/keywharf/storage/remote_repo.py +27 -0
- keywharf-1.0.1/src/keywharf/storage/ssh_files.py +90 -0
- keywharf-1.0.1/src/keywharf/storage/state_store.py +76 -0
- keywharf-1.0.1/src/keywharf/templates/include_block.j2 +2 -0
- keywharf-1.0.1/src/keywharf/templates/init_state.json +4 -0
- keywharf-1.0.1/src/keywharf/templates/workspace_README.md.j2 +21 -0
- keywharf-1.0.1/src/keywharf/templates/workspace_gitignore.j2 +5 -0
- keywharf-1.0.1/src/keywharf/version.py +5 -0
- keywharf-1.0.1/src/keywharf.egg-info/PKG-INFO +155 -0
- keywharf-1.0.1/src/keywharf.egg-info/SOURCES.txt +91 -0
- keywharf-1.0.1/src/keywharf.egg-info/dependency_links.txt +1 -0
- keywharf-1.0.1/src/keywharf.egg-info/entry_points.txt +2 -0
- keywharf-1.0.1/src/keywharf.egg-info/requires.txt +9 -0
- keywharf-1.0.1/src/keywharf.egg-info/top_level.txt +1 -0
- keywharf-1.0.1/tests/test_apply_service.py +121 -0
- keywharf-1.0.1/tests/test_cli.py +57 -0
- keywharf-1.0.1/tests/test_config.py +52 -0
- keywharf-1.0.1/tests/test_import_surfaces.py +9 -0
- keywharf-1.0.1/tests/test_init.py +73 -0
- keywharf-1.0.1/tests/test_install_include.py +55 -0
- keywharf-1.0.1/tests/test_local_commands.py +101 -0
- keywharf-1.0.1/tests/test_package_resources.py +47 -0
- keywharf-1.0.1/tests/test_privilege.py +89 -0
- keywharf-1.0.1/tests/test_remote_host_commands.py +220 -0
- keywharf-1.0.1/tests/test_render_service.py +81 -0
- keywharf-1.0.1/tests/test_runtime_paths.py +128 -0
- keywharf-1.0.1/tests/test_select_commands.py +115 -0
- keywharf-1.0.1/tests/test_state_store.py +39 -0
- keywharf-1.0.1/tests/test_validate_service.py +156 -0
- keywharf-1.0.1/tests/test_version.py +17 -0
keywharf-1.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 keywharf
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
keywharf-1.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keywharf
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Manage local SSH config alongside a remote key repository.
|
|
5
|
+
Author: keywharf
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: ssh,automation,cli,configuration
|
|
8
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: System Administrators
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: System :: Systems Administration
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: jinja2<4,>=3.1
|
|
20
|
+
Requires-Dist: pydantic<3,>=2.7
|
|
21
|
+
Requires-Dist: rich>=13
|
|
22
|
+
Requires-Dist: typer>=0.12
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
25
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
26
|
+
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# keywharf
|
|
30
|
+
|
|
31
|
+
`keywharf` is a Python 3.11+ CLI for selecting remote SSH host definitions into a local desired state, then materializing only manager-owned SSH artifacts.
|
|
32
|
+
|
|
33
|
+
It manages only:
|
|
34
|
+
|
|
35
|
+
- one explicit local state file
|
|
36
|
+
- one managed SSH config fragment
|
|
37
|
+
- one managed key directory
|
|
38
|
+
|
|
39
|
+
It does not take over the user's whole `~/.ssh/config`. Only `install-include` may minimally append one `Include` block to the main SSH config.
|
|
40
|
+
|
|
41
|
+
## Recommended Workflow
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
keywharf --data-root ~/keywharf init
|
|
45
|
+
keywharf --data-root ~/keywharf pull
|
|
46
|
+
keywharf --data-root ~/keywharf remote host list
|
|
47
|
+
keywharf --data-root ~/keywharf remote host show demo
|
|
48
|
+
keywharf --data-root ~/keywharf remote host add demo --hostname demo.example.com --user fox --identity-file keys/id_demo
|
|
49
|
+
keywharf --data-root ~/keywharf select demo --endpoint public --auth home
|
|
50
|
+
keywharf --data-root ~/keywharf validate
|
|
51
|
+
keywharf --data-root ~/keywharf render
|
|
52
|
+
keywharf --data-root ~/keywharf apply
|
|
53
|
+
keywharf --data-root ~/keywharf install-include
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
If the manager config lives outside the default workspace root, use `--config <path>` instead of `--data-root`.
|
|
57
|
+
|
|
58
|
+
## Ownership Boundary
|
|
59
|
+
|
|
60
|
+
`keywharf` manages:
|
|
61
|
+
|
|
62
|
+
- `state_path`
|
|
63
|
+
- `managed_config_path`
|
|
64
|
+
- `managed_keys_dir`
|
|
65
|
+
|
|
66
|
+
`keywharf` does not manage:
|
|
67
|
+
|
|
68
|
+
- unrelated `Host` entries in the main SSH config
|
|
69
|
+
- `Match` blocks
|
|
70
|
+
- other `Include` lines
|
|
71
|
+
- user comments and ordering in the main SSH config
|
|
72
|
+
|
|
73
|
+
## Workspace Discovery
|
|
74
|
+
|
|
75
|
+
Workspace resolution is explicit and predictable:
|
|
76
|
+
|
|
77
|
+
1. `--data-root`
|
|
78
|
+
2. `KEYWHARF_DATA_ROOT`
|
|
79
|
+
3. current directory, if it already contains both the `KEYWHARF_DATA_ROOT` marker and `config.json`
|
|
80
|
+
4. nearest ancestor workspace marker
|
|
81
|
+
5. `~/keywharf`
|
|
82
|
+
6. fail with the checked candidate paths listed
|
|
83
|
+
|
|
84
|
+
`keywharf init` creates the marker, `config.json`, `state/state.json`, directory skeleton, and small workspace text files from package resources.
|
|
85
|
+
|
|
86
|
+
## Formal Config And Templates
|
|
87
|
+
|
|
88
|
+
Manager config is a formal runtime config:
|
|
89
|
+
|
|
90
|
+
- defaults come from `pkg://keywharf/config_defaults/manager.json`
|
|
91
|
+
- file or mapping input is override only
|
|
92
|
+
- defaults and overrides are deep-merged before Pydantic v2 validation
|
|
93
|
+
- runtime path resolution is separate from raw config loading
|
|
94
|
+
|
|
95
|
+
Resource roles are intentionally split:
|
|
96
|
+
|
|
97
|
+
- `config_defaults/*.json`: formal defaults for manager config
|
|
98
|
+
- `templates/*.json`: structured starter data such as the empty state file
|
|
99
|
+
- `templates/*.j2`: human-facing text templates such as workspace `README.md`, workspace `.gitignore`, and the include block text
|
|
100
|
+
|
|
101
|
+
## Remote Host CRUD
|
|
102
|
+
|
|
103
|
+
`remote host` edits only the local checkout copy of the remote repository config:
|
|
104
|
+
|
|
105
|
+
- `remote host list`
|
|
106
|
+
- `remote host show`
|
|
107
|
+
- `remote host add`
|
|
108
|
+
- `remote host update`
|
|
109
|
+
- `remote host remove`
|
|
110
|
+
|
|
111
|
+
These commands do not commit, push, or mutate git metadata. They perform structured JSON reads/writes, preserve array order, and revalidate the resulting host set before writing.
|
|
112
|
+
|
|
113
|
+
This round only adds Host-level CRUD. `ExtraConfig` is preserved and rendered, but it is not exposed as a CLI editor yet.
|
|
114
|
+
|
|
115
|
+
## `--sudo`
|
|
116
|
+
|
|
117
|
+
Mutating commands support `--sudo`:
|
|
118
|
+
|
|
119
|
+
- `init`
|
|
120
|
+
- `pull`
|
|
121
|
+
- `select`
|
|
122
|
+
- `deselect`
|
|
123
|
+
- `apply`
|
|
124
|
+
- `install-include`
|
|
125
|
+
- `remote host add`
|
|
126
|
+
- `remote host update`
|
|
127
|
+
- `remote host remove`
|
|
128
|
+
|
|
129
|
+
Privilege handling is centralized:
|
|
130
|
+
|
|
131
|
+
- normal writable paths run without sudo
|
|
132
|
+
- unwritable paths fail fast with concrete path-based reasons
|
|
133
|
+
- `--sudo` re-execs the full command through `sudo`
|
|
134
|
+
|
|
135
|
+
## Installation
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
python3.11 -m venv .venv
|
|
139
|
+
. .venv/bin/activate
|
|
140
|
+
python -m pip install -e '.[dev]'
|
|
141
|
+
pytest
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Runtime requirements:
|
|
145
|
+
|
|
146
|
+
- Python 3.11+
|
|
147
|
+
- system `git`
|
|
148
|
+
|
|
149
|
+
## Documentation
|
|
150
|
+
|
|
151
|
+
- [`docs/architecture.md`](docs/architecture.md)
|
|
152
|
+
- [`docs/configuration.md`](docs/configuration.md)
|
|
153
|
+
- [`docs/cli.md`](docs/cli.md)
|
|
154
|
+
- [`docs/development.md`](docs/development.md)
|
|
155
|
+
- [`CHANGELOG.md`](CHANGELOG.md)
|
keywharf-1.0.1/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# keywharf
|
|
2
|
+
|
|
3
|
+
`keywharf` is a Python 3.11+ CLI for selecting remote SSH host definitions into a local desired state, then materializing only manager-owned SSH artifacts.
|
|
4
|
+
|
|
5
|
+
It manages only:
|
|
6
|
+
|
|
7
|
+
- one explicit local state file
|
|
8
|
+
- one managed SSH config fragment
|
|
9
|
+
- one managed key directory
|
|
10
|
+
|
|
11
|
+
It does not take over the user's whole `~/.ssh/config`. Only `install-include` may minimally append one `Include` block to the main SSH config.
|
|
12
|
+
|
|
13
|
+
## Recommended Workflow
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
keywharf --data-root ~/keywharf init
|
|
17
|
+
keywharf --data-root ~/keywharf pull
|
|
18
|
+
keywharf --data-root ~/keywharf remote host list
|
|
19
|
+
keywharf --data-root ~/keywharf remote host show demo
|
|
20
|
+
keywharf --data-root ~/keywharf remote host add demo --hostname demo.example.com --user fox --identity-file keys/id_demo
|
|
21
|
+
keywharf --data-root ~/keywharf select demo --endpoint public --auth home
|
|
22
|
+
keywharf --data-root ~/keywharf validate
|
|
23
|
+
keywharf --data-root ~/keywharf render
|
|
24
|
+
keywharf --data-root ~/keywharf apply
|
|
25
|
+
keywharf --data-root ~/keywharf install-include
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
If the manager config lives outside the default workspace root, use `--config <path>` instead of `--data-root`.
|
|
29
|
+
|
|
30
|
+
## Ownership Boundary
|
|
31
|
+
|
|
32
|
+
`keywharf` manages:
|
|
33
|
+
|
|
34
|
+
- `state_path`
|
|
35
|
+
- `managed_config_path`
|
|
36
|
+
- `managed_keys_dir`
|
|
37
|
+
|
|
38
|
+
`keywharf` does not manage:
|
|
39
|
+
|
|
40
|
+
- unrelated `Host` entries in the main SSH config
|
|
41
|
+
- `Match` blocks
|
|
42
|
+
- other `Include` lines
|
|
43
|
+
- user comments and ordering in the main SSH config
|
|
44
|
+
|
|
45
|
+
## Workspace Discovery
|
|
46
|
+
|
|
47
|
+
Workspace resolution is explicit and predictable:
|
|
48
|
+
|
|
49
|
+
1. `--data-root`
|
|
50
|
+
2. `KEYWHARF_DATA_ROOT`
|
|
51
|
+
3. current directory, if it already contains both the `KEYWHARF_DATA_ROOT` marker and `config.json`
|
|
52
|
+
4. nearest ancestor workspace marker
|
|
53
|
+
5. `~/keywharf`
|
|
54
|
+
6. fail with the checked candidate paths listed
|
|
55
|
+
|
|
56
|
+
`keywharf init` creates the marker, `config.json`, `state/state.json`, directory skeleton, and small workspace text files from package resources.
|
|
57
|
+
|
|
58
|
+
## Formal Config And Templates
|
|
59
|
+
|
|
60
|
+
Manager config is a formal runtime config:
|
|
61
|
+
|
|
62
|
+
- defaults come from `pkg://keywharf/config_defaults/manager.json`
|
|
63
|
+
- file or mapping input is override only
|
|
64
|
+
- defaults and overrides are deep-merged before Pydantic v2 validation
|
|
65
|
+
- runtime path resolution is separate from raw config loading
|
|
66
|
+
|
|
67
|
+
Resource roles are intentionally split:
|
|
68
|
+
|
|
69
|
+
- `config_defaults/*.json`: formal defaults for manager config
|
|
70
|
+
- `templates/*.json`: structured starter data such as the empty state file
|
|
71
|
+
- `templates/*.j2`: human-facing text templates such as workspace `README.md`, workspace `.gitignore`, and the include block text
|
|
72
|
+
|
|
73
|
+
## Remote Host CRUD
|
|
74
|
+
|
|
75
|
+
`remote host` edits only the local checkout copy of the remote repository config:
|
|
76
|
+
|
|
77
|
+
- `remote host list`
|
|
78
|
+
- `remote host show`
|
|
79
|
+
- `remote host add`
|
|
80
|
+
- `remote host update`
|
|
81
|
+
- `remote host remove`
|
|
82
|
+
|
|
83
|
+
These commands do not commit, push, or mutate git metadata. They perform structured JSON reads/writes, preserve array order, and revalidate the resulting host set before writing.
|
|
84
|
+
|
|
85
|
+
This round only adds Host-level CRUD. `ExtraConfig` is preserved and rendered, but it is not exposed as a CLI editor yet.
|
|
86
|
+
|
|
87
|
+
## `--sudo`
|
|
88
|
+
|
|
89
|
+
Mutating commands support `--sudo`:
|
|
90
|
+
|
|
91
|
+
- `init`
|
|
92
|
+
- `pull`
|
|
93
|
+
- `select`
|
|
94
|
+
- `deselect`
|
|
95
|
+
- `apply`
|
|
96
|
+
- `install-include`
|
|
97
|
+
- `remote host add`
|
|
98
|
+
- `remote host update`
|
|
99
|
+
- `remote host remove`
|
|
100
|
+
|
|
101
|
+
Privilege handling is centralized:
|
|
102
|
+
|
|
103
|
+
- normal writable paths run without sudo
|
|
104
|
+
- unwritable paths fail fast with concrete path-based reasons
|
|
105
|
+
- `--sudo` re-execs the full command through `sudo`
|
|
106
|
+
|
|
107
|
+
## Installation
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
python3.11 -m venv .venv
|
|
111
|
+
. .venv/bin/activate
|
|
112
|
+
python -m pip install -e '.[dev]'
|
|
113
|
+
pytest
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Runtime requirements:
|
|
117
|
+
|
|
118
|
+
- Python 3.11+
|
|
119
|
+
- system `git`
|
|
120
|
+
|
|
121
|
+
## Documentation
|
|
122
|
+
|
|
123
|
+
- [`docs/architecture.md`](docs/architecture.md)
|
|
124
|
+
- [`docs/configuration.md`](docs/configuration.md)
|
|
125
|
+
- [`docs/cli.md`](docs/cli.md)
|
|
126
|
+
- [`docs/development.md`](docs/development.md)
|
|
127
|
+
- [`CHANGELOG.md`](CHANGELOG.md)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "keywharf"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Manage local SSH config alongside a remote key repository."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
authors = [{ name = "keywharf" }]
|
|
12
|
+
license = { text = "MIT" }
|
|
13
|
+
keywords = ["ssh", "automation", "cli", "configuration"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: System Administrators",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Topic :: System :: Systems Administration",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"jinja2>=3.1,<4",
|
|
26
|
+
"pydantic>=2.7,<3",
|
|
27
|
+
"rich>=13",
|
|
28
|
+
"typer>=0.12",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=7",
|
|
34
|
+
"ruff>=0.5",
|
|
35
|
+
"mypy>=1.8",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
keywharf = "keywharf.cli:main"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools]
|
|
42
|
+
package-dir = {"" = "src"}
|
|
43
|
+
license-files = ["LICENSE"]
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.packages.find]
|
|
46
|
+
where = ["src"]
|
|
47
|
+
include = ["keywharf*"]
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.package-data]
|
|
50
|
+
keywharf = ["config_defaults/**/*.json", "templates/**/*.json", "templates/**/*.j2"]
|
|
51
|
+
|
|
52
|
+
[tool.setuptools.dynamic]
|
|
53
|
+
version = {attr = "keywharf.version.__version__"}
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
pythonpath = ["src"]
|
keywharf-1.0.1/setup.cfg
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""CLI entrypoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from keywharf.commands import register
|
|
10
|
+
from keywharf.commands.context import build_cli_state
|
|
11
|
+
from keywharf.domain.errors import KeywharfError
|
|
12
|
+
from keywharf.version import __version__
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="keywharf",
|
|
17
|
+
help="Select remote SSH hosts into local desired state, then render/apply keywharf owned SSH config fragments.",
|
|
18
|
+
invoke_without_command=True,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.callback()
|
|
23
|
+
def callback(
|
|
24
|
+
ctx: typer.Context,
|
|
25
|
+
config: Path | None = typer.Option(
|
|
26
|
+
None,
|
|
27
|
+
"--config",
|
|
28
|
+
"-c",
|
|
29
|
+
help="Path to the keywharf config.json file (defaults to resolved data root/config.json).",
|
|
30
|
+
dir_okay=False,
|
|
31
|
+
exists=False,
|
|
32
|
+
readable=True,
|
|
33
|
+
),
|
|
34
|
+
data_root: Path | None = typer.Option(
|
|
35
|
+
None,
|
|
36
|
+
"--data-root",
|
|
37
|
+
help="Explicit keywharf workspace root.",
|
|
38
|
+
file_okay=False,
|
|
39
|
+
dir_okay=True,
|
|
40
|
+
exists=False,
|
|
41
|
+
readable=True,
|
|
42
|
+
),
|
|
43
|
+
version: bool = typer.Option(
|
|
44
|
+
False,
|
|
45
|
+
"--version",
|
|
46
|
+
help="Show version and exit.",
|
|
47
|
+
is_eager=True,
|
|
48
|
+
),
|
|
49
|
+
) -> None:
|
|
50
|
+
if version:
|
|
51
|
+
typer.echo(__version__)
|
|
52
|
+
raise typer.Exit()
|
|
53
|
+
|
|
54
|
+
ctx.obj = build_cli_state(
|
|
55
|
+
config,
|
|
56
|
+
data_root.expanduser().resolve() if data_root is not None else None,
|
|
57
|
+
)
|
|
58
|
+
if ctx.invoked_subcommand is None:
|
|
59
|
+
typer.echo(ctx.get_help())
|
|
60
|
+
raise typer.Exit()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
register(app)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main() -> None:
|
|
67
|
+
try:
|
|
68
|
+
app()
|
|
69
|
+
except KeywharfError as exc:
|
|
70
|
+
typer.echo(str(exc), err=True)
|
|
71
|
+
raise typer.Exit(code=exc.exit_code) from exc
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
main()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Typer command registration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from keywharf.commands import apply, deselect, init, install_include, local, pull, render, select, validate
|
|
8
|
+
from keywharf.commands.remote.group import app as remote_app
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register(app: typer.Typer) -> None:
|
|
12
|
+
for module in [init, pull, validate, render, apply, install_include, select, deselect]:
|
|
13
|
+
module.register(app)
|
|
14
|
+
app.add_typer(local.app, name="local")
|
|
15
|
+
app.add_typer(remote_app, name="remote")
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Canonical CLI invocation helpers for retry hints and sudo re-exec."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import shlex
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any, Mapping
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
from click.core import ParameterSource
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_AUTO_EXCLUDED_PARAMETER_NAMES = {"install_completion", "show_completion"}
|
|
18
|
+
_DEFAULT_PARAMETER_SOURCES = {None, ParameterSource.DEFAULT, ParameterSource.DEFAULT_MAP}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class CommandInvocation:
|
|
23
|
+
"""Stable, canonical argv for the current command."""
|
|
24
|
+
|
|
25
|
+
argv: list[str]
|
|
26
|
+
|
|
27
|
+
def display(self) -> str:
|
|
28
|
+
return shlex.join(["keywharf", *self.argv])
|
|
29
|
+
|
|
30
|
+
def with_sudo_flag(self) -> "CommandInvocation":
|
|
31
|
+
if "--sudo" in self.argv:
|
|
32
|
+
return CommandInvocation([*self.argv])
|
|
33
|
+
return CommandInvocation([*self.argv, "--sudo"])
|
|
34
|
+
|
|
35
|
+
def sudo_exec_args(self) -> list[str]:
|
|
36
|
+
cli_path = Path(sys.executable).resolve().with_name("keywharf")
|
|
37
|
+
sudo_argv = self.with_sudo_flag().argv
|
|
38
|
+
if cli_path.exists() and os.access(cli_path, os.X_OK):
|
|
39
|
+
return ["sudo", str(cli_path), *sudo_argv]
|
|
40
|
+
return ["sudo", sys.executable, "-m", "keywharf", *sudo_argv]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_command_invocation(
|
|
44
|
+
ctx: click.Context,
|
|
45
|
+
*,
|
|
46
|
+
overrides: Mapping[str, Any] | None = None,
|
|
47
|
+
exclude: tuple[str, ...] = ("sudo",),
|
|
48
|
+
) -> CommandInvocation:
|
|
49
|
+
"""Build one canonical argv for the current Click/Typer command context."""
|
|
50
|
+
|
|
51
|
+
override_map = dict(overrides or {})
|
|
52
|
+
excluded_names = set(exclude) | _AUTO_EXCLUDED_PARAMETER_NAMES
|
|
53
|
+
argv: list[str] = []
|
|
54
|
+
context_chain = _context_chain(ctx)
|
|
55
|
+
|
|
56
|
+
root_ctx = context_chain[0]
|
|
57
|
+
argv.extend(
|
|
58
|
+
_serialize_context_params(root_ctx, excluded_names=excluded_names, overrides=override_map)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
for command_ctx in context_chain[1:]:
|
|
62
|
+
command_name = command_ctx.info_name or command_ctx.command.name
|
|
63
|
+
if command_name:
|
|
64
|
+
argv.append(command_name)
|
|
65
|
+
argv.extend(
|
|
66
|
+
_serialize_context_params(
|
|
67
|
+
command_ctx,
|
|
68
|
+
excluded_names=excluded_names,
|
|
69
|
+
overrides=override_map,
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return CommandInvocation(argv)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _context_chain(ctx: click.Context) -> list[click.Context]:
|
|
77
|
+
chain: list[click.Context] = []
|
|
78
|
+
current: click.Context | None = ctx
|
|
79
|
+
while current is not None:
|
|
80
|
+
chain.append(current)
|
|
81
|
+
current = current.parent
|
|
82
|
+
chain.reverse()
|
|
83
|
+
return chain
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _serialize_context_params(
|
|
87
|
+
ctx: click.Context,
|
|
88
|
+
*,
|
|
89
|
+
excluded_names: set[str],
|
|
90
|
+
overrides: Mapping[str, Any],
|
|
91
|
+
) -> list[str]:
|
|
92
|
+
argv: list[str] = []
|
|
93
|
+
for parameter in ctx.command.params:
|
|
94
|
+
if not parameter.expose_value:
|
|
95
|
+
continue
|
|
96
|
+
name = parameter.name
|
|
97
|
+
if name is None or name in excluded_names:
|
|
98
|
+
continue
|
|
99
|
+
value = overrides[name] if name in overrides else ctx.params.get(name)
|
|
100
|
+
if isinstance(parameter, click.Argument):
|
|
101
|
+
argv.extend(_serialize_argument(value))
|
|
102
|
+
continue
|
|
103
|
+
if name not in overrides and ctx.get_parameter_source(name) in _DEFAULT_PARAMETER_SOURCES:
|
|
104
|
+
continue
|
|
105
|
+
argv.extend(_serialize_option(parameter, value))
|
|
106
|
+
return argv
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _serialize_argument(value: Any) -> list[str]:
|
|
110
|
+
return [_serialize_scalar(item) for item in _iter_values(value)]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _serialize_option(parameter: click.Option, value: Any) -> list[str]:
|
|
114
|
+
if value is None:
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
if parameter.is_bool_flag:
|
|
118
|
+
if value:
|
|
119
|
+
return [parameter.opts[0]]
|
|
120
|
+
if parameter.secondary_opts:
|
|
121
|
+
return [parameter.secondary_opts[0]]
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
if parameter.multiple:
|
|
125
|
+
argv: list[str] = []
|
|
126
|
+
for item in _iter_values(value):
|
|
127
|
+
argv.append(parameter.opts[0])
|
|
128
|
+
argv.extend(_serialize_composite_value(item))
|
|
129
|
+
return argv
|
|
130
|
+
|
|
131
|
+
return [parameter.opts[0], *_serialize_composite_value(value)]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _serialize_composite_value(value: Any) -> list[str]:
|
|
135
|
+
return [_serialize_scalar(item) for item in _iter_values(value)]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _iter_values(value: Any) -> list[Any]:
|
|
139
|
+
if value is None:
|
|
140
|
+
return []
|
|
141
|
+
if isinstance(value, tuple):
|
|
142
|
+
return list(value)
|
|
143
|
+
if isinstance(value, list):
|
|
144
|
+
return value
|
|
145
|
+
return [value]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _serialize_scalar(value: Any) -> str:
|
|
149
|
+
if isinstance(value, Path):
|
|
150
|
+
return str(value)
|
|
151
|
+
if isinstance(value, Enum):
|
|
152
|
+
return str(value.value)
|
|
153
|
+
return str(value)
|
|
154
|
+
|