@soulbatical/tetra-dev-toolkit 1.20.12 → 1.20.14
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-setup.js +103 -0
- package/bin/tetra-setup.js.tmp +0 -0
- package/lib/audits/test-coverage-audit.js +24 -3
- package/lib/checks/health/index.js +2 -1
- package/lib/checks/health/scanner.js +4 -2
- package/lib/checks/health/sentry-monitoring.js +189 -0
- package/lib/checks/health/types.js +1 -1
- package/lib/templates/hooks/doppler-guard.sh +30 -0
- package/lib/templates/hooks/worktree-guard.sh +75 -0
- package/package.json +1 -1
package/bin/tetra-setup.js
CHANGED
|
@@ -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) {
|
|
@@ -1057,3 +1159,4 @@ jobs:
|
|
|
1057
1159
|
}
|
|
1058
1160
|
|
|
1059
1161
|
program.parse()
|
|
1162
|
+
|
|
File without changes
|
|
@@ -107,8 +107,16 @@ export async function scanRoutes(projectRoot) {
|
|
|
107
107
|
* Scan frontend pages by finding all page.tsx files.
|
|
108
108
|
*/
|
|
109
109
|
export async function scanFrontendPages(projectRoot) {
|
|
110
|
-
const
|
|
111
|
-
|
|
110
|
+
const frontendPatterns = [
|
|
111
|
+
'frontend/src/app/**/page.tsx',
|
|
112
|
+
'frontend-user/src/app/**/page.tsx',
|
|
113
|
+
'frontend-sparkbuddy/src/app/**/page.tsx',
|
|
114
|
+
]
|
|
115
|
+
const files = []
|
|
116
|
+
for (const pattern of frontendPatterns) {
|
|
117
|
+
const found = await glob(pattern, { cwd: projectRoot })
|
|
118
|
+
files.push(...found)
|
|
119
|
+
}
|
|
112
120
|
|
|
113
121
|
const pages = []
|
|
114
122
|
for (const file of files) {
|
|
@@ -137,8 +145,14 @@ export async function scanFrontendPages(projectRoot) {
|
|
|
137
145
|
export async function scanTestFiles(projectRoot) {
|
|
138
146
|
const patterns = [
|
|
139
147
|
'tests/e2e/**/*.test.ts',
|
|
140
|
-
'frontend/e2e/**/*.spec.ts',
|
|
141
148
|
'tests/**/*.test.ts',
|
|
149
|
+
'frontend/e2e/**/*.spec.ts',
|
|
150
|
+
'frontend-user/tests/**/*.spec.ts',
|
|
151
|
+
'frontend-user/tests/**/*.test.ts',
|
|
152
|
+
'frontend-sparkbuddy/tests/**/*.spec.ts',
|
|
153
|
+
'backend/tests/**/*.test.ts',
|
|
154
|
+
'frontend-user/tests/fixtures/**/*.ts',
|
|
155
|
+
'frontend/e2e/fixtures.ts',
|
|
142
156
|
]
|
|
143
157
|
|
|
144
158
|
const allFiles = []
|
|
@@ -180,6 +194,13 @@ export async function scanTestFiles(projectRoot) {
|
|
|
180
194
|
if (basePath) pageRefs.add(basePath)
|
|
181
195
|
}
|
|
182
196
|
|
|
197
|
+
// Extract path definitions from fixture files (e.g. { path: '/nl/user' })
|
|
198
|
+
const pathDefRegex2 = /path:\s*['"]([^'"]+)['"]/g
|
|
199
|
+
let pathDefMatch2
|
|
200
|
+
while ((pathDefMatch2 = pathDefRegex2.exec(content)) !== null) {
|
|
201
|
+
pageRefs.add(pathDefMatch2[1])
|
|
202
|
+
}
|
|
203
|
+
|
|
183
204
|
// Detect auth wall tests
|
|
184
205
|
const hasAuthTests = /(?:toBe|toEqual|status)\s*\(\s*401\s*\)/.test(content) ||
|
|
185
206
|
/expect\(.*status.*\).*401/.test(content) ||
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Health Checks — All
|
|
2
|
+
* Health Checks — All 29 project health checks
|
|
3
3
|
*
|
|
4
4
|
* Main entry: scanProjectHealth(projectPath, projectName, options?)
|
|
5
5
|
* Individual checks available via named imports.
|
|
@@ -41,3 +41,4 @@ export { check as checkSecurityLayers } from './security-layers.js'
|
|
|
41
41
|
export { check as checkSmokeReadiness } from './smoke-readiness.js'
|
|
42
42
|
export { check as checkReleasePipeline } from './release-pipeline.js'
|
|
43
43
|
export { check as checkTestStructure } from './test-structure.js'
|
|
44
|
+
export { check as checkSentryMonitoring } from './sentry-monitoring.js'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Project Health Scanner
|
|
3
3
|
*
|
|
4
|
-
* Orchestrates all
|
|
4
|
+
* Orchestrates all 29 health checks and produces a HealthReport.
|
|
5
5
|
* This is the main entry point — consumers call scanProjectHealth().
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -37,6 +37,7 @@ import { check as checkSecurityLayers } from './security-layers.js'
|
|
|
37
37
|
import { check as checkSmokeReadiness } from './smoke-readiness.js'
|
|
38
38
|
import { check as checkReleasePipeline } from './release-pipeline.js'
|
|
39
39
|
import { check as checkTestStructure } from './test-structure.js'
|
|
40
|
+
import { check as checkSentryMonitoring } from './sentry-monitoring.js'
|
|
40
41
|
import { calculateHealthStatus } from './types.js'
|
|
41
42
|
|
|
42
43
|
/**
|
|
@@ -82,7 +83,8 @@ export async function scanProjectHealth(projectPath, projectName, options = {})
|
|
|
82
83
|
checkSecurityLayers(projectPath),
|
|
83
84
|
checkSmokeReadiness(projectPath),
|
|
84
85
|
checkReleasePipeline(projectPath),
|
|
85
|
-
checkTestStructure(projectPath)
|
|
86
|
+
checkTestStructure(projectPath),
|
|
87
|
+
checkSentryMonitoring(projectPath)
|
|
86
88
|
])
|
|
87
89
|
|
|
88
90
|
const totalScore = checks.reduce((sum, c) => sum + c.score, 0)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Check: Sentry Error Monitoring
|
|
3
|
+
*
|
|
4
|
+
* Checks: @sentry/node in backend, @sentry/nextjs or @sentry/react in frontend,
|
|
5
|
+
* instrument.ts exists, SENTRY_DSN referenced via env var (not hardcoded).
|
|
6
|
+
* Score: 0-3 (backend setup, frontend setup, no hardcoded DSN)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync } from 'fs'
|
|
10
|
+
import { join } from 'path'
|
|
11
|
+
import { createCheck } from './types.js'
|
|
12
|
+
|
|
13
|
+
export async function check(projectPath) {
|
|
14
|
+
const result = createCheck('sentry-monitoring', 3, {
|
|
15
|
+
backend: { hasSentryDep: false, hasInstrumentFile: false, importedFirst: false },
|
|
16
|
+
frontend: { hasSentryDep: false, sentryPackage: null },
|
|
17
|
+
hardcodedDsn: false,
|
|
18
|
+
dsnFiles: []
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// --- Check 1: Backend Sentry setup (0-1 point) ---
|
|
22
|
+
const backendPkgPath = join(projectPath, 'backend', 'package.json')
|
|
23
|
+
if (existsSync(backendPkgPath)) {
|
|
24
|
+
try {
|
|
25
|
+
const pkg = JSON.parse(readFileSync(backendPkgPath, 'utf-8'))
|
|
26
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
27
|
+
if (allDeps['@sentry/node']) {
|
|
28
|
+
result.details.backend.hasSentryDep = true
|
|
29
|
+
}
|
|
30
|
+
} catch { /* ignore */ }
|
|
31
|
+
|
|
32
|
+
// Check instrument.ts or instrument.js
|
|
33
|
+
for (const ext of ['ts', 'js']) {
|
|
34
|
+
const instrumentPath = join(projectPath, 'backend', 'src', `instrument.${ext}`)
|
|
35
|
+
if (existsSync(instrumentPath)) {
|
|
36
|
+
result.details.backend.hasInstrumentFile = true
|
|
37
|
+
|
|
38
|
+
// Check if it's imported first in index.ts/index.js/server.ts
|
|
39
|
+
for (const entryName of ['index', 'server']) {
|
|
40
|
+
for (const indexExt of ['ts', 'js']) {
|
|
41
|
+
const indexPath = join(projectPath, 'backend', 'src', `${entryName}.${indexExt}`)
|
|
42
|
+
if (existsSync(indexPath)) {
|
|
43
|
+
try {
|
|
44
|
+
const content = readFileSync(indexPath, 'utf-8')
|
|
45
|
+
const lines = content.split('\n').filter(l => l.trim() && !l.trim().startsWith('//') && !l.trim().startsWith('*') && !l.trim().startsWith('/**'))
|
|
46
|
+
const firstImport = lines.find(l => l.includes('import '))
|
|
47
|
+
if (firstImport && firstImport.includes('instrument')) {
|
|
48
|
+
result.details.backend.importedFirst = true
|
|
49
|
+
}
|
|
50
|
+
} catch { /* ignore */ }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
break
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Check for Tetra createApp pattern — Sentry is auto-configured via SENTRY_DSN env var
|
|
59
|
+
if (!result.details.backend.hasInstrumentFile) {
|
|
60
|
+
for (const entryFile of ['index', 'server', 'app']) {
|
|
61
|
+
for (const ext of ['ts', 'js']) {
|
|
62
|
+
const filePath = join(projectPath, 'backend', 'src', `${entryFile}.${ext}`)
|
|
63
|
+
if (existsSync(filePath)) {
|
|
64
|
+
try {
|
|
65
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
66
|
+
if (content.includes('createApp')) {
|
|
67
|
+
result.details.backend.usesCreateApp = true
|
|
68
|
+
result.details.backend.hasInstrumentFile = true
|
|
69
|
+
result.details.backend.importedFirst = true
|
|
70
|
+
}
|
|
71
|
+
} catch { /* ignore */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (result.details.backend.usesCreateApp) break
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (result.details.backend.hasSentryDep && result.details.backend.hasInstrumentFile) {
|
|
79
|
+
result.score += 1
|
|
80
|
+
} else if (result.details.backend.hasSentryDep) {
|
|
81
|
+
result.score += 0.5
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
// No backend = skip this point, adjust maxScore
|
|
85
|
+
result.maxScore -= 1
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- Check 2: Frontend Sentry setup (0-1 point) ---
|
|
89
|
+
let hasAnyFrontend = false
|
|
90
|
+
const frontendDirs = ['frontend']
|
|
91
|
+
// Check for multi-frontend setups
|
|
92
|
+
try {
|
|
93
|
+
const { readdirSync } = await import('fs')
|
|
94
|
+
for (const entry of readdirSync(projectPath)) {
|
|
95
|
+
if (entry.startsWith('frontend-') && existsSync(join(projectPath, entry, 'package.json'))) {
|
|
96
|
+
frontendDirs.push(entry)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch { /* ignore */ }
|
|
100
|
+
|
|
101
|
+
for (const feDir of frontendDirs) {
|
|
102
|
+
const fePkgPath = join(projectPath, feDir, 'package.json')
|
|
103
|
+
if (!existsSync(fePkgPath)) continue
|
|
104
|
+
hasAnyFrontend = true
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const pkg = JSON.parse(readFileSync(fePkgPath, 'utf-8'))
|
|
108
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
109
|
+
if (allDeps['@sentry/nextjs']) {
|
|
110
|
+
result.details.frontend.hasSentryDep = true
|
|
111
|
+
result.details.frontend.sentryPackage = '@sentry/nextjs'
|
|
112
|
+
} else if (allDeps['@sentry/react']) {
|
|
113
|
+
result.details.frontend.hasSentryDep = true
|
|
114
|
+
result.details.frontend.sentryPackage = '@sentry/react'
|
|
115
|
+
}
|
|
116
|
+
} catch { /* ignore */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!hasAnyFrontend) {
|
|
120
|
+
result.maxScore -= 1
|
|
121
|
+
} else if (result.details.frontend.hasSentryDep) {
|
|
122
|
+
result.score += 1
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- Check 3: No hardcoded DSN (0-1 point) ---
|
|
126
|
+
// Scan for hardcoded Sentry DSN patterns in src files
|
|
127
|
+
const DSN_PATTERN = /https:\/\/[a-f0-9]{32}@[a-z0-9.]+\.sentry\.io\/\d+/
|
|
128
|
+
const filesToCheck = []
|
|
129
|
+
|
|
130
|
+
function collectFiles(dir, depth = 0) {
|
|
131
|
+
if (depth > 3) return
|
|
132
|
+
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage'])
|
|
133
|
+
try {
|
|
134
|
+
const { readdirSync, statSync } = require('fs')
|
|
135
|
+
for (const entry of readdirSync(dir)) {
|
|
136
|
+
if (SKIP.has(entry)) continue
|
|
137
|
+
const full = join(dir, entry)
|
|
138
|
+
try {
|
|
139
|
+
const stat = statSync(full)
|
|
140
|
+
if (stat.isDirectory()) collectFiles(full, depth + 1)
|
|
141
|
+
else if (/\.(ts|js|tsx|jsx)$/.test(entry) && !entry.endsWith('.d.ts')) {
|
|
142
|
+
filesToCheck.push(full)
|
|
143
|
+
}
|
|
144
|
+
} catch { /* ignore */ }
|
|
145
|
+
}
|
|
146
|
+
} catch { /* ignore */ }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const dir of ['backend/src', 'frontend/src']) {
|
|
150
|
+
const fullDir = join(projectPath, dir)
|
|
151
|
+
if (existsSync(fullDir)) collectFiles(fullDir)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const file of filesToCheck) {
|
|
155
|
+
try {
|
|
156
|
+
const content = readFileSync(file, 'utf-8')
|
|
157
|
+
if (DSN_PATTERN.test(content)) {
|
|
158
|
+
result.details.hardcodedDsn = true
|
|
159
|
+
const relPath = file.replace(projectPath + '/', '')
|
|
160
|
+
result.details.dsnFiles.push(relPath)
|
|
161
|
+
}
|
|
162
|
+
} catch { /* ignore */ }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!result.details.hardcodedDsn) {
|
|
166
|
+
result.score += 1
|
|
167
|
+
} else {
|
|
168
|
+
result.status = 'warning'
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Finalize
|
|
172
|
+
result.score = Math.min(result.score, result.maxScore)
|
|
173
|
+
|
|
174
|
+
if (result.maxScore > 0 && result.score === 0) {
|
|
175
|
+
result.status = 'warning'
|
|
176
|
+
result.details.message = 'No Sentry error monitoring configured'
|
|
177
|
+
} else if (result.score < result.maxScore) {
|
|
178
|
+
result.status = 'warning'
|
|
179
|
+
const issues = []
|
|
180
|
+
if (!result.details.backend.hasSentryDep) issues.push('no @sentry/node in backend')
|
|
181
|
+
if (result.details.backend.hasSentryDep && !result.details.backend.hasInstrumentFile) issues.push('missing instrument.ts')
|
|
182
|
+
if (hasAnyFrontend && !result.details.frontend.hasSentryDep) issues.push('no Sentry in frontend')
|
|
183
|
+
if (result.details.hardcodedDsn) issues.push(`hardcoded DSN in ${result.details.dsnFiles.join(', ')}`)
|
|
184
|
+
if (!result.details.backend.importedFirst && result.details.backend.hasInstrumentFile) issues.push('instrument.ts not imported first')
|
|
185
|
+
result.details.message = issues.join(', ')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return result
|
|
189
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* @typedef {'plugins'|'mcps'|'git'|'tests'|'secrets'|'quality-toolkit'|'naming-conventions'|'rls-audit'|'rpc-param-mismatch'|'typescript-strict'|'prettier'|'coverage-thresholds'|'eslint-security'|'dependency-cruiser'|'conventional-commits'|'knip'|'dependency-automation'|'license-audit'|'sast'|'bundle-size'|'gitignore'|'repo-visibility'|'vincifox-widget'|'stella-integration'|'claude-md'|'doppler-compliance'|'infrastructure-yml'|'file-organization'|'security-layers'|'smoke-readiness'|'release-pipeline'} HealthCheckType
|
|
8
|
+
* @typedef {'plugins'|'mcps'|'git'|'tests'|'secrets'|'quality-toolkit'|'naming-conventions'|'rls-audit'|'rpc-param-mismatch'|'typescript-strict'|'prettier'|'coverage-thresholds'|'eslint-security'|'dependency-cruiser'|'conventional-commits'|'knip'|'dependency-automation'|'license-audit'|'sast'|'bundle-size'|'gitignore'|'repo-visibility'|'vincifox-widget'|'stella-integration'|'claude-md'|'doppler-compliance'|'infrastructure-yml'|'file-organization'|'security-layers'|'smoke-readiness'|'release-pipeline'|'sentry-monitoring'} HealthCheckType
|
|
9
9
|
*
|
|
10
10
|
* @typedef {'ok'|'warning'|'error'} HealthStatus
|
|
11
11
|
*
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# doppler-guard.sh — PreToolUse hook
|
|
3
|
+
# Blocks creating/editing .env files with secrets.
|
|
4
|
+
# Installed by: tetra-setup hooks
|
|
5
|
+
|
|
6
|
+
INPUT=$(cat)
|
|
7
|
+
|
|
8
|
+
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
|
|
9
|
+
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
|
|
10
|
+
|
|
11
|
+
if [[ "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "Write" ]]; then
|
|
12
|
+
exit 0
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
[[ -z "$FILE_PATH" ]] && exit 0
|
|
16
|
+
|
|
17
|
+
BASENAME=$(basename "$FILE_PATH")
|
|
18
|
+
|
|
19
|
+
[[ "$BASENAME" != *".env"* ]] && exit 0
|
|
20
|
+
|
|
21
|
+
# Allowed: .env.example, .env.local, frontend/.env
|
|
22
|
+
[[ "$BASENAME" == ".env.example" ]] && exit 0
|
|
23
|
+
[[ "$BASENAME" == ".env.local" ]] && exit 0
|
|
24
|
+
[[ "$FILE_PATH" == *"frontend/.env" && "$BASENAME" == ".env" ]] && exit 0
|
|
25
|
+
|
|
26
|
+
echo '{
|
|
27
|
+
"decision": "block",
|
|
28
|
+
"reason": "DOPPLER GUARD: .env files with secrets are NOT allowed.\n\nUse Doppler for secret management — no .env files on disk.\n\n- Add secrets: doppler secrets set KEY=value\n- Start server: doppler run -- npm run dev\n\nAllowed exceptions:\n .env.example (template)\n .env.local (machine-specific ports)\n frontend/.env (public VITE_*/NEXT_PUBLIC_* keys)"
|
|
29
|
+
}'
|
|
30
|
+
exit 2
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# ╔══════════════════════════════════════════════════════════════════╗
|
|
3
|
+
# ║ WORKTREE GUARD — Block concurrent edits on shared repos ║
|
|
4
|
+
# ║ Installed by: tetra-setup hooks ║
|
|
5
|
+
# ║ ║
|
|
6
|
+
# ║ PreToolUse hook for Write/Edit/Bash ║
|
|
7
|
+
# ║ Blocks file mutations when multiple Claude sessions work on ║
|
|
8
|
+
# ║ the same repo WITHOUT worktree isolation. ║
|
|
9
|
+
# ╚══════════════════════════════════════════════════════════════════╝
|
|
10
|
+
|
|
11
|
+
INPUT=$(cat)
|
|
12
|
+
|
|
13
|
+
TOOL=$(echo "$INPUT" | node -e "try{const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));console.log(d.tool_name||'')}catch{}" 2>/dev/null)
|
|
14
|
+
FILE_PATH=$(echo "$INPUT" | node -e "try{const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));const i=d.tool_input||{};console.log(i.file_path||i.command||'')}catch{}" 2>/dev/null)
|
|
15
|
+
|
|
16
|
+
case "$TOOL" in
|
|
17
|
+
Write|Edit|MultiEdit|NotebookEdit) ;;
|
|
18
|
+
Bash)
|
|
19
|
+
case "$FILE_PATH" in
|
|
20
|
+
*git\ checkout*|*git\ restore*|*git\ reset*|*git\ stash*|*rm\ *|*mv\ *) ;;
|
|
21
|
+
*) exit 0 ;;
|
|
22
|
+
esac
|
|
23
|
+
;;
|
|
24
|
+
*) exit 0 ;;
|
|
25
|
+
esac
|
|
26
|
+
|
|
27
|
+
case "$FILE_PATH" in
|
|
28
|
+
*@fix_plan.md*|*fix_plan.md*) exit 0 ;;
|
|
29
|
+
esac
|
|
30
|
+
|
|
31
|
+
# Temporary bypass — touch /tmp/worktree-guard-bypass-<PID>
|
|
32
|
+
for bf in /tmp/worktree-guard-bypass-*; do
|
|
33
|
+
[ -f "$bf" ] && pid="${bf##*-}" && kill -0 "$pid" 2>/dev/null && exit 0
|
|
34
|
+
done
|
|
35
|
+
|
|
36
|
+
CWD=$(echo "$INPUT" | node -e "try{const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));console.log(d.cwd||'')}catch{}" 2>/dev/null)
|
|
37
|
+
[ -z "$CWD" ] && CWD=$(pwd)
|
|
38
|
+
|
|
39
|
+
GIT_DIR=$(cd "$CWD" && git rev-parse --git-dir 2>/dev/null)
|
|
40
|
+
GIT_COMMON=$(cd "$CWD" && git rev-parse --git-common-dir 2>/dev/null)
|
|
41
|
+
if [ -n "$GIT_DIR" ] && [ -n "$GIT_COMMON" ] && [ "$GIT_DIR" != "$GIT_COMMON" ]; then
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
[ -f "$CWD/.git" ] && exit 0
|
|
45
|
+
|
|
46
|
+
MY_PID=$$
|
|
47
|
+
REPO_PATH=$(cd "$CWD" && git rev-parse --show-toplevel 2>/dev/null)
|
|
48
|
+
[ -z "$REPO_PATH" ] && exit 0
|
|
49
|
+
|
|
50
|
+
CONCURRENT=0
|
|
51
|
+
while IFS= read -r pid; do
|
|
52
|
+
[ -z "$pid" ] && continue
|
|
53
|
+
[ "$pid" = "$MY_PID" ] && continue
|
|
54
|
+
OTHER_CWD=$(lsof -p "$pid" 2>/dev/null | grep cwd | awk '{print $NF}')
|
|
55
|
+
[ -z "$OTHER_CWD" ] && continue
|
|
56
|
+
OTHER_REPO=$(cd "$OTHER_CWD" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null)
|
|
57
|
+
if [ "$OTHER_REPO" = "$REPO_PATH" ]; then
|
|
58
|
+
OTHER_GIT_DIR=$(cd "$OTHER_CWD" 2>/dev/null && git rev-parse --git-dir 2>/dev/null)
|
|
59
|
+
OTHER_GIT_COMMON=$(cd "$OTHER_CWD" 2>/dev/null && git rev-parse --git-common-dir 2>/dev/null)
|
|
60
|
+
[ "$OTHER_GIT_DIR" = "$OTHER_GIT_COMMON" ] && CONCURRENT=$((CONCURRENT + 1))
|
|
61
|
+
fi
|
|
62
|
+
done < <(pgrep -f "claude" 2>/dev/null)
|
|
63
|
+
|
|
64
|
+
if [ "$CONCURRENT" -gt 0 ]; then
|
|
65
|
+
REPO_NAME=$(basename "$REPO_PATH")
|
|
66
|
+
cat <<EOF
|
|
67
|
+
{
|
|
68
|
+
"decision": "block",
|
|
69
|
+
"reason": "WORKTREE GUARD: ${CONCURRENT} other Claude session(s) working on ${REPO_NAME} without worktree isolation. Use 'claude -w <name>' or Agent tool with isolation: 'worktree' to prevent file conflicts."
|
|
70
|
+
}
|
|
71
|
+
EOF
|
|
72
|
+
exit 2
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
exit 0
|