Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/migrations-into-cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sanity/cli': patch
---

The content migration commands (`sanity migrations create`, `list`, and `run`) are now built into the CLI.
6 changes: 5 additions & 1 deletion packages/@sanity/cli/oclif.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ export default {
'./dist/hooks/prerun/warnings.js',
],
},
plugins: ['@oclif/plugin-help', '@sanity/runtime-cli', '@sanity/migrate', '@sanity/codegen'],
// Note: do not add '@sanity/migrate' here. The `migrations` commands now ship
// natively (see commands/migrations/); re-adding the plugin would register
// duplicate command ids.
plugins: ['@oclif/plugin-help', '@sanity/runtime-cli', '@sanity/codegen'],
topics: {
backups: {description: 'Manage dataset backups'},
cors: {description: 'Manage CORS origins for your project'},
Expand All @@ -24,6 +27,7 @@ export default {
manifest: {description: 'Extract studio configuration as JSON manifests'},
mcp: {description: 'Configure Sanity MCP server for AI editors'},
media: {description: 'Manage media assets and aspect definitions'},
migrations: {description: 'Run and manage content migrations'},
openapi: {description: 'Manage OpenAPI specifications'},
projects: {description: 'Manage Sanity projects'},
schemas: {description: 'Manage and validate schemas'},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {readdir} from 'node:fs/promises'

import {type Migration} from '@sanity/migrate'
import {afterEach, expect, test, vi} from 'vitest'

import {resolveMigrations} from '../resolveMigrations.js'

const mocks = vi.hoisted(() => ({
readdir: vi.fn(),
resolveMigrationScript: vi.fn(),
}))

vi.mock('node:fs/promises', () => ({
readdir: mocks.readdir,
}))

// Keep the real isLoadableMigrationScript; only stub the on-disk resolution.
vi.mock('../resolveMigrationScript.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../resolveMigrationScript.js')>()),
resolveMigrationScript: mocks.resolveMigrationScript,
}))

const mockReaddir = vi.mocked(readdir)
const mockResolveMigrationScript = mocks.resolveMigrationScript

const migration = {migrate: vi.fn(), title: 'Rename field'} as unknown as Migration

afterEach(() => {
vi.clearAllMocks()
})

test('returns a migration id only once when multiple loadable candidates resolve for one entry', async () => {
mockReaddir.mockResolvedValue([
{isDirectory: () => true, name: 'rename-field'} as unknown as Awaited<
ReturnType<typeof readdir>
>[0],
])

// e.g. both `rename-field.ts` and `rename-field/index.ts` exist and load.
mockResolveMigrationScript.mockResolvedValue([
{
absolutePath: '/p/migrations/rename-field.ts',
mod: {default: migration},
relativePath: 'migrations/rename-field.ts',
},
{
absolutePath: '/p/migrations/rename-field/index.ts',
mod: {default: migration},
relativePath: 'migrations/rename-field/index.ts',
},
])

const result = await resolveMigrations('/p')

expect(result).toHaveLength(1)
expect(result[0]?.id).toBe('rename-field')
})

test('returns a migration id only once when a file and a directory share the same base name', async () => {
mockReaddir.mockResolvedValue([
{isDirectory: () => false, name: 'rename-field.ts'} as unknown as Awaited<
ReturnType<typeof readdir>
>[0],
{isDirectory: () => true, name: 'rename-field'} as unknown as Awaited<
ReturnType<typeof readdir>
>[0],
])

mockResolveMigrationScript.mockResolvedValue([
{
absolutePath: '/p/migrations/rename-field.ts',
mod: {default: migration},
relativePath: 'migrations/rename-field.ts',
},
])

const result = await resolveMigrations('/p')

expect(result).toHaveLength(1)
expect(result[0]?.id).toBe('rename-field')
})
3 changes: 3 additions & 0 deletions packages/@sanity/cli/src/actions/migration/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const MIGRATIONS_DIRECTORY = 'migrations'
export const MIGRATION_SCRIPT_EXTENSIONS = ['mjs', 'js', 'ts', 'cjs']
export const DEFAULT_API_VERSION = 'v2024-01-29'
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {type APIConfig} from '@sanity/migrate'

type ApiVersion = APIConfig['apiVersion']

const VERSION_PATTERN = /^v\d+-\d+-\d+$|^vX$/ // Matches version strings like vYYYY-MM-DD or vX

/**
* Ensures that the provided API version string is in the correct format.
* If the version does not start with 'v', it will be prefixed with 'v'.
* If the version does not match the expected pattern, an error will be thrown.
*/
export function ensureApiVersionFormat(version: string): ApiVersion {
const normalizedVersion = version.startsWith('v') ? version : `v${version}`

// Check if the version matches the expected pattern
if (!VERSION_PATTERN.test(normalizedVersion)) {
throw new Error(
`Invalid API version format: ${normalizedVersion}. Expected format: vYYYY-MM-DD or vX`,
)
}

return normalizedVersion as ApiVersion
}
16 changes: 16 additions & 0 deletions packages/@sanity/cli/src/actions/migration/fileExists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {access} from 'node:fs/promises'

