spindb 0.3.6 → 0.4.1
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 +62 -8
- package/cli/commands/create.ts +275 -1
- package/cli/commands/deps.ts +326 -0
- package/cli/commands/menu.ts +387 -29
- package/cli/commands/restore.ts +173 -16
- package/cli/index.ts +2 -0
- package/cli/ui/prompts.ts +133 -0
- package/config/os-dependencies.ts +358 -0
- package/config/paths.ts +39 -1
- package/core/dependency-manager.ts +429 -0
- package/core/postgres-binary-manager.ts +44 -28
- package/engines/base-engine.ts +9 -0
- package/engines/postgresql/index.ts +53 -0
- package/package.json +2 -2
- package/types/index.ts +7 -0
package/cli/commands/menu.ts
CHANGED
|
@@ -5,9 +5,11 @@ import { processManager } from '../../core/process-manager'
|
|
|
5
5
|
import { getEngine } from '../../engines'
|
|
6
6
|
import {
|
|
7
7
|
promptContainerSelect,
|
|
8
|
+
promptContainerName,
|
|
8
9
|
promptDatabaseName,
|
|
9
10
|
promptCreateOptions,
|
|
10
11
|
promptConfirm,
|
|
12
|
+
promptInstallDependencies,
|
|
11
13
|
} from '../ui/prompts'
|
|
12
14
|
import { createSpinner } from '../ui/spinner'
|
|
13
15
|
import {
|
|
@@ -21,12 +23,13 @@ import {
|
|
|
21
23
|
import { existsSync } from 'fs'
|
|
22
24
|
import { readdir, rm, lstat } from 'fs/promises'
|
|
23
25
|
import { spawn } from 'child_process'
|
|
24
|
-
import { platform } from 'os'
|
|
26
|
+
import { platform, tmpdir } from 'os'
|
|
25
27
|
import { join } from 'path'
|
|
26
28
|
import { paths } from '../../config/paths'
|
|
27
29
|
import { portManager } from '../../core/port-manager'
|
|
28
30
|
import { defaults } from '../../config/defaults'
|
|
29
31
|
import inquirer from 'inquirer'
|
|
32
|
+
import { getMissingDependencies } from '../../core/dependency-manager'
|
|
30
33
|
|
|
31
34
|
type MenuChoice =
|
|
32
35
|
| {
|
|
@@ -168,7 +171,8 @@ async function showMainMenu(): Promise<void> {
|
|
|
168
171
|
async function handleCreate(): Promise<void> {
|
|
169
172
|
console.log()
|
|
170
173
|
const answers = await promptCreateOptions()
|
|
171
|
-
|
|
174
|
+
let { name: containerName } = answers
|
|
175
|
+
const { engine, version, port, database } = answers
|
|
172
176
|
|
|
173
177
|
console.log()
|
|
174
178
|
console.log(header('Creating Database Container'))
|
|
@@ -176,6 +180,41 @@ async function handleCreate(): Promise<void> {
|
|
|
176
180
|
|
|
177
181
|
const dbEngine = getEngine(engine)
|
|
178
182
|
|
|
183
|
+
// Check for required client tools BEFORE creating anything
|
|
184
|
+
const depsSpinner = createSpinner('Checking required tools...')
|
|
185
|
+
depsSpinner.start()
|
|
186
|
+
|
|
187
|
+
let missingDeps = await getMissingDependencies(engine)
|
|
188
|
+
if (missingDeps.length > 0) {
|
|
189
|
+
depsSpinner.warn(
|
|
190
|
+
`Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
// Offer to install
|
|
194
|
+
const installed = await promptInstallDependencies(
|
|
195
|
+
missingDeps[0].binary,
|
|
196
|
+
engine,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if (!installed) {
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Verify installation worked
|
|
204
|
+
missingDeps = await getMissingDependencies(engine)
|
|
205
|
+
if (missingDeps.length > 0) {
|
|
206
|
+
console.log(
|
|
207
|
+
error(`Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`),
|
|
208
|
+
)
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
console.log(chalk.green(' ✓ All required tools are now available'))
|
|
213
|
+
console.log()
|
|
214
|
+
} else {
|
|
215
|
+
depsSpinner.succeed('Required tools available')
|
|
216
|
+
}
|
|
217
|
+
|
|
179
218
|
// Check if port is currently in use
|
|
180
219
|
const portAvailable = await portManager.isPortAvailable(port)
|
|
181
220
|
|
|
@@ -196,6 +235,12 @@ async function handleCreate(): Promise<void> {
|
|
|
196
235
|
binarySpinner.succeed(`PostgreSQL ${version} binaries downloaded`)
|
|
197
236
|
}
|
|
198
237
|
|
|
238
|
+
// Check if container name already exists and prompt for new name if needed
|
|
239
|
+
while (await containerManager.exists(containerName)) {
|
|
240
|
+
console.log(chalk.yellow(` Container "${containerName}" already exists.`))
|
|
241
|
+
containerName = await promptContainerName()
|
|
242
|
+
}
|
|
243
|
+
|
|
199
244
|
// Create container
|
|
200
245
|
const createSpinnerInstance = createSpinner('Creating container...')
|
|
201
246
|
createSpinnerInstance.start()
|
|
@@ -678,47 +723,330 @@ async function handleConnect(): Promise<void> {
|
|
|
678
723
|
})
|
|
679
724
|
}
|
|
680
725
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
726
|
+
/**
|
|
727
|
+
* Create a new container for the restore flow
|
|
728
|
+
* Returns the container name and config if successful, null if cancelled/error
|
|
729
|
+
*/
|
|
730
|
+
async function handleCreateForRestore(): Promise<{
|
|
731
|
+
name: string
|
|
732
|
+
config: NonNullable<Awaited<ReturnType<typeof containerManager.getConfig>>>
|
|
733
|
+
} | null> {
|
|
734
|
+
console.log()
|
|
735
|
+
const answers = await promptCreateOptions()
|
|
736
|
+
let { name: containerName } = answers
|
|
737
|
+
const { engine, version, port, database } = answers
|
|
684
738
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
739
|
+
console.log()
|
|
740
|
+
console.log(header('Creating Database Container'))
|
|
741
|
+
console.log()
|
|
742
|
+
|
|
743
|
+
const dbEngine = getEngine(engine)
|
|
744
|
+
|
|
745
|
+
// Check if port is currently in use
|
|
746
|
+
const portAvailable = await portManager.isPortAvailable(port)
|
|
747
|
+
if (!portAvailable) {
|
|
748
|
+
console.log(
|
|
749
|
+
error(`Port ${port} is in use. Please choose a different port.`),
|
|
750
|
+
)
|
|
751
|
+
return null
|
|
688
752
|
}
|
|
689
753
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
754
|
+
// Ensure binaries
|
|
755
|
+
const binarySpinner = createSpinner(
|
|
756
|
+
`Checking PostgreSQL ${version} binaries...`,
|
|
693
757
|
)
|
|
694
|
-
|
|
758
|
+
binarySpinner.start()
|
|
759
|
+
|
|
760
|
+
const isInstalled = await dbEngine.isBinaryInstalled(version)
|
|
761
|
+
if (isInstalled) {
|
|
762
|
+
binarySpinner.succeed(`PostgreSQL ${version} binaries ready (cached)`)
|
|
763
|
+
} else {
|
|
764
|
+
binarySpinner.text = `Downloading PostgreSQL ${version} binaries...`
|
|
765
|
+
await dbEngine.ensureBinaries(version, ({ message }) => {
|
|
766
|
+
binarySpinner.text = message
|
|
767
|
+
})
|
|
768
|
+
binarySpinner.succeed(`PostgreSQL ${version} binaries downloaded`)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Check if container name already exists and prompt for new name if needed
|
|
772
|
+
while (await containerManager.exists(containerName)) {
|
|
773
|
+
console.log(chalk.yellow(` Container "${containerName}" already exists.`))
|
|
774
|
+
containerName = await promptContainerName()
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Create container
|
|
778
|
+
const createSpinnerInstance = createSpinner('Creating container...')
|
|
779
|
+
createSpinnerInstance.start()
|
|
780
|
+
|
|
781
|
+
await containerManager.create(containerName, {
|
|
782
|
+
engine: dbEngine.name,
|
|
783
|
+
version,
|
|
784
|
+
port,
|
|
785
|
+
database,
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
createSpinnerInstance.succeed('Container created')
|
|
789
|
+
|
|
790
|
+
// Initialize database cluster
|
|
791
|
+
const initSpinner = createSpinner('Initializing database cluster...')
|
|
792
|
+
initSpinner.start()
|
|
793
|
+
|
|
794
|
+
await dbEngine.initDataDir(containerName, version, {
|
|
795
|
+
superuser: defaults.superuser,
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
initSpinner.succeed('Database cluster initialized')
|
|
799
|
+
|
|
800
|
+
// Start container
|
|
801
|
+
const startSpinner = createSpinner('Starting PostgreSQL...')
|
|
802
|
+
startSpinner.start()
|
|
695
803
|
|
|
696
804
|
const config = await containerManager.getConfig(containerName)
|
|
697
805
|
if (!config) {
|
|
698
|
-
|
|
699
|
-
return
|
|
806
|
+
startSpinner.fail('Failed to get container config')
|
|
807
|
+
return null
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
await dbEngine.start(config)
|
|
811
|
+
await containerManager.updateConfig(containerName, { status: 'running' })
|
|
812
|
+
|
|
813
|
+
startSpinner.succeed('PostgreSQL started')
|
|
814
|
+
|
|
815
|
+
// Create the user's database (if different from 'postgres')
|
|
816
|
+
if (database !== 'postgres') {
|
|
817
|
+
const dbSpinner = createSpinner(`Creating database "${database}"...`)
|
|
818
|
+
dbSpinner.start()
|
|
819
|
+
|
|
820
|
+
await dbEngine.createDatabase(config, database)
|
|
821
|
+
|
|
822
|
+
dbSpinner.succeed(`Database "${database}" created`)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
console.log()
|
|
826
|
+
console.log(success('Container ready for restore'))
|
|
827
|
+
console.log()
|
|
828
|
+
|
|
829
|
+
return { name: containerName, config }
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async function handleRestore(): Promise<void> {
|
|
833
|
+
const containers = await containerManager.list()
|
|
834
|
+
const running = containers.filter((c) => c.status === 'running')
|
|
835
|
+
|
|
836
|
+
// Build choices: running containers + create new option
|
|
837
|
+
const choices = [
|
|
838
|
+
...running.map((c) => ({
|
|
839
|
+
name: `${c.name} ${chalk.gray(`(${c.engine} ${c.version}, port ${c.port})`)} ${chalk.green('● running')}`,
|
|
840
|
+
value: c.name,
|
|
841
|
+
short: c.name,
|
|
842
|
+
})),
|
|
843
|
+
new inquirer.Separator(),
|
|
844
|
+
{
|
|
845
|
+
name: `${chalk.green('➕')} Create new container`,
|
|
846
|
+
value: '__create_new__',
|
|
847
|
+
short: 'Create new',
|
|
848
|
+
},
|
|
849
|
+
]
|
|
850
|
+
|
|
851
|
+
const { selectedContainer } = await inquirer.prompt<{
|
|
852
|
+
selectedContainer: string
|
|
853
|
+
}>([
|
|
854
|
+
{
|
|
855
|
+
type: 'list',
|
|
856
|
+
name: 'selectedContainer',
|
|
857
|
+
message: 'Select container to restore to:',
|
|
858
|
+
choices,
|
|
859
|
+
},
|
|
860
|
+
])
|
|
861
|
+
|
|
862
|
+
let containerName: string
|
|
863
|
+
let config: Awaited<ReturnType<typeof containerManager.getConfig>>
|
|
864
|
+
|
|
865
|
+
if (selectedContainer === '__create_new__') {
|
|
866
|
+
// Run the create flow first
|
|
867
|
+
const createResult = await handleCreateForRestore()
|
|
868
|
+
if (!createResult) return // User cancelled or error
|
|
869
|
+
containerName = createResult.name
|
|
870
|
+
config = createResult.config
|
|
871
|
+
} else {
|
|
872
|
+
containerName = selectedContainer
|
|
873
|
+
config = await containerManager.getConfig(containerName)
|
|
874
|
+
if (!config) {
|
|
875
|
+
console.error(error(`Container "${containerName}" not found`))
|
|
876
|
+
return
|
|
877
|
+
}
|
|
700
878
|
}
|
|
701
879
|
|
|
702
|
-
//
|
|
703
|
-
|
|
704
|
-
|
|
880
|
+
// Check for required client tools BEFORE doing anything
|
|
881
|
+
const depsSpinner = createSpinner('Checking required tools...')
|
|
882
|
+
depsSpinner.start()
|
|
705
883
|
|
|
706
|
-
|
|
707
|
-
|
|
884
|
+
let missingDeps = await getMissingDependencies(config.engine)
|
|
885
|
+
if (missingDeps.length > 0) {
|
|
886
|
+
depsSpinner.warn(
|
|
887
|
+
`Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
// Offer to install
|
|
891
|
+
const installed = await promptInstallDependencies(
|
|
892
|
+
missingDeps[0].binary,
|
|
893
|
+
config.engine,
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
if (!installed) {
|
|
897
|
+
return
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Verify installation worked
|
|
901
|
+
missingDeps = await getMissingDependencies(config.engine)
|
|
902
|
+
if (missingDeps.length > 0) {
|
|
903
|
+
console.log(
|
|
904
|
+
error(`Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`),
|
|
905
|
+
)
|
|
906
|
+
return
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
console.log(chalk.green(' ✓ All required tools are now available'))
|
|
910
|
+
console.log()
|
|
911
|
+
} else {
|
|
912
|
+
depsSpinner.succeed('Required tools available')
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Ask for restore source
|
|
916
|
+
const { restoreSource } = await inquirer.prompt<{
|
|
917
|
+
restoreSource: 'file' | 'connection'
|
|
708
918
|
}>([
|
|
709
919
|
{
|
|
710
|
-
type: '
|
|
711
|
-
name: '
|
|
712
|
-
message: '
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
920
|
+
type: 'list',
|
|
921
|
+
name: 'restoreSource',
|
|
922
|
+
message: 'Restore from:',
|
|
923
|
+
choices: [
|
|
924
|
+
{
|
|
925
|
+
name: `${chalk.magenta('📁')} Dump file (drag and drop or enter path)`,
|
|
926
|
+
value: 'file',
|
|
927
|
+
},
|
|
928
|
+
{
|
|
929
|
+
name: `${chalk.cyan('🔗')} Connection string (pull from remote database)`,
|
|
930
|
+
value: 'connection',
|
|
931
|
+
},
|
|
932
|
+
],
|
|
719
933
|
},
|
|
720
934
|
])
|
|
721
|
-
|
|
935
|
+
|
|
936
|
+
let backupPath = ''
|
|
937
|
+
let isTempFile = false
|
|
938
|
+
|
|
939
|
+
if (restoreSource === 'connection') {
|
|
940
|
+
// Get connection string and create dump
|
|
941
|
+
const { connectionString } = await inquirer.prompt<{
|
|
942
|
+
connectionString: string
|
|
943
|
+
}>([
|
|
944
|
+
{
|
|
945
|
+
type: 'input',
|
|
946
|
+
name: 'connectionString',
|
|
947
|
+
message: 'Connection string (postgresql://user:pass@host:port/dbname):',
|
|
948
|
+
validate: (input: string) => {
|
|
949
|
+
if (!input) return 'Connection string is required'
|
|
950
|
+
if (
|
|
951
|
+
!input.startsWith('postgresql://') &&
|
|
952
|
+
!input.startsWith('postgres://')
|
|
953
|
+
) {
|
|
954
|
+
return 'Connection string must start with postgresql:// or postgres://'
|
|
955
|
+
}
|
|
956
|
+
return true
|
|
957
|
+
},
|
|
958
|
+
},
|
|
959
|
+
])
|
|
960
|
+
|
|
961
|
+
const engine = getEngine(config.engine)
|
|
962
|
+
|
|
963
|
+
// Create temp file for the dump
|
|
964
|
+
const timestamp = Date.now()
|
|
965
|
+
const tempDumpPath = join(tmpdir(), `spindb-dump-${timestamp}.dump`)
|
|
966
|
+
|
|
967
|
+
let dumpSuccess = false
|
|
968
|
+
let attempts = 0
|
|
969
|
+
const maxAttempts = 2 // Allow one retry after installing deps
|
|
970
|
+
|
|
971
|
+
while (!dumpSuccess && attempts < maxAttempts) {
|
|
972
|
+
attempts++
|
|
973
|
+
const dumpSpinner = createSpinner('Creating dump from remote database...')
|
|
974
|
+
dumpSpinner.start()
|
|
975
|
+
|
|
976
|
+
try {
|
|
977
|
+
await engine.dumpFromConnectionString(connectionString, tempDumpPath)
|
|
978
|
+
dumpSpinner.succeed('Dump created from remote database')
|
|
979
|
+
backupPath = tempDumpPath
|
|
980
|
+
isTempFile = true
|
|
981
|
+
dumpSuccess = true
|
|
982
|
+
} catch (err) {
|
|
983
|
+
const e = err as Error
|
|
984
|
+
dumpSpinner.fail('Failed to create dump')
|
|
985
|
+
|
|
986
|
+
// Check if this is a missing tool error
|
|
987
|
+
if (
|
|
988
|
+
e.message.includes('pg_dump not found') ||
|
|
989
|
+
e.message.includes('ENOENT')
|
|
990
|
+
) {
|
|
991
|
+
const installed = await promptInstallDependencies('pg_dump')
|
|
992
|
+
if (installed) {
|
|
993
|
+
// Loop will retry
|
|
994
|
+
continue
|
|
995
|
+
}
|
|
996
|
+
} else {
|
|
997
|
+
console.log()
|
|
998
|
+
console.log(error('pg_dump error:'))
|
|
999
|
+
console.log(chalk.gray(` ${e.message}`))
|
|
1000
|
+
console.log()
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// Clean up temp file if it was created
|
|
1004
|
+
try {
|
|
1005
|
+
await rm(tempDumpPath, { force: true })
|
|
1006
|
+
} catch {
|
|
1007
|
+
// Ignore cleanup errors
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Wait for user to see the error
|
|
1011
|
+
await inquirer.prompt([
|
|
1012
|
+
{
|
|
1013
|
+
type: 'input',
|
|
1014
|
+
name: 'continue',
|
|
1015
|
+
message: chalk.gray('Press Enter to continue...'),
|
|
1016
|
+
},
|
|
1017
|
+
])
|
|
1018
|
+
return
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Safety check - should never reach here without backupPath set
|
|
1023
|
+
if (!dumpSuccess) {
|
|
1024
|
+
console.log(error('Failed to create dump after retries'))
|
|
1025
|
+
return
|
|
1026
|
+
}
|
|
1027
|
+
} else {
|
|
1028
|
+
// Get backup file path
|
|
1029
|
+
// Strip quotes that terminals add when drag-and-dropping files
|
|
1030
|
+
const stripQuotes = (path: string) =>
|
|
1031
|
+
path.replace(/^['"]|['"]$/g, '').trim()
|
|
1032
|
+
|
|
1033
|
+
const { backupPath: rawBackupPath } = await inquirer.prompt<{
|
|
1034
|
+
backupPath: string
|
|
1035
|
+
}>([
|
|
1036
|
+
{
|
|
1037
|
+
type: 'input',
|
|
1038
|
+
name: 'backupPath',
|
|
1039
|
+
message: 'Path to backup file (drag and drop or enter path):',
|
|
1040
|
+
validate: (input: string) => {
|
|
1041
|
+
if (!input) return 'Backup path is required'
|
|
1042
|
+
const cleanPath = stripQuotes(input)
|
|
1043
|
+
if (!existsSync(cleanPath)) return 'File not found'
|
|
1044
|
+
return true
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1047
|
+
])
|
|
1048
|
+
backupPath = stripQuotes(rawBackupPath)
|
|
1049
|
+
}
|
|
722
1050
|
|
|
723
1051
|
const databaseName = await promptDatabaseName(containerName)
|
|
724
1052
|
|
|
@@ -936,6 +1264,15 @@ async function handleRestore(): Promise<void> {
|
|
|
936
1264
|
console.log()
|
|
937
1265
|
}
|
|
938
1266
|
|
|
1267
|
+
// Clean up temp file if we created one
|
|
1268
|
+
if (isTempFile) {
|
|
1269
|
+
try {
|
|
1270
|
+
await rm(backupPath, { force: true })
|
|
1271
|
+
} catch {
|
|
1272
|
+
// Ignore cleanup errors
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
939
1276
|
// Wait for user to see the result before returning to menu
|
|
940
1277
|
await inquirer.prompt([
|
|
941
1278
|
{
|
|
@@ -1482,6 +1819,27 @@ export const menuCommand = new Command('menu')
|
|
|
1482
1819
|
await showMainMenu()
|
|
1483
1820
|
} catch (err) {
|
|
1484
1821
|
const e = err as Error
|
|
1822
|
+
|
|
1823
|
+
// Check if this is a missing tool error
|
|
1824
|
+
if (
|
|
1825
|
+
e.message.includes('pg_restore not found') ||
|
|
1826
|
+
e.message.includes('psql not found') ||
|
|
1827
|
+
e.message.includes('pg_dump not found')
|
|
1828
|
+
) {
|
|
1829
|
+
const missingTool = e.message.includes('pg_restore')
|
|
1830
|
+
? 'pg_restore'
|
|
1831
|
+
: e.message.includes('pg_dump')
|
|
1832
|
+
? 'pg_dump'
|
|
1833
|
+
: 'psql'
|
|
1834
|
+
const installed = await promptInstallDependencies(missingTool)
|
|
1835
|
+
if (installed) {
|
|
1836
|
+
console.log(
|
|
1837
|
+
chalk.yellow(' Please re-run spindb to continue.'),
|
|
1838
|
+
)
|
|
1839
|
+
}
|
|
1840
|
+
process.exit(1)
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1485
1843
|
console.error(error(e.message))
|
|
1486
1844
|
process.exit(1)
|
|
1487
1845
|
}
|