diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4accfc9a..d128f6b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,10 @@ jobs: with: node-version-file: .node-version cache: pnpm + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true - run: pnpm install --frozen-lockfile - run: pnpm --filter @servediff/web exec playwright install --with-deps chromium - run: pnpm check:js @@ -55,5 +59,5 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm build - run: go run ./tools/stage-web apps/web/dist internal/webui/assets - - run: mkdir -p dist && go build -trimpath -o dist/servediff-go ./cmd/servediff + - run: mkdir -p dist && go build -trimpath -o dist/servediff ./cmd/servediff - run: pnpm test:conformance diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 8fa8cfac..d74e1f6e 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -13,6 +13,8 @@ builds: binary: servediff env: - CGO_ENABLED=0 + ldflags: + - -s -w -X main.version={{.Version}} goos: [linux, darwin, windows] goarch: [amd64, arm64] diff --git a/DESIGN.md b/DESIGN.md index 6232a742..cbb330d6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -106,7 +106,7 @@ measured elements or replace the application tokens with framework defaults. file-kind hints. They are a bounded categorical palette, not extra UI accents. - Status always includes text, a sign, an icon, or a shape. Preserve staging's empty/half/full dot distinction; its half-fill is information, not decoration. -- CPU and RAM in the status bar describe the ServeDiff Node server process, not +- CPU and RAM in the status bar describe the ServeDiff server process, not the browser or whole machine. Keep that scope explicit in the tooltip. - Pierre owns syntax colors, changed-line fills, word highlights, line selection, and code-theme surfaces. App status tokens govern the surrounding UI only. diff --git a/README.md b/README.md index 7b2f5527..b8b7eb90 100644 --- a/README.md +++ b/README.md @@ -1,118 +1,64 @@ # servediff -Review local Git changes in your browser. Built with React, Vite, Tailwind CSS v4, -[shadcn/ui](https://ui.shadcn.com) on Base UI, and -[Pierre diffs](https://diffs.com); inspired by diffshub and -[diffx](https://github.com/wong2/diffx). - -## Setup - -Requires **Node 26**, **pnpm 11**, and **Git**. Developing or building the -parallel Go implementation also requires **Go 1.25**. From this checkout: - -```sh -pnpm install -pnpm build -pnpm add -g . -``` - -This registers `servediff` globally, pointing to this checkout. Keep the checkout in place. If pnpm reports a missing global bin directory, run `pnpm setup`, restart your terminal, and retry. +Review local Git changes in your browser without modifying your working tree or +index. ## Usage -From any Git repository: +Run from a Git repository: ```sh servediff . ``` -servediff automatically opens the local address in your browser on macOS and -Linux, except in SSH sessions. Stop with **Ctrl+C**. The network address is -reachable by other devices on your local network. +You can also serve another repository or choose the address: ```sh -servediff . # current repository -servediff /path/to/repo # another repository -servediff . --port 4000 # different port -``` - -Subdirectories resolve to the repository root. Switch between all, staged, and unstaged changes; edits refresh automatically. Use the file tree to navigate and **+** beside a line to comment. **Copy unresolved** or **Copy all** exports agent-ready XML with instructions, short comment IDs, captured code context, and review provenance. Comments and reviewed-file marks persist through the local server; display preferences stay in your browser. The viewer never changes your Git files or index. - -Or pipe Git output directly: - -```sh -git diff | servediff -git show | servediff -git show main..HEAD~1 | servediff -git diff main HEAD~1 | servediff -servediff - < saved.patch +servediff /path/to/repo +servediff . --port 4000 +servediff . --host 0.0.0.0 ``` -Piped or redirected input takes priority over a directory argument; an empty pipe opens an empty diff. Without input redirection, a directory is required. Piped diffs are fixed snapshots; re-run the command to update. Commit ranges show each commit separately, including repeated files. `git diff` between two refs shows their net difference. Standard Git patches up to 16 MiB total / 2 MiB per file are supported; use `git show --diff-merges=separate` for merge commits. +By default, servediff binds to `127.0.0.1`, opens a browser, and uses the first +available port from 7981 through 7990. Stop it with **Ctrl+C**. -## Development +Passing `--host 0.0.0.0` exposes the unauthenticated server on every network +interface and accepts any HTTP host name. Use it only on a trusted network. -From this checkout: +## Reviewing changes -```sh -pnpm dev:web -pnpm dev:server -``` +Switch between all, staged, and unstaged changes. Use the file tree to navigate, +mark files as reviewed, and select **+** beside a line to comment. **Copy +unresolved** and **Copy all** export review comments as agent-ready XML. -Both development commands use `test/fixtures/sample.diff`. The web workspace -runs the production API handler with an in-memory fixture store preloaded with -comments and reviewed-file state; the server workspace runs the Node CLI with -the same diff piped to stdin. +Comments and reviewed-file marks persist by repository. Display preferences stay +in the browser. -The repository also contains a contract-compatible Go server. It coexists with -the Node server while parity is verified; the Node implementation remains the -published `servediff` command. - -```sh -task dev # Go API with fixture data + Vite HMR -task dev:go # standalone Go server with built web UI -task build:go # dependency-free dist/servediff-go binary -task check # JavaScript, Go, and cross-server conformance -``` +## Piped diffs -Without [Task](https://taskfile.dev), equivalent `pnpm` and `go` commands are -listed in `Taskfile.yml`. The built Go binary accepts a repository path, piped -patch, or `--fixture`; use `--state memory` for isolated development/tests. - -Install Chromium once, then test either workspace independently or run every -check from the root: +Serve a fixed Git patch instead of a live repository: ```sh -pnpm test:browser:install -pnpm --filter @servediff/server test -pnpm --filter @servediff/web test -pnpm check +git diff | servediff +git show | servediff +git show main..HEAD~1 | servediff +servediff - < saved.patch ``` -Run `pnpm build` after frontend changes, then restart `servediff`. No global reinstall needed. - -See [docs/architecture.md](docs/architecture.md) for workspace boundaries and -[ADR 0001](docs/adr/0001-polyglot-monorepo.md) for the migration decision. +Piped input takes priority over a directory argument. Re-run the command to +refresh a piped diff. Standard Git patches are limited to 16 MiB total and 2 MiB +per file. For merge commits, use `git show --diff-merges=separate`. -UI work follows [DESIGN.md](DESIGN.md). Existing semantic tokens in -`apps/web/src/tokens.css` remain canonical and are mapped into Tailwind/shadcn -theme roles by the global stylesheet. Reuse repository-owned primitives in -`apps/web/src/components/ui`; reserve authored CSS for specialized layout, -dynamic geometry, and Pierre's measured rendering boundary. +## API and data -`App.tsx` composes the page sections. `app-state.tsx` owns shared state through -`AppProvider`; `DiffWorkspace.tsx` owns Pierre rendering, and `use-review.ts` -uses the generated REST client. Section-only state stays with its component. +The OpenAPI 3.1 contract is served at `/openapi.yaml`; REST endpoints are under +`/api/v1`. -## API +The API is unauthenticated. Browser same-origin and cross-site checks remain, but +anyone who can reach the server can access its review data. -The server prints a per-run API token and includes it in browser URLs as a URL -fragment. REST clients send it as `Authorization: Bearer `. The OpenAPI -3.1 contract is available at `/openapi.yaml`; regenerate the TypeScript client -after contract changes with `pnpm generate:api`. +Review data is stored under `$XDG_STATE_HOME/servediff/reviews`, or +`~/.local/state/servediff/reviews` when `XDG_STATE_HOME` is unset. -The versioned API exposes the active source, current diff snapshots, file -patches and contents, comment CRUD/export, and reviewed-file marks under -`/api/v1`. Piped input supports only the `all` scope and remains immutable. -Comments persist by source under `$XDG_STATE_HOME/servediff/reviews` (or -`~/.local/state/servediff/reviews`). +For source setup, development commands, and architecture, see +[docs/development.md](docs/development.md). diff --git a/Taskfile.yml b/Taskfile.yml index 77a9b1e1..20d33fed 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -14,49 +14,35 @@ tasks: cmds: - go run ./tools/stage-web apps/web/dist internal/webui/assets - build:node: - cmds: - - pnpm build - - build:go: + build: cmds: - task: generate - task: web:stage - mkdir -p dist - - go build -trimpath -o dist/servediff-go ./cmd/servediff + - go build -trimpath -o dist/servediff ./cmd/servediff - build: + install: cmds: - - task: build:node - - go run ./tools/stage-web apps/web/dist internal/webui/assets - - mkdir -p dist - - go build -trimpath -o dist/servediff-go ./cmd/servediff + - task: build + - | + if [ "$(pnpm list --global --depth 0 --parseable servediff-workspace | wc -l)" -gt 1 ]; then + pnpm remove --global servediff-workspace + fi + - go run ./tools/remove-legacy-install + - go install -trimpath ./cmd/servediff dev:web: cmds: - pnpm dev:web - dev:node: - cmds: - - pnpm dev:server - - dev:go: + dev:server: deps: [web:build] cmds: - go run ./cmd/servediff --fixture test/fixtures/sample.diff --state memory --web-dir apps/web/dist - dev:go-api: - cmds: - - go run ./cmd/servediff --fixture test/fixtures/sample.diff --state memory --no-auth --no-browser --port 3333 - - dev:web-go: - env: - SERVEDIFF_API_URL: http://127.0.0.1:3333 - cmds: - - pnpm dev:web - dev: - deps: [dev:go-api, dev:web-go] + cmds: + - task: dev:web test:go: cmds: @@ -64,7 +50,7 @@ tasks: test:conformance: cmds: - - task: build:go + - task: build - pnpm test:conformance check:go: diff --git a/apps/server/package.json b/apps/server/package.json deleted file mode 100644 index 08f5613a..00000000 --- a/apps/server/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@servediff/server", - "private": true, - "license": "apache-2.0", - "type": "module", - "scripts": { - "build": "pnpm typecheck", - "dev": "node src/cli.ts --dev - < ../../test/fixtures/sample.diff", - "test": "node --test test/*.test.ts", - "typecheck": "tsc --noEmit -p tsconfig.json" - }, - "dependencies": { - "@pierre/diffs": "catalog:", - "@servediff/shared": "workspace:*", - "vite": "catalog:" - }, - "devDependencies": { - "@servediff/api": "workspace:*", - "@types/node": "catalog:", - "typescript": "catalog:" - } -} diff --git a/apps/server/src/api.ts b/apps/server/src/api.ts deleted file mode 100644 index 0ecb4fa6..00000000 --- a/apps/server/src/api.ts +++ /dev/null @@ -1,534 +0,0 @@ -import { randomBytes } from "node:crypto"; -import { readFile } from "node:fs/promises"; -import type { IncomingMessage, ServerResponse } from "node:http"; -import { fileURLToPath } from "node:url"; -import { parseDiffFromFile, parsePatchFiles } from "@pierre/diffs"; -import { - commentApplicability, - commentContext, - formatComments, - isDiffMode, - validComment, - type DiffMode, - type RepositoryDiff, - type ReviewComment, -} from "@servediff/shared"; -import { getProcessMetrics } from "./metrics.ts"; -import { ReviewStore } from "./review-store.ts"; -import { type DiffSource, RequestError } from "./source.ts"; - -const openapiPath = fileURLToPath( - new URL("../../../packages/api/openapi.yaml", import.meta.url), -); - -interface ApiContext { - source: DiffSource; - sessionId: string; - token: string; - store: ReviewStore; - snapshot(mode: DiffMode, fresh?: boolean): Promise; -} - -type CommentSelection = "all" | "open" | "resolved" | "stale"; - -async function commentRepositories( - context: ApiContext, - comments: readonly ReviewComment[], -) { - const scopes = new Set( - comments - .map((comment) => comment.scope) - .filter((commentScope) => context.source.scopes.includes(commentScope)), - ); - return Promise.all( - [...scopes].map((commentScope) => context.snapshot(commentScope)), - ); -} - -function selectedComment( - comment: ReviewComment, - selection: CommentSelection, - repositories: readonly RepositoryDiff[], -) { - if (selection === "all") return true; - const repository = repositories.find( - (candidate) => candidate.mode === comment.scope, - ); - const stale = commentApplicability(comment, repository ?? null) === "stale"; - return selection === "stale" ? stale : !stale && comment.status === selection; -} - -function record(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function problem(response: ServerResponse, error: unknown) { - const status = error instanceof RequestError ? error.status : 500; - const detail = - error instanceof Error ? error.message : "Unable to process request"; - response.writeHead(status, { - "Content-Type": "application/problem+json; charset=utf-8", - ...(status === 401 ? { "WWW-Authenticate": "Bearer" } : {}), - }); - response.end( - JSON.stringify({ - type: "about:blank", - title: status >= 500 ? "Internal Server Error" : "Request Failed", - status, - detail, - }), - ); -} - -function json(response: ServerResponse, status: number, body: unknown) { - response.writeHead(status, { - "Content-Type": "application/json; charset=utf-8", - }); - response.end(JSON.stringify(body)); -} - -async function requestBody(request: IncomingMessage): Promise { - if (!request.headers["content-type"]?.startsWith("application/json")) - throw new RequestError(415, "Expected application/json request body"); - const chunks: Buffer[] = []; - let size = 0; - for await (const chunk of request) { - if (!Buffer.isBuffer(chunk)) - throw new RequestError(400, "Invalid request body"); - size += chunk.length; - if (size > 1024 * 1024) - throw new RequestError(413, "Request body exceeds 1 MiB"); - chunks.push(chunk); - } - try { - const value: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")); - return value; - } catch { - throw new RequestError(400, "Invalid JSON request body"); - } -} - -function scope(url: URL) { - const value = url.searchParams.get("scope"); - if (!isDiffMode(value)) throw new RequestError(400, "Invalid diff scope"); - return value; -} - -async function currentFile( - context: ApiContext, - mode: DiffMode, - diffId: string, - fileId: string, - fileVersion: string | null, - fresh = false, -) { - const snapshot = await context.snapshot(mode, fresh); - if (snapshot.revision !== diffId) - throw new RequestError( - 409, - "Diff changed. Refresh to load the latest version.", - ); - const file = snapshot.files.find((entry) => entry.id === fileId); - if (!file) throw new RequestError(404, "File is not in the current diff"); - if (fileVersion !== file.fingerprint) - throw new RequestError( - 409, - "File changed. Refresh to load the latest version.", - ); - return { snapshot, file }; -} - -function createCommentBody(value: unknown): value is { - diffId: string; - fileId: string; - scope: DiffMode; - fileVersion: string; - side: "additions" | "deletions"; - start: number; - end: number; - body: string; -} { - const keys = record(value) ? Object.keys(value) : []; - return ( - record(value) && - keys.length === 8 && - keys.every((key) => - [ - "diffId", - "fileId", - "scope", - "fileVersion", - "side", - "start", - "end", - "body", - ].includes(key), - ) && - typeof value.diffId === "string" && - typeof value.fileId === "string" && - isDiffMode(value.scope) && - typeof value.fileVersion === "string" && - (value.side === "additions" || value.side === "deletions") && - typeof value.start === "number" && - Number.isInteger(value.start) && - typeof value.end === "number" && - Number.isInteger(value.end) && - typeof value.body === "string" - ); -} - -function updateCommentBody(value: unknown): value is { - body?: string; - status?: "open" | "resolved"; -} { - if (!record(value)) return false; - const keys = Object.keys(value); - return ( - keys.length > 0 && - keys.every((key) => key === "body" || key === "status") && - (value.body === undefined || typeof value.body === "string") && - (value.status === undefined || - value.status === "open" || - value.status === "resolved") - ); -} - -async function commentCode( - context: ApiContext, - snapshot: RepositoryDiff, - file: RepositoryDiff["files"][number], - side: "additions" | "deletions", - start: number, - end: number, -) { - const preview = await context.source.patch( - snapshot.mode, - file, - snapshot.head, - ); - const parsed = - preview.contents && preview.message === null - ? parseDiffFromFile( - { - name: file.oldPath ?? file.path, - contents: preview.contents.before, - }, - { name: file.path, contents: preview.contents.after }, - ) - : preview.patch - ? parsePatchFiles(preview.patch, file.fingerprint, true).flatMap( - (patch) => patch.files, - )[0] - : undefined; - const contextResult = parsed - ? commentContext(parsed, { side, start, end }) - : null; - if (!contextResult) - throw new RequestError(400, "Select up to 200 visible lines on one side"); - return contextResult.code; -} - -function commentRoute(pathname: string) { - const match = /^\/api\/v1\/comments\/([^/]+)$/.exec(pathname); - return match?.[1] ?? null; -} - -function fileRoute(pathname: string) { - const match = - /^\/api\/v1\/diffs\/([^/]+)\/files\/([^/]+)\/(patch|contents)$/.exec( - pathname, - ); - if (!match?.[1] || !match[2] || !match[3]) return null; - return { diffId: match[1], fileId: match[2], resource: match[3] }; -} - -function reviewMarkRoute(pathname: string) { - const match = /^\/api\/v1\/review-marks\/([^/]+)$/.exec(pathname); - return match?.[1] ?? null; -} - -export async function handleApi( - request: IncomingMessage, - response: ServerResponse, - url: URL, - context: ApiContext, -) { - if (url.pathname === "/openapi.yaml") { - if (request.method !== "GET" && request.method !== "HEAD") - throw new RequestError(405, "Method not allowed"); - response.setHeader("Content-Type", "application/yaml; charset=utf-8"); - response.end( - request.method === "HEAD" ? undefined : await readFile(openapiPath), - ); - return true; - } - if (!url.pathname.startsWith("/api/v1/")) return false; - if ( - context.token && - request.headers.authorization !== `Bearer ${context.token}` - ) - throw new RequestError(401, "Missing or invalid API token"); - - if (url.pathname === "/api/v1/metrics" && request.method === "GET") { - json(response, 200, getProcessMetrics()); - return true; - } - if (url.pathname === "/api/v1/session" && request.method === "GET") { - const snapshot = await context.snapshot(context.source.scopes[0] ?? "all"); - json(response, 200, { - id: context.sessionId, - source: context.source.kind, - name: snapshot.name, - root: snapshot.root, - capabilities: { - scopes: context.source.scopes, - live: context.source.live, - fullFileContents: context.source.kind === "local", - }, - }); - return true; - } - if (url.pathname === "/api/v1/diffs/current" && request.method === "GET") { - json(response, 200, await context.snapshot(scope(url))); - return true; - } - const file = fileRoute(url.pathname); - if (file && request.method === "GET") { - const mode = scope(url); - const current = await currentFile( - context, - mode, - file.diffId, - file.fileId, - url.searchParams.get("fileVersion"), - ); - json( - response, - 200, - file.resource === "patch" - ? await context.source.patch(mode, current.file, current.snapshot.head) - : await context.source.contents( - mode, - current.file, - current.snapshot.head, - ), - ); - return true; - } - if (url.pathname === "/api/v1/comments" && request.method === "GET") { - json(response, 200, { - comments: await context.store.comments(context.sessionId), - }); - return true; - } - if (url.pathname === "/api/v1/comments" && request.method === "DELETE") { - const status = url.searchParams.get("status"); - if ( - status !== "all" && - status !== "open" && - status !== "resolved" && - status !== "stale" - ) - throw new RequestError(400, "Invalid comment status"); - const comments = await context.store.comments(context.sessionId); - const repositories = await commentRepositories(context, comments); - const ids = new Set( - comments - .filter((comment) => selectedComment(comment, status, repositories)) - .map((comment) => comment.id), - ); - const deleted = await context.store.deleteComments(context.sessionId, ids); - json(response, 200, { - comments: await context.store.comments(context.sessionId), - deleted, - }); - return true; - } - if (url.pathname === "/api/v1/comments" && request.method === "POST") { - const body = await requestBody(request); - if (!createCommentBody(body) || !body.body.trim()) - throw new RequestError(400, "Invalid comment"); - const current = await currentFile( - context, - body.scope, - body.diffId, - body.fileId, - body.fileVersion, - true, - ); - const comment: ReviewComment = { - id: randomBytes(16).toString("hex"), - path: current.file.path, - scope: body.scope, - fingerprint: current.file.fingerprint, - side: body.side, - start: Math.min(body.start, body.end), - end: Math.max(body.start, body.end), - code: await commentCode( - context, - current.snapshot, - current.file, - body.side, - body.start, - body.end, - ), - body: body.body.trim(), - status: "open", - createdAt: Date.now(), - origin: { - source: current.snapshot.source, - repository: current.snapshot.name, - branch: current.snapshot.branch, - head: current.snapshot.head, - revision: current.snapshot.revision, - file: { status: current.file.status, oldPath: current.file.oldPath }, - }, - }; - await context.store.putComment(context.sessionId, comment); - json(response, 201, comment); - return true; - } - if (url.pathname === "/api/v1/comments/import" && request.method === "POST") { - const body = await requestBody(request); - if ( - !record(body) || - Object.keys(body).length !== 1 || - !Array.isArray(body.comments) || - !body.comments.every(validComment) - ) - throw new RequestError(400, "Invalid comment import"); - json(response, 200, { - comments: await context.store.importComments( - context.sessionId, - body.comments, - ), - }); - return true; - } - if (url.pathname === "/api/v1/comments/export" && request.method === "GET") { - const resolvedParameter = url.searchParams.get("includeResolved"); - if ( - resolvedParameter !== null && - resolvedParameter !== "true" && - resolvedParameter !== "false" - ) - throw new RequestError(400, "Invalid includeResolved value"); - const includeResolved = resolvedParameter === "true"; - const requestedCommentId = url.searchParams.get("commentId"); - const revision = url.searchParams.get("revision"); - const requestedScope = url.searchParams.get("scope"); - if (requestedScope !== null && !isDiffMode(requestedScope)) - throw new RequestError(400, "Invalid diff scope"); - if ((revision === null) !== (requestedScope === null)) - throw new RequestError( - 400, - "revision and scope must be provided together", - ); - const comments = await context.store.comments(context.sessionId); - const current = - revision && requestedScope - ? await context.snapshot(requestedScope) - : null; - const requestedComments = requestedCommentId - ? comments.filter((comment) => comment.id === requestedCommentId) - : comments; - const selected = revision - ? requestedComments.filter( - (comment) => - comment.scope === requestedScope && - (comment.origin?.revision === revision || - (comment.origin === undefined && - current?.revision === revision && - current.files.some( - (file) => - file.path === comment.path && - file.fingerprint === comment.fingerprint, - ))), - ) - : requestedComments; - const repositories = await commentRepositories(context, selected); - response.setHeader("Content-Type", "application/xml; charset=utf-8"); - response.end(formatComments(selected, includeResolved, repositories)); - return true; - } - const commentId = commentRoute(url.pathname); - if (commentId && request.method === "PATCH") { - const body = await requestBody(request); - if (!updateCommentBody(body) || body.body?.trim() === "") - throw new RequestError(400, "Invalid comment update"); - const comments = await context.store.comments(context.sessionId); - const current = comments.find((comment) => comment.id === commentId); - if (!current) throw new RequestError(404, "Comment not found"); - const updated: ReviewComment = { - ...current, - ...(body.body === undefined ? {} : { body: body.body.trim() }), - ...(body.status === undefined ? {} : { status: body.status }), - }; - await context.store.putComment(context.sessionId, updated); - json(response, 200, updated); - return true; - } - if (commentId && request.method === "DELETE") { - if (!(await context.store.deleteComment(context.sessionId, commentId))) - throw new RequestError(404, "Comment not found"); - response.writeHead(204).end(); - return true; - } - if (url.pathname === "/api/v1/review-marks" && request.method === "GET") { - json(response, 200, { - marks: await context.store.marks(context.sessionId, scope(url)), - }); - return true; - } - if (url.pathname === "/api/v1/review-marks" && request.method === "DELETE") { - await context.store.clearMarks(context.sessionId, scope(url)); - response.writeHead(204).end(); - return true; - } - const fileId = reviewMarkRoute(url.pathname); - if (fileId && request.method === "PUT") { - const mode = scope(url); - const body = await requestBody(request); - if ( - !record(body) || - Object.keys(body).length !== 1 || - typeof body.fileVersion !== "string" - ) - throw new RequestError(400, "Invalid review mark"); - const snapshot = await context.snapshot(mode, true); - const changedFile = snapshot.files.find((entry) => entry.id === fileId); - if (!changedFile) - throw new RequestError(404, "File is not in the current diff"); - if (changedFile.fingerprint !== body.fileVersion) - throw new RequestError(409, "File changed. Refresh to try again."); - const mark = { fileId, fileVersion: body.fileVersion, scope: mode }; - await context.store.putMark(context.sessionId, mark); - json(response, 200, mark); - return true; - } - if (fileId && request.method === "DELETE") { - await context.store.deleteMark(context.sessionId, scope(url), fileId); - response.writeHead(204).end(); - return true; - } - if ( - url.pathname === "/api/v1/session" || - url.pathname === "/api/v1/diffs/current" || - url.pathname === "/api/v1/comments" || - url.pathname === "/api/v1/comments/import" || - url.pathname === "/api/v1/comments/export" || - url.pathname === "/api/v1/review-marks" || - file || - commentId || - fileId - ) - throw new RequestError(405, "Method not allowed"); - throw new RequestError(404, "Unknown API route"); -} - -export async function respondWithProblem( - response: ServerResponse, - error: unknown, -) { - problem(response, error); -} diff --git a/apps/server/src/browser.ts b/apps/server/src/browser.ts deleted file mode 100644 index 78170fb9..00000000 --- a/apps/server/src/browser.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { spawn } from "node:child_process"; - -export type BrowserLauncher = (command: string, url: string) => Promise; - -export function browserCommand(platform: NodeJS.Platform) { - if (platform === "darwin") return "open"; - if (platform === "linux") return "xdg-open"; - return null; -} - -export function isSshSession(environment: NodeJS.ProcessEnv) { - return Boolean(environment.SSH_CONNECTION || environment.SSH_TTY); -} - -function launch(command: string, url: string) { - return new Promise((resolve, reject) => { - const child = spawn(command, [url], { detached: true, stdio: "ignore" }); - child.once("error", reject); - child.once("spawn", () => { - child.unref(); - resolve(); - }); - }); -} - -export async function openBrowser( - url: string, - { - platform = process.platform, - environment = process.env, - launcher = launch, - }: { - platform?: NodeJS.Platform; - environment?: NodeJS.ProcessEnv; - launcher?: BrowserLauncher; - } = {}, -) { - if (isSshSession(environment)) return null; - const command = browserCommand(platform); - if (!command) return null; - try { - await launcher(command, url); - return null; - } catch (error) { - return `Unable to open browser with ${command}: ${error instanceof Error ? error.message : String(error)}`; - } -} diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts deleted file mode 100755 index d26d416e..00000000 --- a/apps/server/src/cli.ts +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env node -import { fstatSync } from "node:fs"; -import { parseArgs } from "node:util"; -import { openBrowser } from "./browser.ts"; -import { startServer } from "./server.ts"; -import { readPatchInput } from "./stdin.ts"; - -try { - const { values, positionals } = parseArgs({ - options: { - port: { type: "string", short: "p", default: "3333" }, - dev: { type: "boolean", default: false }, - help: { type: "boolean", short: "h" }, - }, - allowPositionals: true, - }); - if (values.help) { - console.log( - "Usage: servediff [directory | -] [--port 3333] [--dev]\n\nExamples:\n servediff . Watch the current repository\n servediff /path/to/repo Watch another repository\n git diff | servediff Read a patch from stdin\n git show | servediff Review a commit\n servediff - < saved.patch Read a saved patch\n servediff . --port 4000 Use a different port\n\nPiped or redirected input takes priority, even when empty.\nWithout input redirection, provide a directory to watch with Git.\nRequires Node 26. Opens your browser automatically on macOS and Linux, except over SSH. Ctrl+C stops the server.", - ); - } else { - if (Number(process.versions.node.split(".")[0]) !== 26) - throw new Error("servediff requires Node 26"); - if (positionals.length > 1) - throw new Error("Expected at most one repository directory"); - const port = Number(values.port); - if (!Number.isInteger(port) || port < 0 || port > 65535) - throw new Error("Port must be between 0 and 65535"); - // An empty pipe is a valid empty diff. A terminal or /dev/null is no input. - const stdin = fstatSync(0); - const piped = - positionals[0] === "-" || - stdin.isFIFO() || - stdin.isSocket() || - stdin.isFile(); - const input = piped ? await readPatchInput(process.stdin) : ""; - if (!piped && (!positionals[0] || positionals[0] === "-")) - throw new Error( - "Provide a repository path (servediff .) or pipe a Git diff into servediff. No input received.", - ); - const server = await startServer({ - directory: positionals[0] ?? process.cwd(), - port, - dev: values.dev, - ...(piped ? { input } : {}), - }); - const browserError = await openBrowser(server.addresses.localhost); - console.log( - `\n servediff\n Local ${server.addresses.localhost}\n All ${server.addresses.all}\n Network ${server.addresses.network ?? "unavailable"}\n API token ${server.token}\n ${piped ? "Piped diff ยท fixed snapshot" : server.root}\n\n Press Ctrl+C to stop.\n`, - ); - if (browserError) console.warn(`servediff: ${browserError}`); - let closing = false; - const close = () => { - if (!closing) { - closing = true; - void server.close(); - } - }; - process.once("SIGINT", close); - process.once("SIGTERM", close); - } -} catch (error) { - console.error( - `servediff: ${error instanceof Error ? error.message : String(error)}`, - ); - process.exitCode = 1; -} diff --git a/apps/server/src/git.ts b/apps/server/src/git.ts deleted file mode 100644 index 88341f89..00000000 --- a/apps/server/src/git.ts +++ /dev/null @@ -1,476 +0,0 @@ -import { execFile } from "node:child_process"; -import { createHash } from "node:crypto"; -import { lstat, readFile, readlink } from "node:fs/promises"; -import { basename, join, resolve } from "node:path"; -import { promisify } from "node:util"; -import type { - ChangedFile, - DiffMode, - FilePatch, - RepositoryDiff, -} from "@servediff/shared"; -import { type DiffSource, RequestError } from "./source.ts"; - -const execute = promisify(execFile); -const MAX_PATCH_BYTES = 2 * 1024 * 1024; -const patchFormat = [ - "--no-color", - "--src-prefix=a/", - "--dst-prefix=b/", - "--output-indicator-new=+", - "--output-indicator-old=-", - "--output-indicator-context= ", -]; - -// Git receives argument arrays and literal pathspecs; filenames never become shell code. -async function git(root: string, args: string[], maxBuffer = 16 * 1024 * 1024) { - const result = execute( - "git", - ["--literal-pathspecs", "-c", "core.quotePath=false", ...args], - { - cwd: root, - encoding: "utf8", - maxBuffer, - timeout: 20_000, - env: { - ...process.env, - GIT_OPTIONAL_LOCKS: "0", - GIT_TERMINAL_PROMPT: "0", - }, - }, - ); - result.child.stdin?.end(); - return result; -} - -function digest(value: string) { - return createHash("sha256").update(value).digest("hex"); -} - -function diffArgs(mode: DiffMode, base: string) { - return [ - "diff", - "--no-ext-diff", - "--no-textconv", - "--ignore-submodules=none", - "--find-renames", - ...patchFormat, - ...(mode === "staged" ? ["--cached", base] : mode === "all" ? [base] : []), - ]; -} - -// NUL records preserve spaces, tabs, newlines, Unicode, and rename pairs in Git paths. -function parseRaw(raw: string): Map { - const entries = raw.split("\0"); - const files = new Map(); - for (let index = 0; index < entries.length; index++) { - const header = entries[index]; - if (!header?.startsWith(":")) continue; - const status = header.split(" ").at(-1)?.[0] ?? "M"; - const firstPath = entries[++index]; - const path = - status === "R" || status === "C" ? entries[++index] : firstPath; - if (path === undefined || firstPath === undefined) - throw new Error("Invalid Git file record"); - if (files.get(path)?.status === "U") continue; - files.set(path, { - id: digest(path), - path, - oldPath: path === firstPath ? null : firstPath, - status, - indexStatus: " ", - worktreeStatus: " ", - additions: 0, - deletions: 0, - binary: false, - fingerprint: header, - }); - } - return files; -} - -function applyStats(files: Map, output: string) { - const entries = output.split("\0"); - for (let index = 0; index < entries.length; index++) { - const record = entries[index]; - if (!record) continue; - const first = record.indexOf("\t"); - const second = record.indexOf("\t", first + 1); - const additions = record.slice(0, first); - const deletions = record.slice(first + 1, second); - let path = record.slice(second + 1); - if (path === "") { - index++; - path = entries[++index] ?? ""; - } - const file = files.get(path); - if (file) { - file.binary = additions === "-"; - file.additions = Number.parseInt(additions, 10) || 0; - file.deletions = Number.parseInt(deletions, 10) || 0; - } - } -} - -// Porcelain separates index and working-tree changes, including rename pairs. -// A staged deletion followed by recreation produces two records for one path. -function parseStatus(output: string) { - const renamedPaths = new Map(); - const statuses = new Map< - string, - { indexStatus: string; worktreeStatus: string } - >(); - const records = output.split("\0"); - for (let index = 0; index < records.length; index++) { - const record = records[index]; - if (!record) continue; - const path = record.slice(3); - const previous = statuses.get(path); - const indexStatus = record[0] ?? " "; - const worktreeStatus = record[1] ?? " "; - const status = { - indexStatus: - indexStatus === "?" ? (previous?.indexStatus ?? "?") : indexStatus, - worktreeStatus: previous?.worktreeStatus === "?" ? "?" : worktreeStatus, - }; - statuses.set(path, status); - if (/[RC]/.test(indexStatus + worktreeStatus)) { - const oldPath = records[++index]; - if (oldPath !== undefined) renamedPaths.set(oldPath, path); - } - } - for (const [oldPath, path] of renamedPaths) { - const status = statuses.get(path); - // A net diff may split a heavily edited rename into delete/add entries. - // Edits at the destination must not count as unstaged edits at the source. - if (status && !statuses.has(oldPath)) - statuses.set(oldPath, { - indexStatus: status.indexStatus === "R" ? "D" : " ", - worktreeStatus: status.worktreeStatus === "R" ? "D" : " ", - }); - } - return statuses; -} - -export async function openRepository(directory: string) { - let root: string; - try { - root = ( - await git(resolve(directory), ["rev-parse", "--show-toplevel"]) - ).stdout.replace(/\n$/, ""); - } catch { - throw new Error(`Not a Git working tree: ${resolve(directory)}`); - } - - // Git ignores an index-deleted path even after it is recreated on disk. - // Compare its HEAD contents explicitly, without adding it back to the index. - async function recreatedContents( - path: string, - head: string, - ): Promise { - const absolute = join(root, path); - const stat = await lstat(absolute); - if ( - (!stat.isFile() && !stat.isSymbolicLink()) || - stat.size > MAX_PATCH_BYTES - ) { - return { - patch: "", - message: - "Recreated file exceeds the preview limit or is not a text file.", - }; - } - try { - const before = ( - await git(root, ["show", `${head}:${path}`], MAX_PATCH_BYTES) - ).stdout; - const after = stat.isSymbolicLink() - ? await readlink(absolute) - : await readFile(absolute, "utf8"); - return { - patch: "", - message: - before.includes("\0") || after.includes("\0") - ? "Binary file changed. No text preview available." - : null, - contents: { before, after }, - }; - } catch { - return { - patch: "", - message: - "Original file exceeds the 2 MiB preview limit or changed while loading.", - }; - } - } - - async function snapshot(mode: DiffMode): Promise { - const head = await git(root, ["rev-parse", "--verify", "HEAD"]).then( - (result) => result.stdout.trim(), - () => null, - ); - const base = - head ?? - (await git(root, ["hash-object", "-t", "tree", "--stdin"])).stdout.trim(); - const args = diffArgs(mode, base); - const [raw, stats, untracked, branch, statusOutput] = await Promise.all([ - git(root, [...args, "--raw", "--no-abbrev", "-z", "--"]), - git(root, [...args, "--numstat", "-z", "--"]), - mode === "staged" - ? Promise.resolve({ stdout: "" }) - : git(root, ["ls-files", "--others", "--exclude-standard", "-z"]), - git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]).then( - (result) => result.stdout.trim(), - () => `detached at ${head?.slice(0, 7) ?? "HEAD"}`, - ), - git(root, [ - "status", - "--porcelain=v1", - "-z", - "--untracked-files=all", - "--ignore-submodules=none", - "--renames", - ]), - ]); - const files = parseRaw(raw.stdout); - applyStats(files, stats.stdout); - // A staged deletion recreated on disk is untracked in the index, but still - // belongs to HEAD. In All changes, retain its net diff (or no diff at all). - const headPaths = - mode === "all" && head && untracked.stdout - ? new Set( - ( - await git(root, ["ls-tree", "-r", "--name-only", "-z", head]) - ).stdout.split("\0"), - ) - : new Set(); - for (const path of untracked.stdout.split("\0").filter(Boolean)) { - if (head && headPaths.has(path)) { - const original = files.get(path); - if (!original) continue; - const preview = await recreatedContents(path, head); - const stat = await lstat(join(root, path)); - const mode = stat.isSymbolicLink() - ? "120000" - : stat.mode & 0o111 - ? "100755" - : "100644"; - const previousMode = original.fingerprint.split(" ")[0]?.slice(1); - if ( - preview.contents && - preview.contents.before === preview.contents.after && - mode === previousMode - ) { - files.delete(path); - } else { - original.status = "M"; - original.additions = 0; - original.deletions = 0; - original.recreated = true; - original.binary = preview.message?.startsWith("Binary") ?? false; - } - continue; - } - if (files.has(path)) continue; - files.set(path, { - id: digest(path), - path, - oldPath: null, - status: "?", - indexStatus: " ", - worktreeStatus: " ", - additions: 0, - deletions: 0, - binary: false, - fingerprint: "untracked", - }); - } - // Worktree metadata detects edits even when a file's diff line counts stay the same. - await Promise.all( - [...files.values()].map(async (file) => { - const { - indexStatus: _indexStatus, - worktreeStatus: _worktreeStatus, - ...contentFile - } = file; - const stat = - mode === "staged" - ? null - : await lstat(join(root, file.path)).catch(() => null); - file.fingerprint = digest( - JSON.stringify([ - mode, - head, - contentFile, - stat?.size, - stat?.mtimeMs, - stat?.ctimeMs, - ]), - ); - }), - ); - // Staging metadata changes the manifest revision, not comment/code identity. - const statuses = parseStatus(statusOutput.stdout); - for (const file of files.values()) { - const status = - statuses.get(file.path) ?? - (file.oldPath ? statuses.get(file.oldPath) : undefined); - if (status) Object.assign(file, status); - } - const sorted = [...files.values()].sort((a, b) => - a.path.localeCompare(b.path), - ); - return { - source: "local", - root, - name: basename(root), - branch, - head, - mode, - files: sorted, - revision: digest(JSON.stringify([head, branch, sorted])), - }; - } - - async function patch( - mode: DiffMode, - file: ChangedFile, - head: string | null, - ): Promise { - if (file.recreated && head) return recreatedContents(file.path, head); - if (file.binary) - return { - patch: "", - message: "Binary file changed. No text preview available.", - }; - if (file.status === "U") - return { - patch: "", - message: - "Unresolved merge conflict. Resolve this file in your editor; the viewer is read-only.", - }; - if (mode !== "staged") { - const stat = await lstat(join(root, file.path)).catch(() => null); - if (stat?.isDirectory()) - return { - patch: "", - message: - "Submodule or directory changed. Open its repository to review the contents.", - }; - if (stat && !stat.isFile() && !stat.isSymbolicLink()) - return { - patch: "", - message: "Special file. No text preview available.", - }; - if (stat && stat.size > MAX_PATCH_BYTES) - return { patch: "", message: "File exceeds the 2 MiB preview limit." }; - } - const base = - head ?? - (await git(root, ["hash-object", "-t", "tree", "--stdin"])).stdout.trim(); - const args = - file.status === "?" - ? [ - "diff", - "--no-index", - "--no-ext-diff", - "--no-textconv", - ...patchFormat, - "--", - "/dev/null", - file.path, - ] - : [ - ...diffArgs(mode, base), - "--patch", - "--unified=5", - "--", - ...(file.oldPath ? [file.oldPath] : []), - file.path, - ]; - try { - const { stdout } = await git(root, args, MAX_PATCH_BYTES); - return { - patch: stdout, - message: stdout - ? null - : "No text changes (file mode or metadata changed).", - }; - } catch (error) { - // git diff --no-index returns 1 when differences exist, which is success here. - if ( - file.status === "?" && - error instanceof Error && - "code" in error && - error.code === 1 && - "stdout" in error && - typeof error.stdout === "string" - ) { - return { patch: error.stdout, message: null }; - } - if ( - error instanceof Error && - "code" in error && - error.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" - ) { - return { patch: "", message: "Diff exceeds the 2 MiB preview limit." }; - } - throw new RequestError( - 409, - "File changed while loading. Refresh to try again.", - ); - } - } - async function contents( - mode: DiffMode, - file: ChangedFile, - head: string | null, - ) { - if (file.binary || file.status === "U") - throw new RequestError(400, "Full text is unavailable for this file"); - const previousPath = file.oldPath ?? file.path; - const readBlob = async (source: string, path: string, missing: boolean) => { - try { - return (await git(root, ["show", `${source}:${path}`], MAX_PATCH_BYTES)) - .stdout; - } catch (error) { - if (missing) return ""; - throw error; - } - }; - const readWorking = async () => { - const stat = await lstat(join(root, file.path)); - if (stat.size > MAX_PATCH_BYTES) - throw new RequestError(413, "File exceeds the 2 MiB preview limit"); - return stat.isSymbolicLink() - ? await readlink(join(root, file.path)) - : await readFile(join(root, file.path), "utf8"); - }; - const beforeSource = mode === "unstaged" ? "" : head; - const before = await readBlob( - beforeSource ?? "", - previousPath, - !beforeSource || file.status === "A" || file.status === "?", - ); - const after = - mode === "staged" - ? await readBlob("", file.path, file.status === "D") - : file.status === "D" - ? "" - : await readWorking(); - if (before.includes("\0") || after.includes("\0")) - throw new RequestError(400, "Full text is unavailable for binary files"); - return { before, after }; - } - return { - root, - kind: "local", - scopes: ["all", "staged", "unstaged"], - live: true, - snapshot, - patch, - contents, - } satisfies DiffSource; -} - -export type Repository = DiffSource; diff --git a/apps/server/src/metrics.ts b/apps/server/src/metrics.ts deleted file mode 100644 index bd1d6b86..00000000 --- a/apps/server/src/metrics.ts +++ /dev/null @@ -1,48 +0,0 @@ -interface CpuTime { - user: number; - system: number; -} - -export interface ServerMetrics { - rssBytes: number; - cpuUsage: number; -} - -const minimumSampleNanoseconds = 200_000_000n; - -export function createProcessMetricsCollector( - dependencies: { - clock?: () => bigint; - cpu?: () => CpuTime; - rss?: () => number; - } = {}, -) { - const clock = dependencies.clock ?? process.hrtime.bigint; - const cpu = dependencies.cpu ?? process.cpuUsage; - const rss = dependencies.rss ?? process.memoryUsage.rss; - let previousWall: bigint | null = null; - let previousCpu: CpuTime | null = null; - let cpuUsage = 0; - - return (): ServerMetrics => { - const wall = clock(); - const cpuTime = cpu(); - if (previousWall === null || previousCpu === null) { - previousWall = wall; - previousCpu = cpuTime; - } else { - const wallDelta = wall - previousWall; - if (wallDelta >= minimumSampleNanoseconds) { - const cpuMicroseconds = - cpuTime.user - previousCpu.user + cpuTime.system - previousCpu.system; - const usage = (cpuMicroseconds / (Number(wallDelta) / 1_000)) * 100; - cpuUsage = Number.isFinite(usage) && usage > 0 ? usage : 0; - previousWall = wall; - previousCpu = cpuTime; - } - } - return { rssBytes: rss(), cpuUsage }; - }; -} - -export const getProcessMetrics = createProcessMetricsCollector(); diff --git a/apps/server/src/review-store.ts b/apps/server/src/review-store.ts deleted file mode 100644 index 3712c53e..00000000 --- a/apps/server/src/review-store.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { homedir } from "node:os"; -import { dirname, join } from "node:path"; -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; -import { - validComment, - type DiffMode, - type ReviewComment, - type ReviewMark, -} from "@servediff/shared"; - -interface SessionReview { - comments: ReviewComment[]; - marks: ReviewMark[]; -} - -interface ReviewData { - version: 1; - sessions: Record; -} - -function record(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function reviewMark(value: unknown): value is ReviewMark { - return ( - record(value) && - typeof value.fileId === "string" && - typeof value.fileVersion === "string" && - (value.scope === "all" || - value.scope === "staged" || - value.scope === "unstaged") - ); -} - -function parseData(raw: string): ReviewData { - try { - const value: unknown = JSON.parse(raw); - if (!record(value) || value.version !== 1 || !record(value.sessions)) - throw new Error("Unsupported review data"); - const sessions: Record = {}; - for (const [key, session] of Object.entries(value.sessions)) { - if (!record(session)) continue; - sessions[key] = { - comments: Array.isArray(session.comments) - ? session.comments.filter(validComment) - : [], - marks: Array.isArray(session.marks) - ? session.marks.filter(reviewMark) - : [], - }; - } - return { version: 1, sessions }; - } catch { - throw new Error("Invalid servediff review data"); - } -} - -function missingFile(error: unknown) { - return record(error) && error.code === "ENOENT"; -} - -export function defaultReviewPath(sessionId: string) { - const state = - process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"); - return join(state, "servediff", "reviews", `${sessionId}.json`); -} - -export class ReviewStore { - readonly path: string | null; - private data: Promise; - private pending: Promise = Promise.resolve(); - - constructor(path: string | null) { - this.path = path; - this.data = path - ? readFile(path, "utf8").then(parseData, (error: unknown) => { - if (missingFile(error)) return { version: 1, sessions: {} }; - throw error; - }) - : Promise.resolve({ version: 1, sessions: {} }); - } - - private async session(id: string) { - const data = await this.data; - const current = data.sessions[id] ?? { comments: [], marks: [] }; - data.sessions[id] = current; - return current; - } - - private async persist() { - if (!this.path) return; - const data = await this.data; - await mkdir(dirname(this.path), { recursive: true, mode: 0o700 }); - const temporary = `${this.path}.${process.pid}.tmp`; - await writeFile(temporary, JSON.stringify(data), { - encoding: "utf8", - mode: 0o600, - }); - await rename(temporary, this.path); - } - - private async mutate(change: () => Promise) { - this.pending = this.pending.then(async () => { - await change(); - await this.persist(); - }); - await this.pending; - } - - async comments(sessionId: string) { - await this.pending; - return [...(await this.session(sessionId)).comments]; - } - - async putComment(sessionId: string, comment: ReviewComment) { - await this.mutate(async () => { - const session = await this.session(sessionId); - const index = session.comments.findIndex( - (entry) => entry.id === comment.id, - ); - if (index < 0) session.comments.push(comment); - else session.comments[index] = comment; - }); - } - - async importComments(sessionId: string, comments: readonly ReviewComment[]) { - await this.mutate(async () => { - const session = await this.session(sessionId); - const ids = new Set(session.comments.map((comment) => comment.id)); - for (const comment of comments) { - if (ids.has(comment.id)) continue; - session.comments.push(comment); - ids.add(comment.id); - } - }); - return this.comments(sessionId); - } - - async deleteComment(sessionId: string, id: string) { - let deleted = false; - await this.mutate(async () => { - const session = await this.session(sessionId); - const next = session.comments.filter((comment) => comment.id !== id); - deleted = next.length !== session.comments.length; - session.comments = next; - }); - return deleted; - } - - async deleteComments(sessionId: string, ids: ReadonlySet) { - let deleted = 0; - await this.mutate(async () => { - const session = await this.session(sessionId); - const next = session.comments.filter((comment) => !ids.has(comment.id)); - deleted = session.comments.length - next.length; - session.comments = next; - }); - return deleted; - } - - async marks(sessionId: string, scope: DiffMode) { - await this.pending; - return (await this.session(sessionId)).marks.filter( - (mark) => mark.scope === scope, - ); - } - - async putMark(sessionId: string, mark: ReviewMark) { - await this.mutate(async () => { - const session = await this.session(sessionId); - session.marks = session.marks.filter( - (entry) => entry.scope !== mark.scope || entry.fileId !== mark.fileId, - ); - session.marks.push(mark); - }); - } - - async deleteMark(sessionId: string, scope: DiffMode, fileId: string) { - await this.mutate(async () => { - const session = await this.session(sessionId); - session.marks = session.marks.filter( - (mark) => mark.scope !== scope || mark.fileId !== fileId, - ); - }); - } - - async clearMarks(sessionId: string, scope: DiffMode) { - await this.mutate(async () => { - const session = await this.session(sessionId); - session.marks = session.marks.filter((mark) => mark.scope !== scope); - }); - } -} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts deleted file mode 100644 index c143f334..00000000 --- a/apps/server/src/server.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { createHash, randomBytes } from "node:crypto"; -import { createReadStream } from "node:fs"; -import { readFile, stat } from "node:fs/promises"; -import { createServer } from "node:http"; -import { networkInterfaces } from "node:os"; -import { extname, resolve, sep } from "node:path"; -import { fileURLToPath } from "node:url"; -import type { DiffMode, RepositoryDiff } from "@servediff/shared"; -import { handleApi, respondWithProblem } from "./api.ts"; -import { openRepository } from "./git.ts"; -import { defaultReviewPath, ReviewStore } from "./review-store.ts"; -import { RequestError } from "./source.ts"; -import { openPatch } from "./stdin.ts"; - -const webRoot = fileURLToPath(new URL("../../web/", import.meta.url)); -const mime: Record = { - ".html": "text/html; charset=utf-8", - ".js": "text/javascript; charset=utf-8", - ".css": "text/css; charset=utf-8", - ".svg": "image/svg+xml", - ".woff2": "font/woff2", - ".wasm": "application/wasm", - ".json": "application/json", -}; - -function localNetworkAddress() { - for (const addresses of Object.values(networkInterfaces())) { - for (const address of addresses ?? []) { - if (address.family === "IPv4" && !address.internal) - return address.address; - } - } - return null; -} - -export async function startServer(options: { - directory: string; - port: number; - dev?: boolean; - input?: string; - token?: string; - store?: ReviewStore; -}) { - const source = - options.input === undefined - ? await openRepository(options.directory) - : openPatch(options.input); - const token = options.token ?? randomBytes(24).toString("hex"); - const sessionId = createHash("sha256") - .update(`${source.kind}\0${source.root}`) - .digest("hex"); - const store = options.store ?? new ReviewStore(defaultReviewPath(sessionId)); - const dist = resolve(webRoot, "dist"); - if (!options.dev) { - await stat(resolve(dist, "index.html")).catch(() => { - throw new Error( - "Web assets missing. Run pnpm build in the servediff checkout, or start with servediff . --dev.", - ); - }); - } - const vite = options.dev - ? await import("vite").then(({ createServer }) => - createServer({ - root: webRoot, - server: { middlewareMode: true, hmr: false }, - appType: "custom", - }), - ) - : null; - const snapshots = new Map< - DiffMode, - { time: number; result: Promise } - >(); - const networkAddress = localNetworkAddress(); - function snapshot(mode: DiffMode, fresh = false) { - const cached = snapshots.get(mode); - if (!fresh && cached && Date.now() - cached.time < 500) - return cached.result; - const result = source.snapshot(mode); - snapshots.set(mode, { time: Date.now(), result }); - result.catch(() => snapshots.delete(mode)); - return result; - } - - const server = createServer(async (request, response) => { - try { - // Reject unadvertised hosts before exposing local code. - const host = request.headers.host; - const address = server.address(); - const port = - address && typeof address === "object" ? address.port : options.port; - const allowedHosts = ["127.0.0.1", "localhost", "0.0.0.0"]; - if (networkAddress) allowedHosts.push(networkAddress); - if (!allowedHosts.some((address) => host === `${address}:${port}`)) - throw new RequestError(403, "Invalid host"); - if (request.headers.origin && request.headers.origin !== `http://${host}`) - throw new RequestError(403, "Cross-origin access denied"); - if (request.headers["sec-fetch-site"] === "cross-site") - throw new RequestError(403, "Cross-site access denied"); - response.setHeader("X-Content-Type-Options", "nosniff"); - response.setHeader("Referrer-Policy", "no-referrer"); - response.setHeader("Cache-Control", "no-store"); - const url = new URL(request.url ?? "/", `http://${host}`); - if ( - await handleApi(request, response, url, { - source, - sessionId, - token, - store, - snapshot, - }) - ) - return; - if (request.method !== "GET" && request.method !== "HEAD") - throw new RequestError(405, "Method not allowed"); - if (vite) { - if (url.pathname === "/") { - const html = await vite.transformIndexHtml( - "/", - await readFile(resolve(webRoot, "index.html"), "utf8"), - ); - response.setHeader("Content-Type", mime[".html"] ?? "text/html"); - response.end(request.method === "HEAD" ? undefined : html); - } else { - vite.middlewares(request, response, () => { - response.writeHead(404).end("Not found"); - }); - } - return; - } - const path = resolve( - dist, - `.${decodeURIComponent(url.pathname === "/" ? "/index.html" : url.pathname)}`, - ); - if (!path.startsWith(`${dist}${sep}`)) - throw new RequestError(404, "Not found"); - const file = await stat(path).catch(() => null); - if (!file?.isFile()) throw new RequestError(404, "Not found"); - response.setHeader( - "Content-Type", - mime[extname(path)] ?? "application/octet-stream", - ); - if (request.method === "HEAD") response.end(); - else - createReadStream(path) - .on("error", () => response.destroy()) - .pipe(response); - } catch (error) { - if (response.headersSent) { - response.destroy(); - return; - } - await respondWithProblem(response, error); - } - }); - try { - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(options.port, "0.0.0.0", resolve); - }); - } catch (error) { - await vite?.close(); - throw error; - } - const address = server.address(); - if (!address || typeof address === "string") - throw new Error("Unable to bind server"); - return { - root: source.root, - token, - sessionId, - url: `http://127.0.0.1:${address.port}`, - addresses: { - localhost: `http://localhost:${address.port}#token=${token}`, - all: `http://0.0.0.0:${address.port}#token=${token}`, - network: networkAddress - ? `http://${networkAddress}:${address.port}#token=${token}` - : null, - }, - async close() { - await vite?.close(); - server.closeAllConnections(); - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ); - }, - }; -} diff --git a/apps/server/src/source.ts b/apps/server/src/source.ts deleted file mode 100644 index 38ee1fb0..00000000 --- a/apps/server/src/source.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { - ChangedFile, - DiffMode, - FilePatch, - RepositoryDiff, -} from "@servediff/shared"; - -export interface DiffSource { - root: string; - kind: "local" | "stdin"; - scopes: readonly DiffMode[]; - live: boolean; - snapshot(mode: DiffMode): Promise; - patch( - mode: DiffMode, - file: ChangedFile, - head: string | null, - ): Promise; - contents( - mode: DiffMode, - file: ChangedFile, - head: string | null, - ): Promise<{ before: string; after: string }>; -} - -export class RequestError extends Error { - status: number; - constructor(status: number, message: string) { - super(message); - this.status = status; - } -} diff --git a/apps/server/src/stdin.ts b/apps/server/src/stdin.ts deleted file mode 100644 index 96b252a2..00000000 --- a/apps/server/src/stdin.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { createHash } from "node:crypto"; -import type { Readable } from "node:stream"; -import { stripVTControlCharacters } from "node:util"; -import { parsePatchFiles } from "@pierre/diffs"; -import type { ChangedFile, FilePatch, RepositoryDiff } from "@servediff/shared"; -import { type DiffSource, RequestError } from "./source.ts"; - -const MAX_INPUT_BYTES = 16 * 1024 * 1024; -const MAX_FILE_BYTES = 2 * 1024 * 1024; - -// Wait for EOF so the viewer represents exactly one bounded command result. -export async function readPatchInput(input: Readable): Promise { - const chunks: Buffer[] = []; - let size = 0; - for await (const chunk of input) { - if (!Buffer.isBuffer(chunk)) - throw new Error("Expected binary stdin stream"); - size += chunk.length; - if (size > MAX_INPUT_BYTES) - throw new Error("Piped diff exceeds the 16 MiB input limit"); - chunks.push(chunk); - } - return Buffer.concat(chunks).toString("utf8"); -} - -// Git headers are unprefixed; hunk contents always start with space, +, or -. -// Split commit boundaries before file boundaries to avoid swallowing later commits. -export function openPatch(input: string): DiffSource { - if (Buffer.byteLength(input) > MAX_INPUT_BYTES) - throw new Error("Piped diff exceeds the 16 MiB input limit"); - const data = stripVTControlCharacters(input); - if (/^diff --(?:cc|combined) /m.test(data)) - throw new Error( - "Combined merge diffs are not supported. Use git show --diff-merges=separate | servediff instead.", - ); - const revision = createHash("sha256").update(data).digest("hex"); - const root = `stdin:${revision}`; - const files: ChangedFile[] = []; - const previews = new Map(); - const commits = data - .split(/(?=^(?:commit [a-f\d]{40,64}(?: .*)?|From [a-f\d]{40,64} .*)$)/m) - .filter((part) => /^diff --git /m.test(part)); - let section = 0; - for (const commit of commits) { - section++; - const commitId = /^(?:commit|From) ([a-f\d]{40,64})/m.exec(commit)?.[1]; - const prefix = - commits.length > 1 - ? `${section} ยท ${commitId?.slice(0, 8) ?? "patch"}/` - : ""; - const chunks = commit - .split(/(?=^diff --git )/m) - .filter((part) => part.startsWith("diff --git ")); - for (const patch of chunks) { - if (Buffer.byteLength(patch) > MAX_FILE_BYTES) - throw new Error( - "A file in the piped diff exceeds the 2 MiB preview limit", - ); - const parsed = parsePatchFiles(patch, undefined, true).flatMap( - (entry) => entry.files, - ); - const file = parsed[0]; - if (!file || parsed.length !== 1) - throw new Error("Could not parse a file in the piped diff"); - const path = `${prefix}${file.name}`; - if (previews.has(path)) - throw new Error( - `Repeated file in patch: ${path}. Pipe standard git show or git diff output.`, - ); - const binary = /^(Binary files .* differ|GIT binary patch)$/m.test(patch); - const fingerprint = createHash("sha256").update(patch).digest("hex"); - files.push({ - id: createHash("sha256").update(path).digest("hex"), - path, - oldPath: file.prevName ? `${prefix}${file.prevName}` : null, - status: - file.type === "new" - ? "A" - : file.type === "deleted" - ? "D" - : file.type.startsWith("rename") - ? "R" - : /^copy from /m.test(patch) - ? "C" - : file.mode && - file.prevMode && - file.mode[0] !== file.prevMode[0] - ? "T" - : "M", - indexStatus: "", - worktreeStatus: "", - additions: file.hunks.reduce( - (sum, hunk) => sum + hunk.additionLines, - 0, - ), - deletions: file.hunks.reduce( - (sum, hunk) => sum + hunk.deletionLines, - 0, - ), - binary, - fingerprint, - }); - previews.set(path, { - patch: binary ? "" : patch, - message: binary - ? "Binary file changed. No text preview available." - : null, - }); - } - } - if ( - !files.length && - data.trim() && - !/^(commit |From )[a-f\d]{40,64}/m.test(data) - ) - throw new Error( - "No Git patch found on stdin. Pipe git diff or git show output into servediff.", - ); - const manifest: RepositoryDiff = { - source: "stdin", - root, - name: "Piped diff", - branch: "stdin", - head: null, - mode: "all", - files, - revision, - }; - return { - root, - kind: "stdin", - scopes: ["all"], - live: false, - async snapshot(mode) { - if (mode !== "all") - throw new RequestError( - 400, - "Piped diffs have no staged or unstaged scope", - ); - return manifest; - }, - async patch(_mode, file) { - const preview = previews.get(file.path); - if (!preview) - throw new RequestError(404, "File is not in the piped diff"); - return preview; - }, - async contents() { - throw new RequestError( - 400, - "Full context is unavailable for piped diffs", - ); - }, - }; -} diff --git a/apps/server/test/api.test.ts b/apps/server/test/api.test.ts deleted file mode 100644 index 1083a018..00000000 --- a/apps/server/test/api.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { test } from "node:test"; -import { fileURLToPath } from "node:url"; -import { createApiClient } from "@servediff/api"; -import { ReviewStore } from "../src/review-store.ts"; -import { startServer } from "../src/server.ts"; - -const input = await readFile( - fileURLToPath(new URL("../../../test/fixtures/sample.diff", import.meta.url)), - "utf8", -); - -test("persists comments and review marks through the authenticated API", async (t) => { - const path = join( - tmpdir(), - `servediff-review-${process.pid}-${Date.now()}.json`, - ); - t.after(() => rm(path, { force: true })); - const token = "test-token"; - const first = await startServer({ - directory: ".", - input, - port: 0, - dev: true, - token, - store: new ReviewStore(path), - }); - const api = `${first.url}/api/v1`; - const client = createApiClient({ baseUrl: first.url, token }); - assert.equal((await fetch(`${first.url}/openapi.yaml`)).status, 200); - assert.equal((await fetch(`${api}/comments`)).status, 401); - assert.equal((await fetch(`${api}/metrics`)).status, 401); - const { data: metrics } = await client.GET("/api/v1/metrics"); - assert.ok(metrics); - assert.ok(metrics.rssBytes > 0); - assert.ok(Number.isFinite(metrics.cpuUsage)); - assert.ok(metrics.cpuUsage >= 0); - const { data: session } = await client.GET("/api/v1/session"); - assert.ok(session); - assert.deepEqual(session.capabilities, { - scopes: ["all"], - live: false, - fullFileContents: false, - }); - const { data: snapshot } = await client.GET("/api/v1/diffs/current", { - params: { query: { scope: "all" } }, - }); - assert.ok(snapshot); - const file = snapshot.files.find((entry) => entry.path === "src/value.ts"); - assert.ok(file); - const { data: created, response: createdResponse } = await client.POST( - "/api/v1/comments", - { - body: { - diffId: snapshot.revision, - fileId: file.id, - scope: "all", - fileVersion: file.fingerprint, - side: "additions", - start: 1, - end: 1, - body: "Keep the new value.", - }, - }, - ); - assert.equal(createdResponse.status, 201); - assert.ok(created); - assert.equal(created.code, "+ export const value = 2;"); - assert.ok(created.origin); - assert.equal(created.origin.revision, snapshot.revision); - const { data: resolved } = await client.PATCH( - "/api/v1/comments/{commentId}", - { - params: { path: { commentId: created.id } }, - body: { status: "resolved" }, - }, - ); - assert.ok(resolved); - assert.equal(resolved.status, "resolved"); - const { response: importResponse } = await client.POST( - "/api/v1/comments/import", - { - body: { - comments: [ - { - ...created, - id: "stale-comment", - fingerprint: "earlier-file-version", - status: "open", - }, - ], - }, - }, - ); - assert.equal(importResponse.status, 200); - const { response: markResponse } = await client.PUT( - "/api/v1/review-marks/{fileId}", - { - params: { - path: { fileId: file.id }, - query: { scope: "all" }, - }, - body: { fileVersion: file.fingerprint }, - }, - ); - assert.equal(markResponse.status, 200); - const unresolvedExport = await client.GET("/api/v1/comments/export", { - params: { query: { includeResolved: false } }, - parseAs: "text", - }); - assert.equal(unresolvedExport.data ?? "", ""); - const allExport = - ( - await client.GET("/api/v1/comments/export", { - params: { query: { includeResolved: true } }, - parseAs: "text", - }) - ).data ?? ""; - assert.match(allExport, /Keep the new value/); - assert.match(allExport, /status="open" applicability="stale"/); - assert.match(allExport, /status="resolved" applicability="anchored"/); - const singleExport = - ( - await client.GET("/api/v1/comments/export", { - params: { - query: { includeResolved: true, commentId: "stale-comment" }, - }, - parseAs: "text", - }) - ).data ?? ""; - assert.match(singleExport, /status="open" applicability="stale"/); - assert.doesNotMatch(singleExport, /applicability="anchored"/); - await first.close(); - - const second = await startServer({ - directory: ".", - input, - port: 0, - dev: true, - token, - store: new ReviewStore(path), - }); - t.after(() => second.close()); - const secondClient = createApiClient({ baseUrl: second.url, token }); - const { data: comments } = await secondClient.GET("/api/v1/comments"); - assert.ok(comments); - assert.equal(comments.comments[0]?.id, created.id); - const { data: marks } = await secondClient.GET("/api/v1/review-marks", { - params: { query: { scope: "all" } }, - }); - assert.ok(marks); - assert.deepEqual(marks.marks, [ - { fileId: file.id, fileVersion: file.fingerprint, scope: "all" }, - ]); - const { data: staleDeletion } = await secondClient.DELETE( - "/api/v1/comments", - { params: { query: { status: "stale" } } }, - ); - assert.ok(staleDeletion); - assert.equal(staleDeletion.deleted, 1); - assert.deepEqual( - staleDeletion.comments.map((comment) => comment.status), - ["resolved"], - ); - const { data: resolvedDeletion } = await secondClient.DELETE( - "/api/v1/comments", - { params: { query: { status: "resolved" } } }, - ); - assert.ok(resolvedDeletion); - assert.equal(resolvedDeletion.deleted, 1); - assert.deepEqual(resolvedDeletion.comments, []); - const { data: restored } = await secondClient.POST( - "/api/v1/comments/import", - { - body: { - comments: [ - { ...created, id: "bulk-open", status: "open" }, - { ...resolved, id: "bulk-resolved", status: "resolved" }, - ], - }, - }, - ); - assert.equal(restored?.comments.length, 2); - const { data: openDeletion } = await secondClient.DELETE("/api/v1/comments", { - params: { query: { status: "open" } }, - }); - assert.ok(openDeletion); - assert.equal(openDeletion.deleted, 1); - assert.equal(openDeletion.comments[0]?.status, "resolved"); - const { data: allDeletion } = await secondClient.DELETE("/api/v1/comments", { - params: { query: { status: "all" } }, - }); - assert.ok(allDeletion); - assert.equal(allDeletion.deleted, 1); - assert.deepEqual(allDeletion.comments, []); -}); diff --git a/apps/server/test/browser.test.ts b/apps/server/test/browser.test.ts deleted file mode 100644 index ce23730d..00000000 --- a/apps/server/test/browser.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { browserCommand, isSshSession, openBrowser } from "../src/browser.ts"; - -test("selects the browser launcher for supported platforms", () => { - assert.equal(browserCommand("darwin"), "open"); - assert.equal(browserCommand("linux"), "xdg-open"); - assert.equal(browserCommand("win32"), null); -}); - -test("recognizes SSH sessions", () => { - assert.equal(isSshSession({ SSH_CONNECTION: "host 1 2 3" }), true); - assert.equal(isSshSession({ SSH_TTY: "/dev/pts/0" }), true); - assert.equal(isSshSession({}), false); -}); - -test("opens localhost with the platform launcher outside SSH", async () => { - const calls: Array<[string, string]> = []; - const error = await openBrowser("http://localhost:3333", { - platform: "linux", - environment: {}, - launcher: async (command, url) => { - calls.push([command, url]); - }, - }); - assert.equal(error, null); - assert.deepEqual(calls, [["xdg-open", "http://localhost:3333"]]); -}); - -test("does not launch a browser in SSH or on unsupported platforms", async () => { - const launcher = async () => { - throw new Error("should not launch"); - }; - assert.equal( - await openBrowser("http://localhost:3333", { - environment: { SSH_CONNECTION: "host 1 2 3" }, - launcher, - }), - null, - ); - assert.equal( - await openBrowser("http://localhost:3333", { - platform: "win32", - environment: {}, - launcher, - }), - null, - ); -}); - -test("reports launcher failures without throwing", async () => { - const error = await openBrowser("http://localhost:3333", { - platform: "darwin", - environment: {}, - launcher: async () => { - throw new Error("not found"); - }, - }); - assert.equal(error, "Unable to open browser with open: not found"); -}); diff --git a/apps/server/test/git.test.ts b/apps/server/test/git.test.ts deleted file mode 100644 index 59653672..00000000 --- a/apps/server/test/git.test.ts +++ /dev/null @@ -1,507 +0,0 @@ -import assert from "node:assert/strict"; -import { execFile } from "node:child_process"; -import { - mkdir, - mkdtemp, - readFile, - rm, - symlink, - writeFile, -} from "node:fs/promises"; -import { get } from "node:http"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { type TestContext, test } from "node:test"; -import { promisify } from "node:util"; -import { openRepository } from "../src/git.ts"; -import { startServer } from "../src/server.ts"; - -const execute = promisify(execFile); -async function fixture(t: TestContext) { - const root = await mkdtemp(join(tmpdir(), "servediff-test-")); - t.after(() => rm(root, { recursive: true, force: true })); - async function git(...args: string[]) { - return ( - await execute("git", args, { - cwd: root, - env: { - ...process.env, - GIT_AUTHOR_NAME: "Test", - GIT_AUTHOR_EMAIL: "test@example.com", - GIT_COMMITTER_NAME: "Test", - GIT_COMMITTER_EMAIL: "test@example.com", - }, - }) - ).stdout; - } - await git("init", "-b", "main"); - const repository = await openRepository(root); - return { - root, - git, - repository, - write: (path: string, contents: string | Buffer) => - writeFile(join(root, path), contents), - }; -} - -test("separates HEAD, index, working tree, and untracked files without writing the index", async (t) => { - const { root, git, repository, write } = await fixture(t); - await write("example.ts", "export const value = 'committed';\n"); - await write(".gitignore", "ignored.txt\n"); - await git("add", "."); - await git("commit", "-m", "Initial"); - await write("example.ts", "export const value = 'staged';\n"); - await git("add", "example.ts"); - await write("example.ts", "export const value = 'working';\n"); - await write("untracked.txt", "new file\n"); - await write("ignored.txt", "ignored\n"); - const indexBefore = await readFile(join(root, ".git/index")); - for (const mode of ["all", "staged", "unstaged"] satisfies Array< - "all" | "staged" | "unstaged" - >) { - const snapshot = await repository.snapshot(mode); - assert.equal( - snapshot.files.some((file) => file.path === "untracked.txt"), - mode !== "staged", - ); - assert.equal( - snapshot.files.some((file) => file.path === "ignored.txt"), - false, - ); - const file = snapshot.files.find((file) => file.path === "example.ts"); - assert.ok(file); - assert.equal(file.indexStatus, "M"); - assert.equal(file.worktreeStatus, "M"); - const { patch } = await repository.patch(mode, file, snapshot.head); - assert.match( - patch, - new RegExp( - `-${"export const value = '"}${mode === "unstaged" ? "staged" : "committed"}`, - ), - ); - assert.deepEqual(await repository.contents(mode, file, snapshot.head), { - before: `export const value = '${mode === "unstaged" ? "staged" : "committed"}';\n`, - after: `export const value = '${mode === "staged" ? "staged" : "working"}';\n`, - }); - assert.match( - patch, - new RegExp( - `\\+export const value = '${mode === "staged" ? "staged" : "working"}`, - ), - ); - } - assert.deepEqual(await readFile(join(root, ".git/index")), indexBefore); -}); - -test("supports unborn repositories and resolves subdirectories to the working tree root", async (t) => { - const { root, git, repository, write } = await fixture(t); - await write("first.txt", "first\n"); - const untracked = await repository.snapshot("all"); - assert.equal(untracked.head, null); - assert.equal(untracked.files[0]?.status, "?"); - assert.equal(untracked.files[0]?.indexStatus, "?"); - assert.equal(untracked.files[0]?.worktreeStatus, "?"); - const first = untracked.files[0]; - assert.ok(first); - assert.match((await repository.patch("all", first, null)).patch, /\+first/); - await git("add", "."); - const staged = await repository.snapshot("staged"); - assert.equal(staged.files[0]?.status, "A"); - assert.equal(staged.files[0]?.indexStatus, "A"); - assert.equal(staged.files[0]?.worktreeStatus, " "); - const stagedFile = staged.files[0]; - assert.ok(stagedFile); - assert.match( - (await repository.patch("staged", stagedFile, null)).patch, - /\+first/, - ); - assert.equal((await repository.snapshot("unstaged")).files.length, 0); - await mkdir(join(root, "nested")); - assert.equal( - (await openRepository(join(root, "nested"))).root, - repository.root, - ); -}); - -test("preserves rename pairs, unusual filenames, deletions, binary and mode-only changes", async (t) => { - const { git, repository, write, root } = await fixture(t); - await write("old.txt", "keep this line\n"); - await write("delete.txt", "delete\n"); - await write("mode.sh", "echo hello\n"); - await write("binary.bin", Buffer.from([0, 1, 2])); - await git("add", "."); - await git("commit", "-m", "Initial"); - const path = "renamed\twith\n้›ช.txt"; - await git("mv", "old.txt", path); - await git("rm", "delete.txt"); - await git("update-index", "--chmod=+x", "mode.sh"); - await write("binary.bin", Buffer.from([0, 2, 3])); - await git("add", "binary.bin"); - const snapshot = await repository.snapshot("staged"); - const renamed = snapshot.files.find((file) => file.path === path); - assert.ok(renamed); - assert.equal(renamed.oldPath, "old.txt"); - assert.equal(renamed.status, "R"); - assert.equal(renamed.indexStatus, "R"); - assert.equal(renamed.worktreeStatus, " "); - assert.deepEqual( - await repository.contents("staged", renamed, snapshot.head), - { before: "keep this line\n", after: "keep this line\n" }, - ); - const deleted = snapshot.files.find((file) => file.path === "delete.txt"); - assert.ok(deleted); - assert.equal(deleted.deletions, 1); - assert.deepEqual( - await repository.contents("staged", deleted, snapshot.head), - { before: "delete\n", after: "" }, - ); - const binary = snapshot.files.find((file) => file.path === "binary.bin"); - assert.ok(binary); - assert.equal(binary.binary, true); - assert.match( - (await repository.patch("staged", binary, snapshot.head)).message ?? "", - /Binary/, - ); - await assert.rejects( - repository.contents("staged", binary, snapshot.head), - /Full text is unavailable/, - ); - const mode = snapshot.files.find((file) => file.path === "mode.sh"); - assert.ok(mode); - assert.match( - (await repository.patch("staged", mode, snapshot.head)).patch, - /new mode 100755/, - ); - await write(":(glob)*.txt", "literal\n"); - const all = await repository.snapshot("all"); - const literal = all.files.find((file) => file.path === ":(glob)*.txt"); - assert.ok(literal); - assert.match( - (await repository.patch("all", literal, all.head)).patch, - /\+literal/, - ); - await rm(join(root, ":(glob)*.txt")); -}); - -test("refresh fingerprints detect same-size edits and index-only updates", async (t) => { - const { git, repository, write } = await fixture(t); - await write("value.txt", "base\n"); - await git("add", "."); - await git("commit", "-m", "Initial"); - await write("value.txt", "edit\n"); - const before = await repository.snapshot("all"); - await write("value.txt", "next\n"); - const after = await repository.snapshot("all"); - assert.notEqual(before.revision, after.revision); - await git("add", "."); - const stagedBefore = await repository.snapshot("staged"); - await write("value.txt", "more\n"); - await git("add", "."); - assert.notEqual( - stagedBefore.revision, - (await repository.snapshot("staged")).revision, - ); -}); - -test("does not follow untracked symlinks and limits oversized previews", async (t) => { - const { root, repository, write } = await fixture(t); - const secret = join(root, ".git", "secret"); - await writeFile(secret, "DO NOT READ TARGET\n"); - await symlink(secret, join(root, "link.txt")); - await write("large.txt", "x".repeat(2 * 1024 * 1024 + 1)); - const snapshot = await repository.snapshot("all"); - const link = snapshot.files.find((file) => file.path === "link.txt"); - assert.ok(link); - const result = await repository.patch("all", link, null); - assert.doesNotMatch(result.patch, /DO NOT READ TARGET/); - assert.deepEqual(await repository.contents("all", link, null), { - before: "", - after: secret, - }); - const large = snapshot.files.find((file) => file.path === "large.txt"); - assert.ok(large); - assert.match( - (await repository.patch("all", large, null)).message ?? "", - /2 MiB/, - ); - await assert.rejects(repository.contents("all", large, null), /2 MiB/); -}); - -test("reports merge conflicts and detached HEAD", async (t) => { - const { git, repository, write } = await fixture(t); - await write("conflict.txt", "base\n"); - await git("add", "."); - await git("commit", "-m", "Initial"); - await git("checkout", "-b", "other"); - await write("conflict.txt", "other\n"); - await git("commit", "-am", "Other"); - await git("checkout", "main"); - await write("conflict.txt", "main\n"); - await git("commit", "-am", "Main"); - await assert.rejects(git("merge", "other")); - const snapshot = await repository.snapshot("unstaged"); - const conflict = snapshot.files.find((file) => file.path === "conflict.txt"); - assert.ok(conflict); - assert.equal(conflict.status, "U"); - assert.equal(conflict.indexStatus, "U"); - assert.equal(conflict.worktreeStatus, "U"); - assert.match( - (await repository.patch("unstaged", conflict, snapshot.head)).message ?? "", - /conflict/, - ); - await git("merge", "--abort"); - await git("checkout", "--detach"); - assert.match((await repository.snapshot("all")).branch, /^detached at/); -}); - -test("serves browser assets and validates API paths, versions, modes, hosts, origins, methods", async (t) => { - const { root, repository, write } = await fixture(t); - await write("hello.ts", "export const hello = true;\n"); - const server = await startServer({ directory: root, port: 0, dev: true }); - t.after(() => server.close()); - assert.match( - server.addresses.localhost, - /^http:\/\/localhost:\d+#token=[a-f\d]+$/, - ); - assert.match( - server.addresses.all, - /^http:\/\/0\.0\.0\.0:\d+#token=[a-f\d]+$/, - ); - if (server.addresses.network) { - assert.match( - server.addresses.network, - /^http:\/\/\d{1,3}(?:\.\d{1,3}){3}:\d+#token=[a-f\d]+$/, - ); - assert.equal((await fetch(server.addresses.network)).status, 200); - } - const snapshot = await repository.snapshot("all"); - const file = snapshot.files[0]; - assert.ok(file); - const query = new URLSearchParams({ - scope: "all", - fileVersion: file.fingerprint, - }); - const fileURL = `${server.url}/api/v1/diffs/${snapshot.revision}/files/${file.id}/patch?${query}`; - const contentsURL = `${server.url}/api/v1/diffs/${snapshot.revision}/files/${file.id}/contents?${query}`; - const authorized = { headers: { Authorization: `Bearer ${server.token}` } }; - assert.equal((await fetch(server.url)).status, 200); - assert.match(await (await fetch(server.url)).text(), /servediff/); - assert.equal((await fetch(`${server.url}/logo.png`)).status, 404); - assert.equal( - (await fetch(`${server.url}/api/v1/diffs/current?scope=all`)).status, - 401, - ); - assert.equal( - (await fetch(`${server.url}/api/v1/diffs/current?scope=all`, authorized)) - .status, - 200, - ); - assert.match( - await (await fetch(fileURL, authorized)).text(), - /export const hello/, - ); - assert.deepEqual(await (await fetch(contentsURL, authorized)).json(), { - before: "", - after: "export const hello = true;\n", - }); - assert.equal( - (await fetch(`${server.url}/api/v1/diffs/current?scope=bad`, authorized)) - .status, - 400, - ); - assert.equal( - ( - await fetch( - `${server.url}/api/v1/diffs/${snapshot.revision}/files/missing/patch?scope=all&fileVersion=x`, - authorized, - ) - ).status, - 404, - ); - assert.equal( - ( - await fetch( - `${server.url}/api/v1/diffs/${snapshot.revision}/files/${file.id}/patch?scope=all&fileVersion=stale`, - authorized, - ) - ).status, - 409, - ); - assert.equal( - ( - await fetch( - `${server.url}/api/v1/diffs/stale/files/${file.id}/contents?scope=all&fileVersion=${file.fingerprint}`, - authorized, - ) - ).status, - 409, - ); - const invalidHostStatus = await new Promise( - (resolve, reject) => { - get( - `${server.url}/api/v1/diffs/current?scope=all`, - { headers: { Host: "evil.example" } }, - (response) => { - response.resume(); - resolve(response.statusCode); - }, - ).on("error", reject); - }, - ); - assert.equal(invalidHostStatus, 403); - assert.equal( - ( - await fetch(`${server.url}/api/v1/diffs/current?scope=all`, { - headers: { - Authorization: `Bearer ${server.token}`, - Origin: "https://evil.example", - }, - }) - ).status, - 403, - ); - assert.equal( - ( - await fetch(`${server.url}/api/v1/diffs/current?scope=all`, { - ...authorized, - method: "POST", - }) - ).status, - 405, - ); - assert.equal((await fetch(`${server.url}/missing.js`)).status, 404); -}); - -test("rejects directories outside a Git working tree", async (t) => { - const root = await mkdtemp(join(tmpdir(), "servediff-not-git-")); - t.after(() => rm(root, { recursive: true, force: true })); - await assert.rejects(openRepository(root), /Not a Git working tree/); -}); - -test("ignores Git display settings and external diff drivers", async (t) => { - const { git, repository, write } = await fixture(t); - await write("file.txt", "before\n"); - await git("add", "."); - await git("commit", "-m", "Initial"); - await git("config", "color.ui", "always"); - await git("config", "diff.noprefix", "true"); - await git("config", "diff.outputIndicatorNew", ">"); - await git("config", "diff.external", "this-command-must-not-run"); - await write("file.txt", "after\n"); - const snapshot = await repository.snapshot("all"); - const file = snapshot.files[0]; - assert.ok(file); - const { patch } = await repository.patch("all", file, snapshot.head); - assert.match(patch, /--- a\/file.txt/); - assert.match(patch, /\+after/); - assert.equal(patch.includes(String.fromCharCode(27)), false); -}); - -test("supports linked worktrees", async (t) => { - const { git, write } = await fixture(t); - await write("file.txt", "before\n"); - await git("add", "."); - await git("commit", "-m", "Initial"); - const directory = await mkdtemp(join(tmpdir(), "servediff-worktree-")); - t.after(() => rm(directory, { recursive: true, force: true })); - await git("worktree", "add", "--detach", directory, "HEAD"); - await writeFile(join(directory, "file.txt"), "linked worktree\n"); - const repository = await openRepository(directory); - const snapshot = await repository.snapshot("all"); - assert.equal(snapshot.files[0]?.path, "file.txt"); - assert.match(snapshot.branch, /^detached at/); -}); - -test("All changes retains net HEAD diff when a staged deletion is recreated", async (t) => { - const { git, repository, write } = await fixture(t); - await write("file.txt", "original\n"); - await git("add", "."); - await git("commit", "-m", "Initial"); - await git("rm", "file.txt"); - await write("file.txt", "recreated\n"); - const all = await repository.snapshot("all"); - const file = all.files[0]; - assert.ok(file); - assert.equal(file.status, "M"); - assert.equal(file.indexStatus, "D"); - assert.equal(file.worktreeStatus, "?"); - const { contents } = await repository.patch("all", file, all.head); - assert.equal(contents?.before, "original\n"); - assert.equal(contents?.after, "recreated\n"); - assert.equal((await repository.snapshot("staged")).files[0]?.status, "D"); - assert.equal((await repository.snapshot("unstaged")).files[0]?.status, "?"); - await write("file.txt", "original\n"); - assert.equal((await repository.snapshot("all")).files.length, 0); -}); - -test("reports staging transitions and deleted files independently of the selected diff scope", async (t) => { - const { git, repository, write, root } = await fixture(t); - await write("modified.ts", "stable first line\nbefore\nstable last line\n"); - await write("deleted.md", "before\n"); - await git("add", "."); - await git("commit", "-m", "Initial"); - await write("modified.ts", "stable first line\nafter\nstable last line\n"); - await rm(join(root, "deleted.md")); - const unstaged = await repository.snapshot("all"); - assert.deepEqual( - unstaged.files.map(({ path, status, indexStatus, worktreeStatus }) => [ - path, - status, - indexStatus, - worktreeStatus, - ]), - [ - ["deleted.md", "D", " ", "D"], - ["modified.ts", "M", " ", "M"], - ], - ); - await git("add", "."); - const staged = await repository.snapshot("all"); - assert.notEqual(staged.revision, unstaged.revision); - assert.deepEqual( - staged.files.map(({ path, status, indexStatus, worktreeStatus }) => [ - path, - status, - indexStatus, - worktreeStatus, - ]), - [ - ["deleted.md", "D", "D", " "], - ["modified.ts", "M", "M", " "], - ], - ); - await git("mv", "modified.ts", "renamed\t้›ช.ts"); - await write( - "renamed\t้›ช.ts", - "stable first line\nworking\nstable last line\n", - ); - const working = await repository.snapshot("unstaged"); - assert.equal(working.files[0]?.path, "renamed\t้›ช.ts"); - assert.equal(working.files[0]?.worktreeStatus, "M"); - assert.equal(working.files[0]?.indexStatus, "R"); - await write("modified.ts", "new file at old path\n"); - const recreated = (await repository.snapshot("unstaged")).files.find( - (file) => file.path === "modified.ts", - ); - assert.ok(recreated); - assert.equal(recreated.indexStatus, "?"); - assert.equal(recreated.worktreeStatus, "?"); -}); - -test("does not attribute edits at a rename destination to the deleted source", async (t) => { - const { git, repository, write } = await fixture(t); - await write("old.txt", "original contents\n"); - await git("add", "."); - await git("commit", "-m", "Initial"); - await git("mv", "old.txt", "new.txt"); - await write("new.txt", "entirely rewritten\n"); - const snapshot = await repository.snapshot("all"); - const source = snapshot.files.find((file) => file.path === "old.txt"); - const destination = snapshot.files.find((file) => file.path === "new.txt"); - assert.ok(source && destination); - assert.equal(source.indexStatus, "D"); - assert.equal(source.worktreeStatus, " "); - assert.equal(destination.indexStatus, "R"); - assert.equal(destination.worktreeStatus, "M"); -}); diff --git a/apps/server/test/metrics.test.ts b/apps/server/test/metrics.test.ts deleted file mode 100644 index 4cf9bf79..00000000 --- a/apps/server/test/metrics.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { createProcessMetricsCollector } from "../src/metrics.ts"; - -test("measures process CPU over elapsed wall time", () => { - const walls = [0n, 100_000_000n, 1_000_000_000n]; - const cpuTimes = [ - { user: 100_000, system: 50_000 }, - { user: 140_000, system: 60_000 }, - { user: 300_000, system: 100_000 }, - ]; - const rssValues = [10, 20, 30]; - let index = 0; - const collect = createProcessMetricsCollector({ - clock: () => walls[index] ?? 0n, - cpu: () => cpuTimes[index] ?? { user: 0, system: 0 }, - rss: () => rssValues[index] ?? 0, - }); - - assert.deepEqual(collect(), { rssBytes: 10, cpuUsage: 0 }); - index++; - assert.deepEqual(collect(), { rssBytes: 20, cpuUsage: 0 }); - index++; - assert.deepEqual(collect(), { rssBytes: 30, cpuUsage: 25 }); -}); diff --git a/apps/server/test/stdin.test.ts b/apps/server/test/stdin.test.ts deleted file mode 100644 index c9bec1f2..00000000 --- a/apps/server/test/stdin.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import assert from "node:assert/strict"; -import { execFile, spawn } from "node:child_process"; -import { once } from "node:events"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Readable } from "node:stream"; -import { type TestContext, test } from "node:test"; -import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; -import { openPatch, readPatchInput } from "../src/stdin.ts"; - -const execute = promisify(execFile); -const cli = fileURLToPath(new URL("../src/cli.ts", import.meta.url)); -const patch = await readFile( - fileURLToPath(new URL("../../../test/fixtures/sample.diff", import.meta.url)), - "utf8", -); - -async function directory(t: TestContext) { - const root = await mkdtemp(join(tmpdir(), "serve diff stdin ")); - t.after(() => rm(root, { force: true, recursive: true })); - return root; -} - -test("serves parsed patch contents without consulting a Git repository", async () => { - const repository = openPatch(patch); - const data = await repository.snapshot("all"); - assert.equal(data.source, "stdin"); - assert.equal(data.files.length, 12); - const file = data.files.find((entry) => entry.path === "src/value.ts"); - assert.ok(file); - assert.equal(file.status, "M"); - assert.equal(file.additions, 1); - assert.equal(file.deletions, 1); - assert.equal(file.indexStatus, ""); - assert.equal(file.worktreeStatus, ""); - assert.match( - (await repository.patch("all", file, null)).patch, - /-export const value = 1;\n\+export const value = 2;/, - ); - assert.equal(openPatch(patch).root, repository.root); - assert.notEqual( - openPatch(patch.replace("value = 2", "value = 3")).root, - repository.root, - ); - await assert.rejects(repository.snapshot("staged"), /no staged or unstaged/); -}); - -test("handles actual git show ranges without dropping repeated files or mixing their patches", async (t) => { - const root = await directory(t); - const git = async (...args: string[]) => - ( - await execute("git", args, { - cwd: root, - env: { - ...process.env, - GIT_AUTHOR_NAME: "Test", - GIT_AUTHOR_EMAIL: "test@example.com", - GIT_COMMITTER_NAME: "Test", - GIT_COMMITTER_EMAIL: "test@example.com", - }, - }) - ).stdout; - await git("init", "-b", "main"); - await writeFile(join(root, "value.ts"), "base\n"); - await git("add", "."); - await git("commit", "-m", "Base"); - await git("checkout", "-b", "feature"); - for (const value of ["one", "two", "tip"]) { - await writeFile(join(root, "value.ts"), `${value}\n`); - await git("commit", "-am", value); - } - const output = await git("show", "--no-color", "main..HEAD~1"); - const repository = openPatch(output); - const data = await repository.snapshot("all"); - assert.equal(data.files.length, 2); - assert.equal(new Set(data.files.map((file) => file.path)).size, 2); - assert.ok(data.files.every((file) => file.path.endsWith("/value.ts"))); - const first = data.files[0]; - const second = data.files[1]; - assert.ok(first && second); - assert.match( - (await repository.patch("all", first, null)).patch, - /-one\n\+two/, - ); - assert.match( - (await repository.patch("all", second, null)).patch, - /-base\n\+one/, - ); - const single = await openPatch( - await git("show", "--color=always", "HEAD"), - ).snapshot("all"); - assert.equal(single.files[0]?.path, "value.ts"); - const mail = await openPatch( - await git("format-patch", "--stdout", "main..HEAD~1"), - ).snapshot("all"); - assert.equal(mail.files.length, 2); -}); - -test("preserves rename, delete, binary, and mode-only entries", async () => { - const input = `diff --git a/old.ts b/new.ts -similarity index 100% -rename from old.ts -rename to new.ts -diff --git a/gone.txt b/gone.txt -deleted file mode 100644 -index 1111111..0000000 ---- a/gone.txt -+++ /dev/null -@@ -1 +0,0 @@ --gone -diff --git a/image.bin b/image.bin -index 1111111..2222222 100644 -Binary files a/image.bin and b/image.bin differ -diff --git a/run.sh b/run.sh -old mode 100644 -new mode 100755 -`; - const repository = openPatch(input); - const data = await repository.snapshot("all"); - assert.deepEqual( - data.files.map(({ path, status, oldPath }) => [path, status, oldPath]), - [ - ["new.ts", "R", "old.ts"], - ["gone.txt", "D", null], - ["image.bin", "M", null], - ["run.sh", "M", null], - ], - ); - const binary = data.files.find((file) => file.binary); - assert.ok(binary); - assert.match( - (await repository.patch("all", binary, null)).message ?? "", - /Binary/, - ); -}); - -test("reads chunked UTF-8, bounds input, and rejects unsupported output clearly", async () => { - const bytes = Buffer.from("้›ช\n"); - assert.equal( - await readPatchInput( - Readable.from([bytes.subarray(0, 1), bytes.subarray(1)]), - ), - "้›ช\n", - ); - await assert.rejects( - readPatchInput(Readable.from([Buffer.alloc(16 * 1024 * 1024 + 1)])), - /16 MiB/, - ); - assert.throws(() => openPatch("not a diff"), /No Git patch/); - assert.throws(() => openPatch("diff --cc file.ts\n"), /Combined merge diffs/); - assert.throws( - () => openPatch(patch.replace("@@ -1,3 +1,3 @@", "@@ -20,5 +20,5 @@")), - /./, - ); - assert.equal((await openPatch("").snapshot("all")).files.length, 0); -}); - -for (const input of [patch, ""]) { - test(`CLI gives ${input ? "populated" : "empty"} stdin priority over a path, serves assets without Git, and shuts down on SIGINT`, async (t) => { - const root = await directory(t); - const child = spawn( - process.execPath, - [cli, "/nonexistent/repository", "--port", "0", "--dev"], - { - cwd: root, - env: { ...process.env, PATH: "/nonexistent" }, - stdio: "pipe", - }, - ); - t.after(() => { - if (child.exitCode === null && child.signalCode === null) - child.kill("SIGKILL"); - }); - let stderr = ""; - child.stderr.on("data", (chunk) => { - stderr += String(chunk); - }); - const ready = new Promise<{ url: string; token: string }>( - (resolve, reject) => { - let output = ""; - const timeout = setTimeout( - () => reject(new Error(`CLI did not start: ${stderr}`)), - 10_000, - ); - child.once("error", (error) => { - clearTimeout(timeout); - reject(error); - }); - child.once("exit", () => { - clearTimeout(timeout); - reject(new Error(stderr)); - }); - child.stdout.on("data", (chunk) => { - output += String(chunk); - const url = /http:\/\/localhost:\d+/.exec(output)?.[0]; - const token = /API token\s+([a-f\d]+)/.exec(output)?.[1]; - if (url && token) { - clearTimeout(timeout); - resolve({ url, token }); - } - }); - }, - ); - child.stdin.end(input); - const { url, token } = await ready; - const authorized = { headers: { Authorization: `Bearer ${token}` } }; - assert.equal((await fetch(url)).status, 200); - const data = await ( - await fetch(`${url}/api/v1/diffs/current?scope=all`, authorized) - ).text(); - assert.match(data, /"source":"stdin"/); - if (input) assert.match(data, /src\/value.ts/); - else assert.match(data, /"files":\[\]/); - assert.equal( - (await fetch(`${url}/api/v1/diffs/current?scope=staged`, authorized)) - .status, - 400, - ); - const stopped = once(child, "exit"); - child.kill("SIGINT"); - assert.deepEqual(await stopped, [0, null]); - }); -} - -test("CLI requires a directory when stdin is disconnected rather than piped", async (t) => { - const root = await directory(t); - const child = spawn(process.execPath, [cli], { - cwd: root, - stdio: ["ignore", "pipe", "pipe"], - }); - let stderr = ""; - child.stderr.on("data", (chunk) => { - stderr += String(chunk); - }); - const [code] = await once(child, "exit"); - assert.equal(code, 1); - assert.match(stderr, /Provide a repository path/); -}); diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json deleted file mode 100644 index e8b02ca4..00000000 --- a/apps/server/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": ["src/**/*.ts", "test/**/*.ts"] -} diff --git a/apps/web/test/browser/app.spec.ts b/apps/web/test/browser/app.spec.ts index 4209e121..28d0e559 100644 --- a/apps/web/test/browser/app.spec.ts +++ b/apps/web/test/browser/app.spec.ts @@ -3,7 +3,7 @@ import { resetFixtureState } from "./fixture-state.ts"; test.beforeEach(async ({ request }) => resetFixtureState(request)); -test("shows Node server process metrics in the compact status bar", async ({ +test("shows server process metrics in the compact status bar", async ({ page, }) => { await page.route("**/api/v1/metrics", (route) => diff --git a/apps/web/test/fixture-api.test.ts b/apps/web/test/fixture-api.test.ts deleted file mode 100644 index 2fef406b..00000000 --- a/apps/web/test/fixture-api.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import { test } from "node:test"; -import { fileURLToPath } from "node:url"; -import { ReviewStore } from "../../server/src/review-store.ts"; -import { openPatch } from "../../server/src/stdin.ts"; -import { seedFixtureReview } from "./fixture-api.ts"; - -test("seeds comments and reviewed-file state for web development", async () => { - const input = await readFile( - fileURLToPath( - new URL("../../../test/fixtures/sample.diff", import.meta.url), - ), - "utf8", - ); - const repository = await openPatch(input).snapshot("all"); - const store = new ReviewStore(null); - const sessionId = "fixture-test"; - - await seedFixtureReview(store, sessionId, repository); - - const comments = await store.comments(sessionId); - assert.deepEqual( - comments.map(({ path, status }) => ({ path, status })), - [ - { path: "src/value.ts", status: "open" }, - { path: "src/components/Badge.tsx", status: "resolved" }, - { path: "src/components/Button.tsx", status: "open" }, - ], - ); - assert.equal(comments[0]?.origin?.revision, repository.revision); - assert.equal(comments[1]?.origin?.revision, repository.revision); - assert.equal(comments[2]?.origin?.revision, "earlier-fixture-review"); - assert.equal(comments[2]?.fingerprint, "earlier-button-version"); - - const legacy = repository.files.find( - (file) => file.path === "docs/legacy.md", - ); - assert.ok(legacy); - assert.deepEqual(await store.marks(sessionId, "all"), [ - { - fileId: legacy.id, - fileVersion: legacy.fingerprint, - scope: "all", - }, - ]); -}); diff --git a/apps/web/test/fixture-api.ts b/apps/web/test/fixture-api.ts deleted file mode 100644 index d75f3852..00000000 --- a/apps/web/test/fixture-api.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; -import type { - ChangedFile, - DiffMode, - RepositoryDiff, - ReviewComment, -} from "@servediff/shared"; -import type { Plugin } from "vite"; -import { handleApi, respondWithProblem } from "../../server/src/api.ts"; -import { ReviewStore } from "../../server/src/review-store.ts"; -import { openPatch } from "../../server/src/stdin.ts"; - -function fixtureFile(repository: RepositoryDiff, path: string) { - const file = repository.files.find((entry) => entry.path === path); - if (!file) throw new Error(`Missing fixture file: ${path}`); - return file; -} - -function fixtureComment( - repository: RepositoryDiff, - file: ChangedFile, - comment: Omit, -): ReviewComment { - return { - ...comment, - path: file.path, - scope: repository.mode, - fingerprint: file.fingerprint, - origin: { - source: repository.source, - repository: repository.name, - branch: repository.branch, - head: repository.head, - revision: repository.revision, - file: { status: file.status, oldPath: file.oldPath }, - }, - }; -} - -export async function seedFixtureReview( - store: ReviewStore, - sessionId: string, - repository: RepositoryDiff, -) { - const value = fixtureFile(repository, "src/value.ts"); - const badge = fixtureFile(repository, "src/components/Badge.tsx"); - const button = fixtureFile(repository, "src/components/Button.tsx"); - const legacy = fixtureFile(repository, "docs/legacy.md"); - await store.importComments(sessionId, [ - fixtureComment(repository, value, { - id: "fixture-open-comment", - side: "additions", - start: 1, - end: 1, - code: "+ export const value = 2;", - body: "Should this remain backwards-compatible with value 1?", - status: "open", - createdAt: Date.UTC(2026, 0, 1), - }), - fixtureComment(repository, badge, { - id: "fixture-resolved-comment", - side: "additions", - start: 4, - end: 4, - code: "+ return {label};", - body: "Please expose the label as accessible text.", - status: "resolved", - createdAt: Date.UTC(2026, 0, 2), - }), - { - id: "fixture-stale-comment", - path: button.path, - scope: repository.mode, - fingerprint: "earlier-button-version", - side: "additions", - start: 3, - end: 3, - code: "+ export function Button({ children }: ButtonProps) {", - body: "Keep the button API backwards-compatible.", - status: "open", - createdAt: Date.UTC(2025, 11, 31), - origin: { - source: repository.source, - repository: repository.name, - branch: repository.branch, - head: repository.head, - revision: "earlier-fixture-review", - file: { status: button.status, oldPath: button.oldPath }, - }, - }, - ]); - await store.putMark(sessionId, { - fileId: legacy.id, - fileVersion: legacy.fingerprint, - scope: repository.mode, - }); -} - -export async function fixtureApiPlugin(): Promise { - const input = await readFile( - new URL("../../../test/fixtures/sample.diff", import.meta.url), - "utf8", - ); - const source = openPatch(input); - const store = new ReviewStore(null); - const sessionId = createHash("sha256") - .update(`fixture\0${source.root}`) - .digest("hex"); - const snapshots = new Map>(); - const snapshot = (mode: DiffMode, fresh = false) => { - if (fresh) return source.snapshot(mode); - const current = snapshots.get(mode) ?? source.snapshot(mode); - snapshots.set(mode, current); - return current; - }; - await seedFixtureReview(store, sessionId, await snapshot("all")); - - return { - name: "servediff-fixture-api", - configureServer(server) { - server.middlewares.use((request, response, next) => { - const url = new URL(request.url ?? "/", "http://localhost"); - void handleApi(request, response, url, { - source, - sessionId, - token: "", - store, - snapshot, - }) - .then((handled) => { - if (!handled) next(); - }) - .catch((error: unknown) => respondWithProblem(response, error)); - }); - }, - }; -} diff --git a/apps/web/test/fixture-server.ts b/apps/web/test/fixture-server.ts new file mode 100644 index 00000000..2422bb69 --- /dev/null +++ b/apps/web/test/fixture-server.ts @@ -0,0 +1,109 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; +import type { Plugin } from "vite"; + +function stopProcess(child: ChildProcess) { + if (child.exitCode !== null || child.signalCode !== null) return; + const processId = child.pid; + if (process.platform === "win32" || processId === undefined) { + child.kill("SIGTERM"); + return; + } + try { + process.kill(-processId, "SIGTERM"); + } catch (error) { + if (!child.killed) child.kill("SIGTERM"); + if (child.exitCode === null && child.signalCode === null) + console.warn("Unable to stop Go fixture process", error); + } +} + +export async function startFixtureServer(): Promise<{ + target: string; + plugin: Plugin; +}> { + const root = fileURLToPath(new URL("../../..", import.meta.url)); + const child = spawn( + "go", + [ + "run", + "./cmd/servediff", + "--fixture", + "test/fixtures/sample.diff", + "--state", + "memory", + "--no-browser", + "--port", + "0", + ], + { + cwd: root, + detached: process.platform !== "win32", + env: { ...process.env, SERVEDIFF_EXIT_ON_STDIN_CLOSE: "1" }, + stdio: ["pipe", "pipe", "inherit"], + }, + ); + const output = child.stdout; + if (!output) { + stopProcess(child); + throw new Error("Unable to read Go fixture server address"); + } + const target = await new Promise((resolve, reject) => { + let settled = false; + const lines = createInterface({ input: output }); + const timer = setTimeout(() => { + fail(new Error("Timed out starting Go fixture server")); + }, 30_000); + const fail = (error: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + lines.close(); + stopProcess(child); + reject(error); + }; + child.once("error", fail); + child.once("exit", (code, signal) => { + fail( + new Error( + `Go fixture server exited before startup (${signal ?? code ?? "unknown"})`, + ), + ); + }); + lines.on("line", (line) => { + const port = /url:\s+http:\/\/127\.0\.0\.1:(\d+)/.exec(line)?.[1]; + if (!port || settled) return; + settled = true; + clearTimeout(timer); + lines.close(); + resolve(`http://127.0.0.1:${port}`); + }); + }); + const stop = () => stopProcess(child); + const interrupt = () => { + stop(); + process.exit(130); + }; + const terminate = () => { + stop(); + process.exit(143); + }; + process.once("exit", stop); + process.once("SIGINT", interrupt); + process.once("SIGTERM", terminate); + return { + target, + plugin: { + name: "servediff-fixture", + configureServer(server) { + server.httpServer?.once("close", () => { + process.off("exit", stop); + process.off("SIGINT", interrupt); + process.off("SIGTERM", terminate); + stop(); + }); + }, + }, + }; +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index c2ce1065..1f30b1cb 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,19 +1,33 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { fileURLToPath } from "node:url"; -import { defineConfig } from "vite"; -import { fixtureApiPlugin } from "./test/fixture-api.ts"; +import { defineConfig, type ProxyOptions } from "vite"; +import { startFixtureServer } from "./test/fixture-server.ts"; export default defineConfig(async ({ command }) => { - const apiUrl = process.env.SERVEDIFF_API_URL; + const configuredApiUrl = process.env.SERVEDIFF_API_URL; + const fixture = + command === "serve" && !configuredApiUrl + ? await startFixtureServer() + : undefined; + const apiUrl = configuredApiUrl ?? fixture?.target; + const proxy: ProxyOptions | undefined = apiUrl + ? { + target: apiUrl, + changeOrigin: true, + configure(server) { + server.on("proxyReq", (request) => request.removeHeader("Origin")); + }, + } + : undefined; return { server: { host: true, - ...(apiUrl + ...(proxy ? { proxy: { - "/api": { target: apiUrl, changeOrigin: true }, - "/openapi.yaml": { target: apiUrl, changeOrigin: true }, + "/api": proxy, + "/openapi.yaml": proxy, }, } : {}), @@ -21,10 +35,6 @@ export default defineConfig(async ({ command }) => { resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, }, - plugins: [ - react(), - tailwindcss(), - ...(command === "serve" && !apiUrl ? [await fixtureApiPlugin()] : []), - ], + plugins: [react(), tailwindcss(), ...(fixture ? [fixture.plugin] : [])], }; }); diff --git a/cmd/servediff/input.go b/cmd/servediff/input.go new file mode 100644 index 00000000..a65b8b52 --- /dev/null +++ b/cmd/servediff/input.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "time" + + "github.com/flexdinesh/servediff/internal/diffsource" + "github.com/flexdinesh/servediff/internal/review" +) + +type loadedInput struct { + source diffsource.Source + directory string + mode string + snapshot review.RepositoryDiff + processed time.Duration +} + +func loadInput(ctx context.Context, values options, stdin *os.File) (loadedInput, error) { + started := time.Now() + absolute, err := filepath.Abs(".") + if err != nil { + return loadedInput{}, err + } + var source diffsource.Source + if values.fixture != "" { + raw, readError := os.ReadFile(values.fixture) + if readError != nil { + return loadedInput{}, readError + } + source, err = diffsource.OpenPatch(string(raw)) + } else { + piped, statError := redirected(stdin) + if statError != nil { + return loadedInput{}, statError + } + if values.directory == "-" || piped { + raw, readError := io.ReadAll(io.LimitReader(stdin, diffsource.MaxInputBytes+1)) + if readError != nil { + return loadedInput{}, readError + } + if len(raw) > diffsource.MaxInputBytes { + return loadedInput{}, errors.New("piped diff exceeds the 16 MiB input limit") + } + source, err = diffsource.OpenPatch(string(raw)) + } else { + if !values.repositorySet { + return loadedInput{}, errors.New("provide a repository path, a fixture, or pipe a Git diff") + } + source, err = diffsource.OpenRepository(ctx, values.directory) + } + } + if err != nil { + return loadedInput{}, err + } + mode := "pipe" + if source.Kind() == "local" { + mode = "git" + absolute = source.Root() + } + snapshot, err := source.Snapshot(ctx, review.DiffAll) + if err != nil { + return loadedInput{}, err + } + return loadedInput{source: source, directory: absolute, mode: mode, snapshot: snapshot, processed: time.Since(started)}, nil +} + +func redirected(stdin *os.File) (bool, error) { + stat, err := stdin.Stat() + if err != nil { + return false, err + } + return stat.Mode()&(os.ModeNamedPipe|os.ModeSocket) != 0 || stat.Mode().IsRegular(), nil +} diff --git a/cmd/servediff/main.go b/cmd/servediff/main.go index d0f0d631..c2b24286 100644 --- a/cmd/servediff/main.go +++ b/cmd/servediff/main.go @@ -3,107 +3,41 @@ package main import ( "context" "errors" - "flag" "fmt" "io" - "net" "net/http" "os" "os/signal" "path/filepath" - "strings" "syscall" "time" "github.com/flexdinesh/servediff/internal/browser" - "github.com/flexdinesh/servediff/internal/diffsource" "github.com/flexdinesh/servediff/internal/httpapi" "github.com/flexdinesh/servediff/internal/reviewstore" "github.com/flexdinesh/servediff/internal/webui" ) -type options struct { - port int - fixture string - state string - webDir string - token string - noAuth bool - noBrowser bool -} - func run(ctx context.Context, arguments []string, stdin *os.File, stdout, stderr io.Writer) error { - flags := flag.NewFlagSet("servediff-go", flag.ContinueOnError) - flags.SetOutput(stderr) - values := options{} - flags.IntVar(&values.port, "port", 3333, "HTTP port; 0 chooses a free port") - flags.IntVar(&values.port, "p", 3333, "HTTP port; 0 chooses a free port") - flags.StringVar(&values.fixture, "fixture", "", "read a Git patch fixture") - flags.StringVar(&values.state, "state", "", "review state path; memory disables persistence") - flags.StringVar(&values.webDir, "web-dir", "", "serve web assets from a directory") - flags.StringVar(&values.token, "token", "", "fixed API token") - flags.BoolVar(&values.noAuth, "no-auth", false, "disable API authentication") - flags.BoolVar(&values.noBrowser, "no-browser", false, "do not open a browser") - flags.Usage = func() { - fmt.Fprintln(stderr, "Usage: servediff-go [directory | -] [options]") - flags.PrintDefaults() - } - if err := flags.Parse(normalizeArguments(arguments)); err != nil { - if errors.Is(err, flag.ErrHelp) { + values, err := parseOptions(arguments, stderr) + if err != nil { + if errors.Is(err, errHelp) { return nil } return err } - if values.port < 0 || values.port > 65535 { - return errors.New("port must be between 0 and 65535") - } - if flags.NArg() > 1 { - return errors.New("expected at most one repository directory") - } - directory := "." - if flags.NArg() == 1 { - directory = flags.Arg(0) - } - var source diffsource.Source - var err error - if values.fixture != "" { - raw, readError := os.ReadFile(values.fixture) - if readError != nil { - return readError - } - source, err = diffsource.OpenPatch(string(raw)) - } else { - piped, statError := redirected(stdin) - if statError != nil { - return statError - } - if flags.Arg(0) == "-" || piped { - raw, readError := io.ReadAll(io.LimitReader(stdin, diffsource.MaxInputBytes+1)) - if readError != nil { - return readError - } - if len(raw) > diffsource.MaxInputBytes { - return errors.New("piped diff exceeds the 16 MiB input limit") - } - source, err = diffsource.OpenPatch(string(raw)) - } else { - if flags.NArg() == 0 { - return errors.New("provide a repository path, a fixture, or pipe a Git diff") - } - source, err = diffsource.OpenRepository(ctx, directory) - } - } + input, err := loadInput(ctx, values, stdin) if err != nil { return err } - token := values.token - if values.noAuth { - token = "" - } else if token == "" { - token, err = httpapi.RandomToken() - if err != nil { - return err - } + if os.Getenv("SERVEDIFF_EXIT_ON_STDIN_CLOSE") == "1" { + childContext, cancel := context.WithCancel(ctx) + defer cancel() + ctx = childContext + go func() { + _, _ = io.Copy(io.Discard, stdin) + cancel() + }() } assets := webui.Assets() if values.webDir != "" { @@ -115,7 +49,7 @@ func run(ctx context.Context, arguments []string, stdin *os.File, stdout, stderr } statePath := values.state if statePath == "" { - statePath, err = reviewstore.DefaultPath(httpapi.SessionID(source)) + statePath, err = reviewstore.DefaultPath(httpapi.SessionID(input.source)) if err != nil { return err } @@ -124,38 +58,24 @@ func run(ctx context.Context, arguments []string, stdin *os.File, stdout, stderr if err != nil { return err } - handler := httpapi.New(source, store, token, assets) - listener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", values.port)) + handler := identifyProcess(httpapi.New(input.source, store, assets)) + bound, err := bind(values, stdin, stdout) if err != nil { - return err - } - defer listener.Close() - port := listener.Addr().(*net.TCPAddr).Port - local := fmt.Sprintf("http://localhost:%d", port) - if token != "" { - local += "#token=" + token - } - network := networkAddress() - all := fmt.Sprintf("http://0.0.0.0:%d", port) - if token != "" { - all += "#token=" + token - } - networkURL := "unavailable" - if network != "" { - networkURL = fmt.Sprintf("http://%s:%d", network, port) - if token != "" { - networkURL += "#token=" + token + if errors.Is(err, errStartupCancelled) { + return nil } + return err } - fmt.Fprintf(stdout, "\n servediff (Go)\n Local %s\n All %s\n Network %s\n API token %s\n %s\n\n Press Ctrl+C to stop.\n\n", local, all, networkURL, tokenLabel(token), source.Root()) + defer bound.listener.Close() + writeStartup(stdout, input, bound.url) if !values.noBrowser { - if browserError := browser.Open(local); browserError != nil { - fmt.Fprintf(stderr, "servediff-go: %v\n", browserError) + if browserError := browser.Open(bound.browserURL); browserError != nil { + fmt.Fprintf(stderr, " servediff: %v\n", browserError) } } server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second} serverErrors := make(chan error, 1) - go func() { serverErrors <- server.Serve(listener) }() + go func() { serverErrors <- server.Serve(bound.listener) }() select { case <-ctx.Done(): shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -169,32 +89,6 @@ func run(ctx context.Context, arguments []string, stdin *os.File, stdout, stderr } } -func normalizeArguments(arguments []string) []string { - valueOptions := map[string]bool{ - "-p": true, "--port": true, "--fixture": true, "--state": true, - "--web-dir": true, "--token": true, - } - options, positionals := make([]string, 0, len(arguments)), make([]string, 0, 1) - for index := 0; index < len(arguments); index++ { - argument := arguments[index] - if argument == "--" { - positionals = append(positionals, arguments[index+1:]...) - break - } - if argument != "-" && strings.HasPrefix(argument, "-") { - options = append(options, argument) - name := strings.SplitN(argument, "=", 2)[0] - if valueOptions[name] && !strings.Contains(argument, "=") && index+1 < len(arguments) { - index++ - options = append(options, arguments[index]) - } - continue - } - positionals = append(positionals, argument) - } - return append(options, positionals...) -} - func mapStatePath(value string) string { if value == "memory" { return "" @@ -202,37 +96,11 @@ func mapStatePath(value string) string { return value } -func redirected(stdin *os.File) (bool, error) { - stat, err := stdin.Stat() - if err != nil { - return false, err - } - return stat.Mode()&(os.ModeNamedPipe|os.ModeSocket) != 0 || stat.Mode().IsRegular(), nil -} - -func networkAddress() string { - addresses, _ := net.InterfaceAddrs() - for _, address := range addresses { - ip, _, err := net.ParseCIDR(address.String()) - if err == nil && ip.To4() != nil && !ip.IsLoopback() { - return ip.String() - } - } - return "" -} - -func tokenLabel(token string) string { - if token == "" { - return "disabled" - } - return token -} - func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() if err := run(ctx, os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil { - fmt.Fprintf(os.Stderr, "servediff-go: %v\n", err) + fmt.Fprintf(os.Stderr, " servediff: %v\n", err) os.Exit(1) } } diff --git a/cmd/servediff/main_test.go b/cmd/servediff/main_test.go index 687d5f33..b8f0f18a 100644 --- a/cmd/servediff/main_test.go +++ b/cmd/servediff/main_test.go @@ -1,10 +1,26 @@ package main import ( + "bytes" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" "reflect" + "strings" "testing" + "time" + + "github.com/flexdinesh/servediff/internal/review" ) +type testListener struct{ address net.Addr } + +func (listener testListener) Accept() (net.Conn, error) { return nil, errors.New("closed") } +func (listener testListener) Close() error { return nil } +func (listener testListener) Addr() net.Addr { return listener.address } + func TestNormalizeArgumentsAllowsFlagsAfterPath(t *testing.T) { actual := normalizeArguments([]string{".", "--port", "4000", "--no-browser"}) expected := []string{"--port", "4000", "--no-browser", "."} @@ -12,3 +28,91 @@ func TestNormalizeArgumentsAllowsFlagsAfterPath(t *testing.T) { t.Fatalf("got %v, want %v", actual, expected) } } + +func TestOptionsDefaultToLocalhostAndAutomaticPort(t *testing.T) { + values, err := parseOptions([]string{".", "--host", "192.0.2.1", "--port", "8123"}, io.Discard) + if err != nil { + t.Fatal(err) + } + if values.host != "192.0.2.1" || values.port != 8123 || !values.portSet || !values.repositorySet { + t.Fatalf("unexpected explicit options: %#v", values) + } + defaults, err := parseOptions([]string{"."}, io.Discard) + if err != nil { + t.Fatal(err) + } + if defaults.host != "127.0.0.1" || defaults.portSet { + t.Fatalf("unexpected defaults: %#v", defaults) + } +} + +func TestListenRangeAdvancesPastBusyPort(t *testing.T) { + var output bytes.Buffer + listener, err := listenRangeWith("127.0.0.1", 7981, 7982, &output, func(_ string, address string) (net.Listener, error) { + if strings.HasSuffix(address, ":7981") { + return nil, errors.New("busy") + } + return testListener{address: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 7982}}, nil + }) + if err != nil { + t.Fatal(err) + } + if listener.Addr().(*net.TCPAddr).Port != 7982 { + t.Fatalf("unexpected listener: %s", listener.Addr()) + } + if output.String() != " port 7981 is busy; trying 7982\n" { + t.Fatalf("unexpected output: %q", output.String()) + } +} + +func TestStartupOutputSummarizesDiff(t *testing.T) { + var output bytes.Buffer + writeStartup(&output, loadedInput{ + directory: "/work/repository", + mode: "git", + processed: 37 * time.Millisecond, + snapshot: review.RepositoryDiff{Files: []review.ChangedFile{ + {Additions: 3, Deletions: 1}, + {Additions: 2, Binary: true}, + }}, + }, "http://127.0.0.1:7981") + value := output.String() + for _, expected := range []string{ + " servediff ", + " serving directory: /work/repository", + " mode: git", + " url: http://127.0.0.1:7981", + " diff statistics 37ms", + " 2 files changed", + " 5 additions", + " 1 deletion", + " 1 binary file", + " ctrl-c to stop.", + } { + if !strings.Contains(value, expected) { + t.Fatalf("missing %q in %q", expected, value) + } + } + for _, line := range strings.Split(strings.TrimSuffix(value, "\n"), "\n") { + if !strings.HasPrefix(line, " ") { + t.Fatalf("line lacks indentation: %q", line) + } + } +} + +func TestProcessIdentityHeader(t *testing.T) { + handler := processHandler{pid: 42, next: http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusNoContent) + })} + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodHead, "/", nil)) + if response.Header().Get(processHeader) != "42" { + t.Fatalf("unexpected process header: %q", response.Header().Get(processHeader)) + } +} + +func TestConfirmationAnswers(t *testing.T) { + if !isYes("YES\n") || isYes("no") || isYes("") { + t.Fatal("unexpected confirmation parsing") + } +} diff --git a/cmd/servediff/options.go b/cmd/servediff/options.go new file mode 100644 index 00000000..6d1f9a87 --- /dev/null +++ b/cmd/servediff/options.go @@ -0,0 +1,94 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "net" + "strings" +) + +var errHelp = errors.New("help requested") + +type options struct { + host string + port int + portSet bool + directory string + repositorySet bool + fixture string + state string + webDir string + noBrowser bool +} + +func parseOptions(arguments []string, stderr io.Writer) (options, error) { + flags := flag.NewFlagSet("servediff", flag.ContinueOnError) + flags.SetOutput(stderr) + values := options{host: "127.0.0.1", directory: "."} + flags.StringVar(&values.host, "host", values.host, "IP address to bind") + flags.IntVar(&values.port, "port", 0, "HTTP port; defaults to the first available port from 7981 to 7990") + flags.IntVar(&values.port, "p", 0, "HTTP port; defaults to the first available port from 7981 to 7990") + flags.StringVar(&values.fixture, "fixture", "", "read a Git patch fixture") + flags.StringVar(&values.state, "state", "", "review state path; memory disables persistence") + flags.StringVar(&values.webDir, "web-dir", "", "serve web assets from a directory") + flags.BoolVar(&values.noBrowser, "no-browser", false, "do not open a browser") + flags.Usage = func() { + fmt.Fprintln(stderr, " Usage: servediff [directory | -] [options]") + flags.PrintDefaults() + } + if err := flags.Parse(normalizeArguments(arguments)); err != nil { + if errors.Is(err, flag.ErrHelp) { + return options{}, errHelp + } + return options{}, err + } + if values.port < 0 || values.port > 65535 { + return options{}, errors.New("port must be between 0 and 65535") + } + host := net.ParseIP(values.host) + if host == nil { + return options{}, errors.New("host must be an IP address") + } + values.host = host.String() + if flags.NArg() > 1 { + return options{}, errors.New("expected at most one repository directory") + } + if flags.NArg() == 1 { + values.directory = flags.Arg(0) + values.repositorySet = true + } + flags.Visit(func(value *flag.Flag) { + if value.Name == "port" || value.Name == "p" { + values.portSet = true + } + }) + return values, nil +} + +func normalizeArguments(arguments []string) []string { + valueOptions := map[string]bool{ + "-p": true, "--port": true, "--host": true, "--fixture": true, + "--state": true, "--web-dir": true, + } + options, positionals := make([]string, 0, len(arguments)), make([]string, 0, 1) + for index := 0; index < len(arguments); index++ { + argument := arguments[index] + if argument == "--" { + positionals = append(positionals, arguments[index+1:]...) + break + } + if argument != "-" && strings.HasPrefix(argument, "-") { + options = append(options, argument) + name := strings.SplitN(argument, "=", 2)[0] + if valueOptions[name] && !strings.Contains(argument, "=") && index+1 < len(arguments) { + index++ + options = append(options, arguments[index]) + } + continue + } + positionals = append(positionals, argument) + } + return append(options, positionals...) +} diff --git a/cmd/servediff/output.go b/cmd/servediff/output.go new file mode 100644 index 00000000..9360e6fa --- /dev/null +++ b/cmd/servediff/output.go @@ -0,0 +1,101 @@ +package main + +import ( + "fmt" + "io" + "os" + "strings" + "time" +) + +var version = "dev" + +type colors struct { + bold string + cyan string + green string + yellow string + red string + magenta string + dim string + reset string +} + +func terminalColors(writer io.Writer) colors { + file, ok := writer.(*os.File) + _, noColor := os.LookupEnv("NO_COLOR") + if !ok || noColor || os.Getenv("TERM") == "dumb" { + return colors{} + } + stat, err := file.Stat() + if err != nil || stat.Mode()&os.ModeCharDevice == 0 { + return colors{} + } + return colors{ + bold: "\x1b[1m", cyan: "\x1b[36m", green: "\x1b[32m", + yellow: "\x1b[33m", red: "\x1b[31m", magenta: "\x1b[35m", + dim: "\x1b[2m", reset: "\x1b[0m", + } +} + +func (color colors) paint(style, value string) string { + if style == "" { + return value + } + return style + value + color.reset +} + +func displayVersion() string { + return version +} + +func writeStartup(writer io.Writer, input loadedInput, url string) { + color := terminalColors(writer) + files, additions, deletions, binaries := 0, 0, 0, 0 + for _, file := range input.snapshot.Files { + files++ + additions += file.Additions + deletions += file.Deletions + if file.Binary { + binaries++ + } + } + fmt.Fprintf(writer, " %s\n", color.paint(color.bold+color.cyan, "servediff "+displayVersion())) + fmt.Fprintf(writer, " %s %s\n", color.paint(color.dim, "serving directory:"), input.directory) + fmt.Fprintf(writer, " %s %s\n", color.paint(color.dim, "mode:"), input.mode) + fmt.Fprintf(writer, " %s %s\n", color.paint(color.dim, "url:"), color.paint(color.green, url)) + fmt.Fprintln(writer, " ") + elapsed := max(input.processed.Round(time.Millisecond).Milliseconds(), 1) + fmt.Fprintf(writer, " %s %s\n", color.paint(color.dim, "diff statistics"), color.paint(color.magenta, fmt.Sprintf("%dms", elapsed))) + fmt.Fprintf(writer, " %s\n", plural(files, "file changed", "files changed")) + fmt.Fprintf(writer, " %s\n", color.paint(color.green, plural(additions, "addition", "additions"))) + fmt.Fprintf(writer, " %s\n", color.paint(color.red, plural(deletions, "deletion", "deletions"))) + if binaries > 0 { + fmt.Fprintf(writer, " %s\n", plural(binaries, "binary file", "binary files")) + } + fmt.Fprintln(writer, " ") + fmt.Fprintf(writer, " %s\n", color.paint(color.dim, "ctrl-c to stop.")) +} + +func writePortBusy(writer io.Writer, port, next int) { + color := terminalColors(writer) + message := fmt.Sprintf("port %d is busy; trying %d", port, next) + fmt.Fprintf(writer, " %s\n", color.paint(color.yellow, message)) +} + +func plural(count int, singular, plural string) string { + label := plural + if count == 1 { + label = singular + } + return fmt.Sprintf("%d %s", count, label) +} + +func isYes(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "y", "yes": + return true + default: + return false + } +} diff --git a/cmd/servediff/ports.go b/cmd/servediff/ports.go new file mode 100644 index 00000000..b4790e0d --- /dev/null +++ b/cmd/servediff/ports.go @@ -0,0 +1,225 @@ +package main + +import ( + "bufio" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "runtime" + "strconv" + "time" +) + +const ( + firstDefaultPort = 7981 + lastDefaultPort = 7990 + processHeader = "X-Servediff-Process" +) + +var ( + errNoAvailablePort = errors.New("no available servediff port") + errStartupCancelled = errors.New("startup cancelled") +) + +type boundServer struct { + listener net.Listener + url string + browserURL string +} + +type listenFunc func(network, address string) (net.Listener, error) + +type processHandler struct { + next http.Handler + pid int +} + +func (handler processHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) { + response.Header().Set(processHeader, strconv.Itoa(handler.pid)) + handler.next.ServeHTTP(response, request) +} + +func identifyProcess(next http.Handler) http.Handler { + return processHandler{next: next, pid: os.Getpid()} +} + +func bind(values options, stdin *os.File, stdout io.Writer) (boundServer, error) { + if values.portSet { + listener, err := net.Listen("tcp", net.JoinHostPort(values.host, strconv.Itoa(values.port))) + if err != nil { + return boundServer{}, fmt.Errorf("listen on %s:%d: %w", values.host, values.port, err) + } + return makeBoundServer(values.host, listener), nil + } + probe, err := net.Listen("tcp", net.JoinHostPort(values.host, "0")) + if err != nil { + return boundServer{}, fmt.Errorf("bind host %s: %w", values.host, err) + } + _ = probe.Close() + listener, err := listenRange(values.host, firstDefaultPort, lastDefaultPort, stdout) + if err == nil { + return makeBoundServer(values.host, listener), nil + } + if !errors.Is(err, errNoAvailablePort) { + return boundServer{}, err + } + pids, processError := servediffProcesses(values.host) + if processError != nil { + return boundServer{}, processError + } + confirmed, confirmError := confirmTermination(stdin, stdout) + if confirmError != nil { + return boundServer{}, confirmError + } + if !confirmed { + return boundServer{}, errStartupCancelled + } + for _, pid := range pids { + if err := terminateProcess(pid); err != nil && !errors.Is(err, os.ErrProcessDone) { + return boundServer{}, fmt.Errorf("terminate servediff process %d: %w", pid, err) + } + } + listener, err = waitForRange(values.host, firstDefaultPort, lastDefaultPort, 2*time.Second) + if err != nil { + return boundServer{}, fmt.Errorf("ports %d-%d did not become available: %w", firstDefaultPort, lastDefaultPort, err) + } + return makeBoundServer(values.host, listener), nil +} + +func listenRange(host string, first, last int, stdout io.Writer) (net.Listener, error) { + return listenRangeWith(host, first, last, stdout, net.Listen) +} + +func listenRangeWith(host string, first, last int, stdout io.Writer, listen listenFunc) (net.Listener, error) { + for port := first; port <= last; port++ { + listener, err := listen("tcp", net.JoinHostPort(host, strconv.Itoa(port))) + if err == nil { + return listener, nil + } + if port < last { + writePortBusy(stdout, port, port+1) + } + } + return nil, errNoAvailablePort +} + +func makeBoundServer(host string, listener net.Listener) boundServer { + port := listener.Addr().(*net.TCPAddr).Port + url := "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + browserHost := host + if ip := net.ParseIP(host); ip != nil && ip.IsUnspecified() { + browserHost = "127.0.0.1" + if ip.To4() == nil { + browserHost = "::1" + } + } + return boundServer{ + listener: listener, + url: url, + browserURL: "http://" + net.JoinHostPort(browserHost, strconv.Itoa(port)), + } +} + +func servediffProcesses(host string) ([]int, error) { + client := &http.Client{ + Timeout: 300 * time.Millisecond, + Transport: &http.Transport{}, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + pids := make([]int, 0, lastDefaultPort-firstDefaultPort+1) + seen := make(map[int]bool) + for port := firstDefaultPort; port <= lastDefaultPort; port++ { + url := "http://" + net.JoinHostPort(connectHost(host), strconv.Itoa(port)) + "/openapi.yaml" + request, err := http.NewRequest(http.MethodHead, url, nil) + if err != nil { + return nil, err + } + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("ports %d-%d are busy; port %d is not a reachable servediff process", firstDefaultPort, lastDefaultPort, port) + } + response.Body.Close() + pid, parseError := strconv.Atoi(response.Header.Get(processHeader)) + if parseError != nil || pid <= 1 || pid == os.Getpid() || seen[pid] { + return nil, fmt.Errorf("ports %d-%d are busy; port %d is not a distinct servediff process", firstDefaultPort, lastDefaultPort, port) + } + seen[pid] = true + pids = append(pids, pid) + } + return pids, nil +} + +func connectHost(host string) string { + ip := net.ParseIP(host) + if ip == nil || !ip.IsUnspecified() { + return host + } + if ip.To4() != nil { + return "127.0.0.1" + } + return "::1" +} + +func confirmTermination(stdin *os.File, stdout io.Writer) (bool, error) { + input, closeInput, err := promptInput(stdin) + if err != nil { + return false, errors.New("10 servediff processes are running on ports 7981-7990; an interactive terminal is required to terminate them") + } + defer closeInput() + color := terminalColors(stdout) + prompt := "10 servediff processes are running on ports 7981-7990. terminate all and restart on 7981? [y/N] " + fmt.Fprintf(stdout, " %s", color.paint(color.yellow, prompt)) + answer, readError := bufio.NewReader(input).ReadString('\n') + if readError != nil && !errors.Is(readError, io.EOF) { + return false, readError + } + return isYes(answer), nil +} + +func promptInput(stdin *os.File) (io.Reader, func(), error) { + stat, err := stdin.Stat() + if err == nil && stat.Mode()&os.ModeCharDevice != 0 { + return stdin, func() {}, nil + } + name := "/dev/tty" + if runtime.GOOS == "windows" { + name = "CONIN$" + } + terminal, err := os.Open(name) + if err != nil { + return nil, func() {}, err + } + return terminal, func() { _ = terminal.Close() }, nil +} + +func waitForRange(host string, first, last int, timeout time.Duration) (net.Listener, error) { + deadline := time.Now().Add(timeout) + var lastError error + for time.Now().Before(deadline) { + listeners := make([]net.Listener, 0, last-first+1) + for port := first; port <= last; port++ { + listener, err := net.Listen("tcp", net.JoinHostPort(host, strconv.Itoa(port))) + if err != nil { + lastError = err + break + } + listeners = append(listeners, listener) + } + if len(listeners) == last-first+1 { + for _, listener := range listeners[1:] { + _ = listener.Close() + } + return listeners[0], nil + } + for _, listener := range listeners { + _ = listener.Close() + } + time.Sleep(50 * time.Millisecond) + } + return nil, lastError +} diff --git a/cmd/servediff/terminate_unix.go b/cmd/servediff/terminate_unix.go new file mode 100644 index 00000000..065366a0 --- /dev/null +++ b/cmd/servediff/terminate_unix.go @@ -0,0 +1,16 @@ +//go:build !windows + +package main + +import ( + "os" + "syscall" +) + +func terminateProcess(pid int) error { + process, err := os.FindProcess(pid) + if err != nil { + return err + } + return process.Signal(syscall.SIGTERM) +} diff --git a/cmd/servediff/terminate_windows.go b/cmd/servediff/terminate_windows.go new file mode 100644 index 00000000..cfd91c77 --- /dev/null +++ b/cmd/servediff/terminate_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package main + +import "os" + +func terminateProcess(pid int) error { + process, err := os.FindProcess(pid) + if err != nil { + return err + } + return process.Kill() +} diff --git a/docs/adr/0001-polyglot-monorepo.md b/docs/adr/0001-polyglot-monorepo.md deleted file mode 100644 index 3f015a8f..00000000 --- a/docs/adr/0001-polyglot-monorepo.md +++ /dev/null @@ -1,26 +0,0 @@ -# ADR 0001: Native polyglot workspace - -Status: accepted - -## Context - -servediff has an existing Node server and React application. A Go server will -provide the same behavior, later host additional transports such as MCP, and -produce a single binary containing the web UI. Future Go commands and web apps -may share private Go packages or focused TypeScript packages. - -## Decision - -- Keep pnpm workspaces for JavaScript and use one root Go module. -- Keep the Node implementation during Go parity work. -- Use OpenAPI and serialized fixtures as cross-language boundaries. -- Put Go binaries in `cmd` and shared private Go packages in root `internal`. -- Use Taskfile only to orchestrate native pnpm and Go commands. -- Embed staged Vite output in the Go binary. -- Add modules, Nx, Turborepo, or Bazel only after measured need. - -## Consequences - -Cross-language changes remain atomic. Each ecosystem keeps native tooling. The -final Go build requires both toolchains, while Go unit tests do not require Node. -During migration, behavior must be tested against both server implementations. diff --git a/docs/architecture.md b/docs/architecture.md index c27cceb9..3633f596 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,11 +1,10 @@ # Architecture -servediff is a polyglot monorepo with two server implementations and one web -application. Node remains the reference implementation while Go reaches parity. +servediff is a polyglot monorepo with a Go server and a React web application. +Node is a frontend build and test dependency only. ## Project boundaries -- `apps/server`: existing Node CLI, REST server, persistence, and Git adapters. - `apps/web`: React/Vite UI. It consumes the REST contract, never Go packages. - `cmd/servediff`: Go composition root and CLI only. - `internal`: Go application behavior and private adapters shared by future Go commands. @@ -19,19 +18,12 @@ services once a second transport establishes the reusable behavior. Transports must not call each other. Cross-language sharing happens through OpenAPI and serialized fixtures, not source imports. -## Development modes - -- `task dev:web`: Vite with the in-memory fixture API. -- `task dev:node`: existing Node server with the shared patch fixture. -- `task dev:go`: Go server with the same fixture and a Vite production build. -- `task dev`: Go fixture API and Vite HMR through a development proxy. - ## Production builds -The Node build remains supported. The Go build generates the API client, builds -the web application, stages its output below `internal/webui`, then embeds it in -`dist/servediff-go`. Node and pnpm are build dependencies, not Go binary runtime -dependencies. Live repository mode still requires the Git executable. +The build generates the API client, builds the web application, stages its +output below `internal/webui`, then embeds it in `dist/servediff`. Node and pnpm +are build dependencies, not binary runtime dependencies. Live repository mode +still requires the Git executable. ## Dependency rules diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 00000000..03f4b47c --- /dev/null +++ b/docs/development.md @@ -0,0 +1,85 @@ +# Development + +servediff is a Go server with a React/Vite web application embedded into the +release binary. Node and pnpm are build and test dependencies only. + +## Requirements + +- Go 1.25 +- Git +- Node 26 +- pnpm 11 +- [Task](https://taskfile.dev) + +Install JavaScript dependencies after cloning: + +```sh +pnpm install +``` + +## Commands + +| Purpose | Command | +| ---------------------------------------------------------- | ---------------------------- | +| Start Vite HMR with a managed Go fixture server | `task dev` or `pnpm dev:web` | +| Start a standalone Go fixture server with built web assets | `task dev:server` | +| Generate the TypeScript API types | `task generate` | +| Build the web application | `task web:build` | +| Build the dependency-free CLI at `dist/servediff` | `task build` | +| Build and locally install the CLI | `task install` | +| Install Playwright Chromium | `pnpm test:browser:install` | +| Run web unit and browser tests | `pnpm test:web` | +| Run Go tests | `task test:go` | +| Run distribution API conformance tests | `task test:conformance` | +| Run TypeScript checks | `pnpm typecheck` | +| Run JavaScript linting | `pnpm lint` | +| Format supported files | `pnpm format` | +| Run every repository check | `task check` | + +`task install` embeds the current web build and installs `servediff` into +`$GOBIN`, or `$GOPATH/bin` when `GOBIN` is unset. Ensure that directory is on +`PATH`. The task also removes obsolete pnpm-global Node shims created by older +versions of the repository. + +## Development data + +Development uses `test/fixtures/sample.diff` with in-memory review state. The +Vite server starts and stops its own Go fixture process, so frontend development +does not need a real repository or persisted data. + +The Go binary also supports explicit fixture use: + +```sh +go run ./cmd/servediff \ + --fixture test/fixtures/sample.diff \ + --state memory \ + --no-browser +``` + +## Build pipeline + +The production build generates API types, builds the web application, stages the +Vite output under `internal/webui`, and embeds those assets into the Go binary. +The resulting `dist/servediff` executable has no Node runtime dependency. Live +repository mode still requires Git. + +The OpenAPI contract in `packages/api/openapi.yaml` is the frontend/server +boundary. After changing it, run: + +```sh +task generate +``` + +## Frontend conventions + +Follow [DESIGN.md](../DESIGN.md). Semantic tokens in `apps/web/src/tokens.css` +map into Tailwind and shadcn theme roles. Reuse primitives from +`apps/web/src/components/ui`; keep authored CSS for specialized layout, dynamic +geometry, and Pierre's measured rendering boundary. + +`App.tsx` composes page sections. `app-state.tsx` owns shared state through +`AppProvider`; `DiffWorkspace.tsx` owns Pierre rendering; `use-review.ts` uses +the generated REST client. Keep section-only state within its component. + +See [architecture.md](architecture.md) for repository boundaries and dependency +rules. diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index db3df427..3b360fdc 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -9,7 +9,6 @@ import ( "io" "io/fs" "mime" - "net" "net/http" "path" "strings" @@ -32,30 +31,19 @@ type cachedSnapshot struct { type Handler struct { source diffsource.Source store *reviewstore.Store - token string sessionID string assets fs.FS metrics *processmetrics.Collector mu sync.Mutex snapshots map[review.DiffMode]cachedSnapshot - hosts map[string]bool } -func New(source diffsource.Source, store *reviewstore.Store, token string, assets fs.FS) *Handler { +func New(source diffsource.Source, store *reviewstore.Store, assets fs.FS) *Handler { sessionID := SessionID(source) - hosts := map[string]bool{"127.0.0.1": true, "localhost": true, "0.0.0.0": true, "::1": true} - interfaces, _ := net.InterfaceAddrs() - for _, address := range interfaces { - value := address.String() - if index := strings.LastIndex(value, "/"); index >= 0 { - value = value[:index] - } - hosts[value] = true - } return &Handler{ - source: source, store: store, token: token, assets: assets, + source: source, store: store, assets: assets, sessionID: sessionID, metrics: processmetrics.New(), - snapshots: make(map[review.DiffMode]cachedSnapshot), hosts: hosts, + snapshots: make(map[review.DiffMode]cachedSnapshot), } } @@ -64,8 +52,8 @@ func SessionID(source diffsource.Source) string { return hex.EncodeToString(hash[:]) } -func RandomToken() (string, error) { - value := make([]byte, 24) +func randomID() (string, error) { + value := make([]byte, 16) if _, err := rand.Read(value); err != nil { return "", err } @@ -114,14 +102,6 @@ func (handler *Handler) ServeHTTP(response http.ResponseWriter, request *http.Re } func (handler *Handler) checkRequest(request *http.Request) error { - host := request.Host - if parsed, _, err := net.SplitHostPort(request.Host); err == nil { - host = parsed - } - host = strings.Trim(host, "[]") - if !handler.hosts[host] { - return diffsource.Error(403, "Invalid host") - } if origin := request.Header.Get("Origin"); origin != "" && origin != "http://"+request.Host { return diffsource.Error(403, "Cross-origin access denied") } @@ -146,9 +126,6 @@ func (handler *Handler) api(response http.ResponseWriter, request *http.Request) if !strings.HasPrefix(pathname, "/api/v1/") { return false, nil } - if handler.token != "" && request.Header.Get("Authorization") != "Bearer "+handler.token { - return true, diffsource.Error(401, "Missing or invalid API token") - } if pathname == "/api/v1/metrics" && request.Method == http.MethodGet { return true, writeJSON(response, 200, handler.metrics.Collect()) } @@ -365,12 +342,12 @@ func (handler *Handler) createComment(response http.ResponseWriter, request *htt if start > end { start, end = end, start } - id, err := RandomToken() + id, err := randomID() if err != nil { return err } comment := review.ReviewComment{ - ID: id[:32], Path: file.Path, Scope: body.Scope, Fingerprint: file.Fingerprint, + ID: id, Path: file.Path, Scope: body.Scope, Fingerprint: file.Fingerprint, Side: body.Side, Start: start, End: end, Code: code, Body: strings.TrimSpace(body.Body), Status: "open", CreatedAt: float64(time.Now().UnixMilli()), Origin: &review.ReviewOrigin{Source: snapshot.Source, Repository: snapshot.Name, Branch: snapshot.Branch, Head: snapshot.Head, Revision: snapshot.Revision, File: review.ReviewFileOrigin{Status: file.Status, OldPath: file.OldPath}}, } @@ -650,9 +627,6 @@ func (handler *Handler) problem(response http.ResponseWriter, err error) { if status >= 500 { title = "Internal Server Error" } - if status == 401 { - response.Header().Set("WWW-Authenticate", "Bearer") - } _ = writeJSONType(response, status, "application/problem+json; charset=utf-8", map[string]any{"type": "about:blank", "title": title, "status": status, "detail": err.Error()}) } diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index 0da22da0..b09c849d 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -16,7 +16,7 @@ import ( "github.com/flexdinesh/servediff/internal/reviewstore" ) -func request(t *testing.T, client *http.Client, method, url, token string, body any) *http.Response { +func request(t *testing.T, client *http.Client, method, url string, body any) *http.Response { t.Helper() var content io.Reader if body != nil { @@ -33,9 +33,6 @@ func request(t *testing.T, client *http.Client, method, url, token string, body if body != nil { req.Header.Set("Content-Type", "application/json") } - if token != "" { - req.Header.Set("Authorization", "Bearer "+token) - } response, err := client.Do(req) if err != nil { t.Fatal(err) @@ -66,36 +63,41 @@ func TestAPIReviewWorkflow(t *testing.T) { if err != nil { t.Fatal(err) } - handler := New(source, store, "test-token", fstest.MapFS{"index.html": {Data: []byte("web")}}) + handler := New(source, store, fstest.MapFS{"index.html": {Data: []byte("web")}}) server := httptest.NewServer(handler) defer server.Close() - unauthorized := request(t, server.Client(), http.MethodGet, server.URL+"/api/v1/session", "", nil) - if unauthorized.StatusCode != http.StatusUnauthorized { - t.Fatalf("unauthorized status: %d", unauthorized.StatusCode) + emptyCommentsResponse := request(t, server.Client(), http.MethodGet, server.URL+"/api/v1/comments", nil) + if emptyCommentsResponse.StatusCode != http.StatusOK { + t.Fatalf("comments status: %d", emptyCommentsResponse.StatusCode) + } + emptyComments := decode[struct { + Comments []review.ReviewComment `json:"comments"` + }](t, emptyCommentsResponse) + if emptyComments.Comments == nil || len(emptyComments.Comments) != 0 { + t.Fatalf("expected empty comment array, got %#v", emptyComments.Comments) } - unauthorized.Body.Close() for _, endpoint := range []string{ "/api/v1/comments/export?scope=", "/api/v1/comments/export?includeResolved=", } { - response := request(t, server.Client(), http.MethodGet, server.URL+endpoint, "test-token", nil) + response := request(t, server.Client(), http.MethodGet, server.URL+endpoint, nil) if response.StatusCode != http.StatusBadRequest { t.Fatalf("%s status: %d", endpoint, response.StatusCode) } response.Body.Close() } - diffResponse := request(t, server.Client(), http.MethodGet, server.URL+"/api/v1/diffs/current?scope=all", "test-token", nil) + diffResponse := request(t, server.Client(), http.MethodGet, server.URL+"/api/v1/diffs/current?scope=all", nil) if diffResponse.StatusCode != http.StatusOK { t.Fatalf("diff status: %d", diffResponse.StatusCode) } snapshot := decode[review.RepositoryDiff](t, diffResponse) file := snapshot.Files[0] - emptyMark := request(t, server.Client(), http.MethodPut, server.URL+"/api/v1/review-marks/"+file.ID+"?scope=all", "test-token", map[string]string{"fileVersion": ""}) + emptyMark := request(t, server.Client(), http.MethodPut, server.URL+"/api/v1/review-marks/"+file.ID+"?scope=all", map[string]string{"fileVersion": ""}) if emptyMark.StatusCode != http.StatusConflict { t.Fatalf("empty mark status: %d", emptyMark.StatusCode) } emptyMark.Body.Close() - createdResponse := request(t, server.Client(), http.MethodPost, server.URL+"/api/v1/comments", "test-token", map[string]any{ + createdResponse := request(t, server.Client(), http.MethodPost, server.URL+"/api/v1/comments", map[string]any{ "diffId": snapshot.Revision, "fileId": file.ID, "scope": "all", "fileVersion": file.Fingerprint, "side": "additions", "start": 1, "end": 1, "body": "Keep the new value.", }) @@ -107,21 +109,36 @@ func TestAPIReviewWorkflow(t *testing.T) { if created.Code != "+ export const value = 2;" || created.Origin == nil { t.Fatalf("created comment: %#v", created) } - markResponse := request(t, server.Client(), http.MethodPut, server.URL+"/api/v1/review-marks/"+file.ID+"?scope=all", "test-token", map[string]string{"fileVersion": file.Fingerprint}) + markResponse := request(t, server.Client(), http.MethodPut, server.URL+"/api/v1/review-marks/"+file.ID+"?scope=all", map[string]string{"fileVersion": file.Fingerprint}) if markResponse.StatusCode != http.StatusOK { t.Fatalf("mark status: %d", markResponse.StatusCode) } markResponse.Body.Close() - exportResponse := request(t, server.Client(), http.MethodGet, server.URL+"/api/v1/comments/export", "test-token", nil) + exportResponse := request(t, server.Client(), http.MethodGet, server.URL+"/api/v1/comments/export", nil) exported, _ := io.ReadAll(exportResponse.Body) exportResponse.Body.Close() if !strings.Contains(string(exported), "Keep the new value.") || !strings.Contains(string(exported), `applicability="anchored"`) { t.Fatalf("unexpected export: %s", exported) } - asset := request(t, server.Client(), http.MethodGet, server.URL+"/", "", nil) + asset := request(t, server.Client(), http.MethodGet, server.URL+"/", nil) content, _ := io.ReadAll(asset.Body) asset.Body.Close() if string(content) != "web" { t.Fatalf("unexpected asset: %q", content) } + hostRecorder := httptest.NewRecorder() + hostRequest := httptest.NewRequest(http.MethodHead, "/openapi.yaml", nil) + hostRequest.Host = "controlbox.device.host:7981" + handler.ServeHTTP(hostRecorder, hostRequest) + if hostRecorder.Code != http.StatusOK { + t.Fatalf("custom host status: %d", hostRecorder.Code) + } + crossOriginRecorder := httptest.NewRecorder() + crossOriginRequest := httptest.NewRequest(http.MethodHead, "/openapi.yaml", nil) + crossOriginRequest.Host = "controlbox.device.host:7981" + crossOriginRequest.Header.Set("Origin", "http://example.invalid") + handler.ServeHTTP(crossOriginRecorder, crossOriginRequest) + if crossOriginRecorder.Code != http.StatusForbidden { + t.Fatalf("cross-origin status: %d", crossOriginRecorder.Code) + } } diff --git a/internal/processmetrics/metrics.go b/internal/processmetrics/metrics.go index b81e6a3f..31e9d694 100644 --- a/internal/processmetrics/metrics.go +++ b/internal/processmetrics/metrics.go @@ -15,37 +15,37 @@ type Metrics struct { CPUUsage float64 `json:"cpuUsage"` } +type cpuTimes struct { + total float64 + idle float64 +} + type Collector struct { mu sync.Mutex clock func() time.Time - cpuSeconds func() float64 + cpuTimes func() cpuTimes rss func() uint64 previousAt time.Time - previousCPU float64 + previousCPU cpuTimes usage float64 } func New() *Collector { - return newCollector(time.Now, runtimeCPUSeconds, residentMemory) + return newCollector(time.Now, runtimeCPUTimes, residentMemory) } -func newCollector(clock func() time.Time, cpuSeconds func() float64, rss func() uint64) *Collector { - return &Collector{clock: clock, cpuSeconds: cpuSeconds, rss: rss} +func newCollector(clock func() time.Time, cpuTimes func() cpuTimes, rss func() uint64) *Collector { + return &Collector{clock: clock, cpuTimes: cpuTimes, rss: rss} } func (collector *Collector) Collect() Metrics { collector.mu.Lock() defer collector.mu.Unlock() - now, cpu := collector.clock(), collector.cpuSeconds() + now, cpu := collector.clock(), collector.cpuTimes() if !collector.previousAt.IsZero() { elapsed := now.Sub(collector.previousAt).Seconds() if elapsed >= 0.2 { - usage := (cpu - collector.previousCPU) / elapsed * 100 - if usage > 0 { - collector.usage = usage - } else { - collector.usage = 0 - } + collector.usage = cpuUsage(collector.previousCPU, cpu) collector.previousAt, collector.previousCPU = now, cpu } } else { @@ -54,10 +54,22 @@ func (collector *Collector) Collect() Metrics { return Metrics{RSSBytes: collector.rss(), CPUUsage: collector.usage} } -func runtimeCPUSeconds() float64 { - samples := []runtimemetrics.Sample{{Name: "/cpu/classes/total:cpu-seconds"}} +func cpuUsage(previous, current cpuTimes) float64 { + total := current.total - previous.total + if total <= 0 { + return 0 + } + idle := current.idle - previous.idle + return min(max((total-idle)/total*100, 0), 100) +} + +func runtimeCPUTimes() cpuTimes { + samples := []runtimemetrics.Sample{ + {Name: "/cpu/classes/total:cpu-seconds"}, + {Name: "/cpu/classes/idle:cpu-seconds"}, + } runtimemetrics.Read(samples) - return samples[0].Value.Float64() + return cpuTimes{total: samples[0].Value.Float64(), idle: samples[1].Value.Float64()} } func residentMemory() uint64 { diff --git a/internal/processmetrics/metrics_test.go b/internal/processmetrics/metrics_test.go index 7a74e852..d2008b3b 100644 --- a/internal/processmetrics/metrics_test.go +++ b/internal/processmetrics/metrics_test.go @@ -5,12 +5,12 @@ import ( "time" ) -func TestCollectorSamplesCPUOverWallTime(t *testing.T) { +func TestCollectorSamplesBusyCPUCapacity(t *testing.T) { times := []time.Time{time.Unix(0, 0), time.Unix(0, 100_000_000), time.Unix(1, 0)} - cpus := []float64{0.15, 0.20, 0.40} + cpus := []cpuTimes{{total: 4, idle: 3}, {total: 4.4, idle: 3.3}, {total: 8, idle: 6}} rss := []uint64{10, 20, 30} index := 0 - collector := newCollector(func() time.Time { return times[index] }, func() float64 { return cpus[index] }, func() uint64 { return rss[index] }) + collector := newCollector(func() time.Time { return times[index] }, func() cpuTimes { return cpus[index] }, func() uint64 { return rss[index] }) if value := collector.Collect(); value.RSSBytes != 10 || value.CPUUsage != 0 { t.Fatalf("first sample: %#v", value) } @@ -23,3 +23,21 @@ func TestCollectorSamplesCPUOverWallTime(t *testing.T) { t.Fatalf("sample: %#v", value) } } + +func TestCPUUsageClampsRuntimeEstimationNoise(t *testing.T) { + for name, test := range map[string]struct { + previous cpuTimes + current cpuTimes + expected float64 + }{ + "negative": {previous: cpuTimes{total: 4, idle: 3}, current: cpuTimes{total: 8, idle: 8}, expected: 0}, + "over": {previous: cpuTimes{total: 4, idle: 3}, current: cpuTimes{total: 8, idle: 2}, expected: 100}, + "reset": {previous: cpuTimes{total: 4, idle: 3}, current: cpuTimes{total: 2, idle: 1}, expected: 0}, + } { + t.Run(name, func(t *testing.T) { + if actual := cpuUsage(test.previous, test.current); actual != test.expected { + t.Fatalf("got %f, want %f", actual, test.expected) + } + }) + } +} diff --git a/internal/reviewstore/store.go b/internal/reviewstore/store.go index ba39e07d..fddce26f 100644 --- a/internal/reviewstore/store.go +++ b/internal/reviewstore/store.go @@ -121,7 +121,13 @@ func (store *Store) save() error { func (store *Store) Comments(id string) []review.ReviewComment { store.mu.Lock() defer store.mu.Unlock() - return append([]review.ReviewComment(nil), store.session(id).Comments...) + return cloneComments(store.session(id).Comments) +} + +func cloneComments(comments []review.ReviewComment) []review.ReviewComment { + result := make([]review.ReviewComment, len(comments)) + copy(result, comments) + return result } func (store *Store) PutComment(id string, comment review.ReviewComment) error { @@ -158,7 +164,7 @@ func (store *Store) ImportComments(id string, comments []review.ReviewComment) ( } } store.data.Sessions[id] = session - return append([]review.ReviewComment(nil), session.Comments...), store.save() + return cloneComments(session.Comments), store.save() } func (store *Store) DeleteComments(id string, selected map[string]bool) (int, error) { diff --git a/internal/reviewstore/store_test.go b/internal/reviewstore/store_test.go index 591c350f..2d9ed913 100644 --- a/internal/reviewstore/store_test.go +++ b/internal/reviewstore/store_test.go @@ -36,3 +36,20 @@ func TestStorePersistsNodeCompatibleData(t *testing.T) { t.Fatalf("state permissions: %o", stat.Mode().Perm()) } } + +func TestEmptyCommentsAreNonNil(t *testing.T) { + store, err := Open("") + if err != nil { + t.Fatal(err) + } + if comments := store.Comments("session"); comments == nil || len(comments) != 0 { + t.Fatalf("expected empty non-nil comments, got %#v", comments) + } + comments, err := store.ImportComments("session", nil) + if err != nil { + t.Fatal(err) + } + if comments == nil || len(comments) != 0 { + t.Fatalf("expected empty non-nil imported comments, got %#v", comments) + } +} diff --git a/package.json b/package.json index 90d23cd2..2f8ffd60 100644 --- a/package.json +++ b/package.json @@ -2,19 +2,13 @@ "name": "servediff-workspace", "private": true, "license": "apache-2.0", - "bin": { - "servediff": "./apps/server/src/cli.ts" - }, "type": "module", "scripts": { - "build": "pnpm generate:api && pnpm --filter @servediff/web build && pnpm --filter @servediff/server build", + "build": "pnpm generate:api && pnpm --filter @servediff/web build", "dev:web": "pnpm --filter @servediff/web dev", - "dev:server": "pnpm --filter @servediff/server dev", - "dev:go": "go run ./cmd/servediff --fixture test/fixtures/sample.diff --state memory --web-dir apps/web/dist", "generate:api": "pnpm --filter @servediff/api generate", "typecheck": "pnpm --recursive --if-present typecheck && tsc --noEmit -p test/conformance/tsconfig.json", "test": "pnpm --recursive --if-present test", - "test:server": "pnpm --filter @servediff/server test", "test:web": "pnpm --filter @servediff/web test", "test:go": "go test ./...", "test:conformance": "node --test test/conformance/*.test.ts", diff --git a/packages/api/openapi.yaml b/packages/api/openapi.yaml index da8dac2f..3f9539af 100644 --- a/packages/api/openapi.yaml +++ b/packages/api/openapi.yaml @@ -5,8 +5,6 @@ info: description: Local diff review API. servers: - url: / -security: - - bearerAuth: [] paths: /api/v1/metrics: get: @@ -301,10 +299,6 @@ paths: default: $ref: "#/components/responses/Problem" components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer parameters: Scope: name: scope @@ -355,6 +349,7 @@ components: cpuUsage: type: number minimum: 0 + maximum: 100 DiffMode: type: string enum: [all, staged, unstaged] diff --git a/packages/api/src/client.ts b/packages/api/src/client.ts index 8c3f1039..458edfca 100644 --- a/packages/api/src/client.ts +++ b/packages/api/src/client.ts @@ -1,50 +1,14 @@ import createClient from "openapi-fetch"; import type { components, paths } from "./schema.d.ts"; -const TOKEN_KEY = "servediff:api-token"; - -function token() { - if (typeof window === "undefined") return null; - try { - const fragmentToken = new URLSearchParams(location.hash.slice(1)).get( - "token", - ); - if (fragmentToken) { - sessionStorage.setItem(TOKEN_KEY, fragmentToken); - history.replaceState(null, "", `${location.pathname}${location.search}`); - return fragmentToken; - } - return sessionStorage.getItem(TOKEN_KEY); - } catch { - return null; - } -} - -export function createApiClient({ - baseUrl, - token, -}: { - baseUrl?: string; - token?: string; -} = {}) { +export function createApiClient({ baseUrl }: { baseUrl?: string } = {}) { return createClient({ ...(baseUrl === undefined ? {} : { baseUrl }), - ...(token === undefined - ? {} - : { headers: { Authorization: `Bearer ${token}` } }), }); } export const api = createApiClient(); -api.use({ - onRequest({ request }) { - const value = token(); - if (value) request.headers.set("Authorization", `Bearer ${value}`); - return request; - }, -}); - export type ApiSession = components["schemas"]["Session"]; export type ApiServerMetrics = components["schemas"]["ServerMetrics"]; export type ApiRepositoryDiff = components["schemas"]["RepositoryDiff"]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb3f291c..6739625e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,28 +84,6 @@ importers: specifier: 'catalog:' version: 7.0.2 - apps/server: - dependencies: - '@pierre/diffs': - specifier: 'catalog:' - version: 1.4.1(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@servediff/shared': - specifier: workspace:* - version: link:../../packages/shared - vite: - specifier: 'catalog:' - version: 8.2.2(@types/node@26.4.1)(jiti@2.7.0) - devDependencies: - '@servediff/api': - specifier: workspace:* - version: link:../../packages/api - '@types/node': - specifier: 'catalog:' - version: 26.4.1 - typescript: - specifier: 'catalog:' - version: 7.0.2 - apps/web: dependencies: '@base-ui/react': diff --git a/test/conformance/server.test.ts b/test/conformance/server.test.ts index edb76771..aca5d181 100644 --- a/test/conformance/server.test.ts +++ b/test/conformance/server.test.ts @@ -1,27 +1,15 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { readFile } from "node:fs/promises"; import { createServer } from "node:net"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { - createApiClient, - type ApiChangedFile, - type ApiFilePatch, -} from "../../packages/api/src/client.ts"; -import { ReviewStore } from "../../apps/server/src/review-store.ts"; -import { startServer } from "../../apps/server/src/server.ts"; +import { createApiClient } from "../../packages/api/src/client.ts"; const fixture = fileURLToPath( new URL("../fixtures/sample.diff", import.meta.url), ); -const binary = fileURLToPath( - new URL("../../dist/servediff-go", import.meta.url), -); -const apiValues = { - allScope: "all", - additionsSide: "additions", -} satisfies { allScope: "all"; additionsSide: "additions" }; +const binary = fileURLToPath(new URL("../../dist/servediff", import.meta.url)); +const apiValues = { allScope: "all" } satisfies { allScope: "all" }; async function freePort() { const server = createServer(); @@ -54,115 +42,72 @@ async function waitForServer(url: string) { throw new Error("Go server did not start", { cause: lastError }); } -test("Node and Go implement the fixture API contract", async (t) => { - const token = "conformance-token"; - const input = await readFile(fixture, "utf8"); - const node = await startServer({ - directory: ".", - input, - port: 0, - token, - store: new ReviewStore(null), - }); - t.after(() => node.close()); - +test("Go distribution implements the API contract", async (t) => { const port = await freePort(); - const go = spawn( + const server = spawn( binary, [ "--fixture", fixture, "--state", "memory", - "--token", - token, "--no-browser", "--port", String(port), ], { stdio: "ignore" }, ); - t.after(() => go.kill("SIGTERM")); - const goUrl = `http://127.0.0.1:${port}`; - await waitForServer(goUrl); - - const nodeClient = createApiClient({ baseUrl: node.url, token }); - const goClient = createApiClient({ baseUrl: goUrl, token }); - const [nodeSession, goSession] = await Promise.all([ - nodeClient.GET("/api/v1/session"), - goClient.GET("/api/v1/session"), - ]); - assert.deepEqual(goSession.data, nodeSession.data); + t.after(() => server.kill("SIGTERM")); + const url = `http://127.0.0.1:${port}`; + await waitForServer(url); + const client = createApiClient({ baseUrl: url }); - const [nodeDiff, goDiff] = await Promise.all([ - nodeClient.GET("/api/v1/diffs/current", { - params: { query: { scope: apiValues.allScope } }, - }), - goClient.GET("/api/v1/diffs/current", { - params: { query: { scope: apiValues.allScope } }, - }), - ]); - assert.deepEqual(goDiff.data, nodeDiff.data); - assert.ok(nodeDiff.data); - assert.ok(goDiff.data); + const comments = await client.GET("/api/v1/comments"); + assert.deepEqual(comments.data, { comments: [] }); - for (const file of nodeDiff.data.files) { - const goFile: ApiChangedFile | undefined = goDiff.data.files.find( - (candidate) => candidate.id === file.id, - ); - assert.ok(goFile); - const nodePatch: { data?: ApiFilePatch } = await nodeClient.GET( - "/api/v1/diffs/{diffId}/files/{fileId}/patch", - { - params: { - path: { diffId: nodeDiff.data.revision, fileId: file.id }, - query: { - scope: apiValues.allScope, - fileVersion: file.fingerprint, - }, - }, - }, - ); - const goPatch: { data?: ApiFilePatch } = await goClient.GET( - "/api/v1/diffs/{diffId}/files/{fileId}/patch", - { - params: { - path: { diffId: nodeDiff.data.revision, fileId: file.id }, - query: { - scope: apiValues.allScope, - fileVersion: file.fingerprint, - }, - }, - }, - ); - assert.deepEqual(goPatch.data, nodePatch.data); - } + const session = await client.GET("/api/v1/session"); + assert.equal(session.data?.source, "stdin"); + assert.equal(session.data?.name, "Piped diff"); + assert.deepEqual(session.data?.capabilities, { + scopes: [apiValues.allScope], + live: false, + fullFileContents: false, + }); - const file = nodeDiff.data.files.find( + const diff = await client.GET("/api/v1/diffs/current", { + params: { query: { scope: apiValues.allScope } }, + }); + assert.ok(diff.data); + assert.equal(diff.data.files.length, 12); + const file = diff.data.files.find( (candidate) => candidate.path === "src/value.ts", ); assert.ok(file); - const body = { - diffId: nodeDiff.data.revision, - fileId: file.id, - scope: apiValues.allScope, - fileVersion: file.fingerprint, - side: apiValues.additionsSide, - start: 1, - end: 1, - body: "Keep the new value.", - }; - const [nodeComment, goComment] = await Promise.all([ - nodeClient.POST("/api/v1/comments", { body }), - goClient.POST("/api/v1/comments", { body }), - ]); - assert.ok(nodeComment.data); - assert.ok(goComment.data); - const { - id: _nodeID, - createdAt: _nodeCreated, - ...nodeStable - } = nodeComment.data; - const { id: _goID, createdAt: _goCreated, ...goStable } = goComment.data; - assert.deepEqual(goStable, nodeStable); + + const preview = await client.GET( + "/api/v1/diffs/{diffId}/files/{fileId}/patch", + { + params: { + path: { diffId: diff.data.revision, fileId: file.id }, + query: { scope: apiValues.allScope, fileVersion: file.fingerprint }, + }, + }, + ); + assert.match(preview.data?.patch ?? "", /export const value = 2/); + + const created = await client.POST("/api/v1/comments", { + body: { + diffId: diff.data.revision, + fileId: file.id, + scope: apiValues.allScope, + fileVersion: file.fingerprint, + side: "additions", + start: 1, + end: 1, + body: "Keep the new value.", + }, + }); + assert.equal(created.data?.path, file.path); + assert.equal(created.data?.code, "+ export const value = 2;"); + assert.equal(created.data?.body, "Keep the new value."); }); diff --git a/tools/remove-legacy-install/main.go b/tools/remove-legacy-install/main.go new file mode 100644 index 00000000..01930e15 --- /dev/null +++ b/tools/remove-legacy-install/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const maximumShimSize = 64 * 1024 + +func removeLegacyShims(directory string) ([]string, error) { + removed := make([]string, 0, 3) + for _, name := range []string{"servediff", "servediff.cmd", "servediff.ps1"} { + path := filepath.Join(directory, name) + stat, err := os.Stat(path) + if os.IsNotExist(err) { + continue + } + if err != nil { + return nil, err + } + if stat.Size() > maximumShimSize { + continue + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + contents := strings.ReplaceAll(string(raw), `\`, "/") + if !strings.Contains(contents, "servediff-workspace") || !strings.Contains(contents, "apps/server/src/cli.ts") { + continue + } + if err := os.Remove(path); err != nil { + return nil, err + } + removed = append(removed, path) + } + return removed, nil +} + +func run() error { + output, err := exec.Command("pnpm", "bin", "--global").Output() + if err != nil { + return fmt.Errorf("locate pnpm global bin: %w", err) + } + removed, err := removeLegacyShims(strings.TrimSpace(string(output))) + if err != nil { + return err + } + for _, path := range removed { + fmt.Printf(" removed obsolete Node shim: %s\n", path) + } + return nil +} + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, " remove legacy servediff install: %v\n", err) + os.Exit(1) + } +} diff --git a/tools/remove-legacy-install/main_test.go b/tools/remove-legacy-install/main_test.go new file mode 100644 index 00000000..51715303 --- /dev/null +++ b/tools/remove-legacy-install/main_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRemoveLegacyShimsOnlyRemovesNodeEntrypoints(t *testing.T) { + directory := t.TempDir() + legacy := filepath.Join(directory, "servediff") + current := filepath.Join(directory, "servediff.cmd") + if err := os.WriteFile(legacy, []byte(`node C:\global\node_modules\servediff-workspace\apps\server\src\cli.ts`), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(current, []byte("current Go command"), 0o755); err != nil { + t.Fatal(err) + } + removed, err := removeLegacyShims(directory) + if err != nil { + t.Fatal(err) + } + if len(removed) != 1 || removed[0] != legacy { + t.Fatalf("unexpected removals: %v", removed) + } + if _, err := os.Stat(legacy); !os.IsNotExist(err) { + t.Fatalf("legacy shim remains: %v", err) + } + if _, err := os.Stat(current); err != nil { + t.Fatalf("current command removed: %v", err) + } +}