@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,300 @@
1
+ """Scoped destructive tear-down for Kit-managed named environments and local Docker state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import shlex
8
+ import shutil
9
+ import subprocess
10
+ from typing import Any, Iterable
11
+
12
+ from .errors import DeploymentKitError
13
+ from .profiles import load_profile
14
+ from .route_registry import apply_route_change, plan_route_removal, route_inventory
15
+ from .ssh import ssh_args, verify_access
16
+ from .teardown_remote import REMOTE_SCRIPT
17
+
18
+ TEAR_DOWN_WRITE_PHRASE = "[POC TEAR DOWN]"
19
+ PRODUCTION_DATA_DELETE_PHRASE = "[PRODUCTION DATA DELETE]"
20
+ RESERVED_SCOPES = ("local", "all")
21
+
22
+ _SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
23
+ _REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
24
+ _TARGET_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
25
+ _PACKAGE_RE = re.compile(r"^ghcr\.io/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
26
+
27
+
28
+ def _slug(value: str, field: str) -> str:
29
+ if not isinstance(value, str) or _SLUG_RE.fullmatch(value) is None:
30
+ raise DeploymentKitError(f"{field} must use lowercase letters, digits, and internal hyphens.")
31
+ return value
32
+
33
+
34
+ def _owner_repo(value: str) -> str:
35
+ if not isinstance(value, str) or _REPO_RE.fullmatch(value) is None:
36
+ raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
37
+ return value
38
+
39
+
40
+ def _package(value: str) -> str:
41
+ if not isinstance(value, str) or _PACKAGE_RE.fullmatch(value) is None:
42
+ raise DeploymentKitError("localPackage must use exact ghcr.io/owner/package form.")
43
+ return value
44
+
45
+
46
+ def parse_environment_targets(items: Iterable[str]) -> dict[str, str]:
47
+ result: dict[str, str] = {}
48
+ for item in items:
49
+ if not isinstance(item, str) or "=" not in item:
50
+ raise DeploymentKitError("environment target bindings must use environment=target form.")
51
+ environment, target = item.split("=", 1)
52
+ environment = _slug(environment, "environment")
53
+ if environment in RESERVED_SCOPES:
54
+ raise DeploymentKitError(f"{environment!r} is reserved and cannot be a remote environment name.")
55
+ if not target or _TARGET_RE.fullmatch(target) is None:
56
+ raise DeploymentKitError("target profile names contain unsupported characters.")
57
+ if environment in result:
58
+ raise DeploymentKitError(f"Duplicate environment target binding: {environment}")
59
+ result[environment] = target
60
+ return dict(sorted(result.items()))
61
+
62
+
63
+ def _normalize_production_environments(items: Iterable[str]) -> set[str]:
64
+ result = {"production"}
65
+ for item in items:
66
+ environment = _slug(item, "productionEnvironment")
67
+ if environment in RESERVED_SCOPES:
68
+ raise DeploymentKitError("local/all cannot be classified as Production environments.")
69
+ result.add(environment)
70
+ return result
71
+
72
+
73
+ def normalize_teardown_spec(
74
+ *,
75
+ owner_repo: str,
76
+ app_slug: str,
77
+ scope: str,
78
+ environment_targets: dict[str, str] | Iterable[tuple[str, str]],
79
+ local_package: str | None,
80
+ production_environments: Iterable[str] = (),
81
+ ) -> dict[str, Any]:
82
+ scope = _slug(scope, "scope")
83
+ pairs = environment_targets.items() if isinstance(environment_targets, dict) else environment_targets
84
+ normalized_targets: dict[str, str] = {}
85
+ for environment, target in pairs:
86
+ environment = _slug(environment, "environment")
87
+ if environment in RESERVED_SCOPES:
88
+ raise DeploymentKitError(f"{environment!r} is reserved and cannot be a remote environment name.")
89
+ if not isinstance(target, str) or _TARGET_RE.fullmatch(target) is None:
90
+ raise DeploymentKitError("target profile names contain unsupported characters.")
91
+ if environment in normalized_targets:
92
+ raise DeploymentKitError(f"Duplicate environment target binding: {environment}")
93
+ normalized_targets[environment] = target
94
+
95
+ if scope == "local":
96
+ selected_targets: dict[str, str] = {}
97
+ elif scope == "all":
98
+ selected_targets = dict(sorted(normalized_targets.items()))
99
+ else:
100
+ if scope not in normalized_targets:
101
+ raise DeploymentKitError(f"scope {scope!r} requires an explicit {scope}=target binding.")
102
+ selected_targets = {scope: normalized_targets[scope]}
103
+
104
+ package = _package(local_package) if local_package is not None else None
105
+ if scope == "local" and package is None:
106
+ raise DeploymentKitError("scope 'local' requires --local-package for exact local Docker ownership.")
107
+
108
+ production_set = _normalize_production_environments(production_environments)
109
+ selected_production = sorted(set(selected_targets).intersection(production_set))
110
+
111
+ return {
112
+ "schemaVersion": 2,
113
+ "ownerRepo": _owner_repo(owner_repo),
114
+ "appSlug": _slug(app_slug, "appSlug"),
115
+ "scope": scope,
116
+ "environmentTargets": selected_targets,
117
+ "localPackage": package,
118
+ "productionEnvironments": selected_production,
119
+ }
120
+
121
+
122
+ def _find_route(inventory: dict[str, Any], app_slug: str, environment: str) -> dict[str, Any] | None:
123
+ route_id = f"{app_slug}--{environment}"
124
+ return next((item for item in inventory["managedRoutes"] if item["routeId"] == route_id), None)
125
+
126
+
127
+ def _tear_down_route(target: str, owner_repo: str, app_slug: str, environment: str) -> dict[str, Any]:
128
+ inventory = route_inventory(target)
129
+ route = _find_route(inventory, app_slug, environment)
130
+ if route is None:
131
+ return {"state": "absent", "mutated": False}
132
+ if route["ownerRepo"] != owner_repo:
133
+ raise DeploymentKitError(
134
+ f"Route {app_slug}--{environment} is owned by {route['ownerRepo']}, not {owner_repo}."
135
+ )
136
+ plan = plan_route_removal(
137
+ target,
138
+ owner_repo=owner_repo,
139
+ environment=environment,
140
+ app_slug=app_slug,
141
+ )
142
+ apply_route_change(plan)
143
+ return {"state": "removed", "mutated": True, "hostnames": route["hostnames"]}
144
+
145
+
146
+ def _tear_down_remote_environment(
147
+ target: str,
148
+ owner_repo: str,
149
+ app_slug: str,
150
+ environment: str,
151
+ ) -> dict[str, Any]:
152
+ profile = load_profile(target)
153
+ evidence = verify_access(profile)
154
+ command = ssh_args(
155
+ profile,
156
+ evidence["address"],
157
+ command="sudo -n bash -s -- "
158
+ + " ".join(shlex.quote(value) for value in (owner_repo, app_slug, environment)),
159
+ )
160
+ completed = subprocess.run(
161
+ command,
162
+ input=REMOTE_SCRIPT.encode("utf-8"),
163
+ capture_output=True,
164
+ check=False,
165
+ )
166
+ stdout = completed.stdout.decode("utf-8", errors="replace").strip()
167
+ stderr = completed.stderr.decode("utf-8", errors="replace").strip()
168
+ if completed.returncode != 0:
169
+ raise DeploymentKitError(
170
+ "Remote POC tear-down failed: " + (stderr or stdout or f"exit {completed.returncode}")
171
+ )
172
+ parts = stdout.split("\t")
173
+ if len(parts) not in {4, 5} or parts[0] != "TEAR_DOWN" or parts[1] not in {"absent", "removed"}:
174
+ raise DeploymentKitError("Remote POC tear-down returned malformed evidence.")
175
+ if parts[2] != app_slug or parts[3] != environment:
176
+ raise DeploymentKitError("Remote POC tear-down returned mismatched app/environment evidence.")
177
+ result: dict[str, Any] = {"state": parts[1], "mutated": parts[1] == "removed"}
178
+ if len(parts) == 5:
179
+ result["containerName"] = parts[4]
180
+ return result
181
+
182
+
183
+ def _run_local(args: list[str]) -> subprocess.CompletedProcess[str]:
184
+ return subprocess.run(
185
+ args,
186
+ text=True,
187
+ encoding="utf-8",
188
+ errors="replace",
189
+ capture_output=True,
190
+ check=False,
191
+ )
192
+
193
+
194
+ def _image_matches_package(image: str, package: str) -> bool:
195
+ return image == package or image.startswith(package + ":") or image.startswith(package + "@")
196
+
197
+
198
+ def tear_down_workstation_package(package: str) -> dict[str, Any]:
199
+ package = _package(package)
200
+ docker = shutil.which("docker")
201
+ if not docker:
202
+ return {"state": "docker-unavailable", "mutated": False, "containers": [], "images": []}
203
+
204
+ listed = _run_local([docker, "ps", "-a", "--format", "{{.ID}}"])
205
+ if listed.returncode != 0:
206
+ raise DeploymentKitError(
207
+ "Local Docker container inventory failed: " + (listed.stderr or listed.stdout).strip()
208
+ )
209
+
210
+ containers: list[str] = []
211
+ for container_id in [line.strip() for line in listed.stdout.splitlines() if line.strip()]:
212
+ inspected = _run_local([docker, "inspect", "--format", "{{.Config.Image}}", container_id])
213
+ if inspected.returncode != 0:
214
+ raise DeploymentKitError(f"Local Docker inspect failed for container {container_id}.")
215
+ if _image_matches_package(inspected.stdout.strip(), package):
216
+ containers.append(container_id)
217
+
218
+ if containers:
219
+ removed = _run_local([docker, "rm", "-f", "-v", *containers])
220
+ if removed.returncode != 0:
221
+ raise DeploymentKitError(
222
+ "Local POC container removal failed: " + (removed.stderr or removed.stdout).strip()
223
+ )
224
+
225
+ images_result = _run_local([docker, "images", "--format", "{{.Repository}}:{{.Tag}}"])
226
+ if images_result.returncode != 0:
227
+ raise DeploymentKitError(
228
+ "Local Docker image inventory failed: " + (images_result.stderr or images_result.stdout).strip()
229
+ )
230
+ image_refs = sorted(
231
+ {
232
+ line.strip()
233
+ for line in images_result.stdout.splitlines()
234
+ if line.strip().startswith(package + ":") and not line.strip().endswith(":<none>")
235
+ }
236
+ )
237
+ if image_refs:
238
+ removed = _run_local([docker, "image", "rm", "-f", *image_refs])
239
+ if removed.returncode != 0:
240
+ raise DeploymentKitError(
241
+ "Local POC image removal failed: " + (removed.stderr or removed.stdout).strip()
242
+ )
243
+ return {
244
+ "state": "removed" if containers or image_refs else "absent",
245
+ "mutated": bool(containers or image_refs),
246
+ "containers": containers,
247
+ "images": image_refs,
248
+ }
249
+
250
+
251
+ def tear_down_poc(spec: dict[str, Any]) -> dict[str, Any]:
252
+ normalized = normalize_teardown_spec(
253
+ owner_repo=spec.get("ownerRepo"),
254
+ app_slug=spec.get("appSlug"),
255
+ scope=spec.get("scope"),
256
+ environment_targets=(spec.get("environmentTargets") or {}).items(),
257
+ local_package=spec.get("localPackage"),
258
+ production_environments=spec.get("productionEnvironments") or (),
259
+ )
260
+
261
+ environments: list[dict[str, Any]] = []
262
+ mutated = False
263
+ for environment, target in normalized["environmentTargets"].items():
264
+ route = _tear_down_route(target, normalized["ownerRepo"], normalized["appSlug"], environment)
265
+ runtime = _tear_down_remote_environment(
266
+ target,
267
+ normalized["ownerRepo"],
268
+ normalized["appSlug"],
269
+ environment,
270
+ )
271
+ mutated = mutated or route["mutated"] or runtime["mutated"]
272
+ environments.append(
273
+ {
274
+ "environment": environment,
275
+ "targetName": target,
276
+ "route": route,
277
+ "runtimeAndPersistentData": runtime,
278
+ }
279
+ )
280
+
281
+ workstation: dict[str, Any] = {"state": "not-selected", "mutated": False}
282
+ if normalized["scope"] in {"local", "all"} and normalized["localPackage"] is not None:
283
+ workstation = tear_down_workstation_package(normalized["localPackage"])
284
+ mutated = mutated or workstation["mutated"]
285
+
286
+ return {
287
+ "schemaVersion": 2,
288
+ "ownerRepo": normalized["ownerRepo"],
289
+ "appSlug": normalized["appSlug"],
290
+ "scope": normalized["scope"],
291
+ "environments": environments,
292
+ "workstation": workstation,
293
+ "productionEnvironments": normalized["productionEnvironments"],
294
+ "productionDataIncluded": bool(normalized["productionEnvironments"]),
295
+ "mutated": mutated,
296
+ }
297
+
298
+
299
+ def render_teardown_summary(result: dict[str, Any]) -> str:
300
+ return json.dumps(result, indent=2, sort_keys=True)
@@ -0,0 +1,139 @@
1
+ """CLI for scoped destructive POC tear-down."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from .errors import DeploymentKitError
9
+ from .teardown import (
10
+ PRODUCTION_DATA_DELETE_PHRASE,
11
+ TEAR_DOWN_WRITE_PHRASE,
12
+ normalize_teardown_spec,
13
+ parse_environment_targets,
14
+ render_teardown_summary,
15
+ tear_down_poc,
16
+ )
17
+
18
+
19
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
20
+ parser = argparse.ArgumentParser(
21
+ prog="project-forge-deploy tear-down",
22
+ description=(
23
+ "Remove one named Kit-managed environment, all explicitly bound environments, "
24
+ "and/or exact-package local Docker state. Remote tear-down deletes persistent "
25
+ "application data for selected environments."
26
+ ),
27
+ )
28
+ parser.add_argument(
29
+ "scope",
30
+ help="local, all, or one exact named environment such as demo, staging, qa, or production",
31
+ )
32
+ parser.add_argument("--owner-repo", required=True)
33
+ parser.add_argument("--app-slug", required=True)
34
+ parser.add_argument(
35
+ "--environment-target",
36
+ action="append",
37
+ default=[],
38
+ metavar="ENV=TARGET",
39
+ help="Explicit named environment-to-target binding; repeat as needed.",
40
+ )
41
+ parser.add_argument(
42
+ "--production-environment",
43
+ action="append",
44
+ default=[],
45
+ metavar="ENV",
46
+ help=(
47
+ "Classify an additional named environment as Production for the stronger data-deletion gate. "
48
+ "The literal 'production' environment is always Production."
49
+ ),
50
+ )
51
+ parser.add_argument(
52
+ "--local-package",
53
+ help=(
54
+ "Exact local Docker package identity, e.g. ghcr.io/owner/package. "
55
+ "Required for local; optional for all."
56
+ ),
57
+ )
58
+ parser.add_argument(
59
+ "--authorization",
60
+ help=f"Required destructive phrase: {TEAR_DOWN_WRITE_PHRASE}",
61
+ )
62
+ parser.add_argument(
63
+ "--production-data-authorization",
64
+ help=(
65
+ "Additional phrase required whenever selected environments include Production data: "
66
+ f"{PRODUCTION_DATA_DELETE_PHRASE}"
67
+ ),
68
+ )
69
+ return parser.parse_args(argv)
70
+
71
+
72
+ def _preview(spec: dict[str, object]) -> dict[str, object]:
73
+ production = bool(spec["productionEnvironments"])
74
+ return {
75
+ "schemaVersion": 2,
76
+ "ownerRepo": spec["ownerRepo"],
77
+ "appSlug": spec["appSlug"],
78
+ "scope": spec["scope"],
79
+ "environmentTargets": spec["environmentTargets"],
80
+ "localPackage": spec["localPackage"],
81
+ "productionEnvironments": spec["productionEnvironments"],
82
+ "destructive": True,
83
+ "deletesPersistentData": bool(spec["environmentTargets"]),
84
+ "deletesProductionData": production,
85
+ "preservesSharedPlatform": True,
86
+ "preservesUnrelatedPocs": True,
87
+ "deletesGhcrPackage": False,
88
+ "requiredAuthorization": TEAR_DOWN_WRITE_PHRASE,
89
+ "requiredProductionDataAuthorization": PRODUCTION_DATA_DELETE_PHRASE if production else None,
90
+ }
91
+
92
+
93
+ def main(argv: list[str] | None = None) -> int:
94
+ try:
95
+ args = parse_args(argv)
96
+ bindings = parse_environment_targets(args.environment_target)
97
+ spec = normalize_teardown_spec(
98
+ owner_repo=args.owner_repo,
99
+ app_slug=args.app_slug,
100
+ scope=args.scope,
101
+ environment_targets=bindings,
102
+ local_package=args.local_package,
103
+ production_environments=args.production_environment,
104
+ )
105
+ preview = _preview(spec)
106
+
107
+ missing_general = args.authorization != TEAR_DOWN_WRITE_PHRASE
108
+ needs_production = bool(spec["productionEnvironments"])
109
+ missing_production = (
110
+ needs_production
111
+ and args.production_data_authorization != PRODUCTION_DATA_DELETE_PHRASE
112
+ )
113
+ if missing_general or missing_production:
114
+ print("POC TEAR-DOWN PREVIEW")
115
+ print(render_teardown_summary(preview))
116
+ if missing_general:
117
+ print(
118
+ f"ERROR: destructive tear-down requires --authorization {TEAR_DOWN_WRITE_PHRASE!r}",
119
+ file=sys.stderr,
120
+ )
121
+ if missing_production:
122
+ print(
123
+ "ERROR: Production persistent-data deletion requires "
124
+ f"--production-data-authorization {PRODUCTION_DATA_DELETE_PHRASE!r}",
125
+ file=sys.stderr,
126
+ )
127
+ return 2
128
+
129
+ result = tear_down_poc(spec)
130
+ print("POC TEAR-DOWN COMPLETE")
131
+ print(render_teardown_summary(result))
132
+ return 0
133
+ except DeploymentKitError as exc:
134
+ print(f"ERROR: {exc}", file=sys.stderr)
135
+ return 2
136
+
137
+
138
+ if __name__ == "__main__":
139
+ raise SystemExit(main())
@@ -0,0 +1,100 @@
1
+ """Remote destructive tear-down for one Kit-managed POC environment."""
2
+
3
+ REMOTE_SCRIPT = r'''#!/usr/bin/env bash
4
+ set -Eeuo pipefail
5
+
6
+ APP_ROOT="/srv/project-forge-apps"
7
+ OWNER="$1"
8
+ APP="$2"
9
+ ENVIRONMENT="$3"
10
+
11
+ valid_slug() { [[ "$1" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; }
12
+ valid_repo() { [[ "$1" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; }
13
+ meta_value() {
14
+ local path="$1" key="$2"
15
+ [[ -f "$path" && ! -L "$path" ]] || return 1
16
+ sed -n "s/^${key}=//p" "$path" | head -n1
17
+ }
18
+ assert_owned_marker() {
19
+ local path="$1"
20
+ [[ -f "$path" && ! -L "$path" ]] || return 1
21
+ [[ "$(meta_value "$path" OWNER_REPO || true)" == "$OWNER" && \
22
+ "$(meta_value "$path" APP_SLUG || true)" == "$APP" && \
23
+ "$(meta_value "$path" ENVIRONMENT || true)" == "$ENVIRONMENT" ]]
24
+ }
25
+
26
+ valid_repo "$OWNER" || { echo "Invalid owner repository." >&2; exit 20; }
27
+ valid_slug "$APP" || { echo "Invalid application slug." >&2; exit 20; }
28
+ valid_slug "$ENVIRONMENT" || { echo "Invalid environment slug." >&2; exit 20; }
29
+
30
+ ROOT="$APP_ROOT/$APP/$ENVIRONMENT"
31
+ STATE="$ROOT/deployment.env"
32
+ PERSISTENCE="$ROOT/persistence.env"
33
+
34
+ if [[ ! -e "$ROOT" ]]; then
35
+ printf 'TEAR_DOWN\tabsent\t%s\t%s\n' "$APP" "$ENVIRONMENT"
36
+ exit 0
37
+ fi
38
+
39
+ [[ -d "$ROOT" && ! -L "$ROOT" ]] || {
40
+ echo "POC environment root is not a safe managed directory: $ROOT" >&2
41
+ exit 21
42
+ }
43
+
44
+ HAS_STATE=false
45
+ HAS_PERSISTENCE=false
46
+ [[ -f "$STATE" && ! -L "$STATE" ]] && HAS_STATE=true
47
+ [[ -f "$PERSISTENCE" && ! -L "$PERSISTENCE" ]] && HAS_PERSISTENCE=true
48
+ [[ "$HAS_STATE" == true || "$HAS_PERSISTENCE" == true ]] || {
49
+ echo "POC environment root has no managed ownership marker: $ROOT" >&2
50
+ exit 21
51
+ }
52
+
53
+ if [[ "$HAS_STATE" == true ]] && ! assert_owned_marker "$STATE"; then
54
+ echo "POC deployment ownership mismatch for $ROOT" >&2
55
+ exit 22
56
+ fi
57
+ if [[ "$HAS_PERSISTENCE" == true ]] && ! assert_owned_marker "$PERSISTENCE"; then
58
+ echo "POC persistence ownership mismatch for $ROOT" >&2
59
+ exit 22
60
+ fi
61
+
62
+ CONTAINER=""
63
+ if [[ "$HAS_STATE" == true ]]; then
64
+ CURRENT_RELEASE="$(meta_value "$STATE" CURRENT_RELEASE || true)"
65
+ [[ -n "$CURRENT_RELEASE" && "$CURRENT_RELEASE" != */* && "$CURRENT_RELEASE" != *..* ]] || {
66
+ echo "Managed deployment state has an invalid current release." >&2
67
+ exit 23
68
+ }
69
+
70
+ RELEASE_FILE="$ROOT/releases/$CURRENT_RELEASE/release.env"
71
+ [[ -f "$RELEASE_FILE" && ! -L "$RELEASE_FILE" ]] || {
72
+ echo "Managed current release metadata is missing: $RELEASE_FILE" >&2
73
+ exit 23
74
+ }
75
+ assert_owned_marker "$RELEASE_FILE" || {
76
+ echo "Managed release ownership does not match deployment state." >&2
77
+ exit 23
78
+ }
79
+ CONTAINER="$(meta_value "$RELEASE_FILE" CONTAINER_NAME || true)"
80
+ [[ "$CONTAINER" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ ]] || {
81
+ echo "Managed release contains an invalid container name." >&2
82
+ exit 23
83
+ }
84
+
85
+ if docker inspect "$CONTAINER" >/dev/null 2>&1; then
86
+ docker rm -f "$CONTAINER" >/dev/null
87
+ fi
88
+ fi
89
+
90
+ # The verified environment root is owned by this POC/environment. Tear-down
91
+ # removes releases, evidence, persistent data, and backups together.
92
+ rm -rf --one-file-system -- "$ROOT"
93
+
94
+ APP_DIR="$APP_ROOT/$APP"
95
+ if [[ -d "$APP_DIR" ]] && ! find "$APP_DIR" -mindepth 1 -print -quit | grep -q .; then
96
+ rmdir "$APP_DIR"
97
+ fi
98
+
99
+ printf 'TEAR_DOWN\tremoved\t%s\t%s\t%s\n' "$APP" "$ENVIRONMENT" "$CONTAINER"
100
+ '''