UNPKG

storyblok-backup

Version:

npx CLI tool to create a full backup of a Storyblok space and restore single resources from it.

399 lines (343 loc) 11.8 kB
#!/usr/bin/env node /* eslint-disable no-console */ import fs from 'fs' import { Readable } from 'stream' import { finished } from 'stream/promises' import zipLib from 'zip-lib' import minimist from 'minimist' import StoryblokClient from 'storyblok-js-client' import { performance } from 'perf_hooks' import dotenvx from '@dotenvx/dotenvx' const startTime = performance.now() dotenvx.config({ quiet: true }) let resourceTypes = [ 'stories', 'collaborators', 'components', 'component-groups', 'assets', 'asset-folders', 'internal-tags', 'datasources', 'space', 'space-roles', 'tasks', 'activities', 'presets', 'field-types', 'webhooks', 'workflow-stages', 'workflow-stage-changes', 'workflows', 'releases', 'pipeline-branches', 'access-tokens', ] const args = minimist(process.argv.slice(2)) if ('help' in args) { console.log(`USAGE $ npx storyblok-backup OPTIONS --token <token> (required) Personal OAuth access token created in the account settings of a Stoyblok user. (NOT the Access Token of a Space!) Alternatively, you can set the STORYBLOK_OAUTH_TOKEN environment variable. --space <space_id> (required) ID of the space to backup Alternatively, you can set the STORYBLOK_SPACE_ID environment variable. --region <region> Region of the space. Possible values are: - 'eu' (default): EU - 'us': US - 'ap': Australia - 'ca': Canada - 'cn': China Alternatively, you can set the STORYBLOK_REGION environment variable. --types <types> Comma separated list of resource-types to backup (default=all). Possible values are: - '${resourceTypes.join("'\n - '")}' --omit-types <types> Comma separated list of resource-types to omit. --with-asset-files Downloads all files (assets) of the space (default=false). --output-dir <dir> Directory to write the backup to (default=./.output) (ATTENTION: Will fail if the directory already exists!) --force Force deletion and recreation of existing output directory. --create-zip Create a zip file of the backup (default=false). --zip-prefix <dir> Prefix for the zip file. (default='backup'). (The suffix will automatically be the current date.) --verbose Will show detailed output for every file written. --help Show this help MINIMAL EXAMPLE $ npx storyblok-backup --token 1234567890abcdef --space 12345 MAXIMAL EXAMPLE $ npx storyblok-backup \\ --token 1234567890abcdef \\ --space 12345 \\ --region ap \\ --types "stories,components" \\ --omit-types "activities" \\ --with-asset-files \\ --output-dir ./my-dir \\ --force \\ --create-zip \\ --zip-prefix daily \\ --verbose `) process.exit(0) } if (!('token' in args) && !process.env.STORYBLOK_OAUTH_TOKEN) { console.log( 'Error: State your oauth token via the --token argument or the environment variable STORYBLOK_OAUTH_TOKEN. Use --help to find out more.' ) process.exit(1) } const oauthToken = args.token || process.env.STORYBLOK_OAUTH_TOKEN if (!('space' in args) && !process.env.STORYBLOK_SPACE_ID) { console.log( 'Error: State your space id via the --space argument or the environment variable STORYBLOK_SPACE_ID. Use --help to find out more.' ) process.exit(1) } const spaceId = args.space || process.env.STORYBLOK_SPACE_ID let region = 'eu' if ('region' in args || process.env.STORYBLOK_REGION) { region = args.region || process.env.STORYBLOK_REGION if (!['eu', 'us', 'ap', 'ca', 'cn'].includes(region)) { console.log('Error: Invalid region parameter stated. Use --help to find out more.') process.exit(1) } } if ('types' in args) { const typesToBackup = args.types.split(',') for (const type of typesToBackup) { if (!resourceTypes.includes(type)) { console.log( `Error: Invalid type parameter stated. "${type}" is not a valid resource type. Use --help to find out more.` ) process.exit(1) } } resourceTypes = typesToBackup } if ('omit-types' in args) { const typesToOmit = args['omit-types'].split(',') resourceTypes = resourceTypes.filter((type) => !typesToOmit.includes(type)) } const verbose = 'verbose' in args const outputDir = args['output-dir'] || './.output' const backupDir = `${outputDir}/backup` if (fs.existsSync(outputDir) && !('force' in args)) { console.log( `Error: Output directory "${outputDir}" already exists. Use --force to delete and recreate it (POSSIBLY DANGEROUS!).` ) process.exit(1) } const filePrefix = args['zip-prefix'] || 'backup' const fileName = [ filePrefix, new Date().getFullYear(), (new Date().getMonth() + 1).toString().padStart(2, '0'), new Date().getDate().toString().padStart(2, '0'), new Date().getHours().toString().padStart(2, '0'), new Date().getMinutes().toString().padStart(2, '0'), new Date().getSeconds().toString().padStart(2, '0'), ].join('-') + '.zip' const filePath = `${outputDir}/${fileName}` // Output General information console.log('') console.log(`Creating backup for space ${spaceId}:`) console.log(`- output dir: ${outputDir}`) if ('create-zip' in args) { console.log(`- output zip: ${filePath}`) } console.log(`- resource types: ${resourceTypes.join(', ')}`) console.log(`- with asset files: ${'with-asset-files' in args ? 'yes' : 'no'}`) console.log(`- force output folder: ${'force' in args ? 'yes' : 'no'}`) console.log('') // Init Management API const StoryblokMAPI = new StoryblokClient({ oauthToken: oauthToken, region: region, }) // Remove existing output directory if (fs.existsSync(outputDir)) { fs.rmSync(outputDir, { recursive: true, force: true }) } // Create output directories (except for space, since this json will be saved in the root) fs.mkdirSync(backupDir, { recursive: true }) resourceTypes.forEach( (resource) => resource === 'space' || fs.mkdirSync(`${backupDir}/${resource}`) ) // Create separate directory for asset-files if (resourceTypes.includes('assets') && 'with-asset-files' in args) { fs.mkdirSync(`${backupDir}/asset-files`) } // Function to perform a default fetch const defaultFetch = async (endpoint, type, fileField, fileFieldObject) => { if (resourceTypes.includes(type)) { console.log(`Fetching ${type}`) await StoryblokMAPI.getAll(`spaces/${spaceId}/${endpoint}`) .then((items) => { items.forEach((item) => writeJson( type, fileFieldObject ? item[fileFieldObject][fileField] : item[fileField], item ) ) }) .catch((error) => { throw error }) } } // Function to write a file const writeJson = (folder, file, content) => { let outputFile = backupDir if (folder !== null) { outputFile += `/${folder}` } outputFile += `/${file}.json` fs.writeFileSync(outputFile, JSON.stringify(content, null, 2), (error) => { if (error) { throw error } }) if (verbose) console.log(`Written file ${outputFile}`) } // Function to download a file const downloadFile = async (type, name, url) => { const res = await fetch(url) const outputFile = `${backupDir}/${type}/${name}` const fileStream = fs.createWriteStream(outputFile, { flags: 'wx' }) await finished(Readable.fromWeb(res.body).pipe(fileStream)) if (verbose) console.log(`Written file ${outputFile}`) } // Fetch space info if (resourceTypes.includes('space')) { console.log(`Fetching space`) await StoryblokMAPI.get(`spaces/${spaceId}/`) .then((response) => { // Delete request_count_today, since it increments on every request // and is thus counterproductive for incremental backups. delete response.data.space.request_count_today writeJson(null, `space-${spaceId}`, response.data.space) }) .catch((error) => { throw error }) } // Fetch all stories if (resourceTypes.includes('stories')) { console.log(`Fetching stories`) await StoryblokMAPI.getAll(`spaces/${spaceId}/stories`) .then(async (stories) => { for (const story of stories) { await StoryblokMAPI.get(`spaces/${spaceId}/stories/${story.id}`) .then((response) => { // Delete preview_token, since it changes on every request // and is thus counterproductive for incremental backups. delete response.data.story.preview_token writeJson('stories', story.id, response.data.story) }) .catch((error) => { throw error }) } }) .catch((error) => { throw error }) } // Fetch all collaborators await defaultFetch('collaborators', 'collaborators', 'user_id') // Fetch all components await defaultFetch('components', 'components', 'name') // Fetch all component-groups await defaultFetch('component_groups', 'component-groups', 'id') // Fetch all assets (including files) if (resourceTypes.includes('assets')) { console.log(`Fetching assets`) await StoryblokMAPI.getAll(`spaces/${spaceId}/assets`) .then(async (assets) => { for (const asset of assets) { writeJson('assets', asset.id, asset) if ('with-asset-files' in args) { const fileExtension = asset.filename.split('.').at(-1) const fileName = asset.id + '.' + fileExtension await downloadFile('asset-files', fileName, asset.filename) } } }) .catch((error) => { throw error }) } // Fetch all asset-folders await defaultFetch('asset_folders', 'asset-folders', 'id') // Fetch all internal-tags await defaultFetch('internal_tags', 'internal-tags', 'id') // Fetch all datasources (including entries) if (resourceTypes.includes('datasources')) { console.log(`Fetching datasources`) await StoryblokMAPI.getAll(`spaces/${spaceId}/datasources`) .then(async (datasources) => { for (const datasource of datasources) { writeJson('datasources', datasource.id, datasource) await StoryblokMAPI.getAll(`spaces/${spaceId}/datasource_entries`, { datasource_id: datasource.id, }) .then((dateSourceEntries) => writeJson('datasources', datasource.id + '_entries', dateSourceEntries) ) .catch((error) => { throw error }) } }) .catch((error) => { throw error }) } // Fetch all space roles await defaultFetch('space_roles', 'space-roles', 'id') // Fetch all tasks await defaultFetch('tasks', 'tasks', 'id') // Fetch all activities await defaultFetch('activities', 'activities', 'id', 'activity') // Fetch all presets await defaultFetch('presets', 'presets', 'id') // Fetch all field-types await defaultFetch('field_types', 'field-types', 'name') // Fetch all webhooks await defaultFetch('webhook_endpoints', 'webhooks', 'id') // Fetch all workflow-stages await defaultFetch('workflow_stages', 'workflow-stages', 'id') // Fetch all workflow-stage-changes await defaultFetch('workflow_stage_changes', 'workflow-stage-changes', 'id') // Fetch all workflows await defaultFetch('workflows', 'workflows', 'id') // Fetch all releases await defaultFetch('releases', 'releases', 'id') // Fetch all pipeline branches await defaultFetch('branches', 'pipeline-branches', 'id') // Fetch all access tokens await defaultFetch('api_keys', 'access-tokens', 'id') // Create zip file if ('create-zip' in args) { console.log(`Creating zip file`) await zipLib .archiveFolder(backupDir, filePath) .then( function () { console.log(`Backup file '${filePath}' successfully created.`) }, function (err) { throw err } ) .catch((error) => { throw error }) } const endTime = performance.now() console.log(`Backup successfully created in ${Math.round((endTime - startTime) / 1000)} seconds.`) process.exit(0)