@myapihq/cli 1.3.8 → 1.3.12

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.
@@ -0,0 +1,6 @@
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 SCHEMA: FlagSchema;
5
+ export declare const EXPOSES: Exposes;
6
+ export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
@@ -0,0 +1,75 @@
1
+ // `myapi account` — account-scoped operations.
2
+ //
3
+ // Today: mailing-address (CAN-SPAM compliance for transactional email).
4
+ // Future home for any other account-level reads/writes that don't fit
5
+ // `billing` (which owns money) or `keys` (which owns auth credentials).
6
+ import { hq as sdkHq } from '@myapihq/sdk';
7
+ import { requireConfig } from '../config.js';
8
+ import { success, error, info, printJson } from '../output.js';
9
+ export const SCHEMA = {};
10
+ export const EXPOSES = [
11
+ 'GET /hq/account/mailing-address',
12
+ 'PATCH /hq/account/mailing-address',
13
+ ];
14
+ const SUBCOMMAND_USAGE = {
15
+ 'mailing-address': `myapi account mailing-address Show current value (null if unset)
16
+ myapi account mailing-address "<address>" Set the mailing address
17
+
18
+ The mailing address is required for transactional email (CAN-SPAM):
19
+ \`email message send\` returns MAILING_ADDRESS_REQUIRED until this is set.
20
+ Pass the full postal address as a single string — e.g.
21
+ "123 Main St, Springfield, IL 62701, USA".`,
22
+ };
23
+ async function mailingAddress(args, flags) {
24
+ const config = requireConfig();
25
+ // Positional arg present → set. Absent → get.
26
+ const value = args[0];
27
+ if (value === undefined) {
28
+ const res = await sdkHq.getMailingAddress(config.api_key);
29
+ if (flags.json) {
30
+ printJson(res);
31
+ return;
32
+ }
33
+ if (res.mailing_address) {
34
+ info(`Mailing address: ${res.mailing_address}`);
35
+ }
36
+ else {
37
+ info('Mailing address: (not set)');
38
+ info('Set it with: myapi account mailing-address "<address>"');
39
+ info('Required for: myapi email message send (CAN-SPAM).');
40
+ }
41
+ return;
42
+ }
43
+ // Reject obviously bad inputs early — the backend would 400 anyway.
44
+ const trimmed = value.trim();
45
+ if (!trimmed) {
46
+ error('Mailing address must be a non-empty string. Example: "123 Main St, Springfield, IL 62701, USA".');
47
+ }
48
+ const res = await sdkHq.setMailingAddress(config.api_key, trimmed);
49
+ if (flags.json) {
50
+ printJson(res);
51
+ return;
52
+ }
53
+ success(`Mailing address set: ${res.mailing_address}`);
54
+ }
55
+ export async function run(subcommand, args, flags) {
56
+ if (!subcommand || (flags.help && !subcommand)) {
57
+ info(`Usage: myapi account <subcommand>
58
+
59
+ Subcommands:
60
+ mailing-address Get or set the account's mailing address (CAN-SPAM)`);
61
+ return;
62
+ }
63
+ if (flags.help) {
64
+ const usage = SUBCOMMAND_USAGE[subcommand];
65
+ if (usage)
66
+ info(`Usage: ${usage}`);
67
+ else
68
+ info(`Unknown subcommand: ${subcommand}. Run "myapi account --help" for the list.`);
69
+ return;
70
+ }
71
+ switch (subcommand) {
72
+ case 'mailing-address': return mailingAddress(args, flags);
73
+ default: error(`Unknown subcommand: ${subcommand}. Run "myapi account --help" for the list.`);
74
+ }
75
+ }
@@ -51,8 +51,8 @@ export async function setFunnel(id, _flags, via = 'auth config') {
51
51
  if (!orgId)
52
52
  error('No default organization set. Run: myapi auth config set-org <id>');
53
53
  info('› Validating…');
54
- const rawFunnel = await sdkFunnel.getFunnel(config.api_key, orgId, id);
55
- const funnelId = rawFunnel.funnel?.id ?? rawFunnel.id;
54
+ const { funnel } = await sdkFunnel.getFunnel(config.api_key, orgId, id);
55
+ const funnelId = funnel.id;
56
56
  config.default_funnel = funnelId;
57
57
  saveConfig(config);
58
58
  success(`Default funnel set to: ${funnelId}`);
@@ -5,6 +5,10 @@ import { success, error, info, printTable, printJson } from '../../output.js';
5
5
  import { requireOrg, requireArg } from '../../helpers.js';
6
6
  export const EXPOSES = [
7
7
  'POST /crm/orgs/{org_id}/companies',
8
+ // Backend (2026-06-06): bare GET list with query-param filters —
9
+ // companion to the POST /search verb. See contacts.ts for the same
10
+ // pattern.
11
+ 'GET /crm/orgs/{org_id}/companies',
8
12
  'POST /crm/orgs/{org_id}/companies/promote',
9
13
  'POST /crm/orgs/{org_id}/companies/search',
10
14
  'GET /crm/orgs/{org_id}/companies/{id}',
@@ -5,6 +5,11 @@ import { success, error, info, printTable, printJson } from '../../output.js';
5
5
  import { requireOrg, requireArg } from '../../helpers.js';
6
6
  export const EXPOSES = [
7
7
  'POST /crm/orgs/{org_id}/contacts',
8
+ // Backend (2026-06-06): bare GET list with query-param filters —
9
+ // companion to the POST /search verb (richer filters via body). The
10
+ // CLI `list` verb still uses /search for forward-compat; the bare GET
11
+ // is declared so it's covered.
12
+ 'GET /crm/orgs/{org_id}/contacts',
8
13
  'POST /crm/orgs/{org_id}/contacts/promote',
9
14
  'POST /crm/orgs/{org_id}/contacts/search',
10
15
  'GET /crm/orgs/{org_id}/contacts/{id}',
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ // Unit tests for the doctor's `setup` section augmentation (2026-06-05).
2
+ // Exercises the re-interpretation of backend sections — when the
3
+ // `domains` or `emails` sections come back empty (zero-state), the
4
+ // `setup` augmentation should escalate to actionable warns.
5
+ import { describe, it, expect } from 'vitest';
6
+ import { _setupSection } from './doctor.js';
7
+ function mkReport(sections) {
8
+ return {
9
+ org_id: 'org-test',
10
+ generated_at: '2026-06-05T00:00:00Z',
11
+ sections,
12
+ totals: { ok: 0, warn: 0, crit: 0 },
13
+ };
14
+ }
15
+ function empty(name) {
16
+ return { name, summary: 'no resources', issues: [] };
17
+ }
18
+ function populated(name) {
19
+ return { name, summary: 'all checks passed', issues: [{
20
+ id: 'x', severity: 'ok', scope: name, message: 'something',
21
+ }] };
22
+ }
23
+ describe('_setupSection', () => {
24
+ it('returns null when both domains + emails sections are populated', () => {
25
+ expect(_setupSection(mkReport([populated('domains'), populated('emails')]))).toBeNull();
26
+ });
27
+ it('emits both warns when both sections are empty', () => {
28
+ const s = _setupSection(mkReport([empty('domains'), empty('emails')]));
29
+ expect(s).not.toBeNull();
30
+ expect(s.issues.map(i => i.severity)).toEqual(['warn', 'warn']);
31
+ expect(s.issues[0].message).toMatch(/No domain registered/);
32
+ expect(s.issues[1].message).toMatch(/No email inbox configured/);
33
+ expect(s.summary).toBe('2 setup gaps');
34
+ });
35
+ it('emits one warn when only emails is empty', () => {
36
+ const s = _setupSection(mkReport([populated('domains'), empty('emails')]));
37
+ expect(s).not.toBeNull();
38
+ expect(s.issues).toHaveLength(1);
39
+ expect(s.issues[0].scope).toBe('setup/email-inbox');
40
+ expect(s.summary).toBe('1 setup gap');
41
+ });
42
+ it('emits one warn when only domains is empty', () => {
43
+ const s = _setupSection(mkReport([empty('domains'), populated('emails')]));
44
+ expect(s).not.toBeNull();
45
+ expect(s.issues).toHaveLength(1);
46
+ expect(s.issues[0].scope).toBe('setup/domain');
47
+ });
48
+ it('returns null when the backend omits both sections entirely', () => {
49
+ // If a future backend drops these sections, we shouldn't fabricate
50
+ // warns out of nothing. Silence is the safe default.
51
+ expect(_setupSection(mkReport([populated('funnels')]))).toBeNull();
52
+ });
53
+ it('attaches actionable hints', () => {
54
+ const s = _setupSection(mkReport([empty('domains'), empty('emails')]));
55
+ expect(s.issues[0].hint).toMatch(/myapi domain register/);
56
+ expect(s.issues[1].hint).toMatch(/myapi email mailbox create/);
57
+ });
58
+ it('emits a mailing-address warn when ctx.mailingAddress is null', () => {
59
+ const s = _setupSection(mkReport([populated('domains'), populated('emails')]), { mailingAddress: null });
60
+ expect(s).not.toBeNull();
61
+ expect(s.issues).toHaveLength(1);
62
+ expect(s.issues[0].scope).toBe('setup/mailing-address');
63
+ expect(s.issues[0].hint).toMatch(/myapi account mailing-address/);
64
+ });
65
+ it('does NOT emit mailing-address warn when set', () => {
66
+ const s = _setupSection(mkReport([populated('domains'), populated('emails')]), { mailingAddress: '1 Test St, Paris' });
67
+ expect(s).toBeNull();
68
+ });
69
+ it('stays silent when mailing-address fetch failed (undefined)', () => {
70
+ // undefined = "unknown, infra blip" — don't false-alarm.
71
+ const s = _setupSection(mkReport([populated('domains'), populated('emails')]), { mailingAddress: undefined });
72
+ expect(s).toBeNull();
73
+ });
74
+ });
@@ -1,6 +1,11 @@
1
+ import { hq as sdkHq } from '@myapihq/sdk';
1
2
  import type { FlagSchema } from '../flags.js';
2
3
  import { type Flags } from '../helpers.js';
3
4
  import type { Exposes } from '../exposes.js';
4
5
  export declare const EXPOSES: Exposes;
5
6
  export declare const SCHEMA: FlagSchema;
7
+ export interface SetupContext {
8
+ mailingAddress?: string | null;
9
+ }
10
+ export declare function _setupSection(report: sdkHq.DoctorReport, ctx?: SetupContext): sdkHq.DoctorSection | null;
6
11
  export declare function run(_subcommand: string | undefined, _args: string[], flags: Flags): Promise<void>;
@@ -27,6 +27,8 @@ export const EXPOSES = [
27
27
  'GET /container/orgs/{org_id}/containers',
28
28
  'GET /funnel/orgs/{org_id}/funnels',
29
29
  'GET /funnel/orgs/{org_id}/funnels/{funnel_id}/pages',
30
+ // Best-effort read for the setup-gap mailing_address check (CAN-SPAM).
31
+ 'GET /hq/account/mailing-address',
30
32
  ];
31
33
  export const SCHEMA = {};
32
34
  const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
@@ -58,6 +60,54 @@ function fmtIssue(i) {
58
60
  const head = ` ${MARK[i.severity] ?? '·'} ${label}${i.message}`;
59
61
  return i.hint ? `${head}\n ${C.dim}→ ${i.hint}${C.reset}` : head;
60
62
  }
63
+ export function _setupSection(report, ctx = {}) {
64
+ const byName = new Map();
65
+ for (const s of report.sections)
66
+ byName.set(s.name, s);
67
+ const issues = [];
68
+ const dom = byName.get('domains');
69
+ if (dom && dom.issues.length === 0) {
70
+ issues.push({
71
+ id: localIssueId('setup_no_domain', report.org_id),
72
+ severity: 'warn',
73
+ scope: 'setup/domain',
74
+ category: 'setup',
75
+ message: 'No domain registered for this org',
76
+ hint: 'Register one with: myapi domain register <domain> && myapi domain assign <domain>',
77
+ });
78
+ }
79
+ const em = byName.get('emails');
80
+ if (em && em.issues.length === 0) {
81
+ issues.push({
82
+ id: localIssueId('setup_no_mailbox', report.org_id),
83
+ severity: 'warn',
84
+ scope: 'setup/email-inbox',
85
+ category: 'setup',
86
+ message: 'No email inbox configured',
87
+ hint: 'Create one with: myapi email mailbox create <username>@<your-domain>',
88
+ });
89
+ }
90
+ // Account-scoped (so use report.org_id only for the dedup id, not as
91
+ // entity scope). Hard-gates every transactional `email send` —
92
+ // backend returns MAILING_ADDRESS_REQUIRED until set.
93
+ if (ctx.mailingAddress === null) {
94
+ issues.push({
95
+ id: localIssueId('setup_no_mailing_address', report.org_id),
96
+ severity: 'warn',
97
+ scope: 'setup/mailing-address',
98
+ category: 'setup',
99
+ message: 'Account has no mailing address (CAN-SPAM)',
100
+ hint: 'Set one with: myapi account mailing-address "<full postal address>"',
101
+ });
102
+ }
103
+ if (issues.length === 0)
104
+ return null;
105
+ return {
106
+ name: 'setup',
107
+ summary: `${issues.length} setup gap${issues.length === 1 ? '' : 's'}`,
108
+ issues,
109
+ };
110
+ }
61
111
  // Local DNS-resolution probe for every distinct domain the report names.
62
112
  // The backend can verify domain provisioning state from its own egress;
63
113
  // this checks whether the operator's network reaches them today — a
@@ -207,7 +257,26 @@ export async function run(_subcommand, _args, flags) {
207
257
  error(`doctor endpoint failed: ${e?.message ?? String(e)}`);
208
258
  }
209
259
  // Local augmentation, appended after the backend's sections by design —
210
- // see the file header on why client-observed checks stay grouped at the end.
260
+ // see the file header on why client-observed checks stay grouped at the
261
+ // end. `setup` re-interprets the backend's own sections (zero-state →
262
+ // warn) and comes first; `local network` + `reachability` are liveness
263
+ // probes that follow.
264
+ //
265
+ // Best-effort fetch the account-scoped mailing_address — `null` if
266
+ // unset, `undefined` if the call fails (transient / permissions / 404
267
+ // on older backends). `null` triggers the warn; `undefined` is silent
268
+ // so we don't false-alarm on infra blips.
269
+ let mailingAddress;
270
+ try {
271
+ const res = await sdkHq.getMailingAddress(apiKey);
272
+ mailingAddress = res?.mailing_address ?? null;
273
+ }
274
+ catch {
275
+ mailingAddress = undefined;
276
+ }
277
+ const setup = _setupSection(report, { mailingAddress });
278
+ if (setup)
279
+ report.sections.push(setup);
211
280
  const localSection = await dnsProbeSection(report);
212
281
  if (localSection)
213
282
  report.sections.push(localSection);
@@ -266,6 +335,9 @@ Sections returned by the backend today:
266
335
  funnels, webhooks, workflows, domains, containers, emails, payments
267
336
 
268
337
  Local additions:
338
+ setup — Flags zero-state config the backend currently reports as
339
+ "no resources": no domain registered, no email inbox.
340
+ Warnings, not critical — the org may not need them yet.
269
341
  local network — DNS resolution from your egress for each domain mentioned.
270
342
  reachability — HTTP GET to every container URL and funnel page; surfaces
271
343
  the status code. Failures are warnings, never critical —
@@ -13,6 +13,7 @@ export declare function unassign(domainArg: string, flags: Flags): Promise<void>
13
13
  export declare function status(domainArg: string, flags: Flags): Promise<void>;
14
14
  export declare function emailSetup(domainArg: string, flags: Flags): Promise<void>;
15
15
  export declare function retryProvisioning(domainArg: string, flags: Flags): Promise<void>;
16
+ export declare function mailServerResync(domainArg: string, flags: Flags): Promise<void>;
16
17
  export declare function settings(domainArg: string, flags: Flags): Promise<void>;
17
18
  export declare function updateSettings(domainArg: string, flags: Flags): Promise<void>;
18
19
  export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
@@ -1,4 +1,4 @@
1
- import { domain as sdkDomain } from '@myapihq/sdk';
1
+ import { domain as sdkDomain, email as sdkEmail } from '@myapihq/sdk';
2
2
  import { requireConfig } from '../config.js';
3
3
  import { success, error, info, printTable, printJson } from '../output.js';
4
4
  import { formatDate } from '../utils.js';
@@ -16,6 +16,12 @@ export const EXPOSES = [
16
16
  'GET /domain/orgs/{org_id}/{domain}/status',
17
17
  'POST /domain/orgs/{org_id}/{domain}/email-infra',
18
18
  'POST /domain/orgs/{org_id}/{domain}/retry-provisioning',
19
+ // Mail-server (Stalwart) reprovisioning — distinct from DNS/cert
20
+ // retry-provisioning above. Call when mailbox-create returns
21
+ // DOMAIN_NOT_MAIL_READY / MAILBOX_PROVISION_FAILED, or when inbox
22
+ // reads return "mail server error" on a domain whose
23
+ // email_infra_ready=true.
24
+ 'POST /email/orgs/{org_id}/domains/{domain}/mail-server-resync',
19
25
  'GET /domain/orgs/{org_id}/{domain}/records',
20
26
  'POST /domain/orgs/{org_id}/{domain}/records',
21
27
  'GET /domain/orgs/{org_id}/{domain}/records/{record_id}',
@@ -312,6 +318,24 @@ export async function retryProvisioning(domainArg, flags) {
312
318
  info(res.next_step);
313
319
  info(`Track with: myapi domain status ${domain} --watch`);
314
320
  }
321
+ // Re-runs mail-server (Stalwart) provisioning for a domain. Distinct from
322
+ // retry-provisioning above — that re-runs the DNS / cert side; this one
323
+ // re-runs the IMAP/SMTP / mail-store side. Idempotent. Use when:
324
+ // - `email mailbox create` returns DOMAIN_NOT_MAIL_READY / MAILBOX_PROVISION_FAILED
325
+ // - `email message inbox <addr>` returns "mail server error" on a domain
326
+ // whose `email_infra_ready=true` according to `domain list`.
327
+ export async function mailServerResync(domainArg, flags) {
328
+ const config = requireConfig();
329
+ const orgId = requireOrg(flags, config, 'myapi domain mail-server-resync <domain> [--org <id>]');
330
+ const domain = requireDomain(domainArg, flags, config, 'myapi domain mail-server-resync <domain> [--org <id>]');
331
+ const res = await sdkEmail.mailServerResync(config.api_key, orgId, domain);
332
+ if (flags.json) {
333
+ printJson(res);
334
+ return;
335
+ }
336
+ success(`Mail-server resync triggered for ${domain}`);
337
+ info('Retry mailbox-create / inbox reads in a few seconds.');
338
+ }
315
339
  export async function settings(domainArg, flags) {
316
340
  const config = requireConfig();
317
341
  const orgId = requireOrg(flags, config, 'myapi domain settings <domain> [--org <id>]');
@@ -591,6 +615,17 @@ Re-runs domain provisioning when status=infra_error and error_detail.retryable=t
591
615
  The CLI surfaces the retry hint automatically when status renders infra_error.
592
616
 
593
617
  After triggering retry, poll: myapi domain status <domain> --watch`,
618
+ 'mail-server-resync': `myapi domain mail-server-resync <domain> [--org <id>]
619
+
620
+ Re-runs the mail-server (Stalwart) provisioning for a domain. Distinct
621
+ from "retry-provisioning" — that re-runs the DNS / cert side; this one
622
+ re-runs the IMAP / SMTP / mail-store side. Idempotent.
623
+
624
+ Use when:
625
+ - "email mailbox create" returns DOMAIN_NOT_MAIL_READY or MAILBOX_PROVISION_FAILED
626
+ - "email message inbox <addr>" returns "mail server error" but
627
+ "domain list" shows email_infra_ready=true for that domain
628
+ - The fix-path called out in backend-email-postdeploy-findings-2026-06-05`,
594
629
  'update-settings': `myapi domain update-settings <domain> [--security=<level>] [--browser-check=on|off] [--purge-cache] [--org <id>]
595
630
 
596
631
  --security=<level> essentially_off | low | medium | high | under_attack (default: medium)
@@ -610,6 +645,7 @@ Subcommands:
610
645
  email-setup Opt in to MyAPI-managed email on a subdomain (default: mail.<domain>)
611
646
  import Bring your own domain (BYOD) — registrar-agnostic
612
647
  list List domains
648
+ mail-server-resync Re-run Stalwart mail-server provisioning for a domain (fixes inbox/create on broken domains)
613
649
  records Manage DNS records in the zone (list / get / create / update / delete)
614
650
  register Register a domain
615
651
  renew Renew a registered domain for another period
@@ -643,6 +679,7 @@ Tip: Set defaults with "myapi config set-org <id>" / "set-domain <domain>" to sk
643
679
  case 'update-settings': return updateSettings(args[0], flags);
644
680
  case 'records': return recordsRun(args[0], args.slice(1), flags);
645
681
  case 'email-setup': return emailSetup(args[0], flags);
682
+ case 'mail-server-resync': return mailServerResync(args[0], flags);
646
683
  case 'retry-provisioning': return retryProvisioning(args[0], flags);
647
684
  default: error(`Unknown subcommand: ${subcommand}. Run "myapi domain --help" for a list of valid subcommands.`);
648
685
  }
@@ -4,6 +4,7 @@ import { success, error, printTable, info } from '../../output.js';
4
4
  export const EXPOSES = [
5
5
  'POST /email/mailboxes/create',
6
6
  'GET /email/mailboxes',
7
+ 'DELETE /email/mailboxes/{address}',
7
8
  'PUT /email/mailboxes/{address}/forwarding',
8
9
  'DELETE /email/mailboxes/{address}/forwarding',
9
10
  'POST /email/sending/activate',
@@ -70,6 +71,17 @@ async function clearForwarding(address, _flags) {
70
71
  await sdkEmail.deleteForwarding(config.api_key, address);
71
72
  success(`Forwarding cleared for ${address}`);
72
73
  }
74
+ async function del(address, _flags) {
75
+ const config = requireConfig();
76
+ if (!address)
77
+ error('Missing required arguments.\nUsage: myapi email mailbox delete <user@domain>');
78
+ // Backend is idempotent — 204 whether or not the mailbox existed. So we
79
+ // don't pre-check existence; just call delete and report success. The
80
+ // 409 MAILBOX_IN_USE case bubbles up as an API error with a clear
81
+ // pointer to pause the campaign first.
82
+ await sdkEmail.deleteMailbox(config.api_key, address);
83
+ success(`Mailbox deleted: ${address}`);
84
+ }
73
85
  const USAGE = {
74
86
  'create': `myapi email mailbox create <user@domain> [--display-name <name>]
75
87
  myapi email mailbox create --username <u> --domain <d> [--display-name <name>]
@@ -83,6 +95,11 @@ matches the rest of the CLI ("first required arg is positional").`,
83
95
  org (orphaned mailboxes — domain was unassigned
84
96
  without first deleting them).`,
85
97
  'activate-sending': 'myapi email mailbox activate-sending --address <email>',
98
+ 'delete': `myapi email mailbox delete <user@domain>
99
+
100
+ Idempotent — returns success whether or not the mailbox existed.
101
+ Refuses with MAILBOX_IN_USE when an active or paused campaign still
102
+ sends from this address; pause/delete the campaign first.`,
86
103
  'set-forwarding': `myapi email mailbox set-forwarding <user@domain> <forward-to@domain>
87
104
 
88
105
  Redirects a copy of every incoming message to an external address
@@ -97,6 +114,7 @@ Subcommands:
97
114
  activate-sending Activate outbound sending for a mailbox
98
115
  clear-forwarding Stop forwarding for a mailbox
99
116
  create Create a mailbox (positional <user@domain> or --username/--domain)
117
+ delete Delete a mailbox (idempotent; rejects if a campaign still uses it)
100
118
  list List mailboxes on a domain (--domain) or orphaned ones (--filter unassigned)
101
119
  set-forwarding Forward a copy of incoming mail to an external address`);
102
120
  return;
@@ -112,6 +130,7 @@ Subcommands:
112
130
  switch (sub) {
113
131
  case 'create': return create(args[0], flags);
114
132
  case 'list': return list(flags);
133
+ case 'delete': return del(args[0], flags);
115
134
  case 'activate-sending': return activateSending(flags);
116
135
  case 'set-forwarding': return setForwarding(args[0], args[1], flags);
117
136
  case 'clear-forwarding': return clearForwarding(args[0], flags);
@@ -27,6 +27,7 @@ export const SCHEMA = {
27
27
  funnel: 'string',
28
28
  slug: 'string',
29
29
  env: 'string',
30
+ force: 'boolean',
30
31
  'api-fn': 'string',
31
32
  // form
32
33
  fields: 'string',
@@ -44,6 +45,28 @@ function validateFunnelName(name) {
44
45
  error(`Invalid --name "${name}". Lowercase letters, digits, hyphens; 1-50 chars; starts with a letter or digit.`);
45
46
  }
46
47
  }
48
+ // Canonical key for comparing slugs across the wire shapes the backend may
49
+ // return (`/about`, `about`, `/about/`). Home is the empty string whether it
50
+ // arrives as `/` or ``.
51
+ function slugKey(s) {
52
+ return (s.startsWith('/') ? s.slice(1) : s).replace(/\/+$/, '');
53
+ }
54
+ // Resolve human-readable labels for the namespace a destructive write lands
55
+ // on. The whole point of the funnel-write guardrails is that the agent (and
56
+ // the human reading the transcript) can SEE which org + funnel it touched —
57
+ // a bare UUID hides the cross-org mistake this is meant to catch. Best-effort:
58
+ // fall back to ids if either lookup fails so we never block the real work on a
59
+ // cosmetic call.
60
+ async function describeTarget(apiKey, orgId, funnelId) {
61
+ const [org, funnelRes] = await Promise.all([
62
+ hq.getOrg(apiKey, orgId).catch(() => undefined),
63
+ sdkFunnel.getFunnel(apiKey, orgId, funnelId).catch(() => undefined),
64
+ ]);
65
+ const orgLabel = org?.name ? `${org.name} (${orgId})` : orgId;
66
+ const fname = funnelRes?.funnel?.name;
67
+ const funnelLabel = fname ? `${fname} (${funnelId})` : funnelId;
68
+ return { orgLabel, funnelLabel };
69
+ }
47
70
  export async function list(flags) {
48
71
  const config = requireConfig();
49
72
  const orgId = requireOrg(flags, config, 'myapi funnel list [--org <id>]');
@@ -83,16 +106,12 @@ export async function get(id, flags) {
83
106
  const orgId = requireOrg(flags, config, 'myapi funnel get <id> [--org <id>]');
84
107
  if (!id)
85
108
  error('Missing required arguments.\nUsage: myapi funnel get <id> [--org <id>]');
86
- const raw = await sdkFunnel.getFunnel(config.api_key, orgId, id);
87
- // Backend response shape varies between two layouts:
88
- // 1) { funnel: {...}, subdomain_url, domain_url } (newer, wrapped)
89
- // 2) { id, ..., subdomain_url, domain_url } (older, flat)
90
- // Coalesce both so this works regardless of which shape we get back.
91
- const funnel = raw.funnel ?? raw;
92
- const subdomain_url = raw.subdomain_url ?? funnel.subdomain_url;
93
- const domain_url = raw.domain_url ?? funnel.domain_url;
109
+ // SDK normalizes the wire shape to the wrapped envelope. `--json`
110
+ // still emits the envelope (back-compat: legacy consumers parse it).
111
+ const result = await sdkFunnel.getFunnel(config.api_key, orgId, id);
112
+ const { funnel, subdomain_url, domain_url } = result;
94
113
  if (flags.json) {
95
- printJson(raw);
114
+ printJson(result);
96
115
  return;
97
116
  }
98
117
  info(`ID: ${funnel.id}`);
@@ -104,8 +123,8 @@ export async function get(id, flags) {
104
123
  info(`Created: ${formatDate(funnel.created_at)}`);
105
124
  if (funnel.updated_at)
106
125
  info(`Updated: ${formatDate(funnel.updated_at)}`);
107
- if (Array.isArray(funnel.pages))
108
- info(`Pages: ${funnel.pages.length}`);
126
+ // `pages` isn't part of the typed Funnel; backend doesn't include it
127
+ // here (use `myapi funnel pages` for that). Drop the inline count.
109
128
  }
110
129
  export async function del(id, flags) {
111
130
  const config = requireConfig();
@@ -153,6 +172,29 @@ export async function push(slug, flags) {
153
172
  if (process.stdin.isTTY) {
154
173
  error("No content provided via stdin. Use a pipe or file:\n echo '<h1>Hello</h1>' | myapi funnel push /\n cat page.html | myapi funnel push /about");
155
174
  }
175
+ // Overwrite guard. `push` silently replaces whatever is live at the slug,
176
+ // so an agent pushing `/` onto an org's existing site destroys it with no
177
+ // second chance. Mirror the my-domain-api principle ("destructive ops don't
178
+ // ride on silent defaults"): if a page already exists at this slug, refuse
179
+ // and name the org + funnel + slug, unless --force. We do this BEFORE
180
+ // reading stdin so a refused push never consumes the generated HTML.
181
+ const force = flags.force === true;
182
+ const existingPages = await sdkFunnel.listFunnelPages(config.api_key, orgId, funnelId);
183
+ const clash = existingPages.find(p => slugKey(p.slug) === slugKey(finalSlug));
184
+ if (clash && !force) {
185
+ const { orgLabel, funnelLabel } = await describeTarget(config.api_key, orgId, funnelId);
186
+ const when = clash.updated_at ? `, last updated ${formatDate(clash.updated_at)}` : '';
187
+ error(`Refusing to overwrite an existing page.
188
+
189
+ Org: ${orgLabel}
190
+ Funnel: ${funnelLabel}
191
+ Slug: ${finalSlug} (already published${when})
192
+
193
+ This funnel already serves a page at ${finalSlug}; pushing would replace it.
194
+ • Wrong org or funnel? Re-run with --org <id> and/or --funnel <id>.
195
+ • New demo? Create a fresh funnel: myapi funnel create --name <demo> --org <id>
196
+ • Meant to replace it? Re-run the same command with --force.`);
197
+ }
156
198
  const html = await new Promise((resolve, reject) => {
157
199
  let data = '';
158
200
  process.stdin.setEncoding('utf-8');
@@ -163,9 +205,12 @@ export async function push(slug, flags) {
163
205
  if (!html.trim())
164
206
  error("No content provided via stdin. Usage: echo '<h1>Hello</h1>' | myapi funnel push [slug]");
165
207
  const result = await sdkFunnel.pushFunnelPage(config.api_key, orgId, funnelId, { slug: finalSlug, html });
166
- success(`Pushed page to ${finalSlug}`);
208
+ const { orgLabel, funnelLabel } = await describeTarget(config.api_key, orgId, funnelId);
209
+ success(`Pushed ${clash ? '(overwrote) ' : ''}page to ${finalSlug}`);
210
+ info(`Org: ${orgLabel}`);
211
+ info(`Funnel: ${funnelLabel}`);
167
212
  if (resolvedFromOrg) {
168
- info(`(Used the org's only funnel: ${funnelId}. Set a default with: myapi config set-funnel ${funnelId})`);
213
+ info(`(Auto-picked the org's only funnel. Pin it explicitly with --funnel ${funnelId} or: myapi config set-funnel ${funnelId})`);
169
214
  }
170
215
  let liveUrl = result?.url;
171
216
  if (!liveUrl) {
@@ -256,10 +301,32 @@ export async function formCmd(funnelArg, flags) {
256
301
  error(`Honeypot "${honeypot}" collides with a real field. Pick a different --honeypot name.`);
257
302
  const cta = flags.cta || 'Sign up';
258
303
  const successMsg = flags.success || "Thanks — we'll be in touch.";
259
- // Optional binding via --capture-to.
304
+ // Resolve the binding destination.
305
+ //
306
+ // Without a binding, the backend falls submissions through to the
307
+ // funnel's default webhook with NO honeypot / field validation. That
308
+ // makes the on-page honeypot purely cosmetic and lets trivial bots
309
+ // write to your CRM. So: always register a binding. When
310
+ // `--capture-to` is omitted, target the funnel's auto-provisioned
311
+ // `org_webhook_id` — same destination as the fallback, just with
312
+ // server-side guards turned on. Backend POST upserts on duplicate
313
+ // slug, so re-runs are safe.
260
314
  let registeredBinding;
315
+ let autoBound = false;
316
+ let dest = null;
261
317
  if (typeof flags['capture-to'] === 'string' && flags['capture-to']) {
262
- const dest = parseCaptureTo(flags['capture-to']);
318
+ dest = parseCaptureTo(flags['capture-to']);
319
+ }
320
+ else {
321
+ const { funnel } = await sdkFunnel.getFunnel(config.api_key, orgId, funnelId);
322
+ if (funnel.org_webhook_id) {
323
+ dest = { kind: 'webhook', id: funnel.org_webhook_id };
324
+ autoBound = true;
325
+ }
326
+ // Legacy funnels migrated before 2026-06-03 may briefly lack
327
+ // org_webhook_id — emit the HTML without a binding and warn.
328
+ }
329
+ if (dest) {
263
330
  const binding = {
264
331
  slug,
265
332
  destination: `${dest.kind}:${dest.id}`,
@@ -310,7 +377,13 @@ document.querySelectorAll('[data-myapi-form]').forEach(f => {
310
377
  }
311
378
  if (registeredBinding) {
312
379
  // Print confirmation on stderr so stdout stays the clean snippet.
313
- process.stderr.write(`✓ Registered form binding: slug=${slug} ${registeredBinding.destination}\n`);
380
+ const tag = autoBound ? ' (auto, default webhook)' : '';
381
+ process.stderr.write(`✓ Registered form binding: slug=${slug} → ${registeredBinding.destination}${tag}\n`);
382
+ }
383
+ else {
384
+ // No org_webhook_id and no --capture-to → honeypot/field guards
385
+ // won't fire. Surface it so the agent can act.
386
+ process.stderr.write(`⚠ No binding registered (funnel has no org_webhook_id). Honeypot and field guards are NOT enforced for this slug. Pass --capture-to webhook:<id> or workflow:<id>, or wait for the backend migration to backfill org_webhook_id.\n`);
314
387
  }
315
388
  process.stdout.write(html + '\n');
316
389
  }
@@ -381,6 +454,29 @@ export async function publish(dir, flags) {
381
454
  const files = await collectFiles(dir, dir);
382
455
  if (files.length === 0)
383
456
  error(`No files found under ${dir}.`);
457
+ // Overwrite guard — prod only. Publishing to prod replaces the whole live
458
+ // site; the dev channel is the throwaway preview you re-publish freely, so
459
+ // it stays unguarded. If the funnel already serves pages and this is a prod
460
+ // publish, refuse without --force and name the namespace at risk.
461
+ const force = flags.force === true;
462
+ const channel = env || 'prod';
463
+ if (channel === 'prod' && !force) {
464
+ const existingPages = await sdkFunnel.listFunnelPages(config.api_key, orgId, funnelId);
465
+ if (existingPages.length > 0) {
466
+ const { orgLabel, funnelLabel } = await describeTarget(config.api_key, orgId, funnelId);
467
+ error(`Refusing to replace a published site.
468
+
469
+ Org: ${orgLabel}
470
+ Funnel: ${funnelLabel}
471
+ Live: ${existingPages.length} page(s) currently published
472
+
473
+ Publishing to the prod channel replaces the entire live site.
474
+ • Wrong org or funnel? Re-run with --org <id> and/or --funnel <id>.
475
+ • New demo? Create a fresh funnel: myapi funnel create --name <demo> --org <id>
476
+ • Preview safely first: myapi funnel publish ${dir} --env dev
477
+ • Meant to replace it? Re-run the same command with --force.`);
478
+ }
479
+ }
384
480
  const result = await sdkFunnel.publishFiles(config.api_key, orgId, funnelId, files, {
385
481
  env: env,
386
482
  apiFunctionId: flags['api-fn'],
@@ -389,7 +485,10 @@ export async function publish(dir, flags) {
389
485
  printJson(result);
390
486
  return;
391
487
  }
488
+ const { orgLabel, funnelLabel } = await describeTarget(config.api_key, orgId, funnelId);
392
489
  success(`Published ${result.file_count} file(s) to the ${result.channel} channel`);
490
+ info(`Org: ${orgLabel}`);
491
+ info(`Funnel: ${funnelLabel}`);
393
492
  info(`Size: ${(result.size_bytes / 1024).toFixed(1)} KB`);
394
493
  info(`SPA: ${result.spa_mode ? 'on' : 'off'}`);
395
494
  info(`Live: ${result.published_url}`);
@@ -442,7 +541,7 @@ free.`,
442
541
  'get': 'myapi funnel get <id> [--org <id>] [--json]',
443
542
  'list': 'myapi funnel list [--org <id>] [--json]',
444
543
  'pages': 'myapi funnel pages [funnel_id] [--funnel <id>] [--org <id>] [--json]',
445
- 'publish': `myapi funnel publish <dir> [--funnel <id>] [--env dev|prod] [--api-fn <id>] [--org <id>]
544
+ 'publish': `myapi funnel publish <dir> [--funnel <id>] [--env dev|prod] [--api-fn <id>] [--force] [--org <id>]
446
545
 
447
546
  Uploads a whole local directory as the funnel's site. Each file's path
448
547
  within <dir> becomes its path on the site (e.g. dir/about/index.html →
@@ -451,18 +550,29 @@ within <dir> becomes its path on the site (e.g. dir/about/index.html →
451
550
  --env dev|prod Target channel (default prod). dev publishes to
452
551
  <name>-dev.makeautonomous.com for preview.
453
552
  --api-fn <id> Bind /api/* on the site to a deployed function.
553
+ --force Publish to prod even when the funnel already serves
554
+ pages (replaces the entire live site). Refused without
555
+ it. The dev channel is never guarded.
454
556
 
557
+ Publishing to prod replaces the whole live site. The command prints the
558
+ resolved org + funnel so you can confirm the namespace before it lands.
455
559
  SPA fallback is auto-enabled when the publish has a root index.html.
456
560
 
457
561
  Examples:
458
- myapi funnel publish ./dist
459
- myapi funnel publish ./dist --env dev
562
+ myapi funnel publish ./dist --env dev # safe preview
563
+ myapi funnel publish ./dist # prod — refused if a site exists
564
+ myapi funnel publish ./dist --force # prod — replace existing site
460
565
  myapi funnel publish ./site --api-fn <function_id>`,
461
- 'push': `myapi funnel push [slug] [--funnel <id>] [--slug <path>] [--org <id>] < page.html
566
+ 'push': `myapi funnel push [slug] [--funnel <id>] [--slug <path>] [--force] [--org <id>] < page.html
462
567
 
463
568
  Reads HTML from stdin and publishes to <slug> on your funnel. Funnel is resolved
464
569
  from --funnel, the default funnel, or (only if the org has exactly one) auto-picked.
465
570
 
571
+ Pushing OVERWRITES whatever page is already live at <slug>. If a page already
572
+ exists there, push refuses and names the org + funnel + slug at risk; re-run
573
+ with --force to replace it. On success it prints the resolved org + funnel so
574
+ you can confirm you wrote to the namespace you intended.
575
+
466
576
  By default, your funnel is served on a preview subdomain (*.makeautonomous.com).
467
577
  To serve on your own domain, register and assign one with:
468
578
  myapi domain register <domain>
@@ -471,7 +581,7 @@ To serve on your own domain, register and assign one with:
471
581
  Examples:
472
582
  echo '<h1>Hello</h1>' | myapi funnel push /
473
583
  cat about.html | myapi funnel push /about
474
- myapi funnel push < index.html
584
+ cat new.html | myapi funnel push / --force # replace the live homepage
475
585
  cat p.html | myapi funnel push /pricing --funnel <uuid>`,
476
586
  'verify': `myapi funnel verify [slug] [--funnel <id>] [--org <id>]
477
587
 
@@ -26,7 +26,7 @@ const BLOCK_END = `# end ${PROGRAM} completion`;
26
26
  // test/smoke/completion.test.ts, which fails if a command in `myapi --help`
27
27
  // is missing here.
28
28
  export const COMMANDS = [
29
- 'audience', 'auth', 'billing', 'company', 'completion', 'config', 'container',
29
+ 'account', 'audience', 'auth', 'billing', 'company', 'completion', 'config', 'container',
30
30
  'crm', 'database', 'domain', 'email', 'fn', 'funnel', 'git', 'help', 'image',
31
31
  'doctor', 'install-skills', 'keys', 'llm', 'org', 'payments', 'people', 'pixel', 'queue',
32
32
  'setup', 'status', 'storage', 'task', 'update', 'url', 'webhook', 'whoami',
@@ -35,10 +35,11 @@ export const COMMANDS = [
35
35
  // command → subcommands, for `myapi <command> <TAB>`. Mirrors each
36
36
  // command's dispatcher; commands absent here take no subcommand.
37
37
  export const SUBCOMMANDS = {
38
+ account: ['mailing-address'],
38
39
  auth: ['setup', 'import-key', 'whoami', 'link', 'switch', 'install-skills', 'config', 'registrant', 'api-keys'],
39
40
  org: ['create', 'delete', 'get', 'import', 'list', 'sync-brand', 'update'],
40
41
  billing: ['balance', 'history', 'usage', 'setup', 'topup', 'spend-cap'],
41
- domain: ['assign', 'check', 'email-setup', 'import', 'list', 'records', 'register', 'renew', 'retry-provisioning', 'settings', 'status'],
42
+ domain: ['assign', 'check', 'email-setup', 'import', 'list', 'mail-server-resync', 'records', 'register', 'renew', 'retry-provisioning', 'settings', 'status'],
42
43
  funnel: ['create', 'delete', 'get', 'list', 'pages', 'publish', 'push', 'verify'],
43
44
  webhook: ['create', 'delete', 'delivery', 'list', 'update'],
44
45
  workflow: ['create', 'delete', 'disable', 'enable', 'get', 'get-run', 'list', 'runs', 'update'],
@@ -6,6 +6,7 @@ import * as url from 'url';
6
6
  // EXPOSES array so the coverage tool (S-102) can introspect the full CLI
7
7
  // surface.
8
8
  const COMMAND_MODULES = [
9
+ './commands/account.js',
9
10
  './commands/auth.js',
10
11
  './commands/billing.js',
11
12
  './commands/config.js',
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import * as fs from 'fs';
6
6
  import { parseFlags } from './flags.js';
7
7
  const pkgPath = new URL('../package.json', import.meta.url);
8
8
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
9
+ import * as accountCmd from './commands/account.js';
9
10
  import * as keysCmd from './commands/keys.js';
10
11
  import * as billingCmd from './commands/billing.js';
11
12
  import * as orgCmd from './commands/org.js';
@@ -40,6 +41,7 @@ import * as doctorCmd from './commands/doctor.js';
40
41
  // into a single schema for the upfront parse, so adding a new value flag in
41
42
  // one command means editing one file (its SCHEMA), not a global allowlist.
42
43
  const COMBINED_SCHEMA = {
44
+ ...accountCmd.SCHEMA,
43
45
  ...authCmd.SCHEMA,
44
46
  ...billingCmd.SCHEMA,
45
47
  ...configCmd.SCHEMA,
@@ -170,6 +172,9 @@ async function main() {
170
172
  case 'org':
171
173
  await orgCmd.run(subcommand, restArgs, flags);
172
174
  break;
175
+ case 'account':
176
+ await accountCmd.run(subcommand, restArgs, flags);
177
+ break;
173
178
  case 'billing':
174
179
  await billingCmd.run(subcommand, restArgs, flags);
175
180
  break;
@@ -367,6 +372,7 @@ const HELP_TARGETS = {
367
372
  task: f => taskCmd.run(undefined, [], f),
368
373
  doctor: f => doctorCmd.run(undefined, [], f),
369
374
  org: f => orgCmd.run(undefined, [], f),
375
+ account: f => accountCmd.run(undefined, [], f),
370
376
  billing: f => billingCmd.run(undefined, [], f),
371
377
  keys: f => keysCmd.run(undefined, [], f),
372
378
  config: f => configCmd.run(undefined, [], f),
@@ -399,6 +405,7 @@ Usage: myapi <command> [subcommand] [args]
399
405
  myapi --version
400
406
 
401
407
  Commands:
408
+ account Account-scoped settings (mailing address)
402
409
  audience Save filter snapshots as named audiences (people or companies)
403
410
  auth Manage account · setup · whoami · link
404
411
  billing Check balance and manage billing
@@ -15,6 +15,8 @@ A funnel is a website tied to an org. Push raw HTML pages to slugs and they're s
15
15
  <!-- llm:start -->
16
16
  Funnels are the publishing surface. You create a funnel under an org (one command), then `funnel push` raw HTML to any slug (`/`, `/about`, `/pricing`, etc.). The edge serves the page within seconds — no CI/CD, no build, no deploy queue.
17
17
 
18
+ **Before you push, know where you're writing.** `funnel push` and `funnel publish --env prod` **overwrite** whatever is live at the target — no undo. An org may already host a real site; pushing `/` onto it replaces the homepage. See **Namespace & safety** below — this is the #1 way agents wreck an existing site.
19
+
18
20
  By default, your funnel lives on a free preview subdomain (`*.makeautonomous.com`) you get with every org. To serve on a custom domain, register and assign one via **mydomainapi** first.
19
21
 
20
22
  Every funnel auto-provisions a **webhook** at creation time (`org_webhook_id`). The funnel exposes two public proxy endpoints your HTML can call directly (no API key needed): a **form submit** at `POST /funnel/funnels/{id}/submit/{slug}` that delivers submissions through that webhook (CRM upsert + bound workflows fire automatically), and an **analytics/event ingest** for pageviews and click tracking. For multi-form funnels you can bind individual slugs to specific destinations with `myapi funnel form ... --capture-to webhook:<id>`.
@@ -28,51 +30,66 @@ Every funnel auto-provisions a **webhook** at creation time (`org_webhook_id`).
28
30
  | `myapi funnel list` | List all funnels with preview/domain URLs |
29
31
  | `myapi funnel get <id>` | Inspect a funnel's metadata + preview URL |
30
32
  | `myapi funnel delete <id>` | Delete the funnel and purge its edge pages |
31
- | `myapi funnel push [slug]` | Push HTML from stdin to a slug (default: `/`) |
33
+ | `myapi funnel push [slug]` | Push HTML from stdin to a slug (default: `/`). **Overwrites** an existing page — refused without `--force`, prints the resolved org + funnel on success |
32
34
  | `myapi funnel pages [funnel_id]` | List the pages currently published to a funnel |
33
35
  | `myapi funnel form [funnel_id]` | Emit canonical form HTML (and register a binding with `--capture-to`) |
34
36
  | `myapi funnel verify [slug]` | Verify a published page is reachable + check links/webhooks |
35
37
  <!-- generated:end -->
36
38
 
39
+ ## Namespace & safety (read before any write)
40
+
41
+ A write targets a `(org, funnel, slug)` address. Get all three right *before* pushing — the CLI will help, but the thinking is yours:
42
+
43
+ 1. **Confirm the org.** `--org` (or `default_org`) decides whose namespace you touch. For a demo or a new project, pass `--org <id>` explicitly every time — don't trust the ambient default. `myapi org list` shows the orgs you can reach.
44
+ 2. **Look before you write.** `myapi funnel list --org <id>` shows the org's funnels; `myapi funnel pages --funnel <id>` shows what's already published. If a slug is taken by something real, you're about to replace it.
45
+ 3. **A new demo = a new funnel.** Don't reuse an org's existing funnel for an unrelated demo. `myapi funnel create --name <demo> --org <id>` gives you a clean namespace and its own preview subdomain.
46
+ 4. **Pin the funnel.** When an org has exactly one funnel, `push`/`publish` auto-pick it — convenient, but it's how a demo lands on the wrong site. Pass `--funnel <id>` (or set a default) so the target is explicit, not inferred.
47
+
48
+ The CLI backstops you: `push` refuses to overwrite an existing slug (and `publish --env prod` refuses to replace a live site) without `--force`, and both print the resolved **org + funnel** on success. `--force` is the deliberate "yes, replace it" — never reach for it just to clear the error; first check whether the clash means you're aimed at the wrong place.
49
+
37
50
  ## Examples
38
51
  <!-- llm:start -->
39
52
  ```bash
40
- # Create a funnel + push a homepage
41
- myapi funnel create
42
- echo '<h1>Hello</h1>' | myapi funnel push /
53
+ # New demo, done safely — explicit org, fresh funnel, confirm, then push
54
+ myapi org list # which orgs can I reach?
55
+ myapi funnel list --org <org_id> # what already lives here?
56
+ myapi funnel create --name acme-demo --org <org_id> # clean namespace for the demo
57
+ echo '<h1>Hello</h1>' | myapi funnel push / --funnel <new_funnel_id>
43
58
 
44
- # Push multiple pages
45
- cat about.html | myapi funnel push /about
46
- cat pricing.html | myapi funnel push /pricing
59
+ # Push multiple pages to that funnel
60
+ cat about.html | myapi funnel push /about --funnel <new_funnel_id>
61
+ cat pricing.html | myapi funnel push /pricing --funnel <new_funnel_id>
47
62
 
48
- # Pipe directly from a generator
49
- my-html-gen | myapi funnel push /landing
63
+ # Inspect what's published before touching an existing funnel
64
+ myapi funnel pages --funnel <funnel_id>
65
+ myapi funnel verify /pricing --funnel <funnel_id>
50
66
 
51
- # Inspect what's published
52
- myapi funnel pages
53
- myapi funnel verify /pricing
67
+ # Deliberately replace a live page (only after confirming it's the right target)
68
+ cat new-home.html | myapi funnel push / --funnel <funnel_id> --force
54
69
 
55
70
  # Clean up
56
- myapi funnel delete <funnel_id>
71
+ myapi funnel delete <funnel_id> --org <org_id>
57
72
  ```
58
73
 
59
- Omitting `[slug]` defaults to `/`. The funnel id is resolved from `--funnel`, the saved default funnel, or — only if the org has exactly one funnel — auto-picked.
74
+ Omitting `[slug]` defaults to `/`. The funnel id is resolved from `--funnel`, the saved default funnel, or — only if the org has exactly one funnel — auto-picked (so an unintended push can silently land on an existing site; pass `--funnel` to be sure). `push` refuses to overwrite an occupied slug without `--force`.
60
75
  <!-- llm:end -->
61
76
 
62
77
  ## Form submissions (canonical recipe)
63
78
 
64
79
  The funnel submit proxy is the canonical form-capture path. **Never hardcode `https://api.mywebhookapi.com/webhook/in/<slug>` into funnel HTML** — that URL leaks into your git history, breaks on rotation, gets scraped, and locks the platform out of future form-quality features (rate-limit, captcha, anti-bot, field validation). Use the proxy instead — same plumbing, hidden URL, automatic CRM ingest on `email`.
65
80
 
66
- Zero-config form (the happy path most agents want):
81
+ Zero-config form (the happy path most agents want) — still pin the org + funnel so the snippet and the page land in the namespace you mean (see **Namespace & safety**):
67
82
 
68
83
  ```bash
69
- myapi funnel create
70
- myapi funnel form --slug join --fields email:required,name > snippet.html
84
+ myapi funnel create --name acme-demo --org <org_id>
85
+ myapi funnel form --slug join --fields email:required,name --funnel <funnel_id> > snippet.html
71
86
  # paste snippet.html into your page (or pipe through funnel push):
72
- cat page-with-snippet.html | myapi funnel push /
87
+ cat page-with-snippet.html | myapi funnel push / --funnel <funnel_id>
73
88
  # Submissions land in the funnel's auto-provisioned webhook → CRM upsert on `email` → any bound workflow fires.
74
89
  ```
75
90
 
91
+ `funnel form` always registers a per-slug binding (idempotent on repeat). Without `--capture-to` it auto-targets the funnel's `org_webhook_id` — same destination as the fallback, with server-side honeypot + field validation turned on.
92
+
76
93
  Multi-form funnel (each slug to a different destination):
77
94
 
78
95
  ```bash
@@ -95,7 +112,8 @@ myapi funnel form <funnel_id> --slug survey \
95
112
 
96
113
  ## Notes
97
114
 
98
- - Set defaults with `myapi config set-org <id>` and `myapi config set-funnel <id>` to skip flags on every command.
115
+ - Set defaults with `myapi config set-org <id>` and `myapi config set-funnel <id>` to skip flags on every command — but a stale default is exactly how a push lands on the wrong org/funnel. For demos and one-offs, pass `--org`/`--funnel` explicitly instead of relying on whatever default was set last.
116
+ - `push` (any slug) and `publish --env prod` overwrite live content and are refused without `--force` when something already exists at the target. `--force` means "yes, replace the live page/site" — confirm you're aimed at the right `(org, funnel, slug)` before using it, don't use it to silence the error.
99
117
  - Deleting a funnel purges all its edge pages immediately.
100
118
  - `402` errors mean insufficient credits — run `myapi billing topup <amount>`.
101
119
 
@@ -89,6 +89,22 @@ Shared usage block on every verb:
89
89
 
90
90
  The model/provider is **never** named in the verb response. That's the point — the verb is the contract, the model is implementation.
91
91
 
92
+ ### OpenAI-compatible drop-in
93
+
94
+ `POST /llm/orgs/{org_id}/chat/completions` (and `/v1/chat/completions` alias) accepts the OpenAI request shape and returns the OpenAI response shape — **no envelope**. Same catalog and pricing as raw `complete`. Use it when existing OpenAI SDK / LangChain / any `base_url`-configurable tooling should point at MyAPI without rewriting.
95
+
96
+ ```python
97
+ from openai import OpenAI
98
+ client = OpenAI(
99
+ api_key="hq_live_…",
100
+ base_url="https://api.myapihq.com/llm/orgs/<org_id>/v1",
101
+ )
102
+ r = client.chat.completions.create(model="Qwen/Qwen3-Coder-30B-A3B-Instruct",
103
+ messages=[{"role":"user","content":"Hi"}])
104
+ ```
105
+
106
+ Use raw `complete` (MyAPI shape) for first-party integrations; use the OpenAI-compat path for compatibility with existing client code.
107
+
92
108
  <!-- llm:end -->
93
109
 
94
110
  ## Commands
@@ -145,10 +161,7 @@ INTENT=$(printf '%s' "$BODY" | myapi llm classify - \
145
161
 
146
162
  ## Notes
147
163
 
148
- - **`draft` context safety — what the platform does and doesn't do.** Every field in `context` is quoted into the prompt as referent data the model is told to incorporate. Sensitive-named keys (`secret`, `api_key`, `password`, `token`, etc.) are **not** automatically redacted if you pass `{"secret": "abc123"}`, the model may include `"abc123"` in the output. The verb does two things on top:
149
- - **Injection defense.** Keys named `instructions`, `system`, `system_prompt`, `prompt`, `override`, `directives`, and the JS prototype-pollution sentinels are stripped at the boundary and surfaced in `meta.warnings`; the model can't be hijacked through them.
150
- - **Output guardrail.** After generation, every fact value (length ≥ 4) is substring-scanned in the output; matches surface under `meta.guardrails.facts_in_output: ["secret", "api_key", ...]`. This is a **signal, not a redaction** — you see when a value landed in the body.
151
- Rule of thumb: if a value must not appear in the output, **do not put it in `context`**. Pass identifiers (recipient name, account id, topic) and let the prompt reference them indirectly; keep credentials, PII, and any internal-only metadata out of the body entirely.
152
- - **Self-hosted raw, server-picked verbs.** Raw runs on MyAPI's TPU; verbs route wherever the server picks (future proprietary models land here, wrapped behind the verb contract).
153
- - **Cost + latency.** `usage.cost_cents` is authoritative — no markup today. Qwen3-Coder-30B-A3B: 200–600ms to first token, 1–3s end-to-end on a few-hundred-token reply.
154
- - **Live catalog, no streaming, no BYOK.** Don't hard-code model ids — `models` is the source of truth (CLI picks if `--model` omitted). Full reply only.
164
+ - **`draft` context safety.** `context` fields are quoted into the prompt verbatim; sensitive-named keys (`secret`, `api_key`, `password`, …) are NOT redacted. Two guards on top: (a) injection-defense strips `instructions`/`system`/`prompt`/`override` keys and surfaces them in `meta.warnings`; (b) output guardrail substring-scans fact values (length ≥ 4) in the response and lists matches in `meta.guardrails.facts_in_output` (signal, not redaction). Rule of thumb: never put credentials, PII, or internal metadata in `context` — pass identifiers, reference them indirectly.
165
+ - **Self-hosted raw, server-picked verbs.** Raw runs on MyAPI's TPU; verbs route wherever the server picks.
166
+ - **Cost + latency.** `usage.cost_cents` is authoritative no markup. Qwen3-Coder-30B: 200–600 ms to first token, 1–3 s end-to-end.
167
+ - **Live catalog, no streaming, no BYOK.** Don't hard-code ids `models` is truth (CLI auto-picks if `--model` omitted). Full reply only.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@myapihq/cli",
3
3
  "license": "Apache-2.0",
4
- "version": "1.3.8",
4
+ "version": "1.3.12",
5
5
  "description": "MyAPI command-line interface",
6
6
  "type": "module",
7
7
  "files": [
@@ -30,7 +30,7 @@
30
30
  "lint:skills:strict": "node scripts/lint-skills.js --strict"
31
31
  },
32
32
  "dependencies": {
33
- "@myapihq/sdk": "^1.3.8"
33
+ "@myapihq/sdk": "^1.3.12"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/node": "^25.6.0",