spindb 0.8.2 → 0.9.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.
Files changed (40) hide show
  1. package/README.md +87 -7
  2. package/cli/commands/clone.ts +6 -0
  3. package/cli/commands/connect.ts +115 -14
  4. package/cli/commands/create.ts +170 -8
  5. package/cli/commands/doctor.ts +320 -0
  6. package/cli/commands/edit.ts +209 -9
  7. package/cli/commands/engines.ts +34 -3
  8. package/cli/commands/info.ts +81 -26
  9. package/cli/commands/list.ts +64 -9
  10. package/cli/commands/logs.ts +9 -3
  11. package/cli/commands/menu/backup-handlers.ts +52 -21
  12. package/cli/commands/menu/container-handlers.ts +433 -127
  13. package/cli/commands/menu/engine-handlers.ts +128 -4
  14. package/cli/commands/menu/index.ts +5 -1
  15. package/cli/commands/menu/shell-handlers.ts +105 -21
  16. package/cli/commands/menu/sql-handlers.ts +16 -4
  17. package/cli/commands/menu/update-handlers.ts +278 -0
  18. package/cli/commands/restore.ts +83 -23
  19. package/cli/commands/run.ts +27 -11
  20. package/cli/commands/url.ts +17 -9
  21. package/cli/constants.ts +1 -0
  22. package/cli/helpers.ts +41 -1
  23. package/cli/index.ts +2 -0
  24. package/cli/ui/prompts.ts +148 -7
  25. package/config/engine-defaults.ts +14 -0
  26. package/config/os-dependencies.ts +66 -0
  27. package/config/paths.ts +8 -0
  28. package/core/container-manager.ts +191 -32
  29. package/core/dependency-manager.ts +18 -0
  30. package/core/error-handler.ts +31 -0
  31. package/core/port-manager.ts +2 -0
  32. package/core/process-manager.ts +25 -3
  33. package/engines/index.ts +4 -0
  34. package/engines/mysql/backup.ts +53 -36
  35. package/engines/mysql/index.ts +48 -5
  36. package/engines/postgresql/index.ts +6 -0
  37. package/engines/sqlite/index.ts +606 -0
  38. package/engines/sqlite/registry.ts +185 -0
  39. package/package.json +1 -1
  40. package/types/index.ts +26 -0
package/cli/ui/prompts.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import inquirer from 'inquirer'
2
2
  import chalk from 'chalk'
3
3
  import ora from 'ora'
4
+ import { existsSync, statSync } from 'fs'
5
+ import { resolve, join } from 'path'
6
+ import { homedir } from 'os'
4
7
  import { listEngines, getEngine } from '../../engines'
5
8
  import { defaults, getEngineDefaults } from '../../config/defaults'
6
9
  import { installPostgresBinaries } from '../../engines/postgresql/binary-manager'
@@ -249,6 +252,29 @@ export async function promptContainerSelect(
249
252
  return container
250
253
  }
251
254
 
