@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.
@@ -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
- const SUPPORTED_STEP_TYPES = ['send_email', 'email', 'slack_message', 'slack'];
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
- function validateSteps(steps) {
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
- error('--steps must be a JSON array of step objects.');
43
+ return '--steps must be a JSON array of step objects.';
35
44
  if (steps.length === 0)
36
- error('--steps cannot be an empty array.');
37
- steps.forEach((s, i) => {
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
- error(`${where}: must be a JSON object.`);
50
+ return `${where}: must be a JSON object.`;
41
51
  if (!s.type)
42
- error(`${where}: missing required field "type". Supported: ${SUPPORTED_STEP_TYPES.join(', ')}`);
52
+ return `${where}: missing required field "type". Supported: ${SUPPORTED_STEP_TYPES.join(', ')}`;
43
53
  if (!SUPPORTED_STEP_TYPES.includes(s.type)) {
44
- error(`${where}: unknown type "${s.type}". Supported: ${SUPPORTED_STEP_TYPES.join(', ')}`);
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
- error(`${where} (${s.type}): missing required field "${f}".`);
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
- error(`${where} (${s.type}): must include exactly one of "body", "html", or "template_id".`);
65
+ return `${where} (${s.type}): must include exactly one of "body", "html", or "template_id".`;
56
66
  }
57
67
  if (bodyForms.length > 1) {
58
- error(`${where} (${s.type}): can only use one of "body", "html", "template_id" — got: ${bodyForms.join(', ')}.`);
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
- error(`${where} (${s.type}): "template_vars" only makes sense with "template_id".`);
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
- error(`${where} (${s.type}): "template_vars" must be a JSON object.`);
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
- error(`${where} (${s.type}): missing required field "webhook_url".`);
79
+ return `${where} (${s.type}): missing required field "webhook_url".`;
70
80
  }
71
81
  if (!SLACK_HOOK_RE.test(s.webhook_url)) {
72
- error(`${where} (${s.type}): "webhook_url" must look like https://hooks.slack.com/services/T.../B.../xxx — got "${s.webhook_url}".`);
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
- error(`${where} (${s.type}): missing required field "text".`);
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 {
@@ -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. The backend will surface a `cf_message` field with the underlying detail.',
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(code) {
97
- return ERROR_MESSAGES[code] || code;
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.code) || err.message);
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 {};