@mriekki/lean 0.0.0-stage → 0.1.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 (82) hide show
  1. package/README.md +67 -3
  2. package/assets/components.json +33 -0
  3. package/assets/core/distribution.json +51 -0
  4. package/assets/deployment/distribution.json +34 -0
  5. package/assets/generated/ASSEMBLY.json +382 -0
  6. package/assets/generated/core/core/agents/coach.agent.md +69 -0
  7. package/assets/generated/core/core/agents/reviewer.agent.md +85 -0
  8. package/assets/generated/core/core/instructions/lean-docs.instructions.md +14 -0
  9. package/assets/generated/core/core/instructions/lean-workflow.instructions.md +35 -0
  10. package/assets/generated/core/core/learning/CODING_DRILLS.md +35 -0
  11. package/assets/generated/core/core/learning/DIAGRAM_GUIDE.md +19 -0
  12. package/assets/generated/core/core/learning/ENGINEERING_LEARNING_WORKBOOK.md +82 -0
  13. package/assets/generated/core/core/learning/LEARNING_MAP.md +51 -0
  14. package/assets/generated/core/core/learning/LEARNING_PROGRESS.md +31 -0
  15. package/assets/generated/core/core/learning/RESOURCE_CATALOG.md +25 -0
  16. package/assets/generated/core/core/learning/SQL_DRILLS.md +27 -0
  17. package/assets/generated/core/core/learning/SYSTEM_DESIGN_DRILLS.md +21 -0
  18. package/assets/generated/core/core/process/COACHING_GUIDE.md +57 -0
  19. package/assets/generated/core/core/process/GUIDED_NEXT_STEPS.md +48 -0
  20. package/assets/generated/core/core/process/HUMAN_GATES.md +81 -0
  21. package/assets/generated/core/core/process/INTERACTIVE_SESSION_GUIDE.md +62 -0
  22. package/assets/generated/core/core/process/PRODUCTION_ENABLEMENT.md +59 -0
  23. package/assets/generated/core/core/process/PR_WORKFLOW.md +53 -0
  24. package/assets/generated/core/core/process/SETUP_ACCEPTANCE_CHECKLIST.md +72 -0
  25. package/assets/generated/core/core/process/WORK_ITEM_MODEL.md +56 -0
  26. package/assets/generated/core/core/skills/cleanup-branches/SKILL.md +190 -0
  27. package/assets/generated/core/core/skills/coach/SKILL.md +136 -0
  28. package/assets/generated/core/core/skills/code-walkthrough/SKILL.md +197 -0
  29. package/assets/generated/core/core/skills/core-sync/SKILL.md +220 -0
  30. package/assets/generated/core/core/skills/define-feature/SKILL.md +144 -0
  31. package/assets/generated/core/core/skills/deliver/SKILL.md +315 -0
  32. package/assets/generated/core/core/skills/deploy/SKILL.md +193 -0
  33. package/assets/generated/core/core/skills/grill-me/SKILL.md +77 -0
  34. package/assets/generated/core/core/skills/grill-with-docs/SKILL.md +90 -0
  35. package/assets/generated/core/core/skills/learning-review/SKILL.md +90 -0
  36. package/assets/generated/core/core/skills/next-step/SKILL.md +166 -0
  37. package/assets/generated/core/core/skills/production-readiness/SKILL.md +357 -0
  38. package/assets/generated/core/core/skills/pull-latest/SKILL.md +166 -0
  39. package/assets/generated/core/core/skills/refine-requirements/SKILL.md +207 -0
  40. package/assets/generated/core/core/skills/start-work/SKILL.md +178 -0
  41. package/assets/generated/core/core/skills/tear-down/SKILL.md +138 -0
  42. package/assets/generated/core/core/skills/upstream-fix/SKILL.md +206 -0
  43. package/assets/generated/core/core/skills/wait-what/SKILL.md +86 -0
  44. package/assets/generated/core/core/templates/FEATURE_BRIEF_TEMPLATE.md +27 -0
  45. package/assets/generated/core/core/templates/PRODUCT_BRIEF_TEMPLATE.md +27 -0
  46. package/assets/generated/core/core/templates/PULL_REQUEST_TEMPLATE.md +37 -0
  47. package/assets/generated/core/core/templates/REQUIREMENTS_TEMPLATE.md +17 -0
  48. package/assets/generated/core/profiles/greenfield/lean-workspace.json +12 -0
  49. package/assets/generated/core/tools/package/check_baseline.py +102 -0
  50. package/assets/generated/core/tools/package/check_upstream.py +171 -0
  51. package/assets/generated/deployment/distribution/lean-poc-deploy.py +8 -0
  52. package/assets/generated/deployment/src/lean_poc_deployment/__init__.py +3 -0
  53. package/assets/generated/deployment/src/lean_poc_deployment/__main__.py +3 -0
  54. package/assets/generated/deployment/src/lean_poc_deployment/app_cli.py +140 -0
  55. package/assets/generated/deployment/src/lean_poc_deployment/app_runtime.py +455 -0
  56. package/assets/generated/deployment/src/lean_poc_deployment/app_runtime_remote.py +416 -0
  57. package/assets/generated/deployment/src/lean_poc_deployment/box_platform.py +208 -0
  58. package/assets/generated/deployment/src/lean_poc_deployment/box_platform_assets.py +100 -0
  59. package/assets/generated/deployment/src/lean_poc_deployment/box_platform_remote.py +271 -0
  60. package/assets/generated/deployment/src/lean_poc_deployment/cli.py +437 -0
  61. package/assets/generated/deployment/src/lean_poc_deployment/doctor.py +121 -0
  62. package/assets/generated/deployment/src/lean_poc_deployment/entrypoint.py +25 -0
  63. package/assets/generated/deployment/src/lean_poc_deployment/errors.py +5 -0
  64. package/assets/generated/deployment/src/lean_poc_deployment/keys.py +171 -0
  65. package/assets/generated/deployment/src/lean_poc_deployment/profiles.py +204 -0
  66. package/assets/generated/deployment/src/lean_poc_deployment/route_registry.py +367 -0
  67. package/assets/generated/deployment/src/lean_poc_deployment/route_registry_remote.py +443 -0
  68. package/assets/generated/deployment/src/lean_poc_deployment/security.py +290 -0
  69. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter.py +335 -0
  70. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter_remote.py +340 -0
  71. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_cli.py +86 -0
  72. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_import_recovery.py +437 -0
  73. package/assets/generated/deployment/src/lean_poc_deployment/ssh.py +118 -0
  74. package/assets/generated/deployment/src/lean_poc_deployment/teardown.py +286 -0
  75. package/assets/generated/deployment/src/lean_poc_deployment/teardown_cli.py +120 -0
  76. package/assets/generated/deployment/src/lean_poc_deployment/teardown_remote.py +100 -0
  77. package/assets/generated/deployment/src/lean_poc_deployment/trust.py +278 -0
  78. package/bin/lean.mjs +9 -0
  79. package/package.json +33 -6
  80. package/src/cli.mjs +131 -0
  81. package/src/engine.mjs +243 -0
  82. package/src/package-data.mjs +18 -0
@@ -0,0 +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}
@@ -0,0 +1,204 @@
1
+ """User-scoped logical target profiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import json
7
+ import os
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .errors import DeploymentKitError
13
+
14
+
15
+ PROFILE_SCHEMA_VERSION = 1
16
+ TARGET_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
17
+ ALIAS_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
18
+ USER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]{0,31}$")
19
+ HOST_LABEL_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$")
20
+ SUPPORTED_TARGET_OS = {"ubuntu"}
21
+
22
+
23
+ def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
24
+ value: dict[str, Any] = {}
25
+ for key, item in pairs:
26
+ if key in value:
27
+ raise DeploymentKitError(f"Duplicate JSON property: {key}")
28
+ value[key] = item
29
+ return value
30
+
31
+
32
+ def config_root(home: Path | None = None) -> Path:
33
+ override = os.environ.get("LEAN_POC_HOME")
34
+ if override:
35
+ candidate = Path(override).expanduser()
36
+ if not candidate.is_absolute():
37
+ raise DeploymentKitError("LEAN_POC_HOME must be an absolute path.")
38
+ return candidate.resolve()
39
+ return ((home or Path.home()).resolve() / ".lean-poc").resolve()
40
+
41
+
42
+ def validate_target_name(name: str) -> str:
43
+ if not isinstance(name, str) or TARGET_NAME_RE.fullmatch(name) is None:
44
+ raise DeploymentKitError(
45
+ "Target name must use lowercase letters, digits, and hyphens, and must start with a letter or digit."
46
+ )
47
+ return name
48
+
49
+
50
+ def target_profile_path(name: str, home: Path | None = None) -> Path:
51
+ validate_target_name(name)
52
+ return config_root(home) / "targets" / f"{name}.json"
53
+
54
+
55
+ def _validate_host_name(value: Any) -> str:
56
+ if not isinstance(value, str) or not value or len(value) > 253:
57
+ raise DeploymentKitError("hostName must be a DNS/mDNS hostname.")
58
+ if value.endswith("."):
59
+ value = value[:-1]
60
+ try:
61
+ ipaddress.ip_address(value)
62
+ except ValueError:
63
+ pass
64
+ else:
65
+ raise DeploymentKitError(
66
+ "hostName must be a stable DNS/mDNS name, not an IP address; use addressOverride for a verified temporary IP."
67
+ )
68
+ labels = value.split(".")
69
+ if any(HOST_LABEL_RE.fullmatch(label) is None for label in labels):
70
+ raise DeploymentKitError("hostName must be a valid DNS/mDNS hostname.")
71
+ return value.lower()
72
+
73
+
74
+ def _ssh_path(value: Any, field: str, home: Path) -> Path:
75
+ if not isinstance(value, str) or not value.strip():
76
+ raise DeploymentKitError(f"{field} must not be empty.")
77
+ if value == "~":
78
+ candidate = home
79
+ elif value.startswith("~/") or value.startswith("~\\"):
80
+ candidate = home / value[2:]
81
+ else:
82
+ candidate = Path(value)
83
+ if not candidate.is_absolute():
84
+ raise DeploymentKitError(f"{field} must be absolute or home-relative with '~'.")
85
+ resolved = candidate.resolve()
86
+ ssh_dir = (home / ".ssh").resolve()
87
+ if resolved == ssh_dir or not resolved.is_relative_to(ssh_dir):
88
+ raise DeploymentKitError(f"{field} must be a file inside the user's SSH directory: {ssh_dir}")
89
+ return resolved
90
+
91
+
92
+ def validate_profile(
93
+ value: dict[str, Any],
94
+ *,
95
+ expected_target_name: str | None = None,
96
+ home: Path | None = None,
97
+ ) -> dict[str, Any]:
98
+ expected_fields = {
99
+ "schemaVersion",
100
+ "targetName",
101
+ "targetOs",
102
+ "hostName",
103
+ "sshAlias",
104
+ "user",
105
+ "port",
106
+ "identityFile",
107
+ "knownHostsFile",
108
+ "addressOverride",
109
+ }
110
+ if not isinstance(value, dict) or set(value) != expected_fields:
111
+ raise DeploymentKitError(
112
+ "Target profile must contain exactly: " + ", ".join(sorted(expected_fields)) + "."
113
+ )
114
+ if type(value["schemaVersion"]) is not int or value["schemaVersion"] != PROFILE_SCHEMA_VERSION:
115
+ raise DeploymentKitError("Unsupported target profile schemaVersion.")
116
+
117
+ target_name = validate_target_name(value["targetName"])
118
+ if expected_target_name is not None and target_name != expected_target_name:
119
+ raise DeploymentKitError(
120
+ f"Target profile mismatch: expected '{expected_target_name}', got '{target_name}'."
121
+ )
122
+
123
+ target_os = value["targetOs"]
124
+ if target_os not in SUPPORTED_TARGET_OS:
125
+ raise DeploymentKitError(
126
+ f"Unsupported targetOs '{target_os}'. Initial support is limited to: {', '.join(sorted(SUPPORTED_TARGET_OS))}."
127
+ )
128
+
129
+ host_name = _validate_host_name(value["hostName"])
130
+ alias = value["sshAlias"]
131
+ if not isinstance(alias, str) or ALIAS_RE.fullmatch(alias) is None:
132
+ raise DeploymentKitError("sshAlias contains unsupported characters.")
133
+
134
+ username = value["user"]
135
+ if not isinstance(username, str) or USER_RE.fullmatch(username) is None:
136
+ raise DeploymentKitError("user contains unsupported characters.")
137
+
138
+ port = value["port"]
139
+ if type(port) is not int or not 1 <= port <= 65535:
140
+ raise DeploymentKitError("port must be an integer from 1 through 65535.")
141
+
142
+ user_home = (home or Path.home()).resolve()
143
+ identity_file = _ssh_path(value["identityFile"], "identityFile", user_home)
144
+ known_hosts_file = _ssh_path(value["knownHostsFile"], "knownHostsFile", user_home)
145
+ if identity_file == known_hosts_file:
146
+ raise DeploymentKitError("identityFile and knownHostsFile must be different paths.")
147
+
148
+ address_override = value["addressOverride"]
149
+ if address_override is not None:
150
+ if not isinstance(address_override, str):
151
+ raise DeploymentKitError("addressOverride must be null or an IP address.")
152
+ try:
153
+ parsed = ipaddress.ip_address(address_override)
154
+ except ValueError as error:
155
+ raise DeploymentKitError("addressOverride must be a valid IPv4 or IPv6 address.") from error
156
+ if isinstance(parsed, ipaddress.IPv6Address) and parsed.scope_id is not None:
157
+ raise DeploymentKitError("addressOverride must not include an IPv6 scope identifier.")
158
+ address_override = str(parsed)
159
+
160
+ return {
161
+ "schemaVersion": PROFILE_SCHEMA_VERSION,
162
+ "targetName": target_name,
163
+ "targetOs": target_os,
164
+ "hostName": host_name,
165
+ "sshAlias": alias,
166
+ "user": username,
167
+ "port": port,
168
+ "identityFile": str(identity_file),
169
+ "knownHostsFile": str(known_hosts_file),
170
+ "addressOverride": address_override,
171
+ }
172
+
173
+
174
+ def load_profile(name: str, *, home: Path | None = None) -> dict[str, Any]:
175
+ path = target_profile_path(name, home)
176
+ if path.is_symlink() or path.parent.is_symlink():
177
+ raise DeploymentKitError("Target profile and its containing directory must not be symbolic links.")
178
+ if not path.is_file():
179
+ raise DeploymentKitError(f"Target profile is missing: {path}")
180
+ try:
181
+ with path.open(encoding="utf-8") as source:
182
+ value = json.load(source, object_pairs_hook=_unique_object)
183
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
184
+ raise DeploymentKitError(f"Cannot read target profile {path}: {error}") from error
185
+ return validate_profile(value, expected_target_name=name, home=home)
186
+
187
+
188
+ def save_profile(value: dict[str, Any], *, home: Path | None = None) -> Path:
189
+ normalized = validate_profile(value, home=home)
190
+ path = target_profile_path(normalized["targetName"], home)
191
+ directory = path.parent
192
+ if directory.exists() and (directory.is_symlink() or not directory.is_dir()):
193
+ raise DeploymentKitError(f"Target profile directory is not a real directory: {directory}")
194
+ directory.mkdir(parents=True, exist_ok=True, mode=0o700)
195
+ if os.name != "nt":
196
+ directory.chmod(0o700)
197
+ if path.exists() and path.is_symlink():
198
+ raise DeploymentKitError(f"Refusing to replace symbolic-link target profile: {path}")
199
+ temporary = path.with_suffix(".tmp")
200
+ temporary.write_text(json.dumps(normalized, indent=2) + "\n", encoding="utf-8")
201
+ if os.name != "nt":
202
+ temporary.chmod(0o600)
203
+ temporary.replace(path)
204
+ return path