@sanity/cli 3.67.2-corel.454 → 3.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/cli",
3
- "version": "3.67.2-corel.454+ea86580c76",
3
+ "version": "3.68.0",
4
4
  "description": "Sanity CLI tool for managing Sanity installations, managing plugins, schemas and datasets",
5
5
  "keywords": [
6
6
  "sanity",
@@ -58,9 +58,10 @@
58
58
  "dependencies": {
59
59
  "@babel/traverse": "^7.23.5",
60
60
  "@sanity/client": "^6.24.1",
61
- "@sanity/codegen": "3.67.2-corel.454+ea86580c76",
61
+ "@sanity/codegen": "3.68.0",
62
62
  "@sanity/telemetry": "^0.7.7",
63
- "@sanity/util": "3.67.2-corel.454+ea86580c76",
63
+ "@sanity/template-validator": "^1.0.2",
64
+ "@sanity/util": "3.68.0",
64
65
  "chalk": "^4.1.2",
65
66
  "debug": "^4.3.4",
66
67
  "decompress": "^4.2.0",
@@ -72,17 +73,16 @@
72
73
  "prettier": "^3.3.0",
73
74
  "semver": "^7.3.5",
74
75
  "silver-fleece": "1.1.0",
75
- "validate-npm-package-name": "^3.0.0",
76
- "yaml": "^2.6.1"
76
+ "validate-npm-package-name": "^3.0.0"
77
77
  },
78
78
  "devDependencies": {
79
- "@repo/package.config": "3.67.1",
80
- "@repo/test-config": "3.67.1",
79
+ "@repo/package.config": "3.68.0",
80
+ "@repo/test-config": "3.68.0",
81
81
  "@rexxars/gitconfiglocal": "^3.0.1",
82
82
  "@rollup/plugin-node-resolve": "^15.2.3",
83
83
  "@sanity/eslint-config-studio": "^4.0.0",
84
84
  "@sanity/generate-help-url": "^3.0.0",
85
- "@sanity/types": "3.67.2-corel.454+ea86580c76",
85
+ "@sanity/types": "3.68.0",
86
86
  "@types/babel__traverse": "^7.20.5",
87
87
  "@types/configstore": "^5.0.1",
88
88
  "@types/cpx": "^1.5.2",
@@ -127,12 +127,12 @@
127
127
  "semver-compare": "^1.0.0",
128
128
  "tar": "^6.1.11",
129
129
  "vite": "^5.4.11",
130
- "vitest": "^2.1.1",
130
+ "vitest": "^2.1.8",
131
131
  "which": "^2.0.2",
132
132
  "xdg-basedir": "^4.0.0"
133
133
  },
134
134
  "engines": {
135
135
  "node": ">=18"
136
136
  },
137
- "gitHead": "ea86580c76aa4b5f2fefda043afed3168c6a7d82"
137
+ "gitHead": "121e7bb9b6239364c1fd0745c683d18dfa29cfff"
138
138
  }
@@ -6,13 +6,15 @@ import {detectFrameworkRecord, LocalFileSystemDetector} from '@vercel/fs-detecto
6
6
 
7
7
  import {debug} from '../../debug'
8
8
  import {type CliCommandContext} from '../../types'
9
+ import {getDefaultPortForFramework} from '../../util/frameworkPort'
9
10
  import {
10
11
  applyEnvVariables,
11
12
  checkNeedsReadToken,
12
13
  downloadAndExtractRepo,
13
14
  generateSanityApiReadToken,
14
- getMonoRepo,
15
+ getPackages,
15
16
  type RepoInfo,
17
+ setCorsOrigin,
16
18
  tryApplyPackageName,
17
19
  validateRemoteTemplate,
18
20
  } from '../../util/remoteTemplate'
