@mriekki/project-forge 3.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/assets/components.json +4 -4
  2. package/assets/core/distribution.json +0 -4
  3. package/assets/deployment/distribution.json +1 -0
  4. package/assets/generated/ASSEMBLY.json +22 -37
  5. package/assets/generated/core/core/instructions/project-forge-workflow.instructions.md +8 -2
  6. package/assets/generated/core/core/process/INTERACTIVE_SESSION_GUIDE.md +1 -2
  7. package/assets/generated/core/core/process/OPERATION_JOURNAL.md +1 -2
  8. package/assets/generated/core/core/skills/grill-me/SKILL.md +84 -46
  9. package/assets/generated/core/core/skills/project-forge-update/SKILL.md +3 -3
  10. package/assets/generated/core/core/skills/setup/SKILL.md +149 -199
  11. package/assets/generated/core/core/skills/start-work/SKILL.md +5 -6
  12. package/assets/generated/deployment/docs/DEVELOPER_ENVIRONMENTS.md +52 -12
  13. package/assets/generated/deployment/docs/ENVIRONMENT_PROOF.md +56 -6
  14. package/assets/generated/deployment/src/project_forge_deployment/app_runtime_workstation.py +103 -42
  15. package/assets/generated/deployment/src/project_forge_deployment/environment_cli.py +41 -4
  16. package/assets/generated/deployment/src/project_forge_deployment/environment_probe.py +152 -79
  17. package/assets/generated/deployment/src/project_forge_deployment/environment_setup.py +241 -0
  18. package/assets/generated/deployment/src/project_forge_deployment/image_transfer.py +51 -11
  19. package/package.json +1 -1
  20. package/assets/generated/core/core/agents/coach.agent.md +0 -69
  21. package/assets/generated/core/core/skills/core-sync/SKILL.md +0 -184
  22. package/assets/generated/core/core/skills/developer-env-mode/SKILL.md +0 -231
  23. package/assets/generated/core/core/skills/grill-with-docs/SKILL.md +0 -88
@@ -10,9 +10,10 @@ import subprocess
10
10
  from typing import Any
11
11
 
12
12
  from .app_runtime import normalize_app_spec, plan_app_deploy
13
- from .app_runtime_workstation import apply_app_deploy_from_workstation
13
+ from .app_runtime_workstation import apply_app_deploy_with_preloaded_image
14
14
  from .box_platform import platform_status
15
15
  from .errors import DeploymentKitError
16
+ from .image_transfer import remote_image_present, transfer_image, verify_local_image
16
17
  from .registry_access import normalize_registry_image
17
18
  from .teardown import tear_down_project
18
19
 
@@ -123,10 +124,66 @@ def plan_environment_proof(
123
124
  "requiredAuthorization": (
124
125
  PRODUCTION_ENVIRONMENT_PROOF_WRITE_PHRASE if production else ENVIRONMENT_PROOF_WRITE_PHRASE
125
126
  ),
127
+ }
128
+
129
+
130
+ def _proof_result(plan: dict[str, Any]) -> dict[str, Any]:
131
+ return {
132
+ "targetName": plan["targetName"],
133
+ "ownerRepo": plan["ownerRepo"],
134
+ "environment": plan["environment"],
135
+ "production": plan["production"],
136
+ "probeAppSlug": plan["probeAppSlug"],
137
+ "probeImageTag": plan["probeImageTag"],
138
+ "preflight": {
139
+ "targetAccess": "verified-by-platform-doctor",
140
+ "hostPlatform": "ready",
141
+ },
142
+ "registryDigest": None,
143
+ "localImageId": None,
144
+ "transferState": "not-started",
145
+ "remoteImageId": None,
146
+ "runtimeState": "not-started",
147
+ "httpHealth": "not-run",
148
+ "cleanupState": "not-run",
149
+ "imageCacheState": "unknown",
150
+ "proof": "failed",
126
151
  "mutated": False,
152
+ "finalMutationState": "none",
153
+ "error": None,
154
+ "cleanupError": None,
127
155
  }
128
156
 
129
157
 
