@myapihq/cli 2.31.5 → 2.31.7

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.
@@ -421,6 +421,10 @@ export async function updateSettings(domainArg, flags) {
421
421
  error('Nothing to update. Pass at least one of --security=<level>, --browser-check=<on|off>, --purge-cache.');
422
422
  }
423
423
  const res = await sdkDomain.updateDomainSettings(config.api_key, orgId, domain, payload);
424
+ if (flags.json) {
425
+ printJson(res);
426
+ return;
427
+ }
424
428
  success(`Updated settings for ${domain}!\n${JSON.stringify(res, null, 2)}`);
425
429
  }
426
430
  // ── DNS records sub-surface (`myapi domain records ...`) ────────────────────
@@ -72,6 +72,10 @@ async function activateSending(flags) {
72
72
  if (!address)
73
73
  error('Missing required arguments.\nUsage: myapi email mailbox activate-sending --address <email>');
74
74
  const res = await sdkEmail.activateSending(config.api_key, address);
75
+ if (flags.json) {
76
+ printJson(res);
77
+ return;
78
+ }
75
79
  // A repaired account was broken a moment ago: it held a mailbox that reported
76
80
  // sending_enabled while the ACCOUNT had no credential behind it, so every send
77
81
  // failed with nothing in the mailbox or domain to explain why. Reporting that
@@ -85,12 +89,16 @@ async function activateSending(flags) {
85
89
  }
86
90
  success(`Sending activated: ${address} (${res.emails_quota_remaining} emails/day quota)`);
87
91
  }
