@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.
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.6.0",
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": "ff91df577814d4ef398fe5a931f8a8343a5c024c",
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": "880a911bc87e38c74ca4b336c9563a6f026ac3b4",
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.6.0",
4
+ "version": "4.7.0",
5
5
  "components": {
6
6
  "core": {
7
- "revision": "ff91df577814d4ef398fe5a931f8a8343a5c024c",
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": "0ba13a009347742ede2c6e6121a21f29640a05864a994c7a51b4c992c0b2c619"
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": "880a911bc87e38c74ca4b336c9563a6f026ac3b4",
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": "341e4682a050e9eea8c6ecf0e8728efacc57f06144d9eceb12ac37cf8fb2a3b4"
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": "f0946186e384794c81a78fbf50b6afdbbd79a0b22216034778fbb24d523696b6"
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
- For SSH target-access failure, prefer the Deployment Kit's structured environment setup status/plan evidence. Preserve distinct categories such as:
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 _check_posix_directories(path: Path) -> None:
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() or not directory.is_dir():
186
- raise DeploymentKitError(f"SSH directory must be a real directory: {directory}")
187
- if directory.stat().st_mode & (stat.S_IRWXG | stat.S_IRWXO):
188
- raise DeploymentKitError(f"SSH directory permissions are too broad: {directory}")
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 one SSH-material directory when absent, then verify its safety before secrets are written."""
210
+ """Create missing owned SSH directories safely without silently repairing existing unsafe state."""
193
211
  _ssh_directory_for(path)
194
- if path.exists():
195
- if path.is_symlink() or not path.is_dir():
196
- raise DeploymentKitError(f"SSH material directory is not a real directory: {path}")
197
- else:
198
- path.mkdir(parents=True, mode=0o700)
199
- if os.name != "nt":
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.6.0",
3
+ "version": "4.7.0",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- console.log(`\nUnsafe drift/conflicts: ${plan.unsafe.length}`);
54
- for (const item of plan.unsafe) console.log(` - ${item.component}: ${item.state}: ${item.path}`);
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
- console.log(`Project Forge check: ${result.clean ? 'PASS' : 'STALE OR DRIFTED'}`);
122
- return result.clean ? 0 : 1;
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;