/**
* Checks if a file exists and can be "accessed".
* Prone to race conditions, but good enough for our use cases.
*
* @param filePath - The path to the file to check
* @returns A promise that resolves to true if the file exists, false otherwise
* @internal
*/
export function fileExists(filePath: string): Promise<boolean> {
return access(filePath).then(
() => true,
() => false,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import {isatty} from 'node:tty'
import {styleText} from 'node:util'

import {type Migration, type Mutation, type NodePatch, type Transaction} from '@sanity/migrate'
import {type KeyedSegment} from '@sanity/types'

import {convertToTree, formatTree, maxKeyLength} from './tree.js'

type ItemRef = number | string
type Impact = 'destructive' | 'incremental' | 'maybeDestructive'
type Variant = 'info' | Impact

const isTty = isatty(1)

interface FormatterOptions<Subject> {
migration: Migration
subject: Subject

indentSize?: number
}

export function prettyFormat({
indentSize = 0,
migration,
subject,
}: FormatterOptions<(Mutation | Transaction)[] | Mutation | Transaction>): string {
return (Array.isArray(subject) ? subject : [subject])
.map((subjectEntry) => {
if (subjectEntry.type === 'transaction') {
return [
[
badge('transaction', 'info'),
subjectEntry.id === undefined ? null : styleText('underline', subjectEntry.id),
]
.filter(Boolean)
.join(' '),
indent(
prettyFormat({
indentSize: indentSize,
migration,
subject: subjectEntry.mutations,
}),
),
].join('\n\n')
}
return prettyFormatMutation({
indentSize,
migration,
subject: subjectEntry,
})
})
.join('\n\n')
}

function encodeItemRef(ref: KeyedSegment | number): ItemRef {
return typeof ref === 'number' ? ref : ref._key
}

function badgeStyle(variant: Variant, label: string) {
const styles: Record<Variant, string> = {
destructive: styleText(['bgRed', 'black', 'bold'], label),
incremental: styleText(['bgGreen', 'black', 'bold'], label),
info: styleText(['bgWhite', 'black'], label),
maybeDestructive: styleText(['bgYellow', 'black', 'bold'], label),
}

return styles[variant]
}

function badge(label: string, variant: Variant): string {
if (!isTty) {
return `[${label}]`
}

return badgeStyle(variant, ` ${label} `)
}

const mutationImpact: Record<Mutation['type'], Impact> = {
create: 'incremental',
createIfNotExists: 'incremental',
createOrReplace: 'maybeDestructive',
delete: 'destructive',
patch: 'maybeDestructive',
}

function documentId(mutation: Mutation): string | undefined {
if ('id' in mutation) {
return mutation.id
}

if ('document' in mutation) {
return mutation.document._id
}

return undefined
}

const listFormatter = new Intl.ListFormat('en-US', {
type: 'disjunction',
})

function mutationHeader(mutation: Mutation, migration: Migration): string {
const mutationType = badge(mutation.type, mutationImpact[mutation.type])

const documentType =
'document' in mutation || migration.documentTypes
? badge(
'document' in mutation
? mutation.document._type
: listFormatter.format(migration.documentTypes ?? []),
'info',
)
: null

// TODO: Should we list documentType when a mutation can be yielded for any document type?
return [mutationType, documentType, styleText('underline', documentId(mutation) ?? '')]
.filter(Boolean)
.join(' ')
}

function prettyFormatMutation({
indentSize = 0,
migration,
subject,
}: FormatterOptions<Mutation>): string {
const lock =
'options' in subject ? styleText('cyan', `(if revision==${subject.options?.ifRevision})`) : ''
const header = [mutationHeader(subject, migration), lock].join(' ')
const padding = ' '.repeat(indentSize)

if (
subject.type === 'create' ||
subject.type === 'createIfNotExists' ||
subject.type === 'createOrReplace'
) {
return [header, '\n', indent(JSON.stringify(subject.document, null, 2), indentSize)].join('')
}

if (subject.type === 'patch') {
const tree = convertToTree<NodePatch>(subject.patches.flat())
const paddingLength = Math.max(maxKeyLength(tree.children) + 2, 30)

return [
header,
'\n',
formatTree<NodePatch>({
getMessage: (patch: NodePatch) => formatPatchMutation(patch),
indent: padding,
node: tree.children,
paddingLength,
}),
].join('')
}

return header
}

function formatPatchMutation(patch: NodePatch): string {
const {op} = patch
const formattedType = styleText('bold', op.type)
if (op.type === 'unset') {
return `${styleText('red', formattedType)}()`
}
if (op.type === 'diffMatchPatch') {
return `${styleText('yellow', formattedType)}(${op.value})`
}
if (op.type === 'inc' || op.type === 'dec') {
return `${styleText('yellow', formattedType)}(${op.amount})`
}
if (op.type === 'set') {
return `${styleText('yellow', formattedType)}(${JSON.stringify(op.value)})`
}
if (op.type === 'setIfMissing') {
return `${styleText('green', formattedType)}(${JSON.stringify(op.value)})`
}
if (op.type === 'insert') {
return `${styleText('green', formattedType)}(${op.position}, ${encodeItemRef(
op.referenceItem,
)}, ${JSON.stringify(op.items)})`
}
if (op.type === 'replace') {
return `${styleText('yellow', formattedType)}(${encodeItemRef(op.referenceItem)}, ${JSON.stringify(
op.items,
)})`
}
if (op.type === 'truncate') {
return `${styleText('red', formattedType)}(${op.startIndex}, ${op.endIndex})`
}

throw new Error(`Invalid operation type: ${(op as {type: string}).type}`)
}

function indent(subject: string, size = 2): string {
const padding = ' '.repeat(size)

return subject
.split('\n')
.map((line) => padding + line)
.join('\n')
}
Loading
Loading