938 lines
30 KiB
Markdown
938 lines
30 KiB
Markdown
# B1: Next.js Scaffold Implementation Plan
|
||
|
||
> **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:** Stand up a `web/` Next.js 15 project, a `docker/web-next/Dockerfile`, and a `web-next` compose service exposed on `${CM_WEB_NEXT_HOST_PORT:-8010}` that serves a `frontend-design`-generated placeholder page and a catch-all Route Handler proxy to `api-server:3000`.
|
||
|
||
**Architecture:** Hand-roll the Next.js project files instead of using `npx create-next-app` for reproducibility. Tailwind v4 (CSS-first config — no `tailwind.config.ts`). Catch-all `web/app/api/[...path]/route.ts` forwards GET/POST to `api-server:3000` preserving the trailing slash. Multi-stage `node:22-alpine` Dockerfile with `output: "standalone"`. Side-by-side with the existing `cm-web` Flask service — both run; B4 retires Flask later.
|
||
|
||
**Tech Stack:** Next.js 15.x stable, React 19.x, TypeScript 5.x, Tailwind CSS v4 (`@tailwindcss/postcss`), Node 22 LTS, npm. No new dev tools. UI code (`layout.tsx`, `page.tsx`) generated by the `frontend-design` skill per the spec.
|
||
|
||
**Spec:** [docs/superpowers/specs/2026-05-02-b1-nextjs-scaffold-design.md](../specs/2026-05-02-b1-nextjs-scaffold-design.md)
|
||
|
||
---
|
||
|
||
## File Map
|
||
|
||
| File | Operation | Purpose |
|
||
|---|---|---|
|
||
| `web/package.json` | Create | Next.js 15 + React 19 + Tailwind v4 deps; npm scripts. |
|
||
| `web/package-lock.json` | Create | Generated by `npm install`. Committed for reproducible Docker builds. |
|
||
| `web/tsconfig.json` | Create | TypeScript config matching Next.js 15 defaults. |
|
||
| `web/next.config.ts` | Create | `output: "standalone"`, `trailingSlash: true`. |
|
||
| `web/postcss.config.mjs` | Create | Tailwind v4 PostCSS plugin. |
|
||
| `web/.gitignore` | Create | `.next/`, `node_modules/`, build/test outputs. |
|
||
| `web/.dockerignore` | Create | Excludes `node_modules/`, `.next/`, `.git/`. |
|
||
| `web/next-env.d.ts` | Create | Auto-generated reference; committed verbatim. |
|
||
| `web/app/layout.tsx` | Create (via frontend-design) | Root layout. |
|
||
| `web/app/page.tsx` | Create (via frontend-design) | Scaffold-confirmation placeholder. |
|
||
| `web/app/globals.css` | Create | `@import "tailwindcss";` |
|
||
| `web/app/api/[...path]/route.ts` | Create | Catch-all GET/POST proxy that maps the public hash to the upstream path and forwards to `api-server:3000`. |
|
||
| `web/lib/api-paths.ts` | Create | Hash → upstream-name mapping (single source of truth for both the Route Handler and future client code). |
|
||
| `docker/web-next/Dockerfile` | Create | Multi-stage Node 22 alpine, standalone output. |
|
||
| `docker-compose.yml` | Modify | Add `web-next` service. |
|
||
| `docker-compose.override.yml` | Modify | Add `web-next` build directive. |
|
||
| `envs/dev/.env.example` | Modify | `CM_WEB_NEXT_HOST_PORT=8010` |
|
||
| `envs/rex/.env.example` | Modify | `CM_WEB_NEXT_HOST_PORT=8011` |
|
||
| `envs/siong/.env.example` | Modify | `CM_WEB_NEXT_HOST_PORT=8012` |
|
||
| `scripts/dev.sh` | Modify | Include `web-next` in `up`/`logs`/`reset-db`. |
|
||
| `scripts/publish.sh` | Modify | Append `web-next` to `SERVICES`. |
|
||
| `AGENTS.md` | Modify | Mention `web/` and the `cm-web-next` service. |
|
||
|
||
No file removals. Nothing in `app/` is touched.
|
||
|
||
---
|
||
|
||
## Task 1: Bootstrap `web/` package + configs
|
||
|
||
**Files:**
|
||
- Create: `web/package.json`
|
||
- Create: `web/tsconfig.json`
|
||
- Create: `web/next.config.ts`
|
||
- Create: `web/postcss.config.mjs`
|
||
- Create: `web/.gitignore`
|
||
- Create: `web/.dockerignore`
|
||
- Create: `web/next-env.d.ts`
|
||
|
||
- [ ] **Step 1: Create `web/` and write `package.json`**
|
||
|
||
```bash
|
||
mkdir -p /home/yiekheng/projects/cm_bot_v2/web
|
||
```
|
||
|
||
Create `web/package.json`:
|
||
|
||
```json
|
||
{
|
||
"name": "cm-web-next",
|
||
"version": "0.1.0",
|
||
"private": true,
|
||
"scripts": {
|
||
"dev": "next dev",
|
||
"build": "next build",
|
||
"start": "next start",
|
||
"lint": "next lint"
|
||
},
|
||
"dependencies": {
|
||
"next": "15.1.0",
|
||
"react": "19.0.0",
|
||
"react-dom": "19.0.0"
|
||
},
|
||
"devDependencies": {
|
||
"@tailwindcss/postcss": "4.0.0",
|
||
"@types/node": "22.10.0",
|
||
"@types/react": "19.0.0",
|
||
"@types/react-dom": "19.0.0",
|
||
"tailwindcss": "4.0.0",
|
||
"typescript": "5.7.0"
|
||
}
|
||
}
|
||
```
|
||
|
||
The pinned versions are mid-2024-stable Next.js 15 + React 19 + Tailwind v4 final. Lockfile (Step 6) will resolve transitive deps.
|
||
|
||
- [ ] **Step 2: Write `web/tsconfig.json`**
|
||
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "ES2022",
|
||
"lib": ["dom", "dom.iterable", "esnext"],
|
||
"allowJs": true,
|
||
"skipLibCheck": true,
|
||
"strict": true,
|
||
"noEmit": true,
|
||
"esModuleInterop": true,
|
||
"module": "esnext",
|
||
"moduleResolution": "bundler",
|
||
"resolveJsonModule": true,
|
||
"isolatedModules": true,
|
||
"jsx": "preserve",
|
||
"incremental": true,
|
||
"plugins": [{ "name": "next" }],
|
||
"paths": { "@/*": ["./*"] }
|
||
},
|
||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||
"exclude": ["node_modules"]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Write `web/next.config.ts`**
|
||
|
||
```typescript
|
||
import type { NextConfig } from "next";
|
||
|
||
const config: NextConfig = {
|
||
output: "standalone",
|
||
trailingSlash: true,
|
||
};
|
||
|
||
export default config;
|
||
```
|
||
|
||
- [ ] **Step 4: Write `web/postcss.config.mjs`**
|
||
|
||
```javascript
|
||
const config = {
|
||
plugins: {
|
||
"@tailwindcss/postcss": {},
|
||
},
|
||
};
|
||
|
||
export default config;
|
||
```
|
||
|
||
- [ ] **Step 5: Write `web/.gitignore` and `web/.dockerignore`**
|
||
|
||
`web/.gitignore`:
|
||
|
||
```
|
||
node_modules/
|
||
/.next/
|
||
/out/
|
||
/build/
|
||
.DS_Store
|
||
*.tsbuildinfo
|
||
next-env.d.ts.bak
|
||
.env*.local
|
||
```
|
||
|
||
`web/.dockerignore`:
|
||
|
||
```
|
||
node_modules
|
||
.next
|
||
.git
|
||
.gitignore
|
||
README.md
|
||
```
|
||
|
||
- [ ] **Step 6: Write `web/next-env.d.ts`**
|
||
|
||
This file is normally auto-generated by Next.js; committing it verbatim avoids a phantom diff every build.
|
||
|
||
```typescript
|
||
/// <reference types="next" />
|
||
/// <reference types="next/image-types/global" />
|
||
|
||
// NOTE: This file should not be edited.
|
||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||
```
|
||
|
||
- [ ] **Step 7: Install dependencies and generate the lockfile**
|
||
|
||
```bash
|
||
cd /home/yiekheng/projects/cm_bot_v2/web && \
|
||
npm install --no-audit --no-fund 2>&1 | tail -10
|
||
```
|
||
|
||
Expected: completion line like `added <N> packages` and a new `web/package-lock.json`. Errors at this step usually mean the version pins above don't co-resolve; bump the offender to its latest patch and rerun.
|
||
|
||
- [ ] **Step 8: Verify the build chain works on configs alone**
|
||
|
||
`next build` requires at least one route. Defer the build smoke to Task 3 once we have `app/page.tsx` and the route handler. For now, sanity-check that `npx tsc --noEmit` doesn't error:
|
||
|
||
```bash
|
||
cd /home/yiekheng/projects/cm_bot_v2/web && \
|
||
npx tsc --noEmit && echo "tsc OK"
|
||
```
|
||
|
||
Expected: `tsc OK` (no output from tsc, since there are no .ts files yet — just configs).
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
cd /home/yiekheng/projects/cm_bot_v2 && \
|
||
git add web/package.json web/package-lock.json web/tsconfig.json web/next.config.ts web/postcss.config.mjs web/.gitignore web/.dockerignore web/next-env.d.ts && \
|
||
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
|
||
commit -m "feat(web): bootstrap Next.js 15 project configs and lockfile"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Generate `layout.tsx` and `page.tsx` via `frontend-design`
|
||
|
||
**Files:**
|
||
- Create: `web/app/layout.tsx` (via frontend-design)
|
||
- Create: `web/app/page.tsx` (via frontend-design)
|
||
- Create: `web/app/globals.css`
|
||
|
||
- [ ] **Step 1: Write `web/app/globals.css`**
|
||
|
||
```bash
|
||
mkdir -p /home/yiekheng/projects/cm_bot_v2/web/app
|
||
```
|
||
|
||
Create `web/app/globals.css`:
|
||
|
||
```css
|
||
@import "tailwindcss";
|
||
```
|
||
|
||
- [ ] **Step 2: Invoke the `frontend-design` skill**
|
||
|
||
Use the Skill tool with `skill="frontend-design:frontend-design"` and the following brief verbatim (from the B1 spec's "Empty UI page" section):
|
||
|
||
```
|
||
Generate two files for a Next.js 15 App Router project that uses Tailwind v4
|
||
(already configured via `@import "tailwindcss";` in `app/globals.css`):
|
||
|
||
1. `app/layout.tsx` — minimal root layout. <html lang="en">; <body> with
|
||
Tailwind defaults (no custom font). Tab title: "CM Bot V2". Imports
|
||
`./globals.css`. Server Component. No metadata API beyond `title`.
|
||
|
||
2. `app/page.tsx` — a scaffold-confirmation placeholder for the
|
||
`cm-web-next` service. Required content:
|
||
- Product name "CM Bot V2"
|
||
- Literal text "cm-web-next scaffold" (operators grep for this)
|
||
- One-line note that the real dashboard lands in B2
|
||
- An obvious link to /api/414322309db5c06d/ for smoke-testing the proxy
|
||
(this is the SHA-256[:16] of "acc"; see web/lib/api-paths.ts)
|
||
|
||
Constraints:
|
||
- Tailwind v4 utility classes only — no external font, image, or icon deps.
|
||
- Server Component (no "use client", no JS interactivity).
|
||
- Single page, no navigation.
|
||
- Should clearly read as a temporary scaffold, not a real dashboard. The
|
||
visual treatment should signal "work-in-progress / placeholder" so a
|
||
user landing here doesn't mistake it for the production UI.
|
||
- Mobile-first responsive defaults; no dark mode, no animations.
|
||
|
||
Out of scope: dark mode, multi-route navigation, charts/tables, animations.
|
||
```
|
||
|
||
The skill will return TSX content for the two files. Save the returned `layout.tsx` to `web/app/layout.tsx` and the returned `page.tsx` to `web/app/page.tsx`.
|
||
|
||
- [ ] **Step 3: Verify the generated files compile and the page renders**
|
||
|
||
```bash
|
||
cd /home/yiekheng/projects/cm_bot_v2/web && \
|
||
npm run build 2>&1 | tail -20
|
||
```
|
||
|
||
Expected:
|
||
|
||
- A `Compiled successfully` line (or equivalent for Next.js 15).
|
||
- A route summary line for `/` (the page) and any other auto-generated routes.
|
||
- No TypeScript errors. If any appear, the issue is in the generated TSX — re-invoke `frontend-design` with the additional constraint "fix this TS error: <paste>" and replace.
|
||
|
||
- [ ] **Step 4: Verify the placeholder text is present**
|
||
|
||
```bash
|
||
cd /home/yiekheng/projects/cm_bot_v2/web && \
|
||
grep -E "cm-web-next scaffold|CM Bot V2" app/page.tsx app/layout.tsx
|
||
```
|
||
|
||
Expected: hits in both files. Specifically `page.tsx` should contain the literal string `cm-web-next scaffold` (operators search for this) and a reference to `/api/acc/` (the smoke-test link).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/yiekheng/projects/cm_bot_v2 && \
|
||
git add web/app/layout.tsx web/app/page.tsx web/app/globals.css && \
|
||
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
|
||
commit -m "feat(web): add scaffold layout and page (frontend-design generated)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: API path mapping + catch-all Route Handler proxy
|
||
|
||
**Files:**
|
||
- Create: `web/lib/api-paths.ts`
|
||
- Create: `web/app/api/[...path]/route.ts`
|
||
|
||
- [ ] **Step 1: Create `web/lib/api-paths.ts`**
|
||
|
||
```bash
|
||
mkdir -p /home/yiekheng/projects/cm_bot_v2/web/lib
|
||
```
|
||
|
||
Create `web/lib/api-paths.ts` with the precomputed hashes:
|
||
|
||
```typescript
|
||
// SHA-256(endpoint).hex[:16]. Deterministic; no salt. Public-boundary only:
|
||
// the upstream api-server still uses the readable names. See B1 spec.
|
||
export const API_PATHS = {
|
||
acc: "414322309db5c06d", // upstream: /acc/
|
||
user: "04f8996da763b7a9", // upstream: /user/
|
||
updateAcc: "982830e2982d95de", // upstream: /update-acc-data
|
||
updateUser: "f1a25b37d8db494c", // upstream: /update-user-data
|
||
} as const;
|
||
|
||
// Reverse map for the Route Handler. Keys are the public path segment,
|
||
// values are { upstream: string; trailingSlash: boolean }.
|
||
export const PUBLIC_TO_UPSTREAM: Record<string, { upstream: string; trailingSlash: boolean }> = {
|
||
[API_PATHS.acc]: { upstream: "acc", trailingSlash: true },
|
||
[API_PATHS.user]: { upstream: "user", trailingSlash: true },
|
||
[API_PATHS.updateAcc]: { upstream: "update-acc-data", trailingSlash: false },
|
||
[API_PATHS.updateUser]: { upstream: "update-user-data", trailingSlash: false },
|
||
};
|
||
```
|
||
|
||
Verify the hashes (so future readers can double-check):
|
||
|
||
```bash
|
||
.venv/bin/python3 -c "
|
||
import hashlib
|
||
for endpoint in ['acc', 'user', 'update-acc-data', 'update-user-data']:
|
||
print(f'{endpoint:24s} -> {hashlib.sha256(endpoint.encode()).hexdigest()[:16]}')
|
||
"
|
||
```
|
||
|
||
Expected output (matches the constants above):
|
||
|
||
```
|
||
acc -> 414322309db5c06d
|
||
user -> 04f8996da763b7a9
|
||
update-acc-data -> 982830e2982d95de
|
||
update-user-data -> f1a25b37d8db494c
|
||
```
|
||
|
||
- [ ] **Step 2: Create the route handler directory**
|
||
|
||
```bash
|
||
mkdir -p /home/yiekheng/projects/cm_bot_v2/web/app/api/\[...path\]
|
||
```
|
||
|
||
(The square brackets are Next.js's catch-all dynamic segment syntax. Shell-escape with backslashes.)
|
||
|
||
- [ ] **Step 3: Write the route handler**
|
||
|
||
Create `web/app/api/[...path]/route.ts`:
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from "next/server";
|
||
import { PUBLIC_TO_UPSTREAM } from "@/lib/api-paths";
|
||
|
||
const API_BASE_URL = process.env.API_BASE_URL ?? "http://api-server:3000";
|
||
|
||
async function forward(
|
||
request: NextRequest,
|
||
path: string[],
|
||
): Promise<NextResponse> {
|
||
const hash = path[0];
|
||
const route = PUBLIC_TO_UPSTREAM[hash];
|
||
if (!route) {
|
||
return NextResponse.json({ error: "Not Found" }, { status: 404 });
|
||
}
|
||
const target = `${API_BASE_URL}/${route.upstream}${route.trailingSlash ? "/" : ""}`;
|
||
|
||
const init: RequestInit = {
|
||
method: request.method,
|
||
headers: {
|
||
"content-type":
|
||
request.headers.get("content-type") ?? "application/json",
|
||
},
|
||
};
|
||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||
init.body = await request.text();
|
||
}
|
||
|
||
try {
|
||
const upstream = await fetch(target, init);
|
||
const body = await upstream.text();
|
||
return new NextResponse(body, {
|
||
status: upstream.status,
|
||
headers: {
|
||
"content-type":
|
||
upstream.headers.get("content-type") ?? "application/json",
|
||
},
|
||
});
|
||
} catch (err) {
|
||
return NextResponse.json({ error: String(err) }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
export async function GET(
|
||
request: NextRequest,
|
||
ctx: { params: Promise<{ path: string[] }> },
|
||
): Promise<NextResponse> {
|
||
return forward(request, (await ctx.params).path);
|
||
}
|
||
|
||
export async function POST(
|
||
request: NextRequest,
|
||
ctx: { params: Promise<{ path: string[] }> },
|
||
): Promise<NextResponse> {
|
||
return forward(request, (await ctx.params).path);
|
||
}
|
||
```
|
||
|
||
Mapped requests (`GET /api/414322309db5c06d/` etc.) resolve through `PUBLIC_TO_UPSTREAM` and forward to `api-server:3000/acc/` etc. Unmapped hashes return 404 — they're literally not routes we expose. Network/upstream errors return `{"error": <stringified-error>}` with HTTP 500, matching `cm_web_view.py`'s shape.
|
||
|
||
- [ ] **Step 3: Build to verify the route compiles**
|
||
|
||
```bash
|
||
cd /home/yiekheng/projects/cm_bot_v2/web && \
|
||
npm run build 2>&1 | tail -25
|
||
```
|
||
|
||
Expected: route summary now includes `/api/[...path]` (or similar). No TS errors.
|
||
|
||
- [ ] **Step 4: Build and verify the route compiles**
|
||
|
||
```bash
|
||
cd /home/yiekheng/projects/cm_bot_v2/web && \
|
||
npm run build 2>&1 | tail -25
|
||
```
|
||
|
||
Expected: route summary now includes `/api/[...path]`. No TS errors. The `@/lib/api-paths` import resolves via the `paths` mapping in `tsconfig.json`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/yiekheng/projects/cm_bot_v2 && \
|
||
git add web/lib/api-paths.ts web/app/api/\[...path\]/route.ts && \
|
||
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
|
||
commit -m "feat(web): hash-encoded API paths + catch-all Route Handler proxy"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Multi-stage Dockerfile for `cm-web-next`
|
||
|
||
**Files:**
|
||
- Create: `docker/web-next/Dockerfile`
|
||
|
||
- [ ] **Step 1: Create the Dockerfile directory and file**
|
||
|
||
```bash
|
||
mkdir -p /home/yiekheng/projects/cm_bot_v2/docker/web-next
|
||
```
|
||
|
||
Create `docker/web-next/Dockerfile`:
|
||
|
||
```dockerfile
|
||
# syntax=docker/dockerfile:1.7
|
||
|
||
# --- deps ---
|
||
FROM node:22-alpine AS deps
|
||
WORKDIR /app
|
||
COPY web/package.json web/package-lock.json* ./
|
||
RUN npm ci
|
||
|
||
# --- build ---
|
||
FROM node:22-alpine AS build
|
||
WORKDIR /app
|
||
ENV NEXT_TELEMETRY_DISABLED=1
|
||
COPY --from=deps /app/node_modules ./node_modules
|
||
COPY web/ ./
|
||
RUN npm run build
|
||
|
||
# --- runtime ---
|
||
FROM node:22-alpine AS runtime
|
||
WORKDIR /app
|
||
ENV NODE_ENV=production
|
||
ENV NEXT_TELEMETRY_DISABLED=1
|
||
ENV PORT=3000
|
||
ENV HOSTNAME=0.0.0.0
|
||
COPY --from=build /app/.next/standalone ./
|
||
COPY --from=build /app/.next/static ./.next/static
|
||
COPY --from=build /app/public ./public
|
||
EXPOSE 3000
|
||
CMD ["node", "server.js"]
|
||
```
|
||
|
||
`web/public/` doesn't exist yet but the `COPY public ./public` step is harmless if Next.js 15 didn't create it during build (the standalone output bundles public). If `npm run build` fails because `public/` is missing, create the directory empty:
|
||
|
||
```bash
|
||
mkdir -p /home/yiekheng/projects/cm_bot_v2/web/public
|
||
touch /home/yiekheng/projects/cm_bot_v2/web/public/.gitkeep
|
||
```
|
||
|
||
- [ ] **Step 2: (Optional) Verify the Dockerfile builds locally**
|
||
|
||
This is optional because it requires docker on the engineer's machine. If available:
|
||
|
||
```bash
|
||
cd /home/yiekheng/projects/cm_bot_v2 && \
|
||
sudo docker build -f docker/web-next/Dockerfile -t cm-web-next:plan-test . 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: a "Successfully tagged cm-web-next:plan-test" line. If the build fails because of a missing `web/public/` directory, run the `mkdir -p web/public` from Step 1's note and rebuild. Skip this step if docker isn't available locally; Task 8 covers the integration verification.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /home/yiekheng/projects/cm_bot_v2 && \
|
||
git add docker/web-next/Dockerfile $(test -d web/public && echo "web/public/.gitkeep") && \
|
||
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
|
||
commit -m "feat(docker): add multi-stage Dockerfile for cm-web-next"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Add `web-next` to compose
|
||
|
||
**Files:**
|
||
- Modify: `docker-compose.yml`
|
||
- Modify: `docker-compose.override.yml`
|
||
|
||
- [ ] **Step 1: Add `web-next` service to base compose**
|
||
|
||
Find the existing `transfer-bot:` block in `docker-compose.yml` and add the `web-next:` block immediately above it (so the natural reading order is: telegram-bot → api-server → web-view → web-next → transfer-bot). The new block:
|
||
|
||
```yaml
|
||
# Next.js Web View (side-by-side with web-view during B-cycle migration).
|
||
web-next:
|
||
image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-web-next:${DOCKER_IMAGE_TAG:-latest}"
|
||
container_name: ${CM_DEPLOY_NAME:-cm}-web-next
|
||
restart: unless-stopped
|
||
ports:
|
||
- "${CM_WEB_NEXT_HOST_PORT:-8010}:3000"
|
||
environment:
|
||
NODE_ENV: production
|
||
NEXT_TELEMETRY_DISABLED: "1"
|
||
API_BASE_URL: http://api-server:3000
|
||
volumes:
|
||
- /etc/timezone:/etc/timezone:ro
|
||
- /etc/localtime:/etc/localtime:ro
|
||
networks:
|
||
- bot-network
|
||
depends_on:
|
||
- api-server
|
||
```
|
||
|
||
- [ ] **Step 2: Add the build directive in the override**
|
||
|
||
In `docker-compose.override.yml`, append to the `services:` section (after the existing `transfer-bot:` block, before any top-level keys like `volumes:`):
|
||
|
||
```yaml
|
||
web-next:
|
||
build:
|
||
context: .
|
||
dockerfile: docker/web-next/Dockerfile
|
||
image: "${CM_IMAGE_PREFIX:-local}/cm-web-next:${DOCKER_IMAGE_TAG:-dev}"
|
||
```
|
||
|
||
No `command:` override and no `profiles:` — `web-next` is part of the dev stack like `web-view`.
|
||
|
||
- [ ] **Step 3: Validate both compose files**
|
||
|
||
```bash
|
||
cd /home/yiekheng/projects/cm_bot_v2 && \
|
||
.venv/bin/python -c "
|
||
import yaml
|
||
with open('docker-compose.yml') as f:
|
||
base = yaml.safe_load(f)
|
||
assert 'web-next' in base['services'], 'web-next missing from base'
|
||
wn = base['services']['web-next']
|
||
assert wn['ports'] == ['\${CM_WEB_NEXT_HOST_PORT:-8010}:3000']
|
||
assert wn['environment']['API_BASE_URL'] == 'http://api-server:3000'
|
||
assert wn['depends_on'] == ['api-server']
|
||
print('base config: web-next service wired correctly')
|
||
|
||
with open('docker-compose.override.yml') as f:
|
||
over = yaml.safe_load(f)
|
||
assert 'web-next' in over['services']
|
||
wn_over = over['services']['web-next']
|
||
assert wn_over['build']['dockerfile'] == 'docker/web-next/Dockerfile'
|
||
print('override: web-next build directive present')
|
||
"
|
||
```
|
||
|
||
Expected:
|
||
```
|
||
base config: web-next service wired correctly
|
||
override: web-next build directive present
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd /home/yiekheng/projects/cm_bot_v2 && \
|
||
git add docker-compose.yml docker-compose.override.yml && \
|
||
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
|
||
commit -m "feat(compose): add web-next service (side-by-side with web-view)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Update env example files
|
||
|
||
**Files:**
|
||
- Modify: `envs/dev/.env.example`
|
||
- Modify: `envs/rex/.env.example`
|
||
- Modify: `envs/siong/.env.example`
|
||
|
||
- [ ] **Step 1: Update `envs/dev/.env.example`**
|
||
|
||
Find the `CM_WEB_HOST_PORT=8000` line in the `=== Deployment Identity ===` section and add `CM_WEB_NEXT_HOST_PORT=8010` immediately after it:
|
||
|
||
```
|
||
# === Deployment Identity ===
|
||
CM_DEPLOY_NAME=dev-cm
|
||
CM_WEB_HOST_PORT=8000
|
||
CM_WEB_NEXT_HOST_PORT=8010
|
||
```
|
||
|
||
- [ ] **Step 2: Update `envs/rex/.env.example`**
|
||
|
||
Add `CM_WEB_NEXT_HOST_PORT=8011` after `CM_WEB_HOST_PORT=8001`:
|
||
|
||
```
|
||
# === Deployment Identity ===
|
||
CM_DEPLOY_NAME=rex-cm
|
||
CM_WEB_HOST_PORT=8001
|
||
CM_WEB_NEXT_HOST_PORT=8011
|
||
```
|
||
|
||
- [ ] **Step 3: Update `envs/siong/.env.example`**
|
||
|
||
Add `CM_WEB_NEXT_HOST_PORT=8012` after `CM_WEB_HOST_PORT=8005`:
|
||
|
||
```
|
||
# === Deployment Identity ===
|
||
CM_DEPLOY_NAME=siong-cm
|
||
CM_WEB_HOST_PORT=8005
|
||
CM_WEB_NEXT_HOST_PORT=8012
|
||
```
|
||
|
||
- [ ] **Step 4: Verify all three files agree on the new key**
|
||
|
||
```bash
|
||
cd /home/yiekheng/projects/cm_bot_v2 && \
|
||
grep -H "^CM_WEB_NEXT_HOST_PORT=" envs/*/.env.example
|
||
```
|
||
|
||
Expected: three lines, one per deployment, each with a distinct port (8010 / 8011 / 8012).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/yiekheng/projects/cm_bot_v2 && \
|
||
git add envs/dev/.env.example envs/rex/.env.example envs/siong/.env.example && \
|
||
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
|
||
commit -m "feat(envs): add CM_WEB_NEXT_HOST_PORT to all .env.example templates"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Wire `web-next` into `dev.sh` and `publish.sh`
|
||
|
||
**Files:**
|
||
- Modify: `scripts/dev.sh`
|
||
- Modify: `scripts/publish.sh`
|
||
|
||
- [ ] **Step 1: Update `dev.sh`**
|
||
|
||
In `scripts/dev.sh`, find the three places that currently pass the explicit service list and add `web-next`:
|
||
|
||
`up` case (currently `mysql api-server web-view`):
|
||
|
||
```bash
|
||
up)
|
||
"${COMPOSE[@]}" up -d --build mysql api-server web-view web-next
|
||
"${COMPOSE[@]}" ps
|
||
;;
|
||
```
|
||
|
||
`reset-db` case (the inner `up` invocation):
|
||
|
||
```bash
|
||
reset-db)
|
||
"${COMPOSE[@]}" down --volumes --remove-orphans
|
||
"${COMPOSE[@]}" up -d --build mysql api-server web-view web-next
|
||
;;
|
||
```
|
||
|
||
`logs` case:
|
||
|
||
```bash
|
||
logs)
|
||
"${COMPOSE[@]}" logs -f mysql api-server web-view web-next
|
||
;;
|
||
```
|
||
|
||
The `status` case stays as-is — it only checks `mysql`.
|
||
|
||
Update `usage()` so the help text mentions the new service:
|
||
|
||
```bash
|
||
usage() {
|
||
cat <<'EOF'
|
||
Lifecycle for the dev stack (mysql + api-server + web-view + web-next).
|
||
|
||
Usage:
|
||
scripts/dev.sh up Start all dev services in the background.
|
||
scripts/dev.sh down Stop the stack. mysql volume kept (DB persists).
|
||
scripts/dev.sh reset-db Stop the stack AND drop the mysql volume; then start.
|
||
scripts/dev.sh logs Tail logs from the running stack.
|
||
scripts/dev.sh status Print 'OK' if mysql is running, else exit 1.
|
||
|
||
Environment:
|
||
NO_SUDO=1 Skip the 'sudo' prefix (use if your user is in the docker group).
|
||
EOF
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update `publish.sh`**
|
||
|
||
In `scripts/publish.sh`, find the `SERVICES=` array (currently four entries) and append `web-next`:
|
||
|
||
```bash
|
||
SERVICES=(
|
||
"api docker/api/Dockerfile"
|
||
"telegram docker/telegram/Dockerfile"
|
||
"web docker/web/Dockerfile"
|
||
"transfer docker/transfer/Dockerfile"
|
||
"web-next docker/web-next/Dockerfile"
|
||
)
|
||
```
|
||
|
||
The image-name template `${REGISTRY_PREFIX}/cm-${SERVICE}:${IMAGE_TAG}` produces `gitea.04080616.xyz/yiekheng/cm-web-next:<tag>` — matching the compose `image:` reference.
|
||
|
||
- [ ] **Step 3: Bash syntax-check both scripts**
|
||
|
||
```bash
|
||
cd /home/yiekheng/projects/cm_bot_v2 && \
|
||
bash -n scripts/dev.sh && bash -n scripts/publish.sh && echo "syntax OK"
|
||
```
|
||
|
||
Expected: `syntax OK`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd /home/yiekheng/projects/cm_bot_v2 && \
|
||
git add scripts/dev.sh scripts/publish.sh && \
|
||
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
|
||
commit -m "feat(scripts): include web-next in dev.sh and publish.sh"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: AGENTS.md updates
|
||
|
||
**Files:**
|
||
- Modify: `AGENTS.md`
|
||
|
||
- [ ] **Step 1: Add `web/` to the Project Structure section**
|
||
|
||
Find the existing `## Project Structure & Module Organization` section and add a new bullet after the `app/` bullets, immediately above the `docker/<service>/Dockerfile` line:
|
||
|
||
Find:
|
||
|
||
```
|
||
- `docker/<service>/Dockerfile` builds one image per service (`cm-api`, `cm-web`, `cm-telegram`, `cm-transfer`).
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```
|
||
- `web/` is the Next.js 15 app for the new web view (`cm-web-next` service). Tailwind v4, App Router, TypeScript. Side-by-side with the legacy Flask `cm_web_view.py` until B4 cuts over.
|
||
- `docker/<service>/Dockerfile` builds one image per service (`cm-api`, `cm-web`, `cm-web-next`, `cm-telegram`, `cm-transfer`).
|
||
```
|
||
|
||
- [ ] **Step 2: Update the Dev Tier section's URL note**
|
||
|
||
In the existing `## Dev Tier (Local Development)` section, find the bullet that mentions the lifecycle script:
|
||
|
||
```
|
||
- Lifecycle: `bash scripts/dev.sh {up,down,reset-db,logs,status}`.
|
||
```
|
||
|
||
Add a follow-up bullet describing the new URL:
|
||
|
||
```
|
||
- Lifecycle: `bash scripts/dev.sh {up,down,reset-db,logs,status}`.
|
||
- URLs: `http://localhost:8000/` (legacy Flask UI), `http://localhost:8010/` (new Next.js scaffold). Both run side-by-side until the B4 cutover retires the Flask version.
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /home/yiekheng/projects/cm_bot_v2 && \
|
||
git add AGENTS.md && \
|
||
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
|
||
commit -m "docs(agents): document web/ Next.js project and cm-web-next dev URL"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Integration verification (deployer host required)
|
||
|
||
This task corresponds to the verification scenarios in the spec. No commits — these are smoke checks. If anything fails, debug before declaring done.
|
||
|
||
**Files:** none modified.
|
||
|
||
**Prerequisites:** docker compose v2 plugin installed; the engineer's `.env` has `CM_WEB_NEXT_HOST_PORT` set (default 8010 if absent thanks to `${CM_WEB_NEXT_HOST_PORT:-8010}` in the compose interpolation).
|
||
|
||
- [ ] **Step 1: Bring up the dev stack**
|
||
|
||
```bash
|
||
cd /home/yiekheng/projects/cm_bot_v2 && \
|
||
bash scripts/dev.sh up
|
||
```
|
||
|
||
Wait ~25–35s (mysql healthcheck + npm/Next.js startup). Then:
|
||
|
||
```bash
|
||
sudo docker compose -f docker-compose.yml -f docker-compose.override.yml ps
|
||
```
|
||
|
||
Expected: five containers running — `dev-cm-mysql`, `dev-cm-api-server`, `dev-cm-web-view`, `dev-cm-web-next`, plus a healthy `mysql`.
|
||
|
||
- [ ] **Step 2: Empty page renders**
|
||
|
||
```bash
|
||
curl -s http://localhost:8010/ | grep -E "cm-web-next scaffold|CM Bot V2"
|
||
```
|
||
|
||
Expected: hits in the response. Open `http://localhost:8010/` in a browser — page is visible, looks like a placeholder, has a link to `/api/acc/`.
|
||
|
||
- [ ] **Step 3: API proxy parity (GET)**
|
||
|
||
```bash
|
||
# Old Flask: /api/acc/. New Next.js: /api/<sha256("acc")[:16]>/.
|
||
diff \
|
||
<(curl -s http://localhost:8000/api/acc/) \
|
||
<(curl -s http://localhost:8010/api/414322309db5c06d/) \
|
||
&& echo "GET parity OK"
|
||
```
|
||
|
||
Expected: `GET parity OK`. Both go through to `api-server:3000/acc/` and return the same JSON. The hash mapping is the only thing in front of the upstream — same body, same status.
|
||
|
||
- [ ] **Step 4: API proxy parity (POST)**
|
||
|
||
```bash
|
||
diff \
|
||
<(curl -s -X POST -H 'Content-Type: application/json' \
|
||
-d '{"username":"13c1000","password":"x","status":"","link":""}' \
|
||
http://localhost:8000/api/update-acc-data) \
|
||
<(curl -s -X POST -H 'Content-Type: application/json' \
|
||
-d '{"username":"13c1000","password":"x","status":"","link":""}' \
|
||
http://localhost:8010/api/982830e2982d95de) \
|
||
&& echo "POST parity OK"
|
||
```
|
||
|
||
Expected: `POST parity OK`. Both POSTs round-trip through to `api-server:3000/update-acc-data`.
|
||
|
||
- [ ] **Step 4b: Unmapped hash returns 404**
|
||
|
||
```bash
|
||
curl -s -o /dev/null -w "code=%{http_code}\n" http://localhost:8010/api/deadbeefdeadbeef/
|
||
```
|
||
|
||
Expected: `code=404`. Confirms the proxy is allowlist-based, not pass-through.
|
||
|
||
- [ ] **Step 5: Old `cm-web` still serves**
|
||
|
||
```bash
|
||
curl -sf http://localhost:8000/ | head -c 200; echo
|
||
```
|
||
|
||
Expected: HTML containing the existing Flask `<title>CM Bot Database Viewer</title>` (unchanged).
|
||
|
||
- [ ] **Step 6: Image is buildable through `publish.sh`**
|
||
|
||
```bash
|
||
bash scripts/publish.sh --help | head -5
|
||
```
|
||
|
||
Expected: usage block lists `web-next` (or at least no errors). Optional full publish: `bash scripts/publish.sh dev-test` after `docker login gitea.04080616.xyz`.
|
||
|
||
- [ ] **Step 7: Prod compose parity check**
|
||
|
||
```bash
|
||
sudo docker compose -f docker-compose.yml config | grep -E "web-next:|web-view:|api-server:" | head
|
||
sudo docker compose -f docker-compose.yml config | grep -E "8010|8001|3000:3000" | head
|
||
```
|
||
|
||
Expected: `web-next:` listed alongside the other services; web-next bound on `${CM_WEB_NEXT_HOST_PORT:-8010}:3000`; api-server has no host port (preserved from C5).
|
||
|
||
- [ ] **Step 8: Tear down**
|
||
|
||
```bash
|
||
bash scripts/dev.sh down
|
||
```
|
||
|
||
Expected: clean shutdown (no orphan complaints — `--remove-orphans` from the C cycle handles it).
|
||
|
||
---
|
||
|
||
## Spec Coverage Check (self-review)
|
||
|
||
| Spec requirement | Task |
|
||
|---|---|
|
||
| `web/` directory at repo root, full Next.js project | Task 1 |
|
||
| Next.js 15 + App Router + TypeScript + Tailwind v4 | Task 1 (configs) + Task 2 (app shell) |
|
||
| `output: "standalone"`, `trailingSlash: true` in `next.config.ts` | Task 1 step 3 |
|
||
| `frontend-design`-generated `layout.tsx` and `page.tsx` | Task 2 step 2 |
|
||
| Hash-encoded API paths at the public boundary | Task 3 |
|
||
| `web/lib/api-paths.ts` shared mapping for server + client | Task 3 step 1 |
|
||
| Catch-all proxy at `web/app/api/[...path]/route.ts` (allowlist-based) | Task 3 step 3 |
|
||
| Unmapped hash returns 404 (not pass-through) | Task 9 step 4b |
|
||
| Multi-stage Dockerfile (Node 22 alpine, standalone output) | Task 4 |
|
||
| `web-next` service in base compose, `${CM_WEB_NEXT_HOST_PORT:-8010}:3000` | Task 5 step 1 |
|
||
| Build directive in override | Task 5 step 2 |
|
||
| `CM_WEB_NEXT_HOST_PORT` in dev/rex/siong .env.example (8010/8011/8012) | Task 6 |
|
||
| `dev.sh` includes `web-next` in up/logs/reset-db | Task 7 step 1 |
|
||
| `publish.sh` adds `web-next` image | Task 7 step 2 |
|
||
| AGENTS.md updates | Task 8 |
|
||
| Side-by-side preserved (cm-web Flask untouched) | Verified across Tasks 5, 9 |
|
||
| Integration verification | Task 9 |
|
||
|
||
No gaps. No placeholders. Type names (`NextRequest`, `NextResponse`) and config keys (`output`, `trailingSlash`, `API_BASE_URL`, `CM_WEB_NEXT_HOST_PORT`) consistent across tasks. The frontend-design invocation is the only step where the produced TSX content isn't quoted verbatim — by design, since the skill is the source of authority for the design.
|