spindb 0.5.3 → 0.5.5
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 +78 -2
- package/cli/commands/backup.ts +263 -0
- package/cli/commands/config.ts +144 -66
- package/cli/commands/connect.ts +336 -111
- package/cli/commands/engines.ts +2 -10
- package/cli/commands/info.ts +3 -3
- package/cli/commands/list.ts +43 -5
- package/cli/commands/menu.ts +447 -33
- package/cli/commands/restore.ts +4 -1
- package/cli/index.ts +2 -0
- package/cli/ui/prompts.ts +99 -6
- package/cli/ui/theme.ts +12 -1
- package/config/os-dependencies.ts +92 -0
- package/core/binary-manager.ts +12 -19
- package/core/config-manager.ts +133 -37
- package/core/container-manager.ts +76 -2
- package/core/dependency-manager.ts +140 -0
- package/engines/base-engine.ts +20 -0
- package/engines/mysql/backup.ts +159 -0
- package/engines/mysql/index.ts +39 -0
- package/engines/mysql/restore.ts +16 -2
- package/engines/postgresql/backup.ts +93 -0
- package/engines/postgresql/index.ts +68 -1
- package/package.json +1 -1
- package/types/index.ts +20 -0
package/cli/ui/prompts.ts
CHANGED
|
@@ -53,7 +53,7 @@ export async function promptEngine(): Promise<string> {
|
|
|
53
53
|
|
|
54
54
|
// Build choices from available engines
|
|
55
55
|
const choices = engines.map((e) => ({
|
|
56
|
-
name: `${engineIcons[e.name] || '
|
|
56
|
+
name: `${engineIcons[e.name] || '▣'} ${e.displayName} ${chalk.gray(`(versions: ${e.supportedVersions.join(', ')})`)}`,
|
|
57
57
|
value: e.name,
|
|
58
58
|
short: e.displayName,
|
|
59
59
|
}))
|
|
@@ -227,7 +227,7 @@ export async function promptContainerSelect(
|
|
|
227
227
|
name: 'container',
|
|
228
228
|
message,
|
|
229
229
|
choices: containers.map((c) => ({
|
|
230
|
-
name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || '
|
|
230
|
+
name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || '▣'} ${c.engine} ${c.version}, port ${c.port})`)} ${
|
|
231
231
|
c.status === 'running'
|
|
232
232
|
? chalk.green('● running')
|
|
233
233
|
: chalk.gray('○ stopped')
|
|
@@ -243,19 +243,25 @@ export async function promptContainerSelect(
|
|
|
243
243
|
|
|
244
244
|
/**
|
|
245
245
|
* Prompt for database name
|
|
246
|
+
* @param defaultName - Default value for the database name
|
|
247
|
+
* @param engine - Database engine (mysql shows "schema" terminology)
|
|
246
248
|
*/
|
|
247
249
|
export async function promptDatabaseName(
|
|
248
250
|
defaultName?: string,
|
|
251
|
+
engine?: string,
|
|
249
252
|
): Promise<string> {
|
|
253
|
+
// MySQL uses "schema" terminology (database and schema are synonymous)
|
|
254
|
+
const label = engine === 'mysql' ? 'Database (schema) name:' : 'Database name:'
|
|
255
|
+
|
|
250
256
|
const { database } = await inquirer.prompt<{ database: string }>([
|
|
251
257
|
{
|
|
252
258
|
type: 'input',
|
|
253
259
|
name: 'database',
|
|
254
|
-
message:
|
|
260
|
+
message: label,
|
|
255
261
|
default: defaultName,
|
|
256
262
|
validate: (input: string) => {
|
|
257
263
|
if (!input) return 'Database name is required'
|
|
258
|
-
// PostgreSQL database naming rules
|
|
264
|
+
// PostgreSQL database naming rules (also valid for MySQL)
|
|
259
265
|
if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(input)) {
|
|
260
266
|
return 'Database name must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens'
|
|
261
267
|
}
|
|
@@ -270,6 +276,93 @@ export async function promptDatabaseName(
|
|
|
270
276
|
return database
|
|
271
277
|
}
|
|
272
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Prompt to select a database from a list of databases in a container
|
|
281
|
+
*/
|
|
282
|
+
export async function promptDatabaseSelect(
|
|
283
|
+
databases: string[],
|
|
284
|
+
message: string = 'Select database:',
|
|
285
|
+
): Promise<string> {
|
|
286
|
+
if (databases.length === 0) {
|
|
287
|
+
throw new Error('No databases available to select')
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (databases.length === 1) {
|
|
291
|
+
return databases[0]
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const { database } = await inquirer.prompt<{ database: string }>([
|
|
295
|
+
{
|
|
296
|
+
type: 'list',
|
|
297
|
+
name: 'database',
|
|
298
|
+
message,
|
|
299
|
+
choices: databases.map((db, index) => ({
|
|
300
|
+
name: index === 0 ? `${db} ${chalk.gray('(primary)')}` : db,
|
|
301
|
+
value: db,
|
|
302
|
+
short: db,
|
|
303
|
+
})),
|
|
304
|
+
},
|
|
305
|
+
])
|
|
306
|
+
|
|
307
|
+
return database
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Prompt for backup format selection
|
|
312
|
+
*/
|
|
313
|
+
export async function promptBackupFormat(
|
|
314
|
+
engine: string,
|
|
315
|
+
): Promise<'sql' | 'dump'> {
|
|
316
|
+
const sqlDescription =
|
|
317
|
+
engine === 'mysql'
|
|
318
|
+
? 'Plain SQL - human-readable, larger file'
|
|
319
|
+
: 'Plain SQL - human-readable, larger file'
|
|
320
|
+
const dumpDescription =
|
|
321
|
+
engine === 'mysql'
|
|
322
|
+
? 'Compressed SQL (.sql.gz) - smaller file'
|
|
323
|
+
: 'Custom format - smaller file, faster restore'
|
|
324
|
+
|
|
325
|
+
const { format } = await inquirer.prompt<{ format: 'sql' | 'dump' }>([
|
|
326
|
+
{
|
|
327
|
+
type: 'list',
|
|
328
|
+
name: 'format',
|
|
329
|
+
message: 'Select backup format:',
|
|
330
|
+
choices: [
|
|
331
|
+
{ name: `.sql ${chalk.gray(`- ${sqlDescription}`)}`, value: 'sql' },
|
|
332
|
+
{ name: `.dump ${chalk.gray(`- ${dumpDescription}`)}`, value: 'dump' },
|
|
333
|
+
],
|
|
334
|
+
default: 'sql',
|
|
335
|
+
},
|
|
336
|
+
])
|
|
337
|
+
|
|
338
|
+
return format
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Prompt for backup filename
|
|
343
|
+
*/
|
|
344
|
+
export async function promptBackupFilename(
|
|
345
|
+
defaultName: string,
|
|
346
|
+
): Promise<string> {
|
|
347
|
+
const { filename } = await inquirer.prompt<{ filename: string }>([
|
|
348
|
+
{
|
|
349
|
+
type: 'input',
|
|
350
|
+
name: 'filename',
|
|
351
|
+
message: 'Backup filename (without extension):',
|
|
352
|
+
default: defaultName,
|
|
353
|
+
validate: (input: string) => {
|
|
354
|
+
if (!input) return 'Filename is required'
|
|
355
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(input)) {
|
|
356
|
+
return 'Filename must contain only letters, numbers, underscores, and hyphens'
|
|
357
|
+
}
|
|
358
|
+
return true
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
])
|
|
362
|
+
|
|
363
|
+
return filename
|
|
364
|
+
}
|
|
365
|
+
|
|
273
366
|
export type CreateOptions = {
|
|
274
367
|
name: string
|
|
275
368
|
engine: string
|
|
@@ -282,12 +375,12 @@ export type CreateOptions = {
|
|
|
282
375
|
* Full interactive create flow
|
|
283
376
|
*/
|
|
284
377
|
export async function promptCreateOptions(): Promise<CreateOptions> {
|
|
285
|
-
console.log(chalk.cyan('\n
|
|
378
|
+
console.log(chalk.cyan('\n ▣ Create New Database Container\n'))
|
|
286
379
|
|
|
287
380
|
const engine = await promptEngine()
|
|
288
381
|
const version = await promptVersion(engine)
|
|
289
382
|
const name = await promptContainerName()
|
|
290
|
-
const database = await promptDatabaseName(name) // Default to container name
|
|
383
|
+
const database = await promptDatabaseName(name, engine) // Default to container name
|
|
291
384
|
|
|
292
385
|
// Get engine-specific default port
|
|
293
386
|
const engineDefaults = getEngineDefaults(engine)
|
package/cli/ui/theme.ts
CHANGED
|
@@ -40,7 +40,7 @@ export const theme = {
|
|
|
40
40
|
info: chalk.blue('ℹ'),
|
|
41
41
|
arrow: chalk.cyan('→'),
|
|
42
42
|
bullet: chalk.gray('•'),
|
|
43
|
-
database: '
|
|
43
|
+
database: '▣',
|
|
44
44
|
postgres: '🐘',
|
|
45
45
|
},
|
|
46
46
|
}
|
|
@@ -155,3 +155,14 @@ export function connectionBox(
|
|
|
155
155
|
|
|
156
156
|
return box(lines)
|
|
157
157
|
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Format bytes into human-readable format (B, KB, MB, GB)
|
|
161
|
+
*/
|
|
162
|
+
export function formatBytes(bytes: number): string {
|
|
163
|
+
if (bytes === 0) return '0 B'
|
|
164
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
165
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
|
166
|
+
const value = bytes / Math.pow(1024, i)
|
|
167
|
+
return `${value.toFixed(1)} ${units[i]}`
|
|
168
|
+
}
|
|
@@ -289,6 +289,98 @@ const mysqlDependencies: EngineDependencies = {
|
|
|
289
289
|
],
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
// =============================================================================
|
|
293
|
+
// Optional Tools (engine-agnostic)
|
|
294
|
+
// =============================================================================
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* usql - Universal SQL client
|
|
298
|
+
* Works with PostgreSQL, MySQL, SQLite, and 20+ other databases
|
|
299
|
+
* https://github.com/xo/usql
|
|
300
|
+
*/
|
|
301
|
+
export const usqlDependency: Dependency = {
|
|
302
|
+
name: 'usql',
|
|
303
|
+
binary: 'usql',
|
|
304
|
+
description:
|
|
305
|
+
'Universal SQL client with auto-completion, syntax highlighting, and multi-database support',
|
|
306
|
+
packages: {
|
|
307
|
+
brew: {
|
|
308
|
+
package: 'xo/xo/usql',
|
|
309
|
+
preInstall: ['brew tap xo/xo'],
|
|
310
|
+
},
|
|
311
|
+
// Note: usql is not in standard Linux package repos, must use manual install
|
|
312
|
+
},
|
|
313
|
+
manualInstall: {
|
|
314
|
+
darwin: [
|
|
315
|
+
'Install Homebrew: /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
|
|
316
|
+
'Then run: brew tap xo/xo && brew install xo/xo/usql',
|
|
317
|
+
],
|
|
318
|
+
linux: [
|
|
319
|
+
'Download from GitHub releases: https://github.com/xo/usql/releases',
|
|
320
|
+
'Extract and move to PATH: sudo mv usql /usr/local/bin/',
|
|
321
|
+
'Or install via Go: go install github.com/xo/usql@latest',
|
|
322
|
+
],
|
|
323
|
+
},
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* pgcli - PostgreSQL CLI with auto-completion and syntax highlighting
|
|
328
|
+
* https://github.com/dbcli/pgcli
|
|
329
|
+
*/
|
|
330
|
+
export const pgcliDependency: Dependency = {
|
|
331
|
+
name: 'pgcli',
|
|
332
|
+
binary: 'pgcli',
|
|
333
|
+
description:
|
|
334
|
+
'PostgreSQL CLI with intelligent auto-completion and syntax highlighting',
|
|
335
|
+
packages: {
|
|
336
|
+
brew: { package: 'pgcli' },
|
|
337
|
+
apt: { package: 'pgcli' },
|
|
338
|
+
dnf: { package: 'pgcli' },
|
|
339
|
+
yum: { package: 'pgcli' },
|
|
340
|
+
pacman: { package: 'pgcli' },
|
|
341
|
+
},
|
|
342
|
+
manualInstall: {
|
|
343
|
+
darwin: [
|
|
344
|
+
'Install with Homebrew: brew install pgcli',
|
|
345
|
+
'Or with pip: pip install pgcli',
|
|
346
|
+
],
|
|
347
|
+
linux: [
|
|
348
|
+
'Debian/Ubuntu: sudo apt install pgcli',
|
|
349
|
+
'Fedora: sudo dnf install pgcli',
|
|
350
|
+
'Or with pip: pip install pgcli',
|
|
351
|
+
],
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* mycli - MySQL CLI with auto-completion and syntax highlighting
|
|
357
|
+
* https://github.com/dbcli/mycli
|
|
358
|
+
*/
|
|
359
|
+
export const mycliDependency: Dependency = {
|
|
360
|
+
name: 'mycli',
|
|
361
|
+
binary: 'mycli',
|
|
362
|
+
description:
|
|
363
|
+
'MySQL/MariaDB CLI with intelligent auto-completion and syntax highlighting',
|
|
364
|
+
packages: {
|
|
365
|
+
brew: { package: 'mycli' },
|
|
366
|
+
apt: { package: 'mycli' },
|
|
367
|
+
dnf: { package: 'mycli' },
|
|
368
|
+
yum: { package: 'mycli' },
|
|
369
|
+
pacman: { package: 'mycli' },
|
|
370
|
+
},
|
|
371
|
+
manualInstall: {
|
|
372
|
+
darwin: [
|
|
373
|
+
'Install with Homebrew: brew install mycli',
|
|
374
|
+
'Or with pip: pip install mycli',
|
|
375
|
+
],
|
|
376
|
+
linux: [
|
|
377
|
+
'Debian/Ubuntu: sudo apt install mycli',
|
|
378
|
+
'Fedora: sudo dnf install mycli',
|
|
379
|
+
'Or with pip: pip install mycli',
|
|
380
|
+
],
|
|
381
|
+
},
|
|
382
|
+
}
|
|
383
|
+
|
|
292
384
|
// =============================================================================
|
|
293
385
|
// Registry
|
|
294
386
|
// =============================================================================
|
package/core/binary-manager.ts
CHANGED
|
@@ -54,26 +54,19 @@ export class BinaryManager {
|
|
|
54
54
|
return version
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/**
|
|
58
|
-
* Get major version from any version string (e.g., "17.7.0" -> "17", "16" -> "16")
|
|
59
|
-
* Used for directory naming to ensure one directory per major version.
|
|
60
|
-
*/
|
|
61
|
-
getMajorVersion(version: string): string {
|
|
62
|
-
return version.split('.')[0]
|
|
63
|
-
}
|
|
64
|
-
|
|
65
57
|
/**
|
|
66
58
|
* Check if binaries for a specific version are already installed
|
|
59
|
+
* Uses full version for directory naming (e.g., postgresql-17.7.0-darwin-arm64)
|
|
67
60
|
*/
|
|
68
61
|
async isInstalled(
|
|
69
62
|
version: string,
|
|
70
63
|
platform: string,
|
|
71
64
|
arch: string,
|
|
72
65
|
): Promise<boolean> {
|
|
73
|
-
const
|
|
66
|
+
const fullVersion = this.getFullVersion(version)
|
|
74
67
|
const binPath = paths.getBinaryPath({
|
|
75
68
|
engine: 'postgresql',
|
|
76
|
-
version:
|
|
69
|
+
version: fullVersion,
|
|
77
70
|
platform,
|
|
78
71
|
arch,
|
|
79
72
|
})
|
|
@@ -122,15 +115,15 @@ export class BinaryManager {
|
|
|
122
115
|
arch: string,
|
|
123
116
|
onProgress?: ProgressCallback,
|
|
124
117
|
): Promise<string> {
|
|
125
|
-
const
|
|
118
|
+
const fullVersion = this.getFullVersion(version)
|
|
126
119
|
const url = this.getDownloadUrl(version, platform, arch)
|
|
127
120
|
const binPath = paths.getBinaryPath({
|
|
128
121
|
engine: 'postgresql',
|
|
129
|
-
version:
|
|
122
|
+
version: fullVersion,
|
|
130
123
|
platform,
|
|
131
124
|
arch,
|
|
132
125
|
})
|
|
133
|
-
const tempDir = join(paths.bin, `temp-${
|
|
126
|
+
const tempDir = join(paths.bin, `temp-${fullVersion}-${platform}-${arch}`)
|
|
134
127
|
const jarFile = join(tempDir, 'postgres.jar')
|
|
135
128
|
|
|
136
129
|
// Ensure directories exist
|
|
@@ -210,10 +203,10 @@ export class BinaryManager {
|
|
|
210
203
|
platform: string,
|
|
211
204
|
arch: string,
|
|
212
205
|
): Promise<boolean> {
|
|
213
|
-
const
|
|
206
|
+
const fullVersion = this.getFullVersion(version)
|
|
214
207
|
const binPath = paths.getBinaryPath({
|
|
215
208
|
engine: 'postgresql',
|
|
216
|
-
version:
|
|
209
|
+
version: fullVersion,
|
|
217
210
|
platform,
|
|
218
211
|
arch,
|
|
219
212
|
})
|
|
@@ -267,10 +260,10 @@ export class BinaryManager {
|
|
|
267
260
|
arch: string,
|
|
268
261
|
binary: string,
|
|
269
262
|
): string {
|
|
270
|
-
const
|
|
263
|
+
const fullVersion = this.getFullVersion(version)
|
|
271
264
|
const binPath = paths.getBinaryPath({
|
|
272
265
|
engine: 'postgresql',
|
|
273
|
-
version:
|
|
266
|
+
version: fullVersion,
|
|
274
267
|
platform,
|
|
275
268
|
arch,
|
|
276
269
|
})
|
|
@@ -286,7 +279,7 @@ export class BinaryManager {
|
|
|
286
279
|
arch: string,
|
|
287
280
|
onProgress?: ProgressCallback,
|
|
288
281
|
): Promise<string> {
|
|
289
|
-
const
|
|
282
|
+
const fullVersion = this.getFullVersion(version)
|
|
290
283
|
if (await this.isInstalled(version, platform, arch)) {
|
|
291
284
|
onProgress?.({
|
|
292
285
|
stage: 'cached',
|
|
@@ -294,7 +287,7 @@ export class BinaryManager {
|
|
|
294
287
|
})
|
|
295
288
|
return paths.getBinaryPath({
|
|
296
289
|
engine: 'postgresql',
|
|
297
|
-
version:
|
|
290
|
+
version: fullVersion,
|
|
298
291
|
platform,
|
|
299
292
|
arch,
|
|
300
293
|
})
|
package/core/config-manager.ts
CHANGED
|
@@ -18,6 +18,32 @@ const DEFAULT_CONFIG: SpinDBConfig = {
|
|
|
18
18
|
binaries: {},
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// Cache staleness threshold (7 days in milliseconds)
|
|
22
|
+
const CACHE_STALENESS_MS = 7 * 24 * 60 * 60 * 1000
|
|
23
|
+
|
|
24
|
+
// All tools organized by category
|
|
25
|
+
const POSTGRESQL_TOOLS: BinaryTool[] = [
|
|
26
|
+
'psql',
|
|
27
|
+
'pg_dump',
|
|
28
|
+
'pg_restore',
|
|
29
|
+
'pg_basebackup',
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
const MYSQL_TOOLS: BinaryTool[] = [
|
|
33
|
+
'mysql',
|
|
34
|
+
'mysqldump',
|
|
35
|
+
'mysqladmin',
|
|
36
|
+
'mysqld',
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
const ENHANCED_SHELLS: BinaryTool[] = ['pgcli', 'mycli', 'usql']
|
|
40
|
+
|
|
41
|
+
const ALL_TOOLS: BinaryTool[] = [
|
|
42
|
+
...POSTGRESQL_TOOLS,
|
|
43
|
+
...MYSQL_TOOLS,
|
|
44
|
+
...ENHANCED_SHELLS,
|
|
45
|
+
]
|
|
46
|
+
|
|
21
47
|
export class ConfigManager {
|
|
22
48
|
private config: SpinDBConfig | null = null
|
|
23
49
|
|
|
@@ -170,44 +196,55 @@ export class ConfigManager {
|
|
|
170
196
|
}
|
|
171
197
|
|
|
172
198
|
/**
|
|
173
|
-
* Get common installation paths for
|
|
199
|
+
* Get common installation paths for database tools
|
|
174
200
|
*/
|
|
175
201
|
private getCommonBinaryPaths(tool: BinaryTool): string[] {
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
// Homebrew (macOS)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
`/
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
202
|
+
const commonPaths: string[] = []
|
|
203
|
+
|
|
204
|
+
// Homebrew (macOS ARM)
|
|
205
|
+
commonPaths.push(`/opt/homebrew/bin/${tool}`)
|
|
206
|
+
// Homebrew (macOS Intel)
|
|
207
|
+
commonPaths.push(`/usr/local/bin/${tool}`)
|
|
208
|
+
|
|
209
|
+
// PostgreSQL-specific paths
|
|
210
|
+
if (POSTGRESQL_TOOLS.includes(tool) || tool === 'pgcli') {
|
|
211
|
+
commonPaths.push(`/opt/homebrew/opt/libpq/bin/${tool}`)
|
|
212
|
+
commonPaths.push(`/usr/local/opt/libpq/bin/${tool}`)
|
|
213
|
+
// Postgres.app (macOS)
|
|
214
|
+
commonPaths.push(
|
|
215
|
+
`/Applications/Postgres.app/Contents/Versions/latest/bin/${tool}`,
|
|
216
|
+
)
|
|
217
|
+
// Linux PostgreSQL paths
|
|
218
|
+
commonPaths.push(`/usr/lib/postgresql/17/bin/${tool}`)
|
|
219
|
+
commonPaths.push(`/usr/lib/postgresql/16/bin/${tool}`)
|
|
220
|
+
commonPaths.push(`/usr/lib/postgresql/15/bin/${tool}`)
|
|
221
|
+
commonPaths.push(`/usr/lib/postgresql/14/bin/${tool}`)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// MySQL-specific paths
|
|
225
|
+
if (MYSQL_TOOLS.includes(tool) || tool === 'mycli') {
|
|
226
|
+
commonPaths.push(`/opt/homebrew/opt/mysql/bin/${tool}`)
|
|
227
|
+
commonPaths.push(`/opt/homebrew/opt/mysql-client/bin/${tool}`)
|
|
228
|
+
commonPaths.push(`/usr/local/opt/mysql/bin/${tool}`)
|
|
229
|
+
commonPaths.push(`/usr/local/opt/mysql-client/bin/${tool}`)
|
|
230
|
+
// Linux MySQL/MariaDB paths
|
|
231
|
+
commonPaths.push(`/usr/bin/${tool}`)
|
|
232
|
+
commonPaths.push(`/usr/sbin/${tool}`)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// General Linux paths
|
|
236
|
+
commonPaths.push(`/usr/bin/${tool}`)
|
|
237
|
+
|
|
238
|
+
return commonPaths
|
|
196
239
|
}
|
|
197
240
|
|
|
198
241
|
/**
|
|
199
242
|
* Detect all available client tools on the system
|
|
200
243
|
*/
|
|
201
244
|
async detectAllTools(): Promise<Map<BinaryTool, string>> {
|
|
202
|
-
const tools: BinaryTool[] = [
|
|
203
|
-
'psql',
|
|
204
|
-
'pg_dump',
|
|
205
|
-
'pg_restore',
|
|
206
|
-
'pg_basebackup',
|
|
207
|
-
]
|
|
208
245
|
const found = new Map<BinaryTool, string>()
|
|
209
246
|
|
|
210
|
-
for (const tool of
|
|
247
|
+
for (const tool of ALL_TOOLS) {
|
|
211
248
|
const path = await this.detectSystemBinary(tool)
|
|
212
249
|
if (path) {
|
|
213
250
|
found.set(tool, path)
|
|
@@ -219,18 +256,19 @@ export class ConfigManager {
|
|
|
219
256
|
|
|
220
257
|
/**
|
|
221
258
|
* Initialize config by detecting all available tools
|
|
259
|
+
* Groups results by category for better display
|
|
222
260
|
*/
|
|
223
|
-
async initialize(): Promise<{
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
261
|
+
async initialize(): Promise<{
|
|
262
|
+
found: BinaryTool[]
|
|
263
|
+
missing: BinaryTool[]
|
|
264
|
+
postgresql: { found: BinaryTool[]; missing: BinaryTool[] }
|
|
265
|
+
mysql: { found: BinaryTool[]; missing: BinaryTool[] }
|
|
266
|
+
enhanced: { found: BinaryTool[]; missing: BinaryTool[] }
|
|
267
|
+
}> {
|
|
230
268
|
const found: BinaryTool[] = []
|
|
231
269
|
const missing: BinaryTool[] = []
|
|
232
270
|
|
|
233
|
-
for (const tool of
|
|
271
|
+
for (const tool of ALL_TOOLS) {
|
|
234
272
|
const path = await this.getBinaryPath(tool)
|
|
235
273
|
if (path) {
|
|
236
274
|
found.push(tool)
|
|
@@ -239,7 +277,57 @@ export class ConfigManager {
|
|
|
239
277
|
}
|
|
240
278
|
}
|
|
241
279
|
|
|
242
|
-
return {
|
|
280
|
+
return {
|
|
281
|
+
found,
|
|
282
|
+
missing,
|
|
283
|
+
postgresql: {
|
|
284
|
+
found: found.filter((t) => POSTGRESQL_TOOLS.includes(t)),
|
|
285
|
+
missing: missing.filter((t) => POSTGRESQL_TOOLS.includes(t)),
|
|
286
|
+
},
|
|
287
|
+
mysql: {
|
|
288
|
+
found: found.filter((t) => MYSQL_TOOLS.includes(t)),
|
|
289
|
+
missing: missing.filter((t) => MYSQL_TOOLS.includes(t)),
|
|
290
|
+
},
|
|
291
|
+
enhanced: {
|
|
292
|
+
found: found.filter((t) => ENHANCED_SHELLS.includes(t)),
|
|
293
|
+
missing: missing.filter((t) => ENHANCED_SHELLS.includes(t)),
|
|
294
|
+
},
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Check if the config cache is stale (older than 7 days)
|
|
300
|
+
*/
|
|
301
|
+
async isStale(): Promise<boolean> {
|
|
302
|
+
const config = await this.load()
|
|
303
|
+
if (!config.updatedAt) {
|
|
304
|
+
return true
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const updatedAt = new Date(config.updatedAt).getTime()
|
|
308
|
+
const now = Date.now()
|
|
309
|
+
return now - updatedAt > CACHE_STALENESS_MS
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Refresh all tool paths if cache is stale
|
|
314
|
+
* Returns true if refresh was performed
|
|
315
|
+
*/
|
|
316
|
+
async refreshIfStale(): Promise<boolean> {
|
|
317
|
+
if (await this.isStale()) {
|
|
318
|
+
await this.refreshAllBinaries()
|
|
319
|
+
return true
|
|
320
|
+
}
|
|
321
|
+
return false
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Force refresh all binary paths
|
|
326
|
+
* Re-detects all tools and updates versions
|
|
327
|
+
*/
|
|
328
|
+
async refreshAllBinaries(): Promise<void> {
|
|
329
|
+
await this.clearAllBinaries()
|
|
330
|
+
await this.initialize()
|
|
243
331
|
}
|
|
244
332
|
|
|
245
333
|
/**
|
|
@@ -269,3 +357,11 @@ export class ConfigManager {
|
|
|
269
357
|
}
|
|
270
358
|
|
|
271
359
|
export const configManager = new ConfigManager()
|
|
360
|
+
|
|
361
|
+
// Export tool categories for use in commands
|
|
362
|
+
export {
|
|
363
|
+
POSTGRESQL_TOOLS,
|
|
364
|
+
MYSQL_TOOLS,
|
|
365
|
+
ENHANCED_SHELLS,
|
|
366
|
+
ALL_TOOLS,
|
|
367
|
+
}
|
|
@@ -51,6 +51,7 @@ export class ContainerManager {
|
|
|
51
51
|
version,
|
|
52
52
|
port,
|
|
53
53
|
database,
|
|
54
|
+
databases: [database],
|
|
54
55
|
created: new Date().toISOString(),
|
|
55
56
|
status: 'created',
|
|
56
57
|
}
|
|
@@ -63,6 +64,7 @@ export class ContainerManager {
|
|
|
63
64
|
/**
|
|
64
65
|
* Get container configuration
|
|
65
66
|
* If engine is not provided, searches all engine directories
|
|
67
|
+
* Automatically migrates old schemas to include databases array
|
|
66
68
|
*/
|
|
67
69
|
async getConfig(
|
|
68
70
|
name: string,
|
|
@@ -77,7 +79,8 @@ export class ContainerManager {
|
|
|
77
79
|
return null
|
|
78
80
|
}
|
|
79
81
|
const content = await readFile(configPath, 'utf8')
|
|
80
|
-
|
|
82
|
+
const config = JSON.parse(content) as ContainerConfig
|
|
83
|
+
return this.migrateConfig(config)
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
// Search all engine directories
|
|
@@ -86,13 +89,43 @@ export class ContainerManager {
|
|
|
86
89
|
const configPath = paths.getContainerConfigPath(name, { engine: eng })
|
|
87
90
|
if (existsSync(configPath)) {
|
|
88
91
|
const content = await readFile(configPath, 'utf8')
|
|
89
|
-
|
|
92
|
+
const config = JSON.parse(content) as ContainerConfig
|
|
93
|
+
return this.migrateConfig(config)
|
|
90
94
|
}
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
return null
|
|
94
98
|
}
|
|
95
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Migrate old container configs to include databases array
|
|
102
|
+
* Ensures primary database is always in the databases array
|
|
103
|
+
*/
|
|
104
|
+
private async migrateConfig(
|
|
105
|
+
config: ContainerConfig,
|
|
106
|
+
): Promise<ContainerConfig> {
|
|
107
|
+
let needsSave = false
|
|
108
|
+
|
|
109
|
+
// If databases array is missing, create it with the primary database
|
|
110
|
+
if (!config.databases) {
|
|
111
|
+
config.databases = [config.database]
|
|
112
|
+
needsSave = true
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Ensure primary database is in the array
|
|
116
|
+
if (!config.databases.includes(config.database)) {
|
|
117
|
+
config.databases = [config.database, ...config.databases]
|
|
118
|
+
needsSave = true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Save if we made changes
|
|
122
|
+
if (needsSave) {
|
|
123
|
+
await this.saveConfig(config.name, { engine: config.engine }, config)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return config
|
|
127
|
+
}
|
|
128
|
+
|
|
96
129
|
/**
|
|
97
130
|
* Save container configuration
|
|
98
131
|
*/
|
|
@@ -333,6 +366,47 @@ export class ContainerManager {
|
|
|
333
366
|
return /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)
|
|
334
367
|
}
|
|
335
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Add a database to the container's databases array
|
|
371
|
+
*/
|
|
372
|
+
async addDatabase(containerName: string, database: string): Promise<void> {
|
|
373
|
+
const config = await this.getConfig(containerName)
|
|
374
|
+
if (!config) {
|
|
375
|
+
throw new Error(`Container "${containerName}" not found`)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Ensure databases array exists
|
|
379
|
+
if (!config.databases) {
|
|
380
|
+
config.databases = [config.database]
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Add if not already present
|
|
384
|
+
if (!config.databases.includes(database)) {
|
|
385
|
+
config.databases.push(database)
|
|
386
|
+
await this.saveConfig(containerName, { engine: config.engine }, config)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Remove a database from the container's databases array
|
|
392
|
+
*/
|
|
393
|
+
async removeDatabase(containerName: string, database: string): Promise<void> {
|
|
394
|
+
const config = await this.getConfig(containerName)
|
|
395
|
+
if (!config) {
|
|
396
|
+
throw new Error(`Container "${containerName}" not found`)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Don't remove the primary database from the array
|
|
400
|
+
if (database === config.database) {
|
|
401
|
+
throw new Error(`Cannot remove primary database "${database}" from tracking`)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (config.databases) {
|
|
405
|
+
config.databases = config.databases.filter((db) => db !== database)
|
|
406
|
+
await this.saveConfig(containerName, { engine: config.engine }, config)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
336
410
|
/**
|
|
337
411
|
* Get connection string for a container
|
|
338
412
|
* Delegates to the appropriate engine
|