13 Commits

Author SHA1 Message Date
Hartmut 12044f638e ci: retrigger — E2E webServer timeout on run #172 (QNAP runner flake)
CI / Typecheck (pull_request) Successful in 3m25s
CI / Architecture Guardrails (pull_request) Successful in 3m44s
CI / Lint (pull_request) Successful in 2m8s
CI / Assistant Split Regression (pull_request) Successful in 3m44s
CI / Unit Tests (pull_request) Successful in 7m29s
CI / Build (pull_request) Successful in 6m52s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 3m53s
CI / E2E Tests (pull_request) Successful in 20m24s
CI / Release Images (pull_request) Has been skipped
2026-05-22 09:34:10 +02:00
Hartmut 2383bcbdc0 fix(timeline): trigger scroll-to-today on isInitialLoading→false not totalCanvasWidth
CI / Architecture Guardrails (pull_request) Successful in 2m53s
CI / Typecheck (pull_request) Successful in 3m28s
CI / Assistant Split Regression (pull_request) Successful in 3m40s
CI / Lint (pull_request) Successful in 4m26s
CI / Unit Tests (pull_request) Successful in 8m36s
CI / Build (pull_request) Successful in 9m47s
CI / E2E Tests (pull_request) Failing after 14m2s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 16m53s
CI / Release Images (pull_request) Has been skipped
totalCanvasWidth is computed from viewStart/viewDays before data loads,
so the previous trigger fired during the loading spinner. scrollLeft
was clipped to 0 (no canvas in DOM yet) and the guard was set, blocking
the real scroll after data arrived. Using isInitialLoading as the dep
fires the effect exactly when the canvas enters the DOM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:48:23 +02:00
Hartmut 0e9d6ec388 fix(timeline): wait for canvas width before scrolling to today
CI / Assistant Split Regression (pull_request) Has been cancelled
CI / Lint (pull_request) Has been cancelled
CI / Typecheck (pull_request) Has been cancelled
CI / Unit Tests (pull_request) Has been cancelled
CI / Build (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Fresh-Linux Docker Deploy (pull_request) Has been cancelled
CI / Release Images (pull_request) Has been cancelled
CI / Architecture Guardrails (pull_request) Has been cancelled
useLayoutEffect([]) fired before isInitialLoading resolved, so the
scroll container had no canvas yet — scrollLeft was clipped to 0.
Now the scroll-to-today fires on the first render where totalCanvasWidth
becomes non-zero. The cleanup effect resets the guard on unmount so
React Strict Mode's fake-unmount+remount also scrolls correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:45:09 +02:00
Hartmut 7285668c52 fix(timeline): use empty-deps useLayoutEffect for mount scroll to today
CI / Architecture Guardrails (pull_request) Successful in 4m53s
CI / Typecheck (pull_request) Successful in 4m55s
CI / Assistant Split Regression (pull_request) Successful in 5m38s
CI / Build (pull_request) Has been cancelled
CI / Lint (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Fresh-Linux Docker Deploy (pull_request) Has been cancelled
CI / Release Images (pull_request) Has been cancelled
CI / Unit Tests (pull_request) Has been cancelled
The guard-ref approach broke in React Strict Mode (dev): the ref
persisted as `true` across the simulated remount, so the second
invocation skipped the scroll — leaving scrollLeft=0 (today-90
at the left edge, not today). An empty-deps useLayoutEffect runs
twice in Strict Mode but both executions fire against the same
initial `toLeft` and produce the correct result.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:38:08 +02:00
Hartmut 944d36bdb2 fix(timeline): pre-load 90-day past buffer + scroll to today on mount
CI / Architecture Guardrails (pull_request) Successful in 5m6s
CI / Typecheck (pull_request) Successful in 7m31s
CI / Assistant Split Regression (pull_request) Successful in 6m45s
CI / Lint (pull_request) Successful in 6m19s
CI / Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Fresh-Linux Docker Deploy (pull_request) Has been cancelled
CI / Release Images (pull_request) Has been cancelled
CI / Build (pull_request) Has been cancelled
viewStart=today left no canvas to the left of scrollLeft=0, making
left-scroll physically impossible. Now viewStart defaults to today-90
so the canvas always has 90 days to scroll into, and a mount-time
useLayoutEffect positions the viewport with today at the left edge.

The Today button restores this view: scrolls in-range, or resets
viewStart and schedules a post-layout scroll if today has scrolled
out of the visible window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:15:37 +02:00
Hartmut 6ec512e302 test(cron): raise timeout for next/server cold-import on act runner
CI / Architecture Guardrails (pull_request) Successful in 4m22s
CI / Typecheck (pull_request) Successful in 6m48s
CI / Assistant Split Regression (pull_request) Successful in 7m49s
CI / Lint (pull_request) Successful in 7m59s
CI / E2E Tests (pull_request) Has been cancelled
CI / Fresh-Linux Docker Deploy (pull_request) Has been cancelled
CI / Release Images (pull_request) Has been cancelled
CI / Unit Tests (pull_request) Has been cancelled
CI / Build (pull_request) Has been cancelled
The test takes >5s on the QNAP act runner because dynamic import of
next/server has to transpile the module cold on first call. Raise the
per-test timeout to 15s to give it headroom without changing the test logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:06:56 +02:00
Hartmut 4a841d5acb feat(timeline): start at today and allow infinite scroll into the past
CI / Architecture Guardrails (pull_request) Successful in 17m31s
CI / Assistant Split Regression (pull_request) Successful in 9m42s
CI / Typecheck (pull_request) Successful in 20m48s
CI / Lint (pull_request) Successful in 8m6s
CI / Unit Tests (pull_request) Failing after 7m32s
CI / Build (pull_request) Successful in 9m12s
CI / E2E Tests (pull_request) Successful in 6m12s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 6m58s
CI / Release Images (pull_request) Has been skipped
Previously viewStart defaulted to today-30 and the scroll container had
no left-edge expansion logic, so users hit a hard wall when scrolling
left. This change:

- Sets viewStart default to today so the viewport opens with today at
  the left edge (URL ?startDate= override still respected).
- Adds left-edge auto-expansion in handleContainerScroll: when the user
  scrolls within 40 cells of the left boundary, 120 days are prepended
  and a useLayoutEffect applies the matching scrollLeft compensation in
  the same paint frame to prevent a visual jump.
- Floors backward navigation at 5 years (minDate) to prevent unbounded
  viewDays growth.
- Updates handleNavigateToday to match: resets to today rather than
  today-30.

Both resource view and project view use the same TimelineContext /
TimelineView, so both are fixed by this change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 07:16:34 +02:00
Hartmut 749a39097c ci: retrigger — runner flake on unit-tests step (run #163)
CI / Architecture Guardrails (pull_request) Successful in 4m9s
CI / Typecheck (pull_request) Successful in 5m41s
CI / Lint (pull_request) Successful in 5m47s
CI / Assistant Split Regression (pull_request) Successful in 6m8s
CI / Build (pull_request) Failing after 15m55s
CI / E2E Tests (pull_request) Has been skipped
CI / Fresh-Linux Docker Deploy (pull_request) Has been skipped
CI / Unit Tests (pull_request) Successful in 30m26s
CI / Release Images (pull_request) Failing after 10m48s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 07:05:16 +02:00
Hartmut a58b99a33a rename(cleanup): drop last capakraken strings from UI, scripts, schema, tests
CI / Architecture Guardrails (pull_request) Successful in 4m26s
CI / Assistant Split Regression (pull_request) Successful in 5m38s
CI / Lint (pull_request) Successful in 6m6s
CI / Typecheck (pull_request) Successful in 6m34s
CI / Build (pull_request) Successful in 4m13s
CI / Unit Tests (pull_request) Failing after 10m20s
CI / E2E Tests (pull_request) Successful in 5m28s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 6m14s
CI / Release Images (pull_request) Has been skipped
AppShell.tsx top-left brand → Nexus (desktop sidebar + mobile top-bar),
shell echo strings, prisma schema header, test fixture token, playwright
runtime DB URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:57:43 +02:00
Hartmut c5b58a5bdc fix(docs): update nginx-hardening.conf to nexus domain and log paths
Server block comment, access_log and error_log paths all updated from
capakraken.hartmut-noerenberg.com to nexus.hartmut-noerenberg.com.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 20:41:58 +02:00
Hartmut 52ddbe7377 fix(migrate): use relname not table_name in pg_stat_user_tables query
CI / Architecture Guardrails (push) Successful in 2m54s
CI / Typecheck (push) Successful in 2m56s
CI / Lint (push) Successful in 3m2s
CI / Assistant Split Regression (push) Successful in 4m49s
CI / Unit Tests (push) Successful in 6m26s
CI / Build (push) Successful in 6m36s
CI / E2E Tests (push) Successful in 5m26s
CI / Fresh-Linux Docker Deploy (push) Successful in 6m2s
CI / Release Images (push) Successful in 7m53s
pg_stat_user_tables uses relname, not table_name. The wrong column caused
the row-count verification step to abort with ERROR: column does not exist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 20:11:57 +02:00
Hartmut 19aeb2ba04 rename(phase 3): compose/DB/infra + stray code refs capakraken → nexus (#62)
CI / Lint (push) Successful in 3m4s
CI / Typecheck (push) Successful in 3m6s
CI / Architecture Guardrails (push) Successful in 3m8s
CI / Assistant Split Regression (push) Successful in 3m48s
CI / Build (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 3): compose/DB/infra + stray code refs capakraken → nexus (#62)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
2026-05-21 20:07:18 +02:00
Hartmut b41c1d2501 rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
2026-05-21 16:28:40 +02:00
53 changed files with 548 additions and 244 deletions
+1
View File
@@ -0,0 +1 @@
{"sessionId":"aed37e34-4be8-4788-b03a-7145d9b4b2ce","pid":3544538,"procStart":"34480817","acquiredAt":1779373227101}
+2 -2
View File
@@ -32,7 +32,7 @@ POSTGRES_PASSWORD=
# host (outside Docker). Must match POSTGRES_PASSWORD above. Inside the app
# container this variable is overridden by docker-compose.yml (which routes
# to the postgres service name on the internal network).
DATABASE_URL=postgresql://capakraken:capakraken_dev@localhost:5433/capakraken
DATABASE_URL=postgresql://nexus:nexus_dev@localhost:5433/nexus
# ─── Redis ───────────────────────────────────────────────────────────────────
@@ -104,7 +104,7 @@ PGADMIN_PASSWORD=
# that any resolved path remains inside this directory; this prevents an
# admin (or compromised admin token) from pointing the parser at arbitrary
# files on disk and reaching ExcelJS CVEs. Defaults to ./imports if unset.
# DISPO_IMPORT_DIR=/var/lib/capakraken/imports
# DISPO_IMPORT_DIR=/var/lib/nexus/imports
# ─── Testing (never enable in production) ────────────────────────────────────
+29 -25
View File
@@ -159,11 +159,11 @@ jobs:
postgres:
image: postgres:16
env:
POSTGRES_DB: capakraken_test
POSTGRES_USER: capakraken
POSTGRES_PASSWORD: capakraken_test
POSTGRES_DB: nexus_test
POSTGRES_USER: nexus
POSTGRES_PASSWORD: nexus_test
options: >-
--health-cmd="pg_isready -U capakraken -d capakraken_test"
--health-cmd="pg_isready -U nexus -d nexus_test"
--health-interval=10s
--health-timeout=5s
--health-retries=5
@@ -175,7 +175,7 @@ jobs:
--health-timeout=5s
--health-retries=5
env:
DATABASE_URL: postgresql://capakraken:capakraken_test@postgres:5432/capakraken_test
DATABASE_URL: postgresql://nexus:nexus_test@postgres:5432/nexus_test
REDIS_URL: redis://redis:6379
# Force in-memory rate limiter to avoid cross-test state when Redis drops.
# Redis fallback downgrades to max/10 limits which rate-limits unit tests.
@@ -291,11 +291,11 @@ jobs:
e2epg:
image: postgres:16
env:
POSTGRES_DB: capakraken_test
POSTGRES_USER: capakraken
POSTGRES_PASSWORD: capakraken_test
POSTGRES_DB: nexus_test
POSTGRES_USER: nexus
POSTGRES_PASSWORD: nexus_test
options: >-
--health-cmd="pg_isready -U capakraken -d capakraken_test"
--health-cmd="pg_isready -U nexus -d nexus_test"
--health-interval=10s
--health-timeout=5s
--health-retries=5
@@ -307,14 +307,14 @@ jobs:
--health-timeout=5s
--health-retries=5
env:
DATABASE_URL: postgresql://capakraken:capakraken_test@e2epg:5432/capakraken_test
DATABASE_URL: postgresql://nexus:nexus_test@e2epg:5432/nexus_test
# Playwright test-server.mjs requires an explicit test DB URL.
PLAYWRIGHT_DATABASE_URL: postgresql://capakraken:capakraken_test@e2epg:5432/capakraken_test
PLAYWRIGHT_DATABASE_URL: postgresql://nexus:nexus_test@e2epg:5432/nexus_test
# prisma-with-env.mjs refuses to run unless DATABASE_URL's db name matches
# the expected target; default is "capakraken", CI uses capakraken_test.
CAPAKRAKEN_EXPECTED_DB_NAME: capakraken_test
# the expected target; default is "nexus", CI uses nexus_test.
NEXUS_EXPECTED_DB_NAME: nexus_test
ALLOW_DESTRUCTIVE_DB_TOOLS: "true"
CONFIRM_DESTRUCTIVE_DB_NAME: capakraken_test
CONFIRM_DESTRUCTIVE_DB_NAME: nexus_test
REDIS_URL: redis://e2eredis:6379
PORT: 3100
# test-server.mjs spawns `docker compose --profile test up postgres-test`;
@@ -375,7 +375,7 @@ jobs:
- name: Push DB schema & seed
env:
PGPASSWORD: capakraken_test
PGPASSWORD: nexus_test
run: |
# Nuke any leftover schema state from a previous job that shared the
# postgres service container (act_runner reuses service volumes).
@@ -397,7 +397,7 @@ jobs:
IPS=$(getent hosts e2epg | awk '{print $1}')
PG_IP=""
for ip in $IPS; do
if PGPASSWORD=capakraken_test psql -h "$ip" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 -Atc "SELECT 1" >/dev/null 2>&1; then
if PGPASSWORD=nexus_test psql -h "$ip" -U nexus -d nexus_test -v ON_ERROR_STOP=1 -Atc "SELECT 1" >/dev/null 2>&1; then
PG_IP="$ip"
echo "Locked onto postgres at $PG_IP"
break
@@ -406,19 +406,19 @@ jobs:
fi
done
if [ -z "$PG_IP" ]; then
echo "ERROR: no resolved e2epg IP accepted capakraken_test credentials"
echo "ERROR: no resolved e2epg IP accepted nexus_test credentials"
exit 1
fi
PINNED_URL="postgresql://capakraken:capakraken_test@$PG_IP:5432/capakraken_test"
PINNED_URL="postgresql://nexus:nexus_test@$PG_IP:5432/nexus_test"
echo "DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV"
echo "PLAYWRIGHT_DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV"
echo "--- DROP SCHEMA ---"
psql -h "$PG_IP" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 \
-c "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO capakraken; GRANT ALL ON SCHEMA public TO public;"
psql -h "$PG_IP" -U nexus -d nexus_test -v ON_ERROR_STOP=1 \
-c "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO nexus; GRANT ALL ON SCHEMA public TO public;"
echo "--- prisma db push ---"
DATABASE_URL="$PINNED_URL" pnpm --filter @nexus/db exec prisma db push --schema ./prisma/schema.prisma --accept-data-loss --skip-generate
echo "--- tables in public after push ---"
psql -h "$PG_IP" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 -At \
psql -h "$PG_IP" -U nexus -d nexus_test -v ON_ERROR_STOP=1 -At \
-c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename" \
| tee /tmp/tables.txt
if ! grep -qx 'audit_logs' /tmp/tables.txt; then
@@ -468,8 +468,8 @@ jobs:
NEXTAUTH_SECRET=ci-test-secret-minimum-32-chars-xx
PGADMIN_PASSWORD=ci-pgadmin
# Must match the password baked into docker-compose.ci.yml's
# DATABASE_URL override (capakraken_dev).
POSTGRES_PASSWORD=capakraken_dev
# DATABASE_URL override (nexus_dev).
POSTGRES_PASSWORD=nexus_dev
EOF
- name: Tear down any stale stack & volumes
@@ -477,7 +477,11 @@ jobs:
# runs. A previous run's failed migration entry in _prisma_migrations
# causes P3009 on the next migrate deploy; wipe volumes for a truly
# fresh deploy test every time.
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true
# Also tear down the legacy "capakraken" project (pre-Phase-3 rename)
# in case old containers are still holding host ports 5433/6380.
run: |
docker compose -p capakraken --profile full -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true
docker compose --profile full -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true
- name: Start infrastructure (postgres + redis)
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d postgres redis
@@ -485,7 +489,7 @@ jobs:
- name: Wait for postgres
run: |
for i in $(seq 1 20); do
docker compose -f docker-compose.yml -f docker-compose.ci.yml exec -T postgres pg_isready -U capakraken -d capakraken && break
docker compose -f docker-compose.yml -f docker-compose.ci.yml exec -T postgres pg_isready -U nexus -d nexus && break
sleep 3
done
+1 -1
View File
@@ -101,7 +101,7 @@ test.describe("Assistant approvals", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript((conversationId) => {
window.sessionStorage.setItem("capakraken-chat-conversation-id", conversationId);
window.sessionStorage.setItem("nexus-chat-conversation-id", conversationId);
}, CURRENT_CONVERSATION_ID);
runDb(`
+4 -4
View File
@@ -42,9 +42,9 @@ test.describe("Auth — login / logout", () => {
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/auth\/signin/, { timeout: 5000 });
// Error message visible
await expect(
page.locator("text=/invalid|incorrect|wrong|credentials/i"),
).toBeVisible({ timeout: 5000 });
await expect(page.locator("text=/invalid|incorrect|wrong|credentials/i")).toBeVisible({
timeout: 5000,
});
});
test("after logout, protected routes redirect to sign-in", async ({ page }) => {
@@ -75,7 +75,7 @@ test.describe("Session registry — no tRPC 401s after login", () => {
// At least one user row should be visible
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
await expect(page.locator("text=/planarchy\\.dev|capakraken\\.dev/").first()).toBeVisible({
await expect(page.locator("text=/planarchy\\.dev|nexus\\.dev/").first()).toBeVisible({
timeout: 10000,
});
await expect(page.locator("text=No users found")).toHaveCount(0);
@@ -12,7 +12,7 @@
* - Creates a temporary test user via tRPC (admin session) for isolation.
* - Cleans up the test user in afterAll.
* - Uses an empty storageState to ensure no cross-user localStorage bleed.
* - localStorage key is user-scoped: "capakraken_dashboard_v1_{userId}".
* - localStorage key is user-scoped: "nexus_dashboard_v1_{userId}".
*/
import { expect, test, type Browser, type Page } from "@playwright/test";
@@ -20,9 +20,16 @@ import { STORAGE_STATE } from "../../playwright.dev.config.js";
// ─── tRPC helpers ─────────────────────────────────────────────────────────────
type TrpcResult = { result?: { data?: unknown }; error?: { data?: { code?: string }; message?: string } };
type TrpcResult = {
result?: { data?: unknown };
error?: { data?: { code?: string }; message?: string };
};
async function trpcMutation(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
async function trpcMutation(
page: Page,
procedure: string,
input: unknown = null,
): Promise<TrpcResult> {
return page.evaluate(
async ({ procedure, input }) => {
const res = await fetch(`/api/trpc/${procedure}?batch=1`, {
@@ -38,7 +45,11 @@ async function trpcMutation(page: Page, procedure: string, input: unknown = null
);
}
async function trpcQuery(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
async function trpcQuery(
page: Page,
procedure: string,
input: unknown = null,
): Promise<TrpcResult> {
return page.evaluate(
async ({ procedure, input }) => {
const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } }));
@@ -128,7 +139,9 @@ test.describe("Dashboard — widget management", () => {
// Default layout should show at least the stat-cards widget
// (from createDefaultDashboardLayout in useDashboardLayout)
await expect(page.locator('[data-testid="widget-stat-cards"], .react-grid-item').first()).toBeVisible({
await expect(
page.locator('[data-testid="widget-stat-cards"], .react-grid-item').first(),
).toBeVisible({
timeout: 8000,
});
});
@@ -138,16 +151,21 @@ test.describe("Dashboard — widget management", () => {
await navigateToDashboard(page);
// Open modal
await page.getByRole("button", { name: /add widget/i }).first().click();
await page
.getByRole("button", { name: /add widget/i })
.first()
.click();
// Verify modal is open
await expect(page.getByRole("heading", { name: /add widget/i })).toBeVisible({ timeout: 5000 });
await expect(page.getByRole("heading", { name: /add widget/i })).toBeVisible({
timeout: 5000,
});
// Verify widget entries are visible in the modal
// The catalog has 11 widgets; check for at least 5 visible buttons inside the modal
const widgetButtons = page.locator(
'[role="dialog"] button, .fixed button[type="button"]',
).filter({ hasText: /./ });
const widgetButtons = page
.locator('[role="dialog"] button, .fixed button[type="button"]')
.filter({ hasText: /./ });
// Count items in the grid (the ×-close button is excluded by checking for icon content)
const modalContent = page.locator(".fixed.inset-0 .grid");
@@ -166,10 +184,16 @@ test.describe("Dashboard — widget management", () => {
const initialCount = await page.locator(".react-grid-item").count();
// Open modal and add "Resource Table" widget
await page.getByRole("button", { name: /add widget/i }).first().click();
await page
.getByRole("button", { name: /add widget/i })
.first()
.click();
await expect(page.locator(".fixed.inset-0")).toBeVisible({ timeout: 5000 });
await page.locator(".fixed.inset-0 button").filter({ hasText: /resource table/i }).click();
await page
.locator(".fixed.inset-0 button")
.filter({ hasText: /resource table/i })
.click();
// Modal should close after adding
await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 });
@@ -184,9 +208,15 @@ test.describe("Dashboard — widget management", () => {
await navigateToDashboard(page);
// Add a recognizable widget
await page.getByRole("button", { name: /add widget/i }).first().click();
await page
.getByRole("button", { name: /add widget/i })
.first()
.click();
await expect(page.locator(".fixed.inset-0")).toBeVisible({ timeout: 5000 });
await page.locator(".fixed.inset-0 button").filter({ hasText: /project overview/i }).click();
await page
.locator(".fixed.inset-0 button")
.filter({ hasText: /project overview/i })
.click();
await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 });
const countAfterAdd = await page.locator(".react-grid-item").count();
@@ -214,19 +244,23 @@ test.describe("Dashboard — widget management", () => {
// Read the admin's localStorage key to verify it is user-scoped
const adminUserId = await adminPage.evaluate(async () => {
const res = await fetch("/api/trpc/user.me?batch=1&input=" + encodeURIComponent(JSON.stringify({ "0": { json: null } })), {
credentials: "include",
});
const body = await res.json() as [{ result?: { data?: { json?: { id?: string } } } }];
const res = await fetch(
"/api/trpc/user.me?batch=1&input=" +
encodeURIComponent(JSON.stringify({ "0": { json: null } })),
{
credentials: "include",
},
);
const body = (await res.json()) as [{ result?: { data?: { json?: { id?: string } } } }];
return body[0]?.result?.data?.json?.id ?? null;
});
// Verify admin has a user-scoped storage key (not shared "capakraken_dashboard_v1")
// Verify admin has a user-scoped storage key (not shared "nexus_dashboard_v1")
if (adminUserId) {
const storageKey = await adminPage.evaluate((userId) => {
// Check both old (unscoped) and new (user-scoped) key formats
const oldKey = "capakraken_dashboard_v1";
const newKey = `capakraken_dashboard_v1_${userId}`;
const oldKey = "nexus_dashboard_v1";
const newKey = `nexus_dashboard_v1_${userId}`;
const oldValue = localStorage.getItem(oldKey);
const newValue = localStorage.getItem(newKey);
return { oldKey: oldValue !== null, newKey: newValue !== null };
@@ -244,8 +278,13 @@ test.describe("Dashboard — widget management", () => {
// Inject the admin's storage key to simulate same browser
await newUserPage.evaluate(
({ key, value }) => { localStorage.setItem(key, value ?? ""); },
{ key: `capakraken_dashboard_v1_${adminUserId}`, value: JSON.stringify({ version: 2, gridCols: 12, widgets: [] }) },
({ key, value }) => {
localStorage.setItem(key, value ?? "");
},
{
key: `nexus_dashboard_v1_${adminUserId}`,
value: JSON.stringify({ version: 2, gridCols: 12, widgets: [] }),
},
);
// Log in as test user
@@ -262,7 +301,10 @@ test.describe("Dashboard — widget management", () => {
const gridItems = await newUserPage.locator(".react-grid-item").count();
// Either show default layout (≥1 widget) OR the properly-scoped empty state with Add Widget CTA
// The key check: the test user's Add Widget button should still work
await newUserPage.getByRole("button", { name: /add widget/i }).first().click();
await newUserPage
.getByRole("button", { name: /add widget/i })
.first()
.click();
// Modal must show widgets to choose from
const modalContent = newUserPage.locator(".fixed.inset-0 .grid");
+3 -3
View File
@@ -25,9 +25,9 @@ const RESET_TEST_USER = {
password: "Dev123456!",
};
const DB_CONTAINER = "capakraken-postgres-1";
const DB_USER = "capakraken";
const DB_NAME = "capakraken";
const DB_CONTAINER = "nexus-postgres-1";
const DB_USER = "nexus";
const DB_NAME = "nexus";
function psqlExec(sql: string): string {
return execSync(
+15 -12
View File
@@ -26,7 +26,7 @@ export async function signOut(page: Page) {
await page.goto("/dashboard"); // land on any authenticated page for cookie context
await page.evaluate(async () => {
const csrfRes = await fetch("/api/auth/csrf");
const { csrfToken } = await csrfRes.json() as { csrfToken: string };
const { csrfToken } = (await csrfRes.json()) as { csrfToken: string };
await fetch("/api/auth/signout", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -62,11 +62,9 @@ function decodeMimeBody(body: string, encoding: string | undefined): string {
const enc = (encoding ?? "").toLowerCase().trim();
if (enc === "quoted-printable") {
return body
.replace(/=\r\n/g, "") // soft line break (CRLF)
.replace(/=\n/g, "") // soft line break (LF)
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) =>
String.fromCharCode(parseInt(hex, 16)),
);
.replace(/=\r\n/g, "") // soft line break (CRLF)
.replace(/=\n/g, "") // soft line break (LF)
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) => String.fromCharCode(parseInt(hex, 16)));
}
if (enc === "base64") {
return Buffer.from(body.replace(/\s/g, ""), "base64").toString("utf8");
@@ -90,7 +88,10 @@ export async function clearMailhog(): Promise<void> {
*/
export async function getLatestEmailTo(
address: string,
{ timeoutMs = 10_000, pollIntervalMs = 500 }: { timeoutMs?: number; pollIntervalMs?: number } = {},
{
timeoutMs = 10_000,
pollIntervalMs = 500,
}: { timeoutMs?: number; pollIntervalMs?: number } = {},
): Promise<{ subject: string; body: string; html: string }> {
const deadline = Date.now() + timeoutMs;
@@ -144,7 +145,9 @@ export function extractUrlFromEmail(
pathPrefix: string,
): string {
const text = email.html || email.body;
const match = text.match(new RegExp(`https?://[^\\s"'<>]*${pathPrefix.replace("/", "\\/")}[^\\s"'<>]*`));
const match = text.match(
new RegExp(`https?://[^\\s"'<>]*${pathPrefix.replace("/", "\\/")}[^\\s"'<>]*`),
);
if (!match?.[0]) {
throw new Error(`No URL with prefix "${pathPrefix}" found in email`);
}
@@ -166,10 +169,10 @@ export async function resetPasswordViaApi(
// argon2id hashes use base64 chars only — safe inside a SQL single-quoted string
// Column name is camelCase (Prisma default) — must be double-quoted in SQL
const sql = `UPDATE users SET "passwordHash" = '${passwordHash}' WHERE email = '${email}';`;
execSync(
`docker exec -i capakraken-postgres-1 psql -U capakraken -d capakraken`,
{ input: sql, encoding: "utf8" },
);
execSync(`docker exec -i nexus-postgres-1 psql -U nexus -d nexus`, {
input: sql,
encoding: "utf8",
});
}
// ── tRPC helpers ───────────────────────────────────────────────────────────────
+5 -3
View File
@@ -27,7 +27,7 @@ test.describe("invite flow", () => {
});
test("admin invites a new user and invited user can sign in", async ({ page, browser }) => {
const testEmail = `invite-e2e-${Date.now()}@capakraken.test`;
const testEmail = `invite-e2e-${Date.now()}@nexus.test`;
// Step 1: Navigate to admin users page
await page.goto("/admin/users");
@@ -36,7 +36,7 @@ test.describe("invite flow", () => {
// Step 2: Open invite modal
await page.click('button:has-text("Invite User")');
// Wait for the modal heading — AnimatedModal does not use role="dialog"
await page.waitForSelector('text=Invite User', { state: "visible" });
await page.waitForSelector("text=Invite User", { state: "visible" });
// Step 3: Fill in invite form
await page.fill('input[type="email"]', testEmail);
@@ -45,7 +45,9 @@ test.describe("invite flow", () => {
await page.click('button:has-text("Send Invite")');
// Step 5: Wait for success message (exact text from InviteUserModal.tsx)
await expect(page.locator("text=Invitation sent successfully.")).toBeVisible({ timeout: 10_000 });
await expect(page.locator("text=Invitation sent successfully.")).toBeVisible({
timeout: 10_000,
});
// Step 6: Read invite email from Mailhog
const email = await getLatestEmailTo(testEmail, { timeoutMs: 15_000 });
@@ -28,7 +28,7 @@ test.describe("RBAC — admin routes (admin session)", () => {
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
// Seed users have planarchy.dev or nexus.dev email domains
await expect(page.locator("text=/planarchy\\.dev|capakraken\\.dev/").first()).toBeVisible({
await expect(page.locator("text=/planarchy\\.dev|nexus\\.dev/").first()).toBeVisible({
timeout: 10000,
});
});
+5 -5
View File
@@ -16,9 +16,9 @@ const webDistDirPath = resolve(webRoot, webDistDir);
const managedEnvBanner = "# Managed by apps/web/e2e/test-server.mjs";
const e2ePort = process.env.PLAYWRIGHT_TEST_PORT ?? "3110";
const e2eBaseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL ?? `http://localhost:${e2ePort}`;
const e2eAuthSecret = process.env.PLAYWRIGHT_AUTH_SECRET ?? `capakraken-e2e-${randomBytes(24).toString("hex")}`;
const e2eAuthSecret = process.env.PLAYWRIGHT_AUTH_SECRET ?? `nexus-e2e-${randomBytes(24).toString("hex")}`;
const manageWebEnvFile = process.env.PLAYWRIGHT_MANAGE_WEB_ENV_FILE === "true";
const composeProjectName = `capakraken-e2e-${process.pid}`;
const composeProjectName = `nexus-e2e-${process.pid}`;
const managedEnvKeys = [
"DATABASE_URL",
"REDIS_URL",
@@ -29,7 +29,7 @@ const managedEnvKeys = [
"NODE_ENV",
"PORT",
];
const e2eComposePrefix = "capakraken-e2e-";
const e2eComposePrefix = "nexus-e2e-";
function dockerComposeArgs(...args) {
return ["compose", "-p", composeProjectName, ...args];
@@ -256,7 +256,7 @@ async function ensureE2eDatabaseContainer() {
try {
await runQuiet(
"docker",
dockerComposeArgs("exec", "-T", "postgres-test", "pg_isready", "-U", "capakraken", "-d", "capakraken_test", "-q"),
dockerComposeArgs("exec", "-T", "postgres-test", "pg_isready", "-U", "nexus", "-d", "nexus_test", "-q"),
workspaceRoot,
);
return;
@@ -360,7 +360,7 @@ process.env.PLAYWRIGHT_DATABASE_URL = playwrightDatabaseUrl;
if (selectedTestDbPort !== undefined) {
process.env.POSTGRES_TEST_PORT = String(selectedTestDbPort);
}
process.env.CAPAKRAKEN_EXPECTED_DB_NAME = playwrightDatabaseName;
process.env.NEXUS_EXPECTED_DB_NAME = playwrightDatabaseName;
process.env.ALLOW_DESTRUCTIVE_DB_TOOLS = "true";
process.env.CONFIRM_DESTRUCTIVE_DB_NAME = playwrightDatabaseName;
process.env.NODE_ENV = process.env.NODE_ENV ?? "development";
+4 -4
View File
@@ -856,10 +856,10 @@ async function switchToResourceView(page: Page, readySelector?: string) {
async function ensureOpenDemandVisibilityEnabled(page: Page) {
await page.evaluate(() => {
const raw = window.localStorage.getItem("capakraken_prefs");
const raw = window.localStorage.getItem("nexus_prefs");
const parsed = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
window.localStorage.setItem(
"capakraken_prefs",
"nexus_prefs",
JSON.stringify({
...parsed,
showDemandProjects: true,
@@ -874,9 +874,9 @@ test.describe("Timeline", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem("capakraken_theme", JSON.stringify({ mode: "dark" }));
localStorage.setItem("nexus_theme", JSON.stringify({ mode: "dark" }));
localStorage.setItem(
"capakraken_prefs",
"nexus_prefs",
JSON.stringify({
hideCompletedProjects: true,
timelineDisplayMode: "strip",
@@ -82,7 +82,7 @@ describe("GET /api/cron/auth-anomaly-check — cron secret enforcement", () => {
const { GET } = await importRoute();
const res = await GET(makeRequest());
expect(res.status).toBe(401);
});
}, 15_000); // next/server cold-import can take >5s on the act runner
it("proceeds when verifyCronSecret returns null (allowed)", async () => {
verifyCronSecretMock.mockReturnValue(null);
+2 -2
View File
@@ -450,7 +450,7 @@ function SidebarContent({
{!sidebarCollapsed && (
<div className="overflow-hidden">
<h1 className="font-display text-xl font-semibold text-gray-900 dark:text-gray-50">
Capa<span className="text-brand-600">Kraken</span>
Nex<span className="text-brand-600">us</span>
</h1>
<p className="text-xs uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Resource & Capacity Planning
@@ -984,7 +984,7 @@ export function AppShell({
<HamburgerIcon />
</button>
<span className="ml-3 font-display text-sm font-semibold text-gray-900 dark:text-gray-50">
Capa<span className="text-brand-600">Kraken</span>
Nex<span className="text-brand-600">us</span>
</span>
</div>
<PageTransition>{children}</PageTransition>
@@ -284,7 +284,7 @@ export function TimelineProvider({
const d = new Date(sp);
if (!isNaN(d.getTime())) return d;
}
return addDays(today, -30);
return addDays(today, -90);
});
const [viewDays, setViewDays] = useState(() => {
const sp = searchParams.get("days");
@@ -310,7 +310,7 @@ export function TimelineProvider({
const d = new Date(spStart);
if (!isNaN(d.getTime())) return d;
}
return addDays(today, -30);
return addDays(today, -90);
});
const spDays = searchParams.get("days");
@@ -2,7 +2,7 @@
import { clsx } from "clsx";
import { useSession } from "next-auth/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useAllocationHistory } from "~/hooks/useAllocationHistory.js";
import { useProjectDragContext } from "~/hooks/useProjectDragContext.js";
import { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
@@ -685,15 +685,70 @@ function TimelineViewContent({
const scrollRafRef = useRef<number | null>(null);
const [scrollLeft, setScrollLeft] = useState(0);
// Pixels to add to scrollLeft after a left-extension re-render (prevents jump).
const pendingLeftCompensationPx = useRef(0);
// Flag: scroll viewport to today after the next viewStart-driven re-layout.
const pendingScrollToTodayRef = useRef(false);
// Guard reset on every real unmount (including Strict Mode fake-unmount) so the
// scroll-to-today fires correctly on remount.
const hasScrolledToTodayOnLoad = useRef(false);
useLayoutEffect(() => {
return () => {
hasScrolledToTodayOnLoad.current = false;
};
}, []);
// Scroll to today the first time the canvas is in the DOM (isInitialLoading → false).
// totalCanvasWidth is non-zero before data loads, so it can't be used as the trigger.
useLayoutEffect(() => {
if (isInitialLoading) return;
if (hasScrolledToTodayOnLoad.current) return;
const el = scrollContainerRef.current;
if (!el) return;
el.scrollLeft = toLeft(today);
hasScrolledToTodayOnLoad.current = true;
}, [isInitialLoading, toLeft, today]);
// Apply scroll compensation synchronously after the canvas grows (left-extend or Today button).
useLayoutEffect(() => {
const el = scrollContainerRef.current;
if (!el) return;
const px = pendingLeftCompensationPx.current;
if (px !== 0) {
el.scrollLeft += px;
pendingLeftCompensationPx.current = 0;
} else if (pendingScrollToTodayRef.current) {
el.scrollLeft = toLeft(today);
pendingScrollToTodayRef.current = false;
}
}, [viewStart, toLeft, today]);
// 5-year floor — no practical data exists further back; prevents runaway growth.
const minDate = useMemo(() => addDays(today, -(365 * 5)), [today]);
// ─── Navigation callbacks for TimelineToolbar ────────────────────────────
const handleNavigateBack = useCallback(
() => setViewStart((v) => addDays(v, -28)),
[setViewStart],
);
const handleNavigateToday = useCallback(
() => setViewStart(addDays(today, -30)),
[setViewStart, today],
() =>
setViewStart((v) => {
const candidate = addDays(v, -28);
return candidate < minDate ? minDate : candidate;
}),
[setViewStart, minDate],
);
const handleNavigateToday = useCallback(() => {
const el = scrollContainerRef.current;
const todayMs = new Date(today).setHours(0, 0, 0, 0);
const vsMs = new Date(viewStart).setHours(0, 0, 0, 0);
const veMs = new Date(addDays(viewStart, viewDays)).setHours(0, 0, 0, 0);
if (todayMs >= vsMs && todayMs < veMs && el) {
// Today is in range — just scroll without touching state.
el.scrollLeft = toLeft(today);
} else {
// Today is out of range — reset the window and schedule a scroll.
pendingScrollToTodayRef.current = true;
setViewStart(addDays(today, -90));
}
}, [today, viewStart, viewDays, toLeft, setViewStart]);
const handleNavigateForward = useCallback(
() => setViewStart((v) => addDays(v, 28)),
[setViewStart],
@@ -709,10 +764,31 @@ function TimelineViewContent({
const handleContainerScroll = useCallback(() => {
const el = scrollContainerRef.current;
if (!el) return;
// Right-edge: extend future range
const distanceFromRight = el.scrollWidth - el.scrollLeft - el.clientWidth;
if (distanceFromRight < CELL_WIDTH * 40) {
setViewDays((d) => d + 120);
}
// Left-edge: prepend past range and compensate scroll position so viewport doesn't jump
if (el.scrollLeft < CELL_WIDTH * 40 && viewStart > minDate) {
const daysToPrepend = 120;
// Count the exact visible days (respecting showWeekends) being prepended
let prependedVisible = 0;
for (let i = 1; i <= daysToPrepend; i++) {
const d = addDays(viewStart, -i);
const dow = d.getDay();
if (filters.showWeekends || (dow !== 0 && dow !== 6)) prependedVisible++;
}
pendingLeftCompensationPx.current = prependedVisible * CELL_WIDTH;
setViewStart((v) => {
const candidate = addDays(v, -daysToPrepend);
return candidate < minDate ? minDate : candidate;
});
setViewDays((d) => d + daysToPrepend);
}
scrollLeftRef.current = el.scrollLeft;
if (scrollRafRef.current === null) {
scrollRafRef.current = requestAnimationFrame(() => {
@@ -720,7 +796,7 @@ function TimelineViewContent({
setScrollLeft(scrollLeftRef.current);
});
}
}, [CELL_WIDTH, setViewDays]);
}, [CELL_WIDTH, setViewDays, viewStart, minDate, setViewStart, filters.showWeekends]);
// ─── Canvas mousemove — only forwards event when drag overlay is active ───
const handleMouseMove = useCallback(
+2 -2
View File
@@ -34,8 +34,8 @@ services:
# REDIS_URL to the unique compose container names so resolution is
# unambiguous regardless of attached networks.
environment:
DATABASE_URL: postgresql://capakraken:capakraken_dev@capakraken-postgres-1:5432/capakraken
REDIS_URL: redis://capakraken-redis-1:6379
DATABASE_URL: postgresql://nexus:nexus_dev@nexus-postgres-1:5432/nexus
REDIS_URL: redis://nexus-redis-1:6379
networks:
gitea_gitea:
+12 -12
View File
@@ -1,4 +1,4 @@
name: capakraken-prod
name: nexus-prod
services:
postgres:
@@ -7,8 +7,8 @@ services:
ports:
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
environment:
POSTGRES_DB: capakraken
POSTGRES_USER: capakraken
POSTGRES_DB: nexus
POSTGRES_USER: nexus
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
command: >
postgres
@@ -18,9 +18,9 @@ services:
-c log_line_prefix='%t [%p] %u@%d '
-c log_min_duration_statement=1000
volumes:
- capakraken_prod_pgdata:/var/lib/postgresql/data
- nexus_prod_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U capakraken -d capakraken"]
test: ["CMD-SHELL", "pg_isready -U nexus -d nexus"]
interval: 10s
timeout: 5s
retries: 5
@@ -34,7 +34,7 @@ services:
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru --requirepass ${REDIS_PASSWORD}
volumes:
- capakraken_prod_redis:/data
- nexus_prod_redis:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "--no-auth-warning", "ping"]
interval: 10s
@@ -49,7 +49,7 @@ services:
env_file:
- .env.production
environment:
DATABASE_URL: postgresql://capakraken:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/capakraken
DATABASE_URL: postgresql://nexus:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/nexus
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
RATE_LIMIT_BACKEND: ${RATE_LIMIT_BACKEND:-redis}
depends_on:
@@ -67,7 +67,7 @@ services:
env_file:
- .env.production
environment:
DATABASE_URL: postgresql://capakraken:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/capakraken
DATABASE_URL: postgresql://nexus:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/nexus
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
RATE_LIMIT_BACKEND: ${RATE_LIMIT_BACKEND:-redis}
NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-}
@@ -84,7 +84,7 @@ services:
start_period: 30s
volumes:
capakraken_prod_pgdata:
name: capakraken_prod_pgdata
capakraken_prod_redis:
name: capakraken_prod_redis
nexus_prod_pgdata:
name: nexus_prod_pgdata
nexus_prod_redis:
name: nexus_prod_redis
+18 -18
View File
@@ -1,4 +1,4 @@
name: capakraken
name: nexus
services:
postgres:
@@ -6,8 +6,8 @@ services:
ports:
- "5433:5432"
environment:
POSTGRES_DB: capakraken
POSTGRES_USER: capakraken
POSTGRES_DB: nexus
POSTGRES_USER: nexus
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env (any non-empty value for local dev)}
command: >
postgres
@@ -17,9 +17,9 @@ services:
-c log_line_prefix='%t [%p] %u@%d '
-c log_min_duration_statement=1000
volumes:
- capakraken_pgdata:/var/lib/postgresql/data
- nexus_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U capakraken -d capakraken"]
test: ["CMD-SHELL", "pg_isready -U nexus -d nexus"]
interval: 5s
timeout: 3s
retries: 5
@@ -61,7 +61,7 @@ services:
# Always use the Docker-internal service name. The host-level DATABASE_URL
# (localhost:5433) must not bleed into the container where "localhost" is
# the container itself, not the host.
DATABASE_URL: postgresql://capakraken:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/capakraken
DATABASE_URL: postgresql://nexus:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/nexus
REDIS_URL: redis://redis:6379
NEXTAUTH_URL: ${NEXTAUTH_URL:?NEXTAUTH_URL must be set (e.g. https://your-domain.com)}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?set NEXTAUTH_SECRET}
@@ -90,9 +90,9 @@ services:
volumes:
- .:/app
# Named volumes mask the bind-mount for generated/installed artefacts.
# Named (not anonymous) so they can be selectively removed: docker volume rm capakraken_node_modules
- capakraken_node_modules:/app/node_modules
- capakraken_next:/app/apps/web/.next
# Named (not anonymous) so they can be selectively removed: docker volume rm nexus_node_modules
- nexus_node_modules:/app/node_modules
- nexus_next:/app/apps/web/.next
profiles:
- full
@@ -101,18 +101,18 @@ services:
ports:
- "${POSTGRES_TEST_PORT:-5434}:5432"
environment:
POSTGRES_DB: capakraken_test
POSTGRES_USER: capakraken
POSTGRES_PASSWORD: capakraken_test
POSTGRES_DB: nexus_test
POSTGRES_USER: nexus
POSTGRES_PASSWORD: nexus_test
tmpfs:
- /var/lib/postgresql/data
profiles:
- test
volumes:
capakraken_pgdata:
name: capakraken_pgdata
capakraken_node_modules:
name: capakraken_node_modules
capakraken_next:
name: capakraken_next
nexus_pgdata:
name: nexus_pgdata
nexus_node_modules:
name: nexus_node_modules
nexus_next:
name: nexus_next
+4 -4
View File
@@ -1,5 +1,5 @@
# CapaKraken nginx Security Hardening
# Apply to the server block for capakraken.hartmut-noerenberg.com
# Nexus nginx Security Hardening
# Apply to the server block for nexus.hartmut-noerenberg.com
#
# References:
# - EAPPS 3.3.1.3.04 (Server Header entfernen)
@@ -113,5 +113,5 @@ log_format security '$remote_addr - $remote_user [$time_local] '
'"$http_referer" "$http_user_agent" '
'$request_time $upstream_response_time';
access_log /var/log/nginx/capakraken_access.log security;
error_log /var/log/nginx/capakraken_error.log warn;
access_log /var/log/nginx/nexus_access.log security;
error_log /var/log/nginx/nexus_error.log warn;
+1 -1
View File
@@ -20,7 +20,7 @@
"check:imports": "node ./scripts/check-workspace-imports.mjs",
"worktree:hygiene": "node ./scripts/worktree-hygiene.mjs",
"clean:next": "node ./scripts/clean-next-artifacts.mjs",
"db:doctor": "node ./scripts/db-doctor.mjs capakraken",
"db:doctor": "node ./scripts/db-doctor.mjs nexus",
"db:prisma": "node ./scripts/prisma-with-env.mjs",
"db:push": "node ./scripts/prisma-with-env.mjs db push",
"db:migrate": "node ./scripts/prisma-with-env.mjs migrate dev",
@@ -7,12 +7,14 @@ vi.mock("../lib/audit.js", () => ({
vi.mock("../router/assistant-approvals.js", () => ({
clearPendingAssistantApproval: vi.fn().mockResolvedValue(undefined),
consumePendingAssistantApproval: vi.fn(),
toApprovalPayload: vi.fn((approval: { id: string; toolName: string; summary: string }, status: string) => ({
id: approval.id,
toolName: approval.toolName,
summary: approval.summary,
status,
})),
toApprovalPayload: vi.fn(
(approval: { id: string; toolName: string; summary: string }, status: string) => ({
id: approval.id,
toolName: approval.toolName,
summary: approval.summary,
status,
}),
),
}));
vi.mock("../router/assistant-confirmation.js", () => ({
@@ -39,16 +41,10 @@ import {
clearPendingAssistantApproval,
consumePendingAssistantApproval,
} from "../router/assistant-approvals.js";
import {
canExecuteMutationTool,
isCancellationReply,
} from "../router/assistant-confirmation.js";
import { canExecuteMutationTool, isCancellationReply } from "../router/assistant-confirmation.js";
import { buildAssistantInsight } from "../router/assistant-insights.js";
import { handlePendingAssistantApproval } from "../router/assistant-chat-response.js";
import {
readToolError,
readToolSuccessMessage,
} from "../router/assistant-tool-results.js";
import { readToolError, readToolSuccessMessage } from "../router/assistant-tool-results.js";
import { executeTool } from "../router/assistant-tools.js";
function createPendingApproval() {
@@ -57,14 +53,16 @@ function createPendingApproval() {
userId: "user_1",
conversationId: "conv_1",
toolName: "create_project",
toolArguments: "{\"name\":\"Apollo\"}",
toolArguments: '{"name":"Apollo"}',
summary: "create project (name=Apollo)",
createdAt: Date.now(),
expiresAt: Date.now() + 60_000,
};
}
function createHandleInput(overrides: Partial<Parameters<typeof handlePendingAssistantApproval>[0]> = {}) {
function createHandleInput(
overrides: Partial<Parameters<typeof handlePendingAssistantApproval>[0]> = {},
) {
return {
db: {} as never,
dbUserId: "user_1",
@@ -81,7 +79,10 @@ function createHandleInput(overrides: Partial<Parameters<typeof handlePendingAss
pendingApproval: createPendingApproval(),
lastUserMessage: { role: "user" as const, content: "ja" },
messages: [
{ role: "assistant" as const, content: "__CAPAKRAKEN_CONFIRM__ create project (name=Apollo). Bitte bestätigen." },
{
role: "assistant" as const,
content: "__NEXUS_CONFIRM__ create project (name=Apollo). Bitte bestätigen.",
},
{ role: "user" as const, content: "ja" },
],
collectedActions: [],
@@ -103,9 +104,11 @@ describe("assistant pending approval handling", () => {
it("cancels pending approvals when the user aborts", async () => {
vi.mocked(isCancellationReply).mockReturnValue(true);
const result = await handlePendingAssistantApproval(createHandleInput({
lastUserMessage: { role: "user", content: "nein, abbrechen" },
}));
const result = await handlePendingAssistantApproval(
createHandleInput({
lastUserMessage: { role: "user", content: "nein, abbrechen" },
}),
);
expect(result).toMatchObject({
response: {
@@ -127,7 +130,7 @@ describe("assistant pending approval handling", () => {
summary: "create project (name=Apollo, status=DRAFT)",
} as never);
vi.mocked(executeTool).mockResolvedValue({
content: "{\"message\":\"Projekt Apollo angelegt\"}",
content: '{"message":"Projekt Apollo angelegt"}',
data: { message: "Projekt Apollo angelegt" },
action: { type: "refresh" },
} as never);
@@ -148,29 +151,35 @@ describe("assistant pending approval handling", () => {
status: "approved",
},
actions: [{ type: "refresh" }],
insights: [{
kind: "holiday_region",
title: "Berlin",
}],
insights: [
{
kind: "holiday_region",
title: "Berlin",
},
],
},
});
expect(executeTool).toHaveBeenCalledWith(
"create_project",
"{\"name\":\"Apollo\"}",
'{"name":"Apollo"}',
expect.objectContaining({ userId: "user_1" }),
);
expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({
entityName: "create_project",
summary: "AI executed previously approved tool: create_project",
}));
expect(createAuditEntry).toHaveBeenCalledWith(
expect.objectContaining({
entityName: "create_project",
summary: "AI executed previously approved tool: create_project",
}),
);
});
it("does nothing when the user reply is not a valid confirmation", async () => {
vi.mocked(canExecuteMutationTool).mockReturnValue(false);
const result = await handlePendingAssistantApproval(createHandleInput({
lastUserMessage: { role: "user", content: "vielleicht" },
}));
const result = await handlePendingAssistantApproval(
createHandleInput({
lastUserMessage: { role: "user", content: "vielleicht" },
}),
);
expect(result).toBeNull();
expect(consumePendingAssistantApproval).not.toHaveBeenCalled();
@@ -105,10 +105,10 @@ describe("RBAC cache Redis pub/sub (#57)", () => {
// Simulate a peer instance publishing an invalidation: grab any
// subscriber on the channel and fire the event as if Redis delivered it.
const subs = channelSubscribers.get("capakraken:rbac-invalidate");
const subs = channelSubscribers.get("nexus:rbac-invalidate");
expect(subs).toBeDefined();
expect(subs!.size).toBeGreaterThanOrEqual(1);
for (const sub of subs!) sub.emit("message", "capakraken:rbac-invalidate", "1");
for (const sub of subs!) sub.emit("message", "nexus:rbac-invalidate", "1");
// Next load must hit the DB again.
await loadRoleDefaults();
@@ -126,6 +126,6 @@ describe("RBAC cache Redis pub/sub (#57)", () => {
const newPublishes = publishCalls.slice(countBefore);
expect(newPublishes.length).toBe(1);
expect(newPublishes[0]!.channel).toBe("capakraken:rbac-invalidate");
expect(newPublishes[0]!.channel).toBe("nexus:rbac-invalidate");
});
});
@@ -24,7 +24,7 @@ describe("assertWebhookUrlAllowed — SSRF guard", () => {
it("allows an HTTPS URL with a path and query string", async () => {
await expect(
assertWebhookUrlAllowed("https://hooks.external.io/events?source=capakraken"),
assertWebhookUrlAllowed("https://hooks.external.io/events?source=nexus"),
).resolves.toBeUndefined();
});
+3 -3
View File
@@ -22,15 +22,15 @@ export function getAppBaseUrl(): string {
if (process.env["NODE_ENV"] === "production") {
throw new Error(
"NEXTAUTH_URL must be set in production — email links will contain localhost otherwise. " +
"Set it to the public URL of this app (e.g. https://capakraken.example.com).",
"Set it to the public URL of this app (e.g. https://nexus.example.com).",
);
}
if (!warned) {
warned = true;
console.warn(
"[capakraken] NEXTAUTH_URL is not set — falling back to http://localhost:3100 for email links. " +
"Set NEXTAUTH_URL in your .env to suppress this warning.",
"[nexus] NEXTAUTH_URL is not set — falling back to http://localhost:3100 for email links. " +
"Set NEXTAUTH_URL in your .env to suppress this warning.",
);
}
+2 -2
View File
@@ -44,13 +44,13 @@ const redactConfig = { paths: REDACT_PATHS, censor: "[REDACTED]" };
export const logger = isProduction
? pino({
level: LOG_LEVEL,
base: { service: "capakraken-api" },
base: { service: "nexus-api" },
redact: redactConfig,
})
: pino(
{
level: LOG_LEVEL,
base: { service: "capakraken-api" },
base: { service: "nexus-api" },
redact: redactConfig,
formatters: {
level(label: string) {
+1 -1
View File
@@ -31,7 +31,7 @@ type RateLimiterBackend = {
reset: () => Promise<void>;
};
const DEFAULT_REDIS_KEY_PREFIX = "capakraken:ratelimit";
const DEFAULT_REDIS_KEY_PREFIX = "nexus:ratelimit";
const DEFAULT_REDIS_BACKEND = process.env["RATE_LIMIT_BACKEND"] as RateLimitBackendMode | undefined;
const DEFAULT_REDIS_URL = process.env["REDIS_URL"]?.trim();
const warnedRedisFailures = new Set<string>();
+1 -1
View File
@@ -201,7 +201,7 @@ const REDIS_URL =
: (() => {
throw new Error("REDIS_URL required in production");
})());
const CHANNEL = "capakraken:sse";
const CHANNEL = "nexus:sse";
let publisher: Redis | null = null;
let subscriber: Redis | null = null;
+1 -1
View File
@@ -42,7 +42,7 @@ const ROLE_DEFAULTS_TTL = 10_000;
// We publish a single invalidate message per change; every node subscribes and
// clears its local cache on receipt. Failure to publish/subscribe is logged
// but never thrown — the TTL above is the fall-back.
const RBAC_INVALIDATE_CHANNEL = "capakraken:rbac-invalidate";
const RBAC_INVALIDATE_CHANNEL = "nexus:rbac-invalidate";
let _rbacPublisher: Redis | null = null;
let _rbacSubscriber: Redis | null = null;
+1 -1
View File
@@ -8,7 +8,7 @@
"./client": "./src/client.ts"
},
"scripts": {
"db:doctor": "node ../../scripts/db-doctor.mjs capakraken",
"db:doctor": "node ../../scripts/db-doctor.mjs nexus",
"db:push": "node ../../scripts/prisma-with-env.mjs db push --schema ./prisma/schema.prisma",
"db:migrate": "node ../../scripts/prisma-with-env.mjs migrate dev --schema ./prisma/schema.prisma",
"db:migrate:deploy": "node ../../scripts/prisma-with-env.mjs migrate deploy --schema ./prisma/schema.prisma",
+1 -1
View File
@@ -1,4 +1,4 @@
// CapaKraken — Prisma Schema
// Nexus — Prisma Schema
// All monetary values stored as integer cents to avoid float precision issues.
generator client {
+19 -19
View File
@@ -22,34 +22,34 @@ test.afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
test("assertDestructiveDbAllowed allows an explicitly confirmed disposable capakraken test database", () => {
test("assertDestructiveDbAllowed allows an explicitly confirmed disposable nexus test database", () => {
setEnv({
DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken_test",
DATABASE_URL: "postgresql://tester:secret@localhost:5432/nexus_test",
ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
CONFIRM_DESTRUCTIVE_DB_NAME: "capakraken_test",
CONFIRM_DESTRUCTIVE_DB_NAME: "nexus_test",
});
const target = assertDestructiveDbAllowed({
commandName: "db:test",
allowedDatabaseNames: ["capakraken_test"],
allowedDatabaseNames: ["nexus_test"],
});
assert.equal(target.databaseName, "capakraken_test");
assert.equal(target.databaseName, "nexus_test");
assert.equal(target.hostname, "localhost");
});
test("assertDestructiveDbAllowed rejects protected live database names even if allowlisted", () => {
setEnv({
DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken",
DATABASE_URL: "postgresql://tester:secret@localhost:5432/nexus",
ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
CONFIRM_DESTRUCTIVE_DB_NAME: "capakraken",
CONFIRM_DESTRUCTIVE_DB_NAME: "nexus",
});
assert.throws(
() =>
assertDestructiveDbAllowed({
commandName: "db:test",
allowedDatabaseNames: ["capakraken"],
allowedDatabaseNames: ["nexus"],
}),
/explicitly protected/u,
);
@@ -57,7 +57,7 @@ test("assertDestructiveDbAllowed rejects protected live database names even if a
test("assertDestructiveDbAllowed rejects missing confirmation", () => {
setEnv({
DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken_e2e",
DATABASE_URL: "postgresql://tester:secret@localhost:5432/nexus_e2e",
ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
CONFIRM_DESTRUCTIVE_DB_NAME: "wrong_db",
});
@@ -66,24 +66,24 @@ test("assertDestructiveDbAllowed rejects missing confirmation", () => {
() =>
assertDestructiveDbAllowed({
commandName: "db:test",
allowedDatabaseNames: ["capakraken_e2e"],
allowedDatabaseNames: ["nexus_e2e"],
}),
/CONFIRM_DESTRUCTIVE_DB_NAME=capakraken_e2e/u,
/CONFIRM_DESTRUCTIVE_DB_NAME=nexus_e2e/u,
);
});
test("assertDestructiveDbAllowed rejects missing destructive allow flag", () => {
setEnv({
DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken_ci",
DATABASE_URL: "postgresql://tester:secret@localhost:5432/nexus_ci",
ALLOW_DESTRUCTIVE_DB_TOOLS: undefined,
CONFIRM_DESTRUCTIVE_DB_NAME: "capakraken_ci",
CONFIRM_DESTRUCTIVE_DB_NAME: "nexus_ci",
});
assert.throws(
() =>
assertDestructiveDbAllowed({
commandName: "db:test",
allowedDatabaseNames: ["capakraken_ci"],
allowedDatabaseNames: ["nexus_ci"],
}),
/ALLOW_DESTRUCTIVE_DB_TOOLS=true/u,
);
@@ -99,19 +99,19 @@ test("assertSafeSeedTarget rejects unexpected legacy disposable databases", () =
assert.throws(() => assertSafeSeedTarget("db:seed"), /not in the destructive-tool allowlist/u);
});
test("assertNexusDbTarget accepts non-destructive capakraken targets", () => {
test("assertNexusDbTarget accepts non-destructive nexus targets", () => {
setEnv({
DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken_dev",
DATABASE_URL: "postgresql://tester:secret@localhost:5432/nexus_dev",
});
const target = assertNexusDbTarget("db:seed:holidays");
assert.equal(target.databaseName, "capakraken_dev");
assert.equal(target.databaseName, "nexus_dev");
});
test("assertNexusDbTarget rejects legacy non-capakraken targets", () => {
test("assertNexusDbTarget rejects legacy non-nexus targets", () => {
setEnv({
DATABASE_URL: "postgresql://tester:secret@localhost:5432/legacy_non_capakraken",
DATABASE_URL: "postgresql://tester:secret@localhost:5432/legacy_non_nexus",
});
assert.throws(() => assertNexusDbTarget("db:seed:holidays"), /not a valid Nexus target/u);
+1 -1
View File
@@ -6,7 +6,7 @@ interface DestructiveGuardOptions {
requireConfirmation?: boolean;
}
const PROTECTED_DATABASE_NAMES = new Set(["capakraken"]);
const PROTECTED_DATABASE_NAMES = new Set(["nexus"]);
export function parseDatabaseUrl(rawUrl: string) {
const parsed = new URL(rawUrl);
+1 -1
View File
@@ -157,7 +157,7 @@ async function main() {
const options = parseArgs(process.argv.slice(2));
const target = assertDestructiveDbAllowed({
commandName: "db:reset:dispo",
allowedDatabaseNames: ["capakraken_test", "capakraken_e2e", "capakraken_ci"],
allowedDatabaseNames: ["nexus_test", "nexus_e2e", "nexus_ci"],
});
const databaseUrl = process.env.DATABASE_URL;
+2 -2
View File
@@ -4,7 +4,7 @@ import {
parseDatabaseUrl,
} from "./destructive-db-guard.js";
const TEST_DATABASE_NAMES = ["capakraken_test", "capakraken_e2e", "capakraken_ci"];
const TEST_DATABASE_NAMES = ["nexus_test", "nexus_e2e", "nexus_ci"];
export function assertSafeSeedTarget(commandName: string) {
return assertDestructiveDbAllowed({
@@ -24,7 +24,7 @@ export function assertNexusDbTarget(commandName: string) {
const target = parseDatabaseUrl(rawUrl);
if (!target.databaseName.startsWith("capakraken")) {
if (!target.databaseName.startsWith("nexus")) {
throw new Error(
`${commandName} aborted: database '${target.databaseName}' is not a valid Nexus target. Target=${formatTarget(target)}`,
);
+1 -1
View File
@@ -2372,7 +2372,7 @@ async function main() {
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
const email = `${eid}@capakraken.example`;
const email = `${eid}@nexus.example`;
const lcrCents = Math.round(lcr * 100);
const ucrCents = Math.round(ucr * 100);
const availability = computeAvailability(fraction, availDays);
+2 -2
View File
@@ -23,7 +23,7 @@ function toDisplayName(eid) {
}
function toEmail(eid) {
return `${eid}@capakraken.example`;
return `${eid}@nexus.example`;
}
function computeSkillLabel(chapter, typeOfWork) {
@@ -150,7 +150,7 @@ async function main() {
{
col: 15, // O
header: "Email\n(generated)",
doc: "Generated email: firstname.lastname@capakraken.example. Required unique field in Nexus. Replace with real email in production.",
doc: "Generated email: firstname.lastname@nexus.example. Required unique field in Nexus. Replace with real email in production.",
},
{
col: 16, // P
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# restart.sh — Rebuild the CapaKraken app container from scratch.
# restart.sh — Rebuild the Nexus app container from scratch.
#
# When to use:
# - After changing pnpm-lock.yaml (new/removed dependencies)
@@ -40,7 +40,7 @@ docker compose --profile "$PROFILE" stop app 2>/dev/null || true
if $CLEAN; then
echo "==> Removing stale node_modules and .next volumes..."
docker volume rm capakraken_node_modules capakraken_next 2>/dev/null || true
docker volume rm nexus_node_modules nexus_next 2>/dev/null || true
fi
echo "==> Rebuilding and starting ($( [[ -z "$SERVICES" ]] && echo "all services" || echo "$SERVICES" ))..."
Binary file not shown.
+2 -2
View File
@@ -5,9 +5,9 @@
import { createRequire } from "module";
import { writeFileSync, mkdirSync } from "fs";
const require = createRequire(import.meta.url);
const XLSX = require("/home/hartmut/Documents/Copilot/capakraken/node_modules/.pnpm/xlsx@0.18.5/node_modules/xlsx/xlsx.js");
const XLSX = require("/home/hartmut/Documents/Copilot/nexus/node_modules/.pnpm/xlsx@0.18.5/node_modules/xlsx/xlsx.js");
const OUT_DIR = "/home/hartmut/Documents/Copilot/capakraken/samples/skillmatrix_dummydata";
const OUT_DIR = "/home/hartmut/Documents/Copilot/nexus/samples/skillmatrix_dummydata";
mkdirSync(OUT_DIR, { recursive: true });
// ─── Skill Definitions ─────────────────────────────────────────────────────
+1 -1
View File
@@ -719,7 +719,7 @@ export const rules = [
],
forbidden: [
{
pattern: /pnpm --filter @capakraken\/db exec prisma generate/,
pattern: /pnpm --filter @nexus\/db exec prisma generate/,
message: "CI must not call prisma generate directly outside the workspace wrapper",
},
],
+2 -2
View File
@@ -4,7 +4,7 @@ export function formatDatabaseTarget(parsedUrl, databaseName) {
return `${parsedUrl.protocol}//${decodeURIComponent(parsedUrl.username)}@${parsedUrl.hostname}${parsedUrl.port ? `:${parsedUrl.port}` : ""}/${databaseName}`;
}
export function inspectDatabaseUrl(rawUrl, expectedDatabase = "capakraken") {
export function inspectDatabaseUrl(rawUrl, expectedDatabase = "nexus") {
if (!rawUrl) {
throw new Error("DATABASE_URL is not configured.");
}
@@ -82,5 +82,5 @@ export function shouldGuardPrismaCommand(args) {
}
export function getExpectedDatabaseName() {
return process.env.CAPAKRAKEN_EXPECTED_DB_NAME?.trim() || "capakraken";
return process.env.NEXUS_EXPECTED_DB_NAME?.trim() || "nexus";
}
+8 -8
View File
@@ -6,21 +6,21 @@ import {
} from "./db-target-guard.mjs";
describe("db target guard", () => {
it("accepts the expected capakraken database target", () => {
it("accepts the expected nexus database target", () => {
const result = inspectDatabaseUrl(
"postgresql://capakraken:secret@localhost:5432/capakraken",
"capakraken",
"postgresql://nexus:secret@localhost:5432/nexus",
"nexus",
);
assert.equal(result.databaseName, "capakraken");
assert.equal(result.expectedDatabase, "capakraken");
assert.equal(result.target, "postgresql://capakraken@localhost:5432/capakraken");
assert.equal(result.databaseName, "nexus");
assert.equal(result.expectedDatabase, "nexus");
assert.equal(result.target, "postgresql://nexus@localhost:5432/nexus");
});
it("rejects a mismatched database target", () => {
assert.throws(
() => inspectDatabaseUrl("postgresql://capakraken:secret@localhost:5432/planarchy", "capakraken"),
/Unexpected database target 'planarchy'\. Expected 'capakraken'\./,
() => inspectDatabaseUrl("postgresql://nexus:secret@localhost:5432/planarchy", "nexus"),
/Unexpected database target 'planarchy'\. Expected 'nexus'\./,
);
});
+5 -5
View File
@@ -10,8 +10,8 @@
* node scripts/export-dev-seed.mjs
*
* Requirements:
* - The capakraken-postgres-1 Docker container must be running
* - DATABASE_URL must point to a local capakraken database
* - The nexus-postgres-1 Docker container must be running
* - DATABASE_URL must point to a local nexus database
*/
import { execSync, spawnSync } from "node:child_process";
@@ -48,7 +48,7 @@ if (!["localhost", "127.0.0.1", "::1"].includes(host)) {
// ── Docker container check ────────────────────────────────────────────────────
const CONTAINER = "capakraken-postgres-1";
const CONTAINER = "nexus-postgres-1";
const containerCheck = spawnSync("docker", ["inspect", "--format={{.State.Running}}", CONTAINER], {
encoding: "utf8",
});
@@ -83,8 +83,8 @@ const excludeFlags = EXCLUDE_TABLES.flatMap((t) => ["--exclude-table-data", `pub
// ── Run pg_dump inside the Docker container ───────────────────────────────────
const DB_USER = decodeURIComponent(parsedUrl.username) || "capakraken";
const DB_NAME = parsedUrl.pathname.replace(/^\/+/, "") || "capakraken";
const DB_USER = decodeURIComponent(parsedUrl.username) || "nexus";
const DB_NAME = parsedUrl.pathname.replace(/^\/+/, "") || "nexus";
const DB_PORT = parsedUrl.port || "5432";
console.log(`🔍 Exporting ${DB_USER}@${host}:${DB_PORT}/${DB_NAME}`);
+2 -2
View File
@@ -2,8 +2,8 @@
# Remove SUPERUSER from the application database user
# Run after initial setup: bash scripts/harden-postgres.sh
DB_USER="${DB_USER:-capakraken}"
DB_NAME="${DB_NAME:-capakraken}"
DB_USER="${DB_USER:-nexus}"
DB_NAME="${DB_NAME:-nexus}"
echo "Hardening PostgreSQL for $DB_USER..."
+5 -5
View File
@@ -10,8 +10,8 @@
* node scripts/import-dev-seed.mjs
*
* Requirements:
* - The capakraken-postgres-1 Docker container must be running
* - DATABASE_URL must point to a local capakraken database
* - The nexus-postgres-1 Docker container must be running
* - DATABASE_URL must point to a local nexus database
* - dev-seed.sql must exist (run export-dev-seed.mjs first)
*/
@@ -46,13 +46,13 @@ if (!["localhost", "127.0.0.1", "::1"].includes(host)) {
process.exit(1);
}
const DB_USER = decodeURIComponent(parsedUrl.username) || "capakraken";
const DB_NAME = parsedUrl.pathname.replace(/^\/+/, "") || "capakraken";
const DB_USER = decodeURIComponent(parsedUrl.username) || "nexus";
const DB_NAME = parsedUrl.pathname.replace(/^\/+/, "") || "nexus";
const DB_PORT = parsedUrl.port || "5432";
// ── Docker container check ────────────────────────────────────────────────────
const CONTAINER = "capakraken-postgres-1";
const CONTAINER = "nexus-postgres-1";
const containerCheck = spawnSync("docker", ["inspect", "--format={{.State.Running}}", CONTAINER], {
encoding: "utf8",
});
+1 -1
View File
@@ -27,7 +27,7 @@ if (shouldGuardPrismaCommand(prismaArgs)) {
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
console.error("Refusing to run Prisma against an unexpected database target.");
console.error("Use the repo env files for Nexus, or set CAPAKRAKEN_EXPECTED_DB_NAME explicitly if you intentionally target another database.");
console.error("Use the repo env files for Nexus, or set NEXUS_EXPECTED_DB_NAME explicitly if you intentionally target another database.");
process.exit(1);
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail
cd "$(dirname "$0")/.."
echo "Restarting CapaKraken..."
echo "Restarting Nexus..."
echo ""
# Stop
+3 -3
View File
@@ -5,7 +5,7 @@ cd "$(dirname "$0")/.."
APP_PORT="${APP_PORT:-3100}"
APP_CONTAINER="${APP_CONTAINER:-$(docker compose --profile full ps -q app 2>/dev/null | head -1)}"
echo "Starting CapaKraken..."
echo "Starting Nexus..."
# 1. Start Docker services
echo " Starting PostgreSQL + Redis..."
@@ -15,7 +15,7 @@ sleep 2
# 2. Wait for PostgreSQL to be healthy
echo " Waiting for PostgreSQL..."
for i in {1..30}; do
if docker compose exec -T postgres pg_isready -U capakraken -d capakraken -q 2>/dev/null; then
if docker compose exec -T postgres pg_isready -U nexus -d nexus -q 2>/dev/null; then
break
fi
sleep 1
@@ -34,7 +34,7 @@ echo " Waiting for server (up to 90s)..."
for i in {1..90}; do
if curl -sf "http://localhost:${APP_PORT}/api/health" > /dev/null 2>&1; then
echo ""
echo "CapaKraken is running!"
echo "Nexus is running!"
curl -s "http://localhost:${APP_PORT}/api/ready" | python3 -m json.tool 2>/dev/null || curl -s "http://localhost:${APP_PORT}/api/ready"
echo ""
echo " URL: http://localhost:${APP_PORT}"
+2 -2
View File
@@ -2,7 +2,7 @@
set -euo pipefail
cd "$(dirname "$0")/.."
echo "Stopping CapaKraken..."
echo "Stopping Nexus..."
# 1. Stop any legacy local dev server
if [ -f /tmp/nexus-dev.pid ]; then
@@ -28,4 +28,4 @@ echo " Stopping app, PostgreSQL and Redis..."
docker compose --profile full stop app postgres redis 2>/dev/null || true
echo ""
echo "CapaKraken stopped."
echo "Nexus stopped."
+1 -1
View File
@@ -9,7 +9,7 @@ import {
function createGitStub(statusOutput) {
return (args) => {
if (args[0] === "rev-parse" && args[1] === "--show-toplevel") {
return "/tmp/capakraken\n";
return "/tmp/nexus\n";
}
if (args[0] === "rev-parse" && args[1] === "--abbrev-ref") {
return "main\n";
+2 -2
View File
@@ -1,5 +1,5 @@
APP_IMAGE=ghcr.io/example/capakraken-app:sha-abc123
MIGRATOR_IMAGE=ghcr.io/example/capakraken-migrator:sha-abc123
APP_IMAGE=ghcr.io/example/nexus-app:sha-abc123
MIGRATOR_IMAGE=ghcr.io/example/nexus-migrator:sha-abc123
APP_HOST_PORT=3000
GHCR_USERNAME=
GHCR_TOKEN=
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env bash
#
# Phase 3 cutover: migrate the running stack from `capakraken` (DB, role,
# volumes, compose project) to `nexus`. Intended to be run inside a brief
# maintenance window — the app is stopped for the duration.
#
# Idempotency: each step checks the current state. Re-running after a
# partial run only does the missing pieces. The script never DROPS the
# legacy `capakraken` DB or role — that's a manual decision after a
# stability window.
#
# Usage:
# ./tooling/migrate/rename-to-nexus.sh dev # against docker-compose.yml
# ./tooling/migrate/rename-to-nexus.sh prod # against docker-compose.prod.yml
#
# Requires:
# - POSTGRES_PASSWORD set in env (or .env file picked up by docker compose)
# - docker compose CLI v2+
# - the `nexus` branch of code already checked out (compose files reference nexus_*)
set -euo pipefail
MODE="${1:-dev}"
case "$MODE" in
dev)
COMPOSE_FILE=docker-compose.yml
OLD_PROJECT=capakraken
NEW_PROJECT=nexus
VOLUMES=(pgdata node_modules next)
;;
prod)
COMPOSE_FILE=docker-compose.prod.yml
OLD_PROJECT=capakraken-prod
NEW_PROJECT=nexus-prod
VOLUMES=(prod_pgdata prod_redis)
;;
*)
echo "Usage: $0 [dev|prod]" >&2
exit 2
;;
esac
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
echo "POSTGRES_PASSWORD must be set in the environment." >&2
exit 2
fi
DUMP_FILE="/tmp/capakraken-pre-rename-$(date +%Y%m%d-%H%M%S).sql"
echo "=== Phase 3 cutover ($MODE) ==="
echo "compose: $COMPOSE_FILE project: $OLD_PROJECT$NEW_PROJECT dump: $DUMP_FILE"
echo
#───────────────────────────────────────────────────────────────────────────────
# 1. Stop the app (DB stays up so we can dump it).
#───────────────────────────────────────────────────────────────────────────────
echo "[1/7] Stopping app container under old project name..."
docker compose -p "$OLD_PROJECT" -f "$COMPOSE_FILE" stop app 2>/dev/null || true
#───────────────────────────────────────────────────────────────────────────────
# 2. Capture row counts for verification.
#───────────────────────────────────────────────────────────────────────────────
echo "[2/7] Capturing pre-rename row counts..."
PRE_COUNTS=$(docker compose -p "$OLD_PROJECT" -f "$COMPOSE_FILE" exec -T postgres \
psql -U capakraken -d capakraken -t -c \
"SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY relname;")
echo "$PRE_COUNTS" | head -20
echo "..."
#───────────────────────────────────────────────────────────────────────────────
# 3. Dump existing DB.
#───────────────────────────────────────────────────────────────────────────────
echo "[3/7] pg_dump capakraken → $DUMP_FILE..."
docker compose -p "$OLD_PROJECT" -f "$COMPOSE_FILE" exec -T postgres \
pg_dump -U capakraken -d capakraken --clean --if-exists > "$DUMP_FILE"
echo "Dump size: $(du -h "$DUMP_FILE" | cut -f1)"
#───────────────────────────────────────────────────────────────────────────────
# 4. Create new role + DB inside the running postgres container.
#───────────────────────────────────────────────────────────────────────────────
echo "[4/7] Creating nexus role + database..."
docker compose -p "$OLD_PROJECT" -f "$COMPOSE_FILE" exec -T postgres \
psql -U capakraken -d postgres <<SQL
DO \$\$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='nexus') THEN
CREATE ROLE nexus LOGIN PASSWORD '${POSTGRES_PASSWORD}';
END IF;
END
\$\$;
SELECT 'CREATE DATABASE nexus OWNER nexus'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname='nexus')
\gexec
SQL
#───────────────────────────────────────────────────────────────────────────────
# 5. Restore into new DB.
#───────────────────────────────────────────────────────────────────────────────
echo "[5/7] Restoring dump into nexus DB..."
# Replace OWNER directives so restored objects belong to `nexus`.
sed 's/OWNER TO capakraken/OWNER TO nexus/g' "$DUMP_FILE" | \
docker compose -p "$OLD_PROJECT" -f "$COMPOSE_FILE" exec -T postgres \
psql -U nexus -d nexus
#───────────────────────────────────────────────────────────────────────────────
# 6. Volume rename (offline copy).
#───────────────────────────────────────────────────────────────────────────────
echo "[6/7] Stopping old project and copying volumes..."
docker compose -p "$OLD_PROJECT" -f "$COMPOSE_FILE" down --remove-orphans
for v in "${VOLUMES[@]}"; do
src="${OLD_PROJECT//-/_}_${v}"
dst="${NEW_PROJECT//-/_}_${v}"
# Skip dev-mode app overlays (node_modules / next) — they regenerate on startup.
if [ "$MODE" = "dev" ] && { [ "$v" = "node_modules" ] || [ "$v" = "next" ]; }; then
echo " Skipping $v (regenerated on next boot)"
continue
fi
if ! docker volume inspect "$src" >/dev/null 2>&1; then
echo " Source volume $src missing — skip"
continue
fi
if docker volume inspect "$dst" >/dev/null 2>&1; then
echo " Destination volume $dst already exists — skip"
continue
fi
echo " $src$dst"
docker volume create "$dst" >/dev/null
docker run --rm \
-v "${src}:/from:ro" \
-v "${dst}:/to" \
alpine sh -c "cd /from && cp -a . /to/"
done
#───────────────────────────────────────────────────────────────────────────────
# 7. Bring up under new compose project name.
#───────────────────────────────────────────────────────────────────────────────
echo "[7/7] Starting stack under new project name '$NEW_PROJECT'..."
PROFILE=""
[ "$MODE" = "dev" ] && PROFILE="--profile full"
# shellcheck disable=SC2086
docker compose -p "$NEW_PROJECT" -f "$COMPOSE_FILE" $PROFILE up -d
echo
echo "Waiting 15s for postgres to be ready..."
sleep 15
echo "=== Verification ==="
POST_COUNTS=$(docker compose -p "$NEW_PROJECT" -f "$COMPOSE_FILE" exec -T postgres \
psql -U nexus -d nexus -t -c \
"SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY relname;")
echo "Post-rename row counts (sample):"
echo "$POST_COUNTS" | head -20
if diff <(echo "$PRE_COUNTS") <(echo "$POST_COUNTS") >/dev/null; then
echo "✓ Row counts match — migration verified."
else
echo "⚠ Row counts differ — review diff:"
diff <(echo "$PRE_COUNTS") <(echo "$POST_COUNTS") | head
fi
echo
echo "Done. Old DB+role retained for rollback. Dump kept at $DUMP_FILE."
echo "After a stability window, drop with:"
echo " docker compose -p $NEW_PROJECT -f $COMPOSE_FILE exec postgres psql -U nexus -d postgres \\"
echo " -c 'DROP DATABASE capakraken; DROP ROLE capakraken;'"