spine-framework 0.3.76 → 0.3.78
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.
|
@@ -65,24 +65,33 @@ export function registerInstallAppCommands(program: Command) {
|
|
|
65
65
|
|
|
66
66
|
// 4. Copy app files to custom/apps/<slug>/
|
|
67
67
|
const appDest = join(PROJECT_ROOT, 'custom/apps', appSlug)
|
|
68
|
-
|
|
68
|
+
const isReinstall = existsSync(appDest)
|
|
69
|
+
if (!isReinstall) {
|
|
69
70
|
mkdirSync(appDest, { recursive: true })
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
//
|
|
73
|
+
// On reinstall, preserve the developer's local manifest.json — it is
|
|
74
|
+
// the source of truth for route_prefix and other project-level config.
|
|
75
|
+
// All other files are updated from npm as normal.
|
|
73
76
|
const allowedEntries = [
|
|
74
77
|
'index.tsx', 'manifest.json', 'package.json',
|
|
75
78
|
'pages', 'components', 'hooks', 'utils', 'functions', 'seed', 'public'
|
|
76
79
|
]
|
|
77
|
-
|
|
80
|
+
|
|
78
81
|
console.log(` Copying files from ${pkgPath} to ${appDest}`)
|
|
79
|
-
|
|
82
|
+
|
|
80
83
|
for (const entry of allowedEntries) {
|
|
81
84
|
const src = join(pkgPath, entry)
|
|
82
|
-
if (existsSync(src))
|
|
83
|
-
|
|
84
|
-
|
|
85
|
+
if (!existsSync(src)) continue
|
|
86
|
+
|
|
87
|
+
// Preserve local manifest on reinstall — developer owns it after first install
|
|
88
|
+
if (entry === 'manifest.json' && isReinstall) {
|
|
89
|
+
console.log(` Preserving local manifest.json (use update-db-app to sync changes)`)
|
|
90
|
+
continue
|
|
85
91
|
}
|
|
92
|
+
|
|
93
|
+
console.log(` Copying ${entry}`)
|
|
94
|
+
cpSync(src, join(appDest, entry), { recursive: true })
|
|
86
95
|
}
|
|
87
96
|
console.log(` ✓ App files copied to custom/apps/${appSlug}/`)
|
|
88
97
|
|
|
@@ -206,6 +215,80 @@ export function registerInstallAppCommands(program: Command) {
|
|
|
206
215
|
})
|
|
207
216
|
}
|
|
208
217
|
|
|
218
|
+
// update-db-app
|
|
219
|
+
program
|
|
220
|
+
.command('update-db-app <slug>')
|
|
221
|
+
.description('Sync local manifest.json changes to the database (no npm, no file changes)')
|
|
222
|
+
.action(async (slug: string) => {
|
|
223
|
+
console.log(`\nSyncing manifest to DB for: ${slug}\n`)
|
|
224
|
+
loadEnv()
|
|
225
|
+
|
|
226
|
+
const appDir = join(PROJECT_ROOT, 'custom/apps', slug)
|
|
227
|
+
const manifestPath = join(appDir, 'manifest.json')
|
|
228
|
+
|
|
229
|
+
if (!existsSync(manifestPath)) {
|
|
230
|
+
console.error(`❌ No app found at custom/apps/${slug}/manifest.json`)
|
|
231
|
+
console.error(` Run install-app first: npx spine-framework install-app spine-framework-${slug}`)
|
|
232
|
+
process.exit(1)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const supabaseUrl = process.env.SUPABASE_URL
|
|
236
|
+
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
237
|
+
|
|
238
|
+
if (!supabaseUrl || !serviceRoleKey) {
|
|
239
|
+
console.error('❌ SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be set')
|
|
240
|
+
process.exit(1)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let manifest: Record<string, any> = {}
|
|
244
|
+
try {
|
|
245
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
246
|
+
} catch {
|
|
247
|
+
console.error(`❌ Could not parse manifest.json`)
|
|
248
|
+
process.exit(1)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const db = createClient(supabaseUrl, serviceRoleKey, { auth: { persistSession: false } })
|
|
252
|
+
|
|
253
|
+
const { data: appRow, error: appErr } = await db
|
|
254
|
+
.from('apps')
|
|
255
|
+
.upsert(
|
|
256
|
+
{
|
|
257
|
+
slug,
|
|
258
|
+
name: manifest.name ?? slug,
|
|
259
|
+
description: manifest.description ?? '',
|
|
260
|
+
version: manifest.version ?? '1.0.0',
|
|
261
|
+
nav_items: manifest.nav_items ?? [],
|
|
262
|
+
app_type: 'custom',
|
|
263
|
+
source: 'npm',
|
|
264
|
+
is_active: true,
|
|
265
|
+
route_prefix: manifest.route_prefix ?? ('/' + slug),
|
|
266
|
+
renderer: manifest.renderer ?? 'custom',
|
|
267
|
+
required_roles: manifest.required_roles ?? (manifest.min_role ? [manifest.min_role] : []),
|
|
268
|
+
min_role: manifest.required_roles?.[0] ?? manifest.min_role ?? null,
|
|
269
|
+
},
|
|
270
|
+
{ onConflict: 'slug' }
|
|
271
|
+
)
|
|
272
|
+
.select('id')
|
|
273
|
+
.single()
|
|
274
|
+
|
|
275
|
+
if (appErr || !appRow?.id) {
|
|
276
|
+
console.error(`❌ Failed to update app row: ${appErr?.message ?? 'no id returned'}`)
|
|
277
|
+
process.exit(1)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
console.log(` ✓ apps row updated (route_prefix: ${manifest.route_prefix ?? '/' + slug})`)
|
|
281
|
+
|
|
282
|
+
// Update registration config in spine.config.json if declared
|
|
283
|
+
if (manifest.registration?.enabled) {
|
|
284
|
+
updateRegistrationConfig(slug, manifest.registration)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
console.log(`\n✅ ${slug} DB synced from local manifest.\n`)
|
|
288
|
+
console.log(`Next: npm run assemble && netlify dev\n`)
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
209
292
|
// ─── HELPERS ────────────────────────────────────────────────────────────────
|
|
210
293
|
|
|
211
294
|
function resolveAppSlug(pkgPath: string, pkgName: string): string {
|
|
@@ -286,48 +369,66 @@ async function seedApp(appDir: string, appSlug: string) {
|
|
|
286
369
|
const appId = appRow.id
|
|
287
370
|
console.log(` ✓ App registered (id: ${appId})`)
|
|
288
371
|
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
372
|
+
// ── Manifest-driven seed loop ─────────────────────────────────────────────
|
|
373
|
+
// If manifest declares a `seed` array, use it. Each entry:
|
|
374
|
+
// { file, table, conflict, inject_app_id? }
|
|
375
|
+
// inject_app_id defaults to true — set false for tables with no app_id column.
|
|
376
|
+
//
|
|
377
|
+
// Fallback: if no manifest.seed, use legacy hardcoded behavior so existing
|
|
378
|
+
// apps without a seed block continue to work.
|
|
379
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
interface SeedEntry {
|
|
382
|
+
file: string
|
|
383
|
+
table: string
|
|
384
|
+
conflict: string
|
|
385
|
+
inject_app_id?: boolean
|
|
301
386
|
}
|
|
302
387
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
388
|
+
const seedEntries: SeedEntry[] = Array.isArray(manifest.seed)
|
|
389
|
+
? manifest.seed
|
|
390
|
+
: [
|
|
391
|
+
// Legacy fallback — matches pre-019 behavior
|
|
392
|
+
{ file: 'seed/roles.json', table: 'roles', conflict: 'app_id,slug' },
|
|
393
|
+
{ file: 'seed/types.json', table: 'types', conflict: 'app_id,kind,slug' },
|
|
394
|
+
{ file: 'seed/link-types.json', table: 'link_types', conflict: 'app_id,slug' },
|
|
395
|
+
]
|
|
396
|
+
|
|
397
|
+
for (const entry of seedEntries) {
|
|
398
|
+
const filePath = join(appDir, entry.file)
|
|
399
|
+
if (!existsSync(filePath)) {
|
|
400
|
+
console.log(` ○ ${entry.file} not found, skipping`)
|
|
401
|
+
continue
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
let records: Record<string, any>[]
|
|
405
|
+
try {
|
|
406
|
+
records = JSON.parse(readFileSync(filePath, 'utf8'))
|
|
407
|
+
} catch {
|
|
408
|
+
console.warn(` ⚠ Could not parse ${entry.file}, skipping`)
|
|
409
|
+
continue
|
|
313
410
|
}
|
|
314
|
-
console.log(` ✓ Roles seeded`)
|
|
315
|
-
}
|
|
316
411
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
412
|
+
if (!Array.isArray(records) || records.length === 0) {
|
|
413
|
+
console.log(` ○ ${entry.file} is empty, skipping`)
|
|
414
|
+
continue
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const injectAppId = entry.inject_app_id !== false // default true
|
|
418
|
+
console.log(` Seeding ${records.length} record(s) from ${entry.file} → ${entry.table}...`)
|
|
419
|
+
|
|
420
|
+
for (const record of records) {
|
|
421
|
+
const row = injectAppId ? { ...record, app_id: appId } : { ...record }
|
|
422
|
+
const { error } = await db
|
|
423
|
+
.from(entry.table)
|
|
424
|
+
.upsert(row, { onConflict: entry.conflict })
|
|
425
|
+
if (error) {
|
|
426
|
+
const label = record.slug ?? record.name ?? JSON.stringify(record).slice(0, 40)
|
|
427
|
+
console.warn(` ⚠ ${entry.table} "${label}": ${error.message}`)
|
|
328
428
|
}
|
|
329
|
-
console.log(` ✓ Link types seeded`)
|
|
330
429
|
}
|
|
430
|
+
|
|
431
|
+
console.log(` ✓ ${entry.table} seeded`)
|
|
331
432
|
}
|
|
332
433
|
}
|
|
333
434
|
|
|
@@ -8,11 +8,21 @@ import { fileURLToPath } from 'url'
|
|
|
8
8
|
const __filename = fileURLToPath(import.meta.url)
|
|
9
9
|
const __dirname = dirname(__filename)
|
|
10
10
|
|
|
11
|
+
interface SeedEntry {
|
|
12
|
+
file: string
|
|
13
|
+
table: string
|
|
14
|
+
conflict: string
|
|
15
|
+
inject_app_id?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
interface Manifest {
|
|
12
19
|
name: string
|
|
13
20
|
slug: string
|
|
14
21
|
version: string
|
|
15
22
|
app_type: 'full' | 'ui' | 'backend' | 'data'
|
|
23
|
+
entry_point?: string
|
|
24
|
+
sidebar_component?: string
|
|
25
|
+
seed?: SeedEntry[]
|
|
16
26
|
registration?: {
|
|
17
27
|
enabled?: boolean
|
|
18
28
|
default_role?: string
|
|
@@ -25,6 +35,7 @@ interface Manifest {
|
|
|
25
35
|
interface PackageJson {
|
|
26
36
|
name: string
|
|
27
37
|
version: string
|
|
38
|
+
private?: boolean
|
|
28
39
|
files?: string[]
|
|
29
40
|
spine?: {
|
|
30
41
|
type?: string
|
|
@@ -170,6 +181,107 @@ function validateDevinDirectory(appPath: string): void {
|
|
|
170
181
|
}
|
|
171
182
|
}
|
|
172
183
|
|
|
184
|
+
function validateRoutes(appPath: string, manifest: Manifest): void {
|
|
185
|
+
if (!manifest.route_prefix) {
|
|
186
|
+
console.warn('⚠️ manifest.json is missing route_prefix — install-app will default to /{slug}')
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const prefix = manifest.route_prefix === '/' ? null : manifest.route_prefix
|
|
191
|
+
|
|
192
|
+
if (!Array.isArray((manifest as any).routes)) return
|
|
193
|
+
|
|
194
|
+
for (const route of (manifest as any).routes as string[]) {
|
|
195
|
+
if (prefix && route.startsWith(prefix)) {
|
|
196
|
+
throw new ValidationError(
|
|
197
|
+
`❌ manifest.json route "${route}" must be root-relative (should not include route_prefix "${prefix}"). Use "${route.replace(prefix, '') || '/'}" instead.`
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
if (!route.startsWith('/')) {
|
|
201
|
+
throw new ValidationError(
|
|
202
|
+
`❌ manifest.json route "${route}" must start with /`
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// nav_items paths must also be root-relative
|
|
208
|
+
if (Array.isArray((manifest as any).nav_items)) {
|
|
209
|
+
const checkNavItem = (item: any) => {
|
|
210
|
+
if (item.path && prefix && item.path.startsWith(prefix)) {
|
|
211
|
+
throw new ValidationError(
|
|
212
|
+
`❌ manifest.json nav_items path "${item.path}" must be root-relative (should not include route_prefix "${prefix}"). Use "${item.path.replace(prefix, '') || '/'}" instead.`
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
if (Array.isArray(item.children)) {
|
|
216
|
+
for (const child of item.children) checkNavItem(child)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const item of (manifest as any).nav_items) checkNavItem(item)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function validateManifestComponents(appPath: string, manifest: Manifest): void {
|
|
224
|
+
// entry_point — if declared, the file must exist
|
|
225
|
+
if (manifest.entry_point) {
|
|
226
|
+
const ep = manifest.entry_point.replace(/^\.\//, '')
|
|
227
|
+
if (!existsSync(join(appPath, ep))) {
|
|
228
|
+
throw new ValidationError(`❌ manifest.json entry_point "${manifest.entry_point}" does not exist`)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// sidebar_component — if declared, the file must exist
|
|
233
|
+
if (manifest.sidebar_component) {
|
|
234
|
+
const sc = manifest.sidebar_component.replace(/^\.\//, '')
|
|
235
|
+
if (!existsSync(join(appPath, sc))) {
|
|
236
|
+
throw new ValidationError(`❌ manifest.json sidebar_component "${manifest.sidebar_component}" does not exist`)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// registration — account_strategy "existing" requires target_account
|
|
241
|
+
if (manifest.registration?.account_strategy === 'existing' && !manifest.registration?.target_account) {
|
|
242
|
+
throw new ValidationError(`❌ manifest.json registration.account_strategy is "existing" but target_account is not set`)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function validateSeedBlock(appPath: string, manifest: Manifest): void {
|
|
247
|
+
if (!manifest.seed) return // optional — legacy apps without seed block are fine
|
|
248
|
+
|
|
249
|
+
if (!Array.isArray(manifest.seed)) {
|
|
250
|
+
throw new ValidationError('❌ manifest.json seed must be an array')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
for (const entry of manifest.seed) {
|
|
254
|
+
if (!entry.file || !entry.table || !entry.conflict) {
|
|
255
|
+
throw new ValidationError(
|
|
256
|
+
`❌ manifest.json seed entry missing required field(s) (file, table, conflict): ${JSON.stringify(entry)}`
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
const filePath = join(appPath, entry.file)
|
|
260
|
+
if (!existsSync(filePath)) {
|
|
261
|
+
throw new ValidationError(`❌ manifest.json seed entry file "${entry.file}" does not exist`)
|
|
262
|
+
}
|
|
263
|
+
// Verify the file is valid JSON and an array
|
|
264
|
+
try {
|
|
265
|
+
const contents = JSON.parse(readFileSync(filePath, 'utf8'))
|
|
266
|
+
if (!Array.isArray(contents)) {
|
|
267
|
+
throw new ValidationError(`❌ Seed file "${entry.file}" must export a JSON array`)
|
|
268
|
+
}
|
|
269
|
+
} catch (err: any) {
|
|
270
|
+
if (err instanceof ValidationError) throw err
|
|
271
|
+
throw new ValidationError(`❌ Seed file "${entry.file}" is not valid JSON: ${err.message}`)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function validatePackagePublishability(packageJson: PackageJson): void {
|
|
277
|
+
if (packageJson.private === true) {
|
|
278
|
+
throw new ValidationError('❌ package.json has "private": true — app cannot be published to npm')
|
|
279
|
+
}
|
|
280
|
+
if (packageJson.private === undefined) {
|
|
281
|
+
console.warn('⚠️ package.json is missing "private": false — add it to confirm the package is publishable')
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
173
285
|
function validatePackageFiles(appPath: string, packageJson: PackageJson): void {
|
|
174
286
|
const { files = [] } = packageJson
|
|
175
287
|
|
|
@@ -202,13 +314,25 @@ function validateApp(appPath: string): void {
|
|
|
202
314
|
|
|
203
315
|
validateRequiredFiles(appPath, manifest)
|
|
204
316
|
console.log(`✅ Required files present for app_type: ${manifest.app_type}`)
|
|
205
|
-
|
|
317
|
+
|
|
318
|
+
validateRoutes(appPath, manifest)
|
|
319
|
+
console.log(`✅ manifest.json routes and nav_items are root-relative`)
|
|
320
|
+
|
|
321
|
+
validateManifestComponents(appPath, manifest)
|
|
322
|
+
console.log(`✅ manifest.json entry_point, sidebar_component, and registration valid`)
|
|
323
|
+
|
|
324
|
+
validateSeedBlock(appPath, manifest)
|
|
325
|
+
console.log(`✅ manifest.json seed block valid`)
|
|
326
|
+
|
|
206
327
|
validateAllowedDirectories(appPath)
|
|
207
328
|
console.log(`✅ No unknown directories found`)
|
|
208
|
-
|
|
329
|
+
|
|
209
330
|
validateDevinDirectory(appPath)
|
|
210
331
|
console.log(`✅ .devin/ directory valid (if present)`)
|
|
211
|
-
|
|
332
|
+
|
|
333
|
+
validatePackagePublishability(packageJson)
|
|
334
|
+
console.log(`✅ package.json publishability valid`)
|
|
335
|
+
|
|
212
336
|
validatePackageFiles(appPath, packageJson)
|
|
213
337
|
console.log(`✅ package.json files array checked`)
|
|
214
338
|
|
package/.framework/src/App.tsx
CHANGED
|
@@ -64,18 +64,23 @@ function AuthenticatedRouter() {
|
|
|
64
64
|
)
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// Sort: explicit prefixes first, root (/) last
|
|
67
|
+
// Sort: longer explicit prefixes first (most specific), root (/) last so it
|
|
68
|
+
// doesn't swallow other routes. spine-framework reserved namespace excluded.
|
|
68
69
|
const sorted = [...apps]
|
|
69
70
|
.filter(app => app.slug !== 'spine-framework' && !app.route_prefix?.startsWith('/spine-framework'))
|
|
70
71
|
.sort((a, b) => {
|
|
71
|
-
if (a.route_prefix === '/') return 1
|
|
72
|
+
if (a.route_prefix === '/') return 1 // root always last
|
|
72
73
|
if (b.route_prefix === '/') return -1
|
|
73
74
|
return (b.route_prefix?.length || 0) - (a.route_prefix?.length || 0)
|
|
74
75
|
})
|
|
75
76
|
|
|
76
|
-
// Determine default redirect:
|
|
77
|
+
// Determine default redirect:
|
|
78
|
+
// - If an app owns root ('/'), redirect there directly
|
|
79
|
+
// - Otherwise use the first non-root app's prefix
|
|
80
|
+
// - Fall back to /dashboard if no apps installed
|
|
81
|
+
const rootApp = sorted.find(app => app.route_prefix === '/')
|
|
77
82
|
const defaultApp = sorted.find(app => app.route_prefix && app.route_prefix !== '/')
|
|
78
|
-
const defaultRedirect = defaultApp?.route_prefix || '/dashboard'
|
|
83
|
+
const defaultRedirect = rootApp ? '/' : (defaultApp?.route_prefix || '/dashboard')
|
|
79
84
|
|
|
80
85
|
return (
|
|
81
86
|
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><LoadingSpinner /></div>}>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useCurrentApp } from '../contexts/AppContext'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns a helper function that prepends the current app's route_prefix
|
|
5
|
+
* to a root-relative path.
|
|
6
|
+
*
|
|
7
|
+
* Use this instead of hardcoding absolute paths in app components so that
|
|
8
|
+
* changing `route_prefix` in manifest.json (and running update-db-app) is
|
|
9
|
+
* the only change needed to move an app to a new URL location.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* const appPath = useAppPath()
|
|
13
|
+
* appPath('/dashboard') // → '/cortex/dashboard' (if prefix is /cortex)
|
|
14
|
+
* appPath('/crm/accounts') // → '/cortex/crm/accounts'
|
|
15
|
+
* appPath('/') // → '/cortex'
|
|
16
|
+
*
|
|
17
|
+
* // With route_prefix = '/'
|
|
18
|
+
* appPath('/dashboard') // → '/dashboard'
|
|
19
|
+
* appPath('/crm/accounts') // → '/crm/accounts'
|
|
20
|
+
*/
|
|
21
|
+
export function useAppPath(): (path: string) => string {
|
|
22
|
+
const app = useCurrentApp()
|
|
23
|
+
|
|
24
|
+
// Normalize: '/' prefix means the app owns root — no prefix to prepend.
|
|
25
|
+
// Any other prefix is prepended as-is (trailing slash stripped).
|
|
26
|
+
const prefix =
|
|
27
|
+
!app.route_prefix || app.route_prefix === '/'
|
|
28
|
+
? ''
|
|
29
|
+
: app.route_prefix.replace(/\/$/, '')
|
|
30
|
+
|
|
31
|
+
return (path: string): string => {
|
|
32
|
+
// path should be root-relative (start with /)
|
|
33
|
+
// If the caller passes '/' return just the prefix (or '/' if no prefix)
|
|
34
|
+
if (path === '/') return prefix || '/'
|
|
35
|
+
const normalized = path.startsWith('/') ? path : `/${path}`
|
|
36
|
+
return `${prefix}${normalized}`
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
# Spine Framework — Custom Apps Agent Guide
|
|
2
|
+
|
|
3
|
+
This document explains the structure, contract, and tooling for apps under `custom/apps/`. Read this before touching any app code.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Directory Layout
|
|
8
|
+
|
|
9
|
+
Every app lives at `custom/apps/{slug}/` and must follow this structure:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
custom/apps/{slug}/
|
|
13
|
+
├── package.json # npm identity + spine metadata block
|
|
14
|
+
├── manifest.json # runtime contract — the source of truth
|
|
15
|
+
├── index.tsx # default export: root React component
|
|
16
|
+
├── components/ # app-level shared components (sidebar, cards, etc.)
|
|
17
|
+
├── pages/ # route-mapped page components
|
|
18
|
+
│ └── {section}/
|
|
19
|
+
│ └── SomePage.tsx
|
|
20
|
+
├── functions/ # Netlify function handlers scoped to this app
|
|
21
|
+
├── hooks/ # React hooks
|
|
22
|
+
├── utils/ # Pure utility functions
|
|
23
|
+
├── public/ # Static assets copied to PROJECT_ROOT/public/ at assemble time
|
|
24
|
+
└── seed/ # Data seeded into the DB on install
|
|
25
|
+
├── roles.json
|
|
26
|
+
├── types.json
|
|
27
|
+
├── link-types.json
|
|
28
|
+
└── (any other declared seed files)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
No other top-level directories are allowed. `validate-app` will fail on unknown directories.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## package.json — Required Shape
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"name": "spine-framework-{slug}",
|
|
40
|
+
"version": "X.Y.Z",
|
|
41
|
+
"private": false,
|
|
42
|
+
"description": "...",
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"spine-framework": ">=0.1.0"
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"index.tsx",
|
|
48
|
+
"manifest.json",
|
|
49
|
+
"seed/",
|
|
50
|
+
"pages/",
|
|
51
|
+
"components/",
|
|
52
|
+
"hooks/",
|
|
53
|
+
"utils/",
|
|
54
|
+
"functions/",
|
|
55
|
+
"public/",
|
|
56
|
+
"README.md"
|
|
57
|
+
],
|
|
58
|
+
"scripts": {
|
|
59
|
+
"validate": "npx spine-framework validate-app ."
|
|
60
|
+
},
|
|
61
|
+
"spine": {
|
|
62
|
+
"type": "app",
|
|
63
|
+
"slug": "{slug}",
|
|
64
|
+
"manifestPath": "manifest.json"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Rules:**
|
|
70
|
+
- `"private": false` is required — apps must be publishable to npm
|
|
71
|
+
- `files` must include every directory that exists in the app
|
|
72
|
+
- `version` must match `manifest.json` version exactly
|
|
73
|
+
- `spine.slug` must match `manifest.json` slug exactly
|
|
74
|
+
- Package name convention: `spine-framework-{slug}`
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## manifest.json — Full Contract
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"name": "My App",
|
|
83
|
+
"slug": "my-app",
|
|
84
|
+
"description": "...",
|
|
85
|
+
"version": "X.Y.Z",
|
|
86
|
+
"app_type": "full",
|
|
87
|
+
"route_prefix": "/my-app",
|
|
88
|
+
"required_roles": ["role-slug"],
|
|
89
|
+
"routes": ["/", "/page"],
|
|
90
|
+
"nav_items": [
|
|
91
|
+
{ "title": "Home", "path": "/", "icon": "Home", "order": 1 }
|
|
92
|
+
],
|
|
93
|
+
"features": ["feature-a", "feature-b"],
|
|
94
|
+
"dependencies": ["items", "accounts"],
|
|
95
|
+
"entry_point": "./index.tsx",
|
|
96
|
+
"sidebar_component": "./components/MyAppSidebar.tsx",
|
|
97
|
+
"seed": [
|
|
98
|
+
{ "file": "seed/roles.json", "table": "roles", "conflict": "app_id,slug" },
|
|
99
|
+
{ "file": "seed/types.json", "table": "types", "conflict": "app_id,kind,slug" },
|
|
100
|
+
{ "file": "seed/link-types.json", "table": "link_types", "conflict": "app_id,slug" }
|
|
101
|
+
],
|
|
102
|
+
"registration": {
|
|
103
|
+
"enabled": true,
|
|
104
|
+
"default_role": "role-slug",
|
|
105
|
+
"redirect_path": "/my-app",
|
|
106
|
+
"account_strategy": "new"
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Field Reference
|
|
112
|
+
|
|
113
|
+
| Field | Required | Notes |
|
|
114
|
+
|-------|----------|-------|
|
|
115
|
+
| `name` | yes | Display name — written to `apps.name` in DB |
|
|
116
|
+
| `slug` | yes | Unique identifier — written to `apps.slug` |
|
|
117
|
+
| `version` | yes | Must match `package.json` version |
|
|
118
|
+
| `app_type` | yes | One of: `full`, `ui`, `backend`, `data` |
|
|
119
|
+
| `route_prefix` | yes | Where the app is mounted (e.g. `/cortex`, `/`, `/workspace`). Single source of truth for routing. |
|
|
120
|
+
| `required_roles` | yes | Array of role slugs required to access the app |
|
|
121
|
+
| `routes` | recommended | Root-relative paths the app handles — **must NOT include route_prefix** |
|
|
122
|
+
| `nav_items` | recommended | Root-relative sidebar nav paths — **must NOT include route_prefix** |
|
|
123
|
+
| `entry_point` | yes (full/ui) | Path to root React component (relative to app root) |
|
|
124
|
+
| `sidebar_component` | yes (full/ui) | Path to sidebar component — used by AppWrapper |
|
|
125
|
+
| `seed` | yes | Declares all seed files (see below) |
|
|
126
|
+
| `registration` | if app supports self-registration | Controls `spine.config.json` registration entry |
|
|
127
|
+
|
|
128
|
+
### `app_type` values
|
|
129
|
+
|
|
130
|
+
- `full` — has UI (pages, components) and backend (functions). Requires `index.tsx`, `pages/`, `functions/`
|
|
131
|
+
- `ui` — frontend only. Requires `index.tsx`, `pages/`
|
|
132
|
+
- `backend` — functions only. Requires `functions/`
|
|
133
|
+
- `data` — schema/seed only. No UI or functions required
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Dynamic route_prefix
|
|
138
|
+
|
|
139
|
+
`route_prefix` in `manifest.json` is the **single source of truth** for where an app is served. Changing it and running `update-db-app` is the only step needed to move an app to a different URL.
|
|
140
|
+
|
|
141
|
+
### How it flows
|
|
142
|
+
|
|
143
|
+
1. `manifest.json` declares `"route_prefix": "/cortex"` (or `/`, or anything)
|
|
144
|
+
2. `install-app` / `update-db-app` writes it to `apps.route_prefix` in the DB
|
|
145
|
+
3. `App.tsx` reads all app records and mounts each at `{route_prefix}/*`
|
|
146
|
+
4. Inside the app, `useAppPath()` prepends the prefix to all links — no hardcoded paths
|
|
147
|
+
|
|
148
|
+
### Rules for routes and nav_items
|
|
149
|
+
|
|
150
|
+
Routes and nav_items must be **root-relative** — declared from the app's own root, not the full URL:
|
|
151
|
+
|
|
152
|
+
```json
|
|
153
|
+
// CORRECT
|
|
154
|
+
"route_prefix": "/cortex",
|
|
155
|
+
"routes": ["/", "/dashboard", "/crm/accounts"],
|
|
156
|
+
"nav_items": [{ "title": "Dashboard", "path": "/dashboard" }]
|
|
157
|
+
|
|
158
|
+
// WRONG — validate-app will reject these
|
|
159
|
+
"routes": ["/cortex", "/cortex/dashboard", "/cortex/crm/accounts"],
|
|
160
|
+
"nav_items": [{ "title": "Dashboard", "path": "/cortex/dashboard" }]
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### useAppPath() hook
|
|
164
|
+
|
|
165
|
+
All absolute links inside an app must use `useAppPath()` instead of hardcoded strings:
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
import { useAppPath } from '@core/hooks/useAppPath'
|
|
169
|
+
|
|
170
|
+
function MyComponent() {
|
|
171
|
+
const appPath = useAppPath()
|
|
172
|
+
|
|
173
|
+
return <a href={appPath('/dashboard')}>Dashboard</a>
|
|
174
|
+
// → '/cortex/dashboard' if route_prefix is /cortex
|
|
175
|
+
// → '/dashboard' if route_prefix is /
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Use `useAppPath()` in: sidebar nav arrays, breadcrumbs, `navigate()` calls, any `<Link>` with an absolute path.
|
|
180
|
+
|
|
181
|
+
### Changing an app's location
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
# 1. Edit manifest.json
|
|
185
|
+
"route_prefix": "/" # was "/cortex"
|
|
186
|
+
|
|
187
|
+
# 2. Sync to DB (no npm, no file changes)
|
|
188
|
+
npx spine-framework update-db-app cortex
|
|
189
|
+
|
|
190
|
+
# 3. Rebuild and serve
|
|
191
|
+
npm run assemble && netlify dev
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
That's the entire workflow. No code changes needed.
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Seed Block
|
|
199
|
+
|
|
200
|
+
The `seed` array in `manifest.json` declares every file to be seeded into the DB at install time. `install-app` reads this array and runs a generic upsert loop — no hardcoded file names.
|
|
201
|
+
|
|
202
|
+
### Seed entry format
|
|
203
|
+
|
|
204
|
+
```json
|
|
205
|
+
{ "file": "seed/roles.json", "table": "roles", "conflict": "app_id,slug" }
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
| Field | Required | Notes |
|
|
209
|
+
|-------|----------|-------|
|
|
210
|
+
| `file` | yes | Path relative to app root |
|
|
211
|
+
| `table` | yes | Supabase table name |
|
|
212
|
+
| `conflict` | yes | Column(s) for `onConflict` upsert (comma-separated) |
|
|
213
|
+
| `inject_app_id` | no | Default `true` — set `false` for tables without `app_id` |
|
|
214
|
+
|
|
215
|
+
### Common conflict keys
|
|
216
|
+
|
|
217
|
+
| Table | conflict |
|
|
218
|
+
|-------|----------|
|
|
219
|
+
| `roles` | `app_id,slug` |
|
|
220
|
+
| `types` | `app_id,kind,slug` |
|
|
221
|
+
| `link_types` | `app_id,slug` |
|
|
222
|
+
| `triggers` | `app_id,name` |
|
|
223
|
+
|
|
224
|
+
### Seed file format
|
|
225
|
+
|
|
226
|
+
Every seed file must be a JSON array. Each object maps to one DB row. Do NOT include `id` or `app_id` — these are set by the framework at install time.
|
|
227
|
+
|
|
228
|
+
```json
|
|
229
|
+
[
|
|
230
|
+
{
|
|
231
|
+
"slug": "my-role",
|
|
232
|
+
"name": "My Role",
|
|
233
|
+
"description": "...",
|
|
234
|
+
"permissions": ["*"],
|
|
235
|
+
"is_system": true,
|
|
236
|
+
"is_active": true,
|
|
237
|
+
"is_protected": false
|
|
238
|
+
}
|
|
239
|
+
]
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
All seed files must be idempotent — safe to run against an existing DB without creating duplicates.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## registration block
|
|
247
|
+
|
|
248
|
+
Controls how users self-register for this app. Written to `spine.config.json` by `install-app`.
|
|
249
|
+
|
|
250
|
+
```json
|
|
251
|
+
"registration": {
|
|
252
|
+
"enabled": true,
|
|
253
|
+
"default_role": "member",
|
|
254
|
+
"redirect_path": "/portal",
|
|
255
|
+
"account_strategy": "new"
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
| `account_strategy` | Behavior |
|
|
260
|
+
|--------------------|----------|
|
|
261
|
+
| `new` | Creates a new account per registering user (customer-facing apps) |
|
|
262
|
+
| `existing` | Assigns user to an existing account — requires `target_account` |
|
|
263
|
+
| `choice` | User chooses at registration |
|
|
264
|
+
|
|
265
|
+
If `account_strategy` is `existing`, `target_account` (account slug) is required.
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## CLI Commands
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
# Install an app from npm (first time)
|
|
273
|
+
npx spine-framework install-app spine-framework-{slug}
|
|
274
|
+
npx spine-framework install-app spine-framework-{slug}@next # pre-release
|
|
275
|
+
|
|
276
|
+
# Sync local manifest.json changes to the DB (no npm, no file changes)
|
|
277
|
+
# Use this after changing route_prefix, nav_items, registration, etc.
|
|
278
|
+
npx spine-framework update-db-app {slug}
|
|
279
|
+
|
|
280
|
+
# Validate an app before publishing
|
|
281
|
+
npx spine-framework validate-app custom/apps/{slug}
|
|
282
|
+
# or from inside the app directory:
|
|
283
|
+
npm run validate
|
|
284
|
+
|
|
285
|
+
# Uninstall an app
|
|
286
|
+
npx spine-framework uninstall-app spine-framework-{slug}
|
|
287
|
+
|
|
288
|
+
# Update app code from npm (preserves local manifest.json)
|
|
289
|
+
npx spine-framework update-app spine-framework-{slug}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**Key distinction:**
|
|
293
|
+
- `install-app` = get/update code from npm. After first install, `manifest.json` is preserved — the developer owns it.
|
|
294
|
+
- `update-db-app` = push local manifest changes to DB. No npm involved.
|
|
295
|
+
|
|
296
|
+
`validate-app` checks:
|
|
297
|
+
- `manifest.json` present, valid JSON, all required fields
|
|
298
|
+
- `package.json` present, versions match, `"private": false`
|
|
299
|
+
- Required files exist for `app_type`
|
|
300
|
+
- `entry_point` and `sidebar_component` files exist (if declared)
|
|
301
|
+
- `seed` block entries are valid and all declared files exist
|
|
302
|
+
- `registration.account_strategy: "existing"` has `target_account`
|
|
303
|
+
- No unknown directories
|
|
304
|
+
- `.devin/` present → must contain `AGENTS.md` or `AGENT.md`
|
|
305
|
+
- All directories in `files` array are present
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Assemble
|
|
310
|
+
|
|
311
|
+
`assemble-frontend.sh` and `assemble-functions.sh` pick up app files at build time:
|
|
312
|
+
|
|
313
|
+
- `functions/` → assembled into the Netlify functions directory
|
|
314
|
+
- `public/` → copied to `PROJECT_ROOT/public/` (Vite's publicDir)
|
|
315
|
+
- `pages/`, `components/`, `hooks/`, `utils/` → assembled into `.assembled/src/{slug}/`
|
|
316
|
+
|
|
317
|
+
**Important:** `public/` assets must go to `PROJECT_ROOT/public/`, not `.assembled/src/public/`. This was a known bug fixed in `spine-framework@0.3.76`.
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## Publishing
|
|
322
|
+
|
|
323
|
+
Apps are published independently to npm, separate from `spine-framework`.
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
# From inside custom/apps/{slug}/
|
|
327
|
+
npm version patch # bump version (must match manifest.json!)
|
|
328
|
+
# Also bump version in manifest.json to match
|
|
329
|
+
npm publish --tag next --access public # pre-release
|
|
330
|
+
npm publish --tag latest --access public # stable release
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Always publish with `--tag next` for development/testing. Promote to `--tag latest` only when stable and tested E2E in a dogfood project.
|
|
334
|
+
|
|
335
|
+
Version must be kept in sync between `package.json` and `manifest.json` — `validate-app` will fail if they differ.
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
## Current Apps
|
|
340
|
+
|
|
341
|
+
| Slug | Package | Description |
|
|
342
|
+
|------|---------|-------------|
|
|
343
|
+
| `cortex` | `spine-framework-cortex` | Internal workspace: CRM, Support, Community, KB |
|
|
344
|
+
| `portal` | `spine-framework-portal` | Customer-facing: Tickets, KB, Courses, Community |
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## Adding a New App
|
|
349
|
+
|
|
350
|
+
1. Create `custom/apps/{slug}/` with the directory structure above
|
|
351
|
+
2. Write `package.json` and `manifest.json` following the templates above
|
|
352
|
+
3. Write `index.tsx` exporting the root React component
|
|
353
|
+
4. Write `components/{Slug}Sidebar.tsx` and declare it as `sidebar_component`
|
|
354
|
+
5. Write seed files and declare them in the `seed` block
|
|
355
|
+
6. Run `npm run validate` — fix all errors before proceeding
|
|
356
|
+
7. Publish with `--tag next`
|
|
357
|
+
8. Test E2E: `npx spine-framework install-app spine-framework-{slug}@next`
|