spindb 0.9.0 → 0.9.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.
Files changed (49) hide show
  1. package/README.md +7 -0
  2. package/cli/commands/backup.ts +13 -11
  3. package/cli/commands/clone.ts +18 -8
  4. package/cli/commands/config.ts +29 -29
  5. package/cli/commands/connect.ts +51 -39
  6. package/cli/commands/create.ts +120 -43
  7. package/cli/commands/delete.ts +8 -8
  8. package/cli/commands/deps.ts +17 -15
  9. package/cli/commands/doctor.ts +16 -15
  10. package/cli/commands/edit.ts +115 -60
  11. package/cli/commands/engines.ts +50 -17
  12. package/cli/commands/info.ts +12 -8
  13. package/cli/commands/list.ts +34 -19
  14. package/cli/commands/logs.ts +24 -14
  15. package/cli/commands/menu/backup-handlers.ts +72 -49
  16. package/cli/commands/menu/container-handlers.ts +140 -80
  17. package/cli/commands/menu/engine-handlers.ts +145 -11
  18. package/cli/commands/menu/index.ts +4 -4
  19. package/cli/commands/menu/shell-handlers.ts +34 -31
  20. package/cli/commands/menu/sql-handlers.ts +22 -16
  21. package/cli/commands/menu/update-handlers.ts +19 -17
  22. package/cli/commands/restore.ts +105 -43
  23. package/cli/commands/run.ts +20 -18
  24. package/cli/commands/self-update.ts +5 -5
  25. package/cli/commands/start.ts +11 -9
  26. package/cli/commands/stop.ts +9 -9
  27. package/cli/commands/url.ts +12 -9
  28. package/cli/helpers.ts +49 -4
  29. package/cli/ui/prompts.ts +21 -8
  30. package/cli/ui/spinner.ts +4 -4
  31. package/cli/ui/theme.ts +4 -4
  32. package/core/binary-manager.ts +5 -1
  33. package/core/container-manager.ts +81 -30
  34. package/core/error-handler.ts +31 -0
  35. package/core/platform-service.ts +3 -3
  36. package/core/port-manager.ts +2 -0
  37. package/core/process-manager.ts +25 -3
  38. package/core/start-with-retry.ts +6 -6
  39. package/core/transaction-manager.ts +6 -6
  40. package/engines/mysql/backup.ts +53 -36
  41. package/engines/mysql/index.ts +59 -16
  42. package/engines/mysql/restore.ts +4 -4
  43. package/engines/mysql/version-validator.ts +2 -2
  44. package/engines/postgresql/binary-manager.ts +17 -17
  45. package/engines/postgresql/index.ts +13 -2
  46. package/engines/postgresql/restore.ts +2 -2
  47. package/engines/postgresql/version-validator.ts +2 -2
  48. package/engines/sqlite/index.ts +31 -9
  49. package/package.json +1 -1
@@ -4,7 +4,7 @@ import { processManager } from '../../core/process-manager'
4
4
  import { getEngine } from '../../engines'
5
5
  import { promptContainerSelect } from '../ui/prompts'
6
6
  import { createSpinner } from '../ui/spinner'
7
- import { success, error, warning } from '../ui/theme'
7
+ import { uiSuccess, uiError, uiWarning } from '../ui/theme'
8
8
 
9
9
  export const stopCommand = new Command('stop')
10
10
  .description('Stop a container')
@@ -17,7 +17,7 @@ export const stopCommand = new Command('stop')
17
17
  const running = containers.filter((c) => c.status === 'running')
18
18
 
19
19
  if (running.length === 0) {
20
- console.log(warning('No running containers found'))
20
+ console.log(uiWarning('No running containers found'))
21
21
  return
22
22
  }
23
23
 
@@ -34,7 +34,7 @@ export const stopCommand = new Command('stop')
34
34
  spinner.succeed(`Stopped "${container.name}"`)
35
35
  }
36
36
 
37
- console.log(success(`Stopped ${running.length} container(s)`))
37
+ console.log(uiSuccess(`Stopped ${running.length} container(s)`))
38
38
  return
39
39
  }
40
40
 
@@ -45,7 +45,7 @@ export const stopCommand = new Command('stop')
45
45
  const running = containers.filter((c) => c.status === 'running')
46
46
 
47
47
  if (running.length === 0) {
48
- console.log(warning('No running containers found'))
48
+ console.log(uiWarning('No running containers found'))
49
49
  return
50
50
  }
51
51
 
@@ -59,7 +59,7 @@ export const stopCommand = new Command('stop')
59
59
 
60
60
  const config = await containerManager.getConfig(containerName)
61
61
  if (!config) {
62
- console.error(error(`Container "${containerName}" not found`))
62
+ console.error(uiError(`Container "${containerName}" not found`))
63
63
  process.exit(1)
64
64
  }
65
65
 
@@ -67,7 +67,7 @@ export const stopCommand = new Command('stop')
67
67
  engine: config.engine,
68
68
  })
69
69
  if (!running) {
70
- console.log(warning(`Container "${containerName}" is not running`))
70
+ console.log(uiWarning(`Container "${containerName}" is not running`))
71
71
  return
72
72
  }
73
73
 
@@ -80,9 +80,9 @@ export const stopCommand = new Command('stop')
80
80
  await containerManager.updateConfig(containerName, { status: 'stopped' })
81
81
 
82
82
  spinner.succeed(`Container "${containerName}" stopped`)
83
- } catch (err) {
84
- const e = err as Error
85
- console.error(error(e.message))
83
+ } catch (error) {
84
+ const e = error as Error
85
+ console.error(uiError(e.message))
86
86
  process.exit(1)
87
87
  }
88
88
  })
@@ -3,7 +3,7 @@ import { containerManager } from '../../core/container-manager'
3
3
  import { platformService } from '../../core/platform-service'
4
4
  import { getEngine } from '../../engines'
5
5
  import { promptContainerSelect } from '../ui/prompts'
6
- import { error, warning, success } from '../ui/theme'
6
+ import { uiError, uiWarning, uiSuccess } from '../ui/theme'
7
7
 
8
8
  export const urlCommand = new Command('url')
9
9
  .alias('connection-string')
@@ -24,7 +24,7 @@ export const urlCommand = new Command('url')
24
24
  const containers = await containerManager.list()
25
25
 
26
26
  if (containers.length === 0) {
27
- console.log(warning('No containers found'))
27
+ console.log(uiWarning('No containers found'))
28
28
  return
29
29
  }
30
30
 
@@ -38,13 +38,16 @@ export const urlCommand = new Command('url')
38
38
 
39
39
  const config = await containerManager.getConfig(containerName)
40
40
  if (!config) {
41
- console.error(error(`Container "${containerName}" not found`))
41
+ console.error(uiError(`Container "${containerName}" not found`))
42
42
  process.exit(1)
43
43
  }
44
44
 
45
45
  const engine = getEngine(config.engine)
46
46
  const databaseName = options.database || config.database
47
- const connectionString = engine.getConnectionString(config, databaseName)
47
+ const connectionString = engine.getConnectionString(
48
+ config,
49
+ databaseName,
50
+ )
48
51
 
49
52
  if (options.json) {
50
53
  const jsonOutput =
@@ -72,10 +75,10 @@ export const urlCommand = new Command('url')
72
75
  const copied = await platformService.copyToClipboard(connectionString)
73
76
  if (copied) {
74
77
  console.log(connectionString)
75
- console.error(success('Copied to clipboard'))
78
+ console.error(uiSuccess('Copied to clipboard'))
76
79
  } else {
77
80
  console.log(connectionString)
78
- console.error(warning('Could not copy to clipboard'))
81
+ console.error(uiWarning('Could not copy to clipboard'))
79
82
  }
80
83
  } else {
81
84
  process.stdout.write(connectionString)
@@ -83,9 +86,9 @@ export const urlCommand = new Command('url')
83
86
  console.log()
84
87
  }
