spindb 0.9.2 → 0.10.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/README.md +24 -18
- package/cli/commands/attach.ts +108 -0
- package/cli/commands/create.ts +78 -42
- package/cli/commands/detach.ts +100 -0
- package/cli/commands/doctor.ts +16 -2
- package/cli/commands/edit.ts +12 -2
- package/cli/commands/engines.ts +61 -0
- package/cli/commands/list.ts +96 -2
- package/cli/commands/logs.ts +3 -29
- package/cli/commands/menu/container-handlers.ts +76 -4
- package/cli/commands/menu/sql-handlers.ts +4 -26
- package/cli/commands/sqlite.ts +247 -0
- package/cli/helpers.ts +6 -6
- package/cli/index.ts +9 -3
- package/cli/utils/file-follower.ts +95 -0
- package/config/defaults.ts +3 -0
- package/config/os-dependencies.ts +79 -1
- package/config/paths.ts +0 -8
- package/core/binary-manager.ts +181 -66
- package/core/config-manager.ts +37 -65
- package/core/dependency-manager.ts +39 -1
- package/core/platform-service.ts +149 -11
- package/core/process-manager.ts +152 -33
- package/engines/base-engine.ts +27 -0
- package/engines/mysql/backup.ts +49 -18
- package/engines/mysql/index.ts +328 -110
- package/engines/mysql/restore.ts +22 -6
- package/engines/postgresql/backup.ts +7 -3
- package/engines/postgresql/binary-manager.ts +47 -31
- package/engines/postgresql/edb-binary-urls.ts +123 -0
- package/engines/postgresql/index.ts +109 -22
- package/engines/postgresql/version-maps.ts +63 -0
- package/engines/sqlite/index.ts +18 -26
- package/engines/sqlite/registry.ts +64 -33
- package/engines/sqlite/scanner.ts +99 -0
- package/package.json +7 -4
- package/types/index.ts +21 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/spindb)
|
|
4
4
|
[](LICENSE)
|
|
5
|
-
[](#supported-platforms)
|
|
5
|
+
[](#supported-platforms)
|
|
6
6
|
|
|
7
7
|
**Local databases without the Docker baggage.**
|
|
8
8
|
|
|
@@ -140,9 +140,11 @@ That's it. Your database is running on `localhost:5432`, and your data persists
|
|
|
140
140
|
| Versions | 14, 15, 16, 17 |
|
|
141
141
|
| Default port | 5432 |
|
|
142
142
|
| Default user | `postgres` |
|
|
143
|
-
| Binary source | [zonky.io](https://github.com/zonkyio/embedded-postgres-binaries) |
|
|
143
|
+
| Binary source | [zonky.io](https://github.com/zonkyio/embedded-postgres-binaries) (macOS/Linux), [EDB](https://www.enterprisedb.com/) (Windows) |
|
|
144
144
|
|
|
145
|
-
SpinDB downloads PostgreSQL server binaries automatically
|
|
145
|
+
SpinDB downloads PostgreSQL server binaries automatically:
|
|
146
|
+
- **macOS/Linux:** Pre-compiled binaries from the zonky.io project, hosted on Maven Central
|
|
147
|
+
- **Windows:** Official binaries from EnterpriseDB (EDB)
|
|
146
148
|
|
|
147
149
|
**Why download binaries instead of using system PostgreSQL?** You might want PostgreSQL 14 for one project and 17 for another. SpinDB lets you run different versions side-by-side without conflicts.
|
|
148
150
|
|
|
@@ -170,6 +172,12 @@ brew install mysql
|
|
|
170
172
|
# Ubuntu/Debian
|
|
171
173
|
sudo apt install mysql-server
|
|
172
174
|
|
|
175
|
+
# Windows (Chocolatey)
|
|
176
|
+
choco install mysql
|
|
177
|
+
|
|
178
|
+
# Windows (winget)
|
|
179
|
+
winget install Oracle.MySQL
|
|
180
|
+
|
|
173
181
|
# Check if SpinDB can find MySQL
|
|
174
182
|
spindb deps check --engine mysql
|
|
175
183
|
```
|
|
@@ -561,11 +569,11 @@ When you stop a container:
|
|
|
561
569
|
|
|
562
570
|
### Binary Sources
|
|
563
571
|
|
|
564
|
-
**PostgreSQL:** Server binaries are downloaded
|
|
565
|
-
- macOS (
|
|
566
|
-
-
|
|
572
|
+
**PostgreSQL:** Server binaries are downloaded automatically:
|
|
573
|
+
- **macOS/Linux:** From [zonky.io/embedded-postgres-binaries](https://github.com/zonkyio/embedded-postgres-binaries), hosted on Maven Central
|
|
574
|
+
- **Windows:** From [EnterpriseDB (EDB)](https://www.enterprisedb.com/download-postgresql-binaries), official PostgreSQL distributions
|
|
567
575
|
|
|
568
|
-
**MySQL:** Uses your system's MySQL installation. SpinDB detects binaries from Homebrew, apt
|
|
576
|
+
**MySQL:** Uses your system's MySQL installation. SpinDB detects binaries from Homebrew (macOS), apt/pacman (Linux), or Chocolatey/winget/Scoop (Windows).
|
|
569
577
|
|
|
570
578
|
---
|
|
571
579
|
|
|
@@ -577,15 +585,14 @@ When you stop a container:
|
|
|
577
585
|
| macOS | Intel (x64) | ✅ Supported |
|
|
578
586
|
| Linux | x64 | ✅ Supported |
|
|
579
587
|
| Linux | ARM64 | ✅ Supported |
|
|
580
|
-
| Windows |
|
|
588
|
+
| Windows | x64 | ✅ Supported |
|
|
581
589
|
|
|
582
|
-
|
|
590
|
+
Windows uses EnterpriseDB (EDB) official binaries for PostgreSQL. MySQL and SQLite require system installations via Chocolatey, winget, or Scoop.
|
|
583
591
|
|
|
584
592
|
---
|
|
585
593
|
|
|
586
594
|
## Limitations
|
|
587
595
|
|
|
588
|
-
- **No Windows support** - zonky.io doesn't provide Windows PostgreSQL binaries
|
|
589
596
|
- **Client tools required** - `psql` and `mysql` must be installed separately for some operations
|
|
590
597
|
- **Local only** - Databases bind to `127.0.0.1`; remote connections planned for v1.1
|
|
591
598
|
- **MySQL requires system install** - Unlike PostgreSQL, we don't download MySQL binaries
|
|
@@ -653,16 +660,15 @@ rm -rf ~/.spindb
|
|
|
653
660
|
|
|
654
661
|
## Contributing
|
|
655
662
|
|
|
656
|
-
|
|
663
|
+
Note: This repo currently assumes `pnpm` for running tests. `npm test` will shell out to `pnpm` and fail if `pnpm` isn't installed.
|
|
657
664
|
|
|
658
|
-
|
|
665
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, testing, and distribution info.
|
|
659
666
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
```
|
|
667
|
+
See [ARCHITECTURE.md](ARCHITECTURE.md) for project architecture and comprehensive CLI command examples.
|
|
668
|
+
|
|
669
|
+
See [CLAUDE.md](CLAUDE.md) for AI-assisted development context.
|
|
670
|
+
|
|
671
|
+
See [ENGINES.md](ENGINES.md) for detailed engine documentation (backup formats, planned engines, etc.).
|
|
666
672
|
|
|
667
673
|
---
|
|
668
674
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import { existsSync } from 'fs'
|
|
3
|
+
import { resolve, basename } from 'path'
|
|
4
|
+
import chalk from 'chalk'
|
|
5
|
+
import { sqliteRegistry } from '../../engines/sqlite/registry'
|
|
6
|
+
import { containerManager } from '../../core/container-manager'
|
|
7
|
+
import { deriveContainerName } from '../../engines/sqlite/scanner'
|
|
8
|
+
import { uiSuccess, uiError } from '../ui/theme'
|
|
9
|
+
|
|
10
|
+
export const attachCommand = new Command('attach')
|
|
11
|
+
.description('Register an existing SQLite database with SpinDB')
|
|
12
|
+
.argument('<path>', 'Path to SQLite database file')
|
|
13
|
+
.option('-n, --name <name>', 'Container name (defaults to filename)')
|
|
14
|
+
.option('--json', 'Output as JSON')
|
|
15
|
+
.action(
|
|
16
|
+
async (
|
|
17
|
+
path: string,
|
|
18
|
+
options: { name?: string; json?: boolean },
|
|
19
|
+
): Promise<void> => {
|
|
20
|
+
try {
|
|
21
|
+
const absolutePath = resolve(path)
|
|
22
|
+
|
|
23
|
+
// Verify file exists
|
|
24
|
+
if (!existsSync(absolutePath)) {
|
|
25
|
+
if (options.json) {
|
|
26
|
+
console.log(
|
|
27
|
+
JSON.stringify({ success: false, error: 'File not found' }),
|
|
28
|
+
)
|
|
29
|
+
} else {
|
|
30
|
+
console.error(uiError(`File not found: ${absolutePath}`))
|
|
31
|
+
}
|
|
32
|
+
process.exit(1)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Check if already registered
|
|
36
|
+
if (await sqliteRegistry.isPathRegistered(absolutePath)) {
|
|
37
|
+
const entry = await sqliteRegistry.getByPath(absolutePath)
|
|
38
|
+
if (options.json) {
|
|
39
|
+
console.log(
|
|
40
|
+
JSON.stringify({
|
|
41
|
+
success: false,
|
|
42
|
+
error: 'Already registered',
|
|
43
|
+
existingName: entry?.name,
|
|
44
|
+
}),
|
|
45
|
+
)
|
|
46
|
+
} else {
|
|
47
|
+
console.error(
|
|
48
|
+
uiError(`File is already registered as "${entry?.name}"`),
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
process.exit(1)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Determine container name
|
|
55
|
+
const containerName =
|
|
56
|
+
options.name || deriveContainerName(basename(absolutePath))
|
|
57
|
+
|
|
58
|
+
// Check if container name exists
|
|
59
|
+
if (await containerManager.exists(containerName)) {
|
|
60
|
+
if (options.json) {
|
|
61
|
+
console.log(
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
success: false,
|
|
64
|
+
error: 'Container name already exists',
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
67
|
+
} else {
|
|
68
|
+
console.error(uiError(`Container "${containerName}" already exists`))
|
|
69
|
+
}
|
|
70
|
+
process.exit(1)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Register the file
|
|
74
|
+
await sqliteRegistry.add({
|
|
75
|
+
name: containerName,
|
|
76
|
+
filePath: absolutePath,
|
|
77
|
+
created: new Date().toISOString(),
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
if (options.json) {
|
|
81
|
+
console.log(
|
|
82
|
+
JSON.stringify({
|
|
83
|
+
success: true,
|
|
84
|
+
name: containerName,
|
|
85
|
+
filePath: absolutePath,
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
} else {
|
|
89
|
+
console.log(
|
|
90
|
+
uiSuccess(
|
|
91
|
+
`Registered "${basename(absolutePath)}" as "${containerName}"`,
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
console.log()
|
|
95
|
+
console.log(chalk.gray(' Connect with:'))
|
|
96
|
+
console.log(chalk.cyan(` spindb connect ${containerName}`))
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
const e = error as Error
|
|
100
|
+
if (options.json) {
|
|
101
|
+
console.log(JSON.stringify({ success: false, error: e.message }))
|
|
102
|
+
} else {
|
|
103
|
+
console.error(uiError(e.message))
|
|
104
|
+
}
|
|
105
|
+
process.exit(1)
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
)
|
package/cli/commands/create.ts
CHANGED
|
@@ -100,6 +100,12 @@ async function createSqliteContainer(
|
|
|
100
100
|
restoreSpinner.succeed('Backup restored successfully')
|
|
101
101
|
} catch (error) {
|
|
102
102
|
restoreSpinner.fail('Failed to restore backup')
|
|
103
|
+
// Clean up the created container on restore failure
|
|
104
|
+
try {
|
|
105
|
+
await containerManager.delete(containerName, { force: true })
|
|
106
|
+
} catch {
|
|
107
|
+
// Ignore cleanup errors - still throw the original restore error
|
|
108
|
+
}
|
|
103
109
|
throw error
|
|
104
110
|
}
|
|
105
111
|
}
|
|
@@ -282,6 +288,7 @@ export const createCommand = new Command('create')
|
|
|
282
288
|
console.log()
|
|
283
289
|
|
|
284
290
|
const dbEngine = getEngine(engine)
|
|
291
|
+
const isPostgreSQL = engine === Engine.PostgreSQL
|
|
285
292
|
|
|
286
293
|
// SQLite has a simplified flow (no port, no start/stop)
|
|
287
294
|
if (engine === Engine.SQLite) {
|
|
@@ -316,6 +323,60 @@ export const createCommand = new Command('create')
|
|
|
316
323
|
}
|
|
317
324
|
}
|
|
318
325
|
|
|
326
|
+
const portSpinner = createSpinner('Finding available port...')
|
|
327
|
+
portSpinner.start()
|
|
328
|
+
|
|
329
|
+
let port: number
|
|
330
|
+
if (options.port) {
|
|
331
|
+
port = parseInt(options.port, 10)
|
|
332
|
+
const available = await portManager.isPortAvailable(port)
|
|
333
|
+
if (!available) {
|
|
334
|
+
portSpinner.fail(`Port ${port} is already in use`)
|
|
335
|
+
process.exit(1)
|
|
336
|
+
}
|
|
337
|
+
portSpinner.succeed(`Using port ${port}`)
|
|
338
|
+
} else {
|
|
339
|
+
const { port: foundPort, isDefault } =
|
|
340
|
+
await portManager.findAvailablePort({
|
|
341
|
+
preferredPort: engineDefaults.defaultPort,
|
|
342
|
+
portRange: engineDefaults.portRange,
|
|
343
|
+
})
|
|
344
|
+
port = foundPort
|
|
345
|
+
if (isDefault) {
|
|
346
|
+
portSpinner.succeed(`Using default port ${port}`)
|
|
347
|
+
} else {
|
|
348
|
+
portSpinner.warn(
|
|
349
|
+
`Default port ${engineDefaults.defaultPort} is in use, using port ${port}`,
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// For PostgreSQL, download binaries FIRST - they include client tools (psql, pg_dump, etc.)
|
|
355
|
+
// This avoids requiring a separate system installation of client tools
|
|
356
|
+
if (isPostgreSQL) {
|
|
357
|
+
const binarySpinner = createSpinner(
|
|
358
|
+
`Checking ${dbEngine.displayName} ${version} binaries...`,
|
|
359
|
+
)
|
|
360
|
+
binarySpinner.start()
|
|
361
|
+
|
|
362
|
+
const isInstalled = await dbEngine.isBinaryInstalled(version)
|
|
363
|
+
if (isInstalled) {
|
|
364
|
+
binarySpinner.succeed(
|
|
365
|
+
`${dbEngine.displayName} ${version} binaries ready (cached)`,
|
|
366
|
+
)
|
|
367
|
+
} else {
|
|
368
|
+
binarySpinner.text = `Downloading ${dbEngine.displayName} ${version} binaries...`
|
|
369
|
+
await dbEngine.ensureBinaries(version, ({ message }) => {
|
|
370
|
+
binarySpinner.text = message
|
|
371
|
+
})
|
|
372
|
+
binarySpinner.succeed(
|
|
373
|
+
`${dbEngine.displayName} ${version} binaries downloaded`,
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Check dependencies (all engines need this)
|
|
379
|
+
// For PostgreSQL, this runs AFTER binary download so client tools are available
|
|
319
380
|
const depsSpinner = createSpinner('Checking required tools...')
|
|
320
381
|
depsSpinner.start()
|
|
321
382
|
|
|
@@ -350,54 +411,29 @@ export const createCommand = new Command('create')
|
|
|
350
411
|
depsSpinner.succeed('Required tools available')
|
|
351
412
|
}
|
|
352
413
|
|
|
353
|
-
|
|
354
|
-
|
|
414
|
+
// For MySQL (and other non-PostgreSQL server DBs), download binaries after dep check
|
|
415
|
+
if (!isPostgreSQL) {
|
|
416
|
+
const binarySpinner = createSpinner(
|
|
417
|
+
`Checking ${dbEngine.displayName} ${version} binaries...`,
|
|
418
|
+
)
|
|
419
|
+
binarySpinner.start()
|
|
355
420
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
portSpinner.fail(`Port ${port} is already in use`)
|
|
362
|
-
process.exit(1)
|
|
363
|
-
}
|
|
364
|
-
portSpinner.succeed(`Using port ${port}`)
|
|
365
|
-
} else {
|
|
366
|
-
const { port: foundPort, isDefault } =
|
|
367
|
-
await portManager.findAvailablePort({
|
|
368
|
-
preferredPort: engineDefaults.defaultPort,
|
|
369
|
-
portRange: engineDefaults.portRange,
|
|
370
|
-
})
|
|
371
|
-
port = foundPort
|
|
372
|
-
if (isDefault) {
|
|
373
|
-
portSpinner.succeed(`Using default port ${port}`)
|
|
421
|
+
const isInstalled = await dbEngine.isBinaryInstalled(version)
|
|
422
|
+
if (isInstalled) {
|
|
423
|
+
binarySpinner.succeed(
|
|
424
|
+
`${dbEngine.displayName} ${version} binaries ready (cached)`,
|
|
425
|
+
)
|
|
374
426
|
} else {
|
|
375
|
-
|
|
376
|
-
|
|
427
|
+
binarySpinner.text = `Downloading ${dbEngine.displayName} ${version} binaries...`
|
|
428
|
+
await dbEngine.ensureBinaries(version, ({ message }) => {
|
|
429
|
+
binarySpinner.text = message
|
|
430
|
+
})
|
|
431
|
+
binarySpinner.succeed(
|
|
432
|
+
`${dbEngine.displayName} ${version} binaries downloaded`,
|
|
377
433
|
)
|
|
378
434
|
}
|
|
379
435
|
}
|
|
380
436
|
|
|
381
|
-
const binarySpinner = createSpinner(
|
|
382
|
-
`Checking ${dbEngine.displayName} ${version} binaries...`,
|
|
383
|
-
)
|
|
384
|
-
binarySpinner.start()
|
|
385
|
-
|
|
386
|
-
const isInstalled = await dbEngine.isBinaryInstalled(version)
|
|
387
|
-
if (isInstalled) {
|
|
388
|
-
binarySpinner.succeed(
|
|
389
|
-
`${dbEngine.displayName} ${version} binaries ready (cached)`,
|
|
390
|
-
)
|
|
391
|
-
} else {
|
|
392
|
-
binarySpinner.text = `Downloading ${dbEngine.displayName} ${version} binaries...`
|
|
393
|
-
await dbEngine.ensureBinaries(version, ({ message }) => {
|
|
394
|
-
binarySpinner.text = message
|
|
395
|
-
})
|
|
396
|
-
binarySpinner.succeed(
|
|
397
|
-
`${dbEngine.displayName} ${version} binaries downloaded`,
|
|
398
|
-
)
|
|
399
|
-
}
|
|
400
|
-
|
|
401
437
|
while (await containerManager.exists(containerName)) {
|
|
402
438
|
console.log(
|
|
403
439
|
chalk.yellow(` Container "${containerName}" already exists.`),
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import { sqliteRegistry } from '../../engines/sqlite/registry'
|
|
4
|
+
import { containerManager } from '../../core/container-manager'
|
|
5
|
+
import { promptConfirm } from '../ui/prompts'
|
|
6
|
+
import { uiSuccess, uiError, uiWarning } from '../ui/theme'
|
|
7
|
+
import { Engine } from '../../types'
|
|
8
|
+
|
|
9
|
+
export const detachCommand = new Command('detach')
|
|
10
|
+
.description('Unregister a SQLite database from SpinDB (keeps file on disk)')
|
|
11
|
+
.argument('<name>', 'Container name')
|
|
12
|
+
.option('-f, --force', 'Skip confirmation prompt')
|
|
13
|
+
.option('--json', 'Output as JSON')
|
|
14
|
+
.action(
|
|
15
|
+
async (
|
|
16
|
+
name: string,
|
|
17
|
+
options: { force?: boolean; json?: boolean },
|
|
18
|
+
): Promise<void> => {
|
|
19
|
+
try {
|
|
20
|
+
// Get container config
|
|
21
|
+
const config = await containerManager.getConfig(name)
|
|
22
|
+
|
|
23
|
+
if (!config) {
|
|
24
|
+
if (options.json) {
|
|
25
|
+
console.log(
|
|
26
|
+
JSON.stringify({ success: false, error: 'Container not found' }),
|
|
27
|
+
)
|
|
28
|
+
} else {
|
|
29
|
+
console.error(uiError(`Container "${name}" not found`))
|
|
30
|
+
}
|
|
31
|
+
process.exit(1)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Verify it's a SQLite container
|
|
35
|
+
if (config.engine !== Engine.SQLite) {
|
|
36
|
+
if (options.json) {
|
|
37
|
+
console.log(
|
|
38
|
+
JSON.stringify({
|
|
39
|
+
success: false,
|
|
40
|
+
error:
|
|
41
|
+
'Not a SQLite container. Use "spindb delete" for server databases.',
|
|
42
|
+
}),
|
|
43
|
+
)
|
|
44
|
+
} else {
|
|
45
|
+
console.error(uiError(`"${name}" is not a SQLite container`))
|
|
46
|
+
console.log(
|
|
47
|
+
chalk.gray(
|
|
48
|
+
' Use "spindb delete" for server databases (PostgreSQL, MySQL)',
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
process.exit(1)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Confirm unless --force
|
|
56
|
+
if (!options.force && !options.json) {
|
|
57
|
+
const confirmed = await promptConfirm(
|
|
58
|
+
`Detach "${name}" from SpinDB? (file will be kept on disk)`,
|
|
59
|
+
true,
|
|
60
|
+
)
|
|
61
|
+
if (!confirmed) {
|
|
62
|
+
console.log(uiWarning('Cancelled'))
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const entry = await sqliteRegistry.get(name)
|
|
68
|
+
const filePath = entry?.filePath
|
|
69
|
+
|
|
70
|
+
// Remove from registry only (not the file)
|
|
71
|
+
await sqliteRegistry.remove(name)
|
|
72
|
+
|
|
73
|
+
if (options.json) {
|
|
74
|
+
console.log(
|
|
75
|
+
JSON.stringify({
|
|
76
|
+
success: true,
|
|
77
|
+
name,
|
|
78
|
+
filePath,
|
|
79
|
+
}),
|
|
80
|
+
)
|
|
81
|
+
} else {
|
|
82
|
+
console.log(uiSuccess(`Detached "${name}" from SpinDB`))
|
|
83
|
+
if (filePath) {
|
|
84
|
+
console.log(chalk.gray(` File remains at: ${filePath}`))
|
|
85
|
+
}
|
|
86
|
+
console.log()
|
|
87
|
+
console.log(chalk.gray(' Re-attach with:'))
|
|
88
|
+
console.log(chalk.cyan(` spindb attach ${filePath || '<path>'}`))
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const e = error as Error
|
|
92
|
+
if (options.json) {
|
|
93
|
+
console.log(JSON.stringify({ success: false, error: e.message }))
|
|
94
|
+
} else {
|
|
95
|
+
console.error(uiError(e.message))
|
|
96
|
+
}
|
|
97
|
+
process.exit(1)
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
)
|
package/cli/commands/doctor.ts
CHANGED
|
@@ -141,8 +141,9 @@ async function checkContainers(): Promise<HealthCheckResult> {
|
|
|
141
141
|
async function checkSqliteRegistry(): Promise<HealthCheckResult> {
|
|
142
142
|
try {
|
|
143
143
|
const entries = await sqliteRegistry.list()
|
|
144
|
+
const ignoredFolders = await sqliteRegistry.listIgnoredFolders()
|
|
144
145
|
|
|
145
|
-
if (entries.length === 0) {
|
|
146
|
+
if (entries.length === 0 && ignoredFolders.length === 0) {
|
|
146
147
|
return {
|
|
147
148
|
name: 'SQLite Registry',
|
|
148
149
|
status: 'ok',
|
|
@@ -153,11 +154,18 @@ async function checkSqliteRegistry(): Promise<HealthCheckResult> {
|
|
|
153
154
|
const orphans = await sqliteRegistry.findOrphans()
|
|
154
155
|
|
|
155
156
|
if (orphans.length > 0) {
|
|
157
|
+
const details = [
|
|
158
|
+
...orphans.map((o) => `"${o.name}" → ${o.filePath}`),
|
|
159
|
+
...(ignoredFolders.length > 0
|
|
160
|
+
? [`${ignoredFolders.length} folder(s) ignored`]
|
|
161
|
+
: []),
|
|
162
|
+
]
|
|
163
|
+
|
|
156
164
|
return {
|
|
157
165
|
name: 'SQLite Registry',
|
|
158
166
|
status: 'warning',
|
|
159
167
|
message: `${orphans.length} orphaned entr${orphans.length === 1 ? 'y' : 'ies'} found`,
|
|
160
|
-
details
|
|
168
|
+
details,
|
|
161
169
|
action: {
|
|
162
170
|
label: 'Remove orphaned entries from registry',
|
|
163
171
|
handler: async () => {
|
|
@@ -168,10 +176,16 @@ async function checkSqliteRegistry(): Promise<HealthCheckResult> {
|
|
|
168
176
|
}
|
|
169
177
|
}
|
|
170
178
|
|
|
179
|
+
const details = [`${entries.length} database(s) registered, all files exist`]
|
|
180
|
+
if (ignoredFolders.length > 0) {
|
|
181
|
+
details.push(`${ignoredFolders.length} folder(s) ignored`)
|
|
182
|
+
}
|
|
183
|
+
|
|
171
184
|
return {
|
|
172
185
|
name: 'SQLite Registry',
|
|
173
186
|
status: 'ok',
|
|
174
187
|
message: `${entries.length} database(s) registered, all files exist`,
|
|
188
|
+
details: ignoredFolders.length > 0 ? details : undefined,
|
|
175
189
|
}
|
|
176
190
|
} catch (error) {
|
|
177
191
|
return {
|
package/cli/commands/edit.ts
CHANGED
|
@@ -521,6 +521,11 @@ export const editCommand = new Command('edit')
|
|
|
521
521
|
spinner.start()
|
|
522
522
|
|
|
523
523
|
try {
|
|
524
|
+
// Track if we need to delete source file after registry update
|
|
525
|
+
// (for cross-device moves where rename doesn't work)
|
|
526
|
+
let needsSourceCleanup = false
|
|
527
|
+
const originalPath = config.database
|
|
528
|
+
|
|
524
529
|
// Try rename first (fast, same filesystem)
|
|
525
530
|
try {
|
|
526
531
|
renameSync(config.database, newPath)
|
|
@@ -531,8 +536,8 @@ export const editCommand = new Command('edit')
|
|
|
531
536
|
try {
|
|
532
537
|
// Copy file preserving mode/permissions
|
|
533
538
|
copyFileSync(config.database, newPath)
|
|
534
|
-
//
|
|
535
|
-
|
|
539
|
+
// Don't delete source yet - wait for registry update to succeed
|
|
540
|
+
needsSourceCleanup = true
|
|
536
541
|
} catch (copyErr) {
|
|
537
542
|
// Clean up partial target on failure
|
|
538
543
|
if (existsSync(newPath)) {
|
|
@@ -555,6 +560,11 @@ export const editCommand = new Command('edit')
|
|
|
555
560
|
})
|
|
556
561
|
await sqliteRegistry.update(containerName, { filePath: newPath })
|
|
557
562
|
|
|
563
|
+
// Now safe to delete source file for cross-device moves
|
|
564
|
+
if (needsSourceCleanup && existsSync(originalPath)) {
|
|
565
|
+
unlinkSync(originalPath)
|
|
566
|
+
}
|
|
567
|
+
|
|
558
568
|
spinner.succeed(`Database relocated to ${newPath}`)
|
|
559
569
|
} catch (error) {
|
|
560
570
|
spinner.fail('Failed to relocate database')
|
package/cli/commands/engines.ts
CHANGED
|
@@ -3,6 +3,10 @@ import chalk from 'chalk'
|
|
|
3
3
|
import { rm } from 'fs/promises'
|
|
4
4
|
import inquirer from 'inquirer'
|
|
5
5
|
import { containerManager } from '../../core/container-manager'
|
|
6
|
+
import { getEngine } from '../../engines'
|
|
7
|
+
import { binaryManager } from '../../core/binary-manager'
|
|
8
|
+
import { paths } from '../../config/paths'
|
|
9
|
+
import { platformService } from '../../core/platform-service'
|
|
6
10
|
import { promptConfirm } from '../ui/prompts'
|
|
7
11
|
import { createSpinner } from '../ui/spinner'
|
|
8
12
|
import { uiError, uiWarning, uiInfo, formatBytes } from '../ui/theme'
|
|
@@ -14,6 +18,7 @@ import {
|
|
|
14
18
|
type InstalledMysqlEngine,
|
|
15
19
|
type InstalledSqliteEngine,
|
|
16
20
|
} from '../helpers'
|
|
21
|
+
import { Engine } from '../../types'
|
|
17
22
|
|
|
18
23
|
/**
|
|
19
24
|
* Pad string to width, accounting for emoji taking 2 display columns
|
|
@@ -279,3 +284,59 @@ enginesCommand
|
|
|
279
284
|
}
|
|
280
285
|
},
|
|
281
286
|
)
|
|
287
|
+
|
|
288
|
+
// Download subcommand
|
|
289
|
+
enginesCommand
|
|
290
|
+
.command('download <engine> <version>')
|
|
291
|
+
.description('Download engine binaries (PostgreSQL only)')
|
|
292
|
+
.action(async (engineName: string, version: string) => {
|
|
293
|
+
try {
|
|
294
|
+
// Validate engine name
|
|
295
|
+
const validEngines = ['postgresql', 'pg', 'postgres']
|
|
296
|
+
if (!validEngines.includes(engineName.toLowerCase())) {
|
|
297
|
+
console.error(
|
|
298
|
+
uiError(
|
|
299
|
+
`Only PostgreSQL binaries can be downloaded. MySQL and SQLite use system installations.`,
|
|
300
|
+
),
|
|
301
|
+
)
|
|
302
|
+
process.exit(1)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const engine = getEngine(Engine.PostgreSQL)
|
|
306
|
+
|
|
307
|
+
// Check if already installed
|
|
308
|
+
const isInstalled = await engine.isBinaryInstalled(version)
|
|
309
|
+
if (isInstalled) {
|
|
310
|
+
console.log(
|
|
311
|
+
uiInfo(`PostgreSQL ${version} binaries are already installed.`),
|
|
312
|
+
)
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const spinner = createSpinner(
|
|
317
|
+
`Downloading PostgreSQL ${version} binaries...`,
|
|
318
|
+
)
|
|
319
|
+
spinner.start()
|
|
320
|
+
|
|
321
|
+
await engine.ensureBinaries(version, ({ message }) => {
|
|
322
|
+
spinner.text = message
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
spinner.succeed(`PostgreSQL ${version} binaries downloaded`)
|
|
326
|
+
|
|
327
|
+
// Show the path for reference
|
|
328
|
+
const { platform, arch } = platformService.getPlatformInfo()
|
|
329
|
+
const fullVersion = binaryManager.getFullVersion(version)
|
|
330
|
+
const binPath = paths.getBinaryPath({
|
|
331
|
+
engine: 'postgresql',
|
|
332
|
+
version: fullVersion,
|
|
333
|
+
platform,
|
|
334
|
+
arch,
|
|
335
|
+
})
|
|
336
|
+
console.log(chalk.gray(` Location: ${binPath}`))
|
|
337
|
+
} catch (error) {
|
|
338
|
+
const e = error as Error
|
|
339
|
+
console.error(uiError(e.message))
|
|
340
|
+
process.exit(1)
|
|
341
|
+
}
|
|
342
|
+
})
|