@soulbatical/tetra-dev-toolkit 1.20.11 → 1.20.13

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.
@@ -3,16 +3,24 @@
3
3
  /**
4
4
  * Tetra Init Tests — Scaffold golden standard E2E test structure.
5
5
  *
6
- * Creates:
7
- * tests/e2e/helpers/api-client.ts (HTTP client with X-Test-Key bypass)
8
- * tests/e2e/helpers/test-users.ts (Login + token caching)
9
- * tests/e2e/01-auth.test.ts (Auth: login, session, refresh, logout)
10
- * tests/e2e/07-security.test.ts (Auth walls + role access)
11
- * vitest.config.e2e.ts (Vitest config for E2E)
6
+ * Creates the complete test infrastructure:
7
+ * tests/e2e/helpers/api-client.ts HTTP client with X-Test-Key bypass
8
+ * tests/e2e/helpers/test-users.ts Login + token caching
9
+ * tests/e2e/global-setup.ts Login once, share tokens
10
+ * tests/e2e/.gitignore Exclude token files
11
+ * tests/e2e/01-auth.test.ts Auth: login, session, refresh, logout
12
+ * tests/e2e/02-crud-resources.test.ts CRUD for all resources (customize)
13
+ * tests/e2e/03-permissions.test.ts Auth walls for all routes (customize)
14
+ * tests/e2e/04-business-flows.test.ts End-to-end user journeys (customize)
15
+ * tests/e2e/05-security.test.ts Input validation, injection, CORS
16
+ * vitest.config.e2e.ts Vitest config for E2E
17
+ * .github/workflows/ci.yml CI with API tests (if missing)
18
+ * package.json test:e2e script (if missing)
12
19
  *
13
20
  * Usage:
14
- * tetra-init-tests # Interactive
21
+ * tetra-init-tests # Scaffold all
15
22
  * tetra-init-tests --force # Overwrite existing files
23
+ * tetra-init-tests --ci-only # Only add CI workflow
16
24
  */
17
25
 
18
26
  import { program } from 'commander'
@@ -24,9 +32,13 @@ const __filename = fileURLToPath(import.meta.url)
24
32
  const __dirname = dirname(__filename)
25
33
  const TEMPLATES_DIR = join(__dirname, '..', 'lib', 'templates', 'tests')
26
34
 
