@sanity/cli 3.65.1 → 3.65.2-corel.465

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.
@@ -0,0 +1,506 @@
1
+ import {access, readFile, writeFile} from 'node:fs/promises'
2
+ import {join, posix, sep} from 'node:path'
3
+ import {Readable} from 'node:stream'
4
+ import {pipeline} from 'node:stream/promises'
5
+ import {type ReadableStream} from 'node:stream/web'
6
+
7
+ import {x} from 'tar'
8
+ import {parse as parseYaml} from 'yaml'
9
+
10
+ import {type CliApiClient, type PackageJson} from '../types'
11
+
12
+ 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
15
+ READ_TOKEN: 'SANITY_API_READ_TOKEN',
16
+ } as const
17
+
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
+ type EnvData = {
27
+ projectId: string
28
+ dataset: string
29
+ readToken?: string
30
+ }
31
+
32
+ type GithubUrlString =
33
+ | `https://github.com/${string}/${string}`
34
+ | `https://www.github.com/${string}/${string}`
35
+
36
+ export type RepoInfo = {
37
+ username: string
38
+ name: string
39
+ branch: string
40
+ filePath: string
41
+ }
42
+
43
+ function isGithubRepoShorthand(value: string): boolean {
44
+ if (URL.canParse(value)) {
45
+ return false
46
+ }
47
+ // This supports :owner/:repo and :owner/:repo/nested/path, e.g.
48
+ // sanity-io/sanity
49
+ // sanity-io/sanity/templates/next-js
50
+ // sanity-io/templates/live-content-api
51
+ // sanity-io/sanity/packages/@sanity/cli/test/test-template
52
+ return /^[\w-]+\/[\w-.]+(\/[@\w-.]+)*$/.test(value)
53
+ }
54
+
55
+ function isGithubRepoUrl(value: string | URL): value is URL | GithubUrlString {
56
+ if (URL.canParse(value) === false) {
57
+ return false
58
+ }
59
+ const url = new URL(value)
60
+ const pathSegments = url.pathname.slice(1).split('/')
61
+
62
+ return (
63
+ url.protocol === 'https:' &&
64
+ url.hostname === 'github.com' &&
65
+ // The pathname must have at least 2 segments. If it has more than 2, the
66
+ // third must be "tree" and it must have at least 4 segments.
67
+ // https://github.com/:owner/:repo
68
+ // https://github.com/:owner/:repo/tree/:ref
69
+ pathSegments.length >= 2 &&
70
+ (pathSegments.length > 2 ? pathSegments[2] === 'tree' && pathSegments.length >= 4 : true)
71
+ )
72
+ }
73
+
74
+ async function downloadTarStream(url: string, bearerToken?: string): Promise<Readable> {
75
+ const headers: Record<string, string> = {}
76
+ if (bearerToken) {
77
+ headers.Authorization = `Bearer ${bearerToken}`
78
+ }
79
+
80
+ const res = await fetch(url, {headers})
81
+
82
+ if (!res.body) {
83
+ throw new Error(`Failed to download: ${url}`)
84
+ }
85
+
86
+ return Readable.fromWeb(res.body as ReadableStream)
87
+ }
88
+
89
+ export function checkIsRemoteTemplate(templateName?: string): boolean {
90
+ return templateName?.includes('/') ?? false
91
+ }
92
+
93
+ export async function getGitHubRepoInfo(value: string, bearerToken?: string): Promise<RepoInfo> {
94
+ let username = ''
95
+ let name = ''
96
+ let branch = ''
97
+ let filePath = ''
98
+
99
+ if (isGithubRepoShorthand(value)) {
100
+ const parts = value.split('/')
101
+ username = parts[0]
102
+ name = parts[1]
103
+ // If there are more segments after owner/repo, they form the file path
104
+ if (parts.length > 2) {
105
+ filePath = parts.slice(2).join('/')
106
+ }
107
+ }
108
+
109
+ if (isGithubRepoUrl(value)) {
110
+ const url = new URL(value)
111
+ const pathSegments = url.pathname.slice(1).split('/')
112
+ username = pathSegments[0]
113
+ name = pathSegments[1]
114
+
115
+ // If we have a "tree" segment, everything after branch is the file path
116
+ if (pathSegments[2] === 'tree') {
117
+ branch = pathSegments[3]
118
+ if (pathSegments.length > 4) {
119
+ filePath = pathSegments.slice(4).join('/')
120
+ }
121
+ }
122
+ }
123
+
124
+ if (!username || !name) {
125
+ throw new Error('Invalid GitHub repository format')
126
+ }
127
+
128
+ const tokenMessage =
129
+ 'GitHub repository not found. For private repositories, use --template-token to provide an access token.\n\n' +
130
+ 'You can generate a new token at https://github.com/settings/personal-access-tokens/new\n' +
131
+ 'Set the token to "read-only" with repository access and a short expiry (e.g. 7 days) for security.'
132
+
133
+ try {
134
+ const headers: Record<string, string> = {}
135
+ if (bearerToken) {
136
+ headers.Authorization = `Bearer ${bearerToken}`
137
+ }
138
+
139
+ const infoResponse = await fetch(`https://api.github.com/repos/${username}/${name}`, {
140
+ headers,
141
+ })
142
+
143
+ if (infoResponse.status !== 200) {
144
+ if (infoResponse.status === 404) {
145
+ throw new Error(tokenMessage)
146
+ }
147
+ throw new Error('GitHub repository not found')
148
+ }
149
+
150
+ const info = await infoResponse.json()
151
+
152
+ return {
153
+ username,
154
+ name,
155
+ branch: branch || info.default_branch,
156
+ filePath,
157
+ }
158
+ } catch {
159
+ throw new Error(tokenMessage)
160
+ }
161
+ }
162
+
163
+ export async function downloadAndExtractRepo(
164
+ root: string,
165
+ {username, name, branch, filePath}: RepoInfo,
166
+ bearerToken?: string,
167
+ ): Promise<void> {
168
+ let rootPath: string | null = null
169
+ await pipeline(
170
+ await downloadTarStream(
171
+ `https://codeload.github.com/${username}/${name}/tar.gz/${branch}`,
172
+ bearerToken,
173
+ ),
174
+ x({
175
+ cwd: root,
176
+ strip: filePath ? filePath.split('/').length + 1 : 1,
177
+ filter: (p: string) => {
178
+ const posixPath = p.split(sep).join(posix.sep)
179
+ if (rootPath === null) {
180
+ const pathSegments = posixPath.split(posix.sep)
181
+ rootPath = pathSegments.length ? pathSegments[0] : null
182
+ }
183
+ return posixPath.startsWith(`${rootPath}${filePath ? `/${filePath}/` : '/'}`)
184
+ },
185
+ }),
186
+ )
187
+ }
188
+
189
+ /**
190
+ * Checks if a GitHub repository is a monorepo by examining common monorepo configuration files.
191
+ * Supports pnpm workspaces, Lerna, Rush, and npm workspaces (package.json).
192
+ * @returns Promise that resolves to an array of package paths/names if monorepo is detected, undefined otherwise
193
+ */
194
+ export async function getMonoRepo(
195
+ repoInfo: RepoInfo,
196
+ bearerToken?: string,
197
+ ): Promise<string[] | undefined> {
198
+ const {username, name, branch, filePath} = repoInfo
199
+ const baseUrl = `https://raw.githubusercontent.com/${username}/${name}/${branch}/${filePath}`
200
+
201
+ const headers: Record<string, string> = {}
202
+ if (bearerToken) {
203
+ headers.Authorization = `Bearer ${bearerToken}`
204
+ }
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
268
+ }
269
+
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
+ export async function validateRemoteTemplate(
372
+ repoInfo: RepoInfo,
373
+ packages: string[] = [''],
374
+ bearerToken?: string,
375
+ ): Promise<void> {
376
+ const {username, name, branch, filePath} = repoInfo
377
+ const baseUrl = `https://raw.githubusercontent.com/${username}/${name}/${branch}/${filePath}`
378
+
379
+ const headers: Record<string, string> = {}
380
+ if (bearerToken) {
381
+ headers.Authorization = `Bearer ${bearerToken}`
382
+ }
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
+ )
410
+ }
411
+ }
412
+
413
+ export async function isNextJsTemplate(root: string): Promise<boolean> {
414
+ try {
415
+ const packageJson = await readFile(join(root, 'package.json'), 'utf8')
416
+ const pkg = JSON.parse(packageJson)
417
+ return !!(pkg.dependencies?.next || pkg.devDependencies?.next)
418
+ } catch {
419
+ return false
420
+ }
421
+ }
422
+
423
+ export async function applyEnvVariables(
424
+ root: string,
425
+ envData: EnvData,
426
+ targetName = '.env',
427
+ ): Promise<void> {
428
+ const templatePath = await Promise.any(
429
+ ENV_TEMPLATE_FILES.map(async (file) => {
430
+ await access(join(root, file))
431
+ return file
432
+ }),
433
+ ).catch(() => {
434
+ throw new Error('Could not find .env.template, .env.example or .env.local.example file')
435
+ })
436
+
437
+ try {
438
+ const templateContent = await readFile(join(root, templatePath), 'utf8')
439
+ const {projectId, dataset, readToken = ''} = envData
440
+
441
+ const findAndReplaceVariable = (
442
+ content: string,
443
+ varRegex: RegExp | string,
444
+ value: string,
445
+ useQuotes: boolean,
446
+ ) => {
447
+ const pattern = varRegex instanceof RegExp ? varRegex : new RegExp(`${varRegex}=.*$`, 'm')
448
+ const match = content.match(pattern)
449
+ if (!match) return content
450
+
451
+ const varName = match[0].split('=')[0]
452
+ return content.replace(
453
+ new RegExp(`${varName}=.*$`, 'm'),
454
+ `${varName}=${useQuotes ? `"${value}"` : value}`,
455
+ )
456
+ }
457
+
458
+ let envContent = templateContent
459
+ const vars = [
460
+ {pattern: ENV_VAR.PROJECT_ID, value: projectId},
461
+ {pattern: ENV_VAR.DATASET, value: dataset},
462
+ {pattern: ENV_VAR.READ_TOKEN, value: readToken},
463
+ ]
464
+ const useQuotes = templateContent.includes('="')
465
+
466
+ for (const {pattern, value} of vars) {
467
+ envContent = findAndReplaceVariable(envContent, pattern, value, useQuotes)
468
+ }
469
+
470
+ await writeFile(join(root, targetName), envContent)
471
+ } catch (err) {
472
+ throw new Error(
473
+ 'Failed to set environment variables. This could be due to file permissions or the .env file format. See https://www.sanity.io/docs/environment-variables for details on environment variable setup.',
474
+ )
475
+ }
476
+ }
477
+
478
+ export async function tryApplyPackageName(root: string, name: string): Promise<void> {
479
+ try {
480
+ const packageJson = await readFile(join(root, 'package.json'), 'utf8')
481
+ const pkg: PackageJson = JSON.parse(packageJson)
482
+ pkg.name = name
483
+
484
+ await writeFile(join(root, 'package.json'), JSON.stringify(pkg, null, 2))
485
+ } catch (err) {
486
+ // noop
487
+ }
488
+ }
489
+
490
+ export async function generateSanityApiReadToken(
491
+ label: string,
492
+ projectId: string,
493
+ apiClient: CliApiClient,
494
+ ): Promise<string> {
495
+ const response = await apiClient({requireProject: false, requireUser: true})
496
+ .config({apiVersion: 'v2021-06-07'})
497
+ .request<{key: string}>({
498
+ uri: `/projects/${projectId}/tokens`,
499
+ method: 'POST',
500
+ body: {
501
+ label: `${label} (${Date.now()})`, // Add timestamp to ensure uniqueness
502
+ roleName: 'viewer',
503
+ },
504
+ })
505
+ return response.key
506
+ }