@soulbatical/tetra-dev-toolkit 1.19.0 → 1.20.0

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.
package/README.md CHANGED
@@ -231,6 +231,82 @@ If any step fails, fix it before writing code. No exceptions.
231
231
 
232
232
  ---
233
233
 
234
+ ## Claude Code statusline
235
+
236
+ A 3-line statusline for Claude Code that shows context usage, costs, project health, and session metadata.
237
+
238
+ ### Setup
239
+
240
+ ```bash
241
+ # Symlink the script
242
+ ln -sf /path/to/tetra/packages/dev-toolkit/hooks/statusline.sh ~/.claude/hooks/statusline.sh
243
+
244
+ # Add to ~/.claude/settings.json
245
+ {
246
+ "statusLine": {
247
+ "type": "command",
248
+ "command": "~/.claude/hooks/statusline.sh"
249
+ }
250
+ }
251
+ ```
252
+
253
+ ### What it shows
254
+
255
+ ```
256
+ repo: vibecodingacademy tasks: 3 open / 12 done started by: user cmux: workspace:308 ⠂ Claude Code
257
+ opus ██████░░░░░░░░░ 44% context: 89K / 200K 99% cached this turn: +7K
258
+ $3.76 5m20s (api: 3m5s) lines: +47 -12 turn #5 main* v2.1.73
259
+ ```
260
+
261
+ **Line 1 — project dashboard**
262
+
263
+ | Field | Source | Description |
264
+ |-------|--------|-------------|
265
+ | `repo:` | `workspace.project_dir` | Current project directory name |
266
+ | `tasks:` | `.ralph/@fix_plan.md` | Open/done task count from Ralph fix plan |
267
+ | `started by:` | Process tree + cmux | `user`, `monica`, `ralph`, `cursor`, `vscode` |
268
+ | `cmux:` | `cmux identify` + `cmux list-workspaces` | Workspace ref and name (only in cmux terminal) |
269
+
270
+ **Line 2 — context window**
271
+
272
+ | Field | Source | Description |
273
+ |-------|--------|-------------|
274
+ | Model | `model.id` | Short name: `opus`, `sonnet`, `haiku` |
275
+ | Progress bar | `context_window.used_percentage` | 15-char bar, green <50%, yellow 50-80%, red >80% |
276
+ | `context:` | input + cache tokens | Effective tokens in window vs max |
277
+ | `cached` | `cache_read / context` | % of context served from prompt cache (saves cost) |
278
+ | `this turn:` | Delta from previous turn | Token growth this turn (helps spot expensive hooks) |
279
+
280
+ **Line 3 — session stats**
281
+
282
+ | Field | Source | Description |
283
+ |-------|--------|-------------|
284
+ | Cost | `cost.total_cost_usd` | Session cost so far |
285
+ | Duration | `cost.total_duration_ms` | Wall clock time |
286
+ | API time | `cost.total_api_duration_ms` | Time spent waiting for API responses |
287
+ | Lines | `cost.total_lines_added/removed` | Code changes this session |
288
+ | Turn | Delta tracker | Number of assistant responses |
289
+ | Branch | `git branch` | Current branch, `*` if dirty |
290
+ | Version | `version` | Claude Code version |
291
+
292
+ ### How "started by" detection works
293
+
294
+ 1. If running in cmux: checks workspace name — `monica:*` → `monica`
295
+ 2. Fallback: walks the process tree looking for `ralph`, `cursor`, `code` (VS Code)
296
+ 3. Default: `user` (manual terminal session)
297
+
298
+ ### Task colors
299
+
300
+ | Color | Meaning |
301
+ |-------|---------|
302
+ | Green | 0 open tasks (all done) |
303
+ | Yellow | 1-10 open tasks |
304
+ | Red | 10+ open tasks |
305
+
306
+ Projects without `.ralph/@fix_plan.md` don't show the tasks field.
307
+
308
+ ---
309
+
234
310
  ## Changelog
235
311
 
236
312
  ### 1.16.0
File without changes
@@ -43,7 +43,7 @@ program
43
43
  console.log('')
44
44
 
45
45
  const components = component === 'all' || !component
46
- ? ['hooks', 'ci', 'config']
46
+ ? ['hooks', 'ci', 'config', 'smoke']
47
47
  : [component]
48
48
 
49
49
  for (const comp of components) {
@@ -81,9 +81,12 @@ program
81
81
  case 'license-audit':
82
82
  await setupLicenseAudit(options)
83
83
  break
84
+ case 'smoke':
85
+ await setupSmoke(options)
86
+ break
84
87
  default:
85
88
  console.log(`Unknown component: ${comp}`)
86
- console.log('Available: hooks, ci, config, prettier, eslint-security, coverage, lighthouse, commitlint, depcruiser, knip, license-audit')
89
+ console.log('Available: hooks, ci, config, smoke, prettier, eslint-security, coverage, lighthouse, commitlint, depcruiser, knip, license-audit')
87
90
  }
88
91
  }
89
92
 
@@ -867,4 +870,171 @@ async function setupLicenseAudit(options) {
867
870
  console.log(' 📦 Run: npm install --save-dev license-checker')
868
871
  }
869
872
 
