@myapihq/cli 1.2.4 → 1.2.6

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.
Files changed (42) hide show
  1. package/dist/commands/billing.d.ts +1 -0
  2. package/dist/commands/billing.js +61 -2
  3. package/dist/commands/domain.js +31 -7
  4. package/dist/commands/fn-validation.test.d.ts +1 -0
  5. package/dist/commands/fn-validation.test.js +60 -0
  6. package/dist/commands/fn.d.ts +16 -0
  7. package/dist/commands/fn.js +271 -0
  8. package/dist/commands/funnel.d.ts +1 -0
  9. package/dist/commands/funnel.js +101 -5
  10. package/dist/commands/keys-validation.test.d.ts +1 -0
  11. package/dist/commands/keys-validation.test.js +87 -0
  12. package/dist/commands/keys.d.ts +6 -2
  13. package/dist/commands/keys.js +189 -55
  14. package/dist/commands/payments-validation.test.d.ts +1 -0
  15. package/dist/commands/payments-validation.test.js +31 -0
  16. package/dist/commands/payments.d.ts +13 -0
  17. package/dist/commands/payments.js +219 -0
  18. package/dist/commands/webhook-validation.test.d.ts +1 -0
  19. package/dist/commands/webhook-validation.test.js +35 -0
  20. package/dist/commands/webhook.d.ts +2 -0
  21. package/dist/commands/webhook.js +72 -2
  22. package/dist/commands/workflow-validation.test.d.ts +1 -0
  23. package/dist/commands/workflow-validation.test.js +137 -0
  24. package/dist/commands/workflow.d.ts +1 -0
  25. package/dist/commands/workflow.js +58 -17
  26. package/dist/exposes.test.js +2 -0
  27. package/dist/index.js +14 -0
  28. package/dist/sdk-domain-assign.test.d.ts +1 -0
  29. package/dist/sdk-domain-assign.test.js +53 -0
  30. package/dist/sdk-function.test.d.ts +1 -0
  31. package/dist/sdk-function.test.js +257 -0
  32. package/dist/sdk-funnel-name.test.d.ts +1 -0
  33. package/dist/sdk-funnel-name.test.js +48 -0
  34. package/dist/sdk-funnel-publish.test.d.ts +1 -0
  35. package/dist/sdk-funnel-publish.test.js +89 -0
  36. package/dist/sdk-iam.test.d.ts +1 -0
  37. package/dist/sdk-iam.test.js +190 -0
  38. package/dist/sdk-payments.test.d.ts +1 -0
  39. package/dist/sdk-payments.test.js +139 -0
  40. package/dist/sdk-webhook.test.d.ts +1 -0
  41. package/dist/sdk-webhook.test.js +86 -0
  42. package/package.json +4 -2
@@ -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,8 @@ const COMMAND_MODULES = [
37
37
  './commands/url.js',
38
38
  './commands/webhook.js',
39
39
  './commands/workflow.js',
40
+ './commands/fn.js',
41
+ './commands/payments.js',
40
42
  ];
41
43
  const ENDPOINT_PATTERN = /^(GET|POST|PATCH|PUT|DELETE) \/[A-Za-z0-9_\-./{}]*$/;
