#!/usr/bin/env node import * as p from '@clack/prompts'; import color from 'picocolors'; import fs from 'fs-extra'; import path from 'path'; import { spawn, spawnSync } from 'node:child_process'; async function runCommand(cmd, args, cwd) { return new Promise((resolve, reject) => { const proc = spawn(cmd, args, { cwd, stdio: 'ignore' }); proc.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`Command failed: ${cmd} ${args.join(' ')}`)))); }); } async function main() { p.intro(`${color.bgRedBright(color.black(' ✨ Welcome to Lumify Sanity Template! ✨ '))}`); const project = await p.group( { projectName: () => p.text({ message: `${color.cyan('🔖 Enter your project name (for display):')}`, placeholder: 'My Sanity Lumify page', validate: (v) => (v.length < 2 ? `${color.red('❌ Too short!')}` : undefined), }), dirName: ({ results }) => { const defaultKebab = String(results.projectName) .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); return p.text({ message: `${color.cyan('📂 Enter a directory name for your project:')}`, placeholder: `./${defaultKebab}`, initialValue: defaultKebab, validate: (v) => { if (v.length < 2) return `${color.red('❌ Too short!')}`; const kebab = String(v) .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); const root = path.resolve(process.cwd(), kebab); if (fs.existsSync(root)) { return `${color.red('⚠️ Directory already exists:')} ${color.yellow(root)}`; } return undefined; }, }); }, shouldGit: () => p.confirm({ message: `${color.green('🌱 Do you want to initialize a git repository?')}`, initialValue: true, }), gitOrg: ({ results }) => { if (!results.shouldGit) return undefined; return p.text({ message: `${color.cyan('🐙 Enter the GitHub organization/user to push to (leave empty to skip remote):')}`, placeholder: 'my-org-or-username', initialValue: 'lumify-systems', validate: (v) => (v && !/^([\w-]+)$/.test(v) ? `${color.red('❌ Invalid org/user name!')}` : undefined), }); }, packageManager: () => p.select({ message: `${color.magenta('📦 Which package manager do you want to use?')}`, options: [ { value: 'bun', label: `${color.yellow('bun 🥯')}` }, { value: 'npm', label: `${color.green('npm 📦')}` }, ], initialValue: 'bun', }), faviconPath: () => p.text({ message: `${color.cyan('🖼️ Path to favicon SVG (leave empty to skip):')}`, placeholder: './myicon.svg', }), extraDomain: () => p.text({ message: `${color.cyan('🌍 Enter a domain this will run on later (leave empty to skip):')}`, placeholder: 'youronlinedomain.com', validate: () => undefined, }), }, { onCancel: () => { p.cancel('Operation cancelled.'); process.exit(0); }, } ); const kebabName = String(project.dirName) .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); const rootDir = path.resolve(process.cwd(), kebabName); const pmx = project.packageManager === 'bun' ? 'bunx' : 'npx'; const studioDir = path.join(rootDir, 'apps', 'studio'); await p.tasks([ { title: `${color.yellow('📁 Copying template contents to root')}`, task: async () => { const __dirname = path.dirname(new URL(import.meta.url).pathname); const templateDir = path.resolve(__dirname, 'template'); await fs.ensureDir(rootDir); await fs.copy(templateDir, rootDir, { overwrite: true }); return 'Template copied!'; }, }, { title: `${color.green(`📦 Installing dependencies with ${project.packageManager}`)}`, task: async () => { await runCommand(project.packageManager === 'bun' ? 'bun' : 'npm', ['install'], rootDir); return 'Dependencies installed successfully'; }, }, { title: `${color.cyan('🔑 Checking Sanity login status')}`, task: async () => { await fs.ensureDir(studioDir); let loggedIn = false; let hasProjects = false; try { const result = await new Promise((res, rej) => { let out = ''; const c = spawn(pmx, ['sanity', 'projects', 'list'], { cwd: studioDir, stdio: ['ignore', 'pipe', 'pipe'] }); c.stdout.on('data', (d) => (out += d)); c.on('exit', (code) => (code === 0 ? res(out) : rej(new Error('fail')))); }); if (result.includes('id ')) { loggedIn = true; hasProjects = result.split('\n').slice(1).some((l) => l.trim()); } } catch { } if (loggedIn) { return hasProjects ? 'Already logged in to Sanity!' : 'Logged in to Sanity (no projects yet).'; } else { await runCommand(pmx, ['sanity', 'login'], studioDir); return 'Sanity login complete!'; } }, }, { title: `${color.magenta('🛠️ Creating Sanity project')}`, task: async () => { spawnSync(pmx, [ "sanity", "projects", "create", project.projectName ], { cwd: studioDir, stdio: "inherit", encoding: "utf-8" }) const listProjects = spawnSync(pmx, [ "sanity", "projects", "list" ], { cwd: studioDir, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }) let sanityJson; if (listProjects.stdout) { try { const lines = listProjects.stdout.split(/\r?\n/).filter(l => l.trim() && !l.startsWith('id') && !l.startsWith('─')); const projectIds = lines.map(line => { const match = line.match(/^([a-z0-9]{8})\b/); return match ? match[1] : null; }).filter(id => !!id); if (projectIds.length > 0) { sanityJson = { projectId: projectIds[0] }; } } catch (e) { throw new Error("Could not parse Sanity project list output."); } } if (!sanityJson || !sanityJson.projectId) { throw new Error("Sanity project creation failed."); } // Update connection file with project ID const connPath = path.join(rootDir, "packages/sanity-connection/index.ts"); if (!(await fs.pathExists(connPath))) { throw new Error(`Connection file not found at ${connPath}`); } let connFile = await fs.readFile(connPath, "utf8"); if (!/projectId: "[^"]+"/.test(connFile)) { throw new Error(`Could not find projectId in ${connPath}. Please ensure the file contains a line like projectId: "${sanityJson.projectId}"`); } connFile = connFile.replace(/projectId: "[^"]+"/, `projectId: "${sanityJson.projectId}"`); await fs.writeFile(connPath, connFile); // Update wrangler.jsonc with project name const wranglerPath = path.join(rootDir, "apps/client/wrangler.jsonc"); if (await fs.pathExists(wranglerPath)) { let wranglerFile = await fs.readFile(wranglerPath, "utf8"); wranglerFile = wranglerFile.replace(/"name": "[^"]+"/, `"name": "${kebabName}"`); await fs.writeFile(wranglerPath, wranglerFile); } return `Sanity project created with ID: ${sanityJson.projectId}`; }, }, { title: `${color.green('🔐 Creating Sanity viewer token')}`, task: async () => { if (!(await fs.pathExists(studioDir))) throw new Error(`Studio directory not found at ${studioDir}`); const addOut = await new Promise((res, rej) => { let buf = ''; const c = spawn( pmx, ['sanity', 'tokens', 'add', 'Main Viewer API Token', '--role=viewer', '-y', '--json'], { cwd: studioDir, stdio: ['ignore', 'pipe', 'pipe'] } ); c.stdout.on('data', (d) => (buf += d)); c.on('exit', (code) => (code === 0 ? res(buf) : rej(new Error('token failed')))); }); const token = JSON.parse(addOut); const connPath = path.join(rootDir, 'packages/sanity-connection/index.ts'); let content = await fs.readFile(connPath, 'utf8'); content = content .replace(/publicViewerToken: "[^"]+"/, `publicViewerToken: "${token.key}"`) .replace(/studioHost: "[^"]+"/, `studioHost: "${kebabName}"`) .replace(/studioUrl: "[^"]+"/, `studioUrl: "https://${kebabName}.sanity.studio"`); await fs.writeFile(connPath, content); return 'Viewer token created and configured!'; }, }, ]); if (project.faviconPath && project.faviconPath.trim()) { await p.tasks([ { title: `${color.yellow('🌟 Generating favicon')}`, task: async () => { await runCommand(pmx, ['create', "favicon", project.faviconPath, 'packages/ui/favicon'], rootDir); await fs.copy(path.join(rootDir, 'packages/ui/favicon/'), path.join(rootDir, 'apps/client/public/'), { overwrite: true }); await fs.copy(path.join(rootDir, 'packages/ui/favicon/'), path.join(rootDir, 'apps/studio/static/'), { overwrite: true }); return 'Favicon generated and copied!'; }, }, ]); } const corsOrigins = [ 'http://localhost:5173', 'https://*.api.sanity.io', 'wss://*.api.sanity.io', `https://${kebabName}.sanity.studio`, ]; if (project.extraDomain && project.extraDomain.trim()) { corsOrigins.push(`https://${project.extraDomain.trim()}`); } await p.tasks( corsOrigins.map((origin) => ({ title: `${color.cyan('🌐 Adding Sanity CORS origin:')} ${color.yellow(origin)}`, task: async () => { const args = ['sanity', 'cors', 'add', origin, '--yes']; if (origin === `https://${kebabName}.sanity.studio`) args.push('--credentials'); await runCommand(pmx, args, studioDir); return `CORS added: ${origin}` + (args.includes('--credentials') ? ' (credentials allowed)' : ''); }, })) ); if (project.shouldGit) { await p.tasks([ { title: `${color.green('🌱 Setting up Git repository')}`, task: async () => { await runCommand('git', ['config', '--global', 'init.defaultBranch', 'main'], process.cwd()); await runCommand('git', ['init'], rootDir); await runCommand('git', ['add', '.'], rootDir); await runCommand('git', ['commit', '-m', 'Initiated from Lumify Sanity template :rocket:'], rootDir); await runCommand('git', ['branch', '-M', 'main'], rootDir); return 'Git repository initialized with main branch'; }, }, ]); const org = typeof project.gitOrg === 'string' ? project.gitOrg.trim() : ''; if (org) { const githubUrl = `https://github.com/new?name=${kebabName}&owner=${org}`; p.note( `Please create a new GitHub repository named\n\`${kebabName}\` under \`${org}\` at:\n\n${color.cyan(githubUrl)}\n\nThe browser will open for you.`, 'GitHub Setup Required' ); await runCommand('xdg-open', [githubUrl], process.cwd()); await p.text({ message: 'Press Enter after you have created the GitHub repository to continue', placeholder: '', validate: () => undefined, }); await p.tasks([ { title: `${color.magenta('🚀 Pushing to GitHub')}`, task: async () => { await runCommand('git', ['remote', 'add', 'origin', `git@github.com:${org}/${kebabName}.git`], rootDir); await runCommand('git', ['push', '-u', 'origin', 'main'], rootDir); return 'Pushed to GitHub successfully!'; }, }, ]); } } const pmRun = `${project.packageManager} run dev`; const pmDeploy = `${project.packageManager} run deploy`; p.text({ message: `${color.bgGreen(color.black('✨ Setup complete! ✨'))}\n${color.dim('Need help?')} ${color.underline(color.cyan('https://docs.lumify.systems/'))} ${color.yellow('💡')}`, }); p.outro( color.bold(color.cyan('🎉 All done! Your project is ready! 🎉')) + '\n\n' + color.bold('Next steps:\n') + ` ${color.bold(color.green('cd'))} ${color.cyan(kebabName)}\n` + ` ${color.bold(color.green(pmRun))} ${color.dim('# Start your local dev server')} ${color.yellow('🖥️')}\n` + ` ${color.bold(color.green(pmDeploy))} ${color.dim('# Deploy your Sanity Studio')} ${color.yellow('🚀')}\n\n` + color.dim('Local development:\n') + ` • App: ${color.cyan('http://localhost:5173')} ${color.yellow('🌐')}\n` + ` • Studio: ${color.cyan('http://localhost:3333')} ${color.yellow('🛠️')}\n\n` + color.dim('After deploying:\n') + ` • Studio: ${color.cyan(`https://${kebabName}.sanity.studio`)} ${color.yellow('✨')}` ); process.exit(0); } main().catch((err) => { p.log.error('An error occurred during setup:'); p.log.error(err.message || err); process.exit(1); });