@myapihq/cli 2.23.2 → 2.24.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,111 @@
1
+ // Every field PATCH accepts must be one a CLI verb can actually send.
2
+ //
3
+ // A container setting that can be given at creation and not changed afterwards
4
+ // is not a missing convenience — it is a delete-and-recreate, and recreating a
5
+ // container loses its URL, its scoped key and its custom domain binding.
6
+ //
7
+ // That has now happened twice. `env` was create-only, and a customer recreated
8
+ // containers three times in a week to edit variables. Then `health_check` was
9
+ // create-only, and a customer paid for a production swap to adopt a probe.
10
+ // Both times the backend widened PATCH and the CLI did not follow, so the fix
11
+ // reached nobody: the route accepted a field that no client ever sent, and
12
+ // nothing on either side was red.
13
+ //
14
+ // So this is driven from the schema snapshot rather than a hand-kept list, and
15
+ // it asserts on what arrives at the SDK boundary rather than on the shape of
16
+ // our source — testing the parser proves the flag parses, not that it is sent.
17
+ // When the backend adds a field to this PATCH body, this fails until a verb
18
+ // can send it.
19
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
20
+ import { readFileSync } from 'node:fs';
21
+ const ORG = '11111111-1111-4111-8111-111111111111';
22
+ const sdk = vi.hoisted(() => ({
23
+ container: {
24
+ updateContainer: vi.fn(),
25
+ updateContainerEnv: vi.fn(),
26
+ },
27
+ withFundsRetry: vi.fn(async (f) => f()),
28
+ MyApiError: class MyApiError extends Error {
29
+ code = '';
30
+ status = 0;
31
+ },
32
+ }));
33
+ vi.mock('@myapihq/sdk', () => sdk);
34
+ vi.mock('../config.js', () => ({
35
+ requireConfig: () => ({ api_key: 'hq_live_test', default_org: ORG }),
36
+ loadConfig: () => ({ api_key: 'hq_live_test', default_org: ORG }),
37
+ CONFIG_DIR: '/tmp/nowhere',
38
+ }));
39
+ const SNAPSHOT = JSON.parse(readFileSync(new URL('../../schema-snapshot.json', import.meta.url), 'utf8'));
40
+ const PATCH_ROUTE = '/container/orgs/{org_id}/containers/{id}';
41
+ function patchBodyFields() {
42
+ const op = SNAPSHOT.paths?.[PATCH_ROUTE]?.patch;
43
+ const props = op?.requestBody?.content?.['application/json']?.schema?.properties;
44
+ return props ? Object.keys(props) : [];
45
+ }
46
+ const APPLIED = { set: [], removed: [], applied: true, env: {}, revision: 'rev-2' };
47
+ beforeEach(async () => {
48
+ vi.clearAllMocks();
49
+ sdk.container.updateContainer.mockResolvedValue(APPLIED);
50
+ sdk.container.updateContainerEnv.mockResolvedValue(APPLIED);
51
+ const output = await import('../output.js');
52
+ vi.spyOn(output, 'error').mockImplementation(((m) => {
53
+ throw new Error('__EXIT__');
54
+ }));
55
+ vi.spyOn(output, 'info').mockImplementation(() => { });
56
+ vi.spyOn(output, 'success').mockImplementation(() => { });
57
+ vi.spyOn(output, 'printJson').mockImplementation(() => { });
58
+ });
59
+ afterEach(() => vi.restoreAllMocks());
60
+ async function run(fn) {
61
+ try {
62
+ await fn();
63
+ }
64
+ catch (e) {
65
+ if (e?.message !== '__EXIT__')
66
+ throw e;
67
+ }
68
+ }
69
+ // Drives each verb that patches a container and collects the body keys that
70
+ // actually reach the SDK.
71
+ async function fieldsTheCliCanSend() {
72
+ const container = await import('./container.js');
73
+ const sent = new Set();
74
+ await run(() => container.run('env', ['c1'], { env: 'A=1', org: ORG }));
75
+ await run(() => container.run('health-check', ['c1', '/livez'], { org: ORG }));
76
+ for (const call of sdk.container.updateContainer.mock.calls) {
77
+ Object.keys(call[3] ?? {}).forEach(k => sent.add(k));
78
+ }
79
+ // The env shorthand takes the map itself rather than a patch object.
80
+ if (sdk.container.updateContainerEnv.mock.calls.length)
81
+ sent.add('env');
82
+ return sent;
83
+ }
84
+ describe('container PATCH coverage', () => {
85
+ it('the snapshot still describes this route (or the test measures nothing)', () => {
86
+ // Without this, a renamed route upstream would leave an empty field list
87
+ // and a permanently, silently green test.
88
+ expect(patchBodyFields().length).toBeGreaterThan(0);
89
+ });
90
+ it('every field PATCH accepts is one a CLI verb actually sends', async () => {
91
+ const sent = await fieldsTheCliCanSend();
92
+ const unreachable = patchBodyFields().filter(f => !sent.has(f));
93
+ expect(unreachable, `PATCH ${PATCH_ROUTE} accepts ${unreachable.join(', ')}, but no CLI ` +
94
+ 'verb sends it. Until one does, changing that setting means deleting the ' +
95
+ 'container and creating another — which loses the URL, the scoped key and ' +
96
+ 'the custom domain.').toEqual([]);
97
+ });
98
+ it('clearing the probe sends the empty string the API defines for it', async () => {
99
+ // '' is how the API says "no probe". The verb spells it --clear so nobody
100
+ // has to discover that an empty string is the meaningful value, but the
101
+ // wire form still has to be right.
102
+ const container = await import('./container.js');
103
+ await run(() => container.run('health-check', ['c1'], { clear: true, org: ORG }));
104
+ expect(sdk.container.updateContainer).toHaveBeenCalledWith('hq_live_test', ORG, 'c1', { health_check: '' });
105
+ });
106
+ it('refuses /healthz before it costs a round trip', async () => {
107
+ const container = await import('./container.js');
108
+ await run(() => container.run('health-check', ['c1', '/healthz'], { org: ORG }));
109
+ expect(sdk.container.updateContainer).not.toHaveBeenCalled();
110
+ });
111
+ });
@@ -12,7 +12,9 @@ export declare function splitEnvEntries(raw: string): string[];
12
12
  export declare function create(nameArg: string | undefined, flags: Flags): Promise<void>;
