diff --git a/packages/create-ideal-cms/README.md b/packages/create-ideal-cms/README.md index 0079501c..6c9eeacb 100644 --- a/packages/create-ideal-cms/README.md +++ b/packages/create-ideal-cms/README.md @@ -18,10 +18,15 @@ You'll be prompted for: - Postgres connection string (`DATABASE_URL`) - Public server URL (`NEXT_PUBLIC_SERVER_URL`) - Optional: OpenAI key, Vercel Blob token, OIDC SSO credentials +- Optional: a FocusReactive private-plugin registry token - Package manager (`bun` / `pnpm` / `npm` / skip) `PAYLOAD_SECRET` is auto-generated. +## Premium plugins + +`apps/cms` in the source monorepo includes FocusReactive-only premium plugins (currently Visual Editing) behind the `@fr-private` npm scope. Without a private-registry token, the scaffolder strips their dependency and wiring entirely so `install` doesn't 404 on a scope you can't reach. With a token, it instead writes a `.npmrc` that reads the token from an `NPM_TOKEN` environment variable (never written to disk or committed) and installs normally — export `NPM_TOKEN` in your shell before future installs. + ## What you get The output mirrors the diff --git a/packages/create-ideal-cms/src/index.ts b/packages/create-ideal-cms/src/index.ts index 972438e0..21d3ef69 100644 --- a/packages/create-ideal-cms/src/index.ts +++ b/packages/create-ideal-cms/src/index.ts @@ -43,9 +43,19 @@ ${pc.bold("Options:")} }; } -function runCommand(cmd: string, args: string[], cwd: string): Promise { +function runCommand( + cmd: string, + args: string[], + cwd: string, + env?: NodeJS.ProcessEnv +): Promise { return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { cwd, stdio: "inherit", shell: false }); + const child = spawn(cmd, args, { + cwd, + stdio: "inherit", + shell: false, + env: env ? { ...process.env, ...env } : process.env, + }); child.on("error", reject); child.on("close", (code) => { if (code === 0) { @@ -63,9 +73,14 @@ function pmRunArgs(pm: PackageManager, script: string, args: string[] = []): str return ["run", script, ...args]; } -async function installDeps(targetDir: string, pm: PackageManager): Promise { +async function installDeps( + targetDir: string, + pm: PackageManager, + privateRegistryToken: string +): Promise { if (pm === "skip") return; - await runCommand(pm, ["install"], targetDir); + const env = privateRegistryToken ? { NPM_TOKEN: privateRegistryToken } : undefined; + await runCommand(pm, ["install"], targetDir, env); } async function runInitialMigration(targetDir: string, pm: PackageManager): Promise { @@ -110,6 +125,19 @@ function printNextSteps(answers: Answers, migrationRan: boolean): void { lines.push(` ${run} run dev ${pc.dim("# starts on port 3333")}`); lines.push(""); lines.push(pc.dim("Edit apps/cms/.env to add OpenAI / OIDC / Blob tokens later.")); + if (answers.privateRegistryToken) { + lines.push( + pc.dim( + "Export NPM_TOKEN in your shell before future installs (needed for @fr-private packages)." + ) + ); + } else { + lines.push( + pc.dim( + "Skipped premium plugins (no private registry token) — re-run with one to include them." + ) + ); + } outro(lines.join("\n")); } @@ -153,7 +181,7 @@ async function main(): Promise { if (answers.packageManager !== "skip") { log.step(`Installing dependencies with ${answers.packageManager}…`); try { - await installDeps(answers.targetDir, answers.packageManager); + await installDeps(answers.targetDir, answers.packageManager, answers.privateRegistryToken); } catch (err) { log.error(`Install failed: ${(err as Error).message}`); log.message(`You can finish setup manually with: ${answers.packageManager} install`); diff --git a/packages/create-ideal-cms/src/prompts.ts b/packages/create-ideal-cms/src/prompts.ts index 60ed16e2..2a289da2 100644 --- a/packages/create-ideal-cms/src/prompts.ts +++ b/packages/create-ideal-cms/src/prompts.ts @@ -33,6 +33,7 @@ export type Answers = { packageManager: PackageManager; source: TemplateSource; runInitialMigration: boolean; + privateRegistryToken: string; }; const HEX_RE = /^#[0-9a-fA-F]{6}$/u; @@ -165,6 +166,22 @@ export async function collectAnswers(argv: { ); } + const hasPrivateRegistryToken = unwrap( + await confirm({ + message: + "Do you have a FocusReactive private-plugin registry token? (enables premium plugins like Visual Editing)", + initialValue: false, + }) + ); + const privateRegistryToken = hasPrivateRegistryToken + ? unwrap( + await password({ + message: "Private registry token (NPM_TOKEN)", + mask: "*", + }) + ) + : ""; + const runInitialMigration = unwrap( await confirm({ message: @@ -213,6 +230,7 @@ export async function collectAnswers(argv: { packageManager, source, runInitialMigration, + privateRegistryToken, }; } diff --git a/packages/create-ideal-cms/src/transforms.ts b/packages/create-ideal-cms/src/transforms.ts index 428f21f2..98528f66 100644 --- a/packages/create-ideal-cms/src/transforms.ts +++ b/packages/create-ideal-cms/src/transforms.ts @@ -1,7 +1,12 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { Answers } from "./prompts.js"; +// Premium plugins that require a FocusReactive private-registry token. Without one, +// the scaffold strips them out entirely rather than shipping a project that fails +// `bun install` for anyone without @fr-private access. +const PRIVATE_PLUGIN_DEPENDENCY = "@fr-private/payload-plugin-visual-editing"; + // Plugins that ship as `workspace:*` in the source monorepo and need a real // version range in the scaffold output. Pinned exactly to match the source's // "exact-version" style. Bump when a new plugin version ships. @@ -69,6 +74,163 @@ async function transformCmsPackageJson(targetDir: string): Promise { await writeJson(file, pkg); } +async function applyTextReplacements( + file: string, + replacements: [string, string][] +): Promise { + let content = await readFile(file, "utf-8"); + for (const [find, replace] of replacements) { + if (!content.includes(find)) { + throw new Error(`create-ideal-cms: expected to find this in ${file}:\n${find}`); + } + content = content.replace(find, replace); + } + await writeFile(file, content); +} + +async function stripPrivatePlugins(targetDir: string): Promise { + const cmsPkgFile = join(targetDir, "apps/cms/package.json"); + const cmsPkg = await readJson(cmsPkgFile); + if (cmsPkg.dependencies) { + // oxlint-disable-next-line typescript/no-dynamic-delete + delete cmsPkg.dependencies[PRIVATE_PLUGIN_DEPENDENCY]; + } + await writeJson(cmsPkgFile, cmsPkg); + + await applyTextReplacements(join(targetDir, "apps/cms/src/lib/plugins/index.ts"), [ + ['import { visualEditingPlugin } from "@fr-private/payload-plugin-visual-editing";\n', ""], + [ + ` + visualEditingPlugin({ + adminBasePath: "/admin", + skipCollections: [ + "users", + "media", + "categories", + "authors", + "testimonials", + "header", + "footer", + "document-embeddings", + "redirects", + "presets", + "comments", + "comment-reads", + "ab-experiments", + "payload-mcp-api-keys", + ], + skipGlobals: ["site-settings"], + }), +`, + "", + ], + ]); + + await applyTextReplacements(join(targetDir, "apps/cms/src/app/(frontend)/[locale]/layout.tsx"), [ + ['import { VisualEditing } from "@fr-private/payload-plugin-visual-editing/client";\n\n', ""], + ['import { VisualEditingEditRouter } from "@/components/VisualEditingEditRouter";\n', ""], + [ + ` {draft ? ( + + + {children} + + + + ) : ( + children + )}`, + ` {draft ? ( + <> + + {children} + + ) : ( + children + )}`, + ], + ]); + + await applyTextReplacements( + join(targetDir, "apps/cms/src/components/shared/RichText/index.tsx"), + [ + [ + 'import { withVisualEditingPath } from "@fr-private/payload-plugin-visual-editing/client";\n', + "", + ], + [ + ` return ( +
+ +
+ );`, + ` return ( + + );`, + ], + ] + ); + + await applyTextReplacements(join(targetDir, "apps/cms/src/lib/adapters/prepareMediaProps.ts"), [ + [ + 'import { withVisualEditingPath } from "@fr-private/payload-plugin-visual-editing/client";\n\n', + "", + ], + [ + ` const visualEditing = withVisualEditingPath(image); + const media = image && typeof image === "object" ? image : null;`, + ` const media = image && typeof image === "object" ? image : null;`, + ], + [ + ` data: { kind: "video", src: getMediaUrl(src) }, + visualEditing, + };`, + ` data: { kind: "video", src: getMediaUrl(src) }, + };`, + ], + [ + ` }, + visualEditing, + imageProps, + };`, + ` }, + imageProps, + };`, + ], + ]); + + await applyTextReplacements(join(targetDir, "apps/cms/src/app/(payload)/admin/importMap.js"), [ + [ + "import { VisualEditingBridgeProvider as VisualEditingBridgeProvider_673e524fc3ed2dc6764c4e182a583baf } from '@fr-private/payload-plugin-visual-editing/admin'\n", + "", + ], + [ + ' "@fr-private/payload-plugin-visual-editing/admin#VisualEditingBridgeProvider": VisualEditingBridgeProvider_673e524fc3ed2dc6764c4e182a583baf,\n', + "", + ], + ]); + + await rm(join(targetDir, "apps/cms/src/components/VisualEditingEditRouter"), { + recursive: true, + force: true, + }); +} + +async function writePrivateRegistryNpmrc(targetDir: string): Promise { + // References NPM_TOKEN rather than embedding the token literally, so `git init && git add .` + // downstream (initGit) can't commit the secret into the scaffolded project's history. + const content = `@fr-private:registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=\${NPM_TOKEN}\n`; + await writeFile(join(targetDir, ".npmrc"), content); +} + function escapeEnvValue(value: string): string { if (value === "") return ""; return `'${value.replace(/'/gu, "'\\''")}'`; @@ -121,6 +283,11 @@ async function overrideThemeColor(targetDir: string, hex: string): Promise export async function applyTransforms(answers: Answers): Promise { await transformRootPackageJson(answers.targetDir, answers); await transformCmsPackageJson(answers.targetDir); + if (answers.privateRegistryToken) { + await writePrivateRegistryNpmrc(answers.targetDir); + } else { + await stripPrivatePlugins(answers.targetDir); + } await writeEnvFile(answers.targetDir, answers); await overrideThemeColor(answers.targetDir, answers.primaryColor); }