# 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.ts` - `packages/core-shared/src/lib/{env.ts,date.ts}` + tests - `packages/core-shared/src/payload/index.ts` - `packages/core-shared/src/payload/access/is-admin.ts` + test - `packages/core-shared/src/payload/fields/{slug-field.ts,seo-fields.ts}` + tests - `packages/core-shared/src/payload/blocks/cta.ts` + test - `packages/core-shared/src/payload/hooks/{set-published-at.ts,slugify-if-missing.ts}` + tests - `packages/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.ts` - `packages/core-ui/{package.json,tsconfig.json,turbo.json}` - `packages/core-ui/src/index.ts` - `packages/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-core` for 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** ```json { "$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** ```bash 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** ```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/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", "@types/node": "^22.0.0", "vitest": "^3.1.0" } } ``` - [ ] **Step 2: Create tsconfig.json** ```json { "extends": "@repo/typescript-config/base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ``` - [ ] **Step 3: Create turbo.json (with tag)** ```json { "extends": ["//"], "tags": ["core"] } ``` - [ ] **Step 4: Create empty index.ts** ```typescript export {}; ``` - [ ] **Step 5: Commit** ```bash 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** ```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/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", "@types/node": "^22.0.0" } } ``` - [ ] **Step 2: Create tsconfig.json** ```json { "extends": "@repo/typescript-config/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** ```json { "extends": ["//"], "tags": ["core"] } ``` - [ ] **Step 4: Create empty index.ts** (will export the config in Phase 3) ```typescript export {}; ``` - [ ] **Step 5: Create empty generated-types.ts placeholder** ```typescript // Generated by Payload — do not edit by hand. // This file is regenerated by `pnpm generate:types` in apps/cms. export {}; ``` - [ ] **Step 6: Commit** ```bash 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** ```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/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", "@types/node": "^22.0.0" } } ``` - [ ] **Step 2: Create tsconfig.json** ```json { "extends": "@repo/typescript-config/base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ``` - [ ] **Step 3: Create turbo.json** ```json { "extends": ["//"], "tags": ["core"] } ``` - [ ] **Step 4: Create root.ts (empty appRouter that compiles)** ```typescript 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/init` which 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 runs `pnpm install`, not `typecheck`. Typecheck runs after Phase 2 completes. - [ ] **Step 5: Create index.ts** ```typescript export { appRouter, type AppRouter } from "./root"; ``` - [ ] **Step 6: Commit** ```bash 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** ```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/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", "@types/react": "^19.0.0" } } ``` - [ ] **Step 2: Create tsconfig.json (jsx for React provider files added in Plan 3)** ```json { "extends": "@repo/typescript-config/base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", "lib": ["ES2022", "DOM"], "jsx": "preserve" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ``` - [ ] **Step 3: Create turbo.json** ```json { "extends": ["//"], "tags": ["core"] } ``` - [ ] **Step 4: Create empty index.ts (client + providers added in Plan 3)** ```typescript export {}; ``` - [ ] **Step 5: Commit** ```bash 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** ```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/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", "@types/react": "^19.0.0" } } ``` - [ ] **Step 2: Create tsconfig.json** ```json { "extends": "@repo/typescript-config/base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", "lib": ["ES2022", "DOM"], "jsx": "preserve" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ``` - [ ] **Step 3: Create turbo.json** ```json { "extends": ["//"], "tags": ["core"] } ``` - [ ] **Step 4: Create empty index.ts (contents migrated from packages/ui in Plan 3)** ```typescript export {}; ``` - [ ] **Step 5: Commit** ```bash 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** ```bash git add pnpm-lock.yaml git commit -m "build: update lockfile for new core-* packages" ``` > **Phase 1 Gate:** `pnpm install` green. (Typecheck/test deferred until Phase 2 fills `core-shared` — `core-api/src/root.ts` imports a not-yet-existing `core-shared/trpc/init` module.) --- ## 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.json` and `vitest.base.ts`** Current contents (verified): ```json { "name": "@repo/typescript-config", "private": true, "version": "0.0.0" } ``` Replace with: ```json { "name": "@repo/typescript-config", "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** ```typescript 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** ```bash 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** ```typescript import { baseVitestConfig } from "@repo/typescript-config/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** ```bash 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** ```typescript // 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** ```typescript // 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** ```bash 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** ```typescript // 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** ```typescript // 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** ```bash 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** ```typescript // 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** ```typescript // 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** ```bash 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** ```typescript // 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** ```typescript // 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** ```bash 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** ```typescript // 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** ```typescript // 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** ```bash 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** ```typescript // 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** ```typescript // 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** ```bash 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** ```typescript // 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** ```typescript // 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** ```bash 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** ```typescript // 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** ```typescript // 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** ```bash 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** ```typescript // 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** ```bash 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) ```typescript // 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** ```bash 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** ```typescript // packages/core-shared/src/trpc/context.ts export async function createTrpcContext() { return {}; } export type TrpcContext = Awaited>; ``` - [ ] **Step 2: Verify it compiles** Run: `cd packages/core-shared && pnpm typecheck` Expected: PASS. - [ ] **Step 3: Commit** ```bash 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** ```typescript // 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/payload` subpath; tRPC via `/trpc/init` and `/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** ```bash 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-shared` green; `pnpm typecheck` green 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** ```typescript // 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: []` and `globals: []` initially. Plan 2 wires in feature-owned `@repo//cms` exports here. - [ ] **Step 2: Update index.ts to re-export the config** ```typescript // 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** ```bash 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** ```typescript // 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): ```json "@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** ```bash 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** ```bash 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/cms` boots and serves `/admin`; `pnpm generate:types` succeeds; `pnpm typecheck && pnpm test --filter @repo/core-shared` green. --- ## 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-shared` pass (8 test files) - [ ] `pnpm typecheck` green across whole repo - [ ] `pnpm dev --filter @repo/cms` serves `/admin` against new `@repo/core-cms` config - [ ] `pnpm generate:types` succeeds - [ ] Five new packages (`core-shared`, `core-cms`, `core-api`, `core-trpc`, `core-ui`) registered in workspace - [ ] `tsconfig.base.json` exists 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`.