spindb 0.7.0 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,10 +17,6 @@ function getSpinDBRoot(): string {
17
17
  return join(home, '.spindb')
18
18
  }
19
19
 
20
- // =============================================================================
21
- // Types
22
- // =============================================================================
23
-
24
20
  export type ErrorSeverity = 'fatal' | 'error' | 'warning' | 'info'
25
21
 
26
22
  export type SpinDBErrorInfo = {
@@ -31,10 +27,6 @@ export type SpinDBErrorInfo = {
31
27
  context?: Record<string, unknown>
32
28
  }
33
29
 
34
- // =============================================================================
35
- // Error Codes
36
- // =============================================================================
37
-
38
30
  export const ErrorCodes = {
39
31
  // Port errors
40
32
  PORT_IN_USE: 'PORT_IN_USE',
@@ -83,10 +75,6 @@ export const ErrorCodes = {
83
75
 
84
76
  export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]
85
77
 
86
- // =============================================================================
87
- // SpinDBError Class
88
- // =============================================================================
89
-
90
78
  export class SpinDBError extends Error {
91
79
  public readonly code: string
92
80
  public readonly severity: ErrorSeverity
@@ -131,13 +119,6 @@ export class SpinDBError extends Error {
131
119
  }
132
120
  }
133
121
 
134
- // =============================================================================
135
- // Logging Functions
136
- // =============================================================================
137
-
138
- /**
139
- * Get the path to the log file
140
- */
141
122
  function getLogPath(): string {
142
123
  return join(getSpinDBRoot(), 'spindb.log')
143
124
  }
@@ -264,13 +245,6 @@ export function logDebug(
264
245
  })
265
246
  }
266
247
 
