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,234 @@
|
|
|
1
|
+
import { existsSync } from 'fs'
|
|
2
|
+
import { mkdir, readdir, readFile, writeFile, rm, cp } from 'fs/promises'
|
|
3
|
+
import { paths } from '@/config/paths'
|
|
4
|
+
import { processManager } from '@/core/process-manager'
|
|
5
|
+
import { portManager } from '@/core/port-manager'
|
|
6
|
+
import type { ContainerConfig } from '@/types'
|
|
7
|
+
|
|
8
|
+
export interface CreateOptions {
|
|
9
|
+
engine: string
|
|
10
|
+
version: string
|
|
11
|
+
port: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DeleteOptions {
|
|
15
|
+
force?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ContainerManager {
|
|
19
|
+
/**
|
|
20
|
+
* Create a new container
|
|
21
|
+
*/
|
|
22
|
+
async create(name: string, options: CreateOptions): Promise<ContainerConfig> {
|
|
23
|
+
const { engine, version, port } = options
|
|
24
|
+
|
|
25
|
+
// Validate container name
|
|
26
|
+
if (!this.isValidName(name)) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
'Container name must be alphanumeric with hyphens/underscores only',
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Check if container already exists
|
|
33
|
+
if (await this.exists(name)) {
|
|
34
|
+
throw new Error(`Container "${name}" already exists`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Create container directory
|
|
38
|
+
const containerPath = paths.getContainerPath(name)
|
|
39
|
+
const dataPath = paths.getContainerDataPath(name)
|
|
40
|
+
|
|
41
|
+
await mkdir(containerPath, { recursive: true })
|
|
42
|
+
await mkdir(dataPath, { recursive: true })
|
|
43
|
+
|
|
44
|
+
// Create container config
|
|
45
|
+
const config: ContainerConfig = {
|
|
46
|
+
name,
|
|
47
|
+
engine,
|
|
48
|
+
version,
|
|
49
|
+
port,
|
|
50
|
+
created: new Date().toISOString(),
|
|
51
|
+
status: 'created',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await this.saveConfig(name, config)
|
|
55
|
+
|
|
56
|
+
return config
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get container configuration
|
|
61
|
+
*/
|
|
62
|
+
async getConfig(name: string): Promise<ContainerConfig | null> {
|
|
63
|
+
const configPath = paths.getContainerConfigPath(name)
|
|
64
|
+
|
|
65
|
+
if (!existsSync(configPath)) {
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const content = await readFile(configPath, 'utf8')
|
|
70
|
+
return JSON.parse(content) as ContainerConfig
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Save container configuration
|
|
75
|
+
*/
|
|
76
|
+
async saveConfig(name: string, config: ContainerConfig): Promise<void> {
|
|
77
|
+
const configPath = paths.getContainerConfigPath(name)
|
|
78
|
+
await writeFile(configPath, JSON.stringify(config, null, 2))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Update container configuration
|
|
83
|
+
*/
|
|
84
|
+
async updateConfig(
|
|
85
|
+
name: string,
|
|
86
|
+
updates: Partial<ContainerConfig>,
|
|
87
|
+
): Promise<ContainerConfig> {
|
|
88
|
+
const config = await this.getConfig(name)
|
|
89
|
+
if (!config) {
|
|
90
|
+
throw new Error(`Container "${name}" not found`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const updatedConfig = { ...config, ...updates }
|
|
94
|
+
await this.saveConfig(name, updatedConfig)
|
|
95
|
+
return updatedConfig
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check if a container exists
|
|
100
|
+
*/
|
|
101
|
+
async exists(name: string): Promise<boolean> {
|
|
102
|
+
const configPath = paths.getContainerConfigPath(name)
|
|
103
|
+
return existsSync(configPath)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* List all containers
|
|
108
|
+
*/
|
|
109
|
+
async list(): Promise<ContainerConfig[]> {
|
|
110
|
+
const containersDir = paths.containers
|
|
111
|
+
|
|
112
|
+
if (!existsSync(containersDir)) {
|
|
113
|
+
return []
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const entries = await readdir(containersDir, { withFileTypes: true })
|
|
117
|
+
const containers: ContainerConfig[] = []
|
|
118
|
+
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
if (entry.isDirectory()) {
|
|
121
|
+
const config = await this.getConfig(entry.name)
|
|
122
|
+
if (config) {
|
|
123
|
+
// Check if actually running
|
|
124
|
+
const running = await processManager.isRunning(entry.name)
|
|
125
|
+
containers.push({
|
|
126
|
+
...config,
|
|
127
|
+
status: running ? 'running' : 'stopped',
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return containers
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Delete a container
|
|
138
|
+
*/
|
|
139
|
+
async delete(name: string, options: DeleteOptions = {}): Promise<void> {
|
|
140
|
+
const { force = false } = options
|
|
141
|
+
|
|
142
|
+
if (!(await this.exists(name))) {
|
|
143
|
+
throw new Error(`Container "${name}" not found`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Check if running
|
|
147
|
+
const running = await processManager.isRunning(name)
|
|
148
|
+
if (running && !force) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`Container "${name}" is running. Stop it first or use --force`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const containerPath = paths.getContainerPath(name)
|
|
155
|
+
await rm(containerPath, { recursive: true, force: true })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Clone a container
|
|
160
|
+
*/
|
|
161
|
+
async clone(
|
|
162
|
+
sourceName: string,
|
|
163
|
+
targetName: string,
|
|
164
|
+
): Promise<ContainerConfig> {
|
|
165
|
+
// Validate target name
|
|
166
|
+
if (!this.isValidName(targetName)) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
'Container name must be alphanumeric with hyphens/underscores only',
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Check source exists
|
|
173
|
+
if (!(await this.exists(sourceName))) {
|
|
174
|
+
throw new Error(`Source container "${sourceName}" not found`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Check target doesn't exist
|
|
178
|
+
if (await this.exists(targetName)) {
|
|
179
|
+
throw new Error(`Target container "${targetName}" already exists`)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Check source is not running
|
|
183
|
+
const running = await processManager.isRunning(sourceName)
|
|
184
|
+
if (running) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`Source container "${sourceName}" is running. Stop it first`,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Copy container directory
|
|
191
|
+
const sourcePath = paths.getContainerPath(sourceName)
|
|
192
|
+
const targetPath = paths.getContainerPath(targetName)
|
|
193
|
+
|
|
194
|
+
await cp(sourcePath, targetPath, { recursive: true })
|
|
195
|
+
|
|
196
|
+
// Update target config
|
|
197
|
+
const config = await this.getConfig(targetName)
|
|
198
|
+
if (!config) {
|
|
199
|
+
throw new Error('Failed to read cloned container config')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
config.name = targetName
|
|
203
|
+
config.created = new Date().toISOString()
|
|
204
|
+
config.clonedFrom = sourceName
|
|
205
|
+
|
|
206
|
+
// Assign new port
|
|
207
|
+
const { port } = await portManager.findAvailablePort()
|
|
208
|
+
config.port = port
|
|
209
|
+
|
|
210
|
+
await this.saveConfig(targetName, config)
|
|
211
|
+
|
|
212
|
+
return config
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Validate container name
|
|
217
|
+
*/
|
|
218
|
+
isValidName(name: string): boolean {
|
|
219
|
+
return /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Get connection string for a container
|
|
224
|
+
*/
|
|
225
|
+
getConnectionString(
|
|
226
|
+
config: ContainerConfig,
|
|
227
|
+
database: string = 'postgres',
|
|
228
|
+
): string {
|
|
229
|
+
const { port } = config
|
|
230
|
+
return `postgresql://postgres@localhost:${port}/${database}`
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const containerManager = new ContainerManager()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import net from 'net'
|
|
2
|
+
import { exec } from 'child_process'
|
|
3
|
+
import { promisify } from 'util'
|
|
4
|
+
import { defaults } from '@/config/defaults'
|
|
5
|
+
import type { PortResult } from '@/types'
|
|
6
|
+
|
|
7
|
+
const execAsync = promisify(exec)
|
|
8
|
+
|
|
9
|
+
export class PortManager {
|
|
10
|
+
/**
|
|
11
|
+
* Check if a specific port is available
|
|
12
|
+
*/
|
|
13
|
+
async isPortAvailable(port: number): Promise<boolean> {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const server = net.createServer()
|
|
16
|
+
|
|
17
|
+
server.once('error', (err: NodeJS.ErrnoException) => {
|
|
18
|
+
if (err.code === 'EADDRINUSE') {
|
|
19
|
+
resolve(false)
|
|
20
|
+
} else {
|
|
21
|
+
// Other errors - assume port is available
|
|
22
|
+
resolve(true)
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
server.once('listening', () => {
|
|
27
|
+
server.close()
|
|
28
|
+
resolve(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
server.listen(port, '127.0.0.1')
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Find the next available port starting from the default
|
|
37
|
+
* Returns the port number and whether it's the default port
|
|
38
|
+
*/
|
|
39
|
+
async findAvailablePort(
|
|
40
|
+
preferredPort: number = defaults.port,
|
|
41
|
+
): Promise<PortResult> {
|
|
42
|
+
// First try the preferred port
|
|
43
|
+
if (await this.isPortAvailable(preferredPort)) {
|
|
44
|
+
return {
|
|
45
|
+
port: preferredPort,
|
|
46
|
+
isDefault: preferredPort === defaults.port,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Scan for available ports in the range
|
|
51
|
+
for (
|
|
52
|
+
let port = defaults.portRange.start;
|
|
53
|
+
port <= defaults.portRange.end;
|
|
54
|
+
port++
|
|
55
|
+
) {
|
|
56
|
+
if (port === preferredPort) continue // Already tried this one
|
|
57
|
+
|
|
58
|
+
if (await this.isPortAvailable(port)) {
|
|
59
|
+
return {
|
|
60
|
+
port,
|
|
61
|
+
isDefault: false,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
throw new Error(
|
|
67
|
+
`No available ports found in range ${defaults.portRange.start}-${defaults.portRange.end}`,
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get what's using a specific port (for diagnostics)
|
|
73
|
+
*/
|
|
74
|
+
async getPortUser(port: number): Promise<string | null> {
|
|
75
|
+
try {
|
|
76
|
+
const { stdout } = await execAsync(`lsof -i :${port} -P -n | head -5`)
|
|
77
|
+
return stdout.trim()
|
|
78
|
+
} catch {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const portManager = new PortManager()
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { exec, spawn } from 'child_process'
|
|
2
|
+
import { promisify } from 'util'
|
|
3
|
+
import { existsSync } from 'fs'
|
|
4
|
+
import { readFile } from 'fs/promises'
|
|
5
|
+
import { paths } from '@/config/paths'
|
|
6
|
+
import type { ProcessResult, StatusResult } from '@/types'
|
|
7
|
+
|
|
8
|
+
const execAsync = promisify(exec)
|
|
9
|
+
|
|
10
|
+
export interface InitdbOptions {
|
|
11
|
+
superuser?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface StartOptions {
|
|
15
|
+
port?: number
|
|
16
|
+
logFile?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PsqlOptions {
|
|
20
|
+
port: number
|
|
21
|
+
database?: string
|
|
22
|
+
user?: string
|
|
23
|
+
command?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PgRestoreOptions {
|
|
27
|
+
port: number
|
|
28
|
+
database: string
|
|
29
|
+
user?: string
|
|
30
|
+
format?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class ProcessManager {
|
|
34
|
+
/**
|
|
35
|
+
* Initialize a new PostgreSQL data directory
|
|
36
|
+
*/
|
|
37
|
+
async initdb(
|
|
38
|
+
initdbPath: string,
|
|
39
|
+
dataDir: string,
|
|
40
|
+
options: InitdbOptions = {},
|
|
41
|
+
): Promise<ProcessResult> {
|
|
42
|
+
const { superuser = 'postgres' } = options
|
|
43
|
+
|
|
44
|
+
const args = [
|
|
45
|
+
'-D',
|
|
46
|
+
dataDir,
|
|
47
|
+
'-U',
|
|
48
|
+
superuser,
|
|
49
|
+
'--auth=trust',
|
|
50
|
+
'--encoding=UTF8',
|
|
51
|
+
'--no-locale',
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const proc = spawn(initdbPath, args, {
|
|
56
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
let stdout = ''
|
|
60
|
+
let stderr = ''
|
|
61
|
+
|
|
62
|
+
proc.stdout.on('data', (data: Buffer) => {
|
|
63
|
+
stdout += data.toString()
|
|
64
|
+
})
|
|
65
|
+
proc.stderr.on('data', (data: Buffer) => {
|
|
66
|
+
stderr += data.toString()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
proc.on('close', (code) => {
|
|
70
|
+
if (code === 0) {
|
|
71
|
+
resolve({ stdout, stderr })
|
|
72
|
+
} else {
|
|
73
|
+
reject(new Error(`initdb failed with code ${code}: ${stderr}`))
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
proc.on('error', reject)
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Start PostgreSQL server using pg_ctl
|
|
83
|
+
*/
|
|
84
|
+
async start(
|
|
85
|
+
pgCtlPath: string,
|
|
86
|
+
dataDir: string,
|
|
87
|
+
options: StartOptions = {},
|
|
88
|
+
): Promise<ProcessResult> {
|
|
89
|
+
const { port, logFile } = options
|
|
90
|
+
|
|
91
|
+
const pgOptions: string[] = []
|
|
92
|
+
if (port) {
|
|
93
|
+
pgOptions.push(`-p ${port}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const args = [
|
|
97
|
+
'start',
|
|
98
|
+
'-D',
|
|
99
|
+
dataDir,
|
|
100
|
+
'-l',
|
|
101
|
+
logFile || '/dev/null',
|
|
102
|
+
'-w', // Wait for startup to complete
|
|
103
|
+
'-o',
|
|
104
|
+
pgOptions.join(' '),
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const proc = spawn(pgCtlPath, args, {
|
|
109
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
let stdout = ''
|
|
113
|
+
let stderr = ''
|
|
114
|
+
|
|
115
|
+
proc.stdout.on('data', (data: Buffer) => {
|
|
116
|
+
stdout += data.toString()
|
|
117
|
+
})
|
|
118
|
+
proc.stderr.on('data', (data: Buffer) => {
|
|
119
|
+
stderr += data.toString()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
proc.on('close', (code) => {
|
|
123
|
+
if (code === 0) {
|
|
124
|
+
resolve({ stdout, stderr })
|
|
125
|
+
} else {
|
|
126
|
+
reject(
|
|
127
|
+
new Error(
|
|
128
|
+
`pg_ctl start failed with code ${code}: ${stderr || stdout}`,
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
proc.on('error', reject)
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Stop PostgreSQL server using pg_ctl
|
|
140
|
+
*/
|
|
141
|
+
async stop(pgCtlPath: string, dataDir: string): Promise<ProcessResult> {
|
|
142
|
+
const args = [
|
|
143
|
+
'stop',
|
|
144
|
+
'-D',
|
|
145
|
+
dataDir,
|
|
146
|
+
'-m',
|
|
147
|
+
'fast',
|
|
148
|
+
'-w', // Wait for shutdown to complete
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
return new Promise((resolve, reject) => {
|
|
152
|
+
const proc = spawn(pgCtlPath, args, {
|
|
153
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
let stdout = ''
|
|
157
|
+
let stderr = ''
|
|
158
|
+
|
|
159
|
+
proc.stdout.on('data', (data: Buffer) => {
|
|
160
|
+
stdout += data.toString()
|
|
161
|
+
})
|
|
162
|
+
proc.stderr.on('data', (data: Buffer) => {
|
|
163
|
+
stderr += data.toString()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
proc.on('close', (code) => {
|
|
167
|
+
if (code === 0) {
|
|
168
|
+
resolve({ stdout, stderr })
|
|
169
|
+
} else {
|
|
170
|
+
reject(
|
|
171
|
+
new Error(
|
|
172
|
+
`pg_ctl stop failed with code ${code}: ${stderr || stdout}`,
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
proc.on('error', reject)
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Get PostgreSQL server status
|
|
184
|
+
*/
|
|
185
|
+
async status(pgCtlPath: string, dataDir: string): Promise<StatusResult> {
|
|
186
|
+
const args = ['status', '-D', dataDir]
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const { stdout } = await execAsync(`"${pgCtlPath}" ${args.join(' ')}`)
|
|
190
|
+
return {
|
|
191
|
+
running: true,
|
|
192
|
+
message: stdout.trim(),
|
|
193
|
+
}
|
|
194
|
+
} catch (error) {
|
|
195
|
+
// pg_ctl status returns non-zero if server is not running
|
|
196
|
+
const err = error as { stderr?: string; message: string }
|
|
197
|
+
return {
|
|
198
|
+
running: false,
|
|
199
|
+
message: err.stderr?.trim() || err.message,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Check if PostgreSQL is running by looking for PID file
|
|
206
|
+
*/
|
|
207
|
+
async isRunning(containerName: string): Promise<boolean> {
|
|
208
|
+
const pidFile = paths.getContainerPidPath(containerName)
|
|
209
|
+
if (!existsSync(pidFile)) {
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const content = await readFile(pidFile, 'utf8')
|
|
215
|
+
const pid = parseInt(content.split('\n')[0], 10)
|
|
216
|
+
|
|
217
|
+
// Check if process is still running
|
|
218
|
+
process.kill(pid, 0)
|
|
219
|
+
return true
|
|
220
|
+
} catch {
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Get the PID of a running PostgreSQL server
|
|
227
|
+
*/
|
|
228
|
+
async getPid(containerName: string): Promise<number | null> {
|
|
229
|
+
const pidFile = paths.getContainerPidPath(containerName)
|
|
230
|
+
if (!existsSync(pidFile)) {
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const content = await readFile(pidFile, 'utf8')
|
|
236
|
+
return parseInt(content.split('\n')[0], 10)
|
|
237
|
+
} catch {
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Execute psql command
|
|
244
|
+
*/
|
|
245
|
+
async psql(
|
|
246
|
+
psqlPath: string,
|
|
247
|
+
options: PsqlOptions,
|
|
248
|
+
): Promise<ProcessResult & { code?: number }> {
|
|
249
|
+
const { port, database = 'postgres', user = 'postgres', command } = options
|
|
250
|
+
|
|
251
|
+
const args = [
|
|
252
|
+
'-h',
|
|
253
|
+
'127.0.0.1',
|
|
254
|
+
'-p',
|
|
255
|
+
String(port),
|
|
256
|
+
'-U',
|
|
257
|
+
user,
|
|
258
|
+
'-d',
|
|
259
|
+
database,
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
if (command) {
|
|
263
|
+
args.push('-c', command)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return new Promise((resolve, reject) => {
|
|
267
|
+
const proc = spawn(psqlPath, args, {
|
|
268
|
+
stdio: command ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
if (command) {
|
|
272
|
+
let stdout = ''
|
|
273
|
+
let stderr = ''
|
|
274
|
+
|
|
275
|
+
proc.stdout?.on('data', (data: Buffer) => {
|
|
276
|
+
stdout += data.toString()
|
|
277
|
+
})
|
|
278
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
279
|
+
stderr += data.toString()
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
proc.on('close', (code) => {
|
|
283
|
+
if (code === 0) {
|
|
284
|
+
resolve({ stdout, stderr, code: code ?? undefined })
|
|
285
|
+
} else {
|
|
286
|
+
reject(new Error(`psql failed with code ${code}: ${stderr}`))
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
} else {
|
|
290
|
+
proc.on('close', (code) => {
|
|
291
|
+
resolve({ stdout: '', stderr: '', code: code ?? undefined })
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
proc.on('error', reject)
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Execute pg_restore command
|
|
301
|
+
*/
|
|
302
|
+
async pgRestore(
|
|
303
|
+
pgRestorePath: string,
|
|
304
|
+
backupFile: string,
|
|
305
|
+
options: PgRestoreOptions,
|
|
306
|
+
): Promise<ProcessResult & { code?: number }> {
|
|
307
|
+
const { port, database, user = 'postgres', format } = options
|
|
308
|
+
|
|
309
|
+
const args = [
|
|
310
|
+
'-h',
|
|
311
|
+
'127.0.0.1',
|
|
312
|
+
'-p',
|
|
313
|
+
String(port),
|
|
314
|
+
'-U',
|
|
315
|
+
user,
|
|
316
|
+
'-d',
|
|
317
|
+
database,
|
|
318
|
+
'--no-owner',
|
|
319
|
+
'--no-privileges',
|
|
320
|
+
]
|
|
321
|
+
|
|
322
|
+
if (format) {
|
|
323
|
+
args.push('-F', format)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
args.push(backupFile)
|
|
327
|
+
|
|
328
|
+
return new Promise((resolve, reject) => {
|
|
329
|
+
const proc = spawn(pgRestorePath, args, {
|
|
330
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
let stdout = ''
|
|
334
|
+
let stderr = ''
|
|
335
|
+
|
|
336
|
+
proc.stdout.on('data', (data: Buffer) => {
|
|
337
|
+
stdout += data.toString()
|
|
338
|
+
})
|
|
339
|
+
proc.stderr.on('data', (data: Buffer) => {
|
|
340
|
+
stderr += data.toString()
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
proc.on('close', (code) => {
|
|
344
|
+
// pg_restore may return non-zero even on partial success
|
|
345
|
+
resolve({ stdout, stderr, code: code ?? undefined })
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
proc.on('error', reject)
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export const processManager = new ProcessManager()
|