Files
agentic-dev/docs/superpowers/plans/2026-05-04-plan-1-foundation.md
Danijel Martinek 0972645ebb refactor: rename eslint-config + typescript-config to core-eslint + core-typescript
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>
2026-05-05 10:37:07 +02:00

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

{
  "$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/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
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 install green. (Typecheck/test deferred until Phase 2 fills core-sharedcore-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):

{
  "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/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
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

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