@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.
- package/dist/commands/domain.js +26 -7
- package/dist/commands/fn-validation.test.d.ts +1 -0
- package/dist/commands/fn-validation.test.js +60 -0
- package/dist/commands/fn.d.ts +13 -0
- package/dist/commands/fn.js +180 -0
- package/dist/commands/funnel.js +18 -5
- package/dist/commands/webhook-validation.test.d.ts +1 -0
- package/dist/commands/webhook-validation.test.js +35 -0
- package/dist/commands/webhook.d.ts +2 -0
- package/dist/commands/webhook.js +72 -2
- package/dist/commands/workflow-validation.test.d.ts +1 -0
- package/dist/commands/workflow-validation.test.js +137 -0
- package/dist/commands/workflow.d.ts +1 -0
- package/dist/commands/workflow.js +58 -17
- package/dist/exposes.test.js +1 -0
- package/dist/index.js +7 -0
- package/dist/sdk-domain-assign.test.d.ts +1 -0
- package/dist/sdk-domain-assign.test.js +53 -0
- package/dist/sdk-function.test.d.ts +1 -0
- package/dist/sdk-function.test.js +190 -0
- package/dist/sdk-funnel-name.test.d.ts +1 -0
- package/dist/sdk-funnel-name.test.js +48 -0
- package/dist/sdk-webhook.test.d.ts +1 -0
- package/dist/sdk-webhook.test.js +86 -0
- package/package.json +2 -2
package/dist/commands/domain.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
516
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/commands/funnel.js
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/commands/webhook.js
CHANGED
|
@@ -5,6 +5,7 @@ import { requireOrg } from '../helpers.js';
|
|
|
5
5
|
export const EXPOSES = [
|
|
6
6
|
'POST /webhook/orgs/{org_id}/endpoints',
|
|
7
7
|
'GET /webhook/orgs/{org_id}/endpoints',
|
|
8
|
+
'PATCH /webhook/orgs/{org_id}/endpoints/{endpoint_id}',
|
|
8
9
|
'DELETE /webhook/orgs/{org_id}/endpoints/{endpoint_id}',
|
|
9
10
|
'GET /webhook/orgs/{org_id}/deliveries/{delivery_id}',
|
|
10
11
|
];
|
|
@@ -13,6 +14,8 @@ export const SCHEMA = {
|
|
|
13
14
|
name: 'string',
|
|
14
15
|
description: 'string',
|
|
15
16
|
'crm-email-path': 'string',
|
|
17
|
+
// 2026-05-15: backend's POST/PATCH endpoints accept forward_url.
|
|
18
|
+
'forward-url': 'string',
|
|
16
19
|
};
|
|
17
20
|
export async function list(flags) {
|
|
18
21
|
const config = requireConfig();
|
|
@@ -29,21 +32,78 @@ export async function list(flags) {
|
|
|
29
32
|
}
|
|
30
33
|
export async function create(nameArg, flags) {
|
|
31
34
|
const config = requireConfig();
|
|
32
|
-
const orgId = requireOrg(flags, config, 'myapi webhook create <name> [--description <desc>] [--org <id>]');
|
|
35
|
+
const orgId = requireOrg(flags, config, 'myapi webhook create <name> [--description <desc>] [--crm-email-path <path>] [--forward-url <url>] [--org <id>]');
|
|
33
36
|
const name = nameArg || flags.name;
|
|
34
37
|
if (!name)
|
|
35
|
-
error('Missing required arguments.\nUsage: myapi webhook create <name> [--description <desc>] [--crm-email-path <path>] [--org <id>]\n or: myapi webhook create --name <name> [...]');
|
|
38
|
+
error('Missing required arguments.\nUsage: myapi webhook create <name> [--description <desc>] [--crm-email-path <path>] [--forward-url <url>] [--org <id>]\n or: myapi webhook create --name <name> [...]');
|
|
36
39
|
const opts = {};
|
|
37
40
|
if (typeof flags.description === 'string')
|
|
38
41
|
opts.description = flags.description;
|
|
39
42
|
if (typeof flags['crm-email-path'] === 'string')
|
|
40
43
|
opts.crm_email_path = flags['crm-email-path'];
|
|
44
|
+
if (typeof flags['forward-url'] === 'string') {
|
|
45
|
+
validateForwardUrl(flags['forward-url']);
|
|
46
|
+
opts.forward_url = flags['forward-url'];
|
|
47
|
+
}
|
|
41
48
|
const res = await sdkWebhook.createEndpoint(config.api_key, orgId, name, opts);
|
|
42
49
|
if (flags.json) {
|
|
43
50
|
printJson(res);
|
|
44
51
|
return;
|
|
45
52
|
}
|
|
46
53
|
success(`Webhook created! ID: ${res.id}\nInbound URL: ${res.url}`);
|
|
54
|
+
if (opts.forward_url)
|
|
55
|
+
info(`Forwarding to: ${opts.forward_url}`);
|
|
56
|
+
}
|
|
57
|
+
export async function update(id, flags) {
|
|
58
|
+
const config = requireConfig();
|
|
59
|
+
const orgId = requireOrg(flags, config, 'myapi webhook update <id> [--name <name>] [--description <desc>] [--crm-email-path <path>] [--forward-url <url>] [--org <id>]');
|
|
60
|
+
if (!id)
|
|
61
|
+
error('Missing required arguments.\nUsage: myapi webhook update <id> [--name <name>] [--description <desc>] [--crm-email-path <path>] [--forward-url <url>]');
|
|
62
|
+
const payload = {};
|
|
63
|
+
if (typeof flags.name === 'string')
|
|
64
|
+
payload.name = flags.name;
|
|
65
|
+
if (typeof flags.description === 'string')
|
|
66
|
+
payload.description = flags.description;
|
|
67
|
+
// crm-email-path & forward-url accept "" (empty string) to explicitly
|
|
68
|
+
// disable. Don't coerce to undefined — that would mean "leave as-is".
|
|
69
|
+
if (typeof flags['crm-email-path'] === 'string')
|
|
70
|
+
payload.crm_email_path = flags['crm-email-path'];
|
|
71
|
+
if (typeof flags['forward-url'] === 'string') {
|
|
72
|
+
const fu = flags['forward-url'];
|
|
73
|
+
if (fu !== '')
|
|
74
|
+
validateForwardUrl(fu);
|
|
75
|
+
payload.forward_url = fu;
|
|
76
|
+
}
|
|
77
|
+
if (Object.keys(payload).length === 0) {
|
|
78
|
+
error('Nothing to update. Provide at least one of --name, --description, --crm-email-path, --forward-url.');
|
|
79
|
+
}
|
|
80
|
+
const res = await sdkWebhook.updateEndpoint(config.api_key, orgId, id, payload);
|
|
81
|
+
if (flags.json) {
|
|
82
|
+
printJson(res);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
success(`Webhook ${res.id} updated`);
|
|
86
|
+
}
|
|
87
|
+
// Exported for unit tests. Returns null on success, error message on failure.
|
|
88
|
+
// Matches the backend's documented "Use http:// or https://" constraint.
|
|
89
|
+
export function _validateForwardUrl(url) {
|
|
90
|
+
if (url === '')
|
|
91
|
+
return null; // empty = unset; valid
|
|
92
|
+
try {
|
|
93
|
+
const u = new URL(url);
|
|
94
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
95
|
+
return `--forward-url must use http:// or https:// (got ${u.protocol})`;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return `--forward-url is not a valid URL: "${url}"`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function validateForwardUrl(url) {
|
|
104
|
+
const err = _validateForwardUrl(url);
|
|
105
|
+
if (err)
|
|
106
|
+
error(err);
|
|
47
107
|
}
|
|
48
108
|
export async function del(id, flags) {
|
|
49
109
|
const config = requireConfig();
|
|
@@ -74,6 +134,14 @@ CRM auto-ingest (optional):
|
|
|
74
134
|
Default 'email' (top-level). For Stripe:
|
|
75
135
|
'data.object.customer_email'. For GitHub:
|
|
76
136
|
'sender.email'. Empty string disables ingest.`,
|
|
137
|
+
'update': `myapi webhook update <id> [--name <name>] [--description <desc>] [--crm-email-path <path>] [--forward-url <url>] [--org <id>]
|
|
138
|
+
|
|
139
|
+
All fields optional — only the ones you pass get changed. Pass an empty
|
|
140
|
+
string to --crm-email-path or --forward-url to explicitly clear that
|
|
141
|
+
field (e.g., --forward-url "" disables forwarding without deleting the
|
|
142
|
+
endpoint).
|
|
143
|
+
|
|
144
|
+
Slug is immutable; create a new endpoint if you need a different slug.`,
|
|
77
145
|
'delete': 'myapi webhook delete <id> [--org <id>]',
|
|
78
146
|
'delivery': 'myapi webhook delivery <delivery_id> [--org <id>]',
|
|
79
147
|
};
|
|
@@ -84,6 +152,7 @@ export async function run(subcommand, args, flags) {
|
|
|
84
152
|
Subcommands:
|
|
85
153
|
list List all inbound webhook endpoints
|
|
86
154
|
create Create a new endpoint to receive data (returns an inbound URL)
|
|
155
|
+
update Patch endpoint name / description / crm-email-path / forward-url
|
|
87
156
|
delete Delete a webhook endpoint
|
|
88
157
|
delivery Inspect a specific webhook delivery (payload, received_at)
|
|
89
158
|
|
|
@@ -118,6 +187,7 @@ See the my-webhook-api skill for the full HTML + JS recipe.`);
|
|
|
118
187
|
switch (subcommand) {
|
|
119
188
|
case 'list': return list(flags);
|
|
120
189
|
case 'create': return create(args[0], flags);
|
|
190
|
+
case 'update': return update(args[0], flags);
|
|
121
191
|
case 'delete': return del(args[0], flags);
|
|
122
192
|
case 'delivery': return delivery(args[0], flags);
|
|
123
193
|
default: error(`Unknown subcommand: ${subcommand}. Run "myapi webhook --help" for a list of valid subcommands.`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|