spindb 0.34.3 β†’ 0.35.2

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 +4 -4
  2. package/cli/commands/attach.ts +38 -9
  3. package/cli/commands/backups.ts +5 -0
  4. package/cli/commands/connect.ts +6 -6
  5. package/cli/commands/create.ts +22 -1
  6. package/cli/commands/detach.ts +16 -9
  7. package/cli/commands/doctor.ts +2 -2
  8. package/cli/commands/duckdb.ts +273 -0
  9. package/cli/commands/edit.ts +31 -21
  10. package/cli/commands/engines.ts +51 -21
  11. package/cli/commands/info.ts +26 -16
  12. package/cli/commands/list.ts +44 -26
  13. package/cli/commands/menu/container-handlers.ts +17 -1
  14. package/cli/commands/menu/engine-handlers.ts +48 -29
  15. package/cli/commands/menu/update-handlers.ts +2 -2
  16. package/cli/commands/sqlite.ts +21 -0
  17. package/cli/index.ts +2 -0
  18. package/cli/ui/theme.ts +5 -2
  19. package/config/engines.json +2 -2
  20. package/core/base-binary-manager.ts +6 -2
  21. package/core/base-document-binary-manager.ts +5 -2
  22. package/core/base-embedded-binary-manager.ts +5 -2
  23. package/core/base-server-binary-manager.ts +5 -2
  24. package/core/hostdb-client.ts +157 -22
  25. package/core/hostdb-metadata.ts +67 -43
  26. package/engines/clickhouse/binary-urls.ts +1 -1
  27. package/engines/cockroachdb/binary-urls.ts +9 -7
  28. package/engines/cockroachdb/hostdb-releases.ts +18 -106
  29. package/engines/cockroachdb/version-maps.ts +1 -1
  30. package/engines/couchdb/binary-urls.ts +1 -1
  31. package/engines/duckdb/binary-urls.ts +1 -1
  32. package/engines/duckdb/index.ts +4 -74
  33. package/engines/duckdb/scanner.ts +22 -0
  34. package/engines/ferretdb/README.md +76 -38
  35. package/engines/ferretdb/backup.ts +18 -10
  36. package/engines/ferretdb/binary-manager.ts +233 -35
  37. package/engines/ferretdb/binary-urls.ts +69 -24
  38. package/engines/ferretdb/index.ts +424 -213
  39. package/engines/ferretdb/restore.ts +23 -16
  40. package/engines/ferretdb/version-maps.ts +36 -8
  41. package/engines/file-based-utils.ts +262 -0
  42. package/engines/index.ts +3 -4
  43. package/engines/influxdb/binary-urls.ts +1 -1
  44. package/engines/mariadb/binary-urls.ts +2 -2
  45. package/engines/meilisearch/binary-urls.ts +1 -1
  46. package/engines/mysql/binary-urls.ts +2 -2
  47. package/engines/postgresql/binary-urls.ts +1 -1
  48. package/engines/qdrant/binary-urls.ts +1 -1
  49. package/engines/questdb/binary-manager.ts +16 -9
  50. package/engines/questdb/binary-urls.ts +9 -10
  51. package/engines/questdb/hostdb-releases.ts +19 -97
  52. package/engines/questdb/version-maps.ts +2 -2
  53. package/engines/redis/binary-urls.ts +1 -8
  54. package/engines/sqlite/binary-urls.ts +1 -1
  55. package/engines/sqlite/index.ts +4 -74
  56. package/engines/sqlite/scanner.ts +11 -88
  57. package/engines/surrealdb/binary-urls.ts +9 -7
  58. package/engines/surrealdb/hostdb-releases.ts +18 -106
  59. package/engines/surrealdb/version-maps.ts +1 -1
  60. package/engines/typedb/binary-urls.ts +10 -8
  61. package/engines/typedb/hostdb-releases.ts +18 -113
  62. package/engines/typedb/version-maps.ts +1 -1
  63. package/engines/valkey/binary-urls.ts +1 -1
  64. package/package.json +4 -1
