mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
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:
parent
f2c9e69792
commit
c3675d3d12
10 changed files with 1804 additions and 1 deletions
389
gitnexus/package-lock.json
generated
389
gitnexus/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
186
gitnexus/src/cli/ci-setup.ts
Normal file
186
gitnexus/src/cli/ci-setup.ts
Normal 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('');
|
||||
}
|
||||
140
gitnexus/src/cli/ci-setup/detect.ts
Normal file
140
gitnexus/src/cli/ci-setup/detect.ts
Normal 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 };
|
||||
}
|
||||
88
gitnexus/src/cli/ci-setup/prompts.ts
Normal file
88
gitnexus/src/cli/ci-setup/prompts.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
524
gitnexus/src/cli/ci-setup/templates.ts
Normal file
524
gitnexus/src/cli/ci-setup/templates.ts
Normal 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;
|
||||
}
|
||||
35
gitnexus/src/cli/ci-setup/types.ts
Normal file
35
gitnexus/src/cli/ci-setup/types.ts
Normal 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[];
|
||||
}
|
||||
|
|
@ -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)')
|
||||
|
|
|
|||
234
gitnexus/test/unit/ci-setup-templates.test.ts
Normal file
234
gitnexus/test/unit/ci-setup-templates.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
190
gitnexus/test/unit/ci-setup.test.ts
Normal file
190
gitnexus/test/unit/ci-setup.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue