spindb 0.10.1 → 0.10.3

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
@@ -283,9 +283,22 @@ spindb stop mydb
283
283
 
284
284
  ```bash
285
285
  spindb delete mydb
286
- spindb delete mydb --force # Skip confirmation
286
+ spindb delete mydb --yes # Skip confirmation prompt
287
+ spindb delete mydb --force # Force stop if running
288
+ spindb delete mydb -fy # Both: force stop + skip confirmation
287
289
  ```
288
290
 
291
+ <details>
292
+ <summary>All options</summary>
293
+
294
+ | Option | Description |
295
+ |--------|-------------|
296
+ | `--force`, `-f` | Force stop if container is running before deleting |
297
+ | `--yes`, `-y` | Skip confirmation prompt (for scripts/automation) |
298
+ | `--json`, `-j` | Output result as JSON |
299
+
300
+ </details>
301
+
289
302
  ### Data Operations
290
303
 
291
304
  #### `connect` - Open database shell
@@ -345,13 +358,41 @@ spindb backup mydb --sql
345
358
  spindb backup mydb --dump
346
359
  ```
347
360
 
361
+ <details>
362
+ <summary>All options</summary>
363
+
364
+ | Option | Description |
365
+ |--------|-------------|
366
+ | `--database`, `-d` | Database to backup (defaults to primary) |
367
+ | `--name`, `-n` | Custom backup filename (without extension) |
368
+ | `--output`, `-o` | Output directory (defaults to current directory) |
369
+ | `--format` | Output format: `sql` or `dump` |
370
+ | `--sql` | Shorthand for `--format sql` |
371
+ | `--dump` | Shorthand for `--format dump` |
372
+ | `--json`, `-j` | Output result as JSON |
373
+
374
+ </details>
375
+
348
376
  #### `restore` - Restore from backup
349
377
 
350
378
  ```bash
351
379
  spindb restore mydb backup.dump
352
380
  spindb restore mydb backup.sql --database my_app
