spindb 0.9.0 → 0.9.2
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/backup.ts +13 -11
- package/cli/commands/clone.ts +18 -8
- package/cli/commands/config.ts +29 -29
- package/cli/commands/connect.ts +51 -39
- package/cli/commands/create.ts +120 -43
- package/cli/commands/delete.ts +8 -8
- package/cli/commands/deps.ts +17 -15
- package/cli/commands/doctor.ts +16 -15
- package/cli/commands/edit.ts +115 -60
- package/cli/commands/engines.ts +50 -17
- package/cli/commands/info.ts +12 -8
- package/cli/commands/list.ts +34 -19
- package/cli/commands/logs.ts +24 -14
- package/cli/commands/menu/backup-handlers.ts +72 -49
- package/cli/commands/menu/container-handlers.ts +140 -80
- package/cli/commands/menu/engine-handlers.ts +145 -11
- package/cli/commands/menu/index.ts +4 -4
- package/cli/commands/menu/shell-handlers.ts +34 -31
- package/cli/commands/menu/sql-handlers.ts +22 -16
- package/cli/commands/menu/update-handlers.ts +19 -17
- package/cli/commands/restore.ts +105 -43
- package/cli/commands/run.ts +20 -18
- package/cli/commands/self-update.ts +5 -5
- package/cli/commands/start.ts +11 -9
- package/cli/commands/stop.ts +9 -9
- package/cli/commands/url.ts +12 -9
- package/cli/helpers.ts +49 -4
- package/cli/ui/prompts.ts +21 -8
- package/cli/ui/spinner.ts +4 -4
- package/cli/ui/theme.ts +4 -4
- package/core/binary-manager.ts +5 -1
- package/core/container-manager.ts +81 -30
- package/core/error-handler.ts +31 -0
- package/core/platform-service.ts +3 -3
- package/core/port-manager.ts +2 -0
- package/core/process-manager.ts +25 -3
- package/core/start-with-retry.ts +6 -6
- package/core/transaction-manager.ts +6 -6
- package/engines/mysql/backup.ts +53 -36
- package/engines/mysql/index.ts +59 -16
- package/engines/mysql/restore.ts +4 -4
- package/engines/mysql/version-validator.ts +2 -2
- package/engines/postgresql/binary-manager.ts +17 -17
- package/engines/postgresql/index.ts +13 -2
- package/engines/postgresql/restore.ts +2 -2
- package/engines/postgresql/version-validator.ts +2 -2
- package/engines/sqlite/index.ts +31 -9
- package/package.json +1 -1
package/cli/commands/stop.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { processManager } from '../../core/process-manager'
|
|
|
4
4
|
import { getEngine } from '../../engines'
|
|
5
5
|
import { promptContainerSelect } from '../ui/prompts'
|
|
6
6
|
import { createSpinner } from '../ui/spinner'
|
|
7
|
-
import {
|
|
7
|
+
import { uiSuccess, uiError, uiWarning } from '../ui/theme'
|
|
8
8
|
|
|
9
9
|
export const stopCommand = new Command('stop')
|
|
10
10
|
.description('Stop a container')
|
|
@@ -17,7 +17,7 @@ export const stopCommand = new Command('stop')
|
|
|
17
17
|
const running = containers.filter((c) => c.status === 'running')
|
|
18
18
|
|
|
19
19
|
if (running.length === 0) {
|
|
20
|
-
console.log(
|
|
20
|
+
console.log(uiWarning('No running containers found'))
|
|
21
21
|
return
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -34,7 +34,7 @@ export const stopCommand = new Command('stop')
|
|
|
34
34
|
spinner.succeed(`Stopped "${container.name}"`)
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
console.log(
|
|
37
|
+
console.log(uiSuccess(`Stopped ${running.length} container(s)`))
|
|
38
38
|
return
|
|
39
39
|
}
|
|
40
40
|
|
|
@@ -45,7 +45,7 @@ export const stopCommand = new Command('stop')
|
|
|
45
45
|
const running = containers.filter((c) => c.status === 'running')
|
|
46
46
|
|
|
47
47
|
if (running.length === 0) {
|
|
48
|
-
console.log(
|
|
48
|
+
console.log(uiWarning('No running containers found'))
|
|
49
49
|
return
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -59,7 +59,7 @@ export const stopCommand = new Command('stop')
|
|
|
59
59
|
|
|
60
60
|
const config = await containerManager.getConfig(containerName)
|
|
61
61
|
if (!config) {
|
|
62
|
-
console.error(
|
|
62
|
+
console.error(uiError(`Container "${containerName}" not found`))
|
|
63
63
|
process.exit(1)
|
|
64
64
|
}
|
|
65
65
|
|
|
@@ -67,7 +67,7 @@ export const stopCommand = new Command('stop')
|
|
|
67
67
|
engine: config.engine,
|
|
68
68
|
})
|
|
69
69
|
if (!running) {
|
|
70
|
-
console.log(
|
|
70
|
+
console.log(uiWarning(`Container "${containerName}" is not running`))
|
|
71
71
|
return
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -80,9 +80,9 @@ export const stopCommand = new Command('stop')
|
|
|
80
80
|
await containerManager.updateConfig(containerName, { status: 'stopped' })
|
|
81
81
|
|
|
82
82
|
spinner.succeed(`Container "${containerName}" stopped`)
|
|
83
|
-
} catch (
|
|
84
|
-
const e =
|
|
85
|
-
console.error(
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const e = error as Error
|
|
85
|
+
console.error(uiError(e.message))
|
|
86
86
|
process.exit(1)
|
|
87
87
|
}
|
|
88
88
|
})
|
package/cli/commands/url.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { containerManager } from '../../core/container-manager'
|
|
|
3
3
|
import { platformService } from '../../core/platform-service'
|
|
4
4
|
import { getEngine } from '../../engines'
|
|
5
5
|
import { promptContainerSelect } from '../ui/prompts'
|
|
6
|
-
import {
|
|
6
|
+
import { uiError, uiWarning, uiSuccess } from '../ui/theme'
|
|
7
7
|
|
|
8
8
|
export const urlCommand = new Command('url')
|
|
9
9
|
.alias('connection-string')
|
|
@@ -24,7 +24,7 @@ export const urlCommand = new Command('url')
|
|
|
24
24
|
const containers = await containerManager.list()
|
|
25
25
|
|
|
26
26
|
if (containers.length === 0) {
|
|
27
|
-
console.log(
|
|
27
|
+
console.log(uiWarning('No containers found'))
|
|
28
28
|
return
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -38,13 +38,16 @@ export const urlCommand = new Command('url')
|
|
|
38
38
|
|
|
39
39
|
const config = await containerManager.getConfig(containerName)
|
|
40
40
|
if (!config) {
|
|
41
|
-
console.error(
|
|
41
|
+
console.error(uiError(`Container "${containerName}" not found`))
|
|
42
42
|
process.exit(1)
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
const engine = getEngine(config.engine)
|
|
46
46
|
const databaseName = options.database || config.database
|
|
47
|
-
const connectionString = engine.getConnectionString(
|
|
47
|
+
const connectionString = engine.getConnectionString(
|
|
48
|
+
config,
|
|
49
|
+
databaseName,
|
|
50
|
+
)
|
|
48
51
|
|
|
49
52
|
if (options.json) {
|
|
50
53
|
const jsonOutput =
|
|
@@ -72,10 +75,10 @@ export const urlCommand = new Command('url')
|
|
|
72
75
|
const copied = await platformService.copyToClipboard(connectionString)
|
|
73
76
|
if (copied) {
|
|
74
77
|
console.log(connectionString)
|
|
75
|
-
console.error(
|
|
78
|
+
console.error(uiSuccess('Copied to clipboard'))
|
|
76
79
|
} else {
|
|
77
80
|
console.log(connectionString)
|
|
78
|
-
console.error(
|
|
81
|
+
console.error(uiWarning('Could not copy to clipboard'))
|
|
79
82
|
}
|
|
80
83
|
} else {
|
|
81
84
|
process.stdout.write(connectionString)
|
|
@@ -83,9 +86,9 @@ export const urlCommand = new Command('url')
|
|
|
83
86
|
console.log()
|
|
84
87
|
}
|
|
85
88
|
}
|
|
86
|
-
} catch (
|
|
87
|
-
const e =
|
|
88
|
-
console.error(
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const e = error as Error
|
|
91
|
+
console.error(uiError(e.message))
|
|
89
92
|
process.exit(1)
|
|
90
93
|
}
|
|
91
94
|
},
|
package/cli/helpers.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'fs'
|
|
2
2
|
import { readdir, lstat } from 'fs/promises'
|
|
3
3
|
import { join } from 'path'
|
|
4
|
-
import { exec } from 'child_process'
|
|
4
|
+
import { exec, execFile } from 'child_process'
|
|
5
5
|
import { promisify } from 'util'
|
|
6
6
|
import { paths } from '../config/paths'
|
|
7
7
|
import {
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from '../engines/mysql/binary-detection'
|
|
12
12
|
|
|
13
13
|
const execAsync = promisify(exec)
|
|
14
|
+
const execFileAsync = promisify(execFile)
|
|
14
15
|
|
|
15
16
|
export type InstalledPostgresEngine = {
|
|
16
17
|
engine: 'postgresql'
|
|
@@ -30,7 +31,17 @@ export type InstalledMysqlEngine = {
|
|
|
30
31
|
isMariaDB: boolean
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
export type
|
|
34
|
+
export type InstalledSqliteEngine = {
|
|
35
|
+
engine: 'sqlite'
|
|
36
|
+
version: string
|
|
37
|
+
path: string
|
|
38
|
+
source: 'system'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type InstalledEngine =
|
|
42
|
+
| InstalledPostgresEngine
|
|
43
|
+
| InstalledMysqlEngine
|
|
44
|
+
| InstalledSqliteEngine
|
|
34
45
|
|
|
35
46
|
async function getPostgresVersion(binPath: string): Promise<string | null> {
|
|
36
47
|
const postgresPath = join(binPath, 'bin', 'postgres')
|
|
@@ -39,7 +50,7 @@ async function getPostgresVersion(binPath: string): Promise<string | null> {
|
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
try {
|
|
42
|
-
const { stdout } = await
|
|
53
|
+
const { stdout } = await execFileAsync(postgresPath, ['--version'])
|
|
43
54
|
const match = stdout.match(/\(PostgreSQL\)\s+([\d.]+)/)
|
|
44
55
|
return match ? match[1] : null
|
|
45
56
|
} catch {
|
|
@@ -47,7 +58,9 @@ async function getPostgresVersion(binPath: string): Promise<string | null> {
|
|
|
47
58
|
}
|
|
48
59
|
}
|
|
49
60
|
|
|
50
|
-
export async function getInstalledPostgresEngines(): Promise<
|
|
61
|
+
export async function getInstalledPostgresEngines(): Promise<
|
|
62
|
+
InstalledPostgresEngine[]
|
|
63
|
+
> {
|
|
51
64
|
const binDir = paths.bin
|
|
52
65
|
|
|
53
66
|
if (!existsSync(binDir)) {
|
|
@@ -125,6 +138,33 @@ async function getInstalledMysqlEngine(): Promise<InstalledMysqlEngine | null> {
|
|
|
125
138
|
}
|
|
126
139
|
}
|
|
127
140
|
|
|
141
|
+
async function getInstalledSqliteEngine(): Promise<InstalledSqliteEngine | null> {
|
|
142
|
+
try {
|
|
143
|
+
// TODO: Use 'where sqlite3' on Windows when adding Windows support
|
|
144
|
+
const { stdout: whichOutput } = await execAsync('which sqlite3')
|
|
145
|
+
const sqlitePath = whichOutput.trim()
|
|
146
|
+
if (!sqlitePath) {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const { stdout: versionOutput } = await execFileAsync(sqlitePath, [
|
|
151
|
+
'--version',
|
|
152
|
+
])
|
|
153
|
+
// sqlite3 --version outputs: "3.43.2 2023-10-10 12:14:04 ..."
|
|
154
|
+
const versionMatch = versionOutput.match(/^([\d.]+)/)
|
|
155
|
+
const version = versionMatch ? versionMatch[1] : 'unknown'
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
engine: 'sqlite',
|
|
159
|
+
version,
|
|
160
|
+
path: sqlitePath,
|
|
161
|
+
source: 'system',
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
128
168
|
export function compareVersions(a: string, b: string): number {
|
|
129
169
|
const partsA = a.split('.').map((p) => parseInt(p, 10) || 0)
|
|
130
170
|
const partsB = b.split('.').map((p) => parseInt(p, 10) || 0)
|
|
@@ -148,5 +188,10 @@ export async function getInstalledEngines(): Promise<InstalledEngine[]> {
|
|
|
148
188
|
engines.push(mysqlEngine)
|
|
149
189
|
}
|
|
150
190
|
|
|
191
|
+
const sqliteEngine = await getInstalledSqliteEngine()
|
|
192
|
+
if (sqliteEngine) {
|
|
193
|
+
engines.push(sqliteEngine)
|
|
194
|
+
}
|
|
195
|
+
|
|
151
196
|
return engines
|
|
152
197
|
}
|
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
|
|
|
@@ -284,7 +289,9 @@ export async function promptDatabaseName(
|
|
|
284
289
|
engine === 'mysql' ? 'Database (schema) name:' : 'Database name:'
|
|
285
290
|
|
|
286
291
|
// Sanitize the default name to ensure it's valid
|
|
287
|
-
const sanitizedDefault = defaultName
|
|
292
|
+
const sanitizedDefault = defaultName
|
|
293
|
+
? sanitizeDatabaseName(defaultName)
|
|
294
|
+
: undefined
|
|
288
295
|
|
|
289
296
|
const { database } = await inquirer.prompt<{ database: string }>([
|
|
290
297
|
{
|
|
@@ -295,8 +302,9 @@ export async function promptDatabaseName(
|
|
|
295
302
|
validate: (input: string) => {
|
|
296
303
|
if (!input) return 'Database name is required'
|
|
297
304
|
// PostgreSQL database naming rules (also valid for MySQL)
|
|
298
|
-
|
|
299
|
-
|
|
305
|
+
// Hyphens excluded to avoid requiring quoted identifiers in SQL
|
|
306
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(input)) {
|
|
307
|
+
return 'Database name must start with a letter or underscore and contain only letters, numbers, and underscores'
|
|
300
308
|
}
|
|
301
309
|
if (input.length > 63) {
|
|
302
310
|
return 'Database name must be 63 characters or less'
|
|
@@ -414,7 +422,11 @@ export async function promptSqlitePath(
|
|
|
414
422
|
): Promise<string | undefined> {
|
|
415
423
|
const defaultPath = `./${containerName}.sqlite`
|
|
416
424
|
|
|
417
|
-
console.log(
|
|
425
|
+
console.log(
|
|
426
|
+
chalk.gray(
|
|
427
|
+
' SQLite databases are stored as files in your project directory.',
|
|
428
|
+
),
|
|
429
|
+
)
|
|
418
430
|
console.log(chalk.gray(` Default: ${defaultPath}`))
|
|
419
431
|
console.log()
|
|
420
432
|
|
|
@@ -490,7 +502,8 @@ export async function promptSqlitePath(
|
|
|
490
502
|
{
|
|
491
503
|
type: 'list',
|
|
492
504
|
name: 'overwrite',
|
|
493
|
-
message:
|
|
505
|
+
message:
|
|
506
|
+
'A file already exists at this location. What would you like to do?',
|
|
494
507
|
choices: [
|
|
495
508
|
{ name: 'Choose a different path', value: 'different' },
|
|
496
509
|
{ name: 'Cancel', value: 'cancel' },
|
|
@@ -696,8 +709,8 @@ export async function promptInstallDependencies(
|
|
|
696
709
|
|
|
697
710
|
return false
|
|
698
711
|
}
|
|
699
|
-
} catch (
|
|
700
|
-
const e =
|
|
712
|
+
} catch (error) {
|
|
713
|
+
const e = error as Error
|
|
701
714
|
console.log()
|
|
702
715
|
console.log(chalk.red(` Installation failed: ${e.message}`))
|
|
703
716
|
console.log()
|
package/cli/ui/spinner.ts
CHANGED
|
@@ -27,10 +27,10 @@ export async function withSpinner<T>(
|
|
|
27
27
|
})
|
|
28
28
|
spinner.succeed()
|
|
29
29
|
return result
|
|
30
|
-
} catch (
|
|
31
|
-
const
|
|
32
|
-
spinner.fail(
|
|
33
|
-
throw
|
|
30
|
+
} catch (error) {
|
|
31
|
+
const e = error as Error
|
|
32
|
+
spinner.fail(e.message)
|
|
33
|
+
throw e
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
package/cli/ui/theme.ts
CHANGED
|
@@ -60,28 +60,28 @@ ${chalk.cyan('└' + line + '┘')}
|
|
|
60
60
|
/**
|
|
61
61
|
* Format a success message
|
|
62
62
|
*/
|
|
63
|
-
export function
|
|
63
|
+
export function uiSuccess(message: string): string {
|
|
64
64
|
return `${theme.icons.success} ${message}`
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
68
|
* Format an error message
|
|
69
69
|
*/
|
|
70
|
-
export function
|
|
70
|
+
export function uiError(message: string): string {
|
|
71
71
|
return `${theme.icons.error} ${chalk.red(message)}`
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
75
|
* Format a warning message
|
|
76
76
|
*/
|
|
77
|
-
export function
|
|
77
|
+
export function uiWarning(message: string): string {
|
|
78
78
|
return `${theme.icons.warning} ${chalk.yellow(message)}`
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
82
|
* Format an info message
|
|
83
83
|
*/
|
|
84
|
-
export function
|
|
84
|
+
export function uiInfo(message: string): string {
|
|
85
85
|
return `${theme.icons.info} ${message}`
|
|
86
86
|
}
|
|
87
87
|
|
package/core/binary-manager.ts
CHANGED
|
@@ -6,7 +6,11 @@ import { exec } from 'child_process'
|
|
|
6
6
|
import { promisify } from 'util'
|
|
7
7
|
import { paths } from '../config/paths'
|
|
8
8
|
import { defaults } from '../config/defaults'
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
type Engine,
|
|
11
|
+
type ProgressCallback,
|
|
12
|
+
type InstalledBinary,
|
|
13
|
+
} from '../types'
|
|
10
14
|
|
|
11
15
|
const execAsync = promisify(exec)
|
|
12
16
|
|
|
@@ -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 (error) {
|
|
401
|
+
// Clean up the copied directory on failure
|
|
402
|
+
await rm(targetPath, { recursive: true, force: true }).catch(() => {
|
|
403
|
+
// Ignore cleanup errors
|
|
404
|
+
})
|
|
405
|
+
throw error
|
|
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 (error) {
|
|
501
|
+
const e = error 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 error
|
|
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/platform-service.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { homedir, platform as osPlatform, arch as osArch } from 'os'
|
|
14
|
-
import { execSync, exec, spawn } from 'child_process'
|
|
14
|
+
import { execSync, execFileSync, exec, spawn } from 'child_process'
|
|
15
15
|
import { promisify } from 'util'
|
|
16
16
|
import { existsSync } from 'fs'
|
|
17
17
|
|
|
@@ -205,7 +205,7 @@ class DarwinPlatformService extends BasePlatformService {
|
|
|
205
205
|
let getentResult: string | null = null
|
|
206
206
|
if (sudoUser) {
|
|
207
207
|
try {
|
|
208
|
-
getentResult =
|
|
208
|
+
getentResult = execFileSync('getent', ['passwd', sudoUser], {
|
|
209
209
|
encoding: 'utf-8',
|
|
210
210
|
})
|
|
211
211
|
} catch {
|
|
@@ -347,7 +347,7 @@ class LinuxPlatformService extends BasePlatformService {
|
|
|
347
347
|
let getentResult: string | null = null
|
|
348
348
|
if (sudoUser) {
|
|
349
349
|
try {
|
|
350
|
-
getentResult =
|
|
350
|
+
getentResult = execFileSync('getent', ['passwd', sudoUser], {
|
|
351
351
|
encoding: 'utf-8',
|
|
352
352
|
})
|
|
353
353
|
} catch {
|
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 {
|
package/core/process-manager.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { exec, spawn } from 'child_process'
|
|
2
2
|
import { promisify } from 'util'
|
|
3
3
|
import { existsSync } from 'fs'
|
|
4
|
-
import { readFile } from 'fs/promises'
|
|
4
|
+
import { readFile, rm } from 'fs/promises'
|
|
5
5
|
import { paths } from '../config/paths'
|
|
6
6
|
import { logDebug } from './error-handler'
|
|
7
7
|
import type { ProcessResult, StatusResult } from '../types'
|
|
@@ -42,6 +42,9 @@ export class ProcessManager {
|
|
|
42
42
|
): Promise<ProcessResult> {
|
|
43
43
|
const { superuser = 'postgres' } = options
|
|
44
44
|
|
|
45
|
+
// Track if directory existed before initdb (to know if we should clean up)
|
|
46
|
+
const dirExistedBefore = existsSync(dataDir)
|
|
47
|
+
|
|
45
48
|
const args = [
|
|
46
49
|
'-D',
|
|
47
50
|
dataDir,
|
|
@@ -52,6 +55,21 @@ export class ProcessManager {
|
|
|
52
55
|
'--no-locale',
|
|
53
56
|
]
|
|
54
57
|
|
|
58
|
+
// Helper to clean up data directory on failure
|
|
59
|
+
const cleanupOnFailure = async () => {
|
|
60
|
+
// Only clean up if initdb created the directory (it didn't exist before)
|
|
61
|
+
if (!dirExistedBefore && existsSync(dataDir)) {
|
|
62
|
+
try {
|
|
63
|
+
await rm(dataDir, { recursive: true, force: true })
|
|
64
|
+
logDebug(`Cleaned up data directory after initdb failure: ${dataDir}`)
|
|
65
|
+
} catch (cleanupErr) {
|
|
66
|
+
logDebug(
|
|
67
|
+
`Failed to clean up data directory: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`,
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
55
73
|
return new Promise((resolve, reject) => {
|
|
56
74
|
const proc = spawn(initdbPath, args, {
|
|
57
75
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -67,15 +85,19 @@ export class ProcessManager {
|
|
|
67
85
|
stderr += data.toString()
|
|
68
86
|
})
|
|
69
87
|
|
|
70
|
-
proc.on('close', (code) => {
|
|
88
|
+
proc.on('close', async (code) => {
|
|
71
89
|
if (code === 0) {
|
|
72
90
|
resolve({ stdout, stderr })
|
|
73
91
|
} else {
|
|
92
|
+
await cleanupOnFailure()
|
|
74
93
|
reject(new Error(`initdb failed with code ${code}: ${stderr}`))
|
|
75
94
|
}
|
|
76
95
|
})
|
|
77
96
|
|
|
78
|
-
proc.on('error',
|
|
97
|
+
proc.on('error', async (err) => {
|
|
98
|
+
await cleanupOnFailure()
|
|
99
|
+
reject(err)
|
|
100
|
+
})
|
|
79
101
|
})
|
|
80
102
|
}
|
|
81
103
|
|