spindb 0.5.4 → 0.6.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.
- package/README.md +46 -4
- package/cli/commands/backup.ts +269 -0
- package/cli/commands/config.ts +200 -67
- package/cli/commands/connect.ts +29 -9
- package/cli/commands/engines.ts +1 -9
- package/cli/commands/list.ts +41 -4
- package/cli/commands/menu.ts +289 -19
- package/cli/commands/restore.ts +3 -0
- package/cli/commands/self-update.ts +109 -0
- package/cli/commands/version.ts +55 -0
- package/cli/index.ts +84 -1
- package/cli/ui/prompts.ts +89 -1
- package/cli/ui/theme.ts +11 -0
- package/core/config-manager.ts +123 -37
- package/core/container-manager.ts +78 -2
- package/core/dependency-manager.ts +5 -0
- package/core/update-manager.ts +194 -0
- package/engines/base-engine.ts +20 -0
- package/engines/mysql/backup.ts +159 -0
- package/engines/mysql/index.ts +39 -0
- package/engines/mysql/restore.ts +16 -2
- package/engines/postgresql/backup.ts +93 -0
- package/engines/postgresql/index.ts +37 -0
- package/package.json +1 -1
- package/types/index.ts +26 -0
package/cli/ui/prompts.ts
CHANGED
|
@@ -251,7 +251,8 @@ export async function promptDatabaseName(
|
|
|
251
251
|
engine?: string,
|
|
252
252
|
): Promise<string> {
|
|
253
253
|
// MySQL uses "schema" terminology (database and schema are synonymous)
|
|
254
|
-
const label =
|
|
254
|
+
const label =
|
|
255
|
+
engine === 'mysql' ? 'Database (schema) name:' : 'Database name:'
|
|
255
256
|
|
|
256
257
|
const { database } = await inquirer.prompt<{ database: string }>([
|
|
257
258
|
{
|
|
@@ -276,6 +277,93 @@ export async function promptDatabaseName(
|
|
|
276
277
|
return database
|
|
277
278
|
}
|
|
278
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Prompt to select a database from a list of databases in a container
|
|
282
|
+
*/
|
|
283
|
+
export async function promptDatabaseSelect(
|
|
284
|
+
databases: string[],
|
|
285
|
+
message: string = 'Select database:',
|
|
286
|
+
): Promise<string> {
|
|
287
|
+
if (databases.length === 0) {
|
|
288
|
+
throw new Error('No databases available to select')
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (databases.length === 1) {
|
|
292
|
+
return databases[0]
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const { database } = await inquirer.prompt<{ database: string }>([
|
|
296
|
+
{
|
|
297
|
+
type: 'list',
|
|
298
|
+
name: 'database',
|
|
299
|
+
message,
|
|
300
|
+
choices: databases.map((db, index) => ({
|
|
301
|
+
name: index === 0 ? `${db} ${chalk.gray('(primary)')}` : db,
|
|
302
|
+
value: db,
|
|
303
|
+
short: db,
|
|
304
|
+
})),
|
|
305
|
+
},
|
|
306
|
+
])
|
|
307
|
+
|
|
308
|
+
return database
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Prompt for backup format selection
|
|
313
|
+
*/
|
|
314
|
+
export async function promptBackupFormat(
|
|
315
|
+
engine: string,
|
|
316
|
+
): Promise<'sql' | 'dump'> {
|
|
317
|
+
const sqlDescription =
|
|
318
|
+
engine === 'mysql'
|
|
319
|
+
? 'Plain SQL - human-readable, larger file'
|
|
320
|
+
: 'Plain SQL - human-readable, larger file'
|
|
321
|
+
const dumpDescription =
|
|
322
|
+
engine === 'mysql'
|
|
323
|
+
? 'Compressed SQL (.sql.gz) - smaller file'
|
|
324
|
+
: 'Custom format - smaller file, faster restore'
|
|
325
|
+
|
|
326
|
+
const { format } = await inquirer.prompt<{ format: 'sql' | 'dump' }>([
|
|
327
|
+
{
|
|
328
|
+
type: 'list',
|
|
329
|
+
name: 'format',
|
|
330
|
+
message: 'Select backup format:',
|
|
331
|
+
choices: [
|
|
332
|
+
{ name: `.sql ${chalk.gray(`- ${sqlDescription}`)}`, value: 'sql' },
|
|
333
|
+
{ name: `.dump ${chalk.gray(`- ${dumpDescription}`)}`, value: 'dump' },
|
|
334
|
+
],
|
|
335
|
+
default: 'sql',
|
|
336
|
+
},
|
|
337
|
+
])
|
|
338
|
+
|
|
339
|
+
return format
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Prompt for backup filename
|
|
344
|
+
*/
|
|
345
|
+
export async function promptBackupFilename(
|
|
346
|
+
defaultName: string,
|
|
347
|
+
): Promise<string> {
|
|
348
|
+
const { filename } = await inquirer.prompt<{ filename: string }>([
|
|
349
|
+
{
|
|
350
|
+
type: 'input',
|
|
351
|
+
name: 'filename',
|
|
352
|
+
message: 'Backup filename (without extension):',
|
|
353
|
+
default: defaultName,
|
|
354
|
+
validate: (input: string) => {
|
|
355
|
+
if (!input) return 'Filename is required'
|
|
356
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(input)) {
|
|
357
|
+
return 'Filename must contain only letters, numbers, underscores, and hyphens'
|
|
358
|
+
}
|
|
359
|
+
return true
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
])
|
|
363
|
+
|
|
364
|
+
return filename
|
|
365
|
+
}
|
|
366
|
+
|
|
279
367
|
export type CreateOptions = {
|
|
280
368
|
name: string
|
|
281
369
|
engine: string
|
package/cli/ui/theme.ts
CHANGED
|
@@ -155,3 +155,14 @@ export function connectionBox(
|
|
|
155
155
|
|
|
156
156
|
return box(lines)
|
|
157
157
|
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Format bytes into human-readable format (B, KB, MB, GB)
|
|
161
|
+
*/
|
|
162
|
+
export function formatBytes(bytes: number): string {
|
|
163
|
+
if (bytes === 0) return '0 B'
|
|
164
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
165
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
|
166
|
+
const value = bytes / Math.pow(1024, i)
|
|
167
|
+
return `${value.toFixed(1)} ${units[i]}`
|
|
168
|
+
}
|
package/core/config-manager.ts
CHANGED
|
@@ -18,6 +18,27 @@ const DEFAULT_CONFIG: SpinDBConfig = {
|
|
|
18
18
|
binaries: {},
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// Cache staleness threshold (7 days in milliseconds)
|
|
22
|
+
const CACHE_STALENESS_MS = 7 * 24 * 60 * 60 * 1000
|
|
23
|
+
|
|
24
|
+
// All tools organized by category
|
|
25
|
+
const POSTGRESQL_TOOLS: BinaryTool[] = [
|
|
26
|
+
'psql',
|
|
27
|
+
'pg_dump',
|
|
28
|
+
'pg_restore',
|
|
29
|
+
'pg_basebackup',
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
const MYSQL_TOOLS: BinaryTool[] = ['mysql', 'mysqldump', 'mysqladmin', 'mysqld']
|
|
33
|
+
|
|
34
|
+
const ENHANCED_SHELLS: BinaryTool[] = ['pgcli', 'mycli', 'usql']
|
|
35
|
+
|
|
36
|
+
const ALL_TOOLS: BinaryTool[] = [
|
|
37
|
+
...POSTGRESQL_TOOLS,
|
|
38
|
+
...MYSQL_TOOLS,
|
|
39
|
+
...ENHANCED_SHELLS,
|
|
40
|
+
]
|
|
41
|
+
|
|
21
42
|
export class ConfigManager {
|
|
22
43
|
private config: SpinDBConfig | null = null
|
|
23
44
|
|
|
@@ -170,44 +191,55 @@ export class ConfigManager {
|
|
|
170
191
|
}
|
|
171
192
|
|
|
172
193
|
/**
|
|
173
|
-
* Get common installation paths for
|
|
194
|
+
* Get common installation paths for database tools
|
|
174
195
|
*/
|
|
175
196
|
private getCommonBinaryPaths(tool: BinaryTool): string[] {
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
// Homebrew (macOS)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
`/
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
197
|
+
const commonPaths: string[] = []
|
|
198
|
+
|
|
199
|
+
// Homebrew (macOS ARM)
|
|
200
|
+
commonPaths.push(`/opt/homebrew/bin/${tool}`)
|
|
201
|
+
// Homebrew (macOS Intel)
|
|
202
|
+
commonPaths.push(`/usr/local/bin/${tool}`)
|
|
203
|
+
|
|
204
|
+
// PostgreSQL-specific paths
|
|
205
|
+
if (POSTGRESQL_TOOLS.includes(tool) || tool === 'pgcli') {
|
|
206
|
+
commonPaths.push(`/opt/homebrew/opt/libpq/bin/${tool}`)
|
|
207
|
+
commonPaths.push(`/usr/local/opt/libpq/bin/${tool}`)
|
|
208
|
+
// Postgres.app (macOS)
|
|
209
|
+
commonPaths.push(
|
|
210
|
+
`/Applications/Postgres.app/Contents/Versions/latest/bin/${tool}`,
|
|
211
|
+
)
|
|
212
|
+
// Linux PostgreSQL paths
|
|
213
|
+
commonPaths.push(`/usr/lib/postgresql/17/bin/${tool}`)
|
|
214
|
+
commonPaths.push(`/usr/lib/postgresql/16/bin/${tool}`)
|
|
215
|
+
commonPaths.push(`/usr/lib/postgresql/15/bin/${tool}`)
|
|
216
|
+
commonPaths.push(`/usr/lib/postgresql/14/bin/${tool}`)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// MySQL-specific paths
|
|
220
|
+
if (MYSQL_TOOLS.includes(tool) || tool === 'mycli') {
|
|
221
|
+
commonPaths.push(`/opt/homebrew/opt/mysql/bin/${tool}`)
|
|
222
|
+
commonPaths.push(`/opt/homebrew/opt/mysql-client/bin/${tool}`)
|
|
223
|
+
commonPaths.push(`/usr/local/opt/mysql/bin/${tool}`)
|
|
224
|
+
commonPaths.push(`/usr/local/opt/mysql-client/bin/${tool}`)
|
|
225
|
+
// Linux MySQL/MariaDB paths
|
|
226
|
+
commonPaths.push(`/usr/bin/${tool}`)
|
|
227
|
+
commonPaths.push(`/usr/sbin/${tool}`)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// General Linux paths
|
|
231
|
+
commonPaths.push(`/usr/bin/${tool}`)
|
|
232
|
+
|
|
233
|
+
return commonPaths
|
|
196
234
|
}
|
|
197
235
|
|
|
198
236
|
/**
|
|
199
237
|
* Detect all available client tools on the system
|
|
200
238
|
*/
|
|
201
239
|
async detectAllTools(): Promise<Map<BinaryTool, string>> {
|
|
202
|
-
const tools: BinaryTool[] = [
|
|
203
|
-
'psql',
|
|
204
|
-
'pg_dump',
|
|
205
|
-
'pg_restore',
|
|
206
|
-
'pg_basebackup',
|
|
207
|
-
]
|
|
208
240
|
const found = new Map<BinaryTool, string>()
|
|
209
241
|
|
|
210
|
-
for (const tool of
|
|
242
|
+
for (const tool of ALL_TOOLS) {
|
|
211
243
|
const path = await this.detectSystemBinary(tool)
|
|
212
244
|
if (path) {
|
|
213
245
|
found.set(tool, path)
|
|
@@ -219,18 +251,19 @@ export class ConfigManager {
|
|
|
219
251
|
|
|
220
252
|
/**
|
|
221
253
|
* Initialize config by detecting all available tools
|
|
254
|
+
* Groups results by category for better display
|
|
222
255
|
*/
|
|
223
|
-
async initialize(): Promise<{
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
256
|
+
async initialize(): Promise<{
|
|
257
|
+
found: BinaryTool[]
|
|
258
|
+
missing: BinaryTool[]
|
|
259
|
+
postgresql: { found: BinaryTool[]; missing: BinaryTool[] }
|
|
260
|
+
mysql: { found: BinaryTool[]; missing: BinaryTool[] }
|
|
261
|
+
enhanced: { found: BinaryTool[]; missing: BinaryTool[] }
|
|
262
|
+
}> {
|
|
230
263
|
const found: BinaryTool[] = []
|
|
231
264
|
const missing: BinaryTool[] = []
|
|
232
265
|
|
|
233
|
-
for (const tool of
|
|
266
|
+
for (const tool of ALL_TOOLS) {
|
|
234
267
|
const path = await this.getBinaryPath(tool)
|
|
235
268
|
if (path) {
|
|
236
269
|
found.push(tool)
|
|
@@ -239,7 +272,57 @@ export class ConfigManager {
|
|
|
239
272
|
}
|
|
240
273
|
}
|
|
241
274
|
|
|
242
|
-
return {
|
|
275
|
+
return {
|
|
276
|
+
found,
|
|
277
|
+
missing,
|
|
278
|
+
postgresql: {
|
|
279
|
+
found: found.filter((t) => POSTGRESQL_TOOLS.includes(t)),
|
|
280
|
+
missing: missing.filter((t) => POSTGRESQL_TOOLS.includes(t)),
|
|
281
|
+
},
|
|
282
|
+
mysql: {
|
|
283
|
+
found: found.filter((t) => MYSQL_TOOLS.includes(t)),
|
|
284
|
+
missing: missing.filter((t) => MYSQL_TOOLS.includes(t)),
|
|
285
|
+
},
|
|
286
|
+
enhanced: {
|
|
287
|
+
found: found.filter((t) => ENHANCED_SHELLS.includes(t)),
|
|
288
|
+
missing: missing.filter((t) => ENHANCED_SHELLS.includes(t)),
|
|
289
|
+
},
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Check if the config cache is stale (older than 7 days)
|
|
295
|
+
*/
|
|
296
|
+
async isStale(): Promise<boolean> {
|
|
297
|
+
const config = await this.load()
|
|
298
|
+
if (!config.updatedAt) {
|
|
299
|
+
return true
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const updatedAt = new Date(config.updatedAt).getTime()
|
|
303
|
+
const now = Date.now()
|
|
304
|
+
return now - updatedAt > CACHE_STALENESS_MS
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Refresh all tool paths if cache is stale
|
|
309
|
+
* Returns true if refresh was performed
|
|
310
|
+
*/
|
|
311
|
+
async refreshIfStale(): Promise<boolean> {
|
|
312
|
+
if (await this.isStale()) {
|
|
313
|
+
await this.refreshAllBinaries()
|
|
314
|
+
return true
|
|
315
|
+
}
|
|
316
|
+
return false
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Force refresh all binary paths
|
|
321
|
+
* Re-detects all tools and updates versions
|
|
322
|
+
*/
|
|
323
|
+
async refreshAllBinaries(): Promise<void> {
|
|
324
|
+
await this.clearAllBinaries()
|
|
325
|
+
await this.initialize()
|
|
243
326
|
}
|
|
244
327
|
|
|
245
328
|
/**
|
|
@@ -269,3 +352,6 @@ export class ConfigManager {
|
|
|
269
352
|
}
|
|
270
353
|
|
|
271
354
|
export const configManager = new ConfigManager()
|
|
355
|
+
|
|
356
|
+
// Export tool categories for use in commands
|
|
357
|
+
export { POSTGRESQL_TOOLS, MYSQL_TOOLS, ENHANCED_SHELLS, ALL_TOOLS }
|
|
@@ -51,6 +51,7 @@ export class ContainerManager {
|
|
|
51
51
|
version,
|
|
52
52
|
port,
|
|
53
53
|
database,
|
|
54
|
+
databases: [database],
|
|
54
55
|
created: new Date().toISOString(),
|
|
55
56
|
status: 'created',
|
|
56
57
|
}
|
|
@@ -63,6 +64,7 @@ export class ContainerManager {
|
|
|
63
64
|
/**
|
|
64
65
|
* Get container configuration
|
|
65
66
|
* If engine is not provided, searches all engine directories
|
|
67
|
+
* Automatically migrates old schemas to include databases array
|
|
66
68
|
*/
|
|
67
69
|
async getConfig(
|
|
68
70
|
name: string,
|
|
@@ -77,7 +79,8 @@ export class ContainerManager {
|
|
|
77
79
|
return null
|
|
78
80
|
}
|
|
79
81
|
const content = await readFile(configPath, 'utf8')
|
|
80
|
-
|
|
82
|
+
const config = JSON.parse(content) as ContainerConfig
|
|
83
|
+
return this.migrateConfig(config)
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
// Search all engine directories
|
|
@@ -86,13 +89,43 @@ export class ContainerManager {
|
|
|
86
89
|
const configPath = paths.getContainerConfigPath(name, { engine: eng })
|
|
87
90
|
if (existsSync(configPath)) {
|
|
88
91
|
const content = await readFile(configPath, 'utf8')
|
|
89
|
-
|
|
92
|
+
const config = JSON.parse(content) as ContainerConfig
|
|
93
|
+
return this.migrateConfig(config)
|
|
90
94
|
}
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
return null
|
|
94
98
|
}
|
|
95
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Migrate old container configs to include databases array
|
|
102
|
+
* Ensures primary database is always in the databases array
|
|
103
|
+
*/
|
|
104
|
+
private async migrateConfig(
|
|
105
|
+
config: ContainerConfig,
|
|
106
|
+
): Promise<ContainerConfig> {
|
|
107
|
+
let needsSave = false
|
|
108
|
+
|
|
109
|
+
// If databases array is missing, create it with the primary database
|
|
110
|
+
if (!config.databases) {
|
|
111
|
+
config.databases = [config.database]
|
|
112
|
+
needsSave = true
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Ensure primary database is in the array
|
|
116
|
+
if (!config.databases.includes(config.database)) {
|
|
117
|
+
config.databases = [config.database, ...config.databases]
|
|
118
|
+
needsSave = true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Save if we made changes
|
|
122
|
+
if (needsSave) {
|
|
123
|
+
await this.saveConfig(config.name, { engine: config.engine }, config)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return config
|
|
127
|
+
}
|
|
128
|
+
|
|
96
129
|
/**
|
|
97
130
|
* Save container configuration
|
|
98
131
|
*/
|
|
@@ -333,6 +366,49 @@ export class ContainerManager {
|
|
|
333
366
|
return /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)
|
|
334
367
|
}
|
|
335
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Add a database to the container's databases array
|
|
371
|
+
*/
|
|
372
|
+
async addDatabase(containerName: string, database: string): Promise<void> {
|
|
373
|
+
const config = await this.getConfig(containerName)
|
|
374
|
+
if (!config) {
|
|
375
|
+
throw new Error(`Container "${containerName}" not found`)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Ensure databases array exists
|
|
379
|
+
if (!config.databases) {
|
|
380
|
+
config.databases = [config.database]
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Add if not already present
|
|
384
|
+
if (!config.databases.includes(database)) {
|
|
385
|
+
config.databases.push(database)
|
|
386
|
+
await this.saveConfig(containerName, { engine: config.engine }, config)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Remove a database from the container's databases array
|
|
392
|
+
*/
|
|
393
|
+
async removeDatabase(containerName: string, database: string): Promise<void> {
|
|
394
|
+
const config = await this.getConfig(containerName)
|
|
395
|
+
if (!config) {
|
|
396
|
+
throw new Error(`Container "${containerName}" not found`)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Don't remove the primary database from the array
|
|
400
|
+
if (database === config.database) {
|
|
401
|
+
throw new Error(
|
|
402
|
+
`Cannot remove primary database "${database}" from tracking`,
|
|
403
|
+
)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (config.databases) {
|
|
407
|
+
config.databases = config.databases.filter((db) => db !== database)
|
|
408
|
+
await this.saveConfig(containerName, { engine: config.engine }, config)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
336
412
|
/**
|
|
337
413
|
* Get connection string for a container
|
|
338
414
|
* Delegates to the appropriate engine
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
mycliDependency,
|
|
21
21
|
} from '../config/os-dependencies'
|
|
22
22
|
import { platformService } from './platform-service'
|
|
23
|
+
import { configManager } from './config-manager'
|
|
23
24
|
|
|
24
25
|
const execAsync = promisify(exec)
|
|
25
26
|
|
|
@@ -268,6 +269,10 @@ export async function installDependency(
|
|
|
268
269
|
execWithInheritedStdio(cmd)
|
|
269
270
|
}
|
|
270
271
|
|
|
272
|
+
// Refresh config cache after package manager interaction
|
|
273
|
+
// This ensures newly installed tools are detected with correct versions
|
|
274
|
+
await configManager.refreshAllBinaries()
|
|
275
|
+
|
|
271
276
|
// Verify installation
|
|
272
277
|
const status = await checkDependency(dependency)
|
|
273
278
|
if (!status.installed) {
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { exec } from 'child_process'
|
|
2
|
+
import { promisify } from 'util'
|
|
3
|
+
import { createRequire } from 'module'
|
|
4
|
+
import { configManager } from './config-manager'
|
|
5
|
+
|
|
6
|
+
const execAsync = promisify(exec)
|
|
7
|
+
const require = createRequire(import.meta.url)
|
|
8
|
+
|
|
9
|
+
const NPM_REGISTRY_URL = 'https://registry.npmjs.org/spindb'
|
|
10
|
+
const CHECK_THROTTLE_MS = 24 * 60 * 60 * 1000 // 24 hours
|
|
11
|
+
|
|
12
|
+
export type UpdateCheckResult = {
|
|
13
|
+
currentVersion: string
|
|
14
|
+
latestVersion: string
|
|
15
|
+
updateAvailable: boolean
|
|
16
|
+
lastChecked: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type UpdateResult = {
|
|
20
|
+
success: boolean
|
|
21
|
+
previousVersion: string
|
|
22
|
+
newVersion: string
|
|
23
|
+
error?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class UpdateManager {
|
|
27
|
+
/**
|
|
28
|
+
* Get currently installed version from package.json
|
|
29
|
+
*/
|
|
30
|
+
getCurrentVersion(): string {
|
|
31
|
+
const pkg = require('../package.json') as { version: string }
|
|
32
|
+
return pkg.version
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check npm registry for latest version
|
|
37
|
+
* Throttled to once per 24 hours unless force=true
|
|
38
|
+
*/
|
|
39
|
+
async checkForUpdate(force = false): Promise<UpdateCheckResult | null> {
|
|
40
|
+
const config = await configManager.load()
|
|
41
|
+
const lastCheck = config.update?.lastCheck
|
|
42
|
+
|
|
43
|
+
// Return cached result if within throttle period
|
|
44
|
+
if (!force && lastCheck) {
|
|
45
|
+
const elapsed = Date.now() - new Date(lastCheck).getTime()
|
|
46
|
+
if (elapsed < CHECK_THROTTLE_MS && config.update?.latestVersion) {
|
|
47
|
+
const currentVersion = this.getCurrentVersion()
|
|
48
|
+
return {
|
|
49
|
+
currentVersion,
|
|
50
|
+
latestVersion: config.update.latestVersion,
|
|
51
|
+
updateAvailable:
|
|
52
|
+
this.compareVersions(config.update.latestVersion, currentVersion) >
|
|
53
|
+
0,
|
|
54
|
+
lastChecked: lastCheck,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const latestVersion = await this.fetchLatestVersion()
|
|
61
|
+
const currentVersion = this.getCurrentVersion()
|
|
62
|
+
|
|
63
|
+
// Update cache
|
|
64
|
+
config.update = {
|
|
65
|
+
...config.update,
|
|
66
|
+
lastCheck: new Date().toISOString(),
|
|
67
|
+
latestVersion,
|
|
68
|
+
}
|
|
69
|
+
await configManager.save()
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
currentVersion,
|
|
73
|
+
latestVersion,
|
|
74
|
+
updateAvailable:
|
|
75
|
+
this.compareVersions(latestVersion, currentVersion) > 0,
|
|
76
|
+
lastChecked: new Date().toISOString(),
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Offline or registry error - return null
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Perform self-update via npm
|
|
86
|
+
*/
|
|
87
|
+
async performUpdate(): Promise<UpdateResult> {
|
|
88
|
+
const previousVersion = this.getCurrentVersion()
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
// Execute npm install globally
|
|
92
|
+
await execAsync('npm install -g spindb@latest', { timeout: 60000 })
|
|
93
|
+
|
|
94
|
+
// Verify new version by checking what npm reports
|
|
95
|
+
const { stdout } = await execAsync('npm list -g spindb --json')
|
|
96
|
+
const npmData = JSON.parse(stdout) as {
|
|
97
|
+
dependencies?: { spindb?: { version?: string } }
|
|
98
|
+
}
|
|
99
|
+
const newVersion =
|
|
100
|
+
npmData.dependencies?.spindb?.version || previousVersion
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
success: true,
|
|
104
|
+
previousVersion,
|
|
105
|
+
newVersion,
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
109
|
+
|
|
110
|
+
// Detect permission issues
|
|
111
|
+
if (message.includes('EACCES') || message.includes('permission')) {
|
|
112
|
+
return {
|
|
113
|
+
success: false,
|
|
114
|
+
previousVersion,
|
|
115
|
+
newVersion: previousVersion,
|
|
116
|
+
error: 'Permission denied. Try: sudo npm install -g spindb@latest',
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
success: false,
|
|
122
|
+
previousVersion,
|
|
123
|
+
newVersion: previousVersion,
|
|
124
|
+
error: message,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get cached update info (for showing notification without network call)
|
|
131
|
+
*/
|
|
132
|
+
async getCachedUpdateInfo(): Promise<{
|
|
133
|
+
latestVersion?: string
|
|
134
|
+
autoCheckEnabled: boolean
|
|
135
|
+
}> {
|
|
136
|
+
const config = await configManager.load()
|
|
137
|
+
return {
|
|
138
|
+
latestVersion: config.update?.latestVersion,
|
|
139
|
+
autoCheckEnabled: config.update?.autoCheckEnabled !== false,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Set whether auto-update checks are enabled
|
|
145
|
+
*/
|
|
146
|
+
async setAutoCheckEnabled(enabled: boolean): Promise<void> {
|
|
147
|
+
const config = await configManager.load()
|
|
148
|
+
config.update = {
|
|
149
|
+
...config.update,
|
|
150
|
+
autoCheckEnabled: enabled,
|
|
151
|
+
}
|
|
152
|
+
await configManager.save()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Fetch latest version from npm registry
|
|
157
|
+
*/
|
|
158
|
+
private async fetchLatestVersion(): Promise<string> {
|
|
159
|
+
const controller = new AbortController()
|
|
160
|
+
const timeout = setTimeout(() => controller.abort(), 10000)
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const response = await fetch(NPM_REGISTRY_URL, {
|
|
164
|
+
signal: controller.signal,
|
|
165
|
+
})
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
throw new Error(`Registry returned ${response.status}`)
|
|
168
|
+
}
|
|
169
|
+
const data = (await response.json()) as {
|
|
170
|
+
'dist-tags': { latest: string }
|
|
171
|
+
}
|
|
172
|
+
return data['dist-tags'].latest
|
|
173
|
+
} finally {
|
|
174
|
+
clearTimeout(timeout)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Compare semver versions
|
|
180
|
+
* Returns >0 if a > b, <0 if a < b, 0 if equal
|
|
181
|
+
*/
|
|
182
|
+
compareVersions(a: string, b: string): number {
|
|
183
|
+
const partsA = a.split('.').map((n) => parseInt(n, 10) || 0)
|
|
184
|
+
const partsB = b.split('.').map((n) => parseInt(n, 10) || 0)
|
|
185
|
+
|
|
186
|
+
for (let i = 0; i < 3; i++) {
|
|
187
|
+
const diff = (partsA[i] || 0) - (partsB[i] || 0)
|
|
188
|
+
if (diff !== 0) return diff
|
|
189
|
+
}
|
|
190
|
+
return 0
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export const updateManager = new UpdateManager()
|
package/engines/base-engine.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type {
|
|
|
2
2
|
ContainerConfig,
|
|
3
3
|
ProgressCallback,
|
|
4
4
|
BackupFormat,
|
|
5
|
+
BackupOptions,
|
|
6
|
+
BackupResult,
|
|
5
7
|
RestoreResult,
|
|
6
8
|
DumpResult,
|
|
7
9
|
StatusResult,
|
|
@@ -131,4 +133,22 @@ export abstract class BaseEngine {
|
|
|
131
133
|
connectionString: string,
|
|
132
134
|
outputPath: string,
|
|
133
135
|
): Promise<DumpResult>
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get the size of a database in bytes
|
|
139
|
+
* Returns null if the container is not running or size cannot be determined
|
|
140
|
+
*/
|
|
141
|
+
abstract getDatabaseSize(container: ContainerConfig): Promise<number | null>
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Create a backup of a database
|
|
145
|
+
* @param container - The container configuration
|
|
146
|
+
* @param outputPath - Path to write the backup file
|
|
147
|
+
* @param options - Backup options including database name and format
|
|
148
|
+
*/
|
|
149
|
+
abstract backup(
|
|
150
|
+
container: ContainerConfig,
|
|
151
|
+
outputPath: string,
|
|
152
|
+
options: BackupOptions,
|
|
153
|
+
): Promise<BackupResult>
|
|
134
154
|
}
|