#!/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"; import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); 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, }), 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", }), lowerCaseName: ({ results }) => { const defaultLowercase = String(results.dirName || results.projectName) .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); return p.text({ message: `${color.cyan("📓 Enter a lowercase name for your project (e.g., used as")} ${color.yellow(".sanity.studio")}${color.cyan("):")}`, placeholder: `${defaultLowercase}`, initialValue: defaultLowercase, validate: (v) => { if (v.length < 2) return `${color.red("❌ Too short!")}`; if (!/^[a-z0-9-]+$/.test(v)) return `${color.red("❌ Only lowercase letters, numbers, and hyphens allowed!")}`; if (v.startsWith("-") || v.endsWith("-")) return `${color.red("❌ Cannot start or end with hyphen!")}`; if (v.includes(".sanity.studio")) return `${color.red("❌ Do not include .sanity.studio - it will be added automatically!")}`; }, }); }, 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.join(process.cwd(), kebabName); const pm = project.packageManager === "bun" ? "bun" : "npm"; const studioDir = path.join(rootDir, "apps", "studio"); await p.tasks([ { title: `${color.yellow("📁 Copying template contents to root")}`, task: async () => { const templateDir = path.join(__dirname, "template"); // Ensure root directory exists with proper permissions await fs.ensureDir(rootDir); try { await fs.copy(templateDir, rootDir, { overwrite: true, errorOnExist: false, preserveTimestamps: false, }); } catch (error) { throw new Error(`Failed to copy template files: ${error.message}`); } // Rename gitignore files to .gitignore (dotfiles get lost in npm packages) async function renameGitignoreFiles(dir) { try { const items = await fs.readdir(dir); for (const item of items) { const itemPath = path.join(dir, item); const stat = await fs.stat(itemPath); if (stat.isDirectory()) { await renameGitignoreFiles(itemPath); } else if (item === "gitignore") { const gitignorePath = path.join(dir, ".gitignore"); await fs.move(itemPath, gitignorePath); } } } catch (error) { throw new Error( `Failed to rename gitignore files: ${error.message}`, ); } } await renameGitignoreFiles(rootDir); 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"; }, }, ]); // Now check Sanity login status and prompt for provider if needed console.log(`${color.cyan("🔑 Checking Sanity login status...")}`); let loggedIn = false; let hasProjects = false; try { const result = await new Promise((res, rej) => { let out = ""; const c = spawn(pm, ["x", "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("Not logged in")), ); }); if (result.includes("id ")) { loggedIn = true; hasProjects = result .split("\n") .slice(1) .some((l) => l.trim()); } } catch {} if (!loggedIn) { // Exit clack UI completely for interactive commands p.outro(`${color.yellow("⚠️ Authentication required...")}`); // Clear terminal and use clean console session console.clear(); console.log( `\n${color.bgRedBright(color.black(" ✨ Lumify Sanity Template - Authentication ✨ "))}\n`, ); if (!loggedIn) { p.log.warn(`${color.yellow("⚠️ You need to login to Sanity first.")}`); const provider = await p.select({ message: `${color.cyan("🔐 Choose a provider to login to Sanity:")}`, options: [ { value: "sanity", label: "Sanity (email/password)" }, { value: "google", label: "Google" }, { value: "github", label: "GitHub" }, ], initialValue: "sanity", }); p.log.info(`${color.cyan(`🚀 Logging in with provider: ${provider}`)}`); try { await runCommand( pm, ["x", "sanity", "login", "--provider", provider], studioDir, ); p.log.success(`${color.green("✅ Sanity login complete!")}`); } catch (error) { p.log.error(`${color.red("❌ Sanity login failed:")}`); p.log.error(error.message); process.exit(1); } } else { p.log.success( hasProjects ? `${color.green("✅ Already logged in to Sanity!")}` : `${color.green("✅ Logged in to Sanity (no projects yet).")}`, ); } // Clear terminal for clean project creation // Create Sanity project (manual step to avoid interaction issues) p.log.info(`${color.magenta("🛠️ Creating Sanity project...")}`); p.log.warn( `${color.yellow("⚠️ Due to terminal interaction issues, you need to run this command manually:")}`, ); p.log.message(`${color.cyan("cd " + studioDir)}`); p.log.message( `${color.cyan(`${pm} x sanity projects create "${project.projectName}"`)}`, ); await p.confirm({ message: `${color.green("Press Enter when the project is created successfully...")}`, initialValue: true, }); p.log.success(`${color.green("✅ Continuing with project configuration...")}`); const listProjects = spawnSync(pm, ["x", "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) { p.log.error( `${color.red("❌ Could not parse Sanity project list output.")}`, ); process.exit(1); } } if (!sanityJson || !sanityJson.projectId) { console.error(`${color.red("❌ Sanity project creation failed.")}`); process.exit(1); } // Update connection file with project ID const connPath = path.join(rootDir, "packages/sanity-connection/index.ts"); if (!(await fs.pathExists(connPath))) { console.error( `${color.red("❌ Connection file not found at " + connPath)}`, ); process.exit(1); } let connFile = await fs.readFile(connPath, "utf8"); if (!/projectId: "[^"]+"/.test(connFile)) { console.error( `${color.red("❌ Could not find projectId in connection file")}`, ); process.exit(1); } 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": "${project.lowerCaseName}"`, ); await fs.writeFile(wranglerPath, wranglerFile); } p.log.success( `${color.green("✅ Sanity project created with ID: " + sanityJson.projectId)}`, ); await p.tasks([ { title: `${color.blue("🗄️ Setting up production dataset for Sanity CMS")}`, task: async () => { if (!(await fs.pathExists(studioDir))) throw new Error(`Studio directory not found at ${studioDir}`); try { await runCommand( pm, ["x", "sanity", "dataset", "create", "production"], studioDir, ); return "Production dataset created successfully"; } catch (error) { // Check if dataset already exists if (error.message && error.message.includes("already exists")) { return "Production dataset already exists"; } throw new Error( `Failed to create production dataset: ${error.message}`, ); } }, }, { 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( pm, [ "x", "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: "${project.lowerCaseName}"`, ) .replace( /studioUrl: "[^"]+"/, `studioUrl: "https://${project.lowerCaseName}.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( pm, ["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://${project.lowerCaseName}.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 = ["x", "sanity", "cors", "add", origin, "--yes"]; if (origin === `https://${project.lowerCaseName}.sanity.studio`) args.push("--credentials"); await runCommand(pm, 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 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://duckduckgo.com/"))} ${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://${project.lowerCaseName}.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); });