@@ -40,7 +42,7 @@ export async function bootstrapRemoteTemplate(
40
42
  const spinner = output.spinner(`Bootstrapping files from template "${name}"`).start()
41
43
 
42
44
  debug('Validating remote template')
43
- const packages = await getMonoRepo(repoInfo, bearerToken)
45
+ const packages = await getPackages(repoInfo, bearerToken)
44
46
  await validateRemoteTemplate(repoInfo, packages, bearerToken)
45
47
 
46
48
  debug('Create new directory "%s"', outputPath)
@@ -65,6 +67,12 @@ export async function bootstrapRemoteTemplate(
65
67
  fs: new LocalFileSystemDetector(packagePath),
66
68
  frameworkList: frameworks as readonly Framework[],
67
69
  })
70
+ const port = getDefaultPortForFramework(packageFramework?.slug)
71
+
72
+ debug('Setting CORS origin to http://localhost:%d', port)
73
+ await setCorsOrigin(`http://localhost:${port}`, variables.projectId, apiClient)
74
+
75
+ debug('Applying environment variables to %s', pkg)
68
76
  // Next.js uses `.env.local` for local environment variables
69
77
  const envName = packageFramework?.slug === 'nextjs' ? '.env.local' : '.env'
70
78
  await applyEnvVariables(packagePath, {...variables, readToken}, envName)
@@ -13,19 +13,19 @@ export default {
13
13
  console.log(...args)
14
14
  },
15
15
 
16
- success(...args: unknown[]): void {
17
- console.log(`${SYMBOL_CHECK} ${args.join(' ')}`)
16
+ success(firstPartOfMessage: unknown, ...args: unknown[]): void {
17
+ console.log(`${SYMBOL_CHECK} ${firstPartOfMessage}`, ...args)
18
18
  },
19
19
 
20
- warn(...args: unknown[]): void {
21
- console.warn(`${SYMBOL_WARN} ${args.join(' ')}`)
20
+ warn(firstPartOfMessage: unknown, ...args: unknown[]): void {
21
+ console.warn(`${SYMBOL_WARN} ${firstPartOfMessage}`, ...args)
22
22
  },
23
23
 
24
- error(...args: unknown[]): void {
25
- if (args[0] instanceof Error) {
26
- console.error(`${SYMBOL_FAIL} ${chalk.red(args[0].stack)}`)
24
+ error(firstPartOfMessage: unknown, ...args: unknown[]): void {
25
+ if (firstPartOfMessage instanceof Error) {
26
+ console.error(`${SYMBOL_FAIL} ${chalk.red(firstPartOfMessage.stack)}`)
27
27
  } else {
28
- console.error(`${SYMBOL_FAIL} ${args.join(' ')}`)
28
+ console.error(`${SYMBOL_FAIL} ${firstPartOfMessage}`, ...args)
29
29
  }
30
30
  },
31
31
 
@@ -37,7 +37,7 @@ export default {
37
37
  },
38
38
 
39
39
  spinner(options: Options): Ora {
40
- const spinner = ora({...options, spinner: 'dots'})
40
+ const spinner = ora(options)
41
41
  // Override the default status methods to use custom symbols instead of emojis
42
42
  spinner.succeed = (text?: string) => spinner.stopAndPersist({text, symbol: SYMBOL_CHECK})
43
43
  spinner.warn = (text?: string) => spinner.stopAndPersist({text, symbol: SYMBOL_WARN})
@@ -0,0 +1,63 @@
1
+ const FALLBACK_PORT = 3000
2
+
3
+ const portMap: Record<string, number> = {
4
+ 'nextjs': 3000,
5
+ 'blitzjs': 3000,
6
+ 'gatsby': 8000,
7
+ 'remix': 3000,
8
+ 'astro': 3000,
9
+ 'hexo': 4000,
10
+ 'eleventy': 8080,
11
+ 'docusaurus': 3000,
12
+ 'docusaurus-2': 3000,
13
+ 'preact': 8080,
14
+ 'solidstart': 3000,
15
+ 'solidstart-1': 3000,
16
+ 'dojo': 3000,
17
+ 'ember': 4200,
18
+ 'vue': 8080,
19
+ 'scully': 1668,
20
+ 'ionic-angular': 4200,
21
+ 'angular': 4200,
22
+ 'polymer': 8081,
23
+ 'svelte': 5000,
24
+ 'sveltekit': 5173,
25
+ 'sveltekit-1': 5173,
26
+ 'ionic-react': 3000,
27
+ 'create-react-app': 3000,
28
+ 'gridsome': 8080,
29
+ 'umijs': 8000,
30
+ 'saber': 3000,
31
+ 'stencil': 3333,
32
+ 'nuxtjs': 3000,
33
+ 'redwoodjs': 8910,
34
+ 'hugo': 1313,
35
+ 'jekyll': 4000,
36
+ 'brunch': 3333,
37
+ 'middleman': 4567,
38
+ 'zola': 1111,
39
+ 'hydrogen': 3000,
40
+ 'vite': 5173,
41
+ 'vitepress': 5173,
42
+ 'vuepress': 8080,
43
+ 'parcel': 1234,
44
+ 'fasthtml': 8000,
45
+ 'sanity': 3333,
46
+ 'sanity-v3': 3333,
47
+ 'storybook': 6006,
48
+ }
49
+
50
+ /**
51
+ * Returns the default development port for a given framework.
52
+ * Contains default ports for all frameworks supported by `@vercel/frameworks`.
53
+ * Falls back to port 3000 if framework is not found or not specified.
54
+ *
55
+ * @see https://github.com/vercel/vercel/blob/main/packages/frameworks/src/frameworks.ts
56
+ * for the complete list of supported frameworks
57
+ *
58
+ * @param frameworkSlug - The framework identifier from `@vercel/frameworks`
59
+ * @returns The default port number for the framework
60
+ */
61
+ export function getDefaultPortForFramework(frameworkSlug?: string | null): number {
62
+ return portMap[frameworkSlug ?? ''] ?? FALLBACK_PORT
63
+ }
@@ -4,25 +4,27 @@ import {Readable} from 'node:stream'
4
4
  import {pipeline} from 'node:stream/promises'
5
5
  import {type ReadableStream} from 'node:stream/web'
6
6
 
7
+ import {
8
+ ENV_TEMPLATE_FILES,
9
+ getMonoRepo,
10
+ REQUIRED_ENV_VAR,
11
+ validateSanityTemplate,
12
+ } from '@sanity/template-validator'
7
13
  import {x} from 'tar'
8
- import {parse as parseYaml} from 'yaml'
9
14
 
15
+ import {debug} from '../debug'
10
16
  import {type CliApiClient, type PackageJson} from '../types'
11
17
 
18
+ const DISALLOWED_PATHS = [
19
+ // Prevent security risks from unknown GitHub Actions
20
+ '/.github/',
21
+ ]
22
+
12
23
  const ENV_VAR = {
13
- PROJECT_ID: /SANITY(?:_STUDIO)?_PROJECT_ID/, // Matches SANITY_PROJECT_ID and SANITY_STUDIO_PROJECT_ID
14
- DATASET: /SANITY(?:_STUDIO)?_DATASET/, // Matches SANITY_DATASET and SANITY_STUDIO_DATASET
24
+ ...REQUIRED_ENV_VAR,
15
25
  READ_TOKEN: 'SANITY_API_READ_TOKEN',
16
26
  } as const
17
27
 
18
- const ENV_FILE = {
19
- TEMPLATE: '.env.template',
20
- EXAMPLE: '.env.example',
21
- LOCAL_EXAMPLE: '.env.local.example',
22
- } as const
23
-
24
- const ENV_TEMPLATE_FILES = [ENV_FILE.TEMPLATE, ENV_FILE.EXAMPLE, ENV_FILE.LOCAL_EXAMPLE] as const
25
-
26
28
  type EnvData = {
27
29
  projectId: string
28
30
  dataset: string
@@ -40,6 +42,11 @@ export type RepoInfo = {
40
42
  filePath: string
41
43
  }
42
44
 
45
+ function getGitHubRawContentUrl(repoInfo: RepoInfo): string {
46
+ const {username, name, branch, filePath} = repoInfo
47
+ return `https://raw.githubusercontent.com/${username}/${name}/${branch}/${filePath}`
48
+ }
49
+
43
50
  function isGithubRepoShorthand(value: string): boolean {
44
51
  if (URL.canParse(value)) {
45
52
  return false
@@ -180,6 +187,9 @@ export async function downloadAndExtractRepo(
180
187
  const pathSegments = posixPath.split(posix.sep)
181
188
  rootPath = pathSegments.length ? pathSegments[0] : null
182
189
  }
190
+ for (const disallowedPath of DISALLOWED_PATHS) {
191
+ if (posixPath.includes(disallowedPath)) return false
192
+ }
183
193
  return posixPath.startsWith(`${rootPath}${filePath ? `/${filePath}/` : '/'}`)
184
194
  },
185
195
  }),
@@ -191,222 +201,29 @@ export async function downloadAndExtractRepo(
191
201
  * Supports pnpm workspaces, Lerna, Rush, and npm workspaces (package.json).
192
202
  * @returns Promise that resolves to an array of package paths/names if monorepo is detected, undefined otherwise
193
203
  */
194
- export async function getMonoRepo(
204
+ export async function getPackages(
195
205
  repoInfo: RepoInfo,
196
206
  bearerToken?: string,
197
207
  ): Promise<string[] | undefined> {
198
- const {username, name, branch, filePath} = repoInfo
199
- const baseUrl = `https://raw.githubusercontent.com/${username}/${name}/${branch}/${filePath}`
200
-
201
208
  const headers: Record<string, string> = {}
202
209
  if (bearerToken) {
203
210
  headers.Authorization = `Bearer ${bearerToken}`
204
211
  }
205
-
206
- type MonorepoHandler = {
207
- check: (content: string) => string[] | undefined
208
- }
209
-
210
- const handlers: Record<string, MonorepoHandler> = {
211
- 'package.json': {
212
- check: (content) => {
213
- try {
214
- const pkg = JSON.parse(content)
215
- if (!pkg.workspaces) return undefined
216
- return Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages
217
- } catch {
218
- return undefined
219
- }
220
- },
221
- },
222
- 'pnpm-workspace.yaml': {
223
- check: (content) => {
224
- try {
225
- const config = parseYaml(content)
226
- return config.packages
227
- } catch {
228
- return undefined
229
- }
230
- },
231
- },
232
- 'lerna.json': {
233
- check: (content) => {
234
- try {
235
- const config = JSON.parse(content)
236
- return config.packages
237
- } catch {
238
- return undefined
239
- }
240
- },
241
- },
242
- 'rush.json': {
243
- check: (content) => {
244
- try {
245
- const config = JSON.parse(content)
246
- return config.projects?.map((p: {packageName: string}) => p.packageName)
247
- } catch {
248
- return undefined
249
- }
250
- },
251
- },
252
- }
253
-
254
- const fileChecks = await Promise.all(
255
- Object.keys(handlers).map(async (file) => {
256
- const response = await fetch(`${baseUrl}/${file}`, {headers})
257
- return {file, exists: response.status === 200, content: await response.text()}
258
- }),
259
- )
260
-
261
- for (const check of fileChecks) {
262
- if (!check.exists) continue
263
- const result = handlers[check.file].check(check.content)
264
- if (result) return result
265
- }
266
-
267
- return undefined
212
+ return getMonoRepo(getGitHubRawContentUrl(repoInfo), headers)
268
213
  }
269
214
 
270
- /**
271
- * Validates a single package within a repository against required criteria.
272
- */
273
- async function validatePackage(
274
- baseUrl: string,
275
- packagePath: string,
276
- headers: Record<string, string>,
277
- ): Promise<{
278
- hasSanityConfig: boolean
279
- hasSanityCli: boolean
280
- hasEnvFile: boolean
281
- hasSanityDep: boolean
282
- }> {
283
- const packageUrl = packagePath ? `${baseUrl}/${packagePath}` : baseUrl
284
-
285
- const requiredFiles = [
286
- 'package.json',
287
- 'sanity.config.ts',
288
- 'sanity.config.js',
289
- 'sanity.cli.ts',
290
- 'sanity.cli.js',
291
- ...ENV_TEMPLATE_FILES,
292
- ]
293
-
294
- const fileChecks = await Promise.all(
295
- requiredFiles.map(async (file) => {
296
- const response = await fetch(`${packageUrl}/${file}`, {headers})
297
- return {file, exists: response.status === 200, content: await response.text()}
298
- }),
299
- )
300
-
301
- const packageJson = fileChecks.find((f) => f.file === 'package.json')
302
- if (!packageJson?.exists) {
303
- throw new Error(`Package at ${packagePath || 'root'} must include a package.json file`)
304
- }
305
-
306
- let hasSanityDep = false
307
- try {
308
- const pkg: PackageJson = JSON.parse(packageJson.content)
309
- hasSanityDep = !!(pkg.dependencies?.sanity || pkg.devDependencies?.sanity)
310
- } catch (err) {
311
- throw new Error(`Invalid package.json file in ${packagePath || 'root'}`)
312
- }
313
-
314
- const hasSanityConfig = fileChecks.some(
315
- (f) => f.exists && (f.file === 'sanity.config.ts' || f.file === 'sanity.config.js'),
316
- )
317
-
318
- const hasSanityCli = fileChecks.some(
319
- (f) => f.exists && (f.file === 'sanity.cli.ts' || f.file === 'sanity.cli.js'),
320
- )
321
-
322
- const envFile = fileChecks.find(
323
- (f) => f.exists && ENV_TEMPLATE_FILES.includes(f.file as (typeof ENV_TEMPLATE_FILES)[number]),
324
- )
325
- if (envFile) {
326
- const envContent = envFile.content
327
- const hasProjectId = envContent.match(ENV_VAR.PROJECT_ID)
328
- const hasDataset = envContent.match(ENV_VAR.DATASET)
329
-
330
- if (!hasProjectId || !hasDataset) {
331
- const missing = []
332
- if (!hasProjectId) missing.push('SANITY_PROJECT_ID or SANITY_STUDIO_PROJECT_ID')
333
- if (!hasDataset) missing.push('SANITY_DATASET or SANITY_STUDIO_DATASET')
334
- throw new Error(
335
- `Environment template in ${
336
- packagePath || 'repo'
337
- } must include the following variables: ${missing.join(', ')}`,
338
- )
339
- }
340
- }
341
-
342
- return {
343
- hasSanityConfig,
344
- hasSanityCli,
345
- hasEnvFile: Boolean(envFile),
346
- hasSanityDep,
347
- }
348
- }
349
-
350
- /**
351
- * Validates a GitHub repository template against required criteria.
352
- * Supports both monorepo and single-package repositories.
353
- *
354
- * For monorepos:
355
- * - Each package must have a valid package.json
356
- * - At least one package must include 'sanity' in dependencies or devDependencies
357
- * - At least one package must have sanity.config.js/ts and sanity.cli.js/ts
358
- * - Each package must have a .env.template, .env.example, or .env.local.example
359
- *
360
- * For single-package repositories:
361
- * - Must have a valid package.json with 'sanity' dependency
362
- * - Must have sanity.config.js/ts and sanity.cli.js/ts
363
- * - Must have .env.template, .env.example, or .env.local.example
364
- *
365
- * Environment files must include:
366
- * - SANITY_PROJECT_ID or SANITY_STUDIO_PROJECT_ID variable
367
- * - SANITY_DATASET or SANITY_STUDIO_DATASET variable
368
- *
369
- * @throws Error if validation fails with specific reason
370
- */
371
215
  export async function validateRemoteTemplate(
372
216
  repoInfo: RepoInfo,
373
217
  packages: string[] = [''],
374
218
  bearerToken?: string,
375
219
  ): Promise<void> {
376
- const {username, name, branch, filePath} = repoInfo
377
- const baseUrl = `https://raw.githubusercontent.com/${username}/${name}/${branch}/${filePath}`
378
-
379
220
  const headers: Record<string, string> = {}
380
221
  if (bearerToken) {
381
222
  headers.Authorization = `Bearer ${bearerToken}`
382
223
  }
383
-
384
- const validations = await Promise.all(
385
- packages.map((pkg) => validatePackage(baseUrl, pkg, headers)),
386
- )
387
-
388
- const hasSanityDep = validations.some((v) => v.hasSanityDep)
389
- if (!hasSanityDep) {
390
- throw new Error('At least one package must include "sanity" as a dependency in package.json')
391
- }
392
-
393
- const hasSanityConfig = validations.some((v) => v.hasSanityConfig)
394
- if (!hasSanityConfig) {
395
- throw new Error('At least one package must include a sanity.config.js or sanity.config.ts file')
396
- }
397
-
398
- const hasSanityCli = validations.some((v) => v.hasSanityCli)
399
- if (!hasSanityCli) {
400
- throw new Error('At least one package must include a sanity.cli.js or sanity.cli.ts file')
401
- }
402
-
403
- const missingEnvPackages = packages.filter((pkg, i) => !validations[i].hasEnvFile)
404
- if (missingEnvPackages.length > 0) {
405
- throw new Error(
406
- `The following packages are missing .env.template, .env.example, or .env.local.example files: ${missingEnvPackages.join(
407
- ', ',
408
- )}`,
409
- )
224
+ const result = await validateSanityTemplate(getGitHubRawContentUrl(repoInfo), packages, headers)
225
+ if (!result.isValid) {
226
+ throw new Error(result.errors.join('\n'))
410
227
  }
411
228
  }
412
229
 
@@ -510,3 +327,20 @@ export async function generateSanityApiReadToken(
510
327
  })
511
328
  return response.key
512
329
  }
330
+
331
+ export async function setCorsOrigin(
332
+ origin: string,
333
+ projectId: string,
334
+ apiClient: CliApiClient,
335
+ ): Promise<void> {
336
+ try {
337
+ await apiClient({api: {projectId}}).request({
338
+ method: 'POST',
339
+ url: '/cors',
340
+ body: {origin: origin, allowCredentials: false},
341
+ })
342
+ } catch (error) {
343
+ // Silent fail, it most likely means that the origin is already set
344
+ debug('Failed to set CORS origin', error)
345
+ }
346
+ }
@@ -1,4 +1,4 @@
1
- import React, {useRef, useState} from 'react'
1
+ import React, {useState} from 'react'
2
2
  import {
3
3
  Card,
4
4
  Container,
@@ -32,8 +32,8 @@ export const GetStartedTutorial = () => {
32
32
  )
33
33
 
34
34
  const {sanity} = useTheme()
35
- const rootElement = useRef(null)
36
- const rect = useElementSize(rootElement.current)
35
+ const [rootElement, setRootElement] = useState<HTMLDivElement | null>(null)
36
+ const rect = useElementSize(rootElement)
37
37
  const width = rect?.content?.width
38
38
  const isSmallScreen = width ? width < sanity.media[1] : false
39
39
  const isProdEnv = process.env.NODE_ENV !== 'development'
@@ -48,7 +48,7 @@ export const GetStartedTutorial = () => {
48
48
  }
49
49
 
50
50
  return (
51
- <div ref={rootElement}>
51
+ <div ref={setRootElement}>
52
52
  <Card tone="primary" padding={isSmallScreen ? 3 : 5} paddingBottom={isSmallScreen ? 4 : 6}>
53
53
  <Flex justify={isSmallScreen ? 'space-between' : 'flex-end'} align="center">
54
54
  {isSmallScreen && (