spindb 0.5.5 → 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 CHANGED
@@ -18,11 +18,23 @@ Spin up local PostgreSQL and MySQL databases without Docker. A lightweight alter
18
18
  ## Installation
19
19
 
20
20
  ```bash
21
- # Run directly with pnpx (no install needed)
21
+ # Install globally (recommended)
22
+ npm install -g spindb
23
+
24
+ # Or run directly with pnpx (no install needed)
22
25
  pnpx spindb
26
+ ```
27
+
28
+ ### Updating
29
+
30
+ SpinDB checks for updates automatically and will notify you when a new version is available:
31
+
32
+ ```bash
33
+ # Update to latest version
34
+ spindb self-update
23
35
 
24
- # Or install globally
25
- pnpm add -g spindb
36
+ # Or check manually
37
+ spindb version --check
26
38
  ```
27
39
 
28
40
  ## Quick Start
@@ -61,6 +73,10 @@ spindb connect mydb
61
73
  | `spindb config detect` | Auto-detect database tools |
62
74
  | `spindb deps check` | Check status of client tools |
63
75
  | `spindb deps install` | Install missing client tools |
76
+ | `spindb version` | Show current version |
77
+ | `spindb version --check` | Check for available updates |
78
+ | `spindb self-update` | Update to latest version |
79
+ | `spindb config update-check [on\|off]` | Enable/disable update notifications |
64
80
 
65
81
  ## Supported Engines
66
82
 
@@ -51,7 +51,10 @@ export const backupCommand = new Command('backup')
51
51
  .argument('[container]', 'Container name')
52
52
  .option('-d, --database <name>', 'Database to backup')
53
53
  .option('-n, --name <name>', 'Custom backup filename (without extension)')
54
- .option('-o, --output <path>', 'Output directory (defaults to current directory)')
54
+ .option(
55
+ '-o, --output <path>',
56
+ 'Output directory (defaults to current directory)',
57
+ )
55
58
  .option('--format <format>', 'Output format: sql or dump')
56
59
  .option('--sql', 'Output as plain SQL (shorthand for --format sql)')
57
60
  .option('--dump', 'Output as dump format (shorthand for --format dump)')
@@ -198,7 +201,10 @@ export const backupCommand = new Command('backup')
198
201
  }
199
202
 
200
203
  // Determine filename
201
- const defaultFilename = generateDefaultFilename(containerName, databaseName)
204
+ const defaultFilename = generateDefaultFilename(
205
+ containerName,
206
+ databaseName,
207
+ )
202
208
  let filename = options.name || defaultFilename
203
209
 
204
210
  // In interactive mode with no name provided, optionally prompt for custom name
@@ -229,17 +235,17 @@ export const backupCommand = new Command('backup')
229
235
  console.log(success('Backup complete'))
230
236
  console.log()
231
237
  console.log(chalk.gray(' File:'), chalk.cyan(result.path))
232
- console.log(chalk.gray(' Size:'), chalk.white(formatBytes(result.size)))
238
+ console.log(
239
+ chalk.gray(' Size:'),
240
+ chalk.white(formatBytes(result.size)),
241
+ )
233
242
  console.log(chalk.gray(' Format:'), chalk.white(result.format))
234
243
  console.log()
