spindb 0.4.0 → 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.
@@ -9,6 +9,7 @@ import { defaults } from '../../config/defaults'
9
9
  import {
10
10
  promptCreateOptions,
11
11
  promptInstallDependencies,
12
+ promptContainerName,
12
13
  } from '../ui/prompts'
13
14
  import { createSpinner } from '../ui/spinner'
14
15
  import { header, error, connectionBox } from '../ui/theme'
@@ -16,6 +17,7 @@ import { tmpdir } from 'os'
16
17
  import { join } from 'path'
17
18
  import { spawn } from 'child_process'
18
19
  import { platform } from 'os'
20
+ import { getMissingDependencies } from '../../core/dependency-manager'
19
21
 
20
22
  /**
21
23
  * Detect if a location string is a connection string or a file path
@@ -124,6 +126,43 @@ export const createCommand = new Command('create')
124
126
  // Get the engine
125
127
  const dbEngine = getEngine(engine)
126
128
 
129
+ // Check for required client tools BEFORE creating anything
130
+ const depsSpinner = createSpinner('Checking required tools...')
131
+ depsSpinner.start()
132
+
133
+ let missingDeps = await getMissingDependencies(engine)
134
+ if (missingDeps.length > 0) {
135
+ depsSpinner.warn(
136
+ `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
137
+ )
138
+
139
+ // Offer to install
140
+ const installed = await promptInstallDependencies(
141
+ missingDeps[0].binary,
142
+ engine,
143
+ )
144
+
145
+ if (!installed) {
146
+ process.exit(1)
147
+ }
148
+
149
+ // Verify installation worked
150
+ missingDeps = await getMissingDependencies(engine)
151
+ if (missingDeps.length > 0) {
152
+ console.error(
153
+ error(
154
+ `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
155
+ ),
156
+ )
157
+ process.exit(1)
158
+ }
159
+
160
+ console.log(chalk.green(' ✓ All required tools are now available'))
161
+ console.log()
162
+ } else {
163
+ depsSpinner.succeed('Required tools available')
164
+ }
165
+
127
166
  // Find available port
128
167
  const portSpinner = createSpinner('Finding available port...')
129
168
  portSpinner.start()
@@ -165,6 +204,14 @@ export const createCommand = new Command('create')
165
204
  binarySpinner.succeed(`PostgreSQL ${version} binaries downloaded`)
166
205
  }
167
206
 
207
+ // Check if container name already exists and prompt for new name if needed
208
+ while (await containerManager.exists(containerName)) {
209
+ console.log(
210
+ chalk.yellow(` Container "${containerName}" already exists.`),
211
+ )
212
+ containerName = await promptContainerName()
213
+ }
214
+
168
215
  // Create container
169
216
  const createSpinnerInstance = createSpinner('Creating container...')
170
217
  createSpinnerInstance.start()
@@ -217,41 +264,59 @@ export const createCommand = new Command('create')
217
264
 
218
265
  // Handle --from restore if specified
219
266
  if (restoreLocation && restoreType && config) {
220
- let backupPath: string
267
+ let backupPath = ''
221
268
 
222
269
  if (restoreType === 'connection') {
223
270
  // Create dump from remote database
224
271
  const timestamp = Date.now()
225
272
  tempDumpPath = join(tmpdir(), `spindb-dump-${timestamp}.dump`)
226
273
 
227
- const dumpSpinner = createSpinner(
228
- 'Creating dump from remote database...',
229
- )
230
- dumpSpinner.start()
274
+ let dumpSuccess = false
275
+ let attempts = 0
276
+ const maxAttempts = 2 // Allow one retry after installing deps
231
277
 
232
- try {
233
- await dbEngine.dumpFromConnectionString(
234
- restoreLocation,
235
- tempDumpPath,
278
+ while (!dumpSuccess && attempts < maxAttempts) {
279
+ attempts++
280
+ const dumpSpinner = createSpinner(
281
+ 'Creating dump from remote database...',
236
282
  )
237
- dumpSpinner.succeed('Dump created from remote database')
238
- backupPath = tempDumpPath
239
- } catch (err) {
240
- const e = err as Error
241
- dumpSpinner.fail('Failed to create dump')
242
-
243
- // Check if this is a missing tool error
244
- if (
245
- e.message.includes('pg_dump not found') ||
246
- e.message.includes('ENOENT')
247
- ) {
248
- await promptInstallDependencies('pg_dump')
283
+ dumpSpinner.start()
284
+
285
+ try {
286
+ await dbEngine.dumpFromConnectionString(
287
+ restoreLocation,
288
+ tempDumpPath,
289
+ )
290
+ dumpSpinner.succeed('Dump created from remote database')
291
+ backupPath = tempDumpPath
292
+ dumpSuccess = true
293
+ } catch (err) {
294
+ const e = err as Error
295
+ dumpSpinner.fail('Failed to create dump')
296
+
297
+ // Check if this is a missing tool error
298
+ if (
299
+ e.message.includes('pg_dump not found') ||
300
+ e.message.includes('ENOENT')
301
+ ) {
302
+ const installed = await promptInstallDependencies('pg_dump')
303
+ if (!installed) {
304
+ process.exit(1)
305
+ }
306
+ // Loop will retry
307
+ continue
308
+ }
309
+
310
+ console.log()
311
+ console.error(error('pg_dump error:'))
312
+ console.log(chalk.gray(` ${e.message}`))
249
313
  process.exit(1)
250
314
  }
315
+ }
251
316
 
252
- console.log()
253
- console.error(error('pg_dump error:'))
254
- console.log(chalk.gray(` ${e.message}`))
317
+ // Safety check - should never reach here without backupPath set
318
+ if (!dumpSuccess) {
319
+ console.error(error('Failed to create dump after retries'))
255
320
  process.exit(1)
256
321
  }
257
322
  } else {
@@ -350,7 +415,14 @@ export const createCommand = new Command('create')
350
415
  : e.message.includes('pg_dump')
351
416
  ? 'pg_dump'
352
417
  : 'psql'
353
- await promptInstallDependencies(missingTool)
418
+ const installed = await promptInstallDependencies(missingTool)
419
+ if (installed) {
420
+ console.log(
421
+ chalk.yellow(
422
+ ' Please re-run your command to continue.',
423
+ ),
424
+ )
425
+ }
354
426
  process.exit(1)
355
427
  }
356
428
 
@@ -5,6 +5,7 @@ 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,
@@ -28,6 +29,7 @@ import { paths } from '../../config/paths'
28
29
  import { portManager } from '../../core/port-manager'
29
30
  import { defaults } from '../../config/defaults'
30
31
  import inquirer from 'inquirer'
32
+ import { getMissingDependencies } from '../../core/dependency-manager'
31
33
 
32
34
  type MenuChoice =
33
35
  | {
@@ -169,7 +171,8 @@ async function showMainMenu(): Promise<void> {
169
171
  async function handleCreate(): Promise<void> {
170
172
  console.log()
171
173
  const answers = await promptCreateOptions()
172
- const { name: containerName, engine, version, port, database } = answers
174
+ let { name: containerName } = answers
175
+ const { engine, version, port, database } = answers
173
176
 
174
177
  console.log()
175
178
  console.log(header('Creating Database Container'))
@@ -177,6 +180,41 @@ async function handleCreate(): Promise<void> {
177
180
 
178
181
  const dbEngine = getEngine(engine)
179
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
+
180
218
  // Check if port is currently in use
181
219
  const portAvailable = await portManager.isPortAvailable(port)
182
220
 
@@ -197,6 +235,12 @@ async function handleCreate(): Promise<void> {
197
235
  binarySpinner.succeed(`PostgreSQL ${version} binaries downloaded`)
198
236
  }
199
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
+
200
244
  // Create container
201
245
  const createSpinnerInstance = createSpinner('Creating container...')
202
246
  createSpinnerInstance.start()
@@ -689,7 +733,8 @@ async function handleCreateForRestore(): Promise<{
689
733
  } | null> {
690
734
  console.log()
691
735
  const answers = await promptCreateOptions()
692
- const { name: containerName, engine, version, port, database } = answers
736
+ let { name: containerName } = answers
737
+ const { engine, version, port, database } = answers
693
738
 
694
739
  console.log()
695
740
  console.log(header('Creating Database Container'))
@@ -723,6 +768,12 @@ async function handleCreateForRestore(): Promise<{
723
768
  binarySpinner.succeed(`PostgreSQL ${version} binaries downloaded`)
724
769
  }
725
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
+
726
777
  // Create container
727
778
  const createSpinnerInstance = createSpinner('Creating container...')
728
779
  createSpinnerInstance.start()
@@ -826,6 +877,41 @@ async function handleRestore(): Promise<void> {
826
877
  }
827
878
  }
828
879
 
880
+ // Check for required client tools BEFORE doing anything
881
+ const depsSpinner = createSpinner('Checking required tools...')
882
+ depsSpinner.start()
883
+
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
+
829
915
  // Ask for restore source
830
916
  const { restoreSource } = await inquirer.prompt<{
831
917
  restoreSource: 'file' | 'connection'
@@ -847,7 +933,7 @@ async function handleRestore(): Promise<void> {
847
933
  },
848
934
  ])
849
935
 
850
- let backupPath: string
936
+ let backupPath = ''
851
937
  let isTempFile = false
852
938
 
853
939
  if (restoreSource === 'connection') {
@@ -878,46 +964,64 @@ async function handleRestore(): Promise<void> {
878
964
  const timestamp = Date.now()
879
965
  const tempDumpPath = join(tmpdir(), `spindb-dump-${timestamp}.dump`)
880
966
 
881
- const dumpSpinner = createSpinner('Creating dump from remote database...')
882
- dumpSpinner.start()
967
+ let dumpSuccess = false
968
+ let attempts = 0
969
+ const maxAttempts = 2 // Allow one retry after installing deps
883
970
 
884
- try {
885
- await engine.dumpFromConnectionString(connectionString, tempDumpPath)
886
- dumpSpinner.succeed('Dump created from remote database')
887
- backupPath = tempDumpPath
888
- isTempFile = true
889
- } catch (err) {
890
- const e = err as Error
891
- dumpSpinner.fail('Failed to create dump')
971
+ while (!dumpSuccess && attempts < maxAttempts) {
972
+ attempts++
973
+ const dumpSpinner = createSpinner('Creating dump from remote database...')
974
+ dumpSpinner.start()
892
975
 
893
- // Clean up temp file if it was created
894
976
  try {
895
- await rm(tempDumpPath, { force: true })
896
- } catch {
897
- // Ignore cleanup errors
898
- }
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
+ }
899
1002
 
900
- // Check if this is a missing tool error
901
- if (
902
- e.message.includes('pg_dump not found') ||
903
- e.message.includes('ENOENT')
904
- ) {
905
- await promptInstallDependencies('pg_dump')
906
- } else {
907
- console.log()
908
- console.log(error('pg_dump error:'))
909
- console.log(chalk.gray(` ${e.message}`))
910
- console.log()
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
911
1019
  }
1020
+ }
912
1021
 
913
- // Wait for user to see the error
914
- await inquirer.prompt([
915
- {
916
- type: 'input',
917
- name: 'continue',
918
- message: chalk.gray('Press Enter to continue...'),
919
- },
920
- ])
1022
+ // Safety check - should never reach here without backupPath set
1023
+ if (!dumpSuccess) {
1024
+ console.log(error('Failed to create dump after retries'))
921
1025
  return
922
1026
  }
923
1027
  } else {
@@ -1727,7 +1831,12 @@ export const menuCommand = new Command('menu')
1727
1831
  : e.message.includes('pg_dump')
1728
1832
  ? 'pg_dump'
1729
1833
  : 'psql'
1730
- await promptInstallDependencies(missingTool)
1834
+ const installed = await promptInstallDependencies(missingTool)
1835
+ if (installed) {
1836
+ console.log(
1837
+ chalk.yellow(' Please re-run spindb to continue.'),
1838
+ )
1839
+ }
1731
1840
  process.exit(1)
1732
1841
  }
1733
1842
 
@@ -15,6 +15,7 @@ import { success, error, warning } from '../ui/theme'
15
15
  import { platform, tmpdir } from 'os'
16
16
  import { spawn } from 'child_process'
17
17
  import { join } from 'path'
18
+ import { getMissingDependencies } from '../../core/dependency-manager'
18
19
 
19
20
  export const restoreCommand = new Command('restore')
20
21
  .description('Restore a backup to a container')
@@ -89,6 +90,43 @@ export const restoreCommand = new Command('restore')
89
90
  // Get engine
90
91
  const engine = getEngine(config.engine)
91
92
 
93
+ // Check for required client tools BEFORE doing anything
94
+ const depsSpinner = createSpinner('Checking required tools...')
95
+ depsSpinner.start()
96
+
97
+ let missingDeps = await getMissingDependencies(config.engine)
98
+ if (missingDeps.length > 0) {
99
+ depsSpinner.warn(
100
+ `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
101
+ )
102
+
103
+ // Offer to install
104
+ const installed = await promptInstallDependencies(
105
+ missingDeps[0].binary,
106
+ config.engine,
107
+ )
108
+
109
+ if (!installed) {
110
+ process.exit(1)
111
+ }
112
+
113
+ // Verify installation worked
114
+ missingDeps = await getMissingDependencies(config.engine)
115
+ if (missingDeps.length > 0) {
116
+ console.error(
117
+ error(
118
+ `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
119
+ ),
120
+ )
121
+ process.exit(1)
122
+ }
123
+
124
+ console.log(chalk.green(' ✓ All required tools are now available'))
125
+ console.log()
126
+ } else {
127
+ depsSpinner.succeed('Required tools available')
128
+ }
129
+
92
130
  // Handle --from-url option
93
131
  if (options.fromUrl) {
94
132
  // Validate connection string
@@ -108,31 +146,49 @@ export const restoreCommand = new Command('restore')
108
146
  const timestamp = Date.now()
109
147
  tempDumpPath = join(tmpdir(), `spindb-dump-${timestamp}.dump`)
110
148
 
111
- const dumpSpinner = createSpinner(
112
- 'Creating dump from remote database...',
113
- )
114
- dumpSpinner.start()
149
+ let dumpSuccess = false
150
+ let attempts = 0
151
+ const maxAttempts = 2 // Allow one retry after installing deps
115
152
 
116
- try {
117
- await engine.dumpFromConnectionString(options.fromUrl, tempDumpPath)
118
- dumpSpinner.succeed('Dump created from remote database')
119
- backupPath = tempDumpPath
120
- } catch (err) {
121
- const e = err as Error
122
- dumpSpinner.fail('Failed to create dump')
123
-
124
- // Check if this is a missing tool error
125
- if (
126
- e.message.includes('pg_dump not found') ||
127
- e.message.includes('ENOENT')
128
- ) {
129
- await promptInstallDependencies('pg_dump')
153
+ while (!dumpSuccess && attempts < maxAttempts) {
154
+ attempts++
155
+ const dumpSpinner = createSpinner(
156
+ 'Creating dump from remote database...',
157
+ )
158
+ dumpSpinner.start()
159
+
160
+ try {
161
+ await engine.dumpFromConnectionString(options.fromUrl, tempDumpPath)
162
+ dumpSpinner.succeed('Dump created from remote database')
163
+ backupPath = tempDumpPath
164
+ dumpSuccess = true
165
+ } catch (err) {
166
+ const e = err as Error
167
+ dumpSpinner.fail('Failed to create dump')
168
+
169
+ // Check if this is a missing tool error
170
+ if (
171
+ e.message.includes('pg_dump not found') ||
172
+ e.message.includes('ENOENT')
173
+ ) {
174
+ const installed = await promptInstallDependencies('pg_dump')
175
+ if (!installed) {
176
+ process.exit(1)
177
+ }
178
+ // Loop will retry
179
+ continue
180
+ }
181
+
182
+ console.log()
183
+ console.error(error('pg_dump error:'))
184
+ console.log(chalk.gray(` ${e.message}`))
130
185
  process.exit(1)
131
186
  }
187
+ }
132
188
 
133
- console.log()
134
- console.error(error('pg_dump error:'))
135
- console.log(chalk.gray(` ${e.message}`))
189
+ // Safety check - should never reach here without backupPath set
190
+ if (!dumpSuccess) {
191
+ console.error(error('Failed to create dump after retries'))
136
192
  process.exit(1)
137
193
  }
138
194
  } else {
@@ -162,6 +218,12 @@ export const restoreCommand = new Command('restore')
162
218
  databaseName = await promptDatabaseName(containerName)
163
219
  }
164
220
 
221
+ // At this point backupPath is guaranteed to be set
222
+ if (!backupPath) {
223
+ console.error(error('No backup path specified'))
224
+ process.exit(1)
225
+ }
226
+
165
227
  // Detect backup format
166
228
  const detectSpinner = createSpinner('Detecting backup format...')
167
229
  detectSpinner.start()
@@ -259,7 +321,12 @@ export const restoreCommand = new Command('restore')
259
321
  const missingTool = e.message.includes('pg_restore')
260
322
  ? 'pg_restore'
261
323
  : 'psql'
262
- await promptInstallDependencies(missingTool)
324
+ const installed = await promptInstallDependencies(missingTool)
325
+ if (installed) {
326
+ console.log(
327
+ chalk.yellow(' Please re-run your command to continue.'),
328
+ )
329
+ }
263
330
  process.exit(1)
264
331
  }
265
332
 
package/cli/ui/prompts.ts CHANGED
@@ -403,7 +403,7 @@ export async function promptInstallDependencies(
403
403
  console.log(
404
404
  chalk.green(` ${engineName} client tools installed successfully!`),
405
405
  )
406
- console.log(chalk.gray(' Please try your operation again.'))
406
+ console.log(chalk.gray(' Continuing with your operation...'))
407
407
  console.log()
408
408
  }
409
409
 
package/config/paths.ts CHANGED
@@ -1,7 +1,45 @@
1
1
  import { homedir } from 'os'
2
2
  import { join } from 'path'
3
+ import { execSync } from 'child_process'
3
4
 
4
- const SPINDB_HOME = join(homedir(), '.spindb')
5
+ /**
6
+ * Get the real user's home directory, even when running under sudo.
7
+ * When a user runs `sudo spindb`, we want to use their home directory,
8
+ * not root's home directory.
9
+ */
10
+ function getRealHomeDir(): string {
11
+ // Check if running under sudo
12
+ const sudoUser = process.env.SUDO_USER
13
+
14
+ if (sudoUser) {
15
+ // Get the original user's home directory
16
+ try {
17
+ // Use getent to reliably get the home directory for the sudo user
18
+ const result = execSync(`getent passwd ${sudoUser}`, {
19
+ encoding: 'utf-8',
20
+ })
21
+ const parts = result.trim().split(':')
22
+ if (parts.length >= 6 && parts[5]) {
23
+ return parts[5]
24
+ }
25
+ } catch {
26
+ // Fall back to constructing the path
27
+ // On most Linux systems, home dirs are /home/username
28
+ // On macOS, they're /Users/username
29
+ const platform = process.platform
30
+ if (platform === 'darwin') {
31
+ return `/Users/${sudoUser}`
32
+ } else {
33
+ return `/home/${sudoUser}`
34
+ }
35
+ }
36
+ }
37
+
38
+ // Not running under sudo, use normal homedir
39
+ return homedir()
40
+ }
41
+
42
+ const SPINDB_HOME = join(getRealHomeDir(), '.spindb')
5
43
 
