spine-framework 0.1.61 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,397 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * @module cli/commands/update-app
4
+ * @audience installer
5
+ * @layer cli
6
+ * @stability stable
7
+ *
8
+ * `spine-framework update-app <slug>` — Check for and assist with app updates.
9
+ *
10
+ * Design principle: the agentic IDE is the merge tool. This command never
11
+ * writes to custom/apps/. It only:
12
+ * 1. Gets the new version into node_modules/ (read-only upstream reference)
13
+ * 2. Gives the agent a complete picture: versions, changelog, file diff summary
14
+ * 3. Records the new version in DB once the agent signals it is done (--accept)
15
+ *
16
+ * **Usage:**
17
+ * ```bash
18
+ * spine-framework update-app cortex # check for updates
19
+ * spine-framework update-app cortex --dry-run # full plan: changelog + file diff
20
+ * spine-framework update-app cortex --accept # record new version in DB only
21
+ * ```
22
+ */
23
+
24
+ import type { Command } from 'commander'
25
+ import { existsSync, readFileSync, readdirSync, createReadStream } from 'fs'
26
+ import { resolve, dirname } from 'path'
27
+ import { fileURLToPath } from 'url'
28
+ import { execSync } from 'child_process'
29
+ import { adminDb } from '../../functions/_shared/index.ts'
30
+
31
+ const __filename = fileURLToPath(import.meta.url)
32
+ const __dirname = dirname(__filename)
33
+ const PROJECT_ROOT = process.cwd()
34
+
35
+ interface UpdateOptions {
36
+ dryRun: boolean
37
+ accept: boolean
38
+ }
39
+
40
+ interface InstallationRecord {
41
+ app_slug: string
42
+ is_enabled: boolean
43
+ installed_version: string | null
44
+ package_name: string | null
45
+ }
46
+
47
+ // ─── NPM REGISTRY ─────────────────────────────────────────────────────────────
48
+
49
+ async function fetchLatestVersion(packageName: string): Promise<string | null> {
50
+ try {
51
+ const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`
52
+ const res = await fetch(url)
53
+ if (!res.ok) return null
54
+ const data = await res.json() as { version?: string }
55
+ return data.version || null
56
+ } catch {
57
+ return null
58
+ }
59
+ }
60
+
61
+ // ─── SEMVER COMPARISON ────────────────────────────────────────────────────────
62
+
63
+ function parseSemver(v: string): [number, number, number] {
64
+ const parts = v.replace(/^v/, '').split('.').map(Number)
65
+ return [parts[0] || 0, parts[1] || 0, parts[2] || 0]
66
+ }
67
+
68
+ function isNewer(candidate: string, current: string): boolean {
69
+ const [cMaj, cMin, cPatch] = parseSemver(candidate)
70
+ const [iMaj, iMin, iPatch] = parseSemver(current)
71
+ if (cMaj !== iMaj) return cMaj > iMaj
72
+ if (cMin !== iMin) return cMin > iMin
73
+ return cPatch > iPatch
74
+ }
75
+
76
+ function changelogBetween(
77
+ changelog: Array<{ version: string; notes: string[] }>,
78
+ fromVersion: string,
79
+ toVersion: string,
80
+ ): Array<{ version: string; notes: string[] }> {
81
+ return changelog.filter(entry => isNewer(entry.version, fromVersion) && !isNewer(entry.version, toVersion))
82
+ }
83
+
84
+ // ─── FILE DIFF SUMMARY ────────────────────────────────────────────────────────
85
+
86
+ function readFileSafe(filePath: string): string | null {
87
+ try { return readFileSync(filePath, 'utf8') } catch { return null }
88
+ }
89
+
90
+ function diffAppFiles(
91
+ pkgDir: string,
92
+ localDir: string,
93
+ dirs: string[],
94
+ topFiles: string[],
95
+ ): { changed: string[]; added: string[]; localOnly: string[] } {
96
+ const changed: string[] = []
97
+ const added: string[] = []
98
+ const localOnly: string[] = []
99
+
100
+ function diffFile(relPath: string) {
101
+ const upstreamContent = readFileSafe(resolve(pkgDir, relPath))
102
+ const localContent = readFileSafe(resolve(localDir, relPath))
103
+ if (upstreamContent === null && localContent !== null) {
104
+ localOnly.push(relPath)
105
+ } else if (upstreamContent !== null && localContent === null) {
106
+ added.push(relPath)
107
+ } else if (upstreamContent !== null && localContent !== null && upstreamContent !== localContent) {
108
+ changed.push(relPath)
109
+ }
110
+ }
111
+
112
+ for (const file of topFiles) {
113
+ diffFile(file)
114
+ }
115
+
116
+ for (const dir of dirs) {
117
+ const upstreamDirPath = resolve(pkgDir, dir)
118
+ if (!existsSync(upstreamDirPath)) continue
119
+ const walkDir = (base: string, rel: string) => {
120
+ const entries = readdirSync(resolve(base, rel), { withFileTypes: true })
121
+ for (const entry of entries) {
122
+ const entryRel = rel ? `${rel}/${entry.name}` : entry.name
123
+ if (entry.isDirectory()) {
124
+ walkDir(base, entryRel)
125
+ } else {
126
+ diffFile(`${dir}/${entryRel}`)
127
+ }
128
+ }
129
+ }
130
+ walkDir(upstreamDirPath, '')
131
+ }
132
+
133
+ return { changed, added, localOnly }
134
+ }
135
+
136
+ // ─── SEED HELPERS (reused from install-app logic) ─────────────────────────────
137
+
138
+ async function resolveAppId(slug: string): Promise<string | null> {
139
+ const { data } = await adminDb.from('apps').select('id').eq('slug', slug).single()
140
+ return data?.id || null
141
+ }
142
+
143
+ async function upsertSeedFile(
144
+ appId: string,
145
+ seedFile: string,
146
+ records: any[],
147
+ ): Promise<void> {
148
+ const tableMap: Record<string, string> = {
149
+ 'types.json': 'types',
150
+ 'link-types.json': 'link_types',
151
+ 'roles.json': 'roles',
152
+ 'pipelines.json': 'pipelines',
153
+ }
154
+ const table = tableMap[seedFile]
155
+ if (!table) return
156
+
157
+ const conflictMap: Record<string, string> = {
158
+ types: 'app_id,kind,slug',
159
+ link_types: 'app_id,slug',
160
+ roles: 'app_id,slug',
161
+ pipelines: 'app_id,name',
162
+ }
163
+
164
+ for (const record of records) {
165
+ await adminDb.from(table).upsert(
166
+ { ...record, app_id: appId },
167
+ { onConflict: conflictMap[table], ignoreDuplicates: false },
168
+ )
169
+ }
170
+ }
171
+
172
+ async function reapplySeed(slug: string, pkgDir: string): Promise<void> {
173
+ const appId = await resolveAppId(slug)
174
+ if (!appId) {
175
+ console.log(` ⚠️ App '${slug}' not found in DB — skipping seed re-apply`)
176
+ return
177
+ }
178
+
179
+ const seedDir = resolve(pkgDir, 'seed')
180
+ if (!existsSync(seedDir)) return
181
+
182
+ const SEED_ORDER = ['accounts.json', 'types.json', 'link-types.json', 'roles.json', 'pipelines.json', 'triggers.json']
183
+ const allFiles = readdirSync(seedDir).filter(f => f.endsWith('.json'))
184
+ const seedFiles = [
185
+ ...SEED_ORDER.filter(f => allFiles.includes(f)),
186
+ ...allFiles.filter(f => !SEED_ORDER.includes(f)),
187
+ ]
188
+
189
+ for (const file of seedFiles) {
190
+ try {
191
+ const records = JSON.parse(readFileSync(resolve(seedDir, file), 'utf8'))
192
+ if (Array.isArray(records)) {
193
+ await upsertSeedFile(appId, file, records)
194
+ console.log(` ✓ Re-seeded ${file}`)
195
+ }
196
+ } catch { /* ignore parse errors */ }
197
+ }
198
+ }
199
+
200
+ // ─── MAIN ─────────────────────────────────────────────────────────────────────
201
+
202
+ async function updateApp(slug: string, options: UpdateOptions): Promise<void> {
203
+ console.log(`\n🔄 Update check for '${slug}'...\n`)
204
+
205
+ // 1. Read installation record from DB
206
+ const { data: installation, error: dbErr } = await adminDb
207
+ .from('app_installations')
208
+ .select('app_slug, is_enabled, installed_version, package_name')
209
+ .eq('app_slug', slug)
210
+ .maybeSingle() as { data: InstallationRecord | null; error: any }
211
+
212
+ if (dbErr) {
213
+ console.error(` ❌ DB error: ${dbErr.message}`)
214
+ console.error(` Has migration 004_app_version_tracking.sql been applied?`)
215
+ process.exit(1)
216
+ }
217
+
218
+ if (!installation) {
219
+ console.error(` ❌ App '${slug}' is not installed (not found in app_installations).`)
220
+ console.error(` Run: spine-framework install-app spine-framework-${slug}`)
221
+ process.exit(1)
222
+ }
223
+
224
+ const packageName = installation.package_name || `spine-framework-${slug}`
225
+ const installedVersion = installation.installed_version
226
+
227
+ if (!installedVersion) {
228
+ console.log(` ⚠️ No installed_version recorded. Re-install to track version:`)
229
+ console.log(` spine-framework install-app ${packageName} --force`)
230
+ process.exit(1)
231
+ }
232
+
233
+ // 2. Fetch latest from npm registry
234
+ process.stdout.write(` Checking npm registry for ${packageName}... `)
235
+ const latestVersion = await fetchLatestVersion(packageName)
236
+ if (!latestVersion) {
237
+ console.log(`❌`)
238
+ console.error(` Could not reach npm registry. Check your internet connection.`)
239
+ process.exit(1)
240
+ }
241
+ console.log(`done`)
242
+
243
+ console.log(` Installed : v${installedVersion}`)
244
+ console.log(` Latest : v${latestVersion}`)
245
+
246
+ if (!isNewer(latestVersion, installedVersion)) {
247
+ console.log(`\n✅ Already up to date.\n`)
248
+ return
249
+ }
250
+
251
+ console.log(` Status : update available\n`)
252
+
253
+ if (!options.dryRun && !options.accept) {
254
+ console.log(` Run with --dry-run to see changelog and file diff.`)
255
+ console.log(` Run with --accept after merging to record the new version.\n`)
256
+ return
257
+ }
258
+
259
+ // ── --dry-run ──────────────────────────────────────────────────────────────
260
+ if (options.dryRun) {
261
+ console.log(`📥 Fetching ${packageName}@latest into node_modules (read-only reference)...`)
262
+ try {
263
+ execSync(`npm install ${packageName}@latest`, { cwd: PROJECT_ROOT, stdio: 'inherit' })
264
+ console.log(` ✓ Done\n`)
265
+ } catch (err: any) {
266
+ console.error(` ❌ npm install failed: ${err.message}`)
267
+ process.exit(1)
268
+ }
269
+
270
+ const pkgDir = resolve(PROJECT_ROOT, `node_modules/${packageName}`)
271
+ const localDir = resolve(PROJECT_ROOT, `custom/apps/${slug}`)
272
+
273
+ // Read changelog from new upstream version
274
+ const upstreamManifestPath = resolve(pkgDir, 'manifest.json')
275
+ let changelog: Array<{ version: string; notes: string[] }> = []
276
+ let newVersion = latestVersion
277
+ if (existsSync(upstreamManifestPath)) {
278
+ try {
279
+ const m = JSON.parse(readFileSync(upstreamManifestPath, 'utf8'))
280
+ changelog = m.changelog || []
281
+ newVersion = m.version || latestVersion
282
+ } catch { /* ignore */ }
283
+ }
284
+
285
+ // Print changelog entries between installed and latest
286
+ const relevantChangelog = changelogBetween(changelog, installedVersion, newVersion)
287
+ console.log(`📋 Changelog (v${installedVersion} → v${newVersion}):`)
288
+ if (relevantChangelog.length === 0) {
289
+ console.log(` (no changelog entries found between these versions)`)
290
+ } else {
291
+ for (const entry of relevantChangelog) {
292
+ console.log(`\n v${entry.version}:`)
293
+ for (const note of entry.notes) {
294
+ console.log(` • ${note}`)
295
+ }
296
+ }
297
+ }
298
+
299
+ // Read directories list from upstream manifest
300
+ let appDirs = ['pages', 'components', 'hooks', 'config', 'seed', 'api', 'functions', 'lib']
301
+ try {
302
+ const m = JSON.parse(readFileSync(upstreamManifestPath, 'utf8'))
303
+ if (Array.isArray(m.directories)) appDirs = m.directories.filter((d: string) => d !== 'migrations' && d !== 'tests')
304
+ } catch { /* use defaults */ }
305
+
306
+ const topFiles = ['index.tsx', 'manifest.json']
307
+ const diff = diffAppFiles(pkgDir, localDir, appDirs, topFiles)
308
+
309
+ console.log(`\n📁 File diff summary (upstream vs your version):`)
310
+ console.log(` Upstream (new) : node_modules/${packageName}/`)
311
+ console.log(` Your version : custom/apps/${slug}/\n`)
312
+
313
+ if (diff.changed.length === 0 && diff.added.length === 0 && diff.localOnly.length === 0) {
314
+ console.log(` (no file differences detected)`)
315
+ } else {
316
+ if (diff.changed.length > 0) {
317
+ console.log(` Modified (${diff.changed.length}) — review and merge:`)
318
+ diff.changed.forEach(f => console.log(` ~ ${f}`))
319
+ }
320
+ if (diff.added.length > 0) {
321
+ console.log(`\n New in upstream (${diff.added.length}) — copy to your version:`)
322
+ diff.added.forEach(f => console.log(` + ${f}`))
323
+ }
324
+ if (diff.localOnly.length > 0) {
325
+ console.log(`\n Only in your version (${diff.localOnly.length}) — your additions, keep them:`)
326
+ diff.localOnly.forEach(f => console.log(` → ${f}`))
327
+ }
328
+ }
329
+
330
+ console.log(`\n──────────────────────────────────────────────────────────────`)
331
+ console.log(` Next steps:`)
332
+ console.log(` 1. Review and merge changed files listed above`)
333
+ console.log(` 2. Run: spine-framework update-app ${slug} --accept`)
334
+ console.log(`──────────────────────────────────────────────────────────────\n`)
335
+ return
336
+ }
337
+
338
+ // ── --accept ──────────────────────────────────────────────────────────────
339
+ if (options.accept) {
340
+ console.log(`📝 Recording update...`)
341
+
342
+ const pkgDir = resolve(PROJECT_ROOT, `node_modules/${packageName}`)
343
+ if (!existsSync(pkgDir)) {
344
+ console.error(` ❌ ${packageName} not found in node_modules.`)
345
+ console.error(` Run --dry-run first to fetch the new version.`)
346
+ process.exit(1)
347
+ }
348
+
349
+ // Read new version from upstream manifest
350
+ let newVersion = latestVersion
351
+ const upstreamManifestPath = resolve(pkgDir, 'manifest.json')
352
+ if (existsSync(upstreamManifestPath)) {
353
+ try {
354
+ newVersion = JSON.parse(readFileSync(upstreamManifestPath, 'utf8')).version || latestVersion
355
+ } catch { /* use latestVersion */ }
356
+ }
357
+
358
+ const { error: updateErr } = await adminDb
359
+ .from('app_installations')
360
+ .update({
361
+ installed_version: newVersion,
362
+ updated_at: new Date().toISOString(),
363
+ })
364
+ .eq('app_slug', slug)
365
+
366
+ if (updateErr) {
367
+ console.error(` ❌ Failed to update installed_version: ${updateErr.message}`)
368
+ process.exit(1)
369
+ }
370
+
371
+ console.log(` ✓ installed_version updated to v${newVersion}`)
372
+
373
+ // Re-apply seed (idempotent)
374
+ console.log(`\n🌱 Re-applying seed data (idempotent)...`)
375
+ await reapplySeed(slug, pkgDir)
376
+
377
+ console.log(`\n✅ Update recorded for '${slug}' → v${newVersion}`)
378
+ console.log(` Run 'spine-framework status' to verify.\n`)
379
+ }
380
+ }
381
+
382
+ export function registerUpdateAppCommands(program: Command) {
383
+ program
384
+ .command('update-app <slug>')
385
+ .description('Check for app updates and assist with agentic-IDE-driven merge')
386
+ .option('--dry-run', 'Fetch latest, show changelog + file diff (no writes to custom/apps/)', false)
387
+ .option('--accept', 'Record new version in DB after agent has merged files', false)
388
+ .action(async (slug, opts) => {
389
+ try {
390
+ await updateApp(slug, opts)
391
+ } catch (err: any) {
392
+ console.error('Error:', err.message)
393
+ if (process.env.SPINE_CLI_DEBUG) console.error(err.stack)
394
+ process.exit(1)
395
+ }
396
+ })
397
+ }
@@ -21,7 +21,10 @@
21
21
  * | migrations | commands/migrations.ts |
22
22
  * | init | commands/init.ts |
23
23
  * | install-app | commands/install-app.ts |
24
+ * | update-app | commands/update-app.ts |
25
+ * | publish | commands/publish.ts |
24
26
  * | status | commands/status.ts |
27
+ * | claim | commands/claim.ts |
25
28
  * | uninstall | commands/uninstall-app.ts |
26
29
  *
27
30
  * **Usage:**
@@ -75,7 +78,10 @@ if (!noDbCommands.includes(subcommand)) {
75
78
  { registerGenerateCommands },
76
79
  { registerCreateAppCommands },
77
80
  { registerInstallAppCommands },
81
+ { registerUpdateAppCommands },
82
+ { registerPublishCommands },
78
83
  { registerStatusCommands },
84
+ { registerClaimCommands },
79
85
  { registerUninstallAppCommands },
80
86
  ] = await Promise.all([
81
87
  import('./commands/auth.ts'),
@@ -90,7 +96,10 @@ if (!noDbCommands.includes(subcommand)) {
90
96
  import('./commands/generate.ts'),
91
97
  import('./commands/create-app.ts'),
92
98
  import('./commands/install-app.ts'),
99
+ import('./commands/update-app.ts'),
100
+ import('./commands/publish.ts'),
93
101
  import('./commands/status.ts'),
102
+ import('./commands/claim.ts'),
94
103
  import('./commands/uninstall-app.ts'),
95
104
  ])
96
105
 
@@ -106,7 +115,10 @@ if (!noDbCommands.includes(subcommand)) {
106
115
  registerGenerateCommands(program)
107
116
  registerCreateAppCommands(program)
108
117
  registerInstallAppCommands(program)
118
+ registerUpdateAppCommands(program)
119
+ registerPublishCommands(program)
109
120
  registerStatusCommands(program)
121
+ registerClaimCommands(program)
110
122
  registerUninstallAppCommands(program)
111
123
  }
112
124
 
@@ -139,8 +139,7 @@ CREATE TABLE IF NOT EXISTS public.roles (
139
139
  app_id uuid,
140
140
  account_id uuid,
141
141
  created_at timestamptz DEFAULT now(),
142
- updated_at timestamptz DEFAULT now(),
143
- UNIQUE (app_id, slug)
142
+ updated_at timestamptz DEFAULT now()
144
143
  );
145
144
 
146
145
  -- apps (no dependencies on other custom tables)
@@ -764,8 +763,7 @@ CREATE TABLE IF NOT EXISTS public.app_installations (
764
763
  app_slug varchar NOT NULL,
765
764
  is_enabled boolean DEFAULT true,
766
765
  installed_at timestamptz DEFAULT now(),
767
- updated_at timestamptz DEFAULT now(),
768
- UNIQUE (account_id, app_slug)
766
+ updated_at timestamptz DEFAULT now()
769
767
  );
770
768
 
771
769
  -- ---------------------------------------------------------------------------
@@ -795,6 +793,14 @@ CREATE INDEX IF NOT EXISTS idx_api_keys_key_value ON public.api_keys (key_valu
795
793
 
796
794
  CREATE INDEX IF NOT EXISTS idx_app_installations_account ON public.app_installations (account_id) WHERE (is_enabled = true);
797
795
  CREATE INDEX IF NOT EXISTS idx_app_installations_slug ON public.app_installations (app_slug) WHERE (is_enabled = true);
796
+ -- Dedup: system installs (account_id IS NULL) — one row per app slug
797
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_app_installations_slug_no_account
798
+ ON public.app_installations (app_slug)
799
+ WHERE account_id IS NULL;
800
+ -- Dedup: tenant installs (account_id IS NOT NULL) — one row per (slug, account)
801
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_app_installations_slug_with_account
802
+ ON public.app_installations (app_slug, account_id)
803
+ WHERE account_id IS NOT NULL;
798
804
 
799
805
  CREATE INDEX IF NOT EXISTS idx_apps_owner ON public.apps (owner_account_id);
800
806
  CREATE INDEX IF NOT EXISTS idx_apps_slug ON public.apps (slug);
@@ -848,6 +854,14 @@ CREATE INDEX IF NOT EXISTS idx_prompt_configs_app ON public.prompt_configs (
848
854
  CREATE INDEX IF NOT EXISTS idx_prompt_configs_slug ON public.prompt_configs (slug);
849
855
 
850
856
  CREATE INDEX IF NOT EXISTS idx_roles_slug ON public.roles (slug);
857
+ -- Dedup: system roles (app_id IS NULL) — one row per slug globally
858
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_roles_slug_no_app
859
+ ON public.roles (slug)
860
+ WHERE app_id IS NULL;
861
+ -- Dedup: app-scoped roles (app_id IS NOT NULL) — one row per (app_id, slug)
862
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_roles_slug_with_app
863
+ ON public.roles (app_id, slug)
864
+ WHERE app_id IS NOT NULL;
851
865
 
852
866
  CREATE INDEX IF NOT EXISTS idx_se_account ON public.schedule_executions (account_id);
853
867
  CREATE INDEX IF NOT EXISTS idx_se_schedule ON public.schedule_executions (schedule_id);
@@ -0,0 +1,8 @@
1
+ -- 004_app_version_tracking.sql
2
+ -- Adds version tracking columns to app_installations so the CLI and
3
+ -- agentic IDE can detect drift between the installed version and the
4
+ -- latest published npm package version.
5
+
6
+ ALTER TABLE public.app_installations
7
+ ADD COLUMN IF NOT EXISTS installed_version varchar,
8
+ ADD COLUMN IF NOT EXISTS package_name varchar;
@@ -8,7 +8,7 @@ import { NotFoundPage } from './pages/NotFoundPage'
8
8
  import { DashboardPage } from './pages/DashboardPage'
9
9
  import { AppWrapper } from './components/AppWrapper'
10
10
  import { AppsRegistryProvider, useAppsRegistry } from './contexts/AppContext'
11
- import { CustomAppLoader } from './components/CustomAppLoader'
11
+ import { CustomAppLoader, getInstalledAppSlugs } from './components/CustomAppLoader'
12
12
  import { GenericAppShell } from './components/app-shell/GenericAppShell'
13
13
  import { APIPage } from './pages/spine-framework/APIPage'
14
14
  import { CLIPage } from './pages/spine-framework/CLIPage'
@@ -58,22 +58,31 @@ function AuthenticatedRouter() {
58
58
  )
59
59
  }
60
60
 
61
+ // Filter to apps that are physically present in this build (custom renderer only —
62
+ // generic apps have no module to load so they're always allowed through).
63
+ const installedSlugs = getInstalledAppSlugs()
64
+
61
65
  // Sort: explicit prefixes first, root (/) last
62
66
  const sorted = [...apps]
63
67
  .filter(app => app.slug !== 'spine-framework' && !app.route_prefix?.startsWith('/spine-framework'))
68
+ .filter(app => app.renderer !== 'custom' || installedSlugs.has(app.slug))
64
69
  .sort((a, b) => {
65
70
  if (a.route_prefix === '/') return 1
66
71
  if (b.route_prefix === '/') return -1
67
72
  return (b.route_prefix?.length || 0) - (a.route_prefix?.length || 0)
68
73
  })
69
74
 
75
+ // When an app is mounted at root it owns all routes — suppress the
76
+ // framework's own /dashboard and catch-all 404 to avoid conflicts.
77
+ const hasRootApp = sorted.some(app => app.route_prefix === '/')
78
+
70
79
  return (
71
80
  <Suspense fallback={<div className="min-h-screen flex items-center justify-center"><LoadingSpinner /></div>}>
72
81
  <Routes>
73
- {/* Default redirect */}
74
- <Route path="/" element={<Navigate to="/dashboard" replace />} />
75
- <Route path="/dashboard" element={<DashboardPage />} />
76
-
82
+ {/* Default redirect — suppressed when a root app is installed */}
83
+ {!hasRootApp && <Route path="/" element={<Navigate to="/dashboard" replace />} />}
84
+ {!hasRootApp && <Route path="/dashboard" element={<DashboardPage />} />}
85
+
77
86
  {/* Login route for authenticated users (redirects to qualified app) */}
78
87
  <Route path="/login" element={<LoginPage />} />
79
88
 
@@ -85,12 +94,14 @@ function AuthenticatedRouter() {
85
94
  {/* Dynamic app routes */}
86
95
  {sorted.map(app => {
87
96
  const prefix = app.route_prefix!
97
+ // React Router v6: path="/*" does NOT work for root — must use path="*"
98
+ const routePath = prefix === '/' ? '*' : `${prefix}/*`
88
99
 
89
100
  if (app.renderer === 'custom') {
90
101
  return (
91
102
  <Route
92
103
  key={app.slug}
93
- path={`${prefix}/*`}
104
+ path={routePath}
94
105
  element={
95
106
  <AppWrapper app={app}>
96
107
  <CustomAppLoader slug={app.slug} />
@@ -104,7 +115,7 @@ function AuthenticatedRouter() {
104
115
  return (
105
116
  <Route
106
117
  key={app.slug}
107
- path={`${prefix}/*`}
118
+ path={routePath}
108
119
  element={
109
120
  <AppWrapper app={app}>
110
121
  <GenericAppShell app={app} />
@@ -117,7 +128,8 @@ function AuthenticatedRouter() {
117
128
  return null
118
129
  })}
119
130
 
120
- <Route path="*" element={<NotFoundPage />} />
131
+ {/* 404 — suppressed when a root app is installed (it handles its own not-found) */}
132
+ {!hasRootApp && <Route path="*" element={<NotFoundPage />} />}
121
133
  </Routes>
122
134
  </Suspense>
123
135
  )
