spindb 0.9.2 → 0.9.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.
package/README.md CHANGED
@@ -653,16 +653,13 @@ rm -rf ~/.spindb
653
653
 
654
654
  ## Contributing
655
655
 
656
- See [CLAUDE.md](CLAUDE.md) for development setup and architecture documentation.
656
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, testing, and distribution info.
657
657
 
658
- ### Running Tests
658
+ See [ARCHITECTURE.md](ARCHITECTURE.md) for project architecture and comprehensive CLI command examples.
659
659
 
660
- ```bash
661
- pnpm test # All tests
662
- pnpm test:unit # Unit tests only
663
- pnpm test:pg # PostgreSQL integration
664
- pnpm test:mysql # MySQL integration
665
- ```
660
+ See [CLAUDE.md](CLAUDE.md) for AI-assisted development context.
661
+
662
+ See [ENGINES.md](ENGINES.md) for detailed engine documentation (backup formats, planned engines, etc.).
666
663
 
667
664
  ---
668
665
 
@@ -0,0 +1,108 @@
1
+ import { Command } from 'commander'
2
+ import { existsSync } from 'fs'
3
+ import { resolve, basename } from 'path'
4
+ import chalk from 'chalk'
5
+ import { sqliteRegistry } from '../../engines/sqlite/registry'
6
+ import { containerManager } from '../../core/container-manager'
7
+ import { deriveContainerName } from '../../engines/sqlite/scanner'
8
+ import { uiSuccess, uiError } from '../ui/theme'
9
+
10
+ export const attachCommand = new Command('attach')
11
+ .description('Register an existing SQLite database with SpinDB')
12
+ .argument('<path>', 'Path to SQLite database file')
13
+ .option('-n, --name <name>', 'Container name (defaults to filename)')
14
+ .option('--json', 'Output as JSON')
15
+ .action(
16
+ async (
17
+ path: string,
18
+ options: { name?: string; json?: boolean },
19
+ ): Promise<void> => {
20
+ try {
21
+ const absolutePath = resolve(path)
22
+
23
+ // Verify file exists
24
+ if (!existsSync(absolutePath)) {
25
+ if (options.json) {
26
+ console.log(
27
+ JSON.stringify({ success: false, error: 'File not found' }),
28
+ )
29
+ } else {
30
+ console.error(uiError(`File not found: ${absolutePath}`))
31
+ }
32
+ process.exit(1)
33
+ }
34
+
35
+ // Check if already registered
36
+ if (await sqliteRegistry.isPathRegistered(absolutePath)) {
37
+ const entry = await sqliteRegistry.getByPath(absolutePath)
38
+ if (options.json) {
39
+ console.log(
40
+ JSON.stringify({
41
+ success: false,
42
+ error: 'Already registered',
43
+ existingName: entry?.name,
44
+ }),
45
+ )
46
+ } else {
47
+ console.error(
48
+ uiError(`File is already registered as "${entry?.name}"`),
49
+ )
50
+ }
51
+ process.exit(1)
52
+ }
53
+
54
+ // Determine container name
55
+ const containerName =
56
+ options.name || deriveContainerName(basename(absolutePath))
57
+
58
+ // Check if container name exists
59
+ if (await containerManager.exists(containerName)) {
60
+ if (options.json) {
61
+ console.log(
62
+ JSON.stringify({
63
+ success: false,
64
+ error: 'Container name already exists',
65
+ }),
66
+ )
67
+ } else {
68
+ console.error(uiError(`Container "${containerName}" already exists`))
69
+ }
70
+ process.exit(1)
71
+ }
72
+
73
+ // Register the file
74
+ await sqliteRegistry.add({
75
+ name: containerName,
76
+ filePath: absolutePath,
77
+ created: new Date().toISOString(),
78
+ })
79
+
80
+ if (options.json) {
81
+ console.log(
82
+ JSON.stringify({
83
+ success: true,
84
+ name: containerName,
85
+ filePath: absolutePath,
86
+ }),
87
+ )
88
+ } else {
89
+ console.log(
90
+ uiSuccess(
91
+ `Registered "${basename(absolutePath)}" as "${containerName}"`,
92
+ ),
93
+ )
94
+ console.log()
95
+ console.log(chalk.gray(' Connect with:'))
96
+ console.log(chalk.cyan(` spindb connect ${containerName}`))
97
+ }
98
+ } catch (error) {
99
+ const e = error as Error
100
+ if (options.json) {
101
+ console.log(JSON.stringify({ success: false, error: e.message }))
102
+ } else {
103
+ console.error(uiError(e.message))
104
+ }
105
+ process.exit(1)
106
+ }
107
+ },
108
+ )
@@ -100,6 +100,12 @@ async function createSqliteContainer(
100
100
  restoreSpinner.succeed('Backup restored successfully')
101
101
  } catch (error) {
102
102
  restoreSpinner.fail('Failed to restore backup')
103
+ // Clean up the created container on restore failure
104
+ try {
105
+ await containerManager.delete(containerName, { force: true })
106
+ } catch {
107
+ // Ignore cleanup errors - still throw the original restore error
108
+ }
103
109
  throw error
104
110
  }
105
111
  }
