@mriekki/lean 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +67 -67
  2. package/assets/components.json +33 -33
  3. package/assets/core/distribution.json +51 -51
  4. package/assets/deployment/distribution.json +34 -34
  5. package/assets/generated/ASSEMBLY.json +28 -28
  6. package/assets/generated/deployment/distribution/lean-poc-deploy.py +8 -8
  7. package/assets/generated/deployment/src/lean_poc_deployment/__init__.py +3 -3
  8. package/assets/generated/deployment/src/lean_poc_deployment/__main__.py +3 -3
  9. package/assets/generated/deployment/src/lean_poc_deployment/app_cli.py +140 -140
  10. package/assets/generated/deployment/src/lean_poc_deployment/app_runtime.py +455 -455
  11. package/assets/generated/deployment/src/lean_poc_deployment/app_runtime_remote.py +415 -415
  12. package/assets/generated/deployment/src/lean_poc_deployment/box_platform.py +208 -208
  13. package/assets/generated/deployment/src/lean_poc_deployment/box_platform_assets.py +100 -100
  14. package/assets/generated/deployment/src/lean_poc_deployment/box_platform_remote.py +271 -271
  15. package/assets/generated/deployment/src/lean_poc_deployment/cli.py +437 -437
  16. package/assets/generated/deployment/src/lean_poc_deployment/doctor.py +121 -121
  17. package/assets/generated/deployment/src/lean_poc_deployment/entrypoint.py +25 -25
  18. package/assets/generated/deployment/src/lean_poc_deployment/errors.py +5 -5
  19. package/assets/generated/deployment/src/lean_poc_deployment/keys.py +171 -171
  20. package/assets/generated/deployment/src/lean_poc_deployment/profiles.py +204 -204
  21. package/assets/generated/deployment/src/lean_poc_deployment/route_registry.py +367 -367
  22. package/assets/generated/deployment/src/lean_poc_deployment/route_registry_remote.py +443 -443
  23. package/assets/generated/deployment/src/lean_poc_deployment/security.py +290 -290
  24. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter.py +334 -334
  25. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter_remote.py +339 -339
  26. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_cli.py +85 -85
  27. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_import_recovery.py +437 -437
  28. package/assets/generated/deployment/src/lean_poc_deployment/ssh.py +118 -118
  29. package/assets/generated/deployment/src/lean_poc_deployment/teardown.py +286 -286
  30. package/assets/generated/deployment/src/lean_poc_deployment/teardown_cli.py +120 -120
  31. package/assets/generated/deployment/src/lean_poc_deployment/teardown_remote.py +100 -100
  32. package/assets/generated/deployment/src/lean_poc_deployment/trust.py +278 -278
  33. package/bin/lean.mjs +9 -9
  34. package/package.json +33 -33
  35. package/src/cli.mjs +131 -131
  36. package/src/engine.mjs +243 -243
  37. package/src/package-data.mjs +18 -18
@@ -1,121 +1,121 @@
1
- """Read-only workstation and target readiness checks."""
2
-
3
- from __future__ import annotations
4
-
5
- import shutil
6
- from pathlib import Path
7
- from typing import Any
8
-
9
- from .errors import DeploymentKitError
10
- from .profiles import config_root, load_profile, target_profile_path
11
- from .security import check_ssh_material_security, validate_target_trust_file
12
- from .ssh import resolve_addresses
13
-
14
-
15
- def workstation_doctor(*, home: Path | None = None) -> dict[str, Any]:
16
- root = config_root(home)
17
- targets_dir = root / "targets"
18
- profiles = []
19
- if targets_dir.is_dir() and not targets_dir.is_symlink():
20
- profiles = sorted(path.stem for path in targets_dir.glob("*.json") if path.is_file() and not path.is_symlink())
21
-
22
- tools = {name: shutil.which(name) for name in ("ssh", "ssh-keygen", "ssh-keyscan", "ssh-add")}
23
- missing_tools = [name for name, path in tools.items() if path is None]
24
-
25
- if not root.exists() and not profiles:
26
- state = "fresh-workstation"
27
- elif missing_tools:
28
- state = "blocked"
29
- elif profiles:
30
- state = "configured"
31
- else:
32
- state = "partially-configured"
33
-
34
- return {
35
- "state": state,
36
- "configRoot": str(root),
37
- "profiles": profiles,
38
- "tools": tools,
39
- "missingTools": missing_tools,
40
- "mutated": False,
41
- }
42
-
43
-
44
- def target_doctor(name: str, *, home: Path | None = None) -> dict[str, Any]:
45
- path = target_profile_path(name, home)
46
- if not path.exists():
47
- return {
48
- "targetName": name,
49
- "state": "needs-profile",
50
- "profilePath": str(path),
51
- "nextAction": "configure-target-profile",
52
- "mutated": False,
53
- }
54
-
55
- try:
56
- profile = load_profile(name, home=home)
57
- except DeploymentKitError as error:
58
- return {
59
- "targetName": name,
60
- "state": "blocked",
61
- "profilePath": str(path),
62
- "reason": str(error),
63
- "mutated": False,
64
- }
65
-
66
- identity = Path(profile["identityFile"])
67
- known_hosts = Path(profile["knownHostsFile"])
68
- if not identity.is_file():
69
- return {
70
- "targetName": name,
71
- "state": "needs-key",
72
- "profilePath": str(path),
73
- "identityFile": str(identity),
74
- "nextAction": "create-or-select-dedicated-key",
75
- "mutated": False,
76
- }
77
- if not known_hosts.is_file() or known_hosts.stat().st_size == 0:
78
- return {
79
- "targetName": name,
80
- "state": "needs-trust",
81
- "profilePath": str(path),
82
- "knownHostsFile": str(known_hosts),
83
- "nextAction": "verify-console-fingerprint-before-trust-enrollment",
84
- "mutated": False,
85
- }
86
-
87
- try:
88
- check_ssh_material_security(identity, known_hosts)
89
- validate_target_trust_file(profile)
90
- except DeploymentKitError as error:
91
- return {
92
- "targetName": name,
93
- "state": "blocked",
94
- "profilePath": str(path),
95
- "reason": str(error),
96
- "nextAction": "repair-ssh-material-or-trust",
97
- "mutated": False,
98
- }
99
-
100
- try:
101
- addresses = resolve_addresses(profile)
102
- except DeploymentKitError as error:
103
- return {
104
- "targetName": name,
105
- "state": "needs-address",
106
- "profilePath": str(path),
107
- "hostName": profile["hostName"],
108
- "reason": str(error),
109
- "nextAction": "set-separately-verified-address-override",
110
- "mutated": False,
111
- }
112
-
113
- return {
114
- "targetName": name,
115
- "state": "ready-to-verify",
116
- "profilePath": str(path),
117
- "hostName": profile["hostName"],
118
- "addresses": addresses,
119
- "addressSource": "override" if profile["addressOverride"] else "dns-mdns",
120
- "mutated": False,
121
- }
1
+ """Read-only workstation and target readiness checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .errors import DeploymentKitError
10
+ from .profiles import config_root, load_profile, target_profile_path
11
+ from .security import check_ssh_material_security, validate_target_trust_file
12
+ from .ssh import resolve_addresses
13
+
14
+
15
+ def workstation_doctor(*, home: Path | None = None) -> dict[str, Any]:
16
+ root = config_root(home)
17
+ targets_dir = root / "targets"
18
+ profiles = []
19
+ if targets_dir.is_dir() and not targets_dir.is_symlink():
20
+ profiles = sorted(path.stem for path in targets_dir.glob("*.json") if path.is_file() and not path.is_symlink())
21
+
22
+ tools = {name: shutil.which(name) for name in ("ssh", "ssh-keygen", "ssh-keyscan", "ssh-add")}
23
+ missing_tools = [name for name, path in tools.items() if path is None]
24
+
25
+ if not root.exists() and not profiles:
26
+ state = "fresh-workstation"
27
+ elif missing_tools:
28
+ state = "blocked"
29
+ elif profiles:
30
+ state = "configured"
31
+ else:
32
+ state = "partially-configured"
33
+
34
+ return {
35
+ "state": state,
36
+ "configRoot": str(root),
37
+ "profiles": profiles,
38
+ "tools": tools,
39
+ "missingTools": missing_tools,
40
+ "mutated": False,
41
+ }
42
+
43
+
44
+ def target_doctor(name: str, *, home: Path | None = None) -> dict[str, Any]:
45
+ path = target_profile_path(name, home)
46
+ if not path.exists():
47
+ return {
48
+ "targetName": name,
49
+ "state": "needs-profile",
50
+ "profilePath": str(path),
51
+ "nextAction": "configure-target-profile",
52
+ "mutated": False,
53
+ }
54
+
55
+ try:
56
+ profile = load_profile(name, home=home)
57
+ except DeploymentKitError as error:
58
+ return {
59
+ "targetName": name,
60
+ "state": "blocked",
61
+ "profilePath": str(path),
62
+ "reason": str(error),
63
+ "mutated": False,
64
+ }
65
+
66
+ identity = Path(profile["identityFile"])
67
+ known_hosts = Path(profile["knownHostsFile"])
68
+ if not identity.is_file():
69
+ return {
70
+ "targetName": name,
71
+ "state": "needs-key",
72
+ "profilePath": str(path),
73
+ "identityFile": str(identity),
74
+ "nextAction": "create-or-select-dedicated-key",
75
+ "mutated": False,
76
+ }
77
+ if not known_hosts.is_file() or known_hosts.stat().st_size == 0:
78
+ return {
79
+ "targetName": name,
80
+ "state": "needs-trust",
81
+ "profilePath": str(path),
82
+ "knownHostsFile": str(known_hosts),
83
+ "nextAction": "verify-console-fingerprint-before-trust-enrollment",
84
+ "mutated": False,
85
+ }
86
+
87
+ try:
88
+ check_ssh_material_security(identity, known_hosts)
89
+ validate_target_trust_file(profile)
90
+ except DeploymentKitError as error:
91
+ return {
92
+ "targetName": name,
93
+ "state": "blocked",
94
+ "profilePath": str(path),
95
+ "reason": str(error),
96
+ "nextAction": "repair-ssh-material-or-trust",
97
+ "mutated": False,
98
+ }
99
+
100
+ try:
101
+ addresses = resolve_addresses(profile)
102
+ except DeploymentKitError as error:
103
+ return {
104
+ "targetName": name,
105
+ "state": "needs-address",
106
+ "profilePath": str(path),
107
+ "hostName": profile["hostName"],
108
+ "reason": str(error),
109
+ "nextAction": "set-separately-verified-address-override",
110
+ "mutated": False,
111
+ }
112
+
113
+ return {
114
+ "targetName": name,
115
+ "state": "ready-to-verify",
116
+ "profilePath": str(path),
117
+ "hostName": profile["hostName"],
118
+ "addresses": addresses,
119
+ "addressSource": "override" if profile["addressOverride"] else "dns-mdns",
120
+ "mutated": False,
121
+ }
@@ -1,25 +1,25 @@
1
- """Top-level console entrypoint for Deployment Kit command groups."""
2
-
3
- from __future__ import annotations
4
-
5
- import sys
6
-
7
- from .app_cli import main as app_main
8
- from .cli import main as core_main
9
- from .sqlite_cli import main as sqlite_main
10
- from .teardown_cli import main as teardown_main
11
-
12
-
13
- def main(argv: list[str] | None = None) -> int:
14
- args = list(sys.argv[1:] if argv is None else argv)
15
- if args and args[0] == "app":
16
- return app_main(args[1:])
17
- if args and args[0] == "sqlite":
18
- return sqlite_main(args[1:])
19
- if args and args[0] == "tear-down":
20
- return teardown_main(args[1:])
21
- if not args or args == ["--help"] or args == ["-h"]:
22
- print("Command group: app Generic immutable application runtime inventory/deployment")
23
- print("Command group: sqlite SQLite persistence import/backup/migration/rollback checks")
24
- print("Command group: tear-down Scoped destructive POC environment/local cleanup")
25
- return core_main(args)
1
+ """Top-level console entrypoint for Deployment Kit command groups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .app_cli import main as app_main
8
+ from .cli import main as core_main
9
+ from .sqlite_cli import main as sqlite_main
10
+ from .teardown_cli import main as teardown_main
11
+
12
+
13
+ def main(argv: list[str] | None = None) -> int:
14
+ args = list(sys.argv[1:] if argv is None else argv)
15
+ if args and args[0] == "app":
16
+ return app_main(args[1:])
17
+ if args and args[0] == "sqlite":
18
+ return sqlite_main(args[1:])
19
+ if args and args[0] == "tear-down":
20
+ return teardown_main(args[1:])
21
+ if not args or args == ["--help"] or args == ["-h"]:
22
+ print("Command group: app Generic immutable application runtime inventory/deployment")
23
+ print("Command group: sqlite SQLite persistence import/backup/migration/rollback checks")
24
+ print("Command group: tear-down Scoped destructive POC environment/local cleanup")
25
+ return core_main(args)
@@ -1,5 +1,5 @@
1
- """Deployment Kit exception types."""
2
-
3
-
4
- class DeploymentKitError(Exception):
5
- """Expected fail-closed operational or configuration error."""
1
+ """Deployment Kit exception types."""
2
+
3
+
4
+ class DeploymentKitError(Exception):
5
+ """Expected fail-closed operational or configuration error."""
@@ -1,171 +1,171 @@
1
- """Dedicated SSH-key and agent onboarding helpers."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
- import re
7
- import shutil
8
- import subprocess
9
- from pathlib import Path
10
- from typing import Any
11
-
12
- from .errors import DeploymentKitError
13
- from .security import check_identity_security, prepare_ssh_directory
14
-
15
-
16
- FINGERPRINT_RE = re.compile(r"^SHA256:[A-Za-z0-9+/]{43}$")
17
-
18
-
19
- def required_tool(name: str) -> str:
20
- path = shutil.which(name)
21
- if path is None:
22
- raise DeploymentKitError(f"Required OpenSSH tool is unavailable: {name}")
23
- return path
24
-
25
-
26
- def key_fingerprint(path: Path) -> str:
27
- if not path.is_file() or path.is_symlink():
28
- raise DeploymentKitError(f"SSH key file is missing or unsafe: {path}")
29
- result = subprocess.run(
30
- [required_tool("ssh-keygen"), "-lf", str(path)],
31
- check=False,
32
- capture_output=True,
33
- text=True,
34
- encoding="utf-8",
35
- errors="replace",
36
- )
37
- if result.returncode != 0:
38
- raise DeploymentKitError(f"Cannot read SSH key fingerprint for {path}.")
39
- fingerprint = next(
40
- (token for token in result.stdout.split() if FINGERPRINT_RE.fullmatch(token)),
41
- None,
42
- )
43
- if fingerprint is None:
44
- raise DeploymentKitError(f"ssh-keygen returned no valid SHA256 fingerprint for {path}.")
45
- return fingerprint
46
-
47
-
48
- def create_dedicated_key(profile: dict[str, Any]) -> dict[str, Any]:
49
- identity = Path(profile["identityFile"])
50
- public = Path(f"{identity}.pub")
51
- if identity.exists() or public.exists():
52
- raise DeploymentKitError(
53
- f"Refusing to overwrite existing SSH key material: {identity} / {public}"
54
- )
55
-
56
- prepare_ssh_directory(identity.parent)
57
- comment = f"lean-poc:{profile['targetName']}:{profile['sshAlias']}"
58
- result = subprocess.run(
59
- [
60
- required_tool("ssh-keygen"),
61
- "-q",
62
- "-t",
63
- "ed25519",
64
- "-a",
65
- "64",
66
- "-f",
67
- str(identity),
68
- "-C",
69
- comment,
70
- ],
71
- check=False,
72
- )
73
- if result.returncode != 0:
74
- raise DeploymentKitError(
75
- "ssh-keygen did not complete successfully. Any partial key material is left in place for explicit inspection."
76
- )
77
- if not identity.is_file() or identity.is_symlink() or not public.is_file() or public.is_symlink():
78
- raise DeploymentKitError("ssh-keygen completed without the expected private/public key pair.")
79
-
80
- if os.name != "nt":
81
- identity.chmod(0o600)
82
- public.chmod(0o644)
83
- check_identity_security(identity)
84
- return {
85
- "targetName": profile["targetName"],
86
- "identityFile": str(identity),
87
- "publicKeyFile": str(public),
88
- "identityFingerprint": key_fingerprint(identity),
89
- "status": "created",
90
- "mutated": True,
91
- }
92
-
93
-
94
- def public_key_info(profile: dict[str, Any]) -> dict[str, str]:
95
- identity = Path(profile["identityFile"])
96
- public = Path(f"{identity}.pub")
97
- check_identity_security(identity)
98
- if not public.is_file() or public.is_symlink():
99
- raise DeploymentKitError(f"Dedicated public key is missing or unsafe: {public}")
100
- try:
101
- lines = [line.strip() for line in public.read_text(encoding="utf-8").splitlines() if line.strip()]
102
- except (OSError, UnicodeError) as error:
103
- raise DeploymentKitError(f"Cannot read dedicated public key {public}: {error}") from error
104
- if len(lines) != 1 or not lines[0].startswith("ssh-ed25519 "):
105
- raise DeploymentKitError("Dedicated public-key file must contain exactly one ssh-ed25519 key.")
106
- return {
107
- "targetName": profile["targetName"],
108
- "publicKeyFile": str(public),
109
- "identityFingerprint": key_fingerprint(identity),
110
- "publicKey": lines[0],
111
- }
112
-
113
-
114
- def agent_status(profile: dict[str, Any]) -> dict[str, Any]:
115
- identity = Path(profile["identityFile"])
116
- check_identity_security(identity)
117
- fingerprint = key_fingerprint(identity)
118
- result = subprocess.run(
119
- [required_tool("ssh-add"), "-l"],
120
- check=False,
121
- capture_output=True,
122
- text=True,
123
- encoding="utf-8",
124
- errors="replace",
125
- )
126
- if result.returncode == 2:
127
- return {
128
- "targetName": profile["targetName"],
129
- "state": "agent-unavailable",
130
- "identityFingerprint": fingerprint,
131
- "loaded": False,
132
- "mutated": False,
133
- }
134
- if result.returncode == 1:
135
- return {
136
- "targetName": profile["targetName"],
137
- "state": "not-loaded",
138
- "identityFingerprint": fingerprint,
139
- "loaded": False,
140
- "mutated": False,
141
- }
142
- if result.returncode != 0:
143
- raise DeploymentKitError(f"ssh-add could not inspect the running SSH agent (exit {result.returncode}).")
144
- loaded = fingerprint in result.stdout.split()
145
- return {
146
- "targetName": profile["targetName"],
147
- "state": "loaded" if loaded else "not-loaded",
148
- "identityFingerprint": fingerprint,
149
- "loaded": loaded,
150
- "mutated": False,
151
- }
152
-
153
-
154
- def add_key_to_agent(profile: dict[str, Any]) -> dict[str, Any]:
155
- before = agent_status(profile)
156
- if before["state"] == "agent-unavailable":
157
- raise DeploymentKitError(
158
- "The SSH agent is unavailable. Start/enable it as a separate workstation action; "
159
- "the Deployment Kit will not change agent service configuration implicitly."
160
- )
161
- if before["loaded"]:
162
- return {**before, "status": "already-loaded"}
163
-
164
- identity = Path(profile["identityFile"])
165
- result = subprocess.run([required_tool("ssh-add"), str(identity)], check=False)
166
- if result.returncode != 0:
167
- raise DeploymentKitError("ssh-add did not load the dedicated identity.")
168
- after = agent_status(profile)
169
- if not after["loaded"]:
170
- raise DeploymentKitError("ssh-add reported success but the dedicated identity is not visible in the agent.")
171
- return {**after, "status": "loaded", "mutated": True}
1
+ """Dedicated SSH-key and agent onboarding helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .errors import DeploymentKitError
13
+ from .security import check_identity_security, prepare_ssh_directory
14
+
15
+
16
+ FINGERPRINT_RE = re.compile(r"^SHA256:[A-Za-z0-9+/]{43}$")
17
+
18
+
19
+ def required_tool(name: str) -> str:
20
+ path = shutil.which(name)
21
+ if path is None:
22
+ raise DeploymentKitError(f"Required OpenSSH tool is unavailable: {name}")
23
+ return path
24
+
25
+
26
+ def key_fingerprint(path: Path) -> str:
27
+ if not path.is_file() or path.is_symlink():
28
+ raise DeploymentKitError(f"SSH key file is missing or unsafe: {path}")
29
+ result = subprocess.run(
30
+ [required_tool("ssh-keygen"), "-lf", str(path)],
31
+ check=False,
32
+ capture_output=True,
33
+ text=True,
34
+ encoding="utf-8",
35
+ errors="replace",
36
+ )
37
+ if result.returncode != 0:
38
+ raise DeploymentKitError(f"Cannot read SSH key fingerprint for {path}.")
39
+ fingerprint = next(
40
+ (token for token in result.stdout.split() if FINGERPRINT_RE.fullmatch(token)),
41
+ None,
42
+ )
43
+ if fingerprint is None:
44
+ raise DeploymentKitError(f"ssh-keygen returned no valid SHA256 fingerprint for {path}.")
45
+ return fingerprint
46
+
47
+
48
+ def create_dedicated_key(profile: dict[str, Any]) -> dict[str, Any]:
49
+ identity = Path(profile["identityFile"])
50
+ public = Path(f"{identity}.pub")
51
+ if identity.exists() or public.exists():
52
+ raise DeploymentKitError(
53
+ f"Refusing to overwrite existing SSH key material: {identity} / {public}"
54
+ )
55
+
56
+ prepare_ssh_directory(identity.parent)
57
+ comment = f"lean-poc:{profile['targetName']}:{profile['sshAlias']}"
58
+ result = subprocess.run(
59
+ [
60
+ required_tool("ssh-keygen"),
61
+ "-q",
62
+ "-t",
63
+ "ed25519",
64
+ "-a",
65
+ "64",
66
+ "-f",
67
+ str(identity),
68
+ "-C",
69
+ comment,
70
+ ],
71
+ check=False,
72
+ )
73
+ if result.returncode != 0:
74
+ raise DeploymentKitError(
75
+ "ssh-keygen did not complete successfully. Any partial key material is left in place for explicit inspection."
76
+ )
77
+ if not identity.is_file() or identity.is_symlink() or not public.is_file() or public.is_symlink():
78
+ raise DeploymentKitError("ssh-keygen completed without the expected private/public key pair.")
79
+
80
+ if os.name != "nt":
81
+ identity.chmod(0o600)
82
+ public.chmod(0o644)
83
+ check_identity_security(identity)
84
+ return {
85
+ "targetName": profile["targetName"],
86
+ "identityFile": str(identity),
87
+ "publicKeyFile": str(public),
88
+ "identityFingerprint": key_fingerprint(identity),
89
+ "status": "created",
90
+ "mutated": True,
91
+ }
92
+
93
+
94
+ def public_key_info(profile: dict[str, Any]) -> dict[str, str]:
95
+ identity = Path(profile["identityFile"])
96
+ public = Path(f"{identity}.pub")
97
+ check_identity_security(identity)
98
+ if not public.is_file() or public.is_symlink():
99
+ raise DeploymentKitError(f"Dedicated public key is missing or unsafe: {public}")
100
+ try:
101
+ lines = [line.strip() for line in public.read_text(encoding="utf-8").splitlines() if line.strip()]
102
+ except (OSError, UnicodeError) as error:
103
+ raise DeploymentKitError(f"Cannot read dedicated public key {public}: {error}") from error
104
+ if len(lines) != 1 or not lines[0].startswith("ssh-ed25519 "):
105
+ raise DeploymentKitError("Dedicated public-key file must contain exactly one ssh-ed25519 key.")
106
+ return {
107
+ "targetName": profile["targetName"],
108
+ "publicKeyFile": str(public),
109
+ "identityFingerprint": key_fingerprint(identity),
110
+ "publicKey": lines[0],
111
+ }
112
+
113
+
114
+ def agent_status(profile: dict[str, Any]) -> dict[str, Any]:
115
+ identity = Path(profile["identityFile"])
116
+ check_identity_security(identity)
117
+ fingerprint = key_fingerprint(identity)
118
+ result = subprocess.run(
119
+ [required_tool("ssh-add"), "-l"],
120
+ check=False,
121
+ capture_output=True,
122
+ text=True,
123
+ encoding="utf-8",
124
+ errors="replace",
125
+ )
126
+ if result.returncode == 2:
127
+ return {
128
+ "targetName": profile["targetName"],
129
+ "state": "agent-unavailable",
130
+ "identityFingerprint": fingerprint,
131
+ "loaded": False,
132
+ "mutated": False,
133
+ }
134
+ if result.returncode == 1:
135
+ return {
136
+ "targetName": profile["targetName"],
137
+ "state": "not-loaded",
138
+ "identityFingerprint": fingerprint,
139
+ "loaded": False,
140
+ "mutated": False,
141
+ }
142
+ if result.returncode != 0:
143
+ raise DeploymentKitError(f"ssh-add could not inspect the running SSH agent (exit {result.returncode}).")
144
+ loaded = fingerprint in result.stdout.split()
145
+ return {
146
+ "targetName": profile["targetName"],
147
+ "state": "loaded" if loaded else "not-loaded",
148
+ "identityFingerprint": fingerprint,
149
+ "loaded": loaded,
150
+ "mutated": False,
151
+ }
152
+
153
+
154
+ def add_key_to_agent(profile: dict[str, Any]) -> dict[str, Any]:
155
+ before = agent_status(profile)
156
+ if before["state"] == "agent-unavailable":
157
+ raise DeploymentKitError(
158
+ "The SSH agent is unavailable. Start/enable it as a separate workstation action; "
159
+ "the Deployment Kit will not change agent service configuration implicitly."
160
+ )
161
+ if before["loaded"]:
162
+ return {**before, "status": "already-loaded"}
163
+
164
+ identity = Path(profile["identityFile"])
165
+ result = subprocess.run([required_tool("ssh-add"), str(identity)], check=False)
166
+ if result.returncode != 0:
167
+ raise DeploymentKitError("ssh-add did not load the dedicated identity.")
168
+ after = agent_status(profile)
169
+ if not after["loaded"]:
170
+ raise DeploymentKitError("ssh-add reported success but the dedicated identity is not visible in the agent.")
171
+ return {**after, "status": "loaded", "mutated": True}