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 +5 -8
- package/cli/commands/attach.ts +108 -0
- package/cli/commands/create.ts +6 -0
- package/cli/commands/detach.ts +100 -0
- package/cli/commands/doctor.ts +16 -2
- package/cli/commands/edit.ts +12 -2
- package/cli/commands/list.ts +96 -2
- package/cli/commands/menu/container-handlers.ts +44 -1
- package/cli/commands/sqlite.ts +247 -0
- package/cli/index.ts +6 -0
- package/config/paths.ts +0 -8
- package/core/config-manager.ts +32 -0
- package/engines/mysql/backup.ts +37 -13
- package/engines/sqlite/index.ts +9 -7
- package/engines/sqlite/registry.ts +64 -33
- package/engines/sqlite/scanner.ts +99 -0
- package/package.json +4 -3
- package/types/index.ts +21 -1
package/README.md
CHANGED
|
@@ -653,16 +653,13 @@ rm -rf ~/.spindb
|
|
|
653
653
|
|
|
654
654
|
## Contributing
|
|
655
655
|
|
|
656
|
-
See [
|
|
656
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, testing, and distribution info.
|
|
657
657
|
|
|
658
|
-
|
|
658
|
+
See [ARCHITECTURE.md](ARCHITECTURE.md) for project architecture and comprehensive CLI command examples.
|
|
659
659
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
+
)
|
package/cli/commands/create.ts
CHANGED
|
@@ -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
|
+
)
|
package/cli/commands/doctor.ts
CHANGED
|
@@ -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
|
|
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 {
|
package/cli/commands/edit.ts
CHANGED
|
@@ -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
|
-
//
|
|
535
|
-
|
|
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')
|
package/cli/commands/list.ts
CHANGED
|
@@ -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
|
-
.
|
|
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
|
-
|
|
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
|
}
|
package/core/config-manager.ts
CHANGED
|
@@ -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()
|
package/engines/mysql/backup.ts
CHANGED
|
@@ -98,12 +98,20 @@ async function createSqlBackup(
|
|
|
98
98
|
|
|
99
99
|
proc.on('close', async (code) => {
|
|
100
100
|
if (code === 0) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
}
|
package/engines/sqlite/index.ts
CHANGED
|
@@ -446,15 +446,17 @@ export class SQLiteEngine extends BaseEngine {
|
|
|
446
446
|
throw new Error('sqlite3 not found')
|
|
447
447
|
}
|
|
448
448
|
|
|
449
|
-
|
|
450
|
-
|
|
449
|
+
try {
|
|
450
|
+
// Pipe .dump output to file (avoids shell injection)
|
|
451
|
+
await this.dumpToFile(sqlite3, filePath, outputPath)
|
|
451
452
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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 {
|
|
11
|
-
import {
|
|
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
|
|
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
|
|
28
|
-
* Returns an empty registry if
|
|
22
|
+
* Load the registry from config.json
|
|
23
|
+
* Returns an empty registry if none exists
|
|
29
24
|
*/
|
|
30
|
-
async load(): Promise<
|
|
31
|
-
|
|
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
|
|
45
|
-
* Creates the parent directory if it doesn't exist
|
|
30
|
+
* Save the registry to config.json
|
|
46
31
|
*/
|
|
47
|
-
async save(registry:
|
|
48
|
-
|
|
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.
|
|
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
|
|
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
|