Skip to content
Open
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
3 changes: 3 additions & 0 deletions apps/cms/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,11 @@ so a deeply nested field inside a block can overflow and make `payload migrate:c
- `@focus-reactive/payload-plugin-scheduling` — scheduled publishing on serverless
- `@focus-reactive/payload-plugin-translator` — AI translation (OpenAI provider)
- `@focus-reactive/payload-plugin-ab` — A/B testing with middleware-driven variant rewrites
- `@fr-private/payload-plugin-releases` — batch content publishing (`releases` + `release-items` collections); scheduled releases not wired (built-in poller disabled via `schedulerInterval: false`)
- MCP plugin — exposes content tools to AI agents

Plugins from the private `@fr-private` scope are registered in `src/lib/plugins/private.ts` (spread into the list as `...privatePlugins`), and their client surface is re-exported from `src/lib/plugins/visual-editing/client.ts` so no component imports `@fr-private/*` directly. Both files exist to be **replaced wholesale** by `create-ideal-cms`, which strips the private scope when scaffolding a project — keep the private-scope imports confined to them.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three sentences on one physical line — violates the Markdown convention in ~/.claude/CLAUDE.md.

When writing or substantially editing long Markdown files, put each full sentence on its own line. Preserve normal Markdown structure, but avoid wrapping multiple sentences onto one physical line.

Note this cannot be fixed in isolation: splitting the sentences breaks the LINE_STRIP_FILES entry for apps/cms/CLAUDE.md in packages/create-ideal-cms/src/stubs.ts, which drops whole lines matching @fr-private and would leave the trailing sentences orphaned in scaffolded output. Fix the strip mechanism first (see the comment on stubs.ts), then reformat.


A plugin's **app-side wiring** (config, adapters, provider components) lives under `src/lib/plugins/<name>/` — e.g. `lib/plugins/ab/` (middleware adapter, cookies, variant data) and `lib/plugins/analytics/` (GA4 provider). The published feature itself lives in the monorepo `packages/payload-plugin-<name>/`.

## Key Patterns
Expand Down
1 change: 1 addition & 0 deletions apps/cms/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@focus-reactive/payload-plugin-scheduling": "workspace:*",
"@focus-reactive/payload-plugin-seo": "workspace:*",
"@focus-reactive/payload-plugin-translator": "workspace:*",
"@fr-private/payload-plugin-releases": "^0.1.1",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

payload-types.ts was not regenerated for the new collections.

Adding @fr-private/payload-plugin-releases introduces the releases and release-items collections, which must appear in Config['collections'], Config['collectionsSelect'] and collectionsJoins. apps/cms/src/payload-types.ts is untouched by this PR.

I confirmed this by regenerating locally — the diff is real and non-trivial (adds Release, ReleaseItem, ReleasesSelect, ReleaseItemsSelect, and a collectionsJoins.releases.items entry).

Consequences: any payload.find({ collection: "releases" }) fails to type-check, and the next person who runs generate:types gets a large unrelated diff mixed into their PR.

Note the local regeneration also removed 'payload-jobs': PayloadJob and collapsed jobs.tasks to unknown (dropping TaskSchedulePublish) — worth confirming that is pre-existing drift and not a regression caused by the new plugin's config merge before you commit the regenerated file.

"@fr-private/payload-plugin-visual-editing": "^1.0.2",
"@payloadcms/db-postgres": "3.84.1",
"@payloadcms/live-preview-react": "3.84.1",
Expand Down
2 changes: 1 addition & 1 deletion apps/cms/src/app/(frontend)/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getMessages } from "next-intl/server";
import { draftMode } from "next/headers";
import React from "react";

import { VisualEditing } from "@fr-private/payload-plugin-visual-editing/client";
import { VisualEditing } from "@/lib/plugins/visual-editing/client";

import { Providers } from "@/lib/context";
import { AnalyticsProviderClient } from "@/lib/plugins/analytics/AnalyticsProviderClient";
Expand Down
14 changes: 13 additions & 1 deletion apps/cms/src/app/(payload)/admin/importMap.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/cms/src/components/shared/Media/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { withVisualEditingPath } from "@fr-private/payload-plugin-visual-editing/client";
import { withVisualEditingPath } from "@/lib/plugins/visual-editing/client";
import type { StaticImageData } from "next/image";
import React, { Fragment } from "react";
import type { ElementType, Ref } from "react";
Expand Down
2 changes: 1 addition & 1 deletion apps/cms/src/components/shared/RichText/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
ListJSXConverter,
RichText as RichTextReact,
} from "@payloadcms/richtext-lexical/react";
import { withVisualEditingPath } from "@fr-private/payload-plugin-visual-editing/client";
import { withVisualEditingPath } from "@/lib/plugins/visual-editing/client";
import { Check } from "lucide-react";
import { Image } from "@/components/image";

Expand Down
23 changes: 2 additions & 21 deletions apps/cms/src/lib/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
createOpenAIProvider,
createSyncRunner,
} from "@focus-reactive/payload-plugin-translator";
import { visualEditingPlugin } from "@fr-private/payload-plugin-visual-editing";
import { nestedDocsPlugin } from "@payloadcms/plugin-nested-docs";
import { redirectsPlugin } from "@payloadcms/plugin-redirects";
import { vercelBlobStorage } from "@payloadcms/storage-vercel-blob";
Expand Down Expand Up @@ -40,6 +39,7 @@ import { revalidateRedirects } from "@/lib/hooks/revalidateRedirects";
import type { Page } from "@/payload-types";

import { mcpPluginConfig } from "./mcp";
import { privatePlugins } from "./private";

const resolveAnalyticsPagePath = async (ref: string, req: PayloadRequest): Promise<string> => {
const { defaultLocale } = I18N_CONFIG;
Expand Down Expand Up @@ -372,26 +372,7 @@ export const plugins: Plugin[] = [
},
}),

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"],
}),
...privatePlugins,

mcpPluginConfig,
];
41 changes: 41 additions & 0 deletions apps/cms/src/lib/plugins/private.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { contentReleasesPlugin } from "@fr-private/payload-plugin-releases";
import { visualEditingPlugin } from "@fr-private/payload-plugin-visual-editing";
import type { Plugin } from "payload";

// Plugins published to the private `@fr-private` npm scope. They are grouped
// here — instead of inline in `index.ts` — so `create-ideal-cms` can replace
// THIS FILE with an empty list when scaffolding a project for someone without
// access to that scope (see packages/create-ideal-cms/src/stubs.ts).
export const privatePlugins: Plugin[] = [
visualEditingPlugin({
adminBasePath: "/admin",
skipCollections: [

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

skipCollections is an enumerated denylist that the two brand-new collections silently escape — only array ordering saves it.

contentReleasesPlugin appends releases and release-items to config.collections. Neither is in this skipCollections list, and neither is globalSection (a real content collection added in create_and_wire_global_section_collection).

Today visualEditingPlugin runs before contentReleasesPlugin in this array, so the release collections do not exist yet when it walks config.collections — pure luck. Swap the two entries, or add a third private plugin above the visual-editing one, and the visual-editing overlay starts decorating internal release bookkeeping documents with no compile error and no test to catch it.

An allowlist (includeCollections: ["page", "posts", ...]) would make this structurally impossible, matching the "structural, not enumerated" principle this PR applies to PRUNE_PATHS. At minimum add "releases", "release-items", and "globalSection" here and drop the ordering dependency.

"users",
"media",
"categories",
"authors",
"testimonials",
"header",
"footer",
"document-embeddings",
"redirects",
"presets",
"comments",
"comment-reads",
"ab-experiments",
"payload-mcp-api-keys",
],
skipGlobals: ["site-settings"],
}),

contentReleasesPlugin({

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

contentReleasesPlugin adds two new collections but the PR ships no migration — the tables will never exist.

The plugin returns collections: [...patchedCollections, releasesCollection, releaseItemsCollection], i.e. it registers releases and release-items.

apps/cms/src/lib/database/index.ts configures the adapter with push: options.push ?? false and prodMigrations: migrations, so Payload never auto-syncs schema — every schema change needs a committed migration under src/lib/database/migrations/. This PR adds none (git diff touches no file in that directory, and migrations/index.ts is unchanged).

Result: on any environment, opening the Releases admin view or hitting /api/content-releases/:id/publish fails with relation "releases" does not exist. payload migrate:status will also report drift against the config.

Run payload migrate:create add_content_releases and commit the generated .ts/.json pair plus the index.ts entry.

// Batch content publishing: group document changes into a release and
// publish them together. Scoped to the same collections as our other
// content plugins.
enabledCollections: ["page", "posts"],
// Scheduled releases are not wired up (no cron endpoint / schedulerSecret),
// so disable the built-in setInterval poller — it is pointless here and
// unreliable on serverless. Releases are published manually.
schedulerInterval: false,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

schedulerInterval: false with no schedulerSecret makes the "scheduled" release path a silent dead end for editors.

The plugin only registers /content-releases/run-scheduled when options.schedulerSecret is set, and only starts the setInterval poller when schedulerInterval is neither false nor 0. With both disabled there is no code path anywhere that transitions a release out of scheduled.

But the collection still exposes status: "scheduled" and a scheduledAt datetime field in the admin UI (see ReleaseStatus and the scheduledAt field in the generated types). An editor can set a release to publish next Tuesday, see it accepted, and it will simply never fire — with no error, no log line, nothing.

Disabling the poller is the right call on serverless, but it needs a matching guard: either restrict the status/scheduledAt fields via access.releases.update, or wire the cron endpoint + a Vercel cron. Leaving a visible feature that silently does nothing is worse than not shipping it.

}),
];
11 changes: 11 additions & 0 deletions apps/cms/src/lib/plugins/visual-editing/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Client-side surface of the visual-editing plugin, re-exported through the
// app so frontend components never import `@fr-private/*` directly.
//
// The package is published to a private npm scope, so `create-ideal-cms`
// replaces THIS FILE with inert no-op equivalents when scaffolding a project
// (see packages/create-ideal-cms/src/stubs.ts). Keep the exported surface
// minimal and keep the stub in sync with it.
export {
VisualEditing,
withVisualEditingPath,
} from "@fr-private/payload-plugin-visual-editing/client";
9 changes: 6 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions packages/create-ideal-cms/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import pc from "picocolors";
import { collectAnswers } from "./prompts.js";
import type { Answers, PackageManager } from "./prompts.js";
import { fetchTemplate } from "./scaffold.js";
import type { PluginVersions } from "./scaffold.js";
import { applyTransforms } from "./transforms.js";

function parseCliArgs(): { name?: string; ref?: string; fromLocal?: string } {
Expand Down Expand Up @@ -125,8 +126,9 @@ async function main(): Promise<void> {

const s = spinner();
s.start(`Fetching template from ${describeSource(answers)}`);
let pluginVersions: PluginVersions;
try {
await fetchTemplate(answers.targetDir, answers.source);
pluginVersions = await fetchTemplate(answers.targetDir, answers.source);
s.stop("Template fetched");
} catch (err) {
s.stop("Template fetch failed");
Expand All @@ -136,7 +138,7 @@ async function main(): Promise<void> {

s.start("Applying configuration");
try {
await applyTransforms(answers);
await applyTransforms(answers, pluginVersions);
s.stop("Configuration applied");
} catch (err) {
s.stop("Configuration failed");
Expand Down
104 changes: 93 additions & 11 deletions packages/create-ideal-cms/src/scaffold.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,35 @@
import { downloadTemplate } from "giget";
import { copyFile, mkdir, readdir, readlink, rm, symlink, writeFile } from "node:fs/promises";
import {
copyFile,
mkdir,
readdir,
readFile,
readlink,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";

export type TemplateSource = { kind: "github"; ref: string } | { kind: "local"; path: string };

/** Maps published package name -> version, harvested from the template before pruning. */
export type PluginVersions = Record<string, string>;

