@mriekki/project-forge 4.6.0 → 4.7.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/assets/bootstrap-migrations/managed-baseline-verifier/check-managed-baselines.py +180 -0
- package/assets/bootstrap-migrations/managed-baseline-verifier/manifest.json +17 -0
- package/assets/components.json +3 -3
- package/assets/deployment/distribution.json +2 -0
- package/assets/generated/ASSEMBLY.json +16 -6
- package/assets/generated/core/core/skills/setup/SKILL.md +94 -5
- package/assets/generated/deployment/src/project_forge_deployment/access_cli.py +65 -0
- package/assets/generated/deployment/src/project_forge_deployment/access_diagnosis.py +116 -0
- package/assets/generated/deployment/src/project_forge_deployment/entrypoint.py +4 -0
- package/assets/generated/deployment/src/project_forge_deployment/security.py +44 -15
- package/package.json +1 -1
- package/src/bootstrap-migrations.mjs +160 -0
- package/src/cli.mjs +20 -8
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
|
|
9
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def read_json(name: str) -> dict:
|
|
13
|
+
path = ROOT / name
|
|
14
|
+
try:
|
|
15
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
16
|
+
except Exception as exc:
|
|
17
|
+
raise RuntimeError(f"Unable to read {name}: {exc}") from exc
|
|
18
|
+
if not isinstance(value, dict):
|
|
19
|
+
raise RuntimeError(f"{name} must contain a JSON object")
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def safe_relative(value: object, label: str) -> str:
|
|
24
|
+
if not isinstance(value, str) or not value:
|
|
25
|
+
raise RuntimeError(f"{label} must be a non-empty relative path")
|
|
26
|
+
normalized = value.replace("\\", "/")
|
|
27
|
+
pure = PurePosixPath(normalized)
|
|
28
|
+
if pure.is_absolute() or not pure.parts or any(part in {"", ".", ".."} for part in pure.parts):
|
|
29
|
+
raise RuntimeError(f"Unsafe {label}: {value}")
|
|
30
|
+
return normalized
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sha256(path: Path) -> str:
|
|
34
|
+
digest = hashlib.sha256()
|
|
35
|
+
with path.open("rb") as handle:
|
|
36
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
37
|
+
digest.update(chunk)
|
|
38
|
+
return digest.hexdigest()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def project_component_metadata(component: str, metadata: dict, schema_version: int) -> dict:
|
|
42
|
+
if schema_version == 1:
|
|
43
|
+
result = {
|
|
44
|
+
"revision": metadata.get("commit"),
|
|
45
|
+
"distribution": metadata.get("distribution"),
|
|
46
|
+
}
|
|
47
|
+
if component == "core":
|
|
48
|
+
result["version"] = metadata.get("version")
|
|
49
|
+
return result
|
|
50
|
+
if schema_version == 2:
|
|
51
|
+
result = {
|
|
52
|
+
"revision": metadata.get("revision"),
|
|
53
|
+
"distribution": metadata.get("distribution"),
|
|
54
|
+
}
|
|
55
|
+
if component == "core":
|
|
56
|
+
result["version"] = metadata.get("version")
|
|
57
|
+
return result
|
|
58
|
+
raise RuntimeError("Unsupported PROJECT_FORGE_BASELINE.json schemaVersion")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def component_baseline_metadata(component: str, baseline: dict, filename: str) -> dict:
|
|
62
|
+
schema_version = baseline.get("schemaVersion")
|
|
63
|
+
if schema_version == 1:
|
|
64
|
+
if component == "core":
|
|
65
|
+
return {
|
|
66
|
+
"revision": baseline.get("coreCommit"),
|
|
67
|
+
"version": baseline.get("coreVersion"),
|
|
68
|
+
"distribution": baseline.get("distribution"),
|
|
69
|
+
}
|
|
70
|
+
if component == "deployment":
|
|
71
|
+
return {
|
|
72
|
+
"revision": baseline.get("deploymentKitCommit"),
|
|
73
|
+
"distribution": baseline.get("distribution"),
|
|
74
|
+
}
|
|
75
|
+
elif schema_version == 2:
|
|
76
|
+
if baseline.get("component") != component:
|
|
77
|
+
raise RuntimeError(f"{filename} component does not match PROJECT_FORGE_BASELINE.json")
|
|
78
|
+
result = {
|
|
79
|
+
"revision": baseline.get("revision"),
|
|
80
|
+
"distribution": baseline.get("distribution"),
|
|
81
|
+
}
|
|
82
|
+
if component == "core":
|
|
83
|
+
result["version"] = baseline.get("version")
|
|
84
|
+
return result
|
|
85
|
+
else:
|
|
86
|
+
raise RuntimeError(f"Unsupported {filename} schemaVersion")
|
|
87
|
+
raise RuntimeError(f"Unsupported Project Forge component in generated baseline: {component}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def validate_component(
|
|
91
|
+
component: str,
|
|
92
|
+
project_forge_component: dict,
|
|
93
|
+
filename: str,
|
|
94
|
+
project_schema_version: int,
|
|
95
|
+
) -> int:
|
|
96
|
+
baseline = read_json(filename)
|
|
97
|
+
if baseline.get("schemaVersion") != project_schema_version:
|
|
98
|
+
raise RuntimeError(
|
|
99
|
+
f"{filename} schemaVersion does not match PROJECT_FORGE_BASELINE.json"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
expected = project_component_metadata(component, project_forge_component, project_schema_version)
|
|
103
|
+
actual = component_baseline_metadata(component, baseline, filename)
|
|
104
|
+
for key, expected_value in expected.items():
|
|
105
|
+
if expected_value is not None and actual.get(key) != expected_value:
|
|
106
|
+
raise RuntimeError(
|
|
107
|
+
f"{filename} {key} does not match PROJECT_FORGE_BASELINE.json"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
managed = baseline.get("managedPaths")
|
|
111
|
+
if not isinstance(managed, list) or not managed:
|
|
112
|
+
raise RuntimeError(f"{filename} managedPaths is missing or empty")
|
|
113
|
+
|
|
114
|
+
count = 0
|
|
115
|
+
seen: set[str] = set()
|
|
116
|
+
for item in managed:
|
|
117
|
+
if not isinstance(item, dict):
|
|
118
|
+
raise RuntimeError(f"Invalid managedPaths entry in {filename}")
|
|
119
|
+
relative = safe_relative(item.get("path"), f"{filename} managed path")
|
|
120
|
+
expected_hash = item.get("sha256")
|
|
121
|
+
if not isinstance(expected_hash, str) or len(expected_hash) != 64:
|
|
122
|
+
raise RuntimeError(f"Invalid managed hash in {filename}: {relative}")
|
|
123
|
+
if relative in seen:
|
|
124
|
+
raise RuntimeError(f"Duplicate managed path in {filename}: {relative}")
|
|
125
|
+
seen.add(relative)
|
|
126
|
+
path = ROOT.joinpath(*PurePosixPath(relative).parts)
|
|
127
|
+
if path.is_symlink() or not path.is_file():
|
|
128
|
+
raise RuntimeError(f"Managed file missing or unsafe: {relative}")
|
|
129
|
+
actual_hash = sha256(path)
|
|
130
|
+
if actual_hash != expected_hash:
|
|
131
|
+
raise RuntimeError(f"Managed file drift: {relative}")
|
|
132
|
+
count += 1
|
|
133
|
+
return count
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main() -> int:
|
|
137
|
+
try:
|
|
138
|
+
manifest = read_json("PROJECT_BOOTSTRAP.json")
|
|
139
|
+
approved = (manifest.get("generation") or {}).get("project-forge") or {}
|
|
140
|
+
project_forge = read_json("PROJECT_FORGE_BASELINE.json")
|
|
141
|
+
project_schema_version = project_forge.get("schemaVersion")
|
|
142
|
+
if project_schema_version not in {1, 2}:
|
|
143
|
+
raise RuntimeError("Unsupported PROJECT_FORGE_BASELINE.json schemaVersion")
|
|
144
|
+
for key in ["package", "version", "profile"]:
|
|
145
|
+
if project_forge.get(key) != approved.get(key):
|
|
146
|
+
raise RuntimeError(
|
|
147
|
+
f"PROJECT_FORGE_BASELINE.json {key} does not match PROJECT_BOOTSTRAP.json"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
components = project_forge.get("components")
|
|
151
|
+
if not isinstance(components, dict) or not components:
|
|
152
|
+
raise RuntimeError("PROJECT_FORGE_BASELINE.json components is missing or empty")
|
|
153
|
+
|
|
154
|
+
total = 0
|
|
155
|
+
for component, metadata in sorted(components.items()):
|
|
156
|
+
if component == "core":
|
|
157
|
+
filename = "CORE_BASELINE.json"
|
|
158
|
+
elif component == "deployment":
|
|
159
|
+
filename = "DEPLOYMENT_BASELINE.json"
|
|
160
|
+
else:
|
|
161
|
+
raise RuntimeError(f"Unsupported Project Forge component: {component}")
|
|
162
|
+
if not isinstance(metadata, dict):
|
|
163
|
+
raise RuntimeError(f"Invalid Project Forge component metadata: {component}")
|
|
164
|
+
total += validate_component(
|
|
165
|
+
component,
|
|
166
|
+
metadata,
|
|
167
|
+
filename,
|
|
168
|
+
project_schema_version,
|
|
169
|
+
)
|
|
170
|
+
print(f"PASS {filename}")
|
|
171
|
+
|
|
172
|
+
print(f"PASS Project Forge managed baselines ({total} files)")
|
|
173
|
+
return 0
|
|
174
|
+
except RuntimeError as exc:
|
|
175
|
+
print(f"FAIL Project Forge managed baselines: {exc}", file=sys.stderr)
|
|
176
|
+
return 2
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"id": "bootstrap-managed-baseline-verifier-schema2",
|
|
4
|
+
"owner": "bootstrap",
|
|
5
|
+
"destination": "tools/project-forge-package/check-managed-baselines.py",
|
|
6
|
+
"asset": "assets/bootstrap-migrations/managed-baseline-verifier/check-managed-baselines.py",
|
|
7
|
+
"source": {
|
|
8
|
+
"component": "bootstrap",
|
|
9
|
+
"revision": "b5a83729d211e0492be5368ff7f1e59b63986419",
|
|
10
|
+
"artifact": "migration-assets/package-workspace/check-managed-baselines.py"
|
|
11
|
+
},
|
|
12
|
+
"targetGitBlob": "26496854ed5d6ad47c7b6136078b2114473ac773",
|
|
13
|
+
"supportedPredecessorGitBlobs": [
|
|
14
|
+
"f7ccfc0e8efaac4d6aed6ed5d8e2130d4fb3dc9d"
|
|
15
|
+
],
|
|
16
|
+
"supportedProjectBaselineSchemas": [1, 2]
|
|
17
|
+
}
|
package/assets/components.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.7.0",
|
|
5
5
|
"defaultProfile": "greenfield-project",
|
|
6
6
|
"profiles": {
|
|
7
7
|
"greenfield-project": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"components": {
|
|
15
15
|
"core": {
|
|
16
|
-
"revision": "
|
|
16
|
+
"revision": "737dab0636252410c022be057d25c36c1dd5f116",
|
|
17
17
|
"version": "4.0.0",
|
|
18
18
|
"distribution": "greenfield-workspace",
|
|
19
19
|
"descriptor": "assets/core/distribution.json",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"canonicalDescriptor": "distributions/greenfield-workspace.json"
|
|
22
22
|
},
|
|
23
23
|
"deployment": {
|
|
24
|
-
"revision": "
|
|
24
|
+
"revision": "f7441081114be94a22d6e1774194dc8bd7a88e18",
|
|
25
25
|
"distribution": "greenfield-project",
|
|
26
26
|
"descriptor": "assets/deployment/distribution.json",
|
|
27
27
|
"sourceRoot": "assets/generated/deployment",
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
{"source": "distribution/project-forge-deploy.py", "destination": "tools/project-forge-deployment/project-forge-deploy.py"},
|
|
7
7
|
{"source": "src/project_forge_deployment/__init__.py", "destination": "tools/project-forge-deployment/project_forge_deployment/__init__.py"},
|
|
8
8
|
{"source": "src/project_forge_deployment/__main__.py", "destination": "tools/project-forge-deployment/project_forge_deployment/__main__.py"},
|
|
9
|
+
{"source": "src/project_forge_deployment/access_cli.py", "destination": "tools/project-forge-deployment/project_forge_deployment/access_cli.py"},
|
|
10
|
+
{"source": "src/project_forge_deployment/access_diagnosis.py", "destination": "tools/project-forge-deployment/project_forge_deployment/access_diagnosis.py"},
|
|
9
11
|
{"source": "src/project_forge_deployment/app_cli.py", "destination": "tools/project-forge-deployment/project_forge_deployment/app_cli.py"},
|
|
10
12
|
{"source": "src/project_forge_deployment/app_deployment.py", "destination": "tools/project-forge-deployment/project_forge_deployment/app_deployment.py"},
|
|
11
13
|
{"source": "src/project_forge_deployment/app_runtime.py", "destination": "tools/project-forge-deployment/project_forge_deployment/app_runtime.py"},
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.7.0",
|
|
5
5
|
"components": {
|
|
6
6
|
"core": {
|
|
7
|
-
"revision": "
|
|
7
|
+
"revision": "737dab0636252410c022be057d25c36c1dd5f116",
|
|
8
8
|
"version": "4.0.0",
|
|
9
9
|
"distribution": "greenfield-workspace",
|
|
10
10
|
"files": [
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
{
|
|
52
52
|
"source": "core/skills/setup/SKILL.md",
|
|
53
53
|
"destination": ".github/skills/setup/SKILL.md",
|
|
54
|
-
"sha256": "
|
|
54
|
+
"sha256": "27419e0d330abf2b6b03a696eba352ab2ece13a616c41fc0ade9aed85e8834f1"
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
57
|
"source": "core/skills/build/SKILL.md",
|
|
@@ -326,7 +326,7 @@
|
|
|
326
326
|
]
|
|
327
327
|
},
|
|
328
328
|
"deployment": {
|
|
329
|
-
"revision": "
|
|
329
|
+
"revision": "f7441081114be94a22d6e1774194dc8bd7a88e18",
|
|
330
330
|
"distribution": "greenfield-project",
|
|
331
331
|
"files": [
|
|
332
332
|
{
|
|
@@ -344,6 +344,16 @@
|
|
|
344
344
|
"destination": "tools/project-forge-deployment/project_forge_deployment/__main__.py",
|
|
345
345
|
"sha256": "8742c1b766812edb07174a724d676ffeff58b71b3c192d35102a3e652e962946"
|
|
346
346
|
},
|
|
347
|
+
{
|
|
348
|
+
"source": "src/project_forge_deployment/access_cli.py",
|
|
349
|
+
"destination": "tools/project-forge-deployment/project_forge_deployment/access_cli.py",
|
|
350
|
+
"sha256": "007d01ae0c4e28e64f75fb96f9e8e7b99db5c4c8129698a78923baeac2f51953"
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
"source": "src/project_forge_deployment/access_diagnosis.py",
|
|
354
|
+
"destination": "tools/project-forge-deployment/project_forge_deployment/access_diagnosis.py",
|
|
355
|
+
"sha256": "92861e58a29b17758a927f7a527b089f172a1bc0c9725c79731ef07e00294af1"
|
|
356
|
+
},
|
|
347
357
|
{
|
|
348
358
|
"source": "src/project_forge_deployment/app_cli.py",
|
|
349
359
|
"destination": "tools/project-forge-deployment/project_forge_deployment/app_cli.py",
|
|
@@ -397,7 +407,7 @@
|
|
|
397
407
|
{
|
|
398
408
|
"source": "src/project_forge_deployment/entrypoint.py",
|
|
399
409
|
"destination": "tools/project-forge-deployment/project_forge_deployment/entrypoint.py",
|
|
400
|
-
"sha256": "
|
|
410
|
+
"sha256": "6b767380f502acf0763b71f46aff6efd62674e63a526ed270097efa68c320a1e"
|
|
401
411
|
},
|
|
402
412
|
{
|
|
403
413
|
"source": "src/project_forge_deployment/environment_cli.py",
|
|
@@ -512,7 +522,7 @@
|
|
|
512
522
|
{
|
|
513
523
|
"source": "src/project_forge_deployment/security.py",
|
|
514
524
|
"destination": "tools/project-forge-deployment/project_forge_deployment/security.py",
|
|
515
|
-
"sha256": "
|
|
525
|
+
"sha256": "d24d4faa0d98b2e7484e2a9e959ba4624927be61a84453ed2db150aa6132c0cf"
|
|
516
526
|
},
|
|
517
527
|
{
|
|
518
528
|
"source": "src/project_forge_deployment/sqlite_adapter.py",
|
|
@@ -65,6 +65,14 @@ Do not require another workstation to be online and do not treat another compute
|
|
|
65
65
|
|
|
66
66
|
Detect the current OS automatically. Do not ask the developer which setup launcher to use when the platform can be observed.
|
|
67
67
|
|
|
68
|
+
If the observed workstation OS is not declared in mutable `DEV_ENVIRONMENT.json`, surface that explicitly before claiming setup support:
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
NEW WORKSTATION — PLATFORM NOT DECLARED
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Do not silently reinterpret Bootstrap provenance or append a platform merely because tools happen to run. If current repository/catalog evidence supports the stack on the observed OS, offer one reviewed **consumer-owned** platform expansion to `DEV_ENVIRONMENT.json`; otherwise report the concrete unsupported/capability gap. Preserve original `PROJECT_BOOTSTRAP.json` provenance.
|
|
75
|
+
|
|
68
76
|
A workspace instance owns machine-local state such as local runtime/data, SSH private keys/trust, credential-manager entries, Docker state, and repository-scoped readiness evidence. Git/GitHub is the shared source-code coordination boundary. Never invent a central workstation registry merely to coordinate another computer.
|
|
69
77
|
|
|
70
78
|
## 2. `status` is read-only
|
|
@@ -112,6 +120,10 @@ setup --check
|
|
|
112
120
|
|
|
113
121
|
A manually invoked `/setup` authorizes routine, bounded, non-destructive local setup that the generated script already owns: supported tool installation, VS Code extension installation, dependency restore, personal Project Forge profile initialization, and other declared workstation preparation.
|
|
114
122
|
|
|
123
|
+
Before a large workstation install run, surface all currently knowable prerequisites together when the generated tooling exposes them: missing tools, runtime/version pins, editor extensions, Docker operational state, interactive elevation/terminal requirements, and durable fresh-shell discovery. Group unavoidable sudo/administrator or secret-bearing prompts into clear terminal handoffs instead of discovering them one installer at a time.
|
|
124
|
+
|
|
125
|
+
A temporary process `PATH` adjustment may prove a diagnosis but does not by itself prove durable tool discovery. Re-check important runtime commands in the environment a fresh terminal/editor will use before reporting final readiness.
|
|
126
|
+
|
|
115
127
|
Stop before destructive replacement, unrelated machine mutation, secret disclosure, or repair outside the declared setup contract.
|
|
116
128
|
|
|
117
129
|
## 4. `/setup local` converges verified local development readiness
|
|
@@ -132,7 +144,7 @@ When application source/runtime exists, converge only the repository-owned local
|
|
|
132
144
|
|
|
133
145
|
Verify supported tools/versions using repository setup/doctor tooling when present. Run repository-defined dependency restore/setup commands when missing or stale. Preserve pinned versions and project conventions.
|
|
134
146
|
|
|
135
|
-
Treat **installation**, **version compatibility**, and **operational readiness** as separate evidence when tooling exposes those distinctions. A successful CLI/version probe does not prove that the backing service/runtime is operational. For Docker-based setup, do not call the runtime ready merely because `docker --version` succeeds; require the repository/doctor operational probe before relying on Docker Engine capability.
|
|
147
|
+
Treat **installation**, **version compatibility**, and **operational readiness** as separate evidence when tooling exposes those distinctions. A successful CLI/version probe does not prove that the backing service/runtime is operational for the required Project Forge action. For Docker-based setup, do not call the runtime ready merely because `docker --version` succeeds; require the repository/doctor operational probe before relying on Docker Engine capability.
|
|
136
148
|
|
|
137
149
|
### B. Local persistence
|
|
138
150
|
|
|
@@ -266,6 +278,26 @@ For remote SSH preserve strict host identity. Unknown/changed host identity is a
|
|
|
266
278
|
|
|
267
279
|
Remote readiness does not require configuring Production when the selected environment is non-Production. Production classification remains project topology/policy, not a literal-name check.
|
|
268
280
|
|
|
281
|
+
### One read-only remote preflight before recovery
|
|
282
|
+
|
|
283
|
+
Before asking the developer to repair one SSH prerequisite at a time, collect all currently knowable target-access facts through supported deterministic status/diagnosis surfaces. When installed Deployment Kit exposes the access diagnosis command, prefer:
|
|
284
|
+
|
|
285
|
+
```text
|
|
286
|
+
project-forge-deploy access diagnose <target> --json
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
If stable name resolution is unavailable and the developer supplies an exact address independently observed at the trusted target console, carry that address through read-only diagnosis without persisting it:
|
|
290
|
+
|
|
291
|
+
```text
|
|
292
|
+
project-forge-deploy access diagnose <target> --address <exact-ip> --json
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
The exact IP is address-path evidence, not a replacement SSH identity. Preserve the configured `HostKeyAlias`, target-specific trust, and configured identity. Do not store the address merely because it made diagnosis possible. Persist it only through the separately gated verified-address operation after strict SSH succeeds.
|
|
296
|
+
|
|
297
|
+
When diagnosis exposes route-source evidence, use the actual selected local source address/interface for any source-policy discussion. Never assume Wi-Fi, Ethernet, the first interface, or a subnet merely from workstation configuration.
|
|
298
|
+
|
|
299
|
+
If the Deployment Kit version does not expose the supported diagnosis surface, use its documented structured environment status/plan commands. Do not substitute ad hoc Python imports or unbounded raw `ssh -vvv` transcripts as normal orchestration.
|
|
300
|
+
|
|
269
301
|
### Guided remote failure recovery is part of `/setup`
|
|
270
302
|
|
|
271
303
|
A supported deterministic setup/readiness failure is not, by itself, the end of `/setup`. When installed Deployment Kit tooling returns structured diagnosis or a bounded next action, continue through that diagnosis automatically until one of these boundaries is reached:
|
|
@@ -278,7 +310,7 @@ A supported deterministic setup/readiness failure is not, by itself, the end of
|
|
|
278
310
|
|
|
279
311
|
Do **not** merely repeat an opaque subprocess exit code and tell the developer to fix access independently when deterministic diagnosis is available.
|
|
280
312
|
|
|
281
|
-
|
|
313
|
+
Preserve distinct categories such as:
|
|
282
314
|
|
|
283
315
|
```text
|
|
284
316
|
name resolution / address path
|
|
@@ -293,8 +325,45 @@ remote account/source-policy rejection
|
|
|
293
325
|
|
|
294
326
|
Keep credentials, private keys, raw secure-prompt content, and unbounded SSH transcripts out of conversational diagnostics and journals.
|
|
295
327
|
|
|
328
|
+
For fingerprint input, show the expected public form such as `SHA256:<fingerprint>` and validate that shape before progressing. Distinguish the **server host fingerprint**, the **client public-key fingerprint**, and passwords/passphrases. If input looks credential-like rather than fingerprint-shaped, do not repeat it into commands, reports, journals, or upstream issues.
|
|
329
|
+
|
|
330
|
+
If a local key/agent operation requires a passphrase or another terminal-owned secret prompt, follow the interactive-terminal handoff contract: collect any distinct bounded authorization once, then immediately provide the exact standalone copyable command, correct machine/working directory, expected local prompt, and the non-secret completion evidence to report. Do not ask for the passphrase through chat or ask a second ceremonial question merely to repeat the same authorized command.
|
|
331
|
+
|
|
296
332
|
If client-side evidence shows that the configured key reached the server but access is still rejected, stop reinstalling the same key. Route to a trusted target console or already-authorized administrative session to inspect effective account/source policy. Do not guess that `AllowUsers`, `AllowGroups`, PAM, or another server rule is the exact cause until server-side evidence establishes it.
|
|
297
333
|
|
|
334
|
+
### One bounded trusted-console diagnostic bundle
|
|
335
|
+
|
|
336
|
+
When client evidence reaches the server but cannot establish why the account/session is rejected, request one compact server-side evidence bundle instead of a chain of one-fact-at-a-time questions. Limit the bundle to the facts needed for the selected account and actual route-selected source:
|
|
337
|
+
|
|
338
|
+
- intended SSH account;
|
|
339
|
+
- whether the expected **client public-key fingerprint** is present for that account, without dumping unrelated authorized keys;
|
|
340
|
+
- ownership/modes for the account's `.ssh` directory and `authorized_keys`;
|
|
341
|
+
- a narrowly scoped recent authentication reason for the attempted source/account;
|
|
342
|
+
- effective SSH allow/deny policy relevant to that account and source;
|
|
343
|
+
- the file/include/`Match` location that owns any relevant directive.
|
|
344
|
+
|
|
345
|
+
State exactly where to run each console command and what non-secret output to return. Never request full SSH configs/logs, passwords, private keys, tokens, or unrelated authorized-key material when the focused bundle is enough.
|
|
346
|
+
|
|
347
|
+
Client evidence may classify `remote-authentication-rejected` or `remote-account-policy-rejected`, but only trusted server-side evidence may establish a concrete cause such as `AllowUsers`.
|
|
348
|
+
|
|
349
|
+
### One exact server-policy repair after evidence
|
|
350
|
+
|
|
351
|
+
If the trusted-console evidence establishes that a server-side account/source policy change is required, prepare **one exact repair plan** before any write. The plan must show:
|
|
352
|
+
|
|
353
|
+
- exact affected file/directive and relevant before/after state;
|
|
354
|
+
- the actual durable source address/interface assumption being authorized;
|
|
355
|
+
- preservation of existing allow/deny entries unless the operator explicitly chooses otherwise;
|
|
356
|
+
- an exact backup of the affected file before editing;
|
|
357
|
+
- syntax/config validation before reload;
|
|
358
|
+
- preservation of the current trusted administrative session until new strict access is proven;
|
|
359
|
+
- reload/restart scope, rollback command, and explicit stop conditions.
|
|
360
|
+
|
|
361
|
+
Do not present configuration text as if it were a shell command. Give either one copyable bounded command or explicit editor instructions on the **target host**, with the file path stated. Do not assume that adding a new drop-in overrides or composes safely with existing includes/`Match` blocks. Never broaden a specific source restriction to a subnet merely for convenience.
|
|
362
|
+
|
|
363
|
+
A server-policy write remains a distinct operator/administrative authorization. Once the exact reviewed repair is authorized and the human reports it completed, do not ask the same authorization again: automatically re-run strict structured diagnosis/verification, then continue through separately gated address persistence, host-platform readiness, and proof as applicable.
|
|
364
|
+
|
|
365
|
+
If the human says stop, done, or pause at any point, stop modifying state. Read-only reporting may summarize what is already known, but newly observed prerequisite changes do not override the expressed pause.
|
|
366
|
+
|
|
298
367
|
### Gate password-assisted bootstrap on server capability
|
|
299
368
|
|
|
300
369
|
Before recommending a password-assisted/Home Lab bootstrap against a reachable server, use deterministic evidence of the server-advertised authentication methods when the installed Deployment Kit supports that probe.
|
|
@@ -355,6 +424,8 @@ For a Production-class environment, keep the proof explicitly labeled as a **PRO
|
|
|
355
424
|
|
|
356
425
|
If cleanup fails, report `PROOF PASSED — CLEANUP ACTION NEEDED`; do not claim a clean setup.
|
|
357
426
|
|
|
427
|
+
When proof output distinguishes `transferState: transferred` from `transferState: already-present`, report that distinction. A proof that reused an exact cached image may still prove runtime/health/cleanup, but it must not be described as an observed fresh workstation-to-target image transfer. If fresh transport itself must be tested, plan that separately without deleting or pruning a shared image cache merely to manufacture evidence.
|
|
428
|
+
|
|
358
429
|
When installed Deployment Kit provides a durable local proof receipt or equivalent discoverable evidence, record/reuse it rather than relying on chat history. Reuse only while its target binding, host identity, workstation identity, adapter/provenance, logical environment, and cleanup result remain applicable. A new chat alone must not invalidate valid machine evidence.
|
|
359
430
|
|
|
360
431
|
## 9. `repair` is bounded and fail-closed
|
|
@@ -382,9 +453,27 @@ Stop for a separate explicit decision before:
|
|
|
382
453
|
|
|
383
454
|
## 11. Completion and handoff
|
|
384
455
|
|
|
385
|
-
After setup, report evidence rather than inferring human acceptance.
|
|
456
|
+
After setup, report evidence rather than inferring human acceptance. Keep independent readiness dimensions independent instead of collapsing them into one `READY` label.
|
|
457
|
+
|
|
458
|
+
Prefer a compact final shape such as:
|
|
459
|
+
|
|
460
|
+
```text
|
|
461
|
+
SETUP RESULT
|
|
462
|
+
|
|
463
|
+
Local tools/runtime ......... VERIFIED / BLOCKED / NOT APPLICABLE
|
|
464
|
+
Fresh-shell discovery ....... VERIFIED / CAVEAT / BLOCKED
|
|
465
|
+
Build/tests ................. VERIFIED / BLOCKED / NOT RUN
|
|
466
|
+
Remote target access ........ VERIFIED / BLOCKED / NOT CONFIGURED
|
|
467
|
+
Host platform ............... READY / BLOCKED / NOT APPLICABLE
|
|
468
|
+
Deployment path ............. PROVEN / NOT PROVEN / BLOCKED
|
|
469
|
+
Proof image transfer ........ TRANSFERRED / CACHE REUSED / NOT RUN
|
|
470
|
+
Proof cleanup ............... CLEAN / ACTION NEEDED / NOT RUN
|
|
471
|
+
Project application ......... NOT DEPLOYED / <actual evidence>
|
|
472
|
+
Repository CI ............... PASS / BLOCKED / NOT CHECKED
|
|
473
|
+
Human workspace acceptance .. CONFIRMED / PENDING
|
|
474
|
+
```
|
|
386
475
|
|
|
387
|
-
A generated setup/doctor/proof success is machine evidence. Human workspace acceptance remains distinct.
|
|
476
|
+
A generated setup/doctor/proof success is machine evidence. Human workspace acceptance remains distinct. A continuation/override for a documented Project Forge defect is not a CI waiver and is not human workspace acceptance; preserve its exact scope so resuming setup does not repeatedly ask the same decision.
|
|
388
477
|
|
|
389
478
|
Useful next actions include:
|
|
390
479
|
- `/next-step` — one sensible next action after current evidence is known;
|
|
@@ -393,4 +482,4 @@ Useful next actions include:
|
|
|
393
482
|
- `/deploy <environment>` — only after readiness and its own authorization contract;
|
|
394
483
|
- `/tear-down <scope>` — destructive cleanup under its separate gate.
|
|
395
484
|
|
|
396
|
-
Do not create a second top-level developer-environment-mode workflow. Local/remote workstation convergence belongs here.
|
|
485
|
+
Do not create a second top-level developer-environment-mode workflow. Local/remote workstation convergence belongs here.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""CLI for strict target-access diagnosis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .access_diagnosis import diagnose_target_access
|
|
11
|
+
from .errors import DeploymentKitError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _print(value: Any, *, as_json: bool) -> None:
|
|
15
|
+
if as_json:
|
|
16
|
+
print(json.dumps(value, indent=2, sort_keys=True))
|
|
17
|
+
return
|
|
18
|
+
if isinstance(value, dict):
|
|
19
|
+
for key, item in value.items():
|
|
20
|
+
if isinstance(item, (dict, list)):
|
|
21
|
+
print(f"{key}: {json.dumps(item, sort_keys=True)}")
|
|
22
|
+
else:
|
|
23
|
+
print(f"{key}: {item}")
|
|
24
|
+
return
|
|
25
|
+
print(value)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
29
|
+
parser = argparse.ArgumentParser(
|
|
30
|
+
prog="project-forge-deploy access",
|
|
31
|
+
description="Read-only strict SSH target-access diagnosis.",
|
|
32
|
+
)
|
|
33
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
34
|
+
|
|
35
|
+
diagnose = subparsers.add_parser(
|
|
36
|
+
"diagnose",
|
|
37
|
+
help="Diagnose one configured target without mutating target or workstation state",
|
|
38
|
+
)
|
|
39
|
+
diagnose.add_argument("name")
|
|
40
|
+
diagnose.add_argument(
|
|
41
|
+
"--address",
|
|
42
|
+
help=(
|
|
43
|
+
"Exact temporary IPv4/IPv6 address independently confirmed by the operator. "
|
|
44
|
+
"Used only for this read-only diagnosis and never persisted."
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
diagnose.add_argument("--json", action="store_true")
|
|
48
|
+
return parser
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main(argv: list[str] | None = None) -> int:
|
|
52
|
+
args = build_parser().parse_args(argv)
|
|
53
|
+
try:
|
|
54
|
+
if args.command == "diagnose":
|
|
55
|
+
result = diagnose_target_access(args.name, temporary_address=args.address)
|
|
56
|
+
_print(result, as_json=args.json)
|
|
57
|
+
return 0 if result.get("state") == "verified" else 2
|
|
58
|
+
raise DeploymentKitError("Unsupported access command.")
|
|
59
|
+
except DeploymentKitError as error:
|
|
60
|
+
print(f"ERROR: {error}", file=sys.stderr)
|
|
61
|
+
return 2
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Read-only strict target-access diagnosis with optional temporary address evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import socket
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .errors import DeploymentKitError
|
|
15
|
+
from .profiles import load_profile
|
|
16
|
+
from .ssh import diagnose_access
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _temporary_address(value: str | None) -> str | None:
|
|
20
|
+
if value is None:
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
parsed = ipaddress.ip_address(value)
|
|
24
|
+
except ValueError as error:
|
|
25
|
+
raise DeploymentKitError("Temporary target address must be an exact IPv4 or IPv6 address.") from error
|
|
26
|
+
if isinstance(parsed, ipaddress.IPv6Address) and parsed.scope_id is not None:
|
|
27
|
+
raise DeploymentKitError("Temporary target address must not include an IPv6 scope identifier.")
|
|
28
|
+
return str(parsed)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _route_interface(address: str) -> str | None:
|
|
32
|
+
if sys.platform == "darwin":
|
|
33
|
+
route = shutil.which("route") or ("/sbin/route" if os.path.exists("/sbin/route") else None)
|
|
34
|
+
if route:
|
|
35
|
+
result = subprocess.run(
|
|
36
|
+
[route, "-n", "get", address],
|
|
37
|
+
check=False,
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
encoding="utf-8",
|
|
41
|
+
errors="replace",
|
|
42
|
+
)
|
|
43
|
+
if result.returncode == 0:
|
|
44
|
+
match = re.search(r"^\s*interface:\s*(\S+)\s*$", result.stdout, re.MULTILINE)
|
|
45
|
+
if match:
|
|
46
|
+
return match.group(1)
|
|
47
|
+
elif os.name != "nt":
|
|
48
|
+
ip = shutil.which("ip")
|
|
49
|
+
if ip:
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
[ip, "route", "get", address],
|
|
52
|
+
check=False,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
encoding="utf-8",
|
|
56
|
+
errors="replace",
|
|
57
|
+
)
|
|
58
|
+
if result.returncode == 0:
|
|
59
|
+
match = re.search(r"\bdev\s+(\S+)", result.stdout)
|
|
60
|
+
if match:
|
|
61
|
+
return match.group(1)
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def route_source_evidence(address: str, port: int) -> dict[str, Any]:
|
|
66
|
+
"""Return the local route-selected source for one exact target address without scanning."""
|
|
67
|
+
parsed = ipaddress.ip_address(address)
|
|
68
|
+
family = socket.AF_INET6 if isinstance(parsed, ipaddress.IPv6Address) else socket.AF_INET
|
|
69
|
+
evidence: dict[str, Any] = {
|
|
70
|
+
"targetAddress": str(parsed),
|
|
71
|
+
"targetPort": int(port),
|
|
72
|
+
"sourceAddress": None,
|
|
73
|
+
"interface": _route_interface(str(parsed)),
|
|
74
|
+
}
|
|
75
|
+
try:
|
|
76
|
+
with socket.socket(family, socket.SOCK_DGRAM) as probe:
|
|
77
|
+
probe.connect((str(parsed), int(port)))
|
|
78
|
+
evidence["sourceAddress"] = probe.getsockname()[0]
|
|
79
|
+
except OSError as error:
|
|
80
|
+
evidence["sourceProbeError"] = str(error)
|
|
81
|
+
return evidence
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def diagnose_target_access(name: str, *, temporary_address: str | None = None) -> dict[str, Any]:
|
|
85
|
+
"""Diagnose strict SSH access without persisting a temporary address override."""
|
|
86
|
+
profile = load_profile(name)
|
|
87
|
+
address = _temporary_address(temporary_address)
|
|
88
|
+
effective = dict(profile)
|
|
89
|
+
if address is not None:
|
|
90
|
+
effective["addressOverride"] = address
|
|
91
|
+
|
|
92
|
+
diagnosis = diagnose_access(effective)
|
|
93
|
+
used_address = diagnosis.get("address")
|
|
94
|
+
if used_address is None and address is not None:
|
|
95
|
+
used_address = address
|
|
96
|
+
|
|
97
|
+
result: dict[str, Any] = {
|
|
98
|
+
**diagnosis,
|
|
99
|
+
"addressInput": (
|
|
100
|
+
{
|
|
101
|
+
"source": "temporary-operator-confirmed",
|
|
102
|
+
"address": address,
|
|
103
|
+
"persisted": False,
|
|
104
|
+
}
|
|
105
|
+
if address is not None
|
|
106
|
+
else {
|
|
107
|
+
"source": "profile",
|
|
108
|
+
"address": profile.get("addressOverride"),
|
|
109
|
+
"persisted": profile.get("addressOverride") is not None,
|
|
110
|
+
}
|
|
111
|
+
),
|
|
112
|
+
"mutated": False,
|
|
113
|
+
}
|
|
114
|
+
if used_address is not None:
|
|
115
|
+
result["routeSource"] = route_source_evidence(used_address, int(profile["port"]))
|
|
116
|
+
return result
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import sys
|
|
6
6
|
|
|
7
|
+
from .access_cli import main as access_main
|
|
7
8
|
from .app_cli import main as app_main
|
|
8
9
|
from .cli import main as core_main
|
|
9
10
|
from .environment_cli import main as environment_main
|
|
@@ -16,6 +17,8 @@ from .topology_cli import main as topology_main
|
|
|
16
17
|
|
|
17
18
|
def main(argv: list[str] | None = None) -> int:
|
|
18
19
|
args = list(sys.argv[1:] if argv is None else argv)
|
|
20
|
+
if args and args[0] == "access":
|
|
21
|
+
return access_main(args[1:])
|
|
19
22
|
if args and args[0] == "app":
|
|
20
23
|
return app_main(args[1:])
|
|
21
24
|
if args and args[0] == "environment":
|
|
@@ -31,6 +34,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
31
34
|
if args and args[0] == "topology":
|
|
32
35
|
return topology_main(args[1:])
|
|
33
36
|
if not args or args == ["--help"] or args == ["-h"]:
|
|
37
|
+
print("Command group: access Read-only strict SSH target-access diagnosis")
|
|
34
38
|
print("Command group: app Remote immutable application runtime inventory/deployment")
|
|
35
39
|
print("Command group: environment Portable profiles, Home Lab onboarding, and image transfer")
|
|
36
40
|
print("Command group: local Loopback-only workstation application runtime")
|
|
@@ -180,27 +180,56 @@ def _posix_directory_chain(path: Path) -> list[Path]:
|
|
|
180
180
|
current = current.parent
|
|
181
181
|
|
|
182
182
|
|
|
183
|
-
def
|
|
183
|
+
def _posix_directory_issues(path: Path) -> list[str]:
|
|
184
|
+
issues: list[str] = []
|
|
185
|
+
current_uid = os.getuid()
|
|
184
186
|
for directory in _posix_directory_chain(path):
|
|
185
|
-
if directory.is_symlink()
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
187
|
+
if directory.is_symlink():
|
|
188
|
+
issues.append(f"symbolic link: {directory}")
|
|
189
|
+
continue
|
|
190
|
+
if not directory.exists():
|
|
191
|
+
continue
|
|
192
|
+
if not directory.is_dir():
|
|
193
|
+
issues.append(f"not a directory: {directory}")
|
|
194
|
+
continue
|
|
195
|
+
details = directory.stat()
|
|
196
|
+
if details.st_uid != current_uid:
|
|
197
|
+
issues.append(f"not owned by the current user: {directory}")
|
|
198
|
+
if details.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
199
|
+
issues.append(f"permissions are too broad: {directory}")
|
|
200
|
+
return issues
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _check_posix_directories(path: Path) -> None:
|
|
204
|
+
issues = _posix_directory_issues(path)
|
|
205
|
+
if issues:
|
|
206
|
+
raise DeploymentKitError("SSH directory chain is unsafe: " + "; ".join(issues))
|
|
189
207
|
|
|
190
208
|
|
|
191
209
|
def prepare_ssh_directory(path: Path) -> None:
|
|
192
|
-
"""Create
|
|
210
|
+
"""Create missing owned SSH directories safely without silently repairing existing unsafe state."""
|
|
193
211
|
_ssh_directory_for(path)
|
|
194
|
-
if
|
|
195
|
-
if path.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
path.chmod(0o700)
|
|
201
|
-
_check_posix_directories(path / "placeholder")
|
|
202
|
-
else:
|
|
212
|
+
if os.name == "nt":
|
|
213
|
+
if path.exists():
|
|
214
|
+
if path.is_symlink() or not path.is_dir():
|
|
215
|
+
raise DeploymentKitError(f"SSH material directory is not a real directory: {path}")
|
|
216
|
+
else:
|
|
217
|
+
path.mkdir(parents=True, mode=0o700)
|
|
203
218
|
_check_windows_acl(path)
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
probe = path / "placeholder"
|
|
222
|
+
issues = _posix_directory_issues(probe)
|
|
223
|
+
if issues:
|
|
224
|
+
raise DeploymentKitError("SSH directory chain is unsafe: " + "; ".join(issues))
|
|
225
|
+
|
|
226
|
+
for directory in reversed(_posix_directory_chain(probe)):
|
|
227
|
+
if directory.exists():
|
|
228
|
+
continue
|
|
229
|
+
directory.mkdir(mode=0o700)
|
|
230
|
+
directory.chmod(0o700)
|
|
231
|
+
|
|
232
|
+
_check_posix_directories(probe)
|
|
204
233
|
|
|
205
234
|
|
|
206
235
|
def check_identity_security(identity: Path) -> None:
|
package/package.json
CHANGED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const MANIFEST_RELATIVE = 'assets/bootstrap-migrations/managed-baseline-verifier/manifest.json';
|
|
6
|
+
const BOOTSTRAP_MANIFEST = 'PROJECT_BOOTSTRAP.json';
|
|
7
|
+
|
|
8
|
+
function readJson(file) {
|
|
9
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function gitBlobSha(data) {
|
|
13
|
+
const raw = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
14
|
+
const buffer = Buffer.from(raw.toString('utf8').replaceAll('\r\n', '\n'), 'utf8');
|
|
15
|
+
const header = Buffer.from(`blob ${buffer.length}\0`, 'utf8');
|
|
16
|
+
return crypto.createHash('sha1').update(header).update(buffer).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function safeRelative(value, label) {
|
|
20
|
+
if (typeof value !== 'string' || !value || path.isAbsolute(value)) {
|
|
21
|
+
throw new Error(`Unsafe ${label}: ${value}`);
|
|
22
|
+
}
|
|
23
|
+
const normalized = value.replaceAll('\\', '/');
|
|
24
|
+
if (normalized.split('/').some((part) => !part || part === '.' || part === '..')) {
|
|
25
|
+
throw new Error(`Unsafe ${label}: ${value}`);
|
|
26
|
+
}
|
|
27
|
+
return normalized;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function safeConsumerPath(root, relative) {
|
|
31
|
+
const normalized = safeRelative(relative, 'Bootstrap migration destination');
|
|
32
|
+
const resolvedRoot = path.resolve(root);
|
|
33
|
+
const resolved = path.resolve(resolvedRoot, ...normalized.split('/'));
|
|
34
|
+
if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
|
|
35
|
+
throw new Error(`Unsafe Bootstrap migration destination: ${relative}`);
|
|
36
|
+
}
|
|
37
|
+
return resolved;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function loadManifest(packageData) {
|
|
41
|
+
const file = path.join(packageData.root, ...MANIFEST_RELATIVE.split('/'));
|
|
42
|
+
const manifest = readJson(file);
|
|
43
|
+
if (manifest.schemaVersion !== 1 || manifest.owner !== 'bootstrap') {
|
|
44
|
+
throw new Error('Invalid Bootstrap migration manifest ownership/schema');
|
|
45
|
+
}
|
|
46
|
+
if (manifest.source?.component !== 'bootstrap') {
|
|
47
|
+
throw new Error('Invalid Bootstrap migration logical source component');
|
|
48
|
+
}
|
|
49
|
+
if (!/^[0-9a-f]{40}$/.test(manifest.source?.revision ?? '')) {
|
|
50
|
+
throw new Error('Invalid Bootstrap migration source revision');
|
|
51
|
+
}
|
|
52
|
+
safeRelative(manifest.source?.artifact, 'Bootstrap migration source artifact');
|
|
53
|
+
if (!/^[0-9a-f]{40}$/.test(manifest.targetGitBlob ?? '')) {
|
|
54
|
+
throw new Error('Invalid Bootstrap migration target Git blob');
|
|
55
|
+
}
|
|
56
|
+
if (!Array.isArray(manifest.supportedPredecessorGitBlobs) || !manifest.supportedPredecessorGitBlobs.every((value) => /^[0-9a-f]{40}$/.test(value))) {
|
|
57
|
+
throw new Error('Invalid Bootstrap migration predecessor identities');
|
|
58
|
+
}
|
|
59
|
+
const assetRelative = safeRelative(manifest.asset, 'Bootstrap migration bundled asset');
|
|
60
|
+
const asset = path.join(packageData.root, ...assetRelative.split('/'));
|
|
61
|
+
const bytes = fs.readFileSync(asset);
|
|
62
|
+
if (gitBlobSha(bytes) !== manifest.targetGitBlob) {
|
|
63
|
+
throw new Error('Bundled Bootstrap migration asset identity does not match its manifest');
|
|
64
|
+
}
|
|
65
|
+
return { manifest, bytes };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function planBootstrapMigrations(packageData, consumerRoot) {
|
|
69
|
+
const bootstrapManifest = path.join(consumerRoot, BOOTSTRAP_MANIFEST);
|
|
70
|
+
if (!fs.existsSync(bootstrapManifest)) return { rows: [], unsafe: [] };
|
|
71
|
+
|
|
72
|
+
const { manifest } = loadManifest(packageData);
|
|
73
|
+
const destination = safeConsumerPath(consumerRoot, manifest.destination);
|
|
74
|
+
if (!fs.existsSync(destination)) {
|
|
75
|
+
return {
|
|
76
|
+
rows: [],
|
|
77
|
+
unsafe: [{
|
|
78
|
+
owner: 'bootstrap',
|
|
79
|
+
state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
|
|
80
|
+
path: manifest.destination,
|
|
81
|
+
detail: 'Bootstrap-generated verifier is missing; refusing package mutation before a supported migration is established.'
|
|
82
|
+
}]
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const stat = fs.lstatSync(destination);
|
|
87
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
88
|
+
return {
|
|
89
|
+
rows: [],
|
|
90
|
+
unsafe: [{
|
|
91
|
+
owner: 'bootstrap',
|
|
92
|
+
state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
|
|
93
|
+
path: manifest.destination,
|
|
94
|
+
detail: 'Bootstrap-generated verifier is not a regular file; refusing package mutation.'
|
|
95
|
+
}]
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const realRoot = fs.realpathSync(consumerRoot);
|
|
99
|
+
const realDestination = fs.realpathSync(destination);
|
|
100
|
+
if (realDestination !== realRoot && !realDestination.startsWith(`${realRoot}${path.sep}`)) {
|
|
101
|
+
return {
|
|
102
|
+
rows: [],
|
|
103
|
+
unsafe: [{
|
|
104
|
+
owner: 'bootstrap',
|
|
105
|
+
state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
|
|
106
|
+
path: manifest.destination,
|
|
107
|
+
detail: 'Bootstrap-generated verifier resolves outside the Project root; refusing package mutation.'
|
|
108
|
+
}]
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const currentGitBlob = gitBlobSha(fs.readFileSync(destination));
|
|
113
|
+
if (currentGitBlob === manifest.targetGitBlob) return { rows: [], unsafe: [] };
|
|
114
|
+
if (!manifest.supportedPredecessorGitBlobs.includes(currentGitBlob)) {
|
|
115
|
+
return {
|
|
116
|
+
rows: [],
|
|
117
|
+
unsafe: [{
|
|
118
|
+
owner: 'bootstrap',
|
|
119
|
+
state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
|
|
120
|
+
path: manifest.destination,
|
|
121
|
+
detail: `Unknown/custom Bootstrap verifier identity ${currentGitBlob}; refusing to overwrite it.`
|
|
122
|
+
}]
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
rows: [{
|
|
128
|
+
owner: 'bootstrap',
|
|
129
|
+
state: 'MIGRATE_BOOTSTRAP_VERIFIER',
|
|
130
|
+
path: manifest.destination,
|
|
131
|
+
fromGitBlob: currentGitBlob,
|
|
132
|
+
toGitBlob: manifest.targetGitBlob,
|
|
133
|
+
source: manifest.source
|
|
134
|
+
}],
|
|
135
|
+
unsafe: []
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function applyBootstrapMigrations(packageData, migrationPlan, consumerRoot) {
|
|
140
|
+
if (!migrationPlan?.rows?.length) return;
|
|
141
|
+
if (migrationPlan.unsafe?.length) throw new Error('Refusing unsafe Bootstrap migration plan');
|
|
142
|
+
const { manifest, bytes } = loadManifest(packageData);
|
|
143
|
+
for (const row of migrationPlan.rows) {
|
|
144
|
+
if (row.state !== 'MIGRATE_BOOTSTRAP_VERIFIER' || row.path !== manifest.destination) {
|
|
145
|
+
throw new Error(`Unsupported Bootstrap migration row: ${row.state ?? 'unknown'}`);
|
|
146
|
+
}
|
|
147
|
+
const destination = safeConsumerPath(consumerRoot, row.path);
|
|
148
|
+
if (!fs.existsSync(destination) || fs.lstatSync(destination).isSymbolicLink() || !fs.lstatSync(destination).isFile()) {
|
|
149
|
+
throw new Error(`Bootstrap verifier changed after preview: ${row.path}`);
|
|
150
|
+
}
|
|
151
|
+
const currentGitBlob = gitBlobSha(fs.readFileSync(destination));
|
|
152
|
+
if (currentGitBlob !== row.fromGitBlob || !manifest.supportedPredecessorGitBlobs.includes(currentGitBlob)) {
|
|
153
|
+
throw new Error(`Bootstrap verifier changed after preview: ${row.path}`);
|
|
154
|
+
}
|
|
155
|
+
fs.writeFileSync(destination, bytes);
|
|
156
|
+
if (gitBlobSha(fs.readFileSync(destination)) !== manifest.targetGitBlob) {
|
|
157
|
+
throw new Error(`Bootstrap verifier migration verification failed: ${row.path}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
4
|
import readline from 'node:readline/promises';
|
|
5
5
|
import { stdin as input, stdout as output } from 'node:process';
|
|
6
|
+
import { applyBootstrapMigrations, planBootstrapMigrations } from './bootstrap-migrations.mjs';
|
|
6
7
|
import { applyProfile, checkProfile, loadProjectForgeBaseline, planProfile } from './engine.mjs';
|
|
7
8
|
import { loadPackageData } from './package-data.mjs';
|
|
8
9
|
|
|
@@ -43,15 +44,20 @@ function inferConsumer(root) {
|
|
|
43
44
|
return path.basename(root);
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
function printPlan(plan) {
|
|
47
|
+
function printPlan(plan, bootstrapMigrations = { rows: [], unsafe: [] }) {
|
|
47
48
|
console.log(`Project Forge profile: ${plan.profileName}`);
|
|
49
|
+
if (bootstrapMigrations.rows.length) {
|
|
50
|
+
console.log('\nBOOTSTRAP MIGRATIONS');
|
|
51
|
+
for (const row of bootstrapMigrations.rows) console.log(`${row.state.padEnd(30)} ${row.path}`);
|
|
52
|
+
}
|
|
48
53
|
for (const component of plan.plans) {
|
|
49
54
|
console.log(`\n${component.componentId.toUpperCase()} ${component.component.commit}`);
|
|
50
55
|
for (const row of component.rows) console.log(`${row.state.padEnd(20)} ${row.path}`);
|
|
51
56
|
for (const retired of component.retired) console.log(`${retired.state.padEnd(20)} ${retired.path}`);
|
|
52
57
|
}
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
const unsafe = [...bootstrapMigrations.unsafe, ...plan.unsafe];
|
|
59
|
+
console.log(`\nUnsafe drift/conflicts: ${unsafe.length}`);
|
|
60
|
+
for (const item of unsafe) console.log(` - ${item.owner ?? item.component}: ${item.state}: ${item.path}${item.detail ? ` — ${item.detail}` : ''}`);
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
async function shouldApply(options) {
|
|
@@ -113,13 +119,17 @@ export async function run(argv = process.argv.slice(2), packageData = loadPackag
|
|
|
113
119
|
|
|
114
120
|
if (command === 'check') {
|
|
115
121
|
const result = checkProfile(packageData, profile, root);
|
|
122
|
+
const bootstrapMigrations = planBootstrapMigrations(packageData, root);
|
|
123
|
+
const bootstrapCurrent = bootstrapMigrations.rows.length === 0 && bootstrapMigrations.unsafe.length === 0;
|
|
116
124
|
console.log(`Project Forge package baseline current: ${result.projectForgeCurrent ? 'yes' : 'no'}`);
|
|
125
|
+
console.log(`Bootstrap generated verifier: ${bootstrapCurrent ? 'PASS' : 'STALE OR UNSUPPORTED'}`);
|
|
117
126
|
for (const component of result.plan.plans) {
|
|
118
127
|
const clean = component.metadataCurrent && component.rows.every((row) => row.state === 'SAME') && component.retired.length === 0;
|
|
119
128
|
console.log(`${component.componentId}: ${clean ? 'PASS' : 'STALE OR DRIFTED'}`);
|
|
120
129
|
}
|
|
121
|
-
|
|
122
|
-
|
|
130
|
+
const clean = result.clean && bootstrapCurrent;
|
|
131
|
+
console.log(`Project Forge check: ${clean ? 'PASS' : 'STALE OR DRIFTED'}`);
|
|
132
|
+
return clean ? 0 : 1;
|
|
123
133
|
}
|
|
124
134
|
|
|
125
135
|
if (!['install', 'update'].includes(command)) throw new Error(`Unknown command: ${command}`);
|
|
@@ -127,10 +137,11 @@ export async function run(argv = process.argv.slice(2), packageData = loadPackag
|
|
|
127
137
|
if (command === 'update' && !Object.keys(installed).length) throw new Error('PROJECT_FORGE_BASELINE.json is missing; use install first');
|
|
128
138
|
if (installed.profile && installed.profile !== profile) throw new Error(`Installed Project Forge profile is ${installed.profile}; refusing implicit profile change to ${profile}`);
|
|
129
139
|
|
|
140
|
+
const bootstrapMigrations = planBootstrapMigrations(packageData, root);
|
|
130
141
|
const plan = planProfile(packageData, profile, root, options.acceptedRetired);
|
|
131
|
-
printPlan(plan);
|
|
132
|
-
if (plan.unsafe.length) {
|
|
133
|
-
console.log('\nRefusing apply. Resolve drift/conflicts and preview again. No managed files were changed.');
|
|
142
|
+
printPlan(plan, bootstrapMigrations);
|
|
143
|
+
if (plan.unsafe.length || bootstrapMigrations.unsafe.length) {
|
|
144
|
+
console.log('\nRefusing apply. Resolve drift/conflicts or the unsupported Bootstrap migration and preview again. No managed files were changed.');
|
|
134
145
|
return 2;
|
|
135
146
|
}
|
|
136
147
|
const apply = await shouldApply(options);
|
|
@@ -144,6 +155,7 @@ export async function run(argv = process.argv.slice(2), packageData = loadPackag
|
|
|
144
155
|
if (baselineLabels.length > 1) throw new Error(`Component baselines disagree on consumer baseline: ${baselineLabels.join(', ')}`);
|
|
145
156
|
const consumer = options.consumer ?? installed.consumer ?? baselineConsumers[0] ?? inferConsumer(root);
|
|
146
157
|
const consumerBaseline = options.consumerBaseline ?? installed.consumerBaseline ?? baselineLabels[0] ?? `${packageData.pkg.name}@${packageData.pkg.version}`;
|
|
158
|
+
applyBootstrapMigrations(packageData, bootstrapMigrations, root);
|
|
147
159
|
applyProfile(packageData, plan, root, consumer, consumerBaseline);
|
|
148
160
|
console.log(`\nProject Forge ${command}: PASS (${packageData.pkg.name}@${packageData.pkg.version})`);
|
|
149
161
|
return 0;
|