spine-framework 0.3.19 → 0.3.21

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,202 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * @module cli/commands/init
4
+ * @audience installer
5
+ * @layer cli
6
+ * @stability stable
7
+ *
8
+ * `spine-framework init` — scaffolds a new Spine Framework project in the
9
+ * current directory. Copies framework files, creates the custom/ workspace,
10
+ * writes package.json and .env template.
11
+ *
12
+ * **Usage:**
13
+ * ```bash
14
+ * npx spine-framework init
15
+ * ```
16
+ */
17
+
18
+ import type { Command } from 'commander'
19
+ import { existsSync, mkdirSync, writeFileSync, copyFileSync, readdirSync, statSync, cpSync } from 'fs'
20
+ import { resolve, dirname, join } from 'path'
21
+ import { fileURLToPath } from 'url'
22
+ import { execSync } from 'child_process'
23
+
24
+ const __filename = fileURLToPath(import.meta.url)
25
+ const __dirname = dirname(__filename)
26
+
27
+ // Root of the spine-framework package (node_modules/spine-framework/)
28
+ const PACKAGE_ROOT = resolve(__dirname, '../../../')
29
+ // Root of the user's project (cwd)
30
+ const PROJECT_ROOT = process.cwd()
31
+
32
+ export function registerInitCommands(program: Command) {
33
+ program
34
+ .command('init')
35
+ .description('Scaffold a new Spine Framework project in the current directory')
36
+ .option('--force', 'Overwrite existing files', false)
37
+ .action(async (opts) => {
38
+ console.log('\nInitializing Spine Framework project...\n')
39
+
40
+ // Guard: don't overwrite an existing project unless --force
41
+ if (existsSync(join(PROJECT_ROOT, 'package.json')) && !opts.force) {
42
+ console.error('Error: package.json already exists. Use --force to overwrite.')
43
+ process.exit(1)
44
+ }
45
+
46
+ try {
47
+ // 1. Copy .framework/ into the project
48
+ console.log(' Copying .framework/...')
49
+ const fwSrc = join(PACKAGE_ROOT, '.framework')
50
+ const fwDest = join(PROJECT_ROOT, '.framework')
51
+ if (!existsSync(fwDest)) {
52
+ cpSync(fwSrc, fwDest, { recursive: true })
53
+ console.log(' ✓ .framework/ copied')
54
+ } else {
55
+ console.log(' ○ .framework/ already exists, skipping')
56
+ }
57
+
58
+ // 2. Create custom/ workspace directories
59
+ console.log(' Creating custom/ workspace...')
60
+ const customDirs = [
61
+ 'custom/apps',
62
+ 'custom/functions',
63
+ 'custom/components',
64
+ 'custom/migrations',
65
+ 'custom/tests',
66
+ ]
67
+ for (const dir of customDirs) {
68
+ const full = join(PROJECT_ROOT, dir)
69
+ if (!existsSync(full)) {
70
+ mkdirSync(full, { recursive: true })
71
+ }
72
+ }
73
+ // Copy custom/.devin if present in package
74
+ const devinSrc = join(PACKAGE_ROOT, 'custom/.devin')
75
+ const devinDest = join(PROJECT_ROOT, 'custom/.devin')
76
+ if (existsSync(devinSrc) && !existsSync(devinDest)) {
77
+ cpSync(devinSrc, devinDest, { recursive: true })
78
+ }
79
+ console.log(' ✓ custom/ workspace created')
80
+
81
+ // 3. Copy config files (vite, tailwind, tsconfig, postcss)
82
+ console.log(' Copying config files...')
83
+ const configSrc = join(PACKAGE_ROOT, 'config')
84
+ const configDest = join(PROJECT_ROOT, 'config')
85
+ if (existsSync(configSrc) && !existsSync(configDest)) {
86
+ cpSync(configSrc, configDest, { recursive: true })
87
+ console.log(' ✓ config/ copied')
88
+ }
89
+
90
+ // 4. Copy scripts/
91
+ console.log(' Copying scripts/...')
92
+ const scriptsSrc = join(PACKAGE_ROOT, 'scripts')
93
+ const scriptsDest = join(PROJECT_ROOT, 'scripts')
94
+ if (existsSync(scriptsSrc) && !existsSync(scriptsDest)) {
95
+ cpSync(scriptsSrc, scriptsDest, { recursive: true })
96
+ console.log(' ✓ scripts/ copied')
97
+ }
98
+
99
+ // 5. Copy netlify.toml
100
+ const netlifyTomlSrc = join(PACKAGE_ROOT, 'netlify.toml')
101
+ if (existsSync(netlifyTomlSrc) && !existsSync(join(PROJECT_ROOT, 'netlify.toml'))) {
102
+ copyFileSync(netlifyTomlSrc, join(PROJECT_ROOT, 'netlify.toml'))
103
+ console.log(' ✓ netlify.toml copied')
104
+ }
105
+
106
+ // 6. Write package.json
107
+ console.log(' Writing package.json...')
108
+ const projectName = PROJECT_ROOT.split('/').pop() || 'my-spine-project'
109
+ const pkgJson = {
110
+ name: projectName,
111
+ version: '0.1.0',
112
+ private: true,
113
+ type: 'module',
114
+ scripts: {
115
+ dev: 'netlify dev',
116
+ assemble: 'bash scripts/assemble.sh',
117
+ 'assemble:frontend': 'bash scripts/assemble-frontend.sh',
118
+ 'assemble:functions': 'bash scripts/assemble-functions.sh',
119
+ build: 'npm run assemble && vite build --config config/vite.config.ts',
120
+ verify: 'bash scripts/verify-integrity.sh',
121
+ test: 'vitest run',
122
+ },
123
+ dependencies: {
124
+ 'spine-framework': `^${getPackageVersion()}`,
125
+ },
126
+ }
127
+ if (!existsSync(join(PROJECT_ROOT, 'package.json')) || opts.force) {
128
+ writeFileSync(
129
+ join(PROJECT_ROOT, 'package.json'),
130
+ JSON.stringify(pkgJson, null, 2) + '\n'
131
+ )
132
+ console.log(' ✓ package.json written')
133
+ }
134
+
135
+ // 7. Write .env template
136
+ console.log(' Writing .env template...')
137
+ if (!existsSync(join(PROJECT_ROOT, '.env'))) {
138
+ const envTemplate = [
139
+ '# Supabase credentials',
140
+ '# Get these from: Supabase Dashboard → Your Project → Settings → API',
141
+ 'SUPABASE_URL=https://<project-ref>.supabase.co',
142
+ 'SUPABASE_ANON_KEY=',
143
+ 'SUPABASE_SERVICE_ROLE_KEY=',
144
+ 'SUPABASE_DB_PASSWORD=',
145
+ '',
146
+ '# Vite (browser-safe)',
147
+ 'VITE_SUPABASE_URL=https://<project-ref>.supabase.co',
148
+ 'VITE_SUPABASE_ANON_KEY=',
149
+ 'VITE_DB_SCHEMA=public',
150
+ 'VITE_APP_NAME=Spine',
151
+ '',
152
+ ].join('\n')
153
+ writeFileSync(join(PROJECT_ROOT, '.env'), envTemplate)
154
+ console.log(' ✓ .env template written')
155
+ } else {
156
+ console.log(' ○ .env already exists, skipping')
157
+ }
158
+
159
+ // 8. Write .gitignore
160
+ if (!existsSync(join(PROJECT_ROOT, '.gitignore'))) {
161
+ const gitignore = [
162
+ 'node_modules',
163
+ 'dist',
164
+ '.assembled/',
165
+ '.netlify',
166
+ '.env',
167
+ '.env.local',
168
+ '.env.production',
169
+ '*.log',
170
+ '.DS_Store',
171
+ '*.tsbuildinfo',
172
+ '.vite',
173
+ ].join('\n') + '\n'
174
+ writeFileSync(join(PROJECT_ROOT, '.gitignore'), gitignore)
175
+ console.log(' ✓ .gitignore written')
176
+ }
177
+
178
+ console.log('\n✅ Project initialized!\n')
179
+ console.log('Next steps:')
180
+ console.log(' 1. Edit .env with your Supabase credentials')
181
+ console.log(' 2. Run: npx spine-framework migrate')
182
+ console.log(' 3. Run: npm install && npm run assemble && netlify dev')
183
+ console.log()
184
+
185
+ } catch (err: any) {
186
+ console.error('\n❌ Init failed:', err.message)
187
+ if (process.env.SPINE_CLI_DEBUG) console.error(err.stack)
188
+ process.exit(1)
189
+ }
190
+ })
191
+ }
192
+
193
+ function getPackageVersion(): string {
194
+ try {
195
+ const pkg = JSON.parse(
196
+ require('fs').readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')
197
+ )
198
+ return pkg.version || '0.3.0'
199
+ } catch {
200
+ return '0.3.0'
201
+ }
202
+ }
@@ -0,0 +1,255 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * @module cli/commands/install-app
4
+ * @audience installer
5
+ * @layer cli
6
+ * @stability stable
7
+ *
8
+ * `spine-framework install-app` — installs a Spine Framework app from npm.
9
+ * Copies app files into custom/apps/, seeds types/roles/link-types into the DB.
10
+ *
11
+ * **Usage:**
12
+ * ```bash
13
+ * npx spine-framework install-app spine-framework-portal@latest
14
+ * npx spine-framework install-app spine-framework-cortex
15
+ * npx spine-framework uninstall-app spine-framework-portal
16
+ * npx spine-framework update-app spine-framework-portal
17
+ * ```
18
+ */
19
+
20
+ import type { Command } from 'commander'
21
+ import { existsSync, readFileSync, mkdirSync, cpSync } from 'fs'
22
+ import { resolve, dirname, join, basename } from 'path'
23
+ import { fileURLToPath } from 'url'
24
+ import { execSync } from 'child_process'
25
+ import { createClient } from '@supabase/supabase-js'
26
+
27
+ const __filename = fileURLToPath(import.meta.url)
28
+ const __dirname = dirname(__filename)
29
+ const PROJECT_ROOT = process.cwd()
30
+
31
+ export function registerInstallAppCommands(program: Command) {
32
+ // install-app
33
+ program
34
+ .command('install-app <package>')
35
+ .description('Install a Spine Framework app from npm')
36
+ .option('--no-seed', 'Skip seeding types and roles into the database', false)
37
+ .action(async (packageName: string, opts) => {
38
+ console.log(`\nInstalling app: ${packageName}\n`)
39
+
40
+ loadEnv()
41
+
42
+ try {
43
+ // 1. npm install the package
44
+ console.log(` Installing ${packageName} via npm...`)
45
+ execSync(`npm install ${packageName} --save`, {
46
+ cwd: PROJECT_ROOT,
47
+ stdio: 'inherit',
48
+ })
49
+ console.log(' ✓ npm install complete')
50
+
51
+ // 2. Resolve package location in node_modules
52
+ const pkgSlug = packageName.split('@')[0] // strip version
53
+ const pkgPath = join(PROJECT_ROOT, 'node_modules', pkgSlug)
54
+
55
+ if (!existsSync(pkgPath)) {
56
+ throw new Error(`Package not found at ${pkgPath}`)
57
+ }
58
+
59
+ // 3. Determine app slug from package manifest or name
60
+ const appSlug = resolveAppSlug(pkgPath, pkgSlug)
61
+ console.log(` App slug: ${appSlug}`)
62
+
63
+ // 4. Copy app files to custom/apps/<slug>/
64
+ const appDest = join(PROJECT_ROOT, 'custom/apps', appSlug)
65
+ if (!existsSync(appDest)) {
66
+ mkdirSync(appDest, { recursive: true })
67
+ }
68
+
69
+ // Copy everything except node_modules and package.json internals
70
+ const skipDirs = new Set(['node_modules', '.git'])
71
+ for (const entry of ['index.tsx', 'manifest.json', 'pages', 'components', 'hooks', 'api', 'seed']) {
72
+ const src = join(pkgPath, entry)
73
+ if (existsSync(src)) {
74
+ cpSync(src, join(appDest, entry), { recursive: true })
75
+ }
76
+ }
77
+ console.log(` ✓ App files copied to custom/apps/${appSlug}/`)
78
+
79
+ // 5. Seed types, roles, link-types into database
80
+ if (!opts.seed === false) {
81
+ await seedApp(appDest, appSlug)
82
+ }
83
+
84
+ console.log(`\n✅ ${appSlug} installed successfully!\n`)
85
+ console.log('Next: npm run assemble && netlify dev\n')
86
+
87
+ } catch (err: any) {
88
+ console.error(`\n❌ Install failed: ${err.message}`)
89
+ if (process.env.SPINE_CLI_DEBUG) console.error(err.stack)
90
+ process.exit(1)
91
+ }
92
+ })
93
+
94
+ // uninstall-app
95
+ program
96
+ .command('uninstall-app <package>')
97
+ .description('Remove an installed Spine Framework app')
98
+ .option('--keep-data', 'Keep seeded types and roles in the database', false)
99
+ .action(async (packageName: string, opts) => {
100
+ console.log(`\nUninstalling app: ${packageName}\n`)
101
+ loadEnv()
102
+
103
+ const pkgSlug = packageName.split('@')[0]
104
+ const appSlug = pkgSlug.replace('spine-framework-', '')
105
+ const appDir = join(PROJECT_ROOT, 'custom/apps', appSlug)
106
+
107
+ try {
108
+ if (existsSync(appDir)) {
109
+ const { rmSync } = await import('fs')
110
+ rmSync(appDir, { recursive: true, force: true })
111
+ console.log(` ✓ Removed custom/apps/${appSlug}/`)
112
+ } else {
113
+ console.log(` ○ custom/apps/${appSlug}/ not found, nothing to remove`)
114
+ }
115
+
116
+ execSync(`npm uninstall ${pkgSlug}`, { cwd: PROJECT_ROOT, stdio: 'inherit' })
117
+ console.log(` ✓ npm uninstall complete`)
118
+
119
+ console.log(`\n✅ ${appSlug} uninstalled.\n`)
120
+ } catch (err: any) {
121
+ console.error(`\n❌ Uninstall failed: ${err.message}`)
122
+ process.exit(1)
123
+ }
124
+ })
125
+
126
+ // update-app
127
+ program
128
+ .command('update-app <package>')
129
+ .description('Update an installed Spine Framework app to a newer version')
130
+ .action(async (packageName: string) => {
131
+ console.log(`\nUpdating app: ${packageName}\n`)
132
+ loadEnv()
133
+
134
+ try {
135
+ execSync(`npm install ${packageName}@latest --save`, {
136
+ cwd: PROJECT_ROOT,
137
+ stdio: 'inherit',
138
+ })
139
+
140
+ const pkgSlug = packageName.split('@')[0]
141
+ const pkgPath = join(PROJECT_ROOT, 'node_modules', pkgSlug)
142
+ const appSlug = resolveAppSlug(pkgPath, pkgSlug)
143
+ const appDest = join(PROJECT_ROOT, 'custom/apps', appSlug)
144
+
145
+ if (!existsSync(appDest)) {
146
+ mkdirSync(appDest, { recursive: true })
147
+ }
148
+
149
+ for (const entry of ['index.tsx', 'manifest.json', 'pages', 'components', 'hooks', 'api', 'seed']) {
150
+ const src = join(pkgPath, entry)
151
+ if (existsSync(src)) {
152
+ cpSync(src, join(appDest, entry), { recursive: true })
153
+ }
154
+ }
155
+
156
+ console.log(`\n✅ ${appSlug} updated.\n`)
157
+ } catch (err: any) {
158
+ console.error(`\n❌ Update failed: ${err.message}`)
159
+ process.exit(1)
160
+ }
161
+ })
162
+ }
163
+
164
+ // ─── HELPERS ────────────────────────────────────────────────────────────────
165
+
166
+ function resolveAppSlug(pkgPath: string, pkgName: string): string {
167
+ try {
168
+ const manifest = JSON.parse(readFileSync(join(pkgPath, 'manifest.json'), 'utf8'))
169
+ if (manifest.slug) return manifest.slug
170
+ } catch {}
171
+ // Derive from package name: spine-framework-portal → portal
172
+ return pkgName.replace(/^spine-framework-/, '')
173
+ }
174
+
175
+ function loadEnv() {
176
+ const envPath = join(PROJECT_ROOT, '.env')
177
+ if (!existsSync(envPath)) return
178
+ const lines = readFileSync(envPath, 'utf8').split('\n')
179
+ for (const line of lines) {
180
+ const trimmed = line.trim()
181
+ if (!trimmed || trimmed.startsWith('#')) continue
182
+ const eqIdx = trimmed.indexOf('=')
183
+ if (eqIdx === -1) continue
184
+ const key = trimmed.slice(0, eqIdx).trim()
185
+ const val = trimmed.slice(eqIdx + 1).trim()
186
+ if (key && !(key in process.env)) process.env[key] = val
187
+ }
188
+ }
189
+
190
+ async function seedApp(appDir: string, appSlug: string) {
191
+ const supabaseUrl = process.env.SUPABASE_URL
192
+ const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
193
+
194
+ if (!supabaseUrl || !serviceRoleKey) {
195
+ console.log(' ○ Skipping DB seed (SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY not set)')
196
+ return
197
+ }
198
+
199
+ const db = createClient(supabaseUrl, serviceRoleKey, {
200
+ auth: { persistSession: false },
201
+ })
202
+
203
+ // Get spine-core app id for foreign key
204
+ const { data: coreApp } = await db
205
+ .from('apps')
206
+ .select('id')
207
+ .eq('slug', appSlug)
208
+ .single()
209
+
210
+ const appId = coreApp?.id
211
+
212
+ // Seed types
213
+ const typesPath = join(appDir, 'seed/types.json')
214
+ if (existsSync(typesPath)) {
215
+ const types = JSON.parse(readFileSync(typesPath, 'utf8'))
216
+ console.log(` Seeding ${types.length} type(s) for ${appSlug}...`)
217
+ for (const type of types) {
218
+ const { error } = await db
219
+ .from('types')
220
+ .upsert({ ...type, app_id: appId }, { onConflict: 'slug' })
221
+ if (error) console.warn(` ⚠ type ${type.slug}: ${error.message}`)
222
+ }
223
+ console.log(` ✓ Types seeded`)
224
+ }
225
+
226
+ // Seed roles
227
+ const rolesPath = join(appDir, 'seed/roles.json')
228
+ if (existsSync(rolesPath)) {
229
+ const roles = JSON.parse(readFileSync(rolesPath, 'utf8'))
230
+ console.log(` Seeding ${roles.length} role(s) for ${appSlug}...`)
231
+ for (const role of roles) {
232
+ const { error } = await db
233
+ .from('roles')
234
+ .upsert({ ...role, app_id: appId }, { onConflict: 'slug' })
235
+ if (error) console.warn(` ⚠ role ${role.slug}: ${error.message}`)
236
+ }
237
+ console.log(` ✓ Roles seeded`)
238
+ }
239
+
240
+ // Seed link-types
241
+ const linkTypesPath = join(appDir, 'seed/link-types.json')
242
+ if (existsSync(linkTypesPath)) {
243
+ const linkTypes = JSON.parse(readFileSync(linkTypesPath, 'utf8'))
244
+ if (linkTypes.length > 0) {
245
+ console.log(` Seeding ${linkTypes.length} link type(s) for ${appSlug}...`)
246
+ for (const lt of linkTypes) {
247
+ const { error } = await db
248
+ .from('link_types')
249
+ .upsert({ ...lt, app_id: appId }, { onConflict: 'slug' })
250
+ if (error) console.warn(` ⚠ link_type ${lt.slug}: ${error.message}`)
251
+ }
252
+ console.log(` ✓ Link types seeded`)
253
+ }
254
+ }
255
+ }
@@ -0,0 +1,241 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * @module cli/commands/migrate
4
+ * @audience installer
5
+ * @layer cli
6
+ * @stability stable
7
+ *
8
+ * `spine-framework migrate` — applies pending SQL migrations to the configured
9
+ * Supabase project using the Supabase Management API and direct DB connection.
10
+ *
11
+ * Reads migration files from `.framework/migrations/` and `custom/migrations/`
12
+ * in order, skipping any already tracked in `spine_migrations` table.
13
+ *
14
+ * **Usage:**
15
+ * ```bash
16
+ * npx spine-framework migrate
17
+ * npx spine-framework migrate --dry-run
18
+ * ```
19
+ */
20
+
21
+ import type { Command } from 'commander'
22
+ import { readFileSync, readdirSync, existsSync } from 'fs'
23
+ import { resolve, dirname, join, basename } from 'path'
24
+ import { fileURLToPath } from 'url'
25
+ import { createClient } from '@supabase/supabase-js'
26
+
27
+ const __filename = fileURLToPath(import.meta.url)
28
+ const __dirname = dirname(__filename)
29
+ const PACKAGE_ROOT = resolve(__dirname, '../../../')
30
+ const PROJECT_ROOT = process.cwd()
31
+
32
+ export function registerMigrateCommands(program: Command) {
33
+ program
34
+ .command('migrate')
35
+ .description('Apply pending database migrations to your Supabase project')
36
+ .option('--dry-run', 'Show pending migrations without applying them', false)
37
+ .option('--framework-only', 'Only apply .framework/migrations/, skip custom/', false)
38
+ .action(async (opts) => {
39
+ console.log('\nRunning Spine Framework migrations...\n')
40
+
41
+ // Load env
42
+ loadEnv()
43
+
44
+ const supabaseUrl = process.env.SUPABASE_URL
45
+ const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
46
+
47
+ if (!supabaseUrl || !serviceRoleKey) {
48
+ console.error('Error: SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be set in .env')
49
+ console.error('Run: npx spine-framework init to create a .env template')
50
+ process.exit(1)
51
+ }
52
+
53
+ const db = createClient(supabaseUrl, serviceRoleKey, {
54
+ auth: { persistSession: false },
55
+ db: { schema: 'public' },
56
+ })
57
+
58
+ try {
59
+ // Ensure tracking table exists
60
+ await ensureMigrationsTable(db)
61
+
62
+ // Collect migration files in order
63
+ const migrationFiles = collectMigrations(opts.frameworkOnly)
64
+
65
+ if (migrationFiles.length === 0) {
66
+ console.log('No migration files found.')
67
+ process.exit(0)
68
+ }
69
+
70
+ // Get already-applied migrations
71
+ const { data: applied, error: fetchErr } = await db
72
+ .from('spine_migrations')
73
+ .select('filename')
74
+ .order('applied_at', { ascending: true })
75
+
76
+ if (fetchErr) throw new Error(`Could not read migration state: ${fetchErr.message}`)
77
+
78
+ const appliedSet = new Set((applied || []).map((r: any) => r.filename))
79
+
80
+ const pending = migrationFiles.filter(f => !appliedSet.has(basename(f)))
81
+
82
+ if (pending.length === 0) {
83
+ console.log('✓ All migrations already applied. Nothing to do.\n')
84
+ process.exit(0)
85
+ }
86
+
87
+ console.log(`Found ${pending.length} pending migration(s):\n`)
88
+ for (const f of pending) {
89
+ console.log(` ○ ${basename(f)}`)
90
+ }
91
+ console.log()
92
+
93
+ if (opts.dryRun) {
94
+ console.log('Dry run — no changes made.\n')
95
+ process.exit(0)
96
+ }
97
+
98
+ // Apply each pending migration
99
+ let applied_count = 0
100
+ for (const filepath of pending) {
101
+ const filename = basename(filepath)
102
+ process.stdout.write(` Applying ${filename}...`)
103
+
104
+ const sql = readFileSync(filepath, 'utf8')
105
+
106
+ // Execute via rpc exec_sql or direct query
107
+ const { error } = await db.rpc('exec_sql' as any, { sql }).single()
108
+
109
+ if (error) {
110
+ // exec_sql may not exist — fall back to running via REST
111
+ const fallbackResult = await applyViaMigrationEndpoint(supabaseUrl, serviceRoleKey, sql)
112
+ if (!fallbackResult.ok) {
113
+ console.log(' ✗')
114
+ console.error(`\nFailed to apply ${filename}: ${fallbackResult.error}`)
115
+ console.error('\nFallback: Apply this migration manually in the Supabase SQL editor:')
116
+ console.error(` ${filepath}`)
117
+ process.exit(1)
118
+ }
119
+ }
120
+
121
+ // Track as applied
122
+ await db.from('spine_migrations').insert({
123
+ filename,
124
+ applied_at: new Date().toISOString(),
125
+ })
126
+
127
+ console.log(' ✓')
128
+ applied_count++
129
+ }
130
+
131
+ console.log(`\n✅ Applied ${applied_count} migration(s) successfully.\n`)
132
+
133
+ if (applied_count > 0) {
134
+ console.log('Next: npx spine-framework status — to verify the installation\n')
135
+ }
136
+
137
+ } catch (err: any) {
138
+ console.error('\n❌ Migration failed:', err.message)
139
+ if (process.env.SPINE_CLI_DEBUG) console.error(err.stack)
140
+ process.exit(1)
141
+ }
142
+ })
143
+ }
144
+
145
+ // ─── HELPERS ────────────────────────────────────────────────────────────────
146
+
147
+ function loadEnv() {
148
+ const envPath = join(PROJECT_ROOT, '.env')
149
+ if (!existsSync(envPath)) return
150
+ const lines = readFileSync(envPath, 'utf8').split('\n')
151
+ for (const line of lines) {
152
+ const trimmed = line.trim()
153
+ if (!trimmed || trimmed.startsWith('#')) continue
154
+ const eqIdx = trimmed.indexOf('=')
155
+ if (eqIdx === -1) continue
156
+ const key = trimmed.slice(0, eqIdx).trim()
157
+ const val = trimmed.slice(eqIdx + 1).trim()
158
+ if (key && !(key in process.env)) {
159
+ process.env[key] = val
160
+ }
161
+ }
162
+ }
163
+
164
+ async function ensureMigrationsTable(db: any) {
165
+ // Create tracking table if it doesn't exist
166
+ await db.rpc('exec_sql' as any, {
167
+ sql: `
168
+ CREATE TABLE IF NOT EXISTS public.spine_migrations (
169
+ id SERIAL PRIMARY KEY,
170
+ filename TEXT NOT NULL UNIQUE,
171
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
172
+ );
173
+ `
174
+ })
175
+ // Ignore errors — table may already exist or exec_sql may not exist yet
176
+ // We'll handle this via the fallback path
177
+ }
178
+
179
+ function collectMigrations(frameworkOnly: boolean): string[] {
180
+ const files: string[] = []
181
+
182
+ // Framework migrations (from installed package or local .framework/)
183
+ const fwMigrationsDir = existsSync(join(PROJECT_ROOT, '.framework/migrations'))
184
+ ? join(PROJECT_ROOT, '.framework/migrations')
185
+ : join(PACKAGE_ROOT, '.framework/migrations')
186
+
187
+ if (existsSync(fwMigrationsDir)) {
188
+ const fwFiles = readdirSync(fwMigrationsDir)
189
+ .filter(f => f.endsWith('.sql') && !f.startsWith('.'))
190
+ .sort()
191
+ .map(f => join(fwMigrationsDir, f))
192
+ files.push(...fwFiles)
193
+ }
194
+
195
+ // Custom migrations (from project's custom/migrations/)
196
+ if (!frameworkOnly) {
197
+ const customMigrationsDir = join(PROJECT_ROOT, 'custom/migrations')
198
+ if (existsSync(customMigrationsDir)) {
199
+ const customFiles = readdirSync(customMigrationsDir)
200
+ .filter(f => f.endsWith('.sql') && !f.startsWith('.'))
201
+ .sort()
202
+ .map(f => join(customMigrationsDir, f))
203
+ files.push(...customFiles)
204
+ }
205
+ }
206
+
207
+ return files
208
+ }
209
+
210
+ async function applyViaMigrationEndpoint(
211
+ supabaseUrl: string,
212
+ serviceRoleKey: string,
213
+ sql: string
214
+ ): Promise<{ ok: boolean; error?: string }> {
215
+ try {
216
+ // Use Supabase's REST SQL endpoint
217
+ const projectRef = supabaseUrl.match(/https:\/\/([^.]+)\.supabase\.co/)?.[1]
218
+ if (!projectRef) return { ok: false, error: 'Could not parse project ref from SUPABASE_URL' }
219
+
220
+ const res = await fetch(
221
+ `https://api.supabase.com/v1/projects/${projectRef}/database/query`,
222
+ {
223
+ method: 'POST',
224
+ headers: {
225
+ 'Content-Type': 'application/json',
226
+ 'Authorization': `Bearer ${serviceRoleKey}`,
227
+ },
228
+ body: JSON.stringify({ query: sql }),
229
+ }
230
+ )
231
+
232
+ if (!res.ok) {
233
+ const body = await res.text()
234
+ return { ok: false, error: `API error ${res.status}: ${body}` }
235
+ }
236
+
237
+ return { ok: true }
238
+ } catch (err: any) {
239
+ return { ok: false, error: err.message }
240
+ }
241
+ }
@@ -0,0 +1,153 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * @module cli/commands/status
4
+ * @audience installer
5
+ * @layer cli
6
+ * @stability stable
7
+ *
8
+ * `spine-framework status` — shows installed apps, migration state, and
9
+ * framework version.
10
+ *
11
+ * **Usage:**
12
+ * ```bash
13
+ * npx spine-framework status
14
+ * npx spine-framework status --json
15
+ * ```
16
+ */
17
+
18
+ import type { Command } from 'commander'
19
+ import { existsSync, readFileSync, readdirSync } from 'fs'
20
+ import { resolve, dirname, join, basename } from 'path'
21
+ import { fileURLToPath } from 'url'
22
+ import { createClient } from '@supabase/supabase-js'
23
+
24
+ const __filename = fileURLToPath(import.meta.url)
25
+ const __dirname = dirname(__filename)
26
+ const PACKAGE_ROOT = resolve(__dirname, '../../../')
27
+ const PROJECT_ROOT = process.cwd()
28
+
29
+ export function registerStatusCommands(program: Command) {
30
+ program
31
+ .command('status')
32
+ .description('Show installed apps and framework version')
33
+ .option('--json', 'Output as JSON')
34
+ .action(async (opts) => {
35
+ loadEnv()
36
+
37
+ const supabaseUrl = process.env.SUPABASE_URL
38
+ const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
39
+
40
+ // Framework version
41
+ let frameworkVersion = 'unknown'
42
+ try {
43
+ const pkg = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8'))
44
+ frameworkVersion = pkg.version
45
+ } catch {}
46
+
47
+ // Installed apps (from custom/apps/)
48
+ const appsDir = join(PROJECT_ROOT, 'custom/apps')
49
+ const installedApps: { slug: string; version?: string; hasManifest: boolean }[] = []
50
+
51
+ if (existsSync(appsDir)) {
52
+ for (const slug of readdirSync(appsDir)) {
53
+ const appDir = join(appsDir, slug)
54
+ const manifestPath = join(appDir, 'manifest.json')
55
+ let version: string | undefined
56
+ let hasManifest = false
57
+ if (existsSync(manifestPath)) {
58
+ hasManifest = true
59
+ try {
60
+ const m = JSON.parse(readFileSync(manifestPath, 'utf8'))
61
+ version = m.version
62
+ } catch {}
63
+ }
64
+ installedApps.push({ slug, version, hasManifest })
65
+ }
66
+ }
67
+
68
+ // Migration state
69
+ let migrations: { applied: number; pending: number; files: string[] } = {
70
+ applied: 0,
71
+ pending: 0,
72
+ files: [],
73
+ }
74
+
75
+ const fwMigrationsDir = existsSync(join(PROJECT_ROOT, '.framework/migrations'))
76
+ ? join(PROJECT_ROOT, '.framework/migrations')
77
+ : join(PACKAGE_ROOT, '.framework/migrations')
78
+
79
+ if (existsSync(fwMigrationsDir)) {
80
+ migrations.files = readdirSync(fwMigrationsDir)
81
+ .filter(f => f.endsWith('.sql'))
82
+ .sort()
83
+ }
84
+
85
+ if (supabaseUrl && serviceRoleKey) {
86
+ try {
87
+ const db = createClient(supabaseUrl, serviceRoleKey, {
88
+ auth: { persistSession: false },
89
+ })
90
+ const { data } = await db
91
+ .from('spine_migrations')
92
+ .select('filename')
93
+ const appliedSet = new Set((data || []).map((r: any) => r.filename))
94
+ migrations.applied = migrations.files.filter(f => appliedSet.has(f)).length
95
+ migrations.pending = migrations.files.length - migrations.applied
96
+ } catch {
97
+ // DB not accessible — just show file count
98
+ migrations.pending = migrations.files.length
99
+ }
100
+ }
101
+
102
+ if (opts.json) {
103
+ console.log(JSON.stringify({ frameworkVersion, installedApps, migrations }, null, 2))
104
+ return
105
+ }
106
+
107
+ // Human-readable output
108
+ console.log('\n── Spine Framework Status ─────────────────────────────')
109
+ console.log(` Version: spine-framework@${frameworkVersion}`)
110
+ console.log(` Project: ${PROJECT_ROOT}`)
111
+ console.log()
112
+
113
+ console.log('── Installed Apps ─────────────────────────────────────')
114
+ if (installedApps.length === 0) {
115
+ console.log(' No apps installed.')
116
+ console.log(' Run: npx spine-framework install-app spine-framework-portal@latest')
117
+ } else {
118
+ for (const app of installedApps) {
119
+ const ver = app.version ? `@${app.version}` : ''
120
+ const manifest = app.hasManifest ? '' : ' (no manifest)'
121
+ console.log(` ✓ ${app.slug}${ver}${manifest}`)
122
+ }
123
+ }
124
+ console.log()
125
+
126
+ console.log('── Migrations ─────────────────────────────────────────')
127
+ if (!supabaseUrl) {
128
+ console.log(' (Set SUPABASE_URL in .env to check migration state)')
129
+ } else {
130
+ console.log(` Applied: ${migrations.applied}`)
131
+ console.log(` Pending: ${migrations.pending}`)
132
+ if (migrations.pending > 0) {
133
+ console.log(' Run: npx spine-framework migrate')
134
+ }
135
+ }
136
+ console.log()
137
+ })
138
+ }
139
+
140
+ function loadEnv() {
141
+ const envPath = join(PROJECT_ROOT, '.env')
142
+ if (!existsSync(envPath)) return
143
+ const lines = readFileSync(envPath, 'utf8').split('\n')
144
+ for (const line of lines) {
145
+ const trimmed = line.trim()
146
+ if (!trimmed || trimmed.startsWith('#')) continue
147
+ const eqIdx = trimmed.indexOf('=')
148
+ if (eqIdx === -1) continue
149
+ const key = trimmed.slice(0, eqIdx).trim()
150
+ const val = trimmed.slice(eqIdx + 1).trim()
151
+ if (key && !(key in process.env)) process.env[key] = val
152
+ }
153
+ }
@@ -42,11 +42,15 @@ const program = new Command()
42
42
 
