@mriekki/project-forge 4.14.0 → 4.14.2
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/wsl-setup/setup-launcher.manifest.json +19 -0
- package/assets/bootstrap-migrations/wsl-setup/setup-wsl.manifest.json +21 -0
- package/assets/bootstrap-migrations/wsl-setup/setup-wsl.py +328 -0
- package/assets/bootstrap-migrations/wsl-setup/setup.sh +226 -0
- package/assets/components.json +1 -1
- package/assets/generated/ASSEMBLY.json +1 -1
- package/package.json +1 -1
- package/src/bootstrap-migrations.mjs +132 -62
- package/tools/windows/project-forge-wsl2.ps1 +30 -18
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"id": "bootstrap-wsl2-setup-launcher",
|
|
4
|
+
"owner": "bootstrap",
|
|
5
|
+
"destination": "scripts/setup.sh",
|
|
6
|
+
"asset": "assets/bootstrap-migrations/wsl-setup/setup.sh",
|
|
7
|
+
"source": {
|
|
8
|
+
"component": "bootstrap",
|
|
9
|
+
"revision": "173203baaf36935d04eb81c89f1d016131a40442",
|
|
10
|
+
"artifact": "templates/scripts/setup.sh.tpl"
|
|
11
|
+
},
|
|
12
|
+
"targetGitBlob": "4e8c48a3d33c6a8ae4534ec860c2ee0205e417aa",
|
|
13
|
+
"supportedPredecessorGitBlobs": [
|
|
14
|
+
"ec941c079da066785ac628d6bbb7dd6b668494f2"
|
|
15
|
+
],
|
|
16
|
+
"supportedBootstrapRevisions": [
|
|
17
|
+
"0c45eb332a815ad79ea061443f337500b9dc5712"
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"id": "bootstrap-wsl2-setup-runtime",
|
|
4
|
+
"owner": "bootstrap",
|
|
5
|
+
"destination": "tools/dev-environment/setup-wsl.py",
|
|
6
|
+
"asset": "assets/bootstrap-migrations/wsl-setup/setup-wsl.py",
|
|
7
|
+
"source": {
|
|
8
|
+
"component": "bootstrap",
|
|
9
|
+
"revision": "9f0f7fd4e0e2220250f6fd5b035706a7d15834af",
|
|
10
|
+
"artifact": "runtime/dev_environment_wsl.py"
|
|
11
|
+
},
|
|
12
|
+
"targetGitBlob": "883f7bbadd2de4bc0dcb601ee2285f88a0085b52",
|
|
13
|
+
"supportedPredecessorGitBlobs": [
|
|
14
|
+
"6731910246fb99887c58b9f3a8a14fec1bf02c01"
|
|
15
|
+
],
|
|
16
|
+
"supportedBootstrapRevisions": [
|
|
17
|
+
"0c45eb332a815ad79ea061443f337500b9dc5712",
|
|
18
|
+
"173203baaf36935d04eb81c89f1d016131a40442"
|
|
19
|
+
],
|
|
20
|
+
"allowMissing": true
|
|
21
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
15
|
+
CONFIG = ROOT / "DEV_ENVIRONMENT.json"
|
|
16
|
+
CATALOG = ROOT / "tools" / "dev-environment" / "catalog"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SetupError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_json(path: Path) -> dict[str, Any]:
|
|
24
|
+
try:
|
|
25
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
26
|
+
except FileNotFoundError as exc:
|
|
27
|
+
raise SetupError(f"Missing required file: {path}") from exc
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run(args: list[str], *, capture: bool = True) -> subprocess.CompletedProcess[str]:
|
|
31
|
+
return subprocess.run(
|
|
32
|
+
args,
|
|
33
|
+
cwd=str(ROOT),
|
|
34
|
+
text=True,
|
|
35
|
+
encoding="utf-8",
|
|
36
|
+
errors="replace",
|
|
37
|
+
capture_output=capture,
|
|
38
|
+
check=False,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_wsl() -> bool:
|
|
43
|
+
if os.environ.get("WSL_DISTRO_NAME"):
|
|
44
|
+
return True
|
|
45
|
+
try:
|
|
46
|
+
release = Path("/proc/sys/kernel/osrelease").read_text(encoding="utf-8").lower()
|
|
47
|
+
except OSError:
|
|
48
|
+
return False
|
|
49
|
+
return "microsoft" in release or "wsl" in release
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def command_path(command: str) -> str | None:
|
|
53
|
+
return shutil.which(command)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def version_ok(spec: dict[str, Any], version: str | None) -> tuple[bool, str]:
|
|
57
|
+
command = spec["command"]
|
|
58
|
+
if not command_path(command):
|
|
59
|
+
return False, "missing"
|
|
60
|
+
result = run([command, *spec.get("verifyArgs", [])])
|
|
61
|
+
detail = "\n".join(filter(None, [(result.stdout or "").strip(), (result.stderr or "").strip()]))
|
|
62
|
+
if result.returncode != 0:
|
|
63
|
+
return False, detail or f"exit {result.returncode}"
|
|
64
|
+
if not version:
|
|
65
|
+
return True, detail
|
|
66
|
+
|
|
67
|
+
handler = spec.get("handler")
|
|
68
|
+
if handler == "major-version":
|
|
69
|
+
found = re.search(r"(\d+)\.", detail)
|
|
70
|
+
return bool(found and found.group(1) == version.split(".")[0]), detail
|
|
71
|
+
if handler == "java-major":
|
|
72
|
+
found = re.search(r'(?:(?:version|openjdk)\s+\")?(\d+)', detail, flags=re.IGNORECASE)
|
|
73
|
+
return bool(found and found.group(1) == version.split(".")[0]), detail
|
|
74
|
+
return version in detail, detail
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ensure_apt_package(package: str) -> None:
|
|
78
|
+
if run(["dpkg", "-s", package]).returncode == 0:
|
|
79
|
+
return
|
|
80
|
+
result = run(["sudo", "apt-get", "update"], capture=False)
|
|
81
|
+
if result.returncode != 0:
|
|
82
|
+
raise SetupError("apt-get update failed; rerun from an interactive WSL terminal with sudo access")
|
|
83
|
+
result = run(["sudo", "apt-get", "install", "-y", package], capture=False)
|
|
84
|
+
if result.returncode != 0:
|
|
85
|
+
raise SetupError(f"Unable to install required Ubuntu package: {package}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def fnm_install_dir() -> Path:
|
|
89
|
+
xdg_data_home = os.environ.get("XDG_DATA_HOME")
|
|
90
|
+
base = Path(xdg_data_home).expanduser() if xdg_data_home else Path.home() / ".local" / "share"
|
|
91
|
+
return base / "fnm"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def refresh_fnm_path() -> None:
|
|
95
|
+
install_dir = fnm_install_dir()
|
|
96
|
+
fnm_binary = install_dir / "fnm"
|
|
97
|
+
if not fnm_binary.is_file():
|
|
98
|
+
return
|
|
99
|
+
current = os.environ.get("PATH", "")
|
|
100
|
+
prefix = str(install_dir)
|
|
101
|
+
parts = current.split(os.pathsep) if current else []
|
|
102
|
+
if prefix not in parts:
|
|
103
|
+
os.environ["PATH"] = prefix + (os.pathsep + current if current else "")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def ensure_fnm_available(check_only: bool) -> tuple[bool, str, str]:
|
|
107
|
+
refresh_fnm_path()
|
|
108
|
+
path = command_path("fnm")
|
|
109
|
+
if path:
|
|
110
|
+
result = run(["fnm", "--version"])
|
|
111
|
+
detail = "\n".join(filter(None, [(result.stdout or "").strip(), (result.stderr or "").strip()]))
|
|
112
|
+
return result.returncode == 0, detail or path, "PASS" if result.returncode == 0 else "FAILED"
|
|
113
|
+
|
|
114
|
+
if check_only:
|
|
115
|
+
return False, "fnm is missing in WSL", "MISSING"
|
|
116
|
+
|
|
117
|
+
ensure_apt_package("curl")
|
|
118
|
+
ensure_apt_package("unzip")
|
|
119
|
+
install = run(
|
|
120
|
+
["bash", "-lc", "curl -fsSL https://fnm.vercel.app/install | bash"],
|
|
121
|
+
capture=False,
|
|
122
|
+
)
|
|
123
|
+
if install.returncode != 0:
|
|
124
|
+
return False, "fnm installer failed", "FAILED"
|
|
125
|
+
|
|
126
|
+
refresh_fnm_path()
|
|
127
|
+
path = command_path("fnm")
|
|
128
|
+
if not path:
|
|
129
|
+
return False, f"fnm installer completed but {fnm_install_dir() / 'fnm'} is unavailable", "FAILED"
|
|
130
|
+
|
|
131
|
+
verify = run(["fnm", "--version"])
|
|
132
|
+
detail = "\n".join(filter(None, [(verify.stdout or "").strip(), (verify.stderr or "").strip()]))
|
|
133
|
+
return verify.returncode == 0, detail or path, "PASS" if verify.returncode == 0 else "FAILED"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def ensure_fnm_node(version: str | None, check_only: bool) -> tuple[bool, str, str]:
|
|
137
|
+
fnm_ok, fnm_detail, fnm_state = ensure_fnm_available(check_only)
|
|
138
|
+
if not fnm_ok:
|
|
139
|
+
return False, fnm_detail, fnm_state
|
|
140
|
+
if not version:
|
|
141
|
+
return True, fnm_detail or "fnm available", "PASS"
|
|
142
|
+
|
|
143
|
+
result = run(["fnm", "exec", f"--using={version}", "--", "node", "--version"])
|
|
144
|
+
detail = "\n".join(filter(None, [(result.stdout or "").strip(), (result.stderr or "").strip()]))
|
|
145
|
+
if result.returncode == 0 and version in detail.lstrip("v"):
|
|
146
|
+
return True, detail, "PASS"
|
|
147
|
+
if check_only:
|
|
148
|
+
return False, detail or f"Node {version} is not installed through fnm", "VERSION_MISMATCH"
|
|
149
|
+
|
|
150
|
+
install = run(["fnm", "install", version], capture=False)
|
|
151
|
+
if install.returncode != 0:
|
|
152
|
+
return False, f"fnm failed to install Node {version}", "FAILED"
|
|
153
|
+
default = run(["fnm", "default", version], capture=False)
|
|
154
|
+
if default.returncode != 0:
|
|
155
|
+
return False, f"fnm installed Node {version} but failed to make it the default", "FAILED"
|
|
156
|
+
|
|
157
|
+
verify = run(["fnm", "exec", f"--using={version}", "--", "node", "--version"])
|
|
158
|
+
text = (verify.stdout or verify.stderr or "").strip()
|
|
159
|
+
ok = verify.returncode == 0 and version in text.lstrip("v")
|
|
160
|
+
return ok, text or f"Node {version} verification failed", "PASS" if ok else "FAILED"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def ensure_tool(entry: dict[str, Any], check_only: bool) -> tuple[bool, str, str]:
|
|
164
|
+
tool_id = entry["id"]
|
|
165
|
+
spec = read_json(CATALOG / f"{tool_id}.json")
|
|
166
|
+
version = entry.get("version")
|
|
167
|
+
|
|
168
|
+
if tool_id == "fnm":
|
|
169
|
+
return ensure_fnm_available(check_only)
|
|
170
|
+
if tool_id == "node":
|
|
171
|
+
return ensure_fnm_node(version, check_only)
|
|
172
|
+
|
|
173
|
+
ok, detail = version_ok(spec, version)
|
|
174
|
+
if ok:
|
|
175
|
+
if tool_id == "docker":
|
|
176
|
+
operational = run(["docker", "info", "--format", "{{.ServerVersion}}"])
|
|
177
|
+
operational_detail = (operational.stdout or operational.stderr or "").strip()
|
|
178
|
+
if operational.returncode != 0:
|
|
179
|
+
return False, (
|
|
180
|
+
"Docker CLI is present but Docker Desktop is not operational through WSL integration. "
|
|
181
|
+
"Start Docker Desktop and enable this distro under Settings > Resources > WSL Integration. "
|
|
182
|
+
"Do not install a second Docker Engine inside WSL."
|
|
183
|
+
), "NOT_OPERATIONAL"
|
|
184
|
+
return True, operational_detail, "PASS"
|
|
185
|
+
return True, detail, "PASS"
|
|
186
|
+
|
|
187
|
+
if check_only:
|
|
188
|
+
return False, detail, "MISSING" if detail == "missing" else "VERSION_MISMATCH"
|
|
189
|
+
|
|
190
|
+
if tool_id == "python":
|
|
191
|
+
ensure_apt_package("python3")
|
|
192
|
+
ensure_apt_package("python-is-python3")
|
|
193
|
+
ok, detail = version_ok(spec, version)
|
|
194
|
+
return ok, detail, "PASS" if ok else "FAILED"
|
|
195
|
+
|
|
196
|
+
apt_packages = {
|
|
197
|
+
"git": "git",
|
|
198
|
+
"github-cli": "gh",
|
|
199
|
+
"sqlite": "sqlite3",
|
|
200
|
+
"ssh": "openssh-client",
|
|
201
|
+
"go": "golang-go",
|
|
202
|
+
}
|
|
203
|
+
if tool_id in apt_packages:
|
|
204
|
+
ensure_apt_package(apt_packages[tool_id])
|
|
205
|
+
ok, detail = version_ok(spec, version)
|
|
206
|
+
return ok, detail, "PASS" if ok else "FAILED"
|
|
207
|
+
|
|
208
|
+
if tool_id == "dotnet":
|
|
209
|
+
if not version:
|
|
210
|
+
return False, ".NET SDK version is required for automatic WSL installation", "FAILED"
|
|
211
|
+
major = version.split(".", 1)[0]
|
|
212
|
+
if not re.fullmatch(r"\d+", major):
|
|
213
|
+
return False, f"Unsupported .NET SDK version: {version}", "FAILED"
|
|
214
|
+
ensure_apt_package(f"dotnet-sdk-{major}.0")
|
|
215
|
+
ok, detail = version_ok(spec, version)
|
|
216
|
+
return ok, detail, "PASS" if ok else "FAILED"
|
|
217
|
+
|
|
218
|
+
if tool_id == "vscode":
|
|
219
|
+
return False, (
|
|
220
|
+
"VS Code's WSL command bridge is unavailable. Install VS Code on Windows and the "
|
|
221
|
+
"Remote - WSL extension, then open this repository with `code .` from WSL."
|
|
222
|
+
), "MISSING"
|
|
223
|
+
if tool_id == "docker":
|
|
224
|
+
return False, (
|
|
225
|
+
"Docker is unavailable inside WSL. Install/start Docker Desktop on Windows and enable "
|
|
226
|
+
"WSL integration for this distro; do not install Docker Engine inside WSL."
|
|
227
|
+
), "MISSING"
|
|
228
|
+
if tool_id == "java":
|
|
229
|
+
return False, (
|
|
230
|
+
f"Java {version or ''} is required inside WSL. Install the matching JDK in this distro, "
|
|
231
|
+
"then rerun /setup."
|
|
232
|
+
).strip(), "MISSING" if detail == "missing" else "VERSION_MISMATCH"
|
|
233
|
+
|
|
234
|
+
return False, detail or f"No WSL installer is defined for {tool_id}", "MISSING"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def ensure_extensions(extensions: list[str], check_only: bool) -> list[str]:
|
|
238
|
+
if not extensions or not command_path("code"):
|
|
239
|
+
return extensions if not command_path("code") else []
|
|
240
|
+
result = run(["code", "--list-extensions"])
|
|
241
|
+
installed = {line.strip().lower() for line in (result.stdout or "").splitlines() if line.strip()}
|
|
242
|
+
missing = [value for value in extensions if value.lower() not in installed]
|
|
243
|
+
if not check_only:
|
|
244
|
+
for extension in missing:
|
|
245
|
+
run(["code", "--install-extension", extension, "--force"], capture=False)
|
|
246
|
+
result = run(["code", "--list-extensions"])
|
|
247
|
+
installed = {line.strip().lower() for line in (result.stdout or "").splitlines() if line.strip()}
|
|
248
|
+
missing = [value for value in extensions if value.lower() not in installed]
|
|
249
|
+
return missing
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def restore_project(commands: list[list[str]], check_only: bool) -> list[str]:
|
|
253
|
+
if check_only:
|
|
254
|
+
return []
|
|
255
|
+
plan_path = ROOT / "SCAFFOLD_PLAN.json"
|
|
256
|
+
if plan_path.exists():
|
|
257
|
+
plan = read_json(plan_path)
|
|
258
|
+
missing_markers = [marker for marker in plan.get("markers", []) if not (ROOT / marker).exists()]
|
|
259
|
+
if missing_markers:
|
|
260
|
+
print("DEFERRED project restore — scaffold markers are not present")
|
|
261
|
+
return []
|
|
262
|
+
failures: list[str] = []
|
|
263
|
+
for command in commands:
|
|
264
|
+
if not command:
|
|
265
|
+
continue
|
|
266
|
+
result = run(command, capture=False)
|
|
267
|
+
if result.returncode != 0:
|
|
268
|
+
failures.append(" ".join(command))
|
|
269
|
+
return failures
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def main() -> int:
|
|
273
|
+
parser = argparse.ArgumentParser(description="Prepare/verify a Project Forge developer environment in WSL2")
|
|
274
|
+
parser.add_argument("--check", action="store_true")
|
|
275
|
+
parser.add_argument("--restore", action="store_true")
|
|
276
|
+
args = parser.parse_args()
|
|
277
|
+
|
|
278
|
+
if not is_wsl():
|
|
279
|
+
raise SetupError("This Linux setup runtime is supported only inside WSL2 for the current Windows development profile.")
|
|
280
|
+
if str(ROOT).startswith("/mnt/"):
|
|
281
|
+
raise SetupError(
|
|
282
|
+
"This Project is under a Windows-mounted filesystem. Project Forge's Windows default requires "
|
|
283
|
+
"a Linux-native WSL checkout such as ~/projects/<repo>. Migrate the repository first."
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
config = read_json(CONFIG)
|
|
287
|
+
declared = set(config.get("platforms", []))
|
|
288
|
+
if not ({"windows", "linux"} & declared):
|
|
289
|
+
raise SetupError("This Project does not declare a Windows/WSL-compatible development environment")
|
|
290
|
+
|
|
291
|
+
print(f"== Developer environment (WSL2: {os.environ.get('WSL_DISTRO_NAME', 'Linux')}) ==")
|
|
292
|
+
failed_required: list[str] = []
|
|
293
|
+
failed_optional: list[str] = []
|
|
294
|
+
for entry in config.get("tools", []):
|
|
295
|
+
required = entry.get("required", True)
|
|
296
|
+
ok, detail, state = ensure_tool(entry, args.check)
|
|
297
|
+
version = f" {entry['version']}" if entry.get("version") else ""
|
|
298
|
+
first = detail.splitlines()[0] if detail else ""
|
|
299
|
+
print(f"{state} {entry['id']}{version}" + (f" — {first}" if first else ""))
|
|
300
|
+
if not ok:
|
|
301
|
+
(failed_required if required else failed_optional).append(entry["id"])
|
|
302
|
+
|
|
303
|
+
missing_extensions = ensure_extensions(config.get("vscodeExtensions", []), args.check)
|
|
304
|
+
for extension in missing_extensions:
|
|
305
|
+
print(f"MISSING extension {extension}")
|
|
306
|
+
if missing_extensions:
|
|
307
|
+
failed_required.append("VS Code extensions")
|
|
308
|
+
|
|
309
|
+
restore_failures = restore_project(config.get("projectRestore", []), args.check) if args.restore else []
|
|
310
|
+
if restore_failures:
|
|
311
|
+
failed_required.append("project restore")
|
|
312
|
+
|
|
313
|
+
if failed_optional:
|
|
314
|
+
print("Optional tools not ready: " + ", ".join(failed_optional))
|
|
315
|
+
if failed_required:
|
|
316
|
+
print("Required environment items not ready: " + ", ".join(failed_required), file=sys.stderr)
|
|
317
|
+
return 2
|
|
318
|
+
|
|
319
|
+
print("Project Forge WSL2 setup: PASS")
|
|
320
|
+
return 0
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
if __name__ == "__main__":
|
|
324
|
+
try:
|
|
325
|
+
raise SystemExit(main())
|
|
326
|
+
except SetupError as exc:
|
|
327
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
328
|
+
raise SystemExit(2)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
5
|
+
SETUP_ENGINE="$ROOT/tools/dev-environment/setup.py"
|
|
6
|
+
WSL_SETUP_ENGINE="$ROOT/tools/dev-environment/setup-wsl.py"
|
|
7
|
+
|
|
8
|
+
if [[ "$(/usr/bin/uname -s)" == "Linux" ]]; then
|
|
9
|
+
if [[ -z "${WSL_DISTRO_NAME:-}" ]] && ! /bin/grep -Eqi '(microsoft|wsl)' /proc/sys/kernel/osrelease 2>/dev/null; then
|
|
10
|
+
echo "ERROR: this generated Linux setup path currently supports WSL2 only." >&2
|
|
11
|
+
exit 2
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
PYTHON_BIN="$(command -v python3 || true)"
|
|
15
|
+
if [[ -z "$PYTHON_BIN" ]]; then
|
|
16
|
+
if [[ " ${*:-} " == *" --check "* ]]; then
|
|
17
|
+
echo "ERROR: python3 is required to run the read-only Project Forge WSL2 setup check." >&2
|
|
18
|
+
exit 2
|
|
19
|
+
fi
|
|
20
|
+
echo "Project Forge: installing the Python setup runtime inside WSL."
|
|
21
|
+
sudo apt-get update
|
|
22
|
+
sudo apt-get install -y python3
|
|
23
|
+
PYTHON_BIN="$(command -v python3 || true)"
|
|
24
|
+
if [[ -z "$PYTHON_BIN" ]]; then
|
|
25
|
+
echo "ERROR: python3 installation completed but python3 is still unavailable." >&2
|
|
26
|
+
exit 2
|
|
27
|
+
fi
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
exec "$PYTHON_BIN" "$WSL_SETUP_ENGINE" "$@"
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# Project Forge treats Homebrew as the macOS package-management substrate for
|
|
34
|
+
# generated workstation setup. Keep the installer immutable and verify the
|
|
35
|
+
# exact Git blob before executing it; never execute Homebrew/install HEAD.
|
|
36
|
+
HOMEBREW_INSTALL_REVISION="85d1a3fc80d8a63932f632aca8f787de11d14a64"
|
|
37
|
+
HOMEBREW_INSTALL_BLOB_SHA="30615f54182a9d600455ad979783f3cebcdc58aa"
|
|
38
|
+
HOMEBREW_INSTALL_URL="https://raw.githubusercontent.com/Homebrew/install/${HOMEBREW_INSTALL_REVISION}/install.sh"
|
|
39
|
+
|
|
40
|
+
CHECK_ONLY=0
|
|
41
|
+
for argument in "$@"; do
|
|
42
|
+
if [[ "$argument" == "--check" ]]; then
|
|
43
|
+
CHECK_ONLY=1
|
|
44
|
+
break
|
|
45
|
+
fi
|
|
46
|
+
done
|
|
47
|
+
|
|
48
|
+
find_brew() {
|
|
49
|
+
local candidate
|
|
50
|
+
if command -v brew >/dev/null 2>&1; then
|
|
51
|
+
command -v brew
|
|
52
|
+
return 0
|
|
53
|
+
fi
|
|
54
|
+
for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew; do
|
|
55
|
+
if [[ -x "$candidate" ]]; then
|
|
56
|
+
printf '%s\n' "$candidate"
|
|
57
|
+
return 0
|
|
58
|
+
fi
|
|
59
|
+
done
|
|
60
|
+
return 1
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
activate_brew() {
|
|
64
|
+
local brew_bin="$1"
|
|
65
|
+
local python_prefix=""
|
|
66
|
+
eval "$("$brew_bin" shellenv)"
|
|
67
|
+
|
|
68
|
+
# Homebrew intentionally keeps unversioned Python shims such as `python`
|
|
69
|
+
# under the Python formula's libexec/bin. The generated catalog historically
|
|
70
|
+
# probes `python`, so expose that formula-owned directory to this setup
|
|
71
|
+
# process without modifying the developer's shell startup files.
|
|
72
|
+
python_prefix="$("$brew_bin" --prefix python 2>/dev/null || true)"
|
|
73
|
+
if [[ -n "$python_prefix" && -d "$python_prefix/libexec/bin" ]]; then
|
|
74
|
+
export PATH="$python_prefix/libexec/bin:$PATH"
|
|
75
|
+
fi
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
verify_homebrew_installer() {
|
|
79
|
+
local installer="$1"
|
|
80
|
+
local size actual
|
|
81
|
+
size="$(/usr/bin/wc -c < "$installer" | /usr/bin/tr -d '[:space:]')"
|
|
82
|
+
actual="$({ printf 'blob %s\0' "$size"; /bin/cat "$installer"; } | /usr/bin/shasum -a 1 | /usr/bin/awk '{print $1}')"
|
|
83
|
+
if [[ "$actual" != "$HOMEBREW_INSTALL_BLOB_SHA" ]]; then
|
|
84
|
+
echo "ERROR: Homebrew installer integrity mismatch." >&2
|
|
85
|
+
echo "Expected Git blob $HOMEBREW_INSTALL_BLOB_SHA but got $actual." >&2
|
|
86
|
+
return 2
|
|
87
|
+
fi
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
install_homebrew() {
|
|
91
|
+
local installer
|
|
92
|
+
if [[ "$(/usr/bin/uname -s)" != "Darwin" ]]; then
|
|
93
|
+
echo "ERROR: macOS setup launcher cannot bootstrap Homebrew on this platform." >&2
|
|
94
|
+
return 2
|
|
95
|
+
fi
|
|
96
|
+
if [[ ! -x /usr/bin/curl ]]; then
|
|
97
|
+
echo "ERROR: macOS setup requires /usr/bin/curl to bootstrap Homebrew." >&2
|
|
98
|
+
return 2
|
|
99
|
+
fi
|
|
100
|
+
if [[ ! -x /usr/bin/shasum ]]; then
|
|
101
|
+
echo "ERROR: macOS setup requires /usr/bin/shasum to verify the Homebrew installer." >&2
|
|
102
|
+
return 2
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
installer="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/project-forge-homebrew.XXXXXX")"
|
|
106
|
+
if ! /usr/bin/curl -fsSL "$HOMEBREW_INSTALL_URL" -o "$installer"; then
|
|
107
|
+
/bin/rm -f "$installer"
|
|
108
|
+
echo "ERROR: Unable to download the pinned Homebrew installer." >&2
|
|
109
|
+
return 2
|
|
110
|
+
fi
|
|
111
|
+
if ! verify_homebrew_installer "$installer"; then
|
|
112
|
+
/bin/rm -f "$installer"
|
|
113
|
+
return 2
|
|
114
|
+
fi
|
|
115
|
+
|
|
116
|
+
echo "Project Forge: Homebrew is required for supported macOS tool installation."
|
|
117
|
+
echo "Project Forge: running verified Homebrew installer ${HOMEBREW_INSTALL_REVISION}."
|
|
118
|
+
if [[ -t 0 ]]; then
|
|
119
|
+
if ! /bin/bash "$installer"; then
|
|
120
|
+
/bin/rm -f "$installer"
|
|
121
|
+
echo "ERROR: Homebrew bootstrap did not complete." >&2
|
|
122
|
+
echo "If macOS requested administrator or Command Line Tools approval, complete that native prompt and rerun /setup." >&2
|
|
123
|
+
return 2
|
|
124
|
+
fi
|
|
125
|
+
else
|
|
126
|
+
if ! NONINTERACTIVE=1 /bin/bash "$installer"; then
|
|
127
|
+
/bin/rm -f "$installer"
|
|
128
|
+
echo "ERROR: Homebrew bootstrap needs macOS administrator or Command Line Tools interaction that is unavailable in this non-interactive terminal." >&2
|
|
129
|
+
echo "Rerun /setup from an interactive terminal; Project Forge will continue the same setup flow without requiring manual install commands." >&2
|
|
130
|
+
return 2
|
|
131
|
+
fi
|
|
132
|
+
fi
|
|
133
|
+
/bin/rm -f "$installer"
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
preflight_interactive_casks() {
|
|
137
|
+
local pending
|
|
138
|
+
pending="$("$PYTHON_BIN" - "$ROOT" "$BREW_BIN" <<'PY'
|
|
139
|
+
import json
|
|
140
|
+
import subprocess
|
|
141
|
+
import sys
|
|
142
|
+
from pathlib import Path
|
|
143
|
+
|
|
144
|
+
root = Path(sys.argv[1])
|
|
145
|
+
brew = sys.argv[2]
|
|
146
|
+
config = json.loads((root / "DEV_ENVIRONMENT.json").read_text(encoding="utf-8"))
|
|
147
|
+
catalog = root / "tools" / "dev-environment" / "catalog"
|
|
148
|
+
pending = []
|
|
149
|
+
for entry in config.get("tools", []):
|
|
150
|
+
spec = json.loads((catalog / f"{entry['id']}.json").read_text(encoding="utf-8"))
|
|
151
|
+
install = spec.get("install", {}).get("macos") or {}
|
|
152
|
+
if install.get("interactive") is not True:
|
|
153
|
+
continue
|
|
154
|
+
package = install.get("package")
|
|
155
|
+
if not package and install.get("packageTemplate"):
|
|
156
|
+
version = entry.get("version")
|
|
157
|
+
if not version:
|
|
158
|
+
continue
|
|
159
|
+
package = install["packageTemplate"].format(major=version.split(".")[0], version=version)
|
|
160
|
+
if not package:
|
|
161
|
+
continue
|
|
162
|
+
result = subprocess.run(
|
|
163
|
+
[brew, "list", "--cask", package],
|
|
164
|
+
check=False,
|
|
165
|
+
stdout=subprocess.DEVNULL,
|
|
166
|
+
stderr=subprocess.DEVNULL,
|
|
167
|
+
)
|
|
168
|
+
if result.returncode != 0:
|
|
169
|
+
pending.append(f"{entry['id']} ({package})")
|
|
170
|
+
print(", ".join(pending))
|
|
171
|
+
PY
|
|
172
|
+
)"
|
|
173
|
+
|
|
174
|
+
if [[ -z "$pending" ]]; then
|
|
175
|
+
return 0
|
|
176
|
+
fi
|
|
177
|
+
|
|
178
|
+
echo "Project Forge: macOS interactive-install preflight: $pending"
|
|
179
|
+
echo "Project Forge: these declared tools may require an administrator/native installer prompt during this setup run."
|
|
180
|
+
if [[ ! -t 0 ]]; then
|
|
181
|
+
echo "ERROR: required macOS tool installation needs an interactive terminal before Project Forge starts the install sequence." >&2
|
|
182
|
+
echo "Rerun this same generated setup command from an interactive terminal; do not install the listed packages manually." >&2
|
|
183
|
+
return 2
|
|
184
|
+
fi
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
BREW_BIN="$(find_brew || true)"
|
|
188
|
+
if [[ -z "$BREW_BIN" && "$CHECK_ONLY" -eq 0 ]]; then
|
|
189
|
+
install_homebrew
|
|
190
|
+
BREW_BIN="$(find_brew || true)"
|
|
191
|
+
if [[ -z "$BREW_BIN" ]]; then
|
|
192
|
+
echo "ERROR: Homebrew installation completed but brew is still unavailable." >&2
|
|
193
|
+
exit 2
|
|
194
|
+
fi
|
|
195
|
+
fi
|
|
196
|
+
|
|
197
|
+
if [[ -n "$BREW_BIN" ]]; then
|
|
198
|
+
activate_brew "$BREW_BIN"
|
|
199
|
+
fi
|
|
200
|
+
|
|
201
|
+
PYTHON_BIN="$(command -v python3 || true)"
|
|
202
|
+
if [[ -z "$PYTHON_BIN" ]]; then
|
|
203
|
+
if [[ "$CHECK_ONLY" -eq 1 ]]; then
|
|
204
|
+
echo "ERROR: python3 is required to run the read-only Project Forge setup check." >&2
|
|
205
|
+
exit 2
|
|
206
|
+
fi
|
|
207
|
+
if [[ -z "$BREW_BIN" ]]; then
|
|
208
|
+
echo "ERROR: Homebrew is unavailable, so Project Forge cannot install the Python setup runtime." >&2
|
|
209
|
+
exit 2
|
|
210
|
+
fi
|
|
211
|
+
|
|
212
|
+
echo "Project Forge: installing the Python setup runtime through Homebrew."
|
|
213
|
+
"$BREW_BIN" install python
|
|
214
|
+
activate_brew "$BREW_BIN"
|
|
215
|
+
PYTHON_BIN="$(command -v python3 || true)"
|
|
216
|
+
if [[ -z "$PYTHON_BIN" ]]; then
|
|
217
|
+
echo "ERROR: Homebrew installed Python but python3 is still unavailable." >&2
|
|
218
|
+
exit 2
|
|
219
|
+
fi
|
|
220
|
+
fi
|
|
221
|
+
|
|
222
|
+
if [[ "$CHECK_ONLY" -eq 0 && -n "$BREW_BIN" ]]; then
|
|
223
|
+
preflight_interactive_casks
|
|
224
|
+
fi
|
|
225
|
+
|
|
226
|
+
exec "$PYTHON_BIN" "$SETUP_ENGINE" "$@"
|
package/assets/components.json
CHANGED
package/package.json
CHANGED
|
@@ -2,7 +2,11 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
const MANIFEST_RELATIVES = [
|
|
6
|
+
'assets/bootstrap-migrations/managed-baseline-verifier/manifest.json',
|
|
7
|
+
'assets/bootstrap-migrations/wsl-setup/setup-launcher.manifest.json',
|
|
8
|
+
'assets/bootstrap-migrations/wsl-setup/setup-wsl.manifest.json'
|
|
9
|
+
];
|
|
6
10
|
const BOOTSTRAP_MANIFEST = 'PROJECT_BOOTSTRAP.json';
|
|
7
11
|
|
|
8
12
|
function readJson(file) {
|
|
@@ -37,8 +41,23 @@ function safeConsumerPath(root, relative) {
|
|
|
37
41
|
return resolved;
|
|
38
42
|
}
|
|
39
43
|
|
|
40
|
-
function
|
|
41
|
-
const
|
|
44
|
+
function assertSafeExistingPath(root, destination, relative, kind = 'file') {
|
|
45
|
+
const stat = fs.lstatSync(destination);
|
|
46
|
+
if (kind === 'file' && (!stat.isFile() || stat.isSymbolicLink())) {
|
|
47
|
+
throw new Error(`Bootstrap migration destination is not a regular file: ${relative}`);
|
|
48
|
+
}
|
|
49
|
+
if (kind === 'directory' && (!stat.isDirectory() || stat.isSymbolicLink())) {
|
|
50
|
+
throw new Error(`Bootstrap migration parent is not a regular directory: ${relative}`);
|
|
51
|
+
}
|
|
52
|
+
const realRoot = fs.realpathSync(root);
|
|
53
|
+
const realDestination = fs.realpathSync(destination);
|
|
54
|
+
if (realDestination !== realRoot && !realDestination.startsWith(`${realRoot}${path.sep}`)) {
|
|
55
|
+
throw new Error(`Bootstrap migration path resolves outside the Project root: ${relative}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function loadManifest(packageData, manifestRelative) {
|
|
60
|
+
const file = path.join(packageData.root, ...manifestRelative.split('/'));
|
|
42
61
|
const manifest = readJson(file);
|
|
43
62
|
if (manifest.schemaVersion !== 1 || manifest.owner !== 'bootstrap') {
|
|
44
63
|
throw new Error('Invalid Bootstrap migration manifest ownership/schema');
|
|
@@ -56,105 +75,156 @@ function loadManifest(packageData) {
|
|
|
56
75
|
if (!Array.isArray(manifest.supportedPredecessorGitBlobs) || !manifest.supportedPredecessorGitBlobs.every((value) => /^[0-9a-f]{40}$/.test(value))) {
|
|
57
76
|
throw new Error('Invalid Bootstrap migration predecessor identities');
|
|
58
77
|
}
|
|
78
|
+
if (manifest.supportedBootstrapRevisions !== undefined && (
|
|
79
|
+
!Array.isArray(manifest.supportedBootstrapRevisions)
|
|
80
|
+
|| !manifest.supportedBootstrapRevisions.every((value) => /^[0-9a-f]{40}$/.test(value))
|
|
81
|
+
)) {
|
|
82
|
+
throw new Error('Invalid Bootstrap migration supported Bootstrap revisions');
|
|
83
|
+
}
|
|
84
|
+
if (manifest.allowMissing !== undefined && typeof manifest.allowMissing !== 'boolean') {
|
|
85
|
+
throw new Error('Invalid Bootstrap migration allowMissing value');
|
|
86
|
+
}
|
|
59
87
|
const assetRelative = safeRelative(manifest.asset, 'Bootstrap migration bundled asset');
|
|
60
88
|
const asset = path.join(packageData.root, ...assetRelative.split('/'));
|
|
61
89
|
const bytes = fs.readFileSync(asset);
|
|
62
90
|
if (gitBlobSha(bytes) !== manifest.targetGitBlob) {
|
|
63
|
-
throw new Error(
|
|
91
|
+
throw new Error(`Bundled Bootstrap migration asset identity does not match its manifest: ${manifest.id}`);
|
|
64
92
|
}
|
|
65
93
|
return { manifest, bytes };
|
|
66
94
|
}
|
|
67
95
|
|
|
96
|
+
function loadManifests(packageData) {
|
|
97
|
+
return MANIFEST_RELATIVES.map((relative) => loadManifest(packageData, relative));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function appliesToBootstrap(manifest, bootstrap) {
|
|
101
|
+
if (!manifest.supportedBootstrapRevisions) return true;
|
|
102
|
+
const revision = bootstrap?.bootstrap?.commit;
|
|
103
|
+
return typeof revision === 'string' && manifest.supportedBootstrapRevisions.includes(revision);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function migrationState(manifest) {
|
|
107
|
+
return manifest.id === 'bootstrap-managed-baseline-verifier-schema2'
|
|
108
|
+
? 'MIGRATE_BOOTSTRAP_VERIFIER'
|
|
109
|
+
: 'MIGRATE_BOOTSTRAP_FILE';
|
|
110
|
+
}
|
|
111
|
+
|
|
68
112
|
export function planBootstrapMigrations(packageData, consumerRoot) {
|
|
69
113
|
const bootstrapManifest = path.join(consumerRoot, BOOTSTRAP_MANIFEST);
|
|
70
114
|
if (!fs.existsSync(bootstrapManifest)) return { rows: [], unsafe: [] };
|
|
71
115
|
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
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
|
-
}
|
|
116
|
+
const bootstrap = readJson(bootstrapManifest);
|
|
117
|
+
const rows = [];
|
|
118
|
+
const unsafe = [];
|
|
85
119
|
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
120
|
+
for (const { manifest } of loadManifests(packageData)) {
|
|
121
|
+
if (!appliesToBootstrap(manifest, bootstrap)) continue;
|
|
122
|
+
|
|
123
|
+
const destination = safeConsumerPath(consumerRoot, manifest.destination);
|
|
124
|
+
if (!fs.existsSync(destination)) {
|
|
125
|
+
if (manifest.allowMissing) {
|
|
126
|
+
const parent = path.dirname(destination);
|
|
127
|
+
try {
|
|
128
|
+
if (!fs.existsSync(parent)) throw new Error('parent missing');
|
|
129
|
+
assertSafeExistingPath(consumerRoot, parent, path.dirname(manifest.destination), 'directory');
|
|
130
|
+
rows.push({
|
|
131
|
+
owner: 'bootstrap',
|
|
132
|
+
state: 'ADD_BOOTSTRAP_FILE',
|
|
133
|
+
path: manifest.destination,
|
|
134
|
+
fromGitBlob: null,
|
|
135
|
+
toGitBlob: manifest.targetGitBlob,
|
|
136
|
+
source: manifest.source
|
|
137
|
+
});
|
|
138
|
+
} catch {
|
|
139
|
+
unsafe.push({
|
|
140
|
+
owner: 'bootstrap',
|
|
141
|
+
state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
|
|
142
|
+
path: manifest.destination,
|
|
143
|
+
detail: 'Bootstrap migration parent is missing, unsafe, or resolves outside the Project root.'
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
unsafe.push({
|
|
91
149
|
owner: 'bootstrap',
|
|
92
150
|
state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
|
|
93
151
|
path: manifest.destination,
|
|
94
|
-
detail: 'Bootstrap-generated
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
unsafe: [{
|
|
152
|
+
detail: 'Bootstrap-generated file is missing; refusing package mutation before a supported migration is established.'
|
|
153
|
+
});
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
assertSafeExistingPath(consumerRoot, destination, manifest.destination, 'file');
|
|
159
|
+
} catch (error) {
|
|
160
|
+
unsafe.push({
|
|
104
161
|
owner: 'bootstrap',
|
|
105
162
|
state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
|
|
106
163
|
path: manifest.destination,
|
|
107
|
-
detail:
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
|
|
164
|
+
detail: `${error.message}; refusing package mutation.`
|
|
165
|
+
});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
111
168
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
rows: [],
|
|
117
|
-
unsafe: [{
|
|
169
|
+
const currentGitBlob = gitBlobSha(fs.readFileSync(destination));
|
|
170
|
+
if (currentGitBlob === manifest.targetGitBlob) continue;
|
|
171
|
+
if (!manifest.supportedPredecessorGitBlobs.includes(currentGitBlob)) {
|
|
172
|
+
unsafe.push({
|
|
118
173
|
owner: 'bootstrap',
|
|
119
174
|
state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
|
|
120
175
|
path: manifest.destination,
|
|
121
|
-
detail: `Unknown/custom Bootstrap
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
176
|
+
detail: `Unknown/custom Bootstrap file identity ${currentGitBlob}; refusing to overwrite it.`
|
|
177
|
+
});
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
125
180
|
|
|
126
|
-
|
|
127
|
-
rows: [{
|
|
181
|
+
rows.push({
|
|
128
182
|
owner: 'bootstrap',
|
|
129
|
-
state:
|
|
183
|
+
state: migrationState(manifest),
|
|
130
184
|
path: manifest.destination,
|
|
131
185
|
fromGitBlob: currentGitBlob,
|
|
132
186
|
toGitBlob: manifest.targetGitBlob,
|
|
133
187
|
source: manifest.source
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { rows, unsafe };
|
|
137
192
|
}
|
|
138
193
|
|
|
139
194
|
export function applyBootstrapMigrations(packageData, migrationPlan, consumerRoot) {
|
|
140
195
|
if (!migrationPlan?.rows?.length) return;
|
|
141
196
|
if (migrationPlan.unsafe?.length) throw new Error('Refusing unsafe Bootstrap migration plan');
|
|
142
|
-
|
|
197
|
+
|
|
198
|
+
const byPath = new Map(loadManifests(packageData).map((entry) => [entry.manifest.destination, entry]));
|
|
143
199
|
for (const row of migrationPlan.rows) {
|
|
144
|
-
|
|
200
|
+
const entry = byPath.get(row.path);
|
|
201
|
+
if (!entry) throw new Error(`Unsupported Bootstrap migration row path: ${row.path}`);
|
|
202
|
+
const { manifest, bytes } = entry;
|
|
203
|
+
if (!['MIGRATE_BOOTSTRAP_VERIFIER', 'MIGRATE_BOOTSTRAP_FILE', 'ADD_BOOTSTRAP_FILE'].includes(row.state)) {
|
|
145
204
|
throw new Error(`Unsupported Bootstrap migration row: ${row.state ?? 'unknown'}`);
|
|
146
205
|
}
|
|
206
|
+
|
|
147
207
|
const destination = safeConsumerPath(consumerRoot, row.path);
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
throw new Error(`Bootstrap
|
|
208
|
+
if (row.state === 'ADD_BOOTSTRAP_FILE') {
|
|
209
|
+
if (!manifest.allowMissing || fs.existsSync(destination)) {
|
|
210
|
+
throw new Error(`Bootstrap file changed after preview: ${row.path}`);
|
|
211
|
+
}
|
|
212
|
+
const parent = path.dirname(destination);
|
|
213
|
+
if (!fs.existsSync(parent)) throw new Error(`Bootstrap migration parent changed after preview: ${row.path}`);
|
|
214
|
+
assertSafeExistingPath(consumerRoot, parent, path.dirname(row.path), 'directory');
|
|
215
|
+
fs.writeFileSync(destination, bytes);
|
|
216
|
+
} else {
|
|
217
|
+
if (!fs.existsSync(destination)) throw new Error(`Bootstrap file changed after preview: ${row.path}`);
|
|
218
|
+
assertSafeExistingPath(consumerRoot, destination, row.path, 'file');
|
|
219
|
+
const currentGitBlob = gitBlobSha(fs.readFileSync(destination));
|
|
220
|
+
if (currentGitBlob !== row.fromGitBlob || !manifest.supportedPredecessorGitBlobs.includes(currentGitBlob)) {
|
|
221
|
+
throw new Error(`Bootstrap file changed after preview: ${row.path}`);
|
|
222
|
+
}
|
|
223
|
+
fs.writeFileSync(destination, bytes);
|
|
154
224
|
}
|
|
155
|
-
|
|
225
|
+
|
|
156
226
|
if (gitBlobSha(fs.readFileSync(destination)) !== manifest.targetGitBlob) {
|
|
157
|
-
throw new Error(`Bootstrap
|
|
227
|
+
throw new Error(`Bootstrap migration verification failed: ${row.path}`);
|
|
158
228
|
}
|
|
159
229
|
}
|
|
160
230
|
}
|
|
@@ -16,7 +16,7 @@ function Write-Step([string]$Message) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
function Invoke-Wsl {
|
|
19
|
-
param([Parameter(
|
|
19
|
+
param([Parameter(Mandatory = $true)][string[]]$Arguments)
|
|
20
20
|
$output = & wsl.exe -d $Distro -- @Arguments 2>&1
|
|
21
21
|
if ($LASTEXITCODE -ne 0) {
|
|
22
22
|
throw "WSL command failed ($LASTEXITCODE): $($Arguments -join ' ')`n$output"
|
|
@@ -35,16 +35,28 @@ function Get-GitValue([string]$Repository, [string[]]$Arguments) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function Convert-ToWslPath([string]$WindowsPath) {
|
|
38
|
-
|
|
38
|
+
$resolved = (Resolve-Path $WindowsPath).Path
|
|
39
|
+
if ($resolved -match '^([A-Za-z]):\\(.*)$') {
|
|
40
|
+
$drive = $Matches[1].ToLowerInvariant()
|
|
41
|
+
$rest = $Matches[2] -replace '\\', '/'
|
|
42
|
+
return "/mnt/$drive/$rest"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
throw "Unsupported Windows path for WSL conversion: '$resolved'. Expected a local drive-letter path such as C:\\Projects\\repo."
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
function Resolve-WslDestinationRoot {
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
if ($DestinationRoot.StartsWith("/")) { return $DestinationRoot }
|
|
50
|
+
|
|
51
|
+
$wslHome = ((Invoke-Wsl -Arguments @("sh", "-lc", 'printf %s "$HOME"')) -join "`n").Trim()
|
|
52
|
+
if (-not $wslHome) {
|
|
53
|
+
throw "Could not resolve the WSL home directory for '$Distro'. Supply -DestinationRoot with an absolute Linux path such as /home/<user>/projects."
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if ($DestinationRoot -eq "~") { return $wslHome }
|
|
44
57
|
if ($DestinationRoot.StartsWith("~/")) {
|
|
45
|
-
return "$
|
|
58
|
+
return "$wslHome/$($DestinationRoot.Substring(2))"
|
|
46
59
|
}
|
|
47
|
-
if ($DestinationRoot.StartsWith("/")) { return $DestinationRoot }
|
|
48
60
|
throw "DestinationRoot must be an absolute Linux path or start with ~/ (received '$DestinationRoot')."
|
|
49
61
|
}
|
|
50
62
|
|
|
@@ -115,14 +127,14 @@ if ($missingLinuxTools.Count -gt 0) {
|
|
|
115
127
|
if (-not $InstallMissing) {
|
|
116
128
|
throw "Missing WSL tools: $($missingLinuxTools -join ', '). Re-run with -InstallMissing."
|
|
117
129
|
}
|
|
118
|
-
Invoke-Wsl sudo apt-get update | Out-Null
|
|
119
|
-
Invoke-Wsl sudo apt-get install -y git curl ca-certificates tar build-essential | Out-Null
|
|
130
|
+
Invoke-Wsl -Arguments @("sudo", "apt-get", "update") | Out-Null
|
|
131
|
+
Invoke-Wsl -Arguments @("sudo", "apt-get", "install", "-y", "git", "curl", "ca-certificates", "tar", "build-essential") | Out-Null
|
|
120
132
|
}
|
|
121
133
|
|
|
122
134
|
# Use Linux line endings for new checkouts unless the Builder already chose another global policy.
|
|
123
135
|
$wslAutocrlf = (& wsl.exe -d $Distro -- git config --global --get core.autocrlf 2>$null | Out-String).Trim()
|
|
124
136
|
if (-not $wslAutocrlf) {
|
|
125
|
-
Invoke-Wsl git config --global core.autocrlf input | Out-Null
|
|
137
|
+
Invoke-Wsl -Arguments @("git", "config", "--global", "core.autocrlf", "input") | Out-Null
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
# Carry Git identity into WSL only when WSL does not already have one.
|
|
@@ -131,7 +143,7 @@ if (Test-Command "git") {
|
|
|
131
143
|
$existing = (& wsl.exe -d $Distro -- git config --global --get $key 2>$null | Out-String).Trim()
|
|
132
144
|
if (-not $existing) {
|
|
133
145
|
$hostValue = (& git config --global --get $key 2>$null | Out-String).Trim()
|
|
134
|
-
if ($hostValue) { Invoke-Wsl git config --global $key $hostValue | Out-Null }
|
|
146
|
+
if ($hostValue) { Invoke-Wsl -Arguments @("git", "config", "--global", $key, $hostValue) | Out-Null }
|
|
135
147
|
}
|
|
136
148
|
}
|
|
137
149
|
}
|
|
@@ -171,13 +183,13 @@ if (-not (Test-Command "git")) {
|
|
|
171
183
|
}
|
|
172
184
|
|
|
173
185
|
$destinationRootLinux = Resolve-WslDestinationRoot
|
|
174
|
-
Invoke-Wsl mkdir -p $destinationRootLinux | Out-Null
|
|
186
|
+
Invoke-Wsl -Arguments @("mkdir", "-p", $destinationRootLinux) | Out-Null
|
|
175
187
|
$repositories = @(Get-RepositoryCandidates $SourceRoot)
|
|
176
188
|
if ($repositories.Count -eq 0) {
|
|
177
189
|
throw "No Git repositories found at '$SourceRoot'."
|
|
178
190
|
}
|
|
179
191
|
|
|
180
|
-
Write-Step "Migrating $($repositories.Count) repository/repositories to $Distro:$destinationRootLinux"
|
|
192
|
+
Write-Step "Migrating $($repositories.Count) repository/repositories to ${Distro}:$destinationRootLinux"
|
|
181
193
|
$migrated = New-Object System.Collections.Generic.List[string]
|
|
182
194
|
$skipped = New-Object System.Collections.Generic.List[string]
|
|
183
195
|
|
|
@@ -213,13 +225,13 @@ foreach ($repo in $repositories) {
|
|
|
213
225
|
}
|
|
214
226
|
|
|
215
227
|
# Clone from the local clean repository so committed local-only refs remain available during migration.
|
|
216
|
-
Invoke-Wsl git clone --no-hardlinks $sourceLinux $destinationLinux | Out-Null
|
|
228
|
+
Invoke-Wsl -Arguments @("git", "clone", "--no-hardlinks", $sourceLinux, $destinationLinux) | Out-Null
|
|
217
229
|
if ($origin) {
|
|
218
|
-
Invoke-Wsl git -C $destinationLinux remote set-url origin $origin | Out-Null
|
|
230
|
+
Invoke-Wsl -Arguments @("git", "-C", $destinationLinux, "remote", "set-url", "origin", $origin) | Out-Null
|
|
219
231
|
}
|
|
220
232
|
|
|
221
|
-
$destHead = ((Invoke-Wsl git -C $destinationLinux rev-parse HEAD) -join "`n").Trim()
|
|
222
|
-
$destDirty = ((Invoke-Wsl git -C $destinationLinux status --porcelain=v1) -join "`n").Trim()
|
|
233
|
+
$destHead = ((Invoke-Wsl -Arguments @("git", "-C", $destinationLinux, "rev-parse", "HEAD")) -join "`n").Trim()
|
|
234
|
+
$destDirty = ((Invoke-Wsl -Arguments @("git", "-C", $destinationLinux, "status", "--porcelain=v1")) -join "`n").Trim()
|
|
223
235
|
if ($destHead -ne $head) {
|
|
224
236
|
throw "Migration verification failed for '$name': source HEAD $head != destination HEAD $destHead. Windows source was left untouched."
|
|
225
237
|
}
|
|
@@ -228,13 +240,13 @@ foreach ($repo in $repositories) {
|
|
|
228
240
|
}
|
|
229
241
|
|
|
230
242
|
if ($branch) {
|
|
231
|
-
$destBranch = ((Invoke-Wsl git -C $destinationLinux branch --show-current) -join "`n").Trim()
|
|
243
|
+
$destBranch = ((Invoke-Wsl -Arguments @("git", "-C", $destinationLinux, "branch", "--show-current")) -join "`n").Trim()
|
|
232
244
|
if ($destBranch -ne $branch) {
|
|
233
245
|
Write-Warning "'$name' migrated at the correct HEAD, but checked-out branch differs (source '$branch', destination '$destBranch')."
|
|
234
246
|
}
|
|
235
247
|
}
|
|
236
248
|
|
|
237
|
-
Write-Host "Migrated: $repo -> $Distro:$destinationLinux"
|
|
249
|
+
Write-Host "Migrated: $repo -> ${Distro}:$destinationLinux"
|
|
238
250
|
$migrated.Add($destinationLinux)
|
|
239
251
|
}
|
|
240
252
|
|