spindb 0.6.0 → 0.7.3
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 +421 -294
- package/cli/commands/config.ts +7 -1
- package/cli/commands/connect.ts +1 -0
- package/cli/commands/create.ts +7 -7
- package/cli/commands/edit.ts +10 -0
- package/cli/commands/engines.ts +10 -188
- package/cli/commands/info.ts +7 -14
- package/cli/commands/list.ts +2 -9
- package/cli/commands/logs.ts +130 -0
- package/cli/commands/menu/backup-handlers.ts +798 -0
- package/cli/commands/menu/container-handlers.ts +832 -0
- package/cli/commands/menu/engine-handlers.ts +382 -0
- package/cli/commands/menu/index.ts +184 -0
- package/cli/commands/menu/shared.ts +26 -0
- package/cli/commands/menu/shell-handlers.ts +331 -0
- package/cli/commands/menu/sql-handlers.ts +197 -0
- package/cli/commands/menu/update-handlers.ts +94 -0
- package/cli/commands/run.ts +150 -0
- package/cli/commands/url.ts +19 -5
- package/cli/constants.ts +10 -0
- package/cli/helpers.ts +152 -0
- package/cli/index.ts +5 -2
- package/cli/ui/prompts.ts +3 -11
- package/config/defaults.ts +5 -29
- package/core/binary-manager.ts +2 -2
- package/core/container-manager.ts +3 -2
- package/core/dependency-manager.ts +0 -163
- package/core/error-handler.ts +0 -26
- package/core/platform-service.ts +60 -40
- package/core/start-with-retry.ts +3 -28
- package/core/transaction-manager.ts +0 -8
- package/engines/base-engine.ts +10 -0
- package/engines/mysql/binary-detection.ts +1 -1
- package/engines/mysql/index.ts +78 -2
- package/engines/postgresql/index.ts +49 -0
- package/package.json +1 -1
- package/types/index.ts +7 -4
- package/cli/commands/menu.ts +0 -2670
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import inquirer from 'inquirer'
|
|
3
|
+
import { existsSync } from 'fs'
|
|
4
|
+
import { rm } from 'fs/promises'
|
|
5
|
+
import { join } from 'path'
|
|
6
|
+
import { tmpdir } from 'os'
|
|
7
|
+
import { containerManager } from '../../../core/container-manager'
|
|
8
|
+
import { getMissingDependencies } from '../../../core/dependency-manager'
|
|
9
|
+
import { platformService } from '../../../core/platform-service'
|
|
10
|
+
import { portManager } from '../../../core/port-manager'
|
|
11
|
+
import { getEngine } from '../../../engines'
|
|
12
|
+
import { defaults } from '../../../config/defaults'
|
|
13
|
+
import { getPostgresHomebrewPackage } from '../../../config/engine-defaults'
|
|
14
|
+
import { updatePostgresClientTools } from '../../../engines/postgresql/binary-manager'
|
|
15
|
+
import { type Engine } from '../../../types'
|
|
16
|
+
import {
|
|
17
|
+
promptCreateOptions,
|
|
18
|
+
promptContainerName,
|
|
19
|
+
promptContainerSelect,
|
|
20
|
+
promptDatabaseName,
|
|
21
|
+
promptDatabaseSelect,
|
|
22
|
+
promptBackupFormat,
|
|
23
|
+
promptBackupFilename,
|
|
24
|
+
promptInstallDependencies,
|
|
25
|
+
} from '../../ui/prompts'
|
|
26
|
+
import { createSpinner } from '../../ui/spinner'
|
|
27
|
+
import {
|
|
28
|
+
header,
|
|
29
|
+
success,
|
|
30
|
+
error,
|
|
31
|
+
warning,
|
|
32
|
+
connectionBox,
|
|
33
|
+
formatBytes,
|
|
34
|
+
} from '../../ui/theme'
|
|
35
|
+
import { getEngineIcon } from '../../constants'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Generate a timestamp string for backup filenames
|
|
39
|
+
*/
|
|
40
|
+
function generateBackupTimestamp(): string {
|
|
41
|
+
const now = new Date()
|
|
42
|
+
return now.toISOString().replace(/:/g, '').split('.')[0]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get file extension for backup format
|
|
47
|
+
*/
|
|
48
|
+
function getBackupExtension(format: 'sql' | 'dump', engine: string): string {
|
|
49
|
+
if (format === 'sql') {
|
|
50
|
+
return '.sql'
|
|
51
|
+
}
|
|
52
|
+
return engine === 'mysql' ? '.sql.gz' : '.dump'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Create a new container for the restore flow
|
|
57
|
+
* Returns the container name and config if successful, null if cancelled/error
|
|
58
|
+
*/
|
|
59
|
+
export async function handleCreateForRestore(): Promise<{
|
|
60
|
+
name: string
|
|
61
|
+
config: NonNullable<Awaited<ReturnType<typeof containerManager.getConfig>>>
|
|
62
|
+
} | null> {
|
|
63
|
+
console.log()
|
|
64
|
+
const answers = await promptCreateOptions()
|
|
65
|
+
let { name: containerName } = answers
|
|
66
|
+
const { engine, version, port, database } = answers
|
|
67
|
+
|
|
68
|
+
console.log()
|
|
69
|
+
console.log(header('Creating Database Container'))
|
|
70
|
+
console.log()
|
|
71
|
+
|
|
72
|
+
const dbEngine = getEngine(engine)
|
|
73
|
+
|
|
74
|
+
// Check if port is currently in use
|
|
75
|
+
const portAvailable = await portManager.isPortAvailable(port)
|
|
76
|
+
if (!portAvailable) {
|
|
77
|
+
console.log(
|
|
78
|
+
error(`Port ${port} is in use. Please choose a different port.`),
|
|
79
|
+
)
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Ensure binaries
|
|
84
|
+
const binarySpinner = createSpinner(
|
|
85
|
+
`Checking PostgreSQL ${version} binaries...`,
|
|
86
|
+
)
|
|
87
|
+
binarySpinner.start()
|
|
88
|
+
|
|
89
|
+
const isInstalled = await dbEngine.isBinaryInstalled(version)
|
|
90
|
+
if (isInstalled) {
|
|
91
|
+
binarySpinner.succeed(`PostgreSQL ${version} binaries ready (cached)`)
|
|
92
|
+
} else {
|
|
93
|
+
binarySpinner.text = `Downloading PostgreSQL ${version} binaries...`
|
|
94
|
+
await dbEngine.ensureBinaries(version, ({ message }) => {
|
|
95
|
+
binarySpinner.text = message
|
|
96
|
+
})
|
|
97
|
+
binarySpinner.succeed(`PostgreSQL ${version} binaries downloaded`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Check if container name already exists and prompt for new name if needed
|
|
101
|
+
while (await containerManager.exists(containerName)) {
|
|
102
|
+
console.log(chalk.yellow(` Container "${containerName}" already exists.`))
|
|
103
|
+
containerName = await promptContainerName()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Create container
|
|
107
|
+
const createSpinnerInstance = createSpinner('Creating container...')
|
|
108
|
+
createSpinnerInstance.start()
|
|
109
|
+
|
|
110
|
+
await containerManager.create(containerName, {
|
|
111
|
+
engine: dbEngine.name as Engine,
|
|
112
|
+
version,
|
|
113
|
+
port,
|
|
114
|
+
database,
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
createSpinnerInstance.succeed('Container created')
|
|
118
|
+
|
|
119
|
+
// Initialize database cluster
|
|
120
|
+
const initSpinner = createSpinner('Initializing database cluster...')
|
|
121
|
+
initSpinner.start()
|
|
122
|
+
|
|
123
|
+
await dbEngine.initDataDir(containerName, version, {
|
|
124
|
+
superuser: defaults.superuser,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
initSpinner.succeed('Database cluster initialized')
|
|
128
|
+
|
|
129
|
+
// Start container
|
|
130
|
+
const startSpinner = createSpinner('Starting PostgreSQL...')
|
|
131
|
+
startSpinner.start()
|
|
132
|
+
|
|
133
|
+
const config = await containerManager.getConfig(containerName)
|
|
134
|
+
if (!config) {
|
|
135
|
+
startSpinner.fail('Failed to get container config')
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await dbEngine.start(config)
|
|
140
|
+
await containerManager.updateConfig(containerName, { status: 'running' })
|
|
141
|
+
|
|
142
|
+
startSpinner.succeed('PostgreSQL started')
|
|
143
|
+
|
|
144
|
+
// Create the user's database (if different from 'postgres')
|
|
145
|
+
if (database !== 'postgres') {
|
|
146
|
+
const dbSpinner = createSpinner(`Creating database "${database}"...`)
|
|
147
|
+
dbSpinner.start()
|
|
148
|
+
|
|
149
|
+
await dbEngine.createDatabase(config, database)
|
|
150
|
+
|
|
151
|
+
dbSpinner.succeed(`Database "${database}" created`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
console.log()
|
|
155
|
+
console.log(success('Container ready for restore'))
|
|
156
|
+
console.log()
|
|
157
|
+
|
|
158
|
+
return { name: containerName, config }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function handleRestore(): Promise<void> {
|
|
162
|
+
const containers = await containerManager.list()
|
|
163
|
+
const running = containers.filter((c) => c.status === 'running')
|
|
164
|
+
|
|
165
|
+
// Build choices: running containers + create new option
|
|
166
|
+
const choices = [
|
|
167
|
+
...running.map((c) => ({
|
|
168
|
+
name: `${c.name} ${chalk.gray(`(${getEngineIcon(c.engine)} ${c.engine} ${c.version}, port ${c.port})`)} ${chalk.green('● running')}`,
|
|
169
|
+
value: c.name,
|
|
170
|
+
short: c.name,
|
|
171
|
+
})),
|
|
172
|
+
new inquirer.Separator(),
|
|
173
|
+
{
|
|
174
|
+
name: `${chalk.green('➕')} Create new container`,
|
|
175
|
+
value: '__create_new__',
|
|
176
|
+
short: 'Create new',
|
|
177
|
+
},
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
const { selectedContainer } = await inquirer.prompt<{
|
|
181
|
+
selectedContainer: string
|
|
182
|
+
}>([
|
|
183
|
+
{
|
|
184
|
+
type: 'list',
|
|
185
|
+
name: 'selectedContainer',
|
|
186
|
+
message: 'Select container to restore to:',
|
|
187
|
+
choices,
|
|
188
|
+
pageSize: 15,
|
|
189
|
+
},
|
|
190
|
+
])
|
|
191
|
+
|
|
192
|
+
let containerName: string
|
|
193
|
+
let config: Awaited<ReturnType<typeof containerManager.getConfig>>
|
|
194
|
+
|
|
195
|
+
if (selectedContainer === '__create_new__') {
|
|
196
|
+
// Run the create flow first
|
|
197
|
+
const createResult = await handleCreateForRestore()
|
|
198
|
+
if (!createResult) return // User cancelled or error
|
|
199
|
+
containerName = createResult.name
|
|
200
|
+
config = createResult.config
|
|
201
|
+
} else {
|
|
202
|
+
containerName = selectedContainer
|
|
203
|
+
config = await containerManager.getConfig(containerName)
|
|
204
|
+
if (!config) {
|
|
205
|
+
console.error(error(`Container "${containerName}" not found`))
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Check for required client tools BEFORE doing anything
|
|
211
|
+
const depsSpinner = createSpinner('Checking required tools...')
|
|
212
|
+
depsSpinner.start()
|
|
213
|
+
|
|
214
|
+
let missingDeps = await getMissingDependencies(config.engine)
|
|
215
|
+
if (missingDeps.length > 0) {
|
|
216
|
+
depsSpinner.warn(
|
|
217
|
+
`Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
// Offer to install
|
|
221
|
+
const installed = await promptInstallDependencies(
|
|
222
|
+
missingDeps[0].binary,
|
|
223
|
+
config.engine,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if (!installed) {
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Verify installation worked
|
|
231
|
+
missingDeps = await getMissingDependencies(config.engine)
|
|
232
|
+
if (missingDeps.length > 0) {
|
|
233
|
+
console.log(
|
|
234
|
+
error(
|
|
235
|
+
`Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
console.log(chalk.green(' ✓ All required tools are now available'))
|
|
242
|
+
console.log()
|
|
243
|
+
} else {
|
|
244
|
+
depsSpinner.succeed('Required tools available')
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Ask for restore source
|
|
248
|
+
const { restoreSource } = await inquirer.prompt<{
|
|
249
|
+
restoreSource: 'file' | 'connection'
|
|
250
|
+
}>([
|
|
251
|
+
{
|
|
252
|
+
type: 'list',
|
|
253
|
+
name: 'restoreSource',
|
|
254
|
+
message: 'Restore from:',
|
|
255
|
+
choices: [
|
|
256
|
+
{
|
|
257
|
+
name: `${chalk.magenta('📁')} Dump file (drag and drop or enter path)`,
|
|
258
|
+
value: 'file',
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
name: `${chalk.cyan('🔗')} Connection string (pull from remote database)`,
|
|
262
|
+
value: 'connection',
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
])
|
|
267
|
+
|
|
268
|
+
let backupPath = ''
|
|
269
|
+
let isTempFile = false
|
|
270
|
+
|
|
271
|
+
if (restoreSource === 'connection') {
|
|
272
|
+
// Get connection string and create dump
|
|
273
|
+
console.log(chalk.gray(' Enter connection string, or press Enter to go back'))
|
|
274
|
+
const { connectionString } = await inquirer.prompt<{
|
|
275
|
+
connectionString: string
|
|
276
|
+
}>([
|
|
277
|
+
{
|
|
278
|
+
type: 'input',
|
|
279
|
+
name: 'connectionString',
|
|
280
|
+
message: 'Connection string:',
|
|
281
|
+
validate: (input: string) => {
|
|
282
|
+
if (!input) return true // Empty = go back
|
|
283
|
+
if (
|
|
284
|
+
!input.startsWith('postgresql://') &&
|
|
285
|
+
!input.startsWith('postgres://')
|
|
286
|
+
) {
|
|
287
|
+
return 'Connection string must start with postgresql:// or postgres://'
|
|
288
|
+
}
|
|
289
|
+
return true
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
])
|
|
293
|
+
|
|
294
|
+
// Empty input = go back
|
|
295
|
+
if (!connectionString.trim()) {
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const engine = getEngine(config.engine)
|
|
300
|
+
|
|
301
|
+
// Create temp file for the dump
|
|
302
|
+
const timestamp = Date.now()
|
|
303
|
+
const tempDumpPath = join(tmpdir(), `spindb-dump-${timestamp}.dump`)
|
|
304
|
+
|
|
305
|
+
let dumpSuccess = false
|
|
306
|
+
let attempts = 0
|
|
307
|
+
const maxAttempts = 2 // Allow one retry after installing deps
|
|
308
|
+
|
|
309
|
+
while (!dumpSuccess && attempts < maxAttempts) {
|
|
310
|
+
attempts++
|
|
311
|
+
const dumpSpinner = createSpinner('Creating dump from remote database...')
|
|
312
|
+
dumpSpinner.start()
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
await engine.dumpFromConnectionString(connectionString, tempDumpPath)
|
|
316
|
+
dumpSpinner.succeed('Dump created from remote database')
|
|
317
|
+
backupPath = tempDumpPath
|
|
318
|
+
isTempFile = true
|
|
319
|
+
dumpSuccess = true
|
|
320
|
+
} catch (err) {
|
|
321
|
+
const e = err as Error
|
|
322
|
+
dumpSpinner.fail('Failed to create dump')
|
|
323
|
+
|
|
324
|
+
// Check if this is a missing tool error
|
|
325
|
+
if (
|
|
326
|
+
e.message.includes('pg_dump not found') ||
|
|
327
|
+
e.message.includes('ENOENT')
|
|
328
|
+
) {
|
|
329
|
+
const installed = await promptInstallDependencies('pg_dump')
|
|
330
|
+
if (installed) {
|
|
331
|
+
// Loop will retry
|
|
332
|
+
continue
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
console.log()
|
|
336
|
+
console.log(error('pg_dump error:'))
|
|
337
|
+
console.log(chalk.gray(` ${e.message}`))
|
|
338
|
+
console.log()
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Clean up temp file if it was created
|
|
342
|
+
try {
|
|
343
|
+
await rm(tempDumpPath, { force: true })
|
|
344
|
+
} catch {
|
|
345
|
+
// Ignore cleanup errors
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Wait for user to see the error
|
|
349
|
+
await inquirer.prompt([
|
|
350
|
+
{
|
|
351
|
+
type: 'input',
|
|
352
|
+
name: 'continue',
|
|
353
|
+
message: chalk.gray('Press Enter to continue...'),
|
|
354
|
+
},
|
|
355
|
+
])
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Safety check - should never reach here without backupPath set
|
|
361
|
+
if (!dumpSuccess) {
|
|
362
|
+
console.log(error('Failed to create dump after retries'))
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
// Get backup file path
|
|
367
|
+
// Strip quotes that terminals add when drag-and-dropping files
|
|
368
|
+
const stripQuotes = (path: string) =>
|
|
369
|
+
path.replace(/^['"]|['"]$/g, '').trim()
|
|
370
|
+
|
|
371
|
+
console.log(chalk.gray(' Drag & drop, enter path (abs or rel), or press Enter to go back'))
|
|
372
|
+
const { backupPath: rawBackupPath } = await inquirer.prompt<{
|
|
373
|
+
backupPath: string
|
|
374
|
+
}>([
|
|
375
|
+
{
|
|
376
|
+
type: 'input',
|
|
377
|
+
name: 'backupPath',
|
|
378
|
+
message: 'Backup file path:',
|
|
379
|
+
validate: (input: string) => {
|
|
380
|
+
if (!input) return true // Empty = go back
|
|
381
|
+
const cleanPath = stripQuotes(input)
|
|
382
|
+
if (!existsSync(cleanPath)) return 'File not found'
|
|
383
|
+
return true
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
])
|
|
387
|
+
|
|
388
|
+
// Empty input = go back
|
|
389
|
+
if (!rawBackupPath.trim()) {
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
backupPath = stripQuotes(rawBackupPath)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const databaseName = await promptDatabaseName(containerName, config.engine)
|
|
397
|
+
|
|
398
|
+
const engine = getEngine(config.engine)
|
|
399
|
+
|
|
400
|
+
// Detect format
|
|
401
|
+
const detectSpinner = createSpinner('Detecting backup format...')
|
|
402
|
+
detectSpinner.start()
|
|
403
|
+
|
|
404
|
+
const format = await engine.detectBackupFormat(backupPath)
|
|
405
|
+
detectSpinner.succeed(`Detected: ${format.description}`)
|
|
406
|
+
|
|
407
|
+
// Create database
|
|
408
|
+
const dbSpinner = createSpinner(`Creating database "${databaseName}"...`)
|
|
409
|
+
dbSpinner.start()
|
|
410
|
+
|
|
411
|
+
await engine.createDatabase(config, databaseName)
|
|
412
|
+
dbSpinner.succeed(`Database "${databaseName}" ready`)
|
|
413
|
+
|
|
414
|
+
// Restore
|
|
415
|
+
const restoreSpinner = createSpinner('Restoring backup...')
|
|
416
|
+
restoreSpinner.start()
|
|
417
|
+
|
|
418
|
+
const result = await engine.restore(config, backupPath, {
|
|
419
|
+
database: databaseName,
|
|
420
|
+
createDatabase: false,
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
if (result.code === 0 || !result.stderr) {
|
|
424
|
+
restoreSpinner.succeed('Backup restored successfully')
|
|
425
|
+
} else {
|
|
426
|
+
const stderr = result.stderr || ''
|
|
427
|
+
|
|
428
|
+
// Check for version compatibility errors
|
|
429
|
+
if (
|
|
430
|
+
stderr.includes('unsupported version') ||
|
|
431
|
+
stderr.includes('Archive version') ||
|
|
432
|
+
stderr.includes('too old')
|
|
433
|
+
) {
|
|
434
|
+
restoreSpinner.fail('Version compatibility detected')
|
|
435
|
+
console.log()
|
|
436
|
+
console.log(error('PostgreSQL version incompatibility detected:'))
|
|
437
|
+
console.log(
|
|
438
|
+
warning('Your pg_restore version is too old for this backup file.'),
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
// Clean up the failed database since restore didn't actually work
|
|
442
|
+
console.log(chalk.yellow('Cleaning up failed database...'))
|
|
443
|
+
try {
|
|
444
|
+
await engine.dropDatabase(config, databaseName)
|
|
445
|
+
console.log(chalk.gray(`✓ Removed database "${databaseName}"`))
|
|
446
|
+
} catch {
|
|
447
|
+
console.log(
|
|
448
|
+
chalk.yellow(`Warning: Could not remove database "${databaseName}"`),
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
console.log()
|
|
453
|
+
|
|
454
|
+
// Extract version info from error message
|
|
455
|
+
const versionMatch = stderr.match(/PostgreSQL (\d+)/)
|
|
456
|
+
const requiredVersion = versionMatch ? versionMatch[1] : '17'
|
|
457
|
+
|
|
458
|
+
console.log(
|
|
459
|
+
chalk.gray(
|
|
460
|
+
`This backup was created with PostgreSQL ${requiredVersion}`,
|
|
461
|
+
),
|
|
462
|
+
)
|
|
463
|
+
console.log()
|
|
464
|
+
|
|
465
|
+
// Ask user if they want to upgrade
|
|
466
|
+
const { shouldUpgrade } = await inquirer.prompt({
|
|
467
|
+
type: 'list',
|
|
468
|
+
name: 'shouldUpgrade',
|
|
469
|
+
message: `Would you like to upgrade PostgreSQL client tools to support PostgreSQL ${requiredVersion}?`,
|
|
470
|
+
choices: [
|
|
471
|
+
{ name: 'Yes', value: true },
|
|
472
|
+
{ name: 'No', value: false },
|
|
473
|
+
],
|
|
474
|
+
default: 0,
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
if (shouldUpgrade) {
|
|
478
|
+
console.log()
|
|
479
|
+
const upgradeSpinner = createSpinner(
|
|
480
|
+
'Upgrading PostgreSQL client tools...',
|
|
481
|
+
)
|
|
482
|
+
upgradeSpinner.start()
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
const updateSuccess = await updatePostgresClientTools()
|
|
486
|
+
|
|
487
|
+
if (updateSuccess) {
|
|
488
|
+
upgradeSpinner.succeed('PostgreSQL client tools upgraded')
|
|
489
|
+
console.log()
|
|
490
|
+
console.log(
|
|
491
|
+
success('Please try the restore again with the updated tools.'),
|
|
492
|
+
)
|
|
493
|
+
await new Promise((resolve) => {
|
|
494
|
+
console.log(chalk.gray('Press Enter to continue...'))
|
|
495
|
+
process.stdin.once('data', resolve)
|
|
496
|
+
})
|
|
497
|
+
return
|
|
498
|
+
} else {
|
|
499
|
+
upgradeSpinner.fail('Upgrade failed')
|
|
500
|
+
console.log()
|
|
501
|
+
console.log(
|
|
502
|
+
error('Automatic upgrade failed. Please upgrade manually:'),
|
|
503
|
+
)
|
|
504
|
+
const pgPackage = getPostgresHomebrewPackage()
|
|
505
|
+
const latestMajor = pgPackage.split('@')[1]
|
|
506
|
+
console.log(
|
|
507
|
+
warning(
|
|
508
|
+
` macOS: brew install ${pgPackage} && brew link --force ${pgPackage}`,
|
|
509
|
+
),
|
|
510
|
+
)
|
|
511
|
+
console.log(
|
|
512
|
+
chalk.gray(
|
|
513
|
+
` This installs PostgreSQL ${latestMajor} client tools: pg_restore, pg_dump, psql, and libpq`,
|
|
514
|
+
),
|
|
515
|
+
)
|
|
516
|
+
console.log(
|
|
517
|
+
warning(
|
|
518
|
+
` Ubuntu/Debian: sudo apt update && sudo apt install postgresql-client-${latestMajor}`,
|
|
519
|
+
),
|
|
520
|
+
)
|
|
521
|
+
console.log(
|
|
522
|
+
chalk.gray(
|
|
523
|
+
` This installs PostgreSQL ${latestMajor} client tools: pg_restore, pg_dump, psql, and libpq`,
|
|
524
|
+
),
|
|
525
|
+
)
|
|
526
|
+
await new Promise((resolve) => {
|
|
527
|
+
console.log(chalk.gray('Press Enter to continue...'))
|
|
528
|
+
process.stdin.once('data', resolve)
|
|
529
|
+
})
|
|
530
|
+
return
|
|
531
|
+
}
|
|
532
|
+
} catch {
|
|
533
|
+
upgradeSpinner.fail('Upgrade failed')
|
|
534
|
+
console.log(error('Failed to upgrade PostgreSQL client tools'))
|
|
535
|
+
console.log(
|
|
536
|
+
chalk.gray(
|
|
537
|
+
'Manual upgrade may be required for pg_restore, pg_dump, and psql',
|
|
538
|
+
),
|
|
539
|
+
)
|
|
540
|
+
await new Promise((resolve) => {
|
|
541
|
+
console.log(chalk.gray('Press Enter to continue...'))
|
|
542
|
+
process.stdin.once('data', resolve)
|
|
543
|
+
})
|
|
544
|
+
return
|
|
545
|
+
}
|
|
546
|
+
} else {
|
|
547
|
+
console.log()
|
|
548
|
+
console.log(
|
|
549
|
+
warning(
|
|
550
|
+
'Restore cancelled. Please upgrade PostgreSQL client tools manually and try again.',
|
|
551
|
+
),
|
|
552
|
+
)
|
|
553
|
+
await new Promise((resolve) => {
|
|
554
|
+
console.log(chalk.gray('Press Enter to continue...'))
|
|
555
|
+
process.stdin.once('data', resolve)
|
|
556
|
+
})
|
|
557
|
+
return
|
|
558
|
+
}
|
|
559
|
+
} else {
|
|
560
|
+
// Regular warnings/errors - show as before
|
|
561
|
+
restoreSpinner.warn('Restore completed with warnings')
|
|
562
|
+
// Show stderr output so user can see what went wrong
|
|
563
|
+
if (result.stderr) {
|
|
564
|
+
console.log()
|
|
565
|
+
console.log(chalk.yellow(' Warnings/Errors:'))
|
|
566
|
+
// Show first 20 lines of stderr to avoid overwhelming output
|
|
567
|
+
const lines = result.stderr.split('\n').filter((l) => l.trim())
|
|
568
|
+
const displayLines = lines.slice(0, 20)
|
|
569
|
+
for (const line of displayLines) {
|
|
570
|
+
console.log(chalk.gray(` ${line}`))
|
|
571
|
+
}
|
|
572
|
+
if (lines.length > 20) {
|
|
573
|
+
console.log(chalk.gray(` ... and ${lines.length - 20} more lines`))
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Only show success message if restore actually succeeded
|
|
580
|
+
if (result.code === 0 || !result.stderr) {
|
|
581
|
+
const connectionString = engine.getConnectionString(config, databaseName)
|
|
582
|
+
console.log()
|
|
583
|
+
console.log(success(`Database "${databaseName}" restored`))
|
|
584
|
+
console.log(chalk.gray(' Connection string:'))
|
|
585
|
+
console.log(chalk.cyan(` ${connectionString}`))
|
|
586
|
+
|
|
587
|
+
// Copy connection string to clipboard using platform service
|
|
588
|
+
const copied = await platformService.copyToClipboard(connectionString)
|
|
589
|
+
if (copied) {
|
|
590
|
+
console.log(chalk.gray(' ✓ Connection string copied to clipboard'))
|
|
591
|
+
} else {
|
|
592
|
+
console.log(chalk.gray(' (Could not copy to clipboard)'))
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
console.log()
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Clean up temp file if we created one
|
|
599
|
+
if (isTempFile) {
|
|
600
|
+
try {
|
|
601
|
+
await rm(backupPath, { force: true })
|
|
602
|
+
} catch {
|
|
603
|
+
// Ignore cleanup errors
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Wait for user to see the result before returning to menu
|
|
608
|
+
await inquirer.prompt([
|
|
609
|
+
{
|
|
610
|
+
type: 'input',
|
|
611
|
+
name: 'continue',
|
|
612
|
+
message: chalk.gray('Press Enter to continue...'),
|
|
613
|
+
},
|
|
614
|
+
])
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export async function handleBackup(): Promise<void> {
|
|
618
|
+
const containers = await containerManager.list()
|
|
619
|
+
const running = containers.filter((c) => c.status === 'running')
|
|
620
|
+
|
|
621
|
+
if (running.length === 0) {
|
|
622
|
+
console.log(warning('No running containers. Start a container first.'))
|
|
623
|
+
await inquirer.prompt([
|
|
624
|
+
{
|
|
625
|
+
type: 'input',
|
|
626
|
+
name: 'continue',
|
|
627
|
+
message: chalk.gray('Press Enter to continue...'),
|
|
628
|
+
},
|
|
629
|
+
])
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Select container
|
|
634
|
+
const containerName = await promptContainerSelect(
|
|
635
|
+
running,
|
|
636
|
+
'Select container to backup:',
|
|
637
|
+
)
|
|
638
|
+
if (!containerName) return
|
|
639
|
+
|
|
640
|
+
const config = await containerManager.getConfig(containerName)
|
|
641
|
+
if (!config) {
|
|
642
|
+
console.log(error(`Container "${containerName}" not found`))
|
|
643
|
+
return
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const engine = getEngine(config.engine)
|
|
647
|
+
|
|
648
|
+
// Check for required tools
|
|
649
|
+
const depsSpinner = createSpinner('Checking required tools...')
|
|
650
|
+
depsSpinner.start()
|
|
651
|
+
|
|
652
|
+
let missingDeps = await getMissingDependencies(config.engine)
|
|
653
|
+
if (missingDeps.length > 0) {
|
|
654
|
+
depsSpinner.warn(
|
|
655
|
+
`Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
const installed = await promptInstallDependencies(
|
|
659
|
+
missingDeps[0].binary,
|
|
660
|
+
config.engine,
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
if (!installed) {
|
|
664
|
+
return
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
missingDeps = await getMissingDependencies(config.engine)
|
|
668
|
+
if (missingDeps.length > 0) {
|
|
669
|
+
console.log(
|
|
670
|
+
error(
|
|
671
|
+
`Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
|
|
672
|
+
),
|
|
673
|
+
)
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
console.log(chalk.green(' ✓ All required tools are now available'))
|
|
678
|
+
console.log()
|
|
679
|
+
} else {
|
|
680
|
+
depsSpinner.succeed('Required tools available')
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Select database
|
|
684
|
+
const databases = config.databases || [config.database]
|
|
685
|
+
let databaseName: string
|
|
686
|
+
|
|
687
|
+
if (databases.length > 1) {
|
|
688
|
+
databaseName = await promptDatabaseSelect(
|
|
689
|
+
databases,
|
|
690
|
+
'Select database to backup:',
|
|
691
|
+
)
|
|
692
|
+
} else {
|
|
693
|
+
databaseName = databases[0]
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Select format
|
|
697
|
+
const format = await promptBackupFormat(config.engine)
|
|
698
|
+
|
|
699
|
+
// Get filename
|
|
700
|
+
const defaultFilename = `${containerName}-${databaseName}-backup-${generateBackupTimestamp()}`
|
|
701
|
+
const filename = await promptBackupFilename(defaultFilename)
|
|
702
|
+
|
|
703
|
+
// Build output path
|
|
704
|
+
const extension = getBackupExtension(format, config.engine)
|
|
705
|
+
const outputPath = join(process.cwd(), `${filename}${extension}`)
|
|
706
|
+
|
|
707
|
+
// Create backup
|
|
708
|
+
const backupSpinner = createSpinner(
|
|
709
|
+
`Creating ${format === 'sql' ? 'SQL' : 'dump'} backup of "${databaseName}"...`,
|
|
710
|
+
)
|
|
711
|
+
backupSpinner.start()
|
|
712
|
+
|
|
713
|
+
try {
|
|
714
|
+
const result = await engine.backup(config, outputPath, {
|
|
715
|
+
database: databaseName,
|
|
716
|
+
format,
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
backupSpinner.succeed('Backup created successfully')
|
|
720
|
+
|
|
721
|
+
console.log()
|
|
722
|
+
console.log(success('Backup complete'))
|
|
723
|
+
console.log()
|
|
724
|
+
console.log(chalk.gray(' File:'), chalk.cyan(result.path))
|
|
725
|
+
console.log(chalk.gray(' Size:'), chalk.white(formatBytes(result.size)))
|
|
726
|
+
console.log(chalk.gray(' Format:'), chalk.white(result.format))
|
|
727
|
+
console.log()
|
|
728
|
+
} catch (err) {
|
|
729
|
+
const e = err as Error
|
|
730
|
+
backupSpinner.fail('Backup failed')
|
|
731
|
+
console.log()
|
|
732
|
+
console.log(error(e.message))
|
|
733
|
+
console.log()
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Wait for user to see the result
|
|
737
|
+
await inquirer.prompt([
|
|
738
|
+
{
|
|
739
|
+
type: 'input',
|
|
740
|
+
name: 'continue',
|
|
741
|
+
message: chalk.gray('Press Enter to continue...'),
|
|
742
|
+
},
|
|
743
|
+
])
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
export async function handleClone(): Promise<void> {
|
|
747
|
+
const containers = await containerManager.list()
|
|
748
|
+
const stopped = containers.filter((c) => c.status !== 'running')
|
|
749
|
+
|
|
750
|
+
if (containers.length === 0) {
|
|
751
|
+
console.log(warning('No containers found'))
|
|
752
|
+
return
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (stopped.length === 0) {
|
|
756
|
+
console.log(
|
|
757
|
+
warning(
|
|
758
|
+
'All containers are running. Stop a container first to clone it.',
|
|
759
|
+
),
|
|
760
|
+
)
|
|
761
|
+
return
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const sourceName = await promptContainerSelect(
|
|
765
|
+
stopped,
|
|
766
|
+
'Select container to clone:',
|
|
767
|
+
)
|
|
768
|
+
if (!sourceName) return
|
|
769
|
+
|
|
770
|
+
const { targetName } = await inquirer.prompt<{ targetName: string }>([
|
|
771
|
+
{
|
|
772
|
+
type: 'input',
|
|
773
|
+
name: 'targetName',
|
|
774
|
+
message: 'Name for the cloned container:',
|
|
775
|
+
default: `${sourceName}-copy`,
|
|
776
|
+
validate: (input: string) => {
|
|
777
|
+
if (!input) return 'Name is required'
|
|
778
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(input)) {
|
|
779
|
+
return 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores'
|
|
780
|
+
}
|
|
781
|
+
return true
|
|
782
|
+
},
|
|
783
|
+
},
|
|
784
|
+
])
|
|
785
|
+
|
|
786
|
+
const spinner = createSpinner(`Cloning ${sourceName} to ${targetName}...`)
|
|
787
|
+
spinner.start()
|
|
788
|
+
|
|
789
|
+
const newConfig = await containerManager.clone(sourceName, targetName)
|
|
790
|
+
|
|
791
|
+
spinner.succeed(`Cloned "${sourceName}" to "${targetName}"`)
|
|
792
|
+
|
|
793
|
+
const engine = getEngine(newConfig.engine)
|
|
794
|
+
const connectionString = engine.getConnectionString(newConfig)
|
|
795
|
+
|
|
796
|
+
console.log()
|
|
797
|
+
console.log(connectionBox(targetName, connectionString, newConfig.port))
|
|
798
|
+
}
|