@mriekki/lean 0.1.0 → 0.2.0

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 (41) hide show
  1. package/README.md +71 -67
  2. package/assets/components.json +33 -33
  3. package/assets/core/distribution.json +52 -51
  4. package/assets/deployment/distribution.json +34 -34
  5. package/assets/generated/ASSEMBLY.json +38 -33
  6. package/assets/generated/core/core/skills/core-sync/SKILL.md +78 -120
  7. package/assets/generated/core/core/skills/deploy/SKILL.md +74 -91
  8. package/assets/generated/core/core/skills/lean-update/SKILL.md +189 -0
  9. package/assets/generated/core/core/skills/upstream-fix/SKILL.md +91 -76
  10. package/assets/generated/deployment/distribution/lean-poc-deploy.py +8 -8
  11. package/assets/generated/deployment/src/lean_poc_deployment/__init__.py +3 -3
  12. package/assets/generated/deployment/src/lean_poc_deployment/__main__.py +3 -3
  13. package/assets/generated/deployment/src/lean_poc_deployment/app_cli.py +140 -140
  14. package/assets/generated/deployment/src/lean_poc_deployment/app_runtime.py +455 -455
  15. package/assets/generated/deployment/src/lean_poc_deployment/app_runtime_remote.py +415 -415
  16. package/assets/generated/deployment/src/lean_poc_deployment/box_platform.py +208 -208
  17. package/assets/generated/deployment/src/lean_poc_deployment/box_platform_assets.py +100 -100
  18. package/assets/generated/deployment/src/lean_poc_deployment/box_platform_remote.py +271 -271
  19. package/assets/generated/deployment/src/lean_poc_deployment/cli.py +437 -437
  20. package/assets/generated/deployment/src/lean_poc_deployment/doctor.py +121 -121
  21. package/assets/generated/deployment/src/lean_poc_deployment/entrypoint.py +25 -25
  22. package/assets/generated/deployment/src/lean_poc_deployment/errors.py +5 -5
  23. package/assets/generated/deployment/src/lean_poc_deployment/keys.py +171 -171
  24. package/assets/generated/deployment/src/lean_poc_deployment/profiles.py +204 -204
  25. package/assets/generated/deployment/src/lean_poc_deployment/route_registry.py +367 -367
  26. package/assets/generated/deployment/src/lean_poc_deployment/route_registry_remote.py +443 -443
  27. package/assets/generated/deployment/src/lean_poc_deployment/security.py +290 -290
  28. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter.py +334 -334
  29. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter_remote.py +339 -339
  30. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_cli.py +85 -85
  31. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_import_recovery.py +437 -437
  32. package/assets/generated/deployment/src/lean_poc_deployment/ssh.py +118 -118
  33. package/assets/generated/deployment/src/lean_poc_deployment/teardown.py +286 -286
  34. package/assets/generated/deployment/src/lean_poc_deployment/teardown_cli.py +120 -120
  35. package/assets/generated/deployment/src/lean_poc_deployment/teardown_remote.py +100 -100
  36. package/assets/generated/deployment/src/lean_poc_deployment/trust.py +278 -278
  37. package/bin/lean.mjs +9 -9
  38. package/package.json +33 -33
  39. package/src/cli.mjs +131 -131
  40. package/src/engine.mjs +243 -243
  41. package/src/package-data.mjs +18 -18