13
13
  export declare function list(flags: Flags): Promise<void>;
14
14
  export declare function get(id: string, flags: Flags): Promise<void>;
15
+ export declare function healthCheck(id: string, flags: Flags): Promise<void>;
15
16
  export declare function del(id: string, flags: Flags): Promise<void>;
17
+ export declare function _checkProbePath(hc: string): string;
16
18
  export declare function _isTarball(p: string): boolean;
17
19
  export declare function deploy(id: string, image: string, flags: Flags): Promise<void>;
18
20
  export declare function _parseSmoke(raw: string): sdkContainer.SmokeCheck;
@@ -34,6 +34,7 @@ export const SCHEMA = {
34
34
  scope: 'string',
35
35
  'no-promote': 'boolean',
36
36
  'health-check': 'string',
37
+ clear: 'boolean',
37
38
  smoke: 'string',
38
39
  remove: 'boolean',
39
40
  source: 'string',
@@ -147,15 +148,7 @@ export async function create(nameArg, flags) {
147
148
  if (typeof flags.port === 'number')
148
149
  payload.port = flags.port;
149
150
  if (typeof flags['health-check'] === 'string') {
150
- const hc = flags['health-check'];
151
- // The API refuses /healthz, but say so here rather than round-tripping:
152
- // the reason is specific and worth stating where the user typed it.
153
- if (/^\/?healthz\/?$/i.test(hc)) {
154
- error('The runtime intercepts /healthz, so a probe against it never reaches your container —\nit would report healthy no matter what your code does. Use /livez, or any other path.');
155
- }
156
- if (!hc.startsWith('/'))
157
- error(`--health-check must be a path starting with "/" — got "${hc}".`);
158
- payload.health_check = hc;
151
+ payload.health_check = _checkProbePath(flags['health-check']);
159
152
  }
160
153
  // Repeatable, and create must agree with `container env` on what --env means.
161
154
  // If they disagree, a variable settable at creation cannot be changed
@@ -218,6 +211,7 @@ export async function get(id, flags) {
218
211
  if (c.port)
219
212
  info(`Port: ${c.port}`);
220
213
  info(`URL: ${c.url || '(not deployed)'}`);
214
+ info(`Startup probe: ${c.health_check || '(none)'}`);
221
215
  if (c.egress)
222
216
  info(`Egress: ${c.egress}`);
223
217
  if (c.custom_domain)
@@ -225,6 +219,39 @@ export async function get(id, flags) {
225
219
  info(`Created: ${c.created_at}`);
226
220
  info(`Updated: ${c.updated_at}`);
227
221
  }
222
+ export async function healthCheck(id, flags) {
223
+ const config = requireConfig();
224
+ const orgId = requireOrg(flags, config, 'myapi container health-check <id> <path> [--clear]');
225
+ if (!id)
226
+ error('Missing id.\nUsage: myapi container health-check <id> /livez (or --clear to remove it)');
227
+ const pathArg = typeof flags._probePath === 'string' ? flags._probePath : undefined;
228
+ if (flags.clear && pathArg) {
229
+ error('Pass a path or --clear, not both.');
230
+ }
231
+ if (!flags.clear && !pathArg) {
232
+ error('Nothing to change.\nUsage: myapi container health-check <id> /livez (or --clear to remove the probe)');
233
+ }
234
+ // '' is how the API says "no probe" — the verb spells that --clear so nobody
235
+ // has to discover that an empty string is meaningful.
236
+ const value = flags.clear ? '' : _checkProbePath(pathArg);
237
+ info(value ? `› Setting startup probe to ${value}…` : '› Clearing the startup probe…');
238
+ const res = await sdkContainer.updateContainer(config.api_key, orgId, id, { health_check: value });
239
+ if (flags.json) {
240
+ printJson(res);
241
+ return;
242
+ }
243
+ // Same distinction `container env` draws: stored and live are different
244
+ // states, and only one of them is what the caller asked for.
245
+ if (res.applied) {
246
+ success(`Redeployed on the same image — the probe is live${res.revision ? ` (${res.revision})` : ''}.`);
247
+ }
248
+ else {
249
+ info(' Stored. This container has no deployment yet, so it applies on your first deploy.');
250
+ }
251
+ info(value
252
+ ? ` The runtime now waits for ${value} to answer before sending traffic to a new revision.`
253
+ : ' No startup probe: a new revision takes traffic as soon as the runtime reports it started.');
254
+ }
228
255
  export async function del(id, flags) {
229
256
  const config = requireConfig();
230
257
  const orgId = requireOrg(flags, config, 'myapi container delete <id> [--yes] [--org <id>]');
@@ -234,6 +261,22 @@ export async function del(id, flags) {
234
261
  await sdkContainer.deleteContainer(config.api_key, orgId, id);
235
262
  success(`Deleted container ${id} (org ${orgId})`);
236
263
  }
264
+ // The probe path is validated in exactly one place because `create` and
265
+ // `health-check` must agree on what a valid one is. When a setting is accepted
266
+ // at creation and rejected (or unreachable) afterwards, changing it becomes a
267
+ // delete-and-recreate — which costs the URL, the scoped key and the custom
268
+ // domain. That is the bug this verb exists to close; it should not reappear as
269
+ // a validation mismatch.
270
+ export function _checkProbePath(hc) {
271
+ // The API refuses /healthz, but say so here rather than round-tripping:
272
+ // the reason is specific and worth stating where the user typed it.
273
+ if (/^\/?healthz\/?$/i.test(hc)) {
274
+ error('The runtime intercepts /healthz, so a probe against it never reaches your container —\nit would report healthy no matter what your code does. Use /livez, or any other path.');
275
+ }
276
+ if (!hc.startsWith('/'))
277
+ error(`A health check must be a path starting with "/" — got "${hc}".`);
278
+ return hc;
279
+ }
237
280
  // _isTarball returns true if the path looks like an already-built tar archive
238
281
  // (so we upload it as-is instead of tarring a directory).
239
282
  export function _isTarball(p) {
@@ -711,6 +754,18 @@ domain's MyAPI-managed parent domain must already be registered.
711
754
 
712
755
  Example:
713
756
  myapi container domain <id> app.synthesisdaily.com`,
757
+ 'health-check': `myapi container health-check <id> <path> [--clear] [--org <id>]
758
+
759
+ Set or clear the startup probe on an existing container, rolling a new
760
+ revision on the SAME image. No rebuild.
761
+
762
+ The runtime waits for this path to answer before sending traffic to a new
763
+ revision, so a bad deploy is caught before it takes any. Not /healthz — the
764
+ runtime answers that before it reaches your container, so the probe would
765
+ pass while your app is down.
766
+
767
+ myapi container health-check <id> /livez
768
+ myapi container health-check <id> --clear`,
714
769
  'delete': 'myapi container delete <id> [--org <id>]',
715
770
  };
716
771
  export async function run(subcommand, args, flags) {
@@ -814,6 +869,7 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
814
869
  case 'revisions': return revisions(args[0], flags);
815
870
  case 'promote': return promote(args[0], args[1], flags);
816
871
  case 'domain': return domain(args[0], args[1], flags);
872
+ case 'health-check': return healthCheck(args[0], { ...flags, _probePath: args[1] });
817
873
  case 'delete': return del(args[0], flags);
818
874
  case 'rollback': return rollback(args[0], flags);
819
875
  default: error(`Unknown subcommand: ${subcommand}. Run "myapi container --help" for a list of valid subcommands.`);
@@ -50,3 +50,13 @@ describe('campaign source flags', () => {
50
50
  expect(unread, 'watched by update() but never read when building a source').toEqual([]);
51
51
  });
52
52
  });
53
+ describe('campaign delete', () => {
54
+ // The backend shipped DELETE and the CLI did not, so the only way to remove a
55
+ // campaign was a raw HTTP call. A verb that exists on one side of the wire
56
+ // and not the other is invisible until someone needs it.
57
+ it('is dispatched and declared', async () => {
58
+ const mod = await import('./campaign.js');
59
+ expect(mod.HELP).toContain('delete <id>');
60
+ expect(mod.EXPOSES).toContain('DELETE /email/orgs/{org_id}/campaigns/{campaign_id}');
61
+ });
62
+ });
@@ -1,6 +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 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";
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 delete <id> Remove it and its recipient rows; what was sent stays in the log\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
5
  export declare const SOURCE_FLAGS: readonly ["addresses", "crm-stage", "crm-origin", "crm-company", "crm-audience", "crm-max-days", "crm-min-days", "all-contacts"];
6
6
  export declare function run(sub: string | undefined, args: string[], flags: Flags): Promise<void>;
@@ -7,6 +7,7 @@ export const EXPOSES = [
7
7
  'POST /email/orgs/{org_id}/campaigns',
8
8
  'GET /email/orgs/{org_id}/campaigns',
9
9
  'GET /email/orgs/{org_id}/campaigns/{campaign_id}',
10
+ 'DELETE /email/orgs/{org_id}/campaigns/{campaign_id}',
10
11
  'PATCH /email/orgs/{org_id}/campaigns/{campaign_id}',
11
12
  'POST /email/orgs/{org_id}/campaigns/{campaign_id}/resolve',
12
13
  'POST /email/orgs/{org_id}/campaigns/{campaign_id}/start',
@@ -29,6 +30,7 @@ export const HELP = `Usage: myapi email campaign <subcommand>
29
30
  cancel <id> End it for good; queued recipients are dropped
30
31
  recipients <id> Who it resolved to, and what happened to each
31
32
  stats <id> Counts by state, sent today, and the daily limit
33
+ delete <id> Remove it and its recipient rows; what was sent stays in the log
32
34
 
33
35
  A campaign drains at its per-day limit rather than sending at once, so it is
34
36
  commonly still active tomorrow — that is the design, not a stall.
@@ -203,6 +205,19 @@ async function transition(verb, id, flags) {
203
205
  info(`It sends up to its daily limit and continues tomorrow — check with: myapi email campaign stats ${id}`);
204
206
  }
205
207
  }
208
+ async function remove(id, flags) {
209
+ const config = requireConfig();
210
+ const orgId = requireOrg(flags, config, 'myapi email campaign delete <id>');
211
+ const r = await sdkEmail.deleteCampaign(config.api_key, orgId, id);
212
+ if (flags.json) {
213
+ printJson(r);
214
+ return;
215
+ }
216
+ success(`Deleted campaign ${r.id}`);
217
+ // People reach for delete to undo a send. It does not undo one, and saying
218
+ // so here is cheaper than letting them find out from a reply.
219
+ info('Messages already sent stay in the send log — this removed the campaign, not the mail.');
220
+ }
206
221
  async function recipients(id, flags) {
207
222
  const config = requireConfig();
208
223
  const orgId = requireOrg(flags, config, 'myapi email campaign recipients <id> [--state queued|sent|failed|excluded]');
@@ -252,6 +267,7 @@ export async function run(sub, args, flags) {
252
267
  case 'cancel': return transition('cancel', need('myapi email campaign cancel <id>'), flags);
253
268
  case 'recipients': return recipients(need('myapi email campaign recipients <id>'), flags);
254
269
  case 'stats': return stats(need('myapi email campaign stats <id>'), flags);
270
+ case 'delete': return remove(need('myapi email campaign delete <id>'), flags);
255
271
  default: error(`Unknown subcommand: email campaign ${sub}. Run "myapi email campaign --help".`);
256
272
  }
257
273
  }
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: my-container-api
3
- version: 1.1.1
3
+ version: 1.1.2
4
4
  description: >
5
5
  Run containers on demand — long-running services, background workers, and scheduled jobs. The heavier-duty sibling of edge functions, for native deps and long execution.
6
6
  triggers: [container, cloud run, dynamic app, custom domain app, service, worker, scheduled job, deploy container, docker image]
7
- checksum: sha256-80d64f6086e3d16549e530464b8188d9b9db2db5b54696e85c9ddde60d5b40a0
7
+ checksum: sha256-0e1702e51e8d501edf48bdc4da92728d52e79c4273f534ba5ed41511b4803271
8
8
  ---
9
9
 
10
10
  # MyContainerAPI
@@ -19,9 +19,8 @@ The lifecycle is **create → deploy → (optionally) bind a custom domain**.
19
19
  - `deploy` takes **either** a pre-built image reference **or** a source
20
20
  directory. `--source ./dir` tars the directory, builds it server-side
21
21
  (typically ~4 minutes) and deploys the result — **no Docker on your machine,
22
- no registry account, no image to push**. If you can write a Dockerfile you
23
- can deploy; you do not need to be able to run one.
24
- - `domain` puts the container on a **custom domain** — how you serve a dynamic app at `app.yourbrand.com`.
22
+ no registry account, no image to push**.
23
+ - `domain` puts the container on a **custom domain** a dynamic app at `app.yourbrand.com`.
25
24
 
26
25
  ### Deploying safely
27
26
 
@@ -36,8 +35,8 @@ myapi container deploy <id> --source ./app --smoke 'GET / contains assets/'
36
35
  ```
37
36
 
38
37
  **Assert on content, not status.** A build whose frontend never bundled still
39
- binds its port and answers `200` — that is how a placeholder page reached
40
- production and served a dead page for fifteen minutes.
38
+ binds its port and answers `200` — that is how a placeholder page served a dead
39
+ page for fifteen minutes. Same reasoning as the startup probe below.
41
40
 
42
41
  **`--no-promote`** holds the revision back and prints a URL to test yourself,
43
42
  then `myapi container promote <id> <revision>`. Use it when the check is more
@@ -47,9 +46,10 @@ traffic is not withheld and the output says so.
47
46
  **`myapi container rollback <id>`** returns traffic to the previous ready
48
47
  revision in seconds, no rebuild.
49
48
 
50
- `--health-check /livez` at create makes the startup probe an HTTP request
51
- rather than a bare TCP connect. `/healthz` is refused the runtime intercepts
52
- it, so the probe would never reach your container.
49
+ `--health-check /livez` makes the startup probe an HTTP request, not a bare TCP
50
+ connect at create, or later with `myapi container health-check <id> /livez`
51
+ (`--clear` removes it), which rolls a new revision on the same image.
52
+ `/healthz` is refused: the runtime answers it before your container does.
53
53
 
54
54
  ### Custom domains (dynamic apps)
55
55
 
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: my-email-api
3
- version: 1.2.0
3
+ version: 1.2.1
4
4
  description: >
5
5
  Send transactional and bulk email from your own domain. Create mailboxes, send/receive messages, generate AI templates, and manage warmup.
6
6
  triggers: [email, mailbox, send email, transactional email, template, warmup, inbox, outbox, ses, sender reputation]
7
- checksum: sha256-897cf3dbc843ca06d02aecf298151e8635890852a5db8ab4035ee59df8db0aa9
7
+ checksum: sha256-b458699a69c15d02d33dab6bc00445f05fb6e55ee844d35d8b745a13a6533b9e
8
8
  ---
9
9
 
10
10
  # MyEmailAPI
@@ -28,7 +28,7 @@ A registered domain via **mydomainapi** is the prerequisite — mailboxes need a
28
28
  | `email message` | `send`, `status`, `sent`, `inbox`, `outbox`, `get` | Transactional send + read |
29
29
  | `email warmup` | `start`, `stats`, `pause`, `resume`, `stop` | IP/domain warmup for sending reputation |
30
30
  | `email template` | `generate`, `list`, `get`, `preview`, `edit`, `send-test`, `delete` | AI-generated HTML templates |
31
- | `email campaign` | `create`, `list`, `get`, `update`, `resolve`, `start`, `pause`, `resume`, `cancel`, `recipients`, `stats` | Scheduled bulk send that drains at a daily limit |
31
+ | `email campaign` | `create`, `list`, `get`, `update`, `resolve`, `start`, `pause`, `resume`, `cancel`, `recipients`, `stats`, `delete` | Scheduled bulk send that drains at a daily limit |
32
32
  <!-- generated:end -->
33
33
 
34
34
  ## Examples
@@ -120,7 +120,9 @@ Four things that decide how you use it:
120
120
 
121
121
  `pause` stops after the message in flight; `resume` continues; `cancel` ends it
122
122
  and drops what was queued. Status `paused_insufficient_funds` means the account
123
- could not pay — top up or enable auto-recharge, then `resume`.
123
+ could not pay — top up or enable auto-recharge, then `resume`. `delete` removes
124
+ the campaign and its recipients, never the send log — it tidies up, it does not
125
+ unsend, and it refuses while a campaign is active.
124
126
 
125
127
  ## Notes
126
128
 
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.2",
4
+ "version": "2.24.0",
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.2"
49
+ "@myapihq/sdk": "^2.24.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^25.6.0",