88
- async function setForwarding(address, forwardTo, _flags) {
92
+ async function setForwarding(address, forwardTo, flags) {
89
93
  const config = requireConfig();
90
94
  if (!address || !forwardTo) {
91
95
  error('Missing required arguments.\nUsage: myapi email mailbox set-forwarding <user@domain> <forward-to@domain>');
92
96
  }
93
97
  const res = await sdkEmail.setForwarding(config.api_key, address, forwardTo);
98
+ if (flags.json) {
99
+ printJson(res);
100
+ return;
101
+ }
94
102
  success(`Forwarding set: ${res.address} → ${res.forward_to}`);
95
103
  info('A copy of every incoming message is redirected; the original is kept in the mailbox.');
96
104
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,64 @@
1
+ // An html-only `email message send` arrived with an EMPTY text/plain part.
2
+ //
3
+ // The backend adds the multipart alternative but does not fill it (filed
4
+ // 2026-08-23, still open backend-side). Found live by the deep probe's email
5
+ // journey on its FIRST run with a real fixture, 2026-08-25 — the exact defect
6
+ // the step was written for, invisible to everything that does not read a
7
+ // delivered message. The CLI now derives the plain part from the HTML, so the
8
+ // customer-facing defect is closed regardless of when the backend fills its
9
+ // half.
10
+ import { describe, it, expect, vi } from 'vitest';
11
+ const sdk = vi.hoisted(() => ({
12
+ email: { sendEmail: vi.fn(async () => ({ message_id: 'm-1' })) },
13
+ MyApiError: class MyApiError extends Error {
14
+ code = '';
15
+ status = 0;
16
+ },
17
+ // retryFunds in utils.js wraps sends with the 402 auto-recharge loop.
18
+ withFundsRetry: (fn) => fn(),
19
+ isInsufficientFunds: () => false,
20
+ autoRechargeState: () => undefined,
21
+ }));
22
+ vi.mock('@myapihq/sdk', () => sdk);
23
+ vi.mock('../../config.js', () => ({
24
+ requireConfig: () => ({ api_key: 'hq_live_test' }),
25
+ loadConfig: () => ({ api_key: 'hq_live_test' }),
26
+ CONFIG_DIR: '/tmp/nowhere',
27
+ }));
28
+ import { run, plainTextFromHtml } from './message.js';
29
+ import { withSink } from '../../output.js';
30
+ const SINK = { out: () => { }, err: () => { }, fail: (m) => { throw new Error(m); } };
31
+ describe('html-only send carries a derived text part', () => {
32
+ it('fills text from the html when --body is absent', async () => {
33
+ await withSink(SINK, () => run('send', [], {
34
+ from: 'a@x.co', to: 'b@x.co', subject: 's',
35
+ html: '<h1>Hello</h1><p>Two &amp; two.</p>',
36
+ }));
37
+ const payload = sdk.email.sendEmail.mock.calls.at(-1)[1];
38
+ expect(payload.text).toBe('Hello\nTwo & two.');
39
+ expect(payload.html).toContain('<h1>');
40
+ });
41
+ it('never overwrites an explicit --body', async () => {
42
+ await withSink(SINK, () => run('send', [], {
43
+ from: 'a@x.co', to: 'b@x.co', subject: 's',
44
+ html: '<p>rich</p>', body: 'my own words',
45
+ }));
46
+ expect(sdk.email.sendEmail.mock.calls.at(-1)[1].text).toBe('my own words');
47
+ });
48
+ it('text-only send is untouched', async () => {
49
+ await withSink(SINK, () => run('send', [], {
50
+ from: 'a@x.co', to: 'b@x.co', subject: 's', body: 'plain',
51
+ }));
52
+ const payload = sdk.email.sendEmail.mock.calls.at(-1)[1];
53
+ expect(payload.text).toBe('plain');
54
+ expect(payload.html).toBeUndefined();
55
+ });
56
+ });
57
+ describe('plainTextFromHtml', () => {
58
+ it('turns block boundaries into line breaks and strips the rest', () => {
59
+ expect(plainTextFromHtml('<div>a</div><div>b<br>c</div>')).toBe('a\nb\nc');
60
+ });
61
+ it('unescapes the common entities', () => {
62
+ expect(plainTextFromHtml('<p>&lt;tag&gt; &quot;q&quot; &amp; more&nbsp;here</p>')).toBe('<tag> "q" & more here');
63
+ });
64
+ });
@@ -1,5 +1,6 @@
1
1
  import type { Flags } from '../../helpers.js';
2
2
  import type { Exposes } from '../../exposes.js';
3
3
  export declare const EXPOSES: Exposes;
4
+ export declare function plainTextFromHtml(html: string): string;
4
5
  export declare const SUBCOMMAND_USAGE: Record<string, string>;
5
6
  export declare function run(sub: string | undefined, args: string[], flags: Flags): Promise<void>;
@@ -21,6 +21,21 @@ function summarizeMessage(m) {
21
21
  received_at: m.received_at,
22
22
  };
23
23
  }
24
+ // Block-level tags become line breaks, everything else is stripped, entities
25
+ // beyond the common five are left alone. Exported for the test.
26
+ export function plainTextFromHtml(html) {
27
+ return html
28
+ .replace(/<\s*(br|\/p|\/div|\/h[1-6]|\/li|\/tr)[^>]*>/gi, '\n')
29
+ .replace(/<[^>]+>/g, '')
30
+ .replace(/&nbsp;/g, ' ')
31
+ .replace(/&amp;/g, '&')
32
+ .replace(/&lt;/g, '<')
33
+ .replace(/&gt;/g, '>')
34
+ .replace(/&quot;/g, '"')
35
+ .replace(/[ \t]+\n/g, '\n')
36
+ .replace(/\n{3,}/g, '\n\n')
37
+ .trim();
38
+ }
24
39
  async function send(flags) {
25
40
  const config = requireConfig();
26
41
  // typeof check (not truthiness): a bare `--to` whose value got swallowed by
@@ -41,15 +56,29 @@ async function send(flags) {
41
56
  error('--template-vars must be valid JSON');
42
57
  }
43
58
  }
59
+ // An html-only send used to arrive with an EMPTY text/plain part: the
60
+ // backend adds the alternative but does not fill it (filed 2026-08-23,
61
+ // still open), so text-only clients and some spam filters saw a blank
62
+ // message. The API accepts `text`, so derive one instead of waiting.
63
+ // The stripper is deliberately crude — the plain part's job is to carry the
64
+ // words, not the layout.
65
+ const html = flags.html;
66
+ let text = flags.body;
67
+ if (html && !text)
68
+ text = plainTextFromHtml(html);
44
69
  const res = await retryFunds(() => sdkEmail.sendEmail(config.api_key, {
45
70
  from: flags.from,
46
71
  to: [flags.to],
47
72
  subject: flags.subject,
48
- text: flags.body,
49
- html: flags.html,
73
+ text,
74
+ html,
50
75
  template_id: flags['template-id'],
51
76
  template_vars: templateVars,
52
77
  }));
78
+ if (flags.json) {
79
+ printJson(res);
80
+ return;
81
+ }
53
82
  success(`Email sent! Message ID: ${res.message_id}`);
54
83
  }