158
+ def _cleanup_state(cleanup: dict[str, Any] | None, environment: str) -> str:
159
+ if not isinstance(cleanup, dict):
160
+ return "unknown"
161
+ entry = next(
162
+ (item for item in cleanup.get("environments", []) if item.get("environment") == environment),
163
+ None,
164
+ )
165
+ if not isinstance(entry, dict):
166
+ return "unknown"
167
+ state = entry.get("runtimeAndPersistentData", {}).get("state")
168
+ return state if isinstance(state, str) and state else "unknown"
169
+
170
+
171
+ def _final_mutation_state(result: dict[str, Any]) -> str:
172
+ cleanup = result["cleanupState"]
173
+ cache = result["imageCacheState"]
174
+ if cleanup not in {"removed", "absent", "not-run"}:
175
+ return "cleanup-incomplete"
176
+ if cache == "present":
177
+ return "runtime-clean-image-cached"
178
+ if result["mutated"] and cache == "absent":
179
+ return "clean"
180
+ if result["mutated"]:
181
+ return "runtime-clean-cache-unknown"
182
+ if result["transferState"] == "attempting" and cache == "unknown":
183
+ return "mutation-unknown-after-transfer-attempt"
184
+ return "none"
185
+
186
+
130
187
  def run_environment_proof(plan: dict[str, Any]) -> dict[str, Any]:
131
188
  required = {
132
189
  "targetName",
@@ -140,89 +197,105 @@ def run_environment_proof(plan: dict[str, Any]) -> dict[str, Any]:
140
197
  "healthPath",
141
198
  "cleanup",
142
199
  "requiredAuthorization",
143
- "mutated",
144
200
  }
145
- if not isinstance(plan, dict) or set(plan) != required or plan["mutated"] is not False:
201
+ if not isinstance(plan, dict) or set(plan) != required:
146
202
  raise DeploymentKitError("Malformed environment proof plan.")
147
203
 
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
-
204
+ result = _proof_result(plan)
205
+ deploy_plan: dict[str, Any] | None = None
206
+ transfer: dict[str, Any] | None = None
164
207
  deployment: dict[str, Any] | None = None
165
208
  cleanup: dict[str, Any] | None = None
166
- deploy_error: Exception | None = None
167
- cleanup_error: Exception | None = None
209
+ operation_error: Exception | None = None
210
+
168
211
  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
212
+ exact_image = _resolve_probe_image(plan["probeImageTag"])
213
+ result["registryDigest"] = exact_image
214
+ local = verify_local_image(exact_image)
215
+ result["localImageId"] = local["localImageId"]
216
+
217
+ spec = normalize_app_spec(
218
+ owner_repo=plan["ownerRepo"],
219
+ environment=plan["environment"],
220
+ app_slug=plan["probeAppSlug"],
221
+ container_name=f"{plan['probeAppSlug']}--{plan['environment']}",
222
+ image=exact_image,
223
+ runtime_port=80,
224
+ health_path=plan["healthPath"],
225
+ run_as="0",
226
+ persistence_mode="none",
227
+ data_container_path=None,
228
+ environment_variables={},
229
+ )
230
+ deploy_plan = plan_app_deploy(plan["targetName"], spec)
231
+
232
+ result["transferState"] = "attempting"
233
+ transfer = transfer_image(plan["targetName"], exact_image)
234
+ result["transferState"] = transfer["status"]
235
+ result["remoteImageId"] = transfer["remoteImageId"]
236
+ result["mutated"] = bool(transfer["mutated"])
237
+
238
+ result["runtimeState"] = "applying"
239
+ deployment = apply_app_deploy_with_preloaded_image(deploy_plan, transfer)
240
+ result["runtimeState"] = "healthy"
241
+ result["httpHealth"] = "passed"
242
+ result["mutated"] = True
243
+ except Exception as error: # terminal proof evidence must survive operational failure
244
+ operation_error = error
245
+ result["error"] = str(error)
246
+ if result["runtimeState"] == "applying":
247
+ result["runtimeState"] = "failed-or-partial"
248
+ result["httpHealth"] = "failed-or-not-reached"
172
249
  finally:
173
- try:
174
- cleanup = tear_down_project(
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.")
250
+ if deploy_plan is not None:
251
+ try:
252
+ cleanup = tear_down_project(
253
+ {
254
+ "ownerRepo": plan["ownerRepo"],
255
+ "appSlug": plan["probeAppSlug"],
256
+ "scope": plan["environment"],
257
+ "environmentTargets": {plan["environment"]: plan["targetName"]},
258
+ "localPackage": None,
259
+ "productionEnvironments": [plan["environment"]] if plan["production"] else [],
260
+ }
261
+ )
262
+ result["cleanupState"] = _cleanup_state(cleanup, plan["environment"])
263
+ if result["cleanupState"] == "removed":
264
+ result["mutated"] = True
265
+ except Exception as error:
266
+ result["cleanupState"] = "failed"
267
+ result["cleanupError"] = str(error)
215
268
 
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
- }
269
+ image_id = result.get("localImageId")
270
+ if isinstance(image_id, str):
271
+ try:
272
+ cached = remote_image_present(plan["targetName"], image_id)
273
+ result["imageCacheState"] = "present" if cached else "absent"
274
+ if cached:
275
+ result["remoteImageId"] = image_id
276
+ if result["transferState"] == "attempting":
277
+ result["mutated"] = True
278
+ except Exception:
279
+ result["imageCacheState"] = "unknown"
280
+
281
+ cleanup_terminal = result["cleanupState"] in {"removed", "absent"}
282
+ if operation_error is None and result["cleanupError"] is None and cleanup_terminal:
283
+ assert deployment is not None
284
+ matched = next(
285
+ (
286
+ item
287
+ for item in deployment.get("applications", [])
288
+ if item.get("appSlug") == plan["probeAppSlug"]
289
+ and item.get("environment") == plan["environment"]
290
+ ),
291
+ None,
292
+ )
293
+ if matched and matched.get("containerRunning") and matched.get("httpHealthy"):
294
+ result["proof"] = "passed"
295
+ else:
296
+ result["error"] = "Environment proof returned without healthy disposable runtime evidence."
297
+ elif operation_error is None and result["cleanupError"] is None and not cleanup_terminal:
298
+ result["error"] = "Environment proof cleanup did not return terminal runtime cleanup evidence."
299
+
300
+ result["finalMutationState"] = _final_mutation_state(result)
301
+ return result
@@ -0,0 +1,241 @@
1
+ """Read-only orchestration for one remote Project Forge setup target.
2
+
3
+ This module intentionally composes existing deterministic primitives without applying
4
+ mutations. It gives conversational front ends one state machine to render instead of
5
+ reconstructing profile, SSH, host-platform, and proof readiness themselves.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from .box_platform import platform_doctor
13
+ from .doctor import target_doctor, workstation_doctor
14
+ from .environment_probe import plan_environment_proof
15
+ from .errors import DeploymentKitError
16
+ from .profiles import load_profile
17
+ from .ssh import verify_access
18
+
19
+
20
+ READINESS_ORDER = (
21
+ "workspace",
22
+ "targetDefinition",
23
+ "targetAccess",
24
+ "hostPlatform",
25
+ "deploymentPath",
26
+ "application",
27
+ )
28
+
29
+
30
+ def _phase(label: str, state: str, *, detail: Any = None) -> dict[str, Any]:
31
+ value: dict[str, Any] = {"label": label, "state": state}
32
+ if detail is not None:
33
+ value["detail"] = detail
34
+ return value
35
+
36
+
37
+ def remote_setup_status(name: str) -> dict[str, Any]:
38
+ """Return one read-only, terminal status model for remote environment setup."""
39
+
40
+ workstation = workstation_doctor()
41
+ target = target_doctor(name)
42
+
43
+ phases: dict[str, dict[str, Any]] = {
44
+ "workspace": _phase(
45
+ "WORKSPACE READY",
46
+ "ready" if workstation["state"] in {"configured", "partially-configured"} else workstation["state"],
47
+ detail=workstation,
48
+ ),
49
+ "targetDefinition": _phase(
50
+ "TARGET DEFINITION READY",
51
+ "ready" if target["state"] != "needs-profile" else "needs-profile",
52
+ detail=target,
53
+ ),
54
+ "targetAccess": _phase("TARGET ACCESS READY", "blocked"),
55
+ "hostPlatform": _phase("HOST PLATFORM READY", "blocked"),
56
+ "deploymentPath": _phase("DEPLOYMENT PATH PROVEN", "not-proven"),
57
+ "application": _phase("APPLICATION DEPLOYED", "not-part-of-setup"),
58
+ }
59
+
60
+ target_access: dict[str, Any] | None = None
61
+ host_platform: dict[str, Any] | None = None
62
+
63
+ if target["state"] == "ready-to-verify":
64
+ try:
65
+ target_access = verify_access(load_profile(name))
66
+ phases["targetAccess"] = _phase("TARGET ACCESS READY", "ready", detail=target_access)
67
+ except DeploymentKitError as error:
68
+ phases["targetAccess"] = _phase(
69
+ "TARGET ACCESS READY",
70
+ "verification-failed",
71
+ detail={"reason": str(error)},
72
+ )
73
+ elif target["state"] == "needs-profile":
74
+ phases["targetAccess"] = _phase("TARGET ACCESS READY", "blocked-by-target-definition")
75
+ else:
76
+ phases["targetAccess"] = _phase(
77
+ "TARGET ACCESS READY",
78
+ f"blocked-by-{target['state']}",
79
+ detail={"nextAction": target.get("nextAction")},
80
+ )
81
+
82
+ if phases["targetAccess"]["state"] == "ready":
83
+ try:
84
+ host_platform = platform_doctor(name)
85
+ phases["hostPlatform"] = _phase(
86
+ "HOST PLATFORM READY",
87
+ "ready" if host_platform["state"] == "ready" else host_platform["state"],
88
+ detail=host_platform,
89
+ )
90
+ except DeploymentKitError as error:
91
+ phases["hostPlatform"] = _phase(
92
+ "HOST PLATFORM READY",
93
+ "inspection-failed",
94
+ detail={"reason": str(error)},
95
+ )
96
+ else:
97
+ phases["hostPlatform"] = _phase("HOST PLATFORM READY", "blocked-by-target-access")
98
+
99
+ current = next(
100
+ (key for key in READINESS_ORDER if phases[key]["state"] not in {"ready", "not-part-of-setup"}),
101
+ "application",
102
+ )
103
+ return {
104
+ "targetName": name,
105
+ "state": "host-platform-ready" if phases["hostPlatform"]["state"] == "ready" else "needs-setup",
106
+ "currentPhase": current,
107
+ "phases": phases,
108
+ "mutated": False,
109
+ }
110
+
111
+
112
+ def _next_action(status: dict[str, Any]) -> dict[str, Any]:
113
+ phases = status["phases"]
114
+ target = phases["targetDefinition"].get("detail") or {}
115
+
116
+ if phases["targetDefinition"]["state"] == "needs-profile":
117
+ return {
118
+ "kind": "missing-facts",
119
+ "action": "configure-target-profile",
120
+ "command": f"project-forge-deploy target configure {status['targetName']} <target facts>",
121
+ "reason": "A target profile does not exist yet; host/user/SSH facts must come from the developer or portable profile.",
122
+ }
123
+
124
+ target_state = target.get("state")
125
+ if target_state == "needs-key":
126
+ return {
127
+ "kind": "write-gated",
128
+ "action": "create-or-select-key",
129
+ "command": f"project-forge-deploy target key-create {status['targetName']}",
130
+ "authorization": "[LOCAL KEY WRITE]",
131
+ }
132
+ if target_state == "needs-trust":
133
+ return {
134
+ "kind": "human-verification",
135
+ "action": "verify-and-enroll-host-trust",
136
+ "command": f"project-forge-deploy target trust-candidates {status['targetName']}",
137
+ "reason": "Host-key trust requires an independently observed target-console fingerprint before enrollment.",
138
+ }
139
+ if target_state == "needs-address":
140
+ return {
141
+ "kind": "missing-facts",
142
+ "action": "set-verified-address",
143
+ "command": f"project-forge-deploy target address-set {status['targetName']} --address <verified-address>",
144
+ "reason": "Stable host identity exists but no usable address resolved; use only a separately verified current address.",
145
+ }
146
+ if target_state == "blocked":
147
+ return {
148
+ "kind": "blocked",
149
+ "action": target.get("nextAction", "repair-target-profile"),
150
+ "reason": target.get("reason", "Target definition is blocked."),
151
+ }
152
+
153
+ if phases["targetAccess"]["state"] != "ready":
154
+ return {
155
+ "kind": "read-only",
156
+ "action": "verify-target-access",
157
+ "command": f"project-forge-deploy target verify {status['targetName']}",
158
+ "reason": (phases["targetAccess"].get("detail") or {}).get("reason"),
159
+ }
160
+
161
+ platform = phases["hostPlatform"].get("detail") or {}
162
+ platform_state = phases["hostPlatform"]["state"]
163
+ if platform_state == "installable":
164
+ return {
165
+ "kind": "write-gated",
166
+ "action": "install-host-platform",
167
+ "command": f"project-forge-deploy platform install {status['targetName']}",
168
+ "authorization": "[HOST PLATFORM WRITE]",
169
+ }
170
+ if platform_state == "upgrade-required":
171
+ return {
172
+ "kind": "write-gated",
173
+ "action": "upgrade-host-platform",
174
+ "command": f"project-forge-deploy platform upgrade {status['targetName']}",
175
+ "authorization": "[HOST PLATFORM WRITE]",
176
+ }
177
+ if platform_state in {"blocked-port-80", "drifted", "blocked", "incompatible", "inspection-failed"}:
178
+ return {
179
+ "kind": "blocked",
180
+ "action": platform.get("nextAction", "inspect-host-platform"),
181
+ "reason": platform.get("reason", f"Host platform state is {platform_state}."),
182
+ "safety": "Inspect exact ownership/state first; do not broadly prune or take over unrelated host resources.",
183
+ }
184
+ if platform_state != "ready":
185
+ return {
186
+ "kind": "blocked",
187
+ "action": "inspect-host-platform",
188
+ "reason": f"Host platform state is {platform_state}.",
189
+ }
190
+
191
+ return {
192
+ "kind": "proof-context-required",
193
+ "action": "plan-deployment-path-proof",
194
+ "reason": "Target access and host platform are ready. Owner/repository and logical environment are needed to plan the disposable proof.",
195
+ }
196
+
197
+
198
+ def remote_setup_plan(
199
+ name: str,
200
+ *,
201
+ owner_repo: str | None = None,
202
+ environment: str | None = None,
203
+ production: bool = False,
204
+ ) -> dict[str, Any]:
205
+ """Return the exact next deterministic setup action without mutating state."""
206
+
207
+ status = remote_setup_status(name)
208
+ next_action = _next_action(status)
209
+
210
+ if (
211
+ next_action["action"] == "plan-deployment-path-proof"
212
+ and owner_repo is not None
213
+ and environment is not None
214
+ ):
215
+ proof = plan_environment_proof(
216
+ name,
217
+ owner_repo=owner_repo,
218
+ environment=environment,
219
+ production=production,
220
+ )
221
+ command = (
222
+ f"project-forge-deploy environment proof plan {name} "
223
+ f"--owner-repo {proof['ownerRepo']} --environment {proof['environment']}"
224
+ )
225
+ if production:
226
+ command += " --production"
227
+ next_action = {
228
+ "kind": "read-only",
229
+ "action": "review-deployment-path-proof",
230
+ "command": command,
231
+ "proofPlan": proof,
232
+ "runAuthorization": proof["requiredAuthorization"],
233
+ }
234
+ elif (owner_repo is None) != (environment is None):
235
+ raise DeploymentKitError("ownerRepo and environment must be supplied together for setup proof planning.")
236
+
237
+ return {
238
+ **status,
239
+ "next": next_action,
240
+ "mutated": False,
241
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import re
5
6
  import shlex
6
7
  import shutil
7
8
  import subprocess
@@ -13,6 +14,9 @@ from .registry_access import normalize_registry_image
13
14
  from .ssh import ssh_args, verify_access
14
15
 
15
16
 
17
+ _IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
18
+
19
+
16
20
  def _docker() -> str:
17
21
  path = shutil.which("docker")
18
22
  if path is None:
@@ -20,13 +24,25 @@ def _docker() -> str:
20
24
  return path
21
25
 
22
26
 
27
+ def _image_id(value: str) -> str:
28
+ if not isinstance(value, str) or _IMAGE_ID_RE.fullmatch(value) is None:
29
+ raise DeploymentKitError("Docker image ID must be sha256:<64 lowercase hex>.")
30
+ return value
31
+
32
+
23
33
  def verify_local_image(image: str) -> dict[str, str]:
34
+ """Verify the declared immutable registry digest and resolve its local config/image ID.
35
+
36
+ Registry RepoDigests are release identity. Docker's image ID is the config digest and is
37
+ the content identity preserved by ``docker save``/``docker load`` even when RepoTags are
38
+ absent from the archive.
39
+ """
40
+
24
41
  exact = normalize_registry_image(image)
25
42
  result = subprocess.run(
26
- [_docker(), "image", "inspect", exact],
43
+ [_docker(), "image", "inspect", "--format", "{{.Id}}", exact],
27
44
  check=False,
28
- stdout=subprocess.DEVNULL,
29
- stderr=subprocess.PIPE,
45
+ capture_output=True,
30
46
  text=True,
31
47
  encoding="utf-8",
32
48
  errors="replace",
@@ -36,11 +52,17 @@ def verify_local_image(image: str) -> dict[str, str]:
36
52
  f"Exact image is not present in the workstation Docker engine: {exact}. "
37
53
  "Pull/build it locally before transfer."
38
54
  )
39
- return {"image": exact, "localImage": "verified"}
55
+ image_id = _image_id(result.stdout.strip())
56
+ return {
57
+ "image": exact,
58
+ "registryDigest": exact,
59
+ "localImageId": image_id,
60
+ }
40
61
 
41
62
 
42
- def _remote_has_image(profile: dict[str, Any], address: str, image: str) -> bool:
43
- command = "sudo -n docker image inspect " + shlex.quote(image) + " >/dev/null 2>&1"
63
+ def _remote_has_image(profile: dict[str, Any], address: str, image_id: str) -> bool:
64
+ exact_id = _image_id(image_id)
65
+ command = "sudo -n docker image inspect " + shlex.quote(exact_id) + " >/dev/null 2>&1"
44
66
  result = subprocess.run(
45
67
  ssh_args(profile, address, command=command),
46
68
  check=False,
@@ -50,17 +72,31 @@ def _remote_has_image(profile: dict[str, Any], address: str, image: str) -> bool
50
72
  return result.returncode == 0
51
73
 
52
74
 
75
+ def remote_image_present(name: str, image_id: str) -> bool:
76
+ """Read-only check for one exact Docker config/image ID on a verified target."""
77
+
78
+ exact_id = _image_id(image_id)
79
+ profile = load_profile(name)
80
+ access = verify_access(profile)
81
+ return _remote_has_image(profile, access["address"], exact_id)
82
+
83
+
53
84
  def transfer_image(name: str, image: str) -> dict[str, Any]:
54
- exact = normalize_registry_image(image)
55
- verify_local_image(exact)
85
+ local = verify_local_image(image)
86
+ exact = local["registryDigest"]
87
+ image_id = local["localImageId"]
56
88
  profile = load_profile(name)
57
89
  access = verify_access(profile)
58
90
  address = access["address"]
59
91
 
60
- if _remote_has_image(profile, address, exact):
92
+ if _remote_has_image(profile, address, image_id):
61
93
  return {
62
94
  "targetName": name,
63
95
  "image": exact,
96
+ "registryDigest": exact,
97
+ "localImageId": image_id,
98
+ "remoteImageId": image_id,
99
+ "runtimeImage": image_id,
64
100
  "status": "already-present",
65
101
  "mutated": False,
66
102
  }
@@ -93,13 +129,17 @@ def transfer_image(name: str, image: str) -> dict[str, Any]:
93
129
  )
94
130
  raise DeploymentKitError(f"Remote Docker image load failed: {detail}")
95
131
 
96
- if not _remote_has_image(profile, address, exact):
132
+ if not _remote_has_image(profile, address, image_id):
97
133
  raise DeploymentKitError(
98
- "Remote docker load completed but the exact immutable image reference is not inspectable on the target."
134
+ "Remote docker load completed but the exact Docker config/image ID is not inspectable on the target."
99
135
  )
100
136
  return {
101
137
  "targetName": name,
102
138
  "image": exact,
139
+ "registryDigest": exact,
140
+ "localImageId": image_id,
141
+ "remoteImageId": image_id,
142
+ "runtimeImage": image_id,
103
143
  "status": "transferred",
104
144
  "mutated": True,
105
145
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "3.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {