Compare commits
148
Commits
a659ebeec2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dadbf7946 | ||
|
|
ea6532c703 | ||
|
|
84d8139cca | ||
|
|
6c5cf6d3e6 | ||
|
|
4330f47c65 | ||
|
|
1025454d87 | ||
|
|
66af4193f1 | ||
|
|
a1e550da41 | ||
|
|
c77fa5e15c | ||
|
|
9ce8ad190f | ||
|
|
229cb15632 | ||
|
|
f6ab0c38a0 | ||
|
|
a0da49e2f8 | ||
|
|
7186741258 | ||
|
|
05056cf31d | ||
|
|
1014014145 | ||
|
|
dd98201d79 | ||
|
|
2d26877b71 | ||
|
|
12b99c76a6 | ||
|
|
b4bbf6892c | ||
|
|
464164d8a7 | ||
|
|
ba320380a0 | ||
|
|
48566081f0 | ||
|
|
10a15a133c | ||
|
|
5b1345cf9e | ||
|
|
4ca5f0af39 | ||
|
|
283d66b5e0 | ||
|
|
b311c363c5 | ||
|
|
006bf79655 | ||
|
|
1a45ba9fdb | ||
|
|
4d424e67ad | ||
|
|
263a92f368 | ||
|
|
f44e66bcd5 | ||
|
|
66e6fad876 | ||
|
|
befb898c45 | ||
|
|
0638c4a82d | ||
|
|
e412767847 | ||
|
|
ea4f34271c | ||
|
|
caabe05c68 | ||
|
|
9d8d876ba7 | ||
|
|
8f98bed7dd | ||
|
|
bd3a0894b4 | ||
|
|
03616b4e60 | ||
|
|
5ccba958d2 | ||
|
|
067115ba13 | ||
|
|
011571e12d | ||
|
|
9591c67478 | ||
|
|
d65c5613d3 | ||
|
|
4f0d2da876 | ||
|
|
72b53afe06 | ||
|
|
5d85226ff0 | ||
|
|
5195a65ca5 | ||
|
|
666ba855a2 | ||
|
|
8adfffb6e7 | ||
|
|
864911af20 | ||
|
|
96b8765966 | ||
|
|
5cd9ea92a7 | ||
|
|
2a70a111a2 | ||
|
|
c22e394f98 | ||
|
|
54d5c0ee01 | ||
|
|
c92caede57 | ||
|
|
e01ee22438 | ||
|
|
aedcfbf4e7 | ||
|
|
c6b0b2549a | ||
|
|
34b3b8f56b | ||
|
|
53e0b442a4 | ||
|
|
5fdf5ffb64 | ||
|
|
b88e9b796c | ||
|
|
d62c5d33a4 | ||
|
|
87ce3ebbc6 | ||
|
|
0de2458955 | ||
|
|
7a6e717c28 | ||
|
|
be923cb432 | ||
|
|
ea75d68b1a | ||
|
|
f14f3e92c5 | ||
|
|
6077476254 | ||
|
|
b7d8c455d4 | ||
|
|
0234c5cbe6 | ||
|
|
d8da598f1d | ||
|
|
628068e33d | ||
|
|
074fa9f316 | ||
|
|
596d3cf616 | ||
|
|
77792c624b | ||
|
|
a9b67385c3 | ||
|
|
a937e8f51b | ||
|
|
e8ed5980fc | ||
|
|
90e40c4d48 | ||
|
|
aedfb022f3 | ||
|
|
62116be88d | ||
|
|
a67a8c6469 | ||
|
|
989ee9c464 | ||
|
|
251a6e9b4d | ||
|
|
26a900a030 | ||
|
|
b8d4fc97da | ||
|
|
cc99bd2b74 | ||
|
|
ab3a2dc456 | ||
|
|
90a43e5d0c | ||
|
|
ceb53adba4 | ||
|
|
edbd812c30 | ||
|
|
b31948faba | ||
|
|
040d6cb4ff | ||
|
|
c611601525 | ||
|
|
233e88e911 | ||
|
|
3411dcfa9f | ||
|
|
144e124bcb | ||
|
|
7fb55590bd | ||
|
|
c760c3c087 | ||
|
|
8906ed55a0 | ||
|
|
3cb7879a75 | ||
|
|
3938aeb38d | ||
|
|
be9211ef21 | ||
|
|
d9e09dceea | ||
|
|
d35dd8c887 | ||
|
|
44b887a9a0 | ||
|
|
a82af10851 | ||
|
|
4bbdbb9647 | ||
|
|
977fcc9926 | ||
|
|
ec0abfbd60 | ||
|
|
c400e4a57c | ||
|
|
050ca518c0 | ||
|
|
167d95c5e8 | ||
|
|
f8b525d10c | ||
|
|
8ed8c677e8 | ||
|
|
98437cb999 | ||
|
|
1dd16ebcba | ||
|
|
b998826b71 | ||
|
|
57f90018da | ||
|
|
2d974b8952 | ||
|
|
9afd383eac | ||
|
|
1b2e20764c | ||
|
|
339f8432a6 | ||
|
|
394f207446 | ||
|
|
9d4545885a | ||
|
|
543c34820c | ||
|
|
5d9e60902f | ||
|
|
2b2b8d8759 | ||
|
|
ca2bd19c48 | ||
|
|
76f778c10d | ||
|
|
0cdf261d40 | ||
|
|
53d636af0e | ||
|
|
76ed91c468 | ||
|
|
b621923868 | ||
|
|
ec0ab4dbb3 | ||
|
|
2fac2b5230 | ||
|
|
8790a4f65a | ||
|
|
b454a0fbac | ||
|
|
f4fb43c1a5 | ||
|
|
c7697666fe |
No files matched your search
@@ -0,0 +1,17 @@
|
|||||||
|
name: Dependabot Auto-Merge
|
||||||
|
on: pull_request
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
auto-merge:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.actor == 'dependabot[bot]'
|
||||||
|
steps:
|
||||||
|
- name: Enable auto-merge for Dependabot PR
|
||||||
|
run: gh pr merge --auto --merge "$PR_URL"
|
||||||
|
env:
|
||||||
|
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
groups:
|
||||||
|
production:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
- "patch"
|
||||||
|
development:
|
||||||
|
dependency-type: "development"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
- "patch"
|
||||||
+297
-14
@@ -6,38 +6,321 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
# Cancel in-progress runs for the same ref so a fresh push supersedes them.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.ref }}
|
group: ci-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
VPS_HOST: ${{ secrets.SSH_DEPLOY_HOST }}
|
||||||
|
VPS_USER: ${{ secrets.SSH_DEPLOY_USER }}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
test:
|
||||||
name: typecheck + build (turbo)
|
name: typecheck + tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Bun
|
- name: Set up Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
bun-version: "1.3.14"
|
bun-version: "1.4.0"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
# Real build: typecheck + turbo run build across all workspace packages
|
|
||||||
# (incl. the Next.js web app). A green run means the whole monorepo
|
|
||||||
# compiles and the web app prerenders.
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: bun run typecheck
|
run: bun run typecheck
|
||||||
|
|
||||||
- name: Build
|
- name: Test
|
||||||
|
run: bun run test
|
||||||
|
|
||||||
|
# Build .next for web — Next.js/Turbopack has a Bun incompatibility
|
||||||
|
# in the Nix sandbox. The web derivation (web.nix) packages this
|
||||||
|
# pre-built .next into a Nix store path.
|
||||||
|
- name: Build Next.js .next
|
||||||
|
working-directory: apps/web
|
||||||
run: bun run build
|
run: bun run build
|
||||||
|
|
||||||
# Smoke: verify the MCP server + resources load and the indexer/module
|
- name: Tar .next
|
||||||
# graph resolves. Requires no external services (Redis/Postgres) — pure
|
run: tar -C apps/web --exclude='.next/cache' -czf web-next.tar.gz .next
|
||||||
# module load + in-memory smoke test.
|
|
||||||
- name: MCP smoke test
|
- name: Upload web .next artifact
|
||||||
run: bun --cwd apps/mcp run smoke
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: mcpedia-web-next
|
||||||
|
path: web-next.tar.gz
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
build-and-deploy:
|
||||||
|
name: build + deploy (Nix) — ${{ matrix.service }}
|
||||||
|
needs: test
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
service: [api, mcp, worker]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Nix
|
||||||
|
uses: DeterminateSystems/nix-installer-action@v16
|
||||||
|
with:
|
||||||
|
determinate: false
|
||||||
|
extra-conf: |
|
||||||
|
sandbox = false
|
||||||
|
accept-flake-config = true
|
||||||
|
# Attic caches as substituters: lets CI pull the prebuilt attic
|
||||||
|
# client (and any cached deps/builds) over HTTPS.
|
||||||
|
extra-substituters = https://attic.asepharyana.my.id/gmw https://attic.asepharyana.my.id/mcpedia
|
||||||
|
extra-trusted-public-keys = gmw:Fq2Anzuhkb+T/hftWnPcveHSi21/RzIgIOeG8pCJa88= mcpedia:MNAtF2xaeKSFFVu7r3zOp2Jktu1jKHIQmcT+ePEfv9o=
|
||||||
|
|
||||||
|
- name: Cache Nix
|
||||||
|
uses: DeterminateSystems/magic-nix-cache-action@v14
|
||||||
|
with:
|
||||||
|
use-flakehub: false
|
||||||
|
|
||||||
|
- name: Build ${{ matrix.service }}
|
||||||
|
id: build
|
||||||
|
run: |
|
||||||
|
nix build ".#${{ matrix.service }}" --impure --option sandbox false --print-build-logs
|
||||||
|
STORE_PATH=$(readlink result)
|
||||||
|
echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Build OK ${{ matrix.service }}: $STORE_PATH"
|
||||||
|
|
||||||
|
- name: Setup SSH key
|
||||||
|
env:
|
||||||
|
SSH_KEY: ${{ secrets.SSH_DEPLOY_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
sed -i 's/\r$//' ~/.ssh/id_ed25519
|
||||||
|
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
|
||||||
|
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
chmod 600 ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
# ── Push build to Attic binary cache ───────────────────────────────
|
||||||
|
# Bootstrap the attic client from the gmw cache (pre-built closure,
|
||||||
|
# pulled over HTTPS via the configured extra-substituters), then
|
||||||
|
# push to the mcpedia cache. This is FASTER than nix copy --to ssh://
|
||||||
|
# because the VPS substitutes (downloads) from the cache over HTTPS
|
||||||
|
# instead of a single-stream SSH copy.
|
||||||
|
- name: Push to Attic cache
|
||||||
|
id: push-attic
|
||||||
|
env:
|
||||||
|
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }}
|
||||||
|
STORE_PATH: ${{ steps.build.outputs.store-path }}
|
||||||
|
run: |
|
||||||
|
ATTIC_DIR="/nix/store/fygyy3yk4rqdknxkiwkqambpnhyax0k4-attic-0.1.0"
|
||||||
|
mkdir -p "$HOME/.config/attic"
|
||||||
|
cat > "$HOME/.config/attic/config.toml" <<EOF
|
||||||
|
default-server = "pub"
|
||||||
|
[servers.pub]
|
||||||
|
endpoint = "https://attic.asepharyana.my.id"
|
||||||
|
token = "$ATTIC_TOKEN"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
ATTIC_BIN=""
|
||||||
|
# Fast path: pull attic client from gmw cache
|
||||||
|
if nix-store --realise "$ATTIC_DIR" 2>/dev/null; then
|
||||||
|
ATTIC_BIN="$ATTIC_DIR/bin/attic"
|
||||||
|
fi
|
||||||
|
|
||||||
|
push_ok=""
|
||||||
|
if [ -n "$ATTIC_BIN" ] && [ -x "$ATTIC_BIN" ]; then
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if "$ATTIC_BIN" push pub:mcpedia "$STORE_PATH" --jobs 4 --ignore-upstream-cache-filter; then
|
||||||
|
echo "✅ Pushed $STORE_PATH to Attic"
|
||||||
|
push_ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "⚠️ Attic push attempt $attempt/3 failed; retrying in 10s..."
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$push_ok" ]; then
|
||||||
|
echo "Attic push failed; copying store path directly to VPS"
|
||||||
|
nix copy --to "ssh://${VPS_USER}@${VPS_HOST}" "$STORE_PATH"
|
||||||
|
echo "push-fallback=ssh" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "push-method=attic" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deploy on VPS
|
||||||
|
env:
|
||||||
|
STORE_PATH: ${{ steps.build.outputs.store-path }}
|
||||||
|
SERVICE: ${{ matrix.service }}
|
||||||
|
VPS_HOST: ${{ secrets.SSH_DEPLOY_HOST }}
|
||||||
|
VPS_USER: ${{ secrets.SSH_DEPLOY_USER }}
|
||||||
|
run: |
|
||||||
|
PROFILE="mcpedia-${SERVICE}"
|
||||||
|
# Substitute from Attic cache (fast — downloads over HTTPS)
|
||||||
|
# Falls back to SSH copy if substitution fails
|
||||||
|
REALISE_RESULT=$(ssh "$VPS_USER@$VPS_HOST" \
|
||||||
|
"sudo /nix/var/nix/profiles/default/bin/nix-store --realise '$STORE_PATH' 2>&1 || true")
|
||||||
|
echo "$REALISE_RESULT"
|
||||||
|
|
||||||
|
# Update profile + sync git repo + restart
|
||||||
|
ssh "$VPS_USER@$VPS_HOST" \
|
||||||
|
"sudo /nix/var/nix/profiles/default/bin/nix-env \
|
||||||
|
--profile /nix/var/nix/profiles/$PROFILE \
|
||||||
|
--set '$STORE_PATH' && \
|
||||||
|
(cd /home/code/mcpedia && git fetch origin main && git reset --hard origin/main || true) && \
|
||||||
|
sudo systemctl restart $PROFILE && \
|
||||||
|
sleep 3 && \
|
||||||
|
sudo systemctl status $PROFILE --no-pager --no-legend | head -5"
|
||||||
|
|
||||||
|
- name: Verify service
|
||||||
|
env:
|
||||||
|
VPS_HOST: ${{ secrets.SSH_DEPLOY_HOST }}
|
||||||
|
VPS_USER: ${{ secrets.SSH_DEPLOY_USER }}
|
||||||
|
SERVICE: ${{ matrix.service }}
|
||||||
|
run: |
|
||||||
|
PORT_MAP="api:4017 mcp:4021 worker:4018"
|
||||||
|
PORT=$(echo "$PORT_MAP" | grep "$SERVICE" | awk -F: '{print $2}')
|
||||||
|
ssh "$VPS_USER@$VPS_HOST" \
|
||||||
|
"curl -s -o /dev/null -w '%{http_code}' http://localhost:$PORT/health 2>/dev/null \
|
||||||
|
|| systemctl is-active \"mcpedia-$SERVICE\""
|
||||||
|
|
||||||
|
deploy-web:
|
||||||
|
name: build + deploy (web)
|
||||||
|
needs: test
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: "1.4.0"
|
||||||
|
|
||||||
|
- name: Install Nix
|
||||||
|
uses: DeterminateSystems/nix-installer-action@v16
|
||||||
|
with:
|
||||||
|
determinate: false
|
||||||
|
extra-conf: |
|
||||||
|
sandbox = false
|
||||||
|
accept-flake-config = true
|
||||||
|
extra-substituters = https://attic.asepharyana.my.id/gmw https://attic.asepharyana.my.id/mcpedia
|
||||||
|
extra-trusted-public-keys = gmw:Fq2Anzuhkb+T/hftWnPcveHSi21/RzIgIOeG8pCJa88= mcpedia:MNAtF2xaeKSFFVu7r3zOp2Jktu1jKHIQmcT+ePEfv9o=
|
||||||
|
|
||||||
|
- name: Cache Nix
|
||||||
|
uses: DeterminateSystems/magic-nix-cache-action@v14
|
||||||
|
with:
|
||||||
|
use-flakehub: false
|
||||||
|
|
||||||
|
- name: Download web .next artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: mcpedia-web-next
|
||||||
|
path: /tmp/web-next
|
||||||
|
|
||||||
|
- name: Unpack .next
|
||||||
|
run: tar -xzf /tmp/web-next/web-next.tar.gz -C apps/web/
|
||||||
|
|
||||||
|
- name: Install deps (for bunx in web.nix)
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build web Nix derivation
|
||||||
|
id: build
|
||||||
|
run: |
|
||||||
|
REPO_ROOT="$(pwd)"
|
||||||
|
nix-build -E \
|
||||||
|
"let pkgs = import <nixpkgs> {}; in pkgs.callPackage ${REPO_ROOT}/web.nix {
|
||||||
|
webNextDir = ${REPO_ROOT}/apps/web/.next;
|
||||||
|
}" \
|
||||||
|
--impure --option sandbox false
|
||||||
|
STORE_PATH=$(readlink result)
|
||||||
|
echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Setup SSH key
|
||||||
|
env:
|
||||||
|
SSH_KEY: ${{ secrets.SSH_DEPLOY_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
sed -i 's/\r$//' ~/.ssh/id_ed25519
|
||||||
|
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
|
||||||
|
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
chmod 600 ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Push to Attic cache
|
||||||
|
id: push-attic
|
||||||
|
env:
|
||||||
|
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }}
|
||||||
|
STORE_PATH: ${{ steps.build.outputs.store-path }}
|
||||||
|
run: |
|
||||||
|
ATTIC_DIR="/nix/store/fygyy3yk4rqdknxkiwkqambpnhyax0k4-attic-0.1.0"
|
||||||
|
mkdir -p "$HOME/.config/attic"
|
||||||
|
cat > "$HOME/.config/attic/config.toml" <<EOF
|
||||||
|
default-server = "pub"
|
||||||
|
[servers.pub]
|
||||||
|
endpoint = "https://attic.asepharyana.my.id"
|
||||||
|
token = "$ATTIC_TOKEN"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
ATTIC_BIN=""
|
||||||
|
if nix-store --realise "$ATTIC_DIR" 2>/dev/null; then
|
||||||
|
ATTIC_BIN="$ATTIC_DIR/bin/attic"
|
||||||
|
fi
|
||||||
|
|
||||||
|
push_ok=""
|
||||||
|
if [ -n "$ATTIC_BIN" ] && [ -x "$ATTIC_BIN" ]; then
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if "$ATTIC_BIN" push pub:mcpedia "$STORE_PATH" --jobs 4 --ignore-upstream-cache-filter; then
|
||||||
|
echo "✅ Pushed $STORE_PATH to Attic"
|
||||||
|
push_ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "⚠️ Attic push attempt $attempt/3 failed; retrying in 10s..."
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$push_ok" ]; then
|
||||||
|
echo "Attic push failed; copying store path directly to VPS"
|
||||||
|
nix copy --to "ssh://${VPS_USER}@${VPS_HOST}" "$STORE_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deploy on VPS
|
||||||
|
env:
|
||||||
|
STORE_PATH: ${{ steps.build.outputs.store-path }}
|
||||||
|
VPS_HOST: ${{ secrets.SSH_DEPLOY_HOST }}
|
||||||
|
VPS_USER: ${{ secrets.SSH_DEPLOY_USER }}
|
||||||
|
run: |
|
||||||
|
# Update profile + sync git repo + sync .next + restart
|
||||||
|
ssh "$VPS_USER@$VPS_HOST" \
|
||||||
|
"sudo /nix/var/nix/profiles/default/bin/nix-store --realise '$STORE_PATH' && \
|
||||||
|
sudo /nix/var/nix/profiles/default/bin/nix-env \
|
||||||
|
--profile /nix/var/nix/profiles/mcpedia-web \
|
||||||
|
--set '$STORE_PATH' && \
|
||||||
|
(cd /home/code/mcpedia && git fetch origin main && git reset --hard origin/main) && \
|
||||||
|
sudo rm -rf /home/code/mcpedia/apps/web/.next && \
|
||||||
|
sudo cp -r '$STORE_PATH/share/mcpedia-web/.next' /home/code/mcpedia/apps/web/.next && \
|
||||||
|
sudo systemctl restart mcpedia-web && \
|
||||||
|
sleep 3 && \
|
||||||
|
sudo systemctl status mcpedia-web --no-pager --no-legend | head -5"
|
||||||
|
|
||||||
|
- name: Verify public URL
|
||||||
|
env:
|
||||||
|
VPS_HOST: ${{ secrets.SSH_DEPLOY_HOST }}
|
||||||
|
VPS_USER: ${{ secrets.SSH_DEPLOY_USER }}
|
||||||
|
run: |
|
||||||
|
ssh "$VPS_USER@$VPS_HOST" \
|
||||||
|
"curl -sk -o /dev/null -w '%{http_code}' https://wiki.asepharyana.my.id/"
|
||||||
@@ -43,3 +43,9 @@ next-env.d.ts
|
|||||||
# monorepo / turbo
|
# monorepo / turbo
|
||||||
.turbo/
|
.turbo/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# MCPedia content — DB is source of truth via MCP API (create_document/update_document)
|
||||||
|
# Filesystem .md files are auto-generated backups, not tracked in git.
|
||||||
|
# To edit content: use the MCP create_document/update_document tools (requires x-webhook-secret)
|
||||||
|
content/**/*.md
|
||||||
|
!content/**/.gitkeep
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
# MCPedia Implementation Plan
|
|
||||||
|
|
||||||
> **For Hermes:** Use subagent-driven-development to implement task-by-task. Phase 1 tasks are bite-sized and ready to execute; Phases 2–4 are outlines to expand later.
|
|
||||||
|
|
||||||
**Goal:** Turn the fresh `create-next-app` Next.js 16 repo into the MCPedia knowledge base — Git/Markdown content, a Core business-logic layer shared by a Next.js Web UI and an MCP server for AI agents, with content metadata in Postgres (FTS search), and an architecture that can grow to semantic search, a tRPC API, auth, and async indexing without rewrites.
|
|
||||||
|
|
||||||
**Architecture:** Monorepo (bun workspaces + Turborepo). Content lives as Markdown in `content/` (Git-managed, human-readable). `packages/core` holds all business logic (Document/Content/Search services). Web (`apps/web`) and MCP (`apps/mcp`) both consume Core — no DB access outside Core. Postgres (imrnes `:6432`) stores document metadata + a `tsvector` for keyword search. Phase 1 ships keyword search only; pgvector/semantic, tRPC/Hono API, auth, and BullMQ are later phases behind clean seamsLine truncated
|
|
||||||
|
|
||||||
**Tech Stack (resolved forks):**
|
|
||||||
- Package manager / monorepo: **bun workspaces + Turborepo** (honor existing repo; NOT pnpm — user decision).
|
|
||||||
- DB: **imrnes Postgres `100.121.180.82:6432`** for both dev and deploy (PgBouncer transaction pool → set `prepare: false` on the driver).
|
|
||||||
- Phase 1 scope: **Core + Web + MCP + Postgres FTS only**. tRPC/Hono API, pgvector, auth, Redis/BullMQ deferred.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Environment & preconditions (read first)
|
|
||||||
|
|
||||||
- Repo is currently a flat Next.js 16 app, **not git-initialized**, bun-managed (`packageManager: bun@1.3.14`, `bun.lock` present).
|
|
||||||
- `app/` has only `layout.tsx`, `page.tsx`, `globals.css`, `favicon.ico`. Tailwind v4 (`@tailwindcss/postcss`).
|
|
||||||
- Next.js 16 is "NOT the Next.js you know" (see `AGENTS.md`): **before writing any `apps/web` code, read `node_modules/next/dist/docs/`** (app router, server components, route handlers) for breaking changes. Treat deprecation notices as authoritative.
|
|
||||||
- For every library API used (Next 16, Drizzle, `@modelcontextprotocol/sdk`, gray-matter) **resolve + query Context7** before coding — do not trust training data.
|
|
||||||
- Secrets: `DATABASE_URL` lives in **BWS project `orangevps`** (never committed). Local dev reads it from a gitignored `.env` at repo root.
|
|
||||||
- Seed DB creds: a dedicated `mcpedia` database/user on imrnes; create via `bws` secret or operator. `pgvector` extension is **NOT** required until Phase 2.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 0 — Repo restructure (monorepo scaffold)
|
|
||||||
|
|
||||||
### Task 0.1: Initialize git + baseline commit
|
|
||||||
- **Files:** `.gitignore` (already exists; ensure `.env`, `.next/`, `node_modules/`, `*.tsbuildinfo`, `dist/` ignored).
|
|
||||||
- **Step:** `git init && git add -A && git commit -m "chore: baseline create-next-app scaffold"` (author = asepharyana, no Co-Authored-By).
|
|
||||||
- **Verify:** `git log --oneline -1` shows the commit.
|
|
||||||
|
|
||||||
### Task 0.2: Root workspace package.json + Turbo
|
|
||||||
- **Modify:** `package.json` (root) — strip app deps, add workspaces + turbo scripts.
|
|
||||||
- **Create:** `turbo.json`, `tsconfig.base.json`.
|
|
||||||
- Root `package.json` becomes:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "mcpedia",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"packageManager": "bun@1.3.14",
|
|
||||||
"workspaces": ["apps/*", "packages/*"],
|
|
||||||
"scripts": {
|
|
||||||
"dev": "turbo run dev",
|
|
||||||
"build": "turbo run build",
|
|
||||||
"lint": "turbo run lint",
|
|
||||||
"typecheck": "turbo run typecheck",
|
|
||||||
"index": "bun run scripts/indexer.ts"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"turbo": "^2",
|
|
||||||
"typescript": "^5",
|
|
||||||
"prettier": "^3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `turbo.json`:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"$schema": "https://turbo.build/schema.json",
|
|
||||||
"tasks": {
|
|
||||||
"build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**"] },
|
|
||||||
"lint": {},
|
|
||||||
"typecheck": { "dependsOn": ["^build"] },
|
|
||||||
"dev": { "cache": false, "persistent": true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- **Verify:** `bun install` succeeds; `bunx turbo --version` prints a version.
|
|
||||||
|
|
||||||
### Task 0.3: Move Web app into `apps/web`
|
|
||||||
- **Move:** `app/`, `public/`, `next.config.ts`, `next-env.d.ts`, `postcss.config.mjs`, `eslint.config.mjs`, `tsconfig.json` → `apps/web/` (rename root `app` → `apps/web/app`). Keep Tailwind v4. Add `apps/web/package.json`:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "@mcpedia/web",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "next dev",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "next start",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"next": "16.3.1",
|
|
||||||
"react": "19.2.8",
|
|
||||||
"react-dom": "19.2.8",
|
|
||||||
"@mcpedia/core": "workspace:*",
|
|
||||||
"@mcpedia/config": "workspace:*"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tailwindcss/postcss": "^4",
|
|
||||||
"@types/node": "^20",
|
|
||||||
"@types/react": "^19",
|
|
||||||
"@types/react-dom": "^19",
|
|
||||||
"eslint": "^9",
|
|
||||||
"eslint-config-next": "16.3.1",
|
|
||||||
"tailwindcss": "^4",
|
|
||||||
"typescript": "^5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- Add `apps/web/tsconfig.json` extending `../../tsconfig.base.json` with `@/*` → `./**/*` and `@mcpedia/*` workspace aliases.
|
|
||||||
- **Verify:** `cd apps/web && bun install && bun run build` compiles (may warn about empty content — fine).
|
|
||||||
|
|
||||||
### Task 0.4: `.env` (gitignored) for dev
|
|
||||||
- **Create:** `.env` (gitignored) with `DATABASE_URL=postgresql://<user>:<pass>@100.121.180.82:6432/mcpedia` (value from BWS; `prepare:false` enforced in db client). Add `.env.example` (committed, placeholder only).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1 — MVP: Core + Web + MCP + Postgres FTS (detailed)
|
|
||||||
|
|
||||||
### Task 1.1: `packages/types`
|
|
||||||
- **Create:** `packages/types/package.json` (`@mcpedia/types`, exports `src/index.ts`), `packages/types/src/index.ts`:
|
|
||||||
```ts
|
|
||||||
export type DocSection = "docs" | "writeups" | "research" | "notes";
|
|
||||||
export type DocType = "documentation" | "writeup" | "research" | "note";
|
|
||||||
export type DocStatus = "published" | "draft";
|
|
||||||
|
|
||||||
export interface DocumentMeta {
|
|
||||||
id: string; // slug
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
type: DocType;
|
|
||||||
section: DocSection;
|
|
||||||
status: DocStatus;
|
|
||||||
author: string;
|
|
||||||
tags: string[];
|
|
||||||
path: string; // relative path under content/
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Document extends DocumentMeta {
|
|
||||||
body: string; // raw markdown (read from disk or stored)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchHit {
|
|
||||||
doc: DocumentMeta;
|
|
||||||
rank: number;
|
|
||||||
snippet: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- **Verify:** `bunx tsc --noEmit` in package passes.
|
|
||||||
|
|
||||||
### Task 1.2: `packages/config`
|
|
||||||
- **Create:** `packages/config/package.json` (`@mcpedia/config`), `packages/config/src/index.ts`:
|
|
||||||
```ts
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { dirname, resolve } from "node:path";
|
|
||||||
|
|
||||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
||||||
export const CONTENT_ROOT = process.env.CONTENT_ROOT
|
|
||||||
?? resolve(root, "content");
|
|
||||||
export const DATABASE_URL = process.env.DATABASE_URL ?? "";
|
|
||||||
```
|
|
||||||
- **Verify:** import in a scratch `bun -e` prints a path.
|
|
||||||
|
|
||||||
### Task 1.3: `packages/db` (Drizzle + Postgres, FTS)
|
|
||||||
- **Create:** `packages/db/package.json` (`@mcpedia/db`, deps `drizzle-orm`, `postgres`; dev `drizzle-kit`), `packages/db/src/schema.ts`:
|
|
||||||
```ts
|
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { text, timestamp, integer, index, customType } from "drizzle-orm/pg-core";
|
|
||||||
|
|
||||||
// tsvector isn't a first-class drizzle type; wrap raw.
|
|
||||||
export const documents = pgTable("documents", {
|
|
||||||
id: text("id").primaryKey(), // slug
|
|
||||||
slug: text("slug").notNull().unique(),
|
|
||||||
title: text("title").notNull(),
|
|
||||||
type: text("type").notNull(),
|
|
||||||
section: text("section").notNull(),
|
|
||||||
status: text("status").notNull().default("published"),
|
|
||||||
author: text("author").notNull().default(""),
|
|
||||||
tags: text("tags").array().notNull().default(sql`'{}'`),
|
|
||||||
path: text("path").notNull(),
|
|
||||||
body: text("body").notNull().default(""),
|
|
||||||
searchVector: customType<{ data: string }>({
|
|
||||||
dataType() { return "tsvector"; },
|
|
||||||
})("search_vector")
|
|
||||||
.generatedAlwaysAs(sql`to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(body,''))`),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
|
|
||||||
}, (t) => ({
|
|
||||||
searchIdx: index("documents_search_idx").using("gin", t.searchVector),
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
NOTE: `document_chunks` + `embedding vector` are **Phase 2** — do NOT add now.
|
|
||||||
- **Create:** `packages/db/src/client.ts` — postgres-js with `prepare: false` (PgBouncer safe):
|
|
||||||
```ts
|
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
|
||||||
import postgres from "postgres";
|
|
||||||
import { DATABASE_URL } from "@mcpedia/config";
|
|
||||||
import * as schema from "./schema";
|
|
||||||
|
|
||||||
const client = postgres(DATABASE_URL, { prepare: false, max: 5 });
|
|
||||||
export const db = drizzle(client, { schema });
|
|
||||||
export { schema };
|
|
||||||
```
|
|
||||||
- **Create:** `packages/db/drizzle.config.ts` (dialect `postgresql`, schema `./src/schema.ts`, out `./drizzle`).
|
|
||||||
- **Verify:** `bunx drizzle-kit generate` produces a migration; `bunx drizzle-kit push` applies it to imrnes (operator supplies DATABASE_URL). `bunx drizzle-kit studio` optional.
|
|
||||||
|
|
||||||
### Task 1.4: `packages/parser` (frontmatter)
|
|
||||||
- **Create:** `packages/parser/package.json` (`@mcpedia/parser`, dep `gray-matter`), `packages/parser/src/index.ts`:
|
|
||||||
```ts
|
|
||||||
import matter from "gray-matter";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import type { DocSection, DocType, DocStatus, DocumentMeta } from "@mcpedia/types";
|
|
||||||
|
|
||||||
const SECTIONS: DocSection[] = ["docs", "writeups", "research", "notes"];
|
|
||||||
|
|
||||||
export function parseFile(absPath: string, relPath: string): { meta: DocumentMeta; body: string } {
|
|
||||||
const raw = readFileSync(absPath, "utf8");
|
|
||||||
const { data, content } = matter(raw);
|
|
||||||
const section = SECTIONS.find((s) => relPath.startsWith(s + "/")) ?? "docs";
|
|
||||||
const slug = relPath.replace(/\.mdx?$/, "");
|
|
||||||
const meta: DocumentMeta = {
|
|
||||||
id: slug,
|
|
||||||
slug,
|
|
||||||
title: data.title ?? slug,
|
|
||||||
type: (data.type ?? "documentation") as DocType,
|
|
||||||
section,
|
|
||||||
status: (data.status ?? "published") as DocStatus,
|
|
||||||
author: data.author ?? "",
|
|
||||||
tags: Array.isArray(data.tags) ? dataAtags : [],
|
|
||||||
path: relPath,
|
|
||||||
createdAt: data.created_at ?? new Date().toISOString(),
|
|
||||||
updatedAt: data.updated_at ?? data.created_at ?? new Date().toISOString(),
|
|
||||||
};
|
|
||||||
return { meta, body: content };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
(fix the obvious `dataAtags` typo to `data.tags` when implementing.)
|
|
||||||
- **Verify:** unit test parsing a sample frontmatter → asserts `slug`, `section`, `tags`.
|
|
||||||
|
|
||||||
### Task 1.5: `packages/search` (FTS query)
|
|
||||||
- **Create:** `packages/search/package.json` (`@mcpedia/search`, dep `@mcpedia/db`, `@mcpedia/types`), `packages/search/src/index.ts`:
|
|
||||||
```ts
|
|
||||||
import { db } from "@mcpedia/db";
|
|
||||||
import { documents } from "@mcpedia/db/schema";
|
|
||||||
import { sql, and, eq } from "drizzle-orm";
|
|
||||||
import type { SearchHit } from "@mcpedia/types";
|
|
||||||
|
|
||||||
export function toTsQuery(q: string): string {
|
|
||||||
// plainto_tsquery-style: AND the terms, escape punctuation.
|
|
||||||
const terms = q.trim().split(/\s+/).filter(Boolean).map((t) => t.replace(/[^\p{L}\p{N}]/gu, ""));
|
|
||||||
return terms.map((t) => `${t}:*`).join(" & ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function keywordSearch(q: string, limit = 20): Promise<SearchHit[]> {
|
|
||||||
const query = toTsQuery(q);
|
|
||||||
if (!query) return [];
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
|
||||||
doc: documents,
|
|
||||||
rank: sql<number>`ts_rank(${documents.searchVector}, to_tsquery('simple', ${query}))`,
|
|
||||||
snippet: sql<string>`ts_headline('simple', ${documents.body}, to_tsquery('simple', ${query}), 'MaxWords=20, MinWords=5')`,
|
|
||||||
})
|
|
||||||
.from(documents)
|
|
||||||
.where(and(eq(documents.status, "published"), sql`${documents.searchVector} @@ to_tsquery('simple', ${query})`))
|
|
||||||
.orderBy(sql`ts_rank(${documents.searchVector}, to_tsquery('simple', ${query})) desc`)
|
|
||||||
.limit(limit);
|
|
||||||
return rows.map((r) => ({ doc: r.doc, rank: r.rank, snippet: r.snippet }));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- **Verify:** after seeding, `keywordSearch("websocket")` returns hits.
|
|
||||||
|
|
||||||
### Task 1.6: `packages/core` (services)
|
|
||||||
- **Create:** `packages/core/package.json` (`@mcpedia/core`, deps `@mcpedia/db`, `@mcpedia/parser`, `@mcpedia/search`, `@mcpedia/config`, `@mcpedia/types`), `packages/core/src/`:
|
|
||||||
- `content.service.ts` — read markdown from `CONTENT_ROOT` by slug/section; list files via `fast-glob`.
|
|
||||||
- `document.service.ts` — `listDocuments(section?, type?, status?)`, `getDocument(slug)` (DB meta + disk body), `getRelated(slug)` (shared tags, later vector).
|
|
||||||
- `search.service.ts` — wraps `keywordSearch`.
|
|
||||||
- `index.ts` — re-export services + types.
|
|
||||||
```ts
|
|
||||||
// document.service.ts (sketch)
|
|
||||||
import { db } from "@mcpedia/db";
|
|
||||||
import { documents } from "@mcpedia/db/schema";
|
|
||||||
import { eq, and } from "drizzle-orm";
|
|
||||||
import { CONTENT_ROOT } from "@mcpedia/config";
|
|
||||||
import { readFileSync, existsSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import type { Document, DocumentMeta } from "@mcpedia/types";
|
|
||||||
|
|
||||||
export async function listDocuments(opts: { section?: string; status?: string } = {}): Promise<DocumentMeta[]> {
|
|
||||||
const where = [eq(documents.status, opts.status ?? "published")];
|
|
||||||
if (opts.section) where.push(eq(documents.section, opts.section));
|
|
||||||
return db.select().from(documents).where(and(...where));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getDocument(slug: string): Promise<Document | null> {
|
|
||||||
const [meta] = await db.select().from(documents).where(eq(documents.slug, slug));
|
|
||||||
if (!meta) return null;
|
|
||||||
const abs = resolve(CONTENT_ROOT, meta.path);
|
|
||||||
const body = existsSync(abs) ? readFileSync(abs, "utf8") : meta.body;
|
|
||||||
return { ...meta, body };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- **Verify:** `bunx tsc --noEmit` passes across packages.
|
|
||||||
|
|
||||||
### Task 1.7: `scripts/indexer.ts` (walk content → upsert)
|
|
||||||
- **Create:** `scripts/indexer.ts` — walk `CONTENT_ROOT` (`**/*.{md,mdx}`), `parseFile`, upsert into `documents` (slug PK; `onConflictDoUpdate` title/tags/body/updatedAt). Logs count.
|
|
||||||
```ts
|
|
||||||
import { db } from "@mcpedia/db";
|
|
||||||
import { documents } from "@mcpedia/db/schema";
|
|
||||||
import { parseFile } from "@mcpedia/parser";
|
|
||||||
import { CONTENT_ROOT } from "@mcpedia/config";
|
|
||||||
import { walk } from "fast-glob";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const files = await glob("**/*.{md,mdx}", { cwd: CONTENT_ROOT, absolute: true });
|
|
||||||
let n = 0;
|
|
||||||
for (const abs of files) {
|
|
||||||
const rel = resolve(CONTENT_ROOT, abs).replace(CONTENT_ROOT + "/", "");
|
|
||||||
const { meta, body } = parseFile(abs, rel);
|
|
||||||
await db.insert(documents).values({ ...meta, body })
|
|
||||||
.onConflictDoUpdate({ target: documents.slug, set: { title: meta.title, tags: meta.tags, body, updatedAt: new Date().toISOString() } });
|
|
||||||
n++;
|
|
||||||
}
|
|
||||||
console.log(`indexed ${n} documents`);
|
|
||||||
```
|
|
||||||
- **Verify:** `bun run index` prints `indexed N documents`; `psql`/`drizzle-kit studio` shows rows + non-empty `search_vector`.
|
|
||||||
|
|
||||||
### Task 1.8: Seed content
|
|
||||||
- **Create:** `content/docs/websocket/contract.md`, `content/writeups/debugging/websocket-timeout.md`, `content/research/mcp/architecture.md`, `content/notes/typescript/patterns.md` with the spec's frontmatter shape (id/title/type/tags/status/author/created_at/updated_at). Add 2–3 short paragraphs each so FTS has text.
|
|
||||||
- **Verify:** `bun run index` ingests them; `keywordSearch("websocket")` returns ≥2 hits.
|
|
||||||
|
|
||||||
### Task 1.9: `apps/web` — pages (Next 16, read `node_modules/next/dist/docs` first)
|
|
||||||
- **Read** Node_modules Next 16 docs (app router, server components, route handlers) — mandatory.
|
|
||||||
- **Create:**
|
|
||||||
- `apps/web/app/page.tsx` — home: list sections + recent docs (server component calling `listDocuments`).
|
|
||||||
- `apps/web/app/(docs)/[...slug]/page.tsx` — document view: `getDocument(slug)`, render markdown (Phase 1: simple `dangerouslySetInnerHTML` of a remark→HTML, or raw pre with Shiki later; keep minimal but legible).
|
|
||||||
- `apps/web/app/search/page.tsx` — search box → `keywordSearch`, render hits + snippets.
|
|
||||||
- Keep Tailwind v4 styling minimal but clean (no shadcn yet — Phase 2 polish).
|
|
||||||
- **Verify:** `bun run build` (apps/web) passes; `bun run dev` then `curl -s localhost:3000/` and `/search?q=websocket` return rendered HTML.
|
|
||||||
|
|
||||||
### Task 1.10: `apps/mcp` — MCP server
|
|
||||||
- **Create:** `apps/mcp/package.json` (`@mcpedia/mcp`, deps `@modelcontextprotocol/sdk`, `zod`, `@mcpedia/core`), `apps/mcp/src/index.ts`:
|
|
||||||
```ts
|
|
||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { listDocuments, getDocument } from "@mcpedia/core";
|
|
||||||
import { keywordSearch } from "@mcpedia/search";
|
|
||||||
|
|
||||||
const server = new McpServer({ name: "mcpedia", version: "0.1.0" });
|
|
||||||
|
|
||||||
server.tool("search_documents", { query: z.string(), limit: z.number().optional() },
|
|
||||||
async ({ query, limit }) => {
|
|
||||||
const hits = await keywordSearch(query, limit ?? 20);
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(hits, null, 2) }] };
|
|
||||||
});
|
|
||||||
|
|
||||||
server.tool("get_document", { slug: z.string() },
|
|
||||||
async ({ slug }) => {
|
|
||||||
const doc = await getDocument(slug);
|
|
||||||
return doc ? { content: [{ type: "text", text: doc.body }] }
|
|
||||||
: { content: [{ type: "text", text: "not found" }], isError: true };
|
|
||||||
});
|
|
||||||
|
|
||||||
server.tool("list_documents", { section: z.string().optional() },
|
|
||||||
async ({ section }) => {
|
|
||||||
const docs = await listDocuments({ section });
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(docs, null, 2) }] };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Resource: mcpedia://<section>/<path>
|
|
||||||
server.resource("doc", "mcpedia://", async (uri) => {
|
|
||||||
const slug = uri.path.replace(/^\//, "");
|
|
||||||
const doc = await getDocument(slug);
|
|
||||||
return { contents: [{ uri: uri.href, text: doc?.body ?? "" }] };
|
|
||||||
});
|
|
||||||
|
|
||||||
const transport = new StdioServerTransport();
|
|
||||||
await server.connect(transport);
|
|
||||||
```
|
|
||||||
(Exact SDK method names verified via Context7 before coding.)
|
|
||||||
- **Create:** `apps/mcp/bin` or `bun run` entry; add `"start": "bun run src/index.ts"` to its package.json.
|
|
||||||
- **Verify:** smoke test via `scripts/mcp-smoke.ts` that imports the tool handlers and calls `search_documents({query:"websocket"})` → asserts ≥1 hit. (Running a real stdio client in CI is optional; inspector `@modelcontextprotocol/inspector` for manual check.)
|
|
||||||
|
|
||||||
### Task 1.11: Cross-package verification
|
|
||||||
- **Run:** `bun install` (root) → `bunx turbo run lint` → `bunx turbo run build` → `bunx turbo run typecheck`. All green.
|
|
||||||
- **Run:** `bun run index` then `bun run scripts/mcp-smoke.ts`.
|
|
||||||
- **Verify:** no package imports `pg`/`drizzle` outside `@mcpedia/core` or `@mcpedia/db` (architecture guard). Search the tree: `search_files("from \"pg\"", path="apps")`.
|
|
||||||
|
|
||||||
### Task 1.12: README + phase markers
|
|
||||||
- **Modify:** `README.md` — replace create-next-app text with MCPedia overview, monorepo map, "Phase 1 status", how to dev (`bun install && bun run index && bun run dev`), how to run MCP (`bun run --cwd apps/mcp start`).
|
|
||||||
- **Verify:** `cat README.md` reads correctly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2 — Semantic search + tRPC/Hono API + auth (outline, expand later)
|
|
||||||
|
|
||||||
- Add `document_chunks` table + `embedding vector` (pgvector extension on imrnes — operator enables `CREATE EXTENSION vector`).
|
|
||||||
- `packages/search`: `EmbeddingProvider` abstraction (OpenAI/Gemini/Ollama/Local) behind an interface (spec principle 6). Chunking in `packages/parser` (by heading/section).
|
|
||||||
- Hybrid ranking: fuse FTS `ts_rank` + cosine distance (Reciprocal Rank Fusion).
|
|
||||||
- `apps/api`: Hono + tRPC router exposing `documents.*`, `search.*`; Web switches from direct Core calls to tRPC client; MCP may also call API or keep Core (decide: keep Core for colocated simplicity, API for external).
|
|
||||||
- `packages/auth`: Auth.js/OIDC adapter; `permissions` table; gate write/mutations. Phase 1 documents are public-read.
|
|
||||||
- Categories + tags linking tables (`categories`, `document_tags`, `references`).
|
|
||||||
- MDX rendering with Shiki + shadcn/ui polish on Web.
|
|
||||||
|
|
||||||
## Phase 3 — Async indexing + revisions (outline)
|
|
||||||
|
|
||||||
- Redis (imrnes or local) + BullMQ workers: parse/chunk/embed/reindex off the request path.
|
|
||||||
- `document_revisions` table + revision API; Git sync hook (content/ push → reindex).
|
|
||||||
- Indexer becomes a worker triggered by file watch / webhook, not a manual script.
|
|
||||||
|
|
||||||
## Phase 4 — Scale (outline, only if needed)
|
|
||||||
|
|
||||||
- Dedicated search engine (OpenSearch) behind the same `SearchService` seam — YAGNI until FTS+pgvector saturates.
|
|
||||||
- Object storage for assets, advanced ranking, distributed workers, observability, multi-tenant. Docker Compose for service orchestration pointing at imrnes Postgres.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Risks / tradeoffs / open questions
|
|
||||||
|
|
||||||
- **Next 16 breaking changes** — biggest unknown; mitigated by reading `node_modules/next/dist/docs` before Web tasks. If app-router differs enough, adjust Task 1.9 accordingly.
|
|
||||||
- **PgBouncer + prepared statements** — handled via `prepare: false`; if imrnes uses session pool mode, can drop it.
|
|
||||||
- **FTS language** — using `'simple'` config (no stemming) so mixed ID/EN queries match literally; revisit if relevance is poor (could add `'english'` or a custom ID dict).
|
|
||||||
- **pgvector on imrnes** — extension may need operator enablement; Phase 2 blocker, not Phase 1.
|
|
||||||
- **MCP resource URI shape** — `mcpedia://<section>/<path>` is a sketch; finalize against MCP SDK spec in Task 1.10 (Context7).
|
|
||||||
- **Monorepo driver** — bun workspaces chosen over pnpm per user; Turborepo runs fine under bun. If `turbo` bin resolution misbehaves under bun, fall back to `bunx turbo`.
|
|
||||||
|
|
||||||
## Verification matrix (Phase 1)
|
|
||||||
|
|
||||||
| Check | Command | Expect |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Install | `bun install` | clean |
|
|
||||||
| Lint | `bunx turbo run lint` | 0 errors |
|
|
||||||
| Typecheck | `bunx turbo run typecheck` | 0 errors |
|
|
||||||
| Build | `bunx turbo run build` | web + mcp build |
|
|
||||||
| Index | `bun run index` | `indexed N documents` |
|
|
||||||
| Web | `bun run dev` (apps/web) + curl `/` and `/search?q=websocket` | rendered HTML, hits |
|
|
||||||
| MCP | `bun run scripts/mcp-smoke.ts` | ≥1 hit for "websocket" |
|
|
||||||
| Arch guard | search for `from "pg"` outside `packages/db`,`packages/core` | none |
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
# MCPedia Phase 2 — Semantic Search + tRPC/Hono API
|
|
||||||
|
|
||||||
> **For Hermes:** implement task-by-task. Spec-first (user rule 2026-08-19).
|
|
||||||
|
|
||||||
**Goal:** Add semantic + hybrid search (pgvector) and a typed tRPC/Hono API so
|
|
||||||
MCPedia is queryable by embeddings, not just keyword FTS — and expose the
|
|
||||||
corpus over a programmatic HTTP API.
|
|
||||||
|
|
||||||
**Architecture:** Content (Markdown) → chunk → embed (OpenRouter) → store
|
|
||||||
`document_chunks` with `vector(N)` in Postgres → `semanticSearch` (cosine) and
|
|
||||||
`hybridSearch` (FTS + cosine, reciprocal-rank fusion) in `@mcpedia/search` →
|
|
||||||
exposed via Core, the MCP server (new tools), and a new `apps/api` (Hono +
|
|
||||||
tRPC v11).
|
|
||||||
|
|
||||||
**Embedding provider:** OpenRouter (`openrouter/llama-nemotron-embed-vl-1b-v2:free`)
|
|
||||||
via `9router_ai_llm_api_key` + `9router_ai_llm_base_url` (BWS). Dimension is
|
|
||||||
discovered at first live call (see Step 1.3) and pinned in schema/migration.
|
|
||||||
|
|
||||||
**Tech stack:** drizzle-orm `vector` column + pgvector extension, HNSW index,
|
|
||||||
`@trpc/server` v11 (fetch adapter), `hono` + `@hono/node-server`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task P2.1 — `packages/embeddings` (provider + abstraction)
|
|
||||||
|
|
||||||
**Files:** `packages/embeddings/package.json`, `src/index.ts`, `src/provider.ts`,
|
|
||||||
`src/openrouter.ts`
|
|
||||||
|
|
||||||
- `EmbeddingProvider` interface: `embed(texts: string[]): Promise<number[][]>`, `readonly model`, `readonly dimensions`.
|
|
||||||
- `OpenRouterEmbeddingProvider`: POST `${baseUrl}/embeddings` with `{ model, input }`,
|
|
||||||
`Authorization: Bearer ${key}`. Returns `data[].embedding`. Validate length === dimensions.
|
|
||||||
- Read `EMBED_BASE_URL`, `EMBED_API_KEY`, `EMBED_MODEL` from `@mcpedia/config`
|
|
||||||
(with `.env` fallback). Dimensions discovered live (Step 1.3) → export `EMBED_DIM`.
|
|
||||||
- Chunk helper `chunkText(text, { size=1000, overlap=150 })` in `src/chunk.ts`.
|
|
||||||
|
|
||||||
**Step 1.3 (discover dim):** live call `embed(["test"])`, read `embedding.length`,
|
|
||||||
pin `EMBED_DIM`, assert mismatch throws.
|
|
||||||
|
|
||||||
**Verify:** `bun run` a temp script: `embed(["hello world"])` prints a vector of
|
|
||||||
length N (e.g. 1024). Confirm no key is logged.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task P2.2 — Schema: `document_chunks` + vector extension
|
|
||||||
|
|
||||||
**Files:** `packages/db/src/schema.ts` (add), `packages/db/drizzle.config.ts`
|
|
||||||
(unchanged), new migration.
|
|
||||||
|
|
||||||
- `CREATE EXTENSION IF NOT EXISTS vector;` (idempotent; run once via psql).
|
|
||||||
- `document_chunks` table:
|
|
||||||
- `id` uuid pk default gen_random_uuid()
|
|
||||||
- `document_id` text → `documents.id` on delete cascade
|
|
||||||
- `slug` text (denormalized for convenience)
|
|
||||||
- `chunk_index` integer
|
|
||||||
- `content` text
|
|
||||||
- `embedding` vector(EMBED_DIM)
|
|
||||||
- `created_at` timestamp default now()
|
|
||||||
- index `chunk_embedding_idx` using hnsw (`embedding` op `vector_cosine_ops`)
|
|
||||||
- Generate migration with `drizzle-kit generate`, apply via `psql` (drizzle-kit
|
|
||||||
push is unreliable here — known).
|
|
||||||
|
|
||||||
**Verify:** `\d document_chunks` shows `embedding vector(N)` + HNSW index;
|
|
||||||
`select count(*) from document_chunks` = 0.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task P2.3 — Indexer: chunk + embed + upsert
|
|
||||||
|
|
||||||
**Files:** `scripts/indexer.ts` (extend), `packages/core/src/document.service.ts`
|
|
||||||
(add `indexChunks`).
|
|
||||||
|
|
||||||
- For each published doc: read body (already on disk), `chunkText`, `embed` in
|
|
||||||
batches (≤ 16), delete existing chunks for slug, insert new rows.
|
|
||||||
- Guard: if embedding provider fails, log + skip (don't crash the whole index).
|
|
||||||
- Add `bun run index:embed` (or extend `bun run index` to also embed).
|
|
||||||
|
|
||||||
**Verify:** after running, `select count(*) from document_chunks` > 0; a sample
|
|
||||||
row has non-null `embedding`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task P2.4 — `packages/search`: semantic + hybrid
|
|
||||||
|
|
||||||
**Files:** `packages/search/src/index.ts` (add `semanticSearch`, `hybridSearch`).
|
|
||||||
|
|
||||||
- `semanticSearch(vec, limit)`: order by `embedding <=> ${vec}` asc, filter published.
|
|
||||||
- `hybridSearch(q, limit)`: run FTS (`ts_rank`) + semantic (cosine) in parallel;
|
|
||||||
fuse with reciprocal-rank (RRF: score = 1/(k+rank), k=60); return merged hits.
|
|
||||||
- Keep `keywordSearch` unchanged (Phase 1).
|
|
||||||
|
|
||||||
**Verify:** unit-ish script: embed a query, `semanticSearch` returns relevant
|
|
||||||
chunks; `hybridSearch("websocket")` returns ≥ keyword results.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task P2.5 — `packages/core` expose semantic/hybrid
|
|
||||||
|
|
||||||
**Files:** `packages/core/src/search.service.ts`, `index.ts`.
|
|
||||||
|
|
||||||
- Re-export `semanticSearch`, `hybridSearch` from Core.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task P2.6 — `apps/api` (Hono + tRPC v11)
|
|
||||||
|
|
||||||
**Files:** `apps/api/package.json`, `tsconfig.json`, `src/index.ts`,
|
|
||||||
`src/router.ts`, `src/trpc.ts`.
|
|
||||||
|
|
||||||
- `initTRPC.create()` router with procedures: `search`, `semanticSearch`,
|
|
||||||
`hybridSearch`, `getDocument`, `listDocuments` (mirrors MCP tools).
|
|
||||||
- Mount `fetchRequestHandler` on a Hono app at `/trpc/*`; serve via
|
|
||||||
`@hono/node-server` `serve({ fetch: app.fetch, port: 4020 })`.
|
|
||||||
- `createContext` returns `{ db }`.
|
|
||||||
|
|
||||||
**Verify:** `bun run dev` → `curl -X POST localhost:4020/trpc/search`
|
|
||||||
with JSON body returns hits.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task P2.7 — MCP server: semantic + hybrid tools
|
|
||||||
|
|
||||||
**Files:** `apps/mcp/src/index.ts` (add `semantic_search`, `hybrid_search`),
|
|
||||||
extend `smoke.test.ts`.
|
|
||||||
|
|
||||||
- `semantic_search`: embed query → `semanticSearch`.
|
|
||||||
- `hybrid_search`: embed query → `hybridSearch`.
|
|
||||||
- Smoke: assert both return ≥1 hit for "websocket".
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task P2.8 — Web: semantic toggle on search
|
|
||||||
|
|
||||||
**Files:** `apps/web/app/search/page.tsx`.
|
|
||||||
|
|
||||||
- Add `mode=keyword|hybrid` query param; server component calls Core
|
|
||||||
`hybridSearch` when `mode=hybrid`. Minimal UI toggle (link/buttons).
|
|
||||||
- Keep keyword as default.
|
|
||||||
|
|
||||||
**Verify:** `bun run build`; `curl '/search?q=websocket&mode=hybrid'` returns hits.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task P2.9 — Verify all + commit
|
|
||||||
|
|
||||||
- `bunx turbo run build` (web + api + mcp), `bun run apps/mcp smoke`,
|
|
||||||
live API curl, live web hybrid search.
|
|
||||||
- Update `README.md` + `PHASES.md` (mark Phase 2 ✅).
|
|
||||||
- `git add -A` (exclude `.env`), commit as asepharyana (no Co-Authored-By).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Risks / decisions
|
|
||||||
- **Dimension unknown until live call** → P2.1.3 discovers it; pinned EMBED_DIM=2048.
|
|
||||||
- **pgvector NOT available on shared imrnes Postgres** (extension not installed;
|
|
||||||
installing needs host-level apt on a managed/shared DB — deferred). PIVOT:
|
|
||||||
store `embedding` as `real[]` and compute cosine similarity in the app layer.
|
|
||||||
Brute-force cosine is instant for a KB-sized corpus (dozens of docs / hundreds
|
|
||||||
of chunks). pgvector+HNSW is the Phase-4 scale-out path.
|
|
||||||
- **PgBouncer + real[]**: fine; simple queries, no extension needed.
|
|
||||||
- **API port 4020** (host 4000s range is 4000–4015; 4020 is free for dev). Deploy later.
|
|
||||||
- **YAGNI**: no auth/revisions this phase (Phase 3).
|
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# MCPedia Frontend Redesign — Spec
|
||||||
|
|
||||||
|
Status: DRAFT — minta approval sebelum coding
|
||||||
|
Date: 2026-09-08 WIB
|
||||||
|
Author: Hermes
|
||||||
|
|
||||||
|
## 1. Tujuan
|
||||||
|
|
||||||
|
Merombak `apps/web` agar:
|
||||||
|
- Satu sumber kebenaran: semua data berasal dari `@mcpedia/core` types & `@mcpedia/config` section presets; tidak ada shape duplikat di FE.
|
||||||
|
- Selaras dengan gateway/BE data flow: tRPC `apps/api` (`/trpc/*`) + REST `/api/*` + MCP `:4021` semua mengembalikan shape yang sama (DocumentMeta, SectionInfo, SearchHit, ExportData).
|
||||||
|
- Ganti pola fetch ad-hoc (`fetch("/api/...")` tersebar) dengan **SWR** terpusat — cache, revalidate, mutate, optimistic UI, error boundary konsisten.
|
||||||
|
- Hapus sisa TanStack (saat ini tidak ada deps TanStack — audit `apps/web/package.json` clean; tapi pola akan dibakukan supaya tidak masuk lagi).
|
||||||
|
- UI: Archival Precision tetap (monochrome, hairline, mono telemetry), tapi lebih live dan terisi data real (bukan placeholder).
|
||||||
|
|
||||||
|
## 2. Audit Status Quo
|
||||||
|
|
||||||
|
### FE sekarang
|
||||||
|
- Next.js 16.3.4, App Router, `force-dynamic` di semua page yang baca DB.
|
||||||
|
- RSC langsung import `@mcpedia/core` (`listDocuments`, `getDocument`, `listSections`, `getRelated`, `listRevisions`, `getExportDocuments`). SEO bagus, tapi client components (Sidebar, Header, Search, CommandMenu) fetch ulang via REST tanpa cache sharing.
|
||||||
|
- REST: `/api/search?q&mode&limit`, `/api/docs` (list), `/api/sections`, `/api/export?path&sort`, `/api/revisions/restore`, `/api/auth/login`.
|
||||||
|
- tRPC `apps/api` (Hono) expose: search/semanticSearch/hybridSearch, getDocument, listDocuments, sections, related, revisions, CRUD (gated `x-webhook-secret`), jobStatus, reindex hooks, `/health`, `/metrics`.
|
||||||
|
- MCP `:4021` Streamable HTTP `/mcp` expose 13 tools (tidak dipakai FE, tapi shape-nya sama).
|
||||||
|
- Tidak ada `swr` / `@tanstack/react-query` di `apps/web/package.json` — jadi migrasi = greenfield SWR layer, bukan replace.
|
||||||
|
- Design tokens sudah rapi di `globals.css` (CSS vars light/dark + `--brand`, `--bg-*`, `--text-*`, `--code-*`). Header/Sidebar/Markdown/TOC/DocActions sudah pakai tokens.
|
||||||
|
|
||||||
|
### Masalah yang diselesaikan redesign
|
||||||
|
1. Sidebar & Header fetch `/api/docs` + `/api/sections` tanpa deduplikasi — tiap navigasi fetch ulang, tidak ada stale-while-revalidate.
|
||||||
|
2. Search page client-side `fetch` manual tanpa debounce terpusat, tanpa cache antar-mode (hybrid/keyword/semantic saling overwrite).
|
||||||
|
3. Doc page RSC tidak live: setelah create/update/delete atau reindex, harus hard refresh. Tidak ada `mutate` cross-component.
|
||||||
|
4. Shape drift: Search `snippet` vs `SearchHit` vs `ChunkHit` tidak konsisten antara `/api/search` dan `@mcpedia/search` — perlu typed fetcher.
|
||||||
|
5. Tidak ada surface untuk queue/embeddings status (BE punya `jobStatus`, `queueStatus`, `INDEX_QUEUE` BullMQ) — FE buta terhadap indexing.
|
||||||
|
|
||||||
|
## 3. Arsitektur Target
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web/app/
|
||||||
|
lib/
|
||||||
|
fetcher.ts — typed fetcher (fetch JSON + error envelope)
|
||||||
|
swr.ts — SWRConfig global (dedupingInterval, focusThrottle, errorRetry)
|
||||||
|
api.ts — typed client untuk /api/* (listDocuments, search, sections, export, revisions)
|
||||||
|
hooks/
|
||||||
|
useSections.ts — SWR '/api/sections' -> SectionInfo[]
|
||||||
|
useDocuments.ts — SWR '/api/docs?section&status' -> DocumentMeta[]
|
||||||
|
useDocument.ts — SWR '/api/docs/:slug' (baru, GET single) -> Document
|
||||||
|
useSearch.ts — SWR key [q, mode, limit] -> SearchHit[] + debounced
|
||||||
|
useRelated.ts — SWR '/api/related?slug'
|
||||||
|
useRevisions.ts — SWR '/api/revisions?slug'
|
||||||
|
useQueueStatus.ts — SWR '/api/queue/status' poll 5s (opsional, admin only)
|
||||||
|
components/
|
||||||
|
(existing: Header, Sidebar, CommandMenu, Markdown, TOC, DocActions, ...)
|
||||||
|
SearchBar.tsx — reusable, dipakai Header + Search page + CommandMenu
|
||||||
|
SectionCard.tsx — extract dari page.tsx
|
||||||
|
DocumentCard.tsx — reusable card (recent, related, folder list)
|
||||||
|
TagCloud.tsx — baru: agregasi tags dari docs
|
||||||
|
QueueBadge.tsx — baru: show indexing status (waiting/active/failed)
|
||||||
|
EmptyState.tsx / ErrorState.tsx
|
||||||
|
app/
|
||||||
|
layout.tsx — tambah <SWRProvider> wrapper (client boundary)
|
||||||
|
page.tsx — refactor: RSC shell + client islands via SWR
|
||||||
|
[section]/page.tsx — sama
|
||||||
|
[section]/[...slug]/page.tsx — RSC hero + Markdown, client islands (related/revisions via SWR)
|
||||||
|
search/page.tsx — fully SWR, hapus useState fetch manual
|
||||||
|
create/page.tsx — mutate after create
|
||||||
|
dashboard/page.tsx — BARU (optional phase 2): queue, sections, recent, tag stats
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prinsip SWR
|
||||||
|
- Satu `fetcher` generic: `fetcher<T>(url: string): Promise<T>` — throw on !ok dengan `info` + `status` untuk error boundary.
|
||||||
|
- `SWRConfig` di `layout.tsx` client wrapper: `dedupingInterval: 4000`, `focusThrottleInterval: 10000`, `shouldRetryOnError: false` untuk search, `refreshInterval` hanya untuk queue.
|
||||||
|
- Semua hooks pakai key array/string yang deterministic supaya `mutate` cross-page bisa: `mutate('/api/docs')`, `mutate('/api/sections')`.
|
||||||
|
- Debounce search di hook, bukan di component: `useDebouncedValue(q, 180ms)` internal ke `useSearch`.
|
||||||
|
- Optimistic mutate untuk create/update/delete: `mutate('/api/docs', optimisticList, false)` lalu revalidate.
|
||||||
|
|
||||||
|
## 4. Kontrak Data (FE ↔ BE)
|
||||||
|
|
||||||
|
Semua types import dari `@mcpedia/types` — tidak ada re-declare shape di FE.
|
||||||
|
|
||||||
|
| FE Hook | BE Source | Endpoint | Shape |
|
||||||
|
|---------|-----------|----------|-------|
|
||||||
|
| useSections | `listSections()` | `GET /api/sections` | `SectionInfo[]` |
|
||||||
|
| useDocuments | `listDocuments({section,status})` | `GET /api/docs?section=&status=` | `DocumentMeta[]` |
|
||||||
|
| useDocument | `getDocument(slug)` | `GET /api/docs/:slug` (baru) | `Document` |
|
||||||
|
| useSearch | `keywordSearch/hybridSearch/semanticSearch` | `GET /api/search?q&mode&limit` | `{results: {slug,title,section,score,snippet}[]}` |
|
||||||
|
| useRelated | `getRelated(slug)` | `GET /api/related?slug=` (baru, atau reuse via core) | `DocumentMeta[]` |
|
||||||
|
| useRevisions | `listRevisions(slug)` | `GET /api/revisions?slug=` (baru) | `Revision[]` |
|
||||||
|
| useQueueStatus | BullMQ `getQueue()` | `GET /api/queue/status` (baru, proxy ke apps/api /metrics atau queue) | `{waiting,active,completed,failed,delayed}` |
|
||||||
|
|
||||||
|
**Tambahan endpoint yang perlu dibuat (tipis, proxy ke core):**
|
||||||
|
- `GET /api/docs/[slug]` — single doc (hindari RSC-only fetch, enable SWR).
|
||||||
|
- `GET /api/related?slug=&limit=` — wrapper `getRelated`.
|
||||||
|
- `GET /api/revisions?slug=&limit=` — wrapper `listRevisions`.
|
||||||
|
- `GET /api/queue/status` — wrapper `getQueue().get*Count()` (admin-only atau public read-only).
|
||||||
|
- (Opsional) `GET /api/tags` — agregasi tags dari `listDocuments` untuk TagCloud.
|
||||||
|
|
||||||
|
Semua endpoint return envelope sama: `{data}` atau `{results}` + `{error}` on 500, dengan `Content-Type: application/json`. Auth untuk mutasi tetap `x-webhook-secret` atau cookie `mcpedia_admin`.
|
||||||
|
|
||||||
|
## 5. Perubahan Per-File (Rincian)
|
||||||
|
|
||||||
|
### Phase 0 — Foundation (wajib)
|
||||||
|
- `apps/web/package.json`: tambah `swr: ^2.3.x` (latest 2.x). Tidak tambah TanStack. `bun install` + cek `typecheck`.
|
||||||
|
- `apps/web/app/lib/fetcher.ts` (baru): `export const fetcher = <T>(url: string): Promise<T> => fetch(url).then(r => { if(!r.ok) throw ...; return r.json() })`
|
||||||
|
- `apps/web/app/lib/swr.tsx` (baru): `SWRProvider` client component wrapping `SWRConfig`.
|
||||||
|
- `apps/web/app/lib/api.ts` (baru): typed helpers `searchDocs(q,mode,limit)`, `listDocs()`, `listSections()` — dipakai hooks.
|
||||||
|
- `apps/web/app/layout.tsx`: bungkus children dengan `<SWRProvider>` (client boundary minimal).
|
||||||
|
|
||||||
|
### Phase 1 — Hooks + Refactor Existing Pages
|
||||||
|
- `hooks/useSections.ts`, `useDocuments.ts`, `useSearch.ts`: ganti `useEffect+fetch` di `Header.tsx`, `Sidebar.tsx`, `search/page.tsx`, `CommandMenu.tsx`, `page.tsx` (home).
|
||||||
|
- `Header.tsx`: `useSections()` alih-alih `useState+useEffect fetch /api/sections`.
|
||||||
|
- `Sidebar.tsx`: `useDocuments()` + `useSections()` + filter memo — hilangkan `useEffect fetch /api/docs`.
|
||||||
|
- `search/page.tsx`: `useSearch(q, mode)` — hapus `handleSearch` manual, pakai `isLoading`, `isValidating`, `error` dari SWR. Mode & section filter tetap local state, tapi key SWR mencakup keduanya.
|
||||||
|
- `CommandMenu.tsx`: `useSearch(query)` debounced — hapus timer manual.
|
||||||
|
- `app/page.tsx`: tetap RSC untuk SEO (listDocuments/listSections server), tapi `SectionTree` dan `Recently Updated` bisa jadi client islands yang pakai SWR untuk live update (opsional: keep RSC + `mutate` on focus).
|
||||||
|
- `[section]/page.tsx` & `[section]/[...slug]/page.tsx`: RSC hero + Markdown tetap, `related` & `revisions` pindah ke client islands `RelatedGrid` & `RevisionList` pakai `useRelated`/`useRevisions`.
|
||||||
|
|
||||||
|
### Phase 2 — Fitur Baru (detail)
|
||||||
|
1. **Dashboard `/dashboard` (baru, admin-aware):**
|
||||||
|
- Cards: total docs, sections, queue (waiting/active/failed), embedding provider status.
|
||||||
|
- Section breakdown bar (docCount per section).
|
||||||
|
- Tag cloud (top 20 tags dengan count).
|
||||||
|
- Recent activity (last 10 updated docs).
|
||||||
|
- Queue status poll 5s via `useQueueStatus` (hanya jika admin cookie ada, else hide).
|
||||||
|
2. **Tag Explorer `/tags` atau sidebar widget:**
|
||||||
|
- `GET /api/tags` agregasi, klik tag → `/search?tag=...` atau filter docs.
|
||||||
|
3. **Doc page enhancements:**
|
||||||
|
- `QueueBadge` di hero jika doc sedang di-index (query queue by slug).
|
||||||
|
- Optimistic revision restore: `mutate('/api/revisions?slug=...')` setelah POST restore.
|
||||||
|
- Related docs pakai SWR + `keepPreviousData`.
|
||||||
|
4. **Search enhancements:**
|
||||||
|
- Persist `mode` di URL (`?q=&mode=hybrid`) — sudah ada, pertahankan + SWR key sync.
|
||||||
|
- Highlight snippet `mark` sudah ada — pertahankan.
|
||||||
|
- Empty & error states pakai `EmptyState`/`ErrorState` components.
|
||||||
|
5. **Create/Edit:**
|
||||||
|
- Setelah `POST /api/docs`, `mutate('/api/docs')` + `mutate('/api/sections')` + `router.push('/${slug}')`.
|
||||||
|
- Custom fields (extraFields) tetap flat → `splitPayload` di BE tidak berubah.
|
||||||
|
6. **Export:**
|
||||||
|
- `PdfExportView` tetap, tapi tambah SWR preload `getExportDocuments` untuk preview count sebelum export.
|
||||||
|
|
||||||
|
### Phase 3 — Polish & Cleanup
|
||||||
|
- Hapus semua `fetch` ad-hoc yang tersisa — semua lewat hooks.
|
||||||
|
- Pastikan `globals.css` tokens dipakai konsisten (tidak ada hard-coded hex baru).
|
||||||
|
- `CommandMenu` jadi satu-satunya omnisearch entry — `Header` search icon di mobile buka CommandMenu, bukan link `/search`.
|
||||||
|
- A11y: `aria-live` untuk search results count, `role="status"` untuk queue badge.
|
||||||
|
- Print/PDF styles sudah ada — tidak diubah.
|
||||||
|
|
||||||
|
## 6. File yang Disentuh (Checklist)
|
||||||
|
|
||||||
|
Baru:
|
||||||
|
- `apps/web/app/lib/fetcher.ts`
|
||||||
|
- `apps/web/app/lib/swr.tsx`
|
||||||
|
- `apps/web/app/lib/api.ts`
|
||||||
|
- `apps/web/app/hooks/useSections.ts`
|
||||||
|
- `apps/web/app/hooks/useDocuments.ts`
|
||||||
|
- `apps/web/app/hooks/useSearch.ts`
|
||||||
|
- `apps/web/app/hooks/useRelated.ts`
|
||||||
|
- `apps/web/app/hooks/useRevisions.ts`
|
||||||
|
- `apps/web/app/hooks/useQueueStatus.ts`
|
||||||
|
- `apps/web/app/hooks/useTags.ts` (opsional)
|
||||||
|
- `apps/web/app/components/SearchBar.tsx`
|
||||||
|
- `apps/web/app/components/SectionCard.tsx`
|
||||||
|
- `apps/web/app/components/DocumentCard.tsx`
|
||||||
|
- `apps/web/app/components/TagCloud.tsx`
|
||||||
|
- `apps/web/app/components/QueueBadge.tsx`
|
||||||
|
- `apps/web/app/components/EmptyState.tsx`
|
||||||
|
- `apps/web/app/dashboard/page.tsx` (baru)
|
||||||
|
- `apps/web/app/api/docs/[slug]/route.ts` (baru)
|
||||||
|
- `apps/web/app/api/related/route.ts` (baru)
|
||||||
|
- `apps/web/app/api/revisions/route.ts` (baru GET)
|
||||||
|
- `apps/web/app/api/queue/status/route.ts` (baru)
|
||||||
|
- `apps/web/app/api/tags/route.ts` (baru, opsional)
|
||||||
|
|
||||||
|
Edit:
|
||||||
|
- `apps/web/package.json` (+ swr)
|
||||||
|
- `apps/web/app/layout.tsx` (+ SWRProvider)
|
||||||
|
- `apps/web/app/page.tsx` (extract SectionCard, optional SWR islands)
|
||||||
|
- `apps/web/app/[section]/page.tsx` (DocumentCard)
|
||||||
|
- `apps/web/app/[section]/[...slug]/page.tsx` (related/revisions islands)
|
||||||
|
- `apps/web/app/search/page.tsx` (full SWR)
|
||||||
|
- `apps/web/app/components/Header.tsx` (useSections)
|
||||||
|
- `apps/web/app/components/Sidebar.tsx` (useDocuments/useSections)
|
||||||
|
- `apps/web/app/components/CommandMenu.tsx` (useSearch)
|
||||||
|
- `apps/web/app/create/page.tsx` (mutate)
|
||||||
|
- `apps/web/app/components/DocForm.tsx` (mutate setelah submit)
|
||||||
|
|
||||||
|
Tidak disentuh:
|
||||||
|
- `packages/core`, `packages/types`, `packages/config`, `packages/db` (hanya dibaca)
|
||||||
|
- `apps/api`, `apps/mcp`, `apps/worker` (hanya diproxy)
|
||||||
|
- `globals.css`, `next.config.ts`, `tailwind` config (kecuali perlu token baru)
|
||||||
|
|
||||||
|
## 7. Urutan Implementasi
|
||||||
|
|
||||||
|
1. Phase 0 foundation → `bun install && bun run typecheck` green.
|
||||||
|
2. Phase 1 hooks + Header/Sidebar/Search/CommandMenu → manual test + `next build` smoke.
|
||||||
|
3. Phase 2 doc page islands + create mutate → test CRUD flow dengan `x-webhook-secret` / admin cookie.
|
||||||
|
4. Phase 2 dashboard + tags → behind `canEdit` guard jika perlu.
|
||||||
|
5. Phase 3 cleanup, hapus fetch manual, final `bun run lint && typecheck && build`.
|
||||||
|
|
||||||
|
## 8. Verifikasi
|
||||||
|
|
||||||
|
- `bun run typecheck` — tidak ada error (SWR types + @mcpedia/types).
|
||||||
|
- `bun run lint` — rules Next 16 ok.
|
||||||
|
- `bun --cwd apps/web run build` — semua route `force-dynamic` OK, tidak ada import-time DB throw.
|
||||||
|
- Manual:
|
||||||
|
- Sidebar filter + section count live setelah create doc.
|
||||||
|
- Search mode switch (hybrid/keyword/semantic) cache terpisah, tidak flicker.
|
||||||
|
- CommandMenu ⌘K debounce 180ms, Enter navigasi benar.
|
||||||
|
- Doc related & revisions muncul via SWR tanpa hard refresh.
|
||||||
|
- Queue badge (jika ada) poll 5s, tidak spam (dedupingInterval).
|
||||||
|
- Dark/light toggle tetap work (SWRProvider tidak break `ThemeToggle`).
|
||||||
|
- Staging: `curl /api/search?q=test&mode=hybrid`, `/api/sections`, `/api/docs` return shape konsisten dengan tRPC.
|
||||||
|
|
||||||
|
## 9. Risiko & Mitigasi
|
||||||
|
|
||||||
|
- RSC + SWR double fetch: mitigasi dengan `fallbackData` dari RSC props ke SWR hook di islands (opsi, tidak wajib v1).
|
||||||
|
- Peningkatan request ke DB: SWR deduping 4s + `revalidateOnFocus: false` untuk search, `true` untuk sections/docs.
|
||||||
|
- Auth untuk `/api/queue/status`: jadikan public read-only (hanya counts, bukan detail job) — aman.
|
||||||
|
|
||||||
|
## 10. Keputusan yang Perlu Kamu Konfirmasi
|
||||||
|
|
||||||
|
1. **Dashboard `/dashboard` perlu tidak?** Atau cukup queue badge di existing pages? Rekomendasi: bikin, tapi Phase 2 — bisa ditunda.
|
||||||
|
2. **Endpoint baru (`/api/docs/[slug]`, `/api/related`, `/api/revisions` GET, `/api/queue/status`, `/api/tags`) setuju?** Alternatif: FE langsung pakai tRPC client (`@trpc/client`) — tapi spec ini pilih REST `/api/*` supaya Next cache & SWR fetcher simpel.
|
||||||
|
3. **SWR vs tRPC+SWR?** Spec ini pakai SWR+REST (paling simpel, tidak tambah `@trpc/client` di FE). Jika mau tRPC, tambah `createTRPCClient` + `httpBatchLink` — tradeoff: lebih typed tapi lebih berat. Default: SWR+REST.
|
||||||
|
4. **Keep RSC server fetch untuk SEO atau full client SWR?** Rekomendasi: keep RSC untuk initial HTML (SEO), SWR untuk live revalidation di client islands — hybrid, bukan full SWR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Jawab: `Lanjut Phase 0+1` untuk eksekusi, atau beri koreksi poin 10.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# MCPedia — Nix-based CI/CD Migration Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Migrate from tarball+SSH deploy to Nix-native build-and-deploy (like GMW).
|
||||||
|
CI builds with Nix in GitHub Actions → `nix copy` closure to VPS →
|
||||||
|
`nix-env --set` + `systemctl restart` on VPS. VPS never builds.
|
||||||
|
|
||||||
|
## Services to migrate
|
||||||
|
| Service | Build | Runtime | systemd unit |
|
||||||
|
|---------|-------|---------|-------------|
|
||||||
|
| web (Next.js) | `bun run build` → `.next` | `next start` | `mcpedia-web` |
|
||||||
|
| api (Hono) | `bun build src/index.ts` → `dist/index.js` | `bun run src/index.ts` | `mcpedia-api` |
|
||||||
|
| mcp (MCP server) | `bun build src/http.ts` → `dist/http.js` | `bun run src/http.ts` | `mcpedia-mcp` |
|
||||||
|
| worker (BullMQ) | `bun build src/index.ts` → `dist/index.js` | `bun run src/index.ts` | `mcpedia-worker` |
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### 1. flake.nix (new)
|
||||||
|
- `nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"` (Bun >=1.3)
|
||||||
|
- 4 packages: `web`, `api`, `mcp`, `worker`
|
||||||
|
- Each: `mkDerivation` with `bun` as nativeBuildInput, `sandbox=false`
|
||||||
|
- Filter source to exclude `.next`, `node_modules`, `.git`
|
||||||
|
|
||||||
|
### 2. CI workflow (.github/workflows/ci.yml — rewrite)
|
||||||
|
- `test` job: typecheck + test (Bun, as before)
|
||||||
|
- `build-and-deploy` job: matrix [web, api, mcp, worker]
|
||||||
|
- `DeterminateSystems/nix-installer-action@v16` with `determinate: false`, `sandbox=false`
|
||||||
|
- `DeterminateSystems/magic-nix-cache-action@v14` with `use-flakehub: false`
|
||||||
|
- `nix build .#<service>` → store path
|
||||||
|
- SSH setup (key sanitization)
|
||||||
|
- `nix copy --to ssh://...`
|
||||||
|
- `nix-env --profile /nix/var/nix/profiles/mcpedia-<service> --set <path>`
|
||||||
|
- `systemctl restart mcpedia-<service>`
|
||||||
|
|
||||||
|
### 3. systemd units (update on VPS)
|
||||||
|
- Change `ExecStart` from `bun run` to Nix profile binary path
|
||||||
|
- Profile path: `/nix/var/nix/profiles/mcpedia-web/bin/mcpedia-web`
|
||||||
|
- Keep `EnvironmentFile` pointing to `.env`
|
||||||
|
- Keep `Restart=always`
|
||||||
|
|
||||||
|
### 4. Deploy secrets
|
||||||
|
- `SSH_PRIVATE_KEY` (already exists as `SSH_DEPLOY_KEY`)
|
||||||
|
- `VPS_HOST`, `VPS_USER`, `VPS_SSH_PORT`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `nix build .#web --impure` builds
|
||||||
|
- `gh run list` shows all 4 services `completed success`
|
||||||
|
- `curl https://wiki.asepharyana.my.id/` returns 200
|
||||||
|
- `journalctl -u mcpedia-web` shows Next.js started
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Phase 15b — CI/CD: Build in CI, Deploy only on VPS
|
||||||
|
|
||||||
|
> User: "alur ci nya juga benarkan lah masa build di vps,harusnya kan di vps hanya deploy, dan seharusnya pakai nix"
|
||||||
|
|
||||||
|
## Final Architecture
|
||||||
|
|
||||||
|
### CI Workflow (`.github/workflows/ci.yml`)
|
||||||
|
Single workflow with two jobs:
|
||||||
|
|
||||||
|
**Job 1: `build`** (runs on GitHub Actions runner)
|
||||||
|
1. Checkout → Set up Bun 1.3.14 → `bun install --frozen-lockfile`
|
||||||
|
2. Typecheck (`bun run typecheck`)
|
||||||
|
3. Build web (`working-directory: apps/web && bun run build`) ← **Build happens here, not VPS**
|
||||||
|
4. Upload `.next/` as artifact (`include-hidden-files: true` ← critical for hidden `.next/` dir)
|
||||||
|
5. MCP smoke test (conditional on DATABASE_URL secret — skipped in CI without DB)
|
||||||
|
6. Unit tests (`bun run test`)
|
||||||
|
|
||||||
|
**Job 2: `deploy`** (needs: build, main branch only)
|
||||||
|
1. Download artifact to `apps/web/.next`
|
||||||
|
2. Tar `.next` (exclude cache, ~15MB tarball)
|
||||||
|
3. SCP tarball to VPS (`/tmp/`)
|
||||||
|
4. SSH to VPS: `git pull` → `bun install` → `bun run scripts/indexer.ts` → `tar -xzf` → `systemctl restart`
|
||||||
|
|
||||||
|
### Key Fixes During This Phase
|
||||||
|
|
||||||
|
1. **`actions/checkout@v4` → `@v5`** — Node 20 deprecation warning
|
||||||
|
2. **`bun --cwd apps/web run build` → `working-directory: apps/web`** — `bun run` doesn't support `--cwd` flag; was silently printing usage, exiting 0
|
||||||
|
3. **`path: apps/web/.next/`** → **`path: apps/web/.next`** + **`include-hidden-files: true`** — `.next/` is a hidden directory; `upload-artifact@v4` excludes hidden files by default
|
||||||
|
4. **`bun --cwd apps/mcp run smoke`** → **`working-directory: apps/mcp`** — same `--cwd` bug
|
||||||
|
5. **Smoke test made conditional** on `DATABASE_URL` — was silently failing (never actually ran due to bug #2/#4)
|
||||||
|
6. **SCP of individual `.next/*`** → **tarball transfer** — SCP with glob of hidden files was failing/timing out
|
||||||
|
7. **Deploy tarball excludes `.next/cache`** — cache is ephemeral, reduces transfer size
|
||||||
|
8. **Merged ci.yml + deploy.yml** into single workflow — `workflow_run` trigger can't access artifacts from CI run
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
# MCPedia — Phase 3 "Async + Scale" Implementation Plan
|
|
||||||
|
|
||||||
Status: Phase 1 (MVP) + Phase 2 (Semantic+API) DONE. Phase 3 adds async
|
|
||||||
background work, git-driven reindex, document revision history, and MCP
|
|
||||||
Resources. All logic stays in `@mcpedia/core`; new `packages/queue` wires
|
|
||||||
BullMQ; `apps/worker` runs the worker process; the existing API gets a git-sync
|
|
||||||
webhook + job-status procedures; the MCP server gains Resources.
|
|
||||||
|
|
||||||
## Scope (4 features from PHASES.md)
|
|
||||||
|
|
||||||
1. **Redis + BullMQ background indexing/embedding workers**
|
|
||||||
2. **Git synchronization hook** (auto-reindex on push via webhook)
|
|
||||||
3. **Document revision system** (`document_revisions`)
|
|
||||||
4. **MCP Resources** (`mcpedia://docs/...`) alongside existing tools
|
|
||||||
|
|
||||||
## Architecture decisions (locked)
|
|
||||||
|
|
||||||
- **Redis**: shared imrnes Redis `100.121.180.82:6379`, no auth (verified
|
|
||||||
`+PONG`). `REDIS_URL` env (default `redis://100.121.180.82:6379`), optional
|
|
||||||
`REDIS_PASSWORD`. BullMQ key prefix `mcpedia:` to avoid collisions on the
|
|
||||||
shared instance.
|
|
||||||
- **Queue lib**: `bullmq@6.1.2` + `ioredis@6.0.0` (BullMQ peer dep). Pass an
|
|
||||||
ioredis instance; BullMQ duplicates it for blocking commands.
|
|
||||||
- **Single source of truth preserved**: per-doc indexing logic moves into
|
|
||||||
`@mcpedia/core` as `indexContentFile(relPath, reason?)`. The script, the
|
|
||||||
worker, and the git hook ALL call this. Revisions are snapshotted inside it.
|
|
||||||
- **Revisions**: created only when body actually changes vs the latest revision
|
|
||||||
(avoids bloat on every sync). Stored in `document_revisions`.
|
|
||||||
|
|
||||||
## Files touched
|
|
||||||
|
|
||||||
### packages/config
|
|
||||||
- `src/index.ts`: add `REDIS_URL`, `REDIS_PASSWORD`, `QUEUE_PREFIX`.
|
|
||||||
|
|
||||||
### packages/db
|
|
||||||
- `src/schema.ts`: add `documentRevisions` table
|
|
||||||
(id, documentId→documents.id cascade, slug, revisionNo int, title, body,
|
|
||||||
meta jsonb, reason text, createdAt). Index (document_id, revision_no DESC),
|
|
||||||
(slug).
|
|
||||||
- `drizzle/0002_document_revisions.sql`: migration (applied via psql).
|
|
||||||
- `drizzle/meta/0002_snapshot.json` + `_journal.json` entry (keeps drizzle-kit
|
|
||||||
consistent even though we apply manually).
|
|
||||||
|
|
||||||
### packages/core (new)
|
|
||||||
- `src/index.service.ts`:
|
|
||||||
- `indexContentFile(relPath: string, reason = "index")` — parse → upsert
|
|
||||||
`documents` → `indexChunks` → snapshot revision (if changed).
|
|
||||||
- `runFullIndex(reason?)` — walk content, index each, return counts.
|
|
||||||
- `src/revision.service.ts`:
|
|
||||||
- `createRevision(...)`, `listRevisions(slug, limit)`,
|
|
||||||
`getRevision(id)`, `latestRevisionBody(slug)`, `restoreRevision(id)`.
|
|
||||||
- `src/index.ts`: export both.
|
|
||||||
|
|
||||||
### packages/queue (NEW)
|
|
||||||
- `package.json` (@mcpedia/queue): deps bullmq, ioredis, @mcpedia/core,
|
|
||||||
@mcpedia/db, @mcpedia/config.
|
|
||||||
- `src/client.ts`: ioredis instance factory from config.
|
|
||||||
- `src/queue.ts`:
|
|
||||||
- `INDEX_QUEUE = "mcpedia-index"`.
|
|
||||||
- `enqueueIndexDoc(slug, absPath, reason)`, `enqueueFullIndex(reason)`.
|
|
||||||
- `getQueue()` lazy singleton.
|
|
||||||
- `src/worker.ts`: `startWorker()` — BullMQ Worker with 3 job types:
|
|
||||||
`index-doc` (single), `index-all` (full), `reindex` (full, reason=git-push).
|
|
||||||
Graceful shutdown on SIGINT/SIGTERM. Job progress + error handling.
|
|
||||||
|
|
||||||
### apps/worker (NEW)
|
|
||||||
- `package.json` (@mcpedia/worker): script `start: bun src/index.ts`.
|
|
||||||
- `src/index.ts`: `startWorker()` + heartbeat log.
|
|
||||||
|
|
||||||
### apps/api
|
|
||||||
- `src/index.ts`: add `POST /hooks/reindex` (full) and
|
|
||||||
`POST /hooks/index?slug=` (single) webhook routes → enqueue jobs. Mount
|
|
||||||
AFTER /trpc.
|
|
||||||
- `src/router.ts`: add `jobStatus` (id→state/prev/failedData),
|
|
||||||
`queueStatus` (waiting/active/completed/failed counts),
|
|
||||||
`revisions` (slug→list), `restoreRevision` (id→new slug/doc).
|
|
||||||
- `package.json`: add `@mcpedia/queue` dep, `hooks` reused.
|
|
||||||
|
|
||||||
### apps/mcp
|
|
||||||
- `src/index.ts`: register Resources:
|
|
||||||
- `mcpedia://docs` (list all metas)
|
|
||||||
- `mcpedia://docs/{slug}` (full body from disk)
|
|
||||||
- `mcpedia://docs/{slug}/chunks` (chunk previews)
|
|
||||||
- `mcpedia://docs/{slug}/revisions` (revision list)
|
|
||||||
- `src/smoke.test.ts`: add `listResources` + read `mcpedia://docs` assertion.
|
|
||||||
|
|
||||||
### scripts
|
|
||||||
- `scripts/indexer.ts`: refactor `main()` to call `runFullIndex()`.
|
|
||||||
|
|
||||||
### Root
|
|
||||||
- `package.json`: add `"worker": "bun --cwd apps/worker run start"`,
|
|
||||||
`"reindex": "bun run scripts/worker.ts"`? No — `worker` runs the listener;
|
|
||||||
triggering reindex = `bun run api` webhook or `enqueueFullIndex` helper.
|
|
||||||
Add `"enqueue-index": "bun run scripts/enqueue.ts"` (one-shot enqueue).
|
|
||||||
- `.env.example`: add `REDIS_URL`, `REDIS_PASSWORD`, `QUEUE_PREFIX`.
|
|
||||||
|
|
||||||
### Docs
|
|
||||||
- `PHASES.md`: mark Phase 3 items DONE with notes.
|
|
||||||
- `README.md`: document worker, webhook, revisions, MCP resources.
|
|
||||||
|
|
||||||
## Verification (real, not claimed)
|
|
||||||
|
|
||||||
1. `bun install` picks up new deps.
|
|
||||||
2. `bunx turbo run build` + `typecheck` green across workspace.
|
|
||||||
3. **Real BullMQ e2e against imrnes Redis**: script that enqueues an
|
|
||||||
`index-doc` job, starts a Worker, asserts the job completes and the doc row
|
|
||||||
+ chunks + a revision row appear in Postgres. Verifies Redis+ioredis+bullmq
|
|
||||||
+ db + core all wired correctly.
|
|
||||||
4. `bun --cwd apps/mcp run smoke` passes (incl. new resources).
|
|
||||||
5. `bun run index` (runFullIndex) green; verify `documents`,
|
|
||||||
`document_chunks`, `document_revisions` row counts via psql.
|
|
||||||
6. API webhook: `curl -XPOST localhost:4020/hooks/reindex` enqueues; worker
|
|
||||||
processes; `curl localhost:4020/trpc/queueStatus` reflects counts.
|
|
||||||
7. MCP resource read returns real content.
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# MCPedia Phase 4 — Operability & Correctness Hardening
|
|
||||||
|
|
||||||
> Reinterpretation: the PHASES.md "Scale-out" items (OpenSearch, object storage,
|
|
||||||
> multi-tenant, distributed workers) are YAGNI at KB scale (4 docs). Phase 4 =
|
|
||||||
> make the Phase 3 async + revision machinery **correct, secure, observable, and
|
|
||||||
> deployable** — not speculative infra. Each task below fixes a real gap found
|
|
||||||
> by reading the code, not a hypothetical need.
|
|
||||||
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
### T1 — `restoreRevision` must rebuild semantic chunks (CORRECTNESS BUG)
|
|
||||||
**Root cause:** `packages/core/src/revision.service.ts` `restoreRevision` writes
|
|
||||||
the old body back into `documents` but never calls `indexChunks(slug, body)`.
|
|
||||||
So after a restore, keyword search (FTS on `documents.body`) is correct but
|
|
||||||
`document_chunks`/embeddings stay on the *new* body → semantic + hybrid search
|
|
||||||
return stale/ghost chunks.
|
|
||||||
|
|
||||||
**Fix:**
|
|
||||||
- Add `reindexChunks(slug)` to `@mcpedia/core` that re-runs `indexChunks(slug, body)`
|
|
||||||
using the live `documents.body` (the new body after the update).
|
|
||||||
- Call it inside `restoreRevision` after the `documents` update (wrap in try/catch
|
|
||||||
like `indexContentFile` so embed failure doesn't abort the restore).
|
|
||||||
- Add a unit-style assertion to the MCP smoke test or a small script: restore →
|
|
||||||
`document_chunks` count matches re-chunked body.
|
|
||||||
|
|
||||||
**Files:** `packages/core/src/revision.service.ts`, `packages/core/src/index.ts`,
|
|
||||||
`packages/core/src/document.service.ts` (export existing `indexChunks` if needed).
|
|
||||||
|
|
||||||
### T2 — Secure the git-sync webhook (SECURITY)
|
|
||||||
**Root cause:** `apps/api/src/index.ts` `/hooks/reindex` and `/hooks/index` accept
|
|
||||||
any request with no `WEBHOOK_SECRET` check — `.env.example` defines `WEBHOOK_SECRET`
|
|
||||||
but the router never reads it.
|
|
||||||
|
|
||||||
**Fix:**
|
|
||||||
- In `apps/api/src/index.ts`, compare `c.req.header("x-webhook-secret")` (or
|
|
||||||
`?secret=`) against `WEBHOOK_SECRET` (from `@mcpedia/config`). If unset/mismatch →
|
|
||||||
`401`. If `WEBHOOK_SECRET` env is empty, reject at startup with a clear log
|
|
||||||
(fail-fast, don't run an open endpoint).
|
|
||||||
- Add `WEBHOOK_SECRET` to `packages/config/src/index.ts` export.
|
|
||||||
- Document the header in README + verify with curl (401 without secret, 200 with).
|
|
||||||
|
|
||||||
**Files:** `apps/api/src/index.ts`, `packages/config/src/index.ts`, README.
|
|
||||||
|
|
||||||
### T3 — Web UI revisions view (UX)
|
|
||||||
**Root cause:** Web UI (server components) calls `@mcpedia/core` directly; there is
|
|
||||||
no revisions surface even though `revisions`/`restoreRevision` tRPC + MCP resource
|
|
||||||
exist.
|
|
||||||
|
|
||||||
**Fix (server-component only, no client JS):**
|
|
||||||
- On the doc page (`apps/web/app/[section]/[...slug]/page.tsx`), fetch
|
|
||||||
`listRevisions(fullSlug, 10)` and render a "History" panel: revision number,
|
|
||||||
reason, createdAt, body length, and a `/api/revisions/restore` link/POST that
|
|
||||||
calls the tRPC `restoreRevision` mutation via a server action or a form POST to
|
|
||||||
a small route handler. Simplest: a `<form method="post" action="/api/revisions/restore">`
|
|
||||||
with hidden `id` + a route handler in `apps/web` calling `restoreRevision`.
|
|
||||||
Keep it read-mostly; restore is a deliberate action.
|
|
||||||
- Add `apps/web/app/api/revisions/restore/route.ts` (POST) → `restoreRevision(id)`
|
|
||||||
→ `revalidatePath` the doc.
|
|
||||||
|
|
||||||
**Files:** `apps/web/app/[section]/[...slug]/page.tsx`,
|
|
||||||
`apps/web/app/api/revisions/restore/route.ts`.
|
|
||||||
|
|
||||||
### T4 — Paginate `listRevisions` / `revisions` API (PERF)
|
|
||||||
**Root cause:** `revision.service.ts` `listRevisions` does `select length(body)`
|
|
||||||
(fine) but the tRPC `revisions` and MCP resource return *full* revision rows
|
|
||||||
including the body in some callers; list endpoints should never carry bodies.
|
|
||||||
|
|
||||||
**Fix:**
|
|
||||||
- Ensure `listRevisions` summary excludes `body` (it already does — `bodyLength`
|
|
||||||
only). Add `offset` param for paging. Confirm MCP resource uses the summary.
|
|
||||||
- No behavior change for the doc page (uses summary).
|
|
||||||
|
|
||||||
**Files:** `packages/core/src/revision.service.ts` (add `offset`), router unchanged.
|
|
||||||
|
|
||||||
### T5 — Deployable as supervised services (OPS)
|
|
||||||
**Root cause:** `apps/api` and `apps/worker` run only ad-hoc; the host already runs
|
|
||||||
`zeavis` via Nix/systemd. Phase-4 operability = provide a systemd unit (or Nix
|
|
||||||
service) so `mcpedia-api` + `mcpedia-worker` start on boot and restart on failure.
|
|
||||||
|
|
||||||
**Fix (Nix-first, per MEMORY):**
|
|
||||||
- Write `mcpedia-api.service` + `mcpedia-worker.service` systemd unit files under
|
|
||||||
`deploy/` (bun run api / bun run worker, `WorkingDirectory`, `Restart=on-failure`,
|
|
||||||
`EnvironmentFile` pointing at `.env`, `After=network-online.target`).
|
|
||||||
- README section "Run as a service" with `cp deploy/*.service /etc/systemd/system && systemctl daemon-reload && systemctl enable --now mcpedia-api mcpedia-worker`.
|
|
||||||
- **Do NOT** `systemctl` on the host without user confirmation (changing live
|
|
||||||
services). Provide the files + instructions only; user runs enable.
|
|
||||||
|
|
||||||
**Files:** `deploy/mcpedia-api.service`, `deploy/mcpedia-worker.service`, README.
|
|
||||||
|
|
||||||
## Verification (all real, against imrnes Redis + Postgres)
|
|
||||||
1. `turbo run typecheck` + `turbo run build` green.
|
|
||||||
2. T1: script — edit a doc, reindex (new revision + new chunks), restore rev #1,
|
|
||||||
assert `document_chunks` count for that slug now matches re-chunk of rev #1 body
|
|
||||||
and `semanticSearch` on a term unique to rev #1 returns it.
|
|
||||||
3. T2: `curl -XPOST localhost:4020/hooks/reindex` → 401; with
|
|
||||||
`-H "x-webhook-secret: $WEBHOOK_SECRET"` → 200 + jobId.
|
|
||||||
4. T3: `next build` includes the History panel; restore form rebuilds chunks
|
|
||||||
(verified via T1 path through the route handler).
|
|
||||||
5. T4: `revisions` API returns summaries without body; offset paging works.
|
|
||||||
6. T5: `systemd-analyze verify deploy/*.service` passes (off-host safe check);
|
|
||||||
README documents enable steps.
|
|
||||||
|
|
||||||
## Out of scope (YAGNI, keep deferred per PHASES.md)
|
|
||||||
OpenSearch/Elasticsearch, object storage, multi-tenant, distributed workers,
|
|
||||||
pgvector migration. Revisit only when corpus > ~10k docs or query latency bites.
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Phase 15c — Theme-Consistent Color System
|
||||||
|
|
||||||
|
> User: "banyak pewarnaan yg tidak sesuai tema dark mode atau light mode"
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
63 hardcoded hex colors (#5e6ad2, #7170ff, #4f46e5, etc.) bypass the CSS
|
||||||
|
variable theme system in globals.css. These don't adapt between light/dark mode
|
||||||
|
properly — the `dark:` variant changes text color but background stays the same.
|
||||||
|
|
||||||
|
## Color Mapping
|
||||||
|
|
||||||
|
### `#5e6ad2` (indigo-600)
|
||||||
|
- Light: `--brand: #5e6ad2` (same)
|
||||||
|
- Dark: `--brand: #5e6ad2` (same)
|
||||||
|
→ Replace with `var(--brand)`
|
||||||
|
|
||||||
|
### `#4f46e5` (indigo-700)
|
||||||
|
- Light: `--accent: #4f46e5`
|
||||||
|
- Dark: `--accent: #7170ff`
|
||||||
|
→ Replace with `var(--accent)`
|
||||||
|
|
||||||
|
### `#7170ff` (light blue, dark mode accent)
|
||||||
|
- Light: `var(--accent)` (#4f46e5)
|
||||||
|
- Dark: `var(--accent)` (#7170ff)
|
||||||
|
→ Replace `dark:text-[#7170ff]` → `dark:text-[var(--accent)]`
|
||||||
|
|
||||||
|
### `#6a75e0` (hover lighter)
|
||||||
|
→ Replace with `var(--accent-hover)`
|
||||||
|
|
||||||
|
### `/10`, `/15`, `/20`, `/30` opacity tints
|
||||||
|
- `bg-[#5e6ad2]/15` → `bg-[var(--brand)]/15`
|
||||||
|
- `bg-[#5e6ad2]/10` → `bg-[var(--brand)]/10`
|
||||||
|
- `bg-[#5e6ad2]/30` → `bg-[var(--brand)]/30`
|
||||||
|
|
||||||
|
## Files to fix
|
||||||
|
- `[section]/[...slug]/page.tsx` (~16 instances)
|
||||||
|
- `[section]/page.tsx` (~5 instances)
|
||||||
|
- `components/Sidebar.tsx` (2 instances)
|
||||||
|
- `components/DocForm.tsx` (~6 instances)
|
||||||
|
- `components/CommandMenu.tsx` (~6 instances)
|
||||||
|
- `components/Header.tsx` (2 instances)
|
||||||
|
- `components/McpConfigSnippet.tsx` (1 instance)
|
||||||
|
- `page.tsx` (check)
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
# SSH deploy to bun-source services (not Nix): gotchas
|
||||||
|
|
||||||
|
This repo (`asepharyana/mcpedia`) deploys **bun source** via systemd — NOT via
|
||||||
|
Nix like GMW. The pattern is: CI builds in GitHub Actions → upload artifact →
|
||||||
|
deploy job downloads artifact → SCP tarball to VPS → SSH → git pull + unpack +
|
||||||
|
index + `systemctl restart`. The VPS does NOT build.
|
||||||
|
|
||||||
|
## Gotchas (learned during mcpedia deploy setup, 2026-08-20/21)
|
||||||
|
|
||||||
|
### 1. SSH host = public IP, NOT Tailscale IP
|
||||||
|
|
||||||
|
The VPS appears in `~/.ssh/config` as `Host orange → HostName 100.79.111.61`.
|
||||||
|
That IP is a **Tailscale `tailscale0` interface address** in the CGNAT range
|
||||||
|
(`100.64.0.0/10`). GitHub Actions runners are NOT on the Tailscale network, so
|
||||||
|
they get `Connection timed out` (dropped at the network layer, not refused).
|
||||||
|
|
||||||
|
**Fix:** Use the VPS's real public IP (`45.127.35.244`, discovered via
|
||||||
|
`curl https://api.ipify.org` from the VPS). Port 22 is open in iptables
|
||||||
|
(`ACCEPT tcp dpt:22` from `0.0.0.0/0`).
|
||||||
|
|
||||||
|
### 2. SSH key MUST be stored directly from file (not shell variable)
|
||||||
|
|
||||||
|
Storing the deploy key via a shell variable corrupts it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ❌ WRONG — newlines get mangled by the shell → "ssh: no key found"
|
||||||
|
PRIV_KEY=$(cat keyfile)
|
||||||
|
gh secret set SSH_DEPLOY_KEY --body "$PRIV_KEY"
|
||||||
|
|
||||||
|
# ✅ CORRECT — pipe the file directly so GitHub preserves all bytes
|
||||||
|
cat keyfile | gh secret set SSH_DEPLOY_KEY --repo asepharyana/mcpedia
|
||||||
|
```
|
||||||
|
|
||||||
|
Symptom: `appleboy/ssh-action` fails with
|
||||||
|
`ssh.ParsePrivateKey: ssh: no key found`.
|
||||||
|
|
||||||
|
### 3. Use `appleboy/ssh-action@v1` (not `@v1.1.0`)
|
||||||
|
|
||||||
|
- `@v1.1.0` (very old) has a key-parsing bug that rejects valid keys
|
||||||
|
(`ssh.ParsePrivateKey: ssh: no key found`).
|
||||||
|
- `@v1` (latest) handles OpenSSH ed25519 keys correctly.
|
||||||
|
|
||||||
|
### 4. `appleboy/ssh-action` needs explicit `envs` to pass through secret-derived vars
|
||||||
|
|
||||||
|
The `envs` parameter passes environment variables to the remote script.
|
||||||
|
Include any secret you reference in the script:
|
||||||
|
```yaml
|
||||||
|
envs: SSH_DEPLOY_HOST # passes SSH_DEPLOY_HOST into the remote script
|
||||||
|
```
|
||||||
|
Without it, `echo $SSH_DEPLOY_HOST` on the VPS returns empty even though the
|
||||||
|
action connected.
|
||||||
|
|
||||||
|
### 5. Always pass `-o IdentitiesOnly=yes`
|
||||||
|
|
||||||
|
Without it, SSH offers ALL loaded identities (deploy key + default keys) and the
|
||||||
|
server may reject after "Too many authentication failures". `appleboy/ssh-action`
|
||||||
|
handles this internally via the `key` input, but if you use raw `ssh` add
|
||||||
|
`-o IdentitiesOnly=yes`.
|
||||||
|
|
||||||
|
### 6. GitHub `workflow_run` does NOT carry the push commit SHA
|
||||||
|
|
||||||
|
`workflow_run` events fire after CI completes, but `actions/checkout` checks out
|
||||||
|
the **default branch tip**, not the specific commit. This is fine for deploy
|
||||||
|
(the script does `git pull origin main` anyway), but verify the checkout ref
|
||||||
|
matches what CI built if you rely on it.
|
||||||
|
|
||||||
|
### 7. DB migrations: `db:push` prompt is non-interactive-unfriendly in SSH deploy
|
||||||
|
|
||||||
|
When the schema includes a new column (e.g. adding `extra_fields JSONB`),
|
||||||
|
`drizzle-kit push` in the SSH deploy script needs to be run. Two issues:
|
||||||
|
|
||||||
|
- **`--strict` mode (config default)**: `drizzle.config.ts` has `strict: true`,
|
||||||
|
which makes `drizzle-kit push` prompt `No, abort / Yes, I want to execute all
|
||||||
|
statements`. In a non-interactive SSH script (no TTY), the prompt never receives
|
||||||
|
input and the command times out → SIGTERM → exit code 124 → deploy fails.
|
||||||
|
`set -e` then kills the whole deploy.
|
||||||
|
- **`bun run <script>` ambiguity**: `bun run index` resolves the `index` script
|
||||||
|
from `package.json`. In a monorepo with workspace packages, ensure the script
|
||||||
|
name is unique or use the explicit file path (e.g. `bun run scripts/indexer.ts`).
|
||||||
|
|
||||||
|
**Fix**: Use `psql` for schema changes instead of `db:push`:
|
||||||
|
```bash
|
||||||
|
psql "$DATABASE_URL" -c \
|
||||||
|
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS extra_fields jsonb DEFAULT '{}'::jsonb NOT NULL;"
|
||||||
|
```
|
||||||
|
|
||||||
|
After deploy, check the **public URL** (not just `systemctl is-active`):
|
||||||
|
```bash
|
||||||
|
curl -sk -o /dev/null -w "%{http_code}" https://wiki.asepharyana.my.id/
|
||||||
|
curl -s https://wiki.asepharyana.my.id/docs/websocket/contract | grep -c "frontmatter-pattern"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy workflow template (bun-source, not Nix) — build in CI, deploy-only on VPS
|
||||||
|
|
||||||
|
> **User correction (2026-08-21):** "alur ci nya juga benarkan lah masa build
|
||||||
|
> di vps,harusnya kan di vps hanya deploy,dan seharusnya pakai nix"
|
||||||
|
|
||||||
|
The VPS must **not** build. Build happens in CI; the VPS only deploys the
|
||||||
|
pre-built artifact. Use a single workflow with a `needs:` chain (not
|
||||||
|
`workflow_run` — see pitfall #8 below).
|
||||||
|
|
||||||
|
### Pitfalls for bun-source deploy workflows
|
||||||
|
|
||||||
|
- **`bun --cwd X run Y` is broken.** `bun run` does NOT accept `--cwd` as a
|
||||||
|
subcommand flag — it prints usage and exits 0 (silent failure). Use
|
||||||
|
`working-directory: X` in the step, or `cd X && bun run Y`. (Root cause of
|
||||||
|
CI appearing to pass while the build never actually ran. This also affects
|
||||||
|
the smoke test step: `bun --cwd apps/mcp run smoke` was silently failing.)
|
||||||
|
- **`.next/` is a hidden directory.** `actions/upload-artifact@v4` excludes
|
||||||
|
hidden files/dirs by default (`include-hidden-files: false`). Set
|
||||||
|
`include-hidden-files: true` or the artifact will be empty.
|
||||||
|
- **`workflow_run` cannot download artifacts from the CI run.** This is a
|
||||||
|
known GitHub Actions limitation — the deploy workflow doesn't have access to
|
||||||
|
the CI run's artifacts. **Fix:** merge CI + deploy into a single workflow with
|
||||||
|
`deploy: needs: [build]`.
|
||||||
|
- **`actions/checkout@v4` → Node 20 deprecation.** Bump to `@v5`.
|
||||||
|
- **SCP'ing hundreds of individual files from `.next/` can time out.** Tar first,
|
||||||
|
then SCP the single tarball: `tar -czf app.tar.gz .next`, SCP, then SSH to
|
||||||
|
unpack. Also exclude `.next/cache/` to reduce size (~73MB → ~49MB).
|
||||||
|
- **`download-artifact` path resolution.** When downloading with
|
||||||
|
`path: apps/web/.next`, the artifact (which contains the `.next` directory
|
||||||
|
contents from upload `path: apps/web/.next`) gets extracted correctly to
|
||||||
|
`apps/web/.next/`. Do NOT use `path: .next` — that creates `.next/.next/`.
|
||||||
|
|
||||||
|
### Final working workflow (verified 2026-08-21)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: typecheck + build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Set up Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: bun run typecheck
|
||||||
|
|
||||||
|
- name: Build web
|
||||||
|
working-directory: apps/web
|
||||||
|
run: bun run build
|
||||||
|
|
||||||
|
# Upload .next/ — include-hidden-files is REQUIRED (hidden dir)
|
||||||
|
- name: Upload web build artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: mcpedia-web-build
|
||||||
|
path: apps/web/.next
|
||||||
|
include-hidden-files: true
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# Smoke test: requires Postgres + Redis — skipped in CI without secrets
|
||||||
|
- name: MCP smoke test
|
||||||
|
if: env.DATABASE_URL != ''
|
||||||
|
working-directory: apps/mcp
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
REDIS_URL: ${{ secrets.REDIS_URL }}
|
||||||
|
run: bun run smoke
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: bun run test
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: deploy to VPS
|
||||||
|
needs: build
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Download build artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: mcpedia-web-build
|
||||||
|
path: apps/web/.next
|
||||||
|
|
||||||
|
- name: Tar .next
|
||||||
|
run: tar -C apps/web --exclude='.next/cache' -czf mcpedia-web-next.tar.gz .next
|
||||||
|
|
||||||
|
- name: Copy tarball to VPS
|
||||||
|
uses: appleboy/scp-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_DEPLOY_HOST }}
|
||||||
|
port: ${{ secrets.SSH_DEPLOY_PORT }}
|
||||||
|
username: ${{ secrets.SSH_DEPLOY_USER }}
|
||||||
|
key: ${{ secrets.SSH_DEPLOY_KEY }}
|
||||||
|
source: "mcpedia-web-next.tar.gz"
|
||||||
|
target: "/tmp/"
|
||||||
|
|
||||||
|
- name: Unpack and restart on VPS
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_DEPLOY_HOST }}
|
||||||
|
port: ${{ secrets.SSH_DEPLOY_PORT }}
|
||||||
|
username: ${{ secrets.SSH_DEPLOY_USER }}
|
||||||
|
key: ${{ secrets.SSH_DEPLOY_KEY }}
|
||||||
|
envs: SSH_DEPLOY_HOST
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
cd /home/code/mcpedia
|
||||||
|
git pull origin main
|
||||||
|
/home/code/.bun/bin/bun install --frozen-lockfile
|
||||||
|
/home/code/.bun/bin/bun run scripts/indexer.ts
|
||||||
|
rm -rf apps/web/.next
|
||||||
|
tar -xzf /tmp/mcpedia-web-next.tar.gz -C apps/web/
|
||||||
|
rm -f /tmp/mcpedia-web-next.tar.gz
|
||||||
|
sudo systemctl restart mcpedia-web mcpedia-api mcpedia-mcp mcpedia-worker
|
||||||
|
sleep 3
|
||||||
|
systemctl --no-pager status mcpedia-web mcpedia-api mcpedia-mcp mcpedia-worker --no-legend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Secrets to configure (per-repo GitHub secrets)
|
||||||
|
|
||||||
|
| Secret | Value | How |
|
||||||
|
|--------|-------|-----|
|
||||||
|
| `SSH_DEPLOY_HOST` | VPS public IP (e.g. `45.127.35.244`) | `gh secret set SSH_DEPLOY_HOST --body "45.127.35.244"` |
|
||||||
|
| `SSH_DEPLOY_PORT` | `22` | `gh secret set SSH_DEPLOY_PORT --body "22"` |
|
||||||
|
| `SSH_DEPLOY_USER` | `code` (or whatever user owns the services) | `gh secret set SSH_DEPLOY_USER --body "code"` |
|
||||||
|
| `SSH_DEPLOY_KEY` | SSH deploy private key (ed25519) | `cat keyfile \| gh secret set SSH_DEPLOY_KEY` |
|
||||||
|
|
||||||
|
And on the VPS, add the public key to the deploy user's `authorized_keys`:
|
||||||
|
```bash
|
||||||
|
ssh code@<public-ip> "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys <<< '$(cat keyfile.pub)'"
|
||||||
|
chmod 600 ~/.ssh/authorized_keys
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI monitoring
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh run list --repo asepharyana/mcpedia --workflow "CI" --limit 3
|
||||||
|
gh run view <run_id> --log-failed # only failing-step logs
|
||||||
|
```
|
||||||
@@ -7,3 +7,27 @@ This version has breaking changes — APIs, conventions, and file structure may
|
|||||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||||
|
|
||||||
<!-- END:nextjs-agent-rules -->
|
<!-- END:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
# MCPedia Agent Instructions
|
||||||
|
|
||||||
|
MCPedia is a content-first knowledge base where documents are stored in **PostgreSQL** as the source of truth (with auto-generated .md backups on disk), indexed into embeddings + search vectors, and accessed via Web UI, tRPC API, and Model Context Protocol (MCP).
|
||||||
|
|
||||||
|
- **DB-first**: Documents are created/updated/deleted via the MCP `create_document`/`update_document`/`delete_document` tools (require `x-webhook-secret`). Filesystem `.md` files in `content/` are auto-generated backups (gitignored).
|
||||||
|
- **Git-sync deploy**: CI builds `.next` on GitHub Actions → tarball → SCP to VPS → unpack. VPS never builds. Content changes flow via MCP API to the DB directly.
|
||||||
|
|
||||||
|
## Architecture Principles
|
||||||
|
|
||||||
|
1. **Single Core Layer (`@mcpedia/core`)**: All business logic (document CRUD, indexing, search, revisions, path classification) resides in `packages/core`. Never access the database directly from `apps/web`, `apps/mcp`, or `apps/api`.
|
||||||
|
2. **DB as Source of Truth**: PostgreSQL stores document body + metadata. Filesystem `.md` files in `content/` are auto-generated backups (gitignored). Use the MCP `create_document`/`update_document` tools to write content.
|
||||||
|
3. **Multi-Modal Search**: Keyword search (Postgres FTS `tsvector` + GIN), Semantic search (cosine similarity over chunked embeddings), and Hybrid search (Reciprocal Rank Fusion / RRF) are unified in `@mcpedia/search`.
|
||||||
|
4. **Mutations & Security**: State-changing operations (document creation/updates/deletions, reindexing, revision restoration) require authentication (`x-webhook-secret` header or session cookie).
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
- **Install dependencies**: `bun install`
|
||||||
|
- **Run tests**: `bun run test`
|
||||||
|
- **Typecheck**: `bun run typecheck`
|
||||||
|
- **Build monorepo**: `bunx turbo run build`
|
||||||
|
- **Index content**: `bun run index`
|
||||||
|
- **Start dev servers**: `bun run dev`
|
||||||
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
# MCPedia — Phase Status
|
|
||||||
|
|
||||||
Legend: ✅ built · 🟡 partial · ⬜ deferred
|
|
||||||
|
|
||||||
## Phase 1 — MVP (✅ DONE)
|
|
||||||
|
|
||||||
| Capability | Status | Notes |
|
|
||||||
| --------------------- | ------ | ----- |
|
|
||||||
| Monorepo (bun + Turbo)| ✅ | apps/{web,mcp}, packages/{types,config,db,parser,search,core}, scripts |
|
|
||||||
| Content as Markdown | ✅ | `content/{docs,writeups,research,notes}/`, Git-tracked |
|
|
||||||
| Frontmatter parsing | ✅ | `@mcpedia/parser` (gray-matter) |
|
|
||||||
| Postgres metadata | ✅ | `@mcpedia/db` Drizzle, `documents` table |
|
|
||||||
| Postgres FTS | ✅ | weighted `tsvector` (title A / body B), GIN index, `ts_rank`+`ts_headline` |
|
|
||||||
| Core services | ✅ | Document / Content / Search — single business-logic layer |
|
|
||||||
| Indexer | ✅ | `scripts/indexer.ts` walks content/ → upserts |
|
|
||||||
| Web UI (Next 16) | ✅ | home (list), doc view (SSG), search (dynamic). react-markdown render |
|
|
||||||
| MCP server (stdio) | ✅ | 4 tools; in-memory smoke test passing |
|
|
||||||
| Hybrid/semantic search| ⬜ | Phase 2 |
|
|
||||||
|
|
||||||
## Phase 2 — Semantic + API
|
|
||||||
|
|
||||||
- [x] `packages/embeddings` — `EmbeddingProvider` interface + OpenRouter provider (via 9router `/v1`, `encoding_format:"float"`); `chunkText` + `embedChunks` batcher. `EMBED_DIM=2048` discovered live.
|
|
||||||
- [x] Schema `document_chunks` (id, document_id→documents.id cascade, slug, chunk_index, content, `embedding real[]`). Stored as `real[]` because pgvector **is not installed** on the shared imrnes Postgres (installing needs host-level apt — deferred). Cosine computed in-app; instant for a KB-sized corpus.
|
|
||||||
- [x] `scripts/indexer.ts` — chunks + embeds + upserts (per-doc replace).
|
|
||||||
- [x] `@mcpedia/search` — `semanticSearch` (cosine) + `hybridSearch` (FTS + cosine, RRF fusion). `keywordSearch` unchanged.
|
|
||||||
- [x] `apps/api` — Hono + tRPC v11 (`@trpc/server` fetch adapter, `@hono/node-server` on :4020): `search`, `semanticSearch`, `hybridSearch`, `getDocument`, `listDocuments`, `related`.
|
|
||||||
- [x] MCP server — added `semantic_search` + `hybrid_search` tools (6 total).
|
|
||||||
- [x] Web search — keyword/hybrid toggle (`?mode=hybrid`), hybrid reaches semantically-related docs keyword misses.
|
|
||||||
|
|
||||||
## Phase 3 — Async + Scale ✅ DONE
|
|
||||||
|
|
||||||
- [x] **Redis + BullMQ background indexing / embedding workers** —
|
|
||||||
`packages/queue` (ioredis singleton + BullMQ `Queue`/`Worker`, prefix
|
|
||||||
`mcpedia:` on shared imrnes Redis `:6379`); `apps/worker` runs
|
|
||||||
`startWorker()`. Three job types: `index-doc`, `index-all`, `reindex`.
|
|
||||||
Single indexing entry point `indexContentFile`/`runFullIndex` in
|
|
||||||
`@mcpedia/core` shared by the script, worker, and git hook. Verified
|
|
||||||
end-to-end against live Redis (job enqueue → worker → Postgres write).
|
|
||||||
- [x] **Git synchronization hook (auto-reindex on push)** — API webhook
|
|
||||||
`POST /hooks/reindex` (full) and `POST /hooks/index?slug=` (single) enqueue
|
|
||||||
BullMQ jobs. Wire a Git provider (GitHub/Gitea) post-receive / webhook to
|
|
||||||
`POST /hooks/reindex` to auto-reindex on push. `scripts/enqueue.ts` is a
|
|
||||||
one-shot enqueue helper (`bun run enqueue --all` / `<slug>`).
|
|
||||||
- [x] **Document revision system (`document_revisions`)** — `packages/db`
|
|
||||||
migration `0002_document_revisions.sql`. Indexer snapshots a revision only
|
|
||||||
when the body actually changes vs the latest revision (pure metadata edits
|
|
||||||
don't bloat history). `listRevisions` / `getRevision` / `restoreRevision`
|
|
||||||
in `@mcpedia/core`; exposed as tRPC `revisions` / `getRevision` /
|
|
||||||
`restoreRevision` and the `mcpedia://docs/{+slug}/revisions` MCP Resource.
|
|
||||||
- [x] **MCP Resources (`mcpedia://docs/...`)** — alongside the 6 tools:
|
|
||||||
`mcpedia://docs` (list), `mcpedia://docs/{+slug}` (body from disk),
|
|
||||||
`mcpedia://docs/{+slug}/chunks` (chunk preview),
|
|
||||||
`mcpedia://docs/{+slug}/revisions` (history). `{+slug}` uses RFC 6570
|
|
||||||
reserved expansion so slugs containing `/` match.
|
|
||||||
|
|
||||||
### New/changed commands
|
|
||||||
```
|
|
||||||
bun run index # full reindex (runFullIndex, writes revisions)
|
|
||||||
bun run enqueue --all # enqueue a full reindex job (no worker needed)
|
|
||||||
bun run enqueue <slug> # enqueue a single-doc reindex job
|
|
||||||
bun run worker # start the BullMQ indexing worker (long-running)
|
|
||||||
bun run api # Hono+tRPC API on :4020 (added /hooks/* webhooks)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verification done (real, against imrnes Redis + Postgres)
|
|
||||||
- `turbo run typecheck` green across all 13 packages.
|
|
||||||
- BullMQ e2e: enqueue `index-doc` → worker completes → `documents` +
|
|
||||||
`document_chunks` + `document_revisions` rows present.
|
|
||||||
- Revision dedup proven: editing a body creates a new revision; metadata-only
|
|
||||||
reindex does not; `restoreRevision` writes history back into the live row.
|
|
||||||
- MCP smoke test passes (tools + all 4 resources).
|
|
||||||
- API webhook `POST /hooks/reindex` enqueues → worker drains queue →
|
|
||||||
`queueStatus` reflects counts.
|
|
||||||
|
|
||||||
## Phase 4 — Operability & Correctness Hardening ✅ DONE
|
|
||||||
|
|
||||||
> Reinterpreted from the original "Scale-out" plan: OpenSearch/object-storage/
|
|
||||||
> multi-tenant were flagged YAGNI at KB scale (4 docs), so Phase 4 = make the
|
|
||||||
> Phase 3 async + revision machinery **correct, secure, observable, deployable**.
|
|
||||||
|
|
||||||
- [x] **T1 — `restoreRevision` rebuilds semantic chunks (CORRECTNESS BUG)** —
|
|
||||||
previously restore wrote the old body into `documents` but left `document_chunks`
|
|
||||||
on the *new* body, so semantic/hybrid search went stale after a restore.
|
|
||||||
`@mcpedia/core` `reindexChunks(slug)` now re-chunks + re-embeds from the live
|
|
||||||
body; `restoreRevision` calls it after the update (embed failure is logged, not
|
|
||||||
thrown). Verified: restore → `document_chunks` count matches re-chunk of the
|
|
||||||
restored body.
|
|
||||||
- [x] **T2 — Secure git-sync webhook (SECURITY)** — `/hooks/*` now require an
|
|
||||||
`x-webhook-secret` header matching `WEBHOOK_SECRET` (401 otherwise). API
|
|
||||||
fails fast at startup if `WEBHOOK_SECRET` is unset (no open endpoint). Added
|
|
||||||
`WEBHOOK_SECRET` to `@mcpedia/config` + `.env.example`; generated a real secret
|
|
||||||
in the local `.env` (gitignored).
|
|
||||||
- [x] **T3 — Web UI revisions view (UX)** — doc page now shows a "History" panel
|
|
||||||
(revision no, reason, date, body length) with a per-revision Restore button.
|
|
||||||
Restore POSTs to `apps/web/app/api/revisions/restore/route.ts` → `restoreRevision`
|
|
||||||
→ `revalidatePath` (server-component only, no client JS).
|
|
||||||
- [x] **T4 — Paginate `listRevisions`** — added `offset` param (summary never
|
|
||||||
includes body). API `revisions` + MCP resource use the summary.
|
|
||||||
- [x] **T5 — Deploy as supervised services (OPS)** — `deploy/mcpedia-api.service`
|
|
||||||
+ `deploy/mcpedia-worker.service` systemd units (`Restart=on-failure`,
|
|
||||||
`EnvironmentFile=.env`, `WorkingDirectory=/home/code/mcpedia`). Enable with:
|
|
||||||
`cp deploy/*.service /etc/systemd/system && systemctl daemon-reload &&
|
|
||||||
systemctl enable --now mcpedia-api mcpedia-worker`. (Not auto-enabled on host
|
|
||||||
without explicit user go-ahead.)
|
|
||||||
|
|
||||||
### Verification done (real, against imrnes Redis + Postgres)
|
|
||||||
- `turbo run typecheck` + `turbo run build` green (incl. `next build` with the
|
|
||||||
History panel).
|
|
||||||
- T1: edit → reindex (new revision + chunks) → restore rev #1 → `document_chunks`
|
|
||||||
count for that slug matches re-chunk of rev #1; `semanticSearch` on a term
|
|
||||||
unique to rev #1 returns it.
|
|
||||||
- T2: `curl -XPOST /hooks/reindex` → 401; with `-H "x-webhook-secret: $WEBHOOK_SECRET"`
|
|
||||||
→ 200 + jobId; job drains via worker.
|
|
||||||
- T3: History panel renders; restore route rebuilds chunks (T1 path).
|
|
||||||
- T4: `revisions` returns summaries (no body); `offset` paging works.
|
|
||||||
- T5: `systemd-analyze verify deploy/*.service` passes (off-host safe check).
|
|
||||||
|
|
||||||
## Phase 5 — Deferred scale-out (only when needed)
|
|
||||||
|
|
||||||
- [ ] Dedicated search engine (OpenSearch/Elasticsearch) — YAGNI until FTS is insufficient
|
|
||||||
- [ ] pgvector migration (install on imrnes Postgres) — when `real[]` cosine stalls
|
|
||||||
- [ ] Object storage for assets
|
|
||||||
- [ ] Advanced ranking, distributed workers, observability, multi-tenant
|
|
||||||
|
|
||||||
## Decisions locked (from initial planning)
|
|
||||||
|
|
||||||
- **Tooling:** bun workspaces + Turborepo (repo already used bun; pnpm rejected to minimize churn).
|
|
||||||
- **DB:** imrnes Postgres `100.121.180.82:6432/mcpedia` for both dev and deploy; driver `prepare:false` (PgBouncer). Docker Compose reserved for future prod.
|
|
||||||
- **Phase 1 scope:** Core + Web + MCP only. tRPC/Hono API, pgvector, auth, BullMQ deferred (YAGNI).
|
|
||||||
@@ -1,186 +1,310 @@
|
|||||||
# MCPedia
|
# MCPedia
|
||||||
|
|
||||||
> A content-first knowledge base — readable as Markdown/MDX in Git, queryable by
|
> A content-first knowledge base — readable as Markdown in Git, queryable by humans via a modern Web UI, and accessible to AI agents via the Model Context Protocol (MCP).
|
||||||
> humans via a Web UI and by AI agents via the Model Context Protocol (MCP).
|
|
||||||
|
|
||||||
MCPedia keeps content as plain Markdown files under `content/`. A Git-tracked
|
MCPedia keeps content as plain Markdown files under `content/` as a Git-tracked source of truth. Content is indexed into PostgreSQL (with a weighted `tsvector` full-text search column and chunked embeddings) and served through a unified **Core** layer that every interface (Web, MCP, API, Worker, CLI) shares.
|
||||||
source of truth, indexed into PostgreSQL (metadata + a `tsvector` full-text
|
|
||||||
column) and served through a single **Core** layer that every interface
|
|
||||||
(Web, MCP) shares — no business logic duplicated per surface.
|
|
||||||
|
|
||||||
## Monorepo layout
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Web UI │ │ MCP Server │ │ tRPC / Hono │ │ BullMQ Worker │
|
||||||
|
│ (Next.js 16) │ │ (Stdio + HTTP) │ │ API (:4020) │ │ (Async Queue) │
|
||||||
|
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||||
|
│ │ │ │
|
||||||
|
└──────────────────────┴──────────┬───────────┴──────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ @mcpedia/core │
|
||||||
|
│ (Unified Business Logic)│
|
||||||
|
└────────────┬────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────────────────┼─────────────────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||||
|
│ Markdown Files │ │ @mcpedia/db │ │ @mcpedia/search │
|
||||||
|
│ (content/ tree) │ │ (PostgreSQL) │ │ (FTS + Cosine) │
|
||||||
|
└──────────────────┘ └──────────────────┘ └──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Core Principles
|
||||||
|
|
||||||
|
1. **Content as Source of Truth**: Markdown files with YAML frontmatter under `content/` are primary. The database holds metadata, search indices, chunk embeddings, and revision history.
|
||||||
|
2. **Single Core Layer**: All business logic (CRUD, indexing, search, revisions, path classification) is encapsulated in `@mcpedia/core`. Interfaces never touch the database directly.
|
||||||
|
3. **Multi-Modal Search**: Keyword search (Postgres FTS), semantic search (vector cosine similarity), and hybrid search (Reciprocal Rank Fusion / RRF) work out of the box.
|
||||||
|
4. **Resilient Revision System**: Content edits snapshot revisions automatically; metadata-only edits are deduplicated. Restoring a revision automatically rebuilds semantic search chunks.
|
||||||
|
5. **Dual Interface**: Full human-friendly web experience + first-class AI agent integration via MCP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monorepo Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
mcpedia/
|
mcpedia/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── web/ # Next.js 16 (Turbopack) — human-facing docs UI + search
|
│ ├── web/ # Next.js 16 (Turbopack) — dark-mode UI, hierarchical doc tree, TOC, CRUD
|
||||||
│ ├── mcp/ # MCP server (stdio) — AI-agent interface (tools + resources)
|
│ ├── mcp/ # MCP server (stdio + Streamable HTTP on :4021) — 13 tools + 4 resources
|
||||||
│ └── api/ # Hono + tRPC v11 API on :4020 (+ /hooks/* git-sync webhooks)
|
│ ├── mcp-client/ # Interactive MCP CLI REPL, one-shot command runner, and TypeScript client SDK
|
||||||
|
│ ├── api/ # Hono + tRPC v11 API on :4020, git-sync webhooks, Prometheus metrics, dashboard
|
||||||
|
│ └── worker/ # Long-running BullMQ worker for async indexing and embedding jobs
|
||||||
├── packages/
|
├── packages/
|
||||||
│ ├── types/ # shared domain types (DocSection, Document, SearchHit, ...)
|
│ ├── core/ # Document, Content, Search, Index, Revision, and Path services (the business logic)
|
||||||
│ ├── config/ # loads .env (repo root) as authoritative dev config
|
│ ├── db/ # Drizzle ORM schema, client, and migrations
|
||||||
│ ├── db/ # Drizzle ORM schema + client + drizzle-kit config
|
│ ├── parser/ # Frontmatter (gray-matter) parsing & stringification with dynamic field support
|
||||||
│ ├── parser/ # frontmatter (gray-matter) parsing
|
│ ├── search/ # Postgres FTS query (ts_rank + ts_headline), vector cosine, and RRF hybrid fusion
|
||||||
│ ├── search/ # Postgres FTS query (ts_rank + ts_headline)
|
│ ├── embeddings/ # Chunking algorithms & embedding provider integrations
|
||||||
│ ├── embeddings/ # embedding provider + chunker
|
│ ├── queue/ # Redis (ioredis) client & BullMQ queue/worker definitions
|
||||||
│ ├── queue/ # Redis (ioredis) + BullMQ worker/queue (Phase 3)
|
│ ├── types/ # Shared TypeScript domain types and interfaces
|
||||||
│ └── core/ # Document/Content/Search/Index/Revision — the only business logic
|
│ └── config/ # Authoritative environment configuration loader (.env)
|
||||||
├── content/ # docs/ writeups/ research/ notes/ (the knowledge base)
|
├── content/ # Markdown knowledge base organized by sections and nested folders
|
||||||
└── scripts/ # indexer.ts (full reindex), enqueue.ts (one-shot job enqueue)
|
│ ├── docs/ # System and architectural documentation
|
||||||
|
│ ├── writeups/ # Technical writeups, CTF solutions, and debugging reports
|
||||||
|
│ ├── research/ # Research notes and evaluations
|
||||||
|
│ └── notes/ # Engineering notes and quick references
|
||||||
|
└── scripts/ # indexer.ts (full corpus reindexing), enqueue.ts (one-shot job enqueue)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture principle
|
---
|
||||||
|
|
||||||
```
|
## Key Features
|
||||||
Web ─┐
|
|
||||||
├──► Core ──► Repository (@mcpedia/db) ──► PostgreSQL
|
|
||||||
MCP ─┘
|
|
||||||
```
|
|
||||||
|
|
||||||
All interfaces go through `@mcpedia/core`. Nothing outside `packages/db` and
|
### 1. Multi-Modal Search
|
||||||
`packages/core` touches the database directly.
|
|
||||||
|
|
||||||
## Quick start
|
| Search Mode | Mechanism | Best Used For |
|
||||||
|
|---|---|---|
|
||||||
|
| **Keyword Search** | PostgreSQL `tsvector` (`simple` config, weighted A/B) + GIN index + `ts_rank` + `ts_headline` | Exact terms, code symbols, error codes, identifiers |
|
||||||
|
| **Semantic Search** | Text chunking (1000 chars, 150 overlap) + vector embeddings + cosine similarity ranking | Conceptual questions, paraphrased queries, intent matching |
|
||||||
|
| **Hybrid Search** | Reciprocal Rank Fusion ($RRF = \sum \frac{1}{k + rank}$) fusing keyword and semantic signals | General-purpose search with optimal relevance |
|
||||||
|
|
||||||
|
### 2. Hierarchical Folder Navigation
|
||||||
|
|
||||||
|
MCPedia supports nested folder structures (like GitHub repositories):
|
||||||
|
- **Dynamic Path Classification**: The router inspects paths and automatically distinguishes between leaf documents and folder nodes containing subfolders or child documents.
|
||||||
|
- **Folder Index Pages**: Navigating to any folder (e.g. `/writeups/ctf/defcon-quals-2024`) renders subfolders and documents within that path.
|
||||||
|
- **Collapsible Sidebar**: Hierarchical navigation tree reflecting the on-disk directory structure.
|
||||||
|
- **Section Indexes**: Dedicated overview pages for each section (`/docs`, `/writeups`, `/research`, `/notes`).
|
||||||
|
|
||||||
|
### 3. Dynamic Custom Fields
|
||||||
|
|
||||||
|
Content frontmatter supports arbitrary custom key-value pairs without schema modifications:
|
||||||
|
- **Automatic Storage**: Custom fields are persisted into a JSONB `extra_fields` column in PostgreSQL.
|
||||||
|
- **Type Preservation**: Numbers, booleans, arrays, objects, and strings maintain native types across parser, database, and API.
|
||||||
|
- **Value-Aware UI Badges**: The Web UI automatically styles badges based on value types and semantic patterns (difficulty levels, categories, tags, status) rather than hardcoded field names.
|
||||||
|
|
||||||
|
### 4. Revision History & Rollback
|
||||||
|
|
||||||
|
- **Smart Snapshotting**: Whenever a document body changes, a revision snapshot is created in `document_revisions`.
|
||||||
|
- **Deduplication**: Metadata-only updates do not produce duplicate body snapshots.
|
||||||
|
- **One-Click Restore**: Restoring any past revision writes the historic content back to disk and database, and automatically triggers semantic chunk re-indexing to ensure search consistency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Model Context Protocol (MCP)
|
||||||
|
|
||||||
|
MCPedia runs an MCP server accessible via **Stdio** (for local subagents) and **Streamable HTTP** (for remote agents over the network at `:4021`).
|
||||||
|
|
||||||
|
### MCP Tools (13 Total)
|
||||||
|
|
||||||
|
#### Read Tools (Public)
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `search_documents` | Full-text keyword search over the knowledge base with headline snippets |
|
||||||
|
| `semantic_search` | Embedding-based cosine search across chunked content |
|
||||||
|
| `hybrid_search` | Fused full-text and semantic search via Reciprocal Rank Fusion |
|
||||||
|
| `get_document` | Fetches the full Markdown body of a document by slug |
|
||||||
|
| `list_documents` | Lists documents, optionally filtered by section or status |
|
||||||
|
| `get_related_documents` | Finds documents sharing tags with a given slug |
|
||||||
|
| `queue_status` | Returns current BullMQ indexing queue metrics |
|
||||||
|
|
||||||
|
#### Mutating & Admin Tools (Requires `x-webhook-secret`)
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `create_document` | Creates a new document (writes Markdown file, database row, revision, and chunks) |
|
||||||
|
| `update_document` | Updates an existing document's body, metadata, or dynamic custom fields |
|
||||||
|
| `delete_document` | Removes a document file, database rows, revisions, and chunks |
|
||||||
|
| `index_document` | Enqueues a single-document background reindex job |
|
||||||
|
| `reindex_all` | Enqueues a full-corpus background reindexing job |
|
||||||
|
| `restore_revision` | Restores a document to a specific revision and rebuilds search chunks |
|
||||||
|
|
||||||
|
### MCP Resources
|
||||||
|
|
||||||
|
| Resource URI | Description | MIME Type |
|
||||||
|
|---|---|---|
|
||||||
|
| `mcpedia://docs` | List of all published documents in the knowledge base | `application/json` |
|
||||||
|
| `mcpedia://docs/{+slug}` | Full Markdown body of a single document from disk | `text/markdown` |
|
||||||
|
| `mcpedia://docs/{+slug}/chunks` | Preview of embedded semantic chunks for a document | `application/json` |
|
||||||
|
| `mcpedia://docs/{+slug}/revisions` | Summary of revision history for a document | `application/json` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTP, tRPC & Webhook Endpoints
|
||||||
|
|
||||||
|
The API application (`apps/api`) runs on **:4020** with Hono and tRPC v11:
|
||||||
|
|
||||||
|
### tRPC Procedures
|
||||||
|
- **Queries**: `search`, `semanticSearch`, `hybridSearch`, `getDocument`, `listDocuments`, `related`, `revisions`, `getRevision`, `jobStatus`, `queueStatus`.
|
||||||
|
- **Mutations** (Protected by `x-webhook-secret`): `createDocument`, `updateDocument`, `deleteDocument`, `restoreRevision`.
|
||||||
|
|
||||||
|
### Webhooks & Observability
|
||||||
|
- `POST /hooks/reindex` — Triggers a full corpus reindex (ideal for Git push webhooks).
|
||||||
|
- `POST /hooks/index?slug=<slug>` — Reindexes a single document.
|
||||||
|
- `GET /metrics` — Prometheus metrics exposition (`mcpedia_uptime_seconds`, `mcpedia_queue_jobs{state=...}`).
|
||||||
|
- `GET /dashboard` — Standalone real-time web dashboard for queue monitoring and live search.
|
||||||
|
- `GET /health` — Health check endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication & Security
|
||||||
|
|
||||||
|
MCPedia utilizes dual-tier security:
|
||||||
|
|
||||||
|
1. **Web UI Authentication**:
|
||||||
|
- Gated via `ADMIN_PASSWORD` in `.env`.
|
||||||
|
- Generates an HMAC-signed `mcpedia_admin` HttpOnly cookie upon login at `/login`.
|
||||||
|
- Protects document creation (`/create`), inline editing (`?edit=1`), and deletion.
|
||||||
|
|
||||||
|
2. **API & MCP Write Authentication**:
|
||||||
|
- Gated via `WEBHOOK_SECRET` in `.env`.
|
||||||
|
- Requires the `x-webhook-secret` HTTP header on mutating endpoints and tools.
|
||||||
|
- The API will refuse to start if `WEBHOOK_SECRET` is unset, preventing unsecured deployments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Bun](https://bun.sh/) (v1.1+)
|
||||||
|
- [PostgreSQL](https://www.postgresql.org/) (with `tsvector` support)
|
||||||
|
- [Redis](https://redis.io/) (for BullMQ async workers)
|
||||||
|
|
||||||
|
### 1. Installation & Environment Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun install # install workspace deps
|
# Clone repository
|
||||||
cp .env.example .env # set DATABASE_URL (dev uses imrnes Postgres :6432)
|
git clone https://github.com/asepharyana/mcpedia.git
|
||||||
bunx turbo run build # typecheck + build every package
|
cd mcpedia
|
||||||
|
|
||||||
bun run index # walk content/ -> upsert into Postgres
|
# Install workspace dependencies
|
||||||
bun --cwd apps/web run dev # Web UI on :3000
|
bun install
|
||||||
bun run mcp # MCP server on stdio (pipe to an MCP client)
|
|
||||||
|
# Configure environment variables
|
||||||
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database
|
Ensure `.env` contains your database connection, Redis configuration, webhook secret, and admin password.
|
||||||
|
|
||||||
Schema is defined in `packages/db/src/schema.ts` (`documents` with a weighted
|
### 2. Database Setup
|
||||||
`search_vector` tsvector + GIN index, and `document_chunks` with an `embedding real[]`).
|
|
||||||
The `pgvector` extension is **not available** on the shared imrnes Postgres, so
|
|
||||||
semantic search stores vectors as `real[]` and ranks by in-app cosine similarity.
|
|
||||||
|
|
||||||
Migrations live in `packages/db/drizzle/`. They were applied manually via `psql`
|
Apply migrations to initialize tables (`documents`, `document_chunks`, `document_revisions`):
|
||||||
(`drizzle-kit push` is unreliable under PgBouncer transaction pooling); to
|
|
||||||
re-apply on a fresh DB:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
psql $DATABASE_URL -f packages/db/drizzle/0000_grey_toro.sql
|
psql $DATABASE_URL -f packages/db/drizzle/0000_grey_toro.sql
|
||||||
psql $DATABASE_URL -f packages/db/drizzle/0001_document_chunks.sql
|
psql $DATABASE_URL -f packages/db/drizzle/0001_document_chunks.sql
|
||||||
|
psql $DATABASE_URL -f packages/db/drizzle/0002_document_revisions.sql
|
||||||
|
psql $DATABASE_URL -f packages/db/drizzle/0003_document_extra_fields.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
> Note: on imrnes (PgBouncer `:6432`) a leaked `DATABASE_URL` shell var can
|
### 3. Build & Index
|
||||||
> shadow `.env`. `@mcpedia/config` loads `.env` **last** so the repo config
|
|
||||||
> always wins for local/dev.
|
|
||||||
|
|
||||||
## Content
|
```bash
|
||||||
|
# Typecheck and build all packages
|
||||||
|
bunx turbo run build
|
||||||
|
|
||||||
Each Markdown file carries YAML frontmatter:
|
# Run initial full indexation (parses content/, embeds chunks, writes DB)
|
||||||
|
bun run index
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Running Services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start all development services (Web, API, MCP, Worker) concurrently
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
# Or run services individually:
|
||||||
|
bun --cwd apps/web run dev # Web UI on :3000 (or :4016 in production)
|
||||||
|
bun run api # Hono + tRPC API on :4020
|
||||||
|
bun run worker # BullMQ background worker
|
||||||
|
bun run mcp # MCP server (stdio mode)
|
||||||
|
bun run mcp:http # MCP server (Streamable HTTP mode on :4021)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scripts & CLI Reference
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
|---|---|
|
||||||
|
| `bun run index` | Run full synchronous reindexer over `content/` |
|
||||||
|
| `bun run enqueue --all` | Enqueue a full reindex job to Redis/BullMQ |
|
||||||
|
| `bun run enqueue <slug>` | Enqueue a single-document reindex job |
|
||||||
|
| `bun run worker` | Start the BullMQ background worker |
|
||||||
|
| `bun run api` | Start the Hono + tRPC API server |
|
||||||
|
| `bun run mcp` | Run MCP server in stdio mode |
|
||||||
|
| `bun run mcp:http` | Run MCP server in Streamable HTTP mode |
|
||||||
|
| `bun run chat` | Launch interactive MCP Client REPL |
|
||||||
|
| `bun run ask <cmd> [args]` | Run one-shot MCP client command |
|
||||||
|
| `bun run test` | Run the comprehensive test suite across all packages |
|
||||||
|
| `bun run typecheck` | Run TypeScript type checking across all workspaces |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Content Format
|
||||||
|
|
||||||
|
Content files reside in `content/<section>/` as Markdown files with YAML frontmatter:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
id: websocket-contract
|
id: sample-document
|
||||||
title: WebSocket Contract
|
title: Sample Architecture Document
|
||||||
type: documentation
|
type: documentation
|
||||||
tags: [typescript, websocket, rpc]
|
section: docs
|
||||||
|
tags: [architecture, typescript, postgres]
|
||||||
status: published
|
status: published
|
||||||
author: asep
|
author: asep
|
||||||
created_at: 2026-08-19
|
created_at: 2026-08-20
|
||||||
updated_at: 2026-08-19
|
updated_at: 2026-08-20
|
||||||
|
# Arbitrary custom fields (auto-discovered & styled):
|
||||||
|
difficulty: intermediate
|
||||||
|
points: 100
|
||||||
|
category: system-design
|
||||||
|
verified: true
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# Sample Architecture Document
|
||||||
|
|
||||||
|
Your document content in standard GitHub Flavored Markdown (GFM).
|
||||||
|
Tables, code snippets, and custom sections are fully supported.
|
||||||
```
|
```
|
||||||
|
|
||||||
`slug` = relative path under `content/` (e.g. `docs/websocket/contract`). The
|
---
|
||||||
`body` shown in the UI is always read from the on-disk file (source of truth);
|
|
||||||
the DB stores metadata + the search vector.
|
|
||||||
|
|
||||||
## MCP tools
|
## Production Deployment
|
||||||
|
|
||||||
| Tool | Purpose |
|
Production services run as supervised systemd units behind a Caddy reverse proxy:
|
||||||
| --------------------- | ------------------------------------------------ |
|
|
||||||
| `search_documents` | Postgres FTS over the corpus (ranked + snippet) |
|
|
||||||
| `semantic_search` | Embedding/cosine search over chunked content |
|
|
||||||
| `hybrid_search` | FTS + semantic fused via RRF |
|
|
||||||
| `get_document` | Full markdown body by slug |
|
|
||||||
| `list_documents` | List, optionally filtered by section |
|
|
||||||
| `get_related_documents` | Docs sharing tags with a given slug |
|
|
||||||
|
|
||||||
### MCP Resources
|
|
||||||
|
|
||||||
| URI | Purpose |
|
|
||||||
| -------------------------------- | ---------------------------------------- |
|
|
||||||
| `mcpedia://docs` | List all published documents |
|
|
||||||
| `mcpedia://docs/{+slug}` | Full markdown body (read from disk) |
|
|
||||||
| `mcpedia://docs/{+slug}/chunks` | Preview of embedded semantic chunks |
|
|
||||||
| `mcpedia://docs/{+slug}/revisions` | Revision history summary |
|
|
||||||
|
|
||||||
(`{+slug}` uses RFC 6570 reserved expansion so a slug like
|
|
||||||
`docs/websocket/contract` matches the template.)
|
|
||||||
|
|
||||||
Smoke test (in-memory transport, real JSON-RPC):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun --cwd apps/mcp run smoke
|
|
||||||
```
|
|
||||||
|
|
||||||
## API (Phase 2 + Phase 3)
|
|
||||||
|
|
||||||
A tRPC v11 API is exposed via Hono on **:4020** (all procedures mirror the
|
|
||||||
MCP tools). Phase 3 adds async job + revision procedures and git-sync webhooks:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run api # http://localhost:4020 (GET /health, POST/GET /trpc/*)
|
|
||||||
```
|
|
||||||
|
|
||||||
tRPC procedures: `search`, `semanticSearch`, `hybridSearch`, `getDocument`,
|
|
||||||
`listDocuments`, `related` (Phase 2); plus `revisions`, `getRevision`,
|
|
||||||
`restoreRevision`, `jobStatus`, `queueStatus` (Phase 3).
|
|
||||||
|
|
||||||
Git-sync webhooks (enqueue BullMQ jobs; the worker processes them):
|
|
||||||
- `POST /hooks/reindex` — full-corpus reindex (point your Git provider's
|
|
||||||
push webhook here to auto-reindex on push).
|
|
||||||
- `POST /hooks/index?slug=<slug>` — reindex a single document.
|
|
||||||
|
|
||||||
> **Security:** both webhooks require an `x-webhook-secret` header that matches
|
|
||||||
> `WEBHOOK_SECRET` (set in `.env`). The API refuses to start if `WEBHOOK_SECRET`
|
|
||||||
> is unset, so the hooks are never left open.
|
|
||||||
|
|
||||||
`bun run index` now also chunks + embeds (Phase 2 indexer) and snapshots a
|
|
||||||
revision whenever the body changes (Phase 3). See `.env.example` for
|
|
||||||
`EMBED_*` / `REDIS_*` / `QUEUE_PREFIX` / `WEBHOOK_SECRET` vars.
|
|
||||||
|
|
||||||
### Run as a supervised service (Phase 4)
|
|
||||||
|
|
||||||
`deploy/mcpedia-api.service` + `deploy/mcpedia-worker.service` are systemd units
|
|
||||||
(`Restart=on-failure`, `EnvironmentFile=.env`, `WorkingDirectory=/home/code/mcpedia`).
|
|
||||||
Enable them with:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Copy systemd unit templates
|
||||||
sudo cp deploy/*.service /etc/systemd/system/
|
sudo cp deploy/*.service /etc/systemd/system/
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable --now mcpedia-api mcpedia-worker
|
|
||||||
# tail logs
|
# Enable and start all supervised services
|
||||||
journalctl -u mcpedia-api -u mcpedia-worker -f
|
sudo systemctl enable --now mcpedia-web mcpedia-api mcpedia-worker mcpedia-mcp
|
||||||
|
|
||||||
|
# Inspect service logs
|
||||||
|
journalctl -u mcpedia-web -u mcpedia-api -u mcpedia-worker -u mcpedia-mcp -f
|
||||||
```
|
```
|
||||||
|
|
||||||
The API should sit behind Caddy (or your reverse proxy) for TLS; expose only
|
---
|
||||||
`:4020` internally and the web app publicly.
|
|
||||||
|
|
||||||
## Status
|
## Testing
|
||||||
|
|
||||||
**Phase 1 — MVP (DONE):** monorepo, Core, Web UI (home/doc/search), MCP server,
|
The test suite runs with in-memory mocks without requiring an active database or Redis instance:
|
||||||
Postgres FTS keyword search, content indexing.
|
|
||||||
|
|
||||||
**Phase 2 — Semantic + API (DONE):** embeddings provider (OpenRouter via 9router),
|
```bash
|
||||||
chunked `document_chunks`, `semanticSearch` + `hybridSearch` (RRF), tRPC/Hono API
|
bun run test
|
||||||
(`apps/api`, :4020), MCP `semantic_search`/`hybrid_search` tools, web hybrid toggle.
|
```
|
||||||
|
|
||||||
**Phase 3 — Async + Scale (DONE):** Redis + BullMQ background indexing/embedding
|
All 32+ unit and integration tests across packages and apps validate chunking, frontmatter parsing, cosine similarity, revision deduplication, auth gates, and route handlers.
|
||||||
workers (`packages/queue`, `apps/worker`), git-sync webhooks (`POST /hooks/*`),
|
|
||||||
document revision system (`document_revisions` + restore), and MCP Resources
|
|
||||||
(`mcpedia://docs/...`). See `PHASES.md`.
|
|
||||||
|
|
||||||
> pgvector is **not installed** on the shared imrnes Postgres, so vector storage is
|
|
||||||
> a `real[]` column with in-app cosine similarity (instant at KB scale). pgvector is
|
|
||||||
> the Phase-4 scale-out path. See `PHASES.md`.
|
|
||||||
|
|
||||||
See `PHASES.md` for Phase 3–4 (Redis/BullMQ, auth, revisions, scale-out).
|
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
"dev": "bun run src/index.ts",
|
"dev": "bun run src/index.ts",
|
||||||
"start": "bun run src/index.ts",
|
"start": "bun run src/index.ts",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.0",
|
"@hono/node-server": "^1.13.0",
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
"@mcpedia/queue": "workspace:*",
|
"@mcpedia/queue": "workspace:*",
|
||||||
"@trpc/server": "^11.0.0",
|
"@trpc/server": "^11.0.0",
|
||||||
"hono": "^4.6.0",
|
"hono": "^4.6.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^4.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { test, expect, beforeEach, mock } from "bun:test";
|
||||||
|
import { createApp } from "../src/app";
|
||||||
|
import type { ApiDeps } from "../src/app";
|
||||||
|
import { DASHBOARD_HTML } from "../src/dashboard";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// A fake queue that records calls and returns canned counts. Injected into
|
||||||
|
// createApp so no live Redis/BullMQ is needed.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
function fakeQueue(counts: Record<string, number>) {
|
||||||
|
const base = {
|
||||||
|
waiting: counts.waiting ?? 0,
|
||||||
|
active: counts.active ?? 0,
|
||||||
|
completed: counts.completed ?? 0,
|
||||||
|
failed: counts.failed ?? 0,
|
||||||
|
delayed: counts.delayed ?? 0,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
getWaitingCount: () => Promise.resolve(base.waiting),
|
||||||
|
getActiveCount: () => Promise.resolve(base.active),
|
||||||
|
getCompletedCount: () => Promise.resolve(base.completed),
|
||||||
|
getFailedCount: () => Promise.resolve(base.failed),
|
||||||
|
getDelayedCount: () => Promise.resolve(base.delayed),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDeps(secret: string, q: ReturnType<typeof fakeQueue>): ApiDeps {
|
||||||
|
return { queue: q, webhookSecret: secret };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock @mcpedia/queue so the production lazy-import path in createApp(deps=undefined)
|
||||||
|
// doesn't try to connect to Redis during construction. We never call that path in
|
||||||
|
// these tests (we always inject deps), but the import may still be pulled by the
|
||||||
|
// module graph — mock it to be safe.
|
||||||
|
mock.module("@mcpedia/queue", () => ({
|
||||||
|
getQueue: () => fakeQueue({}),
|
||||||
|
enqueueFullIndex: async () => ({ id: "real-full" }),
|
||||||
|
enqueueIndexDoc: async () => ({ id: "real-doc" }),
|
||||||
|
INDEX_QUEUE: "mcpedia-index",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock @mcpedia/core so tRPC CRUD procedures don't hit Postgres.
|
||||||
|
// Only the functions used by the router are faked; assertions record calls.
|
||||||
|
const coreCalls = {
|
||||||
|
createDocument: [] as Array<any[]>,
|
||||||
|
updateDocument: [] as Array<any[]>,
|
||||||
|
deleteDocument: [] as Array<any[]>,
|
||||||
|
};
|
||||||
|
mock.module("@mcpedia/core", () => ({
|
||||||
|
keywordSearch: () => Promise.resolve([]),
|
||||||
|
getDocument: () => Promise.resolve(null),
|
||||||
|
getRelated: () => Promise.resolve([]),
|
||||||
|
hybridSearch: () => Promise.resolve([]),
|
||||||
|
semanticSearch: () => Promise.resolve([]),
|
||||||
|
listDocuments: () => Promise.resolve([]),
|
||||||
|
listRevisions: () => Promise.resolve([]),
|
||||||
|
getRevision: () => Promise.resolve(null),
|
||||||
|
restoreRevision: () => Promise.resolve(null),
|
||||||
|
createDocument: (...args: any[]) => {
|
||||||
|
coreCalls.createDocument.push(args);
|
||||||
|
return Promise.resolve({ slug: "docs/test", title: "Test" });
|
||||||
|
},
|
||||||
|
updateDocument: (...args: any[]) => {
|
||||||
|
coreCalls.updateDocument.push(args);
|
||||||
|
return Promise.resolve({ slug: args[0], title: "Updated" });
|
||||||
|
},
|
||||||
|
deleteDocument: (...args: any[]) => {
|
||||||
|
coreCalls.deleteDocument.push(args);
|
||||||
|
return Promise.resolve({ deleted: true });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
let SECRET: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
SECRET = "test-secret-" + Math.random().toString(36).slice(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /health returns { ok: true }", async () => {
|
||||||
|
const app = await createApp(makeDeps(SECRET, fakeQueue({})));
|
||||||
|
const res = await app.request("/health");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /metrics emits Prometheus text with all gauge states", async () => {
|
||||||
|
const app = await createApp(
|
||||||
|
makeDeps(
|
||||||
|
SECRET,
|
||||||
|
fakeQueue({ waiting: 1, active: 2, completed: 3, failed: 4, delayed: 5 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const res = await app.request("/metrics");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toMatch(/text\/plain/);
|
||||||
|
const text = await res.text();
|
||||||
|
expect(text).toContain("mcpedia_uptime_seconds");
|
||||||
|
expect(text).toContain('mcpedia_queue_jobs{state="waiting"} 1');
|
||||||
|
expect(text).toContain('mcpedia_queue_jobs{state="active"} 2');
|
||||||
|
expect(text).toContain('mcpedia_queue_jobs{state="completed"} 3');
|
||||||
|
expect(text).toContain('mcpedia_queue_jobs{state="failed"} 4');
|
||||||
|
expect(text).toContain('mcpedia_queue_jobs{state="delayed"} 5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /hooks/reindex without secret -> 401", async () => {
|
||||||
|
const app = await createApp(makeDeps(SECRET, fakeQueue({})));
|
||||||
|
const res = await app.request("/hooks/reindex", { method: "POST" });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect((await res.json()).error).toBe("unauthorized");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /hooks/reindex with correct x-webhook-secret -> 200", async () => {
|
||||||
|
const app = await createApp(makeDeps(SECRET, fakeQueue({})));
|
||||||
|
const res = await app.request("/hooks/reindex", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-webhook-secret": SECRET },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(body.kind).toBe("full");
|
||||||
|
expect(body.jobId).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /hooks/index without slug -> 400", async () => {
|
||||||
|
const app = await createApp(makeDeps(SECRET, fakeQueue({})));
|
||||||
|
const res = await app.request("/hooks/index", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-webhook-secret": SECRET },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /hooks/index with slug + secret -> 200 + relPath", async () => {
|
||||||
|
const app = await createApp(makeDeps(SECRET, fakeQueue({})));
|
||||||
|
const res = await app.request("/hooks/index?slug=docs/test", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-webhook-secret": SECRET },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(body.relPath).toBe("docs/test.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /hooks/index with wrong secret -> 401", async () => {
|
||||||
|
const app = await createApp(makeDeps(SECRET, fakeQueue({})));
|
||||||
|
const res = await app.request("/hooks/index?slug=docs/test", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-webhook-secret": "WRONG" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /dashboard returns the self-contained HTML", async () => {
|
||||||
|
const app = await createApp(makeDeps(SECRET, fakeQueue({})));
|
||||||
|
const res = await app.request("/dashboard");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const html = await res.text();
|
||||||
|
// The dashboard HTML is a module-level constant — assert the route serves it
|
||||||
|
// verbatim and contains the key landmarks.
|
||||||
|
expect(html).toContain("MCPedia Dashboard");
|
||||||
|
expect(html).toContain("Index Queue (BullMQ)");
|
||||||
|
expect(html).toContain(DASHBOARD_HTML.slice(0, 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Phase 11: CRUD tRPC procedures (auth-gated) ---
|
||||||
|
|
||||||
|
function trpcRequest(app: Hono, procedure: string, input: unknown, secret?: string) {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"content-type": "application/json",
|
||||||
|
};
|
||||||
|
if (secret) headers["x-webhook-secret"] = secret;
|
||||||
|
// tRPC fetch adapter expects the procedure input directly as the JSON body
|
||||||
|
// (not wrapped in a JSON-RPC envelope). The procedure name comes from the URL.
|
||||||
|
return app.request("/trpc/" + procedure, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("tRPC createDocument without secret -> rejects (tRPC error)", async () => {
|
||||||
|
const app = await createApp(makeDeps(SECRET, fakeQueue({})));
|
||||||
|
const res = await trpcRequest(app, "createDocument", {
|
||||||
|
slug: "docs/test", title: "Test", section: "docs", body: "# Hi"
|
||||||
|
});
|
||||||
|
// tRPC middleware throws Error -> JSON-RPC error (httpStatus 500, not 401)
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(coreCalls.createDocument).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tRPC createDocument with secret -> 200 + calls core", async () => {
|
||||||
|
const app = await createApp(makeDeps(SECRET, fakeQueue({})));
|
||||||
|
const res = await trpcRequest(app, "createDocument", {
|
||||||
|
slug: "docs/test", title: "Test", section: "docs", body: "# Hi"
|
||||||
|
}, SECRET);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(coreCalls.createDocument).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tRPC updateDocument with secret -> 200 + calls core", async () => {
|
||||||
|
const app = await createApp(makeDeps(SECRET, fakeQueue({})));
|
||||||
|
const res = await trpcRequest(app, "updateDocument", {
|
||||||
|
slug: "docs/test", body: "# Updated"
|
||||||
|
}, SECRET);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(coreCalls.updateDocument).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tRPC deleteDocument without secret -> rejects (tRPC error)", async () => {
|
||||||
|
const app = await createApp(makeDeps(SECRET, fakeQueue({})));
|
||||||
|
const res = await trpcRequest(app, "deleteDocument", {
|
||||||
|
slug: "docs/test"
|
||||||
|
});
|
||||||
|
// tRPC middleware throws Error -> JSON-RPC error (httpStatus 500, not 401)
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(coreCalls.deleteDocument).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tRPC deleteDocument with secret -> 200 + calls core", async () => {
|
||||||
|
const app = await createApp(makeDeps(SECRET, fakeQueue({})));
|
||||||
|
const res = await trpcRequest(app, "deleteDocument", {
|
||||||
|
slug: "docs/test"
|
||||||
|
}, SECRET);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(coreCalls.deleteDocument).toHaveLength(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { serve } from "@hono/node-server";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import type { Context as HonoContext } from "hono";
|
||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||||
|
import { appRouter } from "./router";
|
||||||
|
import type { Context } from "./trpc";
|
||||||
|
import { WEBHOOK_SECRET } from "@mcpedia/config";
|
||||||
|
import { db } from "@mcpedia/db";
|
||||||
|
import { DASHBOARD_HTML } from "./dashboard";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Injectable queue handle. By default we use the real shared BullMQ queue from
|
||||||
|
// @mcpedia/queue; tests pass a fake queue object. This breaks import-time
|
||||||
|
// coupling to Redis so the API surface is unit-testable without a broker.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export interface QueueLike {
|
||||||
|
getWaitingCount(): Promise<number>;
|
||||||
|
getActiveCount(): Promise<number>;
|
||||||
|
getCompletedCount(): Promise<number>;
|
||||||
|
getFailedCount(): Promise<number>;
|
||||||
|
getDelayedCount(): Promise<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiDeps {
|
||||||
|
queue: QueueLike;
|
||||||
|
webhookSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the Hono application. Pure construction — no process exit, no side
|
||||||
|
* side effects. Tests inject fakes via `deps`. Production callers may omit it,
|
||||||
|
* in which case the real queue + configured WEBHOOK_SECRET are used. */
|
||||||
|
export async function createApp(deps?: ApiDeps): Promise<Hono> {
|
||||||
|
const d = deps ?? await realDeps();
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/health", (c) => c.json({ ok: true }));
|
||||||
|
|
||||||
|
// --- Phase 7: Prometheus metrics (public, safe to scrape) ---
|
||||||
|
const startedAt = Date.now();
|
||||||
|
app.get("/metrics", async (c) => {
|
||||||
|
const q = d.queue;
|
||||||
|
const [waiting, active, completed, failed, delayed] = await Promise.all([
|
||||||
|
q.getWaitingCount(),
|
||||||
|
q.getActiveCount(),
|
||||||
|
q.getCompletedCount(),
|
||||||
|
q.getFailedCount(),
|
||||||
|
q.getDelayedCount(),
|
||||||
|
]);
|
||||||
|
const lines = [
|
||||||
|
"# HELP mcpedia_uptime_seconds seconds since process start",
|
||||||
|
"# TYPE mcpedia_uptime_seconds gauge",
|
||||||
|
`mcpedia_uptime_seconds ${((Date.now() - startedAt) / 1000).toFixed(1)}`,
|
||||||
|
`# HELP mcpedia_queue_jobs queue job counts for "mcpedia-index"`,
|
||||||
|
"# TYPE mcpedia_queue_jobs gauge",
|
||||||
|
`mcpedia_queue_jobs{state="waiting"} ${waiting}`,
|
||||||
|
`mcpedia_queue_jobs{state="active"} ${active}`,
|
||||||
|
`mcpedia_queue_jobs{state="completed"} ${completed}`,
|
||||||
|
`mcpedia_queue_jobs{state="failed"} ${failed}`,
|
||||||
|
`mcpedia_queue_jobs{state="delayed"} ${delayed}`,
|
||||||
|
];
|
||||||
|
return c.text(lines.join("\n") + "\n", 200, {
|
||||||
|
"Content-Type": "text/plain; version=0.0.4; charset=utf-8",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shared guard for the git-sync webhooks: require `x-webhook-secret` header
|
||||||
|
// to match the configured secret. Reject anything else with 401. Supports
|
||||||
|
// GitHub native HMAC (X-Hub-Signature-256) + plain header for manual triggers.
|
||||||
|
async function assertWebhookAuth(c: HonoContext): Promise<boolean> {
|
||||||
|
if (!d.webhookSecret) return false;
|
||||||
|
const raw = c.req.raw;
|
||||||
|
const ghSig = raw.headers.get("x-hub-signature-256");
|
||||||
|
if (ghSig && ghSig.startsWith("sha256=")) {
|
||||||
|
try {
|
||||||
|
const body = await raw.text();
|
||||||
|
const mac = createHmac("sha256", d.webhookSecret).update(body).digest("hex");
|
||||||
|
const expected = `sha256=${mac}`;
|
||||||
|
return timingSafeEqual(Buffer.from(ghSig), Buffer.from(expected));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const provided = raw.headers.get("x-webhook-secret");
|
||||||
|
return provided != null && provided === d.webhookSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Phase 3: Git synchronization hook ---
|
||||||
|
// POST /hooks/reindex -> enqueue a full-corpus reindex (git push webhook)
|
||||||
|
// POST /hooks/index?slug=... -> enqueue a single document reindex
|
||||||
|
// When a fake queue is injected (tests), these return a synthetic jobId.
|
||||||
|
let enqueueFull: (() => Promise<{ id: string }>) | null = null;
|
||||||
|
let enqueueDoc: ((relPath: string, reason: string) => Promise<{ id: string }>) | null = null;
|
||||||
|
if (deps === undefined) {
|
||||||
|
// Production: lazy-import the real queue helpers so the module graph stays
|
||||||
|
// clean (no Redis connection at import time if not starting the server).
|
||||||
|
const { enqueueFullIndex, enqueueIndexDoc } = await import("@mcpedia/queue");
|
||||||
|
enqueueFull = enqueueFullIndex as () => Promise<{ id: string }>;
|
||||||
|
enqueueDoc = enqueueIndexDoc as (
|
||||||
|
relPath: string,
|
||||||
|
reason: string,
|
||||||
|
) => Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post("/hooks/reindex", async (c) => {
|
||||||
|
if (!(await assertWebhookAuth(c))) {
|
||||||
|
return c.json({ ok: false, error: "unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
if (enqueueFull) {
|
||||||
|
const job = await enqueueFull();
|
||||||
|
return c.json({ ok: true, jobId: job.id, kind: "full" });
|
||||||
|
}
|
||||||
|
return c.json({ ok: true, jobId: "fake", kind: "full" });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/hooks/index", async (c) => {
|
||||||
|
if (!(await assertWebhookAuth(c))) {
|
||||||
|
return c.json({ ok: false, error: "unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
const slug = c.req.query("slug");
|
||||||
|
if (!slug) return c.json({ ok: false, error: "slug query param required" }, 400);
|
||||||
|
const relPath = slug.endsWith(".md") || slug.endsWith(".mdx") ? slug : `${slug}.md`;
|
||||||
|
if (enqueueDoc) {
|
||||||
|
const job = await enqueueDoc(relPath, "git-push");
|
||||||
|
return c.json({ ok: true, jobId: job.id, kind: "doc", relPath });
|
||||||
|
}
|
||||||
|
return c.json({ ok: true, jobId: "fake", kind: "doc", relPath });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Phase 7: observability dashboard (public) ---
|
||||||
|
app.get("/dashboard", (c) => c.html(DASHBOARD_HTML));
|
||||||
|
|
||||||
|
// tRPC (read-only procedures public; restoreRevision mutation gated by
|
||||||
|
// x-webhook-secret in the router's requireWriteAuth middleware).
|
||||||
|
app.all("/trpc/*", (c) =>
|
||||||
|
fetchRequestHandler({
|
||||||
|
endpoint: "/trpc",
|
||||||
|
req: c.req.raw,
|
||||||
|
router: appRouter,
|
||||||
|
createContext: (): Context => ({
|
||||||
|
db, // real Postgres connection (read procedures use it via @mcpedia/db).
|
||||||
|
webhookSecret: c.req.raw.headers.get("x-webhook-secret") ?? undefined,
|
||||||
|
expectedSecret: d.webhookSecret,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve production deps (real queue + configured secret). */
|
||||||
|
async function realDeps(): Promise<ApiDeps> {
|
||||||
|
const { getQueue } = await import("@mcpedia/queue");
|
||||||
|
return { queue: getQueue(), webhookSecret: WEBHOOK_SECRET };
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = Number(process.env.API_PORT ?? 4020);
|
||||||
|
|
||||||
|
/** Fail-fast production entry: refuses to start with no webhook secret. */
|
||||||
|
export async function start(opts?: { port?: number }): Promise<void> {
|
||||||
|
if (!WEBHOOK_SECRET) {
|
||||||
|
throw new Error(
|
||||||
|
"WEBHOOK_SECRET is not set — /hooks/* would be open. Set it (see .env.example) before starting the API.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const app = await createApp();
|
||||||
|
serve({ fetch: app.fetch, port: opts?.port ?? port }, (info) => {
|
||||||
|
console.log(`MCPedia API listening on http://localhost:${info.port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Self-contained observability dashboard HTML (Phase 8).
|
||||||
|
// A single static HTML string with zero build-time dependencies. The page
|
||||||
|
// reads /metrics (same origin) and queries the MCP /mcp endpoint directly.
|
||||||
|
// All KB-sourced fields are esc() escaped for defense-in-depth (data is
|
||||||
|
// server-trusted, but we never pass unsanitized strings to innerHTML).
|
||||||
|
|
||||||
|
// XSS note: this dashboard consumes only same-origin server data
|
||||||
|
// (/metrics + MCP results). The esc() calls on slug/title/section/error are
|
||||||
|
// defense-in-depth; no user-supplied free text reaches innerHTML.
|
||||||
|
export const DASHBOARD_HTML = `<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>MCPedia — Dashboard</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0d1117;--panel:#161b22;--border:#30363d;--fg:#e6edf3;--muted:#8b949e;--accent:#58a6ff;--ok:#3fb950;--err:#f85149}
|
||||||
|
*{box-sizing:border-box}body{margin:0;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--bg);color:var(--fg)}
|
||||||
|
header{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px}
|
||||||
|
header h1{font-size:16px;margin:0;font-weight:600}header .dot{width:9px;height:9px;border-radius:50%;background:var(--ok)}
|
||||||
|
main{padding:20px;display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;align-items:start}
|
||||||
|
.card{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px}
|
||||||
|
.card h2{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:0 0 10px}
|
||||||
|
.metric{display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px dashed var(--border)}
|
||||||
|
.metric:last-child{border-bottom:0}.metric b{color:var(--accent)}
|
||||||
|
.search{grid-column:1/-1}.search input{width:100%;padding:10px;background:#0d1117;border:1px solid var(--border);border-radius:8px;color:var(--fg);font:inherit}
|
||||||
|
.result{margin-top:10px}.hit{padding:8px 0;border-bottom:1px solid var(--border)}
|
||||||
|
.hit a{color:var(--accent);text-decoration:none}.hit span{color:var(--muted)}
|
||||||
|
.err{color:var(--err)}.pill{display:inline-block;padding:1px 7px;border-radius:999px;background:#21262d;border:1px solid var(--border);color:var(--muted);font-size:11px}
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<header><span class="dot" id="live"></span><h1>MCPedia Dashboard</h1><span class="pill" id="uptime"></span></header>
|
||||||
|
<main>
|
||||||
|
<section class="card"><h2>Index Queue (BullMQ)</h2><div id="queue"></div></section>
|
||||||
|
<section class="card"><h2>Service</h2><div id="svc"></div></section>
|
||||||
|
<section class="card search"><h2>Search the knowledge base (via MCP)</h2>
|
||||||
|
<input id="q" placeholder="type a query, e.g. 'cloudflare 525 tls' and press Enter" autocomplete="off"/>
|
||||||
|
<div class="result" id="results"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const MCP="/mcp";
|
||||||
|
const esc=s=>String(s).replace(/[&<>\"]/g,c=>({"&":"&","<":"<",">":">","\"":"""}[c]));
|
||||||
|
function getMetric(t,name){const m=t.match(new RegExp(name+"\\\\s+([0-9.]+)"));return m?m[1]:'?';}
|
||||||
|
async function loadMetrics(){try{const t=await(await fetch("/metrics")).text();
|
||||||
|
document.getElementById("uptime").textContent="up "+getMetric(t,"mcpedia_uptime_seconds")+"s";
|
||||||
|
const states=["waiting","active","completed","failed","delayed"];
|
||||||
|
document.getElementById("queue").innerHTML=states.map(s=>
|
||||||
|
'<div class="metric"><span>'+s+'</span><b>'+getMetric(t,'mcpedia_queue_jobs{state="'+s+'"}')+'</b></div>').join("");
|
||||||
|
document.getElementById("svc").innerHTML='<div class="metric"><span>metrics</span><b>live</b></div><div class="metric"><span>mcp</span><b>'+MCP+'</b></div>';
|
||||||
|
document.getElementById("live").style.background="var(--ok)";
|
||||||
|
}catch(e){document.getElementById("live").style.background="var(--err)";document.getElementById("queue").innerHTML='<div class="err">metrics fetch failed: '+esc(e.message)+'</div>';}}
|
||||||
|
async function mcpCall(m,p){const r=await fetch(MCP,{method:"POST",headers:{"Content-Type":"application/json","Accept":"application/json, text/event-stream"},body:JSON.stringify({jsonrpc:"2.0",id:1,method:m,params:p})});
|
||||||
|
const raw=await r.text();const ev=raw.split("\\n").find(l=>l.startsWith("data: "));
|
||||||
|
if(!ev)throw new Error("no SSE data");return JSON.parse(ev.slice(6)).result;}
|
||||||
|
async function search(q){const el=document.getElementById("results");el.innerHTML='<span class="muted">searching…</span>';
|
||||||
|
try{await mcpCall("initialize",{protocolVersion:"2025-03-26",capabilities:{},clientInfo:{name:"dashboard",version:"1"}});
|
||||||
|
const r=await mcpCall("tools/call",{name:"hybrid_search",arguments:{query:q,limit:8}});
|
||||||
|
const hits=JSON.parse(r.content[0].text);
|
||||||
|
if(!hits.length){el.innerHTML='<span class="muted">no results</span>';return;}
|
||||||
|
el.innerHTML=hits.map(h=>'<div class="hit"><a href="/'+esc(h.doc.slug)+'" target="_blank">'+esc(h.doc.title||h.doc.slug)+'</a><span>'+esc(h.doc.section||'')+(h.rank!=null?' · rank '+h.rank.toFixed(3):'')+'</span></div>').join("");
|
||||||
|
}catch(e){el.innerHTML='<div class="err">search failed: '+esc(e.message)+'</div>';}}
|
||||||
|
document.getElementById("q").addEventListener("keydown",e=>{if(e.key==="Enter"&&e.target.value.trim())search(e.target.value.trim())});
|
||||||
|
loadMetrics();setInterval(loadMetrics,5000);
|
||||||
|
</script></body></html>`;
|
||||||
+15
-61
@@ -1,63 +1,17 @@
|
|||||||
import { serve } from "@hono/node-server";
|
// API entry point (invoked by `bun run src/index.ts` / systemd unit).
|
||||||
import { Hono } from "hono";
|
// Delegates to the testable factory in app.ts so the HTTP surface can be unit-
|
||||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
// tested without a live process. start() fail-fasts on missing WEBHOOK_SECRET.
|
||||||
import { db } from "@mcpedia/db";
|
import { createApp, start } from "./app";
|
||||||
import { appRouter } from "./router";
|
export { createApp, start };
|
||||||
import type { Context } from "./trpc";
|
export type { ApiDeps, QueueLike } from "./app";
|
||||||
import { enqueueIndexDoc, enqueueFullIndex } from "@mcpedia/queue";
|
|
||||||
import { WEBHOOK_SECRET } from "@mcpedia/config";
|
|
||||||
|
|
||||||
// Fail fast: never expose an open git-sync endpoint. If the operator hasn't
|
// When run directly (bun run src/index.ts), start the server.
|
||||||
// set WEBHOOK_SECRET, refuse to start rather than run an unauthenticated hook.
|
const isMain =
|
||||||
if (!WEBHOOK_SECRET) {
|
typeof process.argv[1] === "string" &&
|
||||||
throw new Error(
|
import.meta.url === `file://${process.argv[1]}`;
|
||||||
"WEBHOOK_SECRET is not set — /hooks/* would be open. Set it (see .env.example) before starting the API.",
|
if (isMain) {
|
||||||
);
|
start().catch((err: unknown) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = new Hono();
|
|
||||||
|
|
||||||
// Health check (no auth — safe to expose).
|
|
||||||
app.get("/health", (c) => c.json({ ok: true }));
|
|
||||||
|
|
||||||
// Shared guard for the git-sync webhooks: require `x-webhook-secret` header to
|
|
||||||
// match the configured secret. Reject anything else with 401.
|
|
||||||
function assertWebhookAuth(c: { req: { header: (k: string) => string | undefined } }): boolean {
|
|
||||||
const provided = c.req.header("x-webhook-secret");
|
|
||||||
return provided != null && provided === WEBHOOK_SECRET;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Phase 3: Git synchronization hook ---
|
|
||||||
// POST /hooks/reindex -> enqueue a full-corpus reindex (git push webhook)
|
|
||||||
// POST /hooks/index?slug=... -> enqueue a single document reindex
|
|
||||||
// Returns the created job id(s). The worker processes them asynchronously.
|
|
||||||
app.post("/hooks/reindex", async (c) => {
|
|
||||||
if (!assertWebhookAuth(c)) return c.json({ ok: false, error: "unauthorized" }, 401);
|
|
||||||
const job = await enqueueFullIndex("git-push");
|
|
||||||
return c.json({ ok: true, jobId: job.id, kind: "full" });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post("/hooks/index", async (c) => {
|
|
||||||
if (!assertWebhookAuth(c)) return c.json({ ok: false, error: "unauthorized" }, 401);
|
|
||||||
const slug = c.req.query("slug");
|
|
||||||
if (!slug) return c.json({ ok: false, error: "slug query param required" }, 400);
|
|
||||||
// slug is the relative path without extension, e.g. docs/websocket/contract
|
|
||||||
const relPath = slug.endsWith(".md") || slug.endsWith(".mdx") ? slug : `${slug}.md`;
|
|
||||||
const job = await enqueueIndexDoc(relPath, "git-push");
|
|
||||||
return c.json({ ok: true, jobId: job.id, kind: "doc", relPath });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mount tRPC at /trpc/*. The fetch adapter is the canonical Bun/Hono adapter.
|
|
||||||
app.all("/trpc/*", (c) =>
|
|
||||||
fetchRequestHandler({
|
|
||||||
endpoint: "/trpc",
|
|
||||||
req: c.req.raw,
|
|
||||||
router: appRouter,
|
|
||||||
createContext: (): Context => ({ db }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const port = Number(process.env.API_PORT ?? 4020);
|
|
||||||
serve({ fetch: app.fetch, port }, (info) => {
|
|
||||||
console.log(`MCPedia API listening on http://localhost:${info.port}`);
|
|
||||||
});
|
|
||||||
+66
-3
@@ -1,19 +1,36 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { publicProcedure, router } from "./trpc";
|
import { publicProcedure, router, t } from "./trpc";
|
||||||
import {
|
import {
|
||||||
getDocument,
|
getDocument,
|
||||||
getRelated,
|
getRelated,
|
||||||
hybridSearch,
|
hybridSearch,
|
||||||
keywordSearch,
|
keywordSearch,
|
||||||
listDocuments,
|
listDocuments,
|
||||||
|
listSections,
|
||||||
semanticSearch,
|
semanticSearch,
|
||||||
listRevisions,
|
listRevisions,
|
||||||
getRevision,
|
getRevision,
|
||||||
restoreRevision,
|
restoreRevision,
|
||||||
|
createDocument,
|
||||||
|
updateDocument,
|
||||||
|
deleteDocument,
|
||||||
} from "@mcpedia/core";
|
} from "@mcpedia/core";
|
||||||
import { getQueue, INDEX_QUEUE } from "@mcpedia/queue";
|
import { getQueue, INDEX_QUEUE } from "@mcpedia/queue";
|
||||||
import { getConnection, BULLMQ_PREFIX } from "@mcpedia/queue/client";
|
import { getConnection, BULLMQ_PREFIX } from "@mcpedia/queue/client";
|
||||||
|
|
||||||
|
// restoreRevision and CRUD operations are state-changing actions.
|
||||||
|
// They must NOT be callable anonymously over the network —
|
||||||
|
// only an operator with the webhook secret (or authorized UI) may execute them.
|
||||||
|
const requireWriteAuth = t.middleware(({ ctx, next }) => {
|
||||||
|
if (!ctx.expectedSecret) {
|
||||||
|
throw new Error("WEBHOOK_SECRET is not configured; writes are disabled");
|
||||||
|
}
|
||||||
|
if (ctx.webhookSecret !== ctx.expectedSecret) {
|
||||||
|
throw new Error("unauthorized: missing or invalid x-webhook-secret");
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
search: publicProcedure
|
search: publicProcedure
|
||||||
.input(z.object({ q: z.string(), limit: z.number().int().min(1).max(50).default(20) }))
|
.input(z.object({ q: z.string(), limit: z.number().int().min(1).max(50).default(20) }))
|
||||||
@@ -35,11 +52,13 @@ export const appRouter = router({
|
|||||||
.input(z.object({ section: z.string().optional(), status: z.string().optional() }).optional())
|
.input(z.object({ section: z.string().optional(), status: z.string().optional() }).optional())
|
||||||
.query(async ({ input }) => listDocuments(input ?? {})),
|
.query(async ({ input }) => listDocuments(input ?? {})),
|
||||||
|
|
||||||
|
sections: publicProcedure.query(async () => listSections()),
|
||||||
|
|
||||||
related: publicProcedure
|
related: publicProcedure
|
||||||
.input(z.object({ slug: z.string(), limit: z.number().int().min(1).max(20).default(5) }))
|
.input(z.object({ slug: z.string(), limit: z.number().int().min(1).max(20).default(5) }))
|
||||||
.query(async ({ input }) => getRelated(input.slug, input.limit)),
|
.query(async ({ input }) => getRelated(input.slug, input.limit)),
|
||||||
|
|
||||||
// --- Phase 3: revisions ---
|
// --- Revisions ---
|
||||||
revisions: publicProcedure
|
revisions: publicProcedure
|
||||||
.input(z.object({ slug: z.string(), limit: z.number().int().min(1).max(50).default(20) }))
|
.input(z.object({ slug: z.string(), limit: z.number().int().min(1).max(50).default(20) }))
|
||||||
.query(async ({ input }) => listRevisions(input.slug, input.limit)),
|
.query(async ({ input }) => listRevisions(input.slug, input.limit)),
|
||||||
@@ -49,10 +68,54 @@ export const appRouter = router({
|
|||||||
.query(async ({ input }) => getRevision(input.id)),
|
.query(async ({ input }) => getRevision(input.id)),
|
||||||
|
|
||||||
restoreRevision: publicProcedure
|
restoreRevision: publicProcedure
|
||||||
|
.use(requireWriteAuth)
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.mutation(async ({ input }) => restoreRevision(input.id)),
|
.mutation(async ({ input }) => restoreRevision(input.id)),
|
||||||
|
|
||||||
// --- Phase 3: async job status ---
|
// --- CRUD (gated by x-webhook-secret / admin auth) ---
|
||||||
|
createDocument: publicProcedure
|
||||||
|
.use(requireWriteAuth)
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
slug: z.string().min(1),
|
||||||
|
title: z.string().min(1),
|
||||||
|
section: z.string().min(1),
|
||||||
|
body: z.string(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
status: z.enum(["published", "draft"]).optional(),
|
||||||
|
author: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
extraFields: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => createDocument(input)),
|
||||||
|
|
||||||
|
updateDocument: publicProcedure
|
||||||
|
.use(requireWriteAuth)
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
slug: z.string().min(1),
|
||||||
|
title: z.string().min(1).optional(),
|
||||||
|
body: z.string().optional(),
|
||||||
|
section: z.string().min(1).optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
status: z.enum(["published", "draft"]).optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
author: z.string().optional(),
|
||||||
|
extraFields: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const { slug, ...rest } = input;
|
||||||
|
return updateDocument(slug, rest);
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteDocument: publicProcedure
|
||||||
|
.use(requireWriteAuth)
|
||||||
|
.input(z.object({ slug: z.string().min(1) }))
|
||||||
|
.mutation(async ({ input }) => deleteDocument(input.slug)),
|
||||||
|
|
||||||
|
// --- Async job status ---
|
||||||
jobStatus: publicProcedure
|
jobStatus: publicProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { db } from "@mcpedia/db";
|
|||||||
|
|
||||||
export interface Context {
|
export interface Context {
|
||||||
db: typeof db;
|
db: typeof db;
|
||||||
|
// Raw `x-webhook-secret` header from the incoming request, if present.
|
||||||
|
// State-changing tRPC mutations (restoreRevision, CRUD) require it to match
|
||||||
|
// the configured secret; read-only procedures ignore it.
|
||||||
|
webhookSecret?: string;
|
||||||
|
// The configured expected secret (from deps). Used by requireWriteAuth
|
||||||
|
// to validate the header — request-scoped so tests can inject a fake.
|
||||||
|
expectedSecret: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const t = initTRPC.context<Context>().create();
|
export const t = initTRPC.context<Context>().create();
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@mcpedia/mcp-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"mcpedia-mcp-client": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun run src/index.ts",
|
||||||
|
"chat": "bun run src/index.ts",
|
||||||
|
"ask": "bun run src/ask.ts",
|
||||||
|
"lint": "tsc --noEmit",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"build": "bun build src/index.ts --outdir=dist --format=esm",
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^26",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { McpediaClient } from "./client";
|
||||||
|
|
||||||
|
// One-shot MCP client — runs a single tool call and prints the result.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// bun run ask search "websocket"
|
||||||
|
// bun run ask doc docs/websocket/contract
|
||||||
|
// bun run ask tools
|
||||||
|
// bun run ask resources
|
||||||
|
// bun run ask hybrid "what is MCP"
|
||||||
|
// bun run ask create --slug=docs/my-doc --title="My Doc" --body="Hello"
|
||||||
|
// (note: create requires WEBHOOK_SECRET env)
|
||||||
|
// bun run ask delete docs/my-doc
|
||||||
|
|
||||||
|
const MCP_URL = process.env.MCP_URL ?? "https://mcp.asepharyana.my.id/mcp";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [, , command, ...args] = process.argv;
|
||||||
|
if (!command) {
|
||||||
|
console.error("Usage: ask <command> [args...]");
|
||||||
|
console.error("Commands: tools, resources, search <q>, ss <q>, hybrid <q>, doc <slug>, related <slug>, create, delete <slug>, index <slug>, status");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const secret = process.env.WEBHOOK_SECRET;
|
||||||
|
if (secret) headers["x-webhook-secret"] = secret;
|
||||||
|
|
||||||
|
const client = await McpediaClient.connect(MCP_URL, { headers });
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (command) {
|
||||||
|
case "tools": {
|
||||||
|
const tools = await client.listTools();
|
||||||
|
console.log(JSON.stringify(tools.map((t: any) => ({ name: t.name, description: t.description })), null, 2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "resources": {
|
||||||
|
const res = await client.listResources();
|
||||||
|
console.log(JSON.stringify(res, null, 2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "search": {
|
||||||
|
const q = args.join(" ");
|
||||||
|
if (!q) throw new Error("search requires a query");
|
||||||
|
const hits = await client.search(q);
|
||||||
|
console.log(JSON.stringify(hits, null, 2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ss": {
|
||||||
|
const q = args.join(" ");
|
||||||
|
if (!q) throw new Error("ss requires a query");
|
||||||
|
const hits = await client.semanticSearch(q);
|
||||||
|
console.log(JSON.stringify(hits, null, 2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "hybrid": {
|
||||||
|
const q = args.join(" ");
|
||||||
|
if (!q) throw new Error("hybrid requires a query");
|
||||||
|
const hits = await client.hybridSearch(q);
|
||||||
|
console.log(JSON.stringify(hits, null, 2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "doc": {
|
||||||
|
const slug = args[0];
|
||||||
|
if (!slug) throw new Error("doc requires a slug");
|
||||||
|
const body = await client.getDocument(slug);
|
||||||
|
console.log(body);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "related": {
|
||||||
|
const slug = args[0];
|
||||||
|
if (!slug) throw new Error("related requires a slug");
|
||||||
|
const rel = await client.getRelated(slug);
|
||||||
|
console.log(JSON.stringify(rel, null, 2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "create": {
|
||||||
|
const opts: Record<string, string> = {};
|
||||||
|
for (const arg of args) {
|
||||||
|
const [k, v] = arg.split("=") as [string, string];
|
||||||
|
if (k && v) opts[k.replace("--", "")] = v;
|
||||||
|
}
|
||||||
|
const res = await client.createDocument({
|
||||||
|
slug: opts.slug || "",
|
||||||
|
title: opts.title || "Untitled",
|
||||||
|
section: (opts.section || "docs") as any,
|
||||||
|
body: opts.body || "",
|
||||||
|
tags: opts.tags ? opts.tags.split(",") : [],
|
||||||
|
author: opts.author,
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(res, null, 2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "delete": {
|
||||||
|
const slug = args[0];
|
||||||
|
if (!slug) throw new Error("delete requires a slug");
|
||||||
|
const res = await client.deleteDocument(slug);
|
||||||
|
console.log(JSON.stringify(res, null, 2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "index": {
|
||||||
|
const slug = args[0];
|
||||||
|
if (!slug) throw new Error("index requires a slug");
|
||||||
|
const res = await client.indexDocument(slug);
|
||||||
|
console.log(JSON.stringify(res, null, 2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "status": {
|
||||||
|
const res = await client.queueStatus();
|
||||||
|
console.log(JSON.stringify(res, null, 2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
console.error(`Unknown command: ${command}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg.includes("unauthorized")) {
|
||||||
|
console.error("⚠️ Write tools require x-webhook-secret. Set WEBHOOK_SECRET env var.");
|
||||||
|
} else {
|
||||||
|
console.error(`Error: ${msg}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect, mock } from "bun:test";
|
||||||
|
|
||||||
|
// Tests for the MCP client wrapper. Uses a fake Client that implements the
|
||||||
|
// subset of methods McpediaClient uses, so no network/DB is needed.
|
||||||
|
|
||||||
|
// Mock the SDK client to avoid real network connections.
|
||||||
|
const mockTools = [
|
||||||
|
{ name: "search_documents", description: "keyword search" },
|
||||||
|
{ name: "get_document", description: "get doc body" },
|
||||||
|
{ name: "create_document", description: "create doc" },
|
||||||
|
{ name: "delete_document", description: "delete doc" },
|
||||||
|
];
|
||||||
|
|
||||||
|
mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||||
|
Client: class {
|
||||||
|
connect() {}
|
||||||
|
close() {}
|
||||||
|
listTools() {
|
||||||
|
return Promise.resolve({ tools: mockTools });
|
||||||
|
}
|
||||||
|
callTool(opts: any) {
|
||||||
|
const { name, arguments: args } = opts;
|
||||||
|
if (name === "get_document") {
|
||||||
|
return Promise.resolve({ content: [{ type: "text", text: `# ${args.slug}` }] });
|
||||||
|
}
|
||||||
|
if (name === "create_document") {
|
||||||
|
return Promise.resolve({ content: [{ type: "text", text: JSON.stringify({ ok: true, slug: args.slug }) }] });
|
||||||
|
}
|
||||||
|
if (name === "delete_document") {
|
||||||
|
return Promise.resolve({ content: [{ type: "text", text: JSON.stringify({ ok: true, deleted: true }) }] });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ content: [{ type: "text", text: "[]" }] });
|
||||||
|
}
|
||||||
|
listResources() {
|
||||||
|
return Promise.resolve({ resources: [{ uri: "mcpedia://docs", mimeType: "application/json" }] });
|
||||||
|
}
|
||||||
|
readResource({ uri }: any) {
|
||||||
|
return Promise.resolve({ contents: [{ uri, mimeType: "text/markdown", text: "# Test" }] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||||
|
StreamableHTTPClientTransport: class {
|
||||||
|
constructor(url: URL, opts?: any) {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { McpediaClient } = await import("../src/client");
|
||||||
|
|
||||||
|
describe("McpediaClient", () => {
|
||||||
|
it("listTools returns all registered tools", async () => {
|
||||||
|
const client = await McpediaClient.connect("http://localhost:0/mcp");
|
||||||
|
const tools = await client.listTools();
|
||||||
|
expect(tools.length).toBe(4);
|
||||||
|
expect(tools.map((t: any) => t.name)).toContain("search_documents");
|
||||||
|
expect(tools.map((t: any) => t.name)).toContain("get_document");
|
||||||
|
await client.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getDocument returns markdown body", async () => {
|
||||||
|
const client = await McpediaClient.connect("http://localhost:0/mcp");
|
||||||
|
const body = await client.getDocument("docs/websocket/contract");
|
||||||
|
expect(body).toBe("# docs/websocket/contract");
|
||||||
|
await client.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createDocument posts args and parses JSON response", async () => {
|
||||||
|
const client = await McpediaClient.connect("http://localhost:0/mcp");
|
||||||
|
const res = await client.createDocument({
|
||||||
|
slug: "docs/test",
|
||||||
|
title: "Test",
|
||||||
|
section: "docs",
|
||||||
|
body: "# Hello",
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.slug).toBe("docs/test");
|
||||||
|
await client.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteDocument returns deleted flag", async () => {
|
||||||
|
const client = await McpediaClient.connect("http://localhost:0/mcp");
|
||||||
|
const res = await client.deleteDocument("docs/test");
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.deleted).toBe(true);
|
||||||
|
await client.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("listResources returns resource list", async () => {
|
||||||
|
const client = await McpediaClient.connect("http://localhost:0/mcp");
|
||||||
|
const resources = await client.listResources();
|
||||||
|
expect(resources.length).toBe(1);
|
||||||
|
expect(resources[0].uri).toBe("mcpedia://docs");
|
||||||
|
await client.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readResource returns text content", async () => {
|
||||||
|
const client = await McpediaClient.connect("http://localhost:0/mcp");
|
||||||
|
const res = await client.readResource("mcpedia://docs/docs/websocket/contract");
|
||||||
|
expect(res.text).toBe("# Test");
|
||||||
|
expect(res.mimeType).toBe("text/markdown");
|
||||||
|
await client.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("send headers via requestInit for auth", async () => {
|
||||||
|
// Verify that options.headers are passed through to the transport constructor
|
||||||
|
const client = await McpediaClient.connect("http://localhost:0/mcp", {
|
||||||
|
headers: { "x-webhook-secret": "secret123" },
|
||||||
|
});
|
||||||
|
// We can't directly assert the transport constructor received headers,
|
||||||
|
// but we verify the client connects and works with headers set.
|
||||||
|
const tools = await client.listTools();
|
||||||
|
expect(tools.length).toBeGreaterThan(0);
|
||||||
|
await client.disconnect();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||||
|
import type { CallToolResult, ListResourcesResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
|
||||||
|
// MCP client that talks to the MCPedia MCP server over Streamable HTTP.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// import { McpediaClient } from "@/src/client";
|
||||||
|
// const client = await McpediaClient.connect(
|
||||||
|
// "https://mcp.asepharyana.my.id/mcp",
|
||||||
|
// { headers: { "x-webhook-secret": process.env.WEBHOOK_SECRET } }
|
||||||
|
// );
|
||||||
|
//
|
||||||
|
// const docs = await client.listDocuments();
|
||||||
|
// const results = await client.search("websocket");
|
||||||
|
// const doc = await client.getDocument("docs/websocket/contract");
|
||||||
|
|
||||||
|
export interface McpediaClientOptions {
|
||||||
|
/** Custom headers to send with each request (e.g. x-webhook-secret for write tools). */
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type-cast helper for the SDK's content union (avoids TS noise from zod-v4 typing).
|
||||||
|
type AnyContent = { text?: string; uri?: string; mimeType?: string; blob?: string };
|
||||||
|
function extractText(result: CallToolResult): string {
|
||||||
|
return ((result.content?.[0] as AnyContent) ?? {}).text ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class McpediaClient {
|
||||||
|
private client: Client;
|
||||||
|
private options: McpediaClientOptions;
|
||||||
|
|
||||||
|
private constructor(client: Client, options: McpediaClientOptions) {
|
||||||
|
this.client = client;
|
||||||
|
this.options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connect to an MCP server over Streamable HTTP. */
|
||||||
|
static async connect(
|
||||||
|
url: string,
|
||||||
|
options: McpediaClientOptions = {},
|
||||||
|
): Promise<McpediaClient> {
|
||||||
|
const transport = new StreamableHTTPClientTransport(new URL(url), {
|
||||||
|
requestInit: options.headers ? { headers: options.headers } : undefined,
|
||||||
|
});
|
||||||
|
const client = new Client(
|
||||||
|
{ name: "mcpedia-client", version: "0.1.0" },
|
||||||
|
{ capabilities: {} },
|
||||||
|
);
|
||||||
|
await client.connect(transport);
|
||||||
|
return new McpediaClient(client, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tool wrappers ---
|
||||||
|
|
||||||
|
async listTools(): Promise<any[]> {
|
||||||
|
const res = await this.client.listTools();
|
||||||
|
return res.tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTool(name: string, args: Record<string, unknown> = {}): Promise<CallToolResult> {
|
||||||
|
return await this.client.callTool({ name, arguments: args }) as CallToolResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience methods for MCPedia-specific tools:
|
||||||
|
|
||||||
|
/** List all documents, optionally filtered by section. */
|
||||||
|
async listDocuments(section?: string): Promise<any[]> {
|
||||||
|
const args: Record<string, unknown> = {};
|
||||||
|
if (section) args.section = section;
|
||||||
|
const result = await this.callTool("list_documents", args);
|
||||||
|
return JSON.parse(extractText(result) || "[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get full markdown body of a document by slug. */
|
||||||
|
async getDocument(slug: string): Promise<string> {
|
||||||
|
const result = await this.callTool("get_document", { slug });
|
||||||
|
return extractText(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keyword (full-text) search. */
|
||||||
|
async search(query: string, limit = 20): Promise<any[]> {
|
||||||
|
const result = await this.callTool("search_documents", { query, limit });
|
||||||
|
return JSON.parse(extractText(result) || "[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Semantic search (embedding-based). */
|
||||||
|
async semanticSearch(query: string, limit = 10): Promise<any[]> {
|
||||||
|
const result = await this.callTool("semantic_search", { query, limit });
|
||||||
|
return JSON.parse(extractText(result) || "[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hybrid search (FTS + semantic via RRF). */
|
||||||
|
async hybridSearch(query: string, limit = 10): Promise<any[]> {
|
||||||
|
const result = await this.callTool("hybrid_search", { query, limit });
|
||||||
|
return JSON.parse(extractText(result) || "[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get related documents by shared tags. */
|
||||||
|
async getRelated(slug: string, limit = 5): Promise<any[]> {
|
||||||
|
const result = await this.callTool("get_related_documents", { slug, limit });
|
||||||
|
return JSON.parse(extractText(result) || "[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index (re-index) a single document. */
|
||||||
|
async indexDocument(slug: string): Promise<any> {
|
||||||
|
const result = await this.callTool("index_document", { slug });
|
||||||
|
return JSON.parse(extractText(result) || "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full-corpus reindex. */
|
||||||
|
async reindexAll(): Promise<any> {
|
||||||
|
const result = await this.callTool("reindex_all", {});
|
||||||
|
return JSON.parse(extractText(result) || "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current queue status. */
|
||||||
|
async queueStatus(): Promise<any> {
|
||||||
|
const result = await this.callTool("queue_status", {});
|
||||||
|
return JSON.parse(extractText(result) || "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a document (requires x-webhook-secret). */
|
||||||
|
async createDocument(input: {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
section: "docs" | "writeups" | "research" | "notes";
|
||||||
|
body: string;
|
||||||
|
type?: "documentation" | "writeup" | "research" | "note";
|
||||||
|
status?: "published" | "draft";
|
||||||
|
author?: string;
|
||||||
|
tags?: string[];
|
||||||
|
extraFields?: Record<string, unknown>;
|
||||||
|
}): Promise<any> {
|
||||||
|
const result = await this.callTool("create_document", input);
|
||||||
|
return JSON.parse(extractText(result) || "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a document (requires x-webhook-secret). */
|
||||||
|
async updateDocument(slug: string, updates: Record<string, unknown>): Promise<any> {
|
||||||
|
const result = await this.callTool("update_document", { slug, ...updates });
|
||||||
|
return JSON.parse(extractText(result) || "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a document (requires x-webhook-secret). */
|
||||||
|
async deleteDocument(slug: string): Promise<any> {
|
||||||
|
const result = await this.callTool("delete_document", { slug });
|
||||||
|
return JSON.parse(extractText(result) || "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Resource wrappers ---
|
||||||
|
|
||||||
|
async listResources(): Promise<any[]> {
|
||||||
|
const res = await this.client.listResources() as ListResourcesResult;
|
||||||
|
return res.resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a resource by URI (e.g. mcpedia://docs). */
|
||||||
|
async readResource(uri: string): Promise<{ uri: string; mimeType: string; text: string }> {
|
||||||
|
const res = await this.client.readResource({ uri }) as ReadResourceResult;
|
||||||
|
const item = res.contents[0] as AnyContent;
|
||||||
|
return { uri: item.uri ?? uri, mimeType: item.mimeType ?? "text/plain", text: item.text ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Graceful disconnect. */
|
||||||
|
async disconnect() {
|
||||||
|
await this.client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { McpediaClient } from "./client";
|
||||||
|
|
||||||
|
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? "";
|
||||||
|
|
||||||
|
// Interactive MCP client — connects to the MCPedia MCP server over
|
||||||
|
// Streamable HTTP and provides a chat-like REPL.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// MCP_URL=https://mcp.asepharyana.my.id/mcp bun run chat
|
||||||
|
// bun run chat (defaults to https://mcp.asepharyana.my.id/mcp)
|
||||||
|
//
|
||||||
|
// Commands in the REPL:
|
||||||
|
// /tools — list available tools
|
||||||
|
// /resources — list available resources
|
||||||
|
// /search <q> — keyword search
|
||||||
|
// /ss <q> — semantic search
|
||||||
|
// /hybrid <q> — hybrid search
|
||||||
|
// /doc <slug> — get document body
|
||||||
|
// /related <slug> — related documents
|
||||||
|
// /create — create a document (prompts for fields)
|
||||||
|
// /delete <slug> — delete a document
|
||||||
|
// /index <slug> — enqueue reindex for a doc
|
||||||
|
// /status — queue status
|
||||||
|
// /help — show help
|
||||||
|
// /quit — exit
|
||||||
|
|
||||||
|
const HELP = `Commands:
|
||||||
|
/tools — list available MCP tools
|
||||||
|
/resources — list available resources
|
||||||
|
/search <query> — keyword (full-text) search
|
||||||
|
/ss <query> — semantic (embedding) search
|
||||||
|
/hybrid <query> — hybrid search (FTS + semantic)
|
||||||
|
/doc <slug> — get document body (e.g. docs/websocket/contract)
|
||||||
|
/related <slug> — get related documents
|
||||||
|
/create — create a document (interactive prompts)
|
||||||
|
/update <slug> — update a document (interactive prompts)
|
||||||
|
/delete <slug> — delete a document
|
||||||
|
/index <slug> — enqueue reindex for a document
|
||||||
|
/status — queue status
|
||||||
|
/help — show this help
|
||||||
|
/quit — exit`;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = process.env.MCP_URL ?? "https://mcp.asepharyana.my.id/mcp";
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (WEBHOOK_SECRET) {
|
||||||
|
headers["x-webhook-secret"] = WEBHOOK_SECRET;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Connecting to MCP server: ${url}`);
|
||||||
|
const client = await McpediaClient.connect(url, { headers });
|
||||||
|
console.log("Connected! Type /help for commands.\n");
|
||||||
|
|
||||||
|
// Show available tools on startup
|
||||||
|
const tools = await client.listTools();
|
||||||
|
console.log(`Available tools (${tools.length}): ${tools.map((t: any) => t.name).join(", ")}`);
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
const rl = await import("node:readline");
|
||||||
|
const readline = rl.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
prompt: "mcp> ",
|
||||||
|
});
|
||||||
|
|
||||||
|
readline.prompt();
|
||||||
|
|
||||||
|
for await (const line of readline) {
|
||||||
|
const cmd = line.trim();
|
||||||
|
if (!cmd) {
|
||||||
|
readline.prompt();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [name, ...args] = cmd.split(/\s+/);
|
||||||
|
const arg = args.join(" ");
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (name) {
|
||||||
|
case "/tools": {
|
||||||
|
const t = await client.listTools();
|
||||||
|
console.log(t.map((x: any) => ` ${x.name}: ${x.description || ""}`).join("\n"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "/resources": {
|
||||||
|
const r = await client.listResources();
|
||||||
|
console.log(r.map((x: any) => ` ${x.uri}`).join("\n"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "/search": {
|
||||||
|
if (!arg) throw new Error("Usage: /search <query>");
|
||||||
|
const hits = await client.search(arg);
|
||||||
|
printHits(hits);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "/ss": {
|
||||||
|
if (!arg) throw new Error("Usage: /ss <query>");
|
||||||
|
const hits = await client.semanticSearch(arg);
|
||||||
|
printHits(hits);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "/hybrid": {
|
||||||
|
if (!arg) throw new Error("Usage: /hybrid <query>");
|
||||||
|
const hits = await client.hybridSearch(arg);
|
||||||
|
printHits(hits);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "/doc": {
|
||||||
|
if (!arg) throw new Error("Usage: /doc <slug>");
|
||||||
|
const text = await client.getDocument(arg);
|
||||||
|
console.log(text);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "/related": {
|
||||||
|
if (!arg) throw new Error("Usage: /related <slug>");
|
||||||
|
const rel = await client.getRelated(arg);
|
||||||
|
console.log(rel.map((x: any) => ` ${x.title} (${x.slug})`).join("\n"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "/create": {
|
||||||
|
await interactiveCreate(client);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "/update": {
|
||||||
|
if (!arg) throw new Error("Usage: /update <slug>");
|
||||||
|
await interactiveUpdate(client, arg);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "/delete": {
|
||||||
|
if (!arg) throw new Error("Usage: /delete <slug>");
|
||||||
|
const res = await client.deleteDocument(arg);
|
||||||
|
console.log(JSON.stringify(res, null, 2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "/index": {
|
||||||
|
if (!arg) throw new Error("Usage: /index <slug>");
|
||||||
|
const res = await client.indexDocument(arg);
|
||||||
|
console.log(JSON.stringify(res, null, 2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "/status": {
|
||||||
|
const res = await client.queueStatus();
|
||||||
|
console.log(JSON.stringify(res, null, 2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "/help":
|
||||||
|
console.log(HELP);
|
||||||
|
break;
|
||||||
|
case "/quit":
|
||||||
|
await client.disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
default:
|
||||||
|
console.log(`Unknown command: ${name}. Type /help for commands.`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg.includes("unauthorized")) {
|
||||||
|
console.log("⚠️ Write tools require x-webhook-secret. Set WEBHOOK_SECRET in your .env.");
|
||||||
|
} else {
|
||||||
|
console.log(`Error: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readline.prompt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHits(hits: any[]) {
|
||||||
|
if (!hits.length) {
|
||||||
|
console.log("No results.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const h of hits) {
|
||||||
|
const slug = h.doc?.slug || h.slug || "?";
|
||||||
|
const title = h.doc?.title || h.title || slug;
|
||||||
|
const score = h.rank !== undefined ? `★ ${h.rank.toFixed(3)}` : h.score ? `★ ${h.score.toFixed(3)}` : "";
|
||||||
|
const section = h.doc?.section || h.section || "";
|
||||||
|
console.log(` ${title} ${score}`);
|
||||||
|
console.log(` /${slug} · ${section}`);
|
||||||
|
if (h.snippet || h.doc?.excerpt) {
|
||||||
|
const snip = (h.snippet || h.doc?.excerpt).slice(0, 120);
|
||||||
|
console.log(` ${snip}...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ask(question: string): Promise<string> {
|
||||||
|
const rl = await import("node:readline");
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const iface = rl.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
iface.question(question, (ans) => {
|
||||||
|
iface.close();
|
||||||
|
resolve(ans.trim());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function interactiveCreate(client: McpediaClient) {
|
||||||
|
console.log("\n--- Create Document ---");
|
||||||
|
const section = (await ask("Section (docs/writeups/research/notes) [docs]: ")) || "docs";
|
||||||
|
const title = await ask("Title: ");
|
||||||
|
if (!title) throw new Error("Title is required.");
|
||||||
|
const slug = (await ask(`Slug (will become ${section}/<slug>): `)) || title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||||
|
const body = await ask("Body (single line, or /edit for editor): ") || "";
|
||||||
|
const tagsStr = await ask("Tags (comma-separated): ");
|
||||||
|
const tags = tagsStr.split(",").map((t) => t.trim()).filter(Boolean);
|
||||||
|
const author = await ask("Author: ");
|
||||||
|
|
||||||
|
const res = await client.createDocument({
|
||||||
|
slug: `${section}/${slug}`,
|
||||||
|
title,
|
||||||
|
section: section as "docs" | "writeups" | "research" | "notes",
|
||||||
|
body,
|
||||||
|
tags,
|
||||||
|
author: author || undefined,
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(res, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function interactiveUpdate(client: McpediaClient, slug: string) {
|
||||||
|
console.log(`\n--- Update: ${slug} ---`);
|
||||||
|
const doc = await client.getDocument(slug);
|
||||||
|
const body = await ask(`Body (current: ${doc.slice(0, 50)}...): `) || doc;
|
||||||
|
const title = await ask("New title (or leave blank to keep): ") || undefined;
|
||||||
|
const tagsStr = await ask("New tags (comma-separated, or blank to keep): ") || "";
|
||||||
|
const tags = tagsStr ? tagsStr.split(",").map((t) => t.trim()).filter(Boolean) : undefined;
|
||||||
|
|
||||||
|
const res = await client.updateDocument(slug, {
|
||||||
|
...(title && { title }),
|
||||||
|
body,
|
||||||
|
...(tags && { tags }),
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(res, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["bun", "node"],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -8,19 +8,22 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun run src/index.ts",
|
"start": "bun run src/index.ts",
|
||||||
|
"serve:http": "bun run src/http.ts",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"smoke": "bun run src/smoke.test.ts"
|
"smoke": "bun run src/smoke.ts",
|
||||||
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mcpedia/config": "workspace:*",
|
"@mcpedia/config": "workspace:*",
|
||||||
"@mcpedia/core": "workspace:*",
|
"@mcpedia/core": "workspace:*",
|
||||||
|
"@mcpedia/queue": "workspace:*",
|
||||||
"@mcpedia/search": "workspace:*",
|
"@mcpedia/search": "workspace:*",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^26",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { test, expect, mock } from "bun:test";
|
||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Mock the heavy dependencies so the MCP server is fully testable without
|
||||||
|
// Postgres / Redis / embeddings. We record calls to the mutating functions
|
||||||
|
// so we can assert the auth gate is (or isn't) the reason a tool errors.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
const calls = {
|
||||||
|
enqueueIndexDoc: [] as Array<[string, string]>,
|
||||||
|
enqueueFullIndex: [] as Array<[string]>,
|
||||||
|
restoreRevision: [] as Array<[string]>,
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module("@mcpedia/queue", () => ({
|
||||||
|
enqueueIndexDoc: (relPath: string, reason: string) => {
|
||||||
|
calls.enqueueIndexDoc.push([relPath, reason]);
|
||||||
|
return Promise.resolve({ id: `doc__${relPath}` });
|
||||||
|
},
|
||||||
|
enqueueFullIndex: (reason: string) => {
|
||||||
|
calls.enqueueFullIndex.push([reason]);
|
||||||
|
return Promise.resolve({ id: `full__${Date.now()}` });
|
||||||
|
},
|
||||||
|
getQueue: () => ({
|
||||||
|
getWaitingCount: () => Promise.resolve(0),
|
||||||
|
getActiveCount: () => Promise.resolve(0),
|
||||||
|
getCompletedCount: () => Promise.resolve(0),
|
||||||
|
getFailedCount: () => Promise.resolve(0),
|
||||||
|
getDelayedCount: () => Promise.resolve(0),
|
||||||
|
}),
|
||||||
|
INDEX_QUEUE: "mcpedia-index",
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("@mcpedia/core", () => ({
|
||||||
|
keywordSearch: () => Promise.resolve([]),
|
||||||
|
getDocument: () => Promise.resolve(null),
|
||||||
|
listDocuments: () => Promise.resolve([]),
|
||||||
|
listSections: () => Promise.resolve([]),
|
||||||
|
getRelated: () => Promise.resolve([]),
|
||||||
|
semanticSearch: () => Promise.resolve([]),
|
||||||
|
hybridSearch: () => Promise.resolve([]),
|
||||||
|
listRevisions: () => Promise.resolve([]),
|
||||||
|
getRevision: () => Promise.resolve(null),
|
||||||
|
restoreRevision: (id: string) => {
|
||||||
|
calls.restoreRevision.push([id]);
|
||||||
|
return Promise.resolve({ slug: "docs/test", documentId: "d1" });
|
||||||
|
},
|
||||||
|
readContentFile: () => "",
|
||||||
|
createDocument: (_input: unknown) => Promise.resolve({ slug: "docs/test", title: "Test" }),
|
||||||
|
updateDocument: (_slug: string, _input: unknown) => Promise.resolve({ slug: "docs/test", title: "Test" }),
|
||||||
|
deleteDocument: (_slug: string) => Promise.resolve({ deleted: true }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import AFTER mocking so the modules resolve to our fakes.
|
||||||
|
const { createMcpServer } = await import("../src/index");
|
||||||
|
|
||||||
|
async function connect(secret?: string) {
|
||||||
|
const server = createMcpServer(secret);
|
||||||
|
const [clientT, serverT] = InMemoryTransport.createLinkedPair();
|
||||||
|
await server.connect(serverT);
|
||||||
|
const client = new Client({ name: "test", version: "0.0.1" });
|
||||||
|
await client.connect(clientT);
|
||||||
|
return { server, client };
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
calls.enqueueIndexDoc.length = 0;
|
||||||
|
calls.enqueueFullIndex.length = 0;
|
||||||
|
calls.restoreRevision.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth gates on write tools ---
|
||||||
|
|
||||||
|
test("index_document without auth secret -> tool returns isError", async () => {
|
||||||
|
reset();
|
||||||
|
const { client, server } = await connect(); // no secret
|
||||||
|
const res = await client.callTool({
|
||||||
|
name: "index_document",
|
||||||
|
arguments: { slug: "docs/foo" },
|
||||||
|
});
|
||||||
|
expect(res.isError).toBe(true);
|
||||||
|
expect(calls.enqueueIndexDoc).toHaveLength(0);
|
||||||
|
await client.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("index_document WITH auth secret -> enqueues job", async () => {
|
||||||
|
reset();
|
||||||
|
const { client, server } = await connect("real-secret");
|
||||||
|
const res = await client.callTool({
|
||||||
|
name: "index_document",
|
||||||
|
arguments: { slug: "docs/foo" },
|
||||||
|
});
|
||||||
|
expect(res.isError).toBeFalsy();
|
||||||
|
const text = (res.content as any[])[0].text;
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
expect(parsed.ok).toBe(true);
|
||||||
|
expect(parsed.jobId).toBeTruthy();
|
||||||
|
expect(calls.enqueueIndexDoc).toHaveLength(1);
|
||||||
|
expect(calls.enqueueIndexDoc[0][0]).toBe("docs/foo.md");
|
||||||
|
await client.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reindex_all without auth -> isError; with auth -> enqueues", async () => {
|
||||||
|
reset();
|
||||||
|
const { client, server } = await connect();
|
||||||
|
let res = await client.callTool({ name: "reindex_all", arguments: {} });
|
||||||
|
expect(res.isError).toBe(true);
|
||||||
|
expect(calls.enqueueFullIndex).toHaveLength(0);
|
||||||
|
await client.close();
|
||||||
|
await server.close();
|
||||||
|
|
||||||
|
const { client: c2, server: s2 } = await connect("real-secret");
|
||||||
|
res = await c2.callTool({ name: "reindex_all", arguments: {} });
|
||||||
|
expect(res.isError).toBeFalsy();
|
||||||
|
expect(calls.enqueueFullIndex).toHaveLength(1);
|
||||||
|
await c2.close();
|
||||||
|
await s2.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restore_revision without auth -> isError; with auth -> calls core.restoreRevision", async () => {
|
||||||
|
reset();
|
||||||
|
const { client, server } = await connect();
|
||||||
|
let res = await client.callTool({
|
||||||
|
name: "restore_revision",
|
||||||
|
arguments: { id: "rev-123" },
|
||||||
|
});
|
||||||
|
expect(res.isError).toBe(true);
|
||||||
|
expect(calls.restoreRevision).toHaveLength(0);
|
||||||
|
await client.close();
|
||||||
|
await server.close();
|
||||||
|
|
||||||
|
const { client: c2, server: s2 } = await connect("real-secret");
|
||||||
|
res = await c2.callTool({
|
||||||
|
name: "restore_revision",
|
||||||
|
arguments: { id: "rev-123" },
|
||||||
|
});
|
||||||
|
expect(res.isError).toBeFalsy();
|
||||||
|
expect(calls.restoreRevision).toHaveLength(1);
|
||||||
|
expect(calls.restoreRevision[0][0]).toBe("rev-123");
|
||||||
|
await c2.close();
|
||||||
|
await s2.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("queue_status is public (no auth needed)", async () => {
|
||||||
|
const { client, server } = await connect(); // no secret
|
||||||
|
const res = await client.callTool({ name: "queue_status", arguments: {} });
|
||||||
|
expect(res.isError).toBeFalsy();
|
||||||
|
const text = (res.content as any[])[0].text;
|
||||||
|
expect(JSON.parse(text).queue).toBe("mcpedia-index");
|
||||||
|
await client.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Phase 11: CRUD write tools auth gates ---
|
||||||
|
|
||||||
|
test("create_document without auth secret -> tool returns isError", async () => {
|
||||||
|
const { client, server } = await connect(); // no secret
|
||||||
|
const res = await client.callTool({
|
||||||
|
name: "create_document",
|
||||||
|
arguments: { slug: "docs/test", title: "Test", section: "docs", body: "# Hi" },
|
||||||
|
});
|
||||||
|
expect(res.isError).toBe(true);
|
||||||
|
await client.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("create_document with auth secret -> success", async () => {
|
||||||
|
const { client, server } = await connect("secret");
|
||||||
|
const res = await client.callTool({
|
||||||
|
name: "create_document",
|
||||||
|
arguments: { slug: "docs/test", title: "Test", section: "docs", body: "# Hi" },
|
||||||
|
});
|
||||||
|
expect(res.isError).toBeFalsy();
|
||||||
|
const text = (res.content as any[])[0].text;
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
expect(parsed.ok).toBe(true);
|
||||||
|
await client.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update_document with auth secret -> success", async () => {
|
||||||
|
const { client, server } = await connect("secret");
|
||||||
|
const res = await client.callTool({
|
||||||
|
name: "update_document",
|
||||||
|
arguments: { slug: "docs/test", body: "# Updated" },
|
||||||
|
});
|
||||||
|
expect(res.isError).toBeFalsy();
|
||||||
|
await client.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delete_document without auth secret -> tool returns isError", async () => {
|
||||||
|
const { client, server } = await connect(); // no secret
|
||||||
|
const res = await client.callTool({
|
||||||
|
name: "delete_document",
|
||||||
|
arguments: { slug: "docs/test" },
|
||||||
|
});
|
||||||
|
expect(res.isError).toBe(true);
|
||||||
|
await client.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tool discovery works without auth (read tools present)", async () => {
|
||||||
|
const { client, server } = await connect();
|
||||||
|
const tools = await client.listTools();
|
||||||
|
const names = tools.tools.map((t) => t.name).sort();
|
||||||
|
expect(names).toContain("search_documents");
|
||||||
|
expect(names).toContain("get_document");
|
||||||
|
expect(names).toContain("list_documents");
|
||||||
|
expect(names).toContain("list_sections");
|
||||||
|
expect(names).toContain("semantic_search");
|
||||||
|
expect(names).toContain("hybrid_search");
|
||||||
|
expect(names).toContain("get_related_documents");
|
||||||
|
// Write tools are registered regardless of secret — the gate is in the
|
||||||
|
// handler, not registration.
|
||||||
|
expect(names).toContain("index_document");
|
||||||
|
expect(names).toContain("reindex_all");
|
||||||
|
expect(names).toContain("restore_revision");
|
||||||
|
expect(names).toContain("queue_status");
|
||||||
|
// Phase 11: CRUD write tools also present in discovery.
|
||||||
|
expect(names).toContain("create_document");
|
||||||
|
expect(names).toContain("update_document");
|
||||||
|
expect(names).toContain("delete_document");
|
||||||
|
await client.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||||
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||||
|
import { createMcpServer } from "./index";
|
||||||
|
|
||||||
|
// Phase 3 follow-up: serve the MCPedia MCP server over Streamable HTTP
|
||||||
|
// (MCP 2025-03-26 transport) so remote clients can use its tools/resources
|
||||||
|
// without spawning a stdio subprocess. Stateless mode (sessionIdGenerator
|
||||||
|
// undefined): one McpServer + transport per request, no session affinity, no
|
||||||
|
// shared-transport connect race, no session-map memory leak. Re-registering
|
||||||
|
// 6 tools + 4 resources per request is negligible for a KB-sized corpus.
|
||||||
|
|
||||||
|
const PORT = Number(process.env.MCP_PORT ?? 4021);
|
||||||
|
const PATH = "/mcp";
|
||||||
|
|
||||||
|
const CORS: Record<string, string> = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers":
|
||||||
|
"Content-Type, Accept, Authorization, MCP-Protocol-Version, mcp-session-id",
|
||||||
|
};
|
||||||
|
|
||||||
|
const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||||
|
for (const [k, v] of Object.entries(CORS)) res.setHeader(k, v);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
res.writeHead(204).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((req.url ?? "").split("?")[0] !== PATH) {
|
||||||
|
res.writeHead(404).end("Not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stateless: fresh server + transport per request. The x-webhook-secret header
|
||||||
|
// (if present) is threaded into the server so write tools can require it.
|
||||||
|
const rawSecret = req.headers["x-webhook-secret"];
|
||||||
|
const authSecret = Array.isArray(rawSecret) ? rawSecret[0] : rawSecret;
|
||||||
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||||
|
const server = createMcpServer(authSecret);
|
||||||
|
await server.connect(transport);
|
||||||
|
await transport.handleRequest(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
httpServer.listen(PORT, () => {
|
||||||
|
console.log(`MCPedia MCP server (Streamable HTTP) listening on http://localhost:${PORT}/mcp`);
|
||||||
|
});
|
||||||
+205
-5
@@ -4,18 +4,34 @@ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
listDocuments,
|
listDocuments,
|
||||||
|
listSections,
|
||||||
getDocument,
|
getDocument,
|
||||||
getRelated,
|
getRelated,
|
||||||
semanticSearch,
|
semanticSearch,
|
||||||
hybridSearch,
|
hybridSearch,
|
||||||
keywordSearch,
|
keywordSearch,
|
||||||
listRevisions,
|
listRevisions,
|
||||||
|
getRevision,
|
||||||
|
restoreRevision,
|
||||||
readContentFile,
|
readContentFile,
|
||||||
|
createDocument,
|
||||||
|
updateDocument,
|
||||||
|
deleteDocument,
|
||||||
} from "@mcpedia/core";
|
} from "@mcpedia/core";
|
||||||
|
import { enqueueIndexDoc, enqueueFullIndex, getQueue, INDEX_QUEUE } from "@mcpedia/queue";
|
||||||
import { CONTENT_ROOT } from "@mcpedia/config";
|
import { CONTENT_ROOT } from "@mcpedia/config";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
export function createMcpServer(): McpServer {
|
// Write tools require the caller to supply `x-webhook-secret` matching the
|
||||||
|
// configured WEBHOOK_SECRET. Read tools are open. `authSecret` is threaded from
|
||||||
|
// the HTTP transport (the request header); for stdio it is undefined (local use).
|
||||||
|
function requireMcpAuth(authSecret?: string) {
|
||||||
|
if (!authSecret) {
|
||||||
|
throw new Error("unauthorized: this tool requires the x-webhook-secret header");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMcpServer(authSecret?: string): McpServer {
|
||||||
const server = new McpServer({
|
const server = new McpServer({
|
||||||
name: "mcpedia",
|
name: "mcpedia",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -60,14 +76,26 @@ export function createMcpServer(): McpServer {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"list_sections",
|
||||||
|
{
|
||||||
|
description: "List all active knowledge base sections and document counts from PostgreSQL.",
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const sections = await listSections();
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(sections, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"list_documents",
|
"list_documents",
|
||||||
{
|
{
|
||||||
description: "List documents, optionally filtered by section.",
|
description: "List documents, optionally filtered by section.",
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
section: z
|
section: z.string().optional().describe("Section name to filter by (e.g. 'docs', 'writeups', 'ctf')"),
|
||||||
.enum(["docs", "writeups", "research", "notes"])
|
|
||||||
.optional(),
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async ({ section }) => {
|
async ({ section }) => {
|
||||||
@@ -131,7 +159,179 @@ export function createMcpServer(): McpServer {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Phase 3: MCP Resources (read-only knowledge base surfaced via URIs) ---
|
// --- Mutating & admin tools (require x-webhook-secret) ---
|
||||||
|
server.registerTool(
|
||||||
|
"index_document",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Enqueue a single-document reindex job (parses, upserts, re-embeds). Requires the x-webhook-secret header. slug is the content path without extension, e.g. 'docs/caddy/reverse-proxy'.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
slug: z.string().describe("Document slug, e.g. 'docs/caddy/reverse-proxy'"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ slug }) => {
|
||||||
|
requireMcpAuth(authSecret);
|
||||||
|
const relPath = slug.endsWith(".md") || slug.endsWith(".mdx") ? slug : `${slug}.md`;
|
||||||
|
const job = await enqueueIndexDoc(relPath, "mcp");
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify({ ok: true, jobId: job.id, slug }) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"reindex_all",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Enqueue a full-corpus reindex (walks every content file). Requires the x-webhook-secret header.",
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
requireMcpAuth(authSecret);
|
||||||
|
const job = await enqueueFullIndex("mcp");
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify({ ok: true, jobId: job.id, kind: "full" }) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"restore_revision",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Restore a document revision by id (writes its body back into the live row + rebuilds chunks). Requires the x-webhook-secret header.",
|
||||||
|
inputSchema: z.object({ id: z.string().describe("Revision UUID") }),
|
||||||
|
},
|
||||||
|
async ({ id }) => {
|
||||||
|
requireMcpAuth(authSecret);
|
||||||
|
const result = await restoreRevision(id);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(result ?? { ok: false, error: "not found" }) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- CRUD write tools (require x-webhook-secret) ---
|
||||||
|
server.registerTool(
|
||||||
|
"create_document",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Create a new document in PostgreSQL (writes DB row, revision, chunks, and disk backup). Requires the x-webhook-secret header.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
slug: z.string().describe("URL-safe slug (e.g. 'docs/my-new-doc' or 'ctf/challenge-1')"),
|
||||||
|
title: z.string().describe("Document title"),
|
||||||
|
section: z.string().describe("Content section (e.g. 'docs', 'writeups', 'research', 'notes', 'guides', 'ctf')"),
|
||||||
|
body: z.string().describe("Markdown body"),
|
||||||
|
type: z.string().optional().describe("Document type (e.g. 'documentation', 'writeup', 'research', 'note')"),
|
||||||
|
status: z.enum(["published", "draft"]).optional(),
|
||||||
|
author: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
extraFields: z.record(z.string(), z.unknown()).optional().describe("Dynamic custom metadata key-value pairs"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ slug, title, section, body, type, status, author, tags, extraFields }) => {
|
||||||
|
requireMcpAuth(authSecret);
|
||||||
|
const doc = await createDocument({
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
section,
|
||||||
|
body,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
author,
|
||||||
|
tags,
|
||||||
|
extraFields,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify({ ok: true, slug: doc.slug }) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"update_document",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Update an existing document in PostgreSQL (title, body, section, tags, status, etc.). Requires the x-webhook-secret header.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
slug: z.string().describe("Document slug to update"),
|
||||||
|
title: z.string().optional(),
|
||||||
|
body: z.string().optional(),
|
||||||
|
section: z.string().optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
status: z.enum(["published", "draft"]).optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
author: z.string().optional(),
|
||||||
|
extraFields: z.record(z.string(), z.unknown()).optional().describe("Dynamic custom metadata key-value pairs"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ slug, title, body, section, type, status, tags, author, extraFields }) => {
|
||||||
|
requireMcpAuth(authSecret);
|
||||||
|
const doc = await updateDocument(slug, {
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
section,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
tags,
|
||||||
|
author,
|
||||||
|
extraFields,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify({ ok: true, slug: doc.slug }) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"delete_document",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Delete a document (removes file + DB rows + chunks + revisions). Requires the x-webhook-secret header.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
slug: z.string().describe("Document slug to delete"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ slug }) => {
|
||||||
|
requireMcpAuth(authSecret);
|
||||||
|
const result = await deleteDocument(slug);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify({ ok: true, deleted: result.deleted }) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"queue_status",
|
||||||
|
{
|
||||||
|
description: "Current BullMQ index-queue counts (waiting/active/completed/failed/delayed).",
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const queue = getQueue();
|
||||||
|
const [waiting, active, completed, failed, delayed] = await Promise.all([
|
||||||
|
queue.getWaitingCount(),
|
||||||
|
queue.getActiveCount(),
|
||||||
|
queue.getCompletedCount(),
|
||||||
|
queue.getFailedCount(),
|
||||||
|
queue.getDelayedCount(),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(
|
||||||
|
{ queue: INDEX_QUEUE, counts: { waiting, active, completed, failed, delayed } },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- MCP Resources (read-only knowledge base surfaced via URIs) ---
|
||||||
// mcpedia://docs -> list all published documents
|
// mcpedia://docs -> list all published documents
|
||||||
// mcpedia://docs/{slug} -> full markdown body (from disk)
|
// mcpedia://docs/{slug} -> full markdown body (from disk)
|
||||||
// mcpedia://docs/{slug}/chunks -> chunked preview (semantic slices)
|
// mcpedia://docs/{slug}/chunks -> chunked preview (semantic slices)
|
||||||
|
|||||||
@@ -15,12 +15,20 @@ async function main() {
|
|||||||
const names = tools.tools.map((t) => t.name).sort();
|
const names = tools.tools.map((t) => t.name).sort();
|
||||||
console.log("tools:", names.join(", "));
|
console.log("tools:", names.join(", "));
|
||||||
const expected = [
|
const expected = [
|
||||||
|
"create_document",
|
||||||
|
"delete_document",
|
||||||
"get_document",
|
"get_document",
|
||||||
"get_related_documents",
|
"get_related_documents",
|
||||||
"hybrid_search",
|
"hybrid_search",
|
||||||
|
"index_document",
|
||||||
"list_documents",
|
"list_documents",
|
||||||
|
"list_sections",
|
||||||
|
"queue_status",
|
||||||
|
"reindex_all",
|
||||||
|
"restore_revision",
|
||||||
"search_documents",
|
"search_documents",
|
||||||
"semantic_search",
|
"semantic_search",
|
||||||
|
"update_document",
|
||||||
].sort();
|
].sort();
|
||||||
if (JSON.stringify(names) !== JSON.stringify(expected)) {
|
if (JSON.stringify(names) !== JSON.stringify(expected)) {
|
||||||
throw new Error(`tool set mismatch: ${names.join(",")}`);
|
throw new Error(`tool set mismatch: ${names.join(",")}`);
|
||||||
@@ -65,7 +73,7 @@ async function main() {
|
|||||||
arguments: { section: "docs" },
|
arguments: { section: "docs" },
|
||||||
});
|
});
|
||||||
const docs = JSON.parse((list.content as any)[0].text);
|
const docs = JSON.parse((list.content as any)[0].text);
|
||||||
if (docs.length !== 1) throw new Error("list_documents docs != 1");
|
if (docs.length !== 4) throw new Error(`list_documents docs != 4 (got ${docs.length})`);
|
||||||
console.log("list_documents(section=docs) =>", docs.length, "doc");
|
console.log("list_documents(section=docs) =>", docs.length, "doc");
|
||||||
|
|
||||||
// 6) semantic_search
|
// 6) semantic_search
|
||||||
@@ -1,90 +1,508 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getDocument, listDocuments, getRelated, listRevisions } from "@mcpedia/core";
|
import { cookies } from "next/headers";
|
||||||
|
import { getDocument, listDocuments, getExportDocuments } from "@mcpedia/core";
|
||||||
|
import { WEBHOOK_SECRET } from "@mcpedia/config";
|
||||||
import Markdown from "@/components/Markdown";
|
import Markdown from "@/components/Markdown";
|
||||||
|
import DocForm from "@/components/DocForm";
|
||||||
|
import TOC from "@/components/TOC";
|
||||||
|
import DocActions from "@/components/DocActions";
|
||||||
|
import PdfExportView from "@/components/PdfExportView";
|
||||||
|
import RelatedGrid from "@/components/RelatedGrid";
|
||||||
|
import RevisionList from "@/components/RevisionList";
|
||||||
|
import { classifyPath, extractFoldersForSection } from "@mcpedia/core";
|
||||||
|
import { getSectionMeta } from "@mcpedia/config";
|
||||||
|
import type { DocumentMeta } from "@mcpedia/core";
|
||||||
|
import {
|
||||||
|
Folder,
|
||||||
|
FileText,
|
||||||
|
Clock,
|
||||||
|
FileDown,
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Target,
|
||||||
|
Trophy,
|
||||||
|
Zap,
|
||||||
|
Tag,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
export default async function DocPage({
|
export const dynamic = "force-dynamic";
|
||||||
params,
|
|
||||||
}: {
|
interface DocPageProps {
|
||||||
params: Promise<{ section: string; slug: string[] }>;
|
params: Promise<{ section: string; slug: string[] }>;
|
||||||
}) {
|
searchParams: Promise<{ edit?: string; path?: string; sort?: string }>;
|
||||||
const { section, slug } = await params;
|
}
|
||||||
const fullSlug = `${section}/${slug.join("/")}`;
|
|
||||||
const doc = await getDocument(fullSlug);
|
|
||||||
if (!doc) notFound();
|
|
||||||
|
|
||||||
const related = await getRelated(fullSlug, 5);
|
function getSectionInfo(section: string) {
|
||||||
const revisions = await listRevisions(fullSlug, 10);
|
return getSectionMeta(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateReadingTime(text: string): string {
|
||||||
|
const words = text.trim().split(/\s+/).length;
|
||||||
|
const minutes = Math.ceil(words / 200);
|
||||||
|
return `${minutes} min read`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomFieldBadges({
|
||||||
|
doc,
|
||||||
|
standardKeys,
|
||||||
|
}: {
|
||||||
|
doc: Record<string, unknown>;
|
||||||
|
standardKeys: Set<string>;
|
||||||
|
}) {
|
||||||
|
const docRecord: Record<string, unknown> = { ...doc } as Record<string, unknown>;
|
||||||
|
const customEntries = Object.entries(docRecord).filter(
|
||||||
|
([k, v]) =>
|
||||||
|
!standardKeys.has(k) && v !== undefined && v !== null && v !== "" && !k.startsWith("_"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (customEntries.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="space-y-4">
|
<div className="flex flex-wrap items-center gap-2 mt-4 pt-3.5 border-t border-[var(--border-color)]">
|
||||||
<div>
|
{customEntries.map(([key, value]) => {
|
||||||
<Link href="/" className="text-sm text-zinc-500 hover:underline">
|
const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " ");
|
||||||
← Back
|
let colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-secondary)]";
|
||||||
</Link>
|
let displayValue: string;
|
||||||
<h1 className="text-2xl font-semibold tracking-tight mt-2">
|
let IconComponent = Tag;
|
||||||
{doc.title}
|
|
||||||
</h1>
|
|
||||||
<div className="text-xs text-zinc-500 mt-1">
|
|
||||||
{doc.tags.map((t) => `#${t}`).join(" ")} · {doc.author || "unknown"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Markdown source={doc.body} />
|
if (typeof value === "number") {
|
||||||
|
colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-primary)] font-semibold";
|
||||||
|
IconComponent = Target;
|
||||||
|
displayValue = `${value} pts`;
|
||||||
|
} else if (typeof value === "boolean") {
|
||||||
|
colorClass = value
|
||||||
|
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400 font-semibold"
|
||||||
|
: "bg-rose-500/10 border-rose-500/30 text-rose-600 dark:text-rose-400 font-semibold";
|
||||||
|
IconComponent = value ? CheckCircle2 : XCircle;
|
||||||
|
displayValue = value ? "Solved" : "Pending";
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-secondary)]";
|
||||||
|
IconComponent = Folder;
|
||||||
|
displayValue = value.join(", ");
|
||||||
|
} else {
|
||||||
|
const v = String(value).toLowerCase();
|
||||||
|
if (/^(easy|simple|beginner)/i.test(v)) {
|
||||||
|
colorClass = "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400 font-semibold";
|
||||||
|
displayValue = "Easy";
|
||||||
|
} else if (/^(medium|intermediate)/i.test(v)) {
|
||||||
|
colorClass = "bg-amber-500/10 border-amber-500/30 text-amber-600 dark:text-amber-400 font-semibold";
|
||||||
|
displayValue = "Medium";
|
||||||
|
} else if (/^(hard|expert|advanced)/i.test(v)) {
|
||||||
|
colorClass = "bg-rose-500/10 border-rose-500/30 text-rose-600 dark:text-rose-400 font-semibold";
|
||||||
|
displayValue = "Hard";
|
||||||
|
} else if (/^(pwn|web|crypto|misc|forensic|reverse|binary)/i.test(v)) {
|
||||||
|
colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-primary)] font-bold";
|
||||||
|
IconComponent = Zap;
|
||||||
|
displayValue = v.toUpperCase();
|
||||||
|
} else if (/ctf|def.?con|event/i.test(v)) {
|
||||||
|
colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-primary)]";
|
||||||
|
IconComponent = Trophy;
|
||||||
|
displayValue = String(value);
|
||||||
|
} else {
|
||||||
|
displayValue = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
{related.length > 0 && (
|
return (
|
||||||
<aside className="mt-8 border-t border-zinc-200 dark:border-zinc-800 pt-4">
|
<span
|
||||||
<h2 className="text-sm font-medium mb-2">Related</h2>
|
key={key}
|
||||||
<ul className="text-sm space-y-1">
|
className={`inline-flex items-center gap-1.5 px-2.5 py-1 border rounded-md text-xs font-mono transition-colors ${colorClass}`}
|
||||||
{related.map((r) => (
|
|
||||||
<li key={r.slug}>
|
|
||||||
<Link
|
|
||||||
href={`/${r.section}/${r.slug}`}
|
|
||||||
className="hover:underline"
|
|
||||||
>
|
>
|
||||||
{r.title}
|
<IconComponent className="w-3.5 h-3.5 opacity-80" />
|
||||||
</Link>
|
<span className="text-[var(--text-muted)] font-sans">{label}:</span>
|
||||||
</li>
|
<span>{displayValue}</span>
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</aside>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{revisions.length > 0 && (
|
|
||||||
<aside className="mt-8 border-t border-zinc-200 dark:border-zinc-800 pt-4">
|
|
||||||
<h2 className="text-sm font-medium mb-2">History</h2>
|
|
||||||
<ul className="text-sm space-y-1">
|
|
||||||
{revisions.map((rev) => (
|
|
||||||
<li
|
|
||||||
key={rev.id}
|
|
||||||
className="flex items-center justify-between gap-2"
|
|
||||||
>
|
|
||||||
<span className="text-zinc-600 dark:text-zinc-400">
|
|
||||||
#{rev.revisionNo} · {rev.reason} ·{" "}
|
|
||||||
{new Date(rev.createdAt).toLocaleString()} · {rev.bodyLength} chars
|
|
||||||
</span>
|
</span>
|
||||||
<form action={`/api/revisions/restore/`} method="post">
|
);
|
||||||
<input type="hidden" name="id" value={rev.id} />
|
})}
|
||||||
<button
|
</div>
|
||||||
type="submit"
|
|
||||||
className="rounded border border-zinc-300 dark:border-zinc-700 px-2 py-0.5 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
Restore
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</aside>
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
function FolderIndexPage({
|
||||||
const docs = await listDocuments();
|
section,
|
||||||
return docs.map((d) => ({
|
slug,
|
||||||
section: d.section,
|
allDocs,
|
||||||
slug: d.slug.split("/").slice(1),
|
}: {
|
||||||
}));
|
section: string;
|
||||||
|
slug: string;
|
||||||
|
allDocs: DocumentMeta[];
|
||||||
|
}) {
|
||||||
|
const prefix = `${section}/${slug}/`;
|
||||||
|
const folderDocPaths = allDocs
|
||||||
|
.filter((d) => d.slug.startsWith(prefix))
|
||||||
|
.map((d) => ({
|
||||||
|
rel: d.slug.slice(prefix.length),
|
||||||
|
doc: d,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.rel.localeCompare(b.rel));
|
||||||
|
|
||||||
|
const immediateFolders = new Map<string, { docCount: number }>();
|
||||||
|
const immediateDocs: DocumentMeta[] = [];
|
||||||
|
|
||||||
|
for (const { rel, doc } of folderDocPaths) {
|
||||||
|
const parts = rel.split("/");
|
||||||
|
if (parts.length === 1) {
|
||||||
|
immediateDocs.push(doc);
|
||||||
|
} else {
|
||||||
|
const folder = parts[0];
|
||||||
|
const existing = immediateFolders.get(folder) || { docCount: 0 };
|
||||||
|
existing.docCount += 1;
|
||||||
|
immediateFolders.set(folder, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const folders = [...immediateFolders.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
const sectionInfo = getSectionInfo(section);
|
||||||
|
const folderName = slug.split("/").pop()!.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Breadcrumb Navigation */}
|
||||||
|
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] flex-wrap">
|
||||||
|
<Link href="/" className="hover:text-[var(--text-primary)] transition-colors">
|
||||||
|
MCPedia
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link href={`/${section}`} className="hover:text-[var(--text-primary)] transition-colors">
|
||||||
|
{sectionInfo.label}
|
||||||
|
</Link>
|
||||||
|
{slug.split("/").map((part, i) => {
|
||||||
|
const path = `${section}/${slug.split("/").slice(0, i + 1).join("/")}`;
|
||||||
|
const isLast = i === slug.split("/").length - 1;
|
||||||
|
const label = part.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
||||||
|
return (
|
||||||
|
<span key={path} className="flex items-center gap-2">
|
||||||
|
<span>/</span>
|
||||||
|
<span className={isLast ? "text-[var(--text-primary)] font-semibold" : "hover:text-[var(--text-primary)]"}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Header Card */}
|
||||||
|
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 shadow-xs">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3.5">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] flex items-center justify-center text-[var(--text-primary)]">
|
||||||
|
<Folder className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight">
|
||||||
|
{folderName}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-[var(--text-muted)] mt-0.5 font-mono">
|
||||||
|
Directory: <code>{section}/{slug}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-mono text-[var(--text-muted)] bg-[var(--bg-elevated)] border border-[var(--border-color)] px-2.5 py-1 rounded">
|
||||||
|
{immediateDocs.length + folders.length} item{folders.length + immediateDocs.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
href={`/${section}/${slug}/export`}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] px-3 py-1.5 rounded font-semibold transition-all shadow-xs"
|
||||||
|
title={`Export all ${folderName} documents as a single PDF report`}
|
||||||
|
>
|
||||||
|
<FileDown className="w-3.5 h-3.5" />
|
||||||
|
<span>Export PDF</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subfolders Grid */}
|
||||||
|
{folders.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase mb-3 tracking-wider font-mono flex items-center gap-2">
|
||||||
|
<Folder className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||||
|
<span>Subfolders ({folders.length})</span>
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{folders.map(([folder, info]) => (
|
||||||
|
<Link
|
||||||
|
key={folder}
|
||||||
|
href={`/${section}/${slug}/${folder}`.replace(/\/+/g, "/")}
|
||||||
|
className="group flex items-center gap-3 p-3.5 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg transition-colors shadow-xs"
|
||||||
|
>
|
||||||
|
<Folder className="w-4 h-4 text-[var(--text-muted)]" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs font-semibold text-[var(--text-primary)] group-hover:underline truncate">
|
||||||
|
{folder.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-[var(--text-muted)] font-mono mt-0.5">
|
||||||
|
{info.docCount} document{info.docCount !== 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Documents List */}
|
||||||
|
{immediateDocs.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase mb-3 tracking-wider font-mono flex items-center gap-2">
|
||||||
|
<FileText className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||||
|
<span>Documents ({immediateDocs.length})</span>
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{immediateDocs.map((doc) => (
|
||||||
|
<Link
|
||||||
|
key={doc.slug}
|
||||||
|
href={`/${doc.slug}`}
|
||||||
|
className="group flex items-center justify-between gap-4 p-4 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg transition-colors shadow-xs"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<FileText className="w-4 h-4 text-[var(--text-muted)] shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] group-hover:underline truncate">
|
||||||
|
{doc.title}
|
||||||
|
</div>
|
||||||
|
{doc.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
|
{doc.tags.slice(0, 4).map((t) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
className="text-[10px] px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] font-mono"
|
||||||
|
>
|
||||||
|
#{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<time className="text-xs text-[var(--text-dim)] shrink-0 font-mono flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
<span>
|
||||||
|
{new Date(doc.updatedAt).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</time>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{folders.length === 0 && immediateDocs.length === 0 && (
|
||||||
|
<div className="text-center py-12 border border-[var(--border-color)] rounded-lg bg-[var(--bg-surface)]">
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">No documents in this directory.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||||
|
const { section, slug } = await params;
|
||||||
|
const searchParamsObj = await searchParams;
|
||||||
|
const { edit, path: queryPath, sort } = searchParamsObj;
|
||||||
|
|
||||||
|
// Handle direct export views
|
||||||
|
if (section === "export" || (slug && slug[slug.length - 1] === "export")) {
|
||||||
|
const isTrailingExport = slug && slug[slug.length - 1] === "export";
|
||||||
|
const targetSlug = isTrailingExport ? slug.slice(0, -1) : slug;
|
||||||
|
const targetPath = queryPath || (targetSlug.length > 0 ? `${section}/${targetSlug.join("/")}` : section);
|
||||||
|
const exportData = await getExportDocuments({
|
||||||
|
path: targetPath === "export" ? queryPath : targetPath,
|
||||||
|
section: targetPath === section ? section : undefined,
|
||||||
|
sortBy: (sort as any) || "category_points",
|
||||||
|
});
|
||||||
|
const backHref = targetPath && targetPath !== "export" ? `/${targetPath}` : section !== "export" ? `/${section}` : "/";
|
||||||
|
return <PdfExportView exportData={exportData} backHref={backHref} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullSlug = `${section}/${slug.join("/")}`;
|
||||||
|
|
||||||
|
const allDocs = await listDocuments();
|
||||||
|
const docSlugs = allDocs.map((d) => d.slug);
|
||||||
|
const classification = classifyPath(docSlugs, fullSlug);
|
||||||
|
|
||||||
|
if (classification === "folder") {
|
||||||
|
return <FolderIndexPage section={section} slug={slug.join("/")} allDocs={allDocs} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = await getDocument(fullSlug);
|
||||||
|
if (!doc) notFound();
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const canEdit = cookieStore.get("mcpedia_admin")?.value != null;
|
||||||
|
|
||||||
|
const STANDARD_KEYS = new Set([
|
||||||
|
"id", "slug", "title", "type", "section", "status",
|
||||||
|
"author", "tags", "path", "createdAt", "updatedAt",
|
||||||
|
"extraFields", "body",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (edit === "1" && canEdit) {
|
||||||
|
const existingFolders = extractFoldersForSection(docSlugs, doc.section);
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link
|
||||||
|
href={`/${doc.slug}`}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
|
<span>Back to document</span>
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight">
|
||||||
|
Edit: {doc.title}
|
||||||
|
</h1>
|
||||||
|
<DocForm
|
||||||
|
mode="edit"
|
||||||
|
slug={fullSlug}
|
||||||
|
secret={WEBHOOK_SECRET}
|
||||||
|
existingSections={Array.from(new Set(allDocs.map((d) => d.section)))}
|
||||||
|
existingFolders={{
|
||||||
|
[doc.section]: existingFolders,
|
||||||
|
}}
|
||||||
|
initial={{
|
||||||
|
title: doc.title,
|
||||||
|
body: doc.body,
|
||||||
|
section: doc.section,
|
||||||
|
type: doc.type,
|
||||||
|
status: doc.status,
|
||||||
|
tags: doc.tags,
|
||||||
|
author: doc.author,
|
||||||
|
extraFields: doc.extraFields,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionInfo = getSectionInfo(doc.section);
|
||||||
|
const readingTime = calculateReadingTime(doc.body);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-8">
|
||||||
|
{/* Main Content Column */}
|
||||||
|
<article className="flex-1 min-w-0">
|
||||||
|
{/* Breadcrumbs Navigation */}
|
||||||
|
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] mb-4 flex-wrap font-mono">
|
||||||
|
<Link href="/" className="hover:text-[var(--text-primary)] transition-colors">
|
||||||
|
MCPedia
|
||||||
|
</Link>
|
||||||
|
{doc.slug.split("/").map((part, i) => {
|
||||||
|
const crumbPath = doc.slug.split("/").slice(0, i + 1).join("/");
|
||||||
|
const isLast = i === doc.slug.split("/").length - 1;
|
||||||
|
const label = i === 0
|
||||||
|
? sectionInfo.label
|
||||||
|
: part.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
||||||
|
return (
|
||||||
|
<span key={crumbPath} className="flex items-center gap-2">
|
||||||
|
<span>/</span>
|
||||||
|
<Link
|
||||||
|
href={`/${crumbPath}`}
|
||||||
|
className={isLast ? "text-[var(--text-primary)] font-semibold font-sans" : "hover:text-[var(--text-primary)] transition-colors font-sans"}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Hero Metadata Card */}
|
||||||
|
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 sm:p-7 mb-8 shadow-xs">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4 mb-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap text-xs">
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-mono font-bold bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-primary)] uppercase">
|
||||||
|
<span>{sectionInfo.icon}</span>
|
||||||
|
<span>{sectionInfo.label}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--text-dim)]">·</span>
|
||||||
|
<span className="text-xs text-[var(--text-muted)] font-mono">
|
||||||
|
{doc.type}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--text-dim)]">·</span>
|
||||||
|
<span className="text-xs text-[var(--text-muted)] font-mono flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
<span>{readingTime}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-extrabold text-[var(--text-primary)] tracking-tight leading-tight">
|
||||||
|
{doc.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<DocActions slug={doc.slug} body={doc.body} canEdit={canEdit} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Author, Date, Tags */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs text-[var(--text-muted)] pt-3.5 border-t border-[var(--border-color)]">
|
||||||
|
{doc.author && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-4 h-4 rounded bg-[var(--brand)] text-[var(--brand-fg)] flex items-center justify-center text-[10px] font-bold font-mono">
|
||||||
|
{doc.author.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="text-[var(--text-secondary)] font-medium">{doc.author}</span>
|
||||||
|
<span className="text-[var(--text-dim)]">·</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1 font-mono text-[11px]">
|
||||||
|
<span>
|
||||||
|
Updated {new Date(doc.updatedAt).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{doc.tags.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-[var(--text-dim)]">·</span>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{doc.tags.map((t) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
className="px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] text-[10px] font-mono"
|
||||||
|
>
|
||||||
|
#{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dynamic Badges */}
|
||||||
|
<CustomFieldBadges doc={{ ...doc }} standardKeys={STANDARD_KEYS} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile TOC */}
|
||||||
|
<div className="xl:hidden mb-6 p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-xs">
|
||||||
|
<TOC source={doc.body} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Markdown Content */}
|
||||||
|
<div className="mb-14">
|
||||||
|
<Markdown source={doc.body} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RelatedGrid slug={fullSlug} />
|
||||||
|
<RevisionList slug={fullSlug} canEdit={canEdit} />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{/* Desktop Sticky Table of Contents Column */}
|
||||||
|
<aside className="hidden xl:block w-64 shrink-0 sticky top-20 max-h-[calc(100vh-6rem)] overflow-y-auto pl-6 border-l border-[var(--border-color)]">
|
||||||
|
<TOC source={doc.body} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { listDocuments } from "@mcpedia/core";
|
||||||
|
import { getSectionMeta } from "@mcpedia/config";
|
||||||
|
import {
|
||||||
|
Folder,
|
||||||
|
FileText,
|
||||||
|
FileDown,
|
||||||
|
Plus,
|
||||||
|
Clock,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
interface DocMeta {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
section: string;
|
||||||
|
tags: string[];
|
||||||
|
updatedAt: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeNode {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
doc?: DocMeta;
|
||||||
|
children: TreeNode[];
|
||||||
|
docCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFolderTree(docs: DocMeta[], section: string): TreeNode[] {
|
||||||
|
const root: TreeNode[] = [];
|
||||||
|
|
||||||
|
for (const doc of docs.filter((d) => (d.section || "").toLowerCase() === section.toLowerCase())) {
|
||||||
|
const rel = doc.slug.startsWith(`${section}/`)
|
||||||
|
? doc.slug.slice(section.length + 1)
|
||||||
|
: doc.slug;
|
||||||
|
const parts = rel.split("/");
|
||||||
|
|
||||||
|
let current = root;
|
||||||
|
let currentPath = section;
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
const isLeaf = i === parts.length - 1;
|
||||||
|
currentPath = `${currentPath}/${part}`;
|
||||||
|
|
||||||
|
let node = current.find((n) => n.name === part);
|
||||||
|
if (!node) {
|
||||||
|
node = {
|
||||||
|
name: part,
|
||||||
|
slug: currentPath,
|
||||||
|
children: [],
|
||||||
|
docCount: 0,
|
||||||
|
};
|
||||||
|
current.push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLeaf) {
|
||||||
|
node.doc = doc;
|
||||||
|
node.docCount = 1;
|
||||||
|
}
|
||||||
|
current = node.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countDocs(node: TreeNode): number {
|
||||||
|
if (node.doc) return 1;
|
||||||
|
return node.children.reduce((sum, child) => sum + countDocs(child), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of root) {
|
||||||
|
node.docCount = countDocs(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTree(nodes: TreeNode[], pathname: string) {
|
||||||
|
return (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{nodes.map((node) => {
|
||||||
|
const isFolder = node.children.length > 0;
|
||||||
|
const isActive = pathname === `/${node.slug}`;
|
||||||
|
const hasActiveChild = pathname.startsWith(`/${node.slug}/`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={node.slug}>
|
||||||
|
<Link
|
||||||
|
href={`/${node.slug}`}
|
||||||
|
className={`flex items-center justify-between gap-2 text-xs py-1.5 px-2.5 rounded-md transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] font-semibold border border-[var(--border-color)] shadow-xs"
|
||||||
|
: hasActiveChild
|
||||||
|
? "text-[var(--text-primary)] bg-[var(--bg-elevated)]"
|
||||||
|
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 truncate">
|
||||||
|
<span className="text-[var(--text-dim)]">
|
||||||
|
{isFolder ? <Folder className="w-3.5 h-3.5" /> : <FileText className="w-3.5 h-3.5" />}
|
||||||
|
</span>
|
||||||
|
<span className="truncate font-medium">{node.doc?.title || node.name}</span>
|
||||||
|
</span>
|
||||||
|
{isFolder && (
|
||||||
|
<span className="text-[10px] text-[var(--text-dim)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-1.5 py-0.2 rounded font-mono">
|
||||||
|
{node.docCount} docs
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
{node.children.length > 0 && (
|
||||||
|
<div className="ml-3.5 border-l border-[var(--border-color)] pl-2.5 mt-0.5 space-y-0.5">
|
||||||
|
{renderTree(node.children, pathname)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SectionIndexPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ section: string }>;
|
||||||
|
}) {
|
||||||
|
const { section } = await params;
|
||||||
|
const sectionInfo = getSectionMeta(section);
|
||||||
|
|
||||||
|
const allDocs = await listDocuments();
|
||||||
|
const sectionDocs = allDocs.filter((d) => (d.section || "").toLowerCase() === section.toLowerCase());
|
||||||
|
const tree = buildFolderTree(allDocs, section);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Breadcrumb Navigation */}
|
||||||
|
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] font-mono">
|
||||||
|
<Link href="/" className="hover:text-[var(--text-primary)] transition-colors">
|
||||||
|
MCPedia
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-[var(--text-muted)] font-semibold font-sans">{sectionInfo.label}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Hero Card */}
|
||||||
|
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 shadow-xs">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4 mb-3">
|
||||||
|
<div className="flex items-center gap-3.5">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border-color)] flex items-center justify-center text-2xl">
|
||||||
|
{sectionInfo.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-extrabold text-[var(--text-primary)] tracking-tight">
|
||||||
|
{sectionInfo.label}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-[var(--text-muted)] font-mono uppercase tracking-wider mt-0.5">
|
||||||
|
Section: <code>{section}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap sm:flex-nowrap">
|
||||||
|
<span className="text-xs font-mono text-[var(--text-muted)] bg-[var(--bg-elevated)] border border-[var(--border-color)] px-2.5 py-1 rounded">
|
||||||
|
{sectionDocs.length} document{sectionDocs.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{sectionDocs.length > 0 && (
|
||||||
|
<Link
|
||||||
|
href={`/${section}/export`}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-primary)] px-3 py-1 rounded-md font-semibold transition-all shadow-xs"
|
||||||
|
title={`Export all ${sectionInfo.label} documents to publication-grade PDF`}
|
||||||
|
>
|
||||||
|
<FileDown className="w-3.5 h-3.5" />
|
||||||
|
<span>Export PDF</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/create"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] px-3 py-1 rounded-md font-semibold transition-all shadow-xs"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>Create Doc</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs sm:text-sm text-[var(--text-muted)] leading-relaxed max-w-2xl mt-2">
|
||||||
|
{sectionInfo.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hierarchical Folder Directory */}
|
||||||
|
{tree.length > 0 && (
|
||||||
|
<section className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-5 shadow-xs">
|
||||||
|
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-[var(--border-color)]">
|
||||||
|
<Folder className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||||
|
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">
|
||||||
|
Folder & Chapter Structure
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{renderTree(tree, section)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Document List */}
|
||||||
|
{sectionDocs.length > 0 ? (
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center gap-2 mb-3.5">
|
||||||
|
<FileText className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||||
|
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">
|
||||||
|
All Documents in {sectionInfo.label}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[...sectionDocs]
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||||
|
)
|
||||||
|
.map((d) => (
|
||||||
|
<Link
|
||||||
|
key={d.slug}
|
||||||
|
href={`/${d.slug}`}
|
||||||
|
className="group flex items-center justify-between gap-4 p-4 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-xl transition-all shadow-xs"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<FileText className="w-4 h-4 text-[var(--text-muted)] shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] group-hover:underline truncate">
|
||||||
|
{d.title}
|
||||||
|
</h3>
|
||||||
|
{d.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
|
{d.tags.slice(0, 4).map((t) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
className="text-[10px] px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] font-mono"
|
||||||
|
>
|
||||||
|
#{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<time className="text-xs text-[var(--text-dim)] font-mono shrink-0 flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
<span>
|
||||||
|
{new Date(d.updatedAt).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</time>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 border border-[var(--border-color)] rounded-xl bg-[var(--bg-surface)]">
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">No documents in this section yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { ADMIN_PASSWORD } from "@mcpedia/config";
|
||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
// POST /api/auth/login — verify admin password, set a signed cookie.
|
||||||
|
// Uses a simple HMAC cookie (no JWT library) — sufficient for a single-admin KB.
|
||||||
|
const COOKIE_NAME = "mcpedia_admin";
|
||||||
|
const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
|
||||||
|
|
||||||
|
function signCookie(value: string): string {
|
||||||
|
const secret = ADMIN_PASSWORD || "fallback";
|
||||||
|
const sig = createHmac("sha256", secret).update(value).digest("hex");
|
||||||
|
return `${value}.${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeTimingEqual(a: string, b: string): boolean {
|
||||||
|
try {
|
||||||
|
const bufA = Buffer.from(a);
|
||||||
|
const bufB = Buffer.from(b);
|
||||||
|
if (bufA.length !== bufB.length) return false;
|
||||||
|
return timingSafeEqual(bufA, bufB);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyCookie(cookieValue: string | undefined): boolean {
|
||||||
|
if (!cookieValue) return false;
|
||||||
|
const [value, sig] = cookieValue.split(".");
|
||||||
|
if (!value || !sig) return false;
|
||||||
|
const expected = signCookie(value);
|
||||||
|
return safeTimingEqual(cookieValue, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const { password } = body;
|
||||||
|
|
||||||
|
if (!ADMIN_PASSWORD || typeof password !== "string") {
|
||||||
|
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = safeTimingEqual(password, ADMIN_PASSWORD);
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie = signCookie("admin");
|
||||||
|
const res = NextResponse.json({ ok: true });
|
||||||
|
res.cookies.set(COOKIE_NAME, cookie, {
|
||||||
|
maxAge: COOKIE_MAX_AGE,
|
||||||
|
httpOnly: true,
|
||||||
|
path: "/",
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/auth/login — returns 200 if currently authenticated, 401 otherwise.
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const cookie = req.cookies.get(COOKIE_NAME)?.value;
|
||||||
|
if (verifyCookie(cookie)) return NextResponse.json({ ok: true });
|
||||||
|
return NextResponse.json({ ok: false }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/auth/login — clear the cookie (logout).
|
||||||
|
export async function DELETE() {
|
||||||
|
const res = NextResponse.json({ ok: true });
|
||||||
|
res.cookies.delete({ name: COOKIE_NAME, path: "/" });
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exported for server components to call directly.
|
||||||
|
export function isAuthenticated(req: NextRequest): boolean {
|
||||||
|
const cookie = req.cookies.get(COOKIE_NAME)?.value;
|
||||||
|
return verifyCookie(cookie);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { updateDocument, deleteDocument } from "@mcpedia/core";
|
||||||
|
import { WEBHOOK_SECRET } from "@mcpedia/config";
|
||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
function isAuthorized(req: NextRequest): boolean {
|
||||||
|
if (!WEBHOOK_SECRET) return false;
|
||||||
|
const headerSecret = req.headers.get("x-webhook-secret") ?? "";
|
||||||
|
if (headerSecret) {
|
||||||
|
const bufA = Buffer.from(headerSecret);
|
||||||
|
const bufB = Buffer.from(WEBHOOK_SECRET);
|
||||||
|
if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
|
||||||
|
return cookie.startsWith("admin.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function unauthorized() {
|
||||||
|
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const STANDARD_FIELDS = new Set([
|
||||||
|
"title", "body", "type", "status",
|
||||||
|
"author", "tags", "createdAt", "updatedAt", "id", "path", "slug",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function parseCustomValue(value: unknown): unknown {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed === "true") return true;
|
||||||
|
if (trimmed === "false") return false;
|
||||||
|
if (/^-?\d+(\.\d+)?$/.test(trimmed) && !(trimmed.startsWith("0") && trimmed.length > 1 && !trimmed.startsWith("0."))) {
|
||||||
|
const num = Number(trimmed);
|
||||||
|
if (!isNaN(num)) return num;
|
||||||
|
}
|
||||||
|
if ((trimmed.startsWith("[") && trimmed.endsWith("]")) || (trimmed.startsWith("{") && trimmed.endsWith("}"))) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitPayload(body: Record<string, unknown>): {
|
||||||
|
standard: Record<string, unknown>;
|
||||||
|
extraFields: Record<string, unknown>;
|
||||||
|
} {
|
||||||
|
const standard: Record<string, unknown> = {};
|
||||||
|
const extraFields: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(body)) {
|
||||||
|
if (key === "extraFields" && typeof value === "object" && value !== null) {
|
||||||
|
Object.assign(extraFields, value);
|
||||||
|
} else if (STANDARD_FIELDS.has(key)) {
|
||||||
|
standard[key] = value;
|
||||||
|
} else if (value !== undefined && value !== null) {
|
||||||
|
extraFields[key] = parseCustomValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { standard, extraFields };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/docs/{slug...} — Update an existing document.
|
||||||
|
export async function PUT(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ slug?: string[] }> },
|
||||||
|
) {
|
||||||
|
if (!isAuthorized(req)) return unauthorized();
|
||||||
|
const { slug } = await params;
|
||||||
|
const fullSlug = (slug ?? []).join("/");
|
||||||
|
if (!fullSlug) {
|
||||||
|
return NextResponse.json({ ok: false, error: "slug required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
if (!body) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Invalid JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { standard, extraFields } = splitPayload(body);
|
||||||
|
const doc = await updateDocument(fullSlug, { ...standard, extraFields } as any);
|
||||||
|
return NextResponse.json({ ok: true, slug: doc.slug, doc });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return NextResponse.json({ ok: false, error: msg }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/docs/{slug...}
|
||||||
|
export async function DELETE(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ slug?: string[] }> },
|
||||||
|
) {
|
||||||
|
if (!isAuthorized(req)) return unauthorized();
|
||||||
|
const { slug } = await params;
|
||||||
|
const fullSlug = (slug ?? []).join("/");
|
||||||
|
if (!fullSlug) {
|
||||||
|
return NextResponse.json({ ok: false, error: "slug required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await deleteDocument(fullSlug);
|
||||||
|
return NextResponse.json({ ok: true, deleted: result.deleted });
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
createDocument,
|
||||||
|
updateDocument,
|
||||||
|
deleteDocument,
|
||||||
|
listDocuments,
|
||||||
|
} from "@mcpedia/core";
|
||||||
|
import { WEBHOOK_SECRET } from "@mcpedia/config";
|
||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
// Web CRUD auth: either the x-webhook-secret header (API/MCP style) OR the
|
||||||
|
// mcpedia_admin cookie (web login). One of the two must be valid.
|
||||||
|
function isAuthorized(req: NextRequest): boolean {
|
||||||
|
if (!WEBHOOK_SECRET) return false;
|
||||||
|
const headerSecret = req.headers.get("x-webhook-secret") ?? "";
|
||||||
|
if (headerSecret) {
|
||||||
|
const bufA = Buffer.from(headerSecret);
|
||||||
|
const bufB = Buffer.from(WEBHOOK_SECRET);
|
||||||
|
if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
|
||||||
|
return cookie.startsWith("admin.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function unauthorized() {
|
||||||
|
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard DocumentMeta field names — NOT custom fields.
|
||||||
|
const STANDARD_FIELDS = new Set([
|
||||||
|
"slug", "title", "body", "section", "type", "status",
|
||||||
|
"author", "tags", "createdAt", "updatedAt", "id", "path",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function parseCustomValue(value: unknown): unknown {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed === "true") return true;
|
||||||
|
if (trimmed === "false") return false;
|
||||||
|
if (/^-?\d+(\.\d+)?$/.test(trimmed) && !(trimmed.startsWith("0") && trimmed.length > 1 && !trimmed.startsWith("0."))) {
|
||||||
|
const num = Number(trimmed);
|
||||||
|
if (!isNaN(num)) return num;
|
||||||
|
}
|
||||||
|
if ((trimmed.startsWith("[") && trimmed.endsWith("]")) || (trimmed.startsWith("{") && trimmed.endsWith("}"))) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Separate a flat payload into standard CRUD fields + extraFields (custom metadata).
|
||||||
|
* The DocForm sends all fields flat — any key not in STANDARD_FIELDS becomes an
|
||||||
|
* entry in extraFields, which is stored as JSONB in the documents table.
|
||||||
|
*/
|
||||||
|
function splitPayload(body: Record<string, unknown>): {
|
||||||
|
standard: Record<string, unknown>;
|
||||||
|
extraFields: Record<string, unknown>;
|
||||||
|
} {
|
||||||
|
const standard: Record<string, unknown> = {};
|
||||||
|
const extraFields: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(body)) {
|
||||||
|
if (key === "extraFields" && typeof value === "object" && value !== null) {
|
||||||
|
Object.assign(extraFields, value);
|
||||||
|
} else if (STANDARD_FIELDS.has(key)) {
|
||||||
|
standard[key] = value;
|
||||||
|
} else if (value !== undefined && value !== null) {
|
||||||
|
extraFields[key] = parseCustomValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { standard, extraFields };
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/docs — list all documents (for sidebar navigation).
|
||||||
|
// Supports ?section=&status= filters.
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const section = searchParams.get("section") ?? undefined;
|
||||||
|
const status = searchParams.get("status") ?? undefined;
|
||||||
|
const docs = await listDocuments({
|
||||||
|
...(section ? { section } : {}),
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
});
|
||||||
|
return NextResponse.json(docs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/docs — Create a new document.
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
if (!isAuthorized(req)) return unauthorized();
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
if (!body) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Invalid JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { standard, extraFields } = splitPayload(body);
|
||||||
|
const doc = await createDocument({ ...standard, extraFields } as any);
|
||||||
|
return NextResponse.json({ ok: true, slug: doc.slug, doc });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return NextResponse.json({ ok: false, error: msg }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
getExportDocuments,
|
||||||
|
compileExportMarkdown,
|
||||||
|
generateExportDocx,
|
||||||
|
type ExportSortOption,
|
||||||
|
} from "@mcpedia/core";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const path = searchParams.get("path") || undefined;
|
||||||
|
const section = searchParams.get("section") || undefined;
|
||||||
|
const format = searchParams.get("format") || "json";
|
||||||
|
const sortBy = (searchParams.get("sort") as ExportSortOption) || "category_points";
|
||||||
|
const slugsParam = searchParams.get("slugs");
|
||||||
|
const slugs = slugsParam ? slugsParam.split(",").map((s) => s.trim()) : undefined;
|
||||||
|
const pageBreaksParam = searchParams.get("pageBreaks");
|
||||||
|
const pageBreaks = pageBreaksParam !== "false" && pageBreaksParam !== "0";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getExportDocuments({
|
||||||
|
path,
|
||||||
|
section,
|
||||||
|
slugs,
|
||||||
|
sortBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
const safeFilename = data.summary.title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "") || "mcpedia-export";
|
||||||
|
|
||||||
|
if (format === "docx" || format === "word") {
|
||||||
|
const buffer = await generateExportDocx(data, { pageBreaks });
|
||||||
|
const uint8 = new Uint8Array(buffer);
|
||||||
|
return new NextResponse(uint8, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type":
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"Content-Disposition": `attachment; filename="${safeFilename}.docx"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === "markdown" || format === "md") {
|
||||||
|
const md = compileExportMarkdown(data);
|
||||||
|
return new NextResponse(md, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/markdown; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="${safeFilename}.md"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err instanceof Error ? err.message : "Export failed" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// GET /api/queue/status — BullMQ queue counts (read-only, public)
|
||||||
|
// Falls back gracefully when Redis is unavailable so the UI never breaks.
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const { getQueue } = await import("@mcpedia/queue");
|
||||||
|
const q = getQueue();
|
||||||
|
const [waiting, active, completed, failed, delayed] = await Promise.all([
|
||||||
|
q.getWaitingCount(),
|
||||||
|
q.getActiveCount(),
|
||||||
|
q.getCompletedCount(),
|
||||||
|
q.getFailedCount(),
|
||||||
|
q.getDelayedCount(),
|
||||||
|
]);
|
||||||
|
return NextResponse.json({ waiting, active, completed, failed, delayed });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
// Return zeroed status so the FE can render gracefully
|
||||||
|
return NextResponse.json(
|
||||||
|
{ waiting: 0, active: 0, completed: 0, failed: 0, delayed: 0, error: msg },
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getRelated } from "@mcpedia/core";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// GET /api/related?slug=...&limit=5
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const slug = new URL(req.url).searchParams.get("slug") ?? "";
|
||||||
|
const limit = Math.min(Math.max(Number(new URL(req.url).searchParams.get("limit") ?? 5), 1), 20);
|
||||||
|
if (!slug.trim()) {
|
||||||
|
return NextResponse.json({ results: [] });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const results = await getRelated(slug, limit);
|
||||||
|
return NextResponse.json({ results });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
return NextResponse.json({ results: [], error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,32 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { restoreRevision, getRevision } from "@mcpedia/core";
|
import { restoreRevision, getRevision } from "@mcpedia/core";
|
||||||
|
import { WEBHOOK_SECRET } from "@mcpedia/config";
|
||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
function isAuthorized(req: NextRequest): boolean {
|
||||||
|
if (!WEBHOOK_SECRET) return false;
|
||||||
|
const headerSecret = req.headers.get("x-webhook-secret") ?? "";
|
||||||
|
if (headerSecret) {
|
||||||
|
const bufA = Buffer.from(headerSecret);
|
||||||
|
const bufB = Buffer.from(WEBHOOK_SECRET);
|
||||||
|
if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
|
||||||
|
return cookie.startsWith("admin.");
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/revisions/restore — restore a document to a past revision.
|
// POST /api/revisions/restore — restore a document to a past revision.
|
||||||
// Body (form-urlencoded): id=<revision uuid>
|
// Body (form-urlencoded): id=<revision uuid>
|
||||||
// After restoring, we rebuild semantic chunks (handled inside restoreRevision)
|
// After restoring, we rebuild semantic chunks (handled inside restoreRevision)
|
||||||
// and revalidate the doc page so the Web UI reflects the restored body.
|
// and revalidate the doc page so the Web UI reflects the restored body.
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
if (!isAuthorized(req)) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const form = await req.formData().catch(() => null);
|
const form = await req.formData().catch(() => null);
|
||||||
const id = form?.get("id");
|
const id = form?.get("id");
|
||||||
if (typeof id !== "string" || id.length === 0) {
|
if (typeof id !== "string" || id.length === 0) {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { listRevisions } from "@mcpedia/core";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// GET /api/revisions?slug=...&limit=10
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const slug = searchParams.get("slug") ?? "";
|
||||||
|
const limit = Math.min(Math.max(Number(searchParams.get("limit") ?? 10), 1), 50);
|
||||||
|
if (!slug.trim()) {
|
||||||
|
return NextResponse.json({ revisions: [] });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const revisions = await listRevisions(slug, limit);
|
||||||
|
return NextResponse.json({ revisions });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
return NextResponse.json({ revisions: [], error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { keywordSearch, hybridSearch, semanticSearch } from "@mcpedia/core";
|
||||||
|
|
||||||
|
// GET /api/search?q=...&mode=hybrid|keyword|semantic
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const q = searchParams.get("q") ?? "";
|
||||||
|
const mode = searchParams.get("mode") ?? "hybrid";
|
||||||
|
const limit = Math.min(Math.max(Number(searchParams.get("limit") ?? 20), 1), 50);
|
||||||
|
|
||||||
|
if (!q.trim()) {
|
||||||
|
return NextResponse.json({ results: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === "keyword") {
|
||||||
|
const hits = await keywordSearch(q, limit);
|
||||||
|
return NextResponse.json({
|
||||||
|
results: hits.map((h) => ({
|
||||||
|
slug: h.doc.slug,
|
||||||
|
title: h.doc.title,
|
||||||
|
section: h.doc.section,
|
||||||
|
score: h.rank,
|
||||||
|
snippet: h.snippet,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "semantic") {
|
||||||
|
const hits = await semanticSearch(q, limit);
|
||||||
|
return NextResponse.json({
|
||||||
|
results: hits.map((h) => ({
|
||||||
|
slug: h.slug,
|
||||||
|
title: h.slug.split("/").pop() ?? h.slug,
|
||||||
|
section: h.slug.split("/")[0] ?? "docs",
|
||||||
|
score: h.score,
|
||||||
|
snippet: h.content.slice(0, 160),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: hybrid search
|
||||||
|
const hits = await hybridSearch(q, limit);
|
||||||
|
return NextResponse.json({
|
||||||
|
results: hits.map((h) => ({
|
||||||
|
slug: h.doc.slug,
|
||||||
|
title: h.doc.title,
|
||||||
|
section: h.doc.section,
|
||||||
|
score: h.rank,
|
||||||
|
snippet: h.snippet,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Search API error:", err);
|
||||||
|
// Fall back to keyword search if embedding provider fails
|
||||||
|
try {
|
||||||
|
const hits = await keywordSearch(q, limit);
|
||||||
|
return NextResponse.json({
|
||||||
|
results: hits.map((h) => ({
|
||||||
|
slug: h.doc.slug,
|
||||||
|
title: h.doc.title,
|
||||||
|
section: h.doc.section,
|
||||||
|
score: h.rank,
|
||||||
|
snippet: h.snippet,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ results: [], error: "Search failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { listSections } from "@mcpedia/core";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// GET /api/sections — list all active sections with document counts dynamically from PostgreSQL.
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const sections = await listSections();
|
||||||
|
return NextResponse.json(sections);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { listDocuments } from "@mcpedia/core";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// GET /api/tags?limit=30 — aggregated tag counts
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const limit = Math.min(Math.max(Number(new URL(req.url).searchParams.get("limit") ?? 30), 1), 100);
|
||||||
|
try {
|
||||||
|
const docs = await listDocuments();
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const d of docs) {
|
||||||
|
for (const t of d.tags ?? []) {
|
||||||
|
const tag = String(t).trim();
|
||||||
|
if (!tag) continue;
|
||||||
|
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tags = [...counts.entries()]
|
||||||
|
.map(([tag, count]) => ({ tag, count }))
|
||||||
|
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag))
|
||||||
|
.slice(0, limit);
|
||||||
|
return NextResponse.json({ tags });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
return NextResponse.json({ tags: [], error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useSearch } from "@/hooks/useSearch";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
ArrowRight,
|
||||||
|
BookOpen,
|
||||||
|
Flag,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface QuickAction {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommandMenu() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const router = useRouter();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { hits, isSearching, isValidating } = useSearch(query, "hybrid", 10);
|
||||||
|
const results = query.trim() ? hits : [];
|
||||||
|
const loading = isSearching || isValidating;
|
||||||
|
|
||||||
|
const quickActions: QuickAction[] = [
|
||||||
|
{ id: "create-doc", title: "Create New Document", subtitle: "Write technical doc, writeup, or note", icon: Plus, href: "/create" },
|
||||||
|
{ id: "hybrid-search", title: "Multi-Modal Hybrid Search", subtitle: "Full-text + Semantic vector search console", icon: Search, href: "/search" },
|
||||||
|
{ id: "browse-docs", title: "Documentation Hub", subtitle: "System setup, API references, protocol specs", icon: BookOpen, href: "/docs" },
|
||||||
|
{ id: "browse-writeups", title: "CTF & Writeups Directory", subtitle: "Pwn, Reverse, Web, Crypto writeups and solutions", icon: Flag, href: "/writeups" },
|
||||||
|
{ id: "browse-research", title: "Research & Benchmarks", subtitle: "Architecture notes, benchmarks, experiments", icon: Sparkles, href: "/research" },
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((prev) => !prev);
|
||||||
|
} else if (e.key === "/" && !["INPUT", "TEXTAREA"].includes((e.target as HTMLElement)?.tagName)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
} else if (e.key === "Escape" && open) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
|
setQuery("");
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
function handleNavigate(href: string) {
|
||||||
|
setOpen(false);
|
||||||
|
router.push(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalItems = query.trim() ? results.length : quickActions.length;
|
||||||
|
|
||||||
|
function handleKeyNavigation(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (totalItems > 0 ? (prev + 1) % totalItems : 0));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (totalItems > 0 ? (prev - 1 + totalItems) % totalItems : 0));
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (query.trim() && results[selectedIndex]) {
|
||||||
|
handleNavigate(`/${results[selectedIndex].slug}`);
|
||||||
|
} else if (!query.trim() && quickActions[selectedIndex]?.href) {
|
||||||
|
handleNavigate(quickActions[selectedIndex].href!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
type="button"
|
||||||
|
className="hidden md:flex items-center gap-2.5 px-3 py-1.5 bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-xs cursor-pointer group"
|
||||||
|
>
|
||||||
|
<Search className="w-3.5 h-3.5 text-[var(--text-dim)] group-hover:text-[var(--text-primary)] transition-colors" />
|
||||||
|
<span className="font-sans">Search...</span>
|
||||||
|
<kbd className="ml-2 font-mono bg-[var(--bg-surface)] px-1.5 py-0.2 rounded border border-[var(--border-color)] text-[10px] text-[var(--text-muted)]">⌘K</kbd>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center pt-16 sm:pt-24 px-4">
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-xs transition-opacity" onClick={() => setOpen(false)} />
|
||||||
|
<div className="relative w-full max-w-2xl bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-2xl overflow-hidden animate-fade-in">
|
||||||
|
<div className="flex items-center gap-3 px-4 sm:px-5 py-3.5 border-b border-[var(--border-color)] bg-[var(--bg-elevated)]">
|
||||||
|
<Search className="w-4 h-4 text-[var(--text-muted)] shrink-0" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={handleKeyNavigation}
|
||||||
|
placeholder="Search documents, writeups, concepts, or tools..."
|
||||||
|
className="w-full bg-transparent text-[var(--text-primary)] placeholder-[var(--text-dim)] text-sm focus:outline-none"
|
||||||
|
/>
|
||||||
|
{loading && <div className="w-4 h-4 border-2 border-[var(--text-muted)] border-t-transparent rounded-full animate-spin shrink-0" />}
|
||||||
|
<button onClick={() => setOpen(false)} type="button" className="text-[10px] font-mono text-[var(--text-dim)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] px-1.5 py-0.5 rounded border border-[var(--border-color)]">ESC</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[22rem] overflow-y-auto p-2">
|
||||||
|
{query.trim() === "" ? (
|
||||||
|
<div className="p-1 space-y-0.5">
|
||||||
|
<div className="px-2.5 py-1 text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">Quick Shortcuts</div>
|
||||||
|
{quickActions.map((action, idx) => {
|
||||||
|
const Icon = action.icon;
|
||||||
|
const isSelected = idx === selectedIndex;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={action.id}
|
||||||
|
onClick={() => action.href && handleNavigate(action.href)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(idx)}
|
||||||
|
className={`flex items-center justify-between gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors ${isSelected ? "bg-[var(--bg-elevated)] text-[var(--text-primary)]" : "hover:bg-[var(--bg-elevated)] text-[var(--text-secondary)]"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className={`p-1.5 rounded ${isSelected ? "bg-[var(--brand)] text-[var(--brand-fg)]" : "bg-[var(--bg-elevated)] text-[var(--text-muted)]"}`}>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-[var(--text-primary)]">{action.title}</div>
|
||||||
|
<div className="text-[11px] text-[var(--text-muted)]">{action.subtitle}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className={`w-3.5 h-3.5 text-[var(--text-dim)] ${isSelected ? "text-[var(--text-primary)]" : ""}`} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : results.length === 0 && !loading ? (
|
||||||
|
<div className="px-4 py-10 text-center text-xs text-[var(--text-muted)] space-y-1">
|
||||||
|
<p>No knowledge base items matched "{query}"</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1 p-1">
|
||||||
|
{results.map((hit, index) => {
|
||||||
|
const isSelected = index === selectedIndex;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={hit.slug}
|
||||||
|
onClick={() => handleNavigate(`/${hit.slug}`)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
className={`flex items-start justify-between gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors ${isSelected ? "bg-[var(--bg-elevated)] text-[var(--text-primary)] border border-[var(--border-color)]" : "hover:bg-[var(--bg-elevated)] text-[var(--text-secondary)] border border-transparent"}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span className="text-[10px] font-mono px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] uppercase font-semibold">{hit.section}</span>
|
||||||
|
<span className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] truncate">{hit.title}</span>
|
||||||
|
</div>
|
||||||
|
{hit.snippet && (
|
||||||
|
<p
|
||||||
|
className="text-xs text-[var(--text-muted)] line-clamp-1 mt-1 [&>mark]:bg-[var(--bg-elevated-hover)] [&>mark]:text-[var(--text-primary)] [&>mark]:font-semibold"
|
||||||
|
// snippet is server-sanitized ts_headline output
|
||||||
|
dangerouslySetInnerHTML={{ __html: hit.snippet }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono text-[var(--text-dim)] shrink-0 self-center">/{hit.slug}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-t border-[var(--border-color)] bg-[var(--bg-elevated)] text-[10px] text-[var(--text-muted)] font-mono">
|
||||||
|
<div className="flex items-center gap-2"><span>[ ↑↓ NAVIGATE ]</span><span>[ ↵ SELECT ]</span></div>
|
||||||
|
<Link href={`/search?q=${encodeURIComponent(query)}`} onClick={() => setOpen(false)} className="hover:text-[var(--text-primary)] font-medium flex items-center gap-1"><span>Full Search Console</span><ArrowRight className="w-3 h-3" /></Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Share2,
|
||||||
|
FileDown,
|
||||||
|
FileText,
|
||||||
|
Edit3,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface DocActionsProps {
|
||||||
|
slug: string;
|
||||||
|
body: string;
|
||||||
|
canEdit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocActions({ slug, body, canEdit }: DocActionsProps) {
|
||||||
|
const [copiedMd, setCopiedMd] = useState(false);
|
||||||
|
const [copiedLink, setCopiedLink] = useState(false);
|
||||||
|
|
||||||
|
function handleCopyMarkdown() {
|
||||||
|
navigator.clipboard.writeText(body);
|
||||||
|
setCopiedMd(true);
|
||||||
|
setTimeout(() => setCopiedMd(false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCopyLink() {
|
||||||
|
navigator.clipboard.writeText(window.location.href);
|
||||||
|
setCopiedLink(true);
|
||||||
|
setTimeout(() => setCopiedLink(false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0 flex-wrap">
|
||||||
|
{/* Copy Raw Markdown */}
|
||||||
|
<button
|
||||||
|
onClick={handleCopyMarkdown}
|
||||||
|
type="button"
|
||||||
|
title="Copy raw markdown content"
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
{copiedMd ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
|
<span className="text-emerald-500 font-medium">Copied</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
<span>Raw MD</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Copy Share Link */}
|
||||||
|
<button
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
type="button"
|
||||||
|
title="Copy link to document"
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
{copiedLink ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
|
<span className="text-emerald-500 font-medium">Copied</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Share2 className="w-3.5 h-3.5" />
|
||||||
|
<span>Share</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Export PDF */}
|
||||||
|
<Link
|
||||||
|
href={`/${slug}/export`}
|
||||||
|
title="Export document to publication-grade PDF"
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs"
|
||||||
|
>
|
||||||
|
<FileDown className="w-3.5 h-3.5" />
|
||||||
|
<span>PDF</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Export Word (.docx) */}
|
||||||
|
<a
|
||||||
|
href={`/api/export?path=${encodeURIComponent(slug)}&format=docx`}
|
||||||
|
title="Download editable Microsoft Word (.docx) file"
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
<FileText className="w-3.5 h-3.5" />
|
||||||
|
<span>Word (.docx)</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Edit Link */}
|
||||||
|
<Link
|
||||||
|
href={canEdit ? `/${slug}?edit=1` : `/login?redirect=/${slug}`}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-primary)] rounded-md transition-colors font-medium shadow-xs"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-3.5 h-3.5" />
|
||||||
|
<span>Edit</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { mutate } from "swr";
|
||||||
|
import Markdown from "./Markdown";
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
Code,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
Edit3,
|
||||||
|
Sliders,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface DocFormProps {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
slug?: string;
|
||||||
|
secret: string;
|
||||||
|
existingFolders?: Record<string, string[]>;
|
||||||
|
existingSections?: string[];
|
||||||
|
initial?: {
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
section?: string;
|
||||||
|
type?: string;
|
||||||
|
status?: "published" | "draft" | string;
|
||||||
|
tags?: string[];
|
||||||
|
author?: string;
|
||||||
|
extraFields?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SECTION_LIST = [
|
||||||
|
"docs",
|
||||||
|
"writeups",
|
||||||
|
"research",
|
||||||
|
"notes",
|
||||||
|
"guides",
|
||||||
|
"tutorials",
|
||||||
|
"ctf",
|
||||||
|
"api",
|
||||||
|
"projects",
|
||||||
|
];
|
||||||
|
const DEFAULT_TYPE_LIST = [
|
||||||
|
"documentation",
|
||||||
|
"writeup",
|
||||||
|
"research",
|
||||||
|
"note",
|
||||||
|
"guide",
|
||||||
|
"tutorial",
|
||||||
|
"spec",
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRESET_FIELDS = [
|
||||||
|
{ key: "difficulty", value: "medium" },
|
||||||
|
{ key: "points", value: "100" },
|
||||||
|
{ key: "category", value: "pwn" },
|
||||||
|
{ key: "event", value: "DEF CON Quals 2024" },
|
||||||
|
{ key: "solved", value: "true" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DocForm({
|
||||||
|
mode,
|
||||||
|
slug,
|
||||||
|
secret,
|
||||||
|
existingFolders,
|
||||||
|
existingSections,
|
||||||
|
initial,
|
||||||
|
}: DocFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<"write" | "preview">("write");
|
||||||
|
|
||||||
|
const [title, setTitle] = useState(initial?.title ?? "");
|
||||||
|
const [slugInput, setSlugInput] = useState("");
|
||||||
|
const [body, setBody] = useState(initial?.body ?? "");
|
||||||
|
const [section, setSection] = useState(initial?.section ?? "docs");
|
||||||
|
const [type, setType] = useState(initial?.type ?? "documentation");
|
||||||
|
const [status, setStatus] = useState(initial?.status ?? "published");
|
||||||
|
const [tags, setTags] = useState(initial?.tags?.join(", ") ?? "");
|
||||||
|
const [author, setAuthor] = useState(initial?.author ?? "");
|
||||||
|
const [parentFolder, setParentFolder] = useState("");
|
||||||
|
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const sectionOptions = Array.from(
|
||||||
|
new Set([...(existingSections ?? []), ...DEFAULT_SECTION_LIST, section].filter(Boolean)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract existing custom fields
|
||||||
|
const [customFields, setCustomFields] = useState<Array<{ key: string; value: string }>>(() => {
|
||||||
|
if (initial?.extraFields) {
|
||||||
|
return Object.entries(initial.extraFields)
|
||||||
|
.filter(([k, v]) => k !== "slug" && v !== undefined && v !== null)
|
||||||
|
.map(([k, v]) => ({ key: k, value: typeof v === "string" ? v : JSON.stringify(v) }))
|
||||||
|
.filter((f) => f.value);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const tagList = tags
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const isEdit = mode === "edit";
|
||||||
|
const effectiveSlug = isEdit
|
||||||
|
? (slug ?? "")
|
||||||
|
: parentFolder
|
||||||
|
? `${parentFolder}/${slugInput}`.replace(/^\/+/, "").replace(/\/+/g, "/")
|
||||||
|
: slugInput;
|
||||||
|
|
||||||
|
const availableFolders = existingFolders?.[section] ?? [];
|
||||||
|
|
||||||
|
const baseInputCls =
|
||||||
|
"w-full px-3.5 py-2 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg text-sm text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--text-primary)] transition-colors";
|
||||||
|
|
||||||
|
function addCustomField(key = "", value = "") {
|
||||||
|
setCustomFields([...customFields, { key, value }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCustomField(index: number, field: "key" | "value", value: string) {
|
||||||
|
const updated = [...customFields];
|
||||||
|
updated[index][field] = value;
|
||||||
|
setCustomFields(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCustomField(index: number) {
|
||||||
|
setCustomFields(customFields.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertMarkdown(prefix: string, suffix = "") {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selection = body.substring(start, end);
|
||||||
|
const replacement = `${prefix}${selection || "text"}${suffix}`;
|
||||||
|
|
||||||
|
const newBody = body.substring(0, start) + replacement + body.substring(end);
|
||||||
|
setBody(newBody);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(start + prefix.length, start + replacement.length - suffix.length);
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!effectiveSlug || !title || !body) {
|
||||||
|
setError("Slug, title, and markdown body are required.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
slug: effectiveSlug,
|
||||||
|
section,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
author,
|
||||||
|
tags: tagList,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const field of customFields) {
|
||||||
|
if (field.key.trim() && field.value.trim()) {
|
||||||
|
payload[field.key.trim()] = field.value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res: Response;
|
||||||
|
if (mode === "create") {
|
||||||
|
res = await fetch("/api/docs", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-webhook-secret": secret,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const editSlug = slug ?? "";
|
||||||
|
res = await fetch(`/api/docs/${editSlug}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-webhook-secret": secret,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error ?? "Failed to save document. Verify ADMIN_PASSWORD or secret.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
// Invalidate SWR caches so Sidebar/Header/search reflect the change immediately
|
||||||
|
mutate("/api/docs");
|
||||||
|
mutate("/api/sections");
|
||||||
|
mutate((key) => typeof key === "string" && key.startsWith("/api/tags"));
|
||||||
|
router.push(result.doc?.slug ? `/${result.doc.slug}` : "/");
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3.5 rounded-lg border border-rose-500/30 bg-rose-500/10 text-rose-500 dark:text-rose-400 text-xs font-medium animate-fade-in">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Document Title */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||||
|
Document Title *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="e.g. Exploiting Stack Alignment Issues in x86_64"
|
||||||
|
className={`${baseInputCls} text-base font-semibold`}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section, Type, Status */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||||
|
Section
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
list="section-list"
|
||||||
|
value={section}
|
||||||
|
onChange={(e) => setSection(e.target.value)}
|
||||||
|
placeholder="e.g. docs, writeups, research"
|
||||||
|
className={baseInputCls}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<datalist id="section-list">
|
||||||
|
{sectionOptions.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s.toUpperCase()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||||
|
Document Type
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
list="type-list"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value)}
|
||||||
|
placeholder="e.g. writeup, documentation, note"
|
||||||
|
className={baseInputCls}
|
||||||
|
/>
|
||||||
|
<datalist id="type-list">
|
||||||
|
{DEFAULT_TYPE_LIST.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => setStatus(e.target.value as typeof status)}
|
||||||
|
className={baseInputCls}
|
||||||
|
>
|
||||||
|
<option value="published">Published</option>
|
||||||
|
<option value="draft">Draft (Unindexed)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Folder + Slug */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||||
|
Parent Folder / Chapter
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={parentFolder}
|
||||||
|
onChange={(e) => setParentFolder(e.target.value)}
|
||||||
|
className={baseInputCls}
|
||||||
|
disabled={isEdit}
|
||||||
|
>
|
||||||
|
<option value="">(Root of section)</option>
|
||||||
|
{availableFolders.map((folder) => (
|
||||||
|
<option key={folder} value={folder}>
|
||||||
|
/{folder}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||||
|
{isEdit ? "Document Path" : "Slug (File name) *"}
|
||||||
|
</label>
|
||||||
|
{isEdit ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={slug ?? ""}
|
||||||
|
readOnly
|
||||||
|
className="w-full px-3.5 py-2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded-lg text-sm text-[var(--text-muted)] font-mono"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={slugInput}
|
||||||
|
onChange={(e) => setSlugInput(e.target.value)}
|
||||||
|
className={`${baseInputCls} font-mono`}
|
||||||
|
placeholder="ret2win-guide"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-[11px] text-[var(--text-dim)] mt-1.5 font-mono">
|
||||||
|
Full Slug: <span className="text-[var(--text-primary)] font-semibold">{effectiveSlug || section}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags + Author */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||||
|
Tags (comma separated)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tags}
|
||||||
|
onChange={(e) => setTags(e.target.value)}
|
||||||
|
className={baseInputCls}
|
||||||
|
placeholder="pwn, x86_64, rop, ctf"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||||
|
Author
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={author}
|
||||||
|
onChange={(e) => setAuthor(e.target.value)}
|
||||||
|
className={baseInputCls}
|
||||||
|
placeholder="e.g. asepharyana"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Metadata Fields */}
|
||||||
|
<div className="p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl space-y-3.5 shadow-xs">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] font-mono flex items-center gap-1.5">
|
||||||
|
<Sliders className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||||
|
<span>Technical Metadata</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-[var(--text-dim)] mt-0.5">
|
||||||
|
Custom fields (points, difficulty, event, solved) will render as styled badges in the document hero.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
<span className="text-[10px] font-mono text-[var(--text-dim)] uppercase">Presets:</span>
|
||||||
|
{PRESET_FIELDS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => addCustomField(preset.key, preset.value)}
|
||||||
|
className="px-2 py-0.5 text-[10px] bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded transition-colors font-mono cursor-pointer"
|
||||||
|
>
|
||||||
|
+{preset.key}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customFields.map((field, i) => (
|
||||||
|
<div key={i} className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Field name (e.g. event)"
|
||||||
|
value={field.key}
|
||||||
|
onChange={(e) => updateCustomField(i, "key", e.target.value)}
|
||||||
|
className={`${baseInputCls} text-xs font-mono`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Value (e.g. DEF CON 2024)"
|
||||||
|
value={field.value}
|
||||||
|
onChange={(e) => updateCustomField(i, "value", e.target.value)}
|
||||||
|
className={`${baseInputCls} text-xs font-mono`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeCustomField(i)}
|
||||||
|
className="p-2 text-rose-500 hover:text-rose-600 rounded hover:bg-[var(--bg-elevated)] transition-colors cursor-pointer"
|
||||||
|
title="Remove field"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addCustomField()}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-[var(--text-primary)] hover:underline px-3 py-1 rounded-md border border-[var(--border-color)] bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] transition-colors font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>Add Custom Field</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Markdown Body Editor */}
|
||||||
|
<div className="border border-[var(--border-color)] rounded-xl overflow-hidden bg-[var(--bg-surface)] shadow-xs">
|
||||||
|
{/* Editor Toolbar */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 bg-[var(--bg-elevated)] border-b border-[var(--border-color)] flex-wrap gap-2">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("write")}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded font-semibold transition-all cursor-pointer ${
|
||||||
|
activeTab === "write"
|
||||||
|
? "bg-[var(--brand)] text-[var(--brand-fg)] shadow-xs"
|
||||||
|
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Edit3 className="w-3.5 h-3.5" />
|
||||||
|
<span>Write</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("preview")}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded font-semibold transition-all cursor-pointer ${
|
||||||
|
activeTab === "preview"
|
||||||
|
? "bg-[var(--brand)] text-[var(--brand-fg)] shadow-xs"
|
||||||
|
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
<span>Preview</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Markdown Formatter Shortcuts */}
|
||||||
|
{activeTab === "write" && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-[var(--text-muted)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => insertMarkdown("**", "**")}
|
||||||
|
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
|
||||||
|
title="Bold"
|
||||||
|
>
|
||||||
|
<Bold className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => insertMarkdown("*", "*")}
|
||||||
|
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
|
||||||
|
title="Italic"
|
||||||
|
>
|
||||||
|
<Italic className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => insertMarkdown("## ")}
|
||||||
|
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
|
||||||
|
title="Heading 2"
|
||||||
|
>
|
||||||
|
<Heading2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => insertMarkdown("### ")}
|
||||||
|
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
|
||||||
|
title="Heading 3"
|
||||||
|
>
|
||||||
|
<Heading3 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => insertMarkdown("```\n", "\n```")}
|
||||||
|
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
|
||||||
|
title="Code Block"
|
||||||
|
>
|
||||||
|
<Code className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => insertMarkdown("[", "](https://)")}
|
||||||
|
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
|
||||||
|
title="Link"
|
||||||
|
>
|
||||||
|
<LinkIcon className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
{activeTab === "write" ? (
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
className="w-full h-96 p-4 bg-transparent text-[var(--text-primary)] placeholder-[var(--text-dim)] font-mono text-sm focus:outline-none resize-y leading-relaxed"
|
||||||
|
placeholder="# Write your technical documentation or writeup here in Markdown..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="p-6 h-96 overflow-y-auto bg-[var(--bg-app)]">
|
||||||
|
{body.trim() ? (
|
||||||
|
<Markdown source={body} />
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-[var(--text-dim)] italic font-mono">No content to preview yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Action Buttons */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="px-6 py-2.5 bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] rounded-lg font-semibold text-xs transition-all shadow-xs disabled:opacity-50 flex items-center gap-2 cursor-pointer font-mono uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<div className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||||
|
)}
|
||||||
|
<span>{loading ? "PERSISTING..." : mode === "create" ? "PUBLISH_DOCUMENT" : "SAVE_CHANGES"}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="px-4 py-2.5 border border-[var(--border-color)] hover:border-[var(--text-muted)] text-[var(--text-muted)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] rounded-lg text-xs transition-colors shadow-xs cursor-pointer font-mono"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loaded 50 of 132 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user