@@ -57,16 +57,33 @@ class AppErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState>
57
57
  // Static glob maps — Vite analyzes these at build time and builds a complete
58
58
  // module map. Runtime lookup is just a map key lookup, no dynamic string eval.
59
59
  // Custom apps take precedence over core apps with the same slug.
60
- const customAppModules = import.meta.glob('../../../custom/apps/*/index.tsx')
60
+ const customAppModules = import.meta.glob('../../custom/apps/*/index.tsx')
61
61
  const coreAppModules = import.meta.glob('../apps/*/index.tsx')
62
62
 
63
+ // Extract app slug from glob key path (e.g., '.../apps/xyz/index.tsx' → 'xyz')
64
+ function slugFromKey(key: string): string {
65
+ return key.split('/').slice(-2)[0]
66
+ }
67
+
68
+ /**
69
+ * Returns the set of app slugs that are physically present in this build.
70
+ * Used by App.tsx to filter DB app records down to only what's actually installed,
71
+ * preventing route registration for apps that exist in the DB but not this deployment.
72
+ */
73
+ export function getInstalledAppSlugs(): Set<string> {
74
+ const slugs = new Set<string>()
75
+ Object.keys(customAppModules).forEach(k => slugs.add(slugFromKey(k)))
76
+ Object.keys(coreAppModules).forEach(k => slugs.add(slugFromKey(k)))
77
+ return slugs
78
+ }
79
+
63
80
  // Module-level cache for lazy components to avoid re-creating on each render
