spindb 0.38.1 → 0.40.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.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/commands/backup.js +13 -2
  3. package/dist/cli/commands/backup.js.map +1 -1
  4. package/dist/cli/commands/clone.js +13 -1
  5. package/dist/cli/commands/clone.js.map +1 -1
  6. package/dist/cli/commands/connect.js +112 -40
  7. package/dist/cli/commands/connect.js.map +1 -1
  8. package/dist/cli/commands/delete.js +27 -0
  9. package/dist/cli/commands/delete.js.map +1 -1
  10. package/dist/cli/commands/engines.js +1 -1
  11. package/dist/cli/commands/engines.js.map +1 -1
  12. package/dist/cli/commands/export.js +12 -1
  13. package/dist/cli/commands/export.js.map +1 -1
  14. package/dist/cli/commands/info.js +54 -12
  15. package/dist/cli/commands/info.js.map +1 -1
  16. package/dist/cli/commands/link.js +215 -0
  17. package/dist/cli/commands/link.js.map +1 -0
  18. package/dist/cli/commands/list.js +28 -7
  19. package/dist/cli/commands/list.js.map +1 -1
  20. package/dist/cli/commands/logs.js +6 -0
  21. package/dist/cli/commands/logs.js.map +1 -1
  22. package/dist/cli/commands/menu/container-handlers.js +260 -32
  23. package/dist/cli/commands/menu/container-handlers.js.map +1 -1
  24. package/dist/cli/commands/menu/index.js +20 -4
  25. package/dist/cli/commands/menu/index.js.map +1 -1
  26. package/dist/cli/commands/menu/shell-handlers.js +206 -75
  27. package/dist/cli/commands/menu/shell-handlers.js.map +1 -1
  28. package/dist/cli/commands/query.js +35 -4
  29. package/dist/cli/commands/query.js.map +1 -1
  30. package/dist/cli/commands/restore.js +12 -1
  31. package/dist/cli/commands/restore.js.map +1 -1
  32. package/dist/cli/commands/run.js +6 -1
  33. package/dist/cli/commands/run.js.map +1 -1
  34. package/dist/cli/commands/start.js +17 -2
  35. package/dist/cli/commands/start.js.map +1 -1
  36. package/dist/cli/commands/stop.js +16 -1
  37. package/dist/cli/commands/stop.js.map +1 -1
  38. package/dist/cli/commands/url.js +59 -15
  39. package/dist/cli/commands/url.js.map +1 -1
  40. package/dist/cli/index.js +2 -0
  41. package/dist/cli/index.js.map +1 -1
  42. package/dist/config/version.js +1 -1
  43. package/dist/core/container-manager.js +15 -1
  44. package/dist/core/container-manager.js.map +1 -1
  45. package/dist/core/remote-container.js +228 -0
  46. package/dist/core/remote-container.js.map +1 -0
  47. package/dist/engines/ferretdb/index.js +29 -10
  48. package/dist/engines/ferretdb/index.js.map +1 -1
  49. package/dist/engines/ferretdb/restore.js.map +1 -1
  50. package/dist/engines/mariadb/index.js +10 -2
  51. package/dist/engines/mariadb/index.js.map +1 -1
  52. package/dist/engines/mongodb/index.js +25 -3
  53. package/dist/engines/mongodb/index.js.map +1 -1
  54. package/dist/engines/mysql/index.js +10 -2
  55. package/dist/engines/mysql/index.js.map +1 -1
  56. package/dist/engines/postgresql/index.js +14 -2
  57. package/dist/engines/postgresql/index.js.map +1 -1
  58. package/dist/engines/redis/index.js +8 -1
  59. package/dist/engines/redis/index.js.map +1 -1
  60. package/dist/engines/valkey/index.js +8 -1
  61. package/dist/engines/valkey/index.js.map +1 -1
  62. package/dist/types/index.js +6 -0
  63. package/dist/types/index.js.map +1 -1
  64. package/package.json +3 -2
@@ -25,12 +25,13 @@ import { handleOpenShell, handleCopyConnectionString, stopPgwebProcess, } from '
25
25
  import { getPgwebStatus } from '../../../core/pgweb-utils.js';
26
26
  import { generatePassword } from '../../../core/credential-generator.js';
27
27
  import { saveCredentials, credentialsExist, getDefaultUsername, } from '../../../core/credential-manager.js';
28
- import { UnsupportedOperationError, isValidUsername, } from '../../../core/error-handler.js';
28
+ import { UnsupportedOperationError, isValidUsername, logDebug, } from '../../../core/error-handler.js';
29
29
  import { handleRunSql, handleViewLogs } from './sql-handlers.js';
