diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..094db44 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node: [22, 24] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm test + - run: node dist/cli.js --version diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e640572 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,12 @@ +# perspective-cli — agent notes + +- TypeScript, ESM, Node ≥ 22.12. Runtime dependency: `commander` only. Keep it that way. +- Scope: a wrapper for the External API (`/v1`, `x-perspective-api-key`) and nothing else. + The MCP server is a separate product for chat assistants; don't add MCP or OAuth here. +- Contract that must not break: stdout is JSON only (prompts and other human text go to + stderr); errors are `CliError` → JSON on stderr; exit codes in `src/errors.ts` are never + renumbered. +- Every command goes through `action()` in `src/command.ts`. +- Changing a command means updating `skills/perspective/SKILL.md`, `README.md`, and the + developer docs (`developer-docs/docs/cli/`). +- Verify with `npm run typecheck`, `npm test` and `npm run build`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fded5dc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +See [AGENTS.md](./AGENTS.md). diff --git a/README.md b/README.md new file mode 100644 index 0000000..808ad44 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# Perspective CLI + +`perspective` drives [Perspective](https://perspective.co) from the shell. It wraps the +[External API](https://developers.perspective.co/api-reference/overview) and is built to be +driven by both people and coding agents: output is JSON, errors are JSON, and exit codes are +stable. + +Full docs: + +## Install + +Requires Node.js ≥ 22.12. + +```bash +npm install -g github:Perspective-Software/perspective-cli +perspective --help +``` + +## Authenticate + +The CLI uses an API key from Perspective → Settings → API Keys. + +```bash +perspective auth login # prompts for the key, validates it, stores it +printf %s "$KEY" | perspective auth login # or pipe it in +perspective auth status +``` + +The key is stored in `~/.config/perspective/credentials.json` (mode 0600). In CI, set +`PERSPECTIVE_API_KEY` instead. Precedence is `--api-key` flag → env → file. + +## Use + +```bash +perspective funnels list --status live +perspective contacts list --all +perspective contacts list --all --fields id,email,status +perspective contacts create --email ada@example.com --first-name Ada --dry-run +perspective contacts create --email ada@example.com --first-name Ada +perspective metrics kpi conversion_rate --days 7 +perspective metrics kpi all --fields type,value,count +perspective metrics chart visitor_devices --from 2026-09-01 --to 2026-10-01 +``` + +Output is pretty-printed at a terminal and compact when piped (`--pretty` / `--no-pretty`). +`--fields a,b,c.d` keeps only those fields of each result. `--dry-run` on `contacts create` +and `contacts set` prints the request instead of sending it. On HTTP 429 the CLI waits +(`Retry-After`, at most a minute) and retries up to 3 times. + +### Exit codes + +| Code | Meaning | +|---|---| +| 0 | success | +| 1 | API error | +| 2 | usage error (bad flags/arguments, HTTP 400) | +| 3 | not authenticated (HTTP 401) | +| 4 | forbidden (HTTP 403) | +| 5 | not found (HTTP 404) | +| 6 | rate limited (HTTP 429, 100 requests/minute) | +| 7 | network error | + +Errors go to stderr as `{"error":{"code","message","status?","hint?"}}`. + +## Agents + +[`skills/perspective/SKILL.md`](skills/perspective/SKILL.md) teaches coding agents (Claude Code and +anything else that reads Agent Skills) how to use the CLI safely. Install it with: + +```bash +perspective skill install # ~/.claude/skills/perspective/SKILL.md +perspective skill install --project # ./.claude/skills/perspective/SKILL.md +perspective skill show # print it (JSON; | jq -r .content) +``` + +## Development + +```bash +npm install # also builds dist/ via `prepare` +npm test +npm run typecheck +npm run dev -- funnels list # run from source (Node's type stripping) +perspective --base-url http://localhost:3000 … # point at a local External API +``` + +Layout: `src/cli.ts` (entry) · `src/commands/*` (one file per command group) · `src/rest.ts` +(API client) · `src/context.ts` (credential resolution) · `skills/perspective/SKILL.md`. + +When you add or change a command, update `SKILL.md` and the docs in +`Perspective-Software/developer-docs` (`docs/cli/`) in the same change. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2ab8baa --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1563 @@ +{ + "name": "@perspective-software/cli", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@perspective-software/cli", + "version": "0.1.0", + "license": "UNLICENSED", + "dependencies": { + "commander": "^15.0.0" + }, + "bin": { + "perspective": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "typescript": "^7.0.2", + "vitest": "^5.0.5" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "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", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "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", + "peer": true, + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d066980 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "@perspective-software/cli", + "version": "0.1.0", + "description": "Command-line interface for the Perspective External API", + "private": true, + "license": "UNLICENSED", + "type": "module", + "bin": { + "perspective": "dist/cli.js" + }, + "files": [ + "dist", + "skills" + ], + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "dev": "node --experimental-strip-types src/cli.ts", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "prepare": "npm run build" + }, + "dependencies": { + "commander": "^15.0.0" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "typescript": "^7.0.2", + "vitest": "^5.0.5" + } +} diff --git a/skills/perspective/SKILL.md b/skills/perspective/SKILL.md new file mode 100644 index 0000000..f39e6ba --- /dev/null +++ b/skills/perspective/SKILL.md @@ -0,0 +1,108 @@ +--- +name: perspective +description: Read and write a Perspective account (perspective.co funnel builder) from the shell with the `perspective` CLI, a wrapper for the Perspective External API — list workspaces and funnels, read and write CRM contacts, and pull funnel KPIs, charts and question insights. Use when the user asks about their Perspective funnels, leads/contacts or conversion metrics, or wants to script Perspective. +--- + +# Perspective CLI + +`perspective` wraps the Perspective External API (`https://api.perspective.co/v1`). +Every command prints **JSON on stdout**; errors print **JSON on stderr** with a +non-zero exit code. Pipe into `jq` freely. + +Docs: https://developers.perspective.co/cli/overview + +## 0. Check setup first + +```bash +perspective auth status +``` + +If `authenticated` is `false`, every command that calls the API fails with exit code 3 +(`skill show` and `skill install` work without a key). Ask the +user for an API key (Perspective → Settings → API Keys, admins only). They can run +`perspective auth login` themselves (it prompts for the key), or you can pipe it in: +`printf %s "$KEY" | perspective auth login`. `PERSPECTIVE_API_KEY` works too. + +Never print, log or commit an API key. + +## 1. Find IDs + +Almost everything takes a `funnelId`. Get it first: + +```bash +perspective funnels list --name "webinar" # flat list: id, name, status (draft|live|offline), workspaceId +perspective workspaces list # nested: workspaces → campaigns +``` + +## 2. Contacts + +```bash +perspective contacts list --limit 100 --sort email --order asc +perspective contacts list --all # every page, {data, meta.total} +perspective contacts get +perspective contacts create --email a@b.co --first-name Ada -s address.city=Berlin +perspective contacts set status qualified # any field or custom property +``` + +Pagination is page-based: read `meta.hasNext`, bump `--page`, or just use `--all`. +The CLI waits and retries by itself when it hits the rate limit. +`-s key=value` values are parsed as JSON when valid (`-s score=5`); dots nest (`-s address.city=Berlin`). + +## 3. Metrics + +The default window is the last 30 days in local time. + +```bash +perspective metrics kpi conversion_rate +perspective metrics kpi conversion_rate total_sessions # several → {data: [...]}, each with its type +perspective metrics kpi all --fields type,value,count # every KPI in one call +perspective metrics kpi new_contacts --from 2026-09-01 --to 2026-10-01 +perspective metrics chart page_to_page_conversion_rate --ab-test variant +perspective metrics insight question_1234 --days 7 +perspective metrics subtypes # valid kpi/chart names +``` + +What a KPI's `value` means: + +| KPI | `value` | `count` | +|---|---|---| +| `conversion_rate`, `completion_rate` | % of sessions (0–100) | conversions / completions | +| `messages_delivery_rate`, `messages_open_rate` | % of messages (0–100) | the events behind the rate | +| `average_time_on_page` | seconds (average over pages of each page's median) | — | +| `time_to_completion` | seconds, median from first page to completion | — | +| `new_contacts`, `total_sessions`, `messages_sent` | count | — | + +A rate on a handful of sessions means little: before you rank or compare funnels, +fetch `total_sessions` with it (`kpi conversion_rate total_sessions`) and +mention low sample sizes. `metrics insight` needs the question's element ID from +the funnel builder; there is no way to list them, so ask the user. An unknown +ID returns an empty result, not an error. + +## Keep output small + +Contact lists can be large. Ask only for the fields you need — `--fields` works on +every command, keeps `meta`, and dots reach into nested objects: + +```bash +perspective contacts list --all --fields id,email,status,properties.city +perspective funnels list --status live --fields id,name +``` + +## Exit codes + +| Code | `error.code` | Meaning / what to do | +|---|---|---| +| 0 | — | Success | +| 1 | `error` | API error — read `error.message` | +| 2 | `usage` | Bad arguments or 400 from the API — fix the call (see `error.hint`) | +| 3 | `unauthenticated` | Missing/invalid API key — see step 0 | +| 4 | `forbidden` | Funnel ID wrong or in another workspace (re-check with `funnels list`), or the key lacks a permission (`crm:read`, `crm:write`, `metrics:read`) | +| 5 | `not_found` | Wrong funnel or contact ID — re-discover IDs | +| 6 | `rate_limited` | 100 requests/minute; the CLI already retried — wait a minute; prefer `--all` over many small calls | +| 7 | `network` | API unreachable | + +## Rules for agents + +- **Read before you write.** `contacts create` and `contacts set` act on a live account and can trigger automations and emails to real people. Run them with `--dry-run` first, show the user the printed request, and send it only after they confirm — unless they explicitly asked for that exact change. Pass `--skip-automation` to either command when the user wants data without side effects. +- Dates are ISO 8601. Metrics windows: `--from/--to`, or `--days N` (default 30). +- Global flags: `--fields`, `--base-url`, `--api-key`, `--pretty`/`--no-pretty`. diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..15da2e7 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,58 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs'; +import { Command, CommanderError } from 'commander'; +import { registerAuth } from './commands/auth.ts'; +import { registerContacts } from './commands/contacts.ts'; +import { registerMetrics } from './commands/metrics.ts'; +import { registerSkill } from './commands/skill.ts'; +import { registerWorkspaces } from './commands/workspaces.ts'; +import { CliError, ExitCode } from './errors.ts'; +import { printError } from './output.ts'; + +const { version } = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +) as { version: string }; + +function buildProgram(): Command { + const program = new Command('perspective') + .description( + 'Perspective from the command line. Output is JSON on stdout; errors are JSON on stderr.\n' + + 'Docs: https://developers.perspective.co/cli/overview', + ) + .version(version) + .option('--base-url ', 'API origin (env PERSPECTIVE_BASE_URL)', undefined) + .option('--api-key ', 'API key (env PERSPECTIVE_API_KEY)') + .option('--fields ', 'only these fields of each result, comma-separated; dots nest (id,email,properties.city)') + .option('--pretty', 'indent JSON output (default when stdout is a terminal)') + .option('--no-pretty', 'compact JSON output (default when piped)') + // Parse errors (unknown option, missing argument, invalid choice) are printed as JSON + // in the catch below; commander's own text would break the stderr contract. + .configureOutput({ outputError: () => {} }) + .exitOverride(); + + registerAuth(program); + registerWorkspaces(program); + registerContacts(program); + registerMetrics(program); + registerSkill(program); + return program; +} + +try { + await buildProgram().parseAsync(process.argv); +} catch (err) { + if (err instanceof CommanderError) { + if (err.exitCode === 0 || err.code === 'commander.help') { + // Help or version text, already printed by commander. + process.exitCode = err.exitCode === 0 ? ExitCode.OK : ExitCode.USAGE; + } else { + process.exitCode = printError( + new CliError('usage', err.message.replace(/^error: /, '').replace(/\s*\n\s*/g, ' '), { + hint: 'Run the command with --help to see its arguments and options.', + }), + ); + } + } else { + process.exitCode = printError(err); + } +} diff --git a/src/command.ts b/src/command.ts new file mode 100644 index 0000000..a99cce7 --- /dev/null +++ b/src/command.ts @@ -0,0 +1,24 @@ +import type { Command } from 'commander'; +import { Context, type GlobalOptions } from './context.ts'; +import { printError, printResult, selectFields } from './output.ts'; + +type Handler = (ctx: Context, ...args: any[]) => Promise; + +/** + * Wraps a command handler: builds the Context from global flags, prints the + * returned value as JSON on stdout, and turns any error into a JSON error on + * stderr plus a documented exit code. + */ +export function action(handler: Handler) { + return async (...args: unknown[]): Promise => { + const command = args[args.length - 1] as Command; + const globals = command.optsWithGlobals(); + const ctx = new Context(globals); + try { + const result = await handler(ctx, ...args.slice(0, -1)); + printResult(globals.fields ? selectFields(result, globals.fields) : result, { pretty: globals.pretty }); + } catch (err) { + process.exitCode = printError(err, { pretty: globals.pretty }); + } + }; +} diff --git a/src/commands/auth.ts b/src/commands/auth.ts new file mode 100644 index 0000000..d3e24d1 --- /dev/null +++ b/src/commands/auth.ts @@ -0,0 +1,111 @@ +import type { Command } from 'commander'; +import { action } from '../command.ts'; +import { clearConfig, configPath, saveConfig } from '../config.ts'; +import { CliError } from '../errors.ts'; +import { readTextArg } from '../input.ts'; +import { RestClient } from '../rest.ts'; + +export function mask(secret: string): string { + return secret.length <= 8 ? '••••' : `${secret.slice(0, 4)}…${secret.slice(-4)}`; +} + +/** + * Prompts on stderr without echoing what is typed. Reads the terminal in raw + * mode: readline would redraw the line and wipe the prompt. + */ +function promptSecret(question: string): Promise { + const stdin = process.stdin; + process.stderr.write(question); + stdin.setRawMode(true); + stdin.setEncoding('utf8'); + stdin.resume(); + return new Promise((resolve) => { + let value = ''; + const finish = () => { + stdin.off('data', onData); + stdin.setRawMode(false); + stdin.pause(); + process.stderr.write('\n'); + }; + const onData = (chunk: string) => { + for (const char of chunk) { + if (char === '\r' || char === '\n' || char === '\u0004') { + finish(); + resolve(value); + return; + } + if (char === '\u0003') { + // Ctrl+C: raw mode swallows SIGINT, so exit the way the shell expects. + finish(); + process.exit(130); + } + if (char === '\u007f' || char === '\b') value = value.slice(0, -1); + else value += char; + } + }; + stdin.on('data', onData); + }); +} + +export function registerAuth(program: Command): void { + const auth = program.command('auth').description('Store, inspect and clear your API key'); + + auth.command('login') + .description( + 'Store an API key (Settings → API Keys). Prompts for it on a terminal, reads it from stdin ' + + 'when piped, or takes it from --api-key. The key is validated before it is saved.', + ) + .action( + action(async (ctx) => { + let raw = ctx.opts.apiKey; + if (raw === undefined && process.stdin.isTTY) { + process.stderr.write( + 'Paste your Perspective API key (create one under Settings → API Keys in the app).\n' + + 'Input is hidden.\n\n', + ); + raw = await promptSecret('API key: '); + } + raw ??= await readTextArg('-'); + const key = raw.trim(); + if (!key) throw new CliError('usage', 'Empty API key'); + // Validate before storing: a cheap authenticated read. + await new RestClient(await ctx.baseUrl(), key).request({ path: '/workspaces' }); + const config = await ctx.stored(); + config.apiKey = key; + await saveConfig(config, ctx.env); + return { ok: true, apiKey: mask(key), configPath: configPath(ctx.env) }; + }), + ); + + auth.command('status') + .description('Show whether an API key is configured and where it comes from (no network calls)') + .action( + action(async (ctx) => { + const config = await ctx.stored(); + const source = ctx.opts.apiKey + ? 'flag' + : ctx.env.PERSPECTIVE_API_KEY + ? 'env' + : config.apiKey + ? 'config' + : null; + const key = ctx.opts.apiKey ?? ctx.env.PERSPECTIVE_API_KEY ?? config.apiKey; + return { + authenticated: Boolean(key), + source, + apiKey: key ? mask(key) : null, + baseUrl: await ctx.baseUrl(), + configPath: configPath(ctx.env), + }; + }), + ); + + auth.command('logout') + .description('Delete the stored API key (environment variables are not affected)') + .action( + action(async (ctx) => { + await clearConfig(ctx.env); + return { ok: true, removed: configPath(ctx.env) }; + }), + ); +} diff --git a/src/commands/contacts.ts b/src/commands/contacts.ts new file mode 100644 index 0000000..73f086f --- /dev/null +++ b/src/commands/contacts.ts @@ -0,0 +1,172 @@ +import { Option, type Command } from 'commander'; +import { action } from '../command.ts'; +import { usage } from '../errors.ts'; +import { applyAssignments, collect, parseInteger, readJsonObject } from '../input.ts'; +import type { Context } from '../context.ts'; +import { RestClient, type RestRequest } from '../rest.ts'; + +const SORT_FIELDS = ['ps_converted_at', 'email', 'firstName', 'lastName']; +/** Server-side cap on `limit`. */ +const MAX_LIMIT = 100; + +/** Sends a write, or with --dry-run returns what would be sent. A dry run needs no API key. */ +async function send(ctx: Context, dryRun: boolean | undefined, req: RestRequest): Promise { + if (dryRun) return { dryRun: true, request: new RestClient(await ctx.baseUrl(), '').describe(req) }; + return new RestClient(await ctx.baseUrl(), await ctx.apiKey()).request(req); +} + +interface ContactsPage { + data: unknown[]; + meta: { total: number; page: number; limit: number; hasNext: boolean }; +} + +export function registerContacts(program: Command): void { + const contacts = program.command('contacts').description("A funnel's CRM contacts"); + + contacts + .command('list') + .description('List contacts of a funnel, newest first') + .argument('') + .option('--page ', 'page number, 0-based', parseInteger('page', 0), 0) + .option('--limit ', `page size, 1–${MAX_LIMIT}`, parseInteger('limit', 1, MAX_LIMIT), 50) + .addOption(new Option('--sort ', 'sort field').choices(SORT_FIELDS).default('ps_converted_at')) + .addOption(new Option('--order ', 'sort direction').choices(['asc', 'desc']).default('desc')) + .option('--all', 'follow pages until the end and return every contact') + .action( + action( + async ( + ctx, + funnelId: string, + opts: { page: number; limit: number; sort: string; order: string; all?: boolean }, + ) => { + const rest = new RestClient(await ctx.baseUrl(), await ctx.apiKey()); + const fetchPage = (page: number, limit: number) => + rest.request({ + path: `/funnels/${encodeURIComponent(funnelId)}/contacts`, + query: { + page, + limit, + sortField: opts.sort, + sortOrder: opts.order === 'asc' ? 1 : -1, + }, + }); + + if (!opts.all) return fetchPage(opts.page, opts.limit); + + const all: unknown[] = []; + let page = 0; + let result: ContactsPage; + do { + result = await fetchPage(page++, MAX_LIMIT); + all.push(...result.data); + } while (result.meta.hasNext); + return { data: all, meta: { total: result.meta.total } }; + }, + ), + ); + + contacts + .command('get') + .description('Get one contact') + .argument('') + .argument('') + .action( + action(async (ctx, funnelId: string, contactId: string) => { + const rest = new RestClient(await ctx.baseUrl(), await ctx.apiKey()); + return rest.request({ + path: `/funnels/${encodeURIComponent(funnelId)}/contacts/${encodeURIComponent(contactId)}`, + }); + }), + ); + + contacts + .command('create') + .description('Create a contact. Needs --email or --phone.') + .argument('') + .option('--email ') + .option('--phone ') + .option('--first-name ') + .option('--last-name ') + .option('--status ', 'one of the statuses configured for the funnel') + .option('--website ') + .option('--birthday ') + .option('-s, --set ', 'any body field, repeatable; dots nest (address.city=Berlin)', collect, []) + .option('--data ', 'full request body as JSON, @file or - for stdin; flags override it') + .option('--skip-automation', 'do not trigger automations for the initial status') + .option('--dry-run', 'print the request instead of sending it') + .action( + action( + async ( + ctx, + funnelId: string, + opts: { + email?: string; + phone?: string; + firstName?: string; + lastName?: string; + status?: string; + website?: string; + birthday?: string; + set: string[]; + data?: string; + skipAutomation?: boolean; + dryRun?: boolean; + }, + ) => { + const body = opts.data ? await readJsonObject(opts.data, '--data') : {}; + const flags = { + email: opts.email, + phone: opts.phone, + firstName: opts.firstName, + lastName: opts.lastName, + status: opts.status, + website: opts.website, + birthday: opts.birthday, + }; + for (const [key, value] of Object.entries(flags)) if (value !== undefined) body[key] = value; + applyAssignments(body, opts.set); + if (opts.skipAutomation) body.skipAutomationTrigger = true; + if (!body.email && !body.phone) throw usage('A contact needs --email or --phone'); + + return send(ctx, opts.dryRun, { + method: 'POST', + path: `/funnels/${encodeURIComponent(funnelId)}/contacts`, + body, + }); + }, + ), + ); + + contacts + .command('set') + .description( + 'Set one field on a contact. Standard fields (firstName, email, status, city, …) or any custom property name.', + ) + .argument('') + .argument('') + .argument('') + .argument('') + .option('--skip-automation', 'do not trigger automations for this change') + .option('--dry-run', 'print the request instead of sending it') + .action( + action( + async ( + ctx, + funnelId: string, + contactId: string, + field: string, + value: string, + opts: { skipAutomation?: boolean; dryRun?: boolean }, + ) => + send(ctx, opts.dryRun, { + method: 'PUT', + path: `/funnels/${encodeURIComponent(funnelId)}/contacts/${encodeURIComponent(contactId)}/values`, + body: { + fieldName: field, + value, + ...(opts.skipAutomation && { skipAutomationTrigger: true }), + }, + }), + ), + ); +} diff --git a/src/commands/metrics.ts b/src/commands/metrics.ts new file mode 100644 index 0000000..758189e --- /dev/null +++ b/src/commands/metrics.ts @@ -0,0 +1,157 @@ +import { Option, type Command } from 'commander'; +import { action } from '../command.ts'; +import { usage } from '../errors.ts'; +import { parseInteger } from '../input.ts'; +import { RestClient } from '../rest.ts'; + +export const KPI_SUBTYPES = [ + 'kpi_conversion_rate', + 'kpi_completion_rate', + 'kpi_average_time_on_page', + 'kpi_time_to_completion', + 'kpi_new_contacts', + 'kpi_total_sessions', + 'kpi_messages_sent', + 'kpi_messages_delivery_rate', + 'kpi_messages_open_rate', +]; + +export const CHART_SUBTYPES = [ + 'chart_page_to_page_conversion_rate', + 'chart_activity_by_daytime', + 'chart_contacts_over_time', + 'chart_visitor_devices', + 'chart_top_utm_sources', + 'chart_time_on_page', + 'chart_button_clicks', +]; + +/** Accepts `conversion_rate`, `conversion-rate` or `kpi_conversion_rate`. */ +export function normalizeSubtype(raw: string, prefix: 'kpi' | 'chart', valid: string[]): string { + const snake = raw.trim().toLowerCase().replace(/-/g, '_'); + const full = snake.startsWith(`${prefix}_`) ? snake : `${prefix}_${snake}`; + if (!valid.includes(full)) { + throw usage(`Unknown ${prefix} subtype "${raw}"`, `Valid: ${valid.join(', ')}`); + } + return full; +} + +/** Resolves KPI names (`all` = every KPI), keeping the given order and dropping repeats. */ +export function resolveKpis(raw: string[]): string[] { + const names = raw.flatMap((name) => (name.trim().toLowerCase() === 'all' ? KPI_SUBTYPES : [name])); + return [...new Set(names.map((name) => normalizeSubtype(name, 'kpi', KPI_SUBTYPES)))]; +} + +interface WindowOptions { + from?: string; + to?: string; + days: number; + tzOffset?: number; +} + +function parseDate(name: string, value: string): Date { + const date = new Date(value); + if (Number.isNaN(date.getTime())) throw usage(`--${name} is not a valid date: "${value}"`); + return date; +} + +/** `--from/--to` win; otherwise the window is the last `--days` days ending now. */ +export function resolveWindow(opts: WindowOptions, now = new Date()) { + const to = opts.to ? parseDate('to', opts.to) : now; + const from = opts.from ? parseDate('from', opts.from) : new Date(to.getTime() - opts.days * 86_400_000); + if (from >= to) throw usage('--from must be before --to'); + // The API wants minutes east of UTC (UTC+2 → "120"); getTimezoneOffset is minutes west. + const offset = String(opts.tzOffset ?? -now.getTimezoneOffset()); + return { from: from.toISOString(), to: to.toISOString(), offset }; +} + +function withWindow(cmd: Command): Command { + return cmd + .option('--from ', 'start of the period (ISO 8601)') + .option('--to ', 'end of the period (ISO 8601), default now') + .option('--days ', 'period length when --from is not given', parseInteger('days', 1, 3650), 30) + .option( + '--tz-offset ', + 'timezone offset in minutes east of UTC, default: local timezone', + parseInteger('tz-offset'), + ); +} + +export function registerMetrics(program: Command): void { + const metrics = program + .command('metrics') + .description('Funnel analytics: KPIs, charts and question insights'); + + withWindow( + metrics + .command('kpi') + .description( + 'KPIs for a funnel, e.g. conversion_rate total_sessions, or `all`. One KPI prints the API response ' + + 'as-is; several print {data: [...]} in the order given, each with its `type`.', + ) + .argument('') + .argument(''), + ).action( + action(async (ctx, funnelId: string, subtypes: string[], opts: WindowOptions) => { + const kpis = resolveKpis(subtypes); + const query = resolveWindow(opts); + const rest = new RestClient(await ctx.baseUrl(), await ctx.apiKey()); + const fetchKpi = (kpi: string) => + rest.request<{ data: unknown }>({ + path: `/funnels/${encodeURIComponent(funnelId)}/metrics/kpis/${kpi}`, + query, + }); + if (kpis.length === 1) return fetchKpi(kpis[0]!); + const results = await Promise.all(kpis.map(fetchKpi)); + return { data: results.map((result) => result.data) }; + }), + ); + + withWindow( + metrics + .command('chart') + .description('Chart data for a funnel, e.g. page_to_page_conversion_rate, visitor_devices') + .argument('') + .argument('') + .addOption( + new Option('--ab-test ', 'A/B filter (page_to_page_conversion_rate only)').choices([ + 'all', + 'original', + 'variant', + ]), + ), + ).action( + action(async (ctx, funnelId: string, subtype: string, opts: WindowOptions & { abTest?: string }) => { + const chart = normalizeSubtype(subtype, 'chart', CHART_SUBTYPES); + if (opts.abTest && chart !== 'chart_page_to_page_conversion_rate') { + throw usage('--ab-test only works with page_to_page_conversion_rate'); + } + const rest = new RestClient(await ctx.baseUrl(), await ctx.apiKey()); + return rest.request({ + path: `/funnels/${encodeURIComponent(funnelId)}/metrics/charts/${chart}`, + query: { ...resolveWindow(opts), abTest: opts.abTest }, + }); + }), + ); + + withWindow( + metrics + .command('insight') + .description('Answers to one question/survey element (insightId as shown in the builder, e.g. question_1234)') + .argument('') + .argument(''), + ).action( + action(async (ctx, funnelId: string, insightId: string, opts: WindowOptions) => { + const rest = new RestClient(await ctx.baseUrl(), await ctx.apiKey()); + return rest.request({ + path: `/funnels/${encodeURIComponent(funnelId)}/metrics/insights/${encodeURIComponent(insightId)}`, + query: resolveWindow(opts), + }); + }), + ); + + metrics + .command('subtypes') + .description('List valid KPI and chart subtypes (offline)') + .action(action(async () => ({ kpi: KPI_SUBTYPES, chart: CHART_SUBTYPES }))); +} diff --git a/src/commands/skill.ts b/src/commands/skill.ts new file mode 100644 index 0000000..c64265f --- /dev/null +++ b/src/commands/skill.ts @@ -0,0 +1,45 @@ +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Command } from 'commander'; +import { action } from '../command.ts'; + +/** Same relative path from src/commands and dist/commands. */ +export const SKILL_SOURCE = fileURLToPath(new URL('../../skills/perspective/SKILL.md', import.meta.url)); + +export function registerSkill(program: Command): void { + const skill = program.command('skill').description('The agent skill (SKILL.md) that teaches coding agents to use this CLI'); + + skill + .command('show') + .description('Print SKILL.md as {path, content}; `| jq -r .content` gives the markdown') + .action(action(async () => ({ path: SKILL_SOURCE, content: await readFile(SKILL_SOURCE, 'utf8') }))); + + skill + .command('install') + .description('Install the skill for Claude Code: ~/.claude/skills/perspective (or ./.claude/skills with --project)') + .option('--project', 'install into the current project instead of your user directory') + .option('--dir ', 'install into /perspective/SKILL.md (any skills directory)') + .action( + action(async (_ctx, opts: { project?: boolean; dir?: string }) => { + const root = opts.dir + ? resolve(opts.dir) + : opts.project + ? resolve('.claude', 'skills') + : join(homedir(), '.claude', 'skills'); + const target = join(root, 'perspective', 'SKILL.md'); + await mkdir(join(root, 'perspective'), { recursive: true }); + // Write a temp file and rename it over the target, so a failed write never leaves half a skill. + const temp = `${target}.${process.pid}.tmp`; + try { + await writeFile(temp, await readFile(SKILL_SOURCE, 'utf8')); + await rename(temp, target); + } catch (err) { + await rm(temp, { force: true }); + throw err; + } + return { ok: true, installed: target }; + }), + ); +} diff --git a/src/commands/workspaces.ts b/src/commands/workspaces.ts new file mode 100644 index 0000000..937b252 --- /dev/null +++ b/src/commands/workspaces.ts @@ -0,0 +1,51 @@ +import { Option, type Command } from 'commander'; +import { action } from '../command.ts'; +import { RestClient } from '../rest.ts'; + +interface Workspace { + id: string; + name: string; + campaigns: { id: string; name: string; status: string }[]; +} + +export function flattenFunnels(workspaces: Workspace[]) { + return workspaces.flatMap((ws) => + ws.campaigns.map((c) => ({ ...c, workspaceId: ws.id, workspaceName: ws.name })), + ); +} + +export function registerWorkspaces(program: Command): void { + program + .command('workspaces') + .description('Workspaces and the funnels in them') + .command('list') + .description('List workspaces with their funnels (campaigns)') + .action( + action(async (ctx) => { + const rest = new RestClient(await ctx.baseUrl(), await ctx.apiKey()); + return rest.request({ path: '/workspaces' }); + }), + ); + + program + .command('funnels') + .description('Funnels across all workspaces') + .command('list') + .description('List every funnel as a flat list — the quickest way to find a funnel ID') + .addOption(new Option('--status ', 'only funnels with this status').choices(['draft', 'live', 'offline'])) + .option('--name ', 'only funnels whose name contains this text (case-insensitive)') + .action( + action(async (ctx, opts: { status?: string; name?: string }) => { + const rest = new RestClient(await ctx.baseUrl(), await ctx.apiKey()); + const { data } = await rest.request<{ data: Workspace[] }>({ path: '/workspaces' }); + const needle = opts.name?.toLowerCase(); + return { + data: flattenFunnels(data).filter( + (f) => + (!opts.status || f.status === opts.status) && + (!needle || f.name.toLowerCase().includes(needle)), + ), + }; + }), + ); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..efa6287 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,60 @@ +import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { CliError } from './errors.ts'; + +export const DEFAULT_BASE_URL = 'https://api.perspective.co'; + +export interface StoredConfig { + baseUrl?: string; + apiKey?: string; +} + +export function configDir(env: NodeJS.ProcessEnv = process.env): string { + if (env.PERSPECTIVE_CONFIG_DIR) return env.PERSPECTIVE_CONFIG_DIR; + const xdg = env.XDG_CONFIG_HOME || join(homedir(), '.config'); + return join(xdg, 'perspective'); +} + +export function configPath(env: NodeJS.ProcessEnv = process.env): string { + return join(configDir(env), 'credentials.json'); +} + +export async function loadConfig(env: NodeJS.ProcessEnv = process.env): Promise { + const path = configPath(env); + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}; + throw err; + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = undefined; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new CliError('error', `${path} is not a JSON object`, { + hint: 'Run `perspective auth logout` to delete it, then `perspective auth login`.', + }); + } + return parsed as StoredConfig; +} + +/** The file holds secrets: the directory is 0700 and the file 0600. */ +export async function saveConfig( + config: StoredConfig, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const dir = configDir(env); + await mkdir(dir, { recursive: true, mode: 0o700 }); + const path = configPath(env); + await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + await chmod(path, 0o600); +} + +export async function clearConfig(env: NodeJS.ProcessEnv = process.env): Promise { + await rm(configPath(env), { force: true }); +} diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..e73863d --- /dev/null +++ b/src/context.ts @@ -0,0 +1,57 @@ +import { CliError } from './errors.ts'; +import { DEFAULT_BASE_URL, loadConfig, type StoredConfig } from './config.ts'; + +export interface GlobalOptions { + baseUrl?: string; + apiKey?: string; + fields?: string; + pretty?: boolean; +} + +/** + * Resolves credentials per call. Precedence, highest first: + * command-line flag → environment variable → stored credentials file. + */ +export class Context { + readonly opts: GlobalOptions; + readonly env: NodeJS.ProcessEnv; + private config?: StoredConfig; + + constructor(opts: GlobalOptions, env: NodeJS.ProcessEnv = process.env) { + this.opts = opts; + this.env = env; + } + + async stored(): Promise { + this.config ??= await loadConfig(this.env); + return this.config; + } + + async baseUrl(): Promise { + // `||`, not `??`: an empty value (e.g. an unset CI variable) means "not set". + const url = + this.opts.baseUrl || + this.env.PERSPECTIVE_BASE_URL || + (await this.stored()).baseUrl || + DEFAULT_BASE_URL; + if (!URL.canParse(url)) { + throw new CliError('usage', `Invalid base URL "${url}"`, { + hint: 'Check --base-url, PERSPECTIVE_BASE_URL or the baseUrl in the credentials file.', + }); + } + return url.replace(/\/+$/, ''); + } + + /** API key for the External API (`/v1`). */ + async apiKey(): Promise { + const key = this.opts.apiKey ?? this.env.PERSPECTIVE_API_KEY ?? (await this.stored()).apiKey; + if (!key) { + throw new CliError('unauthenticated', 'No API key configured', { + hint: + 'Commands need an API key. Create one under Settings → API Keys, then run ' + + '`perspective auth login` or set PERSPECTIVE_API_KEY.', + }); + } + return key; + } +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..de77946 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,84 @@ +/** + * Exit codes are part of the CLI's contract — scripts and agents branch on + * them — so they never change meaning. Add new codes, never renumber. + */ +export const ExitCode = { + OK: 0, + ERROR: 1, + USAGE: 2, + UNAUTHENTICATED: 3, + FORBIDDEN: 4, + NOT_FOUND: 5, + RATE_LIMITED: 6, + NETWORK: 7, +} as const; + +export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode]; + +export type ErrorCode = + | 'error' + | 'usage' + | 'unauthenticated' + | 'forbidden' + | 'not_found' + | 'rate_limited' + | 'network'; + +const EXIT_BY_CODE: Record = { + error: ExitCode.ERROR, + usage: ExitCode.USAGE, + unauthenticated: ExitCode.UNAUTHENTICATED, + forbidden: ExitCode.FORBIDDEN, + not_found: ExitCode.NOT_FOUND, + rate_limited: ExitCode.RATE_LIMITED, + network: ExitCode.NETWORK, +}; + +export class CliError extends Error { + readonly code: ErrorCode; + readonly status?: number; + readonly hint?: string; + readonly details?: unknown; + + constructor( + code: ErrorCode, + message: string, + opts: { status?: number; hint?: string; details?: unknown } = {}, + ) { + super(message); + this.name = 'CliError'; + this.code = code; + this.status = opts.status; + this.hint = opts.hint; + this.details = opts.details; + } + + get exitCode(): ExitCodeValue { + return EXIT_BY_CODE[this.code]; + } + + toJSON(): Record { + return { + error: { + code: this.code, + message: this.message, + ...(this.status !== undefined && { status: this.status }), + ...(this.hint && { hint: this.hint }), + ...(this.details !== undefined && { details: this.details }), + }, + }; + } +} + +export function errorCodeForStatus(status: number): ErrorCode { + if (status === 401) return 'unauthenticated'; + if (status === 403) return 'forbidden'; + if (status === 404) return 'not_found'; + if (status === 429) return 'rate_limited'; + if (status === 400 || status === 422) return 'usage'; + return 'error'; +} + +export function usage(message: string, hint?: string): CliError { + return new CliError('usage', message, { hint }); +} diff --git a/src/input.ts b/src/input.ts new file mode 100644 index 0000000..6766ffe --- /dev/null +++ b/src/input.ts @@ -0,0 +1,84 @@ +import { readFile } from 'node:fs/promises'; +import { usage } from './errors.ts'; + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString('utf8'); +} + +/** Reads text from a literal, `@path` (file) or `-` (stdin). */ +export async function readTextArg(value: string): Promise { + if (value === '-') return readStdin(); + if (value.startsWith('@')) return readFile(value.slice(1), 'utf8'); + return value; +} + +/** Parses a JSON object from a literal, `@path` or `-`. */ +export async function readJsonObject(value: string, what = 'input'): Promise> { + const text = await readTextArg(value); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (err) { + throw usage(`${what} is not valid JSON: ${(err as Error).message}`); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw usage(`${what} must be a JSON object`); + } + return parsed as Record; +} + +/** + * `key=value` → [key, value]. The value is parsed as JSON when it is valid + * JSON (numbers, booleans, arrays, objects, quoted strings) and kept as a + * plain string otherwise. Dotted keys nest: `address.city=Berlin`. + */ +export function parseAssignment(raw: string): [string[], unknown] { + const eq = raw.indexOf('='); + if (eq <= 0) throw usage(`Expected key=value, got "${raw}"`); + const key = raw.slice(0, eq); + const text = raw.slice(eq + 1); + let value: unknown = text; + try { + value = JSON.parse(text); + } catch { + // plain string + } + // Keep integers that a double can't hold exactly (e.g. long IDs) as text. + if (typeof value === 'number' && /^\s*-?\d+\s*$/.test(text) && BigInt(text.trim()) !== BigInt(value)) { + value = text; + } + return [key.split('.'), value]; +} + +export function applyAssignments(target: Record, assignments: string[]): Record { + for (const raw of assignments) { + const [path, value] = parseAssignment(raw); + let node = target; + for (const segment of path.slice(0, -1)) { + const next = node[segment]; + if (!next || typeof next !== 'object' || Array.isArray(next)) node[segment] = {}; + node = node[segment] as Record; + } + node[path[path.length - 1]!] = value; + } + return target; +} + +export function collect(value: string, previous: string[] = []): string[] { + return [...previous, value]; +} + +/** Without `min`, any integer is accepted. */ +export function parseInteger(name: string, min?: number, max = Number.MAX_SAFE_INTEGER) { + return (value: string): number => { + const n = value.trim() === '' ? NaN : Number(value); + if (!Number.isSafeInteger(n) || (min !== undefined && (n < min || n > max))) { + throw usage( + min === undefined ? `--${name} must be an integer` : `--${name} must be an integer between ${min} and ${max}`, + ); + } + return n; + }; +} diff --git a/src/output.ts b/src/output.ts new file mode 100644 index 0000000..ec67bef --- /dev/null +++ b/src/output.ts @@ -0,0 +1,60 @@ +import { CliError } from './errors.ts'; + +export interface OutputOptions { + pretty?: boolean; +} + +/** Pretty-print for humans at a terminal, compact JSON when piped (agents, jq). */ +function indentFor(opts: OutputOptions, stream: NodeJS.WriteStream): number | undefined { + const pretty = opts.pretty ?? stream.isTTY === true; + return pretty ? 2 : undefined; +} + +function pick(item: unknown, paths: string[][]): unknown { + if (!item || typeof item !== 'object' || Array.isArray(item)) return item; + const out: Record = {}; + for (const path of paths) { + let value: unknown = item; + for (const key of path) { + value = value && typeof value === 'object' ? (value as Record)[key] : undefined; + } + out[path.join('.')] = value ?? null; + } + return out; +} + +/** + * Keeps only the listed fields of `data` (each item when it is a list); `meta` + * stays. Dotted paths reach into nested objects and keep the dotted name as the + * key, e.g. `properties.city`. Missing fields come back as null so every item + * has the same keys. + */ +export function selectFields(value: unknown, fields: string): unknown { + const paths = fields + .split(',') + .map((field) => field.trim()) + .filter(Boolean) + .map((field) => field.split('.')); + if (paths.length === 0) return value; + const apply = (data: unknown) => (Array.isArray(data) ? data.map((item) => pick(item, paths)) : pick(data, paths)); + if (value && typeof value === 'object' && !Array.isArray(value) && 'data' in value) { + return { ...value, data: apply((value as { data: unknown }).data) }; + } + return apply(value); +} + +export function printResult(value: unknown, opts: OutputOptions = {}): void { + if (value === undefined) return; + process.stdout.write(`${JSON.stringify(value, null, indentFor(opts, process.stdout))}\n`); +} + +export function printError(err: unknown, opts: OutputOptions = {}): number { + const cliError = + err instanceof CliError + ? err + : new CliError('error', err instanceof Error ? err.message : String(err)); + process.stderr.write( + `${JSON.stringify(cliError.toJSON(), null, indentFor(opts, process.stderr))}\n`, + ); + return cliError.exitCode; +} diff --git a/src/rest.ts b/src/rest.ts new file mode 100644 index 0000000..ef0ec1e --- /dev/null +++ b/src/rest.ts @@ -0,0 +1,121 @@ +import { CliError, errorCodeForStatus } from './errors.ts'; + +export type Query = Record; + +export interface RestRequest { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + path: string; + query?: Query; + body?: unknown; +} + +const USER_AGENT = 'perspective-cli'; + +export interface RestClientOptions { + /** Retries after HTTP 429. The request was not processed, so retrying is safe for writes too. */ + maxRetries?: number; + sleep?: (ms: number) => Promise; + /** Per attempt, including reading the body. */ + timeoutMs?: number; +} + +const MAX_RETRY_WAIT_MS = 60_000; + +/** Waits as long as Retry-After (or RateLimit-Reset) says, else 2^attempt seconds; never more than a minute. */ +export function retryDelayMs(res: Response, attempt: number): number { + const header = res.headers.get('retry-after') ?? res.headers.get('ratelimit-reset'); + const seconds = header !== null && header.trim() !== '' ? Number(header) : NaN; + const ms = Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : 2 ** attempt * 1000; + return Math.min(ms, MAX_RETRY_WAIT_MS); +} + +/** Client for the External API under /v1. Returns the parsed JSON body as-is ({data} / {data, meta}). */ +export class RestClient { + readonly baseUrl: string; + private readonly apiKey: string; + private readonly fetchImpl: typeof fetch; + private readonly maxRetries: number; + private readonly sleep: (ms: number) => Promise; + private readonly timeoutMs: number; + + constructor(baseUrl: string, apiKey: string, fetchImpl: typeof fetch = fetch, options: RestClientOptions = {}) { + this.baseUrl = baseUrl; + this.apiKey = apiKey; + this.fetchImpl = fetchImpl; + this.maxRetries = options.maxRetries ?? 3; + this.timeoutMs = options.timeoutMs ?? 30_000; + this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + } + + url(path: string, query: Query = {}): string { + const url = new URL(`/v1${path}`, this.baseUrl); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + return url.toString(); + } + + /** What `request` would send, without sending it (for --dry-run). The API key is left out. */ + describe({ method = 'GET', path, query, body }: RestRequest): Record { + return { method, url: this.url(path, query), ...(body !== undefined && { body }) }; + } + + async request({ method = 'GET', path, query, body }: RestRequest): Promise { + let res: Response; + let text: string; + for (let attempt = 0; ; attempt++) { + const signal = AbortSignal.timeout(this.timeoutMs); + try { + res = await this.fetchImpl(this.url(path, query), { + method, + headers: { + 'x-perspective-api-key': this.apiKey, + accept: 'application/json', + 'user-agent': USER_AGENT, + ...(body !== undefined && { 'content-type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal, + }); + if (res.status !== 429 || attempt >= this.maxRetries) { + text = await res.text(); + break; + } + } catch (err) { + const reason = signal.aborted ? `no response within ${this.timeoutMs / 1000}s` : (err as Error).message; + throw new CliError('network', `Could not reach ${this.baseUrl}: ${reason}`); + } + await res.body?.cancel(); + await this.sleep(retryDelayMs(res, attempt)); + } + + let parsed: unknown = undefined; + try { + parsed = text ? JSON.parse(text) : undefined; + } catch { + parsed = text; + } + + if (!res.ok) throw httpError(res, parsed); + return parsed as T; + } +} + +function httpError(res: Response, body: unknown): CliError { + const message = + (body && typeof body === 'object' && 'error' in body && typeof body.error === 'string' + ? body.error + : undefined) ?? `HTTP ${res.status} ${res.statusText}`; + const code = errorCodeForStatus(res.status); + const retryAfter = res.headers.get('retry-after') ?? res.headers.get('ratelimit-reset'); + const hint = + code === 'unauthenticated' + ? 'Check your API key (`perspective auth status`).' + : code === 'forbidden' + ? 'Either the funnel ID is wrong or not in a workspace this key can access (check `perspective funnels list`), ' + + 'or the key lacks the permission this endpoint needs (crm:read, crm:write, metrics:read).' + : code === 'rate_limited' + ? `Rate limit is 100 requests/minute, and the CLI already retried.${retryAfter ? ` Retry in ${retryAfter}s.` : ''}` + : undefined; + return new CliError(code, message, { status: res.status, hint }); +} diff --git a/test/clients.test.ts b/test/clients.test.ts new file mode 100644 index 0000000..69844d1 --- /dev/null +++ b/test/clients.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { CliError, ExitCode } from '../src/errors.ts'; +import { RestClient, retryDelayMs } from '../src/rest.ts'; + +type Call = { url: string; init: RequestInit }; + +function fakeFetch(respond: (call: Call) => Response) { + const calls: Call[] = []; + const impl = (async (url: string | URL, init: RequestInit = {}) => { + const call = { url: String(url), init }; + calls.push(call); + return respond(call); + }) as typeof fetch; + return { impl, calls }; +} + +const json = (status: number, body: unknown, headers: Record = {}) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } }); + +describe('RestClient', () => { + it('sends the api key header and builds /v1 URLs with query params', async () => { + const { impl, calls } = fakeFetch(() => json(200, { data: [] })); + const rest = new RestClient('https://api.example.com', 'k_123', impl); + await rest.request({ path: '/funnels/f1/contacts', query: { page: 2, limit: undefined } }); + expect(calls[0]!.url).toBe('https://api.example.com/v1/funnels/f1/contacts?page=2'); + expect((calls[0]!.init.headers as Record)['x-perspective-api-key']).toBe('k_123'); + }); + + it.each([ + [401, 'unauthenticated', ExitCode.UNAUTHENTICATED], + [403, 'forbidden', ExitCode.FORBIDDEN], + [404, 'not_found', ExitCode.NOT_FOUND], + [429, 'rate_limited', ExitCode.RATE_LIMITED], + [400, 'usage', ExitCode.USAGE], + [500, 'error', ExitCode.ERROR], + ])('maps HTTP %i to %s', async (status, code, exit) => { + const { impl } = fakeFetch(() => json(status, { error: 'boom', status })); + const rest = new RestClient('https://api.example.com', 'k', impl, { maxRetries: 0 }); + const err = (await rest.request({ path: '/x' }).catch((e: unknown) => e)) as CliError; + expect(err).toBeInstanceOf(CliError); + expect(err.code).toBe(code); + expect(err.exitCode).toBe(exit); + expect(err.message).toBe('boom'); + }); + + it('reports network failures with the network code', async () => { + const impl = (async () => { + throw new TypeError('fetch failed'); + }) as typeof fetch; + const err = (await new RestClient('https://api.example.com', 'k', impl) + .request({ path: '/x' }) + .catch((e: unknown) => e)) as CliError; + expect(err.code).toBe('network'); + }); +}); + +describe('RestClient rate limiting', () => { + it('retries a 429 after the Retry-After delay and returns the next response', async () => { + let n = 0; + const { impl, calls } = fakeFetch(() => + n++ === 0 ? json(429, { error: 'Too many requests' }, { 'retry-after': '7' }) : json(200, { data: 'ok' }), + ); + const waits: number[] = []; + const rest = new RestClient('https://x', 'k', impl, { sleep: async (ms) => void waits.push(ms) }); + expect(await rest.request({ path: '/x' })).toEqual({ data: 'ok' }); + expect(calls).toHaveLength(2); + expect(waits).toEqual([7000]); + }); + + it('gives up after maxRetries with rate_limited', async () => { + const { impl, calls } = fakeFetch(() => json(429, { error: 'Too many requests' })); + const rest = new RestClient('https://x', 'k', impl, { maxRetries: 2, sleep: async () => {} }); + const err = (await rest.request({ path: '/x' }).catch((e: unknown) => e)) as CliError; + expect(err.code).toBe('rate_limited'); + expect(calls).toHaveLength(3); + }); + + it('backs off exponentially without a header and caps the wait at a minute', () => { + expect(retryDelayMs(new Response(null, { status: 429 }), 0)).toBe(1000); + expect(retryDelayMs(new Response(null, { status: 429 }), 2)).toBe(4000); + expect(retryDelayMs(new Response(null, { status: 429, headers: { 'retry-after': '600' } }), 0)).toBe(60_000); + }); + + it('describes a request without the API key', () => { + const rest = new RestClient('https://api.example.com', 'secret'); + const described = rest.describe({ method: 'PUT', path: '/funnels/f/contacts/c/values', body: { fieldName: 'status', value: 'won' } }); + expect(described).toEqual({ + method: 'PUT', + url: 'https://api.example.com/v1/funnels/f/contacts/c/values', + body: { fieldName: 'status', value: 'won' }, + }); + expect(JSON.stringify(described)).not.toContain('secret'); + }); +}); + +describe('RestClient timeout', () => { + it('fails with the network code when the API does not answer', async () => { + const impl = ((_url: string, init: RequestInit) => + new Promise((_, reject) => init.signal!.addEventListener('abort', () => reject(init.signal!.reason)))) as typeof fetch; + const err = (await new RestClient('https://x', 'k', impl, { timeoutMs: 20 }) + .request({ path: '/workspaces' }) + .catch((e) => e)) as CliError; + expect(err.code).toBe('network'); + expect(err.exitCode).toBe(ExitCode.NETWORK); + expect(err.message).toMatch(/no response within/); + }); +}); diff --git a/test/units.test.ts b/test/units.test.ts new file mode 100644 index 0000000..21b690f --- /dev/null +++ b/test/units.test.ts @@ -0,0 +1,179 @@ +import { mkdtemp, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { mask } from '../src/commands/auth.ts'; +import { KPI_SUBTYPES, normalizeSubtype, resolveKpis, resolveWindow } from '../src/commands/metrics.ts'; +import { flattenFunnels } from '../src/commands/workspaces.ts'; +import { configPath, loadConfig, saveConfig } from '../src/config.ts'; +import { Context } from '../src/context.ts'; +import { CliError } from '../src/errors.ts'; +import { applyAssignments, parseAssignment, parseInteger } from '../src/input.ts'; +import { selectFields } from '../src/output.ts'; + +describe('assignments', () => { + it('parses JSON values and falls back to strings', () => { + expect(parseAssignment('limit=20')).toEqual([['limit'], 20]); + expect(parseAssignment('noBrand=true')).toEqual([['noBrand'], true]); + expect(parseAssignment('name=Spring sale')).toEqual([['name'], 'Spring sale']); + expect(parseAssignment('q=a=b')).toEqual([['q'], 'a=b']); + }); + + it('nests dotted keys and merges into existing objects', () => { + const target = { data: { name: 'x' } } as Record; + applyAssignments(target, ['data.prompt=hi', 'data.noBrand=true']); + expect(target).toEqual({ data: { name: 'x', prompt: 'hi', noBrand: true } }); + }); + + it('rejects malformed input as a usage error', () => { + expect(() => parseAssignment('novalue')).toThrow(CliError); + }); +}); + +describe('metrics helpers', () => { + it('normalizes subtype spellings', () => { + expect(normalizeSubtype('conversion-rate', 'kpi', KPI_SUBTYPES)).toBe('kpi_conversion_rate'); + expect(normalizeSubtype('kpi_new_contacts', 'kpi', KPI_SUBTYPES)).toBe('kpi_new_contacts'); + expect(() => normalizeSubtype('nope', 'kpi', KPI_SUBTYPES)).toThrow(/Unknown kpi subtype/); + }); + + it('defaults to the last N days and honours explicit bounds', () => { + const now = new Date('2026-10-19T12:00:00.000Z'); + const w = resolveWindow({ days: 7, tzOffset: 0 }, now); + expect(w).toEqual({ from: '2026-10-12T12:00:00.000Z', to: '2026-10-19T12:00:00.000Z', offset: '0' }); + const explicit = resolveWindow({ from: '2026-09-01', to: '2026-10-01', days: 30, tzOffset: 120 }, now); + expect(explicit.from).toBe('2026-09-01T00:00:00.000Z'); + expect(explicit.offset).toBe('120'); + expect(() => resolveWindow({ from: '2026-10-02', to: '2026-10-01', days: 30 }, now)).toThrow(/before/); + }); +}); + +describe('flattenFunnels', () => { + it('lifts campaigns out of workspaces', () => { + expect( + flattenFunnels([{ id: 'w1', name: 'Main', campaigns: [{ id: 'f1', name: 'Quiz', status: 'live' }] }]), + ).toEqual([{ id: 'f1', name: 'Quiz', status: 'live', workspaceId: 'w1', workspaceName: 'Main' }]); + }); +}); + +describe('auth', () => { + it('masks secrets', () => { + expect(mask('abcd1234efgh')).toBe('abcd…efgh'); + expect(mask('short')).toBe('••••'); + }); +}); + +describe('config + context', () => { + async function tempEnv(): Promise { + return { PERSPECTIVE_CONFIG_DIR: await mkdtemp(join(tmpdir(), 'pcli-')) }; + } + + it('stores credentials with 0600 permissions', async () => { + const env = await tempEnv(); + await saveConfig({ apiKey: 'k' }, env); + expect((await stat(configPath(env))).mode & 0o777).toBe(0o600); + expect(await loadConfig(env)).toEqual({ apiKey: 'k' }); + }); + + it('resolves flag > env > file', async () => { + const env = await tempEnv(); + await saveConfig({ apiKey: 'from-file', baseUrl: 'https://file.example' }, env); + expect(await new Context({}, env).apiKey()).toBe('from-file'); + expect(await new Context({}, { ...env, PERSPECTIVE_API_KEY: 'from-env' }).apiKey()).toBe('from-env'); + expect(await new Context({ apiKey: 'from-flag' }, { ...env, PERSPECTIVE_API_KEY: 'from-env' }).apiKey()).toBe( + 'from-flag', + ); + expect(await new Context({ baseUrl: 'https://flag.example/' }, env).baseUrl()).toBe('https://flag.example'); + }); + + it('fails with unauthenticated when nothing is configured', async () => { + const env = await tempEnv(); + await expect(new Context({}, env).apiKey()).rejects.toMatchObject({ code: 'unauthenticated' }); + }); + +}); + +describe('selectFields', () => { + const page = { + data: [ + { id: 'c1', email: 'a@x.co', status: 'new', properties: { city: 'Berlin' } }, + { id: 'c2', email: 'b@x.co' }, + ], + meta: { total: 2 }, + }; + + it('keeps the listed fields of each item and the meta', () => { + expect(selectFields(page, 'id,email')).toEqual({ + data: [ + { id: 'c1', email: 'a@x.co' }, + { id: 'c2', email: 'b@x.co' }, + ], + meta: { total: 2 }, + }); + }); + + it('reaches into nested objects and fills missing fields with null', () => { + expect(selectFields(page, 'id, properties.city')).toEqual({ + data: [ + { id: 'c1', 'properties.city': 'Berlin' }, + { id: 'c2', 'properties.city': null }, + ], + meta: { total: 2 }, + }); + }); + + it('works on a single object and on results without a data envelope', () => { + expect(selectFields({ data: { type: 'kpi', value: 3 } }, 'value')).toEqual({ data: { value: 3 } }); + expect(selectFields({ kpi: ['a'], chart: ['b'] }, 'kpi')).toEqual({ kpi: ['a'] }); + }); +}); + +describe('resolveKpis', () => { + it('normalizes names, keeps their order and drops repeats', () => { + expect(resolveKpis(['total_sessions', 'kpi_conversion_rate', 'total-sessions'])).toEqual([ + 'kpi_total_sessions', + 'kpi_conversion_rate', + ]); + }); + + it('expands all to every KPI', () => { + expect(resolveKpis(['all'])).toEqual(KPI_SUBTYPES); + }); + + it('rejects unknown names with the valid list', () => { + expect(() => resolveKpis(['conversion_rate', 'bogus'])).toThrow(CliError); + }); +}); + +describe('review fixes', () => { + it('keeps integers a double cannot hold as text, other numbers as numbers', () => { + expect(parseAssignment('id=12345678901234567890')).toEqual([['id'], '12345678901234567890']); + expect(parseAssignment('n=1e3')).toEqual([['n'], 1000]); + expect(parseAssignment('n=1.0')).toEqual([['n'], 1]); + expect(parseAssignment('n=-42')).toEqual([['n'], -42]); + }); + + it('parses --tz-offset as an integer', () => { + const parse = parseInteger('tz-offset'); + expect(parse('-300')).toBe(-300); + for (const bad of ['abc', '+2h', '1.5', '']) expect(() => parse(bad)).toThrow(/integer/); + }); + + it('reports a broken credentials file as a CliError pointing to logout', async () => { + for (const content of ['{nope', 'null', '[]']) { + const dir = await mkdtemp(join(tmpdir(), 'perspective-cli-')); + await writeFile(join(dir, 'credentials.json'), content); + const err = (await loadConfig({ PERSPECTIVE_CONFIG_DIR: dir }).catch((e) => e)) as CliError; + expect(err).toBeInstanceOf(CliError); + expect(err.hint).toMatch(/auth logout/); + } + }); + + it('treats an empty base URL as unset and rejects an invalid one', async () => { + const dir = await mkdtemp(join(tmpdir(), 'perspective-cli-')); + const env = { PERSPECTIVE_CONFIG_DIR: dir, PERSPECTIVE_BASE_URL: '' }; + expect(await new Context({}, env).baseUrl()).toBe('https://api.perspective.co'); + const err = (await new Context({ baseUrl: 'not a url' }, env).baseUrl().catch((e) => e)) as CliError; + expect(err.code).toBe('usage'); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..3d32a63 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fbb4fd0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rewriteRelativeImportExtensions": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["src", "test"] +}