64
81
  const appModuleCache = new Map<string, React.LazyExoticComponent<React.ComponentType>>()
65
82
 
66
83
  // Resolve the glob key for a given slug from the custom or core map.
67
84
  function resolveAppLoader(slug: string): (() => Promise<{ default: React.ComponentType }>) | null {
68
85
  // Check custom first (custom/apps/{slug}/index.tsx)
69
- const customKey = `../../../custom/apps/${slug}/index.tsx`
86
+ const customKey = `../../custom/apps/${slug}/index.tsx`
70
87
  if (customAppModules[customKey]) {
71
88
  return customAppModules[customKey] as () => Promise<{ default: React.ComponentType }>
72
89
  }
package/LICENSE.md ADDED
@@ -0,0 +1,18 @@
1
+ # Spine Framework Internal Use License 1.0.0
2
+
3
+ **Source-available. Free for internal use. Commercial rights reserved.**
4
+
5
+ Copyright © 2026 Dahl Ventures Inc. All rights reserved.
6
+
7
+ This license is based on the PolyForm Internal Use License 1.0.0, modified for Spine Framework.
8
+
9
+ ## Quick Summary
10
+
11
+ **You CAN:** Use Spine Framework inside your company for internal business operations.
12
+ **You CANNOT:** Sell it, offer it as SaaS, white-label it, distribute it, or use it to build competing products without a Commercial License.
13
+
14
+ ## Full Terms
15
+
16
+ See the complete license text at: https://spine-framework.com/license
17
+
18
+ For Commercial Licensing inquiries: webmaster@spine-framework.com
@@ -10,7 +10,7 @@ const projectRoot = resolve(pkgRoot, '../..')
10
10
 
11
11
  // Handle no-args case here — before spawning tsx or loading db.ts
12
12
  if (process.argv.length === 2) {
13
- require(resolve(__dirname, 'welcome.cjs'))
13
+ require(resolve(__dirname, 'welcome.js'))
14
14
  process.exit(0)
15
15
  }
16
16
 
@@ -12,7 +12,7 @@ const lines = [
12
12
  '│ ███████╗██████╔╝██║██╔██╗ ██║█████╗ │',
13
13
  '│ ╚════██║██╔═══╝ ██║██║╚██╗██║██╔══╝ │',
14
14
  '│ ███████║██║ ██║██║ ╚████║███████╗ │',
15
- '│ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝ Framework v0.1 │',
15
+ '│ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝ Framework v0.2 │',
16
16
  '│ │',
17
17
  '╰──────────────────────────────────────────────────────────────╯',
18
18
  '',
@@ -38,7 +38,7 @@ const lines = [
38
38
  ' spine-framework install-app <app-slug> # optional apps',
39
39
  ' npm run assemble && netlify dev # start dev server',
40
40
  '',
41
- ' Docs: https://github.com/art-mojo-admin/spine',
41
+ ' Docs: https://github.com/spine-framework/spine-framework',
42
42
  '',
43
43
  ]
44
44
 
@@ -21,7 +21,6 @@ export default defineConfig({
21
21
  'vendor-react': ['react', 'react-dom', 'react-router-dom'],
22
22
  'vendor-supabase': ['@supabase/supabase-js'],
23
23
  'vendor-ui': ['lucide-react', 'clsx', 'tailwind-merge', 'class-variance-authority'],
24
- 'vendor-markdown': ['marked'],
25
24
  },
26
25
  },
27
26
  },