@soulbatical/tetra-dev-toolkit 1.20.21 → 1.20.23

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.
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Health Check: Deploy Readiness for Private Packages
3
+ *
4
+ * Verifies that a project is correctly configured to deploy with
5
+ * @soulbatical private npm packages on Railway and Netlify.
6
+ *
7
+ * Checks:
8
+ * 1. .npmrc exists with @soulbatical scope + ${NPM_TOKEN} auth
9
+ * 2. No file: references to @soulbatical packages (breaks CI/CD)
10
+ * 3. Railway projects have a Dockerfile with npm auth step
11
+ * 4. Dockerfile ARG NPM_TOKEN + .npmrc creation happens BEFORE npm install
12
+ *
13
+ * Score: 0-4 (1 per aspect)
14
+ * Skipped if project has no @soulbatical dependencies.
15
+ */
16
+
17
+ import { existsSync, readFileSync } from 'fs'
18
+ import { join } from 'path'
19
+ import { createCheck } from './types.js'
20
+
21
+ const SOULBATICAL_PACKAGES = [
22
+ '@soulbatical/tetra-core',
23
+ '@soulbatical/tetra-ui',
24
+ '@soulbatical/tetra-dev-toolkit',
25
+ '@soulbatical/stella'
26
+ ]
27
+
28
+ /**
29
+ * Collect all @soulbatical dependency references from a project's package.json files
30
+ */
31
+ function findSoulbaticalDeps(projectPath) {
32
+ const pkgPaths = [
33
+ { path: join(projectPath, 'package.json'), label: 'root' },
34
+ { path: join(projectPath, 'backend', 'package.json'), label: 'backend' },
35
+ { path: join(projectPath, 'frontend', 'package.json'), label: 'frontend' },
36
+ { path: join(projectPath, 'backend-mcp', 'package.json'), label: 'backend-mcp' },
37
+ { path: join(projectPath, 'bot', 'package.json'), label: 'bot' }
38
+ ]
39
+
40
+ const deps = []
41
+ const fileRefs = []
42
+
43
+ for (const { path: pkgPath, label } of pkgPaths) {
44
+ if (!existsSync(pkgPath)) continue
45
+ try {
46
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
47
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
48
+
49
+ for (const [name, version] of Object.entries(allDeps)) {
50
+ if (!name.startsWith('@soulbatical/')) continue
51
+ deps.push({ name, version, location: label })
52
+
53
+ if (version.startsWith('file:') || version.startsWith('link:')) {
54
+ fileRefs.push({ name, version, location: label })
55
+ }
56
+ }
57
+ } catch { /* ignore */ }
58
+ }
59
+
60
+ return { deps, fileRefs }
61
+ }
62
+
63
+ /**
64
+ * Check if .npmrc has proper config for private packages
65
+ */
66
+ function checkNpmrc(projectPath) {
67
+ const npmrcPath = join(projectPath, '.npmrc')
68
+ if (!existsSync(npmrcPath)) return { exists: false, hasAuth: false, hasScope: false, content: '' }
69
+
70
+ const content = readFileSync(npmrcPath, 'utf-8')
71
+ return {
72
+ exists: true,
73
+ hasAuth: content.includes('_authToken=${NPM_TOKEN}') || content.includes('_authToken=$NPM_TOKEN'),
74
+ hasScope: content.includes('@soulbatical:registry='),
75
+ content
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Check if Dockerfile has proper npm auth for private packages
81
+ */
82
+ function checkDockerfile(projectPath) {
83
+ // Check common Dockerfile locations
84
+ const dockerfiles = [
85
+ join(projectPath, 'Dockerfile'),
86
+ join(projectPath, 'Dockerfile.prod'),
87
+ join(projectPath, 'backend', 'Dockerfile')
88
+ ]
89
+
90
+ for (const dfPath of dockerfiles) {
91
+ if (!existsSync(dfPath)) continue
92
+ const content = readFileSync(dfPath, 'utf-8')
93
+
94
+ const hasArgNpmToken = /ARG\s+NPM_TOKEN/i.test(content)
95
+ const hasNpmrcCreation = content.includes('.npmrc') && content.includes('authToken')
96
+ const hasNpmInstall = /npm\s+(ci|install)/i.test(content)
97
+
98
+ // Check ordering: .npmrc creation should be BEFORE npm install
99
+ let orderCorrect = false
100
+ if (hasNpmrcCreation && hasNpmInstall) {
101
+ const npmrcPos = content.indexOf('.npmrc')
102
+ const installPos = content.search(/npm\s+(ci|install)/i)
103
+ orderCorrect = npmrcPos < installPos
104
+ }
105
+
106
+ return {
107
+ exists: true,
108
+ path: dfPath,
109
+ hasArgNpmToken,
110
+ hasNpmrcCreation,
111
+ hasNpmInstall,
112
+ orderCorrect
113
+ }
114
+ }
115
+
116
+ return { exists: false }
117
+ }
118
+
119
+ /**
120
+ * Detect if project deploys to Railway (has railway config or infrastructure hints)
121
+ */
122
+ function isRailwayProject(projectPath) {
123
+ if (existsSync(join(projectPath, 'railway.json'))) return true
124
+ if (existsSync(join(projectPath, 'railway.toml'))) return true
125
+
126
+ // Check INFRASTRUCTURE.yml for Railway hosting
127
+ const infraPath = join(projectPath, '.ralph', 'INFRASTRUCTURE.yml')
128
+ if (existsSync(infraPath)) {
129
+ try {
130
+ const content = readFileSync(infraPath, 'utf-8')
131
+ if (content.toLowerCase().includes('railway')) return true
132
+ } catch { /* ignore */ }
133
+ }
134
+
135
+ return false
136
+ }
137
+
138
+ export async function check(projectPath) {
139
+ const result = createCheck('deploy-readiness', 4, {
140
+ hasSoulbaticalDeps: false,
141
+ soulbaticalDeps: [],
142
+ fileRefs: [],
143
+ npmrc: {},
144
+ dockerfile: {},
145
+ isRailway: false,
146
+ skipped: false
147
+ })
148
+
149
+ // Find @soulbatical dependencies
150
+ const { deps, fileRefs } = findSoulbaticalDeps(projectPath)
151
+ result.details.soulbaticalDeps = deps
152
+ result.details.fileRefs = fileRefs
153
+
154
+ // Skip if no @soulbatical dependencies
155
+ if (deps.length === 0) {
156
+ result.details.skipped = true
157
+ result.details.message = 'No @soulbatical dependencies — check skipped'
158
+ result.score = result.maxScore // Full score if not applicable
159
+ return result
160
+ }
161
+
162
+ result.details.hasSoulbaticalDeps = true
163
+ const isRailway = isRailwayProject(projectPath)
164
+ result.details.isRailway = isRailway
165
+
166
+ // --- Check 1: .npmrc exists with auth + scope (+1 point) ---
167
+ const npmrc = checkNpmrc(projectPath)
168
+ result.details.npmrc = npmrc
169
+
170
+ if (npmrc.exists && npmrc.hasAuth && npmrc.hasScope) {
171
+ result.score += 1
172
+ } else if (npmrc.exists && npmrc.hasAuth) {
173
+ result.score += 0.5 // Auth OK but missing scope
174
+ }
175
+
176
+ // --- Check 2: No file: references (+1 point) ---
177
+ if (fileRefs.length === 0) {
178
+ result.score += 1
179
+ }
180
+
181
+ // --- Check 3: Railway projects need Dockerfile (+1 point) ---
182
+ if (isRailway) {
183
+ const df = checkDockerfile(projectPath)
184
+ result.details.dockerfile = df
185
+
186
+ if (df.exists && df.hasArgNpmToken && df.hasNpmrcCreation && df.orderCorrect) {
187
+ result.score += 1
188
+ } else if (df.exists && df.hasNpmInstall) {
189
+ result.score += 0.5 // Dockerfile exists but missing npm auth
190
+ }
191
+ } else {
192
+ result.score += 1 // Not Railway — full score for this check
193
+ }
194
+
195
+ // --- Check 4: Overall deploy confidence (+1 point) ---
196
+ // All non-dev @soulbatical deps use semver (not file:, not link:)
197
+ const nonDevFileRefs = fileRefs.filter(r => r.location !== 'root') // root devDeps are OK locally
198
+ const allSemver = nonDevFileRefs.length === 0
199
+ const npmrcReady = npmrc.exists && npmrc.hasAuth
200
+ const dockerReady = !isRailway || (result.details.dockerfile.exists && result.details.dockerfile.hasArgNpmToken)
201
+
202
+ if (allSemver && npmrcReady && dockerReady) {
203
+ result.score += 1
204
+ }
205
+
206
+ result.score = Math.min(result.score, result.maxScore)
207
+
208
+ // Summary message
209
+ if (result.score < result.maxScore) {
210
+ result.status = 'warning'
211
+ const issues = []
212
+ if (!npmrc.exists) issues.push('missing .npmrc')
213
+ else if (!npmrc.hasAuth) issues.push('.npmrc missing ${NPM_TOKEN} auth')
214
+ else if (!npmrc.hasScope) issues.push('.npmrc missing @soulbatical:registry scope')
215
+ if (fileRefs.length > 0) issues.push(`${fileRefs.length} file: ref(s) to @soulbatical packages — will break CI/CD`)
216
+ if (isRailway && !result.details.dockerfile.exists) issues.push('Railway project without Dockerfile �� private packages will fail')
217
+ else if (isRailway && !result.details.dockerfile.hasArgNpmToken) issues.push('Dockerfile missing ARG NPM_TOKEN')
218
+ result.details.message = issues.join(', ')
219
+ }
220
+
221
+ return result
222
+ }
@@ -102,9 +102,15 @@ const ROOT_CLUTTER_DIRS = new Set([
102
102
  ])
103
103
 
104
104
  /**
105
- * Recursively find files matching a predicate, respecting ignored dirs
105
+ * Recursively find files matching a predicate, respecting ignored dirs.
106
+ * @param {string} dir - Directory to scan
107
+ * @param {Function} predicate - (name, fullPath) => boolean
108
+ * @param {number} maxDepth - Max recursion depth
109
+ * @param {number} currentDepth - Current depth
110
+ * @param {string} rootPath - Project root (for relative path matching against gitignore)
111
+ * @param {Set<string>} gitignored - Gitignored path patterns from .gitignore
106
112
  */
107
- function findFiles(dir, predicate, maxDepth = 5, currentDepth = 0) {
113
+ function findFiles(dir, predicate, maxDepth = 5, currentDepth = 0, rootPath = dir, gitignored = new Set()) {
108
114
  const results = []
109
115
  if (currentDepth >= maxDepth) return results
110
116
 
@@ -117,7 +123,10 @@ function findFiles(dir, predicate, maxDepth = 5, currentDepth = 0) {
117
123
 
118
124
  const fullPath = join(dir, name)
119
125
  if (entry.isDirectory()) {
120
- results.push(...findFiles(fullPath, predicate, maxDepth, currentDepth + 1))
126
+ // Skip directories matched by .gitignore entries
127
+ const relPath = relative(rootPath, fullPath)
128
+ if (gitignored.has(name) || gitignored.has(relPath)) continue
129
+ results.push(...findFiles(fullPath, predicate, maxDepth, currentDepth + 1, rootPath, gitignored))
121
130
  } else if (entry.isFile() && predicate(name, fullPath)) {
122
131
  results.push(fullPath)
123
132
  }
@@ -132,7 +141,7 @@ const ALLOWED_NESTED_DOCS_PARENTS = new Set(['.ralph'])
132
141
  * Find directories named "docs" that are not at the repo root,
133
142
  * excluding allowed parents like .ralph/docs
134
143
  */
135
- function findNestedDocsDirs(rootPath, currentDir, maxDepth = 4, currentDepth = 0) {
144
+ function findNestedDocsDirs(rootPath, currentDir, maxDepth = 4, currentDepth = 0, gitignored = new Set()) {
136
145
  const results = []
137
146
  if (currentDepth >= maxDepth) return results
138
147
 
@@ -146,6 +155,9 @@ function findNestedDocsDirs(rootPath, currentDir, maxDepth = 4, currentDepth = 0
146
155
 
147
156
  const fullPath = join(currentDir, name)
148
157
  const relFromRoot = relative(rootPath, fullPath)
158
+
159
+ // Skip gitignored directories
160
+ if (gitignored.has(name) || gitignored.has(relFromRoot)) continue
149
161
  const topDir = relFromRoot.split('/')[0]
150
162
 
151
163
  if (name === 'docs' && currentDepth > 0) {
@@ -154,7 +166,7 @@ function findNestedDocsDirs(rootPath, currentDir, maxDepth = 4, currentDepth = 0
154
166
  results.push(fullPath)
155
167
  }
156
168
  } else {
157
- results.push(...findNestedDocsDirs(rootPath, fullPath, maxDepth, currentDepth + 1))
169
+ results.push(...findNestedDocsDirs(rootPath, fullPath, maxDepth, currentDepth + 1, gitignored))
158
170
  }
159
171
  }
160
172
  return results
@@ -192,7 +204,7 @@ export async function check(projectPath) {
192
204
  }
193
205
 
194
206
  // --- Check 1: Stray .md files ---
195
- const allMdFiles = findFiles(projectPath, (name) => name.endsWith('.md'))
207
+ const allMdFiles = findFiles(projectPath, (name) => name.endsWith('.md'), 5, 0, projectPath, gitignoredDirs)
196
208
  const strayMd = []
197
209
 
198
210
  for (const file of allMdFiles) {
@@ -224,7 +236,7 @@ export async function check(projectPath) {
224
236
  result.details.totalStrayMd = strayMd.length
225
237
 
226
238
  // --- Check 2: Stray scripts ---
227
- const allScripts = findFiles(projectPath, (name) => name.endsWith('.sh'))
239
+ const allScripts = findFiles(projectPath, (name) => name.endsWith('.sh'), 5, 0, projectPath, gitignoredDirs)
228
240
  const strayScripts = []
229
241
 
230
242
  for (const file of allScripts) {
@@ -246,7 +258,7 @@ export async function check(projectPath) {
246
258
  result.details.totalStrayScripts = strayScripts.length
247
259
 
248
260
  // --- Check 3: Stray config files (.yml/.yaml) ---
249
- const allConfigs = findFiles(projectPath, (name) => name.endsWith('.yml') || name.endsWith('.yaml'))
261
+ const allConfigs = findFiles(projectPath, (name) => name.endsWith('.yml') || name.endsWith('.yaml'), 5, 0, projectPath, gitignoredDirs)
250
262
  const strayConfigs = []
251
263
 
252
264
  for (const file of allConfigs) {
@@ -352,7 +364,7 @@ export async function check(projectPath) {
352
364
  result.details.totalRootClutter = rootClutter.length
353
365
 
354
366
  // --- Check 6: Nested docs/ directories ---
355
- const nestedDocs = findNestedDocsDirs(projectPath, projectPath)
367
+ const nestedDocs = findNestedDocsDirs(projectPath, projectPath, 4, 0, gitignoredDirs)
356
368
  const nestedDocsRel = nestedDocs.map(d => relative(projectPath, d))
357
369
 
358
370
  result.details.nestedDocsDirs = nestedDocsRel
@@ -18,7 +18,8 @@ const CRITICAL = [
18
18
  const RECOMMENDED = [
19
19
  { name: 'credential files (*.pem, *.key)', patterns: ['*.pem', '*.key'] },
20
20
  { name: 'Supabase temp', patterns: ['.supabase', 'supabase/.temp'] },
21
- { name: '.DS_Store', patterns: ['.DS_Store'] }
21
+ { name: '.DS_Store', patterns: ['.DS_Store'] },
22
+ { name: 'Ralph runtime state (.ralph/)', patterns: ['.ralph'] }
22
23
  ]
23
24
 
24
25
  function isCovered(lines, pattern) {
@@ -43,3 +43,4 @@ export { check as checkReleasePipeline } from './release-pipeline.js'
43
43
  export { check as checkTestStructure } from './test-structure.js'
44
44
  export { check as checkSentryMonitoring } from './sentry-monitoring.js'
45
45
  export { check as checkShadcnUiTokens } from './shadcn-ui-tokens.js'
46
+ export { check as checkDeployReadiness } from './deploy-readiness.js'
@@ -39,6 +39,7 @@ import { check as checkReleasePipeline } from './release-pipeline.js'
39
39
  import { check as checkTestStructure } from './test-structure.js'
40
40
  import { check as checkSentryMonitoring } from './sentry-monitoring.js'
41
41
  import { check as checkShadcnUiTokens } from './shadcn-ui-tokens.js'
42
+ import { check as checkDeployReadiness } from './deploy-readiness.js'
42
43
  import { calculateHealthStatus } from './types.js'
43
44
 
44
45
  /**
@@ -86,7 +87,8 @@ export async function scanProjectHealth(projectPath, projectName, options = {})
86
87
  checkReleasePipeline(projectPath),
87
88
  checkTestStructure(projectPath),
88
89
  checkSentryMonitoring(projectPath),
89
- checkShadcnUiTokens(projectPath)
90
+ checkShadcnUiTokens(projectPath),
91
+ checkDeployReadiness(projectPath)
90
92
  ])
91
93
 
92
94
  const totalScore = checks.reduce((sum, c) => sum + c.score, 0)
@@ -19,6 +19,7 @@ export * as rpcParamMismatch from './supabase/rpc-param-mismatch.js'
19
19
  export * as rpcGeneratorOrigin from './supabase/rpc-generator-origin.js'
20
20
 
21
21
  // Code quality checks
22
+ export * as appshellCompliance from './codeQuality/appshell-compliance.js'
22
23
  export * as uiTheming from './codeQuality/ui-theming.js'
23
24
  export * as barrelImportDetector from './codeQuality/barrel-import-detector.js'
24
25
  export * as typescriptStrictness from './codeQuality/typescript-strictness.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulbatical/tetra-dev-toolkit",
3
- "version": "1.20.21",
3
+ "version": "1.20.23",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },
@@ -43,7 +43,8 @@
43
43
  "tetra-test-audit": "./bin/tetra-test-audit.js",
44
44
  "tetra-check-pages": "./bin/tetra-check-pages.js",
45
45
  "tetra-check-views": "./bin/tetra-check-views.js",
46
- "tetra-doctor": "./bin/tetra-doctor.js"
46
+ "tetra-doctor": "./bin/tetra-doctor.js",
47
+ "tetra-style-audit": "./bin/tetra-style-audit.js"
47
48
  },
48
49
  "files": [
49
50
  "bin/",