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
- if (!existsSync(appDest)) {
68
+ const isReinstall = existsSync(appDest)
69
+ if (!isReinstall) {
69
70
  mkdirSync(appDest, { recursive: true })
70
71
  }
71
72
 
72
- // Copy all allowed directories and files
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
- console.log(` Copying ${entry}`)
84
- cpSync(src, join(appDest, entry), { recursive: true })
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
- // Seed types
290
- const typesPath = join(appDir, 'seed/types.json')
291
- if (existsSync(typesPath)) {
292
- const types = JSON.parse(readFileSync(typesPath, 'utf8'))
293
- console.log(` Seeding ${types.length} type(s) for ${appSlug}...`)
294
- for (const type of types) {
295
- const { error } = await db
296
- .from('types')
297
- .upsert({ ...type, app_id: appId }, { onConflict: 'app_id,kind,slug' })
298
- if (error) console.warn(` ⚠ type ${type.slug}: ${error.message}`)
299
- }
300
- console.log(` ✓ Types seeded`)
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
- // Seed roles
304
- const rolesPath = join(appDir, 'seed/roles.json')
305
- if (existsSync(rolesPath)) {
306
- const roles = JSON.parse(readFileSync(rolesPath, 'utf8'))
307
- console.log(` Seeding ${roles.length} role(s) for ${appSlug}...`)
308
- for (const role of roles) {
309
- const { error } = await db
310
- .from('roles')
311
- .upsert({ ...role, app_id: appId }, { onConflict: 'app_id,slug' })
312
- if (error) console.warn(` ⚠ role ${role.slug}: ${error.message}`)
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
- // Seed link-types
318
- const linkTypesPath = join(appDir, 'seed/link-types.json')
319
- if (existsSync(linkTypesPath)) {
320
- const linkTypes = JSON.parse(readFileSync(linkTypesPath, 'utf8'))
321
- if (linkTypes.length > 0) {
322
- console.log(` Seeding ${linkTypes.length} link type(s) for ${appSlug}...`)
323
- for (const lt of linkTypes) {
324
- const { error } = await db
325
- .from('link_types')
326
- .upsert({ ...lt, app_id: appId }, { onConflict: 'app_id,slug' })
327
- if (error) console.warn(` ⚠ link_type ${lt.slug}: ${error.message}`)
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
 
@@ -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: first app with a route_prefix, or fall back to dashboard
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`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework",
3
- "version": "0.3.76",
3
+ "version": "0.3.78",
4
4
  "description": "Multi-tenant, modular application platform for modern SaaS systems",
5
5
  "type": "module",
6
6
  "bin": {