@myapihq/cli 2.23.3 → 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.
- package/dist/commands/container-patch-coverage.test.d.ts +1 -0
- package/dist/commands/container-patch-coverage.test.js +111 -0
- package/dist/commands/container.d.ts +2 -0
- package/dist/commands/container.js +65 -9
- package/dist/commands/crm/companies.js +2 -1
- package/dist/commands/crm/contacts.js +10 -1
- package/dist/commands/flag-reachability.test.js +38 -1
- package/dist/skills/my-container-api/SKILL.md +10 -10
- package/dist/skills/my-crm-api/SKILL.md +19 -14
- package/package.json +2 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Every field PATCH accepts must be one a CLI verb can actually send.
|
|
2
|
+
//
|
|
3
|
+
// A container setting that can be given at creation and not changed afterwards
|
|
4
|
+
// is not a missing convenience — it is a delete-and-recreate, and recreating a
|
|
5
|
+
// container loses its URL, its scoped key and its custom domain binding.
|
|
6
|
+
//
|
|
7
|
+
// That has now happened twice. `env` was create-only, and a customer recreated
|
|
8
|
+
// containers three times in a week to edit variables. Then `health_check` was
|
|
9
|
+
// create-only, and a customer paid for a production swap to adopt a probe.
|
|
10
|
+
// Both times the backend widened PATCH and the CLI did not follow, so the fix
|
|
11
|
+
// reached nobody: the route accepted a field that no client ever sent, and
|
|
12
|
+
// nothing on either side was red.
|
|
13
|
+
//
|
|
14
|
+
// So this is driven from the schema snapshot rather than a hand-kept list, and
|
|
15
|
+
// it asserts on what arrives at the SDK boundary rather than on the shape of
|
|
16
|
+
// our source — testing the parser proves the flag parses, not that it is sent.
|
|
17
|
+
// When the backend adds a field to this PATCH body, this fails until a verb
|
|
18
|
+
// can send it.
|
|
19
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
const ORG = '11111111-1111-4111-8111-111111111111';
|
|
22
|
+
const sdk = vi.hoisted(() => ({
|
|
23
|
+
container: {
|
|
24
|
+
updateContainer: vi.fn(),
|
|
25
|
+
updateContainerEnv: vi.fn(),
|
|
26
|
+
},
|
|
27
|
+
withFundsRetry: vi.fn(async (f) => f()),
|
|
28
|
+
MyApiError: class MyApiError extends Error {
|
|
29
|
+
code = '';
|
|
30
|
+
status = 0;
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
vi.mock('@myapihq/sdk', () => sdk);
|
|
34
|
+
vi.mock('../config.js', () => ({
|
|
35
|
+
requireConfig: () => ({ api_key: 'hq_live_test', default_org: ORG }),
|
|
36
|
+
loadConfig: () => ({ api_key: 'hq_live_test', default_org: ORG }),
|
|
37
|
+
CONFIG_DIR: '/tmp/nowhere',
|
|
38
|
+
}));
|
|
39
|
+
const SNAPSHOT = JSON.parse(readFileSync(new URL('../../schema-snapshot.json', import.meta.url), 'utf8'));
|
|
40
|
+
const PATCH_ROUTE = '/container/orgs/{org_id}/containers/{id}';
|
|
41
|
+
function patchBodyFields() {
|
|
42
|
+
const op = SNAPSHOT.paths?.[PATCH_ROUTE]?.patch;
|
|
43
|
+
const props = op?.requestBody?.content?.['application/json']?.schema?.properties;
|
|
44
|
+
return props ? Object.keys(props) : [];
|
|
45
|
+
}
|
|
46
|
+
const APPLIED = { set: [], removed: [], applied: true, env: {}, revision: 'rev-2' };
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
vi.clearAllMocks();
|
|
49
|
+
sdk.container.updateContainer.mockResolvedValue(APPLIED);
|
|
50
|
+
sdk.container.updateContainerEnv.mockResolvedValue(APPLIED);
|
|
51
|
+
const output = await import('../output.js');
|
|
52
|
+
vi.spyOn(output, 'error').mockImplementation(((m) => {
|
|
53
|
+
throw new Error('__EXIT__');
|
|
54
|
+
}));
|
|
55
|
+
vi.spyOn(output, 'info').mockImplementation(() => { });
|
|
56
|
+
vi.spyOn(output, 'success').mockImplementation(() => { });
|
|
57
|
+
vi.spyOn(output, 'printJson').mockImplementation(() => { });
|
|
58
|
+
});
|
|
59
|
+
afterEach(() => vi.restoreAllMocks());
|
|
60
|
+
async function run(fn) {
|
|
61
|
+
try {
|
|
62
|
+
await fn();
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
if (e?.message !== '__EXIT__')
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Drives each verb that patches a container and collects the body keys that
|
|
70
|
+
// actually reach the SDK.
|
|
71
|
+
async function fieldsTheCliCanSend() {
|
|
72
|
+
const container = await import('./container.js');
|
|
73
|
+
const sent = new Set();
|
|
74
|
+
await run(() => container.run('env', ['c1'], { env: 'A=1', org: ORG }));
|
|
75
|
+
await run(() => container.run('health-check', ['c1', '/livez'], { org: ORG }));
|
|
76
|
+
for (const call of sdk.container.updateContainer.mock.calls) {
|
|
77
|
+
Object.keys(call[3] ?? {}).forEach(k => sent.add(k));
|
|
78
|
+
}
|
|
79
|
+
// The env shorthand takes the map itself rather than a patch object.
|
|
80
|
+
if (sdk.container.updateContainerEnv.mock.calls.length)
|
|
81
|
+
sent.add('env');
|
|
82
|
+
return sent;
|
|
83
|
+
}
|
|
84
|
+
describe('container PATCH coverage', () => {
|
|
85
|
+
it('the snapshot still describes this route (or the test measures nothing)', () => {
|
|
86
|
+
// Without this, a renamed route upstream would leave an empty field list
|
|
87
|
+
// and a permanently, silently green test.
|
|
88
|
+
expect(patchBodyFields().length).toBeGreaterThan(0);
|
|
89
|
+
});
|
|
90
|
+
it('every field PATCH accepts is one a CLI verb actually sends', async () => {
|
|
91
|
+
const sent = await fieldsTheCliCanSend();
|
|
92
|
+
const unreachable = patchBodyFields().filter(f => !sent.has(f));
|
|
93
|
+
expect(unreachable, `PATCH ${PATCH_ROUTE} accepts ${unreachable.join(', ')}, but no CLI ` +
|
|
94
|
+
'verb sends it. Until one does, changing that setting means deleting the ' +
|
|
95
|
+
'container and creating another — which loses the URL, the scoped key and ' +
|
|
96
|
+
'the custom domain.').toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
it('clearing the probe sends the empty string the API defines for it', async () => {
|
|
99
|
+
// '' is how the API says "no probe". The verb spells it --clear so nobody
|
|
100
|
+
// has to discover that an empty string is the meaningful value, but the
|
|
101
|
+
// wire form still has to be right.
|
|
102
|
+
const container = await import('./container.js');
|
|
103
|
+
await run(() => container.run('health-check', ['c1'], { clear: true, org: ORG }));
|
|
104
|
+
expect(sdk.container.updateContainer).toHaveBeenCalledWith('hq_live_test', ORG, 'c1', { health_check: '' });
|
|
105
|
+
});
|
|
106
|
+
it('refuses /healthz before it costs a round trip', async () => {
|
|
107
|
+
const container = await import('./container.js');
|
|
108
|
+
await run(() => container.run('health-check', ['c1', '/healthz'], { org: ORG }));
|
|
109
|
+
expect(sdk.container.updateContainer).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -12,7 +12,9 @@ export declare function splitEnvEntries(raw: string): string[];
|
|
|
12
12
|
export declare function create(nameArg: string | undefined, flags: Flags): Promise<void>;
|
|
13
13
|
export declare function list(flags: Flags): Promise<void>;
|
|
14
14
|
export declare function get(id: string, flags: Flags): Promise<void>;
|
|
15
|
+
export declare function healthCheck(id: string, flags: Flags): Promise<void>;
|
|
15
16
|
export declare function del(id: string, flags: Flags): Promise<void>;
|
|
17
|
+
export declare function _checkProbePath(hc: string): string;
|
|
16
18
|
export declare function _isTarball(p: string): boolean;
|
|
17
19
|
export declare function deploy(id: string, image: string, flags: Flags): Promise<void>;
|
|
18
20
|
export declare function _parseSmoke(raw: string): sdkContainer.SmokeCheck;
|
|
@@ -34,6 +34,7 @@ export const SCHEMA = {
|
|
|
34
34
|
scope: 'string',
|
|
35
35
|
'no-promote': 'boolean',
|
|
36
36
|
'health-check': 'string',
|
|
37
|
+
clear: 'boolean',
|
|
37
38
|
smoke: 'string',
|
|
38
39
|
remove: 'boolean',
|
|
39
40
|
source: 'string',
|
|
@@ -147,15 +148,7 @@ export async function create(nameArg, flags) {
|
|
|
147
148
|
if (typeof flags.port === 'number')
|
|
148
149
|
payload.port = flags.port;
|
|
149
150
|
if (typeof flags['health-check'] === 'string') {
|
|
150
|
-
|
|
151
|
-
// The API refuses /healthz, but say so here rather than round-tripping:
|
|
152
|
-
// the reason is specific and worth stating where the user typed it.
|
|
153
|
-
if (/^\/?healthz\/?$/i.test(hc)) {
|
|
154
|
-
error('The runtime intercepts /healthz, so a probe against it never reaches your container —\nit would report healthy no matter what your code does. Use /livez, or any other path.');
|
|
155
|
-
}
|
|
156
|
-
if (!hc.startsWith('/'))
|
|
157
|
-
error(`--health-check must be a path starting with "/" — got "${hc}".`);
|
|
158
|
-
payload.health_check = hc;
|
|
151
|
+
payload.health_check = _checkProbePath(flags['health-check']);
|
|
159
152
|
}
|
|
160
153
|
// Repeatable, and create must agree with `container env` on what --env means.
|
|
161
154
|
// If they disagree, a variable settable at creation cannot be changed
|
|
@@ -218,6 +211,7 @@ export async function get(id, flags) {
|
|
|
218
211
|
if (c.port)
|
|
219
212
|
info(`Port: ${c.port}`);
|
|
220
213
|
info(`URL: ${c.url || '(not deployed)'}`);
|
|
214
|
+
info(`Startup probe: ${c.health_check || '(none)'}`);
|
|
221
215
|
if (c.egress)
|
|
222
216
|
info(`Egress: ${c.egress}`);
|
|
223
217
|
if (c.custom_domain)
|
|
@@ -225,6 +219,39 @@ export async function get(id, flags) {
|
|
|
225
219
|
info(`Created: ${c.created_at}`);
|
|
226
220
|
info(`Updated: ${c.updated_at}`);
|
|
227
221
|
}
|
|
222
|
+
export async function healthCheck(id, flags) {
|
|
223
|
+
const config = requireConfig();
|
|
224
|
+
const orgId = requireOrg(flags, config, 'myapi container health-check <id> <path> [--clear]');
|
|
225
|
+
if (!id)
|
|
226
|
+
error('Missing id.\nUsage: myapi container health-check <id> /livez (or --clear to remove it)');
|
|
227
|
+
const pathArg = typeof flags._probePath === 'string' ? flags._probePath : undefined;
|
|
228
|
+
if (flags.clear && pathArg) {
|
|
229
|
+
error('Pass a path or --clear, not both.');
|
|
230
|
+
}
|
|
231
|
+
if (!flags.clear && !pathArg) {
|
|
232
|
+
error('Nothing to change.\nUsage: myapi container health-check <id> /livez (or --clear to remove the probe)');
|
|
233
|
+
}
|
|
234
|
+
// '' is how the API says "no probe" — the verb spells that --clear so nobody
|
|
235
|
+
// has to discover that an empty string is meaningful.
|
|
236
|
+
const value = flags.clear ? '' : _checkProbePath(pathArg);
|
|
237
|
+
info(value ? `› Setting startup probe to ${value}…` : '› Clearing the startup probe…');
|
|
238
|
+
const res = await sdkContainer.updateContainer(config.api_key, orgId, id, { health_check: value });
|
|
239
|
+
if (flags.json) {
|
|
240
|
+
printJson(res);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
// Same distinction `container env` draws: stored and live are different
|
|
244
|
+
// states, and only one of them is what the caller asked for.
|
|
245
|
+
if (res.applied) {
|
|
246
|
+
success(`Redeployed on the same image — the probe is live${res.revision ? ` (${res.revision})` : ''}.`);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
info(' Stored. This container has no deployment yet, so it applies on your first deploy.');
|
|
250
|
+
}
|
|
251
|
+
info(value
|
|
252
|
+
? ` The runtime now waits for ${value} to answer before sending traffic to a new revision.`
|
|
253
|
+
: ' No startup probe: a new revision takes traffic as soon as the runtime reports it started.');
|
|
254
|
+
}
|
|
228
255
|
export async function del(id, flags) {
|
|
229
256
|
const config = requireConfig();
|
|
230
257
|
const orgId = requireOrg(flags, config, 'myapi container delete <id> [--yes] [--org <id>]');
|
|
@@ -234,6 +261,22 @@ export async function del(id, flags) {
|
|
|
234
261
|
await sdkContainer.deleteContainer(config.api_key, orgId, id);
|
|
235
262
|
success(`Deleted container ${id} (org ${orgId})`);
|
|
236
263
|
}
|
|
264
|
+
// The probe path is validated in exactly one place because `create` and
|
|
265
|
+
// `health-check` must agree on what a valid one is. When a setting is accepted
|
|
266
|
+
// at creation and rejected (or unreachable) afterwards, changing it becomes a
|
|
267
|
+
// delete-and-recreate — which costs the URL, the scoped key and the custom
|
|
268
|
+
// domain. That is the bug this verb exists to close; it should not reappear as
|
|
269
|
+
// a validation mismatch.
|
|
270
|
+
export function _checkProbePath(hc) {
|
|
271
|
+
// The API refuses /healthz, but say so here rather than round-tripping:
|
|
272
|
+
// the reason is specific and worth stating where the user typed it.
|
|
273
|
+
if (/^\/?healthz\/?$/i.test(hc)) {
|
|
274
|
+
error('The runtime intercepts /healthz, so a probe against it never reaches your container —\nit would report healthy no matter what your code does. Use /livez, or any other path.');
|
|
275
|
+
}
|
|
276
|
+
if (!hc.startsWith('/'))
|
|
277
|
+
error(`A health check must be a path starting with "/" — got "${hc}".`);
|
|
278
|
+
return hc;
|
|
279
|
+
}
|
|
237
280
|
// _isTarball returns true if the path looks like an already-built tar archive
|
|
238
281
|
// (so we upload it as-is instead of tarring a directory).
|
|
239
282
|
export function _isTarball(p) {
|
|
@@ -711,6 +754,18 @@ domain's MyAPI-managed parent domain must already be registered.
|
|
|
711
754
|
|
|
712
755
|
Example:
|
|
713
756
|
myapi container domain <id> app.synthesisdaily.com`,
|
|
757
|
+
'health-check': `myapi container health-check <id> <path> [--clear] [--org <id>]
|
|
758
|
+
|
|
759
|
+
Set or clear the startup probe on an existing container, rolling a new
|
|
760
|
+
revision on the SAME image. No rebuild.
|
|
761
|
+
|
|
762
|
+
The runtime waits for this path to answer before sending traffic to a new
|
|
763
|
+
revision, so a bad deploy is caught before it takes any. Not /healthz — the
|
|
764
|
+
runtime answers that before it reaches your container, so the probe would
|
|
765
|
+
pass while your app is down.
|
|
766
|
+
|
|
767
|
+
myapi container health-check <id> /livez
|
|
768
|
+
myapi container health-check <id> --clear`,
|
|
714
769
|
'delete': 'myapi container delete <id> [--org <id>]',
|
|
715
770
|
};
|
|
716
771
|
export async function run(subcommand, args, flags) {
|
|
@@ -814,6 +869,7 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
|
|
|
814
869
|
case 'revisions': return revisions(args[0], flags);
|
|
815
870
|
case 'promote': return promote(args[0], args[1], flags);
|
|
816
871
|
case 'domain': return domain(args[0], args[1], flags);
|
|
872
|
+
case 'health-check': return healthCheck(args[0], { ...flags, _probePath: args[1] });
|
|
817
873
|
case 'delete': return del(args[0], flags);
|
|
818
874
|
case 'rollback': return rollback(args[0], flags);
|
|
819
875
|
default: error(`Unknown subcommand: ${subcommand}. Run "myapi container --help" for a list of valid subcommands.`);
|
|
@@ -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,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: my-container-api
|
|
3
|
-
version: 1.1.
|
|
3
|
+
version: 1.1.2
|
|
4
4
|
description: >
|
|
5
5
|
Run containers on demand — long-running services, background workers, and scheduled jobs. The heavier-duty sibling of edge functions, for native deps and long execution.
|
|
6
6
|
triggers: [container, cloud run, dynamic app, custom domain app, service, worker, scheduled job, deploy container, docker image]
|
|
7
|
-
checksum: sha256-
|
|
7
|
+
checksum: sha256-0e1702e51e8d501edf48bdc4da92728d52e79c4273f534ba5ed41511b4803271
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# MyContainerAPI
|
|
@@ -19,9 +19,8 @@ The lifecycle is **create → deploy → (optionally) bind a custom domain**.
|
|
|
19
19
|
- `deploy` takes **either** a pre-built image reference **or** a source
|
|
20
20
|
directory. `--source ./dir` tars the directory, builds it server-side
|
|
21
21
|
(typically ~4 minutes) and deploys the result — **no Docker on your machine,
|
|
22
|
-
no registry account, no image to push**.
|
|
23
|
-
|
|
24
|
-
- `domain` puts the container on a **custom domain** — how you serve a dynamic app at `app.yourbrand.com`.
|
|
22
|
+
no registry account, no image to push**.
|
|
23
|
+
- `domain` puts the container on a **custom domain** — a dynamic app at `app.yourbrand.com`.
|
|
25
24
|
|
|
26
25
|
### Deploying safely
|
|
27
26
|
|
|
@@ -36,8 +35,8 @@ myapi container deploy <id> --source ./app --smoke 'GET / contains assets/'
|
|
|
36
35
|
```
|
|
37
36
|
|
|
38
37
|
**Assert on content, not status.** A build whose frontend never bundled still
|
|
39
|
-
binds its port and answers `200` — that is how a placeholder page
|
|
40
|
-
|
|
38
|
+
binds its port and answers `200` — that is how a placeholder page served a dead
|
|
39
|
+
page for fifteen minutes. Same reasoning as the startup probe below.
|
|
41
40
|
|
|
42
41
|
**`--no-promote`** holds the revision back and prints a URL to test yourself,
|
|
43
42
|
then `myapi container promote <id> <revision>`. Use it when the check is more
|
|
@@ -47,9 +46,10 @@ traffic is not withheld and the output says so.
|
|
|
47
46
|
**`myapi container rollback <id>`** returns traffic to the previous ready
|
|
48
47
|
revision in seconds, no rebuild.
|
|
49
48
|
|
|
50
|
-
`--health-check /livez`
|
|
51
|
-
|
|
52
|
-
it,
|
|
49
|
+
`--health-check /livez` makes the startup probe an HTTP request, not a bare TCP
|
|
50
|
+
connect — at create, or later with `myapi container health-check <id> /livez`
|
|
51
|
+
(`--clear` removes it), which rolls a new revision on the same image.
|
|
52
|
+
`/healthz` is refused: the runtime answers it before your container does.
|
|
53
53
|
|
|
54
54
|
### Custom domains (dynamic apps)
|
|
55
55
|
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: my-crm-api
|
|
3
|
-
version: 1.2.
|
|
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-
|
|
7
|
+
checksum: sha256-8bc061c79940045d1f95ec4104a452c83342f9ebe2be3c0e087b4c4953524ce6
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# MyCRMAPI
|
|
11
11
|
|
|
12
|
-
The store that closes the funnel
|
|
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"
|
|
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**.
|
|
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`.**
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
-
|
|
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.
|
|
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.
|
|
49
|
+
"@myapihq/sdk": "^2.25.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/node": "^25.6.0",
|