ndomo 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.env.example +4 -0
  2. package/README.es.md +29 -23
  3. package/README.md +64 -24
  4. package/bun.lock +447 -0
  5. package/docs/configuration.md +4 -4
  6. package/docs/installation.md +53 -34
  7. package/docs/installer.md +164 -0
  8. package/docs/integrations.md +1 -1
  9. package/docs/web-ui.md +124 -0
  10. package/package.json +43 -4
  11. package/scripts/install.sh +28 -0
  12. package/scripts/smoke-install.sh +47 -0
  13. package/scripts/smoke-web.sh +335 -0
  14. package/src/cli/__tests__/install.test.ts +733 -0
  15. package/src/cli/index.ts +8 -0
  16. package/src/cli/install.ts +1273 -0
  17. package/src/config/__tests__/schema.test.ts +223 -0
  18. package/src/config/schema.ts +129 -16
  19. package/src/http/__tests__/auth.test.ts +10 -10
  20. package/src/http/__tests__/spa.test.ts +296 -0
  21. package/src/http/auth.ts +8 -1
  22. package/src/http/server.ts +71 -2
  23. package/.bun-version +0 -1
  24. package/.dockerignore +0 -79
  25. package/.editorconfig +0 -18
  26. package/.github/CODEOWNERS +0 -8
  27. package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -62
  28. package/.github/ISSUE_TEMPLATE/config.yml +0 -2
  29. package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -34
  30. package/.github/dependabot.yml +0 -36
  31. package/.github/pull_request_template.md +0 -24
  32. package/.github/release.yml +0 -30
  33. package/.github/workflows/gitleaks.yml +0 -28
  34. package/.github/workflows/release-please.yml +0 -27
  35. package/.github/workflows/smoke.yml +0 -29
  36. package/.husky/commit-msg +0 -1
  37. package/CHANGELOG.md +0 -114
  38. package/Dockerfile +0 -32
  39. package/bin/ndomo-analyses.ts +0 -4
  40. package/bin/ndomo-status.ts +0 -4
  41. package/biome.json +0 -57
  42. package/commitlint.config.js +0 -3
  43. package/opencode.json +0 -5
  44. package/release-please-config.json +0 -11
  45. package/scripts/dev-bust-cache.sh +0 -164
  46. package/scripts/smoke-e2e.ts +0 -704
  47. package/scripts/smoke-hot.ts +0 -417
  48. package/scripts/smoke-v4.ts +0 -256
  49. package/scripts/smoke-v5.ts +0 -397
  50. package/scripts/uninstall.sh +0 -224
  51. package/src/index.ts +0 -37
  52. package/src/lib.ts +0 -65
  53. package/src/mem/scoped.ts +0 -65
  54. package/src/orchestrator/background.test.ts +0 -268
  55. package/src/orchestrator/background.ts +0 -293
  56. package/src/orchestrator/memory-hook.ts +0 -182
  57. package/src/orchestrator/reconciler.ts +0 -123
  58. package/src/orchestrator/scheduler.test.ts +0 -300
  59. package/src/orchestrator/scheduler.ts +0 -243
  60. package/src/plugin.test.ts +0 -2574
  61. package/src/plugin.ts +0 -1690
  62. package/src/worktrees/manager.ts +0 -236
  63. package/src/worktrees/state.ts +0 -87
  64. package/tests/integration/ranger-flow.test.ts +0 -257
  65. package/tsconfig.json +0 -31
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bash
2
+ # ndomo installer smoke test — verifies TS installer works end-to-end in a
3
+ # fresh tmp directory. Idempotent: cleans up tmp on exit (success or failure).
4
+ # Exit 0 if all smoke checks pass, exit 1 on any failure.
5
+ set -euo pipefail
6
+
7
+ # ── Colors ────────────────────────────────────────────────────────────────────
8
+ readonly GREEN='\033[0;32m'
9
+ readonly RED='\033[0;31m'
10
+ readonly YELLOW='\033[0;33m'
11
+ readonly BLUE='\033[0;34m'
12
+ readonly NC='\033[0m'
13
+
14
+ # ── Paths ─────────────────────────────────────────────────────────────────────
15
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
+ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
17
+ WORKTREE_ROOT="$PROJECT_ROOT"
18
+
19
+ # ── Isolated tmp dir + cleanup trap ───────────────────────────────────────────
20
+ TMPDIR="$(mktemp -d -t ndomo-smoke-install-XXXXXX)"
21
+ export XDG_CONFIG_HOME="$TMPDIR/config"
22
+
23
+ cleanup() {
24
+ local exit_code=$?
25
+ rm -rf "$TMPDIR"
26
+ if [[ $exit_code -eq 0 ]]; then
27
+ echo -e "${GREEN}[smoke-install]${NC} tmp cleaned: $TMPDIR"
28
+ else
29
+ echo -e "${RED}[smoke-install]${NC} tmp cleaned (after failure): $TMPDIR"
30
+ fi
31
+ exit $exit_code
32
+ }
33
+ trap cleanup EXIT
34
+
35
+ echo -e "${BLUE}[smoke-install]${NC} tmp dir: $TMPDIR"
36
+ echo -e "${BLUE}[smoke-install]${NC} XDG_CONFIG_HOME=$XDG_CONFIG_HOME"
37
+ echo -e "${YELLOW}[smoke-install]${NC} running: bun run src/cli/install.ts --dry-run --preset=default --enable-http"
38
+
39
+ # ── Run installer (dry-run, safe — does not write to filesystem) ──────────────
40
+ cd "$PROJECT_ROOT"
41
+ if bun run src/cli/install.ts --dry-run --preset=default --enable-http; then
42
+ echo -e "${GREEN}[smoke-install] PASS:${NC} bunx ndomo install dry-run completed"
43
+ exit 0
44
+ else
45
+ echo -e "${RED}[smoke-install] FAIL:${NC} install.ts dry-run exited non-zero" >&2
46
+ exit 1
47
+ fi
@@ -0,0 +1,335 @@
1
+ #!/usr/bin/env bash
2
+ # ndomo I3 web UI smoke test — verifies Elysia single-port SPA topology.
3
+ #
4
+ # Builds the Vue SPA via `bun run web:build`, boots the HTTP server, then runs
5
+ # a battery of curl assertions:
6
+ # - GET / → 200 text/html (SPA index)
7
+ # - GET /api/health → 200 JSON
8
+ # - GET /api/plans without auth → 401 + WWW-Authenticate
9
+ # - GET /api/plans with auth → 200 JSON array
10
+ # - GET /plans/<random-uuid> → 200 text/html (SPA fallback)
11
+ # - GET /api/plans/<bad-uuid> → 404 JSON
12
+ # - GET /assets/<hashed>.js → 200 application/javascript
13
+ # - GET /assets/<hashed>.css → 200 text/css
14
+ #
15
+ # Plus suite-level checks at the end:
16
+ # - bun run typecheck (0 errors)
17
+ # - bun test (full suite, no regressions)
18
+ # - bun run web:typecheck (0 errors)
19
+ #
20
+ # Usage:
21
+ # bash scripts/smoke-web.sh
22
+ #
23
+ # Env overrides:
24
+ # SMOKE_PORT TCP port for the server (default: 4097)
25
+ # SMOKE_PASSWORD HTTP Basic password (default: smoke-test-password)
26
+ # SMOKE_TIMEOUT Health-check wait in seconds (default: 10)
27
+ # SKIP_BUILD Skip `bun run web:build` if artifacts already present
28
+ #
29
+ # Exit 0 on all assertions pass, exit 1 on any failure.
30
+ set -euo pipefail
31
+
32
+ # ─── Banner ──────────────────────────────────────────────────────────────────
33
+ echo "┌──────────────────────────────────────┐"
34
+ echo "│ I3 web UI SPA smoke test │"
35
+ echo "└──────────────────────────────────────┘"
36
+
37
+ # ─── Resolve project root (this script's parent dir) ─────────────────────────
38
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
39
+ PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
40
+ cd "${PROJECT_ROOT}"
41
+
42
+ # ─── Pre-flight checks ───────────────────────────────────────────────────────
43
+ command -v bun >/dev/null 2>&1 || {
44
+ echo "[FAIL] bun not found in PATH — install from https://bun.sh" >&2
45
+ exit 1
46
+ }
47
+ command -v curl >/dev/null 2>&1 || {
48
+ echo "[FAIL] curl not found in PATH" >&2
49
+ exit 1
50
+ }
51
+
52
+ # ─── Config ──────────────────────────────────────────────────────────────────
53
+ SMOKE_PORT="${SMOKE_PORT:-4097}"
54
+ SMOKE_PASSWORD="${SMOKE_PASSWORD:-smoke-test-password}"
55
+ SMOKE_TIMEOUT="${SMOKE_TIMEOUT:-10}"
56
+ SKIP_BUILD="${SKIP_BUILD:-}"
57
+
58
+ # Validate port range
59
+ if ! [[ "${SMOKE_PORT}" =~ ^[0-9]+$ ]] || [ "${SMOKE_PORT}" -lt 1 ] || [ "${SMOKE_PORT}" -gt 65535 ]; then
60
+ echo "[FAIL] invalid SMOKE_PORT: ${SMOKE_PORT}" >&2
61
+ exit 1
62
+ fi
63
+
64
+ # ─── Port fallback helper ────────────────────────────────────────────────────
65
+ # If SMOKE_PORT is in use, fall back to the next free port up to +10.
66
+ find_free_port() {
67
+ local start="$1"
68
+ for offset in $(seq 0 10); do
69
+ local candidate=$((start + offset))
70
+ if ! ss -lnt 2>/dev/null | awk '{print $4}' | grep -qE "(^|:)${candidate}$"; then
71
+ echo "${candidate}"
72
+ return 0
73
+ fi
74
+ done
75
+ return 1
76
+ }
77
+
78
+ PORT="$(find_free_port "${SMOKE_PORT}")" || {
79
+ echo "[FAIL] no free port in range ${SMOKE_PORT}-$((SMOKE_PORT + 10))" >&2
80
+ exit 1
81
+ }
82
+ if [ "${PORT}" != "${SMOKE_PORT}" ]; then
83
+ echo "[info] SMOKE_PORT ${SMOKE_PORT} busy — falling back to ${PORT}"
84
+ fi
85
+
86
+ # ─── Build SPA (Vite → src/http/web/) ────────────────────────────────────────
87
+ WEB_DIST="${PROJECT_ROOT}/src/http/web"
88
+ if [ -n "${SKIP_BUILD}" ] && [ -f "${WEB_DIST}/index.html" ]; then
89
+ echo "[setup] SKIP_BUILD set + artifacts present — skipping web:build"
90
+ else
91
+ echo "[setup] building Vue SPA (bun run web:build)..."
92
+ bun run web:build || {
93
+ echo "[FAIL] bun run web:build failed" >&2
94
+ exit 1
95
+ }
96
+ fi
97
+
98
+ if [ ! -f "${WEB_DIST}/index.html" ]; then
99
+ echo "[FAIL] ${WEB_DIST}/index.html missing after build" >&2
100
+ exit 1
101
+ fi
102
+
103
+ # ─── Bootstrap .ndomo/state.db if missing ────────────────────────────────────
104
+ NDOMO_DB="${PROJECT_ROOT}/.ndomo/state.db"
105
+ if [ ! -f "${NDOMO_DB}" ]; then
106
+ echo "[setup] bootstrapping .ndomo/state.db..."
107
+ bun -e '
108
+ import { Database } from "bun:sqlite";
109
+ import { mkdirSync } from "node:fs";
110
+ import { join } from "node:path";
111
+ const dir = join(process.cwd(), ".ndomo");
112
+ mkdirSync(dir, { recursive: true });
113
+ const db = new Database(join(dir, "state.db"), { create: true });
114
+ db.exec("PRAGMA foreign_keys = ON");
115
+ db.exec("PRAGMA auto_vacuum = INCREMENTAL");
116
+ db.exec("PRAGMA journal_mode = WAL");
117
+ db.exec("PRAGMA synchronous = NORMAL");
118
+ const { runMigrations } = await import("./src/db/migrations.ts");
119
+ runMigrations(db);
120
+ db.close();
121
+ console.log("[setup] db initialized");
122
+ ' || {
123
+ echo "[FAIL] failed to bootstrap .ndomo/state.db" >&2
124
+ exit 1
125
+ }
126
+ fi
127
+
128
+ # ─── Export server env ───────────────────────────────────────────────────────
129
+ export NDOMO_HTTP_ENABLED=true
130
+ export NDOMO_HTTP_PORT="${PORT}"
131
+ export NDOMO_HTTP_AUTH_REQUIRED=true
132
+ export NDOMO_HTTP_CORS_ORIGINS='*'
133
+ export OPENCODE_SERVER_PASSWORD="${SMOKE_PASSWORD}"
134
+ export OPENCODE_SERVER_URL="${OPENCODE_SERVER_URL:-http://localhost:4096}"
135
+
136
+ # ─── Start server in background ─────────────────────────────────────────────
137
+ LOG="$(mktemp -t smoke-web-XXXXXX.log)"
138
+ echo "[setup] starting server on port ${PORT} (log: ${LOG})"
139
+
140
+ bun run src/cli/serve.ts --port "${PORT}" --cors '*' >"${LOG}" 2>&1 &
141
+ SERVER_PID=$!
142
+
143
+ # Register cleanup trap — runs even on assertion failure
144
+ cleanup() {
145
+ local exit_code=$?
146
+ if kill -0 "${SERVER_PID}" 2>/dev/null; then
147
+ echo "[cleanup] killing server pid ${SERVER_PID}"
148
+ kill "${SERVER_PID}" 2>/dev/null || true
149
+ for _ in 1 2 3 4 5 6; do
150
+ kill -0 "${SERVER_PID}" 2>/dev/null || break
151
+ sleep 0.5
152
+ done
153
+ kill -9 "${SERVER_PID}" 2>/dev/null || true
154
+ wait "${SERVER_PID}" 2>/dev/null || true
155
+ fi
156
+ rm -f "${LOG}"
157
+ exit "${exit_code}"
158
+ }
159
+ trap cleanup EXIT INT TERM
160
+
161
+ # ─── Wait for /health (up to SMOKE_TIMEOUT seconds, 200ms backoff) ────────────
162
+ echo "[wait] polling /health (timeout ${SMOKE_TIMEOUT}s)..."
163
+ ready=false
164
+ deadline=$(( $(date +%s) + SMOKE_TIMEOUT ))
165
+ while [ "$(date +%s)" -lt "${deadline}" ]; do
166
+ if ! kill -0 "${SERVER_PID}" 2>/dev/null; then
167
+ echo "[FAIL] server died before becoming ready. Log:"
168
+ cat "${LOG}" >&2
169
+ exit 1
170
+ fi
171
+ if curl -fsS -o /dev/null "localhost:${PORT}/health" 2>/dev/null; then
172
+ ready=true
173
+ break
174
+ fi
175
+ sleep 0.2
176
+ done
177
+ if [ "${ready}" != "true" ]; then
178
+ echo "[FAIL] /health did not respond within ${SMOKE_TIMEOUT}s. Log:"
179
+ cat "${LOG}" >&2
180
+ exit 1
181
+ fi
182
+ echo "[ready] server up on port ${PORT}"
183
+
184
+ # ─── Assertion harness ───────────────────────────────────────────────────────
185
+ PASS=0
186
+ FAIL=0
187
+ FAILED_NAMES=()
188
+
189
+ assert() {
190
+ local name="$1"
191
+ local cmd="$2"
192
+ if eval "${cmd}" >/dev/null 2>&1; then
193
+ echo "[PASS] ${name}"
194
+ PASS=$((PASS + 1))
195
+ else
196
+ echo "[FAIL] ${name}"
197
+ FAIL=$((FAIL + 1))
198
+ FAILED_NAMES+=("${name}")
199
+ fi
200
+ }
201
+
202
+ # ─── SPA index served at / (PUBLIC — no auth required) ──────────────────────
203
+ # SPA root + static assets + history fallback are exempt from auth. Only /api/*
204
+ # requires the OPENCODE_SERVER_PASSWORD.
205
+ curl -fsS -o /tmp/smoke-index.html "localhost:${PORT}/" || true
206
+ JS_ASSET_PATH="$(grep -oE 'assets/[A-Za-z0-9_./-]+\.js' /tmp/smoke-index.html | head -1 || true)"
207
+ CSS_ASSET_PATH="$(grep -oE 'assets/[A-Za-z0-9_./-]+\.css' /tmp/smoke-index.html | head -1 || true)"
208
+
209
+ assert "SPA index served: GET / → 200 text/html (no auth, public)" \
210
+ "[ \"\$(curl -s -o /dev/null -w '%{http_code}' localhost:${PORT}/)\" = '200' ] && [ \"\$(curl -s -o /dev/null -w '%{content_type}' localhost:${PORT}/ | cut -d';' -f1)\" = 'text/html' ]"
211
+
212
+ assert "SPA index body references hashed asset" \
213
+ "[ -n \"${JS_ASSET_PATH}\" ]"
214
+
215
+ # ─── /health still works (no auth required) ─────────────────────────────────
216
+ assert "API: GET /health → 200 JSON status=ok (no auth)" \
217
+ "[ \"\$(curl -fsS -o /tmp/smoke-health.json -w '%{http_code}' localhost:${PORT}/health)\" = '200' ] && [ \"\$(grep -o '\"status\":\"[^\"]*\"' /tmp/smoke-health.json | head -1 | cut -d'\"' -f4)\" = 'ok' ]"
218
+
219
+ # ─── /api/plans auth required ───────────────────────────────────────────────
220
+ assert "API: GET /api/plans without auth → 401 + WWW-Authenticate" \
221
+ "[ \"\$(curl -s -o /dev/null -w '%{http_code}' localhost:${PORT}/api/plans)\" = '401' ] && [ -n \"\$(curl -s -i localhost:${PORT}/api/plans | grep -i '^www-authenticate:')\" ]"
222
+
223
+ # ─── /api/plans auth OK ──────────────────────────────────────────────────────
224
+ assert "API: GET /api/plans with auth → 200 JSON array" \
225
+ "[ \"\$(curl -fsS -o /tmp/smoke-plans.json -w '%{http_code}' -u \"user:${SMOKE_PASSWORD}\" localhost:${PORT}/api/plans)\" = '200' ] && head -c 1 /tmp/smoke-plans.json | grep -q '\\['"
226
+
227
+ # ─── SPA history fallback (non-/api path → index.html, NOT 404) ─────────────
228
+ RANDOM_UUID="$(cat /proc/sys/kernel/random/uuid)"
229
+ curl -s -o /tmp/smoke-spa.html -w '%{http_code}|%{content_type}' "localhost:${PORT}/plans/${RANDOM_UUID}" > /tmp/smoke-spa-meta.txt || true
230
+ assert "SPA fallback: GET /plans/<random-uuid> → 200 text/html (not 404, no auth)" \
231
+ "[ \"\$(cut -d'|' -f1 /tmp/smoke-spa-meta.txt)\" = '200' ] && [ \"\$(cut -d'|' -f2 /tmp/smoke-spa-meta.txt | cut -d';' -f1)\" = 'text/html' ] && grep -q '<div id=\"app\"' /tmp/smoke-spa.html"
232
+
233
+ # ─── /api/* unknown → 404 JSON (SPA fallback does NOT swallow /api) ─────────
234
+ assert "API: GET /api/plans/<bad-uuid> → 404 JSON error" \
235
+ "[ \"\$(curl -s -o /tmp/smoke-404.json -w '%{http_code}' -u \"user:${SMOKE_PASSWORD}\" localhost:${PORT}/api/plans/does-not-exist-zzz)\" = '404' ] && [ \"\$(grep -o '\"[^\"]*\"' /tmp/smoke-404.json | head -1 | cut -d'\"' -f2)\" = 'error' ]"
236
+
237
+ # ─── Static JS asset served (PUBLIC, no auth) ───────────────────────────────
238
+ if [ -n "${JS_ASSET_PATH}" ]; then
239
+ assert "Static: GET /${JS_ASSET_PATH} → 200 application/javascript (no auth)" \
240
+ "[ \"\$(curl -fsS -o /tmp/smoke-asset.js -w '%{http_code}' localhost:${PORT}/${JS_ASSET_PATH})\" = '200' ] && [ \"\$(curl -s -o /dev/null -w '%{content_type}' localhost:${PORT}/${JS_ASSET_PATH} | cut -d';' -f1)\" = 'application/javascript' ] && [ \"\$(wc -c < /tmp/smoke-asset.js)\" -gt 100 ]"
241
+ else
242
+ assert "Static: GET /assets/<hashed>.js → 200 application/javascript" "false"
243
+ fi
244
+
245
+ # ─── Static CSS asset served (PUBLIC, no auth) ──────────────────────────────
246
+ if [ -n "${CSS_ASSET_PATH}" ]; then
247
+ assert "Static: GET /${CSS_ASSET_PATH} → 200 text/css (no auth)" \
248
+ "[ \"\$(curl -fsS -o /tmp/smoke-style.css -w '%{http_code}' localhost:${PORT}/${CSS_ASSET_PATH})\" = '200' ] && [ \"\$(curl -s -o /dev/null -w '%{content_type}' localhost:${PORT}/${CSS_ASSET_PATH} | cut -d';' -f1)\" = 'text/css' ]"
249
+ else
250
+ # Vue SPA often inlines CSS — skip if no external CSS asset.
251
+ assert "Static: GET /assets/<hashed>.css → 200 text/css (skipped: no external CSS asset in build)" "true"
252
+ fi
253
+
254
+ # ─── Stop server before suite-level checks ──────────────────────────────────
255
+ echo ""
256
+ echo "[cleanup] stopping server before suite-level checks..."
257
+ kill "${SERVER_PID}" 2>/dev/null || true
258
+ for _ in 1 2 3 4 5 6; do
259
+ kill -0 "${SERVER_PID}" 2>/dev/null || break
260
+ sleep 0.5
261
+ done
262
+ kill -9 "${SERVER_PID}" 2>/dev/null || true
263
+ wait "${SERVER_PID}" 2>/dev/null || true
264
+ trap - EXIT INT TERM
265
+
266
+ # ─── Suite-level checks ─────────────────────────────────────────────────────
267
+ echo ""
268
+ echo "[suite] running bun run typecheck..."
269
+ if bun run typecheck >/tmp/smoke-typecheck.log 2>&1; then
270
+ echo "[PASS] typecheck: 0 errors"
271
+ PASS=$((PASS + 1))
272
+ else
273
+ echo "[FAIL] typecheck had errors:" >&2
274
+ tail -30 /tmp/smoke-typecheck.log >&2
275
+ FAIL=$((FAIL + 1))
276
+ FAILED_NAMES+=("typecheck")
277
+ fi
278
+
279
+ echo "[suite] running bun test (src/)..."
280
+ # Unset smoke env vars so they don't pollute tests that check default config behavior.
281
+ unset NDOMO_HTTP_ENABLED
282
+ unset NDOMO_HTTP_PORT
283
+ unset NDOMO_HTTP_AUTH_REQUIRED
284
+ unset NDOMO_HTTP_CORS_ORIGINS
285
+ unset OPENCODE_SERVER_PASSWORD
286
+ unset OPENCODE_SERVER_URL
287
+ # web/__tests__/ uses vitest (vi.mocked etc.) — not bun:test. Scope bun test to src/.
288
+ if bun test src/ >/tmp/smoke-buntest.log 2>&1; then
289
+ TLINE="$(grep -oE '[0-9]+ pass' /tmp/smoke-buntest.log | head -1 || echo 'all pass')"
290
+ echo "[PASS] bun test src/: ${TLINE}"
291
+ PASS=$((PASS + 1))
292
+ else
293
+ echo "[FAIL] bun test src/ had failures:" >&2
294
+ tail -40 /tmp/smoke-buntest.log >&2
295
+ FAIL=$((FAIL + 1))
296
+ FAILED_NAMES+=("bun test src/")
297
+ fi
298
+
299
+ echo "[suite] running bun run web:test (vitest)..."
300
+ if bun run web:test >/tmp/smoke-webtest.log 2>&1; then
301
+ TLINE="$(grep -oE 'Tests *[0-9]+ passed' /tmp/smoke-webtest.log | head -1 || echo 'all pass')"
302
+ echo "[PASS] web:test (vitest): ${TLINE}"
303
+ PASS=$((PASS + 1))
304
+ else
305
+ echo "[FAIL] web:test (vitest) had failures:" >&2
306
+ tail -40 /tmp/smoke-webtest.log >&2
307
+ FAIL=$((FAIL + 1))
308
+ FAILED_NAMES+=("web:test")
309
+ fi
310
+
311
+ echo "[suite] running bun run web:typecheck..."
312
+ if bun run web:typecheck >/tmp/smoke-webtypecheck.log 2>&1; then
313
+ echo "[PASS] web:typecheck: 0 errors"
314
+ PASS=$((PASS + 1))
315
+ else
316
+ echo "[FAIL] web:typecheck had errors:" >&2
317
+ tail -30 /tmp/smoke-webtypecheck.log >&2
318
+ FAIL=$((FAIL + 1))
319
+ FAILED_NAMES+=("web:typecheck")
320
+ fi
321
+
322
+ # ─── Report ──────────────────────────────────────────────────────────────────
323
+ echo ""
324
+ echo "────────────────────────────────────────"
325
+ TOTAL=$((PASS + FAIL))
326
+ echo "PASS: ${PASS}/${TOTAL}"
327
+ if [ "${FAIL}" -gt 0 ]; then
328
+ echo "FAILED assertions:"
329
+ for name in "${FAILED_NAMES[@]}"; do
330
+ echo " - ${name}"
331
+ done
332
+ exit 1
333
+ fi
334
+ echo "PASS: ${PASS}/${PASS}"
335
+ exit 0