// Every `packages/<PLUGIN_DIR_PREFIX>*` directory is pruned: users consume the
// plugins as published npm packages instead of workspace source. Matching by
// prefix rather than an explicit list means a newly added plugin can never be
// forgotten here and leak into scaffolded projects.
const PLUGIN_DIR_PREFIX = "payload-plugin-";

// `apps/cms` is the only app a scaffolded project gets. Everything else under
// `apps/` is a source-monorepo sandbox or demo (`dev`, `multi-tenancy-demo`, …)
// and is pruned by exclusion, so new sandboxes can't leak into output either.
const KEPT_APP = "cms";

const PRUNE_PATHS = [
// Plugin source — users consume them as published npm packages instead.
"packages/payload-plugin-ab",
"packages/payload-plugin-comments",
"packages/payload-plugin-presets",
"packages/payload-plugin-scheduling",
"packages/payload-plugin-translator",
// The CLI itself — its source lives inside the monorepo and must not bleed into scaffolded projects.
"packages/create-ideal-cms",
// The plugin dev sandbox app — only useful inside the source monorepo.
"apps/dev",
// Release machinery — not relevant downstream.
".releaserc.json",
".github",
Expand All @@ -23,6 +38,10 @@ const PRUNE_PATHS = [
"docs",
"CLAUDE.md",
".cursor",
// Agent config: repo-specific skills/subagents, plus `.claude/worktrees/`,
// which holds entire extra checkouts of this monorepo.
".claude",
".agents",
// Lockfile gets regenerated by the user's package manager.
"bun.lock",
// Any caches / build artifacts that may have slipped through.
Expand All @@ -45,6 +64,10 @@ const LOCAL_COPY_SKIP = new Set([
".git",
".DS_Store",
".test-output",
// Pruned anyway, but skipped up front so `.claude/worktrees/` — which can hold
// several full checkouts of this monorepo — is never copied in the first place.
".claude",
".agents",
]);

const MIGRATION_STUB = `// Regenerated by create-ideal-cms.
Expand Down Expand Up @@ -91,7 +114,57 @@ async function resetMigrations(targetDir: string): Promise<void> {
await writeFile(join(dir, "index.ts"), MIGRATION_STUB);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resetMigrations targets a path that does not exist — the source monorepo's ~40 migrations ship into every scaffolded project.

resetMigrations reads join(targetDir, "apps/cms/src/database/migrations"), but the real location is apps/cms/src/lib/database/migrations (see apps/cms/src/lib/database/index.ts, which resolves migrationDir relative to itself, and the repo-root CLAUDE.md: *"owns its Postgres adapter + migrations under src/lib/database/"`).

readdir therefore throws ENOENT, the catch { return; } swallows it, and the function is a silent no-op: the MIGRATION_STUB is never written and none of the 60+ existing migration files are deleted. A scaffolded project inherits this repo's entire migration history — including add_pgvector_embedding_column and every block-schema migration — which will not match the user's own future migrate:create output.

This is a pre-existing bug, but it sits inside fetchTemplate, which this PR rewrites, and it defeats the PR's stated goal of producing a project that actually works.

Suggested change
await writeFile(join(dir, "index.ts"), MIGRATION_STUB);
await writeFile(join(dir, "index.ts"), MIGRATION_STUB);

(the real fix is one line up: const dir = join(targetDir, "apps/cms/src/lib/database/migrations");)

}

