@myapihq/cli 2.23.0 → 2.23.2

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,7 +3,7 @@ import type { Flags } from '../helpers.js';
3
3
  import type { Exposes } from '../exposes.js';
4
4
  export declare const EXPOSES: Exposes;
5
5
  export declare const SCHEMA: FlagSchema;
6
- export declare const HELP = "Usage: myapi account <subcommand>\n\nSubcommands:\n api-keys Manage API keys \u00B7 list / create / revoke\n config Manage CLI defaults (org, funnel, domain) \u00B7 supports set-org / set-funnel / set-domain\n import-key Import an existing API key non-interactively\n install-skills DEPRECATED \u2014 use `myapi install-skills` instead\n keys Alias for api-keys\n link [email] Upgrade anonymous account to registered (or add a second session)\n Use myapi account setup to create a completely new account\n login Sign in via your browser \u2014 Google or email code (preview: --mock)\n mailing-address Get or set the account's mailing address (CAN-SPAM)\n registrant Manage stored WHOIS contact info for domain registration \u00B7 set / get / clear\n resume-sending Turn email sending back on after an automatic pause\n sending Whether this account can send email, and how close it is to the limits\n setup Configure your account\n switch [index] Switch active account by index or email\n whoami Show current account \u00B7 supports --json";
6
+ export declare const HELP = "Usage: myapi account <subcommand>\n\nSubcommands:\n api-keys Manage API keys \u00B7 list / create / revoke\n config Manage CLI defaults (org, funnel, domain) \u00B7 supports set-org / set-funnel / set-domain\n import-key Import an existing API key non-interactively\n install-skills DEPRECATED \u2014 use `myapi install-skills` instead\n keys Alias for api-keys\n link [email] Upgrade anonymous account to registered (or add a second session)\n Use myapi account setup to create a completely new account\n login Sign in via your browser \u2014 Google or email code (preview: --mock)\n mailing-address Get or set the account's mailing address (CAN-SPAM)\n registrant Manage stored WHOIS contact info for domain registration \u00B7 set / get / clear\n resume-sending Turn email sending back on after an automatic pause (account or org)\n sending Whether this account can send email, and how close it is to the limits\n setup Configure your account\n switch [index] Switch active account by index or email\n whoami Show current account \u00B7 supports --json";
7
7
  export declare const INSTALL_SKILLS_HELP = "Usage: myapi install-skills\n\nInstalls the MyAPI skills pack for AI coding agents (Claude, Codex, Gemini, \u2026).\n\nThis command writes skill definition files to:\n ~/.agents/skills/myapi/\n\nAnd creates symlinks in the appropriate agent config directories:\n ~/.claude/ (Claude)\n ~/.gemini/ (Gemini)\n ~/.cursor/ (Cursor, if detected)\n\nThese files teach agents how to use the MyAPI CLI and API directly.\nRun this command again to update existing skills to the latest version.\n\nNote: `myapi account install-skills` is deprecated and will be removed in the next minor.\nUse `myapi install-skills` going forward.";
8
8
  export declare const SUBCOMMAND_USAGE: Record<string, string>;
9
9
  export declare function link(flags?: Flags, emailArg?: string): Promise<void>;
@@ -56,7 +56,7 @@ Subcommands:
56
56
  login Sign in via your browser — Google or email code (preview: --mock)
57
57
  mailing-address Get or set the account's mailing address (CAN-SPAM)
58
58
  registrant Manage stored WHOIS contact info for domain registration · set / get / clear
59
- resume-sending Turn email sending back on after an automatic pause
59
+ resume-sending Turn email sending back on after an automatic pause (account or org)
60
60
  sending Whether this account can send email, and how close it is to the limits
61
61
  setup Configure your account
62
62
  switch [index] Switch active account by index or email