255
+ /**
256
+ * Sanitize a string to be a valid database name
257
+ * Replaces invalid characters with underscores
258
+ */
259
+ function sanitizeDatabaseName(name: string): string {
260
+ // Replace invalid characters with underscores
261
+ // Note: hyphens are excluded because they require quoting in SQL
262
+ let sanitized = name.replace(/[^a-zA-Z0-9_]/g, '_')
263
+ // Ensure it starts with a letter or underscore
264
+ if (sanitized && !/^[a-zA-Z_]/.test(sanitized)) {
265
+ sanitized = '_' + sanitized
266
+ }
267
+ // Collapse multiple underscores
268
+ sanitized = sanitized.replace(/_+/g, '_')
269
+ // Trim trailing underscores
270
+ sanitized = sanitized.replace(/_+$/, '')
271
+ // Fallback if result is empty (e.g., input was "---")
272
+ if (!sanitized) {
273
+ sanitized = 'db'
274
+ }
275
+ return sanitized
276
+ }
277
+
252
278
  /**
253
279
  * Prompt for database name
254
280
  * @param defaultName - Default value for the database name
@@ -262,17 +288,21 @@ export async function promptDatabaseName(
262
288
  const label =
263
289
  engine === 'mysql' ? 'Database (schema) name:' : 'Database name:'
264
290
 
291
+ // Sanitize the default name to ensure it's valid
292
+ const sanitizedDefault = defaultName ? sanitizeDatabaseName(defaultName) : undefined
293
+
265
294
  const { database } = await inquirer.prompt<{ database: string }>([
266
295
  {
267
296
  type: 'input',
268
297
  name: 'database',
269
298
  message: label,
270
- default: defaultName,
299
+ default: sanitizedDefault,
271
300
  validate: (input: string) => {
272
301
  if (!input) return 'Database name is required'
273
302
  // PostgreSQL database naming rules (also valid for MySQL)
274
- if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(input)) {
275
- return 'Database name must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens'
303
+ // Hyphens excluded to avoid requiring quoted identifiers in SQL
304
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(input)) {
305
+ return 'Database name must start with a letter or underscore and contain only letters, numbers, and underscores'
276
306
  }
277
307
  if (input.length > 63) {
278
308
  return 'Database name must be 63 characters or less'
@@ -378,6 +408,111 @@ export type CreateOptions = {
378
408
  version: string
379
409
  port: number
380
410
  database: string
411
+ path?: string // SQLite file path
412
+ }
413
+
414
+ /**
415
+ * Prompt for SQLite database file location
416
+ * Similar to the relocate logic in container-handlers.ts
417
+ */
418
+ export async function promptSqlitePath(
419
+ containerName: string,
420
+ ): Promise<string | undefined> {
421
+ const defaultPath = `./${containerName}.sqlite`
422
+
423
+ console.log(chalk.gray(' SQLite databases are stored as files in your project directory.'))
424
+ console.log(chalk.gray(` Default: ${defaultPath}`))
425
+ console.log()
426
+
427
+ const { useDefault } = await inquirer.prompt<{ useDefault: string }>([
428
+ {
429
+ type: 'list',
430
+ name: 'useDefault',
431
+ message: 'Where should the database file be created?',
432
+ choices: [
433
+ { name: `Use default location (${defaultPath})`, value: 'default' },
434
+ { name: 'Specify custom path', value: 'custom' },
435
+ ],
436
+ },
437
+ ])
438
+
439
+ if (useDefault === 'default') {
440
+ return undefined // Use default
441
+ }
442
+
443
+ const { inputPath } = await inquirer.prompt<{ inputPath: string }>([
444
+ {
445
+ type: 'input',
446
+ name: 'inputPath',
447
+ message: 'File path:',
448
+ default: defaultPath,
449
+ validate: (input: string) => {
450
+ if (!input) return 'Path is required'
451
+ return true
452
+ },
453
+ },
454
+ ])
455
+
456
+ // Expand ~ to home directory
457
+ let expandedPath = inputPath
458
+ if (inputPath === '~') {
459
+ expandedPath = homedir()
460
+ } else if (inputPath.startsWith('~/')) {
461
+ expandedPath = join(homedir(), inputPath.slice(2))
462
+ }
463
+
464
+ // Convert relative paths to absolute
465
+ if (!expandedPath.startsWith('/')) {
466
+ expandedPath = resolve(process.cwd(), expandedPath)
467
+ }
468
+
469
+ // Check if path looks like a file (has db extension) or directory
470
+ const hasDbExtension = /\.(sqlite3?|db)$/i.test(expandedPath)
471
+
472
+ // Treat as directory if:
473
+ // - ends with /
474
+ // - exists and is a directory
475
+ // - doesn't have a database file extension (assume it's a directory path)
476
+ const isDirectory =
477
+ expandedPath.endsWith('/') ||
478
+ (existsSync(expandedPath) && statSync(expandedPath).isDirectory()) ||
479
+ !hasDbExtension
480
+
481
+ let finalPath: string
482
+ if (isDirectory) {
483
+ // Remove trailing slash if present, then append filename
484
+ const dirPath = expandedPath.endsWith('/')
485
+ ? expandedPath.slice(0, -1)
486
+ : expandedPath
487
+ finalPath = join(dirPath, `${containerName}.sqlite`)
488
+ } else {
489
+ finalPath = expandedPath
490
+ }
491
+
492
+ // Check if file already exists
493
+ if (existsSync(finalPath)) {
494
+ console.log(chalk.yellow(` Warning: File already exists: ${finalPath}`))
495
+ const { overwrite } = await inquirer.prompt<{ overwrite: string }>([
496
+ {
497
+ type: 'list',
498
+ name: 'overwrite',
499
+ message: 'A file already exists at this location. What would you like to do?',
500
+ choices: [
501
+ { name: 'Choose a different path', value: 'different' },
502
+ { name: 'Cancel', value: 'cancel' },
503
+ ],
504
+ },
505
+ ])
506
+
507
+ if (overwrite === 'cancel') {
508
+ throw new Error('Creation cancelled')
509
+ }
510
+
511
+ // Recursively prompt again
512
+ return promptSqlitePath(containerName)
513
+ }
514
+
515
+ return finalPath
381
516
  }
