diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index c5f4aa7af..1b3832f93 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -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", diff --git a/gitnexus/package.json b/gitnexus/package.json index cd065cef0..e036e6216 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -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", diff --git a/gitnexus/src/cli/ci-setup.ts b/gitnexus/src/cli/ci-setup.ts new file mode 100644 index 000000000..a1d54a23b --- /dev/null +++ b/gitnexus/src/cli/ci-setup.ts @@ -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 = {}; + 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 { + 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 { + 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(''); +} diff --git a/gitnexus/src/cli/ci-setup/detect.ts b/gitnexus/src/cli/ci-setup/detect.ts new file mode 100644 index 000000000..2bd4fd293 --- /dev/null +++ b/gitnexus/src/cli/ci-setup/detect.ts @@ -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 = { + '.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 { + 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 { + 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 { + const counts: Record = {}; + let scanned = 0; + + async function scan(dir: string, depth: number): Promise { + 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 { + 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 { + 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 }; +} diff --git a/gitnexus/src/cli/ci-setup/prompts.ts b/gitnexus/src/cli/ci-setup/prompts.ts new file mode 100644 index 000000000..13ed32823 --- /dev/null +++ b/gitnexus/src/cli/ci-setup/prompts.ts @@ -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, +): Promise { + // 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 { + const detected = detect.detectedCi; + return select({ + 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 { + return select({ + 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 { + return select({ + 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 { + return select({ + message: 'Branch index strategy', + choices: [ + { name: 'Main + PR-scoped (recommended)', value: 'pr-scoped' }, + { name: 'Main branch only', value: 'main-only' }, + ], + default: 'pr-scoped', + }); +} diff --git a/gitnexus/src/cli/ci-setup/templates.ts b/gitnexus/src/cli/ci-setup/templates.ts new file mode 100644 index 000000000..b6390a46a --- /dev/null +++ b/gitnexus/src/cli/ci-setup/templates.ts @@ -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= +# WORKSPACE_DIR= (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 to the server's IP or hostname.` + : ` // Direct gitnexus port (no auth). Change 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://:${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://:${opts.port + 1}/api/mcp", + "headers": { + "Authorization": "Bearer YOUR_GITNEXUS_TOKEN" + } + } +} +\`\`\` + +Replace \`\` 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://:${opts.port}/api/mcp" + } +} +\`\`\` + +Replace \`\` 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; +} diff --git a/gitnexus/src/cli/ci-setup/types.ts b/gitnexus/src/cli/ci-setup/types.ts new file mode 100644 index 000000000..47ca019d4 --- /dev/null +++ b/gitnexus/src/cli/ci-setup/types.ts @@ -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[]; +} diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index a2a88bd83..3dbbac42b 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -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 ', 'CI/CD system: github-actions, azure-devops, or both') + .option('--deploy ', 'Deploy target: docker, azure-container-app, or both') + .option('--port ', 'gitnexus serve port', '4747') + .option('--auth ', 'Auth mode: token (Caddy proxy) or none', 'token') + .option('--branch-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 ', '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)') diff --git a/gitnexus/test/unit/ci-setup-templates.test.ts b/gitnexus/test/unit/ci-setup-templates.test.ts new file mode 100644 index 000000000..8c4de2f0a --- /dev/null +++ b/gitnexus/test/unit/ci-setup-templates.test.ts @@ -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 { + 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'); + }); + }); +}); diff --git a/gitnexus/test/unit/ci-setup.test.ts b/gitnexus/test/unit/ci-setup.test.ts new file mode 100644 index 000000000..1b3c7e951 --- /dev/null +++ b/gitnexus/test/unit/ci-setup.test.ts @@ -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); + }); +});