diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2dc3b43..36e74ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,9 @@ jobs: - name: Run lint run: pnpm lint + - name: Typecheck (src + tests) + run: pnpm typecheck + test: runs-on: blacksmith-4vcpu-ubuntu-2404 needs: [build, lint] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7305fdb..f2a8b08 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,25 +7,40 @@ The Formo CLI is built with [incur](https://github.com/tryincur/incur), a type-s ## Project structure ``` -apps/cli/ +cli/ ├── src/ │ ├── index.ts # CLI entry point — registers all commands and calls cli.serve() -│ ├── commands/ -│ │ ├── profiles.ts # profiles get / profiles search commands + exported run helpers -│ │ ├── profiles.test.ts # unit tests for profiles commands -│ │ ├── query.ts # query run command + exported run helper -│ │ └── query.test.ts # unit tests for query commands +│ ├── commands/ # one file per command group + exported run helpers +│ │ ├── alerts.ts +│ │ ├── analytics.ts +│ │ ├── boards.ts +│ │ ├── charts.ts +│ │ ├── contracts.ts +│ │ ├── events.ts +│ │ ├── import.ts +│ │ ├── profiles.ts +│ │ ├── query.ts +│ │ └── segments.ts │ └── lib/ │ ├── client.ts # axios HTTP client factory + requireApiKey guard -│ ├── client.test.ts # unit tests for client │ ├── config.ts # ~/.config/formo/config.json read/write + FORMO_API_KEY env -│ └── config.test.ts # unit tests for config +│ ├── json.ts # JSON option parsing helpers +│ ├── sql.ts # SQL helpers (strip trailing FORMAT clause) +│ └── ui.ts # terminal styling utilities +├── test/ +│ ├── commands/ # per-command tests (mostly live-API integration tests) +│ ├── lib/ # unit tests for lib modules +│ ├── helpers/ +│ │ └── liveApi.ts # probes the live API once; skips integration tests on auth failure +│ ├── preload.cjs # loads .env, maps TEST_TOKEN → FORMO_API_KEY +│ └── setup.ts +├── .mocharc.json ├── CONTRIBUTING.md ├── README.md ├── SKILLS.md ├── package.json ├── tsconfig.json -└── vitest.config.ts +└── tsconfig.test.json ``` ## How commands work @@ -59,7 +74,7 @@ This pattern means tests call `getProfileRun(...)` directly without needing to i 2. Export a named helper function containing the logic. 3. Register the command on a `Cli.create(...)` subcommand group or directly on `cli`. 4. Register the group in `src/index.ts` if it's new. -5. Write tests in a `.test.ts` sibling file. +5. Write tests in `test/commands/.test.ts`. Minimal example — adding `formo wallets list`: @@ -115,41 +130,39 @@ color.red('text') // Red text ## Local development ```bash -# Install dependencies from repo root +# Install dependencies pnpm install # Run in dev mode (ts-node, no build needed) -FORMO_API_KEY=formo_xxx pnpm --filter @formo/cli dev profiles get 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 +FORMO_API_KEY=formo_xxx pnpm dev profiles get 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 # Or set the key once and run any command export FORMO_API_KEY=formo_xxx -pnpm --filter @formo/cli dev query run "SELECT count(*) FROM events" +pnpm dev query run "SELECT count(*) FROM events" # Build TypeScript -pnpm --filter @formo/cli build +pnpm build -# Run the built binary directly -node apps/cli/dist/index.js profiles search --limit 5 +# Run the built entrypoint directly +node dist/index.js profiles search --size 5 ``` ## Running tests ```bash # Run all tests once -pnpm --filter @formo/cli test +pnpm test # Watch mode -pnpm --filter @formo/cli test:watch +pnpm test:watch ``` -Tests use [Vitest](https://vitest.dev/) with `vi.mock('../lib/client')` to isolate HTTP calls. The pattern is: +Tests live in `test/` and run with [Mocha](https://mochajs.org/) via [tsx](https://tsx.is/) (see `.mocharc.json`), with [chai](https://www.chaijs.com/) `expect` assertions. There are two kinds of tests: -1. Mock `createClient` to return a fake axios instance with `vi.fn()` methods. -2. Mock `requireApiKey` to be a no-op (or throw for error cases). -3. Import and call the exported helper directly. -4. Assert on the mock calls. +- **Live-API integration tests** — most of `test/commands/` hits the real Formo API. `test/preload.cjs` loads `.env` and requires `TEST_TOKEN` (mapped to `FORMO_API_KEY`) before any test module loads; the run aborts if it is missing. `test/helpers/liveApi.ts` probes the API once and, if the key is rejected or the API is unreachable, integration tests are skipped with a clear message via `requiresLiveApi(this)`. +- **Pure unit tests** — `test/commands/bodyBuilders.test.ts` and the `test/lib/` suites test exported helpers (body builders, JSON/SQL parsing, config) directly without any network calls. -Note: `requireApiKey()` and `JSON.parse()` errors throw **synchronously**, so use `expect(() => fn()).toThrow(...)` (not `rejects.toThrow`) for those cases. +Note: `requireApiKey()` and `JSON.parse()` errors throw **synchronously**, so use `expect(() => fn()).to.throw(...)` for those cases. ## AI agent mode @@ -170,15 +183,15 @@ When an AI agent runs `formo` with the incur sync protocol, it gets a structured ## Authentication in tests -Tests mock `requireApiKey` so they never need a real API key. For manual end-to-end testing: +The test suite needs a real API key: add `TEST_TOKEN=formo_your_key` to a `.env` file in the repo root before running `pnpm test`. For manual end-to-end testing: ```bash # Option 1: env var (temporary) -FORMO_API_KEY=formo_xxx pnpm --filter @formo/cli dev profiles get vitalik.eth +FORMO_API_KEY=formo_xxx pnpm dev profiles get vitalik.eth # Option 2: save to config file (persistent) -pnpm --filter @formo/cli dev login formo_xxx -pnpm --filter @formo/cli dev profiles get vitalik.eth +pnpm dev login formo_xxx +pnpm dev profiles get vitalik.eth ``` The config is stored at `~/.config/formo/config.json` with mode `0600`. diff --git a/README.md b/README.md index d41120c..e06fa0b 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ Search wallet profiles with filters, sorting, and pagination. Returns a `Paginat | Option | Description | |---|---| | `--address` | Filter by wallet address | +| `--search` | Free-text search across address and identity fields | | `--page` | Page number (1-indexed, default `1`) | | `--size` | Page size (default `100`, max `1000`) | | `--order-by` | `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points` | @@ -121,6 +122,20 @@ formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","v formo profiles search --conditions '[{"field":"chains.1.balance","op":"gt","value":1000}]' --size 20 ``` +### Lifecycle tuning (advanced) + +Both `profiles get` and `profiles search` accept optional flags to override the lifecycle stage thresholds used when computing `lifecycle`: + +| Option | Description | +|---|---| +| `--new-window-days` | Override lifecycle new-user window in days | +| `--churn-window-days` | Override lifecycle churn window in days | +| `--power-user-min-active-days` | Override lifecycle power-user minimum active days | +| `--power-user-window-days` | Override lifecycle power-user window in days | +| `--resurrected-gap-days` | Override lifecycle resurrected gap in days | +| `--at-risk-min-days-inactive` | Override lifecycle at-risk minimum inactive days | +| `--at-risk-prior-active-days-threshold` | Override lifecycle at-risk prior active days threshold | + ### `profiles update
` Merge-update identity properties on a wallet profile. @@ -205,6 +220,7 @@ Get a single alert by ID. | `--trigger-filters` | JSON array of trigger filter objects | | `--recipient` | JSON array of recipient objects | | `--secret` | Webhook secret | +| `--slack-property-keys` | JSON array of event/user property keys to include in Slack alerts | ```bash formo alerts create --name "High value tx" --trigger-type event \ @@ -213,7 +229,7 @@ formo alerts create --name "High value tx" --trigger-type event \ ``` ### `alerts update ` -Same options as `create`. Replaces the alert configuration. +Same options as `create`. Replaces the alert configuration in full — omitted options are reset to their defaults (e.g. leaving out `--trigger-filters` clears the existing trigger filters). ### `alerts delete ` Delete an alert. @@ -271,7 +287,7 @@ Delete a board. ## `formo charts` -Chart commands. Charts live inside a board. Requires `charts:read` / `charts:write`. +Chart commands. Charts live inside a board. Requires `boards:read` / `boards:write`. ### `charts list --board-id ` List all charts in a board. @@ -294,8 +310,12 @@ formo charts create --board-id brd_123 --title "Daily Active Users" \ formo charts create --board-id brd_123 --body '{"title":"Recent Events","chart_type":"table","query":"SELECT * FROM events LIMIT 10"}' ``` -### `charts update --board-id --body ''` -Update a chart. +### `charts update --board-id [options]` +Update a chart. Accepts the same options as `create`: a raw `--body ''` and/or typed flags (`--title`, `--chart-type`, `--query`, `--description`, `--x-axis`, `--y-axis`, `--group-by`, `--steps`, `--settings`). Typed flags override matching `--body` keys. + +```bash +formo charts update chart_abc123 --board-id brd_123 --title "Renamed chart" +``` ### `charts query --board-id --date-from --date-to ` Execute a saved chart that uses `{{date_from}}` / `{{date_to}}` variables. @@ -420,7 +440,7 @@ formo analytics retention --filters '[{"field":"location","op":"eq","value":"US" ### `import wallets` -Bulk-import wallet addresses into the project via the events API. +Bulk-import wallet addresses into the project via the main Formo API, authenticated with your workspace API key. | Option | Description | |---|---| @@ -432,17 +452,26 @@ formo import wallets --addresses '["0xabc...","0xdef..."]' formo import wallets --rows '[{"address":"0xabc...","properties":{"display_name":"Alice"}}]' ``` +> Requires `profiles:write` scope. Only available on Scale and Enterprise plans. + --- ## `formo events` ### `events ingest` -Send raw analytics events to `events.formo.so`. This command uses a project SDK write key, not the workspace API key. +Send raw analytics events to `events.formo.so`. This command uses a project SDK write key, not the workspace API key — pass it via `--write-key` or the `FORMO_WRITE_KEY` environment variable. + +| Option | Description | +|---|---| +| `--event` | Single event as a JSON object; wrapped in an array before sending | +| `--events` | JSON array of event objects to send as a batch | +| `--write-key` | Project SDK write key (defaults to `FORMO_WRITE_KEY`) | ```bash export FORMO_WRITE_KEY=formo_write_key_xxx formo events ingest --event '{"type":"track","channel":"cli","version":"1","anonymous_id":"anon_123","event":"CLI Test","context":{},"properties":{},"original_timestamp":"2026-04-27T23:05:38.000Z","sent_at":"2026-04-27T23:05:42.000Z","message_id":"cli-test-1"}' +formo events ingest --events '[{"type":"track","event":"First"},{"type":"track","event":"Second"}]' ``` --- diff --git a/SKILLS.md b/SKILLS.md index 35e6f17..5d0f619 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -32,6 +32,14 @@ formo status Shows whether an API key is configured, where it was loaded from (env var or config file), and masked key value. +### Log out + +```bash +formo logout +``` + +Removes the saved API key and clears authentication state. + --- ## Wallet Profiles @@ -82,6 +90,7 @@ formo profiles search [options] | `--order-dir` | `asc`, `desc` | Sort direction | | `--expand` | `string` | Comma-separated fields to expand | | `--conditions` | JSON array | Advanced filter conditions (see below) | +| `--logic` | `and`, `or` | Logic operator for combining conditions (default `and`) | **`--order-by` values:** `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points` @@ -159,7 +168,7 @@ formo profiles labels delete
--tag-id vip Execute a SQL query against your Formo analytics data (events, sessions, wallet profiles). ```bash -formo query "" +formo query run "" ``` > Requires `query:read` scope on your API key. @@ -167,13 +176,13 @@ formo query "" **Examples:** ```bash # Count total events -formo query "SELECT count(*) FROM events" +formo query run "SELECT count(*) FROM events" # Top 10 wallets by net worth -formo query "SELECT address, net_worth_usd FROM wallet_profiles ORDER BY net_worth_usd DESC LIMIT 10" +formo query run "SELECT address, net_worth_usd FROM wallet_profiles ORDER BY net_worth_usd DESC LIMIT 10" # Recent sessions -formo query "SELECT address, last_seen FROM wallet_profiles ORDER BY last_seen DESC LIMIT 5" +formo query run "SELECT address, last_seen FROM wallet_profiles ORDER BY last_seen DESC LIMIT 5" ``` --- @@ -215,7 +224,7 @@ formo analytics top_wallets --date-from 2026-04-01 --date-to 2026-04-30 --params formo analytics retention --filters '[{"field":"location","op":"eq","value":"US"}]' ``` -Each pipe accepts pipe-specific params via `--params` (see each command's `--help`): e.g. `funnel` → `steps`, `window_seconds`, `funnel_type`, `breakdown`; `kpis` → `group_by`, `limit`; `top_*` → `limit`, `offset`. +Each pipe accepts pipe-specific params via `--params` (see each command's `--help`): e.g. `funnel` → `steps`, `window_seconds`, `funnel_type`, `group_by`, `limit`, `attribution`; `kpis` → `group_by`, `limit`; `top_*` → `limit`, `offset`. --- diff --git a/package.json b/package.json index bac7f82..bfe0c50 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "@formo/cli", "version": "1.1.0", "packageManager": "pnpm@11.1.2", + "engines": { + "node": ">=22.12" + }, "description": "Formo API CLI — query profiles and analytics data", "license": "MIT", "repository": { @@ -22,19 +25,18 @@ ], "scripts": { "build": "tsc", - "dev": "ts-node src/index.ts", + "prepublishOnly": "pnpm build", + "dev": "tsx src/index.ts", "start": "node dist/index.js", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", + "typecheck": "tsc -p tsconfig.test.json --noEmit", "test": "mocha", "test:watch": "mocha --watch" }, "dependencies": { "axios": "^1.18.0", - "incur": "^0.3.4" - }, - "overrides": { - "serialize-javascript": ">=7.0.5" + "incur": "0.3.25" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -46,14 +48,8 @@ "eslint": "^10.2.0", "globals": "^17.4.0", "mocha": "^11.7.5", - "ts-node": "^10.9.2", "tsx": "^4.21.0", "typescript": "^5.5.4", "typescript-eslint": "^8.58.1" - }, - "pnpm": { - "overrides": { - "serialize-javascript": ">=7.0.5" - } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e89d692..4fd5a9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,7 +18,7 @@ importers: specifier: ^1.18.0 version: 1.18.0 incur: - specifier: ^0.3.4 + specifier: 0.3.25 version: 0.3.25(patch_hash=59ec45aa48f7686d1061ad0b42aaf3a47e84080dfce7d2baf3c763a1b2d214fc) devDependencies: '@eslint/js': @@ -48,9 +48,6 @@ importers: mocha: specifier: ^11.7.5 version: 11.7.5 - ts-node: - specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.39)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -66,10 +63,6 @@ packages: '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} - engines: {node: '>=12'} - '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -285,16 +278,6 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@modelcontextprotocol/server@2.0.0-alpha.2': resolution: {integrity: sha512-gmLgdHzlYM8L7Aw/+VE0kxjT25WKamtUSLNhdOgrJq5CrESvqVSoAfWSJJeNPUXNTluQ+dYDGFbKVitdsJtbPA==} engines: {node: '>=20'} @@ -311,18 +294,6 @@ packages: '@toon-format/toon@2.1.0': resolution: {integrity: sha512-JwWptdF5eOA0HaQxbKAzkpQtR4wSWTEfDlEy/y3/4okmOAX1qwnpLZMmtEWr+ncAhTTY1raCKH0kteHhSXnQqg==} - '@tsconfig/node10@1.0.12': - resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} - - '@tsconfig/node12@1.0.11': - resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} - - '@tsconfig/node14@1.0.3': - resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} - - '@tsconfig/node16@1.0.4': - resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -408,10 +379,6 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn-walk@8.3.5: - resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} - engines: {node: '>=0.4.0'} - acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -440,9 +407,6 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} - arg@4.1.3: - resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -508,9 +472,6 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} - create-require@1.1.1: - resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -535,10 +496,6 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} - diff@4.0.4: - resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} - engines: {node: '>=0.3.1'} - diff@7.0.0: resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} engines: {node: '>=0.3.1'} @@ -827,9 +784,6 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - make-error@1.3.6: - resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -984,20 +938,6 @@ packages: peerDependencies: typescript: '>=4.8.4' - ts-node@10.9.2: - resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} - hasBin: true - peerDependencies: - '@swc/core': '>=1.2.50' - '@swc/wasm': '>=1.2.50' - '@types/node': '*' - typescript: '>=2.7' - peerDependenciesMeta: - '@swc/core': - optional: true - '@swc/wasm': - optional: true - tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} @@ -1025,9 +965,6 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - v8-compile-cache-lib@3.0.1: - resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1069,10 +1006,6 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yn@3.1.1: - resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} - engines: {node: '>=6'} - yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1084,10 +1017,6 @@ snapshots: '@cfworker/json-schema@4.1.1': {} - '@cspotcode/source-map-support@0.8.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.9 - '@esbuild/aix-ppc64@0.27.7': optional: true @@ -1220,15 +1149,6 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.9': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - '@modelcontextprotocol/server@2.0.0-alpha.2(@cfworker/json-schema@4.1.1)': dependencies: zod: 4.3.6 @@ -1240,14 +1160,6 @@ snapshots: '@toon-format/toon@2.1.0': {} - '@tsconfig/node10@1.0.12': {} - - '@tsconfig/node12@1.0.11': {} - - '@tsconfig/node14@1.0.3': {} - - '@tsconfig/node16@1.0.4': {} - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1362,10 +1274,6 @@ snapshots: dependencies: acorn: 8.16.0 - acorn-walk@8.3.5: - dependencies: - acorn: 8.16.0 - acorn@8.16.0: {} agent-base@6.0.2: @@ -1391,8 +1299,6 @@ snapshots: ansi-styles@6.2.3: {} - arg@4.1.3: {} - argparse@2.0.1: {} assertion-error@2.0.1: {} @@ -1457,8 +1363,6 @@ snapshots: dependencies: delayed-stream: 1.0.0 - create-require@1.1.1: {} - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -1477,8 +1381,6 @@ snapshots: delayed-stream@1.0.0: {} - diff@4.0.4: {} - diff@7.0.0: {} dotenv@17.4.1: {} @@ -1784,8 +1686,6 @@ snapshots: lru-cache@10.4.3: {} - make-error@1.3.6: {} - math-intrinsics@1.1.0: {} mime-db@1.52.0: {} @@ -1929,24 +1829,6 @@ snapshots: dependencies: typescript: 5.9.3 - ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.12 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 20.19.39 - acorn: 8.16.0 - acorn-walk: 8.3.5 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.4 - make-error: 1.3.6 - typescript: 5.9.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - tsx@4.21.0: dependencies: esbuild: 0.27.7 @@ -1977,8 +1859,6 @@ snapshots: dependencies: punycode: 2.3.1 - v8-compile-cache-lib@3.0.1: {} - which@2.0.2: dependencies: isexe: 2.0.0 @@ -2022,8 +1902,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yn@3.1.1: {} - yocto-queue@0.1.0: {} zod@4.3.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c7d9a6f..08b263a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,5 +7,9 @@ overrides: allowBuilds: esbuild: true +# Kebab-cases camelCase flag names in incur's help/skill/example output. +# incur is pinned to an exact version in package.json while this patch is +# applied — upstream is converging on the same fix, so re-check (and ideally +# drop the patch) on every incur bump. patchedDependencies: incur: patches/incur.patch diff --git a/src/commands/alerts.ts b/src/commands/alerts.ts index e450a73..cece986 100644 --- a/src/commands/alerts.ts +++ b/src/commands/alerts.ts @@ -1,6 +1,13 @@ import { Cli, z } from 'incur' import { createClient, requireApiKey } from '../lib/client' import { parseJsonArray, parseJsonObject } from '../lib/json' +import { + buildPaginationParams, + paginationOptionsSchema, + type PaginationOptions, +} from '../lib/pagination' + +export type { PaginationOptions } export const alerts = Cli.create('alerts', { description: 'Project alert commands — create, list, update, and delete alerts', @@ -8,18 +15,6 @@ export const alerts = Cli.create('alerts', { // ── List alerts ── -export interface PaginationOptions { - page?: number - size?: number -} - -function buildPaginationParams(options: PaginationOptions = {}) { - const params: Record = {} - if (options.page !== undefined) params.page = options.page - if (options.size !== undefined) params.size = options.size - return params -} - export function listAlertsRun(options: PaginationOptions = {}) { requireApiKey() const client = createClient() @@ -28,10 +23,7 @@ export function listAlertsRun(options: PaginationOptions = {}) { alerts.command('list', { description: 'List all alerts for the project', - options: z.object({ - page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'), - size: z.coerce.number().optional().describe('Page size (default 100, max 200)'), - }), + options: z.object(paginationOptionsSchema), examples: [{ description: 'List all project alerts' }], hint: 'Requires alerts:read scope on your API key.', run({ options }) { @@ -63,16 +55,39 @@ alerts.command('get', { // ── Create an alert ── -export interface CreateAlertOptions { +export interface AlertBodyOptions { name: string - triggerType: 'event' | 'user' | string + triggerType: string triggerFilters?: string recipient?: string secret?: string slackPropertyKeys?: string } -export function buildAlertBody(options: CreateAlertOptions | UpdateAlertOptions) { +// Aliases kept for existing importers; create and update take the same body. +export type CreateAlertOptions = AlertBodyOptions +export type UpdateAlertOptions = AlertBodyOptions + +// Shared option fragment for `create` and `update` (same PUT/POST body). +const alertBodyOptionsSchema = { + name: z.string().describe('Alert name'), + triggerType: z.enum(['event', 'user']).describe('Trigger type'), + triggerFilters: z + .string() + .optional() + .describe('JSON array of trigger filter objects'), + recipient: z + .string() + .optional() + .describe('JSON array of recipient objects'), + secret: z.string().optional().describe('Webhook secret for the alert'), + slackPropertyKeys: z + .string() + .optional() + .describe('JSON array of event/user property keys to include in Slack alerts'), +} + +export function buildAlertBody(options: AlertBodyOptions) { const body: Record = { name: options.name, trigger_type: options.triggerType, @@ -112,23 +127,7 @@ export function createAlertRun(options: CreateAlertOptions) { alerts.command('create', { description: 'Create a new project alert', - options: z.object({ - name: z.string().describe('Alert name'), - triggerType: z.enum(['event', 'user']).describe('Trigger type'), - triggerFilters: z - .string() - .optional() - .describe('JSON array of trigger filter objects'), - recipient: z - .string() - .optional() - .describe('JSON array of recipient objects'), - secret: z.string().optional().describe('Webhook secret for the alert'), - slackPropertyKeys: z - .string() - .optional() - .describe('JSON array of event/user property keys to include in Slack alerts'), - }), + options: z.object(alertBodyOptionsSchema), examples: [ { options: { name: 'High value tx', triggerType: 'event' }, @@ -143,16 +142,7 @@ alerts.command('create', { // ── Update an alert ── -export interface UpdateAlertOptions { - name: string - triggerType: 'event' | 'user' | string - triggerFilters?: string - recipient?: string - secret?: string - slackPropertyKeys?: string -} - -export function updateAlertRun(alertId: string, options: UpdateAlertOptions) { +export function updateAlertRun(alertId: string, options: AlertBodyOptions) { requireApiKey() const client = createClient() return client.put( @@ -162,27 +152,12 @@ export function updateAlertRun(alertId: string, options: UpdateAlertOptions) { } alerts.command('update', { - description: 'Update an existing alert', + description: + 'Update an existing alert (full replace — omitted options reset to defaults)', args: z.object({ alertId: z.string().describe('Alert ID to update'), }), - options: z.object({ - name: z.string().describe('Alert name'), - triggerType: z.enum(['event', 'user']).describe('Trigger type'), - triggerFilters: z - .string() - .optional() - .describe('JSON array of trigger filter objects'), - recipient: z - .string() - .optional() - .describe('JSON array of recipient objects'), - secret: z.string().optional().describe('Webhook secret for the alert'), - slackPropertyKeys: z - .string() - .optional() - .describe('JSON array of event/user property keys to include in Slack alerts'), - }), + options: z.object(alertBodyOptionsSchema), examples: [ { args: { alertId: 'alert_abc123' }, diff --git a/src/commands/analytics.ts b/src/commands/analytics.ts index bc5c87f..b37fe66 100644 --- a/src/commands/analytics.ts +++ b/src/commands/analytics.ts @@ -1,5 +1,6 @@ import { Cli, z } from 'incur' import { createClient, requireApiKey } from '../lib/client' +import { parseJsonObject } from '../lib/json' export const analytics = Cli.create('analytics', { description: @@ -72,16 +73,8 @@ export function buildAnalyticsParams( // --params first, so the validated flags below override it. if (options.params) { - let parsed: unknown - try { - parsed = JSON.parse(options.params) - } catch { - throw new Error('--params must be a valid JSON object') - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('--params must be a valid JSON object') - } - for (const [key, value] of Object.entries(parsed as Record)) { + const parsed = parseJsonObject(options.params, '--params') + for (const [key, value] of Object.entries(parsed)) { if (RESERVED_PARAM_KEYS.has(key)) { throw new Error( `--params may not set "${key}" — use the --date-from/--date-to/--filters flags instead`, diff --git a/src/commands/boards.ts b/src/commands/boards.ts index d405961..a6978ec 100644 --- a/src/commands/boards.ts +++ b/src/commands/boards.ts @@ -1,22 +1,17 @@ import { Cli, z } from 'incur' import { createClient, requireApiKey } from '../lib/client' +import { + buildPaginationParams, + paginationOptionsSchema, + type PaginationOptions, +} from '../lib/pagination' + +export type { PaginationOptions } export const boards = Cli.create('boards', { description: 'Dashboard board commands — create, list, update, and delete boards', }) -export interface PaginationOptions { - page?: number - size?: number -} - -function buildPaginationParams(options: PaginationOptions = {}) { - const params: Record = {} - if (options.page !== undefined) params.page = options.page - if (options.size !== undefined) params.size = options.size - return params -} - // ── List boards ── export function listBoardsRun(options: PaginationOptions = {}) { @@ -27,10 +22,7 @@ export function listBoardsRun(options: PaginationOptions = {}) { boards.command('list', { description: 'List all boards for the project', - options: z.object({ - page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'), - size: z.coerce.number().optional().describe('Page size (default 100, max 200)'), - }), + options: z.object(paginationOptionsSchema), examples: [{ description: 'List all dashboard boards' }], hint: 'Requires boards:read scope on your API key.', run({ options }) { @@ -73,7 +65,7 @@ export function buildBoardBody(options: CreateBoardOptions | UpdateBoardOptions) const title = options.title ?? options.name const body: Record = {} if (title !== undefined) { - if (!title) throw new Error('--title must not be empty') + if (!title) throw new Error('--title (or its deprecated alias --name) must not be empty') body.title = title } if (options.description !== undefined) { diff --git a/src/commands/charts.ts b/src/commands/charts.ts index b14d894..14b62d6 100644 --- a/src/commands/charts.ts +++ b/src/commands/charts.ts @@ -6,6 +6,13 @@ import { parseStringArray, } from '../lib/json' import { stripTrailingFormatClause } from '../lib/sql' +import { + buildPaginationParams, + paginationOptionsSchema, + type PaginationOptions, +} from '../lib/pagination' + +export type { PaginationOptions } export const charts = Cli.create('charts', { description: @@ -25,18 +32,6 @@ const chartTypeSchema = z.enum([ 'retention', ]) -export interface PaginationOptions { - page?: number - size?: number -} - -function buildPaginationParams(options: PaginationOptions = {}) { - const params: Record = {} - if (options.page !== undefined) params.page = options.page - if (options.size !== undefined) params.size = options.size - return params -} - // ── Shared chart body builder ── export interface ChartBodyOptions { @@ -149,8 +144,7 @@ charts.command('list', { description: 'List all charts for a board, including executed results', options: z.object({ boardId: z.string().describe('Board ID to list charts from'), - page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'), - size: z.coerce.number().optional().describe('Page size (default 100, max 200)'), + ...paginationOptionsSchema, }), examples: [ { @@ -181,8 +175,7 @@ charts.command('meta', { description: 'List lightweight chart metadata for a board without query results', options: z.object({ boardId: z.string().describe('Board ID to list chart metadata from'), - page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'), - size: z.coerce.number().optional().describe('Page size (default 100, max 200)'), + ...paginationOptionsSchema, }), examples: [ { @@ -331,6 +324,11 @@ export function updateChartRun( const client = createClient() const updates = coerceChartBody(input) + // The charts endpoint only supports full-body PUT, so emulate a partial + // update by fetching the current chart and merging flags over it. Two + // consequences: a concurrent edit between the GET and PUT is overwritten, + // and fields can't be cleared via typed flags (nulls are normalized to + // undefined below) — use --body with explicit nulls to clear. return client .get( `/v0/boards/${encodeURIComponent(boardId)}/charts/${encodeURIComponent(chartId)}`, diff --git a/src/commands/contracts.ts b/src/commands/contracts.ts index a2254ef..404c42e 100644 --- a/src/commands/contracts.ts +++ b/src/commands/contracts.ts @@ -1,24 +1,19 @@ import { Cli, z } from 'incur' import { createClient, requireApiKey } from '../lib/client' import { parseJsonArray } from '../lib/json' +import { + buildPaginationParams, + paginationOptionsSchema, + type PaginationOptions, +} from '../lib/pagination' + +export type { PaginationOptions } export const contracts = Cli.create('contracts', { description: 'Smart contract commands — register, list, recommend, update, toggle pipeline inclusion, and remove tracked contracts', }) -export interface PaginationOptions { - page?: number - size?: number -} - -function buildPaginationParams(options: PaginationOptions = {}) { - const params: Record = {} - if (options.page !== undefined) params.page = options.page - if (options.size !== undefined) params.size = options.size - return params -} - function parseChain(chain: string | number) { const value = typeof chain === 'number' ? chain : Number(chain) if (!Number.isInteger(value) || value < 1) { @@ -39,10 +34,7 @@ export function listContractsRun(options: PaginationOptions = {}) { contracts.command('list', { description: 'List all tracked contracts for the project', - options: z.object({ - page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'), - size: z.coerce.number().optional().describe('Page size (default 100, max 200)'), - }), + options: z.object(paginationOptionsSchema), examples: [{ description: 'List all project contracts' }], hint: 'Requires contracts:read scope on your API key.', run({ options }) { @@ -56,7 +48,7 @@ export function getContractRun(chain: string, address: string) { requireApiKey() const client = createClient() return client.get( - `/v0/contracts/${encodeURIComponent(chain)}/${encodeURIComponent(address)}`, + `/v0/contracts/${parseChain(chain)}/${encodeURIComponent(address)}`, ) } @@ -210,7 +202,7 @@ export function updateContractRun( requireApiKey() const client = createClient() return client.put( - `/v0/contracts/${encodeURIComponent(chain)}/${encodeURIComponent(address)}`, + `/v0/contracts/${parseChain(chain)}/${encodeURIComponent(address)}`, buildUpdateContractBody(chain, address, options), ) } @@ -261,7 +253,7 @@ export function updateContractPipelineRun( requireApiKey() const client = createClient() return client.patch( - `/v0/contracts/${encodeURIComponent(chain)}/${encodeURIComponent(address)}/pipeline`, + `/v0/contracts/${parseChain(chain)}/${encodeURIComponent(address)}/pipeline`, buildUpdateContractPipelineBody(includeInPipeline), ) } @@ -308,7 +300,7 @@ export function deleteContractRun(chain: string, address: string) { requireApiKey() const client = createClient() return client.delete( - `/v0/contracts/${encodeURIComponent(chain)}/${encodeURIComponent(address)}`, + `/v0/contracts/${parseChain(chain)}/${encodeURIComponent(address)}`, ) } diff --git a/src/commands/events.ts b/src/commands/events.ts index a84958c..3f3e618 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -17,6 +17,10 @@ function getWriteKey(options: IngestEventsOptions) { } export function buildIngestEventsBody(options: IngestEventsOptions) { + if (options.events && options.event) { + throw new Error('Provide only one of --event or --events') + } + if (options.events) { const events = parseJsonArrayOfObjects(options.events, '--events') if (events.length === 0) { @@ -33,13 +37,9 @@ export function buildIngestEventsBody(options: IngestEventsOptions) { } export function ingestEventsRun(options: IngestEventsOptions) { - const writeKey = getWriteKey(options) - if (!writeKey) { - throw new Error( - 'No event write key configured. Pass --write-key or set FORMO_WRITE_KEY.', - ) - } - const client = createEventsClient(writeKey) + // createEventsClient throws the "No event write key configured" error for + // a missing key — no need to duplicate the guard here. + const client = createEventsClient(getWriteKey(options) ?? '') return client.post('/v0/raw_events', buildIngestEventsBody(options)) } diff --git a/src/commands/import.ts b/src/commands/import.ts index abf2619..daa5a37 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -15,6 +15,10 @@ export interface ImportWalletsOptions { } export function buildImportBody(options: ImportWalletsOptions) { + if (options.rows && options.addresses) { + throw new Error('Provide only one of --addresses or --rows') + } + if (options.rows) { const rows = parseJsonArrayOfObjects(options.rows, '--rows') const addresses = rows.map((row) => row.address) diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts index b767bc3..b248db0 100644 --- a/src/commands/profiles.ts +++ b/src/commands/profiles.ts @@ -224,8 +224,18 @@ profiles.command('search', { options: z.object({ address: z.string().optional().describe('Filter by wallet address'), search: z.string().optional().describe('Free-text search across address and identity fields'), - page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'), - size: z.coerce.number().optional().describe('Page size (default 100, max 1000)'), + page: z.coerce + .number() + .int() + .positive() + .optional() + .describe('Page number (1-indexed, default 1)'), + size: z.coerce + .number() + .int() + .positive() + .optional() + .describe('Page size (default 100, max 1000)'), orderBy: z .enum([ 'last_onchain', @@ -301,7 +311,7 @@ profiles.command('search', { }, ], hint: 'Requires profiles:read scope on your API key. Filter "field" must be a typed path (e.g. users.net_worth_usd) — bare names are ignored by the API.', - run({ args: _args, options }) { + run({ options }) { return searchProfilesRun(options) }, }) @@ -440,14 +450,16 @@ export interface CreateProfileLabelOptions { export function buildCreateLabelBody(options: CreateProfileLabelOptions): unknown { if (options.labels) { - try { - const parsed = JSON.parse(options.labels) - if (!Array.isArray(parsed) || parsed.length === 0) - throw new Error('not a non-empty array') - return parsed - } catch { - throw new Error('--labels must be a non-empty JSON array of UserLabelInput objects') + const parsed = parseJsonArrayOfObjects(options.labels, '--labels') + if (parsed.length === 0) { + throw new Error('--labels must contain at least one item') + } + for (const label of parsed) { + if (typeof label.tag_id !== 'string' || label.tag_id.length === 0) { + throw new Error('--labels entries must each include a non-empty string tag_id') + } } + return parsed } if (options.tagId) { const single: Record = { tag_id: options.tagId } diff --git a/src/commands/segments.ts b/src/commands/segments.ts index d6f3299..6c03279 100644 --- a/src/commands/segments.ts +++ b/src/commands/segments.ts @@ -1,5 +1,13 @@ import { Cli, z } from 'incur' import { createClient, requireApiKey } from '../lib/client' +import { parseJsonArray } from '../lib/json' +import { + buildPaginationParams, + paginationOptionsSchema, + type PaginationOptions, +} from '../lib/pagination' + +export type { PaginationOptions } export const segments = Cli.create('segments', { description: 'User segment commands — create, list, and delete audience segments', @@ -7,18 +15,6 @@ export const segments = Cli.create('segments', { // ── List segments ── -export interface PaginationOptions { - page?: number - size?: number -} - -function buildPaginationParams(options: PaginationOptions = {}) { - const params: Record = {} - if (options.page !== undefined) params.page = options.page - if (options.size !== undefined) params.size = options.size - return params -} - export function listSegmentsRun(options: PaginationOptions = {}) { requireApiKey() const client = createClient() @@ -27,10 +23,7 @@ export function listSegmentsRun(options: PaginationOptions = {}) { segments.command('list', { description: 'List all user segments for the project', - options: z.object({ - page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'), - size: z.coerce.number().optional().describe('Page size (default 100, max 200)'), - }), + options: z.object(paginationOptionsSchema), examples: [{ description: 'List all project segments' }], hint: 'Requires segments:read scope on your API key.', run({ options }) { @@ -46,14 +39,9 @@ export interface CreateSegmentOptions { } export function buildCreateSegmentBody(options: CreateSegmentOptions) { - let parsedFilterSets: unknown - try { - parsedFilterSets = JSON.parse(options.filterSets) - if (!Array.isArray(parsedFilterSets)) { - throw new Error('not an array') - } - } catch { - throw new Error('--filter-sets must be a valid JSON array') + const parsedFilterSets = parseJsonArray(options.filterSets, '--filter-sets') + if (parsedFilterSets.some((item) => typeof item !== 'string')) { + throw new Error('--filter-sets must be a JSON array of strings') } return { diff --git a/src/index.ts b/src/index.ts index 0bb7a1b..8d76000 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,8 +12,20 @@ import { profiles } from "./commands/profiles"; import { query } from "./commands/query"; import { segments } from "./commands/segments"; import { getApiBaseUrl } from "./lib/client"; -import { clearConfig, getApiKey, readConfig, saveConfig } from "./lib/config"; -import { banner, color, error, info, success, warn } from "./lib/ui"; +import { + clearConfig, + getApiKey, + getConfigFile, + readConfig, + saveConfig, +} from "./lib/config"; +import { banner, color, error, info, maskKey, success, warn } from "./lib/ui"; + +// Single source of truth for --version; a hardcoded literal here drifted +// from package.json more than once. package.json sits outside rootDir, so +// use require() rather than an import (tsc would widen the output layout). +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { version: VERSION } = require("../package.json") as { version: string }; const DASHBOARD_URL = "https://app.formo.so"; const DOCS_URL = "https://docs.formo.so"; @@ -46,32 +58,42 @@ interface ValidateApiKeyData { scopes: { project_id?: string } | null; } +type KeyValidationResult = + | { kind: "valid"; workspace: string; projectId: string } + | { kind: "invalid" } + | { kind: "unreachable" }; + async function validateAndFetchWorkspace( apiKey: string, -): Promise<{ workspace: string; projectId: string } | null> { +): Promise { try { const res = await fetch(`${getApiBaseUrl()}/api/validate-api-key`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ apiKey }), + // fetch has no default timeout — without this, login can hang forever. + signal: AbortSignal.timeout(10_000), }); - if (!res.ok) return null; + // The server answered and rejected the key — distinct from "couldn't + // reach the server", so login can refuse to save a known-bad key. + if (!res.ok) return { kind: "invalid" }; const body = (await res.json()) as ValidateApiKeyData; - if (!body.validated) return null; + if (!body.validated) return { kind: "invalid" }; return { + kind: "valid", workspace: body.details, projectId: body.scopes?.project_id ?? "", }; } catch { - return null; + return { kind: "unreachable" }; } } const cli = Cli.create("formo", { - version: "1.0.1", + version: VERSION, description: "Formo API CLI — Web3 analytics from the terminal", sync: { suggestions: [ @@ -123,26 +145,32 @@ cli.command("login", { process.stderr.write("\n" + color.dim("Validating API key…") + "\n"); } - const workspaceInfo = await validateAndFetchWorkspace(args.apiKey); + const result = await validateAndFetchWorkspace(args.apiKey); + + // The server explicitly rejected the key — refuse to save it. Saving a + // known-bad key just defers the failure to the user's next command. + if (result.kind === "invalid") { + throw new Error( + "API key was rejected by the Formo API (invalid or revoked). " + + "Check the key and try again, or create a new one at " + + `${DASHBOARD_URL} under Settings → API.`, + ); + } - // Save key + workspace info (save even if validation fails — user might be offline) - // Only include workspace/projectId when present so offline logins don't wipe existing values + // Save the key. Offline (unreachable) logins still save — the user may + // simply have no network right now. Only include workspace/projectId + // when validated so offline logins don't wipe existing values, and only + // a non-empty projectId so an unscoped key doesn't clear a stored one. + const workspaceInfo = result.kind === "valid" ? result : undefined; saveConfig({ apiKey: args.apiKey, - ...(workspaceInfo?.workspace !== undefined && { - workspace: workspaceInfo.workspace, - }), - ...(workspaceInfo?.projectId !== undefined && { - projectId: workspaceInfo.projectId, - }), + ...(workspaceInfo && { workspace: workspaceInfo.workspace }), + ...(workspaceInfo?.projectId && { projectId: workspaceInfo.projectId }), }); // Show feedback in human mode if (isTTY) { - const masked = - args.apiKey.length > 12 - ? args.apiKey.slice(0, 8) + "…" + args.apiKey.slice(-4) - : "***"; + const masked = maskKey(args.apiKey); if (workspaceInfo) { process.stderr.write( @@ -155,7 +183,7 @@ cli.command("login", { (workspaceInfo.projectId ? info(`Project: ${color.dim(workspaceInfo.projectId)}`) + "\n" : "") + - info(`Config: ${color.dim("~/.config/formo/config.json")}`) + + info(`Config: ${color.dim(getConfigFile())}`) + "\n\n" + color.dim("You can now use all formo commands.") + "\n" + @@ -170,10 +198,10 @@ cli.command("login", { "\n" + info(`Key: ${color.dim(masked)}`) + "\n" + - info(`Config: ${color.dim("~/.config/formo/config.json")}`) + + info(`Config: ${color.dim(getConfigFile())}`) + "\n" + color.dim( - "The API might be unreachable. Your key is saved and will be used for requests.", + "The API was unreachable. Your key is saved and will be used for requests.", ) + "\n\n", ); @@ -184,7 +212,7 @@ cli.command("login", { ok: true, message: workspaceInfo ? "API key validated and saved" - : "API key saved (not validated)", + : "API key saved (API unreachable, not validated)", workspace: workspaceInfo?.workspace ?? null, projectId: workspaceInfo?.projectId ?? null, }; @@ -205,7 +233,7 @@ cli.command("logout", { process.stderr.write( success("Logged out successfully.") + "\n" + - info("API key removed from ~/.config/formo/config.json") + + info(`API key removed from ${getConfigFile()}`) + "\n", ); } else { @@ -251,10 +279,7 @@ cli.command("status", { if (process.stdout.isTTY) { process.stderr.write("\n"); if (apiKey) { - const masked = - apiKey.length > 12 - ? apiKey.slice(0, 8) + "…" + apiKey.slice(-4) - : "***"; + const masked = maskKey(apiKey); process.stderr.write( success("Authenticated") + "\n" + @@ -285,7 +310,7 @@ cli.command("status", { source: apiKey ? source : null, workspace: config.workspace ?? null, projectId: config.projectId ?? null, - configFile: config.apiKey ? "~/.config/formo/config.json" : null, + configFile: config.apiKey ? getConfigFile() : null, }; }, }); @@ -306,7 +331,8 @@ cli.command(importCmd); // Show banner when run with no args (root help) const args = process.argv.slice(2); const isRootHelp = - args.length === 0 || (args.length === 1 && args[0] === "--help"); + args.length === 0 || + (args.length === 1 && (args[0] === "--help" || args[0] === "-h")); if (isRootHelp && process.stdout.isTTY) { process.stderr.write(banner()); } diff --git a/src/lib/client.ts b/src/lib/client.ts index 8983b7d..49e4d83 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -1,4 +1,8 @@ -import axios, { AxiosError } from 'axios' +import axios, { + AxiosError, + type AxiosInstance, + type AxiosRequestConfig, +} from 'axios' import { getApiKey } from './config' export const DEFAULT_API_BASE_URL = 'https://api.formo.so' @@ -48,7 +52,14 @@ export function parseApiError(error: AxiosError): DecoratedApiError { if (apiError?.param) parts.push(`Param: ${apiError.param}`) if (apiError?.details && Object.keys(apiError.details).length > 0) { const details = Object.entries(apiError.details) - .map(([key, value]) => `${key}: ${String(value)}`) + .map( + ([key, value]) => + `${key}: ${ + typeof value === 'object' && value !== null + ? JSON.stringify(value) + : String(value) + }`, + ) .join('; ') parts.push(`Details: ${details}`) } @@ -69,16 +80,40 @@ export interface ClientOptions { apiKey?: string } -function createClient(options: ClientOptions = {}) { +/** + * The response interceptor below unwraps every response to its body + * (`res.data`), so the raw AxiosInstance types would lie — they promise + * `AxiosResponse` while the runtime value is the body itself. This + * interface states what actually comes back. + */ +export interface FormoClient { + get(url: string, config?: AxiosRequestConfig): Promise + post(url: string, data?: unknown, config?: AxiosRequestConfig): Promise + put(url: string, data?: unknown, config?: AxiosRequestConfig): Promise + patch(url: string, data?: unknown, config?: AxiosRequestConfig): Promise + delete(url: string, config?: AxiosRequestConfig): Promise + request(config: AxiosRequestConfig): Promise + defaults: AxiosInstance['defaults'] +} + +function createClient(options: ClientOptions = {}): FormoClient { const apiKey = options.apiKey ?? getApiKey() const baseURL = options.baseURL ?? getApiBaseUrl() + // Fail here, not with a server-side 401: every authenticated command needs + // a key, and this guard catches call sites that forget requireApiKey(). + if (!apiKey) { + throw new Error( + 'No API key configured. Run `formo login ` or set FORMO_API_KEY env var.', + ) + } + const instance = axios.create({ baseURL, timeout: 30_000, headers: { 'Content-Type': 'application/json', - ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + Authorization: `Bearer ${apiKey}`, }, }) @@ -89,7 +124,9 @@ function createClient(options: ClientOptions = {}) { }, ) - return instance + // The interceptor changes what the promise resolves to; the cast makes the + // public type match the runtime behavior (see FormoClient above). + return instance as unknown as FormoClient } export function createEventsClient(writeKey: string) { diff --git a/src/lib/config.ts b/src/lib/config.ts index ed73f54..cdb5e48 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -2,8 +2,17 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -const CONFIG_DIR = path.join(os.homedir(), '.config', 'formo'); -const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); +// FORMO_CONFIG_DIR lets tests (and users) redirect config away from the real +// ~/.config/formo — resolved lazily so a test can set it after import. +function configDir(): string { + return ( + process.env.FORMO_CONFIG_DIR ?? path.join(os.homedir(), '.config', 'formo') + ); +} + +export function getConfigFile(): string { + return path.join(configDir(), 'config.json'); +} export interface FormoConfig { apiKey?: string; @@ -13,8 +22,14 @@ export interface FormoConfig { export function readConfig(): FormoConfig { try { - const raw = fs.readFileSync(CONFIG_FILE, 'utf-8'); - return JSON.parse(raw) as FormoConfig; + const raw = fs.readFileSync(getConfigFile(), 'utf-8'); + const parsed: unknown = JSON.parse(raw); + // JSON.parse happily returns null/"abc"/[1] — treat anything that isn't a + // plain object as an empty config instead of crashing later callers. + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + return parsed as FormoConfig; } catch { return {}; } @@ -28,30 +43,29 @@ export function saveConfig(updates: Partial): void { // tool, another app under ~/.config) keeps its old, possibly // group/world-readable perms — leaking the plaintext API key on a // multi-user host. chmod unconditionally so 0o700/0o600 always holds. - fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); - fs.chmodSync(CONFIG_DIR, 0o700); - fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2), { + fs.mkdirSync(configDir(), { recursive: true, mode: 0o700 }); + fs.chmodSync(configDir(), 0o700); + fs.writeFileSync(getConfigFile(), JSON.stringify(merged, null, 2), { mode: 0o600, }); - fs.chmodSync(CONFIG_FILE, 0o600); + fs.chmodSync(getConfigFile(), 0o600); } export function clearConfig(): void { try { - if (fs.existsSync(CONFIG_FILE)) { - fs.writeFileSync(CONFIG_FILE, JSON.stringify({}, null, 2), { - mode: 0o600, - }); - // Same create-only-mode caveat as saveConfig: enforce 0o600 on the - // already-existing file so the cleared config can't be left readable. - fs.chmodSync(CONFIG_FILE, 0o600); - } - } catch { - // Ignore errors if file doesn't exist + fs.writeFileSync(getConfigFile(), JSON.stringify({}, null, 2), { + mode: 0o600, + }); + // Same create-only-mode caveat as saveConfig: enforce 0o600 on the + // already-existing file so the cleared config can't be left readable. + fs.chmodSync(getConfigFile(), 0o600); + } catch (err) { + // Nothing to clear is fine; a real write failure (EACCES, EROFS) must + // surface so `formo logout` can't claim success while the key remains. + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; } } export function getApiKey(): string | undefined { return process.env.FORMO_API_KEY ?? readConfig().apiKey; } - diff --git a/src/lib/pagination.ts b/src/lib/pagination.ts new file mode 100644 index 0000000..a4f6e90 --- /dev/null +++ b/src/lib/pagination.ts @@ -0,0 +1,32 @@ +import { z } from 'incur' + +export interface PaginationOptions { + page?: number + size?: number +} + +/** + * Shared zod fragments for paginated list commands. + * Spread into a command's options: `z.object({ ...paginationOptionsSchema })`. + */ +export const paginationOptionsSchema = { + page: z.coerce + .number() + .int() + .positive() + .optional() + .describe('Page number (1-indexed, default 1)'), + size: z.coerce + .number() + .int() + .positive() + .optional() + .describe('Page size (default 100, max 200)'), +} + +export function buildPaginationParams(options: PaginationOptions = {}) { + const params: Record = {} + if (options.page !== undefined) params.page = options.page + if (options.size !== undefined) params.size = options.size + return params +} diff --git a/src/lib/ui.ts b/src/lib/ui.ts index ae612bf..2ae75f6 100644 --- a/src/lib/ui.ts +++ b/src/lib/ui.ts @@ -2,12 +2,13 @@ * Terminal UI utilities for the Formo CLI. * * Uses raw ANSI escape codes (no dependencies) for coloring and styling. - * Colors are automatically disabled when stdout is not a TTY (piped output, CI, etc.) - * or when the NO_COLOR environment variable is set. + * All decorated output is written to stderr (stdout is reserved for command + * results), so color is keyed to stderr being a TTY. Disabled whenever the + * NO_COLOR environment variable is present (any value, per the spec). */ -const isTTY = process.stdout.isTTY === true -const noColor = !!process.env.NO_COLOR +const isTTY = process.stderr.isTTY === true +const noColor = process.env.NO_COLOR !== undefined /** Whether color output is enabled */ const colorEnabled = isTTY && !noColor @@ -45,7 +46,7 @@ const LOGO_LINES = [ /** * Returns the Formo ASCII art banner in green. - * Only shown when stdout is a TTY. + * Only shown when stderr is a TTY. */ export function banner(): string { if (!isTTY) return '' @@ -77,10 +78,7 @@ export function info(message: string): string { // ── Formatting helpers ── -export function keyValue(key: string, value: string): string { - return `${color.dim(key + ':')} ${value}` -} - -export function heading(text: string): string { - return color.boldGreen(text) +/** Mask an API key for display, keeping just enough to identify it. */ +export function maskKey(key: string): string { + return key.length > 12 ? key.slice(0, 8) + '…' + key.slice(-4) : '***' } diff --git a/test/commands/analytics.test.ts b/test/commands/analytics.test.ts index f63f2fa..677a0f2 100644 --- a/test/commands/analytics.test.ts +++ b/test/commands/analytics.test.ts @@ -67,7 +67,7 @@ describe('commands/analytics', function () { /--params must be a valid JSON object/, ); expect(() => buildAnalyticsParams({ params: 'nope' })).to.throw( - /--params must be a valid JSON object/, + /--params must be valid JSON/, ); }); diff --git a/test/commands/bodyBuilders.test.ts b/test/commands/bodyBuilders.test.ts index 0820100..c798758 100644 --- a/test/commands/bodyBuilders.test.ts +++ b/test/commands/bodyBuilders.test.ts @@ -20,6 +20,7 @@ import { parseSearchConditions, } from '../../src/commands/profiles'; import { buildCreateSegmentBody } from '../../src/commands/segments'; +import { buildIngestEventsBody } from '../../src/commands/events'; describe('commands / body builders', function () { // ── Alerts ── @@ -423,4 +424,111 @@ describe('commands / body builders', function () { ); }); }); + + // ── Input validation guards ── + + describe('buildBoardBody() validation', function () { + it('throws when title resolves to an empty string', function () { + expect(() => buildBoardBody({ title: '' })).to.throw(/must not be empty/); + expect(() => buildBoardBody({ name: '' })).to.throw(/must not be empty/); + }); + }); + + describe('chain validation', function () { + const base = { + address: '0xabc', + name: 'Test', + abi: '[]', + events: '[]', + }; + + it('rejects zero, negative, and fractional chain IDs', function () { + for (const chain of [0, -5, 1.5, NaN]) { + expect( + () => buildCreateContractBody({ ...base, chain }), + `chain: ${chain}`, + ).to.throw(/chain must be a positive integer/); + } + }); + + it('accepts a positive integer chain', function () { + const body = buildCreateContractBody({ ...base, chain: 137 }); + expect(body).to.have.property('chain', 137); + }); + }); + + describe('buildCreateSegmentBody() validation', function () { + it('rejects non-array JSON', function () { + expect(() => + buildCreateSegmentBody({ title: 'x', filterSets: '{"a":1}' }), + ).to.throw(/must be a valid JSON array/); + }); + + it('rejects arrays containing non-string entries', function () { + expect(() => + buildCreateSegmentBody({ title: 'x', filterSets: '[{"a":1}]' }), + ).to.throw(/JSON array of strings/); + }); + }); + + describe('buildImportBody() mutually exclusive flags', function () { + it('throws when both --addresses and --rows are provided', function () { + expect(() => + buildImportBody({ + addresses: '["0xabc"]', + rows: '[{"address":"0xdef"}]', + }), + ).to.throw(/only one of --addresses or --rows/); + }); + + it('throws when neither is provided', function () { + expect(() => buildImportBody({})).to.throw(/--addresses or --rows/); + }); + }); + + describe('buildIngestEventsBody()', function () { + it('wraps a single --event in an array', function () { + const body = buildIngestEventsBody({ event: '{"event":"Test"}' }); + expect(body).to.deep.equal([{ event: 'Test' }]); + }); + + it('throws when both --event and --events are provided', function () { + expect(() => + buildIngestEventsBody({ + event: '{"event":"a"}', + events: '[{"event":"b"}]', + }), + ).to.throw(/only one of --event or --events/); + }); + + it('throws when neither is provided', function () { + expect(() => buildIngestEventsBody({})).to.throw(/--event or --events/); + }); + + it('throws on an empty --events array', function () { + expect(() => buildIngestEventsBody({ events: '[]' })).to.throw( + /at least one event/, + ); + }); + }); + + describe('buildCreateLabelBody() batch validation', function () { + it('rejects an empty --labels array', function () { + expect(() => buildCreateLabelBody({ labels: '[]' })).to.throw( + /at least one item/, + ); + }); + + it('rejects non-object entries', function () { + expect(() => buildCreateLabelBody({ labels: '["vip"]' })).to.throw( + /array of objects/, + ); + }); + + it('rejects entries without a tag_id', function () { + expect(() => + buildCreateLabelBody({ labels: '[{"value":"gold"}]' }), + ).to.throw(/non-empty string tag_id/); + }); + }); }); diff --git a/test/commands/charts.test.ts b/test/commands/charts.test.ts index 8ae369b..3ae246f 100644 --- a/test/commands/charts.test.ts +++ b/test/commands/charts.test.ts @@ -7,37 +7,41 @@ import { requiresLiveApi } from '../helpers/liveApi'; // Chart for get (bare resource — no envelope). describe('commands/charts', function () { - let boardId: string | undefined; - let firstChartId: string | undefined; + // Live tests are gated inside their own describe so the offline + // "local validation" tests below always run. + describe('live API', function () { + let boardId: string | undefined; + let firstChartId: string | undefined; - before(async function () { - await requiresLiveApi(this); - const res = await listBoardsRun() as { data: { id: string }[] }; - if (res.data.length > 0) boardId = res.data[0].id; - }); + before(async function () { + await requiresLiveApi(this); + const res = await listBoardsRun() as { data: { id: string }[] }; + if (res.data.length > 0) boardId = res.data[0].id; + }); - describe('listChartsRun()', function () { - it('returns paginated charts for a board with the parent board', async function () { - if (!boardId) return this.skip(); - const res = await listChartsRun(boardId) as { - data: { id: string }[]; - board: { id: string }; - total: number; - has_more: boolean; - }; - expect(res.data).to.be.an('array'); - expect(res.board).to.have.property('id'); - expect(res).to.have.property('total'); - expect(res).to.have.property('has_more'); - if (res.data.length > 0) firstChartId = res.data[0].id; + describe('listChartsRun()', function () { + it('returns paginated charts for a board with the parent board', async function () { + if (!boardId) return this.skip(); + const res = await listChartsRun(boardId) as { + data: { id: string }[]; + board: { id: string }; + total: number; + has_more: boolean; + }; + expect(res.data).to.be.an('array'); + expect(res.board).to.have.property('id'); + expect(res).to.have.property('total'); + expect(res).to.have.property('has_more'); + if (res.data.length > 0) firstChartId = res.data[0].id; + }); }); - }); - describe('getChartRun()', function () { - it('returns a chart by ID', async function () { - if (!boardId || !firstChartId) return this.skip(); - const res = await getChartRun(boardId, firstChartId) as { id: string }; - expect(res).to.have.property('id', firstChartId); + describe('getChartRun()', function () { + it('returns a chart by ID', async function () { + if (!boardId || !firstChartId) return this.skip(); + const res = await getChartRun(boardId, firstChartId) as { id: string }; + expect(res).to.have.property('id', firstChartId); + }); }); }); diff --git a/test/helpers/liveApi.ts b/test/helpers/liveApi.ts index 113b900..fa8a155 100644 --- a/test/helpers/liveApi.ts +++ b/test/helpers/liveApi.ts @@ -7,8 +7,10 @@ * (or directly in the test) to opt into the skip. */ import type { Context } from 'mocha'; +import { getApiBaseUrl } from '../../src/lib/client'; -const API_BASE_URL = 'https://api.formo.so'; +// Honor FORMO_API_BASE_URL so the probe hits the same host the client uses. +const API_BASE_URL = getApiBaseUrl(); let probeStatus: 'unknown' | 'ok' | 'unauthorized' | 'unreachable' = 'unknown'; let probePromise: Promise | undefined; diff --git a/test/lib/client.test.ts b/test/lib/client.test.ts index 0b84920..73a235a 100644 --- a/test/lib/client.test.ts +++ b/test/lib/client.test.ts @@ -26,18 +26,19 @@ describe('lib/client', function () { it('throws when no API key is configured', function () { const savedEnv = process.env.FORMO_API_KEY; + const savedConfigDir = process.env.FORMO_CONFIG_DIR; delete process.env.FORMO_API_KEY; - const configFile = path.join(os.homedir(), '.config', 'formo', 'config.json'); - let configBackup: string | null = null; - try { - configBackup = fs.readFileSync(configFile, 'utf-8'); - fs.writeFileSync(configFile, '{}', { mode: 0o600 }); - } catch { /* file may not exist */ } + // Point config at an empty temp dir so the real config file (which may + // hold a key) can't satisfy the lookup — and is never touched. + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'formo-client-test-')); + process.env.FORMO_CONFIG_DIR = tmpDir; try { expect(() => requireApiKey()).to.throw(/No API key configured/); + expect(() => createClient()).to.throw(/No API key configured/); } finally { - if (configBackup !== null) fs.writeFileSync(configFile, configBackup, { mode: 0o600 }); - process.env.FORMO_API_KEY = savedEnv; + fs.rmSync(tmpDir, { recursive: true, force: true }); + restoreEnv('FORMO_CONFIG_DIR', savedConfigDir); + restoreEnv('FORMO_API_KEY', savedEnv); } }); }); diff --git a/test/lib/config.test.ts b/test/lib/config.test.ts index 0318034..a7c6e74 100644 --- a/test/lib/config.test.ts +++ b/test/lib/config.test.ts @@ -3,32 +3,30 @@ import os from 'os'; import path from 'path'; import { expect } from 'chai'; -// We test config functions by overriding the env var so getApiKey() reads from env, -// and by directly exercising readConfig/saveConfig using the real config path. -// For isolation we save/restore the real config file around each test. +// Config functions honor FORMO_CONFIG_DIR (resolved lazily on every call), +// so the suite points it at a throwaway temp dir — the developer's real +// ~/.config/formo/config.json is never touched. describe('lib/config', function () { let originalApiKey: string | undefined; - let backupConfig: string | null = null; - const configFile = path.join(os.homedir(), '.config', 'formo', 'config.json'); + let originalConfigDir: string | undefined; + let tmpDir: string; + let configFile: string; before(function () { originalApiKey = process.env.FORMO_API_KEY; - // Back up any existing config - try { - backupConfig = fs.readFileSync(configFile, 'utf-8'); - } catch { - backupConfig = null; - } + originalConfigDir = process.env.FORMO_CONFIG_DIR; + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'formo-config-test-')); + process.env.FORMO_CONFIG_DIR = tmpDir; + configFile = path.join(tmpDir, 'config.json'); }); after(function () { - // Restore original config - if (backupConfig !== null) { - fs.mkdirSync(path.dirname(configFile), { recursive: true }); - fs.writeFileSync(configFile, backupConfig, { mode: 0o600 }); + fs.rmSync(tmpDir, { recursive: true, force: true }); + if (originalConfigDir !== undefined) { + process.env.FORMO_CONFIG_DIR = originalConfigDir; } else { - try { fs.unlinkSync(configFile); } catch { /* ignore */ } + delete process.env.FORMO_CONFIG_DIR; } if (originalApiKey !== undefined) { process.env.FORMO_API_KEY = originalApiKey; @@ -51,6 +49,20 @@ describe('lib/config', function () { const cfg = readConfig(); expect(cfg).to.have.property('apiKey', 'test_key_123'); }); + + it('returns empty object for non-object JSON (null, string, array)', async function () { + const { readConfig } = await import('../../src/lib/config'); + for (const content of ['null', '"abc"', '[1,2]']) { + fs.writeFileSync(configFile, content, { mode: 0o600 }); + expect(readConfig(), `content: ${content}`).to.deep.equal({}); + } + }); + + it('returns empty object for malformed JSON', async function () { + const { readConfig } = await import('../../src/lib/config'); + fs.writeFileSync(configFile, '{not json', { mode: 0o600 }); + expect(readConfig()).to.deep.equal({}); + }); }); describe('saveConfig()', function () { @@ -96,7 +108,7 @@ describe('lib/config', function () { saveConfig({ apiKey: 'file_key_fallback' }); expect(getApiKey()).to.equal('file_key_fallback'); // Restore for other tests - process.env.FORMO_API_KEY = process.env.TEST_TOKEN; + process.env.FORMO_API_KEY = originalApiKey; }); }); }); diff --git a/test/lib/json.test.ts b/test/lib/json.test.ts new file mode 100644 index 0000000..b4d4fc8 --- /dev/null +++ b/test/lib/json.test.ts @@ -0,0 +1,97 @@ +import { expect } from 'chai'; +import { + parseJson, + parseJsonArray, + parseJsonArrayOfObjects, + parseJsonObject, + parseStringArray, +} from '../../src/lib/json'; + +describe('lib/json', function () { + describe('parseJson()', function () { + it('parses valid JSON of any type', function () { + expect(parseJson('{"a":1}', '--x')).to.deep.equal({ a: 1 }); + expect(parseJson('[1,2]', '--x')).to.deep.equal([1, 2]); + expect(parseJson('"str"', '--x')).to.equal('str'); + }); + + it('throws with the flag name on invalid JSON', function () { + expect(() => parseJson('nope', '--my-flag')).to.throw( + /--my-flag must be valid JSON/, + ); + }); + }); + + describe('parseJsonObject()', function () { + it('accepts a plain object', function () { + expect(parseJsonObject('{"a":1}', '--x')).to.deep.equal({ a: 1 }); + }); + + it('rejects null, arrays, and primitives', function () { + for (const raw of ['null', '[1]', '"s"', '5']) { + expect(() => parseJsonObject(raw, '--x'), `raw: ${raw}`).to.throw( + /--x must be a valid JSON object/, + ); + } + }); + }); + + describe('parseJsonArray()', function () { + it('accepts an array', function () { + expect(parseJsonArray('[1,"a"]', '--x')).to.deep.equal([1, 'a']); + }); + + it('rejects non-arrays', function () { + expect(() => parseJsonArray('{"a":1}', '--x')).to.throw( + /--x must be a valid JSON array/, + ); + }); + }); + + describe('parseJsonArrayOfObjects()', function () { + it('accepts an array of objects', function () { + expect(parseJsonArrayOfObjects('[{"a":1}]', '--x')).to.deep.equal([ + { a: 1 }, + ]); + }); + + it('rejects entries that are not plain objects', function () { + for (const raw of ['[1]', '["a"]', '[null]', '[[1]]']) { + expect( + () => parseJsonArrayOfObjects(raw, '--x'), + `raw: ${raw}`, + ).to.throw(/--x must be a valid JSON array of objects/); + } + }); + }); + + describe('parseStringArray()', function () { + it('parses a JSON array of strings', function () { + expect(parseStringArray('["a","b"]', '--x')).to.deep.equal(['a', 'b']); + }); + + it('rejects a JSON array with non-string entries', function () { + expect(() => parseStringArray('["a",1]', '--x')).to.throw( + /--x must be a JSON array of strings/, + ); + }); + + it('splits comma-separated values and trims whitespace', function () { + expect(parseStringArray(' a, b ,c ', '--x')).to.deep.equal([ + 'a', + 'b', + 'c', + ]); + }); + + it('drops empty comma segments', function () { + expect(parseStringArray('a,,b,', '--x')).to.deep.equal(['a', 'b']); + }); + + it('throws when only empty segments remain', function () { + expect(() => parseStringArray(' , ,', '--x')).to.throw( + /--x must contain at least one value/, + ); + }); + }); +}); diff --git a/test/lib/pagination.test.ts b/test/lib/pagination.test.ts new file mode 100644 index 0000000..c89c05f --- /dev/null +++ b/test/lib/pagination.test.ts @@ -0,0 +1,20 @@ +import { expect } from 'chai'; +import { buildPaginationParams } from '../../src/lib/pagination'; + +describe('lib/pagination', function () { + describe('buildPaginationParams()', function () { + it('returns an empty object when no options given', function () { + expect(buildPaginationParams()).to.deep.equal({}); + expect(buildPaginationParams({})).to.deep.equal({}); + }); + + it('includes only the provided values', function () { + expect(buildPaginationParams({ page: 2 })).to.deep.equal({ page: 2 }); + expect(buildPaginationParams({ size: 50 })).to.deep.equal({ size: 50 }); + expect(buildPaginationParams({ page: 3, size: 25 })).to.deep.equal({ + page: 3, + size: 25, + }); + }); + }); +}); diff --git a/test/preload.cjs b/test/preload.cjs index 877c448..c21fa3f 100644 --- a/test/preload.cjs +++ b/test/preload.cjs @@ -13,13 +13,17 @@ Module._load = function (id, parent, isMain) { require('dotenv/config'); // Set FORMO_API_KEY synchronously — must happen before any test module loads -// so createClient() always picks up the correct key +// so createClient() always picks up the correct key. +// +// Without TEST_TOKEN the deterministic unit tests (body builders, sql, +// parseApiError, json) still run; a dummy key is set so the liveApi probe +// fails auth and every live-API integration test skips itself. This keeps +// `pnpm test` green locally and on fork PRs where secrets are unavailable. const token = process.env.TEST_TOKEN; if (!token) { process.stderr.write( - '\n ERROR: TEST_TOKEN is not set.\n' + - ' Add TEST_TOKEN=formo_your_key to your .env file before running tests.\n\n', + '\n ⚠ TEST_TOKEN is not set — live-API integration tests will be skipped.\n' + + ' Add TEST_TOKEN=formo_your_key to your .env file to run the full suite.\n\n', ); - process.exit(1); } -process.env.FORMO_API_KEY = token; +process.env.FORMO_API_KEY = token || 'formo_dummy_key_live_tests_will_skip';