873
+ // ─── Smoke Tests ─────────────────────────────────────────────
874
+
875
+ async function setupSmoke(options) {
876
+ console.log('🔥 Setting up smoke tests...')
877
+
878
+ // Step 1: Detect project name from git remote or package.json
879
+ let projectName = null
880
+ try {
881
+ const remoteUrl = execSync('git remote get-url origin 2>/dev/null', { encoding: 'utf8' }).trim()
882
+ const match = remoteUrl.match(/\/([^/]+?)(?:\.git)?$/)
883
+ if (match) projectName = match[1]
884
+ } catch { /* no git remote */ }
885
+
886
+ if (!projectName) {
887
+ try {
888
+ const pkg = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf-8'))
889
+ projectName = pkg.name?.replace(/^@[^/]+\//, '') || null
890
+ } catch { /* no package.json */ }
891
+ }
892
+
893
+ if (!projectName) {
894
+ const { basename } = await import('path')
895
+ projectName = basename(projectRoot)
896
+ }
897
+
898
+ console.log(` Project: ${projectName}`)
899
+
900
+ // Step 2: Get deploy config from ralph-manager
901
+ let backendUrl = null
902
+ let frontendUrl = null
903
+
904
+ const ralphUrl = process.env.RALPH_MANAGER_API || 'http://localhost:3005'
905
+ try {
906
+ const resp = await fetch(`${ralphUrl}/api/internal/projects?name=${encodeURIComponent(projectName)}`, {
907
+ signal: AbortSignal.timeout(5000),
908
+ })
909
+ if (resp.ok) {
910
+ const { data } = await resp.json()
911
+ if (data?.deploy_config) {
912
+ const dc = data.deploy_config
913
+ backendUrl = dc.backend?.url || (dc.domains?.api_domain ? `https://${dc.domains.api_domain}` : null)
914
+ frontendUrl = dc.frontend?.url || (dc.domains?.frontend_domain ? `https://${dc.domains.frontend_domain}` : null)
915
+ }
916
+ }
917
+ } catch {
918
+ console.log(' ⚠️ Could not reach ralph-manager — using manual detection')
919
+ }
920
+
921
+ // Fallback: detect from railway.json
922
+ if (!backendUrl) {
923
+ const railwayPath = join(projectRoot, 'railway.json')
924
+ if (existsSync(railwayPath)) {
925
+ // Railway auto-deploy URL convention: {service-name}-production.up.railway.app
926
+ backendUrl = `https://${projectName}-production.up.railway.app`
927
+ console.log(` ℹ️ Guessed Railway URL: ${backendUrl}`)
928
+ }
929
+ }
930
+
931
+ if (!backendUrl) {
932
+ console.log(' ❌ Could not detect production URL.')
933
+ console.log(' Add deploy_config in ralph-manager or pass --url manually.')
934
+ console.log(' You can also add "smoke.baseUrl" to .tetra-quality.json manually.')
935
+ return
936
+ }
937
+
938
+ console.log(` Backend: ${backendUrl}`)
939
+ if (frontendUrl) console.log(` Frontend: ${frontendUrl}`)
940
+
941
+ // Step 3: Add smoke config to .tetra-quality.json
942
+ const configPath = join(projectRoot, '.tetra-quality.json')
943
+ let config = {}
944
+
945
+ if (existsSync(configPath)) {
946
+ try {
947
+ config = JSON.parse(readFileSync(configPath, 'utf-8'))
948
+ } catch { /* invalid JSON, start fresh */ }
949
+ }
950
+
951
+ if (!config.smoke || options.force) {
952
+ config.smoke = {
953
+ baseUrl: backendUrl,
954
+ ...(frontendUrl ? { frontendUrl } : {}),
955
+ timeout: 10000,
956
+ checks: {
957
+ health: true,
958
+ healthDeep: true,
959
+ authEndpoints: [
960
+ { path: '/api/admin/users', expect: { status: 401 }, description: 'Auth wall: admin' },
961
+ ],
962
+ ...(frontendUrl ? {
963
+ frontendPages: [
964
+ { path: '/', expect: { status: 200 }, description: 'Homepage' },
965
+ ]
966
+ } : {}),
967
+ },
968
+ notify: {
969
+ telegram: true,
970
+ onSuccess: false,
971
+ onFailure: true,
972
+ },
973
+ }
974
+
975
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
976
+ console.log(' ✅ Added smoke config to .tetra-quality.json')
977
+ } else {
978
+ console.log(' ⏭️ Smoke config already exists (use --force to overwrite)')
979
+ }
980
+
981
+ // Step 4: Create post-deploy GitHub Actions workflow
982
+ const workflowDir = join(projectRoot, '.github/workflows')
983
+ if (!existsSync(workflowDir)) {
984
+ mkdirSync(workflowDir, { recursive: true })
985
+ }
986
+
987
+ const smokeWorkflowPath = join(workflowDir, 'post-deploy-tests.yml')
988
+ if (!existsSync(smokeWorkflowPath) || options.force) {
989
+ let workflowContent = `name: Post-Deploy Smoke Tests
990
+
991
+ on:
992
+ # Triggered after deploy via webhook
993
+ repository_dispatch:
994
+ types: [deploy-completed]
995
+
996
+ # Manual trigger
997
+ workflow_dispatch:
998
+
999
+ # Scheduled: every 6 hours
1000
+ schedule:
1001
+ - cron: '0 */6 * * *'
1002
+
1003
+ jobs:
1004
+ smoke:
1005
+ uses: mralbertzwolle/tetra/.github/workflows/smoke-tests.yml@main
1006
+ with:
1007
+ backend-url: ${backendUrl}
1008
+ `
1009
+ if (frontendUrl) {
1010
+ workflowContent += ` frontend-url: ${frontendUrl}\n`
1011
+ }
1012
+ workflowContent += ` wait-seconds: 30
1013
+ post-deploy: true
1014
+ `
1015
+
1016
+ writeFileSync(smokeWorkflowPath, workflowContent)
1017
+ console.log(' ✅ Created .github/workflows/post-deploy-tests.yml')
1018
+ } else {
1019
+ console.log(' ⏭️ Post-deploy workflow already exists (use --force to overwrite)')
1020
+ }
1021
+
1022
+ // Step 5: Verify smoke tests work
1023
+ console.log('')
1024
+ console.log(' 🧪 Quick verification...')
1025
+ try {
1026
+ const resp = await fetch(`${backendUrl}/api/health`, { signal: AbortSignal.timeout(10000) })
1027
+ if (resp.ok) {
1028
+ console.log(` ✅ ${backendUrl}/api/health → ${resp.status} OK`)
1029
+ } else {
1030
+ console.log(` ⚠️ ${backendUrl}/api/health → ${resp.status}`)
1031
+ }
1032
+ } catch (err) {
1033
+ console.log(` ❌ ${backendUrl}/api/health → ${err.message}`)
1034
+ }
1035
+
1036
+ console.log('')
1037
+ console.log(' Next: run `tetra-smoke` to test all endpoints')
1038
+ }
1039
+
870
1040
  program.parse()
@@ -0,0 +1,532 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Tetra Smoke — Config-driven + auto-discover post-deploy smoke tester
5
+ *
6
+ * Runs HTTP-based smoke tests against live endpoints.
7
+ * Two modes:
8
+ * 1. Config-driven: tests from .tetra-quality.json smoke section
9
+ * 2. Auto-discover: fetches /api/health/routes and tests ALL endpoints
10
+ *
11
+ * Usage:
12
+ * tetra-smoke # Config + auto-discover
13
+ * tetra-smoke --url <backend-url> # Override backend URL
14
+ * tetra-smoke --discover-only # Only auto-discover (skip config checks)
15
+ * tetra-smoke --no-discover # Only config checks (skip auto-discover)
16
+ * tetra-smoke --json # JSON output for CI
17
+ * tetra-smoke --ci # GitHub Actions annotations
18
+ * tetra-smoke --notify # Telegram notification on failure
19
+ * tetra-smoke --post-deploy # Wait + retry mode for post-deploy
20
+ *
21
+ * Exit codes:
22
+ * 0 = all checks passed
23
+ * 1 = one or more checks failed
24
+ */
25
+
26
+ import { program } from 'commander'
27
+ import chalk from 'chalk'
28
+ import { readFileSync, existsSync } from 'fs'
29
+ import { join } from 'path'
30
+
31
+ function mergeHeaders(globalHeaders, endpointHeaders) {
32
+ return { ...(globalHeaders || {}), ...(endpointHeaders || {}) }
33
+ }
34
+
35
+ function loadSmokeConfig(projectRoot) {
36
+ const configFile = join(projectRoot, '.tetra-quality.json')
37
+ if (!existsSync(configFile)) return null
38
+
39
+ try {
40
+ const config = JSON.parse(readFileSync(configFile, 'utf-8'))
41
+ return config.smoke || null
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ async function httpCheck(url, options = {}) {
48
+ const timeout = options.timeout || 10000
49
+ const start = Date.now()
50
+
51
+ try {
52
+ const fetchOptions = {
53
+ method: options.method || 'GET',
54
+ signal: AbortSignal.timeout(timeout),
55
+ headers: options.headers || {},
56
+ redirect: 'follow',
57
+ }
58
+
59
+ const resp = await fetch(url, fetchOptions)
60
+
61
+ const duration = Date.now() - start
62
+ const body = await resp.text().catch(() => '')
63
+
64
+ return {
65
+ url,
66
+ status: resp.status,
67
+ duration,
68
+ bodyLength: body.length,
69
+ passed: options.expectStatus ? resp.status === options.expectStatus : resp.ok,
70
+ body: body.substring(0, 500),
71
+ }
72
+ } catch (err) {
73
+ return {
74
+ url,
75
+ status: 0,
76
+ duration: Date.now() - start,
77
+ bodyLength: 0,
78
+ passed: false,
79
+ error: err.message,
80
+ }
81
+ }
82
+ }
83
+
84
+ // ============================================================================
85
+ // AUTO-DISCOVER: Fetch /api/health/routes and test all endpoints
86
+ // ============================================================================
87
+
88
+ async function discoverAndTestRoutes(baseUrl, options = {}) {
89
+ const timeout = options.timeout || 10000
90
+ const results = []
91
+
92
+ // Fetch route manifest
93
+ let routes
94
+ try {
95
+ const resp = await fetch(`${baseUrl}/api/health/routes`, {
96
+ signal: AbortSignal.timeout(timeout),
97
+ })
98
+ if (!resp.ok) {
99
+ return {
100
+ passed: true,
101
+ results: [{
102
+ name: 'Route Discovery',
103
+ url: `${baseUrl}/api/health/routes`,
104
+ status: resp.status,
105
+ duration: 0,
106
+ passed: true, // Don't fail if endpoint doesn't exist yet
107
+ skipped: true,
108
+ error: `Route discovery not available (HTTP ${resp.status}) — deploy tetra-core update first`,
109
+ }],
110
+ discoveredCount: 0,
111
+ }
112
+ }
113
+
114
+ const data = await resp.json()
115
+ routes = data.routes || []
116
+ } catch (err) {
117
+ return {
118
+ passed: true,
119
+ results: [{
120
+ name: 'Route Discovery',
121
+ url: `${baseUrl}/api/health/routes`,
122
+ status: 0,
123
+ duration: 0,
124
+ passed: true,
125
+ skipped: true,
126
+ error: `Route discovery failed: ${err.message}`,
127
+ }],
128
+ discoveredCount: 0,
129
+ }
130
+ }
131
+
132
+ // Filter: only test GET routes (POST/PUT/DELETE would mutate data)
133
+ const getRoutes = routes.filter(r => r.method === 'GET')
134
+
135
+ // Skip routes with :params (we don't have valid IDs to test with)
136
+ // But we CAN test list endpoints, counts, filters, etc.
137
+ const testableRoutes = getRoutes.filter(r => !r.path.includes(':'))
138
+
139
+ // Skip certain paths that need special handling
140
+ const skipPaths = [
141
+ '/api/health', // Already tested
142
+ '/api/health/routes', // This endpoint itself
143
+ ]
144
+
145
+ const routesToTest = testableRoutes.filter(r => !skipPaths.includes(r.path))
146
+
147
+ // Expected status per auth level (without providing auth token)
148
+ const expectedStatus = {
149
+ public: null, // Could be 200, 400 (missing params), or 422 — just not 500
150
+ admin: 401, // Must require auth
151
+ user: 401, // Must require auth
152
+ superadmin: 401, // Must require auth
153
+ }
154
+
155
+ // Test routes in parallel batches of 10
156
+ const BATCH_SIZE = 10
157
+ for (let i = 0; i < routesToTest.length; i += BATCH_SIZE) {
158
+ const batch = routesToTest.slice(i, i + BATCH_SIZE)
159
+
160
+ const batchResults = await Promise.all(
161
+ batch.map(async (route) => {
162
+ const expected = expectedStatus[route.auth]
163
+ const r = await httpCheck(`${baseUrl}${route.path}`, {
164
+ timeout,
165
+ expectStatus: expected || undefined,
166
+ })
167
+
168
+ // For public routes: accept anything except 500 (server error)
169
+ if (route.auth === 'public') {
170
+ r.passed = r.status > 0 && r.status < 500
171
+ }
172
+
173
+ return {
174
+ name: `${route.auth.toUpperCase()} ${route.path}`,
175
+ auth: route.auth,
176
+ ...r,
177
+ }
178
+ })
179
+ )
180
+
181
+ results.push(...batchResults)
182
+ }
183
+
184
+ const allPassed = results.every(r => r.passed)
185
+ return {
186
+ passed: allPassed,
187
+ results,
188
+ discoveredCount: routesToTest.length,
189
+ totalRoutes: routes.length,
190
+ skippedParamRoutes: getRoutes.length - testableRoutes.length,
191
+ skippedNonGet: routes.length - getRoutes.length,
192
+ }
193
+ }
194
+
195
+ // ============================================================================
196
+ // CONFIG-DRIVEN SMOKE TESTS
197
+ // ============================================================================
198
+
199
+ async function runConfigTests(config, options) {
200
+ const baseUrl = options.url || config.baseUrl
201
+ const frontendUrl = config.frontendUrl
202
+ const timeout = config.timeout || 10000
203
+ const results = []
204
+
205
+ if (!baseUrl) {
206
+ return { passed: false, results: [], error: 'No baseUrl configured' }
207
+ }
208
+
209
+ const checks = config.checks || {}
210
+
211
+ // 1. Health check
212
+ if (checks.health !== false) {
213
+ const r = await httpCheck(`${baseUrl}/api/health`, { timeout })
214
+ results.push({ name: 'Health', ...r })
215
+ }
216
+
217
+ // 2. Deep health check
218
+ if (checks.healthDeep) {
219
+ const r = await httpCheck(`${baseUrl}/api/health?deep=true`, { timeout })
220
+ if (r.passed) {
221
+ try {
222
+ const data = JSON.parse(r.body)
223
+ if (data.status === 'degraded') {
224
+ r.passed = false
225
+ r.error = `Degraded: ${JSON.stringify(data.checks)}`
226
+ }
227
+ } catch { /* non-JSON is fine for basic health */ }
228
+ }
229
+ results.push({ name: 'Deep Health', ...r })
230
+ }
231
+
232
+ // 3. Public endpoints
233
+ if (checks.publicEndpoints) {
234
+ for (const ep of checks.publicEndpoints) {
235
+ const expectedStatus = ep.expect?.status || 200
236
+ const r = await httpCheck(`${baseUrl}${ep.path}`, {
237
+ timeout,
238
+ expectStatus: expectedStatus,
239
+ headers: mergeHeaders(config.headers, ep.headers),
240
+ })
241
+ results.push({
242
+ name: ep.description || `Public: ${ep.path}`,
243
+ ...r,
244
+ })
245
+ }
246
+ }
247
+
248
+ // 4. Auth endpoints (should return 401 without token)
249
+ if (checks.authEndpoints) {
250
+ for (const ep of checks.authEndpoints) {
251
+ const expectedStatus = ep.expect?.status || 401
252
+ const r = await httpCheck(`${baseUrl}${ep.path}`, {
253
+ timeout,
254
+ expectStatus: expectedStatus,
255
+ headers: mergeHeaders(config.headers, ep.headers),
256
+ })
257
+ results.push({
258
+ name: ep.description || `Auth wall: ${ep.path}`,
259
+ ...r,
260
+ })
261
+ }
262
+ }
263
+
264
+ // 5. Frontend pages
265
+ if (checks.frontendPages && frontendUrl) {
266
+ for (const ep of checks.frontendPages) {
267
+ const expectedStatus = ep.expect?.status || 200
268
+ const r = await httpCheck(`${frontendUrl}${ep.path}`, {
269
+ timeout,
270
+ expectStatus: expectedStatus,
271
+ })
272
+ if (r.passed && r.bodyLength < 100) {
273
+ r.passed = false
274
+ r.error = `Body too small (${r.bodyLength} bytes) — possibly empty page`
275
+ }
276
+ results.push({
277
+ name: ep.description || `Frontend: ${ep.path}`,
278
+ ...r,
279
+ })
280
+ }
281
+ }
282
+
283
+ const allPassed = results.every(r => r.passed)
284
+ return { passed: allPassed, results }
285
+ }
286
+
287
+ // ============================================================================
288
+ // OUTPUT
289
+ // ============================================================================
290
+
291
+ function printResults(configResults, discoverResults, options) {
292
+ if (options.json) {
293
+ console.log(JSON.stringify({ config: configResults, discover: discoverResults }, null, 2))
294
+ return
295
+ }
296
+
297
+ // Config results
298
+ if (configResults && configResults.results.length > 0) {
299
+ console.log(chalk.blue.bold('\n Config Smoke Tests\n'))
300
+ printCheckResults(configResults.results, options)
301
+ }
302
+
303
+ // Discover results
304
+ if (discoverResults && discoverResults.results.length > 0) {
305
+ const stats = []
306
+ if (discoverResults.totalRoutes) stats.push(`${discoverResults.totalRoutes} total routes`)
307
+ if (discoverResults.discoveredCount) stats.push(`${discoverResults.discoveredCount} testable`)
308
+ if (discoverResults.skippedParamRoutes) stats.push(`${discoverResults.skippedParamRoutes} skipped (need :id)`)
309
+ if (discoverResults.skippedNonGet) stats.push(`${discoverResults.skippedNonGet} skipped (POST/PUT/DELETE)`)
310
+
311
+ console.log(chalk.blue.bold('\n Auto-Discovered Route Tests'))
312
+ if (stats.length > 0) {
313
+ console.log(chalk.gray(` ${stats.join(' | ')}`))
314
+ }
315
+ console.log()
316
+
317
+ // Group by auth level
318
+ const byAuth = {}
319
+ for (const r of discoverResults.results) {
320
+ const auth = r.auth || 'other'
321
+ if (!byAuth[auth]) byAuth[auth] = []
322
+ byAuth[auth].push(r)
323
+ }
324
+
325
+ for (const [auth, results] of Object.entries(byAuth)) {
326
+ const passed = results.filter(r => r.passed).length
327
+ const failed = results.filter(r => !r.passed).length
328
+ const authLabel = auth.toUpperCase()
329
+
330
+ if (failed === 0) {
331
+ console.log(chalk.green(` ✓ ${authLabel}: ${passed}/${results.length} passed`))
332
+ } else {
333
+ console.log(chalk.red(` ✗ ${authLabel}: ${failed}/${results.length} failed`))
334
+ // Show only failed ones
335
+ for (const r of results.filter(r => !r.passed)) {
336
+ console.log(chalk.red(` ✗ ${r.name} [${r.status}] ${r.error || ''}`))
337
+ if (options.ci) {
338
+ console.log(`::error title=Smoke Test Failed::${r.name}: ${r.error || `HTTP ${r.status}`}`)
339
+ }
340
+ }
341
+ }
342
+ }
343
+ }
344
+
345
+ // Summary
346
+ const allResults = [
347
+ ...(configResults?.results || []),
348
+ ...(discoverResults?.results || []),
349
+ ]
350
+ const totalPassed = allResults.filter(r => r.passed).length
351
+ const totalFailed = allResults.filter(r => !r.passed).length
352
+ const total = allResults.length
353
+
354
+ console.log()
355
+ if (totalFailed === 0) {
356
+ console.log(chalk.green.bold(` All ${total} checks passed\n`))
357
+ } else {
358
+ console.log(chalk.red.bold(` ${totalFailed}/${total} checks failed\n`))
359
+ }
360
+ }
361
+
362
+ function printCheckResults(results, options) {
363
+ for (const r of results) {
364
+ const icon = r.passed ? chalk.green('✓') : chalk.red('✗')
365
+ const duration = chalk.gray(`${r.duration}ms`)
366
+ const status = r.status ? chalk.gray(`[${r.status}]`) : ''
367
+
368
+ console.log(` ${icon} ${r.name} ${status} ${duration}`)
369
+
370
+ if (!r.passed && r.error) {
371
+ console.log(chalk.red(` ${r.error}`))
372
+ }
373
+
374
+ if (r.skipped) {
375
+ console.log(chalk.yellow(` ${r.error}`))
376
+ }
377
+
378
+ if (options.ci && !r.passed && !r.skipped) {
379
+ console.log(`::error title=Smoke Test Failed::${r.name}: ${r.error || `HTTP ${r.status}`}`)
380
+ }
381
+
382
+ if (r.passed && r.duration > 3000) {
383
+ console.log(chalk.yellow(` ⚠ Slow response (${r.duration}ms)`))
384
+ }
385
+ }
386
+ }
387
+
388
+ async function sendTelegramNotification(allResults, config) {
389
+ const ralphUrl = process.env.RALPH_MANAGER_API || 'http://localhost:3005'
390
+
391
+ const failedChecks = allResults.filter(r => !r.passed)
392
+ const lines = [
393
+ `🔥 *Smoke Test FAILED*`,
394
+ '',
395
+ `Project: ${config.baseUrl}`,
396
+ `Failed: ${failedChecks.length}/${allResults.length}`,
397
+ '',
398
+ ...failedChecks.slice(0, 10).map(r => `❌ ${r.name}: ${r.error || `HTTP ${r.status}`}`),
399
+ ...(failedChecks.length > 10 ? [`... and ${failedChecks.length - 10} more`] : []),
400
+ ]
401
+
402
+ try {
403
+ await fetch(`${ralphUrl}/api/internal/telegram/send`, {
404
+ method: 'POST',
405
+ headers: { 'Content-Type': 'application/json' },
406
+ body: JSON.stringify({ message: lines.join('\n') }),
407
+ signal: AbortSignal.timeout(5000),
408
+ })
409
+ } catch {
410
+ console.log(chalk.yellow(' Could not send Telegram notification'))
411
+ }
412
+ }
413
+
414
+ // ============================================================================
415
+ // MAIN
416
+ // ============================================================================
417
+
418
+ program
419
+ .name('tetra-smoke')
420
+ .description('Config-driven + auto-discover post-deploy smoke tester')
421
+ .version('1.0.0')
422
+ .option('--url <url>', 'Override backend URL')
423
+ .option('--frontend-url <url>', 'Override frontend URL')
424
+ .option('--discover-only', 'Only run auto-discovered route tests')
425
+ .option('--no-discover', 'Skip auto-discover, only run config tests')
426
+ .option('--json', 'JSON output')
427
+ .option('--ci', 'GitHub Actions annotations')
428
+ .option('--notify', 'Send Telegram notification on failure')
429
+ .option('--post-deploy', 'Wait + retry mode (waits 30s, retries 3x)')
430
+ .option('--timeout <ms>', 'Request timeout in ms', '10000')
431
+ .action(async (options) => {
432
+ try {
433
+ const projectRoot = process.cwd()
434
+ const smokeConfig = loadSmokeConfig(projectRoot)
435
+
436
+ if (!smokeConfig && !options.url) {
437
+ console.error(chalk.red('\n No smoke config found in .tetra-quality.json and no --url provided.\n'))
438
+ console.log(chalk.gray(' Add a "smoke" section to .tetra-quality.json:'))
439
+ console.log(chalk.gray(' {'))
440
+ console.log(chalk.gray(' "smoke": {'))
441
+ console.log(chalk.gray(' "baseUrl": "https://your-app.railway.app",'))
442
+ console.log(chalk.gray(' "checks": { "health": true, "healthDeep": true }'))
443
+ console.log(chalk.gray(' }'))
444
+ console.log(chalk.gray(' }\n'))
445
+ process.exit(1)
446
+ }
447
+
448
+ const config = {
449
+ baseUrl: options.url || smokeConfig?.baseUrl,
450
+ frontendUrl: options.frontendUrl || smokeConfig?.frontendUrl,
451
+ timeout: parseInt(options.timeout, 10) || smokeConfig?.timeout || 10000,
452
+ checks: smokeConfig?.checks || { health: true },
453
+ headers: smokeConfig?.headers || {},
454
+ notify: smokeConfig?.notify || {},
455
+ }
456
+
457
+ const runTests = async () => {
458
+ let configResults = null
459
+ let discoverResults = null
460
+
461
+ // Run config tests (unless --discover-only)
462
+ if (!options.discoverOnly) {
463
+ configResults = await runConfigTests(config, options)
464
+ }
465
+
466
+ // Run auto-discover tests (unless --no-discover or explicitly disabled)
467
+ if (options.discover !== false) {
468
+ discoverResults = await discoverAndTestRoutes(config.baseUrl, {
469
+ timeout: config.timeout,
470
+ })
471
+ }
472
+
473
+ return { configResults, discoverResults }
474
+ }
475
+
476
+ // Post-deploy mode: wait then retry
477
+ if (options.postDeploy) {
478
+ console.log(chalk.gray(' Post-deploy mode: waiting 30s for deploy to stabilize...\n'))
479
+ await new Promise(r => setTimeout(r, 30000))
480
+
481
+ let lastResult = null
482
+ for (let attempt = 1; attempt <= 3; attempt++) {
483
+ console.log(chalk.gray(` Attempt ${attempt}/3...`))
484
+ lastResult = await runTests()
485
+
486
+ const allPassed = (lastResult.configResults?.passed !== false) &&
487
+ (lastResult.discoverResults?.passed !== false)
488
+ if (allPassed) break
489
+
490
+ if (attempt < 3) {
491
+ console.log(chalk.yellow(` Attempt ${attempt} failed, retrying in 15s...`))
492
+ await new Promise(r => setTimeout(r, 15000))
493
+ }
494
+ }
495
+
496
+ printResults(lastResult.configResults, lastResult.discoverResults, options)
497
+
498
+ const allResults = [
499
+ ...(lastResult.configResults?.results || []),
500
+ ...(lastResult.discoverResults?.results || []),
501
+ ]
502
+ const allPassed = allResults.every(r => r.passed || r.skipped)
503
+
504
+ if (!allPassed && (options.notify || config.notify?.onFailure)) {
505
+ await sendTelegramNotification(allResults, config)
506
+ }
507
+
508
+ process.exit(allPassed ? 0 : 1)
509
+ }
510
+
511
+ // Normal mode
512
+ const { configResults, discoverResults } = await runTests()
513
+ printResults(configResults, discoverResults, options)
514
+
515
+ const allResults = [
516
+ ...(configResults?.results || []),
517
+ ...(discoverResults?.results || []),
518
+ ]
519
+ const allPassed = allResults.every(r => r.passed || r.skipped)
520
+
521
+ if (!allPassed && (options.notify || config.notify?.onFailure)) {
522
+ await sendTelegramNotification(allResults, config)
523
+ }
524
+
525
+ process.exit(allPassed ? 0 : 1)
526
+ } catch (err) {
527
+ console.error(chalk.red(`\n ERROR: ${err.message}\n`))
528
+ process.exit(1)
529
+ }
530
+ })
531
+
532
+ program.parse()
@@ -38,3 +38,4 @@ export { check as checkLicenseAudit } from './license-audit.js'
38
38
  export { check as checkSast } from './sast.js'
39
39
  export { check as checkBundleSize } from './bundle-size.js'
40
40
  export { check as checkSecurityLayers } from './security-layers.js'
41
+ export { check as checkSmokeReadiness } from './smoke-readiness.js'
@@ -34,6 +34,7 @@ import { check as checkLicenseAudit } from './license-audit.js'
34
34
  import { check as checkSast } from './sast.js'
35
35
  import { check as checkBundleSize } from './bundle-size.js'
36
36
  import { check as checkSecurityLayers } from './security-layers.js'
37
+ import { check as checkSmokeReadiness } from './smoke-readiness.js'
37
38
  import { calculateHealthStatus } from './types.js'
38
39
 
39
40
  /**
@@ -76,7 +77,8 @@ export async function scanProjectHealth(projectPath, projectName, options = {})
76
77
  checkLicenseAudit(projectPath),
77
78
  checkSast(projectPath),
78
79
  checkBundleSize(projectPath),
79
- checkSecurityLayers(projectPath)
80
+ checkSecurityLayers(projectPath),
81
+ checkSmokeReadiness(projectPath)
80
82
  ])
81
83
 
82
84
  const totalScore = checks.reduce((sum, c) => sum + c.score, 0)
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Health Check: Smoke Test Readiness
3
+ *
4
+ * Checks if a project has proper smoke test and E2E infrastructure:
5
+ * - Smoke config in .tetra-quality.json
6
+ * - Health endpoint configured
7
+ * - E2E test files exist and are runnable
8
+ * - Post-deploy workflow exists
9
+ *
10
+ * Score: up to 5 points
11
+ */
12
+
13
+ import { existsSync, readFileSync, readdirSync } from 'fs'
14
+ import { join } from 'path'
15
+ import { createCheck } from './types.js'
16
+
17
+ export async function check(projectPath) {
18
+ const result = createCheck('smoke-readiness', 5, {
19
+ hasSmokeConfig: false,
20
+ hasHealthEndpoint: false,
21
+ hasE2ETests: false,
22
+ hasPostDeployWorkflow: false,
23
+ hasSmokeEndpoints: false,
24
+ smokeEndpointCount: 0,
25
+ e2eTestCount: 0,
26
+ })
27
+
28
+ // 1. Check for smoke config in .tetra-quality.json (+1 point)
29
+ const configFile = join(projectPath, '.tetra-quality.json')
30
+ if (existsSync(configFile)) {
31
+ try {
32
+ const config = JSON.parse(readFileSync(configFile, 'utf-8'))
33
+ if (config.smoke) {
34
+ result.details.hasSmokeConfig = true
35
+ result.score += 1
36
+
37
+ // Check if smoke endpoints are configured
38
+ const checks = config.smoke.checks || {}
39
+ const endpointCount =
40
+ (checks.publicEndpoints?.length || 0) +
41
+ (checks.authEndpoints?.length || 0) +
42
+ (checks.frontendPages?.length || 0)
43
+
44
+ if (endpointCount > 0) {
45
+ result.details.hasSmokeEndpoints = true
46
+ result.details.smokeEndpointCount = endpointCount
47
+ result.score += 1
48
+ }
49
+ }
50
+ } catch { /* invalid JSON */ }
51
+ }
52
+
53
+ // 2. Check for health endpoint in createApp config (+1 point)
54
+ // Look for createApp usage or /api/health route
55
+ const healthIndicators = [
56
+ 'createApp', // tetra-core createApp includes /api/health
57
+ '/api/health', // explicit health endpoint
58
+ 'healthcheck', // Railway/Docker healthcheck
59
+ ]
60
+
61
+ let hasHealthEndpoint = false
62
+ const srcDirs = ['src', 'backend/src']
63
+ for (const srcDir of srcDirs) {
64
+ const srcPath = join(projectPath, srcDir)
65
+ if (!existsSync(srcPath)) continue
66
+
67
+ try {
68
+ const scanForHealth = (dir) => {
69
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
70
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
71
+ const fullPath = join(dir, entry.name)
72
+ if (entry.isDirectory()) {
73
+ scanForHealth(fullPath)
74
+ } else if (entry.isFile() && /\.(ts|js)$/.test(entry.name)) {
75
+ try {
76
+ const content = readFileSync(fullPath, 'utf-8')
77
+ if (healthIndicators.some(h => content.includes(h))) {
78
+ hasHealthEndpoint = true
79
+ }
80
+ } catch { /* skip */ }
81
+ }
82
+ }
83
+ }
84
+ scanForHealth(srcPath)
85
+ } catch { /* skip */ }
86
+ }
87
+
88
+ if (hasHealthEndpoint) {
89
+ result.details.hasHealthEndpoint = true
90
+ result.score += 1
91
+ }
92
+
93
+ // 3. Check for E2E test files (+1 point)
94
+ const e2eDirs = ['e2e', 'tests/e2e', 'test/e2e', 'cypress/e2e', 'playwright']
95
+ let e2eCount = 0
96
+
97
+ for (const dir of e2eDirs) {
98
+ const dirPath = join(projectPath, dir)
99
+ if (!existsSync(dirPath)) continue
100
+
101
+ try {
102
+ const countE2E = (path) => {
103
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
104
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
105
+ countE2E(join(path, entry.name))
106
+ } else if (entry.isFile() && /\.(test|spec)\.(ts|tsx|js|jsx)$/.test(entry.name)) {
107
+ e2eCount++
108
+ }
109
+ }
110
+ }
111
+ countE2E(dirPath)
112
+ } catch { /* skip */ }
113
+ }
114
+
115
+ if (e2eCount > 0) {
116
+ result.details.hasE2ETests = true
117
+ result.details.e2eTestCount = e2eCount
118
+ result.score += 1
119
+ }
120
+
121
+ // 4. Check for post-deploy or smoke workflow (+1 point)
122
+ const workflowDir = join(projectPath, '.github/workflows')
123
+ if (existsSync(workflowDir)) {
124
+ try {
125
+ const workflows = readdirSync(workflowDir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml'))
126
+ for (const wf of workflows) {
127
+ const content = readFileSync(join(workflowDir, wf), 'utf-8').toLowerCase()
128
+ if (content.includes('tetra-smoke') || content.includes('smoke-tests') || content.includes('post-deploy')) {
129
+ result.details.hasPostDeployWorkflow = true
130
+ result.details.workflowFile = wf
131
+ result.score += 1
132
+ break
133
+ }
134
+ }
135
+ } catch { /* skip */ }
136
+ }
137
+
138
+ // Status
139
+ result.score = Math.min(result.score, result.maxScore)
140
+
141
+ if (result.score === 0) {
142
+ result.status = 'error'
143
+ result.details.message = 'No smoke test infrastructure — add smoke config to .tetra-quality.json'
144
+ } else if (result.score < 3) {
145
+ result.status = 'warning'
146
+ result.details.message = 'Incomplete smoke test coverage'
147
+ }
148
+
149
+ return result
150
+ }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  /**
8
- * @typedef {'plugins'|'mcps'|'git'|'tests'|'secrets'|'quality-toolkit'|'naming-conventions'|'rls-audit'|'rpc-param-mismatch'|'typescript-strict'|'prettier'|'coverage-thresholds'|'eslint-security'|'dependency-cruiser'|'conventional-commits'|'knip'|'dependency-automation'|'license-audit'|'sast'|'bundle-size'|'gitignore'|'repo-visibility'|'vincifox-widget'|'stella-integration'|'claude-md'|'doppler-compliance'|'infrastructure-yml'|'file-organization'|'security-layers'} HealthCheckType
8
+ * @typedef {'plugins'|'mcps'|'git'|'tests'|'secrets'|'quality-toolkit'|'naming-conventions'|'rls-audit'|'rpc-param-mismatch'|'typescript-strict'|'prettier'|'coverage-thresholds'|'eslint-security'|'dependency-cruiser'|'conventional-commits'|'knip'|'dependency-automation'|'license-audit'|'sast'|'bundle-size'|'gitignore'|'repo-visibility'|'vincifox-widget'|'stella-integration'|'claude-md'|'doppler-compliance'|'infrastructure-yml'|'file-organization'|'security-layers'|'smoke-readiness'} HealthCheckType
9
9
  *
10
10
  * @typedef {'ok'|'warning'|'error'} HealthStatus
11
11
  *
@@ -29,7 +29,7 @@ const DUPLICATE_PATTERNS = [
29
29
  {
30
30
  pattern: /const QUESTIONS_DIR\s*=\s*join\(tmpdir\(\)/,
31
31
  label: 'Local QUESTIONS_DIR (telegram question store)',
32
- allowedIn: ['stella/src/telegram.ts']
32
+ allowedIn: ['stella/src/telegram.ts', 'backend/src/features/telegram/']
33
33
  },
34
34
  {
35
35
  pattern: /function detectWorkspaceRef\(\)/,
@@ -49,7 +49,7 @@ const DUPLICATE_PATTERNS = [
49
49
  {
50
50
  pattern: /function splitMessage\(text:\s*string/,
51
51
  label: 'Local splitMessage helper (for telegram)',
52
- allowedIn: ['stella/src/telegram.ts', 'tools/helpers.ts']
52
+ allowedIn: ['stella/src/telegram.ts', 'tools/helpers.ts', 'backend/src/features/telegram/']
53
53
  },
54
54
  {
55
55
  pattern: /function playMacAlert\(\)/,
@@ -63,6 +63,15 @@ const ALLOWED_FILES = [
63
63
  // Domain middleware (sets RLS session vars — needs direct client)
64
64
  /middleware\/domainOrganizationMiddleware\.ts$/,
65
65
 
66
+ // Auth routes that only use Supabase Auth API (not DB queries)
67
+ /routes\/auth\.ts$/,
68
+
69
+ // WebSocket auth verification (only uses auth.getUser for token validation)
70
+ /services\/terminalWebSocket\.ts$/,
71
+
72
+ // Frontend Supabase client (Vite apps — client-side auth only, no Tetra backend)
73
+ /frontend\/src\/lib\/supabase\.ts$/,
74
+
66
75
  // Scripts (not production code)
67
76
  /scripts\//,
68
77
  ]
@@ -34,10 +34,13 @@ export async function run(config, projectRoot) {
34
34
  summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
35
35
  }
36
36
 
37
- // Get all source files
37
+ // Get all source files (always exclude node_modules, even nested ones)
38
38
  const files = await glob('**/*.{ts,tsx,js,jsx,json}', {
39
39
  cwd: projectRoot,
40
- ignore: config.ignore
40
+ ignore: [
41
+ '**/node_modules/**',
42
+ ...config.ignore
43
+ ]
41
44
  })
