@myapihq/cli 2.0.1 → 2.2.0

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.
@@ -3,5 +3,5 @@ import { type Flags } from '../helpers.js';
3
3
  import type { Exposes } from '../exposes.js';
4
4
  export declare const SCHEMA: FlagSchema;
5
5
  export declare const EXPOSES: Exposes;
6
- export declare const HELP = "Usage: myapi auth <subcommand>\n\nAuthentication for your app's end users \u2014 a managed OIDC identity provider\n(\u00E0 la Kinde/Auth0). One auth tenant per org; register OIDC clients (apps)\nagainst it; sign users in via the hosted login page or the JS SDK; verify\ntokens locally against the tenant JWKS.\n\nEnd users sign in with managed Google, email/password, or magic links \u2014 pick\nwhich via `tenant create --connections`. (Operator/account commands moved to\n`myapi account`.)\n\nSubcommands:\n client Register and list OIDC clients (your apps)\n domain Serve auth on your own domain (auth.acme.com)\n tenant Show or create your org's OIDC auth tenant (+ sign-in methods)\n usage Monthly active users (MAU) for the current period";
6
+ export declare const HELP = "Usage: myapi auth <subcommand>\n\nAuthentication for your app's end users \u2014 a managed OIDC identity provider\n(\u00E0 la Kinde/Auth0). One auth tenant per org; register OIDC clients (apps)\nagainst it; sign users in via the hosted login page or the JS SDK; verify\ntokens locally against the tenant JWKS.\n\nEnd users sign in with managed Google, email/password, or magic links \u2014 pick\nwhich via `tenant create --connections`. (Operator/account commands moved to\n`myapi account`.)\n\nSubcommands:\n client Register, list, delete, and rotate OIDC clients (your apps)\n domain Serve auth on your own domain (auth.acme.com)\n tenant Show or create your org's OIDC auth tenant (+ sign-in methods)\n usage Monthly active users (MAU) for the current period";
7
7
  export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
@@ -1,5 +1,6 @@
1
1
  import { auth as sdkAuth } from '@myapihq/sdk';
2
2
  import { requireConfig } from '../config.js';
3
+ import { confirm, isNonInteractive } from '../prompt.js';
3
4
  import { success, error, info, printTable, printJson } from '../output.js';
4
5
  import { requireOrg } from '../helpers.js';
5
6
  // `myapi auth` — the END-USER auth product (my-auth-api): a managed OIDC IdP
@@ -21,8 +22,11 @@ export const EXPOSES = [
21
22
  'POST /auth/orgs/{org_id}/clients',
22
23
  'GET /auth/orgs/{org_id}/clients',
23
24
  'GET /auth/orgs/{org_id}/usage',
25
+ 'DELETE /auth/orgs/{org_id}/clients/{client_id}',
26
+ 'POST /auth/orgs/{org_id}/clients/{client_id}/rotate',
24
27
  'POST /auth/orgs/{org_id}/domain',
25
28
  'GET /auth/orgs/{org_id}/domain',
29
+ 'POST /auth/orgs/{org_id}/domain/verify',
26
30
  'DELETE /auth/orgs/{org_id}/domain',
27
31
  ];
