spindb 0.5.2 → 0.5.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.
Files changed (38) hide show
  1. package/README.md +188 -9
  2. package/cli/commands/connect.ts +334 -105
  3. package/cli/commands/create.ts +106 -67
  4. package/cli/commands/deps.ts +19 -4
  5. package/cli/commands/edit.ts +245 -0
  6. package/cli/commands/engines.ts +434 -0
  7. package/cli/commands/info.ts +279 -0
  8. package/cli/commands/list.ts +1 -1
  9. package/cli/commands/menu.ts +664 -167
  10. package/cli/commands/restore.ts +11 -25
  11. package/cli/commands/start.ts +25 -20
  12. package/cli/commands/url.ts +79 -0
  13. package/cli/index.ts +9 -3
  14. package/cli/ui/prompts.ts +20 -12
  15. package/cli/ui/theme.ts +1 -1
  16. package/config/engine-defaults.ts +24 -1
  17. package/config/os-dependencies.ts +151 -113
  18. package/config/paths.ts +7 -36
  19. package/core/binary-manager.ts +12 -6
  20. package/core/config-manager.ts +17 -5
  21. package/core/dependency-manager.ts +144 -15
  22. package/core/error-handler.ts +336 -0
  23. package/core/platform-service.ts +634 -0
  24. package/core/port-manager.ts +11 -3
  25. package/core/process-manager.ts +12 -2
  26. package/core/start-with-retry.ts +167 -0
  27. package/core/transaction-manager.ts +170 -0
  28. package/engines/mysql/binary-detection.ts +177 -100
  29. package/engines/mysql/index.ts +240 -131
  30. package/engines/mysql/restore.ts +257 -0
  31. package/engines/mysql/version-validator.ts +373 -0
  32. package/{core/postgres-binary-manager.ts → engines/postgresql/binary-manager.ts} +63 -23
  33. package/engines/postgresql/binary-urls.ts +5 -3
  34. package/engines/postgresql/index.ts +35 -4
  35. package/engines/postgresql/restore.ts +54 -5
  36. package/engines/postgresql/version-validator.ts +262 -0
  37. package/package.json +6 -2
  38. package/cli/commands/postgres-tools.ts +0 -216
