spindb 0.1.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/.claude/settings.local.json +20 -0
- package/.env.example +1 -0
- package/.prettierignore +4 -0
- package/.prettierrc +6 -0
- package/CLAUDE.md +162 -0
- package/README.md +204 -0
- package/TODO.md +66 -0
- package/bin/cli.js +7 -0
- package/eslint.config.js +18 -0
- package/package.json +52 -0
- package/seeds/mysql/sample-db.sql +22 -0
- package/seeds/postgres/sample-db.sql +27 -0
- package/src/bin/cli.ts +8 -0
- package/src/cli/commands/clone.ts +101 -0
- package/src/cli/commands/config.ts +215 -0
- package/src/cli/commands/connect.ts +106 -0
- package/src/cli/commands/create.ts +148 -0
- package/src/cli/commands/delete.ts +94 -0
- package/src/cli/commands/list.ts +69 -0
- package/src/cli/commands/menu.ts +675 -0
- package/src/cli/commands/restore.ts +161 -0
- package/src/cli/commands/start.ts +95 -0
- package/src/cli/commands/stop.ts +91 -0
- package/src/cli/index.ts +38 -0
- package/src/cli/ui/prompts.ts +197 -0
- package/src/cli/ui/spinner.ts +94 -0
- package/src/cli/ui/theme.ts +113 -0
- package/src/config/defaults.ts +49 -0
- package/src/config/paths.ts +53 -0
- package/src/core/binary-manager.ts +239 -0
- package/src/core/config-manager.ts +259 -0
- package/src/core/container-manager.ts +234 -0
- package/src/core/port-manager.ts +84 -0
- package/src/core/process-manager.ts +353 -0
- package/src/engines/base-engine.ts +103 -0
- package/src/engines/index.ts +46 -0
- package/src/engines/postgresql/binary-urls.ts +52 -0
- package/src/engines/postgresql/index.ts +298 -0
- package/src/engines/postgresql/restore.ts +173 -0
- package/src/types/index.ts +97 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Color theme for spindb CLI
|
|
5
|
+
*/
|
|
6
|
+
export const theme = {
|
|
7
|
+
// Brand colors
|
|
8
|
+
primary: chalk.cyan,
|
|
9
|
+
secondary: chalk.gray,
|
|
10
|
+
accent: chalk.magenta,
|
|
11
|
+
|
|
12
|
+
// Status colors
|
|
13
|
+
success: chalk.green,
|
|
14
|
+
error: chalk.red,
|
|
15
|
+
warning: chalk.yellow,
|
|
16
|
+
info: chalk.blue,
|
|
17
|
+
|
|
18
|
+
// Text styles
|
|
19
|
+
bold: chalk.bold,
|
|
20
|
+
dim: chalk.dim,
|
|
21
|
+
italic: chalk.italic,
|
|
22
|
+
|
|
23
|
+
// Semantic helpers
|
|
24
|
+
containerName: chalk.cyan.bold,
|
|
25
|
+
version: chalk.yellow,
|
|
26
|
+
port: chalk.green,
|
|
27
|
+
path: chalk.gray,
|
|
28
|
+
command: chalk.cyan,
|
|
29
|
+
|
|
30
|
+
// Status badges
|
|
31
|
+
running: chalk.green.bold('● running'),
|
|
32
|
+
stopped: chalk.gray('○ stopped'),
|
|
33
|
+
created: chalk.blue('◐ created'),
|
|
34
|
+
|
|
35
|
+
// Icons
|
|
36
|
+
icons: {
|
|
37
|
+
success: chalk.green('✔'),
|
|
38
|
+
error: chalk.red('✖'),
|
|
39
|
+
warning: chalk.yellow('⚠'),
|
|
40
|
+
info: chalk.blue('ℹ'),
|
|
41
|
+
arrow: chalk.cyan('→'),
|
|
42
|
+
bullet: chalk.gray('•'),
|
|
43
|
+
database: '🗄️',
|
|
44
|
+
postgres: '🐘',
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Format a header box
|
|
50
|
+
*/
|
|
51
|
+
export function header(text: string): string {
|
|
52
|
+
const line = '─'.repeat(text.length + 4)
|
|
53
|
+
return `
|
|
54
|
+
${chalk.cyan('┌' + line + '┐')}
|
|
55
|
+
${chalk.cyan('│')} ${chalk.bold(text)} ${chalk.cyan('│')}
|
|
56
|
+
${chalk.cyan('└' + line + '┘')}
|
|
57
|
+
`.trim()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Format a success message
|
|
62
|
+
*/
|
|
63
|
+
export function success(message: string): string {
|
|
64
|
+
return `${theme.icons.success} ${message}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Format an error message
|
|
69
|
+
*/
|
|
70
|
+
export function error(message: string): string {
|
|
71
|
+
return `${theme.icons.error} ${chalk.red(message)}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Format a warning message
|
|
76
|
+
*/
|
|
77
|
+
export function warning(message: string): string {
|
|
78
|
+
return `${theme.icons.warning} ${chalk.yellow(message)}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Format an info message
|
|
83
|
+
*/
|
|
84
|
+
export function info(message: string): string {
|
|
85
|
+
return `${theme.icons.info} ${message}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Format a key-value pair
|
|
90
|
+
*/
|
|
91
|
+
export function keyValue(key: string, value: string): string {
|
|
92
|
+
return `${chalk.gray(key + ':')} ${value}`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Format a connection string box
|
|
97
|
+
*/
|
|
98
|
+
export function connectionBox(
|
|
99
|
+
name: string,
|
|
100
|
+
connectionString: string,
|
|
101
|
+
port: number,
|
|
102
|
+
): string {
|
|
103
|
+
return `
|
|
104
|
+
${chalk.cyan('┌─────────────────────────────────────────┐')}
|
|
105
|
+
${chalk.cyan('│')} ${theme.icons.success} Container ${chalk.bold(name)} is ready! ${chalk.cyan('│')}
|
|
106
|
+
${chalk.cyan('│')} ${chalk.cyan('│')}
|
|
107
|
+
${chalk.cyan('│')} ${chalk.gray('Connection string:')} ${chalk.cyan('│')}
|
|
108
|
+
${chalk.cyan('│')} ${chalk.white(connectionString)} ${chalk.cyan('│')}
|
|
109
|
+
${chalk.cyan('│')} ${chalk.cyan('│')}
|
|
110
|
+
${chalk.cyan('│')} ${chalk.gray('Port:')} ${chalk.green(String(port))} ${chalk.cyan('│')}
|
|
111
|
+
${chalk.cyan('└─────────────────────────────────────────┘')}
|
|
112
|
+
`.trim()
|
|
113
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface PlatformMappings {
|
|
2
|
+
[key: string]: string
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface PortRange {
|
|
6
|
+
start: number
|
|
7
|
+
end: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Defaults {
|
|
11
|
+
postgresVersion: string
|
|
12
|
+
port: number
|
|
13
|
+
portRange: PortRange
|
|
14
|
+
engine: string
|
|
15
|
+
supportedPostgresVersions: string[]
|
|
16
|
+
superuser: string
|
|
17
|
+
platformMappings: PlatformMappings
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const defaults: Defaults = {
|
|
21
|
+
// Default PostgreSQL version
|
|
22
|
+
postgresVersion: '16',
|
|
23
|
+
|
|
24
|
+
// Default port (standard PostgreSQL port)
|
|
25
|
+
port: 5432,
|
|
26
|
+
|
|
27
|
+
// Port range to scan if default is busy
|
|
28
|
+
portRange: {
|
|
29
|
+
start: 5432,
|
|
30
|
+
end: 5500,
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
// Default engine
|
|
34
|
+
engine: 'postgresql',
|
|
35
|
+
|
|
36
|
+
// Supported PostgreSQL versions
|
|
37
|
+
supportedPostgresVersions: ['14', '15', '16', '17'],
|
|
38
|
+
|
|
39
|
+
// Default superuser
|
|
40
|
+
superuser: 'postgres',
|
|
41
|
+
|
|
42
|
+
// Platform mappings for zonky.io binaries
|
|
43
|
+
platformMappings: {
|
|
44
|
+
'darwin-arm64': 'darwin-arm64v8',
|
|
45
|
+
'darwin-x64': 'darwin-amd64',
|
|
46
|
+
'linux-arm64': 'linux-arm64v8',
|
|
47
|
+
'linux-x64': 'linux-amd64',
|
|
48
|
+
},
|
|
49
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { homedir } from 'os'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
|
|
4
|
+
const SPINDB_HOME = join(homedir(), '.spindb')
|
|
5
|
+
|
|
6
|
+
export const paths = {
|
|
7
|
+
// Root directory for all spindb data
|
|
8
|
+
root: SPINDB_HOME,
|
|
9
|
+
|
|
10
|
+
// Directory for downloaded database binaries
|
|
11
|
+
bin: join(SPINDB_HOME, 'bin'),
|
|
12
|
+
|
|
13
|
+
// Directory for container data
|
|
14
|
+
containers: join(SPINDB_HOME, 'containers'),
|
|
15
|
+
|
|
16
|
+
// Global config file
|
|
17
|
+
config: join(SPINDB_HOME, 'config.json'),
|
|
18
|
+
|
|
19
|
+
// Get path for a specific binary version
|
|
20
|
+
getBinaryPath(
|
|
21
|
+
engine: string,
|
|
22
|
+
version: string,
|
|
23
|
+
platform: string,
|
|
24
|
+
arch: string,
|
|
25
|
+
): string {
|
|
26
|
+
return join(this.bin, `${engine}-${version}-${platform}-${arch}`)
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
// Get path for a specific container
|
|
30
|
+
getContainerPath(name: string): string {
|
|
31
|
+
return join(this.containers, name)
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
// Get path for container config
|
|
35
|
+
getContainerConfigPath(name: string): string {
|
|
36
|
+
return join(this.containers, name, 'container.json')
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
// Get path for container data directory
|
|
40
|
+
getContainerDataPath(name: string): string {
|
|
41
|
+
return join(this.containers, name, 'data')
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
// Get path for container log file
|
|
45
|
+
getContainerLogPath(name: string): string {
|
|
46
|
+
return join(this.containers, name, 'postgres.log')
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
// Get path for container PID file
|
|
50
|
+
getContainerPidPath(name: string): string {
|
|
51
|
+
return join(this.containers, name, 'data', 'postmaster.pid')
|
|
52
|
+
},
|
|
53
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { createWriteStream, existsSync } from 'fs'
|
|
2
|
+
import { mkdir, readdir, rm, chmod } from 'fs/promises'
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
import { pipeline } from 'stream/promises'
|
|
5
|
+
import { exec } from 'child_process'
|
|
6
|
+
import { promisify } from 'util'
|
|
7
|
+
import { paths } from '@/config/paths'
|
|
8
|
+
import { defaults } from '@/config/defaults'
|
|
9
|
+
import type { ProgressCallback, InstalledBinary } from '@/types'
|
|
10
|
+
|
|
11
|
+
const execAsync = promisify(exec)
|
|
12
|
+
|
|
13
|
+
export class BinaryManager {
|
|
14
|
+
/**
|
|
15
|
+
* Get the download URL for a PostgreSQL version
|
|
16
|
+
*/
|
|
17
|
+
getDownloadUrl(version: string, platform: string, arch: string): string {
|
|
18
|
+
const platformKey = `${platform}-${arch}`
|
|
19
|
+
const zonkyPlatform = defaults.platformMappings[platformKey]
|
|
20
|
+
|
|
21
|
+
if (!zonkyPlatform) {
|
|
22
|
+
throw new Error(`Unsupported platform: ${platformKey}`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Zonky.io Maven Central URL pattern
|
|
26
|
+
const fullVersion = this.getFullVersion(version)
|
|
27
|
+
return `https://repo1.maven.org/maven2/io/zonky/test/postgres/embedded-postgres-binaries-${zonkyPlatform}/${fullVersion}/embedded-postgres-binaries-${zonkyPlatform}-${fullVersion}.jar`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Convert major version to full version (e.g., "16" -> "16.4.0")
|
|
32
|
+
*/
|
|
33
|
+
getFullVersion(majorVersion: string): string {
|
|
34
|
+
const versionMap: Record<string, string> = {
|
|
35
|
+
'14': '14.15.0',
|
|
36
|
+
'15': '15.10.0',
|
|
37
|
+
'16': '16.6.0',
|
|
38
|
+
'17': '17.2.0',
|
|
39
|
+
}
|
|
40
|
+
return versionMap[majorVersion] || `${majorVersion}.0.0`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if binaries for a specific version are already installed
|
|
45
|
+
*/
|
|
46
|
+
async isInstalled(
|
|
47
|
+
version: string,
|
|
48
|
+
platform: string,
|
|
49
|
+
arch: string,
|
|
50
|
+
): Promise<boolean> {
|
|
51
|
+
const binPath = paths.getBinaryPath('postgresql', version, platform, arch)
|
|
52
|
+
const postgresPath = join(binPath, 'bin', 'postgres')
|
|
53
|
+
return existsSync(postgresPath)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* List all installed PostgreSQL versions
|
|
58
|
+
*/
|
|
59
|
+
async listInstalled(): Promise<InstalledBinary[]> {
|
|
60
|
+
const binDir = paths.bin
|
|
61
|
+
if (!existsSync(binDir)) {
|
|
62
|
+
return []
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const entries = await readdir(binDir, { withFileTypes: true })
|
|
66
|
+
const installed: InstalledBinary[] = []
|
|
67
|
+
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (entry.isDirectory() && entry.name.startsWith('postgresql-')) {
|
|
70
|
+
const parts = entry.name.split('-')
|
|
71
|
+
if (parts.length >= 4) {
|
|
72
|
+
installed.push({
|
|
73
|
+
engine: parts[0],
|
|
74
|
+
version: parts[1],
|
|
75
|
+
platform: parts[2],
|
|
76
|
+
arch: parts[3],
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return installed
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Download and extract PostgreSQL binaries
|
|
87
|
+
*
|
|
88
|
+
* The zonky.io JAR files are ZIP archives containing a .txz (tar.xz) file.
|
|
89
|
+
* We need to: 1) unzip the JAR, 2) extract the .txz inside
|
|
90
|
+
*/
|
|
91
|
+
async download(
|
|
92
|
+
version: string,
|
|
93
|
+
platform: string,
|
|
94
|
+
arch: string,
|
|
95
|
+
onProgress?: ProgressCallback,
|
|
96
|
+
): Promise<string> {
|
|
97
|
+
const url = this.getDownloadUrl(version, platform, arch)
|
|
98
|
+
const binPath = paths.getBinaryPath('postgresql', version, platform, arch)
|
|
99
|
+
const tempDir = join(paths.bin, `temp-${version}-${platform}-${arch}`)
|
|
100
|
+
const jarFile = join(tempDir, 'postgres.jar')
|
|
101
|
+
|
|
102
|
+
// Ensure directories exist
|
|
103
|
+
await mkdir(paths.bin, { recursive: true })
|
|
104
|
+
await mkdir(tempDir, { recursive: true })
|
|
105
|
+
await mkdir(binPath, { recursive: true })
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
// Download the JAR file
|
|
109
|
+
onProgress?.({
|
|
110
|
+
stage: 'downloading',
|
|
111
|
+
message: 'Downloading PostgreSQL binaries...',
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const response = await fetch(url)
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Failed to download binaries: ${response.status} ${response.statusText}`,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const fileStream = createWriteStream(jarFile)
|
|
122
|
+
// @ts-expect-error - response.body is ReadableStream
|
|
123
|
+
await pipeline(response.body, fileStream)
|
|
124
|
+
|
|
125
|
+
// Extract the JAR (it's a ZIP file)
|
|
126
|
+
onProgress?.({
|
|
127
|
+
stage: 'extracting',
|
|
128
|
+
message: 'Extracting binaries (step 1/2)...',
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
await execAsync(`unzip -q -o "${jarFile}" -d "${tempDir}"`)
|
|
132
|
+
|
|
133
|
+
// Find and extract the .txz file inside
|
|
134
|
+
onProgress?.({
|
|
135
|
+
stage: 'extracting',
|
|
136
|
+
message: 'Extracting binaries (step 2/2)...',
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const { stdout: findOutput } = await execAsync(
|
|
140
|
+
`find "${tempDir}" -name "*.txz" -o -name "*.tar.xz" | head -1`,
|
|
141
|
+
)
|
|
142
|
+
const txzFile = findOutput.trim()
|
|
143
|
+
|
|
144
|
+
if (!txzFile) {
|
|
145
|
+
throw new Error('Could not find .txz file in downloaded archive')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Extract the tar.xz file (no strip-components since files are at root level)
|
|
149
|
+
await execAsync(`tar -xJf "${txzFile}" -C "${binPath}"`)
|
|
150
|
+
|
|
151
|
+
// Make binaries executable
|
|
152
|
+
const binDir = join(binPath, 'bin')
|
|
153
|
+
if (existsSync(binDir)) {
|
|
154
|
+
const binaries = await readdir(binDir)
|
|
155
|
+
for (const binary of binaries) {
|
|
156
|
+
await chmod(join(binDir, binary), 0o755)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Verify the installation
|
|
161
|
+
onProgress?.({ stage: 'verifying', message: 'Verifying installation...' })
|
|
162
|
+
await this.verify(version, platform, arch)
|
|
163
|
+
|
|
164
|
+
return binPath
|
|
165
|
+
} finally {
|
|
166
|
+
// Clean up temp directory
|
|
167
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Verify that PostgreSQL binaries are working
|
|
173
|
+
*/
|
|
174
|
+
async verify(
|
|
175
|
+
version: string,
|
|
176
|
+
platform: string,
|
|
177
|
+
arch: string,
|
|
178
|
+
): Promise<boolean> {
|
|
179
|
+
const binPath = paths.getBinaryPath('postgresql', version, platform, arch)
|
|
180
|
+
const postgresPath = join(binPath, 'bin', 'postgres')
|
|
181
|
+
|
|
182
|
+
if (!existsSync(postgresPath)) {
|
|
183
|
+
throw new Error(`PostgreSQL binary not found at ${postgresPath}`)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const { stdout } = await execAsync(`"${postgresPath}" --version`)
|
|
188
|
+
const match = stdout.match(/postgres \(PostgreSQL\) (\d+)/)
|
|
189
|
+
if (match && match[1] === version) {
|
|
190
|
+
return true
|
|
191
|
+
}
|
|
192
|
+
// Version might be more specific (e.g., 16.4), so also check if it starts with the major version
|
|
193
|
+
if (stdout.includes(`PostgreSQL) ${version}`)) {
|
|
194
|
+
return true
|
|
195
|
+
}
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Version mismatch: expected ${version}, got ${stdout.trim()}`,
|
|
198
|
+
)
|
|
199
|
+
} catch (error) {
|
|
200
|
+
const err = error as Error
|
|
201
|
+
throw new Error(`Failed to verify PostgreSQL binaries: ${err.message}`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Get the path to a specific binary (postgres, pg_ctl, psql, etc.)
|
|
207
|
+
*/
|
|
208
|
+
getBinaryExecutable(
|
|
209
|
+
version: string,
|
|
210
|
+
platform: string,
|
|
211
|
+
arch: string,
|
|
212
|
+
binary: string,
|
|
213
|
+
): string {
|
|
214
|
+
const binPath = paths.getBinaryPath('postgresql', version, platform, arch)
|
|
215
|
+
return join(binPath, 'bin', binary)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Ensure binaries are available, downloading if necessary
|
|
220
|
+
*/
|
|
221
|
+
async ensureInstalled(
|
|
222
|
+
version: string,
|
|
223
|
+
platform: string,
|
|
224
|
+
arch: string,
|
|
225
|
+
onProgress?: ProgressCallback,
|
|
226
|
+
): Promise<string> {
|
|
227
|
+
if (await this.isInstalled(version, platform, arch)) {
|
|
228
|
+
onProgress?.({
|
|
229
|
+
stage: 'cached',
|
|
230
|
+
message: 'Using cached PostgreSQL binaries',
|
|
231
|
+
})
|
|
232
|
+
return paths.getBinaryPath('postgresql', version, platform, arch)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return this.download(version, platform, arch, onProgress)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export const binaryManager = new BinaryManager()
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { existsSync } from 'fs'
|
|
2
|
+
import { readFile, writeFile, mkdir } from 'fs/promises'
|
|
3
|
+
import { exec } from 'child_process'
|
|
4
|
+
import { promisify } from 'util'
|
|
5
|
+
import { dirname } from 'path'
|
|
6
|
+
import { paths } from '@/config/paths'
|
|
7
|
+
import type {
|
|
8
|
+
SpinDBConfig,
|
|
9
|
+
BinaryConfig,
|
|
10
|
+
BinaryTool,
|
|
11
|
+
BinarySource,
|
|
12
|
+
} from '@/types'
|
|
13
|
+
|
|
14
|
+
const execAsync = promisify(exec)
|
|
15
|
+
|
|
16
|
+
const DEFAULT_CONFIG: SpinDBConfig = {
|
|
17
|
+
binaries: {},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class ConfigManager {
|
|
21
|
+
private config: SpinDBConfig | null = null
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Load config from disk, creating default if it doesn't exist
|
|
25
|
+
*/
|
|
26
|
+
async load(): Promise<SpinDBConfig> {
|
|
27
|
+
if (this.config) {
|
|
28
|
+
return this.config
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const configPath = paths.config
|
|
32
|
+
|
|
33
|
+
if (!existsSync(configPath)) {
|
|
34
|
+
// Create default config
|
|
35
|
+
this.config = { ...DEFAULT_CONFIG }
|
|
36
|
+
await this.save()
|
|
37
|
+
return this.config
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const content = await readFile(configPath, 'utf8')
|
|
42
|
+
this.config = JSON.parse(content) as SpinDBConfig
|
|
43
|
+
return this.config
|
|
44
|
+
} catch {
|
|
45
|
+
// If config is corrupted, reset to default
|
|
46
|
+
this.config = { ...DEFAULT_CONFIG }
|
|
47
|
+
await this.save()
|
|
48
|
+
return this.config
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Save config to disk
|
|
54
|
+
*/
|
|
55
|
+
async save(): Promise<void> {
|
|
56
|
+
const configPath = paths.config
|
|
57
|
+
|
|
58
|
+
// Ensure directory exists
|
|
59
|
+
await mkdir(dirname(configPath), { recursive: true })
|
|
60
|
+
|
|
61
|
+
if (this.config) {
|
|
62
|
+
this.config.updatedAt = new Date().toISOString()
|
|
63
|
+
await writeFile(configPath, JSON.stringify(this.config, null, 2))
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get the path for a binary tool, detecting from system if not configured
|
|
69
|
+
*/
|
|
70
|
+
async getBinaryPath(tool: BinaryTool): Promise<string | null> {
|
|
71
|
+
const config = await this.load()
|
|
72
|
+
|
|
73
|
+
// Check if we have a configured path
|
|
74
|
+
const binaryConfig = config.binaries[tool]
|
|
75
|
+
if (binaryConfig?.path) {
|
|
76
|
+
// Verify it still exists
|
|
77
|
+
if (existsSync(binaryConfig.path)) {
|
|
78
|
+
return binaryConfig.path
|
|
79
|
+
}
|
|
80
|
+
// Path no longer valid, clear it
|
|
81
|
+
delete config.binaries[tool]
|
|
82
|
+
await this.save()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Try to detect from system
|
|
86
|
+
const systemPath = await this.detectSystemBinary(tool)
|
|
87
|
+
if (systemPath) {
|
|
88
|
+
await this.setBinaryPath(tool, systemPath, 'system')
|
|
89
|
+
return systemPath
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Set the path for a binary tool
|
|
97
|
+
*/
|
|
98
|
+
async setBinaryPath(
|
|
99
|
+
tool: BinaryTool,
|
|
100
|
+
path: string,
|
|
101
|
+
source: BinarySource,
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
const config = await this.load()
|
|
104
|
+
|
|
105
|
+
// Get version if possible
|
|
106
|
+
let version: string | undefined
|
|
107
|
+
try {
|
|
108
|
+
const { stdout } = await execAsync(`"${path}" --version`)
|
|
109
|
+
const match = stdout.match(/\d+\.\d+/)
|
|
110
|
+
if (match) {
|
|
111
|
+
version = match[0]
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// Version detection failed, that's ok
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
config.binaries[tool] = {
|
|
118
|
+
tool,
|
|
119
|
+
path,
|
|
120
|
+
source,
|
|
121
|
+
version,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
await this.save()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get configuration for a specific binary
|
|
129
|
+
*/
|
|
130
|
+
async getBinaryConfig(tool: BinaryTool): Promise<BinaryConfig | null> {
|
|
131
|
+
const config = await this.load()
|
|
132
|
+
return config.binaries[tool] || null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Detect a binary on the system PATH
|
|
137
|
+
*/
|
|
138
|
+
async detectSystemBinary(tool: BinaryTool): Promise<string | null> {
|
|
139
|
+
try {
|
|
140
|
+
const { stdout } = await execAsync(`which ${tool}`)
|
|
141
|
+
const path = stdout.trim()
|
|
142
|
+
if (path && existsSync(path)) {
|
|
143
|
+
return path
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// which failed, binary not found
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check common locations
|
|
150
|
+
const commonPaths = this.getCommonBinaryPaths(tool)
|
|
151
|
+
for (const path of commonPaths) {
|
|
152
|
+
if (existsSync(path)) {
|
|
153
|
+
return path
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get common installation paths for PostgreSQL client tools
|
|
162
|
+
*/
|
|
163
|
+
private getCommonBinaryPaths(tool: BinaryTool): string[] {
|
|
164
|
+
const paths: string[] = []
|
|
165
|
+
|
|
166
|
+
// Homebrew (macOS)
|
|
167
|
+
paths.push(`/opt/homebrew/bin/${tool}`)
|
|
168
|
+
paths.push(`/opt/homebrew/opt/libpq/bin/${tool}`)
|
|
169
|
+
paths.push(`/usr/local/bin/${tool}`)
|
|
170
|
+
paths.push(`/usr/local/opt/libpq/bin/${tool}`)
|
|
171
|
+
|
|
172
|
+
// Postgres.app (macOS)
|
|
173
|
+
paths.push(
|
|
174
|
+
`/Applications/Postgres.app/Contents/Versions/latest/bin/${tool}`,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
// Linux common paths
|
|
178
|
+
paths.push(`/usr/bin/${tool}`)
|
|
179
|
+
paths.push(`/usr/lib/postgresql/16/bin/${tool}`)
|
|
180
|
+
paths.push(`/usr/lib/postgresql/15/bin/${tool}`)
|
|
181
|
+
paths.push(`/usr/lib/postgresql/14/bin/${tool}`)
|
|
182
|
+
|
|
183
|
+
return paths
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Detect all available client tools on the system
|
|
188
|
+
*/
|
|
189
|
+
async detectAllTools(): Promise<Map<BinaryTool, string>> {
|
|
190
|
+
const tools: BinaryTool[] = [
|
|
191
|
+
'psql',
|
|
192
|
+
'pg_dump',
|
|
193
|
+
'pg_restore',
|
|
194
|
+
'pg_basebackup',
|
|
195
|
+
]
|
|
196
|
+
const found = new Map<BinaryTool, string>()
|
|
197
|
+
|
|
198
|
+
for (const tool of tools) {
|
|
199
|
+
const path = await this.detectSystemBinary(tool)
|
|
200
|
+
if (path) {
|
|
201
|
+
found.set(tool, path)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return found
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Initialize config by detecting all available tools
|
|
210
|
+
*/
|
|
211
|
+
async initialize(): Promise<{ found: BinaryTool[]; missing: BinaryTool[] }> {
|
|
212
|
+
const tools: BinaryTool[] = [
|
|
213
|
+
'psql',
|
|
214
|
+
'pg_dump',
|
|
215
|
+
'pg_restore',
|
|
216
|
+
'pg_basebackup',
|
|
217
|
+
]
|
|
218
|
+
const found: BinaryTool[] = []
|
|
219
|
+
const missing: BinaryTool[] = []
|
|
220
|
+
|
|
221
|
+
for (const tool of tools) {
|
|
222
|
+
const path = await this.getBinaryPath(tool)
|
|
223
|
+
if (path) {
|
|
224
|
+
found.push(tool)
|
|
225
|
+
} else {
|
|
226
|
+
missing.push(tool)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { found, missing }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Get the full config
|
|
235
|
+
*/
|
|
236
|
+
async getConfig(): Promise<SpinDBConfig> {
|
|
237
|
+
return this.load()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Clear a binary configuration
|
|
242
|
+
*/
|
|
243
|
+
async clearBinaryPath(tool: BinaryTool): Promise<void> {
|
|
244
|
+
const config = await this.load()
|
|
245
|
+
delete config.binaries[tool]
|
|
246
|
+
await this.save()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Clear all binary configurations (useful for re-detection)
|
|
251
|
+
*/
|
|
252
|
+
async clearAllBinaries(): Promise<void> {
|
|
253
|
+
const config = await this.load()
|
|
254
|
+
config.binaries = {}
|
|
255
|
+
await this.save()
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export const configManager = new ConfigManager()
|