@sanity/cli 3.67.1 → 3.67.2-content-os.38
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/lib/_chunks-cjs/cli.js +140 -381
- package/lib/_chunks-cjs/cli.js.map +1 -1
- package/lib/index.d.mts +1 -0
- package/lib/index.d.ts +1 -0
- package/package.json +8 -9
- package/src/actions/init-project/bootstrapRemoteTemplate.ts +10 -2
- package/src/actions/init-project/initProject.ts +28 -51
- package/src/actions/login/login.ts +2 -3
- package/src/commands/logout/logoutCommand.ts +1 -1
- package/src/outputters/cliOutputter.ts +21 -8
- package/src/types.ts +1 -0
- package/src/util/frameworkPort.ts +63 -0
- package/src/util/generateCommandsDocumentation.ts +7 -4
- package/src/util/remoteTemplate.ts +43 -209
@@ -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
|
-
|
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
|
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
|
-
|
385
|
-
|
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
|
+
}
|