@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.
@@ -11,6 +11,8 @@ export declare function list(flags: Flags): Promise<void>;
11
11
  export declare function assign(domainArg: string, flags: Flags): Promise<void>;
12
12
  export declare function unassign(domainArg: string, flags: Flags): Promise<void>;
13
13
  export declare function status(domainArg: string, flags: Flags): Promise<void>;
14
+ export declare function emailSetup(domainArg: string, flags: Flags): Promise<void>;
15
+ export declare function retryProvisioning(domainArg: string, flags: Flags): Promise<void>;
14
16
  export declare function settings(domainArg: string, flags: Flags): Promise<void>;
15
17
  export declare function updateSettings(domainArg: string, flags: Flags): Promise<void>;
16
18
  export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
@@ -14,6 +14,8 @@ export const EXPOSES = [
14
14
  'GET /domain/orgs/{org_id}/{domain}/settings',
15
15
  'POST /domain/orgs/{org_id}/{domain}/settings',
16
16
  'GET /domain/orgs/{org_id}/{domain}/status',
17
+ 'POST /domain/orgs/{org_id}/{domain}/email-infra',
18
+ 'POST /domain/orgs/{org_id}/{domain}/retry-provisioning',
17
19
  ];
18
20
  export const SCHEMA = {
19
21
  org: 'string',
@@ -46,6 +48,10 @@ export const SCHEMA = {
46
48
  priority: 'number',
47
49
  proxied: 'boolean',
48
50
  yes: 'boolean',
51
+ // Email-infra opt-in
52
+ subdomain: 'string',
53
+ // Assign: opt out of binding the www Worker route alongside apex.
54
+ 'no-www': 'boolean',
49
55
  };
50
56
  export async function check(domainArg, flags) {
51
57
  const config = requireConfig();
@@ -155,11 +161,20 @@ function summarizeDomain(d) {
155
161
  }
156
162
  export async function assign(domainArg, flags) {
157
163
  const config = requireConfig();
158
- 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>]');
159
165
  if (!domainArg)
160
- error('Missing required arguments.\nUsage: myapi domain assign <domain> [--org <id>]');
161
- 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
+ }
162
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.`);
163
178
  }
164
179
  export async function unassign(domainArg, flags) {
165
180
  const config = requireConfig();
@@ -169,8 +184,10 @@ export async function unassign(domainArg, flags) {
169
184
  await sdkDomain.unassignDomain(config.api_key, orgId, domainArg);
170
185
  success(`Unassigned ${domainArg} from org ${orgId}`);
171
186
  }
172
- // Terminal states — stop polling under --watch.
173
- const TERMINAL_STATUSES = new Set(['active', 'failed', 'error', 'expired']);
187
+ // Terminal states — stop polling under --watch. `infra_error` is included
188
+ // because watching past it is pointless — the user has to act (retry or
189
+ // contact support); a re-watch after retry is the natural next step.
190
+ const TERMINAL_STATUSES = new Set(['active', 'failed', 'error', 'expired', 'infra_error']);
174
191
  export async function status(domainArg, flags) {
175
192
  const config = requireConfig();
176
193
  const orgId = requireOrg(flags, config, 'myapi domain status <domain> [--org <id>] [--watch]');
@@ -212,13 +229,63 @@ function renderStatus(res) {
212
229
  info(`Status: ${res.status}`);
213
230
  if (res.expires_at)
214
231
  info(`Expires: ${formatDate(res.expires_at)}`);
232
+ if (res.dns_active !== undefined)
233
+ info(`DNS: ${res.dns_active ? 'active' : 'pending'}`);
234
+ if (res.email_infra) {
235
+ const where = res.email_subdomain ? ` on ${res.email_subdomain}` : '';
236
+ info(`Email: ${res.email_infra}${where}`);
237
+ }
215
238
  if (res.status === 'pending_ns_change') {
216
239
  info('Waiting for you to change nameservers at your current registrar. Each poll re-checks Cloudflare.');
217
240
  }
241
+ if (res.status === 'infra_error' && res.error_detail) {
242
+ info(` Failed step: ${res.error_detail.failed_step}`);
243
+ info(` Message: ${res.error_detail.message}`);
244
+ if (res.error_detail.attempt_count !== undefined) {
245
+ const last = res.error_detail.last_attempt_at ? `; last try ${res.error_detail.last_attempt_at}` : '';
246
+ info(` Attempts: ${res.error_detail.attempt_count}${last}`);
247
+ }
248
+ if (res.error_detail.retryable) {
249
+ info(` → myapi domain retry-provisioning ${res.domain}`);
250
+ }
251
+ else {
252
+ info(' Not retryable — contact support.');
253
+ }
254
+ }
218
255
  if (res.status === 'active') {
219
256
  info('Note: if recently activated, the SSL certificate may still be provisioning — allow a few minutes before the site is reachable over HTTPS.');
220
257
  }
221
258
  }
259
+ // ── Email infra opt-in + retry-provisioning ──────────────────────────────────
260
+ export async function emailSetup(domainArg, flags) {
261
+ const config = requireConfig();
262
+ const orgId = requireOrg(flags, config, 'myapi domain email-setup <domain> [--subdomain <label>] [--org <id>]');
263
+ const domain = requireDomain(domainArg, flags, config, 'myapi domain email-setup <domain> [--subdomain <label>] [--org <id>]');
264
+ const subdomain = typeof flags.subdomain === 'string' ? flags.subdomain : undefined;
265
+ const res = await sdkDomain.setupEmailInfra(config.api_key, orgId, domain, subdomain);
266
+ if (flags.json) {
267
+ printJson(res);
268
+ return;
269
+ }
270
+ success(`Email infra provisioning on ${res.email_subdomain} (state: ${res.email_infra})`);
271
+ if (res.next_step)
272
+ info(res.next_step);
273
+ info(`Track with: myapi domain status ${domain} --watch`);
274
+ }
275
+ export async function retryProvisioning(domainArg, flags) {
276
+ const config = requireConfig();
277
+ const orgId = requireOrg(flags, config, 'myapi domain retry-provisioning <domain> [--org <id>]');
278
+ const domain = requireDomain(domainArg, flags, config, 'myapi domain retry-provisioning <domain> [--org <id>]');
279
+ const res = await sdkDomain.retryProvisioning(config.api_key, orgId, domain);
280
+ if (flags.json) {
281
+ printJson(res);
282
+ return;
283
+ }
284
+ success(`Retry triggered for ${res.domain}`);
285
+ if (res.next_step)
286
+ info(res.next_step);
287
+ info(`Track with: myapi domain status ${domain} --watch`);
288
+ }
222
289
  export async function settings(domainArg, flags) {
223
290
  const config = requireConfig();
224
291
  const orgId = requireOrg(flags, config, 'myapi domain settings <domain> [--org <id>]');
@@ -454,12 +521,20 @@ Charged against the org's billing balance — confirm with "myapi billing balanc
454
521
  before running.
455
522
 
456
523
  Track expiry afterward with: myapi domain status <domain>`,
457
- 'assign': `myapi domain assign <domain> [--org <id>]
524
+ 'assign': `myapi domain assign <domain> [--no-www] [--org <id>]
458
525
 
459
- Assigns a registered domain to an org. Once assigned, your funnel is served at
460
- https://<domain> after DNS propagation completes.
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.
461
530
 
462
- To transfer to a different org: unassign first, then assign to the new org.`,
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.
533
+
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.`,
463
538
  'unassign': 'myapi domain unassign <domain> [--org <id>]',
464
539
  'status': `myapi domain status <domain> [--org <id>] [--watch] [--json]
465
540
 
@@ -472,6 +547,24 @@ To transfer to a different org: unassign first, then assign to the new org.`,
472
547
  Gets current edge/CDN settings (security level, browser check, cache).
473
548
  Use "myapi domain update-settings" to change them.`,
474
549
  'records': RECORDS_HELP,
550
+ 'email-setup': `myapi domain email-setup <domain> [--subdomain <label>] [--org <id>]
551
+
552
+ Opts in to MyAPI-managed outbound email on a subdomain of the domain.
553
+ The apex is never touched — this is safe to run on a domain whose apex
554
+ email is served by Google Workspace / Microsoft 365 / your existing provider.
555
+
556
+ Defaults the subdomain to "mail" (i.e. mail.<domain>). Pass --subdomain to
557
+ override (e.g. --subdomain=notifications for notifications.<domain>).
558
+
559
+ Backend provisions an SES identity on <subdomain>.<domain> and writes
560
+ DKIM / SPF / DMARC records to the CF zone. Track readiness via:
561
+ myapi domain status <domain> --watch`,
562
+ 'retry-provisioning': `myapi domain retry-provisioning <domain> [--org <id>]
563
+
564
+ Re-runs domain provisioning when status=infra_error and error_detail.retryable=true.
565
+ The CLI surfaces the retry hint automatically when status renders infra_error.
566
+
567
+ After triggering retry, poll: myapi domain status <domain> --watch`,
475
568
  'update-settings': `myapi domain update-settings <domain> [--security=<level>] [--browser-check=on|off] [--purge-cache] [--org <id>]
476
569
 
477
570
  --security=<level> essentially_off | low | medium | high | under_attack (default: medium)
@@ -497,6 +590,8 @@ Subcommands:
497
590
  settings Get edge/CDN settings
498
591
  update-settings Update edge/CDN settings
499
592
  records Manage DNS records in the zone (list / get / create / update / delete)
593
+ email-setup Opt in to MyAPI-managed email on a subdomain (default: mail.<domain>)
594
+ retry-provisioning Re-run provisioning when status=infra_error and the failure is retryable
500
595
 
501
596
  Tip: Set defaults with "myapi config set-org <id>" / "set-domain <domain>" to skip flags on every command.`);
502
597
  return;
@@ -521,6 +616,8 @@ Tip: Set defaults with "myapi config set-org <id>" / "set-domain <domain>" to sk
521
616
  case 'settings': return settings(args[0], flags);
522
617
  case 'update-settings': return updateSettings(args[0], flags);
523
618
  case 'records': return recordsRun(args[0], args.slice(1), flags);
619
+ case 'email-setup': return emailSetup(args[0], flags);
620
+ case 'retry-provisioning': return retryProvisioning(args[0], flags);
524
621
  default: error(`Unknown subcommand: ${subcommand}. Run "myapi domain --help" for a list of valid subcommands.`);
525
622
  }
526
623
  }
@@ -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>;