spine-framework 0.2.1 → 0.2.3
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 +6 -1
- package/.framework/src/components/CustomAppLoader.tsx +17 -0
- package/bin/spine-framework.cjs +0 -0
- package/bin/welcome.cjs +2 -2
- 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.json +3 -2
- package/bin/ws-shim.cjs +0 -8
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
+
for (const stmt of statements) {
|
|
109
|
+
await client.query(stmt + ';')
|
|
110
|
+
}
|
|
99
111
|
console.log('✓')
|
|
100
112
|
applied++
|
|
101
113
|
} catch (err: any) {
|