spindb 0.8.2 → 0.9.1

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 +87 -7
  2. package/cli/commands/clone.ts +6 -0
  3. package/cli/commands/connect.ts +115 -14
  4. package/cli/commands/create.ts +170 -8
  5. package/cli/commands/doctor.ts +320 -0
  6. package/cli/commands/edit.ts +209 -9
  7. package/cli/commands/engines.ts +34 -3
  8. package/cli/commands/info.ts +81 -26
  9. package/cli/commands/list.ts +64 -9
  10. package/cli/commands/logs.ts +9 -3
  11. package/cli/commands/menu/backup-handlers.ts +52 -21
  12. package/cli/commands/menu/container-handlers.ts +433 -127
  13. package/cli/commands/menu/engine-handlers.ts +128 -4
  14. package/cli/commands/menu/index.ts +5 -1
  15. package/cli/commands/menu/shell-handlers.ts +105 -21
  16. package/cli/commands/menu/sql-handlers.ts +16 -4
  17. package/cli/commands/menu/update-handlers.ts +278 -0
  18. package/cli/commands/restore.ts +83 -23
  19. package/cli/commands/run.ts +27 -11
  20. package/cli/commands/url.ts +17 -9
  21. package/cli/constants.ts +1 -0
  22. package/cli/helpers.ts +41 -1
  23. package/cli/index.ts +2 -0
  24. package/cli/ui/prompts.ts +148 -7
  25. package/config/engine-defaults.ts +14 -0
  26. package/config/os-dependencies.ts +66 -0
  27. package/config/paths.ts +8 -0
  28. package/core/container-manager.ts +191 -32
  29. package/core/dependency-manager.ts +18 -0
  30. package/core/error-handler.ts +31 -0
  31. package/core/port-manager.ts +2 -0
  32. package/core/process-manager.ts +25 -3
  33. package/engines/index.ts +4 -0
  34. package/engines/mysql/backup.ts +53 -36
  35. package/engines/mysql/index.ts +48 -5
  36. package/engines/postgresql/index.ts +6 -0
  37. package/engines/sqlite/index.ts +606 -0
  38. package/engines/sqlite/registry.ts +185 -0
  39. package/package.json +1 -1
  40. package/types/index.ts +26 -0
@@ -1,12 +1,15 @@
1
1
  import chalk from 'chalk'
2
2
  import inquirer from 'inquirer'
3
- import { existsSync } from 'fs'
3
+ import { existsSync, renameSync, statSync, mkdirSync, copyFileSync, unlinkSync } from 'fs'
4
+ import { dirname, basename, join, resolve } from 'path'
5
+ import { homedir } from 'os'
4
6
  import { containerManager } from '../../../core/container-manager'
5
7
  import { getMissingDependencies } from '../../../core/dependency-manager'
6
8
  import { platformService } from '../../../core/platform-service'
7
9
  import { portManager } from '../../../core/port-manager'
8
10
  import { processManager } from '../../../core/process-manager'
9
11
  import { getEngine } from '../../../engines'
12
+ import { sqliteRegistry } from '../../../engines/sqlite/registry'
10
13
  import { defaults } from '../../../config/defaults'
11
14
  import { paths } from '../../../config/paths'