42
44
  describe('every CLI command exports a typed EXPOSES array (S-101)', () => {
package/dist/index.js CHANGED
@@ -29,6 +29,8 @@ 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';
33
+ import * as paymentsCmd from './commands/payments.js';
32
34
  import { initCompletion, installCompletion, uninstallCompletion } from './completion.js';
33
35
  // Each command file declares the value flags it understands. We union them
34
36
  // into a single schema for the upfront parse, so adding a new value flag in
@@ -55,6 +57,8 @@ const COMBINED_SCHEMA = {
55
57
  ...urlCmd.SCHEMA,
56
58
  ...webhookCmd.SCHEMA,
57
59
  ...workflowCmd.SCHEMA,
60
+ ...fnCmd.SCHEMA,
61
+ ...paymentsCmd.SCHEMA,
58
62
  // Top-level flags
59
63
  version: 'boolean',
60
64
  V: 'boolean',
@@ -197,6 +201,12 @@ async function main() {
197
201
  case 'url':
198
202
  await urlCmd.run(subcommand, restArgs, flags);
199
203
  break;
204
+ case 'fn':
205
+ await fnCmd.run(subcommand, restArgs, flags);
206
+ break;
207
+ case 'payments':
208
+ await paymentsCmd.run(subcommand, restArgs, flags);
209
+ break;
200
210
  // Convenience aliases
201
211
  case 'setup':
202
212
  await setupCmd.setup(flags);
@@ -317,6 +327,8 @@ const HELP_TARGETS = {
317
327
  llm: f => llmCmd.run(undefined, [], f),
318
328
  database: f => databaseCmd.run(undefined, [], f),
319
329
  crm: f => crmCmd.run(undefined, [], f),
330
+ fn: f => fnCmd.run(undefined, [], f),
331
+ payments: f => paymentsCmd.run(undefined, [], f),
320
332
  org: f => orgCmd.run(undefined, [], f),
321
333
  billing: f => billingCmd.run(undefined, [], f),
322
334
  keys: f => keysCmd.run(undefined, [], f),
@@ -358,6 +370,8 @@ Commands:
358
370
  update Update CLI and skills to the latest version
359
371
  domain Manage domain configurations
360
372
  funnel Manage websites (publish pages, custom domains, funnels)
373
+ fn Create and deploy functions on the edge runtime
374
+ payments Take payments with Stripe Checkout (connect, charge, refund)
361
375
  webhook Manage inbound webhook endpoints and inspect deliveries
362
376
  email Manage mailboxes, send/read email, templates, and campaigns
363
377
  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 {};
@@ -0,0 +1,257 @@
1
+ // SDK-level unit tests for the function module. Verifies request URL/body
2
+ // shape, response parsing, and error envelopes against the Story 1 contract
3
+ // shipped by myapi-hq/internal/routes/function/crud.go.
4
+ //
5
+ // Mocks global fetch — does NOT hit the network.
6
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
7
+ import { fn, MyApiError } from '@myapihq/sdk';
8
+ const API_KEY = 'myapi_test_abc';
9
+ const ORG_ID = '11111111-1111-4111-8111-111111111111';
10
+ const FN_ID = '22222222-2222-4222-8222-222222222222';
11
+ let fetchMock;
12
+ function ok(data, status = 200) {
13
+ // Backend wraps everything in `{ success, data, meta }`. The SDK unwraps.
14
+ return new Response(JSON.stringify({ success: true, data, meta: {} }), {
15
+ status,
16
+ headers: { 'content-type': 'application/json' },
17
+ });
18
+ }
19
+ function fail(code, message, status = 422) {
20
+ return new Response(JSON.stringify({ success: false, error: { code, message }, meta: {} }), {
21
+ status,
22
+ headers: { 'content-type': 'application/json' },
23
+ });
24
+ }
25
+ beforeEach(() => {
26
+ fetchMock = vi.fn();
27
+ globalThis.fetch = fetchMock;
28
+ });
29
+ afterEach(() => {
30
+ vi.restoreAllMocks();
31
+ });
32
+ describe('fn.createFunction', () => {
33
+ it('POSTs to /function/orgs/{org_id}/functions with the bearer auth + JSON body', async () => {
34
+ fetchMock.mockResolvedValueOnce(ok({
35
+ function: { id: FN_ID, org_id: ORG_ID, name: 'my-app-api', trigger_type: 'http', invocation_url: '', created_at: 't1', updated_at: 't1' },
36
+ scoped_api_key: 'hq_live_scoped_xyz',
37
+ scoped_api_key_id: 'key_xyz',
38
+ }));
39
+ const result = await fn.createFunction(API_KEY, ORG_ID, { name: 'my-app-api', trigger_type: 'http' });
40
+ expect(fetchMock).toHaveBeenCalledOnce();
41
+ const [url, init] = fetchMock.mock.calls[0];
42
+ expect(url).toContain(`/function/orgs/${ORG_ID}/functions`);
43
+ expect(init.method).toBe('POST');
44
+ expect(init.headers.Authorization).toBe(`Bearer ${API_KEY}`);
45
+ expect(init.headers['Content-Type']).toBe('application/json');
46
+ expect(JSON.parse(init.body)).toEqual({ name: 'my-app-api', trigger_type: 'http' });
47
+ expect(result.function.id).toBe(FN_ID);
48
+ expect(result.scoped_api_key).toBe('hq_live_scoped_xyz');
49
+ expect(result.scoped_api_key_id).toBe('key_xyz');
50
+ // Invocation URL is empty in Story 1 — populated by Story 2.
51
+ expect(result.function.invocation_url).toBe('');
52
+ });
53
+ it('passes cron_schedule through when trigger_type=cron', async () => {
54
+ fetchMock.mockResolvedValueOnce(ok({
55
+ function: { id: FN_ID, org_id: ORG_ID, name: 'daily-report', trigger_type: 'cron', cron_schedule: '0 8 * * *', invocation_url: '', created_at: 't1', updated_at: 't1' },
56
+ scoped_api_key: 'hq_live_scoped_xyz',
57
+ scoped_api_key_id: 'key_xyz',
58
+ }));
59
+ await fn.createFunction(API_KEY, ORG_ID, { name: 'daily-report', trigger_type: 'cron', cron_schedule: '0 8 * * *' });
60
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
61
+ expect(body).toEqual({ name: 'daily-report', trigger_type: 'cron', cron_schedule: '0 8 * * *' });
62
+ });
63
+ it('encodes path params (defensive against : ? / # in org id)', async () => {
64
+ // UUIDs don't contain reserved chars in practice — but the SDK still
65
+ // encodes, so an alternate id with unsafe chars wouldn't break path
66
+ // routing.
67
+ fetchMock.mockResolvedValueOnce(ok({
68
+ function: { id: FN_ID, org_id: 'org/with?weird#chars', name: 'x', trigger_type: 'http', invocation_url: '', created_at: 't', updated_at: 't' },
69
+ scoped_api_key: 'k', scoped_api_key_id: 'i',
70
+ }));
71
+ await fn.createFunction(API_KEY, 'org/with?weird#chars', { name: 'x' });
72
+ const [url] = fetchMock.mock.calls[0];
73
+ expect(url).toContain('/function/orgs/org%2Fwith%3Fweird%23chars/functions');
74
+ });
75
+ it('surfaces backend INVALID_NAME (422) as a typed MyApiError', async () => {
76
+ fetchMock.mockResolvedValueOnce(fail('INVALID_NAME', 'name must match ^[a-z0-9][a-z0-9-]{0,49}$', 422));
77
+ await expect(fn.createFunction(API_KEY, ORG_ID, { name: 'BAD NAME' }))
78
+ .rejects.toMatchObject({
79
+ name: 'MyApiError',
80
+ code: 'INVALID_NAME',
81
+ status: 422,
82
+ });
83
+ });
84
+ it('surfaces NAME_TAKEN (409) cleanly', async () => {
85
+ fetchMock.mockResolvedValueOnce(fail('NAME_TAKEN', 'function name "x" is already used in this org', 409));
86
+ await expect(fn.createFunction(API_KEY, ORG_ID, { name: 'x' }))
87
+ .rejects.toMatchObject({ code: 'NAME_TAKEN', status: 409 });
88
+ });
89
+ it('surfaces 401 (invalid api key)', async () => {
90
+ fetchMock.mockResolvedValueOnce(fail('unauthorized', 'invalid api key', 401));
91
+ await expect(fn.createFunction(API_KEY, ORG_ID, { name: 'x' }))
92
+ .rejects.toMatchObject({ status: 401 });
93
+ });
94
+ it('surfaces 403 SCOPE_FORBIDDEN when called with a slot_call-only key', async () => {
95
+ // 2026-05-15 enforcement: scoped api_keys with scopes=["slot_call"]
96
+ // can only call slot endpoints, not /hq/*, /admin/*, /internal/*.
97
+ // Note: /function/* IS a slot path, so SCOPE_FORBIDDEN shouldn't trigger
98
+ // here in practice — but the SDK must still surface it cleanly if it
99
+ // ever appears (e.g. for /hq/* mistakes).
100
+ fetchMock.mockResolvedValueOnce(fail('SCOPE_FORBIDDEN', 'this key cannot call /hq/* endpoints', 403));
101
+ await expect(fn.createFunction(API_KEY, ORG_ID, { name: 'x' }))
102
+ .rejects.toMatchObject({ code: 'SCOPE_FORBIDDEN', status: 403 });
103
+ });
104
+ it('wraps non-JSON 5xx responses (HTML error pages) helpfully', async () => {
105
+ fetchMock.mockResolvedValueOnce(new Response('<html>502 Bad Gateway</html>', {
106
+ status: 502, headers: { 'content-type': 'text/html' },
107
+ }));
108
+ await expect(fn.createFunction(API_KEY, ORG_ID, { name: 'x' }))
109
+ .rejects.toMatchObject({ code: 'invalid_json_response', status: 502 });
110
+ });
111
+ });
112
+ describe('fn.listFunctions', () => {
113
+ it('GETs /function/orgs/{org_id}/functions with bearer auth + no body', async () => {
114
+ fetchMock.mockResolvedValueOnce(ok([
115
+ { id: FN_ID, org_id: ORG_ID, name: 'a', trigger_type: 'http', invocation_url: '', created_at: 't', updated_at: 't' },
116
+ ]));
117
+ const fns = await fn.listFunctions(API_KEY, ORG_ID);
118
+ expect(fns).toHaveLength(1);
119
+ expect(fns[0].name).toBe('a');
120
+ const [url, init] = fetchMock.mock.calls[0];
121
+ expect(url).toContain(`/function/orgs/${ORG_ID}/functions`);
122
+ expect(init.method).toBe('GET');
123
+ expect(init.body).toBeUndefined();
124
+ });
125
+ it('returns an empty array when org has no functions', async () => {
126
+ fetchMock.mockResolvedValueOnce(ok([]));
127
+ expect(await fn.listFunctions(API_KEY, ORG_ID)).toEqual([]);
128
+ });
129
+ });
130
+ describe('fn.getFunction', () => {
131
+ it('GETs /function/orgs/{org_id}/functions/{id}', async () => {
132
+ fetchMock.mockResolvedValueOnce(ok({ id: FN_ID, org_id: ORG_ID, name: 'a', trigger_type: 'http', invocation_url: '', created_at: 't', updated_at: 't' }));
133
+ const got = await fn.getFunction(API_KEY, ORG_ID, FN_ID);
134
+ expect(got.id).toBe(FN_ID);
135
+ const [url] = fetchMock.mock.calls[0];
136
+ expect(url).toContain(`/function/orgs/${ORG_ID}/functions/${FN_ID}`);
137
+ });
138
+ it('throws function_not_found on 404', async () => {
139
+ fetchMock.mockResolvedValueOnce(fail('function_not_found', 'not found', 404));
140
+ await expect(fn.getFunction(API_KEY, ORG_ID, FN_ID))
141
+ .rejects.toMatchObject({ code: 'function_not_found', status: 404 });
142
+ });
143
+ });
144
+ describe('fn.deleteFunction', () => {
145
+ it('DELETEs /function/orgs/{org_id}/functions/{id}', async () => {
146
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
147
+ await fn.deleteFunction(API_KEY, ORG_ID, FN_ID);
148
+ const [url, init] = fetchMock.mock.calls[0];
149
+ expect(url).toContain(`/function/orgs/${ORG_ID}/functions/${FN_ID}`);
150
+ expect(init.method).toBe('DELETE');
151
+ });
152
+ });
153
+ describe('fn.uploadBundle', () => {
154
+ it('POSTs multipart to /bundle with the `bundle` field + bearer auth', async () => {
155
+ fetchMock.mockResolvedValueOnce(ok({
156
+ function: { id: FN_ID, org_id: ORG_ID, name: 'my-app-api', trigger_type: 'http', invocation_url: 'https://x.fn.myapihq.com', created_at: 't', updated_at: 't' },
157
+ invocation_url: 'https://x.fn.myapihq.com',
158
+ scoped_api_key: 'hq_live_rotated_abc',
159
+ }));
160
+ const result = await fn.uploadBundle(API_KEY, ORG_ID, FN_ID, 'export default {}', 'app.js');
161
+ const [url, init] = fetchMock.mock.calls[0];
162
+ expect(url).toContain(`/function/orgs/${ORG_ID}/functions/${FN_ID}/bundle`);
163
+ expect(init.method).toBe('POST');
164
+ expect(init.headers.Authorization).toBe(`Bearer ${API_KEY}`);
165
+ // multipart — body is FormData, no JSON Content-Type set by the SDK.
166
+ expect(init.body).toBeInstanceOf(FormData);
167
+ expect(init.body.has('bundle')).toBe(true);
168
+ expect(result.invocation_url).toBe('https://x.fn.myapihq.com');
169
+ expect(result.scoped_api_key).toBe('hq_live_rotated_abc');
170
+ });
171
+ it('surfaces BUNDLE_TOO_LARGE (413) as a typed MyApiError', async () => {
172
+ fetchMock.mockResolvedValueOnce(fail('BUNDLE_TOO_LARGE', 'bundle exceeds 4MB', 413));
173
+ await expect(fn.uploadBundle(API_KEY, ORG_ID, FN_ID, 'x'))
174
+ .rejects.toMatchObject({ code: 'BUNDLE_TOO_LARGE', status: 413 });
175
+ });
176
+ it('surfaces RUNTIME_UNAVAILABLE (503)', async () => {
177
+ fetchMock.mockResolvedValueOnce(fail('RUNTIME_UNAVAILABLE', 'cf not configured', 503));
178
+ await expect(fn.uploadBundle(API_KEY, ORG_ID, FN_ID, 'x'))
179
+ .rejects.toMatchObject({ code: 'RUNTIME_UNAVAILABLE', status: 503 });
180
+ });
181
+ });
182
+ describe('fn.setFunctionEnv', () => {
183
+ it('POSTs {name,value} JSON and resolves on 204', async () => {
184
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
185
+ await fn.setFunctionEnv(API_KEY, ORG_ID, FN_ID, 'STRIPE_KEY', 'sk_live_x');
186
+ const [url, init] = fetchMock.mock.calls[0];
187
+ expect(url).toContain(`/function/orgs/${ORG_ID}/functions/${FN_ID}/env`);
188
+ expect(init.method).toBe('POST');
189
+ expect(JSON.parse(init.body)).toEqual({ name: 'STRIPE_KEY', value: 'sk_live_x' });
190
+ });
191
+ it('surfaces NOT_DEPLOYED (409) when the function has no bundle yet', async () => {
192
+ fetchMock.mockResolvedValueOnce(fail('NOT_DEPLOYED', 'deploy a bundle first', 409));
193
+ await expect(fn.setFunctionEnv(API_KEY, ORG_ID, FN_ID, 'K', 'v'))
194
+ .rejects.toMatchObject({ code: 'NOT_DEPLOYED', status: 409 });
195
+ });
196
+ });
197
+ describe('fn.listFunctionRuns', () => {
198
+ it('GETs /runs and returns the run records', async () => {
199
+ fetchMock.mockResolvedValueOnce(ok([
200
+ { id: 'run_1', function_id: FN_ID, invoked_at: 't', duration_ms: 12, status: 'ok' },
201
+ { id: 'run_2', function_id: FN_ID, invoked_at: 't', status: 'error', error_message: 'boom' },
202
+ ]));
203
+ const runs = await fn.listFunctionRuns(API_KEY, ORG_ID, FN_ID);
204
+ expect(runs).toHaveLength(2);
205
+ expect(runs[1].error_message).toBe('boom');
206
+ const [url, init] = fetchMock.mock.calls[0];
207
+ expect(url).toContain(`/function/orgs/${ORG_ID}/functions/${FN_ID}/runs`);
208
+ expect(init.method).toBe('GET');
209
+ });
210
+ it('returns an empty array when there are no runs', async () => {
211
+ fetchMock.mockResolvedValueOnce(ok([]));
212
+ expect(await fn.listFunctionRuns(API_KEY, ORG_ID, FN_ID)).toEqual([]);
213
+ });
214
+ });
215
+ describe('fn.EXPOSES', () => {
216
+ it('matches the Story 1 + Story 2/4/5 contract — exactly 7 endpoints', () => {
217
+ expect(fn.EXPOSES).toEqual([
218
+ 'POST /function/orgs/{org_id}/functions',
219
+ 'GET /function/orgs/{org_id}/functions',
220
+ 'GET /function/orgs/{org_id}/functions/{id}',
221
+ 'DELETE /function/orgs/{org_id}/functions/{id}',
222
+ 'POST /function/orgs/{org_id}/functions/{id}/bundle',
223
+ 'POST /function/orgs/{org_id}/functions/{id}/env',
224
+ 'GET /function/orgs/{org_id}/functions/{id}/runs',
225
+ ]);
226
+ });
227
+ it('exposes deploy/env/runs but not /logs (still pending)', () => {
228
+ const flat = fn.EXPOSES.join(' ');
229
+ expect(flat).toContain('/bundle');
230
+ expect(flat).toContain('/env');
231
+ expect(flat).toContain('/runs');
232
+ expect(flat).not.toContain('/logs');
233
+ });
234
+ });
235
+ // MyApiError is the universal error shape callers can `instanceof`-check or
236
+ // switch on `.code`. Validate it surfaces the right fields from typed
237
+ // (object) errors and raw-string errors alike.
238
+ describe('MyApiError surfacing', () => {
239
+ it('preserves the body object on object-shaped errors (for CF_API_ERROR etc.)', async () => {
240
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
241
+ success: false,
242
+ error: { code: 'CF_API_ERROR', message: 'cf failed', cf_message: 'Forbidden zone', cf_status: 403 },
243
+ meta: {},
244
+ }), { status: 500, headers: { 'content-type': 'application/json' } }));
245
+ try {
246
+ await fn.createFunction(API_KEY, ORG_ID, { name: 'x' });
247
+ expect.fail('Should have thrown');
248
+ }
249
+ catch (e) {
250
+ expect(e).toBeInstanceOf(MyApiError);
251
+ const err = e;
252
+ expect(err.code).toBe('CF_API_ERROR');
253
+ expect(err.body?.cf_message).toBe('Forbidden zone');
254
+ expect(err.body?.cf_status).toBe(403);
255
+ }
256
+ });
257
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,48 @@
1
+ // 2026-05-15: POST /funnel/orgs/{org_id}/funnels now accepts optional
2
+ // `name` (defaults server-side to the org's preview_subdomain). SDK
3
+ // createFunnel was extended to forward { name } through.
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5
+ import { funnel } from '@myapihq/sdk';
6
+ const API_KEY = 'myapi_test';
7
+ const ORG_ID = '11111111-1111-4111-8111-111111111111';
8
+ let fetchMock;
9
+ function ok(data) {
10
+ return new Response(JSON.stringify({ success: true, data, meta: {} }), {
11
+ status: 200,
12
+ headers: { 'content-type': 'application/json' },
13
+ });
14
+ }
15
+ beforeEach(() => {
16
+ fetchMock = vi.fn().mockResolvedValue(ok({
17
+ funnel: { id: 'f1', org_id: ORG_ID, name: 'my-app', created_at: 't', updated_at: 't' },
18
+ subdomain_url: 'https://my-app.makeautonomous.com',
19
+ }));
20
+ globalThis.fetch = fetchMock;
21
+ });
22
+ afterEach(() => { vi.restoreAllMocks(); });
23
+ describe('funnel.createFunnel — name (additive 2026-05-15)', () => {
24
+ it('omits name from body when not provided (back-compat — server picks preview_subdomain)', async () => {
25
+ await funnel.createFunnel(API_KEY, ORG_ID);
26
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({});
27
+ });
28
+ it('omits name when opts is provided but has no name', async () => {
29
+ await funnel.createFunnel(API_KEY, ORG_ID, {});
30
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({});
31
+ });
32
+ it('includes name when provided', async () => {
33
+ await funnel.createFunnel(API_KEY, ORG_ID, { name: 'my-app' });
34
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ name: 'my-app' });
35
+ });
36
+ it('hits the right path and method', async () => {
37
+ await funnel.createFunnel(API_KEY, ORG_ID, { name: 'x' });
38
+ const [url, init] = fetchMock.mock.calls[0];
39
+ expect(url).toContain(`/funnel/orgs/${ORG_ID}/funnels`);
40
+ expect(init.method).toBe('POST');
41
+ expect(init.headers.Authorization).toBe(`Bearer ${API_KEY}`);
42
+ });
43
+ it('returns the funnel with name populated', async () => {
44
+ const result = await funnel.createFunnel(API_KEY, ORG_ID, { name: 'my-app' });
45
+ expect(result.funnel.name).toBe('my-app');
46
+ expect(result.subdomain_url).toBe('https://my-app.makeautonomous.com');
47
+ });
48
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,89 @@
1
+ // SDK-level unit tests for funnel.publishFiles — the my-funnel-api v2
2
+ // directory publish. Verifies the multipart shape (all parts named `files`,
3
+ // each part's filename = the site path), the form fields, and response
4
+ // parsing against myapi-hq/internal/routes/funnel/files.go.
5
+ //
6
+ // Mocks global fetch — does NOT hit the network.
7
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
8
+ import { funnel } from '@myapihq/sdk';
9
+ const API_KEY = 'myapi_test_abc';
10
+ const ORG_ID = '11111111-1111-4111-8111-111111111111';
11
+ const FUNNEL_ID = '44444444-4444-4444-8444-444444444444';
12
+ let fetchMock;
13
+ function ok(data, status = 201) {
14
+ return new Response(JSON.stringify({ success: true, data, meta: {} }), {
15
+ status,
16
+ headers: { 'content-type': 'application/json' },
17
+ });
18
+ }
19
+ function fail(code, message, status = 422) {
20
+ return new Response(JSON.stringify({ success: false, error: { code, message }, meta: {} }), {
21
+ status,
22
+ headers: { 'content-type': 'application/json' },
23
+ });
24
+ }
25
+ beforeEach(() => {
26
+ fetchMock = vi.fn();
27
+ globalThis.fetch = fetchMock;
28
+ });
29
+ afterEach(() => {
30
+ vi.restoreAllMocks();
31
+ });
32
+ describe('funnel.publishFiles', () => {
33
+ it('POSTs multipart to /files with one `files` part per file + bearer auth', async () => {
34
+ fetchMock.mockResolvedValueOnce(ok({
35
+ manifest_id: 'm1', channel: 'prod', file_count: 2, size_bytes: 42,
36
+ spa_mode: true, published_url: 'https://site.makeautonomous.com',
37
+ }));
38
+ const res = await funnel.publishFiles(API_KEY, ORG_ID, FUNNEL_ID, [
39
+ { path: 'index.html', content: '<h1>hi</h1>' },
40
+ { path: 'assets/app.js', content: 'console.log(1)' },
41
+ ]);
42
+ const [url, init] = fetchMock.mock.calls[0];
43
+ expect(url).toContain(`/funnel/orgs/${ORG_ID}/funnels/${FUNNEL_ID}/files`);
44
+ expect(init.method).toBe('POST');
45
+ expect(init.headers.Authorization).toBe(`Bearer ${API_KEY}`);
46
+ expect(init.body).toBeInstanceOf(FormData);
47
+ // Both files share the part name `files`.
48
+ expect(init.body.getAll('files')).toHaveLength(2);
49
+ expect(res.file_count).toBe(2);
50
+ expect(res.published_url).toBe('https://site.makeautonomous.com');
51
+ });
52
+ it('includes env / spa_mode / api_function_id form fields when given', async () => {
53
+ fetchMock.mockResolvedValueOnce(ok({
54
+ manifest_id: 'm1', channel: 'dev', file_count: 1, size_bytes: 10,
55
+ spa_mode: false, published_url: 'https://site-dev.makeautonomous.com',
56
+ }));
57
+ await funnel.publishFiles(API_KEY, ORG_ID, FUNNEL_ID, [{ path: 'index.html', content: 'x' }], { env: 'dev', spaMode: false, apiFunctionId: 'fn_1' });
58
+ const body = fetchMock.mock.calls[0][1].body;
59
+ expect(body.get('env')).toBe('dev');
60
+ expect(body.get('spa_mode')).toBe('false');
61
+ expect(body.get('api_function_id')).toBe('fn_1');
62
+ });
63
+ it('omits optional form fields when not given', async () => {
64
+ fetchMock.mockResolvedValueOnce(ok({
65
+ manifest_id: 'm1', channel: 'prod', file_count: 1, size_bytes: 10,
66
+ spa_mode: true, published_url: 'https://site.makeautonomous.com',
67
+ }));
68
+ await funnel.publishFiles(API_KEY, ORG_ID, FUNNEL_ID, [{ path: 'index.html', content: 'x' }]);
69
+ const body = fetchMock.mock.calls[0][1].body;
70
+ expect(body.has('env')).toBe(false);
71
+ expect(body.has('spa_mode')).toBe(false);
72
+ expect(body.has('api_function_id')).toBe(false);
73
+ });
74
+ it('surfaces NO_FILES (422) as a typed MyApiError', async () => {
75
+ fetchMock.mockResolvedValueOnce(fail('NO_FILES', 'files field required', 422));
76
+ await expect(funnel.publishFiles(API_KEY, ORG_ID, FUNNEL_ID, []))
77
+ .rejects.toMatchObject({ code: 'NO_FILES', status: 422 });
78
+ });
79
+ it('surfaces publish_too_large (413)', async () => {
80
+ fetchMock.mockResolvedValueOnce(fail('publish_too_large', 'exceeds 25MB', 413));
81
+ await expect(funnel.publishFiles(API_KEY, ORG_ID, FUNNEL_ID, [{ path: 'big', content: 'x' }]))
82
+ .rejects.toMatchObject({ code: 'publish_too_large', status: 413 });
83
+ });
84
+ });
85
+ describe('funnel.EXPOSES includes the v2 publish endpoint', () => {
86
+ it('lists POST .../files', () => {
87
+ expect(funnel.EXPOSES).toContain('POST /funnel/orgs/{org_id}/funnels/{funnel_id}/files');
88
+ });
89
+ });
@@ -0,0 +1 @@
1
+ export {};