@cooperco/contentful-cli-migrations
Version:
Contentful CLI Migration Tool
682 lines (603 loc) • 24.6 kB
JavaScript
#! /usr/bin/env node
const PLACEHOLDER_MANAGEMENT_TOKEN = 'placeholder-management-token'
const PLACEHOLDER_SPACE_ID = 'placeholder-space-id'
const DEFAULT_MIGRATIONS_DIR = 'migrations/scripts/'
const DEFAULT_LOCALE = 'en-US'
const DEFAULT_FIRST_MIGRATION = '0001-init.cjs'
const path = await import('path')
;(async function main() {
try {
const localWorkingDir = process.cwd()
const scriptDirectory = await getDirNamePath()
const envValues = await getEnvValues(localWorkingDir, scriptDirectory)
const parsedArguments = await parseArguments(localWorkingDir, envValues)
const environmentSingleton = await getEnvironment(parsedArguments)
console.log(
'##/INFO: Applying migrations to environment-id: ' +
environmentSingleton?.sys?.id
)
let migrationArray
let latestMigrationNumber = 0
if (parsedArguments?.shouldInitialise) {
await createFirstMigration(parsedArguments)
migrationArray = [DEFAULT_FIRST_MIGRATION]
console.log(
'##/INFO: This is the fist run of the script. We will create the Counter content-type.'
)
} else {
latestMigrationNumber = await getCounter(
environmentSingleton,
parsedArguments
)
console.log(
'##/INFO: Latest migration successfully run # ' + latestMigrationNumber
)
if (parsedArguments.rollback) {
migrationArray = await parseMigrationsToRun(
parsedArguments,
latestMigrationNumber,
true
)
} else {
migrationArray = await parseMigrationsToRun(
parsedArguments,
latestMigrationNumber
)
}
}
await performMigrations(
environmentSingleton,
parsedArguments,
latestMigrationNumber,
migrationArray,
parsedArguments.rollback
)
} catch (error) {
console.error('@@/ERROR:', error)
}
})()
/**
* Reads environment values from .env files.
*
* @param {string} localWorkingDir - The directory path where the library is located.
* @param {string} scriptDirectory - The directory path where the script is running.
* @return {Promise<object>} The environment values.
* @property {string} CONTENTFUL_CMA_TOKEN - The CMA token for Contentful.
* @property {string} CONTENTFUL_SPACE_ID - The Space ID.
* @property {string} CMS_MIGRATIONS_DIR - The folder where the migration scripts are.
* @property {string} CMS_MIGRATIONS_COUNTER_ID - The entry-id used for the counter
* @property {string} CMS_MIGRATIONS_COUNTER_FIELD - The field in that entry that will store the actual counter
* @property {string} CMS_MIGRATIONS_COUNTER_LOCALE - The locale to look for in that field
*/
async function getEnvValues(localWorkingDir, scriptDirectory) {
const fileSystem = await import('fs')
const { config } = await import('dotenv')
const envDataFromPath = path =>
fileSystem.existsSync(path) ? config({ path }).parsed : {}
const paths = [
`${scriptDirectory}/../../.env`,
`${scriptDirectory}/../../.env.local`,
`${localWorkingDir}/.env`,
`${localWorkingDir}/.env.local`
]
const envValues = paths.map(envDataFromPath)
return Object.assign({}, ...envValues)
}
/**
* Parses command line arguments and sets default values.
*
* @param {string} rootFolder - The directory path where the .env files are located.
* @param {Object} envValues - The .env values loaded.
* @property {string} envValues.CONTENTFUL_CMA_TOKEN - The CMA token for Contentful.
* @property {string} envValues.CONTENTFUL_SPACE_ID - The Space ID.
* @property {string} envValues.CMS_MIGRATIONS_DIR - The folder where the migration scripts are.
* @property {string} envValues.CMS_MIGRATIONS_COUNTER_ID - The entry-id used for the counter
* @property {string} envValues.CMS_MIGRATIONS_COUNTER_FIELD - The field in that entry that will store the actual counter
* @property {string} envValues.CMS_MIGRATIONS_COUNTER_LOCALE - The locale to look for in that field
* @returns {Promise<object>} The initial settings.
* @property {string} managementToken - The CMS Management Token.
* @property {string} spaceId - The CMS Space ID.
* @property {string} environmentId - The CMS Environment ID.
* @property {string} rootDestinationFolder - The folder containing the migrations
* @property {string} counterEntryId - The entry ID storing the counter.
* @property {string} counterFieldId - The field ID to retrieve the latest ran migration.
* @property {string} counterLocale - The locale of the field to look for.
* @property {boolean} forceYes - If it should run all the migrations.
* @property {boolean} shouldInitialise - If it should create the content-type and entry for the Counter.
*
* @throws {Error} If '--environment-id' or '--to' are not provided or if '--management-token' or '--mt' are duplicated.
*/
async function parseArguments(rootFolder, envValues) {
const minimist = (await import('minimist')).default
const parsedArguments = minimist(process.argv.slice(2))
await checkArgs(parsedArguments)
const {
'space-id': spaceId = envValues?.CONTENTFUL_SPACE_ID ??
PLACEHOLDER_SPACE_ID,
'management-token': managementToken = parsedArguments['mt'] ??
envValues?.CONTENTFUL_CMA_TOKEN ??
PLACEHOLDER_MANAGEMENT_TOKEN,
'counter-id': counterEntryId = envValues?.CMS_MIGRATIONS_COUNTER_ID,
'counter-field': counterFieldId = envValues?.CMS_MIGRATIONS_COUNTER_FIELD,
'counter-locale':
counterLocale = envValues?.CMS_MIGRATIONS_COUNTER_LOCALE ??
DEFAULT_LOCALE,
'migrations-dir': migrationsDir = envValues?.CMS_MIGRATIONS_DIR ??
DEFAULT_MIGRATIONS_DIR
} = parsedArguments
const rootDestinationFolder = await getDestinationFolder(
rootFolder,
migrationsDir,
parsedArguments
)
const environmentId = parsedArguments?.to || parsedArguments['environment-id']
if (!environmentId) {
console.error('@@/ERROR: An environment-id should be specified')
process.exit(1)
}
return {
managementToken,
spaceId,
environmentId,
rootDestinationFolder,
counterEntryId,
counterFieldId,
counterLocale,
forceYes: parsedArguments.hasOwnProperty('force-yes'),
shouldInitialise: parsedArguments.hasOwnProperty('initialise'),
rollback: parsedArguments.hasOwnProperty('rollback')
}
}
/**
* This function checks the arguments passed in the command line.
*
* @param {Object} parsedArguments - The object that contains the parsed command line arguments.
* @property {string} parsedArguments.managementToken - The CMS Management Token.
* @property {string} parsedArguments.spaceId - The CMS Space ID.
* @property {string} parsedArguments.environmentId - The CMS Environment ID.
* @property {string} parsedArguments.rootDestinationFolder - The folder containing the migrations
* @property {string} parsedArguments.counterEntryId - The entry ID storing the counter.
* @property {string} parsedArguments.counterFieldId - The field ID to retrieve the latest ran migration.
* @property {string} parsedArguments.counterLocale - The locale of the field to look for.
* @property {boolean} parsedArguments.forceYes - If it should run all the migrations.
* @property {boolean} parsedArguments.shouldInitialise - If it should create the content-type and entry for the Counter.
* @returns {Promise<object>} An object containing the evaluated command line arguments.
*
* @throws {Error} If both 'to' and 'environment-id' options are specified or if neither is specified.
* @throws {Error} If both 'management-token' and 'mt' options are specified.
*/
async function checkArgs(parsedArguments) {
if (
!(Boolean(parsedArguments.to) ^ Boolean(parsedArguments['environment-id']))
) {
console.error(
"@@/ERROR: Only one of the two options '--environment-id' or '--to' should be specified"
)
process.exit(1)
}
if (
Boolean(parsedArguments['management-token']) &&
Boolean(parsedArguments.mt)
) {
console.error(
"@@/ERROR: Only one of the two options '--management-token' or '--mt' can be specified"
)
process.exit(1)
}
}
/**
* This function gets the destination folder based on whether a custom folder is provided or not.
*
* @param {string} rootFolder - The directory path where the script is being executed.
* @param {string} cmsMigrationsDir - The CMS Default Migrations Directory.
* @param {Object} parsedArguments - The object that contains the parsed command line arguments.
* @property {string} parsedArguments.managementToken - The CMS Management Token.
* @property {string} parsedArguments.spaceId - The CMS Space ID.
* @property {string} parsedArguments.environmentId - The CMS Environment ID.
* @property {string} parsedArguments.rootDestinationFolder - The folder containing the migrations
* @property {string} parsedArguments.counterEntryId - The entry ID storing the counter.
* @property {string} parsedArguments.counterFieldId - The field ID to retrieve the latest ran migration.
* @property {string} parsedArguments.counterLocale - The locale of the field to look for.
* @property {boolean} parsedArguments.forceYes - If it should run all the migrations.
* @property {boolean} parsedArguments.shouldInitialise - If it should create the content-type and entry for the Counter.
*
* @returns {Promise<string>} The path of the evaluated destination folder.
* @property {string} destinationFolder - The destination folder for the export.
*
* @throws {Error} If the destination folder does not exist or is not accessible.
*/
async function getDestinationFolder(
rootFolder,
cmsMigrationsDir,
parsedArguments
) {
const fileSystem = await import('fs')
const path = await import('path')
const defaultExportDirectory = path.join(rootFolder, cmsMigrationsDir)
let destinationFolder =
path.resolve(parsedArguments['migrations-dir'] || defaultExportDirectory) +
'/'
// Create destination folder if not present
if (!fileSystem.existsSync(destinationFolder)) {
fileSystem.mkdirSync(destinationFolder, { recursive: true })
}
if (
!fileSystem.existsSync(destinationFolder) ||
destinationFolder === path.sep
) {
console.error(
'@@/ERROR: Destination folder does not exist or is not accessible!'
)
process.exit(1)
}
return destinationFolder
}
/**
* Gets the current directory's path.
*
* @return {Promise<string>} The path of the current directory.
*/
async function getDirNamePath() {
const { fileURLToPath } = await import('url')
const { dirname } = await import('path')
const __filename = fileURLToPath(import.meta.url)
return dirname(__filename)
}
/**
* Create a First Migration if empty Environment
*
* @param {Object} parsedArguments
* @property {string} parsedArguments.managementToken - The CMS Management Token.
* @property {string} parsedArguments.spaceId - The CMS Space ID.
* @property {string} parsedArguments.environmentId - The CMS Environment ID.
* @property {string} parsedArguments.rootDestinationFolder - The folder containing the migrations
* @property {string} parsedArguments.counterEntryId - The entry ID storing the counter.
* @property {string} parsedArguments.counterFieldId - The field ID to retrieve the latest ran migration.
* @property {string} parsedArguments.counterLocale - The locale of the field to look for.
* @property {boolean} parsedArguments.forceYes - If it should run all the migrations.
* @property {boolean} parsedArguments.shouldInitialise - If it should create the content-type and entry for the Counter.
* @return {Promise<void>}
*/
async function createFirstMigration(parsedArguments) {
const fileSystem = await import('fs')
const firstMigrationName =
parsedArguments?.rootDestinationFolder + DEFAULT_FIRST_MIGRATION
const data = `module.exports = {
up: async function (migration, context) {
const versionTrackingContentType = migration.createContentType('migrationVersionTracker', {
name: 'Migration Version Tracker',
displayField: 'entryName'
})
versionTrackingContentType
.createField('entryName')
.name('Entry Name')
.type('Symbol')
.localized(false)
.required(true)
.validations([
{
"unique": true
}
])
.disabled(false)
.omitted(false)
versionTrackingContentType
.createField('version')
.name('Version')
.type('Integer')
.required(true)
.validations([])
.disabled(true)
.omitted(false)
versionTrackingContentType
.changeFieldControl('version', 'builtin', 'numberEditor', {})
},
}`
fileSystem.writeFileSync(firstMigrationName, data)
}
/**
* Create a Counter entry if missing
*
* @param {import("contentful-management/dist/typings/entities/environment").Environment} environmentSingleton - The Contentful environment object.
* @return {Promise<void>}
*/
async function createCounterEntry(environmentSingleton) {
try {
const lib = await import('contentful-lib-helpers')
const defaultLocale = await lib.getDefaultLocale(environmentSingleton)
const counterEntry = await environmentSingleton.createEntry(
'migrationVersionTracker',
{
fields: {
entryName: {
[defaultLocale.code]: 'Migration-Version-Tracker-DO-NOT-DELETE'
},
version: {
[defaultLocale.code]: 1
}
}
}
)
console.log('##/INFO: We created the Counter entry for you.')
console.log(
'##/INFO: Please write these values in your .env/.env.local file.'
)
console.log('CMS_MIGRATIONS_COUNTER_ID=' + counterEntry?.sys?.id)
console.log('CMS_MIGRATIONS_COUNTER_FIELD=version')
console.log('CMS_MIGRATIONS_COUNTER_LOCALE=' + defaultLocale.code)
} catch (error) {
console.error('@@/ERROR:', error)
process.exit(1)
}
}
/**
* Check if the destination environment exists before running the migration(s)
*
* @param {Object} parsedArguments
* @property {string} parsedArguments.managementToken - The CMS Management Token.
* @property {string} parsedArguments.spaceId - The CMS Space ID.
* @property {string} parsedArguments.environmentId - The CMS Environment ID.
* @returns {Promise<import("contentful-management/dist/typings/entities/environment").Environment|null>} - A Promise that resolves with the environment object, or `null` if not found.
*/
async function getEnvironment(parsedArguments) {
const contentfulManagement = (await import('contentful-management')).default
const lib = await import('contentful-lib-helpers')
const environmentSingleton = await lib.getEnvironment(
contentfulManagement,
parsedArguments?.managementToken,
parsedArguments?.spaceId,
parsedArguments?.environmentId,
0
)
if (!environmentSingleton) {
console.error(
`Unable to retrieve Destination environment-id '${parsedArguments?.environmentId}' for space-id '${parsedArguments?.spaceId}'!`
)
console.error(
`Could also be that the management token or space-id are invalid.`
)
process.exit(1)
}
return environmentSingleton
}
/**
* Get the value of the latest successful migration from the Counter Entry
*
* @param {import("contentful-management/dist/typings/entities/environment").Environment} environmentSingleton - The Contentful environment object.
* @param {Object} parsedArguments
* @property {string} parsedArguments.managementToken - The CMS Management Token.
* @property {string} parsedArguments.spaceId - The CMS Space ID.
* @property {string} parsedArguments.environmentId - The CMS Environment ID.
* @property {string} parsedArguments.rootDestinationFolder - The folder containing the migrations
* @property {string} parsedArguments.counterEntryId - The entry ID storing the counter.
* @property {string} parsedArguments.counterFieldId - The field ID to retrieve the latest ran migration.
* @property {string} parsedArguments.counterLocale - The locale of the field to look for.
* @property {boolean} parsedArguments.forceYes - If it should run all the migrations.
* @property {boolean} parsedArguments.shouldInitialise - If it should create the content-type and entry for the Counter.
* @return {Promise<number>}
*/
async function getCounter(environmentSingleton, parsedArguments) {
try {
const fieldId = parsedArguments?.counterFieldId
const fieldLocale = parsedArguments?.counterLocale
const counterEntry = await environmentSingleton.getEntry(
parsedArguments?.counterEntryId
)
if (!counterEntry?.fields?.[fieldId]) {
throw new Error(
'Impossible to retrieve the Counter entry! Please check entry-id, field-id and locale in your configuration'
)
}
if (counterEntry.fields[fieldId][fieldLocale] === undefined) {
counterEntry.fields[fieldId][fieldLocale] = '0'
try {
await counterEntry.update()
console.log('##/INFO: Counter entry is being initialised...')
} catch (e) {
throw new Error(
'Counter entry is invalid! Please check entry-id, field-id and locale in your configuration'
)
}
}
return parseInt(counterEntry.fields[fieldId]?.[fieldLocale], 10) || 0
} catch (error) {
console.error('@@/ERROR:', error)
process.exit(1)
}
}
/**
* Parses the migrations to run based on the latest migration number.
*
* @param {Object} parsedArguments - The script arguments.
* @property {string} parsedArguments.managementToken - The CMS Management Token.
* @property {string} parsedArguments.spaceId - The CMS Space ID.
* @property {string} parsedArguments.environmentId - The CMS Environment ID.
* @property {string} parsedArguments.rootDestinationFolder - The folder containing the migrations
* @property {string} parsedArguments.counterEntryId - The entry ID storing the counter.
* @property {string} parsedArguments.counterFieldId - The field ID to retrieve the latest ran migration.
* @property {string} parsedArguments.counterLocale - The locale of the field to look for.
* @property {boolean} parsedArguments.forceYes - If it should run all the migrations.
* @property {boolean} parsedArguments.shouldInitialise - If it should create the content-type and entry for the Counter.
* @param {number} latestMigrationNumber - The latest migration number.
* @returns {Promise<string[]>} An array of migrations to run.
*/
async function parseMigrationsToRun(
parsedArguments,
latestMigrationNumber,
rollback = false
) {
const fileSystem = await import('fs')
const folderMigrationScript = parsedArguments?.rootDestinationFolder
const allFiles = fileSystem.readdirSync(folderMigrationScript)
const files = allFiles.filter(item => !/(^|\/)\.[^/.]/g.test(item))
let migrationArray = []
let indexObject = []
let arrayLength = files.length
if (arrayLength === 0) {
console.log('%%/DEBUG: The migration folder is empty')
process.exit(1)
}
indexObject[0] = 'unused'
for (let i = 0; i < arrayLength; i++) {
const fileIndexValue = parseInt(files[i].split('-')[0])
if (fileIndexValue === undefined || !Number.isInteger(fileIndexValue)) {
console.error('@@/ERROR: There is an invalid migration:')
console.error('@@/ERROR: ' + folderMigrationScript + files[i])
process.exit(1)
}
if (indexObject[fileIndexValue]) {
console.error('@@/ERROR: There are duplicated migrations!')
console.error('@@/ERROR: ' + folderMigrationScript + files[i])
process.exit(1)
}
indexObject[fileIndexValue] = files[i]
}
if (rollback) {
// Collect migrations to roll back, in reverse order
for (let i = latestMigrationNumber; i > 0; i--) {
if (indexObject[i]) {
migrationArray.push(indexObject[i])
}
}
} else {
// Collect migrations to run
for (let i = 1; i < indexObject.length; i++) {
if (i > latestMigrationNumber) {
migrationArray.push(indexObject[i])
}
}
}
return migrationArray
}
/**
* Performs the Contentful migrations.
*
* @param {import("contentful-management/dist/typings/entities/environment").Environment} environmentSingleton - The Contentful environment object.
* @param {Object} parsedArguments - The script arguments.
* @property {string} parsedArguments.managementToken - The CMS Management Token.
* @property {string} parsedArguments.spaceId - The CMS Space ID.
* @property {string} parsedArguments.environmentId - The CMS Environment ID.
* @property {string} parsedArguments.rootDestinationFolder - The folder containing the migrations
* @property {string} parsedArguments.counterEntryId - The entry ID storing the counter.
* @property {string} parsedArguments.counterFieldId - The field ID to retrieve the latest ran migration.
* @property {string} parsedArguments.counterLocale - The locale of the field to look for.
* @property {boolean} parsedArguments.forceYes - If it should run all the migrations.
* @property {boolean} parsedArguments.shouldInitialise - If it should create the content-type and entry for the Counter.
* @param {number} latestMigrationNumber - The latest migration number.
* @param {string[]} migrationArray - An array of migrations to run.
* @returns {Promise<void>}
*/
async function performMigrations(
environmentSingleton,
parsedArguments,
latestMigrationNumber,
migrationArray,
rollback = false
) {
const fileSystem = await import('fs')
const path = await import('path')
const { runMigration } = await import('contentful-migration')
const folderMigrationScript =
parsedArguments?.rootDestinationFolder ?? DEFAULT_MIGRATIONS_DIR
if (migrationArray.length === 0) {
console.log(
'##/INFO: Your environment is already up to date to the latest migration'
)
return
}
let targetVersion = latestMigrationNumber // Default target version for rollback is the latest migration
if (rollback) {
if (parsedArguments.version) {
// Rollback to a specific version if provided
targetVersion = parseInt(parsedArguments.version, 10)
} else {
// Otherwise, rollback only the last migration
targetVersion = latestMigrationNumber - 1
}
// Filter migrations for rollback
migrationArray = migrationArray
.filter(migrationScript => {
const migrationVersion = parseInt(migrationScript.split('-')[0], 10)
return migrationVersion > targetVersion
})
.reverse() // Rollback should reverse the order of migrations
}
// Loop through and run migrations sequentially
for (const migrationScript of migrationArray) {
try {
const resolvedPath =
process.platform === 'win32'
? 'file://' +
path
.resolve(folderMigrationScript + migrationScript)
.replace(/\\/g, '/')
: path.resolve(folderMigrationScript + migrationScript)
const migrationModule = await import(resolvedPath)
const operation = rollback ? 'down' : 'up'
const migrationFunction = migrationModule.default[operation] // Accessing the correct function
if (!migrationFunction) {
throw new Error(
`The migration script does not define a ${operation} function. File: ${migrationScript}`
)
}
let options = {
spaceId: parsedArguments?.spaceId,
environmentId: environmentSingleton?.sys?.id,
accessToken: parsedArguments?.managementToken,
yes: parsedArguments?.forceYes
}
if (
fileSystem.readFileSync(folderMigrationScript + migrationScript)
.length === 0
) {
console.error('@@/ERROR: The following migration is empty')
throw new Error('Migration file is empty')
}
console.log(
`##/INFO: Running migration ${migrationScript} (${operation})`
)
await runMigration({
migrationFunction,
context: { environmentSingleton, parsedArguments },
...options
})
console.log(`##/INFO: Migration ${migrationScript} (${operation}) Done!`)
if (!rollback && !parsedArguments?.shouldInitialise) {
latestMigrationNumber++
const fieldId = parsedArguments?.counterFieldId
const fieldLocale = parsedArguments?.counterLocale
const entrySavingCounter = await environmentSingleton.getEntry(
parsedArguments?.counterEntryId
)
entrySavingCounter.fields[fieldId][fieldLocale] = latestMigrationNumber
await entrySavingCounter.update()
} else if (rollback && !parsedArguments?.shouldInitialise) {
latestMigrationNumber--
const fieldId = parsedArguments?.counterFieldId
const fieldLocale = parsedArguments?.counterLocale
const entrySavingCounter = await environmentSingleton.getEntry(
parsedArguments?.counterEntryId
)
entrySavingCounter.fields[fieldId][fieldLocale] = latestMigrationNumber
await entrySavingCounter.update()
} else if (rollback && parsedArguments?.shouldInitialise) {
console.error('@@/ERROR: Cannot rollback initialization migration')
break
} else {
await createCounterEntry(environmentSingleton)
}
} catch (e) {
console.error(
'@@/ERROR ' +
e +
' - while running the migration: ' +
folderMigrationScript +
migrationScript
)
break // Stop further execution if an error occurs
}
}
console.log(
'##/INFO: All migrations completed successfully or stopped due to an error.'
)
}