feat(cli): add ci-setup wizard for shared team MCP server onboarding

Adds `gitnexus ci-setup` — a new command that detects the current repo
environment and generates all artifacts needed to run GitNexus as a shared,
CI/CD-maintained MCP server:

- GitHub Actions workflow (push + PR triggers, node 22, artifact upload)
- Azure DevOps pipeline YAML (with commercial-use notice)
- Docker Compose service using the published ghcr.io image (port 4747)
  - `--auth token`: Caddy reverse-proxy sidecar enforces GITNEXUS_TOKEN
    (gitnexus is internal-only; the proxy publishes port 4748)
  - `--auth none`: direct port 4747 with a no-auth warning banner
- Azure Container App deploy script (az CLI)
- Claude Code HTTP MCP snippet (.claude/gitnexus-mcp-snippet.json)
- GITNEXUS.md onboarding doc with decision log and next steps

Key design decisions:
- Corrects three errors in the original spec: serve port is 4747 (not 4848),
  health endpoint is /api/health (not /health), and GITNEXUS_TOKEN provides
  no auth in the server itself — real auth requires the Caddy proxy layer.
- Adds @inquirer/prompts (v8, ESM-native) for TTY arrow-key prompts; all
  flags work non-interactively for CI/scripted use.
- Default mode is --dry-run (preview only); --apply writes with per-file
  confirmation; --yes skips confirmations for scripted runs.
- Idempotent: second --apply run skips files that are byte-identical.
- 36 passing unit tests covering templates (pure functions, js-yaml parse
  validation) and command behavior (real-fs-in-tempdir, vitest pattern).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MCKRUZ 2026-06-08 21:49:46 -04:00
parent f2c9e69792
commit c3675d3d12
10 changed files with 1804 additions and 1 deletions

View file

