Skip to content

feat(create-ideal-cms): make scaffolded projects installable - #95

Open
dogfrogfog wants to merge 3 commits into
mainfrom
feature/update-scaffolding-tool
Open

feat(create-ideal-cms): make scaffolded projects installable#95
dogfrogfog wants to merge 3 commits into
mainfrom
feature/update-scaffolding-tool

Conversation

@dogfrogfog

@dogfrogfog dogfrogfog commented Aug 7, 2026

Copy link
Copy Markdown
Member

Makes npx @focus-reactive/create-ideal-cms produce a project that actually installs, and wires the content-releases plugin into apps/cms.

Why

A scaffolded project was shipping three problems at once:

  1. Stale versions. PLUGIN_VERSIONS in transforms.ts was maintained by hand and had drifted badly — it pinned payload-plugin-seo@1.3.0 against a real 1.10.1, plus stale comments, presets, translator, analytics.
  2. Leaked source. Plugin dirs and sandbox apps were pruned by hardcoded name lists, so anything added later slipped into output — payload-plugin-analytics, payload-plugin-seo and apps/multi-tenancy-demo were all being copied in.
  3. Broken install. apps/cms depends on @fr-private/*, a private npm scope that 404s for anyone outside the org, so install failed outright in a scaffolded project.

What changed

create-ideal-cms

Versions are derived, not maintained. fetchTemplate reads each packages/payload-plugin-*/package.json before pruning it and returns the real name → version map. The hardcoded map is gone, so the pin cannot drift again.

