spindb 0.7.0 → 0.7.5

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 (40) hide show
  1. package/README.md +421 -294
  2. package/cli/commands/backup.ts +1 -30
  3. package/cli/commands/clone.ts +0 -6
  4. package/cli/commands/config.ts +7 -1
  5. package/cli/commands/connect.ts +1 -16
  6. package/cli/commands/create.ts +4 -55
  7. package/cli/commands/delete.ts +0 -6
  8. package/cli/commands/edit.ts +9 -25
  9. package/cli/commands/engines.ts +10 -188
  10. package/cli/commands/info.ts +7 -34
  11. package/cli/commands/list.ts +2 -18
  12. package/cli/commands/logs.ts +118 -0
  13. package/cli/commands/menu/backup-handlers.ts +749 -0
  14. package/cli/commands/menu/container-handlers.ts +825 -0
  15. package/cli/commands/menu/engine-handlers.ts +362 -0
  16. package/cli/commands/menu/index.ts +179 -0
  17. package/cli/commands/menu/shared.ts +26 -0
  18. package/cli/commands/menu/shell-handlers.ts +320 -0
  19. package/cli/commands/menu/sql-handlers.ts +194 -0
  20. package/cli/commands/menu/update-handlers.ts +94 -0
  21. package/cli/commands/restore.ts +2 -28
  22. package/cli/commands/run.ts +139 -0
  23. package/cli/commands/start.ts +2 -10
  24. package/cli/commands/stop.ts +0 -5
  25. package/cli/commands/url.ts +18 -13
  26. package/cli/constants.ts +10 -0
  27. package/cli/helpers.ts +152 -0
  28. package/cli/index.ts +5 -2
  29. package/cli/ui/prompts.ts +3 -11
  30. package/core/dependency-manager.ts +0 -163
  31. package/core/error-handler.ts +0 -26
  32. package/core/platform-service.ts +60 -40
  33. package/core/start-with-retry.ts +3 -28
  34. package/core/transaction-manager.ts +0 -8
  35. package/engines/base-engine.ts +10 -0
  36. package/engines/mysql/binary-detection.ts +1 -1
  37. package/engines/mysql/index.ts +78 -2
  38. package/engines/postgresql/index.ts +49 -0
  39. package/package.json +1 -1
  40. package/cli/commands/menu.ts +0 -2670
@@ -8,16 +8,10 @@ import { promptContainerSelect } from '../ui/prompts'
8
8
  import { createSpinner } from '../ui/spinner'
9
9
  import { error, warning, success } from '../ui/theme'
10
10
 
11
- /**
12
- * Validate container name format
13
- */
14
11
  function isValidName(name: string): boolean {
15
12
  return /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)
16
13
  }
17
14
 
