spine-framework 0.3.36 → 0.3.38

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.
@@ -213,7 +213,7 @@ export function registerInitCommands(program: Command) {
213
213
  function getPackageVersion(): string {
214
214
  try {
215
215
  const pkg = JSON.parse(
216
- require('fs').readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')
216
+ readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')
217
217
  )
218
218
  return pkg.version || '0.3.0'
219
219
  } catch {
@@ -5,137 +5,140 @@
5
5
  * @layer cli
6
6
  * @stability stable
7
7
  *
8
- * `spine-framework migrate` — applies pending SQL migrations to the configured
9
- * Supabase project using the Supabase Management API and direct DB connection.
8
+ * `spine-framework migrate` — Apply SQL migrations to a Supabase project
9
+ * via a direct Postgres connection.
10
10
  *
11
- * Reads migration files from `.framework/migrations/` and `custom/migrations/`
12
- * in order, skipping any already tracked in `spine_migrations` table.
11
+ * Requires SUPABASE_DB_PASSWORD in .env (or --db-password flag).
12
+ * Get it from: https://supabase.com/dashboard/project/_/settings/database
13
13
  *
14
14
  * **Usage:**
15
15
  * ```bash
16
16
  * npx spine-framework migrate
17
17
  * npx spine-framework migrate --dry-run
18
+ * npx spine-framework migrate --db-password <password>
18
19
  * ```
19
20
  */
20
21
 
21
22
  import type { Command } from 'commander'
22
- import { readFileSync, readdirSync, existsSync } from 'fs'
23
- import { resolve, dirname, join, basename } from 'path'
23
+ import { existsSync, readdirSync, readFileSync } from 'fs'
24
+ import { resolve, dirname, join } from 'path'
24
25
  import { fileURLToPath } from 'url'
25
- import { createClient } from '@supabase/supabase-js'
26
26
 
27
27
  const __filename = fileURLToPath(import.meta.url)
28
28
  const __dirname = dirname(__filename)
29
29
  const PACKAGE_ROOT = resolve(__dirname, '../../../')
30
30
  const PROJECT_ROOT = process.cwd()
31
31
 
32
+ interface MigrateOptions {
33
+ dbPassword?: string
34
+ dryRun: boolean
35
+ frameworkOnly: boolean
36
+ }
37
+
38
+ async function migrateCommand(options: MigrateOptions): Promise<void> {
39
+ console.log('\nRunning Spine Framework migrations...\n')
40
+
41
+ // Load .env from project root
42
+ loadEnv()
43
+
44
+ const supabaseUrl = process.env.SUPABASE_URL
45
+ if (!supabaseUrl || supabaseUrl.includes('placeholder') || supabaseUrl.includes('your-project')) {
46
+ console.error('Error: SUPABASE_URL not set. Edit your .env file first.')
47
+ console.error(' Get it from: https://supabase.com/dashboard/project/_/settings/api')
48
+ process.exit(1)
49
+ }
50
+
51
+ const dbPassword = options.dbPassword || process.env.SUPABASE_DB_PASSWORD
52
+ if (!dbPassword) {
53
+ console.error('Error: SUPABASE_DB_PASSWORD not set.')
54
+ console.error(' Add it to .env or pass --db-password <password>')
55
+ console.error(' Get it from: https://supabase.com/dashboard/project/_/settings/database')
56
+ process.exit(1)
57
+ }
58
+
59
+ const projectRef = supabaseUrl.replace('https://', '').split('.')[0]
60
+ const connectionString = `postgresql://postgres:${dbPassword}@db.${projectRef}.supabase.co:5432/postgres`
61
+
62
+ // Collect migration files
63
+ const migrationFiles = collectMigrations(options.frameworkOnly)
64
+
65
+ if (migrationFiles.length === 0) {
66
+ console.log('No migration files found.')
67
+ return
68
+ }
69
+
70
+ console.log(` Found ${migrationFiles.length} migration file(s)\n`)
71
+
72
+ if (options.dryRun) {
73
+ for (const file of migrationFiles) {
74
+ console.log(` [dry-run] Would apply: ${file.name}`)
75
+ }
76
+ console.log('\nDry run complete — no changes made.\n')
77
+ return
78
+ }
79
+
80
+ // Dynamically import pg to avoid loading it at module init time
81
+ const { default: pg } = await import('pg')
82
+ const client = new pg.Client({ connectionString })
83
+
84
+ try {
85
+ await client.connect()
86
+ console.log(' Connected to database\n')
87
+ } catch (err: any) {
88
+ console.error(`Error: Could not connect to database: ${err.message}`)
89
+ console.error(' Check your SUPABASE_DB_PASSWORD and that the project is active.')
90
+ process.exit(1)
91
+ }
92
+
93
+ let applied = 0
94
+ let failed = 0
95
+
96
+ for (const { name, path } of migrationFiles) {
97
+ const sql = readFileSync(path, 'utf8')
98
+ process.stdout.write(` Applying ${name}... `)
99
+
100
+ const statements = splitSqlStatements(sql)
101
+
102
+ try {
103
+ for (const stmt of statements) {
104
+ await client.query(stmt)
105
+ }
106
+ console.log('✓')
107
+ applied++
108
+ } catch (err: any) {
109
+ console.log('✗')
110
+ console.error(` ${err.message}`)
111
+ failed++
112
+ }
113
+ }
114
+
115
+ await client.end()
116
+
117
+ console.log(`\n ${applied} applied, ${failed} failed`)
118
+
119
+ if (failed > 0) {
120
+ console.error('\nSome migrations failed. Check errors above.')
121
+ process.exit(1)
122
+ }
123
+
124
+ console.log('\n✅ All migrations applied successfully!\n')
125
+ console.log('Next steps:')
126
+ console.log(' npx spine-framework install-app spine-framework-portal@latest')
127
+ console.log(' npm install && npm run assemble && netlify dev\n')
128
+ }
129
+
32
130
  export function registerMigrateCommands(program: Command) {
33
131
  program
34
132
  .command('migrate')
35
133
  .description('Apply pending database migrations to your Supabase project')
134
+ .option('--db-password <password>', 'DB password (default: reads SUPABASE_DB_PASSWORD from .env)')
36
135
  .option('--dry-run', 'Show pending migrations without applying them', false)
37
136
  .option('--framework-only', 'Only apply .framework/migrations/, skip custom/', false)
38
137
  .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
138
  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
-
139
+ await migrateCommand(opts)
137
140
  } catch (err: any) {
138
- console.error('\n❌ Migration failed:', err.message)
141
+ console.error('Error:', err.message)
139
142
  if (process.env.SPINE_CLI_DEBUG) console.error(err.stack)
140
143
  process.exit(1)
141
144
  }
@@ -155,87 +158,64 @@ function loadEnv() {
155
158
  if (eqIdx === -1) continue
156
159
  const key = trimmed.slice(0, eqIdx).trim()
157
160
  const val = trimmed.slice(eqIdx + 1).trim()
158
- if (key && !(key in process.env)) {
159
- process.env[key] = val
160
- }
161
+ if (key && !(key in process.env)) process.env[key] = val
161
162
  }
162
163
  }
163
164
 
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
- }
165
+ function collectMigrations(frameworkOnly: boolean): { name: string; path: string }[] {
166
+ const files: { name: string; path: string }[] = []
178
167
 
179
- function collectMigrations(frameworkOnly: boolean): string[] {
180
- const files: string[] = []
181
-
182
- // Framework migrations (from installed package or local .framework/)
168
+ // Framework migrations (prefer local .framework/ copy, fall back to package)
183
169
  const fwMigrationsDir = existsSync(join(PROJECT_ROOT, '.framework/migrations'))
184
170
  ? join(PROJECT_ROOT, '.framework/migrations')
185
171
  : join(PACKAGE_ROOT, '.framework/migrations')
186
172
 
187
173
  if (existsSync(fwMigrationsDir)) {
188
- const fwFiles = readdirSync(fwMigrationsDir)
174
+ readdirSync(fwMigrationsDir)
189
175
  .filter(f => f.endsWith('.sql') && !f.startsWith('.'))
190
176
  .sort()
191
- .map(f => join(fwMigrationsDir, f))
192
- files.push(...fwFiles)
177
+ .forEach(f => files.push({ name: f, path: join(fwMigrationsDir, f) }))
193
178
  }
194
179
 
195
- // Custom migrations (from project's custom/migrations/)
180
+ // Custom migrations
196
181
  if (!frameworkOnly) {
197
182
  const customMigrationsDir = join(PROJECT_ROOT, 'custom/migrations')
198
183
  if (existsSync(customMigrationsDir)) {
199
- const customFiles = readdirSync(customMigrationsDir)
184
+ readdirSync(customMigrationsDir)
200
185
  .filter(f => f.endsWith('.sql') && !f.startsWith('.'))
201
186
  .sort()
202
- .map(f => join(customMigrationsDir, f))
203
- files.push(...customFiles)
187
+ .forEach(f => files.push({ name: f, path: join(customMigrationsDir, f) }))
204
188
  }
205
189
  }
206
190
 
207
191
  return files
208
192
  }
209
193
 
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
- )
194
+ function splitSqlStatements(sql: string): string[] {
195
+ const dollarBlocks: string[] = []
196
+ let blockIndex = 0
231
197
 
232
- if (!res.ok) {
233
- const body = await res.text()
234
- return { ok: false, error: `API error ${res.status}: ${body}` }
235
- }
198
+ // Protect dollar-quoted blocks (they can contain semicolons)
199
+ const protectedSql = sql.replace(/\$[A-Za-z_]*\$[\s\S]*?\$[A-Za-z_]*\$/g, (match) => {
200
+ const placeholder = `__DOLLAR_BLOCK_${blockIndex}__`
201
+ dollarBlocks[blockIndex] = match
202
+ blockIndex++
203
+ return placeholder
204
+ })
236
205
 
237
- return { ok: true }
238
- } catch (err: any) {
239
- return { ok: false, error: err.message }
240
- }
206
+ // Strip single-line comments
207
+ const noComments = protectedSql.replace(/--[^\n]*/g, '')
208
+
209
+ // Split by semicolons and restore dollar blocks
210
+ return noComments
211
+ .split(';')
212
+ .map(s => {
213
+ let restored = s.trim()
214
+ dollarBlocks.forEach((block, idx) => {
215
+ restored = restored.replace(`__DOLLAR_BLOCK_${idx}__`, () => block)
216
+ })
217
+ return restored
218
+ })
219
+ .filter(s => s.length > 0)
220
+ .map(s => s + ';')
241
221
  }