382
517
 
383
518
  /**
@@ -391,11 +526,17 @@ export async function promptCreateOptions(): Promise<CreateOptions> {
391
526
  const name = await promptContainerName()
392
527
  const database = await promptDatabaseName(name, engine) // Default to container name
393
528
 
394
- // Get engine-specific default port
395
- const engineDefaults = getEngineDefaults(engine)
396
- const port = await promptPort(engineDefaults.defaultPort)
529
+ // SQLite is file-based, no port needed but needs path
530
+ let port = 0
531
+ let path: string | undefined
532
+ if (engine === 'sqlite') {
533
+ path = await promptSqlitePath(name)
534
+ } else {
535
+ const engineDefaults = getEngineDefaults(engine)
536
+ port = await promptPort(engineDefaults.defaultPort)
537
+ }
397
538
 
398
- return { name, engine, version, port, database }
539
+ return { name, engine, version, port, database, path }
399
540
  }
400
541
 
401
542
  /**
@@ -59,6 +59,20 @@ export const engineDefaults: Record<string, EngineDefaults> = {
59
59
  clientTools: ['mysql', 'mysqldump', 'mysqlpump'],
60
60
  maxConnections: 200, // Higher than default 151 for parallel builds
61
61
  },
62
+ sqlite: {
63
+ defaultVersion: '3',
64
+ defaultPort: 0, // File-based, no port
65
+ portRange: { start: 0, end: 0 }, // N/A
66
+ supportedVersions: ['3'],
67
+ latestVersion: '3',
68
+ superuser: '', // No authentication
69
+ connectionScheme: 'sqlite',
70
+ logFileName: '', // No log file
71
+ pidFileName: '', // No PID file (no server process)
72
+ dataSubdir: '', // File is the data
73
+ clientTools: ['sqlite3'],
74
+ maxConnections: 0, // N/A - file-based
75
+ },
62
76
  }
63
77
 
64
78
  /**
@@ -289,6 +289,42 @@ const mysqlDependencies: EngineDependencies = {
289
289
  ],
290
290
  }
291
291
 
292
+ // =============================================================================
293
+ // SQLite Dependencies
294
+ // =============================================================================
295
+
296
+ const sqliteDependencies: EngineDependencies = {
297
+ engine: 'sqlite',
298
+ displayName: 'SQLite',
299
+ dependencies: [
300
+ {
301
+ name: 'sqlite3',
302
+ binary: 'sqlite3',
303
+ description: 'SQLite command-line interface',
304
+ packages: {
305
+ brew: { package: 'sqlite' },
306
+ apt: { package: 'sqlite3' },
307
+ yum: { package: 'sqlite' },
308
+ dnf: { package: 'sqlite' },
309
+ pacman: { package: 'sqlite' },
310
+ },
311
+ manualInstall: {
312
+ darwin: [
313
+ 'Install Homebrew: /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
314
+ 'Then run: brew install sqlite',
315
+ 'Note: macOS includes sqlite3 by default in /usr/bin/sqlite3',
316
+ ],
317
+ linux: [
318
+ 'Debian/Ubuntu: sudo apt install sqlite3',
319
+ 'CentOS/RHEL: sudo yum install sqlite',
320
+ 'Fedora: sudo dnf install sqlite',
321
+ 'Arch: sudo pacman -S sqlite',
322
+ ],
323
+ },
324
+ },
325
+ ],
326
+ }
327
+
292
328
  // =============================================================================
293
329
  // Optional Tools (engine-agnostic)
294
330
  // =============================================================================
@@ -381,6 +417,35 @@ export const mycliDependency: Dependency = {
381
417
  },
382
418
  }
383
419
 
420
+ /**
421
+ * litecli - SQLite CLI with auto-completion and syntax highlighting
422
+ * https://github.com/dbcli/litecli
423
+ */
424
+ export const litecliDependency: Dependency = {
425
+ name: 'litecli',
426
+ binary: 'litecli',
427
+ description:
428
+ 'SQLite CLI with intelligent auto-completion and syntax highlighting',
429
+ packages: {
430
+ brew: { package: 'litecli' },
431
+ apt: { package: 'litecli' },
432
+ dnf: { package: 'litecli' },
433
+ yum: { package: 'litecli' },
434
+ pacman: { package: 'litecli' },
435
+ },
436
+ manualInstall: {
437
+ darwin: [
438
+ 'Install with Homebrew: brew install litecli',
439
+ 'Or with pip: pip install litecli',
440
+ ],
441
+ linux: [
442
+ 'Debian/Ubuntu: sudo apt install litecli',
443
+ 'Fedora: sudo dnf install litecli',
444
+ 'Or with pip: pip install litecli',
445
+ ],
446
+ },
447
+ }
448
+
384
449
  // =============================================================================
