spine-framework 0.3.77 → 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 {
|
|
@@ -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
|
|
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
|
+
}
|
package/custom/.devin/AGENT.md
CHANGED
|
@@ -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": ["/
|
|
89
|
+
"routes": ["/", "/page"],
|
|
89
90
|
"nav_items": [
|
|
90
|
-
{ "title": "Home", "path": "/
|
|
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 |
|
|
120
|
-
| `nav_items` | recommended |
|
|
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
|
|
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`
|