381
+ spindb restore mydb --from-url "postgresql://user:pass@host/db"
353
382
  ```
354
383
 
384
+ <details>
385
+ <summary>All options</summary>
386
+
387
+ | Option | Description |
388
+ |--------|-------------|
389
+ | `--database`, `-d` | Target database name |
390
+ | `--from-url` | Pull data from a remote database connection string |
391
+ | `--force`, `-f` | Overwrite existing database without confirmation |
392
+ | `--json`, `-j` | Output result as JSON |
393
+
394
+ </details>
395
+
355
396
  ### Container Management
356
397
 
357
398
  #### `list` - List all containers
@@ -376,28 +376,26 @@ export const createCommand = new Command('create')
376
376
  }
377
377
  }
378
378
 
379
- // For PostgreSQL, download binaries FIRST - they include client tools (psql, pg_dump, etc.)
380
- // This avoids requiring a separate system installation of client tools
379
+ // For PostgreSQL, ensure binaries FIRST - they include client tools (psql, pg_dump, etc.)
380
+ // ensureBinaries also registers tool paths in config cache so getMissingDependencies can find them
381
381
  if (isPostgreSQL) {
382
382
  const binarySpinner = createSpinner(
383
383
  `Checking ${dbEngine.displayName} ${version} binaries...`,
384
384
  )
385
385
  binarySpinner.start()
386
386
 
387
- const isInstalled = await dbEngine.isBinaryInstalled(version)
388
- if (isInstalled) {
389
- binarySpinner.succeed(
390
- `${dbEngine.displayName} ${version} binaries ready (cached)`,
391
- )
392
- } else {
393
- binarySpinner.text = `Downloading ${dbEngine.displayName} ${version} binaries...`
394
- await dbEngine.ensureBinaries(version, ({ message }) => {
387
+ // Always call ensureBinaries - it handles cached binaries gracefully
388
+ // and registers client tool paths in config (needed for dependency checks)
389
+ await dbEngine.ensureBinaries(version, ({ stage, message }) => {
390
+ if (stage === 'cached') {
391
+ binarySpinner.text = `${dbEngine.displayName} ${version} binaries ready (cached)`
392
+ } else {
395
393
  binarySpinner.text = message
396
- })
397
- binarySpinner.succeed(
398
- `${dbEngine.displayName} ${version} binaries downloaded`,
399
- )
400
- }
394
+ }
395
+ })
396
+ binarySpinner.succeed(
397
+ `${dbEngine.displayName} ${version} binaries ready`,
398
+ )
401
399
  }
402
400
 
403
401
  // Check dependencies (all engines need this)
@@ -7,9 +7,16 @@ import { getEngine } from '../../engines'
7
7
  import { binaryManager } from '../../core/binary-manager'
8
8
  import { paths } from '../../config/paths'
9
9
  import { platformService } from '../../core/platform-service'
10
+ import {
11
+ detectPackageManager,
12
+ checkEngineDependencies,
13
+ installEngineDependencies,
14
+ getManualInstallInstructions,
15
+ getCurrentPlatform,
16
+ } from '../../core/dependency-manager'
10
17
  import { promptConfirm } from '../ui/prompts'
11
18
  import { createSpinner } from '../ui/spinner'
12
- import { uiError, uiWarning, uiInfo, formatBytes } from '../ui/theme'
19
+ import { uiError, uiWarning, uiInfo, uiSuccess, formatBytes } from '../ui/theme'
13
20
  import { getEngineIcon, ENGINE_ICONS } from '../constants'
14
21
  import {
15
22
  getInstalledEngines,
@@ -250,6 +257,80 @@ async function deleteEngine(
250
257
  }
251
258
  }
252
259
 
260
+ // Install an engine via system package manager
261
+ async function installEngineViaPackageManager(
262
+ engine: string,
263
+ displayName: string,
264
+ ): Promise<void> {
265
+ // Check if already installed
266
+ const statuses = await checkEngineDependencies(engine)
267
+ const allInstalled = statuses.every((s) => s.installed)
268
+
269
+ if (allInstalled) {
270
+ console.log(uiInfo(`${displayName} is already installed.`))
271
+ for (const status of statuses) {
272
+ if (status.path) {
273
+ console.log(chalk.gray(` ${status.dependency.binary}: ${status.path}`))
274
+ }
275
+ }
276
+ return
277
+ }
278
+
279
+ // Detect package manager
280
+ const packageManager = await detectPackageManager()
281
+
282
+ if (!packageManager) {
283
+ console.error(uiError('No supported package manager found.'))
284
+ console.log()
285
+ console.log(chalk.yellow('Manual installation instructions:'))
286
+ const platform = getCurrentPlatform()
287
+ const missingDeps = statuses.filter((s) => !s.installed)
288
+ for (const status of missingDeps) {
289
+ const instructions = getManualInstallInstructions(
290
+ status.dependency,
291
+ platform,
292
+ )
293
+ console.log(chalk.gray(` ${status.dependency.name}:`))
294
+ for (const instruction of instructions) {
295
+ console.log(chalk.gray(` ${instruction}`))
296
+ }
297
+ }
298
+ process.exit(1)
299
+ }
300
+
301
+ console.log(uiInfo(`Installing ${displayName} via ${packageManager.name}...`))
302
+ console.log()
303
+
304
+ // Install missing dependencies
305
+ const results = await installEngineDependencies(engine, packageManager)
306
+
307
+ // Report results
308
+ const succeeded = results.filter((r) => r.success)
309
+ const failed = results.filter((r) => !r.success)
310
+
311
+ if (succeeded.length > 0) {
312
+ console.log()
313
+ console.log(uiSuccess(`${displayName} installed successfully.`))
314
+
315
+ // Show installed paths
316
+ const newStatuses = await checkEngineDependencies(engine)
317
+ for (const status of newStatuses) {
318
+ if (status.installed && status.path) {
319
+ console.log(chalk.gray(` ${status.dependency.binary}: ${status.path}`))
320
+ }
321
+ }
322
+ }
323
+
324
+ if (failed.length > 0) {
325
+ console.log()
326
+ console.error(uiError('Some components failed to install:'))
327
+ for (const result of failed) {
328
+ console.error(chalk.red(` ${result.dependency.name}: ${result.error}`))
329
+ }
330
+ process.exit(1)
331
+ }
332
+ }
333
+
253
334
  // Main engines command
254
335
  export const enginesCommand = new Command('engines')
255
336
  .description('Manage installed database engines')
@@ -287,53 +368,74 @@ enginesCommand
287
368
 
288
369
  // Download subcommand
289
370
  enginesCommand
290
- .command('download <engine> <version>')
291
- .description('Download engine binaries (PostgreSQL only)')
292
- .action(async (engineName: string, version: string) => {
371
+ .command('download <engine> [version]')
372
+ .description('Download/install engine binaries')
373
+ .action(async (engineName: string, version?: string) => {
293
374
  try {
294
- // Validate engine name
295
- const validEngines = ['postgresql', 'pg', 'postgres']
296
- if (!validEngines.includes(engineName.toLowerCase())) {
297
- console.error(
298
- uiError(
299
- `Only PostgreSQL binaries can be downloaded. MySQL and SQLite use system installations.`,
300
- ),
301
- )
302
- process.exit(1)
303
- }
375
+ const normalizedEngine = engineName.toLowerCase()
376
+
377
+ // PostgreSQL: download binaries
378
+ if (['postgresql', 'pg', 'postgres'].includes(normalizedEngine)) {
379
+ if (!version) {
380
+ console.error(uiError('PostgreSQL requires a version (e.g., 17)'))
381
+ process.exit(1)
382
+ }
304
383
 
305
- const engine = getEngine(Engine.PostgreSQL)
384
+ const engine = getEngine(Engine.PostgreSQL)
306
385
 
307
- // Check if already installed
308
- const isInstalled = await engine.isBinaryInstalled(version)
309
- if (isInstalled) {
310
- console.log(
311
- uiInfo(`PostgreSQL ${version} binaries are already installed.`),
386
+ const spinner = createSpinner(
387
+ `Checking PostgreSQL ${version} binaries...`,
312
388
  )
389
+ spinner.start()
390
+
391
+ // Always call ensureBinaries - it handles cached binaries gracefully
392
+ // and registers client tool paths in config (needed for dependency checks)
393
+ let wasCached = false
394
+ await engine.ensureBinaries(version, ({ stage, message }) => {
395
+ if (stage === 'cached') {
396
+ wasCached = true
397
+ spinner.text = `PostgreSQL ${version} binaries ready (cached)`
398
+ } else {
399
+ spinner.text = message
400
+ }
401
+ })
402
+
403
+ if (wasCached) {
404
+ spinner.succeed(`PostgreSQL ${version} binaries already installed`)
405
+ } else {
406
+ spinner.succeed(`PostgreSQL ${version} binaries downloaded`)
407
+ }
408
+
409
+ // Show the path for reference
410
+ const { platform, arch } = platformService.getPlatformInfo()
411
+ const fullVersion = binaryManager.getFullVersion(version)
412
+ const binPath = paths.getBinaryPath({
413
+ engine: 'postgresql',
414
+ version: fullVersion,
415
+ platform,
416
+ arch,
417
+ })
418
+ console.log(chalk.gray(` Location: ${binPath}`))
313
419
  return
314
420
  }
315
421
 
316
- const spinner = createSpinner(
317
- `Downloading PostgreSQL ${version} binaries...`,
318
- )
319
- spinner.start()
320
-
321
- await engine.ensureBinaries(version, ({ message }) => {
322
- spinner.text = message
323
- })
422
+ // MySQL and SQLite: install via system package manager
423
+ if (['mysql', 'mariadb'].includes(normalizedEngine)) {
424
+ await installEngineViaPackageManager('mysql', 'MySQL')
425
+ return
426
+ }
324
427
 
325
- spinner.succeed(`PostgreSQL ${version} binaries downloaded`)
428
+ if (['sqlite', 'sqlite3'].includes(normalizedEngine)) {
429
+ await installEngineViaPackageManager('sqlite', 'SQLite')
430
+ return
431
+ }
326
432
 
327
- // Show the path for reference
328
- const { platform, arch } = platformService.getPlatformInfo()
329
- const fullVersion = binaryManager.getFullVersion(version)
330
- const binPath = paths.getBinaryPath({
331
- engine: 'postgresql',
332
- version: fullVersion,
333
- platform,
334
- arch,
335
- })
336
- console.log(chalk.gray(` Location: ${binPath}`))
433
+ console.error(
434
+ uiError(
435
+ `Unknown engine "${engineName}". Supported: postgresql, mysql, sqlite`,
436
+ ),
437
+ )
438
+ process.exit(1)
337
439
  } catch (error) {
338
440
  const e = error as Error
339
441
  console.error(uiError(e.message))
package/cli/ui/prompts.ts CHANGED
@@ -608,18 +608,36 @@ export async function promptInstallDependencies(
608
608
  const engineDeps = getEngineDependencies(engine)
609
609
  const engineName = engineDeps?.displayName || engine
610
610
 
611
- const { shouldInstall } = await inquirer.prompt<{ shouldInstall: string }>([
612
- {
613
- type: 'list',
614
- name: 'shouldInstall',
615
- message: `Would you like to install ${engineName} client tools now?`,
616
- choices: [
617
- { name: 'Yes, install now', value: 'yes' },
618
- { name: 'No, I will install manually', value: 'no' },
619
- ],
620
- default: 'yes',
621
- },
622
- ])
611
+ // In CI environments (no TTY or CI env var), auto-install without prompting
612
+ const isCI = !!(
613
+ process.env.CI ||
614
+ process.env.GITHUB_ACTIONS ||
615
+ !process.stdin.isTTY
616
+ )
617
+
618
+ let shouldInstall = 'yes'
619
+
620
+ if (!isCI) {
621
+ const response = await inquirer.prompt<{ shouldInstall: string }>([
622
+ {
623
+ type: 'list',
624
+ name: 'shouldInstall',
625
+ message: `Would you like to install ${engineName} client tools now?`,
626
+ choices: [
627
+ { name: 'Yes, install now', value: 'yes' },
628
+ { name: 'No, I will install manually', value: 'no' },
629
+ ],
630
+ default: 'yes',
631
+ },
632
+ ])
633
+ shouldInstall = response.shouldInstall
634
+ } else {
635
+ console.log(
636
+ chalk.gray(
637
+ ` CI environment detected - auto-installing ${engineName} client tools...`,
638
+ ),
639
+ )
640
+ }
623
641
 
624
642
  if (shouldInstall === 'no') {
625
643
  console.log()
package/cli/ui/theme.ts CHANGED
@@ -1,8 +1,5 @@
1
1
  import chalk from 'chalk'
2
2
 
3
- /**
4
- * Color theme for spindb CLI
5
- */
6
3
  export const theme = {
7
4
  // Brand colors
8
5
  primary: chalk.cyan,
@@ -45,9 +42,6 @@ export const theme = {
45
42
  },
46
43
  }
47
44
 
48
- /**
49
- * Format a header box
50
- */
51
45
  export function header(text: string): string {
52
46
  const line = '─'.repeat(text.length + 4)
53
47
  return `