385
450
  // Registry
386
451
  // =============================================================================
@@ -391,6 +456,7 @@ export const mycliDependency: Dependency = {
391
456
  export const engineDependencies: EngineDependencies[] = [
392
457
  postgresqlDependencies,
393
458
  mysqlDependencies,
459
+ sqliteDependencies,
394
460
  ]
395
461
 
396
462
  /**
package/config/paths.ts CHANGED
@@ -114,4 +114,12 @@ export const paths = {
114
114
  getEngineContainersPath(engine: string): string {
115
115
  return join(this.containers, engine)
116
116
  },
117
+
118
+ /**
119
+ * Get path for SQLite registry file
120
+ * SQLite uses a registry (not container directories) since databases are stored externally
121
+ */
122
+ getSqliteRegistryPath(): string {
123
+ return join(this.root, 'sqlite-registry.json')
124
+ },
117
125
  }
@@ -1,10 +1,20 @@
1
1
  import { existsSync } from 'fs'
2
- import { mkdir, readdir, readFile, writeFile, rm, cp } from 'fs/promises'
2
+ import {
3
+ mkdir,
4
+ readdir,
5
+ readFile,
6
+ writeFile,
7
+ rm,
8
+ cp,
9
+ unlink,
10
+ rename as fsRename,
11
+ } from 'fs/promises'
3
12
  import { paths } from '../config/paths'
4
13
  import { processManager } from './process-manager'
5
14
  import { portManager } from './port-manager'
6
15
  import { getEngineDefaults, getSupportedEngines } from '../config/defaults'
7
16
  import { getEngine } from '../engines'
17
+ import { sqliteRegistry } from '../engines/sqlite/registry'
8
18
  import type { ContainerConfig } from '../types'
9
19
  import { Engine } from '../types'
10
20
 