@@ -20,22 +20,7 @@ VALUES (
20
20
  NULL
21
21
  );
22
22
 
23
- -- ============================================
24
- -- 2. MASTER SYSTEM ACCOUNT
25
- -- ============================================
26
-
27
- INSERT INTO public.accounts (id, slug, display_name, description, data, is_active, design_schema, validation_schema, type_id)
28
- VALUES (
29
- gen_random_uuid(),
30
- 'spine-system',
31
- 'Spine System',
32
- 'Master system account for internal Spine operations',
33
- '{}'::jsonb,
34
- true,
35
- '{}'::jsonb,
36
- '{}'::jsonb,
37
- gen_random_uuid() -- placeholder, will update after types seeded
38
- );
23
+ -- NOTE: System account inserted after types are seeded (section 5) to satisfy FK constraint.
39
24
 
40
25
  -- ============================================
41
26
  -- 3. SYSTEM APP (spine-core)
@@ -143,6 +128,21 @@ BEGIN
143
128
  true
144
129
  );
145
130
 
131
+ -- 2. MASTER SYSTEM ACCOUNT (inserted here to satisfy FK on type_id)
132
+ INSERT INTO public.accounts (id, slug, display_name, description, data, is_active, design_schema, validation_schema, type_id)
133
+ SELECT
134
+ gen_random_uuid(),
135
+ 'spine-system',
136
+ 'Spine System',
137
+ 'Master system account for internal Spine operations',
138
+ '{}'::jsonb,
139
+ true,
140
+ '{}'::jsonb,
141
+ '{}'::jsonb,
142
+ id
143
+ FROM public.types WHERE slug = 'account' LIMIT 1
144
+ ON CONFLICT (slug) DO NOTHING;
145
+
146
146
  -- 5.2 people type
147
147
  INSERT INTO public.types (id, app_id, kind, slug, name, description, design_schema, validation_schema, ownership, is_active)
148
148
  VALUES (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework",
3
- "version": "0.3.36",
3
+ "version": "0.3.38",
4
4
  "description": "Multi-tenant, modular application platform for modern SaaS systems",
5
5
  "type": "module",
6
6
  "bin": {
@@ -77,6 +77,7 @@
77
77
  "dependencies": {
78
78
  "tsx": "^4.21.0",
79
79
  "ws": "^8.18.0",
80
+ "pg": "^8.13.0",
80
81
  "@base-ui/react": "^1.4.1",
81
82
  "@fontsource-variable/inter": "^5.2.8",
82
83
  "@heroicons/react": "^2.2.0",