267
- // =============================================================================
268
- // Error Creation Helpers
269
- // =============================================================================
270
-
271
- /**
272
- * Create a port-in-use error with helpful suggestion
273
- */
274
248
  export function createPortInUseError(port: number): SpinDBError {
275
249
  return new SpinDBError(
276
250
  ErrorCodes.PORT_IN_USE,
@@ -17,13 +17,47 @@ import { existsSync } from 'fs'
17
17
 
18
18
  const execAsync = promisify(exec)
19
19
 
20
- // =============================================================================
21
- // Types
22
- // =============================================================================
23
-
24
20
  export type Platform = 'darwin' | 'linux' | 'win32'
25
21
  export type Architecture = 'arm64' | 'x64'
26
22
 
23
+ /**
24
+ * Options for resolving home directory under sudo
25
+ */
26
+ export type ResolveHomeDirOptions = {
27
+ sudoUser: string | null
28
+ getentResult: string | null
29
+ platform: 'darwin' | 'linux'
30
+ defaultHome: string
31
+ }
32
+
33
+ /**
34
+ * Resolve the correct home directory, handling sudo scenarios.
35
+ * This is extracted as a pure function for testability.
36
+ *
37
+ * When running under sudo, we need to use the original user's home directory,
38
+ * not root's home. This prevents ~/.spindb from being created in /root/.
39
+ */
40
+ export function resolveHomeDir(options: ResolveHomeDirOptions): string {
41
+ const { sudoUser, getentResult, platform, defaultHome } = options
42
+
43
+ // Not running under sudo - use default
44
+ if (!sudoUser) {
45
+ return defaultHome
46
+ }
47
+
48
+ // Try to parse home from getent passwd output
49
+ // Format: username:password:uid:gid:gecos:home:shell
50
+ if (getentResult) {
51
+ const parts = getentResult.trim().split(':')
52
+ if (parts.length >= 6 && parts[5]) {
53
+ return parts[5]
54
+ }
55
+ }
56
+
57
+ // Fallback to platform-specific default
58
+ return platform === 'darwin' ? `/Users/${sudoUser}` : `/home/${sudoUser}`
59
+ }
60
+
27
61
  export type PlatformInfo = {
28
62
  platform: Platform
29
63
  arch: Architecture
@@ -54,10 +88,6 @@ export type PackageManagerInfo = {
54
88
  updateCommand: string
55
89
  }
56
90
 
57
- // =============================================================================
58
- // Abstract Base Class
59
- // =============================================================================
60
-
61
91
  export abstract class BasePlatformService {
62
92
  protected cachedPlatformInfo: PlatformInfo | null = null
63
93
 
@@ -165,33 +195,31 @@ export abstract class BasePlatformService {
165
195
  }
166
196
  }
167
197
 
168
- // =============================================================================
169
- // Darwin (macOS) Implementation
170
- // =============================================================================
171
-
172
198
  class DarwinPlatformService extends BasePlatformService {
173
199
  getPlatformInfo(): PlatformInfo {
174
200
  if (this.cachedPlatformInfo) return this.cachedPlatformInfo
175
201
 
176
202
  const sudoUser = process.env.SUDO_USER || null
177
- let homeDir: string
178
203
 
204
+ // Try to get home from getent passwd (may fail on macOS)
205
+ let getentResult: string | null = null
179
206
  if (sudoUser) {
180
- // Running under sudo - get original user's home
181
207
  try {
182
- const result = execSync(`getent passwd ${sudoUser}`, {
208
+ getentResult = execSync(`getent passwd ${sudoUser}`, {
183
209
  encoding: 'utf-8',
184
210
  })
185
- const parts = result.trim().split(':')
186
- homeDir =
187
- parts.length >= 6 && parts[5] ? parts[5] : `/Users/${sudoUser}`
188
211
  } catch {
189
- homeDir = `/Users/${sudoUser}`
212
+ // getent may not be available on macOS
190
213
  }
191
- } else {
192
- homeDir = homedir()
193
214
  }
194
215
 
216
+ const homeDir = resolveHomeDir({
217
+ sudoUser,
218
+ getentResult,
219
+ platform: 'darwin',
220
+ defaultHome: homedir(),
221
+ })
222
+
195
223
  this.cachedPlatformInfo = {
196
224
  platform: 'darwin',
197
225
  arch: osArch() as Architecture,
@@ -309,31 +337,31 @@ class DarwinPlatformService extends BasePlatformService {
309
337
  }
310
338
  }
311
339
 
312
- // =============================================================================
313
- // Linux Implementation
314
- // =============================================================================
315
-
316
340
  class LinuxPlatformService extends BasePlatformService {
317
341
  getPlatformInfo(): PlatformInfo {
318
342
  if (this.cachedPlatformInfo) return this.cachedPlatformInfo
319
343
 
320
344
  const sudoUser = process.env.SUDO_USER || null
321
- let homeDir: string
322
345
 
346
+ // Try to get home from getent passwd
347
+ let getentResult: string | null = null
323
348
  if (sudoUser) {
324
349
  try {
325
- const result = execSync(`getent passwd ${sudoUser}`, {
350
+ getentResult = execSync(`getent passwd ${sudoUser}`, {
326
351
  encoding: 'utf-8',
327
352
  })
328
- const parts = result.trim().split(':')
329
- homeDir = parts.length >= 6 && parts[5] ? parts[5] : `/home/${sudoUser}`
330
353
  } catch {
331
- homeDir = `/home/${sudoUser}`
354
+ // getent failed
332
355
  }
333
- } else {
334
- homeDir = homedir()
335
356
  }
336
357
 
358
+ const homeDir = resolveHomeDir({
359
+ sudoUser,
360
+ getentResult,
361
+ platform: 'linux',
362
+ defaultHome: homedir(),
363
+ })
364
+
337
365
  // Check if running in WSL
338
366
  let isWSL = false
339
367
  try {
@@ -494,10 +522,6 @@ class LinuxPlatformService extends BasePlatformService {
494
522
  }
495
523
  }
496
524
 
497
- // =============================================================================
498
- // Windows Implementation (Stub for future support)
499
- // =============================================================================
500
-
501
525
  class Win32PlatformService extends BasePlatformService {
502
526
  getPlatformInfo(): PlatformInfo {
503
527
  if (this.cachedPlatformInfo) return this.cachedPlatformInfo
@@ -608,10 +632,6 @@ class Win32PlatformService extends BasePlatformService {
608
632
  }
609
633
  }
610
634
 
611
- // =============================================================================
612
- // Factory and Singleton
613
- // =============================================================================
614
-
615
635
  /**
616
636
  * Create the appropriate platform service for the current OS
617
637
  */
@@ -9,12 +9,9 @@ import { portManager } from './port-manager'
9
9
  import { containerManager } from './container-manager'
10
10
  import { logWarning, logDebug } from './error-handler'
11
11
  import type { BaseEngine } from '../engines/base-engine'
12
+ import { getEngineDefaults } from '../config/defaults'
12
13
  import type { ContainerConfig } from '../types'
13
14
 
14
- // =============================================================================
15
- // Types
16
- // =============================================================================
17
-
18
15
  export type StartWithRetryOptions = {
19
16
  engine: BaseEngine
20
17
  config: ContainerConfig
@@ -29,13 +26,6 @@ export type StartWithRetryResult = {
29
26
  error?: Error
30
27
  }
31
28
 
32
- // =============================================================================
33
- // Port Error Detection
34
- // =============================================================================
35
-
36
- /**
37
- * Check if an error is a port-in-use error
38
- */
39
29
  function isPortInUseError(err: unknown): boolean {
40
30
  const message = (err as Error)?.message?.toLowerCase() || ''
41
31
  return (
@@ -47,10 +37,6 @@ function isPortInUseError(err: unknown): boolean {
47
37
  )
48
38
  }
49
39
 
50
- // =============================================================================
51
- // Start with Retry Implementation
52
- // =============================================================================
53
-
54
40
  /**
55
41
  * Start a database container with automatic port retry on conflict
56
42
  *
@@ -129,20 +115,9 @@ export async function startWithRetry(
129
115
  }
130
116
  }
131
117
 
132
- /**
133
- * Get the port range for an engine
134
- */
135
118
  function getEnginePortRange(engine: string): { start: number; end: number } {
136
- // Import defaults dynamically to avoid circular dependency
137
- // These are the standard ranges from config/defaults.ts
138
- if (engine === 'postgresql') {
139
- return { start: 5432, end: 5500 }
140
- }
141
- if (engine === 'mysql') {
142
- return { start: 3306, end: 3400 }
143
- }
144
- // Default fallback range
145
- return { start: 5432, end: 6000 }
119
+ const engineDefaults = getEngineDefaults(engine)
120
+ return engineDefaults.portRange
146
121
  }
147
122
 
148
123
  /**
@@ -7,19 +7,11 @@
7
7
 
8
8
  import { logError, logDebug, ErrorCodes } from './error-handler'
9
9
 
10
- // =============================================================================
11
- // Types
12
- // =============================================================================
13
-
14
10
  export type RollbackAction = {
15
11
  description: string
16
12
  execute: () => Promise<void>
17
13
  }
18
14
 
19
- // =============================================================================
20
- // Transaction Manager Implementation
21
- // =============================================================================
22
-
23
15
  /**
24
16
  * Manages a stack of rollback actions for transactional operations.
25
17
  *
@@ -151,4 +151,14 @@ export abstract class BaseEngine {
151
151
  outputPath: string,
152
152
  options: BackupOptions,
153
153
  ): Promise<BackupResult>
154
+
155
+ /**
156
+ * Run a SQL file or inline SQL statement against the database
157
+ * @param container - The container configuration
158
+ * @param options - Options including file path or SQL statement, and target database
159
+ */
160
+ abstract runScript(
161
+ container: ContainerConfig,
162
+ options: { file?: string; sql?: string; database?: string },
163
+ ): Promise<void>
154
164
  }
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { exec } from 'child_process'
7
+ import { existsSync } from 'fs'
7
8
  import { promisify } from 'util'
8
9
  import { platformService } from '../../core/platform-service'
9
10
 
@@ -129,7 +130,6 @@ export async function detectInstalledVersions(): Promise<
129
130
  '/usr/local/opt/mysql@8.4/bin/mysqld',
130
131
  ]
131
132
 
132
- const { existsSync } = await import('fs')
133
133
  for (const path of homebrewPaths) {
134
134
  if (existsSync(path)) {
135
135
  const version = await getMysqlVersion(path)
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { spawn, exec } from 'child_process'
7
7
  import { promisify } from 'util'
8
- import { existsSync } from 'fs'
8
+ import { existsSync, createReadStream } from 'fs'
9
9
  import { mkdir, writeFile, readFile, unlink } from 'fs/promises'
10
10
  import { join } from 'path'
11
11
  import { BaseEngine } from '../base-engine'
@@ -289,8 +289,9 @@ export class MySQLEngine extends BaseEngine {
289
289
  // Write PID file manually since we're running detached
290
290
  try {
291
291
  await writeFile(pidFile, String(proc.pid))
292
- } catch {
292
+ } catch (error) {
293
293
  // PID file might be written by mysqld itself
294
+ logDebug(`Could not write PID file (mysqld may write it): ${error}`)
294
295
  }
295
296
 
296
297
  // Wait for MySQL to be ready
@@ -842,6 +843,81 @@ export class MySQLEngine extends BaseEngine {
842
843
  ): Promise<BackupResult> {
843
844
  return createBackup(container, outputPath, options)
844
845
  }
846
+
847
+ /**
848
+ * Run a SQL file or inline SQL statement against the database
849
+ * CLI wrapper: mysql -h 127.0.0.1 -P {port} -u root {db} < {file}
850
+ * CLI wrapper: mysql -h 127.0.0.1 -P {port} -u root {db} -e "{sql}"
851
+ */
852
+ async runScript(
853
+ container: ContainerConfig,
854
+ options: { file?: string; sql?: string; database?: string },
855
+ ): Promise<void> {
856
+ const { port } = container
857
+ const db = options.database || container.database || 'mysql'
858
+
859
+ const mysql = await getMysqlClientPath()
860
+ if (!mysql) {
861
+ throw new Error(
862
+ 'mysql client not found. Install MySQL client tools:\n' +
863
+ ' macOS: brew install mysql-client\n' +
864
+ ' Ubuntu/Debian: sudo apt install mysql-client',
865
+ )
866
+ }
867
+
868
+ const args = [
869
+ '-h',
870
+ '127.0.0.1',
871
+ '-P',
872
+ String(port),
873
+ '-u',
874
+ engineDef.superuser,
875
+ db,
876
+ ]
877
+
878
+ if (options.sql) {
879
+ // For inline SQL, use -e flag
880
+ args.push('-e', options.sql)
881
+ return new Promise((resolve, reject) => {
882
+ const proc = spawn(mysql, args, { stdio: 'inherit' })
883
+
884
+ proc.on('error', reject)
885
+ proc.on('close', (code) => {
886
+ if (code === 0) {
887
+ resolve()
888
+ } else {
889
+ reject(new Error(`mysql exited with code ${code}`))
890
+ }
891
+ })
892
+ })
893
+ } else if (options.file) {
894
+ // For file input, pipe the file to mysql stdin
895
+ return new Promise((resolve, reject) => {
896
+ const fileStream = createReadStream(options.file!)
897
+ const proc = spawn(mysql, args, {
898
+ stdio: ['pipe', 'inherit', 'inherit'],
899
+ })
900
+
901
+ fileStream.pipe(proc.stdin)
902
+
903
+ fileStream.on('error', (err) => {
904
+ proc.kill()
905
+ reject(err)
906
+ })
907
+
908
+ proc.on('error', reject)
909
+ proc.on('close', (code) => {
910
+ if (code === 0) {
911
+ resolve()
912
+ } else {
913
+ reject(new Error(`mysql exited with code ${code}`))
914
+ }
915
+ })
916
+ })
917
+ } else {
918
+ throw new Error('Either file or sql option must be provided')
919
+ }
920
+ }
845
921
  }
846
922
 
847
923
  export const mysqlEngine = new MySQLEngine()
@@ -457,6 +457,55 @@ export class PostgreSQLEngine extends BaseEngine {
457
457
  ): Promise<BackupResult> {
458
458
  return createBackup(container, outputPath, options)
459
459
  }
460
+
461
+ /**
462
+ * Run a SQL file or inline SQL statement against the database
463
+ * CLI wrapper: psql -h 127.0.0.1 -p {port} -U postgres -d {db} -f {file}
464
+ * CLI wrapper: psql -h 127.0.0.1 -p {port} -U postgres -d {db} -c "{sql}"
465
+ */
466
+ async runScript(
467
+ container: ContainerConfig,
468
+ options: { file?: string; sql?: string; database?: string },
469
+ ): Promise<void> {
470
+ const { port } = container
471
+ const db = options.database || container.database || 'postgres'
472
+ const psqlPath = await this.getPsqlPath()
473
+
474
+ const args = [
475
+ '-h',
476
+ '127.0.0.1',
477
+ '-p',
478
+ String(port),
479
+ '-U',
480
+ defaults.superuser,
481
+ '-d',
482
+ db,
483
+ ]
484
+
485
+ if (options.file) {
486
+ args.push('-f', options.file)
487
+ } else if (options.sql) {
488
+ args.push('-c', options.sql)
489
+ } else {
490
+ throw new Error('Either file or sql option must be provided')
491
+ }
492
+
493
+ return new Promise((resolve, reject) => {
494
+ const proc = spawn(psqlPath, args, { stdio: 'inherit' })
495
+
496
+ proc.on('error', (err: NodeJS.ErrnoException) => {
497
+ reject(err)
498
+ })
499
+
500
+ proc.on('close', (code) => {
501
+ if (code === 0) {
502
+ resolve()
503
+ } else {
504
+ reject(new Error(`psql exited with code ${code}`))
505
+ }
506
+ })
507
+ })
508
+ }
460
509
  }
461
510
 
462
511
  export const postgresqlEngine = new PostgreSQLEngine()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spindb",
3
- "version": "0.7.0",
3
+ "version": "0.7.3",
4
4
  "description": "Spin up local database containers without Docker. A DBngin-like CLI for PostgreSQL and MySQL.",
5
5
  "type": "module",
6
6
  "bin": {