@soulbatical/tetra-dev-toolkit 1.18.1 → 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 +76 -0
- package/bin/tetra-check-peers.js +22 -1
- package/bin/tetra-security-gate.js +293 -0
- package/bin/tetra-setup.js +172 -2
- package/bin/tetra-smoke.js +532 -0
- package/lib/checks/health/index.js +1 -0
- package/lib/checks/health/scanner.js +3 -1
- package/lib/checks/health/smoke-readiness.js +150 -0
- package/lib/checks/health/types.js +1 -1
- package/lib/checks/hygiene/stella-compliance.js +2 -2
- package/lib/checks/security/config-rls-alignment.js +10 -4
- package/lib/checks/security/direct-supabase-client.js +9 -0
- package/lib/checks/security/hardcoded-secrets.js +5 -2
- package/lib/checks/security/rls-live-audit.js +97 -6
- package/lib/runner.js +46 -8
- package/package.json +4 -2
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
|
package/bin/tetra-check-peers.js
CHANGED
|
@@ -21,7 +21,28 @@ import { execSync } from 'child_process'
|
|
|
21
21
|
|
|
22
22
|
// ─── Config ──────────────────────────────────────────────
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// Resolve paths dynamically:
|
|
25
|
+
// 1. TETRA_PROJECTS_ROOT env var (explicit override)
|
|
26
|
+
// 2. Detect from tetra repo: this script lives in tetra/packages/dev-toolkit/bin/
|
|
27
|
+
// so tetra root = 4 levels up, and projects root = 5 levels up (sibling dirs)
|
|
28
|
+
// 3. Fallback: ~/projecten
|
|
29
|
+
function resolveProjectsRoot() {
|
|
30
|
+
if (process.env.TETRA_PROJECTS_ROOT) return process.env.TETRA_PROJECTS_ROOT
|
|
31
|
+
|
|
32
|
+
// This file: <projects>/<tetra>/packages/dev-toolkit/bin/tetra-check-peers.js
|
|
33
|
+
const scriptDir = dirname(new URL(import.meta.url).pathname)
|
|
34
|
+
const tetraRoot = join(scriptDir, '..', '..', '..') // → tetra/
|
|
35
|
+
const possibleProjectsRoot = join(tetraRoot, '..') // → projects/
|
|
36
|
+
|
|
37
|
+
// Verify: does this directory contain a 'tetra' subdirectory?
|
|
38
|
+
if (existsSync(join(possibleProjectsRoot, 'tetra', 'packages'))) {
|
|
39
|
+
return possibleProjectsRoot
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return join(process.env.HOME || '~', 'projecten')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const PROJECTS_ROOT = resolveProjectsRoot()
|
|
25
46
|
const TETRA_ROOT = join(PROJECTS_ROOT, 'tetra', 'packages')
|
|
26
47
|
|
|
27
48
|
// Tetra packages that have peerDependencies
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tetra Security Gate — AI-powered pre-push security review
|
|
5
|
+
*
|
|
6
|
+
* Detects security-sensitive file changes in the current git diff,
|
|
7
|
+
* submits them to ralph-manager's security gate agent for review,
|
|
8
|
+
* and blocks the push if the agent denies the changes.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* tetra-security-gate # Auto-detect ralph-manager URL
|
|
12
|
+
* tetra-security-gate --url <url> # Explicit ralph-manager URL
|
|
13
|
+
* tetra-security-gate --timeout 120 # Custom timeout (seconds)
|
|
14
|
+
* tetra-security-gate --dry-run # Show what would be sent, don't block
|
|
15
|
+
*
|
|
16
|
+
* Exit codes:
|
|
17
|
+
* 0 = approved (or no security files changed)
|
|
18
|
+
* 1 = denied (security violation found)
|
|
19
|
+
* 0 = ralph-manager offline (graceful fallback, doesn't block)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { program } from 'commander'
|
|
23
|
+
import { execSync } from 'child_process'
|
|
24
|
+
import chalk from 'chalk'
|
|
25
|
+
|
|
26
|
+
// Security-sensitive file patterns
|
|
27
|
+
const SECURITY_PATTERNS = [
|
|
28
|
+
/supabase\/migrations\/.*\.sql$/i,
|
|
29
|
+
/\.rls\./i,
|
|
30
|
+
/rls[-_]?policy/i,
|
|
31
|
+
/auth[-_]?config/i,
|
|
32
|
+
/middleware\/auth/i,
|
|
33
|
+
/middleware\/security/i,
|
|
34
|
+
/security\.ts$/i,
|
|
35
|
+
/security\.js$/i,
|
|
36
|
+
/\.env$/,
|
|
37
|
+
/\.env\.\w+$/,
|
|
38
|
+
/doppler\.yaml$/,
|
|
39
|
+
/auth-config/i,
|
|
40
|
+
/permissions/i,
|
|
41
|
+
/checks\/security\//i,
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
function isSecurityFile(file) {
|
|
45
|
+
return SECURITY_PATTERNS.some(p => p.test(file))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getChangedFiles() {
|
|
49
|
+
try {
|
|
50
|
+
// Files changed between HEAD and upstream (what's being pushed)
|
|
51
|
+
const upstream = execSync('git rev-parse --abbrev-ref @{upstream} 2>/dev/null', { encoding: 'utf8' }).trim()
|
|
52
|
+
if (upstream) {
|
|
53
|
+
return execSync(`git diff --name-only ${upstream}...HEAD`, { encoding: 'utf8' }).trim().split('\n').filter(Boolean)
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// No upstream — compare against origin/main or origin/master
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const base of ['origin/main', 'origin/master']) {
|
|
60
|
+
try {
|
|
61
|
+
return execSync(`git diff --name-only ${base}...HEAD`, { encoding: 'utf8' }).trim().split('\n').filter(Boolean)
|
|
62
|
+
} catch { /* try next */ }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Fallback: last commit
|
|
66
|
+
try {
|
|
67
|
+
return execSync('git diff --name-only HEAD~1', { encoding: 'utf8' }).trim().split('\n').filter(Boolean)
|
|
68
|
+
} catch {
|
|
69
|
+
return []
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getDiff(files) {
|
|
74
|
+
try {
|
|
75
|
+
const upstream = execSync('git rev-parse --abbrev-ref @{upstream} 2>/dev/null', { encoding: 'utf8' }).trim()
|
|
76
|
+
if (upstream) {
|
|
77
|
+
return execSync(`git diff ${upstream}...HEAD -- ${files.join(' ')}`, { encoding: 'utf8', maxBuffer: 1024 * 1024 })
|
|
78
|
+
}
|
|
79
|
+
} catch { /* fallback */ }
|
|
80
|
+
|
|
81
|
+
for (const base of ['origin/main', 'origin/master']) {
|
|
82
|
+
try {
|
|
83
|
+
return execSync(`git diff ${base}...HEAD -- ${files.join(' ')}`, { encoding: 'utf8', maxBuffer: 1024 * 1024 })
|
|
84
|
+
} catch { /* try next */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
return execSync(`git diff HEAD~1 -- ${files.join(' ')}`, { encoding: 'utf8', maxBuffer: 1024 * 1024 })
|
|
89
|
+
} catch {
|
|
90
|
+
return ''
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getProjectName() {
|
|
95
|
+
try {
|
|
96
|
+
const remoteUrl = execSync('git remote get-url origin 2>/dev/null', { encoding: 'utf8' }).trim()
|
|
97
|
+
const match = remoteUrl.match(/\/([^/]+?)(?:\.git)?$/)
|
|
98
|
+
if (match) return match[1]
|
|
99
|
+
} catch { /* fallback */ }
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
return execSync('basename "$(git rev-parse --show-toplevel)"', { encoding: 'utf8' }).trim()
|
|
103
|
+
} catch {
|
|
104
|
+
return 'unknown'
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getRalphManagerUrl() {
|
|
109
|
+
// Check .ralph/ports.json first
|
|
110
|
+
try {
|
|
111
|
+
const portsJson = execSync('cat .ralph/ports.json 2>/dev/null', { encoding: 'utf8' })
|
|
112
|
+
const ports = JSON.parse(portsJson)
|
|
113
|
+
if (ports.api_url) return ports.api_url
|
|
114
|
+
} catch { /* fallback */ }
|
|
115
|
+
|
|
116
|
+
// Check RALPH_MANAGER_API env
|
|
117
|
+
if (process.env.RALPH_MANAGER_API) return process.env.RALPH_MANAGER_API
|
|
118
|
+
|
|
119
|
+
// Default
|
|
120
|
+
return 'http://localhost:3005'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function pollForVerdict(baseUrl, gateId, timeoutSeconds) {
|
|
124
|
+
const deadline = Date.now() + timeoutSeconds * 1000
|
|
125
|
+
const pollInterval = 3000 // 3 seconds
|
|
126
|
+
|
|
127
|
+
while (Date.now() < deadline) {
|
|
128
|
+
try {
|
|
129
|
+
const resp = await fetch(`${baseUrl}/api/internal/security-gate/${gateId}`)
|
|
130
|
+
if (!resp.ok) {
|
|
131
|
+
console.error(chalk.yellow(` Poll failed: HTTP ${resp.status}`))
|
|
132
|
+
break
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { data } = await resp.json()
|
|
136
|
+
|
|
137
|
+
if (data.status === 'approved') {
|
|
138
|
+
return { status: 'approved', reason: data.reason, findings: data.findings }
|
|
139
|
+
}
|
|
140
|
+
if (data.status === 'denied') {
|
|
141
|
+
return { status: 'denied', reason: data.reason, findings: data.findings }
|
|
142
|
+
}
|
|
143
|
+
if (data.status === 'error') {
|
|
144
|
+
return { status: 'error', reason: data.reason }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Still pending — wait and retry
|
|
148
|
+
await new Promise(r => setTimeout(r, pollInterval))
|
|
149
|
+
} catch {
|
|
150
|
+
// Network error — ralph-manager might be restarting
|
|
151
|
+
await new Promise(r => setTimeout(r, pollInterval))
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { status: 'timeout', reason: `No verdict within ${timeoutSeconds}s` }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
program
|
|
159
|
+
.name('tetra-security-gate')
|
|
160
|
+
.description('AI-powered pre-push security gate — reviews RLS/auth/security changes')
|
|
161
|
+
.version('1.0.0')
|
|
162
|
+
.option('--url <url>', 'Ralph Manager URL (default: auto-detect)')
|
|
163
|
+
.option('--timeout <seconds>', 'Max wait time for agent verdict', '180')
|
|
164
|
+
.option('--dry-run', 'Show what would be submitted, do not block')
|
|
165
|
+
.action(async (options) => {
|
|
166
|
+
try {
|
|
167
|
+
console.log(chalk.blue.bold('\n Tetra Security Gate\n'))
|
|
168
|
+
|
|
169
|
+
// Step 1: Detect changed files
|
|
170
|
+
const allFiles = getChangedFiles()
|
|
171
|
+
const securityFiles = allFiles.filter(isSecurityFile)
|
|
172
|
+
|
|
173
|
+
if (securityFiles.length === 0) {
|
|
174
|
+
console.log(chalk.green(' No security-sensitive files changed — skipping gate.'))
|
|
175
|
+
console.log(chalk.gray(` (checked ${allFiles.length} files)\n`))
|
|
176
|
+
process.exit(0)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
console.log(chalk.yellow(` ${securityFiles.length} security-sensitive file(s) detected:`))
|
|
180
|
+
for (const f of securityFiles) {
|
|
181
|
+
console.log(chalk.gray(` - ${f}`))
|
|
182
|
+
}
|
|
183
|
+
console.log()
|
|
184
|
+
|
|
185
|
+
// Step 2: Get the diff
|
|
186
|
+
const diff = getDiff(securityFiles)
|
|
187
|
+
if (!diff.trim()) {
|
|
188
|
+
console.log(chalk.green(' No actual diff content — skipping gate.\n'))
|
|
189
|
+
process.exit(0)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const project = getProjectName()
|
|
193
|
+
|
|
194
|
+
if (options.dryRun) {
|
|
195
|
+
console.log(chalk.cyan(' [DRY RUN] Would submit to security gate:'))
|
|
196
|
+
console.log(chalk.gray(` Project: ${project}`))
|
|
197
|
+
console.log(chalk.gray(` Files: ${securityFiles.length}`))
|
|
198
|
+
console.log(chalk.gray(` Diff size: ${diff.length} chars`))
|
|
199
|
+
console.log()
|
|
200
|
+
process.exit(0)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Step 3: Submit to ralph-manager
|
|
204
|
+
const baseUrl = options.url || getRalphManagerUrl()
|
|
205
|
+
const timeout = parseInt(options.timeout, 10)
|
|
206
|
+
|
|
207
|
+
console.log(chalk.gray(` Submitting to ${baseUrl}...`))
|
|
208
|
+
|
|
209
|
+
let gateId
|
|
210
|
+
try {
|
|
211
|
+
const resp = await fetch(`${baseUrl}/api/internal/security-gate`, {
|
|
212
|
+
method: 'POST',
|
|
213
|
+
headers: { 'Content-Type': 'application/json' },
|
|
214
|
+
body: JSON.stringify({ project, files_changed: securityFiles, diff }),
|
|
215
|
+
signal: AbortSignal.timeout(10_000),
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
if (!resp.ok) {
|
|
219
|
+
const body = await resp.text()
|
|
220
|
+
console.error(chalk.yellow(` Ralph Manager returned ${resp.status}: ${body}`))
|
|
221
|
+
console.log(chalk.yellow(' Falling back to PASS (ralph-manager error).\n'))
|
|
222
|
+
process.exit(0)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const { data } = await resp.json()
|
|
226
|
+
gateId = data.id
|
|
227
|
+
|
|
228
|
+
// If already resolved (e.g. fallback auto-approve)
|
|
229
|
+
if (data.status === 'approved') {
|
|
230
|
+
console.log(chalk.green(` ${chalk.bold('APPROVED')} (immediate): ${data.reason || 'OK'}\n`))
|
|
231
|
+
process.exit(0)
|
|
232
|
+
}
|
|
233
|
+
if (data.status === 'denied') {
|
|
234
|
+
console.error(chalk.red.bold(`\n PUSH BLOCKED — Security Gate DENIED\n`))
|
|
235
|
+
console.error(chalk.red(` Reason: ${data.reason}\n`))
|
|
236
|
+
process.exit(1)
|
|
237
|
+
}
|
|
238
|
+
} catch (err) {
|
|
239
|
+
// Ralph-manager offline — don't block the push
|
|
240
|
+
console.log(chalk.yellow(` Cannot reach ralph-manager at ${baseUrl}`))
|
|
241
|
+
console.log(chalk.yellow(' Falling back to PASS (offline fallback).\n'))
|
|
242
|
+
process.exit(0)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Step 4: Poll for verdict
|
|
246
|
+
console.log(chalk.gray(` Agent reviewing... (timeout: ${timeout}s)`))
|
|
247
|
+
const result = await pollForVerdict(baseUrl, gateId, timeout)
|
|
248
|
+
|
|
249
|
+
if (result.status === 'approved') {
|
|
250
|
+
console.log(chalk.green.bold(`\n APPROVED: ${result.reason || 'No issues found'}`))
|
|
251
|
+
if (result.findings?.length) {
|
|
252
|
+
for (const f of result.findings) {
|
|
253
|
+
console.log(chalk.yellow(` ⚠ ${f}`))
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
console.log()
|
|
257
|
+
process.exit(0)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (result.status === 'denied') {
|
|
261
|
+
console.error(chalk.red.bold(`\n ════════════════════════════════════════════════════════════`))
|
|
262
|
+
console.error(chalk.red.bold(` PUSH BLOCKED — Security Gate DENIED`))
|
|
263
|
+
console.error(chalk.red.bold(` ════════════════════════════════════════════════════════════`))
|
|
264
|
+
console.error(chalk.red(`\n Reason: ${result.reason}`))
|
|
265
|
+
if (result.findings?.length) {
|
|
266
|
+
console.error(chalk.red(`\n Findings:`))
|
|
267
|
+
for (const f of result.findings) {
|
|
268
|
+
console.error(chalk.red(` - ${f}`))
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
console.error(chalk.yellow(`\n Fix the issues and try again.\n`))
|
|
272
|
+
process.exit(1)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (result.status === 'timeout') {
|
|
276
|
+
console.log(chalk.yellow(` Agent did not respond within ${timeout}s.`))
|
|
277
|
+
console.log(chalk.yellow(' Falling back to PASS (timeout fallback).\n'))
|
|
278
|
+
process.exit(0)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Unknown status — don't block
|
|
282
|
+
console.log(chalk.yellow(` Unexpected verdict status: ${result.status}`))
|
|
283
|
+
console.log(chalk.yellow(' Falling back to PASS.\n'))
|
|
284
|
+
process.exit(0)
|
|
285
|
+
|
|
286
|
+
} catch (err) {
|
|
287
|
+
console.error(chalk.red(`\n ERROR: ${err.message}\n`))
|
|
288
|
+
// Never block on internal errors
|
|
289
|
+
process.exit(0)
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
program.parse()
|
package/bin/tetra-setup.js
CHANGED
|
@@ -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()
|