diff --git a/.cursor/.gitignore b/.cursor/.gitignore new file mode 100644 index 000000000..8bf7cc27a --- /dev/null +++ b/.cursor/.gitignore @@ -0,0 +1 @@ +plans/ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..e09631a0a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.gitignore +.DS_Store + +node_modules +**/node_modules + +dist +**/dist +coverage +**/coverage + +.env +.env.local +.env.*.local + +.gitnexus +gitnexus-web/playwright-report +gitnexus-web/test-results diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..b52d05ae1 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +IMAGE_NAME=ghcr.io/abhigyanpatwari/gitnexus:latest +CONTAINER_NAME=gitnexus +HOST_PORT=4173 diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 27eb75383..7010ee6f3 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -41,6 +41,9 @@ jobs: --outputFile=web-test-results.json working-directory: gitnexus-web + - name: Run docker-server integration tests + run: node --test docker-server.test.mjs + - name: Upload test reports if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000..40a1a6eef --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,71 @@ +name: Docker Build & Push + +on: + push: + tags: + - 'v*' + branches: + - main + paths-ignore: ['**.md', 'docs/**', 'LICENSE'] + workflow_dispatch: + +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Tag refs are unique per release — distinct tags run in parallel. +# Pushes to main serialize; cancel superseded runs. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref == 'refs/heads/main' }} + +jobs: + build-push: + name: Build & Push image + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # Required for multi-platform (linux/arm64) emulation. + - name: Set up QEMU + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Computes image tags and labels from Git metadata: + # v* tag → ghcr.io//: (e.g. 1.2.3, 1.2, 1) + # main push → ghcr.io//:latest + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable={{is_default_branch}} + type=sha,prefix=sha-,format=short + + - name: Build and push + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + BUILDPLATFORM=${{ runner.os == 'Linux' && 'linux/amd64' || 'linux/amd64' }} diff --git a/.gitignore b/.gitignore index e8d4077ec..95c9164e0 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ Thumbs.db .env .env.local .env.*.local +docker/.env # Logs *.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..347888c23 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +ARG BUILDPLATFORM +ARG TARGETPLATFORM + +FROM --platform=$BUILDPLATFORM node:22-alpine AS builder + +WORKDIR /app + +COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/ +RUN npm ci --prefix gitnexus-shared + +COPY gitnexus-shared ./gitnexus-shared +RUN npm run build --prefix gitnexus-shared + +COPY gitnexus/package.json ./gitnexus/package.json +COPY gitnexus-web/package.json gitnexus-web/package-lock.json ./gitnexus-web/ +RUN npm ci --prefix gitnexus-web + +COPY gitnexus-web ./gitnexus-web +RUN npm run build --prefix gitnexus-web + +FROM node:22-alpine AS runtime + +RUN apk add --no-cache curl + +WORKDIR /app + +COPY --from=builder /app/gitnexus-web/dist ./dist +COPY docker-server.mjs ./docker-server.mjs + +RUN chown -R node:node /app + +USER node + +EXPOSE 4173 + +CMD ["node", "docker-server.mjs"] diff --git a/README.md b/README.md index 65ac7d332..d61fc54d5 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,42 @@ cd ../gitnexus-web && npm install npm run dev ``` +## Docker + +```bash +docker run --rm \ + --name gitnexus \ + -p 4173:4173 \ + ghcr.io/abhigyanpatwari/gitnexus:latest +``` + +Or with Docker Compose: + +```bash +docker compose up -d +``` + +Optional env file: + +```bash +cp .env.example .env +set -a +source .env +set +a +``` + +Docker files: + +- [Dockerfile](Dockerfile) is the source for the published `gitnexus` image. It builds `gitnexus-shared` and `gitnexus-web`, then serves the production frontend. +- [docker-compose.yaml](docker-compose.yaml) starts the published image with Docker Compose. +- [.env.example](.env.example) sets the image name, container name, and exposed port for the example commands. + +Notes: + +- The published image serves the production frontend only. It does not start `gitnexus serve`. +- In backend mode, the app still defaults to `http://localhost:4747` unless you change the server URL in the UI. +- If you do not want an env file, the defaults are `ghcr.io/abhigyanpatwari/gitnexus:latest`, container name `gitnexus`, and port `4173`. + The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAssembly (Tree-sitter WASM, LadybugDB WASM, in-browser embeddings). It's great for quick exploration but limited by browser memory for larger repos. **Local Backend Mode:** Run `gitnexus serve` and open the web UI locally — it auto-detects the server and shows all your indexed repos, with full AI chat support. No need to re-upload or re-index. The agent's tools (Cypher queries, search, code navigation) route through the backend HTTP API automatically. diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 000000000..849a3ae14 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,13 @@ +services: + gitnexus: + image: ${IMAGE_NAME:-ghcr.io/brainifii/gitnexus:latest} + container_name: ${CONTAINER_NAME:-gitnexus} + ports: + - '${HOST_PORT:-4173}:4173' + restart: unless-stopped + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:4173/'] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/docker-server.mjs b/docker-server.mjs new file mode 100644 index 000000000..adf84a07b --- /dev/null +++ b/docker-server.mjs @@ -0,0 +1,81 @@ +import { createReadStream } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { extname, join, normalize, sep } from 'node:path'; + +const host = '0.0.0.0'; +const port = Number(process.env.PORT || '4173'); +const root = join(process.cwd(), 'dist'); + +const contentTypes = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.woff': 'font/woff', + '.woff2': 'font/woff2', +}; + +function resolvePath(urlPath) { + let decoded; + try { + decoded = decodeURIComponent(urlPath); + } catch { + return null; + } + if (decoded.includes('\0')) return null; + const cleanPath = normalize(decoded.replace(/^\/+/, '')); + const candidate = join(root, cleanPath); + if (candidate !== root && !candidate.startsWith(root + sep)) return null; + return candidate; +} + +const server = createServer(async (req, res) => { + const requestPath = req.url?.split('?')[0] || '/'; + let filePath = resolvePath(requestPath); + + if (!filePath) { + res.writeHead(400); + res.end('Bad request'); + return; + } + + try { + const fileStat = await stat(filePath).catch(() => null); + if (fileStat?.isDirectory()) { + filePath = join(filePath, 'index.html'); + } else if (!fileStat?.isFile()) { + filePath = join(root, 'index.html'); + } + + const finalStat = await stat(filePath).catch(() => null); + if (!finalStat?.isFile()) { + res.writeHead(404); + res.end('Not found'); + return; + } + + res.writeHead(200, { + 'Cache-Control': filePath.includes('/assets/') + ? 'public, max-age=31536000, immutable' + : 'no-cache', + 'Content-Type': contentTypes[extname(filePath)] || 'application/octet-stream', + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + }); + const stream = createReadStream(filePath); + stream.on('error', () => res.destroy()); + stream.pipe(res); + } catch (error) { + res.writeHead(500); + res.end(error instanceof Error ? error.message : 'Internal server error'); + } +}); + +server.listen(port, host, () => { + console.log(`gitnexus-web listening on http://${host}:${port}`); +}); diff --git a/docker-server.test.mjs b/docker-server.test.mjs new file mode 100644 index 000000000..a1005b0e4 --- /dev/null +++ b/docker-server.test.mjs @@ -0,0 +1,107 @@ +import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; +import http, { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { after, before, it } from 'node:test'; +import assert from 'node:assert/strict'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const serverScript = join(__dirname, 'docker-server.mjs'); + +function getFreePort() { + return new Promise((resolve) => { + const s = createServer(); + s.listen(0, '127.0.0.1', () => { + const { port } = s.address(); + s.close(() => resolve(port)); + }); + }); +} + +function rawGet(port, path) { + return new Promise((resolve, reject) => { + const req = http.request({ host: '127.0.0.1', port, path }, (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body })); + }); + req.on('error', reject); + req.end(); + }); +} + +async function waitForServer(port, retries = 30) { + for (let i = 0; i < retries; i++) { + try { + await rawGet(port, '/'); + return; + } catch { + await new Promise((r) => setTimeout(r, 100)); + } + } + throw new Error('Server did not start in time'); +} + +let tmpDir, serverPort, child; + +before(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gitnexus-docker-test-')); + const distDir = join(tmpDir, 'dist'); + const assetsDir = join(distDir, 'assets'); + await mkdir(assetsDir, { recursive: true }); + await writeFile(join(distDir, 'index.html'), 'spa'); + await writeFile(join(assetsDir, 'app.abc123.js'), 'console.log("app")'); + + serverPort = await getFreePort(); + child = spawn(process.execPath, [serverScript], { + cwd: tmpDir, + env: { ...process.env, PORT: String(serverPort) }, + stdio: 'pipe', + }); + child.on('error', (err) => { + throw err; + }); + + await waitForServer(serverPort); +}); + +after(async () => { + child?.kill(); + if (tmpDir) await rm(tmpDir, { recursive: true, force: true }); +}); + +it('serves a valid asset with immutable cache header', async () => { + const res = await rawGet(serverPort, '/assets/app.abc123.js'); + assert.equal(res.status, 200); + assert.match(res.headers['cache-control'], /immutable/); + assert.equal(res.headers['cross-origin-opener-policy'], 'same-origin'); + assert.equal(res.headers['cross-origin-embedder-policy'], 'require-corp'); +}); + +it('serves SPA fallback for unknown routes', async () => { + const res = await rawGet(serverPort, '/some/unknown/route'); + assert.equal(res.status, 200); + assert.match(res.body, /spa/); + assert.match(res.headers['cache-control'], /no-cache/); +}); + +it('rejects path traversal with 400', async () => { + const res = await rawGet(serverPort, '/../../../etc/passwd'); + assert.equal(res.status, 400); +}); + +it('rejects percent-encoded null bytes with 400', async () => { + const res = await rawGet(serverPort, '/foo%00bar'); + assert.equal(res.status, 400); +}); + +it('returns 404 when dist/index.html is missing', async () => { + await unlink(join(tmpDir, 'dist', 'index.html')); + const res = await rawGet(serverPort, '/nonexistent-page'); + assert.equal(res.status, 404); +}); diff --git a/gitnexus-web/vite.config.ts b/gitnexus-web/vite.config.ts index b177f6804..a62b9e586 100644 --- a/gitnexus-web/vite.config.ts +++ b/gitnexus-web/vite.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ alias: { '@': path.resolve(__dirname, './src'), '@shared': path.resolve(__dirname, '../shared'), + 'gitnexus-shared': path.resolve(__dirname, '../gitnexus-shared/src/index.ts'), // Fix for Rollup failing to resolve this deep import from @langchain/anthropic '@anthropic-ai/sdk/lib/transform-json-schema': path.resolve( __dirname, diff --git a/gitnexus/Dockerfile.test b/gitnexus/Dockerfile.test index b2d22384f..7cafbe2c1 100644 --- a/gitnexus/Dockerfile.test +++ b/gitnexus/Dockerfile.test @@ -1,6 +1,6 @@ FROM node:20-bookworm WORKDIR /app -RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* +RUN apt-get -o Acquire::Check-Valid-Until=false -o Acquire::Check-Date=false update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* COPY . . RUN npm ci --ignore-scripts \ && node scripts/patch-tree-sitter-swift.cjs \