spine-framework 0.3.77 → 0.3.79

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
 
@@ -204,6 +213,79 @@ export function registerInstallAppCommands(program: Command) {
204
213
  process.exit(1)
205
214
  }
206
215
  })
216
+
217
+ // update-db-app
218
+ program
219
+ .command('update-db-app <slug>')
220
+ .description('Sync local manifest.json changes to the database (no npm, no file changes)')
221
+ .action(async (slug: string) => {
222
+ console.log(`\nSyncing manifest to DB for: ${slug}\n`)
223
+ loadEnv()
224
+
225
+ const appDir = join(PROJECT_ROOT, 'custom/apps', slug)
226
+ const manifestPath = join(appDir, 'manifest.json')
227
+
228
+ if (!existsSync(manifestPath)) {
229
+ console.error(`❌ No app found at custom/apps/${slug}/manifest.json`)
230
+ console.error(` Run install-app first: npx spine-framework install-app spine-framework-${slug}`)
231
+ process.exit(1)
232
+ }
233
+
234
+ const supabaseUrl = process.env.SUPABASE_URL
235
+ const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
236
+
237
+ if (!supabaseUrl || !serviceRoleKey) {
238
+ console.error('❌ SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be set')
239
+ process.exit(1)
240
+ }
241
+
242
+ let manifest: Record<string, any> = {}
243
+ try {
244
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
245
+ } catch {
246
+ console.error(`❌ Could not parse manifest.json`)
247
+ process.exit(1)
248
+ }
249
+
250
+ const db = createClient(supabaseUrl, serviceRoleKey, { auth: { persistSession: false } })
251
+
252
+ const { data: appRow, error: appErr } = await db
253
+ .from('apps')
254
+ .upsert(
255
+ {
256
+ slug,
257
+ name: manifest.name ?? slug,
258
+ description: manifest.description ?? '',
259
+ version: manifest.version ?? '1.0.0',
260
+ nav_items: manifest.nav_items ?? [],
261
+ app_type: 'custom',
262
+ source: 'npm',
263
+ is_active: true,
264
+ route_prefix: manifest.route_prefix ?? ('/' + slug),
265
+ renderer: manifest.renderer ?? 'custom',
266
+ required_roles: manifest.required_roles ?? (manifest.min_role ? [manifest.min_role] : []),
267
+ min_role: manifest.required_roles?.[0] ?? manifest.min_role ?? null,
268
+ },
269
+ { onConflict: 'slug' }
270
+ )
271
+ .select('id')
272
+ .single()
273
+
274
+ if (appErr || !appRow?.id) {
275
+ console.error(`❌ Failed to update app row: ${appErr?.message ?? 'no id returned'}`)
276
+ process.exit(1)
277
+ }
278
+
279
+ console.log(` ✓ apps row updated (route_prefix: ${manifest.route_prefix ?? '/' + slug})`)
280
+
281
+ // Update registration config in spine.config.json if declared
282
+ if (manifest.registration?.enabled) {
283
+ updateRegistrationConfig(slug, manifest.registration)
284
+ }
285
+
286
+ console.log(`\n✅ ${slug} DB synced from local manifest.\n`)
287
+ console.log(`Next: npm run assemble && netlify dev\n`)
288
+ })
207
289
  }
208
290
 
209
291
  // ─── HELPERS ────────────────────────────────────────────────────────────────
@@ -181,6 +181,45 @@ function validateDevinDirectory(appPath: string): void {
181
181
  }
182
182
  }
183
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
+
184
223
  function validateManifestComponents(appPath: string, manifest: Manifest): void {
185
224
  // entry_point — if declared, the file must exist
186
225
  if (manifest.entry_point) {
@@ -276,6 +315,9 @@ function validateApp(appPath: string): void {
276
315
  validateRequiredFiles(appPath, manifest)
277
316
  console.log(`✅ Required files present for app_type: ${manifest.app_type}`)
278
317
 
318
+ validateRoutes(appPath, manifest)
319
+ console.log(`✅ manifest.json routes and nav_items are root-relative`)
320
+
279
321
  validateManifestComponents(appPath, manifest)
280
322
  console.log(`✅ manifest.json entry_point, sidebar_component, and registration valid`)
281
323
 
@@ -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
+ }
@@ -84,10 +84,11 @@ No other top-level directories are allowed. `validate-app` will fail on unknown
84
84
  "description": "...",
85
85
  "version": "X.Y.Z",
86
86
  "app_type": "full",
87
+ "route_prefix": "/my-app",
87
88
  "required_roles": ["role-slug"],
88
- "routes": ["/my-app", "/my-app/page"],
89
+ "routes": ["/", "/page"],
89
90
  "nav_items": [
90
- { "title": "Home", "path": "/my-app", "icon": "Home", "order": 1 }
91
+ { "title": "Home", "path": "/", "icon": "Home", "order": 1 }
91
92
  ],
92
93
  "features": ["feature-a", "feature-b"],
93
94
  "dependencies": ["items", "accounts"],
@@ -115,9 +116,10 @@ No other top-level directories are allowed. `validate-app` will fail on unknown
115
116
  | `slug` | yes | Unique identifier — written to `apps.slug` |
116
117
  | `version` | yes | Must match `package.json` version |
117
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. |
118
120
  | `required_roles` | yes | Array of role slugs required to access the app |
119
- | `routes` | recommended | All route paths the app handles |
120
- | `nav_items` | recommended | Sidebar nav — written to `apps.nav_items` in DB |
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** |
121
123
  | `entry_point` | yes (full/ui) | Path to root React component (relative to app root) |
122
124
  | `sidebar_component` | yes (full/ui) | Path to sidebar component — used by AppWrapper |
123
125
  | `seed` | yes | Declares all seed files (see below) |
@@ -132,6 +134,67 @@ No other top-level directories are allowed. `validate-app` will fail on unknown
132
134
 
133
135
  ---
134
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
+
135
198
  ## Seed Block
136
199
 
137
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.
@@ -206,10 +269,14 @@ If `account_strategy` is `existing`, `target_account` (account slug) is required
206
269
  ## CLI Commands
207
270
 
208
271
  ```bash
209
- # Install an app from npm
272
+ # Install an app from npm (first time)
210
273
  npx spine-framework install-app spine-framework-{slug}
211
274
  npx spine-framework install-app spine-framework-{slug}@next # pre-release
212
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
+
213
280
  # Validate an app before publishing
214
281
  npx spine-framework validate-app custom/apps/{slug}
215
282
  # or from inside the app directory:
@@ -218,10 +285,14 @@ npm run validate
218
285
  # Uninstall an app
219
286
  npx spine-framework uninstall-app spine-framework-{slug}
220
287
 
221
- # Update an app
288
+ # Update app code from npm (preserves local manifest.json)
222
289
  npx spine-framework update-app spine-framework-{slug}
223
290
  ```
224
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
+
225
296
  `validate-app` checks:
226
297
  - `manifest.json` present, valid JSON, all required fields
227
298
  - `package.json` present, versions match, `"private": false`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework",
3
- "version": "0.3.77",
3
+ "version": "0.3.79",
4
4
  "description": "Multi-tenant, modular application platform for modern SaaS systems",
5
5
  "type": "module",
6
6
  "bin": {