spine-framework 0.1.61 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,447 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * @module cli/commands/claim
4
+ * @audience installer
5
+ * @layer cli
6
+ * @stability stable
7
+ *
8
+ * `spine-framework claim` — Claim framework installation via Spine Portal.
9
+ *
10
+ * Engagement-based claim system that offers contextual prompts based on
11
+ * user activity. Tracks engagement signals and triggers claim prompts
12
+ * at value-realization moments.
13
+ *
14
+ * **Usage:**
15
+ * ```bash
16
+ * spine-framework claim # Interactive claim flow
17
+ * spine-framework claim --status # Show claim status only
18
+ * spine-framework claim --token <t> # Headless claim with token
19
+ * ```
20
+ */
21
+
22
+ import type { Command } from 'commander'
23
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
24
+ import { resolve } from 'path'
25
+ import { createHash, randomBytes } from 'crypto'
26
+ import * as readline from 'readline'
27
+ import { signalConfig, claimTriggers, engagementSignals, type EngagementData, type ClaimPrompt, type EngagementSignalType } from '../../config/signals.ts'
28
+
29
+ const PROJECT_ROOT = process.cwd()
30
+ const SPINE_DIR = resolve(PROJECT_ROOT, '.spine')
31
+ const SERIAL_FILE = resolve(SPINE_DIR, 'serial.json')
32
+ const ENGAGEMENT_FILE = resolve(SPINE_DIR, 'engagement.json')
33
+
34
+ // ─── SERIAL NUMBER MANAGEMENT ────────────────────────────────────────────────
35
+
36
+ export interface SerialData {
37
+ serial_number: string
38
+ salt: string
39
+ created_at: string
40
+ framework_version: string
41
+ account_id: string | null
42
+ machine_fingerprint: string
43
+ }
44
+
45
+ function generateMachineFingerprint(): string {
46
+ const hostname = process.env.HOSTNAME || process.env.COMPUTERNAME || 'unknown'
47
+ const platform = process.platform
48
+ const arch = process.arch
49
+ const data = `${hostname}-${platform}-${arch}-${process.env.USER || 'unknown'}`
50
+ return createHash('sha256').update(data).digest('hex').substring(0, 16)
51
+ }
52
+
53
+ function generateSerialNumber(version: string, machineId: string, salt: string): string {
54
+ const timestamp = Date.now().toString()
55
+ const data = `spine-framework-${version}-${machineId}-${timestamp}-${salt}`
56
+ const hash = createHash('sha256').update(data).digest('hex')
57
+ return `spn_fw_${hash.substring(0, 16).toUpperCase()}`
58
+ }
59
+
60
+ export function ensureSerialData(): SerialData {
61
+ if (existsSync(SERIAL_FILE)) {
62
+ try {
63
+ return JSON.parse(readFileSync(SERIAL_FILE, 'utf8')) as SerialData
64
+ } catch { /* fall through to create new */ }
65
+ }
66
+
67
+ // Create .spine directory if needed
68
+ if (!existsSync(SPINE_DIR)) {
69
+ mkdirSync(SPINE_DIR, { recursive: true })
70
+ }
71
+
72
+ // Generate new serial
73
+ const salt = randomBytes(32).toString('hex')
74
+ const machineFingerprint = generateMachineFingerprint()
75
+ const frameworkVersion = getFrameworkVersion()
76
+ const serialNumber = generateSerialNumber(frameworkVersion, machineFingerprint, salt)
77
+
78
+ const serialData: SerialData = {
79
+ serial_number: serialNumber,
80
+ salt,
81
+ created_at: new Date().toISOString(),
82
+ framework_version: frameworkVersion,
83
+ account_id: null,
84
+ machine_fingerprint: machineFingerprint,
85
+ }
86
+
87
+ writeFileSync(SERIAL_FILE, JSON.stringify(serialData, null, 2) + '\n', 'utf8')
88
+ return serialData
89
+ }
90
+
91
+ function getFrameworkVersion(): string {
92
+ const pkgJsonPath = resolve(PROJECT_ROOT, 'package.json')
93
+ if (existsSync(pkgJsonPath)) {
94
+ try {
95
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
96
+ return pkg.version || '0.0.0'
97
+ } catch { /* fall through */ }
98
+ }
99
+ return '0.0.0'
100
+ }
101
+
102
+ // ─── ENGAGEMENT TRACKING ─────────────────────────────────────────────────────
103
+
104
+ export function readEngagementData(): EngagementData | null {
105
+ if (!existsSync(ENGAGEMENT_FILE)) return null
106
+ try {
107
+ return JSON.parse(readFileSync(ENGAGEMENT_FILE, 'utf8')) as EngagementData
108
+ } catch {
109
+ return null
110
+ }
111
+ }
112
+
113
+ export function writeEngagementData(data: EngagementData): void {
114
+ if (!existsSync(SPINE_DIR)) {
115
+ mkdirSync(SPINE_DIR, { recursive: true })
116
+ }
117
+ writeFileSync(ENGAGEMENT_FILE, JSON.stringify(data, null, 2) + '\n', 'utf8')
118
+ }
119
+
120
+ export function recordEngagementSignal(signalType: EngagementSignalType): void {
121
+ const serialData = ensureSerialData()
122
+ let engagement = readEngagementData()
123
+
124
+ if (!engagement) {
125
+ engagement = {
126
+ framework_install_serial: serialData.serial_number,
127
+ engagement_score: 0,
128
+ signals: [],
129
+ claim_status: 'anonymous',
130
+ }
131
+ }
132
+
133
+ const signalDef = engagementSignals[signalType]
134
+ const entry = {
135
+ type: signalType,
136
+ timestamp: new Date().toISOString(),
137
+ score: signalDef.score,
138
+ }
139
+
140
+ // Avoid duplicate signals of the same type within 1 hour
141
+ const oneHourAgo = Date.now() - 3600000
142
+ const recentSignal = engagement.signals.find(
143
+ s => s.type === signalType && new Date(s.timestamp).getTime() > oneHourAgo
144
+ )
145
+ if (recentSignal) {
146
+ return // Skip duplicate
147
+ }
148
+
149
+ engagement.signals.push(entry)
150
+ engagement.engagement_score += signalDef.score
151
+ writeEngagementData(engagement)
152
+ }
153
+
154
+ export function shouldOfferClaim(): ClaimPrompt | null {
155
+ const engagement = readEngagementData()
156
+ if (!engagement) return null
157
+ if (engagement.claim_status !== 'anonymous') return null
158
+
159
+ const score = engagement.engagement_score
160
+
161
+ if (score >= claimTriggers.power_user) {
162
+ return {
163
+ timing: 'power_user',
164
+ message: "You're a power user! Unlock advanced features and priority support by claiming your installation.",
165
+ }
166
+ } else if (score >= claimTriggers.ecosystem_engaged) {
167
+ return {
168
+ timing: 'ecosystem_engaged',
169
+ message: "You're expanding your toolkit! Claim your install for app-specific guides and community access.",
170
+ }
171
+ } else if (score >= claimTriggers.value_realized) {
172
+ return {
173
+ timing: 'value_realized',
174
+ message: 'Database connected! Claim your install to unlock production deployment guides and audit tools.',
175
+ }
176
+ } else if (score >= claimTriggers.gentle_nudge) {
177
+ return {
178
+ timing: 'gentle_nudge',
179
+ message: 'Framework started! Ready to explore the full ecosystem? Claim your install for docs and support.',
180
+ }
181
+ }
182
+
183
+ return null
184
+ }
185
+
186
+ // ─── SIGNAL SENDING ───────────────────────────────────────────────────────────
187
+
188
+ export interface FrameworkInstallSignal {
189
+ signal_type: 'framework_install'
190
+ install_serial: string
191
+ package: string
192
+ version: string
193
+ anonymous_id: string
194
+ machine_fingerprint: string
195
+ platform: string
196
+ timestamp: string
197
+ }
198
+
199
+ export interface AppInstallSignal {
200
+ signal_type: 'app_install'
201
+ package: string
202
+ version: string
203
+ framework_install_serial: string
204
+ app_serial: string
205
+ timestamp: string
206
+ }
207
+
208
+ export async function sendFrameworkInstallSignal(): Promise<void> {
209
+ const serialData = ensureSerialData()
210
+
211
+ const signal: FrameworkInstallSignal = {
212
+ signal_type: 'framework_install',
213
+ install_serial: serialData.serial_number,
214
+ package: 'spine-framework',
215
+ version: serialData.framework_version,
216
+ anonymous_id: `anon_${randomBytes(8).toString('hex')}`,
217
+ machine_fingerprint: serialData.machine_fingerprint,
218
+ platform: `${process.platform}-${process.arch}`,
219
+ timestamp: new Date().toISOString(),
220
+ }
221
+
222
+ try {
223
+ await fetch(`${signalConfig.endpoint}/api/integrations/webhook/funnel-signal`, {
224
+ method: 'POST',
225
+ headers: { 'Content-Type': 'application/json' },
226
+ body: JSON.stringify(signal),
227
+ })
228
+ } catch {
229
+ // Silent fail - signal endpoint is best-effort
230
+ }
231
+ }
232
+
233
+ export async function sendAppInstallSignal(packageName: string, version: string): Promise<void> {
234
+ const serialData = ensureSerialData()
235
+
236
+ const signal: AppInstallSignal = {
237
+ signal_type: 'app_install',
238
+ package: packageName,
239
+ version,
240
+ framework_install_serial: serialData.serial_number,
241
+ app_serial: `${packageName}_${randomBytes(8).toString('hex')}`,
242
+ timestamp: new Date().toISOString(),
243
+ }
244
+
245
+ try {
246
+ await fetch(`${signalConfig.endpoint}/api/integrations/webhook/funnel-signal`, {
247
+ method: 'POST',
248
+ headers: { 'Content-Type': 'application/json' },
249
+ body: JSON.stringify(signal),
250
+ })
251
+ } catch {
252
+ // Silent fail
253
+ }
254
+ }
255
+
256
+ // ─── CLAIM FLOW ───────────────────────────────────────────────────────────────
257
+
258
+ function promptQuestion(question: string): Promise<string> {
259
+ return new Promise(resolve => {
260
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
261
+ rl.question(question, answer => {
262
+ rl.close()
263
+ resolve(answer.trim())
264
+ })
265
+ })
266
+ }
267
+
268
+ async function openPortalClaim(serialNumber: string): Promise<void> {
269
+ const claimUrl = `https://portal.spine-framework.com/claim?serial=${serialNumber}`
270
+
271
+ // Try to open browser
272
+ const platform = process.platform
273
+ const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'start' : 'xdg-open'
274
+
275
+ console.log(`\n🌐 Opening ${claimUrl}`)
276
+ console.log(' Create your account or log in to claim this installation.\n')
277
+
278
+ try {
279
+ const { execSync } = await import('child_process')
280
+ execSync(`${cmd} "${claimUrl}"`, { stdio: 'ignore' })
281
+ } catch {
282
+ console.log(` Could not open browser automatically.`)
283
+ console.log(` Please visit the URL manually.\n`)
284
+ }
285
+ }
286
+
287
+ async function tokenClaimFlow(serialNumber: string): Promise<void> {
288
+ console.log('\n🔑 Token-Based Claim')
289
+ console.log(' 1. Visit https://portal.spine-framework.com/settings/claim')
290
+ console.log(' 2. Copy your claim token\n')
291
+
292
+ const token = await promptQuestion(' Enter your claim token: ')
293
+
294
+ if (!token) {
295
+ console.log('\n ❌ No token provided. Claim cancelled.')
296
+ return
297
+ }
298
+
299
+ // Validate token with portal API
300
+ try {
301
+ const response = await fetch(`${signalConfig.endpoint}/api/claim`, {
302
+ method: 'POST',
303
+ headers: { 'Content-Type': 'application/json' },
304
+ body: JSON.stringify({
305
+ install_serial: serialNumber,
306
+ user_token: token,
307
+ }),
308
+ })
309
+
310
+ if (!response.ok) {
311
+ console.log('\n ❌ Invalid token or claim failed. Please try again.')
312
+ return
313
+ }
314
+
315
+ const result = await response.json() as { success: boolean; account_id?: string }
316
+
317
+ if (result.success) {
318
+ // Update local files
319
+ const serialData = ensureSerialData()
320
+ serialData.account_id = result.account_id || null
321
+ writeFileSync(SERIAL_FILE, JSON.stringify(serialData, null, 2) + '\n', 'utf8')
322
+
323
+ const engagement = readEngagementData()
324
+ if (engagement) {
325
+ engagement.claim_status = 'claimed'
326
+ engagement.account_id = result.account_id
327
+ writeEngagementData(engagement)
328
+ }
329
+
330
+ console.log('\n ✅ Installation claimed successfully!')
331
+ console.log(' Welcome to the Spine ecosystem. 🎉\n')
332
+ } else {
333
+ console.log('\n ❌ Claim failed. Please check your token and try again.')
334
+ }
335
+ } catch {
336
+ console.log('\n ⚠️ Could not connect to portal. Please try again later.')
337
+ }
338
+ }
339
+
340
+ async function showClaimStatus(): Promise<void> {
341
+ const serialData = existsSync(SERIAL_FILE) ? JSON.parse(readFileSync(SERIAL_FILE, 'utf8')) as SerialData : null
342
+ const engagement = readEngagementData()
343
+
344
+ console.log('\n📊 Claim Status\n')
345
+
346
+ if (!serialData) {
347
+ console.log(' No installation serial found.')
348
+ console.log(' Run any spine-framework command to initialize.\n')
349
+ return
350
+ }
351
+
352
+ console.log(` Serial Number: ${serialData.serial_number}`)
353
+ console.log(` Framework Version: ${serialData.framework_version}`)
354
+ console.log(` Created: ${new Date(serialData.created_at).toLocaleDateString()}`)
355
+
356
+ if (engagement) {
357
+ console.log(`\n Engagement Score: ${engagement.engagement_score}`)
358
+ console.log(` Signals Recorded: ${engagement.signals.length}`)
359
+ console.log(` Claim Status: ${engagement.claim_status}`)
360
+
361
+ if (engagement.claim_status === 'anonymous') {
362
+ const prompt = shouldOfferClaim()
363
+ if (prompt) {
364
+ console.log(`\n 💡 ${prompt.message}`)
365
+ }
366
+ } else if (engagement.account_id) {
367
+ console.log(` Account ID: ${engagement.account_id}`)
368
+ }
369
+ }
370
+
371
+ console.log('')
372
+ }
373
+
374
+ async function runClaimFlow(options: { status: boolean; token?: string }): Promise<void> {
375
+ // Ensure serial exists
376
+ const serialData = ensureSerialData()
377
+
378
+ // Send framework install signal on first claim check
379
+ recordEngagementSignal('framework_initialized')
380
+ await sendFrameworkInstallSignal()
381
+
382
+ if (options.status) {
383
+ await showClaimStatus()
384
+ return
385
+ }
386
+
387
+ // Check if already claimed
388
+ const engagement = readEngagementData()
389
+ if (engagement?.claim_status === 'claimed') {
390
+ console.log('\n✅ This installation is already claimed.')
391
+ console.log(` Account: ${engagement.account_id}\n`)
392
+ return
393
+ }
394
+
395
+ console.log('\n🔐 Claim Your Spine Installation\n')
396
+ console.log(` Serial: ${serialData.serial_number}`)
397
+ console.log('\n Claiming unlocks:')
398
+ console.log(' • Documentation and playbooks')
399
+ console.log(' • App packs and marketplace')
400
+ console.log(' • Support and community access')
401
+ console.log(' • Audit and compliance tools\n')
402
+
403
+ if (options.token) {
404
+ await tokenClaimFlow(serialData.serial_number)
405
+ return
406
+ }
407
+
408
+ console.log(' Choose a claim method:')
409
+ console.log(' 1. Portal flow (opens browser) - RECOMMENDED')
410
+ console.log(' 2. Token flow (paste token from portal)')
411
+ console.log(' 3. Check status only\n')
412
+
413
+ const choice = await promptQuestion(' Select (1-3): ')
414
+
415
+ switch (choice) {
416
+ case '1':
417
+ await openPortalClaim(serialData.serial_number)
418
+ break
419
+ case '2':
420
+ await tokenClaimFlow(serialData.serial_number)
421
+ break
422
+ case '3':
423
+ await showClaimStatus()
424
+ break
425
+ default:
426
+ console.log('\n Invalid choice. Run `spine-framework claim` to try again.\n')
427
+ }
428
+ }
429
+
430
+ // ─── COMMAND REGISTRATION ───────────────────────────────────────────────────
431
+
432
+ export function registerClaimCommands(program: Command): void {
433
+ program
434
+ .command('claim')
435
+ .description('Claim framework installation via Spine Portal')
436
+ .option('--status', 'Show claim status only', false)
437
+ .option('--token <token>', 'Headless claim with token')
438
+ .action(async (opts) => {
439
+ try {
440
+ await runClaimFlow(opts)
441
+ } catch (err: any) {
442
+ console.error('Error:', err.message)
443
+ if (process.env.SPINE_CLI_DEBUG) console.error(err.stack)
444
+ process.exit(1)
445
+ }
446
+ })
447
+ }
@@ -28,11 +28,12 @@
28
28
  */