28
32
  const SUBCOMMAND_USAGE = {
@@ -40,27 +44,35 @@ in against. One per org.
40
44
  'usage': `myapi auth usage [--org <id>] [--json]
41
45
 
42
46
  Monthly active users (MAU) for the current period — auth is billed per MAU.`,
43
- 'domain': `myapi auth domain [show|set|delete] [--domain <host>] [--org <id>] [--json]
47
+ 'domain': `myapi auth domain [show|set|verify|delete] [--domain <host>] [--org <id>] [--json]
44
48
 
45
49
  Serve auth on your own domain (e.g. auth.acme.com) instead of the default issuer.
50
+ Three steps: set → publish the ownership TXT → verify → publish the A record.
46
51
 
47
52
  myapi auth domain Show the current custom domain + status
48
- myapi auth domain set --domain auth.acme.com Register it (prints the DNS record to create)
53
+ myapi auth domain set --domain auth.acme.com Register it (prints a TXT challenge to publish)
54
+ myapi auth domain verify Check the TXT; on success prints the A record
49
55
  myapi auth domain delete Remove the custom domain
50
56
 
51
- After 'set', create the printed DNS A record; TLS provisions automatically
52
- (~30 min) and the domain becomes your issuer once active.`,
53
- 'client': `myapi auth client <list|create> [--org <id>] [--json]
57
+ Flow: 'set' returns a TXT record to prove ownership; create it, then 'verify'.
58
+ Once verified, create the printed A record; TLS provisions automatically (~30 min)
59
+ and the domain becomes your issuer when active.`,
60
+ 'client': `myapi auth client <list|create|delete|rotate> [--org <id>] [--json]
54
61
 
55
62
  OIDC clients are the apps that authenticate against your tenant.
56
63
 
57
64
  myapi auth client list
58
65
  myapi auth client create --name "My App" --type spa --redirect https://app.example.com/callback
66
+ myapi auth client delete <client_id> [--yes]
67
+ myapi auth client rotate <client_id>
59
68
 
60
- --name <n> Human label for the app (required)
69
+ --name <n> Human label for the app (required for create)
61
70
  --type spa|web spa = public (no secret); web = confidential (secret once)
62
- --redirect <urls> Allowed redirect URIs, comma-separated (required).
63
- Absolute https (or http://localhost for dev).`,
71
+ --redirect <urls> Allowed redirect URIs, comma-separated (required for create).
72
+ Absolute https (or http://localhost for dev).
73
+
74
+ delete Revoke a client (irreversible; stops authenticating immediately).
75
+ rotate Re-issue a 'web' client's secret (shown once; old secret stops working).`,
64
76
  };
65
77
  export const HELP = `Usage: myapi auth <subcommand>
66
78
 
@@ -74,7 +86,7 @@ which via \`tenant create --connections\`. (Operator/account commands moved to
74
86
  \`myapi account\`.)
75
87
 
76
88
  Subcommands:
77
- client Register and list OIDC clients (your apps)
89
+ client Register, list, delete, and rotate OIDC clients (your apps)
78
90
  domain Serve auth on your own domain (auth.acme.com)
79
91
  tenant Show or create your org's OIDC auth tenant (+ sign-in methods)
80
92
  usage Monthly active users (MAU) for the current period`;
@@ -165,25 +177,34 @@ async function usage(flags) {
165
177
  info(`Period: ${u.period}`);
166
178
  info(`Active users: ${u.active_users} MAU`);
167
179
  info(`Price: $${(u.price_cents_each / 100).toFixed(2)} per MAU`);
180
+ if (u.as_of)
181
+ info(`As of: ${u.as_of} (MAU is aggregated, not real-time)`);
168
182
  }
169
183
  async function domain(args, flags) {
170
184
  const action = args[0] || 'show';
171
185
  const config = requireConfig();
172
- const orgId = requireOrg(flags, config, 'myapi auth domain [show|set|delete] [--org <id>]');
186
+ const orgId = requireOrg(flags, config, 'myapi auth domain [show|set|verify|delete] [--org <id>]');
173
187
  const showDomain = (d) => {
174
188
  info(`Domain: ${d.domain}`);
175
189
  info(`Status: ${d.status}`);
176
- if (d.status !== 'active') {
190
+ if (d.status === 'awaiting_verification' && d.verification) {
191
+ info('');
192
+ info('Prove ownership — create this DNS record:');
193
+ info(` ${d.verification.type} ${d.verification.name} → ${d.verification.value}`);
194
+ info('\nThen run: myapi auth domain verify');
195
+ }
196
+ else if (d.dns) {
177
197
  info('');
178
- info(`DNS — create this record:`);
198
+ info('DNS — create this record:');
179
199
  info(` ${d.dns.type} ${d.dns.name} → ${d.dns.value}`);
180
- if (d.next)
181
- info(`\n${d.next}`);
182
200
  }
183
- else {
201
+ if (d.status === 'active') {
184
202
  info(`Issuer: ${d.issuer}`);
185
203
  info(`Login URL: ${d.login_url}`);
186
204
  }
205
+ else if (d.next) {
206
+ info(`\n${d.next}`);
207
+ }
187
208
  };
188
209
  if (action === 'show') {
189
210
  try {
@@ -215,6 +236,35 @@ async function domain(args, flags) {
215
236
  showDomain(d);
216
237
  return;
217
238
  }
239
+ if (action === 'verify') {
240
+ try {
241
+ const d = await sdkAuth.verifyDomain(config.api_key, orgId);
242
+ if (flags.json) {
243
+ printJson(d);
244
+ return;
245
+ }
246
+ if (d.status === 'awaiting_verification') {
247
+ // Shouldn't normally happen (backend returns 422 when unverified), but
248
+ // be defensive.
249
+ info('Still awaiting verification — the TXT record was not found.');
250
+ showDomain(d);
251
+ }
252
+ else {
253
+ success(`Ownership verified — domain is now "${d.status}".`);
254
+ showDomain(d);
255
+ }
256
+ }
257
+ catch (e) {
258
+ if (e?.code === 'DOMAIN_NOT_VERIFIED') {
259
+ const exp = e?.body?.expected;
260
+ error(`Ownership TXT record not found yet (DNS can take a few minutes). Create it and retry:\n ${exp ? `${exp.type} ${exp.name} → ${exp.value}` : 'see: myapi auth domain show'}`);
261
+ }
262
+ if (e?.code === 'NO_DOMAIN' || e?.status === 404)
263
+ error('No custom auth domain set. Set one first: myapi auth domain set --domain auth.example.com');
264
+ throw e;
265
+ }
266
+ return;
267
+ }
218
268
  if (action === 'delete' || action === 'remove') {
219
269
  const host = flags.domain || args[1];
220
270
  let target = host;
@@ -231,12 +281,12 @@ async function domain(args, flags) {
231
281
  success(`Custom auth domain removed: ${target}`);
232
282
  return;
233
283
  }
234
- error(`Unknown action "${action}". Use: myapi auth domain [show|set|delete]`);
284
+ error(`Unknown action "${action}". Use: myapi auth domain [show|set|verify|delete]`);
235
285
  }
236
286
  async function client(args, flags) {
237
287
  const action = args[0] || 'list';
238
288
  const config = requireConfig();
239
- const orgId = requireOrg(flags, config, 'myapi auth client <list|create> [--org <id>]');
289
+ const orgId = requireOrg(flags, config, 'myapi auth client <list|create|delete|rotate> [--org <id>]');
240
290
  if (action === 'list') {
241
291
  const res = await sdkAuth.listClients(config.api_key, orgId);
242
292
  if (flags.json) {
@@ -282,5 +332,48 @@ async function client(args, flags) {
282
332
  }
283
333
  return;
284
334
  }
285
- error(`Unknown action "${action}". Use: myapi auth client <list|create>`);
335
+ if (action === 'delete') {
336
+ const clientId = args[1];
337
+ if (!clientId)
338
+ error('Usage: myapi auth client delete <client_id> [--yes]');
339
+ // Destructive + irreversible — echo the exact client_id and confirm (the
340
+ // client stops authenticating immediately). --yes bypasses; refuse to hang
341
+ // in non-interactive contexts without it.
342
+ if (!flags.yes && !flags.y) {
343
+ if (isNonInteractive()) {
344
+ error(`Deleting client ${clientId} is irreversible. Re-run with --yes:\n myapi auth client delete ${clientId} --yes`);
345
+ }
346
+ const ok = await confirm(`› Delete OIDC client ${clientId}? It stops authenticating immediately. (y/N) `, false);
347
+ if (!ok) {
348
+ info('Cancelled.');
349
+ return;
350
+ }
351
+ }
352
+ const res = await sdkAuth.deleteClient(config.api_key, orgId, clientId);
353
+ if (flags.json) {
354
+ printJson(res);
355
+ return;
356
+ }
357
+ success(`Client deleted: ${clientId}`);
358
+ return;
359
+ }
360
+ if (action === 'rotate') {
361
+ const clientId = args[1];
362
+ if (!clientId)
363
+ error('Usage: myapi auth client rotate <client_id>');
364
+ const c = await sdkAuth.rotateClient(config.api_key, orgId, clientId);
365
+ if (flags.json) {
366
+ printJson(c);
367
+ return;
368
+ }
369
+ success(`Secret rotated for client: ${c.client_id || clientId}`);
370
+ info('The previous secret stops working immediately.');
371
+ if (c.client_secret) {
372
+ info('');
373
+ info('New client secret (shown ONCE — store it now, it cannot be retrieved again):');
374
+ info(` ${c.client_secret}`);
375
+ }
376
+ return;
377
+ }
378
+ error(`Unknown action "${action}". Use: myapi auth client <list|create|delete|rotate>`);
286
379
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,103 @@
1
+ // Tests for the auto-recharge client surface: the SDK `withFundsRetry`
2
+ // poll-and-retry helper + the CLI's `autoRechargeSummary` render helper.
3
+ import { describe, it, expect, vi } from 'vitest';
4
+ import { MyApiError, withFundsRetry, isInsufficientFunds, isSpendCapExceeded, autoRechargeState } from '@myapihq/sdk';
5
+ import { autoRechargeSummary } from './billing.js';
6
+ // Build a 402 the way client.ts does — body is the unwrapped `error` object.
7
+ function funds(state, retryAfter) {
8
+ const body = { code: 'INSUFFICIENT_FUNDS', auto_recharge: state };
9
+ if (retryAfter != null)
10
+ body.retry_after_seconds = retryAfter;
11
+ return new MyApiError('INSUFFICIENT_FUNDS', 402, 'insufficient balance', body);
12
+ }
13
+ const noopSleep = () => Promise.resolve();
14
+ describe('withFundsRetry', () => {
15
+ it('returns the value when the call succeeds first try (no retry)', async () => {
16
+ const call = vi.fn().mockResolvedValue('ok');
17
+ await expect(withFundsRetry(call, { sleep: noopSleep })).resolves.toBe('ok');
18
+ expect(call).toHaveBeenCalledTimes(1);
19
+ });
20
+ it('retries on an in-flight refill, then succeeds', async () => {
21
+ const call = vi.fn()
22
+ .mockRejectedValueOnce(funds('in_flight', 0))
23
+ .mockResolvedValueOnce('ok');
24
+ const sleep = vi.fn(noopSleep);
25
+ await expect(withFundsRetry(call, { sleep })).resolves.toBe('ok');
26
+ expect(call).toHaveBeenCalledTimes(2);
27
+ expect(sleep).toHaveBeenCalledTimes(1);
28
+ });
29
+ it('honors the server retry_after_seconds for the sleep duration', async () => {
30
+ const call = vi.fn()
31
+ .mockRejectedValueOnce(funds('in_flight', 7))
32
+ .mockResolvedValueOnce('ok');
33
+ const sleep = vi.fn(noopSleep);
34
+ await withFundsRetry(call, { sleep });
35
+ expect(sleep).toHaveBeenCalledWith(7000);
36
+ });
37
+ it('gives up after maxRetries and rethrows the last error', async () => {
38
+ const call = vi.fn().mockRejectedValue(funds('in_flight', 0));
39
+ await expect(withFundsRetry(call, { maxRetries: 2, sleep: noopSleep }))
40
+ .rejects.toMatchObject({ code: 'INSUFFICIENT_FUNDS' });
41
+ expect(call).toHaveBeenCalledTimes(3); // initial + 2 retries
42
+ });
43
+ it('does NOT retry a capped refill — rethrows immediately (needs a human)', async () => {
44
+ const call = vi.fn().mockRejectedValue(funds('capped'));
45
+ await expect(withFundsRetry(call, { sleep: noopSleep })).rejects.toMatchObject({ code: 'INSUFFICIENT_FUNDS' });
46
+ expect(call).toHaveBeenCalledTimes(1);
47
+ });
48
+ it('does NOT retry no_pm / failed / disabled states', async () => {
49
+ for (const state of ['no_pm', 'failed', 'disabled']) {
50
+ const call = vi.fn().mockRejectedValue(funds(state));
51
+ await expect(withFundsRetry(call, { sleep: noopSleep })).rejects.toBeInstanceOf(MyApiError);
52
+ expect(call).toHaveBeenCalledTimes(1);
53
+ }
54
+ });
55
+ it('does NOT retry a SPEND_CAP_EXCEEDED hard ceiling', async () => {
56
+ const err = new MyApiError('SPEND_CAP_EXCEEDED', 402, 'cap', { code: 'SPEND_CAP_EXCEEDED', cap_cents: 5000 });
57
+ const call = vi.fn().mockRejectedValue(err);
58
+ await expect(withFundsRetry(call, { sleep: noopSleep })).rejects.toBe(err);
59
+ expect(call).toHaveBeenCalledTimes(1);
60
+ });
61
+ it('rethrows non-funds errors untouched', async () => {
62
+ const err = new Error('network down');
63
+ const call = vi.fn().mockRejectedValue(err);
64
+ await expect(withFundsRetry(call, { sleep: noopSleep })).rejects.toBe(err);
65
+ expect(call).toHaveBeenCalledTimes(1);
66
+ });
67
+ });
68
+ describe('funds predicates', () => {
69
+ it('isInsufficientFunds matches both new and legacy codes', () => {
70
+ expect(isInsufficientFunds(funds('disabled'))).toBe(true);
71
+ expect(isInsufficientFunds(new MyApiError('INSUFFICIENT_BALANCE', 402, 'x', {}))).toBe(true);
72
+ expect(isInsufficientFunds(new MyApiError('SPEND_CAP_EXCEEDED', 402, 'x', {}))).toBe(false);
73
+ expect(isInsufficientFunds(new Error('x'))).toBe(false);
74
+ });
75
+ it('isSpendCapExceeded + autoRechargeState read the right fields', () => {
76
+ expect(isSpendCapExceeded(new MyApiError('SPEND_CAP_EXCEEDED', 402, 'x', {}))).toBe(true);
77
+ expect(autoRechargeState(funds('in_flight'))).toBe('in_flight');
78
+ expect(autoRechargeState(new Error('x'))).toBeUndefined();
79
+ });
80
+ });
81
+ describe('autoRechargeSummary', () => {
82
+ const base = {
83
+ enabled: true, threshold_cents: 500, amount_cents: 2000, monthly_cap_cents: 10000,
84
+ month_to_date_recharged_cents: 4000, has_payment_method: true,
85
+ last_recharge_status: 'succeeded', last_recharge_attempt_at: null,
86
+ };
87
+ it('returns null when disabled', () => {
88
+ expect(autoRechargeSummary({ ...base, enabled: false })).toBeNull();
89
+ });
90
+ it('summarizes an enabled config with dollar amounts', () => {
91
+ const s = autoRechargeSummary(base);
92
+ expect(s).toContain('$5.00');
93
+ expect(s).toContain('$20.00');
94
+ expect(s).toContain('$40.00'); // month-to-date
95
+ expect(s).toContain('$100.00'); // cap
96
+ });
97
+ it('flags a failed/capped status but not a healthy/pending one', () => {
98
+ expect(autoRechargeSummary({ ...base, last_recharge_status: 'failed' })).toContain('⚠ failed');
99
+ expect(autoRechargeSummary({ ...base, last_recharge_status: 'capped' })).toContain('⚠ capped');
100
+ expect(autoRechargeSummary({ ...base, last_recharge_status: 'pending' })).not.toContain('⚠');
101
+ expect(autoRechargeSummary(base)).not.toContain('⚠');
102
+ });
103
+ });
@@ -1,3 +1,4 @@
1
+ import { hq } 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';
@@ -9,4 +10,6 @@ export declare function history(flags: Flags): Promise<void>;
9
10
  export declare function usage(flags: Flags): Promise<void>;
10
11
  export declare function topup(amountStr: string, flags: Flags): Promise<void>;
11
12
  export declare function setup(_flags: Flags): Promise<void>;
13
+ export declare function autoRechargeSummary(cfg: hq.AutoRechargeConfig): string | null;
14
+ export declare function autoRecharge(action: string | undefined, flags: Flags): Promise<void>;
12
15
  export declare function spendCap(arg: string | undefined, flags: Flags): Promise<void>;
@@ -5,6 +5,9 @@ import { confirm, isNonInteractive } from '../prompt.js';
5
5
  import { formatDate } from '../utils.js';
6
6
  export const SCHEMA = {
7
7
  period: 'string', // spend-cap window: month | day
8
+ threshold: 'string', // auto-recharge: refill when balance drops below ($)
9
+ amount: 'string', // auto-recharge: how much to refill each time ($)
10
+ 'monthly-cap': 'string', // auto-recharge: max auto-recharged per month ($)
8
11
  };
9
12
  export const EXPOSES = [
10
13
  'GET /hq/billing/balance',
@@ -14,8 +17,26 @@ export const EXPOSES = [
14
17
  'POST /hq/billing/topup',
15
18
  'GET /hq/account/me',
16
19
  'PATCH /hq/account/spend-cap',
20
+ 'GET /hq/billing/auto-recharge',
21
+ 'PUT /hq/billing/auto-recharge',
22
+ 'DELETE /hq/billing/auto-recharge',
17
23
  ];
18
24
  const SUBCOMMAND_USAGE = {
25
+ 'auto-recharge': `myapi billing auto-recharge [show | set | disable]
26
+
27
+ Keep the prepaid wallet funded without a human in the loop: when the balance
28
+ drops below the threshold, MyAPI charges your saved card to refill it, bounded
29
+ by a monthly cap. Off by default; enabling needs a payment method on file.
30
+
31
+ myapi billing auto-recharge Show current config + status
32
+ myapi billing auto-recharge set \\
33
+ --threshold 5 --amount 20 --monthly-cap 100
34
+ Refill to keep ≥$5, $20 at a time,
35
+ up to $100/month
36
+ myapi billing auto-recharge disable Turn off (settings are kept)
37
+
38
+ Amounts are whole dollars. The refill amount must be ≥ $5 and ≥ the threshold;
39
+ the monthly cap must be ≥ the refill amount.`,
19
40
  'balance': 'myapi billing balance [--json]',
20
41
  'history': 'myapi billing history [--json]',
21
42
  'usage': `myapi billing usage [--period month|30d] [--json]
@@ -48,6 +69,7 @@ export async function run(subcommand, args, flags) {
48
69
  info(`Usage: myapi billing <subcommand>
49
70
 
50
71
  Subcommands:
72
+ auto-recharge Keep the wallet funded automatically (show/set/disable)
51
73
  balance Check balance, credits, and payment method status
52
74
  history View recent transactions and top-ups
53
75
  setup Open a checkout link to add or update payment method
@@ -71,6 +93,7 @@ Subcommands:
71
93
  case 'topup': return topup(args[0], flags);
72
94
  case 'setup': return setup(flags);
73
95
  case 'spend-cap': return spendCap(args[0], flags);
96
+ case 'auto-recharge': return autoRecharge(args[0], flags);
74
97
  default: error(`Unknown subcommand: ${subcommand}. Run "myapi billing --help" for available subcommands.`);
75
98
  }
76
99
  }
@@ -165,6 +188,98 @@ export async function setup(_flags) {
165
188
  const result = await hq.setupPayment(config.api_key);
166
189
  success(`Open this URL in your browser to set up payment:\n${result.url}`);
167
190
  }
191
+ function fmtCents(c) {
192
+ return c == null ? '—' : `$${(c / 100).toFixed(2)}`;
193
+ }
194
+ // One-line summary of auto-recharge state, reused by `billing auto-recharge`
195
+ // and `status`. Returns null when off (callers decide whether to show "off").
196
+ export function autoRechargeSummary(cfg) {
197
+ if (!cfg.enabled)
198
+ return null;
199
+ let s = `on — refill to ≥ ${fmtCents(cfg.threshold_cents)}, ${fmtCents(cfg.amount_cents)} each (${fmtCents(cfg.month_to_date_recharged_cents)}/${fmtCents(cfg.monthly_cap_cents)} this month)`;
200
+ if (cfg.last_recharge_status && cfg.last_recharge_status !== 'succeeded' && cfg.last_recharge_status !== 'pending') {
201
+ s += ` ⚠ ${cfg.last_recharge_status}`;
202
+ }
203
+ return s;
204
+ }
205
+ const RECHARGE_STATUS_HINT = {
206
+ failed: 'last refill was declined — check your card: myapi billing setup',
207
+ capped: 'monthly cap reached — refills paused until next month, or raise --monthly-cap',
208
+ no_pm: 'no payment method — add one: myapi billing setup',
209
+ pending: 'a refill is in progress',
210
+ };
211
+ function renderAutoRecharge(cfg) {
212
+ if (!cfg.enabled) {
213
+ info('Auto-recharge: off');
214
+ info(` Payment method on file: ${cfg.has_payment_method ? 'yes' : 'no'}`);
215
+ info(' Enable with: myapi billing auto-recharge set --threshold <$> --amount <$> --monthly-cap <$>');
216
+ if (!cfg.has_payment_method)
217
+ info(' (add a payment method first: myapi billing setup)');
218
+ return;
219
+ }
220
+ info('Auto-recharge: on');
221
+ info(` Refill to keep balance ≥ ${fmtCents(cfg.threshold_cents)}, adding ${fmtCents(cfg.amount_cents)} each time`);
222
+ info(` Monthly cap: ${fmtCents(cfg.month_to_date_recharged_cents)} of ${fmtCents(cfg.monthly_cap_cents)} used this month`);
223
+ info(` Payment method on file: ${cfg.has_payment_method ? 'yes' : 'no'}`);
224
+ if (cfg.last_recharge_status && cfg.last_recharge_status !== 'succeeded') {
225
+ const hint = RECHARGE_STATUS_HINT[cfg.last_recharge_status];
226
+ info(` ⚠ Status: ${cfg.last_recharge_status}${hint ? ` — ${hint}` : ''}`);
227
+ }
228
+ }
229
+ // Keep the wallet funded without a human. No arg / "show" → display config;
230
+ // "set" → enable/update from --threshold/--amount/--monthly-cap (whole
231
+ // dollars); "disable" → turn off (settings preserved for easy re-enable).
232
+ export async function autoRecharge(action, flags) {
233
+ const config = requireConfig();
234
+ const act = action ?? 'show';
235
+ if (act === 'show') {
236
+ const cfg = await hq.getAutoRecharge(config.api_key);
237
+ if (flags.json) {
238
+ printJson(cfg);
239
+ return;
240
+ }
241
+ renderAutoRecharge(cfg);
242
+ return;
243
+ }
244
+ if (act === 'disable') {
245
+ await hq.disableAutoRecharge(config.api_key);
246
+ success('Auto-recharge disabled. Your threshold/amount/cap are kept — re-enable with: myapi billing auto-recharge set');
247
+ return;
248
+ }
249
+ if (act === 'set') {
250
+ const dollarsToCents = (v, name) => {
251
+ if (v == null)
252
+ return undefined;
253
+ const n = Number(v);
254
+ if (!Number.isFinite(n) || n < 0) {
255
+ error(`Invalid --${name} "${v}". Use a whole dollar amount (e.g. --${name} 20).`);
256
+ }
257
+ return Math.round(n * 100);
258
+ };
259
+ const input = { enabled: true };
260
+ const threshold = dollarsToCents(flags.threshold, 'threshold');
261
+ const amount = dollarsToCents(flags.amount, 'amount');
262
+ const cap = dollarsToCents(flags['monthly-cap'], 'monthly-cap');
263
+ if (threshold != null)
264
+ input.threshold_cents = threshold;
265
+ if (amount != null)
266
+ input.amount_cents = amount;
267
+ if (cap != null)
268
+ input.monthly_cap_cents = cap;
269
+ // The backend enforces the invariants (≥$5 floor, amount ≥ threshold,
270
+ // cap ≥ amount, payment method present) and returns a 400 the top-level
271
+ // handler renders — no need to duplicate that validation here.
272
+ const cfg = await hq.setAutoRecharge(config.api_key, input);
273
+ if (flags.json) {
274
+ printJson(cfg);
275
+ return;
276
+ }
277
+ success('Auto-recharge enabled.');
278
+ renderAutoRecharge(cfg);
279
+ return;
280
+ }
281
+ error(`Unknown action "${act}". Use: myapi billing auto-recharge [show | set | disable]`);
282
+ }
168
283
  // The account-level spend ceiling. No arg → show; "clear" → remove; a
169
284
  // dollar amount → set. Distinct from per-key caps (myapi keys create
170
285
  // --spend-cap): this is the aggregate backstop across the whole account.
@@ -3,7 +3,7 @@
3
3
  // `domains` or `emails` sections come back empty (zero-state), the
4
4
  // `setup` augmentation should escalate to actionable warns.
5
5
  import { describe, it, expect } from 'vitest';
6
- import { _setupSection } from './doctor.js';
6
+ import { _setupSection, sanitizeHint, _tallyTotals } from './doctor.js';
7
7
  function mkReport(sections) {
8
8
  return {
9
9
  org_id: 'org-test',
@@ -20,6 +20,12 @@ function populated(name) {
20
20
  id: 'x', severity: 'ok', scope: name, message: 'something',
21
21
  }] };
22
22
  }
23
+ // A section the backend reports as having resources, but with no issues
24
+ // emitted (all healthy, no per-resource ok rows). The issue-count proxy
25
+ // would mis-read this as zero-state; `resource_count` must win.
26
+ function healthyQuiet(name, count) {
27
+ return { name, summary: 'all checks passed', issues: [], resource_count: count };
28
+ }
23
29
  describe('_setupSection', () => {
24
30
  it('returns null when both domains + emails sections are populated', () => {
25
31
  expect(_setupSection(mkReport([populated('domains'), populated('emails')]))).toBeNull();
@@ -71,4 +77,83 @@ describe('_setupSection', () => {
71
77
  const s = _setupSection(mkReport([populated('domains'), populated('emails')]), { mailingAddress: undefined });
72
78
  expect(s).toBeNull();
73
79
  });
80
+ // resource_count is authoritative — it must override the fragile
81
+ // issues.length proxy in both directions.
82
+ it('does NOT flag a healthy-but-quiet domains section (resource_count > 0, no issues)', () => {
83
+ // The bug class this prevents: backend stops emitting per-domain `ok`
84
+ // rows → issues:[] → proxy would falsely say "No domain registered".
85
+ const s = _setupSection(mkReport([healthyQuiet('domains', 2), healthyQuiet('emails', 1)]));
86
+ expect(s).toBeNull();
87
+ });
88
+ it('flags zero-state via resource_count even if an issue happens to be present', () => {
89
+ const domains = {
90
+ name: 'domains', summary: 'x', resource_count: 0,
91
+ issues: [{ id: 'y', severity: 'ok', scope: 'domains', message: 'stale ok row' }],
92
+ };
93
+ const s = _setupSection(mkReport([domains, populated('emails')]));
94
+ expect(s).not.toBeNull();
95
+ expect(s.issues).toHaveLength(1);
96
+ expect(s.issues[0].scope).toBe('setup/domain');
97
+ });
98
+ it('falls back to the issue-count proxy when resource_count is absent', () => {
99
+ // Older backend: no resource_count → empty section still reads as zero-state.
100
+ const s = _setupSection(mkReport([empty('domains'), populated('emails')]));
101
+ expect(s.issues[0].scope).toBe('setup/domain');
102
+ });
103
+ });
104
+ describe('sanitizeHint', () => {
105
+ it('drops a hint referencing an internal docs/ artifact', () => {
106
+ expect(sanitizeHint('see docs/security-debt-pixel-hypersearch-2026-05-27.md item #2 for the fix')).toBeUndefined();
107
+ });
108
+ it('drops a hint referencing an internal code path', () => {
109
+ expect(sanitizeHint('fix in internal/routes/billing/billing.go')).toBeUndefined();
110
+ expect(sanitizeHint('see packages/cli/src/commands/doctor.ts')).toBeUndefined();
111
+ });
112
+ it('keeps actionable command hints unchanged', () => {
113
+ const cmd = 'Create one with: myapi email mailbox create <username>@<your-domain>';
114
+ expect(sanitizeHint(cmd)).toBe(cmd);
115
+ expect(sanitizeHint('attach a workflow if you want a notification on each submission'))
116
+ .toBe('attach a workflow if you want a notification on each submission');
117
+ });
118
+ it('does not false-match a bare word with a dotted suffix (no slash)', () => {
119
+ // "node.js" / "data.object" have no path slash → not an internal artifact.
120
+ expect(sanitizeHint('deploy your node.js app')).toBe('deploy your node.js app');
121
+ });
122
+ it('passes through undefined', () => {
123
+ expect(sanitizeHint(undefined)).toBeUndefined();
124
+ });
125
+ });
126
+ describe('_tallyTotals', () => {
127
+ function sec(name, issues) {
128
+ return {
129
+ name, summary: '', issues: issues.map((i, n) => ({
130
+ id: `${name}-${n}`, severity: 'warn', scope: name, message: 'm', ...i,
131
+ })),
132
+ };
133
+ }
134
+ it('counts plain severities', () => {
135
+ const t = _tallyTotals([sec('a', [{ severity: 'crit' }, { severity: 'warn' }, { severity: 'ok' }])]);
136
+ expect(t).toEqual({ ok: 1, warn: 1, crit: 1, operator: 0 });
137
+ });
138
+ it('pulls an operator_only critical OUT of crit and into operator', () => {
139
+ // The pixel case: a platform-side critical must not count as a customer
140
+ // critical (so it won't set exit code 1).
141
+ const t = _tallyTotals([sec('pixel', [{ severity: 'crit', operator_only: true }])]);
142
+ expect(t.crit).toBe(0);
143
+ expect(t.operator).toBe(1);
144
+ });
145
+ it('pulls an operator_only warning into operator too', () => {
146
+ const t = _tallyTotals([sec('x', [{ severity: 'warn', operator_only: true }, { severity: 'warn' }])]);
147
+ expect(t.warn).toBe(1);
148
+ expect(t.operator).toBe(1);
149
+ });
150
+ it('still counts a real customer critical alongside an operator_only one', () => {
151
+ const t = _tallyTotals([sec('x', [{ severity: 'crit' }, { severity: 'crit', operator_only: true }])]);
152
+ expect(t.crit).toBe(1); // exit code WILL be 1 — there's a real one
153
+ expect(t.operator).toBe(1);
154
+ });
155
+ it('treats an operator_only ok row as a passing check, not an operator issue', () => {
156
+ const t = _tallyTotals([sec('x', [{ severity: 'ok', operator_only: true }])]);
157
+ expect(t).toEqual({ ok: 1, warn: 0, crit: 0, operator: 0 });
158
+ });
74
159
  });
@@ -4,6 +4,14 @@ import { type Flags } from '../helpers.js';
4
4
  import type { Exposes } from '../exposes.js';
5
5
  export declare const EXPOSES: Exposes;
6
6
  export declare const SCHEMA: FlagSchema;
7
+ export interface DoctorTotals {
8
+ ok: number;
9
+ warn: number;
10
+ crit: number;
11
+ operator: number;
12
+ }
13
+ export declare function _tallyTotals(sections: sdkHq.DoctorSection[]): DoctorTotals;
14
+ export declare function sanitizeHint(hint: string | undefined): string | undefined;
7
15
  export interface SetupContext {
8
16
  mailingAddress?: string | null;
9
17
  }
@@ -41,6 +41,33 @@ const MARK = {
41
41
  warn: `${C.warn}⚠${C.reset}`,
42
42
  crit: `${C.err}✗${C.reset}`,
43
43
  };
44
+ // Operator-only issues are platform-side and not the customer's to fix. Render
45
+ // them with a distinct, non-alarming marker so they don't read as a failure the
46
+ // customer must act on — regardless of the backend severity.
47
+ const OPERATOR_MARK = `${C.dim}ℹ${C.reset}`;
48
+ // Tally issues for the exit code + summary. `operator_only` warn/crit issues
49
+ // are pulled into their own `operator` bucket and OUT of crit/warn: a
50
+ // platform-side problem the customer can't fix must not fail their `doctor`
51
+ // run (exit 1) or inflate their critical count. Everything else tallies by
52
+ // severity as before; any ok row (operator or not) counts as ok.
53
+ export function _tallyTotals(sections) {
54
+ const t = { ok: 0, warn: 0, crit: 0, operator: 0 };
55
+ for (const s of sections) {
56
+ for (const i of s.issues) {
57
+ if (i.severity === 'crit' || i.severity === 'warn') {
58
+ if (i.operator_only)
59
+ t.operator++;
60
+ else
61
+ t[i.severity]++;
62
+ }
63
+ else if (i.severity === 'ok') {
64
+ t.ok++;
65
+ }
66
+ // Unknown severities the backend might invent are ignored (no NaN).
67
+ }
68
+ }
69
+ return t;
70
+ }
44
71
  function rule(width = 60) {
45
72
  return `${C.dim}${'─'.repeat(width)}${C.reset}`;
46
73
  }
@@ -51,14 +78,43 @@ function rule(width = 60) {
51
78
  function localIssueId(kind, key) {
52
79
  return `${kind}/${createHash('sha256').update(key).digest('hex').slice(0, 16)}`;
53
80
  }
81
+ // Backend hints are sometimes written for operators, not customers — e.g.
82
+ // "see docs/security-debt-pixel-hypersearch-2026-05-27.md item #2 for the fix".
83
+ // A reference to an internal repo artifact (a file path with a doc/code
84
+ // extension, or anything under `docs/`) is useless to — and leaks internals
85
+ // at — the customer running `myapi doctor`. Drop the hint entirely in that
86
+ // case: the issue's `message` still conveys what's wrong; a hint that points
87
+ // nowhere the user can go is worse than no hint. (Root fix is backend-side
88
+ // hint hygiene — see docs/cross-repo-prompts/backend-doctor-quality-*.md.)
89
+ const INTERNAL_ARTIFACT_RE = /\bdocs\/[\w.\-/]+|\b[\w.\-]+\/[\w.\-/]*\.(?:md|go|tsx?|jsx?|json|ya?ml|sql)\b/i;
90
+ export function sanitizeHint(hint) {
91
+ if (!hint)
92
+ return undefined;
93
+ return INTERNAL_ARTIFACT_RE.test(hint) ? undefined : hint;
94
+ }
54
95
  function fmtIssue(i) {
55
96
  // Prefix the entity name so sibling issues with identical messages
56
97
  // (e.g. four "domain is active" rows) are distinguishable. Skip it when
57
98
  // the message already names the entity — the local DNS section does.
58
99
  const name = i.entity?.name;
59
100
  const label = name && !i.message.includes(name) ? `${C.bold}${name}${C.reset} — ` : '';
60
- const head = ` ${MARK[i.severity] ?? '·'} ${label}${i.message}`;
61
- return i.hint ? `${head}\n ${C.dim} ${i.hint}${C.reset}` : head;
101
+ const mark = i.operator_only ? OPERATOR_MARK : (MARK[i.severity] ?? '·');
102
+ const head = ` ${mark} ${label}${i.message}`;
103
+ const hint = sanitizeHint(i.hint);
104
+ return hint ? `${head}\n ${C.dim}→ ${hint}${C.reset}` : head;
105
+ }
106
+ // "Does this section's org have zero resources of its kind?" Prefer the
107
+ // backend's authoritative `resource_count` when present; fall back to the
108
+ // issue-count proxy only for older backends that omit it. The proxy is
109
+ // fragile — it reads "section emitted no issues" as "no resources", which
110
+ // holds today only because the backend emits an `ok` row per healthy
111
+ // resource. The moment a section returns `issues: []` for an all-healthy
112
+ // org, the proxy would mis-fire (e.g. "No domain registered" for an org
113
+ // that has a live domain). `resource_count` removes that coupling.
114
+ function isZeroState(section) {
115
+ if (typeof section.resource_count === 'number')
116
+ return section.resource_count === 0;
117
+ return section.issues.length === 0;
62
118
  }
63
119
  export function _setupSection(report, ctx = {}) {
64
120
  const byName = new Map();
@@ -66,7 +122,7 @@ export function _setupSection(report, ctx = {}) {
66
122
  byName.set(s.name, s);
67
123
  const issues = [];
68
124
  const dom = byName.get('domains');
69
- if (dom && dom.issues.length === 0) {
125
+ if (dom && isZeroState(dom)) {
70
126
  issues.push({
71
127
  id: localIssueId('setup_no_domain', report.org_id),
72
128
  severity: 'warn',
@@ -77,7 +133,7 @@ export function _setupSection(report, ctx = {}) {
77
133
  });
78
134
  }
79
135
  const em = byName.get('emails');
80
- if (em && em.issues.length === 0) {
136
+ if (em && isZeroState(em)) {
81
137
  issues.push({
82
138
  id: localIssueId('setup_no_mailbox', report.org_id),
83
139
  severity: 'warn',
@@ -283,18 +339,12 @@ export async function run(_subcommand, _args, flags) {
283
339
  const reachSection = await httpProbeSection(apiKey, orgId);
284
340
  if (reachSection)
285
341
  report.sections.push(reachSection);
286
- // Re-tally totals after local augmentation.
287
- const totals = { ok: 0, warn: 0, crit: 0 };
288
- for (const s of report.sections) {
289
- for (const i of s.issues) {
290
- // Ignore any severity the backend invents that we don't model — better
291
- // a missed count than a NaN poisoning the whole tally.
292
- if (i.severity in totals)
293
- totals[i.severity]++;
294
- }
295
- }
342
+ // Re-tally totals after local augmentation. `operator_only` issues land in
343
+ // their own bucket and out of crit/warn (see _tallyTotals).
344
+ const totals = _tallyTotals(report.sections);
296
345
  // Set the exit code before any output branch — CI relies on it in both
297
- // the human and the --json path.
346
+ // the human and the --json path. Only customer-actionable criticals fail
347
+ // the run; a platform-side (operator_only) critical does not.
298
348
  if (totals.crit)
299
349
  process.exitCode = 1;
300
350
  if (wantJson) {
@@ -322,6 +372,11 @@ export async function run(_subcommand, _args, flags) {
322
372
  else {
323
373
  info(`${MARK.ok} ${totals.ok} check${totals.ok === 1 ? '' : 's'} passed`);
324
374
  }
375
+ // Platform-side issues are surfaced for transparency but don't count against
376
+ // the org or fail the run — make that explicit.
377
+ if (totals.operator) {
378
+ info(`${OPERATOR_MARK} ${C.dim}${totals.operator} platform-side issue${totals.operator === 1 ? '' : 's'} the MyAPI team is handling (not counted against your org)${C.reset}`);
379
+ }
325
380
  if (!verbose)
326
381
  info(`${C.dim}(--verbose to show passing checks · --json for machine-readable)${C.reset}`);
327
382
  }
@@ -3,9 +3,11 @@ import { hq, domain, funnel, webhook, workflow, MyApiError } from '@myapihq/sdk'
3
3
  // filter, so there is no global "all mailboxes in this org" call. Use
4
4
  // `myapi email mailbox list --domain <d>` for per-domain detail.
5
5
  import { loadConfig } from '../config.js';
6
+ import { autoRechargeSummary } from './billing.js';
6
7
  import { info, error, printJson } from '../output.js';
7
8
  export const EXPOSES = [
8
9
  'GET /hq/billing/balance',
10
+ 'GET /hq/billing/auto-recharge',
9
11
  'GET /hq/account/free-tier',
10
12
  'GET /domain/orgs/{org_id}/list',
11
13
  'GET /funnel/orgs/{org_id}/funnels',
@@ -44,8 +46,10 @@ export async function run(_subcommand, _args, flags = {}) {
44
46
  const accountResults = await Promise.allSettled([
45
47
  hq.getBalance(config.api_key),
46
48
  hq.getFreeTier(config.api_key),
49
+ hq.getAutoRecharge(config.api_key),
47
50
  ]);
48
51
  const balance = unwrap(accountResults[0]);
52
+ const autoR = unwrap(accountResults[2]);
49
53
  // The backend currently returns an array of { service, used, allowance }
50
54
  // per-service entries. The SDK type lies about this (claims scalar) — same
51
55
  // bug surfaces in `whoami`. Treat as unknown here and narrow at render time.
@@ -78,6 +82,7 @@ export async function run(_subcommand, _args, flags = {}) {
78
82
  key_rejected: keyRejected,
79
83
  },
80
84
  balance,
85
+ auto_recharge: autoR,
81
86
  free_tier: freeTier,
82
87
  org_resources: orgKnown ? {
83
88
  domains, funnels, webhooks, workflows,
@@ -100,6 +105,13 @@ export async function run(_subcommand, _args, flags = {}) {
100
105
  if (config.is_anonymous) {
101
106
  info(' → Link an email to unlock $5 free credit + paid actions: myapi account link <email>');
102
107
  }
108
+ // Auto-recharge: only worth a line when it's on (or off but a refill recently
109
+ // failed/capped and needs attention).
110
+ if (autoR) {
111
+ const summary = autoRechargeSummary(autoR);
112
+ if (summary)
113
+ info(`Recharge: ${summary}`);
114
+ }
103
115
  if (Array.isArray(freeTier) && freeTier.length > 0) {
104
116
  const parts = freeTier
105
117
  .filter((e) => e && typeof e.service === 'string')
package/dist/index.js CHANGED
@@ -296,19 +296,40 @@ async function main() {
296
296
  if (err.status === 401)
297
297
  error('Invalid API key. Run: myapi account setup');
298
298
  else if (err.status === 402) {
299
+ const body = (err.body ?? {});
299
300
  if (err.code === 'REGISTRATION_REQUIRED' || err.code === 'UPGRADE_REQUIRED')
300
301
  error('A verified email is required. Run: myapi account link');
301
302
  else if (err.code === 'NO_PAYMENT_METHOD')
302
303
  error('No payment method on file. Run: myapi billing setup');
304
+ else if (err.code === 'SPEND_CAP_EXCEEDED') {
305
+ // A self-imposed ceiling, NOT an empty wallet — topping up won't
306
+ // help. Point at the cap, not the balance.
307
+ const cap = typeof body.cap_cents === 'number' ? `$${(body.cap_cents / 100).toFixed(2)}` : 'your cap';
308
+ const spent = typeof body.spent_cents === 'number' ? `$${(body.spent_cents / 100).toFixed(2)} of ` : '';
309
+ const period = typeof body.period === 'string' ? body.period : 'period';
310
+ error(`Spend cap reached (${spent}${cap} this ${period}). This is a ceiling you set, not an empty wallet — raise or clear it: myapi billing spend-cap`);
311
+ }
303
312
  else {
304
- // Anonymous accounts can't top up (no payment surface). The
305
- // natural unblock is to link an email the upgrade grants the
306
- // free credit and opens the paid surface.
313
+ // INSUFFICIENT_FUNDS (or legacy INSUFFICIENT_BALANCE): empty wallet.
314
+ // Tailor the unblock to the auto-recharge state the backend reports
315
+ // so an agent knows whether to wait, fix a card, or top up.
316
+ const state = body.auto_recharge;
307
317
  const cfg = loadConfig();
308
- if (cfg?.is_anonymous) {
309
- error('Insufficient balance. Anonymous accounts have no free credit link an email to unlock $5: myapi account link <email>');
318
+ if (state === 'in_flight') {
319
+ const secs = typeof body.retry_after_seconds === 'number' ? body.retry_after_seconds : 5;
320
+ error(`Wallet empty — auto-recharge is in flight; retry in ~${secs}s.`);
310
321
  }
311
- error('Insufficient balance. Run: myapi billing topup <amount>');
322
+ else if (state === 'capped')
323
+ error('Wallet empty and auto-recharge hit your monthly cap. Raise it: myapi billing auto-recharge set --monthly-cap <amount>, or top up: myapi billing topup <amount>');
324
+ else if (state === 'failed')
325
+ error('Wallet empty and the last auto-recharge was declined. Fix your card: myapi billing setup, or top up: myapi billing topup <amount>');
326
+ else if (state === 'no_pm')
327
+ error('Wallet empty and auto-recharge has no payment method. Add one: myapi billing setup, or top up: myapi billing topup <amount>');
328
+ // Anonymous accounts can't top up (no payment surface) — link to unlock.
329
+ else if (cfg?.is_anonymous)
330
+ error('Insufficient balance. Anonymous accounts have no free credit — link an email to unlock $5: myapi account link <email>');
331
+ else
332
+ error('Insufficient balance. Top up: myapi billing topup <amount> — or keep it funded automatically: myapi billing auto-recharge set');
312
333
  }
313
334
  }
314
335
  else
@@ -8,6 +8,7 @@ Core identity and billing hub for the MyAPI ecosystem. **Start here** — every
8
8
  - API key creation and management
9
9
  - Organization management (`org_id` used by all other services)
10
10
  - Balance top-up and billing history
11
+ - Org-wide health check (`myapi doctor`)
11
12
 
12
13
  ## Quickstart
13
14
 
@@ -3,7 +3,7 @@ name: my-api-hq
3
3
  version: 1.0.0
4
4
  description: >
5
5
  Auth, organizations, and billing hub. Start here to get an api_key and org_id — every other service depends on both.
6
- triggers: [api key, account, organization, org, billing, balance, topup, credits, setup, defaults, brand, sync brand]
6
+ triggers: [api key, account, organization, org, billing, balance, topup, credits, setup, defaults, brand, sync brand, doctor, health check, is my org healthy]
7
7
  checksum: sha256-pending
8
8
  ---
9
9
 
@@ -13,7 +13,7 @@ The root service. It manages accounts, API keys, organizations, and billing. No
13
13
 
14
14
  ## Capabilities
15
15
  <!-- llm:start -->
16
- MyApiHQ is the platform's foundation. Every other service (domain, funnel, email, image, storage, pixel, webhook, workflow, url) requires both an `api_key` and (for org-scoped resources) an `org_id` minted here. Setup is one command — `myapi account setup` — which provisions an account, generates an api_key, creates a default org, and stores everything in `~/.myapi/config.json`. Subsequent commands pick up those defaults automatically.
16
+ MyApiHQ is the platform's foundation. Every other service (domain, funnel, auth, payments, fn, workflow, database, storage, email, webhook, crm, llm, image, pixel, url) requires both an `api_key` and (for org-scoped resources) an `org_id` minted here. Setup is one command — `myapi account setup` — which provisions an account, generates an api_key, creates a default org, and stores everything in `~/.myapi/config.json`. Subsequent commands pick up those defaults automatically.
17
17
 
18
18
  ```
19
19
  myapihq ──► org_id + api_key
@@ -30,7 +30,11 @@ Two tiers, chosen at setup time:
30
30
  - **Anonymous** (`myapi account setup --anonymous`): zero-friction account creation. **Starts with $0 credit.** Good for catalog browsing, reading help, inspecting schemas — nothing that costs upstream money. The agent-onboarding path: provisions an account in one call, no email needed.
31
31
  - **Registered** (verified email via `myapi account link <email>`): unlocks $5 free credit and the paid surface (LLM, image, email, domain register, etc.). Required for `myapi billing setup` and anything that hits Stripe.
32
32
 
33
- An anonymous account can upgrade at any time via `myapi account link <email>` — the credit grants on successful verification. Anonymous accounts that need paid actions hit a friendly `INSUFFICIENT_BALANCE` or `REGISTRATION_REQUIRED` error pointing at `myapi account link`.
33
+ An anonymous account can upgrade at any time via `myapi account link <email>` — the credit grants on successful verification. Anonymous accounts that need paid actions hit a friendly `REGISTRATION_REQUIRED` error pointing at `myapi account link`; a registered account with an empty wallet hits `402 INSUFFICIENT_FUNDS` (top up, or enable auto-recharge — below).
34
+
35
+ ### Health check
36
+
37
+ `myapi doctor` runs an org-wide consistency check across every slot (funnels, webhooks, domains, containers, workflows, emails, payments) and layers on customer-perspective DNS/HTTP probes from your machine. It returns per-section findings (`✓` pass / `⚠` warning / `✗` critical) with remediation hints; add `--json` for machine output. The exit code is non-zero **only** on customer-actionable criticals — platform-side issues the MyAPI team is already handling are surfaced with an `ℹ` marker but don't fail the run. Run it to self-check before building (is the org set up?) and after (did everything wire up?).
34
38
  <!-- llm:end -->
35
39
 
36
40
  ## Commands
@@ -53,9 +57,11 @@ An anonymous account can upgrade at any time via `myapi account link <email>`
53
57
  | `myapi billing history` | Recent transactions |
54
58
  | `myapi billing usage [--period month|30d]` | Spend rolled up by service (this month, or trailing 30d) |
55
59
  | `myapi billing spend-cap [<amount> | clear] [--period month|day]` | Set/show/clear the account-level spend ceiling (IAM Layer 2) |
60
+ | `myapi billing auto-recharge [show \| set \| disable]` | Keep the wallet funded automatically — off-session refill when balance drops below a threshold, bounded by a monthly cap |
56
61
  | `myapi account mailing-address ["<address>"]` | Get or set the account's CAN-SPAM mailing address (required for email send) |
57
62
  | `myapi config set-org <id>` / `set-funnel <id>` / `set-domain <name>` | Set CLI defaults |
58
63
  | `myapi install-skills` | Install agent skill files into ~/.claude/, ~/.gemini/, ~/.cursor/ |
64
+ | `myapi doctor [--verbose] [--json]` | Org-wide health check: per-slot config/integrity findings + DNS/HTTP probes |
59
65
  <!-- generated:end -->
60
66
 
61
67
  ## Examples
@@ -70,6 +76,10 @@ myapi account whoami # confirm what's active
70
76
  myapi billing balance # before doing anything that costs credits
71
77
  myapi billing topup 20 # add $20
72
78
 
79
+ # For unattended/autonomous runs: keep the wallet funded so a 402 never
80
+ # stalls the agent. Refill to ≥$5, $20 at a time, up to $100/month.
81
+ myapi billing auto-recharge set --threshold 5 --amount 20 --monthly-cap 100
82
+
73
83
  # Sync brand info from an existing website
74
84
  myapi org sync-brand acme.com
75
85
 
@@ -77,7 +87,7 @@ myapi org sync-brand acme.com
77
87
  myapi account switch 2
78
88
  ```
79
89
 
80
- If any service returns `402`, run `myapi billing balance` and top up before retrying.
90
+ If any service returns `402 INSUFFICIENT_FUNDS`, top up (`myapi billing topup`) or enable `myapi billing auto-recharge` so it refills itself. A `402 SPEND_CAP_EXCEEDED` is different: you hit a spend ceiling you set, so raise it with `myapi billing spend-cap` rather than topping up.
81
91
 
82
92
  Each org gets a free preview subdomain (`*.makeautonomous.com`) usable before registering a custom domain.
83
93
  <!-- llm:end -->
@@ -39,9 +39,10 @@ its clients.
39
39
  returns a `client_secret` **once** — store it immediately. `--redirect` lists
40
40
  allowed callback URIs (absolute https, or http://localhost for dev).
41
41
  - **Usage** — `auth usage` shows monthly active users (auth is billed per MAU).
42
- - **Custom domain** — `auth domain set --domain auth.acme.com` serves auth on
43
- your own domain; it prints the DNS record, TLS provisions automatically, and
44
- the domain becomes your issuer once active.
42
+ - **Custom domain** — serve auth on `auth.acme.com`. Three steps: `auth domain
43
+ set --domain auth.acme.com` prints a **TXT ownership challenge**; publish it,
44
+ then `auth domain verify`; that prints the **A record** to publish, TLS
45
+ provisions automatically, and the domain becomes your issuer once active.
45
46
 
46
47
  **Sign-in methods** are chosen per tenant via
47
48
  `auth tenant create --connections google,password,magic` (default `google`):
@@ -61,8 +62,10 @@ CLI is only the management surface.
61
62
  | `myapi auth tenant create` | Create/enable the tenant (`--connections google,password,magic`; `--theme <json>`) |
62
63
  | `myapi auth client list` | List the OIDC clients (apps) registered to your tenant |
63
64
  | `myapi auth client create` | Register an OIDC client (`--name`, `--type spa\|web`, `--redirect`) |
64
- | `myapi auth usage` | Monthly active users (MAU) for the current period |
65
- | `myapi auth domain` | Show/set/delete a custom auth domain (`set --domain auth.acme.com`) |
65
+ | `myapi auth client delete <id>` | Revoke a client (irreversible); `--yes` to skip the confirm |
66
+ | `myapi auth client rotate <id>` | Re-issue a `web` client's secret (shown once) |
67
+ | `myapi auth usage` | Monthly active users (MAU) for the current period (`as_of` shows freshness) |
68
+ | `myapi auth domain` | Custom auth domain: `set` (TXT challenge) → `verify` → A record; also `show`/`delete` |
66
69
  <!-- generated:end -->
67
70
 
68
71
  ## Examples
@@ -109,4 +112,4 @@ myapi auth client list
109
112
  be retrieved later — store it immediately. SPA clients have no secret.
110
113
  - Redirect URIs are matched exactly: absolute `https://…` (or
111
114
  `http://localhost…` for local dev).
112
- - `402` errors mean insufficient credits — run `myapi billing topup <amount>`.
115
+ - `402 INSUFFICIENT_FUNDS` = empty wallet `myapi billing topup <amount>` (or keep it funded automatically: `myapi billing auto-recharge set`). `402 SPEND_CAP_EXCEEDED` = you hit your account spend ceiling → raise it with `myapi billing spend-cap`.
@@ -114,6 +114,6 @@ Set `essentially_off` + `browser-check=off` to allow AI crawlers and training bo
114
114
 
115
115
  - `register`, `renew`, `assign`, and `unassign` always require an explicit domain argument — they don't fall back to a stored default, to prevent accidental destructive actions.
116
116
  - All commands default to `--org` from your saved config (set with `myapi config set-org <id>`).
117
- - `402` errors mean insufficient credits — run `myapi billing topup <amount>`.
117
+ - `402 INSUFFICIENT_FUNDS` = empty wallet `myapi billing topup <amount>` (or keep it funded automatically: `myapi billing auto-recharge set`). `402 SPEND_CAP_EXCEEDED` = you hit your account spend ceiling → raise it with `myapi billing spend-cap`.
118
118
 
119
119
  Run `myapi domain --help` or `myapi domain <subcommand> --help` for full flag reference.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  # my-email-api
3
3
 
4
- Send transactional email and run drip campaigns from mailboxes on your own registered domains. Includes AI template generation, warmup, and inbox/outbox reading.
4
+ Send transactional email from mailboxes on your own registered domains. Includes AI template generation, warmup, and inbox/outbox reading.
5
5
 
6
6
  ## What it does
7
7
 
@@ -9,7 +9,6 @@ Send transactional email and run drip campaigns from mailboxes on your own regis
9
9
  - Send transactional emails (one-shot or templated)
10
10
  - Read inbox, outbox, sent history, and per-message status
11
11
  - Generate HTML email templates with AI from a prompt
12
- - Run paced drip campaigns against uploaded contact lists
13
12
  - Manage IP/domain warmup for sender reputation
14
13
 
15
14
  ## Quickstart
@@ -36,7 +35,7 @@ export MYAPI_KEY=mak_...
36
35
  Requires:
37
36
  - An `api_key` from **myapihq**
38
37
  - A registered domain via **mydomainapi**, assigned to your org
39
- - Default `org_id` (for templates/campaigns) — set with `myapi account config set-org <id>`
38
+ - Default `org_id` (for templates) — set with `myapi account config set-org <id>`
40
39
 
41
40
  ## Documentation
42
41
 
@@ -115,6 +115,6 @@ myapi funnel form <funnel_id> --slug survey \
115
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
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.
117
117
  - Deleting a funnel purges all its edge pages immediately.
118
- - `402` errors mean insufficient credits run `myapi billing topup <amount>`.
118
+ - `402 INSUFFICIENT_FUNDS` top up (`myapi billing topup`) or enable `myapi billing auto-recharge`.
119
119
 
120
120
  Run `myapi funnel --help` or `myapi funnel <subcommand> --help` for full flag reference.
@@ -32,7 +32,7 @@ myapi llm draft --kind email --prompt "Friendly welcome, under 60 words"
32
32
  export MYAPI_KEY=mak_...
33
33
  ```
34
34
 
35
- Requires `api_key` and `org_id` from **myapihq**. Inference cost is debited from your MyAPI balance — top up via `myapi billing topup`.
35
+ Requires `api_key` and `org_id` from **myapihq**. Inference cost is debited from your MyAPI balance — top up via `myapi billing topup`, or keep it funded automatically with `myapi billing auto-recharge`.
36
36
 
37
37
  ## When to use
38
38
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@myapihq/cli",
3
3
  "license": "Apache-2.0",
4
- "version": "2.0.1",
4
+ "version": "2.2.0",
5
5
  "description": "MyAPI command-line interface",
6
6
  "type": "module",
7
7
  "files": [
@@ -31,7 +31,7 @@
31
31
  "lint:skills:strict": "node scripts/copy-skills.js && node scripts/lint-skills.js --strict"
32
32
  },
33
33
  "dependencies": {
34
- "@myapihq/sdk": "^2.0.1"
34
+ "@myapihq/sdk": "^2.2.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^25.6.0",