diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3b690bc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CLI checks +on: + pull_request: + push: + branches: [master] +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-latest + strategy: + matrix: + node: [22, 24] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + - run: npm ci + - run: npm run check + - run: npm run test:package diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 0000000..7d70b29 --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,42 @@ +name: Publish CLI to npm +on: + workflow_dispatch: + inputs: + version: + description: Exact version already committed on master + required: true + type: string + channel: + description: npm distribution tag + required: true + default: next + type: choice + options: [next, latest] +permissions: + contents: read +concurrency: + group: cli-npm-release + cancel-in-progress: false +jobs: + publish: + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + environment: npm + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + registry-url: https://registry.npmjs.org + - name: Verify requested release + env: + RELEASE_VERSION: ${{ inputs.version }} + run: node --input-type=module -e "import fs from 'node:fs'; import assert from 'node:assert/strict'; assert.equal(JSON.parse(fs.readFileSync('package.json')).version, process.env.RELEASE_VERSION);" + - run: npm ci + - name: Publish implementation and alias + env: + RELEASE_CHANNEL: ${{ inputs.channel }} + run: npm run release:publish -- --tag "$RELEASE_CHANNEL" diff --git a/.gitignore b/.gitignore index caffb31..c9d331a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +artifacts/ *.tgz *.tsbuildinfo .env diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e5210c0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# DealMachine CLI + +This repository owns the public `@dealmachine/cli` package, the `dealmachine` npm alias, and the existing agent plugin assets. Start with [development](docs/development.md), [releases](docs/releases.md), and the [Command reference](docs/commands.md). + +Use `codex/` task branches and PRs into `master`. Inspect Git status before editing and preserve unrelated work. Node.js 22.18 or newer is recommended for development; the shipped CLI supports Node.js 18 and newer. + +Public Commands live in `src/commands`; HTTP, configuration and output helpers live in `src/lib`. Keep ESM `.js` import extensions in TypeScript. The repository builds independently with `npm ci` and `npm run check`. No Next checkout, database or API credentials are needed for those checks. + +Do not copy private beta Commands, private references, application credentials or backend source from Next into this public repository. Next's private CLI extends the installed npm artifact. Keep the public artifact guard and packed-install check passing. The bundled CLI Playbook lives at `playbook/PLAYBOOK.md`; the existing MCP-oriented agent plugin lives under `skills/dealmachine`. They have different execution surfaces. + +Use the existing behavioral tests for changed Commands. Run `npm run check` and `npm run test:package` for packaging or release changes. Cold-start published/deployed evaluations contact external services and are separate release verification. + +Set versions with `npm run release:version -- ` so the canonical package, alias and lockfile agree. A release requires an explicit channel and approved release scope. Publishing the CLI does not deploy the API, MCP server, docs site or Next app. + +Call CLI capabilities Commands, API capabilities Endpoints, and distributable agent instructions Playbooks. Write concrete copy and do not add em dashes to docs or comments. diff --git a/README.md b/README.md index bafccdf..714b2ff 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # DealMachine CLI +Maintainers: [agent instructions](AGENTS.md), [development](docs/development.md), [current Command reference](docs/commands.md), and [npm releases](docs/releases.md). + DealMachine CLI (`dm`) -- property intelligence from the command line. -A standalone Commander.js CLI that talks to the DealMachine REST API. Provides **17 command groups** covering agent guidance, authentication, property search, people lookup, enrichment, comps, list management, and developer utilities. Compiles to a single ESM bundle via `tsc`. +A standalone Commander.js CLI that talks to the DealMachine REST API. Provides Commands for authentication, property and people research, enrichment, lists, prospects, tags, webhooks, mail and developer utilities. Compiles to ESM JavaScript via `tsc`. This package has **zero** `@dealmachine/*` dependencies -- it is a self-contained binary that communicates exclusively through the public API. @@ -106,7 +108,7 @@ The canonical implementation package is `@dealmachine/cli`. The `dealmachine` pa ### From source ```bash -cd packages/cli +cd dealmachine-cli npm run build node dist/index.js whoami ``` @@ -114,7 +116,7 @@ node dist/index.js whoami ### Link for local development ```bash -cd packages/cli +cd dealmachine-cli npm link dm --version ``` @@ -256,7 +258,7 @@ dm agents playbook --json dm agents skill # alias ``` -The public CLI source keeps its bundled Playbook at `playbook/PLAYBOOK.md`. Monorepo builds can also copy `packages/playbooks/playbook/SKILL.md`. The build writes the selected source to `dist/agents/dealmachine-playbook.md`, so the command works from a published CLI package as well as a local source checkout. +The public CLI source keeps its bundled Playbook at `playbook/PLAYBOOK.md`. The build writes the selected source to `dist/agents/dealmachine-playbook.md`, so the command works from a published CLI package as well as a local source checkout. #### `dm agents install claude-code` @@ -423,7 +425,7 @@ Output: Credit Usage ────────────────────────────────────────────────── Plan: Pro - Cycle: Mar 1, 2026 — Mar 31, 2026 + Cycle: Mar 1, 2026 : Mar 31, 2026 Credits: 4,200 / 10,000 (42%) Remaining: 5,800 @@ -1135,7 +1137,7 @@ Batches larger than 250 items are automatically chunked with progress spinners. ## Project Structure ``` -packages/cli/ +dealmachine-cli/ scripts/ copy-agent-assets.mjs # Bundles the Playbook Markdown into dist/agents src/ @@ -1201,7 +1203,7 @@ For distribution as a standalone binary without npm: ```bash # Build -cd packages/cli +cd dealmachine-cli npm run build # The entire dist/ directory is the distributable artifact diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..d57c4bc --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,1282 @@ +# DealMachine CLI + +DealMachine CLI (`dm`) -- property intelligence from the command line. + +A standalone Commander.js CLI that talks to the DealMachine REST API. It covers agent guidance, authentication, property search, people lookup, enrichment, comps, drive history, Prospect management, mail, and developer utilities. Compiles to a single ESM bundle via `tsc`. + +This package has **zero** `@dealmachine/*` dependencies -- it is a self-contained binary that communicates exclusively through the public API. + +--- + +## Table of Contents + +- [Installation](#installation) +- [Authentication](#authentication) +- [Configuration](#configuration) +- [Commands](#commands) + - [Agents](#agents-commands) -- `agents`, `agents guide`, `agents playbook` + - [Auth](#auth-commands) -- `login`, `logout`, `whoami` + - [Config](#config-commands) -- `config get`, `config set`, `config path` + - [Account](#account-commands) -- `account` + - [Usage](#usage-commands) -- `usage` + - [Properties](#properties-commands) -- `search`, `count`, `get`, `ids`, `export` + - [People](#people-commands) -- `search`, `count`, `get`, `ids`, `export` + - [Enrich](#enrich-commands) -- `address`, `latlng`, `apn`, `email`, `phone`, `name` + - [Comps](#comps-commands) -- comparable property analysis + - [Lists](#lists-commands) -- `search`, `create`, `get`, `update`, `delete`, `build`, `import`, `items`, `add`, `remove`, `export` + - [Driving](#driving-commands) -- `list`, `get` + - [Filters](#filters-commands) -- list available search filters + - [Fields](#fields-commands) -- list available data fields + - [Activity](#activity-commands) -- `search`, `get` + - [Addresses](#addresses-commands) -- `autocomplete`, `validate` + - [Dev](#dev-commands) -- `license add`, `license list`, `license remove` +- [Global Options](#global-options) +- [Input Methods](#input-methods) +- [Project Structure](#project-structure) +- [Building](#building) +- [Adding New Commands](#adding-new-commands) +- [Dependencies](#dependencies) + +--- + +## Installation + +### From npm (global) + +```bash +npm install -g dealmachine +dm login +``` + +The canonical implementation package is `@dealmachine/cli`. The `dealmachine` package is the short install alias and provides the same `dm` command. + +### From source + +```bash +cd dealmachine-cli +npm run build +node dist/index.js whoami +``` + +### Link for local development + +```bash +cd dealmachine-cli +npm link +dm --version +``` + +The binary entry is `dist/index.js`, declared in `package.json` under `bin.dm`. Requires Node.js >= 18. + +--- + +## Authentication + +The CLI supports two authentication methods. + +### Device Auth Flow (RFC 8628) + +The default `dm login` command uses the OAuth 2.0 Device Authorization Grant (RFC 8628). This is the recommended flow for interactive use: + +```bash +dm login +``` + +1. The CLI requests a device code from `POST /v1/auth/device/code` with client ID `dealmachine-next-cli` and your machine's hostname. +2. A verification URL and user code are displayed. The browser opens automatically (unless `--no-browser`). +3. You authorize the device in the browser by entering the user code. +4. The CLI polls `POST /v1/auth/device/token` at the server-specified interval. +5. On success, the API key, key ID, and organization details are stored to `~/.dealmachine/config.json`. + +The polling handles all RFC 8628 responses: `authorization_pending`, `slow_down` (backs off by 5s), `access_denied`, and `expired_token`. + +```bash +# Skip auto-opening the browser +dm login --no-browser + +# Target a specific environment +dm login --env local +dm login --env staging +``` + +### Direct API Key Login + +For CI pipelines, scripts, or local development, pass an API key directly: + +```bash +dm login --key dm_sk_live_abc123... +``` + +The key is verified against `GET /v1/account` before being stored. If verification fails, the CLI exits with a non-zero code. + +If you do not have an API key yet, use `dm signup`, `dm plans`, and `dm checkout` first. Public plan checkout only accepts self-serve Basic and Pro prices from the shared plan catalog and is capped at 60,000 monthly data credits. + +### Switching Environments + +If you are already logged in, you can switch the target API environment without logging out: + +```bash +dm login --env local # Switch to http://localhost:3001/v1 +dm login --env staging # Switch to https://api-staging.v2.dealmachine.com/v1 +dm login --env production # Switch to https://api.v2.dealmachine.com/v1 +``` + +### Logout + +```bash +dm logout +``` + +Removes the config file at `~/.dealmachine/config.json`. + +--- + +## Configuration + +Credentials are stored at `~/.dealmachine/config.json` with file permissions `0600` (owner read/write only). The config directory `~/.dealmachine/` is created with mode `0700`. + +### Config File Schema + +```json +{ + "apiKey": "dm_sk_live_...", + "keyId": "key_abc123", + "organizationId": 42, + "organizationName": "Acme Corp", + "organizationSlug": "acme-corp", + "apiEnvironment": "production" +} +``` + +### Environment Variables + +The CLI checks these environment variables for API URL resolution (in priority order): + +| Variable | Purpose | Example | +| ------------------------------------ | ------------------- | ----------------------------------- | +| `DM_API_URL` / `DEALMACHINE_API_URL` | Direct URL override | `http://localhost:3001/v1` | +| `DM_ENV` / `DEALMACHINE_ENVIRONMENT` | Environment name | `local`, `staging`, or `production` | + +If none are set, the CLI falls back to the `apiEnvironment` field in the config file, then defaults to `production`. + +### API Environments + +| Environment | URL | +| ------------ | ------------------------------------------- | +| `local` | `http://localhost:3001/v1` | +| `staging` | `https://api-staging.v2.dealmachine.com/v1` | +| `production` | `https://api.v2.dealmachine.com/v1` | + +--- + +## Commands + +### Agents Commands + +#### `dm agents` + +Print concise guidance for agents using the CLI. This is the recommended first command when an agent has access to `dm` but has not loaded the DealMachine Playbook yet. + +```bash +dm agents +dm agents --json +``` + +The guide tells agents to use `--json` and `--quiet`, verify auth, fetch live filters and fields before searches, count before credit-consuming work, and confirm expected credit usage before fetching records or exporting. + +#### `dm agents guide` + +Print the same concise agent guidance explicitly. + +```bash +dm agents guide +dm agents guide --json +``` + +#### `dm agents playbook` + +Print the bundled DealMachine Playbook Markdown. Agents should load this before translating natural language property, people, contact, enrichment, list, export, comps, or credit-usage requests into CLI commands. + +```bash +dm agents playbook +dm agents playbook --json +dm agents skill # alias +``` + +The Playbook is copied from `packages/playbooks/playbook/SKILL.md` into `dist/agents/dealmachine-playbook.md` during `npm run build`, so the command works from a published CLI package as well as a local source checkout. + +#### `dm agents install claude-code` + +Install the Playbook as a native Claude Code skill. Personal scope is the default. Project scope +installs under the current repository. + +```bash +dm agents install claude-code +dm agents install claude-code --project +``` + +#### `dm agents permissions` + +Print the narrow Claude Code allowlist for free discovery and count commands. Paid and mutating +commands are not pre-approved. + +```bash +dm agents permissions +dm agents permissions --json +``` + +--- + +### Auth Commands + +#### `dm signup` + +Create a public API account and receive an API key: + +```bash +dm signup developer@example.com --first-name Ada --last-name Lovelace --phone-number +15551234567 +dm signup developer@example.com --login +``` + +#### `dm plans` + +List public self-serve Basic and Pro plans: + +```bash +dm plans +dm plans --json +``` + +#### `dm checkout` + +Create a Stripe checkout session using a price ID from `dm plans`: + +```bash +dm checkout --price-id price_xxx_monthly +``` + +#### `dm login` + +Authenticate with your DealMachine account. + +```bash +dm login # Device auth flow (opens browser) +dm login --no-browser # Device auth, manual code entry +dm login --key dm_sk_live_abc123 # Direct API key +dm login --env local # Target local API +``` + +| Option | Description | +| --------------------- | ---------------------------------------------------- | +| `--no-browser` | Do not automatically open the browser | +| `--key ` | Login directly with an API key (skips browser) | +| `--env ` | API environment: `local`, `staging`, or `production` | + +#### `dm logout` + +Remove stored credentials. + +```bash +dm logout +``` + +#### `dm whoami` + +Show current authentication status. + +```bash +dm whoami # Show stored credentials +dm whoami --verify # Verify credentials against the API +``` + +| Option | Description | +| ---------- | ------------------------------- | +| `--verify` | Verify credentials with the API | + +--- + +### Config Commands + +#### `dm config get [key]` + +Get a configuration value, or display all values when no key is given. + +```bash +dm config get # Show all config values +dm config get apiEnvironment # Show specific value +dm config get apiKey # Shows truncated key (first 20 chars) +``` + +Available keys: `organizationName`, `organizationSlug`, `organizationId`, `apiEnvironment`, `keyId`, `apiKey`. + +#### `dm config set ` + +Set a configuration value. Only `apiEnvironment` is editable. + +```bash +dm config set apiEnvironment local +dm config set apiEnvironment staging +dm config set apiEnvironment production +``` + +#### `dm config path` + +Print the absolute path to the config file. + +```bash +dm config path +# /Users/you/.dealmachine/config.json +``` + +--- + +### Account Commands + +#### `dm account` + +Display account information including organization name, ID, creation date, and auth type. + +```bash +dm account +``` + +Output: + +``` +Account +──────────────────────────────────────── +Organization: Acme Corp +Org ID: 42 +Created: Jan 15, 2025 +Auth Type: api_key +``` + +--- + +### Usage Commands + +#### `dm usage` + +Show credit usage for the current billing cycle. + +```bash +dm usage # Human-readable table +dm usage --json # Machine-readable JSON +``` + +Output: + +``` +Credit Usage +────────────────────────────────────────────────── + Plan: Pro + Cycle: Mar 1, 2026 : Mar 31, 2026 + + Credits: 4,200 / 10,000 (42%) + Remaining: 5,800 + + Breakdown: + Properties: 3,100 + People: 1,100 +``` + +--- + +### Properties Commands + +#### `dm properties search` + +Search properties with filters and locations. + +```bash +# Inline JSON body +dm properties search --body '{ + "locations": [{"type": "zip_code", "code": "78704"}], + "filters": [{"filter_id": "property_type", "operator": "is_any_of", "value": ["single_family"]}] +}' + +# From a file +dm properties search -f search.json + +# Pipe from stdin +cat search.json | dm properties search + +# Machine-readable output +dm properties search -f search.json --json + +# Query Builder protocol filters +dm properties search --include-lists 123,456 --exclude-previously-exported --body '{"locations":[]}' +``` + +| Option | Description | +| --------------------------------- | ----------------------------------------------------------------------------- | +| `--body ` | Request body as JSON string | +| `-f, --file ` | Read request body from a JSON file | +| `--include-lists ` | Comma-separated list IDs to include | +| `--exclude-lists ` | Comma-separated list IDs to exclude | +| `--exclude-previously-exported` | Exclude records already exported by your organization | +| `--bigquery-data-environment ` | Query Builder data environment (`1` production, `2` staging, `3` development) | +| `--json` | Output as JSON | + +#### `dm properties count` + +Count properties matching filters without consuming credits. + +```bash +dm properties count --body '{"locations": [{"type": "state", "code": "TX"}]}' +dm properties count -f filters.json --json +``` + +#### `dm properties get ` + +Get a single property by its DealMachine ID. + +```bash +dm properties get prop_12345 +dm properties get prop_12345 --contact-audience owners_and_family +dm properties get prop_12345 --contact-audience none +dm properties get prop_12345 --fields estimated_value,year_built +dm properties get prop_12345 --json +``` + +| Option | Description | +| ------------------------------- | -------------------------------------------------------------------- | +| `--contact-audience ` | `owners`, `owners_and_family`, `renters`, `residents`, `all`, `none` | +| `--fields ` | Comma-separated property field IDs from `dm fields` | +| `--json` | Output as JSON | + +Property lookup defaults to `owners`. If you only need property data, use `--contact-audience none`. This omits contacts and avoids people credits. + +#### `dm properties ids [ids...]` + +Get multiple properties by their IDs in a single batch request. + +```bash +# Positional arguments +dm properties ids prop_111 prop_222 prop_333 + +# Via JSON body +dm properties ids --body '{"ids": ["prop_111", "prop_222"]}' + +# From file +dm properties ids -f ids.json --contact-audience owners +dm properties ids -f ids.json --contact-audience none +``` + +| Option | Description | +| ------------------------------- | -------------------------------------------------------------------------------------- | +| `--body ` | Request body as JSON string | +| `-f, --file ` | Read request body from a JSON file | +| `--contact-audience ` | Include contacts: `owners`, `owners_and_family`, `renters`, `residents`, `all`, `none` | +| `--json` | Output as JSON | + +#### `dm properties export` + +Export properties as CSV (up to 1,000,000 records). Returns signed download URLs. + +```bash +dm properties export -f search.json +dm properties export -f search.json --require-phone --scrub-dnc +dm properties export --body '{"locations": [...]}' --mobile-only --json +``` + +| Option | Description | +| ------------------- | ----------------------------------------------------------- | +| `--body ` | Request body as JSON string | +| `-f, --file ` | Read request body from a JSON file | +| `--require-phone` | Only include records where the contact has a phone number | +| `--require-email` | Only include records where the contact has an email address | +| `--mobile-only` | Only include wireless phone numbers | +| `--landline-only` | Only include landline phone numbers | +| `--scrub-dnc` | Exclude contacts on the Do Not Call registry | +| `--json` | Output as JSON | + +--- + +### People Commands + +#### `dm people search` + +Search people with filters and locations. + +```bash +dm people search --body '{ + "locations": [{"type": "zip_code", "code": "78704"}], + "filters": [{"filter_id": "age", "operator": "between", "value": [30, 50]}] +}' +dm people search -f people-search.json --json +dm people search --include-lists 123 --exclude-lists 456 --exclude-previously-exported --body '{"locations":[]}' +``` + +#### `dm people count` + +Count people matching filters without consuming credits. + +```bash +dm people count -f filters.json +``` + +#### `dm people get ` + +Get a single person by their DealMachine ID. + +```bash +dm people get per_12345 +dm people get per_12345 --include-properties --property-limit 20 +dm people get per_12345 --fields estimated_household_income,estimated_value +dm people get per_12345 --json +``` + +| Option | Description | +| ---------------------- | -------------------------------------------------- | +| `--include-properties` | Include associated properties | +| `--property-limit ` | Maximum associated properties, default 20, max 100 | +| `--fields ` | Comma-separated people or property field IDs | +| `--json` | Output as JSON | + +#### `dm people ids [ids...]` + +Get multiple people by their IDs in a single batch request. + +```bash +dm people ids per_111 per_222 per_333 +dm people ids --body '{"ids": ["per_111", "per_222"]}' --include-properties --property-limit 20 +dm people ids per_111 per_222 --fields estimated_household_income,estimated_value +``` + +`dm people ids` supports the same `--include-properties`, `--property-limit`, and `--fields` +options as `dm people get`. + +#### `dm people export` + +Export people as CSV (up to 1,000,000 records). Returns signed download URLs. + +```bash +dm people export -f search.json --require-email +dm people export -f search.json --mobile-only --scrub-dnc --json +``` + +Contact filter options are the same as `dm properties export`. + +--- + +### Enrich Commands + +All enrichment commands support three input modes: a positional argument for single-item lookup, `--body`/`-f` for JSON payloads, and `-f` with a `.csv` file for batch enrichment from CSV. Batches larger than 250 items are automatically chunked. + +#### `dm enrich address [address]` + +Look up a property by street address. + +```bash +# Single address +dm enrich address "123 Main St, Austin, TX 78704" +dm enrich address "123 Main St, Austin, TX 78704" --contact-audience none +dm enrich address "123 Main St, Austin, TX 78704" --fields estimated_value,year_built + +# Batch from JSON +dm enrich address --body '{"data": [{"full_address": "123 Main St, Austin, TX"}]}' + +# Batch from CSV (auto-detected by .csv extension) +dm enrich address -f addresses.csv --contact-audience owners + +# CSV columns: full_address (or street, city, state, zip) +``` + +| Option | Description | +| ------------------------------- | ------------------------------------------------------------- | +| `--body ` | Request body as JSON string | +| `-f, --file ` | Read from JSON or CSV file | +| `--contact-audience ` | `owners`, `owners_and_family`, `renters`, `residents`, `none` | +| `--fields ` | Comma-separated property or people field IDs | +| `--json` | Output as JSON | + +Use `--contact-audience none` whenever you only need the property. The response omits contacts and consumes zero people credits. + +#### `dm enrich latlng [coords]` + +Look up a property by latitude/longitude coordinates. + +```bash +dm enrich latlng 30.25,-97.75 +dm enrich latlng -f coordinates.csv --contact-audience none --fields estimated_value +# CSV columns: latitude, longitude (or lat, lng/lon/long) +``` + +#### `dm enrich apn [apn]` + +Look up a property by Assessor's Parcel Number. Narrow results with `--state` or `--zip`. + +```bash +dm enrich apn "0123-456-789" --state TX +dm enrich apn -f parcels.csv --zip 78704 --fields estimated_value +# CSV columns: apn (or parcel_id, parcel_number) +``` + +| Option | Description | +| ------------------------------- | ------------------------------------------------------------- | +| `--state ` | Narrow by state (e.g., TX) | +| `--zip ` | Narrow by ZIP code | +| `--contact-audience ` | `owners`, `owners_and_family`, `renters`, `residents`, `none` | +| `--fields ` | Comma-separated property or people field IDs | + +#### `dm enrich email [email]` + +Look up a person by email address. + +```bash +dm enrich email jane@example.com +dm enrich email jane@example.com --include-properties +dm enrich email jane@example.com --fields full_name,phones,estimated_value +dm enrich email -f emails.csv --json +# CSV columns: email (or email_address) +``` + +| Option | Description | +| ---------------------- | -------------------------------------------- | +| `--include-properties` | Include associated properties | +| `--fields ` | Comma-separated people or property field IDs | + +#### `dm enrich phone [phone]` + +Look up a person by phone number. + +```bash +dm enrich phone 5125551234 +dm enrich phone -f phones.csv --include-properties --fields full_name,emails,estimated_value +# CSV columns: phone (or phone_number) +``` + +#### `dm enrich name [name]` + +Look up people by name. Supports "First Last" or just "Last" format. + +```bash +dm enrich name "Jane Doe" +dm enrich name "Jane Doe" --state TX --estimate-cost +dm enrich name "Doe" --state TX --page 2 +dm enrich name "Jane Doe" --zip 78704 --include-properties --fields full_name,phones,estimated_value +dm locations search -q "Austin" --type city --state TX --json +dm enrich name "Jane Doe" --city 7333 --estimate-cost +``` + +| Option | Description | +| ---------------------- | ---------------------------------------------- | +| `--state ` | Narrow by state | +| `--zip ` | Narrow by ZIP code | +| `--county ` | Narrow by county FIPS | +| `--city ` | Narrow by city place ID | +| `--include-properties` | Include associated properties | +| `--fields ` | Comma-separated people or property field IDs | +| `--estimate-cost` | Preview people and property counts and credits | +| `--page ` | Page number | +| `--per-page ` | Results per page | + +Email, phone, and name enrichment return a free `property_count` for every matched person. Phone +types in JSON output are normalized to `wireless`, `landline`, `voip`, or `unknown`. +For name enrichment, combine `--estimate-cost` with `--include-properties` to include associated +property totals and page-aware property credits in the free estimate. + +--- + +### Comps Commands + +#### `dm comps [property_ids...]` + +Find comparable properties (sales comps) for one or more properties. + +```bash +# Single property with defaults +dm comps prop_12345 + +# Multiple properties with options +dm comps prop_12345 prop_67890 --radius 2 --timeframe 12months --limit 50 + +# Full control via JSON body +dm comps --body '{ + "property_ids": ["prop_12345"], + "location": {"type": "radius", "radius_miles": 1.5}, + "criteria": {"timeframe": "6months", "sort_by": "match", "limit": 25} +}' +``` + +| Option | Description | +| ------------------------ | ---------------------------------------------------------- | +| `--body ` | Request body as JSON string | +| `-f, --file ` | Read request body from a JSON file | +| `--radius ` | Search radius in miles (default: 1) | +| `--timeframe ` | `3months`, `6months`, `12months`, `all` (default: 6months) | +| `--limit ` | Max comps per property (default: 25, max: 100) | +| `--sort-by ` | `distance`, `price`, `date`, `match` (default: match) | +| `--sort-direction ` | `asc`, `desc` (default: desc) | +| `--include-foreclosures` | Include foreclosure sales | +| `--json` | Output as JSON | + +Output includes subject property details, value estimation with confidence interval, summary statistics (average/median price, price per sqft), and a table of comparable properties. + +--- + +### Lists Commands + +#### `dm lists search` + +Search and list all saved lists. + +```bash +dm lists search +dm lists search --search "Austin" --source-type properties --sort newest +dm lists search --page 2 --per-page 50 --json +``` + +| Option | Description | +| ---------------------- | ----------------------------------- | +| `--search ` | Search lists by name | +| `--source-type ` | `properties` or `people` | +| `--sort ` | `newest`, `oldest`, `name`, `count` | +| `-p, --page ` | Page number | +| `--per-page ` | Results per page | + +#### `dm lists create` + +Create a new list. + +```bash +# Empty list +dm lists create --name "Austin Leads" + +# Pre-populated with record IDs (max 250) +dm lists create --name "Hot Leads" --source-type properties --ids 123,456,789 + +# With search filters for a list build +dm lists create --name "TX SFR" -f search-filters.json +``` + +| Option | Description | +| ---------------------- | ---------------------------------------------------- | +| `--name ` | List name (required) | +| `--source-type ` | `properties` or `people` | +| `--ids ` | Comma-separated record IDs to pre-populate (max 250) | +| `--body ` | Request body as JSON (filters/locations) | +| `-f, --file ` | Read request body from a JSON file | + +#### `dm lists get ` + +Get details of a specific list including status, progress, and error state. + +```bash +dm lists get list_abc123 +``` + +#### `dm lists update ` + +Rename a list. + +```bash +dm lists update list_abc123 --name "New Name" +``` + +#### `dm lists delete ` + +Delete a list and all its items. + +```bash +dm lists delete list_abc123 +``` + +#### `dm lists build ` + +Build a list from search filters. This is an asynchronous operation -- poll with `dm lists get` for status. + +```bash +dm lists build list_abc123 -f search-filters.json +``` + +#### `dm lists import ` + +Import record IDs into an existing list. + +```bash +dm lists import list_abc123 --ids 111,222,333 --source-type properties +dm lists import list_abc123 -f import-payload.json +``` + +#### `dm lists items ` + +List items in a list with pagination. + +```bash +dm lists items list_abc123 +dm lists items list_abc123 --page 2 --per-page 100 --json +``` + +#### `dm lists add ` + +Add items to a list by ID. + +```bash +dm lists add list_abc123 --ids 111,222,333 +dm lists add list_abc123 --ids 111,222 --id-type internal_property_id +``` + +| Option | Description | +| ------------------ | ---------------------------------------------- | +| `--ids ` | Comma-separated list of IDs to add (required) | +| `--id-type ` | `internal_property_id` or `internal_person_id` | + +#### `dm lists remove ` + +Remove items from a list by ID. + +```bash +dm lists remove list_abc123 --ids 111,222,333 +``` + +#### `dm lists export ` + +Export list items. Credits are charged per record. + +```bash +dm lists export list_abc123 +dm lists export list_abc123 --fields "full_address,estimated_value,owner_name" --anchor property +``` + +| Option | Description | +| ----------------- | ---------------------------------------- | +| `--fields ` | Comma-separated list of fields to export | +| `--anchor ` | `property` or `person` | + +--- + +### Driving Commands + +Read recorded drives and the Prospects added during them. + +```bash +dm driving list +dm driving list --mode free_drive --started-after 2026-08-01 --json +dm driving get drive_session_501 +dm prospects list --source driving +``` + +Drive history is read-only. Use `dm prospects` for lifecycle, notes, and tags on linked Prospects. + +### Filters Commands + +#### `dm filters` + +List available search filters with their types, operators, and groupings. + +```bash +dm filters +dm filters --source-type properties --search "bed" +dm filters --group-id building_information --json +``` + +| Option | Description | +| ---------------------- | ------------------------ | +| `--source-type ` | `properties` or `people` | +| `--group-id ` | Filter by group ID | +| `--search ` | Search filters by name | +| `--page ` | Page number | +| `--per-page ` | Results per page | + +--- + +### Fields Commands + +#### `dm fields` + +List available data fields with filterable/sortable flags. + +```bash +dm fields +dm fields --source-type people --search "phone" +dm fields --group-id contact_info --json +``` + +| Option | Description | +| ---------------------- | ------------------------ | +| `--source-type ` | `properties` or `people` | +| `--group-id ` | Filter by group ID | +| `--search ` | Search fields by name | +| `--page ` | Page number | +| `--per-page ` | Results per page | + +--- + +### Locations Commands + +Search and retrieve DealMachine locations. + +```bash +dm locations search -q "Harris" --type county --state TX --json +dm locations get loc_city_48106 --json +``` + +`dm locations autocomplete` remains available as a deprecated alias for `dm addresses autocomplete`. + +--- + +### Activity Commands + +#### `dm activity search` + +Search past API activity with type filters and free-text search. + +```bash +dm activity search -t search_properties enrich_address +dm activity search -q "Austin" --page 2 +dm activity search --body '{"types": ["search_properties"], "page": 1}' +``` + +| Option | Description | +| ------------------------ | ------------------------------------------ | +| `--body ` | Request body as JSON string | +| `-f, --file ` | Read request body from a JSON file | +| `-t, --types ` | Filter by activity types (space-separated) | +| `-q, --query ` | Free-text search across activity | +| `--page ` | Page number | +| `--per-page ` | Results per page | + +#### `dm activity get ` + +Get full details of a specific activity record, including the original request, result summary, and entity IDs (people and properties). + +```bash +dm activity get act_abc123 +dm activity get act_abc123 --json +``` + +--- + +### Addresses Commands + +#### `dm addresses autocomplete ` + +Return free, bounded DealMachine property-address suggestions with property IDs. + +```bash +dm addresses autocomplete "1200 Barton Springs" --state TX +dm addresses autocomplete "46 Joyce St" --limit 5 --json +``` + +| Option | Description | +| ---------------------- | ----------------------------------------------- | +| `--scope ` | Legacy scope: all (default), address, location; available types depend on the server | +| `--state ` | Narrow to a two-letter state abbreviation | +| `--limit ` | Maximum suggestions, default 5 and max 10 | +| `--latitude ` | Latitude for nearby ranking, requires longitude | +| `--longitude ` | Longitude for nearby ranking, requires latitude | +| `--json` | Output raw JSON response | + +Current address suggestions include a DealMachine `property_id`. The CLI also accepts legacy normalized-location responses and preserves the `--scope` option on both autocomplete entrypoints. Autocomplete does not request fields, perform enrichment, or consume data credits. + +#### `dm addresses validate [address]` + +Validate and standardize addresses via USPS. + +```bash +# Single address +dm addresses validate "123 Main St, Austin, TX 78704" + +# Batch via JSON +dm addresses validate --body '{"data": [{"full_address": "123 Main St, Austin TX"}]}' + +# From file +dm addresses validate -f addresses.json --json +``` + +Output shows each address as valid, corrected (with corrections listed), or invalid (with reason). + +--- + +### Dev Commands + +Local development utilities that operate directly against the Docker MySQL container (`dealmachine-next-mysql`). These require the local database to be running (`npm run db:start` from the repo root). + +#### `dm dev license add ` + +Add a license to an API key in the local database. + +```bash +dm dev license add key_abc123 --type state --code TX +dm dev license add key_abc123 --type zip_code --code 78704 +dm dev license add key_abc123 --type unlimited +dm dev license add key_abc123 --type county --code 48453 --expires 2026-12-31 +``` + +| Option | Description | +| ------------------ | -------------------------------------------------------- | +| `--type ` | `state`, `county`, `zip_code`, or `unlimited` (required) | +| `--code ` | Location code: state abbreviation, FIPS code, or ZIP | +| `--expires ` | Expiration date in ISO format | + +#### `dm dev license list [key_id]` + +List all licenses, optionally filtered by key ID. + +```bash +dm dev license list +dm dev license list key_abc123 +``` + +#### `dm dev license remove ` + +Remove a license by its numeric ID. + +```bash +dm dev license remove 42 +``` + +--- + +## Global Options + +Every command supports these flags: + +| Flag | Description | +| ----------- | ---------------------------------------------------------- | +| `--json` | Output as machine-readable JSON (for scripting and piping) | +| `--quiet` | Suppress spinners and decorative output for agents/scripts | +| `--help` | Show usage information for any command | +| `--version` | Show the CLI version | + +--- + +## Input Methods + +Commands that accept a request body support three input methods, checked in this order: + +1. **`--body `** -- Inline JSON string. +2. **`-f, --file `** -- Read from a JSON file. Enrichment commands also accept `.csv` files for batch processing. +3. **Stdin pipe** -- Read JSON from piped input (detected when stdin is not a TTY). + +```bash +# Inline +dm properties search --body '{"locations": [...]}' + +# File +dm properties search -f query.json + +# Pipe +cat query.json | dm properties search + +# CSV enrichment (enrich commands only) +dm enrich address -f addresses.csv +``` + +### CSV Batch Enrichment + +The `enrich` commands detect `.csv` files by extension and auto-parse them. Expected column names per command: + +| Command | Required Columns | Alternative Column Names | +| ---------------- | ----------------------- | ------------------------------------ | +| `enrich address` | `full_address` | or `street` + `city`, `state`, `zip` | +| `enrich latlng` | `latitude`, `longitude` | `lat`, `lng`/`lon`/`long` | +| `enrich apn` | `apn` | `parcel_id`, `parcel_number` | +| `enrich email` | `email` | `email_address` | +| `enrich phone` | `phone` | `phone_number` | + +Batches larger than 250 items are automatically chunked with progress spinners. If an export limit is reached mid-batch, the CLI stops and returns results collected so far. + +--- + +## Project Structure + +``` +dealmachine-cli/ + scripts/ + copy-agent-assets.mjs # Bundles the Playbook Markdown into dist/agents + src/ + index.ts # Program entrypoint -- registers all 17 command groups + lib/ + config.ts # Read/write ~/.dealmachine/config.json (mode 0600) + client.ts # HTTP client wrapper (apiRequest, formatDate, getApiKey) + api.ts # Device auth flow client (requestDeviceCode, pollForToken, verifyCredentials) + output.ts # Formatting helpers (printTable, printJson, printKeyValue, parseRequestBody) + commands/ + agents.ts # dm agents -- agent guide and Playbook output + login.ts # dm login -- device auth + API key login + logout.ts # dm logout -- remove credentials + whoami.ts # dm whoami -- show/verify auth status + config.ts # dm config -- get, set, path + account.ts # dm account -- show account info + usage.ts # dm usage -- credit usage + properties.ts # dm properties -- search, count, get, ids, export + people.ts # dm people -- search, count, get, ids, export + enrich.ts # dm enrich -- address, latlng, apn, email, phone, name + comps.ts # dm comps -- comparable properties + lists.ts # dm lists -- full CRUD + build, import, export + filters.ts # dm filters -- list available filters + fields.ts # dm fields -- list available fields + activity.ts # dm activity -- search, get + addresses.ts # dm addresses -- validate + dev.ts # dm dev -- local license management + dist/ # Compiled output (ESM) + package.json + tsconfig.json +``` + +### Key Modules + +| Module | Responsibility | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `lib/config.ts` | Manages `~/.dealmachine/config.json`. Enforces `0600` file permissions and `0700` directory permissions. Provides typed read/write/delete helpers. | +| `lib/client.ts` | Central HTTP client. Resolves the API base URL from env vars, config, or defaults. Attaches the `Authorization: Bearer` header and versioned `User-Agent`. Exits with a non-zero code on HTTP errors. | +| `lib/api.ts` | Device authorization flow implementation. Handles `POST /v1/auth/device/code` and `POST /v1/auth/device/token` with RFC 8628-compliant polling and error mapping. Also provides `verifyCredentials` for key validation. | +| `lib/output.ts` | All output formatting: `printTable` (auto-width columns), `printJson`, `printKeyValue`, `printPagination`, `printCredits`, `printTotals`, `printWarning`, `printHeader`. Also exports `parseRequestBody` which handles `--body`, `-f`, and stdin input. | + +--- + +## Building + +```bash +npm run build # Compile TypeScript and bundle agent Playbook assets to dist/ +npm run dev # Watch mode (tsc --watch) +npm run eval:cold-start:local # Verify a clean local install and routing contract +npm run eval:cold-start:published # Verify the latest public npm artifact +npm run eval:cold-start:deployed # Verify deployed documentation and skill assets +``` + +The published and deployed checks are release gates. They are expected to fail before a release is +published or the documentation deployment reaches production. The scenario catalog is stored in +`evals/claude-code-name-lookup.json` so the same prompt variants remain visible and reviewable. + +### Standalone Binary + +The compiled `dist/index.js` includes a `#!/usr/bin/env node` shebang and is declared in `package.json` under `bin.dm`. When installed globally via npm, it becomes available as `dm` on the PATH. + +For distribution as a standalone binary without npm: + +```bash +# Build +cd dealmachine-cli +npm run build + +# The entire dist/ directory is the distributable artifact +# dist/index.js is the entrypoint (requires Node.js >= 18 on the target machine) +``` + +The `files` array in `package.json` ensures only `dist/` is included in the published package. + +### TypeScript Configuration + +- Target: ES2022 +- Module: NodeNext (ESM) +- Strict mode enabled +- Outputs declarations, declaration maps, and source maps +- No project references (standalone compilation) + +--- + +## Adding New Commands + +### Step 1: Create the command file + +Create `src/commands/mycommand.ts`: + +```typescript +/** + * MyCommand -- description of what this command does + */ + +import chalk from 'chalk'; +import ora from 'ora'; +import { apiRequest } from '../lib/client.js'; +import { printJson, printHeader, printKeyValue } from '../lib/output.js'; + +interface MyResponse { + data: { id: string; name: string }; +} + +export async function myCommand(options: { json?: boolean }): Promise { + const spinner = ora('Doing something...').start(); + const data = await apiRequest('/my-endpoint'); + spinner.stop(); + + if (options.json) { + printJson(data); + return; + } + + printHeader('My Command'); + printKeyValue({ + ID: data.data.id, + Name: data.data.name, + }); + console.log(); +} +``` + +### Step 2: Register in index.ts + +Import and wire up the command in `src/index.ts`: + +```typescript +import { myCommand } from './commands/mycommand.js'; + +// Top-level command +program + .command('mycommand') + .description('Description shown in --help') + .option('--json', 'Output as JSON') + .action(async (options) => { + await myCommand(options); + }); + +// Or as a subcommand group +const myGroup = program.command('mygroup').description('Group description'); + +myGroup + .command('sub1') + .description('Subcommand description') + .action(async (options) => { + await mySub1(options); + }); +``` + +### Step 3: Build and test + +```bash +npm run build +node dist/index.js mycommand --json +``` + +### Conventions + +- One file per command group in `src/commands/`. +- Always support `--json` for machine-readable output. +- Use `ora` for spinners during API calls. +- Use `chalk` for colored terminal output. +- Use `apiRequest` from `lib/client.ts` for all API calls -- it handles auth, errors, and exits. +- Use `parseRequestBody` from `lib/output.ts` when the command accepts `--body`, `-f`, or stdin input. +- Use `printHeader`, `printTable`, `printKeyValue`, `printCredits`, `printPagination` for consistent output formatting. +- All imports must use the `.js` extension (ESM requirement with NodeNext resolution). + +--- + +## Dependencies + +### Runtime + +| Package | Version | Purpose | +| ----------- | ------- | ---------------------------------------------------------------------- | +| `commander` | ^12.1.0 | CLI framework -- command registration, option parsing, help generation | +| `chalk` | ^5.3.0 | Terminal string styling (colors, bold, dim) | +| `ora` | ^8.1.0 | Spinner animations for async operations | +| `open` | ^10.1.0 | Opens the browser for the device auth flow | + +### Dev + +| Package | Version | Purpose | +| ------------- | ------- | ------------------------ | +| `typescript` | ^5.6.3 | TypeScript compiler | +| `@types/node` | ^22.0.0 | Node.js type definitions | + +### Internal Package Dependencies + +**None.** This package is a fully standalone binary with zero `@dealmachine/*` dependencies. It communicates exclusively through the public REST API. + +### Used By + +The **Playbook** at `packages/playbooks/playbook/` uses `dm` commands to execute property intelligence workflows. The CLI is the primary interface through which the Playbook interacts with DealMachine data. Agents can load the bundled Playbook directly with `dm agents playbook`. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..e8499f4 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,20 @@ +# CLI development + +This repository is the source of truth for the public CLI. Next consumes its built npm package. Private beta extensions remain in Next's `packages/cli-private`; do not move those files into this public repository. + +```sh +npm ci +npm run check +npm run test:package +npm run dev +``` + +`build` creates JavaScript, declarations and source maps in `dist`, cleaning old output first. It bundles `playbook/PLAYBOOK.md` for offline agent onboarding. `dev` watches TypeScript; run `build` once before the watcher and again after changing the Playbook. From a second terminal, run `npm start -- --help` or `npm start -- agents playbook`. + +The [Command reference](commands.md) describes CLI usage and JSON output. Tests under `tests/` exercise request behavior and output. `test:package` installs both npm archives into an empty temporary project and checks the executable, module import and project-local Claude Code Playbook installation. It does not write to your personal agent setup or call the API. + +For local API work, start the API in its owning checkout, then use `DM_API_URL=http://localhost:3001/v1 npm start -- account`. Supply your development API key through the approved local environment or `dm login`; never commit it. Normal local build and tests need no credentials. Production Commands can read or mutate live data and consume credits, so choose an environment deliberately. + +The public agent plugin manifests and `skills/dealmachine` are retained in this repository. The hosted MCP server is a separate service. This extraction does not make MCP implementation or docs-site deployment part of CLI publication. + +From Factory, use `npm run setup:cli`, `npm run dev:cli`, `npm run cli:check` and `npm run cli:test:package`. Factory's `where cli` locates this checkout and `scripts cli` discovers its npm scripts. Read [releases](releases.md) before publishing. diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..a97dbf9 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,47 @@ +# npm releases + +`@dealmachine/cli` is the implementation and `dealmachine` is the short install alias. Both are public on npmjs.org. They must have the same version; the alias pins the exact implementation version. The version command also updates `src/version.ts`, and packed-install checks verify the reported version against the manifest. Agent plugin and hosted MCP manifest versions follow their own release lifecycle. + +The extraction candidate is `0.4.0-rc.1`; `0.3.0` remains the published baseline until a new release runs. Review the newly moved Commands against the intended API environment before promoting a stable release. + +```sh +npm run release:version -- 0.4.0-rc.1 +npm run release:dry-run -- --tag next +``` + +The version command updates both manifests, the runtime version and the canonical lockfile without creating a Git tag. The dry run executes checks, installs both packed archives in a clean consumer, and calls `npm publish --dry-run`. It does not publish. `release:pack` performs the same validation and leaves archives plus a `release.json` integrity record in ignored `artifacts//`. + +For a local approved release, commit the reviewed changes and use: + +```sh +npm run release:publish -- --tag next +``` + +Publishing requires a clean checkout, authenticated npm access and an explicit `next` or `latest` tag. Prereleases cannot use `latest`. The implementation is published before the alias. Both uploads are independent registry operations: if the second fails, verify the first package's published integrity against `artifacts//release.json`, then publish only the matching alias archive with `npm publish artifacts//dealmachine-.tgz --access public --registry https://registry.npmjs.org --tag next`. Do not rebuild or replace an already published version. + +## GitHub publishing + +The manual `publish-npm.yml` workflow runs only from `master`, checks the requested version, validates the packages and publishes through npm trusted publishing. It uses the GitHub environment `npm` and an OIDC token. It is not triggered by a normal commit or merge. + +For **both** npm package settings, configure a GitHub trusted publisher with organization `DealMachine`, repository `dealmachine-cli`, workflow filename `publish-npm.yml`, environment `npm`, and permission to publish. Configure the GitHub `npm` environment with the team's release reviewers. Do not add tokens to the repository. npm requires CLI 11.5.1+ and Node 22.14+; the workflow uses Node 24. See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/). + +After the source PR is reviewed and merged, an authorized maintainer can dispatch: + +```sh +gh workflow run publish-npm.yml --repo DealMachine/dealmachine-cli --ref master -f version=0.4.0-rc.1 -f channel=next +``` + +To publish a stable version, set a new stable version, rerun validation, merge the reviewed change and dispatch with `channel=latest`. Removing `-rc` creates a new artifact; moving a tag does not rename a version. Never overwrite an existing npm version. + +## Verify and recover + +```sh +npm view @dealmachine/cli dist-tags --json +npm view dealmachine dist-tags --json +npx --yes --package=dealmachine@0.4.0-rc.1 dm --version +npx --yes --package=dealmachine@0.4.0-rc.1 dm agents playbook +``` + +Use exact versions for release verification. `eval:cold-start:published` checks the existing default npm channel; it does not select a prerelease automatically. `eval:cold-start:deployed` checks the hosted docs surface and can fail independently of a valid CLI package. + +Rollback means moving the affected distribution tags for both packages back to the previously verified version, then verifying new installs. Existing installed versions do not change automatically. Publishing this client does not deploy the API, MCP, Next application, or docs site. diff --git a/npm-alias/bin/dm.js b/npm-alias/bin/dm.js index dc71ab4..c756af4 100755 --- a/npm-alias/bin/dm.js +++ b/npm-alias/bin/dm.js @@ -1,3 +1,5 @@ #!/usr/bin/env node -import '@dealmachine/cli/dist/index.js'; +import { program } from '@dealmachine/cli/dist/index.js'; + +await program.parseAsync(); diff --git a/npm-alias/package.json b/npm-alias/package.json index af5feac..5865b95 100644 --- a/npm-alias/package.json +++ b/npm-alias/package.json @@ -1,6 +1,6 @@ { "name": "dealmachine", - "version": "0.3.0", + "version": "0.4.0-rc.1", "description": "Short install alias for the DealMachine property intelligence CLI", "author": "DealMachine", "license": "MIT", @@ -23,13 +23,14 @@ "LICENSE" ], "dependencies": { - "@dealmachine/cli": "0.3.0" + "@dealmachine/cli": "0.4.0-rc.1" }, "engines": { "node": ">=18" }, "publishConfig": { - "access": "public" + "access": "public", + "registry": "https://registry.npmjs.org" }, "keywords": [ "dealmachine", diff --git a/package-lock.json b/package-lock.json index 69b87eb..dfad486 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dealmachine/cli", - "version": "0.3.0", + "version": "0.4.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dealmachine/cli", - "version": "0.3.0", + "version": "0.4.0-rc.1", "license": "MIT", "dependencies": { "chalk": "^5.3.0", @@ -19,6 +19,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "tsx": "^4.20.0", "typescript": "^5.6.3", "vitest": "^4.1.8" }, @@ -60,6 +61,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -691,6 +1134,48 @@ "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1485,6 +1970,25 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index eee5da1..76c938b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dealmachine/cli", - "version": "0.3.0", + "version": "0.4.0-rc.1", "description": "DealMachine CLI for property intelligence, people lookup, and enrichment through the public API", "author": "DealMachine", "license": "MIT", @@ -20,7 +20,7 @@ "dist" ], "scripts": { - "build": "tsc && node scripts/copy-agent-assets.mjs", + "build": "node scripts/build.mjs", "dev": "tsc --watch", "start": "node dist/index.js", "test": "npm run validate:plugin && vitest run", @@ -29,7 +29,16 @@ "eval:cold-start:published": "node scripts/eval-cold-start.mjs published", "eval:cold-start:deployed": "node scripts/eval-cold-start.mjs deployed", "eval:cold-start:all": "npm run build && node scripts/eval-cold-start.mjs all", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run check", + "typecheck": "tsc --noEmit --composite false --incremental false", + "check": "npm run typecheck && npm run build && npm test && npm run check:artifact", + "check:artifact": "node scripts/check-public-artifact.mjs", + "test:package": "node scripts/test-package.mjs", + "release:version": "node scripts/release-version.mjs", + "release:pack": "node scripts/release.mjs pack", + "release:dry-run": "node scripts/release.mjs dry-run", + "release:publish": "node scripts/release.mjs publish", + "prepack": "npm run build && npm run check:artifact" }, "dependencies": { "chalk": "^5.3.0", @@ -39,6 +48,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "tsx": "^4.20.0", "typescript": "^5.6.3", "vitest": "^4.1.8" }, @@ -46,7 +56,8 @@ "node": ">=18" }, "publishConfig": { - "access": "public" + "access": "public", + "registry": "https://registry.npmjs.org" }, "keywords": [ "dealmachine", diff --git a/playbook/PLAYBOOK.md b/playbook/PLAYBOOK.md index 1885fae..98b9b2a 100644 --- a/playbook/PLAYBOOK.md +++ b/playbook/PLAYBOOK.md @@ -1,12 +1,11 @@ --- name: dealmachine -title: DealMachine CLI Playbook -description: Supplementary CLI-only guidance for DealMachine property and people intelligence workflows +description: Natural language interface to the DealMachine CLI. Use when the user wants property or owner lookups, property or people searches, comps and valuations, skip tracing or contact enrichment, address validation, list building, or exports, including "who owns...", "find absentee owners in...", "comps for...", or any mention of DealMachine or the dm CLI. license: MIT metadata: author: DealMachine - version: '1.1' - type: playbook + version: '1.0' + type: application allowed-tools: - Bash(dm agents) - Bash(dm agents guide *) @@ -26,8 +25,6 @@ allowed-tools: # DealMachine Playbook: Natural Language Property Intelligence -This is the bundled CLI Playbook used by `dm agents playbook`. For the current MCP Tool map, CLI command map, and interface selection guidance for version 0.3.0, use [`skills/dealmachine/SKILL.md`](../skills/dealmachine/SKILL.md). - You are a DealMachine power user. Your role is to translate natural language requests into DealMachine CLI commands, execute them on the user's behalf, and return formatted results — while being ruthlessly efficient with credits. ## When to Use This Playbook @@ -143,6 +140,7 @@ If a command fails due to auth, tell the user to run `dm login` in their termina **Routing boundary:** Do not use this state when the user provides a specific person's name. People Search has no name filter. Route a known name to State DM5 and use `dm enrich name`. + **Key Questions:** - Are you looking for property owners, renters, or residents? @@ -154,7 +152,7 @@ Search has no name filter. Route a known name to State DM5 and use `dm enrich na 2. **Always count first** — use `dm people count` (FREE) 3. Run `dm people search ... --estimate-cost` to get the exact free estimate 4. Confirm count, requested data, and credit estimate -5. Execute the approved search with `--yes` and `property_match` set correctly +5. Execute the approved search with `--yes`. People searches accept people filters only — if the user's criteria include property attributes, run them from the property side instead: `dm properties count -f query.json` with `"contact_audience": "owners"` in the body for a free owner count, then `dm properties export -f query.json --contact-audience owners` for owner rows 6. If the user's request implies multiple searches, run each separately and combine results 7. Format and present results @@ -180,6 +178,7 @@ Search has no name filter. Route a known name to State DM5 and use `dm enrich na ZIP code, county, or city place ID when available. For a city name, run `dm locations search -q "" --type city --state --json`, then pass the result's `code` to `dm enrich name --city `. + **Key Questions:** - What identifier do you have? (name, phone, email) @@ -287,9 +286,9 @@ Parse the natural language request to determine: - **What format** they want (table, JSON, specific fields) - **How many searches** this requires — a single request may need multiple searches (e.g., "find high-equity properties and also recently sold ones" = two separate searches) -### Step 2: Fetch Available Filters and Fields (MANDATORY) +### Step 2: Fetch Available Filters and Fields -**ALWAYS run this before any search. No exceptions.** You must know what filters and fields actually exist before constructing a query. Never assume or guess a filter ID. +Run this before any search. Filter ids and value shapes come only from the live API; a guessed id fails or silently filters the wrong field. ```bash # Run these in parallel — both are FREE @@ -309,7 +308,7 @@ After fetching: 4. If a filter the user wants doesn't exist, tell them — don't fabricate one 5. If the user's request is ambiguous, use `dm filters --search ""` to find the best match -**This step is non-negotiable.** The filter and field lists are the source of truth. Your built-in knowledge of common filters is a starting point, but you must verify against the live API before executing. +The filter and field lists are the source of truth. Your knowledge of common filters is a starting point; verify each against these lists before executing. ### Step 3: Plan Searches @@ -403,10 +402,11 @@ Ask if they want to: | "worth between 200K and 500K" | `{"filter_id": "estimated_value", "operator": "range", "value": {"min": 200000, "max": 500000}}` | | "3+ bedrooms" / "at least 3 beds" | `{"filter_id": "num_bedrooms", "operator": "greater_than_or_equal", "value": 3}` | | "built before 1990" | `{"filter_id": "year_built", "operator": "less_than", "value": 1990}` | -| "owner-occupied" | `{"filter_id": "owner_occupied", "operator": "equals", "value": true}` | -| "absentee owners" / "not owner-occupied" | `{"filter_id": "owner_occupied", "operator": "equals", "value": false}` | -| "high equity" / "lots of equity" | `{"filter_id": "estimated_equity_percentage", "operator": "greater_than", "value": 50}` | -| "free and clear" / "no mortgage" | `{"filter_id": "num_mortgages", "operator": "equals", "value": 0}` | +| "owner-occupied" | `{"filter_id": "is_owner_occupied", "operator": "equals", "value": true}` | +| "absentee owners" / "not owner-occupied" | `{"filter_id": "has_absentee_owners", "operator": "equals", "value": true}` | +| "high equity" / "lots of equity" | `{"filter_id": "estimated_equity_percentage", "operator": "greater_than", "value": 50}` (50 is a common starting point; honor any number the user gives) | +| "free and clear" / "no mortgage" | `{"filter_id": "is_free_and_clear", "operator": "equals", "value": true}` (use `num_mortgages` equals 0 only when the user literally means no recorded mortgages) | +| "off market" / "not listed" | `{"filter_id": "is_off_market", "operator": "equals", "value": true}` (never the Market status "Off Market" option) | | "sold in the last year" | `{"filter_id": "last_sale_date", "operator": "relative_time", "value": "last_12_months"}` | **When unsure about a filter:** Run `dm filters --source-type properties --search "" --json` to find matching filters. Always verify the filter exists before using it. @@ -757,8 +757,10 @@ Every command supports: ```json { "locations": [{ "type": "state", "code": "TX" }], - "filters": [{ "filter_id": "estimated_value", "operator": "greater_than", "value": 500000 }], - "property_match": "owner", + "filters": [ + { "filter_id": "estimated_household_income", "operator": "greater_than", "value": 100000 }, + { "filter_id": "has_active_phone_number", "value": true } + ], "page": 1, "per_page": 25 } @@ -768,6 +770,7 @@ Every command supports: - **Locations:** OR logic (match if in ANY location) - **Filters:** AND logic (ALL filters must match) +- **Filter source:** filters cannot be mixed across catalogs. Property endpoints take `source_type=properties` filters; people endpoints take `source_type=people` filters. Crossing them returns `filter_source_mismatch`. To gate people on property criteria, put the property filters in a property request: `dm properties count -f query.json` (add `"contact_audience": "owners"` to the body for the owner count) or `dm properties export -f query.json --contact-audience owners` - For OR-like behavior within a filter, use `contains_any` or `any_of` operators ### Filter Types & Operators @@ -868,7 +871,7 @@ These fields are always returned, even without requesting `fields`: ### Always-Included Person Fields - `dm_person_id`, `full_name`, `first_name`, `last_name` -- `phones` (array: number, type, do_not_call) +- `phones` (array: number, type, do_not_call, carrier) - `emails` (array: address) - `residence` (address, city, state, zip, full_address) diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..e12b434 --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,9 @@ +import { rmSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const cwd = fileURLToPath(new URL('..', import.meta.url)); +// A previous private build must never leave files in the public npm artifact. +rmSync(new URL('../dist', import.meta.url), { recursive: true, force: true }); +execFileSync('tsc', ['--build', '--force'], { cwd, stdio: 'inherit' }); +await import('./copy-agent-assets.mjs'); diff --git a/scripts/check-public-artifact.mjs b/scripts/check-public-artifact.mjs new file mode 100644 index 0000000..da7df93 --- /dev/null +++ b/scripts/check-public-artifact.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const cli = fileURLToPath(new URL('..', import.meta.url)); +const run = (command, args, cwd = cli) => execFileSync(command, args, { cwd, encoding: 'utf8' }); +const [artifact] = JSON.parse(run('npm', ['pack', '--dry-run', '--ignore-scripts', '--json'], cli)); +for (const file of artifact.files) { + assert.doesNotMatch( + file.path, + /(^|\/)(private|v2|dialer|opportunities|opportunity-fields|opportunityActivities|suppression|contactExclusions)(\/|\.)/, + `Private file in npm package: ${file.path}` + ); + if (!/\.(js|md|map|ts)$/.test(file.path)) continue; + const content = readFileSync(resolve(cli, file.path), 'utf8'); + assert.doesNotMatch( + content, + /registerDialerCommands|DM_ENABLE_DIALER|\/dialer(?:\/|['"`])|registerV2Commands|registerBetaCommands|registerOpportunitiesCommands|include_companies|company_limit|V2 Query Workflows|dm query schema|\/query\/schema|\/opportunities(?:\/|['"`])|\/suppression-list|exclude[-_](?:exported|prospect|listed|suppressed)[-_]contacts|include[-_]properties[-_]without[-_]contacts/, + `Private content in npm package: ${file.path}` + ); +} +for (const args of [ + ['--help'], + ['properties', 'get', '--help'], + ['people', 'get', '--help'], + ['properties', 'export', '--help'], + ['people', 'export', '--help'], + ['agents', 'guide'], + ['agents', 'playbook'], +]) { + const output = run(process.execPath, [resolve(cli, 'dist/index.js'), ...args]); + assert.doesNotMatch(output, /V2 Query|include-companies|dm query|rental-comps|Query related V2|exclude-\w+-contacts|include-properties-without-contacts/); + if (args[1] === 'export') assert.doesNotMatch(output, /--estimate-cost/); +} +const blockedCommands = ['query', 'companies', 'opportunities', 'opp', 'suppression', 'dialer']; +for (const [command, dialerFlag] of [...blockedCommands.map(command => [command, 'true']), ['dialer', '1']]) { + const result = spawnSync(process.execPath, [resolve(cli, 'dist/index.js'), command], { + cwd: cli, + encoding: 'utf8', + env: { ...process.env, DM_ENABLE_OPPORTUNITIES: 'true', DM_ENABLE_DIALER: dialerFlag }, + }); + assert.equal(result.status, 1, `${command} must not exist in the public binary, even with a local beta switch`); + assert.match(result.stderr, /unknown command/); +} +console.log( + `Public CLI artifact checked: ${artifact.files.length} files, no private V2 commands or docs.` +); diff --git a/scripts/copy-agent-assets.mjs b/scripts/copy-agent-assets.mjs index 95db922..ef393de 100644 --- a/scripts/copy-agent-assets.mjs +++ b/scripts/copy-agent-assets.mjs @@ -6,7 +6,6 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const packageDir = path.resolve(scriptDir, '..'); const playbookCandidates = [ path.resolve(packageDir, 'playbook/PLAYBOOK.md'), - path.resolve(packageDir, '../playbooks/playbook/SKILL.md'), ]; const outputDir = path.resolve(packageDir, 'dist/agents'); const outputPath = path.join(outputDir, 'dealmachine-playbook.md'); diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs new file mode 100644 index 0000000..d5f1444 --- /dev/null +++ b/scripts/release-version.mjs @@ -0,0 +1,19 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const [version, ...extra] = process.argv.slice(2); +if (extra.length || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[a-zA-Z0-9]+(?:[.-][a-zA-Z0-9]+)*)?$/.test(version ?? '')) { + throw new Error('Usage: npm run release:version -- , such as 0.4.0-rc.1 or 0.4.0'); +} +for (const name of ['package.json', 'npm-alias/package.json', 'package-lock.json']) { + const path = resolve(root, name); + const data = JSON.parse(readFileSync(path, 'utf8')); + data.version = version; + if (name === 'npm-alias/package.json') data.dependencies['@dealmachine/cli'] = version; + if (name === 'package-lock.json') data.packages[''].version = version; + writeFileSync(path, JSON.stringify(data, null, 2) + '\n'); +} +writeFileSync(resolve(root, 'src/version.ts'), `export const CLI_VERSION = '${version}';\nexport const CLI_USER_AGENT = \`dm-cli/\${CLI_VERSION}\`;\n`); +console.log(`CLI and alias set to ${version}. Run npm run check and npm run test:package before release.`); diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 0000000..6862c3a --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const [mode, ...args] = process.argv.slice(2); +assert(['pack', 'dry-run', 'publish'].includes(mode), 'Use pack, dry-run or publish.'); +assert(args.length === 0 || (args.length === 2 && args[0] === '--tag' && ['next', 'latest'].includes(args[1])), 'Use --tag next or --tag latest.'); +const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')); +const alias = JSON.parse(readFileSync(resolve(root, 'npm-alias/package.json'), 'utf8')); +const lock = JSON.parse(readFileSync(resolve(root, 'package-lock.json'), 'utf8')); +assert.equal(alias.version, manifest.version); +assert.equal(alias.dependencies[manifest.name], manifest.version); +assert.equal(lock.packages[''].version, manifest.version); +const tag = args[1] ?? (manifest.version.includes('-') ? 'next' : 'latest'); +if (mode === 'publish') assert(args.length === 2, 'Publishing requires an explicit --tag next or --tag latest.'); +assert(tag !== 'latest' || !manifest.version.includes('-'), 'Prereleases must use the next channel.'); +const run = (command, args, cwd = root) => execFileSync(command, args, { cwd, stdio: 'inherit' }); +if (mode === 'publish') { + const status = execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }); + assert.equal(status.trim(), '', 'Commit the reviewed release before publishing.'); +} +run('npm', ['run', 'check']); +run('npm', ['run', 'test:package']); +const output = resolve(root, 'artifacts', manifest.version); +mkdirSync(output, { recursive: true }); +const artifacts = []; +for (const directory of [root, resolve(root, 'npm-alias')]) { + const [artifact] = JSON.parse(execFileSync('npm', ['pack', '--ignore-scripts', '--json', '--pack-destination', output], { cwd: directory, encoding: 'utf8' })); + artifacts.push(artifact); +} +writeFileSync(resolve(output, 'release.json'), JSON.stringify({ version: manifest.version, tag, artifacts }, null, 2) + '\n'); +if (mode === 'pack') { + console.log(`Packed both packages in ${output}`); +} else { + // Publish the implementation before the alias that pins it. npm versions are immutable. + for (const artifact of artifacts) { + run('npm', ['publish', resolve(output, artifact.filename), '--ignore-scripts', '--access', 'public', '--registry', 'https://registry.npmjs.org', '--tag', tag, ...(mode === 'dry-run' ? ['--dry-run'] : [])]); + } +} diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs new file mode 100644 index 0000000..31ce945 --- /dev/null +++ b/scripts/test-package.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { execFile, execFileSync } from 'node:child_process'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { promisify } from 'node:util'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const temporary = mkdtempSync(join(tmpdir(), 'dm-package-')); +const version = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version; +const run = (command, args, cwd = temporary) => execFileSync(command, args, { cwd, encoding: 'utf8', timeout: 120000 }); +try { + run('npm', ['run', 'build'], root); + run(process.execPath, ['scripts/check-public-artifact.mjs'], root); + const archives = [root, join(root, 'npm-alias')].map(cwd => { + const [artifact] = JSON.parse(run('npm', ['pack', '--ignore-scripts', '--json', '--pack-destination', temporary], cwd)); + return join(temporary, artifact.filename); + }); + writeFileSync(join(temporary, 'package.json'), JSON.stringify({ name: 'dm-install-check', private: true })); + run('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', ...archives]); + const binary = join(temporary, 'node_modules/.bin/dm'); + assert.equal(run(binary, ['--version']).trim(), version); + for (const entry of ['@dealmachine/cli/dist/index.js', 'dealmachine/bin/dm.js']) { + assert.equal(run(process.execPath, [join(temporary, 'node_modules', entry), '--version']).trim(), version); + } + assert.match(run(binary, ['--help']), /properties/); + assert.match(run(binary, ['prospects', '--help']), /add/); + assert.match(run(binary, ['webhooks', '--help']), /events/); + const playbook = JSON.parse(run(binary, ['agents', 'playbook', '--json'])); + assert.match(playbook.content, /name: dealmachine/); + assert.match(playbook.content, /specific name always uses person enrichment/); + run(binary, ['agents', 'install', 'claude-code', '--project', '--json']); + assert.equal(readFileSync(join(temporary, '.claude/skills/dealmachine/SKILL.md'), 'utf8'), playbook.content); + const program = run(process.execPath, ['--input-type=module', '--eval', "const { program } = await import('@dealmachine/cli/dist/index.js'); console.log(program.name());"]); + assert.equal(program.trim(), 'dm'); + // Use the normal encrypted-file credential fallback inside a temporary home. + // Never call the developer's OS keychain or read their personal CLI login. + const fixtureHome = join(temporary, 'home'); + mkdirSync(fixtureHome); + const credentialIsolation = join(temporary, 'credential-isolation.mjs'); + writeFileSync(credentialIsolation, [ + "import os from 'node:os';", + "import childProcess from 'node:child_process';", + "import { syncBuiltinESMExports } from 'node:module';", + `os.homedir = () => ${JSON.stringify(fixtureHome)};`, + "childProcess.execFileSync = () => { throw new Error('OS credential store disabled in package fixture'); };", + 'syncBuiltinESMExports();', + ].join('\n')); + run(process.execPath, ['--import', credentialIsolation, '--input-type=module', '--eval', + "const { writeConfig } = await import('@dealmachine/cli/dist/lib/config.js'); writeConfig({ apiKey: 'dm-package-test', keyId: 'fixture', organizationId: 1, organizationName: 'Package fixture', organizationSlug: 'fixture' });", + ]); + const fixtureConfig = JSON.parse(readFileSync(join(fixtureHome, '.dealmachine/config.json'), 'utf8')); + assert.equal(fixtureConfig.credentialStore, 'encrypted-file'); + assert.equal(fixtureConfig.apiKey, undefined); + const requests = []; + const server = createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + requests.push({ method: request.method, path: request.url, body: JSON.parse(Buffer.concat(chunks).toString()), headers: request.headers }); + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ data: {} })); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + try { + const execute = promisify(execFile); + for (const [args, path] of [ + [['lists', 'create', '--name', 'Package check'], '/lists'], + [['lists', 'add', '123', '--ids', '456'], '/lists/123/items'], + [['lists', 'build', '123', '--body', '{}'], '/lists/123/build'], + [['lists', 'import', '123', '--ids', '456'], '/lists/123/import'], + ]) { + for (const optOut of [false, true]) { + await execute(process.execPath, ['--import', credentialIsolation, binary, ...args, '--json', ...(optOut ? ['--no-prospects'] : [])], { + cwd: temporary, + timeout: 10000, + env: { ...process.env, DM_API_URL: `http://127.0.0.1:${server.address().port}/v1`, DM_API_KEY: 'unrelated-environment-key' }, + }); + const request = requests.pop(); + assert.equal(request.headers.authorization, 'Bearer dm-package-test'); + assert.equal(request.method, 'POST'); + assert.equal(request.path, `/v1${path}`); + assert.equal(request.body.add_as_prospects, optOut ? false : undefined); + assert.equal(request.headers['user-agent'], `dm-cli/${version}`); + assert.equal(request.headers['x-dealmachine-source'], 'cli'); + } + } + } finally { + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + } + console.log(`Installed both ${version} archives in a clean consumer; dm, module import and Playbook installation passed.`); +} finally { + rmSync(temporary, { recursive: true, force: true }); +} diff --git a/scripts/validate-agent-plugin.mjs b/scripts/validate-agent-plugin.mjs index da350b9..9571d0b 100644 --- a/scripts/validate-agent-plugin.mjs +++ b/scripts/validate-agent-plugin.mjs @@ -93,14 +93,12 @@ assert.deepEqual(mcp.mcpServers.dealmachine, { assert.match(skill, /^---\nname: dealmachine\n/, 'DealMachine skill front matter is missing'); assert.match(skill, /\ndescription: .+\n/, 'DealMachine skill description is missing'); -const demoVideoPath = resolve(packageRoot, 'assets/plugin-demo/dealmachine-agent-plugin-demo.mp4'); -const demoVideo = await stat(demoVideoPath); -assert(demoVideo.isFile(), 'submission demo must be a regular file'); -assert(demoVideo.size > 0 && demoVideo.size <= 100 * 1024 * 1024, 'submission demo must be non-empty and at most 100 MB'); -assert.match( - readme, - /assets\/plugin-demo\/dealmachine-agent-plugin-demo\.mp4/, - 'README must link to the public submission demo' -); +// Submission media is optional; a documented local demo must be present and usable. +const demoPath = 'assets/plugin-demo/dealmachine-agent-plugin-demo.mp4'; +if (readme.includes(demoPath)) { + const demoVideo = await stat(resolve(packageRoot, demoPath)); + assert(demoVideo.isFile(), 'submission demo must be a regular file'); + assert(demoVideo.size > 0 && demoVideo.size <= 100 * 1024 * 1024, 'submission demo must be non-empty and at most 100 MB'); +} console.log('Agent Plugin package is valid.'); diff --git a/src/commands/account.ts b/src/commands/account.ts index f11cdba..79671ce 100644 --- a/src/commands/account.ts +++ b/src/commands/account.ts @@ -4,6 +4,7 @@ import chalk from 'chalk'; import { apiRequest, formatDate } from '../lib/client.js'; +import { printJson } from '../lib/output.js'; interface AccountResponse { data: { @@ -19,8 +20,13 @@ interface AccountResponse { }; } -export async function account(): Promise { - const { data } = await apiRequest('/account'); +export async function account(options: { json?: boolean } = {}): Promise { + const response = await apiRequest('/account'); + if (options.json) { + printJson(response); + return; + } + const { data } = response; console.log(); console.log(chalk.bold('Account')); diff --git a/src/commands/agents.ts b/src/commands/agents.ts index e928c0a..c59406d 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -110,9 +110,6 @@ function getPlaybookCandidates(): string[] { path.resolve(currentDir, '../agents', PLAYBOOK_FILE_NAME), path.resolve(currentDir, '../../dist/agents', PLAYBOOK_FILE_NAME), path.resolve(currentDir, '../../playbook/PLAYBOOK.md'), - path.resolve(currentDir, '../../../playbooks/playbook/SKILL.md'), - path.resolve(process.cwd(), 'packages/playbooks/playbook/SKILL.md'), - path.resolve(process.cwd(), '../playbooks/playbook/SKILL.md'), ]) ); } @@ -128,7 +125,7 @@ function loadPlaybook(): { content: string; path: string } { } throw new Error( - `Could not find the DealMachine Playbook. Rebuild the CLI with "npm run build" from packages/cli and try again.` + `Could not find the DealMachine Playbook. Rebuild the CLI with "npm run build" from the CLI repository and try again.` ); } @@ -181,7 +178,8 @@ export async function agentsPlaybook(options: AgentOptions): Promise { console.log(playbook.content); } catch (error) { - const message = error instanceof Error ? error.message : 'Could not load the DealMachine Playbook.'; + const message = + error instanceof Error ? error.message : 'Could not load the DealMachine Playbook.'; if (options.json) { printJson({ error: message }); process.exitCode = 1; @@ -203,12 +201,7 @@ export async function agentsInstallClaudeCode(options: AgentInstallOptions): Pro const current = fs.existsSync(target) ? fs.readFileSync(target, 'utf-8') : undefined; if (current === playbook.content) { - const result = { - status: 'already_installed', - agent: 'claude-code', - scope, - path: target, - }; + const result = { status: 'already_installed', agent: 'claude-code', scope, path: target }; if (options.json) printJson(result); else console.log(`DealMachine Playbook is already installed at ${target}`); return; @@ -220,10 +213,7 @@ export async function agentsInstallClaudeCode(options: AgentInstallOptions): Pro ); } - fs.mkdirSync(path.dirname(target), { - recursive: true, - mode: options.project ? 0o755 : 0o700, - }); + fs.mkdirSync(path.dirname(target), { recursive: true, mode: options.project ? 0o755 : 0o700 }); const temporary = path.join(path.dirname(target), `.SKILL.md.${process.pid}.tmp`); try { fs.writeFileSync(temporary, playbook.content, { @@ -247,7 +237,8 @@ export async function agentsInstallClaudeCode(options: AgentInstallOptions): Pro console.log('Restart Claude Code or start a new session so it discovers the skill.'); } } catch (error) { - const message = error instanceof Error ? error.message : 'Could not install the DealMachine Playbook.'; + const message = + error instanceof Error ? error.message : 'Could not install the DealMachine Playbook.'; if (options.json) printJson({ error: message }); else console.error(message); process.exitCode = 1; diff --git a/src/commands/driving.ts b/src/commands/driving.ts new file mode 100644 index 0000000..d93381b --- /dev/null +++ b/src/commands/driving.ts @@ -0,0 +1,124 @@ +import chalk from 'chalk'; +import { apiRequest, formatDate } from '../lib/client.js'; +import { createSpinner, printHeader, printJson, printKeyValue, printTable } from '../lib/output.js'; + +type Drive = { + id: string; + source: string; + mode: string; + name: string | null; + status: string; + started_at: string; + ended_at: string | null; + driver: { user_id: number | null; name: string }; + distance_meters: number | null; + duration_seconds: number | null; + prospects_added_count: number; +}; + +type DriveDetail = { + id: string; + source: string; + route: unknown; + events: unknown[]; + visits: unknown[]; + prospects: Array<{ + prospect_id: string | null; + record_id: string; + lifecycle: string | null; + source: string | null; + added_at: string; + record: { address: string | null }; + }>; +}; + +type Pagination = { page: number; per_page: number; total: number; has_more: boolean }; + +export async function drivingList(options: { + driverUserId?: string; + mode?: string; + startedAfter?: string; + startedBefore?: string; + page?: string; + perPage?: string; + json?: boolean; +}): Promise { + const spinner = createSpinner('Fetching drives...').start(); + const response = await apiRequest<{ data: Drive[]; pagination: Pagination }>('/driving/drives', { + query: { + driver_user_id: options.driverUserId ? Number.parseInt(options.driverUserId, 10) : undefined, + mode: options.mode, + started_after: options.startedAfter, + started_before: options.startedBefore, + page: options.page ? Number.parseInt(options.page, 10) : undefined, + per_page: options.perPage ? Number.parseInt(options.perPage, 10) : undefined, + }, + }); + spinner.stop(); + + if (options.json) { + printJson(response); + return; + } + + printHeader('Driving History'); + console.log(); + if (response.data.length === 0) { + console.log(chalk.dim(' No drives found.')); + } else { + printTable( + response.data.map((drive) => ({ + id: drive.id, + date: formatDate(drive.started_at), + driver: drive.driver.name, + mode: drive.mode, + status: drive.status, + prospects: drive.prospects_added_count, + })), + ['id', 'date', 'driver', 'mode', 'status', 'prospects'] + ); + } + console.log(); + console.log( + chalk.dim( + `Page ${response.pagination.page}. ${response.pagination.total} total${ + response.pagination.has_more ? ' (more available)' : '' + }` + ) + ); + console.log(); +} + +export async function drivingGet(id: string, options: { json?: boolean }): Promise { + const spinner = createSpinner('Fetching drive...').start(); + const response = await apiRequest<{ data: DriveDetail }>(`/driving/drives/${id}`); + spinner.stop(); + + if (options.json) { + printJson(response); + return; + } + + const drive = response.data; + printHeader(`Drive ${drive.id}`); + printKeyValue({ + Source: drive.source, + Events: String(drive.events.length), + Visits: String(drive.visits.length), + Prospects: String(drive.prospects.length), + }); + if (drive.prospects.length > 0) { + console.log(); + printTable( + drive.prospects.map((prospect) => ({ + prospect: prospect.prospect_id ?? '', + record: prospect.record_id, + address: prospect.record.address ?? '', + lifecycle: prospect.lifecycle ?? '', + added: formatDate(prospect.added_at), + })), + ['prospect', 'record', 'address', 'lifecycle', 'added'] + ); + } + console.log(); +} diff --git a/src/commands/enrich.ts b/src/commands/enrich.ts index 21e469d..dd7f363 100644 --- a/src/commands/enrich.ts +++ b/src/commands/enrich.ts @@ -879,8 +879,7 @@ function printMatchWarnings(items: Record[]): void { .map((item, i) => ({ i, w: (item as any).match_warning as - | { code?: string; message?: string; hint?: Record } - | undefined, + { code?: string; message?: string; hint?: Record } | undefined, })) .filter((entry) => entry.w); if (warned.length === 0) return; diff --git a/src/commands/filters.ts b/src/commands/filters.ts index b09b179..5de1f3d 100644 --- a/src/commands/filters.ts +++ b/src/commands/filters.ts @@ -22,6 +22,7 @@ interface FilterItem { filter_id: string; name: string; description: string | null; + usage_guidance?: string | null; type: string; source_type: string | null; group_id: string | null; @@ -64,7 +65,9 @@ export async function filters(options: { const shouldSuggestNameLookup = data.data.length === 0 && (!options.sourceType || options.sourceType === 'people') && - /(^|[\s_-])(first[\s_-]?name|last[\s_-]?name|full[\s_-]?name|name)($|[\s_-])/i.test(options.search || ''); + /(^|[\s_-])(first[\s_-]?name|last[\s_-]?name|full[\s_-]?name|name)($|[\s_-])/i.test( + options.search || '' + ); if (options.json) { printJson(shouldSuggestNameLookup ? { ...data, suggestion: NAME_LOOKUP_SUGGESTION } : data); diff --git a/src/commands/lists.ts b/src/commands/lists.ts index 0ece32f..2ba2178 100644 --- a/src/commands/lists.ts +++ b/src/commands/lists.ts @@ -51,6 +51,8 @@ interface ListItemRecord { list_item_id: string; internal_property_id: number | null; internal_person_id: number | null; + /** per_, exact for every id. internal_person_id is null past 2^53 - 1. */ + dm_person_id?: string | null; created_at: string; } @@ -64,6 +66,18 @@ interface ListItemsResponse { }; } +/** + * One --ids entry for a request body. A safe integer stays a JSON number (the + * shape the API has always taken); a larger id goes as its decimal string so + * a 64-bit person id is not rounded. Accepts a per_ prefix for person ids. + */ +function parseRecordIdArg(raw: string): number | string { + const text = raw.trim().replace(/^per_/, ''); + if (!/^\d+$/.test(text)) return parseInt(text, 10); + const numeric = Number(text); + return Number.isSafeInteger(numeric) ? numeric : text; +} + // ============================================================================ // Search (GET /lists) // ============================================================================ @@ -127,16 +141,21 @@ export async function listsCreate(options: { body?: string; file?: string; json?: boolean; + /** Commander's `--no-prospects` sets this false; undefined means the API default (true). */ + prospects?: boolean; }): Promise { let requestBody: Record = { name: options.name }; if (options.sourceType) { requestBody.source_type = options.sourceType; } + if (options.prospects === false) { + requestBody.add_as_prospects = false; + } // Parse --ids into record_ids array if (options.ids) { - requestBody.record_ids = options.ids.split(',').map((id) => parseInt(id.trim(), 10)); + requestBody.record_ids = options.ids.split(',').map(parseRecordIdArg); } // Merge in filters/locations from --body or -f @@ -268,9 +287,12 @@ export async function listsDelete( export async function listsBuild( listId: string, - options: { body?: string; file?: string; json?: boolean } + options: { body?: string; file?: string; json?: boolean; prospects?: boolean } ): Promise { const requestBody = await parseRequestBody(options); + if (options.prospects === false) { + requestBody.add_as_prospects = false; + } const spinner = createSpinner('Starting list build...').start(); const data = await apiRequest(`/lists/${listId}/build`, { @@ -308,17 +330,21 @@ export async function listsImport( body?: string; file?: string; json?: boolean; + prospects?: boolean; } ): Promise { let requestBody: Record; if (options.ids) { - const ids = options.ids.split(',').map((id) => parseInt(id.trim(), 10)); + const ids = options.ids.split(',').map(parseRecordIdArg); requestBody = { ids }; if (options.sourceType) requestBody.source_type = options.sourceType; } else { requestBody = await parseRequestBody(options); } + if (options.prospects === false) { + requestBody.add_as_prospects = false; + } const spinner = createSpinner('Starting import...').start(); const data = await apiRequest(`/lists/${listId}/import`, { @@ -373,7 +399,7 @@ export async function listsItems( const rows = data.data.map((item) => ({ item_id: truncate(item.list_item_id, 16), property_id: item.internal_property_id ?? '—', - person_id: item.internal_person_id ?? '—', + person_id: item.internal_person_id ?? item.dm_person_id ?? '—', added: formatDate(item.created_at), })); printTable(rows, ['item_id', 'property_id', 'person_id', 'added']); @@ -393,16 +419,17 @@ export async function listsItems( export async function listsAdd( listId: string, - options: { ids: string; idType?: string; json?: boolean } + options: { ids: string; idType?: string; json?: boolean; prospects?: boolean } ): Promise { if (!options.ids) { console.error(chalk.red('Error: --ids is required (comma-separated list of IDs)')); process.exit(1); } - const ids = options.ids.split(',').map((id) => parseInt(id.trim(), 10)); + const ids = options.ids.split(',').map(parseRecordIdArg); const requestBody: Record = { ids }; if (options.idType) requestBody.id_type = options.idType; + if (options.prospects === false) requestBody.add_as_prospects = false; const spinner = createSpinner('Adding items...').start(); const data = await apiRequest<{ data: { added: number } }>(`/lists/${listId}/items`, { @@ -433,7 +460,7 @@ export async function listsRemove( process.exit(1); } - const ids = options.ids.split(',').map((id) => parseInt(id.trim(), 10)); + const ids = options.ids.split(',').map(parseRecordIdArg); const requestBody: Record = { ids }; if (options.idType) requestBody.id_type = options.idType; diff --git a/src/commands/locations.ts b/src/commands/locations.ts index 3d76ded..7241c83 100644 --- a/src/commands/locations.ts +++ b/src/commands/locations.ts @@ -45,6 +45,7 @@ interface AutocompleteSuggestion { suggestion_id: string; kind: 'address' | 'location'; label: string; + property_id?: string; address?: { address: string; city?: string; @@ -62,7 +63,7 @@ interface AutocompleteResponse { data: AutocompleteSuggestion[]; meta: { query: string; - scope: 'all' | 'address' | 'location'; + scope?: 'all' | 'address' | 'location'; limit: number; returned: number; partial_results: boolean; @@ -150,6 +151,7 @@ export async function addressesAutocomplete(options: { console.log(); if (data.data.length > 0) { + const hasPropertyIds = data.data.some((suggestion) => suggestion.property_id != null); const rows = data.data.map((suggestion) => ({ kind: suggestion.kind, label: truncate(suggestion.label, 56), @@ -157,8 +159,11 @@ export async function addressesAutocomplete(options: { city: suggestion.address?.city || '-', state: suggestion.address?.state || suggestion.location?.state || '-', zip: suggestion.address?.zip || '-', + ...(hasPropertyIds && { property_id: suggestion.property_id || '-' }), })); - printTable(rows, ['kind', 'label', 'location_id', 'city', 'state', 'zip']); + const columns = ['kind', 'label', 'location_id', 'city', 'state', 'zip']; + if (hasPropertyIds) columns.push('property_id'); + printTable(rows, columns); } else { console.log(chalk.dim(' No suggestions found.')); } @@ -176,10 +181,7 @@ export const locationsAutocomplete = addressesAutocomplete; // Get by ID // ============================================================================ -export async function locationsGet( - locationId: string, - options: { json?: boolean } -): Promise { +export async function locationsGet(locationId: string, options: { json?: boolean }): Promise { const spinner = createSpinner('Fetching location...').start(); const data = await apiRequest(`/locations/${locationId}`); spinner.stop(); @@ -191,12 +193,12 @@ export async function locationsGet( printHeader(`Location ${data.location_id}`); printKeyValue({ - 'Type': data.type, - 'Name': data.name, - 'Code': data.code, - 'State': data.state || '-', + Type: data.type, + Name: data.name, + Code: data.code, + State: data.state || '-', 'State Name': data.state_name || '-', - 'Properties': data.property_count.toLocaleString(), + Properties: data.property_count.toLocaleString(), }); console.log(); } diff --git a/src/commands/phones.ts b/src/commands/phones.ts index 75f940e..fdd7ac7 100644 --- a/src/commands/phones.ts +++ b/src/commands/phones.ts @@ -23,6 +23,7 @@ interface DncResult { matched: boolean; do_not_call?: boolean; phone_type?: string; + carrier?: string | null; match_failure?: { code: string; reason: string }; } diff --git a/src/commands/prospects.ts b/src/commands/prospects.ts new file mode 100644 index 0000000..5893812 --- /dev/null +++ b/src/commands/prospects.ts @@ -0,0 +1,866 @@ +/** + * Prospects commands: the records your team is working, with their notes, + * files, photos, tags, and activity. Every command is a thin wrapper over + * `/v1/prospects`; uploads and downloads move bytes directly to and from + * storage using the presigned URLs the API hands back. + */ + +import chalk from 'chalk'; +import { readFile, writeFile } from 'node:fs/promises'; +import { basename } from 'node:path'; + +import { apiRequest, formatDate } from '../lib/client.js'; +import { + createSpinner, + parseRequestBody, + printHeader, + printJson, + printKeyValue, + printTable, + truncate, +} from '../lib/output.js'; + +type Prospect = { + id: string; + record_type: string; + record_id: string; + lifecycle: string; + favorite: boolean; + source: string; + record: { + address?: string | null; + name?: string | null; + city: string | null; + state: string | null; + zip: string | null; + } | null; + tags: Array<{ id: string; name: string }>; + note_count?: number; + file_count?: number; + created_at: string; +}; + +type Pagination = { page: number; per_page: number; total: number; has_more: boolean }; + +function splitIds(value: string | undefined): string[] { + return (value ?? '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean); +} + +function recordLabel(prospect: Prospect): string { + const record = prospect.record; + const main = record?.address ?? record?.name ?? prospect.record_id; + const place = [record?.city, record?.state].filter(Boolean).join(', '); + return place ? `${main} (${place})` : String(main); +} + +function printProspectRows(prospects: Prospect[]) { + const rows = prospects.map((p) => ({ + id: p.id, + record: truncate(recordLabel(p), 44), + lifecycle: p.lifecycle, + fav: p.favorite ? '★' : '', + tags: truncate(p.tags.map((tag) => tag.name).join(', ') || '—', 24), + added: formatDate(p.created_at), + })); + printTable(rows, ['id', 'record', 'lifecycle', 'fav', 'tags', 'added']); +} + +function printPagination(p: Pagination) { + console.log(); + console.log( + chalk.dim(`Page ${p.page} — ${p.total} total${p.has_more ? ' (more available)' : ''}`) + ); + console.log(); +} + +// ============================================================================ +// Prospects +// ============================================================================ + +export async function prospectsList(options: { + recordType?: string; + lifecycle?: string; + source?: string; + favorites?: boolean; + list?: string; + tag?: string; + search?: string; + sort?: string; + page?: string; + perPage?: string; + json?: boolean; +}): Promise { + const spinner = createSpinner('Fetching prospects...').start(); + const data = await apiRequest<{ data: Prospect[]; pagination: Pagination }>('/prospects', { + query: { + record_type: options.recordType, + lifecycle: options.lifecycle, + source: options.source, + favorites_only: options.favorites ? 'true' : undefined, + list_id: options.list, + tag_id: options.tag, + search: options.search, + sort: options.sort, + page: options.page ? parseInt(options.page, 10) : undefined, + per_page: options.perPage ? parseInt(options.perPage, 10) : undefined, + }, + }); + spinner.stop(); + + if (options.json) { + printJson(data); + return; + } + printHeader(`Prospects (${options.lifecycle ?? 'active'})`); + console.log(); + if (data.data.length) printProspectRows(data.data); + else console.log(chalk.dim(' No prospects found.')); + printPagination(data.pagination); +} + +export async function prospectsGet(id: string, options: { json?: boolean }): Promise { + const spinner = createSpinner('Fetching prospect...').start(); + const data = await apiRequest<{ data: Prospect }>(`/prospects/${id}`); + spinner.stop(); + + if (options.json) { + printJson(data); + return; + } + const p = data.data; + printHeader(`Prospect ${p.id}`); + printKeyValue({ + Record: recordLabel(p), + 'Record ID': p.record_id, + Type: p.record_type, + Lifecycle: p.lifecycle, + Favorite: p.favorite ? 'Yes' : 'No', + Source: p.source, + Tags: p.tags.map((tag) => `${tag.name} (${tag.id})`).join(', ') || '—', + Notes: String(p.note_count ?? 0), + Files: String(p.file_count ?? 0), + Added: formatDate(p.created_at), + }); + console.log(); +} + +export async function prospectsGetByRecord(options: { + recordId: string; + recordType?: string; + json?: boolean; +}): Promise { + const spinner = createSpinner('Fetching prospect...').start(); + const data = await apiRequest<{ data: Prospect }>('/prospects/by-record', { + query: { + record_id: options.recordId, + record_type: options.recordType, + }, + }); + spinner.stop(); + + if (options.json) { + printJson(data); + return; + } + const p = data.data; + printHeader(`Prospect ${p.id}`); + printKeyValue({ + Record: recordLabel(p), + 'Record ID': p.record_id, + Type: p.record_type, + Lifecycle: p.lifecycle, + Favorite: p.favorite ? 'Yes' : 'No', + Source: p.source, + Tags: p.tags.map((tag) => `${tag.name} (${tag.id})`).join(', ') || '—', + Added: formatDate(p.created_at), + }); + console.log(); +} + +export async function prospectsAdd(options: { + ids?: string; + recordType?: string; + favorite?: boolean; + body?: string; + file?: string; + json?: boolean; +}): Promise { + let requestBody: Record; + if (options.ids) { + requestBody = { record_ids: splitIds(options.ids) }; + if (options.recordType) requestBody.record_type = options.recordType; + if (options.favorite) requestBody.favorite = true; + } else { + requestBody = await parseRequestBody(options); + } + + const spinner = createSpinner('Adding prospects...').start(); + const data = await apiRequest<{ + data: { created: number; existing: number; reactivated: number; prospects: Prospect[] }; + }>('/prospects', { method: 'POST', body: requestBody }); + spinner.stop(); + + if (options.json) { + printJson(data); + return; + } + const d = data.data; + printHeader('Prospects Added'); + printKeyValue({ + New: String(d.created), + 'Already tracked': String(d.existing), + Reactivated: String(d.reactivated), + }); + console.log(); + printProspectRows(d.prospects); + console.log(); +} + +async function setLifecycle( + id: string, + lifecycle: 'active' | 'opportunity' | 'archived', + options: { json?: boolean; noCascade?: boolean } +) { + const spinner = createSpinner('Updating prospect...').start(); + const data = await apiRequest<{ data: Prospect }>(`/prospects/${id}`, { + method: 'PATCH', + body: { lifecycle, ...(options.noCascade ? { cascade: false } : {}) }, + }); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + console.log(chalk.green(`✓ ${data.data.id} is now ${data.data.lifecycle}`)); + console.log(); +} + +export async function prospectsArchive( + id: string, + options: { json?: boolean; noCascade?: boolean } +) { + await setLifecycle(id, 'archived', options); +} + +export async function prospectsRemove( + id: string, + options: { noCascade?: boolean; json?: boolean } +): Promise { + const spinner = createSpinner('Archiving prospect...').start(); + const data = await apiRequest<{ data: Prospect }>(`/prospects/${id}`, { + method: 'DELETE', + query: { cascade: options.noCascade ? 'false' : undefined }, + }); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + console.log(chalk.green(`✓ Archived ${id}`)); + console.log(); +} + +export async function prospectsReactivate(id: string, options: { json?: boolean }) { + await setLifecycle(id, 'active', options); +} + +export async function prospectsOpportunity(id: string, options: { json?: boolean }) { + await setLifecycle(id, 'opportunity', options); +} + +export async function prospectsFavorite( + id: string, + options: { off?: boolean; json?: boolean } +): Promise { + const spinner = createSpinner(options.off ? 'Removing star...' : 'Starring...').start(); + const data = await apiRequest<{ data: Prospect }>(`/prospects/${id}`, { + method: 'PATCH', + body: { favorite: !options.off }, + }); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + console.log(chalk.green(options.off ? `✓ Removed star from ${id}` : `✓ Starred ${id}`)); + console.log(); +} + +export async function prospectsCheck(options: { + ids: string; + recordType?: string; + json?: boolean; +}): Promise { + const spinner = createSpinner('Checking records...').start(); + const data = await apiRequest<{ + data: Array<{ + record_id: string; + prospect_id: string | null; + lifecycle: string | null; + favorite: boolean; + }>; + }>('/prospects/check', { + method: 'POST', + body: { + record_ids: splitIds(options.ids), + ...(options.recordType && { record_type: options.recordType }), + }, + }); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + printHeader('Prospect Check'); + console.log(); + printTable( + data.data.map((row) => ({ + record: row.record_id, + prospect: row.prospect_id ?? '—', + lifecycle: row.lifecycle ?? 'not a prospect', + fav: row.favorite ? '★' : '', + })), + ['record', 'prospect', 'lifecycle', 'fav'] + ); + console.log(); +} + +export async function prospectsCounts(options: { list?: string; json?: boolean }): Promise { + const spinner = createSpinner('Counting prospects...').start(); + const data = await apiRequest<{ data: Record> }>( + '/prospects/counts', + { + query: { list_id: options.list }, + } + ); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + printHeader('Prospect Counts'); + console.log(); + const rows = (['active', 'opportunity', 'archived'] as const).map((lifecycle) => ({ + lifecycle, + properties: String(data.data[lifecycle]?.property ?? 0), + people: String(data.data[lifecycle]?.person ?? 0), + companies: String(data.data[lifecycle]?.company ?? 0), + })); + printTable(rows, ['lifecycle', 'properties', 'people', 'companies']); + console.log(); +} + +export async function prospectsActivity( + id: string, + options: { category?: string; since?: string; page?: string; perPage?: string; json?: boolean } +): Promise { + const spinner = createSpinner('Fetching activity...').start(); + const data = await apiRequest<{ + data: Array<{ + id: string; + type: string; + summary: string | null; + actor: { type: string; name: string | null } | null; + source: string; + created_at: string; + }>; + pagination: Pagination; + }>(`/prospects/${id}/activity`, { + query: { + category: options.category, + since: options.since, + page: options.page ? parseInt(options.page, 10) : undefined, + per_page: options.perPage ? parseInt(options.perPage, 10) : undefined, + }, + }); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + printHeader(`Activity for ${id}`); + console.log(); + if (data.data.length) { + printTable( + data.data.map((row) => ({ + when: formatDate(row.created_at), + type: row.type, + summary: truncate(row.summary ?? '—', 50), + by: row.actor?.name ?? row.actor?.type ?? '—', + via: row.source, + })), + ['when', 'type', 'summary', 'by', 'via'] + ); + } else { + console.log(chalk.dim(' No activity yet.')); + } + printPagination(data.pagination); +} + +// ============================================================================ +// Notes +// ============================================================================ + +type Note = { + id: string; + body: string; + author: { type: string; name: string | null } | null; + created_at: string; +}; + +export async function prospectNotesList( + id: string, + options: { page?: string; perPage?: string; json?: boolean } +): Promise { + const spinner = createSpinner('Fetching notes...').start(); + const data = await apiRequest<{ data: Note[]; pagination: Pagination }>( + `/prospects/${id}/notes`, + { + query: { + page: options.page ? parseInt(options.page, 10) : undefined, + per_page: options.perPage ? parseInt(options.perPage, 10) : undefined, + }, + } + ); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + printHeader(`Notes on ${id}`); + console.log(); + if (data.data.length) { + printTable( + data.data.map((note) => ({ + id: note.id, + note: truncate(note.body.replace(/\s+/g, ' '), 60), + by: note.author?.name ?? note.author?.type ?? '—', + when: formatDate(note.created_at), + })), + ['id', 'note', 'by', 'when'] + ); + } else { + console.log(chalk.dim(' No notes yet.')); + } + printPagination(data.pagination); +} + +export async function prospectNotesGet( + id: string, + noteId: string, + options: { json?: boolean } +): Promise { + const spinner = createSpinner('Fetching note...').start(); + const data = await apiRequest<{ data: Note }>(`/prospects/${id}/notes/${noteId}`); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + printHeader(`Note ${data.data.id}`); + printKeyValue({ + Body: data.data.body, + By: data.data.author?.name ?? data.data.author?.type ?? '—', + Created: formatDate(data.data.created_at), + }); + console.log(); +} + +export async function prospectNotesAdd( + id: string, + text: string | undefined, + options: { body?: string; file?: string; json?: boolean } +): Promise { + const requestBody = text ? { body: text } : await parseRequestBody(options); + const spinner = createSpinner('Adding note...').start(); + const data = await apiRequest<{ data: Note }>(`/prospects/${id}/notes`, { + method: 'POST', + body: requestBody, + }); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + console.log(chalk.green(`✓ Added ${data.data.id}`)); + console.log(); +} + +export async function prospectNotesEdit( + id: string, + noteId: string, + text: string, + options: { json?: boolean } +): Promise { + const spinner = createSpinner('Updating note...').start(); + const data = await apiRequest<{ data: Note }>(`/prospects/${id}/notes/${noteId}`, { + method: 'PATCH', + body: { body: text }, + }); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + console.log(chalk.green(`✓ Updated ${data.data.id}`)); + console.log(); +} + +export async function prospectNotesRemove(id: string, noteId: string, options: { json?: boolean }) { + const spinner = createSpinner('Deleting note...').start(); + const data = await apiRequest<{ data: { deleted: boolean; id: string } }>( + `/prospects/${id}/notes/${noteId}`, + { + method: 'DELETE', + } + ); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + console.log(chalk.green(`✓ Deleted ${data.data.id}`)); + console.log(); +} + +// ============================================================================ +// Files +// ============================================================================ + +type ProspectFile = { + id: string; + file_name: string; + file_size: number; + content_type: string; + uploaded_by: { type: string; name: string | null } | null; + created_at: string; +}; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export async function prospectFilesList(id: string, options: { json?: boolean }): Promise { + const spinner = createSpinner('Fetching files...').start(); + const data = await apiRequest<{ data: ProspectFile[] }>(`/prospects/${id}/files`); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + printHeader(`Files on ${id}`); + console.log(); + if (data.data.length) { + printTable( + data.data.map((file) => ({ + id: file.id, + name: truncate(file.file_name, 40), + size: formatBytes(file.file_size), + type: file.content_type, + by: file.uploaded_by?.name ?? file.uploaded_by?.type ?? '—', + when: formatDate(file.created_at), + })), + ['id', 'name', 'size', 'type', 'by', 'when'] + ); + } else { + console.log(chalk.dim(' No files yet.')); + } + console.log(); +} + +export async function prospectFilesUpload( + id: string, + path: string, + options: { contentType?: string; name?: string; json?: boolean } +): Promise { + const bytes = await readFile(path); + const fileName = options.name ?? basename(path); + const contentType = options.contentType ?? 'application/octet-stream'; + + const spinner = createSpinner(`Uploading ${fileName}...`).start(); + const target = await apiRequest<{ + data: { upload_url: string; headers: Record; s3_key: string }; + }>(`/prospects/${id}/files/upload-url`, { + method: 'POST', + body: { file_name: fileName, content_type: contentType, file_size: bytes.byteLength }, + }); + const put = await fetch(target.data.upload_url, { + method: 'PUT', + headers: target.data.headers, + body: bytes, + }); + if (!put.ok) { + spinner.stop(); + console.error(chalk.red(`Error: upload failed with status ${put.status}`)); + process.exit(1); + } + const data = await apiRequest<{ data: ProspectFile }>(`/prospects/${id}/files`, { + method: 'POST', + body: { + s3_key: target.data.s3_key, + file_name: fileName, + file_size: bytes.byteLength, + content_type: contentType, + }, + }); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + console.log(chalk.green(`✓ Attached ${data.data.id} (${formatBytes(data.data.file_size)})`)); + console.log(); +} + +export async function prospectFilesDownload( + id: string, + fileId: string, + options: { out?: string; json?: boolean } +): Promise { + const spinner = createSpinner('Fetching download link...').start(); + const link = await apiRequest<{ data: { url: string; expires_in: number } }>( + `/prospects/${id}/files/${fileId}/download`, + { query: { redirect: 'false' } } + ); + if (options.json) { + spinner.stop(); + printJson(link); + return; + } + const response = await fetch(link.data.url); + if (!response.ok) { + spinner.stop(); + console.error(chalk.red(`Error: download failed with status ${response.status}`)); + process.exit(1); + } + const out = options.out ?? `${fileId}.bin`; + await writeFile(out, Buffer.from(await response.arrayBuffer())); + spinner.stop(); + console.log(chalk.green(`✓ Saved to ${out}`)); + console.log(); +} + +export async function prospectFilesRemove(id: string, fileId: string, options: { json?: boolean }) { + const spinner = createSpinner('Deleting file...').start(); + const data = await apiRequest<{ data: { deleted: boolean; id: string } }>( + `/prospects/${id}/files/${fileId}`, + { + method: 'DELETE', + } + ); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + console.log(chalk.green(`✓ Deleted ${data.data.id}`)); + console.log(); +} + +// ============================================================================ +// Photos +// ============================================================================ + +type Photo = { + id: string; + url: string; + photo_type: string | null; + caption: string | null; + created_at: string | null; +}; + +export async function prospectPhotosList(id: string, options: { json?: boolean }): Promise { + const spinner = createSpinner('Fetching photos...').start(); + const data = await apiRequest<{ data: Photo[] }>(`/prospects/${id}/photos`); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + printHeader(`Photos on ${id}`); + console.log(); + if (data.data.length) { + printTable( + data.data.map((photo) => ({ + id: photo.id, + type: photo.photo_type ?? '—', + caption: truncate(photo.caption ?? '—', 30), + when: photo.created_at ? formatDate(photo.created_at) : '—', + url: truncate(photo.url, 48), + })), + ['id', 'type', 'caption', 'when', 'url'] + ); + } else { + console.log(chalk.dim(' No photos yet.')); + } + console.log(); +} + +export async function prospectPhotosAdd( + id: string, + options: { file?: string; url?: string; type?: string; caption?: string; json?: boolean } +): Promise { + if (!options.file && !options.url) { + console.error(chalk.red('Error: pass --file or --url ')); + process.exit(1); + } + const body: Record = { + ...(options.type && { photo_type: options.type }), + ...(options.caption && { caption: options.caption }), + }; + if (options.file) { + const bytes = await readFile(options.file); + if (bytes.byteLength > 700 * 1024) { + console.error( + chalk.red('Error: files over 700 KB must be added with --url (the API body limit is 1 MB).') + ); + process.exit(1); + } + body.image_base64 = bytes.toString('base64'); + } else { + body.image_url = options.url; + } + const spinner = createSpinner('Adding photo...').start(); + const data = await apiRequest<{ data: Photo }>(`/prospects/${id}/photos`, { + method: 'POST', + body, + }); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + console.log(chalk.green(`✓ Added ${data.data.id}`)); + console.log(chalk.dim(` ${data.data.url}`)); + console.log(); +} + +export async function prospectPhotosRemove( + id: string, + photoId: string, + options: { json?: boolean } +) { + const spinner = createSpinner('Deleting photo...').start(); + const data = await apiRequest<{ data: { deleted: boolean; id: string } }>( + `/prospects/${id}/photos/${photoId}`, + { + method: 'DELETE', + } + ); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + console.log(chalk.green(`✓ Deleted ${data.data.id}`)); + console.log(); +} + +// ============================================================================ +// Tags on a prospect +// ============================================================================ + +type Tag = { + id: string; + name: string; + badge_variant: string; + is_system: boolean; + assigned?: boolean; + usage_count?: number; +}; + +export async function prospectTagsList(id: string, options: { json?: boolean }): Promise { + const spinner = createSpinner('Fetching tags...').start(); + const data = await apiRequest<{ data: Tag[] }>(`/prospects/${id}/tags`); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + printHeader(`Tags on ${id}`); + console.log(); + printTable( + data.data.map((tag) => ({ + id: tag.id, + name: tag.name, + assigned: tag.assigned ? '✓' : '', + color: tag.badge_variant, + builtin: tag.is_system ? 'yes' : '', + })), + ['id', 'name', 'assigned', 'color', 'builtin'] + ); + console.log(); +} + +export async function prospectTagsSet( + id: string, + options: { ids: string; json?: boolean } +): Promise { + const spinner = createSpinner('Setting tags...').start(); + const data = await apiRequest<{ data: { tags: Tag[]; added: string[]; removed: string[] } }>( + `/prospects/${id}/tags`, + { method: 'PUT', body: { tag_ids: splitIds(options.ids) } } + ); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + console.log( + chalk.green( + `✓ ${data.data.tags.map((tag) => tag.name).join(', ') || 'No tags'} (added ${data.data.added.length}, removed ${data.data.removed.length})` + ) + ); + console.log(); +} + +export async function prospectTagsAdd(id: string, tagId: string, options: { json?: boolean }) { + const spinner = createSpinner('Adding tag...').start(); + const data = await apiRequest<{ data: { changed: boolean; tag: Tag } }>( + `/prospects/${id}/tags/${tagId}`, + { + method: 'POST', + } + ); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + console.log( + chalk.green( + data.data.changed + ? `✓ Added "${data.data.tag.name}"` + : `"${data.data.tag.name}" was already on ${id}` + ) + ); + console.log(); +} + +export async function prospectTagsRemove(id: string, tagId: string, options: { json?: boolean }) { + const spinner = createSpinner('Removing tag...').start(); + const data = await apiRequest<{ data: { changed: boolean; tag: Tag } }>( + `/prospects/${id}/tags/${tagId}`, + { + method: 'DELETE', + } + ); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + console.log( + chalk.green( + data.data.changed + ? `✓ Removed "${data.data.tag.name}"` + : `"${data.data.tag.name}" was not on ${id}` + ) + ); + console.log(); +} diff --git a/src/commands/tags.ts b/src/commands/tags.ts new file mode 100644 index 0000000..eede61c --- /dev/null +++ b/src/commands/tags.ts @@ -0,0 +1,196 @@ +/** + * Tags commands: the prospect tag catalog. Built-in tags are shared and + * read-only; workspace tags can be created, changed, reordered, and deleted. + */ + +import chalk from 'chalk'; + +import { apiRequest, formatDate } from '../lib/client.js'; +import { + createSpinner, + printHeader, + printJson, + printKeyValue, + printTable, + truncate, +} from '../lib/output.js'; + +type Tag = { + id: string; + name: string; + description: string | null; + badge_variant: string; + is_system: boolean; + is_active: boolean; + sort_order: number; + usage_count?: number; + created_at: string | null; +}; + +function printTagRows(tags: Tag[]) { + printTable( + tags.map((tag) => ({ + id: tag.id, + name: truncate(tag.name, 30), + color: tag.badge_variant, + prospects: String(tag.usage_count ?? 0), + builtin: tag.is_system ? 'yes' : '', + active: tag.is_active ? 'yes' : 'no', + })), + ['id', 'name', 'color', 'prospects', 'builtin', 'active'] + ); +} + +export async function tagsList(options: { + includeInactive?: boolean; + json?: boolean; +}): Promise { + const spinner = createSpinner('Fetching tags...').start(); + const data = await apiRequest<{ data: Tag[] }>('/tags', { + query: { include_inactive: options.includeInactive ? 'true' : undefined }, + }); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + printHeader('Tags'); + console.log(); + printTagRows(data.data); + console.log(); +} + +export async function tagsGet(id: string, options: { json?: boolean }): Promise { + const spinner = createSpinner('Fetching tag...').start(); + const data = await apiRequest<{ data: Tag }>(`/tags/${id}`); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + const tag = data.data; + printHeader(`Tag ${tag.id}`); + printKeyValue({ + Name: tag.name, + Description: tag.description ?? '—', + Color: tag.badge_variant, + 'On prospects': String(tag.usage_count ?? 0), + 'Built in': tag.is_system ? 'Yes' : 'No', + Active: tag.is_active ? 'Yes' : 'No', + Order: String(tag.sort_order), + Created: tag.created_at ? formatDate(tag.created_at) : '—', + }); + console.log(); +} + +export async function tagsCreate(options: { + name: string; + description?: string; + color?: string; + order?: string; + json?: boolean; +}): Promise { + const spinner = createSpinner('Creating tag...').start(); + const data = await apiRequest<{ data: Tag }>('/tags', { + method: 'POST', + body: { + name: options.name, + ...(options.description && { description: options.description }), + ...(options.color && { badge_variant: options.color }), + ...(options.order && { sort_order: parseInt(options.order, 10) }), + }, + }); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + console.log(chalk.green(`✓ Created "${data.data.name}" (${data.data.id})`)); + console.log(); +} + +export async function tagsUpdate( + id: string, + options: { + name?: string; + description?: string; + color?: string; + order?: string; + archive?: boolean; + restore?: boolean; + json?: boolean; + } +): Promise { + const body: Record = { + ...(options.name && { name: options.name }), + ...(options.description !== undefined && { description: options.description }), + ...(options.color && { badge_variant: options.color }), + ...(options.order && { sort_order: parseInt(options.order, 10) }), + ...(options.archive && { is_active: false }), + ...(options.restore && { is_active: true }), + }; + if (Object.keys(body).length === 0) { + console.error( + chalk.red( + 'Error: nothing to change. Pass --name, --description, --color, --order, --archive, or --restore.' + ) + ); + process.exit(1); + } + const spinner = createSpinner('Updating tag...').start(); + const data = await apiRequest<{ data: Tag }>(`/tags/${id}`, { method: 'PATCH', body }); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + console.log(chalk.green(`✓ Updated "${data.data.name}" (${data.data.id})`)); + console.log(); +} + +export async function tagsDelete( + id: string, + options: { force?: boolean; json?: boolean } +): Promise { + const spinner = createSpinner('Deleting tag...').start(); + const data = await apiRequest<{ data: { deleted: boolean; id: string; unassigned: number } }>( + `/tags/${id}`, + { + method: 'DELETE', + query: { force: options.force ? 'true' : undefined }, + } + ); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + console.log( + chalk.green( + `✓ Deleted ${data.data.id}${data.data.unassigned ? ` and removed it from ${data.data.unassigned} prospects` : ''}` + ) + ); + console.log(); +} + +export async function tagsReorder(options: { ids: string; json?: boolean }): Promise { + const spinner = createSpinner('Reordering tags...').start(); + const data = await apiRequest<{ data: Tag[] }>('/tags/reorder', { + method: 'POST', + body: { + tag_ids: options.ids + .split(',') + .map((id) => id.trim()) + .filter(Boolean), + }, + }); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + printHeader('Tags'); + console.log(); + printTagRows(data.data); + console.log(); +} diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index 7c184c8..33ba650 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -1,5 +1,5 @@ /** - * Tasks commands for top-level task management + * Tasks commands — top-level task management (not CRM-specific) */ import chalk from 'chalk'; diff --git a/src/commands/webhooks.ts b/src/commands/webhooks.ts new file mode 100644 index 0000000..1f19eb5 --- /dev/null +++ b/src/commands/webhooks.ts @@ -0,0 +1,413 @@ +/** + * Webhooks commands: register URLs that receive prospect and list events, + * check their health, read the delivery log, and resend. + */ + +import chalk from 'chalk'; + +import { apiRequest, formatDate } from '../lib/client.js'; +import { + createSpinner, + printHeader, + printJson, + printKeyValue, + printTable, + truncate, +} from '../lib/output.js'; + +type Webhook = { + id: string; + url: string; + description: string | null; + event_types: string[]; + consumer: string; + batch_max: number; + include_contacts: boolean; + is_active: boolean; + disabled_reason: string | null; + secret_last4: string; + secret?: string; + previous_secret_expires_at: string | null; + health: { + consecutive_failures: number; + failing_since: string | null; + last_success_at: string | null; + last_failure_at: string | null; + last_response_status: number | null; + }; + created_at: string; +}; + +type Delivery = { + id: string; + event: { id: string; type: string; occurred_at: string }; + status: string; + attempt_count: number; + next_attempt_at: string | null; + response_status: number | null; + response_ms: number | null; + error: string | null; + created_at: string; +}; + +type Pagination = { page: number; per_page: number; total: number; has_more: boolean }; + +function splitList(value: string | undefined): string[] { + return (value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function healthLabel(hook: Webhook): string { + if (!hook.is_active) return `off${hook.disabled_reason ? ` (${hook.disabled_reason})` : ''}`; + if (hook.health.consecutive_failures > 0) return `failing x${hook.health.consecutive_failures}`; + return hook.health.last_success_at ? 'healthy' : 'no deliveries yet'; +} + +function printWebhook(hook: Webhook) { + printKeyValue({ + ID: hook.id, + URL: hook.url, + Description: hook.description ?? '—', + Events: hook.event_types.join(', '), + Consumer: hook.consumer, + 'Batch size': String(hook.batch_max), + 'Include contacts': hook.include_contacts ? 'Yes' : 'No', + Status: healthLabel(hook), + 'Last success': hook.health.last_success_at ? formatDate(hook.health.last_success_at) : '—', + 'Last failure': hook.health.last_failure_at ? formatDate(hook.health.last_failure_at) : '—', + Secret: hook.secret ?? `whsec_…${hook.secret_last4}`, + Created: formatDate(hook.created_at), + }); + if (hook.secret) { + console.log(chalk.yellow(' Save the secret now. It is shown only once.')); + } + console.log(); +} + +export async function webhooksList(options: { + includeZapier?: boolean; + json?: boolean; +}): Promise { + const spinner = createSpinner('Fetching webhooks...').start(); + const data = await apiRequest<{ data: Webhook[] }>('/webhooks', { + query: { include_zapier: options.includeZapier ? 'true' : undefined }, + }); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + printHeader('Webhooks'); + console.log(); + if (data.data.length) { + printTable( + data.data.map((hook) => ({ + id: hook.id, + url: truncate(hook.url, 44), + events: truncate(hook.event_types.join(', '), 30), + status: healthLabel(hook), + 'last success': hook.health.last_success_at ? formatDate(hook.health.last_success_at) : '—', + })), + ['id', 'url', 'events', 'status', 'last success'] + ); + } else { + console.log( + chalk.dim( + ' No webhooks yet. Create one with: dm webhooks create --url https://... --events prospect.added' + ) + ); + } + console.log(); +} + +export async function webhooksGet(id: string, options: { json?: boolean }): Promise { + const spinner = createSpinner('Fetching webhook...').start(); + const data = await apiRequest<{ data: Webhook }>(`/webhooks/${id}`); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + printHeader(`Webhook ${data.data.id}`); + printWebhook(data.data); +} + +export async function webhooksCreate(options: { + url: string; + events: string; + description?: string; + batchMax?: string; + includeContacts?: boolean; + json?: boolean; +}): Promise { + const spinner = createSpinner('Creating webhook...').start(); + const data = await apiRequest<{ data: Webhook }>('/webhooks', { + method: 'POST', + body: { + url: options.url, + event_types: splitList(options.events), + ...(options.description && { description: options.description }), + ...(options.batchMax && { batch_max: parseInt(options.batchMax, 10) }), + ...(options.includeContacts && { include_contacts: true }), + }, + }); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + printHeader('Webhook Created'); + printWebhook(data.data); +} + +export async function webhooksUpdate( + id: string, + options: { + url?: string; + events?: string; + description?: string; + batchMax?: string; + includeContacts?: boolean; + noIncludeContacts?: boolean; + enable?: boolean; + disable?: boolean; + json?: boolean; + } +): Promise { + const body: Record = { + ...(options.url && { url: options.url }), + ...(options.events && { event_types: splitList(options.events) }), + ...(options.description !== undefined && { description: options.description }), + ...(options.batchMax && { batch_max: parseInt(options.batchMax, 10) }), + ...(options.includeContacts && { include_contacts: true }), + ...(options.noIncludeContacts && { include_contacts: false }), + ...(options.enable && { is_active: true }), + ...(options.disable && { is_active: false }), + }; + if (Object.keys(body).length === 0) { + console.error( + chalk.red( + 'Error: nothing to change. Pass --url, --events, --description, --batch-max, --include-contacts, --enable, or --disable.' + ) + ); + process.exit(1); + } + const spinner = createSpinner('Updating webhook...').start(); + const data = await apiRequest<{ data: Webhook }>(`/webhooks/${id}`, { method: 'PATCH', body }); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + printHeader('Webhook Updated'); + printWebhook(data.data); +} + +export async function webhooksDelete(id: string, options: { json?: boolean }): Promise { + const spinner = createSpinner('Deleting webhook...').start(); + const data = await apiRequest<{ data: { deleted: boolean; id: string } }>(`/webhooks/${id}`, { + method: 'DELETE', + }); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + console.log(chalk.green(`✓ Deleted ${data.data.id}`)); + console.log(); +} + +export async function webhooksTest(id: string, options: { json?: boolean }): Promise { + const spinner = createSpinner('Sending test event...').start(); + const data = await apiRequest<{ + data: { + ok: boolean; + status: number | null; + response_ms: number; + response_excerpt: string | null; + error: string | null; + }; + }>(`/webhooks/${id}/test`, { method: 'POST' }); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + const result = data.data; + if (result.ok) { + console.log(chalk.green(`✓ Delivered: HTTP ${result.status} in ${result.response_ms} ms`)); + } else { + console.log(chalk.red(`✗ Failed: ${result.error ?? 'no response'} (${result.response_ms} ms)`)); + if (result.response_excerpt) + console.log(chalk.dim(` ${truncate(result.response_excerpt, 200)}`)); + } + console.log(); +} + +export async function webhooksRotateSecret(id: string, options: { json?: boolean }): Promise { + const spinner = createSpinner('Rotating secret...').start(); + const data = await apiRequest<{ data: Webhook }>(`/webhooks/${id}/rotate-secret`, { + method: 'POST', + }); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + printHeader('Secret Rotated'); + console.log(` New secret: ${chalk.bold(data.data.secret ?? '')}`); + console.log( + chalk.dim( + ` The old secret keeps working until ${data.data.previous_secret_expires_at ?? 'tomorrow'}.` + ) + ); + console.log(); +} + +export async function webhooksDeliveries( + id: string, + options: { + status?: string; + eventType?: string; + since?: string; + page?: string; + perPage?: string; + json?: boolean; + } +): Promise { + const spinner = createSpinner('Fetching deliveries...').start(); + const data = await apiRequest<{ data: Delivery[]; pagination: Pagination }>( + `/webhooks/${id}/deliveries`, + { + query: { + status: options.status, + event_type: options.eventType, + since: options.since, + page: options.page ? parseInt(options.page, 10) : undefined, + per_page: options.perPage ? parseInt(options.perPage, 10) : undefined, + }, + } + ); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + printHeader(`Deliveries for ${id}`); + console.log(); + if (data.data.length) { + printTable( + data.data.map((delivery) => ({ + id: delivery.id, + event: delivery.event.type, + status: delivery.status, + attempts: String(delivery.attempt_count), + http: delivery.response_status ? String(delivery.response_status) : '—', + ms: delivery.response_ms != null ? String(delivery.response_ms) : '—', + error: truncate(delivery.error ?? '—', 36), + when: formatDate(delivery.created_at), + })), + ['id', 'event', 'status', 'attempts', 'http', 'ms', 'error', 'when'] + ); + } else { + console.log(chalk.dim(' No deliveries yet.')); + } + const p = data.pagination; + console.log(); + console.log( + chalk.dim(`Page ${p.page} — ${p.total} total${p.has_more ? ' (more available)' : ''}`) + ); + console.log(); +} + +export async function webhooksDeliveryGet( + id: string, + deliveryId: string, + options: { json?: boolean } +): Promise { + const spinner = createSpinner('Fetching delivery...').start(); + const data = await apiRequest<{ data: Delivery }>(`/webhooks/${id}/deliveries/${deliveryId}`); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + const delivery = data.data; + printHeader(`Delivery ${delivery.id}`); + printKeyValue({ + Event: delivery.event.type, + Status: delivery.status, + Attempts: String(delivery.attempt_count), + HTTP: delivery.response_status == null ? '—' : String(delivery.response_status), + 'Response time': delivery.response_ms == null ? '—' : `${delivery.response_ms} ms`, + Error: delivery.error ?? '—', + Created: formatDate(delivery.created_at), + }); + console.log(); +} + +export async function webhooksRedeliver( + id: string, + deliveryId: string | undefined, + options: { since?: string; json?: boolean } +): Promise { + const target = deliveryId ?? 'all'; + if (target === 'all' && !options.since) { + console.error( + chalk.red('Error: pass a delivery ID, or --since to resend a window.') + ); + process.exit(1); + } + const spinner = createSpinner('Requeueing...').start(); + const data = await apiRequest<{ data: { requeued: number } }>( + `/webhooks/${id}/deliveries/${target}/redeliver`, + { + method: 'POST', + query: { since: options.since }, + } + ); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + console.log( + chalk.green( + `✓ Requeued ${data.data.requeued} ${data.data.requeued === 1 ? 'delivery' : 'deliveries'}` + ) + ); + console.log(); +} + +export async function webhooksEvents(options: { json?: boolean; example?: string }): Promise { + const spinner = createSpinner('Fetching event catalog...').start(); + const data = await apiRequest<{ + data: Array<{ type: string; description: string; example: unknown }>; + api_version: string; + }>('/webhooks/events'); + spinner.stop(); + if (options.example) { + const entry = data.data.find((event) => event.type === options.example); + if (!entry) { + console.error(chalk.red(`Error: unknown event type ${options.example}`)); + process.exit(1); + } + printJson(entry.example); + return; + } + if (options.json) { + printJson(data); + return; + } + printHeader(`Webhook Events (api version ${data.api_version})`); + console.log(); + printTable( + data.data.map((event) => ({ type: event.type, description: truncate(event.description, 70) })), + ['type', 'description'] + ); + console.log(); + console.log(chalk.dim(' See a full example body: dm webhooks events --example prospect.added')); + console.log(); +} diff --git a/src/index.ts b/src/index.ts index c9e7866..69cac75 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ #!/usr/bin/env node +import { realpathSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { Command } from 'commander'; import { CLI_VERSION } from './version.js'; import { login } from './commands/login.js'; @@ -66,6 +68,7 @@ import { listsExport, } from './commands/lists.js'; import { tasksList, tasksGet, tasksCreate, tasksUpdate, tasksDelete } from './commands/tasks.js'; +import { drivingList, drivingGet } from './commands/driving.js'; import { mailCampaignsList, mailCampaignsCreate, @@ -98,8 +101,59 @@ import { mailAnalyticsSummary, mailAnalyticsTimeseries, } from './commands/mail.js'; - -const program = new Command(); +import { + prospectsList, + prospectsGet, + prospectsGetByRecord, + prospectsAdd, + prospectsArchive, + prospectsRemove, + prospectsReactivate, + prospectsOpportunity, + prospectsFavorite, + prospectsCheck, + prospectsCounts, + prospectsActivity, + prospectNotesList, + prospectNotesGet, + prospectNotesAdd, + prospectNotesEdit, + prospectNotesRemove, + prospectFilesList, + prospectFilesUpload, + prospectFilesDownload, + prospectFilesRemove, + prospectPhotosList, + prospectPhotosAdd, + prospectPhotosRemove, + prospectTagsList, + prospectTagsSet, + prospectTagsAdd, + prospectTagsRemove, +} from './commands/prospects.js'; +import { + tagsList, + tagsGet, + tagsCreate, + tagsUpdate, + tagsDelete, + tagsReorder, +} from './commands/tags.js'; +import { + webhooksList, + webhooksGet, + webhooksCreate, + webhooksUpdate, + webhooksDelete, + webhooksTest, + webhooksRotateSecret, + webhooksDeliveries, + webhooksDeliveryGet, + webhooksRedeliver, + webhooksEvents, +} from './commands/webhooks.js'; + +export const program = new Command(); program .name('dm') @@ -148,10 +202,7 @@ Examples: dm agents skill Alias for dm agents playbook` ) .action(async (options: { json?: boolean }) => { - await agentsGuide({ - ...options, - json: options.json || agentsCmd.opts().json, - }); + await agentsGuide({ ...options, json: options.json || agentsCmd.opts().json }); }); agentsCmd @@ -166,10 +217,7 @@ Examples: dm agents guide --json Print agent guidance as JSON` ) .action(async (options: { json?: boolean }) => { - await agentsGuide({ - ...options, - json: options.json || agentsCmd.opts().json, - }); + await agentsGuide({ ...options, json: options.json || agentsCmd.opts().json }); }); agentsCmd @@ -186,10 +234,7 @@ Examples: dm agents skill Alias for dm agents playbook` ) .action(async (options: { json?: boolean }) => { - await agentsPlaybook({ - ...options, - json: options.json || agentsCmd.opts().json, - }); + await agentsPlaybook({ ...options, json: options.json || agentsCmd.opts().json }); }); agentsCmd @@ -211,10 +256,7 @@ Examples: ) .action( async (agent: string, options: { project?: boolean; force?: boolean; json?: boolean }) => { - const normalizedOptions = { - ...options, - json: options.json || agentsCmd.opts().json, - }; + const normalizedOptions = { ...options, json: options.json || agentsCmd.opts().json }; if (agent !== 'claude-code') { const message = `Unsupported agent "${agent}". Supported agents: claude-code`; if (normalizedOptions.json) console.log(JSON.stringify({ error: message }, null, 2)); @@ -238,10 +280,7 @@ Examples: dm agents permissions --json` ) .action(async (options: { json?: boolean }) => { - await agentsPermissions({ - ...options, - json: options.json || agentsCmd.opts().json, - }); + await agentsPermissions({ ...options, json: options.json || agentsCmd.opts().json }); }); // ============================================================================ @@ -270,11 +309,7 @@ Examples: if (options.env) { process.env.DM_ENV = options.env; } - await login({ - noBrowser: options.browser === false, - key: options.key, - env: options.env, - }); + await login({ noBrowser: options.browser === false, key: options.key, env: options.env }); }); program @@ -432,7 +467,7 @@ Examples: dm account --json Show account details as JSON` ) .action(async (options) => { - await account(); + await account(options); }); program @@ -523,8 +558,7 @@ locationsCmd ` Examples: dm locations autocomplete "1200 Barton Springs" --state TX - dm locations autocomplete "saint louis 63101" --scope location --json - dm loc autocomplete "Harris County" --limit 5` + dm loc autocomplete "46 Joyce St" --limit 5 --json` ) .action(async (query, options) => { await locationsAutocomplete({ query, ...options }); @@ -819,6 +853,7 @@ listsCmd .option('--ids ', 'Comma-separated record IDs to pre-populate (max 250)') .option('--body ', 'Request body as JSON (filters/locations)') .option('-f, --file ', 'Read request body from a JSON file') + .option('--no-prospects', 'File the records without adding them as prospects (default adds them)') .option('--json', 'Output as JSON') .addHelpText( 'after', @@ -826,7 +861,8 @@ listsCmd Examples: dm lists create --name "Austin Leads" dm lists create --name "TX Owners" --source-type properties --json - dm lists create --name "Import" --ids 100,200,300` + dm lists create --name "Import" --ids 100,200,300 + dm lists create --name "Mailing only" --ids 100,200 --no-prospects` ) .action(async (options) => { await listsCreate(options); @@ -889,6 +925,7 @@ listsCmd .description('Build a list from search filters') .option('--body ', 'Request body as JSON (filters/locations)') .option('-f, --file ', 'Read request body from a JSON file') + .option('--no-prospects', 'File the records without adding them as prospects (default adds them)') .option('--json', 'Output as JSON') .addHelpText( 'after', @@ -910,6 +947,7 @@ listsCmd .option('--source-type ', 'Source type: properties or people') .option('--body ', 'Request body as JSON') .option('-f, --file ', 'Read request body from a JSON file') + .option('--no-prospects', 'File the records without adding them as prospects (default adds them)') .option('--json', 'Output as JSON') .addHelpText( 'after', @@ -946,6 +984,7 @@ listsCmd .description('Add items to a list') .requiredOption('--ids ', 'Comma-separated list of IDs to add') .option('--id-type ', 'ID type: internal_property_id or internal_person_id') + .option('--no-prospects', 'File the records without adding them as prospects (default adds them)') .option('--json', 'Output as JSON') .addHelpText( 'after', @@ -1407,8 +1446,7 @@ addressesCmd ` Examples: dm addresses autocomplete "1200 Barton Springs" --state TX - dm addresses autocomplete "saint louis 63101" --scope location --json - dm addresses autocomplete "Harris County" --limit 5` + dm addresses autocomplete "46 Joyce St" --limit 5 --json` ) .action(async (query, options) => { await addressesAutocomplete({ query, ...options }); @@ -1536,6 +1574,557 @@ Examples: await tasksDelete(id, options); }); +// ============================================================================ +// Driving commands +// ============================================================================ + +const drivingCmd = program.command('driving').description('Read recorded drive history'); + +drivingCmd + .command('list') + .alias('ls') + .description('List recorded drives') + .option('--driver-user-id ', 'Only drives by this user') + .option('--mode ', 'free_drive, route_plan, or area_drive') + .option('--started-after ', 'Inclusive start date or timestamp') + .option('--started-before ', 'Inclusive end date or timestamp') + .option('-p, --page ', 'Page number') + .option('--per-page ', 'Results per page (max 100)') + .option('--json', 'Output as JSON') + .addHelpText( + 'after', + ` +Examples: + dm driving list + dm driving list --mode free_drive --started-after 2026-08-01 --json` + ) + .action(async (options) => { + await drivingList(options); + }); + +drivingCmd + .command('get ') + .description('Show a drive with visits, events, and prospects added') + .option('--json', 'Output as JSON') + .addHelpText( + 'after', + ` +Examples: + dm driving get drive_session_501 + dm driving get drive_route_7 --json` + ) + .action(async (id, options) => { + await drivingGet(id, options); + }); + +// ============================================================================ +// Prospects commands +// ============================================================================ + +const prospectsCmd = program + .command('prospects') + .description('Work prospects: add, list, archive, notes, files, photos, tags, activity'); + +prospectsCmd + .command('list') + .alias('ls') + .description('List prospects') + .option('--record-type ', 'property (default) or person') + .option('--lifecycle ', 'active (default), opportunity, or archived') + .option('--source ', 'Filter by source, e.g. driving, import, api, cli, or mcp') + .option('--favorites', 'Only starred prospects') + .option('--list ', 'Only members of this list') + .option('--tag ', 'Only prospects with this tag, e.g. tag_5') + .option('--search ', 'Address words for properties; name or city for people') + .option('--sort ', 'newest (default) or oldest') + .option('-p, --page ', 'Page number') + .option('--per-page ', 'Results per page (max 100)') + .option('--json', 'Output as JSON') + .addHelpText( + 'after', + ` +Examples: + dm prospects list + dm prospects list --source driving + dm prospects list --lifecycle opportunity --tag tag_1 + dm prospects list --record-type person --search "austin" --json` + ) + .action(async (options) => { + await prospectsList(options); + }); + +prospectsCmd + .command('get ') + .description('Show one prospect') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await prospectsGet(id, options); + }); + +prospectsCmd + .command('get-by-record ') + .description('Find a prospect by its property, person, or company record ID') + .option('--record-type ', 'property (default), person, or company') + .option('--json', 'Output as JSON') + .action(async (recordId, options) => { + await prospectsGetByRecord({ ...options, recordId }); + }); + +prospectsCmd + .command('add') + .description('Track records as prospects (up to 1,000)') + .option('--ids ', 'Comma-separated record IDs, e.g. prop_123,prop_456') + .option('--record-type ', 'property (default), person, or company') + .option('--favorite', 'Also star them') + .option('--body ', 'Request body as JSON') + .option('-f, --file ', 'Read request body from a JSON file') + .option('--json', 'Output as JSON') + .addHelpText( + 'after', + ` +Examples: + dm prospects add --ids prop_12345,prop_67890 + dm prospects add --ids person_777 --record-type person --favorite` + ) + .action(async (options) => { + await prospectsAdd(options); + }); + +prospectsCmd + .command('archive ') + .description('Archive a prospect (ends its mail and keeps its lists and opportunities)') + .option('--no-cascade', 'Skip optional archive cleanup; mail always ends and deals stay open') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await prospectsArchive(id, { json: options.json, noCascade: options.cascade === false }); + }); + +prospectsCmd + .command('remove ') + .alias('rm') + .description('Archive a prospect through the remove endpoint') + .option('--no-cascade', 'Skip optional archive cleanup; mail always ends and deals stay open') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await prospectsRemove(id, { json: options.json, noCascade: options.cascade === false }); + }); + +prospectsCmd + .command('reactivate ') + .description('Return an archived prospect to active') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await prospectsReactivate(id, options); + }); + +prospectsCmd + .command('opportunity ') + .description('Mark a prospect as an opportunity') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await prospectsOpportunity(id, options); + }); + +prospectsCmd + .command('favorite ') + .description('Star a prospect (or --off to unstar)') + .option('--off', 'Remove the star') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await prospectsFavorite(id, options); + }); + +prospectsCmd + .command('check') + .description('See which records are prospects') + .requiredOption('--ids ', 'Comma-separated record IDs (max 500)') + .option('--record-type ', 'property (default), person, or company') + .option('--json', 'Output as JSON') + .action(async (options) => { + await prospectsCheck(options); + }); + +prospectsCmd + .command('counts') + .description('Prospect counts by lifecycle and record type') + .option('--list ', 'Count only members of this list') + .option('--json', 'Output as JSON') + .action(async (options) => { + await prospectsCounts(options); + }); + +prospectsCmd + .command('activity ') + .description('Show the prospect activity feed') + .option( + '--category ', + 'record, note, tag, list, communication, crm, driving, enrichment' + ) + .option('--since ', 'Only activity after this ISO 8601 time') + .option('-p, --page ', 'Page number') + .option('--per-page ', 'Results per page (max 100)') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await prospectsActivity(id, options); + }); + +const prospectNotesCmd = prospectsCmd.command('notes').description('Notes on a prospect'); +prospectNotesCmd + .command('list ') + .description('List notes') + .option('-p, --page ', 'Page number') + .option('--per-page ', 'Results per page') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await prospectNotesList(id, options); + }); +prospectNotesCmd + .command('get ') + .description('Show one note') + .option('--json', 'Output as JSON') + .action(async (id, noteId, options) => { + await prospectNotesGet(id, noteId, options); + }); +prospectNotesCmd + .command('add [text]') + .description('Add a note') + .option('--body ', 'Request body as JSON ({"body": "..."})') + .option('-f, --file ', 'Read request body from a JSON file') + .option('--json', 'Output as JSON') + .addHelpText( + 'after', + `\nExamples:\n dm prospects notes add prospect_8812 "Owner wants an offer by Friday"` + ) + .action(async (id, text, options) => { + await prospectNotesAdd(id, text, options); + }); +prospectNotesCmd + .command('edit ') + .description('Edit a note') + .option('--json', 'Output as JSON') + .action(async (id, noteId, text, options) => { + await prospectNotesEdit(id, noteId, text, options); + }); +prospectNotesCmd + .command('remove ') + .alias('rm') + .description('Delete a note') + .option('--json', 'Output as JSON') + .action(async (id, noteId, options) => { + await prospectNotesRemove(id, noteId, options); + }); + +const prospectFilesCmd = prospectsCmd.command('files').description('Files on a prospect'); +prospectFilesCmd + .command('list ') + .description('List files') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await prospectFilesList(id, options); + }); +prospectFilesCmd + .command('upload ') + .description('Attach a local file (up to 25 MB)') + .option('--content-type ', 'MIME type, e.g. application/pdf') + .option('--name ', 'Name to show instead of the local file name') + .option('--json', 'Output as JSON') + .addHelpText( + 'after', + `\nExamples:\n dm prospects files upload prospect_8812 ./inspection.pdf --content-type application/pdf` + ) + .action(async (id, path, options) => { + await prospectFilesUpload(id, path, options); + }); +prospectFilesCmd + .command('download ') + .description('Download a file') + .option('-o, --out ', 'Where to save it') + .option('--json', 'Print the signed URL instead of downloading') + .action(async (id, fileId, options) => { + await prospectFilesDownload(id, fileId, options); + }); +prospectFilesCmd + .command('remove ') + .alias('rm') + .description('Delete a file') + .option('--json', 'Output as JSON') + .action(async (id, fileId, options) => { + await prospectFilesRemove(id, fileId, options); + }); + +const prospectPhotosCmd = prospectsCmd + .command('photos') + .description('Property photos on a prospect'); +prospectPhotosCmd + .command('list ') + .description('List photos') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await prospectPhotosList(id, options); + }); +prospectPhotosCmd + .command('add ') + .description('Add a photo from a local file (under 700 KB) or a public https URL') + .option('--file ', 'Local JPEG, PNG, or WebP') + .option('--url ', 'Public https image URL (up to 10 MB)') + .option( + '--type ', + 'street_view, property_front, property_side, property_back, condition, damage, other' + ) + .option('--caption ', 'Caption') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await prospectPhotosAdd(id, options); + }); +prospectPhotosCmd + .command('remove ') + .alias('rm') + .description('Delete a photo') + .option('--json', 'Output as JSON') + .action(async (id, photoId, options) => { + await prospectPhotosRemove(id, photoId, options); + }); + +const prospectTagsCmd = prospectsCmd.command('tags').description('Tags on a prospect'); +prospectTagsCmd + .command('list ') + .description('Show the catalog with what is assigned') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await prospectTagsList(id, options); + }); +prospectTagsCmd + .command('set ') + .description('Replace the prospect tags with exactly these') + .requiredOption('--ids ', 'Comma-separated tag IDs (empty string clears)') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await prospectTagsSet(id, options); + }); +prospectTagsCmd + .command('add ') + .description('Add one tag') + .option('--json', 'Output as JSON') + .action(async (id, tagId, options) => { + await prospectTagsAdd(id, tagId, options); + }); +prospectTagsCmd + .command('remove ') + .alias('rm') + .description('Remove one tag') + .option('--json', 'Output as JSON') + .action(async (id, tagId, options) => { + await prospectTagsRemove(id, tagId, options); + }); + +// ============================================================================ +// Tags commands (catalog) +// ============================================================================ + +const tagsCmd = program.command('tags').description('Manage the prospect tag catalog'); + +tagsCmd + .command('list') + .alias('ls') + .description('List built-in and workspace tags') + .option('--include-inactive', 'Show archived tags too') + .option('--json', 'Output as JSON') + .action(async (options) => { + await tagsList(options); + }); + +tagsCmd + .command('get ') + .description('Show one tag') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await tagsGet(id, options); + }); + +tagsCmd + .command('create') + .description('Create a workspace tag') + .requiredOption('--name ', 'Tag name (unique in the workspace)') + .option('--description ', 'What the tag means') + .option('--color ', 'default, secondary, info, warning, or destructive') + .option('--order ', 'Sort order (lower first)') + .option('--json', 'Output as JSON') + .addHelpText('after', `\nExamples:\n dm tags create --name Probate --color info`) + .action(async (options) => { + await tagsCreate(options); + }); + +tagsCmd + .command('update ') + .description('Change a workspace tag') + .option('--name ', 'New name') + .option('--description ', 'New description') + .option('--color ', 'default, secondary, info, warning, or destructive') + .option('--order ', 'Sort order') + .option('--archive', 'Hide the tag without removing it from prospects') + .option('--restore', 'Show an archived tag again') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await tagsUpdate(id, options); + }); + +tagsCmd + .command('delete ') + .description('Delete a workspace tag') + .option('--force', 'Remove it from every prospect first') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await tagsDelete(id, options); + }); + +tagsCmd + .command('reorder') + .description('Set the display order of workspace tags') + .requiredOption('--ids ', 'Comma-separated tag IDs in the order you want') + .option('--json', 'Output as JSON') + .action(async (options) => { + await tagsReorder(options); + }); + +// ============================================================================ +// Webhooks commands +// ============================================================================ + +const webhooksCmd = program + .command('webhooks') + .description('Receive prospect and list events at your own URLs'); + +webhooksCmd + .command('list') + .alias('ls') + .description('List webhooks with their health') + .option('--include-zapier', 'Also show subscriptions the Zapier app created') + .option('--json', 'Output as JSON') + .action(async (options) => { + await webhooksList(options); + }); + +webhooksCmd + .command('get ') + .description('Show one webhook') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await webhooksGet(id, options); + }); + +webhooksCmd + .command('create') + .description('Register a URL (the signing secret is shown once)') + .requiredOption('--url ', 'Public https URL that accepts POST requests') + .requiredOption( + '--events ', + 'Event types, e.g. prospect.added,prospect.tags_changed (or prospect.* or *)' + ) + .option('--description ', 'What this webhook is for') + .option('--batch-max ', 'Most events per request (1 to 100, default 50)') + .option('--include-contacts', 'Include phone numbers and emails on person prospects') + .option('--json', 'Output as JSON') + .addHelpText( + 'after', + ` +Examples: + dm webhooks create --url https://example.com/hooks/dm --events prospect.added + dm webhooks create --url https://example.com/hooks/dm --events "prospect.*" --batch-max 10` + ) + .action(async (options) => { + await webhooksCreate(options); + }); + +webhooksCmd + .command('update ') + .description('Change a webhook or turn it on or off') + .option('--url ', 'New URL') + .option('--events ', 'New event types') + .option('--description ', 'New description') + .option('--batch-max ', 'Most events per request') + .option('--include-contacts', 'Include contact data') + .option('--no-include-contacts', 'Stop including contact data') + .option('--enable', 'Turn on (also clears a failure lockout)') + .option('--disable', 'Turn off') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await webhooksUpdate(id, { + ...options, + includeContacts: options.includeContacts === true ? true : undefined, + noIncludeContacts: options.includeContacts === false ? true : undefined, + }); + }); + +webhooksCmd + .command('delete ') + .description('Delete a webhook and its delivery log') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await webhooksDelete(id, options); + }); + +webhooksCmd + .command('test ') + .description('Send a signed ping event now and show the response') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await webhooksTest(id, options); + }); + +webhooksCmd + .command('rotate-secret ') + .description('Issue a new signing secret (the old one works for 24 hours)') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await webhooksRotateSecret(id, options); + }); + +webhooksCmd + .command('deliveries ') + .description('Show the delivery log') + .option('--status ', 'pending, processing, delivered, failed, abandoned, or skipped') + .option('--event-type ', 'Only this event type') + .option('--since ', 'Only deliveries after this ISO 8601 time') + .option('-p, --page ', 'Page number') + .option('--per-page ', 'Results per page (max 100)') + .option('--json', 'Output as JSON') + .action(async (id, options) => { + await webhooksDeliveries(id, options); + }); + +webhooksCmd + .command('delivery ') + .description('Show one delivery attempt') + .option('--json', 'Output as JSON') + .action(async (id, deliveryId, options) => { + await webhooksDeliveryGet(id, deliveryId, options); + }); + +webhooksCmd + .command('redeliver [deliveryId]') + .description('Send a delivery again, or every delivery since a time') + .option('--since ', 'Resend everything created after this ISO 8601 time') + .option('--json', 'Output as JSON') + .addHelpText( + 'after', + ` +Examples: + dm webhooks redeliver whk_12 dlv_9001 + dm webhooks redeliver whk_12 --since 2026-08-26T00:00:00Z` + ) + .action(async (id, deliveryId, options) => { + await webhooksRedeliver(id, deliveryId, options); + }); + +webhooksCmd + .command('events') + .description('List event types, or print an example body') + .option('--example ', 'Print the full example request body for one event type') + .option('--json', 'Output as JSON') + .action(async (options) => { + await webhooksEvents(options); + }); + // ============================================================================ // Mail commands // ============================================================================ @@ -2142,4 +2731,16 @@ devLicenseCmd // Parse and execute // ============================================================================ -program.parse(); +function isMainModule(): boolean { + if (!process.argv[1]) return false; + try { + return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); + } catch { + // Importing the program must not depend on the caller's entry file existing. + return false; + } +} + +if (isMainModule()) { + program.parse(); +} diff --git a/src/lib/client.ts b/src/lib/client.ts index 023f139..6e5a036 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -16,6 +16,7 @@ export interface ApiError { error?: { code?: string; message?: string; + details?: Record; }; } @@ -79,6 +80,8 @@ export async function apiRequest( 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'User-Agent': CLI_USER_AGENT, + // Names the CLI as the source on prospects and the activity feed. + 'X-DealMachine-Source': 'cli', }, ...(body && { body: JSON.stringify(body) }), }); @@ -91,6 +94,22 @@ export async function apiRequest( console.error(chalk.dim(` ${method} ${path} -> ${response.status}`)); if (response.status === 401) { console.error(chalk.dim(' Run `dm login` to re-authenticate.')); + } else if (response.status === 402 && error.error?.code === 'prospect_limit_reached') { + const details = error.error?.details as + | { limit?: number; used?: number; remaining?: number } + | undefined; + if (details?.limit != null) { + console.error( + chalk.dim( + ` Prospects: ${(details.used ?? 0).toLocaleString()} of ${details.limit.toLocaleString()} used.` + ) + ); + } + console.error( + chalk.dim(' Add capacity in Billing settings or remove prospects, then retry.') + ); + } else if (response.status === 402) { + console.error(chalk.dim(' Your plan does not cover this request. Check Billing settings.')); } else if (response.status === 422) { console.error(chalk.dim(' Check your request body. Use --body or -f with valid JSON.')); } else if (response.status === 404) { diff --git a/src/version.ts b/src/version.ts index 14589c7..1d20963 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const CLI_VERSION = '0.3.0'; +export const CLI_VERSION = '0.4.0-rc.1'; export const CLI_USER_AGENT = `dm-cli/${CLI_VERSION}`; diff --git a/tests/commands/account.test.ts b/tests/commands/account.test.ts new file mode 100644 index 0000000..795dc77 --- /dev/null +++ b/tests/commands/account.test.ts @@ -0,0 +1,28 @@ +import { afterEach, expect, it, vi } from 'vitest'; +const request = vi.hoisted(() => vi.fn()); +vi.mock('../../src/lib/client', () => ({ apiRequest: request, formatDate: () => 'Sep 8, 2026' })); +import { account } from '../../src/commands/account'; +afterEach(() => vi.restoreAllMocks()); + +const response = { + data: { + data_engine: 'legacy', + organization: { id: 42, name: 'QA', createdAt: '2026-09-08' }, + user: { id: 7, authType: 'api_key' }, + }, +}; +it('keeps the original default account display', async () => { + request.mockResolvedValue(response); + const output = vi.spyOn(console, 'log').mockImplementation(() => {}); + await account(); + expect(output.mock.calls.flat().join('\n')).toContain('Organization:'); + expect(output.mock.calls.flat().join('\n')).not.toContain('data_engine'); + expect(request).toHaveBeenCalledWith('/account'); +}); +it('honors the documented JSON option and exposes the server-resolved engine', async () => { + request.mockResolvedValue(response); + const output = vi.spyOn(console, 'log').mockImplementation(() => {}); + await account({ json: true }); + expect(output).toHaveBeenCalledTimes(1); + expect(JSON.parse(output.mock.calls[0][0])).toEqual(response); +}); diff --git a/tests/commands/agents.test.ts b/tests/commands/agents.test.ts index 08740db..77570aa 100644 --- a/tests/commands/agents.test.ts +++ b/tests/commands/agents.test.ts @@ -2,7 +2,12 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { agentsGuide, agentsInstallClaudeCode, agentsPermissions, agentsPlaybook } from '../../src/commands/agents'; +import { + agentsGuide, + agentsInstallClaudeCode, + agentsPermissions, + agentsPlaybook, +} from '../../src/commands/agents'; const temporaryDirectories: string[] = []; @@ -23,8 +28,13 @@ describe('agent commands', () => { expect(log).toHaveBeenCalledWith(expect.stringContaining('dm agents playbook')); expect(log).toHaveBeenCalledWith(expect.stringContaining('Credit-Safe Workflow')); - expect(log).toHaveBeenCalledWith(expect.stringContaining('A specific person by name uses `dm enrich name`')); - expect(log).toHaveBeenCalledWith(expect.stringContaining('People Search does not have a name filter')); + expect(log.mock.calls.flat().join(' ')).not.toMatch(/V2|dm query|include-companies/); + expect(log).toHaveBeenCalledWith( + expect.stringContaining('A specific person by name uses `dm enrich name`') + ); + expect(log).toHaveBeenCalledWith( + expect.stringContaining('People Search does not have a name filter') + ); }); it('prints agent guidance as JSON', async () => { @@ -51,8 +61,12 @@ describe('agent commands', () => { name: 'DealMachine Playbook', type: 'playbook', }); - expect(payload.content).toContain('DealMachine Playbook: Natural Language Property Intelligence'); - expect(payload.content).toContain('A specific name always uses person enrichment, not People Search.'); + expect(payload.content).toContain( + 'DealMachine Playbook: Natural Language Property Intelligence' + ); + expect(payload.content).toContain( + 'A specific name always uses person enrichment, not People Search.' + ); expect(payload.content).toContain('allowed-tools:'); expect(payload.content).not.toContain('Bash(dm *)'); }); diff --git a/tests/commands/filters.test.ts b/tests/commands/filters.test.ts index 7ee7bc0..ae2925d 100644 --- a/tests/commands/filters.test.ts +++ b/tests/commands/filters.test.ts @@ -57,12 +57,10 @@ describe('filter lookup guidance', () => { }); it('does not suggest name enrichment for unrelated empty filter searches', async () => { - await filters({ - sourceType: 'people', - search: 'household income', - json: true, - }); + await filters({ sourceType: 'people', search: 'household income', json: true }); - expect(mockPrintJson).toHaveBeenCalledWith(expect.not.objectContaining({ suggestion: expect.anything() })); + expect(mockPrintJson).toHaveBeenCalledWith( + expect.not.objectContaining({ suggestion: expect.anything() }) + ); }); }); diff --git a/tests/commands/publicApiExtensions.test.ts b/tests/commands/publicApiExtensions.test.ts index c9afa25..6fab6d2 100644 --- a/tests/commands/publicApiExtensions.test.ts +++ b/tests/commands/publicApiExtensions.test.ts @@ -1,18 +1,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { mockApiRequest, mockParseRequestBody, mockPrintTable } = vi.hoisted(() => ({ +const { mockApiRequest, mockParseRequestBody } = vi.hoisted(() => ({ mockApiRequest: vi.fn(), mockParseRequestBody: vi.fn().mockResolvedValue({}), - mockPrintTable: vi.fn(), })); -vi.mock('../../src/lib/client.js', () => ({ apiRequest: mockApiRequest })); +vi.mock('../../src/lib/client.js', () => ({ + apiRequest: mockApiRequest, + formatDate: () => 'Sep 24, 2026', +})); vi.mock('../../src/lib/output.js', () => ({ isQuiet: vi.fn(() => false), parseRequestBody: mockParseRequestBody, printJson: vi.fn(), printHeader: vi.fn(), - printTable: mockPrintTable, + printTable: vi.fn(), printPagination: vi.fn(), printTotals: vi.fn(), printCredits: vi.fn(), @@ -28,6 +30,7 @@ vi.mock('../../src/lib/output.js', () => ({ })), })); +import { printJson, printTable } from '../../src/lib/output.js'; import { peopleGet, peopleIds, peopleSearch } from '../../src/commands/people.js'; import { propertiesGet, propertiesIds, propertiesSearch } from '../../src/commands/properties.js'; import { @@ -39,6 +42,10 @@ import { enrichPhone, } from '../../src/commands/enrich.js'; import { addressesAutocomplete, locationsAutocomplete } from '../../src/commands/locations.js'; +import { drivingGet, drivingList } from '../../src/commands/driving.js'; +import { prospectsList } from '../../src/commands/prospects.js'; +import { listsItems } from '../../src/commands/lists.js'; +import { phonesDnc } from '../../src/commands/phones.js'; beforeEach(() => { vi.clearAllMocks(); @@ -52,6 +59,42 @@ beforeEach(() => { }); describe('CLI public API extensions', () => { + it('reads drive history and filters prospects added during drives', async () => { + mockApiRequest + .mockResolvedValueOnce({ + data: [], + pagination: { page: 1, per_page: 25, total: 0, has_more: false }, + }) + .mockResolvedValueOnce({ + data: { id: 'drive_session_501', events: [], visits: [], prospects: [] }, + }) + .mockResolvedValueOnce({ + data: [], + pagination: { page: 1, per_page: 25, total: 0, has_more: false }, + }); + + await drivingList({ mode: 'free_drive', page: '2', perPage: '10', json: true }); + expect(mockApiRequest).toHaveBeenCalledWith('/driving/drives', { + query: { + driver_user_id: undefined, + mode: 'free_drive', + started_after: undefined, + started_before: undefined, + page: 2, + per_page: 10, + }, + }); + + await drivingGet('drive_session_501', { json: true }); + expect(mockApiRequest).toHaveBeenCalledWith('/driving/drives/drive_session_501'); + + await prospectsList({ source: 'driving', json: true }); + expect(mockApiRequest).toHaveBeenCalledWith( + '/prospects', + expect.objectContaining({ query: expect.objectContaining({ source: 'driving' }) }) + ); + }); + it('passes contact_audience=none to single and batch property lookups', async () => { mockApiRequest.mockResolvedValue({ data: { dm_property_id: 'prop_123' }, @@ -63,11 +106,7 @@ describe('CLI public API extensions', () => { query: { contact_audience: 'none' }, }); - await propertiesIds({ - ids: ['prop_123'], - contactAudience: 'none', - json: true, - }); + await propertiesIds({ ids: ['prop_123'], contactAudience: 'none', json: true }); expect(mockApiRequest).toHaveBeenCalledWith('/properties/ids', { method: 'POST', body: { ids: ['prop_123'], contact_audience: 'none' }, @@ -79,6 +118,7 @@ describe('CLI public API extensions', () => { contactAudience: 'none', json: true, }); + expect(mockApiRequest).toHaveBeenCalledWith('/enrichment/address', { method: 'POST', body: { @@ -106,33 +146,25 @@ describe('CLI public API extensions', () => { }); }); - it('passes fields and property_limit to a single person lookup', async () => { + it('passes fields to a single person lookup', async () => { mockApiRequest.mockResolvedValue({ data: { dm_person_id: 'per_123' }, credits: { used: 1, properties: 0, people: 1, deduplicated: 0 }, }); await peopleGet('per_123', { - includeProperties: true, - propertyLimit: '20', fields: 'estimated_household_income,estimated_value', json: true, }); expect(mockApiRequest).toHaveBeenCalledWith('/people/per_123', { - query: { - include_properties: 'true', - property_limit: '20', - fields: 'estimated_household_income,estimated_value', - }, + query: { fields: 'estimated_household_income,estimated_value' }, }); }); - it('passes fields and property_limit to batch person lookup', async () => { + it('passes fields as an array to batch person lookup', async () => { await peopleIds({ ids: ['per_123', 'per_456'], - includeProperties: true, - propertyLimit: '15', fields: 'estimated_household_income, estimated_value', json: true, }); @@ -141,47 +173,11 @@ describe('CLI public API extensions', () => { method: 'POST', body: { ids: ['per_123', 'per_456'], - include_properties: true, - property_limit: 15, fields: ['estimated_household_income', 'estimated_value'], }, }); }); - it('passes fields to a single property lookup', async () => { - mockApiRequest.mockResolvedValue({ - data: { dm_property_id: 'prop_123' }, - credits: { used: 1, properties: 1, people: 0, deduplicated: 0 }, - }); - - await propertiesGet('prop_123', { - fields: 'estimated_value,equity', - json: true, - }); - - expect(mockApiRequest).toHaveBeenCalledWith('/properties/prop_123', { - query: { fields: 'estimated_value,equity' }, - }); - }); - - it.each([ - ['address', enrichAddress, '123 Main St, Austin, TX 78704', '/enrichment/address'], - ['coordinates', enrichLatLng, '30.25,-97.75', '/enrichment/reverse-geocode'], - ['APN', enrichApn, '0123-456-789', '/enrichment/apn'], - ['email', enrichEmail, 'jane@example.com', '/enrichment/email'], - ['phone', enrichPhone, '5125551234', '/enrichment/phone'], - ['name', enrichName, 'Jane Owner', '/enrichment/name'], - ] as const)('passes selected fields to %s enrichment', async (_label, command, value, path) => { - await command(value, { fields: 'estimated_value, equity', json: true }); - - expect(mockApiRequest).toHaveBeenCalledWith(path, { - method: 'POST', - body: expect.objectContaining({ - fields: ['estimated_value', 'equity'], - }), - }); - }); - it.each([ ['email', enrichEmail, 'jane@example.com', '/enrichment/email'], ['phone', enrichPhone, '5125551234', '/enrichment/phone'], @@ -192,9 +188,7 @@ describe('CLI public API extensions', () => { path, expect.objectContaining({ method: 'POST', - body: expect.objectContaining({ - location: { type: 'city', code: '53584' }, - }), + body: expect.objectContaining({ location: { type: 'city', code: '53584' } }), }) ); }); @@ -204,9 +198,7 @@ describe('CLI public API extensions', () => { expect(mockApiRequest).toHaveBeenCalledWith('/enrichment/name', { method: 'POST', - body: expect.objectContaining({ - location: { type: 'city', code: '53584' }, - }), + body: expect.objectContaining({ location: { type: 'city', code: '53584' } }), }); }); @@ -303,13 +295,7 @@ describe('CLI public API extensions', () => { it('passes autocomplete controls to the addresses endpoint', async () => { mockApiRequest.mockResolvedValue({ data: [], - meta: { - query: '1200 Barton', - scope: 'all', - limit: 5, - returned: 0, - partial_results: false, - }, + meta: { query: '1200 Barton', limit: 5, returned: 0, partial_results: false }, }); await addressesAutocomplete({ @@ -337,66 +323,251 @@ describe('CLI public API extensions', () => { it('keeps the locations autocomplete command as a compatibility alias', async () => { mockApiRequest.mockResolvedValue({ data: [], - meta: { - query: 'Austin', - scope: 'location', - limit: 5, - returned: 0, - partial_results: false, - }, + meta: { query: '1200 Barton', limit: 5, returned: 0, partial_results: false }, }); - await locationsAutocomplete({ - query: 'Austin', - scope: 'location', - json: true, - }); + await locationsAutocomplete({ query: 'Austin', scope: 'location', json: true }); expect(mockApiRequest).toHaveBeenCalledWith('/addresses/autocomplete', { query: { q: 'Austin', scope: 'location' }, }); }); + it('renders legacy location suggestions alongside property address suggestions', async () => { + mockApiRequest.mockResolvedValue({ + data: [ + { + suggestion_id: 'loc_1', + kind: 'location', + label: 'Austin, TX', + location: { location_id: 'loc_city_1', state: 'TX' }, + }, + { + suggestion_id: 'addr_1', + kind: 'address', + label: '123 Main St', + property_id: 'prop_123', + address: { city: 'Austin', state: 'TX', zip: '78701' }, + }, + ], + meta: { query: 'Austin', scope: 'all', partial_results: false }, + }); + + await addressesAutocomplete({ query: 'Austin', scope: 'all' }); + + expect(printTable).toHaveBeenCalledWith( + [ + expect.objectContaining({ + kind: 'location', + location_id: 'loc_city_1', + state: 'TX', + }), + expect.objectContaining({ + kind: 'address', + property_id: 'prop_123', + city: 'Austin', + zip: '78701', + }), + ], + expect.arrayContaining(['kind', 'location_id', 'property_id']) + ); + }); - it('shows the free property_count in name enrichment output', async () => { + it('keeps the legacy autocomplete columns when the response has no property IDs', async () => { mockApiRequest.mockResolvedValue({ - data: [{ dm_person_id: 'per_123', full_name: 'Jane Owner', property_count: 7 }], - pagination: { page: 1, per_page: 25, total: 1, total_pages: 1 }, - credits: { used: 1, properties: 0, people: 1, deduplicated: 0 }, + data: [ + { + suggestion_id: 'addr_1', + kind: 'address', + label: '123 Main St', + address: { city: 'Austin', state: 'TX' }, + }, + ], + meta: { query: '123 Main', scope: 'address', partial_results: false }, }); - await enrichName('Jane Owner', { yes: true }); + await addressesAutocomplete({ query: '123 Main', scope: 'address' }); - expect(mockPrintTable).toHaveBeenCalledWith( - [expect.objectContaining({ properties: '7' })], - ['id', 'name', 'phones', 'emails', 'properties'] + expect(printTable).toHaveBeenCalledWith( + [ + { + kind: 'address', + label: '123 Main St', + location_id: '-', + city: 'Austin', + state: 'TX', + zip: '-', + }, + ], + ['kind', 'label', 'location_id', 'city', 'state', 'zip'] ); }); - it('shows the free property_count in email and phone enrichment output', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + it('shows the free property count before falling back to included records in name enrichment', async () => { mockApiRequest.mockResolvedValue({ data: [ { - matched: true, - input: { email: 'jane@example.com' }, - contacts: [ - { - dm_person_id: 'per_123', - full_name: 'Jane Owner', - property_count: 7, - }, - ], + dm_person_id: 'per_123', + full_name: 'Jane Owner', + property_count: 7, + properties: [{}, {}], + }, + { dm_person_id: 'per_124', full_name: 'Zero Owner', property_count: 0 }, + { + dm_person_id: 'per_125', + full_name: 'Legacy Owner', + properties: [{}], }, ], - totals: { submitted: 1, matched: 1, unmatched: 0 }, - credits: { used: 1, properties: 0, people: 1, deduplicated: 0 }, + pagination: { page: 1, per_page: 25, total: 3, total_pages: 1 }, + credits: { used: 3, properties: 0, people: 3, deduplicated: 0 }, }); - await enrichEmail('jane@example.com', {}); + await enrichName('Jane Owner', { yes: true }); - const rendered = consoleSpy.mock.calls.flat().join('\n'); - expect(rendered).toContain('properties:'); - expect(rendered).toContain('7'); - consoleSpy.mockRestore(); + expect(printTable).toHaveBeenCalledWith( + [ + expect.objectContaining({ properties: '7' }), + expect.objectContaining({ properties: '0' }), + expect.objectContaining({ properties: '1' }), + ], + ['id', 'name', 'phones', 'emails', 'properties'] + ); }); + + it.each([ + ['email', enrichEmail, 'jane@example.com'], + ['phone', enrichPhone, '5125551234'], + ] as const)( + 'keeps the free property count in %s enrichment output', + async (_name, command, value) => { + const output = vi.spyOn(console, 'log').mockImplementation(() => {}); + mockApiRequest.mockResolvedValue({ + data: [ + { + matched: true, + input: { value }, + contacts: [ + { + dm_person_id: 'per_123', + full_name: 'Jane Owner', + property_count: 7, + }, + ], + }, + ], + totals: { submitted: 1, matched: 1, unmatched: 0 }, + credits: { used: 1, properties: 0, people: 1, deduplicated: 0 }, + }); + try { + await command(value, {}); + expect(output.mock.calls.flat().join('\n')).toContain('properties: 7'); + } finally { + output.mockRestore(); + } + } + ); + + it('keeps numeric list person IDs and uses opaque IDs only when the numeric ID is unavailable', async () => { + mockApiRequest.mockResolvedValue({ + data: [ + { list_item_id: 'item_1', internal_property_id: null, internal_person_id: 123, dm_person_id: 'per_123' }, + { list_item_id: 'item_2', internal_property_id: null, internal_person_id: null, dm_person_id: 'per_9007199254740993' }, + ], + pagination: { page: 1, total: 2, has_more: false }, + }); + + await listsItems('list_1', {}); + + expect(printTable).toHaveBeenCalledWith([ + expect.objectContaining({ person_id: 123 }), + expect.objectContaining({ person_id: 'per_9007199254740993' }), + ], ['item_id', 'property_id', 'person_id', 'added']); + }); + +}); + +describe('CLI phone carrier (DEA-2144)', () => { + const phones = [ + { number: '5125551234', type: 'wireless', do_not_call: false, carrier: 'AT&T Mobility' }, + { number: '5125559876', type: 'landline', do_not_call: true, carrier: null }, + ]; + const personResponse = { + data: { dm_person_id: 'per_123', full_name: 'JANE DOE', phones, emails: [] }, + credits: { used: 1, properties: 0, people: 1, deduplicated: 0 }, + }; + const enrichResponse = { + data: [ + { + input: { phone: '5125551234' }, + matched: true, + contacts: [{ dm_person_id: 'per_123', full_name: 'JANE DOE', phones, emails: [] }], + }, + ], + totals: { submitted: 1, matched: 1, unmatched: 0 }, + credits: { used: 1, properties: 0, people: 1, deduplicated: 0 }, + }; + + function captureLog(): () => string { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + return () => { + const printed = log.mock.calls.map((call) => call.join(' ')).join('\n'); + log.mockRestore(); + return printed; + }; + } + + it('hands --json output the API response untouched, carrier included', async () => { + mockApiRequest.mockResolvedValue(personResponse); + await peopleGet('per_123', { json: true }); + expect(printJson).toHaveBeenLastCalledWith(personResponse); + + mockApiRequest.mockResolvedValue(enrichResponse); + await enrichPhone('5125551234', { json: true }); + expect(printJson).toHaveBeenLastCalledWith(enrichResponse); + }); + + it.each([ + ['people get', peopleGet], + ])('%s keeps the existing phone display when carrier metadata is returned', async (_name, get) => { + mockApiRequest.mockResolvedValue(personResponse); + const printed = captureLog(); + await get('per_123', {}); + const output = printed(); + + expect(output).toContain('5125551234 (wireless)'); + expect(output).not.toContain('AT&T Mobility'); + // An unknown carrier prints nothing rather than "null". + expect(output).toContain('5125559876 (landline)'); + expect(output).not.toContain('null'); + }); + + it('enrich keeps the existing nested contact phone display', async () => { + mockApiRequest.mockResolvedValue(enrichResponse); + const printed = captureLog(); + await enrichPhone('5125551234', {}); + const output = printed(); + + expect(output).toContain('phone: 5125551234'); + expect(output).toContain('phone: 5125559876'); + expect(output).not.toContain('wireless'); + expect(output).not.toContain('AT&T Mobility'); + }); + + it('preserves DNC text formatting and keeps carrier metadata available in JSON', async () => { + const response = { + data: [{ input: { number: '5125551234' }, matched: true, do_not_call: false, phone_type: 'wireless', carrier: 'AT&T Mobility' }], + totals: { submitted: 1, matched: 1, unmatched: 0 }, + credits: { used: 1 }, + }; + mockApiRequest.mockResolvedValue(response); + await phonesDnc('5125551234', { json: true }); + expect(printJson).toHaveBeenLastCalledWith(response); + + const printed = captureLog(); + await phonesDnc('5125551234', {}); + const output = printed(); + expect(output).toContain('5125551234 OK (wireless)'); + expect(output).not.toContain('AT&T Mobility'); + }); + }); diff --git a/tests/entrypoint.test.ts b/tests/entrypoint.test.ts new file mode 100644 index 0000000..b77066a --- /dev/null +++ b/tests/entrypoint.test.ts @@ -0,0 +1,22 @@ +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { expect, it } from 'vitest'; + +it('imports the public program when the caller entry file is missing', () => { + const entry = new URL('../src/index.ts', import.meta.url).href; + const missingCaller = fileURLToPath(new URL('./missing-caller.mjs', import.meta.url)); + const output = execFileSync( + process.execPath, + [ + '--import', + 'tsx', + '--input-type=module', + '--eval', + `process.argv[1] = ${JSON.stringify(missingCaller)}; + const { program } = await import(${JSON.stringify(entry)}); + console.log(program.name());`, + ], + { encoding: 'utf8', timeout: 20_000 } + ); + expect(output.trim()).toBe('dm'); +}); diff --git a/tests/lib/api.test.ts b/tests/lib/api.test.ts index 72291f4..a5d21a2 100644 --- a/tests/lib/api.test.ts +++ b/tests/lib/api.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import manifest from '../../package.json'; import { verifyCredentials } from '../../src/lib/api'; describe('CLI API helpers', () => { @@ -35,7 +36,7 @@ describe('CLI API helpers', () => { method: 'GET', headers: { 'Authorization': 'Bearer dm_sk_live_test', - 'User-Agent': 'dm-cli/0.3.0', + 'User-Agent': `dm-cli/${manifest.version}`, }, }); }); diff --git a/tests/lib/client.test.ts b/tests/lib/client.test.ts new file mode 100644 index 0000000..f207db0 --- /dev/null +++ b/tests/lib/client.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +const readConfig = vi.hoisted(() => vi.fn()); +vi.mock('../../src/lib/config.js', () => ({ readConfig })); +import { apiRequest } from '../../src/lib/client.js'; + +beforeEach(() => { + vi.stubEnv('DM_API_URL', 'https://api.example.test/v1'); + vi.stubEnv('DM_API_KEY', 'unrelated-environment-key'); +}); +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +it('sends the stored login credential even when an unrelated environment key is present', async () => { + readConfig.mockReturnValue({ apiKey: 'stored-account-key' }); + const fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: {} }) }); + vi.stubGlobal('fetch', fetch); + + await apiRequest('/account'); + + expect(fetch).toHaveBeenCalledWith( + 'https://api.example.test/v1/account', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer stored-account-key', + }), + }) + ); +}); + +it('requires login after stored credentials are removed even if the environment key remains', async () => { + readConfig.mockReturnValue(null); + const fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: {} }) }); + vi.stubGlobal('fetch', fetch); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + + await expect(apiRequest('/account')).rejects.toThrow('exit'); + expect(process.exit).toHaveBeenCalledWith(1); + expect(fetch).not.toHaveBeenCalled(); +}); diff --git a/tsconfig.json b/tsconfig.json index 6bc6641..ca4588b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,9 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "lib": ["ES2022"], + "lib": [ + "ES2022" + ], "outDir": "./dist", "rootDir": "./src", "strict": true, @@ -13,9 +15,15 @@ "forceConsistentCasingInFileNames": true, "declaration": true, "declarationMap": true, - "sourceMap": true + "sourceMap": true, + "inlineSources": true }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"], + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ], "references": [] } diff --git a/vitest.config.ts b/vitest.config.ts index 8e5a826..09b63d3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ globals: true, clearMocks: true, environment: 'node', - include: ['tests/**/*.{test,spec}.{ts,tsx}', 'packages/cli/tests/**/*.{test,spec}.{ts,tsx}'], + include: ['tests/**/*.{test,spec}.{ts,tsx}'], testTimeout: 10000, }, resolve: {