diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..6d025bc --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,71 @@ +name: build + +# On a PR to main: install, build, unit-test on Linux + Windows (no publish). +# On push to main (or manual dispatch): after tests pass, publish a new build +# to GitHub Packages under the `next` dist-tag (from Ubuntu only). Versions are +# plain semver (major.minor from package.json; patch = highest published + 1), +# so testers can install ANY build: `@reply-team/reply-cli@0.1.42` or `@next`. +# Promotion to `@latest` is handled by the separate release workflow. +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + packages: write + +# Serialize main publishes so two merges can't grab the same patch number. +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://npm.pkg.github.com + scope: '@reply-team' + + - name: Install + run: npm ci + + - name: Build + run: npm run build + + - name: Unit tests + run: npm test + + # Publish only from a single OS (Ubuntu) on push/dispatch — never on PRs, + # never twice. + - name: Compute next @next version + if: ${{ github.event_name != 'pull_request' && matrix.os == 'ubuntu-latest' }} + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + MM=$(node -p "require('./package.json').version.split('.').slice(0,2).join('.')") + PUB=$(npm view "@reply-team/reply-cli" versions --json 2>/dev/null || echo '[]') + NEXT=$(PUB="$PUB" MM="$MM" node -e "const mm=process.env.MM;let vs=[];try{vs=JSON.parse(process.env.PUB)}catch{}; if(!Array.isArray(vs)) vs = vs ? [vs] : []; const ps=vs.filter(v=>typeof v==='string'&&v.startsWith(mm+'.')).map(v=>parseInt(v.slice(mm.length+1),10)).filter(Number.isInteger); process.stdout.write(String(ps.length?Math.max(...ps)+1:0))") + VERSION="${MM}.${NEXT}" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "Publishing $VERSION under @next" + + - name: Publish to GitHub Packages (@next) + if: ${{ github.event_name != 'pull_request' && matrix.os == 'ubuntu-latest' }} + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + npm version "$VERSION" --no-git-tag-version --allow-same-version + npm pkg set commit="${{ github.sha }}" + npm publish --tag next diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c50459f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +name: release + +# Promote a tested build to the `latest` dist-tag — the manual, human-gated +# "ship it" step. No rebuild: the exact bytes testers ran on @next become +# @latest. Also tags the exact source commit the build came from. +on: + workflow_dispatch: + inputs: + version: + description: 'Tested version to promote to @latest (e.g. 0.1.42)' + required: true + +permissions: + contents: write # push the release tag + packages: write # move the dist-tag + +jobs: + promote: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://npm.pkg.github.com + scope: '@reply-team' + + - name: Promote to @latest and tag the source commit + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VER='${{ github.event.inputs.version }}' + # Fail fast if the version was never published. + npm view "@reply-team/reply-cli@${VER}" version + npm dist-tag add "@reply-team/reply-cli@${VER}" latest + echo "Promoted @reply-team/reply-cli@${VER} to @latest" + SHA=$(npm view "@reply-team/reply-cli@${VER}" commit 2>/dev/null || true) + if [ -n "$SHA" ] && ! git rev-parse "v${VER}" >/dev/null 2>&1; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "v${VER}" -m "release ${VER}" "$SHA" + git push origin "v${VER}" + echo "Tagged v${VER} at ${SHA}" + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..edc5d77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tgz +coverage/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..087865b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,88 @@ +# Contributing + +Anyone can clone the repo, build it, and open a pull request — contributions and +bug reports are welcome. Merges are restricted: only Reply employees can approve +and merge a PR. Open one from a branch (or a fork), and a maintainer will review. + +## Prerequisites + +[Node.js](https://nodejs.org) 20 or newer. + +## Setup + +```sh +npm install +npm run build # compile TypeScript to dist/ +npm test # run the test suite (vitest) +npm link # put the built `reply` binary on your PATH +``` + +## Tests + +The suite is fully offline — no test contacts the Reply.io API or the identity +server. `fetch` is stubbed, and the OAuth loopback flow is exercised against a +local `127.0.0.1` listener with an injected browser stub. CI runs the build and +tests on Linux and Windows. + +## Conventions + +- Data is written to stdout; status and error messages go to stderr. +- `--json` emits compact JSON and `--pretty` indented JSON. On either, an error + is a single machine-readable line: + `{"error":{"status":…,"code":…,"title":…,"detail":…,"hint":…}}`. +- Exit codes: `0` success, `1` API or runtime failure, `2` usage error. +- Secrets are never printed; token and key fields are redacted in all output. + +## Credentials on disk + +Credentials are stored as JSON in the config directory (`~/.config/reply`, or +`%APPDATA%\reply` on Windows), created `0600` inside a `0700` directory — the +same plaintext-file model as `gh`, `aws`, and `az`. On Windows the strict mode +bits are a no-op and it relies on the per-user `%APPDATA%` ACLs, as those tools +do. + +Each record is keyed by profile name, so multiple accounts never collide even +when they hit the same backend. A record is either an OAuth entry (access token ++ refresh token + expiry) or an API-key entry. Expired OAuth tokens refresh +automatically; if a refresh fails the record is cleared and the user is prompted +to log in again. The store sits behind a `CredentialStore` interface so an +OS-keychain backend can be added later without touching callers. + +## Credential resolution + +Resolved in strict order, first hit wins: + +1. `--api-key ` flag +2. `REPLY_API_KEY` environment variable +3. the stored credential (from `auth login`) + +The flag and env var are ephemeral — used for the current invocation only, never +written to disk. There is no `.env` file lookup. + +## Testing against a non-prod backend + +Profiles inherit the built-in prod URLs; override them to point a profile at +another environment (internal testing only): + +```sh +reply profile add dev \ + --authority https://oauth.dev.replyapp.io \ + --api-base https://api.dev.reply.io/v3 +reply --profile dev auth login +``` + +Any field left off is inherited from the default (prod). Profiles live in +`config.json` in the config directory and can also be hand-edited: + +```jsonc +// ~/.config/reply/config.json +{ "profiles": { "dev": { "authority": "https://…", "api_base": "https://…/v3" } } } +``` + +## Releases + +- Every push to `main` publishes a build to the `@next` dist-tag: + `npm install -g @reply-team/reply-cli@next`. +- A release promotes a tested `@next` version to `@latest` with + `npm dist-tag add` — the exact published bytes, no rebuild — and tags the + source commit. diff --git a/README.md b/README.md index 4cd06fe..73d71f1 100644 --- a/README.md +++ b/README.md @@ -1 +1,87 @@ -# reply-cli \ No newline at end of file +# Reply CLI + +`reply` is the command-line interface for [Reply.io](https://reply.io). Sign in +once and every Reply.io API request runs as you — from your terminal or your +scripts. Today it handles authentication and identity; resource commands for +sequences, contacts, and the inbox are on the way. + +## Installation + +Requires [Node.js](https://nodejs.org) 20 or newer. The CLI is published to +GitHub Packages under the `@reply-team` scope, so point that scope at the +registry once, then install globally: + +```sh +echo "@reply-team:registry=https://npm.pkg.github.com" >> ~/.npmrc +npm install -g @reply-team/reply-cli +``` + +```sh +reply --version +``` + +## Usage + +```sh +reply [flags] +reply --help +``` + +Run `reply --help` for the full command list. Add `--json` to any command for +machine-readable output suitable for scripts. + +## Authentication + +Log in through your browser with OAuth: + +```sh +reply auth login +``` + +Or store an API key, read from stdin so it never lands in your shell history: + +```sh +reply auth login --with-token +``` + +Inspect and manage the active credential: + +```sh +reply auth status # who you're signed in as, and how — no secrets shown +reply auth whoami # verify the stored credential against the API +reply auth logout # remove the stored credential +``` + +Pass a key for a single command with `--api-key` or the `REPLY_API_KEY` +environment variable; both take precedence over a stored login and are never +written to disk. + +## Profiles + +Profiles keep more than one Reply.io account signed in at once — each stores its +own credential. Name them however you like; account emails work well: + +```sh +reply profile add alice@reply.io +reply profile use alice@reply.io # make it the active profile +reply auth login # signs in alice@reply.io + +reply profile list # '*' marks the active profile +reply --profile bob@reply.io auth whoami # override for a single command +``` + +The active profile is resolved as `--profile` → `REPLY_PROFILE` → the profile +set with `profile use` → the built-in default. + +## Environment variables + +| Variable | Description | +|----------|-------------| +| `REPLY_API_KEY` | API key used as the credential for the current invocation | +| `REPLY_PROFILE` | Profile to use (same as `--profile`) | +| `REPLY_CONFIG_DIR` | Config directory (default `~/.config/reply`; `%APPDATA%\reply` on Windows) | + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for building from source, running the +tests, credential-store internals, and the release process. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..310aa5d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1346 @@ +{ + "name": "@reply-team/reply-cli", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@reply-team/reply-cli", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "commander": "^14.0.2", + "picocolors": "^1.1.1" + }, + "bin": { + "reply": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.6.0", + "vitest": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a72064b --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "@reply-team/reply-cli", + "version": "0.1.0", + "description": "Command-line interface for Reply.io. v1: authentication (OAuth + API key) and identity.", + "main": "dist/index.js", + "bin": { + "reply": "dist/index.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "start": "node dist/index.js", + "clean": "rm -rf dist", + "test": "vitest run", + "test:watch": "vitest" + }, + "author": "Reply.io", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/reply-team/reply-cli.git" + }, + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "engines": { + "node": ">=20.0.0" + }, + "files": [ + "dist", + "README.md" + ], + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.6.0", + "vitest": "^4.0.0" + }, + "dependencies": { + "commander": "^14.0.2", + "picocolors": "^1.1.1" + } +} diff --git a/src/__tests__/auth/oauth-flow.test.ts b/src/__tests__/auth/oauth-flow.test.ts new file mode 100644 index 0000000..6468e2f --- /dev/null +++ b/src/__tests__/auth/oauth-flow.test.ts @@ -0,0 +1,144 @@ +import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'; +import http from 'http'; +import {URL} from 'url'; + +const mock_fetch = vi.fn(); +vi.stubGlobal('fetch', mock_fetch); + +import {build_authorize_url, run_login, refresh_stored, browser_open_command, CLIENT_ID, SCOPE} from '../../auth/oauth-flow'; +import {RuntimeError} from '../../utils/errors'; +import type {Oauth_record} from '../../credentials/types'; + +const AUTHORITY = 'https://oauth.dev.replyapp.io'; +const NOW = 1_700_000_000_000; + +const token_res = (data: unknown, status = 200)=> + new Response(JSON.stringify(data), {status, headers: {'Content-Type': 'application/json'}}); + +// Simulate the browser: hit the loopback callback with the given code/state. +const hit_callback = (authorize_url: string, over: {code?: string; state?: string} = {})=>{ + const u = new URL(authorize_url); + const redirect = new URL(u.searchParams.get('redirect_uri')!); + const state = over.state ?? u.searchParams.get('state')!; + const code = over.code ?? 'auth_code_123'; + const cb = `${redirect.origin}${redirect.pathname}?code=${code}&state=${encodeURIComponent(state)}`; + http.get(cb, res=>{res.resume();}); +}; + +// Hit the loopback callback with an ?error and capture the served HTML body. +const get_callback_body = (authorize_url: string, over: {error: string}): Promise=> + new Promise(resolve=>{ + const u = new URL(authorize_url); + const redirect = new URL(u.searchParams.get('redirect_uri')!); + const cb = `${redirect.origin}${redirect.pathname}?error=${encodeURIComponent(over.error)}`; + http.get(cb, res=>{ + let body = ''; + res.on('data', d=>{ body += d; }); + res.on('end', ()=>resolve(body)); + }); + }); + +describe('auth/oauth-flow', ()=>{ + beforeEach(()=>{ + vi.clearAllMocks(); + }); + afterEach(()=>{ + vi.restoreAllMocks(); + }); + + describe('browser_open_command', ()=>{ + it('uses the native opener with the URL as a standalone arg on mac/linux', ()=>{ + expect(browser_open_command('darwin', 'https://x?a=1&b=2')).toEqual({command: 'open', args: ['https://x?a=1&b=2']}); + expect(browser_open_command('linux', 'https://x?a=1&b=2')).toEqual({command: 'xdg-open', args: ['https://x?a=1&b=2']}); + }); + + it('uses rundll32 on Windows so an & in the URL is never parsed by cmd', ()=>{ + const url = 'https://oauth.dev.replyapp.io/connect/authorize?response_type=code&client_id=Reply.Cli&state=x'; + const {command, args} = browser_open_command('win32', url); + expect(command).toBe('rundll32'); + expect(args[0]).toBe('url.dll,FileProtocolHandler'); + expect(args[1]).toBe(url); // whole URL is one arg — & stays intact + expect(args).toHaveLength(2); + }); + }); + + describe('build_authorize_url', ()=>{ + it('targets /connect/authorize with code+PKCE+state params', ()=>{ + const url = build_authorize_url({ + authority: AUTHORITY, client_id: CLIENT_ID, + redirect_uri: 'http://127.0.0.1:5000/callback', + scope: SCOPE, challenge: 'CHAL', state: 'STATE', + }); + const u = new URL(url); + expect(u.origin + u.pathname).toBe('https://oauth.dev.replyapp.io/connect/authorize'); + expect(u.searchParams.get('response_type')).toBe('code'); + expect(u.searchParams.get('client_id')).toBe('Reply.Cli'); + expect(u.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:5000/callback'); + expect(u.searchParams.get('code_challenge')).toBe('CHAL'); + expect(u.searchParams.get('code_challenge_method')).toBe('S256'); + expect(u.searchParams.get('state')).toBe('STATE'); + expect(u.searchParams.get('scope')).toContain('offline_access'); + }); + + it('uses the 127.0.0.1 IP literal for the loopback redirect (RFC 8252)', ()=>{ + expect(SCOPE).toContain('reply-web-api'); + }); + }); + + describe('run_login (loopback, simulated redirect)', ()=>{ + it('completes the code exchange and returns an oauth record', async()=>{ + mock_fetch.mockResolvedValue(token_res({access_token: 'AT', refresh_token: 'RT', expires_in: 3600})); + const record = await run_login({ + authority: AUTHORITY, now: NOW, + open: (url: string)=>hit_callback(url), + }); + expect(record).toEqual({ + type: 'oauth', access_token: 'AT', refresh_token: 'RT', expires_at: NOW + 3_600_000, + }); + const [token_url, init] = mock_fetch.mock.calls[0]; + expect(token_url).toBe('https://oauth.dev.replyapp.io/connect/token'); + expect(String(init.body)).toContain('grant_type=authorization_code'); + expect(String(init.body)).toContain('code_verifier='); + }); + + it('rejects on a state mismatch (CSRF guard) without exchanging a token', async()=>{ + await expect(run_login({ + authority: AUTHORITY, now: NOW, timeout_ms: 3000, + open: (url: string)=>hit_callback(url, {state: 'WRONG'}), + })).rejects.toBeInstanceOf(RuntimeError); + expect(mock_fetch).not.toHaveBeenCalled(); + }); + + it('HTML-escapes a reflected ?error on the loopback page (no injection)', async()=>{ + const payload = ''; + let body: Promise | undefined; + await expect(run_login({ + authority: AUTHORITY, now: NOW, timeout_ms: 3000, + open: (url: string)=>{ body = get_callback_body(url, {error: payload}); }, + })).rejects.toBeInstanceOf(RuntimeError); + const html = await body!; + expect(html).not.toContain('{ + it('posts a refresh_token grant and maps the new record, keeping the old refresh token', async()=>{ + mock_fetch.mockResolvedValue(token_res({access_token: 'AT2', expires_in: 3600})); + const prev: Oauth_record = {type: 'oauth', access_token: 'AT', refresh_token: 'RT', expires_at: 1}; + const record = await refresh_stored({authority: AUTHORITY, record: prev, now: NOW}); + expect(record.access_token).toBe('AT2'); + expect(record.refresh_token).toBe('RT'); + const [, init] = mock_fetch.mock.calls[0]; + expect(String(init.body)).toContain('grant_type=refresh_token'); + }); + + it('throws when the token endpoint returns an error', async()=>{ + mock_fetch.mockResolvedValue(token_res({error: 'invalid_grant'}, 400)); + const prev: Oauth_record = {type: 'oauth', access_token: 'AT', refresh_token: 'RT', expires_at: 1}; + await expect(refresh_stored({authority: AUTHORITY, record: prev, now: NOW})) + .rejects.toBeInstanceOf(RuntimeError); + }); + }); +}); diff --git a/src/__tests__/auth/pkce.test.ts b/src/__tests__/auth/pkce.test.ts new file mode 100644 index 0000000..bb99d9b --- /dev/null +++ b/src/__tests__/auth/pkce.test.ts @@ -0,0 +1,41 @@ +import {describe, it, expect} from 'vitest'; +import crypto from 'crypto'; +import {create_pkce, create_state} from '../../auth/pkce'; + +const URL_SAFE = /^[A-Za-z0-9\-_]+$/; + +describe('auth/pkce', ()=>{ + describe('create_pkce', ()=>{ + it('produces a URL-safe verifier of RFC 7636 length (43-128)', ()=>{ + const {verifier} = create_pkce(); + expect(verifier).toMatch(URL_SAFE); + expect(verifier.length).toBeGreaterThanOrEqual(43); + expect(verifier.length).toBeLessThanOrEqual(128); + }); + + it('uses the S256 method', ()=>{ + expect(create_pkce().method).toBe('S256'); + }); + + it('challenge is base64url(sha256(verifier))', ()=>{ + const {verifier, challenge} = create_pkce(); + const expected = crypto.createHash('sha256').update(verifier).digest('base64url'); + expect(challenge).toBe(expected); + expect(challenge).toMatch(URL_SAFE); + }); + + it('generates a fresh verifier each call', ()=>{ + expect(create_pkce().verifier).not.toBe(create_pkce().verifier); + }); + }); + + describe('create_state', ()=>{ + it('is a non-empty URL-safe string, distinct per call', ()=>{ + const a = create_state(); + const b = create_state(); + expect(a).toMatch(URL_SAFE); + expect(a.length).toBeGreaterThanOrEqual(16); + expect(a).not.toBe(b); + }); + }); +}); diff --git a/src/__tests__/auth/request-identity.test.ts b/src/__tests__/auth/request-identity.test.ts new file mode 100644 index 0000000..efee473 --- /dev/null +++ b/src/__tests__/auth/request-identity.test.ts @@ -0,0 +1,91 @@ +import {describe, it, expect} from 'vitest'; +import {resolve_request_identity} from '../../auth/request-identity'; +import {UsageError} from '../../utils/errors'; + +const base = {credential_type: 'api_key' as const, env: {} as NodeJS.ProcessEnv}; + +describe('resolve_request_identity — team id precedence (flag > env > profile)', ()=>{ + it('uses --team-id over env and profile', ()=>{ + const r = resolve_request_identity({ + ...base, team_id_flag: '10', profile_team_id: 30, + env: {REPLY_TEAM_ID: '20'} as NodeJS.ProcessEnv, + }); + expect(r.headers['X-TEAM-ID']).toBe('10'); + }); + + it('uses REPLY_TEAM_ID over profile when no flag', ()=>{ + const r = resolve_request_identity({ + ...base, profile_team_id: 30, env: {REPLY_TEAM_ID: '20'} as NodeJS.ProcessEnv, + }); + expect(r.headers['X-TEAM-ID']).toBe('20'); + }); + + it('falls back to the profile team_id', ()=>{ + const r = resolve_request_identity({...base, profile_team_id: 30}); + expect(r.headers['X-TEAM-ID']).toBe('30'); + }); + + it('emits no X-TEAM-ID when nothing is set', ()=>{ + const r = resolve_request_identity({...base}); + expect(r.headers['X-TEAM-ID']).toBeUndefined(); + expect(r.headers).toEqual({}); + }); +}); + +describe('resolve_request_identity — acting user (flag only)', ()=>{ + it('maps --user-id to X-USER-ID', ()=>{ + const r = resolve_request_identity({...base, user_id_flag: '1223'}); + expect(r.headers['X-USER-ID']).toBe('1223'); + expect(r.headers['X-User-Email']).toBeUndefined(); + }); + + it('maps --user-email to X-User-Email (with a team id)', ()=>{ + const r = resolve_request_identity({...base, user_email_flag: 'a@b.co', team_id_flag: '7'}); + expect(r.headers['X-User-Email']).toBe('a@b.co'); + expect(r.headers['X-TEAM-ID']).toBe('7'); + }); +}); + +describe('resolve_request_identity — validation (UsageError)', ()=>{ + it('rejects both --user-id and --user-email', ()=>{ + expect(()=>resolve_request_identity({...base, user_id_flag: '1', user_email_flag: 'a@b.co', team_id_flag: '2'})) + .toThrow(UsageError); + }); + + it('rejects --user-email without a team id', ()=>{ + expect(()=>resolve_request_identity({...base, user_email_flag: 'a@b.co'})).toThrow(UsageError); + }); + + it('rejects a non-integer --team-id', ()=>{ + expect(()=>resolve_request_identity({...base, team_id_flag: 'abc'})).toThrow(UsageError); + }); + + it('rejects a non-integer --user-id', ()=>{ + expect(()=>resolve_request_identity({...base, user_id_flag: '1.5'})).toThrow(UsageError); + }); + + it('rejects a non-integer REPLY_TEAM_ID', ()=>{ + expect(()=>resolve_request_identity({...base, env: {REPLY_TEAM_ID: 'x'} as NodeJS.ProcessEnv})) + .toThrow(UsageError); + }); +}); + +describe('resolve_request_identity — OAuth note', ()=>{ + it('warns (but still sends) when an identity flag is used with OAuth', ()=>{ + const r = resolve_request_identity({...base, credential_type: 'oauth', user_id_flag: '5'}); + expect(r.warnings).toHaveLength(1); + expect(r.warnings[0]).toMatch(/OAuth/i); + expect(r.headers['X-USER-ID']).toBe('5'); + }); + + it('does not warn for an identity flag with an API key', ()=>{ + const r = resolve_request_identity({...base, credential_type: 'api_key', user_id_flag: '5'}); + expect(r.warnings).toEqual([]); + }); + + it('does not warn for a team id alone under OAuth', ()=>{ + const r = resolve_request_identity({...base, credential_type: 'oauth', team_id_flag: '9'}); + expect(r.warnings).toEqual([]); + expect(r.headers['X-TEAM-ID']).toBe('9'); + }); +}); diff --git a/src/__tests__/auth/resolve.test.ts b/src/__tests__/auth/resolve.test.ts new file mode 100644 index 0000000..88c0979 --- /dev/null +++ b/src/__tests__/auth/resolve.test.ts @@ -0,0 +1,96 @@ +import {describe, it, expect, vi} from 'vitest'; +import {resolve_credential} from '../../auth/resolve'; +import {UsageError} from '../../utils/errors'; +import {APP_NAME} from '../../config'; +import type {Credential_record, CredentialStore, Oauth_record} from '../../credentials/types'; + +const API_KEY_ENV = `${APP_NAME.toUpperCase()}_API_KEY`; +const HOST = 'api.dev.reply.io'; +const NOW = 1_700_000_000_000; + +const fake_store = (seed: Record = {})=>{ + const map: Record = {...seed}; + const store: CredentialStore = { + get: vi.fn(async(h: string)=>map[h]), + set: vi.fn(async(h: string, r: Credential_record)=>{map[h] = r;}), + remove: vi.fn(async(h: string)=>{ + const had = h in map; + delete map[h]; + return had; + }), + keys: vi.fn(async()=>Object.keys(map)), + }; + return {store, map}; +}; + +const fresh_oauth = (over: Partial = {}): Oauth_record=>({ + type: 'oauth', access_token: 'at', refresh_token: 'rt', expires_at: NOW + 3_600_000, ...over, +}); + +describe('auth/resolve — credential precedence', ()=>{ + it('1) --api-key flag wins, is ephemeral, and never touches the store', async()=>{ + const {store} = fake_store({[HOST]: {type: 'api_key', key: 'stored'}}); + const r = await resolve_credential({api_key: 'flagkey'}, {key: HOST, store, env: {[API_KEY_ENV]: 'envkey'}, now: NOW}); + expect(r).toMatchObject({token: 'flagkey', type: 'api_key', source: 'flag', ephemeral: true}); + expect(store.get).not.toHaveBeenCalled(); + }); + + it('2) _API_KEY env is used when no flag, ephemeral, store untouched', async()=>{ + const {store} = fake_store({[HOST]: {type: 'api_key', key: 'stored'}}); + const r = await resolve_credential({}, {key: HOST, store, env: {[API_KEY_ENV]: 'envkey'}, now: NOW}); + expect(r).toMatchObject({token: 'envkey', source: 'env', ephemeral: true}); + expect(store.get).not.toHaveBeenCalled(); + }); + + it('3) flag beats env', async()=>{ + const {store} = fake_store(); + const r = await resolve_credential({api_key: 'flagkey'}, {key: HOST, store, env: {[API_KEY_ENV]: 'envkey'}, now: NOW}); + expect(r.token).toBe('flagkey'); + }); + + it('3) falls back to a stored api_key record (not ephemeral)', async()=>{ + const {store} = fake_store({[HOST]: {type: 'api_key', key: 'stored', user: {username: 'u'}}}); + const r = await resolve_credential({}, {key: HOST, store, env: {}, now: NOW}); + expect(r).toMatchObject({token: 'stored', type: 'api_key', source: 'store', ephemeral: false}); + }); + + it('returns a stored oauth access token when it is still fresh', async()=>{ + const {store} = fake_store({[HOST]: fresh_oauth()}); + const refresh = vi.fn(); + const r = await resolve_credential({}, {key: HOST, store, env: {}, now: NOW, refresh}); + expect(r).toMatchObject({token: 'at', type: 'oauth', source: 'store'}); + expect(refresh).not.toHaveBeenCalled(); + }); + + it('refreshes an expired oauth token and persists the new record', async()=>{ + const {store} = fake_store({[HOST]: fresh_oauth({expires_at: NOW - 1})}); + const refreshed: Oauth_record = fresh_oauth({access_token: 'new_at', expires_at: NOW + 3_600_000}); + const refresh = vi.fn(async()=>refreshed); + const r = await resolve_credential({}, {key: HOST, store, env: {}, now: NOW, refresh}); + expect(refresh).toHaveBeenCalledOnce(); + expect(store.set).toHaveBeenCalledWith(HOST, refreshed); + expect(r.token).toBe('new_at'); + }); + + it('clears the record and errors when an expired token has no refresh token', async()=>{ + const {store} = fake_store({[HOST]: fresh_oauth({expires_at: NOW - 1, refresh_token: undefined})}); + await expect(resolve_credential({}, {key: HOST, store, env: {}, now: NOW, refresh: vi.fn()})) + .rejects.toBeInstanceOf(UsageError); + expect(store.remove).toHaveBeenCalledWith(HOST); + }); + + it('clears the record and errors when refresh fails', async()=>{ + const {store} = fake_store({[HOST]: fresh_oauth({expires_at: NOW - 1})}); + const refresh = vi.fn(async()=>{throw new Error('token endpoint said no');}); + await expect(resolve_credential({}, {key: HOST, store, env: {}, now: NOW, refresh})) + .rejects.toBeInstanceOf(UsageError); + expect(store.remove).toHaveBeenCalledWith(HOST); + }); + + it('errors (UsageError) when nothing is available and writes nothing', async()=>{ + const {store} = fake_store(); + await expect(resolve_credential({}, {key: HOST, store, env: {}, now: NOW})) + .rejects.toBeInstanceOf(UsageError); + expect(store.set).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/auth/status.test.ts b/src/__tests__/auth/status.test.ts new file mode 100644 index 0000000..e5460bc --- /dev/null +++ b/src/__tests__/auth/status.test.ts @@ -0,0 +1,59 @@ +import {describe, it, expect} from 'vitest'; +import {describe_status} from '../../auth/status'; +import type {Credential_record} from '../../credentials/types'; + +const NOW = 1_700_000_000_000; +const base = {profile: 'default', now: NOW}; + +describe('auth/status — describe_status', ()=>{ + it('reports the --api-key flag as an ephemeral api_key source', ()=>{ + const s = describe_status({...base, api_key_flag: 'k'}); + expect(s).toMatchObject({authenticated: true, source: 'flag', method: 'api_key', profile: 'default'}); + }); + + it('reports the env var when no flag is present', ()=>{ + const s = describe_status({...base, api_key_env: 'k'}); + expect(s).toMatchObject({authenticated: true, source: 'env', method: 'api_key'}); + }); + + it('prefers the flag over the env var', ()=>{ + const s = describe_status({...base, api_key_flag: 'f', api_key_env: 'e'}); + expect(s.source).toBe('flag'); + }); + + it('carries the active profile name', ()=>{ + const s = describe_status({profile: 'dev', now: NOW}); + expect(s.profile).toBe('dev'); + }); + + it('reports a stored api_key record with its user', ()=>{ + const record: Credential_record = {type: 'api_key', key: 'k', user: {username: 'u@x'}}; + const s = describe_status({...base, record}); + expect(s).toMatchObject({authenticated: true, source: 'store', method: 'api_key'}); + expect(s.user).toEqual({username: 'u@x'}); + }); + + it('reports a fresh stored oauth record with ISO expiry and expired=false', ()=>{ + const record: Credential_record = {type: 'oauth', access_token: 'a', expires_at: NOW + 3_600_000, user: {username: 'u'}}; + const s = describe_status({...base, record}); + expect(s).toMatchObject({authenticated: true, source: 'store', method: 'oauth', expired: false}); + expect(s.expires_at).toBe(new Date(NOW + 3_600_000).toISOString()); + }); + + it('marks a past-expiry oauth record as expired', ()=>{ + const record: Credential_record = {type: 'oauth', access_token: 'a', expires_at: NOW - 1}; + const s = describe_status({...base, record}); + expect(s.expired).toBe(true); + }); + + it('reports not-authenticated (still with the profile) when nothing is available', ()=>{ + const s = describe_status({profile: 'default', now: NOW}); + expect(s).toEqual({authenticated: false, profile: 'default'}); + }); + + it('never includes a raw secret', ()=>{ + const record: Credential_record = {type: 'api_key', key: 'super_secret_key'}; + const s = describe_status({...base, api_key_flag: 'flag_secret', record}); + expect(JSON.stringify(s)).not.toContain('secret'); + }); +}); diff --git a/src/__tests__/auth/token.test.ts b/src/__tests__/auth/token.test.ts new file mode 100644 index 0000000..6bca9ae --- /dev/null +++ b/src/__tests__/auth/token.test.ts @@ -0,0 +1,88 @@ +import {describe, it, expect} from 'vitest'; +import { + needs_refresh, + expires_at_from, + build_token_exchange_body, + build_refresh_body, + to_oauth_record, + DEFAULT_SKEW_MS, +} from '../../auth/token'; + +const NOW = 1_700_000_000_000; + +describe('auth/token', ()=>{ + describe('needs_refresh', ()=>{ + it('is true once the token has expired', ()=>{ + expect(needs_refresh({expires_at: NOW - 1}, NOW)).toBe(true); + }); + + it('is true within the skew window before expiry', ()=>{ + expect(needs_refresh({expires_at: NOW + DEFAULT_SKEW_MS - 1}, NOW)).toBe(true); + }); + + it('is false well before expiry', ()=>{ + expect(needs_refresh({expires_at: NOW + 3_600_000}, NOW)).toBe(false); + }); + + it('honors a custom skew', ()=>{ + expect(needs_refresh({expires_at: NOW + 5_000}, NOW, 10_000)).toBe(true); + expect(needs_refresh({expires_at: NOW + 5_000}, NOW, 1_000)).toBe(false); + }); + }); + + describe('expires_at_from', ()=>{ + it('adds expires_in seconds to now', ()=>{ + expect(expires_at_from({access_token: 'a', expires_in: 3600}, NOW)).toBe(NOW + 3_600_000); + }); + + it('defaults to one hour when expires_in is absent', ()=>{ + expect(expires_at_from({access_token: 'a'}, NOW)).toBe(NOW + 3_600_000); + }); + }); + + describe('build_token_exchange_body', ()=>{ + it('builds an authorization_code grant with PKCE verifier', ()=>{ + const body = build_token_exchange_body({ + code: 'c', verifier: 'v', redirect_uri: 'http://127.0.0.1:5000/callback', client_id: 'Reply.Cli', + }); + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('code')).toBe('c'); + expect(body.get('code_verifier')).toBe('v'); + expect(body.get('redirect_uri')).toBe('http://127.0.0.1:5000/callback'); + expect(body.get('client_id')).toBe('Reply.Cli'); + }); + }); + + describe('build_refresh_body', ()=>{ + it('builds a refresh_token grant', ()=>{ + const body = build_refresh_body({refresh_token: 'rt', client_id: 'Reply.Cli'}); + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('refresh_token')).toBe('rt'); + expect(body.get('client_id')).toBe('Reply.Cli'); + }); + }); + + describe('to_oauth_record', ()=>{ + it('maps a token response into a stored oauth record', ()=>{ + const rec = to_oauth_record({access_token: 'at', refresh_token: 'rt', expires_in: 3600}, NOW); + expect(rec).toEqual({type: 'oauth', access_token: 'at', refresh_token: 'rt', expires_at: NOW + 3_600_000}); + }); + + it('keeps the previous refresh token when the response omits one (no rotation)', ()=>{ + const rec = to_oauth_record({access_token: 'at2', expires_in: 3600}, NOW, {refresh_token: 'old'}); + expect(rec.refresh_token).toBe('old'); + }); + + it('prefers a rotated refresh token from the response', ()=>{ + const rec = to_oauth_record({access_token: 'at2', refresh_token: 'new', expires_in: 3600}, NOW, {refresh_token: 'old'}); + expect(rec.refresh_token).toBe('new'); + }); + + it('carries the previous principal forward on refresh (token endpoint returns no user)', ()=>{ + const rec = to_oauth_record( + {access_token: 'at2', expires_in: 3600}, NOW, + {refresh_token: 'old', user: {id: 1223, username: 'v@r.io', team_id: 1045}}); + expect(rec.user).toEqual({id: 1223, username: 'v@r.io', team_id: 1045}); + }); + }); +}); diff --git a/src/__tests__/commands/auth-login.test.ts b/src/__tests__/commands/auth-login.test.ts new file mode 100644 index 0000000..d5f8486 --- /dev/null +++ b/src/__tests__/commands/auth-login.test.ts @@ -0,0 +1,82 @@ +import {describe, it, expect, beforeEach, vi} from 'vitest'; + +const mock_fetch = vi.fn(); +vi.stubGlobal('fetch', mock_fetch); + +import {handle_login, handle_login_token, handle_whoami} from '../../commands/auth'; +import type {Cli_context} from '../../context'; +import type {CredentialStore, Oauth_record, Credential_record} from '../../credentials/types'; +import {Api_error} from '../../utils/errors'; + +const ok = (data: unknown)=> + new Response(JSON.stringify(data), {status: 200, headers: {'Content-Type': 'application/json'}}); +const unauthorized = ()=>new Response('no', {status: 401}); // non-transient — no retry backoff + +const make_store = ()=>{ + const saved: Record = {}; + return { + set: vi.fn(async(k: string, r: Credential_record)=>{ saved[k] = r; }), + get: vi.fn(async(k: string)=>saved[k]), + remove: vi.fn(async()=>true), + keys: vi.fn(async()=>Object.keys(saved)), + saved, + }; +}; + +const make_ctx = (store: ReturnType): Cli_context=>({ + profile: 'default', authority: 'https://auth', api_base: 'https://api.dev.reply.io/v3', + key: 'default', store: store as unknown as CredentialStore, refresh: async(r)=>r, +}); + +const OAUTH: Oauth_record = {type: 'oauth', access_token: 'AT', refresh_token: 'RT', expires_at: 4_000_000_000_000}; +const fake_login = async(): Promise=>({...OAUTH}); + +describe('handle_login — persist before identity lookup', ()=>{ + beforeEach(()=>{ vi.clearAllMocks(); }); + + it('stores the login then enriches it with the principal on whoami success', async()=>{ + mock_fetch.mockResolvedValue(ok({userId: 1223, username: 'v@r.io', teamId: 1045})); + const store = make_store(); + await handle_login(make_ctx(store), {}, fake_login); + expect(store.set).toHaveBeenCalled(); + expect(store.saved['default']).toMatchObject({ + type: 'oauth', access_token: 'AT', user: {id: 1223, username: 'v@r.io', team_id: 1045}, + }); + }); + + it('keeps the login stored even when whoami fails (no lost token)', async()=>{ + mock_fetch.mockResolvedValue(unauthorized()); + const store = make_store(); + await expect(handle_login(make_ctx(store), {}, fake_login)).resolves.toBeUndefined(); + expect(store.saved['default']).toMatchObject({type: 'oauth', access_token: 'AT'}); + }); +}); + +describe('handle_login_token — verify then store', ()=>{ + beforeEach(()=>{ vi.clearAllMocks(); }); + + it('stores the api key with the resolved principal on success', async()=>{ + mock_fetch.mockResolvedValue(ok({userId: 7, username: 'a@b.co', teamId: 9})); + const store = make_store(); + await handle_login_token(make_ctx(store), {}, async()=>'the-key'); + expect(store.saved['default']).toMatchObject({type: 'api_key', key: 'the-key', user: {id: 7}}); + }); + + it('does NOT store an unverifiable key (whoami rejects it)', async()=>{ + mock_fetch.mockResolvedValue(unauthorized()); + const store = make_store(); + await expect(handle_login_token(make_ctx(store), {}, async()=>'bad-key')).rejects.toBeInstanceOf(Api_error); + expect(store.set).not.toHaveBeenCalled(); + }); +}); + +describe('handle_whoami — ephemeral credentials are never written to disk', ()=>{ + beforeEach(()=>{ vi.clearAllMocks(); }); + + it('does not persist a credential passed via --api-key', async()=>{ + mock_fetch.mockResolvedValue(ok({userId: 1, username: 'x', teamId: 2})); + const store = make_store(); + await handle_whoami(make_ctx(store), {apiKey: 'ephemeral-key'}); + expect(store.set).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/commands/auth-whoami-headers.test.ts b/src/__tests__/commands/auth-whoami-headers.test.ts new file mode 100644 index 0000000..508ce93 --- /dev/null +++ b/src/__tests__/commands/auth-whoami-headers.test.ts @@ -0,0 +1,63 @@ +import {describe, it, expect, beforeEach, vi} from 'vitest'; + +const mock_fetch = vi.fn(); +vi.stubGlobal('fetch', mock_fetch); + +import {handle_whoami} from '../../commands/auth'; +import type {Cli_context} from '../../context'; +import type {CredentialStore, Oauth_record} from '../../credentials/types'; + +const json_res = (data: unknown)=> + new Response(JSON.stringify(data), {status: 200, headers: {'Content-Type': 'application/json'}}); + +const oauth_record: Oauth_record = { + type: 'oauth', access_token: 'tok', refresh_token: 'r', expires_at: 4_000_000_000_000, +}; + +const fake_store = (record: Oauth_record): CredentialStore=>({ + get: async()=>record, + set: async()=>{}, + remove: async()=>true, + keys: async()=>['dev'], +}); + +const ctx = (team_id?: number): Cli_context=>({ + profile: 'dev', + authority: 'https://auth.example', + api_base: 'https://api.dev.reply.io/v3', + key: 'dev', + team_id, + store: fake_store(oauth_record), + refresh: async(r)=>r, +}); + +const sent_headers = (): Record=>mock_fetch.mock.calls[0][1].headers; + +describe('auth whoami — attaches team/acting-user headers', ()=>{ + beforeEach(()=>{ + vi.clearAllMocks(); + mock_fetch.mockResolvedValue(json_res({userId: 1, username: 'a@b.co', teamId: 5})); + }); + + it('sends the profile team_id as X-TEAM-ID alongside the bearer token', async()=>{ + await handle_whoami(ctx(1045), {}); + expect(sent_headers()).toMatchObject({Authorization: 'Bearer tok', 'X-TEAM-ID': '1045'}); + }); + + it('lets --team-id override the profile team_id', async()=>{ + await handle_whoami(ctx(1045), {teamId: '2001'}); + expect(sent_headers()['X-TEAM-ID']).toBe('2001'); + }); + + it('sends X-USER-ID from --user-id (org-key acting user)', async()=>{ + await handle_whoami(ctx(1045), {userId: '77'}); + expect(sent_headers()['X-USER-ID']).toBe('77'); + }); + + it('sends no team/user headers when none are configured', async()=>{ + await handle_whoami(ctx(undefined), {}); + const h = sent_headers(); + expect(h['X-TEAM-ID']).toBeUndefined(); + expect(h['X-USER-ID']).toBeUndefined(); + }); +}); diff --git a/src/__tests__/commands/auth.test.ts b/src/__tests__/commands/auth.test.ts new file mode 100644 index 0000000..5ec9404 --- /dev/null +++ b/src/__tests__/commands/auth.test.ts @@ -0,0 +1,50 @@ +import {describe, it, expect} from 'vitest'; +import {normalize_principal, principal_label} from '../../commands/auth'; + +// The v3 /whoami contract is authoritative: +// record WhoamiResponse(int UserId, string Username, int TeamId) +// serialized camelCase as {userId, username, teamId}. No email, no team name. +describe('normalize_principal — maps the real v3 /whoami contract', ()=>{ + it('maps userId/username/teamId to id/username/team_id', ()=>{ + expect(normalize_principal({userId: 1223, username: 'vitaliy@reply.io', teamId: 1045})) + .toEqual({id: 1223, username: 'vitaliy@reply.io', team_id: 1045}); + }); + + it('yields an empty principal when fields are absent', ()=>{ + expect(normalize_principal({})).toEqual({}); + }); + + it('ignores keys that are not part of the contract', ()=>{ + expect(normalize_principal({email: 'x@y.z', teamName: 'Acme', accountId: 7})) + .toEqual({}); + }); + + it('guards against wrong types (ids must be numbers, username a string)', ()=>{ + expect(normalize_principal({userId: '1223', username: 42, teamId: '1045'})) + .toEqual({}); + }); +}); + +describe('principal_label — surfaces the ids that /whoami actually returns', ()=>{ + it('shows the username plus user and team ids', ()=>{ + expect(principal_label({id: 1223, username: 'vitaliy@reply.io', team_id: 1045})) + .toBe('vitaliy@reply.io (user 1223, team 1045)'); + }); + + it('shows just the username when no ids are present', ()=>{ + expect(principal_label({username: 'vitaliy@reply.io'})).toBe('vitaliy@reply.io'); + }); + + it('shows the team id even when the username is present but user id is not', ()=>{ + expect(principal_label({username: 'vitaliy@reply.io', team_id: 1045})) + .toBe('vitaliy@reply.io (team 1045)'); + }); + + it('falls back to #id (without a redundant "user" bit) when username is missing', ()=>{ + expect(principal_label({id: 1223, team_id: 1045})).toBe('#1223 (team 1045)'); + }); + + it('reports unknown when nothing identifies the principal', ()=>{ + expect(principal_label({})).toBe('unknown'); + }); +}); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts new file mode 100644 index 0000000..fe9eb3c --- /dev/null +++ b/src/__tests__/config.test.ts @@ -0,0 +1,54 @@ +import {describe, it, expect} from 'vitest'; +import path from 'path'; +import {APP_NAME, env_var, default_config_dir, config_dir} from '../config'; + +describe('config', ()=>{ + describe('env_var', ()=>{ + it('prefixes with the uppercased APP_NAME', ()=>{ + expect(env_var('API_KEY')).toBe(`${APP_NAME.toUpperCase()}_API_KEY`); + }); + + it('replaces dashes in APP_NAME with underscores', ()=>{ + expect(env_var('ENV', 'my-app')).toBe('MY_APP_ENV'); + }); + }); + + describe('default_config_dir', ()=>{ + it('uses XDG_CONFIG_HOME/ on linux when set', ()=>{ + const dir = default_config_dir('linux', {XDG_CONFIG_HOME: '/xdg'}, '/home/u'); + expect(dir).toBe(path.join('/xdg', APP_NAME)); + }); + + it('falls back to ~/.config/ on linux without XDG', ()=>{ + const dir = default_config_dir('linux', {}, '/home/u'); + expect(dir).toBe(path.join('/home/u', '.config', APP_NAME)); + }); + + it('ignores a blank XDG_CONFIG_HOME', ()=>{ + const dir = default_config_dir('linux', {XDG_CONFIG_HOME: ' '}, '/home/u'); + expect(dir).toBe(path.join('/home/u', '.config', APP_NAME)); + }); + + it('uses APPDATA/ on win32 when set', ()=>{ + const dir = default_config_dir('win32', {APPDATA: 'C:\\Users\\u\\AppData\\Roaming'}, 'C:\\Users\\u'); + expect(dir).toBe(path.join('C:\\Users\\u\\AppData\\Roaming', APP_NAME)); + }); + + it('falls back to /AppData/Roaming/ on win32 without APPDATA', ()=>{ + const dir = default_config_dir('win32', {}, 'C:\\Users\\u'); + expect(dir).toBe(path.join('C:\\Users\\u', 'AppData', 'Roaming', APP_NAME)); + }); + }); + + describe('config_dir', ()=>{ + it('honors the _CONFIG_DIR override', ()=>{ + const override = {[`${APP_NAME.toUpperCase()}_CONFIG_DIR`]: '/custom/dir'}; + expect(config_dir(override)).toBe('/custom/dir'); + }); + + it('ignores a blank override and falls through to the default', ()=>{ + const override = {[`${APP_NAME.toUpperCase()}_CONFIG_DIR`]: ' '}; + expect(config_dir(override)).toBe(default_config_dir(process.platform, override, require('os').homedir())); + }); + }); +}); diff --git a/src/__tests__/credentials/file-store.test.ts b/src/__tests__/credentials/file-store.test.ts new file mode 100644 index 0000000..fc745ee --- /dev/null +++ b/src/__tests__/credentials/file-store.test.ts @@ -0,0 +1,110 @@ +import {describe, it, expect, beforeEach, afterEach} from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import {FileCredentialStore} from '../../credentials/file-store'; +import {RuntimeError} from '../../utils/errors'; +import type {Credential_record} from '../../credentials/types'; + +const POSIX = process.platform !== 'win32'; + +const OAUTH: Credential_record = { + type: 'oauth', + access_token: 'at', + refresh_token: 'rt', + expires_at: 1_700_000_000_000, + user: {username: 'oleg@reply.io', id: 1}, +}; +const API_KEY: Credential_record = {type: 'api_key', key: 'sk_test', user: {username: 'ann@reply.io'}}; + +let dir: string; +let file: string; +let store: FileCredentialStore; + +beforeEach(()=>{ + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reply-store-')); + file = path.join(dir, 'nested', 'credentials.json'); + store = new FileCredentialStore(file); +}); + +afterEach(()=>{ + fs.rmSync(dir, {recursive: true, force: true}); +}); + +describe('credentials/FileCredentialStore', ()=>{ + it('returns undefined for a host before anything is stored', async()=>{ + expect(await store.get('oauth.dev.replyapp.io')).toBeUndefined(); + expect(await store.keys()).toEqual([]); + }); + + it('round-trips a record for a host', async()=>{ + await store.set('oauth.dev.replyapp.io', OAUTH); + expect(await store.get('oauth.dev.replyapp.io')).toEqual(OAUTH); + }); + + it('keeps records per-host isolated', async()=>{ + await store.set('oauth.dev.replyapp.io', OAUTH); + await store.set('api.reply.io', API_KEY); + expect(await store.get('oauth.dev.replyapp.io')).toEqual(OAUTH); + expect(await store.get('api.reply.io')).toEqual(API_KEY); + expect((await store.keys()).sort()).toEqual(['api.reply.io', 'oauth.dev.replyapp.io']); + }); + + it('overwrites an existing host record', async()=>{ + await store.set('h', OAUTH); + await store.set('h', API_KEY); + expect(await store.get('h')).toEqual(API_KEY); + }); + + it('remove deletes a host and reports whether it existed', async()=>{ + await store.set('h', API_KEY); + expect(await store.remove('h')).toBe(true); + expect(await store.get('h')).toBeUndefined(); + expect(await store.remove('h')).toBe(false); + }); + + it('does not disturb other hosts on remove', async()=>{ + await store.set('a', OAUTH); + await store.set('b', API_KEY); + await store.remove('a'); + expect(await store.get('b')).toEqual(API_KEY); + }); + + it.skipIf(!POSIX)('writes the file with 0600 permissions', async()=>{ + await store.set('h', API_KEY); + const mode = fs.statSync(file).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it.skipIf(!POSIX)('creates the parent config dir with 0700 permissions', async()=>{ + await store.set('h', API_KEY); + const mode = fs.statSync(path.dirname(file)).mode & 0o777; + expect(mode).toBe(0o700); + }); + + it.skipIf(!POSIX)('keeps 0600 after a rewrite', async()=>{ + await store.set('h', API_KEY); + await store.set('h2', OAUTH); + const mode = fs.statSync(file).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it('treats an empty file as no credentials', async()=>{ + fs.mkdirSync(path.dirname(file), {recursive: true}); + fs.writeFileSync(file, ''); + expect(await store.keys()).toEqual([]); + }); + + it('throws a RuntimeError on a corrupt store file', async()=>{ + fs.mkdirSync(path.dirname(file), {recursive: true}); + fs.writeFileSync(file, '{ this is not json'); + await expect(store.get('h')).rejects.toBeInstanceOf(RuntimeError); + await expect(store.get('h')).rejects.toMatchObject({code: 'store.corrupt', detail: file}); + }); + + it('throws a RuntimeError when the file holds a non-object', async()=>{ + fs.mkdirSync(path.dirname(file), {recursive: true}); + fs.writeFileSync(file, '["nope"]'); + await expect(store.keys()).rejects.toBeInstanceOf(RuntimeError); + }); +}); diff --git a/src/__tests__/profile.test.ts b/src/__tests__/profile.test.ts new file mode 100644 index 0000000..22e094d --- /dev/null +++ b/src/__tests__/profile.test.ts @@ -0,0 +1,209 @@ +import {describe, it, expect, beforeEach, afterEach} from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import {resolve_profile, current_profile_name, list_profiles, set_current_profile, add_profile, set_profile, PROD} from '../profile'; +import {UsageError, RuntimeError} from '../utils/errors'; + +let dir: string; + +const env_for = (over: Record = {})=>({REPLY_CONFIG_DIR: dir, ...over}); + +const write_config = (obj: unknown)=> + fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify(obj), 'utf8'); + +beforeEach(()=>{ + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reply-profile-')); +}); +afterEach(()=>{ + fs.rmSync(dir, {recursive: true, force: true}); +}); + +describe('profile — no environment abstraction, only a default + user profiles', ()=>{ + it('resolves the built-in default to prod when nothing is set and no config exists', ()=>{ + const p = resolve_profile(undefined, env_for()); + expect(p).toEqual({name: 'default', authority: PROD.authority, api_base: PROD.api_base}); + expect(p.api_base).toBe('https://api.reply.io/v3'); + expect(p.authority).toBe('https://oauth.reply.io'); + }); + + it('selects a user-defined profile via --profile', ()=>{ + write_config({profiles: {dev: {authority: 'https://oauth.dev.replyapp.io', api_base: 'https://api.dev.reply.io/v3'}}}); + const p = resolve_profile('dev', env_for()); + expect(p).toMatchObject({name: 'dev', authority: 'https://oauth.dev.replyapp.io', api_base: 'https://api.dev.reply.io/v3'}); + }); + + it('uses REPLY_PROFILE when no flag is given', ()=>{ + write_config({profiles: {dev: {authority: 'https://a', api_base: 'https://b'}}}); + expect(resolve_profile(undefined, env_for({REPLY_PROFILE: 'dev'})).name).toBe('dev'); + }); + + it('lets the --profile flag beat REPLY_PROFILE', ()=>{ + write_config({profiles: { + dev: {authority: 'https://dev-a', api_base: 'https://dev-b'}, + stg: {authority: 'https://stg-a', api_base: 'https://stg-b'}, + }}); + expect(resolve_profile('stg', env_for({REPLY_PROFILE: 'dev'})).name).toBe('stg'); + }); + + it('throws a UsageError for an unknown profile', ()=>{ + expect(()=>resolve_profile('nope', env_for())).toThrow(UsageError); + }); + + it('lets a user config override the default profile', ()=>{ + write_config({profiles: {default: {authority: 'https://my-auth', api_base: 'https://my-api'}}}); + expect(resolve_profile(undefined, env_for())).toMatchObject({name: 'default', authority: 'https://my-auth'}); + }); + + it('strips trailing slashes from profile URLs', ()=>{ + write_config({profiles: {dev: {authority: 'https://a/', api_base: 'https://b/v3/'}}}); + const p = resolve_profile('dev', env_for()); + expect(p.authority).toBe('https://a'); + expect(p.api_base).toBe('https://b/v3'); + }); + + it('inherits missing URLs from the embedded default (prod)', ()=>{ + write_config({profiles: {'alice@reply.io': {}}}); + const p = resolve_profile('alice@reply.io', env_for()); + expect(p).toEqual({name: 'alice@reply.io', authority: PROD.authority, api_base: PROD.api_base}); + }); + + it('inherits per-field: overrides api_base but keeps the prod authority', ()=>{ + write_config({profiles: {stg: {api_base: 'https://api.stage.reply.io/v3'}}}); + const p = resolve_profile('stg', env_for()); + expect(p.authority).toBe(PROD.authority); + expect(p.api_base).toBe('https://api.stage.reply.io/v3'); + }); + + it('throws a RuntimeError on a corrupt config file', ()=>{ + fs.writeFileSync(path.join(dir, 'config.json'), '{ not json', 'utf8'); + expect(()=>resolve_profile('dev', env_for())).toThrow(RuntimeError); + }); +}); + +describe('profile — current (persisted) profile', ()=>{ + const two = { + dev: {authority: 'https://dev-a', api_base: 'https://dev-b'}, + stg: {authority: 'https://stg-a', api_base: 'https://stg-b'}, + }; + + it('resolve_profile falls back to the persisted current profile', ()=>{ + write_config({current_profile: 'dev', profiles: two}); + expect(resolve_profile(undefined, env_for()).name).toBe('dev'); + }); + + it('the --profile flag beats the persisted current', ()=>{ + write_config({current_profile: 'dev', profiles: two}); + expect(resolve_profile('stg', env_for()).name).toBe('stg'); + }); + + it('REPLY_PROFILE env beats the persisted current', ()=>{ + write_config({current_profile: 'dev', profiles: two}); + expect(resolve_profile(undefined, env_for({REPLY_PROFILE: 'stg'})).name).toBe('stg'); + }); + + it('current_profile_name is default when unset, else the persisted value', ()=>{ + expect(current_profile_name(env_for())).toBe('default'); + write_config({current_profile: 'dev', profiles: two}); + expect(current_profile_name(env_for())).toBe('dev'); + }); + + it('list_profiles reports default + user profiles and the current one', ()=>{ + write_config({current_profile: 'dev', profiles: two}); + const l = list_profiles(env_for()); + expect(l.current).toBe('dev'); + expect([...l.available].sort()).toEqual(['default', 'dev', 'stg']); + }); + + it('set_current_profile persists the choice and preserves user profiles', ()=>{ + write_config({profiles: two}); + set_current_profile('dev', env_for()); + expect(current_profile_name(env_for())).toBe('dev'); + expect(resolve_profile('stg', env_for()).authority).toBe('https://stg-a'); + }); + + it('set_current_profile allows the built-in default even with no config', ()=>{ + set_current_profile('default', env_for()); + expect(current_profile_name(env_for())).toBe('default'); + }); + + it('set_current_profile rejects an unknown profile', ()=>{ + expect(()=>set_current_profile('nope', env_for())).toThrow(UsageError); + }); +}); + +describe('profile — add (create, URLs optional)', ()=>{ + it('creates a URL-less profile that inherits prod', ()=>{ + add_profile('alice@reply.io', {}, env_for()); + expect(resolve_profile('alice@reply.io', env_for())).toEqual({ + name: 'alice@reply.io', authority: PROD.authority, api_base: PROD.api_base, + }); + }); + + it('creates a profile with explicit URLs', ()=>{ + add_profile('dev', {authority: 'https://oauth.dev.replyapp.io', api_base: 'https://api.dev.reply.io/v3'}, env_for()); + expect(resolve_profile('dev', env_for())).toMatchObject({ + authority: 'https://oauth.dev.replyapp.io', api_base: 'https://api.dev.reply.io/v3', + }); + }); + + it('preserves other profiles and lets set_current select the new one', ()=>{ + add_profile('alice@reply.io', {}, env_for()); + add_profile('bob@reply.io', {}, env_for()); + set_current_profile('bob@reply.io', env_for()); + const l = list_profiles(env_for()); + expect([...l.available].sort()).toEqual(['alice@reply.io', 'bob@reply.io', 'default']); + expect(l.current).toBe('bob@reply.io'); + }); + + it('rejects adding the built-in default and empty names', ()=>{ + expect(()=>add_profile('default', {}, env_for())).toThrow(UsageError); + expect(()=>add_profile(' ', {}, env_for())).toThrow(UsageError); + }); +}); + +describe('profile — team_id property (account-specific, not inherited)', ()=>{ + it('surfaces a profile team_id from config', ()=>{ + write_config({profiles: {dev: {authority: 'https://a', api_base: 'https://b', team_id: 1045}}}); + expect(resolve_profile('dev', env_for()).team_id).toBe(1045); + }); + + it('has no team_id by default and does not inherit one', ()=>{ + write_config({profiles: {'alice@reply.io': {}}}); + expect(resolve_profile('alice@reply.io', env_for()).team_id).toBeUndefined(); + expect(resolve_profile(undefined, env_for()).team_id).toBeUndefined(); + }); + + it('tolerates a numeric-string team_id in hand-edited config', ()=>{ + write_config({profiles: {dev: {team_id: '1045'}}}); + expect(resolve_profile('dev', env_for()).team_id).toBe(1045); + }); + + it('creates a profile with a team_id via add_profile', ()=>{ + add_profile('alice@reply.io', {team_id: 1045}, env_for()); + expect(resolve_profile('alice@reply.io', env_for()).team_id).toBe(1045); + }); +}); + +describe('profile — set (edit an existing profile, merge-safe)', ()=>{ + it('sets team_id on an existing profile without touching its URLs', ()=>{ + add_profile('dev', {authority: 'https://a', api_base: 'https://b'}, env_for()); + set_profile('dev', {team_id: 7}, env_for()); + const p = resolve_profile('dev', env_for()); + expect(p.team_id).toBe(7); + expect(p.authority).toBe('https://a'); + expect(p.api_base).toBe('https://b'); + }); + + it('can pin a team on the built-in default (no named profile needed)', ()=>{ + set_profile('default', {team_id: 99}, env_for()); + const p = resolve_profile(undefined, env_for()); + expect(p.team_id).toBe(99); + expect(p.authority).toBe(PROD.authority); + expect(p.api_base).toBe(PROD.api_base); + }); + + it('rejects editing an unknown named profile', ()=>{ + expect(()=>set_profile('nope', {team_id: 1}, env_for())).toThrow(UsageError); + }); +}); diff --git a/src/__tests__/utils/client.test.ts b/src/__tests__/utils/client.test.ts new file mode 100644 index 0000000..b166e04 --- /dev/null +++ b/src/__tests__/utils/client.test.ts @@ -0,0 +1,123 @@ +import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'; + +const mock_fetch = vi.fn(); +vi.stubGlobal('fetch', mock_fetch); + +import {create_client, get} from '../../utils/client'; +import {Api_error, RuntimeError} from '../../utils/errors'; + +const BASE = 'https://api.dev.reply.io/v3'; + +const real_set_timeout = globalThis.setTimeout; +const instant_timers = ()=>{ + vi.stubGlobal('setTimeout', ((fn: (...a: unknown[])=>void)=>real_set_timeout(fn, 0)) as unknown as typeof setTimeout); +}; + +const json_res = (data: unknown, status = 200)=> + new Response(JSON.stringify(data), {status, headers: {'Content-Type': 'application/json'}}); +const err_res = (status: number, body: unknown = '', headers: Record = {})=> + new Response(typeof body === 'string' ? body : JSON.stringify(body), {status, headers}); + +describe('utils/client', ()=>{ + beforeEach(()=>{ + vi.clearAllMocks(); + }); + afterEach(()=>{ + vi.stubGlobal('setTimeout', real_set_timeout); + }); + + it('sends Authorization: Bearer against the given base URL — one path for JWT or API key', async()=>{ + mock_fetch.mockResolvedValue(json_res({userId: 1})); + const result = await get(BASE, 'tok', '/whoami'); + expect(mock_fetch).toHaveBeenCalledWith( + 'https://api.dev.reply.io/v3/whoami', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({Authorization: 'Bearer tok'}), + }), + ); + expect(result).toEqual({userId: 1}); + }); + + it('returns null on an empty 200 body', async()=>{ + mock_fetch.mockResolvedValue(new Response('', {status: 200})); + expect(await get(BASE, 'tok', '/x')).toBeNull(); + }); + + it('throws Api_error with status + parsed body on a non-ok response', async()=>{ + mock_fetch.mockResolvedValue(err_res(404, {title: 'Not found', code: 'x.notFound'})); + const err = await get(BASE, 'tok', '/x').catch(e=>e); + expect(err).toBeInstanceOf(Api_error); + expect(err.status).toBe(404); + expect(err.code).toBe('x.notFound'); + }); + + it('attaches a 401 hint mentioning login', async()=>{ + mock_fetch.mockResolvedValue(err_res(401, {title: 'Unauthorized'})); + const err = await get(BASE, 'tok', '/x').catch(e=>e); + expect(err.hint).toMatch(/login|credential/i); + }); + + it('retries transient 500s then succeeds', async()=>{ + instant_timers(); + mock_fetch + .mockResolvedValueOnce(err_res(500)) + .mockResolvedValueOnce(json_res({ok: true})); + expect(await get(BASE, 'tok', '/x')).toEqual({ok: true}); + expect(mock_fetch).toHaveBeenCalledTimes(2); + }); + + it('gives up after max retries on persistent 503', async()=>{ + instant_timers(); + mock_fetch.mockResolvedValue(err_res(503)); + const err = await get(BASE, 'tok', '/x').catch(e=>e); + expect(err).toBeInstanceOf(Api_error); + expect(err.status).toBe(503); + expect(mock_fetch).toHaveBeenCalledTimes(4); // initial + 3 retries + }); + + it('wraps a network failure in a RuntimeError after retries', async()=>{ + instant_timers(); + mock_fetch.mockRejectedValue(new TypeError('fetch failed')); + const err = await get(BASE, 'tok', '/x').catch(e=>e); + expect(err).toBeInstanceOf(RuntimeError); + }); + + it('create_client binds base + token for get', async()=>{ + mock_fetch.mockResolvedValue(json_res({userId: 9})); + const client = create_client(BASE, 'tok'); + await client.get('/whoami'); + expect(mock_fetch).toHaveBeenCalledWith( + 'https://api.dev.reply.io/v3/whoami', + expect.objectContaining({headers: expect.objectContaining({Authorization: 'Bearer tok'})}), + ); + }); + + it('create_client attaches extra headers alongside Authorization', async()=>{ + mock_fetch.mockResolvedValue(json_res({ok: true})); + const client = create_client(BASE, 'tok', {'X-TEAM-ID': '1045', 'X-USER-ID': '7'}); + await client.get('/whoami'); + expect(mock_fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({headers: expect.objectContaining({ + Authorization: 'Bearer tok', 'X-TEAM-ID': '1045', 'X-USER-ID': '7', + })}), + ); + }); + + it('sends no extra headers when none are given', async()=>{ + mock_fetch.mockResolvedValue(json_res({ok: true})); + await create_client(BASE, 'tok').get('/x'); + const [, init] = mock_fetch.mock.calls[0]; + expect(init.headers['X-TEAM-ID']).toBeUndefined(); + }); + + it('get forwards opts.headers', async()=>{ + mock_fetch.mockResolvedValue(json_res({ok: true})); + await get(BASE, 'tok', '/x', {headers: {'X-TEAM-ID': '9'}}); + expect(mock_fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({headers: expect.objectContaining({'X-TEAM-ID': '9'})}), + ); + }); +}); diff --git a/src/__tests__/utils/errors.test.ts b/src/__tests__/utils/errors.test.ts new file mode 100644 index 0000000..a010067 --- /dev/null +++ b/src/__tests__/utils/errors.test.ts @@ -0,0 +1,72 @@ +import {describe, it, expect} from 'vitest'; +import {CliError, UsageError, RuntimeError, Api_error} from '../../utils/errors'; + +describe('utils/errors', ()=>{ + describe('UsageError', ()=>{ + it('has exit code 2', ()=>{ + expect(new UsageError('bad flag').exit_code).toBe(2); + }); + + it('is a CliError', ()=>{ + expect(new UsageError('x')).toBeInstanceOf(CliError); + }); + + it('to_json wraps message as title with optional code/hint', ()=>{ + const e = new UsageError('Unknown environment', {code: 'usage.env', hint: 'dev|stage|prod'}); + expect(e.to_json()).toEqual({error: {code: 'usage.env', title: 'Unknown environment', hint: 'dev|stage|prod'}}); + }); + }); + + describe('RuntimeError', ()=>{ + it('has exit code 1 and is a CliError', ()=>{ + const e = new RuntimeError('disk gone'); + expect(e.exit_code).toBe(1); + expect(e).toBeInstanceOf(CliError); + }); + + it('to_json carries title, code, detail and hint', ()=>{ + const e = new RuntimeError('Credential store is corrupt', { + code: 'store.corrupt', detail: '/p/credentials.json', hint: 'delete it and log in again', + }); + expect(e.to_json()).toEqual({error: { + code: 'store.corrupt', + title: 'Credential store is corrupt', + detail: '/p/credentials.json', + hint: 'delete it and log in again', + }}); + }); + }); + + describe('Api_error', ()=>{ + it('has exit code 1 and carries the HTTP status', ()=>{ + const e = new Api_error(401, {title: 'Unauthorized', code: 'auth.invalid'}); + expect(e.exit_code).toBe(1); + expect(e.status).toBe(401); + }); + + it('to_json includes present fields and drops undefined ones', ()=>{ + const e = new Api_error(404, {title: 'Not found', code: 'x.notFound', detail: 'gone'}); + const json = e.to_json(); + expect(json).toEqual({error: {status: 404, code: 'x.notFound', title: 'Not found', detail: 'gone'}}); + expect(JSON.stringify(json)).not.toContain('hint'); + }); + + it('accepts a string body as the detail', ()=>{ + const e = new Api_error(500, 'boom'); + expect(e.detail).toBe('boom'); + }); + + it('builds a human-readable message from title/detail', ()=>{ + const e = new Api_error(404, {title: 'Not found', detail: 'gone'}); + expect(e.message).toContain('Not found'); + expect(e.message).toContain('gone'); + }); + + it('carries an optional hint into message and json', ()=>{ + const e = new Api_error(401, {title: 'Unauthorized'}, {hint: 'run login'}); + expect(e.hint).toBe('run login'); + expect(e.message).toContain('run login'); + expect(e.to_json().error.hint).toBe('run login'); + }); + }); +}); diff --git a/src/__tests__/utils/output.test.ts b/src/__tests__/utils/output.test.ts new file mode 100644 index 0000000..18b270d --- /dev/null +++ b/src/__tests__/utils/output.test.ts @@ -0,0 +1,80 @@ +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; +import {print, success, warn, info, redact, safe_record, REDACTED} from '../../utils/output'; +import type {Credential_record} from '../../credentials/types'; + +let stdout_spy: ReturnType; +let stderr_spy: ReturnType; + +beforeEach(()=>{ + stdout_spy = vi.spyOn(process.stdout, 'write').mockImplementation(()=>true); + stderr_spy = vi.spyOn(console, 'error').mockImplementation(()=>{}); +}); + +afterEach(()=>{ + vi.restoreAllMocks(); +}); + +describe('utils/output — redaction', ()=>{ + it('redact never echoes the secret', ()=>{ + expect(redact('sk_live_supersecret_1234')).toBe(REDACTED); + expect(redact('sk_live_supersecret_1234')).not.toContain('supersecret'); + }); + + it('safe_record masks the api key', ()=>{ + const rec: Credential_record = {type: 'api_key', key: 'sk_live_abc', user: {username: 'u'}}; + const safe = safe_record(rec) as {key: string; user: unknown}; + expect(safe.key).toBe(REDACTED); + expect(safe.user).toEqual({username: 'u'}); + expect(JSON.stringify(safe)).not.toContain('sk_live_abc'); + }); + + it('safe_record masks oauth access and refresh tokens but keeps expiry/user', ()=>{ + const rec: Credential_record = { + type: 'oauth', access_token: 'AT_secret', refresh_token: 'RT_secret', + expires_at: 123, user: {username: 'u'}, + }; + const safe = safe_record(rec) as Record; + expect(safe.access_token).toBe(REDACTED); + expect(safe.refresh_token).toBe(REDACTED); + expect(safe.expires_at).toBe(123); + expect(JSON.stringify(safe)).not.toContain('secret'); + }); + + it('safe_record leaves an oauth record without a refresh token free of a masked field', ()=>{ + const rec: Credential_record = {type: 'oauth', access_token: 'AT', expires_at: 1}; + const safe = safe_record(rec) as Record; + expect(safe.refresh_token).toBeUndefined(); + }); +}); + +describe('utils/output — print routing', ()=>{ + it('writes compact JSON to stdout with --json', ()=>{ + print({a: 1, b: 2}, {json: true}); + expect(stdout_spy).toHaveBeenCalledWith('{"a":1,"b":2}\n'); + }); + + it('writes indented JSON to stdout with --pretty', ()=>{ + print({a: 1}, {pretty: true}); + expect(stdout_spy).toHaveBeenCalledWith(JSON.stringify({a: 1}, null, 2) + '\n'); + }); + + it('pretty-prints objects by default', ()=>{ + print({a: 1}); + expect(stdout_spy).toHaveBeenCalledWith(JSON.stringify({a: 1}, null, 2) + '\n'); + }); + + it('emits strings as-is in the default format', ()=>{ + print('hello'); + expect(stdout_spy).toHaveBeenCalledWith('hello\n'); + }); +}); + +describe('utils/output — status goes to stderr', ()=>{ + it('success/warn/info write to stderr, never stdout', ()=>{ + success('ok'); + warn('careful'); + info('fyi'); + expect(stderr_spy).toHaveBeenCalledTimes(3); + expect(stdout_spy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api/headers.ts b/src/api/headers.ts new file mode 100644 index 0000000..02b9aca --- /dev/null +++ b/src/api/headers.ts @@ -0,0 +1,8 @@ +// Reply.io request headers, mirroring the backend's ReplyKnownHeaders. They pin +// the team/workspace context and, for organization API keys, identify the user +// the request acts on behalf of. Casing matches the backend constants verbatim. +const HEADER_TEAM_ID = 'X-TEAM-ID'; +const HEADER_USER_ID = 'X-USER-ID'; +const HEADER_USER_EMAIL = 'X-User-Email'; + +export {HEADER_TEAM_ID, HEADER_USER_ID, HEADER_USER_EMAIL}; diff --git a/src/auth/oauth-flow.ts b/src/auth/oauth-flow.ts new file mode 100644 index 0000000..d3fcbe4 --- /dev/null +++ b/src/auth/oauth-flow.ts @@ -0,0 +1,256 @@ +import http from 'http'; +import {spawn} from 'child_process'; +import {URL} from 'url'; +import {RuntimeError} from '../utils/errors'; +import {info} from '../utils/output'; +import {create_pkce, create_state} from './pkce'; +import { + build_token_exchange_body, + build_refresh_body, + to_oauth_record, + type Token_response, +} from './token'; +import type {Oauth_record} from '../credentials/types'; + +// Public PKCE client registered in IdentityServer (REPLY-51291 / REPLY-50627). +const CLIENT_ID = 'Reply.Cli'; +const SCOPE = 'openid profile email reply-web-api offline_access'; +const DEFAULT_TIMEOUT_MS = 300_000; // 5 minutes to complete the browser step + +const build_authorize_url = (p: { + authority: string; + client_id: string; + redirect_uri: string; + scope: string; + challenge: string; + state: string; +}): string=>{ + const url = new URL('/connect/authorize', p.authority); + url.search = new URLSearchParams({ + response_type: 'code', + client_id: p.client_id, + redirect_uri: p.redirect_uri, + scope: p.scope, + code_challenge: p.challenge, + code_challenge_method: 'S256', + state: p.state, + }).toString(); + return url.toString(); +}; + +const html_escape = (s: string): string=>s.replace(/[&<>"']/g, c=> + ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c] as string)); + +// Escapes both args so any reflected value (e.g. the `error` query param) can +// never inject markup into the loopback page. +const success_page = (title: string, body: string): string=> + `${html_escape(title)}` + + `` + + `

${html_escape(title)}

${html_escape(body)}

`; + +// The opener + args for a platform. On Windows we use rundll32's +// FileProtocolHandler rather than `cmd /c start`, because `start` treats an +// unquoted `&` (which every OAuth URL has) as a command separator — rundll32 +// takes the whole URL as a single argument, so it stays intact. +const browser_open_command = ( + platform: NodeJS.Platform, + url: string, +): {command: string; args: string[]}=>{ + if (platform === 'darwin') + { + return {command: 'open', args: [url]}; + } + if (platform === 'win32') + { + return {command: 'rundll32', args: ['url.dll,FileProtocolHandler', url]}; + } + return {command: 'xdg-open', args: [url]}; +}; + +// Best-effort browser launch. A headless/missing-opener box emits 'error' +// asynchronously; that is ignored on purpose because the caller always prints +// the URL for manual opening as a fallback. +const open_browser = (url: string): void=>{ + const {command, args} = browser_open_command(process.platform, url); + const child = spawn(command, args, {stdio: 'ignore', detached: true}); + child.on('error', ()=>{ /* manual-URL fallback covers a failed launch */ }); + child.unref(); +}; + +// Start a loopback listener on 127.0.0.1:, drive the browser to +// the authorize endpoint, and resolve with the captured authorization code. +const capture_code = (p: { + authority: string; + client_id: string; + scope: string; + challenge: string; + state: string; + open: (url: string) => void; + timeout_ms: number; +}): Promise<{code: string; redirect_uri: string}>=> + new Promise((resolve, reject)=>{ + let redirect_uri = ''; + let settled = false; + const done = (fn: () => void): void=>{ + if (settled) + { + return; + } + settled = true; + clearTimeout(timer); + server.close(); + fn(); + }; + + const server = http.createServer((req, res)=>{ + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + if (url.pathname !== '/callback') + { + res.writeHead(404, {'Content-Type': 'text/plain'}); + res.end('Not found'); + return; + } + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const err = url.searchParams.get('error'); + if (err) + { + res.writeHead(400, {'Content-Type': 'text/html'}); + res.end(success_page('Login failed', `The authorization server returned: ${err}`)); + done(()=>reject(new RuntimeError(`Authorization failed: ${err}`, {code: 'oauth.authorize'}))); + return; + } + if (!code || state !== p.state) + { + res.writeHead(400, {'Content-Type': 'text/html'}); + res.end(success_page('Login failed', 'State mismatch or missing authorization code.')); + done(()=>reject(new RuntimeError( + 'OAuth state mismatch — aborting to avoid a possible CSRF.', + {code: 'oauth.state'}))); + return; + } + res.writeHead(200, {'Content-Type': 'text/html'}); + res.end(success_page('Login complete', 'You can close this tab and return to the terminal.')); + done(()=>resolve({code, redirect_uri})); + }); + + const timer = setTimeout(()=>{ + done(()=>reject(new RuntimeError('Timed out waiting for the browser login.', { + code: 'oauth.timeout', + hint: 'Re-run the login and complete it in the browser.', + }))); + }, p.timeout_ms); + if (typeof timer.unref === 'function') + { + timer.unref(); + } + + server.on('error', e=>done(()=>reject(new RuntimeError('Could not start the loopback listener.', { + code: 'oauth.loopback', detail: (e as Error).message, + })))); + + // Port 0 = let the OS pick a free port; use the 127.0.0.1 IP literal in + // the redirect (RFC 8252 §8.3) — the IS loopback validator accepts any + // port on the portless registration. + server.listen(0, '127.0.0.1', ()=>{ + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + redirect_uri = `http://127.0.0.1:${port}/callback`; + const authorize_url = build_authorize_url({ + authority: p.authority, client_id: p.client_id, redirect_uri, + scope: p.scope, challenge: p.challenge, state: p.state, + }); + info('Opening your browser to complete sign-in…'); + info(`If it doesn't open, visit:\n ${authorize_url}`); + p.open(authorize_url); + }); + }); + +const parse_token_error = (text: string): string=>{ + try { + const j = JSON.parse(text) as {error?: string; error_description?: string}; + return j.error_description || j.error || text; + } catch { + return text; + } +}; + +const post_token = async(authority: string, body: URLSearchParams): Promise=>{ + const res = await fetch(`${authority}/connect/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }, + body: body.toString(), + }); + const text = await res.text(); + if (!res.ok) + { + throw new RuntimeError('OAuth token request failed.', { + code: 'oauth.token', + detail: `HTTP ${res.status}: ${parse_token_error(text)}`, + }); + } + return JSON.parse(text) as Token_response; +}; + +const exchange_code = async(p: { + authority: string; + client_id: string; + redirect_uri: string; + code: string; + verifier: string; + now?: number; +}): Promise=>{ + const now = p.now ?? Date.now(); + const resp = await post_token(p.authority, build_token_exchange_body({ + code: p.code, verifier: p.verifier, redirect_uri: p.redirect_uri, client_id: p.client_id, + })); + return to_oauth_record(resp, now); +}; + +const refresh_stored = async(p: { + authority: string; + record: Oauth_record; + client_id?: string; + now?: number; +}): Promise=>{ + if (!p.record.refresh_token) + { + throw new RuntimeError('No refresh token available.', {code: 'oauth.refresh'}); + } + const now = p.now ?? Date.now(); + const resp = await post_token(p.authority, build_refresh_body({ + refresh_token: p.record.refresh_token, client_id: p.client_id ?? CLIENT_ID, + })); + return to_oauth_record(resp, now, p.record); +}; + +// Full authorization-code + PKCE loopback login. `open` is injectable so the +// loopback can be exercised without a real browser/IdP in tests. +const run_login = async(opts: { + authority: string; + client_id?: string; + scope?: string; + open?: (url: string) => void; + now?: number; + timeout_ms?: number; +}): Promise=>{ + const client_id = opts.client_id ?? CLIENT_ID; + const scope = opts.scope ?? SCOPE; + const {verifier, challenge} = create_pkce(); + const state = create_state(); + const {code, redirect_uri} = await capture_code({ + authority: opts.authority, + client_id, scope, challenge, state, + open: opts.open ?? open_browser, + timeout_ms: opts.timeout_ms ?? DEFAULT_TIMEOUT_MS, + }); + return exchange_code({authority: opts.authority, client_id, redirect_uri, code, verifier, now: opts.now}); +}; + +export { + CLIENT_ID, SCOPE, + build_authorize_url, run_login, exchange_code, refresh_stored, browser_open_command, +}; diff --git a/src/auth/pkce.ts b/src/auth/pkce.ts new file mode 100644 index 0000000..ace6f51 --- /dev/null +++ b/src/auth/pkce.ts @@ -0,0 +1,23 @@ +import crypto from 'crypto'; + +type Pkce = { + verifier: string; + challenge: string; + method: 'S256'; +}; + +const random_url_safe = (bytes = 32): string=> + crypto.randomBytes(bytes).toString('base64url'); + +// RFC 7636 PKCE pair: a random verifier and its S256 challenge. +const create_pkce = (): Pkce=>{ + const verifier = random_url_safe(32); + const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); + return {verifier, challenge, method: 'S256'}; +}; + +// Opaque anti-CSRF value echoed back on the authorize redirect. +const create_state = (): string=>random_url_safe(32); + +export {create_pkce, create_state}; +export type {Pkce}; diff --git a/src/auth/request-identity.ts b/src/auth/request-identity.ts new file mode 100644 index 0000000..3a6f5b3 --- /dev/null +++ b/src/auth/request-identity.ts @@ -0,0 +1,103 @@ +import {env_var, get_env, type Env} from '../config'; +import {UsageError} from '../utils/errors'; +import {HEADER_TEAM_ID, HEADER_USER_ID, HEADER_USER_EMAIL} from '../api/headers'; + +// Extra headers to attach to an authenticated API call, plus any non-fatal +// notes for the user. Kept a pure value so it is unit-testable — printing the +// warnings is the command's job. +type Request_identity = { + headers: Record; + warnings: string[]; +}; + +type Resolve_identity_input = { + team_id_flag?: string; + user_id_flag?: string; + user_email_flag?: string; + env?: Env; + profile_team_id?: number; + credential_type: 'oauth' | 'api_key'; +}; + +const present = (v?: string): v is string=>v !== undefined && v.trim() !== ''; + +const parse_int_field = (value: string, label: string): number=>{ + if (!/^\d+$/.test(value.trim())) + { + throw new UsageError(`${label} must be a positive integer.`, { + code: 'usage.identity', hint: `Got: ${value}`, + }); + } + return parseInt(value.trim(), 10); +}; + +// Resolve the team/acting-user headers for a request. +// team id: --team-id > REPLY_TEAM_ID > profile team_id +// user id / user email: flag only (never env, never persisted) +// The header is emitted only when its value resolves; there is no gating on the +// credential type (harmless where the server ignores it, required for org keys). +const resolve_request_identity = (input: Resolve_identity_input): Request_identity=>{ + const env = input.env ?? process.env; + + const has_user_id = present(input.user_id_flag); + const has_user_email = present(input.user_email_flag); + if (has_user_id && has_user_email) + { + throw new UsageError('Pass only one of --user-id or --user-email.', { + code: 'usage.identity', + hint: 'Both identify the acting user for an organization API key — use one.', + }); + } + + let team_id: number | undefined; + const env_team = get_env('TEAM_ID', env); + if (present(input.team_id_flag)) + { + team_id = parse_int_field(input.team_id_flag, 'Team id (--team-id)'); + } + else if (present(env_team)) + { + team_id = parse_int_field(env_team, `Team id (${env_var('TEAM_ID')})`); + } + else if (input.profile_team_id !== undefined) + { + team_id = input.profile_team_id; + } + + const user_id = has_user_id ? parse_int_field(input.user_id_flag as string, 'User id (--user-id)') : undefined; + const user_email = has_user_email ? (input.user_email_flag as string).trim() : undefined; + + if (user_email !== undefined && team_id === undefined) + { + throw new UsageError('--user-email requires a team id.', { + code: 'usage.identity', + hint: `Supply it via --team-id, ${env_var('TEAM_ID')}, or the profile's team_id.`, + }); + } + + const headers: Record = {}; + if (team_id !== undefined) + { + headers[HEADER_TEAM_ID] = String(team_id); + } + if (user_id !== undefined) + { + headers[HEADER_USER_ID] = String(user_id); + } + if (user_email !== undefined) + { + headers[HEADER_USER_EMAIL] = user_email; + } + + const warnings: string[] = []; + if (input.credential_type === 'oauth' && (has_user_id || has_user_email)) + { + warnings.push( + '--user-id/--user-email have no effect with an OAuth login; they apply to organization API keys.'); + } + + return {headers, warnings}; +}; + +export {resolve_request_identity}; +export type {Request_identity, Resolve_identity_input}; diff --git a/src/auth/resolve.ts b/src/auth/resolve.ts new file mode 100644 index 0000000..0f72e35 --- /dev/null +++ b/src/auth/resolve.ts @@ -0,0 +1,93 @@ +import {PROGRAM_NAME, env_var, get_env, type Env} from '../config'; +import {UsageError} from '../utils/errors'; +import {needs_refresh} from './token'; +import type {Credential_record, CredentialStore, Oauth_record} from '../credentials/types'; + +type Resolved_source = 'flag' | 'env' | 'store'; + +type Resolved_credential = { + token: string; // bearer value to send (api key or oauth access token) + type: 'api_key' | 'oauth'; + source: Resolved_source; + ephemeral: boolean; // flag/env creds are ephemeral — never persist them + record?: Credential_record; // present only for stored creds +}; + +type Resolve_opts = { + api_key?: string; +}; + +type Resolve_deps = { + key: string; + store: CredentialStore; + env?: Env; + now?: number; + // Refresh an expired oauth record against the token endpoint. Injected so + // resolution stays unit-testable without network. + refresh?: (record: Oauth_record) => Promise; +}; + +// STRICT precedence: 1) --api-key flag 2) _API_KEY env 3) stored +// credential. Flag/env are ephemeral and are never written to disk. A stored +// oauth record is refreshed when expired; if that fails it is cleared and the +// user is told to log in again. +const resolve_credential = async( + opts: Resolve_opts, + deps: Resolve_deps, +): Promise=>{ + if (opts.api_key) + { + return {token: opts.api_key, type: 'api_key', source: 'flag', ephemeral: true}; + } + + const env_key = get_env('API_KEY', deps.env); + if (env_key) + { + return {token: env_key, type: 'api_key', source: 'env', ephemeral: true}; + } + + const record = await deps.store.get(deps.key); + if (!record) + { + throw new UsageError('Not authenticated.', { + code: 'auth.required', + hint: `Run \`${PROGRAM_NAME} auth login\` or set ${env_var('API_KEY')}.`, + }); + } + + if (record.type === 'api_key') + { + return {token: record.key, type: 'api_key', source: 'store', ephemeral: false, record}; + } + + const now = deps.now ?? Date.now(); + if (!needs_refresh(record, now)) + { + return {token: record.access_token, type: 'oauth', source: 'store', ephemeral: false, record}; + } + + if (!record.refresh_token || !deps.refresh) + { + await deps.store.remove(deps.key); + throw new UsageError('Session expired.', { + code: 'auth.expired', + hint: `Run \`${PROGRAM_NAME} auth login\` to sign in again.`, + }); + } + + let refreshed: Oauth_record; + try { + refreshed = await deps.refresh(record); + } catch { + await deps.store.remove(deps.key); + throw new UsageError('Session refresh failed.', { + code: 'auth.refresh_failed', + hint: `Run \`${PROGRAM_NAME} auth login\` to sign in again.`, + }); + } + await deps.store.set(deps.key, refreshed); + return {token: refreshed.access_token, type: 'oauth', source: 'store', ephemeral: false, record: refreshed}; +}; + +export {resolve_credential}; +export type {Resolved_credential, Resolved_source, Resolve_opts, Resolve_deps}; diff --git a/src/auth/status.ts b/src/auth/status.ts new file mode 100644 index 0000000..24e76c4 --- /dev/null +++ b/src/auth/status.ts @@ -0,0 +1,47 @@ +import type {Credential_record, Principal} from '../credentials/types'; + +type Auth_status = { + authenticated: boolean; + profile: string; + source?: 'flag' | 'env' | 'store'; + method?: 'api_key' | 'oauth'; + user?: Principal; + expires_at?: string; // ISO 8601 + expired?: boolean; +}; + +// Pure, non-destructive view of the current auth state — never triggers a +// refresh or a store write, and never carries a raw secret. +const describe_status = (p: { + profile: string; + api_key_flag?: string; + api_key_env?: string; + record?: Credential_record; + now: number; +}): Auth_status=>{ + if (p.api_key_flag) + { + return {authenticated: true, profile: p.profile, source: 'flag', method: 'api_key'}; + } + if (p.api_key_env) + { + return {authenticated: true, profile: p.profile, source: 'env', method: 'api_key'}; + } + if (!p.record) + { + return {authenticated: false, profile: p.profile}; + } + if (p.record.type === 'api_key') + { + return {authenticated: true, profile: p.profile, source: 'store', method: 'api_key', user: p.record.user}; + } + return { + authenticated: true, profile: p.profile, source: 'store', method: 'oauth', + user: p.record.user, + expires_at: new Date(p.record.expires_at).toISOString(), + expired: p.record.expires_at <= p.now, + }; +}; + +export {describe_status}; +export type {Auth_status}; diff --git a/src/auth/token.ts b/src/auth/token.ts new file mode 100644 index 0000000..183c2d6 --- /dev/null +++ b/src/auth/token.ts @@ -0,0 +1,72 @@ +import type {Oauth_record, Principal} from '../credentials/types'; + +// Refresh this many ms before the real expiry, so a token isn't sent right +// as it lapses mid-flight. +const DEFAULT_SKEW_MS = 60_000; + +const DEFAULT_EXPIRES_IN_S = 3600; + +type Token_response = { + access_token: string; + refresh_token?: string; + expires_in?: number; // seconds + token_type?: string; + id_token?: string; +}; + +const needs_refresh = ( + record: {expires_at: number}, + now: number, + skew_ms: number = DEFAULT_SKEW_MS, +): boolean=>record.expires_at - skew_ms <= now; + +const expires_at_from = (resp: Token_response, now: number): number=> + now + (resp.expires_in ?? DEFAULT_EXPIRES_IN_S) * 1000; + +const build_token_exchange_body = (p: { + code: string; + verifier: string; + redirect_uri: string; + client_id: string; +}): URLSearchParams=>new URLSearchParams({ + grant_type: 'authorization_code', + code: p.code, + redirect_uri: p.redirect_uri, + client_id: p.client_id, + code_verifier: p.verifier, +}); + +const build_refresh_body = (p: { + refresh_token: string; + client_id: string; +}): URLSearchParams=>new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: p.refresh_token, + client_id: p.client_id, +}); + +// A token endpoint may rotate the refresh token or omit it; when omitted, keep +// the previous one so subsequent refreshes still work. The token endpoint never +// returns the principal, so carry the previous `user` forward — otherwise the +// first silent refresh would wipe the identity shown by `auth status`. +const to_oauth_record = ( + resp: Token_response, + now: number, + prev?: {refresh_token?: string; user?: Principal}, +): Oauth_record=>({ + type: 'oauth', + access_token: resp.access_token, + refresh_token: resp.refresh_token ?? prev?.refresh_token, + expires_at: expires_at_from(resp, now), + user: prev?.user, +}); + +export { + DEFAULT_SKEW_MS, + needs_refresh, + expires_at_from, + build_token_exchange_body, + build_refresh_body, + to_oauth_record, +}; +export type {Token_response}; diff --git a/src/commands/auth.ts b/src/commands/auth.ts new file mode 100644 index 0000000..8f7ead6 --- /dev/null +++ b/src/commands/auth.ts @@ -0,0 +1,325 @@ +import readline from 'readline'; +import {Command} from 'commander'; +import {PROGRAM_NAME, get_env, env_var} from '../config'; +import {build_context, type Cli_context} from '../context'; +import {create_client} from '../utils/client'; +import {resolve_credential} from '../auth/resolve'; +import {resolve_request_identity} from '../auth/request-identity'; +import {run_login} from '../auth/oauth-flow'; +import {describe_status} from '../auth/status'; +import {UsageError} from '../utils/errors'; +import {success, info, warn, print, pc, type Print_opts} from '../utils/output'; +import type {Api_key_record, Credential_record, Principal} from '../credentials/types'; + +type Global_opts = { + apiKey?: string; + profile?: string; + teamId?: string; + userId?: string; + userEmail?: string; + json?: boolean; + pretty?: boolean; +}; + +const read_globals = (cmd: Command): Global_opts=>{ + const o = cmd.optsWithGlobals(); + return { + apiKey: o.apiKey, profile: o.profile, + teamId: o.teamId, userId: o.userId, userEmail: o.userEmail, + json: o.json, pretty: o.pretty, + }; +}; + +// Resolve the team/acting-user headers for a request from the global flags, +// env, and the profile's pinned team. `credential_type` only affects the +// OAuth advisory note; the headers themselves are never gated on it. +const resolve_identity = (g: Global_opts, ctx: Cli_context, credential_type: 'oauth' | 'api_key')=> + resolve_request_identity({ + team_id_flag: g.teamId, + user_id_flag: g.userId, + user_email_flag: g.userEmail, + env: process.env, + profile_team_id: ctx.team_id, + credential_type, + }); + +const print_opts = (g: Global_opts): Print_opts=>({json: g.json, pretty: g.pretty}); +const wants_json = (g: Global_opts): boolean=>Boolean(g.json || g.pretty); + +// Only surface the profile in human output when it's not the implicit default, +// so the common (no-profile) user never sees profile noise. +const profile_note = (name: string): string=>name === 'default' ? '' : ` (profile: ${name})`; + +// v3 /whoami returns exactly {userId, username, teamId} (WhoamiResponse); +// read those three, guarding types so a malformed body degrades to empty. +const normalize_principal = (raw: Record): Principal=>{ + const num = (v: unknown): number | undefined=>typeof v === 'number' ? v : undefined; + const str = (v: unknown): string | undefined=>typeof v === 'string' ? v : undefined; + return { + id: num(raw.userId), + username: str(raw.username), + team_id: num(raw.teamId), + }; +}; + +const principal_label = (p: Principal): string=>{ + const who = p.username ?? (p.id !== undefined ? `#${p.id}` : 'unknown'); + const meta: string[] = []; + // Only append the user id as extra when the username already carries the + // display name — otherwise `who` is already `#id` and it would be redundant. + if (p.username && p.id !== undefined) + { + meta.push(`user ${p.id}`); + } + if (p.team_id !== undefined) + { + meta.push(`team ${p.team_id}`); + } + return meta.length ? `${who} (${meta.join(', ')})` : who; +}; + +const fetch_whoami = async( + api_base: string, token: string, headers?: Record, +): Promise>=>{ + const raw = await create_client(api_base, token, headers).get>('/whoami'); + return raw ?? {}; +}; + +// The credential is already valid here; persist it before the identity lookup +// so a transient /whoami failure can't discard it. Enriching the stored record +// with the principal is best-effort. +const enrich_identity = async( + ctx: Cli_context, + token: string, + headers: Record, + record: Credential_record, +): Promise=>{ + try { + const user = normalize_principal(await fetch_whoami(ctx.api_base, token, headers)); + record.user = user; + await ctx.store.set(ctx.key, record); + return user; + } catch (e) { + warn(`Signed in, but could not fetch your identity: ${(e as Error).message}`); + return undefined; + } +}; + +const read_token_from_stdin = async(): Promise=>{ + if (process.stdin.isTTY) + { + const rl = readline.createInterface({input: process.stdin, output: process.stderr, terminal: true}); + const line = await new Promise(resolve=>{ + rl.question('Paste your API key, then press Enter: ', resolve); + // The prompt is already written; swallow the echo of typed characters + // so the key never appears on screen (the piped path never echoes). + (rl as unknown as {_writeToOutput: (s: string) => void})._writeToOutput = ()=>{}; + }); + rl.close(); + process.stderr.write('\n'); + return line.trim(); + } + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) + { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8').trim(); +}; + +const handle_login = async( + ctx: Cli_context, g: Global_opts, login: typeof run_login = run_login, +): Promise=>{ + // Validate identity flags before opening the browser, so a bad combo fails fast. + const identity = resolve_identity(g, ctx, 'oauth'); + identity.warnings.forEach(w=>warn(w)); + const record = await login({authority: ctx.authority}); + // Persist first — a good login must survive a transient /whoami failure + // (else the user pays another browser round-trip). + await ctx.store.set(ctx.key, record); + const user = await enrich_identity(ctx, record.access_token, identity.headers, record); + if (wants_json(g)) + { + print({logged_in: true, method: 'oauth', profile: ctx.profile, user}, print_opts(g)); + return; + } + success(`Logged in${user ? ` as ${principal_label(user)}` : ''}${profile_note(ctx.profile)}.`); +}; + +const handle_login_token = async( + ctx: Cli_context, g: Global_opts, read_token: () => Promise = read_token_from_stdin, +): Promise=>{ + // Validate identity flags before consuming stdin. + const identity = resolve_identity(g, ctx, 'api_key'); + const key = await read_token(); + if (!key) + { + throw new UsageError('No API key was provided on stdin.', { + code: 'usage.stdin', + hint: `Try: echo | ${PROGRAM_NAME} auth login --with-token`, + }); + } + const raw = await fetch_whoami(ctx.api_base, key, identity.headers); + const user = normalize_principal(raw); + const record: Api_key_record = {type: 'api_key', key, user}; + await ctx.store.set(ctx.key, record); + if (wants_json(g)) + { + print({logged_in: true, method: 'api_key', profile: ctx.profile, user}, print_opts(g)); + return; + } + success(`Stored API key for ${principal_label(user)}${profile_note(ctx.profile)}.`); +}; + +const handle_logout = async(ctx: Cli_context, g: Global_opts): Promise=>{ + const removed = await ctx.store.remove(ctx.key); + if (wants_json(g)) + { + print({logged_out: removed}, print_opts(g)); + return; + } + if (removed) + { + success('Logged out.'); + return; + } + info('No stored credential to remove.'); +}; + +const handle_status = async(ctx: Cli_context, g: Global_opts): Promise=>{ + const api_key_env = get_env('API_KEY'); + const ephemeral = Boolean(g.apiKey || api_key_env); + const record = ephemeral ? undefined : await ctx.store.get(ctx.key); + const status = describe_status({ + profile: ctx.profile, + api_key_flag: g.apiKey, + api_key_env, + record, + now: Date.now(), + }); + const cred_type: 'oauth' | 'api_key' = ephemeral ? 'api_key' : (record?.type ?? 'api_key'); + const {headers} = resolve_identity(g, ctx, cred_type); + const team_header = headers['X-TEAM-ID']; + const acting_user = headers['X-USER-ID']; + const acting_email = headers['X-User-Email']; + if (!status.authenticated) + { + process.exitCode = 1; + } + if (wants_json(g)) + { + print({ + ...status, + ...(team_header ? {team_id: Number(team_header)} : {}), + ...(acting_user ? {acting_user_id: Number(acting_user)} : {}), + ...(acting_email ? {acting_email} : {}), + }, print_opts(g)); + return; + } + if (!status.authenticated) + { + info(`Not authenticated${profile_note(status.profile)}.`); + info(`Run \`${PROGRAM_NAME} auth login\` or set ${env_var('API_KEY')}.`); + return; + } + success('Authenticated.'); + const lines: string[] = []; + if (status.profile !== 'default') + { + lines.push(` Profile: ${status.profile}`); + } + lines.push(` Source: ${status.source}`); + lines.push(` Method: ${status.method}`); + if (status.user) + { + lines.push(` User: ${principal_label(status.user)}`); + } + if (team_header) + { + lines.push(` Team: ${team_header}`); + } + if (acting_email) + { + lines.push(` Acting as: ${acting_email}`); + } + else if (acting_user) + { + lines.push(` Acting as: user ${acting_user}`); + } + if (status.method === 'oauth') + { + lines.push(` Expires: ${status.expires_at}${status.expired ? pc.red(' (expired)') : ''}`); + } + console.log(lines.join('\n')); +}; + +const handle_whoami = async(ctx: Cli_context, g: Global_opts): Promise=>{ + const resolved = await resolve_credential( + {api_key: g.apiKey}, + {key: ctx.key, store: ctx.store, env: process.env, refresh: ctx.refresh}); + const identity = resolve_identity(g, ctx, resolved.type); + identity.warnings.forEach(w=>warn(w)); + const raw = await fetch_whoami(ctx.api_base, resolved.token, identity.headers); + if (wants_json(g)) + { + print(raw, print_opts(g)); + return; + } + const principal = normalize_principal(raw); + success(`Credential is valid (${resolved.type}, source: ${resolved.source})${profile_note(ctx.profile)}.`); + console.log(` ${principal_label(principal)}`); +}; + +const auth_command = new Command('auth').description('Authenticate and inspect identity'); + +auth_command + .command('login') + .description('Log in via OAuth (browser), or --with-token to store an API key from stdin') + .option('--with-token', 'Read an API key from stdin instead of running the OAuth flow') + .addHelpText('after', + `\nExamples:\n ${PROGRAM_NAME} auth login\n echo | ${PROGRAM_NAME} auth login --with-token`) + .action(async function(this: Command) { + const g = read_globals(this); + const ctx = build_context({profile: g.profile}); + if (this.opts().withToken) + { + await handle_login_token(ctx, g); + return; + } + await handle_login(ctx, g); + }); + +auth_command + .command('logout') + .description('Remove the stored credential') + .action(async function(this: Command) { + const g = read_globals(this); + await handle_logout(build_context({profile: g.profile}), g); + }); + +auth_command + .command('status') + .description('Show the active credential source, method, user and OAuth expiry (no secrets)') + .action(async function(this: Command) { + const g = read_globals(this); + await handle_status(build_context({profile: g.profile}), g); + }); + +auth_command + .command('whoami') + .description('Validate the active credential against the API and print the principal') + .addHelpText('after', `\nExamples:\n ${PROGRAM_NAME} auth whoami\n ${PROGRAM_NAME} auth whoami --json`) + .action(async function(this: Command) { + const g = read_globals(this); + await handle_whoami(build_context({profile: g.profile}), g); + }); + +export { + auth_command, + normalize_principal, + principal_label, + handle_status, + handle_whoami, + handle_login, + handle_login_token, +}; diff --git a/src/commands/profile.ts b/src/commands/profile.ts new file mode 100644 index 0000000..eff67f6 --- /dev/null +++ b/src/commands/profile.ts @@ -0,0 +1,115 @@ +import {Command} from 'commander'; +import {current_profile_name, list_profiles, set_current_profile, add_profile, set_profile} from '../profile'; +import {UsageError} from '../utils/errors'; +import {success, print, pc, type Print_opts} from '../utils/output'; + +type Global_opts = {json?: boolean; pretty?: boolean}; + +const read_globals = (cmd: Command): Global_opts=>{ + const o = cmd.optsWithGlobals(); + return {json: o.json, pretty: o.pretty}; +}; + +const wants_json = (g: Global_opts): boolean=>Boolean(g.json || g.pretty); +const print_opts = (g: Global_opts): Print_opts=>({json: g.json, pretty: g.pretty}); + +const parse_team_id = (v?: string): number | undefined=>{ + if (v === undefined) + { + return undefined; + } + if (!/^\d+$/.test(v.trim())) + { + throw new UsageError('--team-id must be a positive integer.', {code: 'usage.profile', hint: `Got: ${v}`}); + } + return parseInt(v.trim(), 10); +}; + +const profile_command = new Command('profile') + .description('Manage profiles — optional; most users never need one (default = prod)'); + +profile_command + .command('add') + .argument('', 'Profile name (e.g. your account email)') + .option('--team-id ', 'Pin a team/workspace for this profile (sent as X-TEAM-ID)') + .option('--authority ', 'Override the OAuth authority (advanced; defaults to prod)') + .option('--api-base ', 'Override the API base (advanced; defaults to prod)') + .description('Create a profile (URLs optional — omitted fields inherit the default/prod)') + .action(function(this: Command, name: string) { + const g = read_globals(this); + const opts = this.optsWithGlobals(); + add_profile(name, { + authority: opts.authority, api_base: opts.apiBase, team_id: parse_team_id(opts.teamId), + }); + if (wants_json(g)) + { + print({added: name}, print_opts(g)); + return; + } + success(`Profile '${name}' created. Make it current with: profile use ${name}`); + }); + +profile_command + .command('set') + .argument('', 'Profile to edit (a user profile, or "default" to pin a team globally)') + .option('--team-id ', 'Pin a team/workspace (sent as X-TEAM-ID)') + .option('--authority ', 'Override the OAuth authority (advanced)') + .option('--api-base ', 'Override the API base (advanced)') + .description('Edit an existing profile in place — only the fields you pass change') + .action(function(this: Command, name: string) { + const g = read_globals(this); + const opts = this.optsWithGlobals(); + set_profile(name, { + authority: opts.authority, api_base: opts.apiBase, team_id: parse_team_id(opts.teamId), + }); + if (wants_json(g)) + { + print({updated: name}, print_opts(g)); + return; + } + success(`Profile '${name}' updated.`); + }); + +profile_command + .command('use') + .argument('', 'Profile to make current (a user-defined profile, or "default" for prod)') + .description('Set the current profile, used until changed') + .action(function(this: Command, name: string) { + const g = read_globals(this); + set_current_profile(name); + if (wants_json(g)) + { + print({current: name}, print_opts(g)); + return; + } + success(`Current profile set to ${name}.`); + }); + +profile_command + .command('list') + .description('List available profiles and show which is current') + .action(function(this: Command) { + const g = read_globals(this); + const {current, available} = list_profiles(); + if (wants_json(g)) + { + print({current, available}, print_opts(g)); + return; + } + for (const name of available) + { + const marker = name === current ? pc.green('*') : ' '; + console.log(`${marker} ${name}`); + } + }); + +profile_command + .command('current') + .description('Print the current profile name') + .action(function(this: Command) { + const g = read_globals(this); + const current = current_profile_name(); + print(wants_json(g) ? {current} : current, print_opts(g)); + }); + +export {profile_command}; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..4ac6d60 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,65 @@ +import os from 'os'; +import path from 'path'; + +// Single identity. The bin name, config dir, and env-var prefix all derive +// from APP_NAME. There is one build (`reply`); channels are handled by the +// registry/dist-tags, not by a second identity. +const APP_NAME = 'reply'; +const PROGRAM_NAME = APP_NAME; + +type Env = Record; + +const env_prefix = (app: string = APP_NAME): string=> + app.toUpperCase().replace(/-/g, '_'); + +// Build a full env var name, e.g. env_var('API_KEY') -> 'REPLY_API_KEY'. +const env_var = (suffix: string, app: string = APP_NAME): string=> + `${env_prefix(app)}_${suffix}`; + +const get_env = (suffix: string, env: Env = process.env): string | undefined=> + env[env_var(suffix)]; + +// Default per-user config dir, mirroring gh/aws/az conventions. +// linux/mac: $XDG_CONFIG_HOME/ (fallback ~/.config/) +// windows: %APPDATA%\ (fallback \AppData\Roaming\) +const default_config_dir = ( + platform: NodeJS.Platform, + env: Env, + homedir: string, +): string=>{ + if (platform === 'win32') + { + const appdata = env.APPDATA && env.APPDATA.trim() + ? env.APPDATA + : path.join(homedir, 'AppData', 'Roaming'); + return path.join(appdata, APP_NAME); + } + const xdg = env.XDG_CONFIG_HOME; + const base = xdg && xdg.trim() ? xdg : path.join(homedir, '.config'); + return path.join(base, APP_NAME); +}; + +// The active config dir. A _CONFIG_DIR override wins outright. +const config_dir = (env: Env = process.env): string=>{ + const override = get_env('CONFIG_DIR', env); + if (override && override.trim()) + { + return override; + } + return default_config_dir(process.platform, env, os.homedir()); +}; + +// Stored credentials, keyed by profile name (see credentials/file-store.ts). +const credentials_file = (env: Env = process.env): string=> + path.join(config_dir(env), 'credentials.json'); + +// User profile definitions live here (see profile.ts). +const config_file = (env: Env = process.env): string=> + path.join(config_dir(env), 'config.json'); + +export { + PROGRAM_NAME, APP_NAME, + env_prefix, env_var, get_env, + default_config_dir, config_dir, credentials_file, config_file, +}; +export type {Env}; diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..d1c7cb6 --- /dev/null +++ b/src/context.ts @@ -0,0 +1,31 @@ +import {resolve_profile} from './profile'; +import {default_credential_store} from './credentials/file-store'; +import {refresh_stored} from './auth/oauth-flow'; +import type {CredentialStore, Oauth_record} from './credentials/types'; + +// Per-invocation binding: the resolved profile's backend URLs, the credential +// store key (the PROFILE NAME — so multiple accounts on the same backend stay +// isolated), the store, and a bound token-refresh function. +type Cli_context = { + profile: string; + authority: string; + api_base: string; + key: string; + team_id?: number; // the profile's pinned team, if any (sent as X-TEAM-ID) + store: CredentialStore; + refresh: (record: Oauth_record) => Promise; +}; + +const build_context = (opts: {profile?: string} = {}): Cli_context=>{ + const p = resolve_profile(opts.profile); + const store = default_credential_store(); + const refresh = (record: Oauth_record): Promise=> + refresh_stored({authority: p.authority, record}); + return { + profile: p.name, authority: p.authority, api_base: p.api_base, + key: p.name, team_id: p.team_id, store, refresh, + }; +}; + +export {build_context}; +export type {Cli_context}; diff --git a/src/credentials/file-store.ts b/src/credentials/file-store.ts new file mode 100644 index 0000000..723279f --- /dev/null +++ b/src/credentials/file-store.ts @@ -0,0 +1,103 @@ +import fs from 'fs'; +import path from 'path'; +import {credentials_file} from '../config'; +import {RuntimeError} from '../utils/errors'; +import type {Credential_record, CredentialStore} from './types'; + +type Key_map = Record; + +// v1 credential backend: a single JSON file keyed by profile name, written +// with strict 0600 perms in a 0700 dir (the gh/aws/az plaintext-file model). +class FileCredentialStore implements CredentialStore { + private readonly file: string; + + constructor(file: string) + { + this.file = file; + } + + async get(key: string): Promise + { + return this.read()[key]; + } + + async set(key: string, record: Credential_record): Promise + { + const map = this.read(); + map[key] = record; + this.write(map); + } + + async remove(key: string): Promise + { + const map = this.read(); + if (!(key in map)) + { + return false; + } + delete map[key]; + this.write(map); + return true; + } + + async keys(): Promise + { + return Object.keys(this.read()); + } + + private read(): Key_map + { + let raw: string; + try { + raw = fs.readFileSync(this.file, 'utf8'); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') + { + return {}; + } + throw new RuntimeError('Could not read the credential store.', { + code: 'store.read', detail: this.file, + hint: (e as Error).message, + }); + } + if (!raw.trim()) + { + return {}; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new RuntimeError('Credential store is corrupt (invalid JSON).', { + code: 'store.corrupt', detail: this.file, + hint: 'Delete the file and log in again.', + }); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) + { + throw new RuntimeError('Credential store is corrupt (unexpected shape).', { + code: 'store.corrupt', detail: this.file, + hint: 'Delete the file and log in again.', + }); + } + return parsed as Key_map; + } + + // Atomic write: temp file created 0600, then renamed into place so the + // final file is never briefly world-readable and rewrites keep 0600. + private write(map: Key_map): void + { + const dir = path.dirname(this.file); + fs.mkdirSync(dir, {recursive: true, mode: 0o700}); + fs.chmodSync(dir, 0o700); + const tmp = `${this.file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(map, null, 2), {mode: 0o600}); + fs.chmodSync(tmp, 0o600); + fs.renameSync(tmp, this.file); + } +} + +const default_credential_store = (): CredentialStore=> + new FileCredentialStore(credentials_file()); + +export {FileCredentialStore, default_credential_store}; diff --git a/src/credentials/types.ts b/src/credentials/types.ts new file mode 100644 index 0000000..7384816 --- /dev/null +++ b/src/credentials/types.ts @@ -0,0 +1,37 @@ +// The authenticated principal, mirroring the v3 /whoami contract +// (WhoamiResponse: UserId, Username, TeamId). Fields are optional so older +// stored records and malformed responses degrade gracefully. +type Principal = { + id?: number; + username?: string; + team_id?: number; +}; + +type Oauth_record = { + type: 'oauth'; + access_token: string; + refresh_token?: string; + expires_at: number; // epoch milliseconds + user?: Principal; +}; + +type Api_key_record = { + type: 'api_key'; + key: string; + user?: Principal; +}; + +type Credential_record = Oauth_record | Api_key_record; + +// Storage abstraction, keyed by profile name (so multiple accounts on the same +// backend stay isolated — the aws model). v1 backend is a 0600 JSON file; the +// deferred OS-keychain backend fits behind this same interface — hence the +// async signatures (keychain access is inherently async). +interface CredentialStore { + get(key: string): Promise; + set(key: string, record: Credential_record): Promise; + remove(key: string): Promise; + keys(): Promise; +} + +export type {Principal, Oauth_record, Api_key_record, Credential_record, CredentialStore}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..764178a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,118 @@ +#!/usr/bin/env node +import fs from 'fs'; +import path from 'path'; +import {Command, CommanderError} from 'commander'; +import {PROGRAM_NAME} from './config'; +import {auth_command} from './commands/auth'; +import {profile_command} from './commands/profile'; +import {CliError} from './utils/errors'; + +const read_version = (): string=>{ + try { + const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf-8')); + return pkg.version || '0.0.0'; + } catch { + return '0.0.0'; + } +}; + +// Route every command through commander's throwing mode so usage errors reach +// our handler and map to exit code 2 (vs 1 for runtime/API failures). +const set_exit_override = (cmd: Command): void=>{ + cmd.exitOverride(); + for (const sub of cmd.commands) + { + set_exit_override(sub); + } +}; + +const build_program = (): Command=>{ + const program = new Command(); + const PREFIX = PROGRAM_NAME.toUpperCase(); + program + .name(PROGRAM_NAME) + .description('Command-line interface for Reply.io — authentication and identity (v1).') + .version(read_version(), '-v, --version') + .option('-k, --api-key ', 'API key (overrides env var and stored credential)') + .option('-p, --profile ', 'Named backend profile (default: prod)') + .option('--team-id ', `Team/workspace to act in (X-TEAM-ID); else ${PREFIX}_TEAM_ID or the profile`) + .option('--user-id ', 'Act as this user id — organization API keys only (X-USER-ID)') + .option('--user-email ', 'Act as this user email — organization API keys only (needs a team id)') + .option('--json', 'Output compact JSON to stdout') + .option('--pretty', 'Output indented JSON to stdout') + .showHelpAfterError(); + + program.addCommand(auth_command); + program.addCommand(profile_command); + + program.addHelpText('after', ` +Credential precedence: + 1. --api-key 2. ${PREFIX}_API_KEY env 3. stored credential + +Profiles (which backend to talk to): + Precedence: --profile > ${PREFIX}_PROFILE > current profile > default (prod). + Define your own profiles under "profiles" in the config file, e.g.: + { "profiles": { "dev": { "authority": "https://…", "api_base": "https://…/v3" } } } + Then set one as current so you don't repeat --profile: + ${PROGRAM_NAME} profile use dev # used until you change it + ${PROGRAM_NAME} profile list # see all, * marks current + ${PROGRAM_NAME} profile current + +Team & acting user (headers): + --team-id Team/workspace to act in. Precedence: --team-id > ${PREFIX}_TEAM_ID > profile team_id. + Pin one on a profile: ${PROGRAM_NAME} profile set --team-id + --user-id / --user-email Identify the acting user for an ORGANIZATION API key. + Flag-only (never env, never stored); pass exactly one; --user-email also needs a team id. + +Configuration (env vars): + ${PREFIX}_API_KEY API key used as the bearer credential + ${PREFIX}_PROFILE Profile to use (same as --profile) + ${PREFIX}_TEAM_ID Team/workspace id (same as --team-id) + ${PREFIX}_CONFIG_DIR Override the per-user config directory + +Examples: + ${PROGRAM_NAME} auth login + echo | ${PROGRAM_NAME} auth login --with-token + ${PROGRAM_NAME} --profile dev auth whoami --json + ${PROGRAM_NAME} auth status +`); + + return program; +}; + +const wants_json = (): boolean=> + process.argv.includes('--json') || process.argv.includes('--pretty'); + +const main = async(): Promise=>{ + const program = build_program(); + set_exit_override(program); + await program.parseAsync(process.argv); +}; + +void main().catch((error: unknown)=>{ + if (error instanceof CommanderError) + { + // commander has already written help/usage text; help & version exit 0, + // any other usage problem exits 2. + const ok = error.code === 'commander.helpDisplayed' + || error.code === 'commander.version' + || error.code === 'commander.help'; + process.exit(ok ? 0 : 2); + } + if (error instanceof CliError) + { + if (wants_json()) + { + console.error(JSON.stringify(error.to_json())); + } + else + { + console.error(error.message); + } + process.exit(error.exit_code); + } + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); + +export {build_program}; diff --git a/src/profile.ts b/src/profile.ts new file mode 100644 index 0000000..93c8cfb --- /dev/null +++ b/src/profile.ts @@ -0,0 +1,231 @@ +import fs from 'fs'; +import path from 'path'; +import {config_file, get_env, type Env} from './config'; +import {UsageError, RuntimeError} from './utils/errors'; + +// A profile is a named backend bundle. There is NO built-in environment enum: +// the CLI ships only an implicit `default` (prod). Real users typically make +// one profile per account (often the email as the name) — all on prod. A +// profile inherits EVERY field from the embedded default unless it overrides +// it, so an account profile needs no URLs at all. +type Profile = { + name: string; + authority: string; // OAuth authority + api_base: string; // Reply v3 API base + team_id?: number; // optional team/workspace to pin (sent as X-TEAM-ID) +}; + +const DEFAULT_NAME = 'default'; + +// The embedded default profile: prod. Everything inherits from this. +const EMBEDDED = { + authority: 'https://oauth.reply.io', + api_base: 'https://api.reply.io/v3', +}; +// Back-compat alias for callers/tests referencing the prod target. +const PROD = EMBEDDED; + +type Profile_def = {authority?: unknown; api_base?: unknown; team_id?: unknown}; +type Config = {profiles?: unknown; current_profile?: unknown}; + +const strip_trailing_slash = (url: string): string=>url.replace(/\/+$/, ''); + +// team_id is account-specific and NOT inherited from the embedded default. +// Accept a number, or a numeric string from hand-edited config. +const read_team_id = (v: unknown): number | undefined=>{ + if (typeof v === 'number' && Number.isInteger(v)) + { + return v; + } + if (typeof v === 'string' && /^\d+$/.test(v.trim())) + { + return parseInt(v.trim(), 10); + } + return undefined; +}; + +const read_config = (env: Env): Config=>{ + const file = config_file(env); + let raw: string; + try { + raw = fs.readFileSync(file, 'utf8'); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') + { + return {}; + } + throw new RuntimeError('Could not read the config file.', { + code: 'config.read', detail: file, hint: (e as Error).message, + }); + } + if (!raw.trim()) + { + return {}; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new RuntimeError('Config file is corrupt (invalid JSON).', { + code: 'config.corrupt', detail: file, hint: 'Fix or delete the file.', + }); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) + { + throw new RuntimeError('Config file is corrupt (unexpected shape).', { + code: 'config.corrupt', detail: file, hint: 'Fix or delete the file.', + }); + } + return parsed as Config; +}; + +const get_profiles = (cfg: Config, env: Env): Record=>{ + const {profiles} = cfg; + if (profiles === undefined || profiles === null) + { + return {}; + } + if (typeof profiles !== 'object' || Array.isArray(profiles)) + { + throw new RuntimeError('Config "profiles" must be an object.', { + code: 'config.corrupt', detail: config_file(env), hint: 'Fix or delete the file.', + }); + } + return profiles as Record; +}; + +const persisted_current = (cfg: Config): string | undefined=> + typeof cfg.current_profile === 'string' && cfg.current_profile.trim() + ? cfg.current_profile + : undefined; + +const inherit = (value: unknown, fallback: string): string=> + typeof value === 'string' && value.trim() ? value : fallback; + +// Selection precedence: --profile flag -> REPLY_PROFILE env -> persisted +// current_profile -> built-in default. Each field inherits from the embedded +// default unless the named profile overrides it. An unknown name is a usage +// error (typo-safe) — create profiles with `profile add`. +const resolve_profile = (flag?: string, env: Env = process.env): Profile=>{ + const cfg = read_config(env); + const profiles = get_profiles(cfg, env); + const name = flag || get_env('PROFILE', env) || persisted_current(cfg) || DEFAULT_NAME; + + const declared = profiles[name]; + if (!declared && name !== DEFAULT_NAME) + { + throw new UsageError(`Unknown profile '${name}'.`, { + code: 'usage.profile', + hint: `Create it with \`profile add ${name}\`, or omit --profile for the default.`, + }); + } + const over = declared ?? {}; + const team_id = read_team_id(over.team_id); + return { + name, + authority: strip_trailing_slash(inherit(over.authority, EMBEDDED.authority)), + api_base: strip_trailing_slash(inherit(over.api_base, EMBEDDED.api_base)), + ...(team_id !== undefined ? {team_id} : {}), + }; +}; + +const current_profile_name = (env: Env = process.env): string=> + persisted_current(read_config(env)) || DEFAULT_NAME; + +const list_profiles = (env: Env = process.env): {current: string; available: string[]}=>{ + const cfg = read_config(env); + const names = new Set([DEFAULT_NAME, ...Object.keys(get_profiles(cfg, env))]); + return {current: persisted_current(cfg) || DEFAULT_NAME, available: [...names]}; +}; + +const write_config = (cfg: Record, env: Env): void=>{ + const file = config_file(env); + fs.mkdirSync(path.dirname(file), {recursive: true}); + fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', 'utf8'); +}; + +type Profile_fields = {authority?: string; api_base?: string; team_id?: number}; + +// Apply the given fields onto a profile def, in place (only fields actually +// provided are written, so merges are non-destructive). +const apply_fields = (def: Profile_def, fields: Profile_fields): void=>{ + if (fields.authority && fields.authority.trim()) + { + def.authority = fields.authority; + } + if (fields.api_base && fields.api_base.trim()) + { + def.api_base = fields.api_base; + } + if (fields.team_id !== undefined) + { + def.team_id = fields.team_id; + } +}; + +// Create a profile. URLs are optional — anything omitted is inherited from the +// embedded default (prod). team_id is optional and account-specific. +const add_profile = ( + name: string, + fields: Profile_fields, + env: Env = process.env, +): void=>{ + if (!name.trim()) + { + throw new UsageError('A profile name is required.', {code: 'usage.profile'}); + } + if (name === DEFAULT_NAME) + { + throw new UsageError('`default` is the built-in profile and cannot be added.', { + code: 'usage.profile', + hint: 'Pick another name, e.g. your account email.', + }); + } + const cfg = read_config(env) as Record; + const profiles = get_profiles(cfg as Config, env); + const def: Profile_def = {}; + apply_fields(def, fields); + profiles[name] = def; + cfg.profiles = profiles; + write_config(cfg, env); +}; + +// Edit an existing profile in place (merge-safe). Unlike add, `default` is +// allowed — it's the natural way for a no-profile user to pin a team without +// creating a named profile. A named profile must already exist. +const set_profile = ( + name: string, + fields: Profile_fields, + env: Env = process.env, +): void=>{ + if (!name.trim()) + { + throw new UsageError('A profile name is required.', {code: 'usage.profile'}); + } + const cfg = read_config(env) as Record; + const profiles = get_profiles(cfg as Config, env); + if (name !== DEFAULT_NAME && !profiles[name]) + { + throw new UsageError(`Unknown profile '${name}'.`, { + code: 'usage.profile', + hint: `Create it first with \`profile add ${name}\`.`, + }); + } + const def: Profile_def = {...(profiles[name] ?? {})}; + apply_fields(def, fields); + profiles[name] = def; + cfg.profiles = profiles; + write_config(cfg, env); +}; + +// Persist the current profile. Validates the name resolves first (so you can't +// set current to an unknown profile), then writes current_profile. +const set_current_profile = (name: string, env: Env = process.env): void=>{ + resolve_profile(name, env); // throws UsageError if the name is not valid + const cfg = read_config(env) as Record; + cfg.current_profile = name; + write_config(cfg, env); +}; + +export {resolve_profile, current_profile_name, list_profiles, set_current_profile, add_profile, set_profile, EMBEDDED, PROD}; +export type {Profile, Profile_fields}; diff --git a/src/utils/client.ts b/src/utils/client.ts new file mode 100644 index 0000000..3e200e5 --- /dev/null +++ b/src/utils/client.ts @@ -0,0 +1,135 @@ +import {PROGRAM_NAME} from '../config'; +import {Api_error, RuntimeError, type Api_error_body} from './errors'; + +// The v3 API auto-detects JWT (OAuth) vs API key from the same +// `Authorization: Bearer ` header, so both auth methods share this +// one transport path. +const TRANSIENT_STATUSES = [429, 500, 502, 503, 504]; +const MAX_RETRIES = 3; +const RETRY_BASE_MS = 500; +const RETRY_AFTER_CAP_MS = 30_000; + +type Request_opts = { + headers?: Record; // extra request headers (e.g. X-TEAM-ID) +}; + +const hint_for = (status: number): string | undefined=>{ + switch (status) + { + case 401: + return `Invalid or expired credential. Re-check your key/token or run \`${PROGRAM_NAME} auth login\`.`; + case 403: + return 'Access denied — the credential is missing a required scope.'; + case 404: + return 'Resource not found.'; + case 429: + return 'Rate limit exceeded. Wait a moment and try again.'; + default: + return undefined; + } +}; + +const sleep = (ms: number): Promise=>new Promise(resolve=>setTimeout(resolve, ms)); + +const parse_body = (text: string): Api_error_body | string=>{ + if (!text) + { + return ''; + } + try { + return JSON.parse(text) as Api_error_body; + } catch { + return text; + } +}; + +const retry_delay_ms = (res: Response, attempt: number): number=>{ + const retry_after = res.headers.get('Retry-After'); + if (retry_after) + { + const seconds = parseInt(retry_after, 10); + if (!isNaN(seconds) && seconds >= 0) + { + return Math.min(seconds * 1000, RETRY_AFTER_CAP_MS); + } + } + return RETRY_BASE_MS * 2 ** attempt; +}; + +const request = async( + base_url: string, + token: string, + method: string, + endpoint: string, + body?: unknown, + opts: Request_opts = {}, +): Promise=>{ + const url = `${base_url}${endpoint}`; + const headers: Record = { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + ...(opts.headers ?? {}), + }; + const init: RequestInit = {method, headers}; + if (body !== undefined) + { + init.body = JSON.stringify(body); + } + let attempt = 0; + while (attempt <= MAX_RETRIES) + { + let res: Response; + try { + res = await fetch(url, init); + } catch (e) { + if (attempt < MAX_RETRIES) + { + await sleep(RETRY_BASE_MS * 2 ** attempt); + attempt++; + continue; + } + throw new RuntimeError('Network request failed.', { + code: 'network', + detail: (e as Error).message, + hint: 'Check your connection and try again.', + }); + } + if (res.ok) + { + const text = await res.text(); + if (!text) + { + return null as T; + } + const parsed = parse_body(text); + return parsed as T; + } + if (TRANSIENT_STATUSES.includes(res.status) && attempt < MAX_RETRIES) + { + await sleep(retry_delay_ms(res, attempt)); + attempt++; + continue; + } + const err_text = await res.text().catch(()=>''); + throw new Api_error(res.status, parse_body(err_text), {hint: hint_for(res.status)}); + } + throw new RuntimeError('Max retries exceeded.', {code: 'network'}); +}; + +const get = ( + base_url: string, token: string, endpoint: string, opts?: Request_opts, +): Promise=>request(base_url, token, 'GET', endpoint, undefined, opts); + +type Client = { + get(endpoint: string, opts?: Request_opts): Promise; +}; + +const create_client = ( + base_url: string, token: string, headers?: Record, +): Client=>({ + get: (endpoint: string, opts?: Request_opts)=> + get(base_url, token, endpoint, {...opts, headers: {...headers, ...opts?.headers}}), +}); + +export {request, get, create_client}; +export type {Request_opts, Client}; diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..652507e --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,120 @@ +// Error taxonomy driving the CLI's exit-code contract: +// 0 ok · 1 API-or-runtime failure · 2 usage error. +// On --json, the top-level handler prints `error.to_json()` as a single line. + +type Error_json = { + status?: number; + code?: string; + title?: string; + detail?: string; + hint?: string; +}; + +const compact = (obj: Error_json): Error_json=>{ + const out: Error_json = {}; + for (const [k, v] of Object.entries(obj)) + { + if (v !== undefined && v !== null) + { + (out as Record)[k] = v; + } + } + return out; +}; + +abstract class CliError extends Error { + abstract readonly exit_code: number; + code?: string; + title?: string; + detail?: string; + hint?: string; + + to_json(): {error: Error_json} + { + return {error: compact({ + status: (this as {status?: number}).status, + code: this.code, + title: this.title, + detail: this.detail, + hint: this.hint, + })}; + } +} + +// Bad invocation: unknown flag/env, missing argument, no credential to use. +class UsageError extends CliError { + readonly exit_code = 2; + + constructor(message: string, opts: {code?: string; hint?: string} = {}) + { + super(message); + this.name = 'UsageError'; + this.title = message; + this.code = opts.code; + this.hint = opts.hint; + } +} + +// Non-HTTP runtime failure: corrupt credential store, network error, +// browser-launch failure, etc. Distinct from Api_error (which carries an +// HTTP status) but shares the exit-1 code. +class RuntimeError extends CliError { + readonly exit_code = 1; + + constructor(message: string, opts: {code?: string; detail?: string; hint?: string} = {}) + { + super(message); + this.name = 'RuntimeError'; + this.title = message; + this.code = opts.code; + this.detail = opts.detail; + this.hint = opts.hint; + } +} + +// v3 error body: {code: "contact.notFound", title, status, detail}. +type Api_error_body = { + code?: string; + title?: string; + status?: number; + detail?: string; +}; + +class Api_error extends CliError { + readonly exit_code = 1; + status: number; + + constructor( + status: number, + body: Api_error_body | string, + opts: {hint?: string} = {}, + ) + { + const parsed = typeof body === 'string' ? {detail: body} : body; + const title = parsed.title || `HTTP ${status}`; + const parts = [`Error: ${title}`]; + if (parsed.detail) + { + parts.push(` Detail: ${parsed.detail}`); + } + parts.push(` Status: ${status}`); + if (parsed.code) + { + parts.push(` Code: ${parsed.code}`); + } + if (opts.hint) + { + parts.push(` Hint: ${opts.hint}`); + } + super(parts.join('\n')); + this.name = 'Api_error'; + this.status = status; + this.title = title; + this.code = parsed.code; + this.detail = parsed.detail; + this.hint = opts.hint; + } +} + +export {CliError, UsageError, RuntimeError, Api_error}; +export type {Error_json, Api_error_body}; diff --git a/src/utils/output.ts b/src/utils/output.ts new file mode 100644 index 0000000..8e5cb44 --- /dev/null +++ b/src/utils/output.ts @@ -0,0 +1,59 @@ +import pc from 'picocolors'; +import type {Credential_record} from '../credentials/types'; + +// Output contract: data goes to stdout; status/errors go to stderr. This keeps +// `--json` stdout clean for piping while humans still see progress messages. + +const REDACTED = '••••••••'; + +// Fully mask a secret — reveals neither content nor length. `auth status` +// never prints a raw token; this guarantees it by construction. +const redact = (_secret: string): string=>REDACTED; + +// A display/serialization-safe copy of a credential record with every secret +// field masked. +const safe_record = (record: Credential_record): Record=>{ + if (record.type === 'oauth') + { + return { + ...record, + access_token: REDACTED, + refresh_token: record.refresh_token ? REDACTED : undefined, + }; + } + return {...record, key: REDACTED}; +}; + +const is_tty = process.stdout.isTTY === true; + +const success = (msg: string): void=>console.error(pc.green(`✓ ${msg}`)); +const warn = (msg: string): void=>console.error(pc.yellow(`⚠ ${msg}`)); +const info = (msg: string): void=>console.error(pc.dim(msg)); + +type Print_opts = { + json?: boolean; + pretty?: boolean; +}; + +const serialize = (data: unknown, opts: Print_opts): string=>{ + if (opts.pretty) + { + return JSON.stringify(data, null, 2); + } + if (opts.json) + { + return JSON.stringify(data); + } + if (typeof data === 'string') + { + return data; + } + return JSON.stringify(data, null, 2); +}; + +const print = (data: unknown, opts: Print_opts = {}): void=>{ + process.stdout.write(serialize(data, opts) + '\n'); +}; + +export {is_tty, pc, REDACTED, redact, safe_record, success, warn, info, print}; +export type {Print_opts}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c46807e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "declaration": true, + "sourceMap": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/__tests__"] +}