@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.
- package/README.md +134 -2
- package/assets/components.json +33 -0
- package/assets/core/distribution.json +66 -0
- package/assets/deployment/distribution.json +54 -0
- package/assets/generated/ASSEMBLY.json +557 -0
- package/assets/generated/core/core/agents/coach.agent.md +69 -0
- package/assets/generated/core/core/agents/reviewer.agent.md +85 -0
- package/assets/generated/core/core/instructions/project-forge-docs.instructions.md +45 -0
- package/assets/generated/core/core/instructions/project-forge-workflow.instructions.md +79 -0
- package/assets/generated/core/core/learning/CODING_DRILLS.md +35 -0
- package/assets/generated/core/core/learning/DIAGRAM_GUIDE.md +59 -0
- package/assets/generated/core/core/learning/ENGINEERING_LEARNING_WORKBOOK.md +108 -0
- package/assets/generated/core/core/learning/LEARNING_MAP.md +108 -0
- package/assets/generated/core/core/learning/LEARNING_PROGRESS.md +39 -0
- package/assets/generated/core/core/learning/RESOURCE_CATALOG.md +25 -0
- package/assets/generated/core/core/learning/SQL_DRILLS.md +90 -0
- package/assets/generated/core/core/learning/SYSTEM_DESIGN_DRILLS.md +301 -0
- package/assets/generated/core/core/process/COACHING_GUIDE.md +92 -0
- package/assets/generated/core/core/process/DATABASE_LEARNING_WORKBENCH.md +397 -0
- package/assets/generated/core/core/process/DEPLOYMENT_ENABLEMENT.md +166 -0
- package/assets/generated/core/core/process/GUIDED_NEXT_STEPS.md +48 -0
- package/assets/generated/core/core/process/HUMAN_GATES.md +83 -0
- package/assets/generated/core/core/process/INTERACTIVE_SESSION_GUIDE.md +64 -0
- package/assets/generated/core/core/process/PROJECT_DOCUMENTATION_FRAMEWORK.md +326 -0
- package/assets/generated/core/core/process/PROJECT_FORGE_ENGINEERING_GUIDE.md +308 -0
- package/assets/generated/core/core/process/PR_WORKFLOW.md +55 -0
- package/assets/generated/core/core/process/SETUP_ACCEPTANCE_CHECKLIST.md +72 -0
- package/assets/generated/core/core/process/SYSTEM_DESIGN_LEARNING_WORKBENCH.md +520 -0
- package/assets/generated/core/core/process/WORK_ITEM_MODEL.md +56 -0
- package/assets/generated/core/core/skills/cleanup-branches/SKILL.md +190 -0
- package/assets/generated/core/core/skills/coach/SKILL.md +174 -0
- package/assets/generated/core/core/skills/code-walkthrough/SKILL.md +197 -0
- package/assets/generated/core/core/skills/core-sync/SKILL.md +178 -0
- package/assets/generated/core/core/skills/db-lab/SKILL.md +245 -0
- package/assets/generated/core/core/skills/define-feature/SKILL.md +144 -0
- package/assets/generated/core/core/skills/deliver/SKILL.md +397 -0
- package/assets/generated/core/core/skills/deploy/SKILL.md +188 -0
- package/assets/generated/core/core/skills/deployment-readiness/SKILL.md +234 -0
- package/assets/generated/core/core/skills/developer-env-mode/SKILL.md +231 -0
- package/assets/generated/core/core/skills/docs/SKILL.md +116 -0
- package/assets/generated/core/core/skills/grill-me/SKILL.md +77 -0
- package/assets/generated/core/core/skills/grill-with-docs/SKILL.md +90 -0
- package/assets/generated/core/core/skills/learning-review/SKILL.md +90 -0
- package/assets/generated/core/core/skills/next-step/SKILL.md +221 -0
- package/assets/generated/core/core/skills/progress/SKILL.md +207 -0
- package/assets/generated/core/core/skills/project-forge-update/SKILL.md +228 -0
- package/assets/generated/core/core/skills/pull-latest/SKILL.md +166 -0
- package/assets/generated/core/core/skills/refine-requirements/SKILL.md +207 -0
- package/assets/generated/core/core/skills/setup/SKILL.md +362 -0
- package/assets/generated/core/core/skills/start-work/SKILL.md +190 -0
- package/assets/generated/core/core/skills/system-lab/SKILL.md +333 -0
- package/assets/generated/core/core/skills/tear-down/SKILL.md +140 -0
- package/assets/generated/core/core/skills/upstream-fix/SKILL.md +221 -0
- package/assets/generated/core/core/skills/wait-what/SKILL.md +86 -0
- package/assets/generated/core/core/templates/ADR_TEMPLATE.md +41 -0
- package/assets/generated/core/core/templates/FEATURE_BRIEF_TEMPLATE.md +27 -0
- package/assets/generated/core/core/templates/PRODUCT_BRIEF_TEMPLATE.md +27 -0
- package/assets/generated/core/core/templates/PULL_REQUEST_TEMPLATE.md +37 -0
- package/assets/generated/core/core/templates/REQUIREMENTS_TEMPLATE.md +17 -0
- package/assets/generated/core/profiles/greenfield/project-forge-workspace.json +12 -0
- package/assets/generated/core/tools/db_lab/sqlite_workbench.py +463 -0
- package/assets/generated/core/tools/docs_site/docs_site.py +512 -0
- package/assets/generated/core/tools/package/check_baseline.py +102 -0
- package/assets/generated/core/tools/package/check_upstream.py +171 -0
- package/assets/generated/core/tools/system_lab/system_workbench.py +243 -0
- package/assets/generated/deployment/distribution/project-forge-deploy.py +8 -0
- package/assets/generated/deployment/docs/DEVELOPER_ENVIRONMENTS.md +335 -0
- package/assets/generated/deployment/docs/ENVIRONMENT_PROOF.md +95 -0
- package/assets/generated/deployment/docs/WORKSPACE_INSTANCES.md +154 -0
- package/assets/generated/deployment/docs/images/developer-environment-journey.svg +56 -0
- package/assets/generated/deployment/docs/images/personal-environment-architecture.svg +54 -0
- package/assets/generated/deployment/docs/images/poc-environment-model.svg +61 -0
- package/assets/generated/deployment/src/project_forge_deployment/__init__.py +3 -0
- package/assets/generated/deployment/src/project_forge_deployment/__main__.py +3 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_cli.py +163 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_runtime.py +455 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_runtime_remote.py +416 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_runtime_workstation.py +136 -0
- package/assets/generated/deployment/src/project_forge_deployment/box_platform.py +208 -0
- package/assets/generated/deployment/src/project_forge_deployment/box_platform_assets.py +100 -0
- package/assets/generated/deployment/src/project_forge_deployment/box_platform_remote.py +271 -0
- package/assets/generated/deployment/src/project_forge_deployment/cli.py +437 -0
- package/assets/generated/deployment/src/project_forge_deployment/doctor.py +121 -0
- package/assets/generated/deployment/src/project_forge_deployment/entrypoint.py +37 -0
- package/assets/generated/deployment/src/project_forge_deployment/environment_cli.py +271 -0
- package/assets/generated/deployment/src/project_forge_deployment/environment_probe.py +228 -0
- package/assets/generated/deployment/src/project_forge_deployment/errors.py +5 -0
- package/assets/generated/deployment/src/project_forge_deployment/home_lab.py +328 -0
- package/assets/generated/deployment/src/project_forge_deployment/image_transfer.py +105 -0
- package/assets/generated/deployment/src/project_forge_deployment/keys.py +171 -0
- package/assets/generated/deployment/src/project_forge_deployment/local_cli.py +189 -0
- package/assets/generated/deployment/src/project_forge_deployment/local_runtime.py +526 -0
- package/assets/generated/deployment/src/project_forge_deployment/local_spec.py +82 -0
- package/assets/generated/deployment/src/project_forge_deployment/local_sqlite.py +205 -0
- package/assets/generated/deployment/src/project_forge_deployment/portable_profile.py +181 -0
- package/assets/generated/deployment/src/project_forge_deployment/profile_migration.py +70 -0
- package/assets/generated/deployment/src/project_forge_deployment/profile_repository.py +348 -0
- package/assets/generated/deployment/src/project_forge_deployment/profiles.py +204 -0
- package/assets/generated/deployment/src/project_forge_deployment/registry_access.py +114 -0
- package/assets/generated/deployment/src/project_forge_deployment/registry_cli.py +51 -0
- package/assets/generated/deployment/src/project_forge_deployment/route_registry.py +367 -0
- package/assets/generated/deployment/src/project_forge_deployment/route_registry_remote.py +443 -0
- package/assets/generated/deployment/src/project_forge_deployment/security.py +290 -0
- package/assets/generated/deployment/src/project_forge_deployment/sqlite_adapter.py +335 -0
- package/assets/generated/deployment/src/project_forge_deployment/sqlite_adapter_remote.py +340 -0
- package/assets/generated/deployment/src/project_forge_deployment/sqlite_cli.py +86 -0
- package/assets/generated/deployment/src/project_forge_deployment/sqlite_import_recovery.py +437 -0
- package/assets/generated/deployment/src/project_forge_deployment/ssh.py +119 -0
- package/assets/generated/deployment/src/project_forge_deployment/teardown.py +300 -0
- package/assets/generated/deployment/src/project_forge_deployment/teardown_cli.py +139 -0
- package/assets/generated/deployment/src/project_forge_deployment/teardown_remote.py +100 -0
- package/assets/generated/deployment/src/project_forge_deployment/trust.py +278 -0
- package/bin/project-forge.mjs +9 -0
- package/package.json +31 -4
- package/src/cli.mjs +150 -0
- package/src/engine.mjs +243 -0
- 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
|
+
}
|