29
29
 
30
30
  import type { Command } from 'commander'
31
- import { existsSync, readFileSync, readdirSync, cpSync, mkdirSync } from 'fs'
31
+ import { existsSync, readFileSync, readdirSync, cpSync, mkdirSync, statSync } from 'fs'
32
32
  import { resolve, dirname } from 'path'
33
33
  import { fileURLToPath } from 'url'
34
34
  import { execSync } from 'child_process'
35
35
  import { adminDb } from '../../functions/_shared/index.ts'
36
+ import { recordEngagementSignal, sendAppInstallSignal, ensureSerialData } from './claim.ts'
36
37
 
37
38
  const __filename = fileURLToPath(import.meta.url)
38
39
  const __dirname = dirname(__filename)
@@ -42,6 +43,7 @@ interface InstallOptions {
42
43
  account?: string
43
44
  force: boolean
44
45
  dryRun: boolean
46
+ packageName?: string
45
47
  }
46
48
 
47
49
  interface SeedResult {
@@ -332,9 +334,15 @@ const SEED_HANDLERS: Record<string, (appId: string, records: any[], dryRun: bool
332
334
  'roles.json': upsertRoles,
333
335
  }
334
336
 
335
- async function recordInstallation(appSlug: string, accountId: string | null, dryRun: boolean): Promise<void> {
337
+ async function recordInstallation(
338
+ appSlug: string,
339
+ accountId: string | null,
340
+ dryRun: boolean,
341
+ installedVersion?: string,
342
+ packageName?: string,
343
+ ): Promise<void> {
336
344
  if (dryRun) {
337
- console.log(` [dry-run] Would record installation for '${appSlug}'`)
345
+ console.log(` [dry-run] Would record installation for '${appSlug}' (version: ${installedVersion || 'unknown'})`)
338
346
  return
339
347
  }
340
348
 
@@ -344,6 +352,8 @@ async function recordInstallation(appSlug: string, accountId: string | null, dry
344
352
  app_slug: appSlug,
345
353
  account_id: accountId,
346
354
  is_enabled: true,
355
+ installed_version: installedVersion || null,
356
+ package_name: packageName || null,
347
357
  updated_at: new Date().toISOString(),
348
358
  }, {
349
359
  onConflict: 'app_slug,account_id',
@@ -380,10 +390,23 @@ async function checkPrerequisites(_slug: string): Promise<void> {
380
390
  // Future: read prereqs from the app's manifest.json if needed.
381
391
  }
382
392
 
383
- function copyAppFiles(pkg: string, slug: string): void {
393
+ function copyAppFiles(pkg: string, slug: string, force: boolean): void {
384
394
  const pkgDir = resolve(PROJECT_ROOT, `node_modules/${pkg}`)
385
395
  const targetAppDir = resolve(PROJECT_ROOT, `custom/apps/${slug}`)
386
396
 
397
+ // Guard: abort if already installed and --force not passed
398
+ if (existsSync(targetAppDir) && statSync(targetAppDir).isDirectory()) {
399
+ const hasFiles = readdirSync(targetAppDir).length > 0
400
+ if (hasFiles && !force) {
401
+ console.error(` ❌ App '${slug}' is already installed at custom/apps/${slug}/`)
402
+ console.error(` Use 'update-app ${slug}' to upgrade, or 'install-app --force' to overwrite.`)
403
+ process.exit(1)
404
+ }
405
+ if (hasFiles && force) {
406
+ console.log(` ⚠️ --force: overwriting existing custom/apps/${slug}/`)
407
+ }
408
+ }
409
+
387
410
  mkdirSync(targetAppDir, { recursive: true })
388
411
 
389
412
  // Always copy core app files
@@ -437,7 +460,11 @@ async function installApp(slug: string, options: InstallOptions): Promise<void>
437
460
  packageName = barePackageName
438
461
  console.log(`📥 Step 1: Installing npm package...`)
439
462
  const pkgDir = resolve(PROJECT_ROOT, `node_modules/${barePackageName}`)
440
- if (!existsSync(pkgDir)) {
463
+ // Always run npm install when a version specifier is given (e.g. @1.2.3 or @latest)
464
+ // so the correct version is fetched even if an older one is already in node_modules.
465
+ const hasVersionSpec = slug.includes('@') && !slug.startsWith('@') || (slug.startsWith('@') && slug.split('@').length > 2)
466
+ const shouldInstall = !existsSync(pkgDir) || hasVersionSpec
467
+ if (shouldInstall) {
441
468
  try {
442
469
  console.log(` Running: npm install ${slug}`)
443
470
  execSync(`npm install ${slug}`, { cwd: PROJECT_ROOT, stdio: 'inherit' })
@@ -453,7 +480,7 @@ async function installApp(slug: string, options: InstallOptions): Promise<void>
453
480
  console.log(` ✓ App slug: ${resolvedSlug}`)
454
481
 
455
482
  console.log(`\n📁 Step 2: Ejecting app files...`)
456
- copyAppFiles(barePackageName, resolvedSlug)
483
+ copyAppFiles(barePackageName, resolvedSlug, options.force)
457
484
  }
458
485
 
459
486
  // Check prerequisites (e.g. portal requires cortex)
@@ -538,7 +565,40 @@ async function installApp(slug: string, options: InstallOptions): Promise<void>
538
565
 
539
566
  // 5. Record installation
540
567
  console.log(`\n📝 Recording installation...`)
541
- await recordInstallation(resolvedSlug, options.account || null, options.dryRun)
568
+ let installedVersion: string | undefined
569
+ const manifestForVersion = resolve(appDir, 'manifest.json')
570
+ if (existsSync(manifestForVersion)) {
571
+ try {
572
+ installedVersion = JSON.parse(readFileSync(manifestForVersion, 'utf8')).version
573
+ } catch { /* ignore */ }
574
+ }
575
+ await recordInstallation(resolvedSlug, options.account || null, options.dryRun, installedVersion, packageName || undefined)
576
+
577
+ // 6. Signal integration (only on new installs, not --force overwrites)
578
+ if (!options.force && !options.dryRun) {
579
+ console.log(`\n📡 Sending install signal...`)
580
+ try {
581
+ // Ensure framework serial exists
582
+ ensureSerialData()
583
+ // Record engagement
584
+ recordEngagementSignal('app_install')
585
+ // Send app install signal
586
+ if (packageName) {
587
+ await sendAppInstallSignal(packageName, installedVersion || '0.0.0')
588
+ }
589
+ console.log(` ✓ Install signal sent`)
590
+
591
+ // Check for claim prompt
592
+ const { shouldOfferClaim } = await import('./claim.ts')
593
+ const prompt = shouldOfferClaim()
594
+ if (prompt) {
595
+ console.log(`\n💡 ${prompt.message}`)
596
+ console.log(` Run: spine-framework claim`)
597
+ }
598
+ } catch {
599
+ // Silent fail - signal tracking is best-effort
600
+ }
601
+ }
542
602
 
543
603
  console.log(`\n✅ App '${appName}' installed successfully!`)
544
604
  console.log(`\n Next steps:`)
@@ -94,8 +94,20 @@ async function migrateCommand(options: MigrateOptions): Promise<void> {
94
94
  for (const file of files) {
95
95
  const sql = readFileSync(resolve(MIGRATIONS_DIR, file), 'utf8')
96
96
  process.stdout.write(` Applying ${file}... `)
97
+
98
+ // Split into individual statements so each executes in its own round-trip.
99
+ // Running the whole file as one query string causes pg to abort the entire
100
+ // batch on the first error, preventing PL/pgSQL EXCEPTION handlers from
101
+ // catching expected errors like "relation already exists".
102
+ const statements = sql
103
+ .split(/;\s*$/m)
104
+ .map(s => s.trim())
105
+ .filter(s => s.length > 0 && !s.startsWith('--'))
106
+
97
107
  try {
98
- await client.query(sql)
108
+ for (const stmt of statements) {
109
+ await client.query(stmt + ';')
110
+ }
99
111
  console.log('✓')
100
112
  applied++
101
113
  } catch (err: any) {