@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,205 @@
1
+ """Non-destructive workstation SQLite lifecycle helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import sqlite3
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .errors import DeploymentKitError
12
+ from .profiles import config_root
13
+
14
+
15
+ _SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
16
+ _REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
17
+ _DB_FILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
18
+ _METADATA_FILE = "sqlite-local.json"
19
+
20
+
21
+ def _slug(value: str, field: str) -> str:
22
+ if not isinstance(value, str) or _SLUG_RE.fullmatch(value) is None:
23
+ raise DeploymentKitError(f"{field} must use lowercase letters, digits, and internal hyphens.")
24
+ return value
25
+
26
+
27
+ def _owner_repo(value: str) -> str:
28
+ if not isinstance(value, str) or _REPO_RE.fullmatch(value) is None:
29
+ raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
30
+ return value
31
+
32
+
33
+ def _database_file(value: str) -> str:
34
+ if not isinstance(value, str) or _DB_FILE_RE.fullmatch(value) is None or value in {".", ".."}:
35
+ raise DeploymentKitError("databaseFile must be a safe file name without path separators.")
36
+ return value
37
+
38
+
39
+ def _state_root(app_slug: str, environment: str) -> Path:
40
+ return config_root() / "local-apps" / app_slug / environment
41
+
42
+
43
+ def _data_dir(app_slug: str, environment: str) -> Path:
44
+ return _state_root(app_slug, environment) / "data"
45
+
46
+
47
+ def _metadata_path(app_slug: str, environment: str) -> Path:
48
+ return _state_root(app_slug, environment) / _METADATA_FILE
49
+
50
+
51
+ def _database_path(app_slug: str, environment: str, database_file: str) -> Path:
52
+ return _data_dir(app_slug, environment) / database_file
53
+
54
+
55
+ def _load_metadata(app_slug: str, environment: str) -> dict[str, Any] | None:
56
+ path = _metadata_path(app_slug, environment)
57
+ if not path.is_file():
58
+ return None
59
+ try:
60
+ value = json.loads(path.read_text(encoding="utf-8"))
61
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
62
+ raise DeploymentKitError(f"Cannot read local SQLite metadata {path}: {error}") from error
63
+ if not isinstance(value, dict) or value.get("schemaVersion") != 1:
64
+ raise DeploymentKitError(f"Unsupported local SQLite metadata: {path}")
65
+ return value
66
+
67
+
68
+ def _write_metadata(path: Path, value: dict[str, Any]) -> None:
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+ temporary = path.with_suffix(".tmp")
71
+ temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
72
+ temporary.replace(path)
73
+
74
+
75
+ def _quick_check(path: Path) -> str:
76
+ try:
77
+ with sqlite3.connect(path) as connection:
78
+ row = connection.execute("PRAGMA quick_check").fetchone()
79
+ except sqlite3.DatabaseError as error:
80
+ raise DeploymentKitError(f"Local SQLite database is not valid: {path}: {error}") from error
81
+ result = str(row[0]) if row else "missing-result"
82
+ if result.lower() != "ok":
83
+ raise DeploymentKitError(f"Local SQLite quick_check failed for {path}: {result}")
84
+ return result
85
+
86
+
87
+ def local_sqlite_status(
88
+ *, owner_repo: str, environment: str, app_slug: str, database_file: str
89
+ ) -> dict[str, Any]:
90
+ owner_repo = _owner_repo(owner_repo)
91
+ environment = _slug(environment, "environment")
92
+ app_slug = _slug(app_slug, "appSlug")
93
+ database_file = _database_file(database_file)
94
+ metadata = _load_metadata(app_slug, environment)
95
+ path = _database_path(app_slug, environment, database_file)
96
+
97
+ if metadata is not None:
98
+ expected = {
99
+ "ownerRepo": owner_repo,
100
+ "environment": environment,
101
+ "appSlug": app_slug,
102
+ "databaseFile": database_file,
103
+ }
104
+ for key, value in expected.items():
105
+ if metadata.get(key) != value:
106
+ raise DeploymentKitError(
107
+ f"Local SQLite metadata ownership mismatch for {app_slug}/{environment}: {key}."
108
+ )
109
+
110
+ if path.exists() and metadata is None:
111
+ return {
112
+ "schemaVersion": 1,
113
+ "ownerRepo": owner_repo,
114
+ "environment": environment,
115
+ "appSlug": app_slug,
116
+ "databaseFile": database_file,
117
+ "databasePath": str(path),
118
+ "state": "unmanaged-existing",
119
+ "managed": False,
120
+ "present": True,
121
+ "valid": False,
122
+ "mutated": False,
123
+ }
124
+
125
+ if not path.exists():
126
+ return {
127
+ "schemaVersion": 1,
128
+ "ownerRepo": owner_repo,
129
+ "environment": environment,
130
+ "appSlug": app_slug,
131
+ "databaseFile": database_file,
132
+ "databasePath": str(path),
133
+ "state": "missing",
134
+ "managed": metadata is not None,
135
+ "present": False,
136
+ "valid": False,
137
+ "mutated": False,
138
+ }
139
+
140
+ _quick_check(path)
141
+ return {
142
+ "schemaVersion": 1,
143
+ "ownerRepo": owner_repo,
144
+ "environment": environment,
145
+ "appSlug": app_slug,
146
+ "databaseFile": database_file,
147
+ "databasePath": str(path),
148
+ "state": "ready",
149
+ "managed": True,
150
+ "present": True,
151
+ "valid": True,
152
+ "bytes": path.stat().st_size,
153
+ "mutated": False,
154
+ }
155
+
156
+
157
+ def ensure_local_sqlite(
158
+ *, owner_repo: str, environment: str, app_slug: str, database_file: str
159
+ ) -> dict[str, Any]:
160
+ owner_repo = _owner_repo(owner_repo)
161
+ environment = _slug(environment, "environment")
162
+ app_slug = _slug(app_slug, "appSlug")
163
+ database_file = _database_file(database_file)
164
+
165
+ status = local_sqlite_status(
166
+ owner_repo=owner_repo,
167
+ environment=environment,
168
+ app_slug=app_slug,
169
+ database_file=database_file,
170
+ )
171
+ if status["state"] == "unmanaged-existing":
172
+ raise DeploymentKitError(
173
+ "Refusing to adopt an existing unmanaged local SQLite database automatically. "
174
+ "Review its ownership/data before using or moving it."
175
+ )
176
+ if status["state"] == "ready":
177
+ return status
178
+
179
+ path = _database_path(app_slug, environment, database_file)
180
+ path.parent.mkdir(parents=True, exist_ok=True)
181
+ try:
182
+ with sqlite3.connect(path) as connection:
183
+ connection.execute("PRAGMA user_version")
184
+ connection.commit()
185
+ except sqlite3.DatabaseError as error:
186
+ raise DeploymentKitError(f"Unable to create local SQLite database {path}: {error}") from error
187
+
188
+ metadata = {
189
+ "schemaVersion": 1,
190
+ "ownerRepo": owner_repo,
191
+ "environment": environment,
192
+ "appSlug": app_slug,
193
+ "databaseFile": database_file,
194
+ }
195
+ _write_metadata(_metadata_path(app_slug, environment), metadata)
196
+ _quick_check(path)
197
+ result = local_sqlite_status(
198
+ owner_repo=owner_repo,
199
+ environment=environment,
200
+ app_slug=app_slug,
201
+ database_file=database_file,
202
+ )
203
+ result["mutated"] = True
204
+ result["created"] = True
205
+ return result
@@ -0,0 +1,181 @@
1
+ """Portable non-secret target-definition export/import."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .errors import DeploymentKitError
10
+ from .profiles import config_root, load_profile, save_profile, validate_target_name
11
+
12
+
13
+ BUNDLE_SCHEMA_VERSION = 1
14
+ BUNDLE_KIND = "project-forge-environment-bundle"
15
+
16
+
17
+ def _portable_target(profile: dict[str, Any]) -> dict[str, Any]:
18
+ return {
19
+ "targetName": profile["targetName"],
20
+ "targetOs": profile["targetOs"],
21
+ "hostName": profile["hostName"],
22
+ "sshAlias": profile["sshAlias"],
23
+ "user": profile["user"],
24
+ "port": profile["port"],
25
+ }
26
+
27
+
28
+ def list_target_names() -> list[str]:
29
+ directory = config_root() / "targets"
30
+ if not directory.exists():
31
+ return []
32
+ if directory.is_symlink() or not directory.is_dir():
33
+ raise DeploymentKitError(f"Target profile directory is not a real directory: {directory}")
34
+ names: list[str] = []
35
+ for path in sorted(directory.glob("*.json")):
36
+ if path.is_symlink() or not path.is_file():
37
+ raise DeploymentKitError(f"Target profile entry is not a real file: {path}")
38
+ names.append(validate_target_name(path.stem))
39
+ return names
40
+
41
+
42
+ def export_bundle(names: list[str] | None = None) -> dict[str, Any]:
43
+ selected = list_target_names() if names is None else names
44
+ normalized = sorted(dict.fromkeys(validate_target_name(name) for name in selected))
45
+ return {
46
+ "schemaVersion": BUNDLE_SCHEMA_VERSION,
47
+ "kind": BUNDLE_KIND,
48
+ "targets": [_portable_target(load_profile(name)) for name in normalized],
49
+ }
50
+
51
+
52
+ def write_bundle(bundle: dict[str, Any], path: Path) -> Path:
53
+ normalized = validate_bundle(bundle)
54
+ candidate = path.expanduser().resolve()
55
+ if candidate.exists():
56
+ raise DeploymentKitError(f"Refusing to overwrite existing bundle: {candidate}")
57
+ candidate.parent.mkdir(parents=True, exist_ok=True)
58
+ candidate.write_text(json.dumps(normalized, indent=2) + "\n", encoding="utf-8")
59
+ return candidate
60
+
61
+
62
+ def validate_bundle(value: Any) -> dict[str, Any]:
63
+ if not isinstance(value, dict) or set(value) != {"schemaVersion", "kind", "targets"}:
64
+ raise DeploymentKitError("Portable environment bundle has an unexpected shape.")
65
+ if value["schemaVersion"] != BUNDLE_SCHEMA_VERSION or value["kind"] != BUNDLE_KIND:
66
+ raise DeploymentKitError("Unsupported portable environment bundle schema/kind.")
67
+ targets = value["targets"]
68
+ if not isinstance(targets, list):
69
+ raise DeploymentKitError("Portable environment bundle targets must be an array.")
70
+
71
+ expected = {"targetName", "targetOs", "hostName", "sshAlias", "user", "port"}
72
+ seen: set[str] = set()
73
+ normalized: list[dict[str, Any]] = []
74
+ for item in targets:
75
+ if not isinstance(item, dict) or set(item) != expected:
76
+ raise DeploymentKitError("Portable target definition has an unexpected shape.")
77
+ name = validate_target_name(item["targetName"])
78
+ if name in seen:
79
+ raise DeploymentKitError(f"Duplicate portable target definition: {name}")
80
+ seen.add(name)
81
+ machine = _machine_profile(item)
82
+ normalized_profile = _validate_machine_profile(machine)
83
+ normalized.append(_portable_target(normalized_profile))
84
+ return {"schemaVersion": BUNDLE_SCHEMA_VERSION, "kind": BUNDLE_KIND, "targets": normalized}
85
+
86
+
87
+ def load_bundle(path: Path) -> dict[str, Any]:
88
+ candidate = path.expanduser().resolve()
89
+ if candidate.is_symlink() or not candidate.is_file():
90
+ raise DeploymentKitError(f"Portable environment bundle is missing or unsafe: {candidate}")
91
+ try:
92
+ value = json.loads(candidate.read_text(encoding="utf-8"))
93
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
94
+ raise DeploymentKitError(f"Cannot read portable environment bundle {candidate}: {error}") from error
95
+ return validate_bundle(value)
96
+
97
+
98
+ def _machine_paths(name: str) -> tuple[str, str]:
99
+ base = Path.home().resolve() / ".ssh" / "project-forge" / validate_target_name(name)
100
+ return str(base / "id_ed25519"), str(base / "known_hosts")
101
+
102
+
103
+ def _machine_profile(item: dict[str, Any]) -> dict[str, Any]:
104
+ identity, known_hosts = _machine_paths(item["targetName"])
105
+ return {
106
+ "schemaVersion": 1,
107
+ **item,
108
+ "identityFile": identity,
109
+ "knownHostsFile": known_hosts,
110
+ "addressOverride": None,
111
+ }
112
+
113
+
114
+ def _validate_machine_profile(value: dict[str, Any]) -> dict[str, Any]:
115
+ from .profiles import validate_profile
116
+
117
+ return validate_profile(value, expected_target_name=value["targetName"])
118
+
119
+
120
+ def plan_import(bundle: dict[str, Any]) -> dict[str, Any]:
121
+ normalized = validate_bundle(bundle)
122
+ items: list[dict[str, Any]] = []
123
+ for portable in normalized["targets"]:
124
+ desired = _validate_machine_profile(_machine_profile(portable))
125
+ path = config_root() / "targets" / f"{desired['targetName']}.json"
126
+ if path.exists():
127
+ current = load_profile(desired["targetName"])
128
+ current_portable = _portable_target(current)
129
+ if current_portable == portable:
130
+ state = "already-present"
131
+ else:
132
+ state = "conflict"
133
+ else:
134
+ state = "create"
135
+ items.append(
136
+ {
137
+ "targetName": desired["targetName"],
138
+ "state": state,
139
+ "portable": portable,
140
+ "identityFile": desired["identityFile"],
141
+ "knownHostsFile": desired["knownHostsFile"],
142
+ }
143
+ )
144
+ return {
145
+ "schemaVersion": BUNDLE_SCHEMA_VERSION,
146
+ "kind": BUNDLE_KIND,
147
+ "targets": items,
148
+ "hasConflicts": any(item["state"] == "conflict" for item in items),
149
+ "mutated": False,
150
+ }
151
+
152
+
153
+ def apply_import(plan: dict[str, Any]) -> dict[str, Any]:
154
+ if plan.get("kind") != BUNDLE_KIND or not isinstance(plan.get("targets"), list):
155
+ raise DeploymentKitError("Portable environment import plan is malformed.")
156
+ if plan.get("hasConflicts"):
157
+ raise DeploymentKitError("Portable environment import has conflicts; existing target definitions are never overwritten.")
158
+
159
+ created: list[str] = []
160
+ existing: list[str] = []
161
+ for item in plan["targets"]:
162
+ state = item.get("state")
163
+ if state == "already-present":
164
+ existing.append(item["targetName"])
165
+ continue
166
+ if state != "create":
167
+ raise DeploymentKitError("Portable environment import plan contains an unsupported state.")
168
+ desired = _validate_machine_profile(_machine_profile(item["portable"]))
169
+ path = config_root() / "targets" / f"{desired['targetName']}.json"
170
+ if path.exists():
171
+ raise DeploymentKitError(
172
+ f"Target profile appeared after preview; re-run preview before importing: {desired['targetName']}"
173
+ )
174
+ save_profile(desired)
175
+ created.append(desired["targetName"])
176
+ return {
177
+ "created": created,
178
+ "alreadyPresent": existing,
179
+ "mutated": bool(created),
180
+ "next": "Imported definitions still need this workstation's SSH identity/trust before remote readiness.",
181
+ }
@@ -0,0 +1,70 @@
1
+ """One-time migration of an existing workstation into the personal Project Forge profile model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .errors import DeploymentKitError
8
+ from .portable_profile import list_target_names
9
+ from .profile_repository import ensure_profile_repository, publish_profile, sync_profile
10
+ from .profiles import load_profile
11
+ from .ssh import verify_access
12
+
13
+
14
+ PERSONAL_PROFILE_MIGRATION_PHRASE = "[PERSONAL PROFILE MIGRATION]"
15
+
16
+
17
+ def migrate_current_workstation() -> dict[str, Any]:
18
+ """Adopt existing portable target definitions without replacing machine identity/trust.
19
+
20
+ Remote verification is evidence only: an offline/unreachable target does not roll back
21
+ a successfully migrated personal profile/cache. Cleanup of legacy server credentials is
22
+ intentionally outside this operation and requires a proven new deployment path first.
23
+ """
24
+
25
+ local_targets = list_target_names()
26
+ ensured = ensure_profile_repository()
27
+ published = publish_profile()
28
+ synced = sync_profile(apply=True)
29
+ if synced.get("blocked"):
30
+ raise DeploymentKitError(str(synced["blocked"]))
31
+
32
+ checks: list[dict[str, Any]] = []
33
+ for name in local_targets:
34
+ try:
35
+ evidence = verify_access(load_profile(name))
36
+ checks.append(
37
+ {
38
+ "targetName": name,
39
+ "state": "ready",
40
+ "strictVerification": evidence,
41
+ }
42
+ )
43
+ except DeploymentKitError as error:
44
+ checks.append(
45
+ {
46
+ "targetName": name,
47
+ "state": "not-verified",
48
+ "detail": str(error),
49
+ }
50
+ )
51
+
52
+ return {
53
+ "profileRepository": ensured["repository"],
54
+ "localTargets": local_targets,
55
+ "profileEnsure": ensured,
56
+ "profilePublish": published,
57
+ "profileSync": synced,
58
+ "targetVerification": checks,
59
+ "machineKeysAndTrust": "preserved",
60
+ "legacyServerRegistryCredentials": "unchanged-until-new-deployment-path-is-proven",
61
+ "mutated": bool(
62
+ ensured.get("mutated")
63
+ or published.get("mutated")
64
+ or synced.get("mutated")
65
+ ),
66
+ "next": (
67
+ "Use the normal workstation-mediated Staging deployment path when ready; "
68
+ "remove legacy server-side registry credentials only after proving they are unused."
69
+ ),
70
+ }