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
+
-
@@ -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:
+
+
+
+Now consider the onboarding process for a non Windows user:
+
+
+
+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"]
}