@myapihq/cli 1.2.4 → 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.
@@ -50,6 +50,8 @@ export const SCHEMA = {
50
50
  yes: 'boolean',
51
51
  // Email-infra opt-in
52
52
  subdomain: 'string',
53
+ // Assign: opt out of binding the www Worker route alongside apex.
54
+ 'no-www': 'boolean',
53
55
  };
54
56
  export async function check(domainArg, flags) {
55
57
  const config = requireConfig();
@@ -159,11 +161,20 @@ function summarizeDomain(d) {
159
161
  }
160
162
  export async function assign(domainArg, flags) {
161
163
  const config = requireConfig();
162
- const orgId = requireOrg(flags, config, 'myapi domain assign <domain> [--org <id>]');
164
+ const orgId = requireOrg(flags, config, 'myapi domain assign <domain> [--no-www] [--org <id>]');
163
165
  if (!domainArg)
164
- error('Missing required arguments.\nUsage: myapi domain assign <domain> [--org <id>]');
165
- await sdkDomain.assignDomain(config.api_key, orgId, domainArg);
166
+ error('Missing required arguments.\nUsage: myapi domain assign <domain> [--no-www] [--org <id>]');
167
+ const includeWww = flags['no-www'] ? false : undefined; // omitted → backend default (true).
168
+ const res = await sdkDomain.assignDomain(config.api_key, orgId, domainArg, { includeWww });
169
+ if (flags.json) {
170
+ printJson(res);
171
+ return;
172
+ }
166
173
  success(`Assigned ${domainArg} to org ${orgId}`);
174
+ info(` Worker routes bound: ${res.routes_bound.join(' and ')}`);
175
+ if (res.include_www)
176
+ info(` www.${domainArg} 301-redirects to https://${domainArg}/ (canonical apex)`);
177
+ info(` Funnel will be live on https://${domainArg} once DNS propagates.`);
167
178
  }
168
179
  export async function unassign(domainArg, flags) {
169
180
  const config = requireConfig();
@@ -510,12 +521,20 @@ Charged against the org's billing balance — confirm with "myapi billing balanc
510
521
  before running.
511
522
 
512
523
  Track expiry afterward with: myapi domain status <domain>`,
513
- 'assign': `myapi domain assign <domain> [--org <id>]
524
+ 'assign': `myapi domain assign <domain> [--no-www] [--org <id>]
525
+
526
+ Assigns a registered or imported domain to an org. Backend binds Worker
527
+ routes for both <domain>/* and www.<domain>/* by default; www returns a 301
528
+ redirect to the apex (canonical URL). Once assigned, your funnel is served
529
+ at https://<domain> after DNS propagation completes.
514
530
 
515
- Assigns a registered domain to an org. Once assigned, your funnel is served at
516
- https://<domain> after DNS propagation completes.
531
+ --no-www Skip the www route binding. The www form will not resolve at the
532
+ edge unless you create your own DNS + Worker setup for it.
517
533
 
518
- To transfer to a different org: unassign first, then assign to the new org.`,
534
+ WARNING: This is also the reassign path. If you previously assigned <domain>
535
+ to a different org, re-running assign moves it. Pre-flight with:
536
+ myapi domain list --filter all
537
+ to confirm the current binding before reassigning.`,
519
538
  'unassign': 'myapi domain unassign <domain> [--org <id>]',
520
539
  'status': `myapi domain status <domain> [--org <id>] [--watch] [--json]
521
540
 
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
1
+ // Unit tests for fn.ts CLI's pure name validator. Mirrors the regex +
2
+ // reserved set in myapi-hq/internal/routes/function/crud.go so client-side
3
+ // rejection matches server-side rejection.
4
+ import { describe, it, expect } from 'vitest';
5
+ import { _validateName, NAME_RE, RESERVED_NAMES } from './fn.js';
6
+ describe('_validateName — pure helper', () => {
7
+ describe('valid names', () => {
8
+ it.each([
9
+ 'a', // single char ok
10
+ 'a1', // letters + digits
11
+ 'my-app', // hyphen ok
12
+ 'my-app-api',
13
+ '0', // digit-leading
14
+ '0app',
15
+ 'abcdefghijabcdefghijabcdefghijabcdefghijabcdefghij', // 50 chars (max)
16
+ 'fn-2026-05-15',
17
+ ])('accepts %s', (name) => {
18
+ expect(_validateName(name)).toBeNull();
19
+ });
20
+ });
21
+ describe('regex rejections', () => {
22
+ it.each([
23
+ ['empty string', ''],
24
+ ['UPPERCASE', 'BAD'],
25
+ ['mixed case', 'badName'],
26
+ ['underscore', 'my_app'],
27
+ ['leading hyphen', '-app'],
28
+ ['trailing hyphen ok but disallowed length too long', 'a'.repeat(51)],
29
+ ['spaces', 'my app'],
30
+ ['period', 'my.app'],
31
+ ['unicode', 'mōji'],
32
+ ['slash', 'a/b'],
33
+ ])('rejects %s (%s)', (_label, name) => {
34
+ expect(_validateName(name)).toMatch(/Invalid --name/);
35
+ });
36
+ });
37
+ describe('reserved names', () => {
38
+ it.each([...RESERVED_NAMES])('rejects reserved name %s', (name) => {
39
+ const err = _validateName(name);
40
+ expect(err).toMatch(/is reserved/);
41
+ });
42
+ it('regex check runs before reserved check (a value matching neither passes)', () => {
43
+ // 'ADMIN' fails the regex (uppercase) — not the reserved check.
44
+ expect(_validateName('ADMIN')).toMatch(/Invalid --name/);
45
+ });
46
+ });
47
+ describe('NAME_RE alignment with backend', () => {
48
+ it('matches the backend regex exactly: ^[a-z0-9][a-z0-9-]{0,49}$', () => {
49
+ // String equality on the source so any future drift surfaces here.
50
+ expect(NAME_RE.source).toBe('^[a-z0-9][a-z0-9-]{0,49}$');
51
+ });
52
+ });
53
+ describe('RESERVED_NAMES alignment with backend', () => {
54
+ it('matches the backend reservedNames map', () => {
55
+ // Sorted for stable comparison against the backend's list in
56
+ // myapi-hq/internal/routes/function/crud.go:21-28.
57
+ expect([...RESERVED_NAMES].sort()).toEqual(['admin', 'api', 'default', 'system', 'www']);
58
+ });
59
+ });
60
+ });
@@ -0,0 +1,13 @@
1
+ import type { FlagSchema } from '../flags.js';
2
+ import { type Flags } from '../helpers.js';
3
+ import type { Exposes } from '../exposes.js';
4
+ export declare const EXPOSES: Exposes;
5
+ export declare const SCHEMA: FlagSchema;
6
+ export declare const NAME_RE: RegExp;
7
+ export declare const RESERVED_NAMES: Set<string>;
8
+ export declare function _validateName(name: string): string | null;
9
+ export declare function create(flags: Flags): Promise<void>;
10
+ export declare function list(flags: Flags): Promise<void>;
11
+ export declare function get(id: string, flags: Flags): Promise<void>;
12
+ export declare function del(id: string, flags: Flags): Promise<void>;
13
+ export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
@@ -0,0 +1,180 @@
1
+ import { fn as sdkFn } from '@myapihq/sdk';
2
+ import { requireConfig } from '../config.js';
3
+ import { success, error, printTable, info, printJson, banner } from '../output.js';
4
+ import { requireOrg } from '../helpers.js';
5
+ export const EXPOSES = [
6
+ 'POST /function/orgs/{org_id}/functions',
7
+ 'GET /function/orgs/{org_id}/functions',
8
+ 'GET /function/orgs/{org_id}/functions/{id}',
9
+ 'DELETE /function/orgs/{org_id}/functions/{id}',
10
+ ];
11
+ export const SCHEMA = {
12
+ cron: 'string',
13
+ };
14
+ // Backend: Story 1. The function CRUD persists metadata + issues a scoped
15
+ // API key, but does NOT yet upload a JS bundle to Cloudflare Workers (Story
16
+ // 2). So this CLI surface is intentionally narrow — `create` registers a
17
+ // function, not "deploy" — keeping verb honesty until bundle upload ships.
18
+ // Mirrors validateName in myapi-hq/internal/routes/function/crud.go. We
19
+ // pre-validate client-side so typos fail before the network call; backend
20
+ // runs the same regex as defence in depth.
21
+ //
22
+ // Exported (alongside _validateNameOrThrow) so the unit test suite can hit
23
+ // the regex and reserved list directly without spawning a subprocess.
24
+ export const NAME_RE = /^[a-z0-9][a-z0-9-]{0,49}$/;
25
+ export const RESERVED_NAMES = new Set(['www', 'api', 'admin', 'system', 'default']);
26
+ // Returns an error message string on failure, or null on success. Pure —
27
+ // no I/O, no process.exit. The caller in this file wraps it in `error()`
28
+ // (which terminates); tests use the pure form.
29
+ export function _validateName(name) {
30
+ if (!NAME_RE.test(name)) {
31
+ return `Invalid --name "${name}". Lowercase letters, digits, hyphens; 1-50 chars; starts with a letter or digit.`;
32
+ }
33
+ if (RESERVED_NAMES.has(name)) {
34
+ return `Name "${name}" is reserved. Pick a different one.`;
35
+ }
36
+ return null;
37
+ }
38
+ function validateName(name) {
39
+ const err = _validateName(name);
40
+ if (err)
41
+ error(err);
42
+ }
43
+ function summarizeFn(f) {
44
+ return {
45
+ id: f.id,
46
+ name: f.name,
47
+ trigger: f.trigger_type === 'cron' ? `cron ${f.cron_schedule ?? '?'}` : 'http',
48
+ url: f.invocation_url || '(pending Story 2)',
49
+ updated_at: f.updated_at,
50
+ };
51
+ }
52
+ export async function create(flags) {
53
+ const config = requireConfig();
54
+ const orgId = requireOrg(flags, config, 'myapi fn create --name <name> [--cron <expr>] [--org <id>]');
55
+ const name = flags.name;
56
+ if (!name) {
57
+ error('Missing --name.\nUsage: myapi fn create --name <name> [--cron <expr>] [--org <id>]\n\n→ Name is a kebab-case slug, 1-50 chars (e.g. "my-app-api").');
58
+ }
59
+ validateName(name);
60
+ const cron = flags.cron;
61
+ const payload = {
62
+ name,
63
+ trigger_type: cron ? 'cron' : 'http',
64
+ };
65
+ if (cron)
66
+ payload.cron_schedule = cron;
67
+ const result = await sdkFn.createFunction(config.api_key, orgId, payload);
68
+ success(`Function created: ${result.function.id}`);
69
+ info(`Name: ${result.function.name}`);
70
+ info(`Trigger: ${result.function.trigger_type}${result.function.cron_schedule ? ` (${result.function.cron_schedule})` : ''}`);
71
+ // The scoped key is returned ONCE — surface it prominently. It's used by
72
+ // the function runtime shim (Story 2) to call other slot endpoints
73
+ // without a baked-in auth token.
74
+ info('');
75
+ info(`Scoped API key (returned once — save it if you need it):`);
76
+ info(` ${result.scoped_api_key}`);
77
+ info(` (id: ${result.scoped_api_key_id}; scopes: slot_call; rejected at /hq/*, /admin/*, /internal/*)`);
78
+ // Story 2 hasn't landed yet — be explicit about what "create" produces today.
79
+ if (!result.function.invocation_url) {
80
+ info('');
81
+ banner('Note: bundle upload (Story 2) is not yet shipped — this function has no executable code. The metadata record + scoped key are persisted; the invocation URL will appear once Story 2 lands.');
82
+ }
83
+ }
84
+ export async function list(flags) {
85
+ const config = requireConfig();
86
+ const orgId = requireOrg(flags, config, 'myapi fn list [--org <id>]');
87
+ const fns = await sdkFn.listFunctions(config.api_key, orgId);
88
+ if (flags.json) {
89
+ printJson(fns);
90
+ return;
91
+ }
92
+ printTable(fns.map(summarizeFn), {
93
+ flags,
94
+ empty: 'No functions yet. Create one with: myapi fn create --name <name>',
95
+ });
96
+ }
97
+ export async function get(id, flags) {
98
+ const config = requireConfig();
99
+ const orgId = requireOrg(flags, config, 'myapi fn get <id> [--org <id>]');
100
+ if (!id)
101
+ error('Missing id.\nUsage: myapi fn get <id>');
102
+ const fn = await sdkFn.getFunction(config.api_key, orgId, id);
103
+ if (flags.json) {
104
+ printJson(fn);
105
+ return;
106
+ }
107
+ info(`ID: ${fn.id}`);
108
+ info(`Name: ${fn.name}`);
109
+ info(`Trigger: ${fn.trigger_type}${fn.cron_schedule ? ` (${fn.cron_schedule})` : ''}`);
110
+ info(`Invocation URL: ${fn.invocation_url || '(pending Story 2)'}`);
111
+ info(`Created: ${fn.created_at}`);
112
+ info(`Updated: ${fn.updated_at}`);
113
+ }
114
+ export async function del(id, flags) {
115
+ const config = requireConfig();
116
+ const orgId = requireOrg(flags, config, 'myapi fn delete <id> [--org <id>]');
117
+ if (!id)
118
+ error('Missing id.\nUsage: myapi fn delete <id>');
119
+ await sdkFn.deleteFunction(config.api_key, orgId, id);
120
+ success(`Deleted function ${id}`);
121
+ }
122
+ // ── Dispatcher ───────────────────────────────────────────────────────────────
123
+ const SUBCOMMAND_USAGE = {
124
+ 'create': `myapi fn create --name <name> [--cron <expr>] [--org <id>]
125
+
126
+ Backend Story 1: persists a function record + issues a scoped API key.
127
+ Bundle upload (Story 2) is NOT yet shipped — this command does not deploy
128
+ JavaScript code today. When Story 2 lands, the CLI surface will grow to
129
+ accept --bundle <file.js>.
130
+
131
+ Triggers:
132
+ (default) HTTP — function will receive a public invocation URL in Story 2.
133
+ --cron <expr> Cron — function will run on the schedule (e.g. "0 8 * * *").
134
+
135
+ Examples:
136
+ myapi fn create --name my-app-api
137
+ myapi fn create --name daily-report --cron "0 8 * * *"
138
+
139
+ The response returns the scoped API key ONCE. Save it if you need to call
140
+ other MyAPI slots from a script with the function's permissions (scopes=slot_call;
141
+ rejected at /hq/*, /admin/*, /internal/*).`,
142
+ 'list': 'myapi fn list [--org <id>] [--json]',
143
+ 'get': 'myapi fn get <id> [--org <id>] [--json]',
144
+ 'delete': 'myapi fn delete <id> [--org <id>]',
145
+ };
146
+ export async function run(subcommand, args, flags) {
147
+ if (!subcommand || (flags.help && !subcommand)) {
148
+ info(`Usage: myapi fn <subcommand>
149
+
150
+ Create and manage functions on the MyAPI edge runtime.
151
+
152
+ Backend status: Story 1 shipped (metadata + scoped API key). Story 2 (CF
153
+ Workers upload, invocation URL, /logs, /env) lands soon. CLI verbs grow
154
+ once Story 2 ships; today only "create" / "list" / "get" / "delete" work.
155
+
156
+ Subcommands:
157
+ create Register a function and get its scoped API key (returned once)
158
+ list List functions in your org
159
+ get <id> Inspect a function
160
+ delete <id> Soft-delete and revoke its scoped API key
161
+
162
+ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
163
+ return;
164
+ }
165
+ if (flags.help) {
166
+ const usage = SUBCOMMAND_USAGE[subcommand];
167
+ if (usage)
168
+ info(`Usage: ${usage}`);
169
+ else
170
+ info(`Unknown subcommand: ${subcommand}. Run "myapi fn --help" for the list.`);
171
+ return;
172
+ }
173
+ switch (subcommand) {
174
+ case 'create': return create(flags);
175
+ case 'list': return list(flags);
176
+ case 'get': return get(args[0], flags);
177
+ case 'delete': return del(args[0], flags);
178
+ default: error(`Unknown subcommand: ${subcommand}. Run "myapi fn --help" for a list of valid subcommands.`);
179
+ }
180
+ }
@@ -14,10 +14,18 @@ export const EXPOSES = [
14
14
  'GET /hq/orgs/{org_id}',
15
15
  ];
16
16
  export const SCHEMA = {
17
- org: 'string',
18
17
  funnel: 'string',
19
18
  slug: 'string',
20
19
  };
20
+ // Backend (2026-05-15): POST /funnels accepts an optional `name` (defaults
21
+ // to the org's preview_subdomain for back-compat). Mirror the backend's
22
+ // validation client-side so typos fail before the network call.
23
+ const FUNNEL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,49}$/;
24
+ function validateFunnelName(name) {
25
+ if (!FUNNEL_NAME_RE.test(name)) {
26
+ error(`Invalid --name "${name}". Lowercase letters, digits, hyphens; 1-50 chars; starts with a letter or digit.`);
27
+ }
28
+ }
21
29
  export async function list(flags) {
22
30
  const config = requireConfig();
23
31
  const orgId = requireOrg(flags, config, 'myapi funnel list [--org <id>]');
@@ -39,9 +47,14 @@ export async function list(flags) {
39
47
  }
40
48
  export async function create(flags) {
41
49
  const config = requireConfig();
42
- const orgId = requireOrg(flags, config, 'myapi funnel create [--org <id>]');
43
- const result = await sdkFunnel.createFunnel(config.api_key, orgId);
50
+ const orgId = requireOrg(flags, config, 'myapi funnel create [--name <name>] [--org <id>]');
51
+ const name = flags.name;
52
+ if (name)
53
+ validateFunnelName(name);
54
+ const result = await sdkFunnel.createFunnel(config.api_key, orgId, name ? { name } : undefined);
44
55
  success(`Funnel created! ID: ${result.funnel.id}`);
56
+ if (result.funnel.name)
57
+ info(`Name: ${result.funnel.name}`);
45
58
  if (result.domain_url)
46
59
  info(`Live: ${result.domain_url}`);
47
60
  else if (result.subdomain_url)
@@ -183,7 +196,7 @@ export async function verify(slug, flags) {
183
196
  // ── Dispatcher ───────────────────────────────────────────────────────────────
184
197
  const SUBCOMMAND_USAGE = {
185
198
  'list': 'myapi funnel list [--org <id>] [--json]',
186
- 'create': 'myapi funnel create [--org <id>]',
199
+ 'create': 'myapi funnel create [--name <name>] [--org <id>]',
187
200
  'get': 'myapi funnel get <id> [--org <id>] [--json]',
188
201
  'delete': 'myapi funnel delete <id> [--org <id>]',
189
202
  'pages': 'myapi funnel pages [funnel_id] [--funnel <id>] [--org <id>] [--json]',
@@ -219,7 +232,7 @@ export async function run(subcommand, args, flags) {
219
232
 
220
233
  Subcommands:
221
234
  list List funnels
222
- create Create a funnel
235
+ create Create a funnel (optional --name; backend defaults to org's preview_subdomain)
223
236
  get <id> Get funnel details
224
237
  delete Delete a funnel
225
238
  push Push a raw HTML page from stdin to a slug
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ // Unit tests for the webhook CLI's forward_url validator (2026-05-15).
2
+ import { describe, it, expect } from 'vitest';
3
+ import { _validateForwardUrl } from './webhook.js';
4
+ describe('_validateForwardUrl', () => {
5
+ describe('accepts', () => {
6
+ it.each([
7
+ ['', 'empty string (means "unset")'],
8
+ ['https://example.com/forward', 'standard https'],
9
+ ['http://example.com/forward', 'http (non-TLS)'],
10
+ ['https://api.stripe.com', 'no path'],
11
+ ['https://example.com:8443/path?q=v#h', 'port, query, fragment'],
12
+ ])('accepts %s (%s)', (url) => {
13
+ expect(_validateForwardUrl(url)).toBeNull();
14
+ });
15
+ });
16
+ describe('rejects non-http(s) protocols', () => {
17
+ it.each([
18
+ ['ftp://example.com', 'ftp'],
19
+ ['javascript:alert(1)', 'javascript: scheme'],
20
+ ['file:///etc/passwd', 'file:'],
21
+ ['data:text/plain,foo', 'data:'],
22
+ ])('rejects %s (%s)', (url) => {
23
+ expect(_validateForwardUrl(url)).toMatch(/http:\/\/ or https:\/\//);
24
+ });
25
+ });
26
+ describe('rejects malformed URLs', () => {
27
+ it.each([
28
+ 'not a url',
29
+ 'http//missing-colon.example.com',
30
+ 'just-some-text',
31
+ ])('rejects %s', (url) => {
32
+ expect(_validateForwardUrl(url)).toMatch(/not a valid URL/);
33
+ });
34
+ });
35
+ });
@@ -5,6 +5,8 @@ export declare const EXPOSES: Exposes;
5
5
  export declare const SCHEMA: FlagSchema;
6
6
  export declare function list(flags: Flags): Promise<void>;
7
7
  export declare function create(nameArg: string | undefined, flags: Flags): Promise<void>;
8
+ export declare function update(id: string, flags: Flags): Promise<void>;
9
+ export declare function _validateForwardUrl(url: string): string | null;
8
10
  export declare function del(id: string, flags: Flags): Promise<void>;
9
11
  export declare function delivery(id: string, flags: Flags): Promise<void>;
10
12
  export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
@@ -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 {};