30
30
  import { handleBackupForContainer, handleRestoreForContainer, } from './backup-handlers.js';
31
31
  import { exportToDocker, getExportBackupPath, dockerExportExists, getDockerConnectionString, } from '../../../core/docker-exporter.js';
32
32
  import { getDefaultFormat } from '../../../config/backup-formats.js';
33
- import { Engine, isFileBasedEngine } from '../../../types/index.js';
33
+ import { parseConnectionString, detectEngineFromConnectionString, detectProvider, isLocalhost, generateRemoteContainerName, redactConnectionString, buildRemoteConfig, getDefaultPortForEngine, } from '../../../core/remote-container.js';
34
+ import { Engine, isFileBasedEngine, isRemoteContainer } from '../../../types/index.js';
34
35
  import { pressEnterToContinue } from './shared.js';
35
36
  import { getEngineIcon } from '../../constants.js';
36
37
  export async function handleCreate() {
@@ -357,6 +358,141 @@ export async function handleCreate() {
357
358
  }
358
359
  return containerNameFinal;
359
360
  }
361
+ export async function handleLinkRemote() {
362
+ console.log();
363
+ console.log(header('Link Remote Database'));
364
+ console.log();
365
+ // Step 1: Prompt for connection string
366
+ console.log(chalk.gray(' Passwords are automatically masked.'));
367
+ console.log();
368
+ const { connectionString } = await escapeablePrompt([
369
+ {
370
+ type: 'input',
371
+ name: 'connectionString',
372
+ message: 'Connection string:',
373
+ transformer: (input) => redactConnectionString(input.trim()),
374
+ validate: (input) => {
375
+ if (!input.trim())
376
+ return 'Connection string is required';
377
+ try {
378
+ parseConnectionString(input);
379
+ return true;
380
+ }
381
+ catch (error) {
382
+ return error.message;
383
+ }
384
+ },
385
+ },
386
+ ]);
387
+ const parsed = parseConnectionString(connectionString);
388
+ // Step 2: Detect engine
389
+ const detectedEngine = detectEngineFromConnectionString(connectionString);
390
+ let engine;
391
+ if (detectedEngine) {
392
+ engine = detectedEngine;
393
+ console.log(chalk.gray(` Detected engine: ${engine}`));
394
+ }
395
+ else {
396
+ console.log(uiWarning('Could not detect engine from connection string. Please specify.'));
397
+ const { engineInput } = await escapeablePrompt([
398
+ {
399
+ type: 'input',
400
+ name: 'engineInput',
401
+ message: 'Engine (postgresql, mysql, mongodb, redis):',
402
+ validate: (input) => {
403
+ if (!input.trim())
404
+ return 'Engine is required';
405
+ const values = Object.values(Engine);
406
+ if (!values.includes(input.toLowerCase())) {
407
+ return `Unknown engine. Valid: ${values.join(', ')}`;
408
+ }
409
+ return true;
410
+ },
411
+ },
412
+ ]);
413
+ engine = engineInput.toLowerCase();
414
+ }
415
+ // Extract details
416
+ const host = parsed.host;
417
+ const port = parsed.port ?? getDefaultPortForEngine(engine);
418
+ const database = parsed.database || 'default';
419
+ const provider = detectProvider(host);
420
+ // SpinDB collision check
421
+ if (isLocalhost(host) && port > 0) {
422
+ const containers = await containerManager.list();
423
+ const conflicting = containers.find((c) => c.engine === engine && c.port === port && c.status !== 'linked');
424
+ if (conflicting) {
425
+ console.log(uiError(`Port ${port} is already managed by SpinDB container "${conflicting.name}". Use "spindb connect ${conflicting.name}" instead.`));
426
+ await pressEnterToContinue();
427
+ return;
428
+ }
429
+ }
430
+ // Step 3: Container name
431
+ const defaultName = generateRemoteContainerName({
432
+ engine,
433
+ host,
434
+ database,
435
+ provider,
436
+ });
437
+ let containerName = await promptContainerName(defaultName);
438
+ if (!containerName)
439
+ return;
440
+ // Check uniqueness — re-prompt until unique (same pattern as create command)
441
+ while (await containerManager.exists(containerName, { engine })) {
442
+ console.log(chalk.yellow(` Container "${containerName}" already exists.`));
443
+ containerName = await promptContainerName();
444
+ if (!containerName)
445
+ return;
446
+ }
447
+ // Create container
448
+ const containerPath = paths.getContainerPath(containerName, { engine });
449
+ await mkdir(containerPath, { recursive: true });
450
+ const remoteConfig = buildRemoteConfig({
451
+ host,
452
+ connectionString,
453
+ provider,
454
+ });
455
+ const config = {
456
+ name: containerName,
457
+ engine,
458
+ version: 'unknown',
459
+ port,
460
+ database,
461
+ databases: [database],
462
+ created: new Date().toISOString(),
463
+ status: 'linked',
464
+ remote: remoteConfig,
465
+ };
466
+ await containerManager.saveConfig(containerName, { engine }, config);
467
+ // Save credentials — always use 'remote' as credential key for linked containers
468
+ try {
469
+ await saveCredentials(containerName, engine, {
470
+ username: 'remote',
471
+ password: parsed.password || '',
472
+ connectionString,
473
+ engine,
474
+ container: containerName,
475
+ database,
476
+ });
477
+ }
478
+ catch (credError) {
479
+ console.log(uiWarning('Could not save credentials. The full connection string may not be retrievable.'));
480
+ logDebug(`Credential save failed: ${credError.message}`);
481
+ }
482
+ console.log();
483
+ console.log(uiSuccess(`Linked remote database as "${containerName}"`));
484
+ console.log();
485
+ console.log(chalk.gray(' ') + chalk.white('Engine:'.padEnd(14)) + chalk.cyan(engine));
486
+ console.log(chalk.gray(' ') + chalk.white('Host:'.padEnd(14)) + chalk.cyan(host));
487
+ if (provider) {
488
+ console.log(chalk.gray(' ') +
489
+ chalk.white('Provider:'.padEnd(14)) +
490
+ chalk.magenta(provider));
491
+ }
492
+ console.log();
493
+ await pressEnterToContinue();
494
+ return containerName;
495
+ }
360
496
  export async function handleList(showMainMenu, options) {
361
497
  console.clear();
362
498
  console.log(header('Containers'));
@@ -400,20 +536,27 @@ export async function handleList(showMainMenu, options) {
400
536
  const containerChoices = containers.map((c, i) => {
401
537
  const size = sizes[i];
402
538
  const isFileBased = isFileBasedEngine(c.engine);
539
+ const isLinked = c.status === 'linked';
403
540
  // Status display
404
- const statusDisplay = isFileBased
405
- ? c.status === 'running'
406
- ? chalk.blue('● available')
407
- : chalk.gray('○ missing')
408
- : c.status === 'running'
409
- ? chalk.green(' running')
410
- : chalk.gray('○ stopped');
541
+ const statusDisplay = isLinked
542
+ ? chalk.magenta('↔ linked')
543
+ : isFileBased
544
+ ? c.status === 'running'
545
+ ? chalk.blue('● available')
546
+ : chalk.gray(' missing')
547
+ : c.status === 'running'
548
+ ? chalk.green('● running')
549
+ : chalk.gray('○ stopped');
411
550
  // Truncate name if too long
412
551
  const displayName = c.name.length > COL_NAME - 1
413
552
  ? c.name.slice(0, COL_NAME - 2) + '…'
414
553
  : c.name;
415
- // Port or dash for file-based
416
- const portDisplay = isFileBased ? '—' : String(c.port);
554
+ // Port, provider for linked, or dash for file-based
555
+ const portDisplay = isLinked
556
+ ? (c.remote?.provider || 'remote').slice(0, COL_PORT - 1)
557
+ : isFileBased
558
+ ? '—'
559
+ : String(c.port);
417
560
  // Size display
418
561
  const sizeDisplay = size !== null ? formatBytes(size) : '—';
419
562
  // Build formatted row
@@ -437,8 +580,10 @@ export async function handleList(showMainMenu, options) {
437
580
  };
438
581
  });
439
582
  // Calculate summary
440
- const serverContainers = containers.filter((c) => !isFileBasedEngine(c.engine));
441
- const fileBasedContainers = containers.filter((c) => isFileBasedEngine(c.engine));
583
+ const linkedContainers = containers.filter((c) => c.status === 'linked');
584
+ const localContainers = containers.filter((c) => c.status !== 'linked');
585
+ const serverContainers = localContainers.filter((c) => !isFileBasedEngine(c.engine));
586
+ const fileBasedContainers = localContainers.filter((c) => isFileBasedEngine(c.engine));
442
587
  const running = serverContainers.filter((c) => c.status === 'running').length;
443
588
  const stopped = serverContainers.filter((c) => c.status !== 'running').length;
444
589
  const available = fileBasedContainers.filter((c) => c.status === 'running').length;
@@ -450,8 +595,11 @@ export async function handleList(showMainMenu, options) {
450
595
  if (fileBasedContainers.length > 0) {
451
596
  parts.push(`${available} file-based available${missing > 0 ? `, ${missing} missing` : ''}`);
452
597
  }
453
- // Check if there are any server-based (toggleable) containers
454
- const hasServerContainers = containers.some((c) => !isFileBasedEngine(c.engine));
598
+ if (linkedContainers.length > 0) {
599
+ parts.push(`${linkedContainers.length} linked`);
600
+ }
601
+ // Check if there are any server-based (toggleable) containers (exclude linked)
602
+ const hasServerContainers = containers.some((c) => !isFileBasedEngine(c.engine) && c.status !== 'linked');
455
603
  // Build the full choice list with footer items
456
604
  // IMPORTANT: Containers must come FIRST because filterableCount slices from index 0
457
605
  const summary = `${containers.length} container(s): ${parts.join('; ')}`;
@@ -484,7 +632,9 @@ export async function handleList(showMainMenu, options) {
484
632
  if (selectedContainer.startsWith(TOGGLE_PREFIX)) {
485
633
  const containerName = selectedContainer.slice(TOGGLE_PREFIX.length);
486
634
  const config = await containerManager.getConfig(containerName);
487
- if (config && !isFileBasedEngine(config.engine)) {
635
+ if (config &&
636
+ !isFileBasedEngine(config.engine) &&
637
+ !isRemoteContainer(config)) {
488
638
  const isRunning = await processManager.isRunning(containerName, {
489
639
  engine: config.engine,
490
640
  });
@@ -530,6 +680,7 @@ export async function showContainerSubmenu(containerName, showMainMenu, selected
530
680
  console.error(uiError(`Container "${containerName}" not found`));
531
681
  return;
532
682
  }
683
+ const isRemote = isRemoteContainer(config);
533
684
  // File-based databases: Check file existence instead of running status
534
685
  const isSQLite = config.engine === Engine.SQLite;
535
686
  const isDuckDB = config.engine === Engine.DuckDB;
@@ -537,7 +688,12 @@ export async function showContainerSubmenu(containerName, showMainMenu, selected
537
688
  let isRunning;
538
689
  let status;
539
690
  let locationInfo;
540
- if (isFileBasedDB) {
691
+ if (isRemote) {
692
+ isRunning = false;
693
+ status = 'linked';
694
+ locationInfo = `→ ${config.remote?.provider || config.remote?.host || 'remote'}`;
695
+ }
696
+ else if (isFileBasedDB) {
541
697
  const fileExists = existsSync(config.database);
542
698
  isRunning = fileExists; // For file-based DBs, "running" means "file exists"
543
699
  status = fileExists ? 'available' : 'missing';
@@ -571,6 +727,63 @@ export async function showContainerSubmenu(containerName, showMainMenu, selected
571
727
  disabled: '', // Empty string hides the "(Disabled)" text
572
728
  };
573
729
  }
730
+ // Remote containers get a simplified action set
731
+ if (isRemote) {
732
+ actionChoices.push(new inquirer.Separator(chalk.gray(`── Linked ──`)));
733
+ // Connect (open console)
734
+ actionChoices.push({
735
+ name: `${chalk.blue('>')} Open console`,
736
+ value: 'shell',
737
+ });
738
+ // Copy connection string
739
+ actionChoices.push({
740
+ name: `${chalk.green('⎘')} Copy connection string`,
741
+ value: 'copy',
742
+ });
743
+ actionChoices.push(new inquirer.Separator());
744
+ // Unlink (delete)
745
+ actionChoices.push({
746
+ name: `${chalk.red('✕')} Unlink remote database`,
747
+ value: 'delete',
748
+ });
749
+ actionChoices.push(new inquirer.Separator());
750
+ // Navigation
751
+ actionChoices.push({
752
+ name: `${chalk.blue('←')} Back to containers`,
753
+ value: 'back',
754
+ }, {
755
+ name: `${chalk.blue('⌂')} Back to main menu ${chalk.gray('(esc)')}`,
756
+ value: 'main',
757
+ }, new inquirer.Separator());
758
+ const { action } = await escapeablePrompt([
759
+ {
760
+ type: 'list',
761
+ name: 'action',
762
+ message: 'What would you like to do?',
763
+ choices: actionChoices,
764
+ pageSize: getPageSize(),
765
+ },
766
+ ]);
767
+ switch (action) {
768
+ case 'shell':
769
+ await handleOpenShell(containerName, activeDatabase);
770
+ await showContainerSubmenu(containerName, showMainMenu, activeDatabase);
771
+ return;
772
+ case 'copy':
773
+ await handleCopyConnectionString(containerName, activeDatabase);
774
+ await showContainerSubmenu(containerName, showMainMenu, activeDatabase);
775
+ return;
776
+ case 'delete':
777
+ await handleDelete(containerName);
778
+ return;
779
+ case 'back':
780
+ await handleList(showMainMenu);
781
+ return;
782
+ case 'main':
783
+ return;
784
+ }
785
+ return;
786
+ }
574
787
  // Determine if database-specific actions can be performed
575
788
  // Requires: database selected + (running for server DBs OR file exists for file-based DBs)
576
789
  const containerReady = isFileBasedDB ? existsSync(config.database) : isRunning;
@@ -838,8 +1051,10 @@ export async function showContainerSubmenu(containerName, showMainMenu, selected
838
1051
  }
839
1052
  export async function handleStart() {
840
1053
  const containers = await containerManager.list();
841
- // Filter for stopped containers, excluding file-based DBs (no server process to start)
842
- const stopped = containers.filter((c) => c.status !== 'running' && !isFileBasedEngine(c.engine));
1054
+ // Filter for stopped containers, excluding file-based DBs and linked containers
1055
+ const stopped = containers.filter((c) => c.status !== 'running' &&
1056
+ c.status !== 'linked' &&
1057
+ !isFileBasedEngine(c.engine));
843
1058
  if (stopped.length === 0) {
844
1059
  console.log(uiWarning('All containers are already running'));
845
1060
  return;
@@ -1397,25 +1612,38 @@ async function handleDelete(containerName) {
1397
1612
  console.error(uiError(`Container "${containerName}" not found`));
1398
1613
  return;
1399
1614
  }
1400
- const confirmed = await promptConfirm(`Are you sure you want to delete "${containerName}"? This cannot be undone.`, false);
1615
+ const isRemote = isRemoteContainer(config);
1616
+ const confirmMsg = isRemote
1617
+ ? `Unlink "${containerName}"? The remote database will not be affected.`
1618
+ : `Are you sure you want to delete "${containerName}"? This cannot be undone.`;
1619
+ const confirmed = await promptConfirm(confirmMsg, false);
1401
1620
  if (!confirmed) {
1402
- console.log(uiWarning('Deletion cancelled'));
1621
+ console.log(uiWarning(isRemote ? 'Unlink cancelled' : 'Deletion cancelled'));
1403
1622
  return;
1404
1623
  }
1405
- const isRunning = await processManager.isRunning(containerName, {
1406
- engine: config.engine,
1407
- });
1408
- if (isRunning) {
1409
- const stopSpinner = createSpinner(`Stopping ${containerName}...`);
1410
- stopSpinner.start();
1411
- const engine = getEngine(config.engine);
1412
- await engine.stop(config);
1413
- stopSpinner.succeed(`Stopped "${containerName}"`);
1624
+ // Remote containers: skip process checks
1625
+ if (!isRemote) {
1626
+ const running = await processManager.isRunning(containerName, {
1627
+ engine: config.engine,
1628
+ });
1629
+ if (running) {
1630
+ const stopSpinner = createSpinner(`Stopping ${containerName}...`);
1631
+ stopSpinner.start();
1632
+ const engine = getEngine(config.engine);
1633
+ await engine.stop(config);
1634
+ stopSpinner.succeed(`Stopped "${containerName}"`);
1635
+ }
1414
1636
  }
1415
- const deleteSpinner = createSpinner(`Deleting ${containerName}...`);
1637
+ const deleteSpinner = createSpinner(isRemote ? `Unlinking ${containerName}...` : `Deleting ${containerName}...`);
1416
1638
  deleteSpinner.start();
1417
1639
  await containerManager.delete(containerName, { force: true });
1418
- deleteSpinner.succeed(`Container "${containerName}" deleted`);
1640
+ if (isRemote) {
1641
+ deleteSpinner.succeed(`Unlinked "${containerName}"`);
1642
+ console.log(chalk.gray(' The remote database is not affected.'));
1643
+ }
1644
+ else {
1645
+ deleteSpinner.succeed(`Container "${containerName}" deleted`);
1646
+ }
1419
1647
  }
1420
1648
  async function isDockerContainerRunning(containerName) {
1421
1649
  try {