diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3844cd5..41f1e8c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,64 +1,309 @@ -# Publishes @aryam/fixmap-core and @aryam/fixmap to npm. +# Publishes @aryam/fixmap-core, @aryam/fixmap, and the FixMap MCP server. # -# Uses npm trusted publishing (OIDC): no npm token or one-time password is -# needed once each package is linked to this repository and workflow at -# https://www.npmjs.com/package//access -> Trusted Publisher -# (repository: aryamthecodebreaker/FixMap, workflow: publish.yml). +# Release procedure: +# 1. Create and push a stable v* tag for a commit already on main. +# 2. Manually dispatch this workflow from that tag in the Actions tab. +# 3. This workflow validates the tag and all version metadata, publishes and +# verifies npm and MCP Registry artifacts, then creates the GitHub release. # -# Runs when a GitHub release is published, or manually from the Actions tab. +# npm and MCP publication use trusted publishing (OIDC). Each package must be +# linked to this repository and workflow in its registry publisher settings. name: Publish on: - release: - types: [published] workflow_dispatch: permissions: - contents: read + contents: write id-token: write +concurrency: + group: fixmap-release + cancel-in-progress: false + +env: + MCP_PUBLISHER_VERSION: v1.8.0 + MCP_SERVER_NAME: io.github.aryamthecodebreaker/fixmap + MCP_REGISTRY_URL: https://registry.modelcontextprotocol.io/v0/servers + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 45 steps: + - name: Validate selected release tag + shell: bash + run: | + set -euo pipefail + if [[ "${GITHUB_REF_TYPE}" != "tag" ]] || + [[ ! "${GITHUB_REF_NAME}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::Dispatch this workflow from a stable tag such as v0.5.0, not from a branch or prerelease tag." + exit 1 + fi + echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + - uses: actions/checkout@v7 with: fetch-depth: 0 + + - name: Validate tag commit and release metadata + shell: bash + run: | + set -euo pipefail + TAG_COMMIT=$(git rev-list -n 1 "$GITHUB_REF_NAME") + HEAD_COMMIT=$(git rev-parse HEAD) + if [[ "$TAG_COMMIT" != "$HEAD_COMMIT" ]]; then + echo "::error::Checked-out commit ${HEAD_COMMIT} does not match ${GITHUB_REF_NAME} (${TAG_COMMIT})." + exit 1 + fi + + git fetch origin main:refs/remotes/origin/main --no-tags + if ! git merge-base --is-ancestor "$HEAD_COMMIT" origin/main; then + echo "::error::Release tag ${GITHUB_REF_NAME} must point to a commit already on origin/main." + exit 1 + fi + + node --input-type=module <<'NODE' + import { readFileSync } from "node:fs"; + + const readJson = (path) => JSON.parse(readFileSync(path, "utf8")); + const expected = process.env.VERSION; + const root = readJson("package.json"); + const core = readJson("packages/core/package.json"); + const cli = readJson("packages/cli/package.json"); + const action = readJson("packages/action/package.json"); + const lock = readJson("package-lock.json"); + const server = readJson("server.json"); + + const checks = [ + ["package.json version", root.version], + ["packages/core/package.json version", core.version], + ["packages/cli/package.json version", cli.version], + ["packages/action/package.json version", action.version], + ["package-lock.json version", lock.version], + ["package-lock.json root version", lock.packages?.[""]?.version], + ["package-lock.json core version", lock.packages?.["packages/core"]?.version], + ["package-lock.json CLI version", lock.packages?.["packages/cli"]?.version], + ["package-lock.json Action version", lock.packages?.["packages/action"]?.version], + ["CLI core dependency", cli.dependencies?.["@aryam/fixmap-core"]], + ["Action core dependency", action.dependencies?.["@aryam/fixmap-core"]], + ["server.json version", server.version], + ]; + + const errors = checks + .filter(([, actual]) => actual !== expected) + .map(([label, actual]) => `${label}: expected ${expected}, received ${String(actual)}`); + + if (cli.mcpName !== process.env.MCP_SERVER_NAME) { + errors.push( + `CLI mcpName: expected ${process.env.MCP_SERVER_NAME}, received ${String(cli.mcpName)}`, + ); + } + if (server.name !== process.env.MCP_SERVER_NAME) { + errors.push( + `server.json name: expected ${process.env.MCP_SERVER_NAME}, received ${String(server.name)}`, + ); + } + + const npmPackage = server.packages?.find( + (entry) => + entry.registryType === "npm" && + entry.identifier === "@aryam/fixmap", + ); + if (!npmPackage) { + errors.push("server.json must contain the @aryam/fixmap npm package"); + } else if (npmPackage.version !== expected) { + errors.push( + `server.json npm package version: expected ${expected}, received ${String(npmPackage.version)}`, + ); + } + + if (errors.length > 0) { + for (const error of errors) { + console.error(`::error::${error}`); + } + process.exit(1); + } + + console.log(`Validated ${process.env.GITHUB_REF_NAME} at ${process.env.GITHUB_SHA}.`); + NODE + + - name: Require an unpublished GitHub release + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + set +e + RESPONSE=$(gh api \ + -H "Accept: application/vnd.github+json" \ + "/repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" 2>&1) + STATUS=$? + set -e + + if [[ "$STATUS" -eq 0 ]]; then + echo "::error::A GitHub release already exists for ${GITHUB_REF_NAME}; npm and MCP must be verified before the release becomes public." + exit 1 + fi + if ! grep -q "HTTP 404" <<<"$RESPONSE"; then + printf '%s\n' "$RESPONSE" + exit "$STATUS" + fi + - uses: actions/setup-node@v6 with: node-version: 24 cache: npm registry-url: https://registry.npmjs.org + - run: npm ci - run: npm run ci - # Skip versions that are already on npm so a manual dispatch followed by - # the release-published trigger cannot fail on a republish. + - name: Publish @aryam/fixmap-core + shell: bash run: | - VERSION=$(node -p "require('./packages/core/package.json').version") - if npm view "@aryam/fixmap-core@${VERSION}" version >/dev/null 2>&1; then + set -euo pipefail + set +e + VIEW_OUTPUT=$(npm view "@aryam/fixmap-core@${VERSION}" version 2>&1) + VIEW_STATUS=$? + set -e + + if [[ "$VIEW_STATUS" -eq 0 ]]; then + if [[ "$VIEW_OUTPUT" != "$VERSION" ]]; then + echo "::error::npm returned unexpected core version: ${VIEW_OUTPUT}" + exit 1 + fi echo "@aryam/fixmap-core@${VERSION} is already published; skipping." - else + elif grep -q "E404" <<<"$VIEW_OUTPUT"; then npm publish -w packages/core --provenance --access public + else + printf '%s\n' "$VIEW_OUTPUT" + exit "$VIEW_STATUS" fi + - name: Publish @aryam/fixmap + shell: bash run: | - VERSION=$(node -p "require('./packages/cli/package.json').version") - if npm view "@aryam/fixmap@${VERSION}" version >/dev/null 2>&1; then + set -euo pipefail + set +e + VIEW_OUTPUT=$(npm view "@aryam/fixmap@${VERSION}" version 2>&1) + VIEW_STATUS=$? + set -e + + if [[ "$VIEW_STATUS" -eq 0 ]]; then + if [[ "$VIEW_OUTPUT" != "$VERSION" ]]; then + echo "::error::npm returned unexpected CLI version: ${VIEW_OUTPUT}" + exit 1 + fi echo "@aryam/fixmap@${VERSION} is already published; skipping." - else + elif grep -q "E404" <<<"$VIEW_OUTPUT"; then npm publish -w packages/cli --provenance --access public + else + printf '%s\n' "$VIEW_OUTPUT" + exit "$VIEW_STATUS" fi + + - name: Verify npm packages + shell: bash + run: | + set -euo pipefail + + verify_npm_version() { + local package_name=$1 + local published="" + for attempt in 1 2 3 4 5 6; do + if published=$(npm view "${package_name}@${VERSION}" version 2>/dev/null) && + [[ "$published" == "$VERSION" ]]; then + echo "Verified ${package_name}@${VERSION} on npm." + return 0 + fi + sleep 10 + done + echo "::error::Could not verify ${package_name}@${VERSION} on npm; received ${published:-no version}." + return 1 + } + + verify_npm_version "@aryam/fixmap-core" + verify_npm_version "@aryam/fixmap" + + PUBLISHED_CORE_DEPENDENCY=$(npm view \ + "@aryam/fixmap@${VERSION}" \ + dependencies.@aryam/fixmap-core) + if [[ "$PUBLISHED_CORE_DEPENDENCY" != "$VERSION" ]]; then + echo "::error::Published CLI depends on core ${PUBLISHED_CORE_DEPENDENCY}, expected ${VERSION}." + exit 1 + fi + - name: Install mcp-publisher - run: curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" | tar xz mcp-publisher - # OIDC authenticates this repository for the io.github.aryamthecodebreaker/* namespace. - - name: Publish to the MCP Registry run: | - VERSION=$(node -p "require('./packages/cli/package.json').version") - if curl -s "https://registry.modelcontextprotocol.io/v0/servers?search=io.github.aryamthecodebreaker/fixmap" | grep -q "\"version\": *\"${VERSION}\""; then - echo "MCP Registry already has ${VERSION}; skipping." + curl -fsSL --retry 3 \ + "https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz" | + tar xz mcp-publisher + + - name: Publish and verify the exact MCP Registry version + shell: bash + run: | + set -euo pipefail + REGISTRY_RESPONSE="${RUNNER_TEMP}/fixmap-mcp-registry.json" + + fetch_registry() { + curl --fail --silent --show-error --retry 3 \ + --get \ + --data-urlencode "search=${MCP_SERVER_NAME}" \ + "$MCP_REGISTRY_URL" \ + --output "$REGISTRY_RESPONSE" + } + + has_exact_registry_version() { + jq -e \ + --arg name "$MCP_SERVER_NAME" \ + --arg version "$VERSION" \ + 'any( + .servers[]?; + .server.name == $name + and .server.version == $version + and any( + .server.packages[]?; + .registryType == "npm" + and .identifier == "@aryam/fixmap" + and .version == $version + ) + )' \ + "$REGISTRY_RESPONSE" >/dev/null + } + + fetch_registry + if has_exact_registry_version; then + echo "${MCP_SERVER_NAME}@${VERSION} is already published; skipping." else - jq --arg v "$VERSION" '.version = $v | .packages[0].version = $v' server.json > server.tmp && mv server.tmp server.json ./mcp-publisher login github-oidc ./mcp-publisher publish fi + + VERIFIED=false + for attempt in 1 2 3 4 5 6; do + fetch_registry + if has_exact_registry_version; then + VERIFIED=true + break + fi + sleep 10 + done + + if [[ "$VERIFIED" != "true" ]]; then + echo "::error::Could not verify ${MCP_SERVER_NAME}@${VERSION} in the MCP Registry." + exit 1 + fi + echo "Verified ${MCP_SERVER_NAME}@${VERSION} in the MCP Registry." + + - name: Create the GitHub release + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + gh release create "$GITHUB_REF_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --title "FixMap ${GITHUB_REF_NAME}" \ + --generate-notes diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ee0534..d2fd5d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ All notable changes to FixMap are documented here. +## 0.5.0 - 2026-07-18 + +### Added + +- One-command public GitHub repository analysis in the CLI and MCP server: pass a canonical `https://github.com/owner/repository` URL as the repository input and FixMap will scan an anonymous depth-one temporary checkout (#54). +- An informational `remote-repo-fetched` diagnostic records the canonical source URL, default branch, and fetched commit so remote reports remain reproducible. + +### Security + +- Remote inputs accept only credential-free HTTPS URLs on `github.com`. Git credential and askpass helpers, inherited Git configuration and tokens, hooks, submodules, symlinks, and LFS smudging are disabled for the temporary checkout. +- Temporary checkouts are removed on success, clone failure, or analysis failure. Cleanup failure is a hard error rather than a successful report with source left on disk. + +### Changed + +- Remote URL mode is explicitly issue-only; diff analysis continues to require a local checkout with the requested refs. +- Published package metadata now includes homepage, issue tracker, and discovery keywords. +- Release publishing now validates the selected tag and every version field, verifies npm and MCP Registry artifacts before creating the public GitHub release, and includes the MIT license in both npm packages. +- Public copy describes FixMap output as an explainable report rather than claiming checks were executed as a review receipt. + +## 0.4.1 - 2026-07-16 + +### Added + +- Official MCP Registry metadata and OIDC publication, allowing MCP directories to discover `io.github.aryamthecodebreaker/fixmap`. + ## 0.4.0 - 2026-07-15 ### Added diff --git a/README.md b/README.md index 0e984bb..1eb961b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **Give your coding agent a map before it starts editing.** -Turn an issue or git diff into relevant files, test routes, risk notes, and an honest review receipt. +Turn an issue or git diff into relevant files, test routes, risk notes, and clear diagnostics. [![CI](https://github.com/aryamthecodebreaker/FixMap/actions/workflows/ci.yml/badge.svg)](https://github.com/aryamthecodebreaker/FixMap/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/%40aryam%2Ffixmap)](https://www.npmjs.com/package/@aryam/fixmap) @@ -24,13 +24,21 @@ Coding agents are fast once they have the right context. The expensive mistakes - reading a plausible file instead of the owning module - missing the test that would catch the regression - treating an unresolved git diff as “no changes” -- leaving reviewers to guess what was actually verified +- leaving reviewers without a clear map of what should be verified -FixMap is a transparent routing layer for that gap. It works locally, needs no account or API key, and does not send repository source to a third-party service. +FixMap is a transparent routing layer for that gap. It needs no account or API key and never uploads repository source to a third-party service. ## Quick start -Run from a JavaScript or TypeScript repository: +Try FixMap on any public GitHub repository without cloning it first: + +```bash +npx -y @aryam/fixmap plan \ + --issue "support public GitHub repository inputs" \ + --repo https://github.com/aryamthecodebreaker/FixMap +``` + +Or run it from a local JavaScript or TypeScript repository: ```bash npx @aryam/fixmap plan --issue "password reset emails fail" @@ -48,6 +56,12 @@ Machine-readable output: npx @aryam/fixmap plan --base main --head HEAD --format json --output fixmap-report.json ``` +### Public GitHub repository inputs + +CLI and MCP users can pass a canonical `https://github.com/owner/repository` URL to `--repo` / `repo`. FixMap anonymously shallow-clones the default branch into an isolated OS temporary directory, disables credentials, hooks, submodules, symlinks, and LFS smudging, scans without running install/build/test scripts, and removes the checkout before returning the report. + +Remote URL mode is deliberately issue-only in this first release. Clone the repository locally when you need `--diff`, `--base`, or `--head`. Paths and test commands in a remote report are informational because the temporary checkout no longer exists after analysis. + Example result: ```text @@ -85,11 +99,11 @@ Cursor, Windsurf, or any MCP client: } ``` -The agent calls `fixmap_plan` with an issue description or a diff spec (`main...HEAD`) and receives the same report as the CLI: context files with confidence and reasons, test routes, risk notes, and diagnostics. Everything runs locally over stdio; no repository content leaves the machine. +The agent calls `fixmap_plan` with an issue description or a diff spec (`main...HEAD`) and receives the same report as the CLI: context files with confidence and reasons, test routes, risk notes, and diagnostics. The `repo` argument accepts either a local path or a public GitHub HTTPS URL. Analysis runs locally over stdio; source is never uploaded by FixMap. ## Interactive demo -The [live website](https://fixmap-flax.vercel.app) includes a browser-only sample repository: change the task and watch the context pack update. It is deliberately labeled as a sample; the CLI scans real local repositories. +The [live website](https://fixmap-flax.vercel.app) includes a browser-only sample repository: change the task and watch the context pack update. It is deliberately labeled as a sample; the CLI scans real local repositories or isolated temporary checkouts of public GitHub repositories. ![The FixMap website on a desktop viewport: a hero that reads "Give your coding agent a map before it starts editing" next to a terminal mock of a fixmap report with context, verify, and risk sections.](docs/assets/fixmap-site-desktop.png) @@ -123,7 +137,7 @@ jobs: with: fetch-depth: 0 - id: fixmap - uses: aryamthecodebreaker/FixMap/packages/action@v0.4.0 + uses: aryamthecodebreaker/FixMap/packages/action@v0.5.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} ``` @@ -187,7 +201,7 @@ npm run ci ## Status and roadmap -FixMap is an early public release focused on JavaScript and TypeScript repositories. The [changelog](CHANGELOG.md) records what each released version shipped, most recently the MCP server mode and scanner/ranking fixes in v0.3.x. Near-term work: +FixMap is an early public release focused on JavaScript and TypeScript repositories. The [changelog](CHANGELOG.md) records what each release shipped, including cross-repository evaluation, MCP support, and one-command public GitHub repository analysis. Near-term work: - git co-change and ownership signals - adapters and examples for popular monorepo layouts diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index cd376c4..0da75fd 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,6 +1,7 @@ import { Demo } from "./demo"; -const installCommand = "npx @aryam/fixmap plan --issue \"password reset fails\""; +const installCommand = + "npx -y @aryam/fixmap plan --issue \"support public GitHub inputs\" --repo https://github.com/aryamthecodebreaker/FixMap"; export default function HomePage() { return ( @@ -31,6 +32,7 @@ export default function HomePage() {
Deterministic explanations Markdown + JSON + MCP server GitHub Action
@@ -66,7 +68,7 @@ export default function HomePage() {
01

Scan locally

Read repository shape, workspace scripts, tests, and the requested git diff without uploading source.

02

Rank with reasons

Combine task terms, changed files, file type, path proximity, and repository structure.

-
03

Leave a receipt

Return context, commands, risks, confidence, and diagnostics as Markdown or machine-readable JSON.

+
03

Explain the route

Return context, commands, risks, confidence, and diagnostics as Markdown or machine-readable JSON.

@@ -74,7 +76,7 @@ export default function HomePage() {

Thirty-second start

One command. No account. No API key.

-

Run it from the root of a JavaScript or TypeScript repository.

+

Point it at a public GitHub repository, or run it from a local JavaScript or TypeScript checkout.

{installCommand}
diff --git a/package-lock.json b/package-lock.json index 6bef101..18c0526 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "fixmap-workspace", - "version": "0.2.1", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fixmap-workspace", - "version": "0.2.1", + "version": "0.5.0", "license": "MIT", "workspaces": [ "packages/*", @@ -8485,18 +8485,18 @@ }, "packages/action": { "name": "@fixmap/action", - "version": "0.4.1", + "version": "0.5.0", "license": "MIT", "dependencies": { - "@aryam/fixmap-core": "0.3.1" + "@aryam/fixmap-core": "0.5.0" } }, "packages/cli": { "name": "@aryam/fixmap", - "version": "0.4.1", + "version": "0.5.0", "license": "MIT", "dependencies": { - "@aryam/fixmap-core": "0.4.1", + "@aryam/fixmap-core": "0.5.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "bin": { @@ -8508,7 +8508,7 @@ }, "packages/core": { "name": "@aryam/fixmap-core", - "version": "0.4.1", + "version": "0.5.0", "license": "MIT", "devDependencies": {} } diff --git a/package.json b/package.json index a45bb2e..661d641 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "fixmap-workspace", - "version": "0.2.1", + "version": "0.5.0", "private": true, - "description": "Repo context, test routing, and review receipts for AI-assisted development.", + "description": "Repo context, test routing, risk notes, and diagnostics for AI-assisted development.", "license": "MIT", "type": "module", "repository": { @@ -14,11 +14,21 @@ }, "homepage": "https://fixmap-flax.vercel.app", "keywords": [ + "ai-agents", "ai-coding", + "cli", + "coding-agent", "code-review", "developer-tools", + "github-action", "github-actions", + "local-first", + "mcp", + "model-context-protocol", + "repo-map", "repo-intelligence", + "repository-context", + "test-routing", "testing" ], "workspaces": [ diff --git a/packages/action/package.json b/packages/action/package.json index f60a2c2..5b1ab8d 100644 --- a/packages/action/package.json +++ b/packages/action/package.json @@ -1,6 +1,6 @@ { "name": "@fixmap/action", - "version": "0.4.1", + "version": "0.5.0", "description": "GitHub Action wrapper for FixMap pull request reports.", "private": true, "license": "MIT", @@ -10,6 +10,6 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@aryam/fixmap-core": "0.4.1" + "@aryam/fixmap-core": "0.5.0" } } diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE new file mode 100644 index 0000000..1db99bf --- /dev/null +++ b/packages/cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Aryam + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/cli/README.md b/packages/cli/README.md index 1eee828..f357642 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2,11 +2,19 @@ **Give your coding agent a map before it starts editing.** -FixMap turns an issue, prompt, or git diff into ranked context files, test routes, and risk notes — locally, with no account or API key. Nothing leaves your machine. +FixMap turns an issue, prompt, or git diff into ranked context files, test routes, and risk notes — with no account or API key and no source upload. ## Quick start -Run from a JavaScript or TypeScript repository: +Try a public GitHub repository without cloning it first: + +```bash +npx -y @aryam/fixmap plan \ + --issue "support public GitHub repository inputs" \ + --repo https://github.com/aryamthecodebreaker/FixMap +``` + +Or run from a local JavaScript or TypeScript repository: ```bash npx @aryam/fixmap plan --issue "password reset emails fail" @@ -24,6 +32,8 @@ Machine-readable output: npx @aryam/fixmap plan --base main --head HEAD --format json --output fixmap-report.json ``` +Public GitHub URL mode is available in the CLI and MCP server for issue-only analysis. FixMap anonymously shallow-clones the default branch into an isolated temporary directory, disables credentials and repository execution surfaces, and removes the checkout before returning. Clone locally to use `--diff`, `--base`, or `--head`. + ## MCP server FixMap ships as a Model Context Protocol server, so coding agents can request a plan themselves. One tool is exposed: `fixmap_plan`. @@ -57,7 +67,7 @@ fixmap mcp Run FixMap as an MCP server over stdio --diff Git diff spec, such as main...HEAD --base Base ref for diffing when --diff is not given --head Head ref for diffing (defaults to HEAD) ---repo Repository root to scan (defaults to current directory) +--repo Local path or public GitHub HTTPS URL --format Output format: markdown (default) or json --output Write the report to a file instead of stdout ``` diff --git a/packages/cli/package.json b/packages/cli/package.json index b04c828..160a4f6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@aryam/fixmap", - "version": "0.4.1", + "version": "0.5.0", "mcpName": "io.github.aryamthecodebreaker/fixmap", "description": "CLI and MCP server for mapping issues and diffs to relevant files, tests, and review risks.", "license": "MIT", @@ -9,11 +9,30 @@ "url": "git+https://github.com/aryamthecodebreaker/FixMap.git", "directory": "packages/cli" }, + "homepage": "https://fixmap-flax.vercel.app", + "bugs": { + "url": "https://github.com/aryamthecodebreaker/FixMap/issues" + }, + "keywords": [ + "ai-agents", + "ai-coding", + "cli", + "coding-agent", + "developer-tools", + "github-action", + "local-first", + "mcp", + "model-context-protocol", + "repo-intelligence", + "repo-map", + "repository-context", + "test-routing" + ], "type": "module", "bin": { "fixmap": "dist/cli.js" }, - "files": ["dist"], + "files": ["dist", "LICENSE"], "publishConfig": { "access": "public" }, "scripts": { "build": "tsc -p tsconfig.json", @@ -21,7 +40,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@aryam/fixmap-core": "0.4.1", + "@aryam/fixmap-core": "0.5.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "engines": { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d0d33aa..eb14ea3 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,14 +1,15 @@ #!/usr/bin/env node import { readFileSync } from "node:fs"; -import { stat, writeFile } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; +import { writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { buildFixMapReport, renderJsonReport, renderMarkdownReport } from "@aryam/fixmap-core"; +import { renderJsonReport, renderMarkdownReport, type FixMapReport } from "@aryam/fixmap-core"; +import { buildReportForRepository } from "./repository-source.js"; type CliOptions = { command: string; issueText: string; - repoRoot: string; + repo: string; diffSpec?: string | undefined; baseRef?: string | undefined; headRef?: string | undefined; @@ -21,6 +22,7 @@ const USAGE = `FixMap maps an issue, prompt, or diff to context files, test rout Usage: fixmap plan --issue "Users cannot reset passwords" + fixmap plan --issue "Fix login" --repo https://github.com/owner/repository fixmap plan --diff main...HEAD fixmap plan --base main --head HEAD --format json fixmap mcp @@ -34,7 +36,7 @@ Options: --diff Git diff spec, such as main...HEAD --base Base ref for diffing when --diff is not given --head Head ref for diffing (defaults to HEAD) - --repo Repository root to scan (defaults to current directory) + --repo Local path or public GitHub HTTPS URL (defaults to current directory) --format Output format: markdown (default) or json --output Write the report to a file instead of stdout --help, -h Show this help @@ -81,19 +83,20 @@ async function runPlan(args: string[]): Promise { process.exit(1); } - if (!(await isDirectory(options.repoRoot))) { - console.error(`Repository root "${options.repoRoot}" does not exist or is not a directory. Check the --repo path.`); + let report: FixMapReport; + try { + report = await buildReportForRepository({ + repo: options.repo, + issueText: options.issueText, + diffSpec: options.diffSpec, + baseRef: options.baseRef, + headRef: options.headRef + }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); process.exit(1); } - const report = await buildFixMapReport({ - repoRoot: options.repoRoot, - issueText: options.issueText, - diffSpec: options.diffSpec, - baseRef: options.baseRef, - headRef: options.headRef - }); - if (!options.issueText) { const diffFailure = report.diagnostics.find((diagnostic) => diagnostic.code === "diff-unavailable"); if (diffFailure) { @@ -111,18 +114,10 @@ async function runPlan(args: string[]): Promise { } } -async function isDirectory(path: string): Promise { - try { - return (await stat(path)).isDirectory(); - } catch { - return false; - } -} - function parseArgs(args: string[]): CliOptions { const command = args[0] ?? ""; let issueText = ""; - let repoRoot = process.cwd(); + let repo = process.cwd(); let diffSpec: string | undefined; let baseRef: string | undefined; let headRef: string | undefined; @@ -155,7 +150,7 @@ function parseArgs(args: string[]): CliOptions { headRef = nextValue; index += consumedNext ? 1 : 0; } else if (arg === "--repo" && nextValue) { - repoRoot = resolve(nextValue); + repo = nextValue; index += consumedNext ? 1 : 0; } else if (arg === "--format" && (nextValue === "markdown" || nextValue === "json")) { format = nextValue; @@ -171,7 +166,7 @@ function parseArgs(args: string[]): CliOptions { return { command, issueText, - repoRoot, + repo, diffSpec, baseRef, headRef, diff --git a/packages/cli/src/mcp.ts b/packages/cli/src/mcp.ts index e3c421d..2854426 100644 --- a/packages/cli/src/mcp.ts +++ b/packages/cli/src/mcp.ts @@ -1,11 +1,18 @@ import { readFileSync } from "node:fs"; -import { stat } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; -import { buildFixMapReport, renderJsonReport, renderMarkdownReport } from "@aryam/fixmap-core"; +import { + renderJsonReport, + renderMarkdownReport, + type FixMapReport +} from "@aryam/fixmap-core"; +import { + buildReportForRepository, + type RepositorySourceDependencies +} from "./repository-source.js"; type PlanArguments = { issue?: string; @@ -33,7 +40,9 @@ const PLAN_TOOL = { head: { type: "string", description: "Head git ref, defaults to HEAD" }, repo: { type: "string", - description: "Absolute path to the repository root, defaults to the server working directory" + description: + "Local path or public GitHub HTTPS repository URL, defaults to the server working directory. " + + "GitHub URLs support issue-only analysis and are removed after scanning." }, format: { type: "string", @@ -44,7 +53,9 @@ const PLAN_TOOL = { } }; -export function createFixMapMcpServer(): Server { +export function createFixMapMcpServer( + repositorySourceDependencies: RepositorySourceDependencies = {} +): Server { const server = new Server({ name: "fixmap", version: readVersion() }, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [PLAN_TOOL] })); @@ -65,22 +76,22 @@ export function createFixMapMcpServer(): Server { }; } - const repoRoot = args.repo ? resolve(args.repo) : process.cwd(); - if (!(await isDirectory(repoRoot))) { + let report: FixMapReport; + try { + report = await buildReportForRepository({ + repo: args.repo ?? process.cwd(), + issueText: args.issue, + diffSpec: args.diff, + baseRef: args.base, + headRef: args.head + }, repositorySourceDependencies); + } catch (error) { return { isError: true, - content: [{ type: "text", text: `Repository root "${repoRoot}" does not exist or is not a directory.` }] + content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }] }; } - const report = await buildFixMapReport({ - repoRoot, - issueText: args.issue, - diffSpec: args.diff, - baseRef: args.base, - headRef: args.head - }); - if (!args.issue) { const diffFailure = report.diagnostics.find((diagnostic) => diagnostic.code === "diff-unavailable"); if (diffFailure) { @@ -102,14 +113,6 @@ export async function runMcpServer(): Promise { await createFixMapMcpServer().connect(new StdioServerTransport()); } -async function isDirectory(path: string): Promise { - try { - return (await stat(path)).isDirectory(); - } catch { - return false; - } -} - function readVersion(): string { const packageJson = JSON.parse( readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8") diff --git a/packages/cli/src/repository-source.ts b/packages/cli/src/repository-source.ts new file mode 100644 index 0000000..ecbb455 --- /dev/null +++ b/packages/cli/src/repository-source.ts @@ -0,0 +1,378 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { + buildFixMapReport, + type FixMapReport, + type ScanDiagnostic +} from "@aryam/fixmap-core"; + +const exec = promisify(execFile); +const GIT_MAX_BUFFER = 10 * 1024 * 1024; +const CLONE_TIMEOUT_MS = 120_000; +const URL_SCHEME = /^[a-z][a-z\d+.-]*:\/\//i; +const SCP_STYLE_REMOTE = /^[^\\/@\s]+@[^:]+:/; +const GITHUB_NAME = /^[a-z\d._-]+$/i; + +export type RepositoryPlanInput = { + repo: string; + issueText?: string | undefined; + diffSpec?: string | undefined; + baseRef?: string | undefined; + headRef?: string | undefined; +}; + +export type ClonedRepository = { + ref: string; + revision: string; +}; + +export type RepositorySourceDependencies = { + clonePublicRepository?: ( + url: string, + destination: string, + hooksDirectory: string + ) => Promise; + makeTemporaryDirectory?: (prefix: string) => Promise; + removeTemporaryDirectory?: (path: string) => Promise; +}; + +export type ParsedRepositorySource = + | { kind: "local"; repoRoot: string } + | { kind: "github"; cloneUrl: string; displayUrl: string }; + +type ResolvedRepositorySource = { + kind: "local" | "github"; + repoRoot: string; + diagnostic?: ScanDiagnostic | undefined; +}; + +export class RepositorySourceError extends Error { + override name = "RepositorySourceError"; +} + +export function buildIsolatedGitEnvironment( + inheritedEnvironment: NodeJS.ProcessEnv, + homeDirectory: string, + gitConfigPath: string +): NodeJS.ProcessEnv { + const blockedEnvironmentNames = new Set([ + "GCM_INTERACTIVE", + "GH_TOKEN", + "GITHUB_TOKEN", + "HOME", + "SSH_ASKPASS", + "SSH_ASKPASS_REQUIRE", + "SUDO_ASKPASS", + "USERPROFILE", + "XDG_CONFIG_HOME" + ]); + const sanitizedEnvironment = Object.fromEntries( + Object.entries(inheritedEnvironment).filter(([name]) => { + const uppercaseName = name.toUpperCase(); + return !uppercaseName.startsWith("GIT_") && + !blockedEnvironmentNames.has(uppercaseName); + }) + ); + + return { + ...sanitizedEnvironment, + GCM_INTERACTIVE: "Never", + GIT_CONFIG_GLOBAL: gitConfigPath, + GIT_CONFIG_NOSYSTEM: "1", + GIT_LFS_SKIP_SMUDGE: "1", + GIT_TERMINAL_PROMPT: "0", + HOME: homeDirectory, + USERPROFILE: homeDirectory, + XDG_CONFIG_HOME: homeDirectory + }; +} + +export function parseRepositorySource(input: string): ParsedRepositorySource { + const trimmed = input.trim(); + const looksLikeUrl = URL_SCHEME.test(trimmed) || SCP_STYLE_REMOTE.test(trimmed); + + if (!looksLikeUrl) { + if (/^github\.com[\\/]/i.test(trimmed)) { + throw new RepositorySourceError( + `GitHub repository URLs must start with "https://". Try "https://${trimmed.replaceAll("\\", "/")}".` + ); + } + return { kind: "local", repoRoot: resolve(input) }; + } + if ( + /[\u0000-\u001f\u007f]/.test(input) || + trimmed.includes("\\") || + /%(?:2f|5c)/i.test(trimmed) + ) { + throw new RepositorySourceError( + 'Repository URLs must use the canonical form "https://github.com/owner/repository".' + ); + } + + let url: URL; + try { + url = new URL(trimmed); + } catch { + throw new RepositorySourceError( + 'Repository URLs must use the form "https://github.com/owner/repository".' + ); + } + + if (url.protocol !== "https:" || url.hostname.toLowerCase() !== "github.com" || url.port) { + throw new RepositorySourceError( + 'Only public HTTPS GitHub URLs are supported: "https://github.com/owner/repository".' + ); + } + if (url.username || url.password) { + throw new RepositorySourceError( + "GitHub repository URLs must not contain credentials. Use the public HTTPS URL instead." + ); + } + if (url.search || url.hash) { + throw new RepositorySourceError( + "GitHub repository URLs must not contain query parameters or fragments." + ); + } + + const segments = url.pathname.split("/").filter(Boolean); + if (segments.length !== 2) { + throw new RepositorySourceError( + "The GitHub URL must identify one repository. Branch, file, and subdirectory URLs are not supported." + ); + } + + const owner = segments[0] ?? ""; + const rawRepository = segments[1] ?? ""; + const repository = rawRepository.toLowerCase().endsWith(".git") + ? rawRepository.slice(0, -4) + : rawRepository; + if (!GITHUB_NAME.test(owner) || !GITHUB_NAME.test(repository)) { + throw new RepositorySourceError( + 'Repository URLs must use the form "https://github.com/owner/repository".' + ); + } + + const displayUrl = `https://github.com/${owner}/${repository}`; + return { + kind: "github", + displayUrl, + cloneUrl: `${displayUrl}.git` + }; +} + +export async function buildReportForRepository( + input: RepositoryPlanInput, + dependencies: RepositorySourceDependencies = {} +): Promise { + const source = parseRepositorySource(input.repo); + if ( + source.kind === "github" && + (input.diffSpec !== undefined || input.baseRef !== undefined || input.headRef !== undefined) + ) { + throw new RepositorySourceError( + "Git diff options are not supported with a temporary GitHub URL checkout yet. " + + "Use --issue only, or clone the repository locally before using --diff, --base, or --head." + ); + } + + return withRepositorySource(source, async (resolvedSource) => { + const report = await buildFixMapReport({ + repoRoot: resolvedSource.repoRoot, + issueText: input.issueText, + diffSpec: input.diffSpec, + baseRef: input.baseRef, + headRef: input.headRef + }); + if (resolvedSource.diagnostic) { + report.diagnostics.unshift(resolvedSource.diagnostic); + } + return report; + }, dependencies); +} + +export async function withRepositorySource( + source: ParsedRepositorySource, + work: (source: ResolvedRepositorySource) => Promise, + dependencies: RepositorySourceDependencies = {} +): Promise { + if (source.kind === "local") { + if (!(await isDirectory(source.repoRoot))) { + throw new RepositorySourceError( + `Repository root "${source.repoRoot}" does not exist or is not a directory.` + ); + } + return work({ kind: "local", repoRoot: source.repoRoot }); + } + + const makeTemporaryDirectory = dependencies.makeTemporaryDirectory ?? mkdtemp; + const removeTemporaryDirectory = dependencies.removeTemporaryDirectory ?? + ((path: string) => rm(path, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })); + const clonePublicRepository = dependencies.clonePublicRepository ?? defaultClonePublicRepository; + const temporaryRoot = await makeTemporaryDirectory(join(tmpdir(), "fixmap-github-")); + const checkoutRoot = join(temporaryRoot, "repository"); + const hooksDirectory = join(temporaryRoot, "disabled-hooks"); + let primaryError: unknown; + + try { + await mkdir(hooksDirectory, { recursive: true }); + + let cloned: ClonedRepository; + try { + cloned = await clonePublicRepository(source.cloneUrl, checkoutRoot, hooksDirectory); + } catch (error) { + throw new RepositorySourceError( + `Could not fetch public GitHub repository "${source.displayUrl}": ${errorDetail(error)}.` + ); + } + + const diagnostic: ScanDiagnostic = { + code: "remote-repo-fetched", + severity: "info", + message: + `Fetched ${source.displayUrl} at ${cloned.ref}@${cloned.revision} into an isolated ` + + "temporary checkout; no repository hooks or scripts were run, and the checkout was removed after analysis." + }; + + return await work({ + kind: "github", + repoRoot: checkoutRoot, + diagnostic + }); + } catch (error) { + primaryError = error; + throw error; + } finally { + try { + await removeTemporaryDirectory(temporaryRoot); + } catch (cleanupError) { + const cleanupMessage = + `Could not remove temporary checkout "${temporaryRoot}": ${errorDetail(cleanupError)}.`; + if (primaryError instanceof Error) { + throw new RepositorySourceError(`${primaryError.message} ${cleanupMessage}`); + } + throw new RepositorySourceError(cleanupMessage); + } + } +} + +async function defaultClonePublicRepository( + url: string, + destination: string, + hooksDirectory: string +): Promise { + const isolationRoot = dirname(hooksDirectory); + const homeDirectory = join(isolationRoot, "isolated-home"); + const templateDirectory = join(isolationRoot, "empty-template"); + const gitConfigPath = join(isolationRoot, "empty-gitconfig"); + await Promise.all([ + mkdir(homeDirectory, { recursive: true }), + mkdir(templateDirectory, { recursive: true }), + writeFile(gitConfigPath, "", "utf8") + ]); + + const gitEnvironment = buildIsolatedGitEnvironment( + process.env, + homeDirectory, + gitConfigPath + ); + const commonOptions = { + env: gitEnvironment, + maxBuffer: GIT_MAX_BUFFER, + timeout: CLONE_TIMEOUT_MS, + windowsHide: true + }; + + await exec( + "git", + [ + "-c", "credential.helper=", + "-c", "http.extraHeader=", + "-c", "http.sslVerify=true", + "-c", `core.hooksPath=${hooksDirectory}`, + "-c", "protocol.allow=never", + "-c", "protocol.https.allow=always", + "clone", + "--quiet", + "--depth", "1", + "--single-branch", + "--no-tags", + "--no-recurse-submodules", + `--template=${templateDirectory}`, + "--config", "credential.helper=", + "--config", "http.extraHeader=", + "--config", `core.hooksPath=${hooksDirectory}`, + "--config", "core.fsmonitor=false", + "--config", "core.symlinks=false", + "--config", "filter.lfs.smudge=", + "--config", "filter.lfs.required=false", + "--", + url, + destination + ], + commonOptions + ); + + const { stdout: revisionOutput } = await exec( + "git", + ["-C", destination, "rev-parse", "--verify", "HEAD"], + commonOptions + ); + let ref = "HEAD"; + try { + const { stdout: refOutput } = await exec( + "git", + ["-C", destination, "symbolic-ref", "--short", "HEAD"], + commonOptions + ); + ref = refOutput.trim() || ref; + } catch { + // Detached default branches are valid; the commit still identifies the fetched source. + } + + return { + ref, + revision: revisionOutput.trim() + }; +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + +function errorDetail(error: unknown): string { + const candidate = error as { + code?: unknown; + killed?: unknown; + message?: unknown; + stderr?: unknown; + }; + if (candidate.code === "ENOENT") { + return "Git is not installed or is not available on PATH"; + } + if (candidate.killed === true || candidate.code === "ETIMEDOUT") { + return `the clone exceeded the ${CLONE_TIMEOUT_MS / 1000}-second timeout`; + } + + const stderr = typeof candidate.stderr === "string" ? candidate.stderr : ""; + const message = typeof candidate.message === "string" ? candidate.message : String(error); + const detail = stderr.split(/\r?\n/).find((line) => line.trim()) ?? message.split(/\r?\n/)[0] ?? "unknown error"; + const normalized = detail.trim().replace(/\s+/g, " "); + if ( + /repository not found/i.test(normalized) || + /authentication failed/i.test(normalized) || + /terminal prompts disabled/i.test(normalized) + ) { + return "repository was not found or is not publicly accessible"; + } + if (/needed a single revision/i.test(normalized) || /unknown revision.*head/i.test(normalized)) { + return "repository has no default-branch commit to analyze"; + } + return normalized; +} diff --git a/packages/cli/test/mcp.test.ts b/packages/cli/test/mcp.test.ts index 6497853..07f677f 100644 --- a/packages/cli/test/mcp.test.ts +++ b/packages/cli/test/mcp.test.ts @@ -5,6 +5,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { describe, expect, it } from "vitest"; import { createFixMapMcpServer } from "../src/mcp.js"; +import type { RepositorySourceDependencies } from "../src/repository-source.js"; async function createAuthFixture(): Promise { const root = await mkdtemp(join(tmpdir(), "fixmap-mcp-")); @@ -19,9 +20,9 @@ async function createAuthFixture(): Promise { return root; } -async function connectClient() { +async function connectClient(repositorySourceDependencies: RepositorySourceDependencies = {}) { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const server = createFixMapMcpServer(); + const server = createFixMapMcpServer(repositorySourceDependencies); const client = new Client({ name: "fixmap-test-client", version: "0.0.0" }); await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); return client; @@ -39,6 +40,7 @@ describe("fixmap mcp server", () => { expect(Object.keys(plan?.inputSchema.properties ?? {})).toEqual( expect.arrayContaining(["issue", "diff", "base", "head", "repo", "format"]) ); + expect(plan?.inputSchema.properties?.repo?.description).toContain("public GitHub HTTPS"); }); it("returns a markdown report for an issue", async () => { @@ -71,6 +73,92 @@ describe("fixmap mcp server", () => { expect(report.contextFiles[0]?.path).toBe("src/auth/reset-password.ts"); }); + it("analyzes a public GitHub URL through an isolated temporary checkout", async () => { + const client = await connectClient({ + clonePublicRepository: async (_url, destination) => { + await mkdir(join(destination, "src", "auth"), { recursive: true }); + await writeFile( + join(destination, "package.json"), + JSON.stringify({ scripts: { test: "vitest run" } }) + ); + await writeFile( + join(destination, "src", "auth", "reset-password.ts"), + "export function sendResetEmail(email: string) { return email; }\n" + ); + return { + ref: "main", + revision: "0123456789abcdef0123456789abcdef01234567" + }; + } + }); + + const result = await client.callTool({ + name: "fixmap_plan", + arguments: { + issue: "password reset emails fail", + repo: "https://github.com/owner/repository", + format: "json" + } + }); + + expect(result.isError).toBeFalsy(); + const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ""; + const report = JSON.parse(text) as { + contextFiles: Array<{ path: string }>; + diagnostics: Array<{ code: string; severity: string }>; + }; + expect(report.contextFiles[0]?.path).toBe("src/auth/reset-password.ts"); + expect(report.diagnostics[0]).toMatchObject({ + code: "remote-repo-fetched", + severity: "info" + }); + }); + + it("rejects diff options for GitHub URLs before attempting a clone", async () => { + let cloneCalled = false; + const client = await connectClient({ + clonePublicRepository: async () => { + cloneCalled = true; + throw new Error("should not clone"); + } + }); + + const result = await client.callTool({ + name: "fixmap_plan", + arguments: { + issue: "password reset emails fail", + diff: "main...HEAD", + repo: "https://github.com/owner/repository" + } + }); + + expect(result.isError).toBe(true); + expect(cloneCalled).toBe(false); + const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ""; + expect(text).toContain("Git diff options are not supported"); + }); + + it("returns a sanitized error when a public repository cannot be fetched", async () => { + const client = await connectClient({ + clonePublicRepository: async () => { + throw new Error("repository not found"); + } + }); + + const result = await client.callTool({ + name: "fixmap_plan", + arguments: { + issue: "password reset emails fail", + repo: "https://github.com/owner/missing" + } + }); + + expect(result.isError).toBe(true); + const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ""; + expect(text).toContain("Could not fetch public GitHub repository"); + expect(text).toContain("repository was not found or is not publicly accessible"); + }); + it("rejects a nonexistent repo path instead of returning an empty report", async () => { const client = await connectClient(); diff --git a/packages/cli/test/repository-source.test.ts b/packages/cli/test/repository-source.test.ts new file mode 100644 index 0000000..2c5ae83 --- /dev/null +++ b/packages/cli/test/repository-source.test.ts @@ -0,0 +1,287 @@ +import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + buildIsolatedGitEnvironment, + buildReportForRepository, + parseRepositorySource, + withRepositorySource, + type ClonedRepository +} from "../src/repository-source.js"; + +const localFixtures: string[] = []; +const REVISION = "0123456789abcdef0123456789abcdef01234567"; + +afterEach(async () => { + await Promise.all(localFixtures.splice(0).map((path) => + rm(path, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }) + )); +}); + +async function createFixture(root: string): Promise { + await mkdir(join(root, "src", "auth"), { recursive: true }); + await mkdir(join(root, "test", "auth"), { recursive: true }); + await writeFile(join(root, "package.json"), JSON.stringify({ scripts: { test: "vitest run" } })); + await writeFile( + join(root, "src", "auth", "reset-password.ts"), + "export function sendResetEmail(email: string) { return email; }\n" + ); + await writeFile( + join(root, "test", "auth", "reset-password.test.ts"), + "import '../../src/auth/reset-password';\n" + ); +} + +async function fixtureClone( + _url: string, + destination: string +): Promise { + await createFixture(destination); + return { ref: "main", revision: REVISION }; +} + +describe("repository source parsing", () => { + it.each([ + "https://github.com/owner/repository", + "https://github.com/owner/repository/", + "https://github.com/owner/repository.git", + "https://github.com/owner/repository.git/" + ])("accepts a canonical public GitHub repository URL: %s", (input) => { + expect(parseRepositorySource(input)).toEqual({ + kind: "github", + displayUrl: "https://github.com/owner/repository", + cloneUrl: "https://github.com/owner/repository.git" + }); + }); + + it.each([ + "http://github.com/owner/repository", + "git://github.com/owner/repository", + "ssh://git@github.com/owner/repository", + "git@github.com:owner/repository.git", + "file:///tmp/repository", + "https://gitlab.com/owner/repository", + "https://github.com.evil.example/owner/repository", + "https://github.com/owner/repository/tree/main", + "https://github.com/owner/repository?ref=main", + "https://github.com/owner/repository#readme", + "https://user:secret@github.com/owner/repository", + "https://github.com/owner/repository%2Ftree", + "https://github.com/owner/repository%5Ctree", + "https://github.com/owner\\repository", + "https://github.com/owner/repository\n", + "github.com/owner/repository" + ])("rejects unsupported or unsafe repository input: %s", (input) => { + expect(() => parseRepositorySource(input)).toThrow(); + }); + + it("keeps Windows drive paths in the local-path branch", () => { + expect(parseRepositorySource("C:\\work\\repository").kind).toBe("local"); + }); +}); + +describe("Git process isolation", () => { + it("removes inherited credential and askpass helpers without mutating the parent environment", () => { + const inheritedEnvironment: NodeJS.ProcessEnv = { + PATH: "C:\\tools", + HTTPS_PROXY: "https://proxy.example", + GIT_ASKPASS: "C:\\helpers\\git-askpass.exe", + git_config_count: "1", + GitHub_Token: "fake-github-token", + GH_TOKEN: "fake-gh-token", + ssh_askpass: "C:\\helpers\\ssh-askpass.exe", + SSH_ASKPASS_REQUIRE: "force", + SUDO_ASKPASS: "C:\\helpers\\sudo-askpass.exe", + home: "C:\\Users\\someone", + UserProfile: "C:\\Users\\someone" + }; + const originalEnvironment = { ...inheritedEnvironment }; + + const isolatedEnvironment = buildIsolatedGitEnvironment( + inheritedEnvironment, + "C:\\isolated-home", + "C:\\isolated-home\\gitconfig" + ); + + expect(isolatedEnvironment).toMatchObject({ + PATH: "C:\\tools", + HTTPS_PROXY: "https://proxy.example", + GCM_INTERACTIVE: "Never", + GIT_CONFIG_GLOBAL: "C:\\isolated-home\\gitconfig", + GIT_CONFIG_NOSYSTEM: "1", + GIT_LFS_SKIP_SMUDGE: "1", + GIT_TERMINAL_PROMPT: "0", + HOME: "C:\\isolated-home", + USERPROFILE: "C:\\isolated-home", + XDG_CONFIG_HOME: "C:\\isolated-home" + }); + expect( + Object.keys(isolatedEnvironment).filter((name) => + name.toUpperCase().includes("ASKPASS") || + ["GH_TOKEN", "GITHUB_TOKEN"].includes(name.toUpperCase()) + ) + ).toEqual([]); + expect( + Object.keys(isolatedEnvironment).filter((name) => + ["HOME", "USERPROFILE", "XDG_CONFIG_HOME"].includes(name.toUpperCase()) + ) + ).toEqual(["HOME", "USERPROFILE", "XDG_CONFIG_HOME"]); + expect(inheritedEnvironment).toEqual(originalEnvironment); + }); +}); + +describe("repository acquisition", () => { + it("preserves local-directory behavior", async () => { + const root = await mkdtemp(join(tmpdir(), "fixmap-local-source-")); + localFixtures.push(root); + await createFixture(root); + + const report = await buildReportForRepository({ + repo: root, + issueText: "password reset emails fail" + }); + + expect(report.contextFiles[0]?.path).toBe("src/auth/reset-password.ts"); + expect(report.diagnostics.some((diagnostic) => diagnostic.code === "remote-repo-fetched")).toBe(false); + }); + + it("analyzes a temporary GitHub checkout and removes it before returning", async () => { + let checkoutRoot = ""; + + const report = await buildReportForRepository({ + repo: "https://github.com/owner/repository", + issueText: "password reset emails fail" + }, { + clonePublicRepository: async (url, destination) => { + checkoutRoot = destination; + expect(url).toBe("https://github.com/owner/repository.git"); + return fixtureClone(url, destination); + } + }); + + expect(report.contextFiles[0]?.path).toBe("src/auth/reset-password.ts"); + expect(report.diagnostics[0]).toMatchObject({ + code: "remote-repo-fetched", + severity: "info" + }); + expect(report.diagnostics[0]?.message).toContain(`main@${REVISION}`); + await expect(stat(dirname(checkoutRoot))).rejects.toThrow(); + }); + + it("rejects remote diff analysis before cloning", async () => { + const clonePublicRepository = vi.fn(fixtureClone); + + await expect(buildReportForRepository({ + repo: "https://github.com/owner/repository", + issueText: "password reset emails fail", + diffSpec: "main...HEAD" + }, { clonePublicRepository })).rejects.toThrow("Git diff options are not supported"); + + expect(clonePublicRepository).not.toHaveBeenCalled(); + }); + + it("removes the temporary directory after clone failure", async () => { + let checkoutRoot = ""; + + await expect(buildReportForRepository({ + repo: "https://github.com/owner/missing", + issueText: "password reset emails fail" + }, { + clonePublicRepository: async (_url, destination) => { + checkoutRoot = destination; + throw new Error("repository not found"); + } + })).rejects.toThrow("repository was not found or is not publicly accessible"); + + await expect(stat(dirname(checkoutRoot))).rejects.toThrow(); + }); + + it.each([ + { + error: Object.assign(new Error("spawn git ENOENT"), { code: "ENOENT" }), + expected: "Git is not installed" + }, + { + error: Object.assign(new Error("Command timed out"), { killed: true }), + expected: "120-second timeout" + }, + { + error: new Error("fatal: Needed a single revision"), + expected: "no default-branch commit" + } + ])("returns a stable clone error: $expected", async ({ error, expected }) => { + await expect(buildReportForRepository({ + repo: "https://github.com/owner/repository", + issueText: "password reset emails fail" + }, { + clonePublicRepository: async () => { + throw error; + } + })).rejects.toThrow(expected); + }); + + it("removes the temporary directory when analysis fails", async () => { + const source = parseRepositorySource("https://github.com/owner/repository"); + let checkoutRoot = ""; + + await expect(withRepositorySource(source, async () => { + throw new Error("analysis failed"); + }, { + clonePublicRepository: async (url, destination) => { + checkoutRoot = destination; + return fixtureClone(url, destination); + } + })).rejects.toThrow("analysis failed"); + + await expect(stat(dirname(checkoutRoot))).rejects.toThrow(); + }); + + it("turns cleanup failure into a hard error instead of returning a report", async () => { + let temporaryRoot = ""; + const makeTemporaryDirectory = async (prefix: string) => { + temporaryRoot = await mkdtemp(prefix); + return temporaryRoot; + }; + + try { + await expect(buildReportForRepository({ + repo: "https://github.com/owner/repository", + issueText: "password reset emails fail" + }, { + clonePublicRepository: fixtureClone, + makeTemporaryDirectory, + removeTemporaryDirectory: async () => { + throw new Error("directory is locked"); + } + })).rejects.toThrow("Could not remove temporary checkout"); + } finally { + if (temporaryRoot) { + await rm(temporaryRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); + } + } + }); + + it("uses isolated temporary directories for concurrent calls", async () => { + const checkoutRoots: string[] = []; + const clonePublicRepository = async (url: string, destination: string) => { + checkoutRoots.push(destination); + return fixtureClone(url, destination); + }; + + const reports = await Promise.all([ + buildReportForRepository({ + repo: "https://github.com/owner/first", + issueText: "password reset emails fail" + }, { clonePublicRepository }), + buildReportForRepository({ + repo: "https://github.com/owner/second", + issueText: "password reset emails fail" + }, { clonePublicRepository }) + ]); + + expect(reports).toHaveLength(2); + expect(new Set(checkoutRoots).size).toBe(2); + await Promise.all(checkoutRoots.map((path) => expect(stat(dirname(path))).rejects.toThrow())); + }); +}); diff --git a/packages/core/LICENSE b/packages/core/LICENSE new file mode 100644 index 0000000..1db99bf --- /dev/null +++ b/packages/core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Aryam + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/core/package.json b/packages/core/package.json index 32ee8e3..d71be2c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@aryam/fixmap-core", - "version": "0.4.1", + "version": "0.5.0", "description": "Core repository scanner, ranker, and report renderer for FixMap.", "license": "MIT", "repository": { @@ -8,6 +8,22 @@ "url": "git+https://github.com/aryamthecodebreaker/FixMap.git", "directory": "packages/core" }, + "homepage": "https://fixmap-flax.vercel.app", + "bugs": { + "url": "https://github.com/aryamthecodebreaker/FixMap/issues" + }, + "keywords": [ + "ai-agents", + "code-analysis", + "coding-agent", + "developer-tools", + "local-first", + "repo-map", + "repo-intelligence", + "repository-context", + "test-routing", + "typescript" + ], "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -17,7 +33,7 @@ "import": "./dist/index.js" } }, - "files": ["dist"], + "files": ["dist", "LICENSE"], "publishConfig": { "access": "public" }, "scripts": { "build": "tsc -p tsconfig.json", diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 97d2c51..c1fcd71 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -24,9 +24,15 @@ export type PackageScript = { }; export type ScanDiagnostic = { - code: "diff-unavailable" | "package-json-invalid" | "scan-limit-reached" | "repo-root-missing" | "gated-test-skipped"; + code: + | "diff-unavailable" + | "package-json-invalid" + | "scan-limit-reached" + | "repo-root-missing" + | "gated-test-skipped" + | "remote-repo-fetched"; message: string; - severity: "warning" | "error"; + severity: "info" | "warning" | "error"; }; export type RepoMap = { diff --git a/packages/core/test/report.test.ts b/packages/core/test/report.test.ts index b7f524e..e6db8d4 100644 --- a/packages/core/test/report.test.ts +++ b/packages/core/test/report.test.ts @@ -29,6 +29,25 @@ describe("report rendering", () => { expect(JSON.parse(json)).toEqual(report); }); + it("renders informational diagnostics", () => { + const report: FixMapReport = { + summary: "FixMap found 0 context files and generated 0 test routes.", + changedFiles: [], + contextFiles: [], + testRoutes: [], + risks: [], + diagnostics: [{ + code: "remote-repo-fetched", + severity: "info", + message: "Fetched a public repository into an isolated temporary checkout." + }] + }; + + expect(renderMarkdownReport(report)).toContain( + "**info** Fetched a public repository into an isolated temporary checkout." + ); + }); + it("routes nearby tests by path overlap and adds risk notes", () => { const repo: RepoMap = { root: "/repo", diff --git a/server.json b/server.json index 3ee6ad6..a57ac45 100644 --- a/server.json +++ b/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/aryamthecodebreaker/FixMap", "source": "github" }, - "version": "0.4.1", + "version": "0.5.0", "packages": [ { "registryType": "npm", "identifier": "@aryam/fixmap", - "version": "0.4.1", + "version": "0.5.0", "transport": { "type": "stdio" },