235
244
  } catch (err) {
236
245
  const e = err as Error
237
246
 
238
247
  // Check if this is a missing tool error
239
- const missingToolPatterns = [
240
- 'pg_dump not found',
241
- 'mysqldump not found',
242
- ]
248
+ const missingToolPatterns = ['pg_dump not found', 'mysqldump not found']
243
249
 
244
250
  const matchingPattern = missingToolPatterns.find((p) =>
245
251
  e.message.includes(p),
@@ -8,7 +8,8 @@ import {
8
8
  ENHANCED_SHELLS,
9
9
  ALL_TOOLS,
10
10
  } from '../../core/config-manager'
11
- import { error, success, header } from '../ui/theme'
11
+ import { updateManager } from '../../core/update-manager'
12
+ import { error, success, header, info } from '../ui/theme'
12
13
  import { createSpinner } from '../ui/spinner'
13
14
  import type { BinaryTool } from '../../types'
14
15
 
@@ -78,7 +79,9 @@ export const configCommand = new Command('config')
78
79
 
79
80
  if (config.updatedAt) {
80
81
  const isStale = await configManager.isStale()
81
- const staleWarning = isStale ? chalk.yellow(' (stale - run config detect to refresh)') : ''
82
+ const staleWarning = isStale
83
+ ? chalk.yellow(' (stale - run config detect to refresh)')
84
+ : ''
82
85
  console.log(
83
86
  chalk.gray(
84
87
  ` Last updated: ${new Date(config.updatedAt).toLocaleString()}${staleWarning}`,
@@ -291,3 +294,55 @@ export const configCommand = new Command('config')
291
294
  }
292
295
  }),
293
296
  )
297
+ .addCommand(
298
+ new Command('update-check')
299
+ .description('Enable or disable automatic update checks on startup')
300
+ .argument('[state]', 'on or off (omit to show current status)')
301
+ .action(async (state?: string) => {
302
+ try {
303
+ const cached = await updateManager.getCachedUpdateInfo()
304
+
305
+ if (!state) {
306
+ // Show current status
307
+ const status = cached.autoCheckEnabled
308
+ ? chalk.green('enabled')
309
+ : chalk.yellow('disabled')
310
+ console.log()
311
+ console.log(` Update checks on startup: ${status}`)
312
+ console.log()
313
+ console.log(chalk.gray(' Usage:'))
314
+ console.log(
315
+ chalk.gray(' spindb config update-check on # Enable'),
316
+ )
317
+ console.log(
318
+ chalk.gray(' spindb config update-check off # Disable'),
319
+ )
320
+ console.log()
321
+ return
322
+ }
323
+
324
+ if (state !== 'on' && state !== 'off') {
325
+ console.error(error('Invalid state. Use "on" or "off"'))
326
+ process.exit(1)
327
+ }
328
+
329
+ const enabled = state === 'on'
330
+ await updateManager.setAutoCheckEnabled(enabled)
331
+
332
+ if (enabled) {
333
+ console.log(success('Update checks enabled on startup'))
334
+ } else {
335
+ console.log(info('Update checks disabled on startup'))
336
+ console.log(
337
+ chalk.gray(
338
+ ' You can still manually check with: spindb version --check',
339
+ ),
340
+ )
341
+ }
342
+ } catch (err) {
343
+ const e = err as Error
344
+ console.error(error(e.message))
345
+ process.exit(1)
346
+ }
347
+ }),
348
+ )
@@ -26,9 +26,15 @@ export const connectCommand = new Command('connect')
26
26
  .option('-d, --database <name>', 'Database name')
27
27
  .option('--tui', 'Use usql for enhanced shell experience')
28
28
  .option('--install-tui', 'Install usql if not present, then connect')
29
- .option('--pgcli', 'Use pgcli for enhanced PostgreSQL shell (dropdown auto-completion)')
29
+ .option(
30
+ '--pgcli',
31
+ 'Use pgcli for enhanced PostgreSQL shell (dropdown auto-completion)',
32
+ )
30
33
  .option('--install-pgcli', 'Install pgcli if not present, then connect')
31
- .option('--mycli', 'Use mycli for enhanced MySQL shell (dropdown auto-completion)')
34
+ .option(
35
+ '--mycli',
36
+ 'Use mycli for enhanced MySQL shell (dropdown auto-completion)',
37
+ )
32
38
  .option('--install-mycli', 'Install mycli if not present, then connect')
33
39
  .action(
34
40
  async (
@@ -164,7 +170,9 @@ export const connectCommand = new Command('connect')
164
170
  const usePgcli = options.pgcli || options.installPgcli
165
171
  if (usePgcli) {
166
172
  if (engineName !== 'postgresql') {
167
- console.error(error('pgcli is only available for PostgreSQL containers'))
173
+ console.error(
174
+ error('pgcli is only available for PostgreSQL containers'),
175
+ )
168
176
  console.log(chalk.gray('For MySQL, use: spindb connect --mycli'))
169
177
  process.exit(1)
170
178
  }
@@ -173,7 +181,9 @@ export const connectCommand = new Command('connect')
173
181
 
174
182
  if (!pgcliInstalled) {
175
183
  if (options.installPgcli) {
176
- console.log(info('Installing pgcli for enhanced PostgreSQL shell...'))
184
+ console.log(
185
+ info('Installing pgcli for enhanced PostgreSQL shell...'),
186
+ )
177
187
  const pm = await detectPackageManager()
178
188
  if (pm) {
179
189
  const result = await installPgcli(pm)
@@ -181,7 +191,9 @@ export const connectCommand = new Command('connect')
181
191
  console.log(success('pgcli installed successfully!'))
182
192
  console.log()
183
193
  } else {
184
- console.error(error(`Failed to install pgcli: ${result.error}`))
194
+ console.error(
195
+ error(`Failed to install pgcli: ${result.error}`),
196
+ )
185
197
  console.log()
186
198
  console.log(chalk.gray('Manual installation:'))
187
199
  for (const instruction of getPgcliManualInstructions()) {
@@ -201,7 +213,9 @@ export const connectCommand = new Command('connect')
201
213
  } else {
202
214
  console.error(error('pgcli is not installed'))
203
215
  console.log()
204
- console.log(chalk.gray('Install pgcli for enhanced PostgreSQL shell:'))
216
+ console.log(
217
+ chalk.gray('Install pgcli for enhanced PostgreSQL shell:'),
218
+ )
205
219
  console.log(chalk.cyan(' spindb connect --install-pgcli'))
206
220
  console.log()
207
221
  console.log(chalk.gray('Or install manually:'))
@@ -218,7 +232,9 @@ export const connectCommand = new Command('connect')
218
232
  if (useMycli) {
219
233
  if (engineName !== 'mysql') {
220
234
  console.error(error('mycli is only available for MySQL containers'))
221
- console.log(chalk.gray('For PostgreSQL, use: spindb connect --pgcli'))
235
+ console.log(
236
+ chalk.gray('For PostgreSQL, use: spindb connect --pgcli'),
237
+ )
222
238
  process.exit(1)
223
239
  }
224
240
 
@@ -234,7 +250,9 @@ export const connectCommand = new Command('connect')
234
250
  console.log(success('mycli installed successfully!'))
235
251
  console.log()
236
252
  } else {
237
- console.error(error(`Failed to install mycli: ${result.error}`))
253
+ console.error(
254
+ error(`Failed to install mycli: ${result.error}`),
255
+ )
238
256
  console.log()
239
257
  console.log(chalk.gray('Manual installation:'))
240
258
  for (const instruction of getMycliManualInstructions()) {
@@ -327,7 +345,9 @@ export const connectCommand = new Command('connect')
327
345
 
328
346
  if (clientCmd === 'usql') {
329
347
  console.log(chalk.gray(' Install usql:'))
330
- console.log(chalk.cyan(' brew tap xo/xo && brew install xo/xo/usql'))
348
+ console.log(
349
+ chalk.cyan(' brew tap xo/xo && brew install xo/xo/usql'),
350
+ )
331
351
  } else if (clientCmd === 'pgcli') {
332
352
  console.log(chalk.gray(' Install pgcli:'))
333
353
  console.log(chalk.cyan(' brew install pgcli'))
@@ -85,8 +85,7 @@ export const listCommand = new Command('list')
85
85
  const engineDisplay = `${engineIcon} ${container.engine}`
86
86
 
87
87
  // Format size: show value if running, dash if stopped
88
- const sizeDisplay =
89
- size !== null ? formatBytes(size) : chalk.gray('—')
88
+ const sizeDisplay = size !== null ? formatBytes(size) : chalk.gray('—')
90
89
 
91
90
  console.log(
92
91
  chalk.gray(' ') +
@@ -56,6 +56,7 @@ import {
56
56
  isMariaDB,
57
57
  getMysqlInstallInfo,
58
58
  } from '../../engines/mysql/binary-detection'
59
+ import { updateManager } from '../../core/update-manager'
59
60
 
60
61
  type MenuChoice =
61
62
  | {
@@ -167,6 +168,7 @@ async function showMainMenu(): Promise<void> {
167
168
  disabled: hasEngines ? false : 'No engines installed',
168
169
  },
169
170
  new inquirer.Separator(),
171
+ { name: `${chalk.cyan('↑')} Check for updates`, value: 'check-update' },
170
172
  { name: `${chalk.gray('⏻')} Exit`, value: 'exit' },
171
173
  ]
172
174
 
@@ -205,6 +207,9 @@ async function showMainMenu(): Promise<void> {
205
207
  case 'engines':
206
208
  await handleEngines()
207
209
  break
210
+ case 'check-update':
211
+ await handleCheckUpdate()
212
+ break
208
213
  case 'exit':
209
214
  console.log(chalk.gray('\n Goodbye!\n'))
210
215
  process.exit(0)
@@ -214,6 +219,94 @@ async function showMainMenu(): Promise<void> {
214
219
  await showMainMenu()
215
220
  }
216
221
 
222
+ async function handleCheckUpdate(): Promise<void> {
223
+ console.clear()
224
+ console.log(header('Check for Updates'))
225
+ console.log()
226
+
227
+ const spinner = createSpinner('Checking for updates...')
228
+ spinner.start()
229
+
230
+ const result = await updateManager.checkForUpdate(true)
231
+
232
+ if (!result) {
233
+ spinner.fail('Could not reach npm registry')
234
+ console.log()
235
+ console.log(info('Check your internet connection and try again.'))
236
+ console.log(chalk.gray(' Manual update: npm install -g spindb@latest'))
237
+ console.log()
238
+ await pressEnterToContinue()
239
+ return
240
+ }
241
+
242
+ if (result.updateAvailable) {
243
+ spinner.succeed('Update available')
244
+ console.log()
245
+ console.log(chalk.gray(` Current version: ${result.currentVersion}`))
246
+ console.log(
247
+ chalk.gray(` Latest version: ${chalk.green(result.latestVersion)}`),
248
+ )
249
+ console.log()
250
+
251
+ const { action } = await inquirer.prompt<{ action: string }>([
252
+ {
253
+ type: 'list',
254
+ name: 'action',
255
+ message: 'What would you like to do?',
256
+ choices: [
257
+ { name: 'Update now', value: 'update' },
258
+ { name: 'Remind me later', value: 'later' },
259
+ { name: "Don't check for updates on startup", value: 'disable' },
260
+ ],
261
+ },
262
+ ])
263
+
264
+ if (action === 'update') {
265
+ console.log()
266
+ const updateSpinner = createSpinner('Updating spindb...')
267
+ updateSpinner.start()
268
+
269
+ const updateResult = await updateManager.performUpdate()
270
+
271
+ if (updateResult.success) {
272
+ updateSpinner.succeed('Update complete')
273
+ console.log()
274
+ console.log(
275
+ success(
276
+ `Updated from ${updateResult.previousVersion} to ${updateResult.newVersion}`,
277
+ ),
278
+ )
279
+ console.log()
280
+ if (updateResult.previousVersion !== updateResult.newVersion) {
281
+ console.log(warning('Please restart spindb to use the new version.'))
282
+ console.log()
283
+ }
284
+ } else {
285
+ updateSpinner.fail('Update failed')
286
+ console.log()
287
+ console.log(error(updateResult.error || 'Unknown error'))
288
+ console.log()
289
+ console.log(info('Manual update: npm install -g spindb@latest'))
290
+ }
291
+ await pressEnterToContinue()
292
+ } else if (action === 'disable') {
293
+ await updateManager.setAutoCheckEnabled(false)
294
+ console.log()
295
+ console.log(info('Update checks disabled on startup.'))
296
+ console.log(chalk.gray(' Re-enable with: spindb config update-check on'))
297
+ console.log()
298
+ await pressEnterToContinue()
299
+ }
300
+ // 'later' just returns to menu
301
+ } else {
302
+ spinner.succeed('You are on the latest version')
303
+ console.log()
304
+ console.log(chalk.gray(` Version: ${result.currentVersion}`))
305
+ console.log()
306
+ await pressEnterToContinue()
307
+ }
308
+ }
309
+
217
310
  async function handleCreate(): Promise<void> {
218
311
  console.log()
219
312
  const answers = await promptCreateOptions()
@@ -1630,7 +1723,9 @@ async function handleBackup(): Promise<void> {
1630
1723
  missingDeps = await getMissingDependencies(config.engine)
1631
1724
  if (missingDeps.length > 0) {
1632
1725
  console.log(
1633
- error(`Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`),
1726
+ error(
1727
+ `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
1728
+ ),
1634
1729
  )
1635
1730
  return
1636
1731
  }
@@ -1646,7 +1741,10 @@ async function handleBackup(): Promise<void> {
1646
1741
  let databaseName: string
1647
1742
 
1648
1743
  if (databases.length > 1) {
1649
- databaseName = await promptDatabaseSelect(databases, 'Select database to backup:')
1744
+ databaseName = await promptDatabaseSelect(
1745
+ databases,
1746
+ 'Select database to backup:',
1747
+ )
1650
1748
  } else {
1651
1749
  databaseName = databases[0]
1652
1750
  }
@@ -0,0 +1,109 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import inquirer from 'inquirer'
4
+ import { updateManager } from '../../core/update-manager'
5
+ import { createSpinner } from '../ui/spinner'
6
+ import { success, error, info, header } from '../ui/theme'
7
+
8
+ export const selfUpdateCommand = new Command('self-update')
9
+ .alias('update')
10
+ .description('Update spindb to the latest version')
11
+ .option('-f, --force', 'Update even if already on latest version')
12
+ .option('-y, --yes', 'Skip confirmation prompt')
13
+ .action(
14
+ async (options: { force?: boolean; yes?: boolean }): Promise<void> => {
15
+ console.log()
16
+ console.log(header('SpinDB Self-Update'))
17
+ console.log()
18
+
19
+ const checkSpinner = createSpinner('Checking for updates...')
20
+ checkSpinner.start()
21
+
22
+ const result = await updateManager.checkForUpdate(true)
23
+
24
+ if (!result) {
25
+ checkSpinner.fail('Could not reach npm registry')
26
+ console.log()
27
+ console.log(info('Check your internet connection and try again.'))
28
+ console.log(chalk.gray(' Manual update: npm install -g spindb@latest'))
29
+ process.exit(1)
30
+ }
31
+
32
+ if (!result.updateAvailable && !options.force) {
33
+ checkSpinner.succeed('Already on latest version')
34
+ console.log()
35
+ console.log(chalk.gray(` Current version: ${result.currentVersion}`))
36
+ console.log(chalk.gray(` Latest version: ${result.latestVersion}`))
37
+ console.log()
38
+ return
39
+ }
40
+
41
+ if (result.updateAvailable) {
42
+ checkSpinner.succeed('Update available')
43
+ } else {
44
+ checkSpinner.succeed('Version check complete')
45
+ }
46
+
47
+ console.log()
48
+ console.log(chalk.gray(` Current version: ${result.currentVersion}`))
49
+ console.log(
50
+ chalk.gray(
51
+ ` Latest version: ${result.updateAvailable ? chalk.green(result.latestVersion) : result.latestVersion}`,
52
+ ),
53
+ )
54
+ console.log()
55
+
56
+ // Confirm unless --yes
57
+ if (!options.yes) {
58
+ const message = result.updateAvailable
59
+ ? `Update spindb from ${result.currentVersion} to ${result.latestVersion}?`
60
+ : `Reinstall spindb ${result.currentVersion}?`
61
+
62
+ const { confirm } = await inquirer.prompt<{ confirm: boolean }>([
63
+ {
64
+ type: 'confirm',
65
+ name: 'confirm',
66
+ message,
67
+ default: true,
68
+ },
69
+ ])
70
+
71
+ if (!confirm) {
72
+ console.log(chalk.yellow('Update cancelled'))
73
+ return
74
+ }
75
+ }
76
+
77
+ console.log()
78
+ const updateSpinner = createSpinner('Updating spindb...')
79
+ updateSpinner.start()
80
+
81
+ const updateResult = await updateManager.performUpdate()
82
+
83
+ if (updateResult.success) {
84
+ updateSpinner.succeed('Update complete')
85
+ console.log()
86
+ console.log(
87
+ success(
88
+ `Updated from ${updateResult.previousVersion} to ${updateResult.newVersion}`,
89
+ ),
90
+ )
91
+ console.log()
92
+ if (updateResult.previousVersion !== updateResult.newVersion) {
93
+ console.log(
94
+ chalk.gray(
95
+ ' Please restart your terminal to use the new version.',
96
+ ),
97
+ )
98
+ console.log()
99
+ }
100
+ } else {
101
+ updateSpinner.fail('Update failed')
102
+ console.log()
103
+ console.log(error(updateResult.error || 'Unknown error'))
104
+ console.log()
105
+ console.log(info('Manual update: npm install -g spindb@latest'))
106
+ process.exit(1)
107
+ }
108
+ },
109
+ )
@@ -0,0 +1,55 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import { updateManager } from '../../core/update-manager'
4
+ import { createSpinner } from '../ui/spinner'
5
+
6
+ export const versionCommand = new Command('version')
7
+ .description('Show version information and check for updates')
8
+ .option('-c, --check', 'Check for available updates')
9
+ .option('-j, --json', 'Output as JSON')
10
+ .action(
11
+ async (options: { check?: boolean; json?: boolean }): Promise<void> => {
12
+ const currentVersion = updateManager.getCurrentVersion()
13
+
14
+ if (options.check) {
15
+ const spinner = createSpinner('Checking for updates...')
16
+ if (!options.json) spinner.start()
17
+
18
+ const result = await updateManager.checkForUpdate(true)
19
+
20
+ if (!options.json) spinner.stop()
21
+
22
+ if (options.json) {
23
+ console.log(
24
+ JSON.stringify({
25
+ current: currentVersion,
26
+ latest: result?.latestVersion || null,
27
+ updateAvailable: result?.updateAvailable || false,
28
+ }),
29
+ )
30
+ } else {
31
+ console.log()
32
+ console.log(`SpinDB v${currentVersion}`)
33
+ if (result) {
34
+ if (result.updateAvailable) {
35
+ console.log(
36
+ chalk.yellow(`Update available: v${result.latestVersion}`),
37
+ )
38
+ console.log(chalk.gray("Run 'spindb self-update' to update."))
39
+ } else {
40
+ console.log(chalk.green('You are on the latest version.'))
41
+ }
42
+ } else {
43
+ console.log(chalk.gray('Could not check for updates (offline?)'))
44
+ }
45
+ console.log()
46
+ }
47
+ } else {
48
+ if (options.json) {
49
+ console.log(JSON.stringify({ current: currentVersion }))
50
+ } else {
51
+ console.log(`SpinDB v${currentVersion}`)
52
+ }
53
+ }
54
+ },
55
+ )
package/cli/index.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import { program } from 'commander'
2
+ import { createRequire } from 'module'
3
+ import chalk from 'chalk'
2
4
  import { createCommand } from './commands/create'
5
+
6
+ const require = createRequire(import.meta.url)
7
+ const pkg = require('../package.json') as { version: string }
3
8
  import { listCommand } from './commands/list'
4
9
  import { startCommand } from './commands/start'
5
10
  import { stopCommand } from './commands/stop'
@@ -15,12 +20,86 @@ import { enginesCommand } from './commands/engines'
15
20
  import { editCommand } from './commands/edit'
16
21
  import { urlCommand } from './commands/url'
17
22
  import { infoCommand } from './commands/info'
23
+ import { selfUpdateCommand } from './commands/self-update'
24
+ import { versionCommand } from './commands/version'
25
+ import { updateManager } from '../core/update-manager'
26
+
27
+ /**
28
+ * Show update notification banner if an update is available (from cached data)
29
+ * This shows on every run until the user updates or disables checks
30
+ */
31
+ async function showUpdateNotificationIfAvailable(): Promise<void> {
32
+ try {
33
+ const cached = await updateManager.getCachedUpdateInfo()
34
+
35
+ // Skip if auto-check is disabled or no cached version
36
+ if (!cached.autoCheckEnabled || !cached.latestVersion) return
37
+
38
+ const currentVersion = updateManager.getCurrentVersion()
39
+ const latestVersion = cached.latestVersion
40
+
41
+ // Skip if no update available
42
+ if (updateManager.compareVersions(latestVersion, currentVersion) <= 0)
43
+ return
44
+
45
+ // Show notification banner
46
+ console.log()
47
+ console.log(chalk.cyan('┌' + '─'.repeat(52) + '┐'))
48
+ console.log(
49
+ chalk.cyan('│') +
50
+ chalk.yellow(' Update available! ') +
51
+ chalk.gray(`${currentVersion} -> `) +
52
+ chalk.green(latestVersion) +
53
+ ' '.repeat(
54
+ Math.max(
55
+ 0,
56
+ 52 - 21 - currentVersion.length - 4 - latestVersion.length,
57
+ ),
58
+ ) +
59
+ chalk.cyan('│'),
60
+ )
61
+ console.log(
62
+ chalk.cyan('│') +
63
+ chalk.gray(' Run: ') +
64
+ chalk.cyan('spindb self-update') +
65
+ ' '.repeat(28) +
66
+ chalk.cyan('│'),
67
+ )
68
+ console.log(
69
+ chalk.cyan('│') +
70
+ chalk.gray(' To disable: ') +
71
+ chalk.gray('spindb config update-check off') +
72
+ ' '.repeat(8) +
73
+ chalk.cyan('│'),
74
+ )
75
+ console.log(chalk.cyan('└' + '─'.repeat(52) + '┘'))
76
+ console.log()
77
+ } catch {
78
+ // Silently ignore errors - update notification is not critical
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Trigger background update check (fire and forget)
84
+ * This updates the cache for the next run's notification
85
+ */
86
+ function triggerBackgroundUpdateCheck(): void {
87
+ updateManager.checkForUpdate(false).catch(() => {
88
+ // Silently ignore - background check is best-effort
89
+ })
90
+ }
18
91
 
19
92
  export async function run(): Promise<void> {
93
+ // Trigger background update check (non-blocking, updates cache for next run)
94
+ triggerBackgroundUpdateCheck()
95
+
96
+ // Show update notification if an update is available (from cached data)
97
+ await showUpdateNotificationIfAvailable()
98
+
20
99
  program
21
100
  .name('spindb')
22
101
  .description('Spin up local database containers without Docker')
23
- .version('0.1.0', '-v, --version', 'output the version number')
102
+ .version(pkg.version, '-v, --version', 'output the version number')
24
103
 
25
104
  program.addCommand(createCommand)
26
105
  program.addCommand(listCommand)
@@ -38,6 +117,8 @@ export async function run(): Promise<void> {
38
117
  program.addCommand(editCommand)
39
118
  program.addCommand(urlCommand)
40
119
  program.addCommand(infoCommand)
120
+ program.addCommand(selfUpdateCommand)
121
+ program.addCommand(versionCommand)
41
122
 
42
123
  // If no arguments provided, show interactive menu
43
124
  if (process.argv.length <= 2) {
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 = engine === 'mysql' ? 'Database (schema) name:' : 'Database name:'
254
+ const label =
255
+ engine === 'mysql' ? 'Database (schema) name:' : 'Database name:'
255
256
 
256
257
  const { database } = await inquirer.prompt<{ database: string }>([
257
258
  {
@@ -29,12 +29,7 @@ const POSTGRESQL_TOOLS: BinaryTool[] = [
29
29
  'pg_basebackup',
30
30
  ]
31
31
 
32
- const MYSQL_TOOLS: BinaryTool[] = [
33
- 'mysql',
34
- 'mysqldump',
35
- 'mysqladmin',
36
- 'mysqld',
37
- ]
32
+ const MYSQL_TOOLS: BinaryTool[] = ['mysql', 'mysqldump', 'mysqladmin', 'mysqld']
38
33
 
39
34
  const ENHANCED_SHELLS: BinaryTool[] = ['pgcli', 'mycli', 'usql']
40
35
 
@@ -359,9 +354,4 @@ export class ConfigManager {
359
354
  export const configManager = new ConfigManager()
360
355
 
361
356
  // Export tool categories for use in commands
362
- export {
363
- POSTGRESQL_TOOLS,
364
- MYSQL_TOOLS,
365
- ENHANCED_SHELLS,
366
- ALL_TOOLS,
367
- }
357
+ export { POSTGRESQL_TOOLS, MYSQL_TOOLS, ENHANCED_SHELLS, ALL_TOOLS }
@@ -398,7 +398,9 @@ export class ContainerManager {
398
398
 
399
399
  // Don't remove the primary database from the array
400
400
  if (database === config.database) {
401
- throw new Error(`Cannot remove primary database "${database}" from tracking`)
401
+ throw new Error(
402
+ `Cannot remove primary database "${database}" from tracking`,
403
+ )
402
404
  }
403
405
 
404
406
  if (config.databases) {
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spindb",
3
- "version": "0.5.5",
3
+ "version": "0.6.0",
4
4
  "description": "Spin up local database containers without Docker. A DBngin-like CLI for PostgreSQL and MySQL.",
5
5
  "type": "module",
6
6
  "bin": {
package/types/index.ts CHANGED
@@ -147,4 +147,10 @@ export type SpinDBConfig = {
147
147
  }
148
148
  // Last updated timestamp
149
149
  updatedAt?: string
150
+ // Self-update tracking
151
+ update?: {
152
+ lastCheck?: string // ISO timestamp of last npm registry check
153
+ latestVersion?: string // Latest version found from registry
154
+ autoCheckEnabled?: boolean // Default true, user can disable
155
+ }
150
156
  }