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.
- package/.framework/cli/commands/claim.ts +447 -0
- package/.framework/cli/commands/install-app.ts +67 -7
- package/.framework/cli/commands/migrate.ts +13 -1
- package/.framework/cli/commands/publish.ts +191 -0
- package/.framework/cli/commands/status.ts +74 -3
- package/.framework/cli/commands/update-app.ts +397 -0
- package/.framework/cli/index.ts +12 -0
- package/.framework/migrations/000_foundation.sql +18 -4
- package/.framework/migrations/004_app_version_tracking.sql +8 -0
- package/.framework/src/App.tsx +20 -8
- package/.framework/src/components/CustomAppLoader.tsx +19 -2
- package/LICENSE.md +18 -0
- package/bin/{spine-framework.cjs → spine-framework.js} +1 -1
- package/bin/{welcome.cjs → welcome.js} +2 -2
- package/config/vite.config.ts +0 -1
- package/dist/cli/commands/claim.d.ts +56 -0
- package/dist/cli/commands/claim.d.ts.map +1 -0
- package/dist/cli/commands/install-app.d.ts.map +1 -1
- package/dist/cli/commands/migrate.d.ts.map +1 -1
- package/dist/cli/commands/publish.d.ts +23 -0
- package/dist/cli/commands/publish.d.ts.map +1 -0
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/update-app.d.ts +24 -0
- package/dist/cli/commands/update-app.d.ts.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/config/signals.d.ts +82 -0
- package/dist/config/signals.d.ts.map +1 -0
- package/package-project.json +9 -2
- package/package.json +10 -8
- package/scripts/assemble-frontend.sh +4 -7
- package/bin/ws-shim.cjs +0 -8
|
@@ -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
|
+
}
|
package/.framework/cli/index.ts
CHANGED
|
@@ -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;
|
package/.framework/src/App.tsx
CHANGED
|
@@ -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={
|
|
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={
|
|
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
|
-
|
|
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('
|
|
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 =
|
|
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.
|
|
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.
|
|
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/
|
|
41
|
+
' Docs: https://github.com/spine-framework/spine-framework',
|
|
42
42
|
'',
|
|
43
43
|
]
|
|
44
44
|
|
package/config/vite.config.ts
CHANGED
|
@@ -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
|
},
|