pantheon-opencode 1.1.1-beta.2 → 1.1.1-beta.4

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.
@@ -8,9 +8,10 @@
8
8
  * node scripts/install/migrate.mjs --target ~/.config/opencode --force
9
9
  */
10
10
 
11
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
11
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
12
12
  import { dirname, join } from 'node:path'
13
13
  import { fileURLToPath } from 'node:url'
14
+ import { readState, writeState, createInitialState } from './state.mjs'
14
15
 
15
16
  const __dirname = dirname(fileURLToPath(import.meta.url))
16
17
  const ROOT = join(__dirname, '..', '..')
@@ -33,16 +34,9 @@ function readPackageVersion() {
33
34
  * @returns {string|null} - version string or null for fresh install
34
35
  */
35
36
  export function detectVersion(target) {
36
- const stateFile = join(target, '.pantheon', 'install-state.json')
37
- if (existsSync(stateFile)) {
38
- try {
39
- const state = JSON.parse(readFileSync(stateFile, 'utf8'))
40
- return state.pantheon_version || state.version || 'unknown'
41
- } catch {
42
- /* corrupted state — treat as unknown */
43
- }
44
- }
45
- return null // fresh install
37
+ const state = readState(target)
38
+ if (state) return state.pantheon_version || state.version || 'unknown'
39
+ return null
46
40
  }
47
41
 
48
42
  /**
@@ -96,9 +90,42 @@ export function runMigrations(target, currentVersion, { dryRun = false } = {}) {
96
90
  semver[2] = 1
97
91
  }
98
92
 
93
+ // ── Migration: v3.19.1 → v3.19.2 ──────────────────────────────────
94
+ // Upgrade state schema from v1 to v2
95
+ if (semver && lt(semver, [3, 19, 2])) {
96
+ applied.push({
97
+ from: formatSemver(semver),
98
+ to: '3.19.2',
99
+ description: 'Upgrade install state schema to v2 with component tracking',
100
+ })
101
+ semver[0] = 3
102
+ semver[1] = 19
103
+ semver[2] = 2
104
+ }
105
+
99
106
  // ── Write updated state ───────────────────────────────────────────
100
107
  if (applied.length > 0 && !dryRun) {
101
- writeInstallState(target, formatSemver(semver))
108
+ const newVersion = formatSemver(semver)
109
+ let state = readState(target)
110
+ if (!state) state = createInitialState(newVersion)
111
+ state.pantheon_version = newVersion
112
+ for (const m of applied) {
113
+ state.applied_migrations.push({
114
+ from: m.from,
115
+ to: m.to,
116
+ applied_at: new Date().toISOString(),
117
+ status: 'completed',
118
+ })
119
+ }
120
+ if (!state.components) {
121
+ state.components = {
122
+ agents: { version: 1, status: 'installed' },
123
+ skills: { version: 1, status: 'installed' },
124
+ plugins: { version: 1, status: 'installed' },
125
+ runtime: { version: 1, status: 'installed' },
126
+ }
127
+ }
128
+ writeState(target, state)
102
129
  }
103
130
 
104
131
  return {
@@ -107,20 +134,150 @@ export function runMigrations(target, currentVersion, { dryRun = false } = {}) {
107
134
  }
108
135
  }
109
136
 
137
+ // ---------------------------------------------------------------------------
138
+ // Checkpoint system for crash recovery
139
+ // ---------------------------------------------------------------------------
140
+
141
+ const CHECKPOINT_FILE = '.migration-checkpoint'
142
+ const STATE_DIR = '.pantheon'
143
+
144
+ /**
145
+ * Read the migration checkpoint file if it exists.
146
+ * @param {string} target
147
+ * @returns {object|null}
148
+ */
149
+ export function readCheckpoint(target) {
150
+ const cpFile = join(target, STATE_DIR, CHECKPOINT_FILE)
151
+ if (!existsSync(cpFile)) return null
152
+ try {
153
+ return JSON.parse(readFileSync(cpFile, 'utf8'))
154
+ } catch {
155
+ return null
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Write a migration checkpoint before starting migrations.
161
+ * @param {string} target
162
+ * @param {string} fromVersion
163
+ * @param {string} toVersion
164
+ * @param {string[]} steps - Ordered list of step names
165
+ * @returns {object} The checkpoint object
166
+ */
167
+ export function writeCheckpoint(target, fromVersion, toVersion, steps) {
168
+ const cpDir = join(target, STATE_DIR)
169
+ if (!existsSync(cpDir)) mkdirSync(cpDir, { recursive: true })
170
+ const cpFile = join(cpDir, CHECKPOINT_FILE)
171
+ const checkpoint = {
172
+ from: fromVersion,
173
+ to: toVersion,
174
+ started_at: new Date().toISOString(),
175
+ steps_completed: [],
176
+ steps_pending: [...steps],
177
+ }
178
+ writeFileSync(cpFile, `${JSON.stringify(checkpoint, null, 2)}\n`, 'utf8')
179
+ return checkpoint
180
+ }
181
+
182
+ /**
183
+ * Advance the checkpoint: mark a step as completed and remove from pending.
184
+ * @param {string} target
185
+ * @param {string} stepName
186
+ * @returns {object|null} Updated checkpoint, or null if none exists
187
+ */
188
+ export function advanceCheckpoint(target, stepName) {
189
+ const cp = readCheckpoint(target)
190
+ if (!cp) return null
191
+ cp.steps_completed.push(stepName)
192
+ cp.steps_pending = cp.steps_pending.filter((s) => s !== stepName)
193
+ const cpFile = join(target, STATE_DIR, CHECKPOINT_FILE)
194
+ writeFileSync(cpFile, `${JSON.stringify(cp, null, 2)}\n`, 'utf8')
195
+ return cp
196
+ }
197
+
198
+ /**
199
+ * Remove the checkpoint file on successful completion.
200
+ * @param {string} target
201
+ */
202
+ export function removeCheckpoint(target) {
203
+ const cpFile = join(target, STATE_DIR, CHECKPOINT_FILE)
204
+ if (existsSync(cpFile)) {
205
+ rmSync(cpFile, { force: true })
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Resume from a pending checkpoint.
211
+ * Reads the checkpoint and reports what steps remain.
212
+ * @param {string} target
213
+ * @returns {object|null} The checkpoint, or null if none pending
214
+ */
215
+ export function resumePendingMigration(target) {
216
+ const cp = readCheckpoint(target)
217
+ if (!cp) return null
218
+ console.log(` \u{1F504} Resuming migration from checkpoint: ${cp.from} \u2192 ${cp.to}`)
219
+ console.log(` Completed: ${cp.steps_completed.join(', ') || 'none'}`)
220
+ console.log(` Pending: ${cp.steps_pending.join(', ') || 'none'}`)
221
+ return cp
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Rollback support
226
+ // ---------------------------------------------------------------------------
227
+
110
228
  /**
111
- * Write install state with current version to target.
229
+ * Roll back the last completed migration for a given version.
230
+ * Each migration step should define a corresponding down-migration.
231
+ * This is a stub — actual down-migration logic should be registered
232
+ * in a DOWN_MIGRATIONS registry.
233
+ *
234
+ * @param {string} target
235
+ * @param {string} version - The version to roll back from
236
+ * @returns {{ success: boolean, reason?: string, migration?: object }}
112
237
  */
113
- function writeInstallState(target, version) {
114
- const stateDir = join(target, '.pantheon')
115
- const stateFile = join(stateDir, 'install-state.json')
116
- if (!existsSync(stateDir)) {
117
- mkdirSync(stateDir, { recursive: true })
238
+ export function rollbackMigration(target, version) {
239
+ const state = readState(target)
240
+ if (!state) {
241
+ console.log(' \u26A0\uFE0F No install state found — nothing to roll back')
242
+ return { success: false, reason: 'No install state' }
118
243
  }
119
- const state = {
120
- pantheon_version: version,
121
- updated_at: new Date().toISOString(),
244
+
245
+ const migrations = state.applied_migrations || []
246
+ const targetMigration = migrations.find(
247
+ (m) => m.to === version && m.status === 'completed',
248
+ )
249
+ if (!targetMigration) {
250
+ console.log(` \u26A0\uFE0F No completed migration found for version ${version}`)
251
+ return { success: false, reason: 'Migration not found' }
252
+ }
253
+
254
+ console.log(
255
+ ` \u{1F504} Rolling back migration: ${targetMigration.from} \u2192 ${targetMigration.to}`,
256
+ )
257
+
258
+ targetMigration.status = 'rolled_back'
259
+ state.pantheon_version = targetMigration.from
260
+ writeState(target, state)
261
+
262
+ console.log(` \u2705 Rolled back to ${targetMigration.from}`)
263
+ return { success: true, migration: targetMigration }
264
+ }
265
+
266
+ /**
267
+ * List all applied migrations from the install state.
268
+ * @param {string} target
269
+ * @returns {{ currentVersion: string|null, previousVersion: string|null, migrations: object[] }}
270
+ */
271
+ export function listAppliedMigrations(target) {
272
+ const state = readState(target)
273
+ if (!state) {
274
+ return { currentVersion: null, previousVersion: null, migrations: [] }
275
+ }
276
+ return {
277
+ currentVersion: state.pantheon_version,
278
+ previousVersion: state.previous_version,
279
+ migrations: state.applied_migrations || [],
122
280
  }
123
- writeFileSync(stateFile, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
124
281
  }
125
282
 
126
283
  // ---------------------------------------------------------------------------
@@ -172,15 +329,38 @@ function main() {
172
329
  const target = targetIdx !== -1 ? args[targetIdx + 1] : process.cwd()
173
330
  const dryRun = args.includes('--dry-run')
174
331
  const force = args.includes('--force')
332
+ const list = args.includes('--list')
333
+
334
+ if (list) {
335
+ const history = listAppliedMigrations(target)
336
+ if (!history.currentVersion) {
337
+ console.log(' \u2139\uFE0F No install state found in: ' + target)
338
+ return
339
+ }
340
+ console.log(` Current version: ${history.currentVersion}`)
341
+ console.log(` Previous version: ${history.previousVersion || 'N/A'}`)
342
+ console.log(` Applied migrations: ${history.migrations.length}`)
343
+ for (const m of history.migrations) {
344
+ const icon = m.status === 'completed' ? '\u2705' : m.status === 'rolled_back' ? '\u{1F504}' : '\u26A0\uFE0F'
345
+ console.log(` ${icon} ${m.from} \u2192 ${m.to} [${m.status}] (${m.applied_at})`)
346
+ }
347
+ return
348
+ }
349
+
350
+ const rollbackTo = args.find((a) => a.startsWith('--rollback='))?.split('=', 2)[1]
351
+ if (rollbackTo) {
352
+ rollbackMigration(target, rollbackTo)
353
+ return
354
+ }
175
355
 
176
356
  const currentVersion = detectVersion(target)
177
357
  if (!currentVersion) {
178
- // Fresh install — write state and exit
179
358
  if (!dryRun) {
180
- writeInstallState(target, PANTHON_VERSION)
181
- console.log(` ✅ Fresh install — state written (${PANTHON_VERSION})`)
359
+ const state = createInitialState(PANTHON_VERSION)
360
+ writeState(target, state)
361
+ console.log(` \u2705 Fresh install — state written (${PANTHON_VERSION})`)
182
362
  } else {
183
- console.log(` ✅ Fresh install — would write state (${PANTHON_VERSION})`)
363
+ console.log(` \u2705 Fresh install — would write state (${PANTHON_VERSION})`)
184
364
  }
185
365
  return
186
366
  }
@@ -188,19 +368,19 @@ function main() {
188
368
  console.log(` Current version: ${currentVersion}`)
189
369
 
190
370
  if (!force && currentVersion === PANTHON_VERSION) {
191
- console.log(' ⏭️ Already at latest version — no migrations needed')
371
+ console.log(' \u23ED\uFE0F Already at latest version — no migrations needed')
192
372
  return
193
373
  }
194
374
 
195
375
  const migration = runMigrations(target, currentVersion, { dryRun })
196
376
 
197
377
  if (migration.applied > 0) {
198
- console.log(` ✅ Applied ${migration.applied} migration(s):`)
378
+ console.log(` \u2705 Applied ${migration.applied} migration(s):`)
199
379
  for (const msg of migration.messages) {
200
- console.log(` • ${msg}`)
380
+ console.log(` \u2022 ${msg}`)
201
381
  }
202
382
  } else {
203
- console.log(' ⏭️ No applicable migrations')
383
+ console.log(' \u23ED\uFE0F No applicable migrations')
204
384
  }
205
385
  }
206
386
 
@@ -3,17 +3,16 @@
3
3
  * opencode.mjs — OpenCode platform installer
4
4
  */
5
5
 
6
- import { execSync } from 'node:child_process'
7
6
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
8
7
  import { homedir } from 'node:os'
9
8
  import { join, resolve } from 'node:path'
10
9
  import { healthCheck } from './health-check.mjs'
11
10
  import { detectVersion, runMigrations } from './migrate.mjs'
11
+ import { configure, section, step, success, warning, error, info, bullet, spinner, printSummary } from './cli-ui.mjs'
12
12
  import {
13
13
  collectSkillNames,
14
14
  copyFiles,
15
15
  installSkills,
16
- PLATFORM_DIR,
17
16
  parseFrontmatter,
18
17
  ROOT,
19
18
  sourceDirValid,
@@ -22,6 +21,8 @@ import {
22
21
  writeIfChanged,
23
22
  } from './shared.mjs'
24
23
  import { setupVenv } from './venv.mjs'
24
+ import { installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
25
+ import { resolveComponents } from './component.mjs'
25
26
 
26
27
  function deepClone(value) {
27
28
  return JSON.parse(JSON.stringify(value))
@@ -94,21 +95,23 @@ export function installOpenCode(
94
95
  const _subDir = isGlobal ? '' : '.opencode'
95
96
  const agentPrefix = isGlobal ? 'agents' : '.opencode/agents'
96
97
 
98
+ configure({ dryRun })
99
+
97
100
  if (isGlobal) {
98
- console.log(
99
- ' 🌐 Global config directory detected — using flat layout (agents/, skills/, commands/)',
100
- )
101
+ info('Global config directory detected — using flat layout (agents/, skills/, commands/)')
101
102
  }
102
103
 
103
104
  // -----------------------------------------------------------------------
104
105
  // 1. Install agents (--components agents)
105
106
  // -----------------------------------------------------------------------
106
107
  if (componentSet.has('agents')) {
108
+ section('\uD83D\uDCE6 Agents')
107
109
  const srcDir = join(ROOT, 'src', 'agents')
108
110
  if (!sourceDirValid(srcDir)) {
109
- console.warn(` ⚠️ Agent source directory not found: ${srcDir}`)
111
+ warning(`Agent source directory not found: ${srcDir}`)
110
112
  stats.errors++
111
113
  } else {
114
+ const agentStep = step('Installing agents')
112
115
  const dstDir = isGlobal ? join(target, 'agents') : join(target, '.opencode', 'agents')
113
116
  if (!dryRun) mkdirSync(dstDir, { recursive: true })
114
117
  if (clean && existsSync(dstDir) && !dryRun) {
@@ -120,6 +123,7 @@ export function installOpenCode(
120
123
  const { created, skipped } = copyFiles(srcDir, dstDir, dryRun)
121
124
  stats.created += created
122
125
  stats.skipped += skipped
126
+ agentStep(true)
123
127
  }
124
128
  }
125
129
 
@@ -139,9 +143,10 @@ export function installOpenCode(
139
143
  // 2. Install skills (--components skills)
140
144
  // -----------------------------------------------------------------------
141
145
  if (componentSet.has('skills')) {
146
+ section('\uD83D\uDCDA Skills')
142
147
  const skillNames = collectSkillNames()
143
148
  if (skillNames.length > 0) {
144
- console.log(` 📚 Installing ${skillNames.length} skills...`)
149
+ const skillStep = step(`Installing ${skillNames.length} skills`)
145
150
  const dstSkillsDir = isGlobal ? join(target, 'skills') : join(target, '.opencode', 'skills')
146
151
  if (clean && existsSync(dstSkillsDir) && !dryRun) {
147
152
  const existing = readdirSync(dstSkillsDir)
@@ -158,6 +163,7 @@ export function installOpenCode(
158
163
  )
159
164
  stats.created += sCreated
160
165
  stats.skipped += sSkipped
166
+ skillStep(true)
161
167
  }
162
168
  }
163
169
 
@@ -165,6 +171,7 @@ export function installOpenCode(
165
171
  // 2.5 Install instructions: AGENTS.md + instructions/ (--components instructions)
166
172
  // -----------------------------------------------------------------------
167
173
  if (componentSet.has('instructions')) {
174
+ section('\uD83D\uDCCB Instructions')
168
175
  // AGENTS.md
169
176
  const srcAgentsMd = join(ROOT, 'AGENTS.md')
170
177
  const dstAgentsMd = join(target, 'AGENTS.md')
@@ -188,6 +195,7 @@ export function installOpenCode(
188
195
  // 2.6 Install prompts (--components prompts)
189
196
  // -----------------------------------------------------------------------
190
197
  if (componentSet.has('prompts')) {
198
+ section('\uD83D\uDCAC Prompts')
191
199
  const srcPrompts = join(ROOT, 'prompts')
192
200
  const dstPrompts = join(target, 'prompts')
193
201
  if (existsSync(srcPrompts)) {
@@ -201,6 +209,7 @@ export function installOpenCode(
201
209
  // 2.7 Install commands (--components commands)
202
210
  // -----------------------------------------------------------------------
203
211
  if (componentSet.has('commands')) {
212
+ section('\u26A1 Commands')
204
213
  const srcCmds = join(ROOT, 'commands')
205
214
  const dstCmds = isGlobal ? join(target, 'commands') : join(target, '.opencode', 'commands')
206
215
  if (existsSync(srcCmds)) {
@@ -223,10 +232,10 @@ export function installOpenCode(
223
232
  if (cfg.subagent_depth === undefined) { cfg.subagent_depth = 2; changed = true }
224
233
  if (changed && !dryRun) {
225
234
  writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n')
226
- console.log(' ✅ subagent_depth: 2 added to opencode.json')
235
+ success('subagent_depth: 2 added to opencode.json')
227
236
  }
228
237
  } catch (e) {
229
- console.warn(` ⚠️ Could not update opencode.json: ${e.message}`)
238
+ warning(`Could not update opencode.json: ${e.message}`)
230
239
  }
231
240
  }
232
241
  }
@@ -234,46 +243,15 @@ export function installOpenCode(
234
243
  // 2.8 Install TUI plugins (--components plugins)
235
244
  // -----------------------------------------------------------------------
236
245
  if (componentSet.has('plugins')) {
246
+ section('\uD83D\uDD0C Plugins')
237
247
  const srcPluginDir = join(ROOT, 'src', 'plugins', 'tui')
238
248
  const dstPluginDir = isGlobal
239
249
  ? join(target, 'plugins', 'pantheon-tui')
240
250
  : join(target, '.opencode', 'plugins', 'pantheon-tui')
241
- if (!dryRun) mkdirSync(dstPluginDir, { recursive: true })
242
- if (clean && existsSync(dstPluginDir) && !dryRun) {
243
- rmSync(dstPluginDir, { recursive: true, force: true })
244
- mkdirSync(dstPluginDir, { recursive: true })
245
- }
246
- if (!dryRun) mkdirSync(join(dstPluginDir, "dist"), { recursive: true })
247
- // Copy src/index.tsx -> index.tsx
248
- const srcIdx = join(srcPluginDir, "src", "index.tsx")
249
- if (existsSync(srcIdx)) {
250
- writeIfChanged(join(dstPluginDir, "index.tsx"), readFileSync(srcIdx, "utf8"), dryRun)
251
- stats.created++
252
- }
253
- // Copy dist files
254
- const distSrc = join(srcPluginDir, "dist")
255
- if (existsSync(distSrc)) {
256
- for (const f of readdirSync(distSrc)) {
257
- const c = readFileSync(join(distSrc, f), "utf8")
258
- writeIfChanged(join(dstPluginDir, "dist", f), c, dryRun)
259
- stats.created++
260
- }
261
- }
262
- // Copy package.json
263
- const pkgSrc = join(srcPluginDir, "package.json")
264
- if (existsSync(pkgSrc)) {
265
- writeIfChanged(join(dstPluginDir, "package.json"), readFileSync(pkgSrc, "utf8"), dryRun)
266
- stats.created++
267
- }
268
- // Install plugin dependencies (@opentui/core, @opentui/solid, solid-js)
269
- if (!dryRun) {
270
- try {
271
- execSync('npm install --omit=dev --no-audit --no-fund --legacy-peer-deps', { cwd: dstPluginDir, stdio: 'pipe' })
272
- } catch (e) {
273
- console.warn(' ⚠️ Failed to install TUI plugin dependencies:', e.message)
274
- }
275
- }
276
-
251
+ const pluginStats = installPlugin(srcPluginDir, dstPluginDir, { dryRun, clean })
252
+ stats.created += pluginStats.created
253
+ stats.skipped += pluginStats.skipped
254
+ stats.errors += pluginStats.errors
277
255
  }
278
256
 
279
257
  // -----------------------------------------------------------------------
@@ -282,30 +260,9 @@ export function installOpenCode(
282
260
  const targetTuiConfigPath = isGlobal
283
261
  ? join(target, 'tui.json')
284
262
  : join(target, '.opencode', 'tui.json')
285
- let tuiConfig = { plugin: [] }
286
- if (existsSync(targetTuiConfigPath)) {
287
- try {
288
- tuiConfig = JSON.parse(readFileSync(targetTuiConfigPath, 'utf8'))
289
- } catch {
290
- /* use default */
291
- }
292
- }
293
- if (!Array.isArray(tuiConfig.plugin)) {
294
- tuiConfig.plugin = []
295
- }
296
- // Remove stale plugin refs (old dist/tui.tsx path)
297
- const staleRefs = ["plugins/pantheon-tui/dist/tui.tsx", "plugins/pantheon-tui/dist/tui.js"]
298
- for (const stale of staleRefs) {
299
- const idx = tuiConfig.plugin.indexOf(stale)
300
- if (idx !== -1) tuiConfig.plugin.splice(idx, 1)
301
- }
302
- // Add our plugin if not already present
303
263
  const pluginRef = 'plugins/pantheon-tui'
304
- if (!tuiConfig.plugin.includes(pluginRef)) {
305
- tuiConfig.plugin.push(pluginRef)
306
- }
307
- const tuiContent = `${JSON.stringify(tuiConfig, null, 2)}\n`
308
- const tuiStatus = writeIfChanged(targetTuiConfigPath, tuiContent, dryRun)
264
+ unregisterPlugin(targetTuiConfigPath, pluginRef, { dryRun })
265
+ const tuiStatus = registerPlugin(targetTuiConfigPath, pluginRef, { dryRun })
309
266
  if (tuiStatus === 'created') stats.created++
310
267
  else stats.skipped++
311
268
 
@@ -314,6 +271,7 @@ export function installOpenCode(
314
271
  // MCP server scripts, code-mode scripts, tiers.json
315
272
  // -----------------------------------------------------------------------
316
273
  if (componentSet.has('runtime')) {
274
+ section('\u2699\uFE0F Runtime')
317
275
  const runtimeTarget = isGlobal ? target : join(target, '.opencode')
318
276
 
319
277
  // ── MCP server scripts ──
@@ -331,7 +289,7 @@ export function installOpenCode(
331
289
  for (const script of mcpScripts) {
332
290
  const src = join(srcScriptsDir, script)
333
291
  if (!existsSync(src)) {
334
- console.warn(` ⚠️ Script not found: ${src}`)
292
+ warning(`Script not found: ${src}`)
335
293
  stats.errors++
336
294
  continue
337
295
  }
@@ -683,9 +641,9 @@ export function installOpenCode(
683
641
  if (currentVersion) {
684
642
  const migration = runMigrations(target, currentVersion, { dryRun })
685
643
  if (migration.applied > 0) {
686
- console.log(` ✅ Applied ${migration.applied} migration(s)`)
644
+ success(`Applied ${migration.applied} migration(s)`)
687
645
  for (const msg of migration.messages) {
688
- console.log(` • ${msg}`)
646
+ bullet(msg, 1)
689
647
  }
690
648
  }
691
649
  }
@@ -696,22 +654,30 @@ export function installOpenCode(
696
654
  // -----------------------------------------------------------------------
697
655
  if (componentSet.has('runtime')) {
698
656
  try {
657
+ const venvSpinner = spinner('Setting up Python virtual environment')
699
658
  setupVenv(target, { dryRun, force: clean })
659
+ venvSpinner(true)
700
660
  const health = healthCheck(target, { dryRun })
701
661
 
702
662
  // Print health summary
703
- console.log('\n 📊 Health Check:')
704
- for (const p of health.passed) console.log(` ✅ ${p.check}: ${p.detail}`)
705
- for (const w of health.warnings) console.log(` ⚠️ ${w.check}: ${w.detail}`)
706
- for (const f of health.failed) console.log(` ❌ ${f.check}: ${f.detail}`)
663
+ section('\uD83D\uDD0D Health Check')
664
+ const healthStep = step('Running health checks')
665
+ for (const p of health.passed) success(`${p.check}: ${p.detail}`)
666
+ for (const w of health.warnings) warning(`${w.check}: ${w.detail}`)
667
+ for (const f of health.failed) error(`${f.check}: ${f.detail}`)
707
668
 
708
669
  if (health.failed.length > 0) {
709
- console.log(` ⚠️ ${health.failed.length} check(s) failed — review above`)
670
+ healthStep(false)
671
+ warning(`${health.failed.length} check(s) failed — review above`)
710
672
  stats.errors += health.failed.length
673
+ } else {
674
+ healthStep(true)
711
675
  }
712
676
  } catch (err) {
713
- console.error(` ❌ Setup failed: ${err.message}`)
677
+ error(`Setup failed: ${err.message}`)
714
678
  throw err // Fatal — abort installation
715
679
  }
716
680
  }
681
+
682
+ printSummary(target, ['opencode'], stats)
717
683
  }