diff --git a/packages/@sanity/cli/package.json b/packages/@sanity/cli/package.json index b4ea60ba194..89a6eb0e538 100644 --- a/packages/@sanity/cli/package.json +++ b/packages/@sanity/cli/package.json @@ -122,6 +122,7 @@ "p-filter": "^2.1.0", "p-timeout": "^4.0.0", "preferred-pm": "^3.0.3", + "prettier": "^3.1.0", "promise-props-recursive": "^2.0.2", "recast": "^0.22.0", "resolve-from": "^5.0.0", diff --git a/packages/@sanity/cli/src/__telemetry__/init.telemetry.ts b/packages/@sanity/cli/src/__telemetry__/init.telemetry.ts index 5055795a8e4..84e1e965496 100644 --- a/packages/@sanity/cli/src/__telemetry__/init.telemetry.ts +++ b/packages/@sanity/cli/src/__telemetry__/init.telemetry.ts @@ -10,6 +10,14 @@ interface LoginStep { alreadyLoggedIn?: boolean } +interface FetchJourneyConfigStep { + step: 'fetchJourneyConfig' + projectId: string + datasetName: string + displayName: string + isFirstProject: boolean +} + interface CreateOrSelectProjectStep { step: 'createOrSelectProject' projectId: string @@ -68,6 +76,7 @@ interface SelectPackageManagerStep { type InitStepResult = | StartStep | LoginStep + | FetchJourneyConfigStep | CreateOrSelectProjectStep | CreateOrSelectDatasetStep | UseDetectedFrameworkStep diff --git a/packages/@sanity/cli/src/actions/init-project/bootstrapTemplate.ts b/packages/@sanity/cli/src/actions/init-project/bootstrapTemplate.ts index a988dda8c84..b556ab5233e 100644 --- a/packages/@sanity/cli/src/actions/init-project/bootstrapTemplate.ts +++ b/packages/@sanity/cli/src/actions/init-project/bootstrapTemplate.ts @@ -6,6 +6,7 @@ import {debug} from '../../debug' import {studioDependencies} from '../../studioDependencies' import {type CliCommandContext} from '../../types' import {copy} from '../../util/copy' +import {getAndWriteJourneySchemaWorker} from '../../util/journeyConfig' import {resolveLatestVersions} from '../../util/resolveLatestVersions' import {createCliConfig} from './createCliConfig' import {createPackageManifest} from './createPackageManifest' @@ -16,6 +17,11 @@ import templates from './templates' export interface BootstrapOptions { packageName: string templateName: string + /** + * Used for initializing a project from a server schema that is saved in the Journey API + * @beta + */ + schemaUrl?: string outputPath: string useTypeScript: boolean variables: GenerateConfigOptions['variables'] @@ -40,7 +46,12 @@ export async function bootstrapTemplate( // Copy template files debug('Copying files from template "%s" to "%s"', templateName, outputPath) - let spinner = output.spinner('Bootstrapping files from template').start() + let spinner = output + .spinner( + opts.schemaUrl ? 'Extracting your Sanity configuration' : 'Bootstrapping files from template', + ) + .start() + await copy(sourceDir, outputPath, { rename: useTypeScript ? toTypeScriptPath : undefined, }) @@ -50,6 +61,17 @@ export async function bootstrapTemplate( await fs.copyFile(path.join(sharedDir, 'tsconfig.json'), path.join(outputPath, 'tsconfig.json')) } + // If we have a schemaUrl, get the schema and write it to disk + // At this point the selected template should already have been forced to "clean" + if (opts.schemaUrl) { + debug('Fetching and writing remote schema "%s"', opts.schemaUrl) + await getAndWriteJourneySchemaWorker({ + schemasPath: path.join(outputPath, 'schemaTypes'), + useTypeScript, + schemaUrl: opts.schemaUrl, + }) + } + spinner.succeed() // Merge global and template-specific plugins and dependencies diff --git a/packages/@sanity/cli/src/actions/init-project/initProject.ts b/packages/@sanity/cli/src/actions/init-project/initProject.ts index 25c80351e0c..0b4c0bbf3d4 100644 --- a/packages/@sanity/cli/src/actions/init-project/initProject.ts +++ b/packages/@sanity/cli/src/actions/init-project/initProject.ts @@ -38,6 +38,7 @@ import {getProjectDefaults, type ProjectDefaults} from '../../util/getProjectDef import {getUserConfig} from '../../util/getUserConfig' import {isCommandGroup} from '../../util/isCommandGroup' import {isInteractive} from '../../util/isInteractive' +import {fetchJourneyConfig} from '../../util/journeyConfig' import {login, type LoginFlags} from '../login/login' import {createProject} from '../project/createProject' import {type BootstrapOptions, bootstrapTemplate} from './bootstrapTemplate' @@ -66,8 +67,17 @@ import { // eslint-disable-next-line no-process-env const isCI = process.env.CI +/** + * @deprecated - No longer used + */ export interface InitOptions { template: string + // /** + // * Used for initializing a project from a server schema that is saved in the Journey API + // * This will override the `template` option. + // * @beta + // */ + // journeyProjectId?: string outputDir: string name: string displayName: string @@ -235,7 +245,11 @@ export default async function initSanity( } const usingBareOrEnv = cliFlags.bare || cliFlags.env - print(`You're setting up a new project!`) + print( + cliFlags.quickstart + ? "You're ejecting a remote Sanity project!" + : `You're setting up a new project!`, + ) print(`We'll make sure you have an account with Sanity.io. ${usingBareOrEnv ? '' : `Then we'll`}`) if (!usingBareOrEnv) { print('install an open-source JS content editor that connects to') @@ -260,39 +274,13 @@ export default async function initSanity( } const flags = await prepareFlags() - // We're authenticated, now lets select or create a project - debug('Prompting user to select or create a project') - const { - projectId, - displayName, - isFirstProject, - userAction: getOrCreateUserAction, - } = await getOrCreateProject() - trace.log({step: 'createOrSelectProject', projectId, selectedOption: getOrCreateUserAction}) + const {projectId, displayName, isFirstProject, datasetName, schemaUrl} = await getProjectDetails() + const sluggedName = deburr(displayName.toLowerCase()) .replace(/\s+/g, '-') .replace(/[^a-z0-9-]/g, '') - debug(`Project with name ${displayName} selected`) - - // Now let's pick or create a dataset - debug('Prompting user to select or create a dataset') - const {datasetName, userAction: getOrCreateDatasetUserAction} = await getOrCreateDataset({ - projectId, - displayName, - dataset: flags.dataset, - aclMode: flags.visibility, - defaultConfig: flags['dataset-default'], - }) - trace.log({ - step: 'createOrSelectDataset', - selectedOption: getOrCreateDatasetUserAction, - datasetName, - visibility: flags.visibility as 'private' | 'public', - }) - debug(`Dataset with name ${datasetName} selected`) - // If user doesn't want to output any template code if (bareOutput) { print(`\n${chalk.green('Success!')} Below are your project details:\n`) @@ -542,6 +530,7 @@ export default async function initSanity( outputPath, packageName: sluggedName, templateName, + schemaUrl, useTypeScript, variables: { dataset: datasetName, @@ -656,6 +645,57 @@ export default async function initSanity( print('datasets and collaborators safe and snug.') } + async function getProjectDetails(): Promise<{ + projectId: string + datasetName: string + displayName: string + isFirstProject: boolean + schemaUrl?: string + }> { + // If we're doing a quickstart, we don't need to prompt for project details + if (flags.quickstart) { + debug('Fetching project details from Journey API') + const data = await fetchJourneyConfig(apiClient, flags.quickstart) + trace.log({ + step: 'fetchJourneyConfig', + projectId: data.projectId, + datasetName: data.datasetName, + displayName: data.displayName, + isFirstProject: data.isFirstProject, + }) + return data + } + + debug('Prompting user to select or create a project') + const project = await getOrCreateProject() + debug(`Project with name ${project.displayName} selected`) + + // Now let's pick or create a dataset + debug('Prompting user to select or create a dataset') + const dataset = await getOrCreateDataset({ + projectId: project.projectId, + displayName: project.displayName, + dataset: flags.dataset, + aclMode: flags.visibility, + defaultConfig: flags['dataset-default'], + }) + debug(`Dataset with name ${dataset.datasetName} selected`) + + trace.log({ + step: 'createOrSelectDataset', + selectedOption: dataset.userAction, + datasetName: dataset.datasetName, + visibility: flags.visibility as 'private' | 'public', + }) + + return { + projectId: project.projectId, + displayName: project.displayName, + isFirstProject: project.isFirstProject, + datasetName: dataset.datasetName, + } + } + // eslint-disable-next-line complexity async function getOrCreateProject(): Promise<{ projectId: string @@ -923,6 +963,12 @@ export default async function initSanity( } function selectProjectTemplate() { + // Make sure the --quickstart and --template are not used together + // Force template to clean if --quickstart is used + if (flags.quickstart) { + return 'clean' + } + const defaultTemplate = unattended || flags.template ? flags.template || 'clean' : null if (defaultTemplate) { return defaultTemplate @@ -996,6 +1042,16 @@ export default async function initSanity( ) } + if ( + cliFlags.quickstart && + (cliFlags.project || cliFlags.dataset || cliFlags.visibility || cliFlags.template) + ) { + const disallowed = ['project', 'dataset', 'visibility', 'template'] + const usedDisallowed = disallowed.filter((flag) => cliFlags[flag as keyof InitFlags]) + const usedDisallowedStr = usedDisallowed.map((flag) => `--${flag}`).join(', ') + throw new Error(`\`--quickstart\` cannot be combined with ${usedDisallowedStr}`) + } + if (createProjectName === true) { throw new Error('Please specify a project name (`--create-project `)') } diff --git a/packages/@sanity/cli/src/commands/init/initCommand.ts b/packages/@sanity/cli/src/commands/init/initCommand.ts index 17d0fa11eec..b27678680f9 100644 --- a/packages/@sanity/cli/src/commands/init/initCommand.ts +++ b/packages/@sanity/cli/src/commands/init/initCommand.ts @@ -52,8 +52,18 @@ export interface InitFlags { project?: string dataset?: string template?: string + visibility?: string typescript?: boolean + /** + * Used for initializing a project from a server schema that is saved in the Journey API + * Overrides `project` option. + * Overrides `dataset` option. + * Overrides `template` option. + * Overrides `visibility` option. + * @beta + */ + quickstart?: string bare?: boolean env?: boolean | string git?: boolean | string diff --git a/packages/@sanity/cli/src/util/journeyConfig.ts b/packages/@sanity/cli/src/util/journeyConfig.ts new file mode 100644 index 00000000000..a4f2c43600e --- /dev/null +++ b/packages/@sanity/cli/src/util/journeyConfig.ts @@ -0,0 +1,276 @@ +import fs from 'fs/promises' +import path from 'path' +import {format} from 'prettier' +import {Worker} from 'worker_threads' + +import { + type BaseSchemaDefinition, + type DocumentDefinition, + type ObjectDefinition, +} from '../../../types' +import {type CliApiClient} from '../types' +import {getCliWorkerPath} from './cliWorker' + +/** + * A Journey schema is a server schema that is saved in the Journey API + */ + +interface JourneySchemaWorkerData { + schemasPath: string + useTypeScript: boolean + schemaUrl: string +} + +type JourneySchemaWorkerResult = {type: 'success'} | {type: 'error'; error: Error} + +interface JourneyConfigResponse { + projectId: string + datasetName: string + displayName: string + schemaUrl: string + isFirstProject: boolean // Always true for now, making it compatible with the existing getOrCreateProject +} + +type DocumentOrObject = DocumentDefinition | ObjectDefinition +type SchemaObject = BaseSchemaDefinition & { + type: string + fields?: SchemaObject[] + of?: SchemaObject[] + preview?: object +} + +/** + * Fetch a Journey schema from the Sanity schema club API and write it to disk + */ +export async function getAndWriteJourneySchema(data: JourneySchemaWorkerData): Promise { + const {schemasPath, useTypeScript, schemaUrl} = data + try { + const documentTypes = await fetchJourneySchema(schemaUrl) + const fileExtension = useTypeScript ? 'ts' : 'js' + + // Write a file for each schema + for (const documentType of documentTypes) { + const filePath = path.join(schemasPath, `${documentType.name}.${fileExtension}`) + await fs.writeFile(filePath, await assembleJourneySchemaTypeFileContent(documentType)) + } + // Write an index file that exports all the schemas + const indexContent = await assembleJourneyIndexContent(documentTypes) + await fs.writeFile(path.join(schemasPath, `index.${fileExtension}`), indexContent) + } catch (error) { + throw new Error(`Failed to fetch remote schema: ${error.message}`) + } +} + +/** + * Executes the `getAndWriteJourneySchema` operation within a worker thread. + * + * This method is designed to safely import network resources by leveraging the `--experimental-network-imports` flag. + * Due to the experimental nature of this flag, its use is not recommended in the main process. Consequently, + * the task is delegated to a worker thread to ensure both safety and compliance with best practices. + * + * The core functionality involves fetching schema definitions from our own trusted API and writing them to disk. + * This includes handling both predefined and custom schemas. For custom schemas, a process ensures + * that they undergo JSON parsing to remove any JavaScript code and are validated before being saved. + * + * Depending on the configuration, the schemas are saved as either TypeScript or JavaScript files, dictated by the `useTypeScript` flag within the `workerData`. + * + * @param workerData - An object containing the necessary data and flags for the worker thread, including the path to save schemas, flags indicating whether to use TypeScript, and any other relevant configuration details. + * @returns A promise that resolves upon successful execution of the schema fetching and writing process or rejects if an error occurs during the operation. + */ +export async function getAndWriteJourneySchemaWorker( + workerData: JourneySchemaWorkerData, +): Promise { + const workerPath = await getCliWorkerPath('getAndWriteJourneySchema') + return new Promise((resolve, reject) => { + const worker = new Worker(workerPath, { + workerData, + env: { + // eslint-disable-next-line no-process-env + ...process.env, + // Dynamic HTTPS imports are currently behind a Node flag + NODE_OPTIONS: '--experimental-network-imports', + NODE_NO_WARNINGS: '1', + }, + }) + worker.on('message', (message: JourneySchemaWorkerResult) => { + if (message.type === 'success') { + resolve() + } else { + message.error.message = `Import schema worker failed: ${message.error.message}` + reject(message.error) + } + }) + worker.on('error', (error) => { + error.message = `Import schema worker failed: ${error.message}` + reject(error) + }) + worker.on('exit', (code) => { + if (code !== 0) { + reject(new Error(`Worker stopped with exit code ${code}`)) + } + }) + }) +} + +/** + * Fetch a Journey config from the Sanity schema club API + * + * @param projectId - The slug of the Journey schema to fetch + * @returns The Journey schema as an array of Sanity document or object definitions + */ +export async function fetchJourneyConfig( + apiClient: CliApiClient, + projectId: string, +): Promise { + if (!projectId) { + throw new Error('ProjectId is required') + } + if (!/^[a-zA-Z0-9-]+$/.test(projectId)) { + throw new Error('Invalid projectId') + } + try { + const response: { + projectId: string + dataset: string + displayName?: string + schemaUrl: string + } = await apiClient({ + requireUser: true, + requireProject: true, + api: {projectId}, + }) + .config({apiVersion: 'v2024-02-23'}) + .request({ + method: 'GET', + uri: `/journey/projects/${projectId}`, + }) + + return { + projectId: response.projectId, + datasetName: response.dataset, + displayName: response.displayName || 'Sanity Project', + // The endpoint returns a signed URL that can be used to fetch the schema as ESM + schemaUrl: response.schemaUrl, + isFirstProject: true, + } + } catch (err) { + throw new Error(`Failed to fetch remote schema config: ${projectId}`) + } +} + +/** + * Fetch a Journey schema from the Sanity schema club API + * + * @param projectId - The slug of the Journey schema to fetch + * @returns The Journey schema as an array of Sanity document or object definitions + */ +async function fetchJourneySchema(schemaUrl: string): Promise { + try { + const response = await import(schemaUrl) + return response.default + } catch (err) { + throw new Error(`Failed to fetch remote schema: ${schemaUrl}`) + } +} + +/** + * Assemble a Journey schema type into a module export + * Include the necessary imports and export the schema type as a named export + * + * @param schema - The Journey schema to export + * @returns The Journey schema as a module export + */ +async function assembleJourneySchemaTypeFileContent(schemaType: DocumentOrObject): Promise { + const serialised = wrapSchemaTypeInHelpers(schemaType) + const imports = getImports(serialised) + const prettifiedSchemaType = await format(serialised, {parser: 'typescript'}) + // Start file with import, then export the schema type as a named export + return `${imports}\n\nexport const ${schemaType.name} = ${prettifiedSchemaType}\n` +} + +/** + * Assemble a list of Journey schema module exports into a single index file + * + * @param schemas - The Journey schemas to assemble into an index file + * @returns The index file as a string + */ +function assembleJourneyIndexContent(schemas: DocumentOrObject[]): Promise { + const sortedSchema = schemas.slice().sort((a, b) => (a.name > b.name ? 1 : -1)) + const imports = sortedSchema.map((schema) => `import { ${schema.name} } from './${schema.name}'`) + const exports = sortedSchema.map((schema) => schema.name).join(',') + const fileContents = `${imports.join('\n')}\n\nexport const schemaTypes = [${exports}]` + return format(fileContents, {parser: 'typescript'}) +} + +/** + * Get the import statements for a schema type + * + * @param schemaType - The schema type to get the imports for + * @returns The import statements for the schema type + */ +function getImports(schemaType: string): string { + const defaultImports = ['defineType', 'defineField'] + if (schemaType.includes('defineArrayMember')) { + defaultImports.push('defineArrayMember') + } + return `import { ${defaultImports.join(', ')} } from 'sanity'` +} + +/** + * Serialize a singleSanity schema type (signular) into a string. + * Wraps the schema object in the appropriate helper function. + * + * @param schemaType - The schema type to serialize + * @returns The schema type as a string + */ +/** + * Serializes a single Sanity schema type into a string. + * Wraps the schema object in the appropriate helper function. + * + * @param schemaType - The schema type to serialize + * @param root - Whether the schemaType is the root object + * @returns The serialized schema type as a string + */ +export function wrapSchemaTypeInHelpers(schemaType: SchemaObject, root: boolean = true): string { + if (root) { + return generateSchemaDefinition(schemaType, 'defineType') + } else if (schemaType.type === 'array') { + return `${generateSchemaDefinition(schemaType, 'defineField')},` + } + return `${generateSchemaDefinition(schemaType, 'defineField')},` + + function generateSchemaDefinition( + object: SchemaObject, + definitionType: 'defineType' | 'defineField', + ): string { + const {fields, preview, of, ...otherProperties} = object + + const serializedProps = serialize(otherProperties) + const fieldsDef = + fields && `fields: [${fields.map((f) => wrapSchemaTypeInHelpers(f, false)).join('')}],` + const ofDef = of && `of: [${of.map((f) => `defineArrayMember({${serialize(f)}})`).join(',')}],` + const previewDef = preview && `preview: {${serialize(preview)}}` + + const combinedDefinitions = [serializedProps, fieldsDef, ofDef, previewDef] + .filter(Boolean) + .join(',') + return `${definitionType}({ ${combinedDefinitions} })` + } + + function serialize(obj: object) { + return Object.entries(obj) + .map(([key, value]) => { + if (key === 'prepare') { + return `${value.toString()}` + } + if (typeof value === 'string') { + return `${key}: "${value}"` + } + if (typeof value === 'object') { + return `${key}: ${JSON.stringify(value)}` + } + return `${key}: ${value}` + }) + .join(',') + } +} diff --git a/packages/@sanity/cli/src/workers/getAndWriteJourneySchema.ts b/packages/@sanity/cli/src/workers/getAndWriteJourneySchema.ts new file mode 100644 index 00000000000..e060d7c5593 --- /dev/null +++ b/packages/@sanity/cli/src/workers/getAndWriteJourneySchema.ts @@ -0,0 +1,7 @@ +import {parentPort, workerData} from 'worker_threads' + +import {getAndWriteJourneySchema} from '../util/journeyConfig' + +getAndWriteJourneySchema(workerData) + .then(() => parentPort?.postMessage({type: 'success'})) + .catch((error) => parentPort?.postMessage({type: 'error', error})) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4b4882a612..eefa69fb547 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -803,6 +803,9 @@ importers: preferred-pm: specifier: ^3.0.3 version: 3.1.2 + prettier: + specifier: ^3.1.0 + version: 3.2.5 promise-props-recursive: specifier: ^2.0.2 version: 2.0.2