@@ -57,61 +51,37 @@ ${chalk.cyan('└' + line + '┘')}
57
51
  `.trim()
58
52
  }
59
53
 
60
- /**
61
- * Format a success message
62
- */
63
54
  export function uiSuccess(message: string): string {
64
55
  return `${theme.icons.success} ${message}`
65
56
  }
66
57
 
67
- /**
68
- * Format an error message
69
- */
70
58
  export function uiError(message: string): string {
71
59
  return `${theme.icons.error} ${chalk.red(message)}`
72
60
  }
73
61
 
74
- /**
75
- * Format a warning message
76
- */
77
62
  export function uiWarning(message: string): string {
78
63
  return `${theme.icons.warning} ${chalk.yellow(message)}`
79
64
  }
80
65
 
81
- /**
82
- * Format an info message
83
- */
84
66
  export function uiInfo(message: string): string {
85
67
  return `${theme.icons.info} ${message}`
86
68
  }
87
69
 
88
- /**
89
- * Format a key-value pair
90
- */
91
70
  export function keyValue(key: string, value: string): string {
92
71
  return `${chalk.gray(key + ':')} ${value}`
93
72
  }
94
73
 
95
- /**
96
- * Strip ANSI escape codes to get actual string length
97
- */
98
74
  function stripAnsi(str: string): string {
99
75
  // eslint-disable-next-line no-control-regex
100
76
  return str.replace(/\x1B\[[0-9;]*m/g, '')
101
77
  }
102
78
 
103
- /**
104
- * Pad a string (accounting for ANSI codes) to a specific visible width
105
- */
106
79
  function padToWidth(str: string, width: number): string {
107
80
  const visibleLength = stripAnsi(str).length
108
81
  const padding = Math.max(0, width - visibleLength)
109
82
  return str + ' '.repeat(padding)
110
83
  }
111
84
 
112
- /**
113
- * Create a box with dynamic width based on content
114
- */
115
85
  export function box(lines: string[], padding: number = 2): string {
116
86
  // Calculate max visible width
117
87
  const maxWidth = Math.max(...lines.map((line) => stripAnsi(line).length))
@@ -136,9 +106,6 @@ export function box(lines: string[], padding: number = 2): string {
136
106
  return boxLines.join('\n')
137
107
  }
138
108
 
139
- /**
140
- * Format a connection string box
141
- */
142
109
  export function connectionBox(
143
110
  name: string,
144
111
  connectionString: string,
@@ -156,9 +123,6 @@ export function connectionBox(
156
123
  return box(lines)
157
124
  }
158
125
 
159
- /**
160
- * Format bytes into human-readable format (B, KB, MB, GB)
161
- */
162
126
  export function formatBytes(bytes: number): string {
163
127
  if (bytes === 0) return '0 B'
164
128
  const units = ['B', 'KB', 'MB', 'GB', 'TB']
@@ -399,20 +399,197 @@ export class BinaryManager {
399
399
  onProgress?: ProgressCallback,
400
400
  ): Promise<string> {
401
401
  const fullVersion = this.getFullVersion(version)
402
+ let binPath: string
403
+
402
404
  if (await this.isInstalled(version, platform, arch)) {
403
405
  onProgress?.({
404
406
  stage: 'cached',
405
407
  message: 'Using cached PostgreSQL binaries',
406
408
  })
407
- return paths.getBinaryPath({
409
+ binPath = paths.getBinaryPath({
408
410
  engine: 'postgresql',
409
411
  version: fullVersion,
410
412
  platform,
411
413
  arch,
412
414
  })
415
+ } else {
416
+ binPath = await this.download(version, platform, arch, onProgress)
417
+ }
418
+
419
+ // On Linux, zonky.io binaries don't include client tools (psql, pg_dump)
420
+ // Download them separately from the PostgreSQL apt repository
421
+ if (platform === 'linux') {
422
+ await this.ensureClientTools(binPath, version, onProgress)
413
423
  }
414
424
 
415
- return this.download(version, platform, arch, onProgress)
425
+ return binPath
426
+ }
427
+
428
+ /**
429
+ * Ensure PostgreSQL client tools are available on Linux
430
+ * Downloads from PostgreSQL apt repository if missing
431
+ */
432
+ private async ensureClientTools(
433
+ binPath: string,
434
+ version: string,
435
+ onProgress?: ProgressCallback,
436
+ ): Promise<void> {
437
+ const clientTools = ['psql', 'pg_dump', 'pg_restore', 'pg_dumpall']
438
+ const binDir = join(binPath, 'bin')
439
+
440
+ // Check if client tools already exist
441
+ const missingTools = clientTools.filter(
442
+ (tool) => !existsSync(join(binDir, tool)),
443
+ )
444
+
445
+ if (missingTools.length === 0) {
446
+ return // All client tools already present
447
+ }
448
+
449
+ onProgress?.({
450
+ stage: 'downloading',
451
+ message: 'Downloading PostgreSQL client tools...',
452
+ })
453
+
454
+ const majorVersion = version.split('.')[0]
455
+ const tempDir = join(paths.bin, `temp-client-${majorVersion}`)
456
+
457
+ try {
458
+ await mkdir(tempDir, { recursive: true })
459
+
460
+ // Get the latest client package version from apt repository
461
+ const debUrl = await this.getClientPackageUrl(majorVersion)
462
+ const debFile = join(tempDir, 'postgresql-client.deb')
463
+
464
+ // Download the .deb package
465
+ const response = await fetch(debUrl)
466
+ if (!response.ok) {
467
+ throw new Error(`Failed to download client tools: ${response.status}`)
468
+ }
469
+
470
+ const fileStream = createWriteStream(debFile)
471
+ // @ts-expect-error - response.body is ReadableStream
472
+ await pipeline(response.body, fileStream)
473
+
474
+ onProgress?.({
475
+ stage: 'extracting',
476
+ message: 'Extracting PostgreSQL client tools...',
477
+ })
478
+
479
+ // Extract .deb using ar (available on Linux)
480
+ await execAsync(`ar -x "${debFile}"`, { cwd: tempDir })
481
+
482
+ // Find and extract data.tar.xz or data.tar.zst
483
+ const dataFile = await this.findDataTar(tempDir)
484
+ if (!dataFile) {
485
+ throw new Error('Could not find data archive in .deb package')
486
+ }
487
+
488
+ // Determine compression type and extract
489
+ const extractDir = join(tempDir, 'extracted')
490
+ await mkdir(extractDir, { recursive: true })
491
+
492
+ if (dataFile.endsWith('.xz')) {
493
+ await execAsync(`tar -xJf "${dataFile}" -C "${extractDir}"`)
494
+ } else if (dataFile.endsWith('.zst')) {
495
+ await execAsync(`tar --zstd -xf "${dataFile}" -C "${extractDir}"`)
496
+ } else if (dataFile.endsWith('.gz')) {
497
+ await execAsync(`tar -xzf "${dataFile}" -C "${extractDir}"`)
498
+ } else {
499
+ await execAsync(`tar -xf "${dataFile}" -C "${extractDir}"`)
500
+ }
501
+
502
+ // Copy client tools to the bin directory
503
+ const clientBinDir = join(
504
+ extractDir,
505
+ 'usr',
506
+ 'lib',
507
+ 'postgresql',
508
+ majorVersion,
509
+ 'bin',
510
+ )
511
+
512
+ if (existsSync(clientBinDir)) {
513
+ for (const tool of clientTools) {
514
+ const srcPath = join(clientBinDir, tool)
515
+ const destPath = join(binDir, tool)
516
+ if (existsSync(srcPath) && !existsSync(destPath)) {
517
+ await cp(srcPath, destPath)
518
+ await chmod(destPath, 0o755)
519
+ }
520
+ }
521
+ }
522
+
523
+ onProgress?.({
524
+ stage: 'complete',
525
+ message: 'PostgreSQL client tools installed',
526
+ })
527
+ } finally {
528
+ // Clean up temp directory
529
+ await rm(tempDir, { recursive: true, force: true })
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Get the download URL for PostgreSQL client package from apt repository
535
+ */
536
+ private async getClientPackageUrl(majorVersion: string): Promise<string> {
537
+ const baseUrl = 'https://apt.postgresql.org/pub/repos/apt/pool/main/p'
538
+ const packageDir = `postgresql-${majorVersion}`
539
+ const indexUrl = `${baseUrl}/${packageDir}/`
540
+
541
+ try {
542
+ const response = await fetch(indexUrl)
543
+ if (!response.ok) {
544
+ throw new Error(`Failed to fetch package index: ${response.status}`)
545
+ }
546
+
547
+ const html = await response.text()
548
+
549
+ // Find the latest postgresql-client package for amd64
550
+ // Pattern: postgresql-client-17_17.x.x-x.pgdg+1_amd64.deb
551
+ const pattern = new RegExp(
552
+ `href="(postgresql-client-${majorVersion}_[^"]+_amd64\\.deb)"`,
553
+ 'g',
554
+ )
555
+
556
+ const matches: string[] = []
557
+ let match
558
+ while ((match = pattern.exec(html)) !== null) {
559
+ // Skip debug symbols and snapshot versions
560
+ if (!match[1].includes('dbgsym') && !match[1].includes('~')) {
561
+ matches.push(match[1])
562
+ }
563
+ }
564
+
565
+ if (matches.length === 0) {
566
+ throw new Error(
567
+ `No client package found for PostgreSQL ${majorVersion}`,
568
+ )
569
+ }
570
+
571
+ // Sort to get the latest version and return the URL
572
+ matches.sort().reverse()
573
+ const latestPackage = matches[0]
574
+
575
+ return `${indexUrl}${latestPackage}`
576
+ } catch (error) {
577
+ const err = error as Error
578
+ throw new Error(`Failed to get client package URL: ${err.message}`)
579
+ }
580
+ }
581
+
582
+ /**
583
+ * Find data.tar.* file in extracted .deb
584
+ */
585
+ private async findDataTar(dir: string): Promise<string | null> {
586
+ const entries = await readdir(dir)
587
+ for (const entry of entries) {
588
+ if (entry.startsWith('data.tar')) {
589
+ return join(dir, entry)
590
+ }
591
+ }
592
+ return null
416
593
  }
417
594
  }
418
595