@@ -0,0 +1,434 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import { readdir, lstat, rm } from 'fs/promises'
4
+ import { existsSync } from 'fs'
5
+ import { join } from 'path'
6
+ import { exec } from 'child_process'
7
+ import { promisify } from 'util'
8
+ import inquirer from 'inquirer'
9
+ import { paths } from '../../config/paths'
10
+ import { containerManager } from '../../core/container-manager'
11
+ import { promptConfirm } from '../ui/prompts'
12
+ import { createSpinner } from '../ui/spinner'
13
+ import { error, warning, info } from '../ui/theme'
14
+ import {
15
+ getMysqldPath,
16
+ getMysqlVersion,
17
+ isMariaDB,
18
+ } from '../../engines/mysql/binary-detection'
19
+
20
+ const execAsync = promisify(exec)
21
+
22
+ /**
23
+ * Installed engine info for PostgreSQL (downloaded binaries)
24
+ */
25
+ type InstalledPostgresEngine = {
26
+ engine: 'postgresql'
27
+ version: string
28
+ platform: string
29
+ arch: string
30
+ path: string
31
+ sizeBytes: number
32
+ source: 'downloaded'
33
+ }
34
+
35
+ /**
36
+ * Installed engine info for MySQL (system-installed)
37
+ */
38
+ type InstalledMysqlEngine = {
39
+ engine: 'mysql'
40
+ version: string
41
+ path: string
42
+ source: 'system'
43
+ isMariaDB: boolean
44
+ }
45
+
46
+ type InstalledEngine = InstalledPostgresEngine | InstalledMysqlEngine
47
+
48
+ /**
49
+ * Get the actual PostgreSQL version from the binary
50
+ */
51
+ async function getPostgresVersion(binPath: string): Promise<string | null> {
52
+ const postgresPath = join(binPath, 'bin', 'postgres')
53
+ if (!existsSync(postgresPath)) {
54
+ return null
55
+ }
56
+
57
+ try {
58
+ const { stdout } = await execAsync(`"${postgresPath}" --version`)
59
+ // Output: postgres (PostgreSQL) 17.7
60
+ const match = stdout.match(/\(PostgreSQL\)\s+([\d.]+)/)
61
+ return match ? match[1] : null
62
+ } catch {
63
+ return null
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Get installed PostgreSQL engines from ~/.spindb/bin/
69
+ */
70
+ async function getInstalledPostgresEngines(): Promise<
71
+ InstalledPostgresEngine[]
72
+ > {
73
+ const binDir = paths.bin
74
+
75
+ if (!existsSync(binDir)) {
76
+ return []
77
+ }
78
+
79
+ const entries = await readdir(binDir, { withFileTypes: true })
80
+ const engines: InstalledPostgresEngine[] = []
81
+
82
+ for (const entry of entries) {
83
+ if (entry.isDirectory()) {
84
+ // Parse directory name: postgresql-17-darwin-arm64
85
+ const match = entry.name.match(/^(\w+)-(.+)-(\w+)-(\w+)$/)
86
+ if (match && match[1] === 'postgresql') {
87
+ const [, , majorVersion, platform, arch] = match
88
+ const dirPath = join(binDir, entry.name)
89
+
90
+ // Get actual version from the binary
91
+ const actualVersion =
92
+ (await getPostgresVersion(dirPath)) || majorVersion
93
+
94
+ // Get directory size
95
+ let sizeBytes = 0
96
+ try {
97
+ const files = await readdir(dirPath, { recursive: true })
98
+ for (const file of files) {
99
+ try {
100
+ const filePath = join(dirPath, file.toString())
101
+ const fileStat = await lstat(filePath)
102
+ if (fileStat.isFile()) {
103
+ sizeBytes += fileStat.size
104
+ }
105
+ } catch {
106
+ // Skip files we can't stat
107
+ }
108
+ }
109
+ } catch {
110
+ // Skip directories we can't read
111
+ }
112
+
113
+ engines.push({
114
+ engine: 'postgresql',
115
+ version: actualVersion,
116
+ platform,
117
+ arch,
118
+ path: dirPath,
119
+ sizeBytes,
120
+ source: 'downloaded',
121
+ })
122
+ }
123
+ }
124
+ }
125
+
126
+ // Sort by version descending
127
+ engines.sort((a, b) => compareVersions(b.version, a.version))
128
+
129
+ return engines
130
+ }
131
+
132
+ /**
133
+ * Detect system-installed MySQL
134
+ */
135
+ async function getInstalledMysqlEngine(): Promise<InstalledMysqlEngine | null> {
136
+ const mysqldPath = await getMysqldPath()
137
+ if (!mysqldPath) {
138
+ return null
139
+ }
140
+
141
+ const version = await getMysqlVersion(mysqldPath)
142
+ if (!version) {
143
+ return null
144
+ }
145
+
146
+ const mariadb = await isMariaDB()
147
+
148
+ return {
149
+ engine: 'mysql',
150
+ version,
151
+ path: mysqldPath,
152
+ source: 'system',
153
+ isMariaDB: mariadb,
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Get all installed engines (PostgreSQL + MySQL)
159
+ */
160
+ async function getInstalledEngines(): Promise<InstalledEngine[]> {
161
+ const engines: InstalledEngine[] = []
162
+
163
+ // Get PostgreSQL engines
164
+ const pgEngines = await getInstalledPostgresEngines()
165
+ engines.push(...pgEngines)
166
+
167
+ // Get MySQL engine
168
+ const mysqlEngine = await getInstalledMysqlEngine()
169
+ if (mysqlEngine) {
170
+ engines.push(mysqlEngine)
171
+ }
172
+
173
+ return engines
174
+ }
175
+
176
+ function compareVersions(a: string, b: string): number {
177
+ const partsA = a.split('.').map((p) => parseInt(p, 10) || 0)
178
+ const partsB = b.split('.').map((p) => parseInt(p, 10) || 0)
179
+
180
+ for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
181
+ const numA = partsA[i] || 0
182
+ const numB = partsB[i] || 0
183
+ if (numA !== numB) return numA - numB
184
+ }
185
+ return 0
186
+ }
187
+
188
+ function formatBytes(bytes: number): string {
189
+ if (bytes === 0) return '0 B'
190
+ const k = 1024
191
+ const sizes = ['B', 'KB', 'MB', 'GB']
192
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
193
+ return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
194
+ }
195
+
196
+ /**
197
+ * Engine icons
198
+ */
199
+ const engineIcons: Record<string, string> = {
200
+ postgresql: '🐘',
201
+ mysql: '🐬',
202
+ }
203
+
204
+ /**
205
+ * List subcommand action
206
+ */
207
+ async function listEngines(options: { json?: boolean }): Promise<void> {
208
+ const engines = await getInstalledEngines()
209
+
210
+ if (options.json) {
211
+ console.log(JSON.stringify(engines, null, 2))
212
+ return
213
+ }
214
+
215
+ if (engines.length === 0) {
216
+ console.log(info('No engines installed yet.'))
217
+ console.log(
218
+ chalk.gray(
219
+ ' PostgreSQL engines are downloaded automatically when you create a container.',
220
+ ),
221
+ )
222
+ console.log(
223
+ chalk.gray(
224
+ ' MySQL requires system installation (brew install mysql or apt install mysql-server).',
225
+ ),
226
+ )
227
+ return
228
+ }
229
+
230
+ // Separate PostgreSQL and MySQL
231
+ const pgEngines = engines.filter(
232
+ (e): e is InstalledPostgresEngine => e.engine === 'postgresql',
233
+ )
234
+ const mysqlEngine = engines.find(
235
+ (e): e is InstalledMysqlEngine => e.engine === 'mysql',
236
+ )
237
+
238
+ // Calculate total size for PostgreSQL
239
+ const totalPgSize = pgEngines.reduce((acc, e) => acc + e.sizeBytes, 0)
240
+
241
+ // Table header
242
+ console.log()
243
+ console.log(
244
+ chalk.gray(' ') +
245
+ chalk.bold.white('ENGINE'.padEnd(14)) +
246
+ chalk.bold.white('VERSION'.padEnd(12)) +
247
+ chalk.bold.white('SOURCE'.padEnd(18)) +
248
+ chalk.bold.white('SIZE'),
249
+ )
250
+ console.log(chalk.gray(' ' + '─'.repeat(55)))
251
+
252
+ // PostgreSQL rows
253
+ for (const engine of pgEngines) {
254
+ const icon = engineIcons[engine.engine] || '▣'
255
+ const platformInfo = `${engine.platform}-${engine.arch}`
256
+
257
+ console.log(
258
+ chalk.gray(' ') +
259
+ chalk.cyan(`${icon} ${engine.engine}`.padEnd(13)) +
260
+ chalk.yellow(engine.version.padEnd(12)) +
261
+ chalk.gray(platformInfo.padEnd(18)) +
262
+ chalk.white(formatBytes(engine.sizeBytes)),
263
+ )
264
+ }
265
+
266
+ // MySQL row
267
+ if (mysqlEngine) {
268
+ const icon = engineIcons.mysql
269
+ const displayName = mysqlEngine.isMariaDB ? 'mariadb' : 'mysql'
270
+
271
+ console.log(
272
+ chalk.gray(' ') +
273
+ chalk.cyan(`${icon} ${displayName}`.padEnd(13)) +
274
+ chalk.yellow(mysqlEngine.version.padEnd(12)) +
275
+ chalk.gray('system'.padEnd(18)) +
276
+ chalk.gray('(system-installed)'),
277
+ )
278
+ }
279
+
280
+ console.log(chalk.gray(' ' + '─'.repeat(55)))
281
+
282
+ // Summary
283
+ console.log()
284
+ if (pgEngines.length > 0) {
285
+ console.log(
286
+ chalk.gray(
287
+ ` PostgreSQL: ${pgEngines.length} version(s), ${formatBytes(totalPgSize)}`,
288
+ ),
289
+ )
290
+ }
291
+ if (mysqlEngine) {
292
+ console.log(chalk.gray(` MySQL: system-installed at ${mysqlEngine.path}`))
293
+ }
294
+ console.log()
295
+ }
296
+
297
+ /**
298
+ * Delete subcommand action
299
+ */
300
+ async function deleteEngine(
301
+ engine: string | undefined,
302
+ version: string | undefined,
303
+ options: { yes?: boolean },
304
+ ): Promise<void> {
305
+ // Get PostgreSQL engines only (MySQL can't be deleted via spindb)
306
+ const pgEngines = await getInstalledPostgresEngines()
307
+
308
+ if (pgEngines.length === 0) {
309
+ console.log(warning('No deletable engines found.'))
310
+ console.log(
311
+ chalk.gray(
312
+ ' MySQL is system-installed and cannot be deleted via spindb.',
313
+ ),
314
+ )
315
+ return
316
+ }
317
+
318
+ let engineName = engine
319
+ let engineVersion = version
320
+
321
+ // Interactive selection if not provided
322
+ if (!engineName || !engineVersion) {
323
+ const choices = pgEngines.map((e) => ({
324
+ name: `${engineIcons[e.engine]} ${e.engine} ${e.version} ${chalk.gray(`(${formatBytes(e.sizeBytes)})`)}`,
325
+ value: `${e.engine}:${e.version}:${e.path}`,
326
+ }))
327
+
328
+ const { selected } = await inquirer.prompt<{ selected: string }>([
329
+ {
330
+ type: 'list',
331
+ name: 'selected',
332
+ message: 'Select engine to delete:',
333
+ choices,
334
+ },
335
+ ])
336
+
337
+ const [eng, ver] = selected.split(':')
338
+ engineName = eng
339
+ engineVersion = ver
340
+ }
341
+
342
+ // Find the engine
343
+ const targetEngine = pgEngines.find(
344
+ (e) => e.engine === engineName && e.version === engineVersion,
345
+ )
346
+
347
+ if (!targetEngine) {
348
+ console.error(error(`Engine "${engineName} ${engineVersion}" not found`))
349
+ process.exit(1)
350
+ }
351
+
352
+ // Check if any containers are using this engine version
353
+ const containers = await containerManager.list()
354
+ const usingContainers = containers.filter(
355
+ (c) => c.engine === engineName && c.version === engineVersion,
356
+ )
357
+
358
+ if (usingContainers.length > 0) {
359
+ console.error(
360
+ error(
361
+ `Cannot delete: ${usingContainers.length} container(s) are using ${engineName} ${engineVersion}`,
362
+ ),
363
+ )
364
+ console.log(
365
+ chalk.gray(
366
+ ` Containers: ${usingContainers.map((c) => c.name).join(', ')}`,
367
+ ),
368
+ )
369
+ console.log()
370
+ console.log(chalk.gray(' Delete these containers first, then try again.'))
371
+ process.exit(1)
372
+ }
373
+
374
+ // Confirm deletion
375
+ if (!options.yes) {
376
+ const confirmed = await promptConfirm(
377
+ `Delete ${engineName} ${engineVersion}? This cannot be undone.`,
378
+ false,
379
+ )
380
+
381
+ if (!confirmed) {
382
+ console.log(warning('Deletion cancelled'))
383
+ return
384
+ }
385
+ }
386
+
387
+ // Delete the engine
388
+ const spinner = createSpinner(`Deleting ${engineName} ${engineVersion}...`)
389
+ spinner.start()
390
+
391
+ try {
392
+ await rm(targetEngine.path, { recursive: true, force: true })
393
+ spinner.succeed(`Deleted ${engineName} ${engineVersion}`)
394
+ } catch (err) {
395
+ const e = err as Error
396
+ spinner.fail(`Failed to delete: ${e.message}`)
397
+ process.exit(1)
398
+ }
399
+ }
400
+
401
+ // Main engines command
402
+ export const enginesCommand = new Command('engines')
403
+ .description('Manage installed database engines')
404
+ .option('--json', 'Output as JSON')
405
+ .action(async (options: { json?: boolean }) => {
406
+ try {
407
+ await listEngines(options)
408
+ } catch (err) {
409
+ const e = err as Error
410
+ console.error(error(e.message))
411
+ process.exit(1)
412
+ }
413
+ })
414
+
415
+ // Delete subcommand
416
+ enginesCommand
417
+ .command('delete [engine] [version]')
418
+ .description('Delete an installed engine version')
419
+ .option('-y, --yes', 'Skip confirmation')
420
+ .action(
421
+ async (
422
+ engine: string | undefined,
423
+ version: string | undefined,
424
+ options: { yes?: boolean },
425
+ ) => {
426
+ try {
427
+ await deleteEngine(engine, version, options)
428
+ } catch (err) {
429
+ const e = err as Error
430
+ console.error(error(e.message))
431
+ process.exit(1)
432
+ }
433
+ },
434
+ )
@@ -0,0 +1,279 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import { containerManager } from '../../core/container-manager'
4
+ import { processManager } from '../../core/process-manager'
5
+ import { paths } from '../../config/paths'
6
+ import { getEngine } from '../../engines'
7
+ import { error, info, header } from '../ui/theme'
8
+ import type { ContainerConfig } from '../../types'
9
+
10
+ /**
11
+ * Engine icons
12
+ */
13
+ const engineIcons: Record<string, string> = {
14
+ postgresql: '🐘',
15
+ mysql: '🐬',
16
+ }
17
+
18
+ /**
19
+ * Format a date for display
20
+ */
21
+ function formatDate(dateString: string): string {
22
+ const date = new Date(dateString)
23
+ return date.toLocaleString()
24
+ }
25
+
26
+ /**
27
+ * Get actual running status (not just config status)
28
+ */
29
+ async function getActualStatus(
30
+ config: ContainerConfig,
31
+ ): Promise<'running' | 'stopped'> {
32
+ const running = await processManager.isRunning(config.name, {
33
+ engine: config.engine,
34
+ })
35
+ return running ? 'running' : 'stopped'
36
+ }
37
+
38
+ /**
39
+ * Display info for a single container
40
+ */
41
+ async function displayContainerInfo(
42
+ config: ContainerConfig,
43
+ options: { json?: boolean },
44
+ ): Promise<void> {
45
+ const actualStatus = await getActualStatus(config)
46
+ const engine = getEngine(config.engine)
47
+ const connectionString = engine.getConnectionString(config)
48
+ const dataDir = paths.getContainerDataPath(config.name, {
49
+ engine: config.engine,
50
+ })
51
+
52
+ if (options.json) {
53
+ console.log(
54
+ JSON.stringify(
55
+ {
56
+ ...config,
57
+ status: actualStatus,
58
+ connectionString,
59
+ dataDir,
60
+ },
61
+ null,
62
+ 2,
63
+ ),
64
+ )
65
+ return
66
+ }
67
+
68
+ const icon = engineIcons[config.engine] || '▣'
69
+ const statusDisplay =
70
+ actualStatus === 'running'
71
+ ? chalk.green('● running')
72
+ : chalk.gray('○ stopped')
73
+
74
+ console.log()
75
+ console.log(header(`Container: ${config.name}`))
76
+ console.log()
77
+ console.log(
78
+ chalk.gray(' ') +
79
+ chalk.white('Engine:'.padEnd(14)) +
80
+ chalk.cyan(`${icon} ${config.engine} ${config.version}`),
81
+ )
82
+ console.log(
83
+ chalk.gray(' ') + chalk.white('Status:'.padEnd(14)) + statusDisplay,
84
+ )
85
+ console.log(
86
+ chalk.gray(' ') +
87
+ chalk.white('Port:'.padEnd(14)) +
88
+ chalk.green(String(config.port)),
89
+ )
90
+ console.log(
91
+ chalk.gray(' ') +
92
+ chalk.white('Database:'.padEnd(14)) +
93
+ chalk.yellow(config.database),
94
+ )
95
+ console.log(
96
+ chalk.gray(' ') +
97
+ chalk.white('Created:'.padEnd(14)) +
98
+ chalk.gray(formatDate(config.created)),
99
+ )
100
+ console.log(
101
+ chalk.gray(' ') +
102
+ chalk.white('Data Dir:'.padEnd(14)) +
103
+ chalk.gray(dataDir),
104
+ )
105
+ if (config.clonedFrom) {
106
+ console.log(
107
+ chalk.gray(' ') +
108
+ chalk.white('Cloned From:'.padEnd(14)) +
109
+ chalk.gray(config.clonedFrom),
110
+ )
111
+ }
112
+ console.log()
113
+ console.log(chalk.gray(' ') + chalk.white('Connection String:'))
114
+ console.log(chalk.gray(' ') + chalk.cyan(connectionString))
115
+ console.log()
116
+ }
117
+
118
+ /**
119
+ * Display summary info for all containers
120
+ */
121
+ async function displayAllContainersInfo(
122
+ containers: ContainerConfig[],
123
+ options: { json?: boolean },
124
+ ): Promise<void> {
125
+ if (options.json) {
126
+ // Get actual status for all containers
127
+ const containersWithStatus = await Promise.all(
128
+ containers.map(async (config) => {
129
+ const actualStatus = await getActualStatus(config)
130
+ const engine = getEngine(config.engine)
131
+ const connectionString = engine.getConnectionString(config)
132
+ const dataDir = paths.getContainerDataPath(config.name, {
133
+ engine: config.engine,
134
+ })
135
+ return {
136
+ ...config,
137
+ status: actualStatus,
138
+ connectionString,
139
+ dataDir,
140
+ }
141
+ }),
142
+ )
143
+ console.log(JSON.stringify(containersWithStatus, null, 2))
144
+ return
145
+ }
146
+
147
+ console.log()
148
+ console.log(header('All Containers'))
149
+ console.log()
150
+
151
+ // Table header
152
+ console.log(
153
+ chalk.gray(' ') +
154
+ chalk.bold.white('NAME'.padEnd(18)) +
155
+ chalk.bold.white('ENGINE'.padEnd(14)) +
156
+ chalk.bold.white('VERSION'.padEnd(10)) +
157
+ chalk.bold.white('PORT'.padEnd(8)) +
158
+ chalk.bold.white('DATABASE'.padEnd(16)) +
159
+ chalk.bold.white('STATUS'),
160
+ )
161
+ console.log(chalk.gray(' ' + '─'.repeat(78)))
162
+
163
+ // Table rows
164
+ for (const container of containers) {
165
+ const actualStatus = await getActualStatus(container)
166
+ const statusDisplay =
167
+ actualStatus === 'running'
168
+ ? chalk.green('● running')
169
+ : chalk.gray('○ stopped')
170
+
171
+ const icon = engineIcons[container.engine] || '▣'
172
+ const engineDisplay = `${icon} ${container.engine}`
173
+
174
+ console.log(
175
+ chalk.gray(' ') +
176
+ chalk.cyan(container.name.padEnd(18)) +
177
+ chalk.white(engineDisplay.padEnd(13)) +
178
+ chalk.yellow(container.version.padEnd(10)) +
179
+ chalk.green(String(container.port).padEnd(8)) +
180
+ chalk.gray(container.database.padEnd(16)) +
181
+ statusDisplay,
182
+ )
183
+ }
184
+
185
+ console.log()
186
+
187
+ // Summary
188
+ const statusChecks = await Promise.all(
189
+ containers.map((c) => getActualStatus(c)),
190
+ )
191
+ const running = statusChecks.filter((s) => s === 'running').length
192
+ const stopped = statusChecks.filter((s) => s === 'stopped').length
193
+
194
+ console.log(
195
+ chalk.gray(
196
+ ` ${containers.length} container(s): ${running} running, ${stopped} stopped`,
197
+ ),
198
+ )
199
+ console.log()
200
+
201
+ // Connection strings
202
+ console.log(chalk.bold.white(' Connection Strings:'))
203
+ console.log(chalk.gray(' ' + '─'.repeat(78)))
204
+ for (const container of containers) {
205
+ const engine = getEngine(container.engine)
206
+ const connectionString = engine.getConnectionString(container)
207
+ console.log(
208
+ chalk.gray(' ') +
209
+ chalk.cyan(container.name.padEnd(18)) +
210
+ chalk.gray(connectionString),
211
+ )
212
+ }
213
+ console.log()
214
+ }
215
+
216
+ export const infoCommand = new Command('info')
217
+ .description('Show container details')
218
+ .argument('[name]', 'Container name (omit to show all)')
219
+ .option('--json', 'Output as JSON')
220
+ .action(async (name: string | undefined, options: { json?: boolean }) => {
221
+ try {
222
+ const containers = await containerManager.list()
223
+
224
+ if (containers.length === 0) {
225
+ console.log(info('No containers found. Create one with: spindb create'))
226
+ return
227
+ }
228
+
229
+ // If name provided, show single container
230
+ if (name) {
231
+ const config = await containerManager.getConfig(name)
232
+ if (!config) {
233
+ console.error(error(`Container "${name}" not found`))
234
+ process.exit(1)
235
+ }
236
+ await displayContainerInfo(config, options)
237
+ return
238
+ }
239
+
240
+ // If running interactively without name, ask if they want all or specific
241
+ if (!options.json && process.stdout.isTTY && containers.length > 1) {
242
+ const { choice } = await (
243
+ await import('inquirer')
244
+ ).default.prompt<{
245
+ choice: string
246
+ }>([
247
+ {
248
+ type: 'list',
249
+ name: 'choice',
250
+ message: 'Show info for:',
251
+ choices: [
252
+ { name: 'All containers', value: 'all' },
253
+ ...containers.map((c) => ({
254
+ name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || '▣'} ${c.engine})`)}`,
255
+ value: c.name,
256
+ })),
257
+ ],
258
+ },
259
+ ])
260
+
261
+ if (choice === 'all') {
262
+ await displayAllContainersInfo(containers, options)
263
+ } else {
264
+ const config = await containerManager.getConfig(choice)
265
+ if (config) {
266
+ await displayContainerInfo(config, options)
267
+ }
268
+ }
269
+ return
270
+ }
271
+
272
+ // Non-interactive or only one container: show all
273
+ await displayAllContainersInfo(containers, options)
274
+ } catch (err) {
275
+ const e = err as Error
276
+ console.error(error(e.message))
277
+ process.exit(1)
278
+ }
279
+ })
@@ -48,7 +48,7 @@ export const listCommand = new Command('list')
48
48
  ? chalk.green('● running')
49
49
  : chalk.gray('○ stopped')
50
50
 
51
- const engineIcon = engineIcons[container.engine] || '🗄️'
51
+ const engineIcon = engineIcons[container.engine] || ''
52
52
  const engineDisplay = `${engineIcon} ${container.engine}`
53
53
 
54
54
  console.log(