@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 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
@@ -21,7 +21,28 @@ import { execSync } from 'child_process'
21
21
 
22
22
  // ─── Config ──────────────────────────────────────────────
23
23
 
24
- const PROJECTS_ROOT = join(process.env.HOME || '~', 'projecten')
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()
@@ -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()