6
44
  export const paths = {
7
45
  // Root directory for all spindb data
@@ -5,7 +5,7 @@
5
5
  * for database engines.
6
6
  */
7
7
 
8
- import { exec } from 'child_process'
8
+ import { exec, spawnSync } from 'child_process'
9
9
  import { promisify } from 'util'
10
10
  import {
11
11
  type PackageManagerId,
@@ -173,30 +173,51 @@ export async function getAllMissingDependencies(): Promise<Dependency[]> {
173
173
  // =============================================================================
174
174
 
175
175
  /**
176
- * Execute command with timeout
176
+ * Check if stdin is a TTY (interactive terminal)
177
177
  */
178
- async function execWithTimeout(
179
- command: string,
180
- timeoutMs: number = 120000,
181
- ): Promise<{ stdout: string; stderr: string }> {
182
- return new Promise((resolve, reject) => {
183
- const child = exec(
184
- command,
185
- { timeout: timeoutMs },
186
- (error, stdout, stderr) => {
187
- if (error) {
188
- reject(error)
189
- } else {
190
- resolve({ stdout, stderr })
191
- }
192
- },
178
+ function hasTTY(): boolean {
179
+ return process.stdin.isTTY === true
180
+ }
181
+
182
+ /**
183
+ * Check if running as root
184
+ */
185
+ function isRoot(): boolean {
186
+ return process.getuid?.() === 0
187
+ }
188
+
189
+ /**
190
+ * Execute command with inherited stdio (for TTY support with sudo)
191
+ * Uses spawnSync to properly connect to the terminal for password prompts
192
+ */
193
+ function execWithInheritedStdio(command: string): void {
194
+ let cmdToRun = command
195
+
196
+ // If already running as root, strip sudo from the command
197
+ if (isRoot() && command.startsWith('sudo ')) {
198
+ cmdToRun = command.replace(/^sudo\s+/, '')
199
+ }
200
+
201
+ // Check if we need a TTY for sudo password prompts
202
+ if (!hasTTY() && cmdToRun.includes('sudo')) {
203
+ throw new Error(
204
+ 'Cannot run sudo commands without an interactive terminal. Please run the install command manually:\n' +
205
+ ` ${command}`,
193
206
  )
207
+ }
194
208
 
195
- setTimeout(() => {
196
- child.kill('SIGTERM')
197
- reject(new Error(`Command timed out after ${timeoutMs}ms: ${command}`))
198
- }, timeoutMs)
209
+ const result = spawnSync(cmdToRun, [], {
210
+ shell: true,
211
+ stdio: 'inherit',
199
212
  })
213
+
214
+ if (result.error) {
215
+ throw result.error
216
+ }
217
+
218
+ if (result.status !== 0) {
219
+ throw new Error(`Command failed with exit code ${result.status}: ${cmdToRun}`)
220
+ }
200
221
  }
201
222
 
202
223
  /**
@@ -246,7 +267,8 @@ export async function installDependency(
246
267
  const commands = buildInstallCommand(dependency, packageManager)
247
268
 
248
269
  for (const cmd of commands) {
249
- await execWithTimeout(cmd, 120000)
270
+ // Use inherited stdio so sudo can prompt for password in terminal
271
+ execWithInheritedStdio(cmd)
250
272
  }
251
273
 
252
274
  // Verify installation
@@ -308,29 +308,30 @@ export async function installPostgresBinaries(): Promise<boolean> {
308
308
 
309
309
  spinner.succeed(`Found package manager: ${packageManager.name}`)
310
310
 
311
- const installSpinner = createSpinner(
312
- `Installing PostgreSQL client tools with ${packageManager.name}...`,
313
- )
314
- installSpinner.start()
311
+ // Don't use a spinner during installation - it blocks TTY access for sudo password prompts
312
+ console.log(chalk.cyan(` Installing PostgreSQL client tools with ${packageManager.name}...`))
313
+ console.log(chalk.gray(' You may be prompted for your password.'))
314
+ console.log()
315
315
 
316
316
  try {
317
317
  const results = await installEngineDependencies('postgresql', packageManager)
318
318
  const allSuccess = results.every((r) => r.success)
319
319
 
320
320
  if (allSuccess) {
321
- installSpinner.succeed('PostgreSQL client tools installed')
322
- console.log(success('Installation completed successfully'))
321
+ console.log()
322
+ console.log(success('PostgreSQL client tools installed successfully'))
323
323
  return true
324
324
  } else {
325
325
  const failed = results.filter((r) => !r.success)
326
- installSpinner.fail('Some installations failed')
326
+ console.log()
327
+ console.log(themeError('Some installations failed:'))
327
328
  for (const f of failed) {
328
- console.log(themeError(`Failed to install ${f.dependency.name}: ${f.error}`))
329
+ console.log(themeError(` ${f.dependency.name}: ${f.error}`))
329
330
  }
330
331
  return false
331
332
  }
332
333
  } catch (error: unknown) {
333
- installSpinner.fail('Installation failed')
334
+ console.log()
334
335
  console.log(themeError('Failed to install PostgreSQL client tools'))
335
336
  console.log(warning('Please install manually'))
336
337
  if (error instanceof Error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spindb",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Spin up local database containers without Docker. A DBngin-like CLI for PostgreSQL.",
5
5
  "type": "module",
6
6
  "bin": {