@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.
- package/dist/commands/account.d.ts +6 -0
- package/dist/commands/account.js +75 -0
- package/dist/commands/config.js +2 -2
- package/dist/commands/crm/companies.js +4 -0
- package/dist/commands/crm/contacts.js +5 -0
- package/dist/commands/doctor-setup.test.d.ts +1 -0
- package/dist/commands/doctor-setup.test.js +74 -0
- package/dist/commands/doctor.d.ts +5 -0
- package/dist/commands/doctor.js +73 -1
- package/dist/commands/domain.d.ts +1 -0
- package/dist/commands/domain.js +38 -1
- package/dist/commands/email/mailbox.js +19 -0
- package/dist/commands/funnel.js +131 -21
- package/dist/completion.js +3 -2
- package/dist/exposes.test.js +1 -0
- package/dist/index.js +7 -0
- package/dist/skills/my-funnel-api/SKILL.md +37 -19
- package/dist/skills/my-llm-api/SKILL.md +20 -7
- package/package.json +2 -2
|
@@ -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
|
+
}
|
package/dist/commands/config.js
CHANGED
|
@@ -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
|
|
55
|
-
const funnelId =
|
|
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>;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/commands/domain.js
CHANGED
|
@@ -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);
|
package/dist/commands/funnel.js
CHANGED
|
@@ -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
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
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(
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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(`(
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
package/dist/completion.js
CHANGED
|
@@ -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'],
|
package/dist/exposes.test.js
CHANGED
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
|
-
#
|
|
41
|
-
myapi
|
|
42
|
-
|
|
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
|
-
#
|
|
49
|
-
|
|
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
|
-
#
|
|
52
|
-
myapi funnel
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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.
|
|
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.
|
|
33
|
+
"@myapihq/sdk": "^1.3.12"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@types/node": "^25.6.0",
|