@mriekki/project-forge 4.13.5 → 4.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/assets/bootstrap-migrations/wsl-setup/setup-launcher.manifest.json +19 -0
- package/assets/bootstrap-migrations/wsl-setup/setup-wsl.manifest.json +18 -0
- package/assets/bootstrap-migrations/wsl-setup/setup-wsl.py +266 -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 +2 -1
- package/src/bootstrap-migrations.mjs +132 -62
- package/tools/windows/project-forge-wsl2.ps1 +265 -0
package/README.md
CHANGED
|
@@ -94,6 +94,8 @@ clone repository
|
|
|
94
94
|
|
|
95
95
|
Project Forge supports independent workspace instances on multiple developer computers. Git/GitHub is the shared source boundary; local databases, credentials, SSH keys/trust, Docker state, and readiness stay workstation-local.
|
|
96
96
|
|
|
97
|
+
On Windows, Project Forge defaults compatible development to **WSL2 with Ubuntu**: keep repositories in the WSL Linux filesystem (normally `~/projects`), run shells/toolchains from WSL, keep desktop UI applications on Windows, and use Docker Desktop with its WSL2 backend/integration rather than installing a second Docker Engine inside the distro. Native Windows remains valid for Projects with a real Windows-specific requirement. Existing Windows checkouts can be migrated non-destructively with [`tools/windows/project-forge-wsl2.ps1`](tools/windows/project-forge-wsl2.ps1); see [`docs/WINDOWS_WSL2.md`](docs/WINDOWS_WSL2.md).
|
|
98
|
+
|
|
97
99
|
## Public ChatGPT contract
|
|
98
100
|
|
|
99
101
|
Project Forge's public ChatGPT bootstrap entrypoint is [`chatgpt/START_HERE.md`](chatgpt/START_HERE.md).
|
|
@@ -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,18 @@
|
|
|
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": "173203baaf36935d04eb81c89f1d016131a40442",
|
|
10
|
+
"artifact": "runtime/dev_environment_wsl.py"
|
|
11
|
+
},
|
|
12
|
+
"targetGitBlob": "6731910246fb99887c58b9f3a8a14fec1bf02c01",
|
|
13
|
+
"supportedPredecessorGitBlobs": [],
|
|
14
|
+
"supportedBootstrapRevisions": [
|
|
15
|
+
"0c45eb332a815ad79ea061443f337500b9dc5712"
|
|
16
|
+
],
|
|
17
|
+
"allowMissing": true
|
|
18
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
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 ensure_fnm_node(version: str | None, check_only: bool) -> tuple[bool, str, str]:
|
|
89
|
+
if not command_path("fnm"):
|
|
90
|
+
return False, "fnm is missing in WSL; install fnm inside the distro before rerunning /setup", "MISSING"
|
|
91
|
+
if not version:
|
|
92
|
+
return True, "fnm available", "PASS"
|
|
93
|
+
result = run(["fnm", "exec", f"--using={version}", "--", "node", "--version"])
|
|
94
|
+
detail = "\n".join(filter(None, [(result.stdout or "").strip(), (result.stderr or "").strip()]))
|
|
95
|
+
if result.returncode == 0 and version in detail.lstrip("v"):
|
|
96
|
+
return True, detail, "PASS"
|
|
97
|
+
if check_only:
|
|
98
|
+
return False, detail or f"Node {version} is not installed through fnm", "VERSION_MISMATCH"
|
|
99
|
+
install = run(["fnm", "install", version], capture=False)
|
|
100
|
+
if install.returncode != 0:
|
|
101
|
+
return False, f"fnm failed to install Node {version}", "FAILED"
|
|
102
|
+
run(["fnm", "default", version], capture=False)
|
|
103
|
+
verify = run(["fnm", "exec", f"--using={version}", "--", "node", "--version"])
|
|
104
|
+
text = (verify.stdout or verify.stderr or "").strip()
|
|
105
|
+
return verify.returncode == 0 and version in text.lstrip("v"), text, "PASS" if verify.returncode == 0 else "FAILED"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def ensure_tool(entry: dict[str, Any], check_only: bool) -> tuple[bool, str, str]:
|
|
109
|
+
tool_id = entry["id"]
|
|
110
|
+
spec = read_json(CATALOG / f"{tool_id}.json")
|
|
111
|
+
version = entry.get("version")
|
|
112
|
+
|
|
113
|
+
if tool_id == "node":
|
|
114
|
+
return ensure_fnm_node(version, check_only)
|
|
115
|
+
|
|
116
|
+
ok, detail = version_ok(spec, version)
|
|
117
|
+
if ok:
|
|
118
|
+
if tool_id == "docker":
|
|
119
|
+
operational = run(["docker", "info", "--format", "{{.ServerVersion}}"])
|
|
120
|
+
operational_detail = (operational.stdout or operational.stderr or "").strip()
|
|
121
|
+
if operational.returncode != 0:
|
|
122
|
+
return False, (
|
|
123
|
+
"Docker CLI is present but Docker Desktop is not operational through WSL integration. "
|
|
124
|
+
"Start Docker Desktop and enable this distro under Settings > Resources > WSL Integration. "
|
|
125
|
+
"Do not install a second Docker Engine inside WSL."
|
|
126
|
+
), "NOT_OPERATIONAL"
|
|
127
|
+
return True, operational_detail, "PASS"
|
|
128
|
+
return True, detail, "PASS"
|
|
129
|
+
|
|
130
|
+
if check_only:
|
|
131
|
+
return False, detail, "MISSING" if detail == "missing" else "VERSION_MISMATCH"
|
|
132
|
+
|
|
133
|
+
apt_packages = {
|
|
134
|
+
"git": "git",
|
|
135
|
+
"github-cli": "gh",
|
|
136
|
+
"python": "python3",
|
|
137
|
+
"sqlite": "sqlite3",
|
|
138
|
+
"ssh": "openssh-client",
|
|
139
|
+
"go": "golang-go",
|
|
140
|
+
}
|
|
141
|
+
if tool_id in apt_packages:
|
|
142
|
+
ensure_apt_package(apt_packages[tool_id])
|
|
143
|
+
ok, detail = version_ok(spec, version)
|
|
144
|
+
return ok, detail, "PASS" if ok else "FAILED"
|
|
145
|
+
|
|
146
|
+
if tool_id == "vscode":
|
|
147
|
+
return False, (
|
|
148
|
+
"VS Code's WSL command bridge is unavailable. Install VS Code on Windows and the "
|
|
149
|
+
"Remote - WSL extension, then open this repository with `code .` from WSL."
|
|
150
|
+
), "MISSING"
|
|
151
|
+
if tool_id == "docker":
|
|
152
|
+
return False, (
|
|
153
|
+
"Docker is unavailable inside WSL. Install/start Docker Desktop on Windows and enable "
|
|
154
|
+
"WSL integration for this distro; do not install Docker Engine inside WSL."
|
|
155
|
+
), "MISSING"
|
|
156
|
+
if tool_id == "fnm":
|
|
157
|
+
return False, (
|
|
158
|
+
"fnm is required inside WSL for Project-declared Node versions. Install fnm in this "
|
|
159
|
+
"distro, then rerun /setup."
|
|
160
|
+
), "MISSING"
|
|
161
|
+
if tool_id == "dotnet":
|
|
162
|
+
return False, (
|
|
163
|
+
f".NET SDK {version or ''} is required inside WSL. Install the matching Microsoft .NET SDK "
|
|
164
|
+
"for Ubuntu in this distro, then rerun /setup."
|
|
165
|
+
).strip(), "MISSING" if detail == "missing" else "VERSION_MISMATCH"
|
|
166
|
+
if tool_id == "java":
|
|
167
|
+
return False, (
|
|
168
|
+
f"Java {version or ''} is required inside WSL. Install the matching JDK in this distro, "
|
|
169
|
+
"then rerun /setup."
|
|
170
|
+
).strip(), "MISSING" if detail == "missing" else "VERSION_MISMATCH"
|
|
171
|
+
|
|
172
|
+
return False, detail or f"No WSL installer is defined for {tool_id}", "MISSING"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def ensure_extensions(extensions: list[str], check_only: bool) -> list[str]:
|
|
176
|
+
if not extensions or not command_path("code"):
|
|
177
|
+
return extensions if not command_path("code") else []
|
|
178
|
+
result = run(["code", "--list-extensions"])
|
|
179
|
+
installed = {line.strip().lower() for line in (result.stdout or "").splitlines() if line.strip()}
|
|
180
|
+
missing = [value for value in extensions if value.lower() not in installed]
|
|
181
|
+
if not check_only:
|
|
182
|
+
for extension in missing:
|
|
183
|
+
run(["code", "--install-extension", extension, "--force"], capture=False)
|
|
184
|
+
result = run(["code", "--list-extensions"])
|
|
185
|
+
installed = {line.strip().lower() for line in (result.stdout or "").splitlines() if line.strip()}
|
|
186
|
+
missing = [value for value in extensions if value.lower() not in installed]
|
|
187
|
+
return missing
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def restore_project(commands: list[list[str]], check_only: bool) -> list[str]:
|
|
191
|
+
if check_only:
|
|
192
|
+
return []
|
|
193
|
+
plan_path = ROOT / "SCAFFOLD_PLAN.json"
|
|
194
|
+
if plan_path.exists():
|
|
195
|
+
plan = read_json(plan_path)
|
|
196
|
+
missing_markers = [marker for marker in plan.get("markers", []) if not (ROOT / marker).exists()]
|
|
197
|
+
if missing_markers:
|
|
198
|
+
print("DEFERRED project restore — scaffold markers are not present")
|
|
199
|
+
return []
|
|
200
|
+
failures: list[str] = []
|
|
201
|
+
for command in commands:
|
|
202
|
+
if not command:
|
|
203
|
+
continue
|
|
204
|
+
result = run(command, capture=False)
|
|
205
|
+
if result.returncode != 0:
|
|
206
|
+
failures.append(" ".join(command))
|
|
207
|
+
return failures
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def main() -> int:
|
|
211
|
+
parser = argparse.ArgumentParser(description="Prepare/verify a Project Forge developer environment in WSL2")
|
|
212
|
+
parser.add_argument("--check", action="store_true")
|
|
213
|
+
parser.add_argument("--restore", action="store_true")
|
|
214
|
+
args = parser.parse_args()
|
|
215
|
+
|
|
216
|
+
if not is_wsl():
|
|
217
|
+
raise SetupError("This Linux setup runtime is supported only inside WSL2 for the current Windows development profile.")
|
|
218
|
+
if str(ROOT).startswith("/mnt/"):
|
|
219
|
+
raise SetupError(
|
|
220
|
+
"This Project is under a Windows-mounted filesystem. Project Forge's Windows default requires "
|
|
221
|
+
"a Linux-native WSL checkout such as ~/projects/<repo>. Migrate the repository first."
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
config = read_json(CONFIG)
|
|
225
|
+
declared = set(config.get("platforms", []))
|
|
226
|
+
if not ({"windows", "linux"} & declared):
|
|
227
|
+
raise SetupError("This Project does not declare a Windows/WSL-compatible development environment")
|
|
228
|
+
|
|
229
|
+
print(f"== Developer environment (WSL2: {os.environ.get('WSL_DISTRO_NAME', 'Linux')}) ==")
|
|
230
|
+
failed_required: list[str] = []
|
|
231
|
+
failed_optional: list[str] = []
|
|
232
|
+
for entry in config.get("tools", []):
|
|
233
|
+
required = entry.get("required", True)
|
|
234
|
+
ok, detail, state = ensure_tool(entry, args.check)
|
|
235
|
+
version = f" {entry['version']}" if entry.get("version") else ""
|
|
236
|
+
first = detail.splitlines()[0] if detail else ""
|
|
237
|
+
print(f"{state} {entry['id']}{version}" + (f" — {first}" if first else ""))
|
|
238
|
+
if not ok:
|
|
239
|
+
(failed_required if required else failed_optional).append(entry["id"])
|
|
240
|
+
|
|
241
|
+
missing_extensions = ensure_extensions(config.get("vscodeExtensions", []), args.check)
|
|
242
|
+
for extension in missing_extensions:
|
|
243
|
+
print(f"MISSING extension {extension}")
|
|
244
|
+
if missing_extensions:
|
|
245
|
+
failed_required.append("VS Code extensions")
|
|
246
|
+
|
|
247
|
+
restore_failures = restore_project(config.get("projectRestore", []), args.check) if args.restore else []
|
|
248
|
+
if restore_failures:
|
|
249
|
+
failed_required.append("project restore")
|
|
250
|
+
|
|
251
|
+
if failed_optional:
|
|
252
|
+
print("Optional tools not ready: " + ", ".join(failed_optional))
|
|
253
|
+
if failed_required:
|
|
254
|
+
print("Required environment items not ready: " + ", ".join(failed_required), file=sys.stderr)
|
|
255
|
+
return 2
|
|
256
|
+
|
|
257
|
+
print("Project Forge WSL2 setup: PASS")
|
|
258
|
+
return 0
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
try:
|
|
263
|
+
raise SystemExit(main())
|
|
264
|
+
except SetupError as exc:
|
|
265
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
266
|
+
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mriekki/project-forge",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.14.1",
|
|
4
4
|
"description": "Public consumer distribution and updater for Project Forge engineering assets.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"bin/",
|
|
11
11
|
"src/",
|
|
12
12
|
"assets/",
|
|
13
|
+
"tools/",
|
|
13
14
|
"README.md"
|
|
14
15
|
],
|
|
15
16
|
"scripts": {
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
[CmdletBinding(SupportsShouldProcess = $true)]
|
|
2
|
+
param(
|
|
3
|
+
[string]$Distro = "Ubuntu",
|
|
4
|
+
[string]$SourceRoot = (Get-Location).Path,
|
|
5
|
+
[string]$DestinationRoot = "~/projects",
|
|
6
|
+
[switch]$InstallMissing,
|
|
7
|
+
[switch]$MigrateRepositories,
|
|
8
|
+
[switch]$IncludeNestedRepositories,
|
|
9
|
+
[switch]$SkipDockerCheck
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
$ErrorActionPreference = "Stop"
|
|
13
|
+
|
|
14
|
+
function Write-Step([string]$Message) {
|
|
15
|
+
Write-Host "==> $Message"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function Invoke-Wsl {
|
|
19
|
+
param([Parameter(Mandatory = $true)][string[]]$Arguments)
|
|
20
|
+
$output = & wsl.exe -d $Distro -- @Arguments 2>&1
|
|
21
|
+
if ($LASTEXITCODE -ne 0) {
|
|
22
|
+
throw "WSL command failed ($LASTEXITCODE): $($Arguments -join ' ')`n$output"
|
|
23
|
+
}
|
|
24
|
+
return $output
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function Test-Command([string]$Name) {
|
|
28
|
+
return $null -ne (Get-Command $Name -ErrorAction SilentlyContinue)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function Get-GitValue([string]$Repository, [string[]]$Arguments) {
|
|
32
|
+
$result = & git -C $Repository @Arguments 2>$null
|
|
33
|
+
if ($LASTEXITCODE -ne 0) { return $null }
|
|
34
|
+
return (($result -join "`n").Trim())
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function Convert-ToWslPath([string]$WindowsPath) {
|
|
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."
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function Resolve-WslDestinationRoot {
|
|
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 }
|
|
57
|
+
if ($DestinationRoot.StartsWith("~/")) {
|
|
58
|
+
return "$wslHome/$($DestinationRoot.Substring(2))"
|
|
59
|
+
}
|
|
60
|
+
throw "DestinationRoot must be an absolute Linux path or start with ~/ (received '$DestinationRoot')."
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function Get-RepositoryCandidates([string]$Root) {
|
|
64
|
+
$resolved = (Resolve-Path $Root).Path
|
|
65
|
+
$items = New-Object System.Collections.Generic.List[string]
|
|
66
|
+
|
|
67
|
+
if (Test-Path (Join-Path $resolved ".git")) {
|
|
68
|
+
$items.Add($resolved)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
$children = Get-ChildItem -Path $resolved -Directory -Force
|
|
72
|
+
foreach ($child in $children) {
|
|
73
|
+
if (Test-Path (Join-Path $child.FullName ".git")) {
|
|
74
|
+
$items.Add($child.FullName)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if ($IncludeNestedRepositories) {
|
|
79
|
+
Get-ChildItem -Path $resolved -Directory -Force -Recurse -ErrorAction SilentlyContinue |
|
|
80
|
+
Where-Object { Test-Path (Join-Path $_.FullName ".git") } |
|
|
81
|
+
ForEach-Object { $items.Add($_.FullName) }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return $items | Sort-Object -Unique
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (-not [System.Environment]::OSVersion.Platform.ToString().StartsWith("Win")) {
|
|
88
|
+
throw "This tool must be run from Windows PowerShell or PowerShell on Windows."
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
Write-Step "Checking WSL2"
|
|
92
|
+
if (-not (Test-Command "wsl.exe")) {
|
|
93
|
+
if (-not $InstallMissing) {
|
|
94
|
+
throw "WSL is not available. Re-run with -InstallMissing from an elevated PowerShell session."
|
|
95
|
+
}
|
|
96
|
+
& wsl.exe --install -d $Distro
|
|
97
|
+
throw "WSL installation was requested. If Windows asks for a restart, restart first, complete the $Distro first-run setup, then run this command again."
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
& wsl.exe --set-default-version 2 | Out-Null
|
|
101
|
+
$distroNames = (& wsl.exe -l -q 2>$null) -replace "`0", "" | ForEach-Object { $_.Trim() } | Where-Object { $_ }
|
|
102
|
+
if ($distroNames -notcontains $Distro) {
|
|
103
|
+
if (-not $InstallMissing) {
|
|
104
|
+
throw "WSL distro '$Distro' is not installed. Re-run with -InstallMissing."
|
|
105
|
+
}
|
|
106
|
+
& wsl.exe --install -d $Distro
|
|
107
|
+
throw "Installation of '$Distro' was requested. Complete its first-run setup, then run this command again."
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
$versionLines = (& wsl.exe -l -v 2>$null) -replace "`0", ""
|
|
111
|
+
$distroLine = $versionLines | Where-Object { $_ -match "\b$([regex]::Escape($Distro))\b" } | Select-Object -First 1
|
|
112
|
+
if ($distroLine -and $distroLine -match "\s1\s*$") {
|
|
113
|
+
if (-not $InstallMissing) {
|
|
114
|
+
throw "'$Distro' is using WSL1. Re-run with -InstallMissing to convert it to WSL2."
|
|
115
|
+
}
|
|
116
|
+
& wsl.exe --set-version $Distro 2
|
|
117
|
+
if ($LASTEXITCODE -ne 0) { throw "Failed to convert '$Distro' to WSL2." }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
Write-Step "Checking baseline Linux development tools"
|
|
121
|
+
$missingLinuxTools = @()
|
|
122
|
+
foreach ($tool in @("git", "curl", "tar")) {
|
|
123
|
+
& wsl.exe -d $Distro -- sh -lc "command -v $tool >/dev/null 2>&1"
|
|
124
|
+
if ($LASTEXITCODE -ne 0) { $missingLinuxTools += $tool }
|
|
125
|
+
}
|
|
126
|
+
if ($missingLinuxTools.Count -gt 0) {
|
|
127
|
+
if (-not $InstallMissing) {
|
|
128
|
+
throw "Missing WSL tools: $($missingLinuxTools -join ', '). Re-run with -InstallMissing."
|
|
129
|
+
}
|
|
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
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# Use Linux line endings for new checkouts unless the Builder already chose another global policy.
|
|
135
|
+
$wslAutocrlf = (& wsl.exe -d $Distro -- git config --global --get core.autocrlf 2>$null | Out-String).Trim()
|
|
136
|
+
if (-not $wslAutocrlf) {
|
|
137
|
+
Invoke-Wsl -Arguments @("git", "config", "--global", "core.autocrlf", "input") | Out-Null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
# Carry Git identity into WSL only when WSL does not already have one.
|
|
141
|
+
if (Test-Command "git") {
|
|
142
|
+
foreach ($key in @("user.name", "user.email")) {
|
|
143
|
+
$existing = (& wsl.exe -d $Distro -- git config --global --get $key 2>$null | Out-String).Trim()
|
|
144
|
+
if (-not $existing) {
|
|
145
|
+
$hostValue = (& git config --global --get $key 2>$null | Out-String).Trim()
|
|
146
|
+
if ($hostValue) { Invoke-Wsl -Arguments @("git", "config", "--global", $key, $hostValue) | Out-Null }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
Write-Step "Checking VS Code WSL integration"
|
|
152
|
+
if (Test-Command "code") {
|
|
153
|
+
& code --install-extension ms-vscode-remote.remote-wsl --force | Out-Null
|
|
154
|
+
} else {
|
|
155
|
+
Write-Warning "VS Code 'code' command was not found on Windows. Install VS Code if you want the recommended Windows UI + WSL workspace flow."
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
Write-Step "Checking Docker Desktop / WSL integration"
|
|
159
|
+
if (-not $SkipDockerCheck) {
|
|
160
|
+
$dockerDesktop = Get-Command "docker" -ErrorAction SilentlyContinue
|
|
161
|
+
if (-not $dockerDesktop -and $InstallMissing -and (Test-Command "winget")) {
|
|
162
|
+
& winget install --id Docker.DockerDesktop -e --accept-package-agreements --accept-source-agreements
|
|
163
|
+
Write-Warning "Docker Desktop installation was requested. Start Docker Desktop, enable WSL integration for '$Distro', then rerun this command to verify Docker from WSL."
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
& wsl.exe -d $Distro -- docker info 1>$null 2>$null
|
|
167
|
+
if ($LASTEXITCODE -ne 0) {
|
|
168
|
+
Write-Warning "Docker is not operational inside '$Distro'. Start Docker Desktop and enable Settings > Resources > WSL Integration for '$Distro'. Do not install a second Docker Engine inside WSL just to satisfy this check."
|
|
169
|
+
} else {
|
|
170
|
+
Write-Host "Docker from WSL: PASS"
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (-not $MigrateRepositories) {
|
|
175
|
+
Write-Host ""
|
|
176
|
+
Write-Host "WSL2 baseline setup: PASS"
|
|
177
|
+
Write-Host "No repositories were migrated. Re-run with -MigrateRepositories -SourceRoot <windows-folder> when ready."
|
|
178
|
+
exit 0
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (-not (Test-Command "git")) {
|
|
182
|
+
throw "Windows Git is required for migration preflight so source repositories can be verified before copying state."
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
$destinationRootLinux = Resolve-WslDestinationRoot
|
|
186
|
+
Invoke-Wsl -Arguments @("mkdir", "-p", $destinationRootLinux) | Out-Null
|
|
187
|
+
$repositories = @(Get-RepositoryCandidates $SourceRoot)
|
|
188
|
+
if ($repositories.Count -eq 0) {
|
|
189
|
+
throw "No Git repositories found at '$SourceRoot'."
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
Write-Step "Migrating $($repositories.Count) repository/repositories to ${Distro}:$destinationRootLinux"
|
|
193
|
+
$migrated = New-Object System.Collections.Generic.List[string]
|
|
194
|
+
$skipped = New-Object System.Collections.Generic.List[string]
|
|
195
|
+
|
|
196
|
+
foreach ($repo in $repositories) {
|
|
197
|
+
$name = Split-Path $repo -Leaf
|
|
198
|
+
$dirty = (& git -C $repo status --porcelain=v1 2>$null | Out-String).Trim()
|
|
199
|
+
if ($LASTEXITCODE -ne 0) {
|
|
200
|
+
Write-Warning "Skipping '$repo': Git status failed."
|
|
201
|
+
$skipped.Add($repo)
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
if ($dirty) {
|
|
205
|
+
Write-Warning "Skipping '$repo': working tree is not clean. Commit/stash/reconcile it first so migration cannot lose local work."
|
|
206
|
+
$skipped.Add($repo)
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
$head = Get-GitValue $repo @("rev-parse", "HEAD")
|
|
211
|
+
$branch = Get-GitValue $repo @("branch", "--show-current")
|
|
212
|
+
$origin = Get-GitValue $repo @("config", "--get", "remote.origin.url")
|
|
213
|
+
$sourceLinux = Convert-ToWslPath $repo
|
|
214
|
+
$destinationLinux = "$destinationRootLinux/$name"
|
|
215
|
+
|
|
216
|
+
& wsl.exe -d $Distro -- test -e $destinationLinux
|
|
217
|
+
if ($LASTEXITCODE -eq 0) {
|
|
218
|
+
Write-Warning "Skipping '$repo': destination '$destinationLinux' already exists."
|
|
219
|
+
$skipped.Add($repo)
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (-not $PSCmdlet.ShouldProcess($repo, "Create validated WSL2 checkout at $destinationLinux")) {
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
# Clone from the local clean repository so committed local-only refs remain available during migration.
|
|
228
|
+
Invoke-Wsl -Arguments @("git", "clone", "--no-hardlinks", $sourceLinux, $destinationLinux) | Out-Null
|
|
229
|
+
if ($origin) {
|
|
230
|
+
Invoke-Wsl -Arguments @("git", "-C", $destinationLinux, "remote", "set-url", "origin", $origin) | Out-Null
|
|
231
|
+
}
|
|
232
|
+
|
|
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()
|
|
235
|
+
if ($destHead -ne $head) {
|
|
236
|
+
throw "Migration verification failed for '$name': source HEAD $head != destination HEAD $destHead. Windows source was left untouched."
|
|
237
|
+
}
|
|
238
|
+
if ($destDirty) {
|
|
239
|
+
throw "Migration verification failed for '$name': destination checkout is not clean. Windows source was left untouched."
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if ($branch) {
|
|
243
|
+
$destBranch = ((Invoke-Wsl -Arguments @("git", "-C", $destinationLinux, "branch", "--show-current")) -join "`n").Trim()
|
|
244
|
+
if ($destBranch -ne $branch) {
|
|
245
|
+
Write-Warning "'$name' migrated at the correct HEAD, but checked-out branch differs (source '$branch', destination '$destBranch')."
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
Write-Host "Migrated: $repo -> ${Distro}:$destinationLinux"
|
|
250
|
+
$migrated.Add($destinationLinux)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
Write-Host ""
|
|
254
|
+
Write-Host "Migration complete."
|
|
255
|
+
Write-Host "Migrated: $($migrated.Count)"
|
|
256
|
+
Write-Host "Skipped: $($skipped.Count)"
|
|
257
|
+
Write-Host "Windows source checkouts were NOT deleted."
|
|
258
|
+
Write-Host ""
|
|
259
|
+
Write-Host "Next: open a migrated repository from WSL and run its normal Project Forge/project readiness checks."
|
|
260
|
+
if ($migrated.Count -gt 0) {
|
|
261
|
+
Write-Host "Example:"
|
|
262
|
+
Write-Host " wsl -d $Distro"
|
|
263
|
+
Write-Host " cd $($migrated[0])"
|
|
264
|
+
Write-Host " code ."
|
|
265
|
+
}
|