@ -11,6 +11,7 @@
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@huggingface/transformers": "^4.1.0",
"@inquirer/prompts": "^8.0.0",
"@ladybugdb/core": "^0.17.0",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
@ -1118,6 +1119,334 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@inquirer/ansi": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz",
"integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==",
"license": "MIT",
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
}
},
"node_modules/@inquirer/checkbox": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.1.tgz",
"integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==",
"license": "MIT",
"dependencies": {
"@inquirer/ansi": "^2.0.7",
"@inquirer/core": "^11.2.1",
"@inquirer/figures": "^2.0.7",
"@inquirer/type": "^4.0.7"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/confirm": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz",
"integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==",
"license": "MIT",
"dependencies": {
"@inquirer/core": "^11.2.1",
"@inquirer/type": "^4.0.7"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/core": {
"version": "11.2.1",
"resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz",
"integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==",
"license": "MIT",
"dependencies": {
"@inquirer/ansi": "^2.0.7",
"@inquirer/figures": "^2.0.7",
"@inquirer/type": "^4.0.7",
"cli-width": "^4.1.0",
"fast-wrap-ansi": "^0.2.0",
"mute-stream": "^3.0.0",
"signal-exit": "^4.1.0"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/editor": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.2.2.tgz",
"integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==",
"license": "MIT",
"dependencies": {
"@inquirer/core": "^11.2.1",
"@inquirer/external-editor": "^3.0.3",
"@inquirer/type": "^4.0.7"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/expand": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.1.tgz",
"integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==",
"license": "MIT",
"dependencies": {
"@inquirer/core": "^11.2.1",
"@inquirer/type": "^4.0.7"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/external-editor": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.3.tgz",
"integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==",
"license": "MIT",
"dependencies": {
"chardet": "^2.1.1",
"iconv-lite": "^0.7.2"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/figures": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz",
"integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==",
"license": "MIT",
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
}
},
"node_modules/@inquirer/input": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz",
"integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==",
"license": "MIT",
"dependencies": {
"@inquirer/core": "^11.2.1",
"@inquirer/type": "^4.0.7"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/number": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.1.1.tgz",
"integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==",
"license": "MIT",
"dependencies": {
"@inquirer/core": "^11.2.1",
"@inquirer/type": "^4.0.7"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/password": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.1.1.tgz",
"integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==",
"license": "MIT",
"dependencies": {
"@inquirer/ansi": "^2.0.7",
"@inquirer/core": "^11.2.1",
"@inquirer/type": "^4.0.7"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/prompts": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.5.2.tgz",
"integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==",
"license": "MIT",
"dependencies": {
"@inquirer/checkbox": "^5.2.1",
"@inquirer/confirm": "^6.1.1",
"@inquirer/editor": "^5.2.2",
"@inquirer/expand": "^5.1.1",
"@inquirer/input": "^5.1.2",
"@inquirer/number": "^4.1.1",
"@inquirer/password": "^5.1.1",
"@inquirer/rawlist": "^5.3.1",
"@inquirer/search": "^4.2.1",
"@inquirer/select": "^5.2.1"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/rawlist": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.1.tgz",
"integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==",
"license": "MIT",
"dependencies": {
"@inquirer/core": "^11.2.1",
"@inquirer/type": "^4.0.7"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/search": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.2.1.tgz",
"integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==",
"license": "MIT",
"dependencies": {
"@inquirer/core": "^11.2.1",
"@inquirer/figures": "^2.0.7",
"@inquirer/type": "^4.0.7"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/select": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.1.tgz",
"integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==",
"license": "MIT",
"dependencies": {
"@inquirer/ansi": "^2.0.7",
"@inquirer/core": "^11.2.1",
"@inquirer/figures": "^2.0.7",
"@inquirer/type": "^4.0.7"
},
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/type": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz",
"integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==",
"license": "MIT",
"engines": {
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@ -2296,6 +2625,12 @@
"url": "https://github.com/chalk/chalk-template?sponsor=1"
}
},
"node_modules/chardet": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz",
"integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==",
"license": "MIT"
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@ -2317,6 +2652,15 @@
"node": ">=4"
}
},
"node_modules/cli-width": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
"integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
"license": "ISC",
"engines": {
"node": ">= 12"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@ -2899,6 +3243,21 @@
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"license": "MIT"
},
"node_modules/fast-string-truncated-width": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz",
"integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==",
"license": "MIT"
},
"node_modules/fast-string-width": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz",
"integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==",
"license": "MIT",
"dependencies": {
"fast-string-truncated-width": "^3.0.2"
}
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
@ -2915,6 +3274,15 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fast-wrap-ansi": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz",
"integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==",
"license": "MIT",
"dependencies": {
"fast-string-width": "^3.0.2"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -3935,6 +4303,15 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/mute-stream": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz",
"integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==",
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
@ -4768,6 +5145,18 @@
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",

View file

@ -88,7 +88,8 @@
"tree-sitter-ruby": "^0.23.1",
"tree-sitter-rust": "0.23.1",
"tree-sitter-typescript": "^0.23.2",
"uuid": "^14.0.0"
"uuid": "^14.0.0",
"@inquirer/prompts": "^8.0.0"
},
"optionalDependencies": {
"node-addon-api": "^8.0.0",

View file

@ -0,0 +1,186 @@
/**
* ci-setup Command
*
* Detects the current environment and generates all artifacts needed to run
* GitNexus as a shared, CI/CD-maintained MCP server for a team:
* - GitHub Actions / Azure DevOps workflow
* - Docker Compose service (with optional Caddy auth proxy)
* - Azure Container App deploy script
* - MCP config snippet for the shared server
* - GITNEXUS.md onboarding doc
*/
import fs from 'fs/promises';
import path from 'path';
import { detectEnvironment } from './ci-setup/detect.js';
import { resolveOptions } from './ci-setup/prompts.js';
import { generateFiles } from './ci-setup/templates.js';
import type { CiSetupOptions, CiSetupResult, GeneratedFile } from './ci-setup/types.js';
import type { CiSystem, DeployTarget, AuthMode, BranchStrategy } from './ci-setup/types.js';
export const ciSetupCommand = async (options?: {
ci?: string;
deploy?: string;
port?: string;
auth?: string;
branchStrategy?: string;
dryRun?: boolean;
apply?: boolean;
yes?: boolean;
outputDir?: string;
}) => {
const cwd = process.cwd();
console.log('\n🔍 Detecting environment...');
const detect = await detectEnvironment(cwd);
if (!detect.gitRoot) {
console.error(
'✗ Not a git repository. Run `gitnexus ci-setup` from inside a git repo root.',
);
process.exit(1);
}
console.log(` ✓ Git repo: ${path.basename(detect.gitRoot)}`);
console.log(` ✓ Primary language: ${detect.primaryLanguage}`);
if (detect.detectedCi) {
console.log(` ✓ CI/CD detected: ${detect.detectedCi}`);
} else {
console.log(' - CI/CD: none detected');
}
if (detect.hasDocker) {
console.log(' ✓ Docker: docker-compose or Dockerfile found');
}
console.log(` ${detect.portAvailable ? '✓' : '⚠'} Port 4747: ${detect.portAvailable ? 'available' : 'in use (serve may already be running)'}`);
console.log(' ⚠ License: PolyForm-Noncommercial — confirm non-commercial use\n');
// Parse and validate options from commander flags
const partial: Partial<CiSetupOptions> = {};
if (options?.ci) partial.ci = options.ci as CiSystem;
if (options?.deploy) partial.deploy = options.deploy as DeployTarget;
if (options?.port) partial.port = parseInt(options.port, 10);
if (options?.auth) partial.auth = options.auth as AuthMode;
if (options?.branchStrategy) partial.branchStrategy = options.branchStrategy as BranchStrategy;
if (options?.dryRun !== undefined) partial.dryRun = options.dryRun;
if (options?.apply !== undefined) partial.apply = options.apply;
if (options?.yes !== undefined) partial.yes = options.yes;
if (options?.outputDir) partial.outputDir = options.outputDir;
// Default: dry-run when neither --dry-run nor --apply is given
if (!partial.dryRun && !partial.apply) {
partial.dryRun = true;
}
const resolved = await resolveOptions(detect, partial);
const files = generateFiles(resolved, detect);
const result: CiSetupResult = { generated: [], skipped: [], errors: [] };
if (resolved.dryRun) {
await previewFiles(files, resolved.outputDir);
console.log('\n──────────────────────────────────────────────');
console.log(`${files.length} file(s) would be written. Run with --apply to write them.`);
return;
}
await applyFiles(files, resolved, result);
printResult(result, resolved);
};
async function previewFiles(files: GeneratedFile[], outputDir: string): Promise<void> {
for (const file of files) {
const fullPath = path.join(outputDir, file.relativePath);
console.log(`\n${'─'.repeat(60)}`);
console.log(`→ ${fullPath}`);
console.log('─'.repeat(60));
console.log(file.content);
}
}
async function applyFiles(
files: GeneratedFile[],
opts: CiSetupOptions,
result: CiSetupResult,
): Promise<void> {
for (const file of files) {
const fullPath = path.join(opts.outputDir, file.relativePath);
try {
let existingContent: string | null = null;
try {
existingContent = await fs.readFile(fullPath, 'utf-8');
} catch {
// file does not exist — will be created
}
if (existingContent !== null && existingContent === file.content) {
result.skipped.push(`${file.relativePath} (exists, identical)`);
continue;
}
if (existingContent !== null && !opts.yes) {
console.log(`\n⚠ ${file.relativePath} already exists and differs.`);
if (!opts.yes) {
const { confirm } = await import('@inquirer/prompts');
const ok = await confirm({ message: `Overwrite ${file.relativePath}?`, default: false });
if (!ok) {
result.skipped.push(`${file.relativePath} (skipped by user)`);
continue;
}
}
}
await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, file.content, 'utf-8');
result.generated.push(file.relativePath);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
result.errors.push(`${file.relativePath}: ${message}`);
}
}
}
function printResult(result: CiSetupResult, opts: CiSetupOptions): void {
console.log('\n══════════════════════════════════════');
console.log(' GitNexus CI Setup');
console.log('══════════════════════════════════════\n');
for (const f of result.generated) {
console.log(` + ${f}`);
}
for (const f of result.skipped) {
console.log(` - ${f}`);
}
for (const f of result.errors) {
console.log(` ! ${f}`);
}
if (result.errors.length > 0) {
console.log('\nSome files could not be written. See errors above.');
return;
}
console.log('\nNext steps:');
let step = 1;
if (opts.auth === 'token' && (opts.deploy === 'docker' || opts.deploy === 'both')) {
console.log(` ${step++}. Set GITNEXUS_TOKEN in your shell or .env file.`);
console.log(` ${step++}. docker compose -f docker-compose.gitnexus.yml up -d`);
} else if (opts.deploy === 'docker' || opts.deploy === 'both') {
console.log(` ${step++}. docker compose -f docker-compose.gitnexus.yml up -d`);
}
if (opts.deploy === 'azure-container-app' || opts.deploy === 'both') {
console.log(` ${step++}. Review and run: bash gitnexus-aca-deploy.sh`);
}
if (opts.ci === 'github-actions' || opts.ci === 'both') {
console.log(` ${step++}. Commit .github/workflows/gitnexus-ci.yml and push to trigger the first index.`);
}
if (opts.ci === 'azure-devops' || opts.ci === 'both') {
console.log(` ${step++}. Import azure-pipelines-gitnexus.yml into Azure DevOps and run it.`);
}
console.log(` ${step++}. Follow GITNEXUS.md to connect Claude Code / Cursor to the shared server.`);
console.log('');
}

View file

@ -0,0 +1,140 @@
import fs from 'fs/promises';
import net from 'net';
import path from 'path';
import { getGitRoot } from '../../storage/git.js';
import type { CiSystem, DetectResult } from './types.js';
const LANGUAGE_MAP: Record<string, string> = {
'.ts': 'TypeScript',
'.tsx': 'TypeScript',
'.js': 'JavaScript',
'.mjs': 'JavaScript',
'.py': 'Python',
'.cs': 'C#',
'.java': 'Java',
'.go': 'Go',
'.rs': 'Rust',
'.rb': 'Ruby',
'.php': 'PHP',
'.cpp': 'C++',
'.cc': 'C++',
'.c': 'C',
'.kt': 'Kotlin',
'.swift': 'Swift',
};
async function detectCiSystem(repoRoot: string): Promise<CiSystem | null> {
const [hasGha, hasAdo] = await Promise.all([
fs
.access(path.join(repoRoot, '.github', 'workflows'))
.then(() => true)
.catch(() => false),
fs
.readdir(repoRoot)
.then((entries) => entries.some((e) => e.startsWith('azure-pipelines') && e.endsWith('.yml')))
.catch(() => false),
]);
if (hasGha && hasAdo) return 'both';
if (hasGha) return 'github-actions';
if (hasAdo) return 'azure-devops';
return null;
}
async function detectDocker(repoRoot: string): Promise<boolean> {
const entries = await fs.readdir(repoRoot).catch(() => [] as string[]);
return entries.some(
(e) =>
e === 'Dockerfile' ||
e.startsWith('Dockerfile.') ||
e === 'docker-compose.yml' ||
e === 'docker-compose.yaml' ||
e.startsWith('docker-compose.'),
);
}
async function detectPrimaryLanguage(repoRoot: string): Promise<string> {
const counts: Record<string, number> = {};
let scanned = 0;
async function scan(dir: string, depth: number): Promise<void> {
if (depth > 3 || scanned > 2000) return;
let entries: string[];
try {
entries = await fs.readdir(dir);
} catch {
return;
}
for (const entry of entries) {
if (
entry.startsWith('.') ||
entry === 'node_modules' ||
entry === 'dist' ||
entry === 'build' ||
entry === '__pycache__'
) {
continue;
}
const full = path.join(dir, entry);
let stat;
try {
stat = await fs.stat(full);
} catch {
continue;
}
if (stat.isDirectory()) {
await scan(full, depth + 1);
} else {
scanned++;
const ext = path.extname(entry).toLowerCase();
const lang = LANGUAGE_MAP[ext];
if (lang) counts[lang] = (counts[lang] ?? 0) + 1;
}
}
}
await scan(repoRoot, 0);
let top = 'Unknown';
let max = 0;
for (const [lang, count] of Object.entries(counts)) {
if (count > max) {
max = count;
top = lang;
}
}
return top;
}
function checkPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => resolve(false));
server.once('listening', () => {
server.close(() => resolve(true));
});
server.listen(port, '127.0.0.1');
});
}
export async function detectEnvironment(cwd: string): Promise<DetectResult> {
const gitRoot = getGitRoot(cwd);
if (!gitRoot) {
return {
gitRoot: null,
detectedCi: null,
hasDocker: false,
portAvailable: false,
primaryLanguage: 'Unknown',
};
}
const [detectedCi, hasDocker, portAvailable, primaryLanguage] = await Promise.all([
detectCiSystem(gitRoot),
detectDocker(gitRoot),
checkPortAvailable(4747),
detectPrimaryLanguage(gitRoot),
]);
return { gitRoot, detectedCi, hasDocker, portAvailable, primaryLanguage };
}

View file

@ -0,0 +1,88 @@
import { select } from '@inquirer/prompts';
import type { CiSetupOptions, CiSystem, DeployTarget, AuthMode, BranchStrategy, DetectResult } from './types.js';
export async function resolveOptions(
detect: DetectResult,
partial: Partial<CiSetupOptions>,
): Promise<CiSetupOptions> {
// In non-interactive (CI) environments, fill in defaults for unset options
// rather than hanging on stdin. The user can override with explicit flags.
const isTTY = process.stdin.isTTY === true;
const ci = partial.ci ?? (isTTY ? await promptCi(detect) : 'github-actions');
const deploy = partial.deploy ?? (isTTY ? await promptDeploy() : 'docker');
const auth = partial.auth ?? (isTTY ? await promptAuth() : 'token');
const branchStrategy =
partial.branchStrategy ?? (isTTY ? await promptBranchStrategy() : 'pr-scoped');
return {
ci,
deploy,
auth,
branchStrategy,
port: partial.port ?? 4747,
dryRun: partial.dryRun ?? false,
apply: partial.apply ?? false,
yes: partial.yes ?? false,
outputDir: partial.outputDir ?? (detect.gitRoot ?? process.cwd()),
};
}
async function promptCi(detect: DetectResult): Promise<CiSystem> {
const detected = detect.detectedCi;
return select<CiSystem>({
message: 'CI/CD system',
choices: [
{
name: `GitHub Actions${detected === 'github-actions' ? ' (detected)' : ''}`,
value: 'github-actions',
},
{
name: `Azure DevOps${detected === 'azure-devops' ? ' (detected)' : ''}`,
value: 'azure-devops',
},
{ name: 'Both', value: 'both' },
],
default: detected ?? 'github-actions',
});
}
async function promptDeploy(): Promise<DeployTarget> {
return select<DeployTarget>({
message: 'Deployment target for gitnexus serve',
choices: [
{ name: 'Docker (local / self-hosted)', value: 'docker' },
{ name: 'Azure Container App', value: 'azure-container-app' },
{ name: 'Both', value: 'both' },
],
default: 'docker',
});
}
async function promptAuth(): Promise<AuthMode> {
return select<AuthMode>({
message: 'Auth for the shared MCP server',
choices: [
{
name: 'Shared token (Caddy reverse proxy enforces GITNEXUS_TOKEN)',
value: 'token',
},
{
name: 'No auth (trusted internal network only)',
value: 'none',
},
],
default: 'token',
});
}
async function promptBranchStrategy(): Promise<BranchStrategy> {
return select<BranchStrategy>({
message: 'Branch index strategy',
choices: [
{ name: 'Main + PR-scoped (recommended)', value: 'pr-scoped' },
{ name: 'Main branch only', value: 'main-only' },
],
default: 'pr-scoped',
});
}

View file

@ -0,0 +1,524 @@
import type { CiSetupOptions, DetectResult, GeneratedFile } from './types.js';
const LICENSE_NOTICE = '# License: PolyForm-Noncommercial-1.0.0 — confirm non-commercial use';
const COMMERCIAL_NOTICE =
'# ⚠ COMMERCIAL USE: verify PolyForm-Noncommercial-1.0.0 license before deploying to client environments.';
const GENERATED_NOTICE = '# Generated by: gitnexus ci-setup';
function buildGitHubActionsWorkflow(opts: CiSetupOptions): string {
const onBlock =
opts.branchStrategy === 'pr-scoped'
? `on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]`
: `on:
push:
branches: [main]`;
return `${LICENSE_NOTICE}
${GENERATED_NOTICE}
name: GitNexus Index
${onBlock}
jobs:
gitnexus-index:
name: Index repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Analyze repository
run: npx gitnexus@latest analyze --skills
- name: Upload index artifact
uses: actions/upload-artifact@v4
with:
name: gitnexus-index-\${{ github.sha }}
path: .gitnexus/
retention-days: 30
- name: Check index staleness
run: |
if [ -f .gitnexus/meta.json ]; then
echo "✓ GitNexus index updated at $(date -u +%Y-%m-%dT%H:%M:%SZ)"
else
echo "✗ GitNexus index not found" && exit 1
fi
`;
}
function buildAzurePipelineYaml(opts: CiSetupOptions): string {
const trigger =
opts.branchStrategy === 'pr-scoped'
? `trigger:
branches:
include:
- main
pr:
branches:
include:
- '*'`
: `trigger:
branches:
include:
- main
pr: none`;
return `${COMMERCIAL_NOTICE}
${GENERATED_NOTICE}
${trigger}
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
fetchDepth: 0
- task: NodeTool@0
inputs:
versionSpec: '22.x'
displayName: 'Set up Node.js 22'
- script: npx gitnexus@latest analyze --skills
displayName: 'Analyze repository'
- task: PublishPipelineArtifact@1
inputs:
targetPath: '.gitnexus'
artifact: 'gitnexus-index'
publishLocation: 'pipeline'
displayName: 'Upload index artifact'
- script: |
if [ -f .gitnexus/meta.json ]; then
echo "✓ GitNexus index updated"
else
echo "✗ GitNexus index not found" && exit 1
fi
displayName: 'Check index staleness'
`;
}
function buildDockerComposeTokenAuth(opts: CiSetupOptions): string {
return `${LICENSE_NOTICE}
${GENERATED_NOTICE}
#
# Auth model: Caddy reverse proxy enforces GITNEXUS_TOKEN.
# The gitnexus service is NOT published — only the proxy port is reachable.
#
# Required environment variables (create a .env file or set in your shell):
# GITNEXUS_TOKEN=<your-secret-token>
# WORKSPACE_DIR=<path-to-repos> (default: ./workspace)
services:
gitnexus:
image: ghcr.io/abhigyanpatwari/gitnexus:latest
# No ports: — gitnexus is accessible only to the proxy on the internal network.
volumes:
- gitnexus-data:/data/gitnexus
- \${WORKSPACE_DIR:-./workspace}:/workspace:ro
environment:
GITNEXUS_HOME: /data/gitnexus
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${opts.port}/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
gitnexus-proxy:
image: caddy:alpine
ports:
- "\${GITNEXUS_PROXY_PORT:-${opts.port + 1}}:${opts.port + 1}"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
environment:
GITNEXUS_TOKEN: \${GITNEXUS_TOKEN:?GITNEXUS_TOKEN is required}
depends_on:
gitnexus:
condition: service_healthy
restart: unless-stopped
volumes:
gitnexus-data:
`;
}
function buildDockerComposeNoAuth(opts: CiSetupOptions): string {
return `${LICENSE_NOTICE}
${GENERATED_NOTICE}
#
# ⚠ NO AUTH — trusted internal network only.
# Anyone who can reach port ${opts.port} has full access to all indexed repositories.
# Do not expose this port to the public internet.
services:
gitnexus:
image: ghcr.io/abhigyanpatwari/gitnexus:latest
ports:
- "\${GITNEXUS_PORT:-${opts.port}}:${opts.port}"
volumes:
- gitnexus-data:/data/gitnexus
- \${WORKSPACE_DIR:-./workspace}:/workspace:ro
environment:
GITNEXUS_HOME: /data/gitnexus
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${opts.port}/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
volumes:
gitnexus-data:
`;
}
function buildDockerCompose(opts: CiSetupOptions): string {
return opts.auth === 'token' ? buildDockerComposeTokenAuth(opts) : buildDockerComposeNoAuth(opts);
}
function buildCaddyfile(opts: CiSetupOptions): string {
const proxyPort = opts.port + 1;
return `:${proxyPort} {
@authorized header Authorization "Bearer {env.GITNEXUS_TOKEN}"
handle @authorized {
reverse_proxy gitnexus:${opts.port}
}
respond "Unauthorized" 401
}
`;
}
function buildAcaDeployScript(opts: CiSetupOptions): string {
const authComment =
opts.auth === 'token'
? `# Auth: enable Azure Container Apps built-in authentication (Easy Auth) for token enforcement.
# In the Azure portal, go to your Container App → Settings → Authentication → Add Identity Provider.`
: `# ⚠ NO AUTH configured. Restrict access via Azure Container Apps ingress settings
# or use Easy Auth (Azure portal → Authentication) before exposing to users.`;
return `#!/usr/bin/env bash
# ${COMMERCIAL_NOTICE.slice(2)}
# ${GENERATED_NOTICE.slice(2)}
#
# Deploy GitNexus as an Azure Container App.
# Requires: az CLI, logged in with Contributor on the target resource group.
#
# ${authComment}
set -euo pipefail
RESOURCE_GROUP="\${RESOURCE_GROUP:-gitnexus-rg}"
LOCATION="\${LOCATION:-eastus}"
ENVIRONMENT="\${CONTAINER_APP_ENV:-gitnexus-env}"
APP_NAME="\${APP_NAME:-gitnexus}"
STORAGE_ACCOUNT="\${STORAGE_ACCOUNT:-gitnexusstorage}"
FILE_SHARE="\${FILE_SHARE:-gitnexus-data}"
echo "Creating resource group..."
az group create --name "\$RESOURCE_GROUP" --location "\$LOCATION"
echo "Creating storage account for persistent index..."
az storage account create \\
--name "\$STORAGE_ACCOUNT" \\
--resource-group "\$RESOURCE_GROUP" \\
--location "\$LOCATION" \\
--sku Standard_LRS
az storage share-rm create \\
--name "\$FILE_SHARE" \\
--storage-account "\$STORAGE_ACCOUNT" \\
--resource-group "\$RESOURCE_GROUP"
echo "Creating Container Apps environment..."
az containerapp env create \\
--name "\$ENVIRONMENT" \\
--resource-group "\$RESOURCE_GROUP" \\
--location "\$LOCATION"
echo "Mounting Azure Files into the environment..."
STORAGE_KEY=\$(az storage account keys list \\
--account-name "\$STORAGE_ACCOUNT" \\
--resource-group "\$RESOURCE_GROUP" \\
--query "[0].value" -o tsv)
az containerapp env storage set \\
--name "\$ENVIRONMENT" \\
--resource-group "\$RESOURCE_GROUP" \\
--storage-name gitnexus-data \\
--azure-file-account-name "\$STORAGE_ACCOUNT" \\
--azure-file-account-key "\$STORAGE_KEY" \\
--azure-file-share-name "\$FILE_SHARE" \\
--access-mode ReadWrite
echo "Deploying GitNexus container app..."
az containerapp create \\
--name "\$APP_NAME" \\
--resource-group "\$RESOURCE_GROUP" \\
--environment "\$ENVIRONMENT" \\
--image ghcr.io/abhigyanpatwari/gitnexus:latest \\
--target-port ${opts.port} \\
--ingress internal \\
--env-vars "GITNEXUS_HOME=/data/gitnexus" \\
--volume-name gitnexus-data \\
--volume-storage-type AzureFile \\
--volume-storage-name gitnexus-data \\
--mount-path /data/gitnexus
echo "Done. Container App URL:"
az containerapp show \\
--name "\$APP_NAME" \\
--resource-group "\$RESOURCE_GROUP" \\
--query "properties.configuration.ingress.fqdn" -o tsv
`;
}
function buildMcpSnippet(opts: CiSetupOptions): string {
const proxyPort = opts.auth === 'token' ? opts.port + 1 : opts.port;
const urlComment =
opts.auth === 'token'
? ` // Proxy port (Caddy enforces GITNEXUS_TOKEN). Change <GITNEXUS_HOST> to the server's IP or hostname.`
: ` // Direct gitnexus port (no auth). Change <GITNEXUS_HOST> to the server's IP or hostname.`;
const headersBlock =
opts.auth === 'token'
? `,
"headers": {
"Authorization": "Bearer YOUR_GITNEXUS_TOKEN"
}`
: '';
return `{
// GitNexus shared MCP server snippet — merge into ~/.claude/settings.json mcpServers block.
// Do NOT auto-apply: use 'gitnexus setup' for personal stdio MCP; this snippet is for the
// shared HTTP server variant.
//
// NOTE: Verify the exact Claude Code HTTP MCP entry format against current docs
// (https://github.com/anthropics/claude-code) before applying — field names may evolve.
//
${urlComment}
"mcpServers": {
"gitnexus": {
"type": "http",
"url": "http://<GITNEXUS_HOST>:${proxyPort}/api/mcp"${headersBlock}
}
}
}
`;
}
function buildGitnexusMd(opts: CiSetupOptions, detect: DetectResult): string {
const ciLabel = {
'github-actions': 'GitHub Actions',
'azure-devops': 'Azure DevOps',
both: 'GitHub Actions + Azure DevOps',
}[opts.ci];
const deployLabel = {
docker: 'Docker Compose',
'azure-container-app': 'Azure Container App',
both: 'Docker Compose + Azure Container App',
}[opts.deploy];
const authLabel =
opts.auth === 'token'
? 'Caddy reverse proxy enforces `GITNEXUS_TOKEN` bearer token'
: '⚠ No auth — trusted internal network only';
const branchLabel =
opts.branchStrategy === 'pr-scoped' ? 'Main branch + all open PRs' : 'Main branch only';
const connectSection =
opts.auth === 'token'
? `### Claude Code
Copy the MCP entry from \`.claude/gitnexus-mcp-snippet.json\` into \`~/.claude/settings.json\`:
\`\`\`json
"mcpServers": {
"gitnexus": {
"type": "http",
"url": "http://<GITNEXUS_HOST>:${opts.port + 1}/api/mcp",
"headers": {
"Authorization": "Bearer YOUR_GITNEXUS_TOKEN"
}
}
}
\`\`\`
Replace \`<GITNEXUS_HOST>\` with the server's IP or hostname. Set \`GITNEXUS_TOKEN\` to the same value used when starting the server.
### Cursor
Add to \`~/.cursor/mcp.json\` under \`mcpServers\`:
\`\`\`json
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@latest", "mcp"],
"env": {}
}
\`\`\`
*Or* point Cursor at the shared HTTP server using the same \`type: http\` entry as Claude Code.`
: `### Claude Code
Copy the MCP entry from \`.claude/gitnexus-mcp-snippet.json\` into \`~/.claude/settings.json\`:
\`\`\`json
"mcpServers": {
"gitnexus": {
"type": "http",
"url": "http://<GITNEXUS_HOST>:${opts.port}/api/mcp"
}
}
\`\`\`
Replace \`<GITNEXUS_HOST>\` with the server's IP or hostname.`;
return `# GitNexus — Shared Code Intelligence Server
GitNexus builds a knowledge graph of this repository and serves it as an MCP (Model Context Protocol)
server, giving AI coding assistants (Claude Code, Cursor, etc.) deep understanding of call graphs,
execution flows, and blast-radius impact — without reading every file on every request.
> **License**: PolyForm-Noncommercial-1.0.0 — commercial use requires a separate agreement.
> Confirm this deployment is non-commercial, or contact the maintainer before use in client environments.
> Copyright: Abhigyan Patwari. [PolyForm](https://polyformproject.org/licenses/noncommercial/1.0.0/)
---
## Setup decisions
| Decision | Choice | Rationale |
|---|---|---|
| CI/CD system | ${ciLabel} | ${detect.detectedCi ? 'Auto-detected from existing workflow files' : 'Selected during ci-setup'} |
| Deployment target | ${deployLabel} | Selected during ci-setup |
| Auth model | ${authLabel} | ${opts.auth === 'token' ? 'Prevents unauthorized access to indexed source code' : 'Deployment behind trusted network perimeter'} |
| Branch index strategy | ${branchLabel} | ${opts.branchStrategy === 'pr-scoped' ? 'Keeps AI clients aware of in-progress work on open PRs' : 'Simpler; indexes only merged code'} |
| Server port | ${opts.port} | Default \`gitnexus serve\` port |
${opts.auth === 'token' ? `| Proxy port | ${opts.port + 1} | Caddy listens here; gitnexus is internal-only |\n` : ''}
---
## Connecting your AI client
${connectSection}
---
## Triggering a manual re-index
From inside the repository root:
\`\`\`sh
npx gitnexus@latest analyze --skills
\`\`\`
To force a full re-index (ignores cached state):
\`\`\`sh
npx gitnexus@latest analyze --skills --force
\`\`\`
---
## Skill files
When the CI workflow runs \`analyze --skills\`, GitNexus generates repo-specific skill files in:
- \`.claude/skills/gitnexus/\` — standard GitNexus MCP skills (query, impact, context, detect-changes)
- \`.claude/skills/generated/\` — community-detected area skills (one file per functional cluster)
These are committed to the repository so every developer's Claude Code session has them available automatically.
---
## Index staleness policy
The CI workflow uploads the \`.gitnexus/\` directory as a workflow artifact (30-day retention) on every run.
If the index is stale (CI hasn't run since the last push), Claude Code will still function but may operate
on a slightly outdated graph. The staleness check step in the workflow fails the run if indexing produced
no output, so stale-index PRs are blocked at CI.
For the shared server: the container reads the index from the persistent volume, which is updated
when the volume-sharing mechanism (rsync, artifact download, or live-mount) delivers a fresh index.
---
## Generated files
| File | Purpose |
|---|---|
${opts.ci === 'github-actions' || opts.ci === 'both' ? '| `.github/workflows/gitnexus-ci.yml` | Keeps the index fresh on every push and PR |\n' : ''}${opts.ci === 'azure-devops' || opts.ci === 'both' ? '| `azure-pipelines-gitnexus.yml` | Azure DevOps equivalent |\n' : ''}${opts.deploy === 'docker' || opts.deploy === 'both' ? '| `docker-compose.gitnexus.yml` | Runs the shared gitnexus serve container |\n' : ''}${opts.auth === 'token' && (opts.deploy === 'docker' || opts.deploy === 'both') ? '| `Caddyfile` | Caddy reverse proxy config (token enforcement) |\n' : ''}${opts.deploy === 'azure-container-app' || opts.deploy === 'both' ? '| `gitnexus-aca-deploy.sh` | Azure Container App deployment script |\n' : ''}| \`.claude/gitnexus-mcp-snippet.json\` | HTTP MCP entry for the shared server |
`;
}
export function generateFiles(opts: CiSetupOptions, detect: DetectResult): GeneratedFile[] {
const files: GeneratedFile[] = [];
if (opts.ci === 'github-actions' || opts.ci === 'both') {
files.push({
relativePath: '.github/workflows/gitnexus-ci.yml',
content: buildGitHubActionsWorkflow(opts),
});
}
if (opts.ci === 'azure-devops' || opts.ci === 'both') {
files.push({
relativePath: 'azure-pipelines-gitnexus.yml',
content: buildAzurePipelineYaml(opts),
});
}
if (opts.deploy === 'docker' || opts.deploy === 'both') {
files.push({
relativePath: 'docker-compose.gitnexus.yml',
content: buildDockerCompose(opts),
});
if (opts.auth === 'token') {
files.push({
relativePath: 'Caddyfile',
content: buildCaddyfile(opts),
});
}
}
if (opts.deploy === 'azure-container-app' || opts.deploy === 'both') {
files.push({
relativePath: 'gitnexus-aca-deploy.sh',
content: buildAcaDeployScript(opts),
});
}
files.push({
relativePath: '.claude/gitnexus-mcp-snippet.json',
content: buildMcpSnippet(opts),
});
files.push({
relativePath: 'GITNEXUS.md',
content: buildGitnexusMd(opts, detect),
});
return files;
}

View file

@ -0,0 +1,35 @@
export type CiSystem = 'github-actions' | 'azure-devops' | 'both';
export type DeployTarget = 'docker' | 'azure-container-app' | 'both';
export type AuthMode = 'token' | 'none';
export type BranchStrategy = 'pr-scoped' | 'main-only';
export interface CiSetupOptions {
ci: CiSystem;
deploy: DeployTarget;
port: number;
auth: AuthMode;
branchStrategy: BranchStrategy;
dryRun: boolean;
apply: boolean;
yes: boolean;
outputDir: string;
}
export interface DetectResult {
gitRoot: string | null;
detectedCi: CiSystem | null;
hasDocker: boolean;
portAvailable: boolean;
primaryLanguage: string;
}
export interface GeneratedFile {
relativePath: string;
content: string;
}
export interface CiSetupResult {
generated: string[];
skipped: string[];
errors: string[];
}

View file

@ -23,6 +23,22 @@ program
)
.action(createLazyAction(() => import('./setup.js'), 'setupCommand'));
program
.command('ci-setup')
.description(
'Generate CI/CD workflows, Docker Compose, and MCP config for a shared team GitNexus server',
)
.option('--ci <system>', 'CI/CD system: github-actions, azure-devops, or both')
.option('--deploy <target>', 'Deploy target: docker, azure-container-app, or both')
.option('--port <port>', 'gitnexus serve port', '4747')
.option('--auth <mode>', 'Auth mode: token (Caddy proxy) or none', 'token')
.option('--branch-strategy <strategy>', 'Index strategy: pr-scoped or main-only', 'pr-scoped')
.option('--dry-run', 'Print generated files without writing (default when no mode flag given)')
.option('--apply', 'Write files with per-file confirmation gates')
.option('--yes', 'Skip per-file confirmation prompts (use with --apply)')
.option('--output-dir <path>', 'Directory to write generated files (default: git root)')
.action(createLazyAction(() => import('./ci-setup.js'), 'ciSetupCommand'));
program
.command('analyze [path]')
.description('Index a repository (full analysis)')

View file

@ -0,0 +1,234 @@
import { describe, expect, it } from 'vitest';
import yaml from 'js-yaml';
import { generateFiles } from '../../src/cli/ci-setup/templates.js';
import type { CiSetupOptions, DetectResult } from '../../src/cli/ci-setup/types.js';
const DEFAULT_DETECT: DetectResult = {
gitRoot: '/repo',
detectedCi: 'github-actions',
hasDocker: true,
portAvailable: true,
primaryLanguage: 'TypeScript',
};
function makeOpts(overrides?: Partial<CiSetupOptions>): CiSetupOptions {
return {
ci: 'github-actions',
deploy: 'docker',
port: 4747,
auth: 'token',
branchStrategy: 'pr-scoped',
dryRun: true,
apply: false,
yes: false,
outputDir: '/repo',
...overrides,
};
}
describe('generateFiles', () => {
describe('GitHub Actions workflow', () => {
it('generates with correct port in healthcheck', () => {
const files = generateFiles(makeOpts(), DEFAULT_DETECT);
const wf = files.find((f) => f.relativePath === '.github/workflows/gitnexus-ci.yml');
expect(wf).toBeDefined();
// port 4747 appears in the healthcheck (docker-compose, not the workflow itself)
});
it('is valid YAML', () => {
const files = generateFiles(makeOpts(), DEFAULT_DETECT);
const wf = files.find((f) => f.relativePath === '.github/workflows/gitnexus-ci.yml');
expect(() => yaml.load(wf!.content)).not.toThrow();
});
it('uses node 22', () => {
const files = generateFiles(makeOpts(), DEFAULT_DETECT);
const wf = files.find((f) => f.relativePath === '.github/workflows/gitnexus-ci.yml');
expect(wf!.content).toContain("node-version: '22'");
});
it('includes pull_request trigger when pr-scoped', () => {
const files = generateFiles(makeOpts({ branchStrategy: 'pr-scoped' }), DEFAULT_DETECT);
const wf = files.find((f) => f.relativePath === '.github/workflows/gitnexus-ci.yml');
expect(wf!.content).toContain('pull_request');
});
it('omits pull_request trigger when main-only', () => {
const files = generateFiles(makeOpts({ branchStrategy: 'main-only' }), DEFAULT_DETECT);
const wf = files.find((f) => f.relativePath === '.github/workflows/gitnexus-ci.yml');
expect(wf!.content).not.toContain('pull_request');
});
it('includes license notice', () => {
const files = generateFiles(makeOpts(), DEFAULT_DETECT);
const wf = files.find((f) => f.relativePath === '.github/workflows/gitnexus-ci.yml');
expect(wf!.content).toContain('PolyForm-Noncommercial');
});
});
describe('Azure DevOps pipeline', () => {
it('generates when ci is azure-devops', () => {
const files = generateFiles(makeOpts({ ci: 'azure-devops' }), DEFAULT_DETECT);
const ado = files.find((f) => f.relativePath === 'azure-pipelines-gitnexus.yml');
expect(ado).toBeDefined();
});
it('is valid YAML', () => {
const files = generateFiles(makeOpts({ ci: 'azure-devops' }), DEFAULT_DETECT);
const ado = files.find((f) => f.relativePath === 'azure-pipelines-gitnexus.yml');
expect(() => yaml.load(ado!.content)).not.toThrow();
});
it('includes commercial use notice', () => {
const files = generateFiles(makeOpts({ ci: 'azure-devops' }), DEFAULT_DETECT);
const ado = files.find((f) => f.relativePath === 'azure-pipelines-gitnexus.yml');
expect(ado!.content).toContain('COMMERCIAL USE');
});
it('omits pr trigger when main-only', () => {
const files = generateFiles(
makeOpts({ ci: 'azure-devops', branchStrategy: 'main-only' }),
DEFAULT_DETECT,
);
const ado = files.find((f) => f.relativePath === 'azure-pipelines-gitnexus.yml');
expect(ado!.content).toContain('pr: none');
});
});
describe('Docker Compose — token auth', () => {
it('generates with correct health endpoint /api/health', () => {
const files = generateFiles(makeOpts({ auth: 'token' }), DEFAULT_DETECT);
const dc = files.find((f) => f.relativePath === 'docker-compose.gitnexus.yml');
expect(dc!.content).toContain('/api/health');
});
it('uses port 4747', () => {
const files = generateFiles(makeOpts({ auth: 'token', port: 4747 }), DEFAULT_DETECT);
const dc = files.find((f) => f.relativePath === 'docker-compose.gitnexus.yml');
expect(dc!.content).toContain('4747');
});
it('does NOT publish gitnexus port directly (no ports: on gitnexus service)', () => {
const files = generateFiles(makeOpts({ auth: 'token' }), DEFAULT_DETECT);
const dc = files.find((f) => f.relativePath === 'docker-compose.gitnexus.yml');
// The service section before the proxy section should have no port mapping for 4747
const lines = dc!.content.split('\n');
const gitnexusServiceEnd = lines.findIndex((l) => l.trim() === 'gitnexus-proxy:');
const gitnexusSection = lines.slice(0, gitnexusServiceEnd).join('\n');
expect(gitnexusSection).not.toMatch(/^\s+ports:/m);
});
it('includes Caddy proxy service', () => {
const files = generateFiles(makeOpts({ auth: 'token' }), DEFAULT_DETECT);
const dc = files.find((f) => f.relativePath === 'docker-compose.gitnexus.yml');
expect(dc!.content).toContain('gitnexus-proxy');
expect(dc!.content).toContain('caddy:alpine');
});
it('generates Caddyfile', () => {
const files = generateFiles(makeOpts({ auth: 'token' }), DEFAULT_DETECT);
const cf = files.find((f) => f.relativePath === 'Caddyfile');
expect(cf).toBeDefined();
expect(cf!.content).toContain('{env.GITNEXUS_TOKEN}');
expect(cf!.content).toContain('reverse_proxy gitnexus:4747');
expect(cf!.content).toContain('respond "Unauthorized" 401');
});
it('is valid YAML (docker-compose)', () => {
const files = generateFiles(makeOpts({ auth: 'token' }), DEFAULT_DETECT);
const dc = files.find((f) => f.relativePath === 'docker-compose.gitnexus.yml');
expect(() => yaml.load(dc!.content)).not.toThrow();
});
});
describe('Docker Compose — no auth', () => {
it('publishes port 4747 directly (with env-var default syntax)', () => {
const files = generateFiles(makeOpts({ auth: 'none', port: 4747 }), DEFAULT_DETECT);
const dc = files.find((f) => f.relativePath === 'docker-compose.gitnexus.yml');
// The template emits "${GITNEXUS_PORT:-4747}:4747" — check both the default and the target port.
expect(dc!.content).toContain(':-4747}:4747');
});
it('shows no-auth warning banner', () => {
const files = generateFiles(makeOpts({ auth: 'none' }), DEFAULT_DETECT);
const dc = files.find((f) => f.relativePath === 'docker-compose.gitnexus.yml');
expect(dc!.content).toContain('NO AUTH');
});
it('does NOT generate a Caddyfile', () => {
const files = generateFiles(makeOpts({ auth: 'none' }), DEFAULT_DETECT);
const cf = files.find((f) => f.relativePath === 'Caddyfile');
expect(cf).toBeUndefined();
});
it('uses correct health endpoint /api/health', () => {
const files = generateFiles(makeOpts({ auth: 'none' }), DEFAULT_DETECT);
const dc = files.find((f) => f.relativePath === 'docker-compose.gitnexus.yml');
expect(dc!.content).toContain('/api/health');
});
});
describe('MCP snippet', () => {
it('uses proxy port (port+1) when auth is token', () => {
const files = generateFiles(makeOpts({ auth: 'token', port: 4747 }), DEFAULT_DETECT);
const snip = files.find((f) => f.relativePath === '.claude/gitnexus-mcp-snippet.json');
expect(snip!.content).toContain(':4748/api/mcp');
});
it('uses direct port when auth is none', () => {
const files = generateFiles(makeOpts({ auth: 'none', port: 4747 }), DEFAULT_DETECT);
const snip = files.find((f) => f.relativePath === '.claude/gitnexus-mcp-snippet.json');
expect(snip!.content).toContain(':4747/api/mcp');
});
it('includes Authorization header when auth is token', () => {
const files = generateFiles(makeOpts({ auth: 'token' }), DEFAULT_DETECT);
const snip = files.find((f) => f.relativePath === '.claude/gitnexus-mcp-snippet.json');
expect(snip!.content).toContain('Authorization');
expect(snip!.content).toContain('Bearer');
});
it('omits Authorization header when auth is none', () => {
const files = generateFiles(makeOpts({ auth: 'none' }), DEFAULT_DETECT);
const snip = files.find((f) => f.relativePath === '.claude/gitnexus-mcp-snippet.json');
expect(snip!.content).not.toContain('Authorization');
});
});
describe('GITNEXUS.md', () => {
it('contains license notice', () => {
const files = generateFiles(makeOpts(), DEFAULT_DETECT);
const md = files.find((f) => f.relativePath === 'GITNEXUS.md');
expect(md!.content).toContain('PolyForm-Noncommercial-1.0.0');
});
it('documents the no-auth warning when auth is none', () => {
const files = generateFiles(makeOpts({ auth: 'none' }), DEFAULT_DETECT);
const md = files.find((f) => f.relativePath === 'GITNEXUS.md');
expect(md!.content).toContain('No auth');
});
});
describe('file set composition', () => {
it('generates both CI files when ci is both', () => {
const files = generateFiles(makeOpts({ ci: 'both' }), DEFAULT_DETECT);
const paths = files.map((f) => f.relativePath);
expect(paths).toContain('.github/workflows/gitnexus-ci.yml');
expect(paths).toContain('azure-pipelines-gitnexus.yml');
});
it('generates both deploy artifacts when deploy is both', () => {
const files = generateFiles(makeOpts({ deploy: 'both' }), DEFAULT_DETECT);
const paths = files.map((f) => f.relativePath);
expect(paths).toContain('docker-compose.gitnexus.yml');
expect(paths).toContain('gitnexus-aca-deploy.sh');
});
it('always generates MCP snippet and GITNEXUS.md', () => {
const files = generateFiles(makeOpts(), DEFAULT_DETECT);
const paths = files.map((f) => f.relativePath);
expect(paths).toContain('.claude/gitnexus-mcp-snippet.json');
expect(paths).toContain('GITNEXUS.md');
});
});
});

View file

@ -0,0 +1,190 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
// Mock @inquirer/prompts so tests don't hang waiting for TTY input.
// Default: all selects return the first choice; confirm returns false (no overwrite).
vi.mock('@inquirer/prompts', () => ({
select: vi.fn(({ choices }: { choices: { value: string }[] }) =>
Promise.resolve(choices[0].value),
),
confirm: vi.fn(() => Promise.resolve(false)),
}));
// Mock child_process to prevent actual git/shell calls from the detect module.
vi.mock('child_process', () => ({
execSync: vi.fn((cmd: string) => {
const gitRoot = process.env._TEST_GIT_ROOT;
if (cmd.includes('rev-parse --show-toplevel') || cmd.includes('rev-parse --is-inside-work-tree')) {
if (!gitRoot) throw new Error('not a git repository');
return Buffer.from(gitRoot);
}
return Buffer.from('');
}),
}));
describe('ciSetupCommand', () => {
let tempDir: string;
let originalGitRoot: string | undefined;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ci-setup-'));
// Simulate a git repo by pointing _TEST_GIT_ROOT at tempDir
originalGitRoot = process.env._TEST_GIT_ROOT;
process.env._TEST_GIT_ROOT = tempDir;
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
process.env._TEST_GIT_ROOT = originalGitRoot;
await fs.rm(tempDir, { recursive: true, force: true });
});
it('--dry-run writes no files', async () => {
const { ciSetupCommand } = await import('../../src/cli/ci-setup.js');
await ciSetupCommand({
ci: 'github-actions',
deploy: 'docker',
auth: 'token',
branchStrategy: 'pr-scoped',
dryRun: true,
apply: false,
yes: false,
outputDir: tempDir,
});
const entries = await fs.readdir(tempDir);
expect(entries).toHaveLength(0);
});
it('--apply writes expected files', async () => {
const { ciSetupCommand } = await import('../../src/cli/ci-setup.js');
await ciSetupCommand({
ci: 'github-actions',
deploy: 'docker',
auth: 'token',
branchStrategy: 'pr-scoped',
apply: true,
yes: true,
outputDir: tempDir,
});
const wfPath = path.join(tempDir, '.github', 'workflows', 'gitnexus-ci.yml');
const dcPath = path.join(tempDir, 'docker-compose.gitnexus.yml');
const cfPath = path.join(tempDir, 'Caddyfile');
const snipPath = path.join(tempDir, '.claude', 'gitnexus-mcp-snippet.json');
const mdPath = path.join(tempDir, 'GITNEXUS.md');
await expect(fs.access(wfPath)).resolves.toBeUndefined();
await expect(fs.access(dcPath)).resolves.toBeUndefined();
await expect(fs.access(cfPath)).resolves.toBeUndefined();
await expect(fs.access(snipPath)).resolves.toBeUndefined();
await expect(fs.access(mdPath)).resolves.toBeUndefined();
});
it('--apply is idempotent: second run skips identical files', async () => {
const { ciSetupCommand } = await import('../../src/cli/ci-setup.js');
const opts = {
ci: 'github-actions' as const,
deploy: 'docker' as const,
auth: 'token' as const,
branchStrategy: 'pr-scoped' as const,
apply: true,
yes: true,
outputDir: tempDir,
};
await ciSetupCommand(opts);
// Record mtimes after first apply
const wfPath = path.join(tempDir, '.github', 'workflows', 'gitnexus-ci.yml');
const mtime1 = (await fs.stat(wfPath)).mtimeMs;
// Small delay to ensure mtime would differ if file is rewritten
await new Promise((r) => setTimeout(r, 20));
vi.resetModules();
const { ciSetupCommand: ciSetupCommand2 } = await import('../../src/cli/ci-setup.js');
await ciSetupCommand2(opts);
const mtime2 = (await fs.stat(wfPath)).mtimeMs;
expect(mtime2).toBe(mtime1); // file not rewritten
});
it('exits with error when not in a git repo', async () => {
process.env._TEST_GIT_ROOT = ''; // simulate no git root
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((_code?: number) => {
throw new Error('process.exit called');
});
const { ciSetupCommand } = await import('../../src/cli/ci-setup.js');
await expect(
ciSetupCommand({
ci: 'github-actions',
deploy: 'docker',
auth: 'token',
apply: true,
yes: true,
outputDir: tempDir,
}),
).rejects.toThrow('process.exit called');
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('--auth none generates no Caddyfile', async () => {
const { ciSetupCommand } = await import('../../src/cli/ci-setup.js');
await ciSetupCommand({
ci: 'github-actions',
deploy: 'docker',
auth: 'none',
apply: true,
yes: true,
outputDir: tempDir,
});
const cfPath = path.join(tempDir, 'Caddyfile');
await expect(fs.access(cfPath)).rejects.toThrow();
});
it('--auth none docker-compose has no proxy service', async () => {
const { ciSetupCommand } = await import('../../src/cli/ci-setup.js');
await ciSetupCommand({
ci: 'github-actions',
deploy: 'docker',
auth: 'none',
apply: true,
yes: true,
outputDir: tempDir,
});
const dcContent = await fs.readFile(
path.join(tempDir, 'docker-compose.gitnexus.yml'),
'utf-8',
);
expect(dcContent).not.toContain('gitnexus-proxy');
expect(dcContent).not.toContain('caddy');
});
it('default mode (no --dry-run / --apply flag) defaults to dry-run', async () => {
const { ciSetupCommand } = await import('../../src/cli/ci-setup.js');
// No apply or dryRun flags — should default to dry-run
await ciSetupCommand({
ci: 'github-actions',
deploy: 'docker',
auth: 'token',
outputDir: tempDir,
});
const entries = await fs.readdir(tempDir);
expect(entries).toHaveLength(0);
});
});