spindb 0.6.0 → 0.7.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.
Files changed (38) hide show
  1. package/README.md +421 -294
  2. package/cli/commands/config.ts +7 -1
  3. package/cli/commands/connect.ts +1 -0
  4. package/cli/commands/create.ts +7 -7
  5. package/cli/commands/edit.ts +10 -0
  6. package/cli/commands/engines.ts +10 -188
  7. package/cli/commands/info.ts +7 -14
  8. package/cli/commands/list.ts +2 -9
  9. package/cli/commands/logs.ts +130 -0
  10. package/cli/commands/menu/backup-handlers.ts +798 -0
  11. package/cli/commands/menu/container-handlers.ts +832 -0
  12. package/cli/commands/menu/engine-handlers.ts +382 -0
  13. package/cli/commands/menu/index.ts +184 -0
  14. package/cli/commands/menu/shared.ts +26 -0
  15. package/cli/commands/menu/shell-handlers.ts +331 -0
  16. package/cli/commands/menu/sql-handlers.ts +197 -0
  17. package/cli/commands/menu/update-handlers.ts +94 -0
  18. package/cli/commands/run.ts +150 -0
  19. package/cli/commands/url.ts +19 -5
  20. package/cli/constants.ts +10 -0
  21. package/cli/helpers.ts +152 -0
  22. package/cli/index.ts +5 -2
  23. package/cli/ui/prompts.ts +3 -11
  24. package/config/defaults.ts +5 -29
  25. package/core/binary-manager.ts +2 -2
  26. package/core/container-manager.ts +3 -2
  27. package/core/dependency-manager.ts +0 -163
  28. package/core/error-handler.ts +0 -26
  29. package/core/platform-service.ts +60 -40
  30. package/core/start-with-retry.ts +3 -28
  31. package/core/transaction-manager.ts +0 -8
  32. package/engines/base-engine.ts +10 -0
  33. package/engines/mysql/binary-detection.ts +1 -1
  34. package/engines/mysql/index.ts +78 -2
  35. package/engines/postgresql/index.ts +49 -0
  36. package/package.json +1 -1
  37. package/types/index.ts +7 -4
  38. package/cli/commands/menu.ts +0 -2670
