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,191 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * @module cli/commands/publish
4
+ * @audience developer
5
+ * @layer cli
6
+ * @stability stable
7
+ *
8
+ * `spine-framework publish` — Bump version, write changelog, publish to npm, commit.
9
+ *
10
+ * Run from the spine-ia coding repo. Handles both the framework package and
11
+ * individual app packages (cortex, portal, etc.).
12
+ *
13
+ * **Usage:**
14
+ * ```bash
15
+ * spine-framework publish # publish framework (patch bump)
16
+ * spine-framework publish --app cortex # publish cortex app (patch bump)
17
+ * spine-framework publish --app cortex --bump minor
18
+ * spine-framework publish --app cortex --bump patch --notes "Fix sidebar nav"
19
+ * spine-framework publish --app cortex --ci --notes "Fix sidebar nav" --bump patch
20
+ * ```
21
+ */
22
+
23
+ import type { Command } from 'commander'
24
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
25
+ import { resolve, dirname } from 'path'
26
+ import { fileURLToPath } from 'url'
27
+ import { execSync } from 'child_process'
28
+ import * as readline from 'readline'
29
+
30
+ const __filename = fileURLToPath(import.meta.url)
31
+ const __dirname = dirname(__filename)
32
+ const PROJECT_ROOT = process.cwd()
33
+
34
+ interface PublishOptions {
35
+ app?: string
36
+ bump: 'patch' | 'minor' | 'major'
37
+ notes?: string
38
+ ci: boolean
39
+ dryRun: boolean
40
+ }
41
+
42
+ // ─── SEMVER BUMP ──────────────────────────────────────────────────────────────
43
+
44
+ function bumpVersion(current: string, bump: 'patch' | 'minor' | 'major'): string {
45
+ const [major, minor, patch] = current.replace(/^v/, '').split('.').map(Number)
46
+ if (bump === 'major') return `${major + 1}.0.0`
47
+ if (bump === 'minor') return `${major}.${minor + 1}.0`
48
+ return `${major}.${minor}.${patch + 1}`
49
+ }
50
+
51
+ // ─── INTERACTIVE PROMPT ───────────────────────────────────────────────────────
52
+
53
+ function promptNotes(): Promise<string> {
54
+ return new Promise(resolve => {
55
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
56
+ rl.question(' Changelog notes (comma-separated): ', answer => {
57
+ rl.close()
58
+ resolve(answer.trim())
59
+ })
60
+ })
61
+ }
62
+
63
+ // ─── PUBLISH LOGIC ────────────────────────────────────────────────────────────
64
+
65
+ async function publishTarget(options: PublishOptions): Promise<void> {
66
+ const isApp = !!options.app
67
+ const slug = options.app
68
+
69
+ let pkgJsonPath: string
70
+ let manifestPath: string | null
71
+ let publishDir: string
72
+ let displayName: string
73
+
74
+ if (isApp && slug) {
75
+ pkgJsonPath = resolve(PROJECT_ROOT, `custom/apps/${slug}/package.json`)
76
+ manifestPath = resolve(PROJECT_ROOT, `custom/apps/${slug}/manifest.json`)
77
+ publishDir = resolve(PROJECT_ROOT, `custom/apps/${slug}`)
78
+ displayName = `spine-framework-${slug}`
79
+ } else {
80
+ pkgJsonPath = resolve(PROJECT_ROOT, 'package.json')
81
+ manifestPath = null
82
+ publishDir = PROJECT_ROOT
83
+ displayName = 'spine-framework'
84
+ }
85
+
86
+ if (!existsSync(pkgJsonPath)) {
87
+ console.error(` ❌ package.json not found: ${pkgJsonPath}`)
88
+ process.exit(1)
89
+ }
90
+
91
+ // 1. Read current version
92
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
93
+ const currentVersion = pkgJson.version as string
94
+ const newVersion = bumpVersion(currentVersion, options.bump)
95
+
96
+ console.log(`\n📦 Publishing ${displayName}`)
97
+ console.log(` ${currentVersion} → ${newVersion} (${options.bump} bump)\n`)
98
+
99
+ // 2. Collect changelog notes
100
+ let notesInput = options.notes || ''
101
+ if (!notesInput) {
102
+ if (options.ci) {
103
+ console.error(` ❌ --ci mode requires --notes "..."`)
104
+ process.exit(1)
105
+ }
106
+ notesInput = await promptNotes()
107
+ }
108
+ const notes = notesInput
109
+ .split(',')
110
+ .map(s => s.trim())
111
+ .filter(Boolean)
112
+
113
+ if (notes.length === 0) {
114
+ console.error(` ❌ At least one changelog note is required.`)
115
+ process.exit(1)
116
+ }
117
+
118
+ if (options.dryRun) {
119
+ console.log(` [dry-run] Would bump ${displayName} ${currentVersion} → ${newVersion}`)
120
+ console.log(` [dry-run] Changelog entry:`)
121
+ console.log(` { "version": "${newVersion}", "notes": ${JSON.stringify(notes)} }`)
122
+ console.log(` [dry-run] Would run: npm publish (from ${publishDir})`)
123
+ console.log(` [dry-run] Would commit: chore: publish ${displayName}@${newVersion}`)
124
+ return
125
+ }
126
+
127
+ // 3. Bump version in package.json
128
+ pkgJson.version = newVersion
129
+ writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2) + '\n', 'utf8')
130
+ console.log(` ✓ package.json version → ${newVersion}`)
131
+
132
+ // 4. Prepend changelog entry to manifest.json (apps only)
133
+ if (manifestPath && existsSync(manifestPath)) {
134
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
135
+ const changelog: Array<{ version: string; notes: string[] }> = manifest.changelog || []
136
+ changelog.unshift({ version: newVersion, notes })
137
+ manifest.version = newVersion
138
+ manifest.changelog = changelog
139
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8')
140
+ console.log(` ✓ manifest.json changelog prepended, version → ${newVersion}`)
141
+ }
142
+
143
+ // 5. npm publish
144
+ console.log(`\n🚀 Running npm publish...`)
145
+ try {
146
+ execSync('npm publish --access public', { cwd: publishDir, stdio: 'inherit' })
147
+ console.log(` ✓ Published ${displayName}@${newVersion}`)
148
+ } catch (err: any) {
149
+ console.error(` ❌ npm publish failed: ${err.message}`)
150
+ console.error(` Version files have already been updated — commit them manually.`)
151
+ process.exit(1)
152
+ }
153
+
154
+ // 6. Git commit
155
+ console.log(`\n📝 Committing version bump...`)
156
+ try {
157
+ const filesToStage = [pkgJsonPath]
158
+ if (manifestPath && existsSync(manifestPath)) filesToStage.push(manifestPath)
159
+ execSync(`git add ${filesToStage.map(f => `"${f}"`).join(' ')}`, { cwd: PROJECT_ROOT })
160
+ execSync(
161
+ `git commit -m "chore: publish ${displayName}@${newVersion}"`,
162
+ { cwd: PROJECT_ROOT, stdio: 'inherit' },
163
+ )
164
+ console.log(` ✓ Committed`)
165
+ } catch (err: any) {
166
+ console.error(` ⚠️ Git commit failed: ${err.message}`)
167
+ console.error(` Published successfully — commit the version files manually.`)
168
+ }
169
+
170
+ console.log(`\n✅ ${displayName}@${newVersion} published successfully!\n`)
171
+ }
172
+
173
+ export function registerPublishCommands(program: Command) {
174
+ program
175
+ .command('publish')
176
+ .description('Bump version, write changelog, publish to npm, commit (run from spine-ia)')
177
+ .option('--app <slug>', 'App slug to publish (e.g. cortex). Omit to publish the framework itself.')
178
+ .option('--bump <level>', 'Version bump level: patch | minor | major', 'patch')
179
+ .option('--notes <text>', 'Comma-separated changelog notes (required in --ci mode)')
180
+ .option('--ci', 'Non-interactive mode: skip prompts, requires --notes', false)
181
+ .option('--dry-run', 'Show what would happen without making changes', false)
182
+ .action(async (opts) => {
183
+ try {
184
+ await publishTarget(opts as PublishOptions)
185
+ } catch (err: any) {
186
+ console.error('Error:', err.message)
187
+ if (process.env.SPINE_CLI_DEBUG) console.error(err.stack)
188
+ process.exit(1)
189
+ }
190
+ })
191
+ }
@@ -26,6 +26,7 @@ import { resolve, dirname } from 'path'
26
26
  import { fileURLToPath } from 'url'