@@ -74,6 +84,11 @@ export class ContainerManager {
74
84
  const { engine } = options || {}
75
85
 
76
86
  if (engine) {
87
+ // SQLite uses registry instead of filesystem
88
+ if (engine === Engine.SQLite) {
89
+ return this.getSqliteConfig(name)
90
+ }
91
+
77
92
  // Look in specific engine directory
78
93
  const configPath = paths.getContainerConfigPath(name, { engine })
79
94
  if (!existsSync(configPath)) {
@@ -84,8 +99,14 @@ export class ContainerManager {
84
99
  return this.migrateConfig(config)
85
100
  }
86
101
 
87
- // Search all engine directories
88
- const engines = getSupportedEngines()
102
+ // Search SQLite registry first
103
+ const sqliteConfig = await this.getSqliteConfig(name)
104
+ if (sqliteConfig) {
105
+ return sqliteConfig
106
+ }
107
+
108
+ // Search all engine directories (excluding sqlite which uses registry)
109
+ const engines = getSupportedEngines().filter((e) => e !== 'sqlite')
89
110
  for (const eng of engines) {
90
111
  const configPath = paths.getContainerConfigPath(name, { engine: eng })
91
112
  if (existsSync(configPath)) {
@@ -98,6 +119,29 @@ export class ContainerManager {
98
119
  return null
99
120
  }
100
121
 
122
+ /**
123
+ * Get SQLite container config from registry
124
+ */
125
+ private async getSqliteConfig(name: string): Promise<ContainerConfig | null> {
126
+ const entry = await sqliteRegistry.get(name)
127
+ if (!entry) {
128
+ return null
129
+ }
130
+
131
+ // Convert registry entry to ContainerConfig format
132
+ const fileExists = existsSync(entry.filePath)
133
+ return {
134
+ name: entry.name,
135
+ engine: Engine.SQLite,
136
+ version: '3',
137
+ port: 0,
138
+ database: entry.filePath, // For SQLite, database field stores file path
139
+ databases: [entry.filePath],
140
+ created: entry.created,
141
+ status: fileExists ? 'running' : 'stopped', // "running" = file exists
142
+ }
143
+ }
144
+
101
145
  /**
102
146
  * Migrate old container configs to include databases array
103
147
  * Ensures primary database is always in the databases array
@@ -165,12 +209,21 @@ export class ContainerManager {
165
209
  const { engine } = options || {}
166
210
 
167
211
  if (engine) {
212
+ // SQLite uses registry
213
+ if (engine === Engine.SQLite) {
214
+ return sqliteRegistry.exists(name)
215
+ }
168
216
  const configPath = paths.getContainerConfigPath(name, { engine })
169
217
  return existsSync(configPath)
170
218
  }
171
219
 
172
- // Check all engine directories
173
- const engines = getSupportedEngines()
220
+ // Check SQLite registry first
221
+ if (await sqliteRegistry.exists(name)) {
222
+ return true
223
+ }
224
+
225
+ // Check all engine directories (excluding sqlite)
226
+ const engines = getSupportedEngines().filter((e) => e !== 'sqlite')
174
227
  for (const eng of engines) {
175
228
  const configPath = paths.getContainerConfigPath(name, { engine: eng })
176
229
  if (existsSync(configPath)) {
@@ -185,14 +238,31 @@ export class ContainerManager {
185
238
  * List all containers across all engines
186
239
  */
187
240
  async list(): Promise<ContainerConfig[]> {
188
- const containersDir = paths.containers
241
+ const containers: ContainerConfig[] = []
242
+
243
+ // List SQLite containers from registry
244
+ const sqliteEntries = await sqliteRegistry.list()
245
+ for (const entry of sqliteEntries) {
246
+ const fileExists = existsSync(entry.filePath)
247
+ containers.push({
248
+ name: entry.name,
249
+ engine: Engine.SQLite,
250
+ version: '3',
251
+ port: 0,
252
+ database: entry.filePath,
253
+ databases: [entry.filePath],
254
+ created: entry.created,
255
+ status: fileExists ? 'running' : 'stopped', // "running" = file exists
256
+ })
257
+ }
189
258
 
259
+ // List server-based containers (PostgreSQL, MySQL)
260
+ const containersDir = paths.containers
190
261
  if (!existsSync(containersDir)) {
191
- return []
262
+ return containers
192
263
  }
193
264
 
194
- const containers: ContainerConfig[] = []
195
- const engines = getSupportedEngines()
265
+ const engines = getSupportedEngines().filter((e) => e !== 'sqlite')
196
266
 
197
267
  for (const engine of engines) {
198
268
  const engineDir = paths.getEngineContainersPath(engine)
@@ -236,7 +306,23 @@ export class ContainerManager {
236
306
 
237
307
  const { engine } = config
238
308
 
239
- // Check if running
309
+ // SQLite: delete file, remove from registry, and clean up container directory
310
+ if (engine === Engine.SQLite) {
311
+ const entry = await sqliteRegistry.get(name)
312
+ if (entry && existsSync(entry.filePath)) {
313
+ await unlink(entry.filePath)
314
+ }
315
+ await sqliteRegistry.remove(name)
316
+
317
+ // Also remove the container directory (created by containerManager.create)
318
+ const containerPath = paths.getContainerPath(name, { engine })
319
+ if (existsSync(containerPath)) {
320
+ await rm(containerPath, { recursive: true, force: true })
321
+ }
322
+ return
323
+ }
324
+
325
+ // Server databases: check if running first
240
326
  const running = await processManager.isRunning(name, { engine })
241
327
  if (running && !force) {
242
328
  throw new Error(
@@ -289,26 +375,35 @@ export class ContainerManager {
289
375
 
290
376
  await cp(sourcePath, targetPath, { recursive: true })
291
377
 
292
- // Update target config
293
- const config = await this.getConfig(targetName, { engine })
294
- if (!config) {
295
- throw new Error('Failed to read cloned container config')
296
- }
297
-
298
- config.name = targetName
299
- config.created = new Date().toISOString()
300
- config.clonedFrom = sourceName
301
-
302
- // Assign new port (excluding ports already used by other containers)
303
- const engineDefaults = getEngineDefaults(engine)
304
- const { port } = await portManager.findAvailablePortExcludingContainers({
305
- portRange: engineDefaults.portRange,
306
- })
307
- config.port = port
308
-
309
- await this.saveConfig(targetName, { engine }, config)
378
+ // If anything fails after copy, clean up the target directory
379
+ try {
380
+ // Update target config
381
+ const config = await this.getConfig(targetName, { engine })
382
+ if (!config) {
383
+ throw new Error('Failed to read cloned container config')
384
+ }
310
385
 
311
- return config
386
+ config.name = targetName
387
+ config.created = new Date().toISOString()
388
+ config.clonedFrom = sourceName
389
+
390
+ // Assign new port (excluding ports already used by other containers)
391
+ const engineDefaults = getEngineDefaults(engine)
392
+ const { port } = await portManager.findAvailablePortExcludingContainers({
393
+ portRange: engineDefaults.portRange,
394
+ })
395
+ config.port = port
396
+
397
+ await this.saveConfig(targetName, { engine }, config)
398
+
399
+ return config
400
+ } catch (err) {
401
+ // Clean up the copied directory on failure
402
+ await rm(targetPath, { recursive: true, force: true }).catch(() => {
403
+ // Ignore cleanup errors
404
+ })
405
+ throw err
406
+ }
312
407
  }
313
408
 
314
409
  /**
@@ -335,7 +430,38 @@ export class ContainerManager {
335
430
  throw new Error(`Container "${newName}" already exists`)
336
431
  }
337
432
 
338
- // Check container is not running
433
+ // SQLite: rename in registry and handle container directory
434
+ if (engine === Engine.SQLite) {
435
+ const entry = await sqliteRegistry.get(oldName)
436
+ if (!entry) {
437
+ throw new Error(`SQLite container "${oldName}" not found in registry`)
438
+ }
439
+
440
+ // Move container directory first (if it exists) - do filesystem ops before registry
441
+ // This way if the move fails, registry is unchanged
442
+ const oldContainerPath = paths.getContainerPath(oldName, { engine })
443
+ const newContainerPath = paths.getContainerPath(newName, { engine })
444
+ if (existsSync(oldContainerPath)) {
445
+ await this.atomicMoveDirectory(oldContainerPath, newContainerPath)
446
+ }
447
+
448
+ // Now update registry - remove old entry and add new one with updated name
449
+ await sqliteRegistry.remove(oldName)
450
+ await sqliteRegistry.add({
451
+ name: newName,
452
+ filePath: entry.filePath,
453
+ created: entry.created,
454
+ lastVerified: entry.lastVerified,
455
+ })
456
+
457
+ // Return updated config
458
+ return {
459
+ ...sourceConfig,
460
+ name: newName,
461
+ }
462
+ }
463
+
464
+ // Server databases: check container is not running
339
465
  const running = await processManager.isRunning(oldName, { engine })
340
466
  if (running) {
341
467
  throw new Error(`Container "${oldName}" is running. Stop it first`)
@@ -345,8 +471,7 @@ export class ContainerManager {
345
471
  const oldPath = paths.getContainerPath(oldName, { engine })
346
472
  const newPath = paths.getContainerPath(newName, { engine })
347
473
 
348
- await cp(oldPath, newPath, { recursive: true })
349
- await rm(oldPath, { recursive: true, force: true })
474
+ await this.atomicMoveDirectory(oldPath, newPath)
350
475
 
351
476
  // Update config with new name
352
477
  const config = await this.getConfig(newName, { engine })
@@ -360,6 +485,40 @@ export class ContainerManager {
360
485
  return config
361
486
  }
362
487
 
488
+ /**
489
+ * Move a directory atomically when possible, with copy+delete fallback.
490
+ * Uses fs.rename which is atomic on same filesystem, falls back to
491
+ * copy+delete for cross-filesystem moves (with cleanup on failure).
492
+ */
493
+ private async atomicMoveDirectory(
494
+ sourcePath: string,
495
+ targetPath: string,
496
+ ): Promise<void> {
497
+ try {
498
+ // Try atomic rename first (only works on same filesystem)
499
+ await fsRename(sourcePath, targetPath)
500
+ } catch (err) {
501
+ const e = err as NodeJS.ErrnoException
502
+ if (e.code === 'EXDEV') {
503
+ // Cross-filesystem move - fall back to copy+delete
504
+ await cp(sourcePath, targetPath, { recursive: true })
505
+ try {
506
+ await rm(sourcePath, { recursive: true, force: true })
507
+ } catch {
508
+ // If delete fails after copy, we have duplicates
509
+ // Try to clean up the target to avoid inconsistency
510
+ await rm(targetPath, { recursive: true, force: true }).catch(() => {})
511
+ throw new Error(
512
+ `Failed to complete move: source and target may both exist. ` +
513
+ `Please manually remove one of: ${sourcePath} or ${targetPath}`,
514
+ )
515
+ }
516
+ } else {
517
+ throw err
518
+ }
519
+ }
520
+ }
521
+
363
522
  /**
364
523
  * Validate container name
365
524
  */
@@ -18,6 +18,7 @@ import {
18
18
  usqlDependency,
19
19
  pgcliDependency,
20
20
  mycliDependency,
21
+ litecliDependency,
21
22
  } from '../config/os-dependencies'
22
23
  import { platformService } from './platform-service'
23
24
  import { configManager } from './config-manager'
@@ -398,3 +399,20 @@ export function getMycliManualInstructions(
398
399
  ): string[] {
399
400
  return getManualInstallInstructions(mycliDependency, platform)
400
401
  }
402
+
403
+ export async function isLitecliInstalled(): Promise<boolean> {
404
+ const status = await checkDependency(litecliDependency)
405
+ return status.installed
406
+ }
407
+
408
+ export async function installLitecli(
409
+ packageManager: DetectedPackageManager,
410
+ ): Promise<InstallResult> {
411
+ return installDependency(litecliDependency, packageManager)
412
+ }
413
+
414
+ export function getLitecliManualInstructions(
415
+ platform: Platform = getCurrentPlatform(),
416
+ ): string[] {
417
+ return getManualInstallInstructions(litecliDependency, platform)
418
+ }