@@ -0,0 +1,100 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import { sqliteRegistry } from '../../engines/sqlite/registry'
4
+ import { containerManager } from '../../core/container-manager'
5
+ import { promptConfirm } from '../ui/prompts'
6
+ import { uiSuccess, uiError, uiWarning } from '../ui/theme'
7
+ import { Engine } from '../../types'
8
+
9
+ export const detachCommand = new Command('detach')
10
+ .description('Unregister a SQLite database from SpinDB (keeps file on disk)')
11
+ .argument('<name>', 'Container name')
12
+ .option('-f, --force', 'Skip confirmation prompt')
13
+ .option('--json', 'Output as JSON')
14
+ .action(
15
+ async (
16
+ name: string,
17
+ options: { force?: boolean; json?: boolean },
18
+ ): Promise<void> => {
19
+ try {
20
+ // Get container config
21
+ const config = await containerManager.getConfig(name)
22
+
23
+ if (!config) {
24
+ if (options.json) {
25
+ console.log(
26
+ JSON.stringify({ success: false, error: 'Container not found' }),
27
+ )
28
+ } else {
29
+ console.error(uiError(`Container "${name}" not found`))
30
+ }
31
+ process.exit(1)
32
+ }
33
+
34
+ // Verify it's a SQLite container
35
+ if (config.engine !== Engine.SQLite) {
36
+ if (options.json) {
37
+ console.log(
38
+ JSON.stringify({
39
+ success: false,
40
+ error:
41
+ 'Not a SQLite container. Use "spindb delete" for server databases.',
42
+ }),
43
+ )
44
+ } else {
45
+ console.error(uiError(`"${name}" is not a SQLite container`))
46
+ console.log(
47
+ chalk.gray(
48
+ ' Use "spindb delete" for server databases (PostgreSQL, MySQL)',
49
+ ),
50
+ )
51
+ }
52
+ process.exit(1)
53
+ }
54
+
55
+ // Confirm unless --force
56
+ if (!options.force && !options.json) {
57
+ const confirmed = await promptConfirm(
58
+ `Detach "${name}" from SpinDB? (file will be kept on disk)`,
59
+ true,
60
+ )
61
+ if (!confirmed) {
62
+ console.log(uiWarning('Cancelled'))
63
+ return
64
+ }
65
+ }
66
+
67
+ const entry = await sqliteRegistry.get(name)
68
+ const filePath = entry?.filePath
69
+
70
+ // Remove from registry only (not the file)
71
+ await sqliteRegistry.remove(name)
72
+
73
+ if (options.json) {
74
+ console.log(
75
+ JSON.stringify({
76
+ success: true,
77
+ name,
78
+ filePath,
79
+ }),
80
+ )
81
+ } else {
82
+ console.log(uiSuccess(`Detached "${name}" from SpinDB`))
83
+ if (filePath) {
84
+ console.log(chalk.gray(` File remains at: ${filePath}`))
85
+ }
86
+ console.log()
87
+ console.log(chalk.gray(' Re-attach with:'))
88
+ console.log(chalk.cyan(` spindb attach ${filePath || '<path>'}`))
89
+ }
90
+ } catch (error) {
91
+ const e = error as Error
92
+ if (options.json) {
93
+ console.log(JSON.stringify({ success: false, error: e.message }))
94
+ } else {
95
+ console.error(uiError(e.message))
96
+ }
97
+ process.exit(1)
98
+ }
99
+ },
100
+ )
@@ -141,8 +141,9 @@ async function checkContainers(): Promise<HealthCheckResult> {
141
141
  async function checkSqliteRegistry(): Promise<HealthCheckResult> {
142
142
  try {
143
143
  const entries = await sqliteRegistry.list()
144
+ const ignoredFolders = await sqliteRegistry.listIgnoredFolders()
144
145
 
145
- if (entries.length === 0) {
146
+ if (entries.length === 0 && ignoredFolders.length === 0) {
146
147
  return {
147
148
  name: 'SQLite Registry',
148
149
  status: 'ok',
@@ -153,11 +154,18 @@ async function checkSqliteRegistry(): Promise<HealthCheckResult> {
153
154
  const orphans = await sqliteRegistry.findOrphans()
154
155
 
155
156
  if (orphans.length > 0) {
157
+ const details = [
158
+ ...orphans.map((o) => `"${o.name}" → ${o.filePath}`),
159
+ ...(ignoredFolders.length > 0
160
+ ? [`${ignoredFolders.length} folder(s) ignored`]
161
+ : []),
162
+ ]
163
+
156
164
  return {
157
165
  name: 'SQLite Registry',
158
166
  status: 'warning',
159
167
  message: `${orphans.length} orphaned entr${orphans.length === 1 ? 'y' : 'ies'} found`,
160
- details: orphans.map((o) => `"${o.name}" → ${o.filePath}`),
168
+ details,
161
169
  action: {
162
170
  label: 'Remove orphaned entries from registry',
163
171
  handler: async () => {
@@ -168,10 +176,16 @@ async function checkSqliteRegistry(): Promise<HealthCheckResult> {
168
176
  }
169
177
  }
170
178
 
179
+ const details = [`${entries.length} database(s) registered, all files exist`]
180
+ if (ignoredFolders.length > 0) {
181
+ details.push(`${ignoredFolders.length} folder(s) ignored`)
182
+ }
183
+
171
184
  return {
172
185
  name: 'SQLite Registry',
173
186
  status: 'ok',
174
187
  message: `${entries.length} database(s) registered, all files exist`,
188
+ details: ignoredFolders.length > 0 ? details : undefined,
175
189
  }
176
190
  } catch (error) {
177
191
  return {
@@ -521,6 +521,11 @@ export const editCommand = new Command('edit')
521
521
  spinner.start()
522
522
 
523
523
  try {
524
+ // Track if we need to delete source file after registry update
525
+ // (for cross-device moves where rename doesn't work)
526
+ let needsSourceCleanup = false
527
+ const originalPath = config.database
528
+
524
529
  // Try rename first (fast, same filesystem)
525
530
  try {
526
531
  renameSync(config.database, newPath)
@@ -531,8 +536,8 @@ export const editCommand = new Command('edit')
531
536
  try {
532
537
  // Copy file preserving mode/permissions
533
538
  copyFileSync(config.database, newPath)
534
- // Only delete source after successful copy
535
- unlinkSync(config.database)
539
+ // Don't delete source yet - wait for registry update to succeed
540
+ needsSourceCleanup = true
536
541
  } catch (copyErr) {
537
542
  // Clean up partial target on failure
538
543
  if (existsSync(newPath)) {
@@ -555,6 +560,11 @@ export const editCommand = new Command('edit')
555
560
  })
556
561
  await sqliteRegistry.update(containerName, { filePath: newPath })
557
562
 
563
+ // Now safe to delete source file for cross-device moves
564
+ if (needsSourceCleanup && existsSync(originalPath)) {
565
+ unlinkSync(originalPath)
566
+ }
567
+
558
568
  spinner.succeed(`Database relocated to ${newPath}`)
559
569
  } catch (error) {
560
570
  spinner.fail('Failed to relocate database')
@@ -1,12 +1,18 @@
1
1
  import { Command } from 'commander'
2
2
  import chalk from 'chalk'
3
+ import inquirer from 'inquirer'
4
+ import { dirname, basename } from 'path'
3
5
  import { containerManager } from '../../core/container-manager'
4
6
  import { getEngine } from '../../engines'
5
7
  import { uiInfo, uiError, formatBytes } from '../ui/theme'
6
8
  import { getEngineIcon } from '../constants'
7
9
  import { Engine } from '../../types'
8
- import { basename } from 'path'
9
10
  import type { ContainerConfig } from '../../types'
11
+ import { sqliteRegistry } from '../../engines/sqlite/registry'
12
+ import {
13
+ scanForUnregisteredSqliteFiles,
14
+ deriveContainerName,
15
+ } from '../../engines/sqlite/scanner'
10
16
 
11
17
  /**
12
18
  * Pad string to width, accounting for emoji taking 2 display columns
@@ -17,6 +23,88 @@ function padWithEmoji(str: string, width: number): string {
17
23
  return str.padEnd(width + emojiCount)
18
24
  }
19
25
 
26
+ /**
27
+ * Prompt user about unregistered SQLite files in CWD
28
+ * Returns true if user registered any files (refresh needed)
29
+ */
30
+ async function promptUnregisteredFiles(): Promise<boolean> {
31
+ const unregistered = await scanForUnregisteredSqliteFiles()
32
+
33
+ if (unregistered.length === 0) {
34
+ return false
35
+ }
36
+
37
+ let anyRegistered = false
38
+
39
+ for (let i = 0; i < unregistered.length; i++) {
40
+ const file = unregistered[i]
41
+ const prompt =
42
+ unregistered.length > 1 ? `[${i + 1} of ${unregistered.length}] ` : ''
43
+
44
+ const { action } = await inquirer.prompt<{ action: string }>([
45
+ {
46
+ type: 'list',
47
+ name: 'action',
48
+ message: `${prompt}Unregistered SQLite database "${file.fileName}" found in current directory. Register with SpinDB?`,
49
+ choices: [
50
+ { name: 'Yes', value: 'yes' },
51
+ { name: 'No', value: 'no' },
52
+ { name: "No - don't ask again for this folder", value: 'ignore' },
53
+ ],
54
+ },
55
+ ])
56
+
57
+ if (action === 'yes') {
58
+ const suggestedName = deriveContainerName(file.fileName)
59
+ const { containerName } = await inquirer.prompt<{
60
+ containerName: string
61
+ }>([
62
+ {
63
+ type: 'input',
64
+ name: 'containerName',
65
+ message: 'Container name:',
66
+ default: suggestedName,
67
+ validate: (input: string) => {
68
+ if (!input) return 'Name is required'
69
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(input)) {
70
+ return 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores'
71
+ }
72
+ return true
73
+ },
74
+ },
75
+ ])
76
+
77
+ // Check if name already exists
78
+ if (await sqliteRegistry.exists(containerName)) {
79
+ console.log(
80
+ chalk.yellow(` Container "${containerName}" already exists. Skipping.`),
81
+ )
82
+ continue
83
+ }
84
+
85
+ await sqliteRegistry.add({
86
+ name: containerName,
87
+ filePath: file.absolutePath,
88
+ created: new Date().toISOString(),
89
+ })
90
+ console.log(
91
+ chalk.green(` Registered "${file.fileName}" as "${containerName}"`),
92
+ )
93
+ anyRegistered = true
94
+ } else if (action === 'ignore') {
95
+ await sqliteRegistry.addIgnoreFolder(dirname(file.absolutePath))
96
+ console.log(chalk.gray(' Folder will be ignored in future scans.'))
97
+ break // Exit early
98
+ }
99
+ }
100
+
101
+ if (anyRegistered) {
102
+ console.log() // Add spacing before list
103
+ }
104
+
105
+ return anyRegistered
106
+ }
107
+
20
108
  async function getContainerSize(
21
109
  container: ContainerConfig,
22
110
  ): Promise<number | null> {
@@ -46,8 +134,14 @@ export const listCommand = new Command('list')
46
134
  .alias('ls')
47
135
  .description('List all containers')
48
136
  .option('--json', 'Output as JSON')
49
- .action(async (options: { json?: boolean }) => {
137
+ .option('--no-scan', 'Skip scanning for unregistered SQLite files in CWD')
138
+ .action(async (options: { json?: boolean; scan?: boolean }) => {
50
139
  try {
140
+ // Scan for unregistered SQLite files in CWD (unless JSON mode or --no-scan)
141
+ if (!options.json && options.scan !== false) {
142
+ await promptUnregisteredFiles()
143
+ }
144
+
51
145
  const containers = await containerManager.list()
52
146
 
53
147
  if (options.json) {
@@ -200,7 +200,9 @@ export async function handleCreate(): Promise<void> {
200
200
 
201
201
  startSpinner.succeed(`${dbEngine.displayName} started`)
202
202
 
203
- if (config && database !== 'postgres') {
203
+ // Skip creating 'postgres' database for PostgreSQL - it's created by initdb
204
+ // For other engines (MySQL, SQLite), allow creating a database named 'postgres'
205
+ if (config && !(config.engine === 'postgresql' && database === 'postgres')) {
204
206
  const dbSpinner = createSpinner(`Creating database "${database}"...`)
205
207
  dbSpinner.start()
206
208
 
@@ -532,6 +534,14 @@ export async function showContainerSubmenu(
532
534
  })
533
535
  }
534
536
 
537
+ // Detach - only for SQLite (unregisters without deleting file)
538
+ if (isSQLite) {
539
+ actionChoices.push({
540
+ name: `${chalk.yellow('⊘')} Detach from SpinDB`,
541
+ value: 'detach',
542
+ })
543
+ }
544
+
535
545
  // Delete container - SQLite can always delete, server databases must be stopped
536
546
  const canDelete = isSQLite ? true : !isRunning
537
547
  actionChoices.push({
@@ -606,6 +616,9 @@ export async function showContainerSubmenu(
606
616
  await handleCopyConnectionString(containerName)
607
617
  await showContainerSubmenu(containerName, showMainMenu)
608
618
  return
619
+ case 'detach':
620
+ await handleDetachContainer(containerName, showMainMenu)
621
+ return // Return to list after detach
609
622
  case 'delete':
610
623
  await handleDelete(containerName)
611
624
  return // Don't show submenu again after delete
@@ -1135,6 +1148,36 @@ async function handleCloneFromSubmenu(
1135
1148
  }
1136
1149
  }
1137
1150
 
1151
+ async function handleDetachContainer(
1152
+ containerName: string,
1153
+ showMainMenu: () => Promise<void>,
1154
+ ): Promise<void> {
1155
+ const confirmed = await promptConfirm(
1156
+ `Detach "${containerName}" from SpinDB? (file will be kept on disk)`,
1157
+ true,
1158
+ )
1159
+
1160
+ if (!confirmed) {
1161
+ console.log(uiWarning('Cancelled'))
1162
+ await pressEnterToContinue()
1163
+ await showContainerSubmenu(containerName, showMainMenu)
1164
+ return
1165
+ }
1166
+
1167
+ const entry = await sqliteRegistry.get(containerName)
1168
+ await sqliteRegistry.remove(containerName)
1169
+
1170
+ console.log(uiSuccess(`Detached "${containerName}" from SpinDB`))
1171
+ if (entry?.filePath) {
1172
+ console.log(chalk.gray(` File remains at: ${entry.filePath}`))
1173
+ console.log()
1174
+ console.log(chalk.gray(' Re-attach with:'))
1175
+ console.log(chalk.cyan(` spindb attach ${entry.filePath}`))
1176
+ }
1177
+ await pressEnterToContinue()
1178
+ await handleList(showMainMenu)
1179
+ }
1180
+
1138
1181
  async function handleDelete(containerName: string): Promise<void> {
1139
1182
  const config = await containerManager.getConfig(containerName)
1140
1183
  if (!config) {
@@ -0,0 +1,247 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import { existsSync } from 'fs'
4
+ import { resolve, basename } from 'path'
5
+ import { sqliteRegistry } from '../../engines/sqlite/registry'
6
+ import {
7
+ scanForUnregisteredSqliteFiles,
8
+ deriveContainerName,
9
+ } from '../../engines/sqlite/scanner'
10
+ import { containerManager } from '../../core/container-manager'
11
+ import { uiSuccess, uiError, uiInfo } from '../ui/theme'
12
+
13
+ export const sqliteCommand = new Command('sqlite').description(
14
+ 'SQLite-specific operations',
15
+ )
16
+
17
+ // sqlite scan
18
+ sqliteCommand
19
+ .command('scan')
20
+ .description('Scan folder for unregistered SQLite files')
21
+ .option(
22
+ '-p, --path <dir>',
23
+ 'Directory to scan (default: current directory)',
24
+ )
25
+ .option('--json', 'Output as JSON')
26
+ .action(async (options: { path?: string; json?: boolean }): Promise<void> => {
27
+ const dir = options.path ? resolve(options.path) : process.cwd()
28
+
29
+ if (!existsSync(dir)) {
30
+ if (options.json) {
31
+ console.log(
32
+ JSON.stringify({ error: 'Directory not found', directory: dir }),
33
+ )
34
+ } else {
35
+ console.error(uiError(`Directory not found: ${dir}`))
36
+ }
37
+ process.exit(1)
38
+ }
39
+
40
+ const unregistered = await scanForUnregisteredSqliteFiles(dir)
41
+
42
+ if (options.json) {
43
+ console.log(JSON.stringify({ directory: dir, files: unregistered }))
44
+ return
45
+ }
46
+
47
+ if (unregistered.length === 0) {
48
+ console.log(uiInfo(`No unregistered SQLite files found in ${dir}`))
49
+ return
50
+ }
51
+
52
+ console.log(
53
+ chalk.cyan(`Found ${unregistered.length} unregistered SQLite file(s):`),
54
+ )
55
+ for (const file of unregistered) {
56
+ console.log(chalk.gray(` ${file.fileName}`))
57
+ }
58
+ console.log()
59
+ console.log(chalk.gray(' Register with: spindb attach <path>'))
60
+ })
61
+
62
+ // sqlite ignore
63
+ sqliteCommand
64
+ .command('ignore')
65
+ .description('Add folder to ignore list for CWD scanning')
66
+ .argument('[folder]', 'Folder path to ignore (default: current directory)')
67
+ .option('--json', 'Output as JSON')
68
+ .action(
69
+ async (folder: string | undefined, options: { json?: boolean }): Promise<void> => {
70
+ const absolutePath = resolve(folder || process.cwd())
71
+ await sqliteRegistry.addIgnoreFolder(absolutePath)
72
+
73
+ if (options.json) {
74
+ console.log(JSON.stringify({ success: true, folder: absolutePath }))
75
+ } else {
76
+ console.log(uiSuccess(`Added to ignore list: ${absolutePath}`))
77
+ }
78
+ },
79
+ )
80
+
81
+ // sqlite unignore
82
+ sqliteCommand
83
+ .command('unignore')
84
+ .description('Remove folder from ignore list')
85
+ .argument('[folder]', 'Folder path to unignore (default: current directory)')
86
+ .option('--json', 'Output as JSON')
87
+ .action(
88
+ async (folder: string | undefined, options: { json?: boolean }): Promise<void> => {
89
+ const absolutePath = resolve(folder || process.cwd())
90
+ const removed = await sqliteRegistry.removeIgnoreFolder(absolutePath)
91
+
92
+ if (options.json) {
93
+ console.log(JSON.stringify({ success: removed, folder: absolutePath }))
94
+ } else {
95
+ if (removed) {
96
+ console.log(uiSuccess(`Removed from ignore list: ${absolutePath}`))
97
+ } else {
98
+ console.log(uiInfo(`Folder was not in ignore list: ${absolutePath}`))
99
+ }
100
+ }
101
+ },
102
+ )
103
+
104
+ // sqlite ignored (list ignored folders)
105
+ sqliteCommand
106
+ .command('ignored')
107
+ .description('List ignored folders')
108
+ .option('--json', 'Output as JSON')
109
+ .action(async (options: { json?: boolean }): Promise<void> => {
110
+ const folders = await sqliteRegistry.listIgnoredFolders()
111
+
112
+ if (options.json) {
113
+ console.log(JSON.stringify({ folders }))
114
+ return
115
+ }
116
+
117
+ if (folders.length === 0) {
118
+ console.log(uiInfo('No folders are being ignored'))
119
+ return
120
+ }
121
+
122
+ console.log(chalk.cyan('Ignored folders:'))
123
+ for (const folder of folders) {
124
+ console.log(chalk.gray(` ${folder}`))
125
+ }
126
+ })
127
+
128
+ // sqlite attach (alias to top-level attach)
129
+ sqliteCommand
130
+ .command('attach')
131
+ .description('Register an existing SQLite database (alias for "spindb attach")')
132
+ .argument('<path>', 'Path to SQLite database file')
133
+ .option('-n, --name <name>', 'Container name')
134
+ .option('--json', 'Output as JSON')
135
+ .action(
136
+ async (
137
+ path: string,
138
+ options: { name?: string; json?: boolean },
139
+ ): Promise<void> => {
140
+ try {
141
+ const absolutePath = resolve(path)
142
+
143
+ if (!existsSync(absolutePath)) {
144
+ if (options.json) {
145
+ console.log(
146
+ JSON.stringify({ success: false, error: 'File not found' }),
147
+ )
148
+ } else {
149
+ console.error(uiError(`File not found: ${absolutePath}`))
150
+ }
151
+ process.exit(1)
152
+ }
153
+
154
+ if (await sqliteRegistry.isPathRegistered(absolutePath)) {
155
+ const entry = await sqliteRegistry.getByPath(absolutePath)
156
+ if (options.json) {
157
+ console.log(
158
+ JSON.stringify({
159
+ success: false,
160
+ error: 'Already registered',
161
+ existingName: entry?.name,
162
+ }),
163
+ )
164
+ } else {
165
+ console.error(
166
+ uiError(`File is already registered as "${entry?.name}"`),
167
+ )
168
+ }
169
+ process.exit(1)
170
+ }
171
+
172
+ const containerName =
173
+ options.name || deriveContainerName(basename(absolutePath))
174
+
175
+ if (await containerManager.exists(containerName)) {
176
+ if (options.json) {
177
+ console.log(
178
+ JSON.stringify({
179
+ success: false,
180
+ error: 'Container name already exists',
181
+ }),
182
+ )
183
+ } else {
184
+ console.error(uiError(`Container "${containerName}" already exists`))
185
+ }
186
+ process.exit(1)
187
+ }
188
+
189
+ await sqliteRegistry.add({
190
+ name: containerName,
191
+ filePath: absolutePath,
192
+ created: new Date().toISOString(),
193
+ })
194
+
195
+ if (options.json) {
196
+ console.log(
197
+ JSON.stringify({
198
+ success: true,
199
+ name: containerName,
200
+ filePath: absolutePath,
201
+ }),
202
+ )
203
+ } else {
204
+ console.log(
205
+ uiSuccess(
206
+ `Registered "${basename(absolutePath)}" as "${containerName}"`,
207
+ ),
208
+ )
209
+ console.log()
210
+ console.log(chalk.gray(' Connect with:'))
211
+ console.log(chalk.cyan(` spindb connect ${containerName}`))
212
+ }
213
+ } catch (error) {
214
+ const e = error as Error
215
+ if (options.json) {
216
+ console.log(JSON.stringify({ success: false, error: e.message }))
217
+ } else {
218
+ console.error(uiError(e.message))
219
+ }
220
+ process.exit(1)
221
+ }
222
+ },
223
+ )
224
+
225
+ // sqlite detach (alias to top-level detach)
226
+ sqliteCommand
227
+ .command('detach')
228
+ .description('Unregister a SQLite database (alias for "spindb detach")')
229
+ .argument('<name>', 'Container name')
230
+ .option('-f, --force', 'Skip confirmation')
231
+ .option('--json', 'Output as JSON')
232
+ .action(
233
+ async (
234
+ name: string,
235
+ options: { force?: boolean; json?: boolean },
236
+ ): Promise<void> => {
237
+ // Import dynamically to avoid circular dependency issues
238
+ const { detachCommand } = await import('./detach')
239
+
240
+ // Build args array
241
+ const args = ['node', 'detach', name]
242
+ if (options.force) args.push('-f')
243
+ if (options.json) args.push('--json')
244
+
245
+ await detachCommand.parseAsync(args, { from: 'node' })
246
+ },
247
+ )
package/cli/index.ts CHANGED
@@ -25,6 +25,9 @@ import { versionCommand } from './commands/version'
25
25
  import { runCommand } from './commands/run'
26
26
  import { logsCommand } from './commands/logs'
27
27
  import { doctorCommand } from './commands/doctor'
28
+ import { attachCommand } from './commands/attach'
29
+ import { detachCommand } from './commands/detach'
30
+ import { sqliteCommand } from './commands/sqlite'
28
31
  import { updateManager } from '../core/update-manager'
29
32
 
30
33
  /**
@@ -125,6 +128,9 @@ export async function run(): Promise<void> {
125
128
  program.addCommand(runCommand)
126
129
  program.addCommand(logsCommand)
127
130
  program.addCommand(doctorCommand)
131
+ program.addCommand(attachCommand)
132
+ program.addCommand(detachCommand)
133
+ program.addCommand(sqliteCommand)
128
134
 
129
135
  // If no arguments provided, show interactive menu
130
136
  if (process.argv.length <= 2) {
package/config/paths.ts CHANGED
@@ -114,12 +114,4 @@ export const paths = {
114
114
  getEngineContainersPath(engine: string): string {
115
115
  return join(this.containers, engine)
116
116
  },
117
-
118
- /**
119
- * Get path for SQLite registry file
120
- * SQLite uses a registry (not container directories) since databases are stored externally
121
- */
122
- getSqliteRegistryPath(): string {
123
- return join(this.root, 'sqlite-registry.json')
124
- },
125
117
  }
@@ -10,6 +10,7 @@ import type {
10
10
  BinaryConfig,
11
11
  BinaryTool,
12
12
  BinarySource,
13
+ SQLiteEngineRegistry,
13
14
  } from '../types'
14
15
 
15
16
  const execAsync = promisify(exec)
@@ -349,6 +350,37 @@ export class ConfigManager {
349
350
  config.binaries = {}
350
351
  await this.save()
351
352
  }
353
+
354
+ // ============================================================
355
+ // SQLite Registry Methods
356
+ // ============================================================
357
+
358
+ /**
359
+ * Get the SQLite registry from config
360
+ * Returns empty registry if none exists
361
+ */
362
+ async getSqliteRegistry(): Promise<SQLiteEngineRegistry> {
363
+ const config = await this.load()
364
+ return (
365
+ config.registry?.sqlite ?? {
366
+ version: 1,
367
+ entries: [],
368
+ ignoreFolders: {},
369
+ }
370
+ )
371
+ }
372
+
373
+ /**
374
+ * Save the SQLite registry to config
375
+ */
376
+ async saveSqliteRegistry(registry: SQLiteEngineRegistry): Promise<void> {
377
+ const config = await this.load()
378
+ if (!config.registry) {
379
+ config.registry = {}
380
+ }
381
+ config.registry.sqlite = registry
382
+ await this.save()
383
+ }
352
384
  }
353
385
 
354
386
  export const configManager = new ConfigManager()
@@ -98,12 +98,20 @@ async function createSqlBackup(
98
98
 
99
99
  proc.on('close', async (code) => {
100
100
  if (code === 0) {
101
- const stats = await stat(outputPath)
102
- safeResolve({
103
- path: outputPath,
104
- format: 'sql',
105
- size: stats.size,
106
- })
101
+ try {
102
+ const stats = await stat(outputPath)
103
+ safeResolve({
104
+ path: outputPath,
105
+ format: 'sql',
106
+ size: stats.size,
107
+ })
108
+ } catch (error) {
109
+ safeReject(
110
+ new Error(
111
+ `Backup completed but failed to read output file: ${error instanceof Error ? error.message : String(error)}`,
112
+ ),
113
+ )
114
+ }
107
115
  } else {
108
116
  const errorMessage = stderr || `mysqldump exited with code ${code}`
109
117
  safeReject(new Error(errorMessage))
@@ -164,13 +172,29 @@ async function createCompressedBackup(
164
172
  })
165
173
  })
166
174
 
167
- // Wait for both pipeline AND process exit to succeed
168
- await Promise.all([pipelinePromise, exitPromise])
175
+ // Wait for both pipeline AND process exit to complete
176
+ // Use allSettled to handle case where both reject (avoids unhandled rejection)
177
+ const results = await Promise.allSettled([pipelinePromise, exitPromise])
169
178
 
170
- const stats = await stat(outputPath)
171
- return {
172
- path: outputPath,
173
- format: 'compressed',
174
- size: stats.size,
179
+ // Check for any rejections - prefer exitPromise error as it has more context
180
+ const [pipelineResult, exitResult] = results
181
+ if (exitResult.status === 'rejected') {
182
+ throw exitResult.reason
183
+ }
184
+ if (pipelineResult.status === 'rejected') {
185
+ throw pipelineResult.reason
186
+ }
187
+
188
+ try {
189
+ const stats = await stat(outputPath)
190
+ return {
191
+ path: outputPath,
192
+ format: 'compressed',
193
+ size: stats.size,
194
+ }
195
+ } catch (error) {
196
+ throw new Error(
197
+ `Backup completed but failed to read output file: ${error instanceof Error ? error.message : String(error)}`,
198
+ )
175
199
  }
176
200
  }
@@ -446,15 +446,17 @@ export class SQLiteEngine extends BaseEngine {
446
446
  throw new Error('sqlite3 not found')
447
447
  }
448
448
 
449
- // Pipe .dump output to file (avoids shell injection)
450
- await this.dumpToFile(sqlite3, filePath, outputPath)
449
+ try {
450
+ // Pipe .dump output to file (avoids shell injection)
451
+ await this.dumpToFile(sqlite3, filePath, outputPath)
451
452
 
452
- // Clean up temp file if we downloaded it
453
- if (tempFile && existsSync(tempFile)) {
454
- await unlink(tempFile)
453
+ return { filePath: outputPath }
454
+ } finally {
455
+ // Clean up temp file if we downloaded it (even on error)
456
+ if (tempFile && existsSync(tempFile)) {
457
+ await unlink(tempFile)
458
+ }
455
459
  }
456
-
457
- return { filePath: outputPath }
458
460
  }
459
461
 
460
462
  /**
@@ -4,57 +4,38 @@
4
4
  * Unlike PostgreSQL/MySQL which store containers in ~/.spindb/containers/,
5
5
  * SQLite databases are stored in user project directories. This registry
6
6
  * tracks the file paths of all SQLite databases managed by SpinDB.
7
+ *
8
+ * The registry is now stored in ~/.spindb/config.json under registry.sqlite
7
9
  */
8
10
 
9
11
  import { existsSync } from 'fs'
10
- import { readFile, writeFile, mkdir } from 'fs/promises'
11
- import { dirname } from 'path'
12
- import { paths } from '../../config/paths'
13
- import type { SQLiteRegistry, SQLiteRegistryEntry } from '../../types'
12
+ import { configManager } from '../../core/config-manager'
13
+ import type { SQLiteEngineRegistry, SQLiteRegistryEntry } from '../../types'
14
14
 
15
15
  /**
16
16
  * SQLite Registry Manager
17
- * Manages the JSON registry that tracks external SQLite database files
17
+ * Manages the registry that tracks external SQLite database files
18
+ * Data is stored in config.json under registry.sqlite
18
19
  */
19
20
  class SQLiteRegistryManager {
20
- private registryPath: string
21
-
22
- constructor() {
23
- this.registryPath = paths.getSqliteRegistryPath()
24
- }
25
-
26
21
  /**
27
- * Load the registry from disk
28
- * Returns an empty registry if the file doesn't exist
22
+ * Load the registry from config.json
23
+ * Returns an empty registry if none exists
29
24
  */
30
- async load(): Promise<SQLiteRegistry> {
31
- if (!existsSync(this.registryPath)) {
32
- return { version: 1, entries: [] }
33
- }
34
- try {
35
- const content = await readFile(this.registryPath, 'utf8')
36
- return JSON.parse(content) as SQLiteRegistry
37
- } catch {
38
- // If file is corrupted, return empty registry
39
- return { version: 1, entries: [] }
40
- }
25
+ async load(): Promise<SQLiteEngineRegistry> {
26
+ return configManager.getSqliteRegistry()
41
27
  }
42
28
 
43
29
  /**
44
- * Save the registry to disk
45
- * Creates the parent directory if it doesn't exist
30
+ * Save the registry to config.json
46
31
  */
47
- async save(registry: SQLiteRegistry): Promise<void> {
48
- const dir = dirname(this.registryPath)
49
- if (!existsSync(dir)) {
50
- await mkdir(dir, { recursive: true })
51
- }
52
- await writeFile(this.registryPath, JSON.stringify(registry, null, 2))
32
+ async save(registry: SQLiteEngineRegistry): Promise<void> {
33
+ await configManager.saveSqliteRegistry(registry)
53
34
  }
54
35
 
55
36
  /**
56
37
  * Add a new entry to the registry
57
- * @throws Error if a container with the same name already exists
38
+ * @throws Error if a container with the same name or file path already exists
58
39
  */
59
40
  async add(entry: SQLiteRegistryEntry): Promise<void> {
60
41
  const registry = await this.load()
@@ -64,6 +45,13 @@ class SQLiteRegistryManager {
64
45
  throw new Error(`SQLite container "${entry.name}" already exists`)
65
46
  }
66
47
 
48
+ // Check for duplicate file path
49
+ if (registry.entries.some((e) => e.filePath === entry.filePath)) {
50
+ throw new Error(
51
+ `SQLite container for path "${entry.filePath}" already exists`,
52
+ )
53
+ }
54
+
67
55
  registry.entries.push(entry)
68
56
  await this.save(registry)
69
57
  }
@@ -179,6 +167,49 @@ class SQLiteRegistryManager {
179
167
  const registry = await this.load()
180
168
  return registry.entries.find((e) => e.filePath === filePath) || null
181
169
  }
170
+
171
+ // ============================================================
172
+ // Folder Ignore Methods
173
+ // ============================================================
174
+
175
+ /**
176
+ * Check if a folder is in the ignore list
177
+ */
178
+ async isFolderIgnored(folderPath: string): Promise<boolean> {
179
+ const registry = await this.load()
180
+ return folderPath in registry.ignoreFolders
181
+ }
182
+
183
+ /**
184
+ * Add a folder to the ignore list
185
+ */
186
+ async addIgnoreFolder(folderPath: string): Promise<void> {
187
+ const registry = await this.load()
188
+ registry.ignoreFolders[folderPath] = true
189
+ await this.save(registry)
190
+ }
191
+
192
+ /**
193
+ * Remove a folder from the ignore list
194
+ * Returns true if the folder was in the list and removed, false otherwise
195
+ */
196
+ async removeIgnoreFolder(folderPath: string): Promise<boolean> {
197
+ const registry = await this.load()
198
+ if (folderPath in registry.ignoreFolders) {
199
+ delete registry.ignoreFolders[folderPath]
200
+ await this.save(registry)
201
+ return true
202
+ }
203
+ return false
204
+ }
205
+
206
+ /**
207
+ * List all ignored folders
208
+ */
209
+ async listIgnoredFolders(): Promise<string[]> {
210
+ const registry = await this.load()
211
+ return Object.keys(registry.ignoreFolders)
212
+ }
182
213
  }
183
214
 
184
215
  // Export singleton instance
@@ -0,0 +1,99 @@
1
+ /**
2
+ * SQLite Scanner
3
+ *
4
+ * Scans directories for unregistered SQLite database files.
5
+ * Used to detect SQLite databases in the current working directory
6
+ * that are not yet registered with SpinDB.
7
+ */
8
+
9
+ import { readdir } from 'fs/promises'
10
+ import { existsSync } from 'fs'
11
+ import { resolve } from 'path'
12
+ import { sqliteRegistry } from './registry'
13
+
14
+ export type UnregisteredFile = {
15
+ fileName: string
16
+ absolutePath: string
17
+ }
18
+
19
+ /**
20
+ * Scan a directory for unregistered SQLite files
21
+ * Returns files with .sqlite, .sqlite3, or .db extensions
22
+ * that are not already in the registry
23
+ *
24
+ * @param directory Directory to scan (defaults to CWD)
25
+ * @returns Array of unregistered SQLite files
26
+ */
27
+ export async function scanForUnregisteredSqliteFiles(
28
+ directory: string = process.cwd(),
29
+ ): Promise<UnregisteredFile[]> {
30
+ const absoluteDir = resolve(directory)
31
+
32
+ // Check if folder is ignored
33
+ if (await sqliteRegistry.isFolderIgnored(absoluteDir)) {
34
+ return []
35
+ }
36
+
37
+ // Check if directory exists
38
+ if (!existsSync(absoluteDir)) {
39
+ return []
40
+ }
41
+
42
+ try {
43
+ // Get all files in directory
44
+ const entries = await readdir(absoluteDir, { withFileTypes: true })
45
+
46
+ // Filter for SQLite files
47
+ const sqliteFiles = entries
48
+ .filter((e) => e.isFile())
49
+ .filter((e) => /\.(sqlite3?|db)$/i.test(e.name))
50
+ .map((e) => ({
51
+ fileName: e.name,
52
+ absolutePath: resolve(absoluteDir, e.name),
53
+ }))
54
+
55
+ // Filter out already registered files
56
+ const unregistered: UnregisteredFile[] = []
57
+ for (const file of sqliteFiles) {
58
+ if (!(await sqliteRegistry.isPathRegistered(file.absolutePath))) {
59
+ unregistered.push(file)
60
+ }
61
+ }
62
+
63
+ return unregistered
64
+ } catch {
65
+ // If we can't read the directory, return empty
66
+ return []
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Derive a valid container name from a filename
72
+ * Removes extension and converts to valid container name format:
73
+ * - Must start with a letter
74
+ * - Can contain letters, numbers, hyphens, underscores
75
+ *
76
+ * @param fileName The SQLite filename (e.g., "my-database.sqlite")
77
+ * @returns A valid container name (e.g., "my-database")
78
+ */
79
+ export function deriveContainerName(fileName: string): string {
80
+ // Remove extension
81
+ const base = fileName.replace(/\.(sqlite3?|db)$/i, '')
82
+
83
+ // Convert to valid container name (alphanumeric, hyphens, underscores)
84
+ // Replace invalid chars with hyphens
85
+ let name = base.replace(/[^a-zA-Z0-9_-]/g, '-')
86
+
87
+ // Ensure starts with letter
88
+ if (!/^[a-zA-Z]/.test(name)) {
89
+ name = 'db-' + name
90
+ }
91
+
92
+ // Remove consecutive hyphens
93
+ name = name.replace(/-+/g, '-')
94
+
95
+ // Trim leading/trailing hyphens
96
+ name = name.replace(/^-+|-+$/g, '')
97
+
98
+ return name || 'sqlite-db'
99
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spindb",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Spin up local database containers without Docker. A DBngin-like CLI for PostgreSQL and MySQL.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,11 +9,12 @@
9
9
  "scripts": {
10
10
  "start": "tsx cli/bin.ts",
11
11
  "dev": "tsx watch cli/bin.ts",
12
- "test": "pnpm test:pg && pnpm test:mysql",
12
+ "test": "pnpm test:pg && pnpm test:mysql && pnpm test:sqlite",
13
13
  "test:pg": "node --import tsx --test tests/integration/postgresql.test.ts",
14
14
  "test:mysql": "node --import tsx --test tests/integration/mysql.test.ts",
15
+ "test:sqlite": "node --import tsx --test tests/integration/sqlite.test.ts",
15
16
  "test:unit": "node --import tsx --test tests/unit/*.test.ts",
16
- "test:integration": "pnpm test:pg && pnpm test:mysql",
17
+ "test:integration": "pnpm test:pg && pnpm test:mysql && pnpm test:sqlite",
17
18
  "test:all": "pnpm test:unit && pnpm test:integration",
18
19
  "format": "prettier --write .",
19
20
  "lint": "tsc --noEmit && eslint .",
package/types/index.ts CHANGED
@@ -149,6 +149,8 @@ export type SpinDBConfig = {
149
149
  litecli?: BinaryConfig
150
150
  usql?: BinaryConfig
151
151
  }
152
+ // Engine registries (for file-based databases like SQLite)
153
+ registry?: EngineRegistries
152
154
  // Default settings
153
155
  defaults?: {
154
156
  engine?: Engine
@@ -177,7 +179,25 @@ export type SQLiteRegistryEntry = {
177
179
  }
178
180
 
179
181
  /**
180
- * SQLite registry stored at ~/.spindb/sqlite-registry.json
182
+ * SQLite engine registry stored in config.json under registry.sqlite
183
+ * Includes entries and folder ignore list for CWD scanning
184
+ */
185
+ export type SQLiteEngineRegistry = {
186
+ version: 1
187
+ entries: SQLiteRegistryEntry[]
188
+ ignoreFolders: Record<string, true> // O(1) lookup for ignored folders
189
+ }
190
+
191
+ /**
192
+ * Engine registries stored in config.json
193
+ * Currently only SQLite uses this (file-based databases)
194
+ */
195
+ export type EngineRegistries = {
196
+ sqlite?: SQLiteEngineRegistry
197
+ }
198
+
199
+ /**
200
+ * @deprecated Use SQLiteEngineRegistry instead - now stored in config.json
181
201
  */
182
202
  export type SQLiteRegistry = {
183
203
  version: 1