diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 94b313a..c83d6e9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,7 +11,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v1 with: - node-version: 14.x + node-version: 21.x - run: node --version - run: npm --version - name: npm install and build diff --git a/package-lock.json b/package-lock.json index c3f45c0..b942d3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,268 +1,350 @@ { "name": "gitlab-search", - "version": "1.5.0", - "lockfileVersion": 1, + "version": "2.0.0-beta1", + "lockfileVersion": 3, "requires": true, - "dependencies": { - "@glennsl/bs-json": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@glennsl/bs-json/-/bs-json-5.0.2.tgz", - "integrity": "sha512-vVlHJNrhmwvhyea14YiV4L5pDLjqw1edE3GzvMxlbPPQZVhzgO3sTWrUxCpQd2gV+CkMfk4FHBYunx9nWtBoDg==", - "dev": true + "packages": { + "": { + "name": "gitlab-search", + "version": "2.0.0-beta1", + "license": "MIT", + "dependencies": { + "axios": "^1.7.9", + "chalk": "^5.4.1" + }, + "bin": { + "gitlab-search": "bin/gitlab-search.js" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/rc": "^1.2.4", + "@vercel/ncc": "^0.38.3", + "commander": "^5.0.0", + "rc": "^1.2.8", + "rimraf": "^3.0.2", + "typescript": "^5.7.2" + } }, - "@zeit/ncc": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@zeit/ncc/-/ncc-0.22.0.tgz", - "integrity": "sha512-zaS6chwztGSLSEzsTJw9sLTYxQt57bPFBtsYlVtbqGvmDUsfW7xgXPYofzFa1kB9ur2dRop6IxCwPnWLBVCrbQ==", + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", "dev": true }, - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "node_modules/@types/node": { + "version": "22.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", + "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", "dev": true, - "requires": { - "color-convert": "^1.9.0" + "dependencies": { + "undici-types": "~6.20.0" } }, - "axios": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", - "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", + "node_modules/@types/rc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/rc/-/rc-1.2.4.tgz", + "integrity": "sha512-xD6+epQoMH79A1uwmJIq25D+XZ57jUzCQ1DGSvs3tGKdx7QDYOOaMh6m5KBkEIW4+Cy5++bZ7NLDfdpNiYVKYA==", "dev": true, - "requires": { - "follow-redirects": "1.5.10" + "dependencies": { + "@types/minimist": "*" + } + }, + "node_modules/@vercel/ncc": { + "version": "0.38.3", + "resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.3.tgz", + "integrity": "sha512-rnK6hJBS6mwc+Bkab+PGPs9OiS0i/3kdTO+CkI8V0/VrW3vmz7O2Pxjw/owOlmo6PKEIxRSeZKv/kuL9itnpYA==", + "dev": true, + "bin": { + "ncc": "dist/ncc/cli.js" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" } }, - "balanced-match": { + "node_modules/balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", "dev": true }, - "brace-expansion": { + "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, - "requires": { + "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, - "bs-axios": { - "version": "0.0.43", - "resolved": "https://registry.npmjs.org/bs-axios/-/bs-axios-0.0.43.tgz", - "integrity": "sha512-TI+LZ3L4KurI/D6O60Ao04OYoknla7EJuCRBRQvWohxKO7ZMYThelYEEeChIVjjqZxukIVAag3JQ3+/WCJvwrg==", - "dev": true, - "requires": { - "axios": "^0.19.2" - } - }, - "bs-chalk": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/bs-chalk/-/bs-chalk-0.2.1.tgz", - "integrity": "sha512-wyy8l8CuZRUeKD2DHgiD1osOX6RdLKTwLuUazWhlaNqTvgQ3j6wTWaKpfGP+e5JhgF+vmKCreDMvFAWTSZFK4g==", - "dev": true, - "requires": { - "chalk": "^2.3.0" - } - }, - "bs-platform": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/bs-platform/-/bs-platform-5.2.1.tgz", - "integrity": "sha512-3ISP+RBC/NYILiJnphCY0W3RTYpQ11JGa2dBBLVug5fpFZ0qtSaL3ZplD8MyjNeXX2bC7xgrWfgBSn8Tc9om7Q==", - "dev": true - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" + "node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "requires": { - "color-name": "1.1.3" + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" } }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", - "dev": true - }, - "commander": { + "node_modules/commander": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-5.0.0.tgz", "integrity": "sha512-JrDGPAKjMGSP1G0DUoaceEJ3DZgAfr/q6X7FVk4+U5KxUSKviYGM2k6zWkfyyBHy5rAtzgYJFa1ro2O9PtoxwQ==", - "dev": true + "dev": true, + "engines": { + "node": ">= 6" + } }, - "concat-map": { + "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true }, - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "deep-extend": { + "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true + "dev": true, + "engines": { + "node": ">=4.0.0" + } }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } }, - "follow-redirects": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", - "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", - "dev": true, - "requires": { - "debug": "=3.1.0" + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } } }, - "fs.realpath": { + "node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true }, - "glob": { + "node_modules/glob": { "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, - "requires": { + "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.0.4", "once": "^1.3.0", "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true - }, - "inflight": { + "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, - "requires": { + "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, - "inherits": { + "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, - "ini": { + "node_modules/ini": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.7.tgz", "integrity": "sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ==", "dev": true }, - "minimatch": { + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "dev": true, - "requires": { + "dependencies": { "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" } }, - "minimist": { + "node_modules/minimist": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - }, - "once": { + "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, - "requires": { + "dependencies": { "wrappy": "1" } }, - "path-is-absolute": { + "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true + "dev": true, + "engines": { + "node": ">=0.10.0" + } }, - "rc": { + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "dev": true, - "requires": { + "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" } }, - "rimraf": { + "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, - "requires": { + "dependencies": { "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "strip-json-comments": { + "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", - "dev": true + "dev": true, + "engines": { + "node": ">=0.10.0" + } }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "node_modules/typescript": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, - "requires": { - "has-flag": "^3.0.0" + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" } }, - "wrappy": { + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true + }, + "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", diff --git a/package.json b/package.json index ffd5461..4f4af66 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,8 @@ { "name": "gitlab-search", - "version": "1.5.0", + "version": "2.0.0-beta1", "scripts": { - "build": "rimraf dist && bsb -make-world && ncc build lib/js/src/Main.bs.js --out dist", - "start": "bsb -make-world -w", - "clean": "bsb -clean-world", + "build": "rimraf dist && npx tsc && ncc build dist/src/main.js --out dist", "prepublish": "npm run build" }, "keywords": [ @@ -12,20 +10,28 @@ ], "author": "Phillip Johnsen ", "license": "MIT", - "repository": "github:phillipj/gitlab-search", + "repository": { + "type": "git", + "url": "git+https://github.com/phillipj/gitlab-search.git" + }, "files": [ "bin", "dist" ], - "bin": "bin/gitlab-search.js", + "bin": { + "gitlab-search": "bin/gitlab-search.js" + }, "devDependencies": { - "@glennsl/bs-json": "^5.0.2", - "@zeit/ncc": "^0.22.0", - "bs-axios": "0.0.43", - "bs-chalk": "^0.2.1", - "bs-platform": "^5.2.1", + "@types/node": "^22.10.2", + "@types/rc": "^1.2.4", + "@vercel/ncc": "^0.38.3", "commander": "^5.0.0", "rc": "^1.2.8", - "rimraf": "^3.0.2" + "rimraf": "^3.0.2", + "typescript": "^5.7.2" + }, + "dependencies": { + "axios": "^1.7.9", + "chalk": "^5.4.1" } } diff --git a/src/Commander.re b/src/Commander.re deleted file mode 100644 index d5195a6..0000000 --- a/src/Commander.re +++ /dev/null @@ -1,125 +0,0 @@ -/** - * This internal ReasonML module is used to wrap the 3rd party commander.js package - * and mostly consists of interop with JavaScript via bucklescript bindings. - * - * Useful resources to learn about ReasonML/bucklescript's interop with JavaScript: - * - https://medium.com/@Hehk/binding-a-library-in-reasonml-e33b6a58b1b3 - * - https://github.com/glennsl/bucklescript-ffi-cheatsheet - * - https://bucklescript.github.io/bucklescript/Manual.html#_binding_to_a_value_from_a_module_code_bs_module_code - */ - -/* t == commander.js being used for commander during its execution */ -type t; -type actionFnOptions; - -/* this basically does a require("commander") behind the scenes and assigns the result to the type t */ -[@bs.module] external commander: t = "commander"; - -/* an idiomatic make() function to get a hold of commander.js */ -let make = () => commander; - -[@bs.send.pipe: t] -external action: (unit /* in reality variadic arguments */ => unit) => t = - "action"; - -/** - This overrides the Commander.action() function for users of this module, needed because - commander.js invokes the callback provided with variadic (read dynamic) arguments when invoking it. - That doesn't play well with a strongly typed language like ReasonML, where every argument into a - function has to be explicitly declared. - - Therefore doing some raw bucklescript tricks here, to convert all the dynamic arguments provided by - commander.js into *one* array of strings before we invoke the callback provided by the user of this - Commander ReasonML module. - - Worth mentioning [@bs.variadic] does something similar, but as far as I've understood that's only - viable for method calls done from ReasonML / our application code. This is the other way around though, - as this is for making arguments provided *from* JavaScript to ReasonML. - */ -let action: ((array(string), actionFnOptions) => unit, t) => t = - (fn, t) => { - // action() below means the external action() definition above, in other words we're not calling - // ourselfs in a recursive manner here - action( - () => { - // Handling arguments of a callback invoked by JavaScript with variadic arguments isn't - // straight forward to handling in ReasonML/OCaml's type system. The trick we do below is - // to grab all the arguments provided by JavaScript, extract the latest argument and treat it - // as "options", then put all preceding arguments into a string array - let arguments = [%bs.raw {| arguments |}]; - let argumentsCount = Js.Array.length(arguments); - let options: actionFnOptions = - Js.Array.unsafe_get(arguments, argumentsCount - 1); - let allArgsExceptLast: array(string) = - Belt.Array.slice(arguments, ~offset=0, ~len=argumentsCount - 1); - - // this it what ends up invoking the callback provided by the code using Commander.action() - fn(allArgsExceptLast, options); - }, - t, - ); - }; - -// bs.get gets a specific field from a JavaScript object -[@bs.get] external getArgs: t => array(string) = "args"; -// bs.get_index gets a user specified field from a JavaScript object, string argument decides which field to get -[@bs.get_index] -external getOption: (actionFnOptions, string) => option(string) = ""; - -[@bs.get_index] -external getOptionAsInt: (actionFnOptions, string) => option(int) = ""; - -[@bs.get_index] -external getOptionAsBoolean: (actionFnOptions, string) => option(bool) = ""; - -/** - This overrides the Commander.getOptionAsBoolean() function for users of this module because commander.js does - return an *actual* boolean value when an option exist that doesn't have an argument. That will make commander.js - return the CLI option's value as a boolean, rather than a string as it does for other options that accepts an argument. - - Since returning an optional value wrapping a boolean to users of this module, feels a bit clunky, it converts that - optional value that's returned from commander.js into an concrete bool value instead. -*/ -let getOptionAsBoolean = (actionFnOptions, optionName): bool => { - let maybeBoolValue = getOptionAsBoolean(actionFnOptions, optionName); - - Belt.Option.getWithDefault(maybeBoolValue, false); -}; - -[@bs.send.pipe: t] external arguments: string => t = "arguments"; -[@bs.send.pipe: t] external command: string => t = "command"; -[@bs.send.pipe: t] external description: string => t = "description"; -[@bs.send.pipe: t] external help: unit = "help"; -[@bs.send.pipe: t] external option: (string, string) => t = "option"; -[@bs.send.pipe: t] -external optionWithDefault: (string, string, string) => t = "option"; -[@bs.send.pipe: t] external parse: array(string) => t = "parse"; -[@bs.send.pipe: t] external version: string => t = "version"; - -// commander.js' .option() also accepts a validator-function provided as the third argument, -// if so, the forth argument is the default value, not the third as usual -[@bs.send.pipe: t] -external optionWithIntDefault: (string, string, string => int, int) => t = - "option"; - -let optionWithIntDefault = (name, description, defaultValue) => { - optionWithIntDefault( - name, - description, - valueProvidedByEndUser => - try (int_of_string(valueProvidedByEndUser)) { - | Failure(_) => - Js.log( - "Invalid number value (" - ++ valueProvidedByEndUser - ++ ") provided to " - ++ name - ++ ", will be using " - ++ string_of_int(defaultValue) - ++ " instead.", - ); - defaultValue; - }, - defaultValue, - ); -}; diff --git a/src/Config.re b/src/Config.re deleted file mode 100644 index 05ffe31..0000000 --- a/src/Config.re +++ /dev/null @@ -1,128 +0,0 @@ -open Belt; - -module Protocol = { - type t = - | HTTP - | HTTPS; - - let toString = protocol => - switch (protocol) { - | HTTPS => "https" - | HTTP => "http" - }; - - let fromString = protocol => { - switch (protocol) { - | "http" => HTTP - | _ => HTTPS - }; - }; -}; - -type t = { - domain: string, - token: string, - ignoreSSL: bool, - protocol: Protocol.t, - concurrency: int, -}; - -// As the type below is passed to JavaScript as a configuration object, -// it can't be a native ReasonML type, but rather a derived type so that -// the bucklescript compiler creates a proper JavaScript object of it -// with field names set as expected etc -- native ReasonML record types -// won't work because they're internally made with JavaScript arrays -[@bs.deriving abstract] -type serialisedConfig = { - [@bs.optional] - domain: string, - [@bs.optional] - token: string, - [@bs.optional] - config: string, - [@bs.optional] - ignoreSSL: bool, - [@bs.optional] - concurrency: int, -}; - -// https://www.npmjs.com/package/rc -[@bs.module] external rc: string => serialisedConfig = "rc"; - -let defaultDomain = "gitlab.com"; -let defaultDirectory = "."; -let defaultConcurrency = 25; -let defaultArchive = "all"; - -let parseProtocolAndDomain = rootApiUriOrOnlyDomain => { - let splitOnScheme = - rootApiUriOrOnlyDomain |> Js.String.toLowerCase |> Js.String.split("://"); - - switch (splitOnScheme) { - | [|protocol, domain|] => (Protocol.fromString(protocol), domain) - | [|domain|] => (Protocol.HTTPS, domain) - | [||] => (Protocol.HTTPS, defaultDomain) - | _ => - raise( - Js.Exn.raiseError( - "Configured API domain does not look like a valid domain or root GitLab API URI, please double check your configuration of: " - ++ rootApiUriOrOnlyDomain, - ), - ) - }; -}; - -let loadFromFile = (): Belt.Result.t(t, string) => { - let result = rc("gitlabsearch"); - let ignoreSSL = Option.getWithDefault(ignoreSSLGet(result), false); - let concurrency = - Option.getWithDefault(concurrencyGet(result), defaultConcurrency); - let (protocol, domain) = - domainGet(result) - ->Option.getWithDefault(defaultDomain) - ->parseProtocolAndDomain; - - switch (configGet(result)) { - | Some(configPath) => - Option.mapWithDefault( - tokenGet(result), - Result.Error( - "No personal access token was found in " - ++ configPath - ++ ", please run setup again!", - ), - token => - Result.Ok({domain, concurrency, token, ignoreSSL, protocol}) - ) - | None => - Result.Error( - "Could not find .gitlabsearchrc configuration file anywhere, have you run setup yet?", - ) - }; -}; - -let writeToFile = - ( - ~domainOrRootUri: string, - ~ignoreSSL: bool, - ~token: string, - ~directory, - ~concurrency, - ) => { - let filePath = Node.Path.join2(directory, ".gitlabsearchrc"); - let domain = - domainOrRootUri == defaultDomain ? None : Some(domainOrRootUri); - let ignoreSSL = ignoreSSL ? Some(true) : None; - let concurrency = - concurrency == defaultConcurrency ? None : Some(concurrency); - let content = - serialisedConfig(~domain?, ~ignoreSSL?, ~token, ~concurrency?, ()); - - Node.Fs.writeFileSync( - filePath, - Js.Option.getExn(Js.Json.stringifyAny(content)), - `utf8, - ); - - filePath; -}; diff --git a/src/GitLab.re b/src/GitLab.re deleted file mode 100644 index 858b0cc..0000000 --- a/src/GitLab.re +++ /dev/null @@ -1,317 +0,0 @@ -open Belt; - -[@bs.val] external debugEnv: Js.Nullable.t(string) = "process.env.DEBUG"; - -type group = { - id: string, - name: string, -}; - -type project = { - id: int, - name: string, - web_url: string, - archived: bool, -}; - -type searchFilter = - | Filename(option(string)) - | Extension(option(string)) - | Path(option(string)); - -type searchCriterias = { - term: string, - filters: array(searchFilter), -}; - -type searchResult = { - data: string, - filename: string, - ref: string, - startline: int, -}; - -module Decode = { - open Json.Decode; - - let group = json => { - id: json |> field("id", int) |> string_of_int, - name: json |> field("name", string), - }; - let groups = json => json |> array(group); - - let project = json => { - id: json |> field("id", int), - name: json |> field("name", string), - web_url: json |> field("web_url", string), - archived: json |> field("archived", bool), - }; - let projects = json => json |> array(project); - - let searchResult = json => { - data: json |> field("data", string), - filename: json |> field("filename", string), - ref: json |> field("ref", string), - startline: json |> field("startline", int), - }; - let searchResults = (project, json) => ( - project, - array(searchResult, json), - ); -}; - -let configResult = Config.loadFromFile(); - -let httpsAgent = - switch (configResult) { - | Belt.Result.Ok(config) => - switch (config.protocol) { - | HTTP => None - | HTTPS => - Axios.Agent.Https.config( - ~rejectUnauthorized=!config.ignoreSSL, - ~maxSockets=config.concurrency, - (), - ) - ->Axios.Agent.Https.create - ->Some - } - | Belt.Result.Error(_) => None - }; - -let debugLog = (text): unit => { - let isDebugEnabled = !Js.Nullable.isNullable(debugEnv); - - if (isDebugEnabled) { - Js.log(text); - }; -}; - -let request = (relativeUrl, decoder) => { - let config = - switch (configResult) { - | Belt.Result.Ok(value) => value - | Belt.Result.Error(failureReason) => - raise(Js.Exn.raiseError(failureReason)) - }; - - let headers = Axios.Headers.fromObj({"Private-Token": config.token}); - let options = Axios.makeConfig(~headers, ~httpsAgent?, ()); - let scheme = Config.Protocol.toString(config.protocol) ++ "://"; - let url = scheme ++ config.domain ++ "/api/v4" ++ relativeUrl; - - debugLog("Requesting: GET " ++ url); - - Js.Promise.( - Axios.getc(url, options) - |> then_(response => resolve(response##data)) - |> then_(json => resolve(decoder(json))) - ); -}; - -// Helpful when parsing hypermedia Link header values while paginating where URLs will be provided like: -// -let urlWithoutAngleBrackets = url => - Js.String.substring(~from=1, ~to_=Js.String.length(url) - 1, url); - -let getNextPaginationUrl = response => { - // Example from the docs: - // link: ; rel="prev", ; rel="next", ; rel="first", ; rel="last" - // - // Refs https://docs.gitlab.com/ee/api/README.html#pagination - let linkHeader: option(string) = response##headers##link; - - let nextLinkUrl = - Option.flatMap( - linkHeader, - header => { - let linkEntries = Js.String.split(",", header); - let links = - Array.map( - linkEntries, - linkEntry => { - let parts = - Js.String.split(";", linkEntry)->Array.map(Js.String.trim); - - let linkUrl = urlWithoutAngleBrackets(Array.getExn(parts, 0)); - let linkRel = Array.getExn(parts, 1); - - (linkUrl, linkRel); - }, - ); - - links - ->Array.keepMap(link => { - let (url, rel) = link; - - rel == "rel=\"next\"" ? Some(url) : None; - }) - ->Array.get(0); - }, - ); - - nextLinkUrl; -}; - -type requestUrl = - | RelativeUrl(string) // provided initially when kicking off a paginated request - | AbsoluteUrl(string); // provided when more pages of results has to be fetched when paginating - -let rec paginatedRequest = (url: requestUrl, decoder: Js.Json.t => array('a)) => { - let config = - switch (configResult) { - | Belt.Result.Ok(value) => value - | Belt.Result.Error(failureReason) => - raise(Js.Exn.raiseError(failureReason)) - }; - - let headers = Axios.Headers.fromObj({"Private-Token": config.token}); - let options = Axios.makeConfig(~headers, ~httpsAgent?, ()); - let scheme = Config.Protocol.toString(config.protocol) ++ "://"; - let urlToRequest = - switch (url) { - | RelativeUrl(path) => scheme ++ config.domain ++ "/api/v4" ++ path - | AbsoluteUrl(url) => url - }; - - debugLog("Requesting: GET " ++ urlToRequest); - - Js.Promise.( - Axios.getc(urlToRequest, options) - |> then_(response => { - let nextUrl = getNextPaginationUrl(response); - let json = response##data; - let entities = decoder(json); - - switch (nextUrl) { - | Some(url) => - paginatedRequest(AbsoluteUrl(url), decoder) - |> then_(entitesOnNextPage => - resolve(Array.concat(entities, entitesOnNextPage)) - ) - - | None => resolve(entities) - }; - }) - ); -}; - -let groupsFromStringNames = namesAsString => { - let names = Js.String.split(",", namesAsString); - let groups = Array.map(names, name => {id: name, name}); - - Js.Promise.resolve(groups); -}; - -// https://docs.gitlab.com/ee/api/groups.html#list-groups -let fetchGroups = (groupsNames: option(string)) => { - let groupsResult = - switch (groupsNames) { - | Some(names) => groupsFromStringNames(names) - | None => - paginatedRequest(RelativeUrl("/groups?per_page=100"), Decode.groups) - }; - - Js.Promise.( - groupsResult - |> then_(groups => { - let resolvedNames = - Array.map(groups, (group: group) => group.name) - |> Js.Array.joinWith(", "); - - debugLog("Using groups: " ++ resolvedNames); - - resolve(groups); - }) - ); -}; - -// https://docs.gitlab.com/ee/api/groups.html#list-a-groups-projects -let fetchProjectsInGroups = (archiveArgument: option(string), groups: array(group)) => { - let archiveQueryParam = switch (archiveArgument) { - | Some("only") => "&archived=true"; - | Some("exclude") => "&archived=false"; - | _ => ""; - }; - let requests = - Array.map( - groups, - // Very surprised this had to be annotated to be a group, cause or else it would be - // inferred as a project -- why on earth would that happen when the compiler gets very - // explicit information about the incoming function argument is a list of groups - (group: group) => - paginatedRequest( - RelativeUrl("/groups/" ++ group.id ++ "/projects?per_page=100" ++ archiveQueryParam), - Decode.projects, - ) - ); - - // this list <-> array is quite a pain in the backside, but don't have much choice - // since Promise.all() takes an array and list is the structure that has built-in flattening - Js.Promise.( - all(requests) - // concatMany == what usually is called flatten - |> then_(projects => resolve(Array.concatMany(projects))) - |> then_(allProjects => { - let resolvedNames = - Array.map(allProjects, (project: project) => project.name) - |> Js.Array.joinWith(", "); - - debugLog("Using projects: " ++ resolvedNames); - - resolve(allProjects); - }) - ); -}; - -let searchUrlParameter = (criterias: searchCriterias): string => { - let filters = - Array.( - criterias.filters - ->map(filter => - switch (filter) { - | Filename(value) => ("filename", value) - | Extension(value) => ("extension", value) - | Path(value) => ("path", value) - } - ) - ->keepMap(((parameterName, optionalValue)) => - Option.map(optionalValue, value => - parameterName ++ ":" ++ Js.Global.encodeURIComponent(value) - ) - ) - ); - - "&search=" - ++ Js.Global.encodeURIComponent(criterias.term) - ++ " " - ++ Js.Array.joinWith(" ", filters); -}; - -// https://docs.gitlab.com/ee/api/search.html#scope-blobs-2 -let searchInProjects = - (criterias: searchCriterias, projects: array(project)) - : Js.Promise.t(array((project, array(searchResult)))) => { - let requests = - Array.map(projects, project => - request( - "/projects/" - ++ string_of_int(project.id) - ++ "/search?scope=blobs" - ++ searchUrlParameter(criterias), - Decode.searchResults(project), - ) - ); - - Js.Promise.( - all(requests) - |> then_(results => - resolve( - // keep == filter which is only available on List for some reason - Array.keep(results, ((_, searchResults)) => - Array.length(searchResults) > 0 - ), - ) - ) - ); -}; diff --git a/src/Main.re b/src/Main.re deleted file mode 100644 index 7e70259..0000000 --- a/src/Main.re +++ /dev/null @@ -1,117 +0,0 @@ -let packageJson = [%bs.raw {| require("../../../package.json") |}]; - -let program = Commander.(make() |> version(packageJson##version)); - -let main = (args, options) => { - let getOption = optionName => Commander.getOption(options, optionName); - let groups = getOption("groups"); - let criterias = - GitLab.{ - // daring to do an unsafe get operation below because commander.js *should* have - // ensured the search term argument is available before invoking this function - term: Belt.Array.getUnsafe(args, 0), - filters: [| - Filename(getOption("filename")), - Extension(getOption("extension")), - Path(getOption("path")), - |], - }; - - Js.Promise.( - GitLab.fetchGroups(groups) - |> then_(GitLab.fetchProjectsInGroups(getOption("archive"))) - |> then_(GitLab.searchInProjects(criterias)) - |> then_(results => - resolve(Print.searchResults(criterias.term, results)) - ) - |> catch(err => resolve(Js.log2("Something exploded!", err))) - |> ignore - ); -}; - -let setup = (args, options) => { - // token is said to be a required argument so commander.js ensures it's present before executing this function - let token = Belt.Array.getExn(args, 0); - - // options below has default values set in their definition so they always has a value - let directory = Belt.Option.getExn(Commander.getOption(options, "dir")); - let domainOrRootUri = - Belt.Option.getExn(Commander.getOption(options, "apiDomain")); - let concurrency = - Belt.Option.getExn(Commander.getOptionAsInt(options, "concurrency")); - let ignoreSSL = Commander.getOptionAsBoolean(options, "ignoreSsl"); - - let configPath = - Config.writeToFile( - ~domainOrRootUri, - ~ignoreSSL, - ~token, - ~directory, - ~concurrency, - ); - Print.successful( - "Successfully wrote config to " - ++ configPath - ++ ", gitlab-search is now ready to be used", - ); -}; - -Commander.( - program - |> arguments("") - |> option( - "-g, --groups ", - "group(s) to find repositories in (separated with comma)", - ) - |> option( - "-f, --filename ", - "only search for contents in given a file, glob matching with wildcards (*)", - ) - |> option( - "-e, --extension ", - "only search for contents in files with given extension", - ) - |> option("-p, --path ", "only search in files in the given path") - |> optionWithDefault( - "-a, --archive [all,only,exclude]", - "to only search on archived repositories, or to exclude them, by default the search will be apply to all repositories", - Config.defaultArchive, - ) - |> action(main) -); - -Commander.( - program - |> command("setup") - |> description("create configuration file") - |> arguments("") - |> option( - "--ignore-ssl", - "ignore invalid SSL certificate from the GitLab API server", - ) - |> optionWithDefault( - "--api-domain ", - "domain name or root URL of GitLab API server,\nspecify root URL (without trailing slash) to use HTTP instead of HTTPS", - Config.defaultDomain, - ) - |> optionWithDefault( - "--dir ", - "path to directory to save configuration file in", - Config.defaultDirectory, - ) - |> optionWithIntDefault( - "--concurrency ", - "limit the amount of concurrent HTTPS requests sent to GitLab when searching,\nuseful when *many* projects are hosted on a small GitLab instance\nto avoid overwhelming the instance resulting in 502 errors", - Config.defaultConcurrency, - ) - |> action(setup) -); - -Commander.parse(Node.Process.argv, program); - -// commander.js doesn't display help when no arguments are provided by default, -// so we've gotta do that check ourselfs -let args = Commander.getArgs(program); -if (Array.length(args) == 0) { - Commander.help(program); -}; diff --git a/src/Print.re b/src/Print.re deleted file mode 100644 index aa0f806..0000000 --- a/src/Print.re +++ /dev/null @@ -1,52 +0,0 @@ -open Chalk; -open Belt; - -let urlToLineInFile = (project: GitLab.project, result: GitLab.searchResult) => { - project.web_url - ++ "/blob/" - ++ result.ref - ++ "/" - ++ result.filename - ++ "#L" - ++ string_of_int(result.startline); -}; - -let indentPreview = preview => - Js.String.replaceByRe([%re "/\\n/g"], "\n\t\t", preview); - -let highlightMatchedTerm = (term, data) => - Js.String.replaceByRe( - Js.Re.fromStringWithFlags("(" ++ term ++ ")", ~flags="gi"), - // by using a regex catch group, we ensure to keep the original capitalization of - // the matched source code, rather than the search term entered by the end-user - red("$1"), - data, - ); - -let searchResults = - ( - term: string, - results: array((GitLab.project, array(GitLab.searchResult))), - ) => { - Array.forEach( - results, - result => { - let (project: GitLab.project, searchResults) = result; - let formattedResults = - Array.reduce(searchResults, "", (sum, current) => - sum - ++ "\n\t" - ++ underline(urlToLineInFile(project, current)) - ++ "\n\n\t\t" - ++ highlightMatchedTerm(term, indentPreview(current.data)) - ); - - let archivedInfo = project.archived ? bold(red(" (archived)")) : ""; - - Js.log(bold(green(project.name ++ archivedInfo ++ ":"))); - Js.log(formattedResults); - }, - ); -}; - -let successful = message => Js.log(green({js|✔|js}) ++ " " ++ message); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..d2158ef --- /dev/null +++ b/src/config.ts @@ -0,0 +1,112 @@ +import * as fs from "fs"; +import * as path from "path"; +import rc from "rc"; + +// Protocol enumeration +enum Protocol { + HTTP = "http", + HTTPS = "https", +} + +function protocolFromString(protocol: string): Protocol { + switch (protocol.toLowerCase()) { + case "http": + return Protocol.HTTP; + case "https": + return Protocol.HTTPS; + default: + throw new Error(`Invalid protocol: ${protocol}`); + } +} + +type Config = { + domain: string; + token: string; + ignoreSSL: boolean; + protocol: Protocol; + concurrency: number; +}; + +// Serialized configuration type for file storage +type SerializedConfig = { + domain?: string; + token?: string; + ignoreSSL?: boolean; + concurrency?: number; +}; + +// Default values +const DEFAULT_DOMAIN = "gitlab.com"; +const DEFAULT_DIRECTORY = "."; +const DEFAULT_CONCURRENCY = 25; + +// Utility: Parse protocol and domain from a string +function parseProtocolAndDomain(rootApiUriOrOnlyDomain: string): { + protocol: Protocol; + domain: string; +} { + const parts = rootApiUriOrOnlyDomain.toLowerCase().split("://"); + + if (parts.length === 2) { + const [protocol, domain] = parts; + return { protocol: protocolFromString(protocol), domain }; + } + + if (parts.length === 1) { + return { protocol: Protocol.HTTPS, domain: parts[0] }; + } + + throw new Error( + `Configured API domain does not look like a valid domain or root GitLab API URI: ${rootApiUriOrOnlyDomain}` + ); +} + +function loadFromFile(): Config | never { + const result = rc("gitlabsearch"); + + const ignoreSSL = result.ignoreSSL ?? false; + const concurrency = result.concurrency ?? DEFAULT_CONCURRENCY; + + const { protocol, domain } = parseProtocolAndDomain( + result.domain ?? DEFAULT_DOMAIN + ); + + if (result.token) { + return { + domain, + token: result.token, + ignoreSSL, + protocol, + concurrency, + }; + } + + throw new Error( + `Could not find personal access token in the configuration. Have you run setup yet?` + ); +} + +function writeToFile( + domainOrRootUri: string, + ignoreSSL: boolean, + token: string, + directory: string = DEFAULT_DIRECTORY, + concurrency: number = DEFAULT_CONCURRENCY +): string { + const filePath = path.join(directory, ".gitlabsearchrc"); + + const serializedConfig: SerializedConfig = { + domain: domainOrRootUri !== DEFAULT_DOMAIN ? domainOrRootUri : undefined, + ignoreSSL: ignoreSSL || undefined, + token, + concurrency: concurrency !== DEFAULT_CONCURRENCY ? concurrency : undefined, + }; + + const content = JSON.stringify(serializedConfig, null, 2); + + fs.writeFileSync(filePath, content, "utf-8"); + + return filePath; +} + +export { Protocol, Config, loadFromFile, writeToFile }; diff --git a/src/gitlab.ts b/src/gitlab.ts new file mode 100644 index 0000000..8866fcf --- /dev/null +++ b/src/gitlab.ts @@ -0,0 +1,242 @@ +import * as https from "https"; +import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; +import * as Config from "./config"; + +type Group = { + id: string; + name: string; +}; + +type Project = { + id: number; + name: string; + web_url: string; + archived: boolean; +}; + +type SearchFilter = + | { type: "Filename"; value?: string } + | { type: "Extension"; value?: string } + | { type: "Path"; value?: string }; + +type SearchCriterias = { + term: string; + filters: SearchFilter[]; +}; + +type SearchResult = { + data: string; + filename: string; + ref: string; + startline: number; +}; + +const DEBUG_ENV = process.env.DEBUG; +function debugLog(message: string): void { + if (DEBUG_ENV) { + console.log(message); + } +} + +// Configuration loading +let configResult: Config.Config | undefined; +let httpsAgent: https.Agent | undefined; + +// Initiates configuration needed for the GitLab client to work, +// will `throw` if the configuration is missing or invalid +function initGitLabClient(): void { + const cfg = Config.loadFromFile(); + configResult = cfg; + + if (cfg.protocol === Config.Protocol.HTTPS) { + httpsAgent = new https.Agent({ + rejectUnauthorized: !cfg.ignoreSSL, + maxSockets: cfg.concurrency, + }); + } +} + +async function request( + relativeUrl: string, + decoder: (json: any) => T +): Promise { + if (!configResult) { + throw new Error("Configuration is missing or invalid."); + } + + const headers = { "Private-Token": configResult.token }; + const scheme = + configResult.protocol === Config.Protocol.HTTPS ? "https://" : "http://"; + const url = `${scheme}${configResult.domain}/api/v4${relativeUrl}`; + + debugLog(`Requesting: GET ${url}`); + + const options: AxiosRequestConfig = { + headers, + httpsAgent, + }; + + const response = await axios.get(url, options); + return decoder(response.data); +} + +// Decode functions +const decodeGroup = (json: any): Group => ({ + id: json.id.toString(), + name: json.name, +}); + +const decodeGroups = (json: any): Group[] => json.map(decodeGroup); + +const decodeProject = (json: any): Project => ({ + id: json.id, + name: json.name, + web_url: json.web_url, + archived: json.archived, +}); + +const decodeProjects = (json: any): Project[] => json.map(decodeProject); + +const decodeSearchResult = (json: any): SearchResult => ({ + data: json.data, + filename: json.filename, + ref: json.ref, + startline: json.startline, +}); + +const decodeSearchResults = ( + project: Project, + json: any +): [Project, SearchResult[]] => [project, json.map(decodeSearchResult)]; + +async function paginatedRequest( + relativeUrl: string, + decoder: (json: any) => T[] +): Promise { + if (!configResult) { + throw new Error("Configuration is missing or invalid."); + } + + const url = + String(configResult.protocol) + + "://" + + configResult.domain + + "/api/v4" + + relativeUrl; + + let nextUrl: string | undefined = url; + let results: T[] = []; + + while (nextUrl) { + debugLog(`Requesting: GET ${nextUrl}`); + const response: AxiosResponse = await axios.get(nextUrl, { + headers: { "Private-Token": configResult.token }, + httpsAgent, + }); + + results = results.concat(decoder(response.data)); + nextUrl = getNextPaginationUrl(response); + } + + return results; +} + +function getNextPaginationUrl(response: AxiosResponse): string | undefined { + const linkHeader = response.headers.link; + if (!linkHeader) return undefined; + + const matches = linkHeader.match(/<([^>]+)>;\s*rel="next"/); + return matches ? matches[1] : undefined; +} + +// https://docs.gitlab.com/ee/api/groups.html#list-groups +async function fetchGroups(groupNames?: string): Promise { + if (groupNames) { + const names = groupNames.split(","); + return names.map((name) => ({ id: name, name })); + } + + return paginatedRequest("/groups?per_page=100", decodeGroups); +} + +// https://docs.gitlab.com/ee/api/groups.html#list-a-groups-projects +async function fetchProjectsInGroups( + archiveOption: string | undefined, + groups: Group[] +): Promise { + const archiveQueryParam = + archiveOption === "only" + ? "&archived=true" + : archiveOption === "exclude" + ? "&archived=false" + : ""; + + const projectRequests = groups.map((group) => + paginatedRequest( + `/groups/${group.id}/projects?per_page=100${archiveQueryParam}`, + decodeProjects + ) + ); + + const projectsArray = await Promise.all(projectRequests); + const allProjects = projectsArray.flat(); + + debugLog( + `Using projects: ${allProjects.map((project) => project.name).join(", ")}` + ); + return allProjects; +} + +function buildSearchUrlParams(criterias: SearchCriterias): string { + const filters = criterias.filters + .map((filter) => { + switch (filter.type) { + case "Filename": + return filter.value + ? `filename:${encodeURIComponent(filter.value)}` + : null; + case "Extension": + return filter.value + ? `extension:${encodeURIComponent(filter.value)}` + : null; + case "Path": + return filter.value + ? `path:${encodeURIComponent(filter.value)}` + : null; + } + }) + .filter(Boolean) + .join(" "); + + return `&search=${encodeURIComponent(criterias.term)} ${filters}`; +} + +// https://docs.gitlab.com/ee/api/search.html#scope-blobs-2 +async function searchInProjects( + criterias: SearchCriterias, + projects: Project[] +): Promise<[Project, SearchResult[]][]> { + const searchRequests = projects.map((project) => + request( + `/projects/${project.id}/search?scope=blobs${buildSearchUrlParams( + criterias + )}`, + (json) => decodeSearchResults(project, json) + ) + ); + + const results = await Promise.all(searchRequests); + return results.filter(([_, searchResults]) => searchResults.length > 0); +} + +export { + fetchGroups, + fetchProjectsInGroups, + initGitLabClient, + searchInProjects, + SearchCriterias, + SearchFilter, + SearchResult, + Group, + Project, +}; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..9e0ce64 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,118 @@ +import { Command } from "commander"; +import { + fetchGroups, + fetchProjectsInGroups, + initGitLabClient, + SearchCriterias, + searchInProjects, +} from "./gitlab"; +import { writeToFile } from "./config"; +import { searchResults, successful } from "./print"; +import packageJson from "../package.json"; + +const program = new Command(); +program.version(packageJson.version); + +async function main(args: string[], options: Record) { + const groupsOpt = options.groups; + + const criterias: SearchCriterias = { + term: args[0], // Unsafe access assumed to be handled by Commander validation + filters: [ + { type: "Filename", value: options.filename }, + { type: "Extension", value: options.extension }, + { type: "Path", value: options.path }, + ], + }; + + try { + initGitLabClient(); + + const groups = await fetchGroups(groupsOpt); + const projectData = await fetchProjectsInGroups(options.archive, groups); + const results = await searchInProjects(criterias, projectData); + searchResults(criterias.term, results); + } catch (err) { + console.error("Something went wrong!", err); + } +} + +// Setup functionality for configuration +function setup(args: string, options: Record) { + const token = args; // Required argument validated by Commander + const directory = options.dir; + const domainOrRootUri = options.apiDomain; + const concurrency = options.concurrency; + const ignoreSSL = options.ignoreSsl === true; + + const configPath = writeToFile( + domainOrRootUri, + ignoreSSL, + token, + directory, + concurrency + ); + + successful( + `Successfully wrote config to ${configPath}, gitlab-search is now ready to be used.` + ); +} + +// Define the main search command +program + .arguments("") + .option( + "-g, --groups ", + "Group(s) to find repositories in (comma-separated)" + ) + .option( + "-f, --filename ", + "Only search for contents in a given file, supports glob matching with wildcards (*)" + ) + .option( + "-e, --extension ", + "Only search for contents in files with a given extension" + ) + .option("-p, --path ", "Only search in files in the given path") + .option( + "-a, --archive [all|only|exclude]", + "Search on archived repositories, exclude them, or apply to all (default: all)", + "all" // Default value for archive + ) + .action(main); + +// Define the setup command +program + .command("setup") + .description("Create configuration file") + .arguments("") + .option( + "--ignore-ssl", + "Ignore invalid SSL certificate from the GitLab API server" + ) + .option( + "--api-domain ", + "Domain name or root URL of GitLab API server.\nSpecify root URL (without trailing slash) to use HTTP instead of HTTPS", + "gitlab.com" // Default domain + ) + .option( + "--dir ", + "Path to the directory to save the configuration file in", + "." // Default directory + ) + .option( + "--concurrency ", + "Limit the number of concurrent HTTPS requests sent to GitLab when searching.\n" + + "Useful when many projects are hosted on a small GitLab instance to avoid overwhelming it, resulting in 502 errors", + parseInt, // Parse to integer + 25 // Default concurrency + ) + .action(setup); + +// Parse command-line arguments +program.parse(process.argv); + +// Display help if no arguments are provided +if (process.argv.length <= 2) { + program.help(); +} diff --git a/src/print.ts b/src/print.ts new file mode 100644 index 0000000..396201c --- /dev/null +++ b/src/print.ts @@ -0,0 +1,68 @@ +import chalk from "chalk"; + +// Types for GitLab data +type GitLabProject = { + name: string; + web_url: string; + archived: boolean; +}; + +type GitLabSearchResult = { + ref: string; + filename: string; + startline: number; + data: string; +}; + +// Function to create a URL pointing to a specific line in a file +function urlToLineInFile( + project: GitLabProject, + result: GitLabSearchResult +): string { + return `${project.web_url}/blob/${result.ref}/${result.filename}#L${result.startline}`; +} + +// Function to indent multiline previews +function indentPreview(preview: string): string { + return preview.replace(/\n/g, "\n\t\t"); +} + +// Function to highlight matched terms in search results +function highlightMatchedTerm(term: string, data: string): string { + const regex = new RegExp(`(${term})`, "gi"); + return data.replace(regex, chalk.red("$1")); +} + +// Function to display search results +function searchResults( + term: string, + results: Array<[GitLabProject, GitLabSearchResult[]]> +): void { + results.forEach(([project, searchResults]) => { + const formattedResults = searchResults.reduce((sum, current) => { + const resultUrl = urlToLineInFile(project, current); + const highlightedPreview = highlightMatchedTerm( + term, + indentPreview(current.data) + ); + + return ( + sum + `\n\t${chalk.underline(resultUrl)}\n\n\t\t${highlightedPreview}` + ); + }, ""); + + const archivedInfo = project.archived + ? chalk.bold(chalk.red(" (archived)")) + : ""; + + console.log(chalk.bold(chalk.green(`${project.name}${archivedInfo}:`))); + console.log(formattedResults); + }); +} + +// Function to display success messages +function successful(message: string): void { + console.log(`${chalk.green("✔")} ${message}`); +} + +export { searchResults, successful }; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a2f32fa --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2016", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "typeRoots": ["node_modules/@types"], + "types": ["node"], + "resolveJsonModule": true, + "outDir": "./dist", + }, + "exclude": ["node_modules"], +}