@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,348 @@
1
+ """Personal GitHub project-forge-profile repository and validated local cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import shutil
8
+ import subprocess
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .errors import DeploymentKitError
14
+ from .portable_profile import (
15
+ BUNDLE_KIND,
16
+ BUNDLE_SCHEMA_VERSION,
17
+ apply_import,
18
+ export_bundle,
19
+ plan_import,
20
+ validate_bundle,
21
+ )
22
+ from .profiles import config_root
23
+
24
+
25
+ PROFILE_REPOSITORY = "project-forge-profile"
26
+ PROFILE_FILE = "project-forge-profile.json"
27
+ PERSONAL_PROFILE_WRITE_PHRASE = "[PERSONAL PROFILE WRITE]"
28
+ LOCAL_PROFILE_SYNC_PHRASE = "[LOCAL PROFILE SYNC]"
29
+
30
+
31
+ def _gh() -> str:
32
+ path = shutil.which("gh")
33
+ if path is None:
34
+ raise DeploymentKitError("GitHub CLI is unavailable; personal profile sync cannot reach GitHub.")
35
+ return path
36
+
37
+
38
+ def _run_gh(args: list[str]) -> subprocess.CompletedProcess[str]:
39
+ return subprocess.run(
40
+ [_gh(), *args],
41
+ check=False,
42
+ capture_output=True,
43
+ text=True,
44
+ encoding="utf-8",
45
+ errors="replace",
46
+ )
47
+
48
+
49
+ def _detail(result: subprocess.CompletedProcess[str]) -> str:
50
+ return result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}"
51
+
52
+
53
+ def _not_found(result: subprocess.CompletedProcess[str]) -> bool:
54
+ text = (result.stderr + "\n" + result.stdout).lower()
55
+ return result.returncode != 0 and (
56
+ "http 404" in text
57
+ or "not found" in text
58
+ or "could not resolve to a repository" in text
59
+ or "could not resolve to a resource" in text
60
+ )
61
+
62
+
63
+ def authenticated_login() -> str:
64
+ result = _run_gh(["api", "user"])
65
+ if result.returncode != 0:
66
+ raise DeploymentKitError(f"Cannot resolve authenticated GitHub identity: {_detail(result)}")
67
+ try:
68
+ value = json.loads(result.stdout)
69
+ except json.JSONDecodeError as error:
70
+ raise DeploymentKitError("GitHub CLI returned malformed authenticated-user data.") from error
71
+ login = value.get("login") if isinstance(value, dict) else None
72
+ if not isinstance(login, str) or not login:
73
+ raise DeploymentKitError("GitHub CLI authenticated-user data omitted login.")
74
+ return login
75
+
76
+
77
+ def repository_status() -> dict[str, Any]:
78
+ login = authenticated_login()
79
+ full_name = f"{login}/{PROFILE_REPOSITORY}"
80
+ result = _run_gh(["api", f"repos/{full_name}"])
81
+ if _not_found(result):
82
+ return {
83
+ "login": login,
84
+ "repository": full_name,
85
+ "state": "missing",
86
+ "private": None,
87
+ "mutated": False,
88
+ }
89
+ if result.returncode != 0:
90
+ raise DeploymentKitError(f"Cannot inspect personal Project Forge profile repository: {_detail(result)}")
91
+ try:
92
+ value = json.loads(result.stdout)
93
+ except json.JSONDecodeError as error:
94
+ raise DeploymentKitError("GitHub CLI returned malformed repository data.") from error
95
+ if not isinstance(value, dict):
96
+ raise DeploymentKitError("GitHub CLI returned malformed repository data.")
97
+ owner = (value.get("owner") or {}).get("login") if isinstance(value.get("owner"), dict) else None
98
+ name = value.get("name")
99
+ private = value.get("private")
100
+ if owner != login or name != PROFILE_REPOSITORY or type(private) is not bool:
101
+ raise DeploymentKitError("Personal Project Forge profile repository identity could not be verified.")
102
+ if not private:
103
+ raise DeploymentKitError(
104
+ f"Personal Project Forge profile repository {full_name} is public; it must be private before Project Forge will use it."
105
+ )
106
+ return {
107
+ "login": login,
108
+ "repository": full_name,
109
+ "state": "ready",
110
+ "private": True,
111
+ "mutated": False,
112
+ }
113
+
114
+
115
+ def _profile_dirs() -> tuple[Path, Path, Path]:
116
+ root = config_root() / "profile"
117
+ return root / "current", root / "last-known-good", root / "sync-state.json"
118
+
119
+
120
+ def _write_json_atomic(path: Path, value: Any) -> None:
121
+ path.parent.mkdir(parents=True, exist_ok=True)
122
+ if path.is_symlink() or path.parent.is_symlink():
123
+ raise DeploymentKitError(f"Refusing to write profile cache through a symbolic link: {path}")
124
+ temp = path.with_name(path.name + ".tmp")
125
+ temp.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
126
+ temp.replace(path)
127
+
128
+
129
+ def cache_valid_profile(bundle: dict[str, Any], *, source: str, remote_sha: str | None) -> dict[str, Any]:
130
+ normalized = validate_bundle(bundle)
131
+ current, lkg, state_path = _profile_dirs()
132
+ current_file = current / PROFILE_FILE
133
+ lkg_file = lkg / PROFILE_FILE
134
+ _write_json_atomic(current_file, normalized)
135
+ _write_json_atomic(lkg_file, normalized)
136
+ state = {
137
+ "schemaVersion": 1,
138
+ "lastValidatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
139
+ "source": source,
140
+ "remoteSha": remote_sha,
141
+ "current": str(current_file),
142
+ "lastKnownGood": str(lkg_file),
143
+ }
144
+ _write_json_atomic(state_path, state)
145
+ return state
146
+
147
+
148
+ def load_last_known_good() -> dict[str, Any]:
149
+ _, lkg, _ = _profile_dirs()
150
+ path = lkg / PROFILE_FILE
151
+ if path.is_symlink() or not path.is_file():
152
+ raise DeploymentKitError("No validated last-known-good personal Project Forge profile is cached on this workstation.")
153
+ try:
154
+ value = json.loads(path.read_text(encoding="utf-8"))
155
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
156
+ raise DeploymentKitError(f"Cannot read last-known-good personal Project Forge profile: {error}") from error
157
+ return validate_bundle(value)
158
+
159
+
160
+ def _empty_bundle() -> dict[str, Any]:
161
+ return {"schemaVersion": BUNDLE_SCHEMA_VERSION, "kind": BUNDLE_KIND, "targets": []}
162
+
163
+
164
+ def fetch_remote_profile() -> dict[str, Any]:
165
+ status = repository_status()
166
+ if status["state"] != "ready":
167
+ raise DeploymentKitError(f"Personal Project Forge profile repository does not exist: {status['repository']}")
168
+ full_name = status["repository"]
169
+ result = _run_gh(["api", f"repos/{full_name}/contents/{PROFILE_FILE}"])
170
+ if _not_found(result):
171
+ return {"bundle": _empty_bundle(), "sha": None, "repository": full_name}
172
+ if result.returncode != 0:
173
+ raise DeploymentKitError(f"Cannot fetch personal Project Forge profile: {_detail(result)}")
174
+ try:
175
+ value = json.loads(result.stdout)
176
+ encoded = value["content"]
177
+ sha = value["sha"]
178
+ if value.get("encoding") != "base64" or not isinstance(encoded, str) or not isinstance(sha, str):
179
+ raise ValueError("unexpected content payload")
180
+ decoded = base64.b64decode(encoded.encode("ascii"), validate=False).decode("utf-8")
181
+ bundle = validate_bundle(json.loads(decoded))
182
+ except (KeyError, TypeError, ValueError, UnicodeError, json.JSONDecodeError) as error:
183
+ raise DeploymentKitError(
184
+ "Remote personal Project Forge profile is invalid; the validated local last-known-good profile was not replaced."
185
+ ) from error
186
+ return {"bundle": bundle, "sha": sha, "repository": full_name}
187
+
188
+
189
+ def sync_profile(*, apply: bool) -> dict[str, Any]:
190
+ try:
191
+ remote = fetch_remote_profile()
192
+ state = cache_valid_profile(remote["bundle"], source="github", remote_sha=remote["sha"])
193
+ bundle = remote["bundle"]
194
+ source = "github"
195
+ remote_error = None
196
+ except DeploymentKitError as error:
197
+ bundle = load_last_known_good()
198
+ state = {"source": "last-known-good"}
199
+ source = "last-known-good"
200
+ remote_error = str(error)
201
+
202
+ plan = plan_import(bundle)
203
+ result: dict[str, Any] = {
204
+ "source": source,
205
+ "targetCount": len(bundle["targets"]),
206
+ "importPlan": plan,
207
+ "cache": state,
208
+ "remoteError": remote_error,
209
+ "mutated": False,
210
+ }
211
+ if apply and not plan["hasConflicts"]:
212
+ applied = apply_import(plan)
213
+ result["importResult"] = applied
214
+ result["mutated"] = bool(applied["mutated"])
215
+ elif apply and plan["hasConflicts"]:
216
+ result["blocked"] = "Local target-definition conflicts require explicit resolution; no target was overwritten."
217
+ return result
218
+
219
+
220
+ def _create_repository(login: str) -> None:
221
+ full_name = f"{login}/{PROFILE_REPOSITORY}"
222
+ result = _run_gh(
223
+ [
224
+ "repo",
225
+ "create",
226
+ full_name,
227
+ "--private",
228
+ "--description",
229
+ "Portable non-secret Project Forge developer environment definitions",
230
+ ]
231
+ )
232
+ if result.returncode != 0:
233
+ raise DeploymentKitError(f"Cannot create personal Project Forge profile repository: {_detail(result)}")
234
+ status = repository_status()
235
+ if status["state"] != "ready" or not status["private"]:
236
+ raise DeploymentKitError("Personal Project Forge profile repository creation returned without a verified private repository.")
237
+
238
+
239
+ def _put_remote_profile(repository: str, bundle: dict[str, Any], *, current_sha: str | None) -> str:
240
+ normalized = validate_bundle(bundle)
241
+ encoded = base64.b64encode((json.dumps(normalized, indent=2) + "\n").encode("utf-8")).decode("ascii")
242
+ args = [
243
+ "api",
244
+ "--method",
245
+ "PUT",
246
+ f"repos/{repository}/contents/{PROFILE_FILE}",
247
+ "-f",
248
+ "message=Update personal Project Forge profile",
249
+ "-f",
250
+ f"content={encoded}",
251
+ ]
252
+ if current_sha:
253
+ args.extend(["-f", f"sha={current_sha}"])
254
+ result = _run_gh(args)
255
+ if result.returncode != 0:
256
+ raise DeploymentKitError(f"Cannot publish personal Project Forge profile: {_detail(result)}")
257
+ try:
258
+ value = json.loads(result.stdout)
259
+ sha = value["content"]["sha"]
260
+ except (KeyError, TypeError, json.JSONDecodeError) as error:
261
+ raise DeploymentKitError("GitHub returned malformed profile publication evidence.") from error
262
+ if not isinstance(sha, str) or not sha:
263
+ raise DeploymentKitError("GitHub profile publication omitted content SHA.")
264
+ return sha
265
+
266
+
267
+ def ensure_profile_repository() -> dict[str, Any]:
268
+ status = repository_status()
269
+ created = False
270
+ if status["state"] == "missing":
271
+ _create_repository(status["login"])
272
+ created = True
273
+ status = repository_status()
274
+ remote = fetch_remote_profile()
275
+ if remote["sha"] is None:
276
+ local = export_bundle(None)
277
+ sha = _put_remote_profile(status["repository"], local, current_sha=None)
278
+ cache = cache_valid_profile(local, source="github", remote_sha=sha)
279
+ return {
280
+ "repository": status["repository"],
281
+ "createdRepository": created,
282
+ "publishedInitialProfile": True,
283
+ "targetCount": len(local["targets"]),
284
+ "cache": cache,
285
+ "mutated": True,
286
+ }
287
+ cache = cache_valid_profile(remote["bundle"], source="github", remote_sha=remote["sha"])
288
+ return {
289
+ "repository": status["repository"],
290
+ "createdRepository": created,
291
+ "publishedInitialProfile": False,
292
+ "targetCount": len(remote["bundle"]["targets"]),
293
+ "cache": cache,
294
+ "mutated": created,
295
+ }
296
+
297
+
298
+ def _merge_non_conflicting(remote: dict[str, Any], local: dict[str, Any]) -> dict[str, Any]:
299
+ remote = validate_bundle(remote)
300
+ local = validate_bundle(local)
301
+ by_name = {item["targetName"]: item for item in remote["targets"]}
302
+ conflicts: list[str] = []
303
+ for item in local["targets"]:
304
+ current = by_name.get(item["targetName"])
305
+ if current is not None and current != item:
306
+ conflicts.append(item["targetName"])
307
+ continue
308
+ by_name[item["targetName"]] = item
309
+ if conflicts:
310
+ raise DeploymentKitError(
311
+ "Personal profile publish found conflicting existing definitions: "
312
+ + ", ".join(sorted(conflicts))
313
+ + ". Resolve explicitly; remote definitions were not overwritten."
314
+ )
315
+ return validate_bundle(
316
+ {
317
+ "schemaVersion": BUNDLE_SCHEMA_VERSION,
318
+ "kind": BUNDLE_KIND,
319
+ "targets": [by_name[name] for name in sorted(by_name)],
320
+ }
321
+ )
322
+
323
+
324
+ def publish_profile() -> dict[str, Any]:
325
+ status = repository_status()
326
+ if status["state"] != "ready":
327
+ raise DeploymentKitError("Create/ensure the private personal Project Forge profile repository before publishing.")
328
+ remote = fetch_remote_profile()
329
+ local = export_bundle(None)
330
+ merged = _merge_non_conflicting(remote["bundle"], local)
331
+ if merged == remote["bundle"]:
332
+ cache = cache_valid_profile(merged, source="github", remote_sha=remote["sha"])
333
+ return {
334
+ "repository": status["repository"],
335
+ "status": "already-current",
336
+ "targetCount": len(merged["targets"]),
337
+ "cache": cache,
338
+ "mutated": False,
339
+ }
340
+ sha = _put_remote_profile(status["repository"], merged, current_sha=remote["sha"])
341
+ cache = cache_valid_profile(merged, source="github", remote_sha=sha)
342
+ return {
343
+ "repository": status["repository"],
344
+ "status": "published",
345
+ "targetCount": len(merged["targets"]),
346
+ "cache": cache,
347
+ "mutated": True,
348
+ }
@@ -0,0 +1,204 @@
1
+ """User-scoped logical target profiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import json
7
+ import os
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .errors import DeploymentKitError
13
+
14
+
15
+ PROFILE_SCHEMA_VERSION = 1
16
+ TARGET_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
17
+ ALIAS_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
18
+ USER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]{0,31}$")
19
+ HOST_LABEL_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$")
20
+ SUPPORTED_TARGET_OS = {"ubuntu"}
21
+
22
+
23
+ def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
24
+ value: dict[str, Any] = {}
25
+ for key, item in pairs:
26
+ if key in value:
27
+ raise DeploymentKitError(f"Duplicate JSON property: {key}")
28
+ value[key] = item
29
+ return value
30
+
31
+
32
+ def config_root(home: Path | None = None) -> Path:
33
+ override = os.environ.get("PROJECT_FORGE_HOME")
34
+ if override:
35
+ candidate = Path(override).expanduser()
36
+ if not candidate.is_absolute():
37
+ raise DeploymentKitError("PROJECT_FORGE_HOME must be an absolute path.")
38
+ return candidate.resolve()
39
+ return ((home or Path.home()).resolve() / ".project-forge").resolve()
40
+
41
+
42
+ def validate_target_name(name: str) -> str:
43
+ if not isinstance(name, str) or TARGET_NAME_RE.fullmatch(name) is None:
44
+ raise DeploymentKitError(
45
+ "Target name must use lowercase letters, digits, and hyphens, and must start with a letter or digit."
46
+ )
47
+ return name
48
+
49
+
50
+ def target_profile_path(name: str, home: Path | None = None) -> Path:
51
+ validate_target_name(name)
52
+ return config_root(home) / "targets" / f"{name}.json"
53
+
54
+
55
+ def _validate_host_name(value: Any) -> str:
56
+ if not isinstance(value, str) or not value or len(value) > 253:
57
+ raise DeploymentKitError("hostName must be a DNS/mDNS hostname.")
58
+ if value.endswith("."):
59
+ value = value[:-1]
60
+ try:
61
+ ipaddress.ip_address(value)
62
+ except ValueError:
63
+ pass
64
+ else:
65
+ raise DeploymentKitError(
66
+ "hostName must be a stable DNS/mDNS name, not an IP address; use addressOverride for a verified temporary IP."
67
+ )
68
+ labels = value.split(".")
69
+ if any(HOST_LABEL_RE.fullmatch(label) is None for label in labels):
70
+ raise DeploymentKitError("hostName must be a valid DNS/mDNS hostname.")
71
+ return value.lower()
72
+
73
+
74
+ def _ssh_path(value: Any, field: str, home: Path) -> Path:
75
+ if not isinstance(value, str) or not value.strip():
76
+ raise DeploymentKitError(f"{field} must not be empty.")
77
+ if value == "~":
78
+ candidate = home
79
+ elif value.startswith("~/") or value.startswith("~\\"):
80
+ candidate = home / value[2:]
81
+ else:
82
+ candidate = Path(value)
83
+ if not candidate.is_absolute():
84
+ raise DeploymentKitError(f"{field} must be absolute or home-relative with '~'.")
85
+ resolved = candidate.resolve()
86
+ ssh_dir = (home / ".ssh").resolve()
87
+ if resolved == ssh_dir or not resolved.is_relative_to(ssh_dir):
88
+ raise DeploymentKitError(f"{field} must be a file inside the user's SSH directory: {ssh_dir}")
89
+ return resolved
90
+
91
+
92
+ def validate_profile(
93
+ value: dict[str, Any],
94
+ *,
95
+ expected_target_name: str | None = None,
96
+ home: Path | None = None,
97
+ ) -> dict[str, Any]:
98
+ expected_fields = {
99
+ "schemaVersion",
100
+ "targetName",
101
+ "targetOs",
102
+ "hostName",
103
+ "sshAlias",
104
+ "user",
105
+ "port",
106
+ "identityFile",
107
+ "knownHostsFile",
108
+ "addressOverride",
109
+ }
110
+ if not isinstance(value, dict) or set(value) != expected_fields:
111
+ raise DeploymentKitError(
112
+ "Target profile must contain exactly: " + ", ".join(sorted(expected_fields)) + "."
113
+ )
114
+ if type(value["schemaVersion"]) is not int or value["schemaVersion"] != PROFILE_SCHEMA_VERSION:
115
+ raise DeploymentKitError("Unsupported target profile schemaVersion.")
116
+
117
+ target_name = validate_target_name(value["targetName"])
118
+ if expected_target_name is not None and target_name != expected_target_name:
119
+ raise DeploymentKitError(
120
+ f"Target profile mismatch: expected '{expected_target_name}', got '{target_name}'."
121
+ )
122
+
123
+ target_os = value["targetOs"]
124
+ if target_os not in SUPPORTED_TARGET_OS:
125
+ raise DeploymentKitError(
126
+ f"Unsupported targetOs '{target_os}'. Initial support is limited to: {', '.join(sorted(SUPPORTED_TARGET_OS))}."
127
+ )
128
+
129
+ host_name = _validate_host_name(value["hostName"])
130
+ alias = value["sshAlias"]
131
+ if not isinstance(alias, str) or ALIAS_RE.fullmatch(alias) is None:
132
+ raise DeploymentKitError("sshAlias contains unsupported characters.")
133
+
134
+ username = value["user"]
135
+ if not isinstance(username, str) or USER_RE.fullmatch(username) is None:
136
+ raise DeploymentKitError("user contains unsupported characters.")
137
+
138
+ port = value["port"]
139
+ if type(port) is not int or not 1 <= port <= 65535:
140
+ raise DeploymentKitError("port must be an integer from 1 through 65535.")
141
+
142
+ user_home = (home or Path.home()).resolve()
143
+ identity_file = _ssh_path(value["identityFile"], "identityFile", user_home)
144
+ known_hosts_file = _ssh_path(value["knownHostsFile"], "knownHostsFile", user_home)
145
+ if identity_file == known_hosts_file:
146
+ raise DeploymentKitError("identityFile and knownHostsFile must be different paths.")
147
+
148
+ address_override = value["addressOverride"]
149
+ if address_override is not None:
150
+ if not isinstance(address_override, str):
151
+ raise DeploymentKitError("addressOverride must be null or an IP address.")
152
+ try:
153
+ parsed = ipaddress.ip_address(address_override)
154
+ except ValueError as error:
155
+ raise DeploymentKitError("addressOverride must be a valid IPv4 or IPv6 address.") from error
156
+ if isinstance(parsed, ipaddress.IPv6Address) and parsed.scope_id is not None:
157
+ raise DeploymentKitError("addressOverride must not include an IPv6 scope identifier.")
158
+ address_override = str(parsed)
159
+
160
+ return {
161
+ "schemaVersion": PROFILE_SCHEMA_VERSION,
162
+ "targetName": target_name,
163
+ "targetOs": target_os,
164
+ "hostName": host_name,
165
+ "sshAlias": alias,
166
+ "user": username,
167
+ "port": port,
168
+ "identityFile": str(identity_file),
169
+ "knownHostsFile": str(known_hosts_file),
170
+ "addressOverride": address_override,
171
+ }
172
+
173
+
174
+ def load_profile(name: str, *, home: Path | None = None) -> dict[str, Any]:
175
+ path = target_profile_path(name, home)
176
+ if path.is_symlink() or path.parent.is_symlink():
177
+ raise DeploymentKitError("Target profile and its containing directory must not be symbolic links.")
178
+ if not path.is_file():
179
+ raise DeploymentKitError(f"Target profile is missing: {path}")
180
+ try:
181
+ with path.open(encoding="utf-8") as source:
182
+ value = json.load(source, object_pairs_hook=_unique_object)
183
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
184
+ raise DeploymentKitError(f"Cannot read target profile {path}: {error}") from error
185
+ return validate_profile(value, expected_target_name=name, home=home)
186
+
187
+
188
+ def save_profile(value: dict[str, Any], *, home: Path | None = None) -> Path:
189
+ normalized = validate_profile(value, home=home)
190
+ path = target_profile_path(normalized["targetName"], home)
191
+ directory = path.parent
192
+ if directory.exists() and (directory.is_symlink() or not directory.is_dir()):
193
+ raise DeploymentKitError(f"Target profile directory is not a real directory: {directory}")
194
+ directory.mkdir(parents=True, exist_ok=True, mode=0o700)
195
+ if os.name != "nt":
196
+ directory.chmod(0o700)
197
+ if path.exists() and path.is_symlink():
198
+ raise DeploymentKitError(f"Refusing to replace symbolic-link target profile: {path}")
199
+ temporary = path.with_suffix(".tmp")
200
+ temporary.write_text(json.dumps(normalized, indent=2) + "\n", encoding="utf-8")
201
+ if os.name != "nt":
202
+ temporary.chmod(0o600)
203
+ temporary.replace(path)
204
+ return path
@@ -0,0 +1,114 @@
1
+ """Read-only exact-image registry access verification for logical targets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shlex
7
+ import subprocess
8
+ from typing import Any
9
+
10
+ from .errors import DeploymentKitError
11
+ from .profiles import load_profile
12
+ from .ssh import ssh_args, verify_access
13
+
14
+
15
+ _IMAGE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/:+-]*@sha256:[0-9a-f]{64}$")
16
+
17
+ _REMOTE_SCRIPT = r'''#!/usr/bin/env bash
18
+ set -Eeuo pipefail
19
+
20
+ unset DOCKER_CONFIG
21
+
22
+ IMAGE="$1"
23
+
24
+ [[ "$IMAGE" =~ ^[A-Za-z0-9][A-Za-z0-9._/:+-]*@sha256:[0-9a-f]{64}$ ]] || {
25
+ echo "Image must be an immutable sha256 digest reference." >&2
26
+ exit 41
27
+ }
28
+
29
+ command -v docker >/dev/null 2>&1 || {
30
+ echo "Docker CLI is unavailable on the target." >&2
31
+ exit 42
32
+ }
33
+
34
+ config_dir="$HOME/.docker"
35
+ config_file="$config_dir/config.json"
36
+ credential_state="absent"
37
+
38
+ if [[ -e "$config_file" ]]; then
39
+ [[ -d "$config_dir" && ! -L "$config_dir" && -f "$config_file" && ! -L "$config_file" ]] || {
40
+ echo "Docker credential path is not a regular protected directory/file." >&2
41
+ exit 43
42
+ }
43
+ dir_mode="$(stat -c '%a' "$config_dir")"
44
+ file_mode="$(stat -c '%a' "$config_file")"
45
+ current_uid="$(id -u)"
46
+ if (( (8#$dir_mode & 077) != 0 || (8#$file_mode & 077) != 0 )) ||
47
+ [[ "$(stat -c '%u' "$config_dir")" != "$current_uid" || "$(stat -c '%u' "$config_file")" != "$current_uid" ]]; then
48
+ echo "Docker credentials must be owned by the deployment account and deny group/other access." >&2
49
+ exit 43
50
+ fi
51
+ export DOCKER_CONFIG="$config_dir"
52
+ credential_state="protected"
53
+ fi
54
+
55
+ if ! docker manifest inspect "$IMAGE" </dev/null >/dev/null 2>&1; then
56
+ echo "Registry manifest access failed for the exact image." >&2
57
+ exit 44
58
+ fi
59
+
60
+ printf 'ACCESS\t%s\n' "$credential_state"
61
+ '''
62
+
63
+
64
+ def normalize_registry_image(value: str) -> str:
65
+ if not isinstance(value, str) or _IMAGE_RE.fullmatch(value) is None:
66
+ raise DeploymentKitError(
67
+ "image must be an immutable image reference ending in @sha256:<64 lowercase hex>."
68
+ )
69
+ return value
70
+
71
+
72
+ def build_remote_script() -> str:
73
+ return _REMOTE_SCRIPT
74
+
75
+
76
+ def _parse_access(output: str) -> str:
77
+ lines = [line for line in output.splitlines() if line]
78
+ if len(lines) != 1:
79
+ raise DeploymentKitError("Malformed registry access verification output.")
80
+ parts = lines[0].split("\t")
81
+ if len(parts) != 2 or parts[0] != "ACCESS" or parts[1] not in {"absent", "protected"}:
82
+ raise DeploymentKitError("Malformed registry access verification output.")
83
+ return parts[1]
84
+
85
+
86
+ def verify_registry_access(name: str, image: str) -> dict[str, Any]:
87
+ exact_image = normalize_registry_image(image)
88
+ profile = load_profile(name)
89
+ access = verify_access(profile)
90
+ command = ssh_args(
91
+ profile,
92
+ access["address"],
93
+ command="bash -s -- " + shlex.quote(exact_image),
94
+ )
95
+ completed = subprocess.run(
96
+ command,
97
+ input=build_remote_script().encode("utf-8"),
98
+ check=False,
99
+ capture_output=True,
100
+ )
101
+ stdout = completed.stdout.decode("utf-8", errors="replace")
102
+ stderr = completed.stderr.decode("utf-8", errors="replace")
103
+ if completed.returncode != 0:
104
+ detail = stderr.strip() or stdout.strip() or f"exit {completed.returncode}"
105
+ raise DeploymentKitError(f"Read-only registry access verification failed: {detail}")
106
+
107
+ credential_state = _parse_access(stdout)
108
+ return {
109
+ "targetName": name,
110
+ "image": exact_image,
111
+ "manifestAccess": "verified",
112
+ "credentialConfig": credential_state,
113
+ "mutated": False,
114
+ }