Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/create-ideal-cms/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 33 additions & 5 deletions packages/create-ideal-cms/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,19 @@ ${pc.bold("Options:")}
};
}

function runCommand(cmd: string, args: string[], cwd: string): Promise<void> {
function runCommand(
cmd: string,
args: string[],
cwd: string,
env?: NodeJS.ProcessEnv
): Promise<void> {
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) {
Expand All @@ -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<void> {
async function installDeps(
targetDir: string,
pm: PackageManager,
privateRegistryToken: string
): Promise<void> {
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<void> {
Expand Down Expand Up @@ -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"));
}

Expand Down Expand Up @@ -153,7 +181,7 @@ async function main(): Promise<void> {
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`);
Expand Down
18 changes: 18 additions & 0 deletions packages/create-ideal-cms/src/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type Answers = {
packageManager: PackageManager;
source: TemplateSource;
runInitialMigration: boolean;
privateRegistryToken: string;
};

const HEX_RE = /^#[0-9a-fA-F]{6}$/u;
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -213,6 +230,7 @@ export async function collectAnswers(argv: {
packageManager,
source,
runInitialMigration,
privateRegistryToken,
};
}

Expand Down
169 changes: 168 additions & 1 deletion packages/create-ideal-cms/src/transforms.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -69,6 +74,163 @@ async function transformCmsPackageJson(targetDir: string): Promise<void> {
await writeJson(file, pkg);
}

async function applyTextReplacements(
file: string,
replacements: [string, string][]
): Promise<void> {
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<void> {
const cmsPkgFile = join(targetDir, "apps/cms/package.json");
const cmsPkg = await readJson<Pkg>(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 ? (
<VisualEditing.Provider available adminBasePath="/admin">
<VisualEditing.Toggle />
<VisualEditing.Overlay locale={locale}>{children}</VisualEditing.Overlay>
<LivePreviewListener />
<VisualEditingEditRouter />
</VisualEditing.Provider>
) : (
children
)}`,
` {draft ? (
<>
<LivePreviewListener />
{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 (
<div {...withVisualEditingPath(content)}>
<RichTextReact
className={cn(proseVariants({ variant }), className)}
converters={createJsxConverters(variant)}
data={content}
/>
</div>
);`,
` return (
<RichTextReact
className={cn(proseVariants({ variant }), className)}
converters={createJsxConverters(variant)}
data={content}
/>
);`,
],
]
);

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<void> {
// 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, "'\\''")}'`;
Expand Down Expand Up @@ -121,6 +283,11 @@ async function overrideThemeColor(targetDir: string, hex: string): Promise<void>
export async function applyTransforms(answers: Answers): Promise<void> {
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);
}
Loading