@sanity/migrate
Version:
Tooling for running data migrations on Sanity.io projects
183 lines (157 loc) • 5.66 kB
text/typescript
import {access, mkdir, writeFile} from 'node:fs/promises'
import path from 'node:path'
import {Args} from '@oclif/core'
import {SanityCommand} from '@sanity/cli-core'
import {chalk, confirm, input, select} from '@sanity/cli-core/ux'
import {deburr} from 'lodash-es'
import {getMigrationRootDirectory} from '../../actions/migration/getMigrationRootDirectory.js'
import {
minimalAdvanced,
minimalSimple,
renameField,
renameType,
stringToPTE,
} from '../../actions/migration/templates/index.js'
import {MIGRATIONS_DIRECTORY} from '../../utils/migration/constants.js'
const TEMPLATES = [
{name: 'Minimalistic migration to get you started', template: minimalSimple},
{name: 'Rename an object type', template: renameType},
{name: 'Rename a field', template: renameField},
{name: 'Convert string field to Portable Text', template: stringToPTE},
{
name: 'Advanced template using async iterators providing more fine grained control',
template: minimalAdvanced,
},
]
export class CreateMigrationCommand extends SanityCommand<typeof CreateMigrationCommand> {
static override args = {
title: Args.string({
description: 'Title of migration',
required: false,
}),
}
static override description = 'Create a new migration within your project'
static override examples = [
{
command: '<%= config.bin %> <%= command.id %>',
description: 'Create a new migration, prompting for title and options',
},
{
command: '<%= config.bin %> <%= command.id %> "Rename field from location to address"',
description: 'Create a new migration with the provided title, prompting for options',
},
]
public async run(): Promise<void> {
const {args} = await this.parse(CreateMigrationCommand)
const workDir = await getMigrationRootDirectory(this.output)
const title = await this.promptForTitle(args.title)
const types = await this.promptForDocumentTypes()
const {template} = await this.promptForTemplate()
const renderedTemplate = (template || minimalSimple)({
documentTypes: types
.split(',')
.map((t) => t.trim())
.filter(Boolean),
migrationName: title,
})
const sluggedName = deburr(title.toLowerCase())
.replaceAll(/\s+/g, '-')
.replaceAll(/[^a-z0-9-]/g, '')
const destDir = path.join(workDir, MIGRATIONS_DIRECTORY, sluggedName)
const definitionFile = path.join(destDir, 'index.ts')
const dirCreated = await this.createMigrationFile(destDir, definitionFile, renderedTemplate)
if (dirCreated) {
this.log()
this.log(`${chalk.green('✓')} Migration created!`)
this.log()
this.log('Next steps:')
this.log(
`Open ${chalk.bold(
definitionFile,
)} in your code editor and write the code for your migration.`,
)
this.log(
`Dry run the migration with:\n\`${chalk.bold(
`sanity migration run ${sluggedName} --project=<projectId> --dataset <dataset> `,
)}\``,
)
this.log(
`Run the migration against a dataset with:\n \`${chalk.bold(
`sanity migration run ${sluggedName} --project=<projectId> --dataset <dataset> --no-dry-run`,
)}\``,
)
this.log()
this.log(
`👉 Learn more about schema and content migrations at ${chalk.bold(
'https://www.sanity.io/docs/schema-and-content-migrations',
)}`,
)
}
}
private async createMigrationFile(
destDir: string,
definitionFile: string,
renderedTemplate: string,
): Promise<boolean> {
const dirExists = await access(destDir)
.then(() => true)
.catch(() => false)
if (dirExists) {
const shouldOverwrite = await this.promptForOverwrite(destDir)
if (!shouldOverwrite) return false
}
try {
await mkdir(destDir, {recursive: true})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
this.error(`Failed to create migration directory: ${message}`, {exit: 1})
}
try {
await writeFile(definitionFile, renderedTemplate)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
this.error(`Failed to create migration file: ${message}`, {exit: 1})
}
return true
}
private async promptForDocumentTypes(): Promise<string> {
return input({
message:
'Type of documents to migrate. You can add multiple types separated by comma (optional)',
})
}
private async promptForOverwrite(destDir: string): Promise<boolean> {
return confirm({
default: false,
message: `Migration directory ${chalk.cyan(destDir)} already exists. Overwrite?`,
})
}
private async promptForTemplate(): Promise<{
name: string
template: (params: {documentTypes: string[]; migrationName: string}) => string
}> {
const templatesByName = Object.fromEntries(TEMPLATES.map((t) => [t.name, t]))
const templateName = await select({
choices: TEMPLATES.map((definedTemplate) => ({
name: definedTemplate.name,
value: definedTemplate.name,
})),
message: 'Select a template',
})
return templatesByName[templateName]!
}
private async promptForTitle(providedTitle?: string): Promise<string> {
if (providedTitle?.trim()) {
return providedTitle
}
return input({
message: 'Title of migration (e.g. "Rename field from location to address")',
validate: (value) => {
if (!value.trim()) {
return 'Title cannot be empty'
}
return true
},
})
}
}