diff --git a/.all-contributorsrc b/.all-contributorsrc deleted file mode 100644 index 1f65965..0000000 --- a/.all-contributorsrc +++ /dev/null @@ -1,15 +0,0 @@ -{ - "badgeTemplate": "\t\"๐Ÿ‘ช\" src=\"https://img.shields.io/badge/%F0%9F%91%AA_all_contributors-<%= contributors.length %>-21bb42.svg\" />", - "commit": false, - "commitConvention": "angular", - "commitType": "docs", - "contributors": [], - "contributorsPerLine": 7, - "contributorsSortAlphabetically": true, - "files": ["README.md"], - "imageSize": 100, - "projectName": "ado-npm-auth-lite", - "projectOwner": "johnnyreilly", - "repoHost": "https://github.com", - "repoType": "github" -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a1ad84..26a5ea7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ jobs: - uses: ./.github/actions/prepare - run: pnpm build - run: node ./lib/index.js + lint: name: Lint runs-on: ubuntu-latest @@ -15,6 +16,7 @@ jobs: - uses: ./.github/actions/prepare - run: pnpm build - run: pnpm lint + prettier: name: Prettier runs-on: ubuntu-latest @@ -22,6 +24,7 @@ jobs: - uses: actions/checkout@v4 - uses: ./.github/actions/prepare - run: pnpm format --list-different + test: name: Test runs-on: ubuntu-latest @@ -30,6 +33,7 @@ jobs: - uses: ./.github/actions/prepare - run: pnpm run test --coverage - uses: codecov/codecov-action@v3 + type_check: name: Type Check runs-on: ubuntu-latest @@ -38,6 +42,15 @@ jobs: - uses: ./.github/actions/prepare - run: pnpm tsc + are_the_types_wrong: + name: Are the types wrong? + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/prepare + - run: pnpm build + - run: npx --yes @arethetypeswrong/cli --pack . --ignore-rules cjs-resolves-to-esm + name: CI on: diff --git a/.gitignore b/.gitignore index a8bff3b..f7efb75 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ coverage/ lib/ node_modules/ +ignore/ diff --git a/.vscode/settings.json b/.vscode/settings.json index a09020e..c3879df 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,6 @@ { - "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, - "editor.rulers": [80], "eslint.probe": [ "javascript", "javascriptreact", diff --git a/README.md b/README.md index 1f4e594..e5a5760 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@

Set up local authentication to Azure DevOps npm feeds

+ ๐Ÿค Code of Conduct: Kept ๐Ÿค Code of Conduct: Kept - ๐Ÿงช Coverage ๐Ÿ“ License: MIT ๐Ÿ“ฆ npm version ๐Ÿ’ช TypeScript: Strict @@ -12,16 +12,51 @@ ## Usage +To use `ado-npm-auth-lite` do this: + ```shell -npm i ado-npm-auth-lite +az login +npx --yes ado-npm-auth-lite --config .npmrc ``` -```ts -import { greet } from "ado-npm-auth-lite"; +Typically `ado-npm-auth-lite` will be used as part of a `preinstall` script in your `package.json`: -greet("Hello, world! ๐Ÿ’–"); +```json +"scripts": { + "preinstall": "az login && npx --yes ado-npm-auth-lite" +}, ``` +This will ensure that the necessary authentication is set up before any `npm install` commands are run. + +The `az login` command will prompt you to log in to Azure, so that a token may be acquired. It is not necessary to run this command if you are already logged in. The `config` is optional, and if not supplied will default to the `.npmrc` in the project directory. Crucially, `ado-npm-auth-lite` requires the project `.npmrc` in order to operate. + +## Why Azure DevOps npm auth-lite? + +Azure DevOps provides a mechanism for publishing npm packages for private use. This package sets up the necessary authentication to access those packages; particularly for non Windows users. + +Consider the onboarding process for a Windows user: + +![screenshot of the onboarding process for Windows users](screenshot-onboarding-with-windows.png) + +Now consider the onboarding process for a non Windows user: + +![screenshot of the onboarding process for non Windows users](screenshot-onboarding-with-other.png) + +This is a significant difference in the onboarding experience. `ado-npm-auth-lite` aims to make the onboarding experience for non Windows users as simple as it is for Windows users. + +There is an official package named [`ado-npm-auth`](https://github.com/microsoft/ado-npm-auth). However, due to issues experienced in using that package, this was created. + +## Options + +`-c` | `--config` (`string`): The location of the .npmrc file. Defaults to current directory + +`-e` | `--email` (`string`): Allows users to supply an explicit email - if not supplied, will be inferred from git user.config + +`-h` | `--help`: Show help + +`-v` | `--version`: Show version + > ๐Ÿ’™ This package was templated with [`create-typescript-app`](https://github.com/JoshuaKGoldberg/create-typescript-app). diff --git a/bin/index.js b/bin/index.js new file mode 100755 index 0000000..1b7a284 --- /dev/null +++ b/bin/index.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { bin } from "../lib/bin/index.js"; + +process.exitCode = await bin(process.argv.slice(2)); diff --git a/eslint.config.js b/eslint.config.js index 67f94f8..a301db8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -48,36 +48,51 @@ export default tseslint.config( }, }, rules: { + // These on-by-default rules work well for this repo if configured + "@typescript-eslint/no-unused-vars": [ + "error", + { + args: "all", + argsIgnorePattern: "^_", + caughtErrors: "all", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + varsIgnorePattern: "^_", + ignoreRestSiblings: true, + }, + ], + // These on-by-default rules don't work well for this repo and we like them off. + "jsdoc/lines-before-block": "off", + // These off-by-default rules work well for this repo and we like them on. "logical-assignment-operators": [ "error", "always", { enforceForIfStatements: true }, ], - "operator-assignment": "error", - - // These on-by-default rules don't work well for this repo and we like them off. - "jsdoc/lines-before-block": "off", - "no-constant-condition": "off", - - // These on-by-default rules work well for this repo if configured - "@typescript-eslint/no-unused-vars": ["error", { caughtErrors: "all" }], "n/no-unsupported-features/node-builtins": [ "error", { allowExperimental: true }, ], - "perfectionist/sort-objects": [ - "error", - { - order: "asc", - partitionByComment: true, - type: "natural", - }, - ], + "no-constant-condition": "off", // Stylistic concerns that don't interfere with Prettier "no-useless-rename": "error", "object-shorthand": "error", + // "perfectionist/sort-objects": [ + // "error", + // { + // order: "asc", + // partitionByComment: true, + // type: "natural", + // }, + // ], + + "operator-assignment": "error", + "perfectionist/sort-objects": ["off"], + "perfectionist/sort-object-types": ["off"], + + "jsdoc/match-description": "off", }, }), { diff --git a/package.json b/package.json index b4f65da..486fbb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ado-npm-auth-lite", - "version": "0.0.0", + "version": "0.1.0", "description": "Set up local authentication to Azure DevOps npm feeds", "repository": { "type": "git", @@ -12,10 +12,17 @@ "email": "johnny_reilly@hotmail.com" }, "type": "module", - "main": "./lib/index.js", - "bin": "bin", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "import": "./lib/index.js" + } + }, + "module": "./lib/index.js", + "types": "./lib/index.d.ts", + "bin": "./bin/index.js", "files": [ - "bin", + "bin/index.js", "lib/", "package.json", "LICENSE.md", @@ -26,12 +33,20 @@ "format": "prettier .", "lint": "eslint . --max-warnings 0", "prepare": "husky", + "start": "pnpm run build && node ./bin/index.js", "test": "vitest", "tsc": "tsc" }, "lint-staged": { "*": "prettier --ignore-unknown --write" }, + "dependencies": { + "@azure/identity": "^4.5.0", + "@clack/prompts": "^0.7.0", + "chalk": "^5.3.0", + "zod": "^3.23.8", + "zod-validation-error": "^3.3.1" + }, "devDependencies": { "@eslint-community/eslint-plugin-eslint-comments": "^4.4.1", "@eslint/js": "^9.14.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbcd05a..8e7d9d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,22 @@ settings: importers: .: + dependencies: + '@azure/identity': + specifier: ^4.5.0 + version: 4.5.0 + '@clack/prompts': + specifier: ^0.7.0 + version: 0.7.0 + chalk: + specifier: ^5.3.0 + version: 5.3.0 + zod: + specifier: ^3.23.8 + version: 3.23.8 + zod-validation-error: + specifier: ^3.3.1 + version: 3.4.0(zod@3.23.8) devDependencies: '@eslint-community/eslint-plugin-eslint-comments': specifier: ^4.4.1 @@ -96,6 +112,50 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@azure/abort-controller@2.1.2': + resolution: {integrity: sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==} + engines: {node: '>=18.0.0'} + + '@azure/core-auth@1.9.0': + resolution: {integrity: sha512-FPwHpZywuyasDSLMqJ6fhbOK3TqUdviZNF8OqRGA4W5Ewib2lEEZ+pBsYcBa88B2NGO/SEnYPGhyBqNlE8ilSw==} + engines: {node: '>=18.0.0'} + + '@azure/core-client@1.9.2': + resolution: {integrity: sha512-kRdry/rav3fUKHl/aDLd/pDLcB+4pOFwPPTVEExuMyaI5r+JBbMWqRbCY1pn5BniDaU3lRxO9eaQ1AmSMehl/w==} + engines: {node: '>=18.0.0'} + + '@azure/core-rest-pipeline@1.17.0': + resolution: {integrity: sha512-62Vv8nC+uPId3j86XJ0WI+sBf0jlqTqPUFCBNrGtlaUeQUIXWV/D8GE5A1d+Qx8H7OQojn2WguC8kChD6v0shA==} + engines: {node: '>=18.0.0'} + + '@azure/core-tracing@1.2.0': + resolution: {integrity: sha512-UKTiEJPkWcESPYJz3X5uKRYyOcJD+4nYph+KpfdPRnQJVrZfk0KJgdnaAWKfhsBBtAf/D58Az4AvCJEmWgIBAg==} + engines: {node: '>=18.0.0'} + + '@azure/core-util@1.11.0': + resolution: {integrity: sha512-DxOSLua+NdpWoSqULhjDyAZTXFdP/LKkqtYuxxz1SCN289zk3OG8UOpnCQAz/tygyACBtWp/BoO72ptK7msY8g==} + engines: {node: '>=18.0.0'} + + '@azure/identity@4.5.0': + resolution: {integrity: sha512-EknvVmtBuSIic47xkOqyNabAme0RYTw52BTMz8eBgU1ysTyMrD1uOoM+JdS0J/4Yfp98IBT3osqq3BfwSaNaGQ==} + engines: {node: '>=18.0.0'} + + '@azure/logger@1.1.4': + resolution: {integrity: sha512-4IXXzcCdLdlXuCG+8UKEwLA1T1NHqUfanhXYHiQTn+6sfWCZXduqbtXDGceg3Ce5QxTGo7EqmbV6Bi+aqKuClQ==} + engines: {node: '>=18.0.0'} + + '@azure/msal-browser@3.26.1': + resolution: {integrity: sha512-y78sr9g61aCAH9fcLO1um+oHFXc1/5Ap88RIsUSuzkm0BHzFnN+PXGaQeuM1h5Qf5dTnWNOd6JqkskkMPAhh7Q==} + engines: {node: '>=0.8.0'} + + '@azure/msal-common@14.15.0': + resolution: {integrity: sha512-ImAQHxmpMneJ/4S8BRFhjt1MZ3bppmpRPYYNyzeQPeFN288YKbb8TmmISQEbtfkQ1BPASvYZU5doIZOPBAqENQ==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@2.15.0': + resolution: {integrity: sha512-gVPW8YLz92ZeCibQH2QUw96odJoiM3k/ZPH3f2HxptozmH6+OnyyvKXo/Egg39HAM230akarQKHf0W74UHlh0Q==} + engines: {node: '>=16'} + '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -132,6 +192,14 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@clack/core@0.3.4': + resolution: {integrity: sha512-H4hxZDXgHtWTwV3RAVenqcC4VbJZNegbBjlPvzOzCouXtS2y3sDvlO3IsbrPNWuLWPPlYVYPghQdSF64683Ldw==} + + '@clack/prompts@0.7.0': + resolution: {integrity: sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA==} + bundledDependencies: + - is-unicode-supported + '@conventional-changelog/git-client@1.0.1': resolution: {integrity: sha512-PJEqBwAleffCMETaVm/fUgHldzBE35JFk3/9LL6NUA5EXa3qednu+UT6M7E5iBu3zIQZCULYIiZ90fBYHt6xUw==} engines: {node: '>=18'} @@ -957,6 +1025,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1206,6 +1277,10 @@ packages: defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + define-lazy-prop@3.0.0: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} @@ -1248,6 +1323,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + emoji-regex@10.4.0: resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} @@ -1415,6 +1493,10 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -1700,6 +1782,11 @@ packages: resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} engines: {node: '>= 0.4'} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1794,6 +1881,10 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + is-wsl@3.1.0: resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} engines: {node: '>=16'} @@ -1866,6 +1957,22 @@ packages: jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jwa@1.4.1: + resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + + jwa@2.0.0: + resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + + jws@4.0.0: + resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1911,6 +2018,18 @@ packages: lodash.escaperegexp@4.1.2: resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} @@ -1920,6 +2039,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} @@ -2084,6 +2206,10 @@ packages: resolution: {integrity: sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==} engines: {node: '>=18'} + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2430,6 +2556,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -2516,6 +2645,10 @@ packages: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} + stoppable@1.1.0: + resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} + engines: {node: '>=4', npm: '>=6'} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -2731,6 +2864,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -2887,6 +3024,15 @@ packages: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} + zod-validation-error@3.4.0: + resolution: {integrity: sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.18.0 + + zod@3.23.8: + resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + snapshots: '@altano/repository-tools@0.1.1': {} @@ -2896,6 +3042,85 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 + '@azure/abort-controller@2.1.2': + dependencies: + tslib: 2.8.1 + + '@azure/core-auth@1.9.0': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-util': 1.11.0 + tslib: 2.8.1 + + '@azure/core-client@1.9.2': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.9.0 + '@azure/core-rest-pipeline': 1.17.0 + '@azure/core-tracing': 1.2.0 + '@azure/core-util': 1.11.0 + '@azure/logger': 1.1.4 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-rest-pipeline@1.17.0': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.9.0 + '@azure/core-tracing': 1.2.0 + '@azure/core-util': 1.11.0 + '@azure/logger': 1.1.4 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.5 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-tracing@1.2.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-util@1.11.0': + dependencies: + '@azure/abort-controller': 2.1.2 + tslib: 2.8.1 + + '@azure/identity@4.5.0': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.9.0 + '@azure/core-client': 1.9.2 + '@azure/core-rest-pipeline': 1.17.0 + '@azure/core-tracing': 1.2.0 + '@azure/core-util': 1.11.0 + '@azure/logger': 1.1.4 + '@azure/msal-browser': 3.26.1 + '@azure/msal-node': 2.15.0 + events: 3.3.0 + jws: 4.0.0 + open: 8.4.2 + stoppable: 1.1.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/logger@1.1.4': + dependencies: + tslib: 2.8.1 + + '@azure/msal-browser@3.26.1': + dependencies: + '@azure/msal-common': 14.15.0 + + '@azure/msal-common@14.15.0': {} + + '@azure/msal-node@2.15.0': + dependencies: + '@azure/msal-common': 14.15.0 + jsonwebtoken: 9.0.2 + uuid: 8.3.2 + '@babel/code-frame@7.26.2': dependencies: '@babel/helper-validator-identifier': 7.25.9 @@ -2943,6 +3168,17 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@clack/core@0.3.4': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@0.7.0': + dependencies: + '@clack/core': 0.3.4 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@conventional-changelog/git-client@1.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.0.0)': dependencies: '@types/semver': 7.5.8 @@ -3633,6 +3869,8 @@ snapshots: dependencies: fill-range: 7.1.1 + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -3868,6 +4106,8 @@ snapshots: dependencies: clone: 1.0.4 + define-lazy-prop@2.0.0: {} + define-lazy-prop@3.0.0: {} degenerator@5.0.1: @@ -3900,6 +4140,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + emoji-regex@10.4.0: {} emoji-regex@8.0.0: {} @@ -4145,6 +4389,8 @@ snapshots: eventemitter3@5.0.1: {} + events@3.3.0: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.3 @@ -4470,6 +4716,8 @@ snapshots: dependencies: hasown: 2.0.2 + is-docker@2.2.1: {} + is-docker@3.0.0: {} is-extglob@2.1.1: {} @@ -4527,6 +4775,10 @@ snapshots: is-unicode-supported@2.1.0: {} + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + is-wsl@3.1.0: dependencies: is-inside-container: 1.0.0 @@ -4603,6 +4855,41 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.6.3 + + jwa@1.4.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jwa@2.0.0: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.1 + safe-buffer: 5.2.1 + + jws@4.0.0: + dependencies: + jwa: 2.0.0 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -4656,12 +4943,22 @@ snapshots: lodash.escaperegexp@4.1.2: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + lodash.isplainobject@4.0.6: {} lodash.isstring@4.0.1: {} lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash.sortby@4.7.0: {} lodash.uniqby@4.7.0: {} @@ -4808,6 +5105,12 @@ snapshots: is-inside-container: 1.0.0 is-wsl: 3.1.0 + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -5196,6 +5499,8 @@ snapshots: signal-exit@4.1.0: {} + sisteransi@1.0.5: {} + slash@3.0.0: {} slash@4.0.0: {} @@ -5286,6 +5591,8 @@ snapshots: stdin-discarder@0.2.2: {} + stoppable@1.1.0: {} + string-argv@0.3.2: {} string-width@4.2.3: @@ -5488,6 +5795,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@8.3.2: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -5640,3 +5949,9 @@ snapshots: yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.2: {} + + zod-validation-error@3.4.0(zod@3.23.8): + dependencies: + zod: 3.23.8 + + zod@3.23.8: {} diff --git a/screenshot-onboarding-with-other.png b/screenshot-onboarding-with-other.png new file mode 100644 index 0000000..1aaee73 Binary files /dev/null and b/screenshot-onboarding-with-other.png differ diff --git a/screenshot-onboarding-with-windows.png b/screenshot-onboarding-with-windows.png new file mode 100644 index 0000000..6c4ef30 Binary files /dev/null and b/screenshot-onboarding-with-windows.png differ diff --git a/src/bin/help.test.ts b/src/bin/help.test.ts new file mode 100644 index 0000000..055a3ef --- /dev/null +++ b/src/bin/help.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, MockInstance, vi } from "vitest"; + +import { logHelpText } from "./help.js"; + +function makeProxy(receiver: T): T { + return new Proxy(receiver, { + get: () => makeProxy((input: string) => input), + }); +} + +vi.mock("chalk", () => ({ + default: makeProxy({}), +})); + +let mockConsoleLog: MockInstance; + +describe("logHelpText", () => { + beforeEach(() => { + mockConsoleLog = vi + .spyOn(console, "log") + .mockImplementation(() => undefined); + }); + + it("logs help text when called", () => { + logHelpText([]); + + expect(mockConsoleLog.mock.calls).toMatchInlineSnapshot(` + [ + [ + "Configure local development environments for Azure apps with one command", + ], + [ + " ", + ], + [ + "Core options:", + ], + [ + " + -h | --help: Show help", + ], + [ + " + -v | --version: Show version", + ], + [], + [ + " ", + ], + [ + "Optional options:", + ], + [ + " + -c | --config (string): The location of the .npmrc file. Defaults to current directory", + ], + [ + " + -e | --email (string): Allows users to supply an explicit email - if not supplied, will be inferred from git user.config", + ], + [], + ] + `); + }); +}); diff --git a/src/bin/help.ts b/src/bin/help.ts new file mode 100644 index 0000000..6e8d552 --- /dev/null +++ b/src/bin/help.ts @@ -0,0 +1,103 @@ +import chalk from "chalk"; + +import { allArgOptions } from "../shared/options/args.js"; + +interface HelpTextSection { + sectionHeading: string; + subsections: { + flags: SubsectionFlag[]; + subheading?: string; + warning?: string; + }[]; +} + +interface SubsectionFlag { + description: string; + flag: string; + short: string; + type: string; +} + +function logHelpTextSection(section: HelpTextSection): void { + console.log(" "); + + console.log(chalk.black.bgGreenBright(section.sectionHeading)); + + for (const subsection of section.subsections) { + if (subsection.warning) { + console.log(chalk.yellow(subsection.warning)); + } + + if (subsection.subheading) { + console.log(chalk.green(subsection.subheading)); + } + + for (const { description, flag, short, type } of subsection.flags) { + console.log( + chalk.cyan( + ` + -${short} | --${flag}${ + type !== "boolean" ? ` (${chalk.cyanBright(type)})` : "" + }: ${description}`, + ), + ); + } + } +} + +function createHelpTextSections(): HelpTextSection[] { + const core: HelpTextSection = { + sectionHeading: "Core options:", + subsections: [ + { + flags: [], + }, + ], + }; + + const optional: HelpTextSection = { + sectionHeading: "Optional options:", + subsections: [ + { + flags: [], + }, + ], + }; + + const subsections = { + core: core.subsections[0], + optional: optional.subsections[0], + }; + + for (const [option, data] of Object.entries(allArgOptions)) { + subsections[data.docsSection].flags.push({ + description: data.description, + flag: option, + short: data.short, + type: data.type, + }); + } + + return [core, optional]; +} + +export function logHelpText(introLogs: string[]): void { + const helpTextSections = createHelpTextSections(); + + for (const log of introLogs) { + console.log(log); + console.log(" "); + } + + console.log( + chalk.cyan( + `Configure local development environments for Azure apps with one command`, + ), + ); + + for (const section of helpTextSections) { + logHelpTextSection(section); + + console.log(); + } +} diff --git a/src/bin/index.test.ts b/src/bin/index.test.ts new file mode 100644 index 0000000..2c5f635 --- /dev/null +++ b/src/bin/index.test.ts @@ -0,0 +1,212 @@ +import chalk from "chalk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import z from "zod"; + +import { bin } from "./index.js"; +import { getVersionFromPackageJson } from "./packageJson.js"; + +const mockCancel = vi.fn(); +const mockOutro = vi.fn(); + +vi.mock("../cli/spinners.ts", () => ({ + withSpinner() { + return () => ({}); + }, +})); + +vi.mock("@clack/prompts", () => ({ + get cancel() { + return mockCancel; + }, + intro: vi.fn(), + log: { + info: vi.fn(), + }, + get outro() { + return mockOutro; + }, + spinner: vi.fn(), +})); + +const mockLogLine = vi.fn(); + +vi.mock("../shared/cli/lines.js", () => ({ + get logLine() { + return mockLogLine; + }, +})); + +const mockCreate = vi.fn(); + +vi.mock("../create/index.js", () => ({ + get create() { + return mockCreate; + }, +})); + +const mockInitialize = vi.fn(); + +vi.mock("../initialize/index.js", () => ({ + get initialize() { + return mockInitialize; + }, +})); + +const mockMigrate = vi.fn(); + +vi.mock("../migrate/index.js", () => ({ + get migrate() { + return mockMigrate; + }, +})); + +const mockPromptForMode = vi.fn(); + +vi.mock("./promptForMode.js", () => ({ + get promptForMode() { + return mockPromptForMode; + }, +})); + +describe("bin", () => { + beforeEach(() => { + vi.spyOn(console, "clear").mockImplementation(() => undefined); + vi.spyOn(console, "log").mockImplementation(() => undefined); + }); + + it.skip("returns 1 when promptForMode returns an undefined mode", async () => { + mockPromptForMode.mockResolvedValue({}); + + const result = await bin([]); + + expect(mockOutro).toHaveBeenCalledWith( + chalk.red("Operation cancelled. Exiting - maybe another time? ๐Ÿ‘‹"), + ); + expect(result).toBe(1); + }); + + it.skip("returns 1 when promptForMode returns an error mode", async () => { + const error = new Error("Oh no!"); + mockPromptForMode.mockResolvedValue({ mode: error }); + + const result = await bin([]); + + expect(mockOutro).toHaveBeenCalledWith(chalk.red(error.message)); + expect(result).toBe(1); + }); + + it.skip("returns the success result of the corresponding runner without cancel logging when promptForMode returns a mode that succeeds", async () => { + const mode = "create"; + const args = ["--owner", "abc123"]; + const code = 0; + const promptedOptions = { directory: "." }; + + mockPromptForMode.mockResolvedValue({ mode, options: promptedOptions }); + mockCreate.mockResolvedValue({ code, options: {} }); + + const result = await bin(args); + + expect(mockCreate).toHaveBeenCalledWith(args, promptedOptions); + expect(mockCancel).not.toHaveBeenCalled(); + expect(mockInitialize).not.toHaveBeenCalled(); + expect(mockMigrate).not.toHaveBeenCalled(); + expect(result).toEqual(code); + }); + + it.skip("returns the cancel result of the corresponding runner and cancel logs when promptForMode returns a mode that cancels", async () => { + const mode = "create"; + const args = ["--owner", "abc123"]; + const code = 2; + const promptedOptions = { directory: "." }; + + mockPromptForMode.mockResolvedValue({ mode, options: promptedOptions }); + mockCreate.mockResolvedValue({ code, options: {} }); + + const result = await bin(args); + + expect(mockCreate).toHaveBeenCalledWith(args, promptedOptions); + expect(mockCancel).toHaveBeenCalledWith( + `Operation cancelled. Exiting - maybe another time? ๐Ÿ‘‹`, + ); + expect(result).toEqual(code); + }); + + it.skip("returns the cancel result containing a zod error of the corresponding runner and output plus cancel logs when promptForMode returns a mode that cancels with a string error", async () => { + const mode = "initialize"; + const args = ["--email", "abc123"]; + const code = 2; + const error = "Oh no!"; + + mockPromptForMode.mockResolvedValue({ mode }); + mockInitialize.mockResolvedValue({ + code: 2, + error, + options: {}, + }); + + const result = await bin(args); + + expect(mockInitialize).toHaveBeenCalledWith(args, undefined); + expect(mockLogLine).toHaveBeenCalledWith(chalk.red(error)); + expect(mockCancel).toHaveBeenCalledWith( + `Operation cancelled. Exiting - maybe another time? ๐Ÿ‘‹`, + ); + expect(result).toEqual(code); + }); + + it.skip("returns the cancel result containing a zod error of the corresponding runner and output plus cancel logs when promptForMode returns a mode that cancels with a zod error", async () => { + const mode = "initialize"; + const args = ["--email", "abc123"]; + const code = 2; + + const validationResult = z + .object({ email: z.string().email() }) + .safeParse({ email: "abc123" }); + + mockPromptForMode.mockResolvedValue({ mode }); + mockInitialize.mockResolvedValue({ + code: 2, + error: (validationResult as z.SafeParseError<{ email: string }>).error, + options: {}, + }); + + const result = await bin(args); + + expect(mockInitialize).toHaveBeenCalledWith(args, undefined); + expect(mockLogLine).toHaveBeenCalledWith( + chalk.red('Validation error: Invalid email at "email"'), + ); + expect(mockCancel).toHaveBeenCalledWith( + `Operation cancelled. Exiting - maybe another time? ๐Ÿ‘‹`, + ); + expect(result).toEqual(code); + }); + + it.skip("returns the cancel result of the corresponding runner and cancel logs when promptForMode returns a mode that fails", async () => { + const mode = "create"; + const args = ["--owner", "abc123"]; + const code = 1; + const promptedOptions = { directory: "." }; + + mockPromptForMode.mockResolvedValue({ mode, options: promptedOptions }); + mockCreate.mockResolvedValue({ code, options: {} }); + + const result = await bin(args); + + expect(mockCreate).toHaveBeenCalledWith(args, promptedOptions); + expect(mockCancel).toHaveBeenCalledWith( + `Operation failed. Exiting - maybe another time? ๐Ÿ‘‹`, + ); + expect(result).toEqual(code); + }); + + it("prints the version when the --version flag is passed", async () => { + const args = ["--version"]; + const version = await getVersionFromPackageJson(); + + const result = await bin(args); + + expect(console.log).toHaveBeenCalledWith(version); + expect(result).toBe(0); + }); +}); diff --git a/src/bin/index.ts b/src/bin/index.ts new file mode 100644 index 0000000..d0732f7 --- /dev/null +++ b/src/bin/index.ts @@ -0,0 +1,151 @@ +import * as prompts from "@clack/prompts"; +import chalk from "chalk"; +import { parseArgs } from "node:util"; +import { fromZodError } from "zod-validation-error"; + +import { + createPat, + createUserNpmrc, + parseProjectNpmrc, + writeNpmrc, +} from "../index.js"; +import { logLine } from "../shared/cli/lines.js"; +import { withSpinner } from "../shared/cli/spinners.js"; +import { StatusCodes } from "../shared/codes.js"; +import { options } from "../shared/options/args.js"; +import { optionsSchema } from "../shared/options/optionsSchema.js"; +import { logHelpText } from "./help.js"; +import { getVersionFromPackageJson } from "./packageJson.js"; + +const operationMessage = (verb: string) => + `Operation ${verb}. Exiting - maybe another time? ๐Ÿ‘‹`; + +export async function bin(args: string[]) { + console.clear(); + + const version = await getVersionFromPackageJson(); + + const introPrompts = `${chalk.blueBright(`๐Ÿ“ฆ๐Ÿ”‘ Welcome to`)} ${chalk.bgBlueBright.black(`ado-npm-auth-lite`)} ${chalk.blueBright(`${version}! ๐Ÿ“ฆ๐Ÿ”‘`)}`; + const outroPrompts = `${chalk.blueBright(`๐Ÿ“ฆ๐Ÿ”‘ Thanks for using`)} ${chalk.bgBlueBright.black(`ado-npm-auth-lite`)} ${chalk.blueBright(`${version}! ๐Ÿ“ฆ๐Ÿ”‘`)}`; + + const { values } = parseArgs({ + args, + options, + strict: false, + }); + + if (values.help) { + logHelpText([introPrompts]); + return StatusCodes.Success; + } + + if (values.version) { + console.log(version); + return StatusCodes.Success; + } + + prompts.intro(introPrompts); + + logLine(); + + const mappedOptions = { + config: values.config, + email: values.email, + }; + + const optionsParseResult = optionsSchema.safeParse(mappedOptions); + + if (!optionsParseResult.success) { + logLine( + chalk.red( + fromZodError(optionsParseResult.error, { + issueSeparator: "\n - ", + }), + ), + ); + logLine(); + + prompts.cancel(operationMessage("failed")); + prompts.outro(outroPrompts); + + return StatusCodes.Failure; + } + + const { config, email } = optionsParseResult.data; + + prompts.log.info(`options: +- config: ${config ?? "[NONE SUPPLIED - WILL USE DEFAULT]"} +- email: ${email ?? "[NONE SUPPLIED - WILL USE DEFAULT]"}`); + + const logger = { + info: prompts.log.info, + error: prompts.log.error, + }; + + const parsedProjectNpmrc = await withSpinner(`Parsing project .npmrc`, () => + parseProjectNpmrc({ + config, + logger, + }), + ); + + if (!parsedProjectNpmrc) { + prompts.cancel(operationMessage("failed")); + prompts.outro(outroPrompts); + + return StatusCodes.Failure; + } + + const pat = await withSpinner( + `Creating Personal Access Token with vso.packaging scope`, + () => createPat({ logger, organisation: parsedProjectNpmrc.organisation }), + ); + // const pat = { + // patToken: { + // token: "123456", + // }, + // }; + + if (!pat) { + prompts.cancel(operationMessage("failed")); + prompts.outro(outroPrompts); + + return StatusCodes.Failure; + } + + const npmrc = await withSpinner(`Constructing user .npmrc`, () => + Promise.resolve( + createUserNpmrc({ + parsedProjectNpmrc, + email, + logger, + pat: pat.patToken.token, + }), + ), + ); + + if (!npmrc) { + prompts.cancel(operationMessage("failed")); + prompts.outro(outroPrompts); + + return StatusCodes.Failure; + } + + const succeeded = await withSpinner(`Writing user .npmrc`, () => + writeNpmrc({ + npmrc, + logger, + }), + ); + + if (!succeeded) { + prompts.cancel(operationMessage("failed")); + prompts.outro(outroPrompts); + + return StatusCodes.Failure; + } + + prompts.outro(outroPrompts); + + return StatusCodes.Success; +} diff --git a/src/bin/packageJson.test.ts b/src/bin/packageJson.test.ts new file mode 100644 index 0000000..7e1be8d --- /dev/null +++ b/src/bin/packageJson.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; + +import { getVersionFromPackageJson } from "./packageJson.js"; + +const mockReadFileSafeAsJson = vi.fn(); + +vi.mock("../shared/readFileSafeAsJson.js", () => ({ + get readFileSafeAsJson() { + return mockReadFileSafeAsJson; + }, +})); + +describe("getVersionFromPackageJson", () => { + it("returns the current version number from the package.json", async () => { + mockReadFileSafeAsJson.mockResolvedValue({ + version: "1.40.0", + }); + + const version = await getVersionFromPackageJson(); + + expect(version).toBe("1.40.0"); + }); + + it("throws an error when there is no version number", async () => { + mockReadFileSafeAsJson.mockResolvedValue({}); + + await expect(() => getVersionFromPackageJson()).rejects.toEqual( + new Error("Cannot find version number"), + ); + }); +}); diff --git a/src/bin/packageJson.ts b/src/bin/packageJson.ts new file mode 100644 index 0000000..18d7004 --- /dev/null +++ b/src/bin/packageJson.ts @@ -0,0 +1,16 @@ +import { readFileSafeAsJson } from "../shared/readFileSafeAsJson.js"; + +interface PackageWithVersion { + version?: string; +} + +export async function getVersionFromPackageJson(): Promise { + const path = new URL("../../package.json", import.meta.url); + const data = (await readFileSafeAsJson(path)) as PackageWithVersion; + + if (typeof data === "object" && typeof data.version === "string") { + return data.version; + } + + throw new Error("Cannot find version number"); +} diff --git a/src/createPat.ts b/src/createPat.ts new file mode 100644 index 0000000..94d6779 --- /dev/null +++ b/src/createPat.ts @@ -0,0 +1,80 @@ +import { AzureCliCredential } from "@azure/identity"; +import chalk from "chalk"; +import { fromZodError } from "zod-validation-error"; + +import type { TokenResult } from "./types.js"; + +import { fallbackLogger, type Logger } from "./logger.js"; +import { tokenResultSchema } from "./schemas.js"; + +export async function createPat({ + logger = fallbackLogger, + organisation, +}: { + logger?: Logger; + organisation: string; +}): Promise { + // const credential = new InteractiveBrowserCredential({}); + const credential = new AzureCliCredential(); + + // get a token that can be used to authenticate to Azure DevOps + // taken from https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/manage-personal-access-tokens-via-api?view=azure-devops#configure-a-quickstart-application + // 'Update the SCOPE configuration variable to "499b84ac-1321-427f-aa17-267ca6975798/.default" to refer to the Azure DevOps resource and all of its scopes.' + const token = await credential.getToken([ + "499b84ac-1321-427f-aa17-267ca6975798", + ]); + + // Get the current date + const currentDate = new Date(); + + // Add 30 days to the current date + const futureDate = new Date(currentDate); + futureDate.setDate(currentDate.getDate() + 30); + + try { + // https://learn.microsoft.com/en-us/rest/api/azure/devops/tokens/pats/create?view=azure-devops-rest-7.1&tabs=HTTP + const url = `https://vssps.dev.azure.com/${organisation}/_apis/tokens/pats?api-version=7.1-preview.1`; + const data = { + displayName: `made by ado-npm-auth-lite at: ${new Date().toISOString()}`, + scope: "vso.packaging", + validTo: futureDate.toISOString(), + allOrgs: false, + }; + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token.token}`, + "X-TFS-FedAuthRedirect": "Suppress", + "X-VSS-ForceMsaPassThrough": "True", + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + logger.error(`HTTP error! status: ${response.status.toString()}`); + return; + } + + const tokenParseResult = tokenResultSchema.safeParse(await response.json()); + + if (!tokenParseResult.success) { + logger.error( + chalk.red( + fromZodError(tokenParseResult.error, { + issueSeparator: "\n - ", + }), + ), + ); + } + + logger.info(`Created Personal Access Token`); + + return tokenParseResult.data; + } catch (error) { + logger.error( + `Error creating Personal Access Token: ${error instanceof Error ? error.message : ""}`, + ); + } +} diff --git a/src/createUserNpmrc.ts b/src/createUserNpmrc.ts new file mode 100644 index 0000000..ba15c4e --- /dev/null +++ b/src/createUserNpmrc.ts @@ -0,0 +1,43 @@ +import type { Logger } from "./logger.js"; +import type { ParsedProjectNpmrc } from "./parseProjectNpmrc.js"; + +/** + * Make a user .npmrc file that looks a little like this: + * + * ; begin auth token + * //pkgs.dev.azure.com/johnnyreilly/_packaging/npmrc-script-organization/npm/registry/:username=johnnyreilly + * //pkgs.dev.azure.com/johnnyreilly/_packaging/npmrc-script-organization/npm/registry/:_password=[BASE64_ENCODED_PERSONAL_ACCESS_TOKEN] + * //pkgs.dev.azure.com/johnnyreilly/_packaging/npmrc-script-organization/npm/registry/:email=npm requires email to be set but doesn't use the value + * //pkgs.dev.azure.com/johnnyreilly/_packaging/npmrc-script-organization/npm/:username=johnnyreilly + * //pkgs.dev.azure.com/johnnyreilly/_packaging/npmrc-script-organization/npm/:_password=[BASE64_ENCODED_PERSONAL_ACCESS_TOKEN] + * //pkgs.dev.azure.com/johnnyreilly/_packaging/npmrc-script-organization/npm/:email=npm requires email to be set but doesn't use the value + * ; end auth token + */ +export function createUserNpmrc({ + email = "npm requires email to be set but doesn't use the value", + parsedProjectNpmrc, + pat, + // logger = fallbackLogger, +}: { + email?: string | undefined; + parsedProjectNpmrc: ParsedProjectNpmrc; + logger?: Logger; + pat: string; +}): string | undefined { + const base64EncodedPAT = Buffer.from(pat).toString("base64"); + + const { urlWithoutRegistryAtEnd, urlWithoutRegistryAtStart, organisation } = + parsedProjectNpmrc; + + const npmrc = `; begin auth token +${urlWithoutRegistryAtStart}:username=${organisation} +${urlWithoutRegistryAtStart}:_password=${base64EncodedPAT} +${urlWithoutRegistryAtStart}:email=${email} +${urlWithoutRegistryAtEnd}:username=${organisation} +${urlWithoutRegistryAtEnd}:_password=${base64EncodedPAT} +${urlWithoutRegistryAtEnd}:email=${email} +; end auth token +`; + + return npmrc; +} diff --git a/src/greet.test.ts b/src/greet.test.ts deleted file mode 100644 index f729115..0000000 --- a/src/greet.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { greet } from "./greet.js"; - -const message = "Yay, testing!"; - -describe("greet", () => { - it("logs to the console once when message is provided as a string", () => { - const logger = vi.spyOn(console, "log").mockImplementation(() => undefined); - - greet(message); - - expect(logger).toHaveBeenCalledWith(message); - expect(logger).toHaveBeenCalledTimes(1); - }); - - it("logs to the console once when message is provided as an object", () => { - const logger = vi.spyOn(console, "log").mockImplementation(() => undefined); - - greet({ message }); - - expect(logger).toHaveBeenCalledWith(message); - expect(logger).toHaveBeenCalledTimes(1); - }); - - it("logs once when times is not provided in an object", () => { - const logger = vi.fn(); - - greet({ logger, message }); - - expect(logger).toHaveBeenCalledWith(message); - expect(logger).toHaveBeenCalledTimes(1); - }); - - it("logs a specified number of times when times is provided", () => { - const logger = vi.fn(); - const times = 7; - - greet({ logger, message, times }); - - expect(logger).toHaveBeenCalledWith(message); - expect(logger).toHaveBeenCalledTimes(7); - }); -}); diff --git a/src/greet.ts b/src/greet.ts deleted file mode 100644 index a0d3b4c..0000000 --- a/src/greet.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { GreetOptions } from "./types.js"; - -export function greet(options: GreetOptions | string) { - const { - logger = console.log.bind(console), - message, - times = 1, - } = typeof options === "string" ? { message: options } : options; - - for (let i = 0; i < times; i += 1) { - logger(message); - } -} diff --git a/src/index.ts b/src/index.ts index a39b40f..7fdf513 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,5 @@ -export * from "./greet.js"; +export * from "./createPat.js"; +export * from "./createUserNpmrc.js"; +export * from "./parseProjectNpmrc.js"; export * from "./types.js"; +export * from "./writeNpmrc.js"; diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..e4b6aa9 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,9 @@ +export const fallbackLogger: Logger = { + error: console.error.bind(console), + info: console.log.bind(console), +}; + +export interface Logger { + error: (message: string) => void; + info: (message: string) => void; +} diff --git a/src/parseProjectNpmrc.ts b/src/parseProjectNpmrc.ts new file mode 100644 index 0000000..1804e7f --- /dev/null +++ b/src/parseProjectNpmrc.ts @@ -0,0 +1,56 @@ +import path from "node:path"; + +import { fallbackLogger, type Logger } from "./logger.js"; +import { readFileSafe } from "./shared/readFileSafe.js"; + +export interface ParsedProjectNpmrc { + organisation: string; + urlWithoutRegistryAtEnd: string; + urlWithoutRegistryAtStart: string; +} + +/** + * Read the project .npmrc file to acquire necessary info + */ +export async function parseProjectNpmrc({ + config, + logger = fallbackLogger, +}: { + config?: string | undefined; + logger?: Logger; +}): Promise { + const npmrcPath = config + ? path.resolve(config) + : path.resolve(process.cwd(), ".npmrc"); + + logger.info(`Loading .npmrc at: ${npmrcPath}`); + + const npmrcContents = await readFileSafe(npmrcPath, ""); + + if (!npmrcContents) { + logger.error(`No .npmrc found at: ${npmrcPath}`); + return; + } + + const regex = /^registry=.*$/gm; + const match = npmrcContents.match(regex); + + if (!match || match.length === 0) { + logger.error(`Unable to extract information from project .npmrc`); + return; + } + + const urlWithoutRegistryAtStart = match[0] + .replace("registry=https:", "") + .trim(); + const urlWithoutRegistryAtEnd = urlWithoutRegistryAtStart.replace( + /\/registry\/$/, + "", + ); + // extract the organisation which we will use as the username + // not sure why this is the case, but this is the behaviour + // defined in ADO + const organisation = urlWithoutRegistryAtEnd.split("/")[3]; + + return { urlWithoutRegistryAtStart, urlWithoutRegistryAtEnd, organisation }; +} diff --git a/src/schemas.test.ts b/src/schemas.test.ts new file mode 100644 index 0000000..b0c6437 --- /dev/null +++ b/src/schemas.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import type { TokenResult } from "./types.js"; + +import { tokenResultSchema } from "./schemas.js"; + +describe("schemas", () => { + it("successfully parses a valid token result", () => { + const tokenResult: TokenResult = { + patToken: { + displayName: "with cli", + validTo: "2024-11-05T23:46:23.32Z", + scope: "vso.packaging", + targetAccounts: ["a5a177e6-6a30-4db2-bc31-9e9153e7b947"], + validFrom: "2024-11-03T07:19:13.7933333Z", + authorizationId: "1230f77a-ca45-4196-97ee-3b017d31e798", + token: "[TOKEN HERE]", + }, + patTokenError: "none", + }; + + const tokenParseResult = tokenResultSchema.safeParse(tokenResult); + + expect(tokenParseResult.success).toBe(true); + expect(tokenParseResult.data).toMatchObject(tokenResult); + }); + + it("successfully errors on an invalid token result", () => { + const tokenResult = { + patToken: { + displayName: "with cli", + token: "[TOKEN HERE]", + }, + patTokenError: "none", + }; + + const tokenParseResult = tokenResultSchema.safeParse(tokenResult); + + expect(tokenParseResult.success).toBe(false); + }); +}); diff --git a/src/schemas.ts b/src/schemas.ts new file mode 100644 index 0000000..311c206 --- /dev/null +++ b/src/schemas.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +import type { TokenResult } from "./types.js"; + +const patTokenSchema = z.object({ + displayName: z.string(), + validTo: z.string().refine((date) => !isNaN(Date.parse(date)), { + message: "Invalid date format for validTo", + }), + scope: z.string(), + targetAccounts: z.array(z.string().uuid()), + validFrom: z.string().refine((date) => !isNaN(Date.parse(date)), { + message: "Invalid date format for validFrom", + }), + authorizationId: z.string().uuid(), + token: z.string(), +}); + +export const tokenResultSchema: z.ZodType = z.object({ + patToken: patTokenSchema, + patTokenError: z.string(), +}); diff --git a/src/shared/cli/lines.ts b/src/shared/cli/lines.ts new file mode 100644 index 0000000..728e102 --- /dev/null +++ b/src/shared/cli/lines.ts @@ -0,0 +1,19 @@ +import chalk from "chalk"; +import { EOL } from "os"; + +export function logLine(line?: string) { + console.log(makeLine(line)); +} + +export function logAsJson(value: unknown) { + JSON.stringify(value, null, 2).split(EOL).forEach(logLine); +} + +export function logNewSection(line: string) { + logLine(); + console.log(`โ—‡ ${line}`); +} + +export function makeLine(line: string | undefined) { + return [chalk.gray("โ”‚"), line].filter(Boolean).join(" "); +} diff --git a/src/shared/cli/lowerFirst.ts b/src/shared/cli/lowerFirst.ts new file mode 100644 index 0000000..3b96e98 --- /dev/null +++ b/src/shared/cli/lowerFirst.ts @@ -0,0 +1,3 @@ +export function lowerFirst(text: string) { + return text[0].toLowerCase() + text.slice(1); +} diff --git a/src/shared/cli/outro.test.ts b/src/shared/cli/outro.test.ts new file mode 100644 index 0000000..9f228a5 --- /dev/null +++ b/src/shared/cli/outro.test.ts @@ -0,0 +1,61 @@ +import chalk from "chalk"; +import { beforeEach, describe, expect, it, MockInstance, vi } from "vitest"; + +import { outro } from "./outro.js"; + +const mockOutro = vi.fn(); + +vi.mock("@clack/prompts", () => ({ + get outro() { + return mockOutro; + }, +})); + +let mockConsoleLog: MockInstance; + +describe("outro", () => { + beforeEach(() => { + mockConsoleLog = vi + .spyOn(console, "log") + .mockImplementation(() => undefined); + }); + + it("logs only basic statements when no lines are provided", () => { + outro([{ label: "Abc 123" }]); + + expect(mockConsoleLog.mock.calls).toEqual([ + [chalk.blue("Abc 123")], + [], + [chalk.greenBright(`See ya! ๐Ÿ‘‹`)], + [], + ]); + }); + + it("also logs lines when provided", () => { + outro([{ label: "Abc 123", lines: ["one", "two"] }]); + + expect(mockConsoleLog.mock.calls).toEqual([ + [chalk.blue("Abc 123")], + [], + ["one"], + ["two"], + [], + [chalk.greenBright(`See ya! ๐Ÿ‘‹`)], + [], + ]); + }); + + it("logs lines as code when variant is specified", () => { + outro([{ label: "Abc 123", lines: ["one", "two"], variant: "code" }]); + + expect(mockConsoleLog.mock.calls).toEqual([ + [chalk.blue("Abc 123")], + [], + [chalk.gray("one")], + [chalk.gray("two")], + [], + [chalk.greenBright(`See ya! ๐Ÿ‘‹`)], + [], + ]); + }); +}); diff --git a/src/shared/cli/outro.ts b/src/shared/cli/outro.ts new file mode 100644 index 0000000..444891d --- /dev/null +++ b/src/shared/cli/outro.ts @@ -0,0 +1,28 @@ +import * as prompts from "@clack/prompts"; +import chalk from "chalk"; + +export interface OutroGroup { + label: string; + lines?: string[]; + variant?: "code"; +} + +export function outro(groups: OutroGroup[]) { + prompts.outro(chalk.blue(`Great, looks like the script finished! ๐ŸŽ‰`)); + + for (const { label, lines, variant } of groups) { + console.log(chalk.blue(label)); + console.log(); + + if (lines) { + for (const line of lines) { + console.log(variant === "code" ? chalk.gray(line) : line); + } + + console.log(); + } + } + + console.log(chalk.greenBright(`See ya! ๐Ÿ‘‹`)); + console.log(); +} diff --git a/src/shared/cli/spinners.ts b/src/shared/cli/spinners.ts new file mode 100644 index 0000000..e3e65c5 --- /dev/null +++ b/src/shared/cli/spinners.ts @@ -0,0 +1,72 @@ +import * as prompts from "@clack/prompts"; +import chalk from "chalk"; +import readline from "readline"; + +import { logLine, logNewSection, makeLine } from "./lines.js"; +import { lowerFirst } from "./lowerFirst.js"; +import { startLineWithDots } from "./startLineWithDots.js"; + +const s = prompts.spinner(); + +export type SpinnerTask = () => Promise; + +export type LabeledSpinnerTask = [string, SpinnerTask]; + +export async function withSpinner( + label: string, + task: SpinnerTask, +) { + s.start(`${label}...`); + + try { + const result = await task(); + + s.stop(chalk.green(`โœ… Passed ${lowerFirst(label)}.`)); + + return result; + } catch (error) { + s.stop(chalk.red(`โŒ Error ${lowerFirst(label)}.`)); + + throw new Error(`Failed ${lowerFirst(label)}`, { cause: error }); + } +} + +export async function withSpinners( + label: string, + tasks: LabeledSpinnerTask[], +) { + logNewSection(`${label}...`); + + let currentLabel!: string; + let lastLogged!: string; + + for (const [label, run] of tasks) { + currentLabel = label; + + const line = makeLine(chalk.gray(` - ${label}`)); + const stopWriting = startLineWithDots(line); + + try { + await run(); + } catch (error) { + const descriptor = `${lowerFirst(label)} > ${lowerFirst(currentLabel)}`; + + logLine(chalk.red(`โŒ Error ${descriptor}.`)); + + throw new Error(`Failed ${descriptor}`, { cause: error }); + } finally { + const lineLength = stopWriting(); + readline.clearLine(process.stdout, -1); + readline.moveCursor(process.stdout, -lineLength, 0); + } + + lastLogged = chalk.gray(`${line} โœ”๏ธ\n`); + + process.stdout.write(lastLogged); + } + + readline.moveCursor(process.stdout, -lastLogged.length, -tasks.length - 2); + readline.clearScreenDown(process.stdout); + + logNewSection(chalk.green(`โœ… Passed ${lowerFirst(label)}.`)); +} diff --git a/src/shared/cli/startLineWithDots.ts b/src/shared/cli/startLineWithDots.ts new file mode 100644 index 0000000..e803781 --- /dev/null +++ b/src/shared/cli/startLineWithDots.ts @@ -0,0 +1,38 @@ +import readline from "readline"; + +export function startLineWithDots(line: string) { + const timer = [setTimeout(tick, 500)]; + let dots = 0; + let lastLogged!: string; + + function clearLine() { + readline.clearLine(process.stdout, -1); + readline.moveCursor(process.stdout, -lastLogged.length, 0); + } + + function writeLine() { + dots = (dots + 1) % 4; + + const toLog = `${line}${".".repeat(dots)}`; + + process.stdout.write(toLog); + + lastLogged = toLog; + + return toLog; + } + + function tick() { + clearLine(); + writeLine(); + timer[0] = setTimeout(tick, 500); + } + + writeLine(); + + return () => { + clearLine(); + clearInterval(timer[0]); + return lastLogged.length; + }; +} diff --git a/src/shared/codes.ts b/src/shared/codes.ts new file mode 100644 index 0000000..89b3957 --- /dev/null +++ b/src/shared/codes.ts @@ -0,0 +1,7 @@ +export const StatusCodes = { + Cancelled: 2, + Failure: 1, + Success: 0, +} as const; + +export type StatusCode = (typeof StatusCodes)[keyof typeof StatusCodes]; diff --git a/src/shared/options/args.ts b/src/shared/options/args.ts new file mode 100644 index 0000000..715012d --- /dev/null +++ b/src/shared/options/args.ts @@ -0,0 +1,62 @@ +export const options = { + config: { + short: "c", + type: "string", + }, + + email: { + short: "e", + type: "string", + }, + + help: { + short: "h", + type: "boolean", + }, + + version: { + short: "v", + type: "boolean", + }, +} as const; + +export type ValidOption = keyof typeof options; + +export interface DocOption { + description: string; + docsSection: "core" | "optional"; + multiple?: boolean; + short: string; + type: string; +} + +// two modes: use resource group and subscription name to get branch resources, or pass explicit resource names + +export const allArgOptions: Record = { + config: { + ...options.config, + description: + "The location of the .npmrc file. Defaults to current directory", + docsSection: "optional", + }, + + email: { + ...options.email, + description: + "Allows users to supply an explicit email - if not supplied, will be inferred from git user.config", + docsSection: "optional", + }, + + help: { + ...options.help, + description: "Show help", + docsSection: "core", + type: "boolean", + }, + + version: { + ...options.version, + description: "Show version", + docsSection: "core", + }, +} as const; diff --git a/src/shared/options/optionsSchema.ts b/src/shared/options/optionsSchema.ts new file mode 100644 index 0000000..16f793d --- /dev/null +++ b/src/shared/options/optionsSchema.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const optionsSchema = z.object({ + config: z.string().optional(), + email: z.string().optional(), +}); diff --git a/src/shared/readFileSafe.test.ts b/src/shared/readFileSafe.test.ts new file mode 100644 index 0000000..2722a19 --- /dev/null +++ b/src/shared/readFileSafe.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; + +import { readFileSafe } from "./readFileSafe.js"; + +const mockReadFile = vi.fn(); + +vi.mock("node:fs/promises", () => ({ + get readFile() { + return mockReadFile; + }, +})); + +describe("readFundingIfExists", () => { + it("outputs the file content as string when it exists", async () => { + mockReadFile.mockResolvedValue("File content as string"); + const result = await readFileSafe("/path/to/file.ext", "fallback"); + expect(result).toBe("File content as string"); + }); + + it("returns fallback when readFile fails", async () => { + mockReadFile.mockRejectedValue("Oops"); + const result = await readFileSafe("/path/to/nowhere.ext", "fallback"); + expect(result).toBe("fallback"); + }); +}); diff --git a/src/shared/readFileSafe.ts b/src/shared/readFileSafe.ts new file mode 100644 index 0000000..4ad8a77 --- /dev/null +++ b/src/shared/readFileSafe.ts @@ -0,0 +1,9 @@ +import * as fs from "node:fs/promises"; + +export async function readFileSafe(filePath: string | URL, fallback: string) { + try { + return (await fs.readFile(filePath)).toString(); + } catch { + return fallback; + } +} diff --git a/src/shared/readFileSafeAsJson.ts b/src/shared/readFileSafeAsJson.ts new file mode 100644 index 0000000..af2dcbe --- /dev/null +++ b/src/shared/readFileSafeAsJson.ts @@ -0,0 +1,5 @@ +import { readFileSafe } from "./readFileSafe.js"; + +export async function readFileSafeAsJson(filePath: string | URL) { + return JSON.parse(await readFileSafe(filePath, "null")) as unknown; +} diff --git a/src/types.ts b/src/types.ts index 4f16ae3..9b3fec1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,12 @@ -export interface GreetOptions { - logger?: (message: string) => void; - message: string; - times?: number; +export interface TokenResult { + patToken: { + displayName: string; + validTo: string; + scope: string; + targetAccounts: string[]; + validFrom: string; + authorizationId: string; + token: string; + }; + patTokenError: string; } diff --git a/src/writeNpmrc.ts b/src/writeNpmrc.ts new file mode 100644 index 0000000..230b74c --- /dev/null +++ b/src/writeNpmrc.ts @@ -0,0 +1,33 @@ +import * as fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { fallbackLogger, type Logger } from "./logger.js"; + +export async function writeNpmrc({ + npmrc, + logger = fallbackLogger, +}: { + npmrc: string; + logger?: Logger; +}): Promise { + // Get the home directory + const homeDirectory = os.homedir(); + + // // Define the path for the .npmrc file + const userNpmrcPath = path.join(homeDirectory, ".npmrc"); + + logger.info(`Writing users .npmrc to: ${userNpmrcPath}`); + + try { + // Write the content to the .npmrc file + await fs.writeFile(userNpmrcPath, npmrc); + } catch (error) { + logger.error( + `Error writing users .npmrc to ${userNpmrcPath}: ${error instanceof Error ? error.message : ""}`, + ); + return false; + } + + return true; +} diff --git a/tsconfig.json b/tsconfig.json index 223cce1..9668597 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "allowJs": true, "declaration": true, "declarationMap": true, "esModuleInterop": true, @@ -12,5 +13,5 @@ "strict": true, "target": "ES2022" }, - "include": ["src"] + "include": ["src", "bin"] }