spindb 0.5.2 → 0.5.4
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 +188 -9
- package/cli/commands/connect.ts +334 -105
- package/cli/commands/create.ts +106 -67
- package/cli/commands/deps.ts +19 -4
- package/cli/commands/edit.ts +245 -0
- package/cli/commands/engines.ts +434 -0
- package/cli/commands/info.ts +279 -0
- package/cli/commands/list.ts +1 -1
- package/cli/commands/menu.ts +664 -167
- package/cli/commands/restore.ts +11 -25
- package/cli/commands/start.ts +25 -20
- package/cli/commands/url.ts +79 -0
- package/cli/index.ts +9 -3
- package/cli/ui/prompts.ts +20 -12
- package/cli/ui/theme.ts +1 -1
- package/config/engine-defaults.ts +24 -1
- package/config/os-dependencies.ts +151 -113
- package/config/paths.ts +7 -36
- package/core/binary-manager.ts +12 -6
- package/core/config-manager.ts +17 -5
- package/core/dependency-manager.ts +144 -15
- package/core/error-handler.ts +336 -0
- package/core/platform-service.ts +634 -0
- package/core/port-manager.ts +11 -3
- package/core/process-manager.ts +12 -2
- package/core/start-with-retry.ts +167 -0
- package/core/transaction-manager.ts +170 -0
- package/engines/mysql/binary-detection.ts +177 -100
- package/engines/mysql/index.ts +240 -131
- package/engines/mysql/restore.ts +257 -0
- package/engines/mysql/version-validator.ts +373 -0
- package/{core/postgres-binary-manager.ts → engines/postgresql/binary-manager.ts} +63 -23
- package/engines/postgresql/binary-urls.ts +5 -3
- package/engines/postgresql/index.ts +35 -4
- package/engines/postgresql/restore.ts +54 -5
- package/engines/postgresql/version-validator.ts +262 -0
- package/package.json +6 -2
- package/cli/commands/postgres-tools.ts +0 -216
package/cli/commands/restore.ts
CHANGED
|
@@ -12,10 +12,10 @@ import {
|
|
|
12
12
|
} from '../ui/prompts'
|
|
13
13
|
import { createSpinner } from '../ui/spinner'
|
|
14
14
|
import { success, error, warning } from '../ui/theme'
|
|
15
|
-
import {
|
|
16
|
-
import { spawn } from 'child_process'
|
|
15
|
+
import { tmpdir } from 'os'
|
|
17
16
|
import { join } from 'path'
|
|
18
17
|
import { getMissingDependencies } from '../../core/dependency-manager'
|
|
18
|
+
import { platformService } from '../../core/platform-service'
|
|
19
19
|
|
|
20
20
|
export const restoreCommand = new Command('restore')
|
|
21
21
|
.description('Restore a backup to a container')
|
|
@@ -182,7 +182,10 @@ export const restoreCommand = new Command('restore')
|
|
|
182
182
|
dumpSpinner.start()
|
|
183
183
|
|
|
184
184
|
try {
|
|
185
|
-
await engine.dumpFromConnectionString(
|
|
185
|
+
await engine.dumpFromConnectionString(
|
|
186
|
+
options.fromUrl,
|
|
187
|
+
tempDumpPath,
|
|
188
|
+
)
|
|
186
189
|
dumpSpinner.succeed('Dump created from remote database')
|
|
187
190
|
backupPath = tempDumpPath
|
|
188
191
|
dumpSuccess = true
|
|
@@ -243,7 +246,7 @@ export const restoreCommand = new Command('restore')
|
|
|
243
246
|
// Get database name
|
|
244
247
|
let databaseName = options.database
|
|
245
248
|
if (!databaseName) {
|
|
246
|
-
databaseName = await promptDatabaseName(containerName)
|
|
249
|
+
databaseName = await promptDatabaseName(containerName, engineName)
|
|
247
250
|
}
|
|
248
251
|
|
|
249
252
|
// At this point backupPath is guaranteed to be set
|
|
@@ -307,28 +310,11 @@ export const restoreCommand = new Command('restore')
|
|
|
307
310
|
console.log(chalk.gray(' Connection string:'))
|
|
308
311
|
console.log(chalk.cyan(` ${connectionString}`))
|
|
309
312
|
|
|
310
|
-
// Copy connection string to clipboard using platform
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const args =
|
|
314
|
-
platform() === 'darwin' ? [] : ['-selection', 'clipboard']
|
|
315
|
-
|
|
316
|
-
await new Promise<void>((resolve, reject) => {
|
|
317
|
-
const proc = spawn(cmd, args, {
|
|
318
|
-
stdio: ['pipe', 'inherit', 'inherit'],
|
|
319
|
-
})
|
|
320
|
-
proc.stdin?.write(connectionString)
|
|
321
|
-
proc.stdin?.end()
|
|
322
|
-
proc.on('close', (code) => {
|
|
323
|
-
if (code === 0) resolve()
|
|
324
|
-
else
|
|
325
|
-
reject(new Error(`Clipboard command exited with code ${code}`))
|
|
326
|
-
})
|
|
327
|
-
proc.on('error', reject)
|
|
328
|
-
})
|
|
329
|
-
|
|
313
|
+
// Copy connection string to clipboard using platform service
|
|
314
|
+
const copied = await platformService.copyToClipboard(connectionString)
|
|
315
|
+
if (copied) {
|
|
330
316
|
console.log(chalk.gray(' Connection string copied to clipboard'))
|
|
331
|
-
}
|
|
317
|
+
} else {
|
|
332
318
|
console.log(chalk.gray(' (Could not copy to clipboard)'))
|
|
333
319
|
}
|
|
334
320
|
|
package/cli/commands/start.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import chalk from 'chalk'
|
|
3
3
|
import { containerManager } from '../../core/container-manager'
|
|
4
|
-
import { portManager } from '../../core/port-manager'
|
|
5
4
|
import { processManager } from '../../core/process-manager'
|
|
5
|
+
import { startWithRetry } from '../../core/start-with-retry'
|
|
6
6
|
import { getEngine } from '../../engines'
|
|
7
7
|
import { getEngineDefaults } from '../../config/defaults'
|
|
8
8
|
import { promptContainerSelect } from '../ui/prompts'
|
|
@@ -61,32 +61,37 @@ export const startCommand = new Command('start')
|
|
|
61
61
|
// Get engine defaults for port range and database name
|
|
62
62
|
const engineDefaults = getEngineDefaults(engineName)
|
|
63
63
|
|
|
64
|
-
//
|
|
65
|
-
const portAvailable = await portManager.isPortAvailable(config.port)
|
|
66
|
-
if (!portAvailable) {
|
|
67
|
-
// Try to find a new port (using engine-specific port range)
|
|
68
|
-
const { port: newPort } = await portManager.findAvailablePort({
|
|
69
|
-
portRange: engineDefaults.portRange,
|
|
70
|
-
})
|
|
71
|
-
console.log(
|
|
72
|
-
warning(
|
|
73
|
-
`Port ${config.port} is in use, switching to port ${newPort}`,
|
|
74
|
-
),
|
|
75
|
-
)
|
|
76
|
-
config.port = newPort
|
|
77
|
-
await containerManager.updateConfig(containerName, { port: newPort })
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Get engine and start
|
|
64
|
+
// Get engine and start with retry (handles port race conditions)
|
|
81
65
|
const engine = getEngine(engineName)
|
|
82
66
|
|
|
83
67
|
const spinner = createSpinner(`Starting ${containerName}...`)
|
|
84
68
|
spinner.start()
|
|
85
69
|
|
|
86
|
-
await
|
|
70
|
+
const result = await startWithRetry({
|
|
71
|
+
engine,
|
|
72
|
+
config,
|
|
73
|
+
onPortChange: (oldPort, newPort) => {
|
|
74
|
+
spinner.text = `Port ${oldPort} was in use, retrying with port ${newPort}...`
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
if (!result.success) {
|
|
79
|
+
spinner.fail(`Failed to start "${containerName}"`)
|
|
80
|
+
if (result.error) {
|
|
81
|
+
console.error(error(result.error.message))
|
|
82
|
+
}
|
|
83
|
+
process.exit(1)
|
|
84
|
+
}
|
|
85
|
+
|
|
87
86
|
await containerManager.updateConfig(containerName, { status: 'running' })
|
|
88
87
|
|
|
89
|
-
|
|
88
|
+
if (result.retriesUsed > 0) {
|
|
89
|
+
spinner.warn(
|
|
90
|
+
`Container "${containerName}" started on port ${result.finalPort} (original port was in use)`,
|
|
91
|
+
)
|
|
92
|
+
} else {
|
|
93
|
+
spinner.succeed(`Container "${containerName}" started`)
|
|
94
|
+
}
|
|
90
95
|
|
|
91
96
|
// Ensure the user's database exists (if different from default)
|
|
92
97
|
const defaultDb = engineDefaults.superuser // postgres or root
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import { containerManager } from '../../core/container-manager'
|
|
3
|
+
import { platformService } from '../../core/platform-service'
|
|
4
|
+
import { getEngine } from '../../engines'
|
|
5
|
+
import { promptContainerSelect } from '../ui/prompts'
|
|
6
|
+
import { error, warning, success } from '../ui/theme'
|
|
7
|
+
|
|
8
|
+
export const urlCommand = new Command('url')
|
|
9
|
+
.alias('connection-string')
|
|
10
|
+
.description('Output connection string for a container')
|
|
11
|
+
.argument('[name]', 'Container name')
|
|
12
|
+
.option('-c, --copy', 'Copy to clipboard')
|
|
13
|
+
.option('-d, --database <database>', 'Use different database name')
|
|
14
|
+
.action(
|
|
15
|
+
async (
|
|
16
|
+
name: string | undefined,
|
|
17
|
+
options: { copy?: boolean; database?: string },
|
|
18
|
+
) => {
|
|
19
|
+
try {
|
|
20
|
+
let containerName = name
|
|
21
|
+
|
|
22
|
+
// Interactive selection if no name provided
|
|
23
|
+
if (!containerName) {
|
|
24
|
+
const containers = await containerManager.list()
|
|
25
|
+
|
|
26
|
+
if (containers.length === 0) {
|
|
27
|
+
console.log(warning('No containers found'))
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const selected = await promptContainerSelect(
|
|
32
|
+
containers,
|
|
33
|
+
'Select container:',
|
|
34
|
+
)
|
|
35
|
+
if (!selected) return
|
|
36
|
+
containerName = selected
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Get container config
|
|
40
|
+
const config = await containerManager.getConfig(containerName)
|
|
41
|
+
if (!config) {
|
|
42
|
+
console.error(error(`Container "${containerName}" not found`))
|
|
43
|
+
process.exit(1)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Get connection string
|
|
47
|
+
const engine = getEngine(config.engine)
|
|
48
|
+
const connectionString = engine.getConnectionString(
|
|
49
|
+
config,
|
|
50
|
+
options.database,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
// Copy to clipboard if requested
|
|
54
|
+
if (options.copy) {
|
|
55
|
+
const copied = await platformService.copyToClipboard(connectionString)
|
|
56
|
+
if (copied) {
|
|
57
|
+
// Output the string AND confirmation
|
|
58
|
+
console.log(connectionString)
|
|
59
|
+
console.error(success('Copied to clipboard'))
|
|
60
|
+
} else {
|
|
61
|
+
// Output the string but warn about clipboard
|
|
62
|
+
console.log(connectionString)
|
|
63
|
+
console.error(warning('Could not copy to clipboard'))
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
// Just output the connection string (no newline formatting for easy piping)
|
|
67
|
+
process.stdout.write(connectionString)
|
|
68
|
+
// Add newline if stdout is a TTY (interactive terminal)
|
|
69
|
+
if (process.stdout.isTTY) {
|
|
70
|
+
console.log()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch (err) {
|
|
74
|
+
const e = err as Error
|
|
75
|
+
console.error(error(e.message))
|
|
76
|
+
process.exit(1)
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
)
|
package/cli/index.ts
CHANGED
|
@@ -9,14 +9,17 @@ import { connectCommand } from './commands/connect'
|
|
|
9
9
|
import { cloneCommand } from './commands/clone'
|
|
10
10
|
import { menuCommand } from './commands/menu'
|
|
11
11
|
import { configCommand } from './commands/config'
|
|
12
|
-
import { postgresToolsCommand } from './commands/postgres-tools'
|
|
13
12
|
import { depsCommand } from './commands/deps'
|
|
13
|
+
import { enginesCommand } from './commands/engines'
|
|
14
|
+
import { editCommand } from './commands/edit'
|
|
15
|
+
import { urlCommand } from './commands/url'
|
|
16
|
+
import { infoCommand } from './commands/info'
|
|
14
17
|
|
|
15
18
|
export async function run(): Promise<void> {
|
|
16
19
|
program
|
|
17
20
|
.name('spindb')
|
|
18
21
|
.description('Spin up local database containers without Docker')
|
|
19
|
-
.version('0.1.0')
|
|
22
|
+
.version('0.1.0', '-v, --version', 'output the version number')
|
|
20
23
|
|
|
21
24
|
program.addCommand(createCommand)
|
|
22
25
|
program.addCommand(listCommand)
|
|
@@ -28,8 +31,11 @@ export async function run(): Promise<void> {
|
|
|
28
31
|
program.addCommand(cloneCommand)
|
|
29
32
|
program.addCommand(menuCommand)
|
|
30
33
|
program.addCommand(configCommand)
|
|
31
|
-
program.addCommand(postgresToolsCommand)
|
|
32
34
|
program.addCommand(depsCommand)
|
|
35
|
+
program.addCommand(enginesCommand)
|
|
36
|
+
program.addCommand(editCommand)
|
|
37
|
+
program.addCommand(urlCommand)
|
|
38
|
+
program.addCommand(infoCommand)
|
|
33
39
|
|
|
34
40
|
// If no arguments provided, show interactive menu
|
|
35
41
|
if (process.argv.length <= 2) {
|
package/cli/ui/prompts.ts
CHANGED
|
@@ -3,7 +3,7 @@ import chalk from 'chalk'
|
|
|
3
3
|
import ora from 'ora'
|
|
4
4
|
import { listEngines, getEngine } from '../../engines'
|
|
5
5
|
import { defaults, getEngineDefaults } from '../../config/defaults'
|
|
6
|
-
import { installPostgresBinaries } from '../../
|
|
6
|
+
import { installPostgresBinaries } from '../../engines/postgresql/binary-manager'
|
|
7
7
|
import {
|
|
8
8
|
detectPackageManager,
|
|
9
9
|
getManualInstallInstructions,
|
|
@@ -53,7 +53,7 @@ export async function promptEngine(): Promise<string> {
|
|
|
53
53
|
|
|
54
54
|
// Build choices from available engines
|
|
55
55
|
const choices = engines.map((e) => ({
|
|
56
|
-
name: `${engineIcons[e.name] || '
|
|
56
|
+
name: `${engineIcons[e.name] || '▣'} ${e.displayName} ${chalk.gray(`(versions: ${e.supportedVersions.join(', ')})`)}`,
|
|
57
57
|
value: e.name,
|
|
58
58
|
short: e.displayName,
|
|
59
59
|
}))
|
|
@@ -227,7 +227,7 @@ export async function promptContainerSelect(
|
|
|
227
227
|
name: 'container',
|
|
228
228
|
message,
|
|
229
229
|
choices: containers.map((c) => ({
|
|
230
|
-
name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || '
|
|
230
|
+
name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || '▣'} ${c.engine} ${c.version}, port ${c.port})`)} ${
|
|
231
231
|
c.status === 'running'
|
|
232
232
|
? chalk.green('● running')
|
|
233
233
|
: chalk.gray('○ stopped')
|
|
@@ -243,19 +243,25 @@ export async function promptContainerSelect(
|
|
|
243
243
|
|
|
244
244
|
/**
|
|
245
245
|
* Prompt for database name
|
|
246
|
+
* @param defaultName - Default value for the database name
|
|
247
|
+
* @param engine - Database engine (mysql shows "schema" terminology)
|
|
246
248
|
*/
|
|
247
249
|
export async function promptDatabaseName(
|
|
248
250
|
defaultName?: string,
|
|
251
|
+
engine?: string,
|
|
249
252
|
): Promise<string> {
|
|
253
|
+
// MySQL uses "schema" terminology (database and schema are synonymous)
|
|
254
|
+
const label = engine === 'mysql' ? 'Database (schema) name:' : 'Database name:'
|
|
255
|
+
|
|
250
256
|
const { database } = await inquirer.prompt<{ database: string }>([
|
|
251
257
|
{
|
|
252
258
|
type: 'input',
|
|
253
259
|
name: 'database',
|
|
254
|
-
message:
|
|
260
|
+
message: label,
|
|
255
261
|
default: defaultName,
|
|
256
262
|
validate: (input: string) => {
|
|
257
263
|
if (!input) return 'Database name is required'
|
|
258
|
-
// PostgreSQL database naming rules
|
|
264
|
+
// PostgreSQL database naming rules (also valid for MySQL)
|
|
259
265
|
if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(input)) {
|
|
260
266
|
return 'Database name must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens'
|
|
261
267
|
}
|
|
@@ -282,12 +288,12 @@ export type CreateOptions = {
|
|
|
282
288
|
* Full interactive create flow
|
|
283
289
|
*/
|
|
284
290
|
export async function promptCreateOptions(): Promise<CreateOptions> {
|
|
285
|
-
console.log(chalk.cyan('\n
|
|
291
|
+
console.log(chalk.cyan('\n ▣ Create New Database Container\n'))
|
|
286
292
|
|
|
287
293
|
const engine = await promptEngine()
|
|
288
294
|
const version = await promptVersion(engine)
|
|
289
295
|
const name = await promptContainerName()
|
|
290
|
-
const database = await promptDatabaseName(name) // Default to container name
|
|
296
|
+
const database = await promptDatabaseName(name, engine) // Default to container name
|
|
291
297
|
|
|
292
298
|
// Get engine-specific default port
|
|
293
299
|
const engineDefaults = getEngineDefaults(engine)
|
|
@@ -333,7 +339,9 @@ export async function promptInstallDependencies(
|
|
|
333
339
|
if (dep) {
|
|
334
340
|
const instructions = getManualInstallInstructions(dep, platform)
|
|
335
341
|
console.log(
|
|
336
|
-
chalk.gray(
|
|
342
|
+
chalk.gray(
|
|
343
|
+
` Please install ${engineDeps.displayName} client tools:`,
|
|
344
|
+
),
|
|
337
345
|
)
|
|
338
346
|
console.log()
|
|
339
347
|
for (const instruction of instructions) {
|
|
@@ -346,7 +354,9 @@ export async function promptInstallDependencies(
|
|
|
346
354
|
}
|
|
347
355
|
|
|
348
356
|
console.log(
|
|
349
|
-
chalk.gray(
|
|
357
|
+
chalk.gray(
|
|
358
|
+
` Detected package manager: ${chalk.white(packageManager.name)}`,
|
|
359
|
+
),
|
|
350
360
|
)
|
|
351
361
|
console.log()
|
|
352
362
|
|
|
@@ -425,9 +435,7 @@ export async function promptInstallDependencies(
|
|
|
425
435
|
|
|
426
436
|
if (allSuccess) {
|
|
427
437
|
console.log()
|
|
428
|
-
console.log(
|
|
429
|
-
chalk.green(` ${engineName} tools installed successfully!`),
|
|
430
|
-
)
|
|
438
|
+
console.log(chalk.green(` ${engineName} tools installed successfully!`))
|
|
431
439
|
console.log(chalk.gray(' Continuing with your operation...'))
|
|
432
440
|
console.log()
|
|
433
441
|
return true
|
package/cli/ui/theme.ts
CHANGED
|
@@ -12,6 +12,8 @@ export type EngineDefaults = {
|
|
|
12
12
|
portRange: { start: number; end: number }
|
|
13
13
|
/** Supported major versions */
|
|
14
14
|
supportedVersions: string[]
|
|
15
|
+
/** Latest major version (used for Homebrew package names like postgresql@17) */
|
|
16
|
+
latestVersion: string
|
|
15
17
|
/** Default superuser name */
|
|
16
18
|
superuser: string
|
|
17
19
|
/** Connection string scheme (e.g., 'postgresql', 'mysql') */
|
|
@@ -28,10 +30,11 @@ export type EngineDefaults = {
|
|
|
28
30
|
|
|
29
31
|
export const engineDefaults: Record<string, EngineDefaults> = {
|
|
30
32
|
postgresql: {
|
|
31
|
-
defaultVersion: '
|
|
33
|
+
defaultVersion: '17',
|
|
32
34
|
defaultPort: 5432,
|
|
33
35
|
portRange: { start: 5432, end: 5500 },
|
|
34
36
|
supportedVersions: ['14', '15', '16', '17'],
|
|
37
|
+
latestVersion: '17', // Update when PostgreSQL 18 is released
|
|
35
38
|
superuser: 'postgres',
|
|
36
39
|
connectionScheme: 'postgresql',
|
|
37
40
|
logFileName: 'postgres.log',
|
|
@@ -44,6 +47,7 @@ export const engineDefaults: Record<string, EngineDefaults> = {
|
|
|
44
47
|
defaultPort: 3306,
|
|
45
48
|
portRange: { start: 3306, end: 3400 },
|
|
46
49
|
supportedVersions: ['5.7', '8.0', '8.4', '9.0'],
|
|
50
|
+
latestVersion: '9.0', // MySQL doesn't use versioned Homebrew packages, but kept for consistency
|
|
47
51
|
superuser: 'root',
|
|
48
52
|
connectionScheme: 'mysql',
|
|
49
53
|
logFileName: 'mysql.log',
|
|
@@ -82,3 +86,22 @@ export function isEngineSupported(engine: string): boolean {
|
|
|
82
86
|
export function getSupportedEngines(): string[] {
|
|
83
87
|
return Object.keys(engineDefaults)
|
|
84
88
|
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get Homebrew package name for PostgreSQL client tools
|
|
92
|
+
* Returns 'postgresql@17' format for versioned installs
|
|
93
|
+
*/
|
|
94
|
+
export function getPostgresHomebrewPackage(): string {
|
|
95
|
+
const version = engineDefaults.postgresql.latestVersion
|
|
96
|
+
return `postgresql@${version}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get the PostgreSQL Homebrew bin path for a given architecture
|
|
101
|
+
* @param arch - 'arm64' or 'x64'
|
|
102
|
+
*/
|
|
103
|
+
export function getPostgresHomebrewBinPath(arch: 'arm64' | 'x64'): string {
|
|
104
|
+
const pkg = getPostgresHomebrewPackage()
|
|
105
|
+
const prefix = arch === 'arm64' ? '/opt/homebrew' : '/usr/local'
|
|
106
|
+
return `${prefix}/opt/${pkg}/bin`
|
|
107
|
+
}
|