Skip to content

Commit 174a616

Browse files
authoredMar 4, 2024
feat(cli): --quickstart flag for ejecting server schemas (#5797)
* feat: builder flag * feat: rename function * feat: rename functions * feat: undo bad change * feat: rename flag * feat: add helper * feat: edit puntuation * feat: remove flag help, add doc block * feat: replace enum with a dynamic import * feat: added --config flag * feat: minor fixes * feat: cleanup duplicate line * feat: remote redundant await * feat: telemetry * fix: types path * chore: lint * feat: adjustments for new api * feat: fix missing dataset * fix: no use var before declaration * chore: remove test data * feat: add prettier as dep * chore: pnpm i * feat: pr fixes + consume signed url * chore: remove console error * feat: update comment * feat: more explicit trace log * feat: preview support * chore: update deps * fix: typo * feat: rename func * feat: account for root * feat: cleanup serializer * feat: cleanup serializer * feat: changes post review * feat: slide sort
1 parent 75ac3cf commit 174a616

File tree

8 files changed

+414
-30
lines changed

8 files changed

+414
-30
lines changed
 

‎packages/@sanity/cli/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@
122122
"p-filter": "^2.1.0",
123123
"p-timeout": "^4.0.0",
124124
"preferred-pm": "^3.0.3",
125+
"prettier": "^3.1.0",
125126
"promise-props-recursive": "^2.0.2",
126127
"recast": "^0.22.0",
127128
"resolve-from": "^5.0.0",

‎packages/@sanity/cli/src/__telemetry__/init.telemetry.ts

+9
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ interface LoginStep {
1010
alreadyLoggedIn?: boolean
1111
}
1212

13+
interface FetchJourneyConfigStep {
14+
step: 'fetchJourneyConfig'
15+
projectId: string
16+
datasetName: string
17+
displayName: string
18+
isFirstProject: boolean
19+
}
20+
1321
interface CreateOrSelectProjectStep {
1422
step: 'createOrSelectProject'
1523
projectId: string
@@ -68,6 +76,7 @@ interface SelectPackageManagerStep {
6876
type InitStepResult =
6977
| StartStep
7078
| LoginStep
79+
| FetchJourneyConfigStep
7180
| CreateOrSelectProjectStep
7281
| CreateOrSelectDatasetStep
7382
| UseDetectedFrameworkStep

‎packages/@sanity/cli/src/actions/init-project/bootstrapTemplate.ts

+23-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {debug} from '../../debug'
66
import {studioDependencies} from '../../studioDependencies'
77
import {type CliCommandContext} from '../../types'
88
import {copy} from '../../util/copy'
9+
import {getAndWriteJourneySchemaWorker} from '../../util/journeyConfig'
910
import {resolveLatestVersions} from '../../util/resolveLatestVersions'
1011
import {createCliConfig} from './createCliConfig'
1112
import {createPackageManifest} from './createPackageManifest'
@@ -16,6 +17,11 @@ import templates from './templates'
1617
export interface BootstrapOptions {
1718
packageName: string
1819
templateName: string
20+
/**
21+
* Used for initializing a project from a server schema that is saved in the Journey API
22+
* @beta
23+
*/
24+
schemaUrl?: string
1925
outputPath: string
2026
useTypeScript: boolean
2127
variables: GenerateConfigOptions['variables']
@@ -40,7 +46,12 @@ export async function bootstrapTemplate(
4046

4147
// Copy template files
4248
debug('Copying files from template "%s" to "%s"', templateName, outputPath)
43-
let spinner = output.spinner('Bootstrapping files from template').start()
49+
let spinner = output
50+
.spinner(
51+
opts.schemaUrl ? 'Extracting your Sanity configuration' : 'Bootstrapping files from template',
52+
)
53+
.start()
54+
4455
await copy(sourceDir, outputPath, {
4556
rename: useTypeScript ? toTypeScriptPath : undefined,
4657
})
@@ -50,6 +61,17 @@ export async function bootstrapTemplate(
5061
await fs.copyFile(path.join(sharedDir, 'tsconfig.json'), path.join(outputPath, 'tsconfig.json'))
5162
}
5263

64+
// If we have a schemaUrl, get the schema and write it to disk
65+
// At this point the selected template should already have been forced to "clean"
66+
if (opts.schemaUrl) {
67+
debug('Fetching and writing remote schema "%s"', opts.schemaUrl)
68+
await getAndWriteJourneySchemaWorker({
69+
schemasPath: path.join(outputPath, 'schemaTypes'),
70+
useTypeScript,
71+
schemaUrl: opts.schemaUrl,
72+
})
73+
}
74+
5375
spinner.succeed()
5476

5577
// Merge global and template-specific plugins and dependencies

‎packages/@sanity/cli/src/actions/init-project/initProject.ts

+85-29
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {getProjectDefaults, type ProjectDefaults} from '../../util/getProjectDef
3838
import {getUserConfig} from '../../util/getUserConfig'
3939
import {isCommandGroup} from '../../util/isCommandGroup'
4040
import {isInteractive} from '../../util/isInteractive'
41+
import {fetchJourneyConfig} from '../../util/journeyConfig'
4142
import {login, type LoginFlags} from '../login/login'
4243
import {createProject} from '../project/createProject'
4344
import {type BootstrapOptions, bootstrapTemplate} from './bootstrapTemplate'
@@ -66,8 +67,17 @@ import {
6667
// eslint-disable-next-line no-process-env
6768
const isCI = process.env.CI
6869

70+
/**
71+
* @deprecated - No longer used
72+
*/
6973
export interface InitOptions {
7074
template: string
75+
// /**
76+
// * Used for initializing a project from a server schema that is saved in the Journey API
77+
// * This will override the `template` option.
78+
// * @beta
79+
// */
80+
// journeyProjectId?: string
7181
outputDir: string
7282
name: string
7383
displayName: string
@@ -235,7 +245,11 @@ export default async function initSanity(
235245
}
236246

237247
const usingBareOrEnv = cliFlags.bare || cliFlags.env
238-
print(`You're setting up a new project!`)
248+
print(
249+
cliFlags.quickstart
250+
? "You're ejecting a remote Sanity project!"
251+
: `You're setting up a new project!`,
252+
)
239253
print(`We'll make sure you have an account with Sanity.io. ${usingBareOrEnv ? '' : `Then we'll`}`)
240254
if (!usingBareOrEnv) {
241255
print('install an open-source JS content editor that connects to')
@@ -260,39 +274,13 @@ export default async function initSanity(
260274
}
261275

262276
const flags = await prepareFlags()
263-
264277
// We're authenticated, now lets select or create a project
265-
debug('Prompting user to select or create a project')
266-
const {
267-
projectId,
268-
displayName,
269-
isFirstProject,
270-
userAction: getOrCreateUserAction,
271-
} = await getOrCreateProject()
272-
trace.log({step: 'createOrSelectProject', projectId, selectedOption: getOrCreateUserAction})
278+
const {projectId, displayName, isFirstProject, datasetName, schemaUrl} = await getProjectDetails()
279+
273280
const sluggedName = deburr(displayName.toLowerCase())
274281
.replace(/\s+/g, '-')
275282
.replace(/[^a-z0-9-]/g, '')
276283

277-
debug(`Project with name ${displayName} selected`)
278-
279-
// Now let's pick or create a dataset
280-
debug('Prompting user to select or create a dataset')
281-
const {datasetName, userAction: getOrCreateDatasetUserAction} = await getOrCreateDataset({
282-
projectId,
283-
displayName,
284-
dataset: flags.dataset,
285-
aclMode: flags.visibility,
286-
defaultConfig: flags['dataset-default'],
287-
})
288-
trace.log({
289-
step: 'createOrSelectDataset',
290-
selectedOption: getOrCreateDatasetUserAction,
291-
datasetName,
292-
visibility: flags.visibility as 'private' | 'public',
293-
})
294-
debug(`Dataset with name ${datasetName} selected`)
295-
296284
// If user doesn't want to output any template code
297285
if (bareOutput) {
298286
print(`\n${chalk.green('Success!')} Below are your project details:\n`)
@@ -542,6 +530,7 @@ export default async function initSanity(
542530
outputPath,
543531
packageName: sluggedName,
544532
templateName,
533+
schemaUrl,
545534
useTypeScript,
546535
variables: {
547536
dataset: datasetName,
@@ -656,6 +645,57 @@ export default async function initSanity(
656645
print('datasets and collaborators safe and snug.')
657646
}
658647

648+
async function getProjectDetails(): Promise<{
649+
projectId: string
650+
datasetName: string
651+
displayName: string
652+
isFirstProject: boolean
653+
schemaUrl?: string
654+
}> {
655+
// If we're doing a quickstart, we don't need to prompt for project details
656+
if (flags.quickstart) {
657+
debug('Fetching project details from Journey API')
658+
const data = await fetchJourneyConfig(apiClient, flags.quickstart)
659+
trace.log({
660+
step: 'fetchJourneyConfig',
661+
projectId: data.projectId,
662+
datasetName: data.datasetName,
663+
displayName: data.displayName,
664+
isFirstProject: data.isFirstProject,
665+
})
666+
return data
667+
}
668+
669+
debug('Prompting user to select or create a project')
670+
const project = await getOrCreateProject()
671+
debug(`Project with name ${project.displayName} selected`)
672+
673+
// Now let's pick or create a dataset
674+
debug('Prompting user to select or create a dataset')
675+
const dataset = await getOrCreateDataset({
676+
projectId: project.projectId,
677+
displayName: project.displayName,
678+
dataset: flags.dataset,
679+
aclMode: flags.visibility,
680+
defaultConfig: flags['dataset-default'],
681+
})
682+
debug(`Dataset with name ${dataset.datasetName} selected`)
683+
684+
trace.log({
685+
step: 'createOrSelectDataset',
686+
selectedOption: dataset.userAction,
687+
datasetName: dataset.datasetName,
688+
visibility: flags.visibility as 'private' | 'public',
689+
})
690+
691+
return {
692+
projectId: project.projectId,
693+
displayName: project.displayName,
694+
isFirstProject: project.isFirstProject,
695+
datasetName: dataset.datasetName,
696+
}
697+
}
698+
659699
// eslint-disable-next-line complexity
660700
async function getOrCreateProject(): Promise<{
661701
projectId: string
@@ -923,6 +963,12 @@ export default async function initSanity(
923963
}
924964

925965
function selectProjectTemplate() {
966+
// Make sure the --quickstart and --template are not used together
967+
// Force template to clean if --quickstart is used
968+
if (flags.quickstart) {
969+
return 'clean'
970+
}
971+
926972
const defaultTemplate = unattended || flags.template ? flags.template || 'clean' : null
927973
if (defaultTemplate) {
928974
return defaultTemplate
@@ -996,6 +1042,16 @@ export default async function initSanity(
9961042
)
9971043
}
9981044

1045+
if (
1046+
cliFlags.quickstart &&
1047+
(cliFlags.project || cliFlags.dataset || cliFlags.visibility || cliFlags.template)
1048+
) {
1049+
const disallowed = ['project', 'dataset', 'visibility', 'template']
1050+
const usedDisallowed = disallowed.filter((flag) => cliFlags[flag as keyof InitFlags])
1051+
const usedDisallowedStr = usedDisallowed.map((flag) => `--${flag}`).join(', ')
1052+
throw new Error(`\`--quickstart\` cannot be combined with ${usedDisallowedStr}`)
1053+
}
1054+
9991055
if (createProjectName === true) {
10001056
throw new Error('Please specify a project name (`--create-project <name>`)')
10011057
}

‎packages/@sanity/cli/src/commands/init/initCommand.ts

+10
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,18 @@ export interface InitFlags {
5252
project?: string
5353
dataset?: string
5454
template?: string
55+
5556
visibility?: string
5657
typescript?: boolean
58+
/**
59+
* Used for initializing a project from a server schema that is saved in the Journey API
60+
* Overrides `project` option.
61+
* Overrides `dataset` option.
62+
* Overrides `template` option.
63+
* Overrides `visibility` option.
64+
* @beta
65+
*/
66+
quickstart?: string
5767
bare?: boolean
5868
env?: boolean | string
5969
git?: boolean | string
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import fs from 'fs/promises'
2+
import path from 'path'
3+
import {format} from 'prettier'
4+
import {Worker} from 'worker_threads'
5+
6+
import {
7+
type BaseSchemaDefinition,
8+
type DocumentDefinition,
9+
type ObjectDefinition,
10+
} from '../../../types'
11+
import {type CliApiClient} from '../types'
12+
import {getCliWorkerPath} from './cliWorker'
13+
14+
/**
15+
* A Journey schema is a server schema that is saved in the Journey API
16+
*/
17+
18+
interface JourneySchemaWorkerData {
19+
schemasPath: string
20+
useTypeScript: boolean
21+
schemaUrl: string
22+
}
23+
24+
type JourneySchemaWorkerResult = {type: 'success'} | {type: 'error'; error: Error}
25+
26+
interface JourneyConfigResponse {
27+
projectId: string
28+
datasetName: string
29+
displayName: string
30+
schemaUrl: string
31+
isFirstProject: boolean // Always true for now, making it compatible with the existing getOrCreateProject
32+
}
33+
34+
type DocumentOrObject = DocumentDefinition | ObjectDefinition
35+
type SchemaObject = BaseSchemaDefinition & {
36+
type: string
37+
fields?: SchemaObject[]
38+
of?: SchemaObject[]
39+
preview?: object
40+
}
41+
42+
/**
43+
* Fetch a Journey schema from the Sanity schema club API and write it to disk
44+
*/
45+
export async function getAndWriteJourneySchema(data: JourneySchemaWorkerData): Promise<void> {
46+
const {schemasPath, useTypeScript, schemaUrl} = data
47+
try {
48+
const documentTypes = await fetchJourneySchema(schemaUrl)
49+
const fileExtension = useTypeScript ? 'ts' : 'js'
50+
51+
// Write a file for each schema
52+
for (const documentType of documentTypes) {
53+
const filePath = path.join(schemasPath, `${documentType.name}.${fileExtension}`)
54+
await fs.writeFile(filePath, await assembleJourneySchemaTypeFileContent(documentType))
55+
}
56+
// Write an index file that exports all the schemas
57+
const indexContent = await assembleJourneyIndexContent(documentTypes)
58+
await fs.writeFile(path.join(schemasPath, `index.${fileExtension}`), indexContent)
59+
} catch (error) {
60+
throw new Error(`Failed to fetch remote schema: ${error.message}`)
61+
}
62+
}
63+
64+
/**
65+
* Executes the `getAndWriteJourneySchema` operation within a worker thread.
66+
*
67+
* This method is designed to safely import network resources by leveraging the `--experimental-network-imports` flag.
68+
* Due to the experimental nature of this flag, its use is not recommended in the main process. Consequently,
69+
* the task is delegated to a worker thread to ensure both safety and compliance with best practices.
70+
*
71+
* The core functionality involves fetching schema definitions from our own trusted API and writing them to disk.
72+
* This includes handling both predefined and custom schemas. For custom schemas, a process ensures
73+
* that they undergo JSON parsing to remove any JavaScript code and are validated before being saved.
74+
*
75+
* Depending on the configuration, the schemas are saved as either TypeScript or JavaScript files, dictated by the `useTypeScript` flag within the `workerData`.
76+
*
77+
* @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.
78+
* @returns A promise that resolves upon successful execution of the schema fetching and writing process or rejects if an error occurs during the operation.
79+
*/
80+
export async function getAndWriteJourneySchemaWorker(
81+
workerData: JourneySchemaWorkerData,
82+
): Promise<void> {
83+
const workerPath = await getCliWorkerPath('getAndWriteJourneySchema')
84+
return new Promise((resolve, reject) => {
85+
const worker = new Worker(workerPath, {
86+
workerData,
87+
env: {
88+
// eslint-disable-next-line no-process-env
89+
...process.env,
90+
// Dynamic HTTPS imports are currently behind a Node flag
91+
NODE_OPTIONS: '--experimental-network-imports',
92+
NODE_NO_WARNINGS: '1',
93+
},
94+
})
95+
worker.on('message', (message: JourneySchemaWorkerResult) => {
96+
if (message.type === 'success') {
97+
resolve()
98+
} else {
99+
message.error.message = `Import schema worker failed: ${message.error.message}`
100+
reject(message.error)
101+
}
102+
})
103+
worker.on('error', (error) => {
104+
error.message = `Import schema worker failed: ${error.message}`
105+
reject(error)
106+
})
107+
worker.on('exit', (code) => {
108+
if (code !== 0) {
109+
reject(new Error(`Worker stopped with exit code ${code}`))
110+
}
111+
})
112+
})
113+
}
114+
115+
/**
116+
* Fetch a Journey config from the Sanity schema club API
117+
*
118+
* @param projectId - The slug of the Journey schema to fetch
119+
* @returns The Journey schema as an array of Sanity document or object definitions
120+
*/
121+
export async function fetchJourneyConfig(
122+
apiClient: CliApiClient,
123+
projectId: string,
124+
): Promise<JourneyConfigResponse> {
125+
if (!projectId) {
126+
throw new Error('ProjectId is required')
127+
}
128+
if (!/^[a-zA-Z0-9-]+$/.test(projectId)) {
129+
throw new Error('Invalid projectId')
130+
}
131+
try {
132+
const response: {
133+
projectId: string
134+
dataset: string
135+
displayName?: string
136+
schemaUrl: string
137+
} = await apiClient({
138+
requireUser: true,
139+
requireProject: true,
140+
api: {projectId},
141+
})
142+
.config({apiVersion: 'v2024-02-23'})
143+
.request({
144+
method: 'GET',
145+
uri: `/journey/projects/${projectId}`,
146+
})
147+
148+
return {
149+
projectId: response.projectId,
150+
datasetName: response.dataset,
151+
displayName: response.displayName || 'Sanity Project',
152+
// The endpoint returns a signed URL that can be used to fetch the schema as ESM
153+
schemaUrl: response.schemaUrl,
154+
isFirstProject: true,
155+
}
156+
} catch (err) {
157+
throw new Error(`Failed to fetch remote schema config: ${projectId}`)
158+
}
159+
}
160+
161+
/**
162+
* Fetch a Journey schema from the Sanity schema club API
163+
*
164+
* @param projectId - The slug of the Journey schema to fetch
165+
* @returns The Journey schema as an array of Sanity document or object definitions
166+
*/
167+
async function fetchJourneySchema(schemaUrl: string): Promise<DocumentOrObject[]> {
168+
try {
169+
const response = await import(schemaUrl)
170+
return response.default
171+
} catch (err) {
172+
throw new Error(`Failed to fetch remote schema: ${schemaUrl}`)
173+
}
174+
}
175+
176+
/**
177+
* Assemble a Journey schema type into a module export
178+
* Include the necessary imports and export the schema type as a named export
179+
*
180+
* @param schema - The Journey schema to export
181+
* @returns The Journey schema as a module export
182+
*/
183+
async function assembleJourneySchemaTypeFileContent(schemaType: DocumentOrObject): Promise<string> {
184+
const serialised = wrapSchemaTypeInHelpers(schemaType)
185+
const imports = getImports(serialised)
186+
const prettifiedSchemaType = await format(serialised, {parser: 'typescript'})
187+
// Start file with import, then export the schema type as a named export
188+
return `${imports}\n\nexport const ${schemaType.name} = ${prettifiedSchemaType}\n`
189+
}
190+
191+
/**
192+
* Assemble a list of Journey schema module exports into a single index file
193+
*
194+
* @param schemas - The Journey schemas to assemble into an index file
195+
* @returns The index file as a string
196+
*/
197+
function assembleJourneyIndexContent(schemas: DocumentOrObject[]): Promise<string> {
198+
const sortedSchema = schemas.slice().sort((a, b) => (a.name > b.name ? 1 : -1))
199+
const imports = sortedSchema.map((schema) => `import { ${schema.name} } from './${schema.name}'`)
200+
const exports = sortedSchema.map((schema) => schema.name).join(',')
201+
const fileContents = `${imports.join('\n')}\n\nexport const schemaTypes = [${exports}]`
202+
return format(fileContents, {parser: 'typescript'})
203+
}
204+
205+
/**
206+
* Get the import statements for a schema type
207+
*
208+
* @param schemaType - The schema type to get the imports for
209+
* @returns The import statements for the schema type
210+
*/
211+
function getImports(schemaType: string): string {
212+
const defaultImports = ['defineType', 'defineField']
213+
if (schemaType.includes('defineArrayMember')) {
214+
defaultImports.push('defineArrayMember')
215+
}
216+
return `import { ${defaultImports.join(', ')} } from 'sanity'`
217+
}
218+
219+
/**
220+
* Serialize a singleSanity schema type (signular) into a string.
221+
* Wraps the schema object in the appropriate helper function.
222+
*
223+
* @param schemaType - The schema type to serialize
224+
* @returns The schema type as a string
225+
*/
226+
/**
227+
* Serializes a single Sanity schema type into a string.
228+
* Wraps the schema object in the appropriate helper function.
229+
*
230+
* @param schemaType - The schema type to serialize
231+
* @param root - Whether the schemaType is the root object
232+
* @returns The serialized schema type as a string
233+
*/
234+
export function wrapSchemaTypeInHelpers(schemaType: SchemaObject, root: boolean = true): string {
235+
if (root) {
236+
return generateSchemaDefinition(schemaType, 'defineType')
237+
} else if (schemaType.type === 'array') {
238+
return `${generateSchemaDefinition(schemaType, 'defineField')},`
239+
}
240+
return `${generateSchemaDefinition(schemaType, 'defineField')},`
241+
242+
function generateSchemaDefinition(
243+
object: SchemaObject,
244+
definitionType: 'defineType' | 'defineField',
245+
): string {
246+
const {fields, preview, of, ...otherProperties} = object
247+
248+
const serializedProps = serialize(otherProperties)
249+
const fieldsDef =
250+
fields && `fields: [${fields.map((f) => wrapSchemaTypeInHelpers(f, false)).join('')}],`
251+
const ofDef = of && `of: [${of.map((f) => `defineArrayMember({${serialize(f)}})`).join(',')}],`
252+
const previewDef = preview && `preview: {${serialize(preview)}}`
253+
254+
const combinedDefinitions = [serializedProps, fieldsDef, ofDef, previewDef]
255+
.filter(Boolean)
256+
.join(',')
257+
return `${definitionType}({ ${combinedDefinitions} })`
258+
}
259+
260+
function serialize(obj: object) {
261+
return Object.entries(obj)
262+
.map(([key, value]) => {
263+
if (key === 'prepare') {
264+
return `${value.toString()}`
265+
}
266+
if (typeof value === 'string') {
267+
return `${key}: "${value}"`
268+
}
269+
if (typeof value === 'object') {
270+
return `${key}: ${JSON.stringify(value)}`
271+
}
272+
return `${key}: ${value}`
273+
})
274+
.join(',')
275+
}
276+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import {parentPort, workerData} from 'worker_threads'
2+
3+
import {getAndWriteJourneySchema} from '../util/journeyConfig'
4+
5+
getAndWriteJourneySchema(workerData)
6+
.then(() => parentPort?.postMessage({type: 'success'}))
7+
.catch((error) => parentPort?.postMessage({type: 'error', error}))

‎pnpm-lock.yaml

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.