85
88
  }
86
- } catch (err) {
87
- const e = err as Error
88
- console.error(error(e.message))
89
+ } catch (error) {
90
+ const e = error as Error
91
+ console.error(uiError(e.message))
89
92
  process.exit(1)
90
93
  }
91
94
  },
package/cli/helpers.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'fs'
2
2
  import { readdir, lstat } from 'fs/promises'
3
3
  import { join } from 'path'
4
- import { exec } from 'child_process'
4
+ import { exec, execFile } from 'child_process'
5
5
  import { promisify } from 'util'
6
6
  import { paths } from '../config/paths'
7
7
  import {
@@ -11,6 +11,7 @@ import {
11
11
  } from '../engines/mysql/binary-detection'
12
12
 
13
13
  const execAsync = promisify(exec)
14
+ const execFileAsync = promisify(execFile)
14
15
 
15
16
  export type InstalledPostgresEngine = {
16
17
  engine: 'postgresql'
@@ -30,7 +31,17 @@ export type InstalledMysqlEngine = {
30
31
  isMariaDB: boolean
31
32
  }
32
33
 
33
- export type InstalledEngine = InstalledPostgresEngine | InstalledMysqlEngine
34
+ export type InstalledSqliteEngine = {
35
+ engine: 'sqlite'
36
+ version: string
37
+ path: string
38
+ source: 'system'
39
+ }
40
+
41
+ export type InstalledEngine =
42
+ | InstalledPostgresEngine
43
+ | InstalledMysqlEngine
44
+ | InstalledSqliteEngine
34
45
 
35
46
  async function getPostgresVersion(binPath: string): Promise<string | null> {
36
47
  const postgresPath = join(binPath, 'bin', 'postgres')
@@ -39,7 +50,7 @@ async function getPostgresVersion(binPath: string): Promise<string | null> {
39
50
  }
40
51
 
41
52
  try {
42
- const { stdout } = await execAsync(`"${postgresPath}" --version`)
53
+ const { stdout } = await execFileAsync(postgresPath, ['--version'])
43
54
  const match = stdout.match(/\(PostgreSQL\)\s+([\d.]+)/)
44
55
  return match ? match[1] : null
45
56
  } catch {
@@ -47,7 +58,9 @@ async function getPostgresVersion(binPath: string): Promise<string | null> {
47
58
  }
48
59
  }
49
60
 
50
- export async function getInstalledPostgresEngines(): Promise<InstalledPostgresEngine[]> {
61
+ export async function getInstalledPostgresEngines(): Promise<
62
+ InstalledPostgresEngine[]
63
+ > {
51
64
  const binDir = paths.bin
52
65
 
53
66
  if (!existsSync(binDir)) {
@@ -125,6 +138,33 @@ async function getInstalledMysqlEngine(): Promise<InstalledMysqlEngine | null> {
125
138
  }
126
139
  }
127
140
 
141
+ async function getInstalledSqliteEngine(): Promise<InstalledSqliteEngine | null> {
142
+ try {
143
+ // TODO: Use 'where sqlite3' on Windows when adding Windows support
144
+ const { stdout: whichOutput } = await execAsync('which sqlite3')
145
+ const sqlitePath = whichOutput.trim()
146
+ if (!sqlitePath) {
147
+ return null
148
+ }
149
+
150
+ const { stdout: versionOutput } = await execFileAsync(sqlitePath, [
151
+ '--version',
152
+ ])
153
+ // sqlite3 --version outputs: "3.43.2 2023-10-10 12:14:04 ..."
154
+ const versionMatch = versionOutput.match(/^([\d.]+)/)
155
+ const version = versionMatch ? versionMatch[1] : 'unknown'
156
+
157
+ return {
158
+ engine: 'sqlite',
159
+ version,
160
+ path: sqlitePath,
161
+ source: 'system',
162
+ }
163
+ } catch {
164
+ return null
165
+ }
166
+ }
167
+
128
168
  export function compareVersions(a: string, b: string): number {
129
169
  const partsA = a.split('.').map((p) => parseInt(p, 10) || 0)
130
170
  const partsB = b.split('.').map((p) => parseInt(p, 10) || 0)
@@ -148,5 +188,10 @@ export async function getInstalledEngines(): Promise<InstalledEngine[]> {
148
188
  engines.push(mysqlEngine)
149
189
  }
150
190
 
191
+ const sqliteEngine = await getInstalledSqliteEngine()
192
+ if (sqliteEngine) {
193
+ engines.push(sqliteEngine)
194
+ }
195
+
151
196
  return engines
152
197
  }
package/cli/ui/prompts.ts CHANGED
@@ -258,7 +258,8 @@ export async function promptContainerSelect(
258
258
  */
259
259
  function sanitizeDatabaseName(name: string): string {
260
260
  // Replace invalid characters with underscores
261
- let sanitized = name.replace(/[^a-zA-Z0-9_-]/g, '_')
261
+ // Note: hyphens are excluded because they require quoting in SQL
262
+ let sanitized = name.replace(/[^a-zA-Z0-9_]/g, '_')
262
263
  // Ensure it starts with a letter or underscore
263
264
  if (sanitized && !/^[a-zA-Z_]/.test(sanitized)) {
264
265
  sanitized = '_' + sanitized
@@ -267,6 +268,10 @@ function sanitizeDatabaseName(name: string): string {
267
268
  sanitized = sanitized.replace(/_+/g, '_')
268
269
  // Trim trailing underscores
269
270
  sanitized = sanitized.replace(/_+$/, '')
271
+ // Fallback if result is empty (e.g., input was "---")
272
+ if (!sanitized) {
273
+ sanitized = 'db'
274
+ }
270
275
  return sanitized
271
276
  }
272
277
 
@@ -284,7 +289,9 @@ export async function promptDatabaseName(
284
289
  engine === 'mysql' ? 'Database (schema) name:' : 'Database name:'
285
290
 
286
291
  // Sanitize the default name to ensure it's valid
287
- const sanitizedDefault = defaultName ? sanitizeDatabaseName(defaultName) : undefined
292
+ const sanitizedDefault = defaultName
293
+ ? sanitizeDatabaseName(defaultName)
294
+ : undefined
288
295
 
289
296
  const { database } = await inquirer.prompt<{ database: string }>([
290
297
  {
@@ -295,8 +302,9 @@ export async function promptDatabaseName(
295
302
  validate: (input: string) => {
296
303
  if (!input) return 'Database name is required'
297
304
  // PostgreSQL database naming rules (also valid for MySQL)
298
- if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(input)) {
299
- return 'Database name must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens'
305
+ // Hyphens excluded to avoid requiring quoted identifiers in SQL
306
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(input)) {
307
+ return 'Database name must start with a letter or underscore and contain only letters, numbers, and underscores'
300
308
  }
301
309
  if (input.length > 63) {
302
310
  return 'Database name must be 63 characters or less'
@@ -414,7 +422,11 @@ export async function promptSqlitePath(
414
422
  ): Promise<string | undefined> {
415
423
  const defaultPath = `./${containerName}.sqlite`
416
424
 
417
- console.log(chalk.gray(' SQLite databases are stored as files in your project directory.'))
425
+ console.log(
426
+ chalk.gray(
427
+ ' SQLite databases are stored as files in your project directory.',
428
+ ),
429
+ )
418
430
  console.log(chalk.gray(` Default: ${defaultPath}`))
419
431
  console.log()
420
432
 
@@ -490,7 +502,8 @@ export async function promptSqlitePath(
490
502
  {
491
503
  type: 'list',
492
504
  name: 'overwrite',
493
- message: 'A file already exists at this location. What would you like to do?',
505
+ message:
506
+ 'A file already exists at this location. What would you like to do?',
494
507
  choices: [
495
508
  { name: 'Choose a different path', value: 'different' },
496
509
  { name: 'Cancel', value: 'cancel' },
@@ -696,8 +709,8 @@ export async function promptInstallDependencies(
696
709
 
697
710
  return false
698
711
  }
699
- } catch (err) {
700
- const e = err as Error
712
+ } catch (error) {
713
+ const e = error as Error
701
714
  console.log()
702
715
  console.log(chalk.red(` Installation failed: ${e.message}`))
703
716
  console.log()
package/cli/ui/spinner.ts CHANGED
@@ -27,10 +27,10 @@ export async function withSpinner<T>(
27
27
  })
28
28
  spinner.succeed()
29
29
  return result
30
- } catch (err) {
31
- const error = err as Error
32
- spinner.fail(error.message)
33
- throw error
30
+ } catch (error) {
31
+ const e = error as Error
32
+ spinner.fail(e.message)
33
+ throw e
34
34
  }
35
35
  }
36
36
 
package/cli/ui/theme.ts CHANGED
@@ -60,28 +60,28 @@ ${chalk.cyan('└' + line + '┘')}
60
60
  /**
61
61
  * Format a success message
62
62
  */
63
- export function success(message: string): string {
63
+ export function uiSuccess(message: string): string {
64
64
  return `${theme.icons.success} ${message}`
65
65
  }
66
66
 
67
67
  /**
68
68
  * Format an error message
69
69
  */
70
- export function error(message: string): string {
70
+ export function uiError(message: string): string {
71
71
  return `${theme.icons.error} ${chalk.red(message)}`
72
72
  }
73
73
 
74
74
  /**
75
75
  * Format a warning message
76
76
  */
77
- export function warning(message: string): string {
77
+ export function uiWarning(message: string): string {
78
78
  return `${theme.icons.warning} ${chalk.yellow(message)}`
79
79
  }
80
80
 
81
81
  /**
82
82
  * Format an info message
83
83
  */
84
- export function info(message: string): string {
84
+ export function uiInfo(message: string): string {
85
85
  return `${theme.icons.info} ${message}`
86
86
  }
87
87
 
@@ -6,7 +6,11 @@ import { exec } from 'child_process'
6
6
  import { promisify } from 'util'
7
7
  import { paths } from '../config/paths'
8
8
  import { defaults } from '../config/defaults'
9
- import { Engine, type ProgressCallback, type InstalledBinary } from '../types'
9
+ import {
10
+ type Engine,
11
+ type ProgressCallback,
12
+ type InstalledBinary,
13
+ } from '../types'
10
14
 
11
15
  const execAsync = promisify(exec)
12
16
 
@@ -1,5 +1,14 @@
1
1
  import { existsSync } from 'fs'
2
- import { mkdir, readdir, readFile, writeFile, rm, cp, unlink } from 'fs/promises'
2
+ import {
3
+ mkdir,
4
+ readdir,
5
+ readFile,
6
+ writeFile,
7
+ rm,
8
+ cp,
9
+ unlink,
10
+ rename as fsRename,
11
+ } from 'fs/promises'
3
12
  import { paths } from '../config/paths'
4
13
  import { processManager } from './process-manager'
5
14
  import { portManager } from './port-manager'
@@ -76,7 +85,7 @@ export class ContainerManager {
76
85
 
77
86
  if (engine) {
78
87
  // SQLite uses registry instead of filesystem
79
- if (engine === 'sqlite') {
88
+ if (engine === Engine.SQLite) {
80
89
  return this.getSqliteConfig(name)
81
90
  }
82
91
 
@@ -201,7 +210,7 @@ export class ContainerManager {
201
210
 
202
211
  if (engine) {
203
212
  // SQLite uses registry
204
- if (engine === 'sqlite') {
213
+ if (engine === Engine.SQLite) {
205
214
  return sqliteRegistry.exists(name)
206
215
  }
207
216
  const configPath = paths.getContainerConfigPath(name, { engine })
@@ -366,26 +375,35 @@ export class ContainerManager {
366
375
 
367
376
  await cp(sourcePath, targetPath, { recursive: true })
368
377
 
369
- // Update target config
370
- const config = await this.getConfig(targetName, { engine })
371
- if (!config) {
372
- throw new Error('Failed to read cloned container config')
373
- }
378
+ // If anything fails after copy, clean up the target directory
379
+ try {
380
+ // Update target config
381
+ const config = await this.getConfig(targetName, { engine })
382
+ if (!config) {
383
+ throw new Error('Failed to read cloned container config')
384
+ }
374
385
 
375
- config.name = targetName
376
- config.created = new Date().toISOString()
377
- config.clonedFrom = sourceName
386
+ config.name = targetName
387
+ config.created = new Date().toISOString()
388
+ config.clonedFrom = sourceName
378
389
 
379
- // Assign new port (excluding ports already used by other containers)
380
- const engineDefaults = getEngineDefaults(engine)
381
- const { port } = await portManager.findAvailablePortExcludingContainers({
382
- portRange: engineDefaults.portRange,
383
- })
384
- config.port = port
390
+ // Assign new port (excluding ports already used by other containers)
391
+ const engineDefaults = getEngineDefaults(engine)
392
+ const { port } = await portManager.findAvailablePortExcludingContainers({
393
+ portRange: engineDefaults.portRange,
394
+ })
395
+ config.port = port
385
396
 
386
- await this.saveConfig(targetName, { engine }, config)
397
+ await this.saveConfig(targetName, { engine }, config)
387
398
 
388
- return config
399
+ return config
400
+ } catch (error) {
401
+ // Clean up the copied directory on failure
402
+ await rm(targetPath, { recursive: true, force: true }).catch(() => {
403
+ // Ignore cleanup errors
404
+ })
405
+ throw error
406
+ }
389
407
  }
390
408
 
391
409
  /**
@@ -419,7 +437,15 @@ export class ContainerManager {
419
437
  throw new Error(`SQLite container "${oldName}" not found in registry`)
420
438
  }
421
439
 
422
- // Remove old entry and add new one with updated name
440
+ // Move container directory first (if it exists) - do filesystem ops before registry
441
+ // This way if the move fails, registry is unchanged
442
+ const oldContainerPath = paths.getContainerPath(oldName, { engine })
443
+ const newContainerPath = paths.getContainerPath(newName, { engine })
444
+ if (existsSync(oldContainerPath)) {
445
+ await this.atomicMoveDirectory(oldContainerPath, newContainerPath)
446
+ }
447
+
448
+ // Now update registry - remove old entry and add new one with updated name
423
449
  await sqliteRegistry.remove(oldName)
424
450
  await sqliteRegistry.add({
425
451
  name: newName,
@@ -428,14 +454,6 @@ export class ContainerManager {
428
454
  lastVerified: entry.lastVerified,
429
455
  })
430
456
 
431
- // Rename container directory if it exists (created by containerManager.create)
432
- const oldContainerPath = paths.getContainerPath(oldName, { engine })
433
- const newContainerPath = paths.getContainerPath(newName, { engine })
434
- if (existsSync(oldContainerPath)) {
435
- await cp(oldContainerPath, newContainerPath, { recursive: true })
436
- await rm(oldContainerPath, { recursive: true, force: true })
437
- }
438
-
439
457
  // Return updated config
440
458
  return {
441
459
  ...sourceConfig,
@@ -453,8 +471,7 @@ export class ContainerManager {
453
471
  const oldPath = paths.getContainerPath(oldName, { engine })
454
472
  const newPath = paths.getContainerPath(newName, { engine })
455
473
 
456
- await cp(oldPath, newPath, { recursive: true })
457
- await rm(oldPath, { recursive: true, force: true })
474
+ await this.atomicMoveDirectory(oldPath, newPath)
458
475
 
459
476
  // Update config with new name
460
477
  const config = await this.getConfig(newName, { engine })
@@ -468,6 +485,40 @@ export class ContainerManager {
468
485
  return config
469
486
  }
470
487
 
488
+ /**
489
+ * Move a directory atomically when possible, with copy+delete fallback.
490
+ * Uses fs.rename which is atomic on same filesystem, falls back to
491
+ * copy+delete for cross-filesystem moves (with cleanup on failure).
492
+ */
493
+ private async atomicMoveDirectory(
494
+ sourcePath: string,
495
+ targetPath: string,
496
+ ): Promise<void> {
497
+ try {
498
+ // Try atomic rename first (only works on same filesystem)
499
+ await fsRename(sourcePath, targetPath)
500
+ } catch (error) {
501
+ const e = error as NodeJS.ErrnoException
502
+ if (e.code === 'EXDEV') {
503
+ // Cross-filesystem move - fall back to copy+delete
504
+ await cp(sourcePath, targetPath, { recursive: true })
505
+ try {
506
+ await rm(sourcePath, { recursive: true, force: true })
507
+ } catch {
508
+ // If delete fails after copy, we have duplicates
509
+ // Try to clean up the target to avoid inconsistency
510
+ await rm(targetPath, { recursive: true, force: true }).catch(() => {})
511
+ throw new Error(
512
+ `Failed to complete move: source and target may both exist. ` +
513
+ `Please manually remove one of: ${sourcePath} or ${targetPath}`,
514
+ )
515
+ }
516
+ } else {
517
+ throw error
518
+ }
519
+ }
520
+ }
521
+
471
522
  /**
472
523
  * Validate container name
473
524
  */
@@ -56,6 +56,7 @@ export const ErrorCodes = {
56
56
  CONTAINER_CREATE_FAILED: 'CONTAINER_CREATE_FAILED',
57
57
  INIT_FAILED: 'INIT_FAILED',
58
58
  DATABASE_CREATE_FAILED: 'DATABASE_CREATE_FAILED',
59
+ INVALID_DATABASE_NAME: 'INVALID_DATABASE_NAME',
59
60
 
60
61
  // Dependency errors
61
62
  DEPENDENCY_MISSING: 'DEPENDENCY_MISSING',
@@ -308,3 +309,33 @@ export function createDependencyMissingError(
308
309
  { toolName, engine },
309
310
  )
310
311
  }
312
+
313
+ /**
314
+ * Validate a database name to prevent SQL injection.
315
+ * Database names must start with a letter and contain only
316
+ * alphanumeric characters and underscores.
317
+ *
318
+ * Note: Hyphens are excluded because they require quoted identifiers
319
+ * in SQL, which is error-prone for users.
320
+ */
321
+ export function isValidDatabaseName(name: string): boolean {
322
+ // Must start with a letter to be valid in all database systems
323
+ // Hyphens excluded to avoid requiring quoted identifiers in SQL
324
+ return /^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)
325
+ }
326
+
327
+ /**
328
+ * Assert that a database name is valid, throwing SpinDBError if not.
329
+ * Use this at the entry points where database names are accepted.
330
+ */
331
+ export function assertValidDatabaseName(name: string): void {
332
+ if (!isValidDatabaseName(name)) {
333
+ throw new SpinDBError(
334
+ ErrorCodes.INVALID_DATABASE_NAME,
335
+ `Invalid database name: "${name}"`,
336
+ 'error',
337
+ 'Database names must start with a letter and contain only letters, numbers, and underscores',
338
+ { databaseName: name },
339
+ )
340
+ }
341
+ }
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { homedir, platform as osPlatform, arch as osArch } from 'os'
14
- import { execSync, exec, spawn } from 'child_process'
14
+ import { execSync, execFileSync, exec, spawn } from 'child_process'
15
15
  import { promisify } from 'util'
16
16
  import { existsSync } from 'fs'
17
17
 
@@ -205,7 +205,7 @@ class DarwinPlatformService extends BasePlatformService {
205
205
  let getentResult: string | null = null
206
206
  if (sudoUser) {
207
207
  try {
208
- getentResult = execSync(`getent passwd ${sudoUser}`, {
208
+ getentResult = execFileSync('getent', ['passwd', sudoUser], {
209
209
  encoding: 'utf-8',
210
210
  })
211
211
  } catch {
@@ -347,7 +347,7 @@ class LinuxPlatformService extends BasePlatformService {
347
347
  let getentResult: string | null = null
348
348
  if (sudoUser) {
349
349
  try {
350
- getentResult = execSync(`getent passwd ${sudoUser}`, {
350
+ getentResult = execFileSync('getent', ['passwd', sudoUser], {
351
351
  encoding: 'utf-8',
352
352
  })
353
353
  } catch {
@@ -27,6 +27,8 @@ export class PortManager {
27
27
  const server = net.createServer()
28
28
 
29
29
  server.once('error', (err: NodeJS.ErrnoException) => {
30
+ // Always close the server to prevent resource leaks
31
+ server.close()
30
32
  if (err.code === 'EADDRINUSE') {
31
33
  resolve(false)
32
34
  } else {
@@ -1,7 +1,7 @@
1
1
  import { exec, spawn } from 'child_process'
2
2
  import { promisify } from 'util'
3
3
  import { existsSync } from 'fs'
4
- import { readFile } from 'fs/promises'
4
+ import { readFile, rm } from 'fs/promises'
5
5
  import { paths } from '../config/paths'
6
6
  import { logDebug } from './error-handler'
7
7
  import type { ProcessResult, StatusResult } from '../types'
@@ -42,6 +42,9 @@ export class ProcessManager {
42
42
  ): Promise<ProcessResult> {
43
43
  const { superuser = 'postgres' } = options
44
44
 
45
+ // Track if directory existed before initdb (to know if we should clean up)
46
+ const dirExistedBefore = existsSync(dataDir)
47
+
45
48
  const args = [
46
49
  '-D',
47
50
  dataDir,
@@ -52,6 +55,21 @@ export class ProcessManager {
52
55
  '--no-locale',
53
56
  ]
54
57
 
58
+ // Helper to clean up data directory on failure
59
+ const cleanupOnFailure = async () => {
60
+ // Only clean up if initdb created the directory (it didn't exist before)
61
+ if (!dirExistedBefore && existsSync(dataDir)) {
62
+ try {
63
+ await rm(dataDir, { recursive: true, force: true })
64
+ logDebug(`Cleaned up data directory after initdb failure: ${dataDir}`)
65
+ } catch (cleanupErr) {
66
+ logDebug(
67
+ `Failed to clean up data directory: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`,
68
+ )
69
+ }
70
+ }
71
+ }
72
+
55
73
  return new Promise((resolve, reject) => {
56
74
  const proc = spawn(initdbPath, args, {
57
75
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -67,15 +85,19 @@ export class ProcessManager {
67
85
  stderr += data.toString()
68
86
  })
69
87
 
70
- proc.on('close', (code) => {
88
+ proc.on('close', async (code) => {
71
89
  if (code === 0) {
72
90
  resolve({ stdout, stderr })
73
91
  } else {
92
+ await cleanupOnFailure()
74
93
  reject(new Error(`initdb failed with code ${code}: ${stderr}`))
75
94
  }
76
95
  })
77
96
 
78
- proc.on('error', reject)
97
+ proc.on('error', async (err) => {
98
+ await cleanupOnFailure()
99
+ reject(err)
100
+ })
79
101
  })
80
102
  }
81
103