Pruning is structural, not enumerated. Every packages/payload-plugin-*, and every apps/* except cms. A newly added plugin or sandbox can no longer be forgotten. This also drops .claude / .agents.claude/worktrees/ holds entire extra checkouts of this monorepo and was being copied into output.

Private scope is stripped. The deps are removed, the two files that confine that scope are replaced with stubs (inert VisualEditing components, empty plugin list), and @fr-private lines are filtered out of the generated import map and the app's agent guide. Swapping whole files rather than editing component code means the strip cannot half-apply.

Registry auth is stripped. The repo's .npmrc carries //registry.npmjs.org/:_authToken=${NPM_TOKEN}. Copied into a scaffolded project, that sends an empty token on every registry request — install fails for anyone without NPM_TOKEN set, private deps or not. That line and the @fr-private registry mapping are now filtered out.

Fail loud. Any workspace:* left after the rewrite that is not a kept @repo/* package now throws, instead of producing a package.json that breaks at install time.

apps/cms

Wires @fr-private/payload-plugin-releases for batch content publishing over page + posts. Scheduled releases are not set up (no cron endpoint / schedulerSecret), so the built-in setInterval poller is disabled via schedulerInterval: false — releases publish manually.

Both @fr-private plugins now sit behind two dedicated files instead of being referenced across the app:

  • lib/plugins/private.ts — groups their registrations, exports privatePlugins, spread into the plugin list.
  • lib/plugins/visual-editing/client.ts — re-exports the client surface, so layout / Media / RichText no longer import @fr-private directly.

This is what makes the CLI's whole-file swap possible. Keep private-scope imports confined to these two files.

Verification

Scaffolded end-to-end from this branch on GitHub (the real giget tarball path, not --from-local), then installed and type-checked the result:

  • bun install with NPM_TOKEN unset: 1193 packages resolved, no auth error — this is the failure the PR exists to fix
  • installed project passes check-typestsgo --noEmit exit 0, so the stubs satisfy every call site with real deps present
  • output tree is apps/[cms] + packages/[tailwind-config, typescript-config] — no plugin source, no sandboxes
  • deps pinned to real published versions (ab 2.9.0, analytics 1.2.1, comments 1.11.1, presets 0.12.0, scheduling 1.3.0, seo 1.10.1, translator 0.6.2)
  • zero @fr-private imports remain
  • stripped importMap.js parses — node --check OK
  • CLI build + lint clean; source apps/cms check-types clean

Same run against --from-local gives identical output.

Not covered

next build was not run on the scaffolded project — it needs a live DATABASE_URL.

Release note

apps/cms is private, so it triggers no release. The feat(create-ideal-cms) commit bumps the CLI to 1.1.0.

Both must land in the same merge. CLI-only would be actively broken: the stub would overwrite visual-editing/client.ts while main's components still import @fr-private directly. The reverse order is harmless.

Wire @fr-private/payload-plugin-releases for batch content publishing
over `page` + `posts`. Scheduled releases are not set up (no cron
endpoint / schedulerSecret), so the built-in setInterval poller is
disabled via `schedulerInterval: false` — releases publish manually.

Both @fr-private plugins now live behind two dedicated files instead of
being referenced throughout the app:

- `lib/plugins/private.ts` groups their registrations and exports
  `privatePlugins`, spread into the plugin list.
- `lib/plugins/visual-editing/client.ts` re-exports the plugin's client
  surface, so layout / Media / RichText no longer import @fr-private
  directly.

The scope is published to a private npm registry, so create-ideal-cms
has to strip it when scaffolding a project. Confining it to two files
lets the CLI swap them wholesale rather than edit component code.
…vate scope

Scaffolded projects were shipping stale and unusable dependencies. Three
independent causes, all removed rather than patched:

Hardcoded version map drifted. `PLUGIN_VERSIONS` was maintained by hand
and had fallen behind — it pinned seo 1.3.0 against a real 1.10.1, plus
stale comments / presets / translator / analytics. `fetchTemplate` now
reads each `packages/payload-plugin-*/package.json` before pruning it and
returns the real name → version map, so the pin cannot drift again.

Hardcoded prune lists leaked source. Plugin dirs and sandbox apps were
listed by name, so anything added later slipped into output. Both are now
matched structurally: every `packages/payload-plugin-*`, and every
`apps/*` except `cms`. This also drops `.claude` / `.agents`, whose
`worktrees/` subdir holds entire extra checkouts of this monorepo.

Private-scope deps broke install. `@fr-private/*` 404s for anyone outside
the org, so `install` failed outright in a scaffolded project. The two
files that confine that scope are now replaced with stubs (inert
VisualEditing components, empty plugin list), the deps are dropped, and
`@fr-private` lines are filtered out of the generated import map and the
app's agent guide. Swapping whole files means the strip cannot
half-apply or mangle a component.

Any `workspace:*` left after the rewrite that is not a kept `@repo/*`
package is now a hard error instead of a broken lockfile downstream.

Verified against a local scaffold: output type-checks (tsgo exit 0), the
stripped import map parses, and no @fr-private imports remain.
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
turbo-cms-kit-payload Ready Ready Preview Aug 7, 2026 10:11am

Request Review

…d .npmrc

The source repo's `.npmrc` carries
`//registry.npmjs.org/:_authToken=${NPM_TOKEN}` plus a registry mapping
for the private scope. Copied into a scaffolded project it sends an empty
token on every registry request, so `install` fails for anyone without
`NPM_TOKEN` set — removing the `@fr-private` dependencies does not help,
because the token line applies to the whole registry.

Both lines are now filtered out. The line-strip step takes per-file
patterns instead of a single scope string, since the token line does not
mention the scope at all, and `@fr-private:registry=` does not match the
dependency-prefix form `@fr-private/`.

Verified against a scaffold fetched from this branch on GitHub: a real
`bun install` with NPM_TOKEN unset resolves 1193 packages with no auth
error, and the installed project passes `check-types` (tsgo exit 0).
`;

/** Replaces `apps/cms/src/lib/plugins/visual-editing/client.ts`. */
const VISUAL_EDITING_CLIENT_STUB = `"use client";

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.

The stub adds a "use client" boundary that the file it replaces does not have — scaffolded apps will crash at render.

apps/cms/src/lib/plugins/visual-editing/client.ts is a plain re-export module with no "use client" directive (the directive lives inside the package's individual component files). That is what lets Server Components dot into the exported object.

The stub puts "use client" at the top, which turns the whole module into a client boundary. Both consumers are Server Components:

  • app/(frontend)/[locale]/layout.tsx:67-72 does <VisualEditing.Provider> / .Toggle / .Overlay → React throws "Cannot access VisualEditing.Provider on the server. You cannot dot into a client module from a Server Component."
  • components/shared/Media/index.tsx:35 and RichText/index.tsx:105 call withVisualEditingPath(...) during server render → "Attempted to call withVisualEditingPath() from the server but withVisualEditingPath is on the client."

tsgo --noEmit cannot see any of this, and the PR explicitly notes next build was not run on the scaffolded project — so the verification performed would not have caught it. Every scaffolded project's frontend is broken at runtime.

Drop the directive so the stub matches the surface of the file it replaces (Overlay/Provider/Toggle are pass-throughs and withVisualEditingPath is a pure function — none of them need the client).

Suggested change
const VISUAL_EDITING_CLIENT_STUB = `"use client";
const VISUAL_EDITING_CLIENT_STUB = `import type { ReactNode } from "react";

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.

Comment thread apps/cms/package.json
"@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.

@@ -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");)

try {
contents = await readFile(file, "utf-8");
} catch {
return;

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.

Silent return on a missing strip target reintroduces exactly the failure this PR exists to fix.

If apps/cms/src/app/(payload)/admin/importMap.js is ever moved or renamed (Payload regenerates it; the (payload) route group is not stable across upgrades), readFile throws, this catch swallows it, and the scaffolded project ships an importMap.js that still imports @fr-private/payload-plugin-releases/client — with the dependency already deleted from package.json. The user gets a module-not-found at build time and no clue why.

The same applies to .npmrc: if the filename changes the _authToken line survives and install fails for anyone without NPM_TOKEN, which is the headline bug of this PR.

This directly contradicts the "Fail loud" principle applied a few lines below to unresolved workspace:* deps. A missing importMap.js/.npmrc should throw; only genuinely-optional entries should be tolerated, and then explicitly (e.g. an optional: true flag on the LINE_STRIP_FILES entry).

async function stripPrivateScopePlugins(targetDir: string): Promise<void> {
await Promise.all(
Object.entries(PRIVATE_PLUGIN_STUBS).map(([path, contents]) =>
writeFile(join(targetDir, path), contents)

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.

Stub files are written blind — nothing verifies the file being replaced actually exists.

writeFile creates the target if it is absent (as long as the parent dir exists). So if apps/cms/src/lib/plugins/private.ts is ever renamed or moved, this writes a brand-new orphan file at the old path while the real file keeps its import { contentReleasesPlugin } from "@fr-private/..." — producing a scaffolded project that fails to install, with zero diagnostics.

If instead the parent directory disappears, writeFile throws a bare ENOENT: ... open '.../visual-editing/client.ts', which surfaces to the user as the generic "Configuration failed" spinner message.

Both are silent-drift failure modes for a mechanism whose entire correctness rests on two hardcoded paths staying in sync with apps/cms. Stat each path first and throw a named error if it is missing — the same "fail loud" treatment given to unresolved workspace deps below.

// registered component.
{ path: "apps/cms/src/app/(payload)/admin/importMap.js", patterns: [STRIPPED_SCOPE] },
// The plugin list in the app's agent guide.
{ path: "apps/cms/CLAUDE.md", patterns: [STRIPPED_SCOPE] },

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.

The CLAUDE.md strip is whole-line, so its correctness depends on prose formatting — and this PR's own paragraph is one sentence away from breaking it.

The paragraph added at apps/cms/CLAUDE.md:117 is three sentences on one physical line. It survives the strip cleanly only because all three sentences share that line.

Reformat it per the repo/user convention (~/.claude/CLAUDE.md: "When writing or substantially editing long Markdown files, put each full sentence on its own line") and the strip removes only the first sentence, leaving orphaned prose in the scaffolded output:

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.

…in a project where those files have already been replaced and no longer mention any private scope. Confusing at best.

A whole-line filter is the wrong depth for prose. Either fence the private-scope docs in an explicit marker block (<!-- create-ideal-cms:strip --><!-- /create-ideal-cms:strip -->) and drop the block, or use the same whole-file-swap approach already used for private.ts — the strategy this file's own header comment argues for.

// 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.

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.

return (await readdir(join(targetDir, "apps"), { withFileTypes: true }))
.filter((e) => e.isDirectory() && 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.

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)

Comment thread apps/cms/CLAUDE.md
- `@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant