529 lines
17 KiB
JavaScript
Executable File
529 lines
17 KiB
JavaScript
Executable File
#!/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 vaporvee's Sanity Template! ✨ "))}`,
|
|
);
|
|
|
|
const project = await p.group(
|
|
{
|
|
projectName: () =>
|
|
p.text({
|
|
message: `${color.cyan("🔖 Enter your project name (for display):")}`,
|
|
placeholder: "My Sanity Project",
|
|
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("<input>.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(" ✨ vaporvee's 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 vaporvee's 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);
|
|
});
|