43
43
  program
44
44
  .name('spine-framework')
45
- .description('Spine v2 CLI — interact with your Spine instance from the terminal or agentic IDE')
45
+ .description('Spine Framework CLI — install, migrate, and manage your Spine instance')
46
46
  .version('2.0.0')
47
47
  .option('--account <id>', 'Override the account ID for this command')
48
48
 
49
49
  const [
50
+ { registerInitCommands },
51
+ { registerMigrateCommands },
52
+ { registerInstallAppCommands },
53
+ { registerStatusCommands },
50
54
  { registerAuthCommands },
51
55
  { registerPipelineCommands },
52
56
  { registerItemCommands },
@@ -59,6 +63,10 @@ const [
59
63
  { registerGenerateCommands },
60
64
  { registerCreateAppCommands },
61
65
  ] = await Promise.all([
66
+ import('./commands/init.ts'),
67
+ import('./commands/migrate.ts'),
68
+ import('./commands/install-app.ts'),
69
+ import('./commands/status.ts'),
62
70
  import('./commands/auth.ts'),
63
71
  import('./commands/pipelines.ts'),
64
72
  import('./commands/items.ts'),
@@ -72,6 +80,10 @@ const [
72
80
  import('./commands/create-app.ts'),
73
81
  ])
74
82
 
83
+ registerInitCommands(program)
84
+ registerMigrateCommands(program)
85
+ registerInstallAppCommands(program)
86
+ registerStatusCommands(program)
75
87
  registerAuthCommands(program)
76
88
  registerPipelineCommands(program)
77
89
  registerItemCommands(program)
package/package.json CHANGED
@@ -1,11 +1,9 @@
1
1
  {
2
2
  "name": "spine-framework",
3
- "version": "0.3.19",
3
+ "version": "0.3.21",
4
4
  "description": "Multi-tenant, modular application platform for modern SaaS systems",
5
5
  "type": "module",
6
- "bin": {
7
- "spine-framework": "./.framework/cli/bin.mjs"
8
- },
6
+ "bin": "./.framework/cli/bin.mjs",
9
7
  "files": [
10
8
  ".framework/cli/bin.mjs",
11
9
  ".framework/cli/",
@@ -46,7 +44,7 @@
46
44
  "license": "SEE LICENSE IN LICENSE.md",
47
45
  "repository": {
48
46
  "type": "git",
49
- "url": "https://github.com/spine-framework/spine-framework.git"
47
+ "url": "git+https://github.com/spine-framework/spine-framework.git"
50
48
  },
51
49
  "homepage": "https://spine-framework.com",
52
50
  "bugs": {