package/README.md CHANGED
@@ -38,7 +38,7 @@ SpinDB supports **18 database engines** across **5 platform architectures**β€”al
38
38
  | πŸͺΆ **SQLite** | Embedded SQL | βœ… | βœ… | βœ… | βœ… | βœ… |
39
39
  | πŸ¦† **DuckDB** | Embedded OLAP | βœ… | βœ… | βœ… | βœ… | βœ… |
40
40
  | πŸƒ **MongoDB** | Document Store | βœ… | βœ… | βœ… | βœ… | βœ… |
41
- | πŸ¦” **FerretDB** | Document Store | βœ… | βœ… | βœ… | βœ… | ❌ |
41
+ | πŸ¦” **FerretDB** | Document Store | βœ… | βœ… | βœ… | βœ… | ⚠️ |
42
42
  | πŸ”΄ **Redis** | Key-Value | βœ… | βœ… | βœ… | βœ… | βœ… |
43
43
  | πŸ”· **Valkey** | Key-Value | βœ… | βœ… | βœ… | βœ… | βœ… |
44
44
  | 🏠 **ClickHouse** | Columnar OLAP | βœ… | βœ… | βœ… | βœ… | ❌ |
@@ -51,9 +51,9 @@ SpinDB supports **18 database engines** across **5 platform architectures**β€”al
51
51
  | πŸ€– **TypeDB** | Knowledge Graph | βœ… | βœ… | βœ… | βœ… | βœ… |
52
52
  | πŸ“ˆ **InfluxDB** | Time-Series | βœ… | βœ… | βœ… | βœ… | βœ… |
53
53
 
54
- **88 combinations. One CLI. Zero configuration.**
54
+ **89 combinations. One CLI. Zero configuration.**
55
55
 
56
- > ClickHouse and FerretDB are available on Windows via WSL.
56
+ > ClickHouse is available on Windows via WSL. FerretDB v1 is natively supported on Windows (uses plain PostgreSQL backend); v2 requires macOS/Linux.
57
57
 
58
58
  ---
59
59
 
@@ -272,7 +272,7 @@ See [DEPLOY.md](DEPLOY.md) for comprehensive deployment documentation.
272
272
 
273
273
  - **Local only** - Databases bind to `127.0.0.1`. Remote connection support planned for v1.1.
