diff --git a/packages/sanity/src/_internal/cli/actions/backup/archiveDir.ts b/packages/sanity/src/_internal/cli/actions/backup/archiveDir.ts index afab9c9c1ca..89aeb1f4cb2 100644 --- a/packages/sanity/src/_internal/cli/actions/backup/archiveDir.ts +++ b/packages/sanity/src/_internal/cli/actions/backup/archiveDir.ts @@ -1,6 +1,8 @@ -import zlib from 'node:zlib' import {createWriteStream} from 'node:fs' -import {ProgressData} from 'archiver' +import zlib from 'node:zlib' + +import {type ProgressData} from 'archiver' + import debug from './debug' const archiver = require('archiver') diff --git a/packages/sanity/src/_internal/cli/actions/backup/chooseBackupIdPrompt.ts b/packages/sanity/src/_internal/cli/actions/backup/chooseBackupIdPrompt.ts index 14d15b049c9..55fefdb8187 100644 --- a/packages/sanity/src/_internal/cli/actions/backup/chooseBackupIdPrompt.ts +++ b/packages/sanity/src/_internal/cli/actions/backup/chooseBackupIdPrompt.ts @@ -1,4 +1,5 @@ -import type {CliCommandContext} from '@sanity/cli' +import {type CliCommandContext} from '@sanity/cli' + import {defaultApiVersion} from '../../commands/backup/backupGroup' import resolveApiClient from './resolveApiClient' diff --git a/packages/sanity/src/_internal/cli/actions/backup/cleanupTmpDir.ts b/packages/sanity/src/_internal/cli/actions/backup/cleanupTmpDir.ts index fb2d111e5c6..f32dc4b203d 100644 --- a/packages/sanity/src/_internal/cli/actions/backup/cleanupTmpDir.ts +++ b/packages/sanity/src/_internal/cli/actions/backup/cleanupTmpDir.ts @@ -1,4 +1,5 @@ import rimraf from 'rimraf' + import debug from './debug' function cleanupTmpDir(tmpDir: string): void { diff --git a/packages/sanity/src/_internal/cli/actions/backup/downloadAsset.ts b/packages/sanity/src/_internal/cli/actions/backup/downloadAsset.ts index faba7c1c3f5..99268e8b0f3 100644 --- a/packages/sanity/src/_internal/cli/actions/backup/downloadAsset.ts +++ b/packages/sanity/src/_internal/cli/actions/backup/downloadAsset.ts @@ -1,9 +1,11 @@ -import path from 'node:path' import {createWriteStream} from 'node:fs' +import path from 'node:path' + import {getIt} from 'get-it' import {keepAlive, promise} from 'get-it/middleware' -import withRetry from './withRetry' + import debug from './debug' +import withRetry from './withRetry' const CONNECTION_TIMEOUT = 15 * 1000 // 15 seconds const READ_TIMEOUT = 3 * 60 * 1000 // 3 minutes diff --git a/packages/sanity/src/_internal/cli/actions/backup/downloadDocument.ts b/packages/sanity/src/_internal/cli/actions/backup/downloadDocument.ts index bda9fb66d1a..3267b92c659 100644 --- a/packages/sanity/src/_internal/cli/actions/backup/downloadDocument.ts +++ b/packages/sanity/src/_internal/cli/actions/backup/downloadDocument.ts @@ -1,6 +1,6 @@ -import type {MiddlewareResponse} from 'get-it' -import {getIt} from 'get-it' +import {getIt, type MiddlewareResponse} from 'get-it' import {keepAlive, promise} from 'get-it/middleware' + import debug from './debug' import withRetry from './withRetry' diff --git a/packages/sanity/src/_internal/cli/actions/backup/fetchNextBackupPage.ts b/packages/sanity/src/_internal/cli/actions/backup/fetchNextBackupPage.ts index b3d4de58ada..d9c4d288591 100644 --- a/packages/sanity/src/_internal/cli/actions/backup/fetchNextBackupPage.ts +++ b/packages/sanity/src/_internal/cli/actions/backup/fetchNextBackupPage.ts @@ -1,5 +1,6 @@ import {Readable} from 'node:stream' -import {SanityClient, QueryParams} from '@sanity/client' + +import {type QueryParams, type SanityClient} from '@sanity/client' type File = { name: string diff --git a/packages/sanity/src/_internal/cli/actions/backup/parseApiErr.ts b/packages/sanity/src/_internal/cli/actions/backup/parseApiErr.ts new file mode 100644 index 00000000000..92ef01fcb3c --- /dev/null +++ b/packages/sanity/src/_internal/cli/actions/backup/parseApiErr.ts @@ -0,0 +1,35 @@ +// apiErr is a type that represents an error returned by the API +interface ApiErr { + statusCode: number + message: string +} + +// parseApiErr is a function that attempts with the best effort to parse +// an error returned by the API since different API endpoint may end up +// returning different error structures. +// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/explicit-module-boundary-types +function parseApiErr(err: any): ApiErr { + const apiErr = {} as ApiErr + if (err.code) { + apiErr.statusCode = err.code + } else if (err.statusCode) { + apiErr.statusCode = err.statusCode + } + + if (err.message) { + apiErr.message = err.message + } else if (err.statusMessage) { + apiErr.message = err.statusMessage + } else if (err?.response?.body?.message) { + apiErr.message = err.response.body.message + } else if (err?.response?.data?.message) { + apiErr.message = err.response.data.message + } else { + // If no message can be extracted, print the whole error. + apiErr.message = JSON.stringify(err) + } + + return apiErr +} + +export default parseApiErr diff --git a/packages/sanity/src/_internal/cli/actions/backup/progressSpinner.ts b/packages/sanity/src/_internal/cli/actions/backup/progressSpinner.ts index 9a7bd968dab..9009d32a00a 100644 --- a/packages/sanity/src/_internal/cli/actions/backup/progressSpinner.ts +++ b/packages/sanity/src/_internal/cli/actions/backup/progressSpinner.ts @@ -1,4 +1,4 @@ -import type {CliOutputter} from '@sanity/cli' +import {type CliOutputter} from '@sanity/cli' import prettyMs from 'pretty-ms' type ProgressEvent = { diff --git a/packages/sanity/src/_internal/cli/actions/backup/resolveApiClient.ts b/packages/sanity/src/_internal/cli/actions/backup/resolveApiClient.ts index 23f6496ebe8..bda841094f5 100644 --- a/packages/sanity/src/_internal/cli/actions/backup/resolveApiClient.ts +++ b/packages/sanity/src/_internal/cli/actions/backup/resolveApiClient.ts @@ -1,5 +1,6 @@ -import {CliCommandContext} from '@sanity/cli' -import {SanityClient} from '@sanity/client' +import {type CliCommandContext} from '@sanity/cli' +import {type SanityClient} from '@sanity/client' + import {chooseDatasetPrompt} from '../dataset/chooseDatasetPrompt' type ResolvedApiClient = { diff --git a/packages/sanity/src/_internal/cli/commands/backup/backupGroup.ts b/packages/sanity/src/_internal/cli/commands/backup/backupGroup.ts index 381ddcdd22c..e506e3b78af 100644 --- a/packages/sanity/src/_internal/cli/commands/backup/backupGroup.ts +++ b/packages/sanity/src/_internal/cli/commands/backup/backupGroup.ts @@ -1,4 +1,4 @@ -import {CliCommandGroupDefinition} from '@sanity/cli' +import {type CliCommandGroupDefinition} from '@sanity/cli' // defaultApiVersion is the backend API version used for dataset backup. // First version of the backup API is vX since this feature is not yet released @@ -13,16 +13,4 @@ const datasetBackupGroup: CliCommandGroupDefinition = { hideFromHelp: true, } -export function validateLimit(limit: string): string { - const parsed = parseInt(limit, 10) - - // We allow limit up to Number.MAX_SAFE_INTEGER to leave it for server-side validation, - // while still sending sensible value in limit string. - if (isNaN(parsed) || parsed < 1 || parsed > Number.MAX_SAFE_INTEGER) { - throw new Error(`--limit must be an integer between 1 and ${Number.MAX_SAFE_INTEGER}`) - } - - return limit.toString() -} - export default datasetBackupGroup diff --git a/packages/sanity/src/_internal/cli/commands/backup/disableBackupCommand.ts b/packages/sanity/src/_internal/cli/commands/backup/disableBackupCommand.ts index 78674f92fe5..9f2fac3eb4a 100644 --- a/packages/sanity/src/_internal/cli/commands/backup/disableBackupCommand.ts +++ b/packages/sanity/src/_internal/cli/commands/backup/disableBackupCommand.ts @@ -1,4 +1,5 @@ -import {CliCommandDefinition} from '@sanity/cli' +import {type CliCommandDefinition} from '@sanity/cli' + import resolveApiClient from '../../actions/backup/resolveApiClient' import {defaultApiVersion} from './backupGroup' diff --git a/packages/sanity/src/_internal/cli/commands/backup/downloadBackupCommand.ts b/packages/sanity/src/_internal/cli/commands/backup/downloadBackupCommand.ts index b57d73770ae..46c21ca5a97 100644 --- a/packages/sanity/src/_internal/cli/commands/backup/downloadBackupCommand.ts +++ b/packages/sanity/src/_internal/cli/commands/backup/downloadBackupCommand.ts @@ -1,27 +1,33 @@ import {createWriteStream, existsSync, mkdirSync} from 'node:fs' +import {mkdtemp} from 'node:fs/promises' import {tmpdir} from 'node:os' import path from 'node:path' -import {mkdtemp} from 'node:fs/promises' -import type { - CliCommandArguments, - CliCommandContext, - CliCommandDefinition, - SanityClient, + +import { + type CliCommandArguments, + type CliCommandContext, + type CliCommandDefinition, + type SanityClient, } from '@sanity/cli' import {absolutify} from '@sanity/util/fs' -import {isBoolean, isNumber, isString} from 'lodash' -import prettyMs from 'pretty-ms' import {Mutex} from 'async-mutex' import createDebug from 'debug' +import {isString} from 'lodash' +import prettyMs from 'pretty-ms' +import {hideBin} from 'yargs/helpers' +import yargs from 'yargs/yargs' + +import archiveDir from '../../actions/backup/archiveDir' import chooseBackupIdPrompt from '../../actions/backup/chooseBackupIdPrompt' -import resolveApiClient from '../../actions/backup/resolveApiClient' +import cleanupTmpDir from '../../actions/backup/cleanupTmpDir' import downloadAsset from '../../actions/backup/downloadAsset' import downloadDocument from '../../actions/backup/downloadDocument' +import {type File, PaginatedGetBackupStream} from '../../actions/backup/fetchNextBackupPage' +import parseApiErr from '../../actions/backup/parseApiErr' import newProgress from '../../actions/backup/progressSpinner' -import {PaginatedGetBackupStream, File} from '../../actions/backup/fetchNextBackupPage' -import archiveDir from '../../actions/backup/archiveDir' -import cleanupTmpDir from '../../actions/backup/cleanupTmpDir' +import resolveApiClient from '../../actions/backup/resolveApiClient' import humanFileSize from '../../util/humanFileSize' +import isPathDirName from '../../util/isPathDirName' import {defaultApiVersion} from './backupGroup' const debug = createDebug('sanity:backup') @@ -53,6 +59,14 @@ Examples sanity backup download DATASET_NAME --backup-id 2024-01-01-backup-3 --out /path/to/file --overwrite ` +function parseCliFlags(args: {argv?: string[]}) { + return yargs(hideBin(args.argv || process.argv).slice(2)) + .options('backup-id', {type: 'string'}) + .options('out', {type: 'string'}) + .options('concurrency', {type: 'number', default: DEFAULT_DOWNLOAD_CONCURRENCY}) + .options('overwrite', {type: 'boolean', default: false}).argv +} + const downloadBackupCommand: CliCommandDefinition = { name: 'download', group: 'backup', @@ -152,12 +166,8 @@ const downloadBackupCommand: CliCommandDefinition = { ) } catch (error) { progressSpinner.fail() - let msg = error.statusCode ? error.response.body.message : error.message - // If no message can be extracted, print the whole error. - if (msg === undefined) { - msg = String(error) - } - throw new Error(`Downloading dataset backup failed: ${msg}`) + const {message} = parseApiErr(error) + throw new Error(`Downloading dataset backup failed: ${message}`) } progressSpinner.set({step: `Archiving files into a tarball...`, update: true}) @@ -189,7 +199,7 @@ async function prepareBackupOptions( context: CliCommandContext, args: CliCommandArguments, ): Promise<[SanityClient, DownloadBackupOptions]> { - const flags = args.extOptions + const flags = await parseCliFlags(args) const [dataset] = args.argsWithoutOptions const {prompt, workDir} = context const {projectId, datasetName, client} = await resolveApiClient( @@ -213,25 +223,14 @@ async function prepareBackupOptions( } if ('concurrency' in flags) { - if ( - !isNumber(flags.concurrency) || - Number(flags.concurrency) < 1 || - Number(flags.concurrency) > MAX_DOWNLOAD_CONCURRENCY - ) { + if (flags.concurrency < 1 || flags.concurrency > MAX_DOWNLOAD_CONCURRENCY) { throw new Error(`concurrency should be in 1 to ${MAX_DOWNLOAD_CONCURRENCY} range`) } } - if ('overwrite' in flags && !isBoolean(flags.overwrite)) { - throw new Error(`overwrite should be valid boolean`) - } - const defaultOutFileName = `${datasetName}-backup-${backupId}.tar.gz` let out = await (async (): Promise => { - if ('out' in flags) { - if (!isString(flags.out)) { - throw new Error(`output path should be valid string`) - } + if (flags.out !== undefined) { // Rewrite the output path to an absolute path, if it is not already. return absolutify(flags.out) } @@ -274,15 +273,10 @@ async function prepareBackupOptions( token, outDir: path.dirname(out), outFileName: path.basename(out), - overwrite: Boolean(flags.overwrite), - concurrency: Number(flags.concurrency) || DEFAULT_DOWNLOAD_CONCURRENCY, + overwrite: flags.overwrite, + concurrency: flags.concurrency || DEFAULT_DOWNLOAD_CONCURRENCY, }, ] } -function isPathDirName(filepath: string): boolean { - // Check if the path has an extension, commonly indicating a file - return !/\.\w+$/.test(filepath) -} - export default downloadBackupCommand diff --git a/packages/sanity/src/_internal/cli/commands/backup/enableBackupCommand.ts b/packages/sanity/src/_internal/cli/commands/backup/enableBackupCommand.ts index fd841713fcb..48caa4a2286 100644 --- a/packages/sanity/src/_internal/cli/commands/backup/enableBackupCommand.ts +++ b/packages/sanity/src/_internal/cli/commands/backup/enableBackupCommand.ts @@ -1,4 +1,5 @@ -import {CliCommandDefinition} from '@sanity/cli' +import {type CliCommandDefinition} from '@sanity/cli' + import resolveApiClient from '../../actions/backup/resolveApiClient' import {defaultApiVersion} from './backupGroup' diff --git a/packages/sanity/src/_internal/cli/commands/backup/listBackupCommand.ts b/packages/sanity/src/_internal/cli/commands/backup/listBackupCommand.ts index 691e62f2ca2..17e4154ed7c 100644 --- a/packages/sanity/src/_internal/cli/commands/backup/listBackupCommand.ts +++ b/packages/sanity/src/_internal/cli/commands/backup/listBackupCommand.ts @@ -1,8 +1,12 @@ -import type {CliCommandDefinition} from '@sanity/cli' +import {type CliCommandDefinition} from '@sanity/cli' import {Table} from 'console-table-printer' import {isAfter, isValid, lightFormat, parse} from 'date-fns' +import {hideBin} from 'yargs/helpers' +import yargs from 'yargs/yargs' + +import parseApiErr from '../../actions/backup/parseApiErr' import resolveApiClient from '../../actions/backup/resolveApiClient' -import {defaultApiVersion, validateLimit} from './backupGroup' +import {defaultApiVersion} from './backupGroup' const DEFAULT_LIST_BACKUP_LIMIT = 30 @@ -40,6 +44,13 @@ Examples sanity backup list DATASET_NAME --after 2024-01-31 --before 2024-01-10 ` +function parseCliFlags(args: {argv?: string[]}) { + return yargs(hideBin(args.argv || process.argv).slice(2)) + .options('after', {type: 'string'}) + .options('before', {type: 'string'}) + .options('limit', {type: 'number', default: DEFAULT_LIST_BACKUP_LIMIT, alias: 'l'}).argv +} + const listDatasetBackupCommand: CliCommandDefinition = { name: 'list', group: 'backup', @@ -48,7 +59,7 @@ const listDatasetBackupCommand: CliCommandDefinition = { helpText, action: async (args, context) => { const {output, chalk} = context - const flags = args.extOptions + const flags = await parseCliFlags(args) const [dataset] = args.argsWithoutOptions const {projectId, datasetName, token, client} = await resolveApiClient( @@ -59,11 +70,14 @@ const listDatasetBackupCommand: CliCommandDefinition = { const query: ListBackupRequestQueryParams = {limit: DEFAULT_LIST_BACKUP_LIMIT.toString()} if (flags.limit) { - try { - query.limit = validateLimit(flags.limit) - } catch (err) { - throw new Error(`Parsing --limit: ${err}`) + // We allow limit up to Number.MAX_SAFE_INTEGER to leave it for server-side validation, + // while still sending sensible value in limit string. + if (flags.limit < 1 || flags.limit > Number.MAX_SAFE_INTEGER) { + throw new Error( + `Parsing --limit: must be an integer between 1 and ${Number.MAX_SAFE_INTEGER}`, + ) } + query.limit = flags.limit.toString() } if (flags.before || flags.after) { @@ -71,7 +85,7 @@ const listDatasetBackupCommand: CliCommandDefinition = { const parsedBefore = processDateFlags(flags.before) const parsedAfter = processDateFlags(flags.after) - if (parsedAfter && parsedBefore && isAfter(parsedBefore, parsedAfter)) { + if (parsedAfter && parsedBefore && isAfter(parsedAfter, parsedBefore)) { throw new Error('--after date must be before --before') } @@ -90,10 +104,8 @@ const listDatasetBackupCommand: CliCommandDefinition = { query: {...query}, }) } catch (error) { - const msg = error.statusCode - ? error.response.body.message - : error.message || error.statusMessage - output.error(`${chalk.red(`List dataset backup failed: ${msg}`)}\n`) + const {message} = parseApiErr(error) + output.error(`${chalk.red(`List dataset backup failed: ${message}`)}\n`) } if (response && response.backups) { @@ -126,7 +138,7 @@ const listDatasetBackupCommand: CliCommandDefinition = { function processDateFlags(date: string | undefined): Date | undefined { if (!date) return undefined - const parsedDate = parse(date, 'YYYY-MM-DD', new Date()) + const parsedDate = parse(date, 'yyyy-MM-dd', new Date()) if (isValid(parsedDate)) { return parsedDate } diff --git a/packages/sanity/src/_internal/cli/util/isPathDirName.ts b/packages/sanity/src/_internal/cli/util/isPathDirName.ts new file mode 100644 index 00000000000..342c0e5578a --- /dev/null +++ b/packages/sanity/src/_internal/cli/util/isPathDirName.ts @@ -0,0 +1,6 @@ +function isPathDirName(filepath: string): boolean { + // Check if the path has an extension, commonly indicating a file + return !/\.\w+$/.test(filepath) +} + +export default isPathDirName