@myapihq/cli 1.2.2 → 1.2.5
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/domain.d.ts +2 -0
- package/dist/commands/domain.js +106 -9
- package/dist/commands/fn-validation.test.d.ts +1 -0
- package/dist/commands/fn-validation.test.js +60 -0
- package/dist/commands/fn.d.ts +13 -0
- package/dist/commands/fn.js +180 -0
- package/dist/commands/funnel.js +18 -5
- package/dist/commands/webhook-validation.test.d.ts +1 -0
- package/dist/commands/webhook-validation.test.js +35 -0
- package/dist/commands/webhook.d.ts +2 -0
- package/dist/commands/webhook.js +72 -2
- package/dist/commands/workflow-validation.test.d.ts +1 -0
- package/dist/commands/workflow-validation.test.js +137 -0
- package/dist/commands/workflow.d.ts +1 -0
- package/dist/commands/workflow.js +58 -17
- package/dist/exposes.test.js +1 -0
- package/dist/index.js +24 -4
- package/dist/sdk-domain-assign.test.d.ts +1 -0
- package/dist/sdk-domain-assign.test.js +53 -0
- package/dist/sdk-function.test.d.ts +1 -0
- package/dist/sdk-function.test.js +190 -0
- package/dist/sdk-funnel-name.test.d.ts +1 -0
- package/dist/sdk-funnel-name.test.js +48 -0
- package/dist/sdk-webhook.test.d.ts +1 -0
- package/dist/sdk-webhook.test.js +86 -0
- package/dist/skills/my-domain-api/SKILL.md +10 -0
- package/package.json +2 -2
package/dist/commands/webhook.js
CHANGED
|
@@ -5,6 +5,7 @@ import { requireOrg } from '../helpers.js';
|
|
|
5
5
|
export const EXPOSES = [
|
|
6
6
|
'POST /webhook/orgs/{org_id}/endpoints',
|
|
7
7
|
'GET /webhook/orgs/{org_id}/endpoints',
|
|
8
|
+
'PATCH /webhook/orgs/{org_id}/endpoints/{endpoint_id}',
|
|
8
9
|
'DELETE /webhook/orgs/{org_id}/endpoints/{endpoint_id}',
|
|
9
10
|
'GET /webhook/orgs/{org_id}/deliveries/{delivery_id}',
|
|
10
11
|
];
|
|
@@ -13,6 +14,8 @@ export const SCHEMA = {
|
|
|
13
14
|
name: 'string',
|
|
14
15
|
description: 'string',
|
|
15
16
|
'crm-email-path': 'string',
|
|
17
|
+
// 2026-05-15: backend's POST/PATCH endpoints accept forward_url.
|
|
18
|
+
'forward-url': 'string',
|
|
16
19
|
};
|
|
17
20
|
export async function list(flags) {
|
|
18
21
|
const config = requireConfig();
|
|
@@ -29,21 +32,78 @@ export async function list(flags) {
|
|
|
29
32
|
}
|
|
30
33
|
export async function create(nameArg, flags) {
|
|
31
34
|
const config = requireConfig();
|
|
32
|
-
const orgId = requireOrg(flags, config, 'myapi webhook create <name> [--description <desc>] [--org <id>]');
|
|
35
|
+
const orgId = requireOrg(flags, config, 'myapi webhook create <name> [--description <desc>] [--crm-email-path <path>] [--forward-url <url>] [--org <id>]');
|
|
33
36
|
const name = nameArg || flags.name;
|
|
34
37
|
if (!name)
|
|
35
|
-
error('Missing required arguments.\nUsage: myapi webhook create <name> [--description <desc>] [--crm-email-path <path>] [--org <id>]\n or: myapi webhook create --name <name> [...]');
|
|
38
|
+
error('Missing required arguments.\nUsage: myapi webhook create <name> [--description <desc>] [--crm-email-path <path>] [--forward-url <url>] [--org <id>]\n or: myapi webhook create --name <name> [...]');
|
|
36
39
|
const opts = {};
|
|
37
40
|
if (typeof flags.description === 'string')
|
|
38
41
|
opts.description = flags.description;
|
|
39
42
|
if (typeof flags['crm-email-path'] === 'string')
|
|
40
43
|
opts.crm_email_path = flags['crm-email-path'];
|
|
44
|
+
if (typeof flags['forward-url'] === 'string') {
|
|
45
|
+
validateForwardUrl(flags['forward-url']);
|
|
46
|
+
opts.forward_url = flags['forward-url'];
|
|
47
|
+
}
|
|
41
48
|
const res = await sdkWebhook.createEndpoint(config.api_key, orgId, name, opts);
|
|
42
49
|
if (flags.json) {
|
|
43
50
|
printJson(res);
|
|
44
51
|
return;
|
|
45
52
|
}
|
|
46
53
|
success(`Webhook created! ID: ${res.id}\nInbound URL: ${res.url}`);
|
|
54
|
+
if (opts.forward_url)
|
|
55
|
+
info(`Forwarding to: ${opts.forward_url}`);
|
|
56
|
+
}
|
|
57
|
+
export async function update(id, flags) {
|
|
58
|
+
const config = requireConfig();
|
|
59
|
+
const orgId = requireOrg(flags, config, 'myapi webhook update <id> [--name <name>] [--description <desc>] [--crm-email-path <path>] [--forward-url <url>] [--org <id>]');
|
|
60
|
+
if (!id)
|
|
61
|
+
error('Missing required arguments.\nUsage: myapi webhook update <id> [--name <name>] [--description <desc>] [--crm-email-path <path>] [--forward-url <url>]');
|
|
62
|
+
const payload = {};
|
|
63
|
+
if (typeof flags.name === 'string')
|
|
64
|
+
payload.name = flags.name;
|
|
65
|
+
if (typeof flags.description === 'string')
|
|
66
|
+
payload.description = flags.description;
|
|
67
|
+
// crm-email-path & forward-url accept "" (empty string) to explicitly
|
|
68
|
+
// disable. Don't coerce to undefined — that would mean "leave as-is".
|
|
69
|
+
if (typeof flags['crm-email-path'] === 'string')
|
|
70
|
+
payload.crm_email_path = flags['crm-email-path'];
|
|
71
|
+
if (typeof flags['forward-url'] === 'string') {
|
|
72
|
+
const fu = flags['forward-url'];
|
|
73
|
+
if (fu !== '')
|
|
74
|
+
validateForwardUrl(fu);
|
|
75
|
+
payload.forward_url = fu;
|
|
76
|
+
}
|
|
77
|
+
if (Object.keys(payload).length === 0) {
|
|
78
|
+
error('Nothing to update. Provide at least one of --name, --description, --crm-email-path, --forward-url.');
|
|
79
|
+
}
|
|
80
|
+
const res = await sdkWebhook.updateEndpoint(config.api_key, orgId, id, payload);
|
|
81
|
+
if (flags.json) {
|
|
82
|
+
printJson(res);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
success(`Webhook ${res.id} updated`);
|
|
86
|
+
}
|
|
87
|
+
// Exported for unit tests. Returns null on success, error message on failure.
|
|
88
|
+
// Matches the backend's documented "Use http:// or https://" constraint.
|
|
89
|
+
export function _validateForwardUrl(url) {
|
|
90
|
+
if (url === '')
|
|
91
|
+
return null; // empty = unset; valid
|
|
92
|
+
try {
|
|
93
|
+
const u = new URL(url);
|
|
94
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
95
|
+
return `--forward-url must use http:// or https:// (got ${u.protocol})`;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return `--forward-url is not a valid URL: "${url}"`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function validateForwardUrl(url) {
|
|
104
|
+
const err = _validateForwardUrl(url);
|
|
105
|
+
if (err)
|
|
106
|
+
error(err);
|
|
47
107
|
}
|
|
48
108
|
export async function del(id, flags) {
|
|
49
109
|
const config = requireConfig();
|
|
@@ -74,6 +134,14 @@ CRM auto-ingest (optional):
|
|
|
74
134
|
Default 'email' (top-level). For Stripe:
|
|
75
135
|
'data.object.customer_email'. For GitHub:
|
|
76
136
|
'sender.email'. Empty string disables ingest.`,
|
|
137
|
+
'update': `myapi webhook update <id> [--name <name>] [--description <desc>] [--crm-email-path <path>] [--forward-url <url>] [--org <id>]
|
|
138
|
+
|
|
139
|
+
All fields optional — only the ones you pass get changed. Pass an empty
|
|
140
|
+
string to --crm-email-path or --forward-url to explicitly clear that
|
|
141
|
+
field (e.g., --forward-url "" disables forwarding without deleting the
|
|
142
|
+
endpoint).
|
|
143
|
+
|
|
144
|
+
Slug is immutable; create a new endpoint if you need a different slug.`,
|
|
77
145
|
'delete': 'myapi webhook delete <id> [--org <id>]',
|
|
78
146
|
'delivery': 'myapi webhook delivery <delivery_id> [--org <id>]',
|
|
79
147
|
};
|
|
@@ -84,6 +152,7 @@ export async function run(subcommand, args, flags) {
|
|
|
84
152
|
Subcommands:
|
|
85
153
|
list List all inbound webhook endpoints
|
|
86
154
|
create Create a new endpoint to receive data (returns an inbound URL)
|
|
155
|
+
update Patch endpoint name / description / crm-email-path / forward-url
|
|
87
156
|
delete Delete a webhook endpoint
|
|
88
157
|
delivery Inspect a specific webhook delivery (payload, received_at)
|
|
89
158
|
|
|
@@ -118,6 +187,7 @@ See the my-webhook-api skill for the full HTML + JS recipe.`);
|
|
|
118
187
|
switch (subcommand) {
|
|
119
188
|
case 'list': return list(flags);
|
|
120
189
|
case 'create': return create(args[0], flags);
|
|
190
|
+
case 'update': return update(args[0], flags);
|
|
121
191
|
case 'delete': return del(args[0], flags);
|
|
122
192
|
case 'delivery': return delivery(args[0], flags);
|
|
123
193
|
default: error(`Unknown subcommand: ${subcommand}. Run "myapi webhook --help" for a list of valid subcommands.`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Unit tests for workflow.ts CLI's pure step validator. The 2026-05-15
|
|
2
|
+
// additive contract change adds `http_request` (alias `http`) to the
|
|
3
|
+
// supported step types; this test file pins the new validation rules and
|
|
4
|
+
// regression-tests the prior shapes.
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { _validateSteps } from './workflow.js';
|
|
7
|
+
const VALID_EMAIL = { type: 'send_email', from: 'a@x.com', to: 'b@x.com', subject: 'hi', html: '<p>x</p>' };
|
|
8
|
+
const VALID_SLACK = { type: 'slack_message', webhook_url: 'https://hooks.slack.com/services/T1/B1/abc', text: 'hi' };
|
|
9
|
+
const VALID_HTTP = { type: 'http_request', url: 'https://example.com/hook' };
|
|
10
|
+
describe('_validateSteps — input shape', () => {
|
|
11
|
+
it('rejects non-array', () => {
|
|
12
|
+
expect(_validateSteps('not an array')).toMatch(/must be a JSON array/);
|
|
13
|
+
expect(_validateSteps({})).toMatch(/must be a JSON array/);
|
|
14
|
+
expect(_validateSteps(null)).toMatch(/must be a JSON array/);
|
|
15
|
+
});
|
|
16
|
+
it('rejects empty array', () => {
|
|
17
|
+
expect(_validateSteps([])).toMatch(/cannot be an empty array/);
|
|
18
|
+
});
|
|
19
|
+
it('rejects steps that are not objects', () => {
|
|
20
|
+
expect(_validateSteps(['string'])).toMatch(/must be a JSON object/);
|
|
21
|
+
expect(_validateSteps([null])).toMatch(/must be a JSON object/);
|
|
22
|
+
});
|
|
23
|
+
it('rejects unknown step types', () => {
|
|
24
|
+
expect(_validateSteps([{ type: 'pigeon' }])).toMatch(/unknown type "pigeon"/);
|
|
25
|
+
});
|
|
26
|
+
it('rejects steps missing the type field', () => {
|
|
27
|
+
expect(_validateSteps([{ from: 'x' }])).toMatch(/missing required field "type"/);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe('_validateSteps — send_email / email', () => {
|
|
31
|
+
it('accepts valid send_email with html body', () => {
|
|
32
|
+
expect(_validateSteps([VALID_EMAIL])).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
it('accepts the `email` alias', () => {
|
|
35
|
+
expect(_validateSteps([{ ...VALID_EMAIL, type: 'email' }])).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
it('rejects send_email missing required fields', () => {
|
|
38
|
+
expect(_validateSteps([{ type: 'send_email', from: 'a@x.com' }]))
|
|
39
|
+
.toMatch(/missing required field "to"/);
|
|
40
|
+
});
|
|
41
|
+
it('rejects send_email with no body form', () => {
|
|
42
|
+
expect(_validateSteps([{ type: 'send_email', from: 'a@x.com', to: 'b@x.com', subject: 's' }]))
|
|
43
|
+
.toMatch(/must include exactly one of "body", "html", or "template_id"/);
|
|
44
|
+
});
|
|
45
|
+
it('rejects send_email with multiple body forms', () => {
|
|
46
|
+
expect(_validateSteps([{ ...VALID_EMAIL, body: 'plain', html: '<p>x</p>' }]))
|
|
47
|
+
.toMatch(/can only use one of/);
|
|
48
|
+
});
|
|
49
|
+
it('rejects template_vars without template_id', () => {
|
|
50
|
+
expect(_validateSteps([{ ...VALID_EMAIL, template_vars: { name: 'x' } }]))
|
|
51
|
+
.toMatch(/"template_vars" only makes sense with "template_id"/);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe('_validateSteps — slack_message / slack', () => {
|
|
55
|
+
it('accepts valid slack_message', () => {
|
|
56
|
+
expect(_validateSteps([VALID_SLACK])).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
it('accepts the `slack` alias', () => {
|
|
59
|
+
expect(_validateSteps([{ ...VALID_SLACK, type: 'slack' }])).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
it('rejects slack with non-Slack webhook URL', () => {
|
|
62
|
+
expect(_validateSteps([{ ...VALID_SLACK, webhook_url: 'https://example.com/hook' }]))
|
|
63
|
+
.toMatch(/must look like https:\/\/hooks\.slack\.com/);
|
|
64
|
+
});
|
|
65
|
+
it('rejects slack missing text', () => {
|
|
66
|
+
expect(_validateSteps([{ type: 'slack_message', webhook_url: VALID_SLACK.webhook_url }]))
|
|
67
|
+
.toMatch(/missing required field "text"/);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('_validateSteps — http_request / http (2026-05-15)', () => {
|
|
71
|
+
it('accepts the canonical type name', () => {
|
|
72
|
+
expect(_validateSteps([VALID_HTTP])).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
it('accepts the `http` alias', () => {
|
|
75
|
+
expect(_validateSteps([{ ...VALID_HTTP, type: 'http' }])).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
it('accepts all supported HTTP methods', () => {
|
|
78
|
+
for (const m of ['GET', 'POST', 'PATCH', 'PUT', 'DELETE']) {
|
|
79
|
+
expect(_validateSteps([{ ...VALID_HTTP, method: m }])).toBeNull();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
it('accepts a body string with templating', () => {
|
|
83
|
+
expect(_validateSteps([{ ...VALID_HTTP, body: '{"email":"{{ payload.email }}"}' }])).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
it('accepts a headers map of strings', () => {
|
|
86
|
+
expect(_validateSteps([{ ...VALID_HTTP, headers: { 'X-Token': 'abc', 'X-Other': 'def' } }])).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
it('rejects when url is missing', () => {
|
|
89
|
+
expect(_validateSteps([{ type: 'http_request' }]))
|
|
90
|
+
.toMatch(/missing required field "url"/);
|
|
91
|
+
});
|
|
92
|
+
it('rejects when url is not a string', () => {
|
|
93
|
+
expect(_validateSteps([{ type: 'http_request', url: 12345 }]))
|
|
94
|
+
.toMatch(/missing required field "url"/);
|
|
95
|
+
});
|
|
96
|
+
it('rejects non-http(s) URLs', () => {
|
|
97
|
+
expect(_validateSteps([{ ...VALID_HTTP, url: 'ftp://example.com' }]))
|
|
98
|
+
.toMatch(/must start with http:\/\/ or https:\/\//);
|
|
99
|
+
expect(_validateSteps([{ ...VALID_HTTP, url: 'javascript:alert(1)' }]))
|
|
100
|
+
.toMatch(/must start with http:\/\/ or https:\/\//);
|
|
101
|
+
});
|
|
102
|
+
it('rejects unknown HTTP methods', () => {
|
|
103
|
+
expect(_validateSteps([{ ...VALID_HTTP, method: 'TRACE' }]))
|
|
104
|
+
.toMatch(/"method" must be one of/);
|
|
105
|
+
});
|
|
106
|
+
it('rejects body as a non-string (object)', () => {
|
|
107
|
+
expect(_validateSteps([{ ...VALID_HTTP, body: { not: 'a string' } }]))
|
|
108
|
+
.toMatch(/"body" must be a string/);
|
|
109
|
+
});
|
|
110
|
+
it('rejects headers as a non-object', () => {
|
|
111
|
+
expect(_validateSteps([{ ...VALID_HTTP, headers: 'X-Token: abc' }]))
|
|
112
|
+
.toMatch(/"headers" must be a JSON object/);
|
|
113
|
+
});
|
|
114
|
+
it('rejects headers as an array', () => {
|
|
115
|
+
expect(_validateSteps([{ ...VALID_HTTP, headers: ['X-Token: abc'] }]))
|
|
116
|
+
.toMatch(/"headers" must be a JSON object/);
|
|
117
|
+
});
|
|
118
|
+
it('rejects header value that is not a string', () => {
|
|
119
|
+
expect(_validateSteps([{ ...VALID_HTTP, headers: { 'X-Token': 42 } }]))
|
|
120
|
+
.toMatch(/"headers" value for "X-Token" must be a string/);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
describe('_validateSteps — mixed-type pipelines', () => {
|
|
124
|
+
it('accepts an http_request after a send_email (pipeline shape)', () => {
|
|
125
|
+
expect(_validateSteps([
|
|
126
|
+
VALID_EMAIL,
|
|
127
|
+
{ ...VALID_HTTP, body: '{"sent_to":"{{ payload.email }}"}' },
|
|
128
|
+
])).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
it('reports the failing step index in the error message', () => {
|
|
131
|
+
expect(_validateSteps([
|
|
132
|
+
VALID_EMAIL,
|
|
133
|
+
VALID_SLACK,
|
|
134
|
+
{ type: 'http_request' }, // missing url at index 2
|
|
135
|
+
])).toMatch(/^step 2 \(http_request\):/);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -3,6 +3,7 @@ import { type Flags } from '../helpers.js';
|
|
|
3
3
|
import type { Exposes } from '../exposes.js';
|
|
4
4
|
export declare const EXPOSES: Exposes;
|
|
5
5
|
export declare const SCHEMA: FlagSchema;
|
|
6
|
+
export declare function _validateSteps(steps: unknown): string | null;
|
|
6
7
|
export declare function list(flags: Flags): Promise<void>;
|
|
7
8
|
export declare function get(id: string, flags: Flags): Promise<void>;
|
|
8
9
|
export declare function create(nameArg: string | undefined, flags: Flags): Promise<void>;
|
|
@@ -23,59 +23,100 @@ export const SCHEMA = {
|
|
|
23
23
|
// Mirrors the backend's SupportedStepTypes list. Both alias and underscore
|
|
24
24
|
// forms are accepted by the workflow runner. Keep this in sync if the
|
|
25
25
|
// backend grows new step types.
|
|
26
|
-
|
|
26
|
+
//
|
|
27
|
+
// 2026-05-15: backend added `http_request` (alias `http`). See
|
|
28
|
+
// myapi-hq/internal/routes/workflow/execute.go.
|
|
29
|
+
const SUPPORTED_STEP_TYPES = ['send_email', 'email', 'slack_message', 'slack', 'http_request', 'http'];
|
|
30
|
+
const HTTP_METHODS = new Set(['GET', 'POST', 'PATCH', 'PUT', 'DELETE']);
|
|
27
31
|
const SLACK_HOOK_RE = /^https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+/;
|
|
28
32
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
29
33
|
// Per-step-type required fields. Validated client-side so typos and
|
|
30
34
|
// hallucinated shapes fail fast, before the network call. Backend
|
|
31
35
|
// performs the same validation as defense in depth.
|
|
32
|
-
|
|
36
|
+
//
|
|
37
|
+
// `_validateSteps` is the pure form — returns the first error message
|
|
38
|
+
// (string) or null on success. Suitable for unit tests. The internal
|
|
39
|
+
// `validateSteps` wrapper calls `error()` on a non-null result, which
|
|
40
|
+
// exits the process — used by command handlers.
|
|
41
|
+
export function _validateSteps(steps) {
|
|
33
42
|
if (!Array.isArray(steps))
|
|
34
|
-
|
|
43
|
+
return '--steps must be a JSON array of step objects.';
|
|
35
44
|
if (steps.length === 0)
|
|
36
|
-
|
|
37
|
-
steps.
|
|
45
|
+
return '--steps cannot be an empty array.';
|
|
46
|
+
for (let i = 0; i < steps.length; i++) {
|
|
47
|
+
const s = steps[i];
|
|
38
48
|
const where = `step ${i}`;
|
|
39
49
|
if (!s || typeof s !== 'object')
|
|
40
|
-
|
|
50
|
+
return `${where}: must be a JSON object.`;
|
|
41
51
|
if (!s.type)
|
|
42
|
-
|
|
52
|
+
return `${where}: missing required field "type". Supported: ${SUPPORTED_STEP_TYPES.join(', ')}`;
|
|
43
53
|
if (!SUPPORTED_STEP_TYPES.includes(s.type)) {
|
|
44
|
-
|
|
54
|
+
return `${where}: unknown type "${s.type}". Supported: ${SUPPORTED_STEP_TYPES.join(', ')}`;
|
|
45
55
|
}
|
|
46
56
|
if (s.type === 'send_email' || s.type === 'email') {
|
|
47
57
|
const required = ['from', 'to', 'subject'];
|
|
48
58
|
for (const f of required) {
|
|
49
59
|
if (!s[f] || typeof s[f] !== 'string') {
|
|
50
|
-
|
|
60
|
+
return `${where} (${s.type}): missing required field "${f}".`;
|
|
51
61
|
}
|
|
52
62
|
}
|
|
53
63
|
const bodyForms = ['body', 'html', 'template_id'].filter(f => s[f] !== undefined && s[f] !== '');
|
|
54
64
|
if (bodyForms.length === 0) {
|
|
55
|
-
|
|
65
|
+
return `${where} (${s.type}): must include exactly one of "body", "html", or "template_id".`;
|
|
56
66
|
}
|
|
57
67
|
if (bodyForms.length > 1) {
|
|
58
|
-
|
|
68
|
+
return `${where} (${s.type}): can only use one of "body", "html", "template_id" — got: ${bodyForms.join(', ')}.`;
|
|
59
69
|
}
|
|
60
70
|
if (s.template_vars !== undefined && !s.template_id) {
|
|
61
|
-
|
|
71
|
+
return `${where} (${s.type}): "template_vars" only makes sense with "template_id".`;
|
|
62
72
|
}
|
|
63
73
|
if (s.template_vars !== undefined && (typeof s.template_vars !== 'object' || Array.isArray(s.template_vars))) {
|
|
64
|
-
|
|
74
|
+
return `${where} (${s.type}): "template_vars" must be a JSON object.`;
|
|
65
75
|
}
|
|
66
76
|
}
|
|
67
77
|
if (s.type === 'slack_message' || s.type === 'slack') {
|
|
68
78
|
if (!s.webhook_url || typeof s.webhook_url !== 'string') {
|
|
69
|
-
|
|
79
|
+
return `${where} (${s.type}): missing required field "webhook_url".`;
|
|
70
80
|
}
|
|
71
81
|
if (!SLACK_HOOK_RE.test(s.webhook_url)) {
|
|
72
|
-
|
|
82
|
+
return `${where} (${s.type}): "webhook_url" must look like https://hooks.slack.com/services/T.../B.../xxx — got "${s.webhook_url}".`;
|
|
73
83
|
}
|
|
74
84
|
if (!s.text || typeof s.text !== 'string') {
|
|
75
|
-
|
|
85
|
+
return `${where} (${s.type}): missing required field "text".`;
|
|
76
86
|
}
|
|
77
87
|
}
|
|
78
|
-
|
|
88
|
+
if (s.type === 'http_request' || s.type === 'http') {
|
|
89
|
+
if (!s.url || typeof s.url !== 'string') {
|
|
90
|
+
return `${where} (${s.type}): missing required field "url".`;
|
|
91
|
+
}
|
|
92
|
+
if (!/^https?:\/\//.test(s.url)) {
|
|
93
|
+
return `${where} (${s.type}): "url" must start with http:// or https:// — got "${s.url}".`;
|
|
94
|
+
}
|
|
95
|
+
if (s.method !== undefined) {
|
|
96
|
+
if (typeof s.method !== 'string' || !HTTP_METHODS.has(s.method.toUpperCase())) {
|
|
97
|
+
return `${where} (${s.type}): "method" must be one of ${[...HTTP_METHODS].join(', ')}.`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (s.body !== undefined && typeof s.body !== 'string') {
|
|
101
|
+
return `${where} (${s.type}): "body" must be a string (template substitutions supported).`;
|
|
102
|
+
}
|
|
103
|
+
if (s.headers !== undefined) {
|
|
104
|
+
if (typeof s.headers !== 'object' || Array.isArray(s.headers)) {
|
|
105
|
+
return `${where} (${s.type}): "headers" must be a JSON object of {string: string}.`;
|
|
106
|
+
}
|
|
107
|
+
for (const [k, v] of Object.entries(s.headers)) {
|
|
108
|
+
if (typeof v !== 'string')
|
|
109
|
+
return `${where} (${s.type}): "headers" value for "${k}" must be a string.`;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
function validateSteps(steps) {
|
|
117
|
+
const err = _validateSteps(steps);
|
|
118
|
+
if (err)
|
|
119
|
+
error(err);
|
|
79
120
|
}
|
|
80
121
|
function summarizeWorkflow(w) {
|
|
81
122
|
return {
|
package/dist/exposes.test.js
CHANGED
|
@@ -37,6 +37,7 @@ const COMMAND_MODULES = [
|
|
|
37
37
|
'./commands/url.js',
|
|
38
38
|
'./commands/webhook.js',
|
|
39
39
|
'./commands/workflow.js',
|
|
40
|
+
'./commands/fn.js',
|
|
40
41
|
];
|
|
41
42
|
const ENDPOINT_PATTERN = /^(GET|POST|PATCH|PUT|DELETE) \/[A-Za-z0-9_\-./{}]*$/;
|
|
42
43
|
describe('every CLI command exports a typed EXPOSES array (S-101)', () => {
|
package/dist/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import * as audienceCmd from './commands/audience.js';
|
|
|
29
29
|
import * as llmCmd from './commands/llm.js';
|
|
30
30
|
import * as databaseCmd from './commands/database.js';
|
|
31
31
|
import * as crmCmd from './commands/crm/index.js';
|
|
32
|
+
import * as fnCmd from './commands/fn.js';
|
|
32
33
|
import { initCompletion, installCompletion, uninstallCompletion } from './completion.js';
|
|
33
34
|
// Each command file declares the value flags it understands. We union them
|
|
34
35
|
// into a single schema for the upfront parse, so adding a new value flag in
|
|
@@ -55,6 +56,7 @@ const COMBINED_SCHEMA = {
|
|
|
55
56
|
...urlCmd.SCHEMA,
|
|
56
57
|
...webhookCmd.SCHEMA,
|
|
57
58
|
...workflowCmd.SCHEMA,
|
|
59
|
+
...fnCmd.SCHEMA,
|
|
58
60
|
// Top-level flags
|
|
59
61
|
version: 'boolean',
|
|
60
62
|
V: 'boolean',
|
|
@@ -88,13 +90,26 @@ const ERROR_MESSAGES = {
|
|
|
88
90
|
INVALID_TTL: 'Invalid TTL. Use the auto sentinel (1) or a value between 60 and 86400 seconds.',
|
|
89
91
|
INVALID_RECORD_CONTENT: 'Invalid record content for this type.',
|
|
90
92
|
RECORD_LIMIT_EXCEEDED: 'Cloudflare per-zone record limit reached.',
|
|
91
|
-
CF_API_ERROR: 'Cloudflare API error.
|
|
93
|
+
CF_API_ERROR: 'Cloudflare API error.',
|
|
92
94
|
// invalid_json_response intentionally absent — the SDK's MyApiError now
|
|
93
95
|
// builds a useful detailed message for that case (status + URL + body
|
|
94
96
|
// snippet), and friendlyError(err.code) would override it.
|
|
95
97
|
};
|
|
96
|
-
function friendlyError(
|
|
97
|
-
|
|
98
|
+
function friendlyError(err) {
|
|
99
|
+
const body = err.body ?? {};
|
|
100
|
+
// CF_API_ERROR: the cf_message already includes a "Cloudflare API error"
|
|
101
|
+
// prefix, so use it verbatim (with cf_status if present) instead of doubling.
|
|
102
|
+
if (err.code === 'CF_API_ERROR' && typeof body.cf_message === 'string') {
|
|
103
|
+
return typeof body.cf_status === 'number'
|
|
104
|
+
? `${body.cf_message} (HTTP ${body.cf_status})`
|
|
105
|
+
: body.cf_message;
|
|
106
|
+
}
|
|
107
|
+
const base = ERROR_MESSAGES[err.code] || err.code;
|
|
108
|
+
// Generic fallback: append the backend's `message` when it adds info beyond
|
|
109
|
+
// the friendly mapping. Future per-service detail fields can be added here.
|
|
110
|
+
if (err.detail && err.detail !== base)
|
|
111
|
+
return `${base} — ${err.detail}`;
|
|
112
|
+
return base;
|
|
98
113
|
}
|
|
99
114
|
async function main() {
|
|
100
115
|
// Shell autocomplete: if invoked by the shell with completion env vars,
|
|
@@ -184,6 +199,9 @@ async function main() {
|
|
|
184
199
|
case 'url':
|
|
185
200
|
await urlCmd.run(subcommand, restArgs, flags);
|
|
186
201
|
break;
|
|
202
|
+
case 'fn':
|
|
203
|
+
await fnCmd.run(subcommand, restArgs, flags);
|
|
204
|
+
break;
|
|
187
205
|
// Convenience aliases
|
|
188
206
|
case 'setup':
|
|
189
207
|
await setupCmd.setup(flags);
|
|
@@ -250,7 +268,7 @@ async function main() {
|
|
|
250
268
|
}
|
|
251
269
|
}
|
|
252
270
|
else
|
|
253
|
-
error(friendlyError(err
|
|
271
|
+
error(friendlyError(err) || err.message);
|
|
254
272
|
}
|
|
255
273
|
else {
|
|
256
274
|
// Don't JSON.stringify Error instances — that returns "{}" because
|
|
@@ -304,6 +322,7 @@ const HELP_TARGETS = {
|
|
|
304
322
|
llm: f => llmCmd.run(undefined, [], f),
|
|
305
323
|
database: f => databaseCmd.run(undefined, [], f),
|
|
306
324
|
crm: f => crmCmd.run(undefined, [], f),
|
|
325
|
+
fn: f => fnCmd.run(undefined, [], f),
|
|
307
326
|
org: f => orgCmd.run(undefined, [], f),
|
|
308
327
|
billing: f => billingCmd.run(undefined, [], f),
|
|
309
328
|
keys: f => keysCmd.run(undefined, [], f),
|
|
@@ -345,6 +364,7 @@ Commands:
|
|
|
345
364
|
update Update CLI and skills to the latest version
|
|
346
365
|
domain Manage domain configurations
|
|
347
366
|
funnel Manage websites (publish pages, custom domains, funnels)
|
|
367
|
+
fn Create functions on the edge runtime (Story 1 — metadata + scoped key only; bundle upload pending Story 2)
|
|
348
368
|
webhook Manage inbound webhook endpoints and inspect deliveries
|
|
349
369
|
email Manage mailboxes, send/read email, templates, and campaigns
|
|
350
370
|
workflow Run actions (send email, post to Slack) when a webhook fires
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Verifies the 2026-05-15 additive contract change: POST
|
|
2
|
+
// /domain/.../{domain}/assign now accepts an optional `funnel_id` in the body.
|
|
3
|
+
// SDK assignDomain takes `{ funnelId?: string }` and forwards it.
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
5
|
+
import { domain } from '@myapihq/sdk';
|
|
6
|
+
const API_KEY = 'myapi_test_abc';
|
|
7
|
+
const ORG_ID = '11111111-1111-4111-8111-111111111111';
|
|
8
|
+
const DOMAIN = 'example.com';
|
|
9
|
+
const FUNNEL_ID = '33333333-3333-4333-8333-333333333333';
|
|
10
|
+
let fetchMock;
|
|
11
|
+
function ok(data) {
|
|
12
|
+
return new Response(JSON.stringify({ success: true, data, meta: {} }), {
|
|
13
|
+
status: 200,
|
|
14
|
+
headers: { 'content-type': 'application/json' },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
fetchMock = vi.fn().mockResolvedValue(ok({
|
|
19
|
+
domain: DOMAIN, org_id: ORG_ID, include_www: true, routes_bound: [DOMAIN, `www.${DOMAIN}`],
|
|
20
|
+
}));
|
|
21
|
+
globalThis.fetch = fetchMock;
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.restoreAllMocks();
|
|
25
|
+
});
|
|
26
|
+
describe('domain.assignDomain — funnel_id (additive 2026-05-15)', () => {
|
|
27
|
+
it('omits funnel_id from body by default (back-compat)', async () => {
|
|
28
|
+
await domain.assignDomain(API_KEY, ORG_ID, DOMAIN);
|
|
29
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
30
|
+
expect(body).toEqual({ org_id: ORG_ID });
|
|
31
|
+
expect(body).not.toHaveProperty('funnel_id');
|
|
32
|
+
});
|
|
33
|
+
it('includes funnel_id when passed in opts', async () => {
|
|
34
|
+
await domain.assignDomain(API_KEY, ORG_ID, DOMAIN, { funnelId: FUNNEL_ID });
|
|
35
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
36
|
+
expect(body).toEqual({ org_id: ORG_ID, funnel_id: FUNNEL_ID });
|
|
37
|
+
});
|
|
38
|
+
it('combines funnel_id with include_www=false', async () => {
|
|
39
|
+
await domain.assignDomain(API_KEY, ORG_ID, DOMAIN, { funnelId: FUNNEL_ID, includeWww: false });
|
|
40
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
41
|
+
expect(body).toEqual({ org_id: ORG_ID, include_www: false, funnel_id: FUNNEL_ID });
|
|
42
|
+
});
|
|
43
|
+
it('omits funnel_id when it is empty string (treat as not-set)', async () => {
|
|
44
|
+
await domain.assignDomain(API_KEY, ORG_ID, DOMAIN, { funnelId: '' });
|
|
45
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
46
|
+
expect(body).not.toHaveProperty('funnel_id');
|
|
47
|
+
});
|
|
48
|
+
it('still works for unassignDomain (no funnel_id involved)', async () => {
|
|
49
|
+
await domain.unassignDomain(API_KEY, ORG_ID, DOMAIN);
|
|
50
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
51
|
+
expect(body).toEqual({ org_id: null });
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|