Aligns tooling packages with the core-* naming convention used by all other foundation packages (core-shared, core-cms, core-api, core-trpc, core-ui). Updates ~50 files: package.json names, devDependencies, tsconfig extends, eslint.config imports, vitest.config imports, AGENTS.md references, and the boundaries plugin patterns to match the new paths. The tooling-specific patterns in boundaries/elements are now ordered BEFORE the broader core-* pattern to ensure correct first-match-wins behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
40 KiB
Vertical Refactor — Plan 1: Foundation
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Scaffold the five core-* packages, populate core-shared with generic primitives + tRPC plumbing, lift the Payload config into a stub core-cms package, and repoint apps/cms at it — leaving pnpm install, typecheck, and the CMS dev server all green at the end.
Architecture: Three sequential phases. Phase 1 creates empty package skeletons (no code yet) and updates path aliases. Phase 2 fills core-shared with reusable Payload primitives (slug field, SEO fields, CTA block, access helpers, hooks) and tRPC init/context. Phase 3 moves the Payload config into core-cms (with empty collection/global arrays), updates apps/cms to import from the new package, and verifies the admin UI still boots.
Tech Stack: pnpm workspaces, Turborepo, TypeScript 5.8, Vitest 3.x, Payload 3.14, tRPC 11, Zod 3.
Plan position: This is plan 1 of 4 in the vertical-refactor sequence:
- Plan 1 (this doc): Foundation — Phases 1-3
- Plan 2: Feature migrations — Phases 4-5 (blog canonical, then auth + marketing-pages + navigation + media)
- Plan 3: App + UI integration — Phases 6-7 (core-trpc, route handlers, example pages, core-ui migration)
- Plan 4: Cleanup, enforcement, e2e, docs — Phases 8-11
Spec reference: docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md
File Structure (this plan creates/modifies)
Create:
tsconfig.base.json(root — does not exist yet)packages/core-shared/{package.json,tsconfig.json,turbo.json,vitest.config.ts}packages/core-shared/src/index.tspackages/core-shared/src/lib/{env.ts,date.ts}+ testspackages/core-shared/src/payload/index.tspackages/core-shared/src/payload/access/is-admin.ts+ testpackages/core-shared/src/payload/fields/{slug-field.ts,seo-fields.ts}+ testspackages/core-shared/src/payload/blocks/cta.ts+ testpackages/core-shared/src/payload/hooks/{set-published-at.ts,slugify-if-missing.ts}+ testspackages/core-shared/src/trpc/{init.ts,context.ts}packages/core-cms/{package.json,tsconfig.json,turbo.json}packages/core-cms/src/{index.ts,payload.config.ts,generated-types.ts}(generated-types is an empty stub initially)packages/core-api/{package.json,tsconfig.json,turbo.json}packages/core-api/src/{index.ts,root.ts}packages/core-trpc/{package.json,tsconfig.json,turbo.json}packages/core-trpc/src/index.tspackages/core-ui/{package.json,tsconfig.json,turbo.json}packages/core-ui/src/index.tspackages/typescript-config/vitest.base.ts(shared vitest config)
Modify:
apps/cms/src/payload.config.ts(change re-export from@repo/cms-core→@repo/core-cms)apps/cms/package.json(add@repo/core-cms, leave@repo/cms-corefor now — removed in Plan 4)
Do NOT touch in this plan:
packages/core/,packages/api/,packages/api-client/,packages/cms-core/,packages/cms-client/,packages/ui/— these continue to exist; deleted in Plan 4.
Phase 1: Scaffold core-* packages
Task 1.1: Add root tsconfig.base.json with path aliases
Files:
-
Create:
tsconfig.base.json -
Step 1: Create the file
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@repo/core-shared": ["packages/core-shared/src/index.ts"],
"@repo/core-shared/payload": ["packages/core-shared/src/payload/index.ts"],
"@repo/core-shared/trpc/init": ["packages/core-shared/src/trpc/init.ts"],
"@repo/core-shared/trpc/context": ["packages/core-shared/src/trpc/context.ts"],
"@repo/core-cms": ["packages/core-cms/src/index.ts"],
"@repo/core-cms/generated-types": ["packages/core-cms/src/generated-types.ts"],
"@repo/core-api": ["packages/core-api/src/index.ts"],
"@repo/core-trpc": ["packages/core-trpc/src/index.ts"],
"@repo/core-ui": ["packages/core-ui/src/index.ts"]
}
}
}
- Step 2: Commit
git add tsconfig.base.json
git commit -m "build: add root tsconfig.base.json with core-* path aliases"
Task 1.2: Scaffold @repo/core-shared package
Files:
-
Create:
packages/core-shared/package.json -
Create:
packages/core-shared/tsconfig.json -
Create:
packages/core-shared/turbo.json -
Create:
packages/core-shared/src/index.ts -
Step 1: Create package.json
{
"name": "@repo/core-shared",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./payload": "./src/payload/index.ts",
"./trpc/init": "./src/trpc/init.ts",
"./trpc/context": "./src/trpc/context.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@trpc/server": "^11.0.0",
"payload": "^3.14.0",
"superjson": "^2.2.1",
"zod": "^3.24.0"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/node": "^22.0.0",
"vitest": "^3.1.0"
}
}
- Step 2: Create tsconfig.json
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
- Step 3: Create turbo.json (with tag)
{
"extends": ["//"],
"tags": ["core"]
}
- Step 4: Create empty index.ts
export {};
- Step 5: Commit
git add packages/core-shared
git commit -m "feat(core-shared): scaffold empty package with exports + tags"
Task 1.3: Scaffold @repo/core-cms package
Files:
-
Create:
packages/core-cms/package.json -
Create:
packages/core-cms/tsconfig.json -
Create:
packages/core-cms/turbo.json -
Create:
packages/core-cms/src/index.ts -
Create:
packages/core-cms/src/generated-types.ts -
Step 1: Create package.json
{
"name": "@repo/core-cms",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./generated-types": "./src/generated-types.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"payload": "^3.14.0",
"@payloadcms/db-postgres": "^3.14.0",
"@payloadcms/richtext-lexical": "^3.14.0"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/node": "^22.0.0"
}
}
- Step 2: Create tsconfig.json
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Note:
lib: ["ES2022", "DOM"]is needed because Payload's types reference DOM globals (e.g.,URL).
- Step 3: Create turbo.json
{
"extends": ["//"],
"tags": ["core"]
}
- Step 4: Create empty index.ts (will export the config in Phase 3)
export {};
- Step 5: Create empty generated-types.ts placeholder
// Generated by Payload — do not edit by hand.
// This file is regenerated by `pnpm generate:types` in apps/cms.
export {};
- Step 6: Commit
git add packages/core-cms
git commit -m "feat(core-cms): scaffold empty package with exports + tags"
Task 1.4: Scaffold @repo/core-api package
Files:
-
Create:
packages/core-api/package.json -
Create:
packages/core-api/tsconfig.json -
Create:
packages/core-api/turbo.json -
Create:
packages/core-api/src/root.ts -
Create:
packages/core-api/src/index.ts -
Step 1: Create package.json
{
"name": "@repo/core-api",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/core-shared": "workspace:*",
"@trpc/server": "^11.0.0"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/node": "^22.0.0"
}
}
- Step 2: Create tsconfig.json
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
- Step 3: Create turbo.json
{
"extends": ["//"],
"tags": ["core"]
}
- Step 4: Create root.ts (empty appRouter that compiles)
import { router } from "@repo/core-shared/trpc/init";
export const appRouter = router({});
export type AppRouter = typeof appRouter;
Note: This depends on
@repo/core-shared/trpc/initwhich will exist after Task 2.13. To make typecheck pass in isolation now, this file imports a not-yet-existing module — but Phase 1 verification (Task 1.7) only runspnpm install, nottypecheck. Typecheck runs after Phase 2 completes.
- Step 5: Create index.ts
export { appRouter, type AppRouter } from "./root";
- Step 6: Commit
git add packages/core-api
git commit -m "feat(core-api): scaffold empty appRouter aggregator"
Task 1.5: Scaffold @repo/core-trpc package
Files:
-
Create:
packages/core-trpc/package.json -
Create:
packages/core-trpc/tsconfig.json -
Create:
packages/core-trpc/turbo.json -
Create:
packages/core-trpc/src/index.ts -
Step 1: Create package.json
{
"name": "@repo/core-trpc",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/core-api": "workspace:*",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"@tanstack/react-query": "^5.66.0",
"react": "^19.0.0",
"superjson": "^2.2.1"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/react": "^19.0.0"
}
}
- Step 2: Create tsconfig.json (jsx for React provider files added in Plan 3)
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"],
"jsx": "preserve"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
- Step 3: Create turbo.json
{
"extends": ["//"],
"tags": ["core"]
}
- Step 4: Create empty index.ts (client + providers added in Plan 3)
export {};
- Step 5: Commit
git add packages/core-trpc
git commit -m "feat(core-trpc): scaffold empty package (client + providers in Plan 3)"
Task 1.6: Scaffold @repo/core-ui package
Files:
-
Create:
packages/core-ui/package.json -
Create:
packages/core-ui/tsconfig.json -
Create:
packages/core-ui/turbo.json -
Create:
packages/core-ui/src/index.ts -
Step 1: Create package.json
{
"name": "@repo/core-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"clsx": "^2.1.1",
"react": "^19.0.0",
"tailwind-merge": "^3.0.0"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/react": "^19.0.0"
}
}
- Step 2: Create tsconfig.json
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"],
"jsx": "preserve"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
- Step 3: Create turbo.json
{
"extends": ["//"],
"tags": ["core"]
}
- Step 4: Create empty index.ts (contents migrated from packages/ui in Plan 3)
export {};
- Step 5: Commit
git add packages/core-ui
git commit -m "feat(core-ui): scaffold empty package (contents migrated in Plan 3)"
Task 1.7: Install + verify Phase 1
- Step 1: Install deps (picks up new workspace packages)
Run: pnpm install
Expected: install completes; new packages registered in workspace; lockfile updated.
- Step 2: Verify all five packages registered
Run: pnpm list --recursive --depth=-1 | grep -E "core-(shared|cms|api|trpc|ui)"
Expected: all five names appear.
- Step 3: Commit lockfile
git add pnpm-lock.yaml
git commit -m "build: update lockfile for new core-* packages"
Phase 1 Gate:
pnpm installgreen. (Typecheck/test deferred until Phase 2 fillscore-shared—core-api/src/root.tsimports a not-yet-existingcore-shared/trpc/initmodule.)
Phase 2: Populate core-shared
Task 2.1: Add shared vitest base config
Files:
-
Create:
packages/typescript-config/vitest.base.ts -
Modify:
packages/typescript-config/package.json -
Step 1: Replace packages/typescript-config/package.json with the version that exposes both
base.jsonandvitest.base.ts
Current contents (verified):
{
"name": "@repo/core-typescript",
"private": true,
"version": "0.0.0"
}
Replace with:
{
"name": "@repo/core-typescript",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
"./base.json": "./base.json",
"./vitest.base": "./vitest.base.ts"
},
"devDependencies": {
"vitest": "^3.1.0"
}
}
- Step 2: Create vitest.base.ts
import { defineConfig } from "vitest/config";
export const baseVitestConfig = defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts", "src/**/*.test.tsx", "tests/**/*.test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "html"],
include: ["src/**"],
exclude: ["src/**/*.test.{ts,tsx}", "src/**/index.ts"],
},
},
});
- Step 3: Install vitest into typescript-config
Run: pnpm install
Expected: vitest devDep installed; lockfile updated.
- Step 4: Commit
git add packages/typescript-config pnpm-lock.yaml
git commit -m "build(typescript-config): add shared vitest base config"
Task 2.2: Add vitest.config.ts to core-shared
Files:
-
Create:
packages/core-shared/vitest.config.ts -
Modify:
packages/core-shared/package.json(add typescript-config to devDeps if not already) -
Step 1: Create vitest.config.ts
import { baseVitestConfig } from "@repo/core-typescript/vitest.base";
export default baseVitestConfig;
- Step 2: Run vitest to confirm config loads (no tests yet — should report 0 tests)
Run: cd packages/core-shared && pnpm vitest run
Expected: "No test files found, exiting with code 1" — that's fine; the config loads.
- Step 3: Commit
git add packages/core-shared/vitest.config.ts
git commit -m "test(core-shared): wire shared vitest base"
Task 2.3: Implement is-admin access helper
Files:
-
Create:
packages/core-shared/src/payload/access/is-admin.ts -
Create:
packages/core-shared/src/payload/access/is-admin.test.ts -
Step 1: Write the failing test
// packages/core-shared/src/payload/access/is-admin.test.ts
import { describe, expect, it } from "vitest";
import { isAdmin } from "./is-admin";
describe("isAdmin", () => {
it("returns true when user role is 'admin'", () => {
expect(isAdmin({ req: { user: { role: "admin" } } })).toBe(true);
});
it("returns false when user role is not 'admin'", () => {
expect(isAdmin({ req: { user: { role: "editor" } } })).toBe(false);
});
it("returns false when user has no role", () => {
expect(isAdmin({ req: { user: {} } })).toBe(false);
});
it("returns false when there is no user", () => {
expect(isAdmin({ req: {} })).toBe(false);
});
});
- Step 2: Run test — expect failure
Run: cd packages/core-shared && pnpm vitest run src/payload/access/is-admin.test.ts
Expected: FAIL — "Cannot find module './is-admin'"
- Step 3: Implement
// packages/core-shared/src/payload/access/is-admin.ts
export function isAdmin({
req,
}: {
req: { user?: { role?: string } };
}): boolean {
return req.user?.role === "admin";
}
- Step 4: Run test — expect pass
Run: cd packages/core-shared && pnpm vitest run src/payload/access/is-admin.test.ts
Expected: PASS — 4 tests.
- Step 5: Commit
git add packages/core-shared/src/payload/access
git commit -m "feat(core-shared): add isAdmin access helper"
Task 2.4: Implement slug-field
Files:
-
Create:
packages/core-shared/src/payload/fields/slug-field.ts -
Create:
packages/core-shared/src/payload/fields/slug-field.test.ts -
Step 1: Write the failing test
// packages/core-shared/src/payload/fields/slug-field.test.ts
import { describe, expect, it } from "vitest";
import { slugField } from "./slug-field";
describe("slugField", () => {
it("returns a Payload Field with default name 'slug'", () => {
const field = slugField();
expect(field.name).toBe("slug");
expect(field.type).toBe("text");
expect(field.required).toBe(true);
expect(field.unique).toBe(true);
expect(field.index).toBe(true);
});
it("accepts a custom field name", () => {
const field = slugField("permalink");
expect(field.name).toBe("permalink");
});
});
- Step 2: Run — expect failure
Run: cd packages/core-shared && pnpm vitest run src/payload/fields/slug-field.test.ts
Expected: FAIL — "Cannot find module './slug-field'"
- Step 3: Implement
// packages/core-shared/src/payload/fields/slug-field.ts
import type { Field } from "payload";
export function slugField(name = "slug"): Field {
return {
name,
type: "text",
required: true,
unique: true,
index: true,
};
}
- Step 4: Run — expect pass
Run: cd packages/core-shared && pnpm vitest run src/payload/fields/slug-field.test.ts
Expected: PASS — 2 tests.
- Step 5: Commit
git add packages/core-shared/src/payload/fields/slug-field.ts packages/core-shared/src/payload/fields/slug-field.test.ts
git commit -m "feat(core-shared): add slugField helper"
Task 2.5: Implement seo-fields
Files:
-
Create:
packages/core-shared/src/payload/fields/seo-fields.ts -
Create:
packages/core-shared/src/payload/fields/seo-fields.test.ts -
Step 1: Write the failing test
// packages/core-shared/src/payload/fields/seo-fields.test.ts
import { describe, expect, it } from "vitest";
import { seoFields } from "./seo-fields";
describe("seoFields", () => {
it("is a group field named 'seo'", () => {
expect(seoFields.name).toBe("seo");
expect(seoFields.type).toBe("group");
});
it("contains required title and optional description", () => {
if (seoFields.type !== "group") {
throw new Error("seoFields must be a group");
}
const fieldNames = seoFields.fields.map((f) =>
"name" in f ? f.name : null,
);
expect(fieldNames).toContain("title");
expect(fieldNames).toContain("description");
const titleField = seoFields.fields.find(
(f) => "name" in f && f.name === "title",
);
expect(titleField && "required" in titleField && titleField.required).toBe(
true,
);
});
});
- Step 2: Run — expect failure
Run: cd packages/core-shared && pnpm vitest run src/payload/fields/seo-fields.test.ts
Expected: FAIL — "Cannot find module './seo-fields'"
- Step 3: Implement
// packages/core-shared/src/payload/fields/seo-fields.ts
import type { Field } from "payload";
export const seoFields: Field = {
name: "seo",
type: "group",
fields: [
{ name: "title", type: "text", required: true },
{ name: "description", type: "textarea" },
],
};
- Step 4: Run — expect pass
Run: cd packages/core-shared && pnpm vitest run src/payload/fields/seo-fields.test.ts
Expected: PASS — 2 tests.
- Step 5: Commit
git add packages/core-shared/src/payload/fields/seo-fields.ts packages/core-shared/src/payload/fields/seo-fields.test.ts
git commit -m "feat(core-shared): add seoFields group"
Task 2.6: Implement cta block
Files:
-
Create:
packages/core-shared/src/payload/blocks/cta.ts -
Create:
packages/core-shared/src/payload/blocks/cta.test.ts -
Step 1: Write the failing test
// packages/core-shared/src/payload/blocks/cta.test.ts
import { describe, expect, it } from "vitest";
import { cta } from "./cta";
describe("cta block", () => {
it("has slug 'cta'", () => {
expect(cta.slug).toBe("cta");
});
it("requires title, buttonLabel, and href", () => {
const fieldNames = cta.fields.map((f) => ("name" in f ? f.name : null));
expect(fieldNames).toEqual(["title", "buttonLabel", "href"]);
cta.fields.forEach((f) => {
if ("required" in f) expect(f.required).toBe(true);
});
});
});
- Step 2: Run — expect failure
Run: cd packages/core-shared && pnpm vitest run src/payload/blocks/cta.test.ts
Expected: FAIL — "Cannot find module './cta'"
- Step 3: Implement
// packages/core-shared/src/payload/blocks/cta.ts
import type { Block } from "payload";
export const cta: Block = {
slug: "cta",
fields: [
{ name: "title", type: "text", required: true },
{ name: "buttonLabel", type: "text", required: true },
{ name: "href", type: "text", required: true },
],
};
- Step 4: Run — expect pass
Run: cd packages/core-shared && pnpm vitest run src/payload/blocks/cta.test.ts
Expected: PASS — 2 tests.
- Step 5: Commit
git add packages/core-shared/src/payload/blocks
git commit -m "feat(core-shared): add cta block"
Task 2.7: Implement set-published-at hook
Files:
-
Create:
packages/core-shared/src/payload/hooks/set-published-at.ts -
Create:
packages/core-shared/src/payload/hooks/set-published-at.test.ts -
Step 1: Write the failing test
// packages/core-shared/src/payload/hooks/set-published-at.test.ts
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { setPublishedAt } from "./set-published-at";
describe("setPublishedAt", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-04T12:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("sets publishedAt to now when status is published and publishedAt is missing", () => {
const result = setPublishedAt({ data: { status: "published" } });
expect(result?.publishedAt).toBe("2026-05-04T12:00:00.000Z");
});
it("does not overwrite an existing publishedAt", () => {
const result = setPublishedAt({
data: { status: "published", publishedAt: "2025-01-01T00:00:00.000Z" },
});
expect(result?.publishedAt).toBe("2025-01-01T00:00:00.000Z");
});
it("does not set publishedAt when status is not published", () => {
const result = setPublishedAt({ data: { status: "draft" } });
expect(result?.publishedAt).toBeUndefined();
});
it("returns data unchanged when data is missing", () => {
expect(setPublishedAt({})).toBeUndefined();
});
});
- Step 2: Run — expect failure
Run: cd packages/core-shared && pnpm vitest run src/payload/hooks/set-published-at.test.ts
Expected: FAIL — "Cannot find module './set-published-at'"
- Step 3: Implement
// packages/core-shared/src/payload/hooks/set-published-at.ts
export function setPublishedAt({
data,
}: {
data?: { status?: string; publishedAt?: string | null };
}) {
if (!data) return data;
if (data.status === "published" && !data.publishedAt) {
data.publishedAt = new Date().toISOString();
}
return data;
}
- Step 4: Run — expect pass
Run: cd packages/core-shared && pnpm vitest run src/payload/hooks/set-published-at.test.ts
Expected: PASS — 4 tests.
- Step 5: Commit
git add packages/core-shared/src/payload/hooks/set-published-at.ts packages/core-shared/src/payload/hooks/set-published-at.test.ts
git commit -m "feat(core-shared): add setPublishedAt hook"
Task 2.8: Implement slugify-if-missing hook
Files:
-
Create:
packages/core-shared/src/payload/hooks/slugify-if-missing.ts -
Create:
packages/core-shared/src/payload/hooks/slugify-if-missing.test.ts -
Step 1: Write the failing test
// packages/core-shared/src/payload/hooks/slugify-if-missing.test.ts
import { describe, expect, it } from "vitest";
import { slugifyIfMissing } from "./slugify-if-missing";
describe("slugifyIfMissing", () => {
it("derives slug from title on create when slug is empty", () => {
const result = slugifyIfMissing({
data: { title: "Hello World" },
operation: "create",
});
expect(result?.slug).toBe("hello-world");
});
it("does not overwrite an existing slug", () => {
const result = slugifyIfMissing({
data: { title: "New Title", slug: "kept-slug" },
operation: "create",
});
expect(result?.slug).toBe("kept-slug");
});
it("does nothing on update", () => {
const result = slugifyIfMissing({
data: { title: "Hello World" },
operation: "update",
});
expect(result?.slug).toBeUndefined();
});
it("strips non-alphanumerics and trims hyphens", () => {
const result = slugifyIfMissing({
data: { title: " Hello, World!! 2026 " },
operation: "create",
});
expect(result?.slug).toBe("hello-world-2026");
});
it("returns data unchanged when title is missing", () => {
const result = slugifyIfMissing({
data: {},
operation: "create",
});
expect(result?.slug).toBeUndefined();
});
});
- Step 2: Run — expect failure
Run: cd packages/core-shared && pnpm vitest run src/payload/hooks/slugify-if-missing.test.ts
Expected: FAIL — "Cannot find module './slugify-if-missing'"
- Step 3: Implement
// packages/core-shared/src/payload/hooks/slugify-if-missing.ts
export function slugifyIfMissing({
data,
operation,
}: {
data?: { title?: string; slug?: string };
operation?: string;
}) {
if (!data) return data;
if (operation !== "create") return data;
if (data.slug) return data;
if (!data.title) return data;
data.slug = data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return data;
}
- Step 4: Run — expect pass
Run: cd packages/core-shared && pnpm vitest run src/payload/hooks/slugify-if-missing.test.ts
Expected: PASS — 5 tests.
- Step 5: Commit
git add packages/core-shared/src/payload/hooks/slugify-if-missing.ts packages/core-shared/src/payload/hooks/slugify-if-missing.test.ts
git commit -m "feat(core-shared): add slugifyIfMissing hook"
Task 2.9: Implement env helper
Files:
-
Create:
packages/core-shared/src/lib/env.ts -
Create:
packages/core-shared/src/lib/env.test.ts -
Step 1: Write the failing test
// packages/core-shared/src/lib/env.test.ts
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { requireEnv } from "./env";
describe("requireEnv", () => {
const originalEnv = process.env.SOME_KEY;
afterEach(() => {
if (originalEnv === undefined) delete process.env.SOME_KEY;
else process.env.SOME_KEY = originalEnv;
});
it("returns the value when set", () => {
process.env.SOME_KEY = "value";
expect(requireEnv("SOME_KEY")).toBe("value");
});
it("throws when missing", () => {
delete process.env.SOME_KEY;
expect(() => requireEnv("SOME_KEY")).toThrow(
/Missing required env var: SOME_KEY/,
);
});
it("throws when empty string", () => {
process.env.SOME_KEY = "";
expect(() => requireEnv("SOME_KEY")).toThrow();
});
});
- Step 2: Run — expect failure
Run: cd packages/core-shared && pnpm vitest run src/lib/env.test.ts
Expected: FAIL — "Cannot find module './env'"
- Step 3: Implement
// packages/core-shared/src/lib/env.ts
export function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required env var: ${name}`);
}
return value;
}
- Step 4: Run — expect pass
Run: cd packages/core-shared && pnpm vitest run src/lib/env.test.ts
Expected: PASS — 3 tests.
- Step 5: Commit
git add packages/core-shared/src/lib/env.ts packages/core-shared/src/lib/env.test.ts
git commit -m "feat(core-shared): add requireEnv helper"
Task 2.10: Implement date helper
Files:
-
Create:
packages/core-shared/src/lib/date.ts -
Create:
packages/core-shared/src/lib/date.test.ts -
Step 1: Write the failing test
// packages/core-shared/src/lib/date.test.ts
import { describe, expect, it } from "vitest";
import { toIsoString } from "./date";
describe("toIsoString", () => {
it("converts a Date to ISO string", () => {
const d = new Date("2026-05-04T12:00:00.000Z");
expect(toIsoString(d)).toBe("2026-05-04T12:00:00.000Z");
});
it("passes through an existing ISO string", () => {
expect(toIsoString("2026-05-04T12:00:00.000Z")).toBe(
"2026-05-04T12:00:00.000Z",
);
});
it("returns null for null input", () => {
expect(toIsoString(null)).toBeNull();
});
it("returns null for undefined input", () => {
expect(toIsoString(undefined)).toBeNull();
});
});
- Step 2: Run — expect failure
Run: cd packages/core-shared && pnpm vitest run src/lib/date.test.ts
Expected: FAIL — "Cannot find module './date'"
- Step 3: Implement
// packages/core-shared/src/lib/date.ts
export function toIsoString(input: Date | string | null | undefined): string | null {
if (input === null || input === undefined) return null;
if (input instanceof Date) return input.toISOString();
return input;
}
- Step 4: Run — expect pass
Run: cd packages/core-shared && pnpm vitest run src/lib/date.test.ts
Expected: PASS — 4 tests.
- Step 5: Commit
git add packages/core-shared/src/lib/date.ts packages/core-shared/src/lib/date.test.ts
git commit -m "feat(core-shared): add toIsoString helper"
Task 2.11: Create payload barrel export
Files:
-
Create:
packages/core-shared/src/payload/index.ts -
Step 1: Create barrel
// packages/core-shared/src/payload/index.ts
export { isAdmin } from "./access/is-admin";
export { slugField } from "./fields/slug-field";
export { seoFields } from "./fields/seo-fields";
export { cta } from "./blocks/cta";
export { setPublishedAt } from "./hooks/set-published-at";
export { slugifyIfMissing } from "./hooks/slugify-if-missing";
- Step 2: Verify import path resolves
Run: cd packages/core-shared && pnpm typecheck
Expected: PASS — no errors.
- Step 3: Commit
git add packages/core-shared/src/payload/index.ts
git commit -m "feat(core-shared): add payload barrel export"
Task 2.12: Implement trpc init
Files:
-
Create:
packages/core-shared/src/trpc/init.ts -
Step 1: Implement (no test — exercised indirectly through routers in Plan 2)
// packages/core-shared/src/trpc/init.ts
import { initTRPC } from "@trpc/server";
import superjson from "superjson";
const t = initTRPC.create({
transformer: superjson,
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;
- Step 2: Verify it compiles
Run: cd packages/core-shared && pnpm typecheck
Expected: PASS.
- Step 3: Commit
git add packages/core-shared/src/trpc/init.ts
git commit -m "feat(core-shared): add tRPC init with superjson"
Task 2.13: Implement trpc context
Files:
-
Create:
packages/core-shared/src/trpc/context.ts -
Step 1: Implement
// packages/core-shared/src/trpc/context.ts
export async function createTrpcContext() {
return {};
}
export type TrpcContext = Awaited<ReturnType<typeof createTrpcContext>>;
- Step 2: Verify it compiles
Run: cd packages/core-shared && pnpm typecheck
Expected: PASS.
- Step 3: Commit
git add packages/core-shared/src/trpc/context.ts
git commit -m "feat(core-shared): add tRPC context factory"
Task 2.14: Wire root index.ts and verify all tests
Files:
-
Modify:
packages/core-shared/src/index.ts -
Step 1: Replace empty index with re-exports
// packages/core-shared/src/index.ts
export { requireEnv } from "./lib/env";
export { toIsoString } from "./lib/date";
Note: Payload primitives are accessed via
@repo/core-shared/payloadsubpath; tRPC via/trpc/initand/trpc/context. The root export is just the lib helpers.
- Step 2: Run all core-shared tests
Run: cd packages/core-shared && pnpm test
Expected: PASS — 26 tests across 8 test files: is-admin (4) + slug-field (2) + seo-fields (2) + cta (2) + set-published-at (4) + slugify-if-missing (5) + env (3) + date (4). Use pnpm vitest run --reporter=verbose for a full list.
- Step 3: Run typecheck across the whole repo
Run: pnpm typecheck
Expected: PASS (now that core-shared/trpc/init exists, core-api/src/root.ts from Task 1.4 resolves).
- Step 4: Commit
git add packages/core-shared/src/index.ts
git commit -m "feat(core-shared): wire root index.ts barrel"
Phase 2 Gate:
pnpm test --filter @repo/core-sharedgreen;pnpm typecheckgreen across the whole repo.
Phase 3: Populate core-cms stub + repoint apps/cms
Task 3.1: Create core-cms payload.config.ts (stub — empty arrays)
Files:
-
Create:
packages/core-cms/src/payload.config.ts -
Step 1: Implement
// packages/core-cms/src/payload.config.ts
import { buildConfig } from "payload";
import { postgresAdapter } from "@payloadcms/db-postgres";
import { lexicalEditor } from "@payloadcms/richtext-lexical";
import path from "node:path";
import { fileURLToPath } from "node:url";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
export default buildConfig({
editor: lexicalEditor(),
collections: [],
globals: [],
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
db: postgresAdapter({
pool: {
connectionString:
process.env.DATABASE_URL ||
"postgresql://postgres:postgres@localhost:5432/template",
},
}),
typescript: {
outputFile: path.resolve(dirname, "generated-types.ts"),
},
});
Note:
collections: []andglobals: []initially. Plan 2 wires in feature-owned@repo/<feature>/cmsexports here.
- Step 2: Update index.ts to re-export the config
// packages/core-cms/src/index.ts
export { default } from "./payload.config";
- Step 3: Verify it compiles
Run: cd packages/core-cms && pnpm typecheck
Expected: PASS.
- Step 4: Commit
git add packages/core-cms/src
git commit -m "feat(core-cms): add stub payload.config (empty collections/globals)"
Task 3.2: Repoint apps/cms to @repo/core-cms
Files:
-
Modify:
apps/cms/src/payload.config.ts -
Modify:
apps/cms/package.json -
Step 1: Update the re-export in apps/cms/src/payload.config.ts
// apps/cms/src/payload.config.ts
// Re-export Payload config from @repo/core-cms.
// This file exists so @payload-config resolves correctly in the CMS app.
export { default } from "@repo/core-cms";
- Step 2: Update apps/cms/package.json — add @repo/core-cms dependency
Add to dependencies block (keep @repo/cms-core for now — removed in Plan 4):
"@repo/core-cms": "workspace:*",
- Step 3: Install
Run: pnpm install
Expected: install completes; new dep added.
- Step 4: Verify cms typecheck
Run: pnpm typecheck --filter @repo/cms
Expected: PASS.
- Step 5: Commit
git add apps/cms/src/payload.config.ts apps/cms/package.json pnpm-lock.yaml
git commit -m "feat(cms): repoint @payload-config from cms-core to core-cms"
Task 3.3: Boot apps/cms against new core-cms config (smoke test)
- Step 1: Ensure Postgres is running
Run: docker compose ps postgres
Expected: postgres container healthy. If not: docker compose up -d postgres.
- Step 2: Start the cms dev server in the background
Run: pnpm dev --filter @repo/cms
This starts Next.js on port 3001. Wait ~10s for "Ready" / "compiled successfully".
- Step 3: Verify the admin UI responds
Run: curl -sf http://localhost:3001/admin -o /dev/null && echo OK
Expected: OK (the admin UI responds with HTML — even if the empty-collections config means there's nothing to manage yet, the admin shell loads).
- Step 4: Stop the dev server
Stop the pnpm dev process (Ctrl+C if foreground, or kill the background job).
- Step 5: No commit needed for the smoke test (no file changes)
Task 3.4: Verify Payload type generation against the stub
- Step 1: Run type generation
Run: cd apps/cms && pnpm generate:types
Expected: writes a new generated-types.ts somewhere (per the config's outputFile: path.resolve(dirname, "generated-types.ts") — this puts the file in packages/core-cms/src/generated-types.ts).
- Step 2: Inspect the generated file
Run: head -30 packages/core-cms/src/generated-types.ts
Expected: a valid TypeScript file (probably mostly empty or with just a Config interface and Auth types since Users isn't in the empty arrays — Payload may include built-in user/preference types).
- Step 3: Verify the file typechecks
Run: pnpm typecheck --filter @repo/core-cms
Expected: PASS.
- Step 4: Commit the regenerated types
git add packages/core-cms/src/generated-types.ts
git commit -m "feat(core-cms): generate initial (empty) Payload types"
Phase 3 Gate:
pnpm dev --filter @repo/cmsboots and serves/admin;pnpm generate:typessucceeds;pnpm typecheck && pnpm test --filter @repo/core-sharedgreen.
Final Verification
- Step 1: Repo-wide typecheck
Run: pnpm typecheck
Expected: PASS across all packages (old @repo/core, @repo/api, etc. still exist and still typecheck — they're untouched).
- Step 2: Repo-wide tests
Run: pnpm test
Expected: PASS — core-shared tests pass; old @repo/core tests still pass (unchanged).
- Step 3: Lint
Run: pnpm lint
Expected: PASS (no boundary rules added yet — those land in Plan 4).
- Step 4: Build
Run: pnpm build
Expected: PASS.
- Step 5: Final summary commit (optional — only if any cleanup edits were needed)
If everything is already committed phase-by-phase, no extra commit needed.
Plan 1 Done Criteria
- All 26 tests in
core-sharedpass (8 test files) pnpm typecheckgreen across whole repopnpm dev --filter @repo/cmsserves/adminagainst new@repo/core-cmsconfigpnpm generate:typessucceeds- Five new packages (
core-shared,core-cms,core-api,core-trpc,core-ui) registered in workspace tsconfig.base.jsonexists at repo root with all five@repo/core-*aliases- No deletions yet (old packages still intact for Plan 2-4 to drain)
Next plan: Plan 2 — Feature migrations. Migrate the blog feature end-to-end as the canonical example, then auth, marketing-pages, navigation, media. Each feature follows the same template (entities → application → infrastructure → di → interface-adapters/controllers → integrations/cms + integrations/api → ui), composes its /cms export into core-cms and its /api export into core-api.