@@ -1,437 +1,437 @@
1
- """Command-line interface for the Deployment Kit."""
2
-
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import json
7
- import sys
8
- from typing import Any
9
-
10
- from .box_platform import (
11
- HOST_PLATFORM_WRITE_PHRASE,
12
- apply_platform_change,
13
- plan_platform_change,
14
- platform_doctor,
15
- platform_status,
16
- )
17
- from .doctor import target_doctor, workstation_doctor
18
- from .errors import DeploymentKitError
19
- from .keys import add_key_to_agent, agent_status, create_dedicated_key, public_key_info
20
- from .profiles import load_profile, save_profile, validate_profile
21
- from .route_registry import (
22
- ROUTE_REMOVE_PHRASE,
23
- ROUTE_WRITE_PHRASE,
24
- apply_route_change,
25
- plan_route_registration,
26
- plan_route_removal,
27
- route_inventory,
28
- )
29
- from .ssh import verify_access
30
- from .trust import (
31
- apply_trust_plan,
32
- candidate_view,
33
- plan_console_verified_trust,
34
- scan_host_keys,
35
- trust_plan_view,
36
- )
37
-
38
-
39
- LOCAL_WRITE_PHRASE = "[LOCAL CONFIG WRITE]"
40
- KEY_WRITE_PHRASE = "[LOCAL KEY WRITE]"
41
- AGENT_WRITE_PHRASE = "[SSH AGENT WRITE]"
42
- TRUST_WRITE_PHRASE = "[LOCAL TRUST WRITE]"
43
- TRUST_ROTATE_PHRASE = "[LOCAL TRUST ROTATION]"
44
-
45
-
46
- def _print(value: Any, *, as_json: bool) -> None:
47
- if as_json:
48
- print(json.dumps(value, indent=2, sort_keys=True))
49
- return
50
- if isinstance(value, dict):
51
- for key, item in value.items():
52
- if isinstance(item, (dict, list)):
53
- print(f"{key}: {json.dumps(item, sort_keys=True)}")
54
- else:
55
- print(f"{key}: {item}")
56
- elif isinstance(value, list):
57
- for item in value:
58
- print(json.dumps(item, sort_keys=True))
59
- else:
60
- print(value)
61
-
62
-
63
- def _confirm(args: argparse.Namespace, phrase: str, action: str) -> None:
64
- if args.yes:
65
- return
66
- entered = input(f"Type {phrase} to {action}: ").strip()
67
- if entered != phrase:
68
- raise DeploymentKitError(f"{action.capitalize()} was not authorized.")
69
-
70
-
71
- def _configure(args: argparse.Namespace) -> int:
72
- profile = {
73
- "schemaVersion": 1,
74
- "targetName": args.name,
75
- "targetOs": args.target_os,
76
- "hostName": args.host_name,
77
- "sshAlias": args.ssh_alias,
78
- "user": args.user,
79
- "port": args.port,
80
- "identityFile": args.identity_file,
81
- "knownHostsFile": args.known_hosts_file,
82
- "addressOverride": args.address_override,
83
- }
84
- print(json.dumps(profile, indent=2))
85
- _confirm(args, LOCAL_WRITE_PHRASE, "write this user-scoped target profile")
86
- path = save_profile(profile)
87
- print(f"Wrote target profile: {path}")
88
- return 0
89
-
90
-
91
- def _set_address(args: argparse.Namespace) -> int:
92
- profile = load_profile(args.name)
93
- proposed = dict(profile)
94
- proposed["addressOverride"] = args.address
95
- proposed = validate_profile(proposed, expected_target_name=args.name)
96
- evidence = verify_access(proposed)
97
- _print(
98
- {
99
- "targetName": args.name,
100
- "currentOverride": profile["addressOverride"],
101
- "proposedOverride": proposed["addressOverride"],
102
- "strictVerification": evidence,
103
- },
104
- as_json=False,
105
- )
106
- _confirm(args, LOCAL_WRITE_PHRASE, "store this separately verified address override")
107
- save_profile(proposed)
108
- print(f"Stored address override for {args.name}: {proposed['addressOverride']}")
109
- return 0
110
-
111
-
112
- def _clear_address(args: argparse.Namespace) -> int:
113
- profile = load_profile(args.name)
114
- if profile["addressOverride"] is None:
115
- print(f"No address override is set for {args.name}.")
116
- return 0
117
- _print(
118
- {
119
- "targetName": args.name,
120
- "currentOverride": profile["addressOverride"],
121
- "proposedOverride": None,
122
- },
123
- as_json=False,
124
- )
125
- _confirm(args, LOCAL_WRITE_PHRASE, "clear this target address override")
126
- profile["addressOverride"] = None
127
- save_profile(profile)
128
- print(f"Cleared address override for {args.name}.")
129
- return 0
130
-
131
-
132
- def _platform_change(args: argparse.Namespace) -> int:
133
- plan = plan_platform_change(args.name, args.command)
134
- _print(plan, as_json=False)
135
- _confirm(
136
- args,
137
- HOST_PLATFORM_WRITE_PHRASE,
138
- f"{args.command} the shared host platform on target {args.name}",
139
- )
140
- _print(apply_platform_change(plan), as_json=args.json)
141
- return 0
142
-
143
-
144
- def _route_change(args: argparse.Namespace) -> int:
145
- if args.command == "register":
146
- plan = plan_route_registration(
147
- args.name,
148
- owner_repo=args.owner_repo,
149
- environment=args.environment,
150
- app_slug=args.app_slug,
151
- upstream_container=args.upstream_container,
152
- upstream_port=args.upstream_port,
153
- hostnames=args.hostname,
154
- )
155
- phrase = ROUTE_WRITE_PHRASE
156
- action = f"register/update route {plan['route']['routeId']} on target {args.name}"
157
- else:
158
- plan = plan_route_removal(
159
- args.name,
160
- owner_repo=args.owner_repo,
161
- environment=args.environment,
162
- app_slug=args.app_slug,
163
- )
164
- phrase = ROUTE_REMOVE_PHRASE
165
- action = f"remove route {plan['route']['routeId']} from target {args.name}"
166
- _print(plan, as_json=False)
167
- _confirm(args, phrase, action)
168
- _print(apply_route_change(plan), as_json=args.json)
169
- return 0
170
-
171
-
172
- def build_parser() -> argparse.ArgumentParser:
173
- parser = argparse.ArgumentParser(prog="lean-poc-deploy")
174
- subparsers = parser.add_subparsers(dest="group", required=True)
175
-
176
- workstation = subparsers.add_parser("workstation", help="Workstation setup/readiness operations")
177
- workstation_sub = workstation.add_subparsers(dest="command", required=True)
178
- ws_doctor = workstation_sub.add_parser("doctor", help="Inspect workstation readiness without modifying it")
179
- ws_doctor.add_argument("--json", action="store_true")
180
-
181
- target = subparsers.add_parser("target", help="Logical deployment target operations")
182
- target_sub = target.add_subparsers(dest="command", required=True)
183
-
184
- target_doctor_parser = target_sub.add_parser("doctor", help="Inspect one target without modifying it")
185
- target_doctor_parser.add_argument("name")
186
- target_doctor_parser.add_argument("--json", action="store_true")
187
-
188
- show = target_sub.add_parser("show", help="Show normalized non-secret target profile")
189
- show.add_argument("name")
190
- show.add_argument("--json", action="store_true")
191
-
192
- verify = target_sub.add_parser("verify", help="Verify strict public-key SSH access without remote writes")
193
- verify.add_argument("name")
194
- verify.add_argument("--json", action="store_true")
195
-
196
- configure = target_sub.add_parser("configure", help="Write a user-scoped target profile")
197
- configure.add_argument("name")
198
- configure.add_argument("--target-os", default="ubuntu")
199
- configure.add_argument("--host-name", required=True)
200
- configure.add_argument("--ssh-alias", required=True)
201
- configure.add_argument("--user", required=True)
202
- configure.add_argument("--port", type=int, default=22)
203
- configure.add_argument("--identity-file", required=True)
204
- configure.add_argument("--known-hosts-file", required=True)
205
- configure.add_argument("--address-override")
206
- configure.add_argument("--yes", action="store_true", help="Accept the bounded local profile write")
207
-
208
- key_create = target_sub.add_parser(
209
- "key-create",
210
- help="Create the configured dedicated ed25519 key without exposing its passphrase to this tool",
211
- )
212
- key_create.add_argument("name")
213
- key_create.add_argument("--yes", action="store_true")
214
-
215
- key_public = target_sub.add_parser(
216
- "key-public",
217
- help="Show the dedicated public key and fingerprint for console-assisted installation",
218
- )
219
- key_public.add_argument("name")
220
- key_public.add_argument("--json", action="store_true")
221
-
222
- agent_check = target_sub.add_parser("agent-status", help="Inspect whether the dedicated key is loaded")
223
- agent_check.add_argument("name")
224
- agent_check.add_argument("--json", action="store_true")
225
-
226
- agent_add = target_sub.add_parser(
227
- "agent-add",
228
- help="Load the configured dedicated key into an already-running SSH agent",
229
- )
230
- agent_add.add_argument("name")
231
- agent_add.add_argument("--yes", action="store_true")
232
-
233
- trust_candidates = target_sub.add_parser(
234
- "trust-candidates",
235
- help="Read candidate host-key fingerprints; candidates are never trusted by discovery",
236
- )
237
- trust_candidates.add_argument("name")
238
- trust_candidates.add_argument(
239
- "--address",
240
- help="Exact current IP independently obtained from the trusted target console",
241
- )
242
- trust_candidates.add_argument("--json", action="store_true")
243
-
244
- trust_enroll = target_sub.add_parser(
245
- "trust-enroll",
246
- help="Enroll one host key only when it matches the independently observed console fingerprint",
247
- )
248
- trust_enroll.add_argument("name")
249
- trust_enroll.add_argument("--fingerprint", required=True)
250
- trust_enroll.add_argument("--address")
251
- trust_enroll.add_argument("--yes", action="store_true")
252
-
253
- trust_rotate = target_sub.add_parser(
254
- "trust-rotate",
255
- help="Explicitly replace existing target trust after independent console verification",
256
- )
257
- trust_rotate.add_argument("name")
258
- trust_rotate.add_argument("--fingerprint", required=True)
259
- trust_rotate.add_argument("--address")
260
- trust_rotate.add_argument("--yes", action="store_true")
261
-
262
- address_set = target_sub.add_parser(
263
- "address-set",
264
- help="Store an IP override only after strict SSH verification against existing target identity",
265
- )
266
- address_set.add_argument("name")
267
- address_set.add_argument("--address", required=True)
268
- address_set.add_argument("--yes", action="store_true")
269
-
270
- address_clear = target_sub.add_parser("address-clear", help="Clear a target's temporary address override")
271
- address_clear.add_argument("name")
272
- address_clear.add_argument("--yes", action="store_true")
273
-
274
- route = subparsers.add_parser(
275
- "route",
276
- help="Owned application route operations on a ready shared host platform",
277
- )
278
- route_sub = route.add_subparsers(dest="command", required=True)
279
- route_inventory_parser = route_sub.add_parser(
280
- "inventory", help="Read managed route ownership plus legacy route filenames without writes"
281
- )
282
- route_inventory_parser.add_argument("name")
283
- route_inventory_parser.add_argument("--json", action="store_true")
284
-
285
- route_register = route_sub.add_parser(
286
- "register", help="Create or update one POC/environment-owned Caddy + mDNS route"
287
- )
288
- route_register.add_argument("name")
289
- route_register.add_argument("--owner-repo", required=True)
290
- route_register.add_argument("--environment", required=True)
291
- route_register.add_argument("--app-slug", required=True)
292
- route_register.add_argument("--upstream-container", required=True)
293
- route_register.add_argument("--upstream-port", type=int, required=True)
294
- route_register.add_argument("--hostname", action="append", required=True)
295
- route_register.add_argument("--json", action="store_true")
296
- route_register.add_argument("--yes", action="store_true")
297
-
298
- route_remove = route_sub.add_parser(
299
- "remove", help="Remove only one owned route/alias registration, preserving app runtime and data"
300
- )
301
- route_remove.add_argument("name")
302
- route_remove.add_argument("--owner-repo", required=True)
303
- route_remove.add_argument("--environment", required=True)
304
- route_remove.add_argument("--app-slug", required=True)
305
- route_remove.add_argument("--json", action="store_true")
306
- route_remove.add_argument("--yes", action="store_true")
307
-
308
- platform = subparsers.add_parser(
309
- "platform",
310
- help="Shared Ubuntu box platform operations; never registers or removes application routes",
311
- )
312
- platform_sub = platform.add_subparsers(dest="command", required=True)
313
- platform_status_parser = platform_sub.add_parser(
314
- "status", help="Read shared host-platform state without remote writes"
315
- )
316
- platform_status_parser.add_argument("name")
317
- platform_status_parser.add_argument("--json", action="store_true")
318
- platform_doctor_parser = platform_sub.add_parser(
319
- "doctor", help="Read shared host-platform readiness and next action"
320
- )
321
- platform_doctor_parser.add_argument("name")
322
- platform_doctor_parser.add_argument("--json", action="store_true")
323
- for command in ("install", "upgrade"):
324
- change = platform_sub.add_parser(
325
- command,
326
- help=(
327
- "Install shared host platform on a fresh target"
328
- if command == "install"
329
- else "Upgrade an already Kit-owned shared host platform"
330
- ),
331
- )
332
- change.add_argument("name")
333
- change.add_argument("--json", action="store_true")
334
- change.add_argument("--yes", action="store_true")
335
-
336
- return parser
337
-
338
-
339
- def main(argv: list[str] | None = None) -> int:
340
- args = build_parser().parse_args(argv)
341
- try:
342
- if args.group == "workstation" and args.command == "doctor":
343
- _print(workstation_doctor(), as_json=args.json)
344
- return 0
345
- if args.group == "target" and args.command == "doctor":
346
- _print(target_doctor(args.name), as_json=args.json)
347
- return 0
348
- if args.group == "target" and args.command == "show":
349
- _print(load_profile(args.name), as_json=args.json)
350
- return 0
351
- if args.group == "target" and args.command == "verify":
352
- _print(verify_access(load_profile(args.name)), as_json=args.json)
353
- return 0
354
- if args.group == "target" and args.command == "configure":
355
- return _configure(args)
356
- if args.group == "target" and args.command == "key-create":
357
- profile = load_profile(args.name)
358
- _print(
359
- {
360
- "targetName": args.name,
361
- "identityFile": profile["identityFile"],
362
- "keyType": "ed25519",
363
- "passphraseHandling": "entered directly into ssh-keygen",
364
- },
365
- as_json=False,
366
- )
367
- _confirm(args, KEY_WRITE_PHRASE, "create this dedicated workstation key")
368
- _print(create_dedicated_key(profile), as_json=False)
369
- return 0
370
- if args.group == "target" and args.command == "key-public":
371
- _print(public_key_info(load_profile(args.name)), as_json=args.json)
372
- return 0
373
- if args.group == "target" and args.command == "agent-status":
374
- _print(agent_status(load_profile(args.name)), as_json=args.json)
375
- return 0
376
- if args.group == "target" and args.command == "agent-add":
377
- profile = load_profile(args.name)
378
- status = agent_status(profile)
379
- _print(status, as_json=False)
380
- if status["loaded"]:
381
- return 0
382
- if status["state"] == "agent-unavailable":
383
- raise DeploymentKitError(
384
- "The SSH agent is unavailable. Start/enable it as a separate workstation action; "
385
- "this command will not change agent service configuration."
386
- )
387
- _confirm(args, AGENT_WRITE_PHRASE, "load this dedicated key into the running SSH agent")
388
- _print(add_key_to_agent(profile), as_json=False)
389
- return 0
390
- if args.group == "target" and args.command == "trust-candidates":
391
- candidates = scan_host_keys(load_profile(args.name), console_address=args.address)
392
- _print([candidate_view(item) for item in candidates], as_json=args.json)
393
- return 0
394
- if args.group == "target" and args.command in {"trust-enroll", "trust-rotate"}:
395
- profile = load_profile(args.name)
396
- rotate = args.command == "trust-rotate"
397
- plan = plan_console_verified_trust(
398
- profile,
399
- fingerprint=args.fingerprint,
400
- rotate=rotate,
401
- console_address=args.address,
402
- )
403
- _print(trust_plan_view(plan), as_json=False)
404
- phrase = TRUST_ROTATE_PHRASE if rotate else TRUST_WRITE_PHRASE
405
- action = (
406
- "replace existing target trust with this console-verified host key"
407
- if rotate
408
- else "enroll this console-verified target host key"
409
- )
410
- _confirm(args, phrase, action)
411
- _print(apply_trust_plan(profile, plan), as_json=False)
412
- return 0
413
- if args.group == "target" and args.command == "address-set":
414
- return _set_address(args)
415
- if args.group == "target" and args.command == "address-clear":
416
- return _clear_address(args)
417
- if args.group == "route" and args.command == "inventory":
418
- _print(route_inventory(args.name), as_json=args.json)
419
- return 0
420
- if args.group == "route" and args.command in {"register", "remove"}:
421
- return _route_change(args)
422
- if args.group == "platform" and args.command == "status":
423
- _print(platform_status(args.name), as_json=args.json)
424
- return 0
425
- if args.group == "platform" and args.command == "doctor":
426
- _print(platform_doctor(args.name), as_json=args.json)
427
- return 0
428
- if args.group == "platform" and args.command in {"install", "upgrade"}:
429
- return _platform_change(args)
430
- raise DeploymentKitError("Unsupported command.")
431
- except DeploymentKitError as error:
432
- print(f"ERROR: {error}", file=sys.stderr)
433
- return 2
434
-
435
-
436
- if __name__ == "__main__":
437
- raise SystemExit(main())
1
+ """Command-line interface for the Deployment Kit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from typing import Any
9
+
10
+ from .box_platform import (
11
+ HOST_PLATFORM_WRITE_PHRASE,
12
+ apply_platform_change,
13
+ plan_platform_change,
14
+ platform_doctor,
15
+ platform_status,
16
+ )
17
+ from .doctor import target_doctor, workstation_doctor
18
+ from .errors import DeploymentKitError
19
+ from .keys import add_key_to_agent, agent_status, create_dedicated_key, public_key_info
20
+ from .profiles import load_profile, save_profile, validate_profile
21
+ from .route_registry import (
22
+ ROUTE_REMOVE_PHRASE,
23
+ ROUTE_WRITE_PHRASE,
24
+ apply_route_change,
25
+ plan_route_registration,
26
+ plan_route_removal,
27
+ route_inventory,
28
+ )
29
+ from .ssh import verify_access
30
+ from .trust import (
31
+ apply_trust_plan,
32
+ candidate_view,
33
+ plan_console_verified_trust,
34
+ scan_host_keys,
35
+ trust_plan_view,
36
+ )
37
+
38
+
39
+ LOCAL_WRITE_PHRASE = "[LOCAL CONFIG WRITE]"
40
+ KEY_WRITE_PHRASE = "[LOCAL KEY WRITE]"
41
+ AGENT_WRITE_PHRASE = "[SSH AGENT WRITE]"
42
+ TRUST_WRITE_PHRASE = "[LOCAL TRUST WRITE]"
43
+ TRUST_ROTATE_PHRASE = "[LOCAL TRUST ROTATION]"
44
+
45
+
46
+ def _print(value: Any, *, as_json: bool) -> None:
47
+ if as_json:
48
+ print(json.dumps(value, indent=2, sort_keys=True))
49
+ return
50
+ if isinstance(value, dict):
51
+ for key, item in value.items():
52
+ if isinstance(item, (dict, list)):
53
+ print(f"{key}: {json.dumps(item, sort_keys=True)}")
54
+ else:
55
+ print(f"{key}: {item}")
56
+ elif isinstance(value, list):
57
+ for item in value:
58
+ print(json.dumps(item, sort_keys=True))
59
+ else:
60
+ print(value)
61
+
62
+
63
+ def _confirm(args: argparse.Namespace, phrase: str, action: str) -> None:
64
+ if args.yes:
65
+ return
66
+ entered = input(f"Type {phrase} to {action}: ").strip()
67
+ if entered != phrase:
68
+ raise DeploymentKitError(f"{action.capitalize()} was not authorized.")
69
+
70
+
71
+ def _configure(args: argparse.Namespace) -> int:
72
+ profile = {
73
+ "schemaVersion": 1,
74
+ "targetName": args.name,
75
+ "targetOs": args.target_os,
76
+ "hostName": args.host_name,
77
+ "sshAlias": args.ssh_alias,
78
+ "user": args.user,
79
+ "port": args.port,
80
+ "identityFile": args.identity_file,
81
+ "knownHostsFile": args.known_hosts_file,
82
+ "addressOverride": args.address_override,
83
+ }
84
+ print(json.dumps(profile, indent=2))
85
+ _confirm(args, LOCAL_WRITE_PHRASE, "write this user-scoped target profile")
86
+ path = save_profile(profile)
87
+ print(f"Wrote target profile: {path}")
88
+ return 0
89
+
90
+
91
+ def _set_address(args: argparse.Namespace) -> int:
92
+ profile = load_profile(args.name)
93
+ proposed = dict(profile)
94
+ proposed["addressOverride"] = args.address
95
+ proposed = validate_profile(proposed, expected_target_name=args.name)
96
+ evidence = verify_access(proposed)
97
+ _print(
98
+ {
99
+ "targetName": args.name,
100
+ "currentOverride": profile["addressOverride"],
101
+ "proposedOverride": proposed["addressOverride"],
102
+ "strictVerification": evidence,
103
+ },
104
+ as_json=False,
105
+ )
106
+ _confirm(args, LOCAL_WRITE_PHRASE, "store this separately verified address override")
107
+ save_profile(proposed)
108
+ print(f"Stored address override for {args.name}: {proposed['addressOverride']}")
109
+ return 0
110
+
111
+
112
+ def _clear_address(args: argparse.Namespace) -> int:
113
+ profile = load_profile(args.name)
114
+ if profile["addressOverride"] is None:
115
+ print(f"No address override is set for {args.name}.")
116
+ return 0
117
+ _print(
118
+ {
119
+ "targetName": args.name,
120
+ "currentOverride": profile["addressOverride"],
121
+ "proposedOverride": None,
122
+ },
123
+ as_json=False,
124
+ )
125
+ _confirm(args, LOCAL_WRITE_PHRASE, "clear this target address override")
126
+ profile["addressOverride"] = None
127
+ save_profile(profile)
128
+ print(f"Cleared address override for {args.name}.")
129
+ return 0
130
+
131
+
132
+ def _platform_change(args: argparse.Namespace) -> int:
133
+ plan = plan_platform_change(args.name, args.command)
134
+ _print(plan, as_json=False)
135
+ _confirm(
136
+ args,
137
+ HOST_PLATFORM_WRITE_PHRASE,
138
+ f"{args.command} the shared host platform on target {args.name}",
139
+ )
140
+ _print(apply_platform_change(plan), as_json=args.json)
141
+ return 0
142
+
143
+
144
+ def _route_change(args: argparse.Namespace) -> int:
145
+ if args.command == "register":
146
+ plan = plan_route_registration(
147
+ args.name,
148
+ owner_repo=args.owner_repo,
149
+ environment=args.environment,
150
+ app_slug=args.app_slug,
151
+ upstream_container=args.upstream_container,
152
+ upstream_port=args.upstream_port,
153
+ hostnames=args.hostname,
154
+ )
155
+ phrase = ROUTE_WRITE_PHRASE
156
+ action = f"register/update route {plan['route']['routeId']} on target {args.name}"
157
+ else:
158
+ plan = plan_route_removal(
159
+ args.name,
160
+ owner_repo=args.owner_repo,
161
+ environment=args.environment,
162
+ app_slug=args.app_slug,
163
+ )
164
+ phrase = ROUTE_REMOVE_PHRASE
165
+ action = f"remove route {plan['route']['routeId']} from target {args.name}"
166
+ _print(plan, as_json=False)
167
+ _confirm(args, phrase, action)
168
+ _print(apply_route_change(plan), as_json=args.json)
169
+ return 0
170
+
171
+
172
+ def build_parser() -> argparse.ArgumentParser:
173
+ parser = argparse.ArgumentParser(prog="lean-poc-deploy")
174
+ subparsers = parser.add_subparsers(dest="group", required=True)
175
+
176
+ workstation = subparsers.add_parser("workstation", help="Workstation setup/readiness operations")
177
+ workstation_sub = workstation.add_subparsers(dest="command", required=True)
178
+ ws_doctor = workstation_sub.add_parser("doctor", help="Inspect workstation readiness without modifying it")
179
+ ws_doctor.add_argument("--json", action="store_true")
180
+
181
+ target = subparsers.add_parser("target", help="Logical deployment target operations")
182
+ target_sub = target.add_subparsers(dest="command", required=True)
183
+
184
+ target_doctor_parser = target_sub.add_parser("doctor", help="Inspect one target without modifying it")
185
+ target_doctor_parser.add_argument("name")
186
+ target_doctor_parser.add_argument("--json", action="store_true")
187
+
188
+ show = target_sub.add_parser("show", help="Show normalized non-secret target profile")
189
+ show.add_argument("name")
190
+ show.add_argument("--json", action="store_true")
191
+
192
+ verify = target_sub.add_parser("verify", help="Verify strict public-key SSH access without remote writes")
193
+ verify.add_argument("name")
194
+ verify.add_argument("--json", action="store_true")
195
+
196
+ configure = target_sub.add_parser("configure", help="Write a user-scoped target profile")
197
+ configure.add_argument("name")
198
+ configure.add_argument("--target-os", default="ubuntu")
199
+ configure.add_argument("--host-name", required=True)
200
+ configure.add_argument("--ssh-alias", required=True)
201
+ configure.add_argument("--user", required=True)
202
+ configure.add_argument("--port", type=int, default=22)
203
+ configure.add_argument("--identity-file", required=True)
204
+ configure.add_argument("--known-hosts-file", required=True)
205
+ configure.add_argument("--address-override")
206
+ configure.add_argument("--yes", action="store_true", help="Accept the bounded local profile write")
207
+
208
+ key_create = target_sub.add_parser(
209
+ "key-create",
210
+ help="Create the configured dedicated ed25519 key without exposing its passphrase to this tool",
211
+ )
212
+ key_create.add_argument("name")
213
+ key_create.add_argument("--yes", action="store_true")
214
+
215
+ key_public = target_sub.add_parser(
216
+ "key-public",
217
+ help="Show the dedicated public key and fingerprint for console-assisted installation",
218
+ )
219
+ key_public.add_argument("name")
220
+ key_public.add_argument("--json", action="store_true")
221
+
222
+ agent_check = target_sub.add_parser("agent-status", help="Inspect whether the dedicated key is loaded")
223
+ agent_check.add_argument("name")
224
+ agent_check.add_argument("--json", action="store_true")
225
+
226
+ agent_add = target_sub.add_parser(
227
+ "agent-add",
228
+ help="Load the configured dedicated key into an already-running SSH agent",
229
+ )
230
+ agent_add.add_argument("name")
231
+ agent_add.add_argument("--yes", action="store_true")
232
+
233
+ trust_candidates = target_sub.add_parser(
234
+ "trust-candidates",
235
+ help="Read candidate host-key fingerprints; candidates are never trusted by discovery",
236
+ )
237
+ trust_candidates.add_argument("name")
238
+ trust_candidates.add_argument(
239
+ "--address",
240
+ help="Exact current IP independently obtained from the trusted target console",
241
+ )
242
+ trust_candidates.add_argument("--json", action="store_true")
243
+
244
+ trust_enroll = target_sub.add_parser(
245
+ "trust-enroll",
246
+ help="Enroll one host key only when it matches the independently observed console fingerprint",
247
+ )
248
+ trust_enroll.add_argument("name")
249
+ trust_enroll.add_argument("--fingerprint", required=True)
250
+ trust_enroll.add_argument("--address")
251
+ trust_enroll.add_argument("--yes", action="store_true")
252
+
253
+ trust_rotate = target_sub.add_parser(
254
+ "trust-rotate",
255
+ help="Explicitly replace existing target trust after independent console verification",
256
+ )
257
+ trust_rotate.add_argument("name")
258
+ trust_rotate.add_argument("--fingerprint", required=True)
259
+ trust_rotate.add_argument("--address")
260
+ trust_rotate.add_argument("--yes", action="store_true")
261
+
262
+ address_set = target_sub.add_parser(
263
+ "address-set",
264
+ help="Store an IP override only after strict SSH verification against existing target identity",
265
+ )
266
+ address_set.add_argument("name")
267
+ address_set.add_argument("--address", required=True)
268
+ address_set.add_argument("--yes", action="store_true")
269
+
270
+ address_clear = target_sub.add_parser("address-clear", help="Clear a target's temporary address override")
271
+ address_clear.add_argument("name")
272
+ address_clear.add_argument("--yes", action="store_true")
273
+
274
+ route = subparsers.add_parser(
275
+ "route",
276
+ help="Owned application route operations on a ready shared host platform",
277
+ )
278
+ route_sub = route.add_subparsers(dest="command", required=True)
279
+ route_inventory_parser = route_sub.add_parser(
280
+ "inventory", help="Read managed route ownership plus legacy route filenames without writes"
281
+ )
282
+ route_inventory_parser.add_argument("name")
283
+ route_inventory_parser.add_argument("--json", action="store_true")
284
+
285
+ route_register = route_sub.add_parser(
286
+ "register", help="Create or update one POC/environment-owned Caddy + mDNS route"
287
+ )
288
+ route_register.add_argument("name")
289
+ route_register.add_argument("--owner-repo", required=True)
290
+ route_register.add_argument("--environment", required=True)
291
+ route_register.add_argument("--app-slug", required=True)
292
+ route_register.add_argument("--upstream-container", required=True)
293
+ route_register.add_argument("--upstream-port", type=int, required=True)
294
+ route_register.add_argument("--hostname", action="append", required=True)
295
+ route_register.add_argument("--json", action="store_true")
296
+ route_register.add_argument("--yes", action="store_true")
297
+
298
+ route_remove = route_sub.add_parser(
299
+ "remove", help="Remove only one owned route/alias registration, preserving app runtime and data"
300
+ )
301
+ route_remove.add_argument("name")
302
+ route_remove.add_argument("--owner-repo", required=True)
303
+ route_remove.add_argument("--environment", required=True)
304
+ route_remove.add_argument("--app-slug", required=True)
305
+ route_remove.add_argument("--json", action="store_true")
306
+ route_remove.add_argument("--yes", action="store_true")
307
+
308
+ platform = subparsers.add_parser(
309
+ "platform",
310
+ help="Shared Ubuntu box platform operations; never registers or removes application routes",
311
+ )
312
+ platform_sub = platform.add_subparsers(dest="command", required=True)
313
+ platform_status_parser = platform_sub.add_parser(
314
+ "status", help="Read shared host-platform state without remote writes"
315
+ )
316
+ platform_status_parser.add_argument("name")
317
+ platform_status_parser.add_argument("--json", action="store_true")
318
+ platform_doctor_parser = platform_sub.add_parser(
319
+ "doctor", help="Read shared host-platform readiness and next action"
320
+ )
321
+ platform_doctor_parser.add_argument("name")
322
+ platform_doctor_parser.add_argument("--json", action="store_true")
323
+ for command in ("install", "upgrade"):
324
+ change = platform_sub.add_parser(
325
+ command,
326
+ help=(
327
+ "Install shared host platform on a fresh target"
328
+ if command == "install"
329
+ else "Upgrade an already Kit-owned shared host platform"
330
+ ),
331
+ )
332
+ change.add_argument("name")
333
+ change.add_argument("--json", action="store_true")
334
+ change.add_argument("--yes", action="store_true")
335
+
336
+ return parser
337
+
338
+
339
+ def main(argv: list[str] | None = None) -> int:
340
+ args = build_parser().parse_args(argv)
341
+ try:
342
+ if args.group == "workstation" and args.command == "doctor":
343
+ _print(workstation_doctor(), as_json=args.json)
344
+ return 0
345
+ if args.group == "target" and args.command == "doctor":
346
+ _print(target_doctor(args.name), as_json=args.json)
347
+ return 0
348
+ if args.group == "target" and args.command == "show":
349
+ _print(load_profile(args.name), as_json=args.json)
350
+ return 0
351
+ if args.group == "target" and args.command == "verify":
352
+ _print(verify_access(load_profile(args.name)), as_json=args.json)
353
+ return 0
354
+ if args.group == "target" and args.command == "configure":
355
+ return _configure(args)
356
+ if args.group == "target" and args.command == "key-create":
357
+ profile = load_profile(args.name)
358
+ _print(
359
+ {
360
+ "targetName": args.name,
361
+ "identityFile": profile["identityFile"],
362
+ "keyType": "ed25519",
363
+ "passphraseHandling": "entered directly into ssh-keygen",
364
+ },
365
+ as_json=False,
366
+ )
367
+ _confirm(args, KEY_WRITE_PHRASE, "create this dedicated workstation key")
368
+ _print(create_dedicated_key(profile), as_json=False)
369
+ return 0
370
+ if args.group == "target" and args.command == "key-public":
371
+ _print(public_key_info(load_profile(args.name)), as_json=args.json)
372
+ return 0
373
+ if args.group == "target" and args.command == "agent-status":
374
+ _print(agent_status(load_profile(args.name)), as_json=args.json)
375
+ return 0
376
+ if args.group == "target" and args.command == "agent-add":
377
+ profile = load_profile(args.name)
378
+ status = agent_status(profile)
379
+ _print(status, as_json=False)
380
+ if status["loaded"]:
381
+ return 0
382
+ if status["state"] == "agent-unavailable":
383
+ raise DeploymentKitError(
384
+ "The SSH agent is unavailable. Start/enable it as a separate workstation action; "
385
+ "this command will not change agent service configuration."
386
+ )
387
+ _confirm(args, AGENT_WRITE_PHRASE, "load this dedicated key into the running SSH agent")
388
+ _print(add_key_to_agent(profile), as_json=False)
389
+ return 0
390
+ if args.group == "target" and args.command == "trust-candidates":
391
+ candidates = scan_host_keys(load_profile(args.name), console_address=args.address)
392
+ _print([candidate_view(item) for item in candidates], as_json=args.json)
393
+ return 0
394
+ if args.group == "target" and args.command in {"trust-enroll", "trust-rotate"}:
395
+ profile = load_profile(args.name)
396
+ rotate = args.command == "trust-rotate"
397
+ plan = plan_console_verified_trust(
398
+ profile,
399
+ fingerprint=args.fingerprint,
400
+ rotate=rotate,
401
+ console_address=args.address,
402
+ )
403
+ _print(trust_plan_view(plan), as_json=False)
404
+ phrase = TRUST_ROTATE_PHRASE if rotate else TRUST_WRITE_PHRASE
405
+ action = (
406
+ "replace existing target trust with this console-verified host key"
407
+ if rotate
408
+ else "enroll this console-verified target host key"
409
+ )
410
+ _confirm(args, phrase, action)
411
+ _print(apply_trust_plan(profile, plan), as_json=False)
412
+ return 0
413
+ if args.group == "target" and args.command == "address-set":
414
+ return _set_address(args)
415
+ if args.group == "target" and args.command == "address-clear":
416
+ return _clear_address(args)
417
+ if args.group == "route" and args.command == "inventory":
418
+ _print(route_inventory(args.name), as_json=args.json)
419
+ return 0
420
+ if args.group == "route" and args.command in {"register", "remove"}:
421
+ return _route_change(args)
422
+ if args.group == "platform" and args.command == "status":
423
+ _print(platform_status(args.name), as_json=args.json)
424
+ return 0
425
+ if args.group == "platform" and args.command == "doctor":
426
+ _print(platform_doctor(args.name), as_json=args.json)
427
+ return 0
428
+ if args.group == "platform" and args.command in {"install", "upgrade"}:
429
+ return _platform_change(args)
430
+ raise DeploymentKitError("Unsupported command.")
431
+ except DeploymentKitError as error:
432
+ print(f"ERROR: {error}", file=sys.stderr)
433
+ return 2
434
+
435
+
436
+ if __name__ == "__main__":
437
+ raise SystemExit(main())