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
|
-
|
|
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` —
|
|
9
|
-
*
|
|
8
|
+
* `spine-framework migrate` — Apply SQL migrations to a Supabase project
|
|
9
|
+
* via a direct Postgres connection.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
23
|
-
import { resolve, dirname, join
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
+
readdirSync(fwMigrationsDir)
|
|
189
175
|
.filter(f => f.endsWith('.sql') && !f.startsWith('.'))
|
|
190
176
|
.sort()
|
|
191
|
-
.
|
|
192
|
-
files.push(...fwFiles)
|
|
177
|
+
.forEach(f => files.push({ name: f, path: join(fwMigrationsDir, f) }))
|
|
193
178
|
}
|
|
194
179
|
|
|
195
|
-
// Custom migrations
|
|
180
|
+
// Custom migrations
|
|
196
181
|
if (!frameworkOnly) {
|
|
197
182
|
const customMigrationsDir = join(PROJECT_ROOT, 'custom/migrations')
|
|
198
183
|
if (existsSync(customMigrationsDir)) {
|
|
199
|
-
|
|
184
|
+
readdirSync(customMigrationsDir)
|
|
200
185
|
.filter(f => f.endsWith('.sql') && !f.startsWith('.'))
|
|
201
186
|
.sort()
|
|
202
|
-
.
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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.
|
|
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",
|