From 3076db8fe6b95b74f29b13122926831169370dbe Mon Sep 17 00:00:00 2001 From: Luis Henrique Mulinari Date: Fri, 14 Nov 2025 09:29:17 -0300 Subject: [PATCH 1/5] feat: add skip-download option to export sql command Add --skip-download flag to allow users to retrieve the download URL for database backups without downloading the file. This is useful for automation scenarios where users want to handle the download separately. Changes: - Add skipDownload option to ExportSQLCommand - Skip storage confirmation and download steps when flag is set - Output download URL to console instead of downloading - Update progress tracker to support additional info for steps - Add test coverage for skip-download functionality - Add example usage in command help --- __tests__/commands/export-sql.ts | 31 +++++++++++++++++++- src/bin/vip-export-sql.js | 10 +++++-- src/commands/export-sql.ts | 50 +++++++++++++++++--------------- src/lib/cli/progress.ts | 35 +++++++++++++++------- 4 files changed, 88 insertions(+), 38 deletions(-) diff --git a/__tests__/commands/export-sql.ts b/__tests__/commands/export-sql.ts index d9beda614..79edc56db 100644 --- a/__tests__/commands/export-sql.ts +++ b/__tests__/commands/export-sql.ts @@ -254,7 +254,7 @@ describe( 'commands/ExportSQLCommand', () => { const downloadMock = jest.spyOn( downloadFileModule, 'downloadFile' ).mockResolvedValue(); await exportCommand.run(); - expect( stepSuccessSpy ).toHaveBeenCalledWith( 'prepare' ); + expect( stepSuccessSpy ).toHaveBeenCalledWith( 'prepare', expect.any( Array ) ); expect( stepSuccessSpy ).toHaveBeenCalledWith( 'create' ); expect( stepSuccessSpy ).toHaveBeenCalledWith( 'confirmEnoughStorage' ); expect( stepSuccessSpy ).toHaveBeenCalledWith( 'downloadLink' ); @@ -265,6 +265,35 @@ describe( 'commands/ExportSQLCommand', () => { expect.any( Function ) ); } ); + + it( 'should return download URL instead of downloading when skipDownload is true', async () => { + const exportCommandWithUrl = new ExportSQLCommand( app, env, { skipDownload: true } ); + const stepSuccessSpyUrl = jest.spyOn( exportCommandWithUrl.progressTracker, 'stepSuccess' ); + const stepSkippedSpy = jest.spyOn( exportCommandWithUrl.progressTracker, 'stepSkipped' ); + const confirmEnoughStorageSpyUrl = jest.spyOn( exportCommandWithUrl, 'confirmEnoughStorage' ); + const downloadMock = jest.spyOn( downloadFileModule, 'downloadFile' ).mockResolvedValue(); + const consoleLogSpy = jest.spyOn( console, 'log' ).mockImplementation(); + + confirmEnoughStorageSpyUrl.mockResolvedValue( { continue: true, isPromptShown: false } ); + + await exportCommandWithUrl.run(); + + // Should skip confirmEnoughStorage and download steps + expect( stepSkippedSpy ).toHaveBeenCalledWith( 'confirmEnoughStorage' ); + expect( stepSkippedSpy ).toHaveBeenCalledWith( 'download' ); + + // Should not call confirmEnoughStorage or download + expect( confirmEnoughStorageSpyUrl ).not.toHaveBeenCalled(); + expect( downloadMock ).not.toHaveBeenCalled(); + + // Should output the download URL + expect( consoleLogSpy ).toHaveBeenCalledWith( 'Download URL: https://test-backup.sql.gz' ); + + stepSuccessSpyUrl.mockRestore(); + stepSkippedSpy.mockRestore(); + confirmEnoughStorageSpyUrl.mockRestore(); + consoleLogSpy.mockRestore(); + } ); } ); describe( 'liveBackupCopy', () => { diff --git a/src/bin/vip-export-sql.js b/src/bin/vip-export-sql.js index 6f46d452e..8894ec912 100755 --- a/src/bin/vip-export-sql.js +++ b/src/bin/vip-export-sql.js @@ -21,6 +21,11 @@ const examples = [ description: 'Generate a fresh database backup for an environment and download an archived copy of that backup.', }, + { + usage: 'vip @example-app.develop export sql --skip-download', + description: + 'Get the download URL for the most recent database backup without downloading the file.', + }, { usage: 'vip @example-app.develop export sql --table=wp_posts --table=wp_comments', description: @@ -96,12 +101,13 @@ command( { 'generate-backup', 'Generate a fresh database backup and export an archived copy of that backup.' ) + .option( 'skip-download', 'Skip downloading the file and output the download URL instead.' ) .examples( examples ) .argv( process.argv, async ( arg, - { app, env, output, configFile, table, siteId, wpcliCommand, generateBackup } + { app, env, output, configFile, table, siteId, wpcliCommand, generateBackup, skipDownload } ) => { const liveBackupCopyCLIOptions = parseLiveBackupCopyCLIOptions( configFile, @@ -121,7 +127,7 @@ command( { const exportCommand = new ExportSQLCommand( app, env, - { outputFile: output, generateBackup, liveBackupCopyCLIOptions }, + { outputFile: output, generateBackup, liveBackupCopyCLIOptions, skipDownload }, trackerFn ); await exportCommand.run(); diff --git a/src/commands/export-sql.ts b/src/commands/export-sql.ts index bf91b92d5..a0587f8ce 100644 --- a/src/commands/export-sql.ts +++ b/src/commands/export-sql.ts @@ -19,7 +19,7 @@ import { PromptStatus, } from '../lib/backup-storage-availability/backup-storage-availability'; import * as exit from '../lib/cli/exit'; -import { formatBytes, getGlyphForStatus } from '../lib/cli/format'; +import { formatBytes } from '../lib/cli/format'; import { ProgressTracker } from '../lib/cli/progress'; import { downloadFile, OnProgressCallback } from '../lib/http/download-file'; import * as liveBackupCopy from '../lib/live-backup-copy'; @@ -221,6 +221,7 @@ export interface ExportSQLOptions { generateBackup?: boolean; liveBackupCopyCLIOptions?: LiveBackupCopyCLIOptions; showMyDumperWarning?: boolean; + skipDownload?: boolean; } /** @@ -244,6 +245,8 @@ export class ExportSQLCommand { private readonly showMyDumperWarning: boolean; + private readonly skipDownload: boolean; + /** * Creates an instance of SQLExportCommand * @@ -273,6 +276,7 @@ export class ExportSQLCommand { this.liveBackupCopyCLIOptions = options.liveBackupCopyCLIOptions; this.showMyDumperWarning = options.showMyDumperWarning === undefined ? true : options.showMyDumperWarning; + this.skipDownload = options.skipDownload || false; } /** @@ -412,6 +416,17 @@ export class ExportSQLCommand { size = Number( bytesWrittenMeta.value ); } + if ( this.skipDownload ) { + this.progressTracker.stepSkipped( this.steps.CONFIRM_ENOUGH_STORAGE ); + this.progressTracker.stepSkipped( this.steps.DOWNLOAD ); + this.progressTracker.print(); + this.progressTracker.stopPrinting(); + + console.log( `Download URL: ${ url }` ); + + return; + } + const storageConfirmed = await this.progressTracker.handleContinuePrompt( async setPromptShown => { const status = await this.confirmEnoughStorage( Number( size ) ); @@ -478,39 +493,26 @@ export class ExportSQLCommand { exit.withError( `No backup found for site ${ this.app.name }` ); } - if ( ! this.generateBackup ) { - console.log( - `${ getGlyphForStatus( 'success' ) } Latest backup found with timestamp ${ - latestBackup.createdAt - }` - ); - } else { - console.log( - `${ getGlyphForStatus( 'success' ) } Backup created with timestamp ${ - latestBackup.createdAt - }` - ); - } + const prepareAdditionalInfo = []; const showMyDumperWarning = this.showMyDumperWarning && ( latestBackup.sqlDumpTool ?? envSqlDumpTool ) === 'mydumper'; if ( showMyDumperWarning ) { - console.warn( - chalk.yellow.bold( 'WARNING:' ), - chalk.yellow( - 'This is a large or complex database. The backup file for this database is generated with MyDumper. ' + - 'The file can only be loaded with MyLoader. ' + - 'For more information: https://github.com/mydumper/mydumper' - ) + prepareAdditionalInfo.push( + `${ chalk.yellow.bold( + 'WARNING:' + ) } This is a large or complex database. The backup file for this database is generated with MyDumper. The file can only be loaded with MyLoader. For more information: https://github.com/mydumper/mydumper` ); } if ( await this.getExportJob() ) { - console.log( + prepareAdditionalInfo.push( `Attaching to an existing export for the backup with timestamp ${ latestBackup.createdAt }` ); } else { - console.log( `Exporting database backup with timestamp ${ latestBackup.createdAt }` ); + prepareAdditionalInfo.push( + `Exporting database backup with timestamp ${ latestBackup.createdAt }` + ); try { await createExportJob( this.app.id, this.env.id, latestBackup.id ); @@ -545,7 +547,7 @@ export class ExportSQLCommand { this.isPrepared.bind( this ) ); - this.progressTracker.stepSuccess( this.steps.PREPARE ); + this.progressTracker.stepSuccess( this.steps.PREPARE, prepareAdditionalInfo ); await pollUntil( this.getExportJob.bind( this ), diff --git a/src/lib/cli/progress.ts b/src/lib/cli/progress.ts index f47239b65..ff68f5984 100644 --- a/src/lib/cli/progress.ts +++ b/src/lib/cli/progress.ts @@ -20,7 +20,9 @@ export interface Step { id: string; name: string; status: StepStatus; - [ key: string ]: string; + percentage?: string; + progress?: string; + additionalInfo?: string[]; } export type StepConstructorParam = Omit< Step, 'status' > & { status?: StepStatus }; @@ -133,20 +135,20 @@ export class ProgressTracker { return steps.find( ( { status } ) => status === StepStatus.RUNNING ); } - public stepRunning( stepId: string ): void { - this.setStatusForStepId( stepId, StepStatus.RUNNING ); + public stepRunning( stepId: string, additionalInfo: string | string[] = [] ): void { + this.setStatusForStepId( stepId, StepStatus.RUNNING, additionalInfo ); } - public stepFailed( stepId: string ): void { - this.setStatusForStepId( stepId, StepStatus.FAILED ); + public stepFailed( stepId: string, additionalInfo: string | string[] = [] ): void { + this.setStatusForStepId( stepId, StepStatus.FAILED, additionalInfo ); } - public stepSkipped( stepId: string ): void { - this.setStatusForStepId( stepId, StepStatus.SKIPPED ); + public stepSkipped( stepId: string, additionalInfo: string | string[] = [] ): void { + this.setStatusForStepId( stepId, StepStatus.SKIPPED, additionalInfo ); } - public stepSuccess( stepId: string ) { - this.setStatusForStepId( stepId, StepStatus.SUCCESS ); + public stepSuccess( stepId: string, additionalInfo: string | string[] = [] ) { + this.setStatusForStepId( stepId, StepStatus.SUCCESS, additionalInfo ); // The stepSuccess helper automatically sets the next step to "running" const nextStep = this.getNextStep(); if ( nextStep ) { @@ -158,7 +160,11 @@ export class ProgressTracker { return [ ...this.getSteps().values() ].every( ( { status } ) => status === StepStatus.SUCCESS ); } - public setStatusForStepId( stepId: string, status: StepStatus ) { + public setStatusForStepId( + stepId: string, + status: StepStatus, + additionalInfo: string | string[] = [] + ) { const step = this.stepsFromCaller.get( stepId ); if ( ! step ) { // Only allowed to update existing steps with this method @@ -176,6 +182,7 @@ export class ProgressTracker { this.stepsFromCaller.set( stepId, { ...step, status, + additionalInfo: Array.isArray( additionalInfo ) ? additionalInfo : [ additionalInfo ], } ); } @@ -249,7 +256,7 @@ export class ProgressTracker { } const stepValues = [ ...this.getSteps().values() ]; const logs = stepValues.reduce( - ( accumulator, { name, id, percentage, status, progress }, stepNumber ) => { + ( accumulator, { name, id, percentage, status, progress, additionalInfo }, stepNumber ) => { if ( stepNumber < this.displayFromStep ) { return accumulator; } @@ -263,6 +270,12 @@ export class ProgressTracker { } else if ( progress ) { suffix = progress; } + + if ( additionalInfo && additionalInfo.length > 0 ) { + suffix += EOL; + suffix += additionalInfo.map( stepInfo => ` - ${ stepInfo }` ).join( EOL ); + } + return `${ accumulator }${ statusIcon } ${ name } ${ suffix }\n`; }, '' From 7a09d6ffa4505fade465a620349a00da54b09c1d Mon Sep 17 00:00:00 2001 From: Luis Henrique Mulinari Date: Fri, 14 Nov 2025 17:25:11 -0300 Subject: [PATCH 2/5] docs: improve skip-download option description Simplify the description to focus on what the option does rather than redundantly explaining that the URL is always printed. --- src/bin/vip-export-sql.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/bin/vip-export-sql.js b/src/bin/vip-export-sql.js index 8894ec912..0438791e1 100755 --- a/src/bin/vip-export-sql.js +++ b/src/bin/vip-export-sql.js @@ -23,8 +23,7 @@ const examples = [ }, { usage: 'vip @example-app.develop export sql --skip-download', - description: - 'Get the download URL for the most recent database backup without downloading the file.', + description: 'Skip downloading the database backup file.', }, { usage: 'vip @example-app.develop export sql --table=wp_posts --table=wp_comments', @@ -101,7 +100,7 @@ command( { 'generate-backup', 'Generate a fresh database backup and export an archived copy of that backup.' ) - .option( 'skip-download', 'Skip downloading the file and output the download URL instead.' ) + .option( 'skip-download', 'Skip downloading the file.' ) .examples( examples ) .argv( process.argv, From 725f3e5c540bdf771a021ac60c0f99f5331abfa0 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Tue, 25 Nov 2025 14:53:57 -0600 Subject: [PATCH 3/5] Always display a download url as additional info for DOWNLOAD_LINK step --- src/commands/export-sql.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/commands/export-sql.ts b/src/commands/export-sql.ts index a0587f8ce..c1e0762bc 100644 --- a/src/commands/export-sql.ts +++ b/src/commands/export-sql.ts @@ -268,8 +268,8 @@ export class ExportSQLCommand { this.progressTracker = new ProgressTracker( [ { id: this.steps.PREPARE, name: 'Preparing for backup download' }, { id: this.steps.CREATE, name: 'Creating backup copy' }, - { id: this.steps.CONFIRM_ENOUGH_STORAGE, name: "Checking if there's enough storage" }, { id: this.steps.DOWNLOAD_LINK, name: 'Requesting download link' }, + { id: this.steps.CONFIRM_ENOUGH_STORAGE, name: "Checking if there's enough storage" }, { id: this.steps.DOWNLOAD, name: 'Downloading file' }, ] ); this.track = trackerFn; @@ -416,14 +416,14 @@ export class ExportSQLCommand { size = Number( bytesWrittenMeta.value ); } + const downloadURLString = `\n${ chalk.green( 'Download URL' ) }:\n${ url }\n\n`; + if ( this.skipDownload ) { this.progressTracker.stepSkipped( this.steps.CONFIRM_ENOUGH_STORAGE ); this.progressTracker.stepSkipped( this.steps.DOWNLOAD ); this.progressTracker.print(); this.progressTracker.stopPrinting(); - - console.log( `Download URL: ${ url }` ); - + console.log( downloadURLString ); return; } @@ -557,7 +557,7 @@ export class ExportSQLCommand { this.progressTracker.stepSuccess( this.steps.CREATE ); const url = await generateDownloadLink( this.app.id, this.env.id, latestBackup.id ); - this.progressTracker.stepSuccess( this.steps.DOWNLOAD_LINK ); + this.progressTracker.stepSuccess( this.steps.DOWNLOAD_LINK, [ url ] ); return url; } @@ -590,7 +590,7 @@ export class ExportSQLCommand { } ); this.progressTracker.stepSuccess( this.steps.CREATE ); - this.progressTracker.stepSuccess( this.steps.DOWNLOAD_LINK ); + this.progressTracker.stepSuccess( this.steps.DOWNLOAD_LINK, [ result.url ] ); return result; } catch ( err ) { From 1a627c94567b421c7943312fe0f0ed218fae992a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Nov 2025 20:54:43 +0000 Subject: [PATCH 4/5] Initial plan From f58cf7f08df1dba95337265510549cfda5257d58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Nov 2025 20:57:07 +0000 Subject: [PATCH 5/5] docs: add public-facing documentation for --skip-download option Co-authored-by: rinatkhaziev <459254+rinatkhaziev@users.noreply.github.com> --- docs/export-sql-skip-download.md | 112 +++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/export-sql-skip-download.md diff --git a/docs/export-sql-skip-download.md b/docs/export-sql-skip-download.md new file mode 100644 index 000000000..471a23a1f --- /dev/null +++ b/docs/export-sql-skip-download.md @@ -0,0 +1,112 @@ +# Export SQL: Skip Download Option + +The `vip export sql` command includes a `--skip-download` option that allows you to retrieve the download URL for a database backup without downloading the file. This is useful when you need the URL for automation workflows, want to download the file later, or need to use a different download method. + +## Usage + +```bash +vip @. export sql --skip-download +``` + +## Examples + +### Get download URL for the latest backup + +```bash +vip @example-app.production export sql --skip-download +``` + +This command will: +1. Prepare the most recent database backup +2. Generate a download URL +3. Display the URL without downloading the file + +### Combine with other options + +You can combine `--skip-download` with other export options: + +```bash +# Get URL for a fresh backup +vip @example-app.production export sql --generate-backup --skip-download + +# Get URL for a partial export +vip @example-app.production export sql --table=wp_posts --skip-download + +# Get URL for specific sites in a multisite network +vip @example-app.production export sql --site-id=2,3 --skip-download +``` + +## Output + +When using `--skip-download`, the command displays progress information and outputs the download URL: + +``` +✓ Preparing for backup download + • Attaching to an existing export for the backup with timestamp 2025-11-14T13:21:07.000Z +✓ Creating backup copy +✓ Requesting download link + • Download URL: https://example.org/sql.gz + +Download URL: +https://example.org/sql.gz +``` + +## Use Cases + +### Automation workflows + +Store the URL in a variable for use in scripts: + +```bash +#!/bin/bash +OUTPUT=$(vip @example-app.production export sql --skip-download 2>&1) +URL=$(echo "$OUTPUT" | grep -o 'https://[^[:space:]]*') +echo "Backup URL: $URL" +# Use $URL in your automation workflow +``` + +### Download with custom tools + +Get the URL and use your preferred download tool: + +```bash +# Get the URL +vip @example-app.production export sql --skip-download + +# Download with wget +wget -O backup.sql.gz "https://example.org/sql.gz" + +# Or with curl +curl -o backup.sql.gz "https://example.org/sql.gz" +``` + +### Integration with cloud storage + +Retrieve the URL and upload directly to cloud storage without saving locally: + +```bash +# Get the URL +URL=$(vip @example-app.production export sql --skip-download 2>&1 | grep -o 'https://[^[:space:]]*') + +# Upload directly to S3 +wget -O - "$URL" | aws s3 cp - s3://my-bucket/backups/backup.sql.gz +``` + +## Important Notes + +- The download URL is temporary and will expire after a period of time +- When using `--skip-download`, the command skips: + - Storage space confirmation prompt + - File download +- The output file path option (`--output`) has no effect when `--skip-download` is used +- The backup must still be prepared and a download URL generated, which may take some time depending on backup size + +## Related Commands + +- [`vip export sql`](https://docs.wpvip.com/vip-cli/export-sql/) - Download database backups +- [`vip backup db`](https://docs.wpvip.com/vip-cli/backup-db/) - Create database backups + +## See Also + +- [VIP CLI Export SQL Documentation](https://docs.wpvip.com/vip-cli/export-sql/) +- [Database Backups on WordPress VIP](https://docs.wpvip.com/technical-references/backups/)