export async function fetchTemplate(targetDir: string, source: TemplateSource): Promise<void> {
/**
* Lists `packages/payload-plugin-*` dirs and reads each one's published name +
* version. Must run BEFORE the prune — the versions are the only reason those
* directories are fetched at all. Dirs without a readable `package.json` (stray
* build artifacts) contribute nothing but are still returned for pruning.
*/
async function collectPluginPackages(
targetDir: string
): Promise<{ dirs: string[]; versions: PluginVersions }> {
const packagesDir = join(targetDir, "packages");
let entries: string[];
try {
entries = (await readdir(packagesDir, { withFileTypes: true }))
.filter((e) => e.isDirectory() && e.name.startsWith(PLUGIN_DIR_PREFIX))
.map((e) => e.name);
} catch {
return { dirs: [], versions: {} };
}

const versions: PluginVersions = {};
await Promise.all(
entries.map(async (name) => {
try {
const pkg = JSON.parse(
await readFile(join(packagesDir, name, "package.json"), "utf-8")
) as { name?: string; version?: string; private?: boolean };
if (pkg.name && pkg.version && pkg.private !== true) versions[pkg.name] = pkg.version;
} catch {
// No package.json / unparsable — not a real workspace package, prune only.
}
})
);

return { dirs: entries.map((name) => join("packages", name)), versions };
}

/** Every `apps/*` dir except the one app a scaffolded project keeps. */
async function collectExtraAppDirs(targetDir: string): Promise<string[]> {
try {
return (await readdir(join(targetDir, "apps"), { withFileTypes: true }))
.filter((e) => e.isDirectory() && e.name !== KEPT_APP)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dirent.isDirectory() does not follow symlinks, so a symlinked app or plugin dir escapes both prune passes.

readdir(..., { withFileTypes: true }) reports link type from an lstat, so for a symlink isDirectory() is false and isSymbolicLink() is true. Such an entry is filtered out here (and at line 130 in collectPluginPackages) and never reaches rm.

This is reachable via the local path: copyLocal deliberately preserves symlinks verbatim (readlink + symlink), so any symlinked entry under apps/ or packages/ in the source repo survives into the output — as a dangling link, since its target was pruned. This repo already uses relative symlinks as a convention (.claude/skills/* → ../../.agents/skills/* per the root CLAUDE.md), so it is not a hypothetical pattern here.

Suggested change
.filter((e) => e.isDirectory() && e.name !== KEPT_APP)
.filter((e) => (e.isDirectory() || e.isSymbolicLink()) && e.name !== KEPT_APP)

.map((e) => join("apps", e.name));
} catch {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swallowing the readdir error here is a behaviour regression vs. the removed PRUNE_PATHS entry.

Before this PR, "apps/dev" was a literal in PRUNE_PATHS and rm(..., { force: true }) removed it unconditionally. Now the removal is contingent on this readdir succeeding — and on any failure the function returns [], so apps/dev and apps/multi-tenancy-demo are copied into the user's project with no warning. That is precisely the "source leaked into output" class of bug the PR is fixing.

The same catch shape in collectPluginPackages (line 132) is safe by accident, because an empty versions map makes transformCmsPackageJson throw. This one has no such backstop.

apps/ is guaranteed to exist in the template — a readdir failure means the fetch/copy is broken, so rethrow instead of degrading silently.

return [];
}
}

export async function fetchTemplate(
targetDir: string,
source: TemplateSource
): Promise<PluginVersions> {
if (source.kind === "local") {
await copyLocal(source.path, targetDir);
} else {
Expand All @@ -103,9 +176,18 @@ export async function fetchTemplate(targetDir: string, source: TemplateSource):
});
}

const [{ dirs, versions }, extraApps] = await Promise.all([
collectPluginPackages(targetDir),
collectExtraAppDirs(targetDir),
]);

await Promise.all(
PRUNE_PATHS.map((path) => rm(join(targetDir, path), { recursive: true, force: true }))
[...PRUNE_PATHS, ...dirs, ...extraApps].map((path) =>
rm(join(targetDir, path), { recursive: true, force: true })
)
);

await resetMigrations(targetDir);

return versions;
}
Loading