@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.
@@ -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" "$@"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.14.0",
4
+ "version": "4.14.2",
5
5
  "defaultProfile": "greenfield-project",
6
6
  "profiles": {
7
7
  "greenfield-project": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.14.0",
4
+ "version": "4.14.2",
5
5
  "components": {
6
6
  "core": {
7
7
  "revision": "b51cb62bcc9630748168c9fd1900c6109cafb587",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.14.0",
3
+ "version": "4.14.2",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 MANIFEST_RELATIVE = 'assets/bootstrap-migrations/managed-baseline-verifier/manifest.json';
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 loadManifest(packageData) {
41
- const file = path.join(packageData.root, ...MANIFEST_RELATIVE.split('/'));
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('Bundled Bootstrap migration asset identity does not match its manifest');
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 { manifest } = loadManifest(packageData);
73
- const destination = safeConsumerPath(consumerRoot, manifest.destination);
74
- if (!fs.existsSync(destination)) {
75
- return {
76
- rows: [],
77
- unsafe: [{
78
- owner: 'bootstrap',
79
- state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
80
- path: manifest.destination,
81
- detail: 'Bootstrap-generated verifier is missing; refusing package mutation before a supported migration is established.'
82
- }]
83
- };
84
- }
116
+ const bootstrap = readJson(bootstrapManifest);
117
+ const rows = [];
118
+ const unsafe = [];
85
119
 
86
- const stat = fs.lstatSync(destination);
87
- if (!stat.isFile() || stat.isSymbolicLink()) {
88
- return {
89
- rows: [],
90
- unsafe: [{
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 verifier is not a regular file; refusing package mutation.'
95
- }]
96
- };
97
- }
98
- const realRoot = fs.realpathSync(consumerRoot);
99
- const realDestination = fs.realpathSync(destination);
100
- if (realDestination !== realRoot && !realDestination.startsWith(`${realRoot}${path.sep}`)) {
101
- return {
102
- rows: [],
103
- unsafe: [{
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: 'Bootstrap-generated verifier resolves outside the Project root; refusing package mutation.'
108
- }]
109
- };
110
- }
164
+ detail: `${error.message}; refusing package mutation.`
165
+ });
166
+ continue;
167
+ }
111
168
 
112
- const currentGitBlob = gitBlobSha(fs.readFileSync(destination));
113
- if (currentGitBlob === manifest.targetGitBlob) return { rows: [], unsafe: [] };
114
- if (!manifest.supportedPredecessorGitBlobs.includes(currentGitBlob)) {
115
- return {
116
- rows: [],
117
- unsafe: [{
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 verifier identity ${currentGitBlob}; refusing to overwrite it.`
122
- }]
123
- };
124
- }
176
+ detail: `Unknown/custom Bootstrap file identity ${currentGitBlob}; refusing to overwrite it.`
177
+ });
178
+ continue;
179
+ }
125
180
 
126
- return {
127
- rows: [{
181
+ rows.push({
128
182
  owner: 'bootstrap',
129
- state: 'MIGRATE_BOOTSTRAP_VERIFIER',
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
- unsafe: []
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
- const { manifest, bytes } = loadManifest(packageData);
197
+
198
+ const byPath = new Map(loadManifests(packageData).map((entry) => [entry.manifest.destination, entry]));
143
199
  for (const row of migrationPlan.rows) {
144
- if (row.state !== 'MIGRATE_BOOTSTRAP_VERIFIER' || row.path !== manifest.destination) {
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 (!fs.existsSync(destination) || fs.lstatSync(destination).isSymbolicLink() || !fs.lstatSync(destination).isFile()) {
149
- throw new Error(`Bootstrap verifier changed after preview: ${row.path}`);
150
- }
151
- const currentGitBlob = gitBlobSha(fs.readFileSync(destination));
152
- if (currentGitBlob !== row.fromGitBlob || !manifest.supportedPredecessorGitBlobs.includes(currentGitBlob)) {
153
- throw new Error(`Bootstrap verifier changed after preview: ${row.path}`);
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
- fs.writeFileSync(destination, bytes);
225
+
156
226
  if (gitBlobSha(fs.readFileSync(destination)) !== manifest.targetGitBlob) {
157
- throw new Error(`Bootstrap verifier migration verification failed: ${row.path}`);
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(ValueFromRemainingArguments = $true)][string[]]$Arguments)
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
- return ((Invoke-Wsl wslpath -a $WindowsPath) -join "`n").Trim()
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
- $home = ((Invoke-Wsl sh -lc 'printf %s "$HOME"') -join "`n").Trim()
43
- if ($DestinationRoot -eq "~") { return $home }
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 "$home/$($DestinationRoot.Substring(2))"
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