diff --git a/CLAUDE.md b/CLAUDE.md index ac1a939..34551be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,9 @@ npm run keepalive-status # is the deployed container holding its session? npm run session-diagnose # ~2.5h: measure what actually ends the session ``` +`docker-compose.override.yml` is local-only and publishes the server on +`127.0.0.1:8080` and Postgres on `127.0.0.1:55432`; see `docs/LOCAL.md`. + `probe` and `smoke` hit the live Schulcloud and need a valid `.env`. Both are read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`, `src/mcp/` or `src/http/` — the unit tests cover only pure functions. @@ -39,7 +42,9 @@ fallback nobody exercises. Store tests need a database and skip without one: `TEST_DATABASE_URL=postgresql://… npm test`. They use a real Postgres on purpose — the generation/diff semantics are entirely SQL, so a mock would test -nothing. +nothing. **They `TRUNCATE`**, and refuse to run unless the database name +contains "test"; that guard exists because pointing them at the dev database +once put fixtures into real data. ## Architecture diff --git a/Dockerfile b/Dockerfile index db457d3..7027526 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,7 +30,14 @@ COPY --from=deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist COPY package.json ./ -# node:alpine ships an unprivileged `node` user; the process never writes to disk. +# The mirror is the one writable path. Creating it in the image with the right +# owner matters: Docker initialises a new named volume from the image directory, +# including its ownership, so without this the volume lands root-owned and the +# unprivileged user gets EACCES on every write — with the failure recorded per +# file rather than crashing, which makes it easy to miss. +RUN mkdir -p /data/mirror && chown -R node:node /data + +# node:alpine ships an unprivileged `node` user. USER node EXPOSE 8080 diff --git a/README.md b/README.md index 2dce2e8..c44a016 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,9 @@ npm run build npm run probe # verifies the token and API against the live instance ``` -Then either deploy it as a remote connector, or point Claude Code at -`dist/bin/stdio.js`. Both paths are in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). +To try it on your own machine — Docker stack, Claude Code, and the CLI — follow +[docs/LOCAL.md](docs/LOCAL.md). To put it on a Pi behind Caddy, see +[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). Getting `TSC_JWT_COOKIE` takes four clicks in DevTools and then lasts 30 days — provided you close the Schulportal window afterwards. See diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..ab63a59 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,20 @@ +# Local development only. Compose merges this automatically; it is not used on +# the Pi, where Caddy reaches the container over the shared Docker network and +# no host port is published. +services: + postgres: + ports: + # Loopback only. Lets `npm test` reach it via TEST_DATABASE_URL and lets + # the stdio server use the same index as the HTTP one. + - "127.0.0.1:55432:5432" + + schulcloud-mcp: + environment: + # The bundled postgres service, reachable by name on the compose network. + DATABASE_URL: postgresql://schulcloud:${POSTGRES_PASSWORD:-schulcloud}@postgres:5432/schulcloud + # Crawl on demand while developing rather than every 6 hours. + CRAWL_INTERVAL_MS: 0 + ports: + # Bound to loopback: this exposes the account's data, and the bearer token + # is the only thing in front of it. + - "127.0.0.1:8080:8080" diff --git a/docs/LOCAL.md b/docs/LOCAL.md new file mode 100644 index 0000000..40cf515 --- /dev/null +++ b/docs/LOCAL.md @@ -0,0 +1,109 @@ +# Running it locally + +Everything runs on your machine: Postgres, the server, the CLI, and Claude Code +talking to all of it. Only Schulcloud itself is remote. + +## Start the stack + +```bash +cp .env.example .env # fill in TSC_URL, TSC_JWT_COOKIE, MCP_AUTH_TOKEN +npm install && npm run build +docker compose up -d --build +curl -s http://127.0.0.1:8080/healthz # {"status":"ok","sessions":0,"index":"on"} +``` + +`docker-compose.override.yml` is merged automatically and is **local-only**: it +publishes the server on `127.0.0.1:8080` and Postgres on `127.0.0.1:55432`, and +switches crawling to on-demand. On the Pi neither port is published — Caddy +reaches the container over the Docker network. + +Loopback binding is deliberate. The bearer token is the only thing in front of +your account's data, so it should not be listening on your LAN while you test. + +## Populate the index + +A full crawl is ~270 requests; start with one course: + +```bash +node dist/bin/cli.js login --server http://127.0.0.1:8080 --token "$MCP_AUTH_TOKEN" +node dist/bin/cli.js refresh --course # or omit --course for everything +node dist/bin/cli.js status +``` + +Get a course id from `curl -s -H "Authorization: Bearer $TSC_JWT_COOKIE" \ +"$TSC_URL/api/v3/courses?limit=5" | jq -r '.data[] | "\(.id) \(.title)"'`. + +## Connect Claude Code + +Claude Code supports MCP over stdio, SSE and HTTP. The VS Code extension runs +the same Claude Code underneath, so it reads the same configuration — register +once and both work. + +**HTTP — matches the deployed setup:** + +```bash +claude mcp add --transport http schulcloud http://127.0.0.1:8080/mcp \ + --header "Authorization: Bearer $MCP_AUTH_TOKEN" +``` + +**stdio — no server or Docker needed:** + +```bash +claude mcp add schulcloud-stdio \ + -e DATABASE_URL=postgresql://schulcloud:schulcloud@127.0.0.1:55432/schulcloud \ + -- node --env-file=/absolute/path/to/.env /absolute/path/to/dist/bin/stdio.js +``` + +`--env-file` keeps the JWT in `.env` rather than copying it into the MCP config. +The HTTP form has no such option — its header holds the token — so leave it at +the default `local` scope, which writes to `~/.claude.json` rather than a +`.mcp.json` that would be committed. + +Check it: + +```bash +claude mcp list # schulcloud: http://127.0.0.1:8080/mcp (HTTP) - ✔ Connected +claude mcp get schulcloud +``` + +Then just ask: *"which courses am I in?"*, *"what's due this week?"*, *"find the +material about Verschlüsselung"*. Inside a session, `/mcp` lists the servers and +their tools. + +## Test the CLI + +```bash +node dist/bin/cli.js ls --long +node dist/bin/cli.js sync --dry-run # shows what it would mirror +node dist/bin/cli.js sync +``` + +`npm link` puts it on your PATH as `schulcloud`. + +## Run the test suites + +```bash +npm test # 58 offline tests +npm run smoke # end-to-end against the live instance, live-only mode +DATABASE_URL=… npm run smoke # end-to-end with the index (34 checks) +``` + +Store tests need a database and skip without one: + +```bash +docker exec schulcloud-mcp-db psql -U schulcloud -d schulcloud \ + -c "CREATE DATABASE schulcloud_test OWNER schulcloud" +TEST_DATABASE_URL=postgresql://schulcloud:schulcloud@127.0.0.1:55432/schulcloud_test npm test +``` + +**These tests `TRUNCATE`.** They refuse to run unless the database name contains +"test", because aiming them at the dev database once was enough — the fixtures +ended up in real data. + +## Tearing down + +```bash +docker compose down # keep the index and mirror +docker compose down -v # discard them too +claude mcp remove schulcloud +``` diff --git a/test/store.test.ts b/test/store.test.ts index 9c72520..1348609 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -8,7 +8,23 @@ import type { Snapshot } from '../src/core/crawl.ts'; * are entirely SQL, so a mock would test nothing. Skipped when TEST_DATABASE_URL * is unset so `npm test` stays offline by default. */ -const URL = process.env.TEST_DATABASE_URL; +const DB_URL = process.env.TEST_DATABASE_URL; + +/** + * These tests TRUNCATE. Pointing them at a real database destroys it — which + * happened once during development, when TEST_DATABASE_URL was aimed at the dev + * instance and the fixtures ended up in live data. Requiring "test" in the + * database name makes that mistake impossible to repeat by accident. + */ +function assertDisposable(url: string): void { + const name = new globalThis.URL(url).pathname.replace(/^\//, ''); + if (!/test/i.test(name)) { + throw new Error( + `Refusing to run: TEST_DATABASE_URL points at database "${name}", which is not obviously ` + + `disposable. These tests TRUNCATE. Use a database with "test" in its name.`, + ); + } +} function snapshot(courses: { id: string; title: string; boardText?: string; files?: { id: string; name: string; size: number }[] }[]): Snapshot { return { @@ -38,11 +54,12 @@ function snapshot(courses: { id: string; title: string; boardText?: string; file }; } -describe('Store', { skip: URL ? false : 'set TEST_DATABASE_URL to run' }, () => { +describe('Store', { skip: DB_URL ? false : 'set TEST_DATABASE_URL to run' }, () => { let store: Store; before(async () => { - const opened = await Store.open(URL); + assertDisposable(DB_URL!); + const opened = await Store.open(DB_URL); assert.ok(opened, 'store should open'); store = opened; // Start from a clean slate so generation ids are predictable.