@soulbatical/tetra-dev-toolkit 1.5.1 → 1.6.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.
- package/bin/tetra-init.js +623 -0
- package/lib/checks/index.js +1 -0
- package/lib/checks/supabase/rpc-generator-origin.js +217 -0
- package/lib/runner.js +3 -1
- package/package.json +2 -1
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tetra Dev Toolkit - Project Init CLI
|
|
5
|
+
*
|
|
6
|
+
* Initializes a Tetra project with all required config files:
|
|
7
|
+
* - .ralph/ directory (ports.json, INFRASTRUCTURE.yml, MARKETING.yml, config.sh, status.json)
|
|
8
|
+
* - .tetra-quality.json
|
|
9
|
+
* - Verifies doppler.yaml and CLAUDE.md existence
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* tetra-init # Interactive full init
|
|
13
|
+
* tetra-init ralph # Only .ralph/ config files
|
|
14
|
+
* tetra-init check # Verify project completeness (no changes)
|
|
15
|
+
* tetra-init --name myproject # Non-interactive with project name
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { program } from 'commander'
|
|
19
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs'
|
|
20
|
+
import { join, basename } from 'path'
|
|
21
|
+
import { createInterface } from 'readline'
|
|
22
|
+
|
|
23
|
+
const projectRoot = process.cwd()
|
|
24
|
+
|
|
25
|
+
// ─── Helpers ────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function ask(question, defaultValue) {
|
|
28
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
29
|
+
const prompt = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
rl.question(prompt, (answer) => {
|
|
32
|
+
rl.close()
|
|
33
|
+
resolve(answer.trim() || defaultValue || '')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeIfMissing(filePath, content, options = {}) {
|
|
39
|
+
const relativePath = filePath.replace(projectRoot + '/', '')
|
|
40
|
+
if (existsSync(filePath) && !options.force) {
|
|
41
|
+
console.log(` ⏭️ ${relativePath} already exists`)
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
const dir = filePath.substring(0, filePath.lastIndexOf('/'))
|
|
45
|
+
if (!existsSync(dir)) {
|
|
46
|
+
mkdirSync(dir, { recursive: true })
|
|
47
|
+
}
|
|
48
|
+
writeFileSync(filePath, content)
|
|
49
|
+
console.log(` ✅ Created ${relativePath}`)
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function detectProjectName() {
|
|
54
|
+
const packagePath = join(projectRoot, 'package.json')
|
|
55
|
+
if (existsSync(packagePath)) {
|
|
56
|
+
try {
|
|
57
|
+
const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'))
|
|
58
|
+
return pkg.name?.replace(/^@[^/]+\//, '') || basename(projectRoot)
|
|
59
|
+
} catch { /* ignore */ }
|
|
60
|
+
}
|
|
61
|
+
return basename(projectRoot)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function detectPorts() {
|
|
65
|
+
// Try to find ports from doppler.yaml or package.json scripts
|
|
66
|
+
const packagePath = join(projectRoot, 'package.json')
|
|
67
|
+
if (existsSync(packagePath)) {
|
|
68
|
+
try {
|
|
69
|
+
const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'))
|
|
70
|
+
const scripts = pkg.scripts || {}
|
|
71
|
+
const allScripts = Object.values(scripts).join(' ')
|
|
72
|
+
|
|
73
|
+
// Try to extract backend port from scripts
|
|
74
|
+
const backendPortMatch = allScripts.match(/PORT[=:](\d+)/) ||
|
|
75
|
+
allScripts.match(/-p\s+(\d+).*backend/) ||
|
|
76
|
+
allScripts.match(/backend.*-p\s+(\d+)/)
|
|
77
|
+
|
|
78
|
+
// Try to extract frontend port from scripts
|
|
79
|
+
const frontendPortMatch = allScripts.match(/next\s+dev\s+-p\s+(\d+)/) ||
|
|
80
|
+
allScripts.match(/-p\s+(\d+)/)
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
backend: backendPortMatch ? parseInt(backendPortMatch[1]) : null,
|
|
84
|
+
frontend: frontendPortMatch ? parseInt(frontendPortMatch[1]) : null
|
|
85
|
+
}
|
|
86
|
+
} catch { /* ignore */ }
|
|
87
|
+
}
|
|
88
|
+
return { backend: null, frontend: null }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function detectSupabaseProjectId() {
|
|
92
|
+
// Check doppler.yaml or existing env references
|
|
93
|
+
const dopplerPath = join(projectRoot, 'doppler.yaml')
|
|
94
|
+
if (existsSync(dopplerPath)) {
|
|
95
|
+
return 'check-doppler' // User needs to fill in from Doppler
|
|
96
|
+
}
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function hasWorkspace(name) {
|
|
101
|
+
return existsSync(join(projectRoot, name))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Templates ──────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
function generateInfrastructureYml(config) {
|
|
107
|
+
return `# ${config.name} Infrastructure Configuration
|
|
108
|
+
# Single source of truth for all external services and infrastructure
|
|
109
|
+
# Machine-readable by ralph-manager health checks
|
|
110
|
+
|
|
111
|
+
project:
|
|
112
|
+
name: ${config.name}
|
|
113
|
+
phase: development
|
|
114
|
+
description: "${config.description}"
|
|
115
|
+
repo: null
|
|
116
|
+
|
|
117
|
+
hosting:
|
|
118
|
+
frontend:
|
|
119
|
+
type: null
|
|
120
|
+
site_name: null
|
|
121
|
+
url: null
|
|
122
|
+
branch: main
|
|
123
|
+
backend:
|
|
124
|
+
type: null
|
|
125
|
+
service_name: null
|
|
126
|
+
url: null
|
|
127
|
+
branch: main
|
|
128
|
+
|
|
129
|
+
domains:
|
|
130
|
+
registrar: null
|
|
131
|
+
primary: null
|
|
132
|
+
aliases: []
|
|
133
|
+
dns_provider: null
|
|
134
|
+
ssl: auto
|
|
135
|
+
|
|
136
|
+
database:
|
|
137
|
+
provider: supabase
|
|
138
|
+
region: eu-central-1
|
|
139
|
+
project_id: null
|
|
140
|
+
|
|
141
|
+
secrets:
|
|
142
|
+
manager: doppler
|
|
143
|
+
configs: []
|
|
144
|
+
|
|
145
|
+
email:
|
|
146
|
+
provider: null
|
|
147
|
+
type: null
|
|
148
|
+
addresses: []
|
|
149
|
+
|
|
150
|
+
monitoring:
|
|
151
|
+
uptime: null
|
|
152
|
+
error_tracking: null
|
|
153
|
+
analytics: null
|
|
154
|
+
|
|
155
|
+
services:
|
|
156
|
+
payment: null
|
|
157
|
+
email_delivery: null
|
|
158
|
+
cdn: null
|
|
159
|
+
|
|
160
|
+
security:
|
|
161
|
+
rls_enabled: true
|
|
162
|
+
auth_provider: supabase
|
|
163
|
+
rate_limiting: true
|
|
164
|
+
repo_visibility: private
|
|
165
|
+
open_issues: []
|
|
166
|
+
|
|
167
|
+
meta:
|
|
168
|
+
created_at: "${new Date().toISOString().split('T')[0]}"
|
|
169
|
+
updated_by: tetra-init
|
|
170
|
+
version: 1
|
|
171
|
+
`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function generateMarketingYml(config) {
|
|
175
|
+
return `# ${config.name} Marketing Stack Configuration
|
|
176
|
+
# Single source of truth for marketing infrastructure status
|
|
177
|
+
# Machine-readable by ralph-manager
|
|
178
|
+
|
|
179
|
+
project: ${config.name}
|
|
180
|
+
updated_at: "${new Date().toISOString().split('T')[0]}"
|
|
181
|
+
updated_by: tetra-init
|
|
182
|
+
|
|
183
|
+
# ─── Meta (Facebook/Instagram) ───────────────────────────────
|
|
184
|
+
|
|
185
|
+
meta:
|
|
186
|
+
business_portfolio: null
|
|
187
|
+
business_portfolio_id: null
|
|
188
|
+
ad_account:
|
|
189
|
+
name: null
|
|
190
|
+
id: null
|
|
191
|
+
status: not_configured
|
|
192
|
+
currency: EUR
|
|
193
|
+
pixel:
|
|
194
|
+
name: null
|
|
195
|
+
id: null
|
|
196
|
+
status: not_configured
|
|
197
|
+
consent_mode: true
|
|
198
|
+
events: []
|
|
199
|
+
pages: []
|
|
200
|
+
|
|
201
|
+
# ─── Google ──────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
google:
|
|
204
|
+
analytics:
|
|
205
|
+
measurement_id: null
|
|
206
|
+
stream_id: null
|
|
207
|
+
status: not_configured
|
|
208
|
+
consent_mode: true
|
|
209
|
+
ads:
|
|
210
|
+
customer_id: null
|
|
211
|
+
status: not_configured
|
|
212
|
+
search_console:
|
|
213
|
+
verified: false
|
|
214
|
+
status: not_configured
|
|
215
|
+
tag_manager:
|
|
216
|
+
container_id: null
|
|
217
|
+
status: not_configured
|
|
218
|
+
|
|
219
|
+
# ─── Consent & Privacy ──────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
consent:
|
|
222
|
+
platform: null
|
|
223
|
+
cookie_banner: false
|
|
224
|
+
privacy_policy: false
|
|
225
|
+
terms_of_service: false
|
|
226
|
+
gdpr_compliant: false
|
|
227
|
+
|
|
228
|
+
# ─── SEO ────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
seo:
|
|
231
|
+
sitemap: false
|
|
232
|
+
robots_txt: false
|
|
233
|
+
structured_data: false
|
|
234
|
+
canonical_urls: false
|
|
235
|
+
meta_tags: false
|
|
236
|
+
|
|
237
|
+
# ─── Email Marketing ────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
email_marketing:
|
|
240
|
+
provider: null
|
|
241
|
+
list_id: null
|
|
242
|
+
status: not_configured
|
|
243
|
+
|
|
244
|
+
meta_info:
|
|
245
|
+
created_at: "${new Date().toISOString().split('T')[0]}"
|
|
246
|
+
updated_by: tetra-init
|
|
247
|
+
version: 1
|
|
248
|
+
`
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function generatePortsJson(config) {
|
|
252
|
+
const obj = {
|
|
253
|
+
backend_port: config.backendPort,
|
|
254
|
+
frontend_port: config.frontendPort,
|
|
255
|
+
api_url: `http://localhost:${config.backendPort}`,
|
|
256
|
+
synced_at: new Date().toISOString(),
|
|
257
|
+
source: 'tetra-init'
|
|
258
|
+
}
|
|
259
|
+
return JSON.stringify(obj, null, 2) + '\n'
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function generateConfigSh(config) {
|
|
263
|
+
return `# ${config.name} project config overrides for Ralph
|
|
264
|
+
# Allowed tools for autonomous Ralph sessions
|
|
265
|
+
CLAUDE_ALLOWED_TOOLS="Write,Edit,Read,Bash,Glob,Grep,WebFetch,WebSearch"
|
|
266
|
+
`
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function generateStatusJson() {
|
|
270
|
+
return JSON.stringify({
|
|
271
|
+
last_session: null,
|
|
272
|
+
last_task: null,
|
|
273
|
+
updated_at: new Date().toISOString()
|
|
274
|
+
}, null, 2) + '\n'
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function generateFixPlan(config) {
|
|
278
|
+
return `# ${config.name} — Fix Plan
|
|
279
|
+
|
|
280
|
+
## Active Tasks
|
|
281
|
+
|
|
282
|
+
- [ ] Project initialization — verify all config files are correct
|
|
283
|
+
- [ ] First feature implementation
|
|
284
|
+
|
|
285
|
+
## Completed
|
|
286
|
+
|
|
287
|
+
(none yet)
|
|
288
|
+
`
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function generatePromptMd(config) {
|
|
292
|
+
return `# Ralph Development Instructions - ${config.name}
|
|
293
|
+
|
|
294
|
+
## Context
|
|
295
|
+
You are Ralph, an AUTONOMOUS AI development agent working on **${config.name}**.
|
|
296
|
+
|
|
297
|
+
## KRITIEKE REGEL: NOOIT VRAGEN, ALTIJD DOEN
|
|
298
|
+
|
|
299
|
+
\`\`\`
|
|
300
|
+
VERBODEN:
|
|
301
|
+
- "Wat wil je als eerste oppakken?"
|
|
302
|
+
- "Waar wil je mee beginnen?"
|
|
303
|
+
- "Wil je dat ik X doe?"
|
|
304
|
+
- Elke vraag aan de gebruiker over wat je moet doen
|
|
305
|
+
|
|
306
|
+
VERPLICHT:
|
|
307
|
+
- Kies ZELF de eerste onafgeronde taak uit @fix_plan.md
|
|
308
|
+
- Voer die taak UIT in dezelfde loop
|
|
309
|
+
- Vraag alleen om hulp als iets technisch ONMOGELIJK is
|
|
310
|
+
\`\`\`
|
|
311
|
+
|
|
312
|
+
## Sessie Opstart (loop 1)
|
|
313
|
+
|
|
314
|
+
1. \`Read .ralph/@fix_plan.md\` — vind eerste \`- [ ]\` taak
|
|
315
|
+
2. **BEGIN DIRECT met die taak** — niet samenvatten, niet vragen, DOEN
|
|
316
|
+
3. Na afronding: vink af met [x], ga naar volgende taak
|
|
317
|
+
|
|
318
|
+
## Na elke taak
|
|
319
|
+
|
|
320
|
+
1. Vink de taak af in @fix_plan.md
|
|
321
|
+
2. Ga naar de volgende \`- [ ]\` taak
|
|
322
|
+
3. Als alles af is: schrijf samenvatting en stop
|
|
323
|
+
`
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function generateProjectJson(config) {
|
|
327
|
+
return JSON.stringify({
|
|
328
|
+
ralph_id: null,
|
|
329
|
+
name: config.name,
|
|
330
|
+
created_at: new Date().toISOString()
|
|
331
|
+
}, null, 2) + '\n'
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function generateTestConfigYml(config) {
|
|
335
|
+
return `# ${config.name} Test Configuration
|
|
336
|
+
# Used by Ralph for automated testing
|
|
337
|
+
|
|
338
|
+
test_suites:
|
|
339
|
+
unit:
|
|
340
|
+
command: "npm test"
|
|
341
|
+
timeout: 120
|
|
342
|
+
typecheck:
|
|
343
|
+
command: "npx tsc --noEmit"
|
|
344
|
+
timeout: 60
|
|
345
|
+
e2e:
|
|
346
|
+
command: "npx playwright test"
|
|
347
|
+
timeout: 300
|
|
348
|
+
enabled: false
|
|
349
|
+
|
|
350
|
+
health_checks:
|
|
351
|
+
backend:
|
|
352
|
+
url: "http://localhost:${config.backendPort}/api/health"
|
|
353
|
+
timeout: 5
|
|
354
|
+
frontend:
|
|
355
|
+
url: "http://localhost:${config.frontendPort}"
|
|
356
|
+
timeout: 10
|
|
357
|
+
`
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function generateTetraQualityJson() {
|
|
361
|
+
return JSON.stringify({
|
|
362
|
+
"$schema": "https://tetra-tools.dev/schemas/quality-toolkit.json",
|
|
363
|
+
"suites": {
|
|
364
|
+
"security": true,
|
|
365
|
+
"stability": true,
|
|
366
|
+
"codeQuality": true,
|
|
367
|
+
"supabase": "auto",
|
|
368
|
+
"hygiene": true
|
|
369
|
+
},
|
|
370
|
+
"security": {
|
|
371
|
+
"checkHardcodedSecrets": true,
|
|
372
|
+
"checkServiceKeyExposure": true
|
|
373
|
+
},
|
|
374
|
+
"stability": {
|
|
375
|
+
"requireHusky": true,
|
|
376
|
+
"requireCiConfig": true,
|
|
377
|
+
"allowedVulnerabilities": {
|
|
378
|
+
"critical": 0,
|
|
379
|
+
"high": 0,
|
|
380
|
+
"moderate": 10
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
"ignore": [
|
|
384
|
+
"node_modules/**",
|
|
385
|
+
"dist/**",
|
|
386
|
+
"build/**",
|
|
387
|
+
".next/**"
|
|
388
|
+
]
|
|
389
|
+
}, null, 2) + '\n'
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ─── Commands ───────────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
async function initRalph(config, options) {
|
|
395
|
+
console.log('')
|
|
396
|
+
console.log('📁 Initializing .ralph/ directory...')
|
|
397
|
+
|
|
398
|
+
const ralphDir = join(projectRoot, '.ralph')
|
|
399
|
+
if (!existsSync(ralphDir)) {
|
|
400
|
+
mkdirSync(ralphDir, { recursive: true })
|
|
401
|
+
console.log(' ✅ Created .ralph/')
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Specs directory
|
|
405
|
+
const specsDir = join(ralphDir, 'specs')
|
|
406
|
+
if (!existsSync(specsDir)) {
|
|
407
|
+
mkdirSync(specsDir, { recursive: true })
|
|
408
|
+
console.log(' ✅ Created .ralph/specs/')
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
writeIfMissing(join(ralphDir, 'ports.json'), generatePortsJson(config), options)
|
|
412
|
+
writeIfMissing(join(ralphDir, 'INFRASTRUCTURE.yml'), generateInfrastructureYml(config), options)
|
|
413
|
+
writeIfMissing(join(ralphDir, 'MARKETING.yml'), generateMarketingYml(config), options)
|
|
414
|
+
writeIfMissing(join(ralphDir, 'config.sh'), generateConfigSh(config), options)
|
|
415
|
+
writeIfMissing(join(ralphDir, 'status.json'), generateStatusJson(), options)
|
|
416
|
+
writeIfMissing(join(ralphDir, '@fix_plan.md'), generateFixPlan(config), options)
|
|
417
|
+
writeIfMissing(join(ralphDir, 'PROMPT.md'), generatePromptMd(config), options)
|
|
418
|
+
writeIfMissing(join(ralphDir, 'project.json'), generateProjectJson(config), options)
|
|
419
|
+
writeIfMissing(join(ralphDir, 'test-config.yml'), generateTestConfigYml(config), options)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function initQuality(config, options) {
|
|
423
|
+
console.log('')
|
|
424
|
+
console.log('🔍 Initializing quality config...')
|
|
425
|
+
|
|
426
|
+
writeIfMissing(
|
|
427
|
+
join(projectRoot, '.tetra-quality.json'),
|
|
428
|
+
generateTetraQualityJson(),
|
|
429
|
+
options
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function checkCompleteness() {
|
|
434
|
+
console.log('')
|
|
435
|
+
console.log('🔎 Checking project completeness...')
|
|
436
|
+
console.log('═'.repeat(50))
|
|
437
|
+
|
|
438
|
+
const checks = [
|
|
439
|
+
// Root files
|
|
440
|
+
{ path: 'package.json', category: 'root', required: true },
|
|
441
|
+
{ path: 'doppler.yaml', category: 'root', required: true },
|
|
442
|
+
{ path: 'CLAUDE.md', category: 'root', required: true },
|
|
443
|
+
{ path: '.tetra-quality.json', category: 'root', required: false },
|
|
444
|
+
{ path: '.gitignore', category: 'root', required: true },
|
|
445
|
+
|
|
446
|
+
// .ralph/ files
|
|
447
|
+
{ path: '.ralph/ports.json', category: 'ralph', required: true },
|
|
448
|
+
{ path: '.ralph/INFRASTRUCTURE.yml', category: 'ralph', required: true },
|
|
449
|
+
{ path: '.ralph/MARKETING.yml', category: 'ralph', required: false },
|
|
450
|
+
{ path: '.ralph/config.sh', category: 'ralph', required: false },
|
|
451
|
+
{ path: '.ralph/status.json', category: 'ralph', required: false },
|
|
452
|
+
{ path: '.ralph/@fix_plan.md', category: 'ralph', required: true },
|
|
453
|
+
{ path: '.ralph/PROMPT.md', category: 'ralph', required: true },
|
|
454
|
+
{ path: '.ralph/project.json', category: 'ralph', required: true },
|
|
455
|
+
{ path: '.ralph/test-config.yml', category: 'ralph', required: false },
|
|
456
|
+
{ path: '.ralph/specs', category: 'ralph', required: false },
|
|
457
|
+
|
|
458
|
+
// Backend
|
|
459
|
+
{ path: 'backend/package.json', category: 'backend', required: true },
|
|
460
|
+
{ path: 'backend/tsconfig.json', category: 'backend', required: true },
|
|
461
|
+
{ path: 'backend/src/index.ts', category: 'backend', required: true },
|
|
462
|
+
{ path: 'backend/src/auth-config.ts', category: 'backend', required: true },
|
|
463
|
+
{ path: 'backend/src/core/Application.ts', category: 'backend', required: true },
|
|
464
|
+
{ path: 'backend/src/core/RouteManager.ts', category: 'backend', required: true },
|
|
465
|
+
|
|
466
|
+
// Frontend
|
|
467
|
+
{ path: 'frontend/package.json', category: 'frontend', required: true },
|
|
468
|
+
{ path: 'frontend/next.config.ts', category: 'frontend', required: true },
|
|
469
|
+
{ path: 'frontend/src/app/layout.tsx', category: 'frontend', required: true },
|
|
470
|
+
|
|
471
|
+
// Database
|
|
472
|
+
{ path: 'supabase/migrations', category: 'database', required: false },
|
|
473
|
+
]
|
|
474
|
+
|
|
475
|
+
let currentCategory = ''
|
|
476
|
+
let passed = 0
|
|
477
|
+
let failed = 0
|
|
478
|
+
let warnings = 0
|
|
479
|
+
|
|
480
|
+
for (const check of checks) {
|
|
481
|
+
if (check.category !== currentCategory) {
|
|
482
|
+
currentCategory = check.category
|
|
483
|
+
console.log('')
|
|
484
|
+
console.log(` ${currentCategory.toUpperCase()}`)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const fullPath = join(projectRoot, check.path)
|
|
488
|
+
const exists = existsSync(fullPath)
|
|
489
|
+
const icon = exists ? '✅' : (check.required ? '❌' : '⚠️')
|
|
490
|
+
|
|
491
|
+
if (exists) {
|
|
492
|
+
passed++
|
|
493
|
+
} else if (check.required) {
|
|
494
|
+
failed++
|
|
495
|
+
} else {
|
|
496
|
+
warnings++
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
console.log(` ${icon} ${check.path}`)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
console.log('')
|
|
503
|
+
console.log('═'.repeat(50))
|
|
504
|
+
console.log(` ✅ ${passed} present ❌ ${failed} missing (required) ⚠️ ${warnings} missing (optional)`)
|
|
505
|
+
|
|
506
|
+
if (failed > 0) {
|
|
507
|
+
console.log('')
|
|
508
|
+
console.log(' Run `tetra-init` to generate missing files.')
|
|
509
|
+
} else if (warnings > 0) {
|
|
510
|
+
console.log('')
|
|
511
|
+
console.log(' All required files present! Run `tetra-init` to generate optional files.')
|
|
512
|
+
} else {
|
|
513
|
+
console.log('')
|
|
514
|
+
console.log(' 🎉 Project is fully initialized!')
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
console.log('')
|
|
518
|
+
return failed === 0
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ─── Main ───────────────────────────────────────────────────────
|
|
522
|
+
|
|
523
|
+
program
|
|
524
|
+
.name('tetra-init')
|
|
525
|
+
.description('Initialize a Tetra project with all required config files')
|
|
526
|
+
.version('1.0.0')
|
|
527
|
+
.argument('[component]', 'Component to init: ralph, quality, check, or all (default)')
|
|
528
|
+
.option('-n, --name <name>', 'Project name (skips interactive prompt)')
|
|
529
|
+
.option('-d, --description <desc>', 'Project description')
|
|
530
|
+
.option('--backend-port <port>', 'Backend port number', parseInt)
|
|
531
|
+
.option('--frontend-port <port>', 'Frontend port number', parseInt)
|
|
532
|
+
.option('-f, --force', 'Overwrite existing files')
|
|
533
|
+
.option('-y, --yes', 'Accept all defaults (non-interactive)')
|
|
534
|
+
.action(async (component, options) => {
|
|
535
|
+
console.log('')
|
|
536
|
+
console.log('🚀 Tetra Project Init')
|
|
537
|
+
console.log('═'.repeat(50))
|
|
538
|
+
|
|
539
|
+
// Check-only mode
|
|
540
|
+
if (component === 'check') {
|
|
541
|
+
const ok = checkCompleteness()
|
|
542
|
+
process.exit(ok ? 0 : 1)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Gather config
|
|
546
|
+
const detectedName = detectProjectName()
|
|
547
|
+
const detectedPorts = detectPorts()
|
|
548
|
+
const isInteractive = !options.yes && process.stdin.isTTY
|
|
549
|
+
|
|
550
|
+
let config = {
|
|
551
|
+
name: options.name || detectedName,
|
|
552
|
+
description: options.description || '',
|
|
553
|
+
backendPort: options.backendPort || detectedPorts.backend,
|
|
554
|
+
frontendPort: options.frontendPort || detectedPorts.frontend,
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (isInteractive) {
|
|
558
|
+
console.log('')
|
|
559
|
+
config.name = await ask('Project name', config.name)
|
|
560
|
+
config.description = await ask('Description', config.description || `${config.name} platform`)
|
|
561
|
+
config.backendPort = parseInt(await ask('Backend port', String(config.backendPort || 3000))) || 3000
|
|
562
|
+
config.frontendPort = parseInt(await ask('Frontend port', String(config.frontendPort || 3001))) || 3001
|
|
563
|
+
} else {
|
|
564
|
+
// Non-interactive defaults
|
|
565
|
+
config.description = config.description || `${config.name} platform`
|
|
566
|
+
config.backendPort = config.backendPort || 3000
|
|
567
|
+
config.frontendPort = config.frontendPort || 3001
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
console.log('')
|
|
571
|
+
console.log(` Project: ${config.name}`)
|
|
572
|
+
console.log(` Backend: localhost:${config.backendPort}`)
|
|
573
|
+
console.log(` Frontend: localhost:${config.frontendPort}`)
|
|
574
|
+
|
|
575
|
+
const components = component === 'all' || !component
|
|
576
|
+
? ['ralph', 'quality']
|
|
577
|
+
: [component]
|
|
578
|
+
|
|
579
|
+
for (const comp of components) {
|
|
580
|
+
switch (comp) {
|
|
581
|
+
case 'ralph':
|
|
582
|
+
await initRalph(config, options)
|
|
583
|
+
break
|
|
584
|
+
case 'quality':
|
|
585
|
+
await initQuality(config, options)
|
|
586
|
+
break
|
|
587
|
+
default:
|
|
588
|
+
console.log(`Unknown component: ${comp}`)
|
|
589
|
+
console.log('Available: ralph, quality, check, or all (default)')
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Verify essential root files
|
|
594
|
+
console.log('')
|
|
595
|
+
console.log('📋 Checking essential root files...')
|
|
596
|
+
|
|
597
|
+
const essentials = [
|
|
598
|
+
{ path: 'doppler.yaml', hint: 'Create doppler.yaml with your Doppler project config' },
|
|
599
|
+
{ path: 'CLAUDE.md', hint: 'Create CLAUDE.md with project instructions for Claude Code' },
|
|
600
|
+
{ path: '.gitignore', hint: 'Create .gitignore (node_modules, .next, dist, etc.)' },
|
|
601
|
+
]
|
|
602
|
+
|
|
603
|
+
for (const item of essentials) {
|
|
604
|
+
const fullPath = join(projectRoot, item.path)
|
|
605
|
+
if (existsSync(fullPath)) {
|
|
606
|
+
console.log(` ✅ ${item.path}`)
|
|
607
|
+
} else {
|
|
608
|
+
console.log(` ⚠️ ${item.path} — ${item.hint}`)
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
console.log('')
|
|
613
|
+
console.log('✅ Init complete!')
|
|
614
|
+
console.log('')
|
|
615
|
+
console.log('Next steps:')
|
|
616
|
+
console.log(' 1. Review generated files in .ralph/')
|
|
617
|
+
console.log(' 2. Fill in INFRASTRUCTURE.yml with hosting/domain info')
|
|
618
|
+
console.log(' 3. Run `tetra-init check` to verify completeness')
|
|
619
|
+
console.log(' 4. Run `tetra-setup` to add quality hooks')
|
|
620
|
+
console.log('')
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
program.parse()
|
package/lib/checks/index.js
CHANGED
|
@@ -16,6 +16,7 @@ export * as npmAudit from './stability/npm-audit.js'
|
|
|
16
16
|
// Supabase checks
|
|
17
17
|
export * as rlsPolicyAudit from './supabase/rls-policy-audit.js'
|
|
18
18
|
export * as rpcParamMismatch from './supabase/rpc-param-mismatch.js'
|
|
19
|
+
export * as rpcGeneratorOrigin from './supabase/rpc-generator-origin.js'
|
|
19
20
|
|
|
20
21
|
// Health checks (project ecosystem)
|
|
21
22
|
export * as health from './health/index.js'
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RPC Generator Origin Check
|
|
3
|
+
*
|
|
4
|
+
* Ensures that filter/count RPC functions (get_*_results, get_*_counts) are
|
|
5
|
+
* ONLY created via the SQL Generator, never hand-written.
|
|
6
|
+
*
|
|
7
|
+
* Origin: February 2026, SparkBuddy. Hand-editing SQL caused 30+ minutes of
|
|
8
|
+
* debugging when security blocks didn't match, search_path broke, and
|
|
9
|
+
* DO blocks/regex replacements on production failed silently. The only
|
|
10
|
+
* reliable path is: fix the config → regenerate via SQL Generator → deploy.
|
|
11
|
+
*
|
|
12
|
+
* Modes:
|
|
13
|
+
* - Pre-commit (default): Only checks git-staged files matching the pattern.
|
|
14
|
+
* This avoids false positives on legacy files.
|
|
15
|
+
* - Full audit (config.rpcGeneratorOrigin.checkAll = true): Checks ALL files.
|
|
16
|
+
* Use for comprehensive audits.
|
|
17
|
+
*
|
|
18
|
+
* This prevents:
|
|
19
|
+
* - Hand-written RPCs with wrong security patterns (accessLevel mismatch)
|
|
20
|
+
* - Missing public. prefix in search_path="" functions
|
|
21
|
+
* - Security blocks that don't match the generator's output
|
|
22
|
+
* - Silent breakage when generator overwrites manual edits on next run
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFileSync, existsSync, readdirSync } from 'fs'
|
|
26
|
+
import { join, relative } from 'path'
|
|
27
|
+
import { execSync } from 'child_process'
|
|
28
|
+
|
|
29
|
+
export const meta = {
|
|
30
|
+
id: 'rpc-generator-origin',
|
|
31
|
+
name: 'RPC Generator Origin',
|
|
32
|
+
category: 'supabase',
|
|
33
|
+
severity: 'critical',
|
|
34
|
+
description: 'Ensures get_*_results and get_*_counts SQL files are generated by the SQL Generator, not hand-written'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pattern for files that MUST come from the SQL Generator
|
|
39
|
+
*/
|
|
40
|
+
const GENERATED_FILE_PATTERN = /get_\w+_(results|counts)\.sql$/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Required header that the SQL Generator always includes
|
|
44
|
+
*/
|
|
45
|
+
const GENERATOR_HEADER = '-- Generator: SQL Generator'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Alternative headers from older generator versions
|
|
49
|
+
*/
|
|
50
|
+
const LEGACY_HEADERS = [
|
|
51
|
+
'-- Generated by SQL Generator',
|
|
52
|
+
'-- Auto-generated by RPCGenerator',
|
|
53
|
+
'-- Generator: RPC Generator'
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Try to get git-staged files matching our pattern.
|
|
58
|
+
* Returns null if git is not available or not in a repo.
|
|
59
|
+
*/
|
|
60
|
+
function getStagedFiles(projectRoot) {
|
|
61
|
+
try {
|
|
62
|
+
const output = execSync('git diff --cached --name-only --diff-filter=ACM', {
|
|
63
|
+
cwd: projectRoot,
|
|
64
|
+
encoding: 'utf-8',
|
|
65
|
+
timeout: 5000
|
|
66
|
+
}).trim()
|
|
67
|
+
|
|
68
|
+
if (!output) return []
|
|
69
|
+
|
|
70
|
+
return output.split('\n')
|
|
71
|
+
.filter(f => GENERATED_FILE_PATTERN.test(f))
|
|
72
|
+
.map(f => join(projectRoot, f))
|
|
73
|
+
} catch {
|
|
74
|
+
return null // Not a git repo or git not available
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get ALL matching files from migration directories
|
|
80
|
+
*/
|
|
81
|
+
function getAllFiles(config, projectRoot) {
|
|
82
|
+
const migrationPaths = [
|
|
83
|
+
...(config.paths?.migrations || ['supabase/migrations', 'migrations']),
|
|
84
|
+
'backend/supabase/migrations'
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
const sqlFiles = []
|
|
88
|
+
for (const relPath of migrationPaths) {
|
|
89
|
+
const dir = join(projectRoot, relPath)
|
|
90
|
+
if (!existsSync(dir)) continue
|
|
91
|
+
try {
|
|
92
|
+
const files = readdirSync(dir)
|
|
93
|
+
.filter(f => GENERATED_FILE_PATTERN.test(f))
|
|
94
|
+
.sort()
|
|
95
|
+
for (const f of files) {
|
|
96
|
+
sqlFiles.push(join(dir, f))
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// ignore
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// De-duplicate: keep only latest per function name
|
|
104
|
+
const latestByFunction = new Map()
|
|
105
|
+
for (const filePath of sqlFiles) {
|
|
106
|
+
const fileName = filePath.split('/').pop()
|
|
107
|
+
const funcMatch = fileName.match(/\d+_(get_\w+(?:_results|_counts))\.sql$/)
|
|
108
|
+
if (!funcMatch) continue
|
|
109
|
+
latestByFunction.set(funcMatch[1], filePath)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return [...latestByFunction.values()]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Check a single file for the generator header
|
|
117
|
+
*/
|
|
118
|
+
function checkFile(filePath, projectRoot) {
|
|
119
|
+
const relPath = relative(projectRoot, filePath)
|
|
120
|
+
|
|
121
|
+
let content
|
|
122
|
+
try {
|
|
123
|
+
content = readFileSync(filePath, 'utf-8').substring(0, 500)
|
|
124
|
+
} catch {
|
|
125
|
+
return null // Can't read
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const hasCurrentHeader = content.includes(GENERATOR_HEADER)
|
|
129
|
+
const hasLegacyHeader = LEGACY_HEADERS.some(h => content.includes(h))
|
|
130
|
+
|
|
131
|
+
if (hasCurrentHeader || hasLegacyHeader) {
|
|
132
|
+
return { ok: true, file: relPath }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
file: relPath,
|
|
138
|
+
finding: {
|
|
139
|
+
file: relPath,
|
|
140
|
+
line: 1,
|
|
141
|
+
type: 'Hand-written RPC function',
|
|
142
|
+
severity: 'critical',
|
|
143
|
+
message: `${relPath} — Missing "-- Generator: SQL Generator" header. ` +
|
|
144
|
+
`This file appears to be hand-written. ` +
|
|
145
|
+
`RPC filter/count functions MUST be generated via: npm run generate:rpc <feature>. ` +
|
|
146
|
+
`Fix the feature config instead, then regenerate.`,
|
|
147
|
+
fix: [
|
|
148
|
+
'1. Find the feature config: backend/src/features/<feature>/config/<feature>.config.ts',
|
|
149
|
+
'2. Fix the config (accessLevel, customWhereClause, etc.)',
|
|
150
|
+
'3. Regenerate: cd backend && npm run generate:rpc <feature>',
|
|
151
|
+
'4. NEVER edit SQL files directly — the generator will overwrite your changes'
|
|
152
|
+
].join('\n')
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function run(config, projectRoot) {
|
|
158
|
+
const checkAll = config.rpcGeneratorOrigin?.checkAll === true
|
|
159
|
+
const results = {
|
|
160
|
+
passed: true,
|
|
161
|
+
findings: [],
|
|
162
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
163
|
+
details: {
|
|
164
|
+
mode: checkAll ? 'full' : 'staged',
|
|
165
|
+
filesChecked: 0,
|
|
166
|
+
filesWithHeader: 0,
|
|
167
|
+
filesWithoutHeader: 0
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let filesToCheck
|
|
172
|
+
|
|
173
|
+
if (checkAll) {
|
|
174
|
+
// Full audit mode: check all files
|
|
175
|
+
filesToCheck = getAllFiles(config, projectRoot)
|
|
176
|
+
} else {
|
|
177
|
+
// Pre-commit mode: only staged files
|
|
178
|
+
const stagedFiles = getStagedFiles(projectRoot)
|
|
179
|
+
|
|
180
|
+
if (stagedFiles === null) {
|
|
181
|
+
// Not in a git repo — fall back to all files
|
|
182
|
+
filesToCheck = getAllFiles(config, projectRoot)
|
|
183
|
+
results.details.mode = 'full (no git)'
|
|
184
|
+
} else if (stagedFiles.length === 0) {
|
|
185
|
+
// No staged RPC files — nothing to check
|
|
186
|
+
results.details.mode = 'staged (no matching files)'
|
|
187
|
+
return results
|
|
188
|
+
} else {
|
|
189
|
+
filesToCheck = stagedFiles
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (filesToCheck.length === 0) {
|
|
194
|
+
results.skipped = true
|
|
195
|
+
results.skipReason = 'No get_*_results/counts SQL files found'
|
|
196
|
+
return results
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const filePath of filesToCheck) {
|
|
200
|
+
const result = checkFile(filePath, projectRoot)
|
|
201
|
+
if (!result) continue
|
|
202
|
+
|
|
203
|
+
results.details.filesChecked++
|
|
204
|
+
|
|
205
|
+
if (result.ok) {
|
|
206
|
+
results.details.filesWithHeader++
|
|
207
|
+
} else {
|
|
208
|
+
results.details.filesWithoutHeader++
|
|
209
|
+
results.passed = false
|
|
210
|
+
results.findings.push(result.finding)
|
|
211
|
+
results.summary.critical++
|
|
212
|
+
results.summary.total++
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return results
|
|
217
|
+
}
|
package/lib/runner.js
CHANGED
|
@@ -18,6 +18,7 @@ import * as apiResponseFormat from './checks/codeQuality/api-response-format.js'
|
|
|
18
18
|
import * as gitignoreValidation from './checks/security/gitignore-validation.js'
|
|
19
19
|
import * as rlsPolicyAudit from './checks/supabase/rls-policy-audit.js'
|
|
20
20
|
import * as rpcParamMismatch from './checks/supabase/rpc-param-mismatch.js'
|
|
21
|
+
import * as rpcGeneratorOrigin from './checks/supabase/rpc-generator-origin.js'
|
|
21
22
|
import * as fileOrganization from './checks/hygiene/file-organization.js'
|
|
22
23
|
|
|
23
24
|
// Register all checks
|
|
@@ -39,7 +40,8 @@ const ALL_CHECKS = {
|
|
|
39
40
|
],
|
|
40
41
|
supabase: [
|
|
41
42
|
rlsPolicyAudit,
|
|
42
|
-
rpcParamMismatch
|
|
43
|
+
rpcParamMismatch,
|
|
44
|
+
rpcGeneratorOrigin
|
|
43
45
|
],
|
|
44
46
|
hygiene: [
|
|
45
47
|
fileOrganization
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soulbatical/tetra-dev-toolkit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "restricted"
|
|
6
6
|
},
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"main": "lib/index.js",
|
|
27
27
|
"bin": {
|
|
28
28
|
"tetra-audit": "./bin/tetra-audit.js",
|
|
29
|
+
"tetra-init": "./bin/tetra-init.js",
|
|
29
30
|
"tetra-setup": "./bin/tetra-setup.js",
|
|
30
31
|
"tetra-dev-token": "./bin/tetra-dev-token.js"
|
|
31
32
|
},
|