@myapihq/cli 2.0.1 → 2.1.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.
@@ -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
 
@@ -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 -->
@@ -109,4 +109,4 @@ myapi auth client list
109
109
  be retrieved later — store it immediately. SPA clients have no secret.
110
110
  - Redirect URIs are matched exactly: absolute `https://…` (or
111
111
  `http://localhost…` for local dev).
112
- - `402` errors mean insufficient credits — run `myapi billing topup <amount>`.
112
+ - `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.1.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.1.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^25.6.0",