From 48035a1cde947c27cc9adf61aeefe265aaa1bd68 Mon Sep 17 00:00:00 2001 From: RostiMelk Date: Fri, 22 Nov 2024 16:10:03 +0100 Subject: [PATCH] feat: initial branch commit --- ...pTemplate.ts => bootstrapLocalTemplate.ts} | 2 +- .../init-project/bootstrapRemoteTemplate.ts | 48 +++ .../src/actions/init-project/initProject.ts | 82 +++-- packages/@sanity/cli/src/types.ts | 9 + .../@sanity/cli/src/util/getProviderName.ts | 9 + .../@sanity/cli/src/util/remoteTemplate.ts | 280 ++++++++++++++++++ 6 files changed, 399 insertions(+), 31 deletions(-) rename packages/@sanity/cli/src/actions/init-project/{bootstrapTemplate.ts => bootstrapLocalTemplate.ts} (99%) create mode 100644 packages/@sanity/cli/src/actions/init-project/bootstrapRemoteTemplate.ts create mode 100644 packages/@sanity/cli/src/util/getProviderName.ts create mode 100644 packages/@sanity/cli/src/util/remoteTemplate.ts diff --git a/packages/@sanity/cli/src/actions/init-project/bootstrapTemplate.ts b/packages/@sanity/cli/src/actions/init-project/bootstrapLocalTemplate.ts similarity index 99% rename from packages/@sanity/cli/src/actions/init-project/bootstrapTemplate.ts rename to packages/@sanity/cli/src/actions/init-project/bootstrapLocalTemplate.ts index 1c14b8f0614e..21bc537485db 100644 --- a/packages/@sanity/cli/src/actions/init-project/bootstrapTemplate.ts +++ b/packages/@sanity/cli/src/actions/init-project/bootstrapLocalTemplate.ts @@ -28,7 +28,7 @@ export interface BootstrapOptions { variables: GenerateConfigOptions['variables'] } -export async function bootstrapTemplate( +export async function bootstrapLocalTemplate( opts: BootstrapOptions, context: CliCommandContext, ): Promise { diff --git a/packages/@sanity/cli/src/actions/init-project/bootstrapRemoteTemplate.ts b/packages/@sanity/cli/src/actions/init-project/bootstrapRemoteTemplate.ts new file mode 100644 index 000000000000..ee2fad1f1b11 --- /dev/null +++ b/packages/@sanity/cli/src/actions/init-project/bootstrapRemoteTemplate.ts @@ -0,0 +1,48 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +import {debug} from '../../debug' +import {type CliCommandContext} from '../../types' +import { + applyEnvVariables, + downloadAndExtractRepo, + getGihubRepoInfo, + isNextJsTemplate, + validateRemoteTemplate, +} from '../../util/remoteTemplate' +import {type GenerateConfigOptions} from './createStudioConfig' + +export interface BootstrapOptions { + packageName: string + templateName: string + outputPath: string + variables: GenerateConfigOptions['variables'] +} + +export async function bootstrapRemoteTemplate( + opts: BootstrapOptions, + context: CliCommandContext, +): Promise { + const {apiClient, cliRoot, output} = context + + const templatesDir = path.join(cliRoot, 'templates') + const {outputPath, templateName, packageName, variables} = opts + const {projectId, dataset} = variables + const sourceDir = path.join(templatesDir, templateName) + const sharedDir = path.join(templatesDir, 'shared') + + debug('Getting Github repo info for "%s"', templateName) + const repoInfo = await getGihubRepoInfo(templateName) + + // Validate repo here + await validateRemoteTemplate(repoInfo) + + debug('Create new directory "%s"', outputPath) + await fs.mkdir(outputPath, {recursive: true}) + + await downloadAndExtractRepo(outputPath, repoInfo) + + const isNext = await isNextJsTemplate(outputPath) + const envName = isNext ? '.env.local' : '.env' + await applyEnvVariables(outputPath, variables, envName) +} diff --git a/packages/@sanity/cli/src/actions/init-project/initProject.ts b/packages/@sanity/cli/src/actions/init-project/initProject.ts index da782d06a08a..0be2963675ac 100644 --- a/packages/@sanity/cli/src/actions/init-project/initProject.ts +++ b/packages/@sanity/cli/src/actions/init-project/initProject.ts @@ -35,17 +35,21 @@ import { type CliCommandDefinition, type SanityCore, type SanityModuleInternal, + type SanityUser, } from '../../types' import {getClientWrapper} from '../../util/clientWrapper' import {dynamicRequire} from '../../util/dynamicRequire' import {getProjectDefaults, type ProjectDefaults} from '../../util/getProjectDefaults' +import {getProviderName} from '../../util/getProviderName' import {getUserConfig} from '../../util/getUserConfig' import {isCommandGroup} from '../../util/isCommandGroup' import {isInteractive} from '../../util/isInteractive' import {fetchJourneyConfig} from '../../util/journeyConfig' +import {checkIsRemoteTemplate} from '../../util/remoteTemplate' import {login, type LoginFlags} from '../login/login' import {createProject} from '../project/createProject' -import {type BootstrapOptions, bootstrapTemplate} from './bootstrapTemplate' +import {bootstrapLocalTemplate, type BootstrapOptions} from './bootstrapLocalTemplate' +import {bootstrapRemoteTemplate} from './bootstrapRemoteTemplate' import {type GenerateConfigOptions} from './createStudioConfig' import {absolutify, validateEmptyPath} from './fsUtils' import {tryGitInit} from './git' @@ -143,6 +147,7 @@ export default async function initSanity( const bareOutput = cliFlags.bare const env = cliFlags.env const packageManager = cliFlags['package-manager'] + const isRemoteTemplate = checkIsRemoteTemplate(cliFlags.template) let defaultConfig = cliFlags['dataset-default'] let showDefaultConfigPrompt = !defaultConfig @@ -167,6 +172,10 @@ export default async function initSanity( return } + if (detectedFramework && isRemoteTemplate) { + throw new Error('A remote template cannot be used with a detected framework.') + } + // Only allow either --project-plan or --coupon if (intendedCoupon && intendedPlan) { throw new Error( @@ -253,24 +262,20 @@ export default async function initSanity( } const envFilename = typeof env === 'string' ? env : envFilenameDefault if (!envFilename.startsWith('.env')) { - throw new Error(`Env filename must start with .env`) + throw new Error('Env filename must start with .env') } const usingBareOrEnv = cliFlags.bare || cliFlags.env - print( - cliFlags.quickstart - ? "You're ejecting a remote Sanity project!" - : `You're setting up a new project!`, - ) - print(`We'll make sure you have an account with Sanity.io. ${usingBareOrEnv ? '' : `Then we'll`}`) - if (!usingBareOrEnv) { - print('install an open-source JS content editor that connects to') - print('the real-time hosted API on Sanity.io. Hang on.\n') + + let introMessage = "Let's get you started with a new project" + if (cliFlags.quickstart) { + introMessage = "Let's get you started with remote Sanity project" + } else if (isRemoteTemplate) { + introMessage = "Let's get you started with a remote Sanity template" } - print('Press ctrl + C at any time to quit.\n') - print('Prefer web interfaces to terminals?') - print('You can also set up best practice Sanity projects with') - print('your favorite frontends on https://www.sanity.io/templates\n') + print('') + print(` ➡️ ${chalk.gray(introMessage)}`) + print('') // If the user isn't already authenticated, make it so const userConfig = getUserConfig() @@ -279,7 +284,13 @@ export default async function initSanity( debug(hasToken ? 'User already has a token' : 'User has no token') if (hasToken) { trace.log({step: 'login', alreadyLoggedIn: true}) - print('Looks like you already have a Sanity-account. Sweet!\n') + const user = await getUserData(apiClient) + print( + `${chalk.gray(" 👤 You're logged in as %s using %s")}`, + user.name, + getProviderName(user.provider), + ) + print('') } else if (!unattended) { trace.log({step: 'login'}) await getOrCreateUser() @@ -581,18 +592,20 @@ export default async function initSanity( const templateName = await selectProjectTemplate() trace.log({step: 'selectProjectTemplate', selectedOption: templateName}) const template = templates[templateName] - if (!template) { + if (isRemoteTemplate === false && !template) { throw new Error(`Template "${templateName}" not found`) } // Use typescript? - const typescriptOnly = template.typescriptOnly === true let useTypeScript = true - if (!typescriptOnly && typeof cliFlags.typescript === 'boolean') { - useTypeScript = cliFlags.typescript - } else if (!typescriptOnly && !unattended) { - useTypeScript = await promptForTypeScript(prompt) - trace.log({step: 'useTypeScript', selectedOption: useTypeScript ? 'yes' : 'no'}) + if (isRemoteTemplate === false && template) { + const typescriptOnly = template.typescriptOnly === true + if (!typescriptOnly && typeof cliFlags.typescript === 'boolean') { + useTypeScript = cliFlags.typescript + } else if (!typescriptOnly && !unattended) { + useTypeScript = await promptForTypeScript(prompt) + trace.log({step: 'useTypeScript', selectedOption: useTypeScript ? 'yes' : 'no'}) + } } // we enable auto-updates by default, but allow users to specify otherwise @@ -618,7 +631,7 @@ export default async function initSanity( // If the template has a sample dataset, prompt the user whether or not we should import it const shouldImport = - !unattended && template.datasetUrl && (await promptForDatasetImport(template.importPrompt)) + !unattended && template?.datasetUrl && (await promptForDatasetImport(template.importPrompt)) trace.log({step: 'importTemplateDataset', selectedOption: shouldImport ? 'yes' : 'no'}) @@ -641,7 +654,9 @@ export default async function initSanity( debug('Failed to update cliInitializedAt metadata') }), // Bootstrap Sanity, creating required project files, manifests etc - bootstrapTemplate(templateOptions, context), + isRemoteTemplate + ? bootstrapRemoteTemplate(templateOptions, context) + : bootstrapLocalTemplate(templateOptions, context), ]) if (bootstrapPromise.status === 'rejected' && bootstrapPromise.reason instanceof Error) { @@ -823,21 +838,19 @@ export default async function initSanity( isFirstProject: boolean userAction: 'create' | 'select' }> { - const spinner = context.output.spinner('Fetching existing projects').start() + const client = apiClient({requireUser: true, requireProject: false}) let projects let organizations: ProjectOrganization[] + try { - const client = apiClient({requireUser: true, requireProject: false}) const [allProjects, allOrgs] = await Promise.all([ client.projects.list({includeMembers: false}), client.request({uri: '/organizations'}), ]) projects = allProjects.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) organizations = allOrgs - spinner.succeed() } catch (err) { if (unattended && flags.project) { - spinner.succeed() return { projectId: flags.project, displayName: 'Unknown project', @@ -845,7 +858,6 @@ export default async function initSanity( userAction: 'select', } } - spinner.fail() throw new Error(`Failed to communicate with the Sanity API:\n${err.message}`) } @@ -1479,6 +1491,16 @@ async function getPlanFromCoupon(apiClient: CliApiClient, couponCode: string): P return planId } +async function getUserData(apiClient: CliApiClient): Promise { + return await apiClient({ + requireUser: true, + requireProject: false, + }).request({ + method: 'GET', + uri: 'users/me', + }) +} + async function getPlanFromId(apiClient: CliApiClient, planId: string): Promise { const response = await apiClient({ requireUser: false, diff --git a/packages/@sanity/cli/src/types.ts b/packages/@sanity/cli/src/types.ts index 556b15cc6bf8..add5277ae088 100644 --- a/packages/@sanity/cli/src/types.ts +++ b/packages/@sanity/cli/src/types.ts @@ -349,3 +349,12 @@ export interface CliConfig { export type UserViteConfig = | InlineConfig | ((config: InlineConfig, env: ConfigEnv) => InlineConfig | Promise) + +export type SanityUser = { + id: string + name: string + email: string + profileImage?: string + tosAcceptedAt?: string + provider: 'google' | 'github' | 'sanity' | `saml-${string}` +} diff --git a/packages/@sanity/cli/src/util/getProviderName.ts b/packages/@sanity/cli/src/util/getProviderName.ts new file mode 100644 index 000000000000..e2be3abfcee4 --- /dev/null +++ b/packages/@sanity/cli/src/util/getProviderName.ts @@ -0,0 +1,9 @@ +import {type SanityUser} from '../types' + +export function getProviderName(provider: SanityUser['provider']) { + if (provider === 'google') return 'Google' + if (provider === 'github') return 'GitHub' + if (provider === 'sanity') return 'Email' + if (provider.startsWith('saml-')) return 'SAML' + return provider +} diff --git a/packages/@sanity/cli/src/util/remoteTemplate.ts b/packages/@sanity/cli/src/util/remoteTemplate.ts new file mode 100644 index 000000000000..595a7892d6db --- /dev/null +++ b/packages/@sanity/cli/src/util/remoteTemplate.ts @@ -0,0 +1,280 @@ +import {access, readFile, writeFile} from 'node:fs/promises' +import {join, posix, sep} from 'node:path' +import {Readable} from 'node:stream' +import {pipeline} from 'node:stream/promises' +import {type ReadableStream} from 'node:stream/web' + +import {x} from 'tar' + +type GithubUrlString = + | `https://github.com/${string}/${string}` + | `https://www.github.com/${string}/${string}` + +export type RepoInfo = { + username: string + name: string + branch: string + filePath: string +} + +export type EnvData = { + projectId: string + dataset: string + apiVersion?: string +} + +function isGithubRepoShorthand(value: string): boolean { + if (URL.canParse(value)) { + return false + } + // This supports :owner/:repo and :owner/:repo/nested/path, e.g. + // sanity-io/sanity + // sanity-io/sanity/templates/next-js + // sanity-io/templates/live-content-api + return /^[\w-]+\/[\w-.]+(\/[\w-.]+)*$/.test(value) +} + +function isGithubRepoUrl(value: string | URL): value is URL | GithubUrlString { + if (URL.canParse(value)) { + return false + } + try { + const url = new URL(value) + const pathSegments = url.pathname.slice(1).split('/') + + return ( + url.protocol === 'https:' && + url.hostname === 'github.com' && + // The pathname must have at least 2 segments. If it has more than 2, the + // third must be "tree" and it must have at least 4 segments. + // https://github.com/:owner/:repo + // https://github.com/:owner/:repo/tree/:ref + pathSegments.length >= 2 && + (pathSegments.length > 2 ? pathSegments[2] === 'tree' && pathSegments.length >= 4 : true) + ) + } catch (_) { + return false + } +} + +async function downloadTarStream(url: string) { + const res = await fetch(url) + + if (!res.body) { + throw new Error(`Failed to download: ${url}`) + } + + return Readable.fromWeb(res.body as ReadableStream) +} + +export function checkIsRemoteTemplate(templateName?: string): boolean { + return templateName?.includes('/') ?? false +} +export async function getGihubRepoInfo(value: string): Promise { + let username = '' + let name = '' + let branch = '' + + if (isGithubRepoShorthand(value)) { + const [owner, repo] = value.split('/') + username = owner + name = repo + } + + if (isGithubRepoUrl(value)) { + const url = new URL(value) + const pathSegments = url.pathname.slice(1).split('/') + username = pathSegments[0] + name = pathSegments[1] + branch = pathSegments[3] + } + + if (!username || !name) { + throw new Error('Invalid GitHub repository format') + } + + try { + const infoResponse = await fetch(`https://api.github.com/repos/${username}/${name}`) + + if (infoResponse.status !== 200) { + throw new Error('GitHub repository not found') + } + + const info = await infoResponse.json() + + return { + username, + name, + branch: branch || info.default_branch, + filePath: '', + } + } catch { + throw new Error('Failed to fetch GitHub repository info') + } +} + +export async function downloadAndExtractRepo( + root: string, + {username, name, branch, filePath}: RepoInfo, +) { + let rootPath: string | null = null + await pipeline( + await downloadTarStream(`https://codeload.github.com/${username}/${name}/tar.gz/${branch}`), + x({ + cwd: root, + strip: filePath ? filePath.split('/').length + 1 : 1, + filter: (p: string) => { + // Convert Windows path separators to POSIX style + const posixPath = p.split(sep).join(posix.sep) + + // Determine the unpacked root path dynamically instead of hardcoding to the fetched repo's name / branch. + // This avoids the condition when the repository has been renamed, and the old repository name is used to fetch the template. + // The tar download will work as it is redirected automatically, but the root directory of the extracted + // template will be the new, renamed name instead of the name used to fetch the template, breaking the filter. + if (rootPath === null) { + const pathSegments = posixPath.split(posix.sep) + rootPath = pathSegments.length ? pathSegments[0] : null + } + + return posixPath.startsWith(`${rootPath}${filePath ? `/${filePath}/` : '/'}`) + }, + }), + ) +} + +/** + * Validates a GitHub repository template against required criteria. + * + * The validation checks: + * 1. Presence of required files: + * - package.json + * - sanity.config.js/ts + * - sanity.cli.js/ts + * - .env.template or .env.example + * + * 2. Package.json requirements: + * - Must contain valid JSON + * - Must include 'sanity' in dependencies or devDependencies + * + * 3. Environment template requirements: + * - Must include SANITY_PROJECT_ID variable + * - Must include SANITY_DATASET variable + */ +export async function validateRemoteTemplate(repoInfo: RepoInfo): Promise { + const {username, name, branch} = repoInfo + const baseUrl = `https://raw.githubusercontent.com/${username}/${name}/${branch}` + + // Check for required files + const requiredFiles = [ + 'package.json', + 'sanity.config.ts', + 'sanity.config.js', + 'sanity.cli.ts', + 'sanity.cli.js', + '.env.template', + '.env.example', + ] + + const fileChecks = await Promise.all( + requiredFiles.map(async (file) => { + const response = await fetch(`${baseUrl}/${file}`) + return {file, exists: response.status === 200, content: await response.text()} + }), + ) + + // Check package.json existence and sanity dependency + const packageJson = fileChecks.find((f) => f.file === 'package.json') + if (!packageJson?.exists) { + throw new Error('Repository must include a package.json file') + } + + try { + const pkg = JSON.parse(packageJson.content) + const hasSanityDep = pkg.dependencies?.sanity || pkg.devDependencies?.sanity + if (!hasSanityDep) { + throw new Error('Repository must include "sanity" as a dependency in package.json') + } + } catch (err) { + throw new Error('Invalid package.json file') + } + + // Check for configuration files + const hasConfigFile = fileChecks.some( + (f) => f.exists && (f.file === 'sanity.config.ts' || f.file === 'sanity.config.js'), + ) + if (!hasConfigFile) { + throw new Error('Repository must include a sanity.config.js or sanity.config.ts file') + } + + const hasCliFile = fileChecks.some( + (f) => f.exists && (f.file === 'sanity.cli.ts' || f.file === 'sanity.cli.js'), + ) + if (!hasCliFile) { + throw new Error('Repository must include a sanity.cli.js or sanity.cli.ts file') + } + + // Check for environment template and required variables + const envFile = fileChecks.find( + (f) => f.exists && (f.file === '.env.template' || f.file === '.env.example'), + ) + if (!envFile?.exists) { + throw new Error('Repository must include either .env.template or .env.example file') + } + + // Check for required environment variables + const envContent = envFile.content + const hasProjectId = envContent.includes('SANITY_PROJECT_ID') + const hasDataset = envContent.includes('SANITY_DATASET') + + if (!hasProjectId || !hasDataset) { + const missing = [] + if (!hasProjectId) missing.push('SANITY_PROJECT_ID') + if (!hasDataset) missing.push('SANITY_DATASET') + throw new Error( + `Environment template must include the following variables: ${missing.join(', ')}`, + ) + } +} + +export async function isNextJsTemplate(root: string): Promise { + try { + const packageJson = await readFile(join(root, 'package.json'), 'utf8') + const pkg = JSON.parse(packageJson) + return !!(pkg.dependencies?.next || pkg.devDependencies?.next) + } catch { + return false + } +} + +export async function applyEnvVariables( + root: string, + envData: EnvData, + targetName = '.env', +): Promise { + const templateExists = await access(join(root, '.env.template')) + .then(() => '.env.template') + .catch(() => undefined) + + const exampleExists = await access(join(root, '.env.example')) + .then(() => '.env.example') + .catch(() => undefined) + + const templatePath = templateExists || exampleExists + if (!templatePath) { + throw new Error('Could not find .env.template or .env.example file') + } + + try { + const templateContent = await readFile(join(root, templatePath), 'utf8') + const {projectId, dataset, apiVersion = ' vX'} = envData + + const envContent = templateContent + .replace(/SANITY_PROJECT_ID=.*$/m, `SANITY_PROJECT_ID="${projectId}"`) + .replace(/SANITY_DATASET=.*$/m, `SANITY_DATASET="${dataset}"`) + .replace(/SANITY_API_VERSION=.*$/m, `SANITY_API_VERSION="${apiVersion}"`) + + await writeFile(join(root, targetName), envContent) + } catch (err) { + throw new Error('Failed to create environment file') + } +}