42
45
 
43
46
  for (const file of files) {
package/lib/runner.js CHANGED
@@ -47,6 +47,7 @@ import { check as checkBundleSize } from './checks/health/bundle-size.js'
47
47
  import { check as checkSast } from './checks/health/sast.js'
48
48
  import { check as checkLicenseAudit } from './checks/health/license-audit.js'
49
49
  import { check as checkSecurityLayers } from './checks/health/security-layers.js'
50
+ import { check as checkSmokeReadiness } from './checks/health/smoke-readiness.js'
50
51
 
51
52
  /**
52
53
  * Adapt a health check (score-based) to the runner format (meta + run).
@@ -116,7 +117,8 @@ const ALL_CHECKS = {
116
117
  adaptHealthCheck('bundle-size', 'Bundle Size Monitoring', 'low', checkBundleSize),
117
118
  adaptHealthCheck('sast', 'Static Application Security Testing', 'medium', checkSast),
118
119
  adaptHealthCheck('license-audit', 'License Compliance', 'low', checkLicenseAudit),
119
- adaptHealthCheck('security-layers', 'Security Layer Coverage', 'high', checkSecurityLayers)
120
+ adaptHealthCheck('security-layers', 'Security Layer Coverage', 'high', checkSecurityLayers),
121
+ adaptHealthCheck('smoke-readiness', 'Smoke Test Readiness', 'medium', checkSmokeReadiness)
120
122
  ],
121
123
  codeQuality: [
122
124
  apiResponseFormat,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulbatical/tetra-dev-toolkit",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },
@@ -33,7 +33,8 @@
33
33
  "tetra-migration-lint": "./bin/tetra-migration-lint.js",
34
34
  "tetra-db-push": "./bin/tetra-db-push.js",
35
35
  "tetra-check-peers": "./bin/tetra-check-peers.js",
36
- "tetra-security-gate": "./bin/tetra-security-gate.js"
36
+ "tetra-security-gate": "./bin/tetra-security-gate.js",
37
+ "tetra-smoke": "./bin/tetra-smoke.js"
37
38
  },
38
39
  "files": [
39
40
  "bin/",