Skip to content

Commit

Permalink
feat(cli): use @sanity/template-validator package (#8014)
Browse files Browse the repository at this point in the history
  • Loading branch information
RostiMelk authored Dec 13, 2024
1 parent 3869ede commit 27fa7e5
Show file tree
Hide file tree
Showing 4 changed files with 209 additions and 217 deletions.
4 changes: 2 additions & 2 deletions packages/@sanity/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"@sanity/client": "^6.24.1",
"@sanity/codegen": "3.67.1",
"@sanity/telemetry": "^0.7.7",
"@sanity/template-validator": "^1.0.2",
"@sanity/util": "3.67.1",
"chalk": "^4.1.2",
"debug": "^4.3.4",
Expand All @@ -72,8 +73,7 @@
"prettier": "^3.3.0",
"semver": "^7.3.5",
"silver-fleece": "1.1.0",
"validate-npm-package-name": "^3.0.0",
"yaml": "^2.6.1"
"validate-npm-package-name": "^3.0.0"
},
"devDependencies": {
"@repo/package.config": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
checkNeedsReadToken,
downloadAndExtractRepo,
generateSanityApiReadToken,
getMonoRepo,
getPackages,
type RepoInfo,
tryApplyPackageName,
validateRemoteTemplate,
Expand Down Expand Up @@ -40,7 +40,7 @@ export async function bootstrapRemoteTemplate(
const spinner = output.spinner(`Bootstrapping files from template "${name}"`).start()

debug('Validating remote template')
const packages = await getMonoRepo(repoInfo, bearerToken)
const packages = await getPackages(repoInfo, bearerToken)
await validateRemoteTemplate(repoInfo, packages, bearerToken)

debug('Create new directory "%s"', outputPath)
Expand Down
226 changes: 17 additions & 209 deletions packages/@sanity/cli/src/util/remoteTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,21 @@ import {Readable} from 'node:stream'
import {pipeline} from 'node:stream/promises'
import {type ReadableStream} from 'node:stream/web'

import {
ENV_TEMPLATE_FILES,
getMonoRepo,
REQUIRED_ENV_VAR,
validateSanityTemplate,
} from '@sanity/template-validator'
import {x} from 'tar'
import {parse as parseYaml} from 'yaml'

import {type CliApiClient, type PackageJson} from '../types'

const ENV_VAR = {
PROJECT_ID: /SANITY(?:_STUDIO)?_PROJECT_ID/, // Matches SANITY_PROJECT_ID and SANITY_STUDIO_PROJECT_ID
DATASET: /SANITY(?:_STUDIO)?_DATASET/, // Matches SANITY_DATASET and SANITY_STUDIO_DATASET
...REQUIRED_ENV_VAR,
READ_TOKEN: 'SANITY_API_READ_TOKEN',
} as const

const ENV_FILE = {
TEMPLATE: '.env.template',
EXAMPLE: '.env.example',
LOCAL_EXAMPLE: '.env.local.example',
} as const

const ENV_TEMPLATE_FILES = [ENV_FILE.TEMPLATE, ENV_FILE.EXAMPLE, ENV_FILE.LOCAL_EXAMPLE] as const

type EnvData = {
projectId: string
dataset: string
Expand All @@ -40,6 +36,11 @@ export type RepoInfo = {
filePath: string
}

function getGitHubRawContentUrl(repoInfo: RepoInfo): string {
const {username, name, branch, filePath} = repoInfo
return `https://raw.githubusercontent.com/${username}/${name}/${branch}/${filePath}`
}

function isGithubRepoShorthand(value: string): boolean {
if (URL.canParse(value)) {
return false
Expand Down Expand Up @@ -191,222 +192,29 @@ export async function downloadAndExtractRepo(
* Supports pnpm workspaces, Lerna, Rush, and npm workspaces (package.json).
* @returns Promise that resolves to an array of package paths/names if monorepo is detected, undefined otherwise
*/
export async function getMonoRepo(
export async function getPackages(
repoInfo: RepoInfo,
bearerToken?: string,
): Promise<string[] | undefined> {
const {username, name, branch, filePath} = repoInfo
const baseUrl = `https://raw.githubusercontent.com/${username}/${name}/${branch}/${filePath}`

const headers: Record<string, string> = {}
if (bearerToken) {
headers.Authorization = `Bearer ${bearerToken}`
}

type MonorepoHandler = {
check: (content: string) => string[] | undefined
}

const handlers: Record<string, MonorepoHandler> = {
'package.json': {
check: (content) => {
try {
const pkg = JSON.parse(content)
if (!pkg.workspaces) return undefined
return Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages
} catch {
return undefined
}
},
},
'pnpm-workspace.yaml': {
check: (content) => {
try {
const config = parseYaml(content)
return config.packages
} catch {
return undefined
}
},
},
'lerna.json': {
check: (content) => {
try {
const config = JSON.parse(content)
return config.packages
} catch {
return undefined
}
},
},
'rush.json': {
check: (content) => {
try {
const config = JSON.parse(content)
return config.projects?.map((p: {packageName: string}) => p.packageName)
} catch {
return undefined
}
},
},
}

const fileChecks = await Promise.all(
Object.keys(handlers).map(async (file) => {
const response = await fetch(`${baseUrl}/${file}`, {headers})
return {file, exists: response.status === 200, content: await response.text()}
}),
)

for (const check of fileChecks) {
if (!check.exists) continue
const result = handlers[check.file].check(check.content)
if (result) return result
}

return undefined
}

/**
* Validates a single package within a repository against required criteria.
*/
async function validatePackage(
baseUrl: string,
packagePath: string,
headers: Record<string, string>,
): Promise<{
hasSanityConfig: boolean
hasSanityCli: boolean
hasEnvFile: boolean
hasSanityDep: boolean
}> {
const packageUrl = packagePath ? `${baseUrl}/${packagePath}` : baseUrl

const requiredFiles = [
'package.json',
'sanity.config.ts',
'sanity.config.js',
'sanity.cli.ts',
'sanity.cli.js',
...ENV_TEMPLATE_FILES,
]

const fileChecks = await Promise.all(
requiredFiles.map(async (file) => {
const response = await fetch(`${packageUrl}/${file}`, {headers})
return {file, exists: response.status === 200, content: await response.text()}
}),
)

const packageJson = fileChecks.find((f) => f.file === 'package.json')
if (!packageJson?.exists) {
throw new Error(`Package at ${packagePath || 'root'} must include a package.json file`)
}

let hasSanityDep = false
try {
const pkg: PackageJson = JSON.parse(packageJson.content)
hasSanityDep = !!(pkg.dependencies?.sanity || pkg.devDependencies?.sanity)
} catch (err) {
throw new Error(`Invalid package.json file in ${packagePath || 'root'}`)
}

const hasSanityConfig = fileChecks.some(
(f) => f.exists && (f.file === 'sanity.config.ts' || f.file === 'sanity.config.js'),
)

const hasSanityCli = fileChecks.some(
(f) => f.exists && (f.file === 'sanity.cli.ts' || f.file === 'sanity.cli.js'),
)

const envFile = fileChecks.find(
(f) => f.exists && ENV_TEMPLATE_FILES.includes(f.file as (typeof ENV_TEMPLATE_FILES)[number]),
)
if (envFile) {
const envContent = envFile.content
const hasProjectId = envContent.match(ENV_VAR.PROJECT_ID)
const hasDataset = envContent.match(ENV_VAR.DATASET)

if (!hasProjectId || !hasDataset) {
const missing = []
if (!hasProjectId) missing.push('SANITY_PROJECT_ID or SANITY_STUDIO_PROJECT_ID')
if (!hasDataset) missing.push('SANITY_DATASET or SANITY_STUDIO_DATASET')
throw new Error(
`Environment template in ${
packagePath || 'repo'
} must include the following variables: ${missing.join(', ')}`,
)
}
}

return {
hasSanityConfig,
hasSanityCli,
hasEnvFile: Boolean(envFile),
hasSanityDep,
}
return getMonoRepo(getGitHubRawContentUrl(repoInfo), headers)
}

/**
* Validates a GitHub repository template against required criteria.
* Supports both monorepo and single-package repositories.
*
* For monorepos:
* - Each package must have a valid package.json
* - At least one package must include 'sanity' in dependencies or devDependencies
* - At least one package must have sanity.config.js/ts and sanity.cli.js/ts
* - Each package must have a .env.template, .env.example, or .env.local.example
*
* For single-package repositories:
* - Must have a valid package.json with 'sanity' dependency
* - Must have sanity.config.js/ts and sanity.cli.js/ts
* - Must have .env.template, .env.example, or .env.local.example
*
* Environment files must include:
* - SANITY_PROJECT_ID or SANITY_STUDIO_PROJECT_ID variable
* - SANITY_DATASET or SANITY_STUDIO_DATASET variable
*
* @throws Error if validation fails with specific reason
*/
export async function validateRemoteTemplate(
repoInfo: RepoInfo,
packages: string[] = [''],
bearerToken?: string,
): Promise<void> {
const {username, name, branch, filePath} = repoInfo
const baseUrl = `https://raw.githubusercontent.com/${username}/${name}/${branch}/${filePath}`

const headers: Record<string, string> = {}
if (bearerToken) {
headers.Authorization = `Bearer ${bearerToken}`
}

const validations = await Promise.all(
packages.map((pkg) => validatePackage(baseUrl, pkg, headers)),
)

const hasSanityDep = validations.some((v) => v.hasSanityDep)
if (!hasSanityDep) {
throw new Error('At least one package must include "sanity" as a dependency in package.json')
}

const hasSanityConfig = validations.some((v) => v.hasSanityConfig)
if (!hasSanityConfig) {
throw new Error('At least one package must include a sanity.config.js or sanity.config.ts file')
}

const hasSanityCli = validations.some((v) => v.hasSanityCli)
if (!hasSanityCli) {
throw new Error('At least one package must include a sanity.cli.js or sanity.cli.ts file')
}

const missingEnvPackages = packages.filter((pkg, i) => !validations[i].hasEnvFile)
if (missingEnvPackages.length > 0) {
throw new Error(
`The following packages are missing .env.template, .env.example, or .env.local.example files: ${missingEnvPackages.join(
', ',
)}`,
)
const result = await validateSanityTemplate(getGitHubRawContentUrl(repoInfo), packages, headers)
if (!result.isValid) {
throw new Error(result.errors.join('\n'))
}
}

Expand Down
Loading

0 comments on commit 27fa7e5

Please sign in to comment.