27
27
  import { adminDb } from '../../functions/_shared/index.ts'
28
28
  import { discoverManifests } from '../../functions/_shared/app-manifest.ts'
29
+ import { readEngagementData, ensureSerialData, shouldOfferClaim } from './claim.ts'
29
30
 
30
31
  const __filename = fileURLToPath(import.meta.url)
31
32
  const __dirname = dirname(__filename)
@@ -37,14 +38,58 @@ interface StatusOptions {
37
38
 
38
39
  interface StatusReport {
39
40
  database: { connected: boolean; error?: string }
40
- apps: Array<{ slug: string; name: string; hasManifest: boolean; hasSeed: boolean; installed: boolean }>
41
+ installation: {
42
+ frameworkInstallSerial: string | null
43
+ engagementScore: number
44
+ claimStatus: 'anonymous' | 'claimed' | 'pending' | null
45
+ claimPrompt: { timing: string; message: string } | null
46
+ }
47
+ apps: Array<{
48
+ slug: string
49
+ name: string
50
+ hasManifest: boolean
51
+ hasSeed: boolean
52
+ installed: boolean
53
+ installedVersion: string | null
54
+ packageName: string | null
55
+ appInstallSerial: string | null
56
+ installTimestamp: string | null
57
+ }>
41
58
  webhookHandlers: Array<{ name: string; functionName: string; isActive: boolean }>
42
59
  typeCounts: { total: number; byApp: Record<string, number> }
43
60
  }
44
61
 
45
62
  async function gatherStatus(): Promise<StatusReport> {
63
+ // Get installation/engagement data
64
+ let frameworkSerial: string | null = null
65
+ let engagementScore = 0
66
+ let claimStatus: 'anonymous' | 'claimed' | 'pending' | null = null
67
+ let claimPrompt: { timing: string; message: string } | null = null
68
+
69
+ try {
70
+ const serialData = ensureSerialData()
71
+ frameworkSerial = serialData.serial_number
72
+ const engagement = readEngagementData()
73
+ if (engagement) {
74
+ engagementScore = engagement.engagement_score
75
+ claimStatus = engagement.claim_status
76
+ const prompt = shouldOfferClaim()
77
+ if (prompt) {
78
+ claimPrompt = { timing: prompt.timing, message: prompt.message }
79
+ }
80
+ }
81
+ } catch {
82
+ // Silent fail - engagement tracking is optional
83
+ }
84
+
46
85
  const report: StatusReport = {
47
86
  database: { connected: false },
87
+ installation: {
88
+ frameworkInstallSerial: frameworkSerial,
89
+ engagementScore,
90
+ claimStatus,
91
+ claimPrompt,
92
+ },
48
93
  apps: [],
49
94
  webhookHandlers: [],
50
95
  typeCounts: { total: 0, byApp: {} },
@@ -77,12 +122,16 @@ async function gatherStatus(): Promise<StatusReport> {
77
122
 
78
123
  const { data: installations } = await adminDb
79
124
  .from('app_installations')
80
- .select('app_slug, is_enabled')
125
+ .select('app_slug, is_enabled, installed_version, package_name')
81
126
 
82
127
  const installedSlugs = new Set(
83
128
  (installations || []).filter(i => i.is_enabled).map(i => i.app_slug)
84
129
  )
85
130
 
131
+ const installationMap = new Map(
132
+ (installations || []).map(i => [i.app_slug, i])
133
+ )
134
+
86
135
  const allSlugs = new Set([
87
136
  ...manifests.map(m => m.slug),
88
137
  ...(dbApps || []).map(a => a.slug).filter(s => s !== 'spine-core'),
@@ -93,12 +142,17 @@ async function gatherStatus(): Promise<StatusReport> {
93
142
  const dbApp = dbApps?.find(a => a.slug === slug)
94
143
  const seedDir = resolve(PROJECT_ROOT, `custom/apps/${slug}/seed`)
95
144
 
145
+ const installation = installationMap.get(slug)
96
146
  report.apps.push({
97
147
  slug,
98
148
  name: dbApp?.name || slug,
99
149
  hasManifest: !!manifest,
100
150
  hasSeed: existsSync(seedDir) && readdirSync(seedDir).some(f => f.endsWith('.json')),
101
151
  installed: installedSlugs.has(slug),
152
+ installedVersion: installation?.installed_version || null,
153
+ packageName: installation?.package_name || null,
154
+ appInstallSerial: null, // Populated via signal system
155
+ installTimestamp: null, // Populated via signal system
102
156
  })
103
157
  }
104
158
 
@@ -132,6 +186,22 @@ async function gatherStatus(): Promise<StatusReport> {
132
186
  function printStatus(report: StatusReport): void {
133
187
  console.log('\n🔍 Spine Framework Status\n')
134
188
 
189
+ // Installation / Claim Status
190
+ if (report.installation.frameworkInstallSerial) {
191
+ console.log(` 🔐 Installation: ${report.installation.frameworkInstallSerial}`)
192
+ if (report.installation.claimStatus === 'claimed') {
193
+ console.log(' ✅ Claimed: Yes')
194
+ } else {
195
+ console.log(` ⏳ Claim Status: ${report.installation.claimStatus || 'anonymous'}`)
196
+ console.log(` 📊 Engagement Score: ${report.installation.engagementScore}`)
197
+ if (report.installation.claimPrompt) {
198
+ console.log(` 💡 ${report.installation.claimPrompt.message}`)
199
+ console.log(` Run: spine-framework claim`)
200
+ }
201
+ }
202
+ console.log('')
203
+ }
204
+
135
205
  // Database
136
206
  if (report.database.connected) {
137
207
  console.log(' 📡 Database: ✅ Connected')
@@ -146,10 +216,11 @@ function printStatus(report: StatusReport): void {
146
216
  console.log(' (none)')
147
217
  } else {
148
218
  for (const app of report.apps) {
219
+ const versionStr = app.installedVersion ? `v${app.installedVersion}` : ''
149
220
  const status = [
150
221
  app.hasManifest ? '📋 manifest' : '',
151
222
  app.hasSeed ? '🌱 seed' : '',
152
- app.installed ? '✅ installed' : '⬜ not installed',
223
+ app.installed ? `✅ installed${versionStr ? ' ' + versionStr : ''}` : '⬜ not installed',
153
224
  ].filter(Boolean).join(' ')
154
225
  console.log(` ${app.slug.padEnd(24)} ${status}`)
155
226
  }