@mriekki/project-forge 0.0.0-stage → 2.0.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 (117) hide show
  1. package/README.md +134 -2
  2. package/assets/components.json +33 -0
  3. package/assets/core/distribution.json +66 -0
  4. package/assets/deployment/distribution.json +54 -0
  5. package/assets/generated/ASSEMBLY.json +557 -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/project-forge-docs.instructions.md +45 -0
  9. package/assets/generated/core/core/instructions/project-forge-workflow.instructions.md +79 -0
  10. package/assets/generated/core/core/learning/CODING_DRILLS.md +35 -0
  11. package/assets/generated/core/core/learning/DIAGRAM_GUIDE.md +59 -0
  12. package/assets/generated/core/core/learning/ENGINEERING_LEARNING_WORKBOOK.md +108 -0
  13. package/assets/generated/core/core/learning/LEARNING_MAP.md +108 -0
  14. package/assets/generated/core/core/learning/LEARNING_PROGRESS.md +39 -0
  15. package/assets/generated/core/core/learning/RESOURCE_CATALOG.md +25 -0
  16. package/assets/generated/core/core/learning/SQL_DRILLS.md +90 -0
  17. package/assets/generated/core/core/learning/SYSTEM_DESIGN_DRILLS.md +301 -0
  18. package/assets/generated/core/core/process/COACHING_GUIDE.md +92 -0
  19. package/assets/generated/core/core/process/DATABASE_LEARNING_WORKBENCH.md +397 -0
  20. package/assets/generated/core/core/process/DEPLOYMENT_ENABLEMENT.md +166 -0
  21. package/assets/generated/core/core/process/GUIDED_NEXT_STEPS.md +48 -0
  22. package/assets/generated/core/core/process/HUMAN_GATES.md +83 -0
  23. package/assets/generated/core/core/process/INTERACTIVE_SESSION_GUIDE.md +64 -0
  24. package/assets/generated/core/core/process/PROJECT_DOCUMENTATION_FRAMEWORK.md +326 -0
  25. package/assets/generated/core/core/process/PROJECT_FORGE_ENGINEERING_GUIDE.md +308 -0
  26. package/assets/generated/core/core/process/PR_WORKFLOW.md +55 -0
  27. package/assets/generated/core/core/process/SETUP_ACCEPTANCE_CHECKLIST.md +72 -0
  28. package/assets/generated/core/core/process/SYSTEM_DESIGN_LEARNING_WORKBENCH.md +520 -0
  29. package/assets/generated/core/core/process/WORK_ITEM_MODEL.md +56 -0
  30. package/assets/generated/core/core/skills/cleanup-branches/SKILL.md +190 -0
  31. package/assets/generated/core/core/skills/coach/SKILL.md +174 -0
  32. package/assets/generated/core/core/skills/code-walkthrough/SKILL.md +197 -0
  33. package/assets/generated/core/core/skills/core-sync/SKILL.md +178 -0
  34. package/assets/generated/core/core/skills/db-lab/SKILL.md +245 -0
  35. package/assets/generated/core/core/skills/define-feature/SKILL.md +144 -0
  36. package/assets/generated/core/core/skills/deliver/SKILL.md +397 -0
  37. package/assets/generated/core/core/skills/deploy/SKILL.md +188 -0
  38. package/assets/generated/core/core/skills/deployment-readiness/SKILL.md +234 -0
  39. package/assets/generated/core/core/skills/developer-env-mode/SKILL.md +231 -0
  40. package/assets/generated/core/core/skills/docs/SKILL.md +116 -0
  41. package/assets/generated/core/core/skills/grill-me/SKILL.md +77 -0
  42. package/assets/generated/core/core/skills/grill-with-docs/SKILL.md +90 -0
  43. package/assets/generated/core/core/skills/learning-review/SKILL.md +90 -0
  44. package/assets/generated/core/core/skills/next-step/SKILL.md +221 -0
  45. package/assets/generated/core/core/skills/progress/SKILL.md +207 -0
  46. package/assets/generated/core/core/skills/project-forge-update/SKILL.md +228 -0
  47. package/assets/generated/core/core/skills/pull-latest/SKILL.md +166 -0
  48. package/assets/generated/core/core/skills/refine-requirements/SKILL.md +207 -0
  49. package/assets/generated/core/core/skills/setup/SKILL.md +362 -0
  50. package/assets/generated/core/core/skills/start-work/SKILL.md +190 -0
  51. package/assets/generated/core/core/skills/system-lab/SKILL.md +333 -0
  52. package/assets/generated/core/core/skills/tear-down/SKILL.md +140 -0
  53. package/assets/generated/core/core/skills/upstream-fix/SKILL.md +221 -0
  54. package/assets/generated/core/core/skills/wait-what/SKILL.md +86 -0
  55. package/assets/generated/core/core/templates/ADR_TEMPLATE.md +41 -0
  56. package/assets/generated/core/core/templates/FEATURE_BRIEF_TEMPLATE.md +27 -0
  57. package/assets/generated/core/core/templates/PRODUCT_BRIEF_TEMPLATE.md +27 -0
  58. package/assets/generated/core/core/templates/PULL_REQUEST_TEMPLATE.md +37 -0
  59. package/assets/generated/core/core/templates/REQUIREMENTS_TEMPLATE.md +17 -0
  60. package/assets/generated/core/profiles/greenfield/project-forge-workspace.json +12 -0
  61. package/assets/generated/core/tools/db_lab/sqlite_workbench.py +463 -0
  62. package/assets/generated/core/tools/docs_site/docs_site.py +512 -0
  63. package/assets/generated/core/tools/package/check_baseline.py +102 -0
  64. package/assets/generated/core/tools/package/check_upstream.py +171 -0
  65. package/assets/generated/core/tools/system_lab/system_workbench.py +243 -0
  66. package/assets/generated/deployment/distribution/project-forge-deploy.py +8 -0
  67. package/assets/generated/deployment/docs/DEVELOPER_ENVIRONMENTS.md +335 -0
  68. package/assets/generated/deployment/docs/ENVIRONMENT_PROOF.md +95 -0
  69. package/assets/generated/deployment/docs/WORKSPACE_INSTANCES.md +154 -0
  70. package/assets/generated/deployment/docs/images/developer-environment-journey.svg +56 -0
  71. package/assets/generated/deployment/docs/images/personal-environment-architecture.svg +54 -0
  72. package/assets/generated/deployment/docs/images/poc-environment-model.svg +61 -0
  73. package/assets/generated/deployment/src/project_forge_deployment/__init__.py +3 -0
  74. package/assets/generated/deployment/src/project_forge_deployment/__main__.py +3 -0
  75. package/assets/generated/deployment/src/project_forge_deployment/app_cli.py +163 -0
  76. package/assets/generated/deployment/src/project_forge_deployment/app_runtime.py +455 -0
  77. package/assets/generated/deployment/src/project_forge_deployment/app_runtime_remote.py +416 -0
  78. package/assets/generated/deployment/src/project_forge_deployment/app_runtime_workstation.py +136 -0
  79. package/assets/generated/deployment/src/project_forge_deployment/box_platform.py +208 -0
  80. package/assets/generated/deployment/src/project_forge_deployment/box_platform_assets.py +100 -0
  81. package/assets/generated/deployment/src/project_forge_deployment/box_platform_remote.py +271 -0
  82. package/assets/generated/deployment/src/project_forge_deployment/cli.py +437 -0
  83. package/assets/generated/deployment/src/project_forge_deployment/doctor.py +121 -0
  84. package/assets/generated/deployment/src/project_forge_deployment/entrypoint.py +37 -0
  85. package/assets/generated/deployment/src/project_forge_deployment/environment_cli.py +271 -0
  86. package/assets/generated/deployment/src/project_forge_deployment/environment_probe.py +228 -0
  87. package/assets/generated/deployment/src/project_forge_deployment/errors.py +5 -0
  88. package/assets/generated/deployment/src/project_forge_deployment/home_lab.py +328 -0
  89. package/assets/generated/deployment/src/project_forge_deployment/image_transfer.py +105 -0
  90. package/assets/generated/deployment/src/project_forge_deployment/keys.py +171 -0
  91. package/assets/generated/deployment/src/project_forge_deployment/local_cli.py +189 -0
  92. package/assets/generated/deployment/src/project_forge_deployment/local_runtime.py +526 -0
  93. package/assets/generated/deployment/src/project_forge_deployment/local_spec.py +82 -0
  94. package/assets/generated/deployment/src/project_forge_deployment/local_sqlite.py +205 -0
  95. package/assets/generated/deployment/src/project_forge_deployment/portable_profile.py +181 -0
  96. package/assets/generated/deployment/src/project_forge_deployment/profile_migration.py +70 -0
  97. package/assets/generated/deployment/src/project_forge_deployment/profile_repository.py +348 -0
  98. package/assets/generated/deployment/src/project_forge_deployment/profiles.py +204 -0
  99. package/assets/generated/deployment/src/project_forge_deployment/registry_access.py +114 -0
  100. package/assets/generated/deployment/src/project_forge_deployment/registry_cli.py +51 -0
  101. package/assets/generated/deployment/src/project_forge_deployment/route_registry.py +367 -0
  102. package/assets/generated/deployment/src/project_forge_deployment/route_registry_remote.py +443 -0
  103. package/assets/generated/deployment/src/project_forge_deployment/security.py +290 -0
  104. package/assets/generated/deployment/src/project_forge_deployment/sqlite_adapter.py +335 -0
  105. package/assets/generated/deployment/src/project_forge_deployment/sqlite_adapter_remote.py +340 -0
  106. package/assets/generated/deployment/src/project_forge_deployment/sqlite_cli.py +86 -0
  107. package/assets/generated/deployment/src/project_forge_deployment/sqlite_import_recovery.py +437 -0
  108. package/assets/generated/deployment/src/project_forge_deployment/ssh.py +119 -0
  109. package/assets/generated/deployment/src/project_forge_deployment/teardown.py +300 -0
  110. package/assets/generated/deployment/src/project_forge_deployment/teardown_cli.py +139 -0
  111. package/assets/generated/deployment/src/project_forge_deployment/teardown_remote.py +100 -0
  112. package/assets/generated/deployment/src/project_forge_deployment/trust.py +278 -0
  113. package/bin/project-forge.mjs +9 -0
  114. package/package.json +31 -4
  115. package/src/cli.mjs +150 -0
  116. package/src/engine.mjs +243 -0
  117. package/src/package-data.mjs +18 -0