@@ -132,7 +132,9 @@ export const SUBCOMMAND_USAGE = {
132
132
  Under 100 sends in 24h the ratios are not judged, and the reply says so.`,
133
133
  'resume-sending': `myapi account resume-sending [--json]
134
134
 
135
- Turn sending back on after an automatic pause.
135
+ Turn sending back on after an automatic pause — whether the pause is on the
136
+ account or on one of its orgs. It sits under account because one call lifts
137
+ both; an org-scoped twin would be a second name for the same endpoint.
136
138
 
137
139
  Refused with STILL_OVER_THRESHOLD while the last 24 hours are still over
138
140
  the limit — the numbers come back with the refusal, so you can see how far
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { SOURCE_FLAGS } from './campaign.js';
5
+ import { SCHEMA } from './index.js';
6
+ // `campaign update` decides whether the recipient source was touched by looking
7
+ // at a list of flags. sourceFromFlags reads its own. When those two lists
8
+ // disagree, the losing flag is parsed, printed in --help, documented in the
9
+ // skill — and silently discarded: `campaign update --crm-audience <id>`
10
+ // answered "Nothing to change" and made no call at all.
11
+ //
12
+ // They now share SOURCE_FLAGS. These tests keep that true by deriving the
13
+ // answer from the code rather than restating it: a hand-copied list in a test
14
+ // drifts exactly the way the thing it is testing drifted.
15
+ const src = readFileSync(fileURLToPath(new URL('./campaign.ts', import.meta.url)), 'utf-8');
16
+ /** Flag names read inside sourceFromFlags — the flags that actually build a source. */
17
+ function flagsReadWhenBuildingASource() {
18
+ const start = src.indexOf('function sourceFromFlags');
19
+ expect(start, 'sourceFromFlags not found — this test is reading the wrong file').toBeGreaterThan(-1);
20
+ const end = src.indexOf('\nfunction ', start + 10);
21
+ const body = src.slice(start, end === -1 ? undefined : end);
22
+ const names = new Set();
23
+ for (const m of body.matchAll(/flags\['([^']+)'\]/g))
24
+ names.add(m[1]);
25
+ for (const m of body.matchAll(/flags\.([a-zA-Z][\w]*)/g))
26
+ names.add(m[1]);
27
+ return [...names];
28
+ }
29
+ describe('campaign source flags', () => {
30
+ it('every flag sourceFromFlags reads is one update() watches', () => {
31
+ const read = flagsReadWhenBuildingASource();
32
+ // The scan must find something; an empty list would make this pass while
33
+ // measuring nothing.
34
+ expect(read.length).toBeGreaterThanOrEqual(7);
35
+ const watched = new Set(SOURCE_FLAGS);
36
+ const unwatched = read.filter(f => !watched.has(f));
37
+ expect(unwatched, 'these build a source but update() would ignore them').toEqual([]);
38
+ });
39
+ it('every watched flag is declared on the email command', () => {
40
+ // A flag update() watches but the parser does not know is always
41
+ // undefined, so it silently never triggers.
42
+ const undeclared = SOURCE_FLAGS.filter(f => !(f in SCHEMA));
43
+ expect(undeclared, 'watched but not in the email SCHEMA').toEqual([]);
44
+ });
45
+ it('every watched flag actually builds part of a source', () => {
46
+ // The other direction: an entry nobody reads makes `update` call the API
47
+ // with a source that did not change.
48
+ const read = new Set(flagsReadWhenBuildingASource());
49
+ const unread = SOURCE_FLAGS.filter(f => !read.has(f));
50
+ expect(unread, 'watched by update() but never read when building a source').toEqual([]);
51
+ });
52
+ });
@@ -2,4 +2,5 @@ import { type Flags } from '../../helpers.js';
2
2
  import type { Exposes } from '../../exposes.js';
3
3
  export declare const EXPOSES: Exposes;
4
4
  export declare const HELP = "Usage: myapi email campaign <subcommand>\n\n create Create a draft (--template, --from, and a source)\n list Campaigns in this org\n get <id> One campaign\n update <id> Edit a DRAFT's name or source\n resolve <id> Freeze who it reaches; reports the count and cost, sends nothing\n start <id> Begin sending a resolved campaign\n pause <id> Stop after the message in flight\n resume <id> Continue a paused campaign\n cancel <id> End it for good; queued recipients are dropped\n recipients <id> Who it resolved to, and what happened to each\n stats <id> Counts by state, sent today, and the daily limit\n\nA campaign drains at its per-day limit rather than sending at once, so it is\ncommonly still active tomorrow \u2014 that is the design, not a stall.\n\nRecipients come from a SOURCE, not a list you upload:\n --crm-stage / --crm-origin / --crm-max-days filter CRM contacts\n --crm-audience <id> only people promoted from it\n --addresses a@x.com,b@y.com an explicit short list";
5
+ export declare const SOURCE_FLAGS: readonly ["addresses", "crm-stage", "crm-origin", "crm-company", "crm-audience", "crm-max-days", "crm-min-days", "all-contacts"];
5
6
  export declare function run(sub: string | undefined, args: string[], flags: Flags): Promise<void>;
@@ -37,6 +37,21 @@ Recipients come from a SOURCE, not a list you upload:
37
37
  --crm-stage / --crm-origin / --crm-max-days filter CRM contacts
38
38
  --crm-audience <id> only people promoted from it
39
39
  --addresses a@x.com,b@y.com an explicit short list`;
40
+ // Every flag that names part of a recipient source. Declared once because two
41
+ // places need it and they must agree: sourceFromFlags READS them, and `update`
42
+ // decides from them whether the source was touched at all. When those two
43
+ // drifted, `campaign update --crm-audience <id>` answered "Nothing to change"
44
+ // and made no call — the flag was parsed, documented, and then ignored.
45
+ export const SOURCE_FLAGS = [
46
+ 'addresses',
47
+ 'crm-stage',
48
+ 'crm-origin',
49
+ 'crm-company',
50
+ 'crm-audience',
51
+ 'crm-max-days',
52
+ 'crm-min-days',
53
+ 'all-contacts',
54
+ ];
40
55
  // Building the source from flags is the one place the CLI has an opinion: a
41
56
  // campaign names where its people come from, and mixing two sources in one
42
57
  // command is a request nobody can mean.
@@ -136,9 +151,7 @@ async function update(id, flags) {
136
151
  const patch = {};
137
152
  if (typeof flags.name === 'string' && flags.name)
138
153
  patch.name = flags.name;
139
- const wantsSource = flags.addresses || flags['crm-stage'] || flags['crm-origin']
140
- || flags['crm-company'] || flags['crm-audience'] || flags['crm-max-days']
141
- || flags['crm-min-days'] || flags['all-contacts'];
154
+ const wantsSource = SOURCE_FLAGS.some(f => flags[f] !== undefined);
142
155
  if (wantsSource)
143
156
  Object.assign(patch, sourceFromFlags(flags));
144
157
  if (Object.keys(patch).length === 0)
@@ -30,8 +30,20 @@ async function create(addressArg, flags) {
30
30
  error('Missing required arguments.\nUsage: myapi email mailbox create <user@domain> [--display-name <name>]\n or: myapi email mailbox create --username <u> --domain <d> [--display-name <name>]\n(Or set default: myapi config set-domain <domain>)');
31
31
  }
32
32
  const res = await sdkEmail.createMailbox(config.api_key, domain, username, flags['display-name']);
33
- // Backend response sometimes omits address; we know it from the inputs.
34
- success(`Mailbox created: ${res.address || `${username}@${domain}`}`);
33
+ // The address comes back as `mailbox`. It is not "sometimes omitted" — the
34
+ // previous comment here encoded a wrong theory, which turned a permanent
35
+ // contract mismatch into an imagined intermittent one and kept it patched
36
+ // instead of reported. Kept the fallback anyway: the address is derivable
37
+ // from the inputs, and a create that succeeded should still print what it
38
+ // made if the field ever moves again.
39
+ success(`Mailbox created: ${res.mailbox || `${username}@${domain}`}`);
40
+ // A warning on a 2xx means something is configured but incomplete — here, a
41
+ // missing CAN-SPAM mailing address, which refuses every send LATER rather
42
+ // than now. Printing it while the customer is set up in front of the
43
+ // terminal is the whole point of the backend adding it; dropping it silently
44
+ // meant the fix reached nobody using the CLI.
45
+ if (res.warning)
46
+ info(`› ${res.warning}`);
35
47
  }
36
48
  async function list(flags) {
37
49
  const config = requireConfig();
@@ -316,7 +316,14 @@ export async function runs(id, flags) {
316
316
  error: r.error_message || '',
317
317
  })), {
318
318
  flags,
319
- empty: 'No runs recorded yet for this function.',
319
+ // An empty list here means "we do not measure this", not "it did not
320
+ // happen": function_runs has one writer, the workflow engine. A direct call
321
+ // to the invocation URL is served at the edge and never reaches the backend
322
+ // to be recorded. ImmoPilot read the old sentence as proof the API was
323
+ // never called and ruled out the branch of a login investigation that was
324
+ // the actual problem — the function had run hundreds of times that day.
325
+ empty: 'No workflow-triggered runs. Direct calls to the invocation URL are served at the edge '
326
+ + 'and are not recorded here, so an empty list does NOT mean the function was not called.',
320
327
  });
321
328
  }
322
329
  // ── Dispatcher ───────────────────────────────────────────────────────────────
package/dist/errors.js CHANGED
@@ -100,6 +100,31 @@ export function friendlyError(err) {
100
100
  if (err.code === 'REGISTRAR_RATE_LIMITED') {
101
101
  return 'Domain registration is busy right now — please wait a few minutes before trying again (avoid automatic retries).';
102
102
  }
103
+ // A paused account is told how to un-pause itself — and the backend says
104
+ // `POST /hq/account/resume-sending`, which is right for an API caller and
105
+ // wrong for this one. Rewrite the route, keep everything else.
106
+ //
107
+ // Rewriting rather than replacing the sentence: the figures in it are the
108
+ // ones that caused the pause and are per-account. A fixed string here would
109
+ // drop them, and they are the reason the message is worth reading.
110
+ //
111
+ // The backend added this instruction on 2026-08-22 (myapi-hq #159) after a
112
+ // customer waited six days on "contact support to review" while the resume
113
+ // endpoint already existed.
114
+ if (err.code === 'ACCOUNT_PAUSED' || err.code === 'ORG_PAUSED') {
115
+ const raw = (err.detail || ERROR_MESSAGES[err.code] || err.code).trim();
116
+ let out = raw.replace(/POST\s+\/hq\/account\/resume-sending/g, 'myapi account resume-sending');
117
+ // An older backend sends no instruction at all; add one rather than leaving
118
+ // the reader where that customer was.
119
+ if (!out.includes('resume-sending')) {
120
+ out += ' — fix the addresses that bounce, then: myapi account resume-sending';
121
+ }
122
+ // The figures in the message are frozen at the moment of the pause and do
123
+ // not move; `account sending` is the live picture, and reading the frozen
124
+ // ones as current is the whole confusion.
125
+ out += '\n Current numbers: myapi account sending';
126
+ return withOrgContext(out, err);
127
+ }
103
128
  const base = ERROR_MESSAGES[err.code] || err.code;
104
129
  // Generic fallback: append the backend's `message` when it adds info beyond
105
130
  // the friendly mapping. Future per-service detail fields can be added here.
@@ -26,3 +26,45 @@ describe('friendlyError', () => {
26
26
  expect(friendlyError(new MyApiError('SOME_NEW_CODE', 400))).toBe('SOME_NEW_CODE');
27
27
  });
28
28
  });
29
+ describe('paused sending points at the CLI, not an HTTP route', () => {
30
+ // Verbatim from the backend on 2026-08-22 (myapi-hq #159). The instruction is
31
+ // correct for an API caller and wrong for this one.
32
+ const ACCOUNT_TEXT = 'sending paused: bounce rate 7.83% > 4% (9 bounces / 115 sends) — the figures are from '
33
+ + 'when the pause was set and do not change. Fix the addresses that bounce, then POST '
34
+ + '/hq/account/resume-sending';
35
+ const ORG_TEXT = 'sending paused for this org: bounce rate 6.10% > 4% — fix the addresses that bounce, '
36
+ + 'then POST /hq/account/resume-sending';
37
+ it('ACCOUNT_PAUSED names the command instead of the endpoint', () => {
38
+ const msg = friendlyError(new MyApiError('ACCOUNT_PAUSED', 403, ACCOUNT_TEXT));
39
+ expect(msg).toContain('myapi account resume-sending');
40
+ // The whole point: a CLI user must not be told to make an HTTP POST for
41
+ // something this CLI already does.
42
+ expect(msg).not.toContain('POST /hq/account/resume-sending');
43
+ });
44
+ it('keeps the figures and the fact that they are frozen', () => {
45
+ const msg = friendlyError(new MyApiError('ACCOUNT_PAUSED', 403, ACCOUNT_TEXT));
46
+ // These are why the message is worth reading, and a fixed replacement
47
+ // string would have dropped them.
48
+ expect(msg).toContain('7.83%');
49
+ expect(msg).toContain('9 bounces / 115 sends');
50
+ expect(msg).toMatch(/do not change/);
51
+ });
52
+ it('points at the live numbers, which the frozen ones are not', () => {
53
+ const msg = friendlyError(new MyApiError('ACCOUNT_PAUSED', 403, ACCOUNT_TEXT));
54
+ expect(msg).toContain('myapi account sending');
55
+ });
56
+ it('ORG_PAUSED gets the same treatment and keeps its scope', () => {
57
+ const msg = friendlyError(new MyApiError('ORG_PAUSED', 403, ORG_TEXT));
58
+ expect(msg).toContain('myapi account resume-sending');
59
+ expect(msg).not.toContain('POST /hq/account/resume-sending');
60
+ // The reader must still be able to tell WHICH pause they hit — one command
61
+ // lifts both, but the scope changes what they go and fix.
62
+ expect(msg).toContain('for this org');
63
+ });
64
+ it('adds the instruction when an older backend sends none', () => {
65
+ // Before #159 the message ended at "contact support to review", which is
66
+ // where a customer sat for six days.
67
+ const msg = friendlyError(new MyApiError('ACCOUNT_PAUSED', 403, 'sending paused: bounce rate too high — contact support to review'));
68
+ expect(msg).toContain('myapi account resume-sending');
69
+ });
70
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@myapihq/cli",
3
3
  "license": "Apache-2.0",
4
- "version": "2.23.0",
4
+ "version": "2.23.2",
5
5
  "description": "MyAPI command-line interface",
6
6
  "repository": {
7
7
  "type": "git",
@@ -46,7 +46,7 @@
46
46
  "lint:skills:strict": "node scripts/copy-skills.js && node scripts/lint-skills.js --strict"
47
47
  },
48
48
  "dependencies": {
49
- "@myapihq/sdk": "^2.23.0"
49
+ "@myapihq/sdk": "^2.23.2"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^25.6.0",