merge: hotfix/fresh-box-experience → main (v0.3.5 fresh-box experience)
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---
This commit is contained in:
@@ -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 <forge>/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
|
||||
|
||||
|
||||
@@ -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://<your-host>:3000` — the web app targets `http://<your-host>: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://<your-host>: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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
exec "$VENV/bin/uvicorn" ai_service.main:app --reload --host "$HOST" --port "$PORT"
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<number> {
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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<number> {
|
||||
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<number> {
|
||||
return failed === 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
async function checkVenvCapability(ctx: Ctx): Promise<CheckOutcome> {
|
||||
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<version>-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<CheckOutcome> {
|
||||
const version = process.version;
|
||||
if (compareVersions(version, "18") >= 0) {
|
||||
|
||||
+14
-5
@@ -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<string, Command> = { doctor, bootstrap, verify, dev };
|
||||
|
||||
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
||||
@@ -36,13 +40,18 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
||||
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 () => {
|
||||
|
||||
@@ -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<SpawnResult> => {
|
||||
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"));
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<string, string | undefined>;
|
||||
const envClean = { ...rest, ...env } as Record<string, string | undefined>;
|
||||
|
||||
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");
|
||||
});
|
||||
@@ -1 +1,5 @@
|
||||
NEXT_PUBLIC_AI_SERVICE_URL=http://localhost:8420
|
||||
# Browser-side engine API base URL. UNSET = auto (recommended): the browser
|
||||
# derives http://<current-hostname>: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=
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
delete g.window;
|
||||
const mod = await freshModule();
|
||||
assert.equal(mod.engineBaseUrl(), "http://localhost:8420");
|
||||
});
|
||||
Generated
+3
@@ -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
|
||||
|
||||
+5
-2
@@ -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:"*) ;;
|
||||
*)
|
||||
|
||||
Reference in New Issue
Block a user