55
84
  async function status(messageId, _flags) {
@@ -34,6 +34,10 @@ async function generate(nameArg, flags) {
34
34
  failedMessage: 'Template generation failed',
35
35
  timeoutMessage: 'Template generation timed out',
36
36
  });
37
+ if (flags.json) {
38
+ printJson(status);
39
+ return;
40
+ }
37
41
  success(`Template generated! ID: ${status.template_id}\nPreview: ${status.result?.preview_url}`);
38
42
  }
39
43
  async function list(flags) {
@@ -100,6 +104,10 @@ async function edit(id, flags) {
100
104
  if (!id || !flags.prompt)
101
105
  error('Missing required arguments.\nUsage: myapi email template edit <id> --prompt <str> [--org <id>]');
102
106
  const res = await sdkEmail.editTemplate(config.api_key, orgId, id, flags.prompt);
107
+ if (flags.json) {
108
+ printJson(res);
109
+ return;
110
+ }
103
111
  success(`Template ${res.template_id} edited\nPreview: ${res.preview_url}`);
104
112
  }
105
113
  async function sendTest(id, flags) {
@@ -108,6 +116,10 @@ async function sendTest(id, flags) {
108
116
  if (!id || !flags.to)
109
117
  error('Missing required arguments.\nUsage: myapi email template send-test <template_id> --to <email> [--org <id>]');
110
118
  const res = await sdkEmail.sendTestEmail(config.api_key, orgId, id, flags.to);
119
+ if (flags.json) {
120
+ printJson(res);
121
+ return;
122
+ }
111
123
  success(`Test sent! Message ID: ${res.message_id}`);
112
124
  }
113
125
  async function del(id, flags) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ // `fn create --json` printed the human text, JSON nowhere.
2
+ //
3
+ // The scoped API key is returned exactly ONCE, at create. A script running
4
+ // under --json got the prose rendering instead of a parseable object, so the
5
+ // one-shot key was lost to anything that was not a human reading a terminal —
6
+ // the same ignored---json class as `storage upload` and `mailbox create`.
7
+ // Found by examples/a-to-z/run.mjs on its first real run, 2026-08-25.
8
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
9
+ const sdk = vi.hoisted(() => ({
10
+ fn: { createFunction: vi.fn() },
11
+ MyApiError: class MyApiError extends Error {
12
+ code = '';
13
+ status = 0;
14
+ },
15
+ }));
16
+ vi.mock('@myapihq/sdk', () => sdk);
17
+ vi.mock('../config.js', () => ({
18
+ requireConfig: () => ({ api_key: 'hq_live_test', default_org: 'o-1' }),
19
+ loadConfig: () => ({ api_key: 'hq_live_test', default_org: 'o-1' }),
20
+ CONFIG_DIR: '/tmp/nowhere',
21
+ }));
22
+ import { create } from './fn.js';
23
+ import { withSink } from '../output.js';
24
+ const RESULT = {
25
+ function: { id: 'fn-1', name: 'demo', trigger_type: 'http', invocation_url: null },
26
+ scoped_api_key: 'hq_live_once',
27
+ scoped_api_key_id: 'key-1',
28
+ };
29
+ describe('fn create honours --json', () => {
30
+ // stdout and stderr are separate contracts: JSON rides stdout, the
31
+ // org-context banner rides stderr. Mixing them here would re-create the
32
+ // parse bug this file exists to prevent.
33
+ const out = [];
34
+ const err = [];
35
+ const SINK = { out: (m) => out.push(m), err: (m) => err.push(m), fail: (m) => { throw new Error(m); } };
36
+ beforeEach(() => { out.length = 0; err.length = 0; sdk.fn.createFunction.mockResolvedValue(RESULT); });
37
+ it('emits the full result as parseable JSON, including the one-shot key', async () => {
38
+ await withSink(SINK, () => create('demo', { json: true, org: 'o-1' }));
39
+ const parsed = JSON.parse(out.join('\n'));
40
+ expect(parsed.scoped_api_key).toBe('hq_live_once');
41
+ expect(parsed.function.id).toBe('fn-1');
42
+ });
43
+ it('without --json, keeps the human rendering', async () => {
44
+ await withSink(SINK, () => create('demo', { org: 'o-1' }));
45
+ const text = [...out, ...err].join('\n');
46
+ expect(text).toContain('Function created: fn-1');
47
+ expect(() => JSON.parse(text)).toThrow();
48
+ });
49
+ });
@@ -94,6 +94,15 @@ export async function create(nameArg, flags) {
94
94
  payload.scopes = scopes;
95
95
  }
96
96
  const result = await sdkFn.createFunction(config.api_key, orgId, payload);
97
+ // --json must carry the whole result — including the scoped key, which is
98
+ // returned exactly once. A script that created a function under --json got
99
+ // the human text instead (found by examples/a-to-z/run.mjs, the same
100
+ // ignored---json class as storage upload and mailbox create), and the
101
+ // one-shot key was lost to anything that wasn't a human reading a terminal.
102
+ if (flags.json) {
103
+ printJson(result);
104
+ return;
105
+ }
97
106
  success(`Function created: ${result.function.id}`);
98
107
  info(`Name: ${result.function.name}`);
99
108
  info(`Trigger: ${result.function.trigger_type}${result.function.cron_schedule ? ` (${result.function.cron_schedule})` : ''}`);
@@ -288,6 +297,10 @@ export async function setEnv(id, name, value, flags) {
288
297
  if (Object.keys(env).length === 0)
289
298
  error('No secrets given. Usage: myapi fn env <id> --set KEY=VALUE');
290
299
  const result = await sdkFn.setFunctionEnvBulk(config.api_key, orgId, id, env);
300
+ if (flags.json) {
301
+ printJson(result);
302
+ return;
303
+ }
291
304
  success(`Set ${result.set} secret${result.set === 1 ? '' : 's'} on function ${id}`);
292
305
  info('Values are encrypted at rest and never stored or echoed by MyAPI.');
293
306
  return;
@@ -137,6 +137,10 @@ export async function create(flags) {
137
137
  if (name)
138
138
  validateFunnelName(name);
139
139
  const result = await sdkFunnel.createFunnel(config.api_key, orgId, name ? { name } : undefined);
140
+ if (flags.json) {
141
+ printJson(result);
142
+ return;
143
+ }
140
144
  success(`Funnel created! ID: ${result.funnel.id}`);
141
145
  if (result.funnel.name)
142
146
  info(`Name: ${result.funnel.name}`);
@@ -104,6 +104,10 @@ export async function create(name, flags) {
104
104
  const repoName = name || flags.name;
105
105
  requireArg(repoName, 'name', 'myapi git create <name> [--default-branch <b>]');
106
106
  const res = await sdkGit.createRepo(config.api_key, orgId, repoName, flags['default-branch']);
107
+ if (flags.json) {
108
+ printJson(res);
109
+ return;
110
+ }
107
111
  success(`Repository created: ${res.name}`);
108
112
  info(`Default branch: ${res.default_branch}`);
109
113
  }
@@ -334,6 +338,10 @@ export async function merge(repo, flags) {
334
338
  if (!target || !source)
335
339
  error('Both --target <branch> and --source <branch> are required.\nUsage: myapi git merge <repo> --target <b> --source <b>\n(Merge is fast-forward only.)');
336
340
  const res = await sdkGit.merge(config.api_key, orgId, repo, target, source);
341
+ if (flags.json) {
342
+ printJson(res);
343
+ return;
344
+ }
337
345
  success(`Merged ${source} into ${target} → ${shortSha(res.sha)}`);
338
346
  }
339
347
  export async function repack(repo, flags) {
@@ -60,6 +60,10 @@ export async function connect(flags) {
60
60
  if (!key.startsWith('sk_'))
61
61
  error('--stripe-key must be a Stripe secret key (starts with "sk_").');
62
62
  const res = await sdkPayments.connect(config.api_key, orgId, key);
63
+ if (flags.json) {
64
+ printJson(res);
65
+ return;
66
+ }
63
67
  success('Stripe connected.');
64
68
  info(`Tier: ${res.tier}`);
65
69
  info(`Stripe account: ${res.stripe_account_id}`);
@@ -174,6 +178,10 @@ export async function refund(id, flags) {
174
178
  if (!id)
175
179
  error('Missing charge id.\nUsage: myapi payments refund <charge_id>');
176
180
  const res = await sdkPayments.refundCharge(config.api_key, orgId, id);
181
+ if (flags.json) {
182
+ printJson(res);
183
+ return;
184
+ }
177
185
  success(`Charge ${res.id} refunded (status: ${res.status}).`);
178
186
  }
179
187
  // ── Dispatcher ───────────────────────────────────────────────────────────────
@@ -85,6 +85,10 @@ async function ingest(url, flags) {
85
85
  const orgId = requireOrg(flags, config, 'myapi storage ingest <url> [--name <name>] [--org <id>]');
86
86
  requireArg(url, 'url', 'myapi storage ingest <url> [--name <name>] [--org <id>]');
87
87
  const res = await sdkStorage.ingestAsset(config.api_key, orgId, url, flags.name);
88
+ if (flags.json) {
89
+ printJson(res);
90
+ return;
91
+ }
88
92
  success(`Asset ingested! ID: ${res.asset_id}\nHosted URL: ${res.url}`);
89
93
  }
90
94
  async function upload(filePath, flags) {
@@ -216,6 +216,10 @@ export async function create(nameArg, flags) {
216
216
  trigger_config: { endpoint_id: endpointId },
217
217
  steps,
218
218
  });
219
+ if (flags.json) {
220
+ printJson(wf);
221
+ return;
222
+ }
219
223
  const shouldEnable = flags['no-enable'] !== true;
220
224
  if (shouldEnable) {
221
225
  await sdkWorkflow.enableWorkflow(config.api_key, orgId, wf.id);
@@ -253,6 +257,10 @@ export async function update(id, flags) {
253
257
  error('Nothing to update. Provide at least one of --name, --endpoint-id, --steps.');
254
258
  }
255
259
  const wf = await sdkWorkflow.updateWorkflow(config.api_key, orgId, id, payload);
260
+ if (flags.json) {
261
+ printJson(wf);
262
+ return;
263
+ }
256
264
  success(`Workflow ${wf.id} updated`);
257
265
  }
258
266
  export async function enable(id, flags) {
package/dist/errors.js CHANGED
@@ -3,6 +3,9 @@ import { currentOrg } from './helpers.js';
3
3
  // index.ts) so it can be unit-tested without importing the CLI entrypoint,
4
4
  // which runs main() on import.
5
5
  export const ERROR_MESSAGES = {
6
+ // 402, but not about money: the mailbox exists and the wallet is fine —
7
+ // outbound sending was never switched on for this address.
8
+ SENDING_NOT_ACTIVATED: 'This address cannot send yet — sending was never activated (this is not a billing problem). Run: myapi email mailbox activate-sending --address <email>',
6
9
  DOMAIN_NOT_FOUND: 'Domain not found.',
7
10
  INVALID_DOMAIN: 'Invalid domain name.',
8
11
  ORG_NOT_FOUND: 'Organization not found.',
@@ -165,3 +165,16 @@ describe('5xx advice distinguishes a read from a write', () => {
165
165
  expect(friendlyError(err())).toContain('went through');
166
166
  });
167
167
  });
168
+ // SENDING_NOT_ACTIVATED arrives as HTTP 402, and the 402 handler's fallback
169
+ // assumed any unrecognized 402 was an empty wallet — so a user with $92 of
170
+ // credits was told "Insufficient balance. Top up". Money pointed at a problem
171
+ // money cannot fix. Found live by the deep probe's email journey, 2026-08-25.
172
+ describe('402 that is not about money', () => {
173
+ it('SENDING_NOT_ACTIVATED names the activation command, not a top-up', () => {
174
+ const e = new MyApiError('SENDING_NOT_ACTIVATED', 402, 'Activate sending for this address via POST /email/sending/activate');
175
+ const msg = friendlyError(e);
176
+ expect(msg).toContain('activate-sending');
177
+ expect(msg).toContain('not a billing problem');
178
+ expect(msg).not.toContain('topup');
179
+ });
180
+ });
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { error, info, success, banner, setResolvedContextSource } from './output.js';
3
3
  import { loadConfig } from './config.js';
4
- import { MyApiError, setUserAgent, hq } from '@myapihq/sdk';
4
+ import { MyApiError, setUserAgent, hq, isInsufficientFunds } from '@myapihq/sdk';
5
5
  import { friendlyError } from './errors.js';
6
6
  import { currentOrg, adoptSoleOrg } from './helpers.js';
7
7
  import * as fs from 'fs';
@@ -412,8 +412,16 @@ async function main() {
412
412
  // Anonymous accounts can't top up (no payment surface) — link to unlock.
413
413
  else if (cfg?.is_anonymous)
414
414
  error('Insufficient balance. Anonymous accounts have no free credit — link an email to unlock $5: myapi account link <email>');
415
- else
415
+ else if (!err.code || isInsufficientFunds(err)) {
416
416
  error('Insufficient balance. Top up: myapi billing topup <amount> — or keep it funded automatically: myapi billing auto-recharge set');
417
+ }
418
+ // A 402 whose code is NOT about money. SENDING_NOT_ACTIVATED arrives
419
+ // as 402 and landed in the branch above, so a user with $92 of
420
+ // credits was told to top up — money pointed at a problem money
421
+ // cannot fix, the same shape as "Invalid API key → run setup" on the
422
+ // wrong-host bug. Show what the platform actually said.
423
+ else
424
+ error(friendlyError(err));
417
425
  }
418
426
  }
419
427
  else
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@myapihq/cli",
3
3
  "license": "Apache-2.0",
4
- "version": "2.31.5",
4
+ "version": "2.31.7",
5
5
  "description": "MyAPI command-line interface",
6
6
  "repository": {
7
7
  "type": "git",
@@ -37,6 +37,7 @@
37
37
  "lint:query-params": "node scripts/lint-query-params.js",
38
38
  "lint:flag-lexicon": "node scripts/lint-flag-lexicon.js",
39
39
  "lint:verb-symmetry": "node scripts/lint-verb-symmetry.js",
40
+ "lint:json-parity": "node scripts/lint-json-parity.js",
40
41
  "lint:request-fields": "node scripts/lint-request-fields.js",
41
42
  "audit:doctor": "npm run build && node scripts/audit-doctor.js",
42
43
  "lint:docs": "node scripts/lint-docs.js",
@@ -49,7 +50,7 @@
49
50
  "lint:skills:strict": "node scripts/copy-skills.js && node scripts/lint-skills.js --strict"
50
51
  },
51
52
  "dependencies": {
52
- "@myapihq/sdk": "^2.31.5"
53
+ "@myapihq/sdk": "^2.31.7"
53
54
  },
54
55
  "devDependencies": {
55
56
  "@types/node": "^25.6.0",