@mriekki/project-forge 4.9.0 → 4.10.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.9.0",
4
+ "version": "4.10.0",
5
5
  "defaultProfile": "greenfield-project",
6
6
  "profiles": {
7
7
  "greenfield-project": {
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "components": {
15
15
  "core": {
16
- "revision": "a78c2482f4f559ead8efbab6c25b4b05496db735",
16
+ "revision": "8796fd8dc22df8b64078e5b460e1008a8c7a5f7d",
17
17
  "version": "4.0.0",
18
18
  "distribution": "greenfield-workspace",
19
19
  "descriptor": "assets/core/distribution.json",
@@ -21,7 +21,7 @@
21
21
  "canonicalDescriptor": "distributions/greenfield-workspace.json"
22
22
  },
23
23
  "deployment": {
24
- "revision": "fd2cf23fa9e3d4fa27e142ce3a963f51b39cba36",
24
+ "revision": "23799aec3e7c9a99873aaf912b040d43658fa4fb",
25
25
  "distribution": "greenfield-project",
26
26
  "descriptor": "assets/deployment/distribution.json",
27
27
  "sourceRoot": "assets/generated/deployment",
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.9.0",
4
+ "version": "4.10.0",
5
5
  "components": {
6
6
  "core": {
7
- "revision": "a78c2482f4f559ead8efbab6c25b4b05496db735",
7
+ "revision": "8796fd8dc22df8b64078e5b460e1008a8c7a5f7d",
8
8
  "version": "4.0.0",
9
9
  "distribution": "greenfield-workspace",
10
10
  "files": [
@@ -126,7 +126,7 @@
126
126
  {
127
127
  "source": "core/skills/tear-down/SKILL.md",
128
128
  "destination": ".github/skills/tear-down/SKILL.md",
129
- "sha256": "7565b5600af2883abfc47bac29abfd056cfa81536c52408b07da82f696b335b8"
129
+ "sha256": "468314030c6033885c46aba50f54410928cbe44c74d73b998b88338fe495f3d9"
130
130
  },
131
131
  {
132
132
  "source": "core/skills/coach/SKILL.md",
@@ -326,7 +326,7 @@
326
326
  ]
327
327
  },
328
328
  "deployment": {
329
- "revision": "fd2cf23fa9e3d4fa27e142ce3a963f51b39cba36",
329
+ "revision": "23799aec3e7c9a99873aaf912b040d43658fa4fb",
330
330
  "distribution": "greenfield-project",
331
331
  "files": [
332
332
  {
@@ -407,7 +407,7 @@
407
407
  {
408
408
  "source": "src/project_forge_deployment/cloudflare_ingress_remote.py",
409
409
  "destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_ingress_remote.py",
410
- "sha256": "38688e85654090dc7f4ab634eb3b5b6aedce9f3edb695f3d62bb09789214c86e"
410
+ "sha256": "35ac39b4f4538d4ff23f68982cc4b176782378cf03539fa51a228becbdfcf4d0"
411
411
  },
412
412
  {
413
413
  "source": "src/project_forge_deployment/cli.py",
@@ -422,7 +422,7 @@
422
422
  {
423
423
  "source": "src/project_forge_deployment/entrypoint.py",
424
424
  "destination": "tools/project-forge-deployment/project_forge_deployment/entrypoint.py",
425
- "sha256": "90d241bbbfb592c0b8e6368e13d3b3b8fb6c1e3f34c5fac9fe593df4e4a5cf1d"
425
+ "sha256": "021434f8f601ebbff0aae354e6c6b25b36d598a521a7948d02d5f1cc797c22fd"
426
426
  },
427
427
  {
428
428
  "source": "src/project_forge_deployment/environment_cli.py",
@@ -462,7 +462,7 @@
462
462
  {
463
463
  "source": "src/project_forge_deployment/ingress_cli.py",
464
464
  "destination": "tools/project-forge-deployment/project_forge_deployment/ingress_cli.py",
465
- "sha256": "c70b312aacceb9e6761eafa07a534fc41ce728b2b9aef130568b399367b2640c"
465
+ "sha256": "0e4088a17734f560c750528942fbd34d2464bcdd06d60467a9dc684a73fc18e1"
466
466
  },
467
467
  {
468
468
  "source": "src/project_forge_deployment/keys.py",
@@ -572,17 +572,17 @@
572
572
  {
573
573
  "source": "src/project_forge_deployment/teardown.py",
574
574
  "destination": "tools/project-forge-deployment/project_forge_deployment/teardown.py",
575
- "sha256": "08215b8b5fa87ca8abf96f10f38aca0207840bd1b1faf4c2c53cb691caf20a72"
575
+ "sha256": "0c75bb1ec77218d1b6ee3f54a626556ee46f2de8e91d1e9afa87200bce39254e"
576
576
  },
577
577
  {
578
578
  "source": "src/project_forge_deployment/teardown_cli.py",
579
579
  "destination": "tools/project-forge-deployment/project_forge_deployment/teardown_cli.py",
580
- "sha256": "214987895cde77648b5274042f1c21557046c3bf76224bfdd415ea34fec3d873"
580
+ "sha256": "5dbe92b1d5683824bbf3b4892f9099db2d68ad95c0a514bf73f7b596a62775be"
581
581
  },
582
582
  {
583
583
  "source": "src/project_forge_deployment/teardown_remote.py",
584
584
  "destination": "tools/project-forge-deployment/project_forge_deployment/teardown_remote.py",
585
- "sha256": "ad764f5a67c43f81b4973dd5639b2c28b23ae883a85e5ae29efc14dad506e88e"
585
+ "sha256": "5a898ee8ce981c60839d12c63a776bf742e2c32bc409fbb29cdfd798cf9baa19"
586
586
  },
587
587
  {
588
588
  "source": "src/project_forge_deployment/topology_cli.py",
@@ -1,27 +1,52 @@
1
1
  ---
2
2
  name: tear-down
3
- description: "Guided destructive cleanup for local state, one exact repository-defined named environment, or all explicitly configured Project environments. Preserves ownership boundaries and resolved Production-data authorization."
4
- argument-hint: "[local|<environment>|all]"
3
+ description: "List target-owned Project environments and guide destructive cleanup for local state, one exact named environment, or all explicitly configured Project environments. Preserves ownership boundaries, shared Project Forge infrastructure, and separate Production/public-ingress authorization."
4
+ argument-hint: "[list [<target>]|local|<environment>|all]"
5
5
  user-invocable: true
6
6
  disable-model-invocation: true
7
7
  ---
8
8
 
9
9
  # Tear Down
10
10
 
11
- `/tear-down` is the human-facing destructive cleanup workflow for repository-owned Project state.
11
+ `/tear-down` is the human-facing inventory and destructive-cleanup workflow for Project-owned deployment state.
12
12
 
13
13
  Logical environment names are data-driven and are not limited to Staging/Production:
14
14
 
15
15
  ```text
16
+ /tear-down list production
16
17
  /tear-down demo
17
18
  /tear-down qa
18
19
  /tear-down staging
19
20
  /tear-down prod-east
20
21
  ```
21
22
 
22
- The skill owns exact scope selection, read-only preflight, destructive preview, and Human-in-the-Loop authorization. Repository-provided deterministic tooling owns deletion mechanics.
23
+ The skill owns target selection, exact scope selection, read-only inventory/preflight, destructive preview, and Human-in-the-Loop authorization. Deployment Kit deterministic tooling owns inventory and deletion mechanics.
23
24
 
24
- ## 1. Resolve exact project topology
25
+ ## 0. List what is installed before destructive selection
26
+
27
+ When the Builder asks what is deployed, what can be removed, or invokes `list`, use the Deployment Kit read-only target inventory:
28
+
29
+ ```text
30
+ project-forge-deploy target inventory <target> --json
31
+ ```
32
+
33
+ This is the canonical application-cleanup inventory. It correlates the target's managed runtime, trusted-LAN route, and managed public-ingress ownership into application/environment tear-down candidates while reporting the shared host platform separately.
34
+
35
+ The command is intentionally independent of the consumer repository. If a consumer checkout or even its GitHub repository has already been deleted, the same installed/standalone Deployment Kit command may still inventory the target from host-owned metadata and user-scoped target access configuration.
36
+
37
+ Inside a consumer repository, use the repository-provided Deployment Kit launcher when that is the installed contract. Detect the current platform/runtime and invoke the portable Python entrypoint or installed `project-forge-deploy`; do not assume PowerShell syntax or Windows-only paths. macOS/Linux shells and Windows/PowerShell must reach the same deterministic CLI semantics.
38
+
39
+ Do not reconstruct inventory by ad-hoc `ssh`, `docker ps`, filesystem listing, or Cloudflare commands when Deployment Kit target inventory is available.
40
+
41
+ If a public-ingress scope cannot be inferred from the application/environment name, pass only the exact additional scope established by repository topology or trusted operator evidence:
42
+
43
+ ```text
44
+ --ingress-scope <scope>
45
+ ```
46
+
47
+ Listing is read-only. It does not authorize later deletion.
48
+
49
+ ## 1. Resolve exact project topology when the repository exists
25
50
 
26
51
  Prefer:
27
52
 
@@ -37,8 +62,12 @@ If `demo` is configured, `/tear-down demo` means exactly `demo`. If `prod-east`
37
62
 
38
63
  Never guess a similar environment and never widen scope after failure.
39
64
 
65
+ When the repository no longer exists, use target inventory only for target-owned application identity and resource ownership. Do not invent repository-only topology facts. In particular, if a non-literal custom environment's Production classification cannot be established from trusted evidence, stop before destructive authorization rather than weakening the Production-data gate.
66
+
40
67
  ## 2. Select exact destructive scope
41
68
 
69
+ The destructive scope contract remains exactly `[local|<environment>|all]`; `list` is a separate read-only discovery mode and never becomes a destructive scope.
70
+
42
71
  ```text
43
72
  local
44
73
  <one exact repository-defined named environment>
@@ -47,13 +76,15 @@ all
47
76
 
48
77
  If no scope is named, present only scopes the repository actually supports; never silently default to a Production environment or `all`.
49
78
 
79
+ For orphaned deployments where the consumer repository is unavailable, present only exact application/environment candidates returned by target inventory.
80
+
50
81
  `all` means only all explicitly configured scopes for this Project; it never means machine-wide cleanup.
51
82
 
52
83
  Selecting scope does not authorize deletion. Physical colocation never merges ownership: deleting `demo` must not remove `staging` or `prod-east` merely because they share hardware.
53
84
 
54
- If the requested name is absent from project topology, stop. Do not substitute another environment.
85
+ If the requested name is absent from project topology or target inventory, stop. Do not substitute another environment.
55
86
 
56
- ## 3. Resolve Production classification from the deletion set
87
+ ## 3. Resolve Production and public-ingress classification from the deletion set
57
88
 
58
89
  For each selected environment identify:
59
90
 
@@ -62,6 +93,8 @@ environment name
62
93
  purpose / Production classification
63
94
  placement / target
64
95
  repository/application identity
96
+ trusted-LAN route
97
+ managed public-ingress scope/hostname, when present
65
98
  ownership markers required by installed tooling
66
99
  ```
67
100
 
@@ -71,7 +104,13 @@ For current Deployment Kit consumers, custom Production names may be passed expl
71
104
  --production-environment prod-east
72
105
  ```
73
106
 
74
- Use actual installed help/version evidence. If the installed tool cannot preserve a custom Production classification, stop rather than downgrading the destructive gate.
107
+ When target inventory reports an owned public route, preserve its exact environment-to-ingress-scope binding for cleanup, conceptually:
108
+
109
+ ```text
110
+ --public-ingress-scope production=production
111
+ ```
112
+
113
+ Use actual installed help/version evidence. If the installed tool cannot preserve a custom Production classification or exact public-ingress ownership, stop rather than downgrading the destructive gate.
75
114
 
76
115
  ## 4. Present an exact destructive preview
77
116
 
@@ -86,10 +125,17 @@ Selected environments:
86
125
  <name> → <placement/target> — <purpose/classification>
87
126
 
88
127
  Will remove:
89
- <only tool-owned resources for exact selected scopes>
128
+ owned public hostname/DNS + tunnel rule, when selected
129
+ owned trusted-LAN route
130
+ owned application container/runtime
131
+ owned releases, evidence, persistent data, and backups
132
+ remote immutable image only when no other managed release/container still references it
90
133
 
91
134
  Will preserve:
92
- <shared/unrelated environment and platform resources>
135
+ shared Project Forge Caddy/network/platform
136
+ shared Cloudflare tunnel/runtime
137
+ unrelated Projects/environments
138
+ remote images still referenced by another managed release/container
93
139
  ```
94
140
 
95
141
  Do not promise deletion of named/shared Docker volumes unless the installed deterministic contract proves exact ownership and explicitly includes them. Do not promise deletion of any shared/unrelated resource whose ownership cannot be proven.
@@ -108,33 +154,45 @@ whenever **any selected environment is Production-class**, require the stronger
108
154
  [PRODUCTION DATA DELETE]
109
155
  ```
110
156
 
157
+ Whenever the reviewed deletion set includes an owned public-ingress route, also require the separate public-ingress removal authorization, conceptually:
158
+
159
+ ```text
160
+ [PUBLIC INGRESS ROUTE REMOVE]
161
+ ```
162
+
111
163
  Resolve exact phrases from the installed tool/version. Do not tie the Production-data gate only to literal scope strings such as `production` or `all`; it depends on the actual resolved deletion set.
112
164
 
113
- A prior `/setup`, environment proof, `/deploy`, merge, CI, or other approval never authorizes destructive cleanup.
165
+ A prior `/setup`, environment proof, `/deploy`, merge, CI, public-route registration, or other approval never authorizes destructive cleanup.
114
166
 
115
- If identity, selected environment set, classification, binding, ownership evidence, or deletion set changes after preview, require renewed review/authorization.
167
+ If identity, selected environment set, classification, binding, ownership evidence, public-ingress scope, or deletion set changes after preview, require renewed review/authorization.
116
168
 
117
- ## 6. Execute only repository-provided deterministic cleanup
169
+ ## 6. Execute only Deployment Kit deterministic cleanup
118
170
 
119
171
  After authorization, invoke only the approved tear-down command with exact named scopes/bindings. Do not improvise shell commands.
120
172
 
121
173
  For a generalized current Deployment Kit invocation, exact bindings may look conceptually like:
122
174
 
123
175
  ```text
124
- --environment-target demo=<target>
125
176
  --environment-target staging=<target>
126
- --environment-target prod-east=<target>
177
+ --environment-target production=<target>
178
+ --public-ingress-scope production=production
127
179
  --production-environment prod-east
128
180
  ```
129
181
 
130
- Use the installed contract rather than inventing flags for older versions.
182
+ Use target inventory output and the installed contract rather than inventing flags for older versions.
131
183
 
132
- Never add machine-wide Docker prune, shared platform/network removal, unrelated environment cleanup, host reprovisioning, target rebinding, credential/trust deletion, or registry-package deletion unless a separate lifecycle operation explicitly owns and authorizes it.
184
+ Public ingress must be removed before the trusted-LAN route/runtime so a public hostname is never deliberately left pointing at a removed origin. The shared Cloudflare tunnel/runtime is host infrastructure and must remain.
185
+
186
+ Remote image cleanup is bounded: remove only the selected environment's immutable image when no other managed release metadata or container still references it. Never use Docker system/image/volume prune.
187
+
188
+ Never add machine-wide Docker prune, shared platform/network removal, unrelated environment cleanup, host reprovisioning, target rebinding, credential/trust deletion, registry-package deletion, or shared Cloudflare tunnel deletion unless a separate lifecycle operation explicitly owns and authorizes it.
133
189
 
134
190
  If aggregate `all` cleanup stops partway, report completed and remaining scopes. Do not manually compensate by deleting the rest.
135
191
 
136
192
  ## 7. Verify and report outcome
137
193
 
138
- Use repository-provided status/evidence commands when available. Report only observed evidence: requested scope, exact environment names/targets processed, Production-class environments included, local state processed, removed versus already-absent resources, and any remaining scopes after a partial stop.
194
+ Re-run target inventory after cleanup when available. Report only observed evidence: requested scope, exact environment names/targets processed, Production-class environments included, public routes removed/already absent, trusted-LAN routes removed/already absent, runtime/data removal, remote image removed/retained, local state processed, and any remaining scopes after a partial stop.
195
+
196
+ Shared Project Forge platform state and unrelated application candidates should remain present and unchanged.
139
197
 
140
198
  When the installed deterministic contract is idempotent, already-absent owned resources should remain safe to re-check/re-run without broadening scope.
@@ -236,6 +236,7 @@ route_inventory() {
236
236
  printf 'SUMMARY\tscope\t%s\n' "$SCOPE"
237
237
  printf 'SUMMARY\tmanagedRouteCount\t%s\n' "$count"
238
238
  for path in "$ROUTE_ROOT"/*.route; do [[ -f "$path" ]] && emit_route "$path"; done
239
+ return 0
239
240
  }
240
241
 
241
242
  metadata_matches() {
@@ -12,12 +12,15 @@ from .ingress_cli import main as ingress_main
12
12
  from .local_cli import main as local_main
13
13
  from .registry_cli import main as registry_main
14
14
  from .sqlite_cli import main as sqlite_main
15
+ from .teardown_cli import inventory_main as target_inventory_main
15
16
  from .teardown_cli import main as teardown_main
16
17
  from .topology_cli import main as topology_main
17
18
 
18
19
 
19
20
  def main(argv: list[str] | None = None) -> int:
20
21
  args = list(sys.argv[1:] if argv is None else argv)
22
+ if len(args) >= 2 and args[0] == "target" and args[1] == "inventory":
23
+ return target_inventory_main(args[2:])
21
24
  if args and args[0] == "access":
22
25
  return access_main(args[1:])
23
26
  if args and args[0] == "app":
@@ -36,6 +39,8 @@ def main(argv: list[str] | None = None) -> int:
36
39
  return teardown_main(args[1:])
37
40
  if args and args[0] == "topology":
38
41
  return topology_main(args[1:])
42
+ if args[:2] in (["target", "--help"], ["target", "-h"]):
43
+ print("Additional command: inventory Read app-owned tear-down candidates without writes")
39
44
  if not args or args == ["--help"] or args == ["-h"]:
40
45
  print("Command group: access Read-only strict SSH target-access diagnosis")
41
46
  print("Command group: app Remote immutable application runtime inventory/deployment")
@@ -46,4 +51,5 @@ def main(argv: list[str] | None = None) -> int:
46
51
  print("Command group: sqlite SQLite persistence import/backup/migration/rollback checks")
47
52
  print("Command group: tear-down Scoped destructive Project environment/local cleanup")
48
53
  print("Command group: topology Project-owned logical environment binding")
54
+ print("Target command: inventory Read app-owned tear-down candidates without writes")
49
55
  return core_main(args)
@@ -14,7 +14,6 @@ from .cloudflare_ingress import (
14
14
  PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE,
15
15
  PUBLIC_INGRESS_ROUTE_WRITE_PHRASE,
16
16
  apply_cloudflare_ingress,
17
- apply_public_route,
18
17
  apply_public_route_removal,
19
18
  cloudflare_ingress_doctor,
20
19
  cloudflare_ingress_status,
@@ -23,6 +22,7 @@ from .cloudflare_ingress import (
23
22
  plan_public_route,
24
23
  plan_public_route_removal,
25
24
  )
25
+ from .cloudflare_route_apply import apply_public_route
26
26
  from .errors import DeploymentKitError
27
27
 
28
28
 
@@ -3,12 +3,23 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import os
6
7
  import re
7
8
  import shlex
8
9
  import shutil
9
10
  import subprocess
10
11
  from typing import Any, Iterable
11
12
 
13
+ from .app_runtime import app_inventory
14
+ from .box_platform import platform_status
15
+ from .cloudflare_ingress import (
16
+ CLOUDFLARE_API_TOKEN_ENV,
17
+ PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE,
18
+ apply_public_route_removal,
19
+ cloudflare_ingress_status,
20
+ cloudflare_route_inventory,
21
+ plan_public_route_removal,
22
+ )
12
23
  from .errors import DeploymentKitError
13
24
  from .profiles import load_profile
14
25
  from .route_registry import apply_route_change, plan_route_removal, route_inventory
@@ -60,6 +71,24 @@ def parse_environment_targets(items: Iterable[str]) -> dict[str, str]:
60
71
  return dict(sorted(result.items()))
61
72
 
62
73
 
74
+ def parse_environment_scopes(items: Iterable[str]) -> dict[str, str]:
75
+ result: dict[str, str] = {}
76
+ for item in items:
77
+ if not isinstance(item, str) or "=" not in item:
78
+ raise DeploymentKitError("public-ingress scope bindings must use environment=scope form.")
79
+ environment, scope = item.split("=", 1)
80
+ environment = _slug(environment, "environment")
81
+ if environment in RESERVED_SCOPES:
82
+ raise DeploymentKitError(f"{environment!r} is reserved and cannot be a remote environment name.")
83
+ scope = _slug(scope, "public ingress scope")
84
+ if scope in RESERVED_SCOPES:
85
+ raise DeploymentKitError(f"{scope!r} is reserved and cannot be a public ingress scope.")
86
+ if environment in result:
87
+ raise DeploymentKitError(f"Duplicate public-ingress scope binding: {environment}")
88
+ result[environment] = scope
89
+ return dict(sorted(result.items()))
90
+
91
+
63
92
  def _normalize_production_environments(items: Iterable[str]) -> set[str]:
64
93
  result = {"production"}
65
94
  for item in items:
@@ -78,6 +107,7 @@ def normalize_teardown_spec(
78
107
  environment_targets: dict[str, str] | Iterable[tuple[str, str]],
79
108
  local_package: str | None,
80
109
  production_environments: Iterable[str] = (),
110
+ public_ingress_scopes: dict[str, str] | Iterable[tuple[str, str]] = (),
81
111
  ) -> dict[str, Any]:
82
112
  scope = _slug(scope, "scope")
83
113
  pairs = environment_targets.items() if isinstance(environment_targets, dict) else environment_targets
@@ -92,6 +122,23 @@ def normalize_teardown_spec(
92
122
  raise DeploymentKitError(f"Duplicate environment target binding: {environment}")
93
123
  normalized_targets[environment] = target
94
124
 
125
+ ingress_pairs = (
126
+ public_ingress_scopes.items()
127
+ if isinstance(public_ingress_scopes, dict)
128
+ else public_ingress_scopes
129
+ )
130
+ normalized_ingress: dict[str, str] = {}
131
+ for environment, ingress_scope in ingress_pairs:
132
+ environment = _slug(environment, "environment")
133
+ ingress_scope = _slug(ingress_scope, "public ingress scope")
134
+ if environment not in normalized_targets:
135
+ raise DeploymentKitError(
136
+ f"public ingress scope binding {environment!r} has no matching environment target binding."
137
+ )
138
+ if environment in normalized_ingress:
139
+ raise DeploymentKitError(f"Duplicate public-ingress scope binding: {environment}")
140
+ normalized_ingress[environment] = ingress_scope
141
+
95
142
  if scope == "local":
96
143
  selected_targets: dict[str, str] = {}
97
144
  elif scope == "all":
@@ -107,15 +154,21 @@ def normalize_teardown_spec(
107
154
 
108
155
  production_set = _normalize_production_environments(production_environments)
109
156
  selected_production = sorted(set(selected_targets).intersection(production_set))
157
+ selected_ingress = {
158
+ environment: normalized_ingress[environment]
159
+ for environment in selected_targets
160
+ if environment in normalized_ingress
161
+ }
110
162
 
111
163
  return {
112
- "schemaVersion": 2,
164
+ "schemaVersion": 3,
113
165
  "ownerRepo": _owner_repo(owner_repo),
114
166
  "appSlug": _slug(app_slug, "appSlug"),
115
167
  "scope": scope,
116
168
  "environmentTargets": selected_targets,
117
169
  "localPackage": package,
118
170
  "productionEnvironments": selected_production,
171
+ "publicIngressScopes": selected_ingress,
119
172
  }
120
173
 
121
174
 
@@ -124,6 +177,178 @@ def _find_route(inventory: dict[str, Any], app_slug: str, environment: str) -> d
124
177
  return next((item for item in inventory["managedRoutes"] if item["routeId"] == route_id), None)
125
178
 
126
179
 
180
+ def _ensure_candidate(
181
+ candidates: dict[str, dict[str, Any]],
182
+ *,
183
+ owner_repo: str,
184
+ app_slug: str,
185
+ environment: str,
186
+ target_name: str,
187
+ ) -> dict[str, Any]:
188
+ route_id = f"{app_slug}--{environment}"
189
+ candidate = candidates.get(route_id)
190
+ if candidate is None:
191
+ candidate = {
192
+ "routeId": route_id,
193
+ "ownerRepo": owner_repo,
194
+ "appSlug": app_slug,
195
+ "environment": environment,
196
+ "targetName": target_name,
197
+ "runtime": None,
198
+ "trustedLanRoute": None,
199
+ "publicIngress": [],
200
+ }
201
+ candidates[route_id] = candidate
202
+ return candidate
203
+ if candidate["ownerRepo"] != owner_repo:
204
+ raise DeploymentKitError(
205
+ f"Target inventory ownership mismatch for {route_id}: "
206
+ f"{candidate['ownerRepo']} versus {owner_repo}."
207
+ )
208
+ return candidate
209
+
210
+
211
+ def target_inventory(name: str, *, ingress_scopes: Iterable[str] = ()) -> dict[str, Any]:
212
+ """Read one target's shared platform and application-owned tear-down candidates."""
213
+ platform = platform_status(name)
214
+ apps_result = app_inventory(name)
215
+ lan_result = route_inventory(name)
216
+
217
+ candidates: dict[str, dict[str, Any]] = {}
218
+ inferred_scopes: set[str] = set()
219
+
220
+ for app in apps_result["applications"]:
221
+ candidate = _ensure_candidate(
222
+ candidates,
223
+ owner_repo=app["ownerRepo"],
224
+ app_slug=app["appSlug"],
225
+ environment=app["environment"],
226
+ target_name=name,
227
+ )
228
+ candidate["runtime"] = app
229
+ inferred_scopes.add(app["environment"])
230
+
231
+ for route in lan_result["managedRoutes"]:
232
+ candidate = _ensure_candidate(
233
+ candidates,
234
+ owner_repo=route["ownerRepo"],
235
+ app_slug=route["appSlug"],
236
+ environment=route["environment"],
237
+ target_name=name,
238
+ )
239
+ candidate["trustedLanRoute"] = route
240
+ inferred_scopes.add(route["environment"])
241
+
242
+ if isinstance(name, str) and _SLUG_RE.fullmatch(name):
243
+ inferred_scopes.add(name)
244
+ requested_scopes = {_slug(item, "public ingress scope") for item in ingress_scopes}
245
+ scopes = sorted(inferred_scopes | requested_scopes)
246
+
247
+ public_scopes: list[dict[str, Any]] = []
248
+ for scope in scopes:
249
+ status = cloudflare_ingress_status(name, scope)
250
+ routes: list[dict[str, Any]] = []
251
+ if status["markerPresent"]:
252
+ routes = cloudflare_route_inventory(name, scope)["managedRoutes"]
253
+ for route in routes:
254
+ candidate = _ensure_candidate(
255
+ candidates,
256
+ owner_repo=route["ownerRepo"],
257
+ app_slug=route["appSlug"],
258
+ environment=route["environment"],
259
+ target_name=name,
260
+ )
261
+ candidate["publicIngress"].append({"scope": scope, **route})
262
+ public_scopes.append(
263
+ {
264
+ "scope": scope,
265
+ "state": status["state"],
266
+ "markerPresent": status["markerPresent"],
267
+ "managedRoutes": routes,
268
+ }
269
+ )
270
+
271
+ applications: list[dict[str, Any]] = []
272
+ for candidate in sorted(
273
+ candidates.values(),
274
+ key=lambda item: (item["appSlug"], item["environment"], item["ownerRepo"]),
275
+ ):
276
+ public_bindings = sorted(
277
+ {item["scope"] for item in candidate["publicIngress"]}
278
+ )
279
+ literal_production = candidate["environment"] == "production"
280
+ candidate["tearDown"] = {
281
+ "available": True,
282
+ "scope": candidate["environment"],
283
+ "environmentTarget": f"{candidate['environment']}={name}",
284
+ "publicIngressScopes": [
285
+ f"{candidate['environment']}={scope}" for scope in public_bindings
286
+ ],
287
+ "productionClassification": (
288
+ "production" if literal_production else "requires-trusted-evidence"
289
+ ),
290
+ "productionClassificationSource": (
291
+ "literal-environment-name" if literal_production else "not-encoded-in-target-inventory"
292
+ ),
293
+ "requiresProductionDataAuthorization": True if literal_production else None,
294
+ "requiresPublicIngressAuthorization": bool(public_bindings),
295
+ }
296
+ applications.append(candidate)
297
+
298
+ return {
299
+ "schemaVersion": 1,
300
+ "targetName": name,
301
+ "verifiedAddress": apps_result.get("verifiedAddress") or lan_result.get("verifiedAddress"),
302
+ "sharedPlatform": platform,
303
+ "applications": applications,
304
+ "publicIngressScopes": public_scopes,
305
+ "mutated": False,
306
+ }
307
+
308
+
309
+ def _tear_down_public_ingress(
310
+ target: str,
311
+ scope: str,
312
+ owner_repo: str,
313
+ app_slug: str,
314
+ environment: str,
315
+ ) -> dict[str, Any]:
316
+ inventory = cloudflare_route_inventory(target, scope)
317
+ route = _find_route(inventory, app_slug, environment)
318
+ if route is None:
319
+ return {"state": "absent", "mutated": False, "scope": scope}
320
+ if (
321
+ route["ownerRepo"] != owner_repo
322
+ or route["appSlug"] != app_slug
323
+ or route["environment"] != environment
324
+ ):
325
+ raise DeploymentKitError(
326
+ f"Public route {app_slug}--{environment} ownership does not match the requested Project."
327
+ )
328
+ token = os.environ.get(CLOUDFLARE_API_TOKEN_ENV, "").strip()
329
+ if not token:
330
+ raise DeploymentKitError(
331
+ f"Cloudflare API authorization is required to remove owned public ingress. "
332
+ f"Set {CLOUDFLARE_API_TOKEN_ENV} in the current workstation process/secret boundary; "
333
+ "never put the token in the Project repository or command line."
334
+ )
335
+ plan = plan_public_route_removal(
336
+ target,
337
+ scope=scope,
338
+ owner_repo=owner_repo,
339
+ environment=environment,
340
+ app_slug=app_slug,
341
+ api_token=token,
342
+ )
343
+ result = apply_public_route_removal(plan, api_token=token)
344
+ return {
345
+ "state": "removed",
346
+ "mutated": bool(result.get("mutated")),
347
+ "scope": scope,
348
+ "publicHostname": route["publicHostname"],
349
+ }
350
+
351
+
127
352
  def _tear_down_route(target: str, owner_repo: str, app_slug: str, environment: str) -> dict[str, Any]:
128
353
  inventory = route_inventory(target)
129
354
  route = _find_route(inventory, app_slug, environment)
@@ -170,13 +395,18 @@ def _tear_down_remote_environment(
170
395
  "Remote Project tear-down failed: " + (stderr or stdout or f"exit {completed.returncode}")
171
396
  )
172
397
  parts = stdout.split("\t")
173
- if len(parts) not in {4, 5} or parts[0] != "TEAR_DOWN" or parts[1] not in {"absent", "removed"}:
398
+ if len(parts) not in {4, 5, 7} or parts[0] != "TEAR_DOWN" or parts[1] not in {"absent", "removed"}:
174
399
  raise DeploymentKitError("Remote Project tear-down returned malformed evidence.")
175
400
  if parts[2] != app_slug or parts[3] != environment:
176
401
  raise DeploymentKitError("Remote Project tear-down returned mismatched app/environment evidence.")
177
402
  result: dict[str, Any] = {"state": parts[1], "mutated": parts[1] == "removed"}
178
- if len(parts) == 5:
403
+ if len(parts) >= 5:
179
404
  result["containerName"] = parts[4]
405
+ if len(parts) == 7:
406
+ if parts[6] not in {"true", "false"}:
407
+ raise DeploymentKitError("Remote Project tear-down returned malformed image-removal evidence.")
408
+ result["images"] = [item for item in parts[5].split(",") if item]
409
+ result["allImagesRemoved"] = parts[6] == "true"
180
410
  return result
181
411
 
182
412
 
@@ -256,11 +486,21 @@ def tear_down_project(spec: dict[str, Any]) -> dict[str, Any]:
256
486
  environment_targets=(spec.get("environmentTargets") or {}).items(),
257
487
  local_package=spec.get("localPackage"),
258
488
  production_environments=spec.get("productionEnvironments") or (),
489
+ public_ingress_scopes=(spec.get("publicIngressScopes") or {}).items(),
259
490
  )
260
491
 
261
492
  environments: list[dict[str, Any]] = []
262
493
  mutated = False
263
494
  for environment, target in normalized["environmentTargets"].items():
495
+ public_ingress: dict[str, Any] = {"state": "not-selected", "mutated": False}
496
+ if environment in normalized["publicIngressScopes"]:
497
+ public_ingress = _tear_down_public_ingress(
498
+ target,
499
+ normalized["publicIngressScopes"][environment],
500
+ normalized["ownerRepo"],
501
+ normalized["appSlug"],
502
+ environment,
503
+ )
264
504
  route = _tear_down_route(target, normalized["ownerRepo"], normalized["appSlug"], environment)
265
505
  runtime = _tear_down_remote_environment(
266
506
  target,
@@ -268,11 +508,17 @@ def tear_down_project(spec: dict[str, Any]) -> dict[str, Any]:
268
508
  normalized["appSlug"],
269
509
  environment,
270
510
  )
271
- mutated = mutated or route["mutated"] or runtime["mutated"]
511
+ mutated = (
512
+ mutated
513
+ or public_ingress["mutated"]
514
+ or route["mutated"]
515
+ or runtime["mutated"]
516
+ )
272
517
  environments.append(
273
518
  {
274
519
  "environment": environment,
275
520
  "targetName": target,
521
+ "publicIngress": public_ingress,
276
522
  "route": route,
277
523
  "runtimeAndPersistentData": runtime,
278
524
  }
@@ -284,7 +530,7 @@ def tear_down_project(spec: dict[str, Any]) -> dict[str, Any]:
284
530
  mutated = mutated or workstation["mutated"]
285
531
 
286
532
  return {
287
- "schemaVersion": 2,
533
+ "schemaVersion": 3,
288
534
  "ownerRepo": normalized["ownerRepo"],
289
535
  "appSlug": normalized["appSlug"],
290
536
  "scope": normalized["scope"],
@@ -292,6 +538,8 @@ def tear_down_project(spec: dict[str, Any]) -> dict[str, Any]:
292
538
  "workstation": workstation,
293
539
  "productionEnvironments": normalized["productionEnvironments"],
294
540
  "productionDataIncluded": bool(normalized["productionEnvironments"]),
541
+ "publicIngressScopes": normalized["publicIngressScopes"],
542
+ "publicIngressIncluded": bool(normalized["publicIngressScopes"]),
295
543
  "mutated": mutated,
296
544
  }
297
545
 
@@ -3,15 +3,19 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import json
6
7
  import sys
7
8
 
8
9
  from .errors import DeploymentKitError
9
10
  from .teardown import (
10
11
  PRODUCTION_DATA_DELETE_PHRASE,
12
+ PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE,
11
13
  TEAR_DOWN_WRITE_PHRASE,
12
14
  normalize_teardown_spec,
15
+ parse_environment_scopes,
13
16
  parse_environment_targets,
14
17
  render_teardown_summary,
18
+ target_inventory,
15
19
  tear_down_project,
16
20
  )
17
21
 
@@ -22,7 +26,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
22
26
  description=(
23
27
  "Remove one named Kit-managed environment, all explicitly bound environments, "
24
28
  "and/or exact-package local Docker state. Remote tear-down deletes persistent "
25
- "application data for selected environments."
29
+ "application data for selected environments while preserving shared Project Forge platform state."
26
30
  ),
27
31
  )
28
32
  parser.add_argument(
@@ -38,6 +42,16 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
38
42
  metavar="ENV=TARGET",
39
43
  help="Explicit named environment-to-target binding; repeat as needed.",
40
44
  )
45
+ parser.add_argument(
46
+ "--public-ingress-scope",
47
+ action="append",
48
+ default=[],
49
+ metavar="ENV=SCOPE",
50
+ help=(
51
+ "Optional exact environment-to-public-ingress-scope binding discovered from target inventory; "
52
+ "repeat as needed. Selected public routes are removed before LAN/runtime cleanup."
53
+ ),
54
+ )
41
55
  parser.add_argument(
42
56
  "--production-environment",
43
57
  action="append",
@@ -66,34 +80,84 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
66
80
  f"{PRODUCTION_DATA_DELETE_PHRASE}"
67
81
  ),
68
82
  )
83
+ parser.add_argument(
84
+ "--public-ingress-authorization",
85
+ help=(
86
+ "Additional phrase required whenever selected environments include public ingress cleanup: "
87
+ f"{PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE}"
88
+ ),
89
+ )
69
90
  return parser.parse_args(argv)
70
91
 
71
92
 
72
93
  def _preview(spec: dict[str, object]) -> dict[str, object]:
73
94
  production = bool(spec["productionEnvironments"])
95
+ public_ingress = bool(spec["publicIngressScopes"])
74
96
  return {
75
- "schemaVersion": 2,
97
+ "schemaVersion": 3,
76
98
  "ownerRepo": spec["ownerRepo"],
77
99
  "appSlug": spec["appSlug"],
78
100
  "scope": spec["scope"],
79
101
  "environmentTargets": spec["environmentTargets"],
102
+ "publicIngressScopes": spec["publicIngressScopes"],
80
103
  "localPackage": spec["localPackage"],
81
104
  "productionEnvironments": spec["productionEnvironments"],
82
105
  "destructive": True,
83
106
  "deletesPersistentData": bool(spec["environmentTargets"]),
84
107
  "deletesProductionData": production,
108
+ "removesPublicIngress": public_ingress,
109
+ "removesUnreferencedRemoteImages": bool(spec["environmentTargets"]),
85
110
  "preservesSharedPlatform": True,
111
+ "preservesSharedPublicIngressRuntime": True,
86
112
  "preservesUnrelatedProjects": True,
87
113
  "deletesGhcrPackage": False,
88
114
  "requiredAuthorization": TEAR_DOWN_WRITE_PHRASE,
89
115
  "requiredProductionDataAuthorization": PRODUCTION_DATA_DELETE_PHRASE if production else None,
116
+ "requiredPublicIngressAuthorization": (
117
+ PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE if public_ingress else None
118
+ ),
90
119
  }
91
120
 
92
121
 
122
+ def inventory_main(argv: list[str] | None = None) -> int:
123
+ parser = argparse.ArgumentParser(
124
+ prog="project-forge-deploy target inventory",
125
+ description=(
126
+ "Read shared platform state plus application-owned resources that are valid tear-down candidates "
127
+ "on one target. This command is read-only and does not require a consumer repository."
128
+ ),
129
+ )
130
+ parser.add_argument("name", help="Logical Project Forge target profile")
131
+ parser.add_argument(
132
+ "--ingress-scope",
133
+ action="append",
134
+ default=[],
135
+ metavar="SCOPE",
136
+ help=(
137
+ "Additional public-ingress scope to inspect when it cannot be inferred from managed application "
138
+ "or LAN-route environment names; repeat as needed."
139
+ ),
140
+ )
141
+ parser.add_argument("--json", action="store_true")
142
+ try:
143
+ args = parser.parse_args(argv)
144
+ result = target_inventory(args.name, ingress_scopes=args.ingress_scope)
145
+ if args.json:
146
+ print(json.dumps(result, indent=2, sort_keys=True))
147
+ else:
148
+ print("TARGET INVENTORY")
149
+ print(json.dumps(result, indent=2, sort_keys=True))
150
+ return 0
151
+ except DeploymentKitError as exc:
152
+ print(f"ERROR: {exc}", file=sys.stderr)
153
+ return 2
154
+
155
+
93
156
  def main(argv: list[str] | None = None) -> int:
94
157
  try:
95
158
  args = parse_args(argv)
96
159
  bindings = parse_environment_targets(args.environment_target)
160
+ public_bindings = parse_environment_scopes(args.public_ingress_scope)
97
161
  spec = normalize_teardown_spec(
98
162
  owner_repo=args.owner_repo,
99
163
  app_slug=args.app_slug,
@@ -101,6 +165,7 @@ def main(argv: list[str] | None = None) -> int:
101
165
  environment_targets=bindings,
102
166
  local_package=args.local_package,
103
167
  production_environments=args.production_environment,
168
+ public_ingress_scopes=public_bindings,
104
169
  )
105
170
  preview = _preview(spec)
106
171
 
@@ -110,7 +175,12 @@ def main(argv: list[str] | None = None) -> int:
110
175
  needs_production
111
176
  and args.production_data_authorization != PRODUCTION_DATA_DELETE_PHRASE
112
177
  )
113
- if missing_general or missing_production:
178
+ needs_public_ingress = bool(spec["publicIngressScopes"])
179
+ missing_public_ingress = (
180
+ needs_public_ingress
181
+ and args.public_ingress_authorization != PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE
182
+ )
183
+ if missing_general or missing_production or missing_public_ingress:
114
184
  print("PROJECT TEAR-DOWN PREVIEW")
115
185
  print(render_teardown_summary(preview))
116
186
  if missing_general:
@@ -124,6 +194,12 @@ def main(argv: list[str] | None = None) -> int:
124
194
  f"--production-data-authorization {PRODUCTION_DATA_DELETE_PHRASE!r}",
125
195
  file=sys.stderr,
126
196
  )
197
+ if missing_public_ingress:
198
+ print(
199
+ "ERROR: public-ingress removal requires "
200
+ f"--public-ingress-authorization {PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE!r}",
201
+ file=sys.stderr,
202
+ )
127
203
  return 2
128
204
 
129
205
  result = tear_down_project(spec)
@@ -10,6 +10,7 @@ ENVIRONMENT="$3"
10
10
 
11
11
  valid_slug() { [[ "$1" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; }
12
12
  valid_repo() { [[ "$1" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; }
13
+ valid_image() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._/:+-]*@sha256:[0-9a-f]{64}$ ]]; }
13
14
  meta_value() {
14
15
  local path="$1" key="$2"
15
16
  [[ -f "$path" && ! -L "$path" ]] || return 1
@@ -22,6 +23,13 @@ assert_owned_marker() {
22
23
  "$(meta_value "$path" APP_SLUG || true)" == "$APP" && \
23
24
  "$(meta_value "$path" ENVIRONMENT || true)" == "$ENVIRONMENT" ]]
24
25
  }
26
+ append_unique_image() {
27
+ local value="$1" existing
28
+ for existing in "${IMAGES[@]:-}"; do
29
+ [[ "$existing" == "$value" ]] && return 0
30
+ done
31
+ IMAGES+=("$value")
32
+ }
25
33
 
26
34
  valid_repo "$OWNER" || { echo "Invalid owner repository." >&2; exit 20; }
27
35
  valid_slug "$APP" || { echo "Invalid application slug." >&2; exit 20; }
@@ -60,6 +68,23 @@ if [[ "$HAS_PERSISTENCE" == true ]] && ! assert_owned_marker "$PERSISTENCE"; the
60
68
  fi
61
69
 
62
70
  CONTAINER=""
71
+ IMAGES=()
72
+ if [[ -d "$ROOT/releases" && ! -L "$ROOT/releases" ]]; then
73
+ while IFS= read -r release_file; do
74
+ [[ -f "$release_file" && ! -L "$release_file" ]] || continue
75
+ assert_owned_marker "$release_file" || {
76
+ echo "Managed release ownership does not match selected environment: $release_file" >&2
77
+ exit 23
78
+ }
79
+ image="$(meta_value "$release_file" IMAGE || true)"
80
+ valid_image "$image" || {
81
+ echo "Managed release contains an invalid immutable image reference: $release_file" >&2
82
+ exit 23
83
+ }
84
+ append_unique_image "$image"
85
+ done < <(find "$ROOT/releases" -mindepth 2 -maxdepth 2 -type f -name release.env -print 2>/dev/null || true)
86
+ fi
87
+
63
88
  if [[ "$HAS_STATE" == true ]]; then
64
89
  CURRENT_RELEASE="$(meta_value "$STATE" CURRENT_RELEASE || true)"
65
90
  [[ -n "$CURRENT_RELEASE" && "$CURRENT_RELEASE" != */* && "$CURRENT_RELEASE" != *..* ]] || {
@@ -96,5 +121,37 @@ if [[ -d "$APP_DIR" ]] && ! find "$APP_DIR" -mindepth 1 -print -quit | grep -q .
96
121
  rmdir "$APP_DIR"
97
122
  fi
98
123
 
99
- printf 'TEAR_DOWN\tremoved\t%s\t%s\t%s\n' "$APP" "$ENVIRONMENT" "$CONTAINER"
124
+ RETAINED_IMAGE=false
125
+ for IMAGE in "${IMAGES[@]:-}"; do
126
+ [[ -n "$IMAGE" ]] || continue
127
+ IMAGE_REFERENCED_ELSEWHERE=false
128
+ while IFS= read -r candidate; do
129
+ [[ -f "$candidate" && ! -L "$candidate" ]] || continue
130
+ if [[ "$(meta_value "$candidate" IMAGE || true)" == "$IMAGE" ]]; then
131
+ IMAGE_REFERENCED_ELSEWHERE=true
132
+ break
133
+ fi
134
+ done < <(find "$APP_ROOT" -mindepth 5 -maxdepth 5 -type f -name release.env -print 2>/dev/null || true)
135
+
136
+ if [[ "$IMAGE_REFERENCED_ELSEWHERE" == true ]] || \
137
+ docker ps -a --filter "ancestor=$IMAGE" --format '{{.ID}}' | grep -q .; then
138
+ RETAINED_IMAGE=true
139
+ continue
140
+ fi
141
+ if docker image inspect "$IMAGE" >/dev/null 2>&1; then
142
+ if ! docker image rm "$IMAGE" >/dev/null 2>&1; then
143
+ RETAINED_IMAGE=true
144
+ fi
145
+ fi
146
+ done
147
+
148
+ IMAGES_CSV=""
149
+ if [[ ${#IMAGES[@]} -gt 0 ]]; then
150
+ IMAGES_CSV="$(IFS=,; echo "${IMAGES[*]}")"
151
+ fi
152
+ ALL_IMAGES_REMOVED=true
153
+ [[ "$RETAINED_IMAGE" == true ]] && ALL_IMAGES_REMOVED=false
154
+
155
+ printf 'TEAR_DOWN\tremoved\t%s\t%s\t%s\t%s\t%s\n' \
156
+ "$APP" "$ENVIRONMENT" "$CONTAINER" "$IMAGES_CSV" "$ALL_IMAGES_REMOVED"
100
157
  '''
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.9.0",
3
+ "version": "4.10.0",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {