# 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/core-eslint": "workspace:*",
    "@repo/core-typescript": "workspace:*",
    "@types/node": "^22.0.0",
    "vitest": "^3.1.0"
  }
}
```

- [ ] **Step 2: Create tsconfig.json**

```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)**

```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/core-eslint": "workspace:*",
    "@repo/core-typescript": "workspace:*",
    "@types/node": "^22.0.0"
  }
}
```

- [ ] **Step 2: Create tsconfig.json**

```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**

```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/core-eslint": "workspace:*",
    "@repo/core-typescript": "workspace:*",
    "@types/node": "^22.0.0"
  }
}
```

- [ ] **Step 2: Create tsconfig.json**

```json
{
  "extends": "@repo/core-typescript/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/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)**

```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**

```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/core-eslint": "workspace:*",
    "@repo/core-typescript": "workspace:*",
    "@types/react": "^19.0.0"
  }
}
```

- [ ] **Step 2: Create tsconfig.json**

```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**

```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/core-typescript",
  "private": true,
  "version": "0.0.0"
}
```

Replace with:
```json
{
  "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**

```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/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**

```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<ReturnType<typeof createTrpcContext>>;
```

- [ ] **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/<feature>/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`.