@@ -0,0 +1,271 @@
1
+ """CLI for portable developer environment definitions and Home Lab onboarding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .environment_probe import (
12
+ DEFAULT_PROBE_IMAGE,
13
+ ENVIRONMENT_PROOF_WRITE_PHRASE,
14
+ PRODUCTION_ENVIRONMENT_PROOF_WRITE_PHRASE,
15
+ plan_environment_proof,
16
+ run_environment_proof,
17
+ )
18
+ from .errors import DeploymentKitError
19
+ from .home_lab import HOME_LAB_BOOTSTRAP_PHRASE, apply_home_lab_bootstrap, plan_home_lab_bootstrap
20
+ from .image_transfer import transfer_image, verify_local_image
21
+ from .portable_profile import apply_import, export_bundle, load_bundle, plan_import, write_bundle
22
+ from .profile_migration import PERSONAL_PROFILE_MIGRATION_PHRASE, migrate_current_workstation
23
+ from .profile_repository import (
24
+ PERSONAL_PROFILE_WRITE_PHRASE,
25
+ ensure_profile_repository,
26
+ publish_profile,
27
+ repository_status,
28
+ sync_profile,
29
+ )
30
+
31
+
32
+ LOCAL_PROFILE_IMPORT_PHRASE = "[LOCAL PROFILE IMPORT]"
33
+ REMOTE_IMAGE_TRANSFER_PHRASE = "[REMOTE IMAGE TRANSFER]"
34
+
35
+
36
+ def _print(value: Any, *, as_json: bool = False) -> None:
37
+ if as_json:
38
+ print(json.dumps(value, indent=2, sort_keys=True))
39
+ return
40
+ if isinstance(value, dict):
41
+ for key, item in value.items():
42
+ if isinstance(item, (dict, list)):
43
+ print(f"{key}: {json.dumps(item, sort_keys=True)}")
44
+ else:
45
+ print(f"{key}: {item}")
46
+ return
47
+ print(value)
48
+
49
+
50
+ def _confirm(args: argparse.Namespace, phrase: str, action: str) -> None:
51
+ if getattr(args, "yes", False):
52
+ return
53
+ entered = input(f"Type {phrase} to {action}: ").strip()
54
+ if entered != phrase:
55
+ raise DeploymentKitError(f"{action.capitalize()} was not authorized.")
56
+
57
+
58
+ def build_parser() -> argparse.ArgumentParser:
59
+ parser = argparse.ArgumentParser(prog="project-forge-deploy environment")
60
+ groups = parser.add_subparsers(dest="group", required=True)
61
+
62
+ profile = groups.add_parser("profile", help="Personal/portable non-secret target profile operations")
63
+ profile_sub = profile.add_subparsers(dest="command", required=True)
64
+
65
+ status = profile_sub.add_parser("status", help="Inspect the authenticated developer's private project-forge-profile repository")
66
+ status.add_argument("--json", action="store_true")
67
+
68
+ ensure = profile_sub.add_parser(
69
+ "ensure",
70
+ help="Create/initialize the authenticated developer's private project-forge-profile repository when missing",
71
+ )
72
+ ensure.add_argument("--json", action="store_true")
73
+ ensure.add_argument("--yes", action="store_true")
74
+
75
+ migrate = profile_sub.add_parser(
76
+ "migrate",
77
+ help="One-time adoption of this workstation's existing target definitions into the personal profile model",
78
+ )
79
+ migrate.add_argument("--json", action="store_true")
80
+ migrate.add_argument("--yes", action="store_true")
81
+
82
+ sync = profile_sub.add_parser(
83
+ "sync",
84
+ help="Fetch/validate the personal profile, update last-known-good cache, and optionally import missing definitions",
85
+ )
86
+ sync.add_argument("--apply", action="store_true", help="Create only missing non-conflicting local target profiles")
87
+ sync.add_argument("--json", action="store_true")
88
+
89
+ publish = profile_sub.add_parser(
90
+ "publish",
91
+ help="Merge new non-conflicting local target definitions into the private personal profile",
92
+ )
93
+ publish.add_argument("--json", action="store_true")
94
+ publish.add_argument("--yes", action="store_true")
95
+
96
+ export = profile_sub.add_parser("export", help="Export sanitized target definitions to a portable bundle")
97
+ export.add_argument("--target", action="append", default=[])
98
+ export.add_argument("--output", type=Path, required=True)
99
+ export.add_argument("--json", action="store_true")
100
+
101
+ preview = profile_sub.add_parser("preview-import", help="Preview a portable bundle without writing local profiles")
102
+ preview.add_argument("bundle", type=Path)
103
+ preview.add_argument("--json", action="store_true")
104
+
105
+ apply = profile_sub.add_parser("import", help="Import only non-conflicting portable target definitions")
106
+ apply.add_argument("bundle", type=Path)
107
+ apply.add_argument("--json", action="store_true")
108
+ apply.add_argument("--yes", action="store_true")
109
+
110
+ home_lab = groups.add_parser(
111
+ "home-lab",
112
+ help="Password-assisted one-time SSH bootstrap followed by strict per-workstation key access",
113
+ )
114
+ home_lab_sub = home_lab.add_subparsers(dest="command", required=True)
115
+ for command in ("plan", "bootstrap"):
116
+ sub = home_lab_sub.add_parser(command)
117
+ sub.add_argument("name")
118
+ sub.add_argument("--host-name", required=True)
119
+ sub.add_argument("--user", required=True)
120
+ sub.add_argument("--port", type=int, default=22)
121
+ sub.add_argument("--ssh-alias")
122
+ sub.add_argument("--address", help="Optional exact temporary IP; stable host identity remains --host-name/alias")
123
+ sub.add_argument(
124
+ "--protect-key-with-passphrase",
125
+ action="store_true",
126
+ help="Let ssh-keygen prompt locally for a key passphrase; default Home Lab key is unencrypted",
127
+ )
128
+ sub.add_argument("--json", action="store_true")
129
+ if command == "bootstrap":
130
+ sub.add_argument("--yes", action="store_true")
131
+
132
+ image = groups.add_parser("image", help="Workstation-mediated exact Docker image transfer")
133
+ image_sub = image.add_subparsers(dest="command", required=True)
134
+ image_check = image_sub.add_parser("check", help="Verify an exact immutable image exists locally")
135
+ image_check.add_argument("--image", required=True)
136
+ image_check.add_argument("--json", action="store_true")
137
+ image_transfer = image_sub.add_parser("transfer", help="Stream an exact local image to a verified target over SSH")
138
+ image_transfer.add_argument("name")
139
+ image_transfer.add_argument("--image", required=True)
140
+ image_transfer.add_argument("--json", action="store_true")
141
+ image_transfer.add_argument("--yes", action="store_true")
142
+
143
+ proof = groups.add_parser(
144
+ "proof",
145
+ help="Plan/run a disposable end-to-end deployment proof for one named remote environment",
146
+ )
147
+ proof_sub = proof.add_subparsers(dest="command", required=True)
148
+ for command in ("plan", "run"):
149
+ sub = proof_sub.add_parser(command)
150
+ sub.add_argument("name", help="Logical target profile name")
151
+ sub.add_argument("--owner-repo", required=True)
152
+ sub.add_argument("--environment", required=True)
153
+ sub.add_argument(
154
+ "--production",
155
+ action="store_true",
156
+ help="Classify the named environment as Production even when its name is not literally 'production'",
157
+ )
158
+ sub.add_argument("--probe-image", default=DEFAULT_PROBE_IMAGE)
159
+ sub.add_argument("--json", action="store_true")
160
+ if command == "run":
161
+ sub.add_argument("--yes", action="store_true")
162
+
163
+ return parser
164
+
165
+
166
+ def _home_lab_plan(args: argparse.Namespace) -> dict[str, Any]:
167
+ return plan_home_lab_bootstrap(
168
+ args.name,
169
+ host_name=args.host_name,
170
+ user=args.user,
171
+ port=args.port,
172
+ ssh_alias=args.ssh_alias,
173
+ address=args.address,
174
+ protect_key_with_passphrase=args.protect_key_with_passphrase,
175
+ )
176
+
177
+
178
+ def main(argv: list[str] | None = None) -> int:
179
+ args = build_parser().parse_args(argv)
180
+ try:
181
+ if args.group == "profile" and args.command == "status":
182
+ _print(repository_status(), as_json=args.json)
183
+ return 0
184
+ if args.group == "profile" and args.command == "ensure":
185
+ _print(repository_status(), as_json=False)
186
+ _confirm(args, PERSONAL_PROFILE_WRITE_PHRASE, "create/initialize the private personal Project Forge profile when needed")
187
+ _print(ensure_profile_repository(), as_json=args.json)
188
+ return 0
189
+ if args.group == "profile" and args.command == "migrate":
190
+ _confirm(args, PERSONAL_PROFILE_MIGRATION_PHRASE, "migrate this workstation to the personal Project Forge profile model")
191
+ _print(migrate_current_workstation(), as_json=args.json)
192
+ return 0
193
+ if args.group == "profile" and args.command == "sync":
194
+ _print(sync_profile(apply=args.apply), as_json=args.json)
195
+ return 0
196
+ if args.group == "profile" and args.command == "publish":
197
+ _confirm(args, PERSONAL_PROFILE_WRITE_PHRASE, "publish non-secret target definitions to the private personal Project Forge profile")
198
+ _print(publish_profile(), as_json=args.json)
199
+ return 0
200
+ if args.group == "profile" and args.command == "export":
201
+ bundle = export_bundle(args.target or None)
202
+ path = write_bundle(bundle, args.output)
203
+ _print({"path": str(path), "targets": [item["targetName"] for item in bundle["targets"]]}, as_json=args.json)
204
+ return 0
205
+ if args.group == "profile" and args.command == "preview-import":
206
+ _print(plan_import(load_bundle(args.bundle)), as_json=args.json)
207
+ return 0
208
+ if args.group == "profile" and args.command == "import":
209
+ plan = plan_import(load_bundle(args.bundle))
210
+ _print(plan, as_json=False)
211
+ if plan["hasConflicts"]:
212
+ raise DeploymentKitError("Import conflicts must be resolved explicitly; no local profiles were changed.")
213
+ _confirm(args, LOCAL_PROFILE_IMPORT_PHRASE, "import these portable target definitions")
214
+ _print(apply_import(plan), as_json=args.json)
215
+ return 0
216
+ if args.group == "home-lab":
217
+ plan = _home_lab_plan(args)
218
+ _print(plan, as_json=args.json if args.command == "plan" else False)
219
+ if args.command == "plan":
220
+ return 0
221
+ _confirm(args, HOME_LAB_BOOTSTRAP_PHRASE, f"bootstrap Home Lab access for target {args.name}")
222
+ _print(
223
+ apply_home_lab_bootstrap(
224
+ plan,
225
+ protect_key_with_passphrase=args.protect_key_with_passphrase,
226
+ ),
227
+ as_json=args.json,
228
+ )
229
+ return 0
230
+ if args.group == "image" and args.command == "check":
231
+ _print(verify_local_image(args.image), as_json=args.json)
232
+ return 0
233
+ if args.group == "image" and args.command == "transfer":
234
+ _print(verify_local_image(args.image), as_json=False)
235
+ _confirm(args, REMOTE_IMAGE_TRANSFER_PHRASE, f"transfer this exact image to target {args.name}")
236
+ _print(transfer_image(args.name, args.image), as_json=args.json)
237
+ return 0
238
+ if args.group == "proof":
239
+ plan = plan_environment_proof(
240
+ args.name,
241
+ owner_repo=args.owner_repo,
242
+ environment=args.environment,
243
+ production=args.production,
244
+ probe_image=args.probe_image,
245
+ )
246
+ _print(plan, as_json=args.json if args.command == "plan" else False)
247
+ if args.command == "plan":
248
+ return 0
249
+ _confirm(
250
+ args,
251
+ plan["requiredAuthorization"],
252
+ f"run a disposable deployment proof for {plan['environment']} on target {plan['targetName']}",
253
+ )
254
+ _print(run_environment_proof(plan), as_json=args.json)
255
+ return 0
256
+ raise DeploymentKitError("Unsupported environment command.")
257
+ except DeploymentKitError as error:
258
+ print(f"ERROR: {error}", file=sys.stderr)
259
+ return 2
260
+
261
+
262
+ __all__ = [
263
+ "ENVIRONMENT_PROOF_WRITE_PHRASE",
264
+ "PRODUCTION_ENVIRONMENT_PROOF_WRITE_PHRASE",
265
+ "build_parser",
266
+ "main",
267
+ ]
268
+
269
+
270
+ if __name__ == "__main__":
271
+ raise SystemExit(main())
@@ -0,0 +1,228 @@
1
+ """Disposable end-to-end proof for one named remote deployment environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ from typing import Any
11
+
12
+ from .app_runtime import normalize_app_spec, plan_app_deploy
13
+ from .app_runtime_workstation import apply_app_deploy_from_workstation
14
+ from .box_platform import platform_status
15
+ from .errors import DeploymentKitError
16
+ from .registry_access import normalize_registry_image
17
+ from .teardown import tear_down_poc
18
+
19
+ ENVIRONMENT_PROOF_WRITE_PHRASE = "[ENVIRONMENT PROOF WRITE]"
20
+ PRODUCTION_ENVIRONMENT_PROOF_WRITE_PHRASE = "[PRODUCTION ENVIRONMENT PROOF]"
21
+ DEFAULT_PROBE_IMAGE = "nginx:alpine"
22
+
23
+ _SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
24
+ _REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
25
+
26
+
27
+ def _slug(value: str, field: str) -> str:
28
+ if not isinstance(value, str) or _SLUG_RE.fullmatch(value) is None:
29
+ raise DeploymentKitError(f"{field} must use lowercase letters, digits, and internal hyphens.")
30
+ return value
31
+
32
+
33
+ def _owner_repo(value: str) -> str:
34
+ if not isinstance(value, str) or _REPO_RE.fullmatch(value) is None:
35
+ raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
36
+ return value
37
+
38
+
39
+ def _docker() -> str:
40
+ path = shutil.which("docker")
41
+ if path is None:
42
+ raise DeploymentKitError("Docker CLI is unavailable on the workstation.")
43
+ return path
44
+
45
+
46
+ def _probe_slug(owner_repo: str) -> str:
47
+ digest = hashlib.sha256(owner_repo.encode("utf-8")).hexdigest()[:10]
48
+ return f"project-forge-env-probe-{digest}"
49
+
50
+
51
+ def _resolve_probe_image(image_tag: str) -> str:
52
+ if not isinstance(image_tag, str) or not image_tag.strip() or "@sha256:" in image_tag:
53
+ raise DeploymentKitError("probeImage must be a non-empty mutable image tag; Project Forge resolves it to an exact digest.")
54
+ docker = _docker()
55
+ pulled = subprocess.run(
56
+ [docker, "pull", image_tag],
57
+ check=False,
58
+ capture_output=True,
59
+ text=True,
60
+ encoding="utf-8",
61
+ errors="replace",
62
+ )
63
+ if pulled.returncode != 0:
64
+ raise DeploymentKitError(
65
+ "Environment proof image pull failed: " + (pulled.stderr or pulled.stdout).strip()
66
+ )
67
+ inspected = subprocess.run(
68
+ [docker, "image", "inspect", "--format", "{{json .RepoDigests}}", image_tag],
69
+ check=False,
70
+ capture_output=True,
71
+ text=True,
72
+ encoding="utf-8",
73
+ errors="replace",
74
+ )
75
+ if inspected.returncode != 0:
76
+ raise DeploymentKitError(
77
+ "Environment proof image digest inspection failed: " + (inspected.stderr or inspected.stdout).strip()
78
+ )
79
+ try:
80
+ digests = json.loads(inspected.stdout.strip())
81
+ except json.JSONDecodeError as error:
82
+ raise DeploymentKitError("Environment proof image inspection returned malformed RepoDigests JSON.") from error
83
+ if not isinstance(digests, list):
84
+ raise DeploymentKitError("Environment proof image inspection did not return a RepoDigests list.")
85
+ for item in digests:
86
+ try:
87
+ return normalize_registry_image(item)
88
+ except DeploymentKitError:
89
+ continue
90
+ raise DeploymentKitError("Environment proof image has no usable immutable sha256 RepoDigest.")
91
+
92
+
93
+ def plan_environment_proof(
94
+ target_name: str,
95
+ *,
96
+ owner_repo: str,
97
+ environment: str,
98
+ production: bool = False,
99
+ probe_image: str = DEFAULT_PROBE_IMAGE,
100
+ ) -> dict[str, Any]:
101
+ owner = _owner_repo(owner_repo)
102
+ env = _slug(environment, "environment")
103
+ if not isinstance(production, bool):
104
+ raise DeploymentKitError("production must be true or false.")
105
+ if not isinstance(probe_image, str) or not probe_image.strip():
106
+ raise DeploymentKitError("probeImage must be a non-empty image tag.")
107
+ platform = platform_status(target_name)
108
+ if platform.get("state") != "ready":
109
+ raise DeploymentKitError(
110
+ f"Environment proof requires a ready shared platform; target '{target_name}' is '{platform.get('state', 'unknown')}'."
111
+ )
112
+ return {
113
+ "targetName": target_name,
114
+ "ownerRepo": owner,
115
+ "environment": env,
116
+ "production": production,
117
+ "probeAppSlug": _probe_slug(owner),
118
+ "probeImageTag": probe_image,
119
+ "artifactDelivery": "workstation-transfer",
120
+ "persistence": "none",
121
+ "healthPath": "/",
122
+ "cleanup": "automatic",
123
+ "requiredAuthorization": (
124
+ PRODUCTION_ENVIRONMENT_PROOF_WRITE_PHRASE if production else ENVIRONMENT_PROOF_WRITE_PHRASE
125
+ ),
126
+ "mutated": False,
127
+ }
128
+
129
+
130
+ def run_environment_proof(plan: dict[str, Any]) -> dict[str, Any]:
131
+ required = {
132
+ "targetName",
133
+ "ownerRepo",
134
+ "environment",
135
+ "production",
136
+ "probeAppSlug",
137
+ "probeImageTag",
138
+ "artifactDelivery",
139
+ "persistence",
140
+ "healthPath",
141
+ "cleanup",
142
+ "requiredAuthorization",
143
+ "mutated",
144
+ }
145
+ if not isinstance(plan, dict) or set(plan) != required or plan["mutated"] is not False:
146
+ raise DeploymentKitError("Malformed environment proof plan.")
147
+
148
+ exact_image = _resolve_probe_image(plan["probeImageTag"])
149
+ spec = normalize_app_spec(
150
+ owner_repo=plan["ownerRepo"],
151
+ environment=plan["environment"],
152
+ app_slug=plan["probeAppSlug"],
153
+ container_name=f"{plan['probeAppSlug']}--{plan['environment']}",
154
+ image=exact_image,
155
+ runtime_port=80,
156
+ health_path=plan["healthPath"],
157
+ run_as="0",
158
+ persistence_mode="none",
159
+ data_container_path=None,
160
+ environment_variables={},
161
+ )
162
+ deploy_plan = plan_app_deploy(plan["targetName"], spec)
163
+
164
+ deployment: dict[str, Any] | None = None
165
+ cleanup: dict[str, Any] | None = None
166
+ deploy_error: Exception | None = None
167
+ cleanup_error: Exception | None = None
168
+ try:
169
+ deployment = apply_app_deploy_from_workstation(deploy_plan)
170
+ except Exception as error: # cleanup must still be attempted after a partially applied proof
171
+ deploy_error = error
172
+ finally:
173
+ try:
174
+ cleanup = tear_down_poc(
175
+ {
176
+ "ownerRepo": plan["ownerRepo"],
177
+ "appSlug": plan["probeAppSlug"],
178
+ "scope": plan["environment"],
179
+ "environmentTargets": {plan["environment"]: plan["targetName"]},
180
+ "localPackage": None,
181
+ "productionEnvironments": [plan["environment"]] if plan["production"] else [],
182
+ }
183
+ )
184
+ except Exception as error:
185
+ cleanup_error = error
186
+
187
+ if deploy_error is not None:
188
+ detail = str(deploy_error)
189
+ if cleanup_error is not None:
190
+ detail += f" Cleanup also failed: {cleanup_error}"
191
+ raise DeploymentKitError(f"Environment proof deployment failed: {detail}") from deploy_error
192
+ if cleanup_error is not None:
193
+ raise DeploymentKitError(
194
+ f"Environment proof deployment succeeded but cleanup failed: {cleanup_error}"
195
+ ) from cleanup_error
196
+ assert deployment is not None and cleanup is not None
197
+
198
+ matched = next(
199
+ (
200
+ item
201
+ for item in deployment.get("applications", [])
202
+ if item.get("appSlug") == plan["probeAppSlug"]
203
+ and item.get("environment") == plan["environment"]
204
+ ),
205
+ None,
206
+ )
207
+ if not matched or not matched.get("containerRunning") or not matched.get("httpHealthy"):
208
+ raise DeploymentKitError("Environment proof returned without healthy disposable runtime evidence.")
209
+ cleanup_entry = next(
210
+ (item for item in cleanup.get("environments", []) if item.get("environment") == plan["environment"]),
211
+ None,
212
+ )
213
+ if cleanup_entry is None or cleanup_entry.get("runtimeAndPersistentData", {}).get("state") not in {"removed", "absent"}:
214
+ raise DeploymentKitError("Environment proof cleanup did not return terminal runtime cleanup evidence.")
215
+
216
+ return {
217
+ "targetName": plan["targetName"],
218
+ "ownerRepo": plan["ownerRepo"],
219
+ "environment": plan["environment"],
220
+ "production": plan["production"],
221
+ "probeAppSlug": plan["probeAppSlug"],
222
+ "probeImage": exact_image,
223
+ "artifactDelivery": "workstation-transfer",
224
+ "runtimeHealthy": True,
225
+ "cleanupState": cleanup_entry["runtimeAndPersistentData"]["state"],
226
+ "proof": "passed",
227
+ "mutated": True,
228
+ }
@@ -0,0 +1,5 @@
1
+ """Deployment Kit exception types."""
2
+
3
+
4
+ class DeploymentKitError(Exception):
5
+ """Expected fail-closed operational or configuration error."""