274
274
  - **ClickHouse Windows** - Not supported (hostdb doesn't build for Windows).
275
- - **FerretDB Windows** - Not supported (postgresql-documentdb has startup issues on Windows).
275
+ - **FerretDB Windows** - v1 supported natively (plain PostgreSQL backend). v2 not supported (postgresql-documentdb has startup issues); use WSL for v2.
276
276
  - **Qdrant, Meilisearch, CouchDB** - Use REST API instead of CLI shell. Access via HTTP at the configured port.
277
277
 
278
278
  ---
@@ -2,14 +2,24 @@ import { Command } from 'commander'
2
2
  import { existsSync } from 'fs'
3
3
  import { resolve, basename } from 'path'
4
4
  import chalk from 'chalk'
5
- import { sqliteRegistry } from '../../engines/sqlite/registry'
6
5
  import { containerManager } from '../../core/container-manager'
7
- import { deriveContainerName } from '../../engines/sqlite/scanner'
6
+ import {
7
+ detectEngineFromPath,
8
+ getRegistryForEngine,
9
+ deriveContainerName,
10
+ formatAllExtensions,
11
+ } from '../../engines/file-based-utils'
8
12
  import { uiSuccess, uiError } from '../ui/theme'
13
+ import type { Engine } from '../../types'
9
14
 
10
15
  export const attachCommand = new Command('attach')
11
- .description('Register an existing SQLite database with SpinDB')
12
- .argument('<path>', 'Path to SQLite database file')
16
+ .description(
17
+ 'Register an existing file-based database with SpinDB (SQLite or DuckDB)',
18
+ )
19
+ .argument(
20
+ '<path>',
21
+ 'Path to database file (.sqlite, .db, .sqlite3, .duckdb, .ddb)',
22
+ )
13
23
  .option('-n, --name <name>', 'Container name (defaults to filename)')
14
24
  .option('--json', 'Output as JSON')
15
25
  .action(
@@ -20,6 +30,20 @@ export const attachCommand = new Command('attach')
20
30
  try {
21
31
  const absolutePath = resolve(path)
22
32
 
33
+ // Detect engine from file extension
34
+ const engine = detectEngineFromPath(absolutePath)
35
+ if (!engine) {
36
+ const msg = `Unrecognized file extension. Expected one of: ${formatAllExtensions()}`
37
+ if (options.json) {
38
+ console.log(JSON.stringify({ success: false, error: msg }))
39
+ } else {
40
+ console.error(uiError(msg))
41
+ }
42
+ process.exit(1)
43
+ }
44
+
45
+ const registry = getRegistryForEngine(engine)
46
+
23
47
  // Verify file exists
24
48
  if (!existsSync(absolutePath)) {
25
49
  if (options.json) {
@@ -33,8 +57,8 @@ export const attachCommand = new Command('attach')
33
57
  }
34
58
 
35
59
  // Check if already registered
36
- if (await sqliteRegistry.isPathRegistered(absolutePath)) {
37
- const entry = await sqliteRegistry.getByPath(absolutePath)
60
+ if (await registry.isPathRegistered(absolutePath)) {
61
+ const entry = await registry.getByPath(absolutePath)
38
62
  if (options.json) {
39
63
  console.log(
40
64
  JSON.stringify({
@@ -53,7 +77,11 @@ export const attachCommand = new Command('attach')
53
77
 
54
78
  // Determine container name
55
79
  const containerName =
56
- options.name || deriveContainerName(basename(absolutePath))
80
+ options.name ||
81
+ deriveContainerName(
82
+ basename(absolutePath),
83
+ engine as Engine.SQLite | Engine.DuckDB,
84
+ )
57
85
 
58
86
  // Check if container name exists
59
87
  if (await containerManager.exists(containerName)) {
@@ -73,7 +101,7 @@ export const attachCommand = new Command('attach')
73
101
  }
74
102
 
75
103
  // Register the file
76
- await sqliteRegistry.add({
104
+ await registry.add({
77
105
  name: containerName,
78
106
  filePath: absolutePath,
79
107
  created: new Date().toISOString(),
@@ -83,6 +111,7 @@ export const attachCommand = new Command('attach')
83
111
  console.log(
84
112
  JSON.stringify({
85
113
  success: true,
114
+ engine,
86
115
  name: containerName,
87
116
  filePath: absolutePath,
88
117
  }),
@@ -90,7 +119,7 @@ export const attachCommand = new Command('attach')
90
119
  } else {
91
120
  console.log(
92
121
  uiSuccess(
93
- `Registered "${basename(absolutePath)}" as "${containerName}"`,
122
+ `Registered "${basename(absolutePath)}" as "${containerName}" (${engine})`,
94
123
  ),
95
124
  )
96
125
  console.log()
@@ -44,6 +44,9 @@ function detectBackupType(filename: string): {
44
44
  case '.db':
45
45
  case '.sqlite3':
46
46
  return { engine: 'sqlite', format: 'Binary copy' }
47
+ case '.duckdb':
48
+ case '.ddb':
49
+ return { engine: 'duckdb', format: 'Binary copy' }
47
50
  case '.archive':
48
51
  return { engine: 'mongodb', format: 'BSON archive' }
49
52
  case '.rdb':
@@ -65,6 +68,8 @@ function isBackupFile(filename: string): boolean {
65
68
  '.sqlite',
66
69
  '.sqlite3',
67
70
  '.db',
71
+ '.duckdb',
72
+ '.ddb',
68
73
  '.archive',
69
74
  '.rdb',
70
75
  '.redis',
@@ -26,7 +26,7 @@ import { getEngine } from '../../engines'
26
26
  import { getEngineDefaults } from '../../config/defaults'
27
27
  import { promptContainerSelect } from '../ui/prompts'
28
28
  import { uiError, uiWarning, uiInfo, uiSuccess } from '../ui/theme'
29
- import { Engine } from '../../types'
29
+ import { Engine, isFileBasedEngine } from '../../types'
30
30
  import { configManager } from '../../core/config-manager'
31
31
  import { DBLAB_ENGINES, getDblabArgs } from '../../core/dblab-utils'
32
32
  import { downloadDblabCli } from './menu/shell-handlers'
@@ -86,9 +86,9 @@ export const connectCommand = new Command('connect')
86
86
 
87
87
  if (!containerName) {
88
88
  const containers = await containerManager.list()
89
- // SQLite containers are always "available" if file exists, server containers need to be running
89
+ // File-based containers are always "available" if file exists, server containers need to be running
90
90
  const connectable = containers.filter((c) => {
91
- if (c.engine === Engine.SQLite) {
91
+ if (isFileBasedEngine(c.engine)) {
92
92
  return existsSync(c.database)
93
93
  }
94
94
  return c.status === 'running'
@@ -131,11 +131,11 @@ export const connectCommand = new Command('connect')
131
131
  const database =
132
132
  options.database ?? config.database ?? engineDefaults.superuser
133
133
 
134
- // SQLite: check file exists instead of running status
135
- if (engineName === Engine.SQLite) {
134
+ // File-based engines: check file exists instead of running status
135
+ if (isFileBasedEngine(engineName)) {
136
136
  if (!existsSync(config.database)) {
137
137
  console.error(
138
- uiError(`SQLite database file not found: ${config.database}`),
138
+ uiError(`Database file not found: ${config.database}`),
139
139
  )
140
140
  process.exit(1)
141
141
  }
@@ -22,7 +22,11 @@ import { startWithRetry } from '../../core/start-with-retry'
22
22
  import { TransactionManager } from '../../core/transaction-manager'
23
23
  import { isValidDatabaseName, exitWithError } from '../../core/error-handler'
24
24
  import { resolve } from 'path'
25
- import { Engine } from '../../types'
25
+ import { Engine, Platform } from '../../types'
26
+ import {
27
+ FERRETDB_VERSION_MAP,
28
+ isV1 as isFerretDBv1,
29
+ } from '../../engines/ferretdb/version-maps'
26
30
  import type { BaseEngine } from '../../engines/base-engine'
27
31
 
28
32
  /**
@@ -535,6 +539,23 @@ export const createCommand = new Command('create')
535
539
  database = answers.database
536
540
  }
537
541
 
542
+ // FerretDB: force v1 on Windows (v2 requires postgresql-documentdb, not available on Windows)
543
+ // Runs after both CLI and interactive paths have resolved engine + version
544
+ if (
545
+ engine === Engine.FerretDB &&
546
+ !isFerretDBv1(version) &&
547
+ platformService.getPlatformInfo().platform === Platform.Win32
548
+ ) {
549
+ version = FERRETDB_VERSION_MAP['1']
550
+ if (!options.json) {
551
+ console.log(
552
+ chalk.yellow(
553
+ ` FerretDB v2 is not supported on Windows β€” using v1 (${version})`,
554
+ ),
555
+ )
556
+ }
557
+ }
558
+
538
559
  // Redis/Valkey use numbered databases (0-15), default to "0"
539
560
  // Other engines default to container name (with hyphens replaced by underscores for SQL compatibility)
540
561
  if (engine === Engine.Redis || engine === Engine.Valkey) {
@@ -1,13 +1,15 @@
1
1
  import { Command } from 'commander'
2
2
  import chalk from 'chalk'
3
- import { sqliteRegistry } from '../../engines/sqlite/registry'
4
3
  import { containerManager } from '../../core/container-manager'
4
+ import { getRegistryForEngine } from '../../engines/file-based-utils'
5
5
  import { promptConfirm } from '../ui/prompts'
6
6
  import { uiSuccess, uiError, uiWarning } from '../ui/theme'
7
- import { Engine } from '../../types'
7
+ import { isFileBasedEngine } from '../../types'
8
8
 
9
9
  export const detachCommand = new Command('detach')
10
- .description('Unregister a SQLite database from SpinDB (keeps file on disk)')
10
+ .description(
11
+ 'Unregister a file-based database from SpinDB (keeps file on disk)',
12
+ )
11
13
  .argument('<name>', 'Container name')
12
14
  .option('-f, --force', 'Skip confirmation prompt')
13
15
  .option('--json', 'Output as JSON')
@@ -31,18 +33,22 @@ export const detachCommand = new Command('detach')
31
33
  process.exit(1)
32
34
  }
33
35
 
34
- // Verify it's a SQLite container
35
- if (config.engine !== Engine.SQLite) {
36
+ // Verify it's a file-based container
37
+ if (!isFileBasedEngine(config.engine)) {
36
38
  if (options.json) {
37
39
  console.log(
38
40
  JSON.stringify({
39
41
  success: false,
40
42
  error:
41
- 'Not a SQLite container. Use "spindb delete" for server databases.',
43
+ 'Not a file-based container. Use "spindb delete" for server databases.',
42
44
  }),
43
45
  )
44
46
  } else {
45
- console.error(uiError(`"${name}" is not a SQLite container`))
47
+ console.error(
48
+ uiError(
49
+ `"${name}" is not a file-based container (SQLite/DuckDB)`,
50
+ ),
51
+ )
46
52
  console.log(
47
53
  chalk.gray(
48
54
  ' Use "spindb delete" for server databases (PostgreSQL, MySQL)',
@@ -64,11 +70,12 @@ export const detachCommand = new Command('detach')
64
70
  }
65
71
  }
66
72
 
67
- const entry = await sqliteRegistry.get(name)
73
+ const registry = getRegistryForEngine(config.engine)
74
+ const entry = await registry.get(name)
68
75
  const filePath = entry?.filePath
69
76
 
70
77
  // Remove from registry only (not the file)
71
- await sqliteRegistry.remove(name)
78
+ await registry.remove(name)
72
79
 
73
80
  if (options.json) {
74
81
  console.log(
@@ -24,7 +24,7 @@ import { paths } from '../../config/paths'
24
24
  import { getSupportedEngines } from '../../config/engine-defaults'
25
25
  import { checkEngineDependencies } from '../../core/dependency-manager'
26
26
  import { header, uiSuccess } from '../ui/theme'
27
- import { Engine } from '../../types'
27
+ import { type Engine, isFileBasedEngine } from '../../types'
28
28
  import {
29
29
  findOutdatedContainers,
30
30
  migrateContainerVersion,
@@ -178,7 +178,7 @@ async function checkContainers(): Promise<HealthCheckResult> {
178
178
  }
179
179
 
180
180
  const details = Object.entries(byEngine).map(([engine, counts]) => {
181
- if (engine === Engine.SQLite) {
181
+ if (isFileBasedEngine(engine as Engine)) {
182
182
  return `${engine}: ${counts.running} exist, ${counts.stopped} missing`
183
183
  }
184
184
  return `${engine}: ${counts.running} running, ${counts.stopped} stopped`
@@ -0,0 +1,273 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import { existsSync } from 'fs'
4
+ import { resolve, basename } from 'path'
5
+ import { duckdbRegistry } from '../../engines/duckdb/registry'
6
+ import {
7
+ scanForUnregisteredDuckDBFiles,
8
+ deriveContainerName,
9
+ } from '../../engines/duckdb/scanner'
10
+ import {
11
+ isValidExtensionForEngine,
12
+ formatExtensionsForEngine,
13
+ } from '../../engines/file-based-utils'
14
+ import { containerManager } from '../../core/container-manager'
15
+ import { uiSuccess, uiError, uiInfo } from '../ui/theme'
16
+ import { Engine } from '../../types'
17
+ import { detachCommand } from './detach'
18
+
19
+ export const duckdbCommand = new Command('duckdb').description(
20
+ 'DuckDB-specific operations',
21
+ )
22
+
23
+ // duckdb scan
24
+ duckdbCommand
25
+ .command('scan')
26
+ .description('Scan folder for unregistered DuckDB files')
27
+ .option('-p, --path <dir>', 'Directory to scan (default: current directory)')
28
+ .option('--json', 'Output as JSON')
29
+ .action(async (options: { path?: string; json?: boolean }): Promise<void> => {
30
+ const dir = options.path ? resolve(options.path) : process.cwd()
31
+
32
+ if (!existsSync(dir)) {
33
+ if (options.json) {
34
+ console.log(
35
+ JSON.stringify({ error: 'Directory not found', directory: dir }),
36
+ )
37
+ } else {
38
+ console.error(uiError(`Directory not found: ${dir}`))
39
+ }
40
+ process.exit(1)
41
+ }
42
+
43
+ const unregistered = await scanForUnregisteredDuckDBFiles(dir)
44
+
45
+ if (options.json) {
46
+ console.log(JSON.stringify({ directory: dir, files: unregistered }))
47
+ return
48
+ }
49
+
50
+ if (unregistered.length === 0) {
51
+ console.log(uiInfo(`No unregistered DuckDB files found in ${dir}`))
52
+ return
53
+ }
54
+
55
+ console.log(
56
+ chalk.cyan(`Found ${unregistered.length} unregistered DuckDB file(s):`),
57
+ )
58
+ for (const file of unregistered) {
59
+ console.log(chalk.gray(` ${file.fileName}`))
60
+ }
61
+ console.log()
62
+ console.log(chalk.gray(' Register with: spindb attach <path>'))
63
+ })
64
+
65
+ // duckdb ignore
66
+ duckdbCommand
67
+ .command('ignore')
68
+ .description('Add folder to ignore list for CWD scanning')
69
+ .argument('[folder]', 'Folder path to ignore (default: current directory)')
70
+ .option('--json', 'Output as JSON')
71
+ .action(
72
+ async (
73
+ folder: string | undefined,
74
+ options: { json?: boolean },
75
+ ): Promise<void> => {
76
+ const absolutePath = resolve(folder || process.cwd())
77
+ await duckdbRegistry.addIgnoreFolder(absolutePath)
78
+
79
+ if (options.json) {
80
+ console.log(JSON.stringify({ success: true, folder: absolutePath }))
81
+ } else {
82
+ console.log(uiSuccess(`Added to ignore list: ${absolutePath}`))
83
+ }
84
+ },
85
+ )
86
+
87
+ // duckdb unignore
88
+ duckdbCommand
89
+ .command('unignore')
90
+ .description('Remove folder from ignore list')
91
+ .argument('[folder]', 'Folder path to unignore (default: current directory)')
92
+ .option('--json', 'Output as JSON')
93
+ .action(
94
+ async (
95
+ folder: string | undefined,
96
+ options: { json?: boolean },
97
+ ): Promise<void> => {
98
+ const absolutePath = resolve(folder || process.cwd())
99
+ const removed = await duckdbRegistry.removeIgnoreFolder(absolutePath)
100
+
101
+ if (options.json) {
102
+ console.log(JSON.stringify({ success: removed, folder: absolutePath }))
103
+ } else {
104
+ if (removed) {
105
+ console.log(uiSuccess(`Removed from ignore list: ${absolutePath}`))
106
+ } else {
107
+ console.log(uiInfo(`Folder was not in ignore list: ${absolutePath}`))
108
+ }
109
+ }
110
+ },
111
+ )
112
+
113
+ // duckdb ignored (list ignored folders)
114
+ duckdbCommand
115
+ .command('ignored')
116
+ .description('List ignored folders')
117
+ .option('--json', 'Output as JSON')
118
+ .action(async (options: { json?: boolean }): Promise<void> => {
119
+ const folders = await duckdbRegistry.listIgnoredFolders()
120
+
121
+ if (options.json) {
122
+ console.log(JSON.stringify({ folders }))
123
+ return
124
+ }
125
+
126
+ if (folders.length === 0) {
127
+ console.log(uiInfo('No folders are being ignored'))
128
+ return
129
+ }
130
+
131
+ console.log(chalk.cyan('Ignored folders:'))
132
+ for (const folder of folders) {
133
+ console.log(chalk.gray(` ${folder}`))
134
+ }
135
+ })
136
+
137
+ // duckdb attach (alias to top-level attach)
138
+ duckdbCommand
139
+ .command('attach')
140
+ .description(
141
+ 'Register an existing DuckDB database (alias for "spindb attach")',
142
+ )
143
+ .argument('<path>', 'Path to DuckDB database file')
144
+ .option('-n, --name <name>', 'Container name')
145
+ .option('--json', 'Output as JSON')
146
+ .action(
147
+ async (
148
+ path: string,
149
+ options: { name?: string; json?: boolean },
150
+ ): Promise<void> => {
151
+ try {
152
+ const absolutePath = resolve(path)
153
+
154
+ // Validate extension matches DuckDB
155
+ if (!isValidExtensionForEngine(absolutePath, Engine.DuckDB)) {
156
+ const msg = `File extension must be one of: ${formatExtensionsForEngine(Engine.DuckDB)}`
157
+ if (options.json) {
158
+ console.log(JSON.stringify({ success: false, error: msg }))
159
+ } else {
160
+ console.error(uiError(msg))
161
+ console.log(
162
+ chalk.gray(
163
+ ' For SQLite files, use: spindb sqlite attach <path>',
164
+ ),
165
+ )
166
+ }
167
+ process.exit(1)
168
+ }
169
+
170
+ if (!existsSync(absolutePath)) {
171
+ if (options.json) {
172
+ console.log(
173
+ JSON.stringify({ success: false, error: 'File not found' }),
174
+ )
175
+ } else {
176
+ console.error(uiError(`File not found: ${absolutePath}`))
177
+ }
178
+ process.exit(1)
179
+ }
180
+
181
+ if (await duckdbRegistry.isPathRegistered(absolutePath)) {
182
+ const entry = await duckdbRegistry.getByPath(absolutePath)
183
+ if (options.json) {
184
+ console.log(
185
+ JSON.stringify({
186
+ success: false,
187
+ error: 'Already registered',
188
+ existingName: entry?.name,
189
+ }),
190
+ )
191
+ } else {
192
+ console.error(
193
+ uiError(`File is already registered as "${entry?.name}"`),
194
+ )
195
+ }
196
+ process.exit(1)
197
+ }
198
+
199
+ const containerName =
200
+ options.name || deriveContainerName(basename(absolutePath))
201
+
202
+ if (await containerManager.exists(containerName)) {
203
+ if (options.json) {
204
+ console.log(
205
+ JSON.stringify({
206
+ success: false,
207
+ error: 'Container name already exists',
208
+ }),
209
+ )
210
+ } else {
211
+ console.error(
212
+ uiError(`Container "${containerName}" already exists`),
213
+ )
214
+ }
215
+ process.exit(1)
216
+ }
217
+
218
+ await duckdbRegistry.add({
219
+ name: containerName,
220
+ filePath: absolutePath,
221
+ created: new Date().toISOString(),
222
+ })
223
+
224
+ if (options.json) {
225
+ console.log(
226
+ JSON.stringify({
227
+ success: true,
228
+ name: containerName,
229
+ filePath: absolutePath,
230
+ }),
231
+ )
232
+ } else {
233
+ console.log(
234
+ uiSuccess(
235
+ `Registered "${basename(absolutePath)}" as "${containerName}"`,
236
+ ),
237
+ )
238
+ console.log()
239
+ console.log(chalk.gray(' Connect with:'))
240
+ console.log(chalk.cyan(` spindb connect ${containerName}`))
241
+ }
242
+ } catch (error) {
243
+ const e = error as Error
244
+ if (options.json) {
245
+ console.log(JSON.stringify({ success: false, error: e.message }))
246
+ } else {
247
+ console.error(uiError(e.message))
248
+ }
249
+ process.exit(1)
250
+ }
251
+ },
252
+ )
253
+
254
+ // duckdb detach (alias to top-level detach)
255
+ duckdbCommand
256
+ .command('detach')
257
+ .description('Unregister a DuckDB database (alias for "spindb detach")')
258
+ .argument('<name>', 'Container name')
259
+ .option('-f, --force', 'Skip confirmation')
260
+ .option('--json', 'Output as JSON')
261
+ .action(
262
+ async (
263
+ name: string,
264
+ options: { force?: boolean; json?: boolean },
265
+ ): Promise<void> => {
266
+ // Build args array
267
+ const args = ['node', 'detach', name]
268
+ if (options.force) args.push('-f')
269
+ if (options.json) args.push('--json')
270
+
271
+ await detachCommand.parseAsync(args, { from: 'node' })
272
+ },
273
+ )