@@ -45,10 +45,16 @@ export const configCommand = new Command('config')
45
45
  .addCommand(
46
46
  new Command('show')
47
47
  .description('Show current configuration')
48
- .action(async () => {
48
+ .option('--json', 'Output as JSON')
49
+ .action(async (options: { json?: boolean }) => {
49
50
  try {
50
51
  const config = await configManager.getConfig()
51
52
 
53
+ if (options.json) {
54
+ console.log(JSON.stringify(config, null, 2))
55
+ return
56
+ }
57
+
52
58
  console.log()
53
59
  console.log(header('SpinDB Configuration'))
54
60
  console.log()
@@ -21,6 +21,7 @@ import { promptContainerSelect } from '../ui/prompts'
21
21
  import { error, warning, info, success } from '../ui/theme'
22
22
 
23
23
  export const connectCommand = new Command('connect')
24
+ .alias('shell')
24
25
  .description('Connect to a container with database client')
25
26
  .argument('[name]', 'Container name')
26
27
  .option('-d, --database <name>', 'Database name')
@@ -20,7 +20,7 @@ import { getMissingDependencies } from '../../core/dependency-manager'
20
20
  import { platformService } from '../../core/platform-service'
21
21
  import { startWithRetry } from '../../core/start-with-retry'
22
22
  import { TransactionManager } from '../../core/transaction-manager'
23
- import type { EngineName } from '../../types'
23
+ import { Engine } from '../../types'
24
24
 
25
25
  /**
26
26
  * Detect if a location string is a connection string or a file path
@@ -28,19 +28,19 @@ import type { EngineName } from '../../types'
28
28
  */
29
29
  function detectLocationType(location: string): {
30
30
  type: 'connection' | 'file' | 'not_found'
31
- inferredEngine?: EngineName
31
+ inferredEngine?: Engine
32
32
  } {
33
33
  // Check for PostgreSQL connection string
34
34
  if (
35
35
  location.startsWith('postgresql://') ||
36
36
  location.startsWith('postgres://')
37
37
  ) {
38
- return { type: 'connection', inferredEngine: 'postgresql' }
38
+ return { type: 'connection', inferredEngine: Engine.PostgreSQL }
39
39
  }
40
40
 
41
41
  // Check for MySQL connection string
42
42
  if (location.startsWith('mysql://')) {
43
- return { type: 'connection', inferredEngine: 'mysql' }
43
+ return { type: 'connection', inferredEngine: Engine.MySQL }
44
44
  }
45
45
 
46
46
  // Check if file exists
@@ -79,7 +79,7 @@ export const createCommand = new Command('create')
79
79
 
80
80
  try {
81
81
  let containerName = name
82
- let engine: EngineName = (options.engine as EngineName) || 'postgresql'
82
+ let engine: Engine = (options.engine as Engine) || Engine.PostgreSQL
83
83
  let version = options.version
84
84
  let database = options.database
85
85
 
@@ -136,7 +136,7 @@ export const createCommand = new Command('create')
136
136
  if (!containerName) {
137
137
  const answers = await promptCreateOptions()
138
138
  containerName = answers.name
139
- engine = answers.engine as EngineName
139
+ engine = answers.engine as Engine
140
140
  version = answers.version
141
141
  database = answers.database
142
142
  }
@@ -254,7 +254,7 @@ export const createCommand = new Command('create')
254
254
 
255
255
  try {
256
256
  await containerManager.create(containerName, {
257
- engine: dbEngine.name as EngineName,
257
+ engine: dbEngine.name as Engine,
258
258
  version,
259
259
  port,
260
260
  database,
@@ -90,6 +90,16 @@ async function promptNewPort(currentPort: number): Promise<number | null> {
90
90
  return null
91
91
  }
92
92
 
93
+ // Double-check availability and warn (user already confirmed via validation)
94
+ const portAvailable = await portManager.isPortAvailable(newPort)
95
+ if (!portAvailable) {
96
+ console.log(
97
+ warning(
98
+ `Note: Port ${newPort} is currently in use. It will be used when the container starts.`,
99
+ ),
100
+ )
101
+ }
102
+
93
103
  return newPort
94
104
  }
95
105
 
@@ -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,20 +1,14 @@
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
12
  /**
19
13
  * Format a date for display
20
14
  */
@@ -65,7 +59,7 @@ async function displayContainerInfo(
65
59
  return
66
60
  }
67
61
 
68
- const icon = engineIcons[config.engine] || '▣'
62
+ const icon = getEngineIcon(config.engine)
69
63
  const statusDisplay =
70
64
  actualStatus === 'running'
71
65
  ? chalk.green('● running')
@@ -168,7 +162,7 @@ async function displayAllContainersInfo(
168
162
  ? chalk.green('● running')
169
163
  : chalk.gray('○ stopped')
170
164
 
171
- const icon = engineIcons[container.engine] || '▣'
165
+ const icon = getEngineIcon(container.engine)
172
166
  const engineDisplay = `${icon} ${container.engine}`
173
167
 
174
168
  console.log(
@@ -214,6 +208,7 @@ async function displayAllContainersInfo(
214
208
  }
215
209
 
216
210
  export const infoCommand = new Command('info')
211
+ .alias('status')
217
212
  .description('Show container details')
218
213
  .argument('[name]', 'Container name (omit to show all)')
219
214
  .option('--json', 'Output as JSON')
@@ -239,9 +234,7 @@ export const infoCommand = new Command('info')
239
234
 
240
235
  // If running interactively without name, ask if they want all or specific
241
236
  if (!options.json && process.stdout.isTTY && containers.length > 1) {
242
- const { choice } = await (
243
- await import('inquirer')
244
- ).default.prompt<{
237
+ const { choice } = await inquirer.prompt<{
245
238
  choice: string
246
239
  }>([
247
240
  {
@@ -251,7 +244,7 @@ export const infoCommand = new Command('info')
251
244
  choices: [
252
245
  { name: 'All containers', value: 'all' },
253
246
  ...containers.map((c) => ({
254
- name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || '▣'} ${c.engine})`)}`,
247
+ name: `${c.name} ${chalk.gray(`(${getEngineIcon(c.engine)} ${c.engine})`)}`,
255
248
  value: c.name,
256
249
  })),
257
250
  ],
@@ -3,16 +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
9
  /**
17
10
  * Get database size for a container (only if running)
18
11
  */
@@ -81,7 +74,7 @@ export const listCommand = new Command('list')
81
74
  ? chalk.green('● running')
82
75
  : chalk.gray('○ stopped')
83
76
 
84
- const engineIcon = engineIcons[container.engine] || '▣'
77
+ const engineIcon = getEngineIcon(container.engine)
85
78
  const engineDisplay = `${engineIcon} ${container.engine}`
86
79
 
87
80
  // Format size: show value if running, dash if stopped
@@ -0,0 +1,130 @@
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
+ /**
11
+ * Get the last N lines from a file content
12
+ */
13
+ function getLastNLines(content: string, n: number): string {
14
+ const lines = content.split('\n')
15
+ // Filter empty trailing line if present
16
+ const nonEmptyLines =
17
+ lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines
18
+ return nonEmptyLines.slice(-n).join('\n')
19
+ }
20
+
21
+ export const logsCommand = new Command('logs')
22
+ .description('View container logs')
23
+ .argument('[name]', 'Container name')
24
+ .option('-f, --follow', 'Follow log output (like tail -f)')
25
+ .option('-n, --lines <number>', 'Number of lines to show', '50')
26
+ .option('--editor', 'Open logs in $EDITOR')
27
+ .action(
28
+ async (
29
+ name: string | undefined,
30
+ options: { follow?: boolean; lines?: string; editor?: boolean },
31
+ ) => {
32
+ try {
33
+ let containerName = name
34
+
35
+ // Interactive selection if no name provided
36
+ if (!containerName) {
37
+ const containers = await containerManager.list()
38
+
39
+ if (containers.length === 0) {
40
+ console.log(warning('No containers found'))
41
+ return
42
+ }
43
+
44
+ const selected = await promptContainerSelect(
45
+ containers,
46
+ 'Select container:',
47
+ )
48
+ if (!selected) return
49
+ containerName = selected
50
+ }
51
+
52
+ // Get container config
53
+ const config = await containerManager.getConfig(containerName)
54
+ if (!config) {
55
+ console.error(error(`Container "${containerName}" not found`))
56
+ process.exit(1)
57
+ }
58
+
59
+ // Get log file path
60
+ const logPath = paths.getContainerLogPath(config.name, {
61
+ engine: config.engine,
62
+ })
63
+
64
+ // Check if log file exists
65
+ if (!existsSync(logPath)) {
66
+ console.log(
67
+ info(
68
+ `No log file found for "${containerName}". The container may not have been started yet.`,
69
+ ),
70
+ )
71
+ return
72
+ }
73
+
74
+ // Open in editor if requested
75
+ if (options.editor) {
76
+ const editorCmd = process.env.EDITOR || 'vi'
77
+ const child = spawn(editorCmd, [logPath], {
78
+ stdio: 'inherit',
79
+ })
80
+
81
+ await new Promise<void>((resolve, reject) => {
82
+ child.on('close', (code) => {
83
+ if (code === 0) {
84
+ resolve()
85
+ } else {
86
+ reject(new Error(`Editor exited with code ${code}`))
87
+ }
88
+ })
89
+ child.on('error', reject)
90
+ })
91
+ return
92
+ }
93
+
94
+ // Follow mode using tail -f
95
+ if (options.follow) {
96
+ const lineCount = parseInt(options.lines || '50', 10)
97
+ const child = spawn('tail', ['-n', String(lineCount), '-f', logPath], {
98
+ stdio: 'inherit',
99
+ })
100
+
101
+ // Handle SIGINT gracefully
102
+ process.on('SIGINT', () => {
103
+ child.kill('SIGTERM')
104
+ process.exit(0)
105
+ })
106
+
107
+ await new Promise<void>((resolve) => {
108
+ child.on('close', () => resolve())
109
+ })
110
+ return
111
+ }
112
+
113
+ // Default: read and output last N lines
114
+ const lineCount = parseInt(options.lines || '50', 10)
115
+ const content = await readFile(logPath, 'utf-8')
116
+
117
+ if (content.trim() === '') {
118
+ console.log(info('Log file is empty'))
119
+ return
120
+ }
121
+
122
+ const output = getLastNLines(content, lineCount)
123
+ console.log(output)
124
+ } catch (err) {
125
+ const e = err as Error
126
+ console.error(error(e.message))
127
+ process.exit(1)
128
+ }
129
+ },
130
+ )