spindb 0.9.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/cli/commands/clone.ts +6 -0
- package/cli/commands/create.ts +64 -14
- package/cli/commands/doctor.ts +5 -4
- package/cli/commands/edit.ts +7 -5
- package/cli/commands/engines.ts +34 -3
- package/cli/commands/info.ts +4 -2
- package/cli/commands/list.ts +4 -4
- package/cli/commands/logs.ts +9 -3
- package/cli/commands/menu/backup-handlers.ts +26 -8
- package/cli/commands/menu/container-handlers.ts +25 -7
- package/cli/commands/menu/engine-handlers.ts +128 -4
- package/cli/commands/restore.ts +83 -23
- package/cli/helpers.ts +41 -1
- package/cli/ui/prompts.ts +9 -3
- package/core/container-manager.ts +81 -30
- package/core/error-handler.ts +31 -0
- package/core/port-manager.ts +2 -0
- package/core/process-manager.ts +25 -3
- package/engines/mysql/backup.ts +53 -36
- package/engines/mysql/index.ts +48 -5
- package/engines/postgresql/index.ts +6 -0
- package/engines/sqlite/index.ts +13 -4
- package/package.json +1 -1
|
@@ -11,13 +11,24 @@ import {
|
|
|
11
11
|
getInstalledEngines,
|
|
12
12
|
type InstalledPostgresEngine,
|
|
13
13
|
type InstalledMysqlEngine,
|
|
14
|
+
type InstalledSqliteEngine,
|
|
14
15
|
} from '../../helpers'
|
|
15
16
|
import {
|
|
16
17
|
getMysqlVersion,
|
|
17
18
|
getMysqlInstallInfo,
|
|
18
19
|
} from '../../../engines/mysql/binary-detection'
|
|
20
|
+
|
|
19
21
|
import { type MenuChoice } from './shared'
|
|
20
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Pad string to width, accounting for emoji taking 2 display columns
|
|
25
|
+
*/
|
|
26
|
+
function padWithEmoji(str: string, width: number): string {
|
|
27
|
+
// Count emojis using Extended_Pictographic (excludes digits/symbols that \p{Emoji} matches)
|
|
28
|
+
const emojiCount = (str.match(/\p{Extended_Pictographic}/gu) || []).length
|
|
29
|
+
return str.padEnd(width + emojiCount)
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
export async function handleEngines(): Promise<void> {
|
|
22
33
|
console.clear()
|
|
23
34
|
console.log(header('Installed Engines'))
|
|
@@ -46,6 +57,9 @@ export async function handleEngines(): Promise<void> {
|
|
|
46
57
|
const mysqlEngine = engines.find(
|
|
47
58
|
(e): e is InstalledMysqlEngine => e.engine === 'mysql',
|
|
48
59
|
)
|
|
60
|
+
const sqliteEngine = engines.find(
|
|
61
|
+
(e): e is InstalledSqliteEngine => e.engine === 'sqlite',
|
|
62
|
+
)
|
|
49
63
|
|
|
50
64
|
const totalPgSize = pgEngines.reduce((acc, e) => acc + e.sizeBytes, 0)
|
|
51
65
|
|
|
@@ -62,10 +76,11 @@ export async function handleEngines(): Promise<void> {
|
|
|
62
76
|
for (const engine of pgEngines) {
|
|
63
77
|
const icon = getEngineIcon(engine.engine)
|
|
64
78
|
const platformInfo = `${engine.platform}-${engine.arch}`
|
|
79
|
+
const engineDisplay = `${icon} ${engine.engine}`
|
|
65
80
|
|
|
66
81
|
console.log(
|
|
67
82
|
chalk.gray(' ') +
|
|
68
|
-
chalk.cyan(
|
|
83
|
+
chalk.cyan(padWithEmoji(engineDisplay, 13)) +
|
|
69
84
|
chalk.yellow(engine.version.padEnd(12)) +
|
|
70
85
|
chalk.gray(platformInfo.padEnd(18)) +
|
|
71
86
|
chalk.white(formatBytes(engine.sizeBytes)),
|
|
@@ -75,16 +90,30 @@ export async function handleEngines(): Promise<void> {
|
|
|
75
90
|
if (mysqlEngine) {
|
|
76
91
|
const icon = ENGINE_ICONS.mysql
|
|
77
92
|
const displayName = mysqlEngine.isMariaDB ? 'mariadb' : 'mysql'
|
|
93
|
+
const engineDisplay = `${icon} ${displayName}`
|
|
78
94
|
|
|
79
95
|
console.log(
|
|
80
96
|
chalk.gray(' ') +
|
|
81
|
-
chalk.cyan(
|
|
97
|
+
chalk.cyan(padWithEmoji(engineDisplay, 13)) +
|
|
82
98
|
chalk.yellow(mysqlEngine.version.padEnd(12)) +
|
|
83
99
|
chalk.gray('system'.padEnd(18)) +
|
|
84
100
|
chalk.gray('(system-installed)'),
|
|
85
101
|
)
|
|
86
102
|
}
|
|
87
103
|
|
|
104
|
+
if (sqliteEngine) {
|
|
105
|
+
const icon = ENGINE_ICONS.sqlite
|
|
106
|
+
const engineDisplay = `${icon} sqlite`
|
|
107
|
+
|
|
108
|
+
console.log(
|
|
109
|
+
chalk.gray(' ') +
|
|
110
|
+
chalk.cyan(padWithEmoji(engineDisplay, 13)) +
|
|
111
|
+
chalk.yellow(sqliteEngine.version.padEnd(12)) +
|
|
112
|
+
chalk.gray('system'.padEnd(18)) +
|
|
113
|
+
chalk.gray('(system-installed)'),
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
88
117
|
console.log(chalk.gray(' ' + '─'.repeat(55)))
|
|
89
118
|
|
|
90
119
|
console.log()
|
|
@@ -98,6 +127,9 @@ export async function handleEngines(): Promise<void> {
|
|
|
98
127
|
if (mysqlEngine) {
|
|
99
128
|
console.log(chalk.gray(` MySQL: system-installed at ${mysqlEngine.path}`))
|
|
100
129
|
}
|
|
130
|
+
if (sqliteEngine) {
|
|
131
|
+
console.log(chalk.gray(` SQLite: system-installed at ${sqliteEngine.path}`))
|
|
132
|
+
}
|
|
101
133
|
console.log()
|
|
102
134
|
|
|
103
135
|
const choices: MenuChoice[] = []
|
|
@@ -117,6 +149,13 @@ export async function handleEngines(): Promise<void> {
|
|
|
117
149
|
})
|
|
118
150
|
}
|
|
119
151
|
|
|
152
|
+
if (sqliteEngine) {
|
|
153
|
+
choices.push({
|
|
154
|
+
name: `${chalk.blue('ℹ')} SQLite ${sqliteEngine.version} ${chalk.gray('(system-installed)')}`,
|
|
155
|
+
value: `sqlite-info:${sqliteEngine.path}`,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
120
159
|
choices.push(new inquirer.Separator())
|
|
121
160
|
choices.push({ name: `${chalk.blue('←')} Back to main menu`, value: 'back' })
|
|
122
161
|
|
|
@@ -135,16 +174,29 @@ export async function handleEngines(): Promise<void> {
|
|
|
135
174
|
}
|
|
136
175
|
|
|
137
176
|
if (action.startsWith('delete:')) {
|
|
138
|
-
|
|
177
|
+
// Parse from the end to preserve colons in path
|
|
178
|
+
// Format: delete:path:engineName:engineVersion
|
|
179
|
+
const withoutPrefix = action.slice('delete:'.length)
|
|
180
|
+
const lastColon = withoutPrefix.lastIndexOf(':')
|
|
181
|
+
const secondLastColon = withoutPrefix.lastIndexOf(':', lastColon - 1)
|
|
182
|
+
const enginePath = withoutPrefix.slice(0, secondLastColon)
|
|
183
|
+
const engineName = withoutPrefix.slice(secondLastColon + 1, lastColon)
|
|
184
|
+
const engineVersion = withoutPrefix.slice(lastColon + 1)
|
|
139
185
|
await handleDeleteEngine(enginePath, engineName, engineVersion)
|
|
140
186
|
await handleEngines()
|
|
141
187
|
}
|
|
142
188
|
|
|
143
189
|
if (action.startsWith('mysql-info:')) {
|
|
144
|
-
const mysqldPath = action.
|
|
190
|
+
const mysqldPath = action.slice('mysql-info:'.length)
|
|
145
191
|
await handleMysqlInfo(mysqldPath)
|
|
146
192
|
await handleEngines()
|
|
147
193
|
}
|
|
194
|
+
|
|
195
|
+
if (action.startsWith('sqlite-info:')) {
|
|
196
|
+
const sqlitePath = action.slice('sqlite-info:'.length)
|
|
197
|
+
await handleSqliteInfo(sqlitePath)
|
|
198
|
+
await handleEngines()
|
|
199
|
+
}
|
|
148
200
|
}
|
|
149
201
|
|
|
150
202
|
async function handleDeleteEngine(
|
|
@@ -360,3 +412,75 @@ async function handleMysqlInfo(mysqldPath: string): Promise<void> {
|
|
|
360
412
|
},
|
|
361
413
|
])
|
|
362
414
|
}
|
|
415
|
+
|
|
416
|
+
async function handleSqliteInfo(sqlitePath: string): Promise<void> {
|
|
417
|
+
console.clear()
|
|
418
|
+
|
|
419
|
+
console.log(header('SQLite Information'))
|
|
420
|
+
console.log()
|
|
421
|
+
|
|
422
|
+
// Get version
|
|
423
|
+
let version = 'unknown'
|
|
424
|
+
try {
|
|
425
|
+
const { exec } = await import('child_process')
|
|
426
|
+
const { promisify } = await import('util')
|
|
427
|
+
const execAsync = promisify(exec)
|
|
428
|
+
const { stdout } = await execAsync(`"${sqlitePath}" --version`)
|
|
429
|
+
const match = stdout.match(/^([\d.]+)/)
|
|
430
|
+
if (match) {
|
|
431
|
+
version = match[1]
|
|
432
|
+
}
|
|
433
|
+
} catch {
|
|
434
|
+
// Ignore
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const containers = await containerManager.list()
|
|
438
|
+
const sqliteContainers = containers.filter((c) => c.engine === 'sqlite')
|
|
439
|
+
|
|
440
|
+
if (sqliteContainers.length > 0) {
|
|
441
|
+
console.log(info(`${sqliteContainers.length} SQLite database(s) registered:`))
|
|
442
|
+
console.log()
|
|
443
|
+
for (const c of sqliteContainers) {
|
|
444
|
+
const status =
|
|
445
|
+
c.status === 'running'
|
|
446
|
+
? chalk.blue('🔵 available')
|
|
447
|
+
: chalk.gray('⚪ missing')
|
|
448
|
+
console.log(chalk.gray(` • ${c.name} ${status}`))
|
|
449
|
+
}
|
|
450
|
+
console.log()
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
console.log(chalk.white(' Installation Details:'))
|
|
454
|
+
console.log(chalk.gray(' ' + '─'.repeat(50)))
|
|
455
|
+
console.log(
|
|
456
|
+
chalk.gray(' ') +
|
|
457
|
+
chalk.white('Version:'.padEnd(18)) +
|
|
458
|
+
chalk.yellow(version),
|
|
459
|
+
)
|
|
460
|
+
console.log(
|
|
461
|
+
chalk.gray(' ') +
|
|
462
|
+
chalk.white('Binary Path:'.padEnd(18)) +
|
|
463
|
+
chalk.gray(sqlitePath),
|
|
464
|
+
)
|
|
465
|
+
console.log(
|
|
466
|
+
chalk.gray(' ') +
|
|
467
|
+
chalk.white('Type:'.padEnd(18)) +
|
|
468
|
+
chalk.cyan('Embedded (file-based)'),
|
|
469
|
+
)
|
|
470
|
+
console.log()
|
|
471
|
+
|
|
472
|
+
console.log(chalk.white(' Notes:'))
|
|
473
|
+
console.log(chalk.gray(' ' + '─'.repeat(50)))
|
|
474
|
+
console.log(chalk.gray(' • SQLite is typically pre-installed on macOS and most Linux distributions'))
|
|
475
|
+
console.log(chalk.gray(' • No server process - databases are just files'))
|
|
476
|
+
console.log(chalk.gray(' • Use "spindb delete <name>" to unregister a database'))
|
|
477
|
+
console.log()
|
|
478
|
+
|
|
479
|
+
await inquirer.prompt([
|
|
480
|
+
{
|
|
481
|
+
type: 'input',
|
|
482
|
+
name: 'continue',
|
|
483
|
+
message: chalk.gray('Press Enter to go back...'),
|
|
484
|
+
},
|
|
485
|
+
])
|
|
486
|
+
}
|
package/cli/commands/restore.ts
CHANGED
|
@@ -16,6 +16,8 @@ import { tmpdir } from 'os'
|
|
|
16
16
|
import { join } from 'path'
|
|
17
17
|
import { getMissingDependencies } from '../../core/dependency-manager'
|
|
18
18
|
import { platformService } from '../../core/platform-service'
|
|
19
|
+
import { TransactionManager } from '../../core/transaction-manager'
|
|
20
|
+
import { logDebug } from '../../core/error-handler'
|
|
19
21
|
|
|
20
22
|
export const restoreCommand = new Command('restore')
|
|
21
23
|
.description('Restore a backup to a container')
|
|
@@ -245,41 +247,99 @@ export const restoreCommand = new Command('restore')
|
|
|
245
247
|
const format = await engine.detectBackupFormat(backupPath)
|
|
246
248
|
detectSpinner.succeed(`Detected: ${format.description}`)
|
|
247
249
|
|
|
250
|
+
// Use TransactionManager to ensure database is cleaned up on restore failure
|
|
251
|
+
const tx = new TransactionManager()
|
|
252
|
+
let databaseCreated = false
|
|
253
|
+
|
|
248
254
|
const dbSpinner = createSpinner(
|
|
249
255
|
`Creating database "${databaseName}"...`,
|
|
250
256
|
)
|
|
251
257
|
dbSpinner.start()
|
|
252
258
|
|
|
253
|
-
|
|
254
|
-
|
|
259
|
+
try {
|
|
260
|
+
await engine.createDatabase(config, databaseName)
|
|
261
|
+
databaseCreated = true
|
|
262
|
+
dbSpinner.succeed(`Database "${databaseName}" ready`)
|
|
263
|
+
|
|
264
|
+
// Register rollback to drop database if restore fails
|
|
265
|
+
tx.addRollback({
|
|
266
|
+
description: `Drop database "${databaseName}"`,
|
|
267
|
+
execute: async () => {
|
|
268
|
+
try {
|
|
269
|
+
await engine.dropDatabase(config, databaseName)
|
|
270
|
+
logDebug(`Rolled back: dropped database "${databaseName}"`)
|
|
271
|
+
} catch (dropErr) {
|
|
272
|
+
logDebug(
|
|
273
|
+
`Failed to drop database during rollback: ${dropErr instanceof Error ? dropErr.message : String(dropErr)}`,
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
await containerManager.addDatabase(containerName, databaseName)
|
|
280
|
+
|
|
281
|
+
// Register rollback to remove database from container tracking
|
|
282
|
+
tx.addRollback({
|
|
283
|
+
description: `Remove "${databaseName}" from container tracking`,
|
|
284
|
+
execute: async () => {
|
|
285
|
+
try {
|
|
286
|
+
await containerManager.removeDatabase(
|
|
287
|
+
containerName,
|
|
288
|
+
databaseName,
|
|
289
|
+
)
|
|
290
|
+
logDebug(
|
|
291
|
+
`Rolled back: removed "${databaseName}" from container tracking`,
|
|
292
|
+
)
|
|
293
|
+
} catch (removeErr) {
|
|
294
|
+
logDebug(
|
|
295
|
+
`Failed to remove database from tracking during rollback: ${removeErr instanceof Error ? removeErr.message : String(removeErr)}`,
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
})
|
|
255
300
|
|
|
256
|
-
|
|
301
|
+
const restoreSpinner = createSpinner('Restoring backup...')
|
|
302
|
+
restoreSpinner.start()
|
|
257
303
|
|
|
258
|
-
|
|
259
|
-
|
|
304
|
+
const result = await engine.restore(config, backupPath, {
|
|
305
|
+
database: databaseName,
|
|
306
|
+
createDatabase: false,
|
|
307
|
+
})
|
|
260
308
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
309
|
+
// Check if restore completely failed (non-zero code with no data restored)
|
|
310
|
+
if (result.code !== 0 && result.stderr?.includes('FATAL')) {
|
|
311
|
+
restoreSpinner.fail('Restore failed')
|
|
312
|
+
throw new Error(result.stderr || 'Restore failed with fatal error')
|
|
313
|
+
}
|
|
265
314
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
315
|
+
if (result.code === 0) {
|
|
316
|
+
restoreSpinner.succeed('Backup restored successfully')
|
|
317
|
+
} else {
|
|
318
|
+
// pg_restore often returns warnings even on success
|
|
319
|
+
restoreSpinner.warn('Restore completed with warnings')
|
|
320
|
+
if (result.stderr) {
|
|
321
|
+
console.log(chalk.yellow('\n Warnings:'))
|
|
322
|
+
const lines = result.stderr.split('\n').slice(0, 5)
|
|
323
|
+
lines.forEach((line) => {
|
|
324
|
+
if (line.trim()) {
|
|
325
|
+
console.log(chalk.gray(` ${line}`))
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
if (result.stderr.split('\n').length > 5) {
|
|
329
|
+
console.log(chalk.gray(' ...'))
|
|
277
330
|
}
|
|
278
|
-
})
|
|
279
|
-
if (result.stderr.split('\n').length > 5) {
|
|
280
|
-
console.log(chalk.gray(' ...'))
|
|
281
331
|
}
|
|
282
332
|
}
|
|
333
|
+
|
|
334
|
+
// Restore succeeded - commit transaction (clear rollback actions)
|
|
335
|
+
tx.commit()
|
|
336
|
+
} catch (restoreErr) {
|
|
337
|
+
// Restore failed - execute rollbacks to clean up created database
|
|
338
|
+
if (databaseCreated) {
|
|
339
|
+
console.log(chalk.yellow('\n Cleaning up after failed restore...'))
|
|
340
|
+
await tx.rollback()
|
|
341
|
+
}
|
|
342
|
+
throw restoreErr
|
|
283
343
|
}
|
|
284
344
|
|
|
285
345
|
const connectionString = engine.getConnectionString(
|
package/cli/helpers.ts
CHANGED
|
@@ -30,7 +30,17 @@ export type InstalledMysqlEngine = {
|
|
|
30
30
|
isMariaDB: boolean
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export type
|
|
33
|
+
export type InstalledSqliteEngine = {
|
|
34
|
+
engine: 'sqlite'
|
|
35
|
+
version: string
|
|
36
|
+
path: string
|
|
37
|
+
source: 'system'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type InstalledEngine =
|
|
41
|
+
| InstalledPostgresEngine
|
|
42
|
+
| InstalledMysqlEngine
|
|
43
|
+
| InstalledSqliteEngine
|
|
34
44
|
|
|
35
45
|
async function getPostgresVersion(binPath: string): Promise<string | null> {
|
|
36
46
|
const postgresPath = join(binPath, 'bin', 'postgres')
|
|
@@ -125,6 +135,31 @@ async function getInstalledMysqlEngine(): Promise<InstalledMysqlEngine | null> {
|
|
|
125
135
|
}
|
|
126
136
|
}
|
|
127
137
|
|
|
138
|
+
async function getInstalledSqliteEngine(): Promise<InstalledSqliteEngine | null> {
|
|
139
|
+
try {
|
|
140
|
+
// TODO: Use 'where sqlite3' on Windows when adding Windows support
|
|
141
|
+
const { stdout: whichOutput } = await execAsync('which sqlite3')
|
|
142
|
+
const sqlitePath = whichOutput.trim()
|
|
143
|
+
if (!sqlitePath) {
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const { stdout: versionOutput } = await execAsync(`"${sqlitePath}" --version`)
|
|
148
|
+
// sqlite3 --version outputs: "3.43.2 2023-10-10 12:14:04 ..."
|
|
149
|
+
const versionMatch = versionOutput.match(/^([\d.]+)/)
|
|
150
|
+
const version = versionMatch ? versionMatch[1] : 'unknown'
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
engine: 'sqlite',
|
|
154
|
+
version,
|
|
155
|
+
path: sqlitePath,
|
|
156
|
+
source: 'system',
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
128
163
|
export function compareVersions(a: string, b: string): number {
|
|
129
164
|
const partsA = a.split('.').map((p) => parseInt(p, 10) || 0)
|
|
130
165
|
const partsB = b.split('.').map((p) => parseInt(p, 10) || 0)
|
|
@@ -148,5 +183,10 @@ export async function getInstalledEngines(): Promise<InstalledEngine[]> {
|
|
|
148
183
|
engines.push(mysqlEngine)
|
|
149
184
|
}
|
|
150
185
|
|
|
186
|
+
const sqliteEngine = await getInstalledSqliteEngine()
|
|
187
|
+
if (sqliteEngine) {
|
|
188
|
+
engines.push(sqliteEngine)
|
|
189
|
+
}
|
|
190
|
+
|
|
151
191
|
return engines
|
|
152
192
|
}
|
package/cli/ui/prompts.ts
CHANGED
|
@@ -258,7 +258,8 @@ export async function promptContainerSelect(
|
|
|
258
258
|
*/
|
|
259
259
|
function sanitizeDatabaseName(name: string): string {
|
|
260
260
|
// Replace invalid characters with underscores
|
|
261
|
-
|
|
261
|
+
// Note: hyphens are excluded because they require quoting in SQL
|
|
262
|
+
let sanitized = name.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
262
263
|
// Ensure it starts with a letter or underscore
|
|
263
264
|
if (sanitized && !/^[a-zA-Z_]/.test(sanitized)) {
|
|
264
265
|
sanitized = '_' + sanitized
|
|
@@ -267,6 +268,10 @@ function sanitizeDatabaseName(name: string): string {
|
|
|
267
268
|
sanitized = sanitized.replace(/_+/g, '_')
|
|
268
269
|
// Trim trailing underscores
|
|
269
270
|
sanitized = sanitized.replace(/_+$/, '')
|
|
271
|
+
// Fallback if result is empty (e.g., input was "---")
|
|
272
|
+
if (!sanitized) {
|
|
273
|
+
sanitized = 'db'
|
|
274
|
+
}
|
|
270
275
|
return sanitized
|
|
271
276
|
}
|
|
272
277
|
|
|
@@ -295,8 +300,9 @@ export async function promptDatabaseName(
|
|
|
295
300
|
validate: (input: string) => {
|
|
296
301
|
if (!input) return 'Database name is required'
|
|
297
302
|
// PostgreSQL database naming rules (also valid for MySQL)
|
|
298
|
-
|
|
299
|
-
|
|
303
|
+
// Hyphens excluded to avoid requiring quoted identifiers in SQL
|
|
304
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(input)) {
|
|
305
|
+
return 'Database name must start with a letter or underscore and contain only letters, numbers, and underscores'
|
|
300
306
|
}
|
|
301
307
|
if (input.length > 63) {
|
|
302
308
|
return 'Database name must be 63 characters or less'
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { existsSync } from 'fs'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
mkdir,
|
|
4
|
+
readdir,
|
|
5
|
+
readFile,
|
|
6
|
+
writeFile,
|
|
7
|
+
rm,
|
|
8
|
+
cp,
|
|
9
|
+
unlink,
|
|
10
|
+
rename as fsRename,
|
|
11
|
+
} from 'fs/promises'
|
|
3
12
|
import { paths } from '../config/paths'
|
|
4
13
|
import { processManager } from './process-manager'
|
|
5
14
|
import { portManager } from './port-manager'
|
|
@@ -76,7 +85,7 @@ export class ContainerManager {
|
|
|
76
85
|
|
|
77
86
|
if (engine) {
|
|
78
87
|
// SQLite uses registry instead of filesystem
|
|
79
|
-
if (engine ===
|
|
88
|
+
if (engine === Engine.SQLite) {
|
|
80
89
|
return this.getSqliteConfig(name)
|
|
81
90
|
}
|
|
82
91
|
|
|
@@ -201,7 +210,7 @@ export class ContainerManager {
|
|
|
201
210
|
|
|
202
211
|
if (engine) {
|
|
203
212
|
// SQLite uses registry
|
|
204
|
-
if (engine ===
|
|
213
|
+
if (engine === Engine.SQLite) {
|
|
205
214
|
return sqliteRegistry.exists(name)
|
|
206
215
|
}
|
|
207
216
|
const configPath = paths.getContainerConfigPath(name, { engine })
|
|
@@ -366,26 +375,35 @@ export class ContainerManager {
|
|
|
366
375
|
|
|
367
376
|
await cp(sourcePath, targetPath, { recursive: true })
|
|
368
377
|
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
378
|
+
// If anything fails after copy, clean up the target directory
|
|
379
|
+
try {
|
|
380
|
+
// Update target config
|
|
381
|
+
const config = await this.getConfig(targetName, { engine })
|
|
382
|
+
if (!config) {
|
|
383
|
+
throw new Error('Failed to read cloned container config')
|
|
384
|
+
}
|
|
374
385
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
386
|
+
config.name = targetName
|
|
387
|
+
config.created = new Date().toISOString()
|
|
388
|
+
config.clonedFrom = sourceName
|
|
378
389
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
390
|
+
// Assign new port (excluding ports already used by other containers)
|
|
391
|
+
const engineDefaults = getEngineDefaults(engine)
|
|
392
|
+
const { port } = await portManager.findAvailablePortExcludingContainers({
|
|
393
|
+
portRange: engineDefaults.portRange,
|
|
394
|
+
})
|
|
395
|
+
config.port = port
|
|
385
396
|
|
|
386
|
-
|
|
397
|
+
await this.saveConfig(targetName, { engine }, config)
|
|
387
398
|
|
|
388
|
-
|
|
399
|
+
return config
|
|
400
|
+
} catch (err) {
|
|
401
|
+
// Clean up the copied directory on failure
|
|
402
|
+
await rm(targetPath, { recursive: true, force: true }).catch(() => {
|
|
403
|
+
// Ignore cleanup errors
|
|
404
|
+
})
|
|
405
|
+
throw err
|
|
406
|
+
}
|
|
389
407
|
}
|
|
390
408
|
|
|
391
409
|
/**
|
|
@@ -419,7 +437,15 @@ export class ContainerManager {
|
|
|
419
437
|
throw new Error(`SQLite container "${oldName}" not found in registry`)
|
|
420
438
|
}
|
|
421
439
|
|
|
422
|
-
//
|
|
440
|
+
// Move container directory first (if it exists) - do filesystem ops before registry
|
|
441
|
+
// This way if the move fails, registry is unchanged
|
|
442
|
+
const oldContainerPath = paths.getContainerPath(oldName, { engine })
|
|
443
|
+
const newContainerPath = paths.getContainerPath(newName, { engine })
|
|
444
|
+
if (existsSync(oldContainerPath)) {
|
|
445
|
+
await this.atomicMoveDirectory(oldContainerPath, newContainerPath)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Now update registry - remove old entry and add new one with updated name
|
|
423
449
|
await sqliteRegistry.remove(oldName)
|
|
424
450
|
await sqliteRegistry.add({
|
|
425
451
|
name: newName,
|
|
@@ -428,14 +454,6 @@ export class ContainerManager {
|
|
|
428
454
|
lastVerified: entry.lastVerified,
|
|
429
455
|
})
|
|
430
456
|
|
|
431
|
-
// Rename container directory if it exists (created by containerManager.create)
|
|
432
|
-
const oldContainerPath = paths.getContainerPath(oldName, { engine })
|
|
433
|
-
const newContainerPath = paths.getContainerPath(newName, { engine })
|
|
434
|
-
if (existsSync(oldContainerPath)) {
|
|
435
|
-
await cp(oldContainerPath, newContainerPath, { recursive: true })
|
|
436
|
-
await rm(oldContainerPath, { recursive: true, force: true })
|
|
437
|
-
}
|
|
438
|
-
|
|
439
457
|
// Return updated config
|
|
440
458
|
return {
|
|
441
459
|
...sourceConfig,
|
|
@@ -453,8 +471,7 @@ export class ContainerManager {
|
|
|
453
471
|
const oldPath = paths.getContainerPath(oldName, { engine })
|
|
454
472
|
const newPath = paths.getContainerPath(newName, { engine })
|
|
455
473
|
|
|
456
|
-
await
|
|
457
|
-
await rm(oldPath, { recursive: true, force: true })
|
|
474
|
+
await this.atomicMoveDirectory(oldPath, newPath)
|
|
458
475
|
|
|
459
476
|
// Update config with new name
|
|
460
477
|
const config = await this.getConfig(newName, { engine })
|
|
@@ -468,6 +485,40 @@ export class ContainerManager {
|
|
|
468
485
|
return config
|
|
469
486
|
}
|
|
470
487
|
|
|
488
|
+
/**
|
|
489
|
+
* Move a directory atomically when possible, with copy+delete fallback.
|
|
490
|
+
* Uses fs.rename which is atomic on same filesystem, falls back to
|
|
491
|
+
* copy+delete for cross-filesystem moves (with cleanup on failure).
|
|
492
|
+
*/
|
|
493
|
+
private async atomicMoveDirectory(
|
|
494
|
+
sourcePath: string,
|
|
495
|
+
targetPath: string,
|
|
496
|
+
): Promise<void> {
|
|
497
|
+
try {
|
|
498
|
+
// Try atomic rename first (only works on same filesystem)
|
|
499
|
+
await fsRename(sourcePath, targetPath)
|
|
500
|
+
} catch (err) {
|
|
501
|
+
const e = err as NodeJS.ErrnoException
|
|
502
|
+
if (e.code === 'EXDEV') {
|
|
503
|
+
// Cross-filesystem move - fall back to copy+delete
|
|
504
|
+
await cp(sourcePath, targetPath, { recursive: true })
|
|
505
|
+
try {
|
|
506
|
+
await rm(sourcePath, { recursive: true, force: true })
|
|
507
|
+
} catch {
|
|
508
|
+
// If delete fails after copy, we have duplicates
|
|
509
|
+
// Try to clean up the target to avoid inconsistency
|
|
510
|
+
await rm(targetPath, { recursive: true, force: true }).catch(() => {})
|
|
511
|
+
throw new Error(
|
|
512
|
+
`Failed to complete move: source and target may both exist. ` +
|
|
513
|
+
`Please manually remove one of: ${sourcePath} or ${targetPath}`,
|
|
514
|
+
)
|
|
515
|
+
}
|
|
516
|
+
} else {
|
|
517
|
+
throw err
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
471
522
|
/**
|
|
472
523
|
* Validate container name
|
|
473
524
|
*/
|
package/core/error-handler.ts
CHANGED
|
@@ -56,6 +56,7 @@ export const ErrorCodes = {
|
|
|
56
56
|
CONTAINER_CREATE_FAILED: 'CONTAINER_CREATE_FAILED',
|
|
57
57
|
INIT_FAILED: 'INIT_FAILED',
|
|
58
58
|
DATABASE_CREATE_FAILED: 'DATABASE_CREATE_FAILED',
|
|
59
|
+
INVALID_DATABASE_NAME: 'INVALID_DATABASE_NAME',
|
|
59
60
|
|
|
60
61
|
// Dependency errors
|
|
61
62
|
DEPENDENCY_MISSING: 'DEPENDENCY_MISSING',
|
|
@@ -308,3 +309,33 @@ export function createDependencyMissingError(
|
|
|
308
309
|
{ toolName, engine },
|
|
309
310
|
)
|
|
310
311
|
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Validate a database name to prevent SQL injection.
|
|
315
|
+
* Database names must start with a letter and contain only
|
|
316
|
+
* alphanumeric characters and underscores.
|
|
317
|
+
*
|
|
318
|
+
* Note: Hyphens are excluded because they require quoted identifiers
|
|
319
|
+
* in SQL, which is error-prone for users.
|
|
320
|
+
*/
|
|
321
|
+
export function isValidDatabaseName(name: string): boolean {
|
|
322
|
+
// Must start with a letter to be valid in all database systems
|
|
323
|
+
// Hyphens excluded to avoid requiring quoted identifiers in SQL
|
|
324
|
+
return /^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Assert that a database name is valid, throwing SpinDBError if not.
|
|
329
|
+
* Use this at the entry points where database names are accepted.
|
|
330
|
+
*/
|
|
331
|
+
export function assertValidDatabaseName(name: string): void {
|
|
332
|
+
if (!isValidDatabaseName(name)) {
|
|
333
|
+
throw new SpinDBError(
|
|
334
|
+
ErrorCodes.INVALID_DATABASE_NAME,
|
|
335
|
+
`Invalid database name: "${name}"`,
|
|
336
|
+
'error',
|
|
337
|
+
'Database names must start with a letter and contain only letters, numbers, and underscores',
|
|
338
|
+
{ databaseName: name },
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
}
|
package/core/port-manager.ts
CHANGED
|
@@ -27,6 +27,8 @@ export class PortManager {
|
|
|
27
27
|
const server = net.createServer()
|
|
28
28
|
|
|
29
29
|
server.once('error', (err: NodeJS.ErrnoException) => {
|
|
30
|
+
// Always close the server to prevent resource leaks
|
|
31
|
+
server.close()
|
|
30
32
|
if (err.code === 'EADDRINUSE') {
|
|
31
33
|
resolve(false)
|
|
32
34
|
} else {
|