@myapihq/cli 2.24.0 → 2.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -97,9 +97,10 @@ async function get(id, flags) {
97
97
  }
98
98
  async function update(id, flags) {
99
99
  const config = requireConfig();
100
- const orgId = requireOrg(flags, config, 'myapi crm companies update <id> [--stage <s>] [--name <n>] [--custom-json <json>] [--org <id>]');
100
+ const orgId = requireOrg(flags, config, 'myapi crm companies update <id> [--domain <d>] [--stage <s>] [--name <n>] [--custom-json <json>] [--org <id>]');
101
101
  requireArg(id, 'id', 'myapi crm companies update <id>');
102
102
  const patch = {
103
+ domain: typeof flags.domain === 'string' ? flags.domain : undefined,
103
104
  name: typeof flags.name === 'string' ? flags.name : undefined,
104
105
  lifecycle_stage: typeof flags.stage === 'string' ? flags.stage : undefined,
105
106
  custom: parseCustom(flags['custom-json']),
@@ -111,9 +111,10 @@ async function get(id, flags) {
111
111
  }
112
112
  async function update(id, flags) {
113
113
  const config = requireConfig();
114
- const orgId = requireOrg(flags, config, 'myapi crm contacts update <id> [--stage <s>] [--first-name <n>] [--last-name <n>] [--company-id <id>] [--custom-json <json>] [--org <id>]');
114
+ const orgId = requireOrg(flags, config, 'myapi crm contacts update <id> [--email <a>] [--stage <s>] [--first-name <n>] [--last-name <n>] [--company-id <id>] [--custom-json <json>] [--org <id>]');
115
115
  requireArg(id, 'id', 'myapi crm contacts update <id>');
116
116
  const patch = {
117
+ email: typeof flags.email === 'string' ? flags.email : undefined,
117
118
  first_name: typeof flags['first-name'] === 'string' ? flags['first-name'] : undefined,
118
119
  last_name: typeof flags['last-name'] === 'string' ? flags['last-name'] : undefined,
119
120
  company_id: typeof flags['company-id'] === 'string' ? flags['company-id'] : undefined,
@@ -125,6 +126,14 @@ async function update(id, flags) {
125
126
  printJson(c);
126
127
  return;
127
128
  }
129
+ // The API returns the row it re-read, so a change that did not take is
130
+ // visible here. Email is the one that can collide with another contact, and
131
+ // reporting "updated" for a write that did not happen is worse than the
132
+ // failure itself.
133
+ if (patch.email && c.email?.toLowerCase() !== patch.email.trim().toLowerCase()) {
134
+ error(`The email was NOT changed — it is still ${c.email}.\n` +
135
+ `Another contact in this org most likely already uses ${patch.email}.`);
136
+ }
128
137
  success(`Contact ${c.id} updated (stage=${c.lifecycle_stage})`);
129
138
  }
130
139
  async function del(id, flags) {
@@ -40,7 +40,7 @@ const sdk = vi.hoisted(() => ({
40
40
  promoteRevision: vi.fn(),
41
41
  createContainer: vi.fn(),
42
42
  },
43
- crm: { searchContacts: vi.fn(), searchCompanies: vi.fn() },
43
+ crm: { searchContacts: vi.fn(), searchCompanies: vi.fn(), updateContact: vi.fn(), updateCompany: vi.fn() },
44
44
  fn: { listFunctions: vi.fn() },
45
45
  // Top-level SDK exports the handlers reach for. Mocking the module
46
46
  // wholesale drops anything not listed, and the failure reads as a missing
@@ -295,3 +295,40 @@ describe('git commit — authorship must not silently default', () => {
295
295
  expect(sdk.git.commit.mock.calls[0][3].author).toBeUndefined();
296
296
  });
297
297
  });
298
+ // A CRM contact's email is its identity: the field most likely to be wrong
299
+ // after an import and the one that changes when someone switches employer.
300
+ // `--email` was declared in the crm flag schema (create and the filters use
301
+ // it), so it parsed on `contacts update` and was then dropped on the floor —
302
+ // the patch object simply never read it. The only way to correct an address
303
+ // was to delete the contact and create another, losing the timeline, the
304
+ // campaign history and the audience it was promoted from.
305
+ //
306
+ // Found by asking the reverse of the usual question: not "does the API accept
307
+ // everything we send", but "do we send everything the API accepts".
308
+ describe('crm update — identity fields must REACH the patch', () => {
309
+ const CONTACT = { id: 'ct1', email: 'new@example.com', lifecycle_stage: 'lead' };
310
+ const COMPANY = { id: 'co1', domain: 'new.example.com', lifecycle_stage: 'lead' };
311
+ it('sends --email on contacts update', async () => {
312
+ sdk.crm.updateContact.mockResolvedValue(CONTACT);
313
+ const { run: crmRun } = await import('./crm/index.js');
314
+ await run(() => crmRun('contacts', ['update', 'ct1'], { email: 'new@example.com', org: ORG }));
315
+ expect(sdk.crm.updateContact).toHaveBeenCalledWith('hq_live_test', ORG, 'ct1', expect.objectContaining({ email: 'new@example.com' }));
316
+ });
317
+ it('sends --domain on companies update', async () => {
318
+ sdk.crm.updateCompany.mockResolvedValue(COMPANY);
319
+ const { run: crmRun } = await import('./crm/index.js');
320
+ await run(() => crmRun('companies', ['update', 'co1'], { domain: 'new.example.com', org: ORG }));
321
+ expect(sdk.crm.updateCompany).toHaveBeenCalledWith('hq_live_test', ORG, 'co1', expect.objectContaining({ domain: 'new.example.com' }));
322
+ });
323
+ // The backend applies each field with its own UPDATE and does not check the
324
+ // result, so an email colliding with another contact leaves the row
325
+ // unchanged and still answers 200. It re-reads the row before replying, so
326
+ // the truth is in the response — and reporting "updated" for a write that
327
+ // did not happen is worse than the failure.
328
+ it('refuses to report success when the email did not actually change', async () => {
329
+ sdk.crm.updateContact.mockResolvedValue({ ...CONTACT, email: 'old@example.com' });
330
+ const { run: crmRun } = await import('./crm/index.js');
331
+ await run(() => crmRun('contacts', ['update', 'ct1'], { email: 'new@example.com', org: ORG }));
332
+ expect(exitError).toMatch(/NOT changed/);
333
+ });
334
+ });
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  name: my-crm-api
3
- version: 1.2.0
3
+ version: 1.2.1
4
4
  description: >
5
5
  The canonical store of engaged contacts + companies for an org. Auto-ingests from inbound webhooks via a configurable dot-path. Fixed lifecycle_stage enum (cold | warm | qualified | customer | churned). Append-only event timeline with reserved kinds. Soft delete + restore. Promote-from-Goldfox closes the discovery → engagement loop.
6
6
  triggers: [crm, contact, company, lead, engagement, pipeline, lifecycle, qualified, customer, webhook ingest, promote]
7
- checksum: sha256-fab860f3f7592640ab15b4f91670cba8217330617d7c9e96dd8e9e5f76abb657
7
+ checksum: sha256-8bc061c79940045d1f95ec4104a452c83342f9ebe2be3c0e087b4c4953524ce6
8
8
  ---
9
9
 
10
10
  # MyCRMAPI
11
11
 
12
- The store that closes the funnel. Without CRM the loop ends nowhere: discover people (Goldfox) → audience → email → pixel → form-fill via webhook → nothing. People who *engage* live nowhere. CRM is where they land.
12
+ The store that closes the funnel: discover people (Goldfox) → audience → email → pixel → form-fill via webhook → and then nowhere. CRM is where people who *engage* land.
13
13
 
14
14
  ## Capabilities
15
15
  <!-- llm:start -->
@@ -32,6 +32,10 @@ cold | warm | qualified | customer | churned
32
32
 
33
33
  Move stage with `myapi crm contacts update <id> --stage qualified`. Every stage change emits a `stage_changed` event with `{from, to}` so the timeline shows the journey.
34
34
 
35
+ Correct an address with `--email` (companies: `--domain`) rather than recreating
36
+ the record — a new contact has no timeline, no campaign history and no link to
37
+ its source audience. A collision with another contact is refused.
38
+
35
39
  ### Contact sources (fixed enum)
36
40
 
37
41
  ```
@@ -54,7 +58,7 @@ Agents cannot write events directly — the enum is closed on purpose. For custo
54
58
 
55
59
  ### Auto-ingest
56
60
 
57
- - **Webhook** (live): set per endpoint via `crm_email_path`, a JSON dot-path. Default `"email"` ingests `{"email":"x@y.com"}`. For Stripe, set `data.object.customer_email`; for GitHub, `sender.email`. Empty string disables ingest.
61
+ - **Webhook** (live): set per endpoint via `crm_email_path`, a JSON dot-path. Default `"email"`. Stripe: `data.object.customer_email`; GitHub: `sender.email`. Empty string disables ingest.
58
62
 
59
63
  Coming next:
60
64
  - **Email**: every `myapi email message send` writes `email_sent`; opens/clicks fire `email_opened`/`email_clicked`
@@ -66,7 +70,7 @@ An unknown email auto-creates the contact with `source=` the originating service
66
70
 
67
71
  ### Soft delete + restore
68
72
 
69
- `myapi crm contacts delete <id>` sets `deleted_at` but **keeps the event timeline**. By default soft-deleted contacts are excluded from search pass `--include-deleted` to see them. Restore with `myapi crm contacts restore <id>`.
73
+ `myapi crm contacts delete <id>` sets `deleted_at` but **keeps the event timeline**. Soft-deleted contacts are excluded from search unless you pass `--include-deleted`. Restore with `myapi crm contacts restore <id>`.
70
74
 
71
75
  ### Goldfox enrichment (deferred)
72
76
 
@@ -94,7 +98,7 @@ A promoted contact carries a `goldfox_person_id`; the embedded `goldfox_person`
94
98
  | `myapi crm contacts search [--stage ...] [--origin ...] [--email ...] [--min/max-last-engagement-days N]` | Filter contacts |
95
99
  | `myapi crm contacts create <email> [--first-name ...] [--last-name ...] [--stage ...] [--custom-json ...]` | Manually create (source='manual') |
96
100
  | `myapi crm contacts get <id>` | Fetch one contact (with embedded Goldfox enrichment when available) |
97
- | `myapi crm contacts update <id> [--stage ...] [...]` | Patch fields. Stage change emits `stage_changed` event |
101
+ | `myapi crm contacts update <id> [--email <a>] [--stage ...] [...]` | Patch fields, `--email` included. Stage change emits `stage_changed` event |
98
102
  | `myapi crm contacts delete <id>` | Soft delete (events retained) |
99
103
  | `myapi crm contacts restore <id>` | Restore a soft-deleted contact |
100
104
  | `myapi crm contacts promote <goldfox_person_id>` | Idempotent Goldfox → CRM promote |
@@ -132,9 +136,11 @@ myapi crm contacts create alice@acme.com \
132
136
  --custom-json '{"intro_via":"riccardo","topic":"video editing"}'
133
137
 
134
138
  # Update stage as the deal progresses — emits a stage_changed event
135
- myapi crm contacts update <id> --stage qualified
136
139
  myapi crm contacts update <id> --stage customer
137
140
 
141
+ # Fix a mistyped address without losing the timeline
142
+ myapi crm contacts update <id> --email alice@acme.com
143
+
138
144
  # See the full engagement timeline
139
145
  myapi crm contacts events <id>
140
146
 
@@ -159,14 +165,13 @@ myapi crm contacts events <id> --kind webhook_received
159
165
 
160
166
  ## Notes
161
167
 
162
- - **Paginate with `--limit` + `--offset`.** `total` is the true match count
163
- and the response carries `has_more`; branch on `has_more` rather than doing
164
- arithmetic against `total`. (Both were broken until 2026-07-28. Cached
165
- guidance saying the CRM cannot paginate is stale.)
166
- - **Reserved event kinds no custom events in v1.** If an agent needs custom state per contact, use `myapi database` keyed by contact id. The curated timeline stays the authoritative engagement record.
167
- - **`external_id` on an event payload** is the backend's idempotency key — a duplicate of the action's natural id (`goldfox_person_id`, `delivery_id`). Read the semantic field instead; legacy `message_id` rows hold the same value.
168
+ - **Paginate with `--limit` + `--offset`.** Branch on `has_more`, not on
169
+ arithmetic against `total`. (Cached guidance saying CRM cannot paginate is
170
+ stale fixed 2026-07-28.)
171
+ - **Reserved event kinds — no custom events in v1.** For custom per-contact state use `myapi database` keyed by contact id; the curated timeline stays authoritative.
172
+ - **`external_id` on an event payload** is the backend's idempotency key, duplicating the action's natural id (`goldfox_person_id`, `delivery_id`). Read the semantic field instead.
168
173
  - **Only `webhook_received` fires today.** Email and pixel auto-ingest are coming; the CLI surface will not change.
169
- - **Free in v1.** Metered later if usage warrants.
174
+ - **Free in v1.** Metered later if usage warrants it.
170
175
 
171
176
  ## HTTP (from deployed code)
172
177
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@myapihq/cli",
3
3
  "license": "Apache-2.0",
4
- "version": "2.24.0",
4
+ "version": "2.25.0",
5
5
  "description": "MyAPI command-line interface",
6
6
  "repository": {
7
7
  "type": "git",
@@ -46,7 +46,7 @@
46
46
  "lint:skills:strict": "node scripts/copy-skills.js && node scripts/lint-skills.js --strict"
47
47
  },
48
48
  "dependencies": {
49
- "@myapihq/sdk": "^2.24.0"
49
+ "@myapihq/sdk": "^2.25.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^25.6.0",