feat(create-ideal-cms): make scaffolded projects installable - #95
feat(create-ideal-cms): make scaffolded projects installable#95dogfrogfog wants to merge 3 commits into
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…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"; |
There was a problem hiding this comment.
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-72does<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:35andRichText/index.tsx:105callwithVisualEditingPath(...)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).
| const VISUAL_EDITING_CLIENT_STUB = `"use client"; | |
| const VISUAL_EDITING_CLIENT_STUB = `import type { ReactNode } from "react"; |
| skipGlobals: ["site-settings"], | ||
| }), | ||
|
|
||
| contentReleasesPlugin({ |
There was a problem hiding this comment.
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.
| "@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", |
There was a problem hiding this comment.
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); | |||
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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] }, |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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: [ |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
| .filter((e) => e.isDirectory() && e.name !== KEPT_APP) | |
| .filter((e) => (e.isDirectory() || e.isSymbolicLink()) && e.name !== KEPT_APP) |
| - `@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. |
There was a problem hiding this comment.
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.
Makes
npx @focus-reactive/create-ideal-cmsproduce a project that actually installs, and wires the content-releases plugin intoapps/cms.Why
A scaffolded project was shipping three problems at once:
PLUGIN_VERSIONSintransforms.tswas maintained by hand and had drifted badly — it pinnedpayload-plugin-seo@1.3.0against a real1.10.1, plus stalecomments,presets,translator,analytics.payload-plugin-analytics,payload-plugin-seoandapps/multi-tenancy-demowere all being copied in.apps/cmsdepends on@fr-private/*, a private npm scope that 404s for anyone outside the org, soinstallfailed outright in a scaffolded project.What changed
create-ideal-cmsVersions are derived, not maintained.
fetchTemplatereads eachpackages/payload-plugin-*/package.jsonbefore 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 everyapps/*exceptcms. 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
VisualEditingcomponents, empty plugin list), and@fr-privatelines 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
.npmrccarries//registry.npmjs.org/:_authToken=${NPM_TOKEN}. Copied into a scaffolded project, that sends an empty token on every registry request — install fails for anyone withoutNPM_TOKENset, private deps or not. That line and the@fr-privateregistry 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/cmsWires
@fr-private/payload-plugin-releasesfor batch content publishing overpage+posts. Scheduled releases are not set up (no cron endpoint /schedulerSecret), so the built-insetIntervalpoller is disabled viaschedulerInterval: false— releases publish manually.Both
@fr-privateplugins now sit behind two dedicated files instead of being referenced across the app:lib/plugins/private.ts— groups their registrations, exportsprivatePlugins, spread into the plugin list.lib/plugins/visual-editing/client.ts— re-exports the client surface, solayout/Media/RichTextno longer import@fr-privatedirectly.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 installwithNPM_TOKENunset: 1193 packages resolved, no auth error — this is the failure the PR exists to fixcheck-types—tsgo --noEmitexit 0, so the stubs satisfy every call site with real deps presentapps/[cms]+packages/[tailwind-config, typescript-config]— no plugin source, no sandboxesab 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)@fr-privateimports remainimportMap.jsparses —node --checkOKapps/cmscheck-typescleanSame run against
--from-localgives identical output.Not covered
next buildwas not run on the scaffolded project — it needs a liveDATABASE_URL.Release note
apps/cmsis private, so it triggers no release. Thefeat(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.tswhile main's components still import@fr-privatedirectly. The reverse order is harmless.