diff --git a/README.md b/README.md index 64c7825..6e973dd 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,16 @@ curl -sf http://localhost:4016/health curl -sf http://localhost:4016/api/bootstrap ``` +Local production-readiness smoke: + +```bash +pnpm run smoke:local + +# Use an already-running backend or skip shared service checks when isolating product behavior. +SMOKE_START_BACKEND=0 pnpm run smoke:local -- --no-start +pnpm run smoke:local -- --skip-platform +``` + ## Architecture | Surface | Stack | Port | diff --git a/docs/PLATFORM_SMOKE_CHECKS.md b/docs/PLATFORM_SMOKE_CHECKS.md index bd06bb1..60eec42 100644 --- a/docs/PLATFORM_SMOKE_CHECKS.md +++ b/docs/PLATFORM_SMOKE_CHECKS.md @@ -172,6 +172,28 @@ Expected: ## 7. NoteLett Backend Checks +The automated local smoke entrypoint is: + +```bash +pnpm run smoke:local +``` + +It follows the common-platform self-test convention of generating a short-lived local test JWT, then verifies: + +- `GET /health` +- `GET /api/bootstrap` +- platform-service, extraction-service, and mcp-server health +- authenticated workspace create +- authenticated note create/read +- best-effort workspace/note cleanup + +Useful variants: + +```bash +pnpm run smoke:local -- --no-start +pnpm run smoke:local -- --skip-platform +``` + Start backend in memory mode for local product smoke: ```bash diff --git a/package.json b/package.json index 50d7334..d1b8879 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "typecheck": "pnpm --filter @notelett/backend run typecheck && pnpm --filter @notelett/web run typecheck && pnpm --filter @notelett/mobile run typecheck", "test": "pnpm --filter @notelett/backend run test && pnpm --filter @notelett/web run test && pnpm --filter @notelett/mobile run test", "build": "pnpm --filter @notelett/backend run build && pnpm --filter @notelett/web run build", + "smoke:local": "bash scripts/local-smoke.sh", "verify": "pnpm run typecheck && pnpm run test && pnpm run build", "prepare": "husky" }, diff --git a/scripts/local-smoke.sh b/scripts/local-smoke.sh new file mode 100755 index 0000000..970e410 --- /dev/null +++ b/scripts/local-smoke.sh @@ -0,0 +1,267 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMMON_PLAT_DIR="${COMMON_PLAT_DIR:-${ROOT_DIR}/../learning_ai/learning_ai_common_plat}" + +NOTELETT_URL="${NOTELETT_URL:-http://localhost:4016}" +NOTELETT_API_URL="${NOTELETT_API_URL:-${NOTELETT_URL}/api}" +PLATFORM_URL="${PLATFORM_URL:-http://localhost:4003}" +EXTRACTION_URL="${EXTRACTION_URL:-http://localhost:4005}" +MCP_ORIGIN="${MCP_ORIGIN:-http://localhost:4007}" +JWT_SECRET="${JWT_SECRET:-dev-secret-do-not-use-in-prod}" +PRODUCT_ID="${PRODUCT_ID:-notelett}" +SMOKE_USER_ID="${SMOKE_USER_ID:-notelett-smoke-user}" +SMOKE_START_BACKEND="${SMOKE_START_BACKEND:-1}" +SMOKE_SKIP_PLATFORM="${SMOKE_SKIP_PLATFORM:-0}" + +BACKEND_PID="" +BACKEND_LOG="${TMPDIR:-/tmp}/notelett-local-smoke-backend.log" + +usage() { + cat <<'USAGE' +Usage: scripts/local-smoke.sh [--no-start] [--skip-platform] [--help] + +Checks: + - NoteLett GET /health + - NoteLett GET /api/bootstrap + - platform-service, extraction-service, and mcp-server /health + - authenticated workspace + note create/read/delete flow in DB_PROVIDER=memory + +Environment: + NOTELETT_URL=http://localhost:4016 + PLATFORM_URL=http://localhost:4003 + EXTRACTION_URL=http://localhost:4005 + MCP_ORIGIN=http://localhost:4007 + JWT_SECRET=dev-secret-do-not-use-in-prod + SMOKE_START_BACKEND=1 + SMOKE_SKIP_PLATFORM=0 + COMMON_PLAT_DIR=../learning_ai/learning_ai_common_plat +USAGE +} + +for arg in "$@"; do + case "$arg" in + --) + ;; + --no-start) + SMOKE_START_BACKEND=0 + ;; + --skip-platform) + SMOKE_SKIP_PLATFORM=1 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $arg" >&2 + usage >&2 + exit 2 + ;; + esac +done + +cleanup() { + if [[ -n "$BACKEND_PID" ]] && kill -0 "$BACKEND_PID" >/dev/null 2>&1; then + kill "$BACKEND_PID" >/dev/null 2>&1 || true + wait "$BACKEND_PID" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +curl_json() { + local method="$1" + local url="$2" + local body="${3:-}" + shift 3 || true + + if [[ -n "$body" ]]; then + curl -fsS -X "$method" "$url" \ + -H 'content-type: application/json' \ + "$@" \ + --data "$body" + else + curl -fsS -X "$method" "$url" "$@" + fi +} + +healthy() { + curl -fsS "$NOTELETT_URL/health" >/dev/null 2>&1 +} + +ensure_common_platform_builds() { + if [[ ! -d "$COMMON_PLAT_DIR" ]]; then + echo "error: missing common-platform checkout at $COMMON_PLAT_DIR" >&2 + return 1 + fi + + local palace_dist="$COMMON_PLAT_DIR/packages/palace/dist/index.js" + if [[ ! -f "$palace_dist" ]]; then + echo "info: building linked common-platform @bytelyst/palace package" + GITEA_NPM_TOKEN="${GITEA_NPM_TOKEN:-dummy}" \ + pnpm -C "$COMMON_PLAT_DIR" --filter @bytelyst/palace run build + fi + + local llm_dist="$COMMON_PLAT_DIR/packages/llm/dist/index.js" + if [[ ! -f "$llm_dist" ]] || ! grep -q 'buildVisionMessage' "$llm_dist"; then + echo "info: building linked common-platform @bytelyst/llm package" + GITEA_NPM_TOKEN="${GITEA_NPM_TOKEN:-dummy}" \ + pnpm -C "$COMMON_PLAT_DIR" --filter @bytelyst/llm run build + fi +} + +start_backend_if_needed() { + if healthy; then + echo "ok: NoteLett backend already healthy at $NOTELETT_URL" + return + fi + + if [[ "$SMOKE_START_BACKEND" != "1" ]]; then + echo "error: NoteLett backend is not healthy at $NOTELETT_URL and SMOKE_START_BACKEND=0" >&2 + return 1 + fi + + echo "info: starting NoteLett backend in memory mode" + ensure_common_platform_builds + ( + cd "$ROOT_DIR" + DB_PROVIDER=memory \ + NODE_ENV=development \ + JWT_SECRET="$JWT_SECRET" \ + PRODUCT_ID="$PRODUCT_ID" \ + pnpm --filter @notelett/backend run dev + ) >"$BACKEND_LOG" 2>&1 & + BACKEND_PID="$!" + + for _ in {1..60}; do + if healthy; then + echo "ok: NoteLett backend started at $NOTELETT_URL" + return + fi + if ! kill -0 "$BACKEND_PID" >/dev/null 2>&1; then + echo "error: backend exited before becoming healthy; log follows" >&2 + tail -n 80 "$BACKEND_LOG" >&2 || true + return 1 + fi + sleep 1 + done + + echo "error: backend did not become healthy within 60 seconds; log follows" >&2 + tail -n 80 "$BACKEND_LOG" >&2 || true + return 1 +} + +generate_token() { + ( + cd "$ROOT_DIR/backend" + JWT_SECRET="$JWT_SECRET" PRODUCT_ID="$PRODUCT_ID" SMOKE_USER_ID="$SMOKE_USER_ID" node --input-type=module <<'NODE' +import { SignJWT } from 'jose'; + +const secret = new TextEncoder().encode(process.env.JWT_SECRET); +const productId = process.env.PRODUCT_ID ?? 'notelett'; +const sub = process.env.SMOKE_USER_ID ?? 'notelett-smoke-user'; + +const token = await new SignJWT({ + email: 'smoke@notelett.local', + role: 'admin', + productId, + type: 'access', +}) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject(sub) + .setIssuer('bytelyst-platform') + .setIssuedAt() + .setExpirationTime('15m') + .sign(secret); + +process.stdout.write(token); +NODE + ) +} + +json_field_equals() { + local json="$1" + local field="$2" + local expected="$3" + + JSON_INPUT="$json" FIELD="$field" EXPECTED="$expected" node --input-type=module <<'NODE' +const body = JSON.parse(process.env.JSON_INPUT ?? '{}'); +const actual = body[process.env.FIELD]; +if (actual !== process.env.EXPECTED) { + console.error(`Expected ${process.env.FIELD}=${process.env.EXPECTED}, got ${String(actual)}`); + process.exit(1); +} +NODE +} + +check_platform_dependencies() { + if [[ "$SMOKE_SKIP_PLATFORM" == "1" ]]; then + echo "skip: platform dependency checks disabled" + return + fi + + curl -fsS "$PLATFORM_URL/health" >/dev/null + echo "ok: platform-service health" + + curl -fsS "$EXTRACTION_URL/health" >/dev/null + echo "ok: extraction-service health" + + curl -fsS "$MCP_ORIGIN/health" >/dev/null + echo "ok: mcp-server health" +} + +run_product_flow() { + local token="$1" + local run_id + run_id="smoke-$(date +%s)-$$" + local workspace_id="ws-$run_id" + local note_id="note-$run_id" + local auth_header="authorization: Bearer $token" + + local workspace_body + workspace_body="{\"id\":\"$workspace_id\",\"name\":\"Smoke Workspace\",\"members\":[]}" + local workspace + workspace="$(curl_json POST "$NOTELETT_API_URL/workspaces" "$workspace_body" -H "$auth_header")" + json_field_equals "$workspace" id "$workspace_id" + echo "ok: authenticated workspace create" + + local note_body + note_body="{\"id\":\"$note_id\",\"workspaceId\":\"$workspace_id\",\"title\":\"Smoke Note\",\"body\":\"

Local smoke note body.

\",\"tags\":[\"smoke\"],\"links\":[]}" + local note + note="$(curl_json POST "$NOTELETT_API_URL/notes" "$note_body" -H "$auth_header")" + json_field_equals "$note" id "$note_id" + echo "ok: authenticated note create" + + local fetched + fetched="$(curl_json GET "$NOTELETT_API_URL/notes/$note_id?workspaceId=$workspace_id" "" -H "$auth_header")" + json_field_equals "$fetched" productId "$PRODUCT_ID" + json_field_equals "$fetched" id "$note_id" + echo "ok: authenticated note read" + + curl_json DELETE "$NOTELETT_API_URL/notes/$note_id?workspaceId=$workspace_id" "" -H "$auth_header" >/dev/null 2>&1 || true + curl_json DELETE "$NOTELETT_API_URL/workspaces/$workspace_id" "" -H "$auth_header" >/dev/null 2>&1 || true + echo "ok: smoke cleanup attempted" +} + +main() { + start_backend_if_needed + + curl -fsS "$NOTELETT_URL/health" >/dev/null + echo "ok: NoteLett health" + + local bootstrap + bootstrap="$(curl_json GET "$NOTELETT_API_URL/bootstrap" "")" + json_field_equals "$bootstrap" productId "$PRODUCT_ID" + echo "ok: NoteLett bootstrap" + + check_platform_dependencies + + local token + token="$(generate_token)" + run_product_flow "$token" + + echo "ok: local production-readiness smoke passed" +} + +main