18
- /**
19
- * Prompt for what to edit when no options provided
20
- */
21
15
  async function promptEditAction(): Promise<'name' | 'port' | null> {
22
16
  const { action } = await inquirer.prompt<{ action: string }>([
23
17
  {
@@ -36,9 +30,6 @@ async function promptEditAction(): Promise<'name' | 'port' | null> {
36
30
  return action as 'name' | 'port'
37
31
  }
38
32
 
39
- /**
40
- * Prompt for new container name
41
- */
42
33
  async function promptNewName(currentName: string): Promise<string | null> {
43
34
  const { newName } = await inquirer.prompt<{ newName: string }>([
44
35
  {
@@ -64,9 +55,6 @@ async function promptNewName(currentName: string): Promise<string | null> {
64
55
  return newName
65
56
  }
66
57
 
67
- /**
68
- * Prompt for new port
69
- */
70
58
  async function promptNewPort(currentPort: number): Promise<number | null> {
71
59
  const { newPort } = await inquirer.prompt<{ newPort: number }>([
72
60
  {
@@ -90,6 +78,15 @@ async function promptNewPort(currentPort: number): Promise<number | null> {
90
78
  return null
91
79
  }
92
80
 
81
+ const portAvailable = await portManager.isPortAvailable(newPort)
82
+ if (!portAvailable) {
83
+ console.log(
84
+ warning(
85
+ `Note: Port ${newPort} is currently in use. It will be used when the container starts.`,
86
+ ),
87
+ )
88
+ }
89
+
93
90
  return newPort
94
91
  }
95
92
 
@@ -106,7 +103,6 @@ export const editCommand = new Command('edit')
106
103
  try {
107
104
  let containerName = name
108
105
 
109
- // Interactive selection if no name provided
110
106
  if (!containerName) {
111
107
  const containers = await containerManager.list()
112
108
 
@@ -123,14 +119,12 @@ export const editCommand = new Command('edit')
123
119
  containerName = selected
124
120
  }
125
121
 
126
- // Get container config
127
122
  const config = await containerManager.getConfig(containerName)
128
123
  if (!config) {
129
124
  console.error(error(`Container "${containerName}" not found`))
130
125
  process.exit(1)
131
126
  }
132
127
 
133
- // If no options provided, prompt for what to edit
134
128
  if (options.name === undefined && options.port === undefined) {
135
129
  const action = await promptEditAction()
136
130
  if (!action) return
@@ -152,9 +146,7 @@ export const editCommand = new Command('edit')
152
146
  }
153
147
  }
154
148
 
155
- // Handle rename
156
149
  if (options.name) {
157
- // Validate new name
158
150
  if (!isValidName(options.name)) {
159
151
  console.error(
160
152
  error(
@@ -164,7 +156,6 @@ export const editCommand = new Command('edit')
164
156
  process.exit(1)
165
157
  }
166
158
 
167
- // Check if new name already exists
168
159
  const exists = await containerManager.exists(options.name, {
169
160
  engine: config.engine,
170
161
  })
@@ -173,7 +164,6 @@ export const editCommand = new Command('edit')
173
164
  process.exit(1)
174
165
  }
175
166
 
176
- // Check if container is running
177
167
  const running = await processManager.isRunning(containerName, {
178
168
  engine: config.engine,
179
169
  })
@@ -186,7 +176,6 @@ export const editCommand = new Command('edit')
186
176
  process.exit(1)
187
177
  }
188
178
 
189
- // Rename the container
190
179
  const spinner = createSpinner(
191
180
  `Renaming "${containerName}" to "${options.name}"...`,
192
181
  )
@@ -196,19 +185,15 @@ export const editCommand = new Command('edit')
196
185
 
197
186
  spinner.succeed(`Renamed "${containerName}" to "${options.name}"`)
198
187
 
199
- // Update containerName for subsequent operations
200
188
  containerName = options.name
201
189
  }
202
190
 
203
- // Handle port change
204
191
  if (options.port !== undefined) {
205
- // Validate port
206
192
  if (options.port < 1 || options.port > 65535) {
207
193
  console.error(error('Port must be between 1 and 65535'))
208
194
  process.exit(1)
209
195
  }
210
196
 
211
- // Check port availability (warning only)
212
197
  const portAvailable = await portManager.isPortAvailable(options.port)
213
198
  if (!portAvailable) {
214
199
  console.log(
@@ -218,7 +203,6 @@ export const editCommand = new Command('edit')
218
203
  )
219
204
  }
220
205
 
221
- // Update the config
222
206
  const spinner = createSpinner(`Changing port to ${options.port}...`)
223
207
  spinner.start()
224
208
 
@@ -1,197 +1,19 @@
1
1
  import { Command } from 'commander'
2
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'
3
+ import { rm } from 'fs/promises'
8
4
  import inquirer from 'inquirer'
9
- import { paths } from '../../config/paths'
10
5
  import { containerManager } from '../../core/container-manager'
11
6
  import { promptConfirm } from '../ui/prompts'
12
7
  import { createSpinner } from '../ui/spinner'
13
8
  import { error, warning, info, formatBytes } from '../ui/theme'
9
+ import { getEngineIcon, ENGINE_ICONS } from '../constants'
14
10
  import {
15
- getMysqldPath,
16
- getMysqlVersion,
17
- isMariaDB,
18
- } from '../../engines/mysql/binary-detection'
11
+ getInstalledEngines,
12
+ getInstalledPostgresEngines,
13
+ type InstalledPostgresEngine,
14
+ type InstalledMysqlEngine,
15
+ } from '../helpers'
19
16
 
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
- /**
189
- * Engine icons
190
- */
191
- const engineIcons: Record<string, string> = {
192
- postgresql: '🐘',
193
- mysql: '🐬',
194
- }
195
17
 
196
18
  /**
197
19
  * List subcommand action
@@ -243,7 +65,7 @@ async function listEngines(options: { json?: boolean }): Promise<void> {
243
65
 
244
66
  // PostgreSQL rows
245
67
  for (const engine of pgEngines) {
246
- const icon = engineIcons[engine.engine] || '▣'
68
+ const icon = getEngineIcon(engine.engine)
247
69
  const platformInfo = `${engine.platform}-${engine.arch}`
248
70
 
249
71
  console.log(
@@ -257,7 +79,7 @@ async function listEngines(options: { json?: boolean }): Promise<void> {
257
79
 
258
80
  // MySQL row
259
81
  if (mysqlEngine) {
260
- const icon = engineIcons.mysql
82
+ const icon = ENGINE_ICONS.mysql
261
83
  const displayName = mysqlEngine.isMariaDB ? 'mariadb' : 'mysql'
262
84
 
263
85
  console.log(
@@ -313,7 +135,7 @@ async function deleteEngine(
313
135
  // Interactive selection if not provided
314
136
  if (!engineName || !engineVersion) {
315
137
  const choices = pgEngines.map((e) => ({
316
- name: `${engineIcons[e.engine]} ${e.engine} ${e.version} ${chalk.gray(`(${formatBytes(e.sizeBytes)})`)}`,
138
+ name: `${getEngineIcon(e.engine)} ${e.engine} ${e.version} ${chalk.gray(`(${formatBytes(e.sizeBytes)})`)}`,
317
139
  value: `${e.engine}:${e.version}:${e.path}`,
318
140
  }))
319
141
 
@@ -1,31 +1,19 @@
1
1
  import { Command } from 'commander'
2
2
  import chalk from 'chalk'
3
+ import inquirer from 'inquirer'
3
4
  import { containerManager } from '../../core/container-manager'
4
5
  import { processManager } from '../../core/process-manager'
5
6
  import { paths } from '../../config/paths'
6
7
  import { getEngine } from '../../engines'
7
8
  import { error, info, header } from '../ui/theme'
9
+ import { getEngineIcon } from '../constants'
8
10
  import type { ContainerConfig } from '../../types'
9
11
 
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
12
  function formatDate(dateString: string): string {
22
13
  const date = new Date(dateString)
23
14
  return date.toLocaleString()
24
15
  }
25
16
 
26
- /**
27
- * Get actual running status (not just config status)
28
- */
29
17
  async function getActualStatus(
30
18
  config: ContainerConfig,
31
19
  ): Promise<'running' | 'stopped'> {
@@ -35,9 +23,6 @@ async function getActualStatus(
35
23
  return running ? 'running' : 'stopped'
36
24
  }
37
25
 
38
- /**
39
- * Display info for a single container
40
- */
41
26
  async function displayContainerInfo(
42
27
  config: ContainerConfig,
43
28
  options: { json?: boolean },
@@ -65,7 +50,7 @@ async function displayContainerInfo(
65
50
  return
66
51
  }
67
52
 
68
- const icon = engineIcons[config.engine] || '▣'
53
+ const icon = getEngineIcon(config.engine)
69
54
  const statusDisplay =
70
55
  actualStatus === 'running'
71
56
  ? chalk.green('● running')
@@ -115,15 +100,11 @@ async function displayContainerInfo(
115
100
  console.log()
116
101
  }
117
102
 
118
- /**
119
- * Display summary info for all containers
120
- */
121
103
  async function displayAllContainersInfo(
122
104
  containers: ContainerConfig[],
123
105
  options: { json?: boolean },
124
106
  ): Promise<void> {
125
107
  if (options.json) {
126
- // Get actual status for all containers
127
108
  const containersWithStatus = await Promise.all(
128
109
  containers.map(async (config) => {
129
110
  const actualStatus = await getActualStatus(config)
@@ -148,7 +129,6 @@ async function displayAllContainersInfo(
148
129
  console.log(header('All Containers'))
149
130
  console.log()
150
131
 
151
- // Table header
152
132
  console.log(
153
133
  chalk.gray(' ') +
154
134
  chalk.bold.white('NAME'.padEnd(18)) +
@@ -160,7 +140,6 @@ async function displayAllContainersInfo(
160
140
  )
161
141
  console.log(chalk.gray(' ' + '─'.repeat(78)))
162
142
 
163
- // Table rows
164
143
  for (const container of containers) {
165
144
  const actualStatus = await getActualStatus(container)
166
145
  const statusDisplay =
@@ -168,7 +147,7 @@ async function displayAllContainersInfo(
168
147
  ? chalk.green('● running')
169
148
  : chalk.gray('○ stopped')
170
149
 
171
- const icon = engineIcons[container.engine] || '▣'
150
+ const icon = getEngineIcon(container.engine)
172
151
  const engineDisplay = `${icon} ${container.engine}`
173
152
 
174
153
  console.log(
@@ -184,7 +163,6 @@ async function displayAllContainersInfo(
184
163
 
185
164
  console.log()
186
165
 
187
- // Summary
188
166
  const statusChecks = await Promise.all(
189
167
  containers.map((c) => getActualStatus(c)),
190
168
  )
@@ -198,7 +176,6 @@ async function displayAllContainersInfo(
198
176
  )
199
177
  console.log()
200
178
 
201
- // Connection strings
202
179
  console.log(chalk.bold.white(' Connection Strings:'))
203
180
  console.log(chalk.gray(' ' + '─'.repeat(78)))
204
181
  for (const container of containers) {
@@ -214,6 +191,7 @@ async function displayAllContainersInfo(
214
191
  }
215
192
 
216
193
  export const infoCommand = new Command('info')
194
+ .alias('status')
217
195
  .description('Show container details')
218
196
  .argument('[name]', 'Container name (omit to show all)')
219
197
  .option('--json', 'Output as JSON')
@@ -226,7 +204,6 @@ export const infoCommand = new Command('info')
226
204
  return
227
205
  }
228
206
 
229
- // If name provided, show single container
230
207
  if (name) {
231
208
  const config = await containerManager.getConfig(name)
232
209
  if (!config) {
@@ -237,11 +214,8 @@ export const infoCommand = new Command('info')
237
214
  return
238
215
  }
239
216
 
240
- // If running interactively without name, ask if they want all or specific
241
217
  if (!options.json && process.stdout.isTTY && containers.length > 1) {
242
- const { choice } = await (
243
- await import('inquirer')
244
- ).default.prompt<{
218
+ const { choice } = await inquirer.prompt<{
245
219
  choice: string
246
220
  }>([
247
221
  {
@@ -251,7 +225,7 @@ export const infoCommand = new Command('info')
251
225
  choices: [
252
226
  { name: 'All containers', value: 'all' },
253
227
  ...containers.map((c) => ({
254
- name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || '▣'} ${c.engine})`)}`,
228
+ name: `${c.name} ${chalk.gray(`(${getEngineIcon(c.engine)} ${c.engine})`)}`,
255
229
  value: c.name,
256
230
  })),
257
231
  ],
@@ -269,7 +243,6 @@ export const infoCommand = new Command('info')
269
243
  return
270
244
  }
271
245
 
272
- // Non-interactive or only one container: show all
273
246
  await displayAllContainersInfo(containers, options)
274
247
  } catch (err) {
275
248
  const e = err as Error
@@ -3,19 +3,9 @@ import chalk from 'chalk'
3
3
  import { containerManager } from '../../core/container-manager'
4
4
  import { getEngine } from '../../engines'
5
5
  import { info, error, formatBytes } from '../ui/theme'
6
+ import { getEngineIcon } from '../constants'
6
7
  import type { ContainerConfig } from '../../types'
7
8
 
8
- /**
9
- * Engine icons for display
10
- */
11
- const engineIcons: Record<string, string> = {
12
- postgresql: '🐘',
13
- mysql: '🐬',
14
- }
15
-
16
- /**
17
- * Get database size for a container (only if running)
18
- */
19
9
  async function getContainerSize(
20
10
  container: ContainerConfig,
21
11
  ): Promise<number | null> {
@@ -39,7 +29,6 @@ export const listCommand = new Command('list')
39
29
  const containers = await containerManager.list()
40
30
 
41
31
  if (options.json) {
42
- // Include sizes in JSON output
43
32
  const containersWithSize = await Promise.all(
44
33
  containers.map(async (container) => ({
45
34
  ...container,
@@ -55,10 +44,8 @@ export const listCommand = new Command('list')
55
44
  return
56
45
  }
57
46
 
58
- // Fetch sizes for running containers in parallel
59
47
  const sizes = await Promise.all(containers.map(getContainerSize))
60
48
 
61
- // Table header
62
49
  console.log()
63
50
  console.log(
64
51
  chalk.gray(' ') +
@@ -71,7 +58,6 @@ export const listCommand = new Command('list')
71
58
  )
72
59
  console.log(chalk.gray(' ' + '─'.repeat(73)))
73
60
 
74
- // Table rows
75
61
  for (let i = 0; i < containers.length; i++) {
76
62
  const container = containers[i]
77
63
  const size = sizes[i]
@@ -81,10 +67,9 @@ export const listCommand = new Command('list')
81
67
  ? chalk.green('● running')
82
68
  : chalk.gray('○ stopped')
83
69
 
84
- const engineIcon = engineIcons[container.engine] || '▣'
70
+ const engineIcon = getEngineIcon(container.engine)
85
71
  const engineDisplay = `${engineIcon} ${container.engine}`
86
72
 
87
- // Format size: show value if running, dash if stopped
88
73
  const sizeDisplay = size !== null ? formatBytes(size) : chalk.gray('—')
89
74
 
90
75
  console.log(
@@ -100,7 +85,6 @@ export const listCommand = new Command('list')
100
85
 
101
86
  console.log()
102
87
 
103
- // Summary
104
88
  const running = containers.filter((c) => c.status === 'running').length
105
89
  const stopped = containers.filter((c) => c.status !== 'running').length
106
90
  console.log(
@@ -0,0 +1,118 @@
1
+ import { Command } from 'commander'
2
+ import { spawn } from 'child_process'
3
+ import { existsSync } from 'fs'
4
+ import { readFile } from 'fs/promises'
5
+ import { containerManager } from '../../core/container-manager'
6
+ import { paths } from '../../config/paths'
7
+ import { promptContainerSelect } from '../ui/prompts'
8
+ import { error, warning, info } from '../ui/theme'
9
+
10
+ function getLastNLines(content: string, n: number): string {
11
+ const lines = content.split('\n')
12
+ const nonEmptyLines =
13
+ lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines
14
+ return nonEmptyLines.slice(-n).join('\n')
15
+ }
16
+
17
+ export const logsCommand = new Command('logs')
18
+ .description('View container logs')
19
+ .argument('[name]', 'Container name')
20
+ .option('-f, --follow', 'Follow log output (like tail -f)')
21
+ .option('-n, --lines <number>', 'Number of lines to show', '50')
22
+ .option('--editor', 'Open logs in $EDITOR')
23
+ .action(
24
+ async (
25
+ name: string | undefined,
26
+ options: { follow?: boolean; lines?: string; editor?: boolean },
27
+ ) => {
28
+ try {
29
+ let containerName = name
30
+
31
+ if (!containerName) {
32
+ const containers = await containerManager.list()
33
+
34
+ if (containers.length === 0) {
35
+ console.log(warning('No containers found'))
36
+ return
37
+ }
38
+
39
+ const selected = await promptContainerSelect(
40
+ containers,
41
+ 'Select container:',
42
+ )
43
+ if (!selected) return
44
+ containerName = selected
45
+ }
46
+
47
+ const config = await containerManager.getConfig(containerName)
48
+ if (!config) {
49
+ console.error(error(`Container "${containerName}" not found`))
50
+ process.exit(1)
51
+ }
52
+
53
+ const logPath = paths.getContainerLogPath(config.name, {
54
+ engine: config.engine,
55
+ })
56
+
57
+ if (!existsSync(logPath)) {
58
+ console.log(
59
+ info(
60
+ `No log file found for "${containerName}". The container may not have been started yet.`,
61
+ ),
62
+ )
63
+ return
64
+ }
65
+
66
+ if (options.editor) {
67
+ const editorCmd = process.env.EDITOR || 'vi'
68
+ const child = spawn(editorCmd, [logPath], {
69
+ stdio: 'inherit',
70
+ })
71
+
72
+ await new Promise<void>((resolve, reject) => {
73
+ child.on('close', (code) => {
74
+ if (code === 0) {
75
+ resolve()
76
+ } else {
77
+ reject(new Error(`Editor exited with code ${code}`))
78
+ }
79
+ })
80
+ child.on('error', reject)
81
+ })
82
+ return
83
+ }
84
+
85
+ if (options.follow) {
86
+ const lineCount = parseInt(options.lines || '50', 10)
87
+ const child = spawn('tail', ['-n', String(lineCount), '-f', logPath], {
88
+ stdio: 'inherit',
89
+ })
90
+
91
+ process.on('SIGINT', () => {
92
+ child.kill('SIGTERM')
93
+ process.exit(0)
94
+ })
95
+
96
+ await new Promise<void>((resolve) => {
97
+ child.on('close', () => resolve())
98
+ })
99
+ return
100
+ }
101
+
102
+ const lineCount = parseInt(options.lines || '50', 10)
103
+ const content = await readFile(logPath, 'utf-8')
104
+
105
+ if (content.trim() === '') {
106
+ console.log(info('Log file is empty'))
107
+ return
108
+ }
109
+
110
+ const output = getLastNLines(content, lineCount)
111
+ console.log(output)
112
+ } catch (err) {
113
+ const e = err as Error
114
+ console.error(error(e.message))
115
+ process.exit(1)
116
+ }
117
+ },
118
+ )