@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.
- package/dist/commands/domain.d.ts +2 -0
- package/dist/commands/domain.js +106 -9
- 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 +24 -4
- 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/dist/skills/my-domain-api/SKILL.md +10 -0
- package/package.json +2 -2
|
@@ -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>;
|
package/dist/commands/domain.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
460
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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>;
|