From 135ea21a6186cb23248aaa9472f32bb6fd00a4d3 Mon Sep 17 00:00:00 2001 From: CIAgent Date: Sun, 13 Sep 2026 00:05:37 +0000 Subject: [PATCH] =?UTF-8?q?merge:=20hotfix/fresh-box-experience=20?= =?UTF-8?q?=E2=86=92=20main=20(v0.3.5=20fresh-box=20experience)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the v0.3.4 fresh-box failures: silent CLI under bare-word PATH invocation (SEA argv detection), bootstrap dying on venv creation without an actionable hint (poisoned-partial-venv recovery + apt hint + doctor venv-capability probe + preflight), installer falsely 'verifying' a silent binary, and localhost-only server binding (network mode: 0.0.0.0 + wildcard CORS + hostname-derived API URL — remote browsing zero-config). 38 CLI tests, 3 web tests, 409 ai-service tests green; build/typecheck/lint clean. ---ci--- phase: hotfix milestone: v0.4 status: complete type: hotfix requirements: covered: [REQ-4-001, REQ-4-002, REQ-4-003, REQ-4-004, REQ-4-005] partial: [] ---/ci--- --- .ciagent/ARCHITECTURE.md | 3 +- README.md | 9 +++ apps/ai-service/.env.example | 7 ++ apps/ai-service/ai_service/api/telemetry.py | 22 +++++- apps/ai-service/ai_service/config.py | 13 ++++ apps/ai-service/ai_service/main.py | 14 ++-- apps/ai-service/scripts/bootstrap.sh | 59 +++++++++++++-- apps/ai-service/scripts/dev.sh | 20 ++++- apps/ai-service/tests/api/test_cors.py | 46 +++++++++--- .../tests/api/test_telemetry_ingest.py | 38 +++++++--- apps/cli/src/commands/bootstrap.ts | 8 ++ apps/cli/src/commands/doctor.ts | 40 ++++++++++ apps/cli/src/index.ts | 19 +++-- apps/cli/tests/commands.test.ts | 73 ++++++++++++++++--- apps/cli/tests/install-script.test.ts | 24 ++++++ apps/cli/tests/path-invocation.test.ts | 38 ++++++++++ apps/web/.env.example | 6 +- .../components/learner/agent-stream-panel.tsx | 5 +- apps/web/hooks/use-chat-stream.ts | 5 +- apps/web/lib/engine-base-url.ts | 8 ++ apps/web/lib/engine-client.ts | 5 +- apps/web/package.json | 8 +- apps/web/tests/engine-base-url.test.ts | 31 ++++++++ pnpm-lock.yaml | 3 + scripts/install.sh | 7 +- 25 files changed, 441 insertions(+), 70 deletions(-) create mode 100644 apps/cli/tests/path-invocation.test.ts create mode 100644 apps/web/lib/engine-base-url.ts create mode 100644 apps/web/tests/engine-base-url.test.ts diff --git a/.ciagent/ARCHITECTURE.md b/.ciagent/ARCHITECTURE.md index 4b82838..a5d000c 100644 --- a/.ciagent/ARCHITECTURE.md +++ b/.ciagent/ARCHITECTURE.md @@ -56,6 +56,7 @@ Deliberately **not** used: openai-python SDK (the `LLMProvider` protocol is the 20. **D-035 Install path = repo raw `install.sh` + Gitea latest-release API** — the one-liner `curl -fsSL /coreci/nextcraft/raw/main/scripts/install.sh | bash` resolves `GET /api/v1/repos/coreci/nextcraft/releases/latest`, downloads the `nextcraft-linux-x64` + `nextcraft-linux-x64.sha256` assets, verifies sha256 (`shasum -a 256`), installs to `~/.local/bin` (PATH hint), and degrades to printed source-bootstrap instructions when no binary asset exists or the platform mismatches (A-203/A-204/A-206). 21. **D-036 Ongoing binaries = ship-workflow asset step** — the release pipeline (v0.3's `ShipWorkflow.createRelease` equivalent, executed as the ship step's asset stage) builds the binary + checksum and attaches both to every Gitea release from v0.4 onward (A-205). Token resolution stays `.env*`-only (D-006/D-014); binaries are linux x64 only for v0.4 (macOS arm64 deferred — unverifiable on this box). 22. **D-037 CLI package layout** — `apps/cli` is a pnpm workspace package (`@nextcraft/cli`): `src/` (entry, commands/, checks/, lib/), `scripts/build-binary.mjs` (esbuild bundle → SEA inject), unit tests runnable via `pnpm --filter @nextcraft/cli test` (node:test, no new test framework). Root `package.json` gains `cli:*` passthrough scripts mirroring the `ai:*` pattern (D-022). +23. **D-038 Network mode (v0.3.5)** — dev binds 0.0.0.0 (`AI_HOST`, default 0.0.0.0, revert via 127.0.0.1); CORS + WS-origin gates read `AI_CORS_ORIGINS` (default `*` — any origin, safe only because credentials are never enabled; explicit comma list restricts); the web client derives the API base URL from the browser hostname at runtime (`engine-base-url.ts`: `NEXT_PUBLIC_AI_SERVICE_URL` override → `http://${window.location.hostname}:8420` → `localhost` server-side). Hotfix also fixes: SEA direct-run detection (`require("node:sea").isSea()` — argv shape differs by invocation style), installer honesty gate (silent `--version` = hard fail), bootstrap venv recovery (poisoned partial `.venv` removal + distro-specific `apt install python3.XX-venv` hint), and doctor venv-capability probe with bootstrap preflight. ### v0.3 Architecture Decisions (from Research — Credential Engines) @@ -129,7 +130,7 @@ Deliberately **not** used: openai-python SDK (the `LLMProvider` protocol is the | `app/layout.tsx` | Root layout: theme provider, navigation shell, responsive container | All routes | packages/ui | | `components/` | Surface-specific components (learner/, marketplace/, employer/, admin/) plus shared chrome (navigation-shell, header/footer, role-switcher, theme-provider, breadcrumbs, dark-mode-toggle); v0.3 learner: build-surface, sandbox-terminal (read-only exec output), defense-session | App-level components | packages/ui | | `hooks/` | use-chat-stream.ts — SSE client hook: fetch + ReadableStream, byte buffering + frame reassembly, idempotent AbortController cleanup; use-sandbox-session.ts (v0.3) — sandbox lifecycle for the build session: create on task open, destroy on unmount, mid-start failure cleanup, 503/403/429 honest surfaces | Client components only | ai-service SSE / engine API | -| `lib/` | sse.ts (shared SSE frame parser — CRLF normalization + `: ping` immunity, v0.2 G-1), breadcrumbs.ts, format.ts, engine-client.ts (v0.3: typed fetch client for /v1/sandboxes, files/exec, variants, grade, defense, traces) | Pure utilities | None | +| `lib/` | sse.ts (shared SSE frame parser — CRLF normalization + `: ping` immunity, v0.2 G-1), breadcrumbs.ts, format.ts, engine-base-url.ts (v0.3.5: runtime API base — env override → browser hostname → localhost), engine-client.ts (v0.3: typed fetch client for /v1/sandboxes, files/exec, variants, grade, defense, traces) | Pure utilities | None | ### packages/ui — Shared Component Library diff --git a/README.md b/README.md index 6b5d1d0..9843bc8 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,15 @@ pnpm ai:dev Exit-code contract: `0` success, `1` check/step failure (hint printed), `2` usage error. +### Remote server + +`nextcraft dev` binds the API on **0.0.0.0:8420** (and `pnpm dev` serves the web app on all interfaces), so the stack works from other machines out of the box: + +- Browse `http://:3000` — the web app targets `http://:8420` automatically (derived from the browser's hostname). +- CORS admits any origin (`AI_CORS_ORIGINS=*` in `apps/ai-service/.env`). This is safe **only** because credentials are never enabled; to restrict, set an explicit list: `AI_CORS_ORIGINS=http://:3000`. +- To revert to loopback-only: `AI_HOST=127.0.0.1` in `apps/ai-service/.env`. +- Security note: this is an unauthenticated dev API reachable from any network the box exposes. Mitigations that still apply: per-learner sandbox caps + global rate caps + learner allowlist (G-5), telemetry flood control (traces marked `INCOMPLETE_FLOODED` are refused by the grader). Expose only on trusted networks until identity/KYC lands (v0.5). + ## Docs - [apps/cli/README.md](apps/cli/README.md) — CLI internals: build, binary pipeline, troubleshooting diff --git a/apps/ai-service/.env.example b/apps/ai-service/.env.example index 1e00382..65c13fd 100644 --- a/apps/ai-service/.env.example +++ b/apps/ai-service/.env.example @@ -2,6 +2,13 @@ # Real keys live in .ciagent/.env.secrets (gitignored) and are exported by scripts/dev.sh AI_PORT=8420 +# Network mode (v0.3.5, D-038): dev server binds 0.0.0.0 so remote machines can +# reach the stack. Set to 127.0.0.1 to revert to loopback-only. +AI_HOST=0.0.0.0 +# CORS + WS-origin policy: '*' (default) admits any origin — safe because +# credentials are never enabled. Restrict with a comma list, e.g.: +# AI_CORS_ORIGINS=http://nextcraft-1:3000 +AI_CORS_ORIGINS=* AI_PROVIDER=ollama-cloud AI_MODEL=gemma4:31b AI_OLLAMA_CLOUD_BASE_URL=https://ollama.com/v1 diff --git a/apps/ai-service/ai_service/api/telemetry.py b/apps/ai-service/ai_service/api/telemetry.py index c79d5e9..d08f771 100644 --- a/apps/ai-service/ai_service/api/telemetry.py +++ b/apps/ai-service/ai_service/api/telemetry.py @@ -39,16 +39,28 @@ from .deps import get_trace_integrity, get_trace_store router = APIRouter(prefix="/v1/telemetry", tags=["telemetry"]) -#: Browser Origins allowed to open the ingest socket (A-008 mirror). The -#: stdlib capture agent sends NO Origin header (it is not a browser) and +#: Browser Origins allowed to open the ingest socket (A-008 mirror, D-038). +#: The stdlib capture agent sends NO Origin header (it is not a browser) and #: stays allowed; a malicious page loaded in the learner's browser would #: carry an Origin and must not be able to poison/flood the trace. CORS #: middleware does NOT cover WebSocket upgrades, so this gate is explicit. -_ALLOWED_WS_ORIGINS = frozenset( +#: In network mode the configured CORS list governs (default '*' — any +#: origin, since credentials are never used); an explicit list still rejects +#: unlisted origins with 1008. +_LOCAL_WS_ORIGINS = frozenset( {"http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:8420"} ) +def _allowed_ws_origins(settings: object) -> frozenset[str]: + configured = getattr(settings, "cors_origin_list", None) + if configured is None: + return _LOCAL_WS_ORIGINS + if configured == ["*"]: + return frozenset() # empty = wildcard = every Origin passes + return frozenset(configured) | _LOCAL_WS_ORIGINS + + # --- WS ingest (D-026) --------------------------------------------------------- @@ -60,10 +72,12 @@ async def telemetry_ingest_ws(websocket: WebSocket) -> None: FastAPI; this shim is the only place the two layers meet. """ origin = (websocket.headers.get("origin") or "").strip() - if origin and origin not in _ALLOWED_WS_ORIGINS: + allowed = _allowed_ws_origins(getattr(websocket.app.state, "settings", None)) + if origin and allowed and origin not in allowed: # Same-origin dev pages (Next.js on :3000, the service itself on # :8420) pass; anything else is refused pre-accept. Non-browser # producers (the capture agent, tests) send no Origin and pass. + # Wildcard (empty frozenset) passes every Origin in network mode. await websocket.close( code=1008, reason=f"origin {origin!r} not allowed for telemetry ingest" ) diff --git a/apps/ai-service/ai_service/config.py b/apps/ai-service/ai_service/config.py index 258d7ad..e2823dd 100644 --- a/apps/ai-service/ai_service/config.py +++ b/apps/ai-service/ai_service/config.py @@ -78,6 +78,19 @@ class Settings(BaseSettings): # `port` (A-004); only the host is configurable — never a second port. telemetry_ingest_host: str = "127.0.0.1" + # v0.3.5 network mode (D-038): dev.sh binds 0.0.0.0 so remote browsers can + # reach the stack; '*' (default) lets any origin call the API (safe ONLY + # because credentials are never enabled — A-008). Set a comma-separated + # origin list (e.g. 'http://nextcraft-1:3000') to restrict instead. + cors_origins: str = "*" + + @property + def cors_origin_list(self) -> list[str]: + value = self.cors_origins.strip() + if value == "*": + return ["*"] + return [o.strip() for o in value.split(",") if o.strip()] + # Voice provider selection (REQ-3-006, D-030): 'mock' (default — the # no-key path is first-class; tests never call a real voice API) or # 'browser' (browser-native SpeechRecognition/speechSynthesis fallback; diff --git a/apps/ai-service/ai_service/main.py b/apps/ai-service/ai_service/main.py index 0d93efc..16055b4 100644 --- a/apps/ai-service/ai_service/main.py +++ b/apps/ai-service/ai_service/main.py @@ -171,14 +171,16 @@ def create_app(settings: Settings | None = None) -> FastAPI: app = FastAPI(title="Nextcraft AI Service", version="0.3.0", lifespan=lifespan) - # A-008: localhost-only CORS, no credentials. PUT is CONTRACT, not - # trivia: the learner build surface writes workspace files with PUT - # (engine-client writeFile) — v0.3 initially shipped without it and - # every cross-origin Save failed preflight (caught in P7 review; - # tests/api/test_cors.py pins the policy now). + # A-008 + D-038: no-credentials CORS. Default '*' admits remote-browser + # origins in network mode (safe only because allow_credentials stays + # False — never enable credentials with a wildcard). AI_CORS_ORIGINS + # restricts to an explicit list. PUT is CONTRACT, not trivia: the learner + # build surface writes workspace files with PUT (engine-client writeFile) + # — v0.3 initially shipped without it and every cross-origin Save failed + # preflight (caught in P7 review; tests/api/test_cors.py pins the policy). app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], + allow_origins=settings.cors_origin_list, allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=["Content-Type"], allow_credentials=False, diff --git a/apps/ai-service/scripts/bootstrap.sh b/apps/ai-service/scripts/bootstrap.sh index 3fb1984..9c3f156 100755 --- a/apps/ai-service/scripts/bootstrap.sh +++ b/apps/ai-service/scripts/bootstrap.sh @@ -1,28 +1,73 @@ #!/usr/bin/env bash # Idempotent bootstrap: create venv + install deps. -# Handles Debian systems without python3-venv/ensurepip via --without-pip + get-pip. +# Handles Debian/Ubuntu systems without python3-venv/ensurepip via --without-pip + get-pip. +# v2 (v0.3.5): recovers from a poisoned partial .venv left by a failed earlier +# attempt, cleans before each retry, and dies with a distro-specific fix hint +# when venv creation is impossible (e.g. missing python3.XX-venv package). set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" VENV="$APP_DIR/.venv" +venv_usable() { + [ -x "$VENV/bin/python3" ] +} + +rm_broken_venv() { + echo "bootstrap: removing broken partial .venv from a failed earlier attempt" >&2 + rm -rf "$VENV" +} + mkdir -p "$HOME/.cache/ciagent" -if [ ! -x "$VENV/bin/python3" ]; then - if python3 -m venv "$VENV" 2>/dev/null; then +if venv_usable && [ ! -x "$VENV/bin/pip" ]; then + # A usable python3 without pip means the --without-pip fallback half-ran and + # the get-pip step never completed: start over cleanly. + rm_broken_venv +fi + +if ! venv_usable; then + if [ -d "$VENV" ]; then + # Directory exists but no working python3: remains of a crashed venv create. + rm_broken_venv + fi + if python3 -m venv "$VENV" 2>/tmp/venv-create.err; then : else - # No ensurepip available — create bare venv and bootstrap pip separately. - python3 -m venv --without-pip "$VENV" + rm -rf "$VENV" + if python3 -m venv --without-pip "$VENV" 2>>/tmp/venv-create.err; then + : + else + rm -rf "$VENV" + PYVER="$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || true)" + PKG="python3-venv" + [ -n "$PYVER" ] && PKG="python${PYVER}-venv" + echo "bootstrap: could not create a virtual environment." >&2 + echo " python3 reported:" >&2 + sed 's/^/ /' /tmp/venv-create.err >&2 || true + echo " fix (Debian/Ubuntu): install the venv support package, then re-run nextcraft bootstrap:" >&2 + echo " apt install $PKG" >&2 + exit 1 + fi fi fi if [ ! -x "$VENV/bin/pip" ]; then GET_PIP="$HOME/.cache/ciagent/get-pip.py" if [ ! -f "$GET_PIP" ]; then - curl -sSf --max-time 60 https://bootstrap.pypa.io/get-pip.py -o "$GET_PIP" + if ! curl -sSf --max-time 60 https://bootstrap.pypa.io/get-pip.py -o "$GET_PIP"; then + rm -rf "$VENV" + echo "bootstrap: get-pip.py download failed (no network?)." >&2 + echo " fix: restore network access and re-run nextcraft bootstrap" >&2 + exit 1 + fi + fi + if ! "$VENV/bin/python3" "$GET_PIP" --quiet; then + rm -rf "$VENV" + echo "bootstrap: pip installation into the venv failed." >&2 + echo " fix: re-run nextcraft bootstrap (the venv was cleaned; this retry is safe)" >&2 + exit 1 fi - "$VENV/bin/python3" "$GET_PIP" --quiet fi "$VENV/bin/pip" install --quiet --upgrade pip diff --git a/apps/ai-service/scripts/dev.sh b/apps/ai-service/scripts/dev.sh index 00c18ce..c1bc298 100755 --- a/apps/ai-service/scripts/dev.sh +++ b/apps/ai-service/scripts/dev.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash -# Dev server: export secrets (if present) then run uvicorn on :8420. +# Dev server: export secrets (if present) then run uvicorn. +# Binds 0.0.0.0 by default so the stack is reachable from other machines +# (v0.3.5 network mode) — set AI_HOST=127.0.0.1 in .env to revert to loopback. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -7,7 +9,7 @@ REPO_ROOT="$(cd "$APP_DIR/../.." && pwd)" VENV="$APP_DIR/.venv" if [ ! -x "$VENV/bin/uvicorn" ]; then - echo "venv missing — run scripts/bootstrap.sh first" >&2 + echo "venv missing — run nextcraft bootstrap first (or: bash scripts/bootstrap.sh)" >&2 exit 1 fi @@ -22,5 +24,17 @@ if [ -f "$SECRETS" ]; then done < "$SECRETS" fi +ENV_FILE="$APP_DIR/.env" +if [ -f "$ENV_FILE" ]; then + while IFS='=' read -r key value; do + case "$key" in + AI_HOST|AI_PORT|AI_CORS_ORIGINS) export "$key=$value" ;; + esac + done < "$ENV_FILE" +fi + +HOST="${AI_HOST:-0.0.0.0}" +PORT="${AI_PORT:-8420}" + cd "$APP_DIR" -exec "$VENV/bin/uvicorn" ai_service.main:app --reload --port 8420 \ No newline at end of file +exec "$VENV/bin/uvicorn" ai_service.main:app --reload --host "$HOST" --port "$PORT" \ No newline at end of file diff --git a/apps/ai-service/tests/api/test_cors.py b/apps/ai-service/tests/api/test_cors.py index 03f702c..c9916f3 100644 --- a/apps/ai-service/tests/api/test_cors.py +++ b/apps/ai-service/tests/api/test_cors.py @@ -1,4 +1,4 @@ -"""CORS policy tests (A-008, P7 review regression). +"""CORS policy tests (A-008, D-038 network mode). v0.3 initially shipped `allow_methods` WITHOUT "PUT" while the learner build surface writes workspace files with PUT (engine-client writeFile) — @@ -6,11 +6,11 @@ every cross-origin Save failed preflight. These tests pin the policy so a future method-list edit fails loudly instead of silently breaking the headline flow. -Two-layer check: - - preflight (OPTIONS + Access-Control-Request-Method) for every method the - web client actually uses: GET/POST/PUT/DELETE; - - actual cross-origin request echoes the localhost dev origin. -Disallowed origins must NOT be granted (localhost-only, no credentials). +v0.3.5 network mode (D-038): the default AI_CORS_ORIGINS='*' admits any +origin (safe ONLY because credentials are never enabled); an explicit list +restricts. Both modes are pinned here: + - wildcard: remote origin gets the grant; credentials still never sent; + - explicit: unlisted origins get no grant. """ from __future__ import annotations @@ -18,9 +18,14 @@ from __future__ import annotations from fastapi.testclient import TestClient ALLOWED_ORIGIN = "http://localhost:3000" +REMOTE_ORIGIN = "http://nextcraft-1:3000" ALL_CLIENT_METHODS = ("GET", "POST", "PUT", "DELETE") +def _allow_origin(resp) -> str | None: + return resp.headers.get("access-control-allow-origin") + + def test_preflight_allows_every_method_the_web_client_uses(client: TestClient) -> None: for method in ALL_CLIENT_METHODS: resp = client.options( @@ -31,7 +36,7 @@ def test_preflight_allows_every_method_the_web_client_uses(client: TestClient) - }, ) assert resp.status_code == 200, f"preflight {method} failed: {resp.status_code}" - assert resp.headers["access-control-allow-origin"] == ALLOWED_ORIGIN + assert _allow_origin(resp) in ("*", ALLOWED_ORIGIN) allowed = resp.headers["access-control-allow-methods"].split(", ") assert method in allowed, f"{method} missing from CORS methods: {allowed}" @@ -39,13 +44,30 @@ def test_preflight_allows_every_method_the_web_client_uses(client: TestClient) - def test_cross_origin_get_echoes_allow_origin(client: TestClient) -> None: resp = client.get("/v1/sandboxes", headers={"Origin": ALLOWED_ORIGIN}) assert resp.status_code == 200 - assert resp.headers.get("access-control-allow-origin") == ALLOWED_ORIGIN + assert _allow_origin(resp) in ("*", ALLOWED_ORIGIN) -def test_unknown_origin_gets_no_cors_grant(client: TestClient) -> None: - resp = client.get("/v1/sandboxes", headers={"Origin": "https://evil.example"}) - assert resp.status_code == 200 # non-CORS requests still serve - assert resp.headers.get("access-control-allow-origin") is None +def test_wildcard_mode_grants_remote_origins(client: TestClient) -> None: + """D-038 default: '*' grants any origin — remote browsers work zero-config.""" + resp = client.get("/v1/sandboxes", headers={"Origin": REMOTE_ORIGIN}) + assert resp.status_code == 200 + assert _allow_origin(resp) in ("*", REMOTE_ORIGIN) + + +def test_explicit_list_mode_denies_unlisted_origins( + settings, monkeypatch, tmp_path +) -> None: + """Explicit AI_CORS_ORIGINS restricts to the listed origins only.""" + from fastapi.testclient import TestClient as TC + + from ai_service.main import create_app + + restricted = settings.model_copy(update={"cors_origins": "http://localhost:3000"}) + app = create_app(restricted) + with TC(app) as c: + resp = c.get("/v1/sandboxes", headers={"Origin": "https://evil.example"}) + assert resp.status_code == 200 # non-CORS requests still serve + assert resp.headers.get("access-control-allow-origin") is None def test_credentials_never_allowed(client: TestClient) -> None: diff --git a/apps/ai-service/tests/api/test_telemetry_ingest.py b/apps/ai-service/tests/api/test_telemetry_ingest.py index 3ad930b..44af665 100644 --- a/apps/ai-service/tests/api/test_telemetry_ingest.py +++ b/apps/ai-service/tests/api/test_telemetry_ingest.py @@ -394,17 +394,33 @@ def test_missing_identity_query_params_rejected_at_handshake( assert excinfo.value.code == 1008 -def test_browser_origin_not_allowed_for_ingest(client: TestClient) -> None: - """CORS middleware does not cover WS upgrades (P7): a page loaded in the - learner's browser (any non-localhost Origin) must not be able to open - the ingest socket and poison/flood the trace. The stdlib capture agent - sends no Origin and is unaffected (see the no-origin test below).""" - with pytest.raises(WebSocketDisconnect) as excinfo: - with client.websocket_connect( - _ingest_url(), headers={"Origin": "https://evil.example"} - ): - pass - assert excinfo.value.code == 1008 +def test_browser_origin_rejected_in_explicit_list_mode( + settings, monkeypatch: pytest.MonkeyPatch +) -> None: + """D-038: with an explicit AI_CORS_ORIGINS list, a page loaded in the + learner's browser (unlisted Origin) must not be able to open the ingest + socket and poison/flood the trace. The stdlib capture agent sends no + Origin and is unaffected (see the no-origin test below).""" + from fastapi.testclient import TestClient as TC + + restricted = settings.model_copy(update={"cors_origins": "http://localhost:3000"}) + app = create_app(restricted) + with TC(app) as c: + with pytest.raises(WebSocketDisconnect) as excinfo: + with c.websocket_connect( + _ingest_url(), headers={"Origin": "https://evil.example"} + ): + pass + assert excinfo.value.code == 1008 + + +def test_wildcard_mode_admits_any_browser_origin(client: TestClient) -> None: + """D-038 default ('*'): remote-browser origins open the ingest socket — + the remote build surface streams telemetry from the learner's browser.""" + with client.websocket_connect( + _ingest_url(), headers={"Origin": "http://nextcraft-1:3000"} + ) as ws: + ws.send_text(_frame(0)) def test_dev_origin_and_no_origin_both_allowed(client: TestClient) -> None: diff --git a/apps/cli/src/commands/bootstrap.ts b/apps/cli/src/commands/bootstrap.ts index 6710b8a..5dec35c 100644 --- a/apps/cli/src/commands/bootstrap.ts +++ b/apps/cli/src/commands/bootstrap.ts @@ -3,6 +3,7 @@ import type { Ctx } from "../ctx.js"; import { findRepoRoot } from "../lib/repo.js"; import { diffEnvTemplate } from "../checks/check-env.js"; import { hr, info, warn } from "../lib/log.js"; +import { doctor } from "./doctor.js"; const INSTALL_TIMEOUT_MS = 600_000; @@ -18,6 +19,13 @@ export async function bootstrap(_args: string[], ctx: Ctx): Promise { hr("nextcraft bootstrap — monorepo setup", ctx); + info("preflight: checking prerequisites (doctor)...", ctx); + const preflight = await doctor([], ctx); + if (preflight !== 0) { + ctx.stderr.write("\n\u2717 preflight failed — fix the failed checks above, then re-run nextcraft bootstrap\n"); + return 1; + } + info("installing workspace dependencies (pnpm install)...", ctx); const install = await ctx.spawn("pnpm", ["install"], { cwd: root, timeoutMs: INSTALL_TIMEOUT_MS }); if (install.code !== 0) { diff --git a/apps/cli/src/commands/doctor.ts b/apps/cli/src/commands/doctor.ts index 6a8ea4c..07fb2be 100644 --- a/apps/cli/src/commands/doctor.ts +++ b/apps/cli/src/commands/doctor.ts @@ -1,6 +1,9 @@ import type { Ctx } from "../ctx.js"; import { compareVersions } from "../checks/check-command.js"; import { ok, fail, hr, summary } from "../lib/log.js"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; interface CheckOutcome { name: string; @@ -16,6 +19,7 @@ export async function doctor(_args: string[], ctx: Ctx): Promise { results.push(await checkProgram(ctx, "python3", "3.11", "install python3 >= 3.11 (e.g. apt install python3 python3-venv)")); results.push(await checkProgram(ctx, "git", undefined, "install git: https://git-scm.com/download/linux")); results.push(await checkUnshare(ctx)); + results.push(await checkVenvCapability(ctx)); const passed = results.filter((r) => r.passed).length; const failed = results.length - passed; @@ -23,6 +27,42 @@ export async function doctor(_args: string[], ctx: Ctx): Promise { return failed === 0 ? 0 : 1; } +async function checkVenvCapability(ctx: Ctx): Promise { + const probeDir = mkdtempSync(join(tmpdir(), "nc-venv-probe-")); + try { + const full = await ctx.spawn("python3", ["-m", "venv", join(probeDir, "v")], { + capture: true, + timeoutMs: 60_000, + }); + if (full.code === 0) return venvOk(ctx, "full venv"); + const pipless = await ctx.spawn("python3", ["-m", "venv", "--without-pip", join(probeDir, "v2")], { + capture: true, + timeoutMs: 60_000, + }); + if (pipless.code === 0) { + ok("python3 venv capability (fallback path: pip bootstrapped via get-pip)", ctx); + return { name: "venv", passed: true }; + } + return venvFail(ctx); + } finally { + await ctx.spawn("rm", ["-rf", probeDir], { capture: true, timeoutMs: 30_000 }); + } +} + +function venvOk(ctx: Ctx, mode: string): CheckOutcome { + ok(`python3 venv capability (${mode})`, ctx); + return { name: "venv", passed: true }; +} + +function venvFail(ctx: Ctx): CheckOutcome { + fail( + "python3 cannot create virtual environments (python3 -m venv fails)", + "Debian/Ubuntu: apt install python-venv (e.g. python3.12-venv for python 3.12) — nextcraft bootstrap needs it; then re-run nextcraft doctor", + ctx, + ); + return { name: "venv", passed: false }; +} + async function checkNode(ctx: Ctx): Promise { const version = process.version; if (compareVersions(version, "18") >= 0) { diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 621a056..e5cc480 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -7,6 +7,10 @@ import { dev } from "./commands/dev.js"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import net from "node:net"; +interface NodeRequire { + (id: string): unknown; +} + const commands: Record = { doctor, bootstrap, verify, dev }; export async function run(argv: string[], ctx: Ctx): Promise { @@ -36,13 +40,18 @@ export async function run(argv: string[], ctx: Ctx): Promise { return await command(positional.slice(1), ctx); } +declare const require: NodeRequire; + function isDirectRun(): boolean { if (process.env.NODE_TEST_CONTEXT) return false; - const [argv0, argv1] = process.argv; - if (argv0 && argv1 && argv0 === argv1) return true; - if (argv1?.endsWith("dist/index.js")) return true; - if (argv1?.endsWith("src/index.ts")) return true; - return false; + const argv1 = process.argv[1]; + if (argv1?.endsWith("dist/index.js") || argv1?.endsWith("src/index.ts")) return true; + try { + const sea = require("node:sea") as { isSea?: () => boolean }; + return typeof sea.isSea === "function" && sea.isSea(); + } catch { + return false; + } } if (isDirectRun()) { void (async () => { diff --git a/apps/cli/tests/commands.test.ts b/apps/cli/tests/commands.test.ts index 8fc89b7..49fb8c7 100644 --- a/apps/cli/tests/commands.test.ts +++ b/apps/cli/tests/commands.test.ts @@ -11,6 +11,7 @@ import type { SpawnResult } from "../src/lib/spawn.ts"; const r0 = (stdout = ""): SpawnResult => ({ code: 0, signal: null, timedOut: false, stdout, stderr: "" }); const r1 = (): SpawnResult => ({ code: 1, signal: null, timedOut: false, stdout: "", stderr: "" }); +const r = (stdout: string): SpawnResult => r0(stdout); test("doctor: all prerequisites present exits 0", async () => { const ctx = testCtx({ @@ -66,7 +67,15 @@ test("bootstrap: outside a repo fails with clone hint", async () => { assert.ok(ctx.err().includes("git clone")); }); -test("bootstrap: step order — pnpm install before venv bootstrap before env copy", async () => { +const doctorPassSpawn = async (cmd: string, args: string[]): Promise => { + if (cmd === "pnpm" && args[0] === "--version") return r("10.0.0\n"); + if (cmd === "python3" && args[0] === "--version") return r("Python 3.11.2\n"); + if (cmd === "git") return r("git version 2.39.2\n"); + if (cmd === "python3" && args[1] === "venv") return r0(); + return r0(); +}; + +test("bootstrap: step order — preflight, pnpm install, venv bootstrap, env copy", async () => { const dir = mkdtempSync(join(tmpdir(), "nc-repo-")); writeFileSync(join(dir, "pnpm-workspace.yaml"), "packages:\n - apps/*\n"); mkdirSync(join(dir, "apps", "ai-service"), { recursive: true }); @@ -77,13 +86,15 @@ test("bootstrap: step order — pnpm install before venv bootstrap before env co cwd: dir, spawn: async (cmd, args) => { calls.push(`${cmd} ${args.join(" ")}`); - return r0(); + return doctorPassSpawn(cmd, args); }, }); const code = await bootstrap([], ctx); assert.equal(code, 0); - assert.ok(calls[0].startsWith("pnpm install")); - assert.ok(calls[1].startsWith("bash scripts/bootstrap.sh")); + const installIdx = calls.findIndex((c) => c.startsWith("pnpm install")); + const venvIdx = calls.findIndex((c) => c.startsWith("bash scripts/bootstrap.sh")); + assert.ok(installIdx >= 0, "pnpm install runs"); + assert.ok(venvIdx > installIdx, "venv bootstrap runs after pnpm install"); assert.ok(ctx.exists(join(dir, "apps", "ai-service", ".env"))); assert.ok(ctx.out().includes("bootstrap complete")); }); @@ -96,7 +107,7 @@ test("bootstrap: existing .env kept, not overwritten", async () => { writeFileSync(join(aiDir, ".env.example"), "AI_PORT=8420\n"); writeFileSync(join(aiDir, ".env"), "AI_PORT=9999\n"); - const ctx = testCtx({ cwd: dir, spawn: async () => r0() }); + const ctx = testCtx({ cwd: dir, spawn: doctorPassSpawn }); const code = await bootstrap([], ctx); assert.equal(code, 0); assert.ok(ctx.out().includes("kept as-is")); @@ -110,14 +121,15 @@ test("bootstrap: failing pnpm install aborts before venv step", async () => { const ctx = testCtx({ cwd: dir, spawn: async (cmd, args) => { - calls.push(cmd); - return r1(); + calls.push(`${cmd} ${args.join(" ")}`); + if (cmd === "pnpm" && args[0] === "install") return r1(); + return doctorPassSpawn(cmd, args); }, }); const code = await bootstrap([], ctx); assert.equal(code, 1); - assert.equal(calls.length, 1); - assert.ok(ctx.err().includes("pnpm install failed")); + assert.ok(ctx.err().includes("pnpm install failed"), ctx.err()); + assert.equal(calls.filter((c) => c.startsWith("bash scripts/bootstrap.sh")).length, 0, "venv step must not run"); }); test("verify: busy port fails with stop-the-process hint", async () => { const dir = mkdtempSync(join(tmpdir(), "nc-ver-")); @@ -161,3 +173,46 @@ test("verify: free port passes and reports AI_PORT from .env", async () => { assert.equal(code, 0); assert.ok(ctx.out().includes("port 8421 free")); }); + +test("bootstrap: preflight failure aborts before pnpm install", async () => { + const dir = mkdtempSync(join(tmpdir(), "nc-pre-")); + writeFileSync(join(dir, "pnpm-workspace.yaml"), "packages:\n - apps/*\n"); + const calls: string[] = []; + const ctx = testCtx({ + cwd: dir, + spawn: async (cmd, args) => { + calls.push(`${cmd} ${args.join(" ")}`); + if (cmd === "which") return r0(); + if (cmd === "pnpm" && args[0] === "--version") return r("10.0.0\n"); + if (cmd === "python3" && args[0] === "--version") return r("Python 3.11.2\n"); + if (cmd === "git") return r("git version 2.39.2\n"); + if (cmd === "python3" && args[1] === "venv") return r1(); + if (cmd === "rm") return r0(); + return r0(); + }, + }); + const code = await bootstrap([], ctx); + assert.equal(code, 1); + assert.ok(ctx.err().includes("preflight failed")); + assert.equal(calls.filter((c) => c.startsWith("pnpm install")).length, 0, "pnpm install must not run when preflight fails"); +}); + +test("doctor: venv-capability probe failure surfaces apt hint", async () => { + const ctx = testCtx({ + spawn: async (cmd, args) => { + if (cmd === "which") return r0(); + if (cmd === "pnpm") return r("10.0.0\n"); + if (cmd === "python3" && args[0] === "--version") return r("Python 3.12.3\n"); + if (cmd === "git") return r("git version 2.43.0\n"); + if (cmd === "node") return r("v24.0.0\n"); + if (cmd === "python3" && args[1] === "venv") return r1(); + if (cmd === "rm") return r0(); + return r0(); + }, + }); + const code = await doctor([], ctx); + assert.equal(code, 1); + assert.ok(ctx.err().includes("cannot create virtual environments")); + assert.ok(ctx.err().includes("apt install python")); + assert.ok(ctx.err().includes("-venv")); +}); diff --git a/apps/cli/tests/install-script.test.ts b/apps/cli/tests/install-script.test.ts index f901fb1..5a2d1a7 100644 --- a/apps/cli/tests/install-script.test.ts +++ b/apps/cli/tests/install-script.test.ts @@ -122,6 +122,30 @@ test("install.sh: version-mismatching binary is rejected (G-102 install-time int } }); +test("install.sh: silent --version binary is rejected (honesty gate)", async () => { + const home = mkdtempSync(join(tmpdir(), "nc-home-silent-")); + const srv = await serve((srvDir, base) => { + const bin = join(srvDir, "nextcraft-linux-x64"); + writeFileSync(bin, "#!/bin/sh\nexit 0\n"); + writeFileSync(join(srvDir, "nextcraft-linux-x64.sha256"), `${sha256(bin)} nextcraft-linux-x64\n`); + apiManifestDir(srvDir, { + tag_name: "v9.9.9", + assets: [ + { name: "nextcraft-linux-x64", browser_download_url: `${base()}/nextcraft-linux-x64` }, + { name: "nextcraft-linux-x64.sha256", browser_download_url: `${base()}/nextcraft-linux-x64.sha256` }, + ], + }); + }); + try { + const res = await runInstall(home, srv.url); + assert.notEqual(res.status, 0, "silent binary must be rejected"); + assert.ok(res.stderr.includes("produced no output"), `stderr: ${res.stderr}`); + assert.ok(!existsSync(join(home, "bin", "nextcraft")) || res.stderr.includes("Do not use"), "must not leave a trusted silent binary"); + } finally { + srv.stop(); + } +}); + test("install.sh: release without binary assets degrades to source instructions, exit 0", async () => { const home = mkdtempSync(join(tmpdir(), "nc-home-nb-")); const srv = await serve((srvDir) => { diff --git a/apps/cli/tests/path-invocation.test.ts b/apps/cli/tests/path-invocation.test.ts new file mode 100644 index 0000000..5cdffb4 --- /dev/null +++ b/apps/cli/tests/path-invocation.test.ts @@ -0,0 +1,38 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const cliDir = join(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = join(cliDir, "..", ".."); +const binary = join(repoRoot, "apps", "cli", "dist", "nextcraft-linux-x64"); + +test("PATH bare-word invocation: installed binary speaks (SEA regression, v0.3.4 bug)", () => { + if (!existsSync(binary)) return; // built by cli:build:binary; covered in ship flow + const binDir = mkdtempSync(join(tmpdir(), "nc-pathword-")); + cpSync(binary, join(binDir, "nextcraft")); + const env = { + PATH: [binDir, process.env.PATH].filter(Boolean).join(":"), + HOME: process.env.HOME, + }; + const { NODE_TEST_CONTEXT, ...rest } = process.env as Record; + const envClean = { ...rest, ...env } as Record; + + const ver = spawnSync("sh", ["-c", "nextcraft --version"], { encoding: "utf8", timeout: 30000, env: envClean }); + assert.equal(ver.status, 0, `--version rc: ${ver.status} stderr: ${ver.stderr}`); + assert.ok(ver.stdout.trim().length > 0, "--version must print the version (v0.3.4 was silent)"); + + const doc = spawnSync("sh", ["-c", "nextcraft doctor"], { encoding: "utf8", timeout: 120000, env: envClean }); + assert.equal(doc.status, 0, `doctor rc: ${doc.status} stderr: ${doc.stderr}`); + assert.ok(doc.stdout.includes("environment prerequisites"), "doctor must print its report"); + + const noArgs = spawnSync("sh", ["-c", "nextcraft"], { encoding: "utf8", timeout: 30000, env: envClean }); + assert.equal(noArgs.status, 2, "no args must exit 2"); + assert.ok((noArgs.stderr + noArgs.stdout).includes("Usage:"), "no args must print usage"); + + const bad = spawnSync("sh", ["-c", "nextcraft nosuchcmd"], { encoding: "utf8", timeout: 30000, env: envClean }); + assert.equal(bad.status, 2, "unknown command must exit 2"); +}); \ No newline at end of file diff --git a/apps/web/.env.example b/apps/web/.env.example index b7450e1..ab0e6a6 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1 +1,5 @@ -NEXT_PUBLIC_AI_SERVICE_URL=http://localhost:8420 +# Browser-side engine API base URL. UNSET = auto (recommended): the browser +# derives http://:8420 at runtime, so remote browsing works +# zero-config. Set explicitly only for unusual topologies, e.g.: +# NEXT_PUBLIC_AI_SERVICE_URL=http://ai.internal:8420 +# NEXT_PUBLIC_AI_SERVICE_URL= \ No newline at end of file diff --git a/apps/web/components/learner/agent-stream-panel.tsx b/apps/web/components/learner/agent-stream-panel.tsx index e50e191..b6ea0bd 100644 --- a/apps/web/components/learner/agent-stream-panel.tsx +++ b/apps/web/components/learner/agent-stream-panel.tsx @@ -4,8 +4,9 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { parseSseEvents } from '../../lib/sse'; import { Bot, RefreshCw, AlertTriangle, Loader2 } from 'lucide-react'; -const AI_SERVICE_URL = - process.env.NEXT_PUBLIC_AI_SERVICE_URL ?? 'http://localhost:8420'; +import { engineBaseUrl } from '../../lib/engine-base-url'; + +const AI_SERVICE_URL = engineBaseUrl(); interface StreamPanelProps { title: string; diff --git a/apps/web/hooks/use-chat-stream.ts b/apps/web/hooks/use-chat-stream.ts index 7d29367..a3f6d64 100644 --- a/apps/web/hooks/use-chat-stream.ts +++ b/apps/web/hooks/use-chat-stream.ts @@ -3,8 +3,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { parseSseEvents } from '../lib/sse'; -const AI_SERVICE_URL = - process.env.NEXT_PUBLIC_AI_SERVICE_URL ?? 'http://localhost:8420'; +import { engineBaseUrl } from '../lib/engine-base-url'; + +const AI_SERVICE_URL = engineBaseUrl(); export type AgentName = 'coach' | 'tutor' | 'lab' | 'assessor' | 'proctor' | 'mentor'; diff --git a/apps/web/lib/engine-base-url.ts b/apps/web/lib/engine-base-url.ts new file mode 100644 index 0000000..0847f1b --- /dev/null +++ b/apps/web/lib/engine-base-url.ts @@ -0,0 +1,8 @@ +export function engineBaseUrl(): string { + const override = process.env.NEXT_PUBLIC_AI_SERVICE_URL; + if (override) return override; + if (typeof window !== "undefined") { + return `http://${window.location.hostname}:8420`; + } + return "http://localhost:8420"; +} \ No newline at end of file diff --git a/apps/web/lib/engine-client.ts b/apps/web/lib/engine-client.ts index ecef00f..92ba946 100644 --- a/apps/web/lib/engine-client.ts +++ b/apps/web/lib/engine-client.ts @@ -22,8 +22,9 @@ import type { TelemetryEvent, } from '@nextcraft/types'; -export const AI_SERVICE_URL = - process.env.NEXT_PUBLIC_AI_SERVICE_URL ?? 'http://localhost:8420'; +import { engineBaseUrl } from './engine-base-url'; + +export const AI_SERVICE_URL = engineBaseUrl(); /** v0.3 mock session constant (G-5: allowlisted server-side as pilot-learner). */ export const MOCK_LEARNER_ID = 'pilot-learner'; diff --git a/apps/web/package.json b/apps/web/package.json index 2c9df1b..320ff5a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,8 @@ "start": "next start", "typecheck": "tsc --noEmit", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "test": "tsx --test tests/*.test.ts" }, "dependencies": { "@nextcraft/mock-data": "workspace:*", @@ -33,6 +34,7 @@ "@types/react-dom": "^19.0.0", "storybook": "^10.6.0", "tailwindcss": "^4.0.0", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "tsx": "^4.23.0" } -} \ No newline at end of file +} diff --git a/apps/web/tests/engine-base-url.test.ts b/apps/web/tests/engine-base-url.test.ts new file mode 100644 index 0000000..7a2f28e --- /dev/null +++ b/apps/web/tests/engine-base-url.test.ts @@ -0,0 +1,31 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +async function freshModule() { + const qs = `?t=${Date.now()}-${Math.random()}`; + return await import(`../lib/engine-base-url.ts${qs}`); +} + +test("engine-base-url: NEXT_PUBLIC_AI_SERVICE_URL override wins", async () => { + process.env.NEXT_PUBLIC_AI_SERVICE_URL = "http://explicit.example:9000"; + const mod = await freshModule(); + assert.equal(mod.engineBaseUrl(), "http://explicit.example:9000"); + delete process.env.NEXT_PUBLIC_AI_SERVICE_URL; +}); + +test("engine-base-url: browser context derives API host from window.location.hostname", async () => { + delete process.env.NEXT_PUBLIC_AI_SERVICE_URL; + const g = globalThis as Record; + g.window = { location: { hostname: "nextcraft-1" } }; + const mod = await freshModule(); + assert.equal(mod.engineBaseUrl(), "http://nextcraft-1:8420"); + delete g.window; +}); + +test("engine-base-url: server context falls back to localhost", async () => { + delete process.env.NEXT_PUBLIC_AI_SERVICE_URL; + const g = globalThis as Record; + delete g.window; + const mod = await freshModule(); + assert.equal(mod.engineBaseUrl(), "http://localhost:8420"); +}); \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91ff0f8..2d068f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: tailwindcss: specifier: ^4.0.0 version: 4.3.3 + tsx: + specifier: ^4.23.0 + version: 4.23.13 typescript: specifier: ^5.7.2 version: 5.9.3 diff --git a/scripts/install.sh b/scripts/install.sh index bb400bb..3a61b69 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -77,10 +77,13 @@ chmod +x "$DEST/nextcraft" say "nextcraft: installed $DEST/nextcraft ($TAG)" INSTALLED_VERSION="$("$DEST/nextcraft" --version 2>/dev/null || true)" -if [ -n "$INSTALLED_VERSION" ] && [ "$INSTALLED_VERSION" != "$TAG" ]; then +if [ -z "$INSTALLED_VERSION" ]; then + die "nextcraft: installed binary produced no output for --version — it is broken. Do not use it; remove $DEST/nextcraft, report the issue, and fall back to source bootstrap (git clone $FORGE_BASE/$OWNER/$REPO.git)." +fi +if [ "$INSTALLED_VERSION" != "$TAG" ]; then die "nextcraft: binary reports version $INSTALLED_VERSION but release is $TAG — integrity mismatch. Remove $DEST/nextcraft and re-run the installer." fi -say "nextcraft: verified binary version: ${INSTALLED_VERSION:-$TAG}" +say "nextcraft: verified binary version: $INSTALLED_VERSION" case ":$PATH:" in *":$DEST:"*) ;; *)