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.
- package/README.md +87 -7
- package/cli/commands/clone.ts +6 -0
- package/cli/commands/connect.ts +115 -14
- package/cli/commands/create.ts +170 -8
- package/cli/commands/doctor.ts +320 -0
- package/cli/commands/edit.ts +209 -9
- package/cli/commands/engines.ts +34 -3
- package/cli/commands/info.ts +81 -26
- package/cli/commands/list.ts +64 -9
- package/cli/commands/logs.ts +9 -3
- package/cli/commands/menu/backup-handlers.ts +52 -21
- package/cli/commands/menu/container-handlers.ts +433 -127
- package/cli/commands/menu/engine-handlers.ts +128 -4
- package/cli/commands/menu/index.ts +5 -1
- package/cli/commands/menu/shell-handlers.ts +105 -21
- package/cli/commands/menu/sql-handlers.ts +16 -4
- package/cli/commands/menu/update-handlers.ts +278 -0
- package/cli/commands/restore.ts +83 -23
- package/cli/commands/run.ts +27 -11
- package/cli/commands/url.ts +17 -9
- package/cli/constants.ts +1 -0
- package/cli/helpers.ts +41 -1
- package/cli/index.ts +2 -0
- package/cli/ui/prompts.ts +148 -7
- package/config/engine-defaults.ts +14 -0
- package/config/os-dependencies.ts +66 -0
- package/config/paths.ts +8 -0
- package/core/container-manager.ts +191 -32
- package/core/dependency-manager.ts +18 -0
- package/core/error-handler.ts +31 -0
- package/core/port-manager.ts +2 -0
- package/core/process-manager.ts +25 -3
- package/engines/index.ts +4 -0
- package/engines/mysql/backup.ts +53 -36
- package/engines/mysql/index.ts +48 -5
- package/engines/postgresql/index.ts +6 -0
- package/engines/sqlite/index.ts +606 -0
- package/engines/sqlite/registry.ts +185 -0
- package/package.json +1 -1
- package/types/index.ts +26 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor command - System health checks and diagnostics
|
|
3
|
+
*
|
|
4
|
+
* Checks:
|
|
5
|
+
* 1. Configuration file validity
|
|
6
|
+
* 2. Container status across all engines
|
|
7
|
+
* 3. SQLite registry orphaned entries
|
|
8
|
+
* 4. Binary/tool availability
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Command } from 'commander'
|
|
12
|
+
import { existsSync } from 'fs'
|
|
13
|
+
import chalk from 'chalk'
|
|
14
|
+
import inquirer from 'inquirer'
|
|
15
|
+
import { containerManager } from '../../core/container-manager'
|
|
16
|
+
import { configManager } from '../../core/config-manager'
|
|
17
|
+
import { sqliteRegistry } from '../../engines/sqlite/registry'
|
|
18
|
+
import { paths } from '../../config/paths'
|
|
19
|
+
import { getSupportedEngines } from '../../config/engine-defaults'
|
|
20
|
+
import { checkEngineDependencies } from '../../core/dependency-manager'
|
|
21
|
+
import { header, success } from '../ui/theme'
|
|
22
|
+
import { Engine } from '../../types'
|
|
23
|
+
|
|
24
|
+
type HealthCheckResult = {
|
|
25
|
+
name: string
|
|
26
|
+
status: 'ok' | 'warning' | 'error'
|
|
27
|
+
message: string
|
|
28
|
+
details?: string[]
|
|
29
|
+
action?: {
|
|
30
|
+
label: string
|
|
31
|
+
handler: () => Promise<void>
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check configuration file validity
|
|
37
|
+
*/
|
|
38
|
+
async function checkConfiguration(): Promise<HealthCheckResult> {
|
|
39
|
+
const configPath = paths.config
|
|
40
|
+
|
|
41
|
+
if (!existsSync(configPath)) {
|
|
42
|
+
return {
|
|
43
|
+
name: 'Configuration',
|
|
44
|
+
status: 'ok',
|
|
45
|
+
message: 'No config file yet (will be created on first use)',
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const config = await configManager.load()
|
|
51
|
+
const binaryCount = Object.keys(config.binaries || {}).length
|
|
52
|
+
const isStale = await configManager.isStale()
|
|
53
|
+
|
|
54
|
+
if (isStale) {
|
|
55
|
+
return {
|
|
56
|
+
name: 'Configuration',
|
|
57
|
+
status: 'warning',
|
|
58
|
+
message: 'Binary cache is stale (>7 days old)',
|
|
59
|
+
details: [`Binary tools cached: ${binaryCount}`],
|
|
60
|
+
action: {
|
|
61
|
+
label: 'Refresh binary cache',
|
|
62
|
+
handler: async () => {
|
|
63
|
+
await configManager.refreshAllBinaries()
|
|
64
|
+
console.log(success('Binary cache refreshed'))
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
name: 'Configuration',
|
|
72
|
+
status: 'ok',
|
|
73
|
+
message: 'Configuration valid',
|
|
74
|
+
details: [`Binary tools cached: ${binaryCount}`],
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return {
|
|
78
|
+
name: 'Configuration',
|
|
79
|
+
status: 'error',
|
|
80
|
+
message: 'Configuration file is corrupted',
|
|
81
|
+
details: [(err as Error).message],
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check container status across all engines
|
|
88
|
+
*/
|
|
89
|
+
async function checkContainers(): Promise<HealthCheckResult> {
|
|
90
|
+
try {
|
|
91
|
+
const containers = await containerManager.list()
|
|
92
|
+
|
|
93
|
+
if (containers.length === 0) {
|
|
94
|
+
return {
|
|
95
|
+
name: 'Containers',
|
|
96
|
+
status: 'ok',
|
|
97
|
+
message: 'No containers (create one with: spindb create)',
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const byEngine: Record<string, { running: number; stopped: number }> = {}
|
|
102
|
+
|
|
103
|
+
for (const c of containers) {
|
|
104
|
+
const engineName = c.engine
|
|
105
|
+
if (!byEngine[engineName]) {
|
|
106
|
+
byEngine[engineName] = { running: 0, stopped: 0 }
|
|
107
|
+
}
|
|
108
|
+
if (c.status === 'running') {
|
|
109
|
+
byEngine[engineName].running++
|
|
110
|
+
} else {
|
|
111
|
+
byEngine[engineName].stopped++
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const details = Object.entries(byEngine).map(([engine, counts]) => {
|
|
116
|
+
if (engine === Engine.SQLite) {
|
|
117
|
+
return `${engine}: ${counts.running} exist, ${counts.stopped} missing`
|
|
118
|
+
}
|
|
119
|
+
return `${engine}: ${counts.running} running, ${counts.stopped} stopped`
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
name: 'Containers',
|
|
124
|
+
status: 'ok',
|
|
125
|
+
message: `${containers.length} container(s)`,
|
|
126
|
+
details,
|
|
127
|
+
}
|
|
128
|
+
} catch (err) {
|
|
129
|
+
return {
|
|
130
|
+
name: 'Containers',
|
|
131
|
+
status: 'error',
|
|
132
|
+
message: 'Failed to list containers',
|
|
133
|
+
details: [(err as Error).message],
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Check SQLite registry for orphaned entries
|
|
140
|
+
*/
|
|
141
|
+
async function checkSqliteRegistry(): Promise<HealthCheckResult> {
|
|
142
|
+
try {
|
|
143
|
+
const entries = await sqliteRegistry.list()
|
|
144
|
+
|
|
145
|
+
if (entries.length === 0) {
|
|
146
|
+
return {
|
|
147
|
+
name: 'SQLite Registry',
|
|
148
|
+
status: 'ok',
|
|
149
|
+
message: 'No SQLite databases registered',
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const orphans = await sqliteRegistry.findOrphans()
|
|
154
|
+
|
|
155
|
+
if (orphans.length > 0) {
|
|
156
|
+
return {
|
|
157
|
+
name: 'SQLite Registry',
|
|
158
|
+
status: 'warning',
|
|
159
|
+
message: `${orphans.length} orphaned entr${orphans.length === 1 ? 'y' : 'ies'} found`,
|
|
160
|
+
details: orphans.map((o) => `"${o.name}" → ${o.filePath}`),
|
|
161
|
+
action: {
|
|
162
|
+
label: 'Remove orphaned entries from registry',
|
|
163
|
+
handler: async () => {
|
|
164
|
+
const count = await sqliteRegistry.removeOrphans()
|
|
165
|
+
console.log(success(`Removed ${count} orphaned entries`))
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
name: 'SQLite Registry',
|
|
173
|
+
status: 'ok',
|
|
174
|
+
message: `${entries.length} database(s) registered, all files exist`,
|
|
175
|
+
}
|
|
176
|
+
} catch (err) {
|
|
177
|
+
return {
|
|
178
|
+
name: 'SQLite Registry',
|
|
179
|
+
status: 'warning',
|
|
180
|
+
message: 'Could not check registry',
|
|
181
|
+
details: [(err as Error).message],
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Check binary/tool availability for all engines
|
|
188
|
+
*/
|
|
189
|
+
async function checkBinaries(): Promise<HealthCheckResult> {
|
|
190
|
+
try {
|
|
191
|
+
const engines = getSupportedEngines()
|
|
192
|
+
const results: string[] = []
|
|
193
|
+
let hasWarning = false
|
|
194
|
+
|
|
195
|
+
for (const engine of engines) {
|
|
196
|
+
const statuses = await checkEngineDependencies(engine)
|
|
197
|
+
const installed = statuses.filter((s) => s.installed).length
|
|
198
|
+
const total = statuses.length
|
|
199
|
+
|
|
200
|
+
if (installed < total) {
|
|
201
|
+
hasWarning = true
|
|
202
|
+
results.push(`${engine}: ${installed}/${total} tools installed`)
|
|
203
|
+
} else {
|
|
204
|
+
results.push(`${engine}: all ${total} tools available`)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
name: 'Database Tools',
|
|
210
|
+
status: hasWarning ? 'warning' : 'ok',
|
|
211
|
+
message: hasWarning ? 'Some tools missing' : 'All tools available',
|
|
212
|
+
details: results,
|
|
213
|
+
}
|
|
214
|
+
} catch (err) {
|
|
215
|
+
return {
|
|
216
|
+
name: 'Database Tools',
|
|
217
|
+
status: 'error',
|
|
218
|
+
message: 'Failed to check tools',
|
|
219
|
+
details: [(err as Error).message],
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Display a single health check result
|
|
226
|
+
*/
|
|
227
|
+
function displayResult(result: HealthCheckResult): void {
|
|
228
|
+
const icon =
|
|
229
|
+
result.status === 'ok'
|
|
230
|
+
? chalk.green('✓')
|
|
231
|
+
: result.status === 'warning'
|
|
232
|
+
? chalk.yellow('⚠')
|
|
233
|
+
: chalk.red('✕')
|
|
234
|
+
|
|
235
|
+
console.log(`${icon} ${chalk.bold(result.name)}`)
|
|
236
|
+
console.log(` └─ ${result.message}`)
|
|
237
|
+
|
|
238
|
+
if (result.details) {
|
|
239
|
+
for (const detail of result.details) {
|
|
240
|
+
console.log(chalk.gray(` ${detail}`))
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
console.log()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export const doctorCommand = new Command('doctor')
|
|
247
|
+
.description('Check system health and fix common issues')
|
|
248
|
+
.option('--json', 'Output as JSON')
|
|
249
|
+
.action(async (options: { json?: boolean }) => {
|
|
250
|
+
const checks = [
|
|
251
|
+
await checkConfiguration(),
|
|
252
|
+
await checkContainers(),
|
|
253
|
+
await checkSqliteRegistry(),
|
|
254
|
+
await checkBinaries(),
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
if (options.json) {
|
|
258
|
+
// Strip action handlers for JSON output
|
|
259
|
+
const jsonChecks = checks.map(({ action: _action, ...rest }) => rest)
|
|
260
|
+
console.log(JSON.stringify(jsonChecks, null, 2))
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Human-readable output - print header first
|
|
265
|
+
console.log()
|
|
266
|
+
console.log(header('SpinDB Health Check'))
|
|
267
|
+
console.log()
|
|
268
|
+
|
|
269
|
+
// Display results
|
|
270
|
+
for (const check of checks) {
|
|
271
|
+
displayResult(check)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Collect actions for warnings
|
|
275
|
+
const actionsAvailable = checks.filter((c) => c.action)
|
|
276
|
+
|
|
277
|
+
if (actionsAvailable.length > 0) {
|
|
278
|
+
type ActionChoice = {
|
|
279
|
+
name: string
|
|
280
|
+
value: string
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const choices: ActionChoice[] = [
|
|
284
|
+
...actionsAvailable.map((c) => ({
|
|
285
|
+
name: c.action!.label,
|
|
286
|
+
value: c.name,
|
|
287
|
+
})),
|
|
288
|
+
{ name: chalk.gray('Skip (do nothing)'), value: 'skip' },
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
const { selectedAction } = await inquirer.prompt<{
|
|
292
|
+
selectedAction: string
|
|
293
|
+
}>([
|
|
294
|
+
{
|
|
295
|
+
type: 'list',
|
|
296
|
+
name: 'selectedAction',
|
|
297
|
+
message: 'What would you like to do?',
|
|
298
|
+
choices,
|
|
299
|
+
},
|
|
300
|
+
])
|
|
301
|
+
|
|
302
|
+
if (selectedAction === 'skip') {
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Execute the selected action
|
|
307
|
+
const check = checks.find((c) => c.name === selectedAction)
|
|
308
|
+
if (check?.action) {
|
|
309
|
+
console.log()
|
|
310
|
+
await check.action.handler()
|
|
311
|
+
}
|
|
312
|
+
} else {
|
|
313
|
+
const hasIssues = checks.some((c) => c.status !== 'ok')
|
|
314
|
+
if (!hasIssues) {
|
|
315
|
+
console.log(chalk.green('All systems healthy! ✓'))
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
console.log()
|
|
320
|
+
})
|
package/cli/commands/edit.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import chalk from 'chalk'
|
|
3
3
|
import inquirer from 'inquirer'
|
|
4
|
+
import { existsSync, renameSync, mkdirSync, statSync, unlinkSync, copyFileSync } from 'fs'
|
|
5
|
+
import { dirname, resolve, basename, join } from 'path'
|
|
6
|
+
import { homedir } from 'os'
|
|
4
7
|
import { containerManager } from '../../core/container-manager'
|
|
5
8
|
import { processManager } from '../../core/process-manager'
|
|
6
9
|
import { portManager } from '../../core/port-manager'
|
|
7
10
|
import { getEngine } from '../../engines'
|
|
11
|
+
import { sqliteRegistry } from '../../engines/sqlite/registry'
|
|
8
12
|
import { paths } from '../../config/paths'
|
|
9
13
|
import { promptContainerSelect } from '../ui/prompts'
|
|
10
14
|
import { createSpinner } from '../ui/spinner'
|
|
11
15
|
import { error, warning, success, info } from '../ui/theme'
|
|
16
|
+
import { Engine } from '../../types'
|
|
12
17
|
|
|
13
18
|
function isValidName(name: string): boolean {
|
|
14
19
|
return /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)
|
|
@@ -19,14 +24,20 @@ function isValidName(name: string): boolean {
|
|
|
19
24
|
*/
|
|
20
25
|
async function promptEditAction(
|
|
21
26
|
engine: string,
|
|
22
|
-
): Promise<'name' | 'port' | 'config' | null> {
|
|
27
|
+
): Promise<'name' | 'port' | 'config' | 'relocate' | null> {
|
|
23
28
|
const choices = [
|
|
24
29
|
{ name: 'Rename container', value: 'name' },
|
|
25
|
-
{ name: 'Change port', value: 'port' },
|
|
26
30
|
]
|
|
27
31
|
|
|
32
|
+
// SQLite: show relocate instead of port
|
|
33
|
+
if (engine === Engine.SQLite) {
|
|
34
|
+
choices.push({ name: 'Relocate database file', value: 'relocate' })
|
|
35
|
+
} else {
|
|
36
|
+
choices.push({ name: 'Change port', value: 'port' })
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
// Only show config option for engines that support it
|
|
29
|
-
if (engine ===
|
|
40
|
+
if (engine === Engine.PostgreSQL) {
|
|
30
41
|
choices.push({ name: 'Edit database config (postgresql.conf)', value: 'config' })
|
|
31
42
|
}
|
|
32
43
|
|
|
@@ -42,7 +53,7 @@ async function promptEditAction(
|
|
|
42
53
|
])
|
|
43
54
|
|
|
44
55
|
if (action === 'cancel') return null
|
|
45
|
-
return action as 'name' | 'port' | 'config'
|
|
56
|
+
return action as 'name' | 'port' | 'config' | 'relocate'
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
async function promptNewName(currentName: string): Promise<string | null> {
|
|
@@ -173,11 +184,71 @@ async function promptNewPort(currentPort: number): Promise<number | null> {
|
|
|
173
184
|
return newPort
|
|
174
185
|
}
|
|
175
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Prompt for new file location (SQLite relocate)
|
|
189
|
+
*/
|
|
190
|
+
async function promptNewLocation(currentPath: string): Promise<string | null> {
|
|
191
|
+
console.log()
|
|
192
|
+
console.log(chalk.gray(` Current location: ${currentPath}`))
|
|
193
|
+
console.log(chalk.gray(' Enter an absolute path or relative to current directory.'))
|
|
194
|
+
console.log()
|
|
195
|
+
|
|
196
|
+
const { newPath } = await inquirer.prompt<{ newPath: string }>([
|
|
197
|
+
{
|
|
198
|
+
type: 'input',
|
|
199
|
+
name: 'newPath',
|
|
200
|
+
message: 'New file location:',
|
|
201
|
+
default: currentPath,
|
|
202
|
+
validate: (input: string) => {
|
|
203
|
+
if (!input.trim()) return 'Path is required'
|
|
204
|
+
const resolvedPath = resolve(input).toLowerCase()
|
|
205
|
+
if (!resolvedPath.endsWith('.sqlite') && !resolvedPath.endsWith('.db') && !resolvedPath.endsWith('.sqlite3')) {
|
|
206
|
+
return 'Path should end with .sqlite, .sqlite3, or .db'
|
|
207
|
+
}
|
|
208
|
+
return true
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
])
|
|
212
|
+
|
|
213
|
+
const resolvedPath = resolve(newPath)
|
|
214
|
+
|
|
215
|
+
if (resolvedPath === currentPath) {
|
|
216
|
+
console.log(warning('Location unchanged'))
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Check if target already exists
|
|
221
|
+
if (existsSync(resolvedPath)) {
|
|
222
|
+
const { overwrite } = await inquirer.prompt<{ overwrite: boolean }>([
|
|
223
|
+
{
|
|
224
|
+
type: 'confirm',
|
|
225
|
+
name: 'overwrite',
|
|
226
|
+
message: `File already exists at ${resolvedPath}. Overwrite?`,
|
|
227
|
+
default: false,
|
|
228
|
+
},
|
|
229
|
+
])
|
|
230
|
+
if (!overwrite) {
|
|
231
|
+
console.log(warning('Relocate cancelled'))
|
|
232
|
+
return null
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return resolvedPath
|
|
237
|
+
}
|
|
238
|
+
|
|
176
239
|
export const editCommand = new Command('edit')
|
|
177
|
-
.description('Edit container properties (rename, port, or database config)')
|
|
240
|
+
.description('Edit container properties (rename, port, relocate, or database config)')
|
|
178
241
|
.argument('[name]', 'Container name')
|
|
179
242
|
.option('-n, --name <newName>', 'New container name')
|
|
180
243
|
.option('-p, --port <port>', 'New port number', parseInt)
|
|
244
|
+
.option(
|
|
245
|
+
'--relocate <path>',
|
|
246
|
+
'New file location for SQLite database (moves the file)',
|
|
247
|
+
)
|
|
248
|
+
.option(
|
|
249
|
+
'--overwrite',
|
|
250
|
+
'Overwrite destination file if it exists (for --relocate)',
|
|
251
|
+
)
|
|
181
252
|
.option(
|
|
182
253
|
'--set-config <setting>',
|
|
183
254
|
'Set a database config value (e.g., max_connections=200)',
|
|
@@ -185,7 +256,7 @@ export const editCommand = new Command('edit')
|
|
|
185
256
|
.action(
|
|
186
257
|
async (
|
|
187
258
|
name: string | undefined,
|
|
188
|
-
options: { name?: string; port?: number; setConfig?: string },
|
|
259
|
+
options: { name?: string; port?: number; relocate?: string; overwrite?: boolean; setConfig?: string },
|
|
189
260
|
) => {
|
|
190
261
|
try {
|
|
191
262
|
let containerName = name
|
|
@@ -216,6 +287,7 @@ export const editCommand = new Command('edit')
|
|
|
216
287
|
if (
|
|
217
288
|
options.name === undefined &&
|
|
218
289
|
options.port === undefined &&
|
|
290
|
+
options.relocate === undefined &&
|
|
219
291
|
options.setConfig === undefined
|
|
220
292
|
) {
|
|
221
293
|
const action = await promptEditAction(config.engine)
|
|
@@ -235,6 +307,13 @@ export const editCommand = new Command('edit')
|
|
|
235
307
|
} else {
|
|
236
308
|
return
|
|
237
309
|
}
|
|
310
|
+
} else if (action === 'relocate') {
|
|
311
|
+
const newLocation = await promptNewLocation(config.database)
|
|
312
|
+
if (newLocation) {
|
|
313
|
+
options.relocate = newLocation
|
|
314
|
+
} else {
|
|
315
|
+
return
|
|
316
|
+
}
|
|
238
317
|
} else if (action === 'config') {
|
|
239
318
|
const configSetting = await promptConfigSetting()
|
|
240
319
|
if (configSetting) {
|
|
@@ -317,10 +396,129 @@ export const editCommand = new Command('edit')
|
|
|
317
396
|
)
|
|
318
397
|
}
|
|
319
398
|
|
|
399
|
+
// Handle SQLite relocate
|
|
400
|
+
if (options.relocate) {
|
|
401
|
+
if (config.engine !== Engine.SQLite) {
|
|
402
|
+
console.error(
|
|
403
|
+
error('Relocate is only available for SQLite containers'),
|
|
404
|
+
)
|
|
405
|
+
process.exit(1)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Expand ~ to home directory
|
|
409
|
+
let expandedPath = options.relocate
|
|
410
|
+
if (options.relocate === '~') {
|
|
411
|
+
expandedPath = homedir()
|
|
412
|
+
} else if (options.relocate.startsWith('~/')) {
|
|
413
|
+
expandedPath = join(homedir(), options.relocate.slice(2))
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Convert relative paths to absolute
|
|
417
|
+
if (!expandedPath.startsWith('/')) {
|
|
418
|
+
expandedPath = resolve(process.cwd(), expandedPath)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Check if path looks like a file (has db extension) or directory
|
|
422
|
+
const hasDbExtension = /\.(sqlite3?|db)$/i.test(expandedPath)
|
|
423
|
+
|
|
424
|
+
// Treat as directory if:
|
|
425
|
+
// - ends with /
|
|
426
|
+
// - exists and is a directory
|
|
427
|
+
// - doesn't have a database file extension
|
|
428
|
+
const isDirectory = expandedPath.endsWith('/') ||
|
|
429
|
+
(existsSync(expandedPath) && statSync(expandedPath).isDirectory()) ||
|
|
430
|
+
!hasDbExtension
|
|
431
|
+
|
|
432
|
+
let newPath: string
|
|
433
|
+
if (isDirectory) {
|
|
434
|
+
const dirPath = expandedPath.endsWith('/') ? expandedPath.slice(0, -1) : expandedPath
|
|
435
|
+
const currentFileName = basename(config.database)
|
|
436
|
+
newPath = join(dirPath, currentFileName)
|
|
437
|
+
} else {
|
|
438
|
+
newPath = expandedPath
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Check source file exists
|
|
442
|
+
if (!existsSync(config.database)) {
|
|
443
|
+
console.error(
|
|
444
|
+
error(`Source database file not found: ${config.database}`),
|
|
445
|
+
)
|
|
446
|
+
process.exit(1)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Check if destination already exists
|
|
450
|
+
if (existsSync(newPath)) {
|
|
451
|
+
if (options.overwrite) {
|
|
452
|
+
// Remove existing file before move
|
|
453
|
+
unlinkSync(newPath)
|
|
454
|
+
console.log(warning(`Overwriting existing file: ${newPath}`))
|
|
455
|
+
} else {
|
|
456
|
+
console.error(
|
|
457
|
+
error(`Destination file already exists: ${newPath}`),
|
|
458
|
+
)
|
|
459
|
+
console.log(info('Use --overwrite to replace the existing file'))
|
|
460
|
+
process.exit(1)
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Ensure target directory exists
|
|
465
|
+
const targetDir = dirname(newPath)
|
|
466
|
+
if (!existsSync(targetDir)) {
|
|
467
|
+
mkdirSync(targetDir, { recursive: true })
|
|
468
|
+
console.log(info(`Created directory: ${targetDir}`))
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const spinner = createSpinner(
|
|
472
|
+
`Moving database to ${newPath}...`,
|
|
473
|
+
)
|
|
474
|
+
spinner.start()
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
// Try rename first (fast, same filesystem)
|
|
478
|
+
try {
|
|
479
|
+
renameSync(config.database, newPath)
|
|
480
|
+
} catch (renameErr) {
|
|
481
|
+
const e = renameErr as NodeJS.ErrnoException
|
|
482
|
+
// EXDEV = cross-device link, need to copy+delete
|
|
483
|
+
if (e.code === 'EXDEV') {
|
|
484
|
+
try {
|
|
485
|
+
// Copy file preserving mode/permissions
|
|
486
|
+
copyFileSync(config.database, newPath)
|
|
487
|
+
// Only delete source after successful copy
|
|
488
|
+
unlinkSync(config.database)
|
|
489
|
+
} catch (copyErr) {
|
|
490
|
+
// Clean up partial target on failure
|
|
491
|
+
if (existsSync(newPath)) {
|
|
492
|
+
try {
|
|
493
|
+
unlinkSync(newPath)
|
|
494
|
+
} catch {
|
|
495
|
+
// Ignore cleanup errors
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
throw copyErr
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
throw renameErr
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Update the container config and SQLite registry
|
|
506
|
+
await containerManager.updateConfig(containerName, {
|
|
507
|
+
database: newPath,
|
|
508
|
+
})
|
|
509
|
+
await sqliteRegistry.update(containerName, { filePath: newPath })
|
|
510
|
+
|
|
511
|
+
spinner.succeed(`Database relocated to ${newPath}`)
|
|
512
|
+
} catch (err) {
|
|
513
|
+
spinner.fail('Failed to relocate database')
|
|
514
|
+
throw err
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
320
518
|
// Handle config change
|
|
321
519
|
if (options.setConfig) {
|
|
322
520
|
// Only PostgreSQL supports config editing for now
|
|
323
|
-
if (config.engine !==
|
|
521
|
+
if (config.engine !== Engine.PostgreSQL) {
|
|
324
522
|
console.error(
|
|
325
523
|
error(`Config editing is only supported for PostgreSQL containers`),
|
|
326
524
|
)
|
|
@@ -358,10 +556,12 @@ export const editCommand = new Command('edit')
|
|
|
358
556
|
configKey,
|
|
359
557
|
configValue,
|
|
360
558
|
)
|
|
559
|
+
spinner.succeed(`Set ${configKey} = ${configValue}`)
|
|
560
|
+
} else {
|
|
561
|
+
spinner.fail('Config editing not supported for this engine')
|
|
562
|
+
process.exit(1)
|
|
361
563
|
}
|
|
362
564
|
|
|
363
|
-
spinner.succeed(`Set ${configKey} = ${configValue}`)
|
|
364
|
-
|
|
365
565
|
// Check if container is running and warn about restart
|
|
366
566
|
const running = await processManager.isRunning(containerName, {
|
|
367
567
|
engine: config.engine,
|
package/cli/commands/engines.ts
CHANGED
|
@@ -12,8 +12,17 @@ import {
|
|
|
12
12
|
getInstalledPostgresEngines,
|
|
13
13
|
type InstalledPostgresEngine,
|
|
14
14
|
type InstalledMysqlEngine,
|
|
15
|
+
type InstalledSqliteEngine,
|
|
15
16
|
} from '../helpers'
|
|
16
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Pad string to width, accounting for emoji taking 2 display columns
|
|
20
|
+
*/
|
|
21
|
+
function padWithEmoji(str: string, width: number): string {
|
|
22
|
+
// Count emojis using Extended_Pictographic (excludes digits/symbols that \p{Emoji} matches)
|
|
23
|
+
const emojiCount = (str.match(/\p{Extended_Pictographic}/gu) || []).length
|
|
24
|
+
return str.padEnd(width + emojiCount)
|
|
25
|
+
}
|
|
17
26
|
|
|
18
27
|
/**
|
|
19
28
|
* List subcommand action
|
|
@@ -41,13 +50,16 @@ async function listEngines(options: { json?: boolean }): Promise<void> {
|
|
|
41
50
|
return
|
|
42
51
|
}
|
|
43
52
|
|
|
44
|
-
// Separate
|
|
53
|
+
// Separate engines by type
|
|
45
54
|
const pgEngines = engines.filter(
|
|
46
55
|
(e): e is InstalledPostgresEngine => e.engine === 'postgresql',
|
|
47
56
|
)
|
|
48
57
|
const mysqlEngine = engines.find(
|
|
49
58
|
(e): e is InstalledMysqlEngine => e.engine === 'mysql',
|
|
50
59
|
)
|
|
60
|
+
const sqliteEngine = engines.find(
|
|
61
|
+
(e): e is InstalledSqliteEngine => e.engine === 'sqlite',
|
|
62
|
+
)
|
|
51
63
|
|
|
52
64
|
// Calculate total size for PostgreSQL
|
|
53
65
|
const totalPgSize = pgEngines.reduce((acc, e) => acc + e.sizeBytes, 0)
|
|
@@ -67,10 +79,11 @@ async function listEngines(options: { json?: boolean }): Promise<void> {
|
|
|
67
79
|
for (const engine of pgEngines) {
|
|
68
80
|
const icon = getEngineIcon(engine.engine)
|
|
69
81
|
const platformInfo = `${engine.platform}-${engine.arch}`
|
|
82
|
+
const engineDisplay = `${icon} ${engine.engine}`
|
|
70
83
|
|
|
71
84
|
console.log(
|
|
72
85
|
chalk.gray(' ') +
|
|
73
|
-
chalk.cyan(
|
|
86
|
+
chalk.cyan(padWithEmoji(engineDisplay, 13)) +
|
|
74
87
|
chalk.yellow(engine.version.padEnd(12)) +
|
|
75
88
|
chalk.gray(platformInfo.padEnd(18)) +
|
|
76
89
|
chalk.white(formatBytes(engine.sizeBytes)),
|
|
@@ -81,16 +94,31 @@ async function listEngines(options: { json?: boolean }): Promise<void> {
|
|
|
81
94
|
if (mysqlEngine) {
|
|
82
95
|
const icon = ENGINE_ICONS.mysql
|
|
83
96
|
const displayName = mysqlEngine.isMariaDB ? 'mariadb' : 'mysql'
|
|
97
|
+
const engineDisplay = `${icon} ${displayName}`
|
|
84
98
|
|
|
85
99
|
console.log(
|
|
86
100
|
chalk.gray(' ') +
|
|
87
|
-
chalk.cyan(
|
|
101
|
+
chalk.cyan(padWithEmoji(engineDisplay, 13)) +
|
|
88
102
|
chalk.yellow(mysqlEngine.version.padEnd(12)) +
|
|
89
103
|
chalk.gray('system'.padEnd(18)) +
|
|
90
104
|
chalk.gray('(system-installed)'),
|
|
91
105
|
)
|
|
92
106
|
}
|
|
93
107
|
|
|
108
|
+
// SQLite row
|
|
109
|
+
if (sqliteEngine) {
|
|
110
|
+
const icon = ENGINE_ICONS.sqlite
|
|
111
|
+
const engineDisplay = `${icon} sqlite`
|
|
112
|
+
|
|
113
|
+
console.log(
|
|
114
|
+
chalk.gray(' ') +
|
|
115
|
+
chalk.cyan(padWithEmoji(engineDisplay, 13)) +
|
|
116
|
+
chalk.yellow(sqliteEngine.version.padEnd(12)) +
|
|
117
|
+
chalk.gray('system'.padEnd(18)) +
|
|
118
|
+
chalk.gray('(system-installed)'),
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
94
122
|
console.log(chalk.gray(' ' + '─'.repeat(55)))
|
|
95
123
|
|
|
96
124
|
// Summary
|
|
@@ -105,6 +133,9 @@ async function listEngines(options: { json?: boolean }): Promise<void> {
|
|
|
105
133
|
if (mysqlEngine) {
|
|
106
134
|
console.log(chalk.gray(` MySQL: system-installed at ${mysqlEngine.path}`))
|
|
107
135
|
}
|
|
136
|
+
if (sqliteEngine) {
|
|
137
|
+
console.log(chalk.gray(` SQLite: system-installed at ${sqliteEngine.path}`))
|
|
138
|
+
}
|
|
108
139
|
console.log()
|
|
109
140
|
}
|
|
110
141
|
|