12
15
  import {
@@ -26,24 +29,25 @@ import {
26
29
  connectionBox,
27
30
  formatBytes,
28
31
  } from '../../ui/theme'
29
- import { getEngineIcon } from '../../constants'
30
32
  import { handleOpenShell, handleCopyConnectionString } from './shell-handlers'
31
33
  import { handleRunSql, handleViewLogs } from './sql-handlers'
32
- import { type Engine } from '../../../types'
33
- import { type MenuChoice } from './shared'
34
+ import { Engine } from '../../../types'
35
+ import { type MenuChoice, pressEnterToContinue } from './shared'
34
36
 
35
37
  export async function handleCreate(): Promise<void> {
36
38
  console.log()
37
39
  const answers = await promptCreateOptions()
38
40
  let { name: containerName } = answers
39
- const { engine, version, port, database } = answers
41
+ const { engine, version, port, database, path: sqlitePath } = answers
40
42
 
41
43
  console.log()
42
44
  console.log(header('Creating Database Container'))
43
45
  console.log()
44
46
 
45
47
  const dbEngine = getEngine(engine)
48
+ const isSQLite = engine === 'sqlite'
46
49
 
50
+ // Check dependencies (all engines need this)
47
51
  const depsSpinner = createSpinner('Checking required tools...')
48
52
  depsSpinner.start()
49
53
 
@@ -78,22 +82,26 @@ export async function handleCreate(): Promise<void> {
78
82
  depsSpinner.succeed('Required tools available')
79
83
  }
80
84
 
81
- const portAvailable = await portManager.isPortAvailable(port)
85
+ // Server databases: check port and binaries
86
+ let portAvailable = true
87
+ if (!isSQLite) {
88
+ portAvailable = await portManager.isPortAvailable(port)
82
89
 
83
- const binarySpinner = createSpinner(
84
- `Checking PostgreSQL ${version} binaries...`,
85
- )
86
- binarySpinner.start()
87
-
88
- const isInstalled = await dbEngine.isBinaryInstalled(version)
89
- if (isInstalled) {
90
- binarySpinner.succeed(`PostgreSQL ${version} binaries ready (cached)`)
91
- } else {
92
- binarySpinner.text = `Downloading PostgreSQL ${version} binaries...`
93
- await dbEngine.ensureBinaries(version, ({ message }) => {
94
- binarySpinner.text = message
95
- })
96
- binarySpinner.succeed(`PostgreSQL ${version} binaries downloaded`)
90
+ const binarySpinner = createSpinner(
91
+ `Checking ${dbEngine.displayName} ${version} binaries...`,
92
+ )
93
+ binarySpinner.start()
94
+
95
+ const isInstalled = await dbEngine.isBinaryInstalled(version)
96
+ if (isInstalled) {
97
+ binarySpinner.succeed(`${dbEngine.displayName} ${version} binaries ready (cached)`)
98
+ } else {
99
+ binarySpinner.text = `Downloading ${dbEngine.displayName} ${version} binaries...`
100
+ await dbEngine.ensureBinaries(version, ({ message }) => {
101
+ binarySpinner.text = message
102
+ })
103
+ binarySpinner.succeed(`${dbEngine.displayName} ${version} binaries downloaded`)
104
+ }
97
105
  }
98
106
 
99
107
  while (await containerManager.exists(containerName)) {
@@ -113,17 +121,62 @@ export async function handleCreate(): Promise<void> {
113
121
 
114
122
  createSpinnerInstance.succeed('Container created')
115
123
 
116
- const initSpinner = createSpinner('Initializing database cluster...')
124
+ const initSpinner = createSpinner(
125
+ isSQLite ? 'Creating database file...' : 'Initializing database cluster...',
126
+ )
117
127
  initSpinner.start()
118
128
 
119
129
  await dbEngine.initDataDir(containerName, version, {
120
130
  superuser: defaults.superuser,
131
+ path: sqlitePath, // SQLite file path (undefined for server databases)
121
132
  })
122
133
 
123
- initSpinner.succeed('Database cluster initialized')
134
+ initSpinner.succeed(isSQLite ? 'Database file created' : 'Database cluster initialized')
135
+
136
+ // SQLite: show file path, no start needed
137
+ if (isSQLite) {
138
+ const config = await containerManager.getConfig(containerName)
139
+ if (config) {
140
+ const connectionString = dbEngine.getConnectionString(config)
141
+ console.log()
142
+ console.log(success('Database Created'))
143
+ console.log()
144
+ console.log(chalk.gray(` Container: ${containerName}`))
145
+ console.log(chalk.gray(` Engine: ${dbEngine.displayName} ${version}`))
146
+ console.log(chalk.gray(` File: ${config.database}`))
147
+ console.log()
148
+ console.log(success(`Available at ${config.database}`))
149
+ console.log()
150
+ console.log(chalk.gray(' Connection string:'))
151
+ console.log(chalk.cyan(` ${connectionString}`))
152
+
153
+ try {
154
+ const copied = await platformService.copyToClipboard(connectionString)
155
+ if (copied) {
156
+ console.log(chalk.gray(' ✓ Connection string copied to clipboard'))
157
+ } else {
158
+ console.log(chalk.gray(' (Could not copy to clipboard)'))
159
+ }
160
+ } catch {
161
+ console.log(chalk.gray(' (Could not copy to clipboard)'))
162
+ }
163
+
164
+ console.log()
165
+
166
+ await inquirer.prompt([
167
+ {
168
+ type: 'input',
169
+ name: 'continue',
170
+ message: chalk.gray('Press Enter to return to the main menu...'),
171
+ },
172
+ ])
173
+ }
174
+ return
175
+ }
124
176
 
177
+ // Server databases: start and create database
125
178
  if (portAvailable) {
126
- const startSpinner = createSpinner('Starting PostgreSQL...')
179
+ const startSpinner = createSpinner(`Starting ${dbEngine.displayName}...`)
127
180
  startSpinner.start()
128
181
 
129
182
  const config = await containerManager.getConfig(containerName)
@@ -132,7 +185,7 @@ export async function handleCreate(): Promise<void> {
132
185
  await containerManager.updateConfig(containerName, { status: 'running' })
133
186
  }
134
187
 
135
- startSpinner.succeed('PostgreSQL started')
188
+ startSpinner.succeed(`${dbEngine.displayName} started`)
136
189
 
137
190
  if (config && database !== 'postgres') {
138
191
  const dbSpinner = createSpinner(`Creating database "${database}"...`)
@@ -149,11 +202,11 @@ export async function handleCreate(): Promise<void> {
149
202
  console.log(success('Database Created'))
150
203
  console.log()
151
204
  console.log(chalk.gray(` Container: ${containerName}`))
152
- console.log(chalk.gray(` Engine: ${dbEngine.name} ${version}`))
205
+ console.log(chalk.gray(` Engine: ${dbEngine.displayName} ${version}`))
153
206
  console.log(chalk.gray(` Database: ${database}`))
154
207
  console.log(chalk.gray(` Port: ${port}`))
155
208
  console.log()
156
- console.log(success(`Started Running on port ${port}`))
209
+ console.log(success(`Running on port ${port}`))
157
210
  console.log()
158
211
  console.log(chalk.gray(' Connection string:'))
159
212
  console.log(chalk.cyan(` ${connectionString}`))
@@ -233,58 +286,86 @@ export async function handleList(
233
286
  console.log()
234
287
  console.log(
235
288
  chalk.gray(' ') +
236
- chalk.bold.white('NAME'.padEnd(20)) +
237
- chalk.bold.white('ENGINE'.padEnd(12)) +
238
- chalk.bold.white('VERSION'.padEnd(10)) +
239
- chalk.bold.white('PORT'.padEnd(8)) +
240
- chalk.bold.white('SIZE'.padEnd(10)) +
289
+ chalk.bold.white('NAME'.padEnd(16)) +
290
+ chalk.bold.white('ENGINE'.padEnd(11)) +
291
+ chalk.bold.white('VERSION'.padEnd(8)) +
292
+ chalk.bold.white('PORT'.padEnd(6)) +
293
+ chalk.bold.white('SIZE'.padEnd(9)) +
241
294
  chalk.bold.white('STATUS'),
242
295
  )
243
- console.log(chalk.gray(' ' + '─'.repeat(70)))
296
+ console.log(chalk.gray(' ' + '─'.repeat(58)))
244
297
 
245
298
  for (let i = 0; i < containers.length; i++) {
246
299
  const container = containers[i]
247
300
  const size = sizes[i]
301
+ const isSQLite = container.engine === Engine.SQLite
248
302
 
249
- const statusDisplay =
250
- container.status === 'running'
251
- ? chalk.green('running')
252
- : chalk.gray(' stopped')
303
+ // SQLite uses available/missing, server databases use running/stopped
304
+ const statusDisplay = isSQLite
305
+ ? (container.status === 'running'
306
+ ? chalk.blue(' available')
307
+ : chalk.gray('○ missing'))
308
+ : (container.status === 'running'
309
+ ? chalk.green('● running')
310
+ : chalk.gray('○ stopped'))
253
311
 
254
312
  const sizeDisplay = size !== null ? formatBytes(size) : chalk.gray('—')
255
313
 
314
+ // Truncate name if too long
315
+ const displayName = container.name.length > 15
316
+ ? container.name.slice(0, 14) + '…'
317
+ : container.name
318
+
319
+ // SQLite shows dash instead of port
320
+ const portDisplay = isSQLite ? '—' : String(container.port)
321
+
256
322
  console.log(
257
323
  chalk.gray(' ') +
258
- chalk.cyan(container.name.padEnd(20)) +
259
- chalk.white(container.engine.padEnd(12)) +
260
- chalk.yellow(container.version.padEnd(10)) +
261
- chalk.green(String(container.port).padEnd(8)) +
262
- chalk.magenta(sizeDisplay.padEnd(10)) +
324
+ chalk.cyan(displayName.padEnd(16)) +
325
+ chalk.white(container.engine.padEnd(11)) +
326
+ chalk.yellow(container.version.padEnd(8)) +
327
+ chalk.green(portDisplay.padEnd(6)) +
328
+ chalk.magenta(sizeDisplay.padEnd(9)) +
263
329
  statusDisplay,
264
330
  )
265
331
  }
266
332
 
267
333
  console.log()
268
334
 
269
- const running = containers.filter((c) => c.status === 'running').length
270
- const stopped = containers.filter((c) => c.status !== 'running').length
335
+ // Separate counts for server databases and SQLite
336
+ const serverContainers = containers.filter((c) => c.engine !== Engine.SQLite)
337
+ const sqliteContainers = containers.filter((c) => c.engine === Engine.SQLite)
338
+
339
+ const running = serverContainers.filter((c) => c.status === 'running').length
340
+ const stopped = serverContainers.filter((c) => c.status !== 'running').length
341
+ const available = sqliteContainers.filter((c) => c.status === 'running').length
342
+ const missing = sqliteContainers.filter((c) => c.status !== 'running').length
343
+
344
+ const parts: string[] = []
345
+ if (serverContainers.length > 0) {
346
+ parts.push(`${running} running, ${stopped} stopped`)
347
+ }
348
+ if (sqliteContainers.length > 0) {
349
+ parts.push(`${available} SQLite available${missing > 0 ? `, ${missing} missing` : ''}`)
350
+ }
351
+
271
352
  console.log(
272
353
  chalk.gray(
273
- ` ${containers.length} container(s): ${running} running, ${stopped} stopped`,
354
+ ` ${containers.length} container(s): ${parts.join('; ')}`,
274
355
  ),
275
356
  )
276
357
 
277
358
  console.log()
278
359
  const containerChoices = [
279
- ...containers.map((c, i) => {
280
- const size = sizes[i]
281
- const sizeLabel = size !== null ? `, ${formatBytes(size)}` : ''
360
+ ...containers.map((c) => {
361
+ // Simpler selector - table already shows details
362
+ const statusLabel =
363
+ c.engine === Engine.SQLite
364
+ ? (c.status === 'running' ? chalk.blue('● available') : chalk.gray('○ missing'))
365
+ : (c.status === 'running' ? chalk.green('● running') : chalk.gray('○ stopped'))
366
+
282
367
  return {
283
- name: `${c.name} ${chalk.gray(`(${getEngineIcon(c.engine)} ${c.engine} ${c.version}, port ${c.port}${sizeLabel})`)} ${
284
- c.status === 'running'
285
- ? chalk.green('● running')
286
- : chalk.gray('○ stopped')
287
- }`,
368
+ name: `${c.name} ${statusLabel}`,
288
369
  value: c.name,
289
370
  short: c.name,
290
371
  }
@@ -323,71 +404,116 @@ export async function showContainerSubmenu(
323
404
  return
324
405
  }
325
406
 
326
- const isRunning = await processManager.isRunning(containerName, {
327
- engine: config.engine,
328
- })
329
- const status = isRunning ? 'running' : 'stopped'
407
+ // SQLite: Check file existence instead of running status
408
+ const isSQLite = config.engine === Engine.SQLite
409
+ let isRunning: boolean
410
+ let status: string
411
+ let locationInfo: string
412
+
413
+ if (isSQLite) {
414
+ const fileExists = existsSync(config.database)
415
+ isRunning = fileExists // For SQLite, "running" means "file exists"
416
+ status = fileExists ? 'available' : 'missing'
417
+ locationInfo = `at ${config.database}`
418
+ } else {
419
+ isRunning = await processManager.isRunning(containerName, {
420
+ engine: config.engine,
421
+ })
422
+ status = isRunning ? 'running' : 'stopped'
423
+ locationInfo = `on port ${config.port}`
424
+ }
330
425
 
331
426
  console.clear()
332
427
  console.log(header(containerName))
333
428
  console.log()
334
429
  console.log(
335
430
  chalk.gray(
336
- ` ${config.engine} ${config.version} on port ${config.port} - ${status}`,
431
+ ` ${config.engine} ${config.version} ${locationInfo} - ${status}`,
337
432
  ),
338
433
  )
339
434
  console.log()
340
435
 
341
- const actionChoices: MenuChoice[] = [
342
- !isRunning
343
- ? {
344
- name: `${chalk.green('▶')} Start container`,
345
- value: 'start',
346
- }
347
- : {
348
- name: `${chalk.red('')} Stop container`,
349
- value: 'stop',
350
- },
351
- {
352
- name: isRunning
353
- ? `${chalk.blue('')} Open shell`
354
- : chalk.gray('⌘ Open shell'),
355
- value: 'shell',
356
- disabled: isRunning ? false : 'Start container first',
357
- },
358
- {
359
- name: isRunning
360
- ? `${chalk.yellow('▷')} Run SQL file`
361
- : chalk.gray('▷ Run SQL file'),
362
- value: 'run-sql',
363
- disabled: isRunning ? false : 'Start container first',
364
- },
365
- {
366
- name: !isRunning
367
- ? `${chalk.white('⚙')} Edit container`
368
- : chalk.gray('⚙ Edit container'),
369
- value: 'edit',
370
- disabled: !isRunning ? false : 'Stop container first',
371
- },
372
- {
373
- name: !isRunning
374
- ? `${chalk.cyan('')} Clone container`
375
- : chalk.gray('⧉ Clone container'),
376
- value: 'clone',
377
- disabled: !isRunning ? false : 'Stop container first',
378
- },
436
+ // Build action choices based on engine type
437
+ const actionChoices: MenuChoice[] = []
438
+
439
+ // Start/Stop buttons only for server databases (not SQLite)
440
+ if (!isSQLite) {
441
+ if (!isRunning) {
442
+ actionChoices.push({
443
+ name: `${chalk.green('')} Start container`,
444
+ value: 'start',
445
+ })
446
+ } else {
447
+ actionChoices.push({
448
+ name: `${chalk.red('')} Stop container`,
449
+ value: 'stop',
450
+ })
451
+ }
452
+ }
453
+
454
+ // Open shell - always enabled for SQLite (if file exists), server databases need to be running
455
+ const canOpenShell = isSQLite ? existsSync(config.database) : isRunning
456
+ actionChoices.push({
457
+ name: canOpenShell
458
+ ? `${chalk.blue('⌘')} Open shell`
459
+ : chalk.gray('⌘ Open shell'),
460
+ value: 'shell',
461
+ disabled: canOpenShell ? false : (isSQLite ? 'Database file missing' : 'Start container first'),
462
+ })
463
+
464
+ // Run SQL - always enabled for SQLite (if file exists), server databases need to be running
465
+ const canRunSql = isSQLite ? existsSync(config.database) : isRunning
466
+ actionChoices.push({
467
+ name: canRunSql
468
+ ? `${chalk.yellow('▷')} Run SQL file`
469
+ : chalk.gray('▷ Run SQL file'),
470
+ value: 'run-sql',
471
+ disabled: canRunSql ? false : (isSQLite ? 'Database file missing' : 'Start container first'),
472
+ })
473
+
474
+ // Edit container - SQLite can always edit (no running state), server databases must be stopped
475
+ const canEdit = isSQLite ? true : !isRunning
476
+ actionChoices.push({
477
+ name: canEdit
478
+ ? `${chalk.white('⚙')} Edit container`
479
+ : chalk.gray('⚙ Edit container'),
480
+ value: 'edit',
481
+ disabled: canEdit ? false : 'Stop container first',
482
+ })
483
+
484
+ // Clone container - SQLite can always clone, server databases must be stopped
485
+ const canClone = isSQLite ? true : !isRunning
486
+ actionChoices.push({
487
+ name: canClone
488
+ ? `${chalk.cyan('⧉')} Clone container`
489
+ : chalk.gray('⧉ Clone container'),
490
+ value: 'clone',
491
+ disabled: canClone ? false : 'Stop container first',
492
+ })
493
+
494
+ actionChoices.push(
379
495
  { name: `${chalk.magenta('⎘')} Copy connection string`, value: 'copy' },
380
- {
496
+ )
497
+
498
+ // View logs - not available for SQLite (no log file)
499
+ if (!isSQLite) {
500
+ actionChoices.push({
381
501
  name: `${chalk.gray('☰')} View logs`,
382
502
  value: 'logs',
383
- },
384
- {
385
- name: !isRunning
386
- ? `${chalk.red('✕')} Delete container`
387
- : chalk.gray('✕ Delete container'),
388
- value: 'delete',
389
- disabled: !isRunning ? false : 'Stop container first',
390
- },
503
+ })
504
+ }
505
+
506
+ // Delete container - SQLite can always delete, server databases must be stopped
507
+ const canDelete = isSQLite ? true : !isRunning
508
+ actionChoices.push({
509
+ name: canDelete
510
+ ? `${chalk.red('✕')} Delete container`
511
+ : chalk.gray('✕ Delete container'),
512
+ value: 'delete',
513
+ disabled: canDelete ? false : 'Stop container first',
514
+ })
515
+
516
+ actionChoices.push(
391
517
  new inquirer.Separator(),
392
518
  {
393
519
  name: `${chalk.blue('←')} Back to containers`,
@@ -397,7 +523,7 @@ export async function showContainerSubmenu(
397
523
  name: `${chalk.blue('⌂')} Back to main menu`,
398
524
  value: 'main',
399
525
  },
400
- ]
526
+ )
401
527
 
402
528
  const { action } = await inquirer.prompt<{ action: string }>([
403
529
  {
@@ -464,7 +590,10 @@ export async function showContainerSubmenu(
464
590
 
465
591
  export async function handleStart(): Promise<void> {
466
592
  const containers = await containerManager.list()
467
- const stopped = containers.filter((c) => c.status !== 'running')
593
+ // Filter for stopped containers, excluding SQLite (no server process to start)
594
+ const stopped = containers.filter(
595
+ (c) => c.status !== 'running' && c.engine !== Engine.SQLite,
596
+ )
468
597
 
469
598
  if (stopped.length === 0) {
470
599
  console.log(warning('All containers are already running'))
@@ -512,7 +641,10 @@ export async function handleStart(): Promise<void> {
512
641
 
513
642
  export async function handleStop(): Promise<void> {
514
643
  const containers = await containerManager.list()
515
- const running = containers.filter((c) => c.status === 'running')
644
+ // Filter for running containers, excluding SQLite (no server process to stop)
645
+ const running = containers.filter(
646
+ (c) => c.status === 'running' && c.engine !== Engine.SQLite,
647
+ )
516
648
 
517
649
  if (running.length === 0) {
518
650
  console.log(warning('No running containers'))
@@ -629,29 +761,41 @@ async function handleEditContainer(
629
761
  return null
630
762
  }
631
763
 
764
+ const isSQLite = config.engine === Engine.SQLite
765
+
632
766
  console.clear()
633
767
  console.log(header(`Edit: ${containerName}`))
634
768
  console.log()
635
769
 
636
- const editChoices = [
770
+ const editChoices: Array<{ name: string; value: string } | inquirer.Separator> = [
637
771
  {
638
772
  name: `Name: ${chalk.white(containerName)}`,
639
773
  value: 'name',
640
774
  },
641
- {
775
+ ]
776
+
777
+ // SQLite: show relocate option with file path; others: show port
778
+ if (isSQLite) {
779
+ editChoices.push({
780
+ name: `Location: ${chalk.white(config.database)}`,
781
+ value: 'relocate',
782
+ })
783
+ } else {
784
+ editChoices.push({
642
785
  name: `Port: ${chalk.white(String(config.port))}`,
643
786
  value: 'port',
644
- },
645
- new inquirer.Separator(),
646
- {
647
- name: `${chalk.blue('←')} Back to container`,
648
- value: 'back',
649
- },
650
- {
651
- name: `${chalk.blue('⌂')} Back to main menu`,
652
- value: 'main',
653
- },
654
- ]
787
+ })
788
+ }
789
+
790
+ editChoices.push(new inquirer.Separator())
791
+ editChoices.push({
792
+ name: `${chalk.blue('←')} Back to container`,
793
+ value: 'back',
794
+ })
795
+ editChoices.push({
796
+ name: `${chalk.blue('⌂')} Back to main menu`,
797
+ value: 'main',
798
+ })
655
799
 
656
800
  const { field } = await inquirer.prompt<{ field: string }>([
657
801
  {
@@ -748,6 +892,150 @@ async function handleEditContainer(
748
892
  return await handleEditContainer(containerName)
749
893
  }
750
894
 
895
+ if (field === 'relocate') {
896
+ const currentFileName = basename(config.database)
897
+
898
+ const { inputPath } = await inquirer.prompt<{ inputPath: string }>([
899
+ {
900
+ type: 'input',
901
+ name: 'inputPath',
902
+ message: 'New file path:',
903
+ default: config.database,
904
+ validate: (input: string) => {
905
+ if (!input) return 'Path is required'
906
+ return true
907
+ },
908
+ },
909
+ ])
910
+
911
+ // Expand ~ to home directory
912
+ let expandedPath = inputPath
913
+ if (inputPath === '~') {
914
+ expandedPath = homedir()
915
+ } else if (inputPath.startsWith('~/')) {
916
+ expandedPath = join(homedir(), inputPath.slice(2))
917
+ }
918
+
919
+ // Convert relative paths to absolute
920
+ if (!expandedPath.startsWith('/')) {
921
+ expandedPath = resolve(process.cwd(), expandedPath)
922
+ }
923
+
924
+ // Check if path looks like a file (has db extension) or directory
925
+ const hasDbExtension = /\.(sqlite3?|db)$/i.test(expandedPath)
926
+
927
+ // Treat as directory if:
928
+ // - ends with /
929
+ // - exists and is a directory
930
+ // - doesn't have a database file extension (assume it's a directory path)
931
+ const isDirectory = expandedPath.endsWith('/') ||
932
+ (existsSync(expandedPath) && statSync(expandedPath).isDirectory()) ||
933
+ !hasDbExtension
934
+
935
+ let finalPath: string
936
+ if (isDirectory) {
937
+ // Remove trailing slash if present, then append filename
938
+ const dirPath = expandedPath.endsWith('/') ? expandedPath.slice(0, -1) : expandedPath
939
+ finalPath = join(dirPath, currentFileName)
940
+ } else {
941
+ finalPath = expandedPath
942
+ }
943
+
944
+ if (finalPath === config.database) {
945
+ console.log(info('Location unchanged'))
946
+ return await handleEditContainer(containerName)
947
+ }
948
+
949
+ // Check if source file exists
950
+ if (!existsSync(config.database)) {
951
+ console.log(error(`Source file not found: ${config.database}`))
952
+ return await handleEditContainer(containerName)
953
+ }
954
+
955
+ // Check if destination already exists
956
+ if (existsSync(finalPath)) {
957
+ console.log(error(`Destination file already exists: ${finalPath}`))
958
+ return await handleEditContainer(containerName)
959
+ }
960
+
961
+ // Check if destination directory exists
962
+ const destDir = dirname(finalPath)
963
+ if (!existsSync(destDir)) {
964
+ console.log(warning(`Directory does not exist: ${destDir}`))
965
+ const { createDir } = await inquirer.prompt<{ createDir: string }>([
966
+ {
967
+ type: 'list',
968
+ name: 'createDir',
969
+ message: 'Create this directory?',
970
+ choices: [
971
+ { name: 'Yes, create it', value: 'yes' },
972
+ { name: 'No, cancel', value: 'no' },
973
+ ],
974
+ },
975
+ ])
976
+
977
+ if (createDir !== 'yes') {
978
+ return await handleEditContainer(containerName)
979
+ }
980
+
981
+ try {
982
+ mkdirSync(destDir, { recursive: true })
983
+ console.log(success(`Created directory: ${destDir}`))
984
+ } catch (err) {
985
+ console.log(error(`Failed to create directory: ${(err as Error).message}`))
986
+ return await handleEditContainer(containerName)
987
+ }
988
+ }
989
+
990
+ const spinner = createSpinner('Moving database file...')
991
+ spinner.start()
992
+
993
+ try {
994
+ // Try rename first (fast, same filesystem)
995
+ try {
996
+ renameSync(config.database, finalPath)
997
+ } catch (renameErr) {
998
+ const e = renameErr as NodeJS.ErrnoException
999
+ // EXDEV = cross-device link, need to copy+delete
1000
+ if (e.code === 'EXDEV') {
1001
+ try {
1002
+ // Copy file preserving mode/permissions
1003
+ copyFileSync(config.database, finalPath)
1004
+ // Only delete source after successful copy
1005
+ unlinkSync(config.database)
1006
+ } catch (copyErr) {
1007
+ // Clean up partial target on failure
1008
+ if (existsSync(finalPath)) {
1009
+ try {
1010
+ unlinkSync(finalPath)
1011
+ } catch {
1012
+ // Ignore cleanup errors
1013
+ }
1014
+ }
1015
+ throw copyErr
1016
+ }
1017
+ } else {
1018
+ throw renameErr
1019
+ }
1020
+ }
1021
+
1022
+ // Update the container config and SQLite registry
1023
+ await containerManager.updateConfig(containerName, { database: finalPath })
1024
+ await sqliteRegistry.update(containerName, { filePath: finalPath })
1025
+ spinner.succeed(`Moved database to ${finalPath}`)
1026
+
1027
+ // Wait for user to see success message before refreshing
1028
+ await pressEnterToContinue()
1029
+ } catch (err) {
1030
+ spinner.fail('Failed to move database file')
1031
+ console.log(error((err as Error).message))
1032
+ await pressEnterToContinue()
1033
+ }
1034
+
1035
+ // Continue editing (will fetch fresh config)
1036
+ return await handleEditContainer(containerName)
1037
+ }
1038
+
751
1039
  return containerName
752
1040
  }
753
1041
 
@@ -755,6 +1043,12 @@ async function handleCloneFromSubmenu(
755
1043
  sourceName: string,
756
1044
  showMainMenu: () => Promise<void>,
757
1045
  ): Promise<void> {
1046
+ const sourceConfig = await containerManager.getConfig(sourceName)
1047
+ if (!sourceConfig) {
1048
+ console.log(error(`Container "${sourceName}" not found`))
1049
+ return
1050
+ }
1051
+
758
1052
  const { targetName } = await inquirer.prompt<{ targetName: string }>([
759
1053
  {
760
1054
  type: 'input',
@@ -771,20 +1065,32 @@ async function handleCloneFromSubmenu(
771
1065
  },
772
1066
  ])
773
1067
 
1068
+ // Check if target container already exists
1069
+ if (await containerManager.exists(targetName, { engine: sourceConfig.engine })) {
1070
+ console.log(error(`Container "${targetName}" already exists`))
1071
+ return
1072
+ }
1073
+
774
1074
  const spinner = createSpinner(`Cloning ${sourceName} to ${targetName}...`)
775
1075
  spinner.start()
776
1076
 
777
- const newConfig = await containerManager.clone(sourceName, targetName)
1077
+ try {
1078
+ const newConfig = await containerManager.clone(sourceName, targetName)
778
1079
 
779
- spinner.succeed(`Cloned "${sourceName}" to "${targetName}"`)
1080
+ spinner.succeed(`Cloned "${sourceName}" to "${targetName}"`)
780
1081
 
781
- const engine = getEngine(newConfig.engine)
782
- const connectionString = engine.getConnectionString(newConfig)
1082
+ const engine = getEngine(newConfig.engine)
1083
+ const connectionString = engine.getConnectionString(newConfig)
783
1084
 
784
- console.log()
785
- console.log(connectionBox(targetName, connectionString, newConfig.port))
1085
+ console.log()
1086
+ console.log(connectionBox(targetName, connectionString, newConfig.port))
786
1087
 
787
- await showContainerSubmenu(targetName, showMainMenu)
1088
+ await showContainerSubmenu(targetName, showMainMenu)
1089
+ } catch (err) {
1090
+ spinner.fail(`Failed to clone "${sourceName}"`)
1091
+ console.log(error((err as Error).message))
1092
+ await pressEnterToContinue()
1093
+ }
788
1094
  }
789
1095
 
790
1096
  async function handleDelete(containerName: string): Promise<void> {