35
+ let filesCreated = 0
36
+ let filesSkipped = 0
37
+
27
38
  function copyTemplate(templateName, destPath, force = false) {
28
39
  if (existsSync(destPath) && !force) {
29
- console.log(` ⏩ ${destPath} (exists, use --force to overwrite)`)
40
+ console.log(` ⏩ ${destPath} (exists)`)
41
+ filesSkipped++
30
42
  return false
31
43
  }
32
44
 
@@ -40,17 +52,38 @@ function copyTemplate(templateName, destPath, force = false) {
40
52
  mkdirSync(dirname(destPath), { recursive: true })
41
53
  writeFileSync(destPath, content)
42
54
  console.log(` ✅ ${destPath}`)
55
+ filesCreated++
43
56
  return true
44
57
  }
45
58
 
46
59
  function writeFile(destPath, content, force = false) {
47
60
  if (existsSync(destPath) && !force) {
48
61
  console.log(` ⏩ ${destPath} (exists)`)
62
+ filesSkipped++
49
63
  return false
50
64
  }
51
65
  mkdirSync(dirname(destPath), { recursive: true })
52
66
  writeFileSync(destPath, content)
53
67
  console.log(` ✅ ${destPath}`)
68
+ filesCreated++
69
+ return true
70
+ }
71
+
72
+ function addNpmScript(root, scriptName, scriptCmd) {
73
+ const pkgPath = join(root, 'package.json')
74
+ if (!existsSync(pkgPath)) return false
75
+
76
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
77
+ if (pkg.scripts?.[scriptName]) {
78
+ console.log(` ⏩ package.json script "${scriptName}" (exists)`)
79
+ return false
80
+ }
81
+
82
+ pkg.scripts = pkg.scripts || {}
83
+ pkg.scripts[scriptName] = scriptCmd
84
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
85
+ console.log(` ✅ package.json script "${scriptName}" added`)
86
+ filesCreated++
54
87
  return true
55
88
  }
56
89
 
@@ -59,27 +92,39 @@ program
59
92
  .description('Scaffold Tetra golden standard E2E test structure')
60
93
  .option('--force', 'Overwrite existing files')
61
94
  .option('--dir <path>', 'Test directory (default: tests/e2e)', 'tests/e2e')
95
+ .option('--ci-only', 'Only add CI workflow, skip test files')
96
+ .option('--port <port>', 'Backend port (default: 3003)', '3003')
62
97
  .action((options) => {
63
98
  const root = process.cwd()
64
99
  const testDir = join(root, options.dir)
65
100
  const helpersDir = join(testDir, 'helpers')
66
101
  const force = !!options.force
102
+ const port = options.port
67
103
 
68
- console.log('\n Tetra Init Tests — Golden Standard E2E\n')
104
+ console.log('\n Tetra Init Tests — Golden Standard E2E v2\n')
69
105
 
70
- // 1. Helpers
71
- console.log(' Helpers:')
72
- copyTemplate('api-client.ts.tmpl', join(helpersDir, 'api-client.ts'), force)
73
- copyTemplate('test-users.ts.tmpl', join(helpersDir, 'test-users.ts'), force)
106
+ if (!options.ciOnly) {
107
+ // ── 1. Helpers ──
108
+ console.log(' Helpers:')
109
+ copyTemplate('api-client.ts.tmpl', join(helpersDir, 'api-client.ts'), force)
110
+ copyTemplate('test-users.ts.tmpl', join(helpersDir, 'test-users.ts'), force)
74
111
 
75
- // 2. Test files
76
- console.log('\n Test files:')
77
- copyTemplate('01-auth.test.ts.tmpl', join(testDir, '01-auth.test.ts'), force)
78
- copyTemplate('07-security.test.ts.tmpl', join(testDir, '07-security.test.ts'), force)
112
+ // ── 2. Global setup ──
113
+ console.log('\n Setup:')
114
+ copyTemplate('global-setup.ts.tmpl', join(testDir, 'global-setup.ts'), force)
115
+ writeFile(join(testDir, '.gitignore'), '.e2e-tokens.json\n', force)
79
116
 
80
- // 3. Vitest config
81
- console.log('\n Config:')
82
- writeFile(join(root, 'vitest.config.e2e.ts'), `import { defineConfig } from 'vitest/config';
117
+ // ── 3. Test files (complete suite) ──
118
+ console.log('\n Test files:')
119
+ copyTemplate('01-auth.test.ts.tmpl', join(testDir, '01-auth.test.ts'), force)
120
+ copyTemplate('02-crud-resources.test.ts.tmpl', join(testDir, '02-crud-resources.test.ts'), force)
121
+ copyTemplate('03-permissions.test.ts.tmpl', join(testDir, '03-permissions.test.ts'), force)
122
+ copyTemplate('04-business-flows.test.ts.tmpl', join(testDir, '04-business-flows.test.ts'), force)
123
+ copyTemplate('05-security.test.ts.tmpl', join(testDir, '05-security.test.ts'), force)
124
+
125
+ // ── 4. Vitest config ──
126
+ console.log('\n Config:')
127
+ writeFile(join(root, 'vitest.config.e2e.ts'), `import { defineConfig } from 'vitest/config';
83
128
 
84
129
  export default defineConfig({
85
130
  test: {
@@ -88,62 +133,136 @@ export default defineConfig({
88
133
  testTimeout: 30000,
89
134
  hookTimeout: 60000,
90
135
  sequence: { concurrent: false },
136
+ env: {
137
+ E2E_BASE_URL: process.env.E2E_BASE_URL || 'http://localhost:${port}',
138
+ E2E_ADMIN_EMAIL: process.env.E2E_ADMIN_EMAIL || 'e2e-admin@example.com',
139
+ E2E_PASSWORD: process.env.E2E_PASSWORD || 'TestPass1234',
140
+ RATE_LIMIT_BYPASS_KEY: process.env.RATE_LIMIT_BYPASS_KEY || '',
141
+ },
91
142
  },
92
143
  });
93
144
  `, force)
94
145
 
95
- // 4. Global setup stub
96
- writeFile(join(testDir, 'global-setup.ts'), `/**
97
- * Global setup — creates test users via API before all tests.
98
- * CUSTOMIZE: implement signup + invite flow for your project.
99
- *
100
- * Required env vars (set these in your test runner or CI):
101
- * E2E_BASE_URL — Backend URL (e.g. http://localhost:3026)
102
- * RATE_LIMIT_BYPASS_KEY — X-Test-Key value (from .ralph/test-config.yml)
103
- *
104
- * This setup should:
105
- * 1. Create an admin via signup
106
- * 2. Create a shared resource (track/project/workspace)
107
- * 3. Invite an elevated user (coach/manager)
108
- * 4. Invite a basic user (client/member)
109
- * 5. Store IDs/emails in process.env for test-users.ts
110
- */
146
+ // ── 5. npm scripts ──
147
+ console.log('\n Scripts:')
148
+ addNpmScript(root, 'test:e2e', `E2E_BASE_URL=http://localhost:${port} vitest run --config vitest.config.e2e.ts`)
149
+ addNpmScript(root, 'test:e2e:watch', `E2E_BASE_URL=http://localhost:${port} vitest --config vitest.config.e2e.ts`)
150
+ }
111
151
 
112
- const BASE_URL = process.env.E2E_BASE_URL!;
113
- const PASSWORD = 'TestPass1234';
152
+ // ── 6. CI workflow ──
153
+ console.log('\n CI:')
154
+ const ciPath = join(root, '.github', 'workflows', 'ci.yml')
155
+ if (existsSync(ciPath)) {
156
+ // Check if api-tests job already exists
157
+ const ciContent = readFileSync(ciPath, 'utf-8')
158
+ if (ciContent.includes('api-tests')) {
159
+ console.log(' ⏩ .github/workflows/ci.yml (api-tests job exists)')
160
+ } else {
161
+ console.log(' ⚠️ .github/workflows/ci.yml exists but has NO api-tests job')
162
+ console.log(' Add an api-tests job that runs: npx vitest run --config vitest.config.e2e.ts')
163
+ console.log(' See: tetra-init-tests --ci-only --force')
164
+ }
165
+ } else {
166
+ writeFile(ciPath, `name: CI
114
167
 
115
- export async function setup() {
116
- // TODO: Implement for your project
117
- // See CoachHub's global-setup.ts as reference
118
- console.log('\\n ⚠️ global-setup.ts is a stub — implement for your project\\n');
119
- }
168
+ on:
169
+ push:
170
+ branches: [main, develop]
171
+ pull_request:
172
+ branches: [main, develop]
120
173
 
121
- export async function teardown() {
122
- // Best-effort cleanup
123
- }
174
+ jobs:
175
+ typecheck:
176
+ runs-on: ubuntu-latest
177
+ steps:
178
+ - uses: actions/checkout@v4
179
+ - uses: actions/setup-node@v4
180
+ with:
181
+ node-version: 20
182
+ cache: npm
183
+ - run: npm ci
184
+ - run: cd backend && npx tsc --noEmit
185
+
186
+ frontend-build:
187
+ runs-on: ubuntu-latest
188
+ steps:
189
+ - uses: actions/checkout@v4
190
+ - uses: actions/setup-node@v4
191
+ with:
192
+ node-version: 20
193
+ cache: npm
194
+ - run: npm ci
195
+ - run: cd frontend && npx next build
196
+ env:
197
+ NEXT_PUBLIC_API_URL: http://localhost:${port}
198
+ NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co
199
+ NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder
200
+
201
+ api-tests:
202
+ runs-on: ubuntu-latest
203
+ needs: typecheck
204
+ env:
205
+ SUPABASE_URL: \${{ secrets.E2E_SUPABASE_URL }}
206
+ SUPABASE_ANON_KEY: \${{ secrets.E2E_SUPABASE_ANON_KEY }}
207
+ SUPABASE_SERVICE_ROLE_KEY: \${{ secrets.E2E_SUPABASE_SERVICE_ROLE_KEY }}
208
+ SUPABASE_JWT_SECRET: \${{ secrets.E2E_SUPABASE_JWT_SECRET }}
209
+ E2E_ADMIN_EMAIL: \${{ secrets.E2E_ADMIN_EMAIL }}
210
+ E2E_PASSWORD: \${{ secrets.E2E_PASSWORD }}
211
+ steps:
212
+ - uses: actions/checkout@v4
213
+ - uses: actions/setup-node@v4
214
+ with:
215
+ node-version: 20
216
+ cache: npm
217
+ - run: npm ci
218
+ - name: Start backend
219
+ run: |
220
+ cd backend && npx tsx src/index.ts &
221
+ for i in $(seq 1 30); do
222
+ curl -sf http://localhost:${port}/api/health > /dev/null 2>&1 && break
223
+ sleep 2
224
+ done
225
+ curl -sf http://localhost:${port}/api/health || exit 1
226
+ env:
227
+ NODE_ENV: test
228
+ PORT: ${port}
229
+ - name: Run API e2e tests
230
+ run: npx vitest run --config vitest.config.e2e.ts
231
+ env:
232
+ E2E_BASE_URL: http://localhost:${port}
124
233
  `, force)
234
+ }
125
235
 
126
- // Summary
236
+ // ── 7. Post-deploy workflow ──
237
+ const postDeployPath = join(root, '.github', 'workflows', 'post-deploy-tests.yml')
238
+ if (!existsSync(postDeployPath)) {
239
+ console.log(' 💡 No post-deploy-tests.yml found')
240
+ console.log(' Create one with: tetra-smoke --url https://your-app.railway.app')
241
+ }
242
+
243
+ // ── Summary ──
127
244
  console.log(`
128
- Done! Next steps:
129
-
130
- 1. Implement global-setup.ts (create test users via your API)
131
- 2. Set E2E_BASE_URL and RATE_LIMIT_BYPASS_KEY in your env
132
- 3. Customize 07-security.test.ts with your protected routes
133
- 4. Add project-specific test files (02-*.test.ts, 03-*.test.ts, etc.)
134
- 5. Run: npx vitest run --config vitest.config.e2e.ts
135
-
136
- Golden standard pattern:
137
- 01-auth login, session, refresh, logout (universal)
138
- 02-[resource] — main resource CRUD + permissions
139
- 03-[nested] — nested resources (messages, notes, etc.)
140
- 04-admin — admin-only features
141
- 05-[feature] — feature-specific tests
142
- 06-planner — scheduling (if using Tetra Planner)
143
- 07-security — auth walls + role access (universal)
144
- 08-permissions — permission matrix (generatable)
145
- 09-isolation — cross-role data isolation
146
- 10+ — additional features
245
+ ─────────────────────────────────────────
246
+ Created: ${filesCreated} files | Skipped: ${filesSkipped} (already exist)
247
+ ─────────────────────────────────────────
248
+
249
+ Next steps:
250
+
251
+ 1. Edit global-setup.ts set your test user email/password
252
+ 2. Edit 02-crud-resources.test.ts — add your FeatureConfig resources
253
+ 3. Edit 03-permissions.test.ts — add all protected routes
254
+ 4. Edit 04-business-flows.test.ts add your business flows
255
+ 5. Run: npm run test:e2e
256
+
257
+ Then run tetra-test-audit to see what's NOT covered yet.
258
+
259
+ Golden standard layers:
260
+ 01-auth Auth: login, session, refresh (universal)
261
+ 02-crud-resources CRUD for every resource (from FeatureConfig)
262
+ 03-permissions Auth walls for every route (auto-auditable)
263
+ 04-business-flows End-to-end user journeys
264
+ 05-security Input validation, injection, error format
265
+ 06+ Project-specific features
147
266
  `)
148
267
  })
149
268
 
package/bin/tetra-init.js CHANGED
@@ -430,6 +430,66 @@ async function initQuality(config, options) {
430
430
  )
431
431
  }
432
432
 
433
+ async function initCi(config, options) {
434
+ console.log('')
435
+ console.log('🔄 Initializing CI workflow...')
436
+
437
+ const workflowDir = join(projectRoot, '.github/workflows')
438
+ if (!existsSync(workflowDir)) {
439
+ mkdirSync(workflowDir, { recursive: true })
440
+ }
441
+
442
+ // Detect project structure
443
+ const hasBackend = existsSync(join(projectRoot, 'backend/package.json'))
444
+ const hasFrontend = existsSync(join(projectRoot, 'frontend/package.json'))
445
+ const hasMigrations = existsSync(join(projectRoot, 'backend/supabase/migrations')) ||
446
+ existsSync(join(projectRoot, 'supabase/migrations'))
447
+
448
+ let workspaces
449
+ if (hasBackend && hasFrontend) {
450
+ workspaces = 'backend,frontend'
451
+ } else if (hasBackend) {
452
+ workspaces = 'backend'
453
+ } else {
454
+ workspaces = '.'
455
+ }
456
+
457
+ let withLines = [` workspaces: "${workspaces}"`]
458
+ if (!hasFrontend) withLines.push(' skip-frontend: true')
459
+ if (!hasMigrations) withLines.push(' skip-migration-lint: true')
460
+
461
+ const workflowContent = `name: PR Quality Gate
462
+
463
+ on:
464
+ push:
465
+ branches: [main]
466
+ paths-ignore:
467
+ - '*.md'
468
+ - 'docs/**'
469
+ - '.ralph/**'
470
+ - 'LICENSE'
471
+ pull_request:
472
+ branches: [main]
473
+ paths-ignore:
474
+ - '*.md'
475
+ - 'docs/**'
476
+ - '.ralph/**'
477
+ - 'LICENSE'
478
+
479
+ concurrency:
480
+ group: quality-\${{ github.ref }}
481
+ cancel-in-progress: true
482
+
483
+ jobs:
484
+ quality:
485
+ uses: mralbertzwolle/tetra/.github/workflows/pr-quality.yml@main
486
+ with:
487
+ ${withLines.join('\n')}
488
+ `
489
+
490
+ writeIfMissing(join(workflowDir, 'quality.yml'), workflowContent, options)
491
+ }
492
+
433
493
  function checkCompleteness() {
434
494
  console.log('')
435
495
  console.log('🔎 Checking project completeness...')
@@ -441,6 +501,7 @@ function checkCompleteness() {
441
501
  { path: 'doppler.yaml', category: 'root', required: true },
442
502
  { path: 'CLAUDE.md', category: 'root', required: true },
443
503
  { path: '.tetra-quality.json', category: 'root', required: false },
504
+ { path: '.github/workflows/quality.yml', category: 'root', required: true },
444
505
  { path: '.gitignore', category: 'root', required: true },
445
506
 
446
507
  // .ralph/ files
@@ -524,7 +585,7 @@ program
524
585
  .name('tetra-init')
525
586
  .description('Initialize a Tetra project with all required config files')
526
587
  .version('1.0.0')
527
- .argument('[component]', 'Component to init: ralph, quality, check, or all (default)')
588
+ .argument('[component]', 'Component to init: ralph, quality, ci, check, or all (default)')
528
589
  .option('-n, --name <name>', 'Project name (skips interactive prompt)')
529
590
  .option('-d, --description <desc>', 'Project description')
530
591
  .option('--backend-port <port>', 'Backend port number', parseInt)
@@ -573,7 +634,7 @@ program
573
634
  console.log(` Frontend: localhost:${config.frontendPort}`)
574
635
 
575
636
  const components = component === 'all' || !component
576
- ? ['ralph', 'quality']
637
+ ? ['ralph', 'quality', 'ci']
577
638
  : [component]
578
639
 
579
640
  for (const comp of components) {
@@ -584,9 +645,12 @@ program
584
645
  case 'quality':
585
646
  await initQuality(config, options)
586
647
  break
648
+ case 'ci':
649
+ await initCi(config, options)
650
+ break
587
651
  default:
588
652
  console.log(`Unknown component: ${comp}`)
589
- console.log('Available: ralph, quality, check, or all (default)')
653
+ console.log('Available: ralph, quality, ci, check, or all (default)')
590
654
  }
591
655
  }
592
656
 
@@ -252,6 +252,108 @@ fi
252
252
  writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n')
253
253
  console.log(' ✅ Added "prepare": "husky" to package.json')
254
254
  }
255
+
256
+ // Install Claude Code global hooks (worktree-guard, doppler-guard)
257
+ await setupClaudeHooks(options)
258
+ }
259
+
260
+
261
+ async function setupClaudeHooks(options) {
262
+ console.log('')
263
+ console.log('🔒 Setting up Claude Code global hooks...')
264
+
265
+ const homeDir = process.env.HOME || process.env.USERPROFILE
266
+ if (!homeDir) {
267
+ console.log(' ⚠️ Cannot detect home directory — skipping Claude hooks')
268
+ return
269
+ }
270
+
271
+ const claudeHooksDir = join(homeDir, '.claude', 'hooks')
272
+ if (!existsSync(join(homeDir, '.claude'))) {
273
+ console.log(' ⏭️ No ~/.claude directory — Claude Code not installed, skipping')
274
+ return
275
+ }
276
+
277
+ if (!existsSync(claudeHooksDir)) {
278
+ mkdirSync(claudeHooksDir, { recursive: true })
279
+ }
280
+
281
+ // Find our template hooks
282
+ const templatesDir = join(import.meta.dirname || __dirname, '..', 'lib', 'templates', 'hooks')
283
+ if (!existsSync(templatesDir)) {
284
+ console.log(' ⚠️ Hook templates not found — skipping')
285
+ return
286
+ }
287
+
288
+ // Copy hook scripts
289
+ const hooks = ['worktree-guard.sh', 'doppler-guard.sh']
290
+ for (const hookFile of hooks) {
291
+ const src = join(templatesDir, hookFile)
292
+ const dest = join(claudeHooksDir, hookFile)
293
+ if (!existsSync(src)) continue
294
+
295
+ if (!existsSync(dest) || options.force) {
296
+ const content = readFileSync(src, 'utf-8')
297
+ writeFileSync(dest, content)
298
+ try { execSync(`chmod +x ${dest}`) } catch {}
299
+ console.log(` ✅ Installed ~/.claude/hooks/${hookFile}`)
300
+ } else {
301
+ console.log(` ⏭️ ~/.claude/hooks/${hookFile} already exists`)
302
+ }
303
+ }
304
+
305
+ // Register hooks in ~/.claude/settings.json
306
+ const settingsPath = join(homeDir, '.claude', 'settings.json')
307
+ let settings = {}
308
+ if (existsSync(settingsPath)) {
309
+ try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) } catch {}
310
+ }
311
+
312
+ settings.hooks = settings.hooks || {}
313
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse || []
314
+
315
+ // Check if worktree-guard is already registered
316
+ const hasWorktreeGuard = settings.hooks.PreToolUse.some(h =>
317
+ JSON.stringify(h).includes('worktree-guard')
318
+ )
319
+
320
+ if (!hasWorktreeGuard) {
321
+ // Insert at the beginning so it runs first
322
+ settings.hooks.PreToolUse.unshift({
323
+ matcher: 'Edit|Write|Bash',
324
+ hooks: [{
325
+ type: 'command',
326
+ command: '~/.claude/hooks/worktree-guard.sh',
327
+ timeout: 5
328
+ }]
329
+ })
330
+ console.log(' ✅ Registered worktree-guard in ~/.claude/settings.json')
331
+ } else {
332
+ console.log(' ⏭️ worktree-guard already registered')
333
+ }
334
+
335
+ // Check if doppler-guard is already registered
336
+ const hasDopplerGuard = settings.hooks.PreToolUse.some(h =>
337
+ JSON.stringify(h).includes('doppler-guard')
338
+ )
339
+
340
+ if (!hasDopplerGuard) {
341
+ settings.hooks.PreToolUse.push({
342
+ matcher: 'Edit|Write',
343
+ hooks: [{
344
+ type: 'command',
345
+ command: '~/.claude/hooks/doppler-guard.sh',
346
+ timeout: 5
347
+ }]
348
+ })
349
+ console.log(' ✅ Registered doppler-guard in ~/.claude/settings.json')
350
+ } else {
351
+ console.log(' ⏭️ doppler-guard already registered')
352
+ }
353
+
354
+ if (!hasWorktreeGuard || !hasDopplerGuard) {
355
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n')
356
+ }
255
357
  }
256
358
 
257
359
  async function setupCi(options) {
@@ -262,47 +364,66 @@ async function setupCi(options) {
262
364
  mkdirSync(workflowDir, { recursive: true })
263
365
  }
264
366
 
367
+ // Detect project structure
368
+ const hasBackend = existsSync(join(projectRoot, 'backend/package.json'))
369
+ const hasFrontend = existsSync(join(projectRoot, 'frontend/package.json'))
370
+ const hasMigrations = existsSync(join(projectRoot, 'backend/supabase/migrations')) ||
371
+ existsSync(join(projectRoot, 'supabase/migrations'))
372
+
373
+ // Determine workspaces
374
+ let workspaces
375
+ if (hasBackend && hasFrontend) {
376
+ workspaces = 'backend,frontend'
377
+ } else if (hasBackend) {
378
+ workspaces = 'backend'
379
+ } else {
380
+ workspaces = '.'
381
+ }
382
+
383
+ const skipFrontend = !hasFrontend
384
+ const skipMigrationLint = !hasMigrations
385
+
386
+ // Build with block for inputs
387
+ let withBlock = ` workspaces: "${workspaces}"`
388
+ if (skipFrontend) {
389
+ withBlock += `\n skip-frontend: true`
390
+ }
391
+ if (skipMigrationLint) {
392
+ withBlock += `\n skip-migration-lint: true`
393
+ }
394
+
265
395
  const workflowPath = join(workflowDir, 'quality.yml')
266
396
  if (!existsSync(workflowPath) || options.force) {
267
- const workflowContent = `name: Quality Checks
397
+ const workflowContent = `name: PR Quality Gate
268
398
 
269
399
  on:
270
400
  push:
271
- branches: [main, master]
401
+ branches: [main]
402
+ paths-ignore:
403
+ - '*.md'
404
+ - 'docs/**'
405
+ - '.ralph/**'
406
+ - 'LICENSE'
272
407
  pull_request:
273
- branches: [main, master]
408
+ branches: [main]
409
+ paths-ignore:
410
+ - '*.md'
411
+ - 'docs/**'
412
+ - '.ralph/**'
413
+ - 'LICENSE'
414
+
415
+ concurrency:
416
+ group: quality-\${{ github.ref }}
417
+ cancel-in-progress: true
274
418
 
275
419
  jobs:
276
420
  quality:
277
- name: 🔍 Tetra Quality Audit
278
- runs-on: ubuntu-latest
279
-
280
- steps:
281
- - name: Checkout code
282
- uses: actions/checkout@v4
283
-
284
- - name: Setup Node.js
285
- uses: actions/setup-node@v4
286
- with:
287
- node-version: '20'
288
- cache: 'npm'
289
-
290
- - name: Install dependencies
291
- run: npm ci
292
-
293
- - name: Run Tetra Quality Audit
294
- run: npx tetra-audit --ci
295
-
296
- - name: Upload results
297
- if: always()
298
- uses: actions/upload-artifact@v4
299
- with:
300
- name: quality-report
301
- path: quality-report.json
302
- retention-days: 7
421
+ uses: mralbertzwolle/tetra/.github/workflows/pr-quality.yml@main
422
+ with:
423
+ ${withBlock}
303
424
  `
304
425
  writeFileSync(workflowPath, workflowContent)
305
- console.log(' ✅ Created .github/workflows/quality.yml')
426
+ console.log(` ✅ Created .github/workflows/quality.yml (${workspaces}, frontend=${!skipFrontend}, migrations=${!skipMigrationLint})`)
306
427
  } else {
307
428
  console.log(' ⏭️ Workflow already exists (use --force to overwrite)')
308
429
  }
@@ -1038,3 +1159,4 @@ jobs:
1038
1159
  }
1039
1160
 
1040
1161
  program.parse()
1162
+
File without changes