diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ca6f75e --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +UPSTASH_EMAIL=you@example.com +UPSTASH_API_KEY=your_dev_api_key diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9dacaaf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + name: Build & Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - run: npm ci + + - run: npm run build + + - run: npm run typecheck + + test: + name: Tests + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - run: npm ci + + - name: Run tests + env: + UPSTASH_EMAIL: ${{ secrets.UPSTASH_EMAIL }} + UPSTASH_API_KEY: ${{ secrets.UPSTASH_API_KEY }} + run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 45c9043..07987c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,67 +5,28 @@ on: types: - published +permissions: + id-token: write + contents: read + jobs: npm: name: Release on npm runs-on: ubuntu-latest steps: - - name: Checkout Repo - uses: actions/checkout@v2 - - - name: Set env - run: echo "VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV + - uses: actions/checkout@v4 - - name: Setup Node - uses: actions/setup-node@v2 + - uses: actions/setup-node@v4 with: - node-version: 16 - - - run: curl -fsSL https://deno.land/x/install/install.sh | sh + node-version: 24 + registry-url: https://registry.npmjs.org - - run: echo "$HOME/.deno/bin" > $GITHUB_PATH + - run: npm ci - - name: Build - run: deno run -A ./cmd/build.ts $VERSION + - name: Publish release candidate + if: github.event.release.prerelease + run: npm publish --access public --tag next --provenance - name: Publish if: "!github.event.release.prerelease" - working-directory: ./dist - run: | - echo "//registry.npmjs.org/:_authToken=${{secrets.NPM_TOKEN}}" > .npmrc - npm publish --access public - - - name: Publish release candidate - if: "github.event.release.prerelease" - working-directory: ./dist - run: | - echo "//registry.npmjs.org/:_authToken=${{secrets.NPM_TOKEN}}" > .npmrc - npm publish --access public --tag=next - - binaries: - name: Release binary - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - arch: - [ - "x86_64-unknown-linux-gnu", - "x86_64-pc-windows-msvc", - "x86_64-apple-darwin", - "aarch64-apple-darwin", - ] - steps: - - name: Checkout Repo - uses: actions/checkout@v2 - - - run: curl -fsSL https://deno.land/x/install/install.sh | sh - - run: echo "$HOME/.deno/bin" > $GITHUB_PATH - - - name: compile - run: deno compile --allow-env --allow-read --allow-write --allow-net --target=${{ matrix.arch }} --output=./bin/upstash_${{ matrix.arch}} ./src/mod.ts - - - name: upload - run: gh release upload ${GITHUB_REF#refs/*/} ./bin/* - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npm publish --access public --provenance diff --git a/.gitignore b/.gitignore index 5bfcd1f..0672e36 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .env dist +node_modules diff --git a/Makefile b/Makefile deleted file mode 100644 index a9b26fb..0000000 --- a/Makefile +++ /dev/null @@ -1,23 +0,0 @@ - - -fmt: clean - deno fmt - deno lint - - -clean: - rm -rf dist - -build-node: fmt - deno run -A ./cmd/build.ts - - -build-bin: fmt - deno compile \ - --allow-env \ - --allow-read \ - --allow-write \ - --allow-net \ - --output=./bin/upstash\ - ./src/mod.ts - diff --git a/README.md b/README.md index 608b4c7..c2f91db 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,234 @@ # Upstash CLI -Manage Upstash resources in your terminal or CI. - -![](./img/banner.svg) +Manage Upstash services from the terminal or automation via the [Upstash Developer API](https://docs.upstash.com/redis/howto/developerapi). Commands are non-interactive; API-returning commands print JSON on stdout (parse with `jq` or similar). Errors go to stderr as `{ "error": "..." }` with exit code 1. The auth helpers `login` / `logout` print a plain status line. ![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/upstash/cli) [![Downloads/week](https://img.shields.io/npm/dw/lstr.svg)](https://npmjs.org/package/@upstash/cli) -# Installation - -## npm +**Agent reference:** [`CLAUDE.md`](./CLAUDE.md) (Cursor rules). Full CLI catalog for agents also lives at [`.agents/skills/upstash-cli/SKILL.md`](./.agents/skills/upstash-cli/SKILL.md). -You can install upstash's cli directly from npm +## Installation ```bash npm i -g @upstash/cli ``` -It will be added as `upstash` to your system's path. +The `upstash` binary is on your `PATH`. + +Prebuilt binaries (Windows, Linux, macOS Intel and Apple Silicon) are on [GitHub Releases](https://github.com/upstash/cli/releases/latest). -## Compiled binaries: +## Authentication + +Pick any one of these methods: -`upstash` is also available from the -[releases page](https://github.com/upstash/cli/releases/latest) compiled for -windows, linux and mac (both intel and m1). +**`upstash login`** — interactive prompt, or non-interactive with flags. Saves credentials to `$XDG_CONFIG_HOME/upstash/config.json` (default `~/.config/upstash/config.json`, mode `0600`): +```bash +upstash login # interactive +upstash login --email you@example.com --api-key # non-interactive +upstash logout # removes the saved file +``` -# Usage +**Environment variables** +```bash +export UPSTASH_EMAIL=you@example.com +export UPSTASH_API_KEY=your_api_key +``` +**`.env` file** — place a `.env` in your working directory and the CLI loads it automatically: ```bash -> upstash +UPSTASH_EMAIL=you@example.com +UPSTASH_API_KEY=your_api_key +``` - Usage: upstash - Version: development +**Per-command flags** — `--email` and `--api-key` override everything else for that invocation. - Description: +**Custom `.env` path** — use `--env-path ` to load a file from a specific location: +```bash +upstash --env-path ~/secrets/.env redis list +``` - Official cli for Upstash products +Precedence: flags > environment variables > `.env` file > saved config file. - Options: +For agents, a **read-only** Developer API key is often enough: the API only returns what that key allows, and only those operations succeed—mutations fail at the API like in the console. - -h, --help - Show this help. - -V, --version - Show the version number for this program. - -c, --config - Path to .upstash.json file +See [how to get an API key](https://docs.upstash.com/redis/howto/developerapi#api-development). - Commands: +## Global flags - auth - Login and logout - redis - Manage redis database instances - team - Manage your teams and their members +These flags are accepted on commands that call the API: - Environment variables: +| Flag | Description | +|------|-------------| +| `--email ` | Upstash email (overrides `UPSTASH_EMAIL`) | +| `--api-key ` | Upstash API key (overrides `UPSTASH_API_KEY`) | - UPSTASH_EMAIL - The email you use on upstash - UPSTASH_API_KEY - The api key from upstash +Scoped commands use explicit resource flags with a shared placeholder: `--db-id `, `--index-id `, `--qstash-id `, `--team-id ` (see `--help` on each command). + +## Top-level commands + +```text +upstash login # Save credentials to the user config file +upstash logout # Delete saved credentials +upstash redis # Redis databases +upstash team # Teams and members +upstash vector # Vector indexes +upstash search # Search indexes +upstash qstash # QStash instances ``` -## Authentication +```bash +upstash --help +upstash redis --help +``` + +## Output format + +- **Resource payload:** e.g. `{ "database_id": "...", "state": "active", ... }` +- **Boolean success:** `{ "success": true, ... }` +- **Delete:** `{ "deleted": true, ... }` +- **Error (exit code 1):** `{ "error": "message" }` on stderr + +## Redis commands + +### Execute via REST (`redis exec` does not use the Developer API key) + +```bash +upstash redis exec --db-url --db-token SET key value +upstash redis exec --db-url --db-token GET key +upstash redis exec --db-url --db-token --json '["SET","key","value"]' +``` + +`--db-url` and `--db-token` can be omitted if `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` are set (env vars or `.env` file). Use `endpoint` and `rest_token` from `upstash redis get --db-id `. -When running `upstash` for the first time, you should log in using -`upstash auth login`. Provide your email and an api key. -[See here for how to get a key.](https://docs.upstash.com/redis/howto/developerapi#api-development) +### Core -As an alternative to logging in, you can provide `UPSTASH_EMAIL` and -`UPSTASH_API_KEY` as environment variables. +```bash +upstash redis list +upstash redis get --db-id +upstash redis get --db-id --hide-credentials +upstash redis create --name --region +upstash redis create --name --region --read-regions +upstash redis delete --db-id --dry-run +upstash redis delete --db-id +upstash redis rename --db-id --name +upstash redis reset-password --db-id +upstash redis stats --db-id +``` -## Usage +**Regions — AWS:** `us-east-1`, `us-east-2`, `us-west-1`, `us-west-2`, `ca-central-1`, `eu-central-1`, `eu-west-1`, `eu-west-2`, `sa-east-1`, `ap-south-1`, `ap-northeast-1`, `ap-southeast-1`, `ap-southeast-2`, `af-south-1` +**Regions — GCP:** `us-central1`, `us-east4`, `europe-west1`, `asia-northeast1` -Let's create a new redis database: +### Configuration +```bash +upstash redis enable-tls --db-id +upstash redis enable-eviction --db-id +upstash redis disable-eviction --db-id +upstash redis enable-autoupgrade --db-id +upstash redis disable-autoupgrade --db-id +upstash redis change-plan --db-id --plan # free, payg, pro, paid +upstash redis update-budget --db-id --budget +upstash redis update-regions --db-id --read-regions +upstash redis move-to-team --db-id --team-id ``` -> upstash redis create --name=my-db --region=eu-west-1 - Database has been created - database_id a3e25299-132a-45b9-b026-c73f5a807859 - database_name my-db - database_type Pay as You Go - region eu-west-1 - type paid - port 37090 - creation_time 1652687630 - state active - password 88ae6392a1084d1186a3da37fb5f5a30 - user_email andreas@upstash.com - endpoint eu1-magnetic-lacewing-37090.upstash.io - edge false - multizone false - rest_token AZDiASQgYTNlMjUyOTktMTMyYS00NWI5LWIwMjYtYzczZjVhODA3ODU5ODhhZTYzOTJhMTA4NGQxMTg2YTNkYTM3ZmI1ZjVhMzA= - read_only_rest_token ApDiASQgYTNlMjUyOTktMTMyYS00NWI5LWIwMjYtYzczZjVhODA3ODU5O_InFjRVX1XHsaSjq1wSerFCugZ8t8O1aTfbF6Jhq1I= +### Backups +```bash +upstash redis backup list --db-id +upstash redis backup create --db-id --name +upstash redis backup delete --db-id --backup-id --dry-run +upstash redis backup delete --db-id --backup-id +upstash redis backup restore --db-id --backup-id +upstash redis backup enable-daily --db-id +upstash redis backup disable-daily --db-id +``` - You can visit your database details page: https://console.upstash.com/redis/a3e25299-132a-45b9-b026-c73f5a807859 +## Team commands - Connect to your database with redis-cli: redis-cli -u redis://88ae6392a1084d1186a3da37fb5f5a30@eu1-magnetic-lacewing-37090.upstash.io:37090 +```bash +upstash team list +upstash team create --name +upstash team create --name --copy-cc +upstash team delete --team-id --dry-run +upstash team delete --team-id +upstash team members --team-id +upstash team add-member --team-id --member-email --role +upstash team remove-member --team-id --member-email --dry-run +upstash team remove-member --team-id --member-email ``` -## Output +Member roles: `admin`, `dev`, `finance`. -Most commands support the `--json` flag to return the raw api response as json, -which you can parse and automate your system. +## Vector commands ```bash -> upstash redis create --name=test2113 --region=us-central1 --json | jq '.endpoint' +upstash vector list +upstash vector get --index-id +upstash vector create --name --region --similarity-function --dimension-count +upstash vector delete --index-id --dry-run +upstash vector delete --index-id +upstash vector rename --index-id --name +upstash vector reset-password --index-id +upstash vector set-plan --index-id --plan # free, payg, fixed +upstash vector transfer --index-id --target-account +upstash vector stats +upstash vector index-stats --index-id +upstash vector index-stats --index-id --period # 1h, 3h, 12h, 1d, 3d, 7d, 30d +``` - "gusc1-clean-gelding-30208.upstash.io" +**Regions:** `eu-west-1`, `us-east-1`, `us-central1` +**Similarity:** `COSINE`, `EUCLIDEAN`, `DOT_PRODUCT` +**Index types:** `DENSE`, `SPARSE`, `HYBRID` + +## Search commands + +```bash +upstash search list +upstash search get --index-id +upstash search create --name --region --type +upstash search delete --index-id --dry-run +upstash search delete --index-id +upstash search rename --index-id --name +upstash search reset-password --index-id +upstash search transfer --index-id --target-account +upstash search stats +upstash search index-stats --index-id +upstash search index-stats --index-id --period ``` +**Regions:** `eu-west-1`, `us-central1` +**Plans:** `free`, `payg`, `fixed` + +## QStash commands + +```bash +upstash qstash list # All instances; map region → id for other commands +upstash qstash get --qstash-id +upstash qstash rotate-token --qstash-id +upstash qstash set-plan --qstash-id --plan # paid, qstash_fixed_1m, qstash_fixed_10m, qstash_fixed_100m +upstash qstash stats --qstash-id +upstash qstash stats --qstash-id --period # 1h, 3h, 12h, 1d, 3d, 7d, 30d +upstash qstash ipv4 +upstash qstash move-to-team --qstash-id --target-team-id +upstash qstash update-budget --qstash-id --budget # 0 = no limit +upstash qstash enable-prodpack --qstash-id +upstash qstash disable-prodpack --qstash-id +``` + +## Examples + +```bash +upstash redis list | jq '.[].database_id' +upstash vector list | jq '.[] | {id, name, region}' +upstash qstash list | jq '.[] | {id, region}' +upstash team members --team-id | jq '.[].member_email' +``` + +Use `--dry-run` before `delete` or `remove-member`. Use `--hide-credentials` on `redis get` when you do not need the password. + ## Contributing -If anything feels wrong, you discover a bug or want to request improvements, -please create an issue or talk to us on -[Discord](https://discord.com/invite/w9SenAtbme) +From a clone: `npm install` and `npm run build`, then run `node dist/cli.js …` or `npm link` to try your build as `upstash`. + +Issues and improvements: open a ticket or talk to us on [Discord](https://discord.com/invite/w9SenAtbme). diff --git a/cmd/build.ts b/cmd/build.ts deleted file mode 100644 index dd81733..0000000 --- a/cmd/build.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { build, emptyDir } from "https://deno.land/x/dnt@0.23.0/mod.ts"; - -const packageManager = "npm"; -const outDir = "./dist"; -const version = Deno.args[0] ?? "development"; - -await emptyDir(outDir); - -Deno.writeTextFileSync( - "./src/version.ts", - `export const VERSION = "${version}"`, -); - -await build({ - packageManager, - entryPoints: [{ kind: "bin", name: "upstash", path: "src/mod.ts" }], - outDir, - shims: { - deno: true, - // undici: true, - custom: [ - { - package: { name: "node-fetch", version: "latest" }, - globalNames: [{ name: "fetch", exportName: "default" }], - }, - ], - }, - - scriptModule: false, - declaration: false, - typeCheck: false, - test: typeof Deno.env.get("TEST") !== "undefined", - package: { - // package.json properties - name: "@upstash/cli", - version, - description: "CLI for Upstash resources.", - repository: { - type: "git", - url: "git+https://github.com/upstash/cli.git", - }, - keywords: ["cli", "redis", "kafka", "serverless", "edge", "upstash"], - contributors: [ - { - name: "Andreas Thomas", - email: "dev@chronark.com", - }, - ], - license: "MIT", - bugs: { - url: "https://github.com/upstash/cli/issues", - }, - homepage: "https://github.com/upstash/cli#readme", - }, -}); -Deno.writeTextFileSync( - "./src/version.ts", - `// This is set during build -export const VERSION = "development";`, -); - -// post build steps -Deno.copyFileSync("LICENSE", `${outDir}/LICENSE`); -Deno.copyFileSync("README.md", `${outDir}/README.md`); diff --git a/img/banner.svg b/img/banner.svg deleted file mode 100644 index f651d90..0000000 --- a/img/banner.svg +++ /dev/null @@ -1,17 +0,0 @@ -
\ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cc883e3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1486 @@ +{ + "name": "@upstash/cli", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@upstash/cli", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "commander": "^13.0.0", + "dotenv": "^16.4.5" + }, + "bin": { + "upstash": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "typescript": "^5.3.0", + "vitest": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "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/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.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/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "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/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "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": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "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": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "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..2fc614b --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "@upstash/cli", + "version": "1.0.0", + "description": "Agent-friendly CLI for Upstash", + "repository": { + "type": "git", + "url": "https://github.com/upstash/cli.git" + }, + "type": "module", + "bin": { + "upstash": "./dist/cli.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "prepublishOnly": "npm run build", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc -p tsconfig.test.json --noEmit" + }, + "keywords": [ + "upstash", + "cli" + ], + "author": "Upstash", + "license": "MIT", + "dependencies": { + "commander": "^13.0.0", + "dotenv": "^16.4.5" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "typescript": "^5.3.0", + "vitest": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ] +} diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..f680b88 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,38 @@ +export interface Auth { + email: string; + apiKey: string; +} + +import type { Command } from "commander"; +import { readConfig } from "./config.js"; + +export function resolveAuth(cmdOrFlags: Command | { email?: string; apiKey?: string }): Auth { + const opts = typeof (cmdOrFlags as Command).optsWithGlobals === "function" + ? (cmdOrFlags as Command).optsWithGlobals() + : cmdOrFlags; + const flagEmail = (opts as { email?: string }).email; + const flagKey = (opts as { apiKey?: string }).apiKey; + const envEmail = process.env.UPSTASH_EMAIL; + const envKey = process.env.UPSTASH_API_KEY; + + // If any flag/env auth signal is present, resolve from that tier only — + // don't mix a partial session with the saved config, since that silently + // combines credentials from different accounts. + if (flagEmail || flagKey || envEmail || envKey) { + const email = flagEmail ?? envEmail; + const apiKey = flagKey ?? envKey; + if (!email || !apiKey) { + throw new Error( + "Authentication is incomplete: provide both --email and --api-key, or set both UPSTASH_EMAIL and UPSTASH_API_KEY. Or unset them and run `upstash login` to use saved credentials." + ); + } + return { email, apiKey }; + } + + const stored = readConfig(); + if (stored) return stored; + + throw new Error( + "Authentication required. Run `upstash login` to save credentials, or provide --email and --api-key flags, or set UPSTASH_EMAIL and UPSTASH_API_KEY environment variables (also honored from a .env file in the current directory)." + ); +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..165afe8 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import { Command } from "commander"; +import pkg from "../package.json" with { type: "json" }; +const { version } = pkg; +import { registerRedis } from "./commands/redis/index.js"; +import { registerTeam } from "./commands/team/index.js"; +import { registerVector } from "./commands/vector/index.js"; +import { registerSearch } from "./commands/search/index.js"; +import { registerQStash } from "./commands/qstash/index.js"; +import { registerLogin } from "./commands/login.js"; +import { registerLogout } from "./commands/logout.js"; +import { handleError } from "./output.js"; +import dotenv from "dotenv"; + +// Pre-scan argv for --env-path before Commander parses, so dotenv loads +// the right file before any command action reads process.env. +function findEnvPath(argv: string[]): string | undefined { + const eq = argv.find((a) => a.startsWith("--env-path=")); + if (eq) return eq.slice("--env-path=".length); + const i = argv.indexOf("--env-path"); + return i !== -1 ? argv[i + 1] : undefined; +} +const envFilePath = findEnvPath(process.argv); +const dotenvResult = dotenv.config(envFilePath ? { path: envFilePath } : undefined); +if (envFilePath && dotenvResult.error) { + console.error(JSON.stringify({ error: `Could not load env file: ${envFilePath}` })); + process.exit(1); +} + +const program = new Command(); + +program + .name("upstash") + .description("Agent-friendly CLI for Upstash") + .version(version) + .option("--env-path ", "Path to a .env file to load credentials from") + .option("--email ", "Upstash email (overrides UPSTASH_EMAIL)") + .option("--api-key ", "Upstash API key (overrides UPSTASH_API_KEY)"); + +registerLogin(program); +registerLogout(program); +registerRedis(program); +registerTeam(program); +registerVector(program); +registerSearch(program); +registerQStash(program); + +program.parseAsync().catch(handleError); diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..f8f1af2 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,45 @@ +import type { Auth } from "./auth.js"; + +const BASE_URL = "https://api.upstash.com"; + +export class HttpError extends Error { + readonly status: number; + constructor(message: string, status: number) { + super(message); + this.status = status; + } +} + +export async function request( + auth: Auth, + method: string, + path: string, + body?: unknown, +): Promise { + const credentials = Buffer.from(`${auth.email}:${auth.apiKey}`).toString("base64"); + const response = await fetch(`${BASE_URL}${path}`, { + method, + headers: { + Authorization: `Basic ${credentials}`, + "Content-Type": "application/json", + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + const text = await response.text(); + + if (!response.ok) { + let message = text || `HTTP ${response.status}`; + try { + const parsed = JSON.parse(text) as { error?: unknown; message?: unknown }; + const msg = parsed.error ?? parsed.message; + if (typeof msg === "string" && msg.length > 0) message = msg; + } catch { + // fall through with the raw text + } + throw new HttpError(message, response.status); + } + + if (text === "" || text === '"OK"') return "OK" as T; + return JSON.parse(text) as T; +} diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts deleted file mode 100644 index 7a7ccfe..0000000 --- a/src/commands/auth/login.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Command } from "../../util/command.ts"; -import { DEFAULT_CONFIG_PATH, loadConfig } from "../../config.ts"; -import { cliffy } from "../../deps.ts"; -export const loginCmd = new Command() - .name("login") - .description( - `Log into your upstash account. -This will store your email and api key in ${DEFAULT_CONFIG_PATH}. -you can override this with "--config=/path/to/.upstash.json"`, - ) - .option("-e, --email=", "The email you use in upstash console") - .option( - "-k, --api-key=", - "Management api apiKey from https://console.upstash.com/account/api", - ) - .action((options): void => { - const config = loadConfig(options.config); - if (config) { - throw new Error( - `You are already logged in, please log out first or delete ${options.config}`, - ); - } - let email = options.upstashEmail; - if (!email) { - email = options.email; - } - - if (!email) { - throw new cliffy.ValidationError( - `email is missing, either use "--email" or set "UPSTASH_EMAIL" environment variable`, - ); - } - // if (!email) { - // if (options.ci) { - // throw new cliffy.ValidationError("email"); - // } - // email = await cliffy.Input.prompt("Enter your email"); - // } - - let apiKey = options.upstashApiKey; - - if (!apiKey) { - apiKey = options.apiKey; - } - if (!apiKey) { - throw new cliffy.ValidationError( - `api key is missing, either use "--api-key" or set "UPSTASH_API_KEY" environment variable`, - ); - } - // if (!apiKey) { - // if (options.ci) { - // throw new cliffy.ValidationError("apiKey"); - // } - // apiKey = await cliffy.Secret.prompt( - // "Enter your apiKey from https://console.upstash.com/account/api" - // ); - // } - - Deno.writeTextFileSync(options.config, JSON.stringify({ email, apiKey })); - }); diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts deleted file mode 100644 index 6ab8d73..0000000 --- a/src/commands/auth/logout.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Command } from "../../util/command.ts"; -import { deleteConfig } from "../../config.ts"; -export const logoutCmd = new Command() - .name("logout") - .description("Delete local configuration") - .action((options): void => { - try { - deleteConfig(options.config); - console.log("You have been logged out"); - } catch { - console.log("You were not logged in"); - } - }); diff --git a/src/commands/auth/mod.ts b/src/commands/auth/mod.ts deleted file mode 100644 index 92896bf..0000000 --- a/src/commands/auth/mod.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Command } from "../../util/command.ts"; -import { loginCmd } from "./login.ts"; -import { logoutCmd } from "./logout.ts"; -import { whoamiCmd } from "./whoami.ts"; -const authCmd = new Command(); - -authCmd - .description("Login and logout") - .command("login", loginCmd) - .command("logout", logoutCmd) - .command("whoami", whoamiCmd); - -authCmd.reset().action(() => { - authCmd.showHelp(); -}); - -export { authCmd }; diff --git a/src/commands/auth/whoami.ts b/src/commands/auth/whoami.ts deleted file mode 100644 index 5c7666c..0000000 --- a/src/commands/auth/whoami.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { cliffy } from "../../deps.ts"; -import { Command } from "../../util/command.ts"; -import { loadConfig } from "../../config.ts"; -export const whoamiCmd = new Command() - .name("whoami") - .description("Return the current users email") - .action((options): void => { - const config = loadConfig(options.config); - if (!config) { - throw new Error("You are not logged in, please run `upstash auth login`"); - } - - console.log( - `Currently logged in as ${cliffy.colors.brightGreen(config.email)}`, - ); - }); diff --git a/src/commands/login.ts b/src/commands/login.ts new file mode 100644 index 0000000..a7d458b --- /dev/null +++ b/src/commands/login.ts @@ -0,0 +1,90 @@ +import { Command } from "commander"; +import { createInterface } from "node:readline"; +import { writeConfig } from "../config.js"; +import { HttpError, request } from "../client.js"; +import { plainError } from "../output.js"; + +export function registerLogin(program: Command): void { + program + .command("login") + .description("Save Upstash credentials to the user config file. Uses --email/--api-key if provided, otherwise prompts interactively.") + .action(async (_flags: unknown, command: Command) => { + const globals = command.optsWithGlobals() as { email?: string; apiKey?: string }; + const email = globals.email ?? await promptLine("Upstash email: "); + if (!globals.apiKey) { + process.stderr.write("Create an API key at https://console.upstash.com/account/api\n"); + } + const apiKey = globals.apiKey ?? await promptHidden("Upstash API key: "); + + if (!email) throw plainError("Email is required."); + if (!apiKey) throw plainError("API key is required."); + + try { + await request({ email, apiKey }, "GET", "/v2/redis/databases"); + } catch (err) { + if (err instanceof HttpError && (err.status === 401 || err.status === 403)) { + throw plainError("Authentication failed: the email and API key combination is not valid."); + } + const reason = err instanceof Error ? err.message : String(err); + throw plainError(`Could not verify credentials: ${reason}`); + } + + const path = writeConfig({ email, apiKey }); + console.log(`Credentials verified and saved to ${path}`); + }); +} + +function promptLine(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +function promptHidden(question: string): Promise { + return new Promise((resolve, reject) => { + const stdin = process.stdin; + const stderr = process.stderr; + if (!stdin.isTTY || typeof stdin.setRawMode !== "function") { + promptLine(question).then(resolve, reject); + return; + } + stderr.write(question); + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding("utf8"); + let value = ""; + const onData = (chunk: string): void => { + for (const ch of chunk) { + if (ch === "\r" || ch === "\n") { + stdin.setRawMode(false); + stdin.pause(); + stdin.removeListener("data", onData); + stderr.write("\n"); + resolve(value); + return; + } + if (ch === "\u0003") { + stdin.setRawMode(false); + stdin.pause(); + stdin.removeListener("data", onData); + stderr.write("\n"); + process.exit(130); + } + if (ch === "\u007f" || ch === "\b") { + if (value.length > 0) { + value = value.slice(0, -1); + stderr.write("\b \b"); + } + continue; + } + value += ch; + stderr.write("*"); + } + }; + stdin.on("data", onData); + }); +} diff --git a/src/commands/logout.ts b/src/commands/logout.ts new file mode 100644 index 0000000..67cd4b6 --- /dev/null +++ b/src/commands/logout.ts @@ -0,0 +1,17 @@ +import { Command } from "commander"; +import { deleteConfig, getConfigPath } from "../config.js"; + +export function registerLogout(program: Command): void { + program + .command("logout") + .description("Delete saved credentials from the user config file.") + .action(() => { + const path = getConfigPath(); + const removed = deleteConfig(); + if (removed) { + console.log(`Removed credentials at ${path}`); + } else { + console.log(`No saved credentials at ${path}`); + } + }); +} diff --git a/src/commands/qstash/disable-prodpack.ts b/src/commands/qstash/disable-prodpack.ts new file mode 100644 index 0000000..63b5ab7 --- /dev/null +++ b/src/commands/qstash/disable-prodpack.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; + +export function registerQStashDisableProdpack(qstash: Command): void { + qstash + .command("disable-prodpack") + .description("Disable the production pack for a QStash instance") + .requiredOption("--qstash-id ", "QStash instance ID") + .action(async (flags: { qstashId: string }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "POST", `/v2/qstash/disable-prodpack/${flags.qstashId}`); + printJSON(result); + }); +} diff --git a/src/commands/qstash/enable-prodpack.ts b/src/commands/qstash/enable-prodpack.ts new file mode 100644 index 0000000..71af04a --- /dev/null +++ b/src/commands/qstash/enable-prodpack.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; + +export function registerQStashEnableProdpack(qstash: Command): void { + qstash + .command("enable-prodpack") + .description("Enable the production pack for a QStash instance") + .requiredOption("--qstash-id ", "QStash instance ID") + .action(async (flags: { qstashId: string }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "POST", `/v2/qstash/enable-prodpack/${flags.qstashId}`); + printJSON(result); + }); +} diff --git a/src/commands/qstash/get.ts b/src/commands/qstash/get.ts new file mode 100644 index 0000000..bb886d1 --- /dev/null +++ b/src/commands/qstash/get.ts @@ -0,0 +1,17 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { QStashUser } from "../../types.js"; + +export function registerQStashGet(qstash: Command): void { + qstash + .command("get") + .description("Get details of a QStash instance") + .requiredOption("--qstash-id ", "QStash instance ID") + .action(async (flags: { qstashId: string }, command: Command) => { + const auth = resolveAuth(command); + const q = await request(auth, "GET", `/v2/qstash/user/${flags.qstashId}`); + printJSON(q); + }); +} diff --git a/src/commands/qstash/index.ts b/src/commands/qstash/index.ts new file mode 100644 index 0000000..da3c4c3 --- /dev/null +++ b/src/commands/qstash/index.ts @@ -0,0 +1,26 @@ +import { Command } from "commander"; +import { registerQStashGet } from "./get.js"; +import { registerQStashList } from "./list.js"; +import { registerQStashRotateToken } from "./rotate-token.js"; +import { registerQStashSetPlan } from "./set-plan.js"; +import { registerQStashStats } from "./stats.js"; +import { registerQStashIpv4 } from "./ipv4.js"; +import { registerQStashMoveToTeam } from "./move-to-team.js"; +import { registerQStashUpdateBudget } from "./update-budget.js"; +import { registerQStashEnableProdpack } from "./enable-prodpack.js"; +import { registerQStashDisableProdpack } from "./disable-prodpack.js"; + +export function registerQStash(program: Command): void { + const qstash = program.command("qstash").description("Manage QStash instances"); + + registerQStashGet(qstash); + registerQStashList(qstash); + registerQStashRotateToken(qstash); + registerQStashSetPlan(qstash); + registerQStashStats(qstash); + registerQStashIpv4(qstash); + registerQStashMoveToTeam(qstash); + registerQStashUpdateBudget(qstash); + registerQStashEnableProdpack(qstash); + registerQStashDisableProdpack(qstash); +} diff --git a/src/commands/qstash/ipv4.ts b/src/commands/qstash/ipv4.ts new file mode 100644 index 0000000..4a5d0bd --- /dev/null +++ b/src/commands/qstash/ipv4.ts @@ -0,0 +1,15 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; + +export function registerQStashIpv4(qstash: Command): void { + qstash + .command("ipv4") + .description("List IPv4 CIDR blocks used by QStash (for firewall allowlisting)") + .action(async (flags: Record, command: Command) => { + const auth = resolveAuth(command); + const addresses = await request(auth, "GET", "/v2/qstash/ipv4"); + printJSON(addresses); + }); +} diff --git a/src/commands/qstash/list.ts b/src/commands/qstash/list.ts new file mode 100644 index 0000000..244b379 --- /dev/null +++ b/src/commands/qstash/list.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { QStashUser } from "../../types.js"; + +export function registerQStashList(qstash: Command): void { + qstash + .command("list") + .description("List all QStash instances (id and region per deployment)") + .action(async (flags: Record, command: Command) => { + const auth = resolveAuth(command); + const users = await request(auth, "GET", "/v2/qstash/users"); + printJSON(users); + }); +} diff --git a/src/commands/qstash/move-to-team.ts b/src/commands/qstash/move-to-team.ts new file mode 100644 index 0000000..f63646c --- /dev/null +++ b/src/commands/qstash/move-to-team.ts @@ -0,0 +1,17 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; + +export function registerQStashMoveToTeam(qstash: Command): void { + qstash + .command("move-to-team") + .description("Move a QStash instance to a team") + .requiredOption("--qstash-id ", "QStash instance ID") + .requiredOption("--target-team-id ", "Target team ID") + .action(async (flags: { qstashId: string; targetTeamId: string }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "POST", "/v2/qstash/move-to-team", { qstash_id: flags.qstashId, target_team_id: flags.targetTeamId }); + printJSON(result); + }); +} diff --git a/src/commands/qstash/rotate-token.ts b/src/commands/qstash/rotate-token.ts new file mode 100644 index 0000000..884e3e5 --- /dev/null +++ b/src/commands/qstash/rotate-token.ts @@ -0,0 +1,17 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { QStashUser } from "../../types.js"; + +export function registerQStashRotateToken(qstash: Command): void { + qstash + .command("rotate-token") + .description("Reset the authentication token for a QStash instance") + .requiredOption("--qstash-id ", "QStash instance ID") + .action(async (flags: { qstashId: string }, command: Command) => { + const auth = resolveAuth(command); + const q = await request(auth, "POST", `/v2/qstash/rotate-token/${flags.qstashId}`); + printJSON(q); + }); +} diff --git a/src/commands/qstash/set-plan.ts b/src/commands/qstash/set-plan.ts new file mode 100644 index 0000000..418d387 --- /dev/null +++ b/src/commands/qstash/set-plan.ts @@ -0,0 +1,18 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import { QSTASH_PLANS } from "../../types.js"; + +export function registerQStashSetPlan(qstash: Command): void { + qstash + .command("set-plan") + .description(`Change the plan for a QStash instance. Plans: ${QSTASH_PLANS.join(", ")}`) + .requiredOption("--qstash-id ", "QStash instance ID") + .requiredOption("--plan ", `Target plan (${QSTASH_PLANS.join(", ")})`) + .action(async (flags: { qstashId: string; plan: string }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "POST", `/v2/qstash/set-plan/${flags.qstashId}`, { plan_name: flags.plan }); + printJSON(result); + }); +} diff --git a/src/commands/qstash/stats.ts b/src/commands/qstash/stats.ts new file mode 100644 index 0000000..8f85012 --- /dev/null +++ b/src/commands/qstash/stats.ts @@ -0,0 +1,19 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import { STATS_PERIODS } from "../../types.js"; + +export function registerQStashStats(qstash: Command): void { + qstash + .command("stats") + .description("Get usage statistics for a QStash instance") + .requiredOption("--qstash-id ", "QStash instance ID") + .option("--period ", `Time period. Available: ${STATS_PERIODS.join(", ")}`, "1h") + .action(async (flags: { qstashId: string; period?: string }, command: Command) => { + const auth = resolveAuth(command); + const qs = flags.period ? `?period=${encodeURIComponent(flags.period)}` : ""; + const stats = await request>(auth, "GET", `/v2/qstash/stats/${flags.qstashId}${qs}`); + printJSON(stats); + }); +} diff --git a/src/commands/qstash/update-budget.ts b/src/commands/qstash/update-budget.ts new file mode 100644 index 0000000..a9a1a10 --- /dev/null +++ b/src/commands/qstash/update-budget.ts @@ -0,0 +1,27 @@ +import { Command, InvalidArgumentError } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; + +function parseNonNegativeInt(name: string) { + return (v: string): number => { + const n = Number(v); + if (!Number.isInteger(n) || n < 0) { + throw new InvalidArgumentError(`--${name} must be a non-negative integer; got "${v}"`); + } + return n; + }; +} + +export function registerQStashUpdateBudget(qstash: Command): void { + qstash + .command("update-budget") + .description("Update the monthly spend budget for a QStash instance (in dollars, 0 = no limit)") + .requiredOption("--qstash-id ", "QStash instance ID") + .requiredOption("--budget ", "Monthly budget in dollars (0 = no limit)", parseNonNegativeInt("budget")) + .action(async (flags: { qstashId: string; budget: number }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "PATCH", `/v2/qstash/update-budget/${flags.qstashId}`, { budget: flags.budget }); + printJSON(result); + }); +} diff --git a/src/commands/redis/backup/create.ts b/src/commands/redis/backup/create.ts new file mode 100644 index 0000000..c60817d --- /dev/null +++ b/src/commands/redis/backup/create.ts @@ -0,0 +1,17 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../../auth.js"; +import { request } from "../../../client.js"; +import { printJSON } from "../../../output.js"; + +export function registerBackupCreate(backup: Command): void { + backup + .command("create") + .description("Create a backup of a Redis database") + .requiredOption("--db-id ", "Database ID") + .requiredOption("--name ", "Backup name") + .action(async (flags: { dbId: string; name: string }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "POST", `/v2/redis/create-backup/${flags.dbId}`, { name: flags.name }); + printJSON(result); + }); +} diff --git a/src/commands/redis/backup/delete.ts b/src/commands/redis/backup/delete.ts new file mode 100644 index 0000000..8722ba6 --- /dev/null +++ b/src/commands/redis/backup/delete.ts @@ -0,0 +1,22 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../../auth.js"; +import { request } from "../../../client.js"; +import { printJSON } from "../../../output.js"; + +export function registerBackupDelete(backup: Command): void { + backup + .command("delete") + .description("Delete a backup of a Redis database") + .requiredOption("--db-id ", "Database ID") + .requiredOption("--backup-id ", "Backup ID") + .option("--dry-run", "Preview the action without executing it") + .action(async (flags: { dbId: string; backupId: string; dryRun?: boolean }, command: Command) => { + if (flags.dryRun) { + printJSON({ action: "delete-backup", database_id: flags.dbId, backup_id: flags.backupId, dry_run: true }); + return; + } + const auth = resolveAuth(command); + await request(auth, "DELETE", `/v2/redis/delete-backup/${flags.dbId}/${flags.backupId}`); + printJSON({ deleted: true, backup_id: flags.backupId }); + }); +} diff --git a/src/commands/redis/backup/disable-daily.ts b/src/commands/redis/backup/disable-daily.ts new file mode 100644 index 0000000..dddcbad --- /dev/null +++ b/src/commands/redis/backup/disable-daily.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../../auth.js"; +import { request } from "../../../client.js"; +import { printJSON } from "../../../output.js"; + +export function registerDisableDaily(backup: Command): void { + backup + .command("disable-daily") + .description("Disable daily automatic backups for a Redis database") + .requiredOption("--db-id ", "Database ID") + .action(async (flags: { dbId: string }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "POST", `/v2/redis/disable-dailybackup/${flags.dbId}`); + printJSON(result); + }); +} diff --git a/src/commands/redis/backup/enable-daily.ts b/src/commands/redis/backup/enable-daily.ts new file mode 100644 index 0000000..429cdc6 --- /dev/null +++ b/src/commands/redis/backup/enable-daily.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../../auth.js"; +import { request } from "../../../client.js"; +import { printJSON } from "../../../output.js"; + +export function registerEnableDaily(backup: Command): void { + backup + .command("enable-daily") + .description("Enable daily automatic backups for a Redis database") + .requiredOption("--db-id ", "Database ID") + .action(async (flags: { dbId: string }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "POST", `/v2/redis/enable-dailybackup/${flags.dbId}`); + printJSON(result); + }); +} diff --git a/src/commands/redis/backup/index.ts b/src/commands/redis/backup/index.ts new file mode 100644 index 0000000..c74175c --- /dev/null +++ b/src/commands/redis/backup/index.ts @@ -0,0 +1,18 @@ +import { Command } from "commander"; +import { registerBackupList } from "./list.js"; +import { registerBackupCreate } from "./create.js"; +import { registerBackupDelete } from "./delete.js"; +import { registerBackupRestore } from "./restore.js"; +import { registerEnableDaily } from "./enable-daily.js"; +import { registerDisableDaily } from "./disable-daily.js"; + +export function registerBackup(redis: Command): void { + const backup = redis.command("backup").description("Manage Redis database backups"); + + registerBackupList(backup); + registerBackupCreate(backup); + registerBackupDelete(backup); + registerBackupRestore(backup); + registerEnableDaily(backup); + registerDisableDaily(backup); +} diff --git a/src/commands/redis/backup/list.ts b/src/commands/redis/backup/list.ts new file mode 100644 index 0000000..13781e8 --- /dev/null +++ b/src/commands/redis/backup/list.ts @@ -0,0 +1,17 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../../auth.js"; +import { request } from "../../../client.js"; +import { printJSON } from "../../../output.js"; +import type { Backup } from "../../../types.js"; + +export function registerBackupList(backup: Command): void { + backup + .command("list") + .description("List all backups for a Redis database") + .requiredOption("--db-id ", "Database ID") + .action(async (flags: { dbId: string }, command: Command) => { + const auth = resolveAuth(command); + const backups = await request(auth, "GET", `/v2/redis/list-backup/${flags.dbId}`); + printJSON(backups); + }); +} diff --git a/src/commands/redis/backup/restore.ts b/src/commands/redis/backup/restore.ts new file mode 100644 index 0000000..eb7cdb2 --- /dev/null +++ b/src/commands/redis/backup/restore.ts @@ -0,0 +1,17 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../../auth.js"; +import { request } from "../../../client.js"; +import { printJSON } from "../../../output.js"; + +export function registerBackupRestore(backup: Command): void { + backup + .command("restore") + .description("Restore a Redis database from a backup") + .requiredOption("--db-id ", "Database ID") + .requiredOption("--backup-id ", "ID of the backup to restore from") + .action(async (flags: { dbId: string; backupId: string }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "POST", `/v2/redis/restore-backup/${flags.dbId}`, { backup_id: flags.backupId }); + printJSON(result); + }); +} diff --git a/src/commands/redis/change-plan.ts b/src/commands/redis/change-plan.ts new file mode 100644 index 0000000..db21115 --- /dev/null +++ b/src/commands/redis/change-plan.ts @@ -0,0 +1,17 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; + +export function registerChangePlan(redis: Command): void { + redis + .command("change-plan") + .description("Change the pricing plan of a Redis database. Plans: free, payg, pro, paid") + .requiredOption("--db-id ", "Database ID") + .requiredOption("--plan ", "Plan type (free, payg, pro, paid)") + .action(async (flags: { dbId: string; plan: string }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "POST", `/v2/redis/change-plan/${flags.dbId}`, { plan: flags.plan }); + printJSON(result); + }); +} diff --git a/src/commands/redis/create.ts b/src/commands/redis/create.ts index 51e1301..d2b4698 100644 --- a/src/commands/redis/create.ts +++ b/src/commands/redis/create.ts @@ -1,113 +1,25 @@ -import { cliffy } from "../../deps.ts"; -import { Command } from "../../util/command.ts"; -import { parseAuth } from "../../util/auth.ts"; -import { http } from "../../util/http.ts"; -import type { Database } from "./types.ts"; -export enum Region { - "us-west-1" = "us-west-1", - "us-west-2" = "us-west-2", - "us-east-1" = "us-east-1", - "us-central1" = "us-central1", - - "eu-west-1" = "eu-west-1", - "eu-central-1" = "eu-central-1", - - "ap-south-1" = "ap-south-1", - "ap-southeast-1" = "ap-southeast-1", - "ap-southeast-2" = "ap-southeast-2", - - "ap-northeast-1" = "ap-northeast-1", - - "sa-east-1" = "sa-east-1", -} - -export const createCmd = new Command() - .name("create") - .description("Create a new redis database") - .option("-n --name ", "Name of the database", { required: true }) - .type("region", new cliffy.EnumType(Region)) - .option("-r --region ", "Primary region of the database", { - required: true, - }) - .option( - "--read-regions [regions...:region]", - "Read regions of the database", - { - required: false, - }, - ) - .example("global", "upstash redis create --name mydb --region=us-east-1") - .example( - "with replication", - "upstash redis create --name mydb --region=us-east-1 --read-regions=us-west-1 eu-west-1", - ) - .action(async (options): Promise => { - const authorization = await parseAuth(options); - - // if (!options.name) { - // if (options.ci) { - // throw new cliffy.ValidationError("name"); - // } - // options.name = await cliffy.Input.prompt("Set a name for your database"); - // } - - // if (!options.region) { - // if (options.ci) { - // throw new cliffy.ValidationError("region"); - // } - // options.region = (await cliffy.Select.prompt({ - // message: "Select a region", - // options: Object.entries(Region).map(([name, value]) => ({ - // name, - // value, - // })), - // })) as Region; - // } - const body: Record< - string, - string | number | boolean | undefined | string[] - > = { - name: options.name, - region: "global", - primary_region: options.region, - read_regions: options.readRegions, - }; - - if (body.read_regions !== undefined && !Array.isArray(body.read_regions)) { - throw new Error("--read_regions should be an array"); - } - - const db = await http.request({ - method: "POST", - authorization, - path: ["v2", "redis", "database"], - body, +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import { REGIONS } from "../../types.js"; +import type { Database } from "../../types.js"; + +export function registerCreate(redis: Command): void { + redis + .command("create") + .description("Create a new Redis database") + .requiredOption("--name ", "Database name") + .requiredOption("--region ", `Primary region. Available: ${REGIONS.join(", ")}`) + .option("--read-regions ", "Read replica regions (space-separated)") + .action(async (flags: { name: string; region: string; readRegions?: string[] }, command: Command) => { + const auth = resolveAuth(command); + const db = await request(auth, "POST", "/v2/redis/database", { + database_name: flags.name, + region: "global", + primary_region: flags.region, + read_regions: flags.readRegions, + }); + printJSON(db); }); - if (options.json) { - console.log(JSON.stringify(db, null, 2)); - return; - } - console.log(cliffy.colors.brightGreen("Database has been created")); - console.log(); - console.log( - cliffy.Table.from( - Object.entries(db).map(([k, v]) => [k.toString(), v.toString()]), - ).toString(), - ); - console.log(); - console.log(); - - console.log( - "You can visit your database details page: " + - cliffy.colors.yellow( - "https://console.upstash.com/redis/" + db.database_id, - ), - ); - console.log(); - console.log( - "Connect to your database with redis-cli: " + - cliffy.colors.yellow( - `redis-cli --tls -u redis://${db.password}@${db.endpoint}:${db.port}`, - ), - ); - }); +} diff --git a/src/commands/redis/delete.ts b/src/commands/redis/delete.ts index a066c7b..40ec96f 100644 --- a/src/commands/redis/delete.ts +++ b/src/commands/redis/delete.ts @@ -1,46 +1,21 @@ -import { cliffy } from "../../deps.ts"; -import { Command } from "../../util/command.ts"; -import { parseAuth } from "../../util/auth.ts"; -import { http } from "../../util/http.ts"; -// import type { Database } from "./types.ts"; -export const deleteCmd = new Command() - .name("delete") - .description("delete a redis database") - .option("--id=", "The uuid of the cluster", { required: true }) - .example( - "Delete", - `upstash redis delete --id=f860e7e2-27b8-4166-90d5-ea41e90b4809`, - ) - .action(async (options): Promise => { - const authorization = await parseAuth(options); +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; - // if (!options.id) { - // if (options.ci) { - // throw new cliffy.ValidationError("id"); - // } - // const dbs = await http.request({ - // method: "GET", - // authorization, - // path: ["v2", "redis", "databases"], - // }); - // options.id = await cliffy.Select.prompt({ - // message: "Select a database to delete", - // options: dbs.map(({ database_name, database_id }) => ({ - // name: database_name, - // value: database_id, - // })), - // }); - // } - - await http.request({ - method: "DELETE", - authorization, - path: ["v2", "redis", "database", options.id!], +export function registerDelete(redis: Command): void { + redis + .command("delete") + .description("Delete a Redis database") + .requiredOption("--db-id ", "Database ID") + .option("--dry-run", "Preview the action without executing it") + .action(async (flags: { dbId: string; dryRun?: boolean }, command: Command) => { + if (flags.dryRun) { + printJSON({ action: "delete", database_id: flags.dbId, dry_run: true }); + return; + } + const auth = resolveAuth(command); + await request(auth, "DELETE", `/v2/redis/database/${flags.dbId}`); + printJSON({ deleted: true, database_id: flags.dbId }); }); - if (options.json) { - console.log(JSON.stringify({ ok: true }, null, 2)); - return; - } - console.log(cliffy.colors.brightGreen("Database has been deleted")); - console.log(); - }); +} diff --git a/src/commands/redis/disable-autoupgrade.ts b/src/commands/redis/disable-autoupgrade.ts new file mode 100644 index 0000000..85a6971 --- /dev/null +++ b/src/commands/redis/disable-autoupgrade.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; + +export function registerDisableAutoupgrade(redis: Command): void { + redis + .command("disable-autoupgrade") + .description("Disable automatic version upgrades for a Redis database") + .requiredOption("--db-id ", "Database ID") + .action(async (flags: { dbId: string }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "POST", `/v2/redis/disable-autoupgrade/${flags.dbId}`); + printJSON(result); + }); +} diff --git a/src/commands/redis/disable-eviction.ts b/src/commands/redis/disable-eviction.ts new file mode 100644 index 0000000..ae27dab --- /dev/null +++ b/src/commands/redis/disable-eviction.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; + +export function registerDisableEviction(redis: Command): void { + redis + .command("disable-eviction") + .description("Disable key eviction for a Redis database") + .requiredOption("--db-id ", "Database ID") + .action(async (flags: { dbId: string }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "POST", `/v2/redis/disable-eviction/${flags.dbId}`); + printJSON(result); + }); +} diff --git a/src/commands/redis/enable-autoupgrade.ts b/src/commands/redis/enable-autoupgrade.ts new file mode 100644 index 0000000..6a2f950 --- /dev/null +++ b/src/commands/redis/enable-autoupgrade.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; + +export function registerEnableAutoupgrade(redis: Command): void { + redis + .command("enable-autoupgrade") + .description("Enable automatic version upgrades for a Redis database") + .requiredOption("--db-id ", "Database ID") + .action(async (flags: { dbId: string }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "POST", `/v2/redis/enable-autoupgrade/${flags.dbId}`); + printJSON(result); + }); +} diff --git a/src/commands/redis/enable-eviction.ts b/src/commands/redis/enable-eviction.ts new file mode 100644 index 0000000..6aa6b4a --- /dev/null +++ b/src/commands/redis/enable-eviction.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; + +export function registerEnableEviction(redis: Command): void { + redis + .command("enable-eviction") + .description("Enable key eviction for a Redis database") + .requiredOption("--db-id ", "Database ID") + .action(async (flags: { dbId: string }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "POST", `/v2/redis/enable-eviction/${flags.dbId}`); + printJSON(result); + }); +} diff --git a/src/commands/redis/enable-tls.ts b/src/commands/redis/enable-tls.ts new file mode 100644 index 0000000..baf3d5c --- /dev/null +++ b/src/commands/redis/enable-tls.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; + +export function registerEnableTls(redis: Command): void { + redis + .command("enable-tls") + .description("Enable TLS for a Redis database") + .requiredOption("--db-id ", "Database ID") + .action(async (flags: { dbId: string }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "POST", `/v2/redis/enable-tls/${flags.dbId}`); + printJSON(result); + }); +} diff --git a/src/commands/redis/enable_multizone_replication.ts b/src/commands/redis/enable_multizone_replication.ts deleted file mode 100644 index ab64473..0000000 --- a/src/commands/redis/enable_multizone_replication.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { cliffy } from "../../deps.ts"; -import { Command } from "../../util/command.ts"; -import { parseAuth } from "../../util/auth.ts"; -import { http } from "../../util/http.ts"; - -export const enableMultizoneReplicationCmd = new Command() - .name("enable-multizone-replication") - .description("Enable multizone replication for a redis database") - .option("--id=", "The id of your database", { required: true }) - .example( - "Enable", - `upstash redis enable-multizone-replication f860e7e2-27b8-4166-90d5-ea41e90b4809`, - ) - .action(async (options): Promise => { - const authorization = await parseAuth(options); - - // if (!options.id) { - // if (options.ci) { - // throw new cliffy.ValidationError("id"); - // } - // const dbs = await http.request< - // { database_name: string; database_id: string }[] - // >({ - // method: "GET", - // authorization, - // path: ["v2", "redis", "databases"], - // }); - // options.id = await cliffy.Select.prompt({ - // message: "Select a database to rename", - // options: dbs.map(({ database_name, database_id }) => ({ - // name: database_name, - // value: database_id, - // })), - // }); - // } - - const db = await http.request({ - method: "POST", - authorization, - path: ["v2", "redis", "enable-multizone", options.id!], - }); - if (options.json) { - console.log(JSON.stringify(db, null, 2)); - return; - } - console.log( - cliffy.colors.brightGreen("Multizone replication has been enabled"), - ); - }); diff --git a/src/commands/redis/exec.ts b/src/commands/redis/exec.ts new file mode 100644 index 0000000..8d03f67 --- /dev/null +++ b/src/commands/redis/exec.ts @@ -0,0 +1,71 @@ +import { Command } from "commander"; +import { printJSON } from "../../output.js"; + +interface Flags { + dbUrl?: string; + dbToken?: string; + json?: string; +} + +export function registerExec(redis: Command): void { + redis + .command("exec") + .description( + "Execute a Redis command against a database via the REST API. " + + "Pass args as positional tokens (shell handles quoting) or via --json." + ) + .option("--db-url ", "Database REST URL (overrides UPSTASH_REDIS_REST_URL)") + .option("--db-token ", "Database REST token (overrides UPSTASH_REDIS_REST_TOKEN)") + .option("--json ", 'Redis command args as a JSON array (e.g. \'["SET","key","val"]\')') + .argument("[args...]", "Command tokens (e.g. SET key value)") + .action(async (positional: string[], flags: Flags) => { + const dbUrl = flags.dbUrl ?? process.env.UPSTASH_REDIS_REST_URL; + const dbToken = flags.dbToken ?? process.env.UPSTASH_REDIS_REST_TOKEN; + + if (!dbUrl || !dbToken) { + throw new Error( + "Database credentials required. Provide --db-url and --db-token or set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN environment variables." + ); + } + + if (positional.length > 0 && flags.json) { + throw new Error("Provide either positional args or --json, not both."); + } + + const args = flags.json ? parseJsonArgs(flags.json) : positional; + if (args.length === 0) { + throw new Error("Empty command. Pass positional tokens or --json."); + } + + const url = dbUrl.replace(/\/$/, ""); + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${dbToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(args), + }); + + const data = await response.json() as { result?: unknown; error?: string }; + + if (data.error) { + throw new Error(data.error); + } + + printJSON({ result: data.result }); + }); +} + +function parseJsonArgs(input: string): string[] { + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch { + throw new Error(`--json must be valid JSON; got: ${input}`); + } + if (!Array.isArray(parsed) || !parsed.every((x) => typeof x === "string" || typeof x === "number" || typeof x === "boolean")) { + throw new Error("--json must be a JSON array of strings/numbers/booleans."); + } + return parsed.map((x) => String(x)); +} diff --git a/src/commands/redis/get.ts b/src/commands/redis/get.ts index f1ce45c..ae0b386 100644 --- a/src/commands/redis/get.ts +++ b/src/commands/redis/get.ts @@ -1,50 +1,19 @@ -import { cliffy } from "../../deps.ts"; -import { Command } from "../../util/command.ts"; -import { parseAuth } from "../../util/auth.ts"; -import { http } from "../../util/http.ts"; -import type { Database } from "./types.ts"; +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { Database } from "../../types.js"; -export const getCmd = new Command() - .name("get") - .description("get a redis database") - .option("--id=", "The id of your database", { required: true }) - .example("Get", `upstash redis get --id=f860e7e2-27b8-4166-90d5-ea41e90b4809`) - .action(async (options): Promise => { - const authorization = await parseAuth(options); - - // if (!options.id) { - // if (options.ci) { - // throw new cliffy.ValidationError("id"); - // } - // const dbs = await http.request< - // { database_name: string; database_id: string }[] - // >({ - // method: "GET", - // authorization, - // path: ["v2", "redis", "databases"], - // }); - // options.id = await cliffy.Select.prompt({ - // message: "Select a database to delete", - // options: dbs.map(({ database_name, database_id }) => ({ - // name: database_name, - // value: database_id, - // })), - // }); - // } - - const db = await http.request({ - method: "GET", - authorization, - path: ["v2", "redis", "database", options.id!], +export function registerGet(redis: Command): void { + redis + .command("get") + .description("Get details of a Redis database") + .requiredOption("--db-id ", "Database ID") + .option("--hide-credentials", "Omit password from output") + .action(async (flags: { dbId: string; hideCredentials?: boolean }, command: Command) => { + const auth = resolveAuth(command); + const qs = flags.hideCredentials ? "?credentials=hide" : ""; + const db = await request(auth, "GET", `/v2/redis/database/${flags.dbId}${qs}`); + printJSON(db); }); - if (options.json) { - console.log(JSON.stringify(db, null, 2)); - return; - } - - console.log( - cliffy.Table.from( - Object.entries(db).map(([k, v]) => [k.toString(), v.toString()]), - ).toString(), - ); - }); +} diff --git a/src/commands/redis/index.ts b/src/commands/redis/index.ts new file mode 100644 index 0000000..c339b4a --- /dev/null +++ b/src/commands/redis/index.ts @@ -0,0 +1,42 @@ +import { Command } from "commander"; +import { registerCreate } from "./create.js"; +import { registerList } from "./list.js"; +import { registerGet } from "./get.js"; +import { registerDelete } from "./delete.js"; +import { registerRename } from "./rename.js"; +import { registerResetPassword } from "./reset-password.js"; +import { registerStats } from "./stats.js"; +import { registerEnableTls } from "./enable-tls.js"; +import { registerEnableEviction } from "./enable-eviction.js"; +import { registerDisableEviction } from "./disable-eviction.js"; +import { registerEnableAutoupgrade } from "./enable-autoupgrade.js"; +import { registerDisableAutoupgrade } from "./disable-autoupgrade.js"; +import { registerChangePlan } from "./change-plan.js"; +import { registerUpdateBudget } from "./update-budget.js"; +import { registerUpdateRegions } from "./update-regions.js"; +import { registerMoveToTeam } from "./move-to-team.js"; +import { registerBackup } from "./backup/index.js"; +import { registerExec } from "./exec.js"; + +export function registerRedis(program: Command): void { + const redis = program.command("redis").description("Manage Redis databases"); + + registerCreate(redis); + registerList(redis); + registerGet(redis); + registerDelete(redis); + registerRename(redis); + registerResetPassword(redis); + registerStats(redis); + registerEnableTls(redis); + registerEnableEviction(redis); + registerDisableEviction(redis); + registerEnableAutoupgrade(redis); + registerDisableAutoupgrade(redis); + registerChangePlan(redis); + registerUpdateBudget(redis); + registerUpdateRegions(redis); + registerMoveToTeam(redis); + registerBackup(redis); + registerExec(redis); +} diff --git a/src/commands/redis/list.ts b/src/commands/redis/list.ts index 5b27d99..c7615ea 100644 --- a/src/commands/redis/list.ts +++ b/src/commands/redis/list.ts @@ -1,47 +1,16 @@ -import { cliffy } from "../../deps.ts"; -import { Command } from "../../util/command.ts"; -import { parseAuth } from "../../util/auth.ts"; -import { http } from "../../util/http.ts"; -import type { Database } from "./types.ts"; -export const listCmd = new Command() - .name("list") - .description("list all your databases") - .option("-e, --expanded ", "Show expanded information") - .example("List", "upstash redis list") - .action(async (options): Promise => { - const authorization = await parseAuth(options); - const dbs = await http.request({ - method: "GET", - authorization, - path: ["v2", "redis", "databases"], - }); - if (options.json) { - console.log(JSON.stringify(dbs, null, 2)); - return; - } - // if (!options.expanded) { - // console.log( - // cliffy.Table.from( - // dbs.map((db) => [db.database_name, db.database_id]), - // ).toString(), - // ); - // return; - // } +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { Database } from "../../types.js"; - dbs.forEach((db) => { - console.log(); - console.log(); - console.log( - cliffy.colors.underline(cliffy.colors.brightGreen(db.database_name)), - ); - console.log(); - console.log( - cliffy.Table.from( - Object.entries(db).map(([k, v]) => [k.toString(), v.toString()]), - ).toString(), - ); - console.log(); +export function registerList(redis: Command): void { + redis + .command("list") + .description("List all Redis databases") + .action(async (flags: Record, command: Command) => { + const auth = resolveAuth(command); + const dbs = await request(auth, "GET", "/v2/redis/databases"); + printJSON(dbs); }); - console.log(); - console.log(); - }); +} diff --git a/src/commands/redis/mod.ts b/src/commands/redis/mod.ts deleted file mode 100644 index 81d8415..0000000 --- a/src/commands/redis/mod.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Command } from "../../util/command.ts"; -import { createCmd } from "./create.ts"; -import { listCmd } from "./list.ts"; -import { deleteCmd } from "./delete.ts"; -import { getCmd } from "./get.ts"; -import { statsCmd } from "./stats.ts"; -import { resetPasswordCmd } from "./reset_password.ts"; -import { renameCmd } from "./rename.ts"; -import { enableMultizoneReplicationCmd } from "./enable_multizone_replication.ts"; -import { moveToTeamCmd } from "./move_to_team.ts"; -const redisCmd = new Command() - .description("Manage redis database instances") - .globalOption("--json=[boolean:boolean]", "Return raw json response") - .command("create", createCmd) - .command("list", listCmd) - .command("get", getCmd) - .command("delete", deleteCmd) - .command("stats", statsCmd) - .command("rename", renameCmd) - .command("reset-password", resetPasswordCmd) - .command("enable-multizone-replication", enableMultizoneReplicationCmd) - .command("move-to-team", moveToTeamCmd); - -redisCmd.reset().action(() => { - redisCmd.showHelp(); -}); - -export { redisCmd }; diff --git a/src/commands/redis/move-to-team.ts b/src/commands/redis/move-to-team.ts new file mode 100644 index 0000000..5f08cbf --- /dev/null +++ b/src/commands/redis/move-to-team.ts @@ -0,0 +1,17 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; + +export function registerMoveToTeam(redis: Command): void { + redis + .command("move-to-team") + .description("Move a Redis database to a team account") + .requiredOption("--db-id ", "Database ID") + .requiredOption("--team-id ", "Target team ID") + .action(async (flags: { dbId: string; teamId: string }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "POST", `/v2/redis/move-to-team`, { database_id: flags.dbId, team_id: flags.teamId }); + printJSON(result); + }); +} diff --git a/src/commands/redis/move_to_team.ts b/src/commands/redis/move_to_team.ts deleted file mode 100644 index 0f7c457..0000000 --- a/src/commands/redis/move_to_team.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { cliffy } from "../../deps.ts"; -import { Command } from "../../util/command.ts"; -import { parseAuth } from "../../util/auth.ts"; -import { http } from "../../util/http.ts"; - -export const moveToTeamCmd = new Command() - .name("move-to-team") - .description("Move a redis database to another team") - .option("--id=", "The id of your database", { required: true }) - .option("--team-id=", "The id of a team", { required: true }) - .example("Move", `upstash redis move-to-team`) - .action(async (options): Promise => { - const authorization = await parseAuth(options); - - // if (!options.id) { - // if (options.ci) { - // throw new cliffy.ValidationError("id"); - // } - // const dbs = await http.request< - // { database_name: string; database_id: string }[] - // >({ - // method: "GET", - // authorization, - // path: ["v2", "redis", "databases"], - // }); - // options.id = await cliffy.Select.prompt({ - // message: "Select a database to move", - // options: dbs.map(({ database_name, database_id }) => ({ - // name: database_name, - // value: database_id, - // })), - // }); - // } - // if (!options.teamId) { - // const teams = await http.request< - // { team_name: string; team_id: string }[] - // >({ - // method: "GET", - // authorization, - // path: ["v2", "teams"], - // }); - // options.teamId = await cliffy.Select.prompt({ - // message: "Select the new team", - // options: teams.map(({ team_name, team_id }) => ({ - // name: team_name, - // value: team_id, - // })), - // }); - // } - - const db = await http.request({ - method: "POST", - authorization, - path: ["v2", "redis", "move-to-team"], - body: { - database_id: options.id, - team_id: options.teamId, - }, - }); - if (options.json) { - console.log(JSON.stringify(db, null, 2)); - return; - } - console.log(cliffy.colors.brightGreen("Database has been moved")); - }); diff --git a/src/commands/redis/rename.ts b/src/commands/redis/rename.ts index 6a31a51..ad146af 100644 --- a/src/commands/redis/rename.ts +++ b/src/commands/redis/rename.ts @@ -1,58 +1,18 @@ -import { cliffy } from "../../deps.ts"; -import { Command } from "../../util/command.ts"; -import { parseAuth } from "../../util/auth.ts"; -import { http } from "../../util/http.ts"; -export const renameCmd = new Command() - .name("rename") - .description("Rename a redis database") - .option("--id=", "The id of your database", { required: true }) - .option("--name=", "Choose a new name", { required: true }) - .example( - "Rename", - `upstash redis rename f860e7e2-27b8-4166-90d5-ea41e90b4809`, - ) - .action(async (options): Promise => { - const authorization = await parseAuth(options); +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { Database } from "../../types.js"; - // if (!options.id) { - // if (options.ci) { - // throw new cliffy.ValidationError("id"); - // } - // const dbs = await http.request< - // { database_name: string; database_id: string }[] - // >({ - // method: "GET", - // authorization, - // path: ["v2", "redis", "databases"], - // }); - // options.id = await cliffy.Select.prompt({ - // message: "Select a database to rename", - // options: dbs.map(({ database_name, database_id }) => ({ - // name: database_name, - // value: database_id, - // })), - // }); - // } - - // if (!options.name) { - // if (options.ci) { - // throw new cliffy.ValidationError("id"); - // } - // options.name = await cliffy.Input.prompt({ - // message: "Choose a new name", - // }); - // } - const db = await http.request({ - method: "POST", - authorization, - path: ["v2", "redis", "rename", options.id!], - body: { - name: options.name, - }, +export function registerRename(redis: Command): void { + redis + .command("rename") + .description("Rename a Redis database") + .requiredOption("--db-id ", "Database ID") + .requiredOption("--name ", "New database name") + .action(async (flags: { dbId: string; name: string }, command: Command) => { + const auth = resolveAuth(command); + const db = await request(auth, "POST", `/v2/redis/rename/${flags.dbId}`, { name: flags.name }); + printJSON(db); }); - if (options.json) { - console.log(JSON.stringify(db, null, 2)); - return; - } - console.log(cliffy.colors.brightGreen("Database has been renamed")); - }); +} diff --git a/src/commands/redis/reset-password.ts b/src/commands/redis/reset-password.ts new file mode 100644 index 0000000..45049d1 --- /dev/null +++ b/src/commands/redis/reset-password.ts @@ -0,0 +1,17 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { Database } from "../../types.js"; + +export function registerResetPassword(redis: Command): void { + redis + .command("reset-password") + .description("Reset the password of a Redis database") + .requiredOption("--db-id ", "Database ID") + .action(async (flags: { dbId: string }, command: Command) => { + const auth = resolveAuth(command); + const db = await request(auth, "POST", `/v2/redis/reset-password/${flags.dbId}`); + printJSON(db); + }); +} diff --git a/src/commands/redis/reset_password.ts b/src/commands/redis/reset_password.ts deleted file mode 100644 index b50a15e..0000000 --- a/src/commands/redis/reset_password.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { cliffy } from "../../deps.ts"; -import { Command } from "../../util/command.ts"; -import { parseAuth } from "../../util/auth.ts"; -import { http } from "../../util/http.ts"; - -import type { Database } from "./types.ts"; - -export const resetPasswordCmd = new Command() - .name("reset-password") - .description("reset the password of a redis database") - .option("--id=", "The id of your database", { required: true }) - .example( - "Reset", - `upstash redis reset-password --id=f860e7e2-27b8-4166-90d5-ea41e90b4809`, - ) - .action(async (options): Promise => { - const authorization = await parseAuth(options); - - // if (!options.id) { - // if (options.ci) { - // throw new cliffy.ValidationError("id"); - // } - // const dbs = await http.request< - // { database_name: string; database_id: string }[] - // >({ - // method: "GET", - // authorization, - // path: ["v2", "redis", "databases"], - // }); - // options.id = await cliffy.Select.prompt({ - // message: "Select a database to delete", - // options: dbs.map(({ database_name, database_id }) => ({ - // name: database_name, - // value: database_id, - // })), - // }); - // } - - const db = await http.request({ - method: "POST", - authorization, - path: ["v2", "redis", "reset-password", options.id!], - }); - if (options.json) { - console.log(JSON.stringify(db, null, 2)); - return; - } - console.log(cliffy.colors.brightGreen("Database password has been reset")); - console.log(); - console.log( - cliffy.Table.from( - Object.entries(db).map(([k, v]) => [k.toString(), v.toString()]), - ).toString(), - ); - }); diff --git a/src/commands/redis/stats.ts b/src/commands/redis/stats.ts index 4006ecb..d8c6647 100644 --- a/src/commands/redis/stats.ts +++ b/src/commands/redis/stats.ts @@ -1,41 +1,16 @@ -// import { cliffy } from "../../deps.ts"; -import { Command } from "../../util/command.ts"; -import { parseAuth } from "../../util/auth.ts"; -import { http } from "../../util/http.ts"; -import type { Database } from "./types.ts"; -export const statsCmd = new Command() - .name("stats") - .description("Returns detailed information about the databse usage") - .option("--id=", "The id of your database", { required: true }) - .example( - "Get", - `upstash redis stats --id=f860e7e2-27b8-4166-90d5-ea41e90b4809`, - ) - .action(async (options): Promise => { - const authorization = await parseAuth(options); +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; - // if (!options.id) { - // const dbs = await http.request< - // { database_name: string; database_id: string }[] - // >({ - // method: "GET", - // authorization, - // path: ["v2", "redis", "databases"], - // }); - // options.id = await cliffy.Select.prompt({ - // message: "Select a database to delete", - // options: dbs.map(({ database_name, database_id }) => ({ - // name: database_name, - // value: database_id, - // })), - // }); - // } - - const db = await http.request({ - method: "GET", - authorization, - path: ["v2", "redis", "stats", options.id!], +export function registerStats(redis: Command): void { + redis + .command("stats") + .description("Get usage statistics for a Redis database") + .requiredOption("--db-id ", "Database ID") + .action(async (flags: { dbId: string }, command: Command) => { + const auth = resolveAuth(command); + const stats = await request>(auth, "GET", `/v2/redis/stats/${flags.dbId}`); + printJSON(stats); }); - console.log(JSON.stringify(db, null, 2)); - return; - }); +} diff --git a/src/commands/redis/types.ts b/src/commands/redis/types.ts deleted file mode 100644 index ef05358..0000000 --- a/src/commands/redis/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type Database = { - database_id: string; - database_name: string; - password: string; - endpoint: string; - port: number; - - region: string; -}; diff --git a/src/commands/redis/update-budget.ts b/src/commands/redis/update-budget.ts new file mode 100644 index 0000000..b10e7a6 --- /dev/null +++ b/src/commands/redis/update-budget.ts @@ -0,0 +1,27 @@ +import { Command, InvalidArgumentError } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; + +function parseNonNegativeInt(name: string) { + return (v: string): number => { + const n = Number(v); + if (!Number.isInteger(n) || n < 0) { + throw new InvalidArgumentError(`--${name} must be a non-negative integer; got "${v}"`); + } + return n; + }; +} + +export function registerUpdateBudget(redis: Command): void { + redis + .command("update-budget") + .description("Update the monthly spend budget for a Redis database (in cents)") + .requiredOption("--db-id ", "Database ID") + .requiredOption("--budget ", "Monthly budget in cents", parseNonNegativeInt("budget")) + .action(async (flags: { dbId: string; budget: number }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "PATCH", `/v2/redis/update-budget/${flags.dbId}`, { budget: flags.budget }); + printJSON(result); + }); +} diff --git a/src/commands/redis/update-regions.ts b/src/commands/redis/update-regions.ts new file mode 100644 index 0000000..3e4a902 --- /dev/null +++ b/src/commands/redis/update-regions.ts @@ -0,0 +1,18 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import { REGIONS } from "../../types.js"; + +export function registerUpdateRegions(redis: Command): void { + redis + .command("update-regions") + .description("Update read replica regions for a Redis database") + .requiredOption("--db-id ", "Database ID") + .requiredOption("--read-regions ", `Read replica regions. Available: ${REGIONS.join(", ")}`) + .action(async (flags: { dbId: string; readRegions: string[] }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "POST", `/v2/redis/update-regions/${flags.dbId}`, { read_regions: flags.readRegions }); + printJSON(result); + }); +} diff --git a/src/commands/search/create.ts b/src/commands/search/create.ts new file mode 100644 index 0000000..ee3104b --- /dev/null +++ b/src/commands/search/create.ts @@ -0,0 +1,20 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import { SEARCH_REGIONS, SEARCH_PLANS } from "../../types.js"; +import type { SearchIndex } from "../../types.js"; + +export function registerSearchCreate(search: Command): void { + search + .command("create") + .description("Create a new search index") + .requiredOption("--name ", "Index name") + .requiredOption("--region ", `Region. Available: ${SEARCH_REGIONS.join(", ")}`) + .requiredOption("--type ", `Plan type. Available: ${SEARCH_PLANS.join(", ")}`) + .action(async (flags: { name: string; region: string; type: string }, command: Command) => { + const auth = resolveAuth(command); + const idx = await request(auth, "POST", "/v2/search", { name: flags.name, region: flags.region, type: flags.type }); + printJSON(idx); + }); +} diff --git a/src/commands/search/delete.ts b/src/commands/search/delete.ts new file mode 100644 index 0000000..ba2dc6c --- /dev/null +++ b/src/commands/search/delete.ts @@ -0,0 +1,21 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; + +export function registerSearchDelete(search: Command): void { + search + .command("delete") + .description("Delete a search index") + .requiredOption("--index-id ", "Search index ID") + .option("--dry-run", "Preview the action without executing it") + .action(async (flags: { indexId: string; dryRun?: boolean }, command: Command) => { + if (flags.dryRun) { + printJSON({ action: "delete", index_id: flags.indexId, dry_run: true }); + return; + } + const auth = resolveAuth(command); + await request(auth, "DELETE", `/v2/search/${flags.indexId}`); + printJSON({ deleted: true, index_id: flags.indexId }); + }); +} diff --git a/src/commands/search/get.ts b/src/commands/search/get.ts new file mode 100644 index 0000000..5da3ccd --- /dev/null +++ b/src/commands/search/get.ts @@ -0,0 +1,17 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { SearchIndex } from "../../types.js"; + +export function registerSearchGet(search: Command): void { + search + .command("get") + .description("Get details of a search index") + .requiredOption("--index-id ", "Search index ID") + .action(async (flags: { indexId: string }, command: Command) => { + const auth = resolveAuth(command); + const idx = await request(auth, "GET", `/v2/search/${flags.indexId}`); + printJSON(idx); + }); +} diff --git a/src/commands/search/index.ts b/src/commands/search/index.ts new file mode 100644 index 0000000..4704e4b --- /dev/null +++ b/src/commands/search/index.ts @@ -0,0 +1,22 @@ +import { Command } from "commander"; +import { registerSearchList } from "./list.js"; +import { registerSearchCreate } from "./create.js"; +import { registerSearchGet } from "./get.js"; +import { registerSearchDelete } from "./delete.js"; +import { registerSearchRename } from "./rename.js"; +import { registerSearchResetPassword } from "./reset-password.js"; +import { registerSearchTransfer } from "./transfer.js"; +import { registerSearchStats } from "./stats.js"; + +export function registerSearch(program: Command): void { + const search = program.command("search").description("Manage Search indexes"); + + registerSearchList(search); + registerSearchCreate(search); + registerSearchGet(search); + registerSearchDelete(search); + registerSearchRename(search); + registerSearchResetPassword(search); + registerSearchTransfer(search); + registerSearchStats(search); +} diff --git a/src/commands/search/list.ts b/src/commands/search/list.ts new file mode 100644 index 0000000..61f8039 --- /dev/null +++ b/src/commands/search/list.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { SearchIndex } from "../../types.js"; + +export function registerSearchList(search: Command): void { + search + .command("list") + .description("List all search indexes") + .action(async (flags: Record, command: Command) => { + const auth = resolveAuth(command); + const indexes = await request(auth, "GET", "/v2/search"); + printJSON(indexes); + }); +} diff --git a/src/commands/search/rename.ts b/src/commands/search/rename.ts new file mode 100644 index 0000000..bcf50ed --- /dev/null +++ b/src/commands/search/rename.ts @@ -0,0 +1,18 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { SearchIndex } from "../../types.js"; + +export function registerSearchRename(search: Command): void { + search + .command("rename") + .description("Rename a search index") + .requiredOption("--index-id ", "Search index ID") + .requiredOption("--name ", "New index name") + .action(async (flags: { indexId: string; name: string }, command: Command) => { + const auth = resolveAuth(command); + const idx = await request(auth, "POST", `/v2/search/${flags.indexId}/rename`, { name: flags.name }); + printJSON(idx); + }); +} diff --git a/src/commands/search/reset-password.ts b/src/commands/search/reset-password.ts new file mode 100644 index 0000000..896dd0f --- /dev/null +++ b/src/commands/search/reset-password.ts @@ -0,0 +1,17 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { SearchIndex } from "../../types.js"; + +export function registerSearchResetPassword(search: Command): void { + search + .command("reset-password") + .description("Reset tokens for a search index") + .requiredOption("--index-id ", "Search index ID") + .action(async (flags: { indexId: string }, command: Command) => { + const auth = resolveAuth(command); + const idx = await request(auth, "POST", `/v2/search/${flags.indexId}/reset-password`); + printJSON(idx); + }); +} diff --git a/src/commands/search/stats.ts b/src/commands/search/stats.ts new file mode 100644 index 0000000..11df4e9 --- /dev/null +++ b/src/commands/search/stats.ts @@ -0,0 +1,28 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import { STATS_PERIODS } from "../../types.js"; + +export function registerSearchStats(search: Command): void { + search + .command("stats") + .description("Get statistics across all search indexes") + .action(async (flags: Record, command: Command) => { + const auth = resolveAuth(command); + const stats = await request>(auth, "GET", "/v2/search/stats"); + printJSON(stats); + }); + + search + .command("index-stats") + .description("Get statistics for a specific search index") + .requiredOption("--index-id ", "Search index ID") + .option("--period ", `Time period. Available: ${STATS_PERIODS.join(", ")}`, "1h") + .action(async (flags: { indexId: string; period?: string }, command: Command) => { + const auth = resolveAuth(command); + const qs = flags.period ? `?period=${encodeURIComponent(flags.period)}` : ""; + const stats = await request>(auth, "GET", `/v2/search/${flags.indexId}/stats${qs}`); + printJSON(stats); + }); +} diff --git a/src/commands/search/transfer.ts b/src/commands/search/transfer.ts new file mode 100644 index 0000000..42b29d1 --- /dev/null +++ b/src/commands/search/transfer.ts @@ -0,0 +1,17 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; + +export function registerSearchTransfer(search: Command): void { + search + .command("transfer") + .description("Transfer a search index to another team") + .requiredOption("--index-id ", "Search index ID") + .requiredOption("--target-account ", "Target team ID") + .action(async (flags: { indexId: string; targetAccount: string }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "POST", `/v2/search/${flags.indexId}/transfer`, { target_account: flags.targetAccount }); + printJSON(result); + }); +} diff --git a/src/commands/team/add-member.ts b/src/commands/team/add-member.ts new file mode 100644 index 0000000..ade04c6 --- /dev/null +++ b/src/commands/team/add-member.ts @@ -0,0 +1,24 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import { TEAM_MEMBER_ROLES } from "../../types.js"; +import type { TeamMember } from "../../types.js"; + +export function registerTeamAddMember(team: Command): void { + team + .command("add-member") + .description("Add a member to a team") + .requiredOption("--team-id ", "Team ID") + .requiredOption("--member-email ", "Email of the member to add") + .requiredOption("--role ", `Member role (${TEAM_MEMBER_ROLES.join(", ")})`) + .action(async (flags: { teamId: string; memberEmail: string; role: string }, command: Command) => { + const auth = resolveAuth(command); + const member = await request(auth, "POST", "/v2/teams/member", { + team_id: flags.teamId, + member_email: flags.memberEmail, + member_role: flags.role, + }); + printJSON(member); + }); +} diff --git a/src/commands/team/add_member.ts b/src/commands/team/add_member.ts deleted file mode 100644 index d0ad0bb..0000000 --- a/src/commands/team/add_member.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { cliffy } from "../../deps.ts"; -import { Command } from "../../util/command.ts"; -import { parseAuth } from "../../util/auth.ts"; -import { http } from "../../util/http.ts"; -enum Role { - admin = "admin", - dev = "dev", - finance = "finance", -} -export const addMemberCmd = new Command() - .name("add-member") - .description("Add a new member to a team") - .type("role", new cliffy.EnumType(Role)) - .option("--id ", "The id of your team", { required: true }) - .option( - "--member-email ", - "The email of a user you want to add.", - { - required: true, - }, - ) - .option("--role ", "The role for the new user", { - required: true, - }) - .example( - "Add new developer", - `upstash team add-member --id=f860e7e2-27b8-4166-90d5-ea41e90b4809 --member-email=bob@acme.com --role=${Role.dev}`, - ) - .action(async (options): Promise => { - const authorization = await parseAuth(options); - - if (!options.id) { - if (options.ci) { - throw new cliffy.ValidationError("id"); - } - const teams = await http.request< - { team_name: string; team_id: string }[] - >({ - method: "GET", - authorization, - path: ["v2", "teams"], - }); - options.id = await cliffy.Select.prompt({ - message: "Select a team", - options: teams.map(({ team_name, team_id }) => ({ - name: team_name, - value: team_id, - })), - }); - } - if (!options.memberEmail) { - if (options.ci) { - throw new cliffy.ValidationError("memberEmail"); - } - options.memberEmail = await cliffy.Input.prompt("Enter the user's email"); - } - if (!options.role) { - if (options.ci) { - throw new cliffy.ValidationError("role"); - } - options.role = (await cliffy.Select.prompt({ - message: "Select a role", - options: Object.entries(Role).map(([name, value]) => ({ - name, - value, - })), - })) as Role; - } - - const res = await http.request<{ - team_name: string; - member_email: string; - member_role: Role; - }>({ - method: "POST", - authorization, - path: ["v2", "teams", "member"], - body: { - team_id: options.id, - member_email: options.memberEmail, - member_role: options.role, - }, - }); - if (options.json) { - console.log(JSON.stringify(res, null, 2)); - return; - } - console.log( - cliffy.colors.brightGreen( - `${res.member_email} has been invited to join ${res.team_name} as ${res.member_role}`, - ), - ); - }); diff --git a/src/commands/team/create.ts b/src/commands/team/create.ts index a0a8cd4..9a1b1f7 100644 --- a/src/commands/team/create.ts +++ b/src/commands/team/create.ts @@ -1,49 +1,18 @@ -import { cliffy } from "../../deps.ts"; -import { Command } from "../../util/command.ts"; -import { parseAuth } from "../../util/auth.ts"; -import { http } from "../../util/http.ts"; +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { Team } from "../../types.js"; -export const createCmd = new Command() - .name("create") - .description("Create a new team") - .option("-n --name=", "Name of the database", { - required: true, - }) - .option( - "--copy-credit-card=", - "Set true to copy the credit card information to the new team", - { default: false }, - ) - .action(async (options): Promise => { - const authorization = await parseAuth(options); - - // if (!options.name) { - // if (options.ci) { - // throw new cliffy.ValidationError("name"); - // } - // options.name = await cliffy.Input.prompt("Set a name for your team"); - // } - - const body: Record = { - team_name: options.name, - copy_cc: options.copyCreditCard, - }; - - const team = await http.request({ - method: "POST", - authorization, - path: ["v2", "team"], - body, +export function registerTeamCreate(team: Command): void { + team + .command("create") + .description("Create a new team") + .requiredOption("--name ", "Team name") + .option("--copy-cc", "Copy existing credit card information to the team") + .action(async (flags: { name: string; copyCc?: boolean }, command: Command) => { + const auth = resolveAuth(command); + const t = await request(auth, "POST", "/v2/team", { team_name: flags.name, copy_cc: flags.copyCc ?? false }); + printJSON(t); }); - if (options.json) { - console.log(JSON.stringify(team, null, 2)); - return; - } - console.log(cliffy.colors.brightGreen("Team has been created")); - console.log(); - console.log( - cliffy.Table.from( - Object.entries(team).map(([k, v]) => [k.toString(), v.toString()]), - ).toString(), - ); - }); +} diff --git a/src/commands/team/delete.ts b/src/commands/team/delete.ts index 02e2f07..f1e50fe 100644 --- a/src/commands/team/delete.ts +++ b/src/commands/team/delete.ts @@ -1,44 +1,21 @@ -import { cliffy } from "../../deps.ts"; -import { Command } from "../../util/command.ts"; -import { parseAuth } from "../../util/auth.ts"; -import { http } from "../../util/http.ts"; -export const deleteCmd = new Command() - .name("delete") - .description("delete a team") - .option("--id ", "The uuid of your database") - .example("Delete", `upstash team delete f860e7e2-27b8-4166-90d5-ea41e90b4809`) - .action(async (options): Promise => { - const authorization = await parseAuth(options); +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; - // if (!options.id) { - // if (options.ci) { - // throw new cliffy.ValidationError("id"); - // } - // const teams = await http.request< - // { team_name: string; team_id: string }[] - // >({ - // method: "GET", - // authorization, - // path: ["v2", "teams"], - // }); - // options.id = await cliffy.Select.prompt({ - // message: "Select a team to delete", - // options: teams.map(({ team_name, team_id }) => ({ - // name: team_name, - // value: team_id, - // })), - // }); - // } - - await http.request({ - method: "DELETE", - authorization, - path: ["v2", "team", options.id!], +export function registerTeamDelete(team: Command): void { + team + .command("delete") + .description("Delete a team") + .requiredOption("--team-id ", "Team ID") + .option("--dry-run", "Preview the action without executing it") + .action(async (flags: { teamId: string; dryRun?: boolean }, command: Command) => { + if (flags.dryRun) { + printJSON({ action: "delete", team_id: flags.teamId, dry_run: true }); + return; + } + const auth = resolveAuth(command); + await request(auth, "DELETE", `/v2/team/${flags.teamId}`); + printJSON({ deleted: true, team_id: flags.teamId }); }); - if (options.json) { - console.log(JSON.stringify({ ok: true }, null, 2)); - return; - } - console.log(cliffy.colors.brightGreen("Team has been deleted")); - console.log(); - }); +} diff --git a/src/commands/team/index.ts b/src/commands/team/index.ts new file mode 100644 index 0000000..b51ffce --- /dev/null +++ b/src/commands/team/index.ts @@ -0,0 +1,18 @@ +import { Command } from "commander"; +import { registerTeamList } from "./list.js"; +import { registerTeamCreate } from "./create.js"; +import { registerTeamDelete } from "./delete.js"; +import { registerTeamMembers } from "./members.js"; +import { registerTeamAddMember } from "./add-member.js"; +import { registerTeamRemoveMember } from "./remove-member.js"; + +export function registerTeam(program: Command): void { + const team = program.command("team").description("Manage teams"); + + registerTeamList(team); + registerTeamCreate(team); + registerTeamDelete(team); + registerTeamMembers(team); + registerTeamAddMember(team); + registerTeamRemoveMember(team); +} diff --git a/src/commands/team/list.ts b/src/commands/team/list.ts index d28143c..e45579d 100644 --- a/src/commands/team/list.ts +++ b/src/commands/team/list.ts @@ -1,36 +1,16 @@ -import { cliffy } from "../../deps.ts"; -import { Command } from "../../util/command.ts"; -import { parseAuth } from "../../util/auth.ts"; -import { http } from "../../util/http.ts"; +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { Team } from "../../types.js"; -export const listCmd = new Command() - .name("list") - .description("list all your teams") - .example("List", "upstash team list") - .action(async (options): Promise => { - const authorization = await parseAuth(options); - - const teams = await http.request<{ team_name: string }[]>({ - method: "GET", - authorization, - path: ["v2", "teams"], - }); - if (options.json) { - console.log(JSON.stringify(teams, null, 2)); - return; - } - - teams.forEach((team) => { - console.log(); - console.log(); - console.log( - cliffy.colors.underline(cliffy.colors.brightGreen(team.team_name)), - ); - console.log(); - console.log( - cliffy.Table.from( - Object.entries(team).map(([k, v]) => [k.toString(), v.toString()]), - ).toString(), - ); +export function registerTeamList(team: Command): void { + team + .command("list") + .description("List all teams") + .action(async (flags: Record, command: Command) => { + const auth = resolveAuth(command); + const teams = await request(auth, "GET", "/v2/teams"); + printJSON(teams); }); - }); +} diff --git a/src/commands/team/list_members.ts b/src/commands/team/list_members.ts deleted file mode 100644 index 6872a8b..0000000 --- a/src/commands/team/list_members.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { cliffy } from "../../deps.ts"; -import { Command } from "../../util/command.ts"; -import { parseAuth } from "../../util/auth.ts"; -import { http } from "../../util/http.ts"; - -export const listMembersCmd = new Command() - .name("list-members") - .description("List all members of a team") - .option("--id ", "The team id") - .example("List", "upstash team list-members") - .action(async (options): Promise => { - const authorization = await parseAuth(options); - - // if (!options.id) { - // if (options.ci) { - // throw new cliffy.ValidationError("teamID"); - // } - // const teams = await http.request< - // { team_name: string; team_id: string }[] - // >({ - // method: "GET", - // authorization, - // path: ["v2", "teams"], - // }); - // options.id = await cliffy.Select.prompt({ - // message: "Select a team to delete", - // options: teams.map(({ team_name, team_id }) => ({ - // name: team_name, - // value: team_id, - // })), - // }); - // } - const members = await http.request< - { database_name: string; database_id: string }[] - >({ - method: "GET", - authorization, - path: ["v2", "teams", options.id!], - }); - if (options.json) { - console.log(JSON.stringify(members, null, 2)); - return; - } - - members.forEach((member) => { - console.log( - cliffy.Table.from( - Object.entries(member).map(([k, v]) => [k.toString(), v.toString()]), - ).toString(), - ); - console.log(); - }); - console.log(); - console.log(); - }); diff --git a/src/commands/team/members.ts b/src/commands/team/members.ts new file mode 100644 index 0000000..f017bb3 --- /dev/null +++ b/src/commands/team/members.ts @@ -0,0 +1,17 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { TeamMember } from "../../types.js"; + +export function registerTeamMembers(team: Command): void { + team + .command("members") + .description("List all members of a team") + .requiredOption("--team-id ", "Team ID") + .action(async (flags: { teamId: string }, command: Command) => { + const auth = resolveAuth(command); + const members = await request(auth, "GET", `/v2/teams/${flags.teamId}`); + printJSON(members); + }); +} diff --git a/src/commands/team/mod.ts b/src/commands/team/mod.ts deleted file mode 100644 index 85714dc..0000000 --- a/src/commands/team/mod.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Command } from "../../util/command.ts"; -import { createCmd } from "./create.ts"; -import { listCmd } from "./list.ts"; -import { deleteCmd } from "./delete.ts"; -import { addMemberCmd } from "./add_member.ts"; -import { removeMemberCmd } from "./remove_member.ts"; -import { listMembersCmd } from "./list_members.ts"; - -export const teamCmd = new Command() - .description("Manage your teams and their members") - .globalOption("--json=[boolean:boolean]", "Return raw json response") - .command("create", createCmd) - .command("list", listCmd) - .command("delete", deleteCmd) - .command("add-member", addMemberCmd) - .command("remove-member", removeMemberCmd) - .command("list-members", listMembersCmd); - -teamCmd.reset().action(() => { - teamCmd.showHelp(); -}); diff --git a/src/commands/team/remove-member.ts b/src/commands/team/remove-member.ts new file mode 100644 index 0000000..7930f25 --- /dev/null +++ b/src/commands/team/remove-member.ts @@ -0,0 +1,22 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; + +export function registerTeamRemoveMember(team: Command): void { + team + .command("remove-member") + .description("Remove a member from a team") + .requiredOption("--team-id ", "Team ID") + .requiredOption("--member-email ", "Email of the member to remove") + .option("--dry-run", "Preview the action without executing it") + .action(async (flags: { dryRun?: boolean; teamId: string; memberEmail: string }, command: Command) => { + if (flags.dryRun) { + printJSON({ action: "remove-member", team_id: flags.teamId, member_email: flags.memberEmail, dry_run: true }); + return; + } + const auth = resolveAuth(command); + await request(auth, "DELETE", "/v2/teams/member", { team_id: flags.teamId, member_email: flags.memberEmail }); + printJSON({ removed: true, team_id: flags.teamId, member_email: flags.memberEmail }); + }); +} diff --git a/src/commands/team/remove_member.ts b/src/commands/team/remove_member.ts deleted file mode 100644 index 1ad93d6..0000000 --- a/src/commands/team/remove_member.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { cliffy } from "../../deps.ts"; -import { Command } from "../../util/command.ts"; -import { parseAuth } from "../../util/auth.ts"; -import { http } from "../../util/http.ts"; - -export const removeMemberCmd = new Command() - .name("remove-member") - .description("Remove a member from a team") - .option("--id=", "The uuid of the team") - .option("--email=", "The email of the member") - .example( - "Remove", - `upstash team remove-member f860e7e2-27b8-4166-90d5-ea41e90b4809 f860e7e2-27b8-4166-90d5-ea41e90b4809`, - ) - .action(async (options): Promise => { - const authorization = await parseAuth(options); - - // if (!options.id) { - // if (options.ci) { - // throw new cliffy.ValidationError("id"); - // } - // const teams = await http.request< - // { team_name: string; team_id: string }[] - // >({ - // method: "GET", - // authorization, - // path: ["v2", "teams"], - // }); - // options.id = await cliffy.Select.prompt({ - // message: "Select a team", - // options: teams.map(({ team_name, team_id }) => ({ - // name: team_name, - // value: team_id, - // })), - // }); - // } - // if (!options.email) { - // if (options.ci) { - // throw new cliffy.ValidationError("email"); - // } - // options.email = await cliffy.Input.prompt("Enter the user's email"); - // } - - const team = await http.request({ - method: "DELETE", - authorization, - path: ["v2", "teams", "member"], - body: { - team_id: options.id, - member_email: options.email, - }, - }); - if (options.json) { - console.log(JSON.stringify(team, null, 2)); - return; - } - console.log(cliffy.colors.brightGreen("Member has been removed")); - }); diff --git a/src/commands/vector/create.ts b/src/commands/vector/create.ts new file mode 100644 index 0000000..0c7341b --- /dev/null +++ b/src/commands/vector/create.ts @@ -0,0 +1,44 @@ +import { Command, InvalidArgumentError } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import { VECTOR_REGIONS, VECTOR_SIMILARITY_FUNCTIONS, VECTOR_INDEX_TYPES, VECTOR_EMBEDDING_MODELS, VECTOR_SPARSE_MODELS, VECTOR_PLANS } from "../../types.js"; +import type { VectorIndex } from "../../types.js"; + +function parseNonNegativeInt(name: string) { + return (v: string): number => { + const n = Number(v); + if (!Number.isInteger(n) || n < 0) { + throw new InvalidArgumentError(`--${name} must be a non-negative integer; got "${v}"`); + } + return n; + }; +} + +export function registerVectorCreate(vector: Command): void { + vector + .command("create") + .description("Create a new vector index") + .requiredOption("--name ", "Index name") + .requiredOption("--region ", `Region. Available: ${VECTOR_REGIONS.join(", ")}`) + .requiredOption("--similarity-function ", `Similarity function. Available: ${VECTOR_SIMILARITY_FUNCTIONS.join(", ")}`) + .requiredOption("--dimension-count ", "Number of dimensions per vector", parseNonNegativeInt("dimension-count")) + .option("--type ", `Plan type. Available: ${VECTOR_PLANS.join(", ")}`) + .option("--embedding-model ", `Embedding model. Available: ${VECTOR_EMBEDDING_MODELS.join(", ")}`) + .option("--index-type ", `Index type. Available: ${VECTOR_INDEX_TYPES.join(", ")}`) + .option("--sparse-embedding-model ", `Sparse embedding model. Available: ${VECTOR_SPARSE_MODELS.join(", ")}`) + .action(async (flags: { name: string; region: string; similarityFunction: string; dimensionCount: number; type?: string; embeddingModel?: string; indexType?: string; sparseEmbeddingModel?: string }, command: Command) => { + const auth = resolveAuth(command); + const idx = await request(auth, "POST", "/v2/vector/index", { + name: flags.name, + region: flags.region, + similarity_function: flags.similarityFunction, + dimension_count: flags.dimensionCount, + type: flags.type, + embedding_model: flags.embeddingModel, + index_type: flags.indexType, + sparse_embedding_model: flags.sparseEmbeddingModel, + }); + printJSON(idx); + }); +} diff --git a/src/commands/vector/delete.ts b/src/commands/vector/delete.ts new file mode 100644 index 0000000..c72c444 --- /dev/null +++ b/src/commands/vector/delete.ts @@ -0,0 +1,21 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; + +export function registerVectorDelete(vector: Command): void { + vector + .command("delete") + .description("Delete a vector index") + .requiredOption("--index-id ", "Vector index ID") + .option("--dry-run", "Preview the action without executing it") + .action(async (flags: { indexId: string; dryRun?: boolean }, command: Command) => { + if (flags.dryRun) { + printJSON({ action: "delete", index_id: flags.indexId, dry_run: true }); + return; + } + const auth = resolveAuth(command); + await request(auth, "DELETE", `/v2/vector/index/${flags.indexId}`); + printJSON({ deleted: true, index_id: flags.indexId }); + }); +} diff --git a/src/commands/vector/get.ts b/src/commands/vector/get.ts new file mode 100644 index 0000000..ee1860b --- /dev/null +++ b/src/commands/vector/get.ts @@ -0,0 +1,17 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { VectorIndex } from "../../types.js"; + +export function registerVectorGet(vector: Command): void { + vector + .command("get") + .description("Get details of a vector index") + .requiredOption("--index-id ", "Vector index ID") + .action(async (flags: { indexId: string }, command: Command) => { + const auth = resolveAuth(command); + const idx = await request(auth, "GET", `/v2/vector/index/${flags.indexId}`); + printJSON(idx); + }); +} diff --git a/src/commands/vector/index.ts b/src/commands/vector/index.ts new file mode 100644 index 0000000..fae8e43 --- /dev/null +++ b/src/commands/vector/index.ts @@ -0,0 +1,24 @@ +import { Command } from "commander"; +import { registerVectorList } from "./list.js"; +import { registerVectorCreate } from "./create.js"; +import { registerVectorGet } from "./get.js"; +import { registerVectorDelete } from "./delete.js"; +import { registerVectorRename } from "./rename.js"; +import { registerVectorResetPassword } from "./reset-password.js"; +import { registerVectorSetPlan } from "./set-plan.js"; +import { registerVectorTransfer } from "./transfer.js"; +import { registerVectorStats } from "./stats.js"; + +export function registerVector(program: Command): void { + const vector = program.command("vector").description("Manage Vector indexes"); + + registerVectorList(vector); + registerVectorCreate(vector); + registerVectorGet(vector); + registerVectorDelete(vector); + registerVectorRename(vector); + registerVectorResetPassword(vector); + registerVectorSetPlan(vector); + registerVectorTransfer(vector); + registerVectorStats(vector); +} diff --git a/src/commands/vector/list.ts b/src/commands/vector/list.ts new file mode 100644 index 0000000..cc15344 --- /dev/null +++ b/src/commands/vector/list.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { VectorIndex } from "../../types.js"; + +export function registerVectorList(vector: Command): void { + vector + .command("list") + .description("List all vector indexes") + .action(async (flags: Record, command: Command) => { + const auth = resolveAuth(command); + const indexes = await request(auth, "GET", "/v2/vector/index"); + printJSON(indexes); + }); +} diff --git a/src/commands/vector/rename.ts b/src/commands/vector/rename.ts new file mode 100644 index 0000000..bbd4a71 --- /dev/null +++ b/src/commands/vector/rename.ts @@ -0,0 +1,18 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { VectorIndex } from "../../types.js"; + +export function registerVectorRename(vector: Command): void { + vector + .command("rename") + .description("Rename a vector index") + .requiredOption("--index-id ", "Vector index ID") + .requiredOption("--name ", "New index name") + .action(async (flags: { indexId: string; name: string }, command: Command) => { + const auth = resolveAuth(command); + const idx = await request(auth, "POST", `/v2/vector/index/${flags.indexId}/rename`, { name: flags.name }); + printJSON(idx); + }); +} diff --git a/src/commands/vector/reset-password.ts b/src/commands/vector/reset-password.ts new file mode 100644 index 0000000..c7c1b77 --- /dev/null +++ b/src/commands/vector/reset-password.ts @@ -0,0 +1,17 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import type { VectorIndex } from "../../types.js"; + +export function registerVectorResetPassword(vector: Command): void { + vector + .command("reset-password") + .description("Reset tokens for a vector index") + .requiredOption("--index-id ", "Vector index ID") + .action(async (flags: { indexId: string }, command: Command) => { + const auth = resolveAuth(command); + const idx = await request(auth, "POST", `/v2/vector/index/${flags.indexId}/reset-password`); + printJSON(idx); + }); +} diff --git a/src/commands/vector/set-plan.ts b/src/commands/vector/set-plan.ts new file mode 100644 index 0000000..2cba7de --- /dev/null +++ b/src/commands/vector/set-plan.ts @@ -0,0 +1,18 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import { VECTOR_PLANS } from "../../types.js"; + +export function registerVectorSetPlan(vector: Command): void { + vector + .command("set-plan") + .description(`Change the plan of a vector index. Plans: ${VECTOR_PLANS.join(", ")}`) + .requiredOption("--index-id ", "Vector index ID") + .requiredOption("--plan ", `Target plan (${VECTOR_PLANS.join(", ")})`) + .action(async (flags: { indexId: string; plan: string }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "POST", `/v2/vector/index/${flags.indexId}/setplan`, { target_plan: flags.plan }); + printJSON(result); + }); +} diff --git a/src/commands/vector/stats.ts b/src/commands/vector/stats.ts new file mode 100644 index 0000000..cd4a963 --- /dev/null +++ b/src/commands/vector/stats.ts @@ -0,0 +1,28 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; +import { STATS_PERIODS } from "../../types.js"; + +export function registerVectorStats(vector: Command): void { + vector + .command("stats") + .description("Get statistics across all vector indexes") + .action(async (flags: Record, command: Command) => { + const auth = resolveAuth(command); + const stats = await request>(auth, "GET", "/v2/vector/index/stats"); + printJSON(stats); + }); + + vector + .command("index-stats") + .description("Get statistics for a specific vector index") + .requiredOption("--index-id ", "Vector index ID") + .option("--period ", `Time period. Available: ${STATS_PERIODS.join(", ")}`, "1h") + .action(async (flags: { indexId: string; period?: string }, command: Command) => { + const auth = resolveAuth(command); + const qs = flags.period ? `?period=${encodeURIComponent(flags.period)}` : ""; + const stats = await request>(auth, "GET", `/v2/vector/index/${flags.indexId}/stats${qs}`); + printJSON(stats); + }); +} diff --git a/src/commands/vector/transfer.ts b/src/commands/vector/transfer.ts new file mode 100644 index 0000000..0127a92 --- /dev/null +++ b/src/commands/vector/transfer.ts @@ -0,0 +1,17 @@ +import { Command } from "commander"; +import { resolveAuth } from "../../auth.js"; +import { request } from "../../client.js"; +import { printJSON } from "../../output.js"; + +export function registerVectorTransfer(vector: Command): void { + vector + .command("transfer") + .description("Transfer a vector index to another team") + .requiredOption("--index-id ", "Vector index ID") + .requiredOption("--target-account ", "Target team ID") + .action(async (flags: { indexId: string; targetAccount: string }, command: Command) => { + const auth = resolveAuth(command); + const result = await request(auth, "POST", `/v2/vector/index/${flags.indexId}/transfer`, { target_account: flags.targetAccount }); + printJSON(result); + }); +} diff --git a/src/config.ts b/src/config.ts index fd80f2a..50f4703 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,26 +1,55 @@ -import { path } from "./deps.ts"; -export type Config = { - email: string; - apiKey: string; -}; -const homeDir = Deno.env.get("HOME"); -const fileName = ".upstash.json"; -export const DEFAULT_CONFIG_PATH = homeDir - ? path.join(homeDir, fileName) - : fileName; +import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import type { Auth } from "./auth.js"; -export function loadConfig(path: string): Config | null { +interface StoredConfig { + email?: string; + api_key?: string; +} + +export function getConfigDir(): string { + const override = process.env.UPSTASH_CONFIG_HOME; + if (override) return override; + const xdg = process.env.XDG_CONFIG_HOME; + const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".config"); + return join(base, "upstash"); +} + +export function getConfigPath(): string { + return join(getConfigDir(), "config.json"); +} + +export function readConfig(): Auth | null { + const path = getConfigPath(); + if (!existsSync(path)) return null; + let raw: string; + try { + raw = readFileSync(path, "utf8"); + } catch { + return null; + } + let parsed: StoredConfig; try { - return JSON.parse(Deno.readTextFileSync(path)) as Config; + parsed = JSON.parse(raw) as StoredConfig; } catch { return null; } + if (!parsed.email || !parsed.api_key) return null; + return { email: parsed.email, apiKey: parsed.api_key }; } -export function storeConfig(path: string, config: Config): void { - Deno.writeTextFileSync(path, JSON.stringify(config)); +export function writeConfig(auth: Auth): string { + const path = getConfigPath(); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const body: StoredConfig = { email: auth.email, api_key: auth.apiKey }; + writeFileSync(path, JSON.stringify(body, null, 2) + "\n", { mode: 0o600 }); + return path; } -export function deleteConfig(path: string): void { - Deno.removeSync(path); +export function deleteConfig(): boolean { + const path = getConfigPath(); + if (!existsSync(path)) return false; + rmSync(path); + return true; } diff --git a/src/deps.ts b/src/deps.ts deleted file mode 100644 index d3b06f8..0000000 --- a/src/deps.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * as cliffy from "https://deno.land/x/cliffy@v0.24.0/mod.ts"; -export * as path from "https://deno.land/std@0.139.0/path/mod.ts"; -export * as base64 from "https://deno.land/std@0.139.0/encoding/base64.ts"; diff --git a/src/mod.ts b/src/mod.ts deleted file mode 100644 index e212575..0000000 --- a/src/mod.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { authCmd } from "./commands/auth/mod.ts"; -import { redisCmd } from "./commands/redis/mod.ts"; -import { teamCmd } from "./commands/team/mod.ts"; -import { Command } from "./util/command.ts"; -import { cliffy } from "./deps.ts"; -import { VERSION } from "./version.ts"; -import { DEFAULT_CONFIG_PATH } from "./config.ts"; -const cmd = new Command() - .name("upstash") - .version(VERSION) - .description("Official cli for Upstash products") - .globalEnv("UPSTASH_EMAIL=", "The email you use on upstash") - .globalEnv("UPSTASH_API_KEY=", "The api key from upstash") - // .globalEnv( - // "CI=", - // "Disable interactive prompts and throws an error instead", - // { hidden: true } - // ) - // .globalOption( - // "--non-interactive [boolean]", - // "Disable interactive prompts and throws an error instead", - // { hidden: true } - // ) - .globalOption("-c, --config=", "Path to .upstash.json file", { - default: DEFAULT_CONFIG_PATH, - }) - /** - * Nested commands don't seem to work as expected, or maybe I'm just not understanding them. - * The workaround is to cast as `Command` - */ - .command("auth", authCmd as unknown as Command) - .command("redis", redisCmd as unknown as Command) - .command("team", teamCmd as unknown as Command); -cmd.reset().action(() => { - cmd.showHelp(); -}); - -await cmd.parse(Deno.args).catch((err) => { - if (err instanceof cliffy.ValidationError) { - cmd.showHelp(); - console.error("Usage error: %s", err.message); - Deno.exit(err.exitCode); - } else { - console.error(`Error: ${err.message}`); - - Deno.exit(1); - } -}); diff --git a/src/output.ts b/src/output.ts new file mode 100644 index 0000000..9a1c621 --- /dev/null +++ b/src/output.ts @@ -0,0 +1,20 @@ +export function printJSON(data: unknown): void { + console.log(JSON.stringify(data, null, 2)); +} + +export function plainError(message: string): Error { + const err = new Error(message); + (err as { plain?: boolean }).plain = true; + return err; +} + +export function handleError(err: unknown): never { + const message = err instanceof Error ? err.message : String(err); + const plain = err instanceof Error && (err as { plain?: boolean }).plain === true; + if (plain) { + console.error(message); + } else { + console.error(JSON.stringify({ error: message })); + } + process.exit(1); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..fec1d95 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,173 @@ +export const REGIONS = [ + // AWS + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ca-central-1", + "eu-central-1", + "eu-west-1", + "eu-west-2", + "sa-east-1", + "ap-south-1", + "ap-northeast-1", + "ap-southeast-1", + "ap-southeast-2", + "af-south-1", + // GCP + "us-central1", + "us-east4", + "europe-west1", + "asia-northeast1", +] as const; + +export type Region = (typeof REGIONS)[number]; + +export interface Database { + database_id: string; + database_name: string; + password?: string; + endpoint?: string; + port?: number; + creation_time?: number; + state?: string; + tls?: boolean; + type?: string; + budget?: number; + primary_region?: string; + read_regions?: string[]; + eviction?: boolean; + auto_upgrade?: boolean; + consistent?: boolean; + daily_backup_enabled?: boolean; + region?: string; + rest_token?: string; + read_only_rest_token?: string; + db_max_clients?: number; + db_memory_threshold?: number; + db_disk_threshold?: number; + db_max_entry_size?: number; + db_max_request_size?: number; +} + +export interface Backup { + backup_id: string; + backup_name: string; + creation_time: number; + database_id?: string; +} + +export interface Team { + team_id: string; + team_name: string; + copy_cc?: boolean; +} + +export interface TeamMember { + team_id: string; + team_name: string; + member_email: string; + member_role: "owner" | "admin" | "dev" | "finance"; + copy_cc?: boolean; +} + +export const TEAM_MEMBER_ROLES = ["admin", "dev", "finance"] as const; +export type TeamMemberRole = (typeof TEAM_MEMBER_ROLES)[number]; + +// ── Vector ──────────────────────────────────────────────────────────────────── + +export const VECTOR_REGIONS = ["eu-west-1", "us-east-1", "us-central1"] as const; +export type VectorRegion = (typeof VECTOR_REGIONS)[number]; + +export const VECTOR_SIMILARITY_FUNCTIONS = ["COSINE", "EUCLIDEAN", "DOT_PRODUCT"] as const; +export const VECTOR_INDEX_TYPES = ["DENSE", "SPARSE", "HYBRID"] as const; +export const VECTOR_EMBEDDING_MODELS = [ + "BGE_SMALL_EN_V1_5", + "BGE_BASE_EN_V1_5", + "BGE_LARGE_EN_V1_5", + "BGE_M3", +] as const; +export const VECTOR_SPARSE_MODELS = ["BM25", "BGE_M3"] as const; +export const VECTOR_PLANS = ["free", "payg", "fixed"] as const; + +export interface VectorIndex { + id: string; + name: string; + region: string; + similarity_function: string; + dimension_count: number; + embedding_model?: string; + sparse_embedding_model?: string; + index_type?: string; + endpoint: string; + token: string; + read_only_token: string; + type: string; + creation_time?: number; + customer_id?: string; + max_vector_count?: number; + max_daily_queries?: number; + max_daily_updates?: number; + max_writes_per_second?: number; + max_query_per_second?: number; + reserved_price?: number; +} + +// ── Search ──────────────────────────────────────────────────────────────────── + +export const SEARCH_REGIONS = ["eu-west-1", "us-central1"] as const; +export type SearchRegion = (typeof SEARCH_REGIONS)[number]; + +export const SEARCH_PLANS = ["free", "payg", "fixed"] as const; + +export interface SearchIndex { + id: string; + name: string; + region: string; + type: string; + endpoint: string; + token: string; + read_only_token: string; + creation_time?: number; + customer_id?: string; + max_vector_count?: number; + max_daily_queries?: number; + max_daily_updates?: number; + max_writes_per_second?: number; + max_query_per_second?: number; + input_enrichment_enabled?: boolean; +} + +// ── QStash ──────────────────────────────────────────────────────────────────── + +export const QSTASH_PLANS = [ + "paid", + "qstash_fixed_1m", + "qstash_fixed_10m", + "qstash_fixed_100m", +] as const; + +export const STATS_PERIODS = ["1h", "3h", "12h", "1d", "3d", "7d", "30d"] as const; +export type StatsPeriod = (typeof STATS_PERIODS)[number]; + +export interface QStashUser { + id: string; + customer_id: string; + token: string; + password?: string; + active: boolean; + state: string; + type: string; + region: string; + reserved_type?: string; + reserved_price?: number; + budget?: number; + prod_pack_enabled?: boolean; + max_requests_per_day?: number; + max_requests_per_second?: number; + max_topics?: number; + max_schedules?: number; + max_queues?: number; + timeout?: number; + creation_time?: number; +} diff --git a/src/util/auth.ts b/src/util/auth.ts deleted file mode 100644 index 50596f8..0000000 --- a/src/util/auth.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { base64 } from "../deps.ts"; -import { loadConfig } from "../config.ts"; -/** - * Parse cli config and return a basic auth header string - */ -export async function parseAuth(options: { - upstashEmail?: string; - upstashApiKey?: string; - config: string; - ci?: boolean; - [key: string]: unknown; -}): Promise { - let email = options.upstashEmail; - let apiKey = options.upstashApiKey; - const config = loadConfig(options.config); - if (config?.email) { - email = config.email; - } - if (config?.apiKey) { - apiKey = config.apiKey; - } - - if (!email || !apiKey) { - throw new Error( - `Not authenticated, please run "upstash auth login" or specify your config file with "--config=/path/to/.upstash.json"`, - ); - } - - return await Promise.resolve( - `Basic ${base64.encode([email, apiKey].join(":"))}`, - ); -} diff --git a/src/util/command.ts b/src/util/command.ts deleted file mode 100644 index 6110ce4..0000000 --- a/src/util/command.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { cliffy } from "../deps.ts"; - -type GlobalConfig = { - upstashEmail?: string; - upstashApiKey?: string; - ci?: boolean; - json?: boolean; - config: string; -}; - -export class Command extends cliffy.Command {} diff --git a/src/util/http.ts b/src/util/http.ts deleted file mode 100644 index 776e16e..0000000 --- a/src/util/http.ts +++ /dev/null @@ -1,49 +0,0 @@ -export type UpstashRequest = { - authorization: string; - method: "GET" | "POST" | "PUT" | "DELETE"; - path?: string[]; - /** - * Request body will be serialized to json - */ - body?: unknown; -}; - -type HttpClientConfig = { - baseUrl: string; -}; - -class HttpClient { - private readonly baseUrl: string; - - public constructor(config: HttpClientConfig) { - this.baseUrl = config.baseUrl.replace(/\/$/, ""); - } - - public async request(req: UpstashRequest): Promise { - if (!req.path) { - req.path = []; - } - - const url = [this.baseUrl, ...req.path].join("/"); - const init: RequestInit = { - method: req.method, - headers: { - "Content-Type": "application/json", - Authorization: req.authorization, - }, - }; - if (req.method !== "GET") { - init.body = JSON.stringify(req.body); - } - - // fetch is defined by isomorphic fetch - // eslint-disable-next-line no-undef - const res = await fetch(url, init); - if (!res.ok) { - throw new Error(await res.text()); - } - return (await res.json()) as TResponse; - } -} - -export const http = new HttpClient({ baseUrl: "https://api.upstash.com" }); diff --git a/src/version.ts b/src/version.ts deleted file mode 100644 index 3f64591..0000000 --- a/src/version.ts +++ /dev/null @@ -1,2 +0,0 @@ -// This is set during build -export const VERSION = "development"; diff --git a/tests/helpers/program.ts b/tests/helpers/program.ts new file mode 100644 index 0000000..a13a010 --- /dev/null +++ b/tests/helpers/program.ts @@ -0,0 +1,61 @@ +import { Command } from "commander"; + +export async function createRedisProgram(): Promise { + const { registerRedis } = await import("../../src/commands/redis/index.js"); + const p = new Command().exitOverride(); + registerRedis(p); + return p; +} + +export async function createVectorProgram(): Promise { + const { registerVector } = await import("../../src/commands/vector/index.js"); + const p = new Command().exitOverride(); + registerVector(p); + return p; +} + +export async function createSearchProgram(): Promise { + const { registerSearch } = await import("../../src/commands/search/index.js"); + const p = new Command().exitOverride(); + registerSearch(p); + return p; +} + +export async function createQStashProgram(): Promise { + const { registerQStash } = await import("../../src/commands/qstash/index.js"); + const p = new Command().exitOverride(); + registerQStash(p); + return p; +} + +export async function createTeamProgram(): Promise { + const { registerTeam } = await import("../../src/commands/team/index.js"); + const p = new Command().exitOverride(); + registerTeam(p); + return p; +} + +export async function runCommand(program: Command, argv: string[]): Promise { + const output: string[] = []; + const errors: string[] = []; + const origLog = console.log; + const origError = console.error; + const origExit = process.exit; + + console.log = (...args: unknown[]) => output.push(args.join(" ")); + console.error = (...args: unknown[]) => errors.push(args.join(" ")); + process.exit = ((code?: number) => { + throw new Error(`CLI error (exit ${code ?? 1}): ${errors.at(-1) ?? "unknown error"}`); + }) as typeof process.exit; + + try { + await program.parseAsync(["node", "upstash", ...argv]); + } finally { + console.log = origLog; + console.error = origError; + process.exit = origExit; + } + + const last = output.at(-1); + return last ? JSON.parse(last) as unknown : null; +} diff --git a/tests/integration/qstash.test.ts b/tests/integration/qstash.test.ts new file mode 100644 index 0000000..f4fff1d --- /dev/null +++ b/tests/integration/qstash.test.ts @@ -0,0 +1,51 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { createQStashProgram, runCommand } from "../helpers/program.js"; +import type { QStashUser } from "../../src/types.js"; + +let qstashId: string | undefined; + +beforeAll(async () => { + const p = await createQStashProgram(); + const instances = await runCommand(p, ["qstash", "list"]) as QStashUser[]; + expect(Array.isArray(instances)).toBe(true); + expect(instances.length).toBeGreaterThan(0); + qstashId = instances[0]?.id; + expect(qstashId).toBeDefined(); +}); + +describe("qstash list", () => { + it("returns at least one instance", async () => { + const p = await createQStashProgram(); + const instances = await runCommand(p, ["qstash", "list"]) as QStashUser[]; + expect(Array.isArray(instances)).toBe(true); + expect(instances.length).toBeGreaterThan(0); + }); +}); + +describe("qstash get", () => { + it("returns the instance with expected fields", async () => { + const p = await createQStashProgram(); + const instance = await runCommand(p, ["qstash", "get", "--qstash-id", qstashId!]) as QStashUser; + expect(instance.id).toBe(qstashId); + expect(instance.token).toBeDefined(); + expect(instance.region).toBeDefined(); + expect(instance.state).toBeDefined(); + }); +}); + +describe("qstash stats", () => { + it("returns stats for the instance", async () => { + const p = await createQStashProgram(); + const stats = await runCommand(p, ["qstash", "stats", "--qstash-id", qstashId!]) as Record; + expect(stats).toBeDefined(); + expect(typeof stats).toBe("object"); + }); +}); + +describe("qstash ipv4", () => { + it("returns an array of CIDR blocks", async () => { + const p = await createQStashProgram(); + const result = await runCommand(p, ["qstash", "ipv4"]) as unknown; + expect(result).toBeDefined(); + }); +}); diff --git a/tests/integration/redis.test.ts b/tests/integration/redis.test.ts new file mode 100644 index 0000000..a9eabf1 --- /dev/null +++ b/tests/integration/redis.test.ts @@ -0,0 +1,210 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createRedisProgram, runCommand } from "../helpers/program.js"; +import type { Database, Backup } from "../../src/types.js"; + +const TEST_NAME = `cli-test-${Date.now()}`; +let dbId: string | undefined; +let dbEndpoint: string | undefined; +let dbRestToken: string | undefined; + +beforeAll(async () => { + const p = await createRedisProgram(); + const db = await runCommand(p, [ + "redis", "create", + "--name", TEST_NAME, + "--region", "us-east-1", + ]) as Database; + + expect(db.database_id).toBeDefined(); + dbId = db.database_id; + dbEndpoint = db.endpoint; + dbRestToken = db.rest_token; +}); + +afterAll(async () => { + if (!dbId) return; + const p = await createRedisProgram(); + await runCommand(p, ["redis", "delete", "--db-id", dbId]); +}); + +describe("redis list", () => { + it("includes the created database", async () => { + const p = await createRedisProgram(); + const dbs = await runCommand(p, ["redis", "list"]) as Database[]; + expect(Array.isArray(dbs)).toBe(true); + expect(dbs.some((db) => db.database_id === dbId)).toBe(true); + }); +}); + +describe("redis get", () => { + it("returns the database by id", async () => { + const p = await createRedisProgram(); + const db = await runCommand(p, ["redis", "get", "--db-id", dbId!]) as Database; + expect(db.database_id).toBe(dbId); + expect(db.database_name).toBe(TEST_NAME); + }); + + it("omits password with --hide-credentials", async () => { + const p = await createRedisProgram(); + const db = await runCommand(p, ["redis", "get", "--db-id", dbId!, "--hide-credentials"]) as Database; + expect(db.password).toBeUndefined(); + }); +}); + +describe("redis rename", () => { + it("updates the database name", async () => { + const newName = `${TEST_NAME}-renamed`; + const p = await createRedisProgram(); + await runCommand(p, ["redis", "rename", "--db-id", dbId!, "--name", newName]); + + const p2 = await createRedisProgram(); + const db = await runCommand(p2, ["redis", "get", "--db-id", dbId!]) as Database; + + expect(db.database_name).toBe(newName); + + // rename back so subsequent tests aren't affected + const p3 = await createRedisProgram(); + await runCommand(p3, ["redis", "rename", "--db-id", dbId!, "--name", TEST_NAME]); + }); +}); + +describe("redis stats", () => { + it("returns a stats object", async () => { + const p = await createRedisProgram(); + const stats = await runCommand(p, ["redis", "stats", "--db-id", dbId!]) as Record; + expect(stats).toBeDefined(); + expect(typeof stats).toBe("object"); + }); +}); + +describe("redis eviction", () => { + it("enables and disables eviction", async () => { + const p1 = await createRedisProgram(); + const enabled = await runCommand(p1, ["redis", "enable-eviction", "--db-id", dbId!]) as Record; + expect(enabled).toBeDefined(); + + const p2 = await createRedisProgram(); + const disabled = await runCommand(p2, ["redis", "disable-eviction", "--db-id", dbId!]) as Record; + expect(disabled).toBeDefined(); + }); +}); + +describe("redis autoupgrade", () => { + it("enables and disables autoupgrade", async () => { + const p1 = await createRedisProgram(); + await runCommand(p1, ["redis", "enable-autoupgrade", "--db-id", dbId!]); + + const p2 = await createRedisProgram(); + await runCommand(p2, ["redis", "disable-autoupgrade", "--db-id", dbId!]); + + const p3 = await createRedisProgram(); + const db = await runCommand(p3, ["redis", "get", "--db-id", dbId!]) as Database; + expect(db.auto_upgrade).toBe(false); + }); +}); + +describe("redis backup", () => { + let backupId: string | undefined; + + it("creates a backup and appears in list", async () => { + // wait for DB to be fully active before triggering a backup + await new Promise((r) => setTimeout(r, 5000)); + + const p = await createRedisProgram(); + await runCommand(p, [ + "redis", "backup", "create", + "--db-id", dbId!, + "--name", "test-backup", + ]); + + await new Promise((r) => setTimeout(r, 10000)); + const p2 = await createRedisProgram(); + const backups = await runCommand(p2, ["redis", "backup", "list", "--db-id", dbId!]) as Backup[]; + + expect(Array.isArray(backups)).toBe(true); + expect(backups.length).toBeGreaterThan(0); + backupId = backups[0]?.backup_id; + expect(backupId).toBeDefined(); + }); + + it("deletes the backup (dry-run)", async () => { + if (!backupId) return; + const p = await createRedisProgram(); + const result = await runCommand(p, [ + "redis", "backup", "delete", + "--db-id", dbId!, + "--backup-id", backupId, + "--dry-run", + ]) as Record; + expect(result["dry_run"]).toBe(true); + }); + + it("deletes the backup", async () => { + if (!backupId) return; + const p = await createRedisProgram(); + await runCommand(p, [ + "redis", "backup", "delete", + "--db-id", dbId!, + "--backup-id", backupId, + ]); + }); +}); + +describe("redis delete dry-run", () => { + it("returns dry_run: true without deleting", async () => { + const p = await createRedisProgram(); + const result = await runCommand(p, ["redis", "delete", "--db-id", dbId!, "--dry-run"]) as Record; + expect(result["dry_run"]).toBe(true); + expect(result["database_id"]).toBe(dbId); + }); +}); + +describe("redis exec", () => { + it("executes SET and GET commands against the database", async () => { + if (!dbEndpoint || !dbRestToken) { + console.warn("Skipping exec tests: endpoint or rest_token not available"); + return; + } + + const dbUrl = `https://${dbEndpoint}`; + + const p1 = await createRedisProgram(); + const setResult = await runCommand(p1, [ + "redis", "exec", + "--db-url", dbUrl, + "--db-token", dbRestToken, + "SET", "cli-test-key", "hello", + ]) as { result: unknown }; + expect(setResult.result).toBe("OK"); + + const p2 = await createRedisProgram(); + const getResult = await runCommand(p2, [ + "redis", "exec", + "--db-url", dbUrl, + "--db-token", dbRestToken, + "GET", "cli-test-key", + ]) as { result: unknown }; + expect(getResult.result).toBe("hello"); + + const p3 = await createRedisProgram(); + await runCommand(p3, [ + "redis", "exec", + "--db-url", dbUrl, + "--db-token", dbRestToken, + "DEL", "cli-test-key", + ]); + }); + + it("executes PING", async () => { + if (!dbEndpoint || !dbRestToken) return; + + const p = await createRedisProgram(); + const result = await runCommand(p, [ + "redis", "exec", + "--db-url", `https://${dbEndpoint}`, + "--db-token", dbRestToken, + "PING", + ]) as { result: unknown }; + expect(result.result).toBe("PONG"); + }); +}); diff --git a/tests/integration/search.test.ts b/tests/integration/search.test.ts new file mode 100644 index 0000000..ccecb71 --- /dev/null +++ b/tests/integration/search.test.ts @@ -0,0 +1,76 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createSearchProgram, runCommand } from "../helpers/program.js"; +import type { SearchIndex } from "../../src/types.js"; + +const TEST_NAME = `cli-test-${Date.now()}`; +let indexId: string | undefined; + +beforeAll(async () => { + const p = await createSearchProgram(); + const idx = await runCommand(p, [ + "search", "create", + "--name", TEST_NAME, + "--region", "eu-west-1", + "--type", "payg", + ]) as SearchIndex; + + expect(idx.id).toBeDefined(); + indexId = idx.id; +}); + +afterAll(async () => { + if (!indexId) return; + const p = await createSearchProgram(); + await runCommand(p, ["search", "delete", "--index-id", indexId]); +}); + +describe("search list", () => { + it("includes the created index", async () => { + const p = await createSearchProgram(); + const indexes = await runCommand(p, ["search", "list"]) as SearchIndex[]; + expect(Array.isArray(indexes)).toBe(true); + expect(indexes.some((idx) => idx.id === indexId)).toBe(true); + }); +}); + +describe("search get", () => { + it("returns the index by id", async () => { + const p = await createSearchProgram(); + const idx = await runCommand(p, ["search", "get", "--index-id", indexId!]) as SearchIndex; + expect(idx.id).toBe(indexId); + expect(idx.name).toBe(TEST_NAME); + }); +}); + +describe("search rename", () => { + it("updates the index name", async () => { + const newName = `${TEST_NAME}-renamed`; + const p = await createSearchProgram(); + await runCommand(p, ["search", "rename", "--index-id", indexId!, "--name", newName]); + + const p2 = await createSearchProgram(); + const idx = await runCommand(p2, ["search", "get", "--index-id", indexId!]) as SearchIndex; + expect(idx.name).toBe(newName); + + const p3 = await createSearchProgram(); + await runCommand(p3, ["search", "rename", "--index-id", indexId!, "--name", TEST_NAME]); + }); +}); + +describe("search index-stats", () => { + it("returns stats for the index", async () => { + const p = await createSearchProgram(); + const stats = await runCommand(p, ["search", "index-stats", "--index-id", indexId!]) as Record; + expect(stats).toBeDefined(); + expect(typeof stats).toBe("object"); + }); +}); + +describe("search delete dry-run", () => { + it("returns dry_run: true without deleting", async () => { + const p = await createSearchProgram(); + const result = await runCommand(p, ["search", "delete", "--index-id", indexId!, "--dry-run"]) as Record; + expect(result["dry_run"]).toBe(true); + expect(result["index_id"]).toBe(indexId); + }); +}); diff --git a/tests/integration/team.test.ts b/tests/integration/team.test.ts new file mode 100644 index 0000000..0c627f9 --- /dev/null +++ b/tests/integration/team.test.ts @@ -0,0 +1,49 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createTeamProgram, runCommand } from "../helpers/program.js"; +import type { Team, TeamMember } from "../../src/types.js"; + +const TEST_NAME = `cli-test-${Date.now()}`; +let teamId: string | undefined; + +beforeAll(async () => { + const p = await createTeamProgram(); + const team = await runCommand(p, ["team", "create", "--name", TEST_NAME]) as Team; + expect(team.team_id).toBeDefined(); + teamId = team.team_id; +}); + +afterAll(async () => { + if (!teamId) return; + const p = await createTeamProgram(); + await runCommand(p, ["team", "delete", "--team-id", teamId]); +}); + +describe("team list", () => { + it("includes the created team", async () => { + const p = await createTeamProgram(); + const teams = await runCommand(p, ["team", "list"]) as Team[]; + expect(Array.isArray(teams)).toBe(true); + expect(teams.some((t) => t.team_id === teamId)).toBe(true); + }); +}); + +describe("team members", () => { + it("returns the owner as a member", async () => { + const p = await createTeamProgram(); + const members = await runCommand(p, ["team", "members", "--team-id", teamId!]) as TeamMember[]; + expect(Array.isArray(members)).toBe(true); + expect(members.length).toBeGreaterThan(0); + const owner = members.find((m) => m.member_role === "owner"); + expect(owner).toBeDefined(); + expect(owner?.member_email).toBeDefined(); + }); +}); + +describe("team delete dry-run", () => { + it("returns dry_run: true without deleting", async () => { + const p = await createTeamProgram(); + const result = await runCommand(p, ["team", "delete", "--team-id", teamId!, "--dry-run"]) as Record; + expect(result["dry_run"]).toBe(true); + expect(result["team_id"]).toBe(teamId); + }); +}); diff --git a/tests/integration/vector.test.ts b/tests/integration/vector.test.ts new file mode 100644 index 0000000..8e4e981 --- /dev/null +++ b/tests/integration/vector.test.ts @@ -0,0 +1,79 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createVectorProgram, runCommand } from "../helpers/program.js"; +import type { VectorIndex } from "../../src/types.js"; + +const TEST_NAME = `cli-test-${Date.now()}`; +let indexId: string | undefined; + +beforeAll(async () => { + const p = await createVectorProgram(); + const idx = await runCommand(p, [ + "vector", "create", + "--name", TEST_NAME, + "--region", "us-east-1", + "--similarity-function", "COSINE", + "--dimension-count", "1536", + ]) as VectorIndex; + + expect(idx.id).toBeDefined(); + indexId = idx.id; +}); + +afterAll(async () => { + if (!indexId) return; + const p = await createVectorProgram(); + await runCommand(p, ["vector", "delete", "--index-id", indexId]); +}); + +describe("vector list", () => { + it("includes the created index", async () => { + const p = await createVectorProgram(); + const indexes = await runCommand(p, ["vector", "list"]) as VectorIndex[]; + expect(Array.isArray(indexes)).toBe(true); + expect(indexes.some((idx) => idx.id === indexId)).toBe(true); + }); +}); + +describe("vector get", () => { + it("returns the index by id", async () => { + const p = await createVectorProgram(); + const idx = await runCommand(p, ["vector", "get", "--index-id", indexId!]) as VectorIndex; + expect(idx.id).toBe(indexId); + expect(idx.name).toBe(TEST_NAME); + expect(idx.similarity_function).toBe("COSINE"); + expect(idx.dimension_count).toBe(1536); + }); +}); + +describe("vector rename", () => { + it("updates the index name", async () => { + const newName = `${TEST_NAME}-renamed`; + const p = await createVectorProgram(); + await runCommand(p, ["vector", "rename", "--index-id", indexId!, "--name", newName]); + + const p2 = await createVectorProgram(); + const idx = await runCommand(p2, ["vector", "get", "--index-id", indexId!]) as VectorIndex; + expect(idx.name).toBe(newName); + + const p3 = await createVectorProgram(); + await runCommand(p3, ["vector", "rename", "--index-id", indexId!, "--name", TEST_NAME]); + }); +}); + +describe("vector index-stats", () => { + it("returns stats for the index", async () => { + const p = await createVectorProgram(); + const stats = await runCommand(p, ["vector", "index-stats", "--index-id", indexId!]) as Record; + expect(stats).toBeDefined(); + expect(typeof stats).toBe("object"); + }); +}); + +describe("vector delete dry-run", () => { + it("returns dry_run: true without deleting", async () => { + const p = await createVectorProgram(); + const result = await runCommand(p, ["vector", "delete", "--index-id", indexId!, "--dry-run"]) as Record; + expect(result["dry_run"]).toBe(true); + expect(result["index_id"]).toBe(indexId); + }); +}); diff --git a/tests/unit/auth.test.ts b/tests/unit/auth.test.ts new file mode 100644 index 0000000..a6d8ccb --- /dev/null +++ b/tests/unit/auth.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, statSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Command } from "commander"; +import { readConfig, writeConfig, deleteConfig, getConfigPath } from "../../src/config.js"; +import { resolveAuth } from "../../src/auth.js"; +import { registerLogin } from "../../src/commands/login.js"; +import { registerLogout } from "../../src/commands/logout.js"; + +let dir: string; +const originalEnv = { ...process.env }; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "upstash-cli-test-")); + process.env.UPSTASH_CONFIG_HOME = dir; + delete process.env.UPSTASH_EMAIL; + delete process.env.UPSTASH_API_KEY; +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + process.env = { ...originalEnv }; +}); + +describe("config file round-trip", () => { + it("returns null when the file is missing", () => { + expect(readConfig()).toBeNull(); + }); + + it("writes with 0600 perms and reads back", () => { + const path = writeConfig({ email: "a@b.com", apiKey: "key-1" }); + expect(path).toBe(getConfigPath()); + expect(readConfig()).toEqual({ email: "a@b.com", apiKey: "key-1" }); + if (process.platform !== "win32") { + const mode = statSync(path).mode & 0o777; + expect(mode).toBe(0o600); + } + const raw = JSON.parse(readFileSync(path, "utf8")) as Record; + expect(raw).toEqual({ email: "a@b.com", api_key: "key-1" }); + }); + + it("deleteConfig reports whether it removed a file", () => { + expect(deleteConfig()).toBe(false); + writeConfig({ email: "a@b.com", apiKey: "key-1" }); + expect(deleteConfig()).toBe(true); + expect(existsSync(getConfigPath())).toBe(false); + }); +}); + +describe("resolveAuth precedence", () => { + it("throws when nothing is configured and mentions `upstash login`", () => { + expect(() => resolveAuth({})).toThrow(/upstash login/); + }); + + it("falls back to the saved config file", () => { + writeConfig({ email: "file@b.com", apiKey: "file-key" }); + expect(resolveAuth({})).toEqual({ email: "file@b.com", apiKey: "file-key" }); + }); + + it("env vars beat the saved config file", () => { + writeConfig({ email: "file@b.com", apiKey: "file-key" }); + process.env.UPSTASH_EMAIL = "env@b.com"; + process.env.UPSTASH_API_KEY = "env-key"; + expect(resolveAuth({})).toEqual({ email: "env@b.com", apiKey: "env-key" }); + }); + + it("refuses to mix a partial session tier with the saved config", () => { + writeConfig({ email: "file@b.com", apiKey: "file-key" }); + process.env.UPSTASH_EMAIL = "env@b.com"; + // no UPSTASH_API_KEY — session tier is partial, must not silently borrow from config + expect(() => resolveAuth({})).toThrow(/incomplete/); + }); + + it("flags beat env vars and the saved config file", () => { + writeConfig({ email: "file@b.com", apiKey: "file-key" }); + process.env.UPSTASH_EMAIL = "env@b.com"; + process.env.UPSTASH_API_KEY = "env-key"; + expect(resolveAuth({ email: "flag@b.com", apiKey: "flag-key" })).toEqual({ + email: "flag@b.com", + apiKey: "flag-key", + }); + }); +}); + +describe("login / logout commands (flag form)", () => { + function authProgram(): Command { + return new Command() + .exitOverride() + .option("--email ", "Upstash email (overrides UPSTASH_EMAIL)") + .option("--api-key ", "Upstash API key (overrides UPSTASH_API_KEY)") + .configureOutput({ writeOut: () => {}, writeErr: () => {} }); + } + + async function captureStdout(program: Command, argv: string[]): Promise { + const lines: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => lines.push(args.join(" ")); + try { + await program.parseAsync(["node", "upstash", ...argv]); + } finally { + console.log = origLog; + } + return lines.join("\n"); + } + + it("login --email --api-key verifies, then writes the config file and reports the path", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("[]", { status: 200 }), + ); + try { + const p = authProgram(); + registerLogin(p); + const output = await captureStdout(p, ["login", "--email", "cli@b.com", "--api-key", "cli-key"]); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(output).toBe(`Credentials verified and saved to ${getConfigPath()}`); + expect(readConfig()).toEqual({ email: "cli@b.com", apiKey: "cli-key" }); + } finally { + fetchSpy.mockRestore(); + } + }); + + it("login rejects invalid credentials without writing the config file", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response('{"error":"unauthorized"}', { status: 401 }), + ); + try { + const p = authProgram(); + registerLogin(p); + await expect( + captureStdout(p, ["login", "--email", "bad@b.com", "--api-key", "bad-key"]), + ).rejects.toThrow(/Authentication failed/); + expect(readConfig()).toBeNull(); + } finally { + fetchSpy.mockRestore(); + } + }); + + it("logout removes the saved file and reports the path", async () => { + writeConfig({ email: "a@b.com", apiKey: "key-1" }); + const p = authProgram(); + registerLogout(p); + const output = await captureStdout(p, ["logout"]); + expect(output).toBe(`Removed credentials at ${getConfigPath()}`); + expect(readConfig()).toBeNull(); + }); + + it("logout on a clean machine says so without erroring", async () => { + const p = authProgram(); + registerLogout(p); + const output = await captureStdout(p, ["logout"]); + expect(output).toBe(`No saved credentials at ${getConfigPath()}`); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..625b1a0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true + }, + "include": ["src"] +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..508201b --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src", "tests"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..3decdb5 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + pool: "forks", + testTimeout: 30000, + hookTimeout: 30000, + envDir: ".", + fileParallelism: false, + }, + resolve: { conditions: ["node", "import"] }, +});