spine-framework 0.3.63 → 0.3.66
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,236 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander'
|
|
4
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'fs'
|
|
5
|
+
import { join, dirname } from 'path'
|
|
6
|
+
import { fileURLToPath } from 'url'
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
9
|
+
const __dirname = dirname(__filename)
|
|
10
|
+
|
|
11
|
+
interface Manifest {
|
|
12
|
+
name: string
|
|
13
|
+
slug: string
|
|
14
|
+
version: string
|
|
15
|
+
app_type: 'full' | 'ui' | 'backend' | 'data'
|
|
16
|
+
registration?: {
|
|
17
|
+
enabled?: boolean
|
|
18
|
+
default_role?: string
|
|
19
|
+
redirect_path?: string
|
|
20
|
+
account_strategy?: 'existing' | 'new' | 'choice'
|
|
21
|
+
target_account?: string
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface PackageJson {
|
|
26
|
+
name: string
|
|
27
|
+
version: string
|
|
28
|
+
files?: string[]
|
|
29
|
+
spine?: {
|
|
30
|
+
type?: string
|
|
31
|
+
slug?: string
|
|
32
|
+
manifestPath?: string
|
|
33
|
+
app_slug?: string
|
|
34
|
+
entry_point?: string
|
|
35
|
+
manifest?: string
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class ValidationError extends Error {
|
|
40
|
+
constructor(message: string) {
|
|
41
|
+
super(message)
|
|
42
|
+
this.name = 'ValidationError'
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validateManifest(appPath: string): Manifest {
|
|
47
|
+
const manifestPath = join(appPath, 'manifest.json')
|
|
48
|
+
|
|
49
|
+
if (!existsSync(manifestPath)) {
|
|
50
|
+
throw new ValidationError('❌ manifest.json is required')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let manifest: Manifest
|
|
54
|
+
try {
|
|
55
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
56
|
+
} catch (err) {
|
|
57
|
+
throw new ValidationError('❌ manifest.json is not valid JSON')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Required fields
|
|
61
|
+
const requiredFields = ['name', 'slug', 'version', 'app_type']
|
|
62
|
+
for (const field of requiredFields) {
|
|
63
|
+
if (!manifest[field]) {
|
|
64
|
+
throw new ValidationError(`❌ manifest.json missing required field: ${field}`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Validate app_type
|
|
69
|
+
const validAppTypes = ['full', 'ui', 'backend', 'data']
|
|
70
|
+
if (!validAppTypes.includes(manifest.app_type)) {
|
|
71
|
+
throw new ValidationError(`❌ manifest.json app_type must be one of: ${validAppTypes.join(', ')}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return manifest
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function validatePackageJson(appPath: string, manifest: Manifest): PackageJson {
|
|
78
|
+
const packagePath = join(appPath, 'package.json')
|
|
79
|
+
|
|
80
|
+
if (!existsSync(packagePath)) {
|
|
81
|
+
throw new ValidationError('❌ package.json is required')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let packageJson: PackageJson
|
|
85
|
+
try {
|
|
86
|
+
packageJson = JSON.parse(readFileSync(packagePath, 'utf8'))
|
|
87
|
+
} catch (err) {
|
|
88
|
+
throw new ValidationError('❌ package.json is not valid JSON')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check version sync
|
|
92
|
+
if (packageJson.version !== manifest.version) {
|
|
93
|
+
throw new ValidationError(`❌ Version mismatch: package.json has ${packageJson.version} but manifest.json has ${manifest.version}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return packageJson
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function validateRequiredFiles(appPath: string, manifest: Manifest): void {
|
|
100
|
+
const { app_type } = manifest
|
|
101
|
+
|
|
102
|
+
// index.tsx is required for full and ui apps
|
|
103
|
+
if (['full', 'ui'].includes(app_type)) {
|
|
104
|
+
if (!existsSync(join(appPath, 'index.tsx'))) {
|
|
105
|
+
throw new ValidationError('❌ index.tsx is required for full and ui apps')
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// pages/ is required for full and ui apps
|
|
110
|
+
if (['full', 'ui'].includes(app_type)) {
|
|
111
|
+
if (!existsSync(join(appPath, 'pages'))) {
|
|
112
|
+
throw new ValidationError('❌ pages/ directory is required for full and ui apps')
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// functions/ is required for full and backend apps
|
|
117
|
+
if (['full', 'backend'].includes(app_type)) {
|
|
118
|
+
if (!existsSync(join(appPath, 'functions'))) {
|
|
119
|
+
throw new ValidationError('❌ functions/ directory is required for full and backend apps')
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validateAllowedDirectories(appPath: string): void {
|
|
125
|
+
const allowedDirs = [
|
|
126
|
+
'pages', 'components', 'hooks', 'utils', 'functions',
|
|
127
|
+
'seed', 'public'
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
const entries = readdirSync(appPath, { withFileTypes: true })
|
|
131
|
+
.filter(dirent => dirent.isDirectory())
|
|
132
|
+
.map(dirent => dirent.name)
|
|
133
|
+
|
|
134
|
+
// Filter out .devin/ since it's optional and handled separately
|
|
135
|
+
const unknownDirs = entries.filter(dir => !allowedDirs.includes(dir) && dir !== '.devin')
|
|
136
|
+
|
|
137
|
+
if (unknownDirs.length > 0) {
|
|
138
|
+
throw new ValidationError(`❌ Unknown directories found: ${unknownDirs.join(', ')}. Allowed directories: ${allowedDirs.join(', ')} (plus optional .devin/)`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function validateDevinDirectory(appPath: string): void {
|
|
143
|
+
const devinPath = join(appPath, '.devin')
|
|
144
|
+
|
|
145
|
+
if (existsSync(devinPath)) {
|
|
146
|
+
const agentsPath = join(devinPath, 'AGENTS.md')
|
|
147
|
+
const agentsAltPath = join(devinPath, 'AGENT.md')
|
|
148
|
+
|
|
149
|
+
if (!existsSync(agentsPath) && !existsSync(agentsAltPath)) {
|
|
150
|
+
throw new ValidationError('❌ .devin/ directory exists but AGENTS.md is missing. If .devin/ exists, it must contain AGENTS.md with agentic guidance.')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Validate skills if they exist
|
|
154
|
+
const skillsPath = join(devinPath, 'skills')
|
|
155
|
+
if (existsSync(skillsPath)) {
|
|
156
|
+
const skillDirs = readdirSync(skillsPath, { withFileTypes: true })
|
|
157
|
+
.filter(dirent => dirent.isDirectory())
|
|
158
|
+
|
|
159
|
+
for (const skillDir of skillDirs) {
|
|
160
|
+
const skillFile = join(skillsPath, skillDir.name, 'SKILL.md')
|
|
161
|
+
if (!existsSync(skillFile)) {
|
|
162
|
+
throw new ValidationError(`❌ Skill directory ${skillDir.name} exists but SKILL.md is missing`)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
console.log(`✅ .devin/ directory valid (if present)`)
|
|
168
|
+
} else {
|
|
169
|
+
console.log(`✅ .devin/ directory not present (optional)`)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function validatePackageFiles(appPath: string, packageJson: PackageJson): void {
|
|
174
|
+
const { files = [] } = packageJson
|
|
175
|
+
|
|
176
|
+
// Check that important directories are included in files array
|
|
177
|
+
const importantFiles = ['index.tsx', 'manifest.json']
|
|
178
|
+
const importantDirs = ['pages', 'components', 'hooks', 'utils', 'functions', 'seed', 'public']
|
|
179
|
+
|
|
180
|
+
for (const file of importantFiles) {
|
|
181
|
+
if (existsSync(join(appPath, file)) && !files.includes(file)) {
|
|
182
|
+
console.warn(`⚠️ ${file} exists but is not in package.json files array`)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const dir of importantDirs) {
|
|
187
|
+
if (existsSync(join(appPath, dir)) && !files.includes(`${dir}/`)) {
|
|
188
|
+
console.warn(`⚠️ ${dir}/ exists but is not in package.json files array`)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function validateApp(appPath: string): void {
|
|
194
|
+
try {
|
|
195
|
+
console.log(`🔍 Validating app at: ${appPath}`)
|
|
196
|
+
|
|
197
|
+
const manifest = validateManifest(appPath)
|
|
198
|
+
console.log(`✅ manifest.json valid for ${manifest.name} v${manifest.version}`)
|
|
199
|
+
|
|
200
|
+
const packageJson = validatePackageJson(appPath, manifest)
|
|
201
|
+
console.log(`✅ package.json valid and versions match`)
|
|
202
|
+
|
|
203
|
+
validateRequiredFiles(appPath, manifest)
|
|
204
|
+
console.log(`✅ Required files present for app_type: ${manifest.app_type}`)
|
|
205
|
+
|
|
206
|
+
validateAllowedDirectories(appPath)
|
|
207
|
+
console.log(`✅ No unknown directories found`)
|
|
208
|
+
|
|
209
|
+
validateDevinDirectory(appPath)
|
|
210
|
+
console.log(`✅ .devin/ directory valid (if present)`)
|
|
211
|
+
|
|
212
|
+
validatePackageFiles(appPath, packageJson)
|
|
213
|
+
console.log(`✅ package.json files array checked`)
|
|
214
|
+
|
|
215
|
+
console.log(`\n🎉 App validation passed!`)
|
|
216
|
+
|
|
217
|
+
} catch (err) {
|
|
218
|
+
if (err instanceof ValidationError) {
|
|
219
|
+
console.error(err.message)
|
|
220
|
+
process.exit(1)
|
|
221
|
+
} else {
|
|
222
|
+
console.error(`❌ Unexpected error: ${err.message}`)
|
|
223
|
+
process.exit(1)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const program = new Command()
|
|
229
|
+
|
|
230
|
+
program
|
|
231
|
+
.name('validate-app')
|
|
232
|
+
.description('Validate a Spine app structure and configuration')
|
|
233
|
+
.argument('[path]', 'Path to the app directory', '.')
|
|
234
|
+
.action(validateApp)
|
|
235
|
+
|
|
236
|
+
program.parse()
|
|
@@ -42,12 +42,32 @@ fi
|
|
|
42
42
|
|
|
43
43
|
# 3. Overlay custom functions (overrides + additions), excluding test files
|
|
44
44
|
CUSTOM_COUNT=0
|
|
45
|
+
|
|
46
|
+
# 3a. Copy functions from custom/functions/
|
|
45
47
|
if [ -d "$CUSTOM_DIR/functions" ]; then
|
|
46
48
|
# Copy all files except test files (*-test.ts, *.test.ts)
|
|
47
49
|
find "$CUSTOM_DIR/functions" -maxdepth 1 -name '*.ts' ! -name '*-test.ts' ! -name '*.test.ts' -exec cp {} "$TMP_DIR"/ \; 2>/dev/null || true
|
|
48
50
|
# Copy subdirectories if any
|
|
49
51
|
find "$CUSTOM_DIR/functions" -mindepth 1 -type d -exec sh -c 'dir="$1"; base=$(basename "$dir"); mkdir -p "'"$TMP_DIR"'/$base" && cp -r "$dir"/* "'"$TMP_DIR"'/$base/"' _ {} \; 2>/dev/null || true
|
|
50
|
-
CUSTOM_COUNT=$(find "$CUSTOM_DIR/functions" -name '*.ts' ! -name '*-test.ts' ! -name '*.test.ts' 2>/dev/null | wc -l | tr -d ' ')
|
|
52
|
+
CUSTOM_COUNT=$((CUSTOM_COUNT + $(find "$CUSTOM_DIR/functions" -name '*.ts' ! -name '*-test.ts' ! -name '*.test.ts' 2>/dev/null | wc -l | tr -d ' ')))
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
# 3b. Copy functions from custom/apps/*/functions/
|
|
56
|
+
if [ -d "$CUSTOM_DIR/apps" ]; then
|
|
57
|
+
for app_dir in "$CUSTOM_DIR/apps"/*; do
|
|
58
|
+
if [ -d "$app_dir/functions" ]; then
|
|
59
|
+
# Copy all files except test files (*-test.ts, *.test.ts)
|
|
60
|
+
find "$app_dir/functions" -maxdepth 1 -name '*.ts' ! -name '*-test.ts' ! -name '*.test.ts' -exec cp {} "$TMP_DIR"/ \; 2>/dev/null || true
|
|
61
|
+
# Copy subdirectories if any
|
|
62
|
+
find "$app_dir/functions" -mindepth 1 -type d -exec sh -c 'dir="$1"; base=$(basename "$dir"); mkdir -p "'"$TMP_DIR"'/$base" && cp -r "$dir"/* "'"$TMP_DIR"'/$base/"' _ {} \; 2>/dev/null || true
|
|
63
|
+
APP_COUNT=$(find "$app_dir/functions" -name '*.ts' ! -name '*-test.ts' ! -name '*.test.ts' 2>/dev/null | wc -l | tr -d ' ')
|
|
64
|
+
CUSTOM_COUNT=$((CUSTOM_COUNT + APP_COUNT))
|
|
65
|
+
echo " ✓ $(basename "$app_dir"): $APP_COUNT files"
|
|
66
|
+
fi
|
|
67
|
+
done
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
if [ $CUSTOM_COUNT -gt 0 ]; then
|
|
51
71
|
echo " ✓ Custom: $CUSTOM_COUNT files"
|
|
52
72
|
else
|
|
53
73
|
echo " ○ Custom: (empty)"
|