spindb 0.9.2 → 0.10.0
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 +24 -18
- package/cli/commands/attach.ts +108 -0
- package/cli/commands/create.ts +78 -42
- package/cli/commands/detach.ts +100 -0
- package/cli/commands/doctor.ts +16 -2
- package/cli/commands/edit.ts +12 -2
- package/cli/commands/engines.ts +61 -0
- package/cli/commands/list.ts +96 -2
- package/cli/commands/logs.ts +3 -29
- package/cli/commands/menu/container-handlers.ts +76 -4
- package/cli/commands/menu/sql-handlers.ts +4 -26
- package/cli/commands/sqlite.ts +247 -0
- package/cli/helpers.ts +6 -6
- package/cli/index.ts +9 -3
- package/cli/utils/file-follower.ts +95 -0
- package/config/defaults.ts +3 -0
- package/config/os-dependencies.ts +79 -1
- package/config/paths.ts +0 -8
- package/core/binary-manager.ts +181 -66
- package/core/config-manager.ts +37 -65
- package/core/dependency-manager.ts +39 -1
- package/core/platform-service.ts +149 -11
- package/core/process-manager.ts +152 -33
- package/engines/base-engine.ts +27 -0
- package/engines/mysql/backup.ts +49 -18
- package/engines/mysql/index.ts +328 -110
- package/engines/mysql/restore.ts +22 -6
- package/engines/postgresql/backup.ts +7 -3
- package/engines/postgresql/binary-manager.ts +47 -31
- package/engines/postgresql/edb-binary-urls.ts +123 -0
- package/engines/postgresql/index.ts +109 -22
- package/engines/postgresql/version-maps.ts +63 -0
- package/engines/sqlite/index.ts +18 -26
- package/engines/sqlite/registry.ts +64 -33
- package/engines/sqlite/scanner.ts +99 -0
- package/package.json +7 -4
- package/types/index.ts +21 -1
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) {
|
package/cli/commands/logs.ts
CHANGED
|
@@ -6,13 +6,7 @@ import { containerManager } from '../../core/container-manager'
|
|
|
6
6
|
import { paths } from '../../config/paths'
|
|
7
7
|
import { promptContainerSelect } from '../ui/prompts'
|
|
8
8
|
import { uiError, uiWarning, uiInfo } from '../ui/theme'
|
|
9
|
-
|
|
10
|
-
function getLastNLines(content: string, n: number): string {
|
|
11
|
-
const lines = content.split('\n')
|
|
12
|
-
const nonEmptyLines =
|
|
13
|
-
lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines
|
|
14
|
-
return nonEmptyLines.slice(-n).join('\n')
|
|
15
|
-
}
|
|
9
|
+
import { followFile, getLastNLines } from '../utils/file-follower'
|
|
16
10
|
|
|
17
11
|
export const logsCommand = new Command('logs')
|
|
18
12
|
.description('View container logs')
|
|
@@ -84,28 +78,8 @@ export const logsCommand = new Command('logs')
|
|
|
84
78
|
|
|
85
79
|
if (options.follow) {
|
|
86
80
|
const lineCount = parseInt(options.lines || '50', 10)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
['-n', String(lineCount), '-f', logPath],
|
|
90
|
-
{
|
|
91
|
-
stdio: 'inherit',
|
|
92
|
-
},
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
// Use named handler so we can remove it to prevent listener leaks
|
|
96
|
-
const sigintHandler = () => {
|
|
97
|
-
process.removeListener('SIGINT', sigintHandler)
|
|
98
|
-
child.kill('SIGTERM')
|
|
99
|
-
process.exit(0)
|
|
100
|
-
}
|
|
101
|
-
process.on('SIGINT', sigintHandler)
|
|
102
|
-
|
|
103
|
-
await new Promise<void>((resolve) => {
|
|
104
|
-
child.on('close', () => {
|
|
105
|
-
process.removeListener('SIGINT', sigintHandler)
|
|
106
|
-
resolve()
|
|
107
|
-
})
|
|
108
|
-
})
|
|
81
|
+
// Use cross-platform file following (works on Windows, macOS, Linux)
|
|
82
|
+
await followFile(logPath, lineCount)
|
|
109
83
|
return
|
|
110
84
|
}
|
|
111
85
|
|
|
@@ -53,8 +53,37 @@ export async function handleCreate(): Promise<void> {
|
|
|
53
53
|
|
|
54
54
|
const dbEngine = getEngine(engine)
|
|
55
55
|
const isSQLite = engine === 'sqlite'
|
|
56
|
+
const isPostgreSQL = engine === 'postgresql'
|
|
57
|
+
|
|
58
|
+
// For PostgreSQL, download binaries FIRST - they include client tools (psql, pg_dump, etc.)
|
|
59
|
+
// This avoids requiring a separate system installation of client tools
|
|
60
|
+
let portAvailable = true
|
|
61
|
+
if (isPostgreSQL) {
|
|
62
|
+
portAvailable = await portManager.isPortAvailable(port)
|
|
63
|
+
|
|
64
|
+
const binarySpinner = createSpinner(
|
|
65
|
+
`Checking ${dbEngine.displayName} ${version} binaries...`,
|
|
66
|
+
)
|
|
67
|
+
binarySpinner.start()
|
|
68
|
+
|
|
69
|
+
const isInstalled = await dbEngine.isBinaryInstalled(version)
|
|
70
|
+
if (isInstalled) {
|
|
71
|
+
binarySpinner.succeed(
|
|
72
|
+
`${dbEngine.displayName} ${version} binaries ready (cached)`,
|
|
73
|
+
)
|
|
74
|
+
} else {
|
|
75
|
+
binarySpinner.text = `Downloading ${dbEngine.displayName} ${version} binaries...`
|
|
76
|
+
await dbEngine.ensureBinaries(version, ({ message }) => {
|
|
77
|
+
binarySpinner.text = message
|
|
78
|
+
})
|
|
79
|
+
binarySpinner.succeed(
|
|
80
|
+
`${dbEngine.displayName} ${version} binaries downloaded`,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
56
84
|
|
|
57
85
|
// Check dependencies (all engines need this)
|
|
86
|
+
// For PostgreSQL, this runs AFTER binary download so client tools are available
|
|
58
87
|
const depsSpinner = createSpinner('Checking required tools...')
|
|
59
88
|
depsSpinner.start()
|
|
60
89
|
|
|
@@ -89,9 +118,9 @@ export async function handleCreate(): Promise<void> {
|
|
|
89
118
|
depsSpinner.succeed('Required tools available')
|
|
90
119
|
}
|
|
91
120
|
|
|
92
|
-
// Server databases: check port and binaries
|
|
93
|
-
|
|
94
|
-
if (!isSQLite) {
|
|
121
|
+
// Server databases (MySQL): check port and binaries
|
|
122
|
+
// PostgreSQL already handled above
|
|
123
|
+
if (!isSQLite && !isPostgreSQL) {
|
|
95
124
|
portAvailable = await portManager.isPortAvailable(port)
|
|
96
125
|
|
|
97
126
|
const binarySpinner = createSpinner(
|
|
@@ -200,7 +229,9 @@ export async function handleCreate(): Promise<void> {
|
|
|
200
229
|
|
|
201
230
|
startSpinner.succeed(`${dbEngine.displayName} started`)
|
|
202
231
|
|
|
203
|
-
|
|
232
|
+
// Skip creating 'postgres' database for PostgreSQL - it's created by initdb
|
|
233
|
+
// For other engines (MySQL, SQLite), allow creating a database named 'postgres'
|
|
234
|
+
if (config && !(config.engine === 'postgresql' && database === 'postgres')) {
|
|
204
235
|
const dbSpinner = createSpinner(`Creating database "${database}"...`)
|
|
205
236
|
dbSpinner.start()
|
|
206
237
|
|
|
@@ -532,6 +563,14 @@ export async function showContainerSubmenu(
|
|
|
532
563
|
})
|
|
533
564
|
}
|
|
534
565
|
|
|
566
|
+
// Detach - only for SQLite (unregisters without deleting file)
|
|
567
|
+
if (isSQLite) {
|
|
568
|
+
actionChoices.push({
|
|
569
|
+
name: `${chalk.yellow('⊘')} Detach from SpinDB`,
|
|
570
|
+
value: 'detach',
|
|
571
|
+
})
|
|
572
|
+
}
|
|
573
|
+
|
|
535
574
|
// Delete container - SQLite can always delete, server databases must be stopped
|
|
536
575
|
const canDelete = isSQLite ? true : !isRunning
|
|
537
576
|
actionChoices.push({
|
|
@@ -606,6 +645,9 @@ export async function showContainerSubmenu(
|
|
|
606
645
|
await handleCopyConnectionString(containerName)
|
|
607
646
|
await showContainerSubmenu(containerName, showMainMenu)
|
|
608
647
|
return
|
|
648
|
+
case 'detach':
|
|
649
|
+
await handleDetachContainer(containerName, showMainMenu)
|
|
650
|
+
return // Return to list after detach
|
|
609
651
|
case 'delete':
|
|
610
652
|
await handleDelete(containerName)
|
|
611
653
|
return // Don't show submenu again after delete
|
|
@@ -1135,6 +1177,36 @@ async function handleCloneFromSubmenu(
|
|
|
1135
1177
|
}
|
|
1136
1178
|
}
|
|
1137
1179
|
|
|
1180
|
+
async function handleDetachContainer(
|
|
1181
|
+
containerName: string,
|
|
1182
|
+
showMainMenu: () => Promise<void>,
|
|
1183
|
+
): Promise<void> {
|
|
1184
|
+
const confirmed = await promptConfirm(
|
|
1185
|
+
`Detach "${containerName}" from SpinDB? (file will be kept on disk)`,
|
|
1186
|
+
true,
|
|
1187
|
+
)
|
|
1188
|
+
|
|
1189
|
+
if (!confirmed) {
|
|
1190
|
+
console.log(uiWarning('Cancelled'))
|
|
1191
|
+
await pressEnterToContinue()
|
|
1192
|
+
await showContainerSubmenu(containerName, showMainMenu)
|
|
1193
|
+
return
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
const entry = await sqliteRegistry.get(containerName)
|
|
1197
|
+
await sqliteRegistry.remove(containerName)
|
|
1198
|
+
|
|
1199
|
+
console.log(uiSuccess(`Detached "${containerName}" from SpinDB`))
|
|
1200
|
+
if (entry?.filePath) {
|
|
1201
|
+
console.log(chalk.gray(` File remains at: ${entry.filePath}`))
|
|
1202
|
+
console.log()
|
|
1203
|
+
console.log(chalk.gray(' Re-attach with:'))
|
|
1204
|
+
console.log(chalk.cyan(` spindb attach ${entry.filePath}`))
|
|
1205
|
+
}
|
|
1206
|
+
await pressEnterToContinue()
|
|
1207
|
+
await handleList(showMainMenu)
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1138
1210
|
async function handleDelete(containerName: string): Promise<void> {
|
|
1139
1211
|
const config = await containerManager.getConfig(containerName)
|
|
1140
1212
|
if (!config) {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from '../../ui/prompts'
|
|
14
14
|
import { uiError, uiWarning, uiInfo, uiSuccess } from '../../ui/theme'
|
|
15
15
|
import { pressEnterToContinue } from './shared'
|
|
16
|
+
import { followFile, getLastNLines } from '../../utils/file-follower'
|
|
16
17
|
|
|
17
18
|
export async function handleRunSql(containerName: string): Promise<void> {
|
|
18
19
|
const config = await containerManager.getConfig(containerName)
|
|
@@ -171,28 +172,8 @@ export async function handleViewLogs(containerName: string): Promise<void> {
|
|
|
171
172
|
if (action === 'follow') {
|
|
172
173
|
console.log(chalk.gray(' Press Ctrl+C to stop following logs'))
|
|
173
174
|
console.log()
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
})
|
|
177
|
-
await new Promise<void>((resolve) => {
|
|
178
|
-
let settled = false
|
|
179
|
-
|
|
180
|
-
const cleanup = () => {
|
|
181
|
-
if (!settled) {
|
|
182
|
-
settled = true
|
|
183
|
-
process.off('SIGINT', handleSigint)
|
|
184
|
-
resolve()
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const handleSigint = () => {
|
|
189
|
-
child.kill('SIGTERM')
|
|
190
|
-
cleanup()
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
process.on('SIGINT', handleSigint)
|
|
194
|
-
child.on('close', cleanup)
|
|
195
|
-
})
|
|
175
|
+
// Use cross-platform file following (works on Windows, macOS, Linux)
|
|
176
|
+
await followFile(logPath, 50)
|
|
196
177
|
return
|
|
197
178
|
}
|
|
198
179
|
|
|
@@ -202,10 +183,7 @@ export async function handleViewLogs(containerName: string): Promise<void> {
|
|
|
202
183
|
if (content.trim() === '') {
|
|
203
184
|
console.log(uiInfo('Log file is empty'))
|
|
204
185
|
} else {
|
|
205
|
-
|
|
206
|
-
const nonEmptyLines =
|
|
207
|
-
lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines
|
|
208
|
-
console.log(nonEmptyLines.slice(-lineCount).join('\n'))
|
|
186
|
+
console.log(getLastNLines(content, lineCount))
|
|
209
187
|
}
|
|
210
188
|
console.log()
|
|
211
189
|
await pressEnterToContinue()
|
|
@@ -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/helpers.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { existsSync } from 'fs'
|
|
2
2
|
import { readdir, lstat } from 'fs/promises'
|
|
3
3
|
import { join } from 'path'
|
|
4
|
-
import {
|
|
4
|
+
import { execFile } from 'child_process'
|
|
5
5
|
import { promisify } from 'util'
|
|
6
6
|
import { paths } from '../config/paths'
|
|
7
|
+
import { platformService } from '../core/platform-service'
|
|
7
8
|
import {
|
|
8
9
|
getMysqldPath,
|
|
9
10
|
getMysqlVersion,
|
|
10
11
|
isMariaDB,
|
|
11
12
|
} from '../engines/mysql/binary-detection'
|
|
12
13
|
|
|
13
|
-
const execAsync = promisify(exec)
|
|
14
14
|
const execFileAsync = promisify(execFile)
|
|
15
15
|
|
|
16
16
|
export type InstalledPostgresEngine = {
|
|
@@ -44,7 +44,8 @@ export type InstalledEngine =
|
|
|
44
44
|
| InstalledSqliteEngine
|
|
45
45
|
|
|
46
46
|
async function getPostgresVersion(binPath: string): Promise<string | null> {
|
|
47
|
-
const
|
|
47
|
+
const ext = platformService.getExecutableExtension()
|
|
48
|
+
const postgresPath = join(binPath, 'bin', `postgres${ext}`)
|
|
48
49
|
if (!existsSync(postgresPath)) {
|
|
49
50
|
return null
|
|
50
51
|
}
|
|
@@ -140,9 +141,8 @@ async function getInstalledMysqlEngine(): Promise<InstalledMysqlEngine | null> {
|
|
|
140
141
|
|
|
141
142
|
async function getInstalledSqliteEngine(): Promise<InstalledSqliteEngine | null> {
|
|
142
143
|
try {
|
|
143
|
-
//
|
|
144
|
-
const
|
|
145
|
-
const sqlitePath = whichOutput.trim()
|
|
144
|
+
// Use platform service for cross-platform binary detection
|
|
145
|
+
const sqlitePath = await platformService.findToolPath('sqlite3')
|
|
146
146
|
if (!sqlitePath) {
|
|
147
147
|
return null
|
|
148
148
|
}
|
package/cli/index.ts
CHANGED
|
@@ -2,9 +2,6 @@ import { program } from 'commander'
|
|
|
2
2
|
import { createRequire } from 'module'
|
|
3
3
|
import chalk from 'chalk'
|
|
4
4
|
import { createCommand } from './commands/create'
|
|
5
|
-
|
|
6
|
-
const require = createRequire(import.meta.url)
|
|
7
|
-
const pkg = require('../package.json') as { version: string }
|
|
8
5
|
import { listCommand } from './commands/list'
|
|
9
6
|
import { startCommand } from './commands/start'
|
|
10
7
|
import { stopCommand } from './commands/stop'
|
|
@@ -25,8 +22,14 @@ import { versionCommand } from './commands/version'
|
|
|
25
22
|
import { runCommand } from './commands/run'
|
|
26
23
|
import { logsCommand } from './commands/logs'
|
|
27
24
|
import { doctorCommand } from './commands/doctor'
|
|
25
|
+
import { attachCommand } from './commands/attach'
|
|
26
|
+
import { detachCommand } from './commands/detach'
|
|
27
|
+
import { sqliteCommand } from './commands/sqlite'
|
|
28
28
|
import { updateManager } from '../core/update-manager'
|
|
29
29
|
|
|
30
|
+
const require = createRequire(import.meta.url)
|
|
31
|
+
const pkg = require('../package.json') as { version: string }
|
|
32
|
+
|
|
30
33
|
/**
|
|
31
34
|
* Show update notification banner if an update is available (from cached data)
|
|
32
35
|
